总目录

Python编程：从入门到实践

Python机器学习经典实例

Python测试驱动开发：使用Django、Selenium和JavaScript进行Web编程（第2版）

Python计算机视觉编程

Python科学计算基础教程

Python数据处理

Python数据挖掘入门与实践

Python网络编程攻略

Python项目开发实战（第2版）

Python语言及其应用

精選藏書 by 39.2℃

版权信息

书名：Python编程：从入门到实践

作者：[美] Eric Matthes

译者：袁国忠

ISBN：978-7-115-42802-8

本书由北京图灵文化发展有限公司发行数字版。版权所有，侵权必究。

您购买的图灵电子书仅供您个人使用，未经授权，不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟，与我们共同保护知识产权。

如果购买者有侵权行为，我们可能对该用户实施包括但不限于关闭该帐号等维权措施，并可能追究法律责任。

091507240605ToBeReplacedWithUserId

版权声明

前言

读者对象

本书内容

为何使用Python

致谢

第一部分　基础知识

第 1 章　起步

1.1　搭建编程环境

1.1.1　Python 2和Python 3

1.1.2　运行Python代码片段

1.1.3　Hello World程序

1.2　在不同操作系统中搭建Python编程环境

1.2.1　在Linux系统中搭建Python编程环境

1.2.2　在OS X系统中搭建Python编程环境

1.2.3　在Windows系统中搭建Python编程环境

1.3　解决安装问题

1.4　从终端运行Python程序

1.4.1　在Linux和OS X系统中从终端运行Python程序

1.4.2　在Windows系统中从终端运行Python程序

1.5　小结

第 2 章　变量和简单数据类型

2.1　运行hello_world.py时发生的情况

2.2　变量

2.2.1　变量的命名和使用

2.2.2　使用变量时避免命名错误

2.3　字符串

2.3.1　使用方法修改字符串的大小写

2.3.2　合并（拼接）字符串

2.3.3　使用制表符或换行符来添加空白

2.3.4　删除空白

2.3.5　使用字符串时避免语法错误

2.3.6　Python 2中的print语句

2.4　数字

2.4.1　整数

2.4.2　浮点数

2.4.3　使用函数str()避免类型错误

2.4.4　Python 2中的整数

2.5　注释

2.5.1　如何编写注释

2.5.2　该编写什么样的注释

2.6　Python之禅

2.7　小结

第 3 章　列表简介

3.1　列表是什么

3.1.1　访问列表元素

3.1.2　索引从0而不是1开始

3.1.3　使用列表中的各个值

3.2　修改、添加和删除元素

3.2.1　修改列表元素

3.2.2　在列表中添加元素

3.2.3　从列表中删除元素

3.3　组织列表

3.3.1　使用方法sort()对列表进行永久性排序

3.3.2　使用函数sorted()对列表进行临时排序

3.3.3　倒着打印列表

3.3.4　确定列表的长度

3.4　使用列表时避免索引错误

3.5　小结

第 4 章　操作列表

4.1　遍历整个列表

4.1.1　深入地研究循环

4.1.2　在for循环中执行更多的操作

4.1.3　在for循环结束后执行一些操作

4.2　避免缩进错误

4.2.1　忘记缩进

4.2.2　忘记缩进额外的代码行

4.2.3　不必要的缩进

4.2.4　循环后不必要的缩进

4.2.5　遗漏了冒号

4.3　创建数值列表

4.3.1　使用函数range()

4.3.2　使用range()创建数字列表

4.3.3　对数字列表执行简单的统计计算

4.3.4　列表解析

4.4　使用列表的一部分

4.4.1　切片

4.4.2　遍历切片

4.4.3　复制列表

4.5　元组

4.5.1　定义元组

4.5.2　遍历元组中的所有值

4.5.3　修改元组变量

4.6　设置代码格式

4.6.1　格式设置指南

4.6.2　缩进

4.6.3　行长

4.6.4　空行

4.6.5　其他格式设置指南

4.7　小结

第 5 章　if语句

5.1　一个简单示例

5.2　条件测试

5.2.1　检查是否相等

5.2.2　检查是否相等时不考虑大小写

5.2.3　检查是否不相等

5.2.4　比较数字

5.2.5　检查多个条件

5.2.6　检查特定值是否包含在列表中

5.2.7　检查特定值是否不包含在列表中

5.2.8　布尔表达式

5.3　if语句

5.3.1　简单的if语句

5.3.2　if-else语句

5.3.3　if-elif-else结构

5.3.4　使用多个elif代码块

5.3.5　省略else代码块

5.3.6　测试多个条件

5.4　使用if语句处理列表

5.4.1　检查特殊元素

5.4.2　确定列表不是空的

5.4.3　使用多个列表

5.5　设置if语句的格式

5.6　小结

第 6 章　字典

6.1　一个简单的字典

6.2　使用字典

6.2.1　访问字典中的值

6.2.2　添加键—值对

6.2.3　先创建一个空字典

6.2.4　修改字典中的值

6.2.5　删除键—值对

6.2.6　由类似对象组成的字典

6.3　遍历字典

6.3.1　遍历所有的键—值对

6.3.2　遍历字典中的所有键

6.3.3　按顺序遍历字典中的所有键

6.3.4　遍历字典中的所有值

6.4　嵌套

6.4.1　字典列表

6.4.2　在字典中存储列表

6.4.3　在字典中存储字典

6.5　小结

第 7 章　用户输入和while循环

7.1　函数input()的工作原理

7.1.1　编写清晰的程序

7.1.2　使用int()来获取数值输入

7.1.3　求模运算符

7.1.4　在Python 2.7中获取输入

7.2　while循环简介

7.2.1　使用while循环

7.2.2　让用户选择何时退出

7.2.3　使用标志

7.2.4　使用break退出循环

7.2.5　在循环中使用continue

7.2.6　避免无限循环

7.3　使用while循环来处理列表和字典

7.3.1　在列表之间移动元素

7.3.2　删除包含特定值的所有列表元素

7.3.3　使用用户输入来填充字典

7.4　小结

第 8 章　函数

8.1　定义函数

8.1.1　向函数传递信息

8.1.2　实参和形参

8.2　传递实参

8.2.1　位置实参

8.2.2　关键字实参

8.2.3　默认值

8.2.4　等效的函数调用

8.2.5　避免实参错误

8.3　返回值

8.3.1　返回简单值

8.3.2　让实参变成可选的

8.3.3　返回字典

8.3.4　结合使用函数和while循环

8.4　传递列表

8.4.1　在函数中修改列表

8.4.2　禁止函数修改列表

8.5　传递任意数量的实参

8.5.1　结合使用位置实参和任意数量实参

8.5.2　使用任意数量的关键字实参

8.6　将函数存储在模块中

8.6.1　导入整个模块

8.6.2　导入特定的函数

8.6.3　使用as给函数指定别名

8.6.4　使用as给模块指定别名

8.6.5　导入模块中的所有函数

8.7　函数编写指南

8.8　小结

第 9 章　类

9.1　创建和使用类

9.1.1　创建Dog类

9.1.2　根据类创建实例

9.2　使用类和实例

9.2.1　Car类

9.2.2　给属性指定默认值

9.2.3　修改属性的值

9.3　继承

9.3.1　子类的方法__init__()

9.3.2　Python 2.7中的继承

9.3.3　给子类定义属性和方法

9.3.4　重写父类的方法

9.3.5　将实例用作属性

9.3.6　模拟实物

9.4　导入类

9.4.1　导入单个类

9.4.2　在一个模块中存储多个类

9.4.3　从一个模块中导入多个类

9.4.4　导入整个模块

9.4.5　导入模块中的所有类

9.4.6　在一个模块中导入另一个模块

9.4.7　自定义工作流程

9.5　Python标准库

9.6　类编码风格

9.7　小结

第 10 章　文件和异常

10.1　从文件中读取数据

10.1.1　读取整个文件

10.1.2　文件路径

10.1.3　逐行读取

10.1.4　创建一个包含文件各行内容的列表

10.1.5　使用文件的内容

10.1.6　包含一百万位的大型文件

10.1.7　圆周率值中包含你的生日吗

10.2　写入文件

10.2.1　写入空文件

10.2.2　写入多行

10.2.3　附加到文件

10.3　异常

10.3.1　处理ZeroDivisionError异常

10.3.2　使用try-except代码块

10.3.3　使用异常避免崩溃

10.3.4　else代码块

10.3.5　处理FileNotFoundError异常

10.3.6　分析文本

10.3.7　使用多个文件

10.3.8　失败时一声不吭

10.3.9　决定报告哪些错误

10.4　存储数据

10.4.1　使用json.dump()和json.load()

10.4.2　保存和读取用户生成的数据

10.4.3　重构

10.5　小结

第 11 章　测试代码

11.1　测试函数

11.1.1　单元测试和测试用例

11.1.2　可通过的测试

11.1.3　不能通过的测试

11.1.4　测试未通过时怎么办

11.1.5　添加新测试

11.2　测试类

11.2.1　各种断言方法

11.2.2　一个要测试的类

11.2.3　测试AnonymousSurvey类

11.2.4　方法setUp()

11.3　小结

第二部分　项目

项目1　外星人入侵

第 12 章　武装飞船

12.1　规划项目

12.2　安装Pygame

12.2.1　使用pip安装Python包

12.2.2　在Linux系统中安装Pygame

12.2.3　在OS X系统中安装Pygame

12.2.4　在Windows系统中安装Pygame

12.3　开始游戏项目

12.3.1　创建Pygame窗口以及响应用户输入

12.3.2　设置背景色

12.3.3　创建设置类

12.4　添加飞船图像

12.4.1　创建Ship类

12.4.2　在屏幕上绘制飞船

12.5　重构：模块game_functions

12.5.1　函数check_events()

12.5.2　函数update_screen()

12.6　驾驶飞船

12.6.1　响应按键

12.6.2　允许不断移动

12.6.3　左右移动

12.6.4　调整飞船的速度

12.6.5　限制飞船的活动范围

12.6.6　重构check_events()

12.7　简单回顾

12.7.1　alien_invasion.py

12.7.2　settings.py

12.7.3　game_functions.py

12.7.4　ship.py

12.8　射击

12.8.1　添加子弹设置

12.8.2　创建Bullet类

12.8.3　将子弹存储到编组中

12.8.4　开火

12.8.5　删除已消失的子弹

12.8.6　限制子弹数量

12.8.7　创建函数update_bullets()

12.8.8　创建函数fire_bullet()

12.9　小结

第 13 章　外星人

13.1　回顾项目

13.2　创建第一个外星人

13.2.1　创建Alien类

13.2.2　创建Alien实例

13.2.3　让外星人出现在屏幕上

13.3　创建一群外星人

13.3.1　确定一行可容纳多少个外星人

13.3.2　创建多行外星人

13.3.3　创建外星人群

13.3.4　重构create_fleet()

13.3.5　添加行

13.4　让外星人群移动

13.4.1　向右移动外星人

13.4.2　创建表示外星人移动方向的设置

13.4.3　检查外星人是否撞到了屏幕边缘

13.4.4　向下移动外星人群并改变移动方向

13.5　射杀外星人

13.5.1　检测子弹与外星人的碰撞

13.5.2　为测试创建大子弹

13.5.3　生成新的外星人群

13.5.4　提高子弹的速度

13.5.5　重构update_bullets()

13.6　结束游戏

13.6.1　检测外星人和飞船碰撞

13.6.2　响应外星人和飞船碰撞

13.6.3　有外星人到达屏幕底端

13.6.4　游戏结束

13.7　确定应运行游戏的哪些部分

13.8　小结

记分

14.1　添加Play按钮

14.1.1　创建Button类

14.1.2　在屏幕上绘制按钮

14.1.3　开始游戏

14.1.4　重置游戏

14.1.5　将Play按钮切换到非活动状态

14.1.6　隐藏光标

14.2　提高等级

14.2.1　修改速度设置

14.2.2　重置速度

14.3　记分

14.3.1　显示得分

14.3.2　创建记分牌

14.3.3　在外星人被消灭时更新得分

14.3.4　将消灭的每个外星人的点数都计入得分

14.3.5　提高点数

14.3.6　将得分圆整

14.3.7　最高得分

14.3.8　显示等级

14.3.9　显示余下的飞船数

14.4　小结

项目2　数据可视化

第 15 章　生成数据

15.1　安装matplotlib

15.1.1　在Linux系统中安装matplotlib

15.1.2　在OS X系统中安装matplotlib

15.1.3　在Windows系统中安装matplotlib

15.1.4　测试matplotlib

15.1.5　matplotlib画廊

15.2　绘制简单的折线图

15.2.1　修改标签文字和线条粗细

15.2.2　校正图形

15.2.3　使用scatter()绘制散点图并设置其样式

15.2.4　使用scatter()绘制一系列点

15.2.5　自动计算数据

15.2.6　删除数据点的轮廓

15.2.7　自定义颜色

15.2.8　使用颜色映射

15.2.9　自动保存图表

15.3　随机漫步

15.3.1　创建RandomWalk()类

15.3.2　选择方向

15.3.3　绘制随机漫步图

15.3.4　模拟多次随机漫步

15.3.5　设置随机漫步图的样式

15.3.6　给点着色

15.3.7　重新绘制起点和终点

15.3.8　隐藏坐标轴

15.3.9　增加点数

15.3.10　调整尺寸以适合屏幕

15.4　使用Pygal模拟掷骰子

15.4.1　安装Pygal

15.4.2　Pygal画廊

15.4.3　创建Die类

15.4.4　掷骰子

15.4.5　分析结果

15.4.6　绘制直方图

15.4.7　同时掷两个骰子

15.4.8　同时掷两个面数不同的骰子

15.5　小结

第 16 章　下载数据

16.1　CSV文件格式

16.1.1　分析CSV文件头

16.1.2　打印文件头及其位置

16.1.3　提取并读取数据

16.1.4　绘制气温图表

16.1.5　模块datetime

16.1.6　在图表中添加日期

16.1.7　涵盖更长的时间

16.1.8　再绘制一个数据系列

16.1.9　给图表区域着色

16.1.10　错误检查

16.2　制作交易收盘价走势图：JSON 格式

16.2.1　下载收盘价数据

16.2.2　提取相关的数据

16.2.3　将字符串转换为数字值

16.2.4　绘制收盘价折线图

16.2.5　时间序列特征初探

16.2.6　收盘价均值

16.2.7　收盘价数据仪表盘

16.3　小结

第 17 章　使用API

17.1　使用Web API

17.1.1　Git和GitHub

17.1.2　使用API调用请求数据

17.1.3　安装requests

17.1.4　处理API响应

17.1.5　处理响应字典

17.1.6　概述最受欢迎的仓库

17.1.7　监视API的速率限制

17.2　使用Pygal可视化仓库

17.2.1　改进Pygal图表

17.2.2　添加自定义工具提示

17.2.3　根据数据绘图

17.2.4　在图表中添加可单击的链接

17.3　Hacker News API

17.4　小结

项目3　Web应用程序

第 18 章　Django入门

18.1　建立项目

18.1.1　制定规范

18.1.2　建立虚拟环境

18.1.3　安装virtualenv

18.1.4　激活虚拟环境

18.1.6　在Django中创建项目

18.1.7　创建数据库

18.1.8　查看项目

18.2　创建应用程序

18.2.1　定义模型

18.2.2　激活模型

18.2.3　Django管理网站

18.2.4　定义模型Entry

18.2.5　迁移模型Entry

18.2.6　向管理网站注册Entry

18.2.7　Django shell

18.3　创建网页：学习笔记主页

18.3.1　映射URL

18.3.2　编写视图

18.3.3　编写模板

18.4　创建其他网页

18.4.1　模板继承

18.4.2　显示所有主题的页面

18.4.3　显示特定主题的页面

18.5　小结

第 19 章　用户账户

19.1　让用户能够输入数据

19.1.1　添加新主题

19.1.2　添加新条目

19.1.3　编辑条目

19.2　创建用户账户

19.2.1　应用程序users

19.2.2　登录页面

19.2.3　注销

19.2.4　注册页面

19.3　让用户拥有自己的数据

19.3.1　使用@login_required限制访问

19.3.2　将数据关联到用户

19.3.3　只允许用户访问自己的主题

19.3.4　保护用户的主题

19.3.5　保护页面edit_entry

19.3.6　将新主题关联到当前用户

19.4　小结

第 20 章　设置应用程序的样式并对其进行部署

20.1　设置项目“学习笔记”的样式

20.1.1　应用程序django-bootstrap3

20.1.2　使用Bootstrap来设置项目“学习笔记”的样式

20.1.3　修改base.html

20.1.4　使用jumbotron设置主页的样式

20.1.5　设置登录页面的样式

20.1.6　设置new_topic页面的样式

20.1.7　设置topics页面的样式

20.1.8　设置topic页面中条目的样式

20.2　部署“学习笔记”

20.2.1　建立Heroku账户

20.2.2　安装Heroku Toolbelt

20.2.3　安装必要的包

20.2.4　创建包含包列表的文件requirements.txt

20.2.5　指定Python版本

20.2.6　为部署到Heroku而修改settings.py

20.2.7　创建启动进程的Procfile

20.2.8　为部署到Heroku而修改wsgi.py

20.2.9　创建用于存储静态文件的目录

20.2.10　在本地使用gunicorn服务器

20.2.11　使用Git跟踪项目文件

20.2.12　推送到Heroku

20.2.13　在Heroku上建立数据库

20.2.14　改进Heroku部署

20.2.15　确保项目的安全

20.2.16　提交并推送修改

20.2.17　创建自定义错误页面

20.2.18　继续开发

20.2.19　设置SECRET_KEY

20.2.20　将项目从Heroku删除

20.3　小结

附录 A　安装Python

A.1　在Linux系统中安装Python

A.1.1　确定已安装的版本

A.1.2　在Linux系统中安装Python 3

A.2　在OS X系统中安装Python

A.2.1　确定已安装的版本

A.2.2　使用Homebrew来安装Python 3

A.3　在Windows系统中安装Python

A.3.1　在Windows系统中安装Python 3

A.3.2　查找Python解释器

A.3.3　将Python添加到环境变量Path中

A.4　Python关键字和内置函数

A.4.1　Python关键字

A.4.2　Python内置函数

附录 B　文本编辑器

B.1　Geany

B.1.1　在Linux系统中安装Geany

B.1.2　在Windows系统中安装Geany

B.1.3　在Geany中运行Python程序

B.1.4　定制Geany的设置

B.2　Sublime Text

B.2.1　在OS X系统中安装Sublime Text

B.2.2　在Linux系统中安装Sublime Text

B.2.3　在Windows系统中安装Sublime Text

B.2.4　在Sublime Text中运行Python程序

B.2.5　配置Sublime Text

B.2.6　定制Sublime Text的设置

B.3　IDLE

B.3.1　在Linux系统中安装IDLE

B.3.2　在OS X系统中安装IDLE

B.3.3　在Windows系统中安装IDLE

B.3.4　定制IDLE的设置

B.4　Emacs和vim

附录 C　寻求帮助

C.1　第一步

C.1.1　再试试

C.1.2　歇一会儿

C.1.3　参考本书的在线资源

C.2　在线搜索

C.2.1　Stack Overflow

C.2.2　Python官方文档

C.2.3　官方库文档

C.2.4　r/learnpython

C.2.5　博客

C.3　IRC

C.3.1　创建IRC账户

C.3.2　加入频道

C.3.3　IRC文化

附录 D　使用Git进行版本控制

D.1　安装Git

D.1.1　在Linux系统中安装Git

D.1.2　在OS X系统中安装Git

D.1.3　在Windows系统中安装Git

D.1.4　配置Git

D.2　创建项目

D.3　忽略文件

D.4　初始化仓库

D.5　检查状态

D.6　将文件加入到仓库中

D.7　执行提交

D.8　查看提交历史

D.9　第二次提交

D.10　撤销修改

D.11　检出以前的提交

D.12　删除仓库

后记

版权声明

Copyright © 2016 by Eric Matthes. Python Crash Course：A Hands-On, Project-Based Introduction to Programming, ISBN 978-1-59327-603-4, published by No Starch Press. Simplified Chinese-language edition copyright © 2016 by Posts and Telecom Press. All rights reserved.

No part of this book may be reproduced or transmitted in any form or by any means, electronic or mechanical, including photocopying, recording, or by any information storage or retrieval system, without the prior written permission of the copyright owner and the publisher.

本书中文简体字版由No Starch Press授权人民邮电出版社独家出版。未经出版者书面许可，不得以任何方式复制或抄袭本书内容。

版权所有，侵权必究。

谨以此书献给我的父亲，以及儿子Ever。感谢父亲抽出时间来回答我提出的每个编程问题，而儿子Ever也开始向我提问了。

前言

如何学习编写第一个程序，每个程序员都有不同的故事。我还是个孩子时就开始学习编程了，当时我父亲在计算时代的先锋之一——数字设备公司（Digital Equipment Corporation）工作。我使用一台简陋的计算机编写了第一个程序，这台计算机是父亲在家里的地下室组装而成的，它没有机箱，裸露的主板与键盘相连，显示器是裸露的阴极射线管。我编写的这个程序是一款简单的猜数字游戏，其输出类似于下面这样：

I'm thinking of a number! Try to guess the number I'm thinking of: 25
Too low! Guess again: 50
Too high! Guess again: 42
That's it! Would you like to play again? (yes/no) no
Thanks for playing!

看到家人玩着我编写的游戏，而且它完全按我预期的方式运行，我心里不知有多满足。此情此景我永远都忘不了。

儿童时期的这种体验一直影响我至今。现在，每当我通过编写程序解决了一个问题时，心里都会感到非常满足。相比于孩提时期，我现在编写的软件满足了更大的需求，但通过编写程序获得的满足感几乎与从前一样。

读者对象

本书旨在让你尽快学会Python，以便能够编写能正确运行的程序——游戏、数据可视化和Web应用程序，同时掌握让你终身受益的基本编程知识。本书适合任何年龄的读者阅读，它不要求你有任何Python编程经验，甚至不要求你有编程经验。如果你想快速掌握基本的编程知识以便专注于开发感兴趣的项目，并想通过解决有意义的问题来检查你对新学概念的理解程度，那么本书就是为你编写的。本书还可供初中和高中教师用来通过开发项目向学生介绍编程。

本书内容

本书旨在让你成为优秀的程序员，具体地说，是优秀的Python程序员。通过阅读本书，你将迅速掌握编程概念，打下坚实的基础，并养成良好的习惯。阅读本书后，你就可以开始学习Python高级技术，并能够更轻松地掌握其他编程语言。

在本书的第一部分，你将学习编写Python程序时需要熟悉的基本编程概念，你刚接触几乎任何编程语言时都需要学习这些概念。你将学习各种数据以及在程序中将数据存储到列表和字典中的方式。你将学习如何创建数据集合以及如何高效地遍历这些集合。你将学习使用while和if语句来检查条件，并在条件满足时执行代码的一部分，而在条件不满足时执行代码的另一部分——这可为自动完成处理提供极大的帮助。

你将学习获取用户输入，让程序能够与用户交互，并在用户没停止输入时保持运行状态。你将探索如何编写函数来让程序的各个部分可重用，这样你编写执行特定任务的代码后，想使用它多少次都可以。然后，你将学习使用类来扩展这种概念以实现更复杂的行为，从而让非常简单的程序也能处理各种不同的情形。你将学习编写妥善处理常见错误的程序。学习这些基本概念后，你就能编写一些简短的程序来解决一些明确的问题。最后，你将向中级编程迈出第一步，学习如何为代码编写测试，以便在进一步改进程序时不用担心可能引入bug。第一部分介绍的知识让你能够开发更大、更复杂的项目。

在第二部分，你将利用在第一部分学到的知识来开发三个项目。你可以根据自己的情况，以最合适的顺序完成这些项目；你也可以选择只完成其中的某些项目。在第一个项目（第12~14章）中，你将创建一个类似于《太空入侵者》的射击游戏。这个游戏名为《外星人入侵》，它包含多个难度不断增加的等级。完成这个项目后，你就能够自己动手开发2D游戏了。

第二个项目（第15~17章）介绍数据可视化。数据科学家的目标是通过各种可视化技术来搞懂海量信息。你将使用通过代码生成的数据集、已经从网络下载下来的数据集以及程序自动下载的数据集。完成这个项目后，你将能够编写能对大型数据集进行筛选的程序，并以可视化方式将筛选出来的数据呈现出来。

在第三个项目（第18~20章）中，你将创建一个名为“学习笔记”的小型Web应用程序。这个项目能够让用户将学到的与特定主题相关的概念记录下来。你将能够分别记录不同的主题，还可以让其他人建立账户并开始记录自己的学习笔记。你还将学习如何部署这个项目，让任何人都能够通过网络访问它，而不管他身处何方。

为何使用Python

继续使用Python，还是转而使用其他语言——也许是编程领域较新的语言？我每年都会考虑这个问题。可我依然专注于Python，其中的原因很多。Python是一种效率极高的语言：相比于众多其他的语言，使用Python编写时，程序包含的代码行更少。Python的语法也有助于创建整洁的代码：相比其他语言，使用Python编写的代码更容易阅读、调试和扩展。

大家将Python用于众多方面：编写游戏、创建Web应用程序、解决商业问题以及供各类有趣的公司开发内部工具。Python还在科学领域被大量用于学术研究和应用研究。

我依然使用Python的一个最重要的原因是，Python社区有形形色色充满激情的人。对程序员来说，社区非常重要，因为编程绝非孤独的修行。大多数程序员都需要向解决过类似问题的人寻求建议，经验最为丰富的程序员也不例外。需要有人帮助解决问题时，有一个联系紧密、互帮互助的社区至关重要，而对于像你一样将Python作为第一门语言来学习的人而言，Python社区无疑是坚强的后盾。

Python是一门杰出的语言，值得你去学习，咱们现在就开始吧！

致谢

要是没有No Starch Press出色的专业人士的帮助，本书根本不可能出版。Bill Pollock邀请我编写一本入门图书，因此这里要深深感谢他给予我这样的机会。Tyler Ortman在我编写本书的早期帮助我理清思路。Liz Chadwick和Leslie Shen详细阅读了每一章，并提出了宝贵的意见，而Anne Marie Walker让本书的很多地方都更清晰。Riley Hoffman回答了我就图书出版过程提出的每个问题，并且耐心地将我的作品变成了漂亮的图书。

感谢技术审稿人Kenneth Love。我与Kenneth相识于一次PyCon大会，他对Python和Python社区充满热情，一直是我获取专业灵感的源泉。Kenneth不仅检查了本书介绍的知识是否正确，还抱着让初学编程者对Python语言和编程有扎实认识的目的进行了审阅。即便如此，倘若书中有任何不准确的地方，责任都完全由我承担。

感谢我的父亲，感谢他在我很小的时候就向我介绍编程，而且一点都不担心我破坏他的设备。感谢妻子Erin在我编写本书期间对我一如既往的鼓励和支持。还要感谢儿子Ever，他的好奇心每天都会给我带来灵感。

第一部分　基础知识

本书的第一部分介绍编写 Python 程序所需要熟悉的基本概念，其中很多都适用于所有编程语言，因此它们在你的整个程序员生涯中都很有用。

第 1 章介绍在计算机中安装Python，并运行第一个程序——它在屏幕上打印消息“Hello world!”。

第 2 章论述如何在变量中存储信息以及如何使用文本和数字。

第 3 章和第 4 章介绍列表。使用列表能够在一个变量中存储任意数量的信息，从而高效地处理数据：只需几行代码，你就能够处理数百、数千乃至数百万个值。

第 5 章讲解使用 if 语句来编写这样的代码：在特定条件满足时采取一种措施，而在该条件不满足时采取另一种措施。

第 6 章演示如何使用 Python 字典，将不同的信息关联起来。与列表一样，你也可以根据需要在字典中存储任意数量的信息。

第 7 章讲解如何从用户那里获取输入，以让程序变成交互式的。你还将学习 while 循环，它不断地运行代码块，直到指定的条件不再满足为止。

第 8 章介绍编写函数。函数是执行特定任务的被命名的代码块，你可以根据需要随时运行它。

第 9 章介绍类，它让你能够模拟实物，如小狗、小猫、人、汽车、火箭等，让你的代码能够表示任何真实或抽象的东西。

第 10 章介绍如何使用文件，以及如何处理错误以免程序意外地崩溃。你需要在程序关闭前保存数据，并在程序再次运行时读取它们。你将学习 Python 异常，它们让你能够未雨绸缪，从而让程序妥善地处理错误。

第 11 章为代码编写测试，以核实程序是否像你期望的那样工作。这样，扩展程序时，你就不用担心引入新的bug。要想脱离初级程序员的阵容，跻身于中级程序员的行列，测试代码是你必须掌握的基本技能之一。

第 1 章　起步

在本章中，你将运行自己的第一个程序——hello_world.py。为此，你首先需要检查自己的计算机是否安装了Python；如果没有安装，你需要安装它。你还要安装一个文本编辑器，用于编写和运行Python程序。你输入Python代码时，这个文本编辑器能够识别它们并突出显示不同的部分，让你能够轻松地了解代码的结构。

1.1　搭建编程环境

在不同的操作系统中，Python存在细微的差别，因此有几点你需要牢记在心。这里将介绍大家使用的两个主要的Python版本，并简要介绍Python的安装步骤。

1.1.1　Python 2和Python 3

当前，有两个不同的Python版本：Python 2和较新的Python 3。每种编程语言都会随着新概念和新技术的推出而不断发展，Python的开发者也一直致力于丰富和强化其功能。大多数修改都是逐步进行的，你几乎意识不到，但如果你的系统安装的是Python 3，那么有些使用Python 2编写的代码可能无法正确地运行。在本书中，我将指出Python 2和Python 3的重大差别，这样无论你安装的是哪个Python版本，都能够按书中的说明去做。

如果你的系统安装了这两个版本，请使用Python 3；如果没有安装Python，请安装Python 3；如果只安装了Python 2，也可直接使用它来编写代码，但还是尽快升级到Python 3为好，因为这样你就能使用最新的Python版本了。

1.1.2　运行Python代码片段

Python自带了一个在终端窗口中运行的解释器，让你无需保存并运行整个程序就能尝试运行Python代码片段。

本书将以如下方式列出代码片段：

>>> print("Hello Python interpreter!") ❶
Hello Python interpreter!

加粗的文本表示需要你输入之后按回车键来执行的代码。本书的大多数示例都是独立的小程序，你将在编辑器中执行它们，因为大多数代码都是这样编写出来的。然而，为高效地演示某基本概念，需要在Python终端会话中执行一系列代码片段。只要代码清单中包含三个尖括号（如❶所示），就意味着输出来自终端会话。稍后将演示如何在Python解释器中编写代码。

1.1.3　Hello World程序

长期以来，编程界都认为刚接触一门新语言时，如果首先使用它来编写一个在屏幕上显示消息“Hello world!”的程序，将给你带来好运。

要使用Python来编写这种Hello World程序，只需一行代码：

print("Hello world!")

这种程序虽然简单，却有其用途：如果它能够在你的系统上正确地运行，你编写的任何Python程序都将如此。稍后将介绍如何在特定的系统中编写这样的程序。

1.2　在不同操作系统中搭建Python编程环境

Python是一种跨平台的编程语言，这意味着它能够运行在所有主要的操作系统中。在所有安装了Python的现代计算机上，都能够运行你编写的任何Python程序。然而，在不同的操作系统中，安装Python的方法存在细微的差别。

在这一节中，你将学习如何在自己的系统中安装Python和运行Hello World程序。你首先要检查自己的系统是否安装了Python，如果没有，就安装它；接下来，你需要安装一个简单的文本编辑器，并创建一个空的Python文件——hello_world.py。最后，你将运行Hello World程序，并排除各种故障。我将详细介绍如何在各种操作系统中完成这些任务，让你能够搭建一个对初学者友好的Python编程环境。

1.2.1　在Linux系统中搭建Python编程环境

Linux系统是为编程而设计的，因此在大多数Linux计算机中，都默认安装了Python。编写和维护Linux的人认为，你很可能会使用这种系统进行编程，他们也鼓励你这样做。鉴于此，要在这种系统中编程，你几乎不用安装什么软件，也几乎不用修改设置。

1. 检查Python版本

在你的系统中运行应用程序Terminal（如果你使用的是Ubuntu，可按Ctrl + Alt + T），打开一个终端窗口。为确定是否安装了Python，执行命令python（请注意，其中的p是小写的）。输出将类似下面这样，它指出了安装的Python版本；最后的>>>是一个提示符，让你能够输入Python命令。

$ python
Python 2.7.6 (default, Mar 22 2014, 22:59:38)
[GCC 4.8.2] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>>

上述输出表明，当前计算机默认使用的Python版本为Python 2.7.6。看到上述输出后，如果要退出Python并返回到终端窗口，可按Ctrl + D或执行命令exit()。

要检查系统是否安装了Python 3，可能需要指定相应的版本。换句话说，如果输出指出默认版本为Python 2.7，请尝试执行命令python3：

$ python3
Python 3.5.0 (default, Sep 17 2015, 13:05:18)
[GCC 4.8.4] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>>

上述输出表明，系统中也安装了Python 3，因此你可以使用这两个版本中的任何一个。在这种情况下，请将本书中的命令python都替换为python3。大多数Linux系统都默认安装了Python，但如果你的Linux系统不知什么原因没有安装Python或只安装了Python 2，而你要安装Python 3，请参见附录A。

2. 安装文本编辑器

Geany是一款简单的文本编辑器：它易于安装；让你能够直接运行几乎所有的程序（而无需通过终端来运行）；使用不同的颜色来显示代码，以突出代码语法；在终端窗口中运行代码，让你能够习惯使用终端。附录B介绍了其他一些文本编辑器，但我强烈建议你使用Geany，除非你有充分的理由不这样做。

在大多数Linux系统中，都只需执行一个命令就可以安装Geany：

$ sudo apt-get install geany

如果这个命令不管用，请参阅http://geany.org/Download/ThirdPartyPackages/的说明。

3. 运行Hello World程序

为编写第一个程序，需要启动Geany。为此，可按超级（Super）键（俗称Windows键），并在系统中搜索Geany。找到Geany后，双击以启动它；再将其拖曳到任务栏或桌面上，以创建一个快捷方式。接下来，创建一个用于存储项目的文件夹，并将其命名为python_work（在文件名和文件夹名中，最好使用小写字母，并使用下划线来表示空格，因为这是Python采用的命名约定）。回到Geany，选择菜单File ▶ Save As，将当前的空Python文件保存到文件夹python_work，并将其命名为hello_world.py。扩展名.py告诉Geany，文件包含的是Python程序；它还让Geany知道如何运行该程序，并以有益的方式突出其中的代码。

保存文件后，在其中输入下面一行代码：

print("Hello Python world!")

如果你的系统安装了多个Python版本，就必须对Geany进行配置，使其使用正确的版本。为此，可选择菜单Build（生成）▶ Set Build Commands（设置生成命令）；你将看到文字Compile（编译）和Execute（执行），它们旁边都有一个命令。默认情况下，这两个命令都是python，要让Geany使用命令python3，必须做相应的修改。

如果在终端会话中能够执行命令python3，请修改编译命令和执行命令，让Geany使用Python 3解释器。为此，将编译命令修改成下面这样：

python3 -m py_compile "%f"

你必须完全按上面的代码显示的那样输出这个命令，确保空格和大小写都完全相同。

将执行命令修改成下面这样：

python3 "%f"

同样，务必确保空格和大小写都完全与显示的相同。图1-1显示了该如何在Geany中配置这些命令。

图1-1　在Linux中配置Geany，使其使用Python 3

现在来运行程序hello_world.py。为此，可选择菜单Build ▶ Execute、单击Execute图标（两个齿轮）或按F5。将弹出一个终端窗口，其中包含如下输出：

Hello Python world!

------------------
(program exited with code: 0)
Press return to continue

如果没有看到这样的输出，请检查你输入的每个字符。你是不是将print的首字母大写了？是不是遗漏了引号或括号？编程语言对语法的要求非常严格，只要你没有严格遵守语法，就会出错。如果代码都正确，这个程序也不能正确地运行，请参阅1.3节。

4. 在终端会话中运行Python代码

你可以打开一个终端窗口并执行命令python或python3，再尝试运行Python代码片段。检查Python版本时，你就这样做过。下面再次这样做，但在终端会话中输入如下代码行：

>>> print("Hello Python interpreter!")
Hello Python interpreter!
>>>

消息将直接打印到当前终端窗口中。别忘了，要关闭Python解释器，可按Ctrl + D或执行命令exit()。

1.2.2　在OS X系统中搭建Python编程环境

大多数OS X系统都默认安装了Python。确定安装了Python后，你还需安装一个文本编辑器，并确保其配置正确无误。

1. 检查是否安装了Python

在文件夹Applications/Utilities中，选择Terminal，打开一个终端窗口；你也可以按Command + 空格键，再输入terminal并按回车。为确定是否安装了Python，请执行命令python（注意，其中的p是小写的）。输出将类似于下面这样，它指出了安装的Python版本；最后的>>>是一个提示符，让你能够输入Python命令。

$ python
Python 2.7.5 (default, Mar 9 2014, 22:15:05)
[GCC 4.2.1 Compatible Apple LLVM 5.0 (clang-500.0.68)] on darwin
Type "help", "copyright", "credits", or "license" for more information.
>>>

上述输出表明，当前计算机默认使用的Python版本为Python 2.7.5。看到上述输出后，如果要退出Python并返回到终端窗口，可按Ctrl + D或执行命令exit()。

要检查系统是否安装了Python 3，可尝试执行命令python3。可能会出现一条错误消息，但如果输出指出系统安装了Python 3，则无需安装就可使用它。如果在你的系统中能够执行命令python3，则对于本书的所有命令python，都请替换为命令python3。如果不知道出于什么原因你的系统没有安装Python，或者只安装了Python 2，而你又想安装Python 3，请参阅附录A。

2. 在终端会话中运行Python代码

你可以打开一个终端窗口并执行命令python或python3，再尝试运行Python代码片段。检查Python版本时，你就这样做过。下面再次这样做，但在终端会话中输入如下代码行：

>>> print("Hello Python interpreter!")
Hello Python interpreter!
>>>

消息将直接打印到当前终端窗口中。别忘了，要关闭Python解释器，可按Ctrl + D或执行命令exit()。

3. 安装文本编辑器

Sublime Text是一款简单的文本编辑器：它在OS X中易于安装；让你能够直接运行几乎所有程序（而无需通过终端）；使用不同的颜色来显示代码，以突出代码语法；在内嵌在Sublime Text窗口内的终端会话中运行代码，让你能够轻松地查看输出。附录B介绍了其他一些文本编辑器，但我强烈建议你使用Sublime Text，除非你有充分的理由不这样做。

要下载Sublime Text安装程序，可访问http://sublimetext.com/3，单击Download链接，并查找OS X安装程序。Sublime Text的许可策略非常灵活，你可以免费使用这款编辑器，但如果你喜欢它并想长期使用，建议你购买许可证。下载安装程序后，打开它，再将Sublime Text图标拖放到Applications文件夹。

4. 配置Sublime Text使其使用Python 3

如果你启动Python终端会话时使用的命令不是python，就需要配置Sublime Text，让它知道到系统的什么地方去查找正确的Python版本。要获悉Python解释器的完整路径，请执行如下命令：

$ type -a python3
python3 is /usr/local/bin/python3

现在，启动Sublime Text，并选择菜单Tools ▶ Build System ▶ New Build System，这将打开一个新的配置文件。删除其中的所有内容，再输入如下内容：

{
    "cmd": ["/usr/local/bin/python3", "-u", "$file"],
}

这些代码让Sublime Text使用命令python3来运行当前打开的文件。请确保其中的路径为你在前一步使用命令type -a python3获悉的路径。将这个配置文件命名为Python3.sublime-build，并将其保存到默认目录——你选择菜单Save时Sublime Text打开的目录。

5. 运行Hello World程序

为编写第一个程序，需要启动Sublime Text。为此，可打开文件夹Applications，并双击图标Sublime Text；也可按Command + 空格键，再在弹出的搜索框中输入sublime text。

创建一个用于存储项目的文件夹，并将其命名为python_work（在文件名和文件夹名中，最好使用小写字母，并使用下划线来表示空格，因为这是Python采用的命名约定）。在Sublime Text中，选择菜单File ▶ Save As，将当前的空Python文件保存到文件夹python_work，并将其命名为hello_world.py。扩展名.py告诉Sublime Text，文件包含的是Python程序；它还让Sublime Text知道如何运行该程序，并以有益的方式突出其中的代码。

保存文件后，在其中输入下面一行代码：

print("Hello Python world!")

如果在系统中能够运行命令python，就可选择菜单Tools ▶ Build或按Ctrl + B来运行程序。如果你对Sublime Text进行了配置，使其使用的命令不是python，请选择菜单Tools ▶ Build System，再选择Python 3。这将把Python 3设置为默认使用的Python版本；此后，你就可选择菜单Tools ▶ Build或按Command+ B来运行程序了。

Sublime Text窗口底部将出现一个终端屏幕，其中包含如下输出：

Hello Python world!
[Finished in 0.1s]

如果没有看到这样的输出，请检查你输入的每个字符。你是不是将print的首字母大写了？是不是遗漏了引号或括号？编程语言对语法的要求非常严格，只要你没有严格遵守语法，就会出错。如果代码都正确，这个程序也不能正确地运行，请参阅1.3节。

1.2.3　在Windows系统中搭建Python编程环境

Windows系统并非都默认安装了Python，因此你可能需要下载并安装它，再下载并安装一个文本编辑器。

1. 安装Python

首先，检查你的系统是否安装了Python。为此，在“开始”菜单中输入command并按回车以打开一个命令窗口；你也可按住Shift键并右击桌面，再选择“在此处打开命令窗口”。在终端窗口中输入python并按回车；如果出现了Python提示符（>>>），就说明你的系统安装了Python。然而，你也可能会看到一条错误消息，指出python是无法识别的命令。

如果是这样，就需要下载Windows Python安装程序。为此，请访问http://python.org/downloads/。你将看到两个按钮，分别用于下载Python 3和Python 2。单击用于下载Python 3的按钮，这会根据你的系统自动下载正确的安装程序。下载安装程序后，运行它。请务必选中复选框Add Python to PATH（如图1-2所示），这让你能够更轻松地配置系统。

图1-2　确保选中复选框Add Python to PATH

2. 启动Python终端会话

通过配置系统，让其能够在终端会话中运行Python，可简化文本编辑器的配置工作。打开一个命令窗口，并在其中执行命令python。如果出现了Python提示符（>>>），就说明Windows找到了你刚安装的Python版本。

C:\> python
Python 3.5.0 (v3.5.0:374f501f4567, Sep 13 2015, 22:15:05) [MSC v.1900 32 bit
(Intel)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>>

如果是这样，就可以直接跳到下一部分——“在终端会话中运行Python”。

然而，输出可能类似于下面这样：

C:\> python
'python' is not recognized as an internal or external command, operable
program or batch file.

在这种情况下，你就必须告诉Windows如何找到你刚安装的Python版本。命令python通常存储在C盘，因此请在Windows资源管理器中打开C盘，在其中找到并打开以Python打头的文件夹，再找到文件python。例如，在我的计算机中，有一个名为Python35的文件夹，其中有一个名为python的文件，因此文件python的路径为C:\Python35\python。如果找不到这个文件，请在Windows资源管理器的搜索框中输入python，这将让你能够准确地获悉命令python在系统中的存储位置。

如果认为已知道命令python的路径，就在终端窗口中输入该路径进行测试。为此，打开一个命令窗口，并输入你确定的完整路径：

C:\> C:\Python35\python
Python 3.5.0 (v3.5.0:374f501f4567, Sep 13 2015, 22:15:05) [MSC v.1900 32 bit
(Intel)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>>

如果可行，就说明你已经知道如何访问Python了。

3. 在终端会话中运行Python

在Python会话中执行下面的命令，并确认看到了输出“Hello Python world!”。

>>> print("Hello Python world!")
Hello Python world!
>>>

每当要运行Python代码片段时，都请打开一个命令窗口并启动Python终端会话。要关闭该终端会话，可按Ctrl + Z，再按回车键，也可执行命令exit()。

4. 安装文本编辑器

Geany是一款简单的文本编辑器：它易于安装；让你能够直接运行几乎所有的程序（而无需通过终端）；使用不同的颜色来显示代码，以突出代码语法；在终端窗口中运行代码，让你能够习惯使用终端。附录B介绍了其他一些文本编辑器，但我强烈建议你使用Geany，除非你有充分的理由不这样做。

要下载Windows Geany安装程序，可访问http://geany.org/，单击Download下的Releases，找到安装程序geany-1.25_setup.exe或类似的文件。下载安装程序后，运行它并接受所有的默认设置。

为编写第一个程序，需要启动Geany。为此，可按超级（Super）键（俗称Windows键），并在系统中搜索Geany。找到Geany后，双击以启动它；再将其拖曳到任务栏或桌面上，以创建一个快捷方式。接下来，创建一个用于存储项目的文件夹，并将其命名为python_work（在文件名和文件夹名中，最好使用小写字母，并使用下划线来表示空格，因为这是Python采用的命名约定）。回到Geany，选择菜单File ▶ Save As，将当前的空Python文件保存到文件夹python_work，并将其命名为hello_world.py。扩展名.py告诉Geany，文件包含的是Python程序；它还让Geany知道如何运行该程序，并以有益的方式突出其中的代码。

保存文件后，在其中输入下面一行代码：

print("Hello Python world!")

如果能够在系统中执行命令python，就无需配置Geany，因此你可以跳过下一部分，直接进入“运行Hello World程序”部分。如果启动Python解释器时必须指定路径，如C:\Python35\python，请按下面的说明对Geany进行配置。

5. 配置Geany

要配置Geany，请选择菜单Build ▶ Set Build Commands；你将看到文字Compile和Execute，它们旁边都有一个命令。默认情况下，编译命令和执行命令的开头都是python，但Geany不知道命令python存储在系统的什么地方，因此你需要在其中添加你在终端会话中使用的路径。

为此，在编译命令和执行命令中，加上命令python所在的驱动器和文件夹。其中编译命令应类似于下面这样：

C:\Python35\python -m py_compile "%f"

在你的系统中，路径可能稍有不同，但请务必确保空格和大小写与这里显示的一致。

执行命令应类似于下面这样：

C:\Python35\python "%f"

同样，指定执行命令时，务必确保空格和大小写与这里显示的一致。图1-3显示了该如何在Geany中配置这些命令。

图1-3　在Windows中配置Geany，使其使用Python 3

正确地设置这些命令后，单击OK按钮。

6. 运行Hello World程序

现在应该能够成功地运行程序了。请运行程序hello_world.py；为此，可选择菜单Build ▶ Execute、单击Execute图标（两个齿轮）或按F5。将弹出一个终端窗口，其中包含如下输出：

Hello Python world!

------------------
(program exited with code: 0)
Press return to continue

如果没有看到这样的输出，请检查你输入的每个字符。你是不是将print的首字母大写了？是不是遗漏了引号或括号？编程语言对语法的要求非常严格，只要你没有严格遵守语法，就会出错。如果代码都正确，这个程序也不能正确地运行，请参阅下一节。

1.3　解决安装问题

如果你按前面的步骤做，应该能够成功地搭建编程环境。但如果你始终无法运行程序hello_world.py，可尝试如下几个解决方案。

程序存在严重的错误时，Python将显示traceback。Python会仔细研究文件，试图找出其中的问题。traceback可能会提供线索，让你知道是什么问题让程序无法运行。

离开计算机，先休息一会儿，再尝试。别忘了，在编程中，语法非常重要，即便是少一个冒号、引号不匹配或括号不匹配，都可能导致程序无法正确地运行。请再次阅读本章相关的内容，再次审视你所做的工作，看看能否找出错误。

推倒重来。你也许不需要把一切都推倒重来，但将文件hello_world.py删除并重新创建它也许是合理的选择。

让别人在你的计算机或其他计算机上按本章的步骤重做一遍，并仔细观察。你可能遗漏了一小步，而别人刚好没有遗漏。

请懂Python的人帮忙。当你有这样的想法时，可能会发现在你认识的人当中就有人使用Python。

本章的安装说明在本书主页上：http://www.ituring.com.cn/book/1861。对你来说，在线版也许更合适。

到网上寻求帮助。附录C提供了很多在线资源，如论坛或在线聊天网站，你可以前往这些地方，请求解决过你面临的问题的人提供解决方案。

不要担心这会打扰经验丰富的程序员。每个程序员都遇到过问题，而大多数程序员都会乐意帮助你正确地设置系统。只要能清晰地说明你要做什么、尝试了哪些方法及其结果，就很可能有人能够帮到你。正如前言中指出的，Python社区对初学者非常友好。

任何现代计算机都能够运行Python，如果你遇到了困难，请想办法寻求帮助吧。前期的问题可能令人沮丧，但很值得你花时间去解决。能够运行hello_world.py后，你就可以开始学习Python了，而且编程工作会更有趣，也更令人愉快。

1.4　从终端运行Python程序

你编写的大多数程序都将直接在文本编辑器中运行，但有时候，从终端运行程序很有用。例如，你可能想直接运行既有的程序。

在任何安装了Python的系统上都可以这样做，前提是你知道如何进入程序文件所在的目录。为尝试这样做，请确保已将文件hello_world.py存储到了桌面的python_work文件夹中。

1.4.1　在Linux和OS X系统中从终端运行Python程序

在Linux和OS X系统中，从终端运行Python程序的方式相同。在终端会话中，可使用终端命令cd（表示切换目录，change directory）在文件系统中导航。命令ls（list的简写）显示当前目录中所有未隐藏的文件。

为运行程序hello_world.py，请打开一个新的终端窗口，并执行下面的命令：

~> cd Desktop/python_work/ ❶
~/Desktop/python_work> ls ❷
hello_world.py
~/Desktop/python_work> python hello_world.py ❸
Hello Python world!

这里使用了命令cd来切换到文件夹Desktop/python_work（见❶）。接下来，使用命令ls来确认这个文件夹中包含文件hello_world.py（见❷）。最后，使用命令python hello_world.py来运行这个文件（见❸）。

就这么简单。要运行Python程序，只需使用命令python（或python3）即可。

1.4.2　在Windows系统中从终端运行Python程序

在命令窗口中，要在文件系统中导航，可使用终端命令cd；要列出当前目录中的所有文件，可使用命令dir（表示目录， directory）。

为运行程序hello_world.py，请打开一个新的终端窗口，并执行下面的命令：

C:\> cd Desktop\python_work ❶
C:\Desktop\python_work> dir ❷
hello_world.py
C:\Desktop\python_work> python hello_world.py ❸
Hello Python world!

这里使用了命令cd来切换到文件夹Desktop\python_work（见❶）。接下来，使用命令dir来确认这个文件夹中包含文件hello_world.py（见❷）。最后，使用命令python hello_world.py来运行这个文件（见❸）。

如果你没有对系统进行配置以使用简单命令python，就可能需要指定这个命令的路径：

C:\$ cd Desktop\python_work
C:\Desktop\python_work$ dir
hello_world.py
C:\Desktop\python_work$ C:\Python35\python hello_world.py
Hello Python world!

大多数程序都可以直接从编辑器运行，但需要解决的问题比较复杂时，你编写的程序可能需要从终端运行。

动手试一试

本章的练习都是探索性的，但从第2章开始将要求你用那一章学到的知识来解决问题。

1-1 python.org：浏览Python主页（http://python.org/），寻找你感兴趣的主题。你对Python越熟悉，这个网站对你来说就越有用。

1-2 输入错误：打开你刚创建的文件hello_world.py，在代码中添加一个输入错误，再运行这个程序。输入错误会引发错误吗？你能理解显示的错误消息吗？你能添加一个不会导致错误的输入错误吗？你凭什么认为它不会导致错误？

1-3 无穷的技艺：如果你编程技艺无穷，你打算开发什么样的程序呢？你就要开始学习编程了；如果心中有目标，就能立即将新学到的技能付诸应用；现在正是草拟目标的大好时机。将想法记录下来是个不错的习惯，这样每当需要开始新项目时，都可参考它们。现在请花点时间描绘三个你想创建的程序。

1.5　小结

在本章中，你大致了解了Python，并在自己的系统中安装了Python。你还安装了一个文本编辑器，以简化Python代码的编写工作。你学习了如何在终端会话中运行Python代码片段，并运行了第一个货真价实的程序——hello_world.py。你还大致了解了如何解决安装问题。

在下一章，你将学习如何在Python程序中使用各种数据和变量。

第 2 章　变量和简单数据类型

在本章中，你将学习可在Python程序中使用的各种数据，还将学习如何将数据存储到变量中，以及如何在程序中使用这些变量。

2.1　运行hello_world.py时发生的情况

运行hello_world.py时，Python都做了些什么呢？下面来深入研究一下。实际上，即便是运行简单的程序，Python所做的工作也相当多：

hello_world.py

print("Hello Python world!")

运行上述代码时，你将看到如下输出：

Hello Python world!

运行文件hello_world.py时，末尾的.py指出这是一个Python程序，因此编辑器将使用Python解释器来运行它。Python解释器读取整个程序，确定其中每个单词的含义。例如，看到单词print时，解释器就会将括号中的内容打印到屏幕，而不会管括号中的内容是什么。

编写程序时，编辑器会以各种方式突出程序的不同部分。例如，它知道print是一个函数的名称，因此将其显示为蓝色；它知道“Hello Python world!”不是Python代码，因此将其显示为橙色。这种功能称为语法突出，在你刚开始编写程序时很有帮助。

2.2　变量

下面来尝试在hello_world.py中使用一个变量。在这个文件开头添加一行代码，并对第2行代码进行修改，如下所示：

message = "Hello Python world!"
print(message)

运行这个程序，看看结果如何。你会发现，输出与以前相同：

Hello Python world!

我们添加了一个名为message的变量。每个变量都存储了一个值——与变量相关联的信息。在这里，存储的值为文本“Hello Python world!”。

添加变量导致Python解释器需要做更多工作。处理第1行代码时，它将文本“Hello Python world!”与变量message关联起来；而处理第2行代码时，它将与变量message关联的值打印到屏幕。

下面来进一步扩展这个程序：修改hello_world.py，使其再打印一条消息。为此，在hello_world.py中添加一个空行，再添加下面两行代码：

message = "Hello Python world!"
print(message)

message = "Hello Python Crash Course world!"
print(message)

现在如果运行这个程序，将看到两行输出：

Hello Python world!
Hello Python Crash Course world!

在程序中可随时修改变量的值，而Python将始终记录变量的最新值。

2.2.1　变量的命名和使用

在Python中使用变量时，需要遵守一些规则和指南。违反这些规则将引发错误，而指南旨在让你编写的代码更容易阅读和理解。请务必牢记下述有关变量的规则。

变量名只能包含字母、数字和下划线。变量名可以字母或下划线打头，但不能以数字打头，例如，可将变量命名为message_1，但不能将其命名为1_message。

变量名不能包含空格，但可使用下划线来分隔其中的单词。例如，变量名greeting_message可行，但变量名greeting message会引发错误。

不要将Python关键字和函数名用作变量名，即不要使用Python保留用于特殊用途的单词，如print（请参见附录A.4）。

变量名应既简短又具有描述性。例如，name比n好，student_name比s_n好，name_length比length_of_persons_name好。

慎用小写字母l和大写字母O，因为它们可能被人错看成数字1和0。

要创建良好的变量名，需要经过一定的实践，在程序复杂而有趣时尤其如此。随着你编写的程序越来越多，并开始阅读别人编写的代码，将越来越善于创建有意义的变量名。

注意　就目前而言，应使用小写的Python变量名。在变量名中使用大写字母虽然不会导致错误，但避免使用大写字母是个不错的主意。

2.2.2　使用变量时避免命名错误

程序员都会犯错，而且大多数程序员每天都会犯错。虽然优秀的程序员也会犯错，但他们也知道如何高效地消除错误。下面来看一种你可能会犯的错误，并学习如何消除它。

我们将有意地编写一些引发错误的代码。请输入下面的代码，包括其中以粗体显示但拼写不正确的单词mesage：

message = "Hello Python Crash Course reader!"
print(mesage)

程序存在错误时，Python解释器将竭尽所能地帮助你找出问题所在。程序无法成功地运行时，解释器会提供一个traceback。traceback是一条记录，指出了解释器尝试运行代码时，在什么地方陷入了困境。下面是你不小心错误地拼写了变量名时，Python解释器提供的traceback：

Traceback (most recent call last):
  File "hello_world.py", line 2, in <module> ❶
    print(mesage) ❷
NameError: name 'mesage' is not defined ❸

解释器指出，文件hello_world.py的第2行存在错误（见❶）；它列出了这行代码，旨在帮助你快速找出错误（见❷）；它还指出了它发现的是什么样的错误（见❸）。在这里，解释器发现了一个名称错误，并指出打印的变量mesage未定义：Python无法识别你提供的变量名。名称错误通常意味着两种情况：要么是使用变量前忘记了给它赋值，要么是输入变量名时拼写不正确。

在这个示例中，第2行的变量名message中遗漏了字母s。Python解释器不会对代码做拼写检查，但要求变量名的拼写一致。例如，如果在代码的另一个地方也将message错误地拼写成了mesage，结果将如何呢？

mesage = "Hello Python Crash Course reader!"
print(mesage)

在这种情况下，程序将成功地运行：

Hello Python Crash Course reader!

计算机一丝不苟，但不关心拼写是否正确。因此，创建变量名和编写代码时，你无需考虑英语中的拼写和语法规则。

很多编程错误都很简单，只是在程序的某一行输错了一个字符。为找出这种错误而花费很长时间的大有人在。很多程序员天资聪颖、经验丰富，却为找出这种细微的错误花费数小时。你可能觉得这很好笑，但别忘了，在你的编程生涯中，经常会有同样的遭遇。

注意　要理解新的编程概念，最佳的方式是尝试在程序中使用它们。如果你在做本书的练习时陷入了困境，请尝试做点其他的事情。如果这样做后依然无法摆脱困境，请复习相关内容。如果这样做后情况依然如故，请参阅附录C的建议。

动手试一试

请完成下面的练习，在做每个练习时，都编写一个独立的程序。保存每个程序时，使用符合标准Python约定的文件名：使用小写字母和下划线，如simple_message.py和simple_messages.py。

2-1 简单消息：将一条消息存储到变量中，再将其打印出来。

2-2 多条简单消息：将一条消息存储到变量中，将其打印出来；再将变量的值修改为一条新消息，并将其打印出来。

2.3　字符串

大多数程序都定义并收集某种数据，然后使用它们来做些有意义的事情。鉴于此，对数据进行分类大有裨益。我们将介绍的第一种数据类型是字符串。字符串虽然看似简单，但能够以很多不同的方式使用它们。

字符串就是一系列字符。在Python中，用引号括起的都是字符串，其中的引号可以是单引号，也可以是双引号，如下所示：

"This is a string."
'This is also a string.'

这种灵活性让你能够在字符串中包含引号和撇号：

'I told my friend, "Python is my favorite language!"'
"The language 'Python' is named after Monty Python, not the snake."
"One of Python's strengths is its diverse and supportive community."

下面来看一些使用字符串的方式。

2.3.1　使用方法修改字符串的大小写

对于字符串，可执行的最简单的操作之一是修改其中的单词的大小写。请看下面的代码，并尝试判断其作用：

name.py

name = "ada lovelace"
print(name.title())

将这个文件保存为name.py，再运行它。你将看到如下输出：

Ada Lovelace

在这个示例中，小写的字符串"ada lovelace"存储到了变量name中。在print()语句中，方法title()出现在这个变量的后面。方法是Python可对数据执行的操作。在name.title()中，name后面的句点（.）让Python对变量name执行方法title()指定的操作。每个方法后面都跟着一对括号，这是因为方法通常需要额外的信息来完成其工作。这种信息是在括号内提供的。函数title()不需要额外的信息，因此它后面的括号是空的。

title()以首字母大写的方式显示每个单词，即将每个单词的首字母都改为大写。这很有用，因为你经常需要将名字视为信息。例如，你可能希望程序将值Ada、ADA和ada视为同一个名字，并将它们都显示为Ada。

还有其他几个很有用的大小写处理方法。例如，要将字符串改为全部大写或全部小写，可以像下面这样做：

name = "Ada Lovelace"
print(name.upper())
print(name.lower())

这些代码的输出如下：

ADA LOVELACE
ada lovelace

存储数据时，方法lower()很有用。很多时候，你无法依靠用户来提供正确的大小写，因此需要将字符串先转换为小写，再存储它们。以后需要显示这些信息时，再将其转换为最合适的大小写方式。

2.3.2　合并（拼接）字符串

在很多情况下，都需要合并字符串。例如，你可能想将姓和名存储在不同的变量中，等要显示姓名时再将它们合而为一：

first_name = "ada"
last_name = "lovelace"
full_name = first_name + " " + last_name ❶

print(full_name)

Python使用加号（+）来合并字符串。在这个示例中，我们使用+来合并first_name、空格和last_name，以得到完整的姓名（见❶），其结果如下：

ada lovelace

这种合并字符串的方法称为拼接。通过拼接，可使用存储在变量中的信息来创建完整的消息。下面来看一个例子：

first_name = "ada"
last_name = "lovelace"
full_name = first_name + " " + last_name

print("Hello, " + full_name.title() + "!") ❶

在这里，一个问候用户的句子中使用了全名（见❶），并使用了方法title()来将姓名设置为合适的格式。这些代码显示一条格式良好的简单问候语：

Hello, Ada Lovelace!

你可以使用拼接来创建消息，再把整条消息都存储在一个变量中：

first_name = "ada"
last_name = "lovelace"
full_name = first_name + " " + last_name

message = "Hello, " + full_name.title() + "!" ❶
print(message) ❷

上述代码也显示消息“Hello, Ada Lovelace!”，但将这条消息存储在了一个变量中（见❶），这让最后的print语句简单得多（见❷）。

2.3.3　使用制表符或换行符来添加空白

在编程中，空白泛指任何非打印字符，如空格、制表符和换行符。你可使用空白来组织输出，以使其更易读。

要在字符串中添加制表符，可使用字符组合\t，如下述代码的❶处所示：

>>> print("Python")
Python
>>> print("\tPython") ❶
    Python

要在字符串中添加换行符，可使用字符组合\n：

>>> print("Languages:\nPython\nC\nJavaScript")
Languages:
Python
C
JavaScript

还可在同一个字符串中同时包含制表符和换行符。字符串"\n\t"让Python换到下一行，并在下一行开头添加一个制表符。下面的示例演示了如何使用一个单行字符串来生成四行输出：

>>> print("Languages:\n\tPython\n\tC\n\tJavaScript")
Languages:
    Python
    C
    JavaScript

在接下来的两章中，你将使用为数不多的几行代码来生成很多行输出，届时制表符和换行符将提供极大的帮助。

2.3.4　删除空白

在程序中，额外的空白可能令人迷惑。对程序员来说，'python'和'python '看起来几乎没什么两样，但对程序来说，它们却是两个不同的字符串。Python能够发现'python '中额外的空白，并认为它是有意义的——除非你告诉它不是这样的。

空白很重要，因为你经常需要比较两个字符串是否相同。例如，一个重要的示例是，在用户登录网站时检查其用户名。但在一些简单得多的情形下，额外的空白也可能令人迷惑。所幸在Python中，删除用户输入的数据中的多余的空白易如反掌。

Python能够找出字符串开头和末尾多余的空白。要确保字符串末尾没有空白，可使用方法rstrip()。

>>> favorite_language = 'python ' ❶
>>> favorite_language ❷
'python '
>>> favorite_language.rstrip() ❸
'python'
>>> favorite_language ❹
'python '

存储在变量favorite_language中的字符串末尾包含多余的空白（见❶）。你在终端会话中向Python询问这个变量的值时，可看到末尾的空格（见❷）。对变量favorite_language调用方法rstrip()后（见❸），这个多余的空格被删除了。然而，这种删除只是暂时的，接下来再次询问favorite_language的值时，你会发现这个字符串与输入时一样，依然包含多余的空白（见❹）。

要永久删除这个字符串中的空白，必须将删除操作的结果存回到变量中：

>>> favorite_language = 'python '
>>> favorite_language = favorite_language.rstrip() ❶
>>> favorite_language
'python'

为删除这个字符串中的空白，你需要将其末尾的空白剔除，再将结果存回到原来的变量中（见❶）。在编程中，经常需要修改变量的值，再将新值存回到原来的变量中。这就是变量的值可能随程序的运行或用户输入数据而发生变化的原因。

你还可以剔除字符串开头的空白，或同时剔除字符串两端的空白。为此，可分别使用方法lstrip()和strip()：

>>> favorite_language = ' python ' ❶
>>> favorite_language.rstrip() ❷
' python'
>>> favorite_language.lstrip() ❸
'python '
>>> favorite_language.strip() ❹
'python'

在这个示例中，我们首先创建了一个开头和末尾都有空白的字符串（见❶）。接下来，我们分别删除末尾（见❷）、开头（见❸）和两端（见❹）的空格。尝试使用这些剥除函数有助于你熟悉字符串操作。在实际程序中，这些剥除函数最常用于在存储用户输入前对其进行清理。

2.3.5　使用字符串时避免语法错误

语法错误是一种时不时会遇到的错误。程序中包含非法的Python代码时，就会导致语法错误。例如，在用单引号括起的字符串中，如果包含撇号，就将导致错误。这是因为这会导致Python将第一个单引号和撇号之间的内容视为一个字符串，进而将余下的文本视为Python代码，从而引发错误。

下面演示了如何正确地使用单引号和双引号。请将该程序保存为apostrophe.py，再运行它：

apostrophe.py

message = "One of Python's strengths is its diverse community."
print(message)

撇号位于两个双引号之间，因此Python解释器能够正确地理解这个字符串：

One of Python's strengths is its diverse community.

然而，如果你使用单引号，Python将无法正确地确定字符串的结束位置：

message = 'One of Python's strengths is its diverse community.'
print(message)

而你将看到如下输出：

  File "apostrophe.py", line 1
    message = 'One of Python's strengths is its diverse community.'
                            ^❶
SyntaxError: invalid syntax

从上述输出可知，错误发生在第二个单引号后面（见❶）。这种语法错误表明，在解释器看来，其中的有些内容不是有效的Python代码。错误的来源多种多样，这里指出一些常见的。学习编写Python代码时，你可能会经常遇到语法错误。语法错误也是最不具体的错误类型，因此可能难以找出并修复。受困于非常棘手的错误时，请参阅附录C提供的建议。

注意　编写程序时，编辑器的语法突出功能可帮助你快速找出某些语法错误。看到Python代码以普通句子的颜色显示，或者普通句子以Python代码的颜色显示时，就可能意味着文件中存在引号不匹配的情况。

2.3.6　Python 2中的print语句

在Python 2中，print语句的语法稍有不同：

> python2.7
>>> print "Hello Python 2.7 world!"
Hello Python 2.7 world!

在Python 2中，无需将要打印的内容放在括号内。从技术上说，Python 3中的print是一个函数，因此括号必不可少。有些Python 2 print语句也包含括号，但其行为与Python 3中稍有不同。简单地说，在Python 2代码中，有些print语句包含括号，有些不包含。

动手试一试

在做下面的每个练习时，都编写一个独立的程序，并将其保存为名称类似于name_cases.py的文件。如果遇到了困难，请休息一会儿或参阅附录C提供的建议。

2-3 个性化消息：将用户的姓名存到一个变量中，并向该用户显示一条消息。显示的消息应非常简单，如“Hello Eric, would you like to learn some Python today?”。

2-4 调整名字的大小写：将一个人名存储到一个变量中，再以小写、大写和首字母大写的方式显示这个人名。

2-5 名言：找一句你钦佩的名人说的名言，将这个名人的姓名和他的名言打印出来。输出应类似于下面这样（包括引号）：

Albert Einstein once said, “A person who never made a mistake never tried anything new.”

2-6 名言2：重复练习2-5，但将名人的姓名存储在变量famous_person中，再创建要显示的消息，并将其存储在变量message中，然后打印这条消息。

2-7 剔除人名中的空白：存储一个人名，并在其开头和末尾都包含一些空白字符。务必至少使用字符组合"\t"和"\n"各一次。

打印这个人名，以显示其开头和末尾的空白。然后，分别使用剔除函数lstrip()、rstrip()和strip()对人名进行处理，并将结果打印出来。

2.4　数字

在编程中，经常使用数字来记录游戏得分、表示可视化数据、存储Web应用信息等。Python根据数字的用法以不同的方式处理它们。鉴于整数使用起来最简单，下面就先来看看Python是如何管理它们的。

2.4.1　整数

在Python中，可对整数执行加（+）减（-）乘（*）除（/）运算。

>>> 2 + 3
5
>>> 3 - 2
1
>>> 2 * 3
6
>>> 3 / 2
1.5

在终端会话中，Python直接返回运算结果。Python使用两个乘号表示乘方运算：

>>> 3 ** 2
9
>>> 3 ** 3
27
>>> 10 ** 6
1000000

Python还支持运算次序，因此你可在同一个表达式中使用多种运算。你还可以使用括号来修改运算次序，让Python按你指定的次序执行运算，如下所示：

>>> 2 + 3*4
14
>>> (2 + 3) * 4
20

在这些示例中，空格不影响Python计算表达式的方式，它们的存在旨在让你阅读代码时，能迅速确定先执行哪些运算。

2.4.2　浮点数

Python将带小数点的数字都称为浮点数。大多数编程语言都使用了这个术语，它指出了这样一个事实：小数点可出现在数字的任何位置。每种编程语言都须细心设计，以妥善地处理浮点数，确保不管小数点出现在什么位置，数字的行为都是正常的。

从很大程度上说，使用浮点数时都无需考虑其行为。你只需输入要使用的数字，Python通常都会按你期望的方式处理它们：

>>> 0.1 + 0.1
0.2
>>> 0.2 + 0.2
0.4
>>> 2 * 0.1
0.2
>>> 2 * 0.2
0.4

但需要注意的是，结果包含的小数位数可能是不确定的：

>>> 0.2 + 0.1
0.30000000000000004
>>> 3 * 0.1
0.30000000000000004

所有语言都存在这种问题，没有什么可担心的。Python会尽力找到一种方式，以尽可能精确地表示结果，但鉴于计算机内部表示数字的方式，这在有些情况下很难。就现在而言，暂时忽略多余的小数位数即可；在第二部分的项目中，你将学习在需要时处理多余小数位的方式。

2.4.3　使用函数str()避免类型错误

你经常需要在消息中使用变量的值。例如，假设你要祝人生日快乐，可能会编写类似于下面的代码：

birthday.py

age = 23
message = "Happy " + age + "rd Birthday!"

print(message)

你可能认为，上述代码会打印一条简单的生日祝福语：Happy 23rd birthday!。但如果你运行这些代码，将发现它们会引发错误：

Traceback (most recent call last):
  File "birthday.py", line 2, in <module>
    message = "Happy " + age + "rd Birthday!"
TypeError: Can't convert 'int' object to str implicitly ❶

这是一个类型错误，意味着Python无法识别你使用的信息。在这个示例中，Python发现你使用了一个值为整数（int）的变量，但它不知道该如何解读这个值（见❶）。Python知道，这个变量表示的可能是数值23，也可能是字符2和3。像上面这样在字符串中使用整数时，需要显式地指出你希望Python将这个整数用作字符串。为此，可调用函数str()，它让Python将非字符串值表示为字符串：

age = 23
message = "Happy " + str(age) + "rd Birthday!"

print(message)

这样，Python就知道你要将数值23转换为字符串，进而在生日祝福消息中显示字符2和3。经过上述处理后，将显示你期望的消息，而不会引发错误：

Happy 23rd Birthday!

大多数情况下，在Python中使用数字都非常简单。如果结果出乎意料，请检查Python是否按你期望的方式将数字解读为了数值或字符串。

2.4.4　Python 2中的整数

在Python 2中，将两个整数相除得到的结果稍有不同：

>>> python2.7
>>> 3 / 2
1

Python返回的结果为1，而不是1.5。在Python 2中，整数除法的结果只包含整数部分，小数部分被删除。请注意，计算整数结果时，采取的方式不是四舍五入，而是将小数部分直接删除。

在Python 2中，若要避免这种情况，务必确保至少有一个操作数为浮点数，这样结果也将为浮点数：

>>> 3 / 2
1
>>> 3.0 / 2
1.5
>>> 3 / 2.0
1.5
>>> 3.0 / 2.0
1.5

从Python 3转而用Python 2或从Python 2转而用Python 3时，这种除法行为常常会令人迷惑。使用或编写同时使用浮点数和整数的代码时，一定要注意这种异常行为。

动手试一试

2-8 数字8：编写4个表达式，它们分别使用加法、减法、乘法和除法运算，但结果都是数字8。为使用print语句来显示结果，务必将这些表达式用括号括起来，也就是说，你应该编写4行类似于下面的代码：

print(5 + 3)

输出应为4行，其中每行都只包含数字8。

2-9 最喜欢的数字：将你最喜欢的数字存储在一个变量中，再使用这个变量创建一条消息，指出你最喜欢的数字，然后将这条消息打印出来。

2.5　注释

在大多数编程语言中，注释都是一项很有用的功能。本书前面编写的程序中都只包含Python代码，但随着程序越来越大、越来越复杂，就应在其中添加说明，对你解决问题的方法进行大致的阐述。注释让你能够使用自然语言在程序中添加说明。

2.5.1　如何编写注释

在Python中，注释用井号（#）标识。井号后面的内容都会被Python解释器忽略，如下所示：

comment.py

# 向大家问好
print("Hello Python people!")

Python解释器将忽略第1行，只执行第2行。

Hello Python people!

2.5.2　该编写什么样的注释

编写注释的主要目的是阐述代码要做什么，以及是如何做的。在开发项目期间，你对各个部分如何协同工作了如指掌，但过段时间后，有些细节你可能不记得了。当然，你总是可以通过研究代码来确定各个部分的工作原理，但通过编写注释，以清晰的自然语言对解决方案进行概述，可节省很多时间。

要成为专业程序员或与其他程序员合作，就必须编写有意义的注释。当前，大多数软件都是合作编写的，编写者可能是同一家公司的多名员工，也可能是众多致力于同一个开源项目的人员。训练有素的程序员都希望代码中包含注释，因此你最好从现在开始就在程序中添加描述性注释。作为新手，最值得养成的习惯之一是，在代码中编写清晰、简洁的注释。

如果不确定是否要编写注释，就问问自己，找到合理的解决方案前，是否考虑了多个解决方案。如果答案是肯定的，就编写注释对你的解决方案进行说明吧。相比回过头去再添加注释，删除多余的注释要容易得多。从现在开始，本书的示例都将使用注释来阐述代码的工作原理。

动手试一试

2-10 添加注释：选择你编写的两个程序，在每个程序中都至少添加一条注释。如果程序太简单，实在没有什么需要说明的，就在程序文件开头加上你的姓名和当前日期，再用一句话阐述程序的功能。

2.6　Python之禅

编程语言Perl曾在互联网领域长期占据着统治地位，早期的大多数交互式网站使用的都是Perl脚本。彼时，“解决问题的办法有多个”被Perl社区奉为座右铭。这种理念一度深受大家的喜爱，因为这种语言固有的灵活性使得大多数问题都有很多不同的解决之道。在开发项目期间，这种灵活性是可以接受的，但大家最终认识到，过于强调灵活性会导致大型项目难以维护：要通过研究代码搞清楚当时解决复杂问题的人是怎么想的，既困难又麻烦，还会耗费大量的时间。

经验丰富的程序员倡导尽可能避繁就简。Python社区的理念都包含在Tim Peters撰写的“Python之禅”中。要获悉这些有关编写优秀Python代码的指导原则，只需在解释器中执行命令import this。这里不打算赘述整个“Python之禅”，而只与大家分享其中的几条原则，让你明白为何它们对Python新手来说至关重要。

>>> import this
The Zen of Python, by Tim Peters

Beautiful is better than ugly.

Python程序员笃信代码可以编写得漂亮而优雅。编程是要解决问题的，设计良好、高效而漂亮的解决方案都会让程序员心生敬意。随着你对Python的认识越来越深入，并使用它来编写越来越多的代码，有一天也许会有人站在你后面惊呼：“哇，代码编写得真是漂亮！”

Simple is better than complex.

如果有两个解决方案，一个简单，一个复杂，但都行之有效，就选择简单的解决方案吧。这样，你编写的代码将更容易维护，你或他人以后改进这些代码时也会更容易。

Complex is better than complicated.

现实是复杂的，有时候可能没有简单的解决方案。在这种情况下，就选择最简单可行的解决方案吧。

Readability counts.

即便是复杂的代码，也要让它易于理解。开发的项目涉及复杂代码时，一定要为这些代码编写有益的注释。

There should be one-- and preferably only one --obvious way to do it.

如果让两名Python程序员去解决同一个问题，他们提供的解决方案应大致相同。这并不是说编程没有创意空间，而是恰恰相反！然而，大部分编程工作都是使用常见解决方案来解决简单的小问题，但这些小问题都包含在更庞大、更有创意空间的项目中。在你的程序中，各种具体细节对其他Python程序员来说都应易于理解。

Now is better than never.

你可以将余生都用来学习Python和编程的纷繁难懂之处，但这样你什么项目都完不成。不要企图编写完美无缺的代码；先编写行之有效的代码，再决定是对其做进一步改进，还是转而去编写新代码。

等你进入下一章，开始研究更复杂的主题时，务必牢记这种简约而清晰的理念。如此，经验丰富的程序员定将对你编写的代码心生敬意，进而乐意向你提供反馈，并与你合作开发有趣的项目。

动手试一试

2-11 Python之禅：在Python终端会话中执行命令import this，并粗略地浏览一下其他的指导原则。

2.7　小结

在本章中，你学习了：如何使用变量；如何创建描述性变量名以及如何消除名称错误和语法错误；字符串是什么，以及如何使用小写、大写和首字母大写方式显示字符串；使用空白来显示整洁的输出，以及如何剔除字符串中多余的空白；如何使用整数和浮点数；使用数值数据时需要注意的意外行为。你还学习了如何编写说明性注释，让代码对你和其他人来说更容易理解。最后，你了解了让代码尽可能简单的理念。

在第3章，你将学习如何在被称为列表的变量中存储信息集，以及如何通过遍历列表来操作其中的信息。

第 3 章　列表简介

在本章和下一章中，你将学习列表是什么以及如何使用列表元素。列表让你能够在一个地方存储成组的信息，其中可以只包含几个元素，也可以包含数百万个元素。列表是新手可直接使用的最强大的Python功能之一，它融合了众多重要的编程概念。

3.1　列表是什么

列表由一系列按特定顺序排列的元素组成。你可以创建包含字母表中所有字母、数字0~9或所有家庭成员姓名的列表；也可以将任何东西加入列表中，其中的元素之间可以没有任何关系。鉴于列表通常包含多个元素，给列表指定一个表示复数的名称（如letters、digits或names）是个不错的主意。

在Python中，用方括号（[]）来表示列表，并用逗号来分隔其中的元素。下面是一个简单的列表示例，这个列表包含几种自行车：

bicycles.py

bicycles = ['trek', 'cannondale', 'redline', 'specialized']
print(bicycles)

如果你让Python将列表打印出来，Python将打印列表的内部表示，包括方括号：

['trek', 'cannondale', 'redline', 'specialized']

鉴于这不是你要让用户看到的输出，下面来学习如何访问列表元素。

3.1.1　访问列表元素

列表是有序集合，因此要访问列表的任何元素，只需将该元素的位置或索引告诉Python即可。要访问列表元素，可指出列表的名称，再指出元素的索引，并将其放在方括号内。

例如，下面的代码从列表bicycles中提取第一款自行车：

bicycles = ['trek', 'cannondale', 'redline', 'specialized']
print(bicycles[0]) ❶

❶处演示了访问列表元素的语法。当你请求获取列表元素时，Python只返回该元素，而不包括方括号和引号：

trek

这正是你要让用户看到的结果——整洁、干净的输出。

你还可以对任何列表元素调用第2章介绍的字符串方法。例如，可使用方法title()让元素'trek'的格式更整洁：

bicycles = ['trek', 'cannondale', 'redline', 'specialized']
print(bicycles[0].title())

这个示例的输出与前一个示例相同，只是首字母T是大写的。

3.1.2　索引从0而不是1开始

在Python中，第一个列表元素的索引为0，而不是1。在大多数编程语言中都是如此，这与列表操作的底层实现相关。如果结果出乎意料，请看看你是否犯了简单的差一错误。

第二个列表元素的索引为1。根据这种简单的计数方式，要访问列表的任何元素，都可将其位置减1，并将结果作为索引。例如，要访问第四个列表元素，可使用索引3。

下面的代码访问索引1和3处的自行车：

bicycles = ['trek', 'cannondale', 'redline', 'specialized']
print(bicycles[1])
print(bicycles[3])

这些代码返回列表中的第二个和第四个元素：

cannondale
specialized

Python为访问最后一个列表元素提供了一种特殊语法。通过将索引指定为-1，可让Python返回最后一个列表元素：

bicycles = ['trek', 'cannondale', 'redline', 'specialized']
print(bicycles[-1])

这些代码返回'specialized'。这种语法很有用，因为你经常需要在不知道列表长度的情况下访问最后的元素。这种约定也适用于其他负数索引，例如，索引-2返回倒数第二个列表元素，索引-3返回倒数第三个列表元素，以此类推。

3.1.3　使用列表中的各个值

可像使用其他变量一样使用列表中的各个值。例如，你可以使用拼接根据列表中的值来创建消息。

下面来尝试从列表中提取第一款自行车，并使用这个值来创建一条消息：

bicycles = ['trek', 'cannondale', 'redline', 'specialized']
message = "My first bicycle was a " + bicycles[0].title() + "." ❶

print(message)

我们使用bicycles[0]的值生成了一个句子，并将其存储在变量message中（见❶）。输出是一个简单的句子，其中包含列表中的第一款自行车：

My first bicycle was a Trek.

动手试一试

请尝试编写一些简短的程序来完成下面的练习，以获得一些使用Python列表的第一手经验。你可能需要为每章的练习创建一个文件夹，以整洁有序的方式存储为完成各章的练习而编写的程序。

3-1 姓名：将一些朋友的姓名存储在一个列表中，并将其命名为names。依次访问该列表中的每个元素，从而将每个朋友的姓名都打印出来。

3-2 问候语：继续使用练习3-1中的列表，但不打印每个朋友的姓名，而为每人打印一条消息。每条消息都包含相同的问候语，但抬头为相应朋友的姓名。

3-3 自己的列表：想想你喜欢的通勤方式，如骑摩托车或开汽车，并创建一个包含多种通勤方式的列表。根据该列表打印一系列有关这些通勤方式的宣言，如“I would like to own a Honda motorcycle”。

3.2　修改、添加和删除元素

你创建的大多数列表都将是动态的，这意味着列表创建后，将随着程序的运行增删元素。例如，你创建一个游戏，要求玩家射杀从天而降的外星人；为此，可在开始时将一些外星人存储在列表中，然后每当有外星人被射杀时，都将其从列表中删除，而每次有新的外星人出现在屏幕上时，都将其添加到列表中。在整个游戏运行期间，外星人列表的长度将不断变化。

3.2.1　修改列表元素

修改列表元素的语法与访问列表元素的语法类似。要修改列表元素，可指定列表名和要修改的元素的索引，再指定该元素的新值。

例如，假设有一个摩托车列表，其中的第一个元素为'honda'，如何修改它的值呢？

motorcycles.py

motorcycles = ['honda', 'yamaha', 'suzuki'] ❶
print(motorcycles)

motorcycles[0] = 'ducati' ❷
print(motorcycles)

我们首先定义一个摩托车列表，其中的第一个元素为'honda'（见❶）。接下来，我们将第一个元素的值改为'ducati'（见❷）。输出表明，第一个元素的值确实变了，但其他列表元素的值没变：

['honda', 'yamaha', 'suzuki']
['ducati', 'yamaha', 'suzuki']

你可以修改任何列表元素的值，而不仅仅是第一个元素的值。

3.2.2　在列表中添加元素

你可能出于众多原因要在列表中添加新元素，例如，你可能希望游戏中出现新的外星人、添加可视化数据或给网站添加新注册的用户。Python提供了多种在既有列表中添加新数据的方式。

1. 在列表末尾添加元素

在列表中添加新元素时，最简单的方式是将元素附加到列表末尾。给列表附加元素时，它将添加到列表末尾。继续使用前一个示例中的列表，在其末尾添加新元素'ducati'：

motorcycles = ['honda', 'yamaha', 'suzuki']
print(motorcycles)

motorcycles.append('ducati') ❶
print(motorcycles)

方法append()将元素'ducati'添加到了列表末尾（见❶ ），而不影响列表中的其他所有元素：

['honda', 'yamaha', 'suzuki']
['honda', 'yamaha', 'suzuki', 'ducati']

方法append()让动态地创建列表易如反掌，例如，你可以先创建一个空列表，再使用一系列的append()语句添加元素。下面来创建一个空列表，再在其中添加元素'honda'、'yamaha'和'suzuki'：

motorcycles = []

motorcycles.append('honda')
motorcycles.append('yamaha')
motorcycles.append('suzuki')

print(motorcycles)

最终的列表与前述示例中的列表完全相同：

['honda', 'yamaha', 'suzuki']

这种创建列表的方式极其常见，因为经常要等程序运行后，你才知道用户要在程序中存储哪些数据。为控制用户，可首先创建一个空列表，用于存储用户将要输入的值，然后将用户提供的每个新值附加到列表中。

2. 在列表中插入元素

使用方法insert()可在列表的任何位置添加新元素。为此，你需要指定新元素的索引和值。

motorcycles = ['honda', 'yamaha', 'suzuki']

motorcycles.insert(0, 'ducati') ❶
print(motorcycles)

在这个示例中，值'ducati'被插入到了列表开头（见❶）；方法insert()在索引0处添加空间，并将值'ducati'存储到这个地方。这种操作将列表中既有的每个元素都右移一个位置：

['ducati', 'honda', 'yamaha', 'suzuki']

3.2.3　从列表中删除元素

你经常需要从列表中删除一个或多个元素。例如，玩家将空中的一个外星人射杀后，你很可能要将其从存活的外星人列表中删除；当用户在你创建的Web应用中注销其账户时，你需要将该用户从活跃用户列表中删除。你可以根据位置或值来删除列表中的元素。

1. 使用del语句删除元素

如果知道要删除的元素在列表中的位置，可使用del语句。

motorcycles = ['honda', 'yamaha', 'suzuki']
print(motorcycles)

del motorcycles[0] ❶
print(motorcycles)

❶ 处的代码使用del删除了列表motorcycles中的第一个元素——'honda'：

['honda', 'yamaha', 'suzuki']
['yamaha', 'suzuki']

使用del可删除任何位置处的列表元素，条件是知道其索引。下例演示了如何删除前述列表中的第二个元素——'yamaha'：

motorcycles = ['honda', 'yamaha', 'suzuki']
print(motorcycles)

del motorcycles[1]
print(motorcycles)

下面的输出表明，已经将第二款摩托车从列表中删除了：

['honda', 'yamaha', 'suzuki']
['honda', 'suzuki']

在这两个示例中，使用del语句将值从列表中删除后，你就无法再访问它了。

2. 使用方法pop()删除元素

有时候，你要将元素从列表中删除，并接着使用它的值。例如，你可能需要获取刚被射杀的外星人的 x 和 y 坐标，以便在相应的位置显示爆炸效果；在Web应用程序中，你可能要将用户从活跃成员列表中删除，并将其加入到非活跃成员列表中。

方法pop()可删除列表末尾的元素，并让你能够接着使用它。术语弹出（pop）源自这样的类比：列表就像一个栈，而删除列表末尾的元素相当于弹出栈顶元素。

下面从列表motorcycles中弹出一款摩托车：

motorcycles = ['honda', 'yamaha', 'suzuki'] ❶
print(motorcycles)

popped_motorcycle = motorcycles.pop() ❷
print(motorcycles) ❸
print(popped_motorcycle) ❹

我们首先定义并打印了列表motorcycles（见❶）。接下来，我们从这个列表中弹出一个值，并将其存储到变量popped_motorcycle中（见❷）。然后我们打印这个列表，以核实从其中删除了一个值（见❸）。最后，我们打印弹出的值，以证明我们依然能够访问被删除的值（见❹）。

输出表明，列表末尾的值'suzuki'已删除，它现在存储在变量popped_motorcycle中：

['honda', 'yamaha', 'suzuki']
['honda', 'yamaha']
suzuki

方法pop()是怎么起作用的呢？假设列表中的摩托车是按购买时间存储的，就可使用方法pop()打印一条消息，指出最后购买的是哪款摩托车：

motorcycles = ['honda', 'yamaha', 'suzuki']

last_owned = motorcycles.pop()
print("The last motorcycle I owned was a " + last_owned.title() + ".")

输出是一个简单的句子，指出了最新购买的是哪款摩托车：

The last motorcycle I owned was a Suzuki.

3. 弹出列表中任何位置处的元素

实际上，你可以使用pop()来删除列表中任何位置的元素，只需在括号中指定要删除的元素的索引即可。

motorcycles = ['honda', 'yamaha', 'suzuki']

first_owned = motorcycles.pop(0) ❶
print('The first motorcycle I owned was a ' + first_owned.title() + '.') ❷

首先，我们弹出了列表中的第一款摩托车（见❶），然后打印了一条有关这辆摩托车的消息（见❷）。输出是一个简单的句子，描述了我购买的第一辆摩托车：

The first motorcycle I owned was a Honda.

别忘了，每当你使用pop()时，被弹出的元素就不再在列表中了。

如果你不确定该使用del语句还是pop()方法，下面是一个简单的判断标准：如果你要从列表中删除一个元素，且不再以任何方式使用它，就使用del语句；如果你要在删除元素后还能继续使用它，就使用方法pop()。

4. 根据值删除元素

有时候，你不知道要从列表中删除的值所处的位置。如果你只知道要删除的元素的值，可使用方法remove()。

例如，假设我们要从列表motorcycles中删除值'ducati'。

motorcycles = ['honda', 'yamaha', 'suzuki', 'ducati']
print(motorcycles)

motorcycles.remove('ducati') ❶
print(motorcycles)

❶ 处的代码让Python确定'ducati'出现在列表的什么地方，并将该元素删除：

['honda', 'yamaha', 'suzuki', 'ducati']
['honda', 'yamaha', 'suzuki']

使用remove()从列表中删除元素时，也可接着使用它的值。下面删除值'ducati'，并打印一条消息，指出要将其从列表中删除的原因：

motorcycles = ['honda', 'yamaha', 'suzuki', 'ducati'] ❶
print(motorcycles)

too_expensive = 'ducati' ❷
motorcycles.remove(too_expensive) ❸
print(motorcycles)
print("\nA " + too_expensive.title() + " is too expensive for me.") ❹

在❶处定义列表后，我们将值'ducati'存储在变量too_expensive中（见❷）。接下来，我们使用这个变量来告诉Python将哪个值从列表中删除（见❸）。最后，值'ducati'已经从列表中删除，但它还存储在变量too_expensive中（见❹），让我们能够打印一条消息，指出将'ducati'从列表motorcycles中删除的原因：

['honda', 'yamaha', 'suzuki', 'ducati']
['honda', 'yamaha', 'suzuki']

A Ducati is too expensive for me.

>

注意　方法remove()只删除第一个指定的值。如果要删除的值可能在列表中出现多次，就需要使用循环来判断是否删除了所有这样的值。你将在第7章学习如何这样做。

动手试一试

下面的练习比第2章的练习要复杂些，但让你有机会以前面介绍过的各种方式使用列表。

3-4 嘉宾名单：如果你可以邀请任何人一起共进晚餐（无论是在世的还是故去的），你会邀请哪些人？请创建一个列表，其中包含至少3个你想邀请的人；然后，使用这个列表打印消息，邀请这些人来与你共进晚餐。

3-5 修改嘉宾名单：你刚得知有位嘉宾无法赴约，因此需要另外邀请一位嘉宾。

以完成练习3-4时编写的程序为基础，在程序末尾添加一条print语句，指出哪位嘉宾无法赴约。

修改嘉宾名单，将无法赴约的嘉宾的姓名替换为新邀请的嘉宾的姓名。

再次打印一系列消息，向名单中的每位嘉宾发出邀请。

3-6 添加嘉宾：你刚找到了一个更大的餐桌，可容纳更多的嘉宾。请想想你还想邀请哪三位嘉宾。

以完成练习3-4或练习3-5时编写的程序为基础，在程序末尾添加一条print语句，指出你找到了一个更大的餐桌。

使用insert()将一位新嘉宾添加到名单开头。

使用insert()将另一位新嘉宾添加到名单中间。

使用append()将最后一位新嘉宾添加到名单末尾。

打印一系列消息，向名单中的每位嘉宾发出邀请。

3-7 缩减名单：你刚得知新购买的餐桌无法及时送达，因此只能邀请两位嘉宾。

以完成练习3-6时编写的程序为基础，在程序末尾添加一行代码，打印一条你只能邀请两位嘉宾共进晚餐的消息。

使用pop()不断地删除名单中的嘉宾，直到只有两位嘉宾为止。每次从名单中弹出一位嘉宾时，都打印一条消息，让该嘉宾知悉你很抱歉，无法邀请他来共进晚餐。

对于余下的两位嘉宾中的每一位，都打印一条消息，指出他依然在受邀人之列。

使用del将最后两位嘉宾从名单中删除，让名单变成空的。打印该名单，核实程序结束时名单确实是空的。

3.3　组织列表

在你创建的列表中，元素的排列顺序常常是无法预测的，因为你并非总能控制用户提供数据的顺序。这虽然在大多数情况下都是不可避免的，但你经常需要以特定的顺序呈现信息。有时候，你希望保留列表元素最初的排列顺序，而有时候又需要调整排列顺序。Python提供了很多组织列表的方式，可根据具体情况选用。

3.3.1　使用方法sort()对列表进行永久性排序

Python方法sort()让你能够较为轻松地对列表进行排序。假设你有一个汽车列表，并要让其中的汽车按字母顺序排列。为简化这项任务，我们假设该列表中的所有值都是小写的。

cars.py

cars = ['bmw', 'audi', 'toyota', 'subaru']
cars.sort() ❶
print(cars)

方法sort()（见❶）永久性地修改了列表元素的排列顺序。现在，汽车是按字母顺序排列的，再也无法恢复到原来的排列顺序：

['audi', 'bmw', 'subaru', 'toyota']

你还可以按与字母顺序相反的顺序排列列表元素，为此，只需向sort()方法传递参数reverse=True。下面的示例将汽车列表按与字母顺序相反的顺序排列：

cars = ['bmw', 'audi', 'toyota', 'subaru']
cars.sort(reverse=True)
print(cars)

同样，对列表元素排列顺序的修改是永久性的：

['toyota', 'subaru', 'bmw', 'audi']

3.3.2　使用函数sorted()对列表进行临时排序

要保留列表元素原来的排列顺序，同时以特定的顺序呈现它们，可使用函数sorted()。函数sorted()让你能够按特定顺序显示列表元素，同时不影响它们在列表中的原始排列顺序。

下面尝试对汽车列表调用这个函数。

cars = ['bmw', 'audi', 'toyota', 'subaru']

print("Here is the original list:") ❶
print(cars)

print("\nHere is the sorted list:") ❷
print(sorted(cars))

print("\nHere is the original list again:") ❸
print(cars)

我们首先按原始顺序打印列表（见❶），再按字母顺序显示该列表（见❷）。以特定顺序显示列表后，我们进行核实，确认列表元素的排列顺序与以前相同（见❸）。

Here is the original list:
['bmw', 'audi', 'toyota', 'subaru']

Here is the sorted list:
['audi', 'bmw', 'subaru', 'toyota']

Here is the original list again: ❹
['bmw', 'audi', 'toyota', 'subaru']

注意，调用函数sorted()后，列表元素的排列顺序并没有变（见❹）。如果你要按与字母顺序相反的顺序显示列表，也可向函数sorted()传递参数reverse=True。

注意　在并非所有的值都是小写时，按字母顺序排列列表要复杂些。决定排列顺序时，有多种解读大写字母的方式，要指定准确的排列顺序，可能比我们这里所做的要复杂。然而，大多数排序方式都基于本节介绍的知识。

3.3.3　倒着打印列表

要反转列表元素的排列顺序，可使用方法reverse()。假设汽车列表是按购买时间排列的，可轻松地按相反的顺序排列其中的汽车：

cars = ['bmw', 'audi', 'toyota', 'subaru']
print(cars)

cars.reverse()
print(cars)

注意，reverse()不是指按与字母顺序相反的顺序排列列表元素，而只是反转列表元素的排列顺序：

['bmw', 'audi', 'toyota', 'subaru']
['subaru', 'toyota', 'audi', 'bmw']

方法reverse()永久性地修改列表元素的排列顺序，但可随时恢复到原来的排列顺序，为此只需对列表再次调用reverse()即可。

3.3.4　确定列表的长度

使用函数len()可快速获悉列表的长度。在下面的示例中，列表包含4个元素，因此其长度为4：

>>> cars = ['bmw', 'audi', 'toyota', 'subaru']
>>> len(cars)
4

在你需要完成如下任务时，len()很有用：确定还有多少个外星人未被射杀，需要管理多少项可视化数据，网站有多少注册用户等。

注意　Python计算列表元素数时从1开始，因此确定列表长度时，你应该不会遇到差一错误。

动手试一试

3-8 放眼世界：想出至少5个你渴望去旅游的地方。

将这些地方存储在一个列表中，并确保其中的元素不是按字母顺序排列的。

按原始排列顺序打印该列表。不要考虑输出是否整洁的问题，只管打印原始Python列表。

使用sorted()按字母顺序打印这个列表，同时不要修改它。

再次打印该列表，核实排列顺序未变。

使用sorted()按与字母顺序相反的顺序打印这个列表，同时不要修改它。

再次打印该列表，核实排列顺序未变。

使用reverse()修改列表元素的排列顺序。打印该列表，核实排列顺序确实变了。

使用reverse()再次修改列表元素的排列顺序。打印该列表，核实已恢复到原来的排列顺序。

使用sort()修改该列表，使其元素按字母顺序排列。打印该列表，核实排列顺序确实变了。

使用sort()修改该列表，使其元素按与字母顺序相反的顺序排列。打印该列表，核实排列顺序确实变了。

3-9 晚餐嘉宾：在完成练习3-4~练习3-7时编写的程序之一中，使用len()打印一条消息，指出你邀请了多少位嘉宾来与你共进晚餐。

3-10 尝试使用各个函数：想想可存储到列表中的东西，如山岳、河流、国家、城市、语言或你喜欢的任何东西。编写一个程序，在其中创建一个包含这些元素的列表，然后，对于本章介绍的每个函数，都至少使用一次来处理这个列表。

3.4　使用列表时避免索引错误

刚开始使用列表时，经常会遇到一种错误。假设你有一个包含三个元素的列表，却要求获取第四个元素：

motorcycles = ['honda', 'yamaha', 'suzuki']
print(motorcycles[3])

这将导致索引错误：

Traceback (most recent call last):
  File "motorcycles.py", line 3, in <module>
    print(motorcycles[3])
IndexError: list index out of range

Python试图向你提供位于索引3处的元素，但它搜索列表motorcycles时，却发现索引3处没有元素。鉴于列表索引差一的特征，这种错误很常见。有些人从1开始数，因此以为第三个元素的索引为3；但在Python中，第三个元素的索引为2，因为索引是从0开始的。

索引错误意味着Python无法理解你指定的索引。程序发生索引错误时，请尝试将你指定的索引减1，然后再次运行程序，看看结果是否正确。

别忘了，每当需要访问最后一个列表元素时，都可使用索引-1。这在任何情况下都行之有效，即便你最后一次访问列表后，其长度发生了变化：

motorcycles = ['honda', 'yamaha', 'suzuki']
print(motorcycles[-1])

索引-1总是返回最后一个列表元素，这里为值'suzuki'：

'suzuki'

仅当列表为空时，这种访问最后一个元素的方式才会导致错误：

motorcycles = []
print(motorcycles[-1])

列表motorcycles不包含任何元素，因此Python返回一条索引错误消息：

Traceback (most recent call last):
  File "motorcyles.py", line 3, in <module>
    print(motorcycles[-1])
IndexError: list index out of range

注意　发生索引错误却找不到解决办法时，请尝试将列表或其长度打印出来。列表可能与你以为的截然不同，在程序对其进行了动态处理时尤其如此。通过查看列表或其包含的元素数，可帮助你找出这种逻辑错误。

动手试一试

3-11 有意引发错误：如果你还没有在程序中遇到过索引错误，就尝试引发一个这种错误。在你的一个程序中，修改其中的索引，以引发索引错误。关闭程序前，务必消除这个错误。

3.5　小结

在本章中，你学习了：列表是什么以及如何使用其中的元素；如何定义列表以及如何增删元素；如何对列表进行永久性排序，以及如何为展示列表而进行临时排序；如何确定列表的长度，以及在使用列表时如何避免索引错误。

在第4章，你将学习如何以更高效的方式处理列表元素。通过使用为数不多的几行代码来遍历列表元素，你就能高效地处理它们，即便列表包含数千乃至数百万个元素。

第 4 章　操作列表

在第3章，你学习了如何创建简单的列表，还学习了如何操作列表元素。在本章中，你将学习如何遍历整个列表，这只需要几行代码，无论列表有多长。循环让你能够对列表的每个元素都采取一个或一系列相同的措施，从而高效地处理任何长度的列表，包括包含数千乃至数百万个元素的列表。

4.1　遍历整个列表

你经常需要遍历列表的所有元素，对每个元素执行相同的操作。例如，在游戏中，可能需要将每个界面元素平移相同的距离；对于包含数字的列表，可能需要对每个元素执行相同的统计运算；在网站中，可能需要显示文章列表中的每个标题。需要对列表中的每个元素都执行相同的操作时，可使用Python中的for循环。

假设我们有一个魔术师名单，需要将其中每个魔术师的名字都打印出来。为此，我们可以分别获取名单中的每个名字，但这种做法会导致多个问题。例如，如果名单很长，将包含大量重复的代码。另外，每当名单的长度发生变化时，都必须修改代码。通过使用for循环，可让Python去处理这些问题。

下面使用for循环来打印魔术师名单中的所有名字：

magicians.py

magicians = ['alice', 'david', 'carolina'] ❶
for magician in magicians: ❷
    print(magician) ❸

首先，我们像第3章那样定义了一个列表（见❶）。接下来，我们定义了一个for循环（见❷）；这行代码让Python从列表magicians中取出一个名字，并将其存储在变量magician中。最后，我们让Python打印前面存储到变量magician中的名字（见❸）。这样，对于列表中的每个名字，Python都将重复执行❷处和❸处的代码行。你可以这样解读这些代码：对于列表magicians中的每位魔术师，都将其名字打印出来。输出很简单，就是列表中所有的姓名：

alice
david
carolina

4.1.1　深入地研究循环

循环这种概念很重要，因为它是让计算机自动完成重复工作的常见方式之一。例如，在前面的magicians.py中使用的简单循环中，Python将首先读取其中的第一行代码：

for magician in magicians:

这行代码让Python获取列表magicians中的第一个值（'alice'），并将其存储到变量magician中。接下来，Python读取下一行代码：

    print(magician)

它让Python打印magician的值——依然是'alice'。鉴于该列表还包含其他值，Python返回到循环的第一行：

for magician in magicians:

Python获取列表中的下一个名字——'david'，并将其存储到变量magician中，再执行下面这行代码：

    print(magician)

Python再次打印变量magician的值——当前为'david'。接下来，Python再次执行整个循环，对列表中的最后一个值——'carolina'进行处理。至此，列表中没有其他的值了，因此Python接着执行程序的下一行代码。在这个示例中，for循环后面没有其他的代码，因此程序就此结束。

刚开始使用循环时请牢记，对列表中的每个元素，都将执行循环指定的步骤，而不管列表包含多少个元素。如果列表包含一百万个元素，Python就重复执行指定的步骤一百万次，且通常速度非常快。

另外，编写for循环时，对于用于存储列表中每个值的临时变量，可指定任何名称。然而，选择描述单个列表元素的有意义的名称大有帮助。例如，对于小猫列表、小狗列表和一般性列表，像下面这样编写for循环的第一行代码是不错的选择：

for cat in cats:
for dog in dogs:
for item in list_of_items:

这些命名约定有助于你明白for循环中将对每个元素执行的操作。使用单数和复数式名称，可帮助你判断代码段处理的是单个列表元素还是整个列表。

4.1.2　在for循环中执行更多的操作

在for循环中，可对每个元素执行任何操作。下面来扩展前面的示例，对于每位魔术师，都打印一条消息，指出他的表演太精彩了。

magicians = ['alice', 'david', 'carolina']
for magician in magicians:
    print(magician.title() + ", that was a great trick!") ❶

相比于前一个示例，唯一的不同是对于每位魔术师，都打印了一条以其名字为抬头的消息（见❶）。这个循环第一次迭代时，变量magician的值为'alice'，因此Python打印的第一条消息的抬头为'Alice'。第二次迭代时，消息的抬头为'David'，而第三次迭代时，抬头为'Carolina'。

下面的输出表明，对于列表中的每位魔术师，都打印了一条个性化消息：

Alice, that was a great trick!
David, that was a great trick!
Carolina, that was a great trick!

在for循环中，想包含多少行代码都可以。在代码行for magician in magicians后面，每个缩进的代码行都是循环的一部分，且将针对列表中的每个值都执行一次。因此，可对列表中的每个值执行任意次数的操作。

下面再添加一行代码，告诉每位魔术师，我们期待他的下一次表演：

magicians = ['alice', 'david', 'carolina']
for magician in magicians:
    print(magician.title() + ", that was a great trick!")
    print("I can't wait to see your next trick, " + magician.title() + ".\n") ❶

由于两条print语句都缩进了，因此它们都将针对列表中的每位魔术师执行一次。第二条print语句中的换行符"\n"（见❶）在每次迭代结束后都插入一个空行，从而整洁地将针对各位魔术师的消息编组：

Alice, that was a great trick!
I can't wait to see your next trick, Alice.

David, that was a great trick!
I can't wait to see your next trick, David.

Carolina, that was a great trick!
I can't wait to see your next trick, Carolina.

在for循环中，想包含多少行代码都可以。实际上，你会发现使用for循环对每个元素执行众多不同的操作很有用。

4.1.3　在for循环结束后执行一些操作

for循环结束后再怎么做呢？通常，你需要提供总结性输出或接着执行程序必须完成的其他任务。

在for循环后面，没有缩进的代码都只执行一次，而不会重复执行。下面来打印一条向全体魔术师致谢的消息，感谢他们的精彩表演。想要在打印给各位魔术师的消息后面打印一条给全体魔术师的致谢消息，需要将相应的代码放在for循环后面，且不缩进：

magicians = ['alice', 'david', 'carolina']
for magician in magicians:
    print(magician.title() + ", that was a great trick!")
    print("I can't wait to see your next trick, " + magician.title() + ".\n")

print("Thank you, everyone. That was a great magic show!") ❶

你在前面看到了，开头两条print语句针对列表中每位魔术师重复执行。然而，由于第三条print语句没有缩进，因此只执行一次：

Alice, that was a great trick!
I can't wait to see your next trick, Alice.

David, that was a great trick!
I can't wait to see your next trick, David.

Carolina, that was a great trick!
I can't wait to see your next trick, Carolina.

Thank you, everyone. That was a great magic show!

使用for循环处理数据是一种对数据集执行整体操作的不错的方式。例如，你可能使用for循环来初始化游戏——遍历角色列表，将每个角色都显示到屏幕上；再在循环后面添加一个不缩进的代码块，在屏幕上绘制所有角色后显示一个Play Now按钮。

4.2　避免缩进错误

Python根据缩进来判断代码行与前一个代码行的关系。在前面的示例中，向各位魔术师显示消息的代码行是for循环的一部分，因为它们缩进了。Python通过使用缩进让代码更易读；简单地说，它要求你使用缩进让代码整洁而结构清晰。在较长的Python程序中，你将看到缩进程度各不相同的代码块，这让你对程序的组织结构有大致的认识。

当你开始编写必须正确缩进的代码时，需要注意一些常见的缩进错误。例如，有时候，程序员会将不需要缩进的代码块缩进，而对于必须缩进的代码块却忘了缩进。通过查看这样的错误示例，有助于你以后避开它们，以及在它们出现在程序中时进行修复。

下面来看一些较为常见的缩进错误。

4.2.1　忘记缩进

对于位于for语句后面且属于循环组成部分的代码行，一定要缩进。如果你忘记缩进，Python会提醒你：

magicians.py

magicians = ['alice', 'david', 'carolina']
for magician in magicians:
print(magician) ❶

print语句（见❶）应缩进却没有缩进。Python没有找到期望缩进的代码块时，会让你知道哪行代码有问题。

  File "magicians.py", line 3
    print(magician)
        ^
IndentationError: expected an indented block

通常，将紧跟在for语句后面的代码行缩进，可消除这种缩进错误。

4.2.2　忘记缩进额外的代码行

有时候，循环能够运行而不会报告错误，但结果可能会出乎意料。试图在循环中执行多项任务，却忘记缩进其中的一些代码行时，就会出现这种情况。

例如，如果忘记缩进循环中的第2行代码（它告诉每位魔术师，我们期待他的下一次表演），就会出现这种情况：

magicians = ['alice', 'david', 'carolina']
for magician in magicians:
    print(magician.title() + ", that was a great trick!")
print("I can't wait to see your next trick, " + magician.title() + ".\n") ❶

第二条print语句（见❶）原本需要缩进，但Python发现for语句后面有一行代码是缩进的，因此它没有报告错误。最终的结果是，对于列表中的每位魔术师，都执行了第一条print语句，因为它缩进了；而第二条print语句没有缩进，因此它只在循环结束后执行一次。由于变量magician的终值为'carolina'，因此只有她收到了消息“looking forward to the next trick”：

Alice, that was a great trick!
David, that was a great trick!
Carolina, that was a great trick!
I can't wait to see your next trick, Carolina.

这是一个逻辑错误。从语法上看，这些Python代码是合法的，但由于存在逻辑错误，结果并不符合预期。如果你预期某项操作将针对每个列表元素都执行一次，但它却只执行了一次，请确定是否需要将一行或多行代码缩进。

4.2.3　不必要的缩进

如果你不小心缩进了无需缩进的代码行，Python将指出这一点：

hello_world.py

message = "Hello Python world!"
    print(message) ❶

print语句（见❶）无需缩进，因为它并不属于前一行代码，因此Python将指出这种错误：

  File "hello_world.py", line 2
    print(message)
    ^
IndentationError: unexpected indent

为避免意外缩进错误，请只缩进需要缩进的代码。在前面编写的程序中，只有要在for循环中对每个元素执行的代码需要缩进。

4.2.4　循环后不必要的缩进

如果你不小心缩进了应在循环结束后执行的代码，这些代码将针对每个列表元素重复执行。在有些情况下，这可能导致Python报告语法错误，但在大多数情况下，这只会导致逻辑错误。

例如，如果不小心缩进了感谢全体魔术师精彩表演的代码行，结果将如何呢？

magicians = ['alice', 'david', 'carolina']
for magician in magicians:
    print(magician.title() + ", that was a great trick!")
    print("I can't wait to see your next trick, " + magician.title() + ".\n")

    print("Thank you everyone, that was a great magic show!") ❶

由于❶处的代码行被缩进，它将针对列表中的每位魔术师执行一次，如❷所示：

Alice, that was a great trick!
I can't wait to see your next trick, Alice.

Thank you everyone, that was a great magic show! ❷
David, that was a great trick!
I can't wait to see your next trick, David.

Thank you everyone, that was a great magic show! ❷
Carolina, that was a great trick!
I can't wait to see your next trick, Carolina.

Thank you everyone, that was a great magic show! ❷

这也是一个逻辑错误，与4.2.2节的错误类似。Python不知道你的本意，只要代码符合语法，它就会运行。如果原本只应执行一次的操作执行了多次，请确定你是否不应该缩进执行该操作的代码。

4.2.5　遗漏了冒号

for语句末尾的冒号告诉Python，下一行是循环的第一行。

magicians = ['alice', 'david', 'carolina']
for magician in magicians ❶
    print(magician)

如果你不小心遗漏了冒号，如❶所示，将导致语法错误，因为Python不知道你意欲何为。这种错误虽然易于消除，但并不那么容易发现。程序员为找出这样的单字符错误，花费的时间多得令人惊讶。这样的错误之所以难以发现，是因为通常在我们的意料之外。

动手试一试

4-1 比萨：想出至少三种你喜欢的比萨，将其名称存储在一个列表中，再使用for循环将每种比萨的名称都打印出来。

修改这个for循环，使其打印包含比萨名称的句子，而不仅仅是比萨的名称。对于每种比萨，都显示一行输出，如“I like pepperoni pizza”。

在程序末尾添加一行代码，它不在for循环中，指出你有多喜欢比萨。输出应包含针对每种比萨的消息，还有一个总结性句子，如“I really love pizza!”。

4-2 动物：想出至少三种有共同特征的动物，将这些动物的名称存储在一个列表中，再使用for循环将每种动物的名称都打印出来。

修改这个程序，使其针对每种动物都打印一个句子，如“A dog would make a great pet”。

在程序末尾添加一行代码，指出这些动物的共同之处，如打印诸如“Any of these animals would make a great pet!”这样的句子。

4.3　创建数值列表

需要存储一组数字的原因有很多，例如，在游戏中，需要跟踪每个角色的位置，还可能需要跟踪玩家的几个最高得分。在数据可视化中，处理的几乎都是由数字（如温度、距离、人口数量、经度和纬度等）组成的集合。

列表非常适合用于存储数字集合，而Python提供了很多工具，可帮助你高效地处理数字列表。明白如何有效地使用这些工具后，即便列表包含数百万个元素，你编写的代码也能运行得很好。

4.3.1　使用函数range()

Python函数range()让你能够轻松地生成一系列的数字。例如，可以像下面这样使用函数range()来打印一系列的数字：

numbers.py

for value in range(1,5):
    print(value)

上述代码好像应该打印数字1~5，但实际上它不会打印数字5：

1
2
3
4

在这个示例中，range()只是打印数字1~4，这是你在编程语言中经常看到的差一行为的结果。函数range()让Python从你指定的第一个值开始数，并在到达你指定的第二个值后停止，因此输出不包含第二个值（这里为5）。

要打印数字1~5，需要使用range(1,6)：

for value in range(1,6):
    print(value)

这样，输出将从1开始，到5结束：

1
2
3
4
5

使用range()时，如果输出不符合预期，请尝试将指定的值加1或减1。

4.3.2　使用range()创建数字列表

要创建数字列表，可使用函数list()将range()的结果直接转换为列表。如果将range()作为list()的参数，输出将为一个数字列表。

在前一节的示例中，我们打印了一系列数字。要将这些数字转换为一个列表，可使用list()：

numbers = list(range(1,6))
print(numbers)

结果如下：

[1, 2, 3, 4, 5]

使用函数range()时，还可指定步长。例如，下面的代码打印1~10内的偶数：

even_numbers.py

even_numbers = list(range(2,11,2))
print(even_numbers)

在这个示例中，函数range()从2开始数，然后不断地加2，直到达到或超过终值（11），因此输出如下：

[2, 4, 6, 8, 10]

使用函数range()几乎能够创建任何需要的数字集，例如，如何创建一个列表，其中包含前10个整数（即1~10）的平方呢？在Python中，两个星号（**）表示乘方运算。下面的代码演示了如何将前10个整数的平方加入到一个列表中：

squares.py

squares = [] ❶
for value in range(1,11): ❷
    square = value**2 ❸
    squares.append(square) ❹

print(squares) ❺

首先，我们创建了一个空列表（见❶）；接下来，使用函数range()让Python遍历1~10的值（见❷）。在循环中，计算当前值的平方，并将结果存储到变量square中（见❸）。然后，将新计算得到的平方值附加到列表squares末尾（见❹）。最后，循环结束后，打印列表squares（见❺）：

[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

为让这些代码更简洁，可不使用临时变量square，而直接将每个计算得到的值附加到列表末尾：

squares = []
for value in range(1,11):
    squares.append(value**2) ❶

print(squares)

❶处的代码与squares.py中❸处和❹处的代码等效。在循环中，计算每个值的平方，并立即将结果附加到列表squares的末尾。

创建更复杂的列表时，可使用上述两种方法中的任何一种。有时候，使用临时变量会让代码更易读；而在其他情况下，这样做只会让代码无谓地变长。你首先应该考虑的是，编写清晰易懂且能完成所需功能的代码；等到审核代码时，再考虑采用更高效的方法。

4.3.3　对数字列表执行简单的统计计算

有几个专门用于处理数字列表的Python函数。例如，你可以轻松地找出数字列表的最大值、最小值和总和：

>>> digits = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0]
>>> min(digits)
0
>>> max(digits)
9
>>> sum(digits)
45

注意　出于版面考虑，本节使用的数字列表都很短，但这里介绍的知识也适用于包含数百万个数字的列表。

4.3.4　列表解析

前面介绍的生成列表squares的方式包含三四行代码，而列表解析让你只需编写一行代码就能生成这样的列表。列表解析将for循环和创建新元素的代码合并成一行，并自动附加新元素。面向初学者的书籍并非都会介绍列表解析，这里之所以介绍列表解析，是因为等你开始阅读他人编写的代码时，很可能会遇到它们。

下面的示例使用列表解析创建你在前面看到的平方数列表：

squares.py

squares = [value**2 for value in range(1,11)]
print(squares)

要使用这种语法，首先指定一个描述性的列表名，如squares；然后，指定一个左方括号，并定义一个表达式，用于生成你要存储到列表中的值。在这个示例中，表达式为value**2，它计算平方值。接下来，编写一个for循环，用于给表达式提供值，再加上右方括号。在这个示例中，for循环为for value in range(1,11)，它将值1~10提供给表达式value**2。请注意，这里的for语句末尾没有冒号。

结果与你在前面看到的平方数列表相同：

[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

要创建自己的列表解析，需要经过一定的练习，但能够熟练地创建常规列表后，你会发现这样做是完全值得的。当你觉得编写三四行代码来生成列表有点繁复时，就应考虑创建列表解析了。

动手试一试

4-3 数到20：使用一个for循环打印数字1~20（含）。

4-4 一百万：创建一个列表，其中包含数字1~1 000 000，再使用一个for循环将这些数字打印出来（如果输出的时间太长，按Ctrl + C停止输出，或关闭输出窗口）。

4-5 计算1~1 000 000的总和：创建一个列表，其中包含数字1~1 000 000，再使用min()和max()核实该列表确实是从1开始，到1 000 000结束的。另外，对这个列表调用函数sum()，看看Python将一百万个数字相加需要多长时间。

4-6 奇数：通过给函数range()指定第三个参数来创建一个列表，其中包含1~20的奇数；再使用一个for循环将这些数字都打印出来。

4-7 3的倍数：创建一个列表，其中包含3~30内能被3整除的数字；再使用一个for循环将这个列表中的数字都打印出来。

4-8 立方：将同一个数字乘三次称为立方。例如，在Python中，2的立方用2**3表示。请创建一个列表，其中包含前10个整数（即1~10）的立方，再使用一个for循环将这些立方数都打印出来。

4-9 立方解析：使用列表解析生成一个列表，其中包含前10个整数的立方。

4.4　使用列表的一部分

在第3章中，你学习了如何访问单个列表元素。在本章中，你一直在学习如何处理列表的所有元素。你还可以处理列表的部分元素——Python称之为切片。

4.4.1　切片

要创建切片，可指定要使用的第一个元素和最后一个元素的索引。与函数range()一样，Python在到达你指定的第二个索引前面的元素后停止。要输出列表中的前三个元素，需要指定索引0~3，这将输出分别为0、1和2的元素。

下面的示例处理的是一个运动队成员列表：

players.py

players = ['charles', 'martina', 'michael', 'florence', 'eli']
print(players[0:3]) ❶

❶处的代码打印该列表的一个切片，其中只包含三名队员。输出也是一个列表，其中包含前三名队员：

['charles', 'martina', 'michael']

你可以生成列表的任何子集，例如，如果你要提取列表的第2~4个元素，可将起始索引指定为1，并将终止索引指定为4：

players = ['charles', 'martina', 'michael', 'florence', 'eli']
print(players[1:4])

这一次，切片始于'martina'，终于'florence'：

['martina', 'michael', 'florence']

如果你没有指定第一个索引，Python将自动从列表开头开始：

players = ['charles', 'martina', 'michael', 'florence', 'eli']
print(players[:4])

由于没有指定起始索引，Python从列表开头开始提取：

['charles', 'martina', 'michael', 'florence']

要让切片终止于列表末尾，也可使用类似的语法。例如，如果要提取从第3个元素到列表末尾的所有元素，可将起始索引指定为2，并省略终止索引：

players = ['charles', 'martina', 'michael', 'florence', 'eli']
print(players[2:])

Python将返回从第3个元素到列表末尾的所有元素：

['michael', 'florence', 'eli']

无论列表多长，这种语法都能够让你输出从特定位置到列表末尾的所有元素。本书前面说过，负数索引返回离列表末尾相应距离的元素，因此你可以输出列表末尾的任何切片。例如，如果你要输出名单上的最后三名队员，可使用切片players[-3:]：

players = ['charles', 'martina', 'michael', 'florence', 'eli']
print(players[-3:])

上述代码打印最后三名队员的名字，即便队员名单的长度发生变化，也依然如此。

4.4.2　遍历切片

如果要遍历列表的部分元素，可在for循环中使用切片。在下面的示例中，我们遍历前三名队员，并打印他们的名字：

players = ['charles', 'martina', 'michael', 'florence', 'eli']

print("Here are the first three players on my team:")
for player in players[:3]: ❶
    print(player.title())

❶处的代码没有遍历整个队员列表，而只遍历前三名队员：

Here are the first three players on my team:
Charles
Martina
Michael

在很多情况下，切片都很有用。例如，编写游戏时，你可以在玩家退出游戏时将其最终得分加入到一个列表中。然后，为获取该玩家的三个最高得分，你可以将该列表按降序排列，再创建一个只包含前三个得分的切片。处理数据时，可使用切片来进行批量处理；编写Web应用程序时，可使用切片来分页显示信息，并在每页显示数量合适的信息。

4.4.3　复制列表

你经常需要根据既有列表创建全新的列表。下面来介绍复制列表的工作原理，以及复制列表可提供极大帮助的一种情形。

要复制列表，可创建一个包含整个列表的切片，方法是同时省略起始索引和终止索引（[:]）。这让Python创建一个始于第一个元素，终止于最后一个元素的切片，即复制整个列表。

例如，假设有一个列表，其中包含你最喜欢的三种食品，而你还想创建另一个列表，在其中包含一位朋友喜欢的所有食品。不过，你喜欢的食品，这位朋友都喜欢，因此你可以通过复制来创建这个列表：

foods.py

my_foods = ['pizza', 'falafel', 'carrot cake'] ❶
friend_foods = my_foods[:] ❷

print("My favorite foods are:")
print(my_foods)

print("\nMy friend's favorite foods are:")
print(friend_foods)

我们首先创建了一个名为my_foods的食品列表（见❶），然后创建了一个名为friend_foods的新列表（见❷）。我们在不指定任何索引的情况下从列表my_foods中提取一个切片，从而创建了这个列表的副本，再将该副本存储到变量friend_foods中。打印每个列表后，我们发现它们包含的食品相同：

My favorite foods are:
['pizza', 'falafel', 'carrot cake']

My friend's favorite foods are:
['pizza', 'falafel', 'carrot cake']

为核实我们确实有两个列表，下面在每个列表中都添加一种食品，并核实每个列表都记录了相应人员喜欢的食品：

my_foods = ['pizza', 'falafel', 'carrot cake']
friend_foods = my_foods[:] ❶

my_foods.append('cannoli') ❷
friend_foods.append('ice cream') ❸

print("My favorite foods are:")
print(my_foods)

print("\nMy friend's favorite foods are:")
print(friend_foods)

与前一个示例一样，我们首先将my_foods的元素复制到新列表friend_foods中（见❶）。接下来，在每个列表中都添加一种食品：在列表my_foods中添加'cannoli'（见❷），而在friend_foods中添加'ice cream'（见❸）。最后，打印这两个列表，核实这两种食品包含在正确的列表中。

My favorite foods are:
['pizza', 'falafel', 'carrot cake', 'cannoli'] ❹

My friend's favorite foods are:
['pizza', 'falafel', 'carrot cake', 'ice cream'] ❺

❹处的输出表明，'cannoli'包含在你喜欢的食品列表中，而'ice cream'没有。❺处的输出表明，'ice cream'包含在你朋友喜欢的食品列表中，而'cannoli'没有。倘若我们只是简单地将my_foods赋给friend_foods，就不能得到两个列表。例如，下例演示了在不使用切片的情况下复制列表的情况：

my_foods = ['pizza', 'falafel', 'carrot cake']

#这行不通
friend_foods = my_foods ❶

my_foods.append('cannoli')
friend_foods.append('ice cream')

print("My favorite foods are:")
print(my_foods)

print("\nMy friend's favorite foods are:")
print(friend_foods)

这里将my_foods赋给friend_foods，而不是将my_foods的副本存储到friend_foods（见❶）。这种语法实际上是让Python将新变量friend_foods关联到包含在my_foods中的列表，因此这两个变量都指向同一个列表。鉴于此，当我们将'cannoli'添加到my_foods中时，它也将出现在friend_foods中；同样，虽然'ice cream'好像只被加入到了friend_foods中，但它也将出现在这两个列表中。

输出表明，两个列表是相同的，这并非我们想要的结果：

My favorite foods are:
['pizza', 'falafel', 'carrot cake', 'cannoli', 'ice cream']

My friend's favorite foods are:
['pizza', 'falafel', 'carrot cake', 'cannoli', 'ice cream']

注意　现在暂时不要考虑这个示例中的细节。基本上，当你试图使用列表的副本时，如果结果出乎意料，请确认你像第一个示例那样使用切片复制了列表。

动手试一试

4-10 切片：选择你在本章编写的一个程序，在末尾添加几行代码，以完成如下任务。

打印消息“The first three items in the list are:”，再使用切片来打印列表的前三个元素。

打印消息“Three items from the middle of the list are:”，再使用切片来打印列表中间的三个元素。

打印消息“The last three items in the list are:”，再使用切片来打印列表末尾的三个元素。

4-11 你的比萨和我的比萨：在你为完成练习4-1而编写的程序中，创建比萨列表的副本，并将其存储到变量friend_pizzas中，再完成如下任务。

在原来的比萨列表中添加一种比萨。

在列表friend_pizzas中添加另一种比萨。

核实你有两个不同的列表。为此，打印消息“My favorite pizzas are:”，再使用一个for循环来打印第一个列表；打印消息“My friend's favorite pizzas are:”，再使用一个for循环来打印第二个列表。核实新增的比萨被添加到了正确的列表中。

4-12 使用多个循环：在本节中，为节省篇幅，程序foods.py的每个版本都没有使用for循环来打印列表。请选择一个版本的foods.py，在其中编写两个for循环，将各个食品列表都打印出来。

4.5　元组

列表非常适合用于存储在程序运行期间可能变化的数据集。列表是可以修改的，这对处理网站的用户列表或游戏中的角色列表至关重要。然而，有时候你需要创建一系列不可修改的元素，元组可以满足这种需求。Python将不能修改的值称为不可变的，而不可变的列表被称为元组。

4.5.1　定义元组

元组看起来犹如列表，但使用圆括号而不是方括号来标识。定义元组后，就可以使用索引来访问其元素，就像访问列表元素一样。

例如，如果有一个大小不应改变的矩形，可将其长度和宽度存储在一个元组中，从而确保它们是不能修改的：

dimensions.py

dimensions = (200, 50) ❶
print(dimensions[0]) ❷
print(dimensions[1])

我们首先定义了元组dimensions（见❶），为此我们使用了圆括号而不是方括号。接下来，我们分别打印该元组的各个元素，使用的语法与访问列表元素时使用的语法相同（见❷）：

200
50

下面来尝试修改元组dimensions中的一个元素，看看结果如何：

dimensions = (200, 50)
dimensions[0] = 250 ❶

❶处的代码试图修改第一个元素的值，导致Python返回类型错误消息。由于试图修改元组的操作是被禁止的，因此Python指出不能给元组的元素赋值：

Traceback (most recent call last):
  File "dimensions.py", line 3, in <module>
    dimensions[0] = 250
TypeError: 'tuple' object does not support item assignment

代码试图修改矩形的尺寸时，Python报告错误，这很好，因为这正是我们希望的。

4.5.2　遍历元组中的所有值

像列表一样，也可以使用for循环来遍历元组中的所有值：

dimensions = (200, 50)
for dimension in dimensions:
    print(dimension)

就像遍历列表时一样，Python返回元组中所有的元素：

200
50

4.5.3　修改元组变量

虽然不能修改元组的元素，但可以给存储元组的变量赋值。因此，如果要修改前述矩形的尺寸，可重新定义整个元组：

dimensions = (200, 50) ❶
print("Original dimensions:")
for dimension in dimensions:
    print(dimension)

dimensions = (400, 100) ❷
print("\nModified dimensions:") ❸
for dimension in dimensions:
    print(dimension)

我们首先定义了一个元组，并将其存储的尺寸打印了出来（见❶）；接下来，将一个新元组存储到变量dimensions中（见❷）；然后，打印新的尺寸（见❸）。这次，Python不会报告任何错误，因为给元组变量赋值是合法的：

Original dimensions:
200
50

Modified dimensions:
400
100

相比于列表，元组是更简单的数据结构。如果需要存储的一组值在程序的整个生命周期内都不变，可使用元组。

动手试一试

4-13 自助餐：有一家自助式餐馆，只提供五种简单的食品。请想出五种简单的食品，并将其存储在一个元组中。

使用一个for循环将该餐馆提供的五种食品都打印出来。

尝试修改其中的一个元素，核实Python确实会拒绝你这样做。

餐馆调整了菜单，替换了它提供的其中两种食品。请编写一个这样的代码块：给元组变量赋值，并使用一个for循环将新元组的每个元素都打印出来。

4.6　设置代码格式

随着你编写的程序越来越长，有必要了解一些代码格式设置约定。请花时间让你的代码尽可能易于阅读；让代码易于阅读有助于你掌握程序是做什么的，也可以帮助他人理解你编写的代码。

为确保所有人编写的代码的结构都大致一致，Python程序员都遵循一些格式设置约定。学会编写整洁的Python后，就能明白他人编写的Python代码的整体结构——只要他们和你遵循相同的指南。要成为专业程序员，应从现在开始就遵循这些指南，以养成良好的习惯。

4.6.1　格式设置指南

若要提出Python语言修改建议，需要编写Python改进提案（Python Enhancement Proposal，PEP）。PEP 8是最古老的PEP之一，它向Python程序员提供了代码格式设置指南。PEP 8的篇幅很长，但大都与复杂的编码结构相关。

Python格式设置指南的编写者深知，代码被阅读的次数比编写的次数多。代码编写出来后，调试时你需要阅读它；给程序添加新功能时，需要花很长的时间阅读代码；与其他程序员分享代码时，这些程序员也将阅读它们。

如果一定要在让代码易于编写和易于阅读之间做出选择，Python程序员几乎总是会选择后者。下面的指南可帮助你从一开始就编写出清晰的代码。

4.6.2　缩进

PEP 8建议每级缩进都使用四个空格，这既可提高可读性，又留下了足够的多级缩进空间。

在字处理文档中，大家常常使用制表符而不是空格来缩进。对于字处理文档来说，这样做的效果很好，但混合使用制表符和空格会让Python解释器感到迷惑。每款文本编辑器都提供了一种设置，可将输入的制表符转换为指定数量的空格。你在编写代码时应该使用制表符键，但一定要对编辑器进行设置，使其在文档中插入空格而不是制表符。

在程序中混合使用制表符和空格可能导致极难解决的问题。如果你混合使用了制表符和空格，可将文件中所有的制表符转换为空格，大多数编辑器都提供了这样的功能。

4.6.3　行长

很多Python程序员都建议每行不超过80字符。最初制定这样的指南时，在大多数计算机中，终端窗口每行只能容纳79字符；当前，计算机屏幕每行可容纳的字符数多得多，为何还要使用79字符的标准行长呢？这里有别的原因。专业程序员通常会在同一个屏幕上打开多个文件，使用标准行长可以让他们在屏幕上并排打开两三个文件时能同时看到各个文件的完整行。PEP 8还建议注释的行长都不超过72字符，因为有些工具为大型项目自动生成文档时，会在每行注释开头添加格式化字符。

PEP 8中有关行长的指南并非不可逾越的红线，有些小组将最大行长设置为99字符。在学习期间，你不用过多地考虑代码的行长，但别忘了，协作编写程序时，大家几乎都遵守PEP 8指南。在大多数编辑器中，都可设置一个视觉标志——通常是一条竖线，让你知道不能越过的界线在什么地方。

注意　附录B介绍了如何配置文本编辑器，以使其：在你按制表符键时插入四个空格；显示一条垂直参考线，帮助你遵守行长不能超过79字符的约定。

4.6.4　空行

要将程序的不同部分分开，可使用空行。你应该使用空行来组织程序文件，但也不能滥用；只要按本书的示例展示的那样做，就能掌握其中的平衡。例如，如果你有5行创建列表的代码，还有3行处理该列表的代码，那么用一个空行将这两部分隔开是合适的。然而，你不应使用三四个空行将它们隔开。

空行不会影响代码的运行，但会影响代码的可读性。Python解释器根据水平缩进情况来解读代码，但不关心垂直间距。

4.6.5　其他格式设置指南

PEP 8还有很多其他的格式设置建议，但这些指南针对的程序大都比目前为止本书提到的程序复杂。等介绍更复杂的Python结构时，我们再来分享相关的PEP 8指南。

动手试一试

4-14 PEP 8：请访问https://python.org/dev/peps/pep-0008/，阅读PEP 8格式设置指南。当前，这些指南适用的不多，但你可以大致浏览一下。

4-15 代码审核：从本章编写的程序中选择三个，根据PEP 8指南对它们进行修改。

每级缩进都使用四个空格。对你使用的文本编辑器进行设置，使其在你按Tab键时都插入四个空格；如果你还没有这样做，现在就去做吧（有关如何设置，请参阅附录B）。

每行都不要超过80字符。对你使用的编辑器进行设置，使其在第80个字符处显示一条垂直参考线。

不要在程序文件中过多地使用空行。

4.7　小结

在本章中，你学习了：如何高效地处理列表中的元素；如何使用for循环遍历列表，Python如何根据缩进来确定程序的结构以及如何避免一些常见的缩进错误；如何创建简单的数字列表，以及可对数字列表执行的一些操作；如何通过切片来使用列表的一部分和复制列表。你还学习了元组（它对不应变化的值提供了一定程度的保护），以及在代码变得越来越复杂时如何设置格式，使其易于阅读。

在第5章中，你将学习如何使用if语句在不同的条件下采取不同的措施；学习如何将一组较复杂的条件测试组合起来，并在满足特定条件时采取相应的措施。你还将学习如何在遍历列表时，通过使用if语句对特定元素采取特定的措施。

第 5 章　if语句

编程时经常需要检查一系列条件，并据此决定采取什么措施。在Python中，if语句让你能够检查程序的当前状态，并据此采取相应的措施。

在本章中，你将学习条件测试，以检查感兴趣的任何条件。你将学习简单的if语句，以及创建一系列复杂的if语句来确定当前到底处于什么情形。接下来，你将把学到的知识应用于列表，以编写for循环，以一种方式处理列表中的大多数元素，并以另一种不同的方式处理包含特定值的元素。

5.1　一个简单示例

下面是一个简短的示例，演示了如何使用if语句来正确地处理特殊情形。假设你有一个汽车列表，并想将其中每辆汽车的名称打印出来。对于大多数汽车，都应以首字母大写的方式打印其名称，但对于汽车名'bmw'，应以全大写的方式打印。下面的代码遍历一个列表，并以首字母大写的方式打印其中的汽车名，但对于汽车名'bmw'，以全大写的方式打印：

cars.py

cars = ['audi', 'bmw', 'subaru', 'toyota']

for car in cars:
    if car == 'bmw': ❶
        print(car.upper())
    else:
        print(car.title())

这个示例中的循环首先检查当前的汽车名是否是'bmw'（见❶）。如果是，就以全大写的方式打印它；否则就以首字母大写的方式打印：

Audi
BMW
Subaru
Toyota

这个示例涵盖了本章将介绍的很多概念。下面先来介绍可用来在程序中检查条件的测试。

5.2　条件测试

每条if语句的核心都是一个值为True或False的表达式，这种表达式被称为条件测试。Python根据条件测试的值为True还是False来决定是否执行if语句中的代码。如果条件测试的值为True，Python就执行紧跟在if语句后面的代码；如果为False，Python就忽略这些代码。

5.2.1　检查是否相等

大多数条件测试都将一个变量的当前值同特定值进行比较。最简单的条件测试检查变量的值是否与特定值相等：

>>> car = 'bmw' ❶
>>> car == 'bmw' ❷
True

我们首先使用一个等号将car的值设置为'bmw'（见❶），这种做法你已见过很多次。接下来，使用两个等号（==）检查car的值是否为'bmw'。这个相等运算符在它两边的值相等时返回True，否则返回False。在这个示例中，两边的值相等，因此Python返回True。

如果变量car的值不是'bmw'，上述测试将返回False：

>>> car = 'audi' ❶
>>> car == 'bmw' ❷
False

一个等号是陈述；对于❶处的代码，可解读为“将变量car的值设置为'audi'”。两个等号是发问；对于❷处的代码，可解读为“变量car的值是'bmw'吗？”。大多数编程语言使用等号的方式都与这里演示的相同。

5.2.2　检查是否相等时不考虑大小写

在Python中检查是否相等时区分大小写，例如，两个大小写不同的值会被视为不相等：

>>> car = 'Audi'
>>> car == 'audi'
False

如果大小写很重要，这种行为有其优点。但如果大小写无关紧要，而只想检查变量的值，可将变量的值转换为小写，再进行比较：

>>> car = 'Audi'
>>> car.lower() == 'audi'
True

无论值'Audi'的大小写如何，上述测试都将返回True，因为该测试不区分大小写。函数lower()不会修改存储在变量car中的值，因此进行这样的比较时不会影响原来的变量：

>>> car = 'Audi' ❶
>>> car.lower() == 'audi' ❷
True
>>> car ❸
'Audi'

在处，我们将首字母大写的字符串'Audi'存储在变量car中；在❷处，我们获取变量car的值并将其转换为小写，再将结果与字符串'audi'进行比较。这两个字符串相同，因此Python返回True。从❸处的输出可知，这个条件测试并没有影响存储在变量car中的值。

网站采用类似的方式让用户输入的数据符合特定的格式。例如，网站可能使用类似的测试来确保用户名是独一无二的，而并非只是与另一个用户名的大小写不同。用户提交新的用户名时，将把它转换为小写，并与所有既有用户名的小写版本进行比较。执行这种检查时，如果已经有用户名'john'（不管大小写如何），则用户提交用户名'John'时将遭到拒绝。

5.2.3　检查是否不相等

要判断两个值是否不等，可结合使用惊叹号和等号（!=），其中的惊叹号表示不，在很多编程语言中都如此。

下面再使用一条if语句来演示如何使用不等运算符。我们将把要求的比萨配料存储在一个变量中，再打印一条消息，指出顾客要求的配料是否是意式小银鱼（anchovies）：

toppings.py

requested_topping = 'mushrooms'

if requested_topping != 'anchovies': ❶
    print("Hold the anchovies!")

❶处的代码行将requested_topping的值与'anchovies'进行比较，如果它们不相等，Python将返回True，进而执行紧跟在if语句后面的代码；如果这两个值相等，Python将返回False，因此不执行紧跟在if语句后面的代码。

由于requested_topping的值不是'anchovies'，因此执行print语句：

Hold the anchovies!

你编写的大多数条件表达式都检查两个值是否相等，但有时候检查两个值是否不等的效率更高。

5.2.4　比较数字

检查数值非常简单，例如，下面的代码检查一个人是否是18岁：

>>> age = 18
>>> age == 18
True

你还可以检查两个数字是否不等，例如，下面的代码在提供的答案不正确时打印一条消息：

magic_number.py

answer = 17

if answer != 42: ❶
    print("That is not the correct answer. Please try again!")

answer（17）不是42，❶处的条件得到满足，因此缩进的代码块得以执行：

That is not the correct answer. Please try again!

条件语句中可包含各种数学比较，如小于、小于等于、大于、大于等于：

>>> age = 19
>>> age < 21
True
>>> age <= 21
True
>>> age > 21
False
>>> age >= 21
False

在if语句中可使用各种数学比较，这让你能够直接检查关心的条件。

5.2.5　检查多个条件

你可能想同时检查多个条件，例如，有时候你需要在两个条件都为True时才执行相应的操作，而有时候你只要求一个条件为True时就执行相应的操作。在这些情况下，关键字and和or可助你一臂之力。

1. 使用and检查多个条件

要检查是否两个条件都为True，可使用关键字and将两个条件测试合而为一；如果每个测试都通过了，整个表达式就为True；如果至少有一个测试没有通过，整个表达式就为False。

例如，要检查是否两个人都不小于21岁，可使用下面的测试：

>>> age_0 = 22 ❶
>>> age_1 = 18
>>> age_0 >= 21 and age_1 >= 21 ❷
False
>>> age_1 = 22 ❸
>>> age_0 >= 21 and age_1 >= 21
True

在❶处，我们定义了两个用于存储年龄的变量：age_0和age_1。在❷处，我们检查这两个变量是否都大于或等于21；左边的测试通过了，但右边的测试没有通过，因此整个条件表达式的结果为False。在❸处，我们将age_1改为22，这样age_1的值大于21，因此两个测试都通过了，导致整个条件表达式的结果为True。

为改善可读性，可将每个测试都分别放在一对括号内，但并非必须这样做。如果你使用括号，测试将类似于下面这样：

(age_0 >= 21) and (age_1 >= 21)

2. 使用or检查多个条件

关键字or也能够让你检查多个条件，但只要至少有一个条件满足，就能通过整个测试。仅当两个测试都没有通过时，使用or的表达式才为False。

下面再次检查两个人的年龄，但检查的条件是至少有一个人的年龄不小于21岁：

>>> age_0 = 22 ❶
>>> age_1 = 18
>>> age_0 >= 21 or age_1 >= 21 ❷
True
>>> age_0 = 18 ❸
>>> age_0 >= 21 or age_1 >= 21
False

同样，我们首先定义了两个用于存储年龄的变量（见❶）。由于❷处对age_0的测试通过了，因此整个表达式的结果为True。接下来，我们将age_0减小为18；在❸处的测试中，两个测试都没有通过，因此整个表达式的结果为False。

5.2.6　检查特定值是否包含在列表中

有时候，执行操作前必须检查列表是否包含特定的值。例如，结束用户的注册过程前，可能需要检查他提供的用户名是否已包含在用户名列表中。在地图程序中，可能需要检查用户提交的位置是否包含在已知位置列表中。

要判断特定的值是否已包含在列表中，可使用关键字in。来看你可能为比萨店编写的一些代码；这些代码首先创建一个列表，其中包含用户点的比萨配料，然后检查特定的配料是否包含在该列表中。

>>> requested_toppings = ['mushrooms', 'onions', 'pineapple']
>>> 'mushrooms' in requested_toppings ❶
True
>>> 'pepperoni' in requested_toppings ❷
False

在❶处和❷处，关键字in让Python检查列表requested_toppings是否包含'mushrooms'和'pepperoni'。这种技术很有用，它让你能够在创建一个列表后，轻松地检查其中是否包含特定的值。

5.2.7　检查特定值是否不包含在列表中

还有些时候，确定特定的值未包含在列表中很重要；在这种情况下，可使用关键字not in。例如，如果有一个列表，其中包含被禁止在论坛上发表评论的用户，就可在允许用户提交评论前检查他是否被禁言：

banned_users.py

banned_users = ['andrew', 'carolina', 'david']
user = 'marie'

if user not in banned_users: ❶
    print(user.title() + ", you can post a response if you wish.")

❶处的代码行明白易懂：如果user的值未包含在列表banned_users中，Python将返回True，进而执行缩进的代码行。

用户'marie'未包含在列表banned_users中，因此她将看到一条邀请她发表评论的消息：

Marie, you can post a response if you wish.

5.2.8　布尔表达式

随着你对编程的了解越来越深入，将遇到术语布尔表达式，它不过是条件测试的别名。与条件表达式一样，布尔表达式的结果要么为True，要么为False。

布尔值通常用于记录条件，如游戏是否正在运行，或用户是否可以编辑网站的特定内容：

game_active = True
can_edit = False

在跟踪程序状态或程序中重要的条件方面，布尔值提供了一种高效的方式。

动手试一试

5-1 条件测试：编写一系列条件测试；将每个测试以及你对其结果的预测和实际结果都打印出来。你编写的代码应类似于下面这样：

car = 'subaru'
print("Is car == 'subaru'? I predict True.")
print(car == 'subaru')

print("\nIs car == 'audi'? I predict False.")
print(car == 'audi')

详细研究实际结果，直到你明白了它为何为True或False。

创建至少10个测试，且其中结果分别为True和False的测试都至少有5个。

5-2 更多的条件测试：你并非只能创建10个测试。如果你想尝试做更多的比较，可再编写一些测试，并将它们加入到conditional_tests.py中。对于下面列出的各种测试，至少编写一个结果为True和False的测试。

检查两个字符串相等和不等。

使用函数lower()的测试。

检查两个数字相等、不等、大于、小于、大于等于和小于等于。

使用关键字and和or的测试。

测试特定的值是否包含在列表中。

测试特定的值是否未包含在列表中。

5.3　if语句

理解条件测试后，就可以开始编写if语句了。if语句有很多种，选择使用哪种取决于要测试的条件数。前面讨论条件测试时，列举了多个if语句示例，下面更深入地讨论这个主题。

5.3.1　简单的if语句

最简单的if语句只有一个测试和一个操作：

if conditional_test:
     do something

在第1行中，可包含任何条件测试，而在紧跟在测试后面的缩进代码块中，可执行任何操作。如果条件测试的结果为True，Python就会执行紧跟在if语句后面的代码；否则Python将忽略这些代码。

假设有一个表示某人年龄的变量，而你想知道这个人是否够投票的年龄，可使用如下代码：

voting.py

age = 19
if age >= 18: ❶
    print("You are old enough to vote!") ❷

在❶处，Python检查变量age的值是否大于或等于18；答案是肯定的，因此Python执行❷处缩进的print语句：

You are old enough to vote!

在if语句中，缩进的作用与for循环中相同。如果测试通过了，将执行if语句后面所有缩进的代码行，否则将忽略它们。

在紧跟在if语句后面的代码块中，可根据需要包含任意数量的代码行。下面在一个人够投票的年龄时再打印一行输出，问他是否登记了：

age = 19
if age >= 18:
    print("You are old enough to vote!")
    print("Have you registered to vote yet?")

条件测试通过了，而两条print语句都缩进了，因此它们都将执行：

You are old enough to vote!
Have you registered to vote yet?

如果age的值小于18，这个程序将不会有任何输出。

5.3.2　if-else语句

经常需要在条件测试通过了时执行一个操作，并在没有通过时执行另一个操作；在这种情况下，可使用Python提供的if-else语句。if-else语句块类似于简单的if语句，但其中的else语句让你能够指定条件测试未通过时要执行的操作。

下面的代码在一个人够投票的年龄时显示与前面相同的消息，同时在这个人不够投票的年龄时也显示一条消息：

age = 17
if age >= 18: ❶
    print("You are old enough to vote!")
    print("Have you registered to vote yet?")
else: ❷
    print("Sorry, you are too young to vote.")
    print("Please register to vote as soon as you turn 18!")

如果❶处的条件测试通过了，就执行第一个缩进的print语句块；如果测试结果为False，就执行❷处的else代码块。这次age小于18，条件测试未通过，因此执行else代码块中的代码：

Sorry, you are too young to vote.
Please register to vote as soon as you turn 18!

上述代码之所以可行，是因为只存在两种情形：要么够投票的年龄，要么不够。if-else结构非常适合用于要让Python执行两种操作之一的情形。在这种简单的if-else结构中，总是会执行两个操作中的一个。

5.3.3　if-elif-else结构

经常需要检查超过两个的情形，为此可使用Python提供的if-elif-else结构。Python只执行if-elif-else结构中的一个代码块，它依次检查每个条件测试，直到遇到通过了的条件测试。测试通过后，Python将执行紧跟在它后面的代码，并跳过余下的测试。

在现实世界中，很多情况下需要考虑的情形都超过两个。例如，来看一个根据年龄段收费的游乐场：

4岁以下免费；

4~18岁收费5美元；

18岁（含）以上收费10美元。

如果只使用一条if语句，如何确定门票价格呢？下面的代码确定一个人所属的年龄段，并打印一条包含门票价格的消息：

amusement_park.py

age = 12

if age < 4: ❶
    print("Your admission cost is $0.")
elif age < 18: ❷
    print("Your admission cost is $5.")
else: ❸
    print("Your admission cost is $10.")

❶处的if测试检查一个人是否不满4岁，如果是这样，Python就打印一条合适的消息，并跳过余下的测试。❷处的elif代码行其实是另一个if测试，它仅在前面的测试未通过时才会运行。在这里，我们知道这个人不小于4岁，因为第一个测试未通过。如果这个人未满18岁，Python将打印相应的消息，并跳过else代码块。如果if测试和elif测试都未通过，Python将运行❸处else代码块中的代码。

在这个示例中，❶处测试的结果为False，因此不执行其代码块。然而，第二个测试的结果为True（12小于18），因此将执行其代码块。输出为一个句子，向用户指出了门票价格：

Your admission cost is $5.

只要年龄超过17岁，前两个测试就都不能通过。在这种情况下，将执行else代码块，指出门票价格为10美元。

为让代码更简洁，可不在if-elif-else代码块中打印门票价格，而只在其中设置门票价格，并在它后面添加一条简单的print语句：

age = 12

if age < 4:
    price = 0 ❶
elif age < 18:
    price = 5 ❷
else:
    price = 10 ❸

print("Your admission cost is $" + str(price) + ".") ❹

❶处、❷处和❸处的代码行像前一个示例那样，根据人的年龄设置变量price的值。在if-elif-else结构中设置price的值后，一条未缩进的print语句❹会根据这个变量的值打印一条消息，指出门票的价格。

这些代码的输出与前一个示例相同，但if-elif-else结构的作用更小，它只确定门票价格，而不是在确定门票价格的同时打印一条消息。除效率更高外，这些修订后的代码还更容易修改：要调整输出消息的内容，只需修改一条而不是三条print语句。

5.3.4　使用多个elif代码块

可根据需要使用任意数量的elif代码块，例如，假设前述游乐场要给老年人打折，可再添加一个条件测试，判断顾客是否符合打折条件。下面假设对于65岁（含）以上的老人，可以半价（即5美元）购买门票：

age = 12

if age < 4:
    price = 0
elif age < 18:
    price = 5
elif age < 65: ❶
    price = 10
else: ❷
    price = 5

print("Your admission cost is $" + str(price) + ".")

这些代码大都未变。第二个elif代码块（见❶）通过检查确定年龄不到65岁后，才将门票价格设置为全票价格——10美元。请注意，在else代码块（见❷）中，必须将所赋的值改为5，因为仅当年龄超过65（含）时，才会执行这个代码块。

5.3.5　省略else代码块

Python并不要求if-elif结构后面必须有else代码块。在有些情况下，else代码块很有用；而在其他一些情况下，使用一条elif语句来处理特定的情形更清晰：

age = 12

if age < 4:
    price = 0
elif age < 18:
    price = 5
elif age < 65:
    price = 10
elif age >= 65: ❶
    price = 5

print("Your admission cost is $" + str(price) + ".")

❶处的elif代码块在顾客的年龄超过65（含）时，将价格设置为5美元，这比使用else代码块更清晰些。经过这样的修改后，每个代码块都仅在通过了相应的测试时才会执行。

else是一条包罗万象的语句，只要不满足任何if或elif中的条件测试，其中的代码就会执行，这可能会引入无效甚至恶意的数据。如果知道最终要测试的条件，应考虑使用一个elif代码块来代替else代码块。这样，你就可以肯定，仅当满足相应的条件时，你的代码才会执行。

5.3.6　测试多个条件

if-elif-else结构功能强大，但仅适合用于只有一个条件满足的情况：遇到通过了的测试后，Python就跳过余下的测试。这种行为很好，效率很高，让你能够测试一个特定的条件。

然而，有时候必须检查你关心的所有条件。在这种情况下，应使用一系列不包含elif和else代码块的简单if语句。在可能有多个条件为True，且你需要在每个条件为True时都采取相应措施时，适合使用这种方法。

下面再来看前面的比萨店示例。如果顾客点了两种配料，就需要确保在其比萨中包含这些配料：

toppings.py

requested_toppings = ['mushrooms', 'extra cheese'] ❶

if 'mushrooms' in requested_toppings: ❷
    print("Adding mushrooms.")
if 'pepperoni' in requested_toppings: ❸
    print("Adding pepperoni.")
if 'extra cheese' in requested_toppings: ❹
    print("Adding extra cheese.")

print("\nFinished making your pizza!")

我们首先创建了一个列表，其中包含顾客点的配料（见❶）。❷处的if语句检查顾客是否点了配料蘑菇（'mushrooms'），如果点了，就打印一条确认消息。❸处检查配料辣香肠（'pepperoni'）的代码也是一个简单的if语句，而不是elif或else语句；因此不管前一个测试是否通过，都将进行这个测试。❹处的代码检查顾客是否要求多加芝士（'extra cheese'）；不管前两个测试的结果如何，都会执行这些代码。每当这个程序运行时，都会进行这三个独立的测试。

在这个示例中，会检查每个条件，因此将在比萨中添加蘑菇并多加芝士：

Adding mushrooms.
Adding extra cheese.

Finished making your pizza!

如果像下面这样转而使用if-elif-else结构，代码将不能正确地运行，因为有一个测试通过后，就会跳过余下的测试：

requested_toppings = ['mushrooms', 'extra cheese']

if 'mushrooms' in requested_toppings:
    print("Adding mushrooms.")
elif 'pepperoni' in requested_toppings:
    print("Adding pepperoni.")
elif 'extra cheese' in requested_toppings:
    print("Adding extra cheese.")

print("\nFinished making your pizza!")

第一个测试检查列表中是否包含'mushrooms'，它通过了，因此将在比萨中添加蘑菇。然而，Python将跳过if-elif-else结构中余下的测试，不再检查列表中是否包含'extra cheese'和'pepperoni'。其结果是，将添加顾客点的第一种配料，但不会添加其他的配料：

Adding mushrooms.

Finished making your pizza!

总之，如果你只想执行一个代码块，就使用if-elif-else结构；如果要运行多个代码块，就使用一系列独立的if语句。

动手试一试

5-3 外星人颜色#1：假设在游戏中刚射杀了一个外星人，请创建一个名为alien_color的变量，并将其设置为'green'、'yellow'或'red'。

编写一条if语句，检查外星人是否是绿色的；如果是，就打印一条消息，指出玩家获得了5个点。

编写这个程序的两个版本，在一个版本中上述测试通过了，而在另一个版本中未通过（未通过测试时没有输出）。

5-4 外星人颜色#2：像练习5-3那样设置外星人的颜色，并编写一个if-else结构。

如果外星人是绿色的，就打印一条消息，指出玩家因射杀该外星人获得了5个点。

如果外星人不是绿色的，就打印一条消息，指出玩家获得了10个点。

编写这个程序的两个版本，在一个版本中执行if代码块，而在另一个版本中执行else代码块。

5-5 外星人颜色#3：将练习5-4中的if-else结构改为if-elif-else结构。

如果外星人是绿色的，就打印一条消息，指出玩家获得了5个点。

如果外星人是黄色的，就打印一条消息，指出玩家获得了10个点。

如果外星人是红色的，就打印一条消息，指出玩家获得了15个点。

编写这个程序的三个版本，它们分别在外星人为绿色、黄色和红色时打印一条消息。

5-6 人生的不同阶段：设置变量age的值，再编写一个if-elif-else结构，根据age的值判断处于人生的哪个阶段。

如果一个人的年龄小于2岁，就打印一条消息，指出他是婴儿。

如果一个人的年龄为2（含）～4岁，就打印一条消息，指出他正蹒跚学步。

如果一个人的年龄为4（含）～13岁，就打印一条消息，指出他是儿童。

如果一个人的年龄为13（含）～20岁，就打印一条消息，指出他是青少年。

如果一个人的年龄为20（含）～65岁，就打印一条消息，指出他是成年人。

如果一个人的年龄超过65（含）岁，就打印一条消息，指出他是老年人。

5-7 喜欢的水果：创建一个列表，其中包含你喜欢的水果，再编写一系列独立的if语句，检查列表中是否包含特定的水果。

将该列表命名为favorite_fruits，并在其中包含三种水果。

编写5条if语句，每条都检查某种水果是否包含在列表中，如果包含在列表中，就打印一条消息，如“You really like bananas!”。

5.4　使用if语句处理列表

通过结合使用if语句和列表，可完成一些有趣的任务：对列表中特定的值做特殊处理；高效地管理不断变化的情形，如餐馆是否还有特定的食材；证明代码在各种情形下都将按预期那样运行。

5.4.1　检查特殊元素

本章开头通过一个简单示例演示了如何处理特殊值'bmw'——它需要采用不同的格式进行打印。既然你对条件测试和if语句有了大致的认识，下面来进一步研究如何检查列表中的特殊值，并对其做合适的处理。

继续使用前面的比萨店示例。这家比萨店在制作比萨时，每添加一种配料都打印一条消息。通过创建一个列表，在其中包含顾客点的配料，并使用一个循环来指出添加到比萨中的配料，可以以极高的效率编写这样的代码：

toppings.py

requested_toppings = ['mushrooms', 'green peppers', 'extra cheese']

for requested_topping in requested_toppings:
    print("Adding " + requested_topping + ".")

print("\nFinished making your pizza!")

输出很简单，因为上述代码不过是一个简单的for循环：

Adding mushrooms.
Adding green peppers.
Adding extra cheese.

Finished making your pizza!

然而，如果比萨店的青椒用完了，该如何处理呢？为妥善地处理这种情况，可在for循环中包含一条if语句：

requested_toppings = ['mushrooms', 'green peppers', 'extra cheese']

for requested_topping in requested_toppings:
    if requested_topping == 'green peppers': ❶
        print("Sorry, we are out of green peppers right now.")
    else: ❷
        print("Adding " + requested_topping + ".")

print("\nFinished making your pizza!")

这里在比萨中添加每种配料前都进行检查。❶处的代码检查顾客点的是否是青椒，如果是，就显示一条消息，指出不能点青椒的原因。❷处的else代码块确保其他配料都将添加到比萨中。

输出表明，妥善地处理了顾客点的每种配料：

Adding mushrooms.
Sorry, we are out of green peppers right now.
Adding extra cheese.

Finished making your pizza!

5.4.2　确定列表不是空的

到目前为止，对于处理的每个列表都做了一个简单的假设，即假设它们都至少包含一个元素。我们马上就要让用户来提供存储在列表中的信息，因此不能再假设循环运行时列表不是空的。有鉴于此，在运行for循环前确定列表是否为空很重要。

下面在制作比萨前检查顾客点的配料列表是否为空。如果列表是空的，就向顾客确认他是否要点普通比萨；如果列表不为空，就像前面的示例那样制作比萨：

requested_toppings = [] ❶

if requested_toppings: ❷
    for requested_topping in requested_toppings:
        print("Adding " + requested_topping + ".")
    print("\nFinished making your pizza!")
else: ❸
    print("Are you sure you want a plain pizza?")

在这里，我们首先创建了一个空列表，其中不包含任何配料（见❶）。在❷处我们进行了简单检查，而不是直接执行for循环。在if语句中将列表名用在条件表达式中时，Python将在列表至少包含一个元素时返回True，并在列表为空时返回False。如果requested_toppings不为空，就运行与前一个示例相同的for循环；否则，就打印一条消息，询问顾客是否确实要点不加任何配料的普通比萨（见❸）。

在这里，这个列表为空，因此输出如下——询问顾客是否确实要点普通比萨：

Are you sure you want a plain pizza?

如果这个列表不为空，将显示在比萨中添加的各种配料的输出。

5.4.3　使用多个列表

顾客的要求往往五花八门，在比萨配料方面尤其如此。如果顾客要在比萨中添加炸薯条，该怎么办呢？可使用列表和if语句来确定能否满足顾客的要求。

来看看在制作比萨前如何拒绝怪异的配料要求。下面的示例定义了两个列表，其中第一个列表包含比萨店供应的配料，而第二个列表包含顾客点的配料。这次对于requested_toppings中的每个元素，都检查它是否是比萨店供应的配料，再决定是否在比萨中添加它：

available_toppings = ['mushrooms', 'olives', 'green peppers', ❶
                      'pepperoni', 'pineapple', 'extra cheese']

requested_toppings = ['mushrooms', 'french fries', 'extra cheese'] ❷

for requested_topping in requested_toppings: ❸
    if requested_topping in available_toppings: ❹
        print("Adding " + requested_topping + ".")
    else: ❺
        print("Sorry, we don't have " + requested_topping + ".")

print("\nFinished making your pizza!")

在❶处，我们定义了一个列表，其中包含比萨店供应的配料。请注意，如果比萨店供应的配料是固定的，也可使用一个元组来存储它们。在❷处，我们又创建了一个列表，其中包含顾客点的配料，请注意那个不同寻常的配料——'french fries'。在❸处，我们遍历顾客点的配料列表。在这个循环中，对于顾客点的每种配料，我们都检查它是否包含在供应的配料列表中（见❹）；如果答案是肯定的，就将其加入到比萨中，否则将运行else代码块（见❺）：打印一条消息，告诉顾客不供应这种配料。

这些代码的输出整洁而详实：

Adding mushrooms.
Sorry, we don't have french fries.
Adding extra cheese.

Finished making your pizza!

通过为数不多的几行代码，我们高效地处理了一种真实的情形！

动手试一试

5-8 以特殊方式跟管理员打招呼：创建一个至少包含5个用户名的列表，且其中一个用户名为'admin'。想象你要编写代码，在每位用户登录网站后都打印一条问候消息。遍历用户名列表，并向每位用户打印一条问候消息。

如果用户名为'admin'，就打印一条特殊的问候消息，如“Hello admin, would you like to see a status report?”。

否则，打印一条普通的问候消息，如“Hello Eric, thank you for logging in again”。

5-9 处理没有用户的情形：在为完成练习5-8编写的程序中，添加一条if语句，检查用户名列表是否为空。

如果为空，就打印消息“We need to find some users!”。

删除列表中的所有用户名，确定将打印正确的消息。

5-10 检查用户名：按下面的说明编写一个程序，模拟网站确保每位用户的用户名都独一无二的方式。

创建一个至少包含5个用户名的列表，并将其命名为current_users。

再创建一个包含5个用户名的列表，将其命名为new_users，并确保其中有一两个用户名也包含在列表current_users中。

遍历列表new_users，对于其中的每个用户名，都检查它是否已被使用。如果是这样，就打印一条消息，指出需要输入别的用户名；否则，打印一条消息，指出这个用户名未被使用。

确保比较时不区分大小写；换句话说，如果用户名'John'已被使用，应拒绝用户名'JOHN'。

5-11 序数：序数表示位置，如1st和2nd。大多数序数都以th结尾，只有1、2和3例外。

在一个列表中存储数字1~9。

遍历这个列表。

在循环中使用一个if-elif-else结构，以打印每个数字对应的序数。输出内容应为1st、2nd、3rd、4th、5th、6th、7th、8th和9th，但每个序数都独占一行。

5.5　设置if语句的格式

本章的每个示例都展示了良好的格式设置习惯。在条件测试的格式设置方面，PEP 8提供的唯一建议是，在诸如==、>=和<=等比较运算符两边各添加一个空格，例如，if age < 4:要比if age<4:好。

这样的空格不会影响Python对代码的解读，而只是让代码阅读起来更容易。

动手试一试

5-12 设置if语句的格式：审核你在本章编写的程序，确保正确地设置了条件测试的格式。

5-13 自己的想法：与刚拿起本书时相比，现在你是一名能力更强的程序员了。鉴于你对如何在程序中模拟现实情形有了更深入的认识，你可以考虑使用程序来解决一些问题。随着编程技能不断提高，你可能想解决一些问题，请将这方面的想法记录下来。想想你可能想编写的游戏、想研究的数据集以及想创建的Web应用程序。

5.6　小结

在本章中，你学习了如何编写结果要么为True要么为False的条件测试。你学习了如何编写简单的if语句、if-else语句和if-elif-else结构。在程序中，你使用了这些结构来测试特定的条件，以确定这些条件是否满足。你学习了如何在利用高效的for循环的同时，以不同于其他元素的方式对特定的列表元素进行处理。你还再次学习了Python就代码格式方面提出的建议，这可确保即便你编写的程序越来越复杂，其代码依然易于阅读和理解。

在第6章，你将学习Python字典。字典类似于列表，但让你能够将不同的信息关联起来。你将学习如何创建和遍历字典，以及如何将字典同列表和if语句结合起来使用。学习字典让你能够模拟更多现实世界的情形。

第 6 章　字典

在本章中，你将学习能够将相关信息关联起来的Python字典。你将学习如何访问和修改字典中的信息。鉴于字典可存储的信息量几乎不受限制，因此我们会演示如何遍历字典中的数据。另外，你还将学习存储字典的列表、存储列表的字典和存储字典的字典。

理解字典后，你就能够更准确地为各种真实物体建模。你可以创建一个表示人的字典，然后想在其中存储多少信息就存储多少信息：姓名、年龄、地址、职业以及要描述的任何方面。你还能够存储任意两种相关的信息，如一系列单词及其含义，一系列人名及其喜欢的数字，以及一系列山脉及其海拔等。

6.1　一个简单的字典

来看一个游戏，其中包含一些外星人，这些外星人的颜色和点数各不相同。下面是一个简单的字典，存储了有关特定外星人的信息：

alien.py

alien_0 = {'color': 'green', 'points': 5}

print(alien_0['color'])
print(alien_0['points'])

字典alien_0存储了外星人的颜色和点数。使用两条print语句来访问并打印这些信息，如下所示：

green
5

与大多数编程概念一样，要熟练使用字典，也需要一段时间的练习。使用字典一段时间后，你就会明白为何它们能够高效地模拟现实世界中的情形。

6.2　使用字典

在Python中，字典是一系列键—值对。每个键都与一个值相关联，你可以使用键来访问与之相关联的值。与键相关联的值可以是数字、字符串、列表乃至字典。事实上，可将任何Python对象用作字典中的值。

在Python中，字典用放在花括号{}中的一系列键—值对表示，如前面的示例所示：

alien_0 = {'color': 'green', 'points': 5}

键—值对是两个相关联的值。指定键时，Python将返回与之相关联的值。键和值之间用冒号分隔，而键—值对之间用逗号分隔。在字典中，你想存储多少个键—值对都可以。

最简单的字典只有一个键—值对，如下述修改后的字典alien_0所示：

alien_0 = {'color': 'green'}

这个字典只存储了一项有关alien_0的信息，具体地说是这个外星人的颜色。在这个字典中，字符串'color'是一个键，与之相关联的值为'green'。

6.2.1　访问字典中的值

要获取与键相关联的值，可依次指定字典名和放在方括号内的键，如下所示：

alien_0 = {'color': 'green'}
print(alien_0['color'])

这将返回字典alien_0中与键'color'相关联的值：

green

字典中可包含任意数量的键—值对。例如，下面是最初的字典alien_0，其中包含两个键—值对：

alien_0 = {'color': 'green', 'points': 5}

现在，你可以访问外星人alien_0的颜色和点数。如果玩家射杀了这个外星人，你就可以使用下面的代码来确定玩家应获得多少个点：

alien_0 = {'color': 'green', 'points': 5}

new_points = alien_0['points'] ❶
print("You just earned " + str(new_points) + " points!") ❷

上述代码首先定义了一个字典，然后从这个字典中获取与键'points'相关联的值（见❶），并将这个值存储在变量new_points中。接下来，将这个整数转换为字符串，并打印一条消息，指出玩家获得了多少个点（见❷）：

You just earned 5 points!

如果你在有外星人被射杀时都运行这段代码，就会获取该外星人的点数。

6.2.2　添加键—值对

字典是一种动态结构，可随时在其中添加键—值对。要添加键—值对，可依次指定字典名、用方括号括起的键和相关联的值。

下面在字典alien_0中添加两项信息：外星人的 x 坐标和 y 坐标，让我们能够在屏幕的特定位置显示该外星人。我们将这个外星人放在屏幕左边缘，且离屏幕上边缘25像素的地方。由于屏幕坐标系的原点通常为左上角，因此要将该外星人放在屏幕左边缘，可将 x 坐标设置为0；要将该外星人放在离屏幕顶部25像素的地方，可将 y 坐标设置为25，如下所示：

alien_0 = {'color': 'green', 'points': 5}
print(alien_0)

alien_0['x_position'] = 0 ❶
alien_0['y_position'] = 25 ❷
print(alien_0)

我们首先定义了前面一直在使用的字典，然后打印这个字典，以显示其信息快照。在❶处，我们在这个字典中新增了一个键—值对，其中的键为'x_position'，而值为0。在❷处，我们重复这样的操作，但使用的键为'y_position'。打印修改后的字典时，将看到这两个新增的键—值对：

{'color': 'green', 'points': 5}
{'color': 'green', 'points': 5, 'y_position': 25, 'x_position': 0}

这个字典的最终版本包含四个键—值对，其中原来的两个指定外星人的颜色和点数，而新增的两个指定位置。注意，键—值对的排列顺序与添加顺序不同。Python不关心键—值对的添加顺序，而只关心键和值之间的关联关系。

6.2.3　先创建一个空字典

有时候，在空字典中添加键—值对是为了方便，而有时候必须这样做。为此，可先使用一对空的花括号定义一个字典，再分行添加各个键—值对。例如，下例演示了如何以这种方式创建字典alien_0：

alien_0 = {}

alien_0['color'] = 'green'
alien_0['points'] = 5

print(alien_0)

这里首先定义了空字典alien_0，再在其中添加颜色和点数，得到前述示例一直在使用的字典：

{'color': 'green', 'points': 5}

使用字典来存储用户提供的数据或在编写能自动生成大量键—值对的代码时，通常都需要先定义一个空字典。

6.2.4　修改字典中的值

要修改字典中的值，可依次指定字典名、用方括号括起的键以及与该键相关联的新值。例如，假设随着游戏的进行，需要将一个外星人从绿色改为黄色：

alien_0 = {'color': 'green'}
print("The alien is " + alien_0['color'] + ".")

alien_0['color'] = 'yellow'
print("The alien is now " + alien_0['color'] + ".")

我们首先定义了一个表示外星人alien_0的字典，其中只包含这个外星人的颜色。接下来，我们将与键'color'相关联的值改为'yellow'。输出表明，这个外星人确实从绿色变成了黄色：

The alien is green.
The alien is now yellow.

来看一个更有趣的例子：对一个能够以不同速度移动的外星人的位置进行跟踪。为此，我们将存储该外星人的当前速度，并据此确定该外星人将向右移动多远：

alien_0 = {'x_position': 0, 'y_position': 25, 'speed': 'medium'}
print("Original x-position: " + str(alien_0['x_position']))

# 向右移动外星人
# 据外星人当前速度决定将其移动多远
if alien_0['speed'] == 'slow': ❶
    x_increment = 1
elif alien_0['speed'] == 'medium':
    x_increment = 2
else:
    # 这个外星人的速度一定很快
    x_increment = 3

# 新位置等于老位置加上增量
alien_0['x_position'] = alien_0['x_position'] + x_increment ❷

print("New x-position: " + str(alien_0['x_position']))

我们首先定义了一个外星人，其中包含初始的x坐标和y坐标，还有速度'medium'。出于简化考虑，我们省略了颜色和点数，但即便包含这些键-值对，这个示例的工作原理也不会有任何变化。我们还打印了x_position的初始值，旨在让用户知道这个外星人向右移动了多远。

在❶处，使用了一个if-elif-else结构来确定外星人应向右移动多远，并将这个值存储在变量x_increment中。如果外星人的速度为'slow'，它将向右移动一个单位；如果速度为'medium'，将向右移动两个单位；如果为'fast'，将向右移动三个单位。确定移动量后，将其与x_position的当前值相加（见❷），再将结果关联到字典中的键x_position。

由于这是一个速度中等的外星人，因此其位置将向右移动两个单位：

Original x-position: 0
New x-position: 2

这种技术很棒：通过修改外星人字典中的值，可改变外星人的行为。例如，要将这个速度中等的外星人变成速度很快的外星人，可添加如下代码行：

alien_0['speed'] = 'fast'

这样，再次运行这些代码时，其中的if-elif-else结构将把一个更大的值赋给变量x_increment。

6.2.5　删除键—值对

对于字典中不再需要的信息，可使用del语句将相应的键—值对彻底删除。使用del语句时，必须指定字典名和要删除的键。

例如，下面的代码从字典alien_0中删除键'points'及其值：

alien_0 = {'color': 'green', 'points': 5}
print(alien_0)

del alien_0['points'] ❶
print(alien_0)

❶处的代码行让Python将键'points'从字典alien_0中删除，同时删除与这个键相关联的值。输出表明，键'points'及其值5已从字典中删除，但其他键—值对未受影响：

{'color': 'green', 'points': 5}
{'color': 'green'}

注意　删除的键—值对永远消失了。

6.2.6　由类似对象组成的字典

在前面的示例中，字典存储的是一个对象（游戏中的一个外星人）的多种信息，但你也可以使用字典来存储众多对象的同一种信息。例如，假设你要调查很多人，询问他们最喜欢的编程语言，可使用一个字典来存储这种简单调查的结果，如下所示：

favorite_languages = {
    'jen': 'python',
    'sarah': 'c',
    'edward': 'ruby',
    'phil': 'python',
    }

正如你看到的，我们将一个较大的字典放在了多行中。其中每个键都是一个被调查者的名字，而每个值都是被调查者喜欢的语言。确定需要使用多行来定义字典时，在输入左花括号后按回车键，再在下一行缩进四个空格，指定第一个键—值对，并在它后面加上一个逗号。此后你再次按回车键时，文本编辑器将自动缩进后续键—值对，且缩进量与第一个键—值对相同。

定义好字典后，在最后一个键—值对的下一行添加一个右花括号，并缩进四个空格，使其与字典中的键对齐。另外一种不错的做法是在最后一个键—值对后面也加上逗号，为以后在下一行添加键—值对做好准备。

注意　对于较长的列表和字典，大多数编辑器都有以类似方式设置其格式的功能。对于较长的字典，还有其他一些可行的格式设置方式，因此在你的编辑器或其他源代码中，你可能会看到稍微不同的格式设置方式。

给定被调查者的名字，可使用这个字典轻松地获悉他喜欢的语言：

favorite_languages.py

favorite_languages = {
    'jen': 'python',
    'sarah': 'c',
    'edward': 'ruby',
    'phil': 'python',
    }

print("Sarah's favorite language is " + ❶
    favorite_languages['sarah'].title() + ❷
    ".") ❸

为获悉Sarah喜欢的语言，我们使用如下代码：

favorite_languages['sarah']

在print语句中，我们使用了这种语法（见❷）；输出指出了Sarah喜欢的语言：

Sarah's favorite language is C.

这个示例还演示了如何将较长的print语句分成多行。单词print比大多数字典名都短，因此让输出的第一部分紧跟在左括号后面是合理的（见❶）。请选择在合适的地方拆分要打印的内容，并在第一行末尾（见❷）加上一个拼接运算符（+）。按回车键进入print语句的后续各行，并使用Tab键将它们对齐并缩进一级。指定要打印的所有内容后，在print语句的最后一行末尾加上右括号（见❸）。

动手试一试

6-1 人：使用一个字典来存储一个熟人的信息，包括名、姓、年龄和居住的城市。该字典应包含键first_name、last_name、age和city。将存储在该字典中的每项信息都打印出来。

6-2 喜欢的数字：使用一个字典来存储一些人喜欢的数字。请想出5个人的名字，并将这些名字用作字典中的键；想出每个人喜欢的一个数字，并将这些数字作为值存储在字典中。打印每个人的名字和喜欢的数字。为让这个程序更有趣，通过询问朋友确保数据是真实的。

6-3 词汇表：Python字典可用于模拟现实生活中的字典，但为避免混淆，我们将后者称为词汇表。

想出你在前面学过的5个编程词汇，将它们用作词汇表中的键，并将它们的含义作为值存储在词汇表中。

以整洁的方式打印每个词汇及其含义。为此，你可以先打印词汇，在它后面加上一个冒号，再打印词汇的含义；也可在一行打印词汇，再使用换行符（\n）插入一个空行，然后在下一行以缩进的方式打印词汇的含义。

6.3　遍历字典

一个Python字典可能只包含几个键—值对，也可能包含数百万个键—值对。鉴于字典可能包含大量的数据，Python支持对字典遍历。字典可用于以各种方式存储信息，因此有多种遍历字典的方式：可遍历字典的所有键—值对、键或值。

6.3.1　遍历所有的键—值对

探索各种遍历方法前，先来看一个新字典，它用于存储有关网站用户的信息。下面的字典存储一名用户的用户名、名和姓：

user_0 = {
    'username': 'efermi',
    'first': 'enrico',
    'last': 'fermi',
    }

利用本章前面介绍过的知识，可访问user_0的任何一项信息，但如果要获悉该用户字典中的所有信息，该怎么办呢？可以使用一个for循环来遍历这个字典：

user.py

user_0 = {
    'username': 'efermi',
    'first': 'enrico',
    'last': 'fermi',
    }

for key, value in user_0.items(): ❶
    print("\nKey: " + key) ❷
    print("Value: " + value) ❸

如❶所示，要编写用于遍历字典的for循环，可声明两个变量，用于存储键—值对中的键和值。对于这两个变量，可使用任何名称。下面的代码使用了简单的变量名，这完全可行：

for k, v in user_0.items()

for语句的第二部分包含字典名和方法items()（见❶），它返回一个键—值对列表。接下来，for循环依次将每个键—值对存储到指定的两个变量中。在前面的示例中，我们使用这两个变量来打印每个键（见❷）及其相关联的值（见❸）。第一条print语句中的"\n"确保在输出每个键—值对前都插入一个空行：

Key: last
Value: fermi

Key: first
Value: enrico

Key: username
Value: efermi

注意，即便遍历字典时，键—值对的返回顺序也与存储顺序不同。Python不关心键—值对的存储顺序，而只跟踪键和值之间的关联关系。

在6.2.6节的示例favorite_languages.py中，字典存储的是不同人的同一种信息；对于类似这样的字典，遍历所有的键—值对很合适。如果遍历字典favorite_languages，将得到其中每个人的姓名和喜欢的编程语言。由于其中的键都是人名，而值都是语言，因此我们在循环中使用变量name和language，而不是key和value，这让人更容易明白循环的作用：

favorite_languages.py

favorite_languages = {
    'jen': 'python',
    'sarah': 'c',
    'edward': 'ruby',
    'phil': 'python',
    }

for name, language in favorite_languages.items(): ❶
    print(name.title() + "'s favorite language is " + ❷
        language.title() + ".")

❶处的代码让Python遍历字典中的每个键—值对，并将键存储在变量name中，而将值存储在变量language中。这些描述性名称能够让人非常轻松地明白print语句（见❷）是做什么的。

仅使用几行代码，我们就将全部调查结果显示出来了：

Jen's favorite language is Python.
Sarah's favorite language is C.
Phil's favorite language is Python.
Edward's favorite language is Ruby.

即便字典存储的是上千乃至上百万人的调查结果，这种循环也管用。

6.3.2　遍历字典中的所有键

在不需要使用字典中的值时，方法keys()很有用。下面来遍历字典favorite_languages，并将每个被调查者的名字都打印出来：

favorite_languages = {
    'jen': 'python',
    'sarah': 'c',
    'edward': 'ruby',
    'phil': 'python',
    }

for name in favorite_languages.keys(): ❶
    print(name.title())

❶处的代码行让Python提取字典favorite_languages中的所有键，并依次将它们存储到变量name中。输出列出了每个被调查者的名字：

Jen
Sarah
Phil
Edward

遍历字典时，会默认遍历所有的键，因此，如果将上述代码中的for name in favorite_languages.keys():替换为for name in favorite_languages:，输出将不变。

如果显式地使用方法keys()可让代码更容易理解，你可以选择这样做，但如果你愿意，也可省略它。

在这种循环中，可使用当前键来访问与之相关联的值。下面来打印两条消息，指出两位朋友喜欢的语言。我们像前面一样遍历字典中的名字，但在名字为指定朋友的名字时，打印一条消息，指出其喜欢的语言：

favorite_languages = {
    'jen': 'python',
    'sarah': 'c',
    'edward': 'ruby',
    'phil': 'python',
    }

friends = ['phil', 'sarah'] ❶
for name in favorite_languages.keys():
    print(name.title())

    if name in friends: ❷
        print("  Hi " + name.title() +
            ", I see your favorite language is " +
            favorite_languages[name].title() + "!") ❸

在❶处，我们创建了一个列表，其中包含我们要通过打印消息，指出其喜欢的语言的朋友。在循环中，我们打印每个人的名字，并检查当前的名字是否在列表friends中（见❷）。如果在列表中，就打印一句特殊的问候语，其中包含这位朋友喜欢的语言。为访问喜欢的语言，我们使用了字典名，并将变量name的当前值作为键（见❸）。每个人的名字都会被打印，但只对朋友打印特殊消息：

Edward
Phil
  Hi Phil, I see your favorite language is Python!
Sarah
  Hi Sarah, I see your favorite language is C!
Jen

你还可以使用keys()确定某个人是否接受了调查。下面的代码确定Erin是否接受了调查：

favorite_languages = {
    'jen': 'python',
    'sarah': 'c',
    'edward': 'ruby',
    'phil': 'python',
    }

if 'erin' not in favorite_languages.keys(): ❶
    print("Erin, please take our poll!")

方法keys()并非只能用于遍历；实际上，它返回一个列表，其中包含字典中的所有键，因此❶处的代码行只是核实'erin'是否包含在这个列表中。由于她并不包含在这个列表中，因此打印一条消息，邀请她参加调查：

Erin, please take our poll!

6.3.3　按顺序遍历字典中的所有键

字典总是明确地记录键和值之间的关联关系，但获取字典的元素时，获取顺序是不可预测的。这不是问题，因为通常你想要的只是获取与键相关联的正确的值。

要以特定的顺序返回元素，一种办法是在for循环中对返回的键进行排序。为此，可使用函数sorted()来获得按特定顺序排列的键列表的副本：

favorite_languages = {
    'jen': 'python',
    'sarah': 'c',
    'edward': 'ruby',
    'phil': 'python',
    }

for name in sorted(favorite_languages.keys()):
    print(name.title() + ", thank you for taking the poll.")

这条for语句类似于其他for语句，但对方法dictionary.keys()的结果调用了函数sorted()。这让Python列出字典中的所有键，并在遍历前对这个列表进行排序。输出表明，按顺序显示了所有被调查者的名字：

Edward, thank you for taking the poll.
Jen, thank you for taking the poll.
Phil, thank you for taking the poll.
Sarah, thank you for taking the poll.

6.3.4　遍历字典中的所有值

如果你感兴趣的主要是字典包含的值，可使用方法values()，它返回一个值列表，而不包含任何键。例如，如果我们想获得一个这样的列表，即其中只包含被调查者选择的各种语言，而不包含被调查者的名字，可以这样做：

favorite_languages = {
    'jen': 'python',
    'sarah': 'c',
    'edward': 'ruby',
    'phil': 'python',
    }

print("The following languages have been mentioned:")
for language in favorite_languages.values():
    print(language.title())

这条for语句提取字典中的每个值，并将它们依次存储到变量language中。通过打印这些值，就获得了一个列表，其中包含被调查者选择的各种语言：

The following languages have been mentioned:
Python
C
Python
Ruby

这种做法提取字典中所有的值，而没有考虑是否重复。涉及的值很少时，这也许不是问题，但如果被调查者很多，最终的列表可能包含大量的重复项。为剔除重复项，可使用集合（set）。集合类似于列表，但每个元素都必须是独一无二的：

favorite_languages = {
    'jen': 'python',
    'sarah': 'c',
    'edward': 'ruby',
    'phil': 'python',
    }

print("The following languages have been mentioned:")
for language in set(favorite_languages.values()): ❶
    print(language.title())

通过对包含重复元素的列表调用set()，可让Python找出列表中独一无二的元素，并使用这些元素来创建一个集合。在❶处，我们使用了set()来提取favorite_languages.values()中不同的语言。

结果是一个不重复的列表，其中列出了被调查者提及的所有语言：

The following languages have been mentioned:
Python
C
Ruby

随着你更深入地学习Python，经常会发现它内置的功能可帮助你以希望的方式处理数据。

动手试一试

6-4 词汇表2：既然你知道了如何遍历字典，现在请整理你为完成练习6-3而编写的代码，将其中的一系列print语句替换为一个遍历字典中的键和值的循环。确定该循环正确无误后，再在词汇表中添加5个Python术语。当你再次运行这个程序时，这些新术语及其含义将自动包含在输出中。

6-5 河流：创建一个字典，在其中存储三条大河流及其流经的国家。其中一个键—值对可能是'nile': 'egypt'。

使用循环为每条河流打印一条消息，如“The Nile runs through Egypt.”。

使用循环将该字典中每条河流的名字都打印出来。

使用循环将该字典包含的每个国家的名字都打印出来。

6-6 调查：在6.3.1节编写的程序favorite_languages.py中执行以下操作。

创建一个应该会接受调查的人员名单，其中有些人已包含在字典中，而其他人未包含在字典中。

遍历这个人员名单，对于已参与调查的人，打印一条消息表示感谢。对于还未参与调查的人，打印一条消息邀请他参与调查。

6.4　嵌套

有时候，需要将一系列字典存储在列表中，或将列表作为值存储在字典中，这称为嵌套。你可以在列表中嵌套字典、在字典中嵌套列表甚至在字典中嵌套字典。正如下面的示例将演示的，嵌套是一项强大的功能。

6.4.1　字典列表

字典alien_0包含一个外星人的各种信息，但无法存储第二个外星人的信息，更别说屏幕上全部外星人的信息了。如何管理成群结队的外星人呢？一种办法是创建一个外星人列表，其中每个外星人都是一个字典，包含有关该外星人的各种信息。例如，下面的代码创建一个包含三个外星人的列表：

aliens.py

alien_0 = {'color': 'green', 'points': 5}
alien_1 = {'color': 'yellow', 'points': 10}
alien_2 = {'color': 'red', 'points': 15}

aliens = [alien_0, alien_1, alien_2] ❶

for alien in aliens:
    print(alien)

我们首先创建了三个字典，其中每个字典都表示一个外星人。在❶处，我们将这些字典都放到一个名为aliens的列表中。最后，我们遍历这个列表，并将每个外星人都打印出来：

{'color': 'green', 'points': 5}
{'color': 'yellow', 'points': 10}
{'color': 'red', 'points': 15}

更符合现实的情形是，外星人不止三个，且每个外星人都是使用代码自动生成的。在下面的示例中，我们使用range()生成了30个外星人：

# 创建一个用于存储外星人的空列表
aliens = []

# 创建30个绿色的外星人
for alien_number in range(30): ❶
    new_alien = {'color': 'green', 'points': 5, 'speed': 'slow'} ❷
    aliens.append(new_alien) ❸

# 显示前五个外星人
for alien in aliens[:5]: ❹
    print(alien)
print("...")

# 显示创建了多少个外星人
print("Total number of aliens: " + str(len(aliens))) ❺

在这个示例中，首先创建了一个空列表，用于存储接下来将创建的所有外星人。在❶处，range()返回一系列数字，其唯一的用途是告诉Python我们要重复这个循环多少次。每次执行这个循环时，都创建一个外星人（见❷），并将其附加到列表aliens末尾（见❸）。在❹处，使用一个切片来打印前五个外星人；在❺处，打印列表的长度，以证明确实创建了30个外星人：

{'speed': 'slow', 'color': 'green', 'points': 5}
{'speed': 'slow', 'color': 'green', 'points': 5}
{'speed': 'slow', 'color': 'green', 'points': 5}
{'speed': 'slow', 'color': 'green', 'points': 5}
{'speed': 'slow', 'color': 'green', 'points': 5}
...

Total number of aliens: 30

这些外星人都具有相同的特征，但在Python看来，每个外星人都是独立的，这让我们能够独立地修改每个外星人。

在什么情况下需要处理成群结队的外星人呢？想象一下，可能随着游戏的进行，有些外星人会变色且移动速度会加快。必要时，我们可以使用for循环和if语句来修改某些外星人的颜色。例如，要将前三个外星人修改为黄色的、速度为中等且值10个点，可以这样做：

# 创建一个用于存储外星人的空列表
aliens = []

# 创建30个绿色的外星人
for alien_number in range (0,30):
    new_alien = {'color': 'green', 'points': 5, 'speed': 'slow'}
    aliens.append(new_alien)

for alien in aliens[0:3]:
    if alien['color'] == 'green':
        alien['color'] = 'yellow'
        alien['speed'] = 'medium'
        alien['points'] = 10

# 显示前五个外星人
for alien in aliens[0:5]:
    print(alien)
print("...")

鉴于我们要修改前三个外星人，需要遍历一个只包含这些外星人的切片。当前，所有外星人都是绿色的，但情况并非总是如此，因此我们编写了一条if语句来确保只修改绿色外星人。如果外星人是绿色的，我们就将其颜色改为'yellow'，将其速度改为'medium'，并将其点数改为10，如下面的输出所示：

{'speed': 'medium', 'color': 'yellow', 'points': 10}
{'speed': 'medium', 'color': 'yellow', 'points': 10}
{'speed': 'medium', 'color': 'yellow', 'points': 10}
{'speed': 'slow', 'color': 'green', 'points': 5}
{'speed': 'slow', 'color': 'green', 'points': 5}
...

你可以进一步扩展这个循环，在其中添加一个elif代码块，将黄色外星人改为移动速度快且值15个点的红色外星人，如下所示（这里只列出了循环，而没有列出整个程序）：

for alien in aliens[0:3]:
    if alien['color'] == 'green':
        alien['color'] = 'yellow'
        alien['speed'] = 'medium'
        alien['points'] = 10
    elif alien['color'] == 'yellow':
        alien['color'] = 'red'
        alien['speed'] = 'fast'
        alien['points'] = 15

经常需要在列表中包含大量的字典，而其中每个字典都包含特定对象的众多信息。例如，你可能需要为网站的每个用户创建一个字典（就像6.3.1节的user.py中那样），并将这些字典存储在一个名为users的列表中。在这个列表中，所有字典的结构都相同，因此你可以遍历这个列表，并以相同的方式处理其中的每个字典。

6.4.2　在字典中存储列表

有时候，需要将列表存储在字典中，而不是将字典存储在列表中。例如，你如何描述顾客点的比萨呢？如果使用列表，只能存储要添加的比萨配料；但如果使用字典，就不仅可在其中包含配料列表，还可包含其他有关比萨的描述。

在下面的示例中，存储了比萨的两方面信息：外皮类型和配料列表。其中的配料列表是一个与键'toppings'相关联的值。要访问该列表，我们使用字典名和键'toppings'，就像访问字典中的其他值一样。这将返回一个配料列表，而不是单个值：

pizza.py

# 存储所点比萨的信息
pizza = { ❶
    'crust': 'thick',
    'toppings': ['mushrooms', 'extra cheese'],
    }

# 概述所点的比萨
print("You ordered a " + pizza['crust'] + "-crust pizza " + ❷
    "with the following toppings:")

for topping in pizza['toppings']: ❸
    print("\t" + topping)

我们首先创建了一个字典，其中存储了有关顾客所点比萨的信息（见❶）。在这个字典中，一个键是'crust'，与之相关联的值是字符串'thick'；下一个键是'toppings'，与之相关联的值是一个列表，其中存储了顾客要求添加的所有配料。制作前我们概述了顾客所点的比萨（见❷）。为打印配料，我们编写了一个for循环（见❸）。为访问配料列表，我们使用了键'toppings'，这样Python将从字典中提取配料列表。

下面的输出概述了要制作的比萨：

You ordered a thick-crust pizza with the following toppings:
    mushrooms
    extra cheese

每当需要在字典中将一个键关联到多个值时，都可以在字典中嵌套一个列表。在本章前面有关喜欢的编程语言的示例中，如果将每个人的回答都存储在一个列表中，被调查者就可选择多种喜欢的语言。在这种情况下，当我们遍历字典时，与每个被调查者相关联的都是一个语言列表，而不是一种语言；因此，在遍历该字典的for循环中，我们需要再使用一个for循环来遍历与被调查者相关联的语言列表：

favorite_languages.py

favorite_languages = { ❶
    'jen': ['python', 'ruby'],
    'sarah': ['c'],
    'edward': ['ruby', 'go'],
    'phil': ['python', 'haskell'],
    }

for name, languages in favorite_languages.items(): ❷
    print("\n" + name.title() + "'s favorite languages are:")
    for language in languages: ❸
        print("\t" + language.title())

正如你看到的，现在与每个名字相关联的值都是一个列表（见❶）。请注意，有些人喜欢的语言只有一种，而有些人有多种。遍历字典时（见❷），我们使用了变量languages来依次存储字典中的每个值，因为我们知道这些值都是列表。在遍历字典的主循环中，我们又使用了一个for循环来遍历每个人喜欢的语言列表（见❸）。现在，每个人想列出多少种喜欢的语言都可以：

Jen's favorite languages are:
    Python
    Ruby

Sarah's favorite languages are:
    C

Phil's favorite languages are:
    Python
    Haskell

Edward's favorite languages are:
    Ruby
    Go

为进一步改进这个程序，可在遍历字典的for循环开头添加一条if语句，通过查看len(languages)的值来确定当前的被调查者喜欢的语言是否有多种。如果他喜欢的语言有多种，就像以前一样显示输出；如果只有一种，就相应修改输出的措辞，如显示Sarah's favorite language is C。

注意　列表和字典的嵌套层级不应太多。如果嵌套层级比前面的示例多得多，很可能有更简单的解决问题的方案。

6.4.3　在字典中存储字典

可在字典中嵌套字典，但这样做时，代码可能很快复杂起来。例如，如果有多个网站用户，每个都有独特的用户名，可在字典中将用户名作为键，然后将每位用户的信息存储在一个字典中，并将该字典作为与用户名相关联的值。在下面的程序中，对于每位用户，我们都存储了其三项信息：名、姓和居住地；为访问这些信息，我们遍历所有的用户名，并访问与每个用户名相关联的信息字典：

many_users.py

users = {
    'aeinstein': {
        'first': 'albert',
        'last': 'einstein',
        'location': 'princeton',
        },

    'mcurie': {
        'first': 'marie',
        'last': 'curie',
        'location': 'paris',
        },

    }

for username, user_info in users.items(): ❶
    print("\nUsername: " + username) ❷
    full_name = user_info['first'] + " " + user_info['last'] ❸
    location = user_info['location']

    print("\tFull name: " + full_name.title()) ❹
    print("\tLocation: " + location.title())

我们首先定义了一个名为users的字典，其中包含两个键：用户名'aeinstein'和'mcurie'；与每个键相关联的值都是一个字典，其中包含用户的名、姓和居住地。在❶处，我们遍历字典users，让Python依次将每个键存储在变量username中，并依次将与当前键相关联的字典存储在变量user_info中。在主循环内部的❷处，我们将用户名打印出来。

在❸处，我们开始访问内部的字典。变量user_info包含用户信息字典，而该字典包含三个键：'first'、'last'和'location'；对于每位用户，我们都使用这些键来生成整洁的姓名和居住地，然后打印有关用户的简要信息（见❹）：

Username: aeinstein
    Full name: Albert Einstein
    Location: Princeton

Username: mcurie
    Full name: Marie Curie
    Location: Paris

请注意，表示每位用户的字典的结构都相同，虽然Python并没有这样的要求，但这使得嵌套的字典处理起来更容易。倘若表示每位用户的字典都包含不同的键，for循环内部的代码将更复杂。

动手试一试

6-7 人：在为完成练习6-1而编写的程序中，再创建两个表示人的字典，然后将这三个字典都存储在一个名为people的列表中。遍历这个列表，将其中每个人的所有信息都打印出来。

6-8 宠物：创建多个字典，对于每个字典，都使用一个宠物的名称来给它命名；在每个字典中，包含宠物的类型及其主人的名字。将这些字典存储在一个名为pets的列表中，再遍历该列表，并将宠物的所有信息都打印出来。

6-9 喜欢的地方：创建一个名为favorite_places的字典。在这个字典中，将三个人的名字用作键；对于其中的每个人，都存储他喜欢的1~3个地方。为让这个练习更有趣些，可让一些朋友指出他们喜欢的几个地方。遍历这个字典，并将其中每个人的名字及其喜欢的地方打印出来。

6-10 喜欢的数字：修改为完成练习6-2而编写的程序，让每个人都可以有多个喜欢的数字，然后将每个人的名字及其喜欢的数字打印出来。

6-11 城市：创建一个名为cities的字典，其中将三个城市名用作键；对于每座城市，都创建一个字典，并在其中包含该城市所属的国家、人口约数以及一个有关该城市的事实。在表示每座城市的字典中，应包含country、population和fact等键。将每座城市的名字以及有关它们的信息都打印出来。

6-12 扩展：本章的示例足够复杂，可以以很多方式进行扩展了。请对本章的一个示例进行扩展：添加键和值、调整程序要解决的问题或改进输出的格式。

6.5　小结

在本章中，你学习了：如何定义字典，以及如何使用存储在字典中的信息；如何访问和修改字典中的元素，以及如何遍历字典中的所有信息；如何遍历字典中所有的键-值对、所有的键和所有的值；如何在列表中嵌套字典、在字典中嵌套列表以及在字典中嵌套字典。

在下一章中，你将学习while循环以及如何从用户那里获取输入。这是激动人心的一章，让你知道如何将程序变成交互性的——能够对用户输入作出响应。

第 7 章　用户输入和while循环

大多数程序都旨在解决最终用户的问题，为此通常需要从用户那里获取一些信息。例如，假设有人要判断自己是否到了投票的年龄，要编写回答这个问题的程序，就需要知道用户的年龄，这样才能给出答案。因此，这种程序需要让用户输入其年龄，再将其与投票年龄进行比较，以判断用户是否到了投票的年龄，再给出结果。

在本章中，你将学习如何接受用户输入，让程序能够对其进行处理。在程序需要一个名字时，你需要提示用户输入该名字；程序需要一个名单时，你需要提示用户输入一系列名字。为此，你需要使用函数input()。

你还将学习如何让程序不断地运行，让用户能够根据需要输入信息，并在程序中使用这些信息。为此，你需要使用while循环让程序不断地运行，直到指定的条件不满足为止。

通过获取用户输入并学会控制程序的运行时间，可编写出交互式程序。

7.1　函数input()的工作原理

函数input()让程序暂停运行，等待用户输入一些文本。获取用户输入后，Python将其存储在一个变量中，以方便你使用。

例如，下面的程序让用户输入一些文本，再将这些文本呈现给用户：

parrot.py

message = input("Tell me something, and I will repeat it back to you: ")
print(message)

函数input()接受一个参数：即要向用户显示的提示或说明，让用户知道该如何做。在这个示例中，Python运行第1行代码时，用户将看到提示Tell me something, and I will repeat it back to you:。程序等待用户输入，并在用户按回车键后继续运行。输入存储在变量message中，接下来的print(message)将输入呈现给用户：

Tell me something, and I will repeat it back to you: Hello everyone!
Hello everyone!

注意　Sublime Text不能运行提示用户输入的程序。你可以使用Sublime Text来编写提示用户输入的程序，但必须从终端运行它们。详情请参阅1.4节。

7.1.1　编写清晰的程序

每当你使用函数input()时，都应指定清晰而易于明白的提示，准确地指出你希望用户提供什么样的信息——指出用户该输入任何信息的提示都行，如下所示：

greeter.py

name = input("Please enter your name: ")
print("Hello, " + name + "!")

通过在提示末尾（这里是冒号后面）包含一个空格，可将提示与用户输入分开，让用户清楚地知道其输入始于何处，如下所示：

Please enter your name: Eric
Hello, Eric!

有时候，提示可能超过一行，例如，你可能需要指出获取特定输入的原因。在这种情况下，可将提示存储在一个变量中，再将该变量传递给函数input()。这样，即便提示超过一行，input()语句也非常清晰。

greeter.py

prompt = "If you tell us who you are, we can personalize the messages you see."
prompt += "\nWhat is your first name? "

name = input(prompt)
print("\nHello, " + name + "!")

这个示例演示了一种创建多行字符串的方式。第1行将消息的前半部分存储在变量prompt中；在第2行中，运算符+=在存储在prompt中的字符串末尾附加一个字符串。

最终的提示横跨两行，并在问号后面包含一个空格，这也是出于清晰考虑：

If you tell us who you are, we can personalize the messages you see.
What is your first name? Eric

Hello, Eric!

7.1.2　使用int()来获取数值输入

使用函数input()时，Python将用户输入解读为字符串。请看下面让用户输入其年龄的解释器会话：

>>> age = input("How old are you? ")
How old are you? 21
>>> age
'21'

用户输入的是数字21，但我们请求Python提供变量age的值时，它返回的是'21'——用户输入的数值的字符串表示。我们怎么知道Python将输入解读成了字符串呢？因为这个数字用引号括起了。如果我们只想打印输入，这一点问题都没有；但如果你试图将输入作为数字使用，就会引发错误：

>>> age = input("How old are you? ")
How old are you? 21
>>> age >= 18 ❶
Traceback (most recent call last):
  File "", line 1, in
TypeError: unorderable types: str() >= int() ❷

你试图将输入用于数值比较时（见❶），Python会引发错误，因为它无法将字符串和整数进行比较：不能将存储在age中的字符串'21'与数值18进行比较（见❷）。

为解决这个问题，可使用函数int()，它让Python将输入视为数值。函数int()将数字的字符串表示转换为数值表示，如下所示：

>>> age = input("How old are you? ")
How old are you? 21
>>> age = int(age) ❶
>>> age >= 18
True

在这个示例中，我们在提示时输入21后，Python将这个数字解读为字符串，但随后int()将这个字符串转换成了数值表示（见❶）。这样Python就能运行条件测试了：将变量age（它现在包含数值21）同18进行比较，看它是否大于或等于18。测试结果为True。

如何在实际程序中使用函数int()呢？请看下面的程序，它判断一个人是否满足坐过山车的身高要求：

rollercoaster.py

height = input("How tall are you, in inches? ")
height = int(height)

if height >= 36:
    print("\nYou're tall enough to ride!")
else:
    print("\nYou'll be able to ride when you're a little older.")

在这个程序中，为何可以将height同36进行比较呢？因为在比较前，height = int(height)将输入转换成了数值表示。如果输入的数字大于或等于36，我们就告诉用户他满足身高条件：

How tall are you, in inches? 71

You're tall enough to ride!

将数值输入用于计算和比较前，务必将其转换为数值表示。

7.1.3　求模运算符

处理数值信息时，求模运算符（%）是一个很有用的工具，它将两个数相除并返回余数：

>>> 4 % 3
1
>>> 5 % 3
2
>>> 6 % 3
0
>>> 7 % 3
1

求模运算符不会指出一个数是另一个数的多少倍，而只指出余数是多少。

如果一个数可被另一个数整除，余数就为0，因此求模运算符将返回0。你可利用这一点来判断一个数是奇数还是偶数：

even_or_odd.py

number = input("Enter a number, and I'll tell you if it's even or odd: ")
number = int(number)

if number % 2 == 0:
    print("\nThe number " + str(number) + " is even.")
else:
    print("\nThe number " + str(number) + " is odd.")

偶数都能被2整除，因此对一个数（number）和2执行求模运算的结果为零，即number % 2 == 0，那么这个数就是偶数；否则就是奇数。

Enter a number, and I'll tell you if it's even or odd: 42

The number 42 is even.

7.1.4　在Python 2.7中获取输入

如果你使用的是Python 2.7，应使用函数raw_input()来提示用户输入。这个函数与Python 3中的input()一样，也将输入解读为字符串。

Python 2.7也包含函数input()，但它将用户输入解读为Python代码，并尝试运行它们。因此，最好的结果是出现错误，指出Python不明白输入的代码；而最糟的结果是，将运行你原本无意运行的代码。如果你使用的是Python 2.7，请使用raw_input()而不是input()来获取输入。

动手试一试

7-1 汽车租赁：编写一个程序，询问用户要租赁什么样的汽车，并打印一条消息，如“Let me see if I can find you a Subaru”。

7-2 餐馆订位：编写一个程序，询问用户有多少人用餐。如果超过8人，就打印一条消息，指出没有空桌；否则指出有空桌。

7-3 10的整数倍：让用户输入一个数字，并指出这个数字是否是10的整数倍。

7.2　while循环简介

for循环用于针对集合中的每个元素的一个代码块，而while循环不断地运行，直到指定的条件不满足为止。

7.2.1　使用while循环

你可以使用while循环来数数，例如，下面的while循环从1数到5：

counting.py

current_number = 1
while current_number <= 5:
    print(current_number)
    current_number += 1

在第1行，我们将current_number设置为1，从而指定从1开始数。接下来的while循环被设置成这样：只要current_number小于或等于5，就接着运行这个循环。循环中的代码打印current_number的值，再使用代码current_number += 1（代码current_number = current_number + 1的简写）将其值加1。

只要满足条件current_number <= 5，Python就接着运行这个循环。由于1小于5，因此Python打印1，并将current_number加1，使其为2；由于2小于5，因此Python打印2，并将current_number加1，使其为3，以此类推。一旦current_number大于5，循环将停止，整个程序也将到此结束：

1
2
3
4
5

你每天使用的程序很可能就包含while循环。例如，游戏使用while循环，确保在玩家想玩时不断运行，并在玩家想退出时停止运行。如果程序在用户没有让它停止时停止运行，或者在用户要退出时还继续运行，那就太没有意思了；有鉴于此，while循环很有用。

7.2.2　让用户选择何时退出

可使用while循环让程序在用户愿意时不断地运行，如下面的程序parrot.py所示。我们在其中定义了一个退出值，只要用户输入的不是这个值，程序就接着运行：

parrot.py

prompt = "\nTell me something, and I will repeat it back to you:" ❶
prompt += "\nEnter 'quit' to end the program. "
message = "" ❷
while message != 'quit': ❸
    message = input(prompt)
    print(message)

在❶处，我们定义了一条提示消息，告诉用户他有两个选择：要么输入一条消息，要么输入退出值（这里为'quit'）。接下来，我们创建了一个变量——message（见❷），用于存储用户输入的值。我们将变量message的初始值设置为空字符串""，让Python首次执行while代码行时有可供检查的东西。Python首次执行while语句时，需要将message的值与'quit'进行比较，但此时用户还没有输入。如果没有可供比较的东西，Python将无法继续运行程序。为解决这个问题，我们必须给变量message指定一个初始值。虽然这个初始值只是一个空字符串，但符合要求，让Python能够执行while循环所需的比较。只要message的值不是'quit'，这个循环（见❸）就会不断运行。

首次遇到这个循环时，message是一个空字符串，因此Python进入这个循环。执行到代码行message = input(prompt)时，Python显示提示消息，并等待用户输入。不管用户输入是什么，都将存储到变量message中并打印出来；接下来，Python重新检查while语句中的条件。只要用户输入的不是单词'quit'，Python就会再次显示提示消息并等待用户输入。等到用户终于输入'quit'后，Python停止执行while循环，而整个程序也到此结束：

Tell me something, and I will repeat it back to you:
Enter 'quit' to end the program. Hello everyone!
Hello everyone!

Tell me something, and I will repeat it back to you:
Enter 'quit' to end the program. Hello again.
Hello again.

Tell me something, and I will repeat it back to you:
Enter 'quit' to end the program. quit
quit

这个程序很好，唯一美中不足的是，它将单词'quit'也作为一条消息打印了出来。为修复这种问题，只需使用一个简单的if测试：

prompt = "\nTell me something, and I will repeat it back to you:"
prompt += "\nEnter 'quit' to end the program. "

message = ""
while message != 'quit':
    message = input(prompt)

    if message != 'quit':
        print(message)

现在，程序在显示消息前将做简单的检查，仅在消息不是退出值时才打印它：

Tell me something, and I will repeat it back to you:
Enter 'quit' to end the program. Hello everyone!
Hello everyone!

Tell me something, and I will repeat it back to you:
Enter 'quit' to end the program. Hello again.
Hello again.

Tell me something, and I will repeat it back to you:
Enter 'quit' to end the program. quit

7.2.3　使用标志

在前一个示例中，我们让程序在满足指定条件时就执行特定的任务。但在更复杂的程序中，很多不同的事件都会导致程序停止运行；在这种情况下，该怎么办呢？

例如，在游戏中，多种事件都可能导致游戏结束，如玩家一艘飞船都没有了或要保护的城市都被摧毁了。导致程序结束的事件有很多时，如果在一条while语句中检查所有这些条件，将既复杂又困难。

在要求很多条件都满足才继续运行的程序中，可定义一个变量，用于判断整个程序是否处于活动状态。这个变量被称为标志，充当了程序的交通信号灯。你可让程序在标志为True时继续运行，并在任何事件导致标志的值为False时让程序停止运行。这样，在while语句中就只需检查一个条件——标志的当前值是否为True，并将所有测试（是否发生了应将标志设置为False的事件）都放在其他地方，从而让程序变得更为整洁。

下面来在前一节的程序parrot.py中添加一个标志。我们把这个标志命名为active（可给它指定任何名称），它将用于判断程序是否应继续运行：

prompt = "\nTell me something, and I will repeat it back to you:"
prompt += "\nEnter 'quit' to end the program. "

active = True ❶
while active: ❷
    message = input(prompt)

    if message == 'quit': ❸
        active = False
    else: ❹
        print(message)

我们将变量active设置成了True（见❶），让程序最初处于活动状态。这样做简化了while语句，因为不需要在其中做任何比较——相关的逻辑由程序的其他部分处理。只要变量active为True，循环就将继续运行（见❷）。

在while循环中，我们在用户输入后使用一条if语句来检查变量message的值。如果用户输入的是'quit'（见❸），我们就将变量active设置为False，这将导致while循环不再继续执行。如果用户输入的不是'quit'（见❹），我们就将输入作为一条消息打印出来。

这个程序的输出与前一个示例相同。在前一个示例中，我们将条件测试直接放在了while语句中，而在这个程序中，我们使用了一个标志来指出程序是否处于活动状态，这样如果要添加测试（如elif语句）以检查是否发生了其他导致active变为False的事件，将很容易。在复杂的程序中，如很多事件都会导致程序停止运行的游戏中，标志很有用：在其中的任何一个事件导致活动标志变成False时，主游戏循环将退出，此时可显示一条游戏结束消息，并让用户选择是否要重新玩。

7.2.4　使用break退出循环

要立即退出while循环，不再运行循环中余下的代码，也不管条件测试的结果如何，可使用break语句。break语句用于控制程序流程，可使用它来控制哪些代码行将执行，哪些代码行不执行，从而让程序按你的要求执行你要执行的代码。

例如，来看一个让用户指出他到过哪些地方的程序。在这个程序中，我们可以在用户输入'quit'后使用break语句立即退出while循环：

cities.py

prompt = "\nPlease enter the name of a city you have visited:"
prompt += "\n(Enter 'quit' when you are finished.) "

while True: ❶
    city = input(prompt)

    if city == 'quit':
        break
    else:
        print("I'd love to go to " + city.title() + "!")

以while True打头的循环（见❶）将不断运行，直到遇到break语句。这个程序中的循环不断输入用户到过的城市的名字，直到他输入'quit'为止。用户输入'quit'后，将执行break语句，导致Python退出循环：

Please enter the name of a city you have visited:
(Enter 'quit' when you are finished.) New York
I'd love to go to New York!

Please enter the name of a city you have visited:
(Enter 'quit' when you are finished.) San Francisco
I'd love to go to San Francisco!

Please enter the name of a city you have visited:
(Enter 'quit' when you are finished.) quit

注意　在任何Python循环中都可使用break语句。例如，可使用break语句来退出遍历列表或字典的for循环。

7.2.5　在循环中使用continue

要返回到循环开头，并根据条件测试结果决定是否继续执行循环，可使用continue语句，它不像break语句那样不再执行余下的代码并退出整个循环。例如，来看一个从1数到10，但只打印其中奇数的循环：

counting.py

current_number = 0
while current_number < 10:
    current_number += 1 ❶
    if current_number % 2 == 0:
        continue

    print(current_number)

我们首先将current_number设置成了0，由于它小于10，Python进入while循环。进入循环后，我们以步长1的方式往上数（见❶），因此current_number为1。接下来，if语句检查current_number与2的求模运算结果。如果结果为0（意味着current_number可被2整除），就执行continue语句，让Python忽略余下的代码，并返回到循环的开头。如果当前的数字不能被2整除，就执行循环中余下的代码，Python将这个数字打印出来：

1
3
5
7
9

7.2.6　避免无限循环

每个while循环都必须有停止运行的途径，这样才不会没完没了地执行下去。例如，下面的循环从1数到5：

counting.py

x = 1
while x <= 5:
    print(x)
    x += 1

但如果你像下面这样不小心遗漏了代码行x += 1，这个循环将没完没了地运行：

# 这个循环将没完没了地运行！
x = 1
while x <= 5:
    print(x)

在这里，x的初始值为1，但根本不会变，因此条件测试x <= 5始终为True，导致while循环没完没了地打印1，如下所示：

1
1
1
1
--snip--

每个程序员都会偶尔因不小心而编写出无限循环，在循环的退出条件比较微妙时尤其如此。如果程序陷入无限循环，可按Ctrl + C，也可关闭显示程序输出的终端窗口。

要避免编写无限循环，务必对每个while循环进行测试，确保它按预期那样结束。如果你希望程序在用户输入特定值时结束，可运行程序并输入这样的值；如果在这种情况下程序没有结束，请检查程序处理这个值的方式，确认程序至少有一个这样的地方能让循环条件为False或让break语句得以执行。

注意　有些编辑器（如Sublime Text）内嵌了输出窗口，这可能导致难以结束无限循环，因此不得不关闭编辑器来结束无限循环。

动手试一试

7-4 比萨配料：编写一个循环，提示用户输入一系列的比萨配料，并在用户输入'quit'时结束循环。每当用户输入一种配料后，都打印一条消息，说我们会在比萨中添加这种配料。

7-5 电影票：有家电影院根据观众的年龄收取不同的票价：不到3岁的观众免费；3~12岁的观众为10美元；超过12岁的观众为15美元。请编写一个循环，在其中询问用户的年龄，并指出其票价。

7-6 三个出口：以另一种方式完成练习7-4或练习7-5，在程序中采取如下所有做法。

在while循环中使用条件测试来结束循环。

使用变量active来控制循环结束的时机。

使用break语句在用户输入'quit'时退出循环。

7-7 无限循环：编写一个没完没了的循环，并运行它（要结束该循环，可按Ctrl +C，也可关闭显示输出的窗口）。

7.3　使用while循环来处理列表和字典

到目前为止，我们每次都只处理了一项用户信息：获取用户的输入，再将输入打印出来或作出应答；循环再次运行时，我们获悉另一个输入值并作出响应。然而，要记录大量的用户和信息，需要在while循环中使用列表和字典。

for循环是一种遍历列表的有效方式，但在for循环中不应修改列表，否则将导致Python难以跟踪其中的元素。要在遍历列表的同时对其进行修改，可使用while循环。通过将while循环同列表和字典结合起来使用，可收集、存储并组织大量输入，供以后查看和显示。

7.3.1　在列表之间移动元素

假设有一个列表，其中包含新注册但还未验证的网站用户；验证这些用户后，如何将他们移到另一个已验证用户列表中呢？一种办法是使用一个while循环，在验证用户的同时将其从未验证用户列表中提取出来，再将其加入到另一个已验证用户列表中。代码可能类似于下面这样：

confirmed_users.py

# 首先，创建一个待验证用户列表
#  和一个用于存储已验证用户的空列表
unconfirmed_users = ['alice', 'brian', 'candace'] ❶
confirmed_users = []

# 验证每个用户，直到没有未验证用户为止
#  将每个经过验证的列表都移到已验证用户列表中
while unconfirmed_users: ❷
    current_user = unconfirmed_users.pop() ❸

    print("Verifying user: " + current_user.title())
    confirmed_users.append(current_user) ❹

# 显示所有已验证的用户
print("\nThe following users have been confirmed:")
for confirmed_user in confirmed_users:
    print(confirmed_user.title())

我们首先创建了一个未验证用户列表（见❶），其中包含用户Alice、Brian和Candace，还创建了一个空列表，用于存储已验证的用户。❷处的while循环将不断地运行，直到列表unconfirmed_users变成空的。在这个循环中，❸处的函数pop()以每次一个的方式从列表unconfirmed_users末尾删除未验证的用户。由于Candace位于列表unconfirmed_users末尾，因此其名字将首先被删除、存储到变量current_user中并加入到列表confirmed_users中（见❹）。接下来是Brian，然后是Alice。

为模拟用户验证过程，我们打印一条验证消息并将用户加入到已验证用户列表中。未验证用户列表越来越短，而已验证用户列表越来越长。未验证用户列表为空后结束循环，再打印已验证用户列表：

Verifying user: Candace
Verifying user: Brian
Verifying user: Alice

The following users have been confirmed:
Candace
Brian
Alice

7.3.2　删除包含特定值的所有列表元素

在第3章中，我们使用函数remove()来删除列表中的特定值，这之所以可行，是因为要删除的值在列表中只出现了一次。如果要删除列表中所有包含特定值的元素，该怎么办呢？

假设你有一个宠物列表，其中包含多个值为'cat'的元素。要删除所有这些元素，可不断运行一个while循环，直到列表中不再包含值'cat'，如下所示：

pets.py

pets = ['dog', 'cat', 'dog', 'goldfish', 'cat', 'rabbit', 'cat']
print(pets)

while 'cat' in pets:
    pets.remove('cat')

print(pets)

我们首先创建了一个列表，其中包含多个值为'cat'的元素。打印这个列表后，Python进入while循环，因为它发现'cat'在列表中至少出现了一次。进入这个循环后，Python删除第一个'cat'并返回到while代码行，然后发现'cat'还包含在列表中，因此再次进入循环。它不断删除'cat'，直到这个值不再包含在列表中，然后退出循环并再次打印列表：

['dog', 'cat', 'dog', 'goldfish', 'cat', 'rabbit', 'cat']
['dog', 'dog', 'goldfish', 'rabbit']

7.3.3　使用用户输入来填充字典

可使用while循环提示用户输入任意数量的信息。下面来创建一个调查程序，其中的循环每次执行时都提示输入被调查者的名字和回答。我们将收集的数据存储在一个字典中，以便将回答同被调查者关联起来：

mountain_poll.py

responses = {}

# 设置一个标志，指出调查是否继续
polling_active = True

while polling_active:
    # 提示输入被调查者的名字和回答
    name = input("\nWhat is your name? ") ❶
    response = input("Which mountain would you like to climb someday? ")

    # 将答卷存储在字典中
    responses[name] = response ❷

    # 看看是否还有人要参与调查
    repeat = input("Would you like to let another person respond? (yes/ no) ") ❸
    if repeat == 'no':
        polling_active = False

# 调查结束，显示结果
print("\n--- Poll Results ---")
for name, response in responses.items(): ❹
    print(name + " would like to climb " + response + ".")

这个程序首先定义了一个空字典（responses），并设置了一个标志（polling_active），用于指出调查是否继续。只要polling_active为True，Python就运行while循环中的代码。

在这个循环中，提示用户输入其用户名及其喜欢爬哪座山（见❶）。将这些信息存储在字典responses中（见❷），然后询问用户调查是否继续（见❸）。如果用户输入yes，程序将再次进入while循环；如果用户输入no，标志polling_active将被设置为False，而while循环将就此结束。最后一个代码块（见❹）显示调查结果。

如果你运行这个程序，并输入一些名字和回答，输出将类似于下面这样：

What is your name? Eric
Which mountain would you like to climb someday? Denali
Would you like to let another person respond? (yes/ no) yes

What is your name? Lynn
Which mountain would you like to climb someday? Devil's Thumb
Would you like to let another person respond? (yes/ no) no

--- Poll Results ---
Lynn would like to climb Devil's Thumb.
Eric would like to climb Denali.

动手试一试

7-8 熟食店：创建一个名为sandwich_orders的列表，在其中包含各种三明治的名字；再创建一个名为finished_sandwiches的空列表。遍历列表sandwich_orders，对于其中的每种三明治，都打印一条消息，如I made your tuna sandwich，并将其移到列表finished_sandwiches。所有三明治都制作好后，打印一条消息，将这些三明治列出来。

7-9 五香烟熏牛肉（pastrami）卖完了：使用为完成练习7-8而创建的列表sandwich_orders，并确保'pastrami'在其中至少出现了三次。在程序开头附近添加这样的代码：打印一条消息，指出熟食店的五香烟熏牛肉卖完了；再使用一个while循环将列表sandwich_orders中的'pastrami'都删除。确认最终的列表finished_sandwiches中不包含'pastrami'。

7-10 梦想的度假胜地：编写一个程序，调查用户梦想的度假胜地。使用类似于“If you could visit one place in the world, where would you go?”的提示，并编写一个打印调查结果的代码块。

7.4　小结

在本章中，你学习了：如何在程序中使用input()来让用户提供信息；如何处理文本和数字输入，以及如何使用while循环让程序按用户的要求不断地运行；多种控制while循环流程的方式：设置活动标志、使用break语句以及使用continue语句；如何使用while循环在列表之间移动元素，以及如何从列表中删除所有包含特定值的元素；如何结合使用while循环和字典。

在第8章中，你将学习函数。函数让你能够将程序分成多个很小的部分，其中每部分都负责完成一项具体任务。你可以根据需要调用同一个函数任意次，还可将函数存储在独立的文件中。使用函数可让你编写的代码效率更高，更容易维护和排除故障，还可在众多不同的程序中重用。

第 8 章　函数

在本章中，你将学习编写函数。函数是带名字的代码块，用于完成具体的工作。

要执行函数定义的特定任务，可调用该函数。需要在程序中多次执行同一项任务时，你无需反复编写完成该任务的代码，而只需调用执行该任务的函数，让Python运行其中的代码。你将发现，通过使用函数，程序的编写、阅读、测试和修复都将更容易。

在本章中，你还会学习向函数传递信息的方式。你将学习如何编写主要任务是显示信息的函数，还有用于处理数据并返回一个或一组值的函数。最后，你将学习如何将函数存储在被称为模块的独立文件中，让主程序文件的组织更为有序。

8.1　定义函数

下面是一个打印问候语的简单函数，名为greet_user()：

greeter.py

def greet_user(): ❶
    """显示简单的问候语""" ❷
    print("Hello!") ❸

greet_user() ❹

这个示例演示了最简单的函数结构。❶处的代码行使用关键字def来告诉Python你要定义一个函数。这是函数定义，向Python指出了函数名，还可能在括号内指出函数为完成其任务需要什么样的信息。在这里，函数名为greet_user()，它不需要任何信息就能完成其工作，因此括号是空的（即便如此，括号也必不可少）。最后，定义以冒号结尾。

紧跟在def greet_user():后面的所有缩进行构成了函数体。❷处的文本是被称为文档字符串（docstring）的注释，描述了函数是做什么的。文档字符串用三引号括起，Python使用它们来生成有关程序中函数的文档。

代码行print("Hello!")（见❸）是函数体内的唯一一行代码，greet_user()只做一项工作：打印Hello!。

要使用这个函数，可调用它。函数调用让Python执行函数的代码。要调用函数，可依次指定函数名以及用括号括起的必要信息，如❹处所示。由于这个函数不需要任何信息，因此调用它时只需输入greet_user()即可。和预期的一样，它打印Hello!：

Hello!

8.1.1　向函数传递信息

只需稍作修改，就可以让函数greet_user()不仅向用户显示Hello!，还将用户的名字用作抬头。为此，可在函数定义def greet_user()的括号内添加username。通过在这里添加username，就可让函数接受你给username指定的任何值。现在，这个函数要求你调用它时给username指定一个值。调用greet_user()时，可将一个名字传递给它，如下所示：

def greet_user(username):
    """显示简单的问候语"""
    print("Hello, " + username.title() + "!")

greet_user('jesse')

代码greet_user('jesse')调用函数greet_user()，并向它提供执行print语句所需的信息。这个函数接受你传递给它的名字，并向这个人发出问候：

Hello, Jesse!

同样，greet_user('sarah')调用函数greet_user()并向它传递'sarah'，打印Hello, Sarah!。你可以根据需要调用函数greet_user()任意次，调用时无论传入什么样的名字，都会生成相应的输出。

8.1.2　实参和形参

前面定义函数greet_user()时，要求给变量username指定一个值。调用这个函数并提供这种信息（人名）时，它将打印相应的问候语。

在函数greet_user()的定义中，变量username是一个形参——函数完成其工作所需的一项信息。在代码greet_user('jesse')中，值'jesse'是一个实参。实参是调用函数时传递给函数的信息。我们调用函数时，将要让函数使用的信息放在括号内。在greet_user('jesse')中，将实参'jesse'传递给了函数greet_user()，这个值被存储在形参username中。

注意　大家有时候会形参、实参不分，因此如果你看到有人将函数定义中的变量称为实参或将函数调用中的变量称为形参，不要大惊小怪。

动手试一试

8-1 消息：编写一个名为display_message()的函数，它打印一个句子，指出你在本章学的是什么。调用这个函数，确认显示的消息正确无误。

8-2 喜欢的图书：编写一个名为favorite_book()的函数，其中包含一个名为title的形参。这个函数打印一条消息，如One of my favorite books is Alice in Wonderland。调用这个函数，并将一本图书的名称作为实参传递给它。

8.2　传递实参

鉴于函数定义中可能包含多个形参，因此函数调用中也可能包含多个实参。向函数传递实参的方式很多，可使用位置实参，这要求实参的顺序与形参的顺序相同；也可使用关键字实参，其中每个实参都由变量名和值组成；还可使用列表和字典。下面来依次介绍这些方式。

8.2.1　位置实参

你调用函数时，Python必须将函数调用中的每个实参都关联到函数定义中的一个形参。为此，最简单的关联方式是基于实参的顺序。这种关联方式被称为位置实参。

为明白其中的工作原理，来看一个显示宠物信息的函数。这个函数指出一个宠物属于哪种动物以及它叫什么名字，如下所示：

pets.py

def describe_pet(animal_type, pet_name): ❶
    """显示宠物的信息"""
    print("\nI have a " + animal_type + ".")
    print("My " + animal_type + "'s name is " + pet_name.title() + ".")

describe_pet('hamster', 'harry') ❷

这个函数的定义表明，它需要一种动物类型和一个名字（见❶）。调用describe_pet()时，需要按顺序提供一种动物类型和一个名字。例如，在前面的函数调用中，实参'hamster'存储在形参animal_type中，而实参'harry'存储在形参pet_name中（见❷）。在函数体内，使用了这两个形参来显示宠物的信息。

输出描述了一只名为Harry的仓鼠：

I have a hamster.
My hamster's name is Harry.

1. 调用函数多次

你可以根据需要调用函数任意次。要再描述一个宠物，只需再次调用describe_pet()即可：

def describe_pet(animal_type, pet_name):
    """显示宠物的信息"""
    print("\nI have a " + animal_type + ".")
    print("My " + animal_type + "'s name is " + pet_name.title() + ".")

describe_pet('hamster', 'harry')
describe_pet('dog', 'willie')

第二次调用describe_pet()函数时，我们向它传递了实参'dog'和'willie'。与第一次调用时一样，Python将实参'dog'关联到形参animal_type，并将实参'willie'关联到形参pet_name。与前面一样，这个函数完成其任务，但打印的是一条名为Willie的小狗的信息。至此，我们有一只名为Harry的仓鼠，还有一条名为Willie的小狗：

I have a hamster.
My hamster's name is Harry.

I have a dog.
My dog's name is Willie.

调用函数多次是一种效率极高的工作方式。我们只需在函数中编写描述宠物的代码一次，然后每当需要描述新宠物时，都可调用这个函数，并向它提供新宠物的信息。即便描述宠物的代码增加到了10行，你依然只需使用一行调用函数的代码，就可描述一个新宠物。

在函数中，可根据需要使用任意数量的位置实参，Python将按顺序将函数调用中的实参关联到函数定义中相应的形参。

2. 位置实参的顺序很重要

使用位置实参来调用函数时，如果实参的顺序不正确，结果可能出乎意料：

def describe_pet(animal_type, pet_name):
    """显示宠物的信息"""
    print("\nI have a " + animal_type + ".")
    print("My " + animal_type + "'s name is " + pet_name.title() + ".")

describe_pet('harry', 'hamster')

在这个函数调用中，我们先指定名字，再指定动物类型。由于实参'harry'在前，这个值将存储到形参animal_type中；同理，'hamster'将存储到形参pet_name中。结果是我们得到了一个名为Hamster的harry：

I have a harry.
My harry's name is Hamster.

如果结果像上面一样搞笑，请确认函数调用中实参的顺序与函数定义中形参的顺序一致。

8.2.2　关键字实参

关键字实参是传递给函数的名称—值对。你直接在实参中将名称和值关联起来了，因此向函数传递实参时不会混淆（不会得到名为Hamster的harry这样的结果）。关键字实参让你无需考虑函数调用中的实参顺序，还清楚地指出了函数调用中各个值的用途。

下面来重新编写pets.py，在其中使用关键字实参来调用describe_pet()：

def describe_pet(animal_type, pet_name):
    """显示宠物的信息"""
    print("\nI have a " + animal_type + ".")
    print("My " + animal_type + "'s name is " + pet_name.title() + ".")

describe_pet(animal_type='hamster', pet_name='harry')

函数describe_pet()还是原来那样，但调用这个函数时，我们向Python明确地指出了各个实参对应的形参。看到这个函数调用时，Python知道应该将实参'hamster'和'harry'分别存储在形参animal_type和pet_name中。输出正确无误，它指出我们有一只名为Harry的仓鼠。

关键字实参的顺序无关紧要，因为Python知道各个值该存储到哪个形参中。下面两个函数调用是等效的：

describe_pet(animal_type='hamster', pet_name='harry')
describe_pet(pet_name='harry', animal_type='hamster')

注意　使用关键字实参时，务必准确地指定函数定义中的形参名。

8.2.3　默认值

编写函数时，可给每个形参指定默认值。在调用函数中给形参提供了实参时，Python将使用指定的实参值；否则，将使用形参的默认值。因此，给形参指定默认值后，可在函数调用中省略相应的实参。使用默认值可简化函数调用，还可清楚地指出函数的典型用法。

例如，如果你发现调用describe_pet()时，描述的大都是小狗，就可将形参animal_type的默认值设置为'dog'。这样，调用describe_pet()来描述小狗时，就可不提供这种信息：

def describe_pet(pet_name, animal_type='dog'):
    """显示宠物的信息"""
    print("\nI have a " + animal_type + ".")
    print("My " + animal_type + "'s name is " + pet_name.title() + ".")

describe_pet(pet_name='willie')

这里修改了函数describe_pet()的定义，在其中给形参animal_type指定了默认值'dog'。这样，调用这个函数时，如果没有给animal_type指定值，Python将把这个形参设置为'dog'：

I have a dog.
My dog's name is Willie.

请注意，在这个函数的定义中，修改了形参的排列顺序。由于给animal_type指定了默认值，无需通过实参来指定动物类型，因此在函数调用中只包含一个实参——宠物的名字。然而，Python依然将这个实参视为位置实参，因此如果函数调用中只包含宠物的名字，这个实参将关联到函数定义中的第一个形参。这就是需要将pet_name放在形参列表开头的原因所在。

现在，使用这个函数的最简单的方式是，在函数调用中只提供小狗的名字：

describe_pet('willie')

这个函数调用的输出与前一个示例相同。只提供了一个实参——'willie'，这个实参将关联到函数定义中的第一个形参——pet_name。由于没有给animal_type提供实参，因此Python使用其默认值'dog'。

如果要描述的动物不是小狗，可使用类似于下面的函数调用：

describe_pet(pet_name='harry', animal_type='hamster')

由于显式地给animal_type提供了实参，因此Python将忽略这个形参的默认值。

注意　使用默认值时，在形参列表中必须先列出没有默认值的形参，再列出有默认值的形参。这让Python依然能够正确地解读位置实参。

8.2.4　等效的函数调用

鉴于可混合使用位置实参、关键字实参和默认值，通常有多种等效的函数调用方式。请看下面的函数describe_pet()的定义，其中给一个形参提供了默认值：

def describe_pet(pet_name, animal_type='dog'):

基于这种定义，在任何情况下都必须给pet_name提供实参；指定该实参时可以使用位置方式，也可以使用关键字方式。如果要描述的动物不是小狗，还必须在函数调用中给animal_type提供实参；同样，指定该实参时可以使用位置方式，也可以使用关键字方式。

下面对这个函数的所有调用都可行：

# 一条名为Willie的小狗
describe_pet('willie')
describe_pet(pet_name='willie')

# 一只名为Harry的仓鼠
describe_pet('harry', 'hamster')
describe_pet(pet_name='harry', animal_type='hamster')
describe_pet(animal_type='hamster', pet_name='harry')

这些函数调用的输出与前面的示例相同。

注意　使用哪种调用方式无关紧要，只要函数调用能生成你希望的输出就行。使用对你来说最容易理解的调用方式即可。

8.2.5　避免实参错误

等你开始使用函数后，如果遇到实参不匹配错误，不要大惊小怪。你提供的实参多于或少于函数完成其工作所需的信息时，将出现实参不匹配错误。例如，如果调用函数describe_pet()时没有指定任何实参，结果将如何呢？

def describe_pet(animal_type, pet_name):
    """显示宠物的信息"""
    print("\nI have a " + animal_type + ".")
    print("My " + animal_type + "'s name is " + pet_name.title() + ".")

describe_pet()

Python发现该函数调用缺少必要的信息，而traceback指出了这一点：

Traceback (most recent call last):
  File "pets.py", line 6, in <module> ❶
    describe_pet() ❷
TypeError: describe_pet() missing 2 required positional arguments: 'animal_ ❸
type' and 'pet_name'

在❶处，traceback指出了问题出在什么地方，让我们能够回过头去找出函数调用中的错误。在❷处，指出了导致问题的函数调用。在❸处，traceback指出该函数调用少两个实参，并指出了相应形参的名称。如果这个函数存储在一个独立的文件中，我们也许无需打开这个文件并查看函数的代码，就能重新正确地编写函数调用。

Python读取函数的代码，并指出我们需要为哪些形参提供实参，这提供了极大的帮助。这也是应该给变量和函数指定描述性名称的另一个原因；如果你这样做了，那么无论对于你，还是可能使用你编写的代码的其他任何人来说，Python提供的错误消息都将更有帮助。

如果提供的实参太多，将出现类似的traceback，帮助你确保函数调用和函数定义匹配。

动手试一试

8-3 T恤：编写一个名为make_shirt()的函数，它接受一个尺码以及要印到T恤上的字样。这个函数应打印一个句子，概要地说明T恤的尺码和字样。

使用位置实参调用这个函数来制作一件T恤；再使用关键字实参来调用这个函数。

8-4 大号T恤：修改函数make_shirt()，使其在默认情况下制作一件印有字样“I love Python”的大号T恤。调用这个函数来制作如下T恤：一件印有默认字样的大号T恤、一件印有默认字样的中号T恤和一件印有其他字样的T恤（尺码无关紧要）。

8-5 城市：编写一个名为describe_city()的函数，它接受一座城市的名字以及该城市所属的国家。这个函数应打印一个简单的句子，如Reykjavik is in Iceland。给用于存储国家的形参指定默认值。为三座不同的城市调用这个函数，且其中至少有一座城市不属于默认国家。

8.3　返回值

函数并非总是直接显示输出，相反，它可以处理一些数据，并返回一个或一组值。函数返回的值被称为返回值。在函数中，可使用return语句将值返回到调用函数的代码行。返回值让你能够将程序的大部分繁重工作移到函数中去完成，从而简化主程序。

8.3.1　返回简单值

下面来看一个函数，它接受名和姓并返回整洁的姓名：

formatted_name.py

def get_formatted_name(first_name, last_name): ❶
    """返回整洁的姓名"""
    full_name = first_name + ' ' + last_name ❷
    return full_name.title() ❸

musician = get_formatted_name('jimi', 'hendrix') ❹
print(musician)

函数get_formatted_name()的定义通过形参接受名和姓（见❶）。它将姓和名合而为一，在它们之间加上一个空格，并将结果存储在变量full_name中（见❷）。然后，将full_name的值转换为首字母大写格式，并将结果返回到函数调用行（见❸）。

调用返回值的函数时，需要提供一个变量，用于存储返回的值。在这里，将返回值存储在了变量musician中（见❹）。输出为整洁的姓名：

Jimi Hendrix

我们原本只需编写下面的代码就可输出整洁的姓名，相比于此，前面做的工作好像太多了：

print("Jimi Hendrix")

但在需要分别存储大量名和姓的大型程序中，像get_formatted_name()这样的函数非常有用。你分别存储名和姓，每当需要显示姓名时都调用这个函数。

8.3.2　让实参变成可选的

有时候，需要让实参变成可选的，这样使用函数的人就只需在必要时才提供额外的信息。可使用默认值来让实参变成可选的。

例如，假设我们要扩展函数get_formatted_name()，使其还处理中间名。为此，可将其修改成类似于下面这样：

def get_formatted_name(first_name, middle_name, last_name):
    """返回整洁的姓名"""
    full_name = first_name + ' ' + middle_name + ' ' + last_name
    return full_name.title()

musician = get_formatted_name('john', 'lee', 'hooker')
print(musician)

只要同时提供名、中间名和姓，这个函数就能正确地运行。它根据这三部分创建一个字符串，在适当的地方加上空格，并将结果转换为首字母大写格式：

John Lee Hooker

然而，并非所有的人都有中间名，但如果你调用这个函数时只提供了名和姓，它将不能正确地运行。为让中间名变成可选的，可给实参middle_name指定一个默认值——空字符串，并在用户没有提供中间名时不使用这个实参。为让get_formatted_name()在没有提供中间名时依然可行，可给实参middle_name指定一个默认值——空字符串，并将其移到形参列表的末尾：

def get_formatted_name(first_name, last_name, middle_name=''): ❶
    """返回整洁的姓名"""
    if middle_name: ❷
        full_name = first_name + ' ' + middle_name + ' ' + last_name
    else: ❸
        full_name = first_name + ' ' + last_name
    return full_name.title()

musician = get_formatted_name('jimi', 'hendrix')
print(musician)

musician = get_formatted_name('john', 'hooker', 'lee') ❹
print(musician)

在这个示例中，姓名是根据三个可能提供的部分创建的。由于人都有名和姓，因此在函数定义中首先列出了这两个形参。中间名是可选的，因此在函数定义中最后列出该形参，并将其默认值设置为空字符串（见❶）。

在函数体中，我们检查是否提供了中间名。Python将非空字符串解读为True，因此如果函数调用中提供了中间名，if middle_name将为True（见❷）。如果提供了中间名，就将名、中间名和姓合并为姓名，然后将其修改为首字母大写格式，并返回到函数调用行。在函数调用行，将返回的值存储在变量musician中；然后将这个变量的值打印出来。如果没有提供中间名，middle_name将为空字符串，导致if测试未通过，进而执行else代码块（见❸）：只使用名和姓来生成姓名，并将设置好格式的姓名返回给函数调用行。在函数调用行，将返回的值存储在变量musician中；然后将这个变量的值打印出来。

调用这个函数时，如果只想指定名和姓，调用起来将非常简单。如果还要指定中间名，就必须确保它是最后一个实参，这样Python才能正确地将位置实参关联到形参（见❹）。

这个修改后的版本适用于只有名和姓的人，也适用于还有中间名的人：

Jimi Hendrix
John Lee Hooker

可选值让函数能够处理各种不同情形的同时，确保函数调用尽可能简单。

8.3.3　返回字典

函数可返回任何类型的值，包括列表和字典等较复杂的数据结构。例如，下面的函数接受姓名的组成部分，并返回一个表示人的字典：

person.py

def build_person(first_name, last_name):
    """返回一个字典，其中包含有关一个人的信息"""
    person = {'first': first_name, 'last': last_name} ❶
    return person ❷

musician = build_person('jimi', 'hendrix')
print(musician) ❸

函数build_person()接受名和姓，并将这些值封装到字典中（见❶）。存储first_name的值时，使用的键为'first'，而存储last_name的值时，使用的键为'last'。最后，返回表示人的整个字典（见❷）。在❸处，打印这个返回的值，此时原来的两项文本信息存储在一个字典中：

{'first': 'jimi', 'last': 'hendrix'}

这个函数接受简单的文本信息，将其放在一个更合适的数据结构中，让你不仅能打印这些信息，还能以其他方式处理它们。当前，字符串'jimi'和'hendrix'被标记为名和姓。你可以轻松地扩展这个函数，使其接受可选值，如中间名、年龄、职业或你要存储的其他任何信息。例如，下面的修改让你还能存储年龄：

def build_person(first_name, last_name, age=''):
    """返回一个字典，其中包含有关一个人的信息"""
    person = {'first': first_name, 'last': last_name}
    if age:
        person['age'] = age
    return person

musician = build_person('jimi', 'hendrix', age=27)
print(musician)

在函数定义中，我们新增了一个可选形参age，并将其默认值设置为空字符串。如果函数调用中包含这个形参的值，这个值将存储到字典中。在任何情况下，这个函数都会存储人的姓名，但可对其进行修改，使其也存储有关人的其他信息。

8.3.4　结合使用函数和while循环

可将函数同本书前面介绍的任何Python结构结合起来使用。例如，下面将结合使用函数get_formatted_name()和while循环，以更正规的方式问候用户。下面尝试使用名和姓跟用户打招呼：

greeter.py

def get_formatted_name(first_name, last_name):
    """返回整洁的姓名"""
    full_name = first_name + ' ' + last_name
    return full_name.title()

# 这是一个无限循环!
while True:
     print("\nPlease tell me your name:") ❶
     f_name = input("First name: ")
     l_name = input("Last name: ")

     formatted_name = get_formatted_name(f_name, l_name)
     print("\nHello, " + formatted_name + "!")

在这个示例中，我们使用的是get_formatted_name()的简单版本，不涉及中间名。其中的while循环让用户输入姓名：依次提示用户输入名和姓（见❶）。

但这个while循环存在一个问题：没有定义退出条件。请用户提供一系列输入时，该在什么地方提供退出条件呢？我们要让用户能够尽可能容易地退出，因此每次提示用户输入时，都应提供退出途径。每次提示用户输入时，都使用break语句提供了退出循环的简单途径：

def get_formatted_name(first_name, last_name):
    """返回整洁的姓名"""
    full_name = first_name + ' ' + last_name
    return full_name.title()

while True:
    print("\nPlease tell me your name:")
    print("(enter 'q' at any time to quit)")

    f_name = input("First name: ")
    if f_name == 'q':
        break

    l_name = input("Last name: ")
    if l_name == 'q':
        break

    formatted_name = get_formatted_name(f_name, l_name)
    print("\nHello, " + formatted_name + "!")

我们添加了一条消息来告诉用户如何退出，然后在每次提示用户输入时，都检查他输入的是否是退出值，如果是，就退出循环。现在，这个程序将不断地问候，直到用户输入的姓或名为'q'为止：

Please tell me your name:
(enter 'q' at any time to quit)
First name: eric
Last name: matthes

Hello, Eric Matthes!

Please tell me your name:
(enter 'q' at any time to quit)
First name: q

动手试一试

8-6 城市名：编写一个名为city_country()的函数，它接受城市的名称及其所属的国家。这个函数应返回一个格式类似于下面这样的字符串：

"Santiago, Chile"

至少使用三个城市-国家对调用这个函数，并打印它返回的值。

8-7 专辑：编写一个名为make_album()的函数，它创建一个描述音乐专辑的字典。这个函数应接受歌手的名字和专辑名，并返回一个包含这两项信息的字典。使用这个函数创建三个表示不同专辑的字典，并打印每个返回的值，以核实字典正确地存储了专辑的信息。

给函数make_album()添加一个可选形参，以便能够存储专辑包含的歌曲数。如果调用这个函数时指定了歌曲数，就将这个值添加到表示专辑的字典中。调用这个函数，并至少在一次调用中指定专辑包含的歌曲数。

8-8 用户的专辑：在为完成练习8-7编写的程序中，编写一个while循环，让用户输入一个专辑的歌手和名称。获取这些信息后，使用它们来调用函数make_album()，并将创建的字典打印出来。在这个while循环中，务必要提供退出途径。

8.4　传递列表

你经常会发现，向函数传递列表很有用，这种列表包含的可能是名字、数字或更复杂的对象（如字典）。将列表传递给函数后，函数就能直接访问其内容。下面使用函数来提高处理列表的效率。

假设有一个用户列表，我们要问候其中的每位用户。下面的示例将一个名字列表传递给一个名为greet_users()的函数，这个函数问候列表中的每个人：

greet_users.py

def greet_users(names):
    """向列表中的每位用户都发出简单的问候"""
    for name in names:
        msg = "Hello, " + name.title() + "!"
        print(msg)

usernames = ['hannah', 'ty', 'margot'] ❶
greet_users(usernames)

我们将greet_users()定义成接受一个名字列表，并将其存储在形参names中。这个函数遍历收到的列表，并对其中的每位用户都打印一条问候语。在❶处，我们定义了一个用户列表——usernames，然后调用greet_users()，并将这个列表传递给它：

Hello, Hannah!
Hello, Ty!
Hello, Margot!

输出完全符合预期，每位用户都看到了一条个性化的问候语。每当你要问候一组用户时，都可调用这个函数。

8.4.1　在函数中修改列表

将列表传递给函数后，函数就可对其进行修改。在函数中对这个列表所做的任何修改都是永久性的，这让你能够高效地处理大量的数据。

来看一家为用户提交的设计制作3D打印模型的公司。需要打印的设计存储在一个列表中，打印后移到另一个列表中。下面是在不使用函数的情况下模拟这个过程的代码：

printing_models.py

# 首先创建一个列表，其中包含一些要打印的设计
unprinted_designs = ['iphone case', 'robot pendant', 'dodecahedron']
completed_models = []

# 模拟打印每个设计，直到没有未打印的设计为止
#  打印每个设计后，都将其移到列表completed_models中
while unprinted_designs:
    current_design = unprinted_designs.pop()

    #模拟根据设计制作3D打印模型的过程
    print("Printing model: " + current_design)
    completed_models.append(current_design)

# 显示打印好的所有模型
print("\nThe following models have been printed:")
for completed_model in completed_models:
    print(completed_model)

这个程序首先创建一个需要打印的设计列表，还创建一个名为completed_models的空列表，每个设计打印都将移到这个列表中。只要列表unprinted_designs中还有设计，while循环就模拟打印设计的过程：从该列表末尾删除一个设计，将其存储到变量current_design中，并显示一条消息，指出正在打印当前的设计，再将该设计加入到列表completed_models中。循环结束后，显示已打印的所有设计：

Printing model: dodecahedron
Printing model: robot pendant
Printing model: iphone case

The following models have been printed:
dodecahedron
robot pendant
iphone case

为重新组织这些代码，我们可编写两个函数，每个都做一件具体的工作。大部分代码都与原来相同，只是效率更高。第一个函数将负责处理打印设计的工作，而第二个将概述打印了哪些设计：

def print_models(unprinted_designs, completed_models): ❶
    """
    模拟打印每个设计，直到没有未打印的设计为止
    打印每个设计后，都将其移到列表completed_models中
    """
    while unprinted_designs:
        current_design = unprinted_designs.pop()

        # 模拟根据设计制作3D打印模型的过程
        print("Printing model: " + current_design)
        completed_models.append(current_design)

def show_completed_models(completed_models): ❷
    """显示打印好的所有模型"""
    print("\nThe following models have been printed:")
    for completed_model in completed_models:
        print(completed_model)

unprinted_designs = ['iphone case', 'robot pendant', 'dodecahedron']
completed_models = []

print_models(unprinted_designs, completed_models)
show_completed_models(completed_models)

在❶处，我们定义了函数print_models()，它包含两个形参：一个需要打印的设计列表和一个打印好的模型列表。给定这两个列表，这个函数模拟打印每个设计的过程：将设计逐个地从未打印的设计列表中取出，并加入到打印好的模型列表中。在❷处，我们定义了函数show_completed_models()，它包含一个形参：打印好的模型列表。给定这个列表，函数show_completed_models()显示打印出来的每个模型的名称。

这个程序的输出与未使用函数的版本相同，但组织更为有序。完成大部分工作的代码都移到了两个函数中，让主程序更容易理解。只要看看主程序，你就知道这个程序的功能容易看清得多：

unprinted_designs = ['iphone case', 'robot pendant', 'dodecahedron']
completed_models = []

print_models(unprinted_designs, completed_models)
show_completed_models(completed_models)

我们创建了一个未打印的设计列表，还创建了一个空列表，用于存储打印好的模型。接下来，由于我们已经定义了两个函数，因此只需调用它们并传入正确的实参即可。我们调用print_models()并向它传递两个列表；像预期的一样，print_models()模拟打印设计的过程。接下来，我们调用show_completed_models()，并将打印好的模型列表传递给它，让其能够指出打印了哪些模型。描述性的函数名让别人阅读这些代码时也能明白，虽然其中没有任何注释。

相比于没有使用函数的版本，这个程序更容易扩展和维护。如果以后需要打印其他设计，只需再次调用print_models()即可。如果我们发现需要对打印代码进行修改，只需修改这些代码一次，就能影响所有调用该函数的地方；与必须分别修改程序的多个地方相比，这种修改的效率更高。

这个程序还演示了这样一种理念，即每个函数都应只负责一项具体的工作。第一个函数打印每个设计，而第二个显示打印好的模型；这优于使用一个函数来完成两项工作。编写函数时，如果你发现它执行的任务太多，请尝试将这些代码划分到两个函数中。别忘了，总是可以在一个函数中调用另一个函数，这有助于将复杂的任务划分成一系列的步骤。

8.4.2　禁止函数修改列表

有时候，需要禁止函数修改列表。例如，假设像前一个示例那样，你有一个未打印的设计列表，并编写了一个将这些设计移到打印好的模型列表中的函数。你可能会做出这样的决定：即便打印所有设计后，也要保留原来的未打印的设计列表，以供备案。但由于你将所有的设计都移出了unprinted_designs，这个列表变成了空的，原来的列表没有了。为解决这个问题，可向函数传递列表的副本而不是原件；这样函数所做的任何修改都只影响副本，而丝毫不影响原件。

要将列表的副本传递给函数，可以像下面这样做：

function_name(list_name[:])

切片表示法[:]创建列表的副本。在print_models.py中，如果不想清空未打印的设计列表，可像下面这样调用print_models()：

print_models(unprinted_designs[:], completed_models)

这样函数print_models()依然能够完成其工作，因为它获得了所有未打印的设计的名称，但它使用的是列表unprinted_designs的副本，而不是列表unprinted_designs本身。像以前一样，列表completed_models也将包含打印好的模型的名称，但函数所做的修改不会影响到列表unprinted_designs。

虽然向函数传递列表的副本可保留原始列表的内容，但除非有充分的理由需要传递副本，否则还是应该将原始列表传递给函数，因为让函数使用现成列表可避免花时间和内存创建副本，从而提高效率，在处理大型列表时尤其如此。

动手试一试

8-9 魔术师：创建一个包含魔术师名字的列表，并将其传递给一个名为show_magicians()的函数，这个函数打印列表中每个魔术师的名字。

8-10 了不起的魔术师：在你为完成练习8-9而编写的程序中，编写一个名为make_great()的函数，对魔术师列表进行修改，在每个魔术师的名字中都加入字样“the Great”。调用函数show_magicians()，确认魔术师列表确实变了。

8-11 不变的魔术师：修改你为完成练习8-10而编写的程序，在调用函数make_great()时，向它传递魔术师列表的副本。由于不想修改原始列表，请返回修改后的列表，并将其存储到另一个列表中。分别使用这两个列表来调用show_magicians()，确认一个列表包含的是原来的魔术师名字，而另一个列表包含的是添加了字样“the Great”的魔术师名字。

8.5　传递任意数量的实参

有时候，你预先不知道函数需要接受多少个实参，好在Python允许函数从调用语句中收集任意数量的实参。

例如，来看一个制作比萨的函数，它需要接受很多配料，但你无法预先确定顾客要多少种配料。下面的函数只有一个形参*toppings，但不管调用语句提供了多少实参，这个形参都将它们统统收入囊中：

pizza.py

def make_pizza(*toppings):
    """打印顾客点的所有配料"""
    print(toppings)

make_pizza('pepperoni')
make_pizza('mushrooms', 'green peppers', 'extra cheese')

形参名*toppings中的星号让Python创建一个名为toppings的空元组，并将收到的所有值都封装到这个元组中。函数体内的print语句通过生成输出来证明Python能够处理使用一个值调用函数的情形，也能处理使用三个值来调用函数的情形。它以类似的方式处理不同的调用，注意，Python将实参封装到一个元组中，即便函数只收到一个值也如此：

('pepperoni',)
('mushrooms', 'green peppers', 'extra cheese')

现在，我们可以将这条print语句替换为一个循环，对配料列表进行遍历，并对顾客点的比萨进行描述：

def make_pizza(*toppings):
    """概述要制作的比萨"""
    print("\nMaking a pizza with the following toppings:")
    for topping in toppings:
        print("- " + topping)

make_pizza('pepperoni')
make_pizza('mushrooms', 'green peppers', 'extra cheese')

不管收到的是一个值还是三个值，这个函数都能妥善地处理：

Making a pizza with the following toppings:
- pepperoni

Making a pizza with the following toppings:
- mushrooms
- green peppers
- extra cheese

不管函数收到的实参是多少个，这种语法都管用。

8.5.1　结合使用位置实参和任意数量实参

如果要让函数接受不同类型的实参，必须在函数定义中将接纳任意数量实参的形参放在最后。Python先匹配位置实参和关键字实参，再将余下的实参都收集到最后一个形参中。

例如，如果前面的函数还需要一个表示比萨尺寸的实参，必须将该形参放在形参*toppings的前面：

def make_pizza(size, *toppings):
    """概述要制作的比萨"""
    print("\nMaking a " + str(size) +
          "-inch pizza with the following toppings:")
    for topping in toppings:
        print("- " + topping)

make_pizza(16, 'pepperoni')
make_pizza(12, 'mushrooms', 'green peppers', 'extra cheese')

基于上述函数定义，Python将收到的第一个值存储在形参size中，并将其他的所有值都存储在元组toppings中。在函数调用中，首先指定表示比萨尺寸的实参，然后根据需要指定任意数量的配料。

现在，每个比萨都有了尺寸和一系列配料，这些信息按正确的顺序打印出来了——首先是尺寸，然后是配料：

Making a 16-inch pizza with the following toppings:
- pepperoni

Making a 12-inch pizza with the following toppings:
- mushrooms
- green peppers
- extra cheese

8.5.2　使用任意数量的关键字实参

有时候，需要接受任意数量的实参，但预先不知道传递给函数的会是什么样的信息。在这种情况下，可将函数编写成能够接受任意数量的键—值对——调用语句提供了多少就接受多少。一个这样的示例是创建用户简介：你知道你将收到有关用户的信息，但不确定会是什么样的信息。在下面的示例中，函数build_profile()接受名和姓，同时还接受任意数量的关键字实参：

user_profile.py

def build_profile(first, last, **user_info):
    """创建一个字典，其中包含我们知道的有关用户的一切"""
    profile = {}
    profile['first_name'] = first ❶
    profile['last_name'] = last
    for key, value in user_info.items(): ❷
        profile[key] = value
    return profile

user_profile = build_profile('albert', 'einstein',
                             location='princeton',
                             field='physics')
print(user_profile)

函数build_profile()的定义要求提供名和姓，同时允许用户根据需要提供任意数量的名称—值对。形参**user_info中的两个星号让Python创建一个名为user_info的空字典，并将收到的所有名称—值对都封装到这个字典中。在这个函数中，可以像访问其他字典那样访问user_info中的名称—值对。

在build_profile()的函数体内，我们创建了一个名为profile的空字典，用于存储用户简介。在❶处，我们将名和姓加入到这个字典中，因为我们总是会从用户那里收到这两项信息。在❷处，我们遍历字典user_info中的键—值对，并将每个键—值对都加入到字典profile中。最后，我们将字典profile返回给函数调用行。

我们调用build_profile()，向它传递名（'albert'）、姓（'einstein'）和两个键—值对（location='princeton'和field='physics'），并将返回的profile存储在变量user_profile中，再打印这个变量：

{'first_name': 'albert', 'last_name': 'einstein',
'location': 'princeton', 'field': 'physics'}

在这里，返回的字典包含用户的名和姓，还有求学的地方和所学专业。调用这个函数时，不管额外提供了多少个键—值对，它都能正确地处理。

编写函数时，你可以以各种方式混合使用位置实参、关键字实参和任意数量的实参。知道这些实参类型大有裨益，因为阅读别人编写的代码时经常会见到它们。要正确地使用这些类型的实参并知道它们的使用时机，需要经过一定的练习。就目前而言，牢记使用最简单的方法来完成任务就好了。你继续往下阅读，就会知道在各种情况下哪种方法的效率是最高的。

动手试一试

8-12 三明治：编写一个函数，它接受顾客要在三明治中添加的一系列食材。这个函数只有一个形参（它收集函数调用中提供的所有食材），并打印一条消息，对顾客点的三明治进行概述。调用这个函数三次，每次都提供不同数量的实参。

8-13 用户简介：复制前面的程序user_profile.py，在其中调用build_profile()来创建有关你的简介；调用这个函数时，指定你的名和姓，以及三个描述你的键-值对。

8-14 汽车：编写一个函数，将一辆汽车的信息存储在一个字典中。这个函数总是接受制造商和型号，还接受任意数量的关键字实参。这样调用这个函数：提供必不可少的信息，以及两个名称—值对，如颜色和选装配件。这个函数必须能够像下面这样进行调用：

car = make_car('subaru', 'outback', color='blue', tow_package=True)

打印返回的字典，确认正确地处理了所有的信息。

8.6　将函数存储在模块中

函数的优点之一是，使用它们可将代码块与主程序分离。通过给函数指定描述性名称，可让主程序容易理解得多。你还可以更进一步，将函数存储在被称为模块的独立文件中，再将模块导入到主程序中。import语句允许在当前运行的程序文件中使用模块中的代码。

通过将函数存储在独立的文件中，可隐藏程序代码的细节，将重点放在程序的高层逻辑上。这还能让你在众多不同的程序中重用函数。将函数存储在独立文件中后，可与其他程序员共享这些文件而不是整个程序。知道如何导入函数还能让你使用其他程序员编写的函数库。

导入模块的方法有多种，下面对每种都作简要的介绍。

8.6.1　导入整个模块

要让函数是可导入的，得先创建模块。模块是扩展名为.py的文件，包含要导入到程序中的代码。下面来创建一个包含函数make_pizza()的模块。为此，我们将文件pizza.py中除函数make_pizza()之外的其他代码都删除：

pizza.py

def make_pizza(size, *toppings):
    """概述要制作的比萨"""
    print("\nMaking a " + str(size) +
          "-inch pizza with the following toppings:")
    for topping in toppings:
        print("- " + topping)

接下来，我们在pizza.py所在的目录中创建另一个名为making_pizzas.py的文件，这个文件导入刚创建的模块，再调用make_pizza()两次：

making_pizzas.py

import pizza

pizza.make_pizza(16, 'pepperoni') ❶
pizza.make_pizza(12, 'mushrooms', 'green peppers', 'extra cheese')

Python读取这个文件时，代码行import pizza让Python打开文件pizza.py，并将其中的所有函数都复制到这个程序中。你看不到复制的代码，因为这个程序运行时，Python在幕后复制这些代码。你只需知道，在making_pizzas.py中，可以使用pizza.py中定义的所有函数。

要调用被导入的模块中的函数，可指定导入的模块的名称pizza和函数名make_pizza()，并用句点分隔它们（见❶）。这些代码的输出与没有导入模块的原始程序相同：

Making a 16-inch pizza with the following toppings:
- pepperoni

Making a 12-inch pizza with the following toppings:
- mushrooms
- green peppers
- extra cheese

这就是一种导入方法：只需编写一条import语句并在其中指定模块名，就可在程序中使用该模块中的所有函数。如果你使用这种import语句导入了名为module_name.py的整个模块，就可使用下面的语法来使用其中任何一个函数：

module_name.function_name()

8.6.2　导入特定的函数

你还可以导入模块中的特定函数，这种导入方法的语法如下：

from module_name import function_name

通过用逗号分隔函数名，可根据需要从模块中导入任意数量的函数：

from module_name import function_0, function_1, function_2

对于前面的making_pizzas.py示例，如果只想导入要使用的函数，代码将类似于下面这样：

from pizza import make_pizza

make_pizza(16, 'pepperoni')
make_pizza(12, 'mushrooms', 'green peppers', 'extra cheese')

若使用这种语法，调用函数时就无需使用句点。由于我们在import语句中显式地导入了函数make_pizza()，因此调用它时只需指定其名称。

8.6.3　使用as给函数指定别名

如果要导入的函数的名称可能与程序中现有的名称冲突，或者函数的名称太长，可指定简短而独一无二的别名——函数的另一个名称，类似于外号。要给函数指定这种特殊外号，需要在导入它时这样做。

下面给函数make_pizza()指定了别名mp()。这是在import语句中使用make_pizza as mp实现的，关键字as将函数重命名为你提供的别名：

from pizza import make_pizza as mp

mp(16, 'pepperoni')
mp(12, 'mushrooms', 'green peppers', 'extra cheese')

上面的import语句将函数make_pizza()重命名为mp()；在这个程序中，每当需要调用make_pizza()时，都可简写成mp()，而Python将运行make_pizza()中的代码，这可避免与这个程序可能包含的函数make_pizza()混淆。

指定别名的通用语法如下：

from module_name import function_name as fn

8.6.4　使用as给模块指定别名

你还可以给模块指定别名。通过给模块指定简短的别名（如给模块pizza指定别名p），让你能够更轻松地调用模块中的函数。相比于pizza.make_pizza()，p.make_pizza()更为简洁：

import pizza as p

p.make_pizza(16, 'pepperoni')
p.make_pizza(12, 'mushrooms', 'green peppers', 'extra cheese')

上述import语句给模块pizza指定了别名p，但该模块中所有函数的名称都没变。调用函数make_pizza()时，可编写代码p.make_pizza()而不是pizza.make_pizza()，这样不仅能使代码更简洁，还可以让你不再关注模块名，而专注于描述性的函数名。这些函数名明确地指出了函数的功能，对理解代码而言，它们比模块名更重要。

给模块指定别名的通用语法如下：

import module_name as mn

8.6.5　导入模块中的所有函数

使用星号（*）运算符可让Python导入模块中的所有函数：

from pizza import *

make_pizza(16, 'pepperoni')
make_pizza(12, 'mushrooms', 'green peppers', 'extra cheese')

import语句中的星号让Python将模块pizza中的每个函数都复制到这个程序文件中。由于导入了每个函数，可通过名称来调用每个函数，而无需使用句点表示法。然而，使用并非自己编写的大型模块时，最好不要采用这种导入方法：如果模块中有函数的名称与你的项目中使用的名称相同，可能导致意想不到的结果：Python可能遇到多个名称相同的函数或变量，进而覆盖函数，而不是分别导入所有的函数。

最佳的做法是，要么只导入你需要使用的函数，要么导入整个模块并使用句点表示法。这能让代码更清晰，更容易阅读和理解。这里之所以介绍这种导入方法，只是想让你在阅读别人编写的代码时，如果遇到类似于下面的import语句，能够理解它们：

from module_name import *

8.7　函数编写指南

编写函数时，需要牢记几个细节。应给函数指定描述性名称，且只在其中使用小写字母和下划线。描述性名称可帮助你和别人明白代码想要做什么。给模块命名时也应遵循上述约定。

每个函数都应包含简要地阐述其功能的注释，该注释应紧跟在函数定义后面，并采用文档字符串格式。文档良好的函数让其他程序员只需阅读文档字符串中的描述就能够使用它：他们完全可以相信代码如描述的那样运行；只要知道函数的名称、需要的实参以及返回值的类型，就能在自己的程序中使用它。

给形参指定默认值时，等号两边不要有空格：

def function_name(parameter_0, parameter_1='default value')

对于函数调用中的关键字实参，也应遵循这种约定：

function_name(value_0, parameter_1='value')

PEP 8（https://www.python.org/dev/peps/pep-0008/）建议代码行的长度不要超过79字符，这样只要编辑器窗口适中，就能看到整行代码。如果形参很多，导致函数定义的长度超过了79字符，可在函数定义中输入左括号后按回车键，并在下一行按两次Tab键，从而将形参列表和只缩进一层的函数体区分开来。

大多数编辑器都会自动对齐后续参数列表行，使其缩进程度与你给第一个参数列表行指定的缩进程度相同：

def function_name(
        parameter_0, parameter_1, parameter_2,
        parameter_3, parameter_4, parameter_5):
    function body...

如果程序或模块包含多个函数，可使用两个空行将相邻的函数分开，这样将更容易知道前一个函数在什么地方结束，下一个函数从什么地方开始。

所有的import语句都应放在文件开头，唯一例外的情形是，在文件开头使用了注释来描述整个程序。

动手试一试

8-15 打印模型：将示例print_models.py中的函数放在另一个名为printing_functions.py的文件中；在print_models.py的开头编写一条import语句，并修改这个文件以使用导入的函数。

8-16 导入：选择一个你编写的且只包含一个函数的程序，并将这个函数放在另一个文件中。在主程序文件中，使用下述各种方法导入这个函数，再调用它：

import module_name
from module_name import function_name
from module_name import function_name as fn
import module_name as mn
from module_name import *

8-17 函数编写指南：选择你在本章中编写的三个程序，确保它们遵循了本节介绍的函数编写指南。

8.8　小结

在本章中，你学习了：如何编写函数，以及如何传递实参，让函数能够访问完成其工作所需的信息；如何使用位置实参和关键字实参，以及如何接受任意数量的实参；显示输出的函数和返回值的函数；如何将函数同列表、字典、if语句和while循环结合起来使用。你还知道了如何将函数存储在被称为模块的独立文件中，让程序文件更简单、更易于理解。最后，你学习了函数编写指南，遵循这些指南可让程序始终结构良好，并对你和其他人来说易于阅读。

程序员的目标之一是，编写简单的代码来完成任务，而函数有助于你实现这样的目标。它们让你编写好代码块并确定其能够正确运行后，就可置之不理。确定函数能够正确地完成其工作后，你就可以接着投身于下一个编码任务。

函数让你编写代码一次后，想重用它们多少次就重用多少次。需要运行函数中的代码时，只需编写一行函数调用代码，就可让函数完成其工作。需要修改函数的行为时，只需修改一个代码块，而所做的修改将影响调用这个函数的每个地方。

使用函数让程序更容易阅读，而良好的函数名概述了程序各个部分的作用。相对于阅读一系列的代码块，阅读一系列函数调用让你能够更快地明白程序的作用。

函数还让代码更容易测试和调试。如果程序使用一系列的函数来完成其任务，而其中的每个函数都完成一项具体的工作，测试和维护起来将容易得多：你可编写分别调用每个函数的程序，并测试每个函数是否在它可能遇到的各种情形下都能正确地运行。经过这样的测试后你就能信心满满，深信你每次调用这些函数时，它们都将正确地运行。

在第9章，你将学习编写类。类将函数和数据整洁地封装起来，让你能够灵活而高效地使用它们。

第 9 章　类

面向对象编程是最有效的软件编写方法之一。在面向对象编程中，你编写表示现实世界中的事物和情景的类，并基于这些类来创建对象。编写类时，你定义一大类对象都有的通用行为。基于类创建对象时，每个对象都自动具备这种通用行为，然后可根据需要赋予每个对象独特的个性。使用面向对象编程可模拟现实情景，其逼真程度达到了令你惊讶的地步。

根据类来创建对象被称为实例化，这让你能够使用类的实例。在本章中，你将编写一些类并创建其实例。你将指定可在实例中存储什么信息，定义可对这些实例执行哪些操作。你还将编写一些类来扩展既有类的功能，让相似的类能够高效地共享代码。你将把自己编写的类存储在模块中，并在自己的程序文件中导入其他程序员编写的类。

理解面向对象编程有助于你像程序员那样看世界，还可以帮助你真正明白自己编写的代码：不仅是各行代码的作用，还有代码背后更宏大的概念。了解类背后的概念可培养逻辑思维，让你能够通过编写程序来解决遇到的几乎任何问题。

随着面临的挑战日益严峻，类还能让你以及与你合作的其他程序员的生活更轻松。如果你与其他程序员基于同样的逻辑来编写代码，你们就能明白对方所做的工作；你编写的程序将能被众多合作者所理解，每个人都能事半功倍。

9.1　创建和使用类

使用类几乎可以模拟任何东西。下面来编写一个表示小狗的简单类Dog——它表示的不是特定的小狗，而是任何小狗。对于大多数宠物狗，我们都知道些什么呢？它们都有名字和年龄；我们还知道，大多数小狗还会蹲下和打滚。由于大多数小狗都具备上述两项信息（名字和年龄）和两种行为（蹲下和打滚），我们的Dog类将包含它们。这个类让Python知道如何创建表示小狗的对象。编写这个类后，我们将使用它来创建表示特定小狗的实例。

9.1.1　创建Dog类

根据Dog类创建的每个实例都将存储名字和年龄。我们赋予了每条小狗蹲下（sit()）和打滚（roll_over()）的能力：

dog.py

class Dog(): ❶
    """一次模拟小狗的简单尝试""" ❷

    def __init__(self, name, age): ❸
        """初始化属性name和age"""
        self.name = name ❹
        self.age = age

    def sit(self): ❺
        """模拟小狗被命令时蹲下"""
        print(self.name.title() + " is now sitting.")

    def roll_over(self):
        """模拟小狗被命令时打滚"""
        print(self.name.title() + " rolled over!")

这里需要注意的地方很多，但你也不用担心，本章充斥着这样的结构，你有大把的机会熟悉它。在❶处，我们定义了一个名为Dog的类。根据约定，在Python中，首字母大写的名称指的是类。这个类定义中的括号是空的，因为我们要从空白创建这个类。在❷处，我们编写了一个文档字符串，对这个类的功能作了描述。

1. 方法__init__()

类中的函数称为方法；你前面学到的有关函数的一切都适用于方法，就目前而言，唯一重要的差别是调用方法的方式。❸处的方法__init__()是一个特殊的方法，每当你根据Dog类创建新实例时，Python都会自动运行它。在这个方法的名称中，开头和末尾各有两个下划线，这是一种约定，旨在避免Python默认方法与普通方法发生名称冲突。

我们将方法__init__()定义成了包含三个形参：self、name和age。在这个方法的定义中，形参self必不可少，还必须位于其他形参的前面。为何必须在方法定义中包含形参self呢？因为Python调用这个__init__()方法来创建Dog实例时，将自动传入实参self。每个与类相关联的方法调用都自动传递实参self，它是一个指向实例本身的引用，让实例能够访问类中的属性和方法。我们创建Dog实例时，Python将调用Dog类的方法__init__()。我们将通过实参向Dog()传递名字和年龄；self会自动传递，因此我们不需要传递它。每当我们根据Dog类创建实例时，都只需给最后两个形参（name和age）提供值。

❹处定义的两个变量都有前缀self。以self为前缀的变量都可供类中的所有方法使用，我们还可以通过类的任何实例来访问这些变量。self.name = name获取存储在形参name中的值，并将其存储到变量name中，然后该变量被关联到当前创建的实例。self.age = age的作用与此类似。像这样可通过实例访问的变量称为属性。

Dog类还定义了另外两个方法：sit()和roll_over()（见❺）。由于这些方法不需要额外的信息，如名字或年龄，因此它们只有一个形参self。我们后面将创建的实例能够访问这些方法，换句话说，它们都会蹲下和打滚。当前，sit()和roll_over()所做的有限，它们只是打印一条消息，指出小狗正蹲下或打滚。但可以扩展这些方法以模拟实际情况：如果这个类包含在一个计算机游戏中，这些方法将包含创建小狗蹲下和打滚动画效果的代码。如果这个类是用于控制机器狗的，这些方法将引导机器狗做出蹲下和打滚的动作。

2. 在Python 2.7中创建类

在Python 2.7中创建类时，需要做细微的修改——在括号内包含单词object：

class ClassName(object):
    --snip--

这让Python 2.7类的行为更像Python 3类，从而简化了你的工作。

在Python 2.7中定义Dog类时，代码类似于下面这样：

class Dog(object):
    --snip--

9.1.2　根据类创建实例

可将类视为有关如何创建实例的说明。Dog类是一系列说明，让Python知道如何创建表示特定小狗的实例。

下面来创建一个表示特定小狗的实例：

class Dog():
    --snip--

my_dog = Dog('willie', 6) ❶

print("My dog's name is " + my_dog.name.title() + ".") ❷
print("My dog is " + str(my_dog.age) + " years old.") ❸

这里使用的是前一个示例中编写的Dog类。在❶处，我们让Python创建一条名字为'willie'、年龄为6的小狗。遇到这行代码时，Python使用实参'willie'和6调用Dog类中的方法__init__()。方法__init__()创建一个表示特定小狗的示例，并使用我们提供的值来设置属性name和age。方法__init__()并未显式地包含return语句，但Python自动返回一个表示这条小狗的实例。我们将这个实例存储在变量my_dog中。在这里，命名约定很有用：我们通常可以认为首字母大写的名称（如Dog）指的是类，而小写的名称（如my_dog）指的是根据类创建的实例。

1. 访问属性

要访问实例的属性，可使用句点表示法。在❷处，我们编写了如下代码来访问my_dog的属性name的值：

my_dog.name

句点表示法在Python中很常用，这种语法演示了Python如何获悉属性的值。在这里，Python先找到实例my_dog，再查找与这个实例相关联的属性name。在Dog类中引用这个属性时，使用的是self.name。在❸处，我们使用同样的方法来获取属性age的值。在前面的第1条print语句中，my_dog.name.title()将my_dog的属性name的值'willie'改为首字母大写的；在第2条print语句中，str(my_dog.age)将my_dog的属性age的值6转换为字符串。

输出是有关my_dog的摘要：

My dog's name is Willie.
My dog is 6 years old.

2. 调用方法

根据Dog类创建实例后，就可以使用句点表示法来调用Dog类中定义的任何方法。下面来让小狗蹲下和打滚：

class Dog():
    --snip--

my_dog = Dog('willie', 6)
my_dog.sit()
my_dog.roll_over()

要调用方法，可指定实例的名称（这里是my_dog）和要调用的方法，并用句点分隔它们。遇到代码my_dog.sit()时，Python在类Dog中查找方法sit()并运行其代码。Python以同样的方式解读代码my_dog.roll_over()。

Willie按我们的命令做了：

Willie is now sitting.
Willie rolled over!

这种语法很有用。如果给属性和方法指定了合适的描述性名称，如name、age、sit()和roll_over()，即便是从未见过的代码块，我们也能够轻松地推断出它是做什么的。

3. 创建多个实例

可按需求根据类创建任意数量的实例。下面再创建一个名为your_dog的实例：

class Dog():
    --snip--

my_dog = Dog('willie', 6)
your_dog = Dog('lucy', 3)

print("My dog's name is " + my_dog.name.title() + ".")
print("My dog is " + str(my_dog.age) + " years old.")
my_dog.sit()

print("\nYour dog's name is " + your_dog.name.title() + ".")
print("Your dog is " + str(your_dog.age) + " years old.")
your_dog.sit()

在这个实例中，我们创建了两条小狗，它们分别名为Willie和Lucy。每条小狗都是一个独立的实例，有自己的一组属性，能够执行相同的操作：

My dog's name is Willie.
My dog is 6 years old.
Willie is now sitting.

Your dog's name is Lucy.
Your dog is 3 years old.
Lucy is now sitting.

就算我们给第二条小狗指定同样的名字和年龄，Python依然会根据Dog类创建另一个实例。你可按需求根据一个类创建任意数量的实例，条件是将每个实例都存储在不同的变量中，或占用列表或字典的不同位置。

动手试一试

9-1 餐馆：创建一个名为Restaurant的类，其方法__init__()设置两个属性：restaurant_name和cuisine_type。创建一个名为describe_restaurant()的方法和一个名为open_restaurant()的方法，其中前者打印前述两项信息，而后者打印一条消息，指出餐馆正在营业。

根据这个类创建一个名为restaurant的实例，分别打印其两个属性，再调用前述两个方法。

9-2 三家餐馆：根据你为完成练习9-1而编写的类创建三个实例，并对每个实例调用方法describe_restaurant()。

9-3 用户：创建一个名为User的类，其中包含属性first_name和last_name，还有用户简介通常会存储的其他几个属性。在类User中定义一个名为describe_user()的方法，它打印用户信息摘要；再定义一个名为greet_user()的方法，它向用户发出个性化的问候。

创建多个表示不同用户的实例，并对每个实例都调用上述两个方法。

9.2　使用类和实例

你可以使用类来模拟现实世界中的很多情景。类编写好后，你的大部分时间都将花在使用根据类创建的实例上。你需要执行的一个重要任务是修改实例的属性。你可以直接修改实例的属性，也可以编写方法以特定的方式进行修改。

9.2.1　Car类

下面来编写一个表示汽车的类，它存储了有关汽车的信息，还有一个汇总这些信息的方法：

car.py

class Car():
    """一次模拟汽车的简单尝试"""

    def __init__(self, make, model, year): ❶
        """初始化描述汽车的属性"""
        self.make = make
        self.model = model
        self.year = year

    def get_descriptive_name(self): ❷
        """返回整洁的描述性信息"""
        long_name = str(self.year) + ' ' + self.make + ' ' + self.model
        return long_name.title()

my_new_car = Car('audi', 'a4', 2016) ❸
print(my_new_car.get_descriptive_name())

在❶处，我们定义了方法__init__()。与前面的Dog类中一样，这个方法的第一个形参为self；我们还在这个方法中包含了另外三个形参：make、model和year。方法__init__()接受这些形参的值，并将它们存储在根据这个类创建的实例的属性中。创建新的Car实例时，我们需要指定其制造商、型号和生产年份。

在❷处，我们定义了一个名为get_descriptive_name()的方法，它使用属性year、make和model创建一个对汽车进行描述的字符串，让我们无需分别打印每个属性的值。为在这个方法中访问属性的值，我们使用了self.make、self.model和self.year。在❸处，我们根据Car类创建了一个实例，并将其存储到变量my_new_car中。接下来，我们调用方法get_descriptive_name()，指出我们拥有的是一辆什么样的汽车：

2016 Audi A4

为让这个类更有趣，下面给它添加一个随时间变化的属性，它存储汽车的总里程。

9.2.2　给属性指定默认值

类中的每个属性都必须有初始值，哪怕这个值是0或空字符串。在有些情况下，如设置默认值时，在方法__init__()内指定这种初始值是可行的；如果你对某个属性这样做了，就无需包含为它提供初始值的形参。

下面来添加一个名为odometer_reading的属性，其初始值总是为0。我们还添加了一个名为read_odometer()的方法，用于读取汽车的里程表：

class Car():

    def __init__(self, make, model, year):
        """初始化描述汽车的属性"""
        self.make = make
        self.model = model
        self.year = year
        self.odometer_reading = 0 ❶

    def get_descriptive_name(self):
        --snip--

    def read_odometer(self): ❷
        """打印一条指出汽车里程的消息"""
        print("This car has " + str(self.odometer_reading) + " miles on it.")

my_new_car = Car('audi', 'a4', 2016)
print(my_new_car.get_descriptive_name())
my_new_car.read_odometer()

现在，当Python调用方法__init__()来创建新实例时，将像前一个示例一样以属性的方式存储制造商、型号和生产年份。接下来，Python将创建一个名为odometer_reading的属性，并将其初始值设置为0（见❶）。在❷处，我们还定义了一个名为read_odometer()的方法，它让你能够轻松地获悉汽车的里程。

一开始汽车的里程为0：

2016 Audi A4
This car has 0 miles on it.

出售时里程表读数为0的汽车并不多，因此我们需要一个修改该属性的值的途径。

9.2.3　修改属性的值

可以以三种不同的方式修改属性的值：直接通过实例进行修改；通过方法进行设置；通过方法进行递增（增加特定的值）。下面依次介绍这些方法。

1. 直接修改属性的值

要修改属性的值，最简单的方式是通过实例直接访问它。下面的代码直接将里程表读数设置为23：

class Car():
    --snip--

my_new_car = Car('audi', 'a4', 2016)
print(my_new_car.get_descriptive_name())

my_new_car.odometer_reading = 23 ❶
my_new_car.read_odometer()

在❶处，我们使用句点表示法来直接访问并设置汽车的属性odometer_reading。这行代码让Python在实例my_new_car中找到属性odometer_reading，并将该属性的值设置为23：

2016 Audi A4
This car has 23 miles on it.

有时候需要像这样直接访问属性，但其他时候需要编写对属性进行更新的方法。

2. 通过方法修改属性的值

如果有替你更新属性的方法，将大有裨益。这样，你就无需直接访问属性，而可将值传递给一个方法，由它在内部进行更新。

下面的示例演示了一个名为update_odometer()的方法：

class Car():
    --snip--

    def update_odometer(self, mileage): ❶
        """将里程表读数设置为指定的值"""
        self.odometer_reading = mileage

my_new_car = Car('audi', 'a4', 2016)
print(my_new_car.get_descriptive_name())

my_new_car.update_odometer(23) ❷
my_new_car.read_odometer()

对Car类所做的唯一修改是在❶处添加了方法update_odometer()。这个方法接受一个里程值，并将其存储到self.odometer_reading中。在❷处，我们调用了update_odometer()，并向它提供了实参23（该实参对应于方法定义中的形参mileage）。它将里程表读数设置为23；而方法read_odometer()打印该读数：

2016 Audi A4
This car has 23 miles on it.

可对方法update_odometer()进行扩展，使其在修改里程表读数时做些额外的工作。下面来添加一些逻辑，禁止任何人将里程表读数往回调：

class Car():
    --snip--

    def update_odometer(self, mileage):
        """
        将里程表读数设置为指定的值
        禁止将里程表读数往回调
        """
       if mileage >= self.odometer_reading: ❶
            self.odometer_reading = mileage
        else:
            print("You can't roll back an odometer!") ❷

现在，update_odometer()在修改属性前检查指定的读数是否合理。如果新指定的里程（mileage）大于或等于原来的里程（self.odometer_reading），就将里程表读数改为新指定的里程（见❶）；否则就发出警告，指出不能将里程表往回拨（见❷）。

3. 通过方法对属性的值进行递增

有时候需要将属性值递增特定的量，而不是将其设置为全新的值。假设我们购买了一辆二手车，且从购买到登记期间增加了100英里的里程，下面的方法让我们能够传递这个增量，并相应地增加里程表读数：

class Car():
    --snip--

    def update_odometer(self, mileage):
        --snip--

    def increment_odometer(self, miles): ❶
        """将里程表读数增加指定的量"""
        self.odometer_reading += miles

my_used_car = Car('subaru', 'outback', 2013) ❷
print(my_used_car.get_descriptive_name())

my_used_car.update_odometer(23500) ❸
my_used_car.read_odometer()

my_used_car.increment_odometer(100) ❹
my_used_car.read_odometer()

在❶处，新增的方法increment_odometer()接受一个单位为英里的数字，并将其加入到self.odometer_reading中。在❷处，我们创建了一辆二手车——my_used_car。在❸处，我们调用方法update_odometer()并传入23500，将这辆二手车的里程表读数设置为23 500。在❹处，我们调用increment_odometer()并传入100，以增加从购买到登记期间行驶的100英里：

2013 Subaru Outback
This car has 23500 miles on it.
This car has 23600 miles on it.

你可以轻松地修改这个方法，以禁止增量为负值，从而防止有人利用它来回拨里程表。

注意　你可以使用类似于上面的方法来控制用户修改属性值（如里程表读数）的方式，但能够访问程序的人都可以通过直接访问属性来将里程表修改为任何值。要确保安全，除了进行类似于前面的基本检查外，还需特别注意细节。

动手试一试

9-4 就餐人数：在为完成练习9-1而编写的程序中，添加一个名为number_served的属性，并将其默认值设置为0。根据这个类创建一个名为restaurant的实例；打印有多少人在这家餐馆就餐过，然后修改这个值并再次打印它。

添加一个名为set_number_served()的方法，它让你能够设置就餐人数。调用这个方法并向它传递一个值，然后再次打印这个值。

添加一个名为increment_number_served()的方法，它让你能够将就餐人数递增。调用这个方法并向它传递一个这样的值：你认为这家餐馆每天可能接待的就餐人数。

9-5 尝试登录次数：在为完成练习9-3而编写的User类中，添加一个名为login_attempts 的属性。编写一个名为increment_login_attempts()的方法，它将属性login_attempts的值加1。再编写一个名为reset_login_attempts()的方法，它将属性login_attempts的值重置为0。

根据User类创建一个实例，再调用方法increment_login_attempts()多次。打印属性login_attempts的值，确认它被正确地递增；然后，调用方法reset_login_attempts()，并再次打印属性login_attempts的值，确认它被重置为0。

9.3　继承

编写类时，并非总是要从空白开始。如果你要编写的类是另一个现成类的特殊版本，可使用继承。一个类继承另一个类时，它将自动获得另一个类的所有属性和方法；原有的类称为父类，而新类称为子类。子类继承了其父类的所有属性和方法，同时还可以定义自己的属性和方法。

9.3.1　子类的方法__init__()

创建子类的实例时，Python首先需要完成的任务是给父类的所有属性赋值。为此，子类的方法__init__()需要父类施以援手。

例如，下面来模拟电动汽车。电动汽车是一种特殊的汽车，因此我们可以在前面创建的Car类的基础上创建新类ElectricCar，这样我们就只需为电动汽车特有的属性和行为编写代码。

下面来创建一个简单的ElectricCar类版本，它具备Car类的所有功能：

electric_car.py

class Car(): ❶
    """一次模拟汽车的简单尝试"""

    def __init__(self, make, model, year):
        self.make = make
        self.model = model
        self.year = year
        self.odometer_reading = 0

    def get_descriptive_name(self):
        long_name = str(self.year) + ' ' + self.make + ' ' + self.model
        return long_name.title()

    def read_odometer(self):
        print("This car has " + str(self.odometer_reading) + " miles on it.")

    def update_odometer(self, mileage):
        if mileage >= self.odometer_reading:
            self.odometer_reading = mileage
        else:
            print("You can't roll back an odometer!")

    def increment_odometer(self, miles):
        self.odometer_reading += miles

class ElectricCar(Car): ❷
    """电动汽车的独特之处"""

    def __init__(self, make, model, year): ❸
        """初始化父类的属性"""
        super().__init__(make, model, year) ❹

my_tesla = ElectricCar('tesla', 'model s', 2016) ❺
print(my_tesla.get_descriptive_name())

首先是Car类的代码（见❶）。创建子类时，父类必须包含在当前文件中，且位于子类前面。在❷处，我们定义了子类ElectricCar。定义子类时，必须在括号内指定父类的名称。方法__init__()接受创建Car实例所需的信息（见❸）。

❹处的super()是一个特殊函数，帮助Python将父类和子类关联起来。这行代码让Python调用ElectricCar的父类的方法__init__()，让ElectricCar实例包含父类的所有属性。父类也称为超类（superclass），名称super因此而得名。

为测试继承是否能够正确地发挥作用，我们尝试创建一辆电动汽车，但提供的信息与创建普通汽车时相同。在❺处，我们创建ElectricCar类的一个实例，并将其存储在变量my_tesla中。这行代码调用ElectricCar类中定义的方法__init__()，后者让Python调用父类Car中定义的方法__init__()。我们提供了实参'tesla'、'model s'和2016。

除方法__init__()外，电动汽车没有其他特有的属性和方法。当前，我们只想确认电动汽车具备普通汽车的行为：

2016 Tesla Model S

ElectricCar实例的行为与Car实例一样，现在可以开始定义电动汽车特有的属性和方法了。

9.3.2　Python 2.7中的继承

在Python 2.7中，继承语法稍有不同，ElectricCar类的定义类似于下面这样：

class Car(object):
    def __init__(self, make, model, year):
        --snip--

class ElectricCar(Car):
    def __init__(self, make, model, year):
        super(ElectricCar, self).__init__(make, model, year)
        --snip--

函数super()需要两个实参：子类名和对象self。为帮助Python将父类和子类关联起来，这些实参必不可少。另外，在Python 2.7中使用继承时，务必在定义父类时在括号内指定object。

9.3.3　给子类定义属性和方法

让一个类继承另一个类后，可添加区分子类和父类所需的新属性和方法。

下面来添加一个电动汽车特有的属性（电瓶），以及一个描述该属性的方法。我们将存储电瓶容量，并编写一个打印电瓶描述的方法：

class Car():
    --snip--

class ElectricCar(Car):
    """Represent aspects of a car, specific to electric vehicles."""

    def __init__(self, make, model, year):
        """
        电动汽车的独特之处
        初始化父类的属性，再初始化电动汽车特有的属性
        """
        super().__init__(make, model, year)
        self.battery_size = 70 ❶

   def describe_battery(self): ❷
        """打印一条描述电瓶容量的消息"""
        print("This car has a " + str(self.battery_size) + "-kWh battery.")

my_tesla = ElectricCar('tesla', 'model s', 2016)
print(my_tesla.get_descriptive_name())
my_tesla.describe_battery()

在❶处，我们添加了新属性self.battery_size，并设置其初始值（如70）。根据ElectricCar 类创建的所有实例都将包含这个属性，但所有Car实例都不包含它。在❷处，我们还添加了一个名为describe_battery()的方法，它打印有关电瓶的信息。我们调用这个方法时，将看到一条电动汽车特有的描述：

2016 Tesla Model S
This car has a 70-kWh battery.

对于ElectricCar类的特殊化程度没有任何限制。模拟电动汽车时，你可以根据所需的准确程度添加任意数量的属性和方法。如果一个属性或方法是任何汽车都有的，而不是电动汽车特有的，就应将其加入到Car类而不是ElectricCar类中。这样，使用Car类的人将获得相应的功能，而ElectricCar类只包含处理电动汽车特有属性和行为的代码。

9.3.4　重写父类的方法

对于父类的方法，只要它不符合子类模拟的实物的行为，都可对其进行重写。为此，可在子类中定义一个这样的方法，即它与要重写的父类方法同名。这样，Python将不会考虑这个父类方法，而只关注你在子类中定义的相应方法。

假设Car类有一个名为fill_gas_tank()的方法，它对全电动汽车来说毫无意义，因此你可能想重写它。下面演示了一种重写方式：

class ElectricCar(Car):
    --snip--

    def fill_gas_tank():
        """电动汽车没有油箱"""
        print("This car doesn't need a gas tank!")

现在，如果有人对电动汽车调用方法fill_gas_tank()，Python将忽略Car类中的方法fill_gas_tank()，转而运行上述代码。使用继承时，可让子类保留从父类那里继承而来的精华，并剔除不需要的糟粕。

9.3.5　将实例用作属性

使用代码模拟实物时，你可能会发现自己给类添加的细节越来越多：属性和方法清单以及文件都越来越长。在这种情况下，可能需要将类的一部分作为一个独立的类提取出来。你可以将大型类拆分成多个协同工作的小类。

例如，不断给ElectricCar类添加细节时，我们可能会发现其中包含很多专门针对汽车电瓶的属性和方法。在这种情况下，我们可将这些属性和方法提取出来，放到另一个名为Battery的类中，并将一个Battery实例用作ElectricCar类的一个属性：

class Car():
    --snip--

class Battery(): ❶
    """一次模拟电动汽车电瓶的简单尝试"""

    def __init__(self, battery_size=70): ❷
        """初始化电瓶的属性"""
        self.battery_size = battery_size

    def describe_battery(self): ❸
        """打印一条描述电瓶容量的消息"""
        print("This car has a " + str(self.battery_size) + "-kWh battery.")

class ElectricCar(Car):
    """电动汽车的独特之处"""

    def __init__(self, make, model, year):
        """
        初始化父类的属性，再初始化电动汽车特有的属性
        """
        super().__init__(make, model, year)
        self.battery = Battery() ❹

my_tesla = ElectricCar('tesla', 'model s', 2016)

print(my_tesla.get_descriptive_name())
my_tesla.battery.describe_battery()

在❶处，我们定义了一个名为Battery的新类，它没有继承任何类。❷处的方法__init__()除self外，还有另一个形参battery_size。这个形参是可选的：如果没有给它提供值，电瓶容量将被设置为70。方法describe_battery()也移到了这个类中（见❸）。

在ElectricCar类中，我们添加了一个名为self.battery的属性（见❹）。这行代码让Python创建一个新的Battery实例（由于没有指定尺寸，因此为默认值70），并将该实例存储在属性self.battery中。每当方法__init__()被调用时，都将执行该操作；因此现在每个ElectricCar实例都包含一个自动创建的Battery实例。

我们创建一辆电动汽车，并将其存储在变量my_tesla中。要描述电瓶时，需要使用电动汽车的属性battery：

my_tesla.battery.describe_battery()

这行代码让Python在实例my_tesla中查找属性battery，并对存储在该属性中的Battery实例调用方法describe_battery()。

输出与我们前面看到的相同：

2016 Tesla Model S
This car has a 70-kWh battery.

这看似做了很多额外的工作，但现在我们想多详细地描述电瓶都可以，且不会导致ElectricCar类混乱不堪。下面再给Battery类添加一个方法，它根据电瓶容量报告汽车的续航里程：

class Car():
    --snip--

class Battery():
    --snip--

    def get_range(self): ❶
        """打印一条消息，指出电瓶的续航里程"""
       if self.battery_size == 70:
            range = 240
        elif self.battery_size == 85:
            range = 270

        message = "This car can go approximately " + str(range)
        message += " miles on a full charge."
        print(message)

class ElectricCar(Car):
    --snip--

my_tesla = ElectricCar('tesla', 'model s', 2016)
print(my_tesla.get_descriptive_name())
my_tesla.battery.describe_battery()
my_tesla.battery.get_range() ❷

❶处新增的方法get_range()做了一些简单的分析：如果电瓶的容量为70kWh，它就将续航里程设置为240英里；如果容量为85kWh，就将续航里程设置为270英里，然后报告这个值。为使用这个方法，我们也通过汽车的属性battery来调用它（见❷）。

输出指出了汽车的续航里程（这取决于电瓶的容量）：

2016 Tesla Model S
This car has a 70-kWh battery.
This car can go approximately 240 miles on a full charge.

9.3.6　模拟实物

模拟较复杂的物件（如电动汽车）时，需要解决一些有趣的问题。续航里程是电瓶的属性还是汽车的属性呢？如果我们只需描述一辆汽车，那么将方法get_range()放在Battery类中也许是合适的；但如果要描述一家汽车制造商的整个产品线，也许应该将方法get_range()移到ElectricCar类中。在这种情况下，get_range()依然根据电瓶容量来确定续航里程，但报告的是一款汽车的续航里程。我们也可以这样做：将方法get_range()还留在Battery类中，但向它传递一个参数，如car_model；在这种情况下，方法get_range()将根据电瓶容量和汽车型号报告续航里程。

这让你进入了程序员的另一个境界：解决上述问题时，你从较高的逻辑层面（而不是语法层面）考虑；你考虑的不是Python，而是如何使用代码来表示实物。到达这种境界后，你经常会发现，现实世界的建模方法并没有对错之分。有些方法的效率更高，但要找出效率最高的表示法，需要经过一定的实践。只要代码像你希望的那样运行，就说明你做得很好！即便你发现自己不得不多次尝试使用不同的方法来重写类，也不必气馁；要编写出高效、准确的代码，都得经过这样的过程。

动手试一试

9-6 冰淇淋小店：冰淇淋小店是一种特殊的餐馆。编写一个名为IceCreamStand的类，让它继承你为完成练习9-1或练习9-4而编写的Restaurant类。这两个版本的Restaurant类都可以，挑选你更喜欢的那个即可。添加一个名为flavors的属性，用于存储一个由各种口味的冰淇淋组成的列表。编写一个显示这些冰淇淋的方法。创建一个IceCreamStand实例，并调用这个方法。

9-7 管理员：管理员是一种特殊的用户。编写一个名为Admin的类，让它继承你为完成练习9-3或练习9-5而编写的User 类。添加一个名为privileges的属性，用于存储一个由字符串（如"can add post"、"can delete post"、"can ban user"等）组成的列表。编写一个名为show_privileges()的方法，它显示管理员的权限。创建一个Admin实例，并调用这个方法。

9-8 权限：编写一个名为Privileges的类，它只有一个属性——privileges，其中存储了练习9-7所说的字符串列表。将方法show_privileges()移到这个类中。在Admin类中，将一个Privileges实例用作其属性。创建一个Admin实例，并使用方法show_privileges()来显示其权限。

9-9 电瓶升级：在本节最后一个electric_car.py版本中，给Battery类添加一个名为upgrade_battery()的方法。这个方法检查电瓶容量，如果它不是85，就将它设置为85。创建一辆电瓶容量为默认值的电动汽车，调用方法get_range()，然后对电瓶进行升级，并再次调用get_range()。你会看到这辆汽车的续航里程增加了。

9.4　导入类

随着你不断地给类添加功能，文件可能变得很长，即便你妥善地使用了继承亦如此。为遵循Python的总体理念，应让文件尽可能整洁。为在这方面提供帮助，Python允许你将类存储在模块中，然后在主程序中导入所需的模块。

9.4.1　导入单个类

下面来创建一个只包含Car类的模块。这让我们面临一个微妙的命名问题：在本章中，已经有一个名为car.py的文件，但这个模块也应命名为car.py，因为它包含表示汽车的代码。我们将这样解决这个命名问题：将Car类存储在一个名为car.py的模块中，该模块将覆盖前面使用的文件car.py。从现在开始，使用该模块的程序都必须使用更具体的文件名，如my_car.py。下面是模块car.py，其中只包含Car类的代码：

car.py

"""一个可用于表示汽车的类""" ❶

class Car():
    """一次模拟汽车的简单尝试"""

    def __init__(self, make, model, year):
        """初始化描述汽车的属性"""
        self.make = make
        self.model = model
        self.year = year
        self.odometer_reading = 0

    def get_descriptive_name(self):
        """返回整洁的描述性名称"""
        long_name = str(self.year) + ' ' + self.make + ' ' + self.model
        return long_name.title()

    def read_odometer(self):
        """打印一条消息，指出汽车的里程"""
        print("This car has " + str(self.odometer_reading) + " miles on it.")

    def update_odometer(self, mileage):
        """
        将里程表读数设置为指定的值
        拒绝将里程表往回拨
        """
        if mileage >= self.odometer_reading:
            self.odometer_reading = mileage
        else:
            print("You can't roll back an odometer!")

    def increment_odometer(self, miles):
        """将里程表读数增加指定的量"""
        self.odometer_reading += miles

在❶处，我们包含了一个模块级文档字符串，对该模块的内容做了简要的描述。你应为自己创建的每个模块都编写文档字符串。

下面来创建另一个文件——my_car.py，在其中导入Car类并创建其实例：

my_car.py

from car import Car ❶

my_new_car = Car('audi', 'a4', 2016)
print(my_new_car.get_descriptive_name())

my_new_car.odometer_reading = 23
my_new_car.read_odometer()

❶处的import语句让Python打开模块car，并导入其中的Car类。这样我们就可以使用Car类了，就像它是在这个文件中定义的一样。输出与我们在前面看到的一样：

2016 Audi A4
This car has 23 miles on it.

导入类是一种有效的编程方式。如果在这个程序中包含了整个Car类，它该有多长呀！通过将这个类移到一个模块中，并导入该模块，你依然可以使用其所有功能，但主程序文件变得整洁而易于阅读了。这还能让你将大部分逻辑存储在独立的文件中；确定类像你希望的那样工作后，你就可以不管这些文件，而专注于主程序的高级逻辑了。

9.4.2　在一个模块中存储多个类

虽然同一个模块中的类之间应存在某种相关性，但可根据需要在一个模块中存储任意数量的类。类Battery和ElectricCar都可帮助模拟汽车，因此下面将它们都加入模块car.py中：

car.py

"""一组用于表示燃油汽车和电动汽车的类"""

class Car():
    --snip--

class Battery():
    """一次模拟电动汽车电瓶的简单尝试"""

    def __init__(self, battery_size=70):
        """初始化电瓶的属性"""
        self.battery_size = battery_size

    def describe_battery(self):
        """打印一条描述电瓶容量的消息"""
        print("This car has a " + str(self.battery_size) + "-kWh battery.")

    def get_range(self):
        """打印一条描述电瓶续航里程的消息"""
        if self.battery_size == 70:
            range = 240
        elif self.battery_size == 85:
            range = 270

        message = "This car can go approximately " + str(range)
        message += " miles on a full charge."
        print(message)

class ElectricCar(Car):
    """模拟电动汽车的独特之处"""

    def __init__(self, make, model, year):
        """
        初始化父类的属性，再初始化电动汽车特有的属性
        """
        super().__init__(make, model, year)
        self.battery = Battery()

现在，可以新建一个名为my_electric_car.py的文件，导入ElectricCar类，并创建一辆电动汽车了：

my_electric_car.py

from car import ElectricCar

my_tesla = ElectricCar('tesla', 'model s', 2016)

print(my_tesla.get_descriptive_name())
my_tesla.battery.describe_battery()
my_tesla.battery.get_range()

输出与我们前面看到的相同，但大部分逻辑都隐藏在一个模块中：

2016 Tesla Model S
This car has a 70-kWh battery.
This car can go approximately 240 miles on a full charge.

9.4.3　从一个模块中导入多个类

可根据需要在程序文件中导入任意数量的类。如果我们要在同一个程序中创建普通汽车和电动汽车，就需要将Car和ElectricCar类都导入：

my_cars.py

from car import Car, ElectricCar ❶

my_beetle = Car('volkswagen', 'beetle', 2016) ❷
print(my_beetle.get_descriptive_name())

my_tesla = ElectricCar('tesla', 'roadster', 2016) ❸
print(my_tesla.get_descriptive_name())

在❶处从一个模块中导入多个类时，用逗号分隔了各个类。导入必要的类后，就可根据需要创建每个类的任意数量的实例。

在这个示例中，我们在❷处创建了一辆大众甲壳虫普通汽车，并在❸处创建了一辆特斯拉Roadster电动汽车：

2016 Volkswagen Beetle
2016 Tesla Roadster

9.4.4　导入整个模块

你还可以导入整个模块，再使用句点表示法访问需要的类。这种导入方法很简单，代码也易于阅读。由于创建类实例的代码都包含模块名，因此不会与当前文件使用的任何名称发生冲突。

下面的代码导入整个car模块，并创建一辆普通汽车和一辆电动汽车：

my_cars.py

import car ❶

my_beetle = car.Car('volkswagen', 'beetle', 2016) ❷
print(my_beetle.get_descriptive_name())

my_tesla = car.ElectricCar('tesla', 'roadster', 2016) ❸
print(my_tesla.get_descriptive_name())

在❶处，我们导入了整个car模块。接下来，我们使用语法module_name.class_name访问需要的类。像前面一样，我们在❷处创建了一辆大众甲壳虫汽车，并在❸处创建了一辆特斯拉Roadster汽车。

9.4.5　导入模块中的所有类

要导入模块中的每个类，可使用下面的语法：

from module_name import *

不推荐使用这种导入方式，其原因有二。首先，如果只要看一下文件开头的import语句，就能清楚地知道程序使用了哪些类，将大有裨益；但这种导入方式没有明确地指出你使用了模块中的哪些类。这种导入方式还可能引发名称方面的困惑。如果你不小心导入了一个与程序文件中其他东西同名的类，将引发难以诊断的错误。这里之所以介绍这种导入方式，是因为虽然不推荐使用这种方式，但你可能会在别人编写的代码中见到它。

需要从一个模块中导入很多类时，最好导入整个模块，并使用module_name.class_name语法来访问类。这样做时，虽然文件开头并没有列出用到的所有类，但你清楚地知道在程序的哪些地方使用了导入的模块；你还避免了导入模块中的每个类可能引发的名称冲突。

9.4.6　在一个模块中导入另一个模块

有时候，需要将类分散到多个模块中，以免模块太大，或在同一个模块中存储不相关的类。将类存储在多个模块中时，你可能会发现一个模块中的类依赖于另一个模块中的类。在这种情况下，可在前一个模块中导入必要的类。

例如，下面将Car类存储在一个模块中，并将ElectricCar和Battery类存储在另一个模块中。我们将第二个模块命名为electric_car.py（这将覆盖前面创建的文件electric_car.py），并将Battery和ElectricCar类复制到这个模块中：

electric_car.py

"""一组可用于表示电动汽车的类"""

from car import Car ❶

class Battery():
    --snip--

class ElectricCar(Car):
    --snip--

ElectricCar类需要访问其父类Car，因此在❶处，我们直接将Car类导入该模块中。如果我们忘记了这行代码，Python将在我们试图创建ElectricCar实例时引发错误。我们还需要更新模块car，使其包含Car类：

car.py

"""一个可用于表示汽车的类"""

class Car():
    --snip--

现在可以分别从每个模块中导入类，以根据需要创建任何类型的汽车了：

my_cars.py

from car import Car ❶
from electric_car import ElectricCar

my_beetle = Car('volkswagen', 'beetle', 2016)
print(my_beetle.get_descriptive_name())

my_tesla = ElectricCar('tesla', 'roadster', 2016)
print(my_tesla.get_descriptive_name())

在❶处，我们从模块car中导入了Car类，并从模块electric_car中导入ElectricCar类。接下来，我们创建了一辆普通汽车和一辆电动汽车。这两种汽车都得以正确地创建：

2016 Volkswagen Beetle
2016 Tesla Roadster

9.4.7　自定义工作流程

正如你看到的，在组织大型项目的代码方面，Python提供了很多选项。熟悉所有这些选项很重要，这样你才能确定哪种项目组织方式是最佳的，并能理解别人开发的项目。

一开始应让代码结构尽可能简单。先尽可能在一个文件中完成所有的工作，确定一切都能正确运行后，再将类移到独立的模块中。如果你喜欢模块和文件的交互方式，可在项目开始时就尝试将类存储到模块中。先找出让你能够编写出可行代码的方式，再尝试让代码更为组织有序。

动手试一试

9-10 导入Restaurant类：将最新的Restaurant类存储在一个模块中。在另一个文件中，导入Restaurant类，创建一个Restaurant实例，并调用Restaurant的一个方法，以确认import语句正确无误。

9-11 导入Admin类：以为完成练习9-8而做的工作为基础，将User、Privileges和Admin类存储在一个模块中，再创建一个文件，在其中创建一个Admin实例并对其调用方法show_privileges()，以确认一切都能正确地运行。

9-12 多个模块：将User类存储在一个模块中，并将Privileges和Admin类存储在另一个模块中。再创建一个文件，在其中创建一个Admin实例，并对其调用方法show_privileges()，以确认一切都依然能够正确地运行。

9.5　Python标准库

Python标准库是一组模块，安装的Python都包含它。你现在对类的工作原理已有大致的了解，可以开始使用其他程序员编写好的模块了。可使用标准库中的任何函数和类，为此只需在程序开头包含一条简单的import语句。下面来看模块collections中的一个类——OrderedDict。

字典让你能够将信息关联起来，但它们不记录你添加键—值对的顺序。要创建字典并记录其中的键—值对的添加顺序，可使用模块collections中的OrderedDict类。OrderedDict实例的行为几乎与字典相同，区别只在于记录了键—值对的添加顺序。

我们再来看一看第6章的favorite_languages.py示例，但这次将记录被调查者参与调查的顺序：

favorite_languages.py

from collections import OrderedDict ❶

favorite_languages = OrderedDict() ❷

favorite_languages['jen'] = 'python' ❸
favorite_languages['sarah'] = 'c'
favorite_languages['edward'] = 'ruby'
favorite_languages['phil'] = 'python'

for name, language in favorite_languages.items(): ❹
    print(name.title() + "'s favorite language is " +
        language.title() + ".")

我们首先从模块collections中导入了OrderedDict类（见❶）。在❷处，我们创建了OrderedDict类的一个实例，并将其存储到favorite_languages中。请注意，这里没有使用花括号，而是调用OrderedDict()来创建一个空的有序字典，并将其存储在favorite_languages中。接下来，我们以每次一对的方式添加名字—语言对（见❸）。在❹处，我们遍历favorite_languages，但知道将以添加的顺序获取调查结果：

Jen's favorite language is Python.
Sarah's favorite language is C.
Edward's favorite language is Ruby.
Phil's favorite language is Python.

这是一个很不错的类，它兼具列表和字典的主要优点（在将信息关联起来的同时保留原来的顺序）。等你开始对关心的现实情形建模时，可能会发现有序字典正好能够满足需求。随着你对标准库的了解越来越深入，将熟悉大量可帮助你处理常见情形的模块。

注意　你还可以从其他地方下载外部模块。本书第二部分的每个项目都需要使用外部模块，届时你将看到很多这样的示例。

动手试一试

9-13 使用OrderedDict：在练习6-4中，你使用了一个标准字典来表示词汇表。请使用OrderedDict类来重写这个程序，并确认输出的顺序与你在字典中添加键—值对的顺序一致。

9-14 骰子：模块random包含以各种方式生成随机数的函数，其中的randint()返回一个位于指定范围内的整数，例如，下面的代码返回一个1~6内的整数：

from random import randint
x = randint(1, 6)

请创建一个Die类，它包含一个名为sides的属性，该属性的默认值为6。编写一个名为roll_die()的方法，它打印位于1和骰子面数之间的随机数。创建一个6面的骰子，再掷10次。创建一个10面的骰子和一个20面的骰子，并将它们都掷10次。

9-15 Python Module of the Week：要了解Python标准库，一个很不错的资源是网站Python Module of the Week。请访问http://pymotw.com/并查看其中的目录，在其中找一个你感兴趣的模块进行探索，或阅读模块collections和random的文档。

9.6　类编码风格

你必须熟悉有些与类相关的编码风格问题，在你编写的程序较复杂时尤其如此。

类名应采用驼峰命名法，即将类名中的每个单词的首字母都大写，而不使用下划线。实例名和模块名都采用小写格式，并在单词之间加上下划线。

对于每个类，都应紧跟在类定义后面包含一个文档字符串。这种文档字符串简要地描述类的功能，并遵循编写函数的文档字符串时采用的格式约定。每个模块也都应包含一个文档字符串，对其中的类可用于做什么进行描述。

可使用空行来组织代码，但不要滥用。在类中，可使用一个空行来分隔方法；而在模块中，可使用两个空行来分隔类。

需要同时导入标准库中的模块和你编写的模块时，先编写导入标准库模块的import语句，再添加一个空行，然后编写导入你自己编写的模块的import语句。在包含多条import语句的程序中，这种做法让人更容易明白程序使用的各个模块都来自何方。

9.7　小结

在本章中，你学习了：如何编写类；如何使用属性在类中存储信息，以及如何编写方法，以让类具备所需的行为；如何编写方法__init__()，以便根据类创建包含所需属性的实例。你见识了如何修改实例的属性——包括直接修改以及通过方法进行修改。你还了解了：使用继承可简化相关类的创建工作；将一个类的实例用作另一个类的属性可让类更简洁。

你了解到，通过将类存储在模块中，并在需要使用这些类的文件中导入它们，可让项目组织有序。你学习了Python标准库，并见识了一个使用模块collections中的OrderedDict类的示例。最后，你学习了编写类时应遵循的Python约定。

在第10章中，你将学习如何使用文件，这让你能够保存你在程序中所做的工作，以及你让用户做的工作。你还将学习异常，这是一种特殊的Python类，用于帮助你在发生错误时采取相应的措施。

第 10 章　文件和异常

至此，你掌握了编写组织有序而易于使用的程序所需的基本技能，该考虑让程序目标更明确、用途更大了。在本章中，你将学习处理文件，让程序能够快速地分析大量的数据；你将学习错误处理，避免程序在面对意外情形时崩溃；你将学习异常，它们是Python创建的特殊对象，用于管理程序运行时出现的错误；你还将学习模块json，它让你能够保存用户数据，以免在程序停止运行后丢失。

学习处理文件和保存数据可让你的程序使用起来更容易：用户将能够选择输入什么样的数据，以及在什么时候输入；用户使用你的程序做一些工作后，可将程序关闭，以后再接着往下做。学习处理异常可帮助你应对文件不存在的情形，以及处理其他可能导致程序崩溃的问题。这让你的程序在面对错误的数据时更健壮——不管这些错误数据源自无意的错误，还是源自破坏程序的恶意企图。你在本章学习的技能可提高程序的适用性、可用性和稳定性。

10.1　从文件中读取数据

文本文件可存储的数据量多得难以置信：天气数据、交通数据、社会经济数据、文学作品等。每当需要分析或修改存储在文件中的信息时，读取文件都很有用，对数据分析应用程序来说尤其如此。例如，你可以编写一个这样的程序：读取一个文本文件的内容，重新设置这些数据的格式并将其写入文件，让浏览器能够显示这些内容。

要使用文本文件中的信息，首先需要将信息读取到内存中。为此，你可以一次性读取文件的全部内容，也可以以每次一行的方式逐步读取。

10.1.1　读取整个文件

要读取文件，需要一个包含几行文本的文件。下面首先来创建一个文件，它包含精确到小数点后30位的圆周率值，且在小数点后每10位处都换行：

pi_digits.txt

3.1415926535
  8979323846
  2643383279

要动手尝试后续示例，可在编辑器中输入这些数据行，再将文件保存为pi_digits.txt，也可从本书的主页（http://www.ituring.com.cn/book/1861）下载该文件。然后，将该文件保存到本章程序所在的目录中。

下面的程序打开并读取这个文件，再将其内容显示到屏幕上：

file_reader.py

with open('pi_digits.txt') as file_object:
    contents = file_object.read()
    print(contents)

在这个程序中，第1行代码做了大量的工作。我们先来看看函数open()。要以任何方式使用文件——哪怕仅仅是打印其内容，都得先打开文件，这样才能访问它。函数open()接受一个参数：要打开的文件的名称。Python在当前执行的文件所在的目录中查找指定的文件。在这个示例中，当前运行的是file_reader.py，因此Python在file_reader.py所在的目录中查找pi_digits.txt。函数open()返回一个表示文件的对象。在这里，open('pi_digits.txt')返回一个表示文件pi_digits.txt的对象；Python将这个对象存储在我们将在后面使用的变量中。

关键字with在不再需要访问文件后将其关闭。在这个程序中，注意到我们调用了open()，但没有调用close()；你也可以调用open()和close()来打开和关闭文件，但这样做时，如果程序存在bug，导致close()语句未执行，文件将不会关闭。这看似微不足道，但未妥善地关闭文件可能会导致数据丢失或受损。如果在程序中过早地调用close()，你会发现需要使用文件时它已关闭（无法访问），这会导致更多的错误。并非在任何情况下都能轻松确定关闭文件的恰当时机，但通过使用前面所示的结构，可让Python去确定：你只管打开文件，并在需要时使用它，Python自会在合适的时候自动将其关闭。

有了表示pi_digits.txt的文件对象后，我们使用方法read()（前述程序的第2行）读取这个文件的全部内容，并将其作为一个长长的字符串存储在变量contents中。这样，通过打印contents的值，就可将这个文本文件的全部内容显示出来：

3.1415926535
  8979323846
  2643383279

为何会多出这个空行呢？因为read()到达文件末尾时返回一个空字符串，而将这个空字符串显示出来时就是一个空行。要删除末尾的空行，可在print语句中使用rstrip()：

with open('pi_digits.txt') as file_object:
    contents = file_object.read()
    print(contents.rstrip())

本书前面说过，Python方法rstrip()删除（剥除）字符串末尾的空白。现在，输出与原始文件的内容完全相同：

3.1415926535
  8979323846
  2643383279

10.1.2　文件路径

当你将类似pi_digits.txt这样的简单文件名传递给函数open()时，Python将在当前执行的文件（即.py程序文件）所在的目录中查找文件。

根据你组织文件的方式，有时可能要打开不在程序文件所属目录中的文件。例如，你可能将程序文件存储在了文件夹python_work中，而在文件夹python_work中，有一个名为text_files的文件夹，用于存储程序文件操作的文本文件。虽然文件夹text_files包含在文件夹python_work中，但仅向open()传递位于该文件夹中的文件的名称也不可行，因为Python只在文件夹python_work中查找，而不会在其子文件夹text_files中查找。要让Python打开不与程序文件位于同一个目录中的文件，需要提供文件路径，它让Python到系统的特定位置去查找。

由于文件夹text_files位于文件夹python_work中，因此可使用相对文件路径来打开该文件夹中的文件。相对文件路径让Python到指定的位置去查找，而该位置是相对于当前运行的程序所在目录的。在Linux和OS X中，你可以这样编写代码：

with open('text_files/filename.txt') as file_object:

这行代码让Python到文件夹python_work下的文件夹text_files中去查找指定的.txt文件。在Windows系统中，在文件路径中使用反斜杠（\）而不是斜杠（/）：

with open('text_files\filename.txt') as file_object:

你还可以将文件在计算机中的准确位置告诉Python，这样就不用关心当前运行的程序存储在什么地方了。这称为绝对文件路径。在相对路径行不通时，可使用绝对路径。例如，如果text_files并不在文件夹python_work中，而在文件夹other_files中，则向open()传递路径'text_files/ filename.txt'行不通，因为Python只在文件夹python_work中查找该位置。为明确地指出你希望Python到哪里去查找，你需要提供完整的路径。

绝对路径通常比相对路径更长，因此将其存储在一个变量中，再将该变量传递给open()会有所帮助。在Linux和OS X中，绝对路径类似于下面这样：

file_path = '/home/ehmatthes/other_files/text_files/filename.txt'
with open(file_path) as file_object:

而在Windows系统中，它们类似于下面这样：

file_path = 'C:\Users\ehmatthes\other_files\text_files\filename.txt'
with open(file_path) as file_object:

通过使用绝对路径，可读取系统任何地方的文件。就目前而言，最简单的做法是，要么将数据文件存储在程序文件所在的目录，要么将其存储在程序文件所在目录下的一个文件夹（如text_files）中。

注意　Windows系统有时能够正确地解读文件路径中的斜杠。如果你使用的是Windows系统，且结果不符合预期，请确保在文件路径中使用的是反斜杠。另外，由于反斜杠在Python中被视为转义标记，为在Windows中确保万无一失，应以原始字符串的方式指定路径，即在开头的单引号前加上r。

10.1.3　逐行读取

读取文件时，常常需要检查其中的每一行：你可能要在文件中查找特定的信息，或者要以某种方式修改文件中的文本。例如，你可能要遍历一个包含天气数据的文件，并使用天气描述中包含字样sunny的行。在新闻报道中，你可能会查找包含标签<headline>的行，并按特定的格式设置它。

要以每次一行的方式检查文件，可对文件对象使用for循环：

file_reader.py

filename = 'pi_digits.txt' ❶

with open(filename) as file_object: ❷
    for line in file_object: ❸
    print(line)

在❶处，我们将要读取的文件的名称存储在变量filename中，这是使用文件时一种常见的做法。由于变量filename表示的并非实际文件——它只是一个让Python知道到哪里去查找文件的字符串，因此可轻松地将'pi_digits.txt'替换为你要使用的另一个文件的名称。调用open()后，将一个表示文件及其内容的对象存储到了变量file_object中（见❷）。这里也使用了关键字with，让Python负责妥善地打开和关闭文件。为查看文件的内容，我们通过对文件对象执行循环来遍历文件中的每一行（见❸）。

我们打印每一行时，发现空白行更多了：

3.1415926535

  8979323846

  2643383279

为何会出现这些空白行呢？因为在这个文件中，每行的末尾都有一个看不见的换行符，而print语句也会加上一个换行符，因此每行末尾都有两个换行符：一个来自文件，另一个来自print语句。要消除这些多余的空白行，可在print语句中使用rstrip()：

filename = 'pi_digits.txt'

with open(filename) as file_object:
    for line in file_object:
        print(line.rstrip())

现在，输出又与文件内容完全相同了：

3.1415926535
  8979323846
  2643383279

10.1.4　创建一个包含文件各行内容的列表

使用关键字with时，open()返回的文件对象只在with代码块内可用。如果要在with代码块外访问文件的内容，可在with代码块内将文件的各行存储在一个列表中，并在with代码块外使用该列表：你可以立即处理文件的各个部分，也可推迟到程序后面再处理。

下面的示例在with代码块中将文件pi_digits.txt的各行存储在一个列表中，再在with代码块外打印它们：

filename = 'pi_digits.txt'

with open(filename) as file_object:
    lines = file_object.readlines() ❶

for line in lines: ❷
    print(line.rstrip())

❶处的方法readlines()从文件中读取每一行，并将其存储在一个列表中；接下来，该列表被存储到变量lines中；在with代码块外，我们依然可以使用这个变量。在❷处，我们使用一个简单的for循环来打印lines中的各行。由于列表lines的每个元素都对应于文件中的一行，因此输出与文件内容完全一致。

10.1.5　使用文件的内容

将文件读取到内存中后，就可以以任何方式使用这些数据了。下面以简单的方式使用圆周率的值。首先，我们将创建一个字符串，它包含文件中存储的所有数字，且没有任何空格：

pi_string.py

filename = 'pi_digits.txt'

with open(filename) as file_object:
    lines = file_object.readlines()

pi_string = '' ❶
for line in lines: ❷
    pi_string += line.rstrip()

print(pi_string) ❸
print(len(pi_string))

就像前一个示例一样，我们首先打开文件，并将其中的所有行都存储在一个列表中。在❶处，我们创建了一个变量——pi_string，用于存储圆周率的值。接下来，我们使用一个循环将各行都加入pi_string，并删除每行末尾的换行符（见❷）。在❸处，我们打印这个字符串及其长度：

3.1415926535  8979323846  2643383279
36

在变量pi_string存储的字符串中，包含原来位于每行左边的空格，为删除这些空格，可使用strip()而不是rstrip()：

filename = 'pi_30_digits.txt'

with open(filename) as file_object:
    lines = file_object.readlines()

pi_string = ''
for line in lines:
    pi_string += line.strip()

print(pi_string)
print(len(pi_string))

这样，我们就获得了一个这样的字符串：它包含精确到30位小数的圆周率值。这个字符串长32字符，因为它还包含整数部分的3和小数点：

3.141592653589793238462643383279
32

注意　读取文本文件时，Python将其中的所有文本都解读为字符串。如果你读取的是数字，并要将其作为数值使用，就必须使用函数int()将其转换为整数，或使用函数float()将其转换为浮点数。

10.1.6　包含一百万位的大型文件

前面我们分析的都是一个只有三行的文本文件，但这些代码示例也可处理大得多的文件。如果我们有一个文本文件，其中包含精确到小数点后1 000 000位而不是30位的圆周率值，也可创建一个包含所有这些数字的字符串。为此，我们无需对前面的程序做任何修改，只需将这个文件传递给它即可。在这里，我们只打印到小数点后50位，以免终端为显示全部1 000 000位而不断地翻滚：

pi_string.py

filename = 'pi_million_digits.txt'

with open(filename) as file_object:
    lines = file_object.readlines()

pi_string = ''
for line in lines:
    pi_string += line.strip()

print(pi_string[:52] + "...")
print(len(pi_string))

输出表明，我们创建的字符串确实包含精确到小数点后1 000 000位的圆周率值：

3.14159265358979323846264338327950288419716939937510...
1000002

对于你可处理的数据量，Python没有任何限制；只要系统的内存足够多，你想处理多少数据都可以。

注意　要运行这个程序（以及后面的众多示例），你需要从http://www.ituring.com.cn/book/1861下载相关的资源。

10.1.7　圆周率值中包含你的生日吗

我一直想知道自己的生日是否包含在圆周率值中。下面来扩展刚才编写的程序，以确定某个人的生日是否包含在圆周率值的前1 000 000位中。为此，可将生日表示为一个由数字组成的字符串，再检查这个字符串是否包含在pi_string中：

filename = 'pi_million_digits.txt'

with open(filename) as file_object:
    lines = file_object.readlines()

pi_string = ''
for line in lines:
    pi_string += line.strip()

birthday = input("Enter your birthday, in the form mmddyy: ") ❶
if birthday in pi_string: ❷
    print("Your birthday appears in the first million digits of pi!")
else:
    print("Your birthday does not appear in the first million digits of pi.")

在❶处，我们提示用户输入其生日，在接下来的❷处，我们检查这个字符串是否包含在pi_string中。运行一下这个程序：

Enter your birthdate, in the form mmddyy: 120372
Your birthday appears in the first million digits of pi!

我的生日确实出现在了圆周率值中！读取文件的内容后，就可以以你能想到的任何方式对其进行分析。

动手试一试

10-1 Python学习笔记：在文本编辑器中新建一个文件，写几句话来总结一下你至此学到的Python知识，其中每一行都以“In Python you can”打头。将这个文件命名为learning_python.txt，并将其存储到为完成本章练习而编写的程序所在的目录中。编写一个程序，它读取这个文件，并将你所写的内容打印三次：第一次打印时读取整个文件；第二次打印时遍历文件对象；第三次打印时将各行存储在一个列表中，再在with代码块外打印它们。

10-2 C语言学习笔记：可使用方法replace()将字符串中的特定单词都替换为另一个单词。下面是一个简单的示例，演示了如何将句子中的'dog'替换为'cat'：

>>> message = "I really like dogs."
>>> message.replace('dog', 'cat')
'I really like cats.'

读取你刚创建的文件learning_python.txt中的每一行，将其中的Python都替换为另一门语言的名称，如C。将修改后的各行都打印到屏幕上。

10.2　写入文件

保存数据的最简单的方式之一是将其写入到文件中。通过将输出写入文件，即便关闭包含程序输出的终端窗口，这些输出也依然存在：你可以在程序结束运行后查看这些输出，可与别人分享输出文件，还可编写程序来将这些输出读取到内存中并进行处理。

10.2.1　写入空文件

要将文本写入文件，你在调用open()时需要提供另一个实参，告诉Python你要写入打开的文件。为明白其中的工作原理，我们来将一条简单的消息存储到文件中，而不是将其打印到屏幕上：

write_message.py

filename = 'programming.txt'

with open(filename, 'w') as file_object: ❶
    file_object.write("I love programming.") ❷

在这个示例中，调用open()时提供了两个实参（见❶）。第一个实参也是要打开的文件的名称；第二个实参（'w'）告诉Python，我们要以写入模式打开这个文件。打开文件时，可指定读取模式（'r'）、写入模式（'w'）、附加模式（'a'）或让你能够读取和写入文件的模式（'r+'）。如果你省略了模式实参，Python将以默认的只读模式打开文件。

如果你要写入的文件不存在，函数open()将自动创建它。然而，以写入（'w'）模式打开文件时千万要小心，因为如果指定的文件已经存在，Python将在返回文件对象前清空该文件。

在❷处，我们使用文件对象的方法write()将一个字符串写入文件。这个程序没有终端输出，但如果你打开文件programming.txt，将看到其中包含如下一行内容：

programming.txt

I love programming.

相比于你的计算机中的其他文件，这个文件没有什么不同。你可以打开它、在其中输入新文本、复制其内容、将内容粘贴到其中等。

注意　Python只能将字符串写入文本文件。要将数值数据存储到文本文件中，必须先使用函数str()将其转换为字符串格式。

10.2.2　写入多行

函数write()不会在你写入的文本末尾添加换行符，因此如果你写入多行时没有指定换行符，文件看起来可能不是你希望的那样：

filename = 'programming.txt'

with open(filename, 'w') as file_object:
    file_object.write("I love programming.")
    file_object.write("I love creating new games.")

如果你打开programming.txt，将发现两行内容挤在一起：

I love programming.I love creating new games.

要让每个字符串都单独占一行，需要在write()语句中包含换行符：

filename = 'programming.txt'

with open(filename, 'w') as file_object:
    file_object.write("I love programming.\n")
    file_object.write("I love creating new games.\n")

现在，输出出现在不同行中：

I love programming.
I love creating new games.

像显示到终端的输出一样，还可以使用空格、制表符和空行来设置这些输出的格式。

10.2.3　附加到文件

如果你要给文件添加内容，而不是覆盖原有的内容，可以附加模式打开文件。你以附加模式打开文件时，Python不会在返回文件对象前清空文件，而你写入到文件的行都将添加到文件末尾。如果指定的文件不存在，Python将为你创建一个空文件。

下面来修改write_message.py，在既有文件programming.txt中再添加一些你酷爱编程的原因：

write_message.py

filename = 'programming.txt'

with open(filename, 'a') as file_object: ❶
    file_object.write("I also love finding meaning in large datasets.\n") ❷
    file_object.write("I love creating apps that can run in a browser.\n")

在❶处，我们打开文件时指定了实参'a'，以便将内容附加到文件末尾，而不是覆盖文件原来的内容。在❷处，我们又写入了两行，它们被添加到文件programming.txt末尾：

programming.txt

I love programming.
I love creating new games.
I also love finding meaning in large datasets.
I love creating apps that can run in a browser.

最终的结果是，文件原来的内容还在，它们后面是我们刚添加的内容。

动手试一试

10-3 访客：编写一个程序，提示用户输入其名字；用户作出响应后，将其名字写入到文件guest.txt中。

10-4 访客名单：编写一个while循环，提示用户输入其名字。用户输入其名字后，在屏幕上打印一句问候语，并将一条访问记录添加到文件guest_book.txt中。确保这个文件中的每条记录都独占一行。

10-5 关于编程的调查：编写一个while循环，询问用户为何喜欢编程。每当用户输入一个原因后，都将其添加到一个存储所有原因的文件中。

10.3　异常

Python使用被称为异常的特殊对象来管理程序执行期间发生的错误。每当发生让Python不知所措的错误时，它都会创建一个异常对象。如果你编写了处理该异常的代码，程序将继续运行；如果你未对异常进行处理，程序将停止，并显示一个traceback，其中包含有关异常的报告。

异常是使用try-except代码块处理的。try-except代码块让Python执行指定的操作，同时告诉Python发生异常时怎么办。使用了try-except代码块时，即便出现异常，程序也将继续运行：显示你编写的友好的错误消息，而不是令用户迷惑的traceback。

10.3.1　处理ZeroDivisionError异常

下面来看一种导致Python引发异常的简单错误。你可能知道不能将一个数字除以0，但我们还是让Python这样做吧：

division.py

print(5/0)

显然，Python无法这样做，因此你将看到一个traceback：

Traceback (most recent call last):
  File "division.py", line 1, in <module>
    print(5/0)
ZeroDivisionError: division by zero ❶

在上述traceback中，❶处指出的错误ZeroDivisionError是一个异常对象。Python无法按你的要求做时，就会创建这种对象。在这种情况下，Python将停止运行程序，并指出引发了哪种异常，而我们可根据这些信息对程序进行修改。下面我们将告诉Python，发生这种错误时怎么办；这样，如果再次发生这样的错误，我们就有备无患了。

10.3.2　使用try-except代码块

当你认为可能发生了错误时，可编写一个try-except代码块来处理可能引发的异常。你让Python尝试运行一些代码，并告诉它如果这些代码引发了指定的异常，该怎么办。

处理ZeroDivisionError异常的try-except代码块类似于下面这样：

try:
    print(5/0)
except ZeroDivisionError:
    print("You can't divide by zero!")

我们将导致错误的代码行print(5/0)放在了一个try代码块中。如果try代码块中的代码运行起来没有问题，Python将跳过except代码块；如果try代码块中的代码导致了错误，Python将查找这样的except代码块，并运行其中的代码，即其中指定的错误与引发的错误相同。

在这个示例中，try代码块中的代码引发了ZeroDivisionError异常，因此Python指出了该如何解决问题的except代码块，并运行其中的代码。这样，用户看到的是一条友好的错误消息，而不是traceback：

You can't divide by zero!

如果try-except代码块后面还有其他代码，程序将接着运行，因为已经告诉了Python如何处理这种错误。下面来看一个捕获错误后程序将继续运行的示例。

10.3.3　使用异常避免崩溃

发生错误时，如果程序还有工作没有完成，妥善地处理错误就尤其重要。这种情况经常会出现在要求用户提供输入的程序中；如果程序能够妥善地处理无效输入，就能再提示用户提供有效输入，而不至于崩溃。

下面来创建一个只执行除法运算的简单计算器：

division.py

print("Give me two numbers, and I'll divide them.")
print("Enter 'q' to quit.")

while True:
    first_number = input("\nFirst number: ") ❶
    if first_number == 'q':
        break
    second_number = input("Second number: ") ❷
    if second_number == 'q':
        break
    answer = int(first_number) / int(second_number) ❸
    print(answer)

在❶处，这个程序提示用户输入一个数字，并将其存储到变量first_number中；如果用户输入的不是表示退出的q，就再提示用户输入一个数字，并将其存储到变量second_number中（见❷）。接下来，我们计算这两个数字的商（即answer，见❸）。这个程序没有采取任何处理错误的措施，因此让它执行除数为0的除法运算时，它将崩溃：

Give me two numbers, and I'll divide them.
Enter 'q' to quit.

First number: 5
Second number: 0
Traceback (most recent call last):
  File "division.py", line 9, in <module>
    answer = int(first_number) / int(second_number)
ZeroDivisionError: division by zero

程序崩溃可不好，但让用户看到traceback也不是好主意。不懂技术的用户会被它们搞糊涂，而且如果用户怀有恶意，他会通过traceback获悉你不希望他知道的信息。例如，他将知道你的程序文件的名称，还将看到部分不能正确运行的代码。有时候，训练有素的攻击者可根据这些信息判断出可对你的代码发起什么样的攻击。

10.3.4　else代码块

通过将可能引发错误的代码放在try-except代码块中，可提高这个程序抵御错误的能力。错误是执行除法运算的代码行导致的，因此我们需要将它放到try-except代码块中。这个示例还包含一个else代码块；依赖于try代码块成功执行的代码都应放到else代码块中：

print("Give me two numbers, and I'll divide them.")
print("Enter 'q' to quit.")

while True:
    first_number = input("\nFirst number: ")
    if first_number == 'q':
        break
    second_number = input("Second number: ")
    try: ❶
        answer = int(first_number) / int(second_number)
    except ZeroDivisionError: ❷
        print("You can't divide by 0!")
    else: ❸
        print(answer)

我们让Python尝试执行try代码块中的除法运算（见❶），这个代码块只包含可能导致错误的代码。依赖于try代码块成功执行的代码都放在else代码块中；在这个示例中，如果除法运算成功，我们就使用else代码块来打印结果（见❸）。

except代码块告诉Python，出现ZeroDivisionError异常时该怎么办（见❷）。如果try代码块因除零错误而失败，我们就打印一条友好的消息，告诉用户如何避免这种错误。程序将继续运行，用户根本看不到traceback：

Give me two numbers, and I'll divide them.
Enter 'q' to quit.

First number: 5
Second number: 0
You can't divide by 0!

First number: 5
Second number: 2
2.5

First number: q

try-except-else代码块的工作原理大致如下：Python尝试执行try代码块中的代码；只有可能引发异常的代码才需要放在try语句中。有时候，有一些仅在try代码块成功执行时才需要运行的代码；这些代码应放在else代码块中。except代码块告诉Python，如果它尝试运行try代码块中的代码时引发了指定的异常，该怎么办。

通过预测可能发生错误的代码，可编写健壮的程序，它们即便面临无效数据或缺少资源，也能继续运行，从而能够抵御无意的用户错误和恶意的攻击。

10.3.5　处理FileNotFoundError异常

使用文件时，一种常见的问题是找不到文件：你要查找的文件可能在其他地方、文件名可能不正确或者这个文件根本就不存在。对于所有这些情形，都可使用try-except代码块以直观的方式进行处理。

我们来尝试读取一个不存在的文件。下面的程序尝试读取文件alice.txt的内容，但我没有将这个文件存储在alice.py所在的目录中：

alice.py

filename = 'alice.txt'

with open(filename) as f_obj:
    contents = f_obj.read()

Python无法读取不存在的文件，因此它引发一个异常：

Traceback (most recent call last):
  File "alice.py", line 3, in <module>
    with open(filename) as f_obj:
FileNotFoundError: [Errno 2] No such file or directory: 'alice.txt'

在上述traceback中，最后一行报告了FileNotFoundError异常，这是Python找不到要打开的文件时创建的异常。在这个示例中，这个错误是函数open()导致的，因此要处理这个错误，必须将try语句放在包含open()的代码行之前：

filename = 'alice.txt'

try:
    with open(filename) as f_obj:
        contents = f_obj.read()
except FileNotFoundError:
    msg = "Sorry, the file " + filename + " does not exist."
    print(msg)

在这个示例中，try代码块引发FileNotFoundError异常，因此Python找出与该错误匹配的except代码块，并运行其中的代码。最终的结果是显示一条友好的错误消息，而不是traceback：

Sorry, the file alice.txt does not exist.

如果文件不存在，这个程序什么都不做，因此错误处理代码的意义不大。下面来扩展这个示例，看看在你使用多个文件时，异常处理可提供什么样的帮助。

10.3.6　分析文本

你可以分析包含整本书的文本文件。很多经典文学作品都是以简单文本文件的方式提供的，因为它们不受版权限制。本节使用的文本来自项目Gutenberg（http://gutenberg.org/），这个项目提供了一系列不受版权限制的文学作品，如果你要在编程项目中使用文学文本，这是一个很不错的资源。

下面来提取童话 Alice in Wonderland 的文本，并尝试计算它包含多少个单词。我们将使用方法split()，它根据一个字符串创建一个单词列表。下面是对只包含童话名"Alice in Wonderland"的字符串调用方法split()的结果：

>>> title = "Alice in Wonderland"
>>> title.split()
['Alice', 'in', 'Wonderland']

方法split()以空格为分隔符将字符串分拆成多个部分，并将这些部分都存储到一个列表中。结果是一个包含字符串中所有单词的列表，虽然有些单词可能包含标点。为计算 Alice in Wonderland 包含多少个单词，我们将对整篇小说调用split()，再计算得到的列表包含多少个元素，从而确定整篇童话大致包含多少个单词：

filename = 'alice.txt'

try:
    with open(filename) as f_obj:
        contents = f_obj.read()
except FileNotFoundError:
    msg = "Sorry, the file " + filename + " does not exist."
    print(msg)
else:
    # 计算文件大致包含多少个单词
    words = contents.split() ❶
    num_words = len(words) ❷
    print("The file " + filename + " has about " + str(num_words) + " words.") ❸

我们把文件alice.txt移到了正确的目录中，让try代码块能够成功地执行。在❶处，我们对变量contents（它现在是一个长长的字符串，包含童话 Alice in Wonderland 的全部文本）调用方法split()，以生成一个列表，其中包含这部童话中的所有单词。当我们使用len()来确定这个列表的长度时，就知道了原始字符串大致包含多少个单词（见❷）。在❸处，我们打印一条消息，指出文件包含多少个单词。这些代码都放在else代码块中，因为仅当try代码块成功执行时才执行它们。输出指出了文件alice.txt包含多少个单词：

The file alice.txt has about 29461 words.

这个数字有点大，因为这里使用的文本文件包含出版商提供的额外信息，但与童话 Alice in Wonderland 的长度相当一致。

10.3.7　使用多个文件

下面多分析几本书。这样做之前，我们先将这个程序的大部分代码移到一个名为count_words()的函数中，这样对多本书进行分析时将更容易：

word_count.py

def count_words(filename):
    """计算一个文件大致包含多少个单词""" ❶
    try:
        with open(filename) as f_obj:
            contents = f_obj.read()
    except FileNotFoundError:
        msg = "Sorry, the file " + filename + " does not exist."
        print(msg)
    else:
        # 计算文件大致包含多少个单词
        words = contents.split()
        num_words = len(words)
        print("The file " + filename + " has about " + str(num_words) +
            " words.")

filename = 'alice.txt'
count_words(filename)

这些代码大都与原来一样，我们只是将它们移到了函数count_words()中，并增加了缩进量。修改程序的同时更新注释是个不错的习惯，因此我们将注释改成了文档字符串，并稍微调整了一下措辞（见❶）。

现在可以编写一个简单的循环，计算要分析的任何文本包含多少个单词了。为此，我们将要分析的文件的名称存储在一个列表中，然后对列表中的每个文件都调用count_words()。我们将尝试计算 Alice in Wonderland、Siddhartha、Moby Dick 和 Little Women 分别包含多少个单词，它们都不受版权限制。我故意没有将siddhartha.txt放到word_count.py所在的目录中，让你能够看到这个程序在文件不存在时处理得有多出色：

def count_words(filename):
    --snip--

filenames = ['alice.txt', 'siddhartha.txt', 'moby_dick.txt', 'little_women.txt']
for filename in filenames:
    count_words(filename)

文件siddhartha.txt不存在，但这丝毫不影响这个程序处理其他文件：

The file alice.txt has about 29461 words.
Sorry, the file siddhartha.txt does not exist.
The file moby_dick.txt has about 215136 words.
The file little_women.txt has about 189079 words.

在这个示例中，使用try-except代码块提供了两个重要的优点：避免让用户看到traceback；让程序能够继续分析能够找到的其他文件。如果不捕获因找不到siddhartha.txt而引发的FileNotFoundError异常，用户将看到完整的traceback，而程序将在尝试分析 Siddhartha 后停止运行——根本不分析 Moby Dick 和 Little Women。

10.3.8　失败时一声不吭

在前一个示例中，我们告诉用户有一个文件找不到。但并非每次捕获到异常时都需要告诉用户，有时候你希望程序在发生异常时一声不吭，就像什么都没有发生一样继续运行。要让程序在失败时一声不吭，可像通常那样编写try代码块，但在except代码块中明确地告诉Python什么都不要做。Python有一个pass语句，可在代码块中使用它来让Python什么都不要做：

def count_words(filename):
    """计算一个文件大致包含多少个单词"""
    try:
        --snip--
    except FileNotFoundError:
        pass ❶
    else:
        --snip--

filenames = ['alice.txt', 'siddhartha.txt', 'moby_dick.txt', 'little_women.txt']
for filename in filenames:
    count_words(filename)

相比于前一个程序，这个程序唯一不同的地方是❶处的pass语句。现在，出现FileNotFoundError异常时，将执行except代码块中的代码，但什么都不会发生。这种错误发生时，不会出现traceback，也没有任何输出。用户将看到存在的每个文件包含多少个单词，但没有任何迹象表明有一个文件未找到：

The file alice.txt has about 29461 words.
The file moby_dick.txt has about 215136 words.
The file little_women.txt has about 189079 words.

pass语句还充当了占位符，它提醒你在程序的某个地方什么都没有做，并且以后也许要在这里做些什么。例如，在这个程序中，我们可能决定将找不到的文件的名称写入到文件missing_files.txt中。用户看不到这个文件，但我们可以读取这个文件，进而处理所有文件找不到的问题。

10.3.9　决定报告哪些错误

在什么情况下该向用户报告错误？在什么情况下又应该在失败时一声不吭呢？如果用户知道要分析哪些文件，他们可能希望在有文件没有分析时出现一条消息，将其中的原因告诉他们。如果用户只想看到结果，而并不知道要分析哪些文件，可能就无需在有些文件不存在时告知他们。向用户显示他不想看到的信息可能会降低程序的可用性。Python的错误处理结构让你能够细致地控制与用户分享错误信息的程度，要分享多少信息由你决定。

编写得很好且经过详尽测试的代码不容易出现内部错误，如语法或逻辑错误，但只要程序依赖于外部因素，如用户输入、存在指定的文件、有网络链接，就有可能出现异常。凭借经验可判断该在程序的什么地方包含异常处理块，以及出现错误时该向用户提供多少相关的信息。

动手试一试

10-6 加法运算：提示用户提供数值输入时，常出现的一个问题是，用户提供的是文本而不是数字。在这种情况下，当你尝试将输入转换为整数时，将引发ValueError异常。编写一个程序，提示用户输入两个数字，再将它们相加并打印结果。在用户输入的任何一个值不是数字时都捕获ValueError异常，并打印一条友好的错误消息。对你编写的程序进行测试：先输入两个数字，再输入一些文本而不是数字。

10-7 加法计算器：将你为完成练习10-6而编写的代码放在一个while循环中，让用户犯错（输入的是文本而不是数字）后能够继续输入数字。

10-8 猫和狗：创建两个文件cats.txt和dogs.txt，在第一个文件中至少存储三只猫的名字，在第二个文件中至少存储三条狗的名字。编写一个程序，尝试读取这些文件，并将其内容打印到屏幕上。将这些代码放在一个try-except代码块中，以便在文件不存在时捕获FileNotFound错误，并打印一条友好的消息。将其中一个文件移到另一个地方，并确认except代码块中的代码将正确地执行。

10-9 沉默的猫和狗：修改你在练习10-8中编写的except代码块，让程序在文件不存在时一言不发。

10-10 常见单词：访问项目Gutenberg（http://gutenberg.org/），并找一些你想分析的图书。下载这些作品的文本文件或将浏览器中的原始文本复制到文本文件中。

你可以使用方法count()来确定特定的单词或短语在字符串中出现了多少次。例如，下面的代码计算'row'在一个字符串中出现了多少次：

>> line = "Row, row, row your boat"
>> line.count('row')
2
>> line.lower().count('row')
3

请注意，通过使用lower()将字符串转换为小写，可捕捉要查找的单词出现的所有次数，而不管其大小写格式如何。

编写一个程序，它读取你在项目Gutenberg中获取的文件，并计算单词'the'在每个文件中分别出现了多少次。

10.4　存储数据

很多程序都要求用户输入某种信息，如让用户存储游戏首选项或提供要可视化的数据。不管专注的是什么，程序都把用户提供的信息存储在列表和字典等数据结构中。用户关闭程序时，你几乎总是要保存他们提供的信息；一种简单的方式是使用模块json来存储数据。

模块json让你能够将简单的Python数据结构转储到文件中，并在程序再次运行时加载该文件中的数据。你还可以使用json在Python程序之间分享数据。更重要的是，JSON数据格式并非Python专用的，这让你能够将以JSON格式存储的数据与使用其他编程语言的人分享。这是一种轻便格式，很有用，也易于学习。

注意　JSON（JavaScript Object Notation）格式最初是为JavaScript开发的，但随后成了一种常见格式，被包括Python在内的众多语言采用。

10.4.1　使用json.dump()和json.load()

我们来编写一个存储一组数字的简短程序，再编写一个将这些数字读取到内存中的程序。第一个程序将使用json.dump()来存储这组数字，而第二个程序将使用json.load()。

函数json.dump()接受两个实参：要存储的数据以及可用于存储数据的文件对象。下面演示了如何使用json.dump()来存储数字列表：

number_writer.py

import json

numbers = [2, 3, 5, 7, 11, 13]

filename = 'numbers.json' ❶
with open(filename, 'w') as f_obj: ❷
    json.dump(numbers, f_obj) ❸

我们先导入模块json，再创建一个数字列表。在❶处，我们指定了要将该数字列表存储到其中的文件的名称。通常使用文件扩展名.json来指出文件存储的数据为JSON格式。接下来，我们以写入模式打开这个文件，让json能够将数据写入其中（见❷）。在❸处，我们使用函数json.dump()将数字列表存储到文件numbers.json中。

这个程序没有输出，但我们可以打开文件numbers.json，看看其内容。数据的存储格式与Python中一样：

[2, 3, 5, 7, 11, 13]

下面再编写一个程序，使用json.load()将这个列表读取到内存中：

number_reader.py

import json

filename = 'numbers.json' ❶
with open(filename) as f_obj: ❷
    numbers = json.load(f_obj) ❸

print(numbers)

在❶处，我们确保读取的是前面写入的文件。这次我们以读取方式打开这个文件，因为Python只需读取这个文件（见❷）。在❸处，我们使用函数json.load()加载存储在numbers.json中的信息，并将其存储到变量numbers中。最后，我们打印恢复的数字列表，看看它是否与number_writer.py中创建的数字列表相同：

[2, 3, 5, 7, 11, 13]

这是一种在程序之间共享数据的简单方式。

10.4.2　保存和读取用户生成的数据

对于用户生成的数据，使用json保存它们大有裨益，因为如果不以某种方式进行存储，等程序停止运行时用户的信息将丢失。下面来看一个这样的例子：用户首次运行程序时被提示输入自己的名字，这样再次运行程序时就记住他了。

我们先来存储用户的名字：

remember_me.py

import json

username = input("What is your name? ") ❶

filename = 'username.json'
with open(filename, 'w') as f_obj:
    json.dump(username, f_obj) ❷
    print("We'll remember you when you come back, " + username + "!") ❸

在❶处，我们提示输入用户名，并将其存储在一个变量中。接下来，我们调用json.dump()，并将用户名和一个文件对象传递给它，从而将用户名存储到文件中（见❷）。然后，我们打印一条消息，指出我们存储了他输入的信息（见❸）：

What is your name? Eric
We'll remember you when you come back, Eric!

现在再编写一个程序，向其名字被存储的用户发出问候：

greet_user.py

import json

filename = 'username.json'

with open(filename) as f_obj:
    username = json.load(f_obj) ❶
    print("Welcome back, " + username + "!") ❷

在❶处，我们使用json.load()将存储在username.json中的信息读取到变量username中。恢复用户名后，我们就可以欢迎用户回来了（见❷）：

Welcome back, Eric!

我们需要将这两个程序合并到一个程序（remember_me.py）中。这个程序运行时，我们将尝试从文件username.json中获取用户名，因此我们首先编写一个尝试恢复用户名的try代码块。如果这个文件不存在，我们就在except代码块中提示用户输入用户名，并将其存储在username.json中，以便程序再次运行时能够获取它：

remember_me.py

import json

# 如果以前存储了用户名，就加载它
#  否则，就提示用户输入用户名并存储它
filename = 'username.json'
try:
    with open(filename) as f_obj: ❶
        username = json.load(f_obj) ❷
except FileNotFoundError: ❸
    username = input("What is your name? ") ❹
    with open(filename, 'w') as f_obj: ❺
        json.dump(username, f_obj)
        print("We'll remember you when you come back, " + username + "!")
else:
    print("Welcome back, " + username + "!")

这里没有任何新代码，只是将前两个示例的代码合并到了一个程序中。在❶处，我们尝试打开文件username.json。如果这个文件存在，就将其中的用户名读取到内存中（见❷），再执行else代码块，即打印一条欢迎用户回来的消息。用户首次运行这个程序时，文件username.json不存在，将引发FileNotFoundError异常（见❸），因此Python将执行except代码块：提示用户输入其用户名（见❹），再使用json.dump()存储该用户名，并打印一句问候语（见❺）。

无论执行的是except代码块还是else代码块，都将显示用户名和合适的问候语。如果这个程序是首次运行，输出将如下：

What is your name? Eric
We'll remember you when you come back, Eric!

否则，输出将如下：

Welcome back, Eric!

这是程序之前至少运行了一次时的输出。

10.4.3　重构

你经常会遇到这样的情况：代码能够正确地运行，但可做进一步的改进——将代码划分为一系列完成具体工作的函数。这样的过程被称为重构。重构让代码更清晰、更易于理解、更容易扩展。

要重构remember_me.py，可将其大部分逻辑放到一个或多个函数中。remember_me.py的重点是问候用户，因此我们将其所有代码都放到一个名为greet_user()的函数中：

remember_me.py

import json

def greet_user():
    """问候用户，并指出其名字""" ❶
    filename = 'username.json'
    try:
        with open(filename) as f_obj:
            username = json.load(f_obj)
    except FileNotFoundError:
        username = input("What is your name? ")
        with open(filename, 'w') as f_obj:
            json.dump(username, f_obj)
            print("We'll remember you when you come back, " + username + "!")
    else:
        print("Welcome back, " + username + "!")

greet_user()

考虑到现在使用了一个函数，我们删除了注释，转而使用一个文档字符串来指出程序是做什么的（见❶）。这个程序更清晰些，但函数greet_user()所做的不仅仅是问候用户，还在存储了用户名时获取它，而在没有存储用户名时提示用户输入一个。

下面来重构greet_user()，让它不执行这么多任务。为此，我们首先将获取存储的用户名的代码移到另一个函数中：

import json

def get_stored_username():
    """如果存储了用户名，就获取它""" ❶
    filename = 'username.json'
    try:
        with open(filename) as f_obj:
            username = json.load(f_obj)
    except FileNotFoundError:
        return None ❷
    else:
        return username

def greet_user():
    """问候用户，并指出其名字"""
    username = get_stored_username()
    if username: ❸
        print("Welcome back, " + username + "!")
    else:
        username = input("What is your name? ")
        filename = 'username.json'
        with open(filename, 'w') as f_obj:
            json.dump(username, f_obj)
            print("We'll remember you when you come back, " + username + "!")

greet_user()

新增的函数get_stored_username()目标明确，❶处的文档字符串指出了这一点。如果存储了用户名，这个函数就获取并返回它；如果文件username.json不存在，这个函数就返回None（见❷）。这是一种不错的做法：函数要么返回预期的值，要么返回None；这让我们能够使用函数的返回值做简单测试。在❸处，如果成功地获取了用户名，就打印一条欢迎用户回来的消息，否则就提示用户输入用户名。

我们还需将greet_user()中的另一个代码块提取出来：将没有存储用户名时提示用户输入的代码放在一个独立的函数中：

import json

def get_stored_username():
    """如果存储了用户名，就获取它"""
    --snip--

def get_new_username():
    """提示用户输入用户名"""
    username = input("What is your name? ")
    filename = 'username.json'
    with open(filename, 'w') as f_obj:
        json.dump(username, f_obj)
    return username

def greet_user():
    """问候用户，并指出其名字"""
    username = get_stored_username()
    if username:
        print("Welcome back, " + username + "!")
    else:
        username = get_new_username()
        print("We'll remember you when you come back, " + username + "!")

greet_user()

在remember_me.py的这个最终版本中，每个函数都执行单一而清晰的任务。我们调用greet_user()，它打印一条合适的消息：要么欢迎老用户回来，要么问候新用户。为此，它首先调用get_stored_username()，这个函数只负责获取存储的用户名（如果存储了的话），再在必要时调用get_new_username()，这个函数只负责获取并存储新用户的用户名。要编写出清晰而易于维护和扩展的代码，这种划分工作必不可少。

动手试一试

10-11 喜欢的数字：编写一个程序，提示用户输入他喜欢的数字，并使用json.dump()将这个数字存储到文件中。再编写一个程序，从文件中读取这个值，并打印消息“I know your favorite number! It's _____.”。

10-12 记住喜欢的数字：将练习10-11中的两个程序合而为一。如果存储了用户喜欢的数字，就向用户显示它，否则提示用户输入他喜欢的数字并将其存储到文件中。运行这个程序两次，看看它是否像预期的那样工作。

10-13 验证用户：最后一个remember_me.py版本假设用户要么已输入其用户名，要么是首次运行该程序。我们应修改这个程序，以应对这样的情形：当前和最后一次运行该程序的用户并非同一个人。

为此，在greet_user()中打印欢迎用户回来的消息前，先询问他用户名是否是对的。如果不对，就调用get_new_username()让用户输入正确的用户名。

10.5　小结

在本章中，你学习了：如何使用文件；如何一次性读取整个文件，以及如何以每次一行的方式读取文件的内容；如何写入文件，以及如何将文本附加到文件末尾；什么是异常以及如何处理程序可能引发的异常；如何存储Python数据结构，以保存用户提供的信息，避免用户每次运行程序时都需要重新提供。

在第11章中，你将学习高效的代码测试方式，这可帮助你确定代码正确无误，以及发现扩展现有程序时可能引入的bug。

第 11 章　测试代码

编写函数或类时，还可为其编写测试。通过测试，可确定代码面对各种输入都能够按要求的那样工作。测试让你信心满满，深信即便有更多的人使用你的程序，它也能正确地工作。在程序中添加新代码时，你也可以对其进行测试，确认它们不会破坏程序既有的行为。程序员都会犯错，因此每个程序员都必须经常测试其代码，在用户发现问题前找出它们。

在本章中，你将学习如何使用Python模块unittest中的工具来测试代码。你将学习编写测试用例，核实一系列输入都将得到预期的输出。你将看到测试通过了是什么样子，测试未通过又是什么样子，还将知道测试未通过如何有助于改进代码。你将学习如何测试函数和类，并将知道该为项目编写多少个测试。

11.1　测试函数

要学习测试，得有要测试的代码。下面是一个简单的函数，它接受名和姓并返回整洁的姓名：

name_function.py

def get_formatted_name(first, last):
    """Generate a neatly formatted full name."""
    full_name = first + ' ' + last
    return full_name.title()

函数get_formatted_name()将名和姓合并成姓名，在名和姓之间加上一个空格，并将它们的首字母都大写，再返回结果。为核实get_formatted_name()像期望的那样工作，我们来编写一个使用这个函数的程序。程序names.py让用户输入名和姓，并显示整洁的全名：

names.py

from name_function import get_formatted_name

print("Enter 'q' at any time to quit.")
while True:
    first = input("\nPlease give me a first name: ")
    if first == 'q':
        break
    last = input("Please give me a last name: ")
    if last == 'q':
        break

    formatted_name = get_formatted_name(first, last)
    print("\tNeatly formatted name: " + formatted_name + '.')

这个程序从name_function.py中导入get_formatted_name()。用户可输入一系列的名和姓，并看到格式整洁的全名：

Enter 'q' at any time to quit.

Please give me a first name: janis
Please give me a last name: joplin
       Neatly formatted name: Janis Joplin.

Please give me a first name: bob
Please give me a last name: dylan
       Neatly formatted name: Bob Dylan.

Please give me a first name: q

从上述输出可知，合并得到的姓名正确无误。现在假设我们要修改get_formatted_name()，使其还能够处理中间名。这样做时，我们要确保不破坏这个函数处理只有名和姓的姓名的方式。为此，我们可以在每次修改get_formatted_name()后都进行测试：运行程序names.py，并输入像Janis Joplin这样的姓名，但这太烦琐了。所幸Python提供了一种自动测试函数输出的高效方式。倘若我们对get_formatted_name()进行自动测试，就能始终信心满满，确信给这个函数提供我们测试过的姓名时，它都能正确地工作。

11.1.1　单元测试和测试用例

Python标准库中的模块unittest提供了代码测试工具。单元测试用于核实函数的某个方面没有问题；测试用例是一组单元测试，这些单元测试一起核实函数在各种情形下的行为都符合要求。良好的测试用例考虑到了函数可能收到的各种输入，包含针对所有这些情形的测试。全覆盖式测试用例包含一整套单元测试，涵盖了各种可能的函数使用方式。对于大型项目，要实现全覆盖可能很难。通常，最初只要针对代码的重要行为编写测试即可，等项目被广泛使用时再考虑全覆盖。

11.1.2　可通过的测试

创建测试用例的语法需要一段时间才能习惯，但测试用例创建后，再添加针对函数的单元测试就很简单了。要为函数编写测试用例，可先导入模块unittest以及要测试的函数，再创建一个继承unittest.TestCase的类，并编写一系列方法对函数行为的不同方面进行测试。

下面是一个只包含一个方法的测试用例，它检查函数get_formatted_name()在给定名和姓时能否正确地工作：

test_name_function.py

import unittest
from name_function import get_formatted_name

class NamesTestCase(unittest.TestCase): ❶
    """测试name_function.py"""

    def test_first_last_name(self):
        """能够正确地处理像Janis Joplin这样的姓名吗？"""
        formatted_name = get_formatted_name('janis', 'joplin') ❷
        self.assertEqual(formatted_name, 'Janis Joplin') ❸

unittest.main()

首先，我们导入了模块unittest和要测试的函数get_formatted_name()。在❶处，我们创建了一个名为NamesTestCase的类，用于包含一系列针对get_formatted_name()的单元测试。你可随便给这个类命名，但最好让它看起来与要测试的函数相关，并包含字样Test。这个类必须继承unittest.TestCase类，这样Python才知道如何运行你编写的测试。

NamesTestCase只包含一个方法，用于测试get_formatted_name()的一个方面。我们将这个方法命名为test_first_last_name()，因为我们要核实的是只有名和姓的姓名能否被正确地格式化。我们运行testname_function.py时，所有以test打头的方法都将自动运行。在这个方法中，我们调用了要测试的函数，并存储了要测试的返回值。在这个示例中，我们使用实参'janis'和'joplin'调用get_formatted_name()，并将结果存储到变量formatted_name中（见❷）。

在❸处，我们使用了unittest类最有用的功能之一：一个断言方法。断言方法用来核实得到的结果是否与期望的结果一致。在这里，我们知道get_formatted_name()应返回这样的姓名，即名和姓的首字母为大写，且它们之间有一个空格，因此我们期望formatted_name的值为Janis Joplin。为检查是否确实如此，我们调用unittest的方法assertEqual()，并向它传递formatted_name和'Janis Joplin'。代码行self.assertEqual(formatted_name, 'Janis Joplin')的意思是说：“将formatted_name的值同字符串'Janis Joplin'进行比较，如果它们相等，就万事大吉，如果它们不相等，跟我说一声！”

代码行unittest.main()让Python运行这个文件中的测试。运行test_name_function.py时，得到的输出如下：

.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

第1行的句点表明有一个测试通过了。接下来的一行指出Python运行了一个测试，消耗的时间不到0.001秒。最后的OK表明该测试用例中的所有单元测试都通过了。

上述输出表明，给定包含名和姓的姓名时，函数get_formatted_name()总是能正确地处理。修改get_formatted_name()后，可再次运行这个测试用例。如果它通过了，我们就知道在给定Janis Joplin这样的姓名时，这个函数依然能够正确地处理。

11.1.3　不能通过的测试

测试未通过时结果是什么样的呢？我们来修改get_formatted_name()，使其能够处理中间名，但这样做时，故意让这个函数无法正确地处理像Janis Joplin这样只有名和姓的姓名。

下面是函数get_formatted_name()的新版本，它要求通过一个实参指定中间名：

name_function.py

def get_formatted_name(first, middle, last):
    """生成整洁的姓名"""
    full_name = first + ' ' + middle + ' ' + last
    return full_name.title()

这个版本应该能够正确地处理包含中间名的姓名，但对其进行测试时，我们发现它再也不能正确地处理只有名和姓的姓名。这次运行程序test_name_function.py时，输出如下：

E ❶
======================================================================
ERROR: test_first_last_name (__main__.NamesTestCase) ❷
----------------------------------------------------------------------
Traceback (most recent call last): ❸
  File "test_name_function.py", line 8, in test_first_last_name
    formatted_name = get_formatted_name('janis', 'joplin')
TypeError: get_formatted_name() missing 1 required positional argument: 'last'

----------------------------------------------------------------------
Ran 1 test in 0.000s ❹

FAILED (errors=1) ❺

其中包含的信息很多，因为测试未通过时，需要让你知道的事情可能有很多。第1行输出只有一个字母E（见❶），它指出测试用例中有一个单元测试导致了错误。接下来，我们看到NamesTestCase中的test_first_last_name()导致了错误（见❷）。测试用例包含众多单元测试时，知道哪个测试未通过至关重要。在❸处，我们看到了一个标准的traceback，它指出函数调用get_formatted_name('janis', 'joplin')有问题，因为它缺少一个必不可少的位置实参。

我们还看到运行了一个单元测试（见❹）。最后，还看到了一条消息，它指出整个测试用例都未通过，因为运行该测试用例时发生了一个错误（见❺）。这条消息位于输出末尾，让你一眼就能看到——你可不希望为获悉有多少测试未通过而翻阅长长的输出。

11.1.4　测试未通过时怎么办

测试未通过时怎么办呢？如果你检查的条件没错，测试通过了意味着函数的行为是对的，而测试未通过意味着你编写的新代码有错。因此，测试未通过时，不要修改测试，而应修复导致测试不能通过的代码：检查刚对函数所做的修改，找出导致函数行为不符合预期的修改。

在这个示例中，get_formatted_name()以前只需要两个实参——名和姓，但现在它要求提供名、中间名和姓。新增的中间名参数是必不可少的，这导致get_formatted_name()的行为不符合预期。就这里而言，最佳的选择是让中间名变为可选的。这样做后，使用类似于Janis Joplin的姓名进行测试时，测试就会通过了，同时这个函数还能接受中间名。下面来修改get_formatted_name()，将中间名设置为可选的，然后再次运行这个测试用例。如果通过了，我们接着确认这个函数能够妥善地处理中间名。

要将中间名设置为可选的，可在函数定义中将形参middle移到形参列表末尾，并将其默认值指定为一个空字符串。我们还要添加一个if测试，以便根据是否提供了中间名相应地创建姓名：

name_function.py

def get_formatted_name(first, last, middle=''):
    """生成整洁的姓名"""
    if middle:
        full_name = first + ' ' + middle + ' ' + last
    else:
        full_name = first + ' ' + last
    return full_name.title()

在get_formatted_name()的这个新版本中，中间名是可选的。如果向这个函数传递了中间名（if middle:），姓名将包含名、中间名和姓，否则姓名将只包含名和姓。现在，对于两种不同的姓名，这个函数都应该能够正确地处理。为确定这个函数依然能够正确地处理像Janis Joplin这样的姓名，我们再次运行test_name_function.py：

.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

现在，测试用例通过了。太好了，这意味着这个函数又能正确地处理像Janis Joplin这样的姓名了，而且我们无需手工测试这个函数。这个函数很容易就修复了，因为未通过的测试让我们得知新代码破坏了函数原来的行为。

11.1.5　添加新测试

确定get_formatted_name()又能正确地处理简单的姓名后，我们再编写一个测试，用于测试包含中间名的姓名。为此，我们在NamesTestCase类中再添加一个方法：

import unittest
from name_function import get_formatted_name

class NamesTestCase(unittest.TestCase):
    """测试name_function.py """

    def test_first_last_name(self):
        """能够正确地处理像Janis Joplin这样的姓名吗？"""
        formatted_name = get_formatted_name('janis', 'joplin')
        self.assertEqual(formatted_name, 'Janis Joplin')

    def test_first_last_middle_name(self):
        """能够正确地处理像Wolfgang Amadeus Mozart这样的姓名吗？"""
        formatted_name = get_formatted_name( ❶
            'wolfgang', 'mozart', 'amadeus')
        self.assertEqual(formatted_name, 'Wolfgang Amadeus Mozart')

unittest.main()

我们将这个方法命名为test_first_last_middle_name()。方法名必须以test_打头，这样它才会在我们运行test_name_function.py时自动运行。这个方法名清楚地指出了它测试的是get_formatted_name()的哪个行为，这样，如果该测试未通过，我们就会马上知道受影响的是哪种类型的姓名。在TestCase类中使用很长的方法名是可以的；这些方法的名称必须是描述性的，这才能让你明白测试未通过时的输出；这些方法由Python自动调用，你根本不用编写调用它们的代码。

为测试函数get_formatted_name()，我们使用名、姓和中间名调用它（见❶），再使用assertEqual()检查返回的姓名是否与预期的姓名（名、中间名和姓）一致。我们再次运行test_name_function.py时，两个测试都通过了：

..
----------------------------------------------------------------------
Ran 2 tests in 0.000s

OK

太好了！现在我们知道，这个函数又能正确地处理像Janis Joplin这样的姓名了，我们还深信它也能够正确地处理像Wolfgang Amadeus Mozart这样的姓名。

动手试一试

11-1 城市和国家：编写一个函数，它接受两个形参：一个城市名和一个国家名。这个函数返回一个格式为City, Country的字符串，如Santiago, Chile。将这个函数存储在一个名为city_functions.py的模块中。

创建一个名为test_cities.py的程序，对刚编写的函数进行测试（别忘了，你需要导入模块unittest以及要测试的函数）。编写一个名为test_city_country()的方法，核实使用类似于'santiago'和'chile'这样的值来调用前述函数时，得到的字符串是正确的。运行test_cities.py，确认测试test_city_country()通过了。

11-2 人口数量：修改前面的函数，使其包含第三个必不可少的形参population，并返回一个格式为City, Country - population xxx的字符串，如Santiago, Chile - population 5000000。运行test_cities.py，确认测试test_city_country()未通过。

修改上述函数，将形参population设置为可选的。再次运行test_cities.py，确认测试test_city_country()又通过了。

再编写一个名为test_city_country_population()的测试，核实可以使用类似于'santiago'、'chile'和'population=5000000'这样的值来调用这个函数。再次运行test_cities.py，确认测试test_city_country_population()通过了。

11.2　测试类

在本章前半部分，你编写了针对单个函数的测试，下面来编写针对类的测试。很多程序中都会用到类，因此能够证明你的类能够正确地工作会大有裨益。如果针对类的测试通过了，你就能确信对类所做的改进没有意外地破坏其原有的行为。

11.2.1　各种断言方法

Python在unittest.TestCase类中提供了很多断言方法。前面说过，断言方法检查你认为应该满足的条件是否确实满足。如果该条件确实满足，你对程序行为的假设就得到了确认，你就可以确信其中没有错误。如果你认为应该满足的条件实际上并不满足，Python将引发异常。

表11-1描述了6个常用的断言方法。使用这些方法可核实返回的值等于或不等于预期的值、返回的值为True或False、返回的值在列表中或不在列表中。你只能在继承unittest.TestCase的类中使用这些方法，下面来看看如何在测试类时使用其中的一个。

表11-1　unittest Module中的断言方法

方法

用途

assertEqual(a, b)

核实a == b

assertNotEqual(a, b)

核实a != b

assertTrue(x)

核实x为True

assertFalse(x)

核实x为False

assertIn(item, list)

核实item在list中

assertNotIn(item, list)

核实item不在list中

11.2.2　一个要测试的类

类的测试与函数的测试相似——你所做的大部分工作都是测试类中方法的行为，但存在一些不同之处，下面来编写一个类进行测试。来看一个帮助管理匿名调查的类：

survey.py

class AnonymousSurvey():
    """收集匿名调查问卷的答案"""

    def __init__(self, question): ❶
        """存储一个问题，并为存储答案做准备"""
        self.question = question
        self.responses = []

    def show_question(self): ❷
        """显示调查问卷"""
        print(self.question)

    def store_response(self, new_response): ❸
        """存储单份调查答卷"""
        self.responses.append(new_response)

    def show_results(self): ❹
        """显示收集到的所有答卷"""
        print("Survey results:")
        for response in self.responses:
            print('- ' + response)

这个类首先存储了一个你指定的调查问题（见❶），并创建了一个空列表，用于存储答案。这个类包含打印调查问题的方法（见❷）、在答案列表中添加新答案的方法（见❸）以及将存储在列表中的答案都打印出来的方法（见❹）。要创建这个类的实例，只需提供一个问题即可。有了表示调查的实例后，就可使用show_question()来显示其中的问题，可使用store_response()来存储答案，并使用show_results()来显示调查结果。

为证明AnonymousSurvey类能够正确地工作，我们来编写一个使用它的程序：

language_survey.py

from survey import AnonymousSurvey

#定义一个问题，并创建一个表示调查的AnonymousSurvey对象
question = "What language did you first learn to speak?"
my_survey = AnonymousSurvey(question)

#显示问题并存储答案
my_survey.show_question()
print("Enter 'q' at any time to quit.\n")
while True:
    response = input("Language: ")
    if response == 'q':
        break
    my_survey.store_response(response)

# 显示调查结果
print("\nThank you to everyone who participated in the survey!")
my_survey.show_results()

这个程序定义了一个问题（"What language did you first learn to speak? "），并使用这个问题创建了一个AnonymousSurvey对象。接下来，这个程序调用show_question()来显示问题，并提示用户输入答案。收到每个答案的同时将其存储起来。用户输入所有答案（输入q要求退出）后，调用show_results()来打印调查结果：

What language did you first learn to speak?
Enter 'q' at any time to quit.

Language: English
Language: Spanish
Language: English
Language: Mandarin
Language: q

Thank you to everyone who participated in the survey!
Survey results:
- English
- Spanish
- English
- Mandarin

AnonymousSurvey类可用于进行简单的匿名调查。假设我们将它放在了模块survey中，并想进行改进：让每位用户都可输入多个答案；编写一个方法，它只列出不同的答案，并指出每个答案出现了多少次；再编写一个类，用于管理非匿名调查。

进行上述修改存在风险，可能会影响AnonymousSurvey类的当前行为。例如，允许每位用户输入多个答案时，可能不小心修改了处理单个答案的方式。要确认在开发这个模块时没有破坏既有行为，可以编写针对这个类的测试。

11.2.3　测试AnonymousSurvey类

下面来编写一个测试，对AnonymousSurvey类的行为的一个方面进行验证：如果用户面对调查问题时只提供了一个答案，这个答案也能被妥善地存储。为此，我们将在这个答案被存储后，使用方法assertIn()来核实它包含在答案列表中：

test_survey.py

import unittest
from survey import AnonymousSurvey

class TestAnonymousSurvey(unittest.TestCase): ❶
    """针对AnonymousSurvey类的测试"""

    def test_store_single_response(self): ❷
        """测试单个答案会被妥善地存储"""
        question = "What language did you first learn to speak?"
        my_survey = AnonymousSurvey(question) ❸
        my_survey.store_response('English')

        self.assertIn('English', my_survey.responses) ❹

unittest.main()

我们首先导入了模块unittest以及要测试的类AnonymousSurvey。我们将测试用例命名为TestAnonymousSurvey，它也继承了unittest.TestCase（见❶）。第一个测试方法验证调查问题的单个答案被存储后，会包含在调查结果列表中。对于这个方法，一个不错的描述性名称是test_store_single_response()（见❷）。如果这个测试未通过，我们就能通过输出中的方法名得知，在存储单个调查答案方面存在问题。

要测试类的行为，需要创建其实例。在❸处，我们使用问题"What language did you first learn to speak?"创建了一个名为my_survey的实例，然后使用方法store_response()存储了单个答案English。接下来，我们检查English是否包含在列表my_survey.responses中，以核实这个答案是否被妥善地存储了（见❹）。

当我们运行test_survey.py时，测试通过了：

.
----------------------------------------------------------------------
Ran 1 test in 0.001s

OK

这很好，但只能收集一个答案的调查用途不大。下面来核实用户提供三个答案时，它们也将被妥善地存储。为此，我们在TestAnonymousSurvey中再添加一个方法：

import unittest
from survey import AnonymousSurvey

class TestAnonymousSurvey(unittest.TestCase):
    """针对AnonymousSurvey类的测试"""

    def test_store_single_response(self):
        """测试单个答案会被妥善地存储"""
        --snip--

    def test_store_three_responses(self):
        """测试三个答案会被妥善地存储"""
        question = "What language did you first learn to speak?"
        my_survey = AnonymousSurvey(question)
        responses = ['English', 'Spanish', 'Mandarin'] ❶
        for response in responses:
            my_survey.store_response(response)

        for response in responses: ❷
            self.assertIn(response, my_survey.responses)

unittest.main()

我们将这个方法命名为test_store_three_responses()，并像test_store_single_response()一样，在其中创建一个调查对象。我们定义了一个包含三个不同答案的列表（见❶），再对其中每个答案都调用store_response()。存储这些答案后，我们使用一个循环来确认每个答案都包含在my_survey.responses中（见❷）。

我们再次运行test_survey.py时，两个测试（针对单个答案的测试和针对三个答案的测试）都通过了：

..
----------------------------------------------------------------------
Ran 2 tests in 0.000s

OK

前述做法的效果很好，但这些测试有些重复的地方。下面使用unittest的另一项功能来提高它们的效率。

11.2.4　方法setUp()

在前面的test_survey.py中，我们在每个测试方法中都创建了一个AnonymousSurvey实例，并在每个方法中都创建了答案。unittest.TestCase类包含方法setUp()，让我们只需创建这些对象一次，并在每个测试方法中使用它们。如果你在TestCase类中包含了方法setUp()，Python将先运行它，再运行各个以test_打头的方法。这样，在你编写的每个测试方法中都可使用在方法setUp()中创建的对象了。

下面使用setUp()来创建一个调查对象和一组答案，供方法test_store_single_response()和test_store_three_responses()使用：

import unittest
from survey import AnonymousSurvey

class TestAnonymousSurvey(unittest.TestCase):
    """针对AnonymousSurvey类的测试"""

    def setUp(self):
        """
        创建一个调查对象和一组答案，供使用的测试方法使用
        """
        question = "What language did you first learn to speak?"
        self.my_survey = AnonymousSurvey(question) ❶
        self.responses = ['English', 'Spanish', 'Mandarin'] ❷

    def test_store_single_response(self):
        """测试单个答案会被妥善地存储"""
        self.my_survey.store_response(self.responses[0])
        self.assertIn(self.responses[0], self.my_survey.responses)

    def test_store_three_responses(self):
        """测试三个答案会被妥善地存储"""
        for response in self.responses:
            self.my_survey.store_response(response)
        for response in self.responses:
            self.assertIn(response, self.my_survey.responses)

unittest.main()

方法setUp()做了两件事情：创建一个调查对象（见❶）；创建一个答案列表（见❷）。存储这两样东西的变量名包含前缀self（即存储在属性中），因此可在这个类的任何地方使用。这让两个测试方法都更简单，因为它们都不用创建调查对象和答案。方法test_store_single_response()核实self.responses中的第一个答案——self.responses[0]——被妥善地存储，而方法test_store_three_response()核实self.responses中的全部三个答案都被妥善地存储。

再次运行test_survey.py时，这两个测试也都通过了。如果要扩展AnonymousSurvey，使其允许每位用户输入多个答案，这些测试将很有用。修改代码以接受多个答案后，可运行这些测试，确认存储单个答案或一系列答案的行为未受影响。

测试自己编写的类时，方法setUp()让测试方法编写起来更容易：可在setUp()方法中创建一系列实例并设置它们的属性，再在测试方法中直接使用这些实例。相比于在每个测试方法中都创建实例并设置其属性，这要容易得多。

注意　运行测试用例时，每完成一个单元测试，Python都打印一个字符：测试通过时打印一个句点；测试引发错误时打印一个E；测试导致断言失败时打印一个F。这就是你运行测试用例时，在输出的第一行中看到的句点和字符数量各不相同的原因。如果测试用例包含很多单元测试，需要运行很长时间，就可通过观察这些结果来获悉有多少个测试通过了。

动手试一试

11-3 雇员：编写一个名为Employee的类，其方法__init__()接受名、姓和年薪，并将它们都存储在属性中。编写一个名为give_raise()的方法，它默认将年薪增加5000美元，但也能够接受其他的年薪增加量。

为Employee编写一个测试用例，其中包含两个测试方法：test_give_default_raise()和test_give_custom_raise()。使用方法setUp()，以免在每个测试方法中都创建新的雇员实例。运行这个测试用例，确认两个测试都通过了。

11.3　小结

在本章中，你学习了：如何使用模块unittest中的工具来为函数和类编写测试；如何编写继承unittest.TestCase的类，以及如何编写测试方法，以核实函数和类的行为符合预期；如何使用方法setUp()来根据类高效地创建实例并设置其属性，以便在类的所有测试方法中都可使用它们。

测试是很多初学者都不熟悉的主题。作为初学者，并非必须为你尝试的所有项目编写测试；但参与工作量较大的项目时，你应对自己编写的函数和类的重要行为进行测试。这样你就能够更加确定自己所做的工作不会破坏项目的其他部分，你就能够随心所欲地改进既有代码了。如果不小心破坏了原来的功能，你马上就会知道，从而能够轻松地修复问题。相比于等到不满意的用户报告bug后再采取措施，在测试未通过时采取措施要容易得多。

如果你在项目中包含了初步测试，其他程序员将更敬佩你，他们将能够更得心应手地尝试使用你编写的代码，也更愿意与你合作开发项目。如果你要跟其他程序员开发的项目共享代码，就必须证明你编写的代码通过了既有测试，通常还需要为你添加的新行为编写测试。

请通过多开展测试来熟悉代码测试过程。对于自己编写的函数和类，请编写针对其重要行为的测试，但在项目早期，不要试图去编写全覆盖的测试用例，除非有充分的理由这样做。

第二部分　项目

祝贺你！你现在已对 Python 有了足够的认识，可以开始开发有意思的交互式项目了。通过动手开发项目，可学到新技能，并更深入地理解第一部分介绍的概念。

第二部分包含三个不同类型的项目，你可以选择完成其中的任何项目或全部项目，完成这些项目的顺序无关紧要。下面简要地描述每个项目，帮助你决定首先去完成哪个项目。

外星人入侵：使用 Python 开发游戏

在项目“外星人入侵”（第 12~14 章）中，你将使用 Pygame 包来开发一款 2D 游戏，它在玩家每消灭一群向下移动的外星人后，都将玩家提高一个等级；而等级越高，游戏的节奏越快，难度越大。完成这个项目后，你将获得自己动手使用 Pygame 开发 2D 游戏所需的技能。

数据可视化

“数据可视化”项目始于第 15 章，在这一章中，你将学习如何使用 matplotlib 和 Pygal 来生成数据，以及根据这些数据创建实用而漂亮的图表。第 16 章介绍如何从网上获取数据，并将它们提供给可视化包以创建天气图和交易收盘价图。最后，第 17 章介绍如何编写自动下载数据并对其进行可视化的程序。学习可视化让你可以探索数据挖掘领域，这是当前在全球都非常吃香的技能。

Web 应用程序

在“Web 应用程序”项目（第 18~20 章）中，你将使用 Django 包来创建一个简单的 Web 应用程序，它让用户能够记录任意多个一直在学习的主题。用户将通过指定用户名和密码来创建账户，输入主题，并编写条目来记录学习的内容。你还将学习如何部署应用程序，让世界上的任何人都能够访问它。

完成这个项目后，你将能够自己动手创建简单的 Web 应用程序，并能够深入学习其他有关如何使用 Django 开发应用程序的资料。

项目1　外星人入侵

第 12 章　武装飞船

我们来开发一个游戏吧！我们将使用Pygame，这是一组功能强大而有趣的模块，可用于管理图形、动画乃至声音，让你能够更轻松地开发复杂的游戏。通过使用Pygame来处理在屏幕上绘制图像等任务，你不用考虑众多烦琐而艰难的编码工作，而是将重点放在程序的高级逻辑上。

在本章中，你将安装Pygame，再创建一艘能够根据用户输入而左右移动和射击的飞船。在接下来的两章中，你将创建一群作为射杀目标的外星人，并做其他的改进，如限制可供玩家使用的飞船数以及添加记分牌。

从本章开始，你还将学习管理包含多个文件的项目。我们将重构很多代码，以提高代码的效率，并管理文件的内容，以确保项目组织有序。

创建游戏是趣学语言的理想方式。看别人玩你编写的游戏让你很有满足感，而编写简单的游戏有助于你明白专业级游戏是怎么编写出来的。在阅读本章的过程中，请动手输入并运行代码，以明白各个代码块对整个游戏所做的贡献，并尝试不同的值和设置，这样你将对如何改进游戏的交互性有更深入的认识。

注意　游戏《外星人入侵》将包含很多不同的文件，因此请在你的系统中新建一个文件夹，并将其命名为alien_invasion。请务必将这个项目的所有文件都存储到这个文件夹中，这样相关的import语句才能正确地工作。

12.1　规划项目

开发大型项目时，做好规划后再动手编写项目很重要。规划可确保你不偏离轨道，从而提高项目成功的可能性。

下面来编写有关游戏《外星人入侵》的描述，其中虽然没有涵盖这款游戏的所有细节，但能让你清楚地知道该如何动手开发它。

在游戏《外星人入侵》中，玩家控制着一艘最初出现在屏幕底部中央的飞船。玩家可以使用箭头键左右移动飞船，还可使用空格键进行射击。游戏开始时，一群外星人出现在天空中，他们在屏幕中向下移动。玩家的任务是射杀这些外星人。玩家将所有外星人都消灭干净后，将出现一群新的外星人，他们移动的速度更快。只要有外星人撞到了玩家的飞船或到达了屏幕底部，玩家就损失一艘飞船。玩家损失三艘飞船后，游戏结束。

在第一个开发阶段，我们将创建一艘可左右移动的飞船，这艘飞船在用户按空格键时能够开火。设置好这种行为后，我们就能够将注意力转向外星人，并提高这款游戏的可玩性。

12.2　安装Pygame

开始编码前，先来安装Pygame。下面介绍如何在Linux、OS X和Microsoft Windows中安装Pygame。

如果你使用的是Linux系统和Python 3，或者是OS X系统，就需要使用pip来安装Pygame。pip是一个负责为你下载并安装Python包的程序。接下来的几小节介绍如何使用pip来安装Python包。

如果你使用的是Linux系统和Python 2.7，或者是Windows，就无需使用pip来安装Pygame；在这种情况下，请直接跳到12.2.2节或12.2.4节。

注意　接下来的部分包含在各种系统上安装pip的说明，因为数据可视化项目和Web应用程序项目都需要pip。这些说明也可在http://www.ituring.com.cn/book/1861的“随书下载”中找到。如果安装时遇到麻烦，看看在线说明是否管用。

12.2.1　使用pip安装Python包

大多数较新的Python版本都自带pip，因此首先可检查系统是否已经安装了pip。在Python 3中，pip有时被称为pip3。

1. 在Linux和OS X系统中检查是否安装了pip

打开一个终端窗口，并执行如下命令：

$ pip --version
pip 7.0.3 from /usr/local/lib/python3.5/dist-packages (python 3.5) ❶
$

如果你的系统只安装了一个版本的Python，并看到了类似于上面的输出，请跳到12.2.2节或12.2.3节。如果出现了错误消息，请尝试将pip替换为pip3。如果这两个版本都没有安装到你的系统中，请跳到“安装pip”。

如果你的系统安装了多个版本的Python，请核实pip关联到了你使用的Python版本，如python 3.5（见❶）。如果pip关联到了正确的Python版本，请跳到12.2.2节或12.2.3节。如果pip没有关联到正确的Python版本，请尝试将pip替换为pip3。如果执行这两个命令时，输出都表明没有关联到正确的Python版本，请跳到“安装pip”。

2. 在Windows系统中检查是否安装了pip

打开一个终端窗口，并执行如下命令：

> python -m pip --version
pip 7.0.3 from C:\Python35\lib\site-packages (python 3.5) ❶
>

如果你的系统只安装了一个版本的Python，并看到了类似于上面的输出，请跳到12.2.4节。如果出现了错误消息，请尝试将pip替换为pip3。如果执行这两个命令时都出现错误消息，请跳到“安装pip”。

如果你的系统安装了多个版本的Python，请核实pip关联到了你使用的Python版本，如python 3.5（见❶）。如果pip关联到了正确的Python版本，请跳到12.2.4节。如果pip没有关联到正确的Python版本，请尝试将pip替换为pip3。如果执行这两个命令时都出现错误消息，请跳到“安装pip”。

3. 安装pip

要安装pip，请访问https://bootstrap.pypa.io/get-pip.py。如果出现对话框，请选择保存文件；如果get-pip.py的代码出现在浏览器中，请将这些代码复制并粘贴到文本编辑器中，再将文件保存为get-pip.py。将get-pip.py保存到计算机中后，你需要以管理员身份运行它，因为pip将在你的系统中安装新包。

注意　如果你找不到get-pip.py，请访问https://pip.pypa.io/，单击左边面板中的Installation，再单击中间窗口中的链接get-pip.py。

4. 在Linux和OS X系统中安装pip

使用下面的命令以管理员身份运行get-pip.py：

$ sudo python get-pip.py

注意　如果你启动终端会话时使用的是命令python3，那么在这里应使用命令sudo python3 get-pip.py。

这个程序运行后，使用命令pip --version（或pip3 --version）确认正确地安装了pip。

5. 在Windows系统中安装pip

使用下面的命令运行get-pip.py：

> python get-pip.py

如果你在终端中运行Python时使用的是另一个命令，也请使用这个命令来运行get-pip.py。例如，你可能需要使用命令python3 get-pip.py或C:\Python35\python get-pip.py。

这个程序运行后，执行命令python -m pip --version以确认成功地安装了pip。

12.2.2　在Linux系统中安装Pygame

如果你使用的是Python 2.7，请使用包管理器来安装Pygame。为此，打开一个终端窗口，并执行下面的命令，这将下载Pygame，并将其安装到你的系统中：

$ sudo apt-get install python-pygame

执行如下命令，在终端会话中检查安装情况：

$ python
>>> import pygame
>>>

如果没有任何输出，就说明Python导入了Pygame，你可以跳到12.3节。

如果你使用的是Python 3，就需要执行两个步骤：安装Pygame依赖的库；下载并安装Pygame。

执行下面的命令来安装Pygame依赖的库（如果你开始终端会话时使用的是命令python3.5，请将python3-dev替换为python3.5-dev）：

$ sudo apt-get install python3-dev mercurial
$ sudo apt-get install libsdl-image1.2-dev libsdl2-dev libsdl-ttf2.0-dev

这将安装运行《外星人入侵》时需要的库。如果你要启用Pygame的一些高级功能，如添加声音的功能，可安装下面这些额外的库：

$ sudo apt-get install libsdl-mixer1.2-dev libportmidi-dev
$ sudo apt-get install libswscale-dev libsmpeg-dev libavformat-dev libavcodec-dev
$ sudo apt-get install python-numpy

接下来，执行下面的命令来安装Pygame（如有必要，将pip替换为pip3）：

$ pip install --user hg+http://bitbucket.org/pygame/pygame

告知你Pygame找到了哪些库后，输出将暂停一段时间。请按回车键，即便有一些库没有找到。你将看到一条消息，说明成功地安装了Pygame。

要确认安装成功，请启动一个Python终端会话，并尝试执行下面的命令来导入Pygame：

$ python3
>>> import pygame
>>>

如果导入成功，请跳到12.3节。

12.2.3　在OS X系统中安装Pygame

要安装Pygame依赖的有些包，需要Homebrew。如果你没有安装Homebrew，请参阅附录A的说明。

为安装Pygame依赖的库，请执行下面的命令：

$ brew install hg sdl sdl_image sdl_ttf

这将安装运行游戏《外星人入侵》所需的库。每安装一个库后，输出都会向上滚动。

如果你还想启用较高级的功能，如在游戏中包含声音，可安装下面两个额外的库：

$ brew install sdl_mixer portmidi

使用下面的命令来安装Pygame（如果你运行的是Python 2.7，请将pip3替换为pip）：

$ pip3 install --user hg+http://bitbucket.org/pygame/pygame

启动一个Python终端会话，并导入Pygame以检查安装是否成功（如果你运行的是Python 2.7，请将python3替换为python）：

$ python3
>>> import pygame
>>>

如果导入成功，请跳到12.3节。

12.2.4　在Windows系统中安装Pygame

Pygame项目托管在代码分享网站Bitbucket中。要在Windows系统中安装Pygame，请访问https://bitbucket.org/pygame/pygame/downloads/，查找与你运行的Python版本匹配的Windows安装程序。如果在Bitbucket上找不到合适的安装程序，请去http://www.lfd.uci.edu/~gohlke/pythonlibs/#pygame看看。

下载合适的文件后，如果它是.exe文件，就运行它。

如果该文件的扩展名为.whl，就将它复制到你的项目文件夹中。再打开一个命令窗口，切换到该文件所在的文件夹，并使用pip来运行它：

> python -m pip install --user pygame-1.9.2a0-cp35-none-win32.whl

12.3　开始游戏项目

现在来开始开发游戏《外星人入侵》。首先创建一个空的Pygame窗口，供后面用来绘制游戏元素，如飞船和外星人。我们还将让这个游戏响应用户输入、设置背景色以及加载飞船图像。

12.3.1　创建Pygame窗口以及响应用户输入

首先，我们创建一个空的Pygame窗口。使用Pygame编写的游戏的基本结构如下：

alien_invasion.py

import sys

import pygame

def run_game():
    # 初始化游戏并创建一个屏幕对象
    pygame.init() ❶
    screen = pygame.display.set_mode((1200, 800)) ❷
    pygame.display.set_caption("Alien Invasion")

    # 开始游戏的主循环
    while True: ❸

        # 监视键盘和鼠标事件
        for event in pygame.event.get(): ❹
            if event.type == pygame.QUIT: ❺
                sys.exit()

         # 让最近绘制的屏幕可见
        pygame.display.flip() ❻

run_game()

首先，我们导入了模块sys和pygame。模块pygame包含开发游戏所需的功能。玩家退出时，我们将使用模块sys来退出游戏。

游戏《外星人入侵》的开头是函数run_game()。❶处的代码行pygame.init()初始化背景设置，让Pygame能够正确地工作。在❷处，我们调用pygame.display.set_mode()来创建一个名为screen的显示窗口，这个游戏的所有图形元素都将在其中绘制。实参(1200, 800)是一个元组，指定了游戏窗口的尺寸。通过将这些尺寸值传递给pygame.display.set_mode()，我们创建了一个宽1200像素、高800像素的游戏窗口（你可以根据自己的显示器尺寸调整这些值）。

对象screen是一个surface。在Pygame中，surface是屏幕的一部分，用于显示游戏元素。在这个游戏中，每个元素（如外星人或飞船）都是一个surface。display.set_mode()返回的surface表示整个游戏窗口。我们激活游戏的动画循环后，每经过一次循环都将自动重绘这个surface。

这个游戏由一个while循环（见❸）控制，其中包含一个事件循环以及管理屏幕更新的代码。事件是用户玩游戏时执行的操作，如按键或移动鼠标。为让程序响应事件，我们编写一个事件循环，以侦听事件，并根据发生的事件执行相应的任务。❹处的for循环就是一个事件循环。

为访问Pygame检测到的事件，我们使用方法pygame.event.get()。所有键盘和鼠标事件都将促使for循环运行。在这个循环中，我们将编写一系列的if语句来检测并响应特定的事件。例如，玩家单击游戏窗口的关闭按钮时，将检测到pygame.QUIT事件，而我们调用sys.exit()来退出游戏（见❺）。

❻处调用了pygame.display.flip()，命令Pygame让最近绘制的屏幕可见。在这里，它在每次执行while循环时都绘制一个空屏幕，并擦去旧屏幕，使得只有新屏幕可见。在我们移动游戏元素时，pygame.display.flip()将不断更新屏幕，以显示元素的新位置，并在原来的位置隐藏元素，从而营造平滑移动的效果。

在这个基本的游戏结构中，最后一行调用run_game()，这将初始化游戏并开始主循环。

如果此时运行这些代码，你将看到一个空的Pygame窗口。

12.3.2　设置背景色

Pygame默认创建一个黑色屏幕，这太乏味了。下面来将背景设置为另一种颜色：

alien_invasion.py

--snip--
def run_game():
    --snip--
    pygame.display.set_caption("Alien Invasion")

    # 设置背景色
    bg_color = (230, 230, 230) ❶

    # 开始游戏主循环.
    while True:

        # 监听键盘和鼠标事件
        --snip--

        # 每次循环时都重绘屏幕
        screen.fill(bg_color) ❷

        # 让最近绘制的屏幕可见
        pygame.display.flip()

run_game()

首先，我们创建了一种背景色，并将其存储在bg_color中（见❶）。该颜色只需指定一次，因此我们在进入主while循环前定义它。

在Pygame中，颜色是以RGB值指定的。这种颜色由红色、绿色和蓝色值组成，其中每个值的可能取值范围都为0~255。颜色值(255, 0, 0)表示红色，(0, 255, 0)表示绿色，而(0, 0, 255)表示蓝色。通过组合不同的RGB值，可创建1600万种颜色。在颜色值(230, 230, 230)中，红色、蓝色和绿色量相同，它将背景设置为一种浅灰色。

在❷处，我们调用方法screen.fill()，用背景色填充屏幕；这个方法只接受一个实参：一种颜色。

12.3.3　创建设置类

每次给游戏添加新功能时，通常也将引入一些新设置。下面来编写一个名为settings的模块，其中包含一个名为Settings的类，用于将所有设置存储在一个地方，以免在代码中到处添加设置。这样，我们就能传递一个设置对象，而不是众多不同的设置。另外，这让函数调用更简单，且在项目增大时修改游戏的外观更容易：要修改游戏，只需修改settings.py中的一些值，而无需查找散布在文件中的不同设置。

下面是最初的Settings类：

settings.py

class Settings():
    """存储《外星人入侵》的所有设置的类"""

    def __init__(self):
        """初始化游戏的设置"""
        # 屏幕设置
        self.screen_width = 1200
        self.screen_height = 800
        self.bg_color = (230, 230, 230)

为创建Settings实例并使用它来访问设置，将alien_invasion.py修改成下面这样：

alien_invasion.py

--snip--
import pygame

from settings import Settings

def run_game():
    # 初始化pygame、设置和屏幕对象
    pygame.init()
    ai_settings = Settings() ❶
    screen = pygame.display.set_mode( ❷
        (ai_settings.screen_width, ai_settings.screen_height))
    pygame.display.set_caption("Alien Invasion")

    # 开始游戏主循环
    while True:
        --snip--
        # 每次循环时都重绘屏幕
        screen.fill(ai_settings.bg_color) ❸

        # 让最近绘制的屏幕可见
        pygame.display.flip()

run_game()

在主程序文件中，我们导入Settings类，调用pygame.init()，再创建一个Settings实例，并将其存储在变量ai_settings中（见❶）。创建屏幕时（见❷），使用了ai_settings的属性screen_width和screen_height；接下来填充屏幕时，也使用了ai_settings来访问背景色（见❸）。

12.4　添加飞船图像

下面将飞船加入到游戏中。为了在屏幕上绘制玩家的飞船，我们将加载一幅图像，再使用Pygame方法blit()绘制它。

为游戏选择素材时，务必要注意许可。最安全、最不费钱的方式是使用http://pixabay.com/等网站提供的图形，这些图形无需许可，你可以对其进行修改。

在游戏中几乎可以使用任何类型的图像文件，但使用位图（.bmp）文件最为简单，因为Pygame默认加载位图。虽然可配置Pygame以使用其他文件类型，但有些文件类型要求你在计算机上安装相应的图像库。大多数图像都为.jpg、.png或.gif格式，但可使用Photoshop、GIMP和Paint等工具将其转换为位图。

选择图像时，要特别注意其背景色。请尽可能选择背景透明的图像，这样可使用图像编辑器将其背景设置为任何颜色。图像的背景色与游戏的背景色相同时，游戏看起来最漂亮；你也可以将游戏的背景色设置成与图像的背景色相同。

就游戏《外星人入侵》而言，你可以使用文件ship.bmp（如图12-1所示），这个文件可在本书的配套资源（http://www.ituring.com.cn/book/1861）中找到。这个文件的背景色与这个项目使用的设置相同。请在主项目文件夹（alien_invasion）中新建一个文件夹，将其命名为images，并将文件ship.bmp保存到这个文件夹中。

图12-1　游戏《外星人入侵》中的飞船

12.4.1　创建Ship类

选择用于表示飞船的图像后，需要将其显示到屏幕上。我们将创建一个名为ship的模块，其中包含Ship类，它负责管理飞船的大部分行为。

ship.py

import pygame

class Ship():

    def __init__(self, screen):
        """初始化飞船并设置其初始位置"""
        self.screen = screen

        # 加载飞船图像并获取其外接矩形
        self.image = pygame.image.load('images/ship.bmp') ❶
        self.rect = self.image.get_rect() ❷
        self.screen_rect = screen.get_rect() ❸

        # 将每艘新飞船放在屏幕底部中央
        self.rect.centerx = self.screen_rect.centerx ❹
        self.rect.bottom = self.screen_rect.bottom

    def blitme(self): ❺
        """在指定位置绘制飞船"""
        self.screen.blit(self.image, self.rect)

首先，我们导入了模块pygame。Ship的方法__init__()接受两个参数：引用self和screen，其中后者指定了要将飞船绘制到什么地方。为加载图像，我们调用了pygame.image.load()（见❶）。这个函数返回一个表示飞船的surface，而我们将这个surface存储到了self.image中。

加载图像后，我们使用get_rect()获取相应surface的属性rect（见❷）。Pygame的效率之所以如此高，一个原因是它让你能够像处理矩形（rect对象）一样处理游戏元素，即便它们的形状并非矩形。像处理矩形一样处理游戏元素之所以高效，是因为矩形是简单的几何形状。这种做法的效果通常很好，游戏玩家几乎注意不到我们处理的不是游戏元素的实际形状。

处理rect对象时，可使用矩形四角和中心的 x 和 y 坐标。可通过设置这些值来指定矩形的位置。

要将游戏元素居中，可设置相应rect对象的属性center、centerx或centery。要让游戏元素与屏幕边缘对齐，可使用属性top、bottom、left或right；要调整游戏元素的水平或垂直位置，可使用属性x和y，它们分别是相应矩形左上角的 x 和 y 坐标。这些属性让你无需去做游戏开发人员原本需要手工完成的计算，你经常会用到这些属性。

注意　在Pygame中，原点(0, 0)位于屏幕左上角，向右下方移动时，坐标值将增大。在1200×800的屏幕上，原点位于左上角，而右下角的坐标为(1200, 800)。

我们将把飞船放在屏幕底部中央。为此，首先将表示屏幕的矩形存储在self.screen_rect中（见❸），再将self.rect.centerx（飞船中心的x坐标）设置为表示屏幕的矩形的属性centerx（见❹），并将self.rect.bottom（飞船下边缘的y坐标）设置为表示屏幕的矩形的属性bottom。Pygame将使用这些rect属性来放置飞船图像，使其与屏幕下边缘对齐并水平居中。

在❺处，我们定义了方法blitme()，它根据self.rect指定的位置将图像绘制到屏幕上。

12.4.2　在屏幕上绘制飞船

下面来更新alien_invasion.py，使其创建一艘飞船，并调用其方法blitme()：

alien_invasion.py

--snip--
from settings import Settings
from ship import Ship

def run_game():
    --snip--
    pygame.display.set_caption("Alien Invasion")

    # 创建一艘飞船
    ship = Ship(screen) ❶

    # 开始游戏主循环
    while True:
        --snip--
        # 每次循环时都重绘屏幕
        screen.fill(ai_settings.bg_color)
        ship.blitme() ❷

        # 让最近绘制的屏幕可见
        pygame.display.flip()

run_game()

我们导入Ship类，并在创建屏幕后创建一个名为ship的Ship实例。必须在主while循环前面创建该实例（见❶），以免每次循环时都创建一艘飞船。填充背景后，我们调用ship.blitme()将飞船绘制到屏幕上，确保它出现在背景前面（见❷）。

现在如果运行alien_invasion.py，将看到飞船位于空游戏屏幕底部中央，如图12-2所示。

图12-2　游戏《外星人入侵》屏幕底部中央有一艘飞船

12.5　重构：模块game_functions

在大型项目中，经常需要在添加新代码前重构既有代码。重构旨在简化既有代码的结构，使其更容易扩展。在本节中，我们将创建一个名为game_functions的新模块，它将存储大量让游戏《外星人入侵》运行的函数。通过创建模块game_functions，可避免alien_invasion.py太长，并使其逻辑更容易理解。

12.5.1　函数check_events()

我们将首先把管理事件的代码移到一个名为check_events()的函数中，以简化run_game()并隔离事件管理循环。通过隔离事件循环，可将事件管理与游戏的其他方面（如更新屏幕）分离。

将check_events()放在一个名为game_functions的模块中：

game_functions.py

import sys

import pygame

def check_events():
    """响应按键和鼠标事件"""
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            sys.exit()

这个模块中导入了事件检查循环要使用的sys和pygame。当前，函数check_events()不需要任何形参，其函数体复制了alien_invasion.py的事件循环。

下面来修改alien_invasion.py，使其导入模块game_functions，并将事件循环替换为对函数check_events()的调用：

alien_invasion.py

import pygame

from settings import Settings
from ship import Ship
import game_functions as gf

def run_game():
    --snip--
    # 开始游戏主循环
    while True:
        gf.check_events()

        # 每次循环时都重绘屏幕
        --snip--

在主程序文件中，不再需要直接导入sys，因为当前只在模块game_functions中使用了它。出于简化的目的，我们给导入的模块game_functions指定了别名gf。

12.5.2　函数update_screen()

为进一步简化run_game()，下面将更新屏幕的代码移到一个名为update_screen()的函数中，并将这个函数放在模块game_functions.py中：

game_functions.py

--snip--

def check_events():
    --snip--

def update_screen(ai_settings, screen, ship):
    """更新屏幕上的图像，并切换到新屏幕"""
    # 每次循环时都重绘屏幕
    screen.fill(ai_settings.bg_color)
    ship.blitme()

    # 让最近绘制的屏幕可见
    pygame.display.flip()

新函数update_screen()包含三个形参：ai_settings、screen和ship。现在需要将alien_invasion.py的while循环中更新屏幕的代码替换为对函数update_screen()的调用：

alien_invasion.py

--snip--
    # 开始游戏主循环
    while True:
        gf.check_events()
        gf.update_screen(ai_settings, screen, ship)

run_game()

这两个函数让while循环更简单，并让后续开发更容易：在模块game_functions而不是run_game()中完成大部分工作。

鉴于我们一开始只想使用一个文件，因此没有立刻引入模块game_functions。这让你能够了解实际的开发过程：一开始将代码编写得尽可能简单，并在项目越来越复杂时进行重构。

对代码进行重构使其更容易扩展后，可以开始处理游戏的动态方面了！

动手试一试

12-1 蓝色天空：创建一个背景为蓝色的Pygame窗口。

12-2 游戏角色：找一幅你喜欢的游戏角色位图图像或将一幅图像转换为位图。创建一个类，将该角色绘制到屏幕中央，并将该图像的背景色设置为屏幕背景色，或将屏幕背景色设置为该图像的背景色。

12.6　驾驶飞船

下面来让玩家能够左右移动飞船。为此，我们将编写代码，在用户按左或右箭头键时作出响应。我们将首先专注于向右移动，再使用同样的原理来控制向左移动。通过这样做，你将学会如何控制屏幕图像的移动。

12.6.1　响应按键

每当用户按键时，都将在Pygame中注册一个事件。事件都是通过方法pygame.event.get()获取的，因此在函数check_events()中，我们需要指定要检查哪些类型的事件。每次按键都被注册为一个KEYDOWN事件。

检测到KEYDOWN事件时，我们需要检查按下的是否是特定的键。例如，如果按下的是右箭头键，我们就增大飞船的rect.centerx值，将飞船向右移动：

game_functions.py

def check_events(ship):
    """响应按键和鼠标事件"""
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            sys.exit()

        elif event.type == pygame.KEYDOWN: ❶
            if event.key == pygame.K_RIGHT: ❷
                #向右移动飞船
                ship.rect.centerx += 1 ❸

我们在函数check_events()中包含形参ship，因为玩家按右箭头键时，需要将飞船向右移动。在函数check_events()内部，我们在事件循环中添加了一个elif代码块，以便在Pygame 检测到KEYDOWN事件时作出响应（见❶）。我们读取属性event.key，以检查按下的是否是右箭头键（pygame.K_RIGHT）（见❷）。如果按下的是右箭头键，就将ship.rect.centerx的值加1，从而将飞船向右移动（见❸）。

在alien_invasion.py中，我们需要更新调用的check_events()代码，将ship作为实参传递给它：

alien_invasion.py

    # 开始游戏主循环
    while True:
        gf.check_events(ship)
        gf.update_screen(ai_settings, screen, ship)

如果现在运行alien_invasion.py，则每按右箭头键一次，飞船都将向右移动1像素。这是一个开端，但并非控制飞船的高效方式。下面来改进控制方式，允许持续移动。

12.6.2　允许不断移动

玩家按住右箭头键不放时，我们希望飞船不断地向右移动，直到玩家松开为止。我们将让游戏检测pygame.KEYUP事件，以便玩家松开右箭头键时我们能够知道这一点；然后，我们将结合使用KEYDOWN和KEYUP事件，以及一个名为moving_right的标志来实现持续移动。

飞船不动时，标志moving_right将为False。玩家按下右箭头键时，我们将这个标志设置为True；而玩家松开时，我们将这个标志重新设置为False。

飞船的属性都由Ship类控制，因此我们将给这个类添加一个名为moving_right的属性和一个名为update()的方法。方法update()检查标志moving_right的状态，如果这个标志为True，就调整飞船的位置。每当需要调整飞船的位置时，我们都调用这个方法。

下面是对Ship类所做的修改：

ship.py

class Ship():

    def __init__(self, screen):
        --snip--
        # 将每艘新飞船放在屏幕底部中央
        self.rect.centerx = self.screen_rect.centerx
        self.rect.bottom = self.screen_rect.bottom

        # 移动标志
        self.moving_right = False ❶

    def update(self): ❷
        """根据移动标志调整飞船的位置"""
        if self.moving_right:
            self.rect.centerx += 1

    def blitme(self):
        --snip--

在方法__init__()中，我们添加了属性self.moving_right，并将其初始值设置为False（见❶）。接下来，我们添加了方法update()，它在前述标志为True时向右移动飞船（见❷）。

下面来修改check_events()，使其在玩家按下右箭头键时将moving_right设置为True，并在玩家松开时将moving_right设置为False：

game_functions.py

def check_events(ship):
    """响应按键和鼠标事件"""
    for event in pygame.event.get():
        --snip--
        elif event.type == pygame.KEYDOWN:
            if event.key == pygame.K_RIGHT:
                ship.moving_right = True ❶

        elif event.type == pygame.KEYUP: ❷
            if event.key == pygame.K_RIGHT:
                ship.moving_right = False

在❶处，我们修改了游戏在玩家按下右箭头键时响应的方式：不直接调整飞船的位置，而只是将moving_right设置为True。在❷处，我们添加了一个新的elif代码块，用于响应KEYUP事件：玩家松开右箭头键（K_RIGHT）时，我们将moving_right设置为False。

最后，我们需要修改alien_invasion.py中的while循环，以便每次执行循环时都调用飞船的方法update()：

alien_invasion.py

    # 开始游戏主循环
    while True:
        gf.check_events(ship)
        ship.update()
        gf.update_screen(ai_settings, screen, ship)

飞船的位置将在检测到键盘事件后（但在更新屏幕前）更新。这样，玩家输入时，飞船的位置将更新，从而确保使用更新后的位置将飞船绘制到屏幕上。

如果你现在运行alien_invasion.py并按住右箭头键，飞船将不断地向右移动，直到你松开为止。

12.6.3　左右移动

飞船能够不断地向右移动后，添加向左移动的逻辑很容易。我们将再次修改Ship类和函数check_events()。下面显示了对Ship类的方法__init__()和update()所做的相关修改：

ship.py

    def __init__(self, screen):
        --snip--
        # 移动标志
        self.moving_right = False
        self.moving_left = False

    def update(self):
        """根据移动标志调整飞船的位置"""
        if self.moving_right:
            self.rect.centerx += 1
        if self.moving_left:
            self.rect.centerx -= 1

在方法__init__()中，我们添加了标志self.moving_left；在方法update()中，我们添加了一个if代码块而不是elif代码块，这样如果玩家同时按下了左右箭头键，将先增大飞船的rect.centerx值，再降低这个值，即飞船的位置保持不变。如果使用一个elif代码块来处理向左移动的情况，右箭头键将始终处于优先地位。从向左移动切换到向右移动时，玩家可能同时按住左右箭头键，在这种情况下，前面的做法让移动更准确。

我们还需对check_events()作两方面的调整：

game_functions.py

def check_events(ship):
    """响应按键和鼠标事件"""
    for event in pygame.event.get():
        --snip--
        elif event.type == pygame.KEYDOWN:
            if event.key == pygame.K_RIGHT:
                ship.moving_right = True
            elif event.key == pygame.K_LEFT:
                ship.moving_left = True

        elif event.type == pygame.KEYUP:
            if event.key == pygame.K_RIGHT:
                ship.moving_right = False
            elif event.key == pygame.K_LEFT:
                ship.moving_left = False

如果因玩家按下K_LEFT键而触发了KEYDOWN事件，我们就将moving_left设置为True；如果因玩家松开K_LEFT而触发了KEYUP事件，我们就将moving_left设置为False。这里之所以可以使用elif代码块，是因为每个事件都只与一个键相关联；如果玩家同时按下了左右箭头键，将检测到两个不同的事件。

如果此时运行alien_invasion.py，将能够不断地左右移动飞船；如果你同时按左右箭头键，飞船将纹丝不动。

下面来进一步优化飞船的移动方式：调整飞船的速度；限制飞船的移动距离，以免它移到屏幕外面去。

12.6.4　调整飞船的速度

当前，每次执行while循环时，飞船最多移动1像素，但我们可以在Settings类中添加属性ship_speed_factor，用于控制飞船的速度。我们将根据这个属性决定飞船在每次循环时最多移动多少距离。下面演示了如何在settings.py中添加这个新属性：

settings.py

class Settings():
    """一个存储游戏《外星人入侵》的所有设置的类"""

    def __init__(self):
        --snip--

        # 飞船的设置
        self.ship_speed_factor = 1.5

我们将ship_speed_factor的初始值设置成了1.5。需要移动飞船时，我们将移动1.5像素而不是1像素。

通过将速度设置指定为小数值，可在后面加快游戏的节奏时更细致地控制飞船的速度。然而，rect的centerx等属性只能存储整数值，因此我们需要对Ship类做些修改：

ship.py

class Ship():

    def __init__(self, ai_settings, screen): ❶
        """初始化飞船并设置其初始位置"""
        self.screen = screen
        self.ai_settings = ai_settings ❷
        --snip--

        # 将每艘新飞船放在屏幕底部中央
        --snip--

        # 在飞船的属性center中存储小数值
        self.center = float(self.rect.centerx) ❸

        # 移动标志
        self.moving_right = False
        self.moving_left = False

    def update(self):
        """根据移动标志调整飞船的位置"""
        # 更新飞船的center值，而不是rect
        if self.moving_right:
            self.center += self.ai_settings.ship_speed_factor ❹
        if self.moving_left:
            self.center -= self.ai_settings.ship_speed_factor

        # 根据self.center更新rect对象
        self.rect.centerx = self.center ❺

    def blitme(self):
        --snip--

在❶处，我们在__init__()的形参列表中添加了ai_settings，让飞船能够获取其速度设置。接下来，我们将形参ai_settings的值存储在一个属性中，以便能够在update()中使用它（见❷）。鉴于现在调整飞船的位置时，将增加或减去一个单位为像素的小数值，因此需要将位置存储在一个能够存储小数值的变量中。可以使用小数来设置rect的属性，但rect将只存储这个值的整数部分。为准确地存储飞船的位置，我们定义了一个可存储小数值的新属性self.center（见❸）。我们使用函数float()将self.rect.centerx的值转换为小数，并将结果存储到self.center中。

现在在update()中调整飞船的位置时，将self.center的值增加或减去ai_settings.ship_speed_factor的值（见❹）。更新self.center后，我们再根据它来更新控制飞船位置的self.rect.centerx（见❺）。self.rect.centerx将只存储self.center的整数部分，但对显示飞船而言，这问题不大。

在alien_invasion.py中创建Ship实例时，需要传入实参ai_settings：

alien_invasion.py

--snip--
def run_game():
    --snip--
    # 创建飞船
    ship = Ship(ai_settings, screen)
    --snip--

现在，只要ship_speed_factor的值大于1，飞船的移动速度就会比以前更快。这有助于让飞船的反应速度足够快，能够将外星人射下来，还让我们能够随着游戏的进行加快游戏的节奏。

12.6.5　限制飞船的活动范围

当前，如果玩家按住箭头键的时间足够长，飞船将移到屏幕外面，消失得无影无踪。下面来修复这种问题，让飞船到达屏幕边缘后停止移动。为此，我们将修改Ship类的方法update()：

ship.py

    def update(self):
        """根据移动标志调整飞船的位置"""
        # 更新飞船的center值，而不是rect
        if self.moving_right and self.rect.right < self.screen_rect.right: ❶
            self.center += self.ai_settings.ship_speed_factor
        if self.moving_left and self.rect.left > 0: ❷
            self.center -= self.ai_settings.ship_speed_factor

        # 根据self.center更新rect对象
        self.rect.centerx = self.center

上述代码在修改self.center的值之前检查飞船的位置。self.rect.right返回飞船外接矩形的右边缘的 x 坐标，如果这个值小于self.screen_rect.right的值，就说明飞船未触及屏幕右边缘（见❶）。左边缘的情况与此类似：如果rect的左边缘的 x 坐标大于零，就说明飞船未触及屏幕左边缘（见❷）。这确保仅当飞船在屏幕内时，才调整self.center的值。

如果此时运行alien_invasion.py，飞船将在触及屏幕左边缘或右边缘后停止移动。

12.6.6　重构check_events()

随着游戏开发的进行，函数check_events()将越来越长，我们将其部分代码放在两个函数中：一个处理KEYDOWN事件，另一个处理KEYUP事件：

game_functions.py

def check_keydown_events(event, ship):
    """响应按键"""
    if event.key == pygame.K_RIGHT:
        ship.moving_right = True
    elif event.key == pygame.K_LEFT:
        ship.moving_left = True

def check_keyup_events(event, ship):
    """响应松开"""
    if event.key == pygame.K_RIGHT:
        ship.moving_right = False
    elif event.key == pygame.K_LEFT:
        ship.moving_left = False

def check_events(ship):
    """响应按键和鼠标事件"""
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            sys.exit()
        elif event.type == pygame.KEYDOWN:
            check_keydown_events(event, ship)
        elif event.type == pygame.KEYUP:
            check_keyup_events(event, ship)

我们创建了两个新函数：check_keydown_events()和check_keyup_events()，它们都包含形参event和ship。这两个函数的代码是从check_events()中复制而来的，因此我们将函数check_events中相应的代码替换成了对这两个函数的调用。现在，函数check_events()更简单，代码结构更清晰。这样，在其中响应其他玩家输入时将更容易。

12.7　简单回顾

下一节将添加射击功能，这需要新增一个名为bullet.py的文件，并对一些既有文件进行修改。当前，我们有四个文件，其中包含很多类、函数和方法。添加其他功能之前，为让你清楚这个项目的组织结构，先来回顾一下这些文件。

12.7.1　alien_invasion.py

主文件alien_invasion.py创建一系列整个游戏都要用到的对象：存储在ai_settings中的设置、存储在screen中的主显示surface以及一个飞船实例。文件alien_invasion.py还包含游戏的主循环，这是一个调用check_events()、ship.update()和update_screen()的while循环。

要玩游戏《外星人入侵》，只需运行文件alien_invasion.py。其他文件（settings.py、game_functions.py、ship.py）包含的代码被直接或间接地导入到这个文件中。

12.7.2　settings.py

文件settings.py包含Settings类，这个类只包含方法__init__()，它初始化控制游戏外观和飞船速度的属性。

12.7.3　game_functions.py

文件game_functions.py包含一系列函数，游戏的大部分工作都是由它们完成的。函数check_events()检测相关的事件，如按键和松开，并使用辅助函数check_keydown_events()和check_keyup_events()来处理这些事件。就目前而言，这些函数管理飞船的移动。模块game_functions还包含函数update_screen()，它用于在每次执行主循环时都重绘屏幕。

12.7.4　ship.py

文件ship.py包含Ship类，这个类包含方法__init__()、管理飞船位置的方法update()以及在屏幕上绘制飞船的方法blitme()。表示飞船的图像存储在文件夹images下的文件ship.bmp中。

动手试一试

12-3 火箭：编写一个游戏，开始时屏幕中央有一个火箭，而玩家可使用四个方向键上下左右移动火箭。请务必确保火箭不会移到屏幕外面。

12-4 按键：创建一个程序，显示一个空屏幕。在事件循环中，每当检测到pygame.KEYDOWN事件时都打印属性event.key。运行这个程序，并按各种键，看看Pygame如何响应。

12.8　射击

下面来添加射击功能。我们将编写玩家按空格键时发射子弹（小矩形）的代码。子弹将在屏幕中向上穿行，抵达屏幕上边缘后消失。

12.8.1　添加子弹设置

首先，更新settings.py，在其方法__init__()末尾存储新类Bullet所需的值：

settings.py

    def __init__(self):
        --snip--
        # 子弹设置
        self.bullet_speed_factor = 1
        self.bullet_width = 3
        self.bullet_height = 15
        self.bullet_color = 60, 60, 60

这些设置创建宽3像素、高15像素的深灰色子弹。子弹的速度比飞船稍低。

12.8.2　创建Bullet类

下面来创建存储Bullet类的文件bullet.py，其前半部分如下：

bullet.py

import pygame
from pygame.sprite import Sprite

class Bullet(Sprite):
    """一个对飞船发射的子弹进行管理的类"""

    def __init__(self, ai_settings, screen, ship):
        """在飞船所处的位置创建一个子弹对象"""
        super(Bullet, self).__init__()
        self.screen = screen

        # 在(0,0)处创建一个表示子弹的矩形，再设置正确的位置
        self.rect = pygame.Rect(0, 0, ai_settings.bullet_width, ❶
            ai_settings.bullet_height)
        self.rect.centerx = ship.rect.centerx ❷
        self.rect.top = ship.rect.top ❸

        #存储用小数表示的子弹位置
        self.y = float(self.rect.y) ❹

        self.color = ai_settings.bullet_color ❺
        self.speed_factor = ai_settings.bullet_speed_factor

Bullet类继承了我们从模块pygame.sprite中导入的Sprite类。通过使用精灵，可将游戏中相关的元素编组，进而同时操作编组中的所有元素。为创建子弹实例，需要向__init__()传递ai_settings、screen和ship实例，还调用了super()来继承Sprite。

注意　代码super(Bullet, self).__init__()使用了Python 2.7语法。这种语法也适用于Python 3，但你也可以将这行代码简写为super().__init__()。

在❶处，我们创建了子弹的属性rect。子弹并非基于图像的，因此我们必须使用pygame.Rect()类从空白开始创建一个矩形。创建这个类的实例时，必须提供矩形左上角的 x 坐标和 y 坐标，还有矩形的宽度和高度。我们在(0, 0)处创建这个矩形，但接下来的两行代码将其移到了正确的位置，因为子弹的初始位置取决于飞船当前的位置。子弹的宽度和高度是从ai_settings中获取的。

在❷处，我们将子弹的centerx设置为飞船的rect.centerx。子弹应从飞船顶部射出，因此我们将表示子弹的rect的top属性设置为飞船的rect的top属性，让子弹看起来像是从飞船中射出的（见❸）。

我们将子弹的 y 坐标存储为小数值，以便能够微调子弹的速度（见❹）。在❺处，我们将子弹的颜色和速度设置分别存储到self.color和self.speed_factor中。

下面是bullet.py的第二部分——方法update()和draw_bullet()：

bullet.py

    def update(self):
        """向上移动子弹"""
        #更新表示子弹位置的小数值
        self.y -= self.speed_factor ❶
        #更新表示子弹的rect的位置
        self.rect.y = self.y ❷

    def draw_bullet(self):
        """在屏幕上绘制子弹"""
        pygame.draw.rect(self.screen, self.color, self.rect) ❸

方法update()管理子弹的位置。发射出去后，子弹在屏幕中向上移动，这意味着y坐标将不断减小，因此为更新子弹的位置，我们从self.y中减去self.speed_factor的值（见❶）。接下来，我们将self.rect.y设置为self.y的值（见❷）。属性speed_factor让我们能够随着游戏的进行或根据需要提高子弹的速度，以调整游戏的行为。子弹发射后，其 x 坐标始终不变，因此子弹将沿直线垂直地往上穿行。

需要绘制子弹时，我们调用draw_bullet()。函数draw.rect()使用存储在self.color中的颜色填充表示子弹的rect占据的屏幕部分（见❸）。

12.8.3　将子弹存储到编组中

定义Bullet类和必要的设置后，就可以编写代码了，在玩家每次按空格键时都射出一发子弹。首先，我们将在alien_invasion.py中创建一个编组（group），用于存储所有有效的子弹，以便能够管理发射出去的所有子弹。这个编组将是pygame.sprite.Group类的一个实例；pygame.sprite.Group类类似于列表，但提供了有助于开发游戏的额外功能。在主循环中，我们将使用这个编组在屏幕上绘制子弹，以及更新每颗子弹的位置：

alien_invasion.py

import pygame
from pygame.sprite import Group
--snip--

def run_game():
    --snip--
    # 创建一艘飞船
    ship = Ship(ai_settings, screen)
    # 创建一个用于存储子弹的编组
    bullets = Group() ❶

    # 开始游戏主循环
    while True:
        gf.check_events(ai_settings, screen, ship, bullets)
        ship.update()
        bullets.update() ❷
        gf.update_screen(ai_settings, screen, ship, bullets)

run_game()

我们导入了pygame.sprite中的Group类。在❶处，我们创建了一个Group实例，并将其命名为bullets。这个编组是在while循环外面创建的，这样就无需每次运行该循环时都创建一个新的子弹编组。

注意　如果在循环内部创建这样的编组，游戏运行时将创建数千个子弹编组，导致游戏慢得像蜗牛。如果游戏停滞不前，请仔细查看主while循环中发生的情况。

我们将bullets传递给了check_events()和update_screen()。在check_events()中，需要在玩家按空格键时处理bullets；而在update_screen()中，需要更新要绘制到屏幕上的bullets。

当你对编组调用update()时，编组将自动对其中的每个精灵调用update()，因此代码行bullets.update()将为编组bullets中的每颗子弹调用bullet.update()。

12.8.4　开火

在game_functions.py中，我们需要修改check_keydown_events()，以便在玩家按空格键时发射一颗子弹。我们无需修改check_keyup_events()，因为玩家松开空格键时什么都不会发生。我们还需修改update_screen()，确保在调用flip()前在屏幕上重绘每颗子弹。下面是对game_functions.py所做的相关修改：

game_functions.py

--snip--
from bullet import Bullet

def check_keydown_events(event, ai_settings, screen, ship, bullets):❶
    --snip--
    elif event.key == pygame.K_SPACE: ❷
        # 创建一颗子弹，并将其加入到编组bullets中
        new_bullet = Bullet(ai_settings, screen, ship)
        bullets.add(new_bullet)
       --snip--

def check_events(ai_settings, screen, ship, bullets):❸
    """响应按键和鼠标事件"""
    for event in pygame.event.get():
        --snip--
        elif event.type == pygame.KEYDOWN:
            check_keydown_events(event, ai_settings, screen, ship, bullets)
        --snip--

def update_screen(ai_settings, screen, ship, bullets):❹
    --snip--
    # 在飞船和外星人后面重绘所有子弹
    for bullet in bullets.sprites():❺
        bullet.draw_bullet()
    ship.blitme()
    --snip--

编组bulltes传递给了check_keydown_events()（见❶）。玩家按空格键时，创建一颗新子弹（一个名为new_bullet的Bullet实例），并使用方法add()将其加入到编组bullets中（见❷）；代码bullets.add(new_bullet)将新子弹存储到编组bullets中。

在check_events()的定义中，我们需要添加形参bullets（见❸）；调用check_keydown_events()时，我们也需要将bullets作为实参传递给它。

在❹处，我们给在屏幕上绘制子弹的update_screen()添加了形参bullets。方法bullets.sprites()返回一个列表，其中包含编组bullets中的所有精灵。为在屏幕上绘制发射的所有子弹，我们遍历编组bullets中的精灵，并对每个精灵都调用draw_bullet()（见❺）。

如果此时运行alien_invasion.py，将能够左右移动飞船，并发射任意数量的子弹。子弹在屏幕上向上穿行，抵达屏幕顶部后消失，如图12-3所示。可在settings.py中修改子弹的尺寸、颜色和速度。

图12-3　飞船发射一系列子弹后的《外星人入侵》游戏

12.8.5　删除已消失的子弹

当前，子弹抵达屏幕顶端后消失，这仅仅是因为Pygame无法在屏幕外面绘制它们。这些子弹实际上依然存在，它们的 y 坐标为负数，且越来越小。这是个问题，因为它们将继续消耗内存和处理能力。

我们需要将这些已消失的子弹删除，否则游戏所做的无谓工作将越来越多，进而变得越来越慢。为此，我们需要检测这样的条件，即表示子弹的rect的bottom属性为零，它表明子弹已穿过屏幕顶端：

alien_invasion.py

    # 开始游戏主循环
    while True:
        gf.check_events(ai_settings, screen, ship, bullets)
        ship.update()
        bullets.update()

        # 删除已消失的子弹
        for bullet in bullets.copy(): ❶
            if bullet.rect.bottom <= 0: ❷
                 bullets.remove(bullet) ❸
        print(len(bullets)) ❹

        gf.update_screen(ai_settings, screen, ship, bullets)

在for循环中，不应从列表或编组中删除条目，因此必须遍历编组的副本。我们使用了方法copy()来设置for循环（见❶），这让我们能够在循环中修改bullets。我们检查每颗子弹，看看它是否已从屏幕顶端消失（见❷）。如果是这样，就将其从bullets中删除（见❸）。在❹处，我们使用了一条print语句，以显示当前还有多少颗子弹，从而核实已消失的子弹确实删除了。

如果这些代码没有问题，我们发射子弹后查看终端窗口时，将发现随着子弹一颗颗地在屏幕顶端消失，子弹数将逐渐降为零。运行这个游戏并确认子弹已被删除后，将这条print语句删除。如果你留下这条语句，游戏的速度将大大降低，因为将输出写入到终端而花费的时间比将图形绘制到游戏窗口花费的时间还多。

12.8.6　限制子弹数量

很多射击游戏都对可同时出现在屏幕上的子弹数量进行限制，以鼓励玩家有目标地射击。下面在游戏《外星人入侵》中作这样的限制。

首先，在settings.py中存储所允许的最大子弹数：

settings.py

        # 子弹设置
        self.bullet_width = 3
        self.bullet_height = 15
        self.bullet_color = 60, 60, 60
        self.bullets_allowed = 3

这将未消失的子弹数限制为3颗。在game_functions.py的check_keydown_events()中，我们在创建新子弹前检查未消失的子弹数是否小于该设置：

game_functions.py

def check_keydown_events(event, ai_settings, screen, ship, bullets):
    --snip--
    elif event.key == pygame.K_SPACE:
        # 创建新子弹并将其加入到编组bullets中
        if len(bullets) < ai_settings.bullets_allowed:
            new_bullet = Bullet(ai_settings, screen, ship)
            bullets.add(new_bullet)

玩家按空格键时，我们检查bullets的长度。如果len(bullets)小于3，我们就创建一个新子弹；但如果已有3颗未消失的子弹，则玩家按空格键时什么都不会发生。如果你现在运行这个游戏，屏幕上最多只能有3颗子弹。

12.8.7　创建函数update_bullets()

编写并检查子弹管理代码后，可将其移到模块game_functions中，以让主程序文件alien_invasion.py尽可能简单。我们创建一个名为update_bullets()的新函数，并将其添加到game_functions.py的末尾：

game_functions.py

def update_bullets(bullets):
   """更新子弹的位置，并删除已消失的子弹"""
    # 更新子弹的位置
    bullets.update()

    # 删除已消失的子弹
    for bullet in bullets.copy():
        if bullet.rect.bottom <= 0:
            bullets.remove(bullet)

update_bullets()的代码是从alien_invasion.py剪切并粘贴而来的，它只需要一个参数，即编组bullets。

alien_invasion.py中的while循环又变得很简单了：

alien_invasion.py

    # 开始游戏主循环
    while True:
        gf.check_events(ai_settings, screen, ship, bullets) ❶
        ship.update() ❷
        gf.update_bullets(bullets) ❸
        gf.update_screen(ai_settings, screen, ship, bullets) ❹

我们让主循环包含尽可能少的代码，这样只要看函数名就能迅速知道游戏中发生的情况。主循环检查玩家的输入（见❶），然后更新飞船的位置（见❷）和所有未消失的子弹的位置（见❸）。接下来，我们使用更新后的位置来绘制新屏幕（见❹）。

12.8.8　创建函数fire_bullet()

下面将发射子弹的代码移到一个独立的函数中，这样，在check_keydown_events()中只需使用一行代码来发射子弹，让elif代码块变得非常简单：

game_functions.py

def check_keydown_events(event, ai_settings, screen, ship, bullets):
    """响应按键"""
    --snip--
    elif event.key == pygame.K_SPACE:
        fire_bullet(ai_settings, screen, ship, bullets)

def fire_bullet(ai_settings, screen, ship, bullets):
    """如果还没有到达限制，就发射一颗子弹"""
    #创建新子弹，并将其加入到编组bullets中
    if len(bullets) < ai_settings.bullets_allowed:
        new_bullet = Bullet(ai_settings, screen, ship)
        bullets.add(new_bullet)

函数fire_bullet()只包含玩家按空格键时用于发射子弹的代码；在check_keydown_events()中，我们在玩家按空格键时调用fire_bullet()。

请再次运行alien_invasion.py，确认发射子弹时依然没有错误。

动手试一试

12-5 侧面射击：编写一个游戏，将一艘飞船放在屏幕左边，并允许玩家上下移动飞船。在玩家按空格键时，让飞船发射一颗在屏幕中向右穿行的子弹，并在子弹离开屏幕而消失后将其删除。

12.9　小结

在本章中，你学习了：游戏开发计划的制定；使用Pygame编写的游戏的基本结构；如何设置背景色，以及如何将设置存储在可供游戏的各个部分访问的独立类中；如何在屏幕上绘制图像，以及如何让玩家控制游戏元素的移动；如何创建自动移动的元素，如在屏幕中向上飞驰的子弹，以及如何删除不再需要的对象；如何定期重构项目的代码，为后续开发提供便利。

在第13章中，我们将在游戏《外星人入侵》中添加外星人。在第13章结束时，你将能够击落外星人——但愿是在他们撞到飞船前！

第 13 章　外星人

在本章中，我们将在游戏《外星人入侵》中添加外星人。首先，我们在屏幕上边缘附近添加一个外星人，然后生成一群外星人。我们让这群外星人向两边和下面移动，并删除被子弹击中的外星人。最后，我们将显示玩家拥有的飞船数量，并在玩家的飞船用完后结束游戏。

通过阅读本章，你将更深入地了解Pygame和大型项目的管理。你还将学习如何检测游戏对象之间的碰撞，如子弹和外星人之间的碰撞。检测碰撞有助于你定义游戏元素之间的交互：可以将角色限定在迷宫墙壁之内或在两个角色之间传球。我们将时不时地查看游戏开发计划，以确保编程工作不偏离轨道。

着手编写在屏幕上添加一群外星人的代码前，先来回顾一下这个项目，并更新开发计划。

13.1　回顾项目

开发较大的项目时，进入每个开发阶段前回顾一下开发计划，搞清楚接下来要通过编写代码来完成哪些任务都是不错的主意。本章涉及以下内容。

研究既有代码，确定实现新功能前是否要进行重构。

在屏幕左上角添加一个外星人，并指定合适的边距。

根据第一个外星人的边距和屏幕尺寸计算屏幕上可容纳多少个外星人。我们将编写一个循环来创建一系列外星人，这些外星人填满了屏幕的上半部分。

让外星人群向两边和下方移动，直到外星人被全部击落，有外星人撞到飞船，或有外星人抵达屏幕底端。如果整群外星人都被击落，我们将再创建一群外星人。如果有外星人撞到了飞船或抵达屏幕底端，我们将销毁飞船并再创建一群外星人。

限制玩家可用的飞船数量，配给的飞船用完后，游戏结束。

我们将在实现功能的同时完善这个计划，但就目前而言，该计划已足够详尽。

在给项目添加新功能前，还应审核既有代码。每进入一个新阶段，通常项目都会更复杂，因此最好对混乱或低效的代码进行清理。

我们在开发的同时一直不断地重构，因此当前需要做的清理工作不多，但每次为测试新功能而运行这个游戏时，都必须使用鼠标来关闭它，这太讨厌了。下面来添加一个结束游戏的快捷键Q：

game_functions.py

def check_keydown_events(event, ai_settings, screen, ship, bullets):
    --snip--
    elif event.key == pygame.K_q:
        sys.exit()

在check_keydown_events()中，我们添加了一个代码块，以便在玩家按Q时结束游戏。这样的修改很安全，因为Q键离箭头键和空格键很远，玩家不小心按Q键而导致游戏结束的可能性不大。现在测试时可按Q关闭游戏，而无需使用鼠标来关闭窗口了。

13.2　创建第一个外星人

在屏幕上放置外星人与放置飞船类似。每个外星人的行为都由Alien类控制，我们将像创建Ship类那样创建这个类。出于简化考虑，我们也使用位图来表示外星人。你可以自己寻找表示外星人的图像，也可使用图13-1所示的图像，可在本书配套资源（https://www.nostarch.com/pythoncrashcourse/）中找到。这幅图像的背景为灰色，与屏幕背景色一致。请务必将你选择的图像文件保存到文件夹images中。

图13-1　用来创建外星人群的外星人图像

13.2.1　创建Alien类

下面来编写Alien类：

alien.py

import pygame
from pygame.sprite import Sprite

class Alien(Sprite):
    """表示单个外星人的类"""

    def __init__(self, ai_settings, screen):
        """初始化外星人并设置其起始位置"""
        super(Alien, self).__init__()
        self.screen = screen
        self.ai_settings = ai_settings

        # 加载外星人图像，并设置其rect属性
        self.image = pygame.image.load('images/alien.bmp')
        self.rect = self.image.get_rect()

        # 每个外星人最初都在屏幕左上角附近
        self.rect.x = self.rect.width ❶
        self.rect.y = self.rect.height

        # 存储外星人的准确位置
        self.x = float(self.rect.x)

    def blitme(self):
        """在指定位置绘制外星人"""
        self.screen.blit(self.image, self.rect)

除位置不同外，这个类的大部分代码都与Ship类相似。每个外星人最初都位于屏幕左上角附近，我们将每个外星人的左边距都设置为外星人的宽度，并将上边距设置为外星人的高度（见❶）。

13.2.2　创建Alien实例

下面在alien_invasion.py中创建一个Alien实例：

alien_invasion.py

--snip--
from ship import Ship
from alien import Alien
import game_functions as gf

def run_game():
    --snip--
    # 创建一个外星人
    alien = Alien(ai_settings, screen)

    # 开始游戏主循环
    while True:
        gf.check_events(ai_settings, screen, ship, bullets)
        ship.update()
        gf.update_bullets(bullets)
        gf.update_screen(ai_settings, screen, ship, alien, bullets)

run_game()

在这里，我们导入了新创建的Alien类，并在进入主while循环前创建了一个Alien实例。我们没有修改外星人的位置，因此该while循环没有任何新东西，但我们修改了对update_screen()的调用，传递了一个外星人实例。

13.2.3　让外星人出现在屏幕上

为让外星人出现在屏幕上，我们在update_screen()中调用其方法blitme()：

game_functions.py

def update_screen(ai_settings, screen, ship, alien, bullets):
    --snip--

    # 在飞船和外星人后面重绘所有的子弹
    for bullet in bullets:
        bullet.draw_bullet()
    ship.blitme()
    alien.blitme()

    # 让最近绘制的屏幕可见
    pygame.display.flip()

我们先绘制飞船和子弹，再绘制外星人，让外星人在屏幕上位于最前面。图13-2显示了屏幕上的第一个外星人。

图13-2　第一个外星人现身

第一个外星人正确地现身后，下面来编写绘制一群外星人的代码。

13.3　创建一群外星人

要绘制一群外星人，需要确定一行能容纳多少个外星人以及要绘制多少行外星人。我们将首先计算外星人之间的水平间距，并创建一行外星人，再确定可用的垂直空间，并创建整群外星人。

13.3.1　确定一行可容纳多少个外星人

为确定一行可容纳多少个外星人，我们来看看可用的水平空间有多大。屏幕宽度存储在ai_settings.screen_width中，但需要在屏幕两边都留下一定的边距，把它设置为外星人的宽度。由于有两个边距，因此可用于放置外星人的水平空间为屏幕宽度减去外星人宽度的两倍：

available_space_x = ai_settings.screen_width – (2 * alien_width)

我们还需要在外星人之间留出一定的空间，即外星人宽度。因此，显示一个外星人所需的水平空间为外星人宽度的两倍：一个宽度用于放置外星人，另一个宽度为外星人右边的空白区域。为确定一行可容纳多少个外星人，我们将可用空间除以外星人宽度的两倍：

number_aliens_x = available_space_x / (2 * alien_width)

我们将在创建外星人群时使用这些公式。

注意　令人欣慰的是，在程序中执行计算时，一开始你无需确定公式是正确的，而可以尝试直接运行程序，看看结果是否符合预期。即便是在最糟糕的情况下，也只是屏幕上显示的外星人太多或太少。你可以根据在屏幕上看到的情况调整计算公式。

13.3.2　创建多行外星人

为创建一行外星人，首先在alien_invasion.py中创建一个名为aliens的空编组，用于存储全部外星人，再调用game_functions.py中创建外星人群的函数：

alien_invasion.py

import pygame
from pygame.sprite import Group
from settings import Settings
from ship import Ship
import game_functions as gf

def run_game():
    --snip--
    # 创建一艘飞船、一个子弹编组和一个外星人编组
    ship = Ship(ai_settings, screen)
    bullets = Group()
    aliens = Group() ❶

    # 创建外星人群
    gf.create_fleet(ai_settings, screen, aliens) ❷

    # 开始游戏主循环
    while True:
        --snip—
        gf.update_screen(ai_settings, screen, ship, aliens, bullets)❸

run_game()

由于我们不再在alien_invasion.py中直接创建外星人，因此无需在这个文件中导入Alien类。

❶处创建了一个空编组，用于存储所有的外星人。接下来，调用稍后将编写的函数create_fleet()（见❷），并将ai_settings、对象screen和空编组aliens传递给它。然后，修改对update_screen()的调用，让它能够访问外星人编组（见❸）。

我们还需要修改update_screen()：

game_functions.py

def update_screen(ai_settings, screen, ship, aliens, bullets):
    --snip--
    ship.blitme()
    aliens.draw(screen)

    # 让最近绘制的屏幕可见
    pygame.display.flip()

对编组调用draw()时，Pygame自动绘制编组的每个元素，绘制位置由元素的属性rect决定。在这里，aliens.draw(screen)在屏幕上绘制编组中的每个外星人。

13.3.3　创建外星人群

现在可以创建外星人群了。下面是新函数create_fleet()，我们将它放在game_functions.py的末尾。我们还需要导入Alien类，因此务必在文件game_functions.py开头添加相应的import语句：

game_functions.py

--snip--
from bullet import Bullet
from alien import Alien
--snip--

def create_fleet(ai_settings, screen, aliens):
    """创建外星人群"""
    # 创建一个外星人，并计算一行可容纳多少个外星人
    # 外星人间距为外星人宽度
    alien = Alien(ai_settings, screen) ❶
    alien_width = alien.rect.width ❷
    available_space_x = ai_settings.screen_width - 2 * alien_width ❸
    number_aliens_x = int(available_space_x / (2 * alien_width)) ❹

    # 创建第一行外星人
    for alien_number in range(number_aliens_x): ❺
        # 创建一个外星人并将其加入当前行
        alien = Alien(ai_settings, screen) ❻
        alien.x = alien_width + 2 * alien_width * alien_number
        alien.rect.x = alien.x
        aliens.add(alien)

这些代码大都在前面详细介绍过。为放置外星人，我们需要知道外星人的宽度和高度，因此在执行计算前，我们先创建一个外星人（见❶）。这个外星人不是外星人群的成员，因此没有将它加入到编组aliens中。在❷处，我们从外星人的rect属性中获取外星人宽度，并将这个值存储到alien_width中，以免反复访问属性rect。在❸处，我们计算可用于放置外星人的水平空间，以及其中可容纳多少个外星人。

相比于前面介绍的工作，这里唯一的不同是使用了int()来确保计算得到的外星人数量为整数（见❹），因为我们不希望某个外星人只显示一部分，而且函数range()也需要一个整数。函数int()将小数部分丢弃，相当于向下圆整（这大有裨益，因为我们宁愿每行都多出一点点空间，也不希望每行的外星人之间过于拥挤）。

接下来，我们编写了一个循环，它从零数到要创建的外星人数（见❺）。在这个循环的主体中，我们创建一个新的外星人，并通过设置x坐标将其加入当前行（见❻）。将每个外星人都往右推一个外星人的宽度。接下来，我们将外星人宽度乘以2，得到每个外星人占据的空间（其中包括其右边的空白区域），再据此计算当前外星人在当前行的位置。最后，我们将每个新创建的外星人都添加到编组aliens中。

如果你现在运行这个游戏，将看到第一行外星人，如图13-3所示。

图13-3　第一行外星人

这行外星人在屏幕上稍微偏向了左边，这实际上是有好处的，因为我们将让外星人群往右移，触及屏幕边缘后稍微往下移，然后往左移，以此类推。就像经典游戏《太空入侵者》，相比于只往下移，这种移动方式更有趣。我们将让外形人群不断这样移动，直到所有外星人都被击落或有外星人撞上飞船或抵达屏幕底端。

注意　根据你选择的屏幕宽度，在你的系统中，第一行外星人的位置可能稍有不同。

13.3.4　重构create_fleet()

倘若我们创建了外星人群，也许应该让create_fleet()保持原样，但鉴于创建外星人的工作还未完成，我们稍微清理一下这个函数。下面是create_fleet()和两个新函数，get_number_aliens_x()和create_alien()：

game_functions.py

def get_number_aliens_x(ai_settings, alien_width): ❶
    """计算每行可容纳多少个外星人"""
    available_space_x = ai_settings.screen_width - 2 * alien_width
    number_aliens_x = int(available_space_x / (2 * alien_width))
    return number_aliens_x

def create_alien(ai_settings, screen, aliens, alien_number):
    """创建一个外星人并将其放在当前行"""
    alien = Alien(ai_settings, screen)
    alien_width = alien.rect.width ❷
    alien.x = alien_width + 2 * alien_width * alien_number
    alien.rect.x = alien.x
    aliens.add(alien)

def create_fleet(ai_settings, screen, aliens):
    """创建外星人群"""
    # 创建一个外星人，并计算每行可容纳多少个外星人
    alien = Alien(ai_settings, screen)
    number_aliens_x = get_number_aliens_x(ai_settings, alien.rect.width) ❸

    # 创建第一行外星人
    for alien_number in range(number_aliens_x):
        create_alien(ai_settings, screen, aliens, alien_number) ❹

函数get_number_aliens_x()的代码都来自create_fleet()，且未做任何修改（见❶）。函数create_alien()的代码也都来自create_fleet()，且未做任何修改，只是使用刚创建的外星人来获取外星人宽度（见❷）。在❸处，我们将计算可用水平空间的代码替换为对get_number_aliens_x()的调用，并删除了引用alien_width的代码行，因为现在这是在create_alien()中处理的。在❹处，我们调用create_alien()。通过这样的重构，添加新行进而创建整群外星人将更容易。

13.3.5　添加行

要创建外星人群，需要计算屏幕可容纳多少行，并对创建一行外星人的循环重复相应的次数。为计算可容纳的行数，我们这样计算可用垂直空间：将屏幕高度减去第一行外星人的上边距（外星人高度）、飞船的高度以及最初外星人高度加上外星人间距（外星人高度的两倍）：

available_space_y = ai_settings.screen_height – 3 * alien_height – ship_height

这将在飞船上方留出一定的空白区域，给玩家留出射杀外星人的时间。

每行下方都要留出一定的空白区域，并将其设置为外星人的高度。为计算可容纳的行数，我们将可用垂直空间除以外星人高度的两倍（同样，如果这样的计算不对，我们马上就能发现，继而将间距调整为合理的值）。

number_rows = available_space_y / (2 * alien_height)

知道可容纳多少行后，便可重复执行创建一行外星人的代码：

game_functions.py

def get_number_rows(ai_settings, ship_height, alien_height): ❶
    """计算屏幕可容纳多少行外星人"""
    available_space_y = (ai_settings.screen_height - ❷
                            (3 * alien_height) - ship_height)
    number_rows = int(available_space_y / (2 * alien_height))
    return number_rows

def create_alien(ai_settings, screen, aliens, alien_number, row_number):
    --snip--
    alien.x = alien_width + 2 * alien_width * alien_number
    alien.rect.x = alien.x
    alien.rect.y = alien.rect.height + 2 * alien.rect.height * row_number ❸
    aliens.add(alien)

def create_fleet(ai_settings, screen, ship, aliens):
    --snip--
    number_aliens_x = get_number_aliens_x(ai_settings, alien.rect.width)
    number_rows = get_number_rows(ai_settings, ship.rect.height,
        alien.rect.height)

    # 创建外星人群
    for row_number in range(number_rows): ❹
        for alien_number in range(number_aliens_x):
            create_alien(ai_settings, screen, aliens, alien_number,
                row_number)

为计算屏幕可容纳多少行外星人，我们在函数get_number_rows()中实现了前面计算available_space_y和number_rows的公式（见❶），这个函数与get_number_aliens_x()类似。计算公式用括号括起来了，这样可将代码分成两行，以遵循每行不超过79字符的建议（见❷）。这里使用了int()，因为我们不想创建不完整的外星人行。

为创建多行，我们使用两个嵌套在一起的循环：一个外部循环和一个内部循环（见❸）。其中的内部循环创建一行外星人，而外部循环从零数到要创建的外星人行数。Python将重复执行创建单行外星人的代码，重复次数为number_rows。

为嵌套循环，我们编写了一个新的for循环，并缩进了要重复执行的代码。（在大多数文本编辑器中，缩进代码块和取消缩进都很容易，详情请参阅附录B。）我们调用create_alien()时，传递了一个表示行号的实参，将每行都沿屏幕依次向下放置。

create_alien()的定义需要一个用于存储行号的形参。在create_alien()中，我们修改外星人的y坐标（见❹），并在第一行外星人上方留出与外星人等高的空白区域。相邻外星人行的y坐标相差外星人高度的两倍，因此我们将外星人高度乘以2，再乘以行号。第一行的行号为0，因此第一行的垂直位置不变，而其他行都沿屏幕依次向下放置。

在create_fleet()的定义中，还新增了一个用于存储ship对象的形参，因此在alien_invasion.py中调用create_fleet()时，需要传递实参ship：

alien_invasion.py

# 创建外星人群
gf.create_fleet(ai_settings, screen, ship, aliens)

如果你现在运行这个游戏，将看到一群外星人，如图13-4所示。

图13-4　整群外星人都现身了

在下一节，我们将让外星人群动起来！

动手试一试

13-1 星星：找一幅星星图像，并在屏幕上显示一系列整齐排列的星星。

13-2 更逼真的星星：为让星星的分布更逼真，可随机地放置星星。本书前面说过，可像下面这样来生成随机数：

from random import randint
random_number = randint(-10,10)

上述代码返回一个-10和10之间的随机整数。在为完成练习13-1而编写的程序中，随机地调整每颗星星的位置。

13.4　让外星人群移动

下面来让外星人群在屏幕上向右移动，撞到屏幕边缘后下移一定的距离，再沿相反的方向移动。我们将不断地移动所有的外星人，直到所有外星人都被消灭，有外星人撞上飞船，或有外星人抵达屏幕底端。下面先来让外星人向右移动。

13.4.1　向右移动外星人

为移动外星人，我们将使用alien.py中的方法update()，且对外星人群中的每个外星人都调用它。首先，添加一个控制外星人速度的设置：

settings.py

    def __init__(self):
        --snip--
        # 外星人设置
        self.alien_speed_factor = 1

然后，使用这个设置来实现update()：

alien.py

    def update(self):
        """向右移动外星人"""
        self.x += self.ai_settings.alien_speed_factor ❶
        self.rect.x = self.x ❷

每次更新外星人位置时，都将它向右移动，移动量为alien_speed_factor的值。我们使用属性self.x跟踪每个外星人的准确位置，这个属性可存储小数值（见❶）。然后，我们使用self.x的值来更新外星人的rect的位置（见❷）。

在主while循环中已调用了更新飞船和子弹的方法，但现在还需更新每个外星人的位置：

alien_invasion.py

    # 开始游戏主循环
    while True:
        gf.check_events(ai_settings, screen, ship, bullets)
        ship.update()
        gf.update_bullets(bullets)
        gf.update_aliens(aliens)
        gf.update_screen(ai_settings, screen, ship, aliens, bullets)

我们在更新子弹后再更新外星人的位置，因为稍后要检查是否有子弹撞到了外星人。

最后，在文件game_functions.py末尾添加新函数update_aliens()：

game_functions.py

def update_aliens(aliens):
    """更新外星人群中所有外星人的位置"""
    aliens.update()

我们对编组aliens调用方法update()，这将自动对每个外星人调用方法update()。如果你现在运行这个游戏，会看到外星人群向右移，并逐渐在屏幕右边缘消失。

13.4.2　创建表示外星人移动方向的设置

下面来创建让外星人撞到屏幕右边缘后向下移动、再向左移动的设置。实现这种行为的代码如下：

settings.py

        # 外星人设置
        self.alien_speed_factor = 1
        self.fleet_drop_speed = 10
        # fleet_direction为1表示向右移，为-1表示向左移
        self.fleet_direction = 1

设置fleet_drop_speed指定了有外星人撞到屏幕边缘时，外星人群向下移动的速度。将这个速度与水平速度分开是有好处的，这样你就可以分别调整这两种速度了。

要实现fleet_direction设置，可以将其设置为文本值，如'left'或'right'，但这样就必须编写if-elif语句来检查外星人群的移动方向。鉴于只有两个可能的方向，我们使用值1和-1来表示它们，并在外星人群改变方向时在这两个值之间切换。另外，鉴于向右移动时需要增大每个外星人的x坐标，而向左移动时需要减小每个外星人的x坐标，使用数字来表示方向更合理。

13.4.3　检查外星人是否撞到了屏幕边缘

现在需要编写一个方法来检查是否有外星人撞到了屏幕边缘，还需修改update()，以让每个外星人都沿正确的方向移动：

alien.py

    def check_edges(self):
        """如果外星人位于屏幕边缘，就返回True"""
        screen_rect = self.screen.get_rect()
        if self.rect.right >= screen_rect.right: ❶
            return True
        elif self.rect.left <= 0: ❷
            return True

    def update(self):
        """向左或向右移动外星人"""
        self.x += (self.ai_settings.alien_speed_factor * ❸
                        self.ai_settings.fleet_direction)
        self.rect.x = self.x

我们可对任何外星人调用新方法check_edges()，看看它是否位于屏幕左边缘或右边缘。如果外星人的rect的right属性大于或等于屏幕的rect的right属性，就说明外星人位于屏幕右边缘（见❶）。如果外星人的rect的left属性小于或等于0，就说明外星人位于屏幕左边缘（见❷）。

我们修改了方法update()，将移动量设置为外星人速度和fleet_direction的乘积，让外星人向左或向右移。如果fleet_direction为1，就将外星人当前的 x 坐标增大alien_speed_factor，从而将外星人向右移；如果fleet_direction为-1，就将外星人当前的 x 坐标减去alien_speed_factor，从而将外星人向左移。

13.4.4　向下移动外星人群并改变移动方向

有外星人到达屏幕边缘时，需要将整群外星人下移，并改变它们的移动方向。我们需要对game_functions.py做重大修改，因为我们要在这里检查是否有外星人到达了左边缘或右边缘。为此，我们编写函数check_fleet_edges()和change_fleet_direction()，并对update_aliens()进行修改：

game_functions.py

def check_fleet_edges(ai_settings, aliens):
    """有外星人到达边缘时采取相应的措施"""
    for alien in aliens.sprites(): ❶
        if alien.check_edges():
            change_fleet_direction(ai_settings, aliens)
            break

def change_fleet_direction(ai_settings, aliens):
    """将整群外星人下移，并改变它们的方向"""
    for alien in aliens.sprites():
        alien.rect.y += ai_settings.fleet_drop_speed ❷
    ai_settings.fleet_direction *= -1

def update_aliens(ai_settings, aliens):
    """
    检查是否有外星人位于屏幕边缘，并更新整群外星人的位置
    """
    check_fleet_edges(ai_settings, aliens) ❸
    aliens.update()

在check_fleet_edges()中，我们遍历外星人群，并对其中的每个外星人调用check_edges()（见❶）。如果check_edges()返回True，我们就知道相应的外星人位于屏幕边缘，需要改变外星人群的方向，因此我们调用change_fleet_direction()并退出循环。在change_fleet_direction()中，我们遍历所有外星人，将每个外星人下移fleet_drop_speed设置的值（见❷）；然后，将fleet_direction的值修改为其当前值与-1的乘积。

我们修改了函数update_aliens()，在其中通过调用check_fleet_edges()来确定是否有外星人位于屏幕边缘。现在，函数update_aliens()包含形参ai_settings，因此我们调用它时指定了与ai_settings对应的实参：

alien_invasion.py

    # 开始游戏主循环
    while True:
        gf.check_events(ai_settings, screen, ship, bullets)
        ship.update()
        gf.update_bullets(bullets)
        gf.update_aliens(ai_settings, aliens)
        gf.update_screen(ai_settings, screen, ship, aliens, bullets)

如果你现在运行这个游戏，外星人群将在屏幕上来回移动，并在抵达屏幕边缘后向下移动。现在可以开始射杀外星人，检查是否有外星人撞到飞船，或抵达了屏幕底端。

动手试一试

13-3 雨滴：寻找一幅雨滴图像，并创建一系列整齐排列的雨滴。让这些雨滴往下落，直到到达屏幕底端后消失。

13-4 连绵细雨：修改为完成练习13-3而编写的代码，使得一行雨滴消失在屏幕底端后，屏幕顶端又出现一行新雨滴，并开始往下落。

13.5　射杀外星人

我们创建了飞船和外星人群，但子弹击中外星人时，将穿过外星人，因为我们还没有检查碰撞。在游戏编程中，碰撞指的是游戏元素重叠在一起。要让子弹能够击落外星人，我们将使用sprite.groupcollide()检测两个编组的成员之间的碰撞。

13.5.1　检测子弹与外星人的碰撞

子弹击中外星人时，我们要马上知道，以便碰撞发生后让外星人立即消失。为此，我们将在更新子弹的位置后立即检测碰撞。

方法sprite.groupcollide()将每颗子弹的rect同每个外星人的rect进行比较，并返回一个字典，其中包含发生了碰撞的子弹和外星人。在这个字典中，每个键都是一颗子弹，而相应的值都是被击中的外星人（第14章实现记分系统时，也会用到这个字典）。

在函数update_bullets()中，使用下面的代码来检查碰撞：

game_functions.py

def update_bullets(aliens, bullets):
    """更新子弹的位置，并删除已消失的子弹"""
    --snip--
    # 检查是否有子弹击中了外星人
    # 如果是这样，就删除相应的子弹和外星人
    collisions = pygame.sprite.groupcollide(bullets, aliens, True, True)

新增的这行代码遍历编组bullets中的每颗子弹，再遍历编组aliens中的每个外星人。每当有子弹和外星人的rect重叠时，groupcollide()就在它返回的字典中添加一个键-值对。两个实参True告诉Pygame删除发生碰撞的子弹和外星人。（要模拟能够穿行到屏幕顶端的高能子弹——消灭它击中的每个外星人，可将第一个布尔实参设置为False，并让第二个布尔实参为True。这样被击中的外星人将消失，但所有的子弹都始终有效，直到抵达屏幕顶端后消失。）

我们调用update_bullets()时，传递了实参aliens：

alien_invasion.py

    # 开始游戏主循环
    while True:
        gf.check_events(ai_settings, screen, ship, bullets)
        ship.update()
        gf.update_bullets(aliens, bullets)
        gf.update_aliens(ai_settings, aliens)
        gf.update_screen(ai_settings, screen, ship, aliens, bullets)

如果你此时运行这个游戏，被击中的外星人将消失。如图13-5所示，其中有一部分外星人被击落。

图13-5　可以射杀外星人了

13.5.2　为测试创建大子弹

只需通过运行这个游戏就可以测试其很多功能，但有些功能在正常情况下测试起来比较烦琐。例如，要测试代码能否正确地处理外星人编组为空的情形，需要花很长时间将屏幕上的外星人都击落。

测试有些功能时，可以修改游戏的某些设置，以便专注于游戏的特定方面。例如，可以缩小屏幕以减少需要击落的外星人数量，也可以提高子弹的速度，以便能够在单位时间内发射大量子弹。

测试这个游戏时，我喜欢做的一项修改是增大子弹的尺寸，使其在击中外星人后依然有效，如图13-6所示。请尝试将bullet_width设置为300，看看将所有外星人都射杀有多快！

类似这样的修改可提高测试效率，还可能激发出如何赋予玩家更大威力的思想火花。（完成测试后，别忘了将设置恢复正常。）

图13-6　威力更大的子弹让游戏的有些方法测试起来更容易

13.5.3　生成新的外星人群

这个游戏的一个重要特点是外星人无穷无尽，一个外星人群被消灭后，又会出现一群外星人。

要在外星人群被消灭后又显示一群外星人，首先需要检查编组aliens是否为空。如果为空，就调用create_fleet()。我们将在update_bullets()中执行这种检查，因为外星人都是在这里被消灭的：

game_functions.py

def update_bullets(ai_settings, screen, ship, aliens, bullets):
    --snip--
    # 检查是否有子弹击中了外星人
    # 如果是，就删除相应的子弹和外星人
    collisions = pygame.sprite.groupcollide(bullets, aliens, True, True)

    if len(aliens) == 0: ❶
        # 删除现有的子弹并新建一群外星人
        bullets.empty() ❷
        create_fleet(ai_settings, screen, ship, aliens)

在❶处，我们检查编组aliens是否为空。如果是，就使用方法empty()删除编组中余下的所有精灵，从而删除现有的所有子弹。我们还调用了create_fleet()，再次在屏幕上显示一群外星人。

现在，update_bullets()的定义包含额外的形参ai_settings、screen和ship，因此我们需要更新alien_invasion.py中对update_bullets()的调用：

alien_invasion.py

    # 开始游戏主循环
    while True:
        gf.check_events(ai_settings, screen, ship, bullets)
        ship.update()
        gf.update_bullets(ai_settings, screen, ship, aliens, bullets)
        gf.update_aliens(ai_settings, aliens)
        gf.update_screen(ai_settings, screen, ship, aliens, bullets)

现在，当前外星人群消灭干净后，将立刻出现一个新的外星人群。

13.5.4　提高子弹的速度

如果你现在尝试在这个游戏中射杀外星人，可能发现子弹的速度比以前慢，这是因为在每次循环中，Pygame需要做的工作更多了。为提高子弹的速度，可调整settings.py中bullet_speed_factor的值。例如，如果将这个值增大到3，子弹在屏幕上向上穿行的速度将变得相当快：

settings.py

        # 子弹设置
        self.bullet_speed_factor = 3
        self.bullet_width = 3
        --snip--

这项设置的最佳值取决于你的系统速度，请找出适合你的值吧。

13.5.5　重构update_bullets()

下面来重构update_bullets()，使其不再完成那么多任务。我们将把处理子弹和外星人碰撞的代码移到一个独立的函数中：

game_functions.py

def update_bullets(ai_settings, screen, ship, aliens, bullets):
    --snip--
    # 删除已消失的子弹
    for bullet in bullets.copy():
        if bullet.rect.bottom <= 0:
            bullets.remove(bullet)

    check_bullet_alien_collisions(ai_settings, screen, ship, aliens, bullets)

def check_bullet_alien_collisions(ai_settings, screen, ship, aliens, bullets):
    """响应子弹和外星人的碰撞"""
    # 删除发生碰撞的子弹和外星人
    collisions = pygame.sprite.groupcollide(bullets, aliens, True, True)

    if len(aliens) == 0:
        # 删除现有的所有子弹，并创建一个新的外星人群
        bullets.empty()
        create_fleet(ai_settings, screen, ship, aliens)

我们创建了一个新函数——check_bullet_alien_collisions()，以检测子弹和外星人之间的碰撞，以及在整群外星人都被消灭干净时采取相应的措施。这避免了update_bullets()太长，简化了后续的开发工作。

动手试一试

13-5 抓球：创建一个游戏，在屏幕底端放置一个玩家可左右移动的角色。让一个球出现在屏幕顶端，且水平位置是随机的，并让这个球以固定的速度往下落。如果角色与球发生碰撞（表示将球抓住了），就让球消失。每当角色抓住球或球因抵达屏幕底端而消失后，都创建一个新球。

13.6　结束游戏

如果玩家根本不会输，游戏还有什么趣味和挑战性可言？如果玩家没能在足够短的时间内将整群外星人都消灭干净，且有外星人撞到了飞船，飞船将被摧毁。与此同时，我们还限制了可供玩家使用的飞船数，而有外星人抵达屏幕底端时，飞船也将被摧毁。玩家用光了飞船后，游戏便结束。

13.6.1　检测外星人和飞船碰撞

我们首先检查外星人和飞船之间的碰撞，以便外星人撞上飞船时我们能够作出合适的响应。我们在更新每个外星人的位置后立即检测外星人和飞船之间的碰撞。

game_functions.py

def update_aliens(ai_settings, ship, aliens):
    """
    检查是否有外星人到达屏幕边缘
      然后更新所有外星人的位置
    """
    check_fleet_edges(ai_settings, aliens)
    aliens.update()

    # 检测外星人和飞船之间的碰撞
    if pygame.sprite.spritecollideany(ship, aliens): ❶
        print("Ship hit!!!") ❷

方法spritecollideany()接受两个实参：一个精灵和一个编组。它检查编组是否有成员与精灵发生了碰撞，并在找到与精灵发生了碰撞的成员后就停止遍历编组。在这里，它遍历编组aliens，并返回它找到的第一个与飞船发生了碰撞的外星人。

如果没有发生碰撞，spritecollideany()将返回None，因此❶处的if代码块不会执行。如果找到了与飞船发生碰撞的外星人，它就返回这个外星人，因此if代码块将执行：打印“Ship hit!!!”（见❷）。（有外星人撞到飞船时，需要执行的任务很多：需要删除余下的所有外星人和子弹，让飞船重新居中，以及创建一群新的外星人。编写完成这些任务的代码前，需要确定检测外星人和飞船碰撞的方法是否可行。而为确定这一点，最简单的方式是编写一条print语句。）

现在，我们需要将ship传递给update_aliens()：

alien_invasion.py

    # 开始游戏主循环
    while True:
        gf.check_events(ai_settings, screen, ship, bullets)
        ship.update()
        gf.update_bullets(ai_settings, screen, ship, aliens, bullets)
        gf.update_aliens(ai_settings, ship, aliens)
        gf.update_screen(ai_settings, screen, ship, aliens, bullets)

现在如果你运行这个游戏，则每当有外星人撞到飞船时，终端窗口都将显示“Ship hit!!!”。测试这项功能时，请将alien_drop_speed设置为较大的值，如50或100，这样外星人将更快地撞到飞船。

13.6.2　响应外星人和飞船碰撞

现在需要确定外星人与飞船发生碰撞时，该做些什么。我们不销毁ship实例并创建一个新的ship实例，而是通过跟踪游戏的统计信息来记录飞船被撞了多少次（跟踪统计信息还有助于记分）。

下面来编写一个用于跟踪游戏统计信息的新类——GameStats，并将其保存为文件game_stats.py：

game_stats.py

class GameStats():
    """跟踪游戏的统计信息"""

    def __init__(self, ai_settings):
        """初始化统计信息"""
        self.ai_settings = ai_settings
        self.reset_stats() ❶

    def reset_stats(self):
        """初始化在游戏运行期间可能变化的统计信息"""
        self.ships_left = self.ai_settings.ship_limit

在这个游戏运行期间，我们只创建一个GameStats实例，但每当玩家开始新游戏时，需要重置一些统计信息。为此，我们在方法reset_stats()中初始化大部分统计信息，而不是在__init__()中直接初始化它们。我们在__init__()中调用这个方法，这样创建GameStats实例时将妥善地设置这些统计信息（见❶），同时在玩家开始新游戏时也能调用reset_stats()。

当前只有一项统计信息——ships_left，其值在游戏运行期间将不断变化。一开始玩家拥有的飞船数存储在settings.py的ship_limit中：

settings.py

        # 飞船设置
        self.ship_speed_factor = 1.5
        self.ship_limit = 3

我们还需对alien_invasion.py做些修改，以创建一个GameStats实例：

alien_invasion.py

--snip--
from settings import Settings
from game_stats import GameStats ❶
--snip--

def run_game():
    --snip--
    pygame.display.set_caption("Alien Invasion")

    # 创建一个用于存储游戏统计信息的实例
    stats = GameStats(ai_settings) ❷
    --snip--
    # 开始游戏主循环
    while True:
        --snip--
        gf.update_bullets(ai_settings, screen, ship, aliens, bullets)
        gf.update_aliens(ai_settings, stats, screen, ship, aliens, bullets) ❸
        --snip--

我们导入了新类GameStats（见❶），创建了一个名为stats的实例（见❷），再调用update_aliens()并添加了实参stats、screen和ship（见❸）。在有外星人撞到飞船时，我们将使用这些实参来跟踪玩家还有多少艘飞船，以及创建一群新的外星人。

有外星人撞到飞船时，我们将余下的飞船数减1，创建一群新的外星人，并将飞船重新放置到屏幕底端中央（我们还将让游戏暂停一段时间，让玩家在新外星人群出现前注意到发生了碰撞，并将重新创建外星人群）。

下面将实现这些功能的大部分代码放到函数ship_hit()中：

game_functions.py

import sys
from time import sleep ❶

import pygame
--snip--

  def ship_hit(ai_settings, stats, screen, ship, aliens, bullets):
    """响应被外星人撞到的飞船"""
    # 将ships_left减1
    stats.ships_left -= 1 ❷

    # 清空外星人列表和子弹列表
    aliens.empty() ❸
    bullets.empty()

    # 创建一群新的外星人，并将飞船放到屏幕底端中央
    create_fleet(ai_settings, screen, ship, aliens) ❹
    ship.center_ship()

    # 暂停
    sleep(0.5) ❺

def update_aliens(ai_settings, stats, screen, ship, aliens, bullets): ❻
    --snip--
    # 检测外星人和飞船碰撞
    if pygame.sprite.spritecollideany(ship, aliens):
        ship_hit(ai_settings, stats, screen, ship, aliens, bullets)

我们首先从模块time中导入了函数sleep()，以便使用它来让游戏暂停（见❶）。新函数ship_hit()在飞船被外星人撞到时作出响应。在这个函数内部，将余下的飞船数减1（见❷），然后清空编组aliens和bullets（见❸）。

接下来，我们创建一群新的外星人，并将飞船居中（见❹），稍后将在Ship类中添加方法center_ship()。最后，我们更新所有元素后（但在将修改显示到屏幕前）暂停，让玩家知道其飞船被撞到了（见❺）。屏幕将暂时停止变化，让玩家能够看到外星人撞到了飞船。函数sleep()执行完毕后，将接着执行函数update_screen()，将新的外星人群绘制到屏幕上。

我们还更新了update_aliens()的定义，使其包含形参stats、screen和bullets（见❻），让它能够在调用ship_hit()时传递这些值。

下面是新方法center_ship()，请将其添加到ship.py的末尾：

ship.py

    def center_ship(self):
        """让飞船在屏幕上居中"""
        self.center = self.screen_rect.centerx

为让飞船居中，我们将飞船的属性center设置为屏幕中心的x坐标，而该坐标是通过属性screen_rect获得的。

注意　我们根本没有创建多艘飞船，在整个游戏运行期间，我们都只创建了一个飞船实例，并在该飞船被撞到时将其居中。统计信息ships_left让我们知道飞船是否用完。

请运行这个游戏，射杀几个外星人，并让一个外星人撞到飞船。游戏暂停后，将出现一群新的外星人，而飞船将在屏幕底端居中。

13.6.3　有外星人到达屏幕底端

如果有外星人到达屏幕底端，我们将像有外星人撞到飞船那样作出响应。请添加一个执行这项任务的新函数，并将其命名为check_aliens_bottom()：

game_functions.py

def check_aliens_bottom(ai_settings, stats, screen, ship, aliens, bullets):
    """检查是否有外星人到达了屏幕底端"""
    screen_rect = screen.get_rect()
    for alien in aliens.sprites():
        if alien.rect.bottom >= screen_rect.bottom: ❶
            # 像飞船被撞到一样进行处理
            ship_hit(ai_settings, stats, screen, ship, aliens, bullets)
            break

def update_aliens(ai_settings, stats, screen, ship, aliens, bullets):
    --snip--
    # 检查是否有外星人到达屏幕底端
    check_aliens_bottom(ai_settings, stats, screen, ship, aliens, bullets) ❷

函数check_aliens_bottom()检查是否有外星人到达了屏幕底端。到达屏幕底端后，外星人的属性rect.bottom的值大于或等于屏幕的属性rect.bottom的值（见❶）。如果有外星人到达屏幕底端，我们就调用ship_hit()；只要检测到一个外星人到达屏幕底端，就无需检查其他外星人，因此我们在调用ship_hit()后退出循环。

我们在更新所有外星人的位置并检测是否有外星人和飞船发生碰撞后调用check_aliens_bottom()（见❷）。现在，每当有外星人撞到飞船或抵达屏幕底端时，都将出现一群新的外星人。

13.6.4　游戏结束

现在这个游戏看起来更完整了，但它永远都不会结束，只是ships_left不断变成更小的负数。下面在GameStats中添加一个作为标志的属性game_active，以便在玩家的飞船用完后结束游戏：

game_stats.py

    def __init__(self, settings):
        --snip--
        # 游戏刚启动时处于活动状态
        self.game_active = True

现在在ship_hit()中添加代码，在玩家的飞船都用完后将game_active设置为False：

game_functions.py

def ship_hit(ai_settings, stats, screen, ship, aliens, bullets):
    """响应飞船被外星人撞到"""
    if stats.ships_left > 0:
        # 将ships_left减1
        stats.ships_left -= 1
        --snip--
        #暂停一会儿
        sleep(0.5)

    else:
        stats.game_active = False

ship_hit()的大部分代码都没变。我们将原来的所有代码都移到了一个if语句块中，这条if语句检查玩家是否至少还有一艘飞船。如果是这样，就创建一群新的外星人，暂停一会儿，再接着往下执行。如果玩家没有飞船了，就将game_active设置为False。

13.7　确定应运行游戏的哪些部分

在alien_invasion.py中，我们需要确定游戏的哪些部分在任何情况下都应运行，哪些部分仅在游戏处于活动状态时才运行：

alien_invasion.py

    # 开始游戏主循环
    while True:
        gf.check_events(ai_settings, screen, ship, bullets)

        if stats.game_active:
            ship.update()
            gf.update_bullets(ai_settings, screen, ship, aliens, bullets)
            gf.update_aliens(ai_settings, stats, screen, ship, aliens, bullets)

        gf.update_screen(ai_settings, screen, ship, aliens, bullets)

在主循环中，在任何情况下都需要调用check_events()，即便游戏处于非活动状态时亦如此。例如，我们需要知道玩家是否按了Q键以退出游戏，或单击关闭窗口的按钮。我们还需要不断更新屏幕，以便在等待玩家是否选择开始新游戏时能够修改屏幕。其他的函数仅在游戏处于活动状态时才需要调用，因为游戏处于非活动状态时，我们不用更新游戏元素的位置。

现在，你运行这个游戏时，它将在飞船用完后停止不动。

动手试一试

13-6 游戏结束：在为完成练习13-5而编写的代码中，跟踪玩家有多少次未将球接着。在未接着球的次数到达三次后，结束游戏。

13.8　小结

在本章中，你学习了：如何在游戏中添加大量相同的元素，如创建一群外星人；如何使用嵌套循环来创建元素网格，还通过调用每个元素的方法update()移动了大量的元素；如何控制对象在屏幕上移动的方向，以及如何响应事件，如有外星人到达屏幕边缘；如何检测和响应子弹和外星人碰撞以及外星人和飞船碰撞；如何在游戏中跟踪统计信息，以及如何使用标志game_active来判断游戏是否结束了。

在与这个项目相关的最后一章中，我们将添加一个Play按钮，让玩家能够开始游戏，以及游戏结束后再玩。每当玩家消灭一群外星人后，我们都将加快游戏的节奏，并添加一个记分系统，得到一个极具可玩性的游戏！

记分

在本章中，我们将结束游戏《外星人入侵》的开发。我们将添加一个Play按钮，用于根据需要启动游戏以及在游戏结束后重启游戏。我们还将修改这个游戏，使其在玩家的等级提高时加快节奏，并实现一个记分系统。阅读本章后，你将掌握足够多的知识，能够开始编写随玩家等级提高而加大难度以及显示得分的游戏。

14.1　添加Play按钮

在本节中，我们将添加一个Play按钮，它在游戏开始前出现，并在游戏结束后再次出现，让玩家能够开始新游戏。

当前，这个游戏在玩家运行alien_invasion.py时就开始了。下面让游戏一开始处于非活动状态，并提示玩家单击Play按钮来开始游戏。为此，在game_stats.py中输入如下代码：

game_stats.py

    def __init__(self, ai_settings):
        """初始化统计信息"""
        self.ai_settings = ai_settings
        self.reset_stats()

        # 让游戏一开始处于非活动状态
        self.game_active = False

    def reset_stats(self):
        --snip--

现在游戏一开始将处于非活动状态，等我们创建Play按钮后，玩家才能开始游戏。

14.1.1　创建Button类

由于Pygame没有内置创建按钮的方法，我们创建一个Button类，用于创建带标签的实心矩形。你可以在游戏中使用这些代码来创建任何按钮。下面是Button类的第一部分，请将这个类保存为文件button.py：

button.py

import pygame.font

class Button():

    def __init__(self, ai_settings, screen, msg): ❶
        """初始化按钮的属性"""
        self.screen = screen
        self.screen_rect = screen.get_rect()

        # 设置按钮的尺寸和其他属性
        self.width, self.height = 200, 50 ❷
        self.button_color = (0, 255, 0)
        self.text_color = (255, 255, 255)
        self.font = pygame.font.SysFont(None, 48) ❸

        # 创建按钮的rect对象，并使其居中
        self.rect = pygame.Rect(0, 0, self.width, self.height) ❹
        self.rect.center = self.screen_rect.center

        # 按钮的标签只需创建一次
        self.prep_msg(msg) ❺

首先，我们导入了模块pygame.font，它让Pygame能够将文本渲染到屏幕上。方法__init__()接受参数self，对象ai_settings和screen，以及msg，其中msg是要在按钮中显示的文本（见❶）。我们设置按钮的尺寸（见❷），然后通过设置button_color让按钮的rect对象为亮绿色，并通过设置text_color让文本为白色。

在（见❸）处，我们指定使用什么字体来渲染文本。实参None让Pygame使用默认字体，而48指定了文本的字号。为让按钮在屏幕上居中，我们创建一个表示按钮的rect对象（见❹），并将其center属性设置为屏幕的center属性。

Pygame通过将你要显示的字符串渲染为图像来处理文本。在❺处，我们调用prep_msg()来处理这样的渲染。

prep_msg()的代码如下：

button.py

      def prep_msg(self, msg):
        """将msg渲染为图像，并使其在按钮上居中"""
        self.msg_image = self.font.render(msg, True, self.text_color, ❶
            self.button_color)
        self.msg_image_rect = self.msg_image.get_rect() ❷
        self.msg_image_rect.center = self.rect.center

方法prep_msg()接受实参self以及要渲染为图像的文本（msg）。调用font.render()将存储在msg中的文本转换为图像，然后将该图像存储在msg_image中（见❶）。方法font.render()还接受一个布尔实参，该实参指定开启还是关闭反锯齿功能（反锯齿让文本的边缘更平滑）。余下的两个实参分别是文本颜色和背景色。我们启用了反锯齿功能，并将文本的背景色设置为按钮的颜色（如果没有指定背景色，Pygame将以透明背景的方式渲染文本）。

在❷处，我们让文本图像在按钮上居中：根据文本图像创建一个rect，并将其center属性设置为按钮的center属性。

最后，我们创建方法draw_button()，通过调用它可将这个按钮显示到屏幕上：

button.py

    def draw_button(self):
        # 绘制一个用颜色填充的按钮，再绘制文本
        self.screen.fill(self.button_color, self.rect)
        self.screen.blit(self.msg_image, self.msg_image_rect)

我们调用screen.fill()来绘制表示按钮的矩形，再调用screen.blit()，并向它传递一幅图像以及与该图像相关联的rect对象，从而在屏幕上绘制文本图像。至此，Button类便创建好了。

14.1.2　在屏幕上绘制按钮

我们将使用Button类来创建一个Play按钮。鉴于只需要一个Play按钮，我们直接在alien_invasion.py中创建它，如下所示：

alien_invasion.py

--snip--
from game_stats import GameStats
from button import Button
--snip--

def run_game():
    --snip--
    pygame.display.set_caption("Alien Invasion")

    # 创建Play按钮
    play_button = Button(ai_settings, screen, "Play") ❶
    --snip--

    # 开始游戏主循环
    while True:
        --snip--
        gf.update_screen(ai_settings, screen, stats, ship, aliens, bullets, ❷
            play_button)

run_game()

我们导入Button类，并创建一个名为play_button的实例（见❶），然后我们将play_button传递给update_screen()，以便能够在屏幕更新时显示按钮（见❷）。

接下来，修改update_screen()，以便在游戏处于非活动状态时显示Play按钮：

game_functions.py

def update_screen(ai_settings, screen, stats, ship, aliens, bullets,
        play_button):
    """更新屏幕上的图像，并切换到新屏幕"""
    --snip--

    # 如果游戏处于非活动状态，就绘制Play按钮
    if not stats.game_active:
        play_button.draw_button()

    # 让最近绘制的屏幕可见
    pygame.display.flip()

为让Play按钮位于其他所有屏幕元素上面，我们在绘制其他所有游戏元素后再绘制这个按钮，然后切换到新屏幕。如果你现在运行这个游戏，将在屏幕中央看到一个Play按钮，如图14-1所示。

图14-1　游戏处于非活动状态时出现的Play按钮

14.1.3　开始游戏

为在玩家单击Play按钮时开始新游戏，需在game_functions.py中添加如下代码，以监视与这个按钮相关的鼠标事件：

game_functions.py

def check_events(ai_settings, screen, stats, play_button, ship, bullets):
    """响应按键和鼠标事件"""
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            --snip--
        elif event.type == pygame.MOUSEBUTTONDOWN: ❶
            mouse_x, mouse_y = pygame.mouse.get_pos() ❷
            check_play_button(stats, play_button, mouse_x, mouse_y) ❸

def check_play_button(stats, play_button, mouse_x, mouse_y):
    """在玩家单击Play按钮时开始新游戏"""
    if play_button.rect.collidepoint(mouse_x, mouse_y): ❹
        stats.game_active = True

我们修改了check_events()的定义，在其中添加了形参stats和play_button。我们将使用stats来访问标志game_active，并使用play_button来检查玩家是否单击了Play按钮。

无论玩家单击屏幕的什么地方，Pygame都将检测到一个MOUSEBUTTONDOWN事件（见❶），但我们只想让这个游戏在玩家用鼠标单击Play按钮时作出响应。为此，我们使用了pygame.mouse.get_pos()，它返回一个元组，其中包含玩家单击时鼠标的x和y坐标（见❷）。我们将这些值传递给函数check_play_button()（见❸），而这个函数使用collidepoint()检查鼠标单击位置是否在Play按钮的rect内（见❹）。如果是这样的，我们就将game_active设置为True，让游戏就此开始！

在alien_invasion.py中调用check_events()，需要传递另外两个实参——stats和play_button：

alien_invasion.py

    # 开始游戏主循环
    while True:
        gf.check_events(ai_settings, screen, stats, play_button, ship,
            bullets)
        --snip--

至此，你应该能够开始这个游戏了。游戏结束时，game_active应为False，并重新显示Play按钮。

14.1.4　重置游戏

前面编写的代码只处理了玩家第一次单击Play按钮的情况，而没有处理游戏结束的情况，因为没有重置导致游戏结束的条件。

为在玩家每次单击Play按钮时都重置游戏，需要重置统计信息、删除现有的外星人和子弹、创建一群新的外星人，并让飞船居中，如下所示：

game_functions.py

def check_play_button(ai_settings, screen, stats, play_button, ship, aliens,
        bullets, mouse_x, mouse_y):
    """在玩家单击Play按钮时开始新游戏"""
    if play_button.rect.collidepoint(mouse_x, mouse_y):
        # 重置游戏统计信息
        stats.reset_stats() ❶
        stats.game_active = True

        # 清空外星人列表和子弹列表
        aliens.empty() ❷
        bullets.empty()

        # 创建一群新的外星人，并让飞船居中
        create_fleet(ai_settings, screen, ship, aliens) ❸
        ship.center_ship()

我们更新了check_play_button()的定义，使其能够访问ai_settings、stats、ship、aliens和bullets。为重置在游戏期间发生了变化的设置以及刷新游戏的视觉元素，它需要这些对象。

在❶处，我们重置了游戏统计信息，给玩家提供了三艘新飞船。接下来，我们将game_active设置为True（这样，这个函数的代码执行完毕后，游戏就会开始），清空编组aliens和bullets（见❷），创建一群新的外星人，并将飞船居中（见❸）。

check_events()的定义需要修改，调用check_play_button()的代码亦如此：

game_functions.py

def check_events(ai_settings, screen, stats, play_button, ship, aliens,
        bullets):
    """响应按键和鼠标事件"""
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            --snip--
        elif event.type == pygame.MOUSEBUTTONDOWN:
            mouse_x, mouse_y = pygame.mouse.get_pos()
            check_play_button(ai_settings, screen, stats, play_button, ship, ❶
                aliens, bullets, mouse_x, mouse_y)

check_events()的定义需要形参aliens，以便将它传递给check_play_button()。接下来，我们修改了调用check_play_button()的代码，以将合适的实参传递给它（见❶）。

下面来修改alien_invasion.py中调用check_events()的代码，以将实参aliens传递给它：

alien_invasion.py

    # 开始游戏主循环
    while True:
        gf.check_events(ai_settings, screen, stats, play_button, ship,
            aliens, bullets)
        --snip--

现在，每当玩家单击Play按钮时，这个游戏都将正确地重置，让玩家想玩多少次就玩多少次！

14.1.5　将Play按钮切换到非活动状态

当前，Play按钮存在一个问题，那就是即便Play按钮不可见，玩家单击其原来所在的区域时，游戏依然会作出响应。游戏开始后，如果玩家不小心单击了Play按钮原来所处的区域，游戏将重新开始！

为修复这个问题，可让游戏仅在game_active为False时才开始：

game_functions.py

def check_play_button(ai_settings, screen, stats, play_button, ship, aliens,
        bullets, mouse_x, mouse_y):
    """玩家单击Play按钮时开始新游戏"""
    button_clicked = play_button.rect.collidepoint(mouse_x, mouse_y) ❶
    if button_clicked and not stats.game_active: ❷
        #重置游戏统计信息
        --snip--

标志button_clicked的值为True或False（见❶），仅当玩家单击了Play按钮且游戏当前处于非活动状态时，游戏才重新开始（见❷）。为测试这种行为，可开始新游戏，并不断地单击Play按钮原来所在的区域。如果一切都像预期的那样工作，单击Play按钮原来所处的区域应该没有任何影响。

14.1.6　隐藏光标

为让玩家能够开始游戏，我们要让光标可见，但游戏开始后，光标只会添乱。为修复这种问题，我们在游戏处于活动状态时让光标不可见：

game_functions.py

def check_play_button(ai_settings, screen, stats, play_button, ship, aliens,
        bullets, mouse_x, mouse_y):
    """在玩家单击Play按钮时开始新游戏"""
    button_clicked = play_button.rect.collidepoint(mouse_x, mouse_y)
    if button_clicked and not stats.game_active:
        # 隐藏光标
        pygame.mouse.set_visible(False)
        --snip--

通过向set_visible()传递False，让Pygame在光标位于游戏窗口内时将其隐藏起来。

游戏结束后，我们将重新显示光标，让玩家能够单击Play按钮来开始新游戏。相关的代码如下：

game_functions.py

def ship_hit(ai_settings, screen, stats, ship, aliens, bullets):
    """响应飞船被外星人撞到"""
    if stats.ships_left > 0:
        --snip--
    else:
        stats.game_active = False
        pygame.mouse.set_visible(True)

在ship_hit()中，我们在游戏进入非活动状态后，立即让光标可见。关注这样的细节让游戏显得更专业，也让玩家能够专注于玩游戏而不是费力搞明白用户界面。

动手试一试

14-1 按P开始新游戏：鉴于游戏《外星人入侵》使用键盘来控制飞船，最好让玩家也能够通过按键来开始游戏。请添加让玩家在按P时开始游戏的代码。也许这样做会有所帮助：将check_play_button()的一些代码提取出来，放到一个名为start_game()的函数中，并在check_play_button()和check_keydown_events()中调用这个函数。

14-2 射击练习：创建一个矩形，它在屏幕右边缘以固定的速度上下移动。然后，在屏幕左边缘创建一艘飞船，玩家可上下移动该飞船，并射击前述矩形目标。添加一个用于开始游戏的Play按钮，在玩家三次未击中目标时结束游戏，并重新显示Play按钮，让玩家能够通过单击该按钮来重新开始游戏。

14.2　提高等级

当前，将整群外星人都消灭干净后，玩家将提高一个等级，但游戏的难度并没有变。下面来增加一点趣味性：每当玩家将屏幕上的外星人都消灭干净后，加快游戏的节奏，让游戏玩起来更难。

14.2.1　修改速度设置

我们首先重新组织Settings类，将游戏设置划分成静态的和动态的两组。对于随着游戏进行而变化的设置，我们还确保它们在开始新游戏时被重置。settings.py的方法__init__()如下：

settings.py

    def __init__(self):
        """初始化游戏的静态设置"""
        # 屏幕设置
        self.screen_width = 1200
        self.screen_height = 800
        self.bg_color = (230, 230, 230)

        # 飞船设置
        self.ship_limit = 3

        # 子弹设置
        self.bullet_width = 3
        self.bullet_height = 15
        self.bullet_color = 60, 60, 60
        self.bullets_allowed = 3

        # 外星人设置
        self.fleet_drop_speed = 10

        # 以什么样的速度加快游戏节奏
        self.speedup_scale = 1.1 ❶

        self.initialize_dynamic_settings() ❷

我们依然在__init__()中初始化静态设置。在❶处，我们添加了设置speedup_scale，用于控制游戏节奏的加快速度：2表示玩家每提高一个等级，游戏的节奏就翻倍；1表示游戏节奏始终不变。将其设置为1.1能够将游戏节奏提高到够快，让游戏既有难度，又并非不可完成。最后，我们调用initialize_dynamic_settings()，以初始化随游戏进行而变化的属性（见❷）。

initialize_dynamic_settings()的代码如下：

settings.py

    def initialize_dynamic_settings(self):
        """初始化随游戏进行而变化的设置"""
        self.ship_speed_factor = 1.5
        self.bullet_speed_factor = 3
        self.alien_speed_factor = 1

        # fleet_direction为1表示向右；为-1表示向左
        self.fleet_direction = 1

这个方法设置了飞船、子弹和外星人的初始速度。随游戏的进行，我们将提高这些速度，而每当玩家开始新游戏时，都将重置这些速度。在这个方法中，我们还设置了fleet_direction，使得游戏刚开始时，外星人总是向右移动。每当玩家提高一个等级时，我们都使用increase_speed()来提高飞船、子弹和外星人的速度：

settings.py

    def increase_speed(self):
        """提高速度设置"""
        self.ship_speed_factor *= self.speedup_scale
        self.bullet_speed_factor *= self.speedup_scale
        self.alien_speed_factor *= self.speedup_scale

为提高这些游戏元素的速度，我们将每个速度设置都乘以speedup_scale的值。

在check_bullet_alien_collisions()中，我们在整群外星人都被消灭后调用increase_speed()来加快游戏的节奏，再创建一群新的外星人：

game_functions.py

def check_bullet_alien_collisions(ai_settings, screen, ship, aliens, bullets):
    --snip--
    if len(aliens) == 0:
        # 删除现有的子弹，加快游戏节奏，并创建一群新的外星人
        bullets.empty()
        ai_settings.increase_speed()
        create_fleet(ai_settings, screen, ship, aliens)

通过修改速度设置ship_speed_factor、alien_speed_factor和bullet_speed_factor的值，足以加快整个游戏的节奏！

14.2.2　重置速度

每当玩家开始新游戏时，我们都需要将发生了变化的设置重置为初始值，否则新游戏开始时，速度设置将是前一次游戏增加了的值：

game_functions.py

def check_play_button(ai_settings, screen, stats, play_button, ship, aliens,
        bullets, mouse_x, mouse_y):
    """在玩家单击Play按钮时开始新游戏"""
    button_clicked = play_button.rect.collidepoint(mouse_x, mouse_y)
    if button_clicked and not stats.game_active:
        # 重置游戏设置
        ai_settings.initialize_dynamic_settings()

        # 隐藏光标
        pygame.mouse.set_visible(False)
        --snip--

现在，游戏《外星人入侵》玩起来更有趣，也更有挑战性。每当玩家将屏幕上的外星人消灭干净后，游戏都将加快节奏，因此难度会更大些。如果游戏的难度提高得太快，可降低settings.speedup_scale的值；如果游戏的挑战性不足，可稍微提高这个设置的值。找出这个设置的最佳值，让难度的提高速度相对合理：一开始的几群外星人很容易消灭干净；接下来的几群消灭起来有一定难度，但也不是不可能；而要将更靠后的外星人群消灭干净几乎不可能。

动手试一试

14-3 有一定难度的射击练习：以你为完成练习14-2而做的工作为基础，让标靶的移动速度随游戏进行而加快，并在玩家单击Play按钮时将其重置为初始值。

14.3　记分

下面来实现一个记分系统，以实时地跟踪玩家的得分，并显示最高得分、当前等级和余下的飞船数。

得分是游戏的一项统计信息，因此我们在GameStats中添加一个score属性：

game_stats.py

class GameStats():
    --snip--
    def reset_stats(self):
        """初始化随游戏进行可能变化的统计信息"""
        self.ships_left = self.ai_settings.ship_limit
        self.score = 0

为在每次开始游戏时都重置得分，我们在reset_stats()而不是__init__()中初始化score。

14.3.1　显示得分

为在屏幕上显示得分，我们首先创建一个新类Scoreboard。就当前而言，这个类只显示当前得分，但后面我们也将使用它来显示最高得分、等级和余下的飞船数。下面是这个类的前半部分，它被保存为文件scoreboard.py：

scoreboard.py

import pygame.font

class Scoreboard():
    """显示得分信息的类"""

    def __init__(self, ai_settings, screen, stats): ❶
        """初始化显示得分涉及的属性"""
        self.screen = screen
        self.screen_rect = screen.get_rect()
        self.ai_settings = ai_settings
        self.stats = stats

        # 显示得分信息时使用的字体设置
        self.text_color = (30, 30, 30) ❷
        self.font = pygame.font.SysFont(None, 48) ❸

        # 准备初始得分图像
        self.prep_score() ❹

由于Scoreboard在屏幕上显示文本，因此我们首先导入模块pygame.font。接下来，我们在__init__()中包含形参ai_settings、screen和stats，让它能够报告我们跟踪的值（见❶）。然后，我们设置文本颜色（见❷）并实例化一个字体对象（见❸）。

为将要显示的文本转换为图像，我们调用了prep_score()（见❹），其定义如下：

scoreboard.py

    def prep_score(self):
      """将得分转换为一幅渲染的图像"""
        score_str = str(self.stats.score) ❶
        self.score_image = self.font.render(score_str, True, self.text_color, ❷
            self.ai_settings.bg_color)

        # 将得分放在屏幕右上角
        self.score_rect = self.score_image.get_rect() ❸
        self.score_rect.right = self.screen_rect.right - 20 ❹
        self.score_rect.top = 20 ❺

在prep_score()中，我们首先将数字值stats.score转换为字符串（见❶），再将这个字符串传递给创建图像的render()（见❷）。为在屏幕上清晰地显示得分，我们向render()传递了屏幕背景色，以及文本颜色。

我们将得分放在屏幕右上角，并在得分增大导致这个数字更宽时让它向左延伸。为确保得分始终锚定在屏幕右边，我们创建了一个名为score_rect的rect（见❸），让其右边缘与屏幕右边缘相距20像素（见❹），并让其上边缘与屏幕上边缘也相距20像素（见❺）。

最后，我们创建方法show_score()，用于显示渲染好的得分图像：

scoreboard.py

    def show_score(self):
        """在屏幕上显示得分"""
        self.screen.blit(self.score_image, self.score_rect)

这个方法将得分图像显示到屏幕上，并将其放在score_rect指定的位置。

14.3.2　创建记分牌

为显示得分，我们在alien_invasion.py中创建一个Scoreboard实例：

alien_invasion.py

--snip--
from game_stats import GameStats
from scoreboard import Scoreboard
--snip--
def run_game():
    --snip--
    # 创建存储游戏统计信息的实例，并创建记分牌
    stats = GameStats(ai_settings)
    sb = Scoreboard(ai_settings, screen, stats) ❶
    --snip--
    # 开始游戏主循环
    while True:
        --snip--
        gf.update_screen(ai_settings, screen, stats, sb, ship, aliens, ❷
            bullets, play_button)

run_game()

我们导入新创建的类Scoreboard，并在创建实例stats后创建了一个名为sb的Scoreboard实例（见❶）。接下来，我们将sb传递给update_screen()，让它能够在屏幕上显示得分（见❷）。

为显示得分，将update_screen()修改成下面这样：

game_functions.py

def update_screen(ai_settings, screen, stats, sb, ship, aliens, bullets,
        play_button):
    _--snip--_
    # 显示得分
    sb.show_score()

    # 如果游戏处于非活动状态，就显示Play按钮
    if not stats.game_active:
        play_button.draw_button()

    # 让最近绘制的屏幕可见
    pygame.display.flip()

我们在update_screen()的形参列表中添加了sb，并在绘制Play按钮前调用show_score。

如果现在运行这个游戏，你将在屏幕右上角看到0（当前，我们只想在进一步开发记分系统前确认得分出现在正确的地方）。图14-2显示了游戏开始前的得分。

图14-2　得分出现在屏幕右上角

下面来指定每个外星人值多少点！

14.3.3　在外星人被消灭时更新得分

为在屏幕上实时地显示得分，每当有外星人被击中时，我们都更新stats.score的值，再调用prep_score()更新得分图像。但在此之前，我们需要指定玩家每击落一个外星人都将得到多少个点：

settings.py

    def initialize_dynamic_settings(self):
        --snip--

        # 记分
        self.alien_points = 50

随着游戏的进行，我们将提高每个外星人值的点数。为确保每次开始新游戏时这个值都会被重置，我们在initialize_dynamic_settings()中设置它。

在check_bullet_alien_collisions()中，每当有外星人被击落时，都更新得分：

game_functions.py

def check_bullet_alien_collisions(ai_settings, screen, stats, sb, ship,
        aliens, bullets):
    """响应子弹和外星人发生碰撞"""
    # 删除发生碰撞的子弹和外星人
    collisions = pygame.sprite.groupcollide(bullets, aliens, True, True)

    if collisions:
        stats.score += ai_settings.alien_points ❶
        sb.prep_score()
    --snip--

我们更新check_bullet_alien_collisions()的定义，在其中包含了形参stats和sb，让它能够更新得分和记分牌。有子弹撞到外星人时，Pygame返回一个字典（collisions）。我们检查这个字典是否存在，如果存在，就将得分加上一个外星人值的点数（见❶）。接下来，我们调用prep_score()来创建一幅显示最新得分的新图像。

我们需要修改update_bullets()，确保在函数之间传递合适的实参：

game_functions.py

def update_bullets(ai_settings, screen, stats, sb, ship, aliens, bullets):
    """更新子弹的位置，并删除已消失的子弹"""
    --snip--

    check_bullet_alien_collisions(ai_settings, screen, stats, sb, ship,
        aliens, bullets)

在update_bullets()的定义中，需要新增形参stats和sb，而调用check_bullet_alien_collisions()时，也需要传递实参stats和sb。

我们还需要修改主while循环中调用update_bullets()的代码：

alien_invasion.py

    # 开始游戏主循环
    while True:
        gf.check_events(ai_settings, screen, stats, play_button, ship,
            aliens, bullets)
        if stats.game_active:
            ship.update()
            gf.update_bullets(ai_settings, screen, stats, sb, ship, aliens,
                bullets)
            --snip--

调用update_bullets()时，需要传递实参stats和sb。

如果你现在运行这个游戏，得分将不断增加！

14.3.4　将消灭的每个外星人的点数都计入得分

当前，我们的代码可能遗漏了一些被消灭的外星人。例如，如果在一次循环中有两颗子弹射中了外星人，或者因子弹更宽而同时击中了多个外星人，玩家将只能得到一个被消灭的外星人的点数。为修复这种问题，我们来调整检测子弹和外星人碰撞的方式。

在check_bullet_alien_collisions()中，与外星人碰撞的子弹都是字典collisions中的一个键；而与每颗子弹相关的值都是一个列表，其中包含该子弹撞到的外星人。我们遍历字典collisions，确保将消灭的每个外星人的点数都记入得分：

game_functions.py

def check_bullet_alien_collisions(ai_settings, screen, stats, sb, ship,
        aliens, bullets):
    --snip--
    if collisions:
        for aliens in collisions.values(): ❶
            stats.score += ai_settings.alien_points * len(aliens)
            sb.prep_score()
    --snip--

如果字典collisions存在，我们就遍历其中的所有值。别忘了，每个值都是一个列表，包含被同一颗子弹击中的所有外星人。对于每个列表，都将一个外星人的点数乘以其中包含的外星人数量，并将结果加入到当前得分中。为测试这一点，请将子弹宽度改为300像素，并核实你得到了更宽的子弹击中的每个外星人的点数，再将子弹宽度恢复到正常值。

14.3.5　提高点数

玩家每提高一个等级，游戏都变得更难，因此处于较高的等级时，外星人的点数应更高。为实现这种功能，我们添加一些代码，以在游戏节奏加快时提高点数：

settings.py

class Settings():
    """存储游戏《外星人入侵》的所有设置的类"""

    def __init__(self):
        --snip--
        # 加快游戏节奏的速度
        self.speedup_scale = 1.1
        # 外星人点数的提高速度
        self.score_scale = 1.5 ❶

        self.initialize_dynamic_settings()

    def increase_speed(self):
        """提高速度设置和外星人点数"""
        self.ship_speed_factor *= self.speedup_scale
        self.bullet_speed_factor *= self.speedup_scale
        self.alien_speed_factor *= self.speedup_scale

        self.alien_points = int(self.alien_points * self.score_scale) ❷

我们定义了点数提高的速度，并称之为score_scale（见❶）。很小的节奏加快速度（1.1）让游戏很快就变得极具挑战性，但为让记分发生显著的变化，需要将点数的提高速度设置为更大的值（1.5）。现在，我们在加快游戏节奏的同时，提高了每个外星人的点数。为让点数为整数，我们使用了函数int()。

为显示外星人的点数，我们在Settings的方法increase_speed()中添加了一条print语句：

settings.py

    def increase_speed(self):
        --snip--
        self.alien_points = int(self.alien_points * self.score_scale)
        print(self.alien_points)

现在每当提高一个等级时，你都会在终端窗口看到新的点数值。

注意　确认点数在不断增加后，一定要删除这条print语句，否则它可能会影响游戏的性能以及分散玩家的注意力。

14.3.6　将得分圆整

大多数街机风格的射击游戏都将得分显示为10的整数倍，下面让我们的记分系统遵循这个原则。我们还将设置得分的格式，在大数字中添加用逗号表示的千位分隔符。我们在Scoreboard中执行这种修改：

scoreboard.py

    def prep_score(self):
        """将得分转换为渲染的图像"""
        rounded_score = int(round(self.stats.score, -1)) ❶
        score_str = "{:,}".format(rounded_score) ❷
        self.score_image = self.font.render(score_str, True, self.text_color,
            self.ai_settings.bg_color)
        --snip--

函数round()通常让小数精确到小数点后多少位，其中小数位数是由第二个实参指定的。然而，如果将第二个实参指定为负数，round()将圆整到最近的10、100、1000等整数倍。❶处的代码让Python将stats.score的值圆整到最近的10的整数倍，并将结果存储到rounded_score中。

注意　在Python 2.7中，round()总是返回一个小数值，因此我们使用int()来确保报告的得分为整数。如果你使用的是Python 3，可省略对int()的调用。

❷处使用了一个字符串格式设置指令，它让Python将数值转换为字符串时在其中插入逗号，例如，输出1,000,000而不是1000000。如果你现在运行这个游戏，看到的将是10的整数倍的整洁得分，即便得分很高亦如此，如图14-3所示。

图14-3　得分为10的整数倍，并将逗号用作千分位分隔符

14.3.7　最高得分

每个玩家都想超过游戏的最高得分记录。下面来跟踪并显示最高得分，给玩家提供要超越的目标。我们将最高得分存储在GameStats中：

game_stats.py

    def __init__(self, ai_settings):
        --snip--
        # 在任何情况下都不应重置最高得分
        self.high_score = 0

鉴于在任何情况下都不会重置最高得分，我们在__init__()中而不是reset_stats()中初始化high_score。

下面来修改Scoreboard以显示最高得分。先来修改方法__init__()：

scoreboard.py

    def __init__(self, ai_settings, screen, stats):
        --snip--
        # 准备包含最高得分和当前得分的图像
        self.prep_score()
        self.prep_high_score() ❶

最高得分将与当前得分分开显示，因此我们需要编写一个新方法prep_high_score()，用于准备包含最高得分的图像（见❶）。

方法prep_high_score()的代码如下：

scoreboard.py

    def prep_high_score(self):
        """将最高得分转换为渲染的图像"""
        high_score = int(round(self.stats.high_score, -1)) ❶
        high_score_str = "{:,}".format(high_score) ❷
        self.high_score_image = self.font.render(high_score_str, True, ❸
            self.text_color, self.ai_settings.bg_color)

        #将最高得分放在屏幕顶部中央
        self.high_score_rect = self.high_score_image.get_rect()
        self.high_score_rect.centerx = self.screen_rect.centerx ❹
        self.high_score_rect.top = self.score_rect.top ❺

我们将最高得分圆整到最近的10的整数倍（见❶），并添加了用逗号表示的千分位分隔符（见❷）。然后，我们根据最高得分生成一幅图像（见❸），使其水平居中（见❹），并将其top属性设置为当前得分图像的top属性（见❺）。

现在，方法show_score()需要在屏幕右上角显示当前得分，并在屏幕顶部中央显示最高得分：

scoreboard.py

    def show_score(self):
        """在屏幕上显示当前得分和最高得分"""
        self.screen.blit(self.score_image, self.score_rect)
        self.screen.blit(self.high_score_image, self.high_score_rect)

为检查是否诞生了新的最高得分，我们在game_functions.py中添加一个新函数check_high_score()：

game_functions.py

def check_high_score(stats, sb):
    """检查是否诞生了新的最高得分"""
    if stats.score > stats.high_score: ❶
        stats.high_score = stats.score
        sb.prep_high_score()

函数check_high_score()包含两个形参：stats和sb。它使用stats来比较当前得分和最高得分，并在必要时使用sb来修改最高得分图像。在❶处，我们比较当前得分和最高得分，如果当前得分更高，就更新high_score的值，并调用prep_high_score()来更新包含最高得分的图像。

在check_bullet_alien_collisions()中，每当有外星人被消灭，都需要在更新得分后调用check_high_score()：

game_functions.py

def check_bullet_alien_collisions(ai_settings, screen, stats, sb, ship,
        aliens, bullets):
    --snip--
    if collisions:
        for aliens in collisions.values():
            stats.score += ai_settings.alien_points * len(aliens)
            sb.prep_score()
        check_high_score(stats, sb)
    --snip--

字典collisions存在时，我们根据消灭了多少外星人来更新得分，再调用check_high_score()。

第一次玩这款游戏时，当前得分就是最高得分，因此两个地方显示的都是当前得分。但再次开始这个游戏时，最高得分出现在中央，而当前得分出现在右边，如图14-4所示。

图14-4　最高得分显示在屏幕顶部中央

14.3.8　显示等级

为在游戏中显示玩家的等级，首先需要在GameStats中添加一个表示当前等级的属性。为确保每次开始新游戏时都重置等级，在reset_stats()中初始化它：

game_stats.py

    def reset_stats(self):
        """初始化随游戏进行可能变化的统计信息"""
        self.ships_left = self.ai_settings.ship_limit
        self.score = 0
        self.level = 1

为让Scoreboard能够在当前得分下方显示当前等级，我们在__init__()中调用了一个新方法prep_level()：

scoreboard.py

    def __init__(self, ai_settings, screen, stats):
        --snip--

        # 准备包含得分的初始图像
        self.prep_score()
        self.prep_high_score()
        self.prep_level()

prep_level()的代码如下：

scoreboard.py

    def prep_level(self):
        """将等级转换为渲染的图像"""
        self.level_image = self.font.render(str(self.stats.level), True, ❶
                self.text_color, self.ai_settings.bg_color)

        # 将等级放在得分下方
        self.level_rect = self.level_image.get_rect()
        self.level_rect.right = self.score_rect.right ❷
        self.level_rect.top = self.score_rect.bottom + 10 ❸

方法prep_level()根据存储在stats.level中的值创建一幅图像（见❶），并将其right属性设置为得分的right属性（见❷）。然后，将top属性设置为比得分图像的bottom属性大10像素，以便在得分和等级之间留出一定的空间（见❸）。

我们还需要更新show_score()：

scoreboard.py

    def show_score(self):
        """在屏幕上显示飞船和得分"""
        self.screen.blit(self.score_image, self.score_rect)
        self.screen.blit(self.high_score_image, self.high_score_rect)
        self.screen.blit(self.level_image, self.level_rect)

在这个方法中，添加了一行在屏幕上显示等级图像的代码。

我们在check_bullet_alien_collisions()中提高等级，并更新等级图像：

game_functions.py

def check_bullet_alien_collisions(ai_settings, screen, stats, sb, ship,
        aliens, bullets):
    --snip--
    if len(aliens) == 0:
        # 如果整群外星人都被消灭，就提高一个等级
        bullets.empty()
        ai_settings.increase_speed()

        # 提高等级
        stats.level += 1 ❶
        sb.prep_level() ❷

        create_fleet(ai_settings, screen, ship, aliens)

如果整群外星人都被消灭，我们就将stats.level的值加1（见❶），并调用prep_level()，以确保正确地显示新等级（见❷）。

为确保开始新游戏时更新记分和等级图像，在按钮Play被单击时触发重置：

game_functions.py

def check_play_button(ai_settings, screen, stats, sb, play_button, ship,
        aliens, bullets, mouse_x, mouse_y):
    """在玩家单击Play按钮时开始新游戏"""
    button_clicked = play_button.rect.collidepoint(mouse_x, mouse_y)
    if button_clicked and not stats.game_active:
        --snip--

        # 重置游戏统计信息
        stats.reset_stats()
        stats.game_active = True

        # 重置记分牌图像
        sb.prep_score() ❶
        sb.prep_high_score()
        sb.prep_level()

        # 清空外星人列表和子弹列表
        aliens.empty()
        bullets.empty()

        --snip--

check_play_button()的定义需要包含对象sb。为重置记分牌图像，我们在重置相关游戏设置后调用prep_score()、prep_high_score()和prep_level()（见❶）。

在check_events()中，现在需要向check_play_button()传递sb，让它能够访问记分牌对象：

game_functions.py

def check_events(ai_settings, screen, stats, sb, play_button, ship, aliens,
        bullets):
    """响应按键和鼠标事件"""
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            --snip--
        elif event.type == pygame.MOUSEBUTTONDOWN:
            mouse_x, mouse_y = pygame.mouse.get_pos()
            check_play_button(ai_settings, screen, stats, sb, play_button, ❶
                ship, aliens, bullets, mouse_x, mouse_y)

check_events()的定义需要包含形参sb，这样调用check_play_button()时，才能将sb作为实参传递给它（见❶）。

最后，更新alien_invasion.py中调用check_events()的代码，也向它传递sb：

alien_invasion.py

    # 开始游戏主循环
    while True:
        gf.check_events(ai_settings, screen, stats, sb, play_button, ship,
            aliens, bullets)
        --snip--

现在你可以知道升到多少级了，如图14-5所示。

图14-5　当前等级显示在当前得分的正下方

注意　在一些经典游戏中，得分带标签，如Score、High Score和Level。我们没有显示这些标签，因为开始玩这款游戏后，每个数字的含义将一目了然。要包含这些标签，只需在Scoreboard中调用font.render()前，将它们添加到得分字符串中即可。

14.3.9　显示余下的飞船数

最后，我们来显示玩家还有多少艘飞船，但使用图形而不是数字。为此，我们在屏幕左上角绘制飞船图像来指出还余下多少艘飞船，就像众多经典的街机游戏那样。

首先，需要让Ship继承Sprite，以便能够创建飞船编组：

ship.py

import pygame
from pygame.sprite import Sprite

class Ship(Sprite): ❶

    def __init__(self, ai_settings, screen):
        """初始化飞船，并设置其起始位置"""
        super(Ship, self).__init__() ❷
        --snip--

在这里，我们导入了Sprite，让Ship继承Sprite （见❶），并在__init__()的开头就调用了super()（见❷）。

接下来，需要修改Scoreboard，在其中创建一个可供显示的飞船编组。下面是其中的import语句和方法__init__()：

scoreboard.py

import pygame.font
from pygame.sprite import Group

from ship import Ship

class Scoreboard():
    """报告得分信息的类"""

    def __init__(self, ai_settings, screen, stats):
        --snip--
        self.prep_level()
        self.prep_ships()
       --snip--

鉴于要创建一个飞船编组，我们导入Group和Ship类。调用prep_level()后，我们调用了prep_ships()。

prep_ships()的代码如下：

scoreboard.py

    def prep_ships(self):
        """显示还余下多少艘飞船"""
        self.ships = Group() ❶
        for ship_number in range(self.stats.ships_left): ❷
            ship = Ship(self.ai_settings, self.screen)
            ship.rect.x = 10 + ship_number * ship.rect.width ❸
            ship.rect.y = 10 ❹
            self.ships.add(ship) ❺

方法prep_ships()创建一个空编组self.ships，用于存储飞船实例（见❶）。为填充这个编组，根据玩家还有多少艘飞船运行一个循环相应的次数（见❷）。在这个循环中，我们创建一艘新飞船，并设置其x坐标，让整个飞船编组都位于屏幕左边，且每艘飞船的左边距都为10像素（见❸）。我们还将y坐标设置为离屏幕上边缘10像素，让所有飞船都与得分图像对齐（见❹）。最后，我们将每艘新飞船都添加到编组ships中（见❺）。

现在需要在屏幕上绘制飞船了：

scoreboard.py

    def show_score(self):
        --snip--
        self.screen.blit(self.level_image, self.level_rect)
        # 绘制飞船
        self.ships.draw(self.screen)

为在屏幕上显示飞船，我们对编组调用了draw()。Pygame将绘制每艘飞船。

为在游戏开始时让玩家知道他有多少艘飞船，我们在开始新游戏时调用prep_ships()。这是在game_functions.py的check_play_button()中进行的：

game_functions.py

def check_play_button(ai_settings, screen, stats, sb, play_button, ship,
        aliens, bullets, mouse_x, mouse_y):
    """在玩家单击Play按钮时开始新游戏"""
    button_clicked = play_button.rect.collidepoint(mouse_x, mouse_y)
    if button_clicked and not stats.game_active:
        --snip--
        # 重置记分牌图像
        sb.prep_score()
        sb.prep_high_score()
        sb.prep_level()
        sb.prep_ships()
        --snip--

我们还在飞船被外星人撞到时调用prep_ships()，从而在玩家损失一艘飞船时更新飞船图像：

game_functions.py

def update_aliens(ai_settings, screen, stats, sb, ship, aliens, bullets): ❶
    --snip--
    # 检测外星人和飞船之间的碰撞
    if pygame.sprite.spritecollideany(ship, aliens):
        ship_hit(ai_settings, screen, stats, sb, ship, aliens, bullets) ❷

    # 检查是否有外星人抵达屏幕底端
    check_aliens_bottom(ai_settings, screen, stats, sb, ship, aliens, bullets) ❸

def ship_hit(ai_settings, screen, stats, sb, ship, aliens, bullets): ❹
    """响应被外星人撞到的飞船"""
    if stats.ships_left > 0:
        # 将ships_left减1
        stats.ships_left -= 1

        # 更新记分牌
        sb.prep_ships() ❺

        # 清空外星人列表和子弹列表
        --snip--

首先，我们在update_aliens()的定义中添加了形参sb（见❶）。然后，我们向ship_hit()（见❷）和check_aliens_bottom()（见❸）都传递了sb，让它们都能够访问记分牌对象。

接下来，我们更新了ship_hit()的定义，使其包含形参sb（见❹）。我们在将ships_left的值减1后调用了prep_ships()（见❺），这样每次损失了飞船时，显示的飞船数都是正确的。

在check_aliens_bottom()中需要调用ship_hit()，因此对这个函数进行更新：

game_functions.py

def check_aliens_bottom(ai_settings, screen, stats, sb, ship, aliens,
        bullets):
    """检查是否有外星人抵达屏幕底端"""
    screen_rect = screen.get_rect()
    for alien in aliens.sprites():
        if alien.rect.bottom >= screen_rect.bottom:
            # 像飞船被外星人撞到一样处理
            ship_hit(ai_settings, screen, stats, sb, ship, aliens, bullets)
            break

现在，check_aliens_bottom()包含形参sb，并在调用ship_hit()时传递了实参sb。

最后，在alien_invasion.py中修改调用update_aliens()的代码，向它传递实参sb：

alien_invasion.py

    # 开始游戏主循环
    while True:
        --snip--
        if stats.game_active:
            ship.update()
            gf.update_bullets(ai_settings, screen, stats, sb, ship, aliens,
                bullets)
            gf.update_aliens(ai_settings, screen, stats, sb, ship, aliens,
                bullets)
            --snip--

图14-6显示了完整的记分系统，它在屏幕左上角指出了还余下多少艘飞船。

图14-6　游戏《外星人入侵》的完整记分系统

动手试一试

14-4 历史最高分：每当玩家关闭并重新开始游戏《外星人入侵》时，最高分都将被重置。请修复这个问题，调用sys.exit()前将最高分写入文件，并当在GameStats中初始化最高分时从文件中读取它。

14-5 重构：找出执行了多项任务的函数和方法，对它们进行重构，以让代码高效而有序。例如，对于check_bullet_alien_collisions()，将其中在外星人群被消灭干净时开始新等级的代码移到一个名为start_new_level()的函数中；又比如，对于Scoreboard的方法__init__()，将其中调用四个不同方法的代码移到一个名为prep_images()的方法中，以缩短方法__init__()。如果你重构了check_play_button()，方法prep_images()也可为check_play_button()或start_game()提供帮助。

注意　重构项目前，请阅读附录D，了解如果重构时引入了bug，如何将项目恢复到可正确运行的状态。

14-6 扩展游戏《外星人入侵》：想想如何扩展游戏《外星人入侵》。例如，可让外星人也能够向飞船射击，或者添加盾牌，让飞船躲到它后面，使得只有从两边射来的子弹才能摧毁飞船。另外，还可以使用像pygame.mixer这样的模块来添加音效，如爆炸声和射击声。

14.4　小结

在本章中，你学习了如何创建用于开始新游戏的Play按钮，如何检测鼠标事件，以及在游戏处于活动状态时如何隐藏光标。你可以利用学到的知识在游戏中创建其他按钮，如用于显示玩法说明的Help按钮。你还学习了如何随游戏的进行调整其节奏，如何实现记分系统，以及如何以文本和非文本方式显示信息。

项目2　数据可视化

第 15 章　生成数据

数据可视化指的是通过可视化表示来探索数据，它与数据挖掘紧密相关，而数据挖掘指的是使用代码来探索数据集的规律和关联。数据集可以是用一行代码就能表示的小型数字列表，也可以是数以吉字节的数据。

漂亮地呈现数据关乎的并非仅仅是漂亮的图片。以引人注目的简洁方式呈现数据，让观看者能够明白其含义，发现数据集中原本未意识到的规律和意义。

所幸即便没有超级计算机，也能够可视化复杂的数据。鉴于Python的高效性，使用它在笔记本电脑上就能快速地探索由数百万个数据点组成的数据集。数据点并非必须是数字，利用本书前半部分介绍的基本知识，也可以对非数字数据进行分析。

在基因研究、天气研究、政治经济分析等众多领域，大家都使用Python来完成数据密集型工作。数据科学家使用Python编写了一系列令人印象深刻的可视化和分析工具，其中很多也可供你使用。最流行的工具之一是matplotlib，它是一个数学绘图库，我们将使用它来制作简单的图表，如折线图和散点图。然后，我们将基于随机漫步概念生成一个更有趣的数据集——根据一系列随机决策生成的图表。

我们还将使用Pygal包，它专注于生成适合在数字设备上显示的图表。通过使用Pygal，可在用户与图表交互时突出元素以及调整其大小，还可轻松地调整整个图表的尺寸，使其适合在微型智能手表或巨型显示器上显示。我们将使用Pygal以各种方式探索掷骰子的结果。

15.1　安装matplotlib

首先，需要安装matplotlib，我们将使用它来制作开始的几个图表。如果你还未使用过pip，请参阅12.2.1节。

15.1.1　在Linux系统中安装matplotlib

如果你使用的是系统自带的Python版本，可使用系统的包管理器来安装matplotlib，为此只需执行一行命令：

$ sudo apt-get install python3-matplotlib

如果你使用的是Python 2.7，请执行如下命令：

$ sudo apt-get install python-matplotlib

如果你安装了较新的Python版本，就必须安装matplotlib依赖的一些库：

$ sudo apt-get install python3.5-dev python3.5-tk tk-dev
$ sudo apt-get install libfreetype6-dev g++

再使用pip来安装matplotlib：

$ pip install --user matplotlib

15.1.2　在OS X系统中安装matplotlib

Apple的标准Python安装自带了matplotlib。要检查系统是否安装了matplotlib，可打开一个终端会话并尝试导入matplotlib。如果系统没有自带matplotlib，且你的Python是使用Homebrew安装的，则可以像下面这样安装matplotlib：

$ pip install --user matplotlib

注意　安装包时可能需要使用pip3，而不是pip。另外，如果这个命令不管用，你可能需要删除标志--user。

15.1.3　在Windows系统中安装matplotlib

在Windows系统中，首先需要安装Visual Studio。为此，请访问https://dev.windows.com/，单击Downloads，再查找Visual Studio Community——一组免费的Windows开发工具。请下载并运行该安装程序。

接下来，需要下载matplotlib安装程序。为此，请访问https://pypi.python.org/pypi/matplotlib/，并查找与你使用的Python版本匹配的wheel文件（扩展名为.whl的文件）。例如，如果你使用的是32位的Python 3.5，则需要下载matplotlib-1.4.3-cp35-none-win32.whl。

注意　如果找不到与你安装的Python版本匹配的文件，请去http://www.lfd.uci.edu/-gohlke/pythonlibs/#matplotlib看看，这个网站发布安装程序的时间通常比matplotlib官网早些。

将这个.whl文件复制到你的项目文件夹，打开一个命令窗口，并切换到该项目文件夹，再使用pip来安装matplotlib：

> cd python_work
python_work> python -m pip install --user matplotlib-1.4.3-cp35-none-win32.whl

15.1.4　测试matplotlib

安装必要的包后，对安装进行测试。为此，首先使用命令python或python3启动一个终端会话，再尝试导入matplotlib：

$ python3
>>> import matplotlib
>>>

如果没有出现任何错误消息，就说明你的系统安装了matplotlib，可以接着阅读下一节。

注意　如果你在安装过程中遇到了麻烦，请参阅附录C。如果依然无济于事，请向他人寻求帮助。对于你遇到的问题，只要向经验丰富的Python程序员提供少量的信息，他们很可能很快就能帮你解决。

15.1.5　matplotlib画廊

要查看使用matplotlib可制作的各种图表，请访问http://matplotlib.org/的示例画廊。单击画廊中的图表，就可查看用于生成图表的代码。

15.2　绘制简单的折线图

下面来使用matplotlib绘制一个简单的折线图，再对其进行定制，以实现信息更丰富的数据可视化。我们将使用平方数序列1、4、9、16和25来绘制这个图表。

只需向matplotlib提供如下数字，matplotlib就能完成其他的工作：

mpl_squares.py

import matplotlib.pyplot as plt

squares = [1, 4, 9, 16, 25]
plt.plot(squares)
plt.show()

我们首先导入了模块pyplot，并给它指定了别名plt，以免反复输入pyplot。在线示例大都这样做，因此这里也这样做。模块pyplot包含很多用于生成图表的函数。

我们创建了一个列表，在其中存储了前述平方数，再将这个列表传递给函数plot()，这个函数尝试根据这些数字绘制出有意义的图形。plt.show()打开matplotlib查看器，并显示绘制的图形，如图15-1所示。查看器让你能够缩放和导航图形，另外，单击磁盘图标可将图形保存起来。

图15-1　使用matplotlib可制作的最简单的图表

15.2.1　修改标签文字和线条粗细

图15-1所示的图形表明数字是越来越大的，但标签文字太小，线条太细。所幸matplotlib让你能够调整可视化的各个方面。

下面通过一些定制来改善这个图形的可读性，如下所示：

mpl_squares.py

import matplotlib.pyplot as plt

squares = [1, 4, 9, 16, 25]
plt.plot(squares, linewidth=5) ❶

# 设置图表标题，并给坐标轴加上标签
plt.title("Square Numbers", fontsize=24) ❷
plt.xlabel("Value", fontsize=14) ❷
plt.ylabel("Square of Value", fontsize=14)

# 设置刻度标记的大小
plt.tick_params(axis='both', labelsize=14) ❹

plt.show()

参数linewidth（见❶）决定了plot()绘制的线条的粗细。函数title()（见❷）给图表指定标题。在上述代码中，出现了多次的参数fontsize指定了图表中文字的大小。

函数xlabel()和ylabel()让你能够为每条轴设置标题（见❸）；而函数tick_params()设置刻度的样式（见❹），其中指定的实参将影响x轴和y轴上的刻度（axis='both'），并将刻度标记的字号设置为14（labelsize=14）。

最终的图表阅读起来容易得多了，如图15-2所示：标签文字更大，线条也更粗。

图15-2　现在图表阅读起来容易得多

15.2.2　校正图形

图形更容易阅读后，我们发现没有正确地绘制数据：折线图的终点指出4.0的平方为25！下面来修复这个问题。

当你向plot()提供一系列数字时，它假设第一个数据点对应的 x 坐标值为0，但我们的第一个点对应的 x 值为1。为改变这种默认行为，我们可以给plot()同时提供输入值和输出值：

mpl_squares.py

import matplotlib.pyplot as plt

input_values = [1, 2, 3, 4, 5]
squares = [1, 4, 9, 16, 25]
plt.plot(input_values, squares, linewidth=5)

# 设置图表标题并给坐标轴加上标签
--snip--

现在plot()将正确地绘制数据，因为我们同时提供了输入值和输出值，它无需对输出值的生成方式作出假设。最终的图形是正确的，如图15-3所示。

图15-3　根据数据正确地绘制了图形

使用plot()时可指定各种实参，还可使用众多函数对图形进行定制。本章后面处理更有趣的数据集时，将继续探索这些定制函数。

15.2.3　使用scatter()绘制散点图并设置其样式

有时候，需要绘制散点图并设置各个数据点的样式。例如，你可能想以一种颜色显示较小的值，而用另一种颜色显示较大的值。绘制大型数据集时，你还可以对每个点都设置同样的样式，再使用不同的样式选项重新绘制某些点，以突出它们。

要绘制单个点，可使用函数scatter()，并向它传递一对 x 和 y 坐标，它将在指定位置绘制一个点：

scatter_squares.py

import matplotlib.pyplot as plt

plt.scatter(2, 4)
plt.show()

下面来设置输出的样式，使其更有趣：添加标题，给轴加上标签，并确保所有文本都大到能够看清：

import matplotlib.pyplot as plt

plt.scatter(2, 4, s=200) ❶

# 设置图表标题并给坐标轴加上标签
plt.title("Square Numbers", fontsize=24)
plt.xlabel("Value", fontsize=14)
plt.ylabel("Square of Value", fontsize=14)

# 设置刻度标记的大小
plt.tick_params(axis='both', which='major', labelsize=14)

plt.show()

在❶处，我们调用了scatter()，并使用实参s设置了绘制图形时使用的点的尺寸。如果此时运行scatter_squares.py，将在图表中央看到一个点，大致如图15-4所示。

图15-4　绘制单个点

15.2.4　使用scatter()绘制一系列点

要绘制一系列的点，可向scatter()传递两个分别包含x值和y值的列表，如下所示：

scatter_squares.py

import matplotlib.pyplot as plt

x_values = [1, 2, 3, 4, 5]
y_values = [1, 4, 9, 16, 25]

plt.scatter(x_values, y_values, s=100)

# 设置图表标题并给坐标轴指定标签
--snip--

列表x_values包含要计算其平方值的数字，而列表y_values包含前述每个数字的平方值。将这些列表传递给scatter()时，matplotlib依次从每个列表中读取一个值来绘制一个点。要绘制的点的坐标分别为 (1, 1)、(2, 4)、(3, 9)、(4, 16)和(5, 25)，最终的结果如图15-5所示。

图15-5　由多个点组成的散点图

15.2.5　自动计算数据

手工计算列表要包含的值可能效率低下，需要绘制的点很多时尤其如此。可以不必手工计算包含点坐标的列表，而让Python循环来替我们完成这种计算。下面是绘制1000个点的代码：

scatter_squares.py

import matplotlib.pyplot as plt

x_values = list(range(1, 1001)) ❶
y_values = [x**2 for x in x_values]

plt.scatter(x_values, y_values, s=40) ❷

# 设置图表标题并给坐标轴加上标签
--snip--

# 设置每个坐标轴的取值范围
plt.axis([0, 1100, 0, 1100000]) ❸

plt.show()

我们首先创建了一个包含 x 值的列表，其中包含数字1~1000（见❶）。接下来是一个生成 y 值的列表解析，它遍历 x 值（for x in x_values），计算其平方值（x**2），并将结果存储到列表y_values中。然后，将输入列表和输出列表传递给scatter()（见❷）。

由于这个数据集较大，我们将点设置得较小，并使用函数axis()指定了每个坐标轴的取值范围（见❸）。函数axis()要求提供四个值：x 和 y 坐标轴的最小值和最大值。在这里，我们将 x 坐标轴的取值范围设置为0~1100，并将 y 坐标轴的取值范围设置为0~1 100 000。结果如图15-6所示。

图15-6　Python绘制1000个点与绘制5个点一样容易

15.2.6　删除数据点的轮廓

matplotlib允许你给散点图中的各个点指定颜色。默认为蓝色点和黑色轮廓，在散点图包含的数据点不多时效果很好。但绘制很多点时，黑色轮廓可能会粘连在一起。要删除数据点的轮廓，可在调用scatter()时传递实参edgecolor='none'1：

1在2.0.0版本的matplotlib中，scatter()函数的实参edgecolor默认为'none'。——译者注

plt.scatter(x_values, y_values, edgecolor='none', s=40)

将相应调用修改为上述代码后，如果再运行scatter_squares.py，在图表中看到的将是蓝色实心点。

15.2.7　自定义颜色

要修改数据点的颜色，可向scatter()传递参数c，并将其设置为要使用的颜色的名称，如下所示：

plt.scatter(x_values, y_values, c='red', edgecolor='none', s=40)

你还可以使用RGB颜色模式自定义颜色。要指定自定义颜色，可传递参数c，并将其设置为一个元组，其中包含三个0~1之间的小数值，它们分别表示红色、绿色和蓝色分量。例如，下面的代码行创建一个由淡蓝色点组成的散点图：

plt.scatter(x_values, y_values, c=(0, 0, 0.8), edgecolor='none', s=40)

值越接近0，指定的颜色越深，值越接近1，指定的颜色越浅。

15.2.8　使用颜色映射

颜色映射（colormap）是一系列颜色，它们从起始颜色渐变到结束颜色。在可视化中，颜色映射用于突出数据的规律，例如，你可能用较浅的颜色来显示较小的值，并使用较深的颜色来显示较大的值。

模块pyplot内置了一组颜色映射。要使用这些颜色映射，你需要告诉pyplot该如何设置数据集中每个点的颜色。下面演示了如何根据每个点的 y 值来设置其颜色：

scatter_squares.py

import matplotlib.pyplot as plt

x_values = list(range(1, 1001))
y_values = [x**2 for x in x_values]

plt.scatter(x_values, y_values, c=y_values, cmap=plt.cm.Blues,
    edgecolor='none', s=40)

# 设置图表标题并给坐标轴加上标签
--snip--

我们将参数c设置成了一个 y 值列表，并使用参数cmap告诉pyplot使用哪个颜色映射。这些代码将 y 值较小的点显示为浅蓝色，并将 y 值较大的点显示为深蓝色，生成的图形如图15-7所示。

图15-7　使用颜色映射Blues的图表

注意　要了解pyplot中所有的颜色映射，请访问http://matplotlib.org/，单击Examples，向下滚动到Color Examples，再单击colormaps_reference。

15.2.9　自动保存图表

要让程序自动将图表保存到文件中，可将对plt.show()的调用替换为对plt.savefig()的调用：

plt.savefig('squares_plot.png', bbox_inches='tight')

第一个实参指定要以什么样的文件名保存图表，这个文件将存储到scatter_squares.py所在的目录中；第二个实参指定将图表多余的空白区域裁剪掉。如果要保留图表周围多余的空白区域，可省略这个实参。

动手试一试

15-1 立方：数字的三次方被称为其立方。请绘制一个图形，显示前5个整数的立方值，再绘制一个图形，显示前5000个整数的立方值。

15-2 彩色立方：给你前面绘制的立方图指定颜色映射。

15.3　随机漫步

在本节中，我们将使用Python来生成随机漫步数据，再使用matplotlib以引人瞩目的方式将这些数据呈现出来。随机漫步是这样行走得到的路径：每次行走都完全是随机的，没有明确的方向，结果是由一系列随机决策决定的。你可以这样认为，随机漫步就是蚂蚁在晕头转向的情况下，每次都沿随机的方向前行所经过的路径。

在自然界、物理学、生物学、化学和经济领域，随机漫步都有其实际用途。例如，漂浮在水滴上的花粉因不断受到水分子的挤压而在水面上移动。水滴中的分子运动是随机的，因此花粉在水面上的运动路径犹如随机漫步。我们稍后将编写的代码模拟了现实世界的很多情形。

15.3.1　创建RandomWalk()类

为模拟随机漫步，我们将创建一个名为RandomWalk的类，它随机地选择前进方向。这个类需要三个属性，其中一个是存储随机漫步次数的变量，其他两个是列表，分别存储随机漫步经过的每个点的 x 和 y 坐标。

RandomWalk类只包含两个方法：__init__()和fill_walk()，其中后者计算随机漫步经过的所有点。下面先来看看__init__()，如下所示：

random_walk.py

from random import choice ❶

class RandomWalk():
    """一个生成随机漫步数据的类"""

    def __init__(self, num_points=5000): ❷
        """初始化随机漫步的属性"""
        self.num_points = num_points

        # 所有随机漫步都始于(0, 0)
        self.x_values = [0] ❸
        self.y_values = [0]

为做出随机决策，我们将所有可能的选择都存储在一个列表中，并在每次做决策时都使用choice()来决定使用哪种选择（见❶）。接下来，我们将随机漫步包含的默认点数设置为5000，这大到足以生成有趣的模式，同时又足够小，可确保能够快速地模拟随机漫步（见❷）。然后，在❸处，我们创建了两个用于存储x和y值的列表，并让每次漫步都从点(0, 0)出发。

15.3.2　选择方向

我们将使用fill_walk()来生成漫步包含的点，并决定每次漫步的方向，如下所示。请将这个方法添加到random_walk.py中：

random_walk.py

    def fill_walk(self):
          """计算随机漫步包含的所有点"""

        # 不断漫步，直到列表达到指定的长度
        while len(self.x_values) < self.num_points: ❶

            # 决定前进方向以及沿这个方向前进的距离
            x_direction = choice([1, -1]) ❷
            x_distance = choice([0, 1, 2, 3, 4])
            x_step = x_direction * x_distance ❸

            y_direction = choice([1, -1])
            y_distance = choice([0, 1, 2, 3, 4])
            y_step = y_direction * y_distance ❹

            # 拒绝原地踏步
            if x_step == 0 and y_step == 0: ❺
                continue

            # 计算下一个点的x和y值
            next_x = self.x_values[-1] + x_step ❻
            next_y = self.y_values[-1] + y_step

            self.x_values.append(next_x)
            self.y_values.append(next_y)

在❶处，我们建立了一个循环，这个循环不断运行，直到漫步包含所需数量的点。这个方法的主要部分告诉Python如何模拟四种漫步决定：向右走还是向左走？沿指定的方向走多远？向上走还是向下走？沿选定的方向走多远？

我们使用choice([1, -1])给x_direction选择一个值，结果要么是表示向右走的1，要么是表示向左走的-1（见❷）。接下来，choice([0, 1, 2, 3, 4])随机地选择一个0~4之间的整数，告诉Python 沿指定的方向走多远（x_distance）。（通过包含0，我们不仅能够沿两个轴移动，还能够沿y轴移动。）

在❸和❹处，我们将移动方向乘以移动距离，以确定沿 x 和 y 轴移动的距离。如果x_step为正，将向右移动，为负将向左移动，而为零将垂直移动；如果y_step为正，就意味着向上移动，为负意味着向下移动，而为零意味着水平移动。如果x_step和y_step都为零，则意味着原地踏步，我们拒绝这样的情况，接着执行下一次循环（见❺）。

为获取漫步中下一个点的 x 值，我们将x_step与x_values中的最后一个值相加（见❻），对于 y 值也做相同的处理。获得下一个点的 x 值和 y 值后，我们将它们分别附加到列表x_values和y_values的末尾。

15.3.3　绘制随机漫步图

下面的代码将随机漫步的所有点都绘制出来：

rw_visual.py

import matplotlib.pyplot as plt

from random_walk import RandomWalk

# 创建一个RandomWalk实例，并将其包含的点都绘制出来
rw = RandomWalk() ❶
rw.fill_walk()
plt.scatter(rw.x_values, rw.y_values, s=15) ❷
plt.show()

我们首先导入了模块pyplot和RandomWalk类，然后创建了一个RandomWalk实例，并将其存储到rw中（见❶），再调用fill_walk()。在❷处，我们将随机漫步包含的 x 和 y 值传递给scatter()，并选择了合适的点尺寸。图15-8显示了包含5000个点的随机漫步图（本节的示意图未包含matplotlib查看器部分，但你运行rw_visual.py时，依然会看到）。

图15-8　包含5000个点的随机漫步

15.3.4　模拟多次随机漫步

每次随机漫步都不同，因此探索可能生成的各种模式很有趣。要在不多次运行程序的情况下使用前面的代码模拟多次随机漫步，一种办法是将这些代码放在一个while循环中，如下所示：

rw_visual.py

import matplotlib.pyplot as plt

from random_walk import RandomWalk

# 只要程序处于活动状态，就不断地模拟随机漫步
while True:
    # 创建一个RandomWalk实例，并将其包含的点都绘制出来
    rw = RandomWalk()
    rw.fill_walk()
    plt.scatter(rw.x_values, rw.y_values, s=15)
    plt.show()

    keep_running = input("Make another walk? (y/n): ") ❶
    if keep_running == 'n':
        break

这些代码模拟一次随机漫步，在matplotlib查看器中显示结果，再在不关闭查看器的情况下暂停。如果你关闭查看器，程序将询问你是否要再模拟一次随机漫步。如果你输入y，可模拟多次随机漫步：这些随机漫步都在起点附近进行，大多沿特定方向偏离起点，漫步点分布不均匀等。要结束程序，请输入n。

注意　如果你使用的是Python 2.7，别忘了将❶处的input()替换为raw_input()。

15.3.5　设置随机漫步图的样式

在本节中，我们将定制图表，以突出每次漫步的重要特征，并让分散注意力的元素不那么显眼。为此，我们确定要突出的元素，如漫步的起点、终点和经过的路径。接下来确定要使其不那么显眼的元素，如刻度标记和标签。最终的结果是简单的可视化表示，清楚地指出了每次漫步经过的路径。

15.3.6　给点着色

我们将使用颜色映射来指出漫步中各点的先后顺序，并删除每个点的黑色轮廓，让它们的颜色更明显。为根据漫步中各点的先后顺序进行着色，我们传递参数c，并将其设置为一个列表，其中包含各点的先后顺序。由于这些点是按顺序绘制的，因此给参数c指定的列表只需包含数字1~5000，如下所示：

rw_visual.py

--snip--
while True:
    # 创建一个RandomWalk实例，并将其包含的点都绘制出来
    rw = RandomWalk()
    rw.fill_walk()

    point_numbers = list(range(rw.num_points)) ❶
    plt.scatter(rw.x_values, rw.y_values, c=point_numbers, cmap=plt.cm.Blues,
        edgecolor='none', s=15)
    plt.show()

    keep_running = input("Make another walk? (y/n): ")
    --snip--

在❶处，我们使用了range()生成了一个数字列表，其中包含的数字个数与漫步包含的点数相同。接下来，我们将这个列表存储在point_numbers中，以便后面使用它来设置每个漫步点的颜色。我们将参数c设置为point_numbers，指定使用颜色映射Blues，并传递实参edgecolor=none以删除每个点周围的轮廓。最终的随机漫步图从浅蓝色渐变为深蓝色，如图15-9所示。

图15-9　使用颜色映射Blues着色的随机漫步图

15.3.7　重新绘制起点和终点

除了给随机漫步的各个点着色，以指出它们的先后顺序外，如果还能呈现随机漫步的起点和终点就更好了。为此，可在绘制随机漫步图后重新绘制起点和终点。我们让起点和终点变得更大，并显示为不同的颜色，以突出它们，如下所示：

rw_visual.py

--snip--
while True:
    --snip--
    plt.scatter(rw.x_values, rw.y_values, c=point_numbers, cmap=plt.cm.Blues,
        edgecolor='none', s=15)

    # 突出起点和终点
    plt.scatter(0, 0, c='green', edgecolors='none', s=100)
    plt.scatter(rw.x_values[-1], rw.y_values[-1], c='red', edgecolors='none',
        s=100)

    plt.show()
    --snip--

为突出起点，我们使用绿色绘制点(0, 0)，并使其比其他点大（s=100）。为突出终点，我们在漫步包含的最后一个 x 和 y 值处绘制一个点，将其颜色设置为红色，并将尺寸设置为100。请务必将这些代码放在调用plt.show()的代码前面，确保在其他点的上面绘制起点和终点。

如果你现在运行这些代码，将能准确地知道每次随机漫步的起点和终点（如果起点和终点不明显，请调整它们的颜色和大小，直到明显为止）。

15.3.8　隐藏坐标轴

下面来隐藏这个图表中的坐标轴，以免我们注意的是坐标轴而不是随机漫步路径。要隐藏坐标轴，可使用如下代码：

rw_visual.py

--snip--
while True:
    --snip--
    plt.scatter(rw.x_values[-1], rw.y_values[-1], c='red', edgecolors='none',
        s=100)

    # 隐藏坐标轴
    plt.axes().get_xaxis().set_visible(False) ❶
    plt.axes().get_yaxis().set_visible(False)

    plt.show()
    --snip--

为修改坐标轴，使用了函数plt.axes()（见❶）来将每条坐标轴的可见性都设置为False。随着你越来越多地进行数据可视化，经常会看到这种串接方法的方式。

如果你现在运行rw_visual.py，将看到一系列图形，但看不到坐标轴。

15.3.9　增加点数

下面来增加点数，以提供更多的数据。为此，我们在创建RandomWalk实例时增大num_points的值，并在绘图时调整每个点的大小，如下所示：

rw_visual.py

--snip--
while True:
    #创建一个RandomWalk实例，并将其包含的点都绘制出来
    rw = RandomWalk(50000)
    rw.fill_walk()

    # 绘制点并将图形显示出来
    point_numbers = list(range(rw.num_points))
    plt.scatter(rw.x_values, rw.y_values, c=point_numbers, cmap=plt.cm.Blues,
        edgecolor='none', s=1)
    --snip--

这个示例模拟了一次包含50 000个点的随机漫步（以模拟现实情况），并将每个点的大小都设置为1。最终的随机漫步图更纤细，犹如云朵，如图15-10所示。正如你看到的，我们使用简单的散点图制作出了一件艺术品！

请尝试修改上述代码，看看将漫步包含的点数增加到多少后，程序的运行速度变得极其缓慢或绘制出的图形变得很难看。

图15-10　包含50 000个点的随机漫步

15.3.10　调整尺寸以适合屏幕

图表适合屏幕大小时，更能有效地将数据中的规律呈现出来。为让绘图窗口更适合屏幕大小，可像下面这样调整matplotlib输出的尺寸：

rw_visual.py

--snip--
while True:
    # 创建一个RandomWalk实例，并将其包含的点都绘制出来
    rw = RandomWalk()
    rw.fill_walk()

    # 设置绘图窗口的尺寸
    plt.figure(figsize=(10, 6))
    --snip--

函数figure()用于指定图表的宽度、高度、分辨率和背景色。你需要给形参figsize指定一个元组，向matplotlib指出绘图窗口的尺寸，单位为英寸。

Python假定屏幕分辨率为80像素/英寸，如果上述代码指定的图表尺寸不合适，可根据需要调整其中的数字。如果你知道自己的系统的分辨率，可使用形参dpi向figure()传递该分辨率，以有效地利用可用的屏幕空间，如下所示：

plt.figure(dpi=128, figsize=(10, 6))

动手试一试

15-3 分子运动：修改rw_visual.py，将其中的plt.scatter()替换为plt.plot()。为模拟花粉在水滴表面的运动路径，向plt.plot()传递rw.x_values和rw.y_values，并指定实参值linewidth。使用5000个点而不是50 000个点。

15-4 改进的随机漫步：在类RandomWalk中，x_step和y_step是根据相同的条件生成的：从列表[1, -1]中随机地选择方向，并从列表[0, 1, 2, 3, 4]中随机地选择距离。请修改这些列表中的值，看看对随机漫步路径有何影响。尝试使用更长的距离选择列表，如0~8；或者将-1从 x 或 y 方向列表中删除。

15-5 重构：方法fill_walk()很长。请新建一个名为get_step()的方法，用于确定每次漫步的距离和方向，并计算这次漫步将如何移动。然后，在fill_walk()中调用get_step()两次：

x_step = self.get_step()
y_step = self.get_step()

通过这样的重构，可缩小fill_walk()的规模，让这个方法阅读和理解起来更容易。

15.4　使用Pygal模拟掷骰子

在本节中，我们将使用Python可视化包Pygal来生成可缩放的矢量图形文件。对于需要在尺寸不同的屏幕上显示的图表，这很有用，因为它们将自动缩放，以适合观看者的屏幕。如果你打算以在线方式使用图表，请考虑使用Pygal来生成它们，这样它们在任何设备上显示时都会很美观。

在这个项目中，我们将对掷骰子的结果进行分析。掷6面的常规骰子时，可能出现的结果为1~6点，且出现每种结果的可能性相同。然而，如果同时掷两个骰子，某些点数出现的可能性将比其他点数大。为确定哪些点数出现的可能性最大，我们将生成一个表示掷骰子结果的数据集，并根据结果绘制出一个图形。

在数学领域，常常利用掷骰子来解释各种数据分析，但它在赌场和其他博弈场景中也得到了实际应用，在游戏《大富翁》以及众多角色扮演游戏中亦如此。

15.4.1　安装Pygal

请使用pip来安装Pygal（如果还未使用过pip，请参阅12.2.1节）。

在Linux和OS X系统中，应执行的命令类似于下面这样：

pip install --user pygal==1.7

在Windows系统中，命令类似于下面这样：

python -m pip install --user pygal==1.7

注意　你可能需要使用命令pip3而不是pip，如果这还是不管用，你可能需要删除标志--user。

15.4.2　Pygal画廊

要了解使用Pygal可创建什么样的图表，请查看图表类型画廊：访问http://www.pygal.org/，单击Documentation，再单击Chart types。每个示例都包含源代码，让你知道这些图表是如何生成的。

15.4.3　创建Die类

下面的类模拟掷一个骰子：

die.py

from random import randint

class Die():
    """表示一个骰子的类"""

    def __init__(self, num_sides=6): ❶
        """骰子默认为6面"""
        self.num_sides = num_sides

    def roll(self):
        """"返回一个位于1和骰子面数之间的随机值"""
        return randint(1, self.num_sides) ❷

方法__init__()接受一个可选参数。创建这个类的实例时，如果没有指定任何实参，面数默认为6；如果指定了实参，这个值将用于设置骰子的面数（见❶）。骰子是根据面数命名的，6面的骰子名为D6，8面的骰子名为D8，以此类推。

方法roll()使用函数randint()来返回一个1和面数之间的随机数（见❷）。这个函数可能返回起始值1、终止值num_sides或这两个值之间的任何整数。

15.4.4　掷骰子

使用这个类来创建图表前，先来掷D6骰子，将结果打印出来，并检查结果是否合理：

die_visual.py

from die import Die

# 创建一个D6
die = Die() ❶

# 掷几次骰子，并将结果存储在一个列表中
results = []
for roll_num in range(100): ❷
    result = die.roll()
    results.append(result)

print(results)

在❶处，我们创建了一个Die实例，其面数为默认值6。在❷处，我们掷骰子100次，并将每次的结果都存储在列表results中。下面是一个示例结果集：

[4, 6, 5, 6, 1, 5, 6, 3, 5, 3, 5, 3, 2, 2, 1, 3, 1, 5, 3, 6, 3, 6, 5, 4,
 1, 1, 4, 2, 3, 6, 4, 2, 6, 4, 1, 3, 2, 5, 6, 3, 6, 2, 1, 1, 3, 4, 1, 4,
 3, 5, 1, 4, 5, 5, 2, 3, 3, 1, 2, 3, 5, 6, 2, 5, 6, 1, 3, 2, 1, 1, 1, 6,
 5, 5, 2, 2, 6, 4, 1, 4, 5, 1, 1, 1, 4, 5, 3, 3, 1, 3, 5, 4, 5, 6, 5, 4,
 1, 5, 1, 2]

通过快速扫描这些结果可知，Die类看起来没有问题。我们见到了值1和6，这表明返回了最大和最小的可能值；我们没有见到0或7，这表明结果都在正确的范围内。我们还看到了1~6的所有数字，这表明所有可能的结果都出现了。

15.4.5　分析结果

为分析掷一个D6骰子的结果，我们计算每个点数出现的次数：

die_visual.py

--snip--
# 掷几次骰子，并将结果存储在一个列表中
results = []
for roll_num in range(1000): ❶
    result = die.roll()
    results.append(result)

# 分析结果
frequencies = []
for value in range(1, die.num_sides+1): ❷
    frequency = results.count(value) ❸
    frequencies.append(frequency) ❹

print(frequencies)

由于我们将使用Pygal来进行分析，而不是将结果打印出来，因此可以将模拟掷骰子的次数增加到1000（见❶）。为分析结果，我们创建了空列表frequencies，用于存储每种点数出现的次数。在❷处，我们遍历可能的点数（这里为1~6），计算每种点数在results中出现了多少次（见❸），并将这个值附加到列表frequencies的末尾（见❹）。接下来，我们在可视化之前将这个列表打印出来：

[155, 167, 168, 170, 159, 181]

结果看起来是合理的：我们看到了6个值——掷D6骰子时可能出现的每个点数对应一个；我们还发现，没有任何点数出现的频率比其他点数高很多。下面来可视化这些结果。

15.4.6　绘制直方图

有了频率列表后，我们就可以绘制一个表示结果的直方图。直方图是一种条形图，指出了各种结果出现的频率。创建这种直方图的代码如下：

die_visual.py

import pygal
--snip--

# 分析结果
frequencies = []
for value in range(1, die.num_sides+1):
    frequency = results.count(value)
    frequencies.append(frequency)

# 对结果进行可视化
hist = pygal.Bar() ❶

hist.title = "Results of rolling one D6 1000 times."
hist.x_labels = ['1', '2', '3', '4', '5', '6'] ❷
hist.x_title = "Result"
hist.y_title = "Frequency of Result"

hist.add('D6', frequencies) ❸
hist.render_to_file('die_visual.svg')

为创建条形图，我们创建了一个pygal.Bar()实例，并将其存储在hist中（见❶）。接下来，我们设置hist的属性title（用于标示直方图的字符串），将掷D6骰子的可能结果用作 x 轴的标签（见❷），并给每个轴都添加了标题。在❸处，我们使用add()将一系列值添加到图表中（向它传递要给添加的值指定的标签，还有一个列表，其中包含将出现在图表中的值）。最后，我们将这个图表渲染为一个SVG文件，这种文件的扩展名必须为.svg。

要查看生成的直方图，最简单的方式是使用Web浏览器。为此，在任何Web浏览器中新建一个标签页，再在其中打开文件die_visual.svg（它位于die_visual.py所在的文件夹中）。你将看到一个类似于图15-11所示的图表（为方便印刷，我稍微修改了这个图表；默认情况下，Pygal生成的图表的背景比你在图15-11中看到的要暗）。

图15-11　使用Pygal创建的简单条形图

注意，Pygal让这个图表具有交互性：如果你将鼠标指向该图表中的任何条形，将看到与之相关联的数据。在同一个图表中绘制多个数据集时，这项功能显得特别有用。

15.4.7　同时掷两个骰子

同时掷两个骰子时，得到的点数更多，结果分布情况也不同。下面来修改前面的代码，创建两个D6骰子，以模拟同时掷两个骰子的情况。每次掷两个骰子时，我们都将两个骰子的点数相加，并将结果存储在results中。请复制die_visual.py并将其保存为dice_visual.py，再做如下修改：

dice_visual.py

import pygal

from die import Die

# 创建两个D6骰子
die_1 = Die()
die_2 = Die()

# 掷骰子多次，并将结果存储到一个列表中
results = []
for roll_num in range(1000):
    result = die_1.roll() + die_2.roll() ❶
    results.append(result)

# 分析结果
frequencies = []
max_result = die_1.num_sides + die_2.num_sides ❷
for value in range(2, max_result+1): ❸
    frequency = results.count(value)
    frequencies.append(frequency)

# 可视化结果
hist = pygal.Bar()

hist.title = "Results of rolling two D6 dice 1000 times." ❹
hist.x_labels = ['2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12']
hist.x_title = "Result"
hist.y_title = "Frequency of Result"

hist.add('D6 + D6', frequencies)
hist.render_to_file('dice_visual.svg')

创建两个Die实例后，我们掷骰子多次，并计算每次的总点数（见❶）。可能出现的最大点数12为两个骰子的最大可能点数之和，我们将这个值存储在了max_result中（见❷）。可能出现的最小总点数2为两个骰子的最小可能点数之和。分析结果时，我们计算2到max_result的各种点数出现的次数（见❸）。我们原本可以使用range(2, 13)，但这只适用于两个D6骰子。模拟现实世界的情形时，最好编写可轻松地模拟各种情形的代码。前面的代码让我们能够模拟掷任何两个骰子的情形，而不管这些骰子有多少面。

创建图表时，我们修改了标题、x 轴标签和数据系列（见❹）。（如果列表x_labels比这里所示的长得多，那么编写一个循环来自动生成它将更合适。）

运行这些代码后，在浏览器中刷新显示图表的标签页，你将看到如图15-12所示的图表。

图15-12　模拟同时掷两个6面骰子1000次的结果

这个图表显示了掷两个D6骰子时得到的大致结果。正如你看到的，总点数为2或12的可能性最小，而总点数为7的可能性最大，这是因为在6种情况下得到的总点数都为7。这6种情况如下：1和6、2和5、3和4、4和3、5和2、6和1。

15.4.8　同时掷两个面数不同的骰子

下面来创建一个6面骰子和一个10面骰子，看看同时掷这两个骰子50 000次的结果如何：

different_dice.py

from die import Die

import pygal

# 创建一个D6和一个D10
die_1 = Die()
die_2 = Die(10) ❶

# 掷骰子多次，并将结果存储在一个列表中
results = []
for roll_num in range(50000):
    result = die_1.roll() + die_2.roll()
    results.append(result)

# 分析结果
--snip--

# 可视化结果
hist = pygal.Bar()

hist.title = "Results of rolling a D6 and a D10 50,000 times." ❷
hist.x_labels = ['2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12',
    '13', '14', '15', '16']
hist.x_title = "Result"
hist.y_title = "Frequency of Result"

hist.add('D6 + D10', frequencies)
hist.render_to_file('dice_visual.svg')

为创建D10骰子，我们在创建第二个Die实例时传递了实参10（见❶）。我们还修改了第一个循环，以模拟掷骰子50 000次而不是1000次。可能出现的最小总点数依然是2，但现在可能出现的最大总点数为16，因此我们相应地调整了标题、x轴标签和数据系列标签（见❷）。

图15-13显示了最终的图表。可能性最大的点数不是一个，而是5个，这是因为导致出现最小点数和最大点数的组合都只有一种（1和1以及6和10），但面数较小的骰子限制了得到中间点数的组合数：得到总点数7、8、9、10和11的组合数都是6种。因此，这些总点数是最常见的结果，它们出现的可能性相同。

图15-13　同时掷6面骰子和10面骰子50 000次的结果

通过使用Pygal来模拟掷骰子的结果，能够非常自由地探索这种现象。只需几分钟，就可以掷各种骰子很多次。

动手试一试

15-6 自动生成标签：请修改die.py和dice_visual.py，将用来设置hist.x_labels值的列表替换为一个自动生成这种列表的循环。如果你熟悉列表解析，可尝试将die_visual.py和dice_visual.py中的其他for循环也替换为列表解析。

15-7 两个D8骰子：请模拟同时掷两个8面骰子1000次的结果。逐渐增加掷骰子的次数，直到系统不堪重负为止。

15-8 同时掷三个骰子：如果你同时掷三个D6骰子，可能得到的最小点数为3，而最大点数为18。请通过可视化展示同时掷三个D6骰子的结果。

15-9 将点数相乘：同时掷两个骰子时，通常将它们的点数相加。请通过可视化展示将两个骰子的点数相乘的结果。

15-10 练习使用本章介绍的两个库：尝试使用matplotlib通过可视化来模拟掷骰子的情况，并尝试使用Pygal通过可视化来模拟随机漫步的情况。

15.5　小结

在本章中，你学习了：如何生成数据集以及如何对其进行可视化；如何使用matplotlib创建简单的图表，以及如何使用散点图来探索随机漫步过程；如何使用Pygal来创建直方图，以及如何使用直方图来探索同时掷两个面数不同的骰子的结果。

使用代码生成数据集是一种有趣而强大的方式，可用于模拟和探索现实世界的各种情形。完成后面的数据可视化项目时，请注意可使用代码模拟哪些情形。请研究新闻媒体中的可视化，看看其中是否有图表是以你在这些项目中学到的类似方式生成的。

在第16章中，我们将从网上下载数据，并继续使用matplotlib和Pygal来探索这些数据。

第 16 章　下载数据

在本章中，你将从网上下载数据，并对这些数据进行可视化。网上的数据多得难以置信，且大多未经过仔细检查。如果能够对这些数据进行分析，你就能发现别人没有发现的规律和关联。

我们将访问并可视化以两种常见格式存储的数据：CSV和JSON。我们将使用Python模块csv来处理以CSV（逗号分隔的值）格式存储的天气数据，找出两个不同地区在一段时间内的最高温度和最低温度。然后，我们将使用matplotlib根据下载的数据创建一个图表，展示两个不同地区的气温变化：阿拉斯加锡特卡和加州死亡谷。在本章的后面，我们将使用模块json来访问以JSON格式存储的交易收盘价数据，并使用Pygal绘制图形以探索价格变化的周期性。

阅读本章后，你将能够处理各种类型和格式的数据集，并对如何创建复杂的图表有更深入的认识。要处理各种真实世界的数据集，必须能够访问并可视化各种类型和格式的在线数据。

16.1　CSV文件格式

要在文本文件中存储数据，最简单的方式是将数据作为一系列以逗号分隔的值（CSV）写入文件。这样的文件称为CSV文件。例如，下面是一行CSV格式的天气数据：

2014-1-5,61,44,26,18,7,-1,56,30,9,30.34,30.27,30.15,,,,10,4,,0.00,0,,195

这是阿拉斯加锡特卡2014年1月5日的天气数据，其中包含当天的最高气温和最低气温，还有众多其他数据。CSV文件对人来说阅读起来比较麻烦，但程序可轻松地提取并处理其中的值，这有助于加快数据分析过程。

我们将首先处理少量锡特卡的CSV格式的天气数据，这些数据可在本书的配套资源（http://www.ituring.com.cn/book/1861）中找到。请将文件sitka_weather_07-2014.csv复制到存储本章程序的文件夹中（下载本书的配套资源后，你就有了这个项目所需的所有文件）。

注意　这个项目使用的天气数据是从http://www.wunderground.com/history/下载而来的。

16.1.1　分析CSV文件头

csv模块包含在Python标准库中，可用于分析CSV文件中的数据行，让我们能够快速提取感兴趣的值。下面先来查看这个文件的第一行，其中包含一系列有关数据的描述：

highs_lows.py

import csv

filename = 'sitka_weather_07-2014.csv'
with open(filename) as f: ❶
    reader = csv.reader(f) ❷
    header_row = next(reader) ❸
    print(header_row)

导入模块csv后，我们将要使用的文件的名称存储在filename中。接下来，我们打开这个文件，并将结果文件对象存储在f中（见❶）。然后，我们调用csv.reader()，并将前面存储的文件对象作为实参传递给它，从而创建一个与该文件相关联的阅读器（reader）对象（见❷）。我们将这个阅读器对象存储在reader中。

模块csv包含函数next()，调用它并将阅读器对象传递给它时，它将返回文件中的下一行。在前面的代码中，我们只调用了next()一次，因此得到的是文件的第一行，其中包含文件头（见❸）。我们将返回的数据存储在header_row中。正如你看到的，header_row包含与天气相关的文件头，指出了每行都包含哪些数据：

['AKDT', 'Max TemperatureF', 'Mean TemperatureF', 'Min TemperatureF',
'Max Dew PointF', 'MeanDew PointF', 'Min DewpointF', 'Max Humidity',
' Mean Humidity', ' Min Humidity', ' Max Sea Level PressureIn',
' Mean Sea Level PressureIn', ' Min Sea Level PressureIn',
' Max VisibilityMiles', ' Mean VisibilityMiles', ' Min VisibilityMiles',
' Max Wind SpeedMPH', ' Mean Wind SpeedMPH', ' Max Gust SpeedMPH',
'PrecipitationIn', ' CloudCover', ' Events', ' WindDirDegrees']

reader处理文件中以逗号分隔的第一行数据，并将每项数据都作为一个元素存储在列表中。文件头AKDT表示阿拉斯加时间（Alaska Daylight Time），其位置表明每行的第一个值都是日期或时间。文件头Max TemperatureF指出每行的第二个值都是当天的最高华氏温度。可通过阅读其他的文件头来确定文件包含的信息类型。

注意　文件头的格式并非总是一致的，空格和单位可能出现在奇怪的地方。这在原始数据文件中很常见，但不会带来任何问题。

16.1.2　打印文件头及其位置

为让文件头数据更容易理解，将列表中的每个文件头及其位置打印出来：

highs_lows.py

--snip--
with open(filename) as f:
    reader = csv.reader(f)
    header_row = next(reader)

    for index, column_header in enumerate(header_row): ❶
        print(index, column_header)

我们对列表调用了enumerate()（见❶）来获取每个元素的索引及其值。（请注意，我们删除了代码行print(header_row)，转而显示这个更详细的版本。）

输出如下，其中指出了每个文件头的索引：

0 AKDT
1 Max TemperatureF
2 Mean TemperatureF
3 Min TemperatureF
--snip--
20  CloudCover
21  Events
22  WindDirDegrees

从中可知，日期和最高气温分别存储在第0列和第1列。为研究这些数据，我们将处理sitka_weather_07-2014.csv中的每行数据，并提取其中索引为0和1的值。

16.1.3　提取并读取数据

知道需要哪些列中的数据后，我们来读取一些数据。首先读取每天的最高气温：

highs_lows.py

import csv

# 从文件中获取最高气温
filename = 'sitka_weather_07-2014.csv'
with open(filename) as f:
    reader = csv.reader(f)
    header_row = next(reader)

    highs = [] ❶
    for row in reader: ❷
        highs.append(row[1]) ❸

    print(highs)

我们创建了一个名为highs的空列表（见❶），再遍历文件中余下的各行（见❷）。阅读器对象从其停留的地方继续往下读取CSV文件，每次都自动返回当前所处位置的下一行。由于我们已经读取了文件头行，这个循环将从第二行开始——从这行开始包含的是实际数据。每次执行该循环时，我们都将索引1处（第2列）的数据附加到highs末尾（见❸）。

下面显示了highs现在存储的数据：

['64', '71', '64', '59', '69', '62', '61', '55', '57', '61', '57', '59', '57',
 '61', '64', '61', '59', '63', '60', '57', '69', '63', '62', '59', '57', '57',
 '61', '59', '61', '61', '66']

我们提取了每天的最高气温，并将它们作为字符串整洁地存储在一个列表中。

下面使用int()将这些字符串转换为数字，让matplotlib能够读取它们：

highs_lows.py

--snip--
    highs = []
    for row in reader:
        high = int(row[1]) ❶
        highs.append(high)

    print(highs)

在❶处，我们将表示气温的字符串转换成了数字，再将其附加到列表末尾。这样，最终的列表将包含以数字表示的每日最高气温：

[64, 71, 64, 59, 69, 62, 61, 55, 57, 61, 57, 59, 57, 61, 64, 61, 59, 63, 60, 57,
 69, 63, 62, 59, 57, 57, 61, 59, 61, 61, 66]

下面来对这些数据进行可视化。

16.1.4　绘制气温图表

为可视化这些气温数据，我们首先使用matplotlib创建一个显示每日最高气温的简单图形，如下所示：

highs_lows.py

import csv

from matplotlib import pyplot as plt

# 从文件中获取最高气温
--snip--

# 根据数据绘制图形
fig = plt.figure(dpi=128, figsize=(10, 6))
plt.plot(highs, c='red') ❶

# 设置图形的格式
plt.title("Daily high temperatures, July 2014", fontsize=24) ❷
plt.xlabel('', fontsize=16) ❸
plt.ylabel("Temperature (F)", fontsize=16)
plt.tick_params(axis='both', which='major', labelsize=16)

plt.show()

我们将最高气温列表传给plot()（见❶），并传递c='red'以便将数据点绘制为红色（红色显示最高气温，蓝色显示最低气温）。接下来，我们设置了一些其他的格式，如字体大小和标签（见❷），这些都在第15章介绍过。鉴于我们还没有添加日期，因此没有给x轴添加标签，但plt.xlabel()确实修改了字体大小，让默认标签更容易看清。图16-1显示了绘制的图表：一个简单的折线图，显示了阿拉斯加锡特卡2014年7月每天的最高气温。

图16-1　阿拉斯加锡特卡2014年7月每日最高气温折线图

16.1.5　模块datetime

下面在图表中添加日期，使其更有用。在天气数据文件中，第一个日期在第二行：

2014-7-1,64,56,50,53,51,48,96,83,58,30.19,--snip--

读取该数据时，获得的是一个字符串，因为我们需要想办法将字符串'2014-7-1'转换为一个表示相应日期的对象。为创建一个表示2014年7月1日的对象，可使用模块datetime中的方法strptime()。我们在终端会话中看看strptime()的工作原理：

>>> from datetime import datetime
>>> first_date = datetime.strptime('2014-7-1', '%Y-%m-%d')
>>> print(first_date)
2014-07-01 00:00:00

我们首先导入了模块datetime中的datetime类，然后调用方法strptime()，并将包含所需日期的字符串作为第一个实参。第二个实参告诉Python如何设置日期的格式。在这个示例中，'%Y-'让Python将字符串中第一个连字符前面的部分视为四位的年份；'%m-'让Python将第二个连字符前面的部分视为表示月份的数字；而'%d'让Python将字符串的最后一部分视为月份中的一天（1~31）。

方法strptime()可接受各种实参，并根据它们来决定如何解读日期。表16-1列出了其中一些这样的实参。

表16-1　模块datetime中设置日期和时间格式的实参

实参

含义

%A

星期的名称，如Monday

%B

月份名，如January

%m

用数字表示的月份（01~12）

%d

用数字表示月份中的一天（01~31）

%Y

四位的年份，如2015

%y

两位的年份，如15

%H

24小时制的小时数（00~23）

%I

12小时制的小时数（01~12）

%p

am或pm

%M

分钟数（00~59）

%S

秒数（00~61）

16.1.6　在图表中添加日期

知道如何处理CSV文件中的日期后，就可对气温图形进行改进了，即提取日期和最高气温，并将它们传递给plot()，如下所示：

highs_lows.py

import csv
from datetime import datetime

from matplotlib import pyplot as plt

# 从文件中获取日期和最高气温
filename = 'sitka_weather_07-2014.csv'
with open(filename) as f:
    reader = csv.reader(f)
    header_row = next(reader)

    dates, highs = [], [] ❶
    for row in reader:
        current_date = datetime.strptime(row[0], "%Y-%m-%d") ❷
        dates.append(current_date)

        high = int(row[1])
        highs.append(high)

# 根据数据绘制图形
fig = plt.figure(dpi=128, figsize=(10, 6))
plt.plot(dates, highs, c='red') ❸

# 设置图形的格式
plt.title("Daily high temperatures, July 2014", fontsize=24)
plt.xlabel('', fontsize=16)
fig.autofmt_xdate() ❹
plt.ylabel("Temperature (F)", fontsize=16)
plt.tick_params(axis='both', which='major', labelsize=16)

plt.show()

我们创建了两个空列表，用于存储从文件中提取的日期和最高气温（见❶）。然后，我们将包含日期信息的数据（row[0]）转换为datetime对象（见❷），并将其附加到列表dates末尾。在❸处，我们将日期和最高气温值传递给plot()。在❹处，我们调用了fig.autofmt_xdate()来绘制斜的日期标签，以免它们彼此重叠。图16-2显示了改进后的图表。

图16-2　现在图表的 x 轴上有日期，含义更丰富

16.1.7　涵盖更长的时间

设置好图表后，我们来添加更多的数据，以成一幅更复杂的锡特卡天气图。请将文件sitka_weather_2014.csv复制到存储本章程序的文件夹中，该文件包含Weather Underground提供的整年的锡特卡天气数据。

现在可以创建覆盖整年的天气图了：

highs_lows.py

--snip--
# 从文件中获取日期和最高气温
filename = 'sitka_weather_2014.csv' ❶
with open(filename) as f:
--snip--
# 设置图形的格式
plt.title("Daily high temperatures - 2014", fontsize=24) ❷
plt.xlabel('', fontsize=16)
--snip--

我们修改了文件名，以使用新的数据文件sitka_weather_2014.csv（见❶）；我们还修改了图表的标题，以反映其内容的变化（见❷）。图16-3显示了生成的图形。

图16-3　一年的天气数据

16.1.8　再绘制一个数据系列

图16-3所示的改进后的图表显示了大量意义深远的数据，但我们可以在其中再添加最低气温数据，使其更有用。为此，需要从数据文件中提取最低气温，并将它们添加到图表中，如下所示：

highs_lows.py

--snip--
# 从文件中获取日期、最高气温和最低气温
filename = 'sitka_weather_2014.csv'
with open(filename) as f:
    reader = csv.reader(f)
    header_row = next(reader)

    dates, highs, lows = [], [], [] ❶
    for row in reader:
        current_date = datetime.strptime(row[0], "%Y-%m-%d")
        dates.append(current_date)

        high = int(row[1])
        highs.append(high)

        low = int(row[3]) ❷
        lows.append(low)
# 根据数据绘制图形
fig = plt.figure(dpi=128, figsize=(10, 6))
plt.plot(dates, highs, c='red')
plt.plot(dates, lows, c='blue') ❸

# 设置图形的格式
plt.title("Daily high and low temperatures - 2014", fontsize=24) ❹
--snip--

在❶处，我们添加了空列表lows，用于存储最低气温。接下来，我们从每行的第4列（row[3]）提取每天的最低气温，并存储它们（见❷）。在❸处，我们添加了一个对plot()的调用，以使用蓝色绘制最低气温。最后，我们修改了标题（见❹）。图16-4显示了这样绘制出来的图表。

图16-4　在一个图表中包含两个数据系列

16.1.9　给图表区域着色

添加两个数据系列后，我们就可以了解每天的气温范围了。下面来给这个图表做最后的修饰，通过着色来呈现每天的气温范围。为此，我们将使用方法fill_between()，它接受一个 x 值系列和两个 y 值系列，并填充两个 y 值系列之间的空间：

highs_lows.py

--snip--
# 根据数据绘制图形
fig = plt.figure(dpi=128, figsize=(10, 6))
plt.plot(dates, highs, c='red', alpha=0.5) ❶
plt.plot(dates, lows, c='blue', alpha=0.5)
plt.fill_between(dates, highs, lows, facecolor='blue', alpha=0.1) ❷
--snip--

❶处的实参alpha指定颜色的透明度。Alpha值为0表示完全透明，1（默认设置）表示完全不透明。通过将alpha设置为0.5，可让红色和蓝色折线的颜色看起来更浅。

在❷处，我们向fill_between()传递了一个 x 值系列：列表dates，还传递了两个 y 值系列：highs和lows。实参facecolor指定了填充区域的颜色，我们还将alpha设置成了较小的值0.1，让填充区域将两个数据系列连接起来的同时不分散观察者的注意力。图16-5显示了最高气温和最低气温之间的区域被填充的图表。

图16-5　给两个数据集之间的区域着色

通过着色，让两个数据集之间的区域显而易见。

16.1.10　错误检查

我们应该能够使用有关任何地方的天气数据来运行highs_lows.py中的代码，但有些气象站会偶尔出现故障，未能收集部分或全部其应该收集的数据。缺失数据可能会引发异常，如果不妥善地处理，还可能导致程序崩溃。

例如，我们来看看生成加利福尼亚死亡谷的气温图时出现的情况。将文件death_valley_2014.csv复制到本章程序所在的文件夹，再修改highs_lows.py，使其生成死亡谷的气温图：

highs_lows.py

--snip--
# 从文件中获取日期、最高气温和最低气温
filename = 'death_valley_2014.csv'
with open(filename) as f:
--snip--

运行这个程序时，出现了一个错误，如下述输出的最后一行所示：

Traceback (most recent call last):
  File "highs_lows.py", line 17, in <module>
    high = int(row[1])
ValueError: invalid literal for int() with base 10: ''

该traceback指出，Python无法处理其中一天的最高气温，因为它无法将空字符串（' '）转换为整数。只要看一下death_valley_2014.csv，就能发现其中的问题：

2014-2-16,,,,,,,,,,,,,,,,,,,0.00,,,-1

其中好像没有记录2014年2月16日的数据，表示最高温度的字符串为空。为解决这种问题，我们在从CSV文件中读取值时执行错误检查代码，对分析数据集时可能出现的异常进行处理，如下所示：

highs_lows.py

--snip--
# 从文件中获取日期、最高气温和最低气温
filename = 'death_valley_2014.csv'
with open(filename) as f:
    reader = csv.reader(f)
    header_row = next(reader)

    dates, highs, lows = [], [], []
    for row in reader:
        try: ❶
            current_date = datetime.strptime(row[0], "%Y-%m-%d")
            high = int(row[1])
            low = int(row[3])
        except ValueError:
            print(current_date, 'missing data') ❷
        else:
            dates.append(current_date) ❸
            highs.append(high)
            lows.append(low)

#根据数据绘制图形
--snip--

#设置图形的格式
title = "Daily high and low temperatures - 2014\nDeath Valley, CA" ❹
plt.title(title, fontsize=20)
--snip--

对于每一行，我们都尝试从中提取日期、最高气温和最低气温（见❶）。只要缺失其中一项数据，Python就会引发ValueError异常，而我们可这样处理：打印一条错误消息，指出缺失数据的日期（见❷）。打印错误消息后，循环将接着处理下一行。如果获取特定日期的所有数据时没有发生错误，将运行else代码块，并将数据附加到相应列表的末尾（见❸）。鉴于我们绘图时使用的是有关另一个地方的信息，我们修改了标题，在图表中指出了这个地方（见❹）。

如果你现在运行highs_lows.py，将发现缺失数据的日期只有一个：

2014-02-16 missing data

图16-6显示了绘制出的图形。

图16-6　死亡谷每日高气温和最低气温

将这个图表与锡特卡的图表对比可知，总体而言，死亡谷比阿拉斯加东南部暖和，这可能符合预期，但这个沙漠中每天的温差也更大，从着色区域的高度可以明显看出这一点。

使用的很多数据集都可能缺失数据、数据格式不正确或数据本身不正确。对于这样的情形，可使用本书前半部分介绍的工具来处理。在这里，我们使用了一个try-except-else代码块来处理数据缺失的问题。在有些情况下，需要使用continue来跳过一些数据，或者使用remove()或del将已提取的数据删除。可采用任何管用的方法，只要能进行精确而有意义的可视化就好。

动手试一试

16-1 旧金山：旧金山的气温更接近于锡特卡还是死亡谷呢？请绘制一个显示旧金山最高气温和最低气温的图表，并进行比较。可从http://www.wunderground.com/history/下载几乎任何地方的天气数据。为此，请输入相应的地方和日期范围，滚动到页面底部，找到名为Comma-Delimited File的链接，再单击该链接，将数据存储为CSV文件。

16-2 比较锡特卡和死亡谷的气温：在有关锡特卡和死亡谷的图表中，气温刻度反映了数据范围的不同。为准确地比较锡特卡和死亡谷的气温范围，需要在y轴上使用相同的刻度。为此，请修改图16-5和图16-6所示图表的y 轴设置，对锡特卡和死亡谷的气温范围进行直接比较（你也可以对任何两个地方的气温范围进行比较）。你还可以尝试在一个图表中呈现这两个数据集。

16-3 降雨量：选择你感兴趣的任何地方，通过可视化将其降雨量呈现出来。为此，可先只涵盖一个月的数据，确定代码正确无误后，再使用一整年的数据来运行它。

16-4 探索：生成一些图表，对你好奇的任何地方的其他天气数据进行研究。

16.2　制作交易收盘价走势图：JSON 格式

在本节中1 ，你将下载JSON格式的交易收盘价数据，并使用模块json来处理它们。Pygal提供了一个适合初学者使用的绘图工具，你将使用它来对收盘价数据进行可视化，以探索价格变化的周期性。

1本节为陶俊杰根据原作编写。源代码请见本书主页http://www.ituring.com.cn/book/1861，代码为Python 3版本，尽量支持Python 2版本，可以在binder容器中执行jupyter notebook。——编者注

16.2.1　下载收盘价数据

收盘价数据文件位于https://raw.githubusercontent.com/muxuezi/btc/master/btc_close_2017.json。可以直接将文件btc_close_2017.json下载到本章程序所在的文件夹中，也可以用Python 2.x 标准库中模块urllib2（Python 3.x 版本使用的是urllib）的函数urlopen来做，还可以通过Python的第三方模块requests（将在第17章介绍）下载数据。

首先，我们直接下载btc_close_2017.json，看看如何着手处理这个文件中的数据：

btc_close_2017.json

[
  {
    "date": "2017-01-01",
    "month": "01",
    "week": "52",
    "weekday": "Sunday",
    "close": "6928.6492"
  },
  --snip--
  {
    "date": "2017-12-12",
    "month": "12",
    "week": "50",
    "weekday": "Tuesday",
    "close": "113732.6745"
  }
]

这个文件实际上就是一个很长的Python列表，其中每个元素都是一个包含五个键的字典：统计日期、月份、周数、周几以及收盘价。由于2017年1月1日是周日，作为2017年的第一周实在太短，因此将其计入2016年的第52周。于是2017年的第一周是从2017年1月2日（周一）开始的。如果用函数urlopen来下载数据，可以使用下面的代码：

btc_close_2017.py

from __future__ import (absolute_import, division,
                        print_function, unicode_literals)
try: ❶
    # Python 2.x 版本
    from urllib2 import urlopen
except ImportError:
    # Python 3.x 版本
    from urllib.request import urlopen
import json

json_url = 'https://raw.githubusercontent.com/muxuezi/btc/master/btc_close_2017.json'
response = urlopen(json_url) ❷
# 读取数据
req = response.read()
# 将数据写入文件
with open('btc_close_2017_urllib.json','wb') as f: ❸
    f.write(req)
# 加载json格式
file_urllib = json.loads(req) ❹
print(file_urllib)

首先导入下载文件使用的模块。这里用try/except语句（见❶）实现兼容Python 2.x和Python 3.x代码。ImportError可以作为判断Python 2.x 和Python 3.x 的方式：如果用Python 2.x 版本运行代码，from urllib2 import urlopen代码就会执行；如果用Python 3.x 版本运行代码，由于没有urllib2模块，解释器就会触发ImportError，因此from urllib.request import urlopen代码就会执行。条条大路通罗马，最终都会导入urlopen函数。

然后导入模块json，以便之后能够正确地加载文件中的数据。我们的btc_close_2017.json文件放在GitHub网站上，urlopen(json_url)是将json_url网址传入urlopen函数（见❷）。这行代码执行后， Python就会向GitHub的服务器发送请求。GitHub的服务器响应请求后把btc_close_2017.json文件发送给Python，之后用response.read()就可以读取文件数据。这时，可以将文件数据保存到文件夹中（见❸），btc_close_2017_urllib.json与btc_close_2017.json的内容是一样的。最后，我们用函数json.load()（见❹）将文件内容转换成Python能够处理的格式，与前面直接下载的文件内容一致。

函数urlopen的代码稍微复杂一些，第三方模块requests封装了许多常用的方法，让数据下载和读取方式变得非常简单：

import requests

json_url = 'https://raw.githubusercontent.com/muxuezi/btc/master/btc_close_2017.json'
req = requests.get(json_url) ❶
# 将数据写入文件
with open('btc_close_2017_request.json','w') as f:
    f.write(req.text) ❷
file_requests = req.json() ❸

输出结果为：

{'date': '2017-01-01', 'month': '01', 'week': '52', 'weekday': 'Sunday', 'close': '6928.6492'},
{'date': '2017-01-02', 'month': '01', 'week': '1', 'weekday': 'Monday', 'close': '7070.2554'},
--snip--
{'date': '2017-12-11', 'month': '12', 'week': '50', 'weekday': 'Monday', 'close': '110642.88'},
{'date': '2017-12-12', 'month': '12', 'week': '50', 'weekday': 'Tuesday', 'close': '113732.6745'}

requests通过get方法（见❶）向GitHub服务器发送请求。GitHub服务器响应请求后，返回的结果存储在req变量中。req.text属性可以直接读取文件数据，返回格式是字符串（见❷），可以像之前一样保存为文件btc_close_2017_request.json，其内容与btc_close_2017_urllib.json是一样的。另外，直接用req.json()（见❸）就可以将btc_close_2017.json文件的数据转换成Python列表file_requests，与之前的file_urllib内容相同。

print(file_urllib == file_requests)

输出结果为：

True

16.2.2　提取相关的数据

下面编写一个小程序来提取btc_close_2017.json文件中的相关信息：

btc_close_2017.py

import json

# 将数据加载到一个列表中
filename = 'btc_close_2017.json'
with open(filename) as f:
    btc_data = json.load(f) ❶
# 打印每一天的信息
for btc_dict in btc_data: ❷
    date = btc_dict['date'] ❸
    month = btc_dict['month']
    week = btc_dict['week']
    weekday = btc_dict['weekday']
    close = btc_dict['close']
    print("{} is month {} week {}, {}, the close price is {} RMB".format(date, month, week, weekday, close))

首先导入模块json，然后将数据存储在btc_data中（见❶）。在❷处，我们遍历了btc_data中的每个元素。每个元素都是一个字典，包含五个键-值对，btc_dict就用来存储字典中的每个键-值对。之后就可以取出所有键的值（见❸），并将日期、月份、周数、周几和收盘价相关联的值分别存储到date、month、week、weekday与close中。接下来，打印每一天的日期、月份、周数、周几和收盘价。输出结果如下：

2017-01-01 is month 01 week 52, Sunday, the close price is 6928.6492 RMB
2017-01-02 is month 01 week 1, Monday, the close price is 7070.2554 RMB
2017-01-03 is month 01 week 1, Tuesday, the close price is 7175.1082 RMB
--snip--
2017-12-10 is month 12 week 49, Sunday, the close price is 99525.1027 RMB
2017-12-11 is month 12 week 50, Monday, the close price is 110642.88 RMB
2017-12-12 is month 12 week 50, Tuesday, the close price is 113732.6745 RMB

现在，我们已经掌握了json读取数据的方法。下面，让我们将数据转换为Pygal能够处理的格式。

16.2.3　将字符串转换为数字值

btc_close_2017.json中的每个键和值都是字符串。为了能在后面的内容中对交易数据进行计算，需要先将表示周数和收盘价的字符串转换为数值。因此我们使用函数int()：

btc_close_2017.py

--snip--

# 打印每一天的信息
for btc_dict in btc_data:
    date = btc_dict['date']
    month = int(btc_dict['month'])
    week = int(btc_dict['week']) ❶
    weekday = btc_dict['weekday']
    close = int(btc_dict['close']) ❷
    print("{} is month {} week {}, {}, the close price is {} RMB".format(date, month, week, weekday, close))

在❶处，我们将周数的数值都转换为整数格式。将收盘价close转换为整数时，出现了ValueError异常，如下所示：

--snip--
      5     week = int(btc_dict['week']) ❶
      6     weekday = btc_dict['weekday']
----> 7     close = int(btc_dict['close']) ❷
      8     print("{} is month {} week {}, {}, the close price is {} RMB".format(date, month, week, weekday, close))

ValueError: invalid literal for int() with base 10: '6928.6492'

在实际工作中，原始数据的格式经常是不统一的，此类数值类型转换造成的ValueError异常十分普遍。这里的原因在于，Python不能直接将包含小数点的字符串'6928.6492'转换为整数。为了消除这种错误，需要先将字符串转换为浮点数（float），再将浮点数转换为整数（int）：

btc_close_2017.py

--snip--

# 打印每一天的信息
for btc_dict in btc_data:
    date = btc_dict['date']
    month = int(btc_dict['month'])
    week = int(btc_dict['week'])
    weekday = btc_dict['weekday']
    close = int(float(btc_dict['close'])) ❶
    print("{} is month {} week {}, {}, the close price is {} RMB".format(date, month, week, weekday, close))

这里首先用函数float()将字符串转换为小数（见❶），然后再用函数int()去掉小数部分（截尾取整），返回整数部分。现在，再次运行代码，就不会出现异常了。打印出的收盘价信息如下：

2017-01-01 is month 1 week 52, Sunday, the close price is 6928 RMB
2017-01-02 is month 1 week 1, Monday, the close price is 7070 RMB
2017-01-03 is month 1 week 1, Tuesday, the close price is 7175 RMB
--snip--
2017-12-10 is month 12 week 49, Sunday, the close price is 99525 RMB
2017-12-11 is month 12 week 50, Monday, the close price is 110642 RMB
2017-12-12 is month 12 week 50, Tuesday, the close price is 113732 RMB

现在，收盘价都已经成功地先从字符串转换成浮点数，再从浮点数转换成了整数。另外，我们还发现，月份中原来1~9月前面的数字0在转换成整数之后都消失了，周数的数据也根据我们的需求转换成了整数。有了这些数据之后，可以结合Pygal的可视化功能来探索一些有趣的信息。

16.2.4　绘制收盘价折线图

第15章已经介绍了用Pygal绘制条形图（bar chart）的方法，也介绍了用matplotlib绘制折线图（line chart）的方法。下面用Pygal来实现收盘价的折线图。

绘制折线图之前，需要获取x 轴与y 轴数据，因此我们创建了几个列表来存储数据。遍历btc_data，将转换为适当格式的数据存储到对应的列表中。对前面的代码做一些简单的调整：

btc_close_2017.py

--snip--

# 创建5个列表，分别存储日期和收盘价
dates = []
months = []
weeks = []
weekdays = []
close = []
# 每一天的信息
for btc_dict in btc_data:
    dates.append(btc_dict['date'])
    months.append(int(btc_dict['month']))
    weeks.append(int(btc_dict['week']))
    weekdays.append(btc_dict['weekday'])
    close.append(int(float(btc_dict['close'])))

有了x 轴与y 轴的数据，就可以绘制折线图了。由于数据点比较多，x 轴要显示346个日期，在有限的屏幕上会显得十分拥挤。因此我们需要利用Pygal的配置参数，对图形进行适当的调整。代码如下：

btc_close_2017.py

--snip--

import pygal

line_chart = pygal.Line(x_label_rotation=20, show_minor_x_labels=False) ❶
line_chart.title = '收盘价（¥）'
line_chart.x_labels = dates
N = 20 # x轴坐标每隔20天显示一次
line_chart.x_labels_major = dates[::N] ❷
line_chart.add('收盘价', close)
line_chart.render_to_file('收盘价折线图（¥）.svg')

首先导入模块pygal，然后在创建Line实例时，分别设置了x_label_rotation与show_minor_x_ labels作为初始化参数（见❶）。x_label_rotation=20让x 轴上的日期标签顺时针旋转20°，show_minor_x_labels=False则告诉图形不用显示所有的x 轴标签。设置了图形的标题和x 轴标签之后，我们配置x_labels_major属性，让x 轴坐标每隔20天显示一次（见❷），这样x 轴就不会显得非常拥挤了。最终效果如图16-7所示。

图16-7　收盘价折线图（¥）

从图中可以看出，价格从2017年11月12日到2017年12月12日快速增长，平均每天增值约2500元人民币，图中折线简直就像火箭发射一般，垂直升空。下面对价格做一些简单的探索。

16.2.5　时间序列特征初探

进行时间序列分析总是期望发现趋势（trend）、周期性（seasonality）和噪声（noise），从而能够描述事实、预测未来、做出决策。从收盘价的折线图可以看出，2017年的总体趋势是非线性的，而且增长幅度不断增大，似乎呈指数分布。但是，我们还发现，在每个季度末（3月、6月和9月）似乎有一些相似的波动。尽管这些波动被增长的趋势掩盖了，不过其中也许有周期性。为了验证周期性的假设，需要首先将非线性的趋势消除。对数变换（log transformation）是常用的处理方法之一。让我们用Python标准库的数学模块math来解决这个问题。math里有许多常用的数学函数，这里用以10为底的对数函数math.log10计算收盘价，日期仍然保持不变。这种方式称为半对数（semi-logarithmic）变换。代码如下：

btc_close_2017.py

--snip--

import pygal
import math

line_chart = pygal.Line(x_label_rotation=20, show_minor_x_labels=False)
line_chart.title = '收盘价对数变换（¥）'
line_chart.x_labels = dates
N = 20 # x轴坐标每隔20天显示一次
line_chart.x_labels_major = dates[::N]
close_log = [math.log10(_) for _ in close] ❶
line_chart.add('log收盘价', close_log)
line_chart.render_to_file('收盘价对数变换折线图（¥）.svg')

现在，用对数变换剔除非线性趋势之后，整体上涨的趋势更接近线性增长。从图16-8中可以清晰地看出，收盘价在每个季度末似乎有显著的周期性——3月、6月和9月都出现了剧烈的波动。那么，12月是不是会再现这一场景呢？下面再看看收盘价的月日均值与周日均值的表现。

图16-8　收盘价对数变换折线图（¥）

16.2.6　收盘价均值

下面再利用btc_close_2017.json文件中的数据，绘制2017年前11个月的日均值、前49周（2017-01-02~2017-12-10）的日均值，以及每周中各天（Monday~Sunday）的日均值。虽然这些日均值的数值不同，但都是一段时间的均值，计算方法是一样的。因此，可以将之前的绘图代码封装成函数，以便重复使用。

btc_close_2017.py

--snip--

from itertools import groupby ❶

def draw_line(x_data, y_data, title, y_legend):
    xy_map = []
    for x, y in groupby(sorted(zip(x_data, y_data)), key=lambda _: _[0]): ❷
        y_list = [v for _, v in y]
        xy_map.append([x, sum(y_list) / len(y_list)]) ❸
    x_unique, y_mean = [*zip(*xy_map)] ❹
    line_chart = pygal.Line()
    line_chart.title = title
    line_chart.x_labels = x_unique
    line_chart.add(y_legend, y_mean)
    line_chart.render_to_file(title+'.svg')
    return line_chart

由于需要将数据按月份、周数、周几分组，再计算每组的均值，因此我们导入Python标准库中模块itertools的函数groupby（见❶）。然后将x 轴与y 轴的数据合并、排序，再用函数groupby分组（见❷）。分组之后，求出每组的均值，存储到xy_map变量中（见❸）。最后，将xy_map中存储的x 轴与y 轴数据分离（见❹），就可以像之前那样用Pygal画图了。下面我们画出收盘价月日均值。由于2017年12月的数据并不完整，我们只取2017年1月到11月的数据。通过dates查找2017-12-01的索引位置，确定周数和收盘价的取数范围。代码如下所示：

idx_month = dates.index('2017-12-01')
line_chart_month = draw_line(months[:idx_month], close[:idx_month], '收盘价月日均值（¥）',
'月日均值')
line_chart_month

收盘价月日均值如图16-9所示。

图16-9　收盘价月日均值（¥）

从图16-9中可以看出，除了7月相比上个月有所下降，其他各月都是增长的。11月相比10月的增幅非常惊人，月日均增长了45%。

下面再来绘制前49周（2017-01-02~2017-12-10）的日均值。2017年1月1日是周日，归属为2016年第52周，因此2017年的第一周从2017年1月2日开始，取数时需要将第一天去掉。另外，2017年第49周周日是2017年12月10日，因此我们通过dates查找2017-12-11的索引位置，确定周数和收盘价的取数范围。代码如下所示：

idx_week = dates.index('2017-12-11')
line_chart_week = draw_line(weeks[1:idx_week], close[1:idx_week], '收盘价周日均值（¥）', '周日均值')
line_chart_week

收盘价周日均值如图16-10所示。

图16-10　收盘价周日均值（¥）

从图16-10中可以看出，价格与节假日无关。在2017年的各个节假日都没有出现价格低点，包括春节（第4周）、清明节（第14周）、劳动节（第18周）、端午节（第22周）、国庆节（第40周）。

最后，绘制每周中各天的均值。为了使用完整的时间段，还像前面那样取前49周（2017-01-02~2017-12-10）的数据，同样通过dates查找2017-12-11的索引位置，确定周数和收盘价的取数范围。但是，由于这里的周几是字符串，按周一到周日的顺序排列，而不是单词首字母的顺序，绘图时x 轴标签的顺序会有问题。另外，原来的周几都是英文单词，还可以将其调整为中文。因此，需要对前面的程序做一些特殊处理，代码如下所示：

idx_week = dates.index('2017-12-11')
wd = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday','Sunday'] ❶
weekdays_int = [wd.index(w) + 1 for w in weekdays[1:idx_week]]
line_chart_weekday = draw_line(weekdays_int, close[1:idx_week], '收盘价星期均值（¥）',
   '星期均值')
line_chart_weekday.x_labels = ['周一','周二','周三','周四','周五','周六','周日'] ❷
   line_chart_weekday.render_to_file('收盘价星期均值（¥）.svg')

首先，我们列出一周七天的英文单词，然后将weekdays的内容替换成1~7的整数（见❶）。这样函数draw_line在处理数据时按周几的顺序排列，就会将周一放在列表的第一位，周日放在列表的第七位。图形生成之后，再将图形的x轴标签替换为中文（见❷），最终输出的图形如图16-11所示。

图16-11　收盘价星期均值（¥）

从图16-11中可以看出，比特币收盘价在周一最低，周日最高。周一到周四快速拉升，周四是拐点，周五和周四基本持平（其实略低于周四），之后增速放慢。

16.2.7　收盘价数据仪表盘

前面已经为交易收盘价绘制了五幅图，分别是收盘价对数变换、收盘价月日均值、收盘价周日均值、收盘价星期均值。每个SVG文件打开之后都是独立的页面。如果能够将它们整合在一起，就可以很方便地进行长期管理、监测和分析。另外，新的图表也可以十分方便地加入进来，这样就形成了一个数据仪表盘（dashboard）。下面将前面绘制的图整合起来，做一个收盘价数据仪表盘。代码如下：

btc_close_2017.py

--snip--

with open('收盘价Dashboard.html', 'w', encoding='utf8') as html_file: ❶
    html_file.write('<html><head><title>收盘价Dashboard</title><meta charset="utf-8"></head><body>\n')
    for svg in [
            '收盘价折线图（¥）.svg', '收盘价对数变换折线图（¥）.svg', '收盘价月日均值（¥）.svg',
            '收盘价周日均值（¥）.svg', '收盘价星期均值（¥）.svg'
    ]:
        html_file.write('    <object type="image/svg+xml" data="{0}" height=500></object>\n'.format(svg))
    html_file.write('</body></html>')

和常见网络应用的数据仪表盘一样，我们的数据仪表盘也是一个完整的网页（HTML文件）。首先，需要创建一个名为收盘价Dashboard.html的网页文件，然后将每幅图都添加到页面中（见❶）。这里设置SVG图形的默认高度为500像素，由于SVG是矢量图，可以任意缩放且不失真，因此可以通过放大或缩小网页来调整视觉效果。最终效果如图16-12所示，每一幅图都是前面演示过的内容。相信聪明的你一定有更有趣的想法，赶紧动手往数据仪表盘里添加一些新图形吧。

图16-12　收盘价数据仪表盘

关于交易收盘价的分析就介绍到这里，内容非常粗糙、极不严谨，仅作为Python处理JSON文件格式的示例使用。数据背后的故事往往是非常复杂的，即使精通数据分析的技巧，也未必能预见未来。“世事洞明皆学问，人情练达即文章”，在数据分析过程中，这些修炼都是必不可少的，也是非常艰难的挑战。

动手试一试

16-5 分析更完整的数据：本节仅使用了交易收盘价在2017年的部分数据。如果要全面地分析价格走势，还是应该收集更加完整的数据；目前最早的交易时间数据可以追溯到2012年。如果你感兴趣，可以收集早期的数据进行分析。

16-6 选择你感兴趣的数据：免费的JSON格式数据非常丰富，许多著名的国际组织都在积极分享有价值的数据。例如，Open Knowledge International（https://okfn.org/）上就有许多有趣的JSON数据。你也可以用本节的方法获取它们，开启自己的分析项目。

16-7 测试函数draw_line：我们编写函数draw_line时，没有使用测试方法检验它能否正确工作。请利用你在第11章学到的知识，为这个函数编写合适的测试程序。

16-8 尝试Python数据科学工具：虽然Python标准库对数据分析的支持相对有限，但是Python具有非常完善的数据科学生态系统，有许多易学易用、高效便捷的第三方开源数据分析工具。除了前面介绍的matplotlib，还有科学计算工具包Numpy（http://www.numpy.org/）/Scipy（https://www.scipy.org/）、快速数据分析工具Pandas（https://pandas.pydata.org/）、机器学习工具Scikit-learn（http://scikit-learn.org），以及让深度学习开发更简单的Keras（https://keras.io/，它支持TensorFlow、CNTK和Theano）。如果感兴趣，可以用Pandas直接读取JSON文件数据，并进行格式转换、数据聚合、时间序列分析，结合Scikit-learn可以对收盘价进行回归分析与预测。

16.3　小结

在本章中，你学习了：如何使用网上的数据集；如何处理CSV和JSON文件，以及如何提取你感兴趣的数据；如何使用matplotlib来处理以往的天气数据，包括如何使用模块datetime，以及如何在同一个图表中绘制多个数据系列；如何使用模块json来访问以JSON格式存储的交易收盘价数据，并使用Pygal绘制图形以探索价格变化的周期性，以及如何将Pygal图形组合成数据仪表盘。

有了使用CSV和JSON文件的经验后，你将能够处理几乎任何要分析的数据。大多数在线数据集都可以以这两种格式中的一种或两种下载。学习使用这两种格式为学习使用其他格式的数据做好了准备。

在下一章，你将编写自动从网上采集数据并对其进行可视化的程序。如果你只是将编程作为业余爱好，学会这些技能可以增加乐趣；如果你有志于成为专业程序员，就必须掌握这些技能。

第 17 章　使用API

在本章中，你将学习如何编写一个独立的程序，并对其获取的数据进行可视化。这个程序将使用Web应用编程接口（API）自动请求网站的特定信息而不是整个网页，再对这些信息进行可视化。由于这样编写的程序始终使用最新的数据来生成可视化，因此即便数据瞬息万变，它呈现的信息也都是最新的。

17.1　使用Web API

Web API是网站的一部分，用于与使用非常具体的URL请求特定信息的程序交互。这种请求称为API调用。请求的数据将以易于处理的格式（如JSON或CSV）返回。依赖于外部数据源的大多数应用程序都依赖于API调用，如集成社交媒体网站的应用程序。

17.1.1　Git和GitHub

本章的可视化将基于来自GitHub的信息，这是一个让程序员能够协作开发项目的网站。我们将使用GitHub的API来请求有关该网站中Python项目的信息，然后使用Pygal生成交互式可视化，以呈现这些项目的受欢迎程度。

GitHub（https://github.com/）的名字源自Git，Git是一个分布式版本控制系统，让程序员团队能够协作开发项目。Git帮助大家管理为项目所做的工作，避免一个人所做的修改影响其他人所做的修改。你在项目中实现新功能时，Git将跟踪你对每个文件所做的修改。确定代码可行后，你提交所做的修改，而Git将记录项目最新的状态。如果你犯了错，想撤销所做的修改，可轻松地返回以前的任何可行状态（要更深入地了解如何使用Git进行版本控制，请参阅附录D）。GitHub上的项目都存储在仓库中，后者包含与项目相关联的一切：代码、项目参与者的信息、问题或bug报告等。

对于喜欢的项目，GitHub用户可给它加星（star）以表示支持，用户还可跟踪他可能想使用的项目。在本章中，我们将编写一个程序，它自动下载GitHub上星级最高的Python项目的信息，并对这些信息进行可视化。

17.1.2　使用API调用请求数据

GitHub的API让你能够通过API调用来请求各种信息。要知道API调用是什么样的，请在浏览器的地址栏中输入如下地址并按回车键：

https://api.github.com/search/repositories?q=language:python&sort=stars

这个调用返回GitHub当前托管了多少个Python项目，还有有关最受欢迎的Python仓库的信息。下面来仔细研究这个调用。第一部分（https://api.github.com/）将请求发送到GitHub网站中响应API调用的部分；接下来的一部分（search/repositories）让API搜索GitHub上的所有仓库。

repositories后面的问号指出我们要传递一个实参。q表示查询，而等号让我们能够开始指定查询（q=）。通过使用language:python，我们指出只想获取主要语言为Python的仓库的信息。最后一部分（&sort=stars）指定将项目按其获得的星级进行排序。

下面显示了响应的前几行。从响应可知，该URL并不适合人工输入。

{
  "total_count": 713062,
  "incomplete_results": false,
  "items": [
    {
      "id": 3544424,
      "name": "httpie",
      "full_name": "jkbrzt/httpie",
      --snip--

从第二行输出可知，编写本书时，GitHub总共有713 062个Python项目。"incomplete_results"的值为false，据此我们知道请求是成功的（它并非不完整的）。倘若GitHub无法全面处理该API，它返回的这个值将为true。接下来的列表中显示了返回的"items"，其中包含GitHub上最受欢迎的Python项目的详细信息。

17.1.3　安装requests

requests包让Python程序能够轻松地向网站请求信息以及检查返回的响应。要安装requests，请执行类似于下面的命令：

$ pip install --user requests

如果你还没有使用过pip，请参阅12.2.1节（根据系统的设置，你可能需要使用这个命令的稍微不同的版本）。

17.1.4　处理API响应

下面来编写一个程序，它执行API调用并处理结果，找出GitHub上星级最高的Python项目：

python_repos.py

import requests ❶

# 执行API调用并存储响应
url = 'https://api.github.com/search/repositories?q=language:python&sort=stars' ❷
r = requests.get(url) ❸
print("Status code:", r.status_code) ❹

# 将API响应存储在一个变量中
response_dict = r.json() ❺

# 处理结果
print(response_dict.keys())

在❶处，我们导入了模块requests。在❷处，我们存储API调用的URL，然后使用requests来执行调用（见❸）。我们调用get()并将URL传递给它，再将响应对象存储在变量r中。响应对象包含一个名为status_code的属性，它让我们知道请求是否成功了（状态码200表示请求成功）。在❹处，我们打印status_code，核实调用是否成功了。

这个API返回JSON格式的信息，因此我们使用方法json()将这些信息转换为一个Python字典（见❺）。我们将转换得到的字典存储在response_dict中。

最后，我们打印response_dict中的键。输出如下：

Status code: 200
dict_keys(['items', 'total_count', 'incomplete_results'])

状态码为200，因此我们知道请求成功了。响应字典只包含三个键：'items'、'total_count'和'incomplete_results'。

注意　像这样简单的调用应该会返回完整的结果集，因此完全可以忽略与'incomplete_results'相关联的值。但执行更复杂的API调用时，程序应检查这个值。

17.1.5　处理响应字典

将API调用返回的信息存储到字典中后，就可以处理这个字典中的数据了。下面来生成一些概述这些信息的输出。这是一种不错的方式，可确认收到了期望的信息，进而可以开始研究感兴趣的信息：

python_repos.py

import requests

# 执行API调用并存储响应
url = 'https://api.github.com/search/repositories?q=language:python&sort=stars'
r = requests.get(url)
print("Status code:", r.status_code)

# 将API响应存储在一个变量中
response_dict = r.json()
print("Total repositories:", response_dict['total_count']) ❶

# 探索有关仓库的信息
repo_dicts = response_dict['items'] ❷
print("Repositories returned:", len(repo_dicts))

# 研究第一个仓库
repo_dict = repo_dicts[0] ❸
print("\nKeys:", len(repo_dict)) ❹
for key in sorted(repo_dict.keys()): ❺
    print(key)

在❶处，我们打印了与'total_count'相关联的值，它指出了GitHub总共包含多少个Python仓库。

与'items'相关联的值是一个列表，其中包含很多字典，而每个字典都包含有关一个Python仓库的信息。在❷处，我们将这个字典列表存储在repo_dicts中。接下来，我们打印repo_dicts的长度，以获悉我们获得了多少个仓库的信息。

为更深入地了解返回的有关每个仓库的信息，我们提取了repo_dicts中的第一个字典，并将其存储在repo_dict中（见❸）。接下来，我们打印这个字典包含的键数，看看其中有多少信息（见❹）。在❺处，我们打印这个字典的所有键，看看其中包含哪些信息。

输出让我们对实际包含的数据有了更清晰的认识：

Status code: 200
Total repositories: 713062
Repositories returned: 30

Keys: 68 ❶
archive_url
assignees_url
blobs_url
--snip--
url
watchers
watchers_count

GitHub的API返回有关每个仓库的大量信息：repo_dict包含68个键（见❶）。通过仔细查看这些键，可大致知道可提取有关项目的哪些信息（要准确地获悉API将返回哪些信息，要么阅读文档，要么像此处这样使用代码来查看这些信息）。

下面来提取repo_dict中与一些键相关联的值：

python_repos.py

--snip--
# 研究有关仓库的信息
repo_dicts = response_dict['items']
print("Repositories returned:", len(repo_dicts))

# 研究第一个仓库
repo_dict = repo_dicts[0]

print("\nSelected information about first repository:")
print('Name:', repo_dict['name']) ❶
print('Owner:', repo_dict['owner']['login']) ❷
print('Stars:', repo_dict['stargazers_count']) ❸
print('Repository:', repo_dict['html_url'])
print('Created:', repo_dict['created_at']) ❹
print('Updated:', repo_dict['updated_at']) ❺
print('Description:', repo_dict['description'])

在这里，我们打印了表示第一个仓库的字典中与很多键相关联的值。在❶处，我们打印了项目的名称。项目所有者是用一个字典表示的，因此在❷处，我们使用键owner来访问表示所有者的字典，再使用键key来获取所有者的登录名。在❸处，我们打印项目获得了多少个星的评级，以及项目在GitHub仓库的URL。接下来，我们显示项目的创建时间（见❹）和最后一次更新的时间（见❺）。最后，我们打印仓库的描述。输出类似于下面这样：

Status code: 200
Total repositories: 713065
Repositories returned: 30

Selected information about first repository:
Name: httpie
Owner: jkbrzt
Stars: 16101
Repository: https://github.com/jkbrzt/httpie
Created: 2012-02-25T12:39:13Z
Updated: 2015-07-13T14:56:41Z
Description: CLI HTTP client; user-friendly cURL replacement featuring intuitive UI, JSON support, syntax highlighting, wget-like downloads, extensions, etc.

从上述输出可知，编写本书时，GitHub上星级最高的Python项目为HTTPie，其所有者为用户jkbrzt，有16 000多个GitHub用户给这个项目加星。我们可以看到这个项目的仓库的URL，其创建时间为2012年2月，且最近更新了。最后，描述指出HTTPie用于帮助从终端执行HTTP调用（CLI是命令行界面的缩写）。

17.1.6　概述最受欢迎的仓库

对这些数据进行可视化时，我们需要涵盖多个仓库。下面就来编写一个循环，打印API调用返回的每个仓库的特定信息，以便能够在可视化中包含所有这些信息：

python_repos.py

--snip--
# 研究有关仓库的信息
repo_dicts = response_dict['items']
print("Repositories returned:", len(repo_dicts))

print("\nSelected information about each repository:") ❶
for repo_dict in repo_dicts: ❷
    print('\nName:', repo_dict['name'])
    print('Owner:', repo_dict['owner']['login'])
    print('Stars:', repo_dict['stargazers_count'])
    print('Repository:', repo_dict['html_url'])
    print('Description:', repo_dict['description'])

在❶处，我们打印了一条说明性消息。在❷处，我们遍历repo_dicts中的所有字典。在这个循环中，我们打印每个项目的名称、所有者、星级、在GitHub上的URL以及描述：

Status code: 200
Total repositories: 713067
Repositories returned: 30

Selected information about each repository:

Name: httpie
Owner: jkbrzt
Stars: 16101
Repository: https://github.com/jkbrzt/httpie
Description: CLI HTTP client; user-friendly cURL replacement featuring intuitive UI, JSON support, syntax highlighting, wget-like downloads, extensions, etc.

Name: django
Owner: django
Stars: 15028
Repository: https://github.com/django/django
Description: The Web framework for perfectionists with deadlines.
--snip--

Name: powerline
Owner: powerline
Stars: 4315
Repository: https://github.com/powerline/powerline
Description: Powerline is a statusline plugin for vim, and provides statuslines and prompts for several other applications, including zsh, bash, tmux, IPython, Awesome and Qtile.

上述输出中有一些有趣的项目，可能值得再看一眼。但不要在这上面花费太多时间，因为我们即将创建的可视化可让你更容易地看清结果。

17.1.7　监视API的速率限制

大多数API都存在速率限制，即你在特定时间内可执行的请求数存在限制。要获悉你是否接近了GitHub的限制，请在浏览器中输入https://api.github.com/rate_limit，你将看到类似于下面的响应：

{
  "resources": {
    "core": {
      "limit": 60,
      "remaining": 58,
      "reset": 1426082320
    },
    "search": { ❶
      "limit": 10, ❷
      "remaining": 8, ❸
      "reset": 1426078803 ❹
    }
  },
  "rate": {
    "limit": 60,
    "remaining": 58,
    "reset": 1426082320
  }
}

我们关心的信息是搜索API的速率限制（见❶）。从❷处可知，极限为每分钟10个请求，而在当前这一分钟内，我们还可执行8个请求（见❸）。reset值指的是配额将重置的Unix时间或新纪元时间（1970年1月1日午夜后多少秒）（见❹）。用完配额后，你将收到一条简单的响应，由此知道已到达API极限。到达极限后，你必须等待配额重置。

注意　很多API都要求你注册获得API密钥后才能执行API调用。编写本书时，GitHub没有这样的要求，但获得API密钥后，配额将高得多。

17.2　使用Pygal可视化仓库

有了一些有趣的数据后，我们来进行可视化，呈现GitHub上Python项目的受欢迎程度。我们将创建一个交互式条形图：条形的高度表示项目获得了多少颗星。单击条形将带你进入项目在GitHub上的主页。下面是首次尝试这样做：

python_repos.py

import requests
import pygal
from pygal.style import LightColorizedStyle as LCS, LightenStyle as LS

# 执行API调用并存储响应
URL = 'https://api.github.com/search/repositories?q=language:python&sort=star'
r = requests.get(URL)
print("Status code:", r.status_code)

# 将API响应存储在一个变量中
response_dict = r.json()
print("Total repositories:", response_dict['total_count'])

# 研究有关仓库的信息
repo_dicts = response_dict['items']

names, stars = [], [] ❶
for repo_dict in repo_dicts:
    names.append(repo_dict['name']) ❷
    stars.append(repo_dict['stargazers_count'])

# 可视化
my_style = LS('#333366', base_style=LCS) ❸
chart = pygal.Bar(style=my_style, x_label_rotation=45, show_legend=False) ❹
chart.title = 'Most-Starred Python Projects on GitHub'
chart.x_labels = names

chart.add('', stars) ❺
chart.render_to_file('python_repos.svg')

我们首先导入了pygal以及要应用于图表的Pygal样式。接下来，打印API调用响应的状态以及找到的仓库总数，以便获悉API调用是否出现了问题。我们不再打印返回的有关项目的信息，因为将通过可视化来呈现这些信息。

在❶处，我们创建了两个空列表，用于存储将包含在图表中的信息。我们需要每个项目的名称，用于给条形加上标签，我们还需要知道项目获得了多少个星，用于确定条形的高度。在循环中，我们将项目的名称和获得的星数附加到这些列表的末尾❷。

接下来，我们使用LightenStyle类（别名LS）定义了一种样式，并将其基色设置为深蓝色（见❸）。我们还传递了实参base_style，以使用LightColorizedStyle类（别名LCS）。然后，我们使用Bar()创建一个简单的条形图，并向它传递了my_style（见❹）。我们还传递了另外两个样式实参：让标签绕x轴旋转45度（x_label_rotation=45），并隐藏了图例（show_legend=False），因为我们只在图表中绘制一个数据系列。接下来，我们给图表指定了标题，并将属性x_labels设置为列表names。

由于我们不需要给这个数据系列添加标签，因此在❺处添加数据时，将标签设置成了空字符串。生成的图表如图17-1所示。从中可知，前几个项目的受欢迎程度比其他项目高得多，但所有这些项目在Python生态系统中都很重要。

图17-1　GitHub上受欢迎程度最高的Python项目

17.2.1　改进Pygal图表

下面来改进这个图表的样式。我们将进行多个方面的定制，因此先来稍微调整代码的结构，创建一个配置对象，在其中包含要传递给Bar()的所有定制：

python_repos.py

--snip--

# 可视化
my_style = LS('#333366', base_style=LCS)

my_config = pygal.Config() ❶
my_config.x_label_rotation = 45 ❷
my_config.show_legend = False
my_config.title_font_size = 24 ❸
my_config.label_font_size = 14
my_config.major_label_font_size = 18
my_config.truncate_label = 15 ❹
my_config.show_y_guides = False ❺
my_config.width = 1000 ❻

chart = pygal.Bar(my_config, style=my_style) ❼
chart.title = 'Most-Starred Python Projects on GitHub'
chart.x_labels = names

chart.add('', stars)
chart.render_to_file('python_repos.svg')

在❶处，我们创建了一个Pygal类Config的实例，并将其命名为my_config。通过修改my_config的属性，可定制图表的外观。在❷处，我们设置了两个属性——x_label_rotation和show_legend，它们原来是在创建Bar实例时以关键字实参的方式传递的。在❸处，我们设置了图表标题、副标签和主标签的字体大小。在这个图表中，副标签是 x 轴上的项目名以及 y 轴上的大部分数字。主标签是 y 轴上为5000整数倍的刻度；这些标签应更大，以与副标签区分开来。在❹处，我们使用truncate_label将较长的项目名缩短为15个字符（如果你将鼠标指向屏幕上被截短的项目名，将显示完整的项目名）。接下来，我们将show_y_guides设置为False，以隐藏图表中的水平线（见❺）。最后，在❻处设置了自定义宽度，让图表更充分地利用浏览器中的可用空间。

在❼处创建Bar实例时，我们将my_config作为第一个实参，从而通过一个实参传递了所有的配置设置。我们可以通过my_config做任意数量的样式和配置修改，而❼处的代码行将保持不变。图17-2显示了重新设置样式后的图表。

图17-2　改进了图表的样式

17.2.2　添加自定义工具提示

在Pygal中，将鼠标指向条形将显示它表示的信息，这通常称为工具提示。在这个示例中，当前显示的是项目获得了多少个星。下面来创建一个自定义工具提示，以同时显示项目的描述。

来看一个简单的示例，它可视化前三个项目，并给每个项目对应的条形都指定自定义标签。为此，我们向add()传递一个字典列表，而不是值列表：

bar_descriptions.py

import pygal
from pygal.style import LightColorizedStyle as LCS, LightenStyle as LS

my_style = LS('#333366', base_style=LCS)
chart = pygal.Bar(style=my_style, x_label_rotation=45, show_legend=False)

chart.title = 'Python Projects'
chart.x_labels = ['httpie', 'django', 'flask']

plot_dicts = [ ❶
    {'value': 16101, 'label': 'Description of httpie.'}, ❷
    {'value': 15028, 'label': 'Description of django.'},
    {'value': 14798, 'label': 'Description of flask.'},
    ]

chart.add('', plot_dicts) ❸
chart.render_to_file('bar_descriptions.svg')

在❶处，我们定义了一个名为plot_dicts的列表，其中包含三个字典，分别针对项目HTTPie、Django和Flask。每个字典都包含两个键：'value'和'label'。Pygal根据与键'value'相关联的数字来确定条形的高度，并使用与'label'相关联的字符串给条形创建工具提示。例如，❷处的第一个字典将创建一个条形，用于表示一个获得了16 101颗星、工具提示为Description of httpie的项目。

方法add()接受一个字符串和一个列表。这里调用add()时，我们传入了一个由表示条形的字典组成的列表（plot_dicts）（见❸）。图17-3显示了一个工具提示：除默认工具提示（获得的星数）外，Pygal还显示了我们传入的自定义提示。

图17-3　每个条形都有自定义的工具提示标签

17.2.3　根据数据绘图

为根据数据绘图，我们将自动生成plot_dicts，其中包含API调用返回的30个项目的信息。

完成这种工作的代码如下：

python_repos.py

--snip--
# 研究有关仓库的信息
repo_dicts = response_dict['items']
print("Number of items:", len(repo_dicts))

names, plot_dicts = [], [] ❶
for repo_dict in repo_dicts:
    names.append(repo_dict['name'])

    plot_dict = { ❷
        'value': repo_dict['stargazers_count'],
        'label': repo_dict['description'],
        }
    plot_dicts.append(plot_dict) ❸

# 可视化
my_style = LS('#333366', base_style=LCS)
--snip--

chart.add('', plot_dicts) ❹
chart.render_to_file('python_repos.svg')

在❶处，我们创建了两个空列表names和plot_dicts。为生成x轴上的标签，我们依然需要列表names。

在循环内部，对于每个项目，我们都创建了字典plot_dict（见❷）。在这个字典中，我们使用键'value'存储了星数，并使用键'label'存储了项目描述。接下来，我们将字典plot_dict附加到plot_dicts末尾（见❸）。在❹处，我们将列表plot_dicts传递给了add()。图17-4显示了生成的图表。

图17-4　将鼠标指向条形将显示项目的描述

17.2.4　在图表中添加可单击的链接

Pygal还允许你将图表中的每个条形用作网站的链接。为此，只需添加一行代码，在为每个项目创建的字典中，添加一个键为'xlink'的键—值对：

python_repos.py

--snip--
names, plot_dicts = [], []
for repo_dict in repo_dicts:
    names.append(repo_dict['name'])

    plot_dict = {
        'value': repo_dict['stargazers_count'],
        'label': repo_dict['description'],
        'xlink': repo_dict['html_url'],
        }
    plot_dicts.append(plot_dict)
--snip--

Pygal根据与键'xlink'相关联的URL将每个条形都转换为活跃的链接。单击图表中的任何条形时，都将在浏览器中打开一个新的标签页，并在其中显示相应项目的GitHub页面。至此，你对API获取的数据进行了可视化，它是交互性的，包含丰富的信息！

17.3　Hacker News API

为探索如何使用其他网站的API调用，我们来看看Hacker News（http://news.ycombinator.com/）。在Hacker News网站，用户分享编程和技术方面的文章，并就这些文章展开积极的讨论。Hacker News的API让你能够访问有关该网站所有文章和评论的信息，且不要求你通过注册获得密钥。

下面的调用返回本书编写时最热门的文章的信息：

https://hacker-news.firebaseio.com/v0/item/9884165.json

响应是一个字典，包含ID为9884165的文章的信息：

{
    'url': 'http://www.bbc.co.uk/news/science-environment-33524589', ❶
    'type': 'story',
    'title': 'New Horizons: Nasa spacecraft speeds past Pluto', ❷
    'descendants': 141, ❸
    'score': 230,
    'time': 1436875181,
    'text': '',
    'by': 'nns',
    'id': 9884165,
    'kids': [9884723, 9885099, 9884789, 9885604, 9885844] ❹
}

这个字典包含很多键，如'url'（见❶）和'title'（见❷）。与键'descendants'相关联的值是文章被评论的次数（见❸）。与键'kids'相关联的值包含对文章所做的所有评论的ID（见❹）。每个评论自己也可能有kid，因此文章的后代（descendant）数量可能比其kid数量多。

下面来执行一个API调用，返回Hacker News上当前热门文章的ID，再查看每篇排名靠前的文章：

hn_submissions.py

import requests

from operator import itemgetter

# 执行API调用并存储响应
url = 'https://hacker-news.firebaseio.com/v0/topstories.json' ❶
r = requests.get(url)
print("Status code:", r.status_code)

# 处理有关每篇文章的信息
submission_ids = r.json() ❷
submission_dicts = [] ❸
for submission_id in submission_ids[:30]:
    # 对于每篇文章，都执行一个API调用
    url = ('https://hacker-news.firebaseio.com/v0/item/' + ❹
            str(submission_id) + '.json')
    submission_r = requests.get(url)
    print(submission_r.status_code)
    response_dict = submission_r.json()

    submission_dict = { ❺
        'title': response_dict['title'],
        'link': 'http://news.ycombinator.com/item?id=' + str(submission_id),
        'comments': response_dict.get('descendants', 0) ❻
        }
    submission_dicts.append(submission_dict)

submission_dicts = sorted(submission_dicts, key=itemgetter('comments'), ❼
                            reverse=True)

for submission_dict in submission_dicts: ❽
    print("\nTitle:", submission_dict['title'])
    print("Discussion link:", submission_dict['link'])
    print("Comments:", submission_dict['comments'])

首先，我们执行了一个API调用，并打印了响应的状态（见❶）。这个API调用返回一个列表，其中包含Hacker News上当前最热门的500篇文章的ID。接下来，我们将响应文本转换为一个Python列表（见❷），并将其存储在submission_ids中。我们将使用这些ID来创建一系列字典，其中每个字典都存储了一篇文章的信息。

在❸处，我们创建了一个名为submission_dicts的空列表，用于存储前面所说的字典。接下来，我们遍历前30篇文章的ID。对于每篇文章，我们都执行一个API调用，其中的URL包含submission_id的当前值（见❹）。我们打印每次请求的状态，以便知道请求是否成功了。

在❺处，我们为当前处理的文章创建一个字典，并在其中存储文章的标题以及到其讨论页面的链接。在❻处，我们在这个字典中存储了评论数。如果文章还没有评论，响应字典中将没有键'descendants'。不确定某个键是否包含在字典中时，可使用方法dict.get()，它在指定的键存在时返回与之相关联的值，并在指定的键不存在时返回你指定的值（这里是0）。最后，我们将submission_dict附加到submission_dicts末尾。

Hacker News上的文章是根据总体得分排名的，而总体得分取决于很多因素，其中包含被推荐的次数、评论数以及发表的时间。我们要根据评论数对字典列表submission_dicts进行排序，为此，使用了模块operator中的函数itemgetter()（见❼）。我们向这个函数传递了键'comments'，因此它将从这个列表的每个字典中提取与键'comments'相关联的值。这样，函数sorted()将根据这种值对列表进行排序。我们将列表按降序排列，即评论最多的文章位于最前面。

对列表排序后，我们遍历这个列表（见❽），对于每篇热门文章，都打印其三项信息：标题、到讨论页面的链接以及文章现有的评论数：

Status code: 200
200
200
200
--snip--

Title: Firefox deactivates Flash by default
Discussion link: http://news.ycombinator.com/item?id=9883246
Comments: 231

Title: New Horizons: Nasa spacecraft speeds past Pluto
Discussion link: http://news.ycombinator.com/item?id=9884165
Comments: 142

Title: Iran Nuclear Deal Is Reached With World Powers
Discussion link: http://news.ycombinator.com/item?id=9884005
Comments: 141

Title: Match Group Buys PlentyOfFish for $575M
Discussion link: http://news.ycombinator.com/item?id=9884417
Comments: 75

Title: Our Nexus 4 devices are about to explode
Discussion link: http://news.ycombinator.com/item?id=9885625
Comments: 14

--snip--

使用任何API来访问和分析信息时，流程都与此类似。有了这些数据后，你就可以进行可视化，指出最近哪些文章引发了最激烈的讨论。

动手试一试

17-1 其他语言：修改python_repos.py中的API调用，使其在生成的图表中显示使用其他语言编写的最受欢迎的项目。请尝试语言JavaScript、Ruby、C、Java、Perl、Haskell和Go等。

17-2 最活跃的讨论：使用hn_submissions.py中的数据，创建一个条形图，显示Hacker News上当前最活跃的讨论。条形的高度应对应于文章得到的评论数量，条形的标签应包含文章的标题，而每个条形应是到该文章讨论页面的链接。

17-3 测试python_repos.py：在python_repos.py中，打印status_code的值，以核实API调用是否成功了。请编写一个名为test_python_repos.py的程序，它使用unittest来断言status_code的值为200。想想你还可做出哪些断言，如返回的条目数符合预期，仓库总数超过特定的值等。

17.4　小结

在本章中，你学习了：如何使用API来编写独立的程序，它们自动采集所需的数据并对其进行可视化；使用GitHub API来探索GitHub上星级最高的Python项目，还大致地了解了Hacker News API；如何使用requests包来自动执行GitHub API调用，以及如何处理调用的结果。我们还简要地介绍了一些Pygal设置，使用它们可进一步定制生成的图表的外观。

在本书的最后一个项目中，我们将使用Django来创建一个Web应用程序。

项目3　Web应用程序

第 18 章　Django入门

当今的网站实际上都是富应用程序（rich application），就像成熟的桌面应用程序一样。Python提供了一组开发Web应用程序的卓越工具。在本章中，你将学习如何使用Django（http://djangoproject.com/）来开发一个名为“学习笔记”（Learning Log）的项目，这是一个在线日志系统，让你能够记录所学习的有关特定主题的知识。

我们将为这个项目制定规范，然后为应用程序使用的数据定义模型。我们将使用Django的管理系统来输入一些初始数据，再学习编写视图和模板，让Django能够为我们的网站创建网页。

Django是一个Web框架——一套用于帮助开发交互式网站的工具。Django能够响应网页请求，还能让你更轻松地读写数据库、管理用户等。在第19章和第20章，我们将改进“学习笔记”项目，再将其部署到活动的服务器，让你和你的朋友能够使用它。

18.1　建立项目

建立项目时，首先需要以规范的方式对项目进行描述，再建立虚拟环境，以便在其中创建项目。

18.1.1　制定规范

完整的规范详细说明了项目的目标，阐述了项目的功能，并讨论了项目的外观和用户界面。与任何良好的项目规划和商业计划书一样，规范应突出重点，帮助避免项目偏离轨道。这里不会制定完整的项目规划，而只列出一些明确的目标，以突出开发的重点。我们制定的规范如下：

我们要编写一个名为“学习笔记”的Web应用程序，让用户能够记录感兴趣的主题，并在学习每个主题的过程中添加日志条目。“学习笔记”的主页对这个网站进行描述，并邀请用户注册或登录。用户登录后，就可创建新主题、添加新条目以及阅读既有的条目。

学习新的主题时，记录学到的知识可帮助跟踪和复习这些知识。优秀的应用程序让这个记录过程简单易行。

18.1.2　建立虚拟环境

要使用Django，首先需要建立一个虚拟工作环境。虚拟环境是系统的一个位置，你可以在其中安装包，并将其与其他Python包隔离。将项目的库与其他项目分离是有益的，且为了在第20章将“学习笔记”部署到服务器，这也是必须的。

为项目新建一个目录，将其命名为learning_log，再在终端中切换到这个目录，并创建一个虚拟环境。如果你使用的是Python 3，可使用如下命令来创建虚拟环境：

learning_log$ python -m venv ll_env
learning_log$

这里运行了模块venv，并使用它来创建一个名为ll_env的虚拟环境。如果这样做管用，请跳到后面的18.1.4节；如果不管用，请阅读18.1.3节。

18.1.3　安装virtualenv

如果你使用的是较早的Python版本，或者系统没有正确地设置，不能使用模块venv，可安装virtualenv包。为此，可执行如下命令：

$ pip install --user virtualenv

别忘了，对于这个命令，你可能需要使用稍微不同的版本（如果你没有使用过pip，请参阅12.2.1节）。

注意　如果你使用的是Linux系统，且上面的做法不管用，可使用系统的包管理器来安装virtualenv。例如，要在Ubuntu系统中安装virtualenv，可使用命令sudo apt-get install python-virtualenv。

在终端中切换到目录learning_log，并像下面这样创建一个虚拟环境：

learning_log$ virtualenv ll_env
New python executable in ll_env/bin/python
Installing setuptools, pip...done.
learning_log$

注意　如果你的系统安装了多个Python版本，需要指定virtualenv使用的版本。例如，命令virtualenv ll_env --python=python3创建一个使用Python 3的虚拟环境。

18.1.4　激活虚拟环境

建立虚拟环境后，需要使用下面的命令激活它：

learning_log$ source ll_env/bin/activate
(ll_env)learning_log$ ❶

这个命令运行ll_env/bin中的脚本activate。环境处于活动状态时，环境名将包含在括号内，如❶处所示。在这种情况下，你可以在环境中安装包，并使用已安装的包。你在ll_env中安装的包仅在该环境处于活动状态时才可用。

注意　如果你使用的是Windows系统，请使用命令ll_env\Scripts\activate（不包含source）来激活这个虚拟环境。

要停止使用虚拟环境，可执行命令deactivate：

(ll_env)learning_log$ deactivate
learning_log$

如果关闭运行虚拟环境的终端，虚拟环境也将不再处于活动状态。 ###**18.1.5　安装Django** 创建并激活虚拟环境后，就可安装Django了：

(ll_env)learning_log$ pip install Django
Installing collected packages: Django
Successfully installed Django
Cleaning up...
(ll_env)learning_log$

由于我们是在虚拟环境中工作，因此在所有的系统中，安装Django的命令都相同：不需要指定标志--user，也无需使用python -m pip install package_name这样较长的命令。

别忘了，Django仅在虚拟环境处于活动状态时才可用。

18.1.6　在Django中创建项目

在依然处于活动的虚拟环境的情况下（ll_env包含在括号内），执行如下命令来新建一个项目：

(ll_env)learning_log$ django-admin startproject learning_log . ❶
(ll_env)learning_log$ ls ❷
learning_log ll_env manage.py
(ll_env)learning_log$ ls learning_log ❸
__init__.py  settings.py  urls.py  wsgi.py

❶处的命令让Django新建一个名为learning_log的项目。这个命令末尾的句点让新项目使用合适的目录结构，这样开发完成后可轻松地将应用程序部署到服务器。

注意　千万别忘了这个句点，否则部署应用程序时将遭遇一些配置问题。如果忘记了这个句点，就将创建的文件和文件夹删除（ll_env除外），再重新运行这个命令。

在❸处，运行了命令ls（在Windows系统上应为dir），结果表明Django新建了一个名为learning_log的目录。它还创建了一个名为manage.py的文件，这是一个简单的程序，它接受命令并将其交给Django的相关部分去运行。我们将使用这些命令来管理诸如使用数据库和运行服务器等任务。

目录learning_log包含4个文件（见❸），其中最重要的是settings.py、urls.py和wsgi.py。文件settings.py指定Django如何与你的系统交互以及如何管理项目。在开发项目的过程中，我们将修改其中一些设置，并添加一些设置。文件urls.py告诉Django应创建哪些网页来响应浏览器请求。文件wsgi.py帮助Django提供它创建的文件，这个文件名是web server gateway interface（Web服务器网关接口）的首字母缩写。

18.1.7　创建数据库

Django将大部分与项目相关的信息都存储在数据库中，因此我们需要创建一个供Django使用的数据库。为给项目“学习笔记”创建数据库，请在处于活动虚拟环境中的情况下执行下面的命令：

(ll_env)learning_log$ python manage.py migrate
Operations to perform:❶
  Synchronize unmigrated apps: messages, staticfiles
  Apply all migrations: contenttypes, sessions, auth, admin
  --snip--
  Applying sessions.0001_initial... OK
(ll_env)learning_log$ ls ❷
db.sqlite3  learning_log  ll_env  manage.py

我们将修改数据库称为迁移数据库。首次执行命令migrate时，将让Django确保数据库与项目的当前状态匹配。在使用SQLite（后面将更详细地介绍）的新项目中首次执行这个命令时，Django将新建一个数据库。在❶处，Django指出它将创建必要的数据库表，用于存储我们将在这个项目（Synchronize unmigrated apps，同步未迁移的应用程序）中使用的信息，再确保数据库结构与当前代码（Apply all migrations，应用所有的迁移）匹配。

在❷处，我们运行了命令ls，其输出表明Django又创建了一个文件——db.sqlite3。SQLite是一种使用单个文件的数据库，是编写简单应用程序的理想选择，因为它让你不用太关注数据库管理的问题。

18.1.8　查看项目

下面来核实Django是否正确地创建了项目。为此，可执行命令runserver，如下所示：

(ll_env)learning_log$ python manage.py runserver
Performing system checks...

System check identified no issues (0 silenced). ❶
July 15, 2015 - 06:23:51
Django version 1.8.4, using settings 'learning_log.settings' ❷
Starting development server at http://127.0.0.1:8000/ ❸
Quit the server with CONTROL-C.

Django启动一个服务器，让你能够查看系统中的项目，了解它们的工作情况。当你在浏览器中输入URL以请求网页时，该Django服务器将进行响应：生成合适的网页，并将其发送给浏览器。

在❶处，Django通过检查确认正确地创建了项目；在❷处，它指出了使用的Django版本以及当前使用的设置文件的名称；在❸处，它指出了项目的URL。URL http://127.0.0.1:8000/表明项目将在你的计算机（即localhost）的端口8000上侦听请求。localhost是一种只处理当前系统发出的请求，而不允许其他任何人查看你正在开发的网页的服务器。

现在打开一款Web浏览器，并输入URL：http://localhost:8000/；如果这不管用，请输入http://127.0.0.1:8000/。你将看到类似于图18-1所示的页面，这个页面是Django创建的，让你知道到目前为止一切正常。现在暂时不要关闭这个服务器。若要关闭这个服务器，按Ctrl + C即可。

图18-1　到目前为止一切正常

注意　如果出现错误消息“That port is already in use”（指定端口已被占用），请执行命令python manage.py runserver 8001，让Diango使用另一个端口；如果这个端口也不可用，请不断执行上述命令，并逐渐增大其中的端口号，直到找到可用的端口。

动手试一试

18-1 新项目：为更深入地了解Django做了些什么，可创建两个空项目，看看Django创建了什么。新建一个文件夹，并给它指定简单的名称，如InstaBook或FaceGram（不要在目录learning_log中新建该文件夹），在终端中切换到该文件夹，并创建一个虚拟环境。在这个虚拟环境中安装Django，并执行命令django-admin.py startproject instabook.（千万不要忘了这个命令末尾的句点）。

看看这个命令创建了哪些文件和文件夹，并与项目“学习笔记”包含的文件和文件夹进行比较。这样多做几次，直到对Django新建项目时创建的东西了如指掌。然后，将项目目录删除——如果你想这样做的话。

18.2　创建应用程序

Django项目由一系列应用程序组成，它们协同工作，让项目成为一个整体。我们暂时只创建一个应用程序，它将完成项目的大部分工作。在第19章，我们将再添加一个管理用户账户的应用程序。

当前，在前面打开的终端窗口中应该还运行着runserver。请再打开一个终端窗口（或标签页），并切换到manage.py所在的目录。激活该虚拟环境，再执行命令startapp：

 learning_log$ source ll_env/bin/activate
(ll_env)learning_log$ python manage.py startapp learning_logs
(ll_env)learning_log$ ls ❶
db.sqlite3  learning_log  learning_logs  ll_env  manage.py
(ll_env)learning_log$ ls learning_logs/ ❷
admin.py  __init__.py  migrations  models.py  tests.py  views.py

命令startapp appname让Django建立创建应用程序所需的基础设施。如果现在查看项目目录，将看到其中新增了一个文件夹learning_logs（见❶）。打开这个文件夹，看看Django都创建了什么（见❷）。其中最重要的文件是models.py、admin.py和views.py。我们将使用models.py来定义我们要在应用程序中管理的数据。admin.py和views.py将在稍后介绍。

18.2.1　定义模型

我们来想想涉及的数据。每位用户都需要在学习笔记中创建很多主题。用户输入的每个条目都与特定主题相关联，这些条目将以文本的方式显示。我们还需要存储每个条目的时间戳，以便能够告诉用户各个条目都是什么时候创建的。

打开文件models.py，看看它当前包含哪些内容：

models.py

from django.db import models

# 在这里创建模型

这为我们导入了模块models，还让我们创建自己的模型。模型告诉Django如何处理应用程序中存储的数据。在代码层面，模型就是一个类，就像前面讨论的每个类一样，包含属性和方法。下面是表示用户将要存储的主题的模型：

from django.db import models

class Topic(models.Model):
    """用户学习的主题"""
    text = models.CharField(max_length=200) ❶
    date_added = models.DateTimeField(auto_now_add=True) ❷

    def __str__(self): ❸
        """返回模型的字符串表示"""
        return self.text

我们创建了一个名为Topic的类，它继承了Model——Django中一个定义了模型基本功能的类。Topic类只有两个属性：text和date_added。

属性text是一个CharField——由字符或文本组成的数据（见❶）。需要存储少量的文本，如名称、标题或城市时，可使用CharField。定义CharField属性时，必须告诉Django该在数据库中预留多少空间。在这里，我们将max_length设置成了200（即200个字符），这对存储大多数主题名来说足够了。

属性date_added是一个DateTimeField——记录日期和时间的数据（见❷）。我们传递了实参auto_now_add=True，每当用户创建新主题时，这都让Django将这个属性自动设置成当前日期和时间。

注意　要获悉可在模型中使用的各种字段，请参阅Django Model Field Reference（Django模型字段参考），其网址为https://docs.djangoproject.com/en/1.8/ref/models/fields/。就当前而言，你无需全面了解其中的所有内容，但自己开发应用程序时，这些内容会提供极大的帮助。

我们需要告诉Django，默认应使用哪个属性来显示有关主题的信息。Django调用方法__str__()来显示模型的简单表示。在这里，我们编写了方法__str__()，它返回存储在属性text中的字符串（见❸）。

注意　如果你使用的是Python 2.7，应调用方法__unicode__()，而不是__str__()，但其中的代码相同。

18.2.2　激活模型

要使用模型，必须让Django将应用程序包含到项目中。为此，打开settings.py（它位于目录learning_log/learning_log中），你将看到一个这样的片段，即告诉Django哪些应用程序安装在项目中：

settings.py

--snip--
INSTALLED_APPS = (
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
)
--snip--

这是一个元组，告诉Django项目是由哪些应用程序组成的。请将INSTALLED_APPS修改成下面这样，将前面的应用程序添加到这个元组中：

--snip--
INSTALLED_APPS = (
    --snip--
    'django.contrib.staticfiles',

    # 我的应用程序
    'learning_logs',
)
--snip--

通过将应用程序编组，在项目不断增大，包含更多的应用程序时，有助于对应用程序进行跟踪。这里新建了一个名为My apps的片段，当前它只包含应用程序learning_logs。

接下来，需要让Django修改数据库，使其能够存储与模型Topic相关的信息。为此，在终端窗口中执行下面的命令：

(ll_env)learning_log$ python manage.py makemigrations learning_logs
Migrations for 'learning_logs':
  0001_initial.py:
    - Create model Topic
(ll_env)learning_log$

命令makemigrations让Django确定该如何修改数据库，使其能够存储与我们定义的新模型相关联的数据。输出表明Django创建了一个名为0001_initial.py的迁移文件，这个文件将在数据库中为模型Topic创建一个表。

下面来应用这种迁移，让Django替我们修改数据库：

(ll_env)learning_log$ python manage.py migrate
--snip--
Running migrations:
  Rendering model states... DONE
  Applying learning_logs.0001_initial... OK ❶

这个命令的大部分输出都与我们首次执行命令migrate的输出相同。我们需要检查的是❶处的输出行，在这里，Django确认为learning_logs应用迁移时一切正常（OK）。

每当需要修改“学习笔记”管理的数据时，都采取如下三个步骤：修改models.py；对learning_logs调用makemigrations；让Django迁移项目。

18.2.3　Django管理网站

为应用程序定义模型时，Django提供的管理网站（admin site）让你能够轻松地处理模型。网站的管理员可使用管理网站，但普通用户不能使用。在本节中，我们将建立管理网站，并通过它使用模型Topic来添加一些主题。

1. 创建超级用户

Django允许你创建具备所有权限的用户——超级用户。权限决定了用户可执行的操作。最严格的权限设置只允许用户阅读网站的公开信息；注册了的用户通常可阅读自己的私有数据，还可查看一些只有会员才能查看的信息。为有效地管理Web应用程序，网站所有者通常需要访问网站存储的所有信息。优秀的管理员会小心对待用户的敏感信息，因为用户对其访问的应用程序有极大的信任。

为在Django中创建超级用户，请执行下面的命令并按提示做：

(ll_env)learning_log$ python manage.py createsuperuser
Username (leave blank to use 'ehmatthes'): ll_admin ❶
Email address: ❷
Password: ❸
Password (again):
Superuser created successfully.
(ll_env)learning_log$

你执行命令createsuperuser时，Django提示你输入超级用户的用户名（见❶）。这里我们输入的是ll_admin，但你可以输入任何用户名，比如电子邮件地址，也可让这个字段为空（见❷）。你需要输入密码两次（见❸）。

注意　可能会对网站管理员隐藏有些敏感信息。例如，Django并不存储你输入的密码，而存储从该密码派生出来的一个字符串——散列值。每当你输入密码时，Django都计算其散列值，并将结果与存储的散列值进行比较。如果这两个散列值相同，就通过了身份验证。通过存储散列值，即便黑客获得了网站数据库的访问权，也只能获取其中存储的散列值，而无法获得密码。在网站配置正确的情况下，几乎无法根据散列值推导出原始密码。

2. 向管理网站注册模型

Django自动在管理网站中添加了一些模型，如User和Group，但对于我们创建的模型，必须手工进行注册。

我们创建应用程序learning_logs时，Django在models.py所在的目录中创建了一个名为admin.py的文件：

admin.py

from django.contrib import admin

# 在这里注册你的模型

为向管理网站注册Topic，请输入下面的代码：

from django.contrib import admin

from learning_logs.models import Topic ❶

admin.site.register(Topic) ❷

这些代码导入我们要注册的模型Topic（见❶），再使用admin.site.register()（见❷）让Django通过管理网站管理我们的模型。

现在，使用超级用户账户访问管理网站：访问http://localhost:8000/admin/，并输入你刚创建的超级用户的用户名和密码，你将看到类似于图18-2所示的屏幕。这个网页让你能够添加和修改用户和用户组，还可以管理与刚才定义的模型Topic相关的数据。

图18-2　包含模型Topic的管理网站

注意　如果你在浏览器中看到一条消息，指出访问的网页不可用，请确认你在终端窗口中运行着Django服务器。如果没有，请激活虚拟环境，并执行命令python manage.py runserver。

3. 添加主题

向管理网站注册Topic后，我们来添加第一个主题。为此，单击Topics进入主题网页，它几乎是空的，这是因为我们还没有添加任何主题。单击Add，你将看到一个用于添加新主题的表单。在第一个方框中输入Chess，再单击Save，这将返回到主题管理页面，其中包含刚创建的主题。

下面再创建一个主题，以便有更多的数据可供使用。再次单击Add，并创建另一个主题Rock Climbing。当你单击Save时，将重新回到主题管理页面，其中包含主题Chess和Rock Climbing。

18.2.4　定义模型Entry

要记录学到的国际象棋和攀岩知识，需要为用户可在学习笔记中添加的条目定义模型。每个条目都与特定主题相关联，这种关系被称为多对一关系，即多个条目可关联到同一个主题。

下面是模型Entry的代码：

models.py

from django.db import models

class Topic(models.Model):
    --snip--

class Entry(models.Model): ❶
    """学到的有关某个主题的具体知识"""
    topic = models.ForeignKey(Topic) ❷
    text = models.TextField() ❸
    date_added = models.DateTimeField(auto_now_add=True)

    class Meta: ❹
        verbose_name_plural = 'entries'

    def __str__(self):
        """返回模型的字符串表示"""

        return self.text[:50] + "..." ❺

像Topic一样，Entry也继承了Django基类Model（见❶）。第一个属性topic是一个ForeignKey实例（见❷）。外键是一个数据库术语，它引用了数据库中的另一条记录；这些代码将每个条目关联到特定的主题。每个主题创建时，都给它分配了一个键（或ID）。需要在两项数据之间建立联系时，Django使用与每项信息相关联的键。稍后我们将根据这些联系获取与特定主题相关联的所有条目。

接下来是属性text，它是一个TextField实例（见❸）。这种字段不需要长度限制，因为我们不想限制条目的长度。属性date_added让我们能够按创建顺序呈现条目，并在每个条目旁边放置时间戳。

在❹处，我们在Entry类中嵌套了Meta类。Meta存储用于管理模型的额外信息，在这里，它让我们能够设置一个特殊属性，让Django在需要时使用Entries来表示多个条目。如果没有这个类， Django将使用Entrys来表示多个条目。最后，方法__str__()告诉Django，呈现条目时应显示哪些信息。由于条目包含的文本可能很长，我们让Django只显示text的前50个字符（见❺）。我们还添加了一个省略号，指出显示的并非整个条目。

18.2.5　迁移模型Entry

由于我们添加了一个新模型，因此需要再次迁移数据库。你将慢慢地对这个过程了如指掌：修改models.py，执行命令python manage.py makemigrations app_name，再执行命令python manage.py migrate。

下面来迁移数据库并查看输出：

(ll_env)learning_log$ python manage.py makemigrations learning_logs
Migrations for 'learning_logs':
  0002_entry.py: ❶
    - Create model Entry
(ll_env)learning_log$ python manage.py migrate
Operations to perform:
  --snip--
  Applying learning_logs.0002_entry... OK ❷

生成了一个新的迁移文件——0002_entry.py，它告诉Django如何修改数据库，使其能够存储与模型Entry相关的信息（见❶）。执行命令migrate，我们发现Django应用了这种迁移且一切顺利（见❷）。

18.2.6　向管理网站注册Entry

我们还需要注册模型Entry。为此，需要将admin.py修改成类似于下面这样：

admin.py

from django.contrib import admin

from learning_logs.models import Topic, Entry

admin.site.register(Topic)
admin.site.register(Entry)

返回到http://localhost:8000/admin/，你将看到learning_logs下列出了Entries。单击Entries的Add链接，或者单击Entries再选择Add entry。你将看到一个下拉列表，让你能够选择要为哪个主题创建条目，还有一个用于输入条目的文本框。从下拉列表中选择Chess，并添加一个条目。下面是我添加的第一个条目。

The opening is the first part of the game, roughly the first ten moves or so. In the opening, it's a good idea to do three things— bring out your bishops and knights, try to control the center of the board, and castle your king.（国际象棋的第一个阶段是开局，大致是前10步左右。在开局阶段，最好做三件事情：将象和马调出来；努力控制棋盘的中间区域；用车将王护住。）

Of course, these are just guidelines. It will be important to learn when to follow these guidelines and when to disregard these suggestions.（当然，这些只是指导原则。学习什么情况下遵守这些原则、什么情况下不用遵守很重要。）

当你单击Save时，将返回到主条目管理页面。在这里，你将发现使用text[:50]作为条目的字符串表示的好处：管理界面中，只显示了条目的开头部分而不是其所有文本，这使得管理多个条目容易得多。

再来创建一个国际象棋条目，并创建一个攀岩条目，以提供一些初始数据。下面是第二个国际象棋条目。

In the opening phase of the game, it's important to bring out your bishops and knights. These pieces are powerful and maneuverable enough to play a significant role in the beginning moves of a game.（在国际象棋的开局阶段，将象和马调出来很重要。这些棋子威力大，机动性强，在开局阶段扮演着重要角色。）

下面是第一个攀岩条目：

One of the most important concepts in climbing is to keep your weight on your feet as much as possible. There's a myth that climbers can hang all day on their arms. In reality, good climbers have practiced specific ways of keeping their weight over their feet whenever possible.（最重要的攀岩概念之一是尽可能让双脚承受体重。有谬误认为攀岩者能依靠手臂的力量坚持一整天。实际上，优秀的攀岩者都经过专门训练，能够尽可能让双脚承受体重。）

继续往下开发“学习笔记”时，这三个条目可为我们提供使用的数据。

18.2.7　Django shell

输入一些数据后，就可通过交互式终端会话以编程方式查看这些数据了。这种交互式环境称为Django shell，是测试项目和排除其故障的理想之地。下面是一个交互式shell会话示例：

(ll_env)learning_log$ python manage.py shell
>>> from learning_logs.models import Topic ❶
>>> Topic.objects.all()
[, ]

在活动的虚拟环境中执行时，命令python manage.py shell启动一个Python解释器，可使用它来探索存储在项目数据库中的数据。在这里，我们导入了模块learning_logs.models中的模型Topic（见❶），然后使用方法Topic.objects.all()来获取模型Topic的所有实例；它返回的是一个列表，称为查询集（queryset）。

我们可以像遍历列表一样遍历查询集。下面演示了如何查看分配给每个主题对象的ID：

>>> topics = Topic.objects.all()
>>> for topic in topics:
...   print(topic.id, topic)
...
1 Chess
2 Rock Climbing

我们将返回的查询集存储在topics中，然后打印每个主题的id属性和字符串表示。从输出可知，主题Chess的ID为1，而Rock Climbing的ID为2。

知道对象的ID后，就可获取该对象并查看其任何属性。下面来看看主题Chess的属性text和date_added的值：

>>> t = Topic.objects.get(id=1)
>>> t.text
'Chess'
>>> t.date_added
datetime.datetime(2015, 5, 28, 4, 39, 11, 989446, tzinfo=)

我们还可以查看与主题相关联的条目。前面我们给模型Entry定义了属性topic，这是一个ForeignKey，将条目与主题关联起来。利用这种关联，Django能够获取与特定主题相关联的所有条目，如下所示：

>>> t.entry_set.all() ❶
[, ]

为通过外键关系获取数据，可使用相关模型的小写名称、下划线和单词set（见❶）。例如，假设你有模型Pizza和Topping，而Topping通过一个外键关联到Pizza；如果你有一个名为my_pizza的对象，表示一张比萨，就可使用代码my_pizza.topping_set.all()来获取这张比萨的所有配料。

编写用户可请求的网页时，我们将使用这种语法。确认代码能获取所需的数据时，shell很有帮助。如果代码在shell中的行为符合预期，那么它们在项目文件中也能正确地工作。如果代码引发了错误或获取的数据不符合预期，那么在简单的shell环境中排除故障要比在生成网页的文件中排除故障容易得多。我们不会太多地使用shell，但应继续使用它来熟悉对存储在项目中的数据进行访问的Django语法。

注意　每次修改模型后，你都需要重启shell，这样才能看到修改的效果。要退出shell会话，可按Ctr + D；如果你使用的是Windows系统，应按Ctr + Z，再按回车键。

动手试一试

18-2 简短的条目：当前，Django在管理网站或shell中显示Entry实例时，模型Entry的方法__str__()都在它的末尾加上省略号。请在方法__str__()中添加一条if语句，以便仅在条目长度超过50字符时才添加省略号。使用管理网站来添加一个长度少于50字符的条目，并核实显示它时没有省略号。

18-3 Django API：编写访问项目中的数据的代码时，你编写的是查询。请浏览有关如何查询数据的文档，其网址为https://docs.djangoproject.com/en/7.8/topics/db/queries/。其中大部分内容都是你不熟悉的，但等你自己开发项目时，这些内容会很有用。

18-4 比萨店：新建一个名为pizzeria的项目，并在其中添加一个名为pizzas的应用程序。定义一个名为Pizza的模型，它包含字段name，用于存储比萨名称，如Hawaiian和Meat Lovers。定义一个名为Topping的模型，它包含字段pizza和name，其中字段pizza是一个关联到Pizza的外键，而字段name用于存储配料，如pineapple、Canadian bacon和sausage。

向管理网站注册这两个模型，并使用管理网站输入一些比萨名和配料。使用shell来查看你输入的数据。

18.3　创建网页：学习笔记主页

使用Django创建网页的过程通常分三个阶段：定义URL、编写视图和编写模板。首先，你必须定义URL模式。URL模式描述了URL是如何设计的，让Django知道如何将浏览器请求与网站URL匹配，以确定返回哪个网页。

每个URL都被映射到特定的视图——视图函数获取并处理网页所需的数据。视图函数通常调用一个模板，后者生成浏览器能够理解的网页。为明白其中的工作原理，我们来创建学习笔记的主页。我们将定义该主页的URL、编写其视图函数并创建一个简单的模板。

鉴于我们只是要确保“学习笔记”按要求的那样工作，我们将暂时让这个网页尽可能简单。Web应用程序能够正常运行后，设置样式可使其更有趣，但中看不中用的应用程序毫无意义。就目前而言，主页只显示标题和简单的描述。

18.3.1　映射URL

用户通过在浏览器中输入URL以及单击链接来请求网页，因此我们需要确定项目需要哪些URL。主页的URL最重要，它是用户用来访问项目的基础URL。当前，基础URL（http://localhost:8000/）返回默认的Django网站，让我们知道正确地建立了项目。我们将修改这一点，将这个基础URL映射到“学习笔记”的主页。

打开项目主文件夹learning_log中的文件urls.py，你将看到如下代码：

urls.py

from django.conf.urls import include, url ❶
from django.contrib import admin

urlpatterns = [ ❷
    url(r'^admin/', include(admin.site.urls)), ❸
]

前两行导入了为项目和管理网站管理URL的函数和模块（见❶）。这个文件的主体定义了变量urlpatterns（见❷）。在这个针对整个项目的urls.py文件中，变量urlpatterns包含项目中的应用程序的URL。❸处的代码包含模块admin.site.urls，该模块定义了可在管理网站中请求的所有URL。

我们需要包含learning_logs的URL：

from django.conf.urls import include, url
from django.contrib import admin

urlpatterns = [
    url(r'^admin/', include(admin.site.urls)),
    url(r'', include('learning_logs.urls', namespace='learning_logs')), ❶
]

在❶处，我们添加了一行代码来包含模块learning_logs.urls。这行代码包含实参namespace，让我们能够将learning_logs的URL同项目中的其他URL区分开来，这在项目开始扩展时很有帮助。

默认的urls.py包含在文件夹learning_log中，现在我们需要在文件夹learning_logs中创建另一个urls.py文件：

urls.py

"""定义learning_logs的URL模式""" ❶

from django.conf.urls import url ❷

from . import views ❸

urlpatterns = [ ❹
    # 主页
    url(r'^$', views.index, name='index'), ❺
]

为弄清楚当前位于哪个urls.py文件中，我们在这个文件开头添加了一个文档字符串（见❶）。接下来，我们导入了函数url，因为我们需要使用它来将URL映射到视图（见❷）。我们还导入了模块views（见❸），其中的句点让Python从当前的urls.py模块所在的文件夹中导入视图。在这个模块中，变量urlpatterns是一个列表，包含可在应用程序learning_logs中请求的网页（见❹）。

实际的URL模式是一个对函数url()的调用，这个函数接受三个实参（见❺）。第一个是一个正则表达式。Django在urlpatterns中查找与请求的URL字符串匹配的正则表达式，因此正则表达式定义了Django可查找的模式。

我们来看看正则表达式r'^$'。其中的r让Python将接下来的字符串视为原始字符串，而引号告诉Python正则表达式始于和终于何处。脱字符（^）让Python查看字符串的开头，而美元符号让Python查看字符串的末尾。总体而言，这个正则表达式让Python查找开头和末尾之间没有任何东西的URL。Python忽略项目的基础URL（http://localhost:8000/），因此这个正则表达式与基础URL匹配。其他URL都与这个正则表达式不匹配。如果请求的URL不与任何URL模式匹配，Django将返回一个错误页面。

url()的第二个实参（见❺）指定了要调用的视图函数。请求的URL与前述正则表达式匹配时，Django将调用views.index（这个视图函数将在下一节编写）。第三个实参将这个URL模式的名称指定为index，让我们能够在代码的其他地方引用它。每当需要提供到这个主页的链接时，我们都将使用这个名称，而不编写URL。

注意　正则表达式通常被称为regex，几乎每种编程语言都使用它。它们的用途多得难以置信，但需要经过一定的练习才能熟悉。如果你不明白前面介绍的内容，也不用担心，你在完成这个项目的过程中，将会看到很多正则表达式。

18.3.2　编写视图

视图函数接受请求中的信息，准备好生成网页所需的数据，再将这些数据发送给浏览器——这通常是使用定义了网页是什么样的模板实现的。

learning_logs中的文件views.py是你执行命令python manage.py startapp时自动生成的，当前其内容如下：

views.py

from django.shortcuts import render

# 在这里创建视图

当前，这个文件只导入了函数render()，它根据视图提供的数据渲染响应。下面的代码演示了该如何为主页编写视图：

from django.shortcuts import render

def index(request):
    """学习笔记的主页"""
    return render(request, 'learning_logs/index.html')

URL请求与我们刚才定义的模式匹配时，Django将在文件views.py中查找函数index()，再将请求对象传递给这个视图函数。在这里，我们不需要处理任何数据，因此这个函数只包含调用render()的代码。这里向函数render()提供了两个实参：原始请求对象以及一个可用于创建网页的模板。下面来编写这个模板。

18.3.3　编写模板

模板定义了网页的结构。模板指定了网页是什么样的，而每当网页被请求时，Django将填入相关的数据。模板让你能够访问视图提供的任何数据。我们的主页视图没有提供任何数据，因此相应的模板非常简单。

在文件夹learning_logs中新建一个文件夹，并将其命名为templates。在文件夹templates中，再新建一个文件夹，并将其命名为learning_logs。这好像有点多余（我们在文件夹learning_logs中创建了文件夹templates，又在这个文件夹中创建了文件夹learning_logs），但建立了Django能够明确解读的结构，即便项目很大，包含很多应用程序亦如此。在最里面的文件夹learning_logs中，新建一个文件，并将其命名为index.html，再在这个文件中编写如下代码：

index.html

<p>Learning Log</p>

<p>Learning Log helps you keep track of your learning, for any topic you're
learning about.</p>

这个文件非常简单。对于不熟悉HTML的读者，这里解释一下：标签<p></p>标识段落；标签<p>指出了段落的开头位置，而标签</p>指出了段落的结束位置。这里定义了两个段落：第一个充当标题，第二个阐述了用户可使用“学习笔记”来做什么。

现在，如果你请求这个项目的基础URL——http://localhost:8000/，将看到刚才创建的网页，而不是默认的Django网页。Django接受请求的URL，发现该URL与模式r'^$'匹配，因此调用函数views.index()，这将使用index.html包含的模板来渲染网页，结果如图18-3所示。

图18-3　学习笔记的主页

创建网页的过程看起来可能很复杂，但将URL、视图和模板分离的效果实际上很好。这让我们能够分别考虑项目的不同方面，且在项目很大时，让各个参与者可专注于其最擅长的方面。例如，数据库专家可专注于模型，程序员可专注于视图代码，而Web设计人员可专注于模板。

动手试一试

18-5 膳食规划程序：假设你要创建一个应用程序，帮助用户规划一周的膳食。为此，新建一个文件夹，并将其命名为meal_planner，再在这个文件夹中新建一个Django项目。接下来，新建一个名为meal_plans的应用程序，并为这个项目创建一个简单的主页。

18-6 比萨店主页：在你为完成练习18-4而创建的项目Pizzeria中，添加一个主页。

18.4　创建其他网页

制定创建网页的流程后，可以开始扩充“学习笔记”项目了。我们将创建两个显示数据的网页，其中一个列出所有的主题，另一个显示特定主题的所有条目。对于每个网页，我们都将指定URL模式，编写一个视图函数，并编写一个模板。但这样做之前，我们先创建一个父模板，项目中的其他模板都将继承它。

18.4.1　模板继承

创建网站时，几乎都有一些所有网页都将包含的元素。在这种情况下，可编写一个包含通用元素的父模板，并让每个网页都继承这个模板，而不必在每个网页中重复定义这些通用元素。这种方法能让你专注于开发每个网页的独特方面，还能让修改项目的整体外观容易得多。

1. 父模板

我们首先来创建一个名为base.html的模板，并将其存储在index.html所在的目录中。这个文件包含所有页面都有的元素；其他的模板都继承base.html。当前，所有页面都包含的元素只有顶端的标题。我们将在每个页面中包含这个模板，因此我们将这个标题设置为到主页的链接：

base.html

<p>
  <a href="{% url 'learning_logs:index' %}">Learning Log</a> ❶
</p>

{% block content %}{% endblock content %} ❷

这个文件的第一部分创建一个包含项目名的段落，该段落也是一个到主页的链接。为创建链接，我们使用了一个模板标签，它是用大括号和百分号（{% %}）表示的。模板标签是一小段代码，生成要在网页中显示的信息。在这个实例中，模板标签{% url 'learning_logs:index' %}生成一个URL，该URL与learning_logs/urls.py中定义的名为index的URL模式匹配（见❶）。在这个示例中，learning_logs是一个命名空间，而index是该命名空间中一个名称独特的URL模式。

在简单的HTML页面中，链接是使用锚标签定义的：

<a href="link_url">link text</a>

让模板标签来生成URL，可让链接保持最新容易得多。要修改项目中的URL，只需修改urls.py中的URL模式，这样网页被请求时，Django将自动插入修改后的URL。在我们的项目中，每个网页都将继承base.html，因此从现在开始，每个网页都包含到主页的链接。

在❶处，我们插入了一对块标签。这个块名为content，是一个占位符，其中包含的信息将由子模板指定。

子模板并非必须定义父模板中的每个块，因此在父模板中，可使用任意多个块来预留空间，而子模板可根据需要定义相应数量的块。

注意　在Python代码中，我们几乎总是缩进四个空格。相比于Python文件，模板文件的缩进层级更多，因此每个层级通常只缩进两个空格。

2. 子模板

现在需要重新编写index.html，使其继承base.html，如下所示：

index.html

{% extends "learning_logs/base.html" %} ❶

{% block content %} ❷
  <p>Learning Log helps you keep track of your learning, for any topic you're
  learning about.</p>
{% endblock content %} ❸

如果将这些代码与原来的index.html进行比较，可发现我们将标题Learning Log替换成了从父模板那里继承的代码（见❶）。子模板的第一行必须包含标签{% extends %}，让Django知道它继承了哪个父模板。文件base.html位于文件夹learning_logs中，因此父模板路径中包含learning_logs。这行代码导入模板base.html的所有内容，让index.html能够指定要在content块预留的空间中添加的内容。

在❷处，我们插入了一个名为content的{% block %}标签，以定义content块。不是从父模板继承的内容都包含在content块中，在这里是一个描述项目“学习笔记”的段落。在❸处，我们使用标签{% endblock content %}指出了内容定义的结束位置。

模板继承的优点开始显现出来了：在子模板中，只需包含当前网页特有的内容。这不仅简化了每个模板，还使得网站修改起来容易得多。要修改很多网页都包含的元素，只需在父模板中修改该元素，你所做的修改将传导到继承该父模板的每个页面。在包含数十乃至数百个网页的项目中，这种结构使得网站改进起来容易而且快捷得多。

注意　在大型项目中，通常有一个用于整个网站的父模板——base.html，且网站的每个主要部分都有一个父模板。每个部分的父模板都继承base.html，而网站的每个网页都继承相应部分的父模板。这让你能够轻松地修改整个网站的外观、网站任何一部分的外观以及任何一个网页的外观。这种配置提供了一种效率极高的工作方式，让你乐意不断地去改进网站。

18.4.2　显示所有主题的页面

有了高效的网页创建方法，就能专注于另外两个网页了：显示全部主题的网页以及显示特定主题中条目的网页。所有主题页面显示用户创建的所有主题，它是第一个需要使用数据的网页。

1. URL模式

首先，我们来定义显示所有主题的页面的URL。通常，使用一个简单的URL片段来指出网页显示的信息；我们将使用单词topics，因此URL http://localhost:8000/topics/将返回显示所有主题的页面。下面演示了该如何修改learning_logs/urls.py：

urls.py

"""为learning_logs定义URL模式"""
--snip--
urlpatterns = [
    # 主页
    url(r'^$', views.index, name='index'),

    # 显示所有的主题
    url(r'^topics/$', views.topics, name='topics'), ❶
]

我们只是在用于主页URL的正则表达式中添加了topics/（见❶）。Django检查请求的URL时，这个模式与这样的URL匹配：基础URL后面跟着topics。可以在末尾包含斜杠，也可以省略它，但单词topics后面不能有任何东西，否则就与该模式不匹配。其URL与该模式匹配的请求都将交给views.py中的函数topics()进行处理。

2. 视图

函数topics()需要从数据库中获取一些数据，并将其发送给模板。我们需要在views.py中添加的代码如下：

views.py

from django.shortcuts import render

from .models import Topic ❶

def index(request):
    --snip--

def topics(request): ❷
    """显示所有的主题"""
    topics = Topic.objects.order_by('date_added') ❸
    context = {'topics': topics} ❹
    return render(request, 'learning_logs/topics.html', context) ❺

我们首先导入了与所需数据相关联的模型（见❶）。函数topics()包含一个形参：Django从服务器那里收到的request对象（见❷）。在❸处，我们查询数据库——请求提供Topic对象，并按属性date_added对它们进行排序。我们将返回的查询集存储在topics中。

在❹处，我们定义了一个将要发送给模板的上下文。上下文是一个字典，其中的键是我们将在模板中用来访问数据的名称，而值是我们要发送给模板的数据。在这里，只有一个键—值对，它包含我们将在网页中显示的一组主题。创建使用数据的网页时，除对象request和模板的路径外，我们还将变量context传递给render()（见❺）。

3. 模板

显示所有主题的页面的模板接受字典context，以便能够使用topics()提供的数据。请创建一个文件，将其命名为topics.html，并存储到index.html所在的目录中。下面演示了如何在这个模板中显示主题：

topics.html

{% extends "learning_logs/base.html" %}

{% block content %}

  <p>Topics</p>

  <ul> ❶
    {% for topic in topics %} ❷
      <li>{{ topic }}</li> ❸
    {% empty %} ❹
      <li>No topics have been added yet.</li>
    {% endfor %} ❺
  </ul> ❻

{% endblock content %}

就像模板index.html一样，我们首先使用标签{% extends %}来继承base.html，再开始定义content块。这个网页的主体是一个项目列表，其中列出了用户输入的主题。在标准HTML中，项目列表被称为无序列表，用标签<ul></ul>表示。包含所有主题的项目列表始于❶处。

在❷处，我们使用了一个相当于for循环的模板标签，它遍历字典context中的列表topics。模板中使用的代码与Python代码存在一些重要差别：Python使用缩进来指出哪些代码行是for循环的组成部分，而在模板中，每个for循环都必须使用{% endfor %}标签来显式地指出其结束位置。因此在模板中，循环类似于下面这样：

{% for item in list %}
    do something with each item
{% endfor %}

在循环中，我们要将每个主题转换为一个项目列表项。要在模板中打印变量，需要将变量名用双花括号括起来。每次循环时，❸处的代码{{ topic }}都被替换为topic的当前值。这些花括号不会出现在网页中，它们只是用于告诉Django我们使用了一个模板变量。HTML标签<li></li>表示一个项目列表项，在标签对<ul></ul>内部，位于标签<li>和</li>之间的内容都是一个项目列表项。

在❹处，我们使用了模板标签{% empty %}，它告诉Django在列表topics为空时该怎么办：这里是打印一条消息，告诉用户还没有添加任何主题。最后两行分别结束for循环（见❺）和项目列表（见❻）。

现在需要修改父模板，使其包含到显示所有主题的页面的链接：

base.html

<p>
  <a href="{% url 'learning_logs:index' %}">Learning Log</a> - ❶
  <a href="{% url 'learning_logs:topics' %}">Topics</a> ❷
</p>

{% block content %}{% endblock content %}

我们在到主页的链接后面添加了一个连字符（见❶），然后添加了一个到显示所有主题的页面的链接——使用的也是模板标签url（见❷）。这一行让Django生成一个链接，它与learning_logs/ urls.py中名为topics的URL模式匹配。

现在如果你刷新浏览器中的主页，将看到链接Topics。单击这个链接，将看到类似于图18-4所示的网页。

图18-4　显示所有主题的网页

18.4.3　显示特定主题的页面

接下来，我们需要创建一个专注于特定主题的页面——显示该主题的名称及该主题的所有条目。同样，我们将定义一个新的URL模式，编写一个视图并创建一个模板。我们还将修改显示所有主题的网页，让每个项目列表项都是一个链接，单击它将显示相应主题的所有条目。

1. URL模式

显示特定主题的页面的URL模式与前面的所有URL模式都稍有不同，因为它将使用主题的id属性来指出请求的是哪个主题。例如，如果用户要查看主题Chess（其id为1）的详细页面，URL将为http://localhost:8000/topics/1/。下面是与这个URL匹配的模式，它包含在learning_logs/urls.py中：

urls.py

--snip--
urlpatterns = [
    --snip--
    # 特定主题的详细页面
    url(r'^topics/(?P<topic_id>\d+)/$', views.topic, name='topic'),
]

我们来详细研究这个URL模式中的正则表达式——r'^topics/(?P<topic_id>\d+)/$'。r让Django将这个字符串视为原始字符串，并指出正则表达式包含在引号内。这个表达式的第二部分（/(?P<topic_id>\d+)/）与包含在两个斜杠内的整数匹配，并将这个整数存储在一个名为topic_id的实参中。这部分表达式两边的括号捕获URL中的值；?P<topic_id>将匹配的值存储到topic_id中；而表达式\d+与包含在两个斜杆内的任何数字都匹配，不管这个数字为多少位。

发现URL与这个模式匹配时，Django将调用视图函数topic()，并将存储在topic_id中的值作为实参传递给它。在这个函数中，我们将使用topic_id的值来获取相应的主题。

2. 视图

函数topic()需要从数据库中获取指定的主题以及与之相关联的所有条目，如下所示：

views.py

--snip--
def topic(request, topic_id): ❶
    """显示单个主题及其所有的条目"""
    topic = Topic.objects.get(id=topic_id) ❷
    entries = topic.entry_set.order_by('-date_added') ❸
    context = {'topic': topic, 'entries': entries} ❹
    return render(request, 'learning_logs/topic.html', context) ❺

这是第一个除request对象外还包含另一个形参的视图函数。这个函数接受正则表达式(?P<topic_id>\d+)捕获的值，并将其存储到topic_id中（见❶）。在❷处，我们使用get()来获取指定的主题，就像前面在Django shell中所做的那样。在❸处，我们获取与该主题相关联的条目，并将它们按date_added排序：date_added前面的减号指定按降序排列，即先显示最近的条目。我们将主题和条目都存储在字典context中（见❹），再将这个字典发送给模板topic.html（见❺）。

注意　❷处和❸处的代码被称为查询，因为它们向数据库查询特定的信息。在自己的项目中编写这样的查询时，先在Django shell中进行尝试大有裨益。相比于编写视图和模板，再在浏览器中检查结果，在shell中执行代码可更快地获得反馈。

3. 模板

这个模板需要显示主题的名称和条目的内容；如果当前主题不包含任何条目，我们还需向用户指出这一点：

topic.html

{% extends 'learning_logs/base.html' %}

{% block content %}

  <p>Topic: {{ topic }}</p> ❶

  <p>Entries:</p>
  <ul> ❷
  {% for entry in entries %} ❸
    <li>
      <p>{{ entry.date_added|date:'M d, Y H:i' }}</p> ❹
      <p>{{ entry.text|linebreaks }}</p> ❺
    </li>
  {% empty %} ❻
    <li>
      There are no entries for this topic yet.
    </li>
  {% endfor %}
  </ul>

{% endblock content %}

像这个项目的其他页面一样，这里也继承了base.html。接下来，我们显示当前的主题（见❶），它存储在模板变量{{ topic }}中。为什么可以使用变量topic呢？因为它包含在字典context中。接下来，我们开始定义一个显示每个条目的项目列表（见❷），并像前面显示所有主题一样遍历条目（见❸）。

每个项目列表项都将列出两项信息：条目的时间戳和完整的文本。为列出时间戳（见❹），我们显示属性date_added的值。在Django模板中，竖线（|）表示模板过滤器——对模板变量的值进行修改的函数。过滤器date: 'M d, Y H:i'以这样的格式显示时间戳：January 1, 2015 23:00。接下来的一行显示text的完整值，而不仅仅是entry的前50个字符。过滤器linebreaks（见❺）将包含换行符的长条目转换为浏览器能够理解的格式，以免显示为一个不间断的文本块。在❻处，我们使用模板标签{% empty %}打印一条消息，告诉用户当前主题还没有条目。

4. 将显示所有主题的页面中的每个主题都设置为链接

在浏览器中查看显示特定主题的页面前，我们需要修改模板topics.html，让每个主题都链接到相应的网页，如下所示：

topics.html

--snip--
    {% for topic in topics %}
      <li>
        <a href="{% url 'learning_logs:topic' topic.id %}">{{ topic }}</a>
      </li>
    {% empty %}
--snip--

我们使用模板标签url根据learning_logs中名为topic的URL模式来生成合适的链接。这个URL模式要求提供实参topic_id，因此我们在模板标签url中添加了属性topic.id。现在，主题列表中的每个主题都是一个链接，链接到显示相应主题的页面，如http://localhost:8000/topics/1/。

如果你刷新显示所有主题的页面，再单击其中的一个主题，将看到类似于图18-5所示的页面。

图18-5　特定主题的详细页面，其中显示了该主题的所有条目

动手试一试

18-7 模板文档：请浏览Django模板文档，其网址为https://docs.djangoproject.com/en/1.8/ref/templates/。自己开发项目时，可再回过头来参考该文档。

18-8 比萨店页面：在练习18-6中开发的项目Pizzeria中添加一个页面，它显示供应的比萨的名称。然后，将每个比萨名称都设置成一个链接，单击这种链接将显示一个页面，其中列出了相应比萨的配料。请务必使用模板继承来高效地创建页面。

18.5　小结

在本章中，你首先学习了如何使用Django框架来创建Web应用程序。你制定了简要的项目规范，在虚拟环境中安装了Django，创建了一个项目，并核实该项目已正确地创建。你学习了如何创建应用程序，以及如何定义表示应用程序数据的模型。你学习了数据库，以及在你修改模型后，Django可为你迁移数据库提供什么样的帮助。你学习了如何创建可访问管理网站的超级用户，并使用管理网站输入了一些初始数据。

你还探索了Django shell，它让你能够在终端会话中处理项目的数据。你学习了如何定义URL、创建视图函数以及编写为网站创建网页的模板。最后，你使用了模板继承，它可简化各个模板的结构，并使得修改网站更容易。

在第19章，我们将创建对用户友好而直观的网页，让用户无需通过管理网站就能添加新的主题和条目，以及编辑既有的条目。我们还将添加一个用户注册系统，让用户能够创建账户和自己的学习笔记。让任意数量的用户都能与之交互，是Web应用程序的核心所在。

第 19 章　用户账户

Web应用程序的核心是让任何用户都能够注册账户并能够使用它，不管用户身处何方。在本章中，你将创建一些表单，让用户能够添加主题和条目，以及编辑既有的条目。你还将学习Django如何防范对基于表单的网页发起的常见攻击，这让你无需花太多时间考虑确保应用程序安全的问题。

然后，我们将实现一个用户身份验证系统。你将创建一个注册页面，供用户创建账户，并让有些页面只能供已登录的用户访问。接下来，我们将修改一些视图函数，使得用户只能看到自己的数据。你将学习如何确保用户数据的安全。

19.1　让用户能够输入数据

建立用于创建用户账户的身份验证系统之前，我们先来添加几个页面，让用户能够输入数据。我们将让用户能够添加新主题、添加新条目以及编辑既有条目。

当前，只有超级用户能够通过管理网站输入数据。我们不想让用户与管理网站交互，因此我们将使用Django的表单创建工具来创建让用户能够输入数据的页面。

19.1.1　添加新主题

首先来让用户能够添加新主题。创建基于表单的页面的方法几乎与前面创建网页一样：定义一个URL，编写一个视图函数并编写一个模板。一个主要差别是，需要导入包含表单的模块forms.py。

1. 用于添加主题的表单

让用户输入并提交信息的页面都是表单，哪怕它看起来不像表单。用户输入信息时，我们需要进行验证，确认提供的信息是正确的数据类型，且不是恶意的信息，如中断服务器的代码。然后，我们再对这些有效信息进行处理，并将其保存到数据库的合适地方。这些工作很多都是由Django自动完成的。

在Django中，创建表单的最简单方式是使用ModelForm，它根据我们在第18章定义的模型中的信息自动创建表单。创建一个名为forms.py的文件，将其存储到models.py所在的目录中，并在其中编写你的第一个表单：

forms.py

from django import forms

from .models import Topic

class TopicForm(forms.ModelForm):❶
    class Meta:
        model = Topic❷
        fields = ['text']❸
        labels = {'text': ''}❹

我们首先导入了模块forms以及要使用的模型Topic。在❶处，我们定义了一个名为TopicForm的类，它继承了forms.ModelForm。

最简单的ModelForm版本只包含一个内嵌的Meta类，它告诉Django根据哪个模型创建表单，以及在表单中包含哪些字段。在❷处，我们根据模型Topic创建一个表单，该表单只包含字段text（见❸）。❹处的代码让Django不要为字段text生成标签。

2. URL模式new_topic

这个新网页的URL应简短而具有描述性，因此当用户要添加新主题时，我们将切换到http://localhost:8000/new_topic/。下面是网页new_topic的URL模式，我们将其添加到learning_logs/urls.py中：

urls.py

--snip--
urlpatterns = [
    --snip--
    # 用于添加新主题的网页
    url(r'^new_topic/$', views.new_topic, name='new_topic'),
]

这个URL模式将请求交给视图函数new_topic()，接下来我们将编写这个函数。

3. 视图函数new_topic()

函数new_topic()需要处理两种情形：刚进入new_topic网页（在这种情况下，它应显示一个空表单）；对提交的表单数据进行处理，并将用户重定向到网页topics：

views.py

from django.shortcuts import render
from django.http import HttpResponseRedirect
from django.core.urlresolvers import reverse

from .models import Topic
from .forms import TopicForm

--snip--
def new_topic(request):
    """添加新主题"""
    if request.method != 'POST': ❶
        # 未提交数据：创建一个新表单
        form = TopicForm() ❷
    else:
        # POST提交的数据,对数据进行处理
        form = TopicForm(request.POST) ❸
        if form.is_valid(): ❹
            form.save() ❺
            return HttpResponseRedirect(reverse('learning_logs:topics')) ❻

    context = {'form': form} ❼
    return render(request, 'learning_logs/new_topic.html', context)

我们导入了HttpResponseRedirect类，用户提交主题后我们将使用这个类将用户重定向到网页topics。函数reverse()根据指定的URL模型确定URL，这意味着Django将在页面被请求时生成URL。我们还导入了刚才创建的表单TopicForm。

4. GET请求和POST请求

创建Web应用程序时，将用到的两种主要请求类型是GET请求和POST请求。对于只是从服务器读取数据的页面，使用GET请求；在用户需要通过表单提交信息时，通常使用POST请求。处理所有表单时，我们都将指定使用POST方法。还有一些其他类型的请求，但这个项目没有使用。

函数new_topic()将请求对象作为参数。用户初次请求该网页时，其浏览器将发送GET请求；用户填写并提交表单时，其浏览器将发送POST请求。根据请求的类型，我们可以确定用户请求的是空表单（GET请求）还是要求对填写好的表单进行处理（POST请求）。

❶处的测试确定请求方法是GET还是POST。如果请求方法不是POST，请求就可能是GET，因此我们需要返回一个空表单（即便请求是其他类型的，返回一个空表单也不会有任何问题）。我们创建一个TopicForm实例（见❷），将其存储在变量form中，再通过上下文字典将这个表单发送给模板（见❼）。由于实例化TopicForm时我们没有指定任何实参，Django将创建一个可供用户填写的空表单。

如果请求方法为POST，将执行else代码块，对提交的表单数据进行处理。我们使用用户输入的数据（它们存储在request.POST中）创建一个TopicForm实例（见❸），这样对象form将包含用户提交的信息。

要将提交的信息保存到数据库，必须先通过检查确定它们是有效的（见❹）。函数is_valid()核实用户填写了所有必不可少的字段（表单字段默认都是必不可少的），且输入的数据与要求的字段类型一致（例如，字段text少于200个字符，这是我们在第18章中的models.py中指定的）。这种自动验证避免了我们去做大量的工作。如果所有字段都有效，我们就可调用save()（见❺），将表单中的数据写入数据库。保存数据后，就可离开这个页面了。我们使用reverse()获取页面topics的URL，并将其传递给HttpResponseRedirect()（见❻），后者将用户的浏览器重定向到页面topics。在页面topics中，用户将在主题列表中看到他刚输入的主题。

5. 模板new_topic

下面来创建新模板new_topic.html，用于显示我们刚创建的表单：

new_topic.html

{% extends "learning_logs/base.html" %}

{% block content %}
  <p>Add a new topic:</p>

  <form action="{% url 'learning_logs:new_topic' %}" method='post'> ❶
    {% csrf_token %} ❷
    {{ form.as_p }} ❸
    <button name="submit">add topic</button> ❹
  </form>

{% endblock content %}

这个模板继承了base.html，因此其基本结构与项目“学习笔记”的其他页面相同。在❶处，我们定义了一个HTML表单。实参action告诉服务器将提交的表单数据发送到哪里，这里我们将它发回给视图函数new_topic()。实参method让浏览器以POST请求的方式提交数据。

Django使用模板标签{% csrf_token %}（见❷）来防止攻击者利用表单来获得对服务器未经授权的访问（这种攻击被称为跨站请求伪造）。在❸处，我们显示表单，从中可知Django使得完成显示表单等任务有多简单：我们只需包含模板变量{{ form.as_p }}，就可让Django自动创建显示表单所需的全部字段。修饰符as_p让Django以段落格式渲染所有表单元素，这是一种整洁地显示表单的简单方式。

Django不会为表单创建提交按钮，因此我们在❹处定义了一个这样的按钮。

6. 链接到页面new_topic

接下来，我们在页面topics中添加一个到页面new_topic的链接：

topics.html

{% extends "learning_logs/base.html" %}

{% block content %}

  <p>Topics</p>

  <ul>
    --snip--
  </ul>

  <a href="{% url 'learning_logs:new_topic' %}">Add a new topic:</a>

{% endblock content %}

这个链接放在了既有主题列表的后面。图19-1显示了生成的表单。请使用这个表单来添加几个新主题。

图19-1　用于添加新主题的页面

19.1.2　添加新条目

现在用户可以添加新主题了，但他们还想添加新条目。我们将再次定义URL，编写视图函数和模板，并链接到添加新条目的网页。但在此之前，我们需要在forms.py中再添加一个类。

1. 用于添加新条目的表单

我们需要创建一个与模型Entry相关联的表单，但这个表单的定制程度比TopicForm要高些：

forms.py

from django import forms

from .models import Topic, Entry

class TopicForm(forms.ModelForm):
    --snip--

class EntryForm(forms.ModelForm):
    class Meta:
        model = Entry
        fields = ['text']
        labels = {'text': ''} ❶
        widgets = {'text': forms.Textarea(attrs={'cols': 80})} ❷

我们首先修改了import语句，使其除导入Topic外，还导入Entry。新类EntryForm继承了forms.ModelForm，它包含的Meta类指出了表单基于的模型以及要在表单中包含哪些字段。这里也给字段'text'指定了一个空标签（见❶）。

在❷处，我们定义了属性widgets。小部件（widget）是一个HTML表单元素，如单行文本框、多行文本区域或下拉列表。通过设置属性widgets，可覆盖Django选择的默认小部件。通过让Django使用forms.Textarea，我们定制了字段'text'的输入小部件，将文本区域的宽度设置为80列，而不是默认的40列。这给用户提供了足够的空间，可以编写有意义的条目。

2. URL模式new_entry

在用于添加新条目的页面的URL模式中，需要包含实参topic_id，因为条目必须与特定的主题相关联。该URL模式如下，我们将它添加到了learning_logs/urls.py中：

urls.py

--snip--
urlpatterns = [
    --snip--
    # 用于添加新条目的页面
    url(r'^new_entry/(?P<topic_id>\d+)/$', views.new_entry, name='new_entry'),
]

这个URL模式与形式为http://localhost:8000/new_entry/id/的URL匹配，其中id是一个与主题ID匹配的数字。代码(?P<topic_id>\d+)捕获一个数字值，并将其存储在变量topic_id中。请求的URL与这个模式匹配时，Django将请求和主题ID发送给函数new_entry()。

3. 视图函数new_entry()

视图函数new_entry()与函数new_topic()很像：

views.py

from django.shortcuts import render
--snip--

from .models import Topic
from .forms import TopicForm, EntryForm

--snip--
def new_entry(request, topic_id):
    """在特定的主题中添加新条目"""
    topic = Topic.objects.get(id=topic_id) ❶

    if request.method != 'POST': ❷
        # 未提交数据,创建一个空表单
        form = EntryForm() ❸
    else:
        # POST提交的数据,对数据进行处理
        form = EntryForm(data=request.POST) ❹
        if form.is_valid():
            new_entry = form.save(commit=False) ❺
            new_entry.topic = topic ❻
            new_entry.save()
            return HttpResponseRedirect(reverse('learning_logs:topic', ❼
                                        args=[topic_id]))

    context = {'topic': topic, 'form': form}
    return render(request, 'learning_logs/new_entry.html', context)

我们修改了import语句，在其中包含了刚创建的EntryForm。new_entry()的定义包含形参topic_id，用于存储从URL中获得的值。渲染页面以及处理表单数据时，都需要知道针对的是哪个主题，因此我们使用topic_id来获得正确的主题（见❶）。

在❷处，我们检查请求方法是POST还是GET。如果是GET请求，将执行if代码块：创建一个空的EntryForm实例（见❸）。如果请求方法为POST，我们就对数据进行处理：创建一个EntryForm实例，使用request对象中的POST数据来填充它（见❹）；再检查表单是否有效，如果有效，就设置条目对象的属性topic，再将条目对象保存到数据库。

调用save()时，我们传递了实参commit=False（见❺），让Django创建一个新的条目对象，并将其存储到new_entry中，但不将它保存到数据库中。我们将new_entry的属性topic设置为在这个函数开头从数据库中获取的主题（见❻），然后调用save()，且不指定任何实参。这将把条目保存到数据库，并将其与正确的主题相关联。

在❼处，我们将用户重定向到显示相关主题的页面。调用reverse()时，需要提供两个实参：要根据它来生成URL的URL模式的名称；列表args，其中包含要包含在URL中的所有实参。在这里，列表args只有一个元素——topic_id。接下来，调用HttpResponseRedirect()将用户重定向到显示新增条目所属主题的页面，用户将在该页面的条目列表中看到新添加的条目。

4. 模板new_entry

从下面的代码可知，模板new_entry类似于模板new_topic：

new_entry.html

{% extends "learning_logs/base.html" %}

{% block content %}

  <p><a href="{% url 'learning_logs:topic' topic.id %}">{{ topic }}</a></p> ❶

  <p>Add a new entry:</p>
  <form action="{% url 'learning_logs:new_entry' topic.id %}" method='post'> ❷
    {% csrf_token %}
    {{ form.as_p }}
    <button name='submit'>add entry</button>
  </form>

{% endblock content %}

我们在页面顶端显示了主题（见❶），让用户知道他是在哪个主题中添加条目；该主题名也是一个链接，可用于返回到该主题的主页面。

表单的实参action包含URL中的topic_id值，让视图函数能够将新条目关联到正确的主题（见❷）。除此之外，这个模板与模板new_topic.html完全相同。

5. 链接到页面new_entry

接下来，我们需要在显示特定主题的页面中添加到页面new_entry的链接：

topic.html

{% extends "learning_logs/base.html" %}

{% block content %}

  <p>Topic: {{ topic }}</p>

  <p>Entries:</p>
  <p>
    <a href="{% url 'learning_logs:new_entry' topic.id %}">add new entry</a>
  </p>
  <ul>
  --snip—
  </ul>

{% endblock content %}

我们在显示条目前添加链接，因为在这种页面中，执行的最常见的操作是添加新条目。图19-2显示了页面new_entry。现在用户可以添加新主题，还可以在每个主题中添加任意数量的条目。请在一些既有主题中添加一些新条目，尝试使用一下页面new_entry。

图19-2　页面new_entry

19.1.3　编辑条目

下面来创建一个页面，让用户能够编辑既有的条目。

1. URL模式edit_entry

这个页面的URL需要传递要编辑的条目的ID。修改后的learning_logs/urls.py如下：

urls.py

--snip--
urlpatterns = [
    --snip--
    # 用于编辑条目的页面
    url(r'^edit_entry/(?P<entry_id>\d+)/$', views.edit_entry,
        name='edit_entry'),
]

在URL（如http://localhost:8000/edit_entry/1/）中传递的ID存储在形参entry_id中。这个URL模式将预期匹配的请求发送给视图函数edit_entry()。

2. 视图函数edit_entry()

页面edit_entry收到GET请求时，edit_entry()将返回一个表单，让用户能够对条目进行编辑。该页面收到POST请求（条目文本经过修订）时，它将修改后的文本保存到数据库中：

views.py

from django.shortcuts import render
--snip--

from .models import Topic, Entry
from .forms import TopicForm, EntryForm
--snip--

def edit_entry(request, entry_id):
    """编辑既有条目"""
    entry = Entry.objects.get(id=entry_id) ❶
    topic = entry.topic

    if request.method != 'POST':
        # 初次请求，使用当前条目填充表单
        form = EntryForm(instance=entry) ❷
    else:
        # POST提交的数据，对数据进行处理
        form = EntryForm(instance=entry, data=request.POST) ❸
        if form.is_valid():
            form.save() ❹
            return HttpResponseRedirect(reverse('learning_logs:topic', ❺
                                        args=[topic.id]))

    context = {'entry': entry, 'topic': topic, 'form': form}
    return render(request, 'learning_logs/edit_entry.html', context)

我们首先需要导入模型Entry。在❶处，我们获取用户要修改的条目对象，以及与该条目相关联的主题。在请求方法为GET时将执行的if代码块中，我们使用实参instance=entry创建一个EntryForm实例（见❷）。这个实参让Django创建一个表单，并使用既有条目对象中的信息填充它。用户将看到既有的数据，并能够编辑它们。

处理POST请求时，我们传递实参instance=entry和data=request.POST（见❸），让Django根据既有条目对象创建一个表单实例，并根据request.POST中的相关数据对其进行修改。然后，我们检查表单是否有效，如果有效，就调用save()，且不指定任何实参（见❹）。接下来，我们重定向到显示条目所属主题的页面（见❺），用户将在其中看到其编辑的条目的新版本。

3. 模板edit_entry

下面是模板edit_entry.html，它与模板new_entry.html类似：

edit_entry.html

{% extends "learning_logs/base.html" %}

{% block content %}

  <p><a href="{% url 'learning_logs:topic' topic.id %}">{{ topic }}</a></p>

  <p>Edit entry:</p>

  <form action="{% url 'learning_logs:edit_entry' entry.id %}" method='post'> ❶
    {% csrf_token %}
    {{ form.as_p }}
    <button name="submit">save changes</button> ❷
  </form>

{% endblock content %}

在❶处，实参action将表单发回给函数edit_entry()进行处理。在标签{% url %}中，我们将条目ID作为一个实参，让视图对象能够修改正确的条目对象。我们将提交按钮命名为save changes，以提醒用户：单击该按钮将保存所做的编辑，而不是创建一个新条目（见❷）。

4. 链接到页面edit_entry

现在，在显示特定主题的页面中，需要给每个条目添加到页面edit_entry的链接：

topic.html

--snip--
  {% for entry in entries %}
    <li>
      <p>{{ entry.date_added|date:'M d, Y H:i' }}</p>
      <p>{{ entry.text|linebreaks }}</p>
      <p>
        <a href="{% url 'learning_logs:edit_entry' entry.id %}">edit entry</a>
      </p>
    </li>
--snip--

我们将编辑链接放在每个条目的日期和文本后面。在循环中，我们使用模板标签{% url %}根据URL模式edit_entry和当前条目的ID属性（entry.id）来确定URL。链接文本为"edit entry"，它出现在页面中每个条目的后面。图19-3显示了包含这些链接时，显示特定主题的页面是什么样的。

图19-3　每个条目都有一个用于对其进行编辑的链接

至此，“学习笔记”已具备了需要的大部分功能。用户可添加主题和条目，还可根据需要查看任何一组条目。在下一节，我们将实现一个用户注册系统，让任何人都可向“学习笔记”申请账户，并创建自己的主题和条目。

动手试一试

19-1 博客：新建一个Django项目，将其命名为Blog。在这个项目中，创建一个名为blogs的应用程序，并在其中创建一个名为BlogPost的模型。这个模型应包含title、text和date_added等字段。为这个项目创建一个超级用户，并使用管理网站创建几个简短的帖子。创建一个主页，在其中按时间顺序显示所有的帖子。

创建两个表单，其中一个用于发布新帖子，另一个用于编辑既有的帖子。

尝试填写这些表单，确认它们能够正确地工作。

19.2　创建用户账户

在这一节，我们将建立一个用户注册和身份验证系统，让用户能够注册账户，进而登录和注销。我们将创建一个新的应用程序，其中包含与处理用户账户相关的所有功能。我们还将对模型Topic稍做修改，让每个主题都归属于特定用户。

19.2.1　应用程序users

我们首先使用命令startapp来创建一个名为users的应用程序：

(ll_env)learning_log$ python manage.py startapp users
(ll_env)learning_log$ ls
db.sqlite3  learning_log  learning_logs  ll_env  manage.py  users ❶
(ll_env)learning_log$ ls users
admin.py __init__.py migrations models.py tests.py views.py ❷

这个命令新建一个名为users的目录（见❶），其结构与应用程序learning_logs相同（见❷）。

1. 将应用程序users添加到settings.py中

在settings.py中，我们需要将这个新的应用程序添加到INSTALLED_APPS中，如下所示：

settings.py

--snip--
INSTALLED_APPS = (
    --snip--
    # 我的应用程序
    'learning_logs',
    'users',
)
--snip--

这样，Django将把应用程序users包含到项目中。

2. 包含应用程序users的URL

接下来，我们需要修改项目根目录中的urls.py，使其包含我们将为应用程序users定义的URL：

urls.py

from django.conf.urls import include, url
from django.contrib import admin

urlpatterns = [
    url(r'^admin/', include(admin.site.urls)),
    url(r'^users/', include('users.urls', namespace='users')),
    url(r'', include('learning_logs.urls', namespace='learning_logs')),
]

我们添加了一行代码，以包含应用程序users中的文件urls.py。这行代码与任何以单词users打头的URL（如http://localhost:8000/users/login/）都匹配。我们还创建了命名空间'users'，以便将应用程序learning_logs的URL同应用程序users的URL区分开来。

19.2.2　登录页面

我们首先来实现登录页面的功能。为此，我们将使用Django提供的默认登录视图，因此URL模式会稍有不同。在目录learning_log/users/中，新建一个名为urls.py的文件，并在其中添加如下代码：

urls.py

"""为应用程序users定义URL模式"""

from django.conf.urls import url
from django.contrib.auth.views import login ❶

from . import views

urlpatterns = [
    # 登录页面
    url(r'^login/$', login, {'template_name': 'users/login.html'}, ❷
        name='login'),
]

我们首先导入了默认视图login（见❶）。登录页面的URL模式与URL http://localhost:8000/users/login/匹配（见❷）。这个URL中的单词users让Django在users/urls.py中查找，而单词login让它将请求发送给Django默认视图login（请注意，视图实参为login，而不是views.login）。鉴于我们没有编写自己的视图函数，我们传递了一个字典，告诉Django去哪里查找我们将编写的模板。这个模板包含在应用程序users而不是learning_logs中。

1. 模板login.html

用户请求登录页面时，Django将使用其默认视图login，但我们依然需要为这个页面提供模板。为此，在目录learning_log/users/中，创建一个名为templates的目录，并在其中创建一个名为users的目录。以下是模板login.html，你应将其存储到目录learning_log/users/templates/users/中：

login.html

{% extends "learning_logs/base.html" %}

{% block content %}

  {% if form.errors %} ❶
  <p>Your username and password didn't match. Please try again.</p>
  {% endif %}

  <form method="post" action="{% url 'users:login' %}"> ❷
  {% csrf_token %}
  {{ form.as_p }} ❸

  <button name="submit">log in</button> ❹
  <input type="hidden" name="next" value="{% url 'learning_logs:index' %}" /> ❺
  </form>

{% endblock content %}

这个模板继承了base.html，旨在确保登录页面的外观与网站的其他页面相同。请注意，一个应用程序中的模板可继承另一个应用程序中的模板。

如果表单的errors属性被设置，我们就显示一条错误消息（见❶），指出输入的用户名—密码对与数据库中存储的任何用户名—密码对都不匹配。

我们要让登录视图处理表单，因此将实参action设置为登录页面的URL（见❷）。登录视图将一个表单发送给模板，在模板中，我们显示这个表单（见❸）并添加一个提交按钮（见❹）。在❺处，我们包含了一个隐藏的表单元素——'next'，其中的实参value告诉Django在用户成功登录后将其重定向到什么地方——在这里是主页。

2. 链接到登录页面

下面在base.html中添加到登录页面的链接，让所有页面都包含它。用户已登录时，我们不想显示这个链接，因此将它嵌套在一个{% if %}标签中：

base.html

<p>
  <a href="{% url 'learning_logs:index' %}">Learning Log</a> -
  <a href="{% url 'learning_logs:topics' %}">Topics</a> -
  {% if user.is_authenticated %} ❶
    Hello, {{ user.username }}. ❷
  {% else %}
    <a href="{% url 'users:login' %}">log in</a> ❸
  {% endif %}
</p>

{% block content %}{% endblock content %}

在Django身份验证系统中，每个模板都可使用变量user，这个变量有一个is_authenticated属性：如果用户已登录，该属性将为True，否则为False。这让你能够向已通过身份验证的用户显示一条消息，而向未通过身份验证的用户显示另一条消息。

在这里，我们向已登录的用户显示一条问候语（见❶）。对于已通过身份验证的用户，还设置了属性username，我们使用这个属性来个性化问候语，让用户知道他已登录（见❷）。在❸处，对于还未通过身份验证的用户，我们再显示一个到登录页面的链接。

3. 使用登录页面

前面建立了一个用户账户，下面来登录一下，看看登录页面是否管用。请访问http://localhost:8000/admin/，如果你依然是以管理员的身份登录的，请在页眉上找到注销链接并单击它。

注销后，访问http://localhost:8000/users/login/，你将看到类似于图19-4所示的登录页面。输入你在前面设置的用户名和密码，将进入页面index。。在这个主页的页眉中，显示了一条个性化问候语，其中包含你的用户名。

图19-4　登录页面

19.2.3　注销

现在需要提供一个让用户注销的途径。我们不创建用于注销的页面，而让用户只需单击一个链接就能注销并返回到主页。为此，我们将为注销链接定义一个URL模式，编写一个视图函数，并在base.html中添加一个注销链接。

1. 注销URL

下面的代码为注销定义了URL模式，该模式与URL http://locallwst:8000/users/logout/匹配。修改后的users/urls.py如下：

urls.py

--snip--
urlpatterns = [
    # 登录页面
    --snip--
    # 注销
    url(r'^logout/$', views.logout_view, name='logout'),
]

这个URL模式将请求发送给函数logout_view()。这样给这个函数命名，旨在将其与我们将在其中调用的函数logout()区分开来（请确保你修改的是users/urls.py，而不是learning_log/ urls.py）。

2. 视图函数logout_view()

函数logout_view()很简单：只是导入Django函数logout()，并调用它，再重定向到主页。请打开users/views.py，并输入下面的代码：

views.py

from django.http import HttpResponseRedirect
from django.core.urlresolvers import reverse
from django.contrib.auth import logout ❶

def logout_view(request):
    """注销用户"""
    logout(request) ❷
    return HttpResponseRedirect(reverse('learning_logs:index')) ❸

我们从django.contrib.auth中导入了函数logout()（见❶）。在❷处，我们调用了函数logout()，它要求将request对象作为实参。然后，我们重定向到主页（见❸）。

3. 链接到注销视图

现在我们需要添加一个注销链接。我们在base.html中添加这种链接，让每个页面都包含它；我们将它放在标签{% if user.is_authenticated %}中，使得仅当用户登录后才能看到它：

base.html

--snip—
  {% if user.is_authenticated %}
    Hello, {{ user.username }}.
    <a href="{% url 'users:logout' %}">log out</a>
  {% else %}
    <a href="{% url 'users:login' %}">log in</a>
  {% endif %}
--snip--

图19-5显示了用户登录后看到的主页。这里的重点是创建能够正确工作的网站，因此几乎没有设置任何样式。确定所需的功能都能正确运行后，我们将设置这个网站的样式，使其看起来更专业。

图19-5　包含个性化问候语和注销链接的主页

19.2.4　注册页面

下面来创建一个让新用户能够注册的页面。我们将使用Django提供的表单UserCreationForm，但编写自己的视图函数和模板。

1. 注册页面的URL模式

下面的代码定义了注册页面的URL模式，它也包含在users/urls.py中：

urls.py

--snip--
urlpatterns = [
    # 登录页面
    --snip--
    # 注册页面
    url(r'^register/$', views.register, name='register'),
]

这个模式与URL http://localhost:8000/users/register/匹配，并将请求发送给我们即将编写的函数register()。

2. 视图函数register()

在注册页面首次被请求时，视图函数register()需要显示一个空的注册表单，并在用户提交填写好的注册表单时对其进行处理。如果注册成功，这个函数还需让用户自动登录。请在users/views.py中添加如下代码：

views.py

from django.shortcuts import render
from django.http import HttpResponseRedirect
from django.core.urlresolvers import reverse
from django.contrib.auth import login, logout, authenticate
from django.contrib.auth.forms import UserCreationForm

def logout_view(request):
--snip--

def register(request):
    """注册新用户"""
    if request.method != 'POST':
        # 显示空的注册表单
        form = UserCreationForm() ❶
    else:
        # 处理填写好的表单
        form = UserCreationForm(data=request.POST) ❷

        if form.is_valid(): ❸
            new_user = form.save() ❹
            # 让用户自动登录，再重定向到主页
            authenticated_user = authenticate(username=new_user.username, ❺
                password=request.POST['password1'])
            login(request, authenticated_user) ❻
            return HttpResponseRedirect(reverse('learning_logs:index')) ❼

    context = {'form': form}
    return render(request, 'users/register.html', context)

我们首先导入了函数render()，然后导入了函数login()和authenticate()，以便在用户正确地填写了注册信息时让其自动登录。我们还导入了默认表单UserCreationForm。在函数register()中，我们检查要响应的是否是POST请求。如果不是，就创建一个UserCreationForm实例，且不给它提供任何初始数据（见❶）。

如果响应的是POST请求，我们就根据提交的数据创建一个UserCreationForm实例（见❷），并检查这些数据是否有效：就这里而言，是用户名未包含非法字符，输入的两个密码相同，以及用户没有试图做恶意的事情。

如果提交的数据有效，我们就调用表单的方法save()，将用户名和密码的散列值保存到数据库中（见❹）。方法save()返回新创建的用户对象，我们将其存储在new_user中。

保存用户的信息后，我们让用户自动登录，这包含两个步骤。首先，我们调用authenticate()，并将实参new_user.username和密码传递给它（见❺）。用户注册时，被要求输入密码两次；由于表单是有效的，我们知道输入的这两个密码是相同的，因此可以使用其中任何一个。在这里，我们从表单的POST数据中获取与键'password1'相关联的值。如果用户名和密码无误，方法authenticate()将返回一个通过了身份验证的用户对象，而我们将其存储在authenticated_user中。接下来，我们调用函数login()，并将对象request和authenticated_user传递给它（见❻），这将为新用户创建有效的会话。最后，我们将用户重定向到主页（见❼），其页眉中显示了一条个性化的问候语，让用户知道注册成功了。

3. 注册模板

注册页面的模板与登录页面的模板类似，请务必将其保存到login.html所在的目录中：

register.html

{% extends "learning_logs/base.html" %}

{% block content %}

  <form method="post" action="{% url 'users:register' %}">
    {% csrf_token %}
    {{ form.as_p }}

    <button name="submit">register</button>
    <input type="hidden" name="next" value="{% url 'learning_logs:index' %}" />
  </form>

{% endblock content %}

这里也使用了方法as_p，让Django在表单中正确地显示所有的字段，包括错误消息——如果用户没有正确地填写表单。

4. 链接到注册页面

接下来，我们添加这样的代码，即在用户没有登录时显示到注册页面的链接：

base.html

--snip--
  {% if user.is_authenticated %}
    Hello, {{ user.username }}.
    <a href="{% url 'users:logout' %}">log out</a>
  {% else %}
    <a href="{% url 'users:register' %}">register</a> -
    <a href="{% url 'users:login' %}">log in</a>
  {% endif %}
--snip--

现在，已登录的用户看到的是个性化的问候语和注销链接，而未登录的用户看到的是注册链接和登录链接。请尝试使用注册页面创建几个用户名各不相同的用户账户。

在下一节，我们将对一些页面进行限制，仅让已登录的用户访问它们，我们还将确保每个主题都属于特定用户。

注意　这里的注册系统允许用户创建任意数量的账户。有些系统要求用户确认其身份：发送一封确认邮件，用户回复后其账户才生效。通过这样做，系统生成的垃圾账户将比这里使用的简单系统少。然而，学习创建应用程序时，完全可以像这里所做的那样，使用简单的用户注册系统。

动手试一试

19-2 博客账户：在你为完成练习19-1而开发的项目Blog中，添加一个用户身份验证和注册系统。让已登录的用户在屏幕上看到其用户名，并让未注册的用户看到一个到注册页面的链接。

19.3　让用户拥有自己的数据

用户应该能够输入其专有的数据，因此我们将创建一个系统，确定各项数据所属的用户，再限制对页面的访问，让用户只能使用自己的数据。

在本节中，我们将修改模型Topic，让每个主题都归属于特定用户。这也将影响条目，因为每个条目都属于特定的主题。我们先来限制对一些页面的访问。

19.3.1　使用@login_required限制访问

Django提供了装饰器@login_required，让你能够轻松地实现这样的目标：对于某些页面，只允许已登录的用户访问它们。装饰器（decorator）是放在函数定义前面的指令，Python在函数运行前，根据它来修改函数代码的行为。下面来看一个示例。

1. 限制对topics页面的访问

每个主题都归特定用户所有，因此应只允许已登录的用户请求topics页面。为此，在learning_logs/views.py中添加如下代码：

views.py

--snip--
from django.core.urlresolvers import reverse
from django.contrib.auth.decorators import login_required

from .models import Topic, Entry
--snip--

@login_required
def topics(request):
    """显示所有的主题"""
    --snip--

我们首先导入了函数login_required()。我们将login_required()作为装饰器用于视图函数topics()——在它前面加上符号@和login_required，让Python在运行topics()的代码前先运行login_required()的代码。

login_required()的代码检查用户是否已登录，仅当用户已登录时，Django才运行topics()的代码。如果用户未登录，就重定向到登录页面。

为实现这种重定向，我们需要修改settings.py，让Django知道到哪里去查找登录页面。请在settings.py末尾添加如下代码：

settings.py

"""
项目learning_log的Django设置
--snip--

# 我的设置
LOGIN_URL = '/users/login/'

现在，如果未登录的用户请求装饰器@login_required的保护页面，Django将重定向到settings.py中的LOGIN_URL指定的URL。

要测试这个设置，可注销并进入主页。然后，单击链接Topics，这将重定向到登录页面。接下来，使用你的账户登录，并再次单击主页中的Topics链接，你将看到topics页面。

2. 全面限制对项目“学习笔记”的访问

Django让你能够轻松地限制对页面的访问，但你必须针对要保护哪些页面做出决定。最好先确定项目的哪些页面不需要保护，再限制对其他所有页面的访问。你可以轻松地修改过于严格的访问限制，其风险比不限制对敏感页面的访问更低。

在项目“学习笔记”中，我们将不限制对主页、注册页面和注销页面的访问，并限制对其他所有页面的访问。

在下面的learning_logs/views.py中，对除index()外的每个视图都应用了装饰器@login_required：

views.py

--snip--
@login_required
def topics(request):
    --snip--

@login_required
def topic(request, topic_id):
    --snip--

@login_required
def new_topic(request):
    --snip--

@login_required
def new_entry(request, topic_id):
    --snip--

@login_required
def edit_entry(request, entry_id):
    --snip--

如果你在未登录的情况下尝试访问这些页面，将被重定向到登录页面。另外，你还不能单击到new_topic等页面的链接。但如果你输入URL http://localhost:8000/new_topic/，将重定向到登录页面。对于所有与私有用户数据相关的URL，都应限制对它们的访问。

19.3.2　将数据关联到用户

现在，需要将数据关联到提交它们的用户。我们只需将最高层的数据关联到用户，这样更低层的数据将自动关联到用户。例如，在项目“学习笔记”中，应用程序的最高层数据是主题，而所有条目都与特定主题相关联。只要每个主题都归属于特定用户，我们就能确定数据库中每个条目的所有者。

下面来修改模型Topic，在其中添加一个关联到用户的外键。这样做后，我们必须对数据库进行迁移。最后，我们必须对有些视图进行修改，使其只显示与当前登录的用户相关联的数据。

1. 修改模型Topic

对models.py的修改只涉及两行代码：

models.py

from django.db import models
from django.contrib.auth.models import User

class Topic(models.Model):
   """用户要学习的主题"""
    text = models.CharField(max_length=200)
    date_added = models.DateTimeField(auto_now_add=True)
    owner = models.ForeignKey(User)

    def __str__(self):
        """返回模型的字符串表示"""
        return self.text

class Entry(models.Model):
    --snip--

我们首先导入了django.contrib.auth中的模型User，然后在Topic中添加了字段owner，它建立到模型User的外键关系。

2. 确定当前有哪些用户

我们迁移数据库时，Django将对数据库进行修改，使其能够存储主题和用户之间的关联。为执行迁移，Django需要知道该将各个既有主题关联到哪个用户。最简单的办法是，将既有主题都关联到同一个用户，如超级用户。为此，我们需要知道该用户的ID。

下面来查看已创建的所有用户的ID。为此，启动一个Django shell会话，并执行如下命令：

(venv)learning_log$ python manage.py shell
>>> from django.contrib.auth.models import User ❶
>>> User.objects.all() ❷
[, , ]
>>> for user in User.objects.all(): ❸
...     print(user.username, user.id)
...
ll_admin 1
eric 2
willie 3
>>>

在❶处，我们在shell会话中导入了模型User。然后，我们查看到目前为止都创建了哪些用户（见❷）。输出中列出了三个用户：ll_admin、eric和willie。

在❸处，我们遍历用户列表，并打印每位用户的用户名和ID。Django询问要将既有主题关联到哪个用户时，我们将指定其中的一个ID值。

3. 迁移数据库

知道用户ID后，就可以迁移数据库了。

(venv)learning_log$ python manage.py makemigrations learning_logs ❶
You are trying to add a non-nullable field 'owner' to topic without a default; ❷
we can't do that (the database needs something to populate existing rows).
Please select a fix: ❸
 1) Provide a one-off default now (will be set on all existing rows)
 2) Quit, and let me add a default in models.py
Select an option: 1 ❹
Please enter the default value now, as valid Python ❺
The datetime and django.utils.timezone modules are available, so you can do e.g. timezone.now()
>>> 1 ❻
Migrations for 'learning_logs':
  0003_topic_owner.py:
    - Add field owner to topic

我们首先执行了命令makemigrations（见❶）。在❷处的输出中，Django指出我们试图给既有模型Topic添加一个必不可少（不可为空）的字段，而该字段没有默认值。在❸处，Django给我们提供了两种选择：要么现在提供默认值，要么退出并在models.py中添加默认值。在❹处，我们选择了第一个选项，因此Django让我们输入默认值（见❺）。

为将所有既有主题都关联到管理用户ll_admin，我输入了用户ID值1（见❻）。并非必须使用超级用户，而可使用已创建的任何用户的ID。接下来，Django使用这个值来迁移数据库，并生成了迁移文件0003_topic_owner.py，它在模型Topic中添加字段owner。

现在可以执行迁移了。为此，在活动的虚拟环境中执行下面的命令：

(venv)learning_log$ python manage.py migrate
Operations to perform:
  Synchronize unmigrated apps: messages, staticfiles
  Apply all migrations: learning_logs, contenttypes, sessions, admin, auth
--snip--
Running migrations:
  Rendering model states... DONE
  Applying learning_logs.0003_topic_owner... OK ❶
(venv)learning_log$

Django应用新的迁移，结果一切顺利（见❶）。

为验证迁移符合预期，可在shell会话中像下面这样做：

>>> from learning_logs.models import Topic ❶
>>> for topic in Topic.objects.all(): ❷
...     print(topic, topic.owner)
...
Chess ll_admin
Rock Climbing ll_admin
>>>

我们从learning_logs.models中导入Topic（见❶），再遍历所有的既有主题，并打印每个主题及其所属的用户（见❷）。正如你看到的，现在每个主题都属于用户ll_admin。

注意　你可以重置数据库而不是迁移它，但如果这样做，既有的数据都将丢失。一种不错的做法是，学习如何在迁移数据库的同时确保用户数据的完整性。如果你确实想要一个全新的数据库，可执行命令python manage.py flush，这将重建数据库的结构。如果你这样做，就必须重新创建超级用户，且原来的所有数据都将丢失。

19.3.3　只允许用户访问自己的主题

当前，不管你以哪个用户的身份登录，都能够看到所有的主题。我们来改变这种情况，只向用户显示属于自己的主题。

在views.py中，对函数topics()做如下修改：

views.py

--snip--
@login_required
def topics(request):
    """显示所有的主题"""
    topics = Topic.objects.filter(owner=request.user).order_by('date_added')
    context = {'topics': topics}
    return render(request, 'learning_logs/topics.html', context)
--snip--

用户登录后，request对象将有一个user属性，这个属性存储了有关该用户的信息。代码Topic.objects.filter(owner=request.user)让Django只从数据库中获取owner属性为当前用户的Topic对象。由于我们没有修改主题的显示方式，因此无需对页面topics的模板做任何修改。

要查看结果，以所有既有主题关联到的用户的身份登录，并访问topics页面，你将看到所有的主题。然后，注销并以另一个用户的身份登录，topics页面将不会列出任何主题。

19.3.4　保护用户的主题

我们还没有限制对显示单个主题的页面的访问，因此任何已登录的用户都可输入类似于http://localhost:8000/topics/1/的URL，来访问显示相应主题的页面。

你自己试一试就明白了。以拥有所有主题的用户的身份登录，访问特定的主题，并复制该页面的URL，或将其中的ID记录下来。然后，注销并以另一个用户的身份登录，再输入显示前述主题的页面的URL。虽然你是以另一个用户登录的，但依然能够查看该主题中的条目。

为修复这种问题，我们在视图函数topic()获取请求的条目前执行检查：

views.py

from django.shortcuts import render
from django.http import HttpResponseRedirect, Http404 ❶
from django.core.urlresolvers import reverse
--snip--

@login_required
def topic(request, topic_id):
    """显示单个主题及其所有的条目"""
    topic = Topic.objects.get(id=topic_id)
    # 确认请求的主题属于当前用户
    if topic.owner != request.user: ❷
        raise Http404

    entries = topic.entry_set.order_by('-date_added')
    context = {'topic': topic, 'entries': entries}
    return render(request, 'learning_logs/topic.html', context)
--snip--

服务器上没有请求的资源时，标准的做法是返回404响应。在这里，我们导入了异常Http404（见❶），并在用户请求它不能查看的主题时引发这个异常。收到主题请求后，我们在渲染网页前检查该主题是否属于当前登录的用户。如果请求的主题不归当前用户所有，我们就引发Http404异常（见❷），让Django返回一个404错误页面。

现在，如果你试图查看其他用户的主题条目，将看到Django发送的消息Page Not Found。在第20章，我们将对这个项目进行配置，让用户看到更合适的错误页面。

19.3.5　保护页面edit_entry

页面edit_entry的URL为http://localhost:8000/edit_entry/entry_id/，其中entry_id是一个数字。下面来保护这个页面，禁止用户通过输入类似于前面的URL来访问其他用户的条目：

views.py

--snip--
@login_required
def edit_entry(request, entry_id):
    """编辑既有条目"""
    entry = Entry.objects.get(id=entry_id)
    topic = entry.topic
    if topic.owner != request.user:
        raise Http404

    if request.method != 'POST':
        # 初次请求，使用当前条目的内容填充表单
        --snip--

我们获取指定的条目以及与之相关联的主题，然后检查主题的所有者是否是当前登录的用户，如果不是，就引发Http404异常。

19.3.6　将新主题关联到当前用户

当前，用于添加新主题的页面存在问题，因此它没有将新主题关联到特定用户。如果你尝试添加新主题，将看到错误消息IntegrityError，指出learning_logs_topic.user_id不能为NULL。Django的意思是说，创建新主题时，你必须指定其owner字段的值。

由于我们可以通过request对象获悉当前用户，因此存在一个修复这种问题的简单方案。请添加下面的代码，将新主题关联到当前用户：

views.py

--snip--
@login_required
def new_topic(request):
    """添加新主题"""
    if request.method != 'POST':
        # 没有提交的数据,创建一个空表单
        form = TopicForm()
    else:
        # POST提交的数据,对数据进行处理
        form = TopicForm(request.POST)
        if form.is_valid():
            new_topic = form.save(commit=False) ❶
            new_topic.owner = request.user ❷
            new_topic.save() ❸
            return HttpResponseRedirect(reverse('learning_logs:topics'))

    context = {'form': form}
    return render(request, 'learning_logs/new_topic.html', context)
 --snip--

我们首先调用form.save()，并传递实参commit=False，这是因为我们先修改新主题，再将其保存到数据库中（见❶）。接下来，将新主题的owner属性设置为当前用户（见❷）。最后，对刚定义的主题实例调用save()（见❸）。现在主题包含所有必不可少的数据，将被成功地保存。

现在，这个项目允许任何用户注册，而每个用户想添加多少新主题都可以。每个用户都只能访问自己的数据，无论是查看数据、输入新数据还是修改旧数据时都如此。

动手试一试

19-3 重构：在views.py中，我们在两个地方核实主题关联到的用户为当前登录的用户。请将执行这种检查的代码放在一个名为check_topic_owner()的函数中，并在恰当的地方调用这个函数。

19-4 保护页面new_entry：一个用户可在另一个用户的学习笔记中添加条目，方法是输入这样的URL，即其中包含输入另一个用户的主题的ID。为防范这种攻击，请在保存新条目前，核实它所属的主题归当前用户所有。

19-5 受保护的博客：在你创建的项目Blog中，确保每篇博文都与特定用户相关联。确保任何用户都可访问所有的博文，但只有已登录的用户能够发表博文以及编辑既有博文。在让用户能够编辑其博文的视图中，在处理表单前确认用户编辑的是他自己发表的博文。

19.4　小结

在本章中，你学习了如何使用表单来让用户添加新主题、添加新条目和编辑既有条目。接下来，你学习了如何实现用户账户。你让老用户能够登录和注销，并学习了如何使用Django提供的表单UserCreationForm让用户能够创建新账户。

建立简单的用户身份验证和注册系统后，你通过使用装饰器@login_required禁止未登录的用户访问特定页面。然后，你通过使用外键将数据关联到特定用户，还学习了如何执行要求指定默认数据的数据库迁移。

最后，你学习了如何修改视图函数，让用户只能看到属于他的数据。你使用方法filter()来获取合适的数据，并学习了如何将请求的数据的所有者同当前登录的用户进行比较。

该让哪些数据可随便访问，该对哪些数据进行保护呢？这可能并非总是那么显而易见，但通过不断地练习就能掌握这种技能。在本章中，我们就该如何保护用户数据所做的决策表明，与人合作开发项目是个不错的主意：有人对项目进行检查的话，更容易发现其薄弱环节。

至此，我们创建了一个功能齐备的项目，它运行在本地计算机上。在本书的最后一章，我们将设置这个项目的样式，使其更漂亮；我们还将把它部署到一台服务器上，让任何人都可通过互联网注册并创建账户。

第 20 章　设置应用程序的样式并对其进行部署

当前，项目“学习笔记”功能已齐备，但未设置样式，也只是在本地计算机上运行。在本章中，我们将以简单而专业的方式设置这个项目的样式，再将其部署到一台服务器上，让世界上的任何人都能够建立账户。

为设置样式，我们将使用Bootstrap库，这是一组工具，用于为Web应用程序设置样式，使其在任何现代设备上都看起来很专业，无论是大型的平板显示器还是智能手机。为此，我们将使用应用程序django-bootstrap3，这也让你能够练习使用其他Django开发人员开发的应用程序。

我们将把项目“学习笔记”部署到Heroku，这个网站让你能够将项目推送到其服务器，让任何有网络连接的人都可使用它。我们还将使用版本控制系统Git来跟踪对这个项目所做的修改。

完成项目“学习笔记”后，你将能够开发简单的Web应用程序，让它们看起来很漂亮，再将它们部署到服务器。你还能够利用更高级的学习资源来提高技能。

20.1　设置项目“学习笔记”的样式

我们一直专注于项目“学习笔记”的功能，而没有考虑样式设置的问题，这是有意为之的。这是一种不错的开发方法，因为能正确运行的应用程序才是有用的。当然，应用程序能够正确运行后，外观就显得很重要了，因为漂亮的应用程序才能吸引用户使用它。

在本节中，我将简要地介绍应用程序django-bootstrap3，并演示如何将其继承到项目中，为部署项目做好准备。

20.1.1　应用程序django-bootstrap3

我们将使用django-bootstrap3来将Bootstrap继承到项目中。这个应用程序下载必要的Bootstrap文件，将它们放到项目的合适位置，让你能够在项目的模板中使用样式设置指令。

为安装django-bootstrap3，在活动的虚拟环境中执行如下命令：

(ll_env)learning_log$ pip install django-bootstrap3
--snip--
Successfully installed django-bootstrap3

接下来，需要在settings.py的INSTALLED_APPS中添加如下代码，在项目中包含应用程序django-boostrap3：

settings.py

--snip--
INSTALLED_APPS = (
    --snip--
    'django.contrib.staticfiles',

    # 第三方应用程序
    'bootstrap3',

    # 我的应用程序
    'learning_logs',
    'users',
)
--snip--

新建一个用于指定其他开发人员开发的应用程序的片段，将其命名为“第三方应用程序”，并在其中添加'bootstrap3'。大多数应用程序都需要包含在INSTALLED_APPS中，为确定这一点，请阅读要使用的应用程序的设置说明。

我们需要让django-bootstrap3包含jQuery，这是一个JavaScript库，让你能够使用Bootstrap模板提供的一些交互式元素。请在settings.py的末尾添加如下代码：

settings.py

--snip--
# 我的设置
LOGIN_URL = '/users/login/'

# django-bootstrap3的设置
BOOTSTRAP3 = {
    'include_jquery': True,
    }

这些代码让你无需手工下载jQuery并将其放到正确的地方。

20.1.2　使用Bootstrap来设置项目“学习笔记”的样式

Bootstrap基本上就是一个大型的样式设置工具集，它还提供了大量的模板，你可将它们应用于项目以创建独特的总体风格。对Bootstrap初学者来说，这些模板比各个样式设置工具使用起来要容易得多。要查看Bootstrap提供的模板，可访问http://getbootstrap.com/，单击Getting Started，再向下滚动到Examples部分，并找到Navbars in action。我们将使用模板Static top navbar，它提供了简单的顶部导航条、页面标题和用于放置页面内容的容器。

图20-1显示了对base.html应用这个Bootstrap模板并对index.html做细微修改后的主页。

图20-1　项目“学习笔记”的主页——使用Bootstrap设置样式后

知道要获得的效果后，接下来的内容理解起来将更容易。

20.1.3　修改base.html

我们需要修改模板base.html，以使用前述Bootstrap模板。我们把新的base.html分成几个部分进行介绍。

1. 定义HTML头部

对base.html所做的第一项修改是，在这个文件中定义HTML头部，使得显示“学习笔记”的每个页面时，浏览器标题栏都显示这个网站的名称。我们还将添加一些在模板中使用Bootstrap所需的信息。删除base.html的全部代码，并输入下面的代码：

base.html

{% load bootstrap3 %} ❶

<!DOCTYPE html> ❷
<html lang="en"> ❸
  <head> ❹
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <title>Learning Log</title> ❺

    {% bootstrap_css %} ❻
    {% bootstrap_javascript %}

  </head> ❼

在❶处，我们加载了django-bootstrap3中的模板标签集。接下来，我们将这个文件声明为使用英语（见❸）编写的HTML文档（见❷）。HTML文件分为两个主要部分：头部（head）和主体（body）；在这个文件中，头部始于❹处。HTML文件的头部不包含任何内容：它只是将正确显示页面所需的信息告诉浏览器。在❺处，我们包含了一个title元素，在浏览器中打开网站“学习笔记”的页面时，浏览器的标题栏将显示该元素的内容。

在❻处，我们使用了django-bootstrap3的一个自定义模板标签，它让Django包含所有的Bootstrap样式文件。接下来的标签启用你可能在页面中使用的所有交互式行为，如可折叠的导航栏。❼处为结束标签</head>。

2. 定义导航栏

下面来定义页面顶部的导航栏：

--snip--
  </head>

  <body>

    <!-- Static navbar -->
    <nav class="navbar navbar-default navbar-static-top"> ❶
      <div class="container">

        <div class="navbar-header">
          <button type="button" class="navbar-toggle collapsed" ❷
              data-toggle="collapse" data-target="#navbar"
              aria-expanded="false" aria-controls="navbar">
          </button>
          <a class="navbar-brand" href="{% url 'learning_logs:index' %}"> ❸
              Learning Log</a>
        </div>

        <div id="navbar" class="navbar-collapse collapse"> ❹
          <ul class="nav navbar-nav"> ❺
            <li><a href="{% url 'learning_logs:topics' %}">Topics</a></li> ❻
          </ul>

          <ul class="nav navbar-nav navbar-right"> ❼
            {% if user.is_authenticated %}
              <li><a>Hello, {{ user.username }}.</a></li>
              <li><a href="{% url 'users:logout' %}">log out</a></li>
            {% else %}
              <li><a href="{% url 'users:register' %}">register</a></li>
              <li><a href="{% url 'users:login' %}">log in</a></li>
            {% endif %}
          </ul> ❽
        </div><!--/.nav-collapse -->

      </div>
    </nav>

第一个元素为起始标签<body>。HTML文件的主体包含用户将在页面上看到的内容。❶处是一个<nav>元素，表示页面的导航链接部分。对于这个元素内的所有内容，都将根据选择器（selector）navbar、navbar-default和navbar-static-top定义的Bootstrap样式规则来设置样式。选择器决定了特定样式规则将应用于页面上的哪些元素。

在❷处，这个模板定义了一个按钮，它将在浏览器窗口太窄、无法水平显示整个导航栏时显示出来。如果用户单击这个按钮，将出现一个下拉列表，其中包含所有的导航元素。在用户缩小浏览器窗口或在屏幕较小的移动设备上显示网站时，collapse会使导航栏折叠起来。

在❸处，我们在导航栏的最左边显示项目名，并将其设置为到主页的链接，因为它将出现在这个项目的每个页面中。

在❹处，我们定义了一组让用户能够在网站中导航的链接。导航栏其实就是一个以<ul>打头的列表（见❺），其中每个链接都是一个列表项（<li>）。要添加更多的链接，可插入更多使用下述结构的行：

<li><a href="{% url 'learning_logs:title' %}">Title</a></li>

这行表示导航栏中的一个链接。这个链接是直接从base.html的前一个版本中复制而来的。

在❼处，我们添加了第二个导航链接列表，这里使用的选择器为navbar-right。选择器navbar-right设置一组链接的样式，使其出现在导航栏右边——登录链接和注册链接通常出现在这里。在这里，我们要么显示问候语和注销链接，要么显示注册链接和登录链接。这部分余下的代码结束包含导航栏的元素（见❽）。

3. 定义页面的主要部分

base.html的剩余部分包含页面的主要部分：

--snip--
    </nav>

    <div class="container"> ❶

      <div class="page-header">
        {% block header %}{% endblock header %} ❷
      </div>
      <div>
        {% block content %}{% endblock content %} ❸
      </div>

    </div> <!-- /container -->

  </body>
</html>

❶处是一个<div>起始标签，其class属性为container。div是网页的一部分，可用于任何目的，并可通过边框、元素周围的空间（外边距）、内容和边框之间的间距（内边距）、背景色和其他样式规则来设置其样式。这个div是一个容器，其中包含两个元素：一个新增的名为header的块（见❷）以及我们在第18章使用的content块（见❸）。header块的内容告诉用户页面包含哪些信息以及用户可在页面上执行哪些操作；其class属性值page-header将一系列样式应用于这个块。content块是一个独立的div，未使用class属性指定样式。

如果你在浏览器中加载“学习笔记”的主页，将看到一个类似于图20-1所示的专业级导航栏。请尝试调整窗口的大小，使其非常窄；此时导航栏将变成一个按钮，如果你单击这个按钮，将打开一个下拉列表，其中包含所有的导航链接。

注意　这个简化的Bootstrap模板适用于最新的浏览器，而较早的浏览器可能不能正确地渲染某些样式。完整的模板可在http://getbootstrap.com/getting-started/#examples/找到，它几乎在所有浏览器中都管用。

20.1.4　使用jumbotron设置主页的样式

下面来使用新定义的header块及另一个名为jumbotron的Bootstrap元素修改主页。jumbotron元素是一个大框，相比于页面的其他部分显得鹤立鸡群，你想在其中包含什么东西都可以；它通常用于在主页中呈现项目的简要描述。我们还可以修改主页显示的消息。index.html的代码如下：

index.html

{% extends "learning_logs/base.html" %}

{% block header %} ❶
  <div class='jumbotron'> ❷
    <h1>Track your learning.</h1>
  </div>
{% endblock header %}

{% block content %}
  <h2> ❸
    <a href="{% url 'users:register' %}">Register an account</a> to make
    your own Learning Log, and list the topics you're learning about.
  </h2>
  <h2>
    Whenever you learn something new about a topic, make an entry
    summarizing what you've learned.
  </h2>
{% endblock content %}

在❶处，我们告诉Django，我们要定义header块包含的内容。在一个jumbotron元素（见❷）中，我们放置了一条简短的标语——Track your Learning，让首次访问者大致知道“学习笔记”是做什么用的。

在❸处，我们通过添加一些文本，做了更详细的说明。我们邀请用户建立账户，并描述了用户可执行的两种主要操作：添加新主题以及在主题中创建条目。现在的主页类似于图20-1所示，与设置样式前相比，有了很大的改进。

20.1.5　设置登录页面的样式

我们改进了登录页面的整体外观，但还未改进登录表单，下面来让表单与页面的其他部分一致：

login.html

{% extends "learning_logs/base.html" %}
{% load bootstrap3 %} ❶

{% block header %} ❷
  <h2>Log in to your account.</h2>
{% endblock header %}

{% block content %}

  <form method="post" action="{% url 'users:login' %}" class="form"> ❸
    {% csrf_token %}
    {% bootstrap_form form %} ❹

    {% buttons %} ❺
      <button name="submit" class="btn btn-primary">log in</button>
    {% endbuttons %}

    <input type="hidden" name="next" value="{% url 'learning_logs:index' %}" />
  </form>

{% endblock content %}

在❶处，我们在这个模板中加载了bootstrap3模板标签。在❷处，我们定义了header块，它描述了这个页面是做什么用的。注意，我们从这个模板中删除了{% if form.errors %}代码块，因为django-bootstrap3会自动管理表单错误。

在❸处，我们添加了属性class="form"；然后使用模板标签{% bootstrap_form %}来显示表单（见❹）；这个标签替换了我们在第19章使用的标签{{ form.as_p }}。模板标签{% booststrap_form %}将Bootstrap样式规则应用于各个表单元素。❺处是bootstrap3起始模板标签{% buttons %}，它将Bootstrap样式应用于按钮。

图20-2显示了现在渲染的登录表单。这个页面比以前整洁得多，其风格一致，用途明确。如果你尝试使用错误的用户名或密码登录，将发现消息的样式与整个网站也是一致的，毫无违和感。

图20-2　使用Bootstrap设置样式后的登录页面

20.1.6　设置new_topic页面的样式

下面来让其他网页的风格也一致。首先来修改new_topic页面

new_topic.html

{% extends "learning_logs/base.html" %}
{% load bootstrap3 %} ❶

{% block header %}
  <h2>Add a new topic:</h2>
{% endblock header %}

{% block content %}

  <form action="{% url 'learning_logs:new_topic' %}" method='post' ❷
      class="form">

    {% csrf_token %}
    {% bootstrap_form form %} ❸

    {% buttons %} ❹
      <button name="submit" class="btn btn-primary">add topic</button>
    {% endbuttons %}

  </form>

{% endblock content %}

这里的大多数修改都类似于对login.html所做的修改：在❶处加载bootstrap3，添加header块并在其中包含合适的消息；接下来，我们在标签<form>中添加属性class="form"（见❷），使用模板标签{% bootstrap_form %}代替{{ form.as_p }}（见❸），并使用bootstrap3结构来定义提交按钮（见❹）。如果你现在登录并导航到new_topic页面，将发现其外观类似于登录页面。

20.1.7　设置topics页面的样式

下面来确保用于查看信息的页面的样式也是合适的，首先来设置topics页面的样式：

topics.html

{% extends "learning_logs/base.html" %}

{% block header %} ❶
  <h1>Topics</h1>
{% endblock header %}

{% block content %}

  <ul>
    {% for topic in topics %}
      <li>
        <h3> ❷
          <a href="{% url 'learning_logs:topic' topic.id %}">{{ topic }}</a>
        </h3>
      </li>
    {% empty %}
      <li>No topics have been added yet.</li>
    {% endfor %}
  </ul>

  <h3><a href="{% url 'learning_logs:new_topic' %}">Add new topic</h3> ❸

{% endblock content %}

我们不需要标签{% load bootstrap3 %}，因为我们在这个文件中没有使用任何bootstrap3自定义标签。我们在header块中添加了标题Topics（见❶）。为设置每个主题的样式，我们将它们都设置为<h3>元素，让它们在页面上显得大些（见❷）；对于添加新主题的链接，也做了同样的处理（见❸）。

20.1.8　设置topic页面中条目的样式

topic页面包含的内容比其他大部分页面都多，因此需要做的样式设置工作要多些。我们将使用Bootstrap面板（panel）来突出每个条目。面板是一个带预定义样式的div，非常适合用于显示主题的条目：

topic.html

{% extends 'learning_logs/base.html' %}

{% block header %} ❶
  <h2>{{ topic }}</h2>
{% endblock header %}

{% block content %}
  <p>
    <a href="{% url 'learning_logs:new_entry' topic.id %}">add new entry</a>
  </p>

  {% for entry in entries %}
    <div class="panel panel-default"> ❷
      <div class="panel-heading"> ❸
        <h3> ❹
          {{ entry.date_added|date:'M d, Y H:i' }}
          <small> ❺
            <a href="{% url 'learning_logs:edit_entry' entry.id %}">
              edit entry</a>
          </small>
        </h3>
      </div>
      <div class="panel-body"> ❻
        {{ entry.text|linebreaks }}
      </div>
    </div> <!-- panel -->
  {% empty %}
    There are no entries for this topic yet.
  {% endfor %}

{% endblock content %}

我们首先将主题放在了header块中（见❶）。然后，我们删除了这个模板中以前使用的无序列表结构。在❷处，我们创建了一个面板式div元素（而不是将每个条目作为一个列表项），其中包含两个嵌套的div：一个面板标题（panel-heading）div（见❸）和一个面板主体（panel-body）div（见❹）。其中面板标题div包含条目的创建日期以及用于编辑条目的链接，它们都被设置为<h3>元素，而对于编辑条目的链接，还使用了标签<small>，使其比时间戳小些（见❺）。

❻处是面板主体div，其中包含条目的实际文本。注意，只修改了影响页面外观的元素，对在页面中包含信息的Django代码未做任何修改。

图20-3显示了修改后的topic页面。“学习笔记”的功能没有任何变化，但显得更专业了，对用户会更有吸引力。

图20-3　使用Bootstrap设置样式后的topic页面

注意　要使用其他Bootstrap模板，可采用与本章类似的流程：将这个模板复制到base.html中，并修改包含实际内容的元素，以使用该模板来显示项目的信息；然后，使用Bootstrap的样式设置工具来设置各个页面中内容的样式。

动手试一试

20-1 其他表单：我们对登录页面和add_topic页面应用了Bootstrap样式。请对其他基于表单的页面做类似的修改：new_entry页面、edit_entry页面和注册页面。

20-2 设置博客的样式：对于你在第19章创建的项目Blog，使用Bootstrap来设置其样式。

20.2　部署“学习笔记”

至此，项目“学习笔记”的外观显得很专业了，下面来将其部署到一台服务器，让任何有网络连接的人都能够使用它。为此，我们将使用Heroku，这是一个基于Web的平台，让你能够管理Web应用程序的部署。我们将让“学习笔记”在Heroku上运行。

在Windows系统上的部署过程与在Linux和OS X系统上稍有不同。如果你使用的是Windows，请阅读各节的“注意”，它们指出了在Windows系统上需要采取的不同做法。

20.2.1　建立Heroku账户

要建立账户，请访问https://heroku.com/，并单击其中的一个注册链接。注册账户是免费的，Heroku提供了免费试用服务，让你能够将项目部署到服务器并对其进行测试。

注意　Heroku提供的免费试用服务存在一些限制，如可部署的应用程序数量以及用户访问应用程序的频率。但这些限制都很宽松，让你完全能够在不支付任何费用的情况下练习部署应用程序。

20.2.2　安装Heroku Toolbelt

要将项目部署到Heroku的服务器并对其进行管理，需要使用Heroku Toolbelt提供的工具。要安装最新的Heroku Toolbelt版本，请访问https://toolbelt.heroku.com/，并根据你使用的操作系统按相关的说明做：使用只包含一行的终端命令，或下载并运行安装程序。

20.2.3　安装必要的包

你还需安装很多包，以帮助在服务器上支持Django项目提供的服务。为此，在活动的虚拟环境中执行如下命令：

(ll_env)learning_log$ pip install dj-database-url
(ll_env)learning_log$ pip install dj-static
(ll_env)learning_log$ pip install static3
(ll_env)learning_log$ pip install gunicorn

务必逐个地执行这些命令，这样你就能知道哪些包未能正确地安装。dj-database-url包帮助Django与Heroku使用的数据库进行通信，dj-static和static3包帮助Django正确地管理静态文件，而gunicorn是一个服务器软件，能够在在线环境中支持应用程序提供的服务。（静态文件包括样式规则和JavaScript文件。）

注意　在Windows系统中，有些必不可少的包可能无法安装，因此如果在你尝试安装有些这样的包时出现错误消息，也不用担心。重要的是让Heroku在部署中安装这些包，下一节就将这样做。

20.2.4　创建包含包列表的文件requirements.txt

Heroku需要知道我们的项目依赖于哪些包，因此我们将使用pip来生成一个文件，其中列出了这些包。同样，进入活动虚拟环境，并执行如下命令：

(ll_env)learning_log$ pip freeze > requirements.txt

命令freeze让pip将项目中当前安装的所有包的名称都写入到文件requirements.txt中。请打开文件requirements.txt，查看项目中安装的包及其版本（如果你使用的是Windows系统，看到的内容可能不全）：

requirements.txt

Django==1.8.4
dj-database-url==0.3.0
dj-static==0.0.6
django-bootstrap3==6.2.2
gunicorn==19.3.0
static3==0.6.1

“学习笔记”依赖于6个特定版本的包，因此需要在相应的环境中才能正确地运行。我们部署“学习笔记”时，Heroku将安装requirements.txt列出的所有包，从而创建一个环境，其中包含我们在本地使用的所有包。有鉴于此，我们可以信心满满，深信项目部署到Heroku后，行为将与它在本地系统上的完全相同。当你在自己的系统上开发并维护各种项目时，这将是一个巨大的优点。

接下来，我们需要在包列表中添加psycopg2，它帮助Heroku管理活动数据库。为此，打开文件requirements.txt，并添加代码行psycopg2>=2.6.1。这将安装2.6.1版的psycopg2——如果有更高的版本，则安装更高的版本：

requirements.txt

Django==1.8.4
dj-database-url==0.3.0
dj-static==0.0.6
django-bootstrap3==6.2.2
gunicorn==19.3.0
static3==0.6.1
psycopg2>=2.6.1

如果有必不可少的包在你的系统中没有安装，请将其添加到文件requirements.txt中。最终的文件requirements.txt应包含上面列出的每个包。如果在你的系统中，requirements.txt列出的包的版本与上面列出的不同，请保留原来的版本号。

注意　如果你使用的是Windows系统，请确保文件requirements.txt的内容与前面列出的一致，而不要管你在系统中能够安装哪些包。

20.2.5　指定Python版本

如果你没有指定Python版本，Heroku将使用其当前的Python默认版本。下面来确保Heroku使用我们使用的Python版本。为此，在活动的虚拟环境中，执行命令python --version：

(ll_env)learning_log$ python --version
Python 3.5.0

上面的输出表明，我使用的是Python 3.5.0。请在manage.py所在的文件夹中新建一个名为runtime.txt的文件，并在其中输入如下内容：

runtime.txt

python-3.5.0

这个文件应只包含一行内容，以上面所示的格式指定了你使用的Python版本；请确保输入小写的python，在它后面输入一个连字符，再输入由三部分组成的版本号。

注意　如果出现错误消息，指出不能使用你指定的Python版本，请访问https://devcenter.heroku.com/并单击Python，再单击链接Specifying a Python Runtime。浏览打开的文章，了解支持的Python版本，并使用与你使用的Python版本最接近的版本。

20.2.6　为部署到Heroku而修改settings.py

现在需要在settings.py末尾添加一个片段，在其中指定一些Heroku环境设置：

settings.py

--snip--
# django-bootstrap3设置
BOOTSTRAP3 = {
    'include_jquery': True,
    }

# Heroku设置
if os.getcwd() == '/app': ❶
    import dj_database_url ❷
    DATABASES = {
        'default': dj_database_url.config(default='postgres://localhost')
    }

    # 让request.is_secure()承认X-Forwarded-Proto头
    SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') ❸

    # 支持所有的主机头（host header）
    ALLOWED_HOSTS = ['*'] ❹

    # 静态资产配置
    BASE_DIR = os.path.dirname(os.path.abspath(__file__)) ❺
    STATIC_ROOT = 'staticfiles'
    STATICFILES_DIRS = (
        os.path.join(BASE_DIR, 'static'),
    )

在❶处，我们使用了函数getcwd()，它获取当前的工作目录（当前运行的文件所在的目录）。在Heroku部署中，这个目录总是/app。在本地部署中，这个目录通常是项目文件夹的名称（就我们的项目而言，为learning_log）。这个if测试确保仅当项目被部署到Heroku时，才运行这个代码块。这种结构让我们能够将同一个设置文件用于本地开发环境和在线服务器。

在❷处，我们导入了dj_database_url，用于在Heroku上配置服务器。Heroku使用PostgreSQL（也叫Postgres）——一种比SQLite更高级的数据库；这些设置对项目进行配置，使其在Heroku上使用Postgres数据库。其他设置的作用分别如下：支持HTTPS请求（见❸）；让Django能够使用Heroku的URL来提供项目提供的服务（见❹）；设置项目，使其能够在Heroku上正确地提供静态文件（见❺）。

20.2.7　创建启动进程的Procfile

Procfile告诉Heroku启动哪些进程，以便能够正确地提供项目提供的服务。这个文件只包含一行，你应将其命名为Procfile（其中的P为大写），不指定文件扩展名，并保存到manage.py所在的目录中。

Procfile的内容如下：

Procfile

web: gunicorn learning_log.wsgi --log-file -

这行代码让Heroku将gunicorn用作服务器，并使用learning_log/wsgi.py中的设置来启动应用程序。标志log-file告诉Heroku应将哪些类型的事件写入日志。

20.2.8　为部署到Heroku而修改wsgi.py

为部署到Heroku，我们还需修改wsgi.py，因为Heroku需要的设置与我们一直在使用的设置稍有不同：

wsgi.py

--snip--
import os

from django.core.wsgi import get_wsgi_application
from dj_static import Cling

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "learning_log.settings")
application = Cling(get_wsgi_application())

我们导入了帮助正确地提供静态文件的Cling，并使用它来启动应用程序。这些代码在本地也适用，因此无需将其放在if代码块内。

20.2.9　创建用于存储静态文件的目录

在Heroku上，Django搜集所有的静态文件，并将它们放在一个地方，以便能够高效地管理它们。我们将创建一个用于存储这些静态文件的目录。在文件夹learning_log中，有一个名称也为learning_log的子文件夹。在这个子文件夹中，新建一个名为static的文件夹，因此这个文件夹的路径为learning_log/learning_log/static/。我们还需在这个文件夹中创建一个占位文件，因为项目被推送到Heroku时，它将不会包含原来为空的文件夹。在目录static/中，创建一个名为placeholder.txt的文件：

placeholder.txt

This file ensures that learning_log/static/ will be added to the project.
Django will collect static files and place them in learning_log/static/.

上述内容没有什么特别之处，只是指出了在项目中添加这个文件的原因。

20.2.10　在本地使用gunicorn服务器

如果你使用的是Linux或OS X，可在部署到Heroku前尝试在本地使用gunicorn服务器。为此，在活动的虚拟环境中，执行命令heroku local以启动Procfile指定的进程：

(ll_env)learning_log$ heroku local
Installing Heroku Toolbelt v4... done
--snip--
forego | starting web.1 on port 5000
web.1  | [2015-08-13 22:00:45 -0800] [12875] [INFO] Starting gunicorn 19.3.0 ❶
web.1  | [2015-08-13 22:00:45 -0800] [12875] [INFO] Listening at: ❷
    http://0.0.0.0:5000 (12875)
web.1  | [2015-08-13 22:00:45 -0800] [12878] [INFO] Booting worker with pid: 12878 ❸

首次执行命令heroku local时，将安装Heroku Toolbelt中的很多包。这里的输出表明启动了gunicorn，其进程id为12875（见❶）。❷处的输出表明，gunicorn在端口5000上侦听请求。另外，gunicorn还启动了一个工作进程（12878），用于帮助处理请求（见❸）。

为确认一切运行正常，请访问http://localhost:5000/，你将看到“学习笔记”的主页，就像使用Django服务器（runserver）时一样。为停止heroku local启动的进程，请按Ctrl + C，你将在本地开发中继续使用runserver。

注意　gunicorn不能在Windows系统上运行，因此如果你使用的是Windows系统，请跳过这一步。但这不会影响你将项目部署到Heroku。

20.2.11　使用Git跟踪项目文件

如果你阅读完了第17章，就知道Git是一个版本控制程序，让你能够在每次成功实现新功能后都拍摄项目代码的快照。无论出现什么问题（如实现新功能时不小心引入了bug），你都可以轻松地恢复到最后一个可行的快照。每个快照都被称为提交。

使用Git意味着你在试着实现新功能时无需担心破坏项目。将项目部署到服务器时，需要确保部署的是可行版本。如果你想更详细地了解Git和版本控制，请参阅附录D。

1. 安装Git

Heroku Toolbelt包含Git，因此它应该已经安装到了你的系统中。然而，在安装Heroku Toolbelt之前打开的终端窗口中无法访问Git，因此请打开一个新的终端窗口，并在其中执行命令git --version：

(ll_env)learning_log$ git --version
git version 2.5.0

如果由于某种原因出现了错误消息，请参阅附录D中的Git安装说明。

2. 配置Git

Git跟踪谁修改了项目，即便项目由一个人开发时亦如此。为进行跟踪，Git需要知道你的用户名和email。因此，你必须提供用户名，但对于练习项目，可随便伪造一个email：

(ll_env)learning_log$ git config --global user.name "ehmatthes"
(ll_env)learning_log$ git config --global user.email "eric@example.com"

如果你忘记了这一步，当你首次提交时，Git将提示你提供这些信息。

3. 忽略文件

我们无需让Git跟踪项目中的每个文件，因此将让Git忽略一些文件。为此，在manage.py所在的文件夹中创建一个名为.gitignore的文件。注意，这个文件名以句点打头，且不包含扩展名。在这个文件中输入如下内容：

.gitignore

ll_env/
__pycache__/
*.sqlite3

我们让Git忽略目录llenv，因为我们随时都可以自动重新创建它。我们还指定不跟踪目录 \_pycache__，这个目录包含Django运行.py文件时自动创建的.pyc文件。我们没有跟踪对本地数据库的修改，因为这是一个糟糕的做法：如果你在服务器上使用的是SQLite，当你将项目推送到服务器时，可能会不小心用本地测试数据库覆盖在线数据库。

注意　如果你使用的是Python 2.7，请将_pycache_替换为*.pyc，因为Python 2.7不会创建目录__pycache__。

4. 提交项目

我们需要为“学习笔记”初始化一个Git仓库，将所有必要的文件都加入到这个仓库中，并提交项目的初始状态，如下所示：

(ll_env)learning_log$ git init ❶
Initialized empty Git repository in /home/ehmatthes/pcc/learning_log/.git/
(ll_env)learning_log$ git add . ❷
(ll_env)learning_log$ git commit -am "Ready for deployment to heroku." ❸
[master (root-commit) dbc1d99] Ready for deployment to heroku.
 43 files changed, 746 insertions(+)
 create mode 100644 .gitignore
 create mode 100644 Procfile
 --snip--
 create mode 100644 users/views.py
(ll_env)learning_log$ git status ❹
# On branch master
nothing to commit, working directory clean
(ll_env)learning_log$

在❶处，我们执行命令git init，在“学习笔记”所在的目录中初始化一个空仓库。在❷处，我们执行了命令git add .（千万别忘了这个句点），它将未被忽略的文件都添加到这个仓库中。在❸处，我们执行了命令git commit -am commit message，其中的标志-a让Git在这个提交中包含所有修改过的文件，而标志-m让Git记录一条日志消息。

在❹处，我们执行了命令git status，输出表明当前位于分支master中，而工作目录是干净（clean）的。每当你要将项目推送到Heroku时，都希望看到这样的状态。

20.2.12　推送到Heroku

我们终于为将项目推送到Heroku做好了准备。在活动的虚拟环境中，执行下面的命令：

(ll_env)learning_log$ heroku login ❶
Enter your Heroku credentials.
Email: eric@example.com
Password (typing will be hidden):
Logged in as eric@example.com
(ll_env)learning_log$ heroku create ❷
Creating afternoon-meadow-2775... done, stack is cedar-14
https://afternoon-meadow-2775.herokuapp.com/ |
    https://git.heroku.com/afternoon-meadow-2775.git
Git remote heroku added
(ll_env)learning_log$ git push heroku master ❸
--snip--
remote: -----> Launching... done, v6
remote:        https://afternoon-meadow-2775.herokuapp.com/ deployed to Heroku ❹
remote: Verifying deploy.... done.
To https://git.heroku.com/afternoon-meadow-2775.git
   bdb2a35..62d711d  master -> master
(ll_env)learning_log$

首先，在终端会话中，使用你在https://heroku.com/创建账户时指定的用户名和密码来登录Heroku（见❶）。然后，让Heroku创建一个空项目（见❷）。Heroku生成的项目名由两个单词和一个数字组成，你以后可修改这个名称。接下来，我们执行命令git push heroku master（见❸），它让Git将项目的分支master推送到Heroku刚才创建的仓库中；Heroku随后使用这些文件在其服务器上创建项目。❹处列出了用于访问这个项目的URL。

执行这些命令后，项目就部署好了，但还未对其做全面的配置。为核实正确地启动了服务器进程，请执行命令heroku ps：

(ll_env)learning_log$ heroku ps
Free quota left: 17h 40m ❶
=== web (Free): `gunicorn learning_log.wsgi __log-file -` ❷
web.1: up 2015/08/14 07:08:51 (~ 10m ago)
(ll_env)learning_log$

输出指出了在接下来的24小时内，项目还可在多长时间内处于活动状态（见❶）。编写本书时，Heroku允许免费部署在24小时内最多可以有18小时处于活动状态。项目的活动时间超过这个限制后，将显示标准的服务器错误页面，稍后我们将设置这个错误页面。在❷处，我们发现启动了Procfile指定的进程。

现在，我们可以使用命令heroku open在浏览器中打开这个应用程序了：

(ll_env)learning_log$ heroku open
Opening afternoon-meadow-2775... done

你也可以启动浏览器并输入Heroku告诉你的URL，但上述命令可实现同样的结果。你将看到“学习笔记”的主页，其样式设置正确无误，但你还无法使用这个应用程序，因为我们还没有建立数据库。

注意　部署到Heroku的流程会不断变化。如果你遇到无法解决的问题，请通过查看Heroku文档来获取帮助。为此，可访问https://devcenter.heroku.com/，单击Python，再单击链接Getting Started with Django。如果你看不懂这些文档，请参阅附录C提供的建议。

20.2.13　在Heroku上建立数据库

为建立在线数据库，我们需要再次执行命令migrate，并应用在开发期间生成的所有迁移。要对Heroku项目执行Django和Python命令，可使用命令heroku run。下面演示了如何对Heroku部署执行命令migrate：

(ll_env)learning_log$ heroku run python manage.py migrate ❶
Running `python manage.py migrate` on afternoon-meadow-2775... up, run.2435 ❷
  --snip--
Running migrations: ❸
  --snip--
  Applying learning_logs.0001_initial... OK
  Applying learning_logs.0002_entry... OK
  Applying learning_logs.0003_topic_user... OK
  Applying sessions.0001_initial... OK
(ll_env)learning_log$

我们首先执行了命令heroku run python manage.py migrate（见❶）；Heroku随后创建一个终端会话来执行命令migrate（见❷）。在❸处，Django应用默认迁移以及我们在开发“学习笔记”期间生成的迁移。

现在如果你访问这个部署的应用程序，将能够像在本地系统上一样使用它。然而，你看不到你在本地部署中输入的任何数据，因为它们没有复制到在线服务器。一种通常的做法是不将本地数据复制到在线部署中，因为本地数据通常是测试数据。

你可以分享“学习笔记”的Heroku URL，让任何人都可以使用它。在下一节，我们将再完成几个任务，以结束部署过程并让你能够继续开发“学习笔记”。

20.2.14　改进Heroku部署

在本节中，我们将通过创建超级用户来改进部署，就像在本地一样。我们还将让这个项目更安全：将DEBUG设置为False，让用户在错误消息中看不到额外的信息，以防他们使用这些信息来攻击服务器。

1. 在Heroku上创建超级用户

我们知道可使用命令heroku run来执行一次性命令，但也可这样执行命令：在连接到了Heroku服务器的情况下，使用命令heroku run bash来打开Bash终端会话。Bash是众多Linux终端运行的语言。我们将使用Bash终端会话来创建超级用户，以便能够访问在线应用程序的管理网站：

(ll_env)learning_log$ heroku run bash
Running `bash` on afternoon-meadow-2775... up, run.6244
~ $ ls ❶
learning_log learning_logs manage.py Procfile requirements.txt runtime.txt users
staticfiles
~ $ python manage.py createsuperuser ❷
Username (leave blank to use 'u41907'): ll_admin
Email address:
Password:
Password (again):
Superuser created successfully.
~ $ exit ❸
exit
(ll_env)learning_log$

在❶处，我们执行命令ls，以查看服务器上有哪些文件和目录；服务器包含的文件和目录应该与本地系统相同。你可以像遍历其他文件系统一样遍历这个文件系统。

注意　即便你使用的是Windows系统，也应使用这里列出的命令（如ls而不是dir），因为你正通过远程连接运行一个Linux终端。

在❷处，我们执行了创建超级用户的命令，它像第18章在本地系统创建超级用户一样提示你输入相关的信息。在这个终端会话中创建超级用户后，使用命令exit返回到本地系统的终端会话（见❸）。

现在，你可以在在线应用程序的URL末尾添加/admin/来登录管理网站了。对我而言，这个URL为https://afternoon-meadow-2775.herokuapp.com/admin/。

如果已经有其他人开始使用这个项目，别忘了你可以访问他们的所有数据！千万别不把这当回事，否则用户就不会再将其数据托付给你了。

2. 在Heroku上创建对用户友好的URL

你可能希望URL更友好，比https://afternoon-meadow-2775.herokuapp.com/更好记。为此，可只需使用一个命令来重命名应用程序：

(ll_env)learning_log$ heroku apps:rename learning-log
Renaming afternoon-meadow-2775 to learning-log... done
https://learning-log.herokuapp.com/ | https://git.heroku.com/learning-log.git
Git remote heroku updated
(ll_env)learning_log$

给应用程序命名时，可使用字母、数字和连字符；你想怎么命名应用程序都可以，只要指定的名称未被别人使用就行。现在，项目的URL变成了https://learning-log.herokuapp.com/；使用以前的URL再也无法访问它，命令apps:rename将整个项目都移到了新的URL处。

注意　你使用Heroku提供的免费服务来部署项目时，如果项目在指定的时间内未收到请求或过于活跃，Heroku将让项目进入休眠状态。用户初次访问处于休眠状态的网站时，加载时间将更长，但对于后续请求，服务器的响应速度将更快。这就是Heroku能够提供免费部署的原因所在。

20.2.15　确保项目的安全

当前，我们部署的项目存在一个严重的安全问题：settings.py包含设置DEBUG=True，它在发生错误时显示调试信息。开发项目时，Django的错误页面向你显示了重要的调试信息，如果将项目部署到服务器后依然保留这个设置，将给攻击者提供大量可供利用的信息。我们还需确保任何人都无法看到这些信息，也不能冒充项目托管网站来重定向请求。

下面来修改settings.py，以让我们能够在本地看到错误消息，但部署到服务器后不显示任何错误消息：

settings.py

--snip--
# Heroku设置
if os.getcwd() == '/app':
    --snip--
    # 让request.is_secure()承认X-Forwarded-Proto头
    SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')

    # 只允许Heroku托管这个项目
    ALLOWED_HOSTS = ['learning-log.herokuapp.com'] ❶

    DEBUG = False ❷

    # 静态资产配置
    --snip--

我们只需做两方面的修改。在❶处，修改ALLOWED_HOSTS，只允许Heroku托管这个项目。你需要使用应用程序的名称，可以是Heroku提供的名称（如afternoon-meadow-2775.herokuapp.com），也可以是你选择的名称。在❷处，我们将DEBUG设置为False，让Django不在错误发生时显示敏感信息。

20.2.16　提交并推送修改

现在需要将对settings.py所做的修改提交到Git仓库，再将修改推送到Heroku。下面的终端会话演示了这个过程：

(ll_env)learning_log$ git commit -am "Set DEBUG=False for Heroku." ❶
[master 081f635] Set DEBUG=False for Heroku.
 1 file changed, 4 insertions(+), 2 deletions(-)
(ll_env)learning_log$ git status ❷
# On branch master
nothing to commit, working directory clean
(ll_env)learning_log$

我们执行命令git commit，并指定了一条简短而具有描述性的提交消息（见❶）。别忘了，标志-am让Git提交所有修改过的文件，并记录一条日志消息。Git找出唯一一个修改过的文件，并将所做的修改提交到仓库。

❷处显示的状态表明我们在仓库的分支master上工作，当前没有任何未提交的修改。推送到Heroku之前，必须检查状态并看到刚才所说的消息。如果你没有看到这样的消息，说明有未提交的修改，而这些修改将不会推送到服务器。在这种情况下，可尝试再次执行命令commit，但如果你不知道该如何解决这个问题，请阅读附录D，更深入地了解Git的用法。

下面来将修改后的仓库推送到Heroku：

(ll_env)learning_log$ git push heroku master
--snip--
remote: -----> Python app detected
remote: -----> Installing dependencies with pip
--snip--
remote: -----> Launching... done, v8
remote:        https://learning-log.herokuapp.com/ deployed to Heroku
remote: Verifying deploy.... done.
To https://git.heroku.com/learning-log.git
   4c9d111..ef65d2b  master -> master
(ll_env)learning_log$

Heroku发现仓库发生了变化，因此重建项目，确保所有的修改都已生效。它不会重建数据库，因此这次无需执行命令migrate。

现在要核实部署更安全了，请输入项目的URL，并在末尾加上我们未定义的扩展。例如，尝试访问http://learning-log.herokuapp.com/letmein/。你将看到一个通用的错误页面，它没有泄露任何有关该项目的具体信息。如果你尝试向本地的“学习笔记”发出同样的请求——输入URL http://localhost:8000/letmein/，你将看到完整的Django错误页面。这样的结果非常理想，你接着开发这个项目时，将看到信息丰富的错误消息，但用户看不到有关项目代码的重要信息。

20.2.17　创建自定义错误页面

在第19章，我们对“学习笔记”进行了配置，使其在用户请求不属于他的主题或条目时返回404错误。你可能还遇到过一些500错误（内部错误）。404错误通常意味着你的Django代码是正确的，但请求的对象不存在。500错误通常意味着你编写的代码有问题，如views.py中的函数有问题。当前，在这两种情况下，Django都返回通用的错误页面，但我们可以编写外观与“学习笔记”一致的404和500错误页面模板。这些模板必须放在根模板目录中。

1. 创建自定义模板

在文件夹learning_log/learning_log中，新建一个文件夹，并将其命名为templates；再在这个文件夹中新建一个名为404.html的文件，并在其中输入如下内容：

404.html

{% extends "learning_logs/base.html" %}

{% block header %}
    <h2>The item you requested is not available. (404)</h2>
{% endblock header %}

这个简单的模板指定了通用的404错误页面包含的信息，并且该页面的外观与网站的其他部分一致。

再创建一个名为500.html的文件，并在其中输入如下代码：

500.html

{% extends "learning_logs/base.html" %}

{% block header %}
    <h2>There has been an internal error. (500)</h2>
{% endblock header %}

这些新文件要求对settings.py做细微的修改：

settings.py

--snip--
TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [os.path.join(BASE_DIR, 'learning_log/templates')],
        'APP_DIRS': True,
        --snip--
    },
]
--snip--

这项修改让Django在根模板目录中查找错误页面模板。

2. 在本地查看错误页面

在将项目推送到Heroku之前，如果你要在本地查看错误页面是什么样的，首先需要在本地设置中设置Debug=False，以禁止显示默认的Django调试页面。为此，可对settings.py做如下修改（请确保你修改的是用于本地环境的settings.py部分，而不是用于Heroku的部分）：

settings.py

--snip--
# 安全警告：不要在在线环境中启用调试！
DEBUG = False

ALLOWED_HOSTS = ['localhost']
--snip--

DEBUG被设置为False时，你必须在ALLOWED_HOSTS中指定一个主机。现在，请求一个不属于你的主题或条目，以查看404错误页面；请求不存在的URL（如localhost:8000/letmein/），以查看500错误页面。

查看错误页面后，将DEBUG重新设置为True，以方便你进一步开发“学习笔记”。（在settings.py中用于Heroku部署的部分中，确保DEBUG依然被设置为False）。

注意　500错误页面不会显示任何有关当前用户的信息，因为发生服务器错误时，Django不会通过响应发送任何上下文信息。

3. 将修改推送到Heroku

现在需要提交对模板所做的修改，并将这些修改推送到Heroku：

(ll_env)learning_log$ git add . ❶
(ll_env)learning_log$ git commit -am "Added custom 404 and 500 error pages." ❷
 3 files changed, 15 insertions(+), 10 deletions(-)
 create mode 100644 learning_log/templates/404.html
 create mode 100644 learning_log/templates/500.html
(ll_env)learning_log$ git push heroku master ❸
--snip--
remote: Verifying deploy.... done.
To https://git.heroku.com/learning-log.git
   2b34ca1..a64d8d3  master -> master
(ll_env)learning_log$

在❶处，我们执行了命令git add，这是因为我们在项目中创建了一些新文件，因此需要让Git跟踪这些文件。然后，我们提交所做的修改（见❷），并将修改后的项目推送到Heroku（见❸）。

现在，错误页面出现时，其样式应该与网站的其他部分一致，这样在发生错误时，用户将不会感到突兀。

4. 使用方法get_object_or_404()

现在，如果用户手工请求不存在的主题或条目，将导致500错误。Django尝试渲染请求的页面，但没有足够的信息来完成这项任务，进而引发500错误。对于这种情形，将其视为404错误更合适，为此可使用Django快捷函数get_object_or_404()。这个函数尝试从数据库获取请求的对象，如果这个对象不存在，就引发404异常。我们在views.py中导入这个函数，并用它替换函数get()：

views.py

--snip--
from django.shortcuts import render, get_object_or_404
from django.http import HttpResponseRedirect, Http404
--snip--
@login_required
def topic(request, topic_id):
    """显示单个主题及其所有的条目"""
    topic = get_object_or_404(Topic, id=topic_id)
    # 确定主题属于当前用户
    --snip--

现在，如果你请求不存在的主题（例如，使用URL http://localhost:8000/topics/999999/），将看到404错误页面。为部署这里所做的修改，再次提交，并将项目推送到Heroku。

20.2.18　继续开发

将项目“学习笔记”推送到服务器后，你可能想进一步开发它或开发要部署的其他项目。更新项目的过程几乎完全相同。

首先，你对本地项目做必要的修改。如果在修改过程中创建了新文件，使用命令git add .（千万别忘记这个命令末尾的句点）将它们加入到Git仓库中。如果有修改要求迁移数据库，也需要执行这个命令，因为每个迁移都将生成新的迁移文件。

然后，使用命令git commit -am "commit message"将修改提交到仓库，再使用命令git push heroku master将修改推送到Heroku。如果你在本地迁移了数据库，也需要迁移在线数据库。为此，你可以使用一次性命令heroku run python manage.py migrate，也可使用heroku run bash打开一个远程终端会话，并在其中执行命令python manage.py migrate。然后访问在线项目，确认你期望看到的修改已生效。

在这个过程中很容易犯错，因此看到错误时不要大惊小怪。如果代码不能正确地工作，请重新审视所做的工作，尝试找出其中的错误。如果找不出错误，或者不知道如何撤销错误，请参阅附录C中有关如何寻求帮助的建议。不要羞于去寻求帮助：每个学习开发项目的人都可能遇到过你面临的问题，因此总有人乐意伸出援手。通过解决遇到的每个问题，可让你的技能稳步提高，最终能够开发可靠而有意义的项目，还能解决别人遇到的问题。

20.2.19　设置SECRET_KEY

Django根据settings.py中设置SECRET_KEY的值来实现大量的安全协议。在这个项目中，我们提交到仓库的设置文件包含设置SECRET_KEY。对于一个练习项目而言，这足够了，但对于生产网站，应更细致地处理设置SECRET_KEY。如果你创建的项目的用途很重要，务必研究如何更安全地处理设置SECRET_KEY。

20.2.20　将项目从Heroku删除

一个不错的练习是，使用同一个项目或一系列小项目执行部署过程多次，直到对部署过程了如指掌。然而，你需要知道如何删除部署的项目。Heroku可能还限制了你可免费托管的项目数，另外，你也不希望让自己的账户中塞满大量的练习项目。

在Heroku网站（https://heroku.com/）登录后，你将被重定向到一个页面，其中列出了你托管的所有项目。单击要删除的项目，你将看到另一个页面，其中显示了有关这个项目的信息。单击链接Settings，再向下滚动，找到用于删除项目的链接并单击它。这种操作是不可撤销的，因此Heroku让你手工输入要删除的项目的名称，以确认你确实要删除它。

如果你喜欢在终端中工作，也可使用命令destroy来删除项目：

(ll_env)learning_log$ heroku apps:destroy --app appname

其中appname是要删除的项目的名称，可能类似于afternoon-meadow-2775，也可能类似于learning-log（如果你重命名了项目）。你将被要求再次输入项目名，以确认你确实要删除它。

注意　删除Heroku上的项目对本地项目没有任何影响。如果没有人使用你部署的项目，就尽管去练习部署过程好了，在Heroku删除项目再重新部署完全合情合理。

动手试一试

20-3 在线博客：将你一直在开发的项目Blog部署到Heroku。确保将DEBUG设置为False，并修改设置ALLOWED_HOSTS，让部署相当安全。

20-4 在更多的情况下显示404错误页面：在视图函数new_entry()和edit_entry()中，也使用函数get_object_or_404()。完成这些修改后进行测试：输入类似于http://localhost:8000/new_entry/99999/的URL，确认你能够看到404错误页面。

20-5 扩展“学习笔记”：在“学习笔记”中添加一项功能，将修改推送到在线部署。尝试做一项简单的修改，如在主页中对项目作更详细的描述；再尝试添加一项更高级的功能，如让用户能够将主题设置为公开的。为此，需要在模型Topic中添加一个名为public的属性（其默认值为False），并在new_topic页面中添加一个表单元素，让用户能够将私有主题改为公开的。然后，你需要迁移项目，并修改views.py，让未登录的用户也可以看到所有公开的主题。将修改推送到Heroku后，别忘了迁移在线数据库。

20.3　小结

在本章中，你学习了如何使用Bootstrap库和应用程序django-bootstrap3赋予应用程序简单而专业的外观。使用Bootstrap意味着无论用户使用哪种设备来访问你的项目，你选择的样式都将实现几乎相同的效果。

你学习了Bootstrap的模板，并使用模板Static top navbar赋予了“学习笔记”简单的外观。你学习了如何使用jumbotron来突出主页中的消息，还学习了如何给网站的所有网页设置一致的样式。

在本章的最后一部分，你学习了如何将项目部署到Heroku的服务器，让任何人都能够访问它。你创建了一个Heroku账户，并安装了一些帮助管理部署过程的工具。你使用Git将能够正确运行的项目提交到一个仓库，再将这个仓库推送到Heroku的服务器。最后，你将DEBUG设置为False，以确保在线服务器上应用程序的安全。

至此，开发完了项目“学习笔记”后，你可以自己动手开发项目了。请先让项目尽可能简单，确定它能正确运行后，再添加复杂的功能。愿你学习愉快，开发项目时有好运相伴！

附录 A　安装Python

Python有多个不同的版本，而在各种操作系统中安装它的方式有很多。如果第1章介绍的方法不管用，或者你要安装非系统自带的Python版本，这个附录可提供帮助。

A.1　在Linux系统中安装Python

几乎所有Linux系统都默认安装了Python，但你可能想使用非默认版本。如果是这样，请首先确定已安装的Python版本。

A.1.1　确定已安装的版本

打开一个终端窗口，并执行如下命令：

$ python --version
Python 2.7.6

输出表明默认版本是2.7.6，但系统可能还安装了一个Python 3版本。为核实这一点，请执行如下命令：

$ python3 --version
Python 3.5.0

输出表明也安装了Python 3.5.0。安装新版本前，有必要执行上述两个命令。

A.1.2　在Linux系统中安装Python 3

如果你的系统没有安装Python 3，或者你想安装较新的Python 3版本，只需执行几个命令即可。我们使用一个名为deadsnakes的包，它让安装多个Python版本变得很容易：

$ sudo add-apt-repository ppa:fkrull/deadsnakes
$ sudo apt-get update
$ sudo apt-get install python3.5

这些命令在你的系统中安装Python 3.5。下面的命令启动一个运行Python 3.5的终端会话：

$ python3.5
>>>

配置文本编辑器使其使用Python 3以及从终端运行程序时，也需要用到这个命令。

A.2　在OS X系统中安装Python

大多数OS X系统都安装了Python，但你可能想使用非默认版本。如果是这样，请首先确定已安装了哪个版本的Python。

A.2.1　确定已安装的版本

打开一个终端窗口，并执行如下命令：

$ python --version
Python 2.7.6

你还应尝试执行命令python3 --version。执行这个命令时，可能会出现错误消息，但若要确定安装了哪些Python版本，有必要执行这个命令。

A.2.2　使用Homebrew来安装Python 3

如果你的系统只安装了Python 2，或者已安装的Python 3版本较旧，可使用一个名为Homebrew的包来安装最新的Python 3版本。

1. 安装Homebrew

Homebrew依赖于Apple包Xcode，因此请打开一个终端窗口并执行如下命令：

$ xcode-select --install

在不断出现的确认对话框中都单击OK按钮（根据网络连接的速度，这可能会花一些时间）。接下来安装Homebrew：

$ ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"

这个命令可在Homebrew网站（http://brew.sh/）的首页找到。在curl -fsSL和URL之间，务必包含一个空格。

注意　这个命令中的-e让Ruby（Homebrew就是使用这种编程语言编写的）执行下载的代码。除非来源是你信任的，否则不要运行这样的命令。

为确认正确地安装了Homebrew，请执行如下命令：

$ brew doctor
Your system is ready to brew.

上述输出表明你可以使用Homebrew来安装Python包了。

2. 安装Python 3

为安装最新的Python 3版本，请执行如下命令：

$ brew install python3

下面来检查使用这个命令安装的是哪个版本：

$ python3 --version
Python 3.5.0
$

现在，你可以使用命令python3来启动Python 3终端会话了，还可使用命令python3来配置文本编辑器，使其使用Python 3而不是Python 2来运行Python程序。

A.3　在Windows系统中安装Python

Windows系统默认未安装Python，但有必要检查系统是否安装了它。为此，按住Shift键并右击桌面，再选择“在此处打开命令窗口”来打开一个终端窗口。你也可以在开始菜单中执行命令command。在打开的终端窗口中，执行如下命令：

> python --version
Python 3.5.0

如果你看到了类似于上面的输出，说明已安装了Python，但你可能想安装更新的版本。如果看到一条错误消息，就说明你需要下载并安装Python。

A.3.1　在Windows系统中安装Python 3

访问http://python.org/downloads/，并单击你要安装的Python版本。下载安装程序，并在运行它时选择复选框Add Python to PATH。这让你不用手工修改系统的环境变量，在执行命令python时也无需指定其完整路径。安装Python后，打开一个新的终端窗口，并在其中执行命令python --version。如果没有报错，就说明Python安装好了。

A.3.2　查找Python解释器

如果不能执行简单命令python，你就需要告诉Windows去哪里查找Python解释器。要确定Python解释器的位置，请打开C盘，并在其中查找名称以Python打头的文件夹（要找到这样的文件夹，你可能需要在Windows资源管理器中的搜索栏中输入单词python）。打开这个文件夹，并查找名称为python（全部小写）的文件。右击这个文件并选择“属性”，你将在“位置：”右边看到它的路径。

在终端窗口中，使用该路径来确定刚安装的Python版本：

$ C:\\Python35\python --version
Python 3.5.0

A.3.3　将Python添加到环境变量Path中

如果每次启动Python终端时都需要输入完整的路径，那就太讨厌了；有鉴于此，我们将在系统中添加这个路径，让你只需使用命令python即可。如果你在安装Python时选择了复选框Add Python to PATH，可跳过这一步。打开控制面板并单击“系统和安全”，再单击“系统”。单击“高级系统设置”，在打开的窗口中单击按钮“环境变量”。

在“系统变量”部分，找到并单击变量Path，再单击按钮“编辑”。在出现的对话框中，单击“变量值”，并使用右箭头键滚到最右边。千万不要覆盖变量原来的值，如果你不小心这样做了，单击“取消”按钮，再重复前面的步骤。在变量值的末尾添加一个分号，再添加文件python.exe的路径：

%SystemRoot%\system32\...\System32\WindowsPowerShell\v1.0\;C:\Python34

关闭终端窗口，再打开一个新的终端窗口。这将在终端会话中加载变量Path的新值。现在当你执行命令python --version时，将看到刚才在变量Path中设置的Python版本。现在，你只需在命令提示符下输入python并按回车，就可启动Python终端会话了。

A.4　Python关键字和内置函数

Python包含一系列关键字和内置函数，给变量命名时，知道这些关键字和内置函数很重要。编程中面临的一个挑战是给变量指定合适的名称，变量名可以是任何东西，只要它长短合适并描述了变量的作用。同时，不能将Python关键字用作变量名；也不应将Python内置函数的名称用作变量名，否则将覆盖相应的内置函数。

本节将列出Python关键字和内置函数的名称，让你知道应避免使用哪些变量名。

A.4.1　Python关键字

下面的关键字都有特殊含义，如果你将它们用作变量名，将引发错误：

False | class | finally | is | return
None | continue | for | lambda | try
True | def | from | nonlocal | while
and | del | global | not | with
as | elif | if | or | yield
assert | else | import | pass
break | except | in | raise

A.4.2　Python内置函数

将内置函数名用作变量名时，不会导致错误，但将覆盖这些函数的行为：

abs() | divmod() | input() | open() | staticmethod()
all() | enumerate() | int() | ord() | str()
any() | eval() | isinstance() | pow() | sum()
basestring() | execfile() | issubclass() | print() | super()
bin() | file() | iter() | property() | tuple()
bool() | filter() | len() | range() | type()
bytearray() | float() | list() | raw_input() | unichr()
callable() | format() | locals() | reduce() | unicode()
chr() | frozenset() | long() | reload() | vars()
classmethod() | getattr() | map() | repr() | xrange()
cmp() | globals() | max() | reversed()zip() | Zip()
compile() | hasattr() | memoryview() | round() | __import__()
complex() | hash() | min() | set() | apply()
delattr() | help() | next() | setattr() | buffer()
dict() | hex() | object() | slice() | coerce()
dir() | id() | oct() | sorted() | intern()

注意　在Python 2.7中，print是关键字而不是函数。另外，Python 3没有内置函数unicode()。这两个单词都不应用作变量名。

附录 B　文本编辑器

程序员花大量时间来编写、阅读和编辑代码，因此使用的文本编辑器必须能够尽可能提高完成这种工作的效率。高效的编辑器应突出代码的结构，让你在编写代码时就能够发现常见的bug。它还应包含自动缩进功能、显示代码长度的标志以及用于执行常见操作的快捷键。

如果你是编程新手，应使用具备上述功能但学习起来又不难的编辑器。另外，你最好对更高级的编辑器有所了解，这样就知道何时该考虑升级编辑器了。

对于每种主要的操作系统，我们都将介绍一款符合上述条件的编辑器：使用Linux或Windows系统的初学者可使用Geany；使用OS X的初学者可使用Sublime Text（它在Linux和Windows系统中的效果也很好）。我们还将介绍Python自带的编辑器IDLE。最后，我们将介绍Emacs和vim，这是两款高级编辑器，随着你不断地学习编程，经常会听到有人提起它们。我们将把hello_world.py作为示例程序，在上述每款编辑器中运行它。

B.1　Geany

Geany是一款简单的编辑器，你可以在其中直接运行几乎任何程序。它还在一个终端窗口中显示程序的输出，这有助于你逐渐习惯使用终端。

B.1.1　在Linux系统中安装Geany

在大多数Linux系统中，安装Geany只需一个命令：

$ sudo apt-get install geany

如果你的系统安装了多个版本的Python，就必须配置Geany，使其使用正确的版本。启动Geany，选择菜单File ▶ Save As，将当前的空文件保存为hello_world.py，再在编辑窗口中输入下面一行代码：

print("Hello Python world!")

选择菜单Build ▶ Set Build Commands，你将看到文字Compile和Execute，它们旁边都有一个命令。默认情况下，这两个命令都是python，要让Geany使用命令python3，必须做相应的修改。将编译命令修改成下面这样：

python3 -m py_compile "%f"

你必须完全按这里显示的这样输出这个命令，确保空格和大小写都完全相同。

将执行命令修改成下面这样：

python3 "%f"

同样，务必确保空格和大小写都完全与显示的相同。

B.1.2　在Windows系统中安装Geany

要下载Windows Greany安装程序，可访问http://geany.org/，单击Download下的Releases，找到安装程序geany-1.25_setup.exe或类似的文件。下载安装程序后，运行它并接受所有的默认设置。

启动Geany，选择菜单File ▶ Save As，将当前的空文件保存为hello_world.py，再在编辑窗口中输入下面一行代码：

print("Hello Python world!")

现在选择菜单Build ▶ Set Build Commands，你将看到文字Compile和Execute，它们旁边都有一个命令。默认情况下，这两个命令都是python（全部小写），但Geany不知道这个命令位于系统的什么地方。你需要添加启动终端会话时使用的路径（如果你按附录A描述的那样设置了变量Path，可跳过这些步骤）。在编译命令和执行命令中，添加命令python所在的驱动器和文件夹。编译命令应类似于下面这样：

C:\Python35\python -m py_compile "%f"

在你的系统上，路径可能稍有不同，但你必须确保空格和大小写都与这里显示的完全相同。

执行命令应类似于下面这样：

C:\Python35\python "%f"

同样，务必确保空格和大小写都与这里显示的完全相同。正确地设置这些命令后，单击OK按钮。现在，你应该能够成功地运行程序了。

B.1.3　在Geany中运行Python程序

在Geany中运行程序的方式有三种。为运行程序hello_world.py，可选择菜单Build ▶ Execute、单击由两个齿轮组成的图标或按F5。运行hello_world.py时，将弹出一个终端窗口，其中包含如下输出：

Hello Python world!

------------------
(program exited with code: 0)
Press return to continue

B.1.4　定制Geany的设置

下面来定制本附录开头提到的功能，尽可能提高Geany的效率。

1. 将制表符转换为空格

在代码中混合使用制表符和空格可能会给Python程序带来极难诊断的问题。为在Geany中查看缩进设置，选择菜单Edit ▶ Preferences，再依次单击Editor和Indentation。将制表符宽度设置为4，并将Type设置为Spaces。

如果你在程序中混合使用了标识符和空格，可选择菜单Document ▶ Replace Tabs by Spaces（“替换制表符为空格”），将所有制表符都转换为空格。

2. 设置行长标志

在大多数编辑器中，都可设置视觉线索（通常是一条竖线），来指出代码行应在什么地方结束。要在Geany中设置这项功能，请选择菜单Edit ▶ Preferences，再依次单击Editor和Display，确保启用了长行标志，再确保文本框“列”中的值为79。

3. 缩进和撤销缩进代码块

要缩进代码块，可选择它，再选择菜单Edit ▶ Format ▶ Increase Indent，也可按Ctrl + I。要撤销代码块缩进，可选择菜单Edit ▶ Format ▶ Decrease Indent，也可按Ctrl + U。

4. 将代码块注释掉

要暂时禁用一个代码块，可选择它，并将它注释掉，这样Python将忽略它。为此，可选择菜单Edit ▶ Format ▶ Toggle Line Commentation，也可按Ctrl + E。选择的代码将被注释掉，并使用特殊字符序列#~指出这不是常规注释。要对代码块取消注释，可选择它，并再次选择前述菜单。

B.2　Sublime Text

Sublime Text是一款简单的文本编辑器，它在OS X（及其他系统）中易于安装，让你能够直接运行几乎所有程序。它还能在内嵌在Sublime Text窗口内的终端会话中运行代码，让你能够轻松地查看输出。

Sublime Text的许可策略非常灵活，你可以永久地免费使用这款编辑器，但如果你喜欢它并想长期使用，作者建议你购买许可证。我们将下载Sublime Text 3——编写本书时的最新版本。

B.2.1　在OS X系统中安装Sublime Text

要下载Sublime Text安装程序，可访问http://sublimetext.com/3，单击链接Download，并查找OS X安装程序。下载安装程序后，打开它，再将Sublime Text图标拖放到文件夹Applications。

B.2.2　在Linux系统中安装Sublime Text

在大多数Linux系统中，安装Sublime Text的最简单方式是通过终端会话，如下所示：

$ sudo add-apt-repository ppa:webupd8team/sublime-text-3
$ sudo apt-get update
$ sudo apt-get install sublime-text-installer

B.2.3　在Windows系统中安装Sublime Text

从http://www.sublimetext.com/3下载Windows安装程序。运行这个安装程序，你将在开始菜单中看到Sublime Text。

B.2.4　在Sublime Text中运行Python程序

如果你使用的是系统自带的Python版本，可能无需调整任何设置就能运行程序。要运行程序，可选择菜单Tools ▶ Build或按Ctrl + B。运行hello_world.py时，你将在Sublime Text窗口的底部看到一个终端屏幕，其中包含如下输出：

Hello Python world!
[Finished in 0.1s]

B.2.5　配置Sublime Text

如果你安装了多个Python版本或者Sublime Text不能自动运行程序，你可能需要设置一个配置文件。你首先需要知道Python解释器的完整路径，为此，在Linux或OS X系统中执行如下命令：

$ type -a python3
python3 is /usr/local/bin/python3

请将python3替换为你启动终端会话时使用的命令。

如果你使用的是Windows系统，要获悉Python解释器的路径，请参阅“在Windows系统中安装Python 3”一节。

现在，启动Sublime Text，并选择菜单Tools ▶ Build System ▶ New Build System，这将打开一个新的配置文件。删除其中的所有内容，再输入如下内容：

Python3 .sublime-build

{
    "cmd": ["/usr/local/bin/python3", "-u", "$file"],
}

这些代码让Sublime Text使用命令python3来运行当前打开的文件。请确保其中的路径为你在前一步获悉的路径（在Windows系统中，该路径类似于C:/Python35/python）。将这个配置文件命名为Python3.sublime-build，并将其保存到默认目录——选择菜单Save时Sublime Text打开的目录。

打开hello_world.py，选择菜单Tools ▶ Build System ▶ Python3，再选择菜单Tools ▶ Build，你将在内嵌在Sublime Text窗口底部的终端中看到输出。

B.2.6　定制Sublime Text的设置

下面来定制本附录开头提到的功能，以尽可能提高Sublime Text的效率。

1. 将制表符转换为空格

选择菜单View ▶ Indentation，核实选择了复选框Indent Using Spaces。如果没有选择该复选框，现在选择它。

2. 设置行长标志

选择菜单View ▶ Ruler，再单击80，Sublime Text将在这个80字符标志处放置一条竖线。

3. 缩进和取消缩进代码块

要缩进代码块，可选择它，再选择菜单Edit ▶ Line ▶ Indent或按Ctrl + ]。要取消缩进代码块，可选择菜单Edit ▶ Line ▶ Unindent或按Ctrl + [。

4. 将代码块注释掉

要将代码块注释掉，可选择它，再选择菜单Edit ▶ Comment ▶ Toggle Comment或按Ctrl + /。要取消代码块注释，再次执行这个命令。

B.3　IDLE

IDLE是Python的默认编辑器，相比于Geany和Sublime Text，它不那么直观，但其他教程指出它适合初学者使用，因此你不妨试一试。

B.3.1　在Linux系统中安装IDLE

如果你使用的是Python 3，请像下面这样安装idle3包：

$ sudo apt-get install idle3

如果你使用的是Python 2，请像下面这样安装idle包：

$ sudo apt-get install idle

B.3.2　在OS X系统中安装IDLE

如果你的Python是使用Homebrew安装的，那么可能已经安装了IDLE。在终端中，执行命令brew linkapps，它告诉IDLE如何在系统中查找正确的Python解释器。随后，你会在文件夹Applications中看到IDLE。

如果你的Python不是使用Homebrew安装的，请访问https://www.python.org/download/mac/tcltk/，并按这里的说明做。你还需安装IDLE依赖的一些图形包。

B.3.3　在Windows系统中安装IDLE

你安装Python时，应该自动安装了IDLE，因此它应该包含在开始菜单中。

B.3.4　定制IDLE的设置

由于IDLE是默认的Python编辑器，因此它的大多数设置都被设置为推荐的Python设置：将制表符自动转换为空格；行长标志出现在80字符处。

1. 缩进和取消缩进代码块

要缩进代码块，可选择它，再选择菜单Format ▶ Indent Region或按Ctrl + ]；要取消代码块缩进，可选择菜单Format ▶ Dedent Region或按Ctrl + [。

2. 将代码块注释掉

要将代码块注释掉，可选择它，再选择菜单Format ▶ Comment Out Region或按Alt + 3。要取消代码块注释，可选择菜单Format ▶ Uncomment Region或按Alt + 4。

B.4　Emacs和vim

Emacs和vim是两款流行的编辑器，深受众多经验丰富的程序员的喜爱，因为使用它们时，用户根本不用离开键盘。因此，学会使用这些编辑器后，编写、阅读和编辑代码的效率将极高；这也意味着学习使用它们的难度极大。

程序员通常会推荐你试一试，但很多熟练的程序员忘了编程新手尝试学习使用它们将花费很多时间。知道这些编辑器是有益的，但请先使用简单的编辑器，它们让你能够专注于学习编程，而不是费时间去学习如何使用编辑器。等你能够熟悉地编写和编辑代码后，再去使用这些编辑器吧。

附录 C　寻求帮助

每个人学习编程时都会遇到困难，因此作为程序员，需要学习的最重要的技能之一是如何高效地摆脱困境。这个附录简要地介绍几种方法，以帮助你摆脱编程困境。

C.1　第一步

陷入困境后，首先需要判断形势。你必须能够明确地回答如下三个问题，才能够从他人那里获得帮助。

你想要做什么？

你已尝试哪些方式？

结果如何？

答案应尽可能具体。对于第一个问题，像“我要在我的Windows 10计算机上安装最新版本的Python 3”这样明确的陈述足够详细，让Python社区的其他人员能够施以援手；而像“我要安装Python”这样的陈述没有提供足够的信息，别人无法提供太多的帮助。

对于第二个问题，你的答案应提供足够多的细节，这样别人就不会建议你去重复已经尝试过的方式：相比于“我访问Python网站，并下载了一个安装程序”，“我访问http://python.org/downloads/，并单击Python 3的Download按钮，再运行这个安装程序”提供的信息更详细。

对于最后一个问题，知道准确的错误消息对在线搜索解决方案或寻求帮助很有用。

有时候，通过回答这三个问题，你会发现遗漏了什么，从而无需再做其他的事情就能摆脱困境。程序员甚至给这种情形提供了一个名称，称之为橡皮鸭子调试法。如果你向一只橡皮鸭子（或任何无生命的东西）清楚地阐述自己的处境，并向它提出具体的问题，你常常能够回答这个问题。有些编程公司甚至在办公室放置一个橡皮鸭子，旨在鼓励程序员“与这只鸭子交流”。

C.1.1　再试试

只需回过头去重新来一次，就足以解决很多问题。假设你正模仿本书的示例编写一个for循环，你可能遗漏了某种简单的东西，如for语句末尾的冒号。再试一次可能就会帮助你避免重复同样的错误。

C.1.2　歇一会儿

如果你很长时间内一直在试图解决同一个问题，那么休息一会儿实际上是你可采取的最佳战术。长时间从事一个任务时，你可能变得一根筋，脑子里想的都是一个解决方案。你对所做的假设往往会视而不见，而休息一会儿有助于你从不同的角度看问题。不用休息很长时间，只需让你能够摆脱当前的思维方式就行。如果你坐了很长时间，起来做做运动。溜达溜达或去外面待一会儿，也可以喝杯水，或者吃点清淡而健康的零食。

如果你心情沮丧，也许该将工作放到一边，整天都不考虑了。晚上睡个好觉后，你常常会发现问题并不是那么难解决。

C.1.3　参考本书的在线资源

本书通过http://www.ituring.com.cn/book/1861提供了配套的在线资源，其中包含大量有用的信息，比如如何设置系统以及如何解决每章可能遇到的难题。如果你还没有查看这些资源，现在就去查看吧，看看它们能否提供帮助。

C.2　在线搜索

很可能有人以前遇到过你面临的问题，并在网上发表了相关的文章。良好的搜索技能和具体的关键字有助于你找到现有的资源，供你用来解决当前面临的问题。例如，如果你无法在使用Windows 10的计算机上安装Python 3，那么搜索“python 3 windows 10”可能会让你马上找到解决问题的方案。

搜索计算机显示的错误消息也极有帮助。例如，假设你试图启动Python终端会话时出现了如下错误消息：

> python
'python' is not recognized as an internal or external command
>

通过搜索完整的错误消息“python is not recognized as an internal or external command ”，也许能得到不错的建议。

当你搜索与编程相关的主题时，有几个网站会反复出现。下面简要地介绍一下这些网站，让你知道它们可能提供什么样的帮助。

C.2.1　Stack Overflow

Stack Overflow（http://stackoverflow.com/）是最受程序员欢迎的问答网站之一，当你执行与Python相关的搜索时，它常常会出现在第一个结果页中。其成员在陷入困境时提出问题，其他成员努力提供有帮助的答案。用户可推荐他认为最有帮助的答案，因此前几个答案通常就是最佳答案。

对于很多基本的Python问题，Stack Overflow都有非常明确的答案，因为这个社区在不断改进。它鼓励用户发布更新的帖子，因此这里的答案通常与时俱进。编写本书时，Stack Overflow回答的与Python相关的问题超过了400 000个。

C.2.2　Python官方文档

对初学者来说，Python官方文档（http://docs.python.org/）显得有点漫不经心，因为其主要目的是阐述这门语言，而不是进行解释。官方文档中的示例应该很有用，但你也许不能完全弄懂。虽然如此，这还是一个不错的资源，如果它出现在搜索结果中，就值得你去参考；另外，随着你对Python的认识越来越深入，这个资源的用处将越来越大。

C.2.3　官方库文档

如果你使用了库，如Pygame、matplotlib、Django等，搜索结果中通常会包含到其官方文档的链接，例如，http://docs.djangoproject.com/就很有用。如果你要使用这些库，最好熟悉其官方文档。

C.2.4　r/learnpython

Reddit包含很多子论坛，这些子论坛被称为subreddit，其中的r/learnpython（http://reddit.com/r/learnpython/）非常活跃，提供的信息也很有帮助。你可以在这里阅读其他人提出的问题，也可提出自己的问题。

C.2.5　博客

很多程序员都有博客，旨在与人分享针对其使用的语言部分撰写的帖子。接受博客文章提供的建议前，你应大致浏览一下前几个评论，看看其他人的反应。如果文章没有任何评论，请对其持保留态度——它提供的建议可能还没有人验证过。

C.3　IRC

程序员通过IRC（Internet Relay Chat）实时地交流。如果你被问题困住，那么在网上搜索也找不到答案，那么在相关的IRC频道（channel）中寻求帮助可能是最佳选择。出没在这些频道中的人大多彬彬有礼、乐于助人，在你能够详细地描述你想做什么、尝试了哪些方法以及这些方法的结果时尤其如此。

C.3.1　创建IRC账户

要建立IRC账户，请访问http://webchat.freenode.net/，选择一个昵称，输入验证码，再单击Connect。你将看到一条消息，欢迎你访问freenode IRC服务器。在窗口底部的方框中，输入如下命令：

/msg nickserv register password email

请将其中的password和email替换为你的密码和电子邮件地址。请选择一个不用于其他账户的简单密码，这个密码不会以安全的方式传输，因此根本不要试图去创建安全的密码。你将收到一封邮件，其中包含有关如何验证账户的说明。这封邮件将向你提供一个类似于下面的命令：

/msg nickserv verify register nickname verification_code

将这一行粘贴到IRC网站，将其中的nickname替换为你在前面选择的昵称，并将verification_code替换为你看到的验证码。现在，你就可以加入频道了。

C.3.2　加入频道

要加入Python主频道，可在输入框中输入/join #python，你将看到一条确认消息，指出你加入了该频道，还将看到有关该频道的简介。

频道##learnpython（两个井号）也非常活跃。这个频道与http://reddit.com/r/learnpython/相关联，因此你在其中也将看到有关r/learnpython上发表的帖子的消息。频道#pyladies专注于支持学习Python的女性和女性程序员拥趸。如果你正在开发Web应用程序，可能想加入频道#django。

加入频道后，就可看到其他人的交流，还可提出问题。

C.3.3　IRC文化

要获得有效的帮助，你需要知道一些有关IRC文化的细节。将重点放在这个附录开头所说的三个问题，无疑有助于获得可行的解决方案。如果你能准确地阐述你要做什么、尝试了哪些方法以及得到的结果，别人就会乐意伸出援手。为分享代码或输出，IRC成员使用专门为此创建的外部网站，如https://bpaste.net/+python/（#python通过它来分享代码和输出）。这避免了频道充斥着代码，还让分享的代码阅读起来容易得多。

一定要有耐心，这样别人才会更乐意帮助你。准确地提出问题，并等待别人来回答。虽然大家都在忙于交流，但通常总会有人及时地回答你的问题。如果频道的参与者较少，可能需要等一段时间才会有人回答你的问题。

附录 D　使用Git进行版本控制

版本控制软件让你能够拍摄处于可行状态的项目的快照。修改项目（如实现新功能）后，如果项目不能正常运行，可恢复到前一个可行状态。

通过使用版本控制软件，你可以无忧无虑地改进项目，不用担心项目因你犯了错而遭到破坏。对大型项目来说，这显得尤其重要，但对于较小的项目，哪怕是只包含一个文件的程序，这也大有裨益。

在这个附录中，你将学习如何安装Git，以及如何使用它来对当前开发的程序进行版本控制。Git是当前最流行的版本控制软件，它包含很多高级工具，可帮助团队协作开发大型项目，但其最基本的功能也非常适合独立开发人员使用。Git通过跟踪对项目中每个文件的修改来实现版本控制，如果你犯了错，只需恢复到保存的前一个状态即可。

D.1　安装Git

Git可在所有操作系统上运行，但其安装方法因操作系统而异。接下来的几节详细说明了如何在各种操作系统中安装它。

D.1.1　在Linux系统中安装Git

要在Linux系统中安装Git，请执行如下命令：

$ sudo apt-get install git

这就成了。你现在可以在项目中使用Git了。

D.1.2　在OS X系统中安装Git

你的OS X系统可能已经安装了Git，因此请尝试执行命令git --version。如果你在输出中看到了具体的版本号，说明你的系统安装了Git；如果你看到一条消息，提示你安装或升级Git，只需按屏幕上的说明做即可。

你也可以访问https://git-scm.com/，单击链接Downloads，再单击适合你所用系统的安装程序。

D.1.3　在Windows系统中安装Git

要在Windows系统中安装Git，请访问http://msysgit.github.io/，并单击Download。

D.1.4　配置Git

Git跟踪谁修改了项目，哪怕参与项目开发的人只有一个。为此，Git需要知道你的用户名和电子邮件地址。你必须提供用户名，但可以使用虚构的电子邮件地址：

$ git config --global user.name "username"
$ git config --global user.email "username@example.com"

如果你忘记了这一步，在你首次提交时，Git将提示你提供这些信息。

D.2　创建项目

我们来创建一个要进行版本控制的项目。在你的系统中创建一个文件夹，并将其命名为git_practice。在这个文件夹中，创建一个简单的Python程序：

hello_world.py

print("Hello Git world!")

我们将使用这个程序来探索Git的基本功能。

D.3　忽略文件

扩展名为.pyc的文件是根据.py文件自动生成的，因此我们无需让Git跟踪它们。这些文件存储在目录__pycache__中。为让Git忽略这个目录，创建一个名为.gitignore的特殊文件（这个文件名以句点打头，且没有扩展名），并在其中添加下面一行内容：

.gitignore

__pycache__/

这让Git忽略目录__pycache__中的所有文件。使用文件.gitignore可避免项目混乱，开发起来更容易。

注意　如果你使用的是Python 2.7，请将这行内容改为*.pyc。Python 2.7不会创建目录__pycache__，它将每个.pyc文件都存储在相应.py文件所在的目录中。其中的星号让Git忽略所有扩展名为.pyc的文件。

你可能需要修改文本编辑器的设置，使其显示隐藏的文件，这样才能使用它来打开文件.gitignore。有些编辑器被设置成忽略名称以句点打头的文件。

D.4　初始化仓库

你创建了一个目录，其中包含一个Python文件和一个.gitignore文件，可以初始化一个Git仓库了。为此，打开一个终端窗口，切换到文件夹git_practice，并执行如下命令：

git_practice$ git init
Initialized empty Git repository in git_practice/.git/
git_practice$

输出表明Git在git_practice中初始化了一个空仓库。仓库是程序中被Git主动跟踪的一组文件。Git用来管理仓库的文件都存储在隐藏的.git/中，你根本不需要与这个目录打交道，但千万不要删除这个目录，否则将丢弃项目的所有历史记录。

D.5　检查状态

执行其他操作前，先来看一下项目的状态：

git_practice$ git status
# On branch master ❶
#
# Initial commit
#
# Untracked files: ❷
#   (use "git add ..." to include in what will be committed)
#
#   .gitignore
#   hello_world.py
#
nothing added to commit but untracked files present (use "git add" to track) ❸
git_practice$

在Git中，分支是项目的一个版本。从这里的输出可知，我们位于分支master上（见❶）。你每次查看项目的状态时，输出都将指出你位于分支master上。接下来的输出表明，我们将进行初始提交。提交是项目在特定时间点的快照。

Git指出了项目中未被跟踪的文件（见❷），因为我们还没有告诉它要跟踪哪些文件。接下来，我们被告知没有将任何东西添加到当前提交中，但我们可能需要将未跟踪的文件加入到仓库中（见❸）。

D.6　将文件加入到仓库中

下面将这两个文件加入到仓库中，并再次检查状态：

git_practice$ git add . ❶
git_practice$ git status ❷
# On branch master
#
# Initial commit
#
# Changes to be committed:
#   (use "git rm --cached ..." to unstage)
#
#   new file:   .gitignore ❸
#   new file:   hello_world.py
#
git_practice$

命令git add .将项目中未被跟踪的所有文件都加入到仓库中（见❶）。它不提交这些文件，而只是让Git开始关注它们。现在我们检查项目的状态时，发现Git找出了需要提交的一些修改（见❷）。标签new file意味着这些文件是新添加到仓库中的（见❸）。

D.7　执行提交

下面来执行第一次提交：

git_practice$ git commit -m "Started project." ❶
[master (root-commit) c03d2a3] Started project. ❷
 2 files changed, 1 insertion(+) ❸
 create mode 100644 .gitignore
 create mode 100644 hello_world.py
git_practice$ git status ❹
# On branch master
nothing to commit, working directory clean
git_practice$

我们执行命令git commit -m "message"（见❶）以拍摄项目的快照。标志-m让Git将接下来的消息（"Started project."）记录到项目的历史记录中。输出表明我们在分支master上（见❷），且有两个文件被修改了（见❸）。

现在我们检查状态时，发现我们在分支master上，且工作目录是干净的（见❹）。这是你每次提交项目的可行状态时都希望看到的消息。如果显示的消息不是这样的，请仔细阅读，很可能你在提交前忘记了添加文件。

D.8　查看提交历史

Git记录所有的项目提交。下面来看一下提交历史：

git_practice$ git log
commit a9d74d87f1aa3b8f5b2688cb586eac1a908cfc7f
Author: Eric Matthes
Date:   Mon Mar 16 07:23:32 2015 -0800

    Started project.
git_practice$

你每次提交时，Git都会生成一个包含40字符的独一无二的引用ID。它记录提交是谁执行的、提交的时间以及提交时指定的消息。并非在任何情况下你都需要所有这些信息，因此Git提供了一个选项，让你能够打印提交历史条目的更简单的版本：

git_practice$ git log --pretty=oneline
a9d74d87f1aa3b8f5b2688cb586eac1a908cfc7f Started project.
git_practice$

标志--pretty=oneline指定显示两项最重要的信息：提交的引用ID以及为提交记录的消息。

D.9　第二次提交

为展示版本控制的强大威力，我们需要对项目进行修改，并提交所做的修改。为此，我们在hello_world.py中再添加一行代码：

hello_world.py

print("Hello Git world!")
print("Hello everyone.")

如果我们现在查看项目的状态，将发现Git注意到了这个文件发生了变化：

git_practice$ git status
# On branch master ❶
# Changes not staged for commit:
#   (use "git add ..." to update what will be committed)
#   (use "git checkout -- ..." to discard changes in working directory)
#
#   modified:   hello_world.py ❷
#
no changes added to commit (use "git add" and/or "git commit -a") ❸
git_practice$

输出指出了我们当前所在的分支（见❶）、被修改了的文件的名称（见❷），还指出了所做的修改未提交（见❸）。下面来提交所做的修改，并再次查看状态：

git_practice$ git commit -am "Extended greeting." ❶
[master 08d4d5e] Extended greeting.
 1 file changed, 1 insertion(+)
git_practice$ git status ❷
# On branch master
nothing to commit, working directory clean
git_practice$ git log --pretty=oneline ❸
08d4d5e39cb906f6cff197bd48e9ab32203d7ed6 Extended greeting.
be017b7f06d390261dbc64ff593be6803fd2e3a1 Started project.
git_practice$

我们再次执行了提交，并在执行命令git commit时指定了标志-am（见❶）。标志-a让Git将仓库中所有修改了的文件都加入到当前提交中（如果你在两次提交之间创建了新文件，可再次执行命令git add .将这些新文件加入到仓库中）。标志-m让Git在提交历史中记录一条消息。

我们查看项目的状态时，发现工作目录也是干净的（见❷）。最后，我们发现提交历史中包含两个提交（见❸）。

D.10　撤销修改

下面来看看如何放弃所做的修改，恢复到前一个可行状态。为此，首先在hello_world.py中再添加一行代码：

hello_world.py

print("Hello Git world!")
print("Hello everyone.")

print("Oh no, I broke the project!")

保存并运行这个文件。

我们查看状态，发现Git注意到了所做的修改：

git_practice$ git status
# On branch master
# Changes not staged for commit:
#   (use "git add ..." to update what will be committed)
#   (use "git checkout -- ..." to discard changes in working directory)
#
#   modified:   hello_world.py ❶
#
no changes added to commit (use "git add" and/or "git commit -a")
git_practice$

Git注意到我们修改了hello_world.py（见❶）。我们可以提交所做的修改，但这次我们不提交所做的修改，而要恢复到最后一个提交（我们知道，那次提交时项目能够正常地运行）。为此，我们不对hello_world.py执行任何操作——不删除刚添加的代码行，也不使用文本编辑器的撤销功能，而在终端会话中执行如下命令：

git_practice$ git checkout .
git_practice$ git status
# On branch master
nothing to commit, working directory clean
git_practice$

命令git checkout让你能够恢复到以前的任何提交。命令git checkout .放弃自最后一次提交后所做的所有修改，将项目恢复到最后一次提交的状态。

如果我们返回到文本编辑器，将发现hello_world.py被修改成了下面这样：

print("Hello Git world!")
print("Hello everyone.")

就这个项目而言，恢复到前一个状态微不足道，但如果我们开发的是大型项目，其中数十个文件都被修改了，那么恢复到前一个状态，将撤销自最后一次提交后对这些文件所做的所有修改。这个功能很有用：实现新功能时，你可以根据需要做任意数量的修改，如果这些修改不可行，可撤销它们，而不会对项目有任何伤害。你无需记住做了哪些修改，因而不必手工撤销所做的修改，Git会替你完成所有这些工作。

注意　想要看到以前的版本，你可能需要在编辑器窗口中单击，以刷新文件。

D.11　检出以前的提交

你可以检出提交历史中的任何提交，而不仅仅是最后一次提交，为此可在命令git check末尾指定该提交的引用ID的前6个字符（而不是句点）。通过检出以前的提交，你可以对其进行审核，然后返回到最后一次提交，或者放弃最近所做的工作，并选择以前的提交：

git_practice$ git log --pretty=oneline
08d4d5e39cb906f6cff197bd48e9ab32203d7ed6 Extended greeting.
be017b7f06d390261dbc64ff593be6803fd2e3a1 Started project.
git_practice$ git checkout be017b
Note: checking out 'be017b'.

You are in 'detached HEAD' state. You can look around, make experimental ❶
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by performing another checkout.

If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -b with the checkout command again. Example:

  git checkout -b new_branch_name

HEAD is now at be017b7... Started project.
git_practice$

检出以前的提交后，你将离开分支master，并进入Git所说的分离头指针（detached HEAD）状态（见❶）。HEAD表示项目的当前状态，之所以说我们处于分离状态，是因为我们离开了一个命名分支（这里是master）。

要回到分支master，可检出它：

git_practice$ git checkout master
Previous HEAD position was be017b7... Started project.
Switched to branch 'master'
git_practice$

这让你回到分支master。除非你要使用Git的高级功能，否则在检出以前的提交后，最好不要对项目做任何修改。然而，如果参与项目开发的人只有你自己，而你又想放弃较近的所有提交，并恢复到以前的状态，也可以将项目重置到以前的提交。为此，可在处于分支master上的情况下，执行如下命令：

git_practice$ git status ❶
# On branch master
nothing to commit, working directory clean
git_practice$ git log --pretty=oneline ❷
08d4d5e39cb906f6cff197bd48e9ab32203d7ed6 Extended greeting.
be017b7f06d390261dbc64ff593be6803fd2e3a1 Started project.
git_practice$ git reset --hard be017b ❸
HEAD is now at be017b7 Started project.
git_practice$ git status ❹
# On branch master
nothing to commit, working directory clean
git_practice$ git log --pretty=oneline ❺
be017b7f06d390261dbc64ff593be6803fd2e3a1 Started project.
git_practice$

我们首先查看了状态，确认我们在分支master上（见❶）。查看提交历史时，我们看到了两个提交（见❷）。接下来，我们执行命令git reset --hard，并在其中指定了要永久地恢复到的提交的引用ID的前6个字符（见❸）。再次查看状态，发现我们在分支master上，且没有需要提交的修改（见❹）。再次查看提交历史时，发现我们处于要从它重新开始的提交中（见❺）。

D.12　删除仓库

有时候，仓库的历史记录被你搞乱了，而你又不知道如何恢复。在这种情况下，你首先应考虑使用附录C介绍的方法寻求帮助。如果无法恢复且参与项目开发的只有你一个人，可继续使用这些文件，但要将项目的历史记录删除——删除目录.git。这不会影响任何文件的当前状态，而只会删除所有的提交，因此你将无法检出项目的其他任何状态。

为此，可打开一个文件浏览器，并将目录.git删除，也可通过命令行完成这个任务。这样做后，你需要重新创建一个仓库，以重新对修改进行跟踪。下面演示了如何在终端会话中完成这个过程：

git_practice$ git status ❶
# On branch master
nothing to commit, working directory clean
git_practice$ rm -rf .git ❷
git_practice$ git status ❸
fatal: Not a git repository (or any of the parent directories): .git
git_practice$ git init ❹
Initialized empty Git repository in git_practice/.git/
git_practice$ git status ❺
# On branch master
#
# Initial commit
#
# Untracked files:
#   (use "git add ..." to include in what will be committed)
#
#   .gitignore
#   hello_world.py
#
nothing added to commit but untracked files present (use "git add" to track)
git_practice$ git add . ❻
git_practice$ git commit -m "Starting over."
[master (root-commit) 05f5e01] Starting over.
 2 files changed, 2 insertions(+)
 create mode 100644 .gitignore
 create mode 100644 hello_world.py
git_practice$ git status ❼
# On branch master
nothing to commit, working directory clean
git_practice$

我们首先查看了状态，发现工作目录是干净的（见❶）。接下来，我们使用命令rm -rf .git（在Windows系统中，应使用命令rmdir /s .git）删除了目录.git（见❷）。删除文件夹.git后，当我们再次查看状态时，被告知这不是一个Git仓库（见❸）。Git用来跟踪仓库的信息都存储在文件夹.git中，因此删除该文件夹也将删除整个仓库。

接下来，我们使用命令git init新建一个全新的仓库（见❹）。然后，我们查看状态，发现又回到了初始状态，等待着第一次提交（见❺）。我们将所有文件都加入仓库，并执行第一次提交（见❻）。然后，我们再次查看状态，发现我们在分支master上，且没有任何未提交的修改（见❼）。

需要经过一定的练习才能学会使用版本控制，但一旦开始使用，你就再也离不开它。

后记

祝贺你！你已学习了Python基本知识，并利用这些知识创建了一些有意义的项目：创建了一款游戏，对一些数据进行了可视化，还创建了一个Web应用程序。现在，可以通过众多不同的方式进一步提高编程技能了。

首先，你应该根据自己的兴趣继续开发有意义的项目。当你通过编程来解决重要的相关问题时，编程将更具吸引力，而现在你具备了开发各种项目所需的技能。你可以开发自己的游戏，也可以开发模仿经典街机游戏的游戏。你可能想研究一些对你来说很重要的数据，并通过可视化将其中有趣的规律和关系展示出来。你可以创建自己的Web应用程序，也可尝试模拟自己喜欢的应用程序。

只要有机会就向别人发出邀请，让他们尝试使用你编写的程序。如果你编写了游戏，邀请别人来玩一玩；如果你创建了图表，向别人展示展示，看看他们能否看明白；如果你创建了Web应用程序，将其部署到在线服务器，并邀请别人尝试使用它。听听用户怎么说，并尝试根据他们的反馈对项目进行改进，这样你就能成为更优秀的程序员。

自己动手开发项目时，肯定会遇到棘手乃至自己无法解决的问题。想办法寻求帮助，在Python社区找到适合自己的位置。加入当地的Python用户组，或者到一些在线Python社区去逛逛。另外，考虑参加附近举办的Python开发者大会（PyCon）。

你应尽力在开发自己感兴趣的项目和提高Python技能之间取得平衡。网上有很多Python学习资料，市面上还有大量针对中级程序员编写的Python图书。现在你掌握了基本知识，并知道了如何将学到的技能付诸应用，因此这些资料中很多都是你能看懂的。通过阅读教程和图书积累更多的知识，加深你对编程和Python的认识。深入学习Python后再去开发项目时，你将能够更高效地解决更多的问题。

祝贺你在学习Python的道路上走了这么远，愿你在以后的学习中有好运相伴！

看完了

如果您对本书内容有疑问，可发邮件至contact@turingbook.com，会有编辑或作译者协助答疑。也可访问图灵社区，参与本书讨论。

如果是有关电子书的建议或问题，请联系专用客服邮箱：ebook@turingbook.com。

在这里可以找到我们：

微博 @图灵教育 : 好书、活动每日播报

微博 @图灵社区 : 电子书和好文章的消息

微博 @图灵新知 : 图灵教育的科普小组

微信 图灵访谈 : ituring_interview，讲述码农精彩人生

微信 图灵教育 : turingbooks 　　

091507240605ToBeReplacedWithUserId

版权信息

书名：Python机器学习经典实例

作者：[美] Prateek Joshi

译者：陶俊杰　陈小莉

ISBN：978-7-115-46527-6

本书由北京图灵文化发展有限公司发行数字版。版权所有，侵权必究。

您购买的图灵电子书仅供您个人使用，未经授权，不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟，与我们共同保护知识产权。

如果购买者有侵权行为，我们可能对该用户实施包括但不限于关闭该帐号等维权措施，并可能追究法律责任。

091507240605ToBeReplacedWithUserId

版权声明

译者序

前言

本书内容

阅读背景

读者对象

内容组织

准备工作

详细步骤

工作原理

更多内容

另请参阅

排版约定

读者反馈

客户支持

下载示例代码

下载本书的彩色图片

勘误

盗版

问题

电子书

第 1 章　监督学习

1.1　简介

1.2　数据预处理技术

1.2.1　准备工作

1.2.2　详细步骤

1.3　标记编码方法

详细步骤

1.4　创建线性回归器

1.4.1　准备工作

1.4.2　详细步骤

1.5　计算回归准确性

1.5.1　准备工作

1.5.2　详细步骤

1.6　保存模型数据

详细步骤

1.7　创建岭回归器

1.7.1　准备工作

1.7.2　详细步骤

1.8　创建多项式回归器

1.8.1　准备工作

1.8.2　详细步骤

1.9　估算房屋价格

1.9.1　准备工作

1.9.2　详细步骤

1.10　计算特征的相对重要性

详细步骤

1.11　评估共享单车的需求分布

1.11.1　准备工作

1.11.2　详细步骤

1.11.3　更多内容

第 2 章　创建分类器

2.1　简介

2.2　建立简单分类器

2.2.1　详细步骤

2.2.2　更多内容

2.3　建立逻辑回归分类器

详细步骤

2.4　建立朴素贝叶斯分类器

详细步骤

2.5　将数据集分割成训练集和测试集

详细步骤

2.6　用交叉验证检验模型准确性

2.6.1　准备工作

2.6.2　详细步骤

2.7　混淆矩阵可视化

详细步骤

2.8　提取性能报告

详细步骤

2.9　根据汽车特征评估质量

2.9.1　准备工作

2.9.2　详细步骤

2.10　生成验证曲线

详细步骤

2.11　生成学习曲线

详细步骤

2.12　估算收入阶层

详细步骤

第 3 章　预测建模

3.1　简介

3.2　用SVM建立线性分类器

3.2.1　准备工作

3.2.2　详细步骤

3.3　用SVM建立非线性分类器

详细步骤

3.4　解决类型数量不平衡问题

详细步骤

3.5　提取置信度

详细步骤

3.6　寻找最优超参数

详细步骤

3.7　建立事件预测器

3.7.1　准备工作

3.7.2　详细步骤

3.8　估算交通流量

3.8.1　准备工作

3.8.2　详细步骤

第 4 章　无监督学习——聚类

4.1　简介

4.2　用k-means算法聚类数据

详细步骤

4.3　用矢量量化压缩图片

详细步骤

4.4　建立均值漂移聚类模型

详细步骤

4.5　用凝聚层次聚类进行数据分组

详细步骤

4.6　评价聚类算法的聚类效果

详细步骤

4.7　用DBSCAN算法自动估算集群数量

详细步骤

4.8　探索股票数据的模式

详细步骤

4.9　建立客户细分模型

详细步骤

第 5 章　构建推荐引擎

5.1　简介

5.2　为数据处理构建函数组合

详细步骤

5.3　构建机器学习流水线

5.3.1　详细步骤

5.3.2　工作原理

5.4　寻找最近邻

详细步骤

5.5　构建一个KNN分类器

5.5.1　详细步骤

5.5.2　工作原理

5.6　构建一个KNN回归器

5.6.1　详细步骤

5.6.2　工作原理

5.7　计算欧氏距离分数

详细步骤

5.8　计算皮尔逊相关系数

详细步骤

5.9　寻找数据集中的相似用户

详细步骤

5.10　生成电影推荐

详细步骤

第 6 章　分析文本数据

6.1　简介

6.2　用标记解析的方法预处理数据

详细步骤

6.3　提取文本数据的词干

6.3.1　详细步骤

6.3.2　工作原理

6.4　用词形还原的方法还原文本的基本形式

详细步骤

6.5　用分块的方法划分文本

详细步骤

6.6　创建词袋模型

6.6.1　详细步骤

6.6.2　工作原理

6.7　创建文本分类器

6.7.1　详细步骤

6.7.2　工作原理

6.8　识别性别

详细步骤

6.9　分析句子的情感

6.9.1　详细步骤

6.9.2　工作原理

6.10　用主题建模识别文本的模式

6.10.1　详细步骤

6.10.2　工作原理

第 7 章　语音识别

7.1　简介

7.2　读取和绘制音频数据

详细步骤

7.3　将音频信号转换为频域

详细步骤

7.4　自定义参数生成音频信号

详细步骤

7.5　合成音乐

详细步骤

7.6　提取频域特征

详细步骤

7.7　创建隐马尔科夫模型

详细步骤

7.8　创建一个语音识别器

详细步骤

第 8 章　解剖时间序列和时序数据

8.1　简介

8.2　将数据转换为时间序列格式

详细步骤

8.3　切分时间序列数据

详细步骤

8.4　操作时间序列数据

详细步骤

8.5　从时间序列数据中提取统计数字

详细步骤

8.6　针对序列数据创建隐马尔科夫模型

8.6.1　准备工作

8.6.2　详细步骤

8.7　针对序列文本数据创建条件随机场

8.7.1　准备工作

8.7.2　详细步骤

8.8　用隐马尔科夫模型分析股票市场数据

详细步骤

第 9 章　图像内容分析

9.1　简介

9.2　用OpenCV-Pyhon操作图像

详细步骤

9.3　检测边

详细步骤

9.4　直方图均衡化

详细步骤

9.5　检测棱角

详细步骤

9.6　检测SIFT特征点

详细步骤

9.7　创建Star特征检测器

详细步骤

9.8　利用视觉码本和向量量化创建特征

详细步骤

9.9　用极端随机森林训练图像分类器

详细步骤

9.10　创建一个对象识别器

详细步骤

第 10 章　人脸识别

10.1　简介

10.2　从网络摄像头采集和处理视频信息

详细步骤

10.3　用Haar级联创建一个人脸识别器

详细步骤

10.4　创建一个眼睛和鼻子检测器

详细步骤

10.5　做主成分分析

详细步骤

10.6　做核主成分分析

详细步骤

10.7　做盲源分离

详细步骤

10.8　用局部二值模式直方图创建一个人脸识别器

详细步骤

第 11 章　深度神经网络

11.1　简介

11.2　创建一个感知器

详细步骤

11.3　创建一个单层神经网络

详细步骤

11.4　创建一个深度神经网络

详细步骤

11.5　创建一个向量量化器

详细步骤

11.6　为序列数据分析创建一个递归神经网络

详细步骤

11.7　在光学字符识别数据库中将字符可视化

详细步骤

11.8　用神经网络创建一个光学字符识别器

详细步骤

第 12 章　可视化数据

12.1　简介

12.2　画3D散点图

详细步骤

12.3　画气泡图

详细步骤

12.4　画动态气泡图

详细步骤

12.5　画饼图

详细步骤

12.6　画日期格式的时间序列数据

详细步骤

12.7　画直方图

详细步骤

12.8　可视化热力图

详细步骤

12.9　动态信号的可视化模拟

详细步骤

版权声明

Copyright © 2016 Packt Publishing. First published in the English language under the title Python Machine Learning Cookbook.

Simplified Chinese-language edition copyright © 2017 by Packt Publishing. All rights reserved.

本书中文简体字版由Packt Publishing授权人民邮电出版社独家出版。未经出版者书面许可，不得以任何方式复制或抄袭本书内容。

版权所有，侵权必究。

译者序

有一天，忽然想到自己整天面对着52个英文字母、9个数字、32个符号1和一个空格，经常加班没有双休日，好傻。时间不断被各种噪声碎片化，完全就是毛姆在《月亮和六便士》里写的，“If you look on the ground in search of a sixpence, you don't look up, and so miss the moon”，整天低头刷手机，却不记得举头望明月。生活也愈发无序，感觉渐渐被掏空。薛定谔的《生命是什么》给我提了个醒，他在“以‘负熵’为生”（It Feeds On ‘negative Entropy’）一节指出：“要活着，唯一的办法就是从环境里不断地汲取负熵。”在介绍了熵的概念及其统计学意义之后，他紧接着在“从环境中引出‘有序’以维持组织”（Organization Maintained By Extracting ‘Order’From The Environment）一节进一步总结：“一个有机体使本身稳定在较高的有序水平上（等于熵的相当低的水平上）的办法，就是从环境中不断地吸取秩序。”这个秩序（负熵、k_log(1/_n)）可以是食物，也可以是知识，按主流叫法就是“正能量”（有些所谓正能量却碰巧是增加系统无序水平的正熵）。于是，我开始渐渐放弃那些让人沮丧的老梗，远离那些引发混乱的噪声，重新读书，试着翻译，学会去爱。这几年最大的收获就是明白了“隔行如隔山”的道理，试着循序渐进，教学相长，做力所能及之事，让编程变简单。

1见文末Python示例代码。

一般人都不喜欢编程，更不喜欢动手编程（时间消耗：编写 & 测试 40%、重构 40%、风格 & 文档 20%），却喜欢在心里、嘴上编程：“先这样，再那样，如果要XX，就YY，最后就可以ZZ了。”分分钟就可以说完几万行代码的项目，水还剩大半杯。一旦大期将近，即使要亲自动手Copy代码，也会觉得苦堪搬砖，键盘不是红与黑、屏幕不能左右推、小狗总是闹跑追，不断在数不清的理由中增加自己的熵。偶尔看编程书的目的也很明确，就是为了快速上手，找到答案。当然也是在Google、StackOverflow、GitHub网站上找不到答案之后，无可奈何之举。编程书把看着复杂的知识写得更复杂，虽然大多篇幅不输“飞雪连天射白鹿，笑书神侠倚碧鸳”等经典，且纲举目张、图文并茂，甚至有作者爱引经据典，却极少有令人拍案的惊奇之处。为什么同样是文以载道，编程书却不能像武侠小说一样简单具体，反而显得了无生趣，令人望而却步？虽然编程的目的就是用计算机系统解决问题，但是大多数问题的知识都在其他领域中，许多作者在介绍编程技巧时，又试图介绍一些并不熟悉的背景知识，显得生涩难懂，且增加了书的厚度。

有时我们真正需要的，就是能快刀斩乱麻的代码。（Talk is cheap, show me the code.）编程与研究数理化不同，没有任何假设、原命题、思维实验，并非科学；与舞剑、奏乐、炒菜相似，都是手艺，只要基础扎实，便结果立判。编程技巧也可以像剑谱、乐谱、食谱一般立竿见影，这本《Python机器学习经典实例》正是如此，直接上代码，照着做就行，不用纠结为什么。

机器学习是交叉学科，应用广泛，目前主流方法为统计机器学习。既然是以统计学为基础，那么就不只是计算机与数学专业的私房菜了，机器学习在自然科学、农业科学、医药科学、工程与技术科学、人文与社会科学等多种学科中均可应用。如果你遇到了回归、分类、预测、聚类、文本分析、语音识别、图像处理等经典问题，需要快速用Python解决，那么这本菜谱适合你。即使你对机器学习方法还一知半解，也不妨一试。毕竟是Python的机器学习，还能难到哪儿去呢？目前十分流行的Python机器学习库scikit-learn2是全书主角之一，功能全面，接口友好，许多经典的数据集和机器学习案例都来自Kaggle3。若有时间追根溯源，请研究周志华教授的《机器学习》西瓜书，周教授啃着西瓜把机器学习调侃得淋漓尽致，详细的参考文献尤为珍贵。但是想当作菜谱看，拿来就用，还是需要费一番功夫；若看书不过瘾，还有吴恩达（Andrew Ng）教授在Coursera上的机器学习公开课4，机器学习入门最佳视频教程，吴教授用的工具是Matlab的免费开源版本Octave5，你也可以用Python版6演示教学示例。

2scikit-learn网址：http://scikit-learn.org/stable/。

3Kaggle是一个2010年成立的数据建模和数据分析竞赛平台，全球数据科学家、统计学家、机器学习工程师的聚集地，上面有丰富的数据集，经典的机器学习基础教程，以及让人流口水的竞赛奖金，支持Python、R、Julia、SQLite，同时也支持jupyter notebook在线编程环境，2017年3月8日被谷歌收购。

4分免费版和付费版（购买结业证书），学习内容一样，https://zh.coursera.org/learn/machine-learning。

5Octave下载地址：https://www.gnu.org/software/octave/。

6GitHub项目：https://github.com/mstampfer/Coursera-Stanford-ML-Python。

学而时习之，不亦乐乎。学习编程技巧，解决实际问题，是一件快乐的事情。希望这本Python机器学习经典案例，可以成为你的负熵，帮你轻松化解那些陈年老梗。如果再努努力，也许陆汝钤院士在《机器学习》序言中提出的6个问题7，你也有答案了。

7陆院士的6个问题分别是：1. 机器学习早期的符号机器学习，如何在统计机器学习主流中发展；2. 统计机器学习算法中并不现实的“独立同分布”假设如何解决；3. 深度学习得益于硬件革命，是否会取代统计机器学习；4. 机器学习用的都是经典的概率统计、代数逻辑，而目前仅有倒向微分方程用于预测，微分几何的流形用于降维（流形学习，Manifold learning，科普见博文http://blog.pluskid.org/?p=533），只是数学领域的一角，其他现代数学理论是否可以参与其中；5. 机器学习方法仍不够严谨，例如目前流形学习直接将高维数据集假设成微分流形，需要进一步完善；6. 大数据与统计机器学习是如何互动的。

示例代码：

"""打印ASCII字母表、数字、标点符号"""

import string

for item in [string.ascii_letters,
             string.digits,
             string.punctuation]:
    print('{}\t{}'.format(len(item), item))

输出结果：

52 abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ
10 0123456789
32 !"#$%&'()*+,-./:;<=>?@[\]^_`{|}~

前言

在如今这个处处以数据驱动的世界中，机器学习正变得越来越大众化。它已经被广泛地应用于不同领域，如搜索引擎、机器人、无人驾驶汽车等。本书不仅可以帮你了解现实生活中机器学习的应用场景，而且通过有趣的菜谱式教程教你掌握处理具体问题的算法。

本书首先通过实用的案例介绍机器学习的基础知识，然后介绍一些稍微复杂的机器学习算法，例如支持向量机、极端随机森林、隐马尔可夫模型、条件随机场、深度神经网络，等等。本书是为想用机器学习算法开发应用程序的Python程序员准备的。它不仅适合Python初学者（当然，熟悉Python编程方法将有助于体验示例代码），而且也适合想要掌握机器学习技术的Python老手。

通过本书，你不仅可以学会如何做出合理的决策，为自己选择合适的算法类型，而且可以学会如何高效地实现算法以获得最佳学习效果。如果你在图像、文字、语音或其他形式的数据处理中遇到困难，书中处理这些数据的机器学习技术一定会对你有所帮助！

本书内容

第1章介绍各种回归分析的监督学习技术。我们将学习如何分析共享自行车的使用模式，以及如何预测房价。

第2章介绍各种数据分类的监督学习技术。我们将学习如何评估收入层级，以及如何通过特征评估一辆二手汽车的质量。

第3章论述支持向量机的预测建模技术。我们将学习如何使用这些技术预测建筑物里事件发生的概率，以及体育场周边道路的交通情况。

第4章阐述无监督学习算法，包括K-means聚类和均值漂移聚类。我们将学习如何将这些算法应用于股票市场数据和客户细分。

第5章介绍推荐引擎的相关算法。我们将学习如何应用这些算法实现协同滤波和电影推荐。

第6章阐述与文本数据分析相关的技术，包括分词、词干提取、词库模型等。我们将学习如何使用这些技术进行文本情感分析和主题建模。

第7章介绍与语音数据分析相关的算法。我们将学习如何建立语音识别系统。

第8章介绍分析时间序列和有序数据的相关技术，包括隐马尔可夫模型和条件随机场。我们将学习如何将这些技术应用到文本序列分析和股市预测中。

第9章介绍图像内容分析与物体识别方面的算法。我们将学习如何提取图像特征，以及建立物体识别系统。

第10章介绍在图像和视频中检测与识别面部的相关技术。我们将学习使用降维算法建立面部识别器。

第11章介绍建立深度神经网络所需的算法。我们将学习如何使用神经网络建立光学文字识别系统。

第12章介绍机器学习使用的数据可视化技术。我们将学习如何创建不同类型的图形和图表。

阅读背景

Python 2.x 和Python 3.x 的版本之争尚未平息1。一方面，我们坚信世界会向更好的版本不断进化，另一方面，许多开发者仍然喜欢使用Python 2.x 的版本。目前许多操作系统仍然内置Python 2.x。本书的重点是介绍Python机器学习，而非Python语言本身。另外，考虑到程序的兼容性，书中用到了一些尚未被迁移到Python 3.x 版本的程序库，因此，本书依然选择Python 2.x 的版本。我们会尽最大努力保持代码兼容各种Python版本，因为这样可以让你轻松地理解代码，并且很方便地将代码应用到不同场景中。

12020年之前应该不会终结。——译者注

读者对象

本书是为想用机器学习算法开发应用程序的Python程序员准备的。它适合Python初学者阅读，不过熟悉Python编程方法对体验示例代码大有裨益。

内容组织

在本书中，你会频繁地看到下面这些标题（准备工作、详细步骤、工作原理、更多内容、另请参阅）。

为了更好地呈现内容，本书采用以下组织形式。

准备工作

这部分首先介绍本节目标，然后介绍软件配置方法以及所需的准备工作。

详细步骤

这部分介绍具体的实践步骤。

工作原理

这部分通常是对前一部分内容的详细解释。

更多内容

这部分会补充介绍一些信息，帮助你更好地理解前面的内容。

另请参阅

这部分提供一些参考资料。

排版约定

在本书中，你会发现一些不同的文本样式。这里举例说明它们的含义。

嵌入代码、命令、选项、参数、函数、字段、属性、语句等，用等宽的代码字体显示：“这里，我们将25%的数据用于测试，可以通过test_size参数进行设置。”

代码块用如下格式：

import numpy as np
import matplotlib.pyplot as plt

import utilities

# Load input data
input_file = 'data_multivar.txt'
X, y = utilities.load_data(input_file)

命令行输入或输出用如下格式：

$ python object_recognizer.py --input-image imagefile.jpg --model-file
erf.pkl --codebook-file codebook.pkl

新术语和重要文字将采用黑体字。你在屏幕上看到的内容，包括对话框或菜单里的文本，都将这样显示：“如果你将数组改为(0, 0.2, 0, 0, 0)，那么Strawberry部分就会高亮显示。”

读者反馈

我们非常欢迎读者的反馈。如果你对本书有些想法，有什么喜欢或是不喜欢的，请反馈给我们，这将有助于我们出版充分满足读者需求的图书。

一般性反馈请发送电子邮件至feedback@packtpub.com，并在邮件主题中注明书名。

如果你在某个领域有专长，并有意编写一本书或是贡献一份力量，请参考我们的作者指南，地址为http://www.packtpub.com/authors。

客户支持

你现在已经是引以为傲的Packt读者了。为了能让你的购买物超所值，我们还为你准备了以下内容。

下载示例代码

你可以用你的账户从http://www.packtpub.com下载所有已购买Packt图书的示例代码文件。如果你是从其他途径购买的本书，可以访问http://www.packtpub.com/support并注册，我们将通过电子邮件把文件发送给你。

可以通过以下步骤下载示例代码文件：

(1) 用你的电子邮件和密码登录或注册我们的网站；

(2) 将鼠标移到网站上方的客户支持（SUPPORT）标签；

(3) 单击代码下载与勘误（Code Downloads & Errata）按钮；

(4) 在搜索框（Search）中输入书名；

(5) 选择你要下载代码文件的书；

(6) 从下拉菜单中选择你的购书途径；

(7) 单击代码下载（Code Download）按钮。

你也可以通过单击Packt网站上本书网页上的代码文件（Code Files）按钮来下载示例代码，该网页可以通过在搜索框（Search）中输入书名获得。以上操作的前提是你已经登录了Packt网站。

下载文件后，请确保用以下软件的最新版来解压文件：

WinRAR / 7-Zip for Windows ；

Zipeg / iZip / UnRarX for Mac ；

7-Zip / PeaZip for Linux 。

本书的代码包也可以在GitHub上获得，网址是https://github.com/PacktPublishing/Python-Machine-Learning-Cookbook。另外，我们在https://github.com/PacktPublishing上还有其他书的代码包和视频，请需要的读者自行下载。

下载本书的彩色图片

我们也为你提供了一份PDF文件，里面包含了书中的截屏和图表等彩色图片，彩色图片能帮助你更好地理解输出的变化。下载网址为https://www.packtpub.com/sites/default/files/downloads/PythonMachineLearningCookbook_ColorImages.pdf。

勘误

虽然我们已尽力确保本书内容正确，但出错仍旧在所难免。如果你在书中发现错误，不管是文本还是代码，希望能告知我们，我们将不胜感激。这样做，你可以使其他读者免受挫败，也可以帮助我们改进本书的后续版本。如果你发现任何错误，请访问http://www.packtpub.com/submit-errata，选择本书，单击勘误表提交表单（Errata Submission Form）的链接，并输入详细说明。2勘误一经核实，你提交的内容将被接受，此勘误会上传到本公司网站或添加到现有勘误表。

2中文版勘误可以到http://www.ituring.com.cn/book/1894查看和提交。——编者注

访问https://www.packtpub.com/books/content/support，在搜索框中输入书名，可以在勘误（Errata）部分查看已经提交的勘误信息。

盗版

任何媒体都会面临版权内容在互联网上的盗版问题，Packt也不例外。Packt非常重视版权保护。如果你发现我们的作品在互联网上被非法复制，不管以什么形式，都请立即为我们提供相关网址或网站名称，以便我们寻求补救。

请把可疑盗版材料的链接发到copyright@packtpub.com。

保护我们的作者，就是保护我们继续为你带来价值的能力，我们将不胜感激。

问题

如果你对本书内容存有疑问，不管是哪个方面的，都可以通过questions@packtpub.com联系我们，我们会尽最大努力解决。

电子书

扫描如下二维码，即可获得本书电子版。

第 1 章　监督学习

在这一章，我们将介绍以下主题：

数据预处理技术

标记编码方法

创建线性回归器（linear regressor）

计算回归准确性

保存模型数据

创建岭回归器（ridge regressor）

创建多项式回归器（polynomial regressor）

估算房屋价格

计算特征的相对重要性

评估共享单车的需求分布

1.1　简介

如果你熟悉机器学习的基础知识，那么肯定知道什么是监督学习。监督学习是指在有标记的样本（labeled samples）上建立机器学习的模型。例如，如果用尺寸、位置等不同参数建立一套模型来评估一栋房子的价格，那么首先需要创建一个数据库，然后为参数打上标记。我们需要告诉算法，什么样的参数（尺寸、位置）对应什么样的价格。有了这些带标记的数据，算法就可以学会如何根据输入的参数计算房价了。

无监督学习与刚才说的恰好相反，它面对的是没有标记的数据。假设需要把一些数据分成不同的组别，但是对分组的条件毫不知情，于是，无监督学习算法就会以最合理的方式将数据集分成确定数量的组别。我们将在后面章节介绍无监督学习。

建立书中的各种模型时，将使用许多Python程序包，像NumPy、SciPy、scikit-learn、matplotlib等。如果你使用Windows系统，推荐安装兼容SciPy关联程序包的Python发行版，网址为http://www.scipy.org/install.html，这些Python发行版里已经集成了常用的程序包。如果你使用Mac OS X或者Ubuntu系统，安装这些程序包就相当简单了。下面列出来程序包安装和使用文档的链接：

NumPy：http://docs.scipy.org/doc/numpy-1.10.1/user/install.html

SciPy：http://www.scipy.org/install.html

scikit-learn：http://scikit-learn.org/stable/install.html

matplotlib：http://matplotlib.org/1.4.2/users/installing.html

现在，请确保你的计算机已经安装了所有程序包。

1.2　数据预处理技术

在真实世界中，经常需要处理大量的原始数据，这些原始数据是机器学习算法无法理解的。为了让机器学习算法理解原始数据，需要对数据进行预处理。

1.2.1　准备工作

来看看Python是如何对数据进行预处理的。首先，用你最喜欢的文本编辑器打开一个扩展名为.py的文件，例如preprocessor.py。然后在文件里加入下面两行代码：

import numpy as np
from sklearn import preprocessing

我们只是加入了两个必要的程序包。接下来创建一些样本数据。向文件中添加下面这行代码：

data = np.array([[3,  -1.5,    2,  -5.4], [0,    4,    -0.3,  2.1], [1,    3.3,
-1.9, -4.3]])

现在就可以对数据进行预处理了。

1.2.2　详细步骤

数据可以通过许多技术进行预处理，接下来将介绍一些最常用的预处理技术。

均值移除（Mean removal）

通常我们会把每个特征的平均值移除，以保证特征均值为0（即标准化处理）。这样做可以消除特征彼此间的偏差（bias）。将下面几行代码加入之前打开的Python文件中：

data_standardized = preprocessing.scale(data)
print "\nMean =", data_standardized.mean(axis=0)
print "Std deviation =", data_standardized.std(axis=0)

现在来运行代码。打开命令行工具，然后输入以下命令：

$ python preprocessor.py

命令行工具中将显示以下结果：

Mean = [  5.55111512e-17  -1.11022302e-16  -7.40148683e-17  -7.40148683e-17]
Std deviation = [ 1.  1.  1.  1.]

你会发现特征均值几乎是0，而且标准差为1。

范围缩放（Scaling）

数据点中每个特征的数值范围可能变化很大，因此，有时将特征的数值范围缩放到合理的大小是非常重要的。在Python文件中加入下面几行代码，然后运行程序：

data_scaler = preprocessing.MinMaxScaler(feature_range=(0, 1))
data_scaled = data_scaler.fit_transform(data)
print "\nMin max scaled data =", data_scaled

范围缩放之后，所有数据点的特征数值都位于指定的数值范围内。输出结果如下所示：

Min max scaled data:
[[ 1.            0.            1.            0.        ]
 [ 0.            1.            0.41025641    1.        ]
 [ 0.33333333    0.87272727    0.            0.14666667]]

归一化（Normalization）

数据归一化用于需要对特征向量的值进行调整时，以保证每个特征向量的值都缩放到相同的数值范围。机器学习中最常用的归一化形式就是将特征向量调整为L1范数，使特征向量的数值之和为1。增加下面两行代码到前面的Python文件中：

data_normalized = preprocessing.normalize(data, norm='l1')
print "\nL1 normalized data =", data_normalized

执行Python文件，就可以看到下面的结果：

L1    normalized    data:
[[    0.25210084    -0.12605042    0.16806723    -0.45378151]
 [    0.             0.625        -0.046875       0.328125  ]
 [    0.0952381      0.31428571   -0.18095238    -0.40952381]]

这个方法经常用于确保数据点没有因为特征的基本性质而产生较大差异，即确保数据处于同一数量级，提高不同特征数据的可比性。

二值化（Binarization）

二值化用于将数值特征向量转换为布尔类型向量。增加下面两行代码到前面的Python文件中：

data_binarized = preprocessing.Binarizer(threshold=1.4).transform(data)
print "\nBinarized data =", data_binarized

再次执行Python文件，就可以看到下面的结果：

Binarized data:
[[    1.    0.    1.    0.]
 [    0.    1.    0.    1.]
 [    0.    1.    0.    0.]]

如果事先已经对数据有了一定的了解，就会发现使用这个技术的好处了。

独热编码

通常，需要处理的数值都是稀疏地、散乱地分布在空间中，然而，我们并不需要存储这些大数值，这时就需要使用独热编码（One-Hot Encoding）。可以把独热编码看作是一种收紧（tighten）特征向量的工具。它把特征向量的每个特征与特征的非重复总数相对应，通过one-of-k 的形式对每个值进行编码。特征向量的每个特征值都按照这种方式编码，这样可以更加有效地表示空间。例如，我们需要处理4维向量空间，当给一个特性向量的第n 个特征进行编码时，编码器会遍历每个特征向量的第n 个特征，然后进行非重复计数。如果非重复计数的值是K，那么就把这个特征转换为只有一个值是1其他值都是0的K 维向量。增加下面几行代码到前面的Python文件中：

encoder = preprocessing.OneHotEncoder()
encoder.fit([[0, 2, 1, 12], [1, 3, 5, 3], [2, 3, 2, 12], [1, 2, 4, 3]])
encoded_vector = encoder.transform([[2, 3, 5, 3]]).toarray()
print "\nEncoded vector =", encoded_vector

结果如下所示：

Encoded vector:
[[ 0.  0.  1.  0.  1.  0.  0.  0.  1.  1.  0.]]

在上面的示例中，观察一下每个特征向量的第三个特征，分别是1、5、2、4这4个不重复的值，也就是说独热编码向量的长度是4。如果你需要对5进行编码，那么向量就是[0, 1, 0, 0]。向量中只有一个值是1。第二个元素是1，对应的值是5。

1.3　标记编码方法

在监督学习中，经常需要处理各种各样的标记。这些标记可能是数字，也可能是单词。如果标记是数字，那么算法可以直接使用它们，但是，许多情况下，标记都需要以人们可理解的形式存在，因此，人们通常会用单词标记训练数据集。标记编码就是要把单词标记转换成数值形式，让算法懂得如何操作标记。接下来看看如何标记编码。

详细步骤

(1) 新建一个Python文件，然后导入preprocessing程序包：

from sklearn import preprocessing

(2) 这个程序包包含许多数据预处理需要的函数。定义一个标记编码器（label encoder），代码如下所示：

label_encoder = preprocessing.LabelEncoder()

(3) label_encoder对象知道如何理解单词标记。接下来创建一些标记：

input_classes = ['audi', 'ford', 'audi', 'toyota', 'ford', 'bmw']

(4) 现在就可以为这些标记编码了：

label_encoder.fit(input_classes)
print "\nClass mapping:"
for i, item in enumerate(label_encoder.classes_):
    print item, '-->', i

(5) 运行代码，命令行工具中显示下面的结果：

Class mapping:
audi --> 0
bmw --> 1
ford --> 2
toyota --> 3

(6) 就像前面结果显示的那样，单词被转换成从0开始的索引值。现在，如果你遇到一组标记，就可以非常轻松地转换它们了，如下所示：

labels = ['toyota', 'ford', 'audi']
encoded_labels = label_encoder.transform(labels)
print "\nLabels =", labels
print "Encoded labels =", list(encoded_labels)

命令行工具中将显示下面的结果：

Labels = ['toyota', 'ford', 'audi']
Encoded labels = [3, 2, 0]

(7) 这种方式比纯手工进行单词与数字的编码要简单许多。还可以通过数字反转回单词的功能检查结果的正确性：

encoded_labels = [2, 1, 0, 3, 1]
decoded_labels = label_encoder.inverse_transform(encoded_labels)
print "\nEncoded labels =", encoded_labels
print "Decoded labels =", list(decoded_labels)

结果如下所示：

Encoded labels = [2, 1, 0, 3, 1]
Decoded labels = ['ford', 'bmw', 'audi', 'toyota', 'bmw']

可以看到，映射结果是完全正确的。

1.4　创建线性回归器

回归是估计输入数据与连续值输出数据之间关系的过程。数据通常是实数形式的，我们的目标是估计满足输入到输出映射关系的基本函数。让我们从一个简单的示例开始。考虑下面的输入与输出映射关系：

1 → 2

3 → 6

4.3 → 8.6

7.1 → 14.2

如果要你估计输入与输出的关联关系，你可以通过模式匹配轻松地找到结果。我们发现输出结果一直是输入数据的两倍，因此输入与输出的转换公式就是这样：

f(x) = 2x

这是体现输入值与输出值关联关系的一个简单函数。但是，在真实世界中通常都不会这么简单，输入与输出的映射关系函数并不是一眼就可以看出来的。

1.4.1　准备工作

线性回归用输入变量的线性组合来估计基本函数。前面的示例就是一种单输入单输出变量的线性回归。

现在考虑如图1-1所示的情况。

图　1-1

线性回归的目标是提取输入变量与输出变量的关联线性模型，这就要求实际输出与线性方程预测的输出的残差平方和（sum of squares of differences）最小化。这种方法被称为普通最小二乘法（Ordinary Least Squares，OLS）。

你可能觉得用一条曲线对这些点进行拟合效果会更好，但是线性回归不允许这样做。线性回归的主要优点就是方程简单。如果你想用非线性回归，可能会得到更准确的模型，但是拟合速度会慢很多。线性回归模型就像前面那张图里显示的，用一条直线近似数据点的趋势。接下来看看如何用Python建立线性回归模型。

1.4.2　详细步骤

假设你已经创建了数据文件data_singlevar.txt，文件里用逗号分隔符分割字段，第一个字段是输入值，第二个字段是与逗号前面的输入值相对应的输出值。你可以用这个文件作为输入参数。

(1) 创建一个Python文件regressor.py，然后在里面增加下面几行代码：

import sys
import numpy as np
filename = sys.argv[1]
X = []
y = []
with open(filename, 'r') as f:
    for line in f.readlines():
        xt, yt = [float(i) for i in line.split(',')]
        X.append(xt)
        y.append(yt)

把输入数据加载到变量X和y，其中X是数据，y是标记。在代码的for循环体中，我们解析每行数据，用逗号分割字段。然后，把字段转化为浮点数，并分别保存到变量X和y中。

(2) 建立机器学习模型时，需要用一种方法来验证模型，检查模型是否达到一定的满意度（satisfactory level）。为了实现这个方法，把数据分成两组：训练数据集（training dataset）与测试数据集（testing dataset）。训练数据集用来建立模型，测试数据集用来验证模型对未知数据的学习效果。因此，先把数据分成训练数据集与测试数据集：

num_training = int(0.8 * len(X))
num_test = len(X) - num_training

# 训练数据
X_train = np.array(X[:num_training]).reshape((num_training,1))
y_train = np.array(y[:num_training])

# 测试数据
X_test = np.array(X[num_training:]).reshape((num_test,1))
y_test = np.array(y[num_training:])

这里用80%的数据作为训练数据集，其余20%的数据作为测试数据集。

(3) 现在已经准备好训练模型。接下来创建一个回归器对象，代码如下所示：

from sklearn import linear_model

# 创建线性回归对象
linear_regressor = linear_model.LinearRegression()

# 用训练数据集训练模型
linear_regressor.fit(X_train, y_train)

(4) 我们利用训练数据集训练了线性回归器。向fit方法提供输入数据即可训练模型。用下面的代码看看它如何拟合：

import matplotlib.pyplot as plt

y_train_pred = linear_regressor.predict(X_train)
plt.figure()
plt.scatter(X_train, y_train, color='green')
plt.plot(X_train, y_train_pred, color='black', linewidth=4)
plt.title('Training data')
plt.show()

(5) 在命令行工具中执行如下命令：

$ python regressor.py data_singlevar.txt

就会看到如图1-2所示的线性回归。

图　1-2

(6) 在前面的代码中，我们用训练的模型预测了训练数据的输出结果，但这并不能说明模型对未知的数据也适用，因为我们只是在训练数据上运行模型。这只能体现模型对训练数据的拟合效果。从图1-2中可以看到，模型训练的效果很好。

(7) 接下来用模型对测试数据集进行预测，然后画出来看看，代码如下所示：

y_test_pred = linear_regressor.predict(X_test)

plt.scatter(X_test, y_test, color='green')
plt.plot(X_test, y_test_pred, color='black', linewidth=4)
plt.title('Test data')
plt.show()

运行代码，可以看到如图1-3所示的线性回归。

图　1-3

1.5　计算回归准确性

现在已经建立了回归器，接下来最重要的就是如何评价回归器的拟合效果。在模型评价的相关内容中，用误差（error）表示实际值与模型预测值之间的差值。

1.5.1　准备工作

下面快速了解几个衡量回归器拟合效果的重要指标（metric）。回归器可以用许多不同的指标进行衡量，部分指标如下所示。

平均绝对误差（mean absolute error）：这是给定数据集的所有数据点的绝对误差平均值。

均方误差（mean squared error）：这是给定数据集的所有数据点的误差的平方的平均值。这是最流行的指标之一。

中位数绝对误差（median absolute error）：这是给定数据集的所有数据点的误差的中位数。这个指标的主要优点是可以消除异常值（outlier）的干扰。测试数据集中的单个坏点不会影响整个误差指标，均值误差指标会受到异常点的影响。

解释方差分（explained variance score）：这个分数用于衡量我们的模型对数据集波动的解释能力。如果得分1.0分，那么表明我们的模型是完美的。

R方得分（R2 score）：这个指标读作“R方”，是指确定性相关系数，用于衡量模型对未知样本预测的效果。最好的得分是1.0，值也可以是负数。

1.5.2　详细步骤

scikit-learn里面有一个模块，提供了计算所有指标的功能。重新打开一个Python文件，然后输入以下代码：

import sklearn.metrics as sm

print "Mean absolute error =", round(sm.mean_absolute_error(y_test, y_test_pred), 2)
print "Mean squared error =", round(sm.mean_squared_error(y_test, y_ test_pred), 2)
print "Median absolute error =", round(sm.median_absolute_error(y_ test, y_test_pred), 2)
print "Explained variance score =", round(sm.explained_variance_ score(y_test, y_test_pred), 2)
print "R2 score =", round(sm.r2_score(y_test, y_test_pred), 2)

每个指标都描述得面面俱到是非常乏味的，因此只选择一两个指标来评估我们的模型。通常的做法是尽量保证均方误差最低，而且解释方差分最高。

1.6　保存模型数据

模型训练结束之后，如果能够把模型保存成文件，那么下次再使用的时候，只要简单地加载就可以了。

详细步骤

用程序保存模型的具体操作步骤如下。

(1) 在Python文件regressor.py中加入以下代码：

import cPickle as pickle

output_model_file = 'saved_model.pkl'
with open(output_model_file, 'w') as f:
    pickle.dump(linear_regressor, f)

(2) 回归模型会保存在saved_model.pkl文件中。下面看看如何加载并使用它，代码如下所示：

with open(output_model_file, 'r') as f:
    model_linregr = pickle.load(f)

y_test_pred_new = model_linregr.predict(X_test)
print "\nNew mean absolute error =", round(sm.mean_absolute_ error(y_test, y_test_pred_new), 2)

(3) 这里只是把回归模型从Pickle文件加载到model_linregr变量中。你可以将打印结果与前面的结果进行对比，确认模型与之前的一样。

1.7　创建岭回归器

线性回归的主要问题是对异常值敏感。在真实世界的数据收集过程中，经常会遇到错误的度量结果。而线性回归使用的普通最小二乘法，其目标是使平方误差最小化。这时，由于异常值误差的绝对值很大，因此会引起问题，从而破坏整个模型。

1.7.1　准备工作

先看图1-4。

图　1-4

右下角的两个数据点明显是异常值，但是这个模型需要拟合所有的数据点，因此导致整个模型都错了。仅凭直觉观察，我们就会觉得如图1-5的拟合结果更好。

图　1-5

普通最小二乘法在建模时会考虑每个数据点的影响，因此，最终模型就会像图1-4显示的直线那样。显然，我们发现这个模型不是最优的。为了避免这个问题，我们引入正则化项的系数作为阈值来消除异常值的影响。这个方法被称为岭回归。

1.7.2　详细步骤

接下来看看如何用Python建立岭回归器。

(1) 你可以从data_multi_variable.txt文件中加载数据。这个文件的每一行都包含多个数值。除了最后一个数值外，前面的所有数值构成输入特征向量。

(2) 把下面的代码加入regressor.py文件中。我们用一些参数初始化岭回归器：

ridge_regressor = linear_model.Ridge(alpha=0.01, fit_ intercept=True, max_iter=10000)

(3) alpha参数控制回归器的复杂程度。当alpha趋于0时，岭回归器就是用普通最小二乘法的线性回归器。因此，如果你希望模型对异常值不那么敏感，就需要设置一个较大的alpha值。这里把alpha值设置为0.01。

(4) 下面让我们来训练岭回归器。

ridge_regressor.fit(X_train, y_train)
y_test_pred_ridge = ridge_regressor.predict(X_test)
print "Mean absolute error =", round(sm.mean_absolute_error
    (y_ test, y_test_pred_ridge), 2)
print "Mean squared error =", round(sm.mean_squared_error
    (y_test, y_test_pred_ridge), 2)
print "Median absolute error =", round(sm.median_absolute_error
    (y_ test, y_test_pred_ridge), 2)
print "Explain variance score =", round(sm.explained_variance_ score
    (y_test, y_test_pred_ridge), 2)
print "R2 score =", round(sm.r2_score(y_test, y_test_pred_ridge), 2)

运行代码检查误差指标。可以用同样的数据建立一个线性回归器，并与岭回归器的结果进行比较，看看把正则化引入回归模型之后的效果如何。

1.8　创建多项式回归器

线性回归模型有一个主要的局限性，那就是它只能把输入数据拟合成直线，而多项式回归模型通过拟合多项式方程来克服这类问题，从而提高模型的准确性。

1.8.1　准备工作

先看图1-6。

图　1-6

从图1-6中可以看到，数据点本身的模式中带有自然的曲线，而线性模型是不能捕捉到这一点的。再来看看多项式模型的效果，如图1-7所示。

图　1-7

图1-7中的虚线表示线性回归模型，实线表示多项式回归模型。这个模型的曲率是由多项式的次数决定的。随着模型曲率的增加，模型变得更准确。但是，增加曲率的同时也增加了模型的复杂性，因此拟合速度会变慢。当我们对模型的准确性的理想追求与计算能力限制的残酷现实发生冲突时，就需要综合考虑了。

1.8.2　详细步骤

(1) 将下面的代码加入Python文件regressor.py中：

from sklearn.preprocessing import PolynomialFeatures

polynomial = PolynomialFeatures(degree=3)

(2) 上一行将曲线的多项式的次数的初始值设置为3。下面用数据点来计算多项式的参数：

X_train_transformed = polynomial.fit_transform(X_train)

其中，X_train_transformed表示多项式形式的输入，与线性回归模型是一样大的。

(3) 接下来用文件中的第一个数据点来检查多项式模型是否能够准确预测：

datapoint = [0.39,2.78,7.11]
poly_datapoint = polynomial.fit_transform(datapoint)

poly_linear_model = linear_model.LinearRegression()
poly_linear_model.fit(X_train_transformed, y_train)
print "\nLinear regression:", linear_regressor.predict(datapoint) [0]
print "\nPolynomial regression:", poly_linear_model.predict(poly_datapoint)[0]

多项式回归模型计算变量数据点的值恰好就是输入数据文件中的第一行数据值。再用线性回归模型测试一下，唯一的差别就是展示数据的形式。运行代码，可以看到下面的结果：

Linear regression: -11.0587294983
Polynomial regression: -10.9480782122

可以发现，多项式回归模型的预测值更接近实际的输出值。如果想要数据更接近实际输出值，就需要增加多项式的次数。

(4) 将多项式的次数加到10看看结果：

polynomial = PolynomialFeatures(degree=10)

可以看到下面的结果：

Polynomial regression: -8.20472183853

现在，你可以发现预测值与实际的输出值非常地接近。

1.9　估算房屋价格

是时候用所学的知识来解决真实世界的问题了。让我们用这些原理来估算房屋价格。房屋估价是理解回归分析最经典的案例之一，通常是一个不错的切入点。它符合人们的直觉，而且与人们的生活息息相关，因此在用机器学习处理复杂事情之前，通过房屋估价可以更轻松地理解相关概念。我们将使用带AdaBoost算法的决策树回归器（decision tree regressor）来解决这个问题。

1.9.1　准备工作

决策树是一个树状模型，每个节点都做出一个决策，从而影响最终结果。叶子节点表示输出数值，分支表示根据输入特征做出的中间决策。AdaBoost算法是指自适应增强（adaptive boosting）算法，这是一种利用其他系统增强模型准确性的技术。这种技术是将不同版本的算法结果进行组合，用加权汇总的方式获得最终结果，被称为弱学习器（weak learners）。AdaBoost算法在每个阶段获取的信息都会反馈到模型中，这样学习器就可以在后一阶段重点训练难以分类的样本。这种学习方式可以增强系统的准确性。

首先使用AdaBoost算法对数据集进行回归拟合，再计算误差，然后根据误差评估结果，用同样的数据集重新拟合。可以把这些看作是回归器的调优过程，直到达到预期的准确性。假设你拥有一个包含影响房价的各种参数的数据集，我们的目标就是估计这些参数与房价的关系，这样就可以根据未知参数估计房价了。

1.9.2　详细步骤

(1) 创建一个新的Python文件housing.py，然后加入下面的代码：

import numpy as np
from sklearn.tree import DecisionTreeRegressor
from sklearn.ensemble import AdaBoostRegressor
from sklearn import datasets
from sklearn.metrics import mean_squared_error, explained_variance_score
from sklearn.utils import shuffle
import matplotlib.pyplot as plt

(2) 网上有一个标准房屋价格数据库，人们经常用它来研究机器学习。你可以在https://archive.ics.uci.edu/ml/datasets/Housing下载数据。不过scikit-learn提供了数据接口，可以直接通过下面的代码加载数据：

housing_data = datasets.load_boston()

每个数据点由影响房价的13个输入参数构成。你可以用housing_data.data获取输入的数据，用housing_data.target获取对应的房屋价格。

(3) 接下来把输入数据与输出结果分成不同的变量。我们可以通过shuffle函数把数据的顺序打乱：

X, y = shuffle(housing_data.data, housing_data.target, random_ state=7)

(4) 参数random_state用来控制如何打乱数据，让我们可以重新生成结果。接下来把数据分成训练数据集和测试数据集，其中80%的数据用于训练，剩余20%的数据用于测试：

num_training = int(0.8 * len(X))
X_train, y_train = X[:num_training], y[:num_training]
X_test, y_test = X[num_training:], y[num_training:]

(5) 现在已经可以拟合一个决策树回归模型了。选一个最大深度为4的决策树，这样可以限制决策树不变成任意深度：

dt_regressor = DecisionTreeRegressor(max_depth=4)
dt_regressor.fit(X_train, y_train)

(6) 再用带AdaBoost算法的决策树回归模型进行拟合：

ab_regressor =AdaBoostRegressor(DecisionTreeRegressor(max_depth=4), n_estimators=400, random_state=7)
ab_regressor.fit(X_train, y_train)

这样可以帮助我们对比训练效果，看看AdaBoost算法对决策树回归器的训练效果有多大改善。

(7) 接下来评价决策树回归器的训练效果：

y_pred_dt = dt_regressor.predict(X_test)
mse = mean_squared_error(y_test, y_pred_dt)
evs = explained_variance_score(y_test, y_pred_dt)
print "\n#### Decision Tree performance ####"
print "Mean squared error =", round(mse, 2)
print "Explained variance score =", round(evs, 2)

(8) 现在评价一下AdaBoost算法改善的效果：

y_pred_ab = ab_regressor.predict(X_test)
mse = mean_squared_error(y_test, y_pred_ab)
evs = explained_variance_score(y_test, y_pred_ab)
print "\n#### AdaBoost performance ####"
print "Mean squared error =", round(mse, 2)
print "Explained variance score =", round(evs, 2)

(9) 命令行工具显示的输出结果如下所示：

#### 决策树学习效果 ####
Mean squared error = 14.79
Explained variance score = 0.82

#### AdaBoost算法改善效果 ####
Mean squared error = 7.54
Explained variance score = 0.91

前面的结果表明，AdaBoost算法可以让误差更小，且解释方差分更接近1。

1.10　计算特征的相对重要性

所有特征都同等重要吗？在这个案例中，我们用了13个特征，它们对模型都有贡献。但是，有一个重要的问题出现了：如何判断哪个特征更加重要？显然，所有的特征对结果的贡献是不一样的。如果需要忽略一些特征，就需要知道哪些特征不太重要。scikit-learn里面有这样的功能。

详细步骤

(1) 画出特征的相对重要性，在housing.py文件中加入下面几行代码：

plot_feature_importances(dt_regressor.feature_importances_,
        'Decision Tree regressor', housing_data.feature_names)
plot_feature_importances(ab_regressor.feature_importances_,
        'AdaBoost regressor', housing_data.feature_names)

回归器对象有一个feature_importances_方法会告诉我们每个特征的相对重要性。

(2) 接下来需要定义plot_feature_importances来画出条形图：

def plot_feature_importances(feature_importances, title, feature_names):
    # 将重要性值标准化
    feature_importances = 100.0 * (feature_importances / max(feature_importances))

    # 将得分从高到低排序
    index_sorted = np.flipud(np.argsort(feature_importances))

    # 让X坐标轴上的标签居中显示
    pos = np.arange(index_sorted.shape[0]) + 0.5

    # 画条形图
    plt.figure()
    plt.bar(pos, feature_importances[index_sorted], align='center')
    plt.xticks(pos, feature_names[index_sorted])
    plt.ylabel('Relative Importance')
    plt.title(title)
    plt.show()

(3) 我们从feature_importances_方法里取值，然后把数值放大到0~100的范围内。运行前面的代码，可以看到两张图（不带AdaBoost算法与带AdaBoost算法两种模型）。仔细观察图1-8和图1-9，看看能从决策树回归器中获得什么。

图　1-8

(4) 从图1-8可以发现，不带AdaBoost算法的决策树回归器显示的最重要特征是RM。再看看带AdaBoost算法的决策树回归器的特征重要性排序条形图，如图1-9所示。

图　1-9

加入AdaBoost算法之后，房屋估价模型的最重要特征是LSTAT。在现实生活中，如果对这个数据集建立不同的回归器，就会发现最重要的特征是LSTAT，这足以体现AdaBoost算法对决策树回归器训练效果的改善。

1.11　评估共享单车的需求分布

本节将用一种新的回归方法解决共享单车的需求分布问题。我们采用随机森林回归器（random forest regressor）估计输出结果。随机森林是一个决策树集合，它基本上就是用一组由数据集的若干子集构建的决策树构成，再用决策树平均值改善整体学习效果。

1.11.1　准备工作

我们将使用bike_day.csv文件中的数据集，它可以在 https://archive.ics.uci.edu/ml/datasets/Bike+Sharing+Dataset 获取。这份数据集一共16列，前两列是序列号与日期，分析的时候可以不用；最后三列数据是不同类型的输出结果；最后一列是第十四列与第十五列的和，因此建立模型时可以不考虑第十四列与第十五列。

1.11.2　详细步骤

接下来看看Python如何解决这个问题。如果你下载了本书源代码，就可以看到bike_sharing.py文件里已经包含了完整代码。这里将介绍若干重要的部分。

(1) 首先导入一些新的程序包，如下：

import csv
from sklearn.ensemble import RandomForestRegressor
from housing import plot_feature_importances

(2) 我们需要处理CSV文件，因此加入了csv程序包来读取CSV文件。由于这是一个全新的数据集，因此需要自己定义一个数据集加载函数：

def load_dataset(filename):
    file_reader = csv.reader(open(filename, 'rb'), delimiter=',')
    X, y = [], []
    for row in file_reader:
        X.append(row[2:13])
        y.append(row[-1])

    # 提取特征名称
    feature_names = np.array(X[0])

    # 将第一行特征名称移除，仅保留数值
    return np.array(X[1:]).astype(np.float32), np.array(y[1:]).astype(np.float32), feature_names

在这个函数中，我们从CSV文件读取了所有数据。把数据显示在图形中时，特征名称非常有用。把特征名称数据从输入数值中分离出来，并作为函数返回值。

(3) 读取数据，并打乱数据顺序，让新数据与原来文件中数据排列的顺序没有关联性：

X, y, feature_names = load_dataset(sys.argv[1])
X, y = shuffle(X, y, random_state=7)

(4) 和之前的做法一样，需要将数据分成训练数据和测试数据。这一次，我们将90%的数据用于训练，剩余10%的数据用于测试：

num_training = int(0.9 * len(X))
X_train, y_train = X[:num_training], y[:num_training]
X_test, y_test = X[num_training:], y[num_training:]

(5) 下面开始训练回归器：

rf_regressor = RandomForestRegressor(n_estimators=1000, max_depth=10, min_samples_split=1)
rf_regressor.fit(X_train, y_train)

其中，参数n_estimators是指评估器（estimator）的数量，表示随机森林需要使用的决策树数量；参数max_depth是指每个决策树的最大深度；参数min_samples_split是指决策树分裂一个节点需要用到的最小数据样本量。

(6) 评价随机森林回归器的训练效果：

y_pred = rf_regressor.predict(X_test)
mse = mean_squared_error(y_test, y_pred)
evs = explained_variance_score(y_test, y_pred)
print "\n#### Random Forest regressor performance ####"
print "Mean squared error =", round(mse, 2)
print "Explained variance score =", round(evs, 2)

(7) 由于已经有画出特征重要性条形图的函数plot_feature_importances了，接下来直接调用它：

plot_feature_importances(rf_regressor.feature_importances_, 'Random Forest regressor', feature_names)

执行代码，可以看到如图1-10所示的图形。

图　1-10

看来温度（temp）是影响自行车租赁的最重要因素。

1.11.3　更多内容

把第十四列与第十五列数据加入数据集，看看结果有什么区别。在新的特征重要性条形图中，除了这两个特征外，其他特征都变成了0。这是由于输出结果可以通过简单地对第十四列与第十五列数据求和得出，因此算法不需要其他特征计算结果。在数据集加载函数load_dataset中，我们需要对for循环内的取值范围稍作调整：

X.append(row[2:15])

现在再画出特征重要性条形图，可以看到如图1-11所示的柱形图。

图　1-11

与预想的一样，从图中可以看出，只有这两个特征是重要的，这确实也符合常理，因为最终结果仅仅是这两个特征相加得到的。因此，这两个变量与输出结果有直接的关系，回归器也就认为它不需要其他特征来预测结果了。在消除数据集冗余变量方面，这是非常有用的工具。

还有一份按小时统计的自行车共享数据bike_hour.csv。我们需要用到第3~14列，因此先对数据集加载函数load_dataset做一点调整：

X.append(row[2:14])

运行代码，可以看到回归器的训练结果如下：

#### 随机森林学习效果 ####
Mean squared error = 2619.87
Explained variance score = 0.92

特征重要性条形图如图1-12所示。

图　1-12

图1-12中显示，最重要的特征是一天中的不同时点（hr），这也完全符合人们的直觉；其次重要的是温度，与我们之前分析的结果一致。

第 2 章　创建分类器

在这一章，我们将介绍以下主题：

建立简单分类器（simple classifier）

建立逻辑回归分类器（logistic regression classifier）

建立朴素贝叶斯分类器（Naïve Bayes classifier）

将数据集分割成训练集和测试集

用交叉验证（cross-validation）检验模型准确性

混淆矩阵（confusion matrix）可视化

提取性能报告

根据汽车特征评估质量

生成验证曲线（validation curves）

生成学习曲线（learning curves）

估算收入阶层（income bracket）

2.1　简介

在机器学习领域中，分类是指利用数据的特性将其分成若干类型的过程。分类与上一章介绍的回归不同，回归的输出结果是实数。监督学习分类器就是用带标记的训练数据建立一个模型，然后对未知数据进行分类。

分类器可以是实现分类功能的任意算法，最简单的分类器就是简单的数学函数。在真实世界中，分类器可以是非常复杂的形式。在学习过程中，可以看到二元（binary）分类器，将数据分成两类，也可以看到多元（multiclass）分类器，将数据分成两个以上的类型。解决分类问题的数据手段都倾向于解决二元分类问题，可以通过不同的形式对其进行扩展，进而解决多元分类问题。

分类器准确性的估计是机器学习领域的重要内容。我们需要学会如何使用现有的数据获取新的思路（机器学习模型），然后把模型应用到真实世界中。在这一章里，我们将看到许多类似的主题。

2.2　建立简单分类器

本节学习如何用训练数据建立一个简单分类器。

2.2.1　详细步骤

(1)使用simple_classifier.py文件作为参考。假设你已经和上一章一样导入了numpy和matplotlib.pyplot程序包，那么需要创建一些样本数据：

X = np.array([[3,1], [2,5], [1,8], [6,4], [5,2], [3,5], [4,7], [4,-1]])

(2) 为这些数据点分配一些标记：

y = [0, 1, 1, 0, 0, 1, 1, 0]

(3) 因为只有两个类，所以y列表包含0和1。一般情况下，如果你有N个类，那么y的取值范围就是从0到N-1。接下来按照类型标记把样本数据分成两类：

class_0 = np.array([X[i] for i in range(len(X)) if y[i]==0])
class_1 = np.array([X[i] for i in range(len(X)) if y[i]==1])

(4) 为了对数据有个直观的认识，把图像画出来，如下所示：

plt.figure()
plt.scatter(class_0[:,0], class_0[:,1], color='black', marker='s')
plt.scatter(class_1[:,0], class_1[:,1], color='black', marker='x')

这是一个散点图（scatterplot），用方块和叉表示两类数据。在前面的代码中，参数marker用来表示数据点的形状。用方块表示class_0的数据，用叉表示class_1的数据。运行代码，可以看到如图2-1所示的图形。

(5) 在之前的两行代码中，只是用变量X与y之间的映射关系创建了两个列表。如果要你直观地展示数据点的不同类型，在两类数据间画一条分割线，那么怎么实现呢？你只要用直线方程在两类数据之间画一条直线就可以了。下面看看如何实现：

line_x = range(10)
line_y = line_x

(6) 用数学公式y = x创建一条直线。代码如下所示：

plt.figure()
plt.scatter(class_0[:,0], class_0[:,1], color='black', marker='s')
plt.scatter(class_1[:,0], class_1[:,1], color='black', marker='x')
plt.plot(line_x, line_y, color='black', linewidth=3)
plt.show()

图　2-1

(7) 运行代码，可以看到如图2-2所示的图形。

图　2-2

2.2.2　更多内容

用以下规则建立了一个简单的分类器：如果输入点(a, b)的a大于或等于b，那么它属于类型class_0；反之，它属于class_1。如果对数据点逐个进行检查，你会发现每个数都是这样，这样你就建立了一个可以识别未知数据的线性分类器（linear classifier）。之所以称其为线性分类器，是因为分割线是一条直线。如果分割线是一条曲线，就是非线性分类器（nonlinear classifier）。

这样简单的分类器之所以可行，是因为数据点很少，可以直观地判断分割线。如果有几千个数据点呢？如何对分类过程进行一般化处理（generalize）呢？下一节将介绍这一主题。

2.3　建立逻辑回归分类器

虽然这里也出现了上一章介绍的回归这个词，但逻辑回归其实是一种分类方法。给定一组数据点，需要建立一个可以在类之间绘制线性边界的模型。逻辑回归就可以对训练数据派生的一组方程进行求解来提取边界。

详细步骤

(1) 下面看看用Python如何实现逻辑回归。我们使用logistic_regression.py文件作为参考。假设已经导入了需要使用的程序包，接下来创建一些带训练标记的样本数据：

import numpy as np
from sklearn import linear_model
import matplotlib.pyplot as plt

X = np.array([[4, 7], [3.5, 8], [3.1, 6.2], [0.5, 1], [1, 2],
[1.2, 1.9], [6, 2], [5.7, 1.5], [5.4, 2.2]])
y = np.array([0, 0, 0, 1, 1, 1, 2, 2, 2])

这里假设一共有3个类。

(2) 初始化一个逻辑回归分类器：

classifier = linear_model.LogisticRegression(solver='liblinear', C=100)

前面的函数有一些输入参数需要设置，但是最重要的两个参数是solver和C。参数solver用于设置求解系统方程的算法类型，参数C表示正则化强度，数值越小，表示正则化强度越高。

(3) 接下来训练分类器：

classifier.fit(X, y)

(4) 画出数据点和边界：

plot_classifier(classifier, X, y)

需要定义如下画图函数：

def plot_classifier(classifier, X, y):
    # 定义图形的取值范围
    x_min, x_max = min(X[:, 0]) - 1.0, max(X[:, 0]) + 1.0
    y_min, y_max = min(X[:, 1]) - 1.0, max(X[:, 1]) + 1.0

预测值表示我们在图形中想要使用的数值范围，通常是从最小值到最大值。我们增加了一些余量（buffer），例如上面代码中的1.0。

(5) 为了画出边界，还需要利用一组网格（grid）数据求出方程的值，然后把边界画出来。下面继续定义网格：

    # 设置网格数据的步长
    step_size = 0.01

    # 定义网格
    x_values, y_values = np.meshgrid(np.arange(x_min, x_max, step_size), np.arange(y_min, y_max, step_size))

变量x_values和y_values包含求解方程数值的网格点。

(6) 计算出分类器对所有数据点的分类结果：

    # 计算分类器输出结果
    mesh_output = classifier.predict(np.c_[x_values.ravel(), y_values.ravel()])

    # 数组维度变形
    mesh_output = mesh_output.reshape(x_values.shape)

(7) 用彩色区域画出各个类型的边界：

    # 用彩图画出分类结果
    plt.figure()

    # 选择配色方案
    plt.pcolormesh(x_values, y_values, mesh_output, cmap=plt.cm.gray)

这基本算是一个三维画图器，既可以画二维数据点，又可以用色彩清单（color scheme）表示不同区域的相关属性。你可以在http://matplotlib.org/examples/color/colormaps_reference.html 找到所有的色彩清单。

(8) 接下来再把训练数据点画在图上：

    plt.scatter(X[:, 0], X[:, 1], c=y, s=80, edgecolors='black', linewidth=1, cmap=plt.cm.Paired)

    # 设置图形的取值范围
    plt.xlim(x_values.min(), x_values.max())
    plt.ylim(y_values.min(), y_values.max())

    # 设置X轴与Y轴
    plt.xticks((np.arange(int(min(X[:, 0])-1), int(max(X[:, 0])+1), 1.0)))
    plt.yticks((np.arange(int(min(X[:, 1])-1), int(max(X[:, 1])+1), 1.0)))

    plt.show()

其中，plt.scatter把数据点画在二维图上。X[:, 0]表示0轴（X轴）的坐标值，X[:, 1]表示1轴（Y轴）的坐标值。c=y表示颜色的使用顺序。用目标标记映射cmap的颜色表。我们肯定希望不同的标记使用不同的颜色，因此，用y作为映射。坐标轴的取值范围由plt.xlim和plt.ylim确定。为了标记坐标轴的数值，需要使用plt.xticks和plt.yticks。在坐标轴上标出坐标值，就可以直观地看出数据点的位置。在前面的代码中，我们希望坐标轴的最大值与最小值之前的刻度是单位刻度，还希望这些刻度值是整数，因此用int()函数对最值取整。

(9) 运行代码，就可以看到如图2-3所示的输出结果。

图　2-3

(10) 下面看看参数C对模型的影响。参数C表示对分类错误（misclassification）的惩罚值（penalty）。如果把参数C设置为1.0，会得到如图2-4所示的结果。

图　2-4

(11) 如果把参数C设置为10000，会得到如图2-5所示的结果。

图　2-5

随着参数C的不断增大，分类错误的惩罚值越高。因此，各个类型的边界更优。

2.4　建立朴素贝叶斯分类器

朴素贝叶斯分类器是用贝叶斯定理进行建模的监督学习分类器。下面看看如何建立一个朴素贝叶斯分类器。

详细步骤

(1) 我们使用naive_bayes.py文件作为参考。首先导入两个程序包：

from sklearn.naive_bayes import GaussianNB
from logistic_regression import plot_classifier

(2) 下载的示例代码中有一个data_multivar.txt文件，里面包含了将要使用的数据，每一行数据都是由逗号分隔符分割的数值。从文件中加载数据：

input_file = 'data_multivar.txt'

X = []
y = []
with open(input_file, 'r') as f:
    for line in f.readlines():
        data = [float(x) for x in line.split(',')]
        X.append(data[:-1])
        y.append(data[-1])

X = np.array(X)
y = np.array(y)

我们已经把输入数据和标记分别加载到变量X和y中了。

(3) 下面建立一个朴素贝叶斯分类器：

classifier_gaussiannb = GaussianNB()
classifier_gaussiannb.fit(X, y)
y_pred = classifier_gaussiannb.predict(X)

GaussianNB函数指定了正态分布朴素贝叶斯模型（Gaussian Naive Bayes model）。

(4) 接下来计算分类器的准确性：

accuracy = 100.0 * (y == y_pred).sum() / X.shape[0]
print "Accuracy of the classifier =", round(accuracy, 2), "%"

(5) 画出数据点和边界：

plot_classifier(classifier_gaussiannb, X, y)

可以看到如图2-6所示的图形。

图　2-6

从图2-6中可以发现，这里的边界没有严格地区分所有数据点。在前面这个例子中，我们是对所有的数据进行训练。机器学习的一条最佳实践是用没有重叠（nonoverlapping）的数据进行训练和测试。理想情况下，需要一些尚未使用的数据进行测试，可以方便准确地评估模型在未知数据上的执行情况。scikit-learn有一个方法可以非常好地解决这个问题，我们将在下一节介绍它。

2.5　将数据集分割成训练集和测试集

本节一起来看看如何将数据合理地分割成训练数据集和测试数据集。

详细步骤

(1) 增加下面的代码片段到上一节的Python文件中：

from sklearn import cross_validation

X_train, X_test, y_train, y_test = cross_validation.train_test_split(X, y, test_size=0.25, random_state=5)

classifier_gaussiannb_new = GaussianNB()
classifier_gaussiannb_new.fit(X_train, y_train)

这里，我们把参数test_size设置成0.25，表示分配了25%的数据给测试数据集。剩下75%的数据将用于训练数据集。

(2) 用分类器对测试数据进行测试：

y_test_pred = classifier_gaussiannb_new.predict(X_test)

(3) 计算分类器的准确性：

accuracy = 100.0 * (y_test == y_test_pred).sum() / X_test.shape[0]
print "Accuracy of the classifier =", round(accuracy, 2), "%"

(4) 画出测试数据的数据点及其边界：

plot_classifier(classifier_gaussiannb_new, X_test, y_test)

(5) 可以看到如图2-7所示的图形。

图　2-7

2.6　用交叉验证检验模型准确性

交叉验证是机器学习的重要概念。在上一节中，我们把数据分成了训练数据集和测试数据集。然而，为了能够让模型更加稳定，还需要用数据集的不同子集进行反复的验证。如果只是对特定的子集进行微调，最终可能会过度拟合（overfitting）模型。过度拟合是指模型在已知数据集上拟合得超级好，但是一遇到未知数据就挂了。我们真正想要的，是让机器学习模型能够适用于未知数据。

2.6.1　准备工作

介绍如何实现交叉验证之前，先讨论一下性能指标。当处理机器学习模型时，通常关心3个指标：精度（precision）、召回率（recall）和F1得分（F1 score）。可以用参数评分标准（parameter scoring）获得各项指标的得分。精度是指被分类器正确分类的样本数量占分类器总分类样本数量的百分比（分类器分类结果中，有一些样本分错了）。召回率是指被应正确分类的样本数量占某分类总样本数量的百分比（有一些样本属于某分类，但分类器却没有分出来）。

假设数据集有100个样本，其中有82个样本是我们感兴趣的，现在想用分类器选出这82个样本。最终，分类器选出了73个样本，它认为都是我们感兴趣的。在这73个样本中，其实只有65个样本是我们感兴趣的，剩下的8个样本我们不感兴趣，是分类器分错了。可以如下方法计算分类器的精度：

分类正确的样本数量 = 65

总分类样本数量 = 73

精度 = 65 / 73 = 89.04%

召回率的计算过程如下：

数据集中我们感兴趣的样本数量 = 82

分类正确的样本数量 = 65

召回率 = 65 / 82 = 79.26%

一个给力的机器学习模型需要同时具备良好的精度和召回率。这两个指标是二律背反的，一个指标达到100%，那么另一个指标就会非常差！我们需要保持两个指标能够同时处于合理高度。为了量化两个指标的均衡性，引入了F1得分指标，是精度和召回率的合成指标，实际上是精度和召回率的调和均值（harmonic mean）：

F1 得分＝2×精度×召回率 / (精度＋召回率)

上面示例中F1得分的计算过程如下：

F1 得分＝2×0.89×0.79 / (0.89＋0.79)＝0.8370

2.6.2　详细步骤

(1) 下面看看如何实现交叉验证，并提取性能指标。首先计算精度：

num_validations = 5
accuracy = cross_validation.cross_val_score(classifier_gaussiannb,
        X, y,scoring='accuracy', cv=num_validations)
print "Accuracy: " + str(round(100*accuracy.mean(), 2)) + "%"

(2) 用前面的方程分别计算精度、召回率和F1得分：

f1 = cross_validation.cross_val_score(classifier_gaussiannb,
        X, y, scoring='f1_weighted', cv=num_validations)
print "F1: " + str(round(100*f1.mean(), 2)) + "%"

precision = cross_validation.cross_val_score(classifier_ gaussiannb,
        X, y, scoring='precision_weighted', cv=num_validations)
print "Precision: " + str(round(100*precision.mean(), 2)) + "%"

recall = cross_validation.cross_val_score(classifier_gaussiannb,
        X, y, scoring='recall_weighted', cv=num_validations)
print "Recall: " + str(round(100*recall.mean(), 2)) + "%"

2.7　混淆矩阵可视化

混淆矩阵（confusion matrix）是理解分类模型性能的数据表，它有助于我们理解如何把测试数据分成不同的类。当想对算法进行调优时，就需要在对算法做出改变之前了解数据的错误分类情况。有些分类效果比其他分类效果更差，混淆矩阵可以帮助我们理解这些问题。先看看如图2-8所示的混淆矩阵。

图　2-8

在图2-8中，我们可以看出不同类型的分类数据。理想情况下，我们希望矩阵非对角线元素都是0，这是最完美的分类结果。先看看class 0，一共52个样本属于class 0。如果对第一行数据求和，总数就是52。但是现在，只有45个样本被正确地预测出来，分类器说另外4个样本属于class 1，还有3个样本属于class 2。用同样的思路分析另外两行数据，有意思的是，class 1里面有11个样本被错误地预测成了class 0，占到了class 1总数的16%。这就是模型需要优化的切入点。

详细步骤

(1) 我们用confusion_matrix.py文件作为参考。首先看看如何从数据中提取混淆矩阵：

from sklearn.metrics import confusion_matrix
y_true = [1, 0, 0, 2, 1, 0, 3, 3, 3]
y_pred = [1, 1, 0, 2, 1, 0, 1, 3, 3]
confusion_mat = confusion_matrix(y_true, y_pred)
plot_confusion_matrix(confusion_mat)

这里用了一些样本数据，一共有4种类型，取值范围是0~3，也列出了预测的标记类型。用confusion_matrix方法提取混淆矩阵，然后把它画出来。

(2) 继续定义混淆矩阵的画图函数：

# 显示混淆矩阵
def plot_confusion_matrix(confusion_mat):
    plt.imshow(confusion_mat, interpolation='nearest', cmap=plt.cm.Paired)
    plt.title('Confusion matrix')
    plt.colorbar()
    tick_marks = np.arange(4)
    plt.xticks(tick_marks, tick_marks)
    plt.yticks(tick_marks, tick_marks)
    plt.ylabel('True label')
    plt.xlabel('Predicted label')
    plt.show()

这里用imshow函数画混淆矩阵，其他函数都非常简单，只使用相关函数设置了图形的标题、颜色栏、刻度和标签。参数tick_marks的取值范围是0~3，因为数据集中有4个标记类型。np.arange函数会生成一个numpy数组。

(3) 运行代码，可以看到如图2-9所示的图形。

图　2-9

从图2-9中可以看出，对角线的颜色很亮，我们希望它们越亮越好。黑色区域表示0。在非对角线的区域有一些灰色区域，表示分类错误的样本量。例如，当样本真实标记类型是0，而预测标记类型是1时，就像在第一行的第二格看到的那样。事实上，所有的错误分类都属于class-1，因为第二列有3个不为0的格子。这在图2-9中显示得一目了然。

2.8　提取性能报告

也可以直接用scikit-learn打印精度、召回率和F1得分。接下来看看如何实现。

详细步骤

(1) 在一个新的Python文件中加入下面的代码：

from sklearn.metrics import classification_report
y_true = [1, 0, 0, 2, 1, 0, 3, 3, 3]
y_pred = [1, 1, 0, 2, 1, 0, 1, 3, 3]
target_names = ['Class-0', 'Class-1', 'Class-2', 'Class-3']
print(classification_report(y_true, y_pred, target_names=target_names))

(2) 运行代码，可以在命令行工具中看到如图2-10所示的结果。

图　2-10

不需要单独计算各个指标，可以直接用这个函数从模型中提取所有统计值。

2.9　根据汽车特征评估质量

接下来看看如何用分类技术解决现实问题。我们将用一个包含汽车多种细节的数据集，例如车门数量、后备箱大小、维修成本等，来确定汽车的质量。分类的目的是把车辆的质量分成4种类型：不达标、达标、良好、优秀。

2.9.1　准备工作

你可以从https://archive.ics.uci.edu/ml/datasets/Car+Evaluation下载数据集。

你需要把数据集中的每个值看成是字符串。考虑数据集中的6个属性，其取值范围是这样的：

buying：取值范围是vhigh、high、med、low；

maint：取值范围是vhigh、high、med、low；

doors：取值范围是2、3、4、5等；

persons：取值范围是2、4等；

lug_boot：取值范围是small、med、big；

safety：取值范围是low、med、high。

考虑到每一行都包含字符串属性，需要假设所有特征都是字符串，并设置分类器。在上一章中，我们用随机森林建立过回归器，这里再用随机森林建立分类器。

2.9.2　详细步骤

(1) 参考car.py文件中的源代码。首先导入两个软件包：

from sklearn import preprocessing
from sklearn.ensemble import RandomForestClassifier

(2) 加载数据集：

input_file = 'path/to/dataset/car.data.txt'

# 读取数据
X = []
count = 0

with open(input_file, 'r') as f:
    for line in f.readlines():
        data = line[:-1].split(',')
        X.append(data)

X = np.array(X)

每一行都包含由逗号分隔的单词列表。因此，我们解析输入文件，对每一行进行分割，然后将该列表附加到主数据。我们忽略每一行最后一个字符，因为那是一个换行符。由于Python程序包只能处理数值数据，所以需要把这些属性转换成程序包可以理解的形式。

(3) 在上一章中，我们介绍过标记编码。下面可以用这个技术把字符串转换成数值：

# 将字符串转化为数值
label_encoder = []
X_encoded = np.empty(X.shape)
for i,item in enumerate(X[0]):
    label_encoder.append(preprocessing.LabelEncoder())
    X_encoded[:, i] = label_encoder[-1].fit_transform(X[:, i])

X = X_encoded[:, :-1].astype(int)
y = X_encoded[:, -1].astype(int)

由于每个属性可以取有限数量的数值，所以可以用标记编码器将它们转换成数字。我们需要为不同的属性使用不同的标记编码器，例如，lug_boot属性可以取3个不同的值，需要建立一个懂得给这3个属性编码的标记编码器。每一行的最后一个值是类，将它赋值给变量y。

(4) 接下来训练分类器：

# 建立随机森林分类器
params = {'n_estimators': 200, 'max_depth': 8, 'random_state': 7}
classifier = RandomForestClassifier(**params)
classifier.fit(X, y)

你可以改变n_estimators和max_depth参数的值，观察它们如何改变分类器的准确性。我们将用一个标准化的方法处理参数选择问题。

(5) 下面进行交叉验证：

# 交叉验证
from sklearn import cross_validation

accuracy = cross_validation.cross_val_score(classifier,
        X, y, scoring='accuracy', cv=3)
print "Accuracy of the classifier: " + str(round(100*accuracy. mean(), 2)) + "%"

一旦训练好分类器，我们就需要知道它是如何执行的。我们用三折交叉验证（three-fold cross-validation，把数据分3组，轮换着用其中两组数据验证分类器）来计算分类器的准确性。

(6) 建立分类器的主要目的就是要用它对孤立的和未知的数据进行分类。下面用分类器对一个单一数据点进行分类：

# 对单一数据示例进行编码测试
input_data = ['vhigh', 'vhigh', '2', '2', 'small', 'low']
input_data_encoded = [-1] * len(input_data)
for i,item in enumerate(input_data):
    input_data_encoded[i] = int(label_encoder[i].transform(input_data[i]))

input_data_encoded = np.array(input_data_encoded)

第一步是把数据转换成数值类型。需要使用之前训练分类器时使用的标记编码器，因为我们需要保持数据编码规则的前后一致。如果输入数据点里出现了未知数据，标记编码器就会出现异常，因为它不知道如何对这些数据进行编码。例如，如果你把列表中的第一个值vhigh改成abcd，那么标记编码器就不知道如何编码了，因为它不知道怎么处理这个字符串。这就像是错误检查，看看输入数据点是否有效。

(7) 现在可以预测出数据点的输出类型了：

# 预测并打印特定数据点的输出
output_class = classifier.predict(input_data_encoded)
print "Output class:", label_encoder[-1].inverse_transform(output_class)[0]

我们用predict方法估计输出类型。如果输出被编码的输出标记，那么它对我们没有任何意义。因此，用inverse_transform方法对标记进行解码，将它转换成原来的形式，然后打印输出类。

2.10　生成验证曲线

前面用随机森林建立了分类器，但是并不知道如何定义参数。本节来处理两个参数：n_estimators和max_depth参数。它们被称为超参数（hyperparameters），分类器的性能是由它们决定的。当改变超参数时，如果可以看到分类器性能的变化情况，那就再好不过了。这就是验证曲线的作用。这些曲线可以帮助理解每个超参数对训练得分的影响。基本上，我们只对感兴趣的超参数进行调整，其他参数可以保持不变。下面将通过可视化图片演示超参数的变化对训练得分的影响。

详细步骤

(1) 打开上一节的Python文件，加入以下代码：

# 验证曲线

from sklearn.learning_curve import validation_curve

classifier = RandomForestClassifier(max_depth=4, random_state=7)
parameter_grid = np.linspace(25, 200, 8).astype(int)
train_scores, validation_scores = validation_curve(classifier, X, y, "n_estimators", parameter_grid, cv=5)
print "\n##### VALIDATION CURVES #####"
print "\nParam: n_estimators\nTraining scores:\n", train_scores
print "\nParam: n_estimators\nValidation scores:\n", validation_ scores

在这个示例中，我们通过固定max_depth参数的值来定义分类器。我们想观察评估器数量对训练得分的影响，于是用parameter_grid定义了搜索空间。评估器数量会在25~200之间每隔8个数迭代一次，获得模型的训练得分和验证得分。

(2) 运行代码，可以在命令行工具中看到如图2-11所示的结果。

图　2-11

(3) 把数据画成图形：

# 画出曲线图
plt.figure()
plt.plot(parameter_grid, 100*np.average(train_scores, axis=1), color='black')
plt.title('Training curve')
plt.xlabel('Number of estimators')
plt.ylabel('Accuracy')
plt.show()

(4) 得到的图形如图2-12所示。

图　2-12

(5) 用类似的方法对max_depth参数进行验证：

classifier = RandomForestClassifier(n_estimators=20, random_ state=7)
parameter_grid = np.linspace(2, 10, 5).astype(int)
train_scores, valid_scores = validation_curve(classifier, X, y, "max_depth",
        parameter_grid, cv=5)
print "\nParam: max_depth\nTraining scores:\n", train_scores
print "\nParam: max_depth\nValidation scores:\n", validation_ scores

我们把n_estimators参数固定为20，看看max_depth参数变化对性能的影响。命令行工具的输出结果如图2-13所示。

(6) 把数据画成图形：

# 画出曲线图
plt.figure()
plt.plot(parameter_grid, 100*np.average(train_scores, axis=1), color='black')
plt.title('Validation curve')
plt.xlabel('Maximum depth of the tree')
plt.ylabel('Accuracy')
plt.show()

图　2-13

(7) 运行代码，可以看到如图2-14所示的图形。

图　2-14

2.11　生成学习曲线

学习曲线可以帮助我们理解训练数据集的大小对机器学习模型的影响。当遇到计算能力限制时，这一点非常有用。下面改变训练数据集的大小，把学习曲线画出来。

详细步骤

(1) 打开上一节的Python文件，加入以下代码：

# 学习曲线

from sklearn.learning_curve import learning_curve

classifier = RandomForestClassifier(random_state=7)

parameter_grid = np.array([200, 500, 800, 1100])
train_sizes, train_scores, validation_scores = learning_ curve(classifier,
        X, y, train_sizes=parameter_grid, cv=5)
print "\n##### LEARNING CURVES #####"
print "\nTraining scores:\n", train_scores
print "\nValidation scores:\n", validation_scores

我们想分别用200、500、800、1100的训练数据集的大小测试模型的性能指标。我们把learning_curve方法中的cv参数设置为5，就是用五折交叉验证。

(2) 运行代码，可以在命令行工具中看到如图2-15所示的结果。

图　2-15

(3) 把数据画成图形：

# 画出曲线图
plt.figure()
plt.plot(parameter_grid, 100*np.average(train_scores, axis=1), color='black')
plt.title('Learning curve')
plt.xlabel('Number of training samples')
plt.ylabel('Accuracy')
plt.show()

(4) 得到的图形如图2-16所示。

图　2-16

虽然训练数据集的规模越小，仿佛训练准确性越高，但是它们很容易导致过度拟合。如果选择较大规模的训练数据集，就会消耗更多的资源。因此，训练数据集的规模选择也是一个需要结合计算能力进行综合考虑的问题。

2.12　估算收入阶层

本节将根据14个属性建立分类器评估一个人的收入等级。可能的输出类型是“高于50K”和“低于或等于50K”。这个数据集稍微有点复杂，里面的每个数据点都是数字和字符串的混合体。数值数据是有价值的，在这种情况下，不能用标记编码器进行编码。需要设计一套既可以处理数值数据，也可以处理非数值数据的系统。我们将用美国人口普查收入数据集中的数据： https://archive.ics.uci.edu/ml/datasets/Census+Income。

详细步骤

(1) 我们将用income.py文件作为参考，用朴素贝叶斯分类器解决问题。首先导入两个软件包：

from sklearn import preprocessing
from sklearn.naive_bayes import GaussianNB

(2) 加载数据集：

input_file = 'path/to/adult.data.txt'

# 读取数据
X = []
y = []
count_lessthan50k = 0
count_morethan50k = 0
num_images_threshold = 10000

(3) 我们将使用数据集中的20 000个数据点——每种类型10 000个，保证初始类型没有偏差。在模型训练时，如果你的大部分数据点都属于一个类型，那么分类器就会倾向于这个类型。因此，最好使用每个类型数据点数量相等的数据进行训练：

with open(input_file, 'r') as f:
    for line in f.readlines():
        if '?' in line:
            continue

        data = line[:-1].split(', ')

        if data[-1] == '<=50K' and count_lessthan50k < num_images_threshold:
            X.append(data)
            count_lessthan50k = count_lessthan50k + 1

        elif data[-1] == '>50K' and count_morethan50k < num_images_threshold:
            X.append(data)
            count_morethan50k = count_morethan50k + 1

        if count_lessthan50k >= num_images_threshold and count_morethan50k >= num_images_threshold:
            break

X = np.array(X)

同样地，这也是一个带逗号分隔符的文件。我们还是像之前那样处理，把数据加载到变量X。

(4) 我们需要把字符串属性转换为数值数据，同时需要保留原有的数值数据：

# 将字符串转换为数值数据
label_encoder = []
X_encoded = np.empty(X.shape)
for i,item in enumerate(X[0]):
    if item.isdigit():
        X_encoded[:, i] = X[:, i]
    else:
        label_encoder.append(preprocessing.LabelEncoder())
        X_encoded[:, i] = label_encoder[-1].fit_transform(X[:, i])

X = X_encoded[:, :-1].astype(int)
y = X_encoded[:, -1].astype(int)

isdigit()函数帮助我们判断一个属性是不是数值数据。我们把字符串数据转换为数值数据，然后把所有的标记编码器保存在一个列表中，便于在后面处理未知数据时使用。

(5) 训练分类器：

# 建立分类器
classifier_gaussiannb = GaussianNB()
classifier_gaussiannb.fit(X, y)

(6) 把数据分割成训练数据集和测试数据集，方便后面获取性能指标：

# 交叉验证
from sklearn import cross_validation

X_train, X_test, y_train, y_test = cross_validation.train_test_split(X, y, test_size=0.25, random_state=5)
classifier_gaussiannb = GaussianNB()
classifier_gaussiannb.fit(X_train, y_train)
y_test_pred = classifier_gaussiannb.predict(X_test)

(7) 提取性能指标：

# 计算分类器的F1得分
f1 = cross_validation.cross_val_score(classifier_gaussiannb,
        X, y, scoring='f1_weighted', cv=5)
print "F1 score: " + str(round(100*f1.mean(), 2)) + "%"

(8) 接下来看看如何为单一数据点分类。我们需要把数据点转换成分类器可以理解的形式：

# 对单一数据示例进行编码测试
input_data = ['39', 'State-gov', '77516', 'Bachelors', '13', 'Never-married', 'Adm-clerical', 'Not-in-family', 'White', 'Male', '2174', '0', '40', 'United-States']
count = 0
input_data_encoded = [-1] * len(input_data)
for i,item in enumerate(input_data):
    if item.isdigit():
        input_data_encoded[i] = int(input_data[i])
    else:
        input_data_encoded[i] = int(label_encoder[count].transform(input_data[i]))
        count = count + 1

input_data_encoded = np.array(input_data_encoded)

(9) 这样就可以进行分类了：

# 预测并打印特定数据点的输出结果
output_class = classifier_gaussiannb.predict(input_data_encoded)
print label_encoder[-1].inverse_transform(output_class)[0]

和之前的分类案例一样，我们用predict方法获取输出类型，然后用inverse_transform对标记进行解码，将它转换成原来的形式，然后在命令行工具中打印出来。

第 3 章　预测建模

在这一章，我们将介绍以下主题：

用SVM（Support Vector Machines，支持向量机）建立线性分类器

用SVM建立非线性分类器

解决类型数量不平衡问题

提取置信度

寻找最优超参数

建立事件预测器

估算交通流量

3.1　简介

预测建模（Predictive modeling）可能是数据分析中最吸引人的领域之一。近几年，由于大数据在各个垂直领域的蓬勃发展，预测建模备受关注。在数据挖掘领域，预测建模常用来预测未来趋势。

预测建模是一种用来预测系统未来行为的分析技术，它由一群能够识别独立输入变量与反馈目标关联关系的算法构成。我们根据观测值创建一个数学模型，然后用这个模型去预测未来发生的事情。

在预测建模中，需要收集已知的响应数据来训练模型。一旦模型建成，就可以用一些指标来检验它，然后用它预测未来值。可以通过许多种不同的算法来创建预测模型。本章将利用SVM来建立线性模型与非线性模型。

预测模型是用若干可能对系统行为产生影响的特征构建的。例如，如果要预测天气情况，就需要用气温、大气压、降雨量和其他的气象数据。类似地，当处理其他系统问题时，也需要先判断哪些因素可能会影响系统的行为，然后在训练模型之前把这些因素加入特征中。

3.2　用SVM建立线性分类器

SVM是用来构建分类器和回归器的监督学习模型。SVM通过对数学方程组求解，可以找出两组数据之间的最佳分割边界。如果你对SVM不太了解，可以从下面几个不错的教程开始学习：

http://web.mit.edu/zoya/www/SVM.pdf

http://www.support-vector.net/icml-tutorial.pdf

http://www.svms.org/tutorials/Berwick2003.pdf

下面看看如何用SVM建立线性分类器。

3.2.1　准备工作

为了便于理解问题，先对数据进行可视化。我们将参考svm.py文件里已有的源代码。在建立SVM之前，先对数据进行直观的认识。我们将使用源代码文件夹里的data_multivar.txt文件。下面看看如何对数据进行可视化。首先创建一个Python文件，然后在文件中增加下面的代码：

import numpy as np
import matplotlib.pyplot as plt

import utilities

# 加载输入数据
input_file = 'data_multivar.txt'
X, y = utilities.load_data(input_file)

刚刚导入了需要的程序包，然后确定了输入文件的名称。接下来看看load_data()方法：

# 加载输入文件中的多变量数据
def load_data(input_file):
    X = []
    y = []
    with open(input_file, 'r') as f:
        for line in f.readlines():
            data = [float(x) for x in line.split(',')]
            X.append(data[:-1])
            y.append(data[-1])

    X = np.array(X)
    y = np.array(y)

    return X, y

需要将数据分成类，如下所示：

class_0 = np.array([X[i] for i in range(len(X)) if y[i]==0])
class_1 = np.array([X[i] for i in range(len(X)) if y[i]==1])

数据分成类之后，我们把它们画出来：

plt.figure()
plt.scatter(class_0[:,0], class_0[:,1], facecolors='black', edgecolors='black', marker='s')
plt.scatter(class_1[:,0], class_1[:,1], facecolors='None', edgecolors='black', marker='s')
plt.title('Input data')
plt.show()

运行代码，可以看到如图3-1所示的图形。

图　3-1

图3-1由两种类型的数据点构成——实心方块和空心方块。用机器学习的术语说就是，我们的数据由两个类型组成。我们的目标就是要建立一个可以将实心方块和空心方块分开的模型。

3.2.2　详细步骤

(1) 我们需要将数据集分割成训练数据集和测试数据集。在同样的Python文件中加入以下代码：

# 分割数据集并用SVM训练模型
from sklearn import cross_validation
from sklearn.svm import SVC

X_train, X_test, y_train, y_test = cross_validation.train_test_split(X, y, test_size=0.25, random_state=5)

(2) 用线性核函数（linear kernel）初始化一个SVM对象。如果你不了解核函数的概念，请参考http://www.eric-kim.net/eric-kim-net/posts/1/kernel_trick.html。在文件中加入以下代码：

params = {'kernel': 'linear'}
classifier = SVC(**params)

(3) 现在可以训练线性SVM分类器了：

classifier.fit(X_train, y_train)

(4) 现在可以看到分类器是如何执行的：

utilities.plot_classifier(classifier, X_train, y_train, 'Training dataset')
plt.show()

(5) 运行代码，可以看到如图3-2所示的图形。

图　3-2

plot_classifier函数和之前介绍的画图函数一样，只是额外增加了两点内容。你可以在utilities.py文件里查看所有细节。

(6) 接下来看看分类器对测试数据集的执行。在Python文件中增加下面的代码：

y_test_pred = classifier.predict(X_test)
utilities.plot_classifier(classifier, X_test, y_test, 'Test dataset')
plt.show()

(7) 运行代码，可以看到如图3-3所示的图形。

图　3-3

(8) 接下来计算训练数据集的准确性。在Python文件中增加下面的代码：

from sklearn.metrics import classification_report

target_names = ['Class-' + str(int(i)) for i in set(y)]
print "\n" + "#"*30
print "\nClassifier performance on training dataset\n"
print classification_report(y_train, classifier.predict(X_train), target_names=target_names)
print "#"*30 + "\n"

(9) 运行代码，可以在命令行工具中看到如图3-4所示的结果。

图　3-4

(10) 最后看看分类器为测试数据集生成的分类报告：

print "#"*30
print "\nClassification report on test dataset\n"
print classification_report(y_test, y_test_pred, target_names=target_names)
print "#"*30 + "\n"

(11) 运行代码，可以在命令行工具中看到如图3-5所示的结果。

图　3-5

从前面数据的可视化图中可以看出，实心方块完全是被空心方块包围着的，也就是说两种类型的数据不是线性可分的。我们无法画出一条可以分离两种类型数据点的完美直线，因此需要尝试使用非线性分类器来分离这两种数据。

3.3　用SVM建立非线性分类器

SVM为建立非线性分类器提供了许多选项，需要用不同的核函数建立非线性分类器。为了简单起见，考虑两种情况，当想要表示两种类型数据的曲线边界时，既可以用多项式函数，也可以用径向基函数（Radial Basis Function，RBF）。

详细步骤

(1) 对于第一种情况，可以用一个多项式核函数建立非线性分类器。在同样的Python文件中搜索下面的代码：

params = {'kernel': 'linear'}

将其替换成下面的代码：

params = {'kernel': 'poly', 'degree': 3}

这就表示我们用了一个三次多项式方程。如果增加方程的次数，就表示可以让曲线更加弯曲。但是，曲线越弯曲，意味着训练要花费的时间越长，因为计算强度更大。

(2) 运行代码，可以看到如图3-6所示的图形。

图　3-6

(3) 还可以在命令行工具中看到如图3-7所示的分类报告。

图　3-7

(4) 我们还可以用径向基函数建立非线性分类器。在同样的Python文件中搜索下面的代码：

params = {'kernel': 'poly', 'degree': 3}

将其替换成：

params = {'kernel': 'rbf'}

(5) 运行代码，可以看到如图3-8所示的图形。

图　3-8

(6) 还可以在命令行工具中看到如图3-9所示的分类报告。

图　3-9

3.4　解决类型数量不平衡问题

到目前为止，我们处理的问题都是所有类型的数据点数量比较接近的情况，但是在真实世界中，我们不可能总能获取到这么均衡的数据集。有时，某一个类型的数据点数量可能比其他类型多很多，在这种条件下训练的分类器就会有偏差。边界线不会反映数据的真实特性，因为两种类型的数据点数量差别太大。因此，需要慎重考虑这种差异性，并想办法调和，才能保证分类器是不偏不倚的。

详细步骤

(1) 先加载数据：

input_file = 'data_multivar_imbalance.txt'
X, y = utilities.load_data(input_file)

(2) 接下来可视化这些数据。数据可视化的代码和前面章节中的可视化代码完全相同，可以参考svm_imbalance.py文件中的源代码。运行代码，可以看到如图3-10所示的图形。

图　3-10

(3) 下面用线性核函数建立一个SVM分类器，代码和前面章节中的代码完全相同。运行代码，可以看到如图3-11所示的图形。

图　3-11

(4) 你可能会奇怪为什么没有边界线了。其实是因为分类器不能区分两种类型，导致Class-0的准确性是0%。可以在命令行工具中看到如图3-12所示的分类报告。

图　3-12

从图3-12中也可以看出，Class-0的准确性是0%。

(5) 我们继续来解决这个问题。在Python文件中搜索下面的代码：

params = {'kernel': 'linear'}

将其替换成：

params = {'kernel': 'linear', 'class_weight': 'auto'}

参数class_weight的作用是统计不同类型数据点的数量，调整权重，让类型不平衡问题不影响分类效果。

(6) 运行代码，可以看到如图3-13所示的图形。

图　3-13

(7) 可以在命令行工具中看到如图3-14所示的分类报告。

图　3-14

从图3-14中可以看到，Class-0的准确性不是0%了。

3.5　提取置信度

如果能够获取对未知数据进行分类的置信水平，那将会非常有用。当一个新的数据点被分类为某一个已知类别时，我们可以训练SVM来计算出输出类型的置信度。

详细步骤

(1) 读者可以参考svm_confidence.py文件中的源代码，这里只介绍核心部分。首先，定义以下输入数据：

# 测量数据点与边界的距离
input_datapoints = np.array([[2, 1.5], [8, 9], [4.8, 5.2], [4, 4], [2.5, 7], [7.6, 2], [5.4, 5.9]])

(2) 测量数据点到边界的距离：

print "\nDistance from the boundary:"
for i in input_datapoints:
    print i, '-->', classifier.decision_function(i)[0]

(3) 可以在命令行工具中看到如图3-15所示的结果。

图　3-15

(4) 到边界的距离向我们提供了一些关于数据点的信息，但是它并不能准确地告诉我们分类器能够输出某个类型的置信度有多大。为了解决这个问题，需要用到概率输出（Platt scaling）。概率输出是一种将不同类别的距离度量转换成概率度量的方法。读者可以通过http://fastml.com/classifier-calibration-with-platts-scaling-and-isotonic-regression的教程学习概率输出的内容。下面继续用概率输出来训练SVM：

# 测量置信度
params = {'kernel': 'rbf', 'probability': True}
classifier = SVC(**params)

参数probability用于告诉SVM训练的时候要计算出概率。

(5) 训练分类器：

classifier.fit(X_train, y_train)

(6) 计算输入数据点的置信度：

print "\nConfidence measure:"
for i in input_datapoints:
    print i, '-->', classifier.predict_proba(i)[0]

predict_proba函数用于测量置信值。

(7) 可以在命令行工具中看到如图3-16所示的结果。

图　3-16

(8) 再看看数据点与边界的位置：

utilities.plot_classifier(classifier, input_datapoints, [0]*len(input_datapoints), 'Input datapoints', 'True')

(9) 运行代码，可以看到如图3-17所示的图形。

图　3-17

3.6　寻找最优超参数

就像上一章提到的，超参数对分类器的性能至关重要。本节我们看看如何为SVM获取最优的超参数。

详细步骤

(1) 读者可以参考perform_grid_search.py文件中的源代码，这里只介绍核心部分。这里将使用前面介绍过的交叉验证。加载完数据，并将数据分成训练数据集和测试数据集之后，向文件中加入下面的代码：

# 通过交叉检验设置参数
parameter_grid = [  {'kernel': ['linear'], 'C': [1, 10, 50, 600]},
                    {'kernel': ['poly'], 'degree': [2, 3]},
                    {'kernel': ['rbf'], 'gamma': [0.01, 0.001], 'C': [1, 10, 50, 600]},
                 ]

(2) 定义需要使用的指标：

metrics = ['precision', 'recall_weighted']

(3) 下面开始为每个指标搜索最优超参数：

for metric in metrics:
    print "\n#### Searching optimal hyperparameters for", metric

    classifier = grid_search.GridSearchCV(svm.SVC(C=1),
            parameter_grid, cv=5, scoring=metric)
    classifier.fit(X_train, y_train)

(4) 看看指标的得分：

print "\nScores across the parameter grid:"
for params, avg_score, _ in classifier.grid_scores_:
    print params, '-->', round(avg_score, 3)

(5) 打印出最好的参数集：

print "\nHighest scoring parameter set:", classifier.best_params_

(6) 运行代码，可以在命令行工具中看到如图3-18所示的结果。

图　3-18

(7) 从图3-18可以发现，模型搜索到了所有的最优超参数。在这个示例中，超参数是内核、C值和gamma的类型。模型将会尝试各种不同参数的组合来搜索最佳参数。接下来在测试数据集上做测试：

y_true, y_pred = y_test, classifier.predict(X_test)
print "\nFull performance report:\n"
print classification_report(y_true, y_pred)

(8) 运行代码，可以在命令行工具中看到如图3-19所示的结果。

图　3-19

3.7　建立事件预测器

接下来把所学的知识用于解决真实世界的问题。我们将建立一个SVM来预测一栋大楼进出楼门的人数。该数据集可以在 https://archive.ics.uci.edu/ml/datasets/CalIt2+Building+People+Counts 下载。我们将对数据集稍做调整，以便简化分析过程。调整过的数据集存放在building_event_binary.txt文件和building_event_multiclass.txt文件中。

3.7.1　准备工作

在建立模型之前，我们先看看数据格式。building_event_binary.txt文件的每一行都由6个逗号分割的字符串组成。这6个字符串的排序如下：

星期

日期

时间

离开大楼的人数

进入大楼的人数

是否有活动

前5个字符串组成输入数据，我们的任务是预测大楼是否举行活动。

building_event_multiclass.txt文件的每一行都由6个逗号分割的字符串组成。这个数据集比前面的更细，里面指明了大楼举行活动的类型。这6个字符串的排序如下：

星期

日期

时间

离开大楼的人数

进入大楼的人数

活动类型

前5个字符串是输入数据，我们的任务是预测大楼将举行什么活动。

3.7.2　详细步骤

(1) 读者可以参考event.py文件中的源代码。创建一个Python文件，然后加入下面的代码：

import numpy as np
from sklearn import preprocessing
from sklearn.svm import SVC

input_file = 'building_event_binary.txt'

# 读取数据
X = []
count = 0
with open(input_file, 'r') as f:
    for line in f.readlines():
        data = line[:-1].split(',')
        X.append([data[0]] + data[2:])

X = np.array(X)

我们把数据全部加载到变量X中。

(2) 下面将字符串格式转换成数值格式：

# 将字符串转换成数值
label_encoder = []
X_encoded = np.empty(X.shape)
for i,item in enumerate(X[0]):
    if item.isdigit():
        X_encoded[:, i] = X[:, i]
    else:
        label_encoder.append(preprocessing.LabelEncoder())
        X_encoded[:, i] = label_encoder[-1].fit_transform(X[:, i])

X = X_encoded[:, :-1].astype(int)
y = X_encoded[:, -1].astype(int)

(3) 用径向基函数、概率输出和类型平衡方法训练SVM分类器：

# 建立SVM模型
params = {'kernel': 'rbf', 'probability': True, 'class_weight': 'auto'}
classifier = SVC(**params)
classifier.fit(X, y)

(4) 现在就可以进行交叉验证了：

# 交叉验证
from sklearn import cross_validation

accuracy = cross_validation.cross_val_score(classifier,
        X, y, scoring='accuracy', cv=3)
print "Accuracy of the classifier: " + str(round(100*accuracy.mean(), 2)) + "%"

(5) 用一个新的数据点测试SVM：

# 对单一数据示例进行编码测试
input_data = ['Tuesday', '12:30:00','21','23']
input_data_encoded = [-1] * len(input_data)
count = 0
for i,item in enumerate(input_data):
    if item.isdigit():
        input_data_encoded[i] = int(input_data[i])
    else:
        input_data_encoded[i] = int(label_encoder[count].transform(input_data[i]))
        count = count + 1

input_data_encoded = np.array(input_data_encoded)

# 为特定数据点预测并打印输出结果
output_class = classifier.predict(input_data_encoded)
print "Output class:", label_encoder[-1].inverse_transform(output_class)[0]

(6) 运行代码，可以在命令行工具中看到如下结果：

Accuracy of the classifier: 89.88%
Output class: event

(7) 如果用building_event_multiclass.txt文件代替building_event_binary.txt文件作为输入数据文件，可以在命令行工具中看到以下结果：

Accuracy of the classifier: 65.9%
Output class: eventA

3.8　估算交通流量

根据相关数据预测交通流量是SVM的一个有趣应用。在上一节中我们将SVM作为一个分类器，下面将它作为一个回归器来估算交通流量。

3.8.1　准备工作

我们将要使用的数据集可以在https://archive.ics.uci.edu/ml/datasets/Dodgers+Loop+Sensor下载。这个数据集统计了洛杉矶道奇棒球队（Los Angeles Dodgers）进行主场比赛期间，体育场周边马路通过的车辆数量，存放在traffic_data.txt文件中。每一行都包含用逗号分隔的字符串格式，如下所示：

星期

时间

对手球队

棒球比赛是否正在继续

通行汽车的数量

3.8.2　详细步骤

(1) 下面看看如何建立SVM回归器。大家可以参考traffic.py文件中的源代码。创建一个Python文件，然后加入下面的代码：

# 用SVM分类器估算交通流量

import numpy as np
from sklearn import preprocessing
from sklearn.svm import SVR

input_file = 'traffic_data.txt'

# 读取数据
X = []
count = 0
with open(input_file, 'r') as f:
    for line in f.readlines():
        data = line[:-1].split(',')
        X.append(data)

X = np.array(X)

我们把数据全部加载到变量X中。

(2) 对数据进行编码：

# 将字符串转换成数值
label_encoder = []
X_encoded = np.empty(X.shape)
for i,item in enumerate(X[0]):
    if item.isdigit():
        X_encoded[:, i] = X[:, i]
    else:
        label_encoder.append(preprocessing.LabelEncoder())
        X_encoded[:, i] = label_encoder[-1].fit_transform(X[:, i])

X = X_encoded[:, :-1].astype(int)
y = X_encoded[:, -1].astype(int)

(3) 用径向基函数创建并训练SVM回归器：

# 建立SVR
params = {'kernel': 'rbf', 'C': 10.0, 'epsilon': 0.2}
regressor = SVR(**params)
regressor.fit(X, y)

在上面的代码中，参数C指定了对错误分类的惩罚，参数epsilon指定了不使用惩罚的限制。

(4) 用交叉验证来检查回归器的性能：

# 交叉验证
import sklearn.metrics as sm

y_pred = regressor.predict(X)
print "Mean absolute error =", round(sm.mean_absolute_error(y, y_pred), 2)

(5) 在一个数据点上进行测试：

# 对单一数据示例进行编码测试
input_data = ['Tuesday', '13:35', 'San Francisco', 'yes']
input_data_encoded = [-1] * len(input_data)
count = 0
for i,item in enumerate(input_data):
    if item.isdigit():
        input_data_encoded[i] = int(input_data[i])
    else:
        input_data_encoded[i] = int(label_encoder[count].transform(input_data[i]))
        count = count + 1

input_data_encoded = np.array(input_data_encoded)

# 为特定数据点预测并打印分类结果
print "Predicted traffic:", int(regressor.predict(input_data_encoded)[0])

(6) 运行代码，可以在命令行工具中看到以下结果：

Mean absolute error = 4.08
Predicted traffic: 29

第 4 章　无监督学习——聚类

在这一章，我们将介绍以下主题：

用k-means算法聚类数据

用向量量化（vector quantization）压缩图片

建立均值漂移（Mean Shift）聚类模型

用凝聚层次聚类（agglomerative clustering）进行数据分组

评价聚类算法的聚类效果

用DBSCAN算法自动估算集群数量

探索股票数据的模式

建立客户细分模型

4.1　简介

无监督学习是一种对不含标记的数据建立模型的机器学习范式。到目前为止，我们处理的数据都带有某种形式的标记，也就是说，学习算法可以根据标记看到这些数据，并对数据进行分类。但是，在无监督学习的世界中，我们没有这样的条件了。当需要用一些相似性指标对数据集进行分组时，就会用到这些算法了。

最常见的无监督学习方法就是聚类，你一定对这个词耳熟能详。当需要把无标记的数据分成几种集群时，就要用它来分析。这些集群通常是根据某种相似度指标进行划分的，例如欧氏距离（Euclidean distance）。无监督学习广泛应用于各种领域，如数据挖掘、医学影像、股票市场分析、计算机视觉、市场细分等。

4.2　用k-means算法聚类数据

k-means算法是最流行的聚类算法之一。这个算法常常利用数据的不同属性将输入数据划分为k组。分组是使用最优化的技术实现的，即让各组内的数据点与该组中心点的距离平方和最小化。如果你对k-means算法不太了解，可以在http://www.onmyphd.com/?p=k-means.clustering&ckattempt=1上学习。

详细步骤

(1) 本节的完整代码已经放在kmeans.py文件中。我们先创建一个新的Python文件，然后导入下面的程序包：

import numpy as np
import matplotlib.pyplot as plt
from sklearn import metrics
from sklearn.cluster import KMeans

import utilities

(2) 加载输入数据，然后定义集群的数量。我们将使用data_multivar.txt 数据文件：

data = utilities.load_data('data_multivar.txt')
num_clusters = 4

(3) 我们需要看看输入数据是什么样子的。继续向Python文件中加入下面的代码：

plt.figure()
plt.scatter(data[:,0], data[:,1], marker='o',
        facecolors='none', edgecolors='k', s=30)
x_min, x_max = min(data[:, 0]) - 1, max(data[:, 0]) + 1
y_min, y_max = min(data[:, 1]) - 1, max(data[:, 1]) + 1
plt.title('Input data')
plt.xlim(x_min, x_max)
plt.ylim(y_min, y_max)
plt.xticks(())
plt.yticks(())

运行代码，可以看到如图4-1所示的图形。

图　4-1

(4) 现在可以训练模型了。先初始化一个k-means对象，然后训练它：

kmeans = KMeans(init='k-means++', n_clusters=num_clusters, n_init=10)
kmeans.fit(data)

(5) 数据训练之后，我们需要可视化边界。继续向Python文件中加入下面的代码：

# 设置网格数据的步长
step_size = 0.01

# 画出边界
x_min, x_max = min(data[:, 0]) - 1, max(data[:, 0]) + 1
y_min, y_max = min(data[:, 1]) - 1, max(data[:, 1]) + 1
x_values, y_values = np.meshgrid(np.arange(x_min, x_max, step_size), np.arange(y_min, y_max, step_size))

# 预测网格中所有数据点的标记
predicted_labels = kmeans.predict(np.c_[x_values.ravel(), y_values.ravel()])

(6) 我们已经通过网格数据评估了模型。接下来把这些结果都画出来，看看边界线的布局：

# 画出结果
predicted_labels = predicted_labels.reshape(x_values.shape)
plt.figure()
plt.clf()
plt.imshow(predicted_labels, interpolation='nearest',
           extent=(x_values.min(), x_values.max(), y_values.min(), y_values.max()),
           cmap=plt.cm.Paired,
           aspect='auto', origin='lower')

plt.scatter(data[:,0], data[:,1], marker='o',
        facecolors='none', edgecolors='k', s=30)

(7) 把中心点画在图形上：

centroids = kmeans.cluster_centers_
plt.scatter(centroids[:,0], centroids[:,1], marker='o', s=200, linewidths=3,
        color='k', zorder=10, facecolors='black')
x_min, x_max = min(data[:, 0]) - 1, max(data[:, 0]) + 1
y_min, y_max = min(data[:, 1]) - 1, max(data[:, 1]) + 1
plt.title('Centoids and boundaries obtained using KMeans')
plt.xlim(x_min, x_max)
plt.ylim(y_min, y_max)
plt.xticks(())
plt.yticks(())
plt.show()

运行代码，可以看到如图4-2所示的图形。

图　4-2

4.3　用矢量量化压缩图片

k-means聚类的主要应用之一就是矢量量化。简单来说，矢量量化就是“四舍五入”（rounding off）的N维版本。在处理数字等一维数据时，会用四舍五入技术减少存储空间。例如，如果只需要精确到两位小数，那么不会直接存储23.73473572，而是用23.73来代替。如果不关心小数部分，甚至可以直接存储24，这取决于我们的真实需求。

同理，当把四舍五入这个概念推广到N维数据时，就变成了矢量量化。当然，矢量量化的细节很多，你可以在http://www.data-compression.com/vq.shtml里学习更多的内容。矢量量化被广泛应用于图片压缩，我们用比原始图像更少的比特数来存储每个像素，从而实现图像图片。

详细步骤

(1) 本例的完整代码已经放在vector_quantization.py文件中。下面看看它是如何实现的。首先需要导入一些程序库。创建一个新的Python文件，然后加入下面的代码：

import argparse

import numpy as np
from scipy import misc
from sklearn import cluster
import matplotlib.pyplot as plt

(2) 创建一个函数，用来解析输入参数。我们需要把图片和每个像素被压缩的比特数传进去作为输入参数：

def build_arg_parser():
    parser = argparse.ArgumentParser(description='Compress the input image \
            using clustering')
    parser.add_argument("--input-file", dest="input_file", required=True,
            help="Input image")
    parser.add_argument("--num-bits", dest="num_bits", required=False,
            type=int, help="Number of bits used to represent each pixel")
    return parser

(3) 再创建一个函数，用来压缩输入的图片：

def compress_image(img, num_clusters):
    # 将输入的图片转换成（样本量，特征量） 数组，以运行k-means聚类算法
    X = img.reshape((-1, 1))

    # 对输入数据运行k-means聚类
    kmeans = cluster.KMeans(n_clusters=num_clusters, n_init=4, random_state=5)
    kmeans.fit(X)
    centroids = kmeans.cluster_centers_.squeeze()
    labels = kmeans.labels_

    # 为每个数据配置离它最近的中心点，并转变为图片的形状
    input_image_compressed = np.choose(labels, centroids).reshape(img.shape)

    return input_image_compressed

(4) 压缩完图片之后，我们需要看看压缩算法对图片质量的影响。下面定义画图函数：

def plot_image(img, title):
    vmin = img.min()
    vmax = img.max()
    plt.figure()
    plt.title(title)
    plt.imshow(img, cmap=plt.cm.gray, vmin=vmin, vmax=vmax)

(5) 我们现在已经准备好所有的函数了。下面定义主函数main，它可以把输入参数传进去并进行处理，然后提取输出图片：

if __name__=='__main__':
    args = build_arg_parser().parse_args()
    input_file = args.input_file
    num_bits = args.num_bits

    if not 1 <= num_bits <= 8:
        raise TypeError('Number of bits should be between 1 and 8')

    num_clusters = np.power(2, num_bits)

    # 打印压缩率
    compression_rate = round(100 * (8.0 - args.num_bits) / 8.0, 2)
    print "\nThe size of the image will be reduced by a factor of", 8.0/args.num_bits
    print "\nCompression rate = " + str(compression_rate) + "%"

(6) 加载输入图片：

    # 加载输入图片
    input_image = misc.imread(input_file, True).astype(np.uint8)

    # 显示原始图片
    plot_image(input_image, 'Original image')

(7) 用输入的参数压缩图片：

    # 压缩图片
    input_image_compressed = compress_image(input_image, num_clusters)
    plot_image(input_image_compressed, 'Compressed image; compression rate = '
            + str(compression_rate) + '%')

    plt.show()

(8) 现在我们的代码已经准备好了。在命令行工具中运行下面的命令：

$ python vector_quantization.py --input-file flower_image.jpg --num-bits 4

输入的图片如图4-3所示。

图　4-3

压缩过的图片如图4-4所示。

图　4-4

(9) 我们把每个像素的压缩比特数降到2，再压缩图片。在命令行工具中运行下面的命令：

$ python vector_quantization.py --input-file flower_image.jpg --num-bits 2

可以看到压缩过的图片如图4-5所示。

图　4-5

(10) 如果把每个像素的压缩比特数降到1，可以看到只有黑白两种颜色的二进制图像。运行下面的命令：

$ python vector_quantization.py --input-file flower_image.jpg --num-bits 1

图片压缩效果如图4-6所示。

图　4-6

4.4　建立均值漂移聚类模型

均值漂移是一种非常强大的无监督学习算法，用于集群数据点。该算法把数据点的分布看成是概率密度函数（probability-density function），希望在特征空间中根据函数分布特征找出数据点的“模式”（mode）。这些“模式”就对应于一群群局部最密集（local maxima）分布的点。均值漂移算法的优点是它不需要事先确定集群的数量。

假设有一组输入点，我们要在不知道要寻找多少集群的情况下找到它们。均值漂移算法就可以把这些点看成是服从某个概率密度函数的样本。如果这些数据点有集群，那么它们对应于概率密度函数的峰值。该算法从一个随机点开始，逐渐收敛于各个峰值。你可以在 http://homepages.inf.ed.ac.uk/rbf/CVonline/LOCAL_COPIES/TUZEL1/MeanShift.pdf中学习更详细的内容。

详细步骤

(1) 本例的完整代码已经放在mean_shift.py文件中。我们看看它是如何实现的。首先创建一个新的Python文件，然后导入一些需要用到的程序包：

import numpy as np
from sklearn.cluster import MeanShift, estimate_bandwidth

import utilities

(2) 从data_multivar.txt文件中加载输入数据：

# 从输入文件加载数据
X = utilities.load_data('data_multivar.txt')

(3) 通过指定输入参数创建一个均值漂移模型：

# 设置带宽参数bandwidth
bandwidth = estimate_bandwidth(X, quantile=0.1, n_samples=len(X))

# 用MeanShift计算聚类
meanshift_estimator = MeanShift(bandwidth=bandwidth, bin_seeding=True)

(4) 训练模型：

meanshift_estimator.fit(X)

(5) 提取标记：

labels = meanshift_estimator.labels_

(6) 从模型中提取集群的中心点，然后打印集群数量：

centroids = meanshift_estimator.cluster_centers_
num_clusters = len(np.unique(labels))

print "Number of clusters in input data =", num_clusters

(7) 把集群可视化：

# 画出数据点和聚类中心
import matplotlib.pyplot as plt
from itertools import cycle

plt.figure()

# 为每种集群设置不同的标记
markers = '.*xv'

(8) 迭代数据点并画出它们：

for i, marker in zip(range(num_clusters), markers):
    # 画出属于某个集群中心的数据点
    plt.scatter(X[labels==i, 0], X[labels==i, 1], marker=marker, color='k')

    # 画出集群中心
    centroid = centroids[i]
    plt.plot(centroid[0], centroid[1], marker='o', markerfacecolor='k',
             markeredgecolor='k', markersize=15)

plt.title('Clusters and their centroids')
plt.show()

(9) 运行代码，可以看到如图4-7所示的图形。

图　4-7

4.5　用凝聚层次聚类进行数据分组

在介绍凝聚层次聚类之前，我们需要先理解层次聚类（hierarchical clustering）。层次聚类是一组聚类算法，通过不断地分解或合并集群来构建树状集群（tree-like clusters）。层次聚类的结构可以用一颗树表示。

层次聚类算法可以是自下而上的，也可以是自上而下的。具体是什么含义呢？在自下而上的算法中，每个数据点都被看作是一个单独的集群。这些集群不断地合并，直到所有的集群都合并成一个巨型集群。这被称为凝聚层次聚类。与之相反的是，自上而下层次的算法是从一个巨大的集群开始，不断地分解，直到所有的集群变成一个单独的数据点。你可以在http://nlp.stanford.edu/IR-book/html/htmledition/hierarchical-agglomerative-clustering-1.html学习更多的内容。

详细步骤

(1) 本例的完整代码都已经放在agglomerative.py文件中。让我们看看它是如何实现的。首先创建一个新的Python文件，然后导入一些需要用到的程序包：

import numpy as np
import matplotlib.pyplot as plt
from sklearn.cluster import AgglomerativeClustering
from sklearn.neighbors import kneighbors_graph

(2) 定义一个实现凝聚层次聚类的函数：

def perform_clustering(X, connectivity, title, num_clusters=3, linkage='ward'):
    plt.figure()
    model = AgglomerativeClustering(linkage=linkage,
                    connectivity=connectivity, n_clusters=num_clusters)
    model.fit(X)

(3) 提取标记，然后指定不同聚类在图形中的标记：

    # 提取标记
    labels = model.labels_

    # 为每种集群设置不同的标记
    markers = '.vx'

(4) 迭代数据，用不同的标记把聚类的点画在图形中：

    for i, marker in zip(range(num_clusters), markers):
        # 画出属于某个集群中心的数据点
        plt.scatter(X[labels==i, 0], X[labels==i, 1], s=50,
                    marker=marker, color='k', facecolors='none')

    plt.title(title)

(5) 为了演示凝聚层次聚类的优势，我们用它对一些在空间中是连接在一起、但彼此却非常接近的数据进行聚类。我们希望连接在一起的数据可以聚成一类，而不是在空间上非常接近的点聚成一类。下面定义一个函数来获取一组呈螺旋状的数据点：

def get_spiral(t, noise_amplitude=0.5):
    r = t
    x = r * np.cos(t)
    y = r * np.sin(t)

    return add_noise(x, y, noise_amplitude)

(6) 在上面的函数中，我们增加了一些噪声，因为这样做可以增加一些不确定性。下面定义噪声函数：

def add_noise(x, y, amplitude):
    X = np.concatenate((x, y))
    X += amplitude * np.random.randn(2, X.shape[1])
    return X.T

(7) 我们再定义一个函数来获取位于玫瑰曲线上的数据点（rose curve，又称为rhodonea curve，极坐标中的正弦曲线）：

def get_rose(t, noise_amplitude=0.02):
    # 设置玫瑰曲线方程；如果变量k是奇数，那么曲线有k朵花瓣；如果k是偶数，那么有2k朵花瓣
    k = 5
    r = np.cos(k*t) + 0.25
    x = r * np.cos(t)
    y = r * np.sin(t)

    return add_noise(x, y, noise_amplitude)

(8) 为了增加多样性，我们再定义一个hypotrochoid函数：

def get_hypotrochoid(t, noise_amplitude=0):
    a, b, h = 10.0, 2.0, 4.0
    x = (a - b) * np.cos(t) + h * np.cos((a - b) / b * t)
    y = (a - b) * np.sin(t) - h * np.sin((a - b) / b * t)

    return add_noise(x, y, 0)

(9) 现在可以定义主函数main了：

if __name__=='__main__':
    # 生成样本数据
    n_samples = 500
    np.random.seed(2)
    t = 2.5 * np.pi * (1 + 2 * np.random.rand(1, n_samples))
    X = get_spiral(t)

    # 不考虑螺旋形的数据连接性
    connectivity = None
    perform_clustering(X, connectivity, 'No connectivity')

    # 根据数据连接线创建K个临近点的图形
    connectivity = kneighbors_graph(X, 10, include_self=False)
    perform_clustering(X, connectivity, 'K-Neighbors connectivity')

    plt.show()

(10) 运行代码，可以看到如图4-8所示的图形（没有用任何连接特征）。

图　4-8

(11) 还可以看到如图4-9所示的图形（使用连接特征）。

图　4-9

从图4-8和图4-9中可以看出，使用连接特征可以让我们把连接在一起的数据合成一组，而不是按照它们在螺旋线上的位置进行聚类。

4.6　评价聚类算法的聚类效果

到目前为止，我们已经介绍了3种聚类算法，却没有度量过它们的聚类效果。在监督学习中，可以用预测值与原始值进行比较来计算模型的准确性，但是，在无监督学习中，我们的数据没有标记，因此，需要一种度量聚类算法的方法。

度量聚类算法的一个好方法是观察集群被分离的离散程度。这些集群是不是被分离得很合理？一个集群中所有的数据点是不是足够紧密？需要拟定一个指标来衡量这种特征，于是，我们采用一个被称为轮廓系数（Silhouette Coefficient）得分的指标。该得分是为每个数据点定义的，它的定义如下：

得分 = (x – y) / max(x, y)

其中，x 表示在同一个集群中某个数据点与其他数据点的平均距离，y 表示某个数据点与最近的另一个集群的所有点的平均距离。

详细步骤

(1) 本例的完整代码已经放在performance.py文件中。我们看看它是如何实现的。首先创建一个新的Python文件，然后导入一些需要用到的程序包：

import numpy as np
import matplotlib.pyplot as plt
from sklearn import metrics
from sklearn.cluster import KMeans

import utilities

(2) 从data_perf.txt文件中加载输入数据：

# 加载数据
data = utilities.load_data('data_perf.txt')

(3) 为了确定集群的最佳数量，我们迭代一系列的值，找出其中的峰值：

scores = []
range_values = np.arange(2, 10)

for i in range_values:
    # 训练模型
    kmeans = KMeans(init='k-means++', n_clusters=i, n_init=10)
    kmeans.fit(data)
    score = metrics.silhouette_score(data, kmeans.labels_,
                metric='euclidean', sample_size=len(data))

    print "\nNumber of clusters =", i
    print "Silhouette score =", score

    scores.append(score)

(4) 画出图形并找出峰值：

# 画出得分条形图
plt.figure()
plt.bar(range_values, scores, width=0.6, color='k', align='center')
plt.title('Silhouette score vs number of clusters')

# 画出数据
plt.figure()
plt.scatter(data[:,0], data[:,1], color='k', s=30, marker='o', facecolors='none')
x_min, x_max = min(data[:, 0]) - 1, max(data[:, 0]) + 1
y_min, y_max = min(data[:, 1]) - 1, max(data[:, 1]) + 1
plt.title('Input data')
plt.xlim(x_min, x_max)
plt.ylim(y_min, y_max)
plt.xticks(())
plt.yticks(())

plt.show()

(5) 运行代码，可以在命令行工具中看到如图4-10所示的结果。

图　4-10

(6) 画出的条形图如图4-11所示。

图　4-11

(7) 从图4-11中可以看出，5个集群是最好的配置，此时的实际图形如图4-12所示。

图　4-12

可以从图4-12中直观地确认数据实际上有5个集群。我们只是采用了一个包含5个不同集群的小数据集的例子。通过轮廓系数判断聚类效果的方法，对那些包含不容易可视化的高维数据的大型数据集非常有用。

4.7　用DBSCAN算法自动估算集群数量

介绍k-means算法的时候，必须把集群数量当作一个输入参数。在真实世界中，我们事先并不知道这个信息。可以搜索集群数量的参数空间，通过轮廓系数得分找到最优的集群数量，但这是一个非常耗时的过程。难道就没有一种方法可以直接找出集群数量吗？DBSCAN（DensityBased Spatial Clustering of Applications with Noise，带噪声的基于密度的聚类方法）应运而生。

DBSCAN将数据点看成是紧密集群的若干组。如果某个点属于一个集群，那么就应该有许多点也属于同一个集群。该方法里面有一个epsilon参数，可以控制这个点到其他点的最大距离。如果两个点的距离超过了参数epsilon的值，它们就不可能在一个集群中。你可以在 http://staffwww.itn.liu.se/~aidvi/courses/06/dm/Seminars2011/DBSCAN(4).pdf学习更多的内容。这种方法的主要优点是它可以处理异常点。如果有一些点位于数据稀疏区域，DBSCAN就会把这些点作为异常点，而不会强制将它们放入一个集群中。

详细步骤

(1) 本例的完整代码已经放在estimate_clusters.py文件中。我们看看它是如何实现的。首先创建一个新的Python文件，然后导入一些需要用到的程序库：

from itertools import cycle

import numpy as np
from sklearn.cluster import DBSCAN
from sklearn import metrics
import matplotlib.pyplot as plt

from utilities import load_data2

(2) 从data_perf.txt文件中加载输入数据。这和上一例的数据文件一样，这样可以帮助我们用同样的数据集对比两种方法：

# 加载输入数据
input_file = 'data_perf.txt'
X = load_data(input_file)

(3) 我们需要找到最佳集群数量参数。先初始化一些变量：

# 寻找最优的epsilon参数值
eps_grid = np.linspace(0.3, 1.2, num=10)
silhouette_scores = []
eps_best = eps_grid[0]
silhouette_score_max = -1
model_best = None
labels_best = None

(4) 搜索参数空间：

for eps in eps_grid:
    # 训练DBSCAN聚类模型
    model = DBSCAN(eps=eps, min_samples=5).fit(X)

    # 提取标记
    labels = model.labels_

(5) 每次迭代，我们都需要提取性能指标：

    # 提取性能指标
    silhouette_score = round(metrics.silhouette_score(X, labels), 4)
    silhouette_scores.append(silhouette_score)

    print "Epsilon:", eps, " --> silhouette score:", silhouette_score

(6) 我们需要保存指标的最佳得分和对应的epsilon值：

    if silhouette_score > silhouette_score_max:
        silhouette_score_max = silhouette_score
        eps_best = eps
        model_best = model
        labels_best = labels

(7) 画出条形图：

# 画出条形图
plt.figure()
plt.bar(eps_grid, silhouette_scores, width=0.05, color='k', align='center')
plt.title('Silhouette score vs epsilon')

# 打印最优参数
print "\nBest epsilon =", eps_best

(8) 把最优的模型和标记保存起来：

# 最优参数对应的模型与标记
model = model_best
labels = labels_best

(9) 有些数据点还没有分配集群，我们需要识别它们：

# 检查标记中没有分配集群的数据点
offset = 0
if -1 in labels:
    offset = 1

(10) 提取集群的数量：

# 数据中的集群数量
num_clusters = len(set(labels)) - offset

print "\nEstimated number of clusters =", num_clusters

(11) 提取核心样本：

# 从训练模型中提取核心样本的数据点索引
mask_core = np.zeros(labels.shape, dtype=np.bool)
mask_core[model.core_sample_indices_] = True

(12) 接下来将集群结果可视化。首先提取独特的标记集合，然后分配不同的标记：

# 画出集群结果
plt.figure()
labels_uniq = set(labels)
markers = cycle('vo^s<>')

(13) 用迭代法把每个集群的数据点用不同的标记画出来：

for cur_label, marker in zip(labels_uniq, markers):
    # 用黑点表示未分配的数据点
    if cur_label == -1:
        marker = '.'

    # 为当前标记添加符号
    cur_mask = (labels == cur_label)

    cur_data = X[cur_mask & mask_core]
    plt.scatter(cur_data[:, 0], cur_data[:, 1], marker=marker,
             edgecolors='black', s=96, facecolors='none')

    cur_data = X[cur_mask & ~mask_core]
    plt.scatter(cur_data[:, 0], cur_data[:, 1], marker=marker,
             edgecolors='black', s=32)

plt.title('Data separated into clusters')
plt.show()

(14) 运行代码，可以在命令行工具中看到如图4-13所示的结果。

图　4-13

(15) 条形图如图4-14所示。

图　4-14

(16) 用实心标注的未被分配的数据点如图4-15所示。

图　4-15

4.8　探索股票数据的模式

让我们看看如何用无监督学习进行股票数据分析。假设我们并不知道股票市场有多少集群，因此需要用一种近邻传播聚类（Affinity Propagation）算法来集群。这种算法会找出数据中每个集群的代表性数据点，会找到数据点间的相似性度量值，并把所有数据点看成潜在的代表性数据点，也称为取样器（exemplar）。更多内容可参考 http://www.cs.columbia.edu/~delbert/docs/DDueck-thesis_small.pdf。

本例将分析在特定时间内的股票市场变化，我们的目标是根据股价的波动找出公司行为的相似性。

详细步骤

(1) 本例的完整代码已经放在stock_market.py文件中。我们看看它是如何实现的。首先创建一个新的Python文件，然后导入一些需要用到的程序包：

import json
import datetime

import numpy as np
import matplotlib.pyplot as plt
from sklearn import covariance, cluster
from matplotlib.finance import quotes_historical_yahoo_ochl as quotes_yahoo

(2) 我们需要一个包含所有符号以及对应名称的文件，具体信息在symbol_map.json文件中。下面加载这个文件：

# 输入符号信息文件
symbol_file = 'symbol_map.json'

(3) 从符号映射文件中读取数据：

# 加载符号映射信息
with open(symbol_file, 'r') as f:
    symbol_dict = json.loads(f.read())

symbols, names = np.array(list(symbol_dict.items())).T

(4) 让我们指定分析的时间段。将用这个时间段作为输入数据的起止时间：

# 选择时间段
start_date = datetime.datetime(2004, 4, 5)
end_date = datetime.datetime(2007, 6, 2)

(5) 读取输入的数据：

quotes = [quotes_yahoo(symbol, start_date, end_date, asobject=True)
                for symbol in symbols]

(6) 由于需要分析一些特征点，我们使用每天的开盘价和收盘价的差异来分析数据：

# 提取开盘价和收盘价
opening_quotes = np.array([quote.open for quote in quotes]).astype(np.float)
closing_quotes = np.array([quote.close for quote in quotes]).astype(np.float)

# 计算每日股价波动（收盘价-开盘价）
delta_quotes = closing_quotes - opening_quotes

(7) 建立一个协方差图模型：

# 从相关性中建立协方差图模型
edge_model = covariance.GraphLassoCV()

(8) 在使用数据之前先对它进行标准化：

# 数据标准化
X = delta_quotes.copy().T
X /= X.std(axis=0)

(9) 用数据训练模型：

# 训练模型
with np.errstate(invalid='ignore'):
    edge_model.fit(X)

(10) 我们现在已经准备好建立聚类模型了：

# 用近邻传播算法建立聚类模型
_, labels = cluster.affinity_propagation(edge_model.covariance_)
num_labels = labels.max()

# 打印聚类结果
for i in range(num_labels + 1):
    print "Cluster", i+1, "-->", ', '.join(names[labels == i])

运行代码，可以在命令行工具中看到如图4-16所示的结果。

图　4-16

4.9　建立客户细分模型

无监督学习的主要应用场景之一就是市场细分。虽然在我们开发市场时获取的数据都没有标记，但是将市场细分成不同类型至关重要，这样人们就可以关注各自的市场类型了。市场细分对广告投放、库存管理、配送策略的实施、大众传媒等市场行为都非常有用。下面把无监督学习应用到一个市场细分的案例上，看看效果如何。

我们将与一个零售商和他的客户打交道，采用https://archive.ics.uci.edu/ml/datasets/Wholesale+customers的数据进行分析。数据表里包含了不同类型商品的销售数据，目标是找到数据集群，从而为客户提供最优的销售和分销策略。

详细步骤

(1) 本例的完整代码已经放在customer_segmentation.py文件中。我们看看它是如何实现的。首先创建一个新的Python文件，然后导入一些需要用到的程序包：

import csv

import numpy as np
from sklearn import cluster, covariance, manifold
from sklearn.cluster import MeanShift, estimate_bandwidth
import matplotlib.pyplot as plt

(2) 从wholesale.csv文件中加载输入数据：

# 从输入文件加载数据
input_file = 'wholesale.csv'
file_reader = csv.reader(open(input_file, 'rb'), delimiter=',')
X = []
for count, row in enumerate(file_reader):
    if not count:
        names = row[2:]
        continue

    X.append([float(x) for x in row[2:]])

# 转换为numpy数组
X = np.array(X)

(3) 和前面的内容一样，建立一个均值漂移聚类模型：

# 估计带宽参数bandwidth
bandwidth = estimate_bandwidth(X, quantile=0.8, n_samples=len(X))

# 用MeanShift函数计算聚类
meanshift_estimator = MeanShift(bandwidth=bandwidth, bin_seeding=True)
meanshift_estimator.fit(X)
labels = meanshift_estimator.labels_
centroids = meanshift_estimator.cluster_centers_
num_clusters = len(np.unique(labels))

print "\nNumber of clusters in input data =", num_clusters

(4) 打印获得的集群中心：

print "\nCentroids of clusters:"
print '\t'.join([name[:3] for name in names])
for centroid in centroids:
    print '\t'.join([str(int(x)) for x in centroid])

(5) 把两个特征（milk和groceries）的聚类结果可视化，以获取直观的输出：

# 数据可视化

centroids_milk_groceries = centroids[:, 1:3]

# 用centroids_milk_groceries中的坐标画出中心点
plt.figure()
plt.scatter(centroids_milk_groceries[:,0], centroids_milk_groceries[:,1],
        s=100, edgecolors='k', facecolors='none')

offset = 0.2
plt.xlim(centroids_milk_groceries[:,0].min() - offset * centroids_milk_groceries[:,0].ptp(),
        centroids_milk_groceries[:,0].max() + offset * centroids_milk_groceries[:,0].ptp(),)
plt.ylim(centroids_milk_groceries[:,1].min() - offset * centroids_milk_groceries[:,1].ptp(),
        centroids_milk_groceries[:,1].max() + offset * centroids_milk_groceries[:,1].ptp())

plt.title('Centroids of clusters for milk and groceries')
plt.show()

(6) 运行代码，可以在命令行工具中看到如图4-17所示的结果。

图　4-17

(7) 如图4-18所示描绘的是milk（牛奶）和groceries（杂货）两个特征的聚类中心，其中milk特征值对应X轴坐标，groceries特征值对应Y轴坐标。

图　4-18

第 5 章　构建推荐引擎

在这一章，我们将介绍以下主题：

为数据处理构建函数组合

构建机器学习流水线（pipeline）

寻找最近邻

构建一个KNN分类器

构建一个KNN回归器

计算欧氏距离分数（Euclidean distance score）

计算皮尔逊相关系数（Pearson correlation score）

寻找数据集中的相似用户

生成电影推荐

5.1　简介

推荐引擎是一个能预测用户兴趣点的模型。将推荐引擎应用于电影语境时，便成了一个电影推荐引擎。我们通过预测当前用户可能会喜欢的内容，将相应的东西从数据库中筛选出来，这样的推荐引擎可以有助于将用户和数据集中的合适内容连接起来。为什么推荐引擎这么重要？设想你有一个很庞大的商品目录，而用户可能或者不可能查找所有的相关内容。通过推荐合适的内容，可以增加用户消费。有些公司（如Netflix）严重地依赖推荐系统来保持用户参与度。

推荐引擎通常用协同过滤（collaborative filtering）或基于内容的过滤（content-based filtering）来产生一组推荐。两种过滤方法的不同之处在于挖掘推荐的方式。协同过滤从当前用户过去的行为和其他用户对当前用户的评分来构建模型，然后使用这个模型来预测这个用户可能感兴趣的内容；而基于内容的过滤用商品本身的特征来给用户推荐更多的商品，商品间的相似度是模型主要的关注点。本章将重点介绍协同过滤。

5.2　为数据处理构建函数组合

机器学习系统中的主要组成部分是数据处理流水线。在数据被输入到机器学习算法中进行训练之前，需要对数据做各种方式的处理，使得该数据可以被算法利用。在构建一个准确的、可扩展的机器学习系统的过程中，拥有一个健壮的数据处理流水线非常重要。有很多基本的函数功能可以使用，通常数据处理流水线就是这些基本函数的组合。不推荐使用嵌套或循环的方式调用这些函数，而是用函数式编程的方式构建函数组合。接下来介绍如何组合这些函数来形成一个可重用的函数组合，本节将创建3个基本函数，并介绍如何将其组合成一个流水线。

详细步骤

(1) 创建一个Python文件，导入以下程序包：

import numpy as np

(2) 定义第一个函数，将数组的每一个元素加3：

def add3(input_array):
    return map(lambda x: x+3, input_array)

(3) 定义第二个函数，将数组的每一个元素乘以2：

def mul2(input_array):
    return map(lambda x: x*2, input_array)

(4) 定义第三个函数，将数组的每一个元素减去5：

def sub5(input_array):
    return map(lambda x: x-5, input_array)

(5) 定义一个函数组合器，将这些函数作为输入参数，返回一个组合函数。这个组合函数基本上是输入函数按序执行的一个函数：

def function_composer(*args):
    return reduce(lambda f, g: lambda x: f(g(x)), args)

我们用reduce函数依次执行所有函数，也就是将所有的输入函数合并。

(6) 接下来可以做函数组合了。首先定义一些数据和一组操作：

if __name__=='__main__':
    arr = np.array([2,5,4,7])

    print "\nOperation: add3(mul2(sub5(arr)))"

(7) 如果用常规的方法，我们依次执行函数，代码如下：

    arr1 = add3(arr)
    arr2 = mul2(arr1)
    arr3 = sub5(arr2)
    print "Output using the lengthy way:", arr3

(8) 下面用单行代码的函数组合器实现同样的功能：

    func_composed = function_composer(sub5, mul2, add3)
    print "Output using function composition:", func_composed(arr)

(9) 可以通过上面的方法用单行代码实现函数组合，但是其表示方式是嵌套的和不可读的，而且也是不可重用的。当需要再次用到这组操作时，需要重新编写：

    print "\nOperation: sub5(add3(mul2(sub5(mul2(arr)))))\nOutput:", \
            function_composer(mul2, sub5, mul2, add3, sub5)(arr)

(10) 运行代码，可以在命令行工具中看到如图5-1所示的输出结果。

图　5-1

5.3　构建机器学习流水线

scikit-learn库中包含了构建机器学习流水线的方法。只需要指定函数，它就会构建一个组合对象，使数据通过整个流水线。这个流水线可以包括诸如预处理、特征选择、监督式学习、非监督式学习等函数。这一节将构建一个流水线，以便输入特征向量、选择最好的k 个特征、用随机森林分类器进行分类等。

5.3.1　详细步骤

(1) 创建一个Python文件，导入以下程序包：

from sklearn.datasets import samples_generator
from sklearn.ensemble import RandomForestClassifier
from sklearn.feature_selection import SelectKBest, f_regression
from sklearn.pipeline import Pipeline

(2) 生成一些示例数据：

# 生成样本数据
X, y = samples_generator.make_classification(
        n_informative=4, n_features=20, n_redundant=0, random_state=5)

这一行代码生成了一个20维的特征向量，因为这是一个默认值。在这里，你可以通过修改n_features参数来修改特征向量的维数。

(3) 建立流水线的第一步是在数据点进一步操作之前选择k个最好的特征。这里设置k的值为10：

# 特征选择器
selector_k_best = SelectKBest(f_regression, k=10)

(4) 建立流水线的第二步是用随机森林分类器分类数据：

# 随机森林分类器
classifier = RandomForestClassifier(n_estimators=50, max_depth=4)

(5) 接下来可以创建流水线了。pipeline方法允许我们用预定义的对象来创建流水线：

# 构建机器学习流水线
pipeline_classifier = Pipeline([('selector', selector_k_best), ('rf', classifier)])

还可以在流水线中为模块指定名称。上一行代码中将特征选择器命名为selector，将随机森林分类器命名为rf。你也可以任意选用其他名称。

(6) 也可以更新这些参数，用上一步骤中命名的名称设置这些参数。例如，如果希望在特征选择器中将k值设置为6，在随机森林分类器中将n_estimators的值设置为25，可以用下面的代码实现。注意，这些变量名称已在上一步骤中给出。

pipeline_classifier.set_params(selector__k=6,  rf__n_estimators=25)

(7) 接下来训练分类器：

# 训练分类器
pipeline_classifier.fit(X, y)

(8) 为训练数据预测输出结果：

# 预测输出结果
prediction = pipeline_classifier.predict(X)
print "\nPredictions:\n", prediction

(9) 评价分类器的性能：

# 打印分类器得分
print "\nScore:", pipeline_classifier.score(X, y)

(10) 还可以查看哪些特征被选中，并将其打印出：

# 打印被分类器选中的特征
features_status = pipeline_classifier.named_steps['selector'].get_support()
selected_features = []
for count, item in enumerate(features_status):
    if item:
        selected_features.append(count)

print "\nSelected features (0-indexed):", ', '.join([str(x) for x in selected_features])

(11) 运行代码，可以在命令行工具中看到如图5-2所示的输出结果。

图　5-2

5.3.2　工作原理

选择k 个最好的特征，其好处在于可以处理较小维度的数据，这对减小计算复杂度来说非常有用。选择k 个最佳特征的方式是基于单变量的特征选择，选择过程是先进行单变量统计测试，然后从特征向量中抽取最优秀的特征。单变量统计测试是指只涉及一个变量的分析技术。

做了这些测试后，向量空间中的每个特征将有一个评价分数。基于这些评价分数，选择最好的k 个特征。我们在分类器流水线中执行这个预处理步骤。一旦抽取出k 个特征，一个k 维的特征向量就形成了，可以将这个特征向量用于随机森林分类器的输入训练数据。

5.4　寻找最近邻

最近邻模型是指一个通用算法类，其目的是根据训练数据集中的最近邻数量来做决策。接下来学习如何寻找最近邻。

详细步骤

(1) 创建一个Python文件，导入以下程序包：

import numpy as np
import matplotlib.pyplot as plt
from sklearn.neighbors import NearestNeighbors

(2) 创建一些示例的二维数据：

# 输入数据
X = np.array([[1, 1], [1, 3], [2, 2], [2.5, 5], [3, 1],
        [4, 2], [2, 3.5], [3, 3], [3.5, 4]])

(3) 我们的目标是对于任意给定点找到其3个最近邻。定义该参数：

# 寻找最近邻的数量
num_neighbors = 3

(4) 定义一个随机数据点，这个数据点不包括在输入数据中：

# 输入数据点
input_point = [2.6, 1.7]

(5) 看看数据的分布，并将其画出：

# 画出数据点
plt.figure()
plt.scatter(X[:,0], X[:,1], marker='o', s=25, color='k')

(6) 为了寻找最近邻，用适合的参数定义一个NearestNeighbors对象，并用输入数据训练该对象：

# 建立最近邻模型
knn = NearestNeighbors(n_neighbors=num_neighbors, algorithm='ball_tree').fit(X)

(7) 计算输入点与输入数据中所有点的距离：

distances, indices = knn.kneighbors(input_point)

(8) 打印出k 个最近邻：

# 打印k个最近邻点
print "\nk nearest neighbors"
for rank, index in enumerate(indices[0][:num_neighbors]):
    print str(rank+1) + " -->", X[index]

indices数组是一个已排序的数组，因此仅需要解析它并打印出数据点。

(9) 画出输入数据点，并突出显示k 个最近邻：

# 画出最近邻点
plt.figure()
plt.scatter(X[:,0], X[:,1], marker='o', s=25, color='k')
plt.scatter(X[indices][0][:][:,0], X[indices][0][:][:,1],
        marker='o', s=150, color='k', facecolors='none')
plt.scatter(input_point[0], input_point[1],
        marker='x', s=150, color='k', facecolors='none')

plt.show()

(10) 执行代码，可以在命令行工具中看到如图5-3所示的输出。

图　5-3

(11) 输入数据点的绘制情况如图5-4所示。

图　5-4

(12) 如图5-5所示的输出图像展示了测试数据点和3个最近邻的位置。

图　5-5

5.5　构建一个KNN分类器

KNN（k-nearest neighbors）是用k 个最近邻的训练数据集来寻找未知对象分类的一种算法。如果希望找到未知数据点属于哪个类，可以找到KNN并做一个多数表决。接下来学习如何构建这样的分类器。

5.5.1　详细步骤

(1) 创建一个Python文件，导入以下程序包：

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.cm as cm
from sklearn import neighbors, datasets

from utilities import load_data

(2) 我们将用到data_nn_classifier.txt文件作为输入数据。加载该输入数据：

# 加载输入数据
input_file = 'data_nn_classifier.txt'
data = load_data(input_file)
X, y = data[:,:-1], data[:,-1].astype(np.int)

前两列包含输入数据，最后一列包含标签，因此分别将其用X和y两个变量表示。

(3) 将输入数据可视化：

# 画出输入数据
plt.figure()
plt.title('Input datapoints')
markers = '^sov<>hp'
mapper = np.array([markers[i] for i in y])
for i in range(X.shape[0]):
    plt.scatter(X[i, 0], X[i, 1], marker=mapper[i],
            s=50, edgecolors='black', facecolors='none')

迭代所有的数据点，并用合适的标记区分不同的类。

(4) 为了构建分类器，需要指定我们考虑的最近邻的个数。定义该参数如下：

# 设置最近邻的个数
num_neighbors = 10

(5) 为了将边界可视化，需要定义一个网格，用这个网格评价该分类器。定义网格步长如下：

# 定义网格步长
h = 0.01

(6) 接下来创建KNN分类器并进行训练：

# 创建KNN分类器模型并进行训练
classifier = neighbors.KNeighborsClassifier(num_neighbors, weights='distance')
classifier.fit(X, y)

(7) 生成一个网格来画出边界。对网格做如下定义：

# 建立网格来画出边界
x_min, x_max = X[:, 0].min() - 1, X[:, 0].max() + 1
y_min, y_max = X[:, 1].min() - 1, X[:, 1].max() + 1
x_grid, y_grid = np.meshgrid(np.arange(x_min, x_max, h),
np.arange(y_min, y_max, h))

(8) 评价分类器对所有点的输出：

# 计算网格中所有点的输出
predicted_values = classifier.predict(np.c_[x_grid.ravel(), y_grid.ravel()])

(9) 将其画出：

# 在图中画出计算结果
predicted_values = predicted_values.reshape(x_grid.shape)
plt.figure()
plt.pcolormesh(x_grid, y_grid, predicted_values, cmap=cm.Pastel1)

(10) 我们画出了彩色网格，现在要将训练数据点覆盖在其上，查看这些点与边界的关系在哪里：

# 在图中画出训练数据点
for i in range(X.shape[0]):
    plt.scatter(X[i, 0], X[i, 1], marker=mapper[i],
            s=50, edgecolors='black', facecolors='none')

plt.xlim(x_grid.min(), x_grid.max())
plt.ylim(y_grid.min(), y_grid.max())
plt.title('k nearest neighbors classifier boundaries')

(11) 接下来测试数据点，查看分类器能否准确分类。定义并画出它：

# 测试输入数据点
test_datapoint = [4.5, 3.6]
plt.figure()
plt.title('Test datapoint')
for i in range(X.shape[0]):
    plt.scatter(X[i, 0], X[i, 1], marker=mapper[i],
            s=50, edgecolors='black', facecolors='none')

plt.scatter(test_datapoint[0], test_datapoint[1], marker='x',
        linewidth=3, s=200, facecolors='black')

(12) 用以下模型提取KNN：

# 提取KNN分类结果
dist, indices = classifier.kneighbors(test_datapoint)

(13) 画出KNN并突出显示：

# 画出KNN分类结果
plt.figure()
plt.title('k nearest neighbors')

for i in indices:
    plt.scatter(X[i, 0], X[i, 1], marker='o',
            linewidth=3, s=100, facecolors='black')

plt.scatter(test_datapoint[0], test_datapoint[1], marker='x',
        linewidth=3, s=200, facecolors='black')

for i in range(X.shape[0]):
    plt.scatter(X[i, 0], X[i, 1], marker=mapper[i],
            s=50, edgecolors='black', facecolors='none')

plt.show()

(14) 在命令行工具中打印分类器输出结果：

print "Predicted output:", classifier.predict(test_datapoint)[0]

(15) 执行代码，第一幅输出图像展示了输入数据的分布，如图5-6所示。

图　5-6

(16) 第二幅输出图像展示了用KNN分类器获得的边界，如图5-7所示。

图　5-7

(17) 第三幅输出图像展示了测试数据点的位置，如图5-8所示。

图　5-8

(18) 第四幅输出图像展示了10个最近邻的位置，如图5-9所示。

图　5-9

5.5.2　工作原理

KNN分类器存储了所有可用的数据点，并根据相似度指标来对新的数据点进行分类。这个相似度指标通常以距离函数的形式度量。该算法是一个非参数化技术，也就是说它在进行计算前并不需要找出任何隐含的参数。我们只需要选择k的值。

一旦找出KNN，就会做一个多数表决。一个新数据点通过KNN的多数表决来进行分类。这个数据点会被分到和KNN最常见的类中。如果将k的值设置为1，那么这就变成了一个最近邻分类器，在该分类器中，将数据点分类到训练数据集中其最近邻所属的哪一类。更多详细内容可查看http://www.fon.hum.uva.nl/praat/manual/kNN_classifiers_1__What_is_a_kNN_classifier_.html。

5.6　构建一个KNN回归器

我们已经知道如何用KNN算法构建分类器了，下面用KNN算法构建回归器。接下来学习如何构建KNN回归器。

5.6.1　详细步骤

(1) 创建一个Python文件，导入以下程序包：

import numpy as np
import matplotlib.pyplot as plt
from sklearn import neighbors

(2) 生成一些服从正态分布的样本数据：

# 生成样本数据
amplitude = 10
num_points = 100
X = amplitude * np.random.rand(num_points, 1) - 0.5 * amplitude

(3) 下面在数据中加入一些噪声来引入一些随机性。加入噪声的目的在于测试算法是否能忽略噪声，并仍然很健壮地运行函数：

# 计算目标并添加噪声
y = np.sinc(X).ravel()
y += 0.2 * (0.5 - np.random.rand(y.size))

(4) 将数据可视化：

# 画出输入数据图形
plt.figure()
plt.scatter(X, y, s=40, c='k', facecolors='none')
plt.title('Input data')

(5) 我们在上面生成了一些数据，并针对这些点做了连续函数的评价。接下来定义更密集的网格点：

# 用输入数据10倍的密度创建一维网格
x_values = np.linspace(-0.5*amplitude, 0.5*amplitude, 10*num_points)[:, np.newaxis]

定义这个更密集的网格点，因为我们希望针对这些点评价回归器，并查看它和函数的相似程度。

(6) 定义最近邻的个数：

# 定义最近邻的个数
n_neighbors = 8

(7) 用之前定义的参数初始化并训练KNN回归器：

# 定义并训练回归器
knn_regressor = neighbors.KNeighborsRegressor(n_neighbors, weights='distance')
y_values = knn_regressor.fit(X, y).predict(x_values)

(8) 将输入数据和输出数据交叠在一起，以查看回归器的性能表现：

plt.figure()
plt.scatter(X, y, s=40, c='k', facecolors='none', label='input data')
plt.plot(x_values, y_values, c='k', linestyle='--', label='predicted values')
plt.xlim(X.min() - 1, X.max() + 1)
plt.ylim(y.min() - 0.2, y.max() + 0.2)
plt.aopxis('tight')
plt.legend()
plt.title('K Nearest Neighbors Regressor')

plt.show()

(9) 执行代码，第一幅输出图像展示了输入数据的分布，如图5-10所示。

图　5-10

(10) 第二幅图像展示了用KNN回归器预测到的值，如图5-11所示。

图　5-11

5.6.2　工作原理

回归器的目标是预测连续值的输出。这个例子中并没有固定数量的输出类别，仅有一组实际输出值，我们希望回归器可以预测未知数据点的输出值。这个例子中用到一个sinc函数来演示KNN回归器，该函数也被称为基本正弦函数。sinc函数的定义如下：

　 sinc(x) = sin(x)/x　　（x != 0）
= 1 （x = 0）

当 x 为0时，sin(x)/x 变成0/0不定式，因此需要计算该函数在 x 无限趋近于0时函数的极限。我们用到了一组值做训练，并且定义了一个更密集的网格点来进行测试。正如在之前的图中看到的，输出曲线接近于训练输出。

5.7　计算欧氏距离分数

现在已经有了充足的机器学习流水线和最近邻分类器的背景知识，下面可以开始推荐引擎的探讨了。为了构建一个推荐引擎，需要定义相似度指标，以便找到与数据库中特定用户相似的用户。欧氏距离分数就是一个这样的指标，可以计算两个数据点之间的欧几里得距离。下面将重点讨论电影推荐引擎。接下来学习如何计算两个用户间的欧几里得分数。

详细步骤

(1) 创建一个Python文件，导入以下程序包：

import json import numpy as np

(2) 定义一个用于计算两个用户之间的欧几里得分数的函数。第一步先判断用户是否在数据库中出现：

# 计算user1和user2的欧氏距离分数
def euclidean_score(dataset, user1, user2):
    if user1 not in dataset:
        raise TypeError('User ' + user1 + ' not present in the dataset')

    if user2 not in dataset:
        raise TypeError('User ' + user2 + ' not present in the dataset')

(3) 为了计算分数，需要提取两个用户均评过分的电影：

    # 提取两个用户均评过分的电影
    rated_by_both = {}

    for item in dataset[user1]:
        if item in dataset[user2]:
            rated_by_both[item] = 1

(4) 如果没有两个用户共同评过分的电影，则说明这两个用户之间没有相似度（至少根据数据库中的评分信息无法计算出来）：

    # 如果两个用户都没评分过，得分为0
    if len(rated_by_both) == 0:
        return 0

(5) 对于每个共同评分，只计算平方和的平方根，并将该值归一化，使得评分取值在0到1之间：

    squared_differences = []

    for item in dataset[user1]:
        if item in dataset[user2]:
            squared_differences.append(np.square(dataset[user1][item] - dataset[user2][item]))

    return 1 / (1 + np.sqrt(np.sum(squared_differences)))

如果评分相似，那么平方和的差别就会很小，因此评分就会变得很高，这也是我们希望指标达到的效果。

(6) 加载数据文件中的movie_ratings.json文件：

if __name__=='__main__':
    data_file = 'movie_ratings.json'

    with open(data_file, 'r') as f:
        data = json.loads(f.read())

(7) 假定两个随机用户，计算其欧氏距离分数：

    user1 = 'John Carson'
    user2 = 'Michelle Peterson'

    print "\nEuclidean score:"
    print euclidean_score(data, user1, user2)

(8) 运行该代码，可以看到欧氏距离分数显示在命令行工具中。

5.8　计算皮尔逊相关系数

欧氏距离分数是一个非常好的指标，但它也有一些缺点。因此，皮尔逊相关系数常用于推荐引擎。接下来学习如何计算皮尔逊相关系数。

详细步骤

(1) 创建一个Python文件，导入以下程序包：

import json
import numpy as np

(2) 接下来定义一个用于计算两个用户之间的皮尔逊相关度系数的函数。第一步先判断用户是否在数据库中出现：

# 计算user1和user2的皮尔逊相关系数
def pearson_score(dataset, user1, user2):
    if user1 not in dataset:
        raise TypeError('User ' + user1 + ' not present in the dataset')

    if user2 not in dataset:
        raise TypeError('User ' + user2 + ' not present in the dataset')

(3) 提取两个用户均评过分的电影：

    # 提取两个用户均评过分的电影
    rated_by_both = {}

    for item in dataset[user1]:
        if item in dataset[user2]:
            rated_by_both[item] = 1

    num_ratings = len(rated_by_both)

(4) 如果没有两个用户共同评过分的电影，则说明这两个用户之间没有相似度，此时返回0：

    # 如果两个用户都没有评分，得分为0
    if num_ratings == 0:
        return 0

(5) 计算相同评分电影的平方值之和：

    # 计算相同评分电影的平方值之和
    user1_sum = np.sum([dataset[user1][item] for item in rated_by_both])
    user2_sum = np.sum([dataset[user2][item] for item in rated_by_both])

(6) 计算所有相同评分电影的评分的平方和：

    # 计算所有相同评分电影的评分的平方和
    user1_squared_sum = np.sum([np.square(dataset[user1][item]) for item in rated_by_both])
    user2_squared_sum = np.sum([np.square(dataset[user2][item]) for item in rated_by_both])

(7) 计算数据集的乘积之和：

    # 计算数据集的乘积之和
    product_sum = np.sum([dataset[user1][item] * dataset[user2][item] for item in rated_by_both])

(8) 计算皮尔逊相关系数需要的各种元素：

    # 计算皮尔逊相关度
    Sxy = product_sum - (user1_sum * user2_sum / num_ratings)
    Sxx = user1_squared_sum - np.square(user1_sum) / num_ratings
    Syy = user2_squared_sum - np.square(user2_sum) / num_ratings

(9) 考虑分母为0的情况：

    if Sxx * Syy == 0:
        return 0

(10) 如果一切正常，返回皮尔逊相关系数：

    return Sxy / np.sqrt(Sxx * Syy)

(11) 定义main函数并计算两个用户之间的皮尔逊相关系数：

if __name__=='__main__':
    data_file = 'movie_ratings.json'

    with open(data_file, 'r') as f:
        data = json.loads(f.read())

    user1 = 'John Carson'
    user2 = 'Michelle Peterson'

    print "\nPearson score:"
    print pearson_score(data, user1, user2)

(12) 运行该代码，可以看到皮尔逊相关系数显示在命令行工具中。

5.9　寻找数据集中的相似用户

构建推荐引擎中一个非常重要的任务是寻找相似的用户，也就是说，为某位用户生成的推荐信息可以同时推荐给与其相似的用户。接下来学习如何寻找相似用户。

详细步骤

(1) 创建一个Python文件，导入以下程序包：

import json
import numpy as np

from pearson_score import pearson_score

(2) 定义一个函数，用于寻找与输入用户相似的用户。该函数有3个输入参数：数据库、输入用户和寻找的相似用户个数。第一步是查看该用户是否包含在数据库中。如果用户已经存在，则需要计算该用户与数据库中其他所有用户的皮尔逊相关系数：

# 寻找特定数量的与输入用户相似的用户
def find_similar_users(dataset, user, num_users):
    if user not in dataset:
        raise TypeError('User ' + user + ' not present in the dataset')

    # 计算所有用户的皮尔逊相关度
    scores = np.array([[x, pearson_score(dataset, user, x)] for x in dataset if user != x])

(3) 将这些得分按照降序排列：

    # 评分按照第二列排序
    scores_sorted = np.argsort(scores[:, 1])

    # 评分按照降序排列
    scored_sorted_dec = scores_sorted[::-1]

(4) 提取出 k 个最高分并返回：

    # 提取出k个最高分
    top_k = scored_sorted_dec[0:num_users]

    return scores[top_k]

(5) 定义main函数，加载输入数据库：

if __name__=='__main__':
    data_file = 'movie_ratings.json'

    with open(data_file, 'r') as f:
        data = json.loads(f.read())

(6) 我们希望查找3个与John Carson相似的用户。用以下步骤实现：

    user = 'John Carson'
    print "\nUsers similar to " + user + ":\n"
    similar_users = find_similar_users(data, user, 3)
    print "User\t\t\tSimilarity score\n"
    for item in similar_users:
        print item[0], '\t\t', round(float(item[1]), 2)

(7) 运行该代码，可以看到命令行工具中显示了3个用户，如图5-12所示。

图　5-12

5.10　生成电影推荐

至此，我们已经创建了一个推荐引擎的各个不同组成部分，接下来生成一个实际的电影推荐系统。本节将用到前面各节中构建的函数来构建电影推荐引擎，接下来看看具体如何实现。

详细步骤

(1) 创建一个Python文件，导入以下程序包：

import json
import numpy as np

from euclidean_score import euclidean_score
from pearson_score import pearson_score
from find_similar_users import find_similar_users

(2) 定义一个为给定用户生成电影推荐的函数。首先检查该用户是否存在于数据库中：

# 为给定用户生成电影推荐
def generate_recommendations(dataset, user):
    if user not in dataset:
        raise TypeError('User ' + user + ' not present in the dataset')

(3) 计算该用户与数据库中其他用户的皮尔逊相关系数：

    total_scores = {}
    similarity_sums = {}

    for u in [x for x in dataset if x != user]:
        similarity_score = pearson_score(dataset, user, u)

        if similarity_score <= 0:
            continue

(4) 找到还未被该用户评分的电影：

        for item in [x for x in dataset[u] if x not in dataset[user] or dataset[user][x] == 0]:
            total_scores.update({item: dataset[u][item] * similarity_score})
            similarity_sums.update({item: similarity_score})

(5) 如果该用户看过数据库中所有的电影，那就不能为用户推荐电影。对该条件做如下处理：

    if len(total_scores) == 0:
        return ['No recommendations possible']

(6) 有了皮尔逊相关系数列表，下面生成一个电影评分标准化列表：

    # 生成一个电影评分标准化列表
    movie_ranks = np.array([[total/similarity_sums[item], item]
            for item, total in total_scores.items()])

(7) 对皮尔逊相关系数进行降序排列：

    # 根据第一列对皮尔逊相关系数进行降序排列
    movie_ranks = movie_ranks[np.argsort(movie_ranks[:, 0])[::-1]]

(8) 最后提取出推荐的电影：

    # 提取出推荐的电影
    recommendations = [movie for _, movie in movie_ranks]

    return recommendations

(9) 定义main函数，加载数据集：

if __name__=='__main__':
    data_file = 'movie_ratings.json'

    with open(data_file, 'r') as f:
        data = json.loads(f.read())

(10) 为Michael Henry生成推荐：

    user = 'Michael Henry'
    print "\nRecommendations for " + user + ":"
    movies = generate_recommendations(data, user)
    for i, movie in enumerate(movies):
        print str(i+1) + '. ' + movie

(11) 用户John Carson看过所有电影，因此在为他推荐电影时，应该显示0推荐。来看看程序运行结果是否正确：

    user = 'John Carson'
    print "\nRecommendations for " + user + ":"
    movies = generate_recommendations(data, user)
    for i, movie in enumerate(movies):
        print str(i+1) + '. ' + movie

(12) 运行该代码，可以在命令行工具中看到如图5-13所示的结果。

图　5-13

第 6 章　分析文本数据

在这一章，我们将介绍以下主题：

用标记解析的方法预处理数据

提取文本数据的词干

用词形还原的方法还原文本的基本形式

用分块的方法划分文本

创建词袋模型（bag-of-words model）

创建文本分类器

识别性别

分析句子的情感

用主题建模识别文本的模式

6.1　简介

文本分析和NLP（Natural Language Processing，自然语言处理）是现代人工智能系统不可分割的一部分。计算机擅长于用有限的多样性来理解结构死板的数据。然而，当我们用计算机处理非结构化的自由文本时，就会变得很困难。开发NLP应用程序是一种挑战，因为计算机很难理解隐含的概念，而且语言交流方式也有很多细微的差异。这些差异的形式可以是方言、语境、俚语等。

为了解决这个问题，基于机器学习的NLP应运而生。这些算法检测文本数据的模式，以便可以从中得到了解。人工智能公司大量地使用了NLP和文本分析来推送相关结果。NLP最常用的领域包括搜索引擎、情感分析、主题建模、词性标注、实体识别等。NLP的目标是开发出一组算法，以便可以用简单的英文和计算机交流。如果这一目标实现，将不再需要程序设计语言来命令计算机执行指令。这一章将主要介绍文本分析，以及如何从文本数据中提取有意义的信息。我们将大量用到Python中的NLTK（Natural Language Toolkit）包。在进行接下来的学习之前，先确保你已经安装了NLTK，安装步骤可以参考http://www.nltk.org/install.html。你还需要安装NLTK数据，这些数据中包含很多语料和训练模型，这也是文本分析不可分割的部分，安装步骤可以参考http://www.nltk.org/data.html。

6.2　用标记解析的方法预处理数据

标记解析是将文本分割成一组有意义的片段的过程。这些片段被称作标记，例如可以将一段文字分割成单词或者句子。根据手头的任务需要，可以自定义将输入的文本分割成有意义的标记。接下来介绍如何实现这样的标记解析。

详细步骤

(1) 创建一个Python文件，在文件中加入以下内容。这里定义了一些用于分析的示例文本：

text = "Are you curious about tokenization? Let's see how it works!  We need to analyze a couple of sentences with punctuations to see it in action."

(2) 接下来做句子解析。NLTK提供了一个句子解析器，首先加载该模块：

# 对句子进行解析
from nltk.tokenize import sent_tokenize

(3) 对输入文本运行句子解析器，提取出标记：

sent_tokenize_list = sent_tokenize(text)

(4) 打印出句子解析结果列表：

print "\nSentence tokenizer:"
print sent_tokenize_list

(5) 单词解析在NLP中是非常常用的。NLTK附带了几个不同的单词解析器。先从最基本的单词解析器开始：

# 建立一个新的单词解析器
from nltk.tokenize import word_tokenize

print "\nWord tokenizer:"
print word_tokenize(text)

(6) NLTK中另外一个可以使用的单词解析器叫PunktWord，它以标点符号分割文本，如果是单词中的标点符号，则保留不做分割：

# 创建一个带标点的单词解析器
from nltk.tokenize import PunktWordTokenizer

punkt_word_tokenizer = PunktWordTokenizer()
print "\nPunkt word tokenizer:"
print punkt_word_tokenizer.tokenize(text)

(7) 如果需要将标点符号保留到不同的句子标记中，可以用WordPunct标记解析器：

# 创建一个新的WordPunct标记解析器
from nltk.tokenize import WordPunctTokenizer

word_punct_tokenizer = WordPunctTokenizer()
print "\nWord punct tokenizer:"
print word_punct_tokenizer.tokenize(text)

(8) 全部代码已经在tokenizer.py文件中给出。运行该代码，可以在命令行工具中看到如图6-1所示的输出结果。

图　6-1

6.3　提取文本数据的词干

处理文本文档时，可能会碰到单词的不同形式。以单词“play”为例，这个单词可能以各种形式出现，例如“play”“plays”“player”“playing”等，这些是具有同样含义的单词家族。在文本分析中，提取这些单词的原形非常有用，它有助于我们提取一些统计信息来分析整个文本。词干提取的目标是将不同词形的单词都变成其原形。词干提取使用启发式处理方法截取单词的尾部，以提取单词的原形。接下来介绍如何在Python中完成词干提取。

6.3.1　详细步骤

(1) 创建一个Python文件，导入以下程序包：

from nltk.stem.porter import PorterStemmer
from nltk.stem.lancaster import LancasterStemmer
from nltk.stem.snowball import SnowballStemmer

(2) 定义一些单词来进行词干提取：

words = ['table', 'probably', 'wolves', 'playing', 'is','dog', 'the', 'beaches', 'grounded', 'dreamt', 'envision']

(3) 定义一个稍后会用到的词干提取器列表：

# 对比不同的词干提取算法
stemmers = ['PORTER', 'LANCASTER', 'SNOWBALL']

(4) 初始化3个词干提取器对象：

stemmer_porter = PorterStemmer()
stemmer_lancaster = LancasterStemmer()
stemmer_snowball = SnowballStemmer('english')

(5) 为了以整齐的表格形式将输出数据打印出来，需要设定其正确的格式：

formatted_row = '{:>16}' * (len(stemmers) + 1)
print '\n', formatted_row.format('WORD', *stemmers), '\n'

(6) 迭代列表中的单词，并用3个词干提取器进行词干提取：

for word in words:
    stemmed_words = [stemmer_porter.stem(word),
            stemmer_lancaster.stem(word), stemmer_snowball.stem(word)]
    print formatted_row.format(word, *stemmed_words)

(7) 全部代码已经在stemmer.py文件中给出。运行该代码，可以在命令行工具中看到如图6-2所示的输出结果。从图6-2中可以看出，Lancaster词干提取器的输出结果与其他词干提取器的输出结果不同。

图　6-2

6.3.2　工作原理

以上3种词干提取算法的本质目标都是提取出词干，消除词形的影响。它们的不同之处在于操作的严格程度不同。观察输出结果可以看到，Lancaster词干提取器比其他两个词干提取器更严格。就严格程度来说，Porter词干提取器是最宽松的，而Lancaster词干提取器是最严格的。从Lancaster词干提取器得到的词干往往比较模糊，难以理解。Lancaster词干提取算法的速度很快，但是它会减少单词的很大部分，因此通常会选择Snowball词干提取器。

6.4　用词形还原的方法还原文本的基本形式

词形还原的目标也是将单词转换为其原形，但它是一个更结构化的方法。在前一节中，可以看到用词根还原得到的单词原形并不是有意义的，例如单词“wolves”被还原成“wolv”，还原出的单词根本不是一个真实的单词。词形还原通过对单词进行词汇和语法分析来实现，因此可以圆满解决这一问题。词形还原变形词的结尾，例如“ing”或“ed”，然后返回单词的原形形式，这个原形也就是词根（lemma）。如果对单词“wolves”做词根还原，可以得到“wolf”的输出。输出结果取决于标记是一个动词还是一个名词。下面看看如何做词形还原。

详细步骤

(1) 创建一个Python文件，导入以下程序包：

from nltk.stem import WordNetLemmatizer

(2) 定义一组单词来进行词形还原：

words = ['table', 'probably', 'wolves', 'playing', 'is',
        'dog', 'the', 'beaches', 'grounded', 'dreamt', 'envision']

(3) 比较两个不同的词形还原器，NOUN词形还原器和VERB词形还原器：

#  对比不同的词形还原器
lemmatizers = ['NOUN LEMMATIZER', 'VERB LEMMATIZER']

(4) 基于WordNet词形还原器创建一个对象：

lemmatizer_wordnet = WordNetLemmatizer()

(5) 为了以整齐的表格形式将输出数据打印出来，需要设定其正确的格式：

formatted_row = '{:>24}' * (len(lemmatizers) + 1)
print '\n', formatted_row.format('WORD', *lemmatizers), '\n'

(6) 迭代列表中的单词，并用词形还原器进行词形还原：

for word in words:
    lemmatized_words = [lemmatizer_wordnet.lemmatize(word, pos='n'),
           lemmatizer_wordnet.lemmatize(word, pos='v')]
    print formatted_row.format(word, *lemmatized_words)

(7) 全部代码已经在lemmatizer.py文件中给出。运行该代码，可以看到如图6-3所示的输出结果。观察图中对于单词“is”的词形还原，NOUN词形还原器和VERB词形还原器的还原结果有何不同。

图　6-3

6.5　用分块的方法划分文本

分块是指基于任意随机条件将输入文本分割成块。与标记解析不同的是，分块没有条件约束，分块的结果不需要有实际意义。分块在文本分析中经常使用。当处理非常大的文本文档时，就需要将文本进行分块，以便进行下一步分析。在这一节中，将输入文本分成若干块，每块都包含固定数目的单词。

详细步骤

(1) 创建一个Python文件，导入以下程序包：

import numpy as np
from nltk.corpus import brown

(2) 定义一个将文本分割成块的函数。第一步是将文本按照空格划分：

# 将文本分割成块
def splitter(data, num_words):
    words = data.split(' ')
    output = []

(3) 初始化一些后面需要用到的变量：

    cur_count = 0
    cur_words = []

(4) 对这些单词进行迭代：

    for word in words:
        cur_words.append(word)
        cur_count += 1

(5) 获得的单词数量与所需的单词数量相等时，重置相应的变量：

        if cur_count == num_words:
            output.append(' '.join(cur_words))
            cur_words = []
            cur_count = 0

(6) 将块添加到输出变量列表的最后，并返回该输出变量：

    output.append(' '.join(cur_words) )

    return output

(7) 定义一个main函数，从布朗语料库（Brown corpus）加载数据。用到前10 000个单词：

if __name__=='__main__':
    # 从布朗语料库加载数据
    data = ' '.join(brown.words()[:10000])

(8) 定义每块包含的单词数目：

    # 每块包含的单词数目
    num_words = 1700

(9) 定义两个相关变量：

    chunks = []
    counter = 0

(10) 对这个文本数据调用splitter函数，并将其打印输出：

    text_chunks = splitter(data, num_words)

    print "Number of text chunks =", len(text_chunks)

(11) 全部代码已经在chunking.py文件中给出。运行该代码，可以看到生成的块的数目被打印在命令行工具中，并且块的数目应该是6。

6.6　创建词袋模型

如果需要处理包含数百万单词的文本文档，需要将其转化成某种数值表示形式，以便让机器用这些数据来学习算法。这些算法需要数值数据，以便可以对这些数据进行分析，并输出有用的信息。这里需要用到词袋（bag-of-words）。词袋是从所有文档的所有单词中学习词汇的模型。学习之后，词袋通过构建文档中所有单词的直方图来对每篇文档进行建模。

6.6.1　详细步骤

(1) 创建一个Python文件，导入以下程序包：

import numpy as np
from nltk.corpus import brown
from chunking import splitter

(2) 定义一个main函数，从布朗语料库加载输入数据：

if __name__=='__main__':
    # 布朗语料库读取数据
    data = ' '.join(brown.words()[:10000])

(3) 将文本数据分成5块：

    # 每块包含的单词数量
    num_words = 2000

    chunks = []
    counter = 0

    text_chunks = splitter(data, num_words)

(4) 创建一个基于这些文本块的词典：

    for text in text_chunks:
        chunk = {'index': counter, 'text': text}
        chunks.append(chunk)
        counter += 1

(5) 下一步是提取一个文档-词矩阵。文档-词矩阵记录了文档中每个单词出现的频次。下面将用scikit-learn来构建这样的矩阵，因为相比于NLTK，scikit-learn有更简洁的完成这一任务的方式。导入以下程序包：

    # 提取文档-词矩阵
    from sklearn.feature_extraction.text import CountVectorizer

(6) 定义对象，并提取文档-词矩阵:

    vectorizer = CountVectorizer(min_df=5, max_df=.95)
    doc_term_matrix = vectorizer.fit_transform([chunk['text'] for chunk in chunks])

(7) 从vectorizer对象中提取词汇，并打印出：

    vocab = np.array(vectorizer.get_feature_names())
    print "\nVocabulary:"
    print vocab

(8) 打印文档-词矩阵:

    print "\nDocument term matrix:"
    chunk_names = ['Chunk-0', 'Chunk-1', 'Chunk-2', 'Chunk-3', 'Chunk-4']

(9) 为了打印成表格形式，可以做如下格式设置：

    formatted_row = '{:>12}' * (len(chunk_names) + 1)
    print '\n', formatted_row.format('Word', *chunk_names), '\n'

(10) 迭代所有单词，打印每个单词出现在不同块中的次数：

    for word, item in zip(vocab, doc_term_matrix.T):
        # “item”是压缩的稀疏矩阵（csr_matrix）数据结构
        output = [str(x) for x in item.data]
        print formatted_row.format(word, *output)

(11) 全部代码已经在bag_of_words.py文件中给出。运行该代码，可以看到命令行工具中有两部分输出，第一部分输出的是词汇，如图6-4所示。

图　6-4

(12) 第二部分输出的是文档-词矩阵，这个矩阵非常大，这里仅显示前几行，如图6-5所示。

图　6-5

6.6.2　工作原理

以下面的句子为例：

句子1：The brown dog is running.

句子2：The black dog is in the black room.

句子3： Running in the room is forbidden.

以上3句话中有以下9个唯一的单词：

the

brown

dog

is

running

black

in

room

forbidden

我们利用这些单词在每个句子中出现的频次将每个句子转换成直方图。每一个特征向量均是9维的，因为唯一单词个数为9：

句子1： [1, 1, 1, 1, 1, 0, 0, 0, 0]

句子2： [2, 0, 1, 1, 0, 2, 1, 1, 0]

句子3： [0, 0, 0, 1, 1, 0, 1, 1, 1]

提取出这样的特征向量后，就可以用机器学习的算法对这些向量进行分析了。

6.7　创建文本分类器

文本分类的目的是将文本文档分为不同的类，这是NLP中非常重要的分析手段。这里将使用一种技术，它基于一种叫作tf-idf的统计数据，它表示词频-逆文档频率（term frequency—inverse document frequency）。这个统计工具有助于理解一个单词在一组文档中对某一个文档的重要性。它可以作为特征向量来做文档分类，你可以在http://www.tfidf.com中找到更多详细介绍。

6.7.1　详细步骤

(1) 创建一个Python文件，导入以下程序包：

from sklearn.datasets import fetch_20newsgroups

(2) 选择一个类型列表，并且用词典映射的方式定义。这些类型是加载的新闻组数据集的一部分：

category_map = {'misc.forsale': 'Sales', 'rec.motorcycles': 'Motorcycles',
        'rec.sport.baseball': 'Baseball', 'sci.crypt': 'Cryptography',
        'sci.space': 'Space'}

(3) 基于刚刚定义的类型加载训练数据：

training_data = fetch_20newsgroups(subset='train', categories=category_map.keys(), shuffle=True, random_state=7)

(4) 导入特征提取器：

# 特征提取
from sklearn.feature_extraction.text import CountVectorizer

(5) 用训练数据提取特征：

vectorizer = CountVectorizer()
X_train_termcounts = vectorizer.fit_transform(training_data.data)
print "\nDimensions of training data:", X_train_termcounts.shape

(6) 接下来训练分类器。这里将用到多项式朴素贝叶斯（Multinomial Naive Bayes）分类器：

# 训练分类器
from sklearn.naive_bayes import MultinomialNB
from sklearn.feature_extraction.text import TfidfTransformer

(7) 定义一些随机输入的句子：

input_data = [
    "The curveballs of right handed pitchers tend to curve to the left",
    "Caesar cipher is an ancient form of encryption",
    "This two-wheeler is really good on slippery roads"
]

(8) 定义tf-idf变换器对象，并训练：

# tf-idf变换器
tfidf_transformer = TfidfTransformer()
X_train_tfidf = tfidf_transformer.fit_transform(X_train_ termcounts)

(9) 得到特征向量后，用该数据训练多项式朴素贝叶斯分类器：

# 多项式朴素贝叶斯分类器
classifier = MultinomialNB().fit(X_train_tfidf, training_data. target)

(10) 用词频统计转换输入数据：

X_input_termcounts = vectorizer.transform(input_data)

(11) 用tf-idf变换器变换输入数据：

X_input_tfidf = tfidf_transformer.transform(X_input_termcounts)

(12) 用训练过的分类器预测这些输入句子的输出类型：

# 预测输出类型
predicted_categories = classifier.predict(X_input_tfidf)

(13) 打印输出：

# 打印输出
for sentence, category in zip(input_data, predicted_categories):
    print '\nInput:', sentence, '\nPredicted category:', \
            category_map[training_data.target_names[category]]

(14) 全部代码已经在tfidf.py文件中给出。运行该代码，可以在命令行工具中看到如图6-6所示的输出结果。

图　6-6

6.7.2　工作原理

tf-idf技术常用于信息检索领域，目的是了解文档中每个单词的重要性。如果想要识别在文档中多次出现的单词，同时，像“is”和“be”这样的普通词汇并不能真正反映内容的本质，因此仅需要提取出具有实际意义的那些单词。词频越大，则表示这个词越重要，同时，如果这个词经常出现，那么这个词的词频也会增加，这两个因素互相平衡。提取出每个句子的词频，然后将其转换为特征向量，用分类器来对这些句子进行分类。

词频（The term frequency, TF）表示一个单词在给定文档中出现的频次。由于不同文档的长度不同，这些频次的直方图看起来会相差很大，因此需要将其规范化，使得这些频次可以在平等的环境下进行对比。为了实现规范化，我们用频次除以文档中所有单词的个数。逆文档频率（inverse document frequency，IDF）表示给定单词的重要性。当需要计算词频时，假定所有单词是同等重要的。为了抗衡哪些经常出现的单词的频率，需要用一个系数将其权重变小。我们需要计算文档的总数目除以该单词出现的文档数目的比值。逆文档频率对该比值取对数值。

例如，“is”或“the”等简单的单词在各个文档中均大量出现，但是这并不意味着要用这些词作为该篇文档的特征。同时，如果一个单词仅出现一次，那这个单词也不是十分有意义。因此，我们寻找的是那些出现了一定次数，但不太频繁以至于变成噪声的单词。tf-idf值的计算可以挑选出符合要求的单词，并且可以用于分类文档。搜索引擎经常用tf-idf工具来对搜索结果进行相关度排序。

6.8　识别性别

在NLP中，通过姓名识别性别是一个很有趣的任务。这里将用到启发式方法，即姓名的最后几个字符可以界定性别特征。例如，如果某一个名字以“la”结尾，那么它很有可能是一个女性名字，如“Angela”或者“Layla”。另外，如果一个名字以“im”结尾，那么它很有可能是一个男性名字，例如“Tim”或者“Jim”。确定需要用到几个字符来确定性别后，可以来做这个实验。接下来介绍如何识别性别。

详细步骤

(1) 创建一个Python文件，导入以下程序包：

import random
from nltk.corpus import names
from nltk import NaiveBayesClassifier
from nltk.classify import accuracy as nltk_accuracy

(2) 定义一个用于提取输入单词的特征的函数：

# 提取输入单词的特征
def gender_features(word, num_letters=2):
    return {'feature': word[-num_letters:].lower()}

(3) 定义main函数，需要一些带标记的训练数据：

if __name__=='__main__':
    # 提取标记名称
    labeled_names = ([(name, 'male') for name in names.words('male.txt')] +
            [(name, 'female') for name in names.words('female.txt')])

(4) 设置随机生成数的种子值，并混合搅乱训练数据：

    random.seed(7)
    random.shuffle(labeled_names)

(5) 定义一些输入的姓名：

    input_names = ['Leonardo', 'Amy', 'Sam']

(6) 因为不知道需要多少个末尾字符，这里将这个参数设置为1~5。每次循环执行，都会截取相应大小的末尾字符个数：

    # 搜索参数空间
    for i in range(1, 5):
        print '\nNumber of letters:', i
        featuresets = [(gender_features(n, i), gender) for (n, gender) in labeled_names]

(7) 将数据分为训练数据集和测试数据集：

        train_set, test_set = featuresets[500:], featuresets[:500]

(8) 用朴素贝叶斯分类器做分类：

        classifier = NaiveBayesClassifier.train(train_set)

(9) 用参数空间的每一个值评价分类器的效果

        # 打印分类器的准确性
        print 'Accuracy ==>', str(100 * nltk_accuracy(classifier, test_set)) + str('%')

        # 为新输入预测输出结果
        for name in input_names:
            print name, '==>', classifier.classify(gender_features(name, i))

(10) 全部代码已经在gender_identification.py文件中给出。运行该代码，可以看到命令行工具中如图6-7所示的输出结果。

图　6-7

6.9　分析句子的情感

情感分析是NLP最受欢迎的应用之一。情感分析是指确定一段给定的文本是积极还是消极的过程。有一些场景中，我们还会将“中性”作为第三个选项。情感分析常用于发现人们对于一个特定主题的看法。情感分析用于分析很多场景中用户的情绪，如营销活动、社交媒体、电子商务客户等。

6.9.1　详细步骤

(1) 创建一个Python文件，导入以下程序包：

import nltk.classify.util
from nltk.classify import NaiveBayesClassifier
from nltk.corpus import movie_reviews

(2) 定义一个用于提取特征的函数：

def extract_features(word_list):
    return dict([(word, True) for word in word_list])

(3) 我们需要训练数据，这里将用NLTK提供的电影评论数据：

if __name__=='__main__':
    # 加载积极与消极评论
    positive_fileids = movie_reviews.fileids('pos')
    negative_fileids = movie_reviews.fileids('neg')

(4) 将这些评论数据分成积极评论和消极评论：

    features_positive = [(extract_features(movie_reviews.words(fileids=[f])),
            'Positive') for f in positive_fileids]
    features_negative = [(extract_features(movie_reviews.words(fileids=[f])),
            'Negative') for f in negative_fileids]

(5) 将数据分成训练数据集和测试数据集：

    # 分成训练数据集（80%）和测试数据集（20%）
    threshold_factor = 0.8
    threshold_positive = int(threshold_factor * len(features_positive))
    threshold_negative = int(threshold_factor * len(features_negative))

(6) 提取特征：

    features_train = features_positive[:threshold_positive] + features_negative[:threshold_negative]
    features_test = features_positive[threshold_positive:] + features_negative[threshold_negative:]
    print "\nNumber of training datapoints:", len(features_train)
    print "Number of test datapoints:", len(features_test)

(7) 我们将用到朴素贝叶斯分类器。定义该对象并训练：

    # 训练朴素贝叶斯分类器
    classifier = NaiveBayesClassifier.train(features_train)
    print "\nAccuracy of the classifier:", nltk.classify.util.accuracy(classifier, features_test)

(8) 该分类器对象包含分析过程中获得的最有信息量的单词。通过这些单词可以判定哪些可以被归类为积极评论，哪些可以被归类为消极评论。将其打印出来：

    print "\nTop 10 most informative words:"
    for item in classifier.most_informative_features()[:10]:
        print item[0]

(9) 生成一些随机输入句子：

    # 输入一些简单的评论
    input_reviews = [
        "It is an amazing movie",
        "This is a dull movie. I would never recommend it to anyone.",
        "The cinematography is pretty great in this movie",
        "The direction was terrible and the story was all over the place"
    ]

(10) 在这些输入句子上运行分类器，获得预测结果：

    print "\nPredictions:"
    for review in input_reviews:
        print "\nReview:", review
        probdist = classifier.prob_classify(extract_features(review.split()))
        pred_sentiment = probdist.max()

(11) 打印输出：

        print "Predicted sentiment:", pred_sentiment
        print "Probability:", round(probdist.prob(pred_sentiment), 2)

(12) 全部代码已经在sentiment_analysis.py文件中给出。运行该代码，可以看到命令行工具中的输出结果包括3个部分。第一个部分是准确度，如图6-8所示。

图　6-8

(13) 第二部分输出最有信息量的单词，如图6-9所示。

图　6-9

(14) 第三部分是对输入句子的预测列表，如图6-10所示。

图　6-10

6.9.2　工作原理

这个例子将用NLTK的朴素贝叶斯分类器进行分类。在特征提取函数中，我们基本上提取了所有的唯一单词。然而，NLTK分类器需要的数据是用字典的格式存放的，因此这里用到了字典格式，便于NLTK分类器对象读取该数据。

将数据分成训练数据集和测试数据集后，可以训练该分类器，以便将句子分为积极和消极。如果查看最有信息量的那些单词，可以看到例如单词“outstanding”表示积极评论，而“insulting”表示消极评论。这是非常有趣的信息，因为它告诉我们单词可以用来表示情绪。

6.10　用主题建模识别文本的模式

主题建模是指识别文本数据隐藏模式的过程，其目的是发现一组文档的隐藏主题结构。主题建模可以更好地组织文档，以便对这些文档进行分析。主题建模是NLP研究的一个活跃领域，你可以在http://www.cs.columbia.edu/~blei/topicmodeling.html查看更多介绍。本节将用到gensim库，在进行接下来的学习之前，请确保你已经安装了该库，具体安装步骤可以参考https://radimrehurek.com/gensim/install.html。

6.10.1　详细步骤

(1) 创建一个Python文件，导入以下程序包：

from nltk.tokenize import RegexpTokenizer
from nltk.stem.snowball import SnowballStemmer
from gensim import models, corpora
from nltk.corpus import stopwords

(2) 定义一个函数来加载输入数据，本例将用到本书提供的data_topic_modeling.txt文本文件：

# 加载输入数据
def load_data(input_file):
    data = []
    with open(input_file, 'r') as f:
        for line in f.readlines():
            data.append(line[:-1])

    return data

(3) 定义一个预处理文本的类。这个预处理器处理相应的对象，并从输入文本中提取相关的特征：

# 类预处理文本
class Preprocessor(object):
    # 对各种操作进行初始化
    def __init__(self):
        # 创建正则表达式解析器
        self.tokenizer = RegexpTokenizer(r'\w+')

(4) 我们需要一个停用词列表，在分析过程中可以将这些停用词排除。这些停用词都是常用词，例如“in”“the”“is”等：

        # 获取停用词列表
        self.stop_words_english = stopwords.words('english')

(5) 定义一个Snowball词干提取器：

        # 创建Snowball词干提取器
        self.stemmer = SnowballStemmer('english')

(6) 定义一个处理函数，负责标记解析、停用词去除和词干还原：

    # 标记解析、移除停用词、词干提取
    def process(self, input_text):
        # 标记解析
        tokens = self.tokenizer.tokenize(input_text.lower())

(7) 从文本中去除停用词：

        # 移除停用词
        tokens_stopwords = [x for x in tokens if not x in self.stop_words_english]

(8) 对标记做词干提取：

        # 词干提取
        tokens_stemmed = [self.stemmer.stem(x) for x in tokens_stopwords]

(9) 返回处理后的标记：

        return tokens_stemmed

(10) 定义一个main函数。从文本文件中加载输入数据：

if __name__=='__main__':
    # 包含输入数据的文件
    input_file = 'data_topic_modeling.txt'

    # 加载数据
    data = load_data(input_file)

(11) 用我们定义的类定义一个对象：

    # 创建预处理对象
    preprocessor = Preprocessor()

(12) 处理文件中的文本，并提取处理好的标记：

    # 创建一组经过预处理的文档
    processed_tokens = [preprocessor.process(x) for x in data]

(13) 创建一个基于标记文档的词典，用于主题建模：

    # 创建基于标记文档的词典
    dict_tokens = corpora.Dictionary(processed_tokens)

(14) 用处理后的标记创建一个文档-词矩阵：

    # 创建文档-词矩阵
    corpus = [dict_tokens.doc2bow(text) for text in processed_tokens]

(15) 假定文本可以分成两个主题。我们将用隐含狄利克雷分布（Latent Dirichlet Allocation，LDA）做主题建模。定义相关参数并初始化LDA模型对象：

    # 基于刚刚创建的语料库生成LDA模型
    num_topics = 2
    num_words = 4

    ldamodel = models.ldamodel.LdaModel(corpus,
            num_topics=num_topics, id2word=dict_tokens, passes=25)

(16) 识别出两个主题后，可以看到它是如何将两个主题分开来看的：

    print "\nMost contributing words to the topics:"
    for item in ldamodel.print_topics(num_topics=num_topics, num_words=num_words):
        print "\nTopic", item[0], "==>", item[1]

(17) 全部代码已经在topic_modeling.py文件中给出。运行该代码，可以看到命令行工具中的输出结果如图6-11所示。

图　6-11

6.10.2　工作原理

主题建模通过识别文档中最有意义、最能表征主题的词来实现主题分类。这些单词往往可以确定主题的内容。之所以使用正则表达式（regular expression）标记器，是因为只需要那些没有标点或其他标记的单词。停用词去除是另一个非常重要的步骤，因为停用词去除可以减小一些常用词（例如“is”和“the”）的噪声干扰。之后，需要对单词做词干提取，以获得其原形。以上所有步骤均被打包在一个文本分析工具的预处理模块中。在本例的代码中就是这样实现的！

本例用到了隐含狄利克雷分布技术来构建主题模型。隐含狄利克雷分布将文档表示成不同主题的混合，这些主题可以“吐出”单词。这些“吐出”的单词是有一定的概率的。隐含狄利克雷分布的目标是找到这些主题。隐含狄利克雷分布是一个生成主题的模型，该模型试图找到所有主题，而所有主题又负责生成给定主题的文档。你可以在http://blog.echen.me/2011/08/22/introduction-to-latent-dirichlet-allocation中找到更多详细介绍。

你可以在输出结果中看到，诸如单词“talent”和“train”表示运动主题，而单词“encrypt”表示密码主题。这里仅仅试验了非常小的文本文件，在这样小的文本文件中，有一些单词可能看起来不那么相关。显然，如果你用更大的数据集来运行该程序，精确度会更高。

第 7 章　语音识别

在这一章，我们将介绍以下主题：

读取和绘制音频数据

将音频信号转换为频域

自定义参数生成音频信号

合成音乐

提取频域特征

创建隐马尔科夫模型

创建一个语音识别器

7.1　简介

语音识别是指识别和理解口语的过程。输入音频数据，语音识别器将处理这些数据，从中提取出有用的信息。语音识别有很多实际的应用，例如声音控制设备、将语音转换成单词、安全系统等。

自然中的声音信号多种多样。同一种语言中也有很多不同的语音。语音中有很多不同的元素，例如语言、情绪、语调、噪声、口音等，我们很难定义一组构成语音的规则。尽管语音有这么多变量，人类仍然可以很轻松地理解这些。现在，我们希望机器也能以同样的方式理解语音。

在过去的几十年里，研究者们研究了语音的各个方面，例如识别说话者、理解单词、识别口音、翻译语音等。在所有的这些任务中，自动语音识别成为很多研究者重点关注的方向。在这一章中，我们将学习如何构建一个语音识别器。

7.2　读取和绘制音频数据

本节将介绍如何读取音频文件并将该信号进行可视化展现。这是一个好的开始，可以让我们很好地理解音频信号的基本结构。在开始之前，需要理解音频文件是实际音频信号的数字化形式，实际的音频信号是复杂的连续波形。为了将其保存成数字化形式，需要对音频信号进行采样并将其转换成数字。例如，语音通常以44100 Hz的频率进行采样，这就意味着每秒钟信号被分解成 44 100份，然后这些抽样值被保存。换句话说，每隔1/44100 s都会存储一次值。如果采样率很高，用媒体播放器收听音频时，会感觉到信号是连续的。

详细步骤

(1) 创建一个Python文件，并导入以下程序包：

import numpy as np
import matplotlib.pyplot as plt
from scipy.io import wavfile

(2) 使用wavfile包从input_read.wav中读取音频文件：

# 读取输入文件
sampling_freq, audio = wavfile.read('input_read.wav')

(3) 打印这个信号的相关参数：

# 打印参数
print '\nShape:', audio.shape
print 'Datatype:', audio.dtype
print 'Duration:', round(audio.shape[0] / float(sampling_freq), 3), 'seconds'

(4) 该音频信号被存储在一个16位有符号整型数据中。标准化这些值：

# 标准化数值
audio = audio / (2.**15)

(5) 提取前30个值，并将其画出：

# 提取前30个值画图
audio = audio[:30]

(6) X轴为时间轴。创建这个轴，并且X轴应该按照采样频率因子进行缩放：

# 建立时间轴
x_values = np.arange(0, len(audio), 1) / float(sampling_freq)

(7) 将单位转换为秒：

# 将单位转换为秒
x_values *= 1000

(8) 将其画出：

# 画出声音信号图形
plt.plot(x_values, audio, color='black')
plt.xlabel('Time (ms)')
plt.ylabel('Amplitude')
plt.title('Audio signal')
plt.show()

(9) 全部代码已经包含在read_plot.py文件中。运行该代码，可以看到如图7-1所示的信号。

图　7-1

(10) 可以看到终端打印出如图7-2所示的结果。

图　7-2

7.3　将音频信号转换为频域

音频信号是不同频率、幅度和相位的正弦波的复杂混合。正弦波也称作正弦曲线。音频信号的频率内容中隐藏了很多信息。事实上，一个音频信号的性质由其频率内容决定。世界上的语音和音乐都是基于这个事实的。在进行接下来的学习之前，你需要了解一些傅里叶变换（Fourier transforms）的知识，可以在http://www.thefouriertransform.com中找到快速入门介绍。下面来学习如何将音频信号转换为频域。

详细步骤

(1) 创建一个Python文件，并导入以下程序包：

import numpy as np
from scipy.io import wavfile
import matplotlib.pyplot as plt

(2) 读取input_freq.wav音频文件：

# 读取音频文件
sampling_freq, audio = wavfile.read('input_freq.wav')

(3) 对信号进行标准化：

#  对信号进行标准化
audio = audio / (2.**15)

(4) 音频信号就是一个NumPy数组，因此用以下代码提取其长度：

# 提取数组长度
len_audio = len(audio)

(5) 接下来做傅里叶变换。傅里叶变换是关于中心点对称的，因此只需要转换信号的前半部分。我们的最终目标是提取功率信号，因此需要先将信号的值平方：

# 应用傅里叶变换
transformed_signal = np.fft.fft(audio)
half_length = np.ceil((len_audio + 1) / 2.0)
transformed_signal = abs(transformed_signal[0:half_length])
transformed_signal /= float(len_audio)
transformed_signal **= 2

(6) 提取信号的长度：

# 提取转换信号的长度
len_ts = len(transformed_signal)

(7) 根据信号的长度将信号乘以2：

# 将部分信号乘以2
if len_audio % 2:
    transformed_signal[1:len_ts] *= 2
else:
    transformed_signal[1:len_ts-1] *= 2

(8) 功率信号用下面的公式获得：

# 获取功率信号
power = 10 * np.log10(transformed_signal)

(9) X轴是时间轴。接下来需要根据采样频率对其进行缩放，并将其转换成秒：

# 建立时间轴
x_values = np.arange(0, half_length, 1) * (sampling_freq / len_ audio) / 1000.0

(10) 绘制该信号：

# 画图
plt.figure()
plt.plot(x_values, power, color='black')
plt.xlabel('Freq (in kHz)')
plt.ylabel('Power (in dB)')
plt.show()

(11) 全部代码已经包含在freq_transform.py文件中。运行该代码，可以看到如图7-3所示的图像。

图　7-3

7.4　自定义参数生成音频信号

我们可以用NumPy生成音频信号。前面已经说过，音频信号是一些正弦波的复杂混合，下面用该原理生成自己的音频信号。

详细步骤

(1) 创建一个Python文件，并导入以下程序包：

import numpy as np
import matplotlib.pyplot as plt
from scipy.io.wavfile import write

(2) 定义一个输出文件，用于存储生成的音频：

# 定义存储音频的输出文件
output_file = 'output_generated.wav'

(3) 指定音频生成参数。我们希望生成一个3 s长度的信号，采样频率为44100 Hz，音频的频率为587 Hz。时间轴上的值将从-2×pi 到2×pi：

# 指定音频生成的参数
duration = 3    # 单位秒
sampling_freq = 44100   # 单位Hz
tone_freq = 587
min_val = -2 * np.pi
max_val = 2 * np.pi

(4) 生成时间轴和音频信号。音频信号是一个简单的正弦函数，其相关参数之前已做定义：

# 生成音频信号
t = np.linspace(min_val, max_val, duration * sampling_freq)
audio = np.sin(2 * np.pi * tone_freq * t)

(5) 为信号增加一些噪声：

# 增加噪声
noise = 0.4 * np.random.rand(duration * sampling_freq)
audio += noise

(6) 将这些值转换为16位整型数，然后将其保存：

# 转换为16位整型数
scaling_factor = pow(2,15) - 1
audio_normalized = audio / np.max(np.abs(audio))
audio_scaled = np.int16(audio_normalized * scaling_factor)

(7) 将信号写入输出文件：

# 写入输出文件
write(output_file, sampling_freq, audio_scaled)

(8) 用前100个值画出该信号：

# 提取前100个值
audio = audio[:100]

(9) 生成时间轴：

# 生成时间轴
x_values = np.arange(0, len(audio), 1) / float(sampling_freq)

(10) 将时间轴的单位转换为秒：

# 将时间轴的单位转换为秒
x_values *= 1000

(11) 画出该信号：

# 画出音频信号图
plt.plot(x_values, audio, color='black')
plt.xlabel('Time (ms)')
plt.ylabel('Amplitude')
plt.title('Audio signal')
plt.show()

(12) 全部代码已经包含在generate.py文件中。运行该代码，可以看到如图7-4所示的图像。

图　7-4

7.5　合成音乐

现在知道了如何生成音频，下面用同样的原理来合成一些音乐。http://www.phy.mtu.edu/~suits/notefreqs.html列举了各种音阶，例如A、G、D 等，以及它们相应的频率。下面将用它合成简单的音乐。

详细步骤

(1) 创建一个Python文件，并导入以下程序包：

import json
import numpy as np
from scipy.io.wavfile import write
import matplotlib.pyplot as plt

(2) 定义一个函数，该函数基于输入参数合成音调：

# 定义合成音调
def synthesizer(freq, duration, amp=1.0, sampling_freq=44100):

(3) 创建时间轴：

    # 创建时间轴
    t = np.linspace(0, duration, duration * sampling_freq)

(4) 用输入参数构建音频示例，如幅度和频率：

    # 构建音频信号
    audio = amp * np.sin(2 * np.pi * freq * t)

    return audio.astype(np.int16)

(5) 定义main函数。我们提供了文件名为tone_freq_map.json的JSON文件，该文件包括一些音阶以及它们的频率：

if __name__=='__main__':
    tone_map_file = 'tone_freq_map.json'

(6) 加载该文件：

    #读取频率映射文件
    with open(tone_map_file, 'r') as f:
        tone_freq_map = json.loads(f.read())

(7) 假设想生成2秒的G 调：

    # 设置生成G调的输入参数
    tone input_tone = 'G'
    duration = 2    # 单位秒
    amplitude = 10000
    sampling_freq = 44100   # 单位Hz

(8) 用以下参数调用该函数：

    # 生成音阶
    synthesized_tone = synthesizer(tone_freq_map[input_tone], duration, amplitude, sampling_freq)

(9) 将生成信号写入输出文件：

    # 写入输出文件
    write('output_tone.wav', sampling_freq, synthesized_tone)

(10) 用媒体播放器打开文件并试听，它确实是G 调。下面做一些更有趣的事情。生成一系列的音阶，让其有一些音乐的感觉。定义一个音阶及其持续时间（秒）的序列：

    # 音阶及其持续时间
    tone_seq = [('D', 0.3), ('G', 0.6), ('C', 0.5), ('A', 0.3), ('Asharp', 0.7)]

(11) 迭代该序列并为它们调用合成器函数：

    # 构建基于和弦序列的音频信号
    output = np.array([])
    for item in tone_seq:
        input_tone = item[0]
        duration = item[1]
        synthesized_tone = synthesizer(tone_freq_map[input_tone], duration, amplitude, sampling_freq)
        output = np.append(output, synthesized_tone, axis=0)

(12) 将生成信号写入输出文件：

    # 写入输出文件
    write('output_tone_seq.wav', sampling_freq, output)

(13) 全部代码已经包含在synthesize_music.py文件中。用媒体播放器打开output_tone_seq.wav文件并试听，你就可以感受到音乐！

7.6　提取频域特征

前面讨论了如何将信号转换为频域。在多数的现代语音识别系统中，人们都会用到频域特征。将信号转换为频域之后，还需要将其转换成有用的形式。梅尔频率倒谱系数（Mel Frequency Cepstrum Coefficient，MFCC）可以解决这个问题。MFCC首先计算信号的功率谱，然后用滤波器组和离散余弦变换的组合来提取特征。如果需要快速入门，可以查看http://practicalcryptography.com/miscellaneous/machine-learning/guide-mel-frequency-cepstral-coefficients-mfccs。在进行接下来的学习之前，请确保你已经安装了python_speech_features包，安装指南可以参考http://python-speech-features.readthedocs.org/en/latest。接下来介绍如何提取MFCC特征。

详细步骤

(1) 创建一个Python文件，并导入以下程序包：

import numpy as np
import matplotlib.pyplot as plt
from scipy.io import wavfile
from features import mfcc, logfbank

(2) 读取input_freq.wav输入文件：

# 读取输入音频文件
sampling_freq, audio = wavfile.read("input_freq.wav")

(3) 提取MFCC和过滤器组特征：

# 提取MFCC和过滤器组特征
mfcc_features = mfcc(audio, sampling_freq)
filterbank_features = logfbank(audio, sampling_freq)

(4) 打印参数，查看可生成多少个窗体：

# 打印参数
print '\nMFCC:\nNumber of windows =', mfcc_features.shape[0]
print 'Length of each feature =', mfcc_features.shape[1]
print '\nFilter bank:\nNumber of windows =', filterbank_features. shape[0]
print 'Length of each feature =', filterbank_features.shape[1]

(5) 将MFCC特征可视化。转换矩阵，使得时域是水平的：

# 画出特征图
mfcc_features = mfcc_features.T
plt.matshow(mfcc_features)

plt.title('MFCC')

(6) 将滤波器组特征可视化。这里也需要转换矩阵，使得时域是水平的：

filterbank_features = filterbank_features.T
plt.matshow(filterbank_features)
plt.title('Filter bank')

plt.show()

(7) 全部代码已经包含在extract_freq_features.py文件中。运行该代码，可以得到如图7-5所示的MFCC特征图像。

图　7-5

(8) 滤波器组特征图像如图7-6所示。

图　7-6

(9) 终端将输出如图7-7所示的结果。

图　7-7

7.7　创建隐马尔科夫模型

接下来可以讨论语音识别了，本例将用到隐马尔科夫模型（Hidden Markov Models，HMMs）来做语音识别。隐马尔科夫模型非常擅长建立时间序列数据模型。因为一个音频信号同时也是一个时间序列信号，因此隐马尔科夫模型也同样适用于音频信号的处理。假定输出是通过隐藏状态生成的，我们的目标是找到这些隐藏状态，以便对信号建模。你可以在https://www.robots.ox.ac.uk/~vgg/rg/slides/hmm.pdf查看更多关于隐马尔科夫模型的介绍。进行接下来的学习之前，请确保你已经安装了hmmlearn包，安装说明可查看http://hmmlearn.readthedocs.org/en/latest。接下来学习如何创建隐马尔科夫模型。

详细步骤

(1) 创建一个Python文件，定义一个类来创建隐马尔科夫模型：

# 创建类处理HMM相关过程
class HMMTrainer(object):

(2) 初始化该类。下面将用到高斯隐马尔科夫模型（Gaussian HMMs）来对数据建模。参数n_components定义了隐藏状态的个数，参数cov_type定义了转移矩阵的协方差类型，参数n_iter定义了训练的迭代次数：

    def __init__(self, model_name='GaussianHMM', n_components=4, cov_type='diag', n_iter=1000):

这些参数的选定取决于你的需求。只有正确地理解这些参数的含义，才能灵活地进行运用。

(3) 初始化变量：

        self.model_name = model_name
        self.n_components = n_components
        self.cov_type = cov_type
        self.n_iter = n_iter
        self.models = []

(4) 用以下参数定义模型：

        if self.model_name == 'GaussianHMM':
            self.model = hmm.GaussianHMM(n_components=self.n_components,
                    covariance_type=self.cov_type, n_iter=self.n_iter)
        else:
            raise TypeError('Invalid model type')

(5) 输入数据是一个NumPy数组，数组的每个元素都是一个特征向量，每个特征向量都包含k 个维度：

    # X是二维数组，其中每一行是13维
    def train(self, X):
        np.seterr(all='ignore')
        self.models.append(self.model.fit(X))

(6) 基于该模型定义一个提取分数的方法：

    # 对输入数据运行模型
    def get_score(self, input_data):
        return self.model.score(input_data)

(7) 前面创建了一个类来处理隐马尔科夫模型的训练和预测，但是还需要一些数据来观察它的运行情况。下一节将创建一个语音识别器，全部代码包含在speech_recognizer.py文件中。

7.8　创建一个语音识别器

本节需要一个语音文件数据库来创建语音识别器，用到的数据库文件保存在https://code.google.com/archive/p/hmm-speech-recognition/downloads中。其中包含7个不同的单词，并且每个单词都有15个音频文件与之相关。这是一个较小的数据集，但是足够我们理解如何创建一个语音识别器并识别7个不同的单词。我们需要为每一类构建一个隐马尔科夫模型。如果想识别新的输入文件中的单词，需要对该文件运行所有的模型，并找出最佳分数的结果。下面将用到在前一节构建的隐马尔科夫类。

详细步骤

(1) 创建一个Python文件，并导入以下程序包：

import os
import argparse

import numpy as np
from scipy.io import wavfile
from hmmlearn import hmm
from features import mfcc

(2) 定义一个函数来解析命令行中的输入参数：

# 解析输入参数的函数
def build_arg_parser():
    parser = argparse.ArgumentParser(description='Trains the HMM classifier')
    parser.add_argument("--input-folder", dest="input_folder", required=True,
            help="Input folder containing the audio files insubfolders")
    return parser

(3) 定义main函数，解析输入参数：

if __name__=='__main__':
    args = build_arg_parser().parse_args()
    input_folder = args.input_folder

(4) 初始化隐马尔科夫模型的变量：

    hmm_models = []

(5) 解析包含所有数据库音频文件的输入路径：

    # 解析输入路径
    for dirname in os.listdir(input_folder):

(6) 提取子文件夹的名称：

        # 获取子文件夹名称
        subfolder = os.path.join(input_folder, dirname)

        if not os.path.isdir(subfolder):
            continue

(7) 子文件夹的名称即为该类的标记。用以下方式将其提取出来：

        # 提取标记
        label = subfolder[subfolder.rfind('/') + 1:]

(8) 初始化用于训练的变量：

        # 初始化变量
        X = np.array([])
        y_words = []

(9) 迭代每一个子文件夹中的音频文件：

        # 迭代所有音频文件（分别保留一个进行测试）
        for filename in [x for x in os.listdir(subfolder) if x.endswith('.wav')][:-1]:

(10) 读取每个音频文件：

            # 读取每个音频文件
            filepath = os.path.join(subfolder, filename)
            sampling_freq, audio = wavfile.read(filepath)

(11) 提取MFCC特征：

            # 提取MFCC特征
            mfcc_features = mfcc(audio, sampling_freq)

(12) 将MFCC特征添加到X变量：

            # 将MFCC特征添加到X变量
            if len(X) == 0:
                X = mfcc_features
            else:
                X = np.append(X, mfcc_features, axis=0)

(13) 同时添加标记信息：

            # 添加标记
            y_words.append(label)

(14) 一旦提取完当前类所有文件的特征，就可以训练并保存隐马尔科夫模型了。因为隐马尔科夫模型是一个无监督学习的生成模型，所以并不需要利用标记针对每一类构建隐马尔科夫模型。假定每个类都将构建一个隐马尔科夫模型：

        # 训练并保存HMM模型
        hmm_trainer = HMMTrainer()
        hmm_trainer.train(X)
        hmm_models.append((hmm_trainer, label))
        hmm_trainer = None

(15) 获取一个未被用于训练的测试文件列表：

    # 测试文件
    input_files = [
            'data/pineapple/pineapple15.wav',
            'data/orange/orange15.wav',
            'data/apple/apple15.wav',
            'data/kiwi/kiwi15.wav'
            ]

(16) 解析输入文件：

    #为输入数据分类
    for input_file in input_files:

(17) 读取每个音频文件：

        # 读取每个音频文件
        sampling_freq, audio = wavfile.read(input_file)

(18) 提取MFCC特征：

        # 提取MFCC特征
        mfcc_features = mfcc(audio, sampling_freq)

(19) 定义两个变量，分别用于存放最大分数值和输出标记：

        # 定义变量
        max_score = None
        output_label = None

(20) 迭代所有模型，并通过每个模型运行输入文件：

        # 迭代HMM模型并选取得分最高的模型
        for item in hmm_models:
            hmm_model, label = item

(21) 提取分数，并保存最大分数值：

            score = hmm_model.get_score(mfcc_features)
            if score > max_score:
                max_score = score
                output_label = label

(22) 打印真实的、预测的标记：

        # 打印结果
        print "\nTrue:", input_file[input_file.find('/')+1:input_ file.rfind('/')]
        print "Predicted:", output_label

(23) 全部代码已经包含在speech_recognizer.py文件中。运行该代码，可以在终端看到如图7-8所示的显示结果。

图　7-8

第 8 章　解剖时间序列和时序数据

在这一章，我们将介绍以下主题：

将数据转换为时间序列格式

切分时间序列数据

操作时间序列数据

从时间序列数据中提取统计数字

针对序列数据创建隐马尔科夫模型

针对序列文本数据创建条件随机场

用隐马尔科夫模型分析股票市场数据

8.1　简介

时间序列数据就是随着时间的变化收集的测量序列数据。这些数据是根据预定义的变量并在固定的间隔时间采集的。时间序列数据最主要的特征就是其顺序是非常关键的。

我们收集的数据是按照时间轴排序的，它们的出现顺序包含很多隐藏的模式和信息。如果改变顺序，则将彻底改变数据的含义。序列数据的广义概念是指任意序列形式的数据，包括时间序列数据。

我们的目标是构建一个模型，该模型描述了时间序列或任意序列的模式，用于描述时间序列模式的重要特征。可以用这些模型解释过去可能会影响到未来，查看两个数据集是如何相互关联的，如何预测未来可能的值，或者如何控制基于某个度量标准的给定变量。

为了将时间序列数据可视化，我们倾向于将其用折线图或柱状图画出。时间序列数据分析常用于金融、信号处理、天气预测、轨道预测、地震预测或者任意需要处理时间数据的场合。我们在时间序列和顺序数据分析中构建的模型应该考虑数据的顺序，并提取相互之间的关系。接下来分析Python中的时间序列和顺序数据。

8.2　将数据转换为时间序列格式

下面以理解如何将一系列观察结果转换为时间序列数据并将其可视化作为本章的开始。本例将用到pandas库来分析时间序列数据。在进行接下来的学习之前，请确保已经安装好了pandas库，安装方法可以参考http://pandas.pydata.org/pandas-docs/stable/install.htm。

详细步骤

(1) 创建一个新的Python文件，并导入以下程序包：

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

(2) 定义一个函数来读取输入文件，该文件将序列观察结果转换为时间序列数据：

def convert_data_to_timeseries(input_file, column, verbose=False):

(3) 这里将用到一个包含4列的文本文件，其中第一列表示年，第二列表示月，第三列和第四列表示数据。将文件加载到NumPy数组：

    # 加载输入文件
    data = np.loadtxt(input_file, delimiter=',')

(4) 因为数据是按时间的前后顺序排列的，数据的第一行是起始日期，而数据的最后一行是终止日期。下面提取出数据集的起始日期和终止日期：

    # 提取起始日期和终止日期
    start_date = str(int(data[0,0])) + '-' + str(int(data[0,1]))
    end_date = str(int(data[-1,0] + 1)) + '-' + str(int(data[-1,1]% 12 + 1))

(5) 这个函数还有一个详细的版本。因此，当这个值为真时，就打印一些信息。打印出起始日期和终止日期：

    if verbose:
       print "\nStart date =", start_date
       print "End date =", end_date

(6) 创建一个pandas变量，该变量包含了以月为间隔的日期序列：

    # 创建以月为间隔的变量
    dates = pd.date_range(start_date, end_date, freq='M')

(7) 下一步是将给定的列转换为时间序列数据。可以用年和月访问这些数据（而不是索引）：

    # 将日期转换成时间序列
    data_timeseries = pd.Series(data[:,column], index=dates)

(8) 打印出最开始的10个元素：

    if verbose:
        print "\nTime series data:\n", data_timeseries[:10]

(9) 返回时间索引变量：

    return data_timeseries

(10) 定义main函数：

if __name__=='__main__':

(11) 本例将使用本书提供的data_timeseries.txt文件：

    # 输入数据文件
    input_file = 'data_timeseries.txt'

(12) 加载文本文件的第三列，并将其转换为时间序列数据：

    # 加载输入数据
    column_num = 2
    data_timeseries = convert_data_to_timeseries(input_file, column_num)

(13) pandas库提供了非常实用的画图功能，你可以直接在变量上运行：

    # 画出数据序列数据
    data_timeseries.plot()
    plt.title('Input data')

    plt.show()

(14) 全部代码已经包含在convert_to_timeseries.py文件中。运行该代码，可以看到如图8-1所示的图像。

图　8-1

8.3　切分时间序列数据

这一节将介绍如何用pandas切分时间序列数据，帮助你从时间序列数据的各个阶段获得相应的信息。接下来将学习如何用日期处理数据集的子集。

详细步骤

(1) 创建一个新的Python文件，并导入以下程序包：

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from convert_to_timeseries import convert_data_to_timeseries

(2) 使用前一节中用到的文本文件，对该文本文件中的数据进行切分：

# 输入数据文件
input_file = 'data_timeseries.txt'

(3) 又一次用到第三列数据：

# 加载数据
column_num = 2
data_timeseries = convert_data_to_timeseries(input_file, column_ num)

(4) 假定我们希望提取给定的起始年份和终止年份之间的数据，下面做如下定义：

# 确定画图起止年份
start = '2008'
end = '2015'

(5) 画出给定年份范围内的数据：

plt.figure()
data_timeseries[start:end].plot()
plt.title('Data from ' + start + ' to ' + end)

(6) 还可以在给定月份范围内切分数据：

# 画图起止年月
start = '2007-2'
end = '2007-11'

(7) 画出数据：

plt.figure()
data_timeseries[start:end].plot()
plt.title('Data from ' + start + ' to ' + end)

plt.show()

(8) 全部代码已经包含在slicing_data.py文件中。运行该代码，可以看到如图8-2所示的图像。

图　8-2

(9) 如图8-3所示的图像展示了更小时间范围内的数据，因此它看起来像是做了放大处理。

图　8-3

8.4　操作时间序列数据

现在我们知道如何切分数据并抽取各种子数据集了，接下来介绍如何操作时间序列数据。你可以用各种不同的方式过滤数据。pandas库提供了各种操作时间序列数据的方式。

详细步骤

(1) 创建一个新的Python文件，并导入以下程序包：

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from convert_to_timeseries import convert_data_to_timeseries

(2) 使用上一节用到的文本文件：

# 输入数据文件
input_file = 'data_timeseries.txt'

(3) 将用到第三列和第四列数据：

# 加载数据
data1 = convert_data_to_timeseries(input_file, 2)
data2 = convert_data_to_timeseries(input_file, 3)

(4) 将数据转化为pandas的数据帧：

dataframe = pd.DataFrame({'first': data1, 'second': data2})

(5) 画出给定年份范围内的数据：

# 画图
dataframe['1952':'1955'].plot()
plt.title('Data overlapped on top of each other')

(6) 假定我们希望画出在给定年份范围内刚才加载的两列数据的不同，可以用以下方式实现：

# 画出两组数据的不同
plt.figure()
difference = dataframe['1952':'1955']['first'] - dataframe['1952':'1955']['second']
difference.plot()
plt.title('Difference (first - second)')

(7) 如果希望对第一列和第二列用不同的条件来过滤数据，可以指定这些条件并将其画出：

# 当“first”大于某个阈值且“second”小于某个阈值时
dataframe[(dataframe['first'] > 60) & (dataframe['second'] < 20)]. plot()
plt.title('first > 60 and second < 20')

plt.show()

(8) 全部代码已经包含在operating_on_data.py文件中。运行该代码，可以看到如图8-4所示的第一幅图像。

图　8-4

(9) 如图8-5所示的第二幅图像表示出不同之处。

图　8-5

(10) 如图8-6所示的第三幅图像表示过滤数据。

图　8-6

8.5　从时间序列数据中提取统计数字

分析时间序列数据的主要原因之一是从中提取出有趣的统计信息。考虑数据的本质，时间序列分析可以提供很多信息。本节将介绍如何提取这些统计信息。

详细步骤

(1) 创建一个新的Python文件，并导入以下程序包：

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from convert_to_timeseries import convert_data_to_timeseries

(2) 用到前一节用到的文本文件：

# 输入数据文件
input_file = 'data_timeseries.txt'

(3) 加载第三列和第四列数据：

# 加载数据
data1 = convert_data_to_timeseries(input_file, 2)
data2 = convert_data_to_timeseries(input_file, 3)

(4) 创建一个pandas数据结构来保存这些数据，这个数据看着比较像词典，它有对应的键和值：

dataframe = pd.DataFrame({'first': data1, 'second': data2})

(5) 接下来提取一些统计数据。用以下代码提取最大值和最小值：

#打印最大值和最小值
print '\nMaximum:\n', dataframe.max()
print '\nMinimum:\n', dataframe.min()

(6) 打印数据的均值或者是每行的均值：

# 打印均值
print '\nMean:\n', dataframe.mean()
print '\nMean row-wise:\n', dataframe.mean(1)[:10]

(7) 滑动均值是在时间序列分析中较常用的统计。其最著名的应用之一是平滑信号以去除噪声。滑动均值是指计算一个窗口范围内的信号均值，并不断地移动时间窗。这里用到的窗口大小为24：

# 打印滑动均值
pd.rolling_mean(dataframe, window=24).plot()

(8) 相关性系数对于理解数据的本质来说非常有用：

# 打印相关性系数
print '\nCorrelation coefficients:\n', dataframe.corr()

(9) 用大小为60的窗口将其画出：

# 画出滑动相关性
plt.figure()
pd.rolling_corr(dataframe['first'], dataframe['second'], window=60).plot()

plt.show()

(10) 全部代码已经包含在extract_stats.py文件中。运行该代码，可以看到画出的滑动均值如图8-7所示。

图　8-7

(11) 第二幅图像表示滑动相关性，如图8-8所示。

图　8-8

(12) 在命令行工具输出的上半部分，可以看到最大值、最小值和均值，如图8-9所示。

图　8-9

(13) 在命令行工具输出的下半部分，可以看到每行的均值和相关系数，如图8-10所示。

图　8-10

8.6　针对序列数据创建隐马尔科夫模型

隐马尔科夫模型（HMMs）是处理序列数据非常强大的方法，广泛应用于金融、语音分析、天气预测、单词序列等领域。我们往往对发现随时间变化的隐藏模式非常感兴趣。

任何产生输出序列的数据源均可以产生模式。请注意，HMMs是一个生成模型，这也就意味着一旦掌握了其底层结构，就可以产生数据。HMMs并不能对基础形式的类进行区分，这与那些可以做类区分的判定模型形成鲜明的对比，但是这些可以做类区分的判定模型却不能生成数据。

8.6.1　准备工作

例如，我们希望预测明天的天气是晴天、阴天或下雨。为了实现预测，需要查看所有的参数，例如温度、气压等，而潜在的状态是隐藏的。这里，潜在的状态是指3个可选状态：晴天、阴天或下雨。如果希望了解更多HMMs的详细介绍，可以在https://www.robots.ox.ac.uk/~vgg/rg/slides/hmm.pdf找到相关信息。

本例将用到hmmlearn来创建和训练HMMs，在进行接下来的学习之前，请确保你已经安装了hmmlearn，安装说明请查看http://hmmlearn.readthedocs.org/en/latest。

8.6.2　详细步骤

(1) 创建一个新的Python文件，并导入以下程序包：

import datetime

import numpy as np
import matplotlib.pyplot as plt
from hmmlearn.hmm import GaussianHMM

from convert_to_timeseries import convert_data_to_timeseries

(2) 本例将用到提供的data_hmm.txt文件。该文件包括带逗号分隔符的行。每行包括3个值：一个年份、一个月份和一个浮点型数据。下面将它加载到一个NumPy数组中：

# 从输入文件加载数据
input_file = 'data_hmm.txt'
data = np.loadtxt(input_file, delimiter=',')

(3) 将数据按照列的方向堆叠起来用于分析。我们并不需要在技术上做列堆叠，因为只有一个列，但如果你有多于一个列要进行分析，那么可以用下面的代码实现：

# 排列训练数据
X = np.column_stack([data[:,2]])

(4) 用4个成分创建并训练HMM。成分的个数是一个需要进行选择的超参数。这里选择4个成分，也就意味着用4个潜在状态生成数据。接下来看看这个参数的性能如何变化：

# 创建并训练高斯HMM模型
print "\nTraining HMM...."
num_components = 4
model = GaussianHMM(n_components=num_components, covariance_ type="diag", n_iter=1000)
model.fit(X)

(5) 运行预测器以获得隐藏状态：

# 预测HMM的隐藏状态
hidden_states = model.predict(X)

(6) 计算这些隐藏状态的均值和方差：

print "\nMeans and variances of hidden states:"
for i in range(model.n_components):
    print "\nHidden state", i+1
    print "Mean =", round(model.means_[i][0], 3)
    print "Variance =", round(np.diag(model.covars_[i])[0], 3)

(7) 正如前面所述，HMM是一个生成模型，因此这里生成1000个示例数据并将其画出：

# 用模型生成数据
num_samples = 1000
samples, _ = model.sample(num_samples)
plt.plot(np.arange(num_samples), samples[:,0], c='black')
plt.title('Number of components = ' + str(num_components))

plt.show()

(8) 全部代码已经包含在hmm.py文件中。运行该代码，可以看到如图8-11所示的图像。

图　8-11

(9) 可以用n_components参数进行试验，看看随着其值的增加，曲线会变得更好，也可以通过设定更多的隐藏状态来训练和自定义模型。如果将这个参数设为8，其结果如图8-12所示。

图　8-12

(10) 如果将它增加到12，图像会变得更平滑，如图8-13所示。

图　8-13

(11) 在命令行工具中，可以看到如图8-14所示的输出结果。

图　8-14

8.7　针对序列文本数据创建条件随机场

条件随机场（Conditional Random Fields，CRFs）是一个概率模型，该模型用于分析结构化数据。条件随机场常用于标记和分段序列数据。条件随机场与隐马尔科夫模型相反，它是一个判定模型，而隐马尔科夫模型是一个生成模型。条件随机场用于分析序列、股票、语音、单词等。在这些模型中，给定一个带标签的观察序列，对这个序列定义一个条件随机分布。这与隐马尔科夫模型相反，隐马尔科夫模型定义的是对标签和观察序列的联合分布。

8.7.1　准备工作

隐马尔科夫模型假设当前的输出是与之前的输出独立统计的。这是隐马尔科夫模型所需要的，以确保该假设能够以一种健壮的方式工作。然而，这个假设并不总是成立。时间序列中的当前输出往往取决于之前的输出。条件随机场模型优于隐马尔科夫模型的一点在于它们是由自然条件决定的，也就是说，条件随机场模型并不假设输出观察值之间的独立性。不仅如此，条件随机场还有一些优于隐马尔科夫模型的地方。条件随机场模型在诸如语言学、生物信息学、语音分析等领域的应用都优于隐马尔科夫模型。这一节将介绍如何用条件随机场模型分析字母序列。

本例将用到pystruct库来构造和训练条件随机场。在进行接下来的学习之前，请确保你已经安装了它，安装说明可查看https://pystruct.github.io/installation.html。

8.7.2　详细步骤

(1) 创建一个新的Python文件，并导入以下程序包：

import os
import argparse
import cPickle as pickle

import numpy as np
import matplotlib.pyplot as plt
from pystruct.datasets import load_letters
from pystruct.models import ChainCRF
from pystruct.learners import FrankWolfeSSVM

(2) 定义一个参数解析器，并将C值作为输入参数。C是一个超参数，该参数控制你想要的模型的具体程度，而不会失去一般化的能力：

def build_arg_parser():
    parser = argparse.ArgumentParser(description='Trains the CRF classifier')
    parser.add_argument("--c-value", dest="c_value", required=False, type=float,
            default=1.0, help="The C value that will be used for training")
    return parser

(3) 定义一个处理所有与CRF相关处理的类：

class CRFTrainer(object):

(4) 定义一个init函数并初始化其值：

    def __init__(self, c_value, classifier_name='ChainCRF'):
        self.c_value = c_value
        self.classifier_name = classifier_name

(5) 下面将用链式条件随机场来分析数据。这里需要加上一个错误检查：

        if self.classifier_name == 'ChainCRF':
            model = ChainCRF()

(6) 定义一个在条件随机场模型中需要用到的分类器。这里用支持向量机（Support Vector Machine）来实现：

            self.clf = FrankWolfeSSVM(model=model, C=self.c_value, max_iter=50)
        else:
            raise TypeError('Invalid classifier type')

(7) 加载字母数据集。这个数据集包括分割的字母以及和其相关的特征向量。因为已经有了特征向量，所以不需要分析图像。每个单词的首字母都已被去掉，所以剩下的字母都是小写字母：

    def load_data(self):
        letters = load_letters()

(8) 加载数据和标签到其相应的变量：

        X, y, folds = letters['data'], letters['labels'], letters['folds']
        X, y = np.array(X), np.array(y)
        return X, y, folds

(9) 定义一个训练方法：

    # X是由样本组成一个numpy数组，每个样本的维度是(字母, 数值)
    def train(self, X_train, y_train):
        self.clf.fit(X_train, y_train)

(10) 定义一个方法来评价模型的性能：

    def evaluate(self, X_test, y_test):
        return self.clf.score(X_test, y_test)

(11) 定义一个方法，将新数据进行分类：

    # 对输入数据运行分类器
    def classify(self, input_data):
        return self.clf.predict(input_data)[0]

(12) 这些字母在编号的数组中被索引。为了检查输出并将其变得可读，需要将这些数字转换为字母。下面定义一个函数来执行这样的转换：

def decoder(arr):

    alphabets = 'abcdefghijklmnopqrstuvwxyz'
    output = ''
    for i in arr:
        output += alphabets[i]

    return output

(13) 定义main函数并解析输入参数：

if __name__=='__main__':
    args = build_arg_parser().parse_args()
    c_value = args.c_value

(14) 用类和C值初始化变量：

    crf = CRFTrainer(c_value)

(15) 加载字母数据：

    X, y, folds = crf.load_data()

(16) 将数据分成训练数据集和测试数据集：

    X_train, X_test = X[folds == 1], X[folds != 1]
    y_train, y_test = y[folds == 1], y[folds != 1]

(17) 训练CRF模型：

    print "\nTraining the CRF model..."
    crf.train(X_train, y_train)

(18) 评价CRF模型的性能：

    score = crf.evaluate(X_test, y_test)
    print "\nAccuracy score =", str(round(score*100, 2)) + '%'

(19) 输入一个随机测试向量，并用这个模型预测输出：

    print "\nTrue label =", decoder(y_test[0])
    predicted_output = crf.classify([X_test[0]])
    print "Predicted output =", decoder(predicted_output)

(20) 全部代码已经包含在crf.py文件中。运行该代码，可以看到在命令行工具中显示如图8-15所示的输出结果。正如你看到的，这个单词应该是“commanding”，而条件随机场模型很好地预测了这个结果。

图　8-15

8.8　用隐马尔科夫模型分析股票市场数据

本节将用隐马尔科夫模型来预测股票数据。股票市场数据是典型的时间序列数据示例，其数据都是用日期格式来组织的。在即将用到的数据集中，可以看到各个公司的股票数据随时间的波动情况。隐马尔科夫模型是生成模型，可用于分析这样的时间序列数据。这一节将用这些模型分析股票价格。

详细步骤

(1) 创建一个新的Python文件，并导入以下程序包：

import datetime

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.finance import quotes_historical_yahoo_ochl
from hmmlearn.hmm import GaussianHMM

(2) 从雅虎财经（Yahoo finance）获取股票报价。在matplotlib库中有一个函数可以直接加载：

# 从雅虎财经获取股票报价
quotes = quotes_historical_yahoo_ochl("INTC", datetime.date(1994, 4, 5),
         datetime.date(2015, 7, 3))

(3) 每个报价包含6个值。下面提取相关数据，如股票的收盘价和一定时期内股票的成交量：

# 提取需要的数值
dates = np.array([quote[0] for quote in quotes], dtype=np.int)
closing_values = np.array([quote[2] for quote in quotes])
volume_of_shares = np.array([quote[5] for quote in quotes])[1:]

(4) 计算每天收盘价的变化率，用这个变化率作为一个特征：

# 计算每天收盘价的变化率
diff_percentage = 100.0 * np.diff(closing_values) / closing_ values[:-1]

dates = dates[1:]

(5) 将两个数组进行列堆叠，以用作训练：

# 将变化率与交易量组合起来
X = np.column_stack([diff_percentage, volume_of_shares])

(6) 用5个成分训练隐马尔科夫模型：

# 创建并训练高斯HMM模型
print "\nTraining HMM...."
model = GaussianHMM(n_components=5, covariance_type="diag", n_ iter=1000)

model.fit(X)

(7) 生成500个示例数据用于训练隐马尔科夫模型，并将其画出：

# 用模型生成数据
num_samples = 500
samples, _ = model.sample(num_samples)
plt.plot(np.arange(num_samples), samples[:,0], c='black')

plt.show()

(8) 全部代码已经包含在hmm_stock.py文件中。运行该代码，可以看到如图8-16所示的图像。

图　8-16

第 9 章　图像内容分析

在这一章，我们将介绍以下主题：

用OpenCV-Pyhon操作图像

检测边

直方图均衡化

检测棱角

检测SIFT特征点

创建Star特征检测器

利用视觉单词码本和向量量化创建特征

用极端随机森林训练图像分类器

创建一个对象识别器

9.1　简介

计算机视觉是一个研究如何处理、分析和理解视觉数据内容的领域。在图像内容分析中，会用到很多计算机视觉算法来构建我们对图像对象的理解。计算机视觉包括很多方面的图像分析，例如目标识别、形状分析、姿态估计、3D建模、视觉搜索等。人类非常擅长鉴定和识别其周边的事物，而计算机视觉的终极目标就是用计算机准确地模拟人类的视觉系统。

计算机视觉包括多个级别的分析。在低级视觉分析领域，计算机视觉可以进行像素处理，例如边检测、形态处理和光流。在中级和高级视觉分析领域，计算机视觉可以处理事物，例如物体识别、3D建模、运动分析以及其他方面的视觉数据。随着分析层次的深入，我们会对视觉系统的各个概念钻研得更加深入，并基于活动和意图提取出对视觉数据的描述。值得注意的一点是，高层次的分析往往依赖低层次分析的输出结果。

关于计算机视觉最常见的一个问题是“计算机视觉与图像处理有什么不同”。图像处理是在像素级别对图像进行变换。图像处理系统的输入和输出都是图像，常用的图像处理有边检测、直方图均衡化或图像压缩。计算机视觉算法大量依赖了图像处理算法来执行其任务。在计算机视觉领域，我们还处理更复杂的事情，例如在概念层级理解视觉数据，期望借此帮助自己构建对图像对象更有意义的描述。计算机视觉系统的输出是给定图像的3D场景的描述，这样的描述可以是各种形式的，而这取决于你的需要。

这一章将用到OpenCV库来分析图像。OpenCV是世界上最受欢迎的计算机视觉库。由于OpenCV已经为各种不同的平台进行了高度优化，它已然成为业界的事实标准。在继续学习接下来的内容之前，请确保在Python的支持下安装了这个库。你可以在http://opencv.org下载并安装OpenCV。有关各种操作系统的详细安装说明，可以参考网站的文档部分。

9.2　用OpenCV-Pyhon操作图像

下面看看如何用OpenCV-Python操作图像。这一节将介绍如何加载并展示图像，并介绍如何裁剪、调整大小以及将图像保存到输出文件中。

详细步骤

(1) 创建一个Python文件，并导入以下程序包：

import sys

import cv2
import numpy as np

(2) 指定输入图像为文件的第一个参数，并使用图像读取函数来读取参数。这个例子中将用到forest.jpg：

# 加载并显示图像forest.jpg
input_file = sys.argv[1]
img = cv2.imread(input_file)

(3) 显示输入图像：

cv2.imshow('Original', img)

(4) 现在裁剪该图像。提取输入图像的高度和宽度，然后指定边界：

# 裁剪图像
h, w = img.shape[:2]
start_row, end_row = int(0.21*h), int(0.73*h)
start_col, end_col= int(0.37*w), int(0.92*w)

(5) 用NumPy式的切分方式裁剪图像，并将其展示出来：

img_cropped = img[start_row:end_row, start_col:end_col]
cv2.imshow('Cropped', img_cropped)

(6) 将图像大小调整为其原始大小的1.3倍，并将其展示出来：

# 调整图像大小
scaling_factor = 1.3
img_scaled = cv2.resize(img, None, fx=scaling_factor, fy=scaling_ factor,
interpolation=cv2.INTER_LINEAR)
cv2.imshow('Uniform resizing', img_scaled)

(7) 之前的方法将均匀地在两个维度上扩展图像。假定我们希望仅在某一个维度进行调整，可以用以下代码实现：

img_scaled = cv2.resize(img, (250, 400), interpolation=cv2.INTER_ AREA)
cv2.imshow('Skewed resizing', img_scaled)

(8) 将图像保存到输出文件：

# 保存图像
output_file = input_file[:-4] + '_cropped.jpg'
cv2.imwrite(output_file, img_cropped)

cv2.waitKey()

(9) waitKey函数保持显示图像，直到按下键盘上的任一个按键。

(10) 全部代码已经在operating_on_images.py文件中给出。运行该代码，可以看到如图9-1所示的输入图像。

图　9-1

(11) 第二幅输出图像是裁剪后的图像，如图9-2所示。

图　9-2

(12) 第三幅图像是从两个维度均匀地调整大小后的图像，如图9-3所示。

图　9-3

(13) 第四幅图像是仅从一个维度调整大小后的图像，如图9-4所示。

图　9-4

9.3　检测边

边检测是计算机视觉中最常用到的技术之一，常用在很多应用的预处理过程中。接下来介绍如何用不同的边检测器检测输入图像的边。

详细步骤

(1) 创建一个Python文件，并导入以下程序包：

import sys

import cv2
import numpy as np

(2) 加载输入图像，本例用到chair.jpg：

# 加载输入图像chair.jpg，转换成灰度图
input_file = sys.argv[1]
img = cv2.imread(input_file, cv2.IMREAD_GRAYSCALE)

(3) 提取输入图像的高度和宽度：

h, w = img.shape

(4) 索贝尔滤波器（Sobel filter）是一种边检测器，它使用3×3内核来检测水平边和垂直边。你可以在http://www.tutorialspoint.com/dip/sobel_operator.htm查看到更多索贝尔滤波器的信息。先运行索贝尔水平检测器：

sobel_horizontal = cv2.Sobel(img, cv2.CV_64F, 1, 0, ksize=5)

(5) 运行索贝尔垂直检测器：

sobel_vertical = cv2.Sobel(img, cv2.CV_64F, 0, 1, ksize=5)

(6) 拉普拉斯边检测器（Laplacian edge detector）可以检测两个方向上的边。你可以在http://homepages.inf.ed.ac.uk/rbf/HIPR2/log.htm找到更多相关介绍。定义拉普拉斯边检测器如下：

laplacian = cv2.Laplacian(img, cv2.CV_64F)

(7) 尽管拉普拉斯边检测器弥补了索贝尔边检测器的不足，但是拉普拉斯边检测器的输出仍然带有很多噪声。Canny边检测器（Canny edge detector）在解决噪声问题方面优于拉普拉斯边检测器和索贝尔边检测器。Canny边检测器是一个分阶段的处理过程，它用到了迟滞性来做边数据清理。你可以在http://homepages.inf.ed.ac.uk/rbf/HIPR2/canny.htm了解更多相关细节：

canny = cv2.Canny(img, 50, 240)

(8) 显示所有的输出图像：

cv2.imshow('Original', img)
cv2.imshow('Sobel horizontal', sobel_horizontal)
cv2.imshow('Sobel vertical', sobel_vertical)
cv2.imshow('Laplacian', laplacian)
cv2.imshow('Canny', canny)

cv2.waitKey()

(9) 全部代码已经在edge_detector.py文件中给出。运行该代码，可以看到原始输入图像如图9-5所示。

图　9-5

(10) 如图9-6所示是索贝尔水平边检测器的输出。注意，它到检测到的边大致都是垂直的，这是因为它是一个水平边检测器，它能检测出在水平方向上的变化。

图　9-6

(11) 索贝尔垂直边检测器的输出如图9-7所示。

图　9-7

(12) 如图9-8所示是拉普拉斯边检测器的输出。

图　9-8

(13) Canny边检测器较好地检测出了所有的边，如图9-9所示。

图　9-9

9.4　直方图均衡化

直方图均衡化是指修改图像的像素以增强图像的对比强度的过程。人的眼睛喜欢对比，这也是为什么几乎所有的照相机系统都会用直方图均衡化来使图像更好看。有趣的是，直方图均衡化过程不同于彩色图像的灰度化过程。在处理彩色图像时有一个问题，在这一节的介绍中将会提到。接下来具体介绍如何实现直方图均衡化。

详细步骤

(1) 创建一个Python文件，并导入以下程序包：

import sys

import cv2
import numpy as np

(2) 加载输入图像。这个例子将用到sunrise.jpg：

# 加载输入图像sunrise.jpg
input_file = sys.argv[1]
img = cv2.imread(input_file)

(3) 将图像转为灰度并将其显示出来：

# 转化为灰度图
img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
cv2.imshow('Input grayscale image', img_gray)

(4) 均衡灰度图像的直方图，并将其显示出来：

# 均衡直方图
img_gray_histeq = cv2.equalizeHist(img_gray)
cv2.imshow('Histogram equalized - grayscale', img_gray_histeq)

(5) 为了均衡彩色图像的直方图，需要用到不同于以上的步骤。直方图均衡化仅适用于亮度通道。一个RGB图像由3个颜色通道组成，因此不能对这些通道单独地做直方图均衡化。在做其他操作之前，需要将强度信息从颜色信息中分离出来。因此，首先将其转换到YUV色彩空间，均衡Y通道，然后将其转换回RGB并得到输出。更多关于YUV色彩空间的详细介绍可查看http://softpixel.com/~cwright/programming/colorspace/yuv。OpenCV默认用BGR格式加载图像，因此需要先将其从BGR转化为YUV：

# 均衡彩色图像的直方图
img_yuv = cv2.cvtColor(img, cv2.COLOR_BGR2YUV)

(6) 均衡Y通道：

img_yuv[:,:,0] = cv2.equalizeHist(img_yuv[:,:,0])

(7) 将其转换回BGR：

img_histeq = cv2.cvtColor(img_yuv, cv2.COLOR_YUV2BGR)

(8) 显示输入和输出图像：

cv2.imshow('Input color image', img)
cv2.imshow('Histogram equalized - color', img_histeq)

cv2.waitKey()

(9) 全部代码在已经histogram_equalizer.py文件中给出，运行该代码，可以看到原始输入图像如图9-10所示。

图　9-10

(10) 直方图均衡化处理后的图像如图9-11所示。

图　9-11

9.5　检测棱角

棱角检测是计算机视觉中的一个重要环节，它帮助我们识别图像中突出的点。这是用于开发图像分析系统中最早期的特征提取技术之一。

详细步骤

(1) 创建一个Python文件，并导入以下程序包：

import sys

import cv2
import numpy as np

(2) 加载输入图像。本例将用到box.png：

# 加载图像box.png
input_file = sys.argv[1]
img = cv2.imread(input_file)
cv2.imshow('Input image', img)

(3) 将图像转为灰度，并将其强制转换为浮点值。浮点值将用于棱角检测过程：

img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
img_gray = np.float32(img_gray)

(4) 对灰度图像运行哈里斯角检测器（Harris corner detector）函数。你可以在http://docs.opencv.org/3.0-beta/doc/py_tutorials/py_feature2d/py_features_harris/py_features_harris.html查看更多哈里斯角检测器的详细介绍：

# 哈里斯角检测器
img_harris = cv2.cornerHarris(img_gray, 7, 5, 0.04)

(5) 为了标记棱角，需要放大图像：

# 放大图像以标记棱角
img_harris = cv2.dilate(img_harris, None)

(6) 定义显示重要点个数的阈值：

# 用阈值显示棱角
img[img_harris > 0.01 * img_harris.max()] = [0, 0, 0]

(7) 显示输出图像：

cv2.imshow('Harris Corners', img)
cv2.waitKey()

(8) 全部代码已经在corner_detector.py文件中给出。运行该代码，可以看到原始输入图像如图9-12所示。

图　9-12

(9) 检测棱角处理后的图像如图9-13所示。

图　9-13

9.6　检测SIFT特征点

尺度不变特征变换（Scale Invariant Feature Transform，SIFT）是计算机视觉领域最常用的特征之一。David Lowe首次在其论文中提出该特征，具体可参考https://www.cs.ubc.ca/~lowe/papers/ijcv04.pdf。此后，SIFT成为图像识别和图像内容分析领域最有效的特征之一，它在大小、方向、对比度等方向都有较强的健壮性。SIFT也是目标识别系统的基础。接下来介绍如何检测这些特征点。

详细步骤

(1) 创建一个Python文件，并导入以下程序包：

import sys

import cv2
import numpy as np

(2) 加载输入图像。本例将用到table.jpg：

# 加载图像table.jpg
input_file = sys.argv[1]
img = cv2.imread(input_file)

(3) 将图像转为灰度：

img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

(4) 初始化SIFT检测器对象并提取关键点：

sift = cv2.xfeatures2d.SIFT_create()
keypoints = sift.detect(img_gray, None)

(5) 上面所说的关键点是指突出的点，但它们并不是特征。这基本上指出了突出点的位置。SIFT还可以作为非常有效的特征提取器，这一点将在后面的某一节中介绍。

(6) 在输入图像上画出关键点：

img_sift = np.copy(img)
cv2.drawKeypoints(img, keypoints, img_sift, flags=cv2.DRAW_
    MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)

(7) 显示输入和输出图像：

cv2.imshow('Input image', img)
cv2.imshow('SIFT features', img_sift)
cv2.waitKey()

(8) 全部代码已经在feature_detector.py文件中给出。运行该代码，可以看到原始输入图像如图9-14所示。

图　9-14

(9) 输出图像如图9-15所示。

图　9-15

9.7　创建Star特征检测器

SIFT特征检测器在很多场景中都很好用，但是，当创建目标识别系统时，在用SIFT检测特征之前，可能需要用到一个不同的特征检测器，这使我们能够通过灵活地层叠不同的模块来获得最佳的性能。这一节将用到Star特征检测器（Star feature detector），查看其性能表现。

详细步骤

(1) 创建一个Python文件，并导入以下程序包：

import sys

import cv2
import numpy as np

(2) 定义一个类，用于处理与Star特征检测相关的函数：

class StarFeatureDetector(object):
    def __init__(self):
        self.detector = cv2.xfeatures2d.StarDetector_create()

(3) 定义一个对输入图像运行检测器的函数：

    def detect(self, img):
        return self.detector.detect(img)

(4) 在main函数中加载输入图像。本例将用到table.jpg：

if __name__=='__main__':
    # 加载图像table.jpg
    input_file = sys.argv[1]
    input_img = cv2.imread(input_file)

(5) 将图像转为灰度：

    # 转为灰度图
    img_gray = cv2.cvtColor(input_img, cv2.COLOR_BGR2GRAY)

(6) 用Star特征检测器检测出特征：

    # 用Star特征检测器检测出特征
    keypoints = StarFeatureDetector().detect(input_img)

(7) 画出输入图像的关键点：

    cv2.drawKeypoints(input_img, keypoints, input_img,
               flags=cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)

(8) 显示输出图像：

    cv2.imshow('Star features', input_img)
    cv2.waitKey()

(9) 全部代码已经在star_detector.py文件中给出。运行该代码，可以看到原始输入图像如图9-16所示。

图　9-16

9.8　利用视觉码本和向量量化创建特征

为了创建一个目标识别系统，需要从每张图像中提取特征向量。每张图像需要有一个识别标志，以用于匹配。我们用一个叫视觉码本的概念来创建图像识别标志。在训练数据集中，这个码本基本上是一个字典，用于提出关于图像的描述。我们用向量量化方法将很多特征点进行聚类并得出中心点。这些中心点将作为视觉码本的元素，更详细的介绍可以参考http://mi.eng.cam.ac.uk/~cipolla/lectures/PartIB/old/IB-visualcodebook.pdf。

在开始接下来的学习之前，请确保你已经有一些训练图像。本例提供了包含3个类的示例训练数据集，每一类包含20幅图像，这些图像可以在http://www.vision.caltech.edu/html-files/archive.html下载。

为了创建一个健壮的目标识别系统，你需要数万幅图像。该领域有一个非常著名的数据集叫Caltech256，它包括256类图像，每一类都包含上千幅示例图像。你可以在http://www.vision.caltech.edu/Image_Datasets/Caltech256下载该数据集。

详细步骤

(1) 这是一个比较长的例子，因此这里仅介绍一些重点函数。全部代码已经在build_features.py文件中给出。下面先定义一个提取特征的类：

class FeatureBuilder(object):

(2) 定义一个从输入图像提取特征的方法。下面将用Star检测器获得关键点，然后用SIFT提取这些位置的描述信息：

    def extract_features(self, img):
        keypoints = StarFeatureDetector().detect(img)
        keypoints, feature_vectors = compute_sift_features(img, keypoints)
        return feature_vectors

(3) 从描述信息中提取出中心点：

    def get_codewords(self, input_map, scaling_size, max_samples=12):
        keypoints_all = []

        count = 0
        cur_class = ''

(4) 每幅图像都会生成大量的描述信息。这里将仅用一小部分图像，因为这些中心点并不会发生很大的改变：

    for item in input_map:
        if count >= max_samples:
            if cur_class != item['object_class']:
                count = 0
        else:
            continue

    count += 1

(5) 将进程打印出来：

        if count == max_samples:
            print "Built centroids for", item['object_class']

(6) 提取当前标签：

        cur_class = item['object_class']

(7) 读取图像并调整其大小：

        img = cv2.imread(item['image_path'])
        img = resize_image(img, scaling_size)

(8) 设置维度数为128并提取特征：

        num_dims = 128
        feature_vectors = self.extract_features(img)
        keypoints_all.extend(feature_vectors)

(9) 用向量量化来量化特征点。向量量化是一个N维的“四舍五入”，更多详细介绍可参考http://www.data-compression.com/vq.shtml：

       kmeans, centroids = BagOfWords().cluster(keypoints_all)
       return kmeans, centroids

(10) 定义一个类来处理词袋模型和向量量化：

class BagOfWords(object):
    def __init__(self, num_clusters=32):
        self.num_dims = 128
        self.num_clusters = num_clusters
        self.num_retries = 10

(11) 定义一个方法来量化数据点。下面将用k-means聚类来实现：

    def cluster(self, datapoints):
        kmeans = KMeans(self.num_clusters,
           n_init=max(self.num_retries, 1),
           max_iter=10, tol=1.0)

(12) 提取中心点：

        res = kmeans.fit(datapoints)
        centroids = res.cluster_centers_
        return kmeans, centroids

(13) 定义一个方法来归一化数据：

    def normalize(self, input_data):
        sum_input = np.sum(input_data)

        if sum_input > 0:
            return input_data / sum_input
        else:
            return input_data

(14) 定义一个方法来获得特征向量：

    def construct_feature(self, img, kmeans, centroids):
        keypoints = StarFeatureDetector().detect(img)
        keypoints, feature_vectors = compute_sift_features(img, keypoints)
        labels = kmeans.predict(feature_vectors)
        feature_vector = np.zeros(self.num_clusters)

(15) 创建一个直方图并将其归一化：

        for i, item in enumerate(feature_vectors):
            feature_vector[labels[i]] += 1

            feature_vector_img = np.reshape(feature_vector,
    ((1, feature_vector.shape[0])))
            return self.normalize(feature_vector_img)

(16) 定义一个方法来提取SIFT特征：

# 提取SIFT特征
def compute_sift_features(img, keypoints):
        if img is None:
            raise TypeError('Invalid input image')

        img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
        keypoints, descriptors = cv2.xfeatures2d.SIFT_create().compute(img_gray, keypoints)
        return keypoints, descriptors

正如之前提到的，全部代码可参考build_features.py文件。可以用以下方式来运行代码：

$ python build_features.py –-data-folder /path/to/training_images/ --codebook-file codebook.pkl --feature-map-file feature_map.pkl

结果将产生两个文件，分别为codebook.pkl和feature_map.pkl。下一节中将用到这两个文件。

9.9　用极端随机森林训练图像分类器

本节将用极端随机森林（Extremely Random Forests，ERF）来训练图像分类器。一个目标识别系统就是利用图像分类器将图像分到已知的类别中。ERF在机器学习领域非常流行，因为ERF具有较快的速度和比较精确的准确度。我们基于图像的特征构建一组决策树，并通过训练这个森林实现正确决策。更多随机森林的详细内容可参考https://www.stat.berkeley.edu/~breiman/RandomForests/cc_home.htm，更多ERF的内容可参考http://www.montefiore.ulg.ac.be/~ernst/uploads/news/id63/extremely-randomized-trees.pdf。

详细步骤

(1) 创建一个Python文件，并且导入以下程序包：

import argparse
import cPickle as pickle

import numpy as np
from sklearn.ensemble import ExtraTreesClassifier
from sklearn import preprocessing

(2) 定义一个参数解析器：

def build_arg_parser():
    parser = argparse.ArgumentParser(description='Trains the classifier')
    parser.add_argument("--feature-map-file", dest="feature_map_file", required=True,
help="Input pickle file containing the feature map")
    parser.add_argument("--model-file", dest="model_file", required=False,
help="Output file where the trained model will be stored")
    return parser

(3) 定义一个类来处理ERF训练。这里将用到一个标签编码器来对训练标签进行编码：

class ERFTrainer(object):
    def __init__(self, X, label_words):
        self.le = preprocessing.LabelEncoder()
        self.clf = ExtraTreesClassifier(n_estimators=100, max_depth=16, random_state=0)

(4) 对标签编码并训练分类器：

        y = self.encode_labels(label_words)
        self.clf.fit(np.asarray(X), y)

(5) 定义一个函数，用于对标签进行编码：

    def encode_labels(self, label_words):
        self.le.fit(label_words)
        return np.array(self.le.transform(label_words), dtype=np.float32)

(6) 定义一个函数，用于将未知数据点进行分类：

    def classify(self, X):
        label_nums = self.clf.predict(np.asarray(X))
        label_words = self.le.inverse_transform([int(x) for x in label_nums])
        return label_words

(7) 定义main函数并解析输入参数：

if __name__=='__main__':
    args = build_arg_parser().parse_args()
    feature_map_file = args.feature_map_file
    model_file = args.model_file

(8) 加载上一节中生成的特征地图：

    # 加载特征地图
    with open(feature_map_file, 'r') as f:
        feature_map = pickle.load(f)

(9) 提取特征向量：

    # 提取特征向量和标记
    label_words = [x['object_class'] for x in feature_map]
    dim_size = feature_map[0]['feature_vector'].shape[1]
    X = [np.reshape(x['feature_vector'], (dim_size,)) for x in feature_map]

(10) 基于训练数据训练ERF：

    # 训练ERF分类器
    erf = ERFTrainer(X, label_words)

(11) 保存训练的ERF模型：

    if args.model_file:
        with open(args.model_file, 'w') as f:
            pickle.dump(erf, f)

(12) 全部代码已经在trainer.py文件中给出。可以用以下方式运行代码：

$ python trainer.py --feature-map-file feature_map.pkl --model- file erf.pkl

结果将产生一个erf.pkl文件。下一节中将用到该文件。

9.10　创建一个对象识别器

训练好一个ERF模型后，接下来创建一个目标识别器，该识别器可以识别未知图像的内容。

详细步骤

(1) 创建一个Python文件，并且导入以下程序包：

import argparse
import cPickle as pickle

import cv2
import numpy as np

import build_features as bf
from trainer import ERFTrainer

(2) 定义一个参数解析器：

def build_arg_parser():
    parser = argparse.ArgumentParser(description='Extracts features \
from each line and classifies the data')
    parser.add_argument("--input-image", dest="input_image", required=True,
help="Input image to be classified")
    parser.add_argument("--model-file", dest="model_file", required=True,
help="Input file containing the trained model")
    parser.add_argument("--codebook-file", dest="codebook_file",
required=True, help="Input file containing the codebook")
    return parser

(3) 定义一个类来处理图像标签提取函数：

class ImageTagExtractor(object):
    def __init__(self, model_file, codebook_file):
        with open(model_file, 'r') as f:
            self.erf = pickle.load(f)

        with open(codebook_file, 'r') as f:
            self.kmeans, self.centroids = pickle.load(f)

(4) 定义一个函数，用于使用训练好的ERF模型来预测输出：

    def predict(self, img, scaling_size):
        img = bf.resize_image(img, scaling_size)
        feature_vector = bf.BagOfWords().construct_feature(
img, self.kmeans, self.centroids)
        image_tag = self.erf.classify(feature_vector)[0]
        return image_tag

(5) 定义main函数，加载输入图像：

if __name__=='__main__':
    args = build_arg_parser().parse_args()
    model_file = args.model_file
    codebook_file = args.codebook_file
    input_image = cv2.imread(args.input_image)

(6) 合理地调整图像大小：

    scaling_size = 200

(7) 在命令行打印输出结果：

    print "\nOutput:", ImageTagExtractor(model_file,
    codebook_file).predict(input_image, scaling_size)

(8) 全部代码已经在object_recognizer.py文件中给出。可以用以下方式运行代码：

$ python object_recognizer.py --input-image imagefile.jpg --model- file erf.pkl --codebook-file codebook.pkl

可以看到命令行工具中输出的类。

第 10 章　人脸识别

在这一章，我们将介绍以下主题：

从网络摄像头采集和处理视频信息

用Haar级联创建一个人脸识别器

创建一个眼睛和鼻子检测器

做主成分分析

做核主成分分析

做盲源分离

用局部二值模式直方图创建一个人脸识别器

10.1　简介

人脸识别是指在给定图像中识别某个人的工作，它不同于从给定图像中定位人脸位置的人脸检测。在人脸检测中，我们不关心这个人是谁，只需要识别包含脸部的图像区域。因此，在一个典型的生物人脸识别系统中，需要在识别脸部之前确定脸部的位置。

人脸识别对人类来说非常容易，毫不费力就可以做到，并且我们一直都在这样做，但是如何才能让机器做同样的事情呢？我们需要了解会利用脸部的哪个部分来识别一个人。人类的大脑有一个内部结构，它似乎可以对特定的特征做出相应的反应，例如边、角度、情绪等。人类的视觉皮层将这些特征综合起来，做出一个连贯性推断。如果希望机器也能同样精确地识别人脸，那就需要用同样的方式模拟这个问题。下面需要从输入的图像中提取相关特征，并把它转换成一个有意义的表示形式。

10.2　从网络摄像头采集和处理视频信息

本节将用网络摄像头采集视频数据。下面来看如何用OpenCV-Python从网络摄像头采集视频信息。

详细步骤

(1) 创建一个Python文件，并导入以下程序包：

import cv2

(2) OpenCV提供了一个视频采集对象，可以利用该对象从网络摄像头采集图像。0输入参数指定网络摄像头的ID。如果连接的是USB摄像头，将有一个不同的ID：

# 初始化网络摄像头
cap = cv2.VideoCapture(0)

(3) 定义网络摄像头采集的帧的比例系数：

# 定义网络摄像头采集图像的比例系数
scaling_factor = 0.5

(4) 启动一个无限循环来采集帧，直到按下Esc键。从网络摄像头读取帧：

# 循环采集直到按下Esc键
while True:
    # 采集当前画面
    ret, frame = cap.read()

(5) 调整帧的大小不是必须的，但是这在编写代码中很重要：

    # 调整帧的大小
    frame = cv2.resize(frame, None, fx=scaling_factor, fy=scaling_factor,
            interpolation=cv2.INTER_AREA)

(6) 显示帧：

    # 显示帧
    cv2.imshow('Webcam', frame)

(7) 等待1 ms，然后采集下一帧：

    # 检查是否按了Esc键
    c = cv2.waitKey(1)
    if c == 27:
        break

(8) 释放视频采集对象：

# 释放视频采集对象
cap.release()

(9) 在结束代码之前关闭所有活动窗体：

# 关闭所有活动窗体
cv2.destroyAllWindows()

(10) 全部代码已经包含在video_capture.py文件中。运行该代码，可以从网络摄像头中看到类似如图10-1所示的图像。

图　10-1

10.3　用Haar级联创建一个人脸识别器

正如前面讨论过的，人脸检测是确定输入图像中人脸位置的过程。我们将用Haar级联来做人脸检测。Haar级联通过在多个尺度上从图像中提取大量的简单特征来实现。简单特征主要指边、线、矩形特征等，这些特征都非常易于计算，然后通过创建一系列简单的分类器来做训练。使用自适应增强技术可以使得这个过程更健壮，更多细节可以查看http://docs.opencv.org/3.1.0/d7/d8b/tutorial_py_face_detection.html#gsc.tab=0。下面来看如何在网络摄像头采集的视频帧中确定人脸位置。

详细步骤

(1) 创建一个Python文件，并导入以下程序包：

import cv2
import numpy as np

(2) 加载人脸检测级联文件。这是可以用作检测器的训练模型：

# 导入人脸检测级联文件
face_cascade = cv2.CascadeClassifier('cascade_files/haarcascade_
frontalface_alt.xml')

(3) 确定级联文件是否正确地加载：

# 确定级联文件是否正确地加载
if face_cascade.empty():
    raise IOError('Unable to load the face cascade classifier xml file')

(4) 生成一个视频采集对象：

# 初始化视频采集对象
cap = cv2.VideoCapture(0)

(5) 定义图像向下采样的比例系数：

# 定义图像向下采样的比例系数
scaling_factor = 0.5

(6) 循环采集直到按下Esc键：

# 循环采集直到按下Esc键
while True:
    # 采集当前帧并进行调整
    ret, frame = cap.read()

(7) 调整帧的大小：

    frame = cv2.resize(frame, None, fx=scaling_factor, fy=scaling_factor,
            interpolation=cv2.INTER_AREA)

(8) 将图像转为灰度图。这里需要灰度图像来运行人脸检测器：

    # 将图像转为灰度图
    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)

(9) 在灰度图像上运行人脸检测器。参数1.3是指每个阶段的乘积系数。参数5是指每个候选矩形应该拥有的最小近邻数量，这样我们可以维持这一数量。候选矩形是指人脸可能被检测到的候选区域：

    # 在灰度图像上运行人脸检测器
    face_rects = face_cascade.detectMultiScale(gray, 1.3, 5)

(10) 对于每个检测到的人脸区域，在其周围画出矩形：

    # 在脸部画出矩形
    for (x,y,w,h) in face_rects:
        cv2.rectangle(frame, (x,y), (x+w,y+h), (0,255,0), 3)

(11) 展示输出图像：

    # 展示输出图像
    cv2.imshow('Face Detector', frame)

(12) 在下一次迭代之前等待1 ms，如果用户按下Esc键，就跳出循环：

    # 检查是否按下Esc键
    c = cv2.waitKey(1)
    if c == 27:
        break

(13) 在结束代码之前，释放并销毁所有对象：

# 释放视频采样对象并关闭窗口
cap.release()
cv2.destroyAllWindows()

(14) 全部代码已经包含在face_detector.py文件中。运行该代码，可以看到从网络摄像视频文件中人脸被检测出来了，如图10-2所示。

图　10-2

10.4　创建一个眼睛和鼻子检测器

Haar级联方法可以被扩展应用于各种对象的检测。接下来看看如何利用该方法检测输入视频文件中的眼睛和鼻子。

详细步骤

(1) 创建一个Python文件，并导入以下程序包：

import cv2
import numpy as np

(2) 加载人脸、眼睛和鼻子级联文件：

# 加载人脸、眼睛和鼻子级联文件
face_cascade = cv2.CascadeClassifier('cascade_files/haarcascade_
frontalface_alt.xml')
eye_cascade = cv2.CascadeClassifier('cascade_files/haarcascade_eye.xml')
nose_cascade = cv2.CascadeClassifier('cascade_files/haarcascade_mcs_nose.xml')

(3) 确定级联文件是否正确地加载：

# 检查脸部级联文件是否加载
if face_cascade.empty():
    raise IOError('Unable to load the face cascade classifier xml file')

# 检查眼睛级联文件是否加载
if eye_cascade.empty():
    raise IOError('Unable to load the eye cascade classifier xml file')

# 检查鼻子级联文件是否加载
if nose_cascade.empty():
    raise IOError('Unable to load the nose cascade classifier xml file')

(4) 初始化视频采集对象：

# 初始化视频采集对象并定义比例系数
cap = cv2.VideoCapture(0)

(5) 定义比例系数：

scaling_factor = 0.5

(6) 重复循环直至用户按下Esc键：

while True:
    # 读取当前帧画面，调整大小，转为灰度图
    ret, frame = cap.read()

(7) 调整帧的大小：

    frame = cv2.resize(frame, None, fx=scaling_factor, fy=scaling_factor,
            interpolation=cv2.INTER_AREA)

(8) 将图像转为灰度图：

    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)

(9) 在灰度图像上运行人脸检测器：

    # 在灰度图像上运行人脸检测器
    faces = face_cascade.detectMultiScale(gray, 1.3, 5)

(10) 因为眼睛和鼻子总是位于脸部区域，所以这里仅在脸部区域运行检测器：

    # 在每张脸的矩形区域运行眼睛和鼻子检测器
    for (x,y,w,h) in faces:

(11) 提取人脸ROI信息：

        # 从彩色与灰度图中提取人脸ROI信息
        roi_gray = gray[y:y+h, x:x+w]
        roi_color = frame[y:y+h, x:x+w]

(12) 运行眼睛检测器：

        # 在灰度图ROI信息中检测眼睛
        eye_rects = eye_cascade.detectMultiScale(roi_gray)

(13) 运行鼻子检测器：

        # 在灰度图ROI信息中检测鼻子
        nose_rects = nose_cascade.detectMultiScale(roi_gray, 1.3, 5)

(14) 在眼睛周围画圈：

       # 在眼睛周围画绿色的圈
        for (x_eye, y_eye, w_eye, h_eye) in eye_rects:
            center = (int(x_eye + 0.5*w_eye), int(y_eye + 0.5*h_eye))
            radius = int(0.3 * (w_eye + h_eye))
            color = (0, 255, 0)
            thickness = 3
            cv2.circle(roi_color, center, radius, color, thickness)

(15) 在鼻子周围画矩形：

        for (x_nose, y_nose, w_nose, h_nose) in nose_rects:
            cv2.rectangle(roi_color, (x_nose, y_nose), (x_nose+w_nose,
                y_nose+h_nose), (0,255,0), 3)
            break

(16) 展示该图像：

    # 展示图像
    cv2.imshow('Eye and nose detector', frame)

(17) 在下一次迭代之前等待1 ms，如果用户按下Esc键，就跳出循环：

    # 检查是否按了Esc键
    c = cv2.waitKey(1)
    if c == 27:
        break

(18) 在结束代码之前，释放并销毁所有对象：

# 释放视频采样对象并关闭窗口
cap.release()
cv2.destroyAllWindows()

(19) 全部代码已经包含在eye_nose_detector.py文件中。运行该代码，可以看到网络摄像视频文件中的眼睛和鼻子被检测出来了，如图10-3所示。

图　10-3

10.5　做主成分分析

主成分分析（Principal Components Analysis，PCA）是一个降低维度的技术，常用于计算机视觉和机器学习中。当需要处理很大的特征维度时，训练机器学习系统变得异常昂贵，因此需要在训练系统之前降低数据的维度。但是，降低维度时，我们并不想损失数据中的重要信息，此时PCA便是最佳选择。PCA识别数据中的重要成分，并将其按照重要程度排序。你可以在http://dai.fmph.uniba.sk/courses/ml/sl/PCA.pdf中了解更多细节。PCA 也常用于人脸识别系统。接下来看看如何对输入数据做主成分分析。

详细步骤

(1) 创建一个Python文件，并导入以下程序包：

import numpy as np
from sklearn import decomposition

(2) 为输入数据定义5个维度。前两个维度是独立的，但是后3个维度将依赖于前两个维度。也就是说，可以去掉后3个维度，因为它们并没有提供任何新信息：

# 定义特征
x1 = np.random.normal(size=250)
x2 = np.random.normal(size=250)
x3 = 2*x1 + 3*x2
x4 = 4*x1 - x2
x5 = x3 + 2*x4

(3) 创建一个带这些特征的数据集：

# 创建特征数据集
X = np.c_[x1, x3, x2, x5, x4]

(4) 创建一个PCA对象：

# 创建一个PCA
pca = decomposition.PCA()

(5) 对输入数据做主成分分析（PCA）：

pca.fit(X)

(6) 打印维度的方差：

# 打印方差
variances = pca.explained_variance_
print '\nVariances in decreasing order:\n', variances

(7) 如果一个特定的维度是有用的，那么它应该有一个有意义的方差值。设置一个阈值并确定重要的维度：

# 找出有用的维度数量
thresh_variance = 0.8
num_useful_dims = len(np.where(variances > thresh_variance)[0])
print '\nNumber of useful dimensions:', num_useful_dims

(8) 正如之前提到的，PCA识别只有两个维度在这个数据集中很重要：

# 只有两个维度是有效的
pca.n_components = num_useful_dims

(9) 将数据集从5维转换为二维：

X_new = pca.fit_transform(X)
print '\nShape before:', X.shape
print 'Shape after:', X_new.shape

(10) 全部代码已经包含在pca.py文件中。运行该代码，可以在终端看到如图10-4所示的结果。

图　10-4

10.6　做核主成分分析

PCA能很好地降低维度，但PCA是以线性方式工作的，如果数据集不是以线性方式组织的，那么PCA并不能实现其功能。但是核主成分分析可以很好地解决这个问题，关于核主成分分析的细节介绍可参考http://www.ics.uci.edu/~welling/classnotes/papers_class/Kernel-PCA.pdf。接下来看看如何对输入数据做核主成分分析，同时将其与主成分分析进行对比。

详细步骤

(1) 创建一个Python文件，并且导入以下程序包：

import numpy as np
import matplotlib.pyplot as plt

from sklearn.decomposition import PCA, KernelPCA
from sklearn.datasets import make_circles

(2) 定义随机数发生器的种子值，以便产生用于分析的数据示例：

# 定义随机数发生器的种子值
np.random.seed(7)

(3) 生成以同心圆分布的数据，以演示PCA在这种情况下是如何工作的：

# 生成样本
X, y = make_circles(n_samples=500, factor=0.2, noise=0.04)

(4) 对这组数据做主成分分析：

# 做主成分分析
pca = PCA()
X_pca = pca.fit_transform(X)

(5) 对输入数据做核主成分分析：

# 做核主成分分析
kernel_pca = KernelPCA(kernel="rbf", fit_inverse_transform=True, gamma=10)
X_kernel_pca = kernel_pca.fit_transform(X)
X_inverse = kernel_pca.inverse_transform(X_kernel_pca)

(6) 画出原始输入数据：

# 画出原始输入数据
class_0 = np.where(y == 0)
class_1 = np.where(y == 1)
plt.figure()
plt.title("Original data")
plt.plot(X[class_0, 0], X[class_0, 1], "ko", mfc='none')
plt.plot(X[class_1, 0], X[class_1, 1], "kx")
plt.xlabel("1st dimension")
plt.ylabel("2nd dimension")

(7) 画出主成分分析后的数据：

# 画出主成分分析后的数据
plt.figure()
plt.plot(X_pca[class_0, 0], X_pca[class_0, 1], "ko", mfc='none')
plt.plot(X_pca[class_1, 0], X_pca[class_1, 1], "kx")
plt.title("Data transformed using PCA")
plt.xlabel("1st principal component")
plt.ylabel("2nd principal component")

(8) 画出核主成分分析后的数据：

# 画出核主成分分析后的数据
plt.figure()
plt.plot(X_kernel_pca[class_0, 0], X_kernel_pca[class_0, 1], "ko", mfc='none')
plt.plot(X_kernel_pca[class_1, 0], X_kernel_pca[class_1, 1], "kx")
plt.title("Data transformed using Kernel PCA")
plt.xlabel("1st principal component")
plt.ylabel("2nd principal component")

(9) 用核方法将数据转换回原始空间，查看是否存在这样的可逆关系：

# 用核方法将数据转换回原始空间
plt.figure()
plt.plot(X_inverse[class_0, 0], X_inverse[class_0, 1], "ko", mfc='none')
plt.plot(X_inverse[class_1, 0], X_inverse[class_1, 1], "kx")
plt.title("Inverse transform")
plt.xlabel("1st dimension")
plt.ylabel("2nd dimension")

plt.show()

(10) 全部代码已经包含在kpca.py文件中。运行该代码，可以看到4幅图像。第一幅图像是原始数据，如图10-5所示。

图　10-5

第二幅图像是运行主成分分析后的数据，如图10-6所示。

图　10-6

第三幅图像是运行核主成分分析后的数据，如图10-7所示。注意，点都聚集到图像的右半部分。

图　10-7

第四幅图像展示了将数据逆变换回其原始空间，如图10-8所示。

图　10-8

10.7　做盲源分离

盲源分离是指将信号从混合体中分离出来的过程。假设一组不同的信号发生器生成了不同的信号，而一个公共接收机接收到了所有这些信号。现在，我们的工作是利用这些信号的性质将这些信号从混合体中分离出来。我们将用独立成分分析（Independent Components Analysis，ICA）算法来实现。关于独立成分分析的细节可查看http://www.mit.edu/~gari/teaching/6.555/LECTURE_NOTES/ch15_bss.pdf。接下来看看如何实现。

详细步骤

(1) 创建一个Python文件，并导入以下程序包：

import numpy as np
import matplotlib.pyplot as plt
from scipy import signal

from sklearn.decomposition import PCA, FastICA

(2) 本例将用到mixture_of_signals.txt文件提供的数据。加载该数据：

# 加载数据
input_file = 'mixture_of_signals.txt'
X = np.loadtxt(input_file)

(3) 创建ICA对象：

# 计算ICA
ica = FastICA(n_components=4)

(4) 基于ICA重构信号：

# 重构信号
signals_ica = ica.fit_transform(X)

(5) 提取混合矩阵：

# 提取混合矩阵
mixing_mat = ica.mixing_

(6) 执行PCA做对比：

# 执行PCA
pca = PCA(n_components=4)
signals_pca = pca.fit_transform(X)  # 基于正交成分重构信号

(7) 定义信号列表来将其画出：

# 定义画图参数
models = [X, signals_ica, signals_pca]

(8) 指定颜色：

colors = ['blue', 'red', 'black', 'green']

(9) 画出输入信号：

# 画出输入信号
plt.figure()
plt.title('Input signal (mixture)')
for i, (sig, color) in enumerate(zip(X.T, colors), 1):
    plt.plot(sig, color=color)

(10) 画出利用ICA分离的信号：

# 画出利用ICA分离的信号
plt.figure()
plt.title('ICA separated signals')
plt.subplots_adjust(left=0.1, bottom=0.05, right=0.94,
        top=0.94, wspace=0.25, hspace=0.45)

(11) 用不同的颜色画出子图：

for i, (sig, color) in enumerate(zip(signals_ica.T, colors), 1):
    plt.subplot(4, 1, i)
    plt.title('Signal ' + str(i))
    plt.plot(sig, color=color)

(12) 画出用PCA分离的信号：

# 画出PCA信号
plt.figure()
plt.title('PCA separated signals')
plt.subplots_adjust(left=0.1, bottom=0.05, right=0.94,
        top=0.94, wspace=0.25, hspace=0.45)

(13) 用不同的颜色画出各子图：

for i, (sig, color) in enumerate(zip(signals_pca.T, colors), 1):
    plt.subplot(4, 1, i)
    plt.title('Signal ' + str(i))
    plt.plot(sig, color=color)

plt.show()

(14) 全部代码已经包含在blind_source_separation.py文件中。运行该代码，可以看到3幅图像。第一幅图像是原始数据，也就是信号的混合体，如图10-9所示。

图　10-9

第二幅图像是用ICA分离的信号，如图10-10所示。

图　10-10

第三幅图像是用PCA分离的信号，如图10-11所示。

图　10-11

10.8　用局部二值模式直方图创建一个人脸识别器

现在已经准备好创建人脸识别器了。接下来需要一个人脸数据集来做训练，所以这里提供了一个faces_dataset文件夹，该文件夹中包含足够的图像可以用来做训练。该数据集是http://www.vision.caltech.edu/Image_Datasets/faces/faces.tar给出的一个子集，包含了一定数量的图像，可以利用这些图像来训练一个人脸识别系统。

我们将用局部二值模式直方图（Local Binary Patterns Histograms）创建人脸识别系统。在数据集中，你可以看到不同的人。接下来的工作是构建一个能将每个人从其他人中区分出来的系统。如果看到从未见过的图像，系统会将其分派到已有的类中。更多关于局部二值模式直方图的细节信息可查看http://docs.opencv.org/2.4/modules/contrib/doc/facerec/facerec_tutorial.html#local-binary-patterns-histograms。下面看看如何创建一个人脸识别器。

详细步骤

(1) 创建一个Python文件，并导入以下程序包：

import os

import cv2
import numpy as np
from sklearn import preprocessing

(2) 定义一个类来处理与类标签编码相关的所有任务：

# 定义一个类来处理与类标签编码相关的所有任务
class LabelEncoder(object):

(3) 定义一个方法来为这些标签编码。在输入训练数据中，标签用单词表示，但我们需要数字来训练系统。该方法将定义一个预处理对象，该对象将单词转换成数字，同时保留这种前向后向的映射关系：

    # 将单词转换成数字的编码方法
    def encode_labels(self, label_words):
        self.le = preprocessing.LabelEncoder()
        self.le.fit(label_words)

(4) 定义一个将单词转换成数字的方法：

    # 将输入单词转换成数字
    def word_to_num(self, label_word):
        return int(self.le.transform([label_word])[0])

(5) 定义一个方法，用于将数字转换回其原始单词：

    # 将数字转换为单词
    def num_to_word(self, label_num):
        return self.le.inverse_transform([label_num])[0]

(6) 定义一个方法，用于从输入文件夹中提取图像和标签：

# 从输入文件夹中提取图像和标签
def get_images_and_labels(input_path):
    label_words = []

(7) 对输入文件夹做递归迭代，提取所有图像的路径：

    # 对输入文件夹做递归迭代并追加文件
    for root, dirs, files in os.walk(input_path):
        for filename in (x for x in files if x.endswith('.jpg')):
            filepath = os.path.join(root, filename)
            label_words.append(filepath.split('/')[-2])

(8) 初始化变量：

    # 初始化变量
    images = []
    le = LabelEncoder()
    le.encode_labels(label_words)
    labels = []

(9) 为训练解析输入目录：

    # 解析输入目录
    for root, dirs, files in os.walk(input_path):
        for filename in (x for x in files if x.endswith('.jpg')):
            filepath = os.path.join(root, filename)

(10) 将当前图像读取成灰度格式：

    # 将当前图像读取成灰度格式
    image = cv2.imread(filepath, 0)

(11) 从文件夹路径中提取标签：

    # 从文件夹路径中提取标签
    name = filepath.split('/')[-2]

(12) 对该图像做人脸检测：

    # 做人脸检测
    faces = faceCascade.detectMultiScale(image, 1.1, 2, minSize=(100,100))

(13) 提取ROI属性值，并将这些值和标签编码器返回：

            # 循环处理每一张脸
            for (x, y, w, h) in faces:
                images.append(image[y:y+h, x:x+w])
                labels.append(le.word_to_num(name))

      return images, labels, le

(14) 定义main函数，并定义人脸级联文件的路径：

if __name__=='__main__':
    cascade_path = "cascade_files/haarcascade_frontalface_alt.xml"
    path_train = 'faces_dataset/train'
    path_test = 'faces_dataset/test'

(15) 加载人脸级联文件：

    # 加载人脸级联文件
    faceCascade = cv2.CascadeClassifier(cascade_path)

(16) 生成局部二值模式直方图人脸识别器对象：

    # 生成局部二值模式直方图人脸识别器
    recognizer = cv2.face.createLBPHFaceRecognizer()

(17) 为输入路径提取图像、标签和标签编码器：

    # 从训练数据集中提取图像、标签和标签编码器
    images, labels, le = get_images_and_labels(path_train)

(18) 用提取的数据训练人脸识别器：

    # 训练人脸识别器
    print "\nTraining..."
    recognizer.train(images, np.array(labels))

(19) 用未知数据测试人脸识别器：

    # 用未知数据测试人脸识别器
    print '\nPerforming prediction on test images...'
    stop_flag = False
    for root, dirs, files in os.walk(path_test):
        for filename in (x for x in files if x.endswith('.jpg')):
            filepath = os.path.join(root, filename)

(20) 加载图像：

            # 读取图像
            predict_image = cv2.imread(filepath, 0)

(21) 用人脸检测器确定人脸的位置：

            # 检测人脸
            faces = faceCascade.detectMultiScale(predict_image, 1.1,
                    2, minSize=(100,100))

(22) 对于每个人脸ROI，运行人脸识别器：

            # 循环处理每一张脸
            for (x, y, w, h) in faces:
                # Predict the output
                predicted_index, conf = recognizer.predict(
                        predict_image[y:y+h, x:x+w])

(23) 将标签转换为单词：

                # 将标签转换为单词
                predicted_person = le.num_to_word(predicted_index)

(24) 在输出图像中叠加文字，并将其展示：

                # 在输出图像中叠加文字，并显示图像
                cv2.putText(predict_image, 'Prediction: ' + predicted_person,
                        (10,60), cv2.FONT_HERSHEY_SIMPLEX, 2, (255,255,255), 6)
                cv2.imshow("Recognizing face", predict_image)

(25) 检查用户是否按下Esc键。如果有，则跳出循环：

            c = cv2.waitKey(0)
            if c == 27:
                stop_flag = True
                break

        if stop_flag:
            break

(26) 全部代码已经包含在face_recognizer.py文件中。运行该代码，可以得到一个输出窗体，窗体中显示测试图像的预测输出。按下空格键可以继续循环。测试图像中有3个不同的人。第一个人的输出结果如图10-12所示。

图　10-12

第二个人的输出结果如图10-13所示。

图　10-13

第三个人的输出结果如图10-14所示。

图　10-14

第 11 章　深度神经网络

在这一章，我们将介绍以下主题：

创建一个感知器

创建一个单层神经网络

创建一个深度神经网络

创建一个向量量化器

为序列数据分析创建一个递归神经网络

在光学字符识别数据库中将字符可视化

用神经网络创建一个光学字符识别器

11.1　简介

人类的大脑很擅长于鉴别和识别物体，我们希望机器也可以做同样的事情。一个神经网络就是一个模仿人类大脑激发学习过程的框架。神经网络被用于从数据中识别隐藏的模式。正如所有的学习算法，神经网络处理的是数字。因此，如果想要实现处理现实世界中任何包含图像、文字、传感器等的任务，就必须将其转换成数值形式，然后将其输入到一个神经网络。我们可以用神经网络做分类、聚类、生成以及其他相关的任务。

神经网络由一层层神经元组成。这些神经元模拟人类大脑中的生物神经元。每一层都是一组独立的神经元，这些神经元与相邻层的神经元相连。输入层对应我们提供的输入数据，而输出层包括了我们期望的输出结果。输入层与输出层之间的层统称为隐藏层。如果设计的神经网络包括多个隐藏层，那么就能通过这些层的自我训练获得更大的精确度。

假设我们希望神经网络按照我们的要求来对数据进行分类。为了使神经网络完成相应的任务，需要提供带标签的训练数据。神经网络将通过优化成本函数来训练自己。我们不停地迭代，直到错误率下降到一定的阈值为止。

那么“深度”神经网络是什么？深度神经网络是由多个隐藏层组成的神经网络。一般来说，这就属于深度学习的范畴。深度学习用于研究这些神经网络，而这些神经网络由多个层次的多层结构组成。

你可以在http://pages.cs.wisc.edu/~bolo/shipyard/neural/local.html查看神经网络的教程。本章中将用到NeuroLab库。在接下来的学习之前，请确保你已经安装了这个库，安装指导可以参考https://pythonhosted.org/neurolab/install.html。接下来看看如何设计和开发这些神经网络。

11.2　创建一个感知器

让我们通过感知器开始神经网络之旅。感知器是一个单独的神经元，它负责执行所有的计算。这是一个非常简单的模型，但是它奠定了构建复杂神经网络的基础。该模型如图11-1所示。

图　11-1

该神经元将多个输入用不同的权重系数融合起来，并加上偏差值来计算输出。这是一个简单的线性方程，它将输入值与感知器的输出关联起来。

详细步骤

(1) 创建一个Python文件，并导入以下程序包：

import numpy as np
import neurolab as nl
import matplotlib.pyplot as plt

(2)定义一些输入数据及其对应的标签：

# 定义输入数据
data = np.array([[0.3, 0.2], [0.1, 0.4], [0.4, 0.6], [0.9, 0.5]])
labels = np.array([[0], [0], [0], [1]])

(3) 将这些点画出，以查看这些点的布局：

# 画出输入数据
plt.figure()
plt.scatter(data[:,0], data[:,1])
plt.xlabel('X-axis')
plt.ylabel('Y-axis')
plt.title('Input data')

(4) 定义一个感知器perceptron，它有两个输入。该函数还需要限定输入数据的最大值和最小值：

# 定义有两个输入的感知器，在感知器第一个参数的每个元素中指定参数的最大值和最小值
perceptron = nl.net.newp([[0, 1],[0, 1]], 1)

(5) 接下来训练该感知器。epochs的数量指定了训练数据集需要完成的测试次数。show参数指定了显示训练过程的频率。lr参数指定了感知器的学习速度。学习速度是指学习算法在参数空间中搜索的步长。如果这个值太大，算法行进得会很快，但可能会错失最优值。而如果这个值太小，则该算法可以命中最优值，但是算法行进得会很慢，所以需要进行权衡。这里取0.01：

# 训练感知器
error = perceptron.train(data, labels, epochs=50, show=15, lr=0.01)

(6) 画出结果：

# 画出结果
plt.figure()
plt.plot(error)
plt.xlabel('Number of epochs')
plt.ylabel('Training error')
plt.grid()
plt.title('Training error progress')

plt.show()

(7) 全部代码已经在perceptron.py文件中给出。运行该代码，可以看到两幅图像。第一幅图像显示输入数据，如图11-2所示。

图　11-2

第二幅图像显示训练误差进程，如图11-3所示。

图　11-3

11.3　创建一个单层神经网络

知道如何创建一个感知器后，接下来创建一个单层神经网络。单层神经网络由一个层次中的多个神经元组成。总体上看，单层神经网络将会有一个输入层、一个隐藏层和一个输出层。

详细步骤

(1) 创建一个Python文件，并导入以下程序包：

import numpy as np
import matplotlib.pyplot as plt
import neurolab as nl

(2) 本例将会用到data_single_layer.txt文件中的数据。先加载这个文件：

# 定义输入数据
input_file = 'data_single_layer.txt'
input_text = np.loadtxt(input_file)
data = input_text[:, 0:2]
labels = input_text[:, 2:]

(3) 画出输入数据：

# 画出输入数据
plt.figure()
plt.scatter(data[:,0], data[:,1])
plt.xlabel('X-axis')
plt.ylabel('Y-axis')
plt.title('Input data')

(4) 提取最小值和最大值：

# 提取每个维度的最小值和最大值
x_min, x_max = data[:,0].min(), data[:,0].max()
y_min, y_max = data[:,1].min(), data[:,1].max()

(5) 定义一个单层神经网络，该神经网络的隐藏层包含两个神经元：

# 定义一个单层神经网络，包含两个神经元；在感知器第一个参数的每个元素中指定参数的最大值和最小值
single_layer_net = nl.net.newp([[x_min, x_max], [y_min, y_max]], 2)

(6) 通过50次迭代训练该神经网络：

# 训练神经网络
error = single_layer_net.train(data, labels, epochs=50, show=20, lr=0.01)

(7) 画出结果：

# 画出结果
plt.figure()
plt.plot(error)
plt.xlabel('Number of epochs')
plt.ylabel('Training error')
plt.title('Training error progress')
plt.grid()

plt.show()

(8) 用新的测试数据来测试神经网络：

print single_layer_net.sim([[0.3, 4.5]])
print single_layer_net.sim([[4.5, 0.5]])
print single_layer_net.sim([[4.3, 8]])

(9) 全部代码已经在single_layer.py文件中给出。运行该代码，可以看到两幅图像。第一幅图像显示输入数据，如图11-4所示。

图　11-4

(10) 第二幅图像显示训练误差进程，如图11-5所示。

图　11-5

可以在命令行工具中看到如下输出结果，指示输入测试点所属的位置：

[[ 0.   0.]]
[[ 1.   0.]]
[[ 1.   1.]]

可以基于标签来验证输出结果的准确性。

11.4　创建一个深度神经网络

接下来创建一个深度神经网络。一个深度神经网络由一个输入层、多个隐藏层和一个输出层组成。该模型如图11-6所示。

图　11-6

图11-6展示了一个多层神经网络，该神经网络包括一个输入层、多个隐藏层和一个输出层。在一个深度神经网络中，输入层和输出层之间有多个隐藏层。

详细步骤

(1) 创建一个Python文件，并导入以下程序包：

import neurolab as nl
import numpy as np
import matplotlib.pyplot as plt

(2) 定义以下参数，用于生成训练数据：

# 生成训练数据
min_value = -12
max_value = 12
num_datapoints = 90

(3) 训练数据将由我们定义的一个函数组成，该函数将转换值。我们期望神经网络可以根据提供的输入数据和输出数据来学习这一点：

x = np.linspace(min_value, max_value, num_datapoints)
y = 2 * np.square(x) + 7
y /= np.linalg.norm(y)

(4) 数组变形：

data = x.reshape(num_datapoints, 1)
labels = y.reshape(num_datapoints, 1)

(5) 画出输入数据：

# 画出输入数据
plt.figure()
plt.scatter(data, labels)
plt.xlabel('X-axis')
plt.ylabel('Y-axis')
plt.title('Input data')

(6) 定义一个深度神经网络，该神经网络包含两个隐藏层，每个隐藏层包含10个神经元：

# 定义一个深度神经网络，带两个隐藏层；每个隐藏层由10个神经元组成，输出层由一个神经元组成
multilayer_net = nl.net.newff([[min_value, max_value]], [10, 10, 1])

(7) 设置训练算法为梯度下降法（关于梯度下降法的介绍可参考https://spin.atomicobject.com/2014/06/24/gradient-descent-linear-regression）：

# 设置训练算法为梯度下降法
multilayer_net.trainf = nl.train.train_gd

(8) 训练网络：

# 训练网络
error = multilayer_net.train(data, labels, epochs=800, show=100, goal=0.01)

(9) 用训练数据运行该网络，查看其性能表现：

# 用训练数据运行该网络，预测结果
predicted_output = multilayer_net.sim(data)

(10) 画出训练误差结果：

# 画出训练误差结果
plt.figure()
plt.plot(error)
plt.xlabel('Number of epochs')
plt.ylabel('Error')
plt.title('Training error progress')

(11) 创建一组新的输入数据，并运行神经网络，查看其性能表现：

# 画出预测结果
x2 = np.linspace(min_value, max_value, num_datapoints * 2)
y2 = multilayer_net.sim(x2.reshape(x2.size,1)).reshape(x2.size)
y3 = predicted_output.reshape(num_datapoints)

(12) 画出输出结果：

plt.figure()
plt.plot(x2, y2, '-', x, y, '.', x, y3, 'p')
plt.title('Ground truth vs predicted output')

plt.show()

(13) 全部代码已经在deep_neural_network.py文件中给出。运行该代码，可以看到3幅图像。第一幅图像显示输入数据，如图11-7所示。

图　11-7

第二幅图像显示训练误差进程，如图11-8所示。

图　11-8

第三幅图像显示神经网络的输出，如图11-9所示。

图　11-9

可以在命令行工具中看到如图11-10所示的显示结果。

图　11-10

11.5　创建一个向量量化器

你也可以用神经网络来做向量量化。向量量化是N 维空间的“四舍五入”，广泛用于各个领域，如计算机视觉、自然语言处理和机器学习等。

详细步骤

(1) 创建一个Python文件，并导入以下程序包：

import numpy as np
import matplotlib.pyplot as plt
import neurolab as nl

(2) 从data_vq.txt文件加载输入数据：

# 定义输入数据
input_file = 'data_vq.txt'
input_text = np.loadtxt(input_file)
data = input_text[:, 0:2]
labels = input_text[:, 2:]

(3) 定义一个两层的学习向量量化（Learning Vector Quantization，LVQ）神经网络。函数中最后一个参数的数组指定了每个输出的加权百分比（各加权百分比之和应为1）：

# 定义一个两层神经网络：输入含10个神经元，输出含4个神经元
net = nl.net.newlvq(nl.tool.minmax(data), 10, [0.25, 0.25, 0.25, 0.25])

(4) 训练LVQ神经网络：

# 训练神经网络
error = net.train(data, labels, epochs=100, goal=-1)

(5) 创建一个用于测试及可视化的网格点值：

# 创建输入网格
xx, yy = np.meshgrid(np.arange(0, 8, 0.2), np.arange(0, 8, 0.2))
xx.shape = xx.size, 1
yy.shape = yy.size, 1
input_grid = np.concatenate((xx, yy), axis=1)

(6) 用这些网格点值评价该网络：

# 用这些网格点值评价该网络
output_grid = net.sim(input_grid)

(7) 在数据中定义4个类：

# 定义4个类
class1 = data[labels[:,0] == 1]
class2 = data[labels[:,1] == 1]
class3 = data[labels[:,2] == 1]
class4 = data[labels[:,3] == 1]

(8) 为每个类定义网格：

# 为4个类定义网格
grid1 = input_grid[output_grid[:,0] == 1]
grid2 = input_grid[output_grid[:,1] == 1]
grid3 = input_grid[output_grid[:,2] == 1]
grid4 = input_grid[output_grid[:,3] == 1]

(9) 画出输出结果：

# 画出输出结果
plt.plot(class1[:,0], class1[:,1], 'ko', class2[:,0], class2[:,1], 'ko',
                class3[:,0], class3[:,1], 'ko', class4[:,0], class4[:,1], 'ko')
plt.plot(grid1[:,0], grid1[:,1], 'b.', grid2[:,0], grid2[:,1], 'gx',
                grid3[:,0], grid3[:,1], 'cs', grid4[:,0], grid4[:,1], 'ro')
plt.axis([0, 8, 0, 8])
plt.xlabel('X-axis')
plt.ylabel('Y-axis')
plt.title('Vector quantization using neural networks')

plt.show()

(10) 全部代码已经在vector_quantization.py文件中给出。运行该代码，可以看到图像空间被分成不同区域，如图11-11所示。每个区域对应空间中向量量化区域列表中的一个。

图　11-11

11.6　为序列数据分析创建一个递归神经网络

递归神经网络能较好地分析序列和时间序列数据。你可以在http://www.wildml.com/2015/09/recurrent-neural-networks-tutorial-part-1-introduction-to-rnns看到更多关于递归神经网络的详细内容。在处理序列和时间序列数据时，不能简单地扩展通用模型。数据的时序相关性非常关键，构建模型时需要考虑到这一点。接下来看看如何创建递归神经网络。

详细步骤

(1) 创建一个Python文件，并导入以下程序包：

import numpy as np
import matplotlib.pyplot as plt
import neurolab as nl

(2) 定义一个函数，该函数利用输入参数创建一个波形：

def create_waveform(num_points):
    # 创建训练样本
    data1 = 1 * np.cos(np.arange(0, num_points))
    data2 = 2 * np.cos(np.arange(0, num_points))
    data3 = 3 * np.cos(np.arange(0, num_points))
    data4 = 4 * np.cos(np.arange(0, num_points))

(3) 为每个区间创建不同的振幅，以此来创建一个随机波形：

    # 创建不同的振幅
    amp1 = np.ones(num_points)
    amp2 = 4 + np.zeros(num_points)
    amp3 = 2 * np.ones(num_points)
    amp4 = 0.5 + np.zeros(num_points)

(4) 将数组合并生成输出数组，其中数据对应输入，而振幅对应相应的标签：

    data = np.array([data1, data2, data3, data4]).reshape(num_points * 4, 1)
    amplitude = np.array([[amp1, amp2, amp3, amp4]]).reshape(num_points * 4, 1)

    return data, amplitude

(5) 定义一个函数，用于画出将数据传入训练的神经网络后的输出：

# 使用网络画出输出结果
def draw_output(net, num_points_test):
    data_test, amplitude_test = create_waveform(num_points_test)
    output_test = net.sim(data_test)
    plt.plot(amplitude_test.reshape(num_points_test * 4))
    plt.plot(output_test.reshape(num_points_test * 4))

(6) 定义main函数，并生成示例数据：

if __name__=='__main__' :
    # 获取数据
    num_points = 30
    data, amplitude = create_waveform(num_points)

(7) 创建一个两层的递归神经网络：

    # 创建一个两层的神经网络
    net = nl.net.newelm([[-2, 2]], [10, 1], [nl.trans.TanSig(), nl.trans.PureLin()])

(8) 设定每层的初始化函数：

    # 设定初始化函数并进行初始化
    net.layers[0].initf = nl.init.InitRand([-0.1, 0.1], 'wb')
    net.layers[1].initf= nl.init.InitRand([-0.1, 0.1], 'wb')
    net.init()

(9) 训练递归神经网络：

   # 训练递归神经网络
    error = net.train(data, amplitude, epochs=1000, show=100, goal=0.01)

(10) 为训练数据计算来自网络的输出：

   # 计算来自网络的输出
    output = net.sim(data)

(11) 画出训练误差：

    # 画出训练结果
    plt.subplot(211)
    plt.plot(error)
    plt.xlabel('Number of epochs')
    plt.ylabel('Error (MSE)')

(12) 画出结果：

    plt.subplot(212)
    plt.plot(amplitude.reshape(num_points * 4))
    plt.plot(output.reshape(num_points * 4))
    plt.legend(['Ground truth', 'Predicted output'])

(13) 创建一个随机长度的波形，查看该神经网络能否预测：

    # 在多个尺度上对未知数据进行测试
    plt.figure()

    plt.subplot(211)
    draw_output(net, 74)
    plt.xlim([0, 300])

(14) 创建另一个长度更短的波形，查看该神经网络能否预测：

    plt.subplot(212)
    draw_output(net, 54)
    plt.xlim([0, 300])

    plt.show()

(15) 全部代码已经在recurrent_network.py文件中给出。运行该代码，可以看到两幅图像。第一幅图像展示的是训练数据的训练误差及其性能表现，如图11-12所示。

图　11-12

第二幅图像展示的是该训练过的神经网络对于任意长度序列的表现，如图11-13所示。

图　11-13

可以在命令行工具中看到如图11-14所示的结果。

图　11-14

11.7　在光学字符识别数据库中将字符可视化

接下来看看如何利用神经网络做光学字符的识别。光学字符识别是指识别图像中的手写字符的过程。我们会用到http://ai.stanford.edu/~btaskar/ocr中提供的数据集，下载后的默认文件名为letter.data。首先来看如何处理这些数据并将其可视化。

详细步骤

(1) 创建一个Python文件，并导入以下程序包：

import os
import sys

import cv2
import numpy as np

(2) 定义输入文件名：

# 加载数据
input_file = 'letter.data'

(3) 定义可视化参数：

# 定义可视化参数
scaling_factor = 10
start_index = 6
end_index = -1
h, w = 16, 8

(4) 循环迭代文件直至用户按下Esc键。用Tab分隔符将行分隔成字符：

# 循环直至用户按下Esc键
with open(input_file, 'r') as f:
    for line in f.readlines():
        data = np.array([255*float(x) for x in line.split('\t')
               [start_index:end_index]])

(5) 将数组重新调整为所需的形状，调整大小并将其展示：

        img = np.reshape(data, (h,w))
        img_scaled = cv2.resize(img, None, fx=scaling_factor, fy=scaling_factor)
        cv2.imshow('Image', img_scaled)

(6) 如果用户按下Esc键，则终止循环：

        c = cv2.waitKey()
        if c == 27:
            break

(7) 全部代码已经在visualize_characters.py文件中给出。运行该代码，可以看到一个展示字符的窗体。例如，字母“o”的形状如图11-15所示。

图　11-15

字母“i”的形状如图11-16所示。

图　11-16

11.8　用神经网络创建一个光学字符识别器

知道如何与数据交互后，接下来创建一个基于神经网络的光学字符识别系统。

详细步骤

(1) 创建一个Python文件，并导入以下程序包：

import numpy as np
import neurolab as nl

(2) 定义输入文件名称：

# 输入文件
input_file = 'letter.data'

(3) 在用神经网络处理大量数据时，往往需要花费很多时间来做训练。为了展示如何创建这个系统，这里只使用20个数据点：

# 从输入文件加载数据点
num_datapoints = 20

(4) 观察数据，可以看到在前20行有7个不同的字符。将其定义如下：

# 不同的字符
orig_labels = 'omandig'

# 不同字符的数量
num_output = len(orig_labels)

(5) 用数据集的90%做训练，剩下的10%做测试。定义训练和测试参数如下：

# 定义训练和测试参数
num_train = int(0.9 * num_datapoints)
num_test = num_datapoints - num_train

(6) 数据文件中每行的起始索引值和终止索引值设置如下：

# 定义数据集提取参数
start_index = 6
end_index = -1

(7) 生成数据集：

# 生成数据集
data = []
labels = []
with open(input_file, 'r') as f:
    for line in f.readlines():
        # 按Tab键分割
        list_vals = line.split('\t')

(8) 增加一个错误检查步骤，以查看这些字符是否在标签列表中：

        # 如果字符不在标签列表中，跳过
        if list_vals[1] not in orig_labels:
            continue

(9) 提取标签，并将其添加到主列表的后面：

        # 提取标签，并将其添加到主列表的后面
        label = np.zeros((num_output, 1))
        label[orig_labels.index(list_vals[1])] = 1
        labels.append(label)

(10) 提取字符，并将其添加到主列表的后面：

        # 提取字符，并将其添加到主列表的后面
        cur_char = np.array([float(x) for x in list_vals[start_index:end_index]])
        data.append(cur_char)

(11) 当有足够多数据时跳出循环：

        # 当有足够多数据时跳出循环
        if len(data) >= num_datapoints:
            break

(12) 将以上数据转换成NumPy数组：

# 将数据转换成NumPy数组
data = np.asfarray(data)
labels = np.array(labels).reshape(num_datapoints, num_output)

(13) 提取数据的维度信息：

# 提取数据的维度信息
num_dims = len(data[0])

(14) 用10 000次迭代来训练神经网络：

# 创建并训练神经网络
net = nl.net.newff([[0, 1] for _ in range(len(data[0]))], [128, 16, num_output])
net.trainf = nl.train.train_gd
error = net.train(data[:num_train,:], labels[:num_train,:], epochs=10000,
show=100, goal=0.01)

(15) 为测试输入数据预测输出结构：

# 为测试输入数据预测输出结构
predicted_output = net.sim(data[num_train:, :])
print "\nTesting on unknown data:"
for i in range(num_test):
    print "\nOriginal:", orig_labels[np.argmax(labels[i])]
    print "Predicted:", orig_labels[np.argmax(predicted_output[i])]

(16) 全部代码已经在ocr.py文件中给出。运行该代码，可以在命令行工具中看到如图11-17所示的结果。

图　11-17

神经网络的输出结果如图11-18所示。

图　11-18

第 12 章　可视化数据

在这一章，我们将介绍以下主题：

画3D散点图

画气泡图

画动态气泡图

画饼图

画日期格式的时间序列数据

画直方图

可视化热力图

动态信号的可视化模拟

12.1　简介

数据可视化是机器学习的核心，利用它有助于制定正确的策略来理解数据。数据的视觉表示帮助我们选择正确的算法。数据可视化的主要目标之一就是用图和表清晰地表达出数据，以便我们更准确、更有效地交流信息。

在现实世界中总会存在各种数值数据，我们想将这些数值数据编码成图、线、点、条等，以便直观地显示这些数值中包含的信息，同时可以使复杂分布的数据更容易被理解和应用。这一过程被广泛应用于各种场合之中，包括对比分析、增长率跟踪、市场分布、民意调查等。

我们用不同的图来展示各个变量之间的模式或关系，比如用直方图展示数据的分布。如果想查找一个特定的测量，可以用表格表示。这一章将讨论各种场景下最合适的可视化方式。

12.2　画3D散点图

这一节将学习如何画3D散点图，并学习如何在三维空间中可视化这些点。

详细步骤

(1) 生成一个新的Python文件，并导入以下程序包：

import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D

(2) 生成一个空白图像：

# 生成一个空白图像
fig = plt.figure()
ax = fig.add_subplot(111, projection='3d')

(3) 定义应该生成的值的个数：

# 定义生成的值的个数
n = 250

(4) 生成一个lambda函数来生成给定范围的值：

# 生成lambda函数来生成给定范围的值
f = lambda minval, maxval, n: minval + (maxval - minval) * np.random.rand(n)

(5) 用这个函数生成X、Y和Z值：

# 生成值
x_vals = f(15, 41, n)
y_vals = f(-10, 70, n)
z_vals = f(-52, -37, n)

(6) 画出这些值：

# 画出这些值
ax.scatter(x_vals, y_vals, z_vals, c='k', marker='o')
ax.set_xlabel('X axis')
ax.set_ylabel('Y axis')
ax.set_zlabel('Z axis')

plt.show()

(7) 全部代码已经包含在scatter_3d.py文件中。运行该代码，可以看到如图12-1所示的图像。

图　12-1

12.3　画气泡图

下面看看如何画气泡图。在二维的气泡图中，每一个圆圈的大小表示这个点的幅值。

详细步骤

(1) 生成一个Python文件，并导入如下程序包：

import numpy as np
import matplotlib.pyplot as plt

(2) 定义要生成的值的个数：

# 定义值的个数
num_vals = 40

(3) 生成随机的x值和y值：

# 生成随机数
x = np.random.rand(num_vals)
y = np.random.rand(num_vals)

(4) 在气泡图中定义每个点的面积值：

# 定义每个点的面积
# 指定最大半径
max_radius = 25
area = np.pi * (max_radius * np.random.rand(num_vals)) ** 2

(5) 定义颜色：

# 生成颜色
colors = np.random.rand(num_vals)

(6) 画出这些值：

# 画出数据点
plt.scatter(x, y, s=area, c=colors, alpha=1.0)

plt.show()

(7) 全部代码已经包含在bubble_plot.py文件中。运行该代码，可以看到如图12-2所示的图像。

图　12-2

12.4　画动态气泡图

下面来看看如何画动态气泡图。如果需要将动态的数据可视化，那就需要用到该可视化方式。

详细步骤

(1) 生成一个Python文件，并导入以下程序包：

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation

(2) 定义一个tracker函数，该函数将动态更新气泡图：

def tracker(cur_num):
    # 获取当前索引
    cur_index = cur_num % num_points

(3) 定义颜色：

    # 定义数据点颜色
    datapoints['color'][:, 3] = 1.0

(4) 更新圆圈的大小：

    # 更新圆圈的大小
    datapoints['size'] += datapoints['growth']

(5) 更新集合中最老的数据点的位置：

    # 更新集合中最老的数据点的位置
    datapoints['position'][cur_index] = np.random.uniform(0, 1, 2)
    datapoints['size'][cur_index] = 7
    datapoints['color'][cur_index] = (0, 0, 0, 1)
    datapoints['growth'][cur_index] = np.random.uniform(40, 150)

(6) 更新散点图的参数：

    # 更新散点图的参数
    scatter_plot.set_edgecolors(datapoints['color'])
    scatter_plot.set_sizes(datapoints['size'])
    scatter_plot.set_offsets(datapoints['position'])

(7) 定义main函数并生成一个空白的图像：

if __name__=='__main__':
    # 生成一个图像
    fig = plt.figure(figsize=(9, 7), facecolor=(0,0.9,0.9))
    ax = fig.add_axes([0, 0, 1, 1], frameon=False)
    ax.set_xlim(0, 1), ax.set_xticks([])
    ax.set_ylim(0, 1), ax.set_yticks([])

(8) 定义在任意时间点上的点的个数：

    # 在随机位置创建和初始化数据点，并以随机的增长率进行初始化
    num_points = 20

(9) 用随机值定义这些数据点：

    datapoints = np.zeros(num_points, dtype=[('position', float, 2),
            ('size', float, 1), ('growth', float, 1), ('color', float, 4)])
    datapoints['position'] = np.random.uniform(0, 1, (num_points, 2))
    datapoints['growth'] = np.random.uniform(40, 150, num_points)

(10) 创建一个散点图，该散点图的每一帧都会更新：

    # 创建一个每一帧都会更新的散点图
    scatter_plot = ax.scatter(datapoints['position'][:, 0], datapoints['position'][:, 1],
                      s=datapoints['size'], lw=0.7, edgecolors=datapoints['color'],
                      facecolors='none')

(11) 用tracker函数启动动态模拟：

    # 用tracker函数启动动态模拟
    animation = FuncAnimation(fig, tracker, interval=10)

    plt.show()

(12) 全部代码已经包含在dynamic_bubble_plot.py文件中。运行该程序，可以看到如图12-3所示的图像。

图　12-3

12.5　画饼图

下面来看看如何画一个饼图。如果想将一组数据中各标签的比例值可视化展现时，可以用到该可视化方式。

详细步骤

(1) 生成一个Python文件，并导入以下程序包：

import numpy as np
import matplotlib.pyplot as plt

(2) 定义各标签和相应的值：

# 按照顺时针方向定义各标签和相应的值
data = {'Apple': 26,
        'Mango': 17,
        'Pineapple': 21,
        'Banana': 29,
        'Strawberry': 11}

(3) 定义可视化的颜色：

# 定义可视化的颜色
colors = ['orange', 'lightgreen', 'lightblue', 'gold', 'cyan']

(4) 定义一个变量，以突出饼图的一部分，将其与其他部分分离开。如果不想突出任何部分，将所有值设置为0：

# 定义是否需要突出一部分
explode = (0, 0, 0, 0, 0)

(5) 画饼图。注意，如果使用Python 3版本，应该在下面的函数中调用list(data.values())：

# 画饼图
plt.pie(data.values(), explode=explode, labels=data.keys(),
        colors=colors, autopct='%1.1f%%', shadow=False, startangle=90)

# 设置饼图的宽高比，“equal”表示我们希望它是圆形的
plt.axis('equal')

plt.show()

(6) 全部的代码已经包含在pie_chart.py文件中。运行该代码，可以看到如图12-4所示的图像。

图　12-4

如果将explode数组设置为(0, 0.2, 0, 0, 0)，那么Strawberry部分将分离突出显示，如图12-5所示。

图　12-5

12.6　画日期格式的时间序列数据

下面来看看如何用日期格式画时间序列数据。如果要将各时期的股票数据进行可视化展现，可以使用该可视化形式。

详细步骤

(1) 生成一个Python文件，并导入如下程序包：

import numpy
import matplotlib.pyplot as plt
from matplotlib.mlab import csv2rec
import matplotlib.cbook as cbook
from matplotlib.ticker import Formatter

(2) 定义一个用于将日期格式化的类。init函数设置类变量：

# 定义一个类将日期格式化
class DataFormatter(Formatter):
    def __init__(self, dates, date_format='%Y-%m-%d'):
        self.dates = dates
        self.date_format = date_format

(3) 提取给定时间的值，并用如下格式将其返回：

    # 提取“position”位置的时间t的值
    def __call__(self, t, position=0):
        index = int(round(t))
        if index >= len(self.dates) or index < 0:
            return ''

        return self.dates[index].strftime(self.date_format)

(4) 定义main函数。我们将用到matplotlib中苹果公司的股票报价CSV文件：

if __name__=='__main__':
    # 输入包含股价的CSV文件
    input_file = cbook.get_sample_data('aapl.csv', asfileobj=False)

(5) 加载CSV文件：

    # 将CSV文件加载到numpy记录数组中
    data = csv2rec(input_file)

(6) 提取这些值的子集，并将其画出：

    # 提取子集并画出
    data = data[-70:]

(7) 创建一个格式化对象，并将其用日期数据初始化：

    # 创建一个日期格式化对象
    formatter = DataFormatter(data.date)

(8) 定义X轴和Y轴：

    # X轴
    x_vals = numpy.arange(len(data))

    # Y轴表示收盘价
    y_vals = data.close

(9) 画出数据：

    # 画出数据
    fig, ax = plt.subplots()
    ax.xaxis.set_major_formatter(formatter)
    ax.plot(x_vals, y_vals, 'o-')
    fig.autofmt_xdate()
    plt.show()

(10) 全部的代码已经包含在time_series.py文件中。运行该代码，可以看到如图12-6所示的图像。

图　12-6

12.7　画直方图

下面来看看如何画直方图。如果需要对比两组数据，可以用直方图做对比。

详细步骤

(1) 生成一个Python文件，并导入如下程序包：

import numpy as np
import matplotlib.pyplot as plt

(2) 本例对比苹果和橘子的产量，定义如下数据：

# 输入数据
apples = [30, 25, 22, 36, 21, 29]
oranges = [24, 33, 19, 27, 35, 20]

# 设置组数
num_groups = len(apples)

(3) 创建一个图像并定义其参数：

# 创建图像
fig, ax = plt.subplots()

# 定义X轴
indices = np.arange(num_groups)

# 直方图的宽度和透明度
bar_width = 0.4
opacity = 0.6

(4) 画直方图：

# 画直方图
hist_apples = plt.bar(indices, apples, bar_width,
        alpha=opacity, color='g', label='Apples')

hist_oranges = plt.bar(indices + bar_width, oranges, bar_width,
        alpha=opacity, color='b', label='Oranges')

(5) 设置直方图的参数：

plt.xlabel('Month')
plt.ylabel('Production quantity')
plt.title('Comparing apples and oranges')
plt.xticks(indices + bar_width, ('Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'))
plt.ylim([0, 45])
plt.legend()
plt.tight_layout()

plt.show()

(6) 全部的代码已经包含在histogram.py文件中。运行该代码，可以看到如图12-7所示的图像。

图　12-7

12.8　可视化热力图

接下来看看如何画热力图。当两组数据的各点具有一定的相关性时，可以用这种图形表现方式。矩阵包含的单个值都被表示为图中的颜色值。

详细步骤

(1) 生成一个Python文件，并导入如下程序包：

import numpy as np
import matplotlib.pyplot as plt

(2) 定义两组数据：

# 定义两组数据
group1 = ['France', 'Italy', 'Spain', 'Portugal', 'Germany']
group2 = ['Japan', 'China', 'Brazil', 'Russia', 'Australia']

(3) 生成一个随机二维矩阵：

# 生成一些随机数
data = np.random.rand(5, 5)

(4) 创建一个图像：

# 创建一个图像
fig, ax = plt.subplots()

(5) 创建一个热力图：

# 创建一个热力图
heatmap = ax.pcolor(data, cmap=plt.cm.gray)

(6) 画出这些值：

# 将坐标轴放在图块的中间
ax.set_xticks(np.arange(data.shape[0]) + 0.5, minor=False)
ax.set_yticks(np.arange(data.shape[1]) + 0.5, minor=False)

# 让热力图显示成一张表
ax.invert_yaxis()
ax.xaxis.tick_top()

# 增加坐标轴标签
ax.set_xticklabels(group2, minor=False)
ax.set_yticklabels(group1, minor=False)

plt.show()

(7) 全部的代码已经包含在heatmap.py文件中。运行该代码，可以看到如图12-8所示的图像。

图　12-8

12.9　动态信号的可视化模拟

如果需要将实时信号可视化，最好的方式是看到波形是如何产生的。这一节将讲解如何对实时动态变化的信号进行模拟，并将其可视化。

详细步骤

(1) 生成一个Python文件，并导入如下程序包：

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.animation as animation

(2) 创建一个函数，用于生成阻尼正弦信号：

# 生成信号数据
def generate_data(length=2500, t=0, step_size=0.05):
    for count in range(length):
        t += step_size
        signal = np.sin(2*np.pi*t)
        damper = np.exp(-t/8.0)
        yield t, signal * damper

(3) 定义一个initializer函数，用于将图像中的参数初始化：

# 定义初始化函数
def initializer():
    peak_val = 1.0
    buffer_val = 0.1

(4) 设置这些参数：

    ax.set_ylim(-peak_val * (1 + buffer_val), peak_val * (1 + buffer_val))
    ax.set_xlim(0, 10)
    del x_vals[:]
    del y_vals[:]
    line.set_data(x_vals, y_vals)
    return line

(5) 定义一个函数来画出这些值：

def draw(data):
    # 升级数据
    t, signal = data
    x_vals.append(t)
    y_vals.append(signal)
    x_min, x_max = ax.get_xlim()

(6) 如果这些值超出当前X轴最大值的范围，那么更新X轴最大值并扩展图像：

    if t >= x_max:
        ax.set_xlim(x_min, 2 * x_max)
        ax.figure.canvas.draw()

    line.set_data(x_vals, y_vals)

    return line

(7) 定义main函数：

if __name__=='__main__':
    # 创建图形
    fig, ax = plt.subplots()
    ax.grid()

(8) 提取线：

    # 提取线
    line, = ax.plot([], [], lw=1.5)

(9) 创建变量，并用空列表对其初始化：

   # 创建变量
    x_vals, y_vals = [], []

(10) 用动画器对象定义并启动动画：

    # 定义动画器对象
    animator = animation.FuncAnimation(fig, draw, generate_data,
            blit=False, interval=10, repeat=False, init_func=initializer)

    plt.show()

(11) 全部的代码已经包含在moving_wave_variable.py文件中。运行该代码，可以看到如图12-9所示的图像。

图　12-9

看完了

如果您对本书内容有疑问，可发邮件至contact@turingbook.com，会有编辑或作译者协助答疑。也可访问图灵社区，参与本书讨论。

如果是有关电子书的建议或问题，请联系专用客服邮箱：ebook@turingbook.com。

在这里可以找到我们：

微博 @图灵教育 : 好书、活动每日播报

微博 @图灵社区 : 电子书和好文章的消息

微博 @图灵新知 : 图灵教育的科普小组

微信 图灵访谈 : ituring_interview，讲述码农精彩人生

微信 图灵教育 : turingbooks 　　

091507240605ToBeReplacedWithUserId

版权信息

书名：Python测试驱动开发：使用Django、Selenium和JavaScript进行Web编程（第2版）

作者：[英] 哈利 • J.W. 帕西瓦尔

译者：安道

ISBN：978-7-115-48557-1

本书由北京图灵文化发展有限公司发行数字版。版权所有，侵权必究。

您购买的图灵电子书仅供您个人使用，未经授权，不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟，与我们共同保护知识产权。

如果购买者有侵权行为，我们可能对该用户实施包括但不限于关闭该帐号等维权措施，并可能追究法律责任。

091507240605ToBeReplacedWithUserId

版权声明

O'Reilly Media, Inc. 介绍

业界评论

前言

为什么要写一本关于测试驱动开发的书

写作本书的目的

本书结构

排版约定

提交勘误

使用代码示例

O'Reilly Safari

联系我们

电子书

准备工作和应具备的知识

了解Python 3，会编程

HTML的工作方式

Django

JavaScript

需要安装的软件

Git默认使用的编辑器和其他基本配置

安装Firefox和Geckodriver

搭建虚拟环境

激活和停用虚拟环境

安装Django和Selenium

无法激活虚拟环境时可能会看到的一些错误消息

配套视频

致谢

第2版附加致谢

第一部分　TDD和Django基础

第 1 章　使用功能测试协助安装Django

1.1　遵从测试山羊的教诲，没有测试什么也别做

1.2　让Django运行起来

1.3　创建Git仓库

第 2 章　使用unittest模块扩展功能测试

2.1　使用功能测试驱动开发一个最简可用的应用

2.2　Python标准库中的unittest模块

2.3　提交

第 3 章　使用单元测试测试简单的首页

3.1　第一个Django应用，第一个单元测试

3.2　单元测试及其与功能测试的区别

3.3　Django中的单元测试

3.4　Django中的MVC、URL和视图函数

3.5　终于可以编写一些应用代码了

3.6　urls.py

3.7　为视图编写单元测试

“单元测试/编写代码”循环

第 4 章　测试（及重构）的目的

4.1　编程就像从井里打水

4.2　使用Selenium测试用户交互

4.3　遵守“不测试常量”规则，使用模板解决这个问题

4.3.1　使用模板重构

4.3.2　Django测试客户端

4.4　关于重构

4.5　接着修改首页

4.6　总结：TDD流程

第 5 章　保存用户输入：测试数据库

5.1　编写表单，发送POST请求

5.2　在服务器中处理POST请求

5.3　把Python变量传入模板中渲染

5.4　事不过三，三则重构

5.5　Django ORM和第一个模型

5.5.1　第一个数据库迁移

5.5.2　测试向前走得挺远

5.5.3　添加新字段就要创建新迁移

5.6　把POST请求中的数据存入数据库

5.7　处理完POST请求后重定向

更好的单元测试实践方法：一个测试只测试一件事

5.8　在模板中渲染待办事项

5.9　使用迁移创建生产数据库

5.10　回顾

第 6 章　改进功能测试：确保隔离，去掉含糊的休眠

6.1　确保功能测试之间相互隔离

只运行单元测试

6.2　升级Selenium和Geckodriver

6.3　隐式等待、显式等待和含糊的time.sleep

第 7 章　步步为营

7.1　必要时做少量的设计

7.1.1　不要预先做大量设计

7.1.2　YAGNI

7.1.3　REST（式）

7.2　使用TDD实现新设计

7.3　确保出现回归测试

7.4　逐步迭代，实现新设计

7.5　自成一体的第一步：新的URL

7.5.1　一个新URL

7.5.2　一个新视图函数

7.6　变绿了吗？该重构了

7.7　再迈一小步：一个新模板，用于查看清单

7.8　第三小步：用于添加待办事项的URL

7.8.1　用来测试新建清单的测试类

7.8.2　用于新建清单的URL和视图

7.8.3　删除当前多余的代码和测试

7.8.4　出现回归！让表单指向刚添加的新URL

7.9　下定决心，调整模型

7.9.1　外键关系

7.9.2　根据新模型定义调整其他代码

7.10　每个列表都应该有自己的URL

7.10.1　捕获URL中的参数

7.10.2　按照新设计调整new_list视图

7.11　功能测试又检测到回归

7.12　还需要一个视图，把待办事项加入现有清单

7.12.1　小心霸道的正则表达式

7.12.2　最后一个新URL

7.12.3　最后一个新视图

7.12.4　直接测试响应上下文对象

7.13　使用URL引入做最后一次重构

第二部分　Web 开发要素

第 8 章　美化网站：布局、样式及其测试方法

8.1　如何在功能测试中测试布局和样式

8.2　使用CSS框架美化网站

8.3　Django模板继承

8.4　集成Bootstrap

行和列

8.5　Django中的静态文件

换用StaticLiveServerTestCase

8.6　使用Bootstrap中的组件改进网站外观

8.6.1　超大文本块

8.6.2　大型输入框

8.6.3　样式化表格

8.7　使用自己编写的CSS

8.8　补遗：collectstatic命令和其他静态目录

8.9　没谈到的话题

第 9 章　使用过渡网站测试部署

9.1　TDD以及部署的危险区域

9.2　一如既往，先写测试

9.3　注册域名

9.4　手动配置托管网站的服务器

9.4.1　选择在哪里托管网站

9.4.2　搭建服务器

9.4.3　用户账户、SSH和权限

9.4.4　安装Nginx

9.4.5　安装Python 3.6

9.4.6　解析过渡环境和线上环境所用的域名

9.4.7　使用功能测试确认域名可用而且Nginx正在运行

9.5　手动部署代码

9.5.1　调整数据库的位置

9.5.2　手动创建虚拟环境，使用requirements.txt

9.5.3　简单配置Nginx

9.5.4　使用迁移创建数据库

9.6　手动部署大功告成

第 10 章　为部署到生产环境做好准备

10.1　换用Gunicorn

10.2　让Nginx伺服静态文件

10.3　换用Unix套接字

10.4　把DEBUG设为False，设置ALLOWED_HOSTS

10.5　使用Systemd确保引导时启动Gunicorn

保存改动：把Gunicorn添加到requirements.txt

10.6　考虑自动化

保存配置文件的模板

10.7　保存进度

第 11 章　使用Fabric自动部署

11.1　分析一个Fabric部署脚本

11.1.1　分析一个Fabric部署脚本

11.1.2　使用Git拉取源码

11.1.3　更新settings.py

11.1.4　更新虚拟环境

11.1.5　需要时迁移数据库

11.2　试用部署脚本

11.2.1　部署到线上服务器

11.2.2　使用sed配置Nginx和Gunicorn

11.3　使用Git标签标注发布状态

11.4　延伸阅读

第 12 章　输入验证和测试的组织方式

12.1　针对验证的功能测试：避免提交空待办事项

12.1.1　跳过测试

12.1.2　把功能测试分拆到多个文件中

12.1.3　运行单个测试文件

12.2　功能测试新工具：通用显式等待辅助方法

12.3　补完功能测试

12.4　重构单元测试，分拆成多个文件

第 13 章　数据库层验证

13.1　模型层验证

13.1.1　self.assertRaises上下文管理器

13.1.2　Django怪异的表现：保存时不验证数据

13.2　在视图中显示模型验证错误

确保无效的输入值不会存入数据库

13.3　Django模式：在渲染表单的视图中处理POST请求

13.3.1　重构：把new_item实现的功能移到view_list中

13.3.2　在view_list视图中执行模型验证

13.4　重构：去除硬编码的URL

13.4.1　模板标签{% url %}

13.4.2　重定向时使用get_absolute_url

第 14 章　简单的表单

14.1　把验证逻辑移到表单中

14.1.1　使用单元测试探索表单API

14.1.2　换用Django中的ModelForm类

14.1.3　测试和定制表单验证

14.2　在视图中使用这个表单

14.2.1　在处理GET请求的视图中使用这个表单

14.2.2　大量查找和替换

14.3　在处理POST请求的视图中使用这个表单

14.3.1　修改new_list视图的单元测试

14.3.2　在视图中使用这个表单

14.3.3　使用这个表单在模板中显示错误消息

14.4　在其他视图中使用这个表单

14.4.1　定义辅助方法，简化测试

14.4.2　意想不到的好处：HTML5自带的客户端验证

14.5　值得鼓励

14.6　这难道不是浪费时间吗

14.7　使用表单自带的save方法

第 15 章　高级表单

15.1　针对重复待办事项的功能测试

15.1.1　在模型层禁止重复

15.1.2　题外话：查询集合排序和字符串表示形式

15.1.3　重写旧模型测试

15.1.4　保存时确实会显示完整性错误

15.2　在视图层试验待办事项重复验证

15.3　处理唯一性验证的复杂表单

15.4　在清单视图中使用ExistingListItemForm

15.5　小结：目前所学的Django测试知识

第 16 章　试探JavaScript

16.1　从功能测试开始

16.2　安装一个基本的JavaScript测试运行程序

16.3　使用jQuery和<div>固件元素

16.4　为想要实现的功能编写JavaScript单元测试

16.5　固件、执行顺序和全局状态：JavaScript 测试的重大挑战

16.5.1　使用console.log打印调试信息

16.5.2　使用初始化函数精确控制执行时间

16.6　经验做法：onload样板代码和命名空间

16.7　JavaScript测试在TDD循环中的位置

16.8　一些缺憾

第 17 章　部署新代码

17.1　部署到过渡服务器

17.2　部署到线上服务器

17.3　如果看到数据库错误该怎么办

17.4　总结：为这次新发布打上Git标签

第三部分　高级话题

第 18 章　用户身份验证、探究及去掉探究代码

18.1　无密码验证

18.2　探索性编程（又名“探究”）

18.2.1　为此次探究新建一个分支

18.2.2　前端登录UI

18.2.3　从Django中发出邮件

18.2.4　使用环境变量，避免源码中出现机密信息

18.2.5　在数据库中存储令牌

18.2.6　自定义身份验证模型

18.2.7　结束自定义Django身份验证功能

18.3　去掉探究代码

删除探究代码

18.4　一个极简的自定义用户模型

把测试当作文档

18.5　令牌模型：把电子邮件地址与唯一的ID关联起来

第 19 章　使用驭件测试外部依赖或减少重复

19.1　开始之前布好基本管道

19.2　自己动手模拟（打猴子补丁）

19.3　Python的模拟库

19.3.1　使用unittest.patch

19.3.2　让测试向前迈一小步

19.3.3　测试Django消息框架

19.3.4　在HTML中添加消息

19.3.5　构建登录URL

19.3.6　确认给用户发送了带有令牌的链接

19.4　去除自定义的身份验证后端中的探究代码

19.4.1　一个if语句需要一个测试

19.4.2　get_user方法

19.4.3　在登录视图中使用自定义的验证后端

19.5　使用驭件的另一个原因：减少重复

19.5.1　使用驭件的返回值

19.5.2　在类一级上打补丁

19.6　关键时刻：功能测试能通过吗

19.7　理论上正常，那么实际呢

19.8　完善功能测试，测试退出功能

第 20 章　测试固件和一个显式等待装饰器

20.1　事先创建好会话，跳过登录过程

检查是否可行

20.2　显式等待辅助方法最终版：wait装饰器

第 21 章　服务器端调试技术

21.1　实践是检验真理的唯一标准：在过渡服务器中捕获最后的问题

设置日志

21.2　在服务器上通过环境变量设定机密信息

21.3　调整功能测试，以便通过POP3测试真实的电子邮件

21.4　在过渡服务器中管理测试数据库

21.4.1　创建会话的Django管理命令

21.4.2　让功能测试在服务器上运行管理命令

21.4.3　直接在Python代码中使用Fabric

21.4.4　回顾：在本地服务器和过渡服务器中创建会话的方式

21.5　集成日志相关的代码

21.6　小结

第 22 章　完成“My Lists”页面：由外而内的TDD

22.1　对立技术：“由内而外”

22.2　为什么选择使用“由外而内”

22.3　“My Lists”页面的功能测试

22.4　外层：表现层和模板

22.5　下移一层到视图函数（控制器）

22.6　使用由外而内技术，再让一个测试通过

22.6.1　快速重组模板的继承层级

22.6.2　使用模板设计API

22.6.3　移到下一层：视图向模板中传入什么

22.7　视图层的下一个需求：新建清单时应该记录属主

抉择时刻：测试失败时是否要移入下一层

22.8　下移到模型层

最后一步：实现模板需要的.name属性

第 23 章　测试隔离和“倾听测试的心声”

23.1　重温抉择时刻：视图层依赖于尚未编写的模型代码

23.2　首先尝试使用驭件实现隔离

使用驭件的side_effect属性检查事件发生的顺序

23.3　倾听测试的心声：丑陋的测试表明需要重构

23.4　以完全隔离的方式重写视图测试

23.4.1　为了新测试的健全性，保留之前的整合测试组件

23.4.2　完全隔离的新测试组件

23.4.3　站在协作者的角度思考问题

23.5　下移到表单层

始终倾听测试的心声：从应用中删除ORM代码

23.6　下移到模型层

回到视图层

23.7　关键时刻，以及使用模拟技术的风险

23.8　把层与层之间的交互当作“合约”

23.8.1　找出隐形合约

23.8.2　修正由于疏忽导致的问题

23.9　还缺一个测试

23.10　清理：保留哪些整合测试

23.10.1　删除表单层多余的代码

23.10.2　删除以前实现的视图

23.10.3　删除视图层多余的代码

23.11　总结：什么时候编写隔离测试，什么时候编写整合测试

23.11.1　以复杂度为准则

23.11.2　两种测试都要写吗

23.11.3　继续前行

第 24 章　持续集成

24.1　安装Jenkins

24.2　配置Jenkins

24.2.1　首次解锁

24.2.2　现在建议安装的插件

24.2.3　配置管理员用户

24.2.4　添加插件

24.2.5　告诉Jenkins到哪里寻找Python 3和Xvfb

24.2.6　设置HTTPS

24.3　设置项目

24.4　第一次构建

24.5　设置虚拟显示器，让功能测试能在无界面的环境中运行

24.6　截图

24.7　如有疑问，增加超时试试

24.8　使用PhantomJS运行QUnit JavaScript测试

24.8.1　安装node

24.8.2　在Jenkins中添加构建步骤

24.9　CI服务器能完成的其他操作

第 25 章　简单的社会化功能、页面模式以及练习

25.1　有多个用户以及使用addCleanup的功能测试

25.2　页面模式

25.3　扩展功能测试测试第二个用户和“My Lists”页面

25.4　留给读者的练习

第 26 章　测试运行速度的快慢和炽热的岩浆

26.1　正题：单元测试除了运行速度超快之外还有其他优势

26.1.1　测试运行得越快，开发速度越快

26.1.2　神赐的心流状态

26.1.3　经常不想运行速度慢的测试，导致代码变坏

26.1.4　现在还行，不过随着时间推移，整合测试会变得越来越慢

26.1.5　别只听我一个人说

26.1.6　单元测试能驱使我们实现好的设计

26.2　纯粹的单元测试有什么问题

26.2.1　隔离的测试难读也难写

26.2.2　隔离测试不会自动测试集成情况

26.2.3　单元测试几乎不能捕获意料之外的问题

26.2.4　使用驭件的测试可能和实现方式联系紧密

26.2.5　这些问题都可以解决

26.3　合题：我们到底想从测试中得到什么

26.3.1　正确性

26.3.2　简洁可维护的代码

26.3.3　高效的工作流程

26.3.4　根据所需的优势评估测试

26.4　架构方案

26.4.1　端口和适配器（或六边形、简洁）架构

26.4.2　函数式核心，命令式外壳

26.5　小结

扩展阅读

遵从测试山羊的教诲

测试很难

让CI构建始终能通过

像重视代码一样重视测试

别忘了给吧台服务员小费

别见外

附录 A　PythonAnywhere

A.1　使用Xvfb在Firefox中运行Selenium会话

A.2　以PythonAnywhere Web应用的方式安装Django

A.3　清理/tmp目录

A.4　截图

A.5　关于部署

附录 B　基于类的Django视图

B.1　基于类的通用视图

B.2　使用FormView实现首页

B.3　使用form_valid定制CreateView

B.4　一个更复杂的视图，既能查看清单，也能向清单中添加待办事项

B.4.1　测试有指引作用，但时间不长

B.4.2　现在不得不反复实验

B.4.3　测试再次发挥作用

B.4.4　这是最终结果吗

B.5　新旧版对比

B.6　为CBGV编写单元测试有最佳实践吗

B.7　记住：编写多个只有一个断言的隔离视图测试有所帮助

附录 C　使用Ansible配置服务器

C.1　安装系统包和Nginx

C.2　配置Gunicorn，使用处理程序重启服务

C.3　接下来做什么

C.3.1　把Fabric执行的部署操作交给Ansible

C.3.2　使用Vagrant搭建本地虚拟主机

附录 D　测试数据库迁移

D.1　尝试部署到过渡服务器

D.2　在本地执行一个用于测试的迁移

D.2.1　输入有问题的数据

D.2.2　从线上网站中复制测试数据

D.2.3　确认的确有问题

D.3　插入一个数据迁移

重新创建以前的迁移

D.4　一起测试这两个迁移

D.5　小结

附录 E　行为驱动开发

E.1　BDD是什么

E.2　基本的准备工作

E.3　使用Gherkin句法以“特性描述”的形式编写功能测试

E.3.1　As-a/I want to/So that

E.3.2　Given/When/Then

E.3.3　并不始终完美契合

E.4　编写步骤函数

生成占位步骤

E.5　定义第一步

E.6　environment.py中与setUp和tearDown等价的函数

E.7　再次运行

E.8　在步骤中捕获参数

E.9　与行间式功能测试比较

E.10　BDD得到的测试代码结构更好

E.11　与页面模式比较

E.12　BDD可能没有行间注释的表达力强

E.13　非程序员会编写测试吗

E.14　目前的结论

附录 F　构建一个REST API：JSON、Ajax和 JavaScript模拟技术

F.1　本附录采用的方案

F.2　选择测试方案

F.3　基本结构

F.4　返回实质内容

F.5　添加对POST请求的支持

F.6　使用Sinon.js测试客户端Ajax

使用Sinon测试Ajax请求的异步行为

F.7　在模板中连接各部分，确认这样是否真的可行

F.8　实现Ajax POST，包括CSRF令牌

F.9　JavaScript中的模拟技术

结束重构：让测试与代码匹配

F.10　数据验证：留给读者的练习

附录 G　Django-Rest-Framework

G.1　安装

G.2　串化器（具体而言是ModelSerializer）

G.3　Viewset（具体而言是ModelViewSet）和路由器

G.4　通过POST请求添加待办事项的URL

G.5　调整客户端代码

G.6　Django-Rest-Framework的优势

G.6.1　用配置代替代码

G.6.2　自带的功能

附录 H　速查表

H.1　项目开始阶段

H.2　TDD基本流程

H.3　测试不止要在开发环境中运行

H.4　通用的测试最佳实践

H.5　Selenium/功能测试最佳实践

H.6　由外而内，测试隔离与整合测试，模拟技术

附录 I　接下来做什么

I.1　提醒——站内提醒以及邮件提醒

I.2　换用Postgres

I.3　在不同的浏览器中运行测试

I.4　400和500测试

I.5　Django管理后台

I.6　编写一些安全测试

I.7　测试优雅降级

I.8　缓存和性能测试

I.9　JavaScript MVC框架

I.10　异步和websocket

I.11　换用py.test

I.12　试试coverage.py

I.13　客户端加密

I.14　你的建议

附录 J　示例源码

J.1　使用Git检查自己的进度

J.2　下载各章代码的ZIP文件

J.3　不要完全依赖我的代码

参考书目

作者简介

封面介绍

版权声明

© 2017 by Harry Percival.

Simplified Chinese Edition, jointly published by O'Reilly Media, Inc. and Posts & Telecom Press, 2018. Authorized translation of the English edition, 2014 O'Reilly Media, Inc., the owner of all rights to publish and sell the same.

All rights reserved including the rights of reproduction in whole or in part in any form.

英文原版由 O'Reilly Media, Inc. 出版，2017。

简体中文版由人民邮电出版社出版，2018。英文原版的翻译得到 O'Reilly Media, Inc. 的授权。此简体中文版的出版和销售得到出版权和销售权的所有者——O'Reilly Media, Inc. 的许可。

版权所有，未得书面许可，本书的任何部分和全部不得以任何形式重制。

O'Reilly Media, Inc. 介绍

O'Reilly Media 通过图书、杂志、在线服务、调查研究和会议等方式传播创新知识。自 1978 年开始，O'Reilly 一直都是前沿发展的见证者和推动者。超级极客们正在开创着未来，而我们关注真正重要的技术趋势——通过放大那些“细微的信号”来刺激社会对新科技的应用。作为技术社区中活跃的参与者，O'Reilly 的发展充满了对创新的倡导、创造和发扬光大。

O'Reilly 为软件开发人员带来革命性的“动物书”；创建第一个商业网站（GNN）；组织了影响深远的开放源代码峰会，以至于开源软件运动以此命名；创立了 Make 杂志，从而成为 DIY 革命的主要先锋；公司一如既往地通过多种形式缔结信息与人的纽带。 O'Reilly 的会议和峰会集聚了众多超级极客和高瞻远瞩的商业领袖，共同描绘出开创新产业的革命性思想。作为技术人士获取信息的选择，O'Reilly 现在还将先锋专家的知识传递给普通的计算机用户。无论是通过书籍出版、在线服务或者面授课程，每一项 O'Reilly 的产品都反映了公司不可动摇的理念——信息是激发创新的力量。

业界评论

“O'Reilly Radar 博客有口皆碑。”

——Wired

“O'Reilly 凭借一系列（真希望当初我也想到了）非凡想法建立了数百万美元的业务。”

——Business 2.0

“O'Reilly Conference 是聚集关键思想领袖的绝对典范。”

——CRN

“一本 O'Reilly 的书就代表一个有用、有前途、需要学习的主题。”

——Irish Times

“Tim 是位特立独行的商人，他不光放眼于最长远、最广阔的视野，并且切实地按照 Yogi Berra 的建议去做了：‘如果你在路上遇到岔路口，走小路（岔路）。’回顾过去， Tim 似乎每一次都选择了小路，而且有几次都是一闪即逝的机会，尽管大路也不错。”

——Linux Journal

前言

我试图通过这本书与世人分享我从黑客变成软件工程师的过程。本书主要介绍测试，但很快你就会发现，除此之外还有很多其他内容。

感谢你阅读本书。

如果你购买了本书，我十分感激。如果你看的是免费在线版，我仍然要感谢你，因为你确定这本书值得花时间来阅读。谁知道呢，说不定等你读完之后，会决定为自己或朋友买一本纸质书。

如果你有任何评论、疑问或建议，希望你能写信告诉我。你可以通过电子邮件直接和我联系，地址是 obeythetestinggoat@gmail.com；或者在 Twitter 上联系我，我的用户名是 @hjwp。你还可以访问本书的网站和博客（http://www.obeythetestinggoat.com/），以及邮件列表（https://groups.google.com/forum/#!forum/obey-the-testing-goat-book）。

希望阅读本书能让你身心愉悦，就像我在写作本书时感到享受一样。

为什么要写一本关于测试驱动开发的书

我知道你会问：“你是谁，为什么要写这本书，我为什么要读这本书？”

我至今仍然处在编程事业的初期。人们说，不管从事什么工作，都要历经从新手到熟手的过程，最终有可能成为大师。我要说的是，我最多算是个熟练的程序员。但我很幸运，在事业的早期阶段就结识了一群测试驱动开发（Test-Driven Development，TDD）的狂热爱好者，这对我的编程事业产生了极大影响，让我迫不及待地想和所有人分享这段经历。可以说，我很积极地做出了最近这次转变，而且这段学习经历现在还历历在目，我希望能让初学者感同身受。

我在开始学习 Python 时（看的是 Mark Pilgrim 写的 Dive Into Python），偶然知道了 TDD 的概念。我当时就认为：“是的，我绝对知道这个概念的意义所在。”或许你第一次听说 TDD 时也有类似的反应吧。它听起来像是一个非常合理的方案，一个需要养成的非常好的习惯——就像经常刷牙。

随后我做了第一个大型项目。你可能猜到了，有项目就会有客户，有最后期限，有很多事情要做。于是，所有关于 TDD 的好想法都被抛诸脑后。

的确，这对项目没什么影响，对我也没影响。

但只是在初期如此。

一开始，我知道并不真的需要使用 TDD，因为我做的是个小网站，手动检查就能轻易测试出是否能用。在这儿点击链接，在那儿选中下拉菜单选项，就应该有预期的效果，很简单。编写整套测试程序听起来似乎要花费很长时间，而且经过整整三周成熟的代码编写经历，我自负地认为自己已经成为一名出色的程序员了，我能顺利完成这个项目，这没什么难度。

随后，项目变得复杂得可怕，这很快暴露了我的经验不足。

项目不断变大。系统的不同部分之间要开始相互依赖。我尽量遵守良好的开发原则，例如“不要自我重复”（Don't Repeat Yourself，DRY），却被带进了一片危险地带。我很快就用到了多重继承，类的继承有八个层级深，还用到了 eval 语句。

我不敢修改代码，不再像以前一样知道什么依赖什么，也不知道修改某处的代码可能会导致什么后果。噢，天呐，我觉得那部分继承自这里，不，不是继承，是重新定义了，可是却依赖那个类变量。嗯，好吧，如果我再次重定义以前重定义的部分，应该就可以了。我会检查的，可是检查变得更难了。网站中的内容越来越多，手动点击变得不切实际了。最好别动这些能运行的代码，不要重构，就这么凑合吧。

很快，代码就变得像一团麻，丑陋不堪。开发新功能变得很痛苦。

在此之后不久，我幸运地在 Resolver Systems 公司（现在叫 PythonAnywhere）找到了一份工作。这个公司遵循极限编程（Extreme Programming，XP）开发理念。他们向我介绍了严密的 TDD。

虽然之前的经验的确让我认识到自动化测试的好处，但我在每个阶段都心存疑虑。“我的意思是，测试通常来说可能是个不错的主意，但果真如此吗？全部都要测试吗？有些测试看起来完全是在浪费时间……什么？除了单元测试之外还要做功能测试？得了吧，这是多此一举！还要走一遍测试驱动开发中的‘测试 / 小幅度代码改动 / 测试’循环？太荒谬了！我们不需要这种婴儿学步般的过程！既然我们知道正确的答案是什么，为什么不直接跳到最后一步呢？”

相信我！我审视过每一条规则，给每一条捷径提出过建议，为 TDD 的每一个看似毫无意义的做法寻找过理由，最终，我发现了采用 TDD 的明智之处。我记不清在心里说过多少次“谢谢你，测试”，因为功能测试能揭示我们可能永远都无法预测的回归，单元测试能让我避免犯很愚蠢的逻辑错误。从心理学上讲，TDD 大大降低了开发过程中的压力，而且写出的代码让人赏心悦目。

那么，让我告诉你关于 TDD 的一切吧！

写作本书的目的

我写这本书的主要目的是要传授一种用于 Web 开发的方法，它可以让 Web 应用变得更好，也能让开发者更愉快。一本书如果只包含一些上网搜索就能找到的知识，那它就没多大的意思了，所以本书不是 Python 句法指南，也不是 Web 开发教程。我希望教会你的，是如何使用 TDD 理念，更加稳妥地实现我们共同的神圣目标——简洁可用的代码。

即便如此，我仍会从零开始使用 Django、Selenium、jQuery 和 Mock 等工具开发一个 Web 应用，不断提到一个真实可用的示例。阅读本书之前，你无须了解这些工具。读完本书后，你会充分了解这些工具，并掌握 TDD 理念。

在极限编程实践中，我们总是结对编程。写这本书时，我设想自己和以前的自己结成对子，向以前的我解释如何使用这些工具，回答为什么要用这种特别的方式编写代码。所以，如果我表现得有点儿屈尊俯就，那是因为我不是那么聪明，我要对自己很有耐心。如果觉得我说话冒犯了你，那是因为我有点儿烦人，经常不认同别人的说法，所以有时要花很多时间论证，说服自己接受他人的观点。

本书结构

我将这本书分成了三个部分。

第一部分（第 1~7 章）：基础知识

开门见山，介绍如何使用 TDD 开发一个简单的 Web 应用。我们会先（用 Selenium）写一个功能测试，然后介绍 Django 的基础知识，包括模型、视图和模板。在每个阶段，我们都会编写严格的单元测试。除此之外，我还会向你引荐测试山羊。

第二部分（第 8~17 章）：Web 开发要素

介绍 Web 开发过程中一些棘手但不可避免的问题，并展示如何通过测试解决这些问题，包括静态文件、部署到生产环境、表单数据验证、数据库迁移和令人畏惧的 JavaScript。

第三部分（第 18~26 章）：高级话题

介绍模拟技术、集成第三方系统、测试固件、由外而内的 TDD 流程以及持续集成（Continuous Integration，CI）。

排版约定

本书使用了下列排版约定。

黑体

表示新术语或强调的内容。

等宽字体（Constant width）

表示程序片段，以及正文中出现的变量、函数名、数据库、数据类型、环境变量、语句和关键字等。

加粗等宽字体（Constant width bold）

表示应该由用户输入的命令或其他文本。

偶尔使用 [...] 符号表示省略了一些内容，截断较长的输出，或者跳到相关的内容。

　该图标表示提示或建议。

　该图标表示提示、建议或一般注记。

　该图标表示警告或警示。

提交勘误

发现了错误或错别字？本书的相关资源放在 GitHub 上，欢迎你随时提交工单和拉取请求：https://github.com/hjwp/Book-TDD-Web-Dev-Python/。

如果发现中文版有错误或错别字，欢迎提交勘误至 http://www.ituring.com.cn/book/2052。

使用代码示例

代码示例可到 https://github.com/hjwp/book-example/ 下载，各章的代码都放在单独的分支中，请到 http://www.ituring.com.cn/book/2052“随书下载”处下载。附录 J 中有这个仓库的使用方法。

本书是要帮你完成工作的。一般来说，如果本书提供了示例代码，你可以把它用在你的程序或文档中。除非你使用了很大一部分代码，否则无须联系我们获得许可。比如，用本书的几个代码片段写一个程序就无须获得许可，销售或分发 O'Reilly 图书的示例光盘则需要获得许可；引用本书中的示例代码回答问题无须获得许可，将书中大量的代码放到你的产品文档中则需要获得许可。

我们很希望但并不强制要求你在引用本书内容时加上引用说明。引用说明一般包括书名、作者、出版社和 ISBN。比如：“Test-Driven Development with Python, 2nd edition, by Harry Percival (O'Reilly). Copyright 2017 Harry Percival, 978-1-491-95870-4.”

如果你觉得自己对示例代码的用法超出了上述许可的范围，欢迎你通过 permissions@oreilly.com 与我们联系。

O'Reilly Safari

Safari（以前叫 Safari Books Online，http://www.safaribooksonline.com）是会员制平台，为企业、政府、教学人员和个人提供培训和参考资料。

会员可以访问上千种图书、培训视频、学习路径、交互式教程和精心制定的播放列表。这些资源由 250 多家出版社提供，包括 O'Reilly Media、Harvard Business Review、Prentice Hall Professional、Addison-Wesley Professional、Microsoft Press、Sams、Que、Peachpit Press、Adobe、Focal Press、Cisco Press、John Wiley & Sons、Syngress、Morgan Kaufmann、IBM Redbooks、Packt、Adobe Press、FT Press、Apress、Manning、New Riders、McGraw-Hill、Jones & Bartlett 和 Course Technology，等等。

详情请访问 http://oreilly.com/safari。

联系我们

请把对本书的评价和问题发给出版社。美国：

　　O'Reilly Media, Inc.

　　1005 Gravenstein Highway North

　　Sebastopol, CA 95472

中国：

　　北京市西城区西直门南大街 2 号成铭大厦 C 座 807 室（100035）

　　奥莱利技术咨询（北京）有限公司

O'Reilly 的每一本书都有专属网页，你可以在那儿找到本书的相关信息，包括勘误表、示例代码以及其他信息。本书的网站地址是：

　　http://shop.oreilly.com/product/0636920029533.do

对于本书的评论和技术性问题，请发送电子邮件到：bookquestions@oreilly.com。

要了解更多 O'Reilly 图书、培训课程、会议和新闻的信息，请访问以下网站：

　　http://www.oreilly.com

我们在 Facebook 的地址如下：http://facebook.com/oreilly

请关注我们的 Twitter 动态：http://twitter.com/oreillymedia

我们的 YouTube 视频地址如下：http://www.youtube.com/oreillymedia

电子书

如需购买本书电子版，请扫描以下二维码。

准备工作和应具备的知识

我假设读者具备了如下的知识，电脑中还应该安装一些软件。

了解Python 3，会编程

写这本书时，我考虑到了初学者。但如果你刚接触编程，我假设你已经学习了 Python 基础知识。如果还没学，请阅读一份 Python 初学者教程，或者买一本入门书，比如 Dive Into Python 或《“笨办法”学 Python》，或者出于兴趣，看一下《Python 游戏编程快速上手》。这三本都是很好的入门书。

如果你是经验丰富的程序员，但刚接触 Python，阅读本书应该没问题。Python 简单易懂。

本书中我用的是 Python 3。我在 2013—2014 年写这本书时，Python 3 已经发布好几年了，全世界的开发者正处在一个拐点上，他们更倾向于选择使用 Python 3。可以参照本书内容在 Mac、Windows 和 Linux 中实践。在各种操作系统中安装 Python 的详细说明后文会介绍。

　本书内容在 Python 3.6 中测试过。如果你使用的是较低的版本，可能会发现细微的差别（比如 f 字符串句法），所以如果可以，最好升级 Python。

我不建议使用 Python 2，因为它和 Python 3 之间的区别太大。如果碰巧你的下一个项目使用的是 Python 2，仍然可以运用从本书中学到的知识。不过，当你得到的程序输出和本书不一样时，要花时间判断是因为用了 Python 2，还是因为你确实犯了错——这么做太浪费时间了。

如果你想使用 PythonAnywhere（这是我就职的 PaaS 创业公司），不愿在本地安装 Python，可以先快速阅读一遍附录 A。

无论如何，我希望你能使用 Python，知道如何从命令行启动 Python，也知道如何编辑和运行 Python 文件。再次提醒，如果你有任何疑问，看一下我前面推荐的三本书。

　如果你已经安装了 Python 2，担心再安装 Python 3 会破坏之前的版本，那大可以放心，Python 3 和 Python 2 可以相安无事地共存于同一个系统中，使用虚拟环境的话（本书就是）更是如此。

HTML的工作方式

我还假定你基本了解 Web 的工作方式，知道什么是 HTML、什么是 POST 请求等。如果你对这些概念不熟悉，那么需要找一份 HTML 基础教程看一下，webplateform 网站上列出了一些。如果你知道如何在电脑中创建 HTML 页面，并在浏览器中查看，也知道表单以及它的工作方式，那么或许你就符合我的要求。

Django

本书使用 Django 框架，这（或许）是 Python 世界最为人认可的 Web 框架。本书不要求读者事先了解 Django，但如果你刚接触 Python、刚接触 Web 开发，也刚接触测试，偶尔会觉得话题太多，有太多的概念要理解。如果发生了这样的情况，我建议你先把本书放下，找份 Django 教程看看。DjangoGirls 是我所知的对新手最友好的教程。官方教程（https://docs.djangoproject.com/en/1.11/intro/tutorial01/）对有经验的程序员来说是不错的选择。

Django 的安装说明参见后文。

JavaScript

本书后半部分有少量 JavaScript。如果你不了解 JavaScript，先别担心。如果你觉得有些看不懂，我到时会推荐一些参考资料给你。

关于 IDE

如果你来自 Java 或.NET 领域，可能非常想使用 IDE（集成开发环境）编写 Python 代码。IDE 中有各种实用的工具，例如 VCS 集成。Python 领域也有一些很棒的 IDE。刚开始我也用过一个 IDE，它对我最初的几个项目很有用。

我能建议（只是建议）你别用 IDE 吗？至少在阅读本书时别用。在 Python 领域，IDE 不是那么重要。写作本书时，我假定你只使用一个简单的文本编辑器和命令行。某些时候，它们是你所能使用的全部工具（例如在服务器中），所以刚开始时值得花时间学习使用基本的工具，理解它们是如何工作的。即便当你读完本书后决定继续使用 IDE 和其中的实用工具，这些基本的工具还是唾手可得。

需要安装的软件

除了 Python 之外，还要安装以下软件。

Firefox Web 浏览器

Selenium 其实能驱动任意一款主流浏览器，不过以 Firefox 举例最简单，因为它跨平台。而且使用 Firefox 还有另外一个好处——和公司利益没有多少关联。

Git 版本控制系统

Git 可在任何一个平台上使用。Windows 安装环境带有 Bash 命令行，这是本书所需的。

装有 Python 3、Django 1.11 和 Selenium 3 的虚拟环境

Python 3.4+ 现在自带 virtualenv 和 pip（早期版本没有，这是一大进步）。搭建虚拟环境的详细说明参见后文。

Geckodriver

这是通过 Selenium 远程控制 Firefox 的驱动。在“安装 Firefox 和 Geckodriver”一节会给出下载链接。

针对 Windows 的说明

Windows 用户有时会觉得被开源世界忽略了，因为 macOS 和 Linux 太普遍了，很容易让人忘记在 Unix 之外还有一个世界。使用反斜线作为目录分隔符？盘符？这些是什么？不过，阅读本书时仍然可以在 Windows 中实践。下面是一些小提示。

在 Windows 中安装 Git 时，一定要选择“Run Git and included Unix tools from the Windows command prompt”（在 Windows 命令提示符中运行 Git 和所含的 Unix 工具）。选择这个选项之后就能使用 Git Bash 了。把 Git Bash 作为主要命令提示符，你就能使用所有实用的 GNU 命令行工具，例如 ls、touch 和 grep，而且目录分隔符也使用斜线表示。

在 Git 安装程序中，还要勾选“Use Windows'default console”（使用 Windows 的默认控制台），否则 Python 在 Git Bash 窗口中无法正常使用。

安装 Python 3 时，除非已经安装了 Python 2 且想继续将它用作默认版本，否则一定要选中“Add Python 3.6 to PATH”（把 Python 3.6 添加到系统路径中，如图 P-1 所示），这样才能在命令行中顺利运行 Python。

　测试所有软件是否正确安装的方法是，打开 Git Bash 命令提示符，在任意一个文件夹中执行命令 python 或 pip。

图P-1：从安装程序将 Python 加入系统路径

针对 MacOS 的说明

MacOS 比 Windows 稍微正常一点儿，不过在 Python 3.4 之前，安装 pip 还是一项极具挑战性的任务。Python 3.4 发布后，安装方法变得简单明了。

使用下载的安装程序就能安装 Python 3.6，省去了很多麻烦。而且，这个安装程序也会自动安装 pip。

Git 安装程序也能顺利运行。

测试这些软件是否正常安装的方法和 Windows 类似：打开一个终端，然后在任意位置执行命令 git、python3 或 pip。如果遇到问题，搜索关键字“system path”和“command not found”，就能找到解决问题的合适资源。

　或许你还应该检验一下 Homebrew。在 Mac 安装众多 Unix 工具的方式中，它是唯一可信赖的。1 虽然现在 Python 安装程序做得不错，但将来你可能会用到 Homebrew。要使用 Homebrew，需下载大小为 1.1 GB 的 Xcode。不过这有个好处——你得到了一个 C 编译器。

1不过我不建议使用 Homebrew 安装 Firefox，因为 Homebrew 会把 Firefox 二进制文件放到一个陌生的位置，Selenium 找不到。虽然这个问题可以解决，但是以常规的方式安装更简单。

Git默认使用的编辑器和其他基本配置

后文我会逐步介绍如何使用 Git，不过现在最好做些配置。例如，首次提交时，默认情况下会弹出 vi，这可能让你手足无措。鉴于 vi 有两种模式，因此你有两个选择。其一，学一些基本的 vi 命令（按 i 键进入插入模式，输入文本后再按 <Esc> 键返回常规模式，然后输入 :wq<Enter> 写入文件并退出）。学会这些命令后，你就加入了一个互助会，这里的人们知道怎么使用这个古老而让人崇敬的文本编辑器。

另外一个选择是直接拒绝这种穿越到 20 世纪 70 年代的荒唐行为，而是配置 Git，让它使用你选择的编辑器。按 <Esc> 键，再输入 :q!，退出 vi，然后修改 Git 使用的默认编辑器。具体方法参见介绍 Git 基本配置的文档。

安装Firefox和Geckodriver

从 https://www.mozilla.org/firefox/ 可下载 Windows 和 macOS 的 Firefox 安装包。Linux 可能已经预装了 Firefox；如果没有，使用包管理器安装。

Geckodriver 可从 https://github.com/mozilla/geckodriver/releases 下载。下载后解压，放到系统路径中的某个位置。

对 macOS 或 Linux 来说，可以放在~/.local/bin 目录中。

对 Windows 来说，可以放在 Python 的 Scripts 文件夹中。

为了确认是否成功安装，打开一个 Bash 控制台，执行下述命令：

geckodriver --version
geckodriver 0.17.0

The source code of this program is available at
https://github.com/mozilla/geckodriver.

This program is subject to the terms of the Mozilla Public License 2.0.
You can obtain a copy of the license at https://mozilla.org/MPL/2.0/.

如果无法执行这个命令，可能是因为~/.local/bin 不在 PATH 中（针对 Mac 和 Linux 系统）。这个文件夹最好加到 PATH 中，因为使用 pip install --user 安装的 Python 包都存储在这里。把这个文件夹添加到 .bashrc 文件中的方法如下所示 2：

2.bashrc 是 Bash 的初始化文件，在家目录中。每次运行 Bash 都会运行这个文件。

echo 'PATH=~/.local/bin:$PATH' >> ~/.bashrc

然后关闭终端，重新打开，再次确认能否执行 geckodriver --version 命令。

搭建虚拟环境

Python 项目所需的环境使用 virtualenv（virtual environment 的简称）搭建。在不同项目的虚拟环境中可以使用不同的包（例如不同版本的 Django，甚至是不同版本的 Python）。而且虚拟环境中的包不是全局安装的，因此无须 root 权限。

Python 从 3.4 版开始集成了用于搭建虚拟环境的 virtualenv 工具，不过我始终建议使用 virtualenvwrapper 这个辅助工具。先安装 virtualenvwrapper（对 Python 版本没有要求）：

# 在Windows中
pip install virtualenvwrapper
# 在macOS/Linux中
pip install --user virtualenvwrapper
# 然后让Bash自动加载virtualenvwrapper
echo "source virtualenvwrapper.sh" >> ~/.bashrc
source ~/.bashrc

　在 Windows 中，virtualenvwrapper 只能在 Git Bash 中使用，而不能在常规的命令行中使用。

virtualenvwrapper 把所有虚拟环境都放在一个地方，而且为激活和停用虚拟环境提供了便利的工具。

下面创建一个名为“superlists”3 的虚拟环境，并在里面安装 Python 3：

3你可能会问为什么叫“superlists”？我可不想剧透！下一章你就知道了。

# 在macOS/Linux中
mkvirtualenv --python=python3.6 superlists
# 在Windows中
mkvirtualenv --python=`py -3.6 -c"import sys; print(sys.executable)"` superlists
#（为了得到一个装有Python 3.6的虚拟环境，我们绕了点弯子）

激活和停用虚拟环境

阅读本书时，一定要先“激活”你的虚拟环境。你之所以能看出我们处在虚拟环境中，通常是因为提示符中有 (superlists)，例如：

$
(superlists) $

创建虚拟环境之后，就直接激活了虚拟环境。你可以执行 which python 命令再次确认：

(superlists) $ which python
/home/harry/.virtualenvs/superlists/bin/python
#（在Windows中会显示为下面这样
# /C/Users/IEUser/.virtualenvs/superlists/Scripts/python）

(superlists) $ deactivate
$ which python
/usr/bin/python
$ python --version
Python 2.7.12 # 在我的设备中，虚拟环境外部的“python”默认为Python 2

$ workon superlists
(superlists) $ which python
/home/harry/.virtualenvs/superlists/bin/python
(superlists) $ python --version
Python 3.6.0

　激活虚拟环境的命令是 workon superlists。若想确认有没有激活，可以看命令提示符中有没有 (superlists) $，或者执行 which python 命令。

安装Django和Selenium

我们将安装 Django 1.11 和最新版 Selenium，即 Selenium 3：

(superlists) $ pip install "django<1.12" "selenium<4"
Collecting django==1.11.3
  Using cached Django-1.11.3-py2.py3-none-any.whl
Collecting selenium<4
  Using cached selenium-3.4.3-py2.py3-none-any.whl
Installing collected packages: django, selenium
Successfully installed django-1.11.3 selenium-3.4.3

无法激活虚拟环境时可能会看到的一些错误消息

对刚接触虚拟环境的人来说，肯定会经常忘记激活虚拟环境（说实话，老手也经常犯这个错，比如我）。这时，你会看到一个错误消息，其中的重要部分如下所示：

ImportError: No module named selenium

或者是：

ImportError: No module named django.core.management

如果遇到这种错误，不要慌，先看看命令提示符中有没有 (superlists)。通常只需执行 workon superlists 就能解决问题。

除此之外，可能还会遇到这个错误：

bash: workon: command not found

这表明你前面少做了一步，没有把 virtualenvwrapper 添加到 .bashrc 中。从前文中找到 echo source virtualenvwrapper.sh 命令，再执行一遍。

'workon' is not recognized as an internal or external command,
operable program or batch file.

这表明你打开的是 Windows 的默认命令提示符 cmd，而不是 Git Bash。关掉 cmd，打开 Git Bash。

编程快乐！

　上述说明对你没什么用，或者你有更好的说明？请给我发电子邮件吧，地址是 obeythetestinggoat@gmail.com。

配套视频

我为本书录制了一套十集的配套视频（http://oreil.ly/1svTFqB）1，主要针对第一部分的内容。如果你更适合通过视频学习，建议你看看。除了书中的内容之外，这套视频还能让你直观地感受 TDD 流程，了解如何在测试和代码之间切换，与此同时保持思路清晰。

1这套视频没有针对第 2 版更新，不过内容基本上依然适用。

我还特意穿了一件亮黄色 T 恤。

致谢

要感谢的人很多，没有他们就不会有这本书，就算有也会写得比现在差。

首先感谢某出版社的 Greg，他是第一个鼓励我、让我相信我能写完这本书的人。虽然你们公司在版权问题上思想过于退化，但我还是要感激你个人对我的信任。

感谢 Michael Foord，他以前也是 Resolver Systems 的员工。我写书的最初灵感便来自于他，因为他自己也写了一本书。感谢他一直支持这个项目。感谢我的老板 Giles Thomas，他傻傻地允许他的另一位员工也去写书（不过，我觉得他现在已经修改了标准的雇佣合同，加上了“禁止写书”的条款）。同样也感谢你不断增长的智慧，把我带入了测试领域。

感谢我的另外两位同事，Glenn Jones 和 Hansel Dunlop。你们总是给我提供非常宝贵的意见，而且在过去一年中耐心地陪我录制单轨对话。

感谢我的妻子 Clementine 和家人，没有他们的支持和耐心，我绝对无法写完这本书。很多时间本该和家人在一起难忘地度过，我却把它们都花在了写作上，为此我感到抱歉。开始写作时，我不知道这本书会对我的生活产生什么影响（“在闲暇时间写怎么样？听起来可行……”）。没有你们的支持，我写不完这本书。

感谢技术审校人员 Jonathan Hartley、Nicholas Tollervey 和 Emily Bache。感谢你们的鼓励和珍贵的反馈。尤其是 Emily，她认真阅读了每一章。感谢 Nick 和 Jon，由衷感谢。感谢你们在我身旁，让写作的过程变得不那么孤单。没有你们，这本书的内容会像傻瓜一样废话连篇。

感谢每个放弃自己的时间把意见反馈给我的人，感谢你们的热心肠：Gary Bernhardt、Mark Lavin、Matt O'Donnell、Michael Foord、Hynek Schlawack、Russell Keith-Magee、Andrew Godwin 和 Kenneth Reitz。你们比我聪慧得多，让我避免说一些愚蠢的事情。当然，书中还有很多愚蠢的内容，不过责任肯定不在你们。

感谢我的编辑 Meghan Blanchette，她是一位非常友善可爱的监工。谢谢你为我规划时间，制止我愚蠢的想法，让本书的写作在正确的轨道上行进。感谢 O'Reilly 出版社其他所有为我提供帮助的人，包括 Sarah Schneider、Kara Ebrahim 和 Dan Fauxsmith，感谢你们让我继续使用英式英语。感谢 Charles Roumeliotis 在行文风格和语法上给我的帮助。虽然我们对芝加哥学派引用和标点符号规则的优势有不同观点，但有你在身边我仍然高兴。感谢设计部门为封面绘制了一头山羊。

特别感谢预览版的读者，感谢你们挑出拼写错误，给我反馈和建议，感谢你们提出各种有助于使本书学习曲线变平滑的方法，感谢你们中的大多数人给我鼓励和支持，让我一直写下去。感谢 Jason Wirth、Dave Pawson、Jeff Orr、Kevin De Baere、crainbf、dsisson、Galeran、Michael Allan、James O'Donnell、Marek Turnovec、Sooner Bourne、julz、Cody Farmer、William Vincent、Trey Hunner、David Souther、Tom Perkin、Sorcha Bowler、Jon Poler、Charles Quast、Siddhartha Naithani、Steve Young、Roger Camargo、Wesley Hansen、Johansen Christian Vermeer、Ian Laurain、Sean Robertson、Hari Jayaram、Bayard Randel、Konrad Korzel、Matthew Waller、Julian Harley、Barry McClendon、Simon Jakobi、Angelo Cordon、Jyrki Kajala、Manish Jain、Mahadevan Sreenivasan、Konrad Korzel、Deric Crago、Cosmo Smith、Markus Kemmerling、Andrea Costantini、Daniel Patrick、Ryan Allen、Jason Selby、Greg Vaughan、Jonathan Sundqvist、Richard Bailey、Diane Soini、Dale Stewart、Mark Keaton、Johan Wärlander、Simon Scarfe、Eric Grannan、Marc-Anthony Taylor、Maria McKinley、John McKenna、Rafał Szymański、Roel van der Goot、Ignacio Reguero、TJ Tolton、Jonathan Means、Theodor Nolte、Jungsoo Moon、Craig Cook、Gabriel Ewilazarus、Vincenzo Pandolfo、David“farbish2”、Nico Coetzee、Daniel Gonzalez、Jared Contrascere、赵亮等很多人。如果我遗漏了你的名字，你绝对有权感到委屈。我当然非常感激你，请给我写封信，我会尽我所能把你的名字加上。

最后，我要感谢你，现在的读者，感谢你决定阅读这本书，希望你喜欢。

第2版附加致谢

感谢第 2 版的编辑 Nan Barber，感谢 Susan Conant、Kristen Brown 和 O'Reilly 整个团队。再次感谢 Emily 和 Jonathan，感谢你们的技术审阅，还要感谢 Edward Wong 细致的笔记。倘若书中还有错误和不足，责任都在我。

感谢免费版的读者们，感谢你们提出的意见和建议，有些读者甚至发起了拉取请求。感谢 Emre Gonulates、Jésus Gómez、Jordon Birk、James Evans、Iain Houston、Jason DeWitt、Ronnie Raney、Spencer Ogden、Suresh Nimbalkar、Darius、Caco、LeBodro、Jeff、wasabigeek、joegnis、Lars、Mustafa、Jared、Craig、Sorcha、TJ、Ignacio、Roel、Justyna、Nathan、Andrea、Alexandr、bilyanhadzhi、mosegontar、sfarzy、henziger、hunterji、das-g、juanriaza、GeoWill、Windsooon、gonulate，等等。我肯定遗漏了一些名字，为此我深感抱歉。

第一部分　TDD和Django基础

第一部分我要介绍测试驱动开发（Test-Driven Development，TDD）的基础知识。我们会从零开始开发一个真实的 Web 应用，而且每个阶段都要先写测试。

这一部分涵盖使用 Selenium 完成的功能测试以及单元测试，还会介绍二者之间的区别。我会介绍 TDD 流程，我称之为“单元测试 / 编写代码”循环。我们还要做些重构，说明怎么结合 TDD 使用。因为版本控制对重要的软件工程来说是基本需求，所以我们还会用到版本控制系统（Git）。我会介绍何时以及如何提交，如何把提交集成到 TDD 和 Web 开发的流程中。

我们要使用 Django，它（或许）是 Python 领域之中最受欢迎的 Web 框架。我会试着慢慢介绍 Django 的概念，一次一个，除此之外还会提供很多扩展阅读资料的链接。如果你完全是刚接触 Django，那么我极力推荐你花时间阅读这些资料。如果你感觉有点儿茫然，花几小时读一遍 Django 的官方教程，然后再回来阅读本书。

你还会结识测试山羊……

　复制粘贴时要小心

如果你看的是电子版，那么在阅读的过程之中就会很自然地会想要复制粘贴书中的代码清单。如果不这么做的话效果会更好：动手输入能形成肌肉记忆，感觉也更真实。你偶尔会打错字，这是无法避免的，调试错误也是一项需要学习的重要技能。

除此之外，你还会发现 PDF 格式相当诡异，复制粘贴时经常会有意想不到的事情发生……

第 1 章　使用功能测试协助安装Django

TDD 不是天生就会的技术，而是像武术一样的一种技能。就像在功夫电影中一样，你需要一个脾气不好、不可理喻的师傅来强制你学习。我们的师傅是测试山羊。

1.1　遵从测试山羊的教诲，没有测试什么也别做

在 Python 测试社区中，测试山羊是 TDD 的非官方吉祥物。测试山羊对不同的人有不同的意义。对我来说，它是我脑海中的一个声音，告诉我要一直走在测试这条正确的道路上，就像卡通片中浮现在肩膀上的天使或魔鬼一样，只是没那么咄咄逼人。我希望借由这本书，让测试山羊也扎根于你的脑海中。

虽然还不太确定要做什么，但我们已经决定要开发一个网站。Web 开发的第一步通常是安装和配置 Web 框架。下载这个，安装那个，配置那个，运行这个脚本……但是，使用 TDD 时要转换思维方式。做测试驱动开发时，你的心里要一直记着测试山羊，像山羊一样专注，咩咩地叫着：“先测试，先测试！”

在 TDD 的过程中，第一步始终一样：编写测试。

首先要编写测试，然后运行，看是否和预期一样失败，只有失败了才能继续下一步——编写应用程序。请模仿山羊的声调复述这个过程。我就是这么做的。

山羊的另一个特点是一次只迈一步。因此，不管山壁多么陡峭，它们都不会跌落。看看图 1-1 里的这只山羊！

图 1-1：山羊比你想象的要机敏（来源：Flickr 用户 Caitlin Stewart）

我们会碎步向前。使用流行的 Python Web 框架 Django 开发这个应用。

首先，要检查是否安装了 Django，并且能够正常运行。检查的方法是，在本地电脑中能否启动 Django 的开发服务器，并在浏览器中查看能否打开网页。使用浏览器自动化工具 Selenium 完成这个任务。

在你想保存项目代码的地方新建一个 Python 文件，命名为 functional_tests.py，并输入以下代码。如果你喜欢一边输入代码一边像山羊那样轻声念叨，或许会有所帮助：

functional_tests.py

from selenium import webdriver

browser = webdriver.Firefox()
browser.get('http://localhost:8000')

assert 'Django' in browser.title

这是我们编写的第一个功能测试（Functional Test，FT）。后面我会深入说明什么是功能测试，以及它和单元测试的区别。现在，只要能理解这段代码做了什么就行。

启动一个 Selenium webdriver，打开一个真正的 Firefox 浏览器窗口。

在这个浏览器中打开我们期望本地电脑伺服的网页。

检查（做一个测试断言）这个网页的标题中是否包含单词“Django”。

我们尝试运行一下：

$ python functional_tests.py
  File ".../selenium/webdriver/remote/webdriver.py", line 268, in get
    self.execute(Command.GET, {'url': url})
  File ".../selenium/webdriver/remote/webdriver.py", line 256, in execute
    self.error_handler.check_response(response)
  File ".../selenium/webdriver/remote/errorhandler.py", line 194, in
check_response
    raise exception_class(message, screen, stacktrace)
selenium.common.exceptions.WebDriverException: Message: Reached error page: abo
ut:neterror?e=connectionFailure&u=http%3A//localhost%3A8000/[...]

你应该会看到弹出了一个浏览器窗口，尝试打开 localhost:8000，然后显示“无法连接”错误页面。这时回到终端，你会看到一个显眼的错误消息，说 Selenium 遇到了一个错误页面。接着，你会看到 Firefox 窗口停留在桌面上，等待你关闭。这可能会让你生气，我们稍后会修正这个问题。

　如果看到关于导入 Selenium 的错误，或者让你查找“geckodriver”错误，或许你应该往前翻，看一下“准备工作和应具备的知识”。

现在，得到了一个失败测试。这意味着，我们可以开始开发应用了。

别了，罗马数字

很多介绍 TDD 的文章都喜欢以罗马数字为例，闹了笑话，甚至我一开始也是这么写的。如果你好奇，可以查看我在 GitHub 的页面，地址是 https://github.com/hjwp/。

以罗马数字为例有好也有坏。把问题简化，合理地限制在某一范围内，让你能很好地解说 TDD。

但问题是不切实际。因此我才决定要从零开始开发一个真实的 Web 应用，以此为例介绍 TDD。这是一个简单的 Web 应用，我希望你能把从中学到的知识运用到下一个真实的项目中。

1.2　让Django运行起来

你肯定已经读过“准备工作和应具备的知识”了，也安装了 Django。使用 Django 的第一步是创建项目，我们的网站就放在这个项目中。Django 为此提供了一个命令行工具：

$ django-admin.py startproject superlists

这个命令会创建一个名为 superlists 的文件夹，并在其中创建一些文件和子文件夹：

.
├── functional_tests.py
├── geckodriver.log
└── superlists
    ├── manage.py
    └── superlists
        ├── __init__.py
        ├── settings.py
        ├── urls.py
        └── wsgi.py

在 superlists 文件夹中还有一个名为 superlists 的文件夹。这有点让人困惑，不过确实需要如此。回顾 Django 的历史，你会找到出现这种结构的原因。现在，重要的是知道 superlists/superlists 文件夹的作用是保存应用于整个项目的文件，例如 settings.py 的作用是存储网站的全局配置信息。

你还会注意到 manage.py。这个文件是 Django 的瑞士军刀，作用之一是运行开发服务器。我们来试一下。执行命令 cd superlists，进入顶层文件夹 superlists（我们会经常在这个文件夹中工作），然后执行：

$ python manage.py runserver
Performing system checks...

System check identified no issues (0 silenced).

You have 13 unapplied migration(s). Your project may not work properly until
you apply the migrations for app(s): admin, auth, contenttypes, sessions.
Run 'python manage.py migrate' to apply them.

Django version 1.11.3, using settings 'superlists.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.

　暂时先不管关于“未应用迁移”的消息，第 5 章将讨论迁移。

这样，Django 的开发服务器便在设备中运行起来了。让这个命令一直运行着，再打开一个命令行窗口（进入刚刚打开的文件夹），在其中再次运行测试：

$ python functional_tests.py
$

　因为打开了新的终端窗口，所以要先执行 workon superlists 命令激活虚拟环境。

我们在命令行中没执行多少操作，但你应该注意两件事：第一，没有丑陋的 AssertionError 了；第二，Selenium 弹出的 Firefox 窗口中显示的页面不一样了。

这虽然看起来没什么大不了，但毕竟是我们第一个通过的测试啊！值得庆祝。

如果感觉有点神奇，不太现实，为什么不手动查看开发服务器，打开浏览器访问 http:// localhost:8000 呢？你会看到如图 1-2 所示的页面。

图 1-2：Django 可用了

如果想退出开发服务器，可以回到第一个 shell 中，按 Ctrl-C 键。

1.3　创建Git仓库

结束这章之前，还要做一件事：把作品提交到版本控制系统（Version Control System，VCS）。如果你是一名经验丰富的程序员，就无须再听我宣讲版本控制了。如果你刚接触 VCS，请相信我，它是必备工具。当项目在几周内无法完成，代码越来越多时，你需要一个工具查看旧版代码、撤销改动、放心地试验新想法，或者只是做个备份。测试驱动开发和版本控制关系紧密，所以我一定要告诉你如何在开发流程中使用版本控制系统。

好的，来做第一次提交。如果现在提交已经晚了，我表示歉意。我们使用 Git 作为 VCS，因为它是最棒的。

我们先把 functional_tests.py 移到 superlists 文件夹中。然后执行 git init 命令，创建仓库：

$ ls
superlists functional_tests.py geckodriver.log
$ mv functional_tests.py superlists/
$ cd superlists
$ git init .
Initialised empty Git repository in /.../superlists/.git/

自此工作目录都是顶层 superlists 文件夹

从现在起，我们会把顶层文件夹 superlists 作为工作目录。

（简单起见，我在命令列表中都将使用 /.../superlists/ 表示这个目录。但实际上，这个目录的真实地址可能是 /home/kind-reader-username/my-python-projects/superlists/。）

我提供的输入命令都假定在这个目录中执行。同样，如果我提到一个文件的路径，也是相对于这个顶层目录而言。因此，superlists/settings.py 是指次级文件夹 superlists 中的 settings.py。

理解了吗？如果有疑问，就查找 manage.py，你要和这个文件在同一个目录中。

现在，看一下要提交的文件：

$ ls
db.sqlite3  manage.py   superlists  functional_tests.py

db.sqlite3 是数据库文件，无须纳入版本控制。前面见过的 geckodriver.log 是 Selenium 的日志文件，也无须跟踪变化。我们要把这两个文件添加到一个特殊的文件 .gitignore 中，让 Git 忽略它们：

$ echo "db.sqlite3" >> .gitignore
$ echo "geckodriver.log" >> .gitignore

接下来，我们可以添加当前文件夹（“.”）中的其他内容了：

$ git add .
$ git status
On branch master

Initial commit

Changes to be committed:
  (use "git rm --cached <file>..." to unstage)

        new file:   .gitignore
        new file:   functional_tests.py
        new file:   manage.py
        new file:   superlists/__init__.py
        new file:   superlists/__pycache__/__init__.cpython-36.pyc
        new file:   superlists/__pycache__/settings.cpython-36.pyc
        new file:   superlists/__pycache__/urls.cpython-36.pyc
        new file:   superlists/__pycache__/wsgi.cpython-36.pyc
        new file:   superlists/settings.py
        new file:   superlists/urls.py
        new file:   superlists/wsgi.py

糟糕，添加了很多 .pyc 文件，这些文件没必要提交。将其从 Git 中删掉，并添加到 .gitignore 中：

$ git rm -r --cached superlists/__pycache__
rm 'superlists/__pycache__/__init__.cpython-36.pyc'
rm 'superlists/__pycache__/settings.cpython-36.pyc'
rm 'superlists/__pycache__/urls.cpython-36.pyc'
rm 'superlists/__pycache__/wsgi.cpython-36.pyc'
$ echo "__pycache__" >> .gitignore
$ echo "*.pyc" >> .gitignore

现在，来看一下进展到哪里了。（你会看到，我使用 git status 的次数太多了，所以经常会使用别名 git st。我不会告诉你怎么做，你要自己探索 Git 别名的秘密！）

$ git status
On branch master

Initial commit

Changes to be committed:
  (use "git rm --cached <file>..." to unstage)

        new file:   .gitignore
        new file:   functional_tests.py
        new file:   manage.py
        new file:   superlists/__init__.py
        new file:   superlists/settings.py
        new file:   superlists/urls.py
        new file:   superlists/wsgi.py

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

        modified:   .gitignore

看起来不错，可以做第一次提交了：

$ git add .gitignore
$ git commit

输入 git commit 后，会弹出一个编辑器窗口，让你输入提交消息。我写的消息如图 1-3 所示。1

1是不是 vi 弹出后你不知道该做什么？或者，你是不是看到了一个消息，内容是关于账户识别的，其中还显示了 git config --global user.username ？再次看一下“准备工作和应具备的知识”，里面有一些简单说明。

图 1-3：首次 Git 提交

　如果你迫切想完成整个 Git 操作，此时还要学习如何把代码推送到云端的 VCS 托管服务中，例如 GitHub 或 BitBucket。如果你在阅读本书的过程中使用不同的电脑，会发现这么做很有用。具体的操作留给你去发掘，GitHub 和 BitBucket 的文档写得都很好。要不，你可以等到第 9 章，到时我们会使用其中一个服务做部署。

对 VCS 的介绍结束。祝贺你！你使用 Selenium 编写了一个功能测试，安装了 Django，并且使用 TDD 方式，以测试山羊赞许的、先写测试这种有保障的方式运行了 Django。在继续阅读第 2 章之前，先表扬一下自己吧，这是你应得的奖励。

第 2 章　使用unittest模块扩展功能测试

测试目前检查的是 Django 默认的“可用了”页面，修改一下，让其检查我们想在真实的网站首页中看到的内容。

是时候公布我们要开发什么类型的 Web 应用了——一个待办事项清单网站。开发这种网站说明我们始终追随时尚：很多年前所有的 Web 开发教程都介绍如何开发博客，后来一窝蜂地又介绍论坛和投票应用，现在时兴的是待办事项清单。

不过待办事项清单是个很好的例子。很明显，这种应用简单，只显示一个由文本字符串组成的列表，因此很容易得到一个最简可用的应用。而且可以使用各种方式扩展功能，例如使用不同的持久模型、添加最后期限、提醒和分享功能，还可以改进客户端 UI。另外，不必只局限于列出待办事项，这种应用可以列出任何事项。最重要的一点是，通过这种应用，可以演示 Web 开发过程中的所有主要步骤，以及如何在各个步骤中运用 TDD 理念。

2.1　使用功能测试驱动开发一个最简可用的应用

使用 Selenium 实现的测试可以驱动真正的网页浏览器，让我们能从用户的角度查看应用是如何运作的。因此，我们把这类测试叫作功能测试。

这意味着，功能测试在某种程度上可以作为应用的说明书。功能测试的作用是跟踪用户故事（User Story），模拟用户使用某个功能的过程，以及应用应该如何响应用户的操作。

术语：功能测试 = 验收测试= 端到端测试

我所说的功能测试，有些人喜欢称之为验收测试（acceptance test）或端到端测试（endto-end test）。这类测试最重要的作用是从外部观察整个应用是如何运作的。另一个术语是黑箱测试（black box test），因为这种测试对所要测试的系统内部一无所知。

功能测试应该有一个人类可读、容易理解的故事。为了叙事清楚，可以把测试代码和代码注释结合起来使用。编写新功能测试时，可以先写注释，勾勒出用户故事的重点。这样写出的测试人类可读，甚至可以作为一种讨论应用需求和功能的方式分享给非程序员看。

TDD 常与敏捷软件开发方法结合在一起使用，我们经常提到的一个概念是“最简可用的应用”，即我们能开发出来的最简单的而且可以使用的应用。下面我们就来开发一个最简可用的应用，尽早试水。

最简可用的待办事项清单其实只要能让用户输入一些待办事项，并且用户下次访问应用时这些事项还在即可。

打开 functional_tests.py，编写一个类似下面的故事：

functional_tests.py

from selenium import webdriver

browser = webdriver.Firefox()

# 伊迪丝听说有一个很酷的在线待办事项应用
# 她去看了这个应用的首页
browser.get('http://localhost:8000')

# 她注意到网页的标题和头部都包含“To-Do”这个词
assert 'To-Do' in browser.title

# 应用邀请她输入一个待办事项

# 她在一个文本框中输入了“Buy peacock feathers”（购买孔雀羽毛）
# 伊迪丝的爱好是使用假蝇做饵钓鱼

# 她按回车键后，页面更新了
# 待办事项表格中显示了“1: Buy peacock feathers”

# 页面中又显示了一个文本框，可以输入其他的待办事项
# 她输入了“Use peacock feathers to make a fly”（使用孔雀羽毛做假蝇）
# 伊迪丝做事很有条理

# 页面再次更新，她的清单中显示了这两个待办事项

# 伊迪丝想知道这个网站是否会记住她的清单
# 她看到网站为她生成了一个唯一的URL
# 而且页面中有一些文字解说这个功能

# 她访问那个URL，发现她的待办事项列表还在

# 她很满意，去睡觉了

browser.quit()

我们有个词来形容注释

我开始在 Resolver 工作时，出于善意习惯在代码中加入密密麻麻的详细注释。我的同事告诉我：“哈利，我们有个词来形容注释，我们把注释叫作谎言。”我很诧异：可我在学校学到的是，注释是好的习惯啊？

他们说得夸张了。注释有其作用，可以添加上下文，说明代码的目的。他们的意思是，简单重复代码意图的注释毫无意义，例如：

# 把wibble的值增加 1
wibble += 1

这样的注释不仅毫无意义，还有一定危险，如果更新代码后没有修改注释，会误导别人。我们要努力做到让代码可读，使用有意义的变量名和函数名，保持代码结构清晰，这样就不再需要通过注释说明代码做了什么，只要偶尔写一些注释说明为什么这么做。

有些情况下注释很重要。后文会看到，Django 在其生成的文件中用到了很多注释，这是解说 API 的一种方式。而且还在功能测试中使用注释描述用户故事——把测试提炼成一个连贯的故事，确保我们始终从用户的角度测试。

这个领域中还有很多有趣的知识，例如行为驱动开发（Behaviour Driven Development，详情参见附录 E）和测试 DSL（Domain Specific Language，领域特定语言）。这些知识已经超出本书的范畴了。

你可能已经发现了，除了在测试中加入注释之外，我还修改了 assert 这行代码，让其查找单词“To-Do”，而不是“Django”。这意味着现在我们期望测试失败。运行这个测试。

首先，启动服务器：

$ python manage.py runserver

然后在另一个 shell 中运行测试：

$ python functional_tests.py
Traceback (most recent call last):
  File "functional_tests.py", line 10, in <module>
    assert 'To-Do' in browser.title
AssertionError

这就是预期失败。其实失败是好消息，虽不像测试通过那么令人振奋，但至少事出有因，证明测试编写得正确。

2.2　Python标准库中的unittest模块

上述测试中有几个恼人的问题需要处理。首先，“AssertionError”消息没什么用，如果测试能指出在浏览器的标题中到底找到了什么就好了。其次，Firefox 窗口一直停留在桌面上，如果能自动将其关闭就好了。

要解决第一个问题，可以使用 assert 关键字的第二个参数，写成：

assert 'To-Do' in browser.title, "Browser title was " + browser.title

Firefox 窗口可在 try/finally 语句中关闭。但这种问题在测试中很常见，标准库中的 unittest 模块已经提供了现成的解决方法。使用这种方法吧！在 functional_tests.py 中写入如下代码：

functional_tests.py

from selenium import webdriver
import unittest

class NewVisitorTest(unittest.TestCase):  ➊

    def setUp(self):  ➌
        self.browser = webdriver.Firefox()

    def tearDown(self):  ➌
        self.browser.quit()

    def test_can_start_a_list_and_retrieve_it_later(self):  ➋
        # 伊迪丝听说有一个很酷的在线待办事项应用
        # 她去看了这个应用的首页
        self.browser.get('http://localhost:8000')

        # 她注意到网页的标题和头部都包含“To-Do”这个词
        self.assertIn('To-Do', self.browser.title)  ➍
        self.fail('Finish the test!')  ➎

        # 应用邀请她输入一个待办事项
        [其余的注释和之前一样]

if __name__ == '__main__':  ❻
    unittest.main(warnings='ignore')  ❼

你可能注意到以下几个地方了。

❶ 测试组织成类的形式，继承自 unittest.TestCase。

❷ 测试的主要代码写在名为 test_can_start_a_list_and_retrieve_it_later 的方法中。名字以 test_ 开头的方法都是测试方法，由测试运行程序运行。类中可以定义多个测试方法。为测试方法起个有意义的名字是个好主意。

❸ setUp 和 tearDown 是特殊的方法，分别在各个测试方法之前和之后运行。我使用这两个方法打开和关闭浏览器。注意，这两个方法有点类似 try/except 语句，就算测试中出错了，也会运行 tearDown 方法。1 测试结束后，Firefox 窗口不会一直停留在桌面上了。

1唯有一个特例：如果 setUp 方法抛出异常，tearDown 方法就不会运行。

❹ 使用 self.assertIn 代替 assert 编写测试断言。unittest 提供了很多这种用于编写测试断言的辅助函数，如 assertEqual、assertTrue 和 assertFalse 等。更多断言辅助函数参见 unittest 的文档。

❺ 不管怎样，self.fail 都会失败，生成指定的错误消息。我使用这个方法提醒测试结束了。

❻ 最后是 if __name__ == '__main__' 分句（如果你之前没见过这种用法，我告诉你，Python 脚本使用这个语句检查自己是否在命令行中运行，而不是在其他脚本中导入）。我们调用 unittest.main() 启动 unittest 的测试运行程序，这个程序会在文件中自动查找测试类和方法，然后运行。

❼ warnings='ignore' 的作用是禁止抛出 ResourceWarning 异常。写作本书时这个异常会抛出，但你阅读时我可能已经把这个参数去掉了。你可以把这个参数删掉，看一下效果。

　如果你阅读 Django 关于测试的文档，可能会看到有个名为 LiveServerTestCase 的类，而且想知道我们现在能否使用它。你能阅读这份友好的手册真是值得表扬！目前来说，LiveServerTestCase 有点复杂，但我保证后面的章节会用到。

来试一下这个测试。

$ python functional_tests.py
F
======================================================================
FAIL: test_can_start_a_list_and_retrieve_it_later (__main__.NewVisitorTest)
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "functional_tests.py", line 18, in
test_can_start_a_list_and_retrieve_it_later
    self.assertIn('To-Do', self.browser.title)
AssertionError: 'To-Do' not found in 'Welcome to Django'

 ---------------------------------------------------------------------
Ran 1 test in 1.747s

FAILED (failures=1)

这样是不是更好了？这个测试清理了 Firefox 窗口，显示了一个排版精美的报告，指出运行了几个测试，其中有几个测试失败了，而且 assertIn 还显示了一个有利于调试的错误消息。太棒了！

2.3　提交

现在是提交代码的好时机，因为已经做了一次完整的修改。我们扩展了功能测试，加入注释说明我们要在最简可用的待办事项清单应用中执行哪些操作。我们还使用 Python 中的 unittest 模块及其提供的各种测试辅助函数重写了测试。

执行 git status 命令，你会发现只有 functional_tests.py 文件的内容变化了。然后执行 git diff 命令，查看上一次提交和当前硬盘中保存内容之间的差异，你会发现 functional_tests.py 文件的变动很大：

$ git diff
diff --git a/functional_tests.py b/functional_tests.py
index d333591..b0f22dc 100644
--- a/functional_tests.py
+++ b/functional_tests.py
@@ -1,6 +1,45 @@
  from selenium import webdriver
+import unittest

-browser = webdriver.Firefox()
-browser.get('http://localhost:8000')
+class NewVisitorTest(unittest.TestCase):

-assert 'Django' in browser.title
+    def setUp(self):
+        self.browser = webdriver.Firefox()
+
+    def tearDown(self):
+        self.browser.quit()
[...]

现在执行下述命令：

$ git commit -a

-a 的意思是：自动添加已跟踪文件（即已经提交的各文件）中的改动。上述命令不会添加全新的文件（你要使用 git add 命令手动添加这些文件）。不过就像这个例子一样，经常没有添加新文件，因此这是个很有用的简便用法。

弹出编辑器后，写入一个描述性的提交消息，比如“使用注释编写规格的首个功能测试，而且使用了 unittest”。

现在我们身处一个绝妙的位置，可以开始为这个清单应用编写真正的代码了。请继续往下阅读。

有用的 TDD 概念

用户故事

从用户的角度描述应用应该如何运行。用来组织功能测试。

预期失败

意料之中的失败。

第 3 章　使用单元测试测试简单的首页

上一章结束时功能测试是失败的，失败消息指出功能测试希望网站的首页标题中包含“To-Do”这个词。现在要开始编写这个应用了。

警告：要动真格的了

我故意把前两章写得这么友好和简单。从现在开始，要真正编写代码了。提前给你打个预防针：有些地方会出错。你看到的结果可能和我说的不一样。这是好事，因为这才是磨练意志的学习经历。

出现这种情况，一个可能的原因是，我表述不清，让你误解了我的本意。你要退一步想想要实现的是什么，在编辑哪个文件，想让用户做些什么，在测试什么，为什么要测试？有可能你编辑了错误的文件或函数，或者运行了其他测试。我觉得停下来想一想能更好地学习 TDD，比照搬所有操作、复制粘贴代码强得多。

还有一种原因，可能真有问题。认真阅读错误消息（详情请参见 3.5 节的“阅读调用跟踪”），你会找到原因的。可能是漏掉了一个逗号或末尾的斜线，或者某个Selenium查找方法少写了一个“s”。但是，正如 Zed Shaw 所说，这种调试也是学习过程的重要组成部分，所以一定要坚持到底！

如果你真的卡住了，随时可以给我发电子邮件，或者在谷歌小组（https://groups.google.com/forum/#!forum/obey-the-testing-goat-book）中发帖。祝你调试愉快！

3.1　第一个Django应用，第一个单元测试

Django 鼓励以应用的形式组织代码。这么做，一个项目中可以放多个应用，而且可以使用其他人开发的第三方应用，也可以重用自己在其他项目中开发的应用。我承认，我自己从没真正这么做过。不过，应用的确是组织代码的好方法。

为待办事项清单创建一个应用：

$ python manage.py startapp lists

这个命令会在 superlists 文件夹中创建子文件夹 lists，与 superlists 子文件夹相邻，并在 lists 中创建一些占位文件，用来保存模型、视图以及目前最关注的测试：

superlists/
├── db.sqlite3
├── functional_tests.py
├── lists
│   ├── admin.py
│   ├── apps.py
│   ├── __init__.py
│   ├── migrations
│   │   └── __init__.py
│   ├── models.py
│   ├── tests.py
│   └── views.py
├── manage.py
└── superlists
    ├── __init__.py
    ├── __pycache__
    ├── settings.py
    ├── urls.py
    └── wsgi.py

3.2　单元测试及其与功能测试的区别

正如给事物所贴的众多标签一样，单元测试和功能测试之间的界线有时不那么清晰。不过，二者之间有个基本区别：功能测试站在用户的角度从外部测试应用，单元测试则站在程序员的角度从内部测试应用。

我遵从的 TDD 方法同时使用这两种类型测试应用。采用的工作流程大致如下。

(1) 先写功能测试，从用户的角度描述应用的新功能。

(2) 功能测试失败后，想办法编写代码让它通过（或者说至少让当前失败的测试通过）。此时，使用一个或多个单元测试定义希望代码实现的效果，保证为应用中的每一行代码（至少）编写一个单元测试。

(3) 单元测试失败后，编写最少量的应用代码，刚好让单元测试通过。有时，要在第 2 步和第 3 步之间多次往复，直到我们觉得功能测试有一点进展为止。

(4) 然后，再次运行功能测试，看能否通过，或者有没有进展。这一步可能促使我们编写一些新的单元测试和代码等。

由此可以看出，这整个过程中，功能测试站在高层驱动开发，而单元测试则从低层驱动我们做些什么。

这个过程看起来是不是有点儿烦琐？有时确实如此，但功能测试和单元测试的目的不完全一样，而且最终写出的测试代码往往区别也很大。

　功能测试的作用是帮助你开发具有所需功能的应用，还能保证你不会无意中破坏这些功能。单元测试的作用是帮助你编写简洁无错的代码。

理论讲得够多了，下面来看一下如何实践。

3.3　Django中的单元测试

来看一下如何为首页视图编写单元测试。打开新生成的文件 lists/tests.py，你会看到类似下面的内容：

lists/tests.py

from django.test import TestCase

# 在这里编写测试

Django 建议我们使用 TestCase 的一个特殊版本。这个版本由 Django 提供，是标准版 unittest.TestCase 的增强版，添加了一些 Django 专用的功能，后面几章会介绍。

你已经知道 TDD 循环要从失败的测试开始，然后再编写代码让其通过。但在此之前，不管单元测试的内容是什么，我们都想知道自动化测试运行程序能否运行我们编写的单元测试。直接运行 functional_tests.py。但 Django 生成的这个文件有点儿神奇，所以要确认一下。来故意编写一个会失败的愚蠢测试：

lists/tests.py

from django.test import TestCase

class SmokeTest(TestCase):

    def test_bad_maths(self):
        self.assertEqual(1 + 1, 3)

现在，要启动神奇的 Django 测试运行程序。和之前一样，这也是一个 manage.py 命令：

$ python manage.py test
Creating test database for alias 'default'...
F
======================================================================
FAIL: test_bad_maths (lists.tests.SmokeTest)
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "/.../superlists/lists/tests.py", line 6, in test_bad_maths
    self.assertEqual(1 + 1, 3)
AssertionError: 2 != 3
 ---------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (failures=1)
System check identified no issues (0 silenced).
Destroying test database for alias 'default'...

太好了，看起来能运行单元测试。现在是提交的好时机：

$ git status  # 会显示一个消息，说没跟踪lists/
$ git add lists
$ git diff --staged # 会显示将要提交的内容差异
$ git commit -m "Add app for lists, with deliberately failing unit test"

你猜得没错，-m 标志的作用是让你在命令行中编写提交消息，这样就不需要使用编辑器了。如何使用 Git 命令行取决于你自己，我只是向你展示我经常见到的用法。不管使用哪种方法，提交时要遵守一个主要原则：提交前一定要审查想提交的内容。

3.4　Django中的MVC、URL和视图函数

总的来说，Django 遵守了经典的模型 - 视图 - 控制器（Model-View-Controller，MVC）模式，但并没严格遵守。Django 确实有模型，但视图更像是控制器，模板其实才是视图。不过，MVC 的思想还在。如果你有兴趣，可以看一下 Django 常见问题解答（https://docs.djangoproject.com/en/1.11/faq/general/）中的详细说明。

抛开这些，Django 和任何一种 Web 服务器一样，主要任务是决定用户访问网站中的某个 URL 时做些什么。Django 的工作流程有点儿类似下述过程。

(1) 针对某个 URL 的 HTTP 请求进入。

(2) Django 使用一些规则决定由哪个视图函数处理这个请求（这一步叫作解析 URL）。

(3) 选中的视图函数处理请求，然后返回 HTTP 响应。

因此要测试两件事。

能否解析网站根路径（“/”）的 URL，将其对应到我们编写的某个视图函数上？

能否让视图函数返回一些 HTML，让功能测试通过？

先编写第一个测试。打开 lists/tests.py，把之前编写的愚蠢测试改成如下代码：

lists/tests.py

from django.urls import resolve
from django.test import TestCase
from lists.views import home_page  ➋

class HomePageTest(TestCase):

    def test_root_url_resolves_to_home_page_view(self):
        found = resolve('/')  ➊
        self.assertEqual(found.func, home_page)  ➊

这段代码是什么意思呢？

❶ resolve 是 Django 内部使用的函数，用于解析 URL，并将其映射到相应的视图函数上。检查解析网站根路径“/”时，是否能找到名为 home_page 的函数。

❷ 这个函数是什么？这是接下来要定义的视图函数，其作用是返回所需的 HTML。从 import 语句可以看出，要把这个函数保存在文件 lists/views.py 中。

那么，你觉得运行这个测试后会有什么结果？

$ python manage.py test
ImportError: cannot import name 'home_page'

这个结果很容易预料，不过错误消息有点无趣：我们试图导入还没定义的函数。但是这对 TDD 来说算是好消息，预料之中的异常也算是预期失败。现在，功能测试和单元测试都失败了，在测试山羊的庇护下，我们可以编写代码了。

3.5　终于可以编写一些应用代码了

很兴奋吧？但要提醒一下：使用 TDD 时要耐着性子，步步为营。尤其是学习和起步阶段，一次只能修改（或添加）一行代码。每一次修改的代码要尽量少，让失败的测试通过即可。

我是故意这么极端的。还记得这个测试为什么会失败吗？因为我们无法从 lists.views 中导入 home_page。很好，来修正这个问题——仅修正这一个而已。在 lists/views.py 中写入下面的代码：

lists/views.py

from django.shortcuts import render

# 在这里编写视图
home_page = None

你可能会想：“不是开玩笑吧？”

我知道你会这么想，因为我的同事第一次给我演示 TDD 时我也是这么想的。耐心一点，稍后会分析这么做是否太极端。现在，就算你有点儿恼怒，也请跟着我一起做，看添加的这段代码能否帮助我们编写正确的代码，向前迈进一小步。

再次运行测试：

$ python manage.py test
Creating test database for alias 'default'...
E
======================================================================
ERROR: test_root_url_resolves_to_home_page_view (lists.tests.HomePageTest)
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "/.../superlists/lists/tests.py", line 8, in
test_root_url_resolves_to_home_page_view
    found = resolve('/')
  File ".../django/urls/base.py", line 27, in resolve
    return get_resolver(urlconf).resolve(path)
  File ".../django/urls/resolvers.py", line 392, in resolve
    raise Resolver404({'tried': tried, 'path': new_path})
django.urls.exceptions.Resolver404: {'tried': [[<RegexURLResolver
<RegexURLPattern list> (admin:admin) ^admin/>]], 'path': ''}

 ---------------------------------------------------------------------
Ran 1 test in 0.002s

FAILED (errors=1)
System check identified no issues (0 silenced).
Destroying test database for alias 'default'...

阅读调用跟踪

花点儿时间讲解如何阅读调用跟踪，因为在 TDD 中经常要做这件事。很快你就能学会如何扫视调用跟踪，找出解决问题的线索：

======================================================================
ERROR: test_root_url_resolves_to_home_page_view (lists.tests.HomePageTest)  ➋
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "/.../superlists/lists/tests.py", line 8, in
test_root_url_resolves_to_home_page_view
    found = resolve('/')  ➌
  File ".../django/urls/base.py", line 27, in resolve
    return get_resolver(urlconf).resolve(path)
  File ".../django/urls/resolvers.py", line 392, in resolve
    raise Resolver404({'tried': tried, 'path': new_path})
django.urls.exceptions.Resolver404: {'tried': [[<RegexURLResolver  ➊
<RegexURLPattern list> (admin:admin) ^admin/>]], 'path': ''}  ➊
 ---------------------------------------------------------------------
[...]

❶ 首先应该查看错误本身。有时你只需查看这一处，就能立即找出问题所在。但某些时候，比如这个例子，原因就不是那么明显。

❷ 接下来要确认哪个测试失败了。是刚才编写按预期会失败的那个测试吗？在这个例子中，就是这个测试。

❸ 然后查看导致失败的测试代码。要从调用跟踪的顶部往下看，找出错误发生在哪个测试文件中哪个测试函数的哪一行代码。在这个例子中，错误发生在调用 resolve 函数解析“/”的那一行代码。

通常还有第四步，即继续往下看，查找问题牵涉的应用代码。在这个例子中全是 Django 的代码，不过在本书后面的内容中有很多示例都用到了这一步。

把以上几步的结果综合起来，可以解读出这个调用跟踪：尝试解析“/”时，Django 抛出了 404 错误。也就是说，Django 无法找到“/”的 URL 映射。下面来解决这个问题。

3.6　urls.py

测试表明，需要一个 URL 映射。Django 用 urls.py 文件把 URL 映射到视图函数上。在文件夹 superlists/superlists 中有个主 urls.py 文件，这个文件应用于整个网站。看一下其中的内容：

superlists/urls.py

"""superlists URL Configuration

The `urlpatterns` list routes URLs to views. For more information please see:
    https://docs.djangoproject.com/en/1.11/topics/http/urls/
Examples:
Function views
    1. Add an import:  from my_app import views
    2. Add a URL to urlpatterns:  url(r'^$', views.home, name='home')
Class-based views
    1. Add an import:  from other_app.views import Home
    2. Add a URL to urlpatterns:  url(r'^$', Home.as_view(), name='home')
Including another URLconf
    1. Import the include() function: from django.conf.urls import url, include
    2. Add a URL to urlpatterns:  url(r'^blog/', include('blog.urls'))
"""
from django.conf.urls import url
from django.contrib import admin

urlpatterns = [
    url(r'^admin/', admin.site.urls),
]

和之前一样，这个文件中也有很多 Django 生成的辅助注释和默认建议。

url 条目的前半部分是正则表达式，定义适用于哪些 URL。后半部分说明把请求发往何处：发给导入的视图函数，或是别处的 urls.py 文件。

第一个示例条目使用的正则表达式是^$，表示空字符串。这和网站的根路径，即我们要测试的“/”一样吗？分析一下，如果把这行代码的注释去掉会发生什么事呢？

　如果你从未见过正则表达式，现在相信我所说的就行。不过你要记在心上，稍后去学习如何使用正则表达式。

我们还将去掉 admin URL，因为暂时用不到 Django 的管理后台。

superlists/urls.py

from django.conf.urls import url
from lists import views

urlpatterns = [
    url(r'^$', views.home_page, name='home'),
]

执行命令 python manage.py test，再次运行测试：

[...]
TypeError: view must be a callable or a list/tuple in the case of include().

确实有进展，不再显示 404 错误了。

调用跟踪有点乱，不过最后一行却指出了问题所在：单元测试把地址“/”和文件 lists/views.py 中的 home_page = None 连接起来了，现在测试抱怨 home_page 无法调用。由此我们知道，要调整一下，把 home_page 从 None 变成真正的函数。记住，每次改动代码都由测试驱使。

回到文件 lists/views.py，把内容改成：

lists/views.py

from django.shortcuts import render

# 在这里编写视图
def home_page():
    pass

现在测试的结果如何？

$ python manage.py test
Creating test database for alias 'default'...
.
 ---------------------------------------------------------------------
Ran 1 test in 0.003s

OK
System check identified no issues (0 silenced).
Destroying test database for alias 'default'...

太好了，第一个测试终于通过了！这是一个重要时刻，我觉得值得提交一次：

$ git diff # 会显示urls.py、tests.py和views.py中的变动
$ git commit -am "First unit test and url mapping, dummy view"

这是我要介绍的最后一种 git commit 用法，把 a 和 m 标志放在一起使用，意思是添加所有已跟踪文件中的改动，而且使用命令行中输入的提交消息。

　git commit -am 是最快捷的方式，但关于提交内容的反馈信息最少，所以在此之前要先执行 git status 和 git diff，弄清楚要把哪些改动放入仓库。

3.7　为视图编写单元测试

该为视图编写测试了。此时，不能使用什么都不做的函数了，我们要定义一个函数，向浏览器返回真正的 HTML 响应。打开 lists/tests.py，添加一个新测试方法。我会解释每一行代码的作用。

lists/tests.py

from django.urls import resolve
from django.test import TestCase
from django.http import HttpRequest

from lists.views import home_page

class HomePageTest(TestCase):

    def test_root_url_resolves_to_home_page_view(self):
        found = resolve('/')
        self.assertEqual(found.func, home_page)

    def test_home_page_returns_correct_html(self):
        request = HttpRequest()  ➊
        response = home_page(request)  ➋
        html = response.content.decode('utf8')  ➌
        self.assertTrue(html.startswith('<html>'))  ➍
        self.assertIn('<title>To-Do lists</title>', html)  ➎
        self.assertTrue(html.endswith('</html>'))  ➍

这个新测试方法都做了些什么呢？

❶ 创建了一个 HttpRequest 对象，用户在浏览器中请求网页时，Django 看到的就是 HttpRequest 对象。

❷ 把这个 HttpRequest 对象传给 home_page 视图，得到响应。听说响应对象是 HttpResponse 类的实例时，你应该不会觉得奇怪。

❸ 然后，提取响应的 .content。得到的结果是原始字节，即发给用户浏览器的 0 和 1。随后，调用 .decode()，把原始字节转换成发给用户的 HTML 字符串。

❹ 希望响应以 <html> 标签开头，并在结尾处关闭该标签。

❺ 希望响应中有一个 <title> 标签，其内容包含单词“To-Do lists”——因为在功能测试中做了这项测试。

再次说明，单元测试由功能测试驱动，而且更接近于真正的代码。编写单元测试时，按照程序员的方式思考。

运行单元测试，看看进展如何：

TypeError: home_page() takes 0 positional arguments but 1 was given

“单元测试/编写代码”循环

现在可以开始适应 TDD 中的单元测试 / 编写代码循环了。

(1) 在终端里运行单元测试，看它们是如何失败的。

(2) 在编辑器中改动最少量的代码，让当前失败的测试通过。

然后不断重复。

想保证编写的代码无误，每次改动的幅度就要尽量小。这么做才能确保每一部分代码都有对应的测试监护。

乍一看工作量很大，初期也的确如此。但熟练之后你便会发现，即使步伐迈得很小，编程的速度也很快。我们在工作中就是这样编写实际代码的。

看一下这个循环可以运转多快。

小幅代码改动：

lists/views.py

def home_page(request):
    pass

运行测试：

html = response.content.decode('utf8')
AttributeError: 'NoneType' object has no attribute 'content'

编写代码——如你所料，使用 django.http.HttpResponse：

lists/views.py

from django.http import HttpResponse

# 在这里编写视图
def home_page(request):
    return HttpResponse()

再运行测试：

    self.assertTrue(html.startswith('<html>'))
AssertionError: False is not true

再编写代码：

lists/views.py

def home_page(request):
    return HttpResponse('<html>')

运行测试：

AssertionError: '<title>To-Do lists</title>' not found in '<html>'

编写代码：

lists/views.py

def home_page(request):
    return HttpResponse('<html><title>To-Do lists</title>')

运行测试——快通过了吧？

    self.assertTrue(html.endswith('</html>'))
AssertionError: False is not true

加油，最后一击：

lists/views.py

def home_page(request):
    return HttpResponse('<html><title>To-Do lists</title></html>')

通过了吗？

$ python manage.py test
Creating test database for alias 'default'...
..
 ---------------------------------------------------------------------
Ran 2 tests in 0.001s

OK
System check identified no issues (0 silenced).
Destroying test database for alias 'default'...

确实通过了。现在要运行功能测试。如果已经关闭了开发服务器，别忘了启动。感觉这是最后一次运行测试了吧，真是这样吗？

$ python functional_tests.py
F
======================================================================
FAIL: test_can_start_a_list_and_retrieve_it_later (__main__.NewVisitorTest)
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "functional_tests.py", line 19, in
test_can_start_a_list_and_retrieve_it_later
    self.fail('Finish the test!')
AssertionError: Finish the test!

 ---------------------------------------------------------------------
Ran 1 test in 1.609s

FAILED (failures=1)

失败了，怎么会？哦，原来是那个提醒？是吗？是的！我们成功编写了一个网页！

好吧，我觉得这样结束本章很刺激。你可能还有点儿摸不着头脑，或许还想知道怎么调整这些测试，别担心，后面的章节会讲。我只是想在临近收尾的时候让你兴奋一下。

要做一次提交，平复一下心情，再回想学到了什么：

$ git diff  # 会显示tests.py中的新测试方法，以及views.py中的视图
$ git commit -am "Basic view now returns minimal HTML"

这一章内容真丰富啊！为什么不执行 git log 命令回顾一下我们取得的进展呢？或许还可以指定 --oneline 标志：

$ git log --oneline
a6e6cc9 Basic view now returns minimal HTML
450c0f3 First unit test and url mapping, dummy view
ea2b037 Add app for lists, with deliberately failing unit test
[...]

不错，本章介绍了以下知识。

新建 Django 应用。

Django 的单元测试运行程序。

功能测试和单元测试之间的区别。

Django 解析 URL 的方法，urls.py 文件的作用。

Django 的视图函数，请求和响应对象。

如何返回简单的 HTML。

有用的命令和概念

启动Django 的开发服务器

python manage.py runserver

运行功能测试

python functional_tests.py

运行单元测试

python manage.py test

“单元测试/ 编写代码”循环

(1) 在终端里运行单元测试。

(2) 在编辑器中改动最少量的代码。

(3) 重复上两步。

第 4 章　测试（及重构）的目的

现在已经实际演练了基本的 TDD 流程，该停下来说说为什么这么做了。

我想象得出很多读者心中都积压了一些挫败感，某些读者可能以前写过单元测试，另一些读者可能只想快速学会如何测试。你们心中有些疑问，比如说：

编写的测试是不是有点儿多了？

其中一些测试肯定有重复吧，比如功能测试和单元测试之间？

我的意思是，你为什么要在单元测试中导入 django.core.urlresolvers 呢？这不是在测试作为第三方代码的 Django 吗？我觉得没必要这么做，这么想对吗？

单元测试有点儿太琐碎了，测试一行声明代码，而且只让函数返回一个常量，这么做难道不是在浪费时间？我们是不是应该把时间腾出来为复杂功能编写测试？

“单元测试 / 编写代码”循环中的小幅改动有必要吗？我们应该可以直接跳到最后一步吧？我想说，home_page = None ？真的有必要吗？

难道现实中你真这样编写代码吗？

年轻人啊！以前我也这样满腹疑问。这些确实是很好的问题，其实，到现在我也经常问自己这些问题。真的值得做这些事吗？这么做是不是有点儿盲目？

4.1　编程就像从井里打水

编程其实很难，我们的成功往往得益于自己的聪明才智。假如我们不那么聪明，TDD 就能助我们一臂之力。Kent Beck（TDD 理念基本上就是他发明的）打了个比方。试想你用绳子从井里提一桶水，如果井不太深，而且桶不是很满，提起来很容易。就算提满满一桶水，刚开始也很容易。但要不了多久你就累了。TDD 理念好比是一个棘轮，你可以使用它保存当前的进度，休息一会儿，而且能保证进度绝不倒退。这样你就没必要一直那么聪明了（如图 4-1）。

图 4-1：全部都要测试 1

1原画出自 Allie Brosh 的网站 Hyperbole and a Half。——译者注

好吧，或许你基本上接受 TDD 是个好主意，但仍然认为我做得太极端了，有必要测试得这么细、步子这么小吗？

测试是一种技能，不是天生就会的。因为很多结果不会立刻显现，需要等待很长一段时间，所以目前你要强迫自己这么做。这就是测试山羊的图片想要表达的——你要对测试顽固一点儿。

细化测试每个函数的好处

就目前而言，测试简单的函数和常量看起来有点傻。

你可能觉得不遵守这么严格的规则，漏掉一些单元测试，应该也算得上是 TDD。但是在这本书中，我所演示的是完整而严格的 TDD 流程。像学习武术中的招式一样，在不受影响的可控环境中才能让技能变成肌肉记忆。现在看起来之所以琐碎，是因为我们刚开始举的例子很简单。程序变复杂后问题就来了，到时你就知道测试的重要性了。你要面临的危险是，复杂性逐渐靠近，而你可能没发觉，但不久之后你就会变成温水煮青蛙。

我赞成为简单的函数编写细化的简单测试，关于这一观点我还有这么两点要说。

首先，既然测试那么简单，写起来就不会花很长时间。所以，别抱怨了，只管写就是了。

其次，占位测试很重要。先为简单的函数写好测试，当函数变复杂后，这道心理障碍就容易迈过去。你可能会在函数中添加一个 if 语句，几周后再添加一个 for 循环，不知不觉间就将其变成一个基于元类（meta-class）的多态树状结构解析器了。因为从一开始你就编写了测试，每次修改都会自然而然地添加新测试，最终得到的是一个测试良好的函数。相反，如果你试图判断函数什么时候才复杂到需要编写测试的话，那就太主观了，而且情况会变得更糟，因为没有占位测试，此时开始编写测试需要投入很多精力，每次改动代码都冒着风险，你开始拖延，很快青蛙就煮熟了。

不要试图找一些不靠谱的主观规则，去判断什么时候应该编写测试，什么时候可以全身而退。我建议你现在遵守我制定的训练方法，因为所有技能都一样，只有花时间学会了规则才能打破规则。

接下来继续实践。

4.2　使用Selenium测试用户交互

前一章结束时进展到哪里了？重新运行测试找出答案：

$ python functional_tests.py
F
======================================================================
FAIL: test_can_start_a_list_and_retrieve_it_later (__main__.NewVisitorTest)
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "functional_tests.py", line 19, in
test_can_start_a_list_and_retrieve_it_later
    self.fail('Finish the test!')
AssertionError: Finish the test!

 ---------------------------------------------------------------------
Ran 1 test in 1.609s

FAILED (failures=1)

你运行了吗？是不是看到一个错误，说加载页面出错或者无法连接？我也看到了。这是因为运行测试之前没有使用 manage.py runserver 启动开发服务器。运行这个命令，然后你会看到我们期待的那个失败消息。

　TDD 的优点之一是，永远不会忘记接下该做什么——重新运行测试就知道要做的事了。

失败消息说“Finish the test”（结束这个测试），那么就来结束它吧！打开 functional_tests.py 文件，扩充其中的功能测试：

functional_tests.py

from selenium import webdriver
from selenium.webdriver.common.keys import Keys
import time
import unittest

class NewVisitorTest(unittest.TestCase):

    def setUp(self):
        self.browser = webdriver.Firefox()

    def tearDown(self):
        self.browser.quit()

    def test_can_start_a_list_and_retrieve_it_later(self):
        # 伊迪丝听说有一个很酷的在线待办事项应用
        # 她去看了这个应用的首页
        self.browser.get('http://localhost:8000')

        # 她注意到网页的标题和头部都包含“To-Do”这个词
        self.assertIn('To-Do', self.browser.title)
        header_text = self.browser.find_element_by_tag_name('h1').text  ➊
        self.assertIn('To-Do', header_text)

        # 应用邀请她输入一个待办事项
        inputbox = self.browser.find_element_by_id('id_new_item')  ➊
        self.assertEqual(
            inputbox.get_attribute('placeholder'),
            'Enter a to-do item'
        )

        # 她在一个文本框中输入了“Buy peacock feathers”（购买孔雀羽毛）
        # 伊迪丝的爱好是使用假蝇做鱼饵钓鱼
        inputbox.send_keys('Buy peacock feathers')  ➋

        # 她按回车键后，页面更新了
        # 待办事项表格中显示了“1: Buy peacock feathers”
        inputbox.send_keys(Keys.ENTER)  ➌
        time.sleep(1)  ➍

        table = self.browser.find_element_by_id('id_list_table')
        rows = table.find_elements_by_tag_name('tr')  ➊
        self.assertTrue(
            any(row.text == '1: Buy peacock feathers' for row in rows)
        )

        # 页面中又显示了一个文本框，可以输入其他的待办事项
        # 她输入了“Use peacock feathers to make a fly”（使用孔雀羽毛做假蝇）
        # 伊迪丝做事很有条理
        self.fail('Finish the test!')

        # 页面再次更新，她的清单中显示了这两个待办事项
        [...]

❶ 我们使用了 Selenium 提供的几个用来查找网页内容的方法：find_element_by_tag_name、find_element_by_id 和 find_elements_by_tag_name（注意有个 s，也就是说这个方法会返回多个元素）。

❷ 我们还使用了 send_keys，这是 Selenium 在输入框中输入内容的方法。

❸ Keys 类（别忘了导入）的作用是发送回车键等特殊的按键。2

2这里可以直接使用字符串 "\n"，但因为 Keys 还能发送 Ctrl 等特殊的按键，所以我觉得有必要用一下 Keys 类。

❹ 按下回车键后页面会刷新。time.sleep 的作用是等待页面加载完毕，这样才能针对新页面下断言。这叫“显式等待”（特别简单，第 6 章将加以改进）。

　小心 Selenium 中 find_element_by... 和 find_elements_by... 这两类函数的区别。前者返回一个元素，如果找不到就抛出异常；后者返回一个列表，这个列表可能为空。

还有，留意一下 any 函数，它是 Python 中的原生函数，却鲜为人知。不用我解释这个函数的作用了吧？使用 Python 编程就是这么惬意。

不过，如果你不懂 Python 的话，我告诉你，any 函数的参数是个生成器表达式（generator expression），类似于列表推导（list comprehension），但比它更为出色。你需要仔细研究这个概念。你可以搜索 Guido 名为“From List Comprehensions to Generator Expressions”的文章。读完之后你就会知道，这个函数可不仅仅是为了让编程惬意。

看一下测试进展如何：

$ python functional_tests.py
[...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: h1

解释一下，测试报错在页面中找不到 <h1> 元素。看一下如何在首页的 HTML 中加入这个元素。

大幅修改功能测试后往往有必要提交一次。初稿中我没这么做，想通之后就后悔了，可是已经和其他代码混在一起提交了。其实提交得越频繁越好：

$ git diff # 会显示对functional_tests.py的改动
$ git commit -am "Functional test now checks we can input a to-do item"

4.3　遵守“不测试常量”规则，使用模板解决这个问题

看一下 lists/tests.py 中的单元测试。现在，要查找特定的 HTML 字符串，但这不是测试 HTML 的高效方法。一般来说，单元测试的规则之一是不测试常量。以文本形式测试 HTML 很大程度上就是测试常量。

换句话说，如果有如下的代码：

wibble = 3

在测试中就不太有必要这么写：

from myprogram import wibble
assert wibble == 3

单元测试要测试的其实是逻辑、流程控制和配置。编写断言检测 HTML 字符串中是否有指定的字符序列，不是单元测试应该做的。

而且，在 Python 代码中插入原始字符串真的不是处理 HTML 的正确方式。我们有更好的方法，那就是使用模板。如果把 HTML 放在一个扩展名为 .html 的文件中，先不说其他好处，单就得到更好的句法高亮支持这一点而言也值了。Python 领域有很多模板框架，Django 有自己的模板系统，而且很好用。来使用这个模板系统吧。

4.3.1　使用模板重构

现在要做的是让视图函数返回完全一样的 HTML，但使用不同的处理方式。这个过程叫作重构，即在功能不变的前提下改进代码。

功能不变是最重要的。如果重构时添加了新功能，很可能会产生问题。重构本身也是一门学问，有专门的参考书——Martin Fowler 写的《重构》。

重构的首要原则是不能没有测试。幸好我们在做测试驱动开发，测试已经有了。检查一下测试能否通过，测试能通过才能保证重构前后的表现一致：

$ python manage.py test
[...]
OK

很好！先把 HTML 字符串提取出来写入单独的文件。新建用于保存模板的文件夹 lists/ templates，然后新建文件 lists/templates/home.html，再把 HTML 写入这个文件 3。

3有些人喜欢使用和应用同名的子文件夹（即 lists/templates/lists），然后使用 lists/home.html 引用这个模板，这叫作“模板命名空间”。我觉得对小型项目来说使用模板命名空间太复杂了，不过在大型项目中可能有用武之地。详情参阅 Django 教程（https://docs.djangoproject.com/en/1.11/intro/tutorial03/#write-views-that-actually-do-something）。

lists/templates/home.html

<html>
    <title>To-Do lists</title>
</html>

高亮显示的句法，漂亮多了！接下来修改视图函数：

lists/views.py

from django.shortcuts import render

def home_page(request):
    return render(request, 'home.html')

现在不自己构建 HttpResponse 对象了，转而使用 Django 中的 render 函数。这个函数的第一个参数是请求对象（原因稍后说明），第二个参数是渲染的模板名。Django 会自动在所有的应用目录中搜索名为 templates 的文件夹，然后根据模板中的内容构建一个 HttpResponse 对象。

　模板是 Django 中一个很强大的功能，使用模板的主要优势之一是能把 Python 变量代入 HTML 文本。现在还没用到这个功能，不过后面的章节会用到。这就是为什么使用 render 和 render_to_string（稍后用到），而不用原生的 open 函数手动从硬盘中读取模板文件。

看一下模板是否起作用了：

$ python manage.py test
[...]
======================================================================
ERROR: test_home_page_returns_correct_html (lists.tests.HomePageTest)  ➋
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "/.../superlists/lists/tests.py", line 17, in
test_home_page_returns_correct_html
    response = home_page(request)  ➌
  File "/.../superlists/lists/views.py", line 5, in home_page
    return render(request, 'home.html')  ➍
  File "/usr/local/lib/python3.6/dist-packages/django/shortcuts.py", line 48,
in render
    return HttpResponse(loader.render_to_string(*args, **kwargs),
  File "/usr/local/lib/python3.6/dist-packages/django/template/loader.py", line
170, in render_to_string
    t = get_template(template_name, dirs)
  File "/usr/local/lib/python3.6/dist-packages/django/template/loader.py", line
144, in get_template
    template, origin = find_template(template_name, dirs)
  File "/usr/local/lib/python3.6/dist-packages/django/template/loader.py", line
136, in find_template
    raise TemplateDoesNotExist(name)
django.template.base.TemplateDoesNotExist: home.html  ➊

 ---------------------------------------------------------------------
Ran 2 tests in 0.004s

又遇到一次分析调用跟踪的机会。

❶ 先看错误是什么：测试无法找到模板。

❷ 然后确认是哪个测试失败：很显然是测试视图 HTML 的测试。

❸ 然后找到导致失败的是测试中的哪一行：调用 home_page 函数那行。

❹ 最后，在应用的代码中找到导致失败的部分：调用 render 函数那段。

那为什么 Django 找不到模板呢？模板在 lists/templates 文件夹中，它就该放在这个位置啊。

原因是还没有正式在 Django 中注册 lists 应用。执行 startapp 命令以及在项目文件夹中存放一个应用还不够，你要告诉 Django 确实要开发一个应用，并把这个应用添加到文件 settings.py 中。这么做才能保证万无一失。打开 settings.py，找到变量 INSTALLED_APPS，把 lists 加进去：

superlists/settings.py

# Application definition

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'lists',
]

可以看出，默认已经有很多应用了。只需把 lists 加到列表的末尾。别忘了在行尾加上逗号，这么做虽然不是必须的，但如果忘了，Python 会把不在同一行的两个字符串连起来，到时你就傻眼了。

现在可以再运行测试看看：

$ python manage.py test
    [...]
    self.assertTrue(html.endswith('</html>'))
AssertionError: False is not true

糟糕，还是无法通过。

　你能否看到这个错误，取决于你使用的文本编辑器是否会在文件的最后添加一个空行。如果没看到，你可以跳过下面几段，直接跳到测试通过那部分。

不过确实有进展。看起来测试找到模板了，但最后三个断言失败了。很显然输出的末尾出了问题。我使用 print (repr(html)) 调试这个问题，发现是因为转用模板后在响应的末尾引入了一个额外的空行（\n）。按下面的方式修改可以让测试通过：

lists/tests.py

self.assertTrue(html.strip().endswith('</html>'))

这么做有点像作弊，不过 HTML 文件末尾的空白并不重要。再运行测试看看：

$ python manage.py test
[...]
OK

对代码的重构结束了，测试也证实了重构前后的表现一致。现在可以修改测试，不再测试常量，检查是否渲染了正确的模板。

4.3.2　Django测试客户端

测试是否正确渲染模板的一种方法是在测试中手动渲染模板，然后与视图返回的结果做比较。为此，可以利用 Django 提供的 render_to_string 函数：

lists/tests.py

from django.template.loader import render_to_string
[...]

    def test_home_page_returns_correct_html(self):
        request = HttpRequest()
        response = home_page(request)
        html = response.content.decode('utf8')
        expected_html = render_to_string('home.html')
        self.assertEqual(html, expected_html)

但这样测试有点笨拙，而且转来转去调用 .decode() 和 .strip() 太牵扯精力。其实，Django 提供的测试客户端（Test Client）才是检查使用哪个模板的原生方式。相应的测试如下所示：

lists/tests.py

def test_home_page_returns_correct_html(self):
    response = self.client.get('/')  ➊

    html = response.content.decode('utf8')  ➋
    self.assertTrue(html.startswith('<html>'))
    self.assertIn('<title>To-Do lists</title>', html)
    self.assertTrue(html.strip().endswith('</html>'))

    self.assertTemplateUsed(response, 'home.html')  ➌

❶ 不再手动创建 HttpRequest 对象，也不再直接调用视图函数，而是调用 self.client.get，并传入要测试的 URL。

❷ 暂时保留这一行，确保一切与之前一样正常。

❸ .assertTemplateUsed 是 Django TestCase 类提供的测试方法，用于检查响应是使用哪个模板渲染的（注意，这个方法只能测试通过测试客户端获取的响应）。

这个测试依然能通过：

Ran 2 tests in 0.016s

OK

我对没失败的测试始终有所怀疑，所以故意做点破坏：

lists/tests.py

        self.assertTemplateUsed(response, 'wrong.html')

这样还能看看错误消息是什么：

AssertionError: False is not true : Template 'wrong.html' was not a template
used to render the response. Actual template(s) used: home.html

消息的内容很有帮助！现在把断言改回去，顺便把旧的断言删掉。此外，还可以把原来的 test_root_url_resolves 测试删除，因为 Django 测试客户端已经隐式测试过了。我们把两个冗长的测试精简成了一个！

lists/tests.py (ch04l010)

from django.test import TestCase

class HomePageTest(TestCase):

    def test_uses_home_template(self):
        response = self.client.get('/')
        self.assertTemplateUsed(response, 'home.html')

注意，这里的重点是“不要测试常量，而应该测试实现方式”。很好！ 4

4你是不是发现某些代码清单旁有 ch04l0xx 这样的文本，而且还不知道那是什么意思？这是本书示例仓库中特定提交（https://github.com/hjwp/book-example/commits/chapter_philosophy_and_refactoring）的引用，为本书中的测试（https://github.com/hjwp/Book-TDD-Web-Dev-Python/tree/master/tests）使用，也就是这本关于测试的书中的测试的测试；显然，测试自身也需要测试。

为什么不一直使用 Django 测试客户端？

你可能会问：“为什么不从一开始就使用 Django 测试客户端呢？”在现实中，我确实会这么做。但鉴于一些原因，我想告诉你如何“手动”实现。首先，这样可以逐个介绍相关概念，尽量使学习曲线保持平缓；其次，你可能不会一直使用 Django 构建应用，而且相关的测试工具也不是永远可用——但是直接调用函数，然后检查响应是永远可以采用的方法。

此外，Django 测试客户端也有不足之处，后文会讨论完全隔离的单元测试与由测试客户端推动向前的“整合”测试之间的区别。但就目前而言，测试客户端尚且算是务实的选择。

4.4　关于重构

这个重构的例子很烦琐。但正如 Kent Beck 在 Test-Driven Development: By Example 一书中所说的：“我是推荐你在实际工作中这么做吗？不是。我只是建议你要知道怎么按照这种方式做。”

其实，写这一部分时我的第一反应是先修改代码，直接使用 assertTemplateUsed 函数，删除那三个多余的断言，只在渲染得到的结果中检查期望看到的内容，然后再修改代码。但要注意，如果真这么做了可能就会犯错，因为我可能不会在模板中编写正确的 <html> 和 <title> 标签，而是随便写一些字符串。

　重构时，修改代码或者测试，但不能同时修改。

在重构的过程中总有超前几步的冲动，想对应用的功能做些改动，不久，修改的文件就会变得越来越多，最终你会忘记自己身在何处，而且一切都无法正常运行了。如果你不想让自己变成“重构猫”（如图 4-2），迈的步子就要小一点，把重构和功能调整完全分开来做。

图 4-2：重构猫——记得要看完整的动态 GIF 图

　后面会再次遇到重构猫，用来说明头脑发热、一次修改很多内容带来的后果。你可以把这只猫想象成卡通片中浮现在另一个肩膀上的魔鬼，它和测试山羊的观点是对立的，总给些不好的建议。

重构后最好做一次提交：

$ git status # 会看到tests.py、views.py、settings.py以及新建的templates文件夹
$ git add . # 还会添加尚未跟踪的templates文件夹
$ git diff --staged # 审查我们想提交的内容
$ git commit -m "Refactor home page view to use a template"

4.5　接着修改首页

现在功能测试还是失败的。修改代码，让它通过。因为 HTML 现在保存在模板中，可以尽情修改，无须编写额外的单元测试。我们需要一个 <h1> 元素：

lists/templates/home.html

<html>
    <head>
        <title>To-Do lists</title>
    </head>
    <body>
        <h1>Your To-Do list</h1>
    </body>
</html>

看一下功能测试是否认同这次修改：

selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: [id="id_new_item"]

不错，继续修改：

lists/templates/home.html

    [...]
        <h1>Your To-Do list</h1>
        <input id="id_new_item" />
    </body>
    [...]

现在呢？

AssertionError: '' != 'Enter a to-do item'

加上占位文字：

lists/templates/home.html

    <input id="id_new_item" placeholder="Enter a to-do item" />

得到了下述错误：

selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: [id="id_list_table"]

因此要在页面中加入表格。目前表格是空的：

lists/templates/home.html

    <input id="id_new_item" placeholder="Enter a to-do item" />
    <table id="id_list_table">
    </table>
</body>

现在功能测试的结果如何？

  File "functional_tests.py", line 43, in
test_can_start_a_list_and_retrieve_it_later
    any(row.text == '1: Buy peacock feathers' for row in rows)
AssertionError: False is not true

有点儿晦涩。可以使用行号找出问题所在，原来是前面我沾沾自喜的那个 any 函数导致的，或者更准确地说是 assertTrue，因为没有提供给它明确的失败消息。可以把自定义的错误消息传给 unittest 中的大多数 assertX 方法：

functional_tests.py

    self.assertTrue(
        any(row.text == '1: Buy peacock feathers' for row in rows),
        "New to-do item did not appear in table"
    )

再次运行功能测试，应该会看到我们编写的消息：

AssertionError: False is not true : New to-do item did not appear in table

不过现在如果想让测试通过，就要真正处理用户提交的表单，但这是下一章的话题。

现在做个提交吧：

$ git diff
$ git commit -am "Front page HTML now generated from a template"

多亏这次重构，视图能渲染模板了，也不再测试常量了，现在准备好处理用户的输入了。

4.6　总结：TDD流程

至此，我们已经在实践中见识了 TDD 流程中涉及的所有主要概念。

功能测试。

单元测试。

“单元测试 / 编写代码”循环。

重构。

现在要稍微总结一下，或许可以画个流程图。请原谅我，做了这么多年管理顾问，养成了这个习惯。不过流程图也有好的一面，能清楚地表明流程中的循环。

TDD 的总体流程是什么呢？参见图 4-3。

图 4-3：TDD 的总体流程

首先编写一个测试，运行这个测试看着它失败。然后编写最少量的代码取得一些进展，再运行测试。如此不断重复，直到测试通过为止。最后，或许还要重构代码，测试能确保不破坏任何功能。

如果既有功能测试，又有单元测试，该怎么运用这个流程呢？你可以把功能测试当作循环的一种高层视角，而“编写代码让功能测试通过”这一步则是另一个小型 TDD 循环，这个小循环使用单元测试，如图 4-4 所示。

图 4-4：包含功能测试和单元测试的 TDD 流程

编写一个功能测试，看着它失败。接下来，“编写代码让功能测试通过”这一步是一个小型 TDD 循环：编写一个或多个单元测试，然后进入“单元测试 / 编写代码”循环，直到单元测试通过为止。然后回到功能测试，查看是否有进展。这一步还可以多编写一些应用代码，再编写更多的单元测试，如此一直循环下去。

涉及功能测试时应该怎么重构呢？这就要使用功能测试检查重构前后的表现是否一致。不过，你可以修改、添加或删除单元测试，或者使用单元测试循环修改实现方式。

功能测试是应用能否正常运行的最终评判，而单元测试只是整个开发过程中的一个辅助工具。

这种看待事物的方式有时叫作“双循环测试驱动开发”。本书的优秀技术审校人员之一 Emily Bache 写了一篇博客，从不同的视角讨论了这个话题，推荐你阅读，名为“Coding Is Like Cooking”。

接下来的章节会更深入地探索这个工作流程中的各个组成部分。

如何检查你的代码，以及在必要时跳着阅读

书中使用的所有代码示例都可以到我放在 GitHub 中的仓库（https://github.com/hjwp/bookexample/）中获取。因此，如果你想拿自己的代码和我的比较，可以到这个仓库中看一下。

每一章的代码都放在单独的分支中，各分支采用简短形式命名，比如说本章的示例在 chapter_philosophy_and_refactoring 这个分支中。这是本章结束时代码的快照。

所有分支的代码请至 http://www.ituring.com.cn/book/2052 下载。附录 J 说明了如何使用 Git 比较你我的代码。

第 5 章　保存用户输入：测试数据库

要获取用户输入的待办事项，发送给服务器，这样才能使用某种方式保存待办事项，然后再显示给用户查看。

刚开始写这一章时，我立即采用了我认为正确的设计方式：为清单和待办事项创建几个模型，为新建清单和待办事项创建一组不同的 URL，编写三个新视图函数，又为这些操作编写六七个新的单元测试。不过我还是忍住了没这么做。虽然我十分确定自己很聪明，能一次处理所有问题，但是 TDD 的重要思想是必要时一次只做一件事。所以我决定放慢脚步，每次只做必要的操作，让功能测试向前迈出一小步即可。

这么做是为了演示 TDD 对迭代式开发方法的支持——这种方法不是最快的，但最终仍能把你带到目的地。使用这种方法还有个不错的附带好处：我可以一次只介绍一个新概念，例如模型、处理 POST 请求和 Django 模板标签等，不必一股脑儿全抛给你。

并不是说你不能事先考虑后面的事，或者不能发挥自己的聪明才智。下一章我们会稍微多使用一点儿设计和预见思维，展示如何在 TDD 过程中运用这些思维方式。不过现在，我们要坚持自己是无知的，测试让做什么就做什么。

5.1　编写表单，发送POST请求

上一章末尾，测试指出无法保存用户的输入。现在，要使用标准的 HTML POST 请求。虽然有点无聊，但发送过程很简单。后文我们会见识到各种有趣的 HTML5 和 JavaScript 用法。

为了让浏览器发送 POST 请求，我们要做两件事。

(1) 给 <input> 元素指定 name= 属性。

(2) 把它放在 <form> 标签中，并为 <form> 标签指定 method="POST" 属性。

据此调整一下 lists/templates/home.html 中的模板：

lists/templates/home.html

<h1>Your To-Do list</h1>
<form method="POST">
    <input name="item_text" id="id_new_item" placeholder="Enter a to-do item" />
</form>

<table id="id_list_table">

现在运行功能测试，会看到一个晦涩难懂、预料之外的错误：

$ python functional_tests.py
[...]
Traceback (most recent call last):
  File "functional_tests.py", line 40, in
test_can_start_a_list_and_retrieve_it_later
    table = self.browser.find_element_by_id('id_list_table')
[...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: [id="id_list_table"]

如果功能测试出乎意料地失败了，可以做下面几件事，找出问题所在。

添加 print 语句，输出页面中当前显示的文本是什么。

改进错误消息，显示当前状态的更多信息。

亲自手动访问网站。

在测试执行过程中使用 time.sleep 暂停。

本书会分别介绍这几种调试方法，不过我发现自己经常使用 time.sleep。下面试一下这种方法。

其实，在错误发生之前就已经休眠了。那就延长休眠时间：

functional_tests.py

# 按回车键后，页面更新了
# 待办事项表格中显示了“1: Buy peacock feathers”
inputbox.send_keys(Keys.ENTER)
time.sleep(10)

table = self.browser.find_element_by_id('id_list_table')

如果 Selenium 运行得很慢，你可能已经发现了这一问题。现在再次运行功能测试，就有机会看看到底发生了什么：你会看到一个如图 5-1 所示的页面，显示了 Django 提供的很多调试信息。

图 5-1：Django 中的调试页面，显示有 CSRF 错误

安全：异常有趣！

如果你从未听说过跨站请求伪造（Cross-Site Request Forgery，CSRF）漏洞，现在就去查资料吧。和所有安全漏洞一样，研究起来很有趣。CSRF 是一种不寻常的、使用系统的巧妙方式。

在大学攻读计算机科学学位时，出于责任感，我报名学习了安全课程单元。这个单元可能很枯燥乏味，但我觉得最好还是学一下。结果证明，这是所有课程中最吸引人的单元，充满了黑客的乐趣，你要在特定的心境下思考如何通过意想不到的方式使用系统。

我要推荐学这门课程时使用的课本，Ross Anderson 写的 Security Engineering。这本书没有深入讲解纯粹的加密机制，而是讨论了很多意料之外的话题，例如开锁、伪造银行票据和喷墨打印机墨盒的经济原理，以及如何使用重放攻击（replay attack）戏弄南非空军的飞机等。这是本大部头书，大约 3 英寸厚，但相信我，绝对值得一读。

Django 针对 CSRF 的保护措施是在生成的每个表单中放置一个自动生成的令牌，通过这个令牌判断 POST 请求是否来自同一个网站。之前的模板都是纯粹的 HTML，在这里要首次体验 Django 模板的魔力，使用模板标签（template tag）添加 CSRF 令牌。模板标签的句法是花括号和百分号形式，即 {% ... %}——这种写法很有名，要连续多次同时按两个键，是世界上最麻烦的输入方式。

lists/templates/home.html

<form method="POST">
    <input name="item_text" id="id_new_item" placeholder="Enter a to-do item" />
    {% csrf_token %}
</form>

渲染模板时，Django 会把这个模板标签替换成一个 <input type="hidden"> 元素，其值是 CSRF 令牌。现在运行功能测试，会看到一个预期失败：

AssertionError: False is not true : New to-do item did not appear in table

因为 time.sleep 还在，所以测试会在最后一屏上暂停。可以看到，提交表单后新添加的待办事项不见了，页面刷新后又显示了一个空表单。这是因为还没连接服务器让它处理 POST 请求，所以服务器忽略请求，直接显示常规首页。

其实，现在可以删掉 time.sleep 了：

functional_tests.py

    # 待办事项表格中显示了“1: Buy peacock feathers”
    inputbox.send_keys(Keys.ENTER)
    time.sleep(1)

    table = self.browser.find_element_by_id('id_list_table')

5.2　在服务器中处理POST请求

还没为表单指定 action= 属性，因此提交表单后默认返回之前渲染的页面（即“/”），这个页面由视图函数 home_page 处理。下面修改这个视图函数，让它能处理 POST 请求。

这意味着要为视图函数 home_page 编写一个新的单元测试。打开文件 lists/tests.py，在 HomePageTest 类中添加一个新方法：

lists/tests.py (ch05l005)

def test_uses_home_template(self):
    response = self.client.get('/')
    self.assertTemplateUsed(response, 'home.html')

def test_can_save_a_POST_request(self):
    response = self.client.post('/', data={'item_text': 'A new list item'})
    self.assertIn('A new list item', response.content.decode())

为了发送 POST 请求，我们调用 self.client.post，传入 data 参数，指定想发送的表单数据。然后再检查 POST 请求渲染得到的 HTML 中是否有指定的文本。运行测试后，会看到预期的失败：

$ python manage.py test
[...]
AssertionError: 'A new list item' not found in '<html>\n    <head>\n
<title>To-Do lists</title>\n    </head>\n    <body>\n        <h1>Your To-Do
list</h1>\n        <form method="POST">\n            <input name="item_text"
[...]
</body>\n</html>\n'

为了让测试通过，可以添加一个 if 语句，为 POST 请求提供一个不同的代码执行路径。按照典型的 TDD 方式，先故意编写一个愚蠢的返回值：

lists/views.py

from django.http import HttpResponse
from django.shortcuts import render

def home_page(request):
    if request.method == 'POST':
        return HttpResponse(request.POST['item_text'])
    return render(request, 'home.html')

这样单元测试就能通过了，但这并不是我们真正想要做的。我们真正想要做的是把 POST 请求提交的数据添加到首页模板的表格里。

5.3　把Python变量传入模板中渲染

前面已经粗略展示了 Django 的模板句法，现在是时候领略它的真正强大之处了，即从视图的 Python 代码中把变量传入 HTML 模板。

先介绍在模板中使用哪种句法引入 Python 对象。要使用的符号是 {{ ... }}，它会以字符串的形式显示对象：

lists/templates/home.html

<body>
    <h1>Your To-Do list</h1>
    <form method="POST">
        <input name="item_text" id="id_new_item" placeholder="Enter a to-do item" />
        {% csrf_token %}
    </form>

    <table id="id_list_table">
        <tr><td>{{ new_item_text }}</td></tr>
    </table>
</body>

这里要调整单元测试，检查是否依然使用这个模板：

lists/tests.py

    def test_can_save_a_POST_request(self):
        response = self.client.post('/', data={'item_text': 'A new list item'})
        self.assertIn('A new list item', response.content.decode())
        self.assertTemplateUsed(response, 'home.html')

跟预期一样，这个测试会失败：

AssertionError: No templates used to render the response

很好，故意编写的愚蠢返回值已经骗不过测试了，因此要重写视图函数，把 POST 请求中的参数传入模板。render 函数的第三个参数是一个字典，把模板变量的名称映射在值上：

lists/views.py (ch05l009)

def home_page(request):
    return render(request, 'home.html', {
        'new_item_text': request.POST['item_text'],
    })

然后再运行单元测试：

ERROR: test_uses_home_template (lists.tests.HomePageTest)
[...]
  File "/.../superlists/lists/views.py", line 5, in home_page
    'new_item_text': request.POST['item_text'],
[...]
django.utils.datastructures.MultiValueDictKeyError: "'item_text'"

看到的是意料之外的失败。

如果你记得阅读调用跟踪的方法，就会发现这次失败其实发生在另一个测试中。我们让正在处理的测试通过了，但是这个单元测试却导致了一个意想不到的结果，或者称之为“回归”：破坏了没有 POST 请求时执行的那条代码路径。

这就是测试的要义所在。不错，发生这样的事是可以预料的，但如果运气不好或者没有注意到呢？这时测试就能避免破坏应用功能，而且，因为我们在使用 TDD，所以能立即发现什么地方有问题。无须等待质量保证团队的反馈，也不用打开浏览器自己动手在网站中点来点去，直接就能修正问题。这次失败的修正方法如下：

lists/views.py

def home_page(request):
    return render(request, 'home.html', {
         'new_item_text': request.POST.get('item_text', ''),
    })

如果不理解这段代码，可以查阅 dict.get 的文档。

这个单元测试现在应该可以通过了。看一下功能测试的结果如何：

AssertionError: False is not true : New to-do item did not appear in table

　如果你现在或者在本章其他地方看到的功能测试报错与这里不同，而是与 StaleElementReferenceException 有关，你可能就需要增加 time.sleep 的显式等待时间了——可以试试把 1 改成 2 或 3。更可靠的解决方法参见下一章。

错误消息没太大帮助。使用另一种功能测试的调试技术：改进错误消息。这或许是最有建设性的技术，因为改进后的错误消息一直存在，可以协助调试以后出现的错误：

functional_tests.py (ch05l011)

self.assertTrue(
    any(row.text == '1: Buy peacock feathers' for row in rows),
    f"New to-do item did not appear in table. Contents were:\n{table.text}"  ➊
)

➊ 你以前可能没有见过这种句法，这是 Python 新的 f 字符串句法（算是 Python 3.6 最令人激动的新特性）。只需在字符串前面加上一个 f，你就能使用花括号插入局部变量。详情请参见 Python 3.6 的版本说明。

改进后，测试给出了更有用的错误消息：

AssertionError: False is not true : New to-do item did not appear in table.
Contents were:
Buy peacock feathers

知道怎么改效果更好吗？稍微让断言别那么灵巧。你可能还记得，函数 any 让我很满意，但预览版的一个读者（感谢 Jason）建议我使用一种更简单的实现方式，把六行 assertTrue 换成一行 assertIn：

functional_tests.py (ch05l012)

    self.assertIn('1: Buy peacock feathers', [row.text for row in rows])

这样好多了。自作聪明时一定要小心，因为你可能把问题过度复杂化了。修改之后自动获得了下面的错误消息：

    self.assertIn('1: Buy peacock feathers', [row.text for row in rows])
AssertionError: '1: Buy peacock feathers' not found in ['Buy peacock feathers']

就当这是我应得的惩罚吧。

　如果功能测试指出的错误是表格为空（not found in []），那就检查一下 <input> 标签，看看有没有正确设定 name="item_text" 属性。如果没有这个属性，用户的输入就不能与 request.POST 中正确的键关联。

上述错误消息的意思是，功能测试在枚举列表中的项目时希望第一个项目以“1:”开头。让测试通过最快的方法是修改模板时“作弊”：

lists/templates/home.html

    <tr><td>1: {{ new_item_text }}</td></tr>

“遇红 / 变绿 / 重构”和三角法

“单元测试 / 编写代码”循环有时也叫“遇红 / 变绿 / 重构”。

先写一个会失败的单元测试（遇红）。

编写尽可能简单的代码让测试通过（变绿），就算作弊也行。

重构，改进代码，让其更合理。

那么，在重构阶段应该做些什么呢？如何判断什么时候应该把作弊的代码改成令我们满意的实现方式呢？

一种方法是消除重复：如果测试中使用了神奇常量（例如列表项目前面的“1:”），而且应用代码中也用了这个常量，这就算是重复，此时就应该重构。把神奇常量从应用代码中删掉往往意味着你不能再作弊了。

我觉得这种方法有点不太明确，所以经常使用第二种方法，这种方法叫作“三角法”：如果编写无法让你满意的作弊代码（例如返回一个神奇的常量）就能让测试通过，就再写一个测试，强制自己编写更好的代码。现在就要使用这种方法，扩充功能测试，检查输入的第二个列表项目中是否包含“2:”。

现在功能测试能执行到 self.fail('Finish the test!') 了。如果扩充功能测试，检查表格中添加的第二个待办事项（复制粘贴是好帮手），我们会发现刚才使用的简单处理方式不奏效了：

functional_tests.py

    # 页面中还有一个文本框，可以输入其他的待办事项
    # 她输入了“Use peacock feathers to make a fly”（使用孔雀羽毛做假蝇）
    # 伊迪丝做事很有条理
    inputbox = self.browser.find_element_by_id('id_new_item')
    inputbox.send_keys('Use peacock feathers to make a fly')
    inputbox.send_keys(Keys.ENTER)
    time.sleep(1)

    # 页面再次更新，清单中显示了这两个待办事项
    table = self.browser.find_element_by_id('id_list_table')
    rows = table.find_elements_by_tag_name('tr')
    self.assertIn('1: Buy peacock feathers', [row.text for row in rows])
    self.assertIn(
        '2: Use peacock feathers to make a fly',
         [row.text for row in rows]
    )

    # 伊迪丝想知道这个网站是否会记住她的清单
    # 她看到网站为她生成了一个唯一的URL
    # 页面中有一些文字解说这个功能
    self.fail('Finish the test!')

    # 她访问那个URL，发现待办事项清单还在

很显然，这个功能测试会返回一个错误：

AssertionError: '1: Buy peacock feathers' not found in ['1: Use peacock
feathers to make a fly']

5.4　事不过三，三则重构

在继续之前，先看一下功能测试中的代码异味。1 检查清单表格中新添加的待办事项时，用了三个几乎一样的代码块。编程中有个原则叫作不要自我重复（Don't Repeat Yourself，DRY），按照真言“事不过三，三则重构”的说法，运用这个原则。复制粘贴一次，可能还不用删除重复，但如果复制粘贴了三次，就该删除重复了。

1如果你没遇到过这个概念，我告诉你，“代码异味”表明一段代码需要重写。Jeff Atwood 在他的博客 Coding Horror 中搜集了很多这方面的资料。编程经验越丰富，你的鼻子就会变得越灵敏，能够嗅出代码中的异味。

要先提交目前已编写的代码。虽然网站还有重大瑕疵（只能处理一个待办事项），但仍然取得了一定进展。这些代码可能要全部重写，也可能不用，不管怎样，重构之前一定要提交：

$ git diff
# 会看到functional_tests.py、home.html、tests.py和views.py中的变动
$ git commit -a

然后重构功能测试。可以定义一个行间函数，不过这样会稍微搅乱测试流程，还是用辅助方法吧。记住，只有名字以 test_ 开头的方法才会作为测试运行，可以根据需求使用其他方法。

functional_tests.py

    def tearDown(self):
        self.browser.quit()

    def check_for_row_in_list_table(self, row_text):
        table = self.browser.find_element_by_id('id_list_table')
        rows = table.find_elements_by_tag_name('tr')
        self.assertIn(row_text, [row.text for row in rows])

    def test_can_start_a_list_and_retrieve_it_later(self):
        [...]

我喜欢把辅助方法放在类的顶部，置于 tearDown 和第一个测试之间。下面在功能测试中使用这个辅助方法：

functional_tests.py

    # 她按回车键后，页面更新了
    # 待办事项表格中显示了“1: Buy peacock feathers”
    inputbox.send_keys(Keys.ENTER)
    time.sleep(1)
    self.check_for_row_in_list_table('1: Buy peacock feathers')

    # 页面中又显示了一个文本框，可以输入其他的待办事项
    # 她输入了“Use peacock feathers to make a fly”（使用孔雀羽毛做假蝇）
    # 伊迪丝做事很有条理
    inputbox = self.browser.find_element_by_id('id_new_item')
    inputbox.send_keys('Use peacock feathers to make a fly')
    inputbox.send_keys(Keys.ENTER)
    time.sleep(1)

    # 页面再次更新，她的清单中显示了这两个待办事项
    self.check_for_row_in_list_table('1: Buy peacock feathers')
    self.check_for_row_in_list_table('2: Use peacock feathers to make a fly')

    # 伊迪丝想知道这个网站是否会记住她的清单
    [...]

再次运行功能测试，看重构前后的表现是否一致：

AssertionError: '1: Buy peacock feathers' not found in ['1: Use peacock
feathers to make a fly']

很好。接下来，提交这次针对功能测试的小重构：

$ git diff # 查看functional_tests.py中的改动
$ git commit -a

继续开发工作。如果要处理不止一个待办事项，需要某种持久化存储，在 Web 应用领域，数据库是一种成熟的解决方案。

5.5　Django ORM和第一个模型

对象关系映射器（Object-Relational Mapper，ORM）是一个数据抽象层，描述存储在数据库中的表、行和列。处理数据库时，可以使用熟悉的面向对象方式，写出更好的代码。在 ORM 的概念中，类对应数据库中的表，属性对应列，类的单个实例表示数据库中的一行数据。

Django 对 ORM 提供了良好的支持，学习 ORM 的绝佳方法是在单元测试中使用它，因为单元测试能按照指定方式使用 ORM。

下面在 lists/tests.py 文件中新建一个类：

lists/tests.py

from lists.models import Item
[...]

class ItemModelTest(TestCase):

    def test_saving_and_retrieving_items(self):
        first_item = Item()
        first_item.text = 'The first (ever) list item'
        first_item.save()

        second_item = Item()
        second_item.text = 'Item the second'
        second_item.save()

        saved_items = Item.objects.all()
        self.assertEqual(saved_items.count(), 2)
        first_saved_item = saved_items[0]
        second_saved_item = saved_items[1]
        self.assertEqual(first_saved_item.text, 'The first (ever) list item')
        self.assertEqual(second_saved_item.text, 'Item the second')

由上述代码可以看出，在数据库中创建新记录的过程很简单：先创建一个对象，再为一些属性赋值，然后调用 .save() 函数。Django 提供了一个查询数据库的 API，即类属性 .objects。再使用可能是最简单的查询方法 .all()，取回这个表中的全部记录。得到的结果是一个类似列表的对象，叫 QuerySet。从这个对象中可以提取出单个对象，然后还可以再调用其他函数，例如 .count()。接着，检查存储在数据库中的对象，看保存的信息是否正确。

Django 中的 ORM 有很多有用且直观的功能。现在可能是略读 Django 教程（https://docs.djangoproject.com/en/1.11/intro/tutorial01/）的好时机，这个教程很好地介绍了 ORM 的功能。

　这个单元测试写得很啰唆，因为我想借此介绍 Django ORM。我不建议你在现实中也这么写。第 15 章会重写这个测试，尽可能做到精简。

术语：单元测试和集成测试的区别以及数据库

追求纯粹的人会告诉你，真正的单元测试绝不能涉及数据库操作。我刚编写的测试或许叫作“整合测试”（integrated test）更确切，因为它不仅测试代码，还依赖于外部系统，即数据库。

现在可以忽略这种区别，因为有两种测试，一种是功能测试，从用户的角度出发，站在一定高度上测试应用；另一种从程序员的角度出发，做底层测试。

在第 23 章会再次讨论单元测试和整合测试。

试着运行单元测试。接下来要进入另一次“单元测试 / 编写代码”循环：

ImportError: cannot import name 'Item'

很好。下面在 lists/models.py 中写入一些代码，让它有内容可导入。我们有自信，因此会跳过编写 Item = None 这一步，直接创建类：

lists/models.py

from django.db import models

class Item(object):
    pass

这些代码让测试向前进展到了：

    first_item.save()
AttributeError: 'Item' object has no attribute 'save'

为了给 Item 类提供 save 方法，也为了让这个类变成真正的 Django 模型，要让它继承 Model 类：

lists/models.py

from django.db import models

class Item(models.Model):
    pass

5.5.1　第一个数据库迁移

再次运行测试，会看到一个数据库错误：

django.db.utils.OperationalError: no such table: lists_item

在 Django 中，ORM 的任务是模型化数据库。创建数据库其实是由另一个系统负责的，叫作迁移（migration）。迁移的任务是，根据你对 models.py 文件的改动情况，添加或删除表和列。

你可以把迁移想象成数据库使用的版本控制系统。后面会看到，把应用部署到线上服务器升级数据库时，迁移十分有用。

现在只需要知道如何创建第一个数据库迁移——使用 makemigrations 命令创建迁移：2

2你可能想知道什么时候应该运行迁移，什么时候应该创建迁移。别急，后面会讲到。

$ python manage.py makemigrations
Migrations for 'lists':
  lists/migrations/0001_initial.py
    - Create model Item
$ ls lists/migrations
0001_initial.py __init__.py __pycache__

如果好奇，可以看一下迁移文件中的内容，你会发现，这些内容表明了在 models.py 文件中添加的内容。

与此同时，应该会发现测试又取得了一点进展。

5.5.2　测试向前走得挺远

其实，测试向前走得还挺远：

$ python manage.py test lists
[...]
    self.assertEqual(first_saved_item.text, 'The first (ever) list item')
AttributeError: 'Item' object has no attribute 'text'

这离上次失败的位置整整八行。在这八行代码中，保存了两个待办事项，检查它们是否存入了数据库。可是，Django 似乎不记得有 .text 属性。

如果你刚接触 Python，可能会觉得意外，为什么一开始能给 .text 属性赋值呢？在 Java 等语言中，本应该得到一个编译错误。但是 Python 要宽松一些。

继承 models.Model 的类映射到数据库中的一个表。默认情况下，这种类会得到一个自动生成的 id 属性，作为表的主键，但是其他列都要自行定义。定义文本字段的方法如下：

lists/models.py

class Item(models.Model):
    text = models.TextField()

Django 提供了很多其他字段类型，例如 IntegerField、CharField、DateField 等。使用 TextField 而不用 CharField，是因为后者需要限制长度，但是就目前而言，这个字段的长度是随意的。关于字段类型的更多介绍可以阅读 Django 教程（https://docs.djangoproject.com/en/1.11/intro/tutorial02/#creating-models）和文档（https://docs.djangoproject.com/en/1.11/ref/models/fields/）。

5.5.3　添加新字段就要创建新迁移

运行测试，会看到另一个数据库错误：

django.db.utils.OperationalError: no such column: lists_item.text

出现这个错误的原因是在数据库中添加了一个新字段，所以要再创建一个迁移。测试能告诉我们这一点真是太好了！

创建迁移试试：

$ python manage.py makemigrations
You are trying to add a non-nullable field 'text' to item without a default; we
can't do that (the database needs something to populate existing rows).
Please select a fix:
 1) Provide a one-off default now (will be set on all existing rows with a null
value for this column)
 2) Quit, and let me add a default in models.py
Select an option:2

这个命令不允许添加没有默认值的列。选择第二个选项，然后在 models.py 中设定一个默认值。我想你会发现所用的句法无须过多解释：

lists/models.py

class Item(models.Model):
    text = models.TextField(default='')

现在应该可以顺利创建迁移了：

$ python manage.py makemigrations
Migrations for 'lists':
  lists/migrations/0002_item_text.py
    - Add field text to item

在 models.py 中添加了两行新代码，创建了两个数据库迁移，由此得到的结果是，模型对象上的 .text 属性能被识别为一个特殊属性了，因此属性的值能保存到数据库中，测试也能通过了：

$ python manage.py test lists
[...]

Ran 3 tests in 0.010s
OK

下面提交创建的第一个模型：

$ git status # 看到tests.py和models.py以及两个没跟踪的迁移文件
$ git diff # 审查tests.py和models.py中的改动
$ git add lists
$ git commit -m "Model for list Items and associated migration"

5.6　把POST请求中的数据存入数据库

接下来，要修改针对首页中 POST 请求的测试。希望视图把新添加的待办事项存入数据库，而不是直接传给响应。为了测试这个操作，要在现有的测试方法 test_can_save_a_POST_request 中添加三行新代码：

lists/tests.py

def test_can_save_a_POST_request(self):
    response = self.client.post('/', data={'item_text': 'A new list item'})

    self.assertEqual(Item.objects.count(), 1)  ➊
    new_item = Item.objects.first()  ➋
    self.assertEqual(new_item.text, 'A new list item')  ➌

    self.assertIn('A new list item', response.content.decode())
    self.assertTemplateUsed(response, 'home.html')

❶ 检查是否把一个新 Item 对象存入数据库。objects.count() 是 objects.all().count() 的简写形式。

❷ objects.first() 等价于 objects.all()[0]。

❸ 检查待办事项的文本是否正确。

这个测试变得有点儿长，看起来要测试很多不同的东西。这也是一种代码异味。长的单元测试可以分解成两个，或者表明测试太复杂。我们把这个问题记在自己的待办事项清单中，或许可以写在一张便签 3 上：

3这张便签由几张图片合成。——译者注

记在便签上就不会忘记。然后在合适的时候再回来解决。再次运行测试，会看到一个预期失败：

    self.assertEqual(Item.objects.count(), 1)
AssertionError: 0 != 1

修改一下视图：

lists/views.py

from django.shortcuts import render
from lists.models import Item

def home_page(request):
    item = Item()
    item.text = request.POST.get('item_text', '')
    item.save()

    return render(request, 'home.html', {
        'new_item_text': request.POST.get('item_text', ''),
    })

我使用的方法很天真，你或许能发现有一个明显的问题：每次请求首页都保存一个无内容的待办事项。把这个问题记在便签上，稍后再解决。要知道，除了这个明显的严重问题之外，目前还无法为不同的用户创建不同的清单。暂且忽略这些问题。

记住，并不是说在实际的开发中始终要把这种明显的问题忽略。预见到问题时，要做出判断，是停止正在做的事从头再来，还是暂时不管，以后再解决。有时完成手头的工作是可以接受的做法，但有些时候问题可能很严重，必须停下来重新思考。

看一下单元测试的进展如何……通过了，太好了！现在可以做些重构：

lists/views.py

    return render(request, 'home.html', {
        'new_item_text': item.text
    })

看一下便签，我添加了好几件事：

先看第一个问题。虽然可以在现有的测试中添加一个断言，但最好让单元测试一次只测试一件事。那么，定义一个新测试方法吧：

lists/tests.py

class HomePageTest(TestCase):
    [...]

    def test_only_saves_items_when_necessary(self):
        self.client.get('/')
        self.assertEqual(Item.objects.count(), 0)

这个测试得到的是 1 != 0 失败。下面来修正这个问题。注意，虽然对视图函数的逻辑改动幅度很小，但代码的实现方式有很多细微的变动：

lists/views.py

def home_page(request):
    if request.method == 'POST':
        new_item_text = request.POST['item_text']  ➊
        Item.objects.create(text=new_item_text)  ➋
    else:
        new_item_text = ''  ➊

    return render(request, 'home.html', {
        'new_item_text': new_item_text,  ➊
    })

❶ 使用一个名为 new_item_text 的变量，其值是 POST 请求中的数据，或者是空字符串。

❷ .objects.create 是创建新 Item 对象的简化方式，无须再调用 .save() 方法。

这样修改之后，测试就通过了：

Ran 4 tests in 0.010s

OK

5.7　处理完POST请求后重定向

可是 new_item_text = '' 还是让我高兴不起来。幸好现在可以顺带解决这个问题。视图函数有两个作用：一是处理用户输入，二是返回适当的响应。前者已经完成了，即把用户的输入保存到数据库中。下面来看后者。

人们都说处理完 POST 请求后一定要重定向，那么接下来就实现这个功能吧。再次修改针对保存 POST 请求数据的单元测试，不让它渲染包含待办事项的响应，而是重定向到首页：

lists/tests.py

    def test_can_save_a_POST_request(self):
        response = self.client.post('/', data={'item_text': 'A new list item'})
        self.assertEqual(Item.objects.count(), 1)
        new_item = Item.objects.first()
        self.assertEqual(new_item.text, 'A new list item')

        self.assertEqual(response.status_code, 302)
        self.assertEqual(response['location'], '/')

不需要再拿响应中的 .content 属性值和渲染模板得到的结果比较，因此把相应的断言删掉了。现在，响应是 HTTP 重定向，状态码是 302，让浏览器指向一个新地址。

修改之后运行测试，得到的结果是 200 != 302 错误。现在可以大幅度清理视图函数了：

lists/views.py(ch05l028)

from django.shortcuts import redirect, render
from lists.models import Item

def home_page(request):
    if request.method == 'POST':
        Item.objects.create(text=request.POST['item_text'])
        return redirect('/')

    return render(request, 'home.html')

现在，测试应该可以通过了：

Ran 4 tests in 0.010s

OK

更好的单元测试实践方法：一个测试只测试一件事

现在视图函数处理完 POST 请求后会重定向，这是习惯做法，而且单元测试也一定程度上缩短了，不过还可以做得更好。

良好的单元测试实践方法要求，一个测试只能测试一件事。因为这样便于查找问题。如果一个测试中有多个断言，一旦前面的断言导致测试失败，就无法得知后面的断言情况如何。下一章会看到，如果不小心破坏了视图函数，我们想知道到底是保存对象时出错了，还是响应的类型不对。

刚开始可能无法写出只有一个断言的完美单元测试，不过现在似乎是把正在开发的功能分开测试的好机会：

lists/tests.py

    def test_can_save_a_POST_request(self):
        self.client.post('/', data={'item_text': 'A new list item'})
        self.assertEqual(Item.objects.count(), 1)
        new_item = Item.objects.first()
        self.assertEqual(new_item.text, 'A new list item')

    def test_redirects_after_POST(self):
        response = self.client.post('/', data={'item_text': 'A new list item'})
        self.assertEqual(response.status_code, 302)
        self.assertEqual(response['location'], '/')

现在应该看到有五个测试通过，而不是四个：

Ran 5 tests in 0.010s

OK

5.8　在模板中渲染待办事项

感觉好多了！再看待办事项清单。

把问题从清单上划掉几乎和看着测试通过一样让人满足。

第三个问题是最后一个容易解决的问题。要编写一个新单元测试，检查模板是否也能显示多个待办事项：

lists/tests.py

class HomePageTest(TestCase):
    [...]

    def test_displays_all_list_items(self):
        Item.objects.create(text='itemey 1')
        Item.objects.create(text='itemey 2')

        response = self.client.get('/')

        self.assertIn('itemey 1', response.content.decode())
        self.assertIn('itemey 2', response.content.decode())

　看到测试中的空行了吗？我把设置测试的开头两行放在一起，中间放一行，调用要测试的代码，最后再下断言。这不是强制要求，但是有助于分清测试的结构。设置 - 使用 - 断言，这是单元测试的典型结构。

这个测试和预期一样会失败：

AssertionError: 'itemey 1' not found in '<html>\n    <head>\n [...]

Django 的模板句法中有一个用于遍历列表的标签，即 {% for .. in .. %}。可以按照下面的方式使用这个标签：

lists/templates/home.html

<table id="id_list_table">
    {% for item in items %}
        <tr><td>1: {{ item.text }}</td></tr>
    {% endfor %}
</table>

这是模板系统的主要优势之一。现在模板会渲染多个 <tr> 行，每一行对应 items 变量中的一个元素。这么写很优雅！后文我还会介绍更多 Django 模板的魔力，但总有一天你要阅读 Django 文档，学习模板的其他用法。

只修改模板还不能让测试通过，还要在首页的视图中把待办事项传入模板：

lists/views.py

def home_page(request):
    if request.method == 'POST':
        Item.objects.create(text=request.POST['item_text'])
        return redirect('/')

    items = Item.objects.all()
    return render(request, 'home.html', {'items': items})

这样单元测试就能通过了。关键时刻到了，功能测试能通过吗？

$ python functional_tests.py
[...]
AssertionError: 'To-Do' not found in 'OperationalError at /'

很显然不能。要使用另一种功能测试调试技术，也是最直观的一种：手动访问网站。在浏览器中打开 http://localhost:8000，你会看到一个 Django 调试页面，提示“no such table: lists_item”（没有这个表：lists_item），如图 5-2 所示。

图 5-2：又一个很有帮助的调试信息

5.9　使用迁移创建生产数据库

又是一个 Django 生成的很有帮助的错误消息，大意是说没有正确设置数据库。你可能会问：“为什么在单元测试中一切都运行良好呢？”这是因为 Django 为单元测试创建了专用的测试数据库——这是 Django 中 TestCase 所做的神奇事情之一。

为了设置好真正的数据库，要创建一个数据库。SQLite 数据库只是硬盘中的一个文件。你会在 Django 的 settings.py 文件中发现，默认情况下，Django 把数据库保存为 db.sqlite3，放在项目的基目录中：

superlists/settings.py

[...]
# Database
# https://docs.djangoproject.com/en/1.11/ref/settings/#databases

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
    }
}

我们已经在 models.py 文件和后来创建的迁移文件中告诉 Django 创建数据库所需的一切信息，为了创建真正的数据库，要使用 Django 中另一个强大的 manage.py 命令——migrate：

$ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, lists, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  Applying admin.0001_initial... OK
  Applying admin.0002_logentry_remove_auto_add... OK
  Applying contenttypes.0002_remove_content_type_name... OK
  Applying auth.0002_alter_permission_name_max_length... OK
  Applying auth.0003_alter_user_email_max_length... OK
  Applying auth.0004_alter_user_username_opts... OK
  Applying auth.0005_alter_user_last_login_null... OK
  Applying auth.0006_require_contenttypes_0002... OK
  Applying auth.0007_alter_validators_add_error_messages... OK
  Applying auth.0008_alter_user_username_max_length... OK
  Applying lists.0001_initial... OK
  Applying lists.0002_item_text... OK
  Applying sessions.0001_initial... OK

现在，可以刷新 localhost 上的页面了，你会发现错误页面不见了 4。然后再运行功能测试试试：

4如果你看到了另一个错误页面，重启开发服务器试试。Django 可能被发生在眼皮子底下的事情搞糊涂了。

AssertionError: '2: Use peacock feathers to make a fly' not found in ['1: Buy
peacock feathers', '1: Use peacock feathers to make a fly']

快成功了，只需要让清单显示正确的序号即可。另一个出色的 Django 模板标签 forloop.counter 能帮忙解决这个问题：

lists/templates/home.html

    {% for item in items %}
        <tr><td>{{ forloop.counter }}: {{ item.text }}</td></tr>
    {% endfor %}

再试一次，应该会看到功能测试运行到最后了：

    self.fail('Finish the test!')
AssertionError: Finish the test!

不过运行测试时，你可能会注意到有什么地方不对劲，如图 5-3 所示。

图 5-3：有上一次运行测试时遗留下来的待办事项

哦，天呐。看起来上一次运行测试时在数据库中遗留了数据。如果再次运行测试，会发现待办事项又多了：

1: Buy peacock feathers
2: Use peacock feathers to make a fly
3: Buy peacock feathers
4: Use peacock feathers to make a fly
5: Buy peacock feathers
6: Use peacock feathers to make a fly

啊，离成功就差一点点了。需要一种自动清理机制。你可以手动清理，方法是先删除数据库再执行 migrate 命令新建：

$ rm db.sqlite3
$ python manage.py migrate --noinput

清理之后要确保功能测试仍能通过。

除了功能测试中这个小问题之外，我们的代码基本上都可以正常运行了。下面做一次提交吧。

先执行 git status，再执行 git diff，你应该会看到对 home.html、tests.py 和 views.py 所做的改动。然后提交这些改动：

$ git add lists
$ git commit -m "Redirect after POST, and show all items in template"

　你可能会觉得在每一章结束时做个标记很有用，例如在本章结束时可以这么做：git tag end-of-chapter-05。

5.10　回顾

这一章我们做了什么呢？

编写了一个表单，使用 POST 请求把新待办事项添加到清单中。

创建了一个简单的数据库模型，用来存储待办事项。

学习了如何创建数据库迁移，既针对测试数据库（自动运行），也针对真实的数据库（手动运行）。

用到了两个 Django 模板标签：{% csrf_token %} 和 {% for ... endfor %} 循环。

至少用到了三种功能测试调试技术：行间 print 语句、time.sleep 以及改进错误消息。

但待办事项清单中还有两件事没做，其中一项是“功能测试运行完毕后清理战场”，还有一项或许更紧急——“支持多个清单”。

我想说的是，虽然网站现在这个样子可以发布，但用户可能会觉得奇怪：为什么所有人要共用一个待办事项清单。我想这会让人们停下来思考一些问题：我们彼此之间有怎样的联系？在地球这艘宇宙飞船上有着怎样的共同命运？要如何团结起来解决共同面对的全球性问题？

可实际上，这个网站还没什么用。

先这样吧。

有用的 TDD 概念

回归

新添加的代码破坏了应用原本可以正常使用的功能。

意外失败

测试在意料之外失败了。这意味着测试中有错误，或者测试帮我们发现了一个回归，因此要在代码中修正。

遇红 / 变绿 / 重构描述TDD 流程的另一种方式。先编写一个测试看着它失败（遇红），然后编写代码让测试通过（变绿），最后重构，改进实现方式。

三角法

添加一个测试，专门为某些现有的代码编写用例，以此推断出普适的实现方式（在此之前的实现方式可能作弊了）。

事不过三，三则重构

判断何时删除重复代码时使用的经验法则。如果两段代码很相似，往往还要等到第三段相似代码出现，才能确定重构时哪一部分是真正共通、可重用的。

记在便签上的待办事项清单

在便签上记录编写代码过程中遇到的问题，等手头的工作完成后再回过头来解决。

第 6 章　改进功能测试：确保隔离，去掉含糊的休眠

在深入分析和解决真正的问题之前，先来做些清理工作。前一章末尾指出，两次运行的测试之间会彼此影响，那就来修正这个问题吧。此外，我对代码中多次出现的 time.sleep 也不满意；这样做似乎有点不科学，我们将采用更可靠的方式实现。

这两项改动都向着测试“最佳实践”迈进，能让测试更确定、更可靠。

6.1　确保功能测试之间相互隔离

前一章结束时留下了一个典型的测试问题：如何隔离测试。运行功能测试后待办事项一直存在于数据库中，这会影响下次测试的结果。

运行单元测试时，Django 的测试运行程序会自动创建一个全新的测试数据库（和应用真正使用的数据库不同），运行每个测试之前都会清空数据库，等所有测试都运行完之后，再删除这个数据库。但是功能测试目前使用的是应用真正使用的数据库 db.sqlite3。

这个问题的解决方法之一是自己动手，在 functional_tests.py 中添加执行清理任务的代码。这样的任务最适合在 setUp 和 tearDown 方法中完成。

不过从 1.4 版开始，Django 提供的一个新类 LiveServerTestCase 可以代我们完成这一任务。这个类会自动创建一个测试数据库（跟单元测试一样），并启动一个开发服务器，让功能测试在其中运行。虽然这个工具有一定局限性（稍后解决），不过在现阶段十分有用。下面学习如何使用。

LiveServerTestCase 必须使用 manage.py，由 Django 的测试运行程序运行。从 Django 1.6 开始，测试运行程序查找所有名字以 test 开头的文件。为了保持文件结构清晰，要新建一个文件夹保存功能测试，让它看起来就像一个应用。Django 对这个文件夹的要求只有一个——必须是有效的 Python 模块，即文件夹中要有一个 __init__.py 文件。

$ mkdir functional_tests
$ touch functional_tests/__init__.py

然后要移动功能测试，把独立的 functional_tests.py 文件移到 functional_tests 应用中，并把它重命名为 tests.py。使用 git mv 命令完成这个操作，让 Git 知道文件移动了：

$ git mv functional_tests.py functional_tests/tests.py
$ git status # 显示文件重命名为functional_tests/tests.py，而且新增了__init__.py

现在的目录结果如下所示：

.
├── db.sqlite3
├── functional_tests
│   ├── __init__.py
│   └── tests.py
├── lists
│   ├── admin.py
│   ├── apps.py
│   ├── __init__.py
│   ├── migrations
│   │   ├── 0001_initial.py
│   │   ├── 0002_item_text.py
│   │   ├── __init__.py
│   │   └── __pycache__
│   ├── models.py
│   ├── __pycache__
│   ├── templates
│   │   └── home.html
│   ├── tests.py
│   └── views.py
├── manage.py
└── superlists
    ├── __init__.py
    ├── __pycache__
    ├── settings.py
    ├── urls.py
    └── wsgi.py

functional_tests.py 不见了，变成了 functional_tests/tests.py。现在，运行功能测试不执行 python functional_tests.py 命令，而是使用 python manage.py test functional_tests 命令。

　功能测试可以和 lists 应用测试混在一起，不过我喜欢把两种测试分开，因为功能测试检测的功能往往存在于不同应用中。功能测试以用户的视角看待事物，而用户并不关心你如何把网站分成不同的应用。

接下来编辑 functional_tests/tests.py，修改 NewVisitorTest 类，让它使用 LiveServerTestCase：

functional_tests/tests.py (ch06l001)

from django.test import LiveServerTestCase
from selenium import webdriver
from selenium.webdriver.common.keys import Keys
import time

class NewVisitorTest(LiveServerTestCase):

    def setUp(self):
        [...]

继续往下修改。访问网站时，不用硬编码的本地地址（localhost:8000），可以使用 LiveServerTestCase 提供的 live_server_url 属性：

functional_tests/tests.py (ch06l002)

    def test_can_start_a_list_and_retrieve_it_later(self):
        # 伊迪丝听说有一个很酷的在线待办事项应用
        # 她去看了这个应用的首页
        self.browser.get(self.live_server_url)

还可以删除文件末尾的 if __name__ == '__main__' 代码块，因为之后都使用 Django 的测试运行程序运行功能测试。

现在能使用 Django 的测试运行程序运行功能测试了，指明只运行 functional_tests 应用中的测试：

$ python manage.py test functional_tests
Creating test database for alias 'default'...
F
======================================================================
FAIL: test_can_start_a_list_and_retrieve_it_later
(functional_tests.tests.NewVisitorTest)
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "/.../superlists/functional_tests/tests.py", line 65, in
test_can_start_a_list_and_retrieve_it_later
    self.fail('Finish the test!')
AssertionError: Finish the test!

 ---------------------------------------------------------------------
Ran 1 test in 6.578s

FAILED (failures=1)
System check identified no issues (0 silenced).
Destroying test database for alias 'default'...

功能测试和重构前一样，能运行到 self.fail。如果再次运行测试，你会发现，之前的测试不再遗留待办事项了，因为功能测试运行完之后把它们清理掉了。成功了，现在应该提交这次小改动：

$ git status # 重命名并修改了functional_tests.py，新增了__init__.py
$ git add functional_tests
$ git diff --staged -M
$ git commit # 提交消息举例："make functional_tests an app, use LiveServerTestCase"

git diff 命令中的 -M 标志很有用，意思是“检测移动”，所以 git 会注意到 functional_tests.py 和 functional_tests/tests.py 是同一个文件，显示更合理的差异（去掉这个旗标试试）。

只运行单元测试

现在，如果执行 manage.py test 命令，Django 会运行功能测试和单元测试：

$ python manage.py test
Creating test database for alias 'default'...
......F
======================================================================
FAIL: test_can_start_a_list_and_retrieve_it_later
[...]
AssertionError: Finish the test!

 ---------------------------------------------------------------------
Ran 7 tests in 6.732s

FAILED (failures=1)

如果只想运行单元测试，可以指定只运行 lists 应用中的测试：

$ python manage.py test lists
Creating test database for alias 'default'...
......
 ---------------------------------------------------------------------
Ran 6 tests in 0.009s

OK
System check identified no issues (0 silenced).
Destroying test database for alias 'default'...

有用的命令（更新版）

运行功能测试

python manage.py test functional_tests

运行单元测试

python manage.py test lists

如果我说“运行测试”，而你不确定我指的是哪一种测试怎么办？可以回顾一下第 4 章最后一节中的流程图，试着找出我们处在哪一步。通常只在所有单元测试都通过后才会运行功能测试。如果不清楚，两种测试都运行试试吧！

6.2　升级Selenium和Geckodriver

今天再次检查这一章时，我发现功能测试停在那里不动了。

后来我才发现，是因为 Firefox 在夜里自动更新了，而 Selenium 和 Geckodriver 也要随之升级。Geckodriver 的发布页面也证实确实有新版发布了。所以，我们要按照下述步骤进行下载和升级。

执行 pip install --upgrade selenium 命令。

下载新版 Geckodriver。

备份旧版，放在某处，把新版放在 PATH 中的某个位置。

执行 geckodriver --version 命令，确认新版是否可用。

升级之后，功能测试又能按预期运行了。

在此处讲解这个问题没有特殊的原因。当你阅读到这里时也不一定会遇到这个问题，但是你总会遇到的。再加上我们又在做清理工作，所以我觉得现在是最适合的时机。

这是使用 Selenium 时你必须忍受的一件事。虽然可以固定所用的浏览器和 Selenium 版本（例如在 CI 服务器上），但是现实中的浏览器是不断进化的，你要跟上用户的步伐。

　只要发现功能测试遇到奇怪的问题，就可以升级 Selenium 试试。

回到常规的编程上来。

6.3　隐式等待、显式等待和含糊的time.sleep

来看看功能测试中的 time.sleep：

functional_tests/tests.py

    # 她按回车键后，页面更新了
    # 待办事项清单中显示了“1: Buy peacock feathers”
    inputbox.send_keys(Keys.ENTER)
    time.sleep(1)

    self.check_for_row_in_list_table('1: Buy peacock feathers')

这叫“显式等待”，与之相对的是“隐式等待”：某些情况下，当 Selenium 认为页面正在加载时，它会“自动”等待一会儿。如果要在页面中查找的元素尚未出现，还可以使用 Selenium 提供的 implicitly_wait 方法指明要等多久。

其实本书第 1 版完全依赖隐式等待。但是，隐式等待有点奇怪，而且从 Selenium 3 开始，隐式等待变得极度不可靠。此外，Selenium 团队普遍认为隐式等待不是个好主意，应该避免使用。

因此，第 2 版从一开始就使用显式等待。但问题是，time.sleep 自身也有问题。我们现在等待了 1 秒，但谁又能说这是合理的时间呢？对于在自己的设备上运行的多数测试来说，1 秒太长了，严重拖慢了功能测试，等待 0.1 秒就行了。但问题是，如果真等待这么短的时间，常常会导致测试假失败——因为在某些情况下，笔记本电脑的速度可能稍慢一些。话说回来，即便等待了 1 秒，也无法绝对避免不是由真正的问题导致的失败。测试中的假阳性确实烦人（关于这一话题的详细讨论参见 Martin Fowler 写的一篇文章，名为“Eradicating Non-Determinism in Tests”）。

　如果遇到意料之外的 NoSuchElementException 和 StaleElementException 错误，通常就表明没有显式等待。去掉 time.sleep 试试看会不会出现这样的错误。

下面调整休眠的实现方式，让测试等待足够长的时间，以便捕获可能出现的问题。我们将 check_for_row_in_list_table 重命名为 wait_for_row_in_list_table，并添加一些轮询 / 重试逻辑：

functional_tests/tests.py (ch06l004)

from selenium.common.exceptions import WebDriverException

MAX_WAIT = 10  ➊
[...]

    def wait_for_row_in_list_table(self, row_text):
        start_time = time.time()
        while True:  ➋
            try:
                table = self.browser.find_element_by_id('id_list_table')  ➌
                rows = table.find_elements_by_tag_name('tr')
                self.assertIn(row_text, [row.text for row in rows])
                return  ➍
            except (AssertionError, WebDriverException) as e:  ❺
                if time.time() - start_time > MAX_WAIT:  ❻
                    raise e  ❻
                time.sleep(0.5)  ❺

❶ 通过 MAX_WAIT 常量设定准备等待的最长时间。10 秒应该足够捕获潜在的问题或不可预知的缓慢因素了。

❷ 这个循环一直运行，直到遇到两个出口中的一个为止。

❸ 这个三行断言跟这个方法的旧版一样。

❹ 如果能顺利运行而且断言通过了，就退出函数、跳出循环。

❺ 但如果捕获到了异常，就再等一小段时间，然后重新循环。我们要捕获两种异常：一种是 WebDriverException，在页面未加载或 Selenium 未在页面上找到表格元素时抛出；另一种是 AssertionError，因为页面中虽有表格，但它可能在页面重新加载之前就存在，里面还是没有我们要找的行。

❻ 这是第二个出口。如果执行到这里，说明代码不断抛出异常，已经超时。因此这里再次抛出异常，向上冒泡，最终可能出现在调用跟踪中，指明测试失败的原因。

是不是觉得这段代码有点蹩脚、有点不明所以？我同意。后文会做重构，定义一个通用的 wait_for 辅助方法，把计时、重新抛出异常的逻辑与测试断言分开。不过，这要等到需要在多个地方使用它时再做。

　如果你以前用过 Selenium，可能知道它提供了一些用于等待的辅助函数。我不太喜欢使用这些函数。本书将构建几个用于等待的辅助工具，我觉得这样能让代码更优雅、更易于阅读。不过，你绝对应该学习一下 Selenium 自带的那些辅助函数，然后判断要不要使用。

下面调用新的方法，并把含糊的 time.sleep 去掉：

functional_tests/tests.py (ch06l005)

    [...]
    # 她按回车键后，页面更新了
    # 待办事项清单中显示了“1: Buy peacock feathers”
    inputbox.send_keys(Keys.ENTER)
    self.wait_for_row_in_list_table('1: Buy peacock feathers')

    # 页面中还有一个文本框，可以输入其他的待办事项
    # 她输入了“Use peacock feathers to make a fly”（使用孔雀羽毛做假蝇）
    # 伊迪丝做事很有条理
    inputbox = self.browser.find_element_by_id('id_new_item')
    inputbox.send_keys('Use peacock feathers to make a fly')
    inputbox.send_keys(Keys.ENTER)

    # 页面再次更新，清单中显示了这两个待办事项
    self.wait_for_row_in_list_table('2: Use peacock feathers to make a fly')
    self.wait_for_row_in_list_table('1: Buy peacock feathers')
    [...]

然后再次运行测试：

$ python manage.py test
Creating test database for alias 'default'...
......F
======================================================================
FAIL: test_can_start_a_list_and_retrieve_it_later
(functional_tests.tests.NewVisitorTest)
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "/.../superlists/functional_tests/tests.py", line 73, in
test_can_start_a_list_and_retrieve_it_later
    self.fail('Finish the test!')
AssertionError: Finish the test!

 ---------------------------------------------------------------------
Ran 7 tests in 4.552s

FAILED (failures=1)
System check identified no issues (0 silenced).
Destroying test database for alias 'default'...

结果跟之前一样，不过测试的执行时间比之前短了几秒。虽然现在只快了一点，但是随着测试的增多，速度优势便会慢慢显现。

为了确认我们做得对不对，下面将故意破坏测试，看看会出现什么错误。首先看一下能否检测永远不会出现在一行里的文本：

functional_tests/tests.py (ch06l006)

        rows = table.find_elements_by_tag_name('tr')
        self.assertIn('foo', [row.text for row in rows])
        return

我们将看到一个意思明确的测试失败消息：

    self.assertIn('foo', [row.text for row in rows])
AssertionError: 'foo' not found in ['1: Buy peacock feathers']

改回去，然后再破坏其他地方：

functional_tests/tests.py (ch06l007)

    try:
        table = self.browser.find_element_by_id('id_nothing')
        rows = table.find_elements_by_tag_name('tr')
        self.assertIn(row_text, [row.text for row in rows])
        return
    [...]

显然，得到的错误指明页面中没有要查找的元素：

selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: [id="id_nothing"]

看起来一切正常。改回原样，最后再运行测试确认一下：

$ python manage.py test
[...]
AssertionError: Finish the test!

很好。短暂离题之后，下面回到正轨，说明如何实现多个清单。

本章运用的测试“最佳实践”

确保测试隔离，管理全局状态

不同的测试之间不能彼此影响，也就是说每次测试结束后都要还原永久状态。Django 的测试运行程序可以帮助我们创建测试数据库，这个数据库在每次测试结束后都会清空（详情请参见第 23 章）。

避免使用“含糊的”休眠

一旦需要等待什么加载，我们的第一反应便是使用 time.sleep。但是这样做带来的问题是，时间的长度是两眼一抹黑：要么太短，容易导致假失败；要么太长，会拖慢测试。我推荐使用重试循环，它可以轮询应用，尽早向前行进。

不要依赖Selenium的隐式等待

Selenium 确实有理论上的“隐式”等待，但是在不同浏览器上的实现各不相同。而且在写作本书时，隐式等待在 Selenium 3 的 Firefox 驱动上极度不可靠。Python 之禅说道：“明了胜于晦涩”，因此首选显式等待。

第 7 章　步步为营

现在就来解决当前存在的问题。目前，我们的设计只允许创建一个全局清单。这一章将说明一个关键的 TDD 技术：如何使用递增的步进式方法修改现有代码，而且保证代码在修改前后都能正常运行。我们要做测试山羊，而不是重构猫。

7.1　必要时做少量的设计

请想一想应该如何支持多个清单。目前的功能测试（与设计文档最接近）是这样的：

functional_tests/tests.py

    # 伊迪丝想知道这个网站是否会记住她的清单
    # 她看到网站为她生成了一个唯一的URL
    # 页面中有一些解说这个功能的文字
    self.fail('Finish the test!')

    # 她访问那个URL，发现待办事项清单还在

    # 她很满意，去睡觉了

不过，我们要在此基础上扩展一下，让用户不能相互查看各自的清单，而且每个用户都有自己的 URL，能访问自己的清单。怎么实现这样的设计呢？

7.1.1　不要预先做大量设计

TDD 和软件开发中的敏捷运动联系紧密。敏捷运动反对传统软件工程实践中预先做大量设计的做法，因为除了要花费大量时间收集需求之外，设计阶段还要用等量的时间在纸上规划软件。敏捷理念则认为，在实践中解决问题比理论分析能学到更多，而且让应用尽早接受真实用户的检验效果更好。无须花这么多时间提前设计，而要尽早把最简可用的应用放出来，根据实际使用中得到的反馈逐步向前推进设计。

这并不是说要完全禁止思考设计。前一章我们看到，不经思考呆头呆脑往前走，最终也能找到正确答案，不过稍微思考一下设计往往能帮助我们更快地找到答案。那么，下面分析一下这个最简可用的应用，想想应该使用哪种设计方式。

想让每个用户都能保存自己的清单，目前来说，至少能保存一个清单。

清单由多个待办事项组成，待办事项的主要属性应该是一些描述性文字。

要保存清单，以便多次访问。现在，可以为用户提供一个唯一的 URL，指向他们的清单。以后或许需要一种自动识别用户的机制，然后把他们的清单显示出来。

为了实现第一条，看样子要把清单和其中的待办事项存入数据库。每个清单都有一个唯一的 URL，而且清单中的每个待办事项都是一些描述性文字，和所在的清单关联。

7.1.2　YAGNI

关于设计的思考一旦开始就很难停下来，我们会冒出各种想法：或许想给每个清单起个名字或加个标题，或许想使用用户名和密码识别用户，或许想给清单添加一个较长的备注和简短的描述，或许想存储某种顺序，等等。但是，要遵守敏捷理念的另一个信条：“YAGNI”（读作 yag-knee）。它是“You ain't gonna need it”（你不需要这个）的简称。作为软件开发者，我们从创造事物中获得乐趣。有时我们冒出一个想法，觉得可能需要，便无法抵御内心的冲动想要开发出来。可问题是，不管想法有多好，大多数情况下最终你都用不到这个功能。应用中会残留很多没用的代码，还增加了应用的复杂度。YAGNI 是个真言，可以用来抵御热切的创造欲。

7.1.3　REST（式）

我们已经知道怎么处理数据结构，即使用“模型 - 视图 - 控制器”中的模型部分。那视图和控制器部分怎么办呢？在 Web 浏览器中用户怎么处理清单和待办事项呢？

“表现层状态转化”（representational state transfer，REST）是 Web 设计的一种方式，经常用来引导基于 Web 的 API 设计。设计面向用户的网站时，不必严格遵守 REST 规则，可是从中能得到一些启发。（如果想看看真实的 REST API 是什么样子，可以跳到附录 F。）

REST 建议 URL 结构匹配数据结构，即这个应用中的清单和其中的待办事项。清单有各自的 URL：

    /lists/<list identifier>/

这个 URL 满足了功能测试中提出的需求。若想查看某个清单，我们可以发送一个 GET 请求（就是在普通的浏览器中访问这个页面）。

若想创建全新的清单，可以向一个特殊的 URL 发送 POST 请求：

    /lists/new

若想在现有的清单中添加一个新待办事项，我们可以向另外一个 URL 发送 POST 请求：

    /lists/<list identifier>/add_item

（再次说明，我们不会严格遵守 REST 规则，只是从中得到启发。比如，按照 REST 规则，这里应该使用 PUT 请求，但是标准的 HTML 表单无法发送 PUT 请求。）

概括起来，本章的便签如下所示：

7.2　使用TDD实现新设计

应该如何使用 TDD 实现新的设计呢？再回顾一下 TDD 的流程图，如图 7-1 所示。

图 7-1：包含功能测试和单元测试的 TDD 流程

在流程的外层，既要添加新功能（添加新的功能测试，再编写新的应用代码），也要重构应用的代码，即重写部分现有的实现，保持应用的功能不变，但使用新的设计方式。我们通过现有的功能测试确保不破坏现有的功能，同时通过新功能测试驱动开发新功能。

在单元测试层，要添加新测试或者修改现有的测试以检查想改动的功能，没改动的测试则用来保证这个过程没有破坏现有的功能。

7.3　确保出现回归测试

下面根据便签上的待办事项编写一个新的功能测试方法，引入第二个用户，并确认他的待办事项清单与伊迪丝的清单是分开的。

这个功能测试的开头与第一个功能测试基本一样——伊迪丝提交第一个待办事项后，应用创建一个新清单。不过这次要多添加一个断言，确认伊迪丝的清单是独立的，有唯一的 URL：

functional_tests/tests.py (ch07l005)

def test_can_start_a_list_for_one_user(self):
    # 伊迪丝听说有一个很酷的在线待办事项应用
    # 她去看了这个应用的首页
    [...]
    # 页面再次更新，她的清单中显示了这两个待办事项
    self.wait_for_row_in_list_table('2: Use peacock feathers to make a fly')
    self.wait_for_row_in_list_table('1: Buy peacock feathers')

    # 她很满意，然后去睡觉了

def test_multiple_users_can_start_lists_at_different_urls(self):
    # 伊迪丝新建一个待办事项清单
    self.browser.get(self.live_server_url)
    inputbox = self.browser.find_element_by_id('id_new_item')
    inputbox.send_keys('Buy peacock feathers')
    inputbox.send_keys(Keys.ENTER)
    self.wait_for_row_in_list_table('1: Buy peacock feathers')

    # 她注意到清单有个唯一的URL
    edith_list_url = self.browser.current_url
    self.assertRegex(edith_list_url, '/lists/.+')  ➊

➊ assertRegex 是 unittest 提供的辅助函数，用于检查字符串是否匹配正则表达式。我们利用它检查新的 REST 式设计能否实现。详情请参见 unittest 的文档。

然后，我们假设有一个新用户正在访问网站。当新用户访问首页时，要测试他不能看到伊迪丝的待办事项，而且他的清单有自己的唯一 URL：

functional_tests/tests.py (ch07l006)

[...]
self.assertRegex(edith_list_url, '/lists/.+')

# 现在一名叫作弗朗西斯的新用户访问了网站

## 我们使用一个新浏览器会话  ➊
## 确保伊迪丝的信息不会从cookie中泄露出去
self.browser.quit()
self.browser = webdriver.Firefox()

# 弗朗西斯访问首页
# 页面中看不到伊迪丝的清单
self.browser.get(self.live_server_url)
page_text = self.browser.find_element_by_tag_name('body').text
self.assertNotIn('Buy peacock feathers', page_text)
self.assertNotIn('make a fly', page_text)

# 弗朗西斯输入一个新待办事项，新建一个清单
# 他不像伊迪丝那样兴趣盎然
inputbox = self.browser.find_element_by_id('id_new_item')
inputbox.send_keys('Buy milk')
inputbox.send_keys(Keys.ENTER)
self.wait_for_row_in_list_table('1: Buy milk')

# 弗朗西斯获得了他的唯一URL
francis_list_url = self.browser.current_url
self.assertRegex(francis_list_url, '/lists/.+')
self.assertNotEqual(francis_list_url, edith_list_url)

# 这个页面还是没有伊迪丝的清单
page_text = self.browser.find_element_by_tag_name('body').text
self.assertNotIn('Buy peacock feathers', page_text)
self.assertIn('Buy milk', page_text)

# 两人都很满意，然后去睡觉了

➊ 按照习惯，我使用两个 # 表示“元注释”。元注释的作用是说明测试的工作方式以及为什么这么做。使用两个井号是为了和功能测试中解说用户故事的常规注释区分开。这个元注释是发给未来自己的消息，如果没有这条消息，到时你可能会觉得奇怪，想知道到底为什么要退出浏览器再启动一个新会话。

除了元注释之外，就不需要对这个新测试多做解释了。看一下运行功能测试后的情况如何：

$ python manage.py test functional_tests
[...]
.F
======================================================================
FAIL: test_multiple_users_can_start_lists_at_different_urls
(functional_tests.tests.NewVisitorTest)
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "/.../superlists/functional_tests/tests.py", line 83, in
test_multiple_users_can_start_lists_at_different_urls
    self.assertRegex(edith_list_url, '/lists/.+')
AssertionError: Regex didn't match: '/lists/.+' not found in
'http://localhost:8081/'

 ---------------------------------------------------------------------
Ran 2 tests in 5.786s

FAILED (failures=1)

很好，第一个测试仍能通过，而第二个测试也如我们所料失败了。先提交一次，然后再编写一些新模型和新视图：

$ git commit -a

7.4　逐步迭代，实现新设计

我太兴奋了，迫切地想实现新设计，这种欲望太强烈，谁也无法阻拦，我真想现在就开始修改 models.py。但这么做可能会导致一半的单元测试失败，我们不得不一行一行修改代码，而且要一次改完，工作量太大。有这样的冲动很自然，但 TDD 理念一直反对这么做。我们要遵从测试山羊的教诲，不能听信重构猫的谗言。无须一次性实现光鲜亮丽的整个设计，改动的幅度要小一些，每一步都要遵照设计思想的指引，保证修改后应用仍能正常运行。

在待办事项清单中还有四个问题没解决。无法匹配正则表达式的那个功能测试提醒我们，接下来要解决的是第二个问题，即为每个清单添加唯一的 URL 和标识符。下面解决且只解决这个问题。

清单的 URL 出现在重定向 POST 请求之后。在文件 lists/tests.py 中，找到 test_redirects_after_POST，修改重定向期望转向的地址：

lists/tests.py

self.assertEqual(response.status_code, 302)
self.assertEqual(response['location'], '/lists/the-only-list-in-the-world/')

这个 URL 看起来是不是有点儿奇怪？在应用的最终设计中显然不会使用 /lists/the-only-list-in- the-world/ 这个 URL。可是我们承诺过，一次只做一项改动，既然应用现在只支持一个清单，那这就是唯一合理的 URL。我们还在向前进，到时候清单和首页的地址都会变，这是更符合 REST 式设计的一个实现步骤。稍后我们会支持多个清单，也会提供简单的方法修改 URL。

　我们可以换种想法，把这看成是一种解决问题的技术：新的 URL 设计还没实现，所以这个 URL 可用于没有待办事项的清单。最终要设法解决包含 n 个待办事项的清单，不过解决包含一个待办事项的清单是个好的开始。

运行单元测试，会看到一个预期失败：

$ python manage.py test lists
[...]
AssertionError: '/' != '/lists/the-only-list-in-the-world/'

可以修改文件 lists/views.py 中的 home_page 视图：

lists/views.py

def home_page(request):
    if request.method == 'POST':
        Item.objects.create(text=request.POST['item_text'])
        return redirect('/lists/the-only-list-in-the-world/')

    items = Item.objects.all()
    return render(request, 'home.html', {'items': items})

这么修改，功能测试显然会失败，因为网站中并没有这个 URL。毫无疑问，如果运行功能测试，你会看到测试在尝试提交第一个待办事项后失败，提示无法找到显示清单的表格。出现这个错误的原因是，/the-only-list-in-the-world/ 这个 URL 还不存在。

  File "/.../superlists/functional_tests/tests.py", line 57, in
test_can_start_a_list_for_one_user
[...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: [id="id_list_table"]

[...]

  File "/.../superlists/functional_tests/tests.py", line 79, in
test_multiple_users_can_start_lists_at_different_urls
    self.wait_for_row_in_list_table('1: Buy peacock feathers')
[...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: [id="id_list_table"]

不仅新添加的测试失败了，原来那个也失败了。这表明出现了回归。接下来就为这个唯一的清单提供一个 URL，重回正常状态。

7.5　自成一体的第一步：新的URL

打开 lists/tests.py，添加一个新测试类，命名为 ListViewTest。然后把 HomePageTest 类中的 test_displays_all_list_items 方法复制到这个新类中。给这个方法重新命名，再做些修改：

lists/tests.py (ch07l009)

class ListViewTest(TestCase):

    def test_displays_all_items(self):
        Item.objects.create(text='itemey 1')
        Item.objects.create(text='itemey 2')

        response = self.client.get('/lists/the-only-list-in-the-world/')

        self.assertContains(response, 'itemey 1')  ➊
        self.assertContains(response, 'itemey 2')  ➊

➊ 这里用到一个新的辅助方法：现在不必再使用有点儿烦人的 assertIn 和 response.content.decode() 了，Django 提供了 assertContains 方法，它知道如何处理响应以及响应内容中的字节。

运行这个测试，看看情况：

    self.assertContains(response, 'itemey 1')
[...]
AssertionError: 404 != 200 : Couldn't retrieve content: Response code was 404

这是使用 assertContains 的附加好处——它直接指出测试失败的原因是新 URL 不存在，而且返回的是 404 响应。

7.5.1　一个新URL

现在那个唯一的清单 URL 还不存在，要在 superlists/urls.py 中解决这个问题。

　留意 URL 末尾的斜线，在测试中和 urls.py 中都要小心，因为这个斜线往往就是问题的根源。

superlists/urls.py

urlpatterns = [
    url(r'^$', views.home_page, name='home'),
    url(r'^lists/the-only-list-in-the-world/$', views.view_list, name='view_list'),
]

再次运行测试，得到的结果如下：

AttributeError: module 'lists.views' has no attribute 'view_list'

7.5.2　一个新视图函数

这个结果无须过多解说。下面在 lists/views.py 中定义一个新视图函数：

lists/views.py

def view_list(request):
    pass

现在测试的结果变成了：

ValueError: The view lists.views.view_list didn't return an HttpResponse
object. It returned None instead.

[...]
FAILED (errors=1)

失败的只有一个了，而且为我们指明了方向。把 home_page 视图的最后两行复制过来，看能否骗过测试：

lists/views.py

def view_list(request):
    items = Item.objects.all()
    return render(request, 'home.html', {'items': items})

再次运行单元测试，测试应该能通过了：

Ran 7 tests in 0.016s
OK

再运行功能测试，看看情况如何：

FAIL: test_can_start_a_list_for_one_user
[...]
  File "/.../superlists/functional_tests/tests.py", line 67, in
test_can_start_a_list_for_one_user
[...]
AssertionError: '2: Use peacock feathers to make a fly' not found in ['1: Buy
peacock feathers']

FAIL: test_multiple_users_can_start_lists_at_different_urls
[...]
AssertionError: 'Buy peacock feathers' unexpectedly found in 'Your To-Do
list\n1: Buy peacock feathers'
[...]

两个功能测试都有一点进展，不过依然失败了。我们要尽快重回正常状态，让第一个功能测试再次通过。失败消息中有什么线索呢？

可以看出，失败发生在尝试添加第二个待办事项时——看来得调试一番了。我们知道首页是正常的，因为功能测试能执行到第 67 行，也就是至少添加了一个待办事项。而且，单元测试都能通过，因此可以确定 URL 和视图能正常运作——首页使用正确的模板显示、能处理 POST 请求，only-list-in-the-world 视图知道如何显示所有待办事项……但是它不知道怎样处理 POST 请求。啊，这就是线索。

根据经验，第二个线索是，当所有单元测试都能通过而功能测试不能通过时，问题通常是由单元测试没有覆盖的事物引起的——这往往是模板的问题。

最终我们找到了问题的根源：home.html 中的输入表单没有明确指定 POST 的目标 URL。

lists/templates/home.html

        <form method="POST">

默认情况下，浏览器把 POST 数据发回表单当前所在的 URL。这样的话，在首页能正常运行，但到 only-list-in-the-world 页面就不行了。

找到根源后，我们本可以为新视图添加处理 POST 请求的功能，但是这样还得编写一堆测试和代码，而我们的目的是尽早重回正常状态。其实，修正这个问题最快速的方法是使用现在能正常运行的首页视图处理所有 POST 请求：

lists/templates/home.html

       <form method="POST" action="/">

再次运行测试，你会发现功能测试回到之前的状态了：

FAIL: test_multiple_users_can_start_lists_at_different_urls
[...]
AssertionError: 'Buy peacock feathers' unexpectedly found in 'Your To-Do
list\n1: Buy peacock feathers'

Ran 2 tests in 8.541s
FAILED (failures=1)

原先的测试再次通过，由此可以确认我们又回到了正常状态。新功能也许还不可用，但至少旧的功能依旧正常。

7.6　变绿了吗？该重构了

该清理一下测试了。

在遇红 / 变绿 / 重构流程中，已经走到“变绿”这一步，接下来该重构了。现在我们有两个视图，一个用于首页，一个用于单个清单。目前，这两个视图共用一个模板，而且传入了数据库中的所有待办事项。如果仔细查看单元测试中的方法，或许会发现某些部分需要修改：

$ grep -E "class|def" lists/tests.py
class HomePageTest(TestCase):
    def test_uses_home_template(self):
    def test_displays_all_list_items(self):
    def test_can_save_a_POST_request(self):
    def test_redirects_after_POST(self):
    def test_only_saves_items_when_necessary(self):
class ListViewTest(TestCase):
    def test_displays_all_items(self):
class ItemModelTest(TestCase):
    def test_saving_and_retrieving_items(self):

完全可以把 HomePageTest 中的 test_displays_all_list_items 方法删除，因为不需要了。如果现在执行 manage.py test lists 命令，应该会看到运行了 6 个测试，而不是 7 个：

Ran 6 tests in 0.016s
OK

而且，首页模板其实不用再显示所有的待办事项，而应该只显示一个输入框让用户新建清单。

7.7　再迈一小步：一个新模板，用于查看清单

既然首页和清单视图是不同的页面，它们就应该使用不同的 HTML 模板。home.html 可以只包含一个输入框，新模板 list.html 则在表格中显示现有的待办事项。

下面添加一个新测试，检查是否使用了不同的模板：

lists/tests.py

class ListViewTest(TestCase):

    def test_uses_list_template(self):
        response = self.client.get('/lists/the-only-list-in-the-world/')
        self.assertTemplateUsed(response, 'list.html')

    def test_displays_all_items(self):
        [...]

assertTemplateUsed 是 Django 测试客户端提供的强大方法之一。看一下测试的结果如何：

AssertionError: False is not true : Template 'list.html' was not a template
used to render the response. Actual template(s) used: home.html

很好！然后修改视图：

lists/views.py

def view_list(request):
    items = Item.objects.all()
    return render(request, 'list.html', {'items': items})

不过很显然，这个模板还不存在。如果运行单元测试，会得到如下结果：

django.template.exceptions.TemplateDoesNotExist: list.html

新建一个文件，保存为 lists/templates/list.html：

$ touch lists/templates/list.html

这个模板是空的，测试会显示如下错误——幸好有测试，我们才不会忘记输入内容：

AssertionError: False is not true : Couldn't find 'itemey 1' in response

单个清单的模板会用到目前 home.html 中的很多代码，所以可以先把其中的内容复制过来：

$ cp lists/templates/home.html lists/templates/list.html

这会让测试再次通过（变绿）。现在要做一些清理工作（重构）。我们说过，首页不用显示待办事项，只放一个新建清单的输入框就行。因此，可以删除 lists/templates/home.html 中的一些代码，或许还可以把 h1 改成“Start a new To-Do list”：

lists/templates/home.html

<body>
  <h1>Start a new To-Do list</h1>
  <form method="POST">
    <input name="item_text" id="id_new_item" placeholder="Enter a to-do item" />
    {% csrf_token %}
  </form>
</body>

再次运行测试，确认这次改动没有破坏任何功能。很好，继续清理。

其实也不用在 home_page 视图中把全部待办事项都传入 home.html 模板，因此可以把 home_page 视图简化成：

lists/views.py

def home_page(request):
    if request.method == 'POST':
        Item.objects.create(text=request.POST['item_text'])
        return redirect('/lists/the-only-list-in-the-world/')
    return render(request, 'home.html')

再次运行单元测试，它们仍然能通过。然后运行功能测试：

AssertionError: '1: Buy milk' not found in ['1: Buy peacock feathers', '2: Buy
milk']

不错，回归测试（第一个功能测试）通过了，而且新增的测试稍微向前进展了一点——可以指出弗朗西斯没有得到自己的清单页面（因为他仍能看到伊迪丝的部分待办事项）。

你可能觉得并没有取得太多进展，因为网站的功能和本章开始时几乎一模一样。其实有进展，我们正在实现新设计，在前进的道路上铺好了几块垫脚石，而且网站的功能几乎没变。提交目前取得的进展：

$ git status # 会看到4个改动的文件和1个新文件list.html
$ git add lists/templates/list.html
$ git diff # 会看到我们简化了home.html
           # 把一个测试移到了lists/tests.py中的新类里
           # 在views.py中添加了一个新视图
           # 还简化了home_page视图，并在urls.py中增加了一个映射
$ git commit -a # 编写一个消息概述以上操作，或许可以写成
                #“new URL, view and template to display lists”

7.8　第三小步：用于添加待办事项的URL

看一下待办事项清单，现在到哪一步了呢？

第二个问题已经取得了一定进展，不过网站中还是只有一个清单。第一个问题有点吓人。我们能对第三或第四个问题做些什么呢？

下面添加一个新 URL，用于新建待办事项。这么做至少能简化首页视图。

7.8.1　用来测试新建清单的测试类

打开文件 lists/tests.py，把 test_can_save_a_POST_request 和 test_redirects_after_POST 两个方法移到一个新类中，然后再修改 POST 请求的目标 URL：

lists/tests.py (ch07l021-1)

class NewListTest(TestCase):

    def test_can_save_a_POST_request(self):
        self.client.post('/lists/new', data={'item_text': 'A new list item'})
        self.assertEqual(Item.objects.count(), 1)
        new_item = Item.objects.first()
        self.assertEqual(new_item.text, 'A new list item')

    def test_redirects_after_POST(self):
        response = self.client.post('/lists/new', data={'item_text': 'A new list item'})
        self.assertEqual(response.status_code, 302)
        self.assertEqual(response['location'], '/lists/the-only-list-in-the-world/')

　顺便说一句，这里也要注意末尾的斜线——/new 后面不加斜线。我的习惯是，不在修改数据库的“操作”后加斜线。

顺便学习一个新的 Django 测试客户端方法 assertRedirects：

lists/tests.py (ch07l021-2)

    def test_redirects_after_POST(self):
        response = self.client.post('/lists/new', data={'item_text': 'A new list item'})
        self.assertRedirects(response, '/lists/the-only-list-in-the-world/')

这个方法没什么大用，不过能把两个断言精简成一个。

运行这个测试试试：

    self.assertEqual(Item.objects.count(), 1)
AssertionError: 0 != 1
[...]
    self.assertRedirects(response, '/lists/the-only-list-in-the-world/')
[...]
AssertionError: 404 != 302 : Response didn't redirect as expected: Response
code was 404 (expected 302)

第一个失败消息告诉我们，新建的待办事项没有存入数据库。第二个失败消息指出视图返回的状态码是 404，而不是表示重定向的 302。这是因为还没把 /lists/new 添加到 URL 映射中，所以 client.post 得到的是“not found”（未找到）响应。

　还记得之前我们是如何把这种测试分成两个测试方法的吗？如果在一个测试方法中同时测试保存数据和重定向，看到的失败消息就是 0 != 1，调试起来更难。如果你好奇我是怎么知道要这么做的，不要犹豫，问我吧。

7.8.2　用于新建清单的URL和视图

下面添加新的 URL 映射：

superlists/urls.py

urlpatterns = [
    url(r'^$', views.home_page, name='home'),
    url(r'^lists/new$', views.new_list, name='new_list'),
    url(r'^lists/the-only-list-in-the-world/$', views.view_list, name='view_list'),
]

再运行测试，会得到 no attribute'new_list' 错误。修正这个问题，在文件 lists/views.py 中写入：

lists/views.py (ch07l023-1)

def new_list(request):
    pass

再运行测试，得到的失败消息是：“The view lists.views.new_list didn't return an HttpResponse object”（lists.views.new_list 视图没返回 HttpResponse 对象）。这个消息很眼熟。虽然可以返回一个原始的 HttpResponse 对象，但既然知道需要的是重定向，那就从 home_page 视图中借用一行代码吧：

lists/views.py (ch07l023-2)

def new_list(request):
    return redirect('/lists/the-only-list-in-the-world/')

现在测试的结果是：

    self.assertEqual(Item.objects.count(), 1)
AssertionError: 0 != 1

失败消息简洁易懂。再从 home_page 视图中借用一行代码：

lists/views.py (ch07l023-3)

def new_list(request):
    Item.objects.create(text=request.POST['item_text'])
    return redirect('/lists/the-only-list-in-the-world/')

加入这行代码后，测试便能通过了：

Ran 7 tests in 0.030s

OK

而且功能测试表明，我们又回到了正常状态：

[...]
AssertionError: '1: Buy milk' not found in ['1: Buy peacock feathers', '2: Buy
milk']
Ran 2 tests in 8.972s
FAILED (failures=1)

7.8.3　删除当前多余的代码和测试

看起来不错。既然新视图完成了以前 home_page 视图的大部分工作，应该就可以大幅度精简 home_page 了。比如说，可以删除整个 if request.method == 'POST' 部分吗？

lists/views.py

def home_page(request):
    return render(request, 'home.html')

当然可以！

OK

既然已经动手简化了，还可以把当前多余的测试方法 test_only_saves_items_when_necessary 也删掉。

删掉之后是不是感觉挺好的？视图函数变得更简洁了。再次运行测试，确认一切正常：

Ran 6 tests in 0.016s
OK

那功能测试呢？

7.8.4　出现回归！让表单指向刚添加的新URL

糟糕：

ERROR: test_can_start_a_list_for_one_user
[...]
  File "/.../superlists/functional_tests/tests.py", line 57, in
test_can_start_a_list_for_one_user
    self.wait_for_row_in_list_table('1: Buy peacock feathers')
  File "/.../superlists/functional_tests/tests.py", line 23, in
wait_for_row_in_list_table
    table = self.browser.find_element_by_id('id_list_table')
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: [id="id_list_table"]

ERROR: test_multiple_users_can_start_lists_at_different_urls
[...]
  File "/.../superlists/functional_tests/tests.py", line 79, in
test_multiple_users_can_start_lists_at_different_urls
    self.wait_for_row_in_list_table('1: Buy peacock feathers')
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: [id="id_list_table"]
[...]

Ran 2 tests in 11.592s
FAILED (errors=2)

这是因为表单依然指向旧的 URL。在 home.html 和 lists.html 中，把表单改成：

lists/templates/home.html, lists/templates/list.html

    <form method="POST" action="/lists/new">

这样就能回到之前的状态了：

AssertionError: '1: Buy milk' not found in ['1: Buy peacock feathers', '2: Buy
milk']
[...]
FAILED (failures=1)

以上操作可以作为一次完整的提交：对 URL 映射做了些改动，views.py 看起来也精简多了，而且能保证应用还能像以前那样正常运行。我们的重构技术变得越来越好了！

$ git status # 5个改动的文件
$ git diff # 在两个表单中添加了URL，视图和测试都有代码移动，还添加了一个新URL
$ git commit -a

可以在待办事项清单中划掉一个问题了：

7.9　下定决心，调整模型

关于 URL 的清理工作做得够多了，现在下定决心修改模型。先调整模型的单元测试。这次换种方式，以差异的形式表示改动的地方：

lists/tests.py

@@ -1,5 +1,5 @@
  from django.test import TestCase
-from lists.models import Item
+from lists.models import Item, List

  class HomePageTest(TestCase):
@@ -44,22 +44,32 @@ class ListViewTest(TestCase):

-class ItemModelTest(TestCase):
+class ListAndItemModelsTest(TestCase):

    def test_saving_and_retrieving_items(self):

+       list_ = List()
+       list_.save()
+
        first_item = Item()
        first_item.text = 'The first (ever) list item'
+       first_item.list = list_
        first_item.save()

        second_item = Item()
        second_item.text = 'Item the second'
+       second_item.list = list_
        second_item.save()

+       saved_list = List.objects.first()
+       self.assertEqual(saved_list, list_)
+
        saved_items = Item.objects.all()
        self.assertEqual(saved_items.count(), 2)

        first_saved_item = saved_items[0]
        second_saved_item = saved_items[1]
        self.assertEqual(first_saved_item.text, 'The first (ever) list item')
+       self.assertEqual(first_saved_item.list, list_)
        self.assertEqual(second_saved_item.text, 'Item the second')
+       self.assertEqual(second_saved_item.list, list_)

新建了一个 List 对象，然后通过给 .list 属性赋值把两个待办事项归在这个对象名下。要检查这个清单是否正确保存，也要检查是否保存了那两个待办事项与清单之间的关系。你还会注意到可以直接比较两个清单（saved_list 和 list_）——其实比较的是两个清单的主键（.id 属性）是否相同。

　我使用变量名 list_ 的目的是防止遮盖 Python 原生的 list 函数。这么写可能不美观，但我能想到的其他写法也同样不美观，或者更糟，比如 my_list、the_list、list1 和 listey 等。

现在要开始另一个“单元测试 / 编写代码”循环了。

在前几次迭代中，我只给出每次运行测试时期望看到的错误消息，不会告诉你运行测试前要输入哪些代码，你要自己编写每次所需的最少代码改动。

　需要提示？翻回第 5 章，参照引入 Item 模型的步骤。

你首先看到的错误消息是：

ImportError: cannot import name 'List'

解决这个错误后，再次运行测试会看到：

AttributeError: 'List' object has no attribute 'save'

然后会看到：

django.db.utils.OperationalError: no such table: lists_list

因此需要执行一次 makemigrations 命令：

$ python manage.py makemigrations
Migrations for 'lists':
  lists/migrations/0003_list.py
    - Create model List

然后会看到：

    self.assertEqual(first_saved_item.list, list_)
AttributeError: 'Item' object has no attribute 'list'

7.9.1　外键关系

Item 的 list 属性应该怎么实现呢？先天真一点，把它当成 text 属性试试（你可以借机看一下你的实现方式与我的有什么区别）：

lists/models.py

from django.db import models

class List(models.Model):
    pass

class Item(models.Model):
    text = models.TextField(default='')
    list = models.TextField(default='')

照例，测试会告诉我们需要做一次迁移：

$ python manage.py test lists
[...]
django.db.utils.OperationalError: no such column: lists_item.list

$ python manage.py makemigrations
Migrations for 'lists':
  lists/migrations/0004_item_list.py
    - Add field list to item

看一下测试结果如何：

AssertionError: 'List object' != <List: List object>

离成功还有一段距离。请仔细看 != 两边的内容。Django 只保存了 List 对象的字符串形式。若想保存对象之间的关系，要告诉 Django 两个类之间的关系，这种关系使用 ForeignKey 字段表示：

lists/models.py

from django.db import models

class List(models.Model):
    pass

class Item(models.Model):
    text = models.TextField(default='')
    list = models.ForeignKey(List, default=None)

修改之后也要做一次迁移。既然前一个迁移没用了，就把它删掉吧，换一个新的：

$ rm lists/migrations/0004_item_list.py
$ python manage.py makemigrations
Migrations for 'lists':
  lists/migrations/0004_item_list.py
    - Add field list to item

　删除迁移是种危险操作，但偶尔需要这么做，因为不可能从一开始就正确定义模型。如果删除已经用于某个数据库的迁移，Django 就不知道当前状态，因此也就不知道如何运行以后的迁移。只有当你确定某个迁移没被使用时才能将其删除。根据经验，已经提交到 VCS 的迁移决不能删除。

7.9.2　根据新模型定义调整其他代码

再看测试的结果如何：

$ python manage.py test lists
[...]
ERROR: test_displays_all_items (lists.tests.ListViewTest)
django.db.utils.IntegrityError: NOT NULL constraint failed: lists_item.list_id
[...]
ERROR: test_redirects_after_POST (lists.tests.NewListTest)
django.db.utils.IntegrityError: NOT NULL constraint failed: lists_item.list_id
[...]
ERROR: test_can_save_a_POST_request (lists.tests.NewListTest)
django.db.utils.IntegrityError: NOT NULL constraint failed: lists_item.list_id

Ran 6 tests in 0.021s

FAILED (errors=3)

天啊，这么多错误。

可是也有一些好消息。虽然很难看出，不过模型测试通过了。但是三个视图测试出现了重大错误。

出现这些错误是因为我们在待办事项和清单之间建立了关联，在这种关联中，每个待办事项都需要一个父级清单，但是原来的测试和代码并没有考虑到这一点。

不过，这正是测试的目的所在。下面要让测试再次通过。最简单的方法是修改 ListViewTest，为测试中的两个待办事项创建父清单：

lists/tests.py (ch07l031)

class ListViewTest(TestCase):

    def test_displays_all_items(self):
        list_ = List.objects.create()
        Item.objects.create(text='itemey 1', list=list_)
        Item.objects.create(text='itemey 2', list=list_)

修改之后，失败测试减少到两个，而且都是向 new_list 视图发送 POST 请求引起的。使用惯用的技术分析调用跟踪，由错误消息找到导致错误的测试代码，然后再找出相应的应用代码，最终定位到下面这行：

File "/.../superlists/lists/views.py", line 9, in new_list
Item.objects.create(text=request.POST['item_text'])

这行调用跟踪表明创建待办事项时没有指定父清单。因此，要对视图做类似修改：

lists/views.py

from lists.models import Item, List
[...]
def new_list(request):
    list_ = List.objects.create()
    Item.objects.create(text=request.POST['item_text'], list=list_)
    return redirect('/lists/the-only-list-in-the-world/')

修改之后，测试又能通过了：

Ran 6 tests in 0.030s

OK

此时你是不是感觉不舒畅？我们为每个新建的待办事项都指定了所属的清单，但还是集中显示所有待办事项，好像它们都属于同一个清单似的——感觉这么做完全不对。我知道不对，我也有同样的感觉。我们采用的步进方式与直觉不一致，要求代码从一个可用状态变成另一个可用状态。我总想直接动手一次修正所有问题，而不想把一个奇怪的半成品变成另一个半成品。可是你还记得测试山羊吗？爬山时，你要审慎抉择每一步踏在何处，而且一次只能迈一步，确认脚踩的每一个位置都不会让你跌落悬崖。

因此，为了确信一切都能正常运行，要再次运行功能测试：

AssertionError: '1: Buy milk' not found in ['1: Buy peacock feathers', '2: Buy
milk']
[...]

毫无疑问，测试的结果和修改前一样。现有功能没有破坏，在此基础上还修改了数据库。这一点令人欣喜！下面提交：

$ git status # 改动了3个文件，还新建了2个迁移
$ git add lists
$ git diff --staged
$ git commit

又可以从待办事项清单上划掉一个问题了：

7.10　每个列表都应该有自己的URL

应该使用什么作为清单的唯一标识符呢？就目前而言，或许最简单的处理方式是使用数据库自动生成的 id 字段。下面修改 ListViewTest，让其中的两个测试指向新 URL。

还要把 test_displays_all_items 测试重命名为 test_displays_only_items_for_that_list，然后在这个测试中确认只显示属于这个清单的待办事项：

lists/tests.py (ch07l033)

class ListViewTest(TestCase):

    def test_uses_list_template(self):
        list_ = List.objects.create()
        response = self.client.get(f'/lists/{list_.id}/')
        self.assertTemplateUsed(response, 'list.html')

    def test_displays_only_items_for_that_list(self):
        correct_list = List.objects.create()
        Item.objects.create(text='itemey 1', list=correct_list)
        Item.objects.create(text='itemey 2', list=correct_list)
        other_list = List.objects.create()
        Item.objects.create(text='other list item 1', list=other_list)
        Item.objects.create(text='other list item 2', list=other_list)

        response = self.client.get(f'/lists/{correct_list.id}/')

        self.assertContains(response, 'itemey 1')
        self.assertContains(response, 'itemey 2')
        self.assertNotContains(response, 'other list item 1')
        self.assertNotContains(response, 'other list item 2')

　这个代码清单又用到了几个 f 字符串。如果你还是不太了解，看一下文档（https://docs.python.org/3/reference/lexical_analysis.html#f-strings）。（如果你跟我一样没正式学习过 CS，或许应该跳过正式的语法。）

运行这个单元测试，会看到预期的 404，以及另一个相关的错误：

FAIL: test_displays_only_items_for_that_list (lists.tests.ListViewTest)
AssertionError: 404 != 200 : Couldn't retrieve content: Response code was 404
(expected 200)
[...]
FAIL: test_uses_list_template (lists.tests.ListViewTest)
AssertionError: No templates used to render the response

7.10.1　捕获URL中的参数

现在要学习如何把 URL 中的参数传入视图：

superlists/urls.py

urlpatterns = [
    url(r'^$', views.home_page, name='home'),
    url(r'^lists/new$', views.new_list, name='new_list'),
    url(r'^lists/(.+)/$', views.view_list, name='view_list'),
]

调整 URL 映射中使用的正则表达式，加入一个捕获组（capture group，.+），它能匹配随后 / 之前的任意个字符。捕获得到的文本会作为参数传入视图。

也就是说，如果访问 /lists/1/，view_list 视图除了常规的 request 参数之外，还会获得第二个参数，即字符串 "1"。如果访问 /lists/foo/，视图就是 view_list(request, "foo")。

但是视图并未期待有参数传入，毫无疑问，这么做会导致问题：

ERROR: test_displays_only_items_for_that_list (lists.tests.ListViewTest)
[...]
TypeError: view_list() takes 1 positional argument but 2 were given
[...]
ERROR: test_uses_list_template (lists.tests.ListViewTest)
[...]
TypeError: view_list() takes 1 positional argument but 2 were given
[...]
ERROR: test_redirects_after_POST (lists.tests.NewListTest)
[...]
TypeError: view_list() takes 1 positional argument but 2 were given
FAILED (errors=3)

这个问题容易修正，在 views.py 中加入一个参数即可：

lists/views.py

def view_list(request, list_id):
    [...]

现在，前面那个预期失败解决了：

FAIL: test_displays_only_items_for_that_list (lists.tests.ListViewTest)
[...]
AssertionError: 1 != 0 : Response should not contain 'other list item 1'

接下来要让视图决定把哪些待办事项传入模板：

lists/views.py

def view_list(request, list_id):
    list_ = List.objects.get(id=list_id)
    items = Item.objects.filter(list=list_)
    return render(request, 'list.html', {'items': items})

7.10.2　按照新设计调整new_list视图

现在得到发生在另一个测试中的错误：

ERROR: test_redirects_after_POST (lists.tests.NewListTest)
ValueError: invalid literal for int() with base 10:
'the-only-list-in-the-world'

既然这个测试报错了，就来看看它的代码吧：

lists/tests.py

class NewListTest(TestCase):
    [...]

    def test_redirects_after_POST(self):
        response = self.client.post('/lists/new', data={'item_text': 'A new list item'})
        self.assertRedirects(response, '/lists/the-only-list-in-the-world/')

看样子这个测试还没按照清单和待办事项的新设计调整，它应该检查视图是否重定向到指定新建清单的 URL：

lists/tests.py (ch07l036-1)

    def test_redirects_after_POST(self):
        response = self.client.post('/lists/new', data={'item_text': 'A new list item'})
        new_list = List.objects.first()
        self.assertRedirects(response, f'/lists/{new_list.id}/')

修改后测试还是得到无效字面量错误。检查一下视图本身，把它改为重定向到有效的地址：

lists/views.py (ch07l036-2)

def new_list(request):
    list_ = List.objects.create()
    Item.objects.create(text=request.POST['item_text'], list=list_)
    return redirect(f'/lists/{list_.id}/')

这样修改之后单元测试就可以通过了：

$ python3 manage.py test lists
[...]
......
 ---------------------------------------------------------------------
Ran 6 tests in 0.033s

OK

那么功能测试结果如何？差不多也能通过吧？

7.11　功能测试又检测到回归

嗯，快了：

F.
======================================================================
FAIL: test_can_start_a_list_for_one_user
(functional_tests.tests.NewVisitorTest)
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "/.../superlists/functional_tests/tests.py", line 67, in
test_can_start_a_list_for_one_user
    self.wait_for_row_in_list_table('2: Use peacock feathers to make a fly')
[...]
AssertionError: '2: Use peacock feathers to make a fly' not found in ['1: Use
peacock feathers to make a fly']

 ---------------------------------------------------------------------
Ran 2 tests in 8.617s

FAILED (failures=1)

新测试其实通过了，不同的用户有不同的清单，但是旧测试提醒我们出现了回归。看起来无法在清单中添加第二个待办事项。这是因为我们投机取巧了，每次 POST 提交都新建一个清单。这正是编写功能测试的目的！

而这正好和待办事项清单中最后一个问题高度吻合：

7.12　还需要一个视图，把待办事项加入现有清单

还需要一个 URL 和视图（/lists//add_item），把新待办事项添加到现有的清单中。我们已经熟知这种操作了，因此可以一次写好两个测试：

lists/tests.py

class NewItemTest(TestCase):

    def test_can_save_a_POST_request_to_an_existing_list(self):
        other_list = List.objects.create()
        correct_list = List.objects.create()

        self.client.post(
            f'/lists/{correct_list.id}/add_item',
            data={'item_text': 'A new item for an existing list'}
        )

        self.assertEqual(Item.objects.count(), 1)
        new_item = Item.objects.first()
        self.assertEqual(new_item.text, 'A new item for an existing list')
        self.assertEqual(new_item.list, correct_list)

    def test_redirects_to_list_view(self):
        other_list = List.objects.create()
        correct_list = List.objects.create()

        response = self.client.post(
            f'/lists/{correct_list.id}/add_item',
            data={'item_text': 'A new item for an existing list'}
        )

        self.assertRedirects(response, f'/lists/{correct_list.id}/')

　你是不是觉得奇怪，想知道为什么要用 other_list ？这与查看某个清单的测试类似，要确保把待办事项添加到特定的清单中。在数据库中再存储一个对象便无须使用 List.objects.first() 这样可能出错的代码。那样做是不对的，如果真做了，你可能会为之付出惨痛代价（毕竟数字有无穷多个）。这只是主观选择，不过我觉得值得这么做。详情请参见 15.1.1 节。

测试的结果为：

AssertionError: 0 != 1
[...]
AssertionError: 301 != 302 : Response didn't redirect as expected: Response
code was 301 (expected 302)

7.12.1　小心霸道的正则表达式

有点奇怪，还没在 URL 映射中加入 /lists/1/add_item，应该得到 404 != 302 错误，怎么会是 301 呢？

确实令人费解。其实得到这个错误是因为在 URL 映射中使用了一个非常霸道的正则表达式：

superlists/urls.py

    url(r'^lists/(.+)/$', views.view_list, name='view_list'),

根据 Django 的内部处理机制，如果访问的 URL 几乎正确，但却少了末尾的斜线，就会得到一个永久重定向响应（301）。在这里，/lists/1/add_item/ 符合 lists/(.+)/ 的匹配模式，其中 (.+) 捕获 1/add_item，所以 Django 伸出“援手”，猜测你其实是想访问末尾带斜线的 URL。

这个问题的修正方法是，显式指定 URL 模式只捕获数字，即在正则表达式中使用 \d：

superlists/urls.py

    url(r'^lists/(\d+)/$', views.view_list, name='view_list'),

修改后测试的结果是：

AssertionError: 0 != 1
[...]
AssertionError: 404 != 302 : Response didn't redirect as expected: Response
code was 404 (expected 302)

7.12.2　最后一个新URL

现在得到了预期的 404。下面定义一个新 URL，用于把新待办事项添加到现有清单中：

superlists/urls.py

urlpatterns = [
    url(r'^$', views.home_page, name='home'),
    url(r'^lists/new$', views.new_list, name='new_list'),
    url(r'^lists/(\d+)/$', views.view_list, name='view_list'),
    url(r'^lists/(\d+)/add_item$', views.add_item, name='add_item'),
]

现在 URL 映射中定义了三个类似的 URL。在待办事项清单中做个记录，因为这三个 URL 看起来需要重构。

再看测试，现在又提示视图模块缺少属性：

AttributeError: module 'lists.views' has no attribute 'add_item'

7.12.3　最后一个新视图

定义下面这个视图试试：

lists/views.py

def add_item(request):
    pass

效果不错：

TypeError: add_item() takes 1 positional argument but 2 were given

继续修改视图：

lists/views.py

def add_item(request, list_id):
    pass

测试的结果是：

ValueError: The view lists.views.add_item didn't return an HttpResponse object.
It returned None instead.

可以从 new_list 视图中复制 redirect，从 view_list 视图中复制 List.objects.get：

lists/views.py

def add_item(request, list_id):
    list_ = List.objects.get(id=list_id)
    return redirect(f'/lists/{list_.id}/')

现在测试的结果为：

    self.assertEqual(Item.objects.count(), 1)
AssertionError: 0 != 1

最后，让视图保存新建的待办事项：

lists/views.py

def add_item(request, list_id):
    list_ = List.objects.get(id=list_id)
    Item.objects.create(text=request.POST['item_text'], list=list_)
    return redirect(f'/lists/{list_.id}/')

这样，测试又能通过了：

Ran 8 tests in 0.050s

OK

7.12.4　直接测试响应上下文对象

把待办事项添加到现有清单所需的视图和 URL 都有了，现在只剩在 list.html 模板中使用它们了。打开这个模板，修改表单标签：

lists/templates/list.html

    <form method="POST" action="but what should we put here?">

可是，若想获取添加到当前清单的 URL，模板要知道它渲染的是哪个清单，以及要添加哪些待办事项。希望表单能写成下面这样：

lists/templates/list.html

    <form method="POST" action="/lists/{{ list.id }}/add_item">

为了能这样写，视图要把清单传入模板。下面在 ListViewTest 中新建一个单元测试方法：

lists/tests.py (ch07l041)

    def test_passes_correct_list_to_template(self):
        ther_list = List.objects.create()
        orrect_list = List.objects.create()
        esponse = self.client.get(f'/lists/{correct_list.id}/')
        elf.assertEqual(response.context['list'], correct_list)  ➊

➊ response.context 表示要传入 render 函数的上下文——Django 测试客户端把上下文附在 response 对象上，方便测试。

增加这个测试后得到的结果如下：

KeyError: 'list'

这是因为没把 list 传入模板，其实也给了我们一个简化视图的机会：

lists/views.py

def view_list(request, list_id):
    list_ = List.objects.get(id=list_id)
    return render(request, 'list.html', {'list': list_})

这么做显然会破坏一个旧测试，因为模板需要 items：

FAIL: test_displays_only_items_for_that_list (lists.tests.ListViewTest)
[...]
AssertionError: False is not true : Couldn't find 'itemey 1' in response

可以在 list.html 中修正这个问题，同时还要修改表单 POST 请求的目标地址，即 action 属性：

lists/templates/list.html (ch07l043)

    <form method="POST" action="/lists/{{ list.id }}/add_item">  ➊

      [...]

      {% for item in list.item_set.all %}  ➋
        <tr><td>{{ forloop.counter }}: {{ item.text }}</td></tr>
      {% endfor %}

➊ 这是新的目标地址。

➋ .item_set 叫作反向查询（reverse lookup），是 Django 提供的非常有用的 ORM 功能，作用是在其他表中查询某个对象的相关记录。

修改模板之后，单元测试能通过了：

Ran 9 tests in 0.040s

OK

功能测试的结果如何呢？

$ python manage.py test functional_tests
[...]
..
 ---------------------------------------------------------------------
Ran 2 tests in 9.771s

OK

太好了！再看一下待办事项清单：

可惜，测试山羊也是善始不善终的，还有最后一个问题没解决。

在解决这个问题之前，先做提交——着手重构之前一定要提交可正常运行的代码：

$ git diff
$ git commit -am "new URL + view for adding to existing lists. FT passes :-)"

7.13　使用URL引入做最后一次重构

superlists/urls.py 的真正作用是定义整个网站使用的 URL。如果某些 URL 只在 lists 应用中使用，Django 建议使用单独的文件 lists/urls.py，让应用自成一体。定义 lists 使用的 URL，最简单的方法是复制现有的 urls.py：

$ cp superlists/urls.py lists/

然后把 superlists/urls.py 中的三行定义换成一个 include：

superlists/urls.py

from django.conf.urls import include, url
from lists import views as list_views  ➊
from lists import urls as list_urls  ➊

urlpatterns = [
    url(r'^$', list_views.home_page, name='home'),
    url(r'^lists/', include(list_urls)),  ➋
]

❶ 顺便使用 import x as y 句法为视图和 URL 映射创建别名。在顶层 urls.py 中，这是个好做法，便于从多个应用中引入视图和 URL 映射。其实，后文就会这么做。

❷ 这是那个 include。注意，include 可以使用一个正则表达式作为 URL 的前缀，这个前缀会添加到引入的所有 URL 前面（这就是去除重复的方法，同时也让代码结构更清晰）。

回到 lists/urls.py 中，我们只需写入那三个 URL 的后半部分，而且不用再写父级 urls.py 中的其他定义：

lists/urls.py(ch07l046)

from django.conf.urls import url
from lists import views

urlpatterns = [
    url(r'^new$', views.new_list, name='new_list'),
    url(r'^(\d+)/$', views.view_list, name='view_list'),
    url(r'^(\d+)/add_item$', views.add_item, name='add_item'),
]

再次运行单元测试，确认一切仍能正常运行。

我修改时，怀疑自己的能力，不确信能一次改对，所以特意一次只改一个 URL，防止测试失败。如果改错了，还有测试提醒我们。

你可以动手试一下。记得要改回来，而且要确认测试全部都能通过，然后再提交：

$ git status
$ git add lists/urls.py
$ git add superlists/urls.py
$ git diff --staged
$ git commit

终于结束了，这一章可真长啊。我们讨论了很多重要话题，先从测试隔离开始，然后又思考了设计。介绍了一些经验法则，比如“YAGNI”和“事不过三，三则重构”。最重要的是，看到了如何一步步修改现有网站，从一个可运行状态变成另一个可运行状态，逐渐实现新设计。

不得不说，我们的网站已经非常接近发布状态了，也就是说，这个待办事项清单网站的首个测试版可以公之于众了。不过，在此之前可能还要做些美化。在接下来的几章中，我们要介绍部署网站时需要做些什么。

更多 TDD 哲学

从一个可运行状态到另一个可运行状态（又叫测试山羊与重构猫）

本能经常驱使我们直接动手一次修正所有问题，但如果不小心，最终可能像重构猫一样，改动了很多代码但都不起作用。测试山羊建议我们一次只迈一步，从一个可运行状态走到另一个可运行状态。

把工作分解成易于实现的小任务

有时，我们要从“乏味的”工作入手，而不是直指有趣的任务。你要相信人只能活一次，平行宇宙中的另一个你可能过得并不好，把功能都破坏了，极尽所能想让应用再次运行起来。

YAGNI

“You ain't gonna need it”（你不需要这个）的简称，劝诫你不要受诱惑编写当时看起来可能有用的代码。很有可能你根本用不到这些代码，或者没有准确预见未来的需求。第 22 章给出了一种方法，可以让你避免落入这个陷阱。

第二部分　Web 开发要素

“真正的开发者一定会发布自己的产品。”

——Jeff Atwood

如果这是一本普通编程领域内的 TDD 入门书，到这里我们就可以庆贺一番了，毕竟我们已经掌握了扎实的 TDD 和 Django 基础，也具备了开始开发网站所需的一切知识。

但是，真正的开发者一定会发布自己的产品，那就无法回避 Web 开发中的一些棘手问题，比如静态文件、表单数据验证、可怕的 JavaScript 等，但最令人惧怕的还是部署到生产服务器。

在每个阶段，TDD 都能协助我们正确处理这些问题。

在这一部分中，我仍会尽量让学习曲线保持平缓，而且我们会学到多个重要的新概念和技术。我不会深入展开每个话题，只是希望我所演示的方法足够你在自己的项目中开始使用。如果真想在实际工作中使用这些技术，你还得做些扩展阅读。

如果你开始阅读本书之前并不熟悉 Django，现在花点时间读一遍 Django 官方教程，能很好地巩固目前所学的知识。熟悉 Django 的相关概念后，你会更自信，在接下来的几章中，能专注于我要讲的核心概念。

这一部分有很多有趣的知识，敬请期待！

第 8 章　美化网站：布局、样式及其测试方法

我们正考虑要发布网站的第一个版本，但让人尴尬的是，网站现在看起来还很简陋。本章介绍一些样式基础知识，包括如何集成 Bootstrap 这个 HTML/CSS 框架，还要学习 Django 处理静态文件的方式，以及如何测试静态文件。

8.1　如何在功能测试中测试布局和样式

不可否认，我们的网站现在没有太大的吸引力（如图 8-1）。

图 8-1：首页，有点简陋

　执行命令 manage.py runserver 启动开发服务器时，可能会看到一个数据库错误：“table lists_item has no column named list_id”（lists_item 表中没有名为 list_id 的列）。此时，需要执行 manage.py migrate 命令，更新本地数据库，让 models.py 中的改动生效。如果提醒 IntegrityErrors，就删除 1 数据库文件，然后再试。

1什么？删除数据库？疯了吗？并没有。在开发的过程中，本地开发数据库经常与迁移不同步，而且里面也没什么重要数据，因此可以放心删除。不过要谨慎对待生产服务器中的数据库，详情请参见附录 D。

既然不参加 Python 世界的选丑竞赛，就要美化这个网站。或许我们想实现如下效果。

一个精美且很大的输入框，用于新建清单，或者把待办事项添加到现有的清单中。

把这个输入框放在一个更大的居中框体中，吸引用户的注意力。

应该怎么使用 TDD 实现这些效果呢？大多数人都会告诉你，不要测试外观。他们是对的，这就像是测试常量一样毫无意义。

但可以测试装饰外观的方式，确信实现了预期的效果即可。例如，使用层叠样式表（Cascading Style Sheet，CSS）编写样式，样式表以静态文件的形式加载，而静态文件配置起来有点儿复杂（稍后会看到，把静态文件移到主机上，配置起来更麻烦），因此只需做某种“冒烟测试”（smoke test），确保加载了 CSS 即可。无须测试字体、颜色以及像素级位置，而是通过简单的测试，确认重要的输入框在每个页面中都按照预期的方式对齐，由此推断页面中的其他样式或许也都正确应用了。

先在功能测试中编写一个新测试方法：

functional_tests/tests.py (ch08l001)

class NewVisitorTest(LiveServerTestCase):
    [...]

    def test_layout_and_styling(self):
        # 伊迪丝访问首页
        self.browser.get(self.live_server_url)
        self.browser.set_window_size(1024, 768)

        # 她看到输入框完美地居中显示
        inputbox = self.browser.find_element_by_id('id_new_item')
        self.assertAlmostEqual(
            inputbox.location['x'] + inputbox.size['width'] / 2,
            512,
            delta=10
        )

这里有些新知识。先把浏览器的窗口设为固定大小，然后找到输入框元素，获取它的大小和位置，再做些数学计算，检查输入框是否位于网页的中线上。assertAlmostEqual 的作用是帮助处理舍入误差以及偶尔由滚动条等事物导致的异常，这里指定计算结果在正负 10 像素范围内为可接受。

运行功能测试会得到如下结果：

$ python manage.py test functional_tests
[...]
.F.
======================================================================
FAIL: test_layout_and_styling (functional_tests.tests.NewVisitorTest)
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "/.../superlists/functional_tests/tests.py", line 129, in
test_layout_and_styling
    delta=10
AssertionError: 107.0 != 512 within 10 delta

 ---------------------------------------------------------------------
Ran 3 tests in 9.188s

FAILED (failures=1)

这次失败在预料之中。不过，这种功能测试很容易出错，所以要用一种有点作弊的快捷方法确认输入框居中时功能测试能通过。一旦确认功能测试编写正确之后，就把这些代码删掉：

lists/templates/home.html (ch08l002)

<form method="POST" action="/lists/new">
  <p style="text-align: center;">
    <input name="item_text" id="id_new_item" placeholder="Enter a to-do item" />
  </p>
  {% csrf_token %}
</form>

修改之后测试能通过，说明功能测试起作用了。下面扩展这个测试，确保新建清单后输入框仍然居中对齐显示：

functional_tests/tests.py (ch08l003)

# 她新建了一个清单，看到输入框仍完美地居中显示
inputbox.send_keys('testing')
inputbox.send_keys(Keys.ENTER)
self.wait_for_row_in_list_table('1: testing')
inputbox = self.browser.find_element_by_id('id_new_item')
self.assertAlmostEqual(
    inputbox.location['x'] + inputbox.size['width'] / 2,
    512,
    delta=10
)

这会导致测试再次失败：

  File "/.../superlists/functional_tests/tests.py", line 141, in
test_layout_and_styling
    delta=10
AssertionError: 107.0 != 512 within 10 delta

现在只提交功能测试：

$ git add functional_tests/tests.py
$ git commit -m "first steps of FT for layout + styling"

现在，似乎找到了满足需求的适当解决方案，能更好地样式化网站。那么就退回添加 <p style="text-align: center"> 之前的状态吧：

$ git reset --hard

　git reset --hard 是一个破坏力极强的 Git 命令，它会还原所有没提交的改动，所以使用时要小心。它和几乎所有其他 Git 命令都不同，执行之后无法撤销操作。

8.2　使用CSS框架美化网站

设计不简单，现在更难，因为要处理手机、平板等设备。所以很多程序员，尤其是像我一样的懒人，都转而使用 CSS 框架解决问题。框架有很多，不过出现最早且最受欢迎的是 Twitter 开发的 Bootstrap。我们就使用这个框架。

Bootstrap 可在 http://getbootstrap.com/ 获取。

下载 Bootstrap，把它放在 lists 应用中一个新文件夹 static 里：2

2在 Windows 中不能使用 wget 和 unzip，但我相信你知道如何下载 Bootstrap，解压缩，再把 dist 文件夹中的内容移到 lists/static/bootstrap 文件夹中。

$ wget -O bootstrap.zip https://github.com/twbs/bootstrap/releases/download/\
v3.3.4/bootstrap-3.3.4-dist.zip
$ unzip bootstrap.zip
$ mkdir lists/static
$ mv bootstrap-3.3.4-dist lists/static/bootstrap
$ rm bootstrap.zip

dist 文件夹中的内容是未经定制的原始 Bootstrap 框架，现在使用这些内容，但在真正的网站中不能这么做，因为用户能立即看出你使用 Bootstrap 时没有定制，业内人士也能由此推知你懒得为网站编写样式。你应该学习如何使用 LESS，至少把字体改了。Bootstrap 文档中有定制的详细说明，或者你可以阅读一篇名为“How to Customize Twitter's Bootstrap”的指南，写得还不错。

最终得到的 lists 文件夹结构如下：

$ tree lists
lists
├── __init__.py
├── __pycache__
│   └── [...]
├── admin.py
├── models.py
├── static
│   └── bootstrap
│       ├── css
│       │   ├── bootstrap.css
│       │   ├── bootstrap.css.map
│       │   ├── bootstrap.min.css
│       │   ├── bootstrap-theme.css
│       │   ├── bootstrap-theme.css.map
│       │   └── bootstrap-theme.min.css
│       ├── fonts
│       │   ├── glyphicons-halflings-regular.eot
│       │   ├── glyphicons-halflings-regular.svg
│       │   ├── glyphicons-halflings-regular.ttf
│       │   ├── glyphicons-halflings-regular.woff
│       │   └── glyphicons-halflings-regular.woff2
│       └── js
│           ├── bootstrap.js
│           ├── bootstrap.min.js
│           ├── npm.js
├── templates
│   ├── home.html
│   └── list.html
├── tests.py
├── urls.py
└── views.py

在 Bootstrap 文档中的“Getting Started”部分，你会发现 Bootstrap 要求 HTML 模板中包含如下代码：

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Bootstrap 101 Template</title>
    <!-- Bootstrap -->
    <link href="css/bootstrap.min.css" rel="stylesheet">
  </head>
  <body>
    <h1>Hello, world!</h1>
    <script src="http://code.jquery.com/jquery.js"></script>
    <script src="js/bootstrap.min.js"></script>
  </body>
</html>

我们已经有两个 HTML 模板了，所以不想在每个模板中都添加大量的样板代码。这似乎是运用“不要自我重复”原则的好时机，可以把通用代码放在一起。谢天谢地，Django 使用的模板语言可以轻易做到这一点，这种功能叫作“模板继承”。

8.3　Django模板继承

看一下 home.html 和 list.html 之间的差异：

$ diff lists/templates/home.html lists/templates/list.html
<     <h1>Start a new To-Do list</h1>
<     <form method="POST" action="/lists/new">
---
>     <h1>Your To-Do list</h1>
>     <form method="POST" action="/lists/{{ list.id }}/add_item">
[...]
>     <table id="id_list_table">
>       {% for item in list.item_set.all %}
>         <tr><td>{{ forloop.counter }}: {{ item.text }}</td></tr>
>       {% endfor %}
>     </table>

这两个模板头部显示的文本不一样，而且表单的提交地址也不同。除此之外，list.html 还多了一个 <table> 元素。

现在我们弄清了两个模板之间共通以及有差异的地方，然后就可以让它们继承同一个父级模板了。先复制 home.html：

$ cp lists/templates/home.html lists/templates/base.html

把通用的样板代码写入这个基模板中，而且标记出各个“块”，块中的内容留给子模板定制：

lists/templates/base.html

<html>
  <head>
    <title>To-Do lists</title>
  </head>

  <body>
    <h1>{% block header_text %}{% endblock %}</h1>
    <form method="POST" action="{% block form_action %}{% endblock %}">
      <input name="item_text" id="id_new_item" placeholder="Enter a to-do item" />
      {% csrf_token %}
    </form>
    {% block table %}
    {% endblock %}
    </body>
</html>

基模板定义了多个叫作“块”的区域，其他模板可以在这些地方插入自己所需的内容。在实际操作中看一下这种机制的用法。修改 home.html，让它继承 base.html：

lists/templates/home.html

{% extends 'base.html' %}

{% block header_text %}Start a new To-Do list{% endblock %}

{% block form_action %}/lists/new{% endblock %}

可以看出，很多 HTML 样板代码都不见了，只需集中精力编写想定制的部分。然后对 list.html 做同样的修改：

lists/templates/list.html

{% extends 'base.html' %}

{% block header_text %}Your To-Do list{% endblock %}

{% block form_action %}/lists/{{ list.id }}/add_item{% endblock %}

{% block table %}
  <table id="id_list_table">
    {% for item in list.item_set.all %}
      <tr><td>{{ forloop.counter }}: {{ item.text }}</td></tr>
    {% endfor %}
  </table>
{% endblock %}

对模板来说，这是一次重构。再次运行功能测试，确保没有破坏现有功能：

AssertionError: 107.0 != 512 within 10 delta

果然，结果和修改前一样。这次改动值得做一次提交：

$ git diff -b
# -b的意思是忽略空白，因为我们修改了HTML代码中的一些缩进，所以有必要使用这个旗标
$ git status
$ git add lists/templates # 先不添加static文件夹
$ git commit -m "refactor templates to use a base template"

8.4　集成Bootstrap

现在集成 Bootstrap 所需的样板代码更容易了，不过暂时不需要 JavaScript，只加入 CSS 即可：

lists/templates/base.html (ch08l006)

<!DOCTYPE html>
<html lang="en">

  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>To-Do lists</title>
    <link href="css/bootstrap.min.css" rel="stylesheet">
  </head>
[...]

行和列

最后，使用 Bootstrap 中某些真正强大的功能。使用之前你得先阅读 Bootstrap 的文档。可以使用栅格系统和 text-center 类实现所需的效果：

lists/templates/base.html (ch08l007)

<body>
  <div class="container">

    <div class="row">
      <div class="col-md-6 col-md-offset-3">
        <div class="text-center">
          <h1>{% block header_text %}{% endblock %}</h1>
          <form method="POST" action="{% block form_action %}{% endblock %}">
            <input name="item_text" id="id_new_item"
                   placeholder="Enter a to-do item" />
            {% csrf_token %}
          </form>
        </div>
      </div>
    </div>

    <div class="row">
      <div class="col-md-6 col-md-offset-3">
        {% block table %}
        {% endblock %}
      </div>
    </div>

  </div>
</body>

（如果你从来没有把一个 HTML 标签分成多行，可能会觉得上述 <input> 标签有点儿打破常规。这样写完全可行，如果你不喜欢，可以不这么写。）

　如果你从未看过 Bootstrap 文档，花点儿时间浏览一下吧。文档中介绍了很多有用的工具，可以运用到你的网站中。

做了上述修改之后，功能测试能通过吗？

AssertionError: 107.0 != 512 within 10 delta

嗯，还不能。为什么没有加载 CSS 呢？

8.5　Django中的静态文件

Django 处理静态文件时需要知道两件事（其实所有 Web 服务器都是如此）。

(1) 收到指向某个 URL 的请求时，如何区分请求的是静态文件，还是需要经过视图函数处理，生成 HTML。

(2) 到哪里去找用户请求的静态文件。

其实，静态文件就是把 URL 映射到硬盘中的文件上。

为了解决第一个问题，Django 允许我们定义一个 URL 前缀，任何以这个前缀开头的 URL 都被视作针对静态文件的请求。默认的前缀是 /static/，在 settings.py 中定义：

superlists/settings.py

[...]

# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.11/howto/static-files/

STATIC_URL = '/static/'

后面在这一部分添加的设置都和第二个问题有关，即在硬盘中找到真正的静态文件。

既然用的是 Django 开发服务器（manage.py runserver），就可以把寻找静态文件的任务交给 Django 完成。Django 会在各应用中每个名为 static 的子文件夹里寻找静态文件。

现在知道把 Bootstrap 框架的所有静态文件都放在 lists/static 文件夹中的原因了吧。可是为什么现在不起作用呢？因为没在 URL 中加入前缀 /static/。再看一下 base.html 中链接 CSS 的元素：

<link href="css/bootstrap.min.css" rel="stylesheet">

若想让这行代码起作用，要把它改成：

lists/templates/base.html

<link href="/static/bootstrap/css/bootstrap.min.css" rel="stylesheet">

现在，开发服务器收到这个请求时就知道请求的是静态文件，因为 URL 以 /static/ 开头。然后，服务器在每个应用中名为 static 的子文件夹里搜寻名为 bootstrap/css/bootstrap.min.css 的文件。最后找到的文件应该是 lists/static/bootstrap/css/bootstrap.min.css。

如果手动在浏览器中查看，应该会看到样式起作用了，如图 8-2 所示。

图 8-2：网站开始变得有点好看了

换用StaticLiveServerTestCase

不过，功能测试还无法通过：

AssertionError: 107.0 != 512 within 10 delta

这是因为，虽然 runserver 启动的开发服务器能自动找到静态文件，但 LiveServerTestCase 找不到。不过无须担心，Django　为开发者提供了一个更神奇的测试类，叫 StaticLiveServerTestCase。

下面换用这个测试类：

functional_tests/tests.py

@@ -1,14 +1,14 @@
-from django.test import LiveServerTestCase
+from django.contrib.staticfiles.testing import StaticLiveServerTestCase
  from selenium import webdriver
  from selenium.common.exceptions import WebDriverException
  from selenium.webdriver.common.keys import Keys
  import time

  MAX_WAIT = 10

-class NewVisitorTest(LiveServerTestCase):
+class NewVisitorTest(StaticLiveServerTestCase):

     def setUp(self):

现在测试能找到 CSS 了，因此测试也能通过了：

$ python manage.py test functional_tests
Creating test database for alias 'default'...
...
 ---------------------------------------------------------------------
Ran 3 tests in 9.764s

　Windows 用户在这里可能会看到一些错误消息（无关紧要，但容易让人分心）：socket.error: [WinError 10054] An existing connection was forcibly closed by the remote host（现有连接被远程主机强制关闭）。在 tearDown 方法的 self.browser.quit() 之前加上 self.browser.refresh() 就能去掉这些错误。Django 的追踪系统正在关注这个问题。

太好了！

8.6　使用Bootstrap中的组件改进网站外观

看一下使用 Bootstrap 百宝箱中的其他工具能否进一步改善网站的外观。

8.6.1　超大文本块

Bootstrap 中有个类叫 jumbotron，用于特别突出地显示页面中的元素。使用这个类放大显示页面的主头部和输入表单：

lists/templates/base.html (ch08l009)

    <div class="col-md-6 col-md-offset-3 jumbotron">
      <div class="text-center">
        <h1>{% block header_text %}{% endblock %}</h1>
        <form method="POST" action="{% block form_action %}{% endblock %}">
          [...]

　调整网页的设计和布局时，可以打开一个浏览器窗口，时不时地刷新页面。执行 python manage.py runserver 命令启动开发服务器，然后在浏览器中访问 http://localhost:8000，边改边看效果。

8.6.2　大型输入框

超大文本块是个好的开始，不过和其他内容相比，输入框显得太小。幸好 Bootstrap 为表单控件提供了一个类，可以把输入框变大：

lists/templates/base.html (ch08l010)

    <input name="item_text" id="id_new_item"
           class="form-control input-lg"
           placeholder="Enter a to-do item" />

8.6.3　样式化表格

现在表格中的文字和页面中的其他文字相比也很小，加上 Bootstrap 提供的 table 类可以改进显示效果：

lists/templates/list.html (ch08l011)

    <table id="id_list_table" class="table">

8.7　使用自己编写的CSS

最后，我想让输入表单离标题文字远一点儿。Bootstrap 没有提供现成的解决方案，那么就自己实现吧，引入一个自己编写的 CSS 文件：

lists/templates/base.html

    [...]
      <title>To-Do lists</title>
      <link href="/static/bootstrap/css/bootstrap.min.css" rel="stylesheet">
      <link href="/static/base.css" rel="stylesheet">
    </head>

新建文件 lists/static/base.css，写入自己编写的 CSS 新规则。使用输入框的 id（id_new_item）定位元素，然后为其编写样式：

lists/static/base.css

#id_new_item {
    margin-top: 2ex;
}

虽然要多操作几步，不过效果很让我满意（如图 8-3）。

图 8-3：清单页面，各部分都显示得很大

如果想进一步定制 Bootstrap，需要编译 LESS。我强烈推荐你花时间定制，总有一天你会有这种需求。LESS、Sass 等其他伪 CSS 类工具，对普通的 CSS 做了很大改进，即便不使用 Bootstrap 也很有用。我不会在这本书中介绍 LESS，网上有很多参考资料，比如说“How to Customize Twitter's Bootstrap”。

最后再运行一次功能测试，看一切是否仍能正常运行：

$ python manage.py test functional_tests
[...]
...
 ---------------------------------------------------------------------
Ran 3 tests in 10.084s

OK

样式化暂告段落。现在是提交的绝好时机：

$ git status # 修改了tests.py、base.html和list.html，未跟踪lists/static
$ git add .
$ git status # 会显示添加了所有Bootstrap相关文件
$ git commit -m "Use Bootstrap to improve layout"

8.8　补遗：collectstatic命令和其他静态目录

前文说过，Django 的开发服务器会自动在应用的文件夹中查找并呈现静态文件。在开发过程中这种功能不错，但在真正的 Web 服务器中，并不需要让 Django 伺服静态内容，因为使用 Python 伺服原始文件速度慢而且效率低，Apache、Nginx 等 Web 服务器能更好地完成这项任务。或许你还会决定把所有静态文件都上传到 CDN（Content Distribution Network，内容分发网络），不放在自己的主机中。

鉴于此，要把分散在各个应用文件夹中的所有静态文件集中起来，复制一份放在一个位置，为部署做好准备。collectstatic 命令的作用就是完成这项操作。

静态文件集中放置的位置由 settings.py 中的 STATIC_ROOT 定义。下一章会做些部署工作，现在就试着设置一下吧。把 STATIC_ROOT 的值设为仓库之外的一个文件夹——我要使用和主源码文件夹同级的一个文件夹：

workspace
│    ├── superlists
│    │    ├── lists
│    │    │     ├── models.py
│    │    │
│    │    ├── manage.py
│    │    ├── superlists
│    │
│    ├── static
│    │    ├── base.css
│    │    ├── etc...

关键在于，静态文件所在的文件夹不能放在仓库中——不想把这个文件夹纳入版本控制，因为其中的文件和 lists/static 中的一样。

下面是指定这个文件夹位置的一种优雅方式，路径相对 settings.py 文件而言：

superlists/settings.py (ch08l018)

# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.11/howto/static-files/

STATIC_URL = '/static/'
STATIC_ROOT = os.path.abspath(os.path.join(BASE_DIR, '../static'))

在设置文件的顶部，你会看到定义了 BASE_DIR 变量。这个变量利用 __file__ （这是 Python 内置的变量，特别有用），为我们提供了很大的便利。

下面执行 collectstatic 命令试试：

$ python manage.py collectstatic
[...]
Copying '/.../superlists/lists/static/bootstrap/css/bootstrap-theme.css'
Copying '/.../superlists/lists/static/bootstrap/css/bootstrap.min.css'

76 static files copied to '/.../static'.

如果查看 ../static，会看到所有的 CSS 文件：

$ tree ../static/
../static/
├── admin
│   ├── css
│   │   ├── base.css

[...]

│                  └── xregexp.min.js
├── base.css
└── bootstrap
    ├── css
    │   ├── bootstrap.css
    │   ├── bootstrap.css.map
    │   ├── bootstrap.min.css
    │   ├── bootstrap-theme.css
    │   ├── bootstrap-theme.css.map
    │   └── bootstrap-theme.min.css
    ├── fonts
    │   ├── glyphicons-halflings-regular.eot
    │   ├── glyphicons-halflings-regular.svg
    │   ├── glyphicons-halflings-regular.ttf
    │   ├── glyphicons-halflings-regular.woff
    │   └── glyphicons-halflings-regular.woff2
    └── js
        ├── bootstrap.js
        ├── bootstrap.min.js
        └── npm.js

14 directories, 76 files

collectstatic 命令还收集了管理后台的所有 CSS 文件。管理后台是 Django 的强大功能之一，总有一天我们要知道它的全部功能。但现在还不准备用，所以暂且禁用：

superlists/settings.py

INSTALLED_APPS = [
    #'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'lists',
]

然后再执行 collectstatic 试试：

$ rm -rf ../static/
$ python manage.py collectstatic --noinput
Copying '/.../superlists/lists/static/base.css'
[...]
Copying '/.../superlists/lists/static/bootstrap/css/bootstrap-theme.css'
Copying '/.../superlists/lists/static/bootstrap/css/bootstrap.min.css'

15 static files copied to '/.../static'.

好多了。

总之，现在知道了怎么把所有静态文件都聚集到一个文件夹中，这样 Web 服务器就能轻易找到静态文件。下一章会深入介绍这个功能和测试方法。

现在，先提交 settings.py 中的改动：

$ git diff # 会看到settings.py中的改动
$ git commit -am "set STATIC_ROOT in settings and disable admin"

8.9　没谈到的话题

本章只简单介绍了样式化和 CSS，有一些话题我本想涉及，但没做到。你可以进一步研究以下话题。

使用 LESS 或 Sass 定制 Bootstrap。

使用 {% static %} 模板标签，这样做更符合 DRY 原则，也不用硬编码 URL。

客户端打包工具，例如 npm 和 bower。

总结：如何测试设计和布局

简单来说，不应该为设计和布局编写测试。因为这太像是测试常量，所以写出的测试不太牢靠。

这说明设计和布局的实现过程极具技巧性，涉及 CSS 和静态文件。因此，可以编写一些简单的“冒烟测试”，确认静态文件和 CSS 起作用即可。下一章我们会看到，把代码部署到生产环境时，冒烟测试能协助我们发现问题。

但是，如果某部分样式需要很多客户端 JavaScript 代码才能使用（我花了很多时间实现动态缩放），就必须为此编写一些测试。

要试着编写最简的测试，确信设计和布局能起作用即可，不必测试具体的实现。我们的目标是能自由修改设计和布局，且无须时不时地调整测试。

第 9 章　使用过渡网站测试部署

“所有乐趣都在部署到生产环境之前。”

——Devops Borat

是时候发布网站的首个版本让公众使用了。人们常说，如果等到一切就绪再发布，等待的时间就太长了。

我们的网站有用吗？是不是比没有要好？能在这个网站上创建待办事项清单吗？这三个疑问的答案都是肯定的。

可是，现在还无法登录，也无法把任务标记为已完成。不过你真的需要这些功能吗？不一定，因为你永远也不知道用户真正想使用你的网站做什么。我们觉得用户应该想使用这个网站制定待办事项清单，但他们真正想编写的或许是一个“十佳假蝇钓鱼地”列表，因此就不用提供“标记为已完成”功能。在发布之前，我们永远不知道用户的真实需求。

本章会详细介绍如何把网站部署到真实的线上 Web 服务器中。

你可能想跳过这一章，因为本章有很多令人生畏的知识，或许还觉得部署不是你阅读本书的初衷。但是我强烈建议你读一下。本章是我最满意的内容之一，而且有很多读者写信说很庆幸自己当时克服困难读完了这一章。

如果你以前从未把网站部署到服务器上，读过本章后会发现一片新大陆，而且没有什么能比看到自己的网站在真正的互联网中上线更令人满足的了。如果你还迟疑，现今流行的术语，比如“开发运维”（DevOps），一定能让你相信，花时间学习部署是值得的。

　你的网站上线后请写封信告诉我网址。收到这样的来信，我的心里总是感觉暖暖的。我的电子邮件地址是 obeythetestinggoat@gmail.com。

9.1　TDD以及部署的危险区域

把网站部署到线上 Web 服务器是个很复杂的过程。我们经常会听到这样凄苦的抱怨：“但在我的设备中可以正常运行啊！”

部署过程中的一些危险区域如下。

静态文件（CSS、JavaScript、图片等）

Web 服务器往往需要特殊的配置才能伺服静态文件。

数据库

可能会遇到权限和路径问题，还要小心处理，在多次部署之间不能丢失数据。

依赖

要保证服务器上安装了网站依赖的包，而且版本要正确。

不过这些问题都有相应的解决方案。下面一一说明。

使用与生产环境一样的基础架构部署过渡网站（staging site），这么做可以测试部署的过程，确保部署真正的网站时操作正确。

可以在过渡网站中运行功能测试，确保服务器中安装了正确的代码和依赖包。而且为了测试网站的布局，我们编写了冒烟测试，这样就能知道是否正确加载了 CSS。

与在本地设备上一样，当服务器上运行多个 Python 应用时，可以使用虚拟环境管理包和依赖。

最后，一切操作都自动化完成。使用自动化脚本部署新版本，使用同一个脚本把网站部署到过渡环境和生产环境，这么做能尽量保证过渡网站和线上网站一样。 1

1我称之为“过渡”服务器，有人叫它“开发”服务器，还有人叫它“预备生产”服务器。不管叫什么，目的都是架设一个尽量和生产服务器一样的环境来测试代码。

在接下来的几页中，我会详细说明一个部署过程。这不是最佳的部署过程，所以别把它当作最佳实践，也别当作是推荐做法。只是做个演示，告诉你部署过程涉及哪些问题，以及测试在这个过程中的作用。

内容提要

接下来的三章内容很多，这里列出提要，帮助你理清思路。

本章：搭建基础环境

修改功能测试，以便在过渡服务器中运行。

架设服务器，安装所需的全部软件，再把过渡和线上环境使用的域名指向这个服务器。

使用 Git 把代码上传到服务器。

使用 Django 开发服务器在过渡环境的域名下尝试运行过渡网站。

自己动手在服务器上搭建虚拟环境（不用 virtualenvwrapper），确保数据库和静态文件都能使用。

在这个过程中不断运行功能测试，找出哪些功能可以正常运行，哪些不能。

下一章：针对生产环境配置

修改配置，使其适应生产环境：不再使用 Django 开发服务器，让应用在引导时自动启动，把 DEBUG 设为 False，等等。

关于部署的第三章：自动部署

(1) 配置好之后，编写一个脚本，自动执行前面手动完成的操作，这样以后就能自动部署网站了。

(2) 最后，使用自动化脚本把网站的生产版本部署到真正的域名下。

9.2　一如既往，先写测试

稍微修改一下功能测试，让它能在过渡网站中运行。添加一个参数，指定测试所用的临时服务器地址：

functional_tests/tests.py (ch08l001)

import os
[...]

class NewVisitorTest(StaticLiveServerTestCase):

    def setUp(self):
        self.browser = webdriver.Firefox()
        staging_server = os.environ.get('STAGING_SERVER')  ➊
        if staging_server:
            self.live_server_url = 'http://' + staging_server  ➋

还记得我说过 LiveServerTestCase 有一定的缺陷吗？其中一个缺陷是，它总是假定你想使用它自己的测试服务器，这个服务器的地址通过 self.live_server_url 获取。有时我确实想使用这个测试服务器，但也想有别的选择，比如使用一个真正的服务器。

❶ 我通过环境变量 STAGING_SERVER 决定使用哪个服务器。

❷ 我采用的措施是，把 self.live_server_url 替换成“真实”服务器的地址。

按照“常规的”方式运行功能测试，确保上述改动没有破坏现有功能：

$ python manage.py test functional_tests
[...]
Ran 3 tests in 8.544s

OK

然后指定过渡服务器的 URL 再运行试试。我要使用的过渡服务器地址是 superlists-staging.ottg.eu：

$ STAGING_SERVER=superlists-staging.ottg.eu python manage.py test functional_tests

======================================================================
FAIL: test_can_start_a_list_for_one_user
(functional_tests.tests.NewVisitorTest)
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "/.../superlists/functional_tests/tests.py", line 49, in
test_can_start_a_list_and_retrieve_it_later
    self.assertIn('To-Do', self.browser.title)
AssertionError: 'To-Do' not found in 'Domain name registration | Domain names
| Web Hosting | 123-reg'
[...]

======================================================================
FAIL: test_multiple_users_can_start_lists_at_different_urls
(functional_tests.tests.NewVisitorTest)
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File
"/.../superlists/functional_tests/tests.py", line 86, in
test_layout_and_styling
    inputbox = self.browser.find_element_by_id('id_new_item')
[...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: [id="id_new_item"]
[...]

======================================================================
FAIL: test_layout_and_styling (functional_tests.tests.NewVisitorTest)
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File
[...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: [id="id_new_item"]
[...]

Ran 3 tests in 19.480s:

FAILED (failures=3)

　如果在 Windows 上看到“STAGING_SERVER is not recognized as a command”这样的错误，可能是因为你没使用 Git-Bash。请再看一下“准备工作和应具备的知识”。

可以看到，和预期一样，两个测试都失败了，因为我还没有域名呢。实际上，从第一个调用跟踪中可以看出，访问域名注册商的网站首页时测试就失败了。

不过，看起来功能测试的测试对象是正确的，所以做一次提交吧：

$ git diff # 会显示functional_tests.py中的改动
$ git commit -am "Hack FT runner to be able to test staging"

　别使用 export 设定 STAGING_SERVER 环境变量，否则在当前终端里运行的后续测试都会在过渡网站中运行（如果这跟你的预期不一样，会让你十分困惑）。最好在每次运行功能测试时明确设定。

9.3　注册域名

读到这里，我们需要注册几个域名，不过也可以使用同一个域名下的多个二级域名。我要使用的域名是 superlists.ottg.eu 和 superlists-staging.ottg.eu。如果你还没有域名，现在就得注册一个。再次说明，我真的希望你按我所说的做。如果你从未注册过域名，随便选一个老牌注册商买一个便宜的就行，只要花 5 美元左右，甚至还能找到免费域名。我说过想在真正的网站中看到你的应用，这或许能推动你去注册一个域名。

9.4　手动配置托管网站的服务器

可以把部署的过程分成两个任务。

配置新服务器，用于托管代码。

把新版代码部署到配置好的服务器中。

有些人喜欢每次部署都用全新服务器，我们在 PythonAnywhere 就是这么做的。不过这种做法只适用于大型的复杂网站，或者对现有网站做了重大修改。对我们这个简单的网站来说，分别完成上述两个任务更合理。虽然最终这两个任务都要完全自动化，但就目前而言或许更适合手动配置。

在阅读过程中，你要记住，配置的过程各异，因此部署有很多通用的最佳实践。所以，与其尝试记住我的具体做法，不如试着理解其中的基本原理，这样以后你遇到具体问题时就能使用相同的思想解决了。

9.4.1　选择在哪里托管网站

现今，托管网站有大量不同的方案，不过基本上可以归纳为两类。

运行自己的服务器（可能是虚拟服务器）。

使用“平台即服务”（Platform-As-A-Service，PaaS）提供商，例如 Heroku、OpenShift 或 PythonAnywhere。

对小型网站而言，PaaS 的优势尤其明显，我强烈建议你考虑使用 PaaS。不过，基于几个原因，本书不会使用 PaaS。首先，有利益冲突，我觉得 PythonAnywhere 是最棒的，但我这么说是因为我在这家公司工作。其实，各家 Paas 提供商提供的支持各不相同，部署的过程也不一样，所以学会其中一家的部署方法并不能在别家使用。任何一家提供商都有可能完全修改部署过程，或者当你阅读这本书时已经停业了。

因此，我们要学习一些优秀的老式服务器管理方法，包括 SSH 和 Web 服务器配置。这些方法永远不会过时，而且学会这些方法还能从头发花白的老前辈那儿得到一些尊重。

我要试着搭建一个十分类似于 PaaS 环境的服务器，不管以后你选择哪种配置方案，都能用到这个部署过程中学到的知识。

9.4.2　搭建服务器

我不规定你该怎么搭建服务器，不管你选择使用 Amazon AWS、Rackspace 或 Digital Ocean，还是自己的数据中心里的服务器，抑或楼梯后橱柜里的 Raspberry Pi——哪种方案都行，只要满足以下条件即可。

服务器的系统使用 Ubuntu16.04（“Xenial/LTS”）。

有访问服务器的 root 权限。

外网可访问。

可以通过 SSH 登录。

我推荐使用 Ubuntu 发行版是因为其中安装了 Python 3.6，而且有一些配置 Nginx 的特殊方式（后文会用到）。如果你知道自己在做什么，或许可以换用其他发行版，但遇到问题只能靠自己了。

如果你从未用过 Linux 服务器，完全不知道从何处入手，可以参照我在 GitHub 上写的一篇简要指南（https://github.com/hjwp/Book-TDD-Web-Dev-Python/blob/master/server-quickstart.md）。

　有些人阅读本章时会跳过购买域名和架设真正的服务器这两部分，直接在自己的电脑中使用虚拟机。请不要这么做，这两种方式不一样。配置服务器本身已经很复杂了，如果用虚拟机，阅读本章的内容时会遇到更大的困难。如果你担心要花钱，可以四处找找，域名和服务器都有免费的。如果需要进一步指导，可以给我发电子邮件，我一直都乐于助人。

9.4.3　用户账户、SSH和权限

本节假定你的用户账户没有 root 权限，但有使用 sudo 的权限，因此执行需要 root 权限的操作时，我们可以使用 sudo。在下面的说明中，如果需要用 sudo，我会指出来。

我使用的用户名是“elspeth”，你可以根据自己的喜好起名。

9.4.4　安装Nginx

我们需要一个 Web 服务器，既然现在酷小孩都使用 Nginx，那我们也用它吧。我和 Apache 斗争了多年，可以说单就配置文件的可读性这一项而言，Nginx 如同神赐般拯救了我们。

在我的服务器中安装 Nginx 只需执行一次 apt-get 命令即可，然后再执行一个命令就能看到 Nginx 默认的欢迎页面：

elspeth@server:$ sudo apt-get install nginx
elspeth@server:$ sudo systemctl start nginx

（你可能要先执行 apt-get update 和 / 或 apt-get upgrade。）

　注意本章命令行代码清单中的 elspeth@server，它表示命令必须在服务器中执行，而不是在你自己的电脑中执行。

现在访问服务器的 IP 地址就能看到 Nginx 的“Welcome to nginx”（欢迎使用 Nginx）页面，如图 9-1 所示。

图 9-1：Nginx 可用了

　如果没看到这个页面，可能是因为防火墙没有开放 80 端口。以 AWS 为例，你可能得配置服务器的“Security Group”才能打开 80 端口。

9.4.5　安装Python 3.6

写作本书时，Ubuntu 的标准仓库中还没有 Python 3.6，但是用户贡献的“Deadsnakes PPA”中有。

既然我们有 root 权限，下面就来安装所需的系统级关键软件：Python、Git、pip 和 virtualenv。

elspeth@server:$ sudo add-apt-repository ppa:fkrull/deadsnakes
elspeth@server:$ sudo apt-get update
elspeth@server:$ sudo apt-get install python3.6 python3.6-venv

顺便把 Git 也安装上：

elspeth@server:$ sudo apt-get install git

9.4.6　解析过渡环境和线上环境所用的域名

不想总是使用 IP 地址，所以要把过渡环境和线上环境所用的域名解析到服务器上。我的注册商提供的控制面板如图 9-2 所示。

图 9-2：解析域名

在 DNS 系统中，把域名指向一个确切的 IP 地址叫作“A 记录”。各注册商提供的界面有所不同，但在你的注册商网站中四处点击几次应该就能找到正确的页面。

9.4.7　使用功能测试确认域名可用而且Nginx正在运行

为了确认一切顺利，可以再次运行功能测试。你会发现失败消息稍微有点儿不同，其中一个消息和 Nginx 有关：

$ STAGING_SERVER=superlists-staging.ottg.eu python manage.py test functional_tests
[...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: [id="id_new_item"]
[...]
AssertionError: 'To-Do' not found in 'Welcome to nginx!'

有进展。鼓励一下自己，你可以泡杯茶，吃块巧克力饼干。

9.5　手动部署代码

接着要让过渡网站运行起来，检查 Nginx 和 Django 之间能否通信。从这一步起，配置结束了，进入“部署”阶段。在部署的过程中，要思考如何自动化这些操作。

　区分配置阶段和部署阶段有个经验法则：配置时需要 root 权限，但部署时不需要。

需要一个文件夹来存放源码。我们把这个目录放在一个非 root 用户的家目录中，在我的服务器中，路径是 /home/elspeth（好像所有共享主机的系统都是这么设置的。不论使用什么主机，一定要以非 root 用户身份运行 Web 应用）。我要按照下面的文件结构存放网站的代码：

/home/elspeth
├── sites
│   ├── www.live.my-website.com
│   │    ├── database
│   │    │     └── db.sqlite3
│   │    ├── source
│   │    │    ├── manage.py
│   │    │    ├── superlists
│   │    │    ├── etc...
│   │    │
│   │    ├── static
│   │    │    ├── base.css
│   │    │    ├── etc...
│   │    │
│   │    └── virtualenv
│   │          ├── lib
│   │          ├── etc...
│   │
│   ├── www.staging.my-website.com
│   │    ├── database
│   │    ├── etc...

每个网站（过渡网站，线上网站或其他网站）都放在各自的文件夹中。在各文件夹中又有单独的子文件夹，分别存放源码、数据库和静态文件。采用这种结构的逻辑依据是，不同版本的网站源码可能会变，但数据库始终不变。静态文件夹也在同一个相对位置，即 ../static，前一章末尾我们已经设置好了。最后，virtualenv 也有自己的子文件夹（在服务器上无须使用 virtualenvwrapper，我们将动手创建虚拟环境）。

9.5.1　调整数据库的位置

首先，在 settings.py 中修改数据库的位置，而且要保证修改后的位置在本地电脑中也能使用：

superlists/settings.py (ch08l003)

# 项目内的路径使用os.path.join(BASE_DIR, ...)形式构建
import os
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
[...]
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': os.path.join(BASE_DIR, '../database/db.sqlite3'),
    }
}

　看一下 settings.py 文件顶部 BASE_DIR 的定义。注意，最内层是 abspath。处理路径时一定要这么做，否则导入文件时会遇到各种奇怪的问题。感谢 Green Nathan 指出这一点。

在本地试一下：

$ mkdir ../database
$ python manage.py migrate --noinput
Operations to perform:
Apply all migrations: auth, contenttypes, lists, sessions
Running migrations:
[...]
$ ls ../database/
db.sqlite3

看起来可以正常使用。做次提交：

$ git diff # 会看到settings.py中的改动
$ git commit -am "move sqlite database outside of main source tree"

借助代码分享网站，使用 Git 把代码上传到服务器。如果之前没推送，现在要把代码推送到 GitHub、BitBucket 或同类网站中。这些网站都为初学者提供了很好的用法说明，告诉你该怎么推送。

把源码上传到服务器所需的全部 Bash 命令如下所示。如果你不熟悉 Bash 命令，我告诉你，export 的作用是创建一个可在 bash 中使用的“本地变量”：

elspeth@server:$ export SITENAME=superlists-staging.ottg.eu
elspeth@server:$ mkdir -p ~/sites/$SITENAME/database
elspeth@server:$ mkdir -p ~/sites/$SITENAME/static
elspeth@server:$ mkdir -p ~/sites/$SITENAME/virtualenv
# 要把下面这行命令中的URL换成你自己仓库的URL
elspeth@server:$ git clone https://github.com/hjwp/book-example.git \
~/sites/$SITENAME/source
Resolving deltas: 100% [...]

　使用 export 定义的 Bash 变量只在当前终端会话中有效。如果退出服务器后再登录，就需要重新定义。这个特性有点隐晦，因为 Bash 不会报错，而是直接用空字符串表示未定义的变量，这种处理方式会导致诡异的结果。如果不信，可以执行 echo $SITENAME 试试。

现在网站安装好了，在开发服务器中运行试试——这是一个冒烟测试，检查所有活动部件是否连接起来了：

elspeth@server:$ $ cd ~/sites/$SITENAME/source
$ python manage.py runserver
Traceback (most recent call last):
  File "manage.py", line 8, in <module>
    from django.core.management import execute_from_command_line
ImportError: No module named django.core.management

啊，服务器上还没安装 Django。

9.5.2　手动创建虚拟环境，使用requirements.txt

为了“保存”虚拟环境所需的包列表，也为了能够重建服务器，我们要创建 requirements.txt 文件：

$ echo "django==1.11" > requirements.txt
$ git add requirements.txt
$ git commit -m "Add requirements.txt for virtualenv"

　你可能觉得奇怪，为什么没在需要的依赖列表中添加 Selenium ？这是因为 Selenium 只是测试的依赖，而不是应用代码的依赖。有些人喜欢再创建一个名为 test-requirements.txt 的文件。

现在执行 git push 命令，把更新推送到代码分享网站：

$ git push

然后，把改动拉取到服务器上：

elspeth@server:$ git pull # 可能会让你先对git做些配置

若想手动创建虚拟环境（即不使用 virtualenvwrapper），就要使用标准库中的 venv 模块，指定虚拟环境的存放路径：

elspeth@server:$ pwd
/home/espeth/sites/staging.superlists.com/source
elspeth@server:$ python3.6 -m venv ../virtualenv
elspeth@server:$ ls ../virtualenv/bin
activate      activate.fish  easy_install-3.6  pip3    python
activate.csh  easy_install   pip               pip3.6  python3

如果想激活这个虚拟环境，就执行 source ../virtualenv/bin/activate，但是无须这么做。其实，可以直接调用虚拟环境 bin 目录中的可执行文件，运行相应版本的 Python、pip 等。稍后就将这么做。

为了把所需的依赖安装到虚拟环境中，使用虚拟环境中的 pip：

elspeth@server:$ ../virtualenv/bin/pip install -r requirements.txt
Downloading/unpacking Django==1.11 (from -r requirements.txt (line 1))
[...]
Successfully installed Django

为了运行虚拟环境中的 Python，使用虚拟环境中的 python 二进制文件：

elspeth@server:$ ../virtualenv/bin/python manage.py runserver
Validating models...
0 errors found
[...]

　如果防火墙配置得当，你现在甚至可以手动访问网站。你要执行 runserver 0.0.0.0:8000，监听外网和内网 IP 地址，然后访问 http://your.domain.com:8000。

看起来能正常运行。按 Ctrl-C 键停止服务器。

又取得了进展！现在，我们能把代码推送到服务器上（git push），也能从服务器上拉取代码（git pull）。而且我们搭建了一个与本地一致的虚拟环境，还有一个文件（requirements.txt）同步依赖。

接下来将配置 Nginx Web 服务器，让它与 Django 通信，并把网站放到标准的 80 端口上。

9.5.3　简单配置Nginx

下面创建一个 Nginx 配置文件，把过渡网站收到的请求交给 Django 处理。如下是一个极简的配置。

server: /etc/nginx/sites-available/superlists-staging.ottg.eu

server {
    listen 80;
    server_name superlists-staging.ottg.eu;

    location / {
        proxy_pass http://localhost:8000;
    }
}

这个配置只监听过渡网站的域名，而且会把所有请求“代理”到本地 8000 端口，等待 Django 处理请求后得到的响应。

我把这个配置保存为 superlists-staging.ottg.eu 文件，放在 /etc/nginx/sites-available 文件夹里。

　不知道在服务器中如何编辑文件吗？服务器中都有 vi，我建议你之后学习一下如何使用这个工具。此外，也可以使用对初学者相对友好的 nano。注意，还得使用 sudo，因为这个文件在系统文件夹中。

然后创建一个符号链接，把这个文件加入启用的网站列表中：

elspeth@server:$ echo $SITENAME # 检查在这个shell会话中是否还能使用这个变量获取网站名
superlists-staging.ottg.eu
elspeth@server:$ sudo ln -s ../sites-available/$SITENAME /etc/nginx/sitesenabled/$SITENAME
elspeth@server:$ ls -l /etc/nginx/sites-enabled # 确认符号链接是否在那里

在 Debian 和 Ubuntu 中，这是保存 Nginx 配置的推荐做法——把真正的配置文件放在 sites-available 文件夹中，然后在 sites-enabled 文件夹中创建一个符号链接。这么做便于切换网站的在线状态。

或许我们还可以把默认的“Welcome to nginx”页面删除，避免混淆：

elspeth@server:$ sudo rm /etc/nginx/sites-enabled/default

现在测试一下配置：

elspeth@server:$ sudo systemctl reload nginx
elspeth@server:$ ../virtualenv/bin/python manage.py runserver

　我还要编辑 /etc/nginx/nginx.conf 文件，把 server_names_hash_bucket_size 64; 这行的注释去掉，这样才能使用我的长域名。你或许不会遇到这个问题。执行 reload 命令时，如果配置文件有问题，Nginx 会提醒你。

快速进行视觉确认——网站运行起来了（如图 9-3）！

图 9-3：过渡网站运行起来了！

　如果 Nginx 出现异常，执行 sudo nginx -t 命令试试。这个命令的作用是测试配置；如果发现配置文件中有问题，它会提醒你。

我们来看功能测试的结果如何：

$ STAGING_SERVER=superlists-staging.ottg.eu python manage.py test functional_tests
[...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
[...]
AssertionError: 0.0 != 512 within 3 delta

尝试提交新待办事项时测试失败了，因为还没设置数据库。运行测试时你可能注意到了 Django 的黄色报错页（如图 9-4 所示，手动访问网站也能看到），页面中显示的信息和测试失败消息差不多。

图 9-4：数据库还无法使用

　测试避免让我们陷入可能出现的窘境之中。访问网站首页时看起来很正常，如果此时草率地认为工作结束了，那个扰人的 Django 报错页就会被网站的首批用户发现。好吧，这么说可能夸大了影响，说不定我们自己已经发现了，可是如果网站越来越大、越来越复杂怎么办？你不可能确认每项功能，但测试能。

9.5.4　使用迁移创建数据库

执行 migrate 命令时，可以指定 --noinput 参数，禁止两次询问“你确定吗”：

elspeth@server:$ ../virtualenv/bin/python manage.py migrate --noinput
Creating tables ...
[...]
elspeth@server:$ ls ../database/
db.sqlite3
elspeth@server:$ ../virtualenv/bin/python manage.py runserver

再运行功能测试试试：

$ STAGING_SERVER=superlists-staging.ottg.eu python manage.py test functional_tests
[...]

...
 ---------------------------------------------------------------------
Ran 3 tests in 10.718s

OK

看到网站运行起来的感觉太棒了！继续阅读下一节之前，或许我们可以犒赏自己，喝杯茶休息一下——这是你应得的奖励。

　如果看到“502 - Bad Gateway”错误，可能是因为执行 migrate 命令之后忘记使用 manage.py runserver 重启开发服务器。

下述框注中还有一些调试技巧。

服务器调试技巧

部署是个棘手活儿。如果遇到问题，可以使用以下技巧找出原因。

我知道你已经检查过了，不过还是再检查一遍各个文件，看它们的位置和内容是否正确。哪怕只错一个字符，也可能导致重大问题。

查看 Nginx 的错误日志，存储在 /var/log/nginx/error.log 中。

可以使用 -t 标志检查 Nginx 的配置：nginx -t。

确保浏览器没有缓存过期的响应。按下 Ctrl 键的同时点击刷新按钮，或者打开一个新的隐私窗口。

最后，可以使用 sudo reboot 彻底重启试试。遇到无从下手的问题时，我有时就是这样解决的。

如果真的遇到无法解决的问题，还可以推倒重来。第二次肯定顺手得多……

9.6　手动部署大功告成

终于结束了。经过一番努力，我们让网站运行起来了，至少基本上可用了。但是，在生产环境真的不能使用 Django 开发服务器，而且不能靠手动执行 runserver 命令启动服务器。下一章将改进部署过程，让它更适用于生产环境。

测试驱动服务器配置和部署

测试能降低部署过程的不确定性

对开发者来说，管理服务器始终充满“乐趣”，我的意思是，这个过程充满不确定性和意外。我写这一章的目的是告诉你，功能测试组件能降低这个过程的不确定性。

常见的痛点——数据库、静态文件、依赖、自定义设置

数据库配置、静态文件、软件依赖和自定义设置在开发环境和生产环境之间有所不同，部署时要格外留意。自己部署时，一定要审慎处理这些事物。

有测试护航，可以放心试验

改动服务器配置后，可以运行测试组件，确保一切依然正常。有测试的保护，我们可以放心试验，少一分担忧（具体内容参见下一章）。

第 10 章　为部署到生产环境做好准备

本章将做些修改，让我们的网站更适应生产环境的配置。每次修改都将通过测试确认功能是否依然可用。

前面的部署过程有什么问题呢？问题在于，生产环境不能使用 Django 开发服务器，而且没有考虑到“真实的”负载。我们将换用 Gunicorn 运行 Django 代码，并且使用 Nginx 伺服静态文件。

目前的 settings.py 把 DEBUG 设为 True，我极不推荐在生产环境这么做（我们可不想在网站出错时让用户看到供调试的调用跟踪）。安全起见，我们还将设定 ALLOWED_HOSTS。

我们希望网站在服务器重启后自动启动。为此，我们将编写一个 Systemd 配置文件。

最后，把网站绑定到 8000 端口会导致无法在服务器中运行多个网站，因此将换用“unix 套接字”连通 Nginx 和 Django。

10.1　换用Gunicorn

知道为什么 Django 的吉祥物是一匹小马吗？ Django 提供了很多功能，包括 ORM、各种中间件、网站后台等。“除了小马之外你还想要什么呢？”我想，既然你已经有一匹小马了，或许你还想要一头“绿色独角兽”（Green Unicorn），即 Gunicorn。

elspeth@server:$ ../virtualenv/bin/pip install gunicorn

Gunicorn 需要知道 WSGI（Web Server Gateway Interface，Web 服务器网关接口）服务器的路径。这个路径往往可以使用一个名为 application 的函数获取。Django 在文件 superlists/ wsgi.py 中提供了这个函数：

elspeth@server:$ ../virtualenv/bin/gunicorn superlists.wsgi:application
2013-05-27 16:22:01 [10592] [INFO] Starting gunicorn 0.19.6
2013-05-27 16:22:01 [10592] [INFO] Listening at: http://127.0.0.1:8000 (10592)
[...]

如果现在访问网站，会发现所有样式都失效了，如图 10-1 所示。

图 10-1：样式失效

如果运行功能测试，会看到的确出问题了。添加待办事项的测试能顺利通过，但布局和样式的测试失败了。测试做得不错！

$ STAGING_SERVER=superlists-staging.ottg.eu python manage.py test functional_tests
[...]
AssertionError: 125.0 != 512 within 3 delta
FAILED (failures=1)

样式失效的原因是，Django 开发服务器会自动伺服静态文件，但 Gunicorn 不会。现在配置 Nginx，让它代为伺服静态文件。

我们前进了一步，又后退了一步，不过有测试在辅助我们。继续前进！

10.2　让Nginx伺服静态文件

首先，执行 collectstatic 命令，把所有静态文件复制到一个 Nginx 能找到的文件夹中：

elspeth@server:$ ../virtualenv/bin/python manage.py collectstatic --noinput
elspeth@server:$ ls ../static/
base.css bootstrap

下面配置 Nginx，让它伺服静态文件：

server {
    listen 80;
    server_name superlists-staging.ottg.eu;

    location /static {
        alias /home/elspeth/sites/superlists-staging.ottg.eu/static;
    }

    location / {
        proxy_pass http://localhost:8000;
    }
}

然后重启 Nginx 和 Gunicorn：

elspeth@server:$ sudo systemctl reload nginx
elspeth@server:$ ../virtualenv/bin/gunicorn superlists.wsgi:application

如果再次访问网站，会看到外观正常多了。可以再次运行功能测试确认：

$ STAGING_SERVER=superlists-staging.ottg.eu python manage.py test functional_tests
[...]

...
 ---------------------------------------------------------------------
Ran 3 tests in 10.718s

OK

搞定！

10.3　换用Unix套接字

如果想要同时伺服过渡网站和线上网站，这两个网站就不能共用 8000 端口。可以为不同网站分配不同端口，但这么做有点儿随意，而且很容易出错，万一在线上网站的端口上启动过渡服务器（或者反过来）怎么办。

更好的方法是使用 Unix 域套接字。域套接字类似于硬盘中的文件，不过还可以用来处理 Nginx 和 Gunicorn 之间的通信。要把套接字保存在文件夹 /tmp 中。下面修改 Nginx 的代理设置。

server: /etc/nginx/sites-available/superlists-staging.ottg.eu

[...]
    location / {
        proxy_set_header Host $host;
        proxy_pass http://unix:/tmp/superlists-staging.ottg.eu.socket;
    }
}

proxy_set_header 的作用是让 Gunicorn 和 Django 知道它们运行在哪个域名下。ALLOWED_HOSTS 安全功能需要这个设置，稍后会启用这个功能。

现在重启 Gunicorn，不过这一次告诉它监听套接字，而不是默认的端口：

elspeth@server:$ sudo systemctl reload nginx
elspeth@server:$ ../virtualenv/bin/gunicorn --bind \
    unix:/tmp/superlists-staging.ottg.eu.socket superlists.wsgi:application

还要再次运行功能测试，确保所有测试仍能通过：

$ STAGING_SERVER=superlists-staging.ottg.eu python manage.py test functional_tests
[...]
OK

还差几步才能完成部署。

10.4　把DEBUG设为False，设置ALLOWED_HOSTS

在自己的服务器中开启调试模式有利于排查问题，但显示满页的调用跟踪不安全。

在 settings.py 的顶部有 DEBUG 设置项。如果把它设为 False，还需要设置另一个选项，ALLOWED_HOSTS。这个设置在 Django 1.5 中添加，目的是提高安全性。不过，在默认的 settings.py 中没有为这个功能提供有帮助的注释。那就自己添加这个选项吧，在服务器中按照下面的方式修改 settings.py。

server: superlists/settings.py

# 安全警告：别在生产环境中开启调试模式！
DEBUG = False

TEMPLATE_DEBUG = DEBUG

# DEBUG=False时需要这项设置
ALLOWED_HOSTS = ['superlists-staging.ottg.eu']
[...]

然后重启 Gunicorn，再运行功能测试，确保一切正常。

　在服务器中别提交这些改动。现在这只是为了让网站正常运行做的小调整，不是需要纳入仓库的改动。一般来说，简单起见，我只会在本地电脑中把改动提交到 Git 仓库中。如果需要把代码同步到服务器中，再使用 git push 和 git pull。

再次运行测试，确认一切正常：

$ STAGING_SERVER=superlists-staging.ottg.eu python manage.py test functional_tests
[...]
OK

很好。

10.5　使用Systemd确保引导时启动Gunicorn

部署的最后一步是确保服务器引导时自动启动 Gunicorn，如果 Gunicorn 崩溃了还要自动重启。在 Ubuntu 中，可以使用 Systemd 实现这个功能。

server: /etc/systemd/system/gunicorn-superlists-staging.ottg.eu.service

[Unit]
Description=Gunicorn server for superlists-staging.ottg.eu

[Service]
Restart=on-failure  ➊
User=elspeth  ➋
WorkingDirectory=/home/elspeth/sites/superlists-staging.ottg.eu/source  ➌
ExecStart=/home/elspeth/sites/superlists-staging.ottg.eu/virtualenv/bin/gunicorn \
    --bind unix:/tmp/superlists-staging.ottg.eu.socket \
    superlists.wsgi:application  ➍

[Install]
WantedBy=multi-user.target  ❺

Systemd 的配置很简单（如果曾经编写过 init.d 脚本，会觉得更简单），而且一目了然。

❶ Restart=on-failure 指明在崩溃时自动重启进程。

❷ User=elspeth 指明以“elspeth”用户的身份运行进程。

❸ WorkingDirectory 设定当前工作目录。

❹ ExecStart 是要执行的进程。为了提高可读性，我们使用行接续符 \ 把整个命令分成多行，不过也可以写成一行。

❺ [Install] 区中的 WantedBy 告诉 Systemd，我们想在引导时启动这个服务。

Systemd 脚本保存在 /etc/systemd/system 中，而且文件名必须以 .service 结尾。

下面告诉 Systemd，使用 systemctl 命令启动 Gunicorn：

# 必须执行这个命令，让Systemd加载新的配置文件
elspeth@server:$ sudo systemctl daemon-reload
# 这个命令让Systemd在引导时加载服务
elspeth@server:$ sudo systemctl enable gunicorn-superlists-staging.ottg.eu
# 这个命令启动服务
elspeth@server:$ sudo systemctl start gunicorn-superlists-staging.ottg.eu

（顺便说一下，你会发现 systemctl 命令支持按制表符补全，甚至可以补全服务名称。）

现在可以再次运行功能测试，确保一切仍能正常运行。你甚至还可以重启服务器，查看网站能否自动重新运行。

更多调试技巧

执行 sudo journalctl -u gunicorn-superlists-staging.ottg.eu 命令查看 Systemd 的日志。

可以让 Systemd 检查服务配置是否有效：systemd-analyze verify /path/to/my.service。

改动后记得重启相关服务。

修改 Systemd 配置文件后，如果想查看改动的效果，要在执行 systemctl restart 之前执行 daemon-reload 命令。

保存改动：把Gunicorn添加到requirements.txt

回到本地仓库，应该把 Gunicorn 添加到虚拟环境所需的包列表中：

$ pip install gunicorn
$ pip freeze | grep gunicorn >> requirements.txt
$ git commit -am "Add gunicorn to virtualenv requirements"
$ git push

　写作本书时，在 Windows 中使用 pip 能顺利安装 Gunicorn，但 Gunicorn 无法正常使用。幸好我们只在服务器中使用 Gunicorn，因此这不是问题。不过，对 Windows 的支持正在讨论中。

10.6　考虑自动化

总结一下配置和部署的过程。

配置

(1) 假设有用户账户和家目录。

(2) add-apt-repository ppa:fkrull/deadsnakes。

(3) apt-get install nginx git python3.6 pxthon3.6-venv。

(4) 添加 Nginx 虚拟主机配置。

(5) 添加 Upstart 任务，自动启动 Gunicorn。

部署

(1) 在~/sites 中创建目录结构。

(2) 拉取源码，保存到 source 文件夹中。

(3) 启用 ../virtualenv 中的虚拟环境。

(4) pip install -r requirements.txt。

(5) 执行 manage.py migrate，创建数据库。

(6) 执行 collectstatic 命令，收集静态文件。

(7) 在 settings.py 中设置 DEBUG = False 和 ALLOWED_HOSTS。

(8) 重启 Gunicorn。

(9) 运行功能测试，确保一切正常。

假设现在不用完全自动化配置过程，应该怎么保存现阶段取得的结果呢？我说应该把 Nginx 和 Systemd 配置文件保存起来，便于以后重用。下面把这两个配置文件保存到仓库中一个新建的子文件夹中。

保存配置文件的模板

首先，创建这个子文件夹：

$ mkdir deploy_tools

下面是 Nginx 配置的通用模板：

deploy_tools/nginx.template.conf

server {
    listen 80;
    server_name SITENAME;

    location /static {
        alias /home/elspeth/sites/SITENAME/static;
    }
    location / {
        proxy_set_header Host $host;
        proxy_pass http://unix:/tmp/SITENAME.socket;
    }
}

下面是 Gunicorn Systemd 服务的模板：

deploy_tools/gunicorn-systemd.template.service

[Unit]
Description=Gunicorn server for SITENAME

[Service]
Restart=on-failure
User=elspeth
WorkingDirectory=/home/elspeth/sites/SITENAME/source
ExecStart=/home/elspeth/sites/SITENAME/virtualenv/bin/gunicorn \
    --bind unix:/tmp/SITENAME.socket \
    superlists.wsgi:application

[Install]
WantedBy=multi-user.target

再使用这两个文件配置新网站就容易了，查找替换 SITENAME 即可。

其他步骤做些笔记就行。为什么不在仓库中建个文件保存说明呢？

deploy_tools/provisioning_notes.md

配置新网站
=======================

## 需要的包：

* nginx
* Python 3.6
* virtualenv + pip
* Git

以Ubuntu为例：

    sudo add-apt-repository ppa:fkrull/deadsnakes
    sudo apt-get install nginx git python36 python3.6-venv

## Nginx虚拟主机

* 参考nginx.template.conf
* 把SITENAME替换成所需的域名，例如staging.my-domain.com

## Systemd服务

* 参考gunicorn-upstart.template.conf
* 把SITENAME替换成所需的域名，例如staging.my-domain.com

## 文件夹结构：
假设有用户账户，家目录为/home/username

/home/username
└── sites
    └── SITENAME
         ├── database
         ├── source
         ├── static
         └── virtualenv

然后提交上述改动：

$ git add deploy_tools
$ git status # 看到3个新文件
$ git commit -m "Notes and template config files for provisioning"

现在，源码的目录结构如下所示：

.
├── deploy_tools
│   ├── gunicorn-systemd.template.service
│   ├── nginx.template.conf
│   └── provisioning_notes.md
├── functional_tests
│   ├── [...]
├── lists
│   ├── __init__.py
│   ├── models.py
│   ├── [...]
│   ├── static
│   │   ├── base.css
│   │   └── bootstrap
│   │        ├── [...]
│   ├── templates
│   │   ├── base.html
│   │   ├── [...]
│   ├── tests.py
│   ├── urls.py
│   └── views.py
├── manage.py
├── requirements.txt
└── superlists
    ├── [...]

10.7　保存进度

在过渡服务器中运行功能测试，能让我们相信网站确实能正常运行。但大多数情况下，你并不想在真正的服务器中运行功能测试。为了不让我们的劳动付之东流，并且保证生产服务器和过渡服务器一样能正常运行，要让部署的过程可重复执行。

为此，我们需要自动化。这是下一章的话题。

为部署生产服务器做好准备

为生产服务器环境做准备时要考虑以下几点。

不要在生产环境使用 Django 开发服务器

Django 更适合在 Gunicorn 或 uWSGI 等中运行，因为它们支持运行多个职程（worker）。

不要使用 Django 伺服静态文件

没必要用 Python 进程处理伺服静态文件这样的简单任务。可以交给 Nginx 去做，当然其他 Web 服务器也能做到，比如 Apache 或 uWSGI。

检查 settings.py 中只针对开发环境的设置

本章提到了 DEBUG=True 和 ALLOWED_HOSTS，不过可能还有其他设置要注意（让服务器发送电子邮件时还会涉及几个）。

安全性

本书篇幅有限，无法深入讨论服务器安全性。但我要提醒你，若想自己运行服务器，一定要掌握一些安全知识。（有些人选择使用 PaaS 的一个原因就是无须过多担心安全问题。）如果你不知从何着手，可以阅读这篇文章：“My first 5 minutes on a server”。强烈建议你安装 fail2ban，然后查看它的日志文件。你会惊奇地发现，尝试暴力登录 SSH 的活动多得不得了。互联网可不是什么安全之所！

第 11 章　使用Fabric自动部署

“自动化，自动化，自动化。”

——Cay Horstman

手动部署过渡服务器的意义通过自动部署才能体现出来。部署的过程能重复执行，我们才能确信部署到生产环境时不会出错。

使用 Fabric 可以在服务器中自动执行命令。fabric 3 是针对 Python 3 的派生版本：

$ pip install fabric3

　安装 fabric3 的过程中，只要最后提示“Successfully installed...”，就可以放心忽略“failed building wheel”错误。

Fabric 的使用方法一般是创建一个名为 fabfile.py 的文件，在这个文件中定义一个或多个函数，然后使用命令行工具 fab 调用，就像这样：

fab function_name:host=SERVER_ADDRESS

这个命令会调用名为 function_name 的函数，并传入要连接的服务器地址 SERVER_ADDRESS。fab 命令还有很多其他参数，可以指定用户名和密码等，详情可执行 fab --help 命令查阅。

11.1　分析一个Fabric部署脚本

Fabric 的用法最好通过一个实例讲解。我事先写好了一个脚本 1，自动执行前一章用到的所有部署步骤。在这个脚本中，主函数是 deploy，我们在命令行中要调用的就是这个函数。deploy 函数还会调用几个辅助函数，我们会在过程中逐一讲解。

1BBC 的儿童节目《蓝彼得》中经常说这句话，因为这是个直播节目，为了节省时间，往往要事先做好所需的道具。这句话的原文是“Here's one I made earlier”。——译者注

deploy_tools/fabfile.py (ch09l001)

from fabric.contrib.files import append, exists, sed
from fabric.api import env, local, run
import random

REPO_URL = 'https://github.com/hjwp/book-example.git'  ➊

def deploy():
    site_folder = f'/home/{env.user}/sites/{env.host}'  ➋➌
    source_folder = site_folder + '/source'
    _create_directory_structure_if_necessary(site_folder)
    _get_latest_source(source_folder)
    _update_settings(source_folder, env.host)  ➋
    _update_virtualenv(source_folder)
    _update_static_files(source_folder)
    _update_database(source_folder)

❶ 要把常量 REPO_URL 的值改成代码分享网站中你仓库的 URL。

❷ env.host 的值是在命令行中指定的服务器地址，例如 superlists.ottg.eu。

❸ env.user 的值是登录服务器时使用的用户名。

希望辅助函数的名字能表明各自的作用。理论上 fabfile.py 中的每个函数都能在命令行中调用，所以我使用了一种约定，凡是以下划线开头的函数都不是 fabfile.py 的“公开 API”。这些辅助函数按照执行的顺序排列。

11.1.1　分析一个Fabric部署脚本

创建目录结构的方法如下，即便某个文件夹已经存在也不会报错：

deploy_tools/fabfile.py (ch09l002)

def _create_directory_structure_if_necessary(site_folder):
    for subfolder in ('database', 'static', 'virtualenv', 'source'):
        run(f'mkdir -p {site_folder}/{subfolder}')  ➊➋

➊ run 是最常用的 Fabric 函数，作用是在服务器中执行指定的 shell 命令。本章将用 run 命令替代前两章手动执行的多个命令。

➋ mkdir -p 是 mkdir 的一个有用变种，它有两个优势：其一，深入多个文件夹层级创建目录；其二，只在必要时创建目录。所以，mkdir -p /tmp/foo/bar 除了创建目录 bar 之外，如果需要，还会创建父级目录 foo。而且，如果目录 bar 已经存在，也不会报错。2

2如果你想知道构建路径为什么使用 f 字符串而不用前面用过的 os.path.join，我告诉你，因为在 Windows 中运行这个脚本，path.join 会使用反斜线，但在服务器中却要使用斜线。这是一个常见陷阱。

11.1.2　使用Git拉取源码

接下来，我们想像前面那样使用 git pull 把最新的源码下载到服务器中：

deploy_tools/fabfile.py (ch09l003)

def _get_latest_source(source_folder):
    if exists(source_folder + '/.git'):  ➊
        run(f'cd {source_folder} && git fetch')  ➋➌
    else:
        run(f'git clone {REPO_URL} {source_folder}')  ➍
    current_commit = local("git log -n 1 --format=%H", capture=True)  ❺
    run(f'cd {source_folder} && git reset --hard {current_commit}')  ❻

❶ exists 检查服务器中是否有指定的文件夹或文件。我们指定的是隐藏文件夹 .git，检查仓库是否已经克隆到文件夹中。

❷ 很多命令都以 cd 开头，其目的是设定当前工作目录。Fabric 没有状态记忆，所以下次运行 run 命令时不知道在哪个目录中。3

3Fabric 本身也提供了 cd 函数，但我觉得本章要多次用到这个函数，太啰唆。

❸ 在现有仓库中执行 git fetch 命令的作用是从网络中拉取最新提交（与 git pull 类似，但是不会立即更新线上源码）。

❹ 如果仓库不存在，就执行 git clone 命令克隆一份全新的源码。

❺ Fabric 中的 local 函数在本地电脑中执行命令，这个函数其实是对 subprocess.Popen 的再包装，不过用起来十分方便。我们捕获 git log 命令的输出，获取本地仓库中当前提交的 ID。这么做的结果是，服务器中代码将和本地检出的代码版本一致（前提是已经把代码推送到服务器）。

❻ 执行 git reset --hard 命令，切换到指定的提交。这个命令会撤销在服务器中对代码仓库所做的任何改动。

这个函数的最终结果是，全新部署时执行 git clone，已有代码时执行 git fetch 和 git reset --hard。手动部署时，我们执行的是等效的 git pull，但是使用 reset --hard 能强制覆盖本地改动。

　为了让这个脚本可用，你要执行 git push 命令把本地仓库推送到代码分享网站，这样服务器才能拉取仓库，再执行 git reset 命令。如果你遇到 Could not parse object 错误，可以执行 git push 命令试试。

11.1.3　更新settings.py

然后更新配置文件，设置 ALLOWED_HOSTS 和 DEBUG，还要创建一个密钥：

deploy_tools/fabfile.py (ch09l004)

def _update_settings(source_folder, site_name):
    settings_path = source_folder + '/superlists/settings.py'
    sed(settings_path, "DEBUG = True", "DEBUG = False")  ➊
    sed(settings_path,
        'ALLOWED_HOSTS =.+$',
        f'ALLOWED_HOSTS = ["{site_name}"]'  ➋
    )
    secret_key_file = source_folder + '/superlists/secret_key.py'
    if not exists(secret_key_file):  ➌
        chars = 'abcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*(-_=+)'
        key = ''.join(random.SystemRandom().choice(chars) for _ in range(50))
        append(secret_key_file, f'SECRET_KEY = "{key}"')
    append(settings_path, '\nfrom .secret_key import SECRET_KEY')  ➍❺

❶ Fabric 提供的 sed 函数的作用是在文件中替换字符串。这里我们把 DEBUG 的值由 True 改成 False。

❷ 这里使用 sed 调整 ALLOWED_HOSTS 的值，使用正则表达式匹配正确的代码行。

❸ Django 有几处加密操作要使用 SECRET_KEY：cookie 和 CSRF 保护。在服务器中和源码仓库中使用不同的密钥是个好习惯，因为仓库中的代码能被所有人看到。如果还没有密钥，这段代码会生成一个新密钥，然后写入密钥文件。有密钥后，每次部署都要使用相同的密钥。更多信息参见 Django 文档。

❹ append 的作用是在文件末尾添加一行内容。（这个函数很聪明，如果要添加的行已经存在，就不会再次添加；但如果文件末尾不是一个空行，它却不能自动添加一个空行。因此我们加上了 \n。）

❺ 我使用的是相对导入（relative import，使用 from .secret_key 而不是 from secret_key），目的是确保从本地而不是从 sys.path 中其他位置的模块中导入。下一章我会更深入地介绍相对导入。

　以上是修改服务器配置文件的一种方法，另一种常用的方法是使用环境变量（详情请参见第 21 章）。你可以根据个人喜好选择。

11.1.4　更新虚拟环境

接下来创建或更新虚拟环境：

deploy_tools/fabfile.py (ch09l005)

def _update_virtualenv(source_folder):
    virtualenv_folder = source_folder + '/../virtualenv'
    if not exists(virtualenv_folder + '/bin/pip'):  ➊
        run(f'python3.6 -m venv {virtualenv_folder}')
    run(f'{virtualenv_folder}/bin/pip install -r {source_folder}/requirements.txt')  ➋

❶ 在 virtualenv 文件夹中查找可执行文件 pip，以此检查虚拟环境是否存在。

❷ 然后和之前一样，执行 pip install -r 命令。

更新静态文件只需要一个命令：

deploy_tools/fabfile.py (ch09l006)

def _update_static_files(source_folder):
    run(
        f'cd {source_folder}'  ➊
        ' && ../virtualenv/bin/python manage.py collectstatic --noinput'  ➋
    )

❶ 在 Python 中，可以像这样把一行长字符串分为多行，最终拼接为一个字符串。如果真正想要的是字符串列表，但是忘了逗号，就经常会出问题。

❷ 如果需要执行 Django 的 manage.py 命令，就要指定虚拟环境中二进制文件夹，确保使用的是虚拟环境中的 Django 版本，而不是系统中的版本。

11.1.5　需要时迁移数据库

最后，执行 manage.py migrate 命令更新数据库：

deploy_tools/fabfile.py (ch09l007)

def _update_database(source_folder):
    run(
        f'cd {source_folder}'
        ' && ../virtualenv/bin/python manage.py migrate --noinput'
    )

指定 --noinput 选项的目的是不让 Fabric 难以处理的交互式确认（回答“yes”或“no”）出现。

到此结束！虽然要理解很多新东西，但这是值得的，因为我们把整个手动过程变成自动过程了。而且通过一些逻辑处理，我们既能部署全新的服务器，也能更新现有的服务器。如果你喜欢源自拉丁语的词，可以称这个过程是幂等的（idempotent4），即不管执行一次还是执行多次，效果是一样的。

4“幂等”的英文 idempotent，拉丁语词根是 idem。——译者注

11.2　试用部署脚本

下面在现有的过渡网站中试一下这个脚本，看它是如何更新现有网站的：

$ cd deploy_tools
$ fab deploy:host=elspeth@superlists-staging.ottg.eu

[superlists-staging.ottg.eu] Executing task 'deploy'
[superlists-staging.ottg.eu] run: mkdir -p /home/elspeth/sites/superlists-stagin
[superlists-staging.ottg.eu] run: mkdir -p /home/elspeth/sites/superlists-stagin
[superlists-staging.ottg.eu] run: mkdir -p /home/elspeth/sites/superlists-stagin
[superlists-staging.ottg.eu] run: mkdir -p /home/elspeth/sites/superlists-stagin
[superlists-staging.ottg.eu] run: mkdir -p /home/elspeth/sites/superlists-stagin
[superlists-staging.ottg.eu] run: cd /home/elspeth/sites/superlists-staging.ottg
[localhost] local: git log -n 1 --format=%H
[superlists-staging.ottg.eu] run: cd /home/elspeth/sites/superlists-staging.ottg
[superlists-staging.ottg.eu] out: HEAD is now at 85a6c87 Add a fabfile for autom
[superlists-staging.ottg.eu] out:

[superlists-staging.ottg.eu] run: sed -i.bak -r -e 's/DEBUG = True/DEBUG = False
[superlists-staging.ottg.eu] run: echo 'ALLOWED_HOSTS = ["superlists-staging.ott
[superlists-staging.ottg.eu] run: echo 'SECRET_KEY = '\\''4p2u8fi6)bltep(6nd_3tt
[superlists-staging.ottg.eu] run: echo 'from .secret_key import SECRET_KEY' >> "

[superlists-staging.ottg.eu] run: /home/elspeth/sites/superlists-staging.ottg.eu
[superlists-staging.ottg.eu] out: Requirement already satisfied (use --upgrade t
[superlists-staging.ottg.eu] out: Requirement already satisfied (use --upgrade t
[superlists-staging.ottg.eu] out: Cleaning up...
[superlists-staging.ottg.eu] out:

[superlists-staging.ottg.eu] run: cd /home/elspeth/sites/superlists-staging.ottg
[superlists-staging.ottg.eu] out:
[superlists-staging.ottg.eu] out: 0 static files copied, 11 unmodified.
[superlists-staging.ottg.eu] out:

[superlists-staging.ottg.eu] run: cd /home/elspeth/sites/superlists-staging.ottg
[superlists-staging.ottg.eu] out: Creating tables ...
[superlists-staging.ottg.eu] out: Installing custom SQL ...
[superlists-staging.ottg.eu] out: Installing indexes ...
[superlists-staging.ottg.eu] out: Installed 0 object(s) from 0 fixture(s)
[superlists-staging.ottg.eu] out:
Done.
Disconnecting from superlists-staging.ottg.eu... done.

太棒了。我喜欢让电脑成页地显示这种输出（事实上，我完全无法阻止自己制造 20 世纪 70 年代的电脑发出的“啵呃 - 啵呃 - 啵呃”声音，就像《异形》中的电脑 Mother 一样 5）。如果仔细看这些输出，会发现脚本在执行我们的命令：虽然目录结构已经建好，但 mkdir -p 命令还是能顺利执行；然后执行 git pull 命令，拉取我们刚刚提交的几次改动；sed 和 echo >> 修改 settings.py 文件；然后顺利执行完 pip install -r requirements.txt 命令，注意，现有的虚拟环境中已经安装了全部所需的包；collectstatic 命令发现静态文件也收集好了；最后，执行 migrate 命令。这个过程完全没障碍。

5Mother 是电影《异形》系列中诺史莫号的中央控制系统。——译者注

配置 Fabric

如果使用 SSH 密钥登录，密钥存储在默认的位置，而且本地电脑和服务器使用相同的用户名，那么无须配置即可直接使用 Fabric。如果不满足这几个条件，就要配置用户名、SSH 密钥的位置或密码等，才能让 fab 执行命令。

这几个信息可在命令行中传给 Fabric。更多信息可执行 $ fab --help 命令查看，或者阅读 Fabric 的文档。

11.2.1　部署到线上服务器

下面在线上服务器中试试这个脚本：

$ fab deploy:host=elspeth@superlists.ottg.eu
$ fab deploy --host=superlists.ottg.eu

[superlists.ottg.eu] Executing task 'deploy'
[superlists.ottg.eu] run: mkdir -p /home/elspeth/sites/superlists.ottg.eu
[superlists.ottg.eu] run: mkdir -p /home/elspeth/sites/superlists.ottg.eu/databa
[superlists.ottg.eu] run: mkdir -p /home/elspeth/sites/superlists.ottg.eu/static
[superlists.ottg.eu] run: mkdir -p /home/elspeth/sites/superlists.ottg.eu/virtua
[superlists.ottg.eu] run: mkdir -p /home/elspeth/sites/superlists.ottg.eu/source
[superlists.ottg.eu] run: git clone https://github.com/hjwp/book-example.git /ho
[superlists.ottg.eu] out: Cloning into '/home/elspeth/sites/superlists.ottg.eu/s
[superlists.ottg.eu] out: remote: Counting objects: 3128, done.
[superlists.ottg.eu] out: Receiving objects:   0% (1/3128)
[...]
[superlists.ottg.eu] out: Receiving objects: 100% (3128/3128), 2.60 MiB | 829 Ki
[superlists.ottg.eu] out: Resolving deltas: 100% (1545/1545), done.
[superlists.ottg.eu] out:

[localhost] local: git log -n 1 --format=%H
[superlists.ottg.eu] run: cd /home/elspeth/sites/superlists.ottg.eu/source && gi
[superlists.ottg.eu] out: HEAD is now at 6c8615b use a secret key file
[superlists.ottg.eu] out:
[superlists.ottg.eu] run: sed -i.bak -r -e 's/DEBUG = True/DEBUG = False/g' "$(e
[superlists.ottg.eu] run: echo 'ALLOWED_HOSTS = ["superlists.ottg.eu"]' >> "$(ec
[superlists.ottg.eu] run: echo 'SECRET_KEY = '\\''mqu(ffwid5vleol%ke^jil*x1mkj-4
[superlists.ottg.eu] run: echo 'from .secret_key import SECRET_KEY' >> "$(echo /
[superlists.ottg.eu] run: python3.6 -m venv /home/elspeth/sites/superl
[superlists.ottg.eu] out: Using interpreter /usr/bin/python3.6
[superlists.ottg.eu] out: Using base prefix '/usr'
[superlists.ottg.eu] out: New python executable in /home/elspeth/sites/superlist
[superlists.ottg.eu] out: Also creating executable in /home/elspeth/sites/superl
[superlists.ottg.eu] out: Installing Setuptools............................done.

[superlists.ottg.eu] out: Installing Pip...................................done.
[superlists.ottg.eu] out:

[superlists.ottg.eu] run: /home/elspeth/sites/superlists.ottg.eu/source/../virtu
[superlists.ottg.eu] out: Downloading/unpacking Django==1.11 (from -r /home/el
[superlists.ottg.eu] out:   Downloading Django-1.11.tar.gz (8.0MB):
[...]
[superlists.ottg.eu] out:   Downloading Django-1.11.tar.gz (8.0MB): 100% 8.0M
[superlists.ottg.eu] out:   Running setup.py egg_info for package Django
[superlists.ottg.eu] out:
[superlists.ottg.eu] out:     warning: no previously-included files matching '__
[superlists.ottg.eu] out:     warning: no previously-included files matching '*.
[superlists.ottg.eu] out:     Downloading/unpacking gunicorn==17.5 (from -r /home/el
[superlists.ottg.eu] out:   Downloading gunicorn-17.5.tar.gz (367kB): 100%  367k
[...]

[superlists.ottg.eu] out:   Downloading gunicorn-17.5.tar.gz (367kB): 367kB down
[superlists.ottg.eu] out:   Running setup.py egg_info for package gunicorn
[superlists.ottg.eu] out:
[superlists.ottg.eu] out: Installing collected packages: Django, gunicorn
[superlists.ottg.eu] out:   Running setup.py install for Django
[superlists.ottg.eu] out:     changing mode of build/scripts-3.3/django-admin.py
[superlists.ottg.eu] out:
[superlists.ottg.eu] out:     warning: no previously-included files matching '__
[superlists.ottg.eu] out:     warning: no previously-included files matching '*.
[superlists.ottg.eu] out:     changing mode of /home/elspeth/sites/superlists.ot
[superlists.ottg.eu] out:   Running setup.py install for gunicorn
[superlists.ottg.eu] out:
[superlists.ottg.eu] out:     Installing gunicorn_paster script to /home/elspeth
[superlists.ottg.eu] out:     Installing gunicorn script to /home/elspeth/sites/
[superlists.ottg.eu] out:     Installing gunicorn_django script to /home/elspeth
[superlists.ottg.eu] out: Successfully installed Django gunicorn
[superlists.ottg.eu] out: Cleaning up...
[superlists.ottg.eu] out:

[superlists.ottg.eu] run: cd /home/elspeth/sites/superlists.ottg.eu/source && ..
[superlists.ottg.eu] out: Copying '/home/elspeth/sites/superlists.ottg.eu/source
[superlists.ottg.eu] out: Copying '/home/elspeth/sites/superlists.ottg.eu/source
[...]
[superlists.ottg.eu] out: Copying '/home/elspeth/sites/superlists.ottg.eu/source
[superlists.ottg.eu] out:
[superlists.ottg.eu] out: 11 static files copied.
[superlists.ottg.eu] out:
[superlists.ottg.eu] run: cd /home/elspeth/sites/superlists.ottg.eu/source && ..
[superlists.ottg.eu] out: Creating tables ...
[superlists.ottg.eu] out: Creating table auth_permission
[...]
[superlists.ottg.eu] out: Creating table lists_item
[superlists.ottg.eu] out: Installing custom SQL ...
[superlists.ottg.eu] out: Installing indexes ...
[superlists.ottg.eu] out: Installed 0 object(s) from 0 fixture(s)
[superlists.ottg.eu] out:
Done.
Disconnecting from superlists.ottg.eu... done.

啵呃 - 啵呃 - 啵呃。可以看出，这个脚本执行的路径有点不同。这一次，执行 git clone 命令克隆一个全新的仓库，而没有执行 git pull；而且从零开始创建了一个新的虚拟环境，还安装了 pip 和 Django；collectstatic 命令这次真的创建了很多新文件；migrate 看起来也完成了任务。

11.2.2　使用sed配置Nginx和Gunicorn

把网站放到生产环境之前还要做什么呢？根据配置笔记，还要使用模板文件创建 Nginx 虚拟主机和 Systemd 服务。使用 Unix 命令行工具完成这一操作怎么样？

elspeth@server:$ sed "s/SITENAME/superlists.ottg.eu/g" \
    source/deploy_tools/nginx.template.conf \
    | sudo tee /etc/nginx/sites-available/superlists.ottg.eu

sed（stream editor，流编辑器）的作用是编辑文本流。Fabric 中进行文本替换的函数也叫 sed，这并不是巧合。这里，使用 s/replaceme/withthis/g 句法把字符串 SITENAME 替换成网站的地址。6 然后使用管道操作（|）把文本流传给一个有 root 权限的用户处理（sudo），把传入的文本流写入一个文件，即 sites-available 文件夹中的一个虚拟主机配置文件。

6你可能在网上见过电脑达人使用奇怪的 s/change-this/to-this 的写法，这下你知道它的作用了吧。

然后使用一个符号链激活这个文件配置的虚拟主机：

elspeth@server:$ sudo ln -s ../sites-available/superlists.ottg.eu \
    /etc/nginx/sites-enabled/superlists.ottg.eu

再使用 sed 编写 Systemd 服务：

elspeth@server: sed "s/SITENAME/superlists.ottg.eu/g" \
    source/deploy_tools/gunicorn-systemd.template.service \
    | sudo tee /etc/systemd/system/gunicorn-superlists.ottg.eu.service

最后，启动这两个服务：

elspeth@server:$ sudo systemctl daemon-reload
elspeth@server:$ sudo systemctl reload nginx
elspeth@server:$ sudo systemctl enable gunicorn-superlists.ottg.eu
elspeth@server:$ sudo systemctl start gunicorn-superlists.ottg.eu

现在访问网站，见图 11-1。运行起来了，太棒了！

图 11-1：啵呃 - 啵呃 - 啵呃……网站运行起来了

做得不错。可靠的 fabfile，赏你一块饼干。现在可以把它添加到仓库中了：

$ git add deploy_tools/fabfile.py
$ git commit -m "Add a fabfile for automated deploys"

11.3　使用Git标签标注发布状态

最后还要做些管理操作。为了保留历史标记，使用 Git 标签（tag）标注代码库的状态，指明服务器中当前使用的是哪个版本：

$ git tag LIVE
$ export TAG=$(date +DEPLOYED-%F/%H%M) # 生成一个时间戳
$ echo $TAG # 会显示“DEPLOYED-”和时间戳
$ git tag $TAG
$ git push origin LIVE $TAG # 推送标签

现在，无论何时都能轻松查看当前代码库和服务器中的版本有何差异。这个操作在后面介绍数据库迁移的章节中也会用到。看一下提交历史中的标签：

$ git log --graph --oneline --decorate

[...]

总之，现在我们部署了一个线上网站。告诉你的朋友吧！如果他们不感兴趣，就告诉自己的妈妈！下一章我们要继续编程。

11.4　延伸阅读

部署没有唯一正确的方法，而且我无论如何也算不上资深专家。我试着把你领进门，但有很多事情可以使用不同的方法处理，还有很多很多的知识要学习。下面我列出了一些阅读材料，仅供参考。

Hynek Schlawack 的文章“Solid Python Deployments for Everybody”。

Dan Bravender 的文章“Git-based fabric deploys are awesome”。

Dan Greenfield 和 Audrey Roy 合著的 Two Scoops of Django 中与部署相关的章节。

Heroku 团队写的“The 12-factor App”。

如果想知道如何自动完成配置过程，以及如何使用 Fabric 的替代品 Ansible，请阅读附录 C。

自动部署

Fabric

Fabric 允许在 Python 脚本中编写可在服务器中执行的命令。这个工具很适合自动执行服务器管理任务。

幂等

如果部署脚本要在已经配置的服务器中运行，就要把它设计成既可在全新的服务器中运行，又能在已经配置的服务器中运行。

把配置文件纳入版本控制

一定不能只在服务器中保存一份配置文件副本。配置文件对应用非常重要，应该和其他文件一样纳入版本控制。

自动配置

最终，所有操作都要实现自动化，包括配置全新的服务器和安装所需的全部正确软件。配置的过程中会和主机供应商的 API 交互。

配置管理工具

Fabric 很灵活，但其逻辑还是基于脚本的。高级工具使用声明式的方法，用起来更方便。Ansible 和 Vagrant 都值得一试（参见附录 C），此外还有很多同类工具，例如 Chef、Puppet、Salt 和 Juju 等。

第 12 章　输入验证和测试的组织方式

接下来要实现的功能是输入验证。测试越写越多，慢慢地你会发现，把所有测试都写在 functional_tests.py 和 tests.py 中有诸多不便，因此我们将重新组织测试，将它们写入多个文件——这算得上是对测试本身的重构。

此外，我们还将定义一个通用的显式等待辅助方法。

12.1　针对验证的功能测试：避免提交空待办事项

我们的网站开始有用户了。我们注意到用户有时会犯错，把他们的清单弄得一团糟，例如不小心提交空的待办事项，或者在一个清单中输入两个相同的待办事项。计算机能帮助我们避免犯这种愚蠢的错误，看一下能否让网站提供这种帮助。

下面是一个功能测试的大纲：

functional_tests/tests.py (ch11l001)

def test_cannot_add_empty_list_items(self):
    # 伊迪丝访问首页，不小心提交了一个空待办事项
    # 输入框中没输入内容，她就按下了回车键

    # 首页刷新了，显示一个错误消息
    # 提示待办事项不能为空

    # 她输入一些文字，然后再次提交，这次没问题了

    # 她有点儿调皮，又提交了一个空待办事项

    # 在清单页面她看到了一个类似的错误消息

    # 输入文字之后就没问题了
    self.fail('write me!')

测试写得很好，但功能测试文件变得有点儿臃肿，在继续之前，要把功能测试分成多个文件，每个文件中只放一个测试方法。

还记得吗？功能测试和“用户故事”联系紧密。如果使用问题跟踪程序等项目管理工具，你可能想让每个文件对应一个问题或工单（ticket），而且文件名中要包含工单的编号。如果你喜欢使用“功能”的概念考虑问题（一个功能可能包含多个用户故事），可以用一个文件对应一个功能，一个文件中只写一个测试类，每个用户故事使用多个测试方法实现。

还要编写一个测试基类，让所有测试类都继承这个基类。下面分步介绍分拆过程。

12.1.1　跳过测试

重构时最好能让整个测试组件都通过。刚才我们故意编写了一个失败测试，现在要使用 unittest 提供的修饰器 @skip 临时禁止执行这个测试方法：

functional_tests/tests.py (ch11l001-1)

from unittest import skip
[...]

    @skip
    def test_cannot_add_empty_list_items(self):

这个修饰器告诉测试运行程序，忽略这个测试。再次运行功能测试就会看到这么做起作用了，因为测试组件仍能通过：

$ python manage.py test functional_tests
[...]
Ran 4 tests in 11.577s
OK

　跳过测试很危险，把改动提交到仓库之前记得删掉 @skip 修饰器。这就是逐行审查差异的目的。

别忘了“遇红 / 变绿 / 重构”中的“重构”

TDD 有时会被批评说得到的代码架构不好，因为开发者关注的是怎么让测试通过，没有停下来思考整个系统应该怎么设计。我觉得这么说有点不公平。

TDD 不是万能良药。你仍要花时间考虑好的设计。不过开发者经常会忘记“遇红 / 变绿 / 重构”中还有“重构”这一步。使用TDD，为了让测试通过，可以随意丢掉旧代码，但它也要求你在测试通过之后花点儿时间重构，改进设计。否则“技术债务”将高高筑起。

不过，重构的最佳方法往往不那么容易想到，可能等到写下代码之后的几天、几周甚至几个月，处理完全无关的事情时，突然灵光一闪才能想出来。在解决其他问题的途中，应该停下来去重构以前的代码吗？

这要视情况而定。比如像本章开始这种情况，我们还没有开始编写新代码，知道一切都能正常运行，所以可以跳过刚编写的功能测试（让测试全部通过），先重构。

在本章后面的内容中还会遇到需要重构的代码。届时，我们不能冒险在无法正常运行的应用中重构，可以在便签上做个记录，等测试组件能全部通过之后再重构。

12.1.2　把功能测试分拆到多个文件中

先把各个测试方法放在单独的类中，但仍然保存在同一个文件里：

functional_tests/tests.py (ch11l002)

class FunctionalTest(StaticLiveServerTestCase):

    def setUp(self):
        [...]
    def tearDown(self):
        [...]
    def wait_for_row_in_list_table(self, row_text):
        [...]

class NewVisitorTest(FunctionalTest):

    def test_can_start_a_list_for_one_user(self):
        [...]
    def test_multiple_users_can_start_lists_at_different_urls(self):
        [...]

class LayoutAndStylingTest(FunctionalTest):

    def test_layout_and_styling(self):
        [...]

class ItemValidationTest(FunctionalTest):

    @skip
    def test_cannot_add_empty_list_items(self):
        [...]

然后运行功能测试，看是否仍能通过：

Ran 4 tests in 11.577s

OK

这么做可能有点劳动量，或许能找到一种步骤更少的方法。但是，正如我一直所说的，针对简单的情况练习步步为营的方法，以后遇到复杂的情况就能游刃有余。

现在分拆这个测试文件，一个类写入一个文件，而且还有一个文件用来保存所有测试类都继承的基类。要复制四份 test.py，重命名各个文件，然后删除各文件中不需要的代码：

$ git mv functional_tests/tests.py functional_tests/base.py
$ cp functional_tests/base.py functional_tests/test_simple_list_creation.py
$ cp functional_tests/base.py functional_tests/test_layout_and_styling.py
$ cp functional_tests/base.py functional_tests/test_list_item_validation.py

base.py 只需保留 FunctionalTest 类，其他代码全部删掉。留下基类中的辅助方法，因为觉得在新的功能测试中会用到：

functional_tests/base.py (ch11l003)

from django.contrib.staticfiles.testing import StaticLiveServerTestCase
from selenium import webdriver
from selenium.common.exceptions import WebDriverException
import time

MAX_WAIT = 10

class FunctionalTest(StaticLiveServerTestCase):

    def setUp(self):
        [...]
    def tearDown(self):
        [...]
    def wait_for_row_in_list_table(self, row_text):
        [...]

　把辅助方法放在 FunctionalTest 基类中是避免功能测试代码重复的方法之一。第 25 章会用到“页面模式”（page pattern），与这种方法有关，但不用继承，用组合模式。

我们编写的第一个功能测试现在放在单独的文件中了，而且这个文件中只有一个类和一个测试方法：

functional_tests/test_simple_list_creation.py (ch11l004)

from .base import FunctionalTest
from selenium import webdriver
from selenium.webdriver.common.keys import Keys

class NewVisitorTest(FunctionalTest):

    def test_can_start_a_list_for_one_user(self):
        [...]
    def test_multiple_users_can_start_lists_at_different_urls(self):
        [...]

我用到了相对导入（from .base），有些人喜欢在 Django 应用中大量使用这种导入方式（例如，视图可能会使用 from .models import List 导入模型，而不用 from list.models）。这其实是个人喜好问题，只有十分确定要导入的文件位置不会变化时，我才会选择使用相对导入。这里使用相对导入是因为，我确定所有测试文件都会和它们要继承的 base.py 放在一起。

针对布局和样式的功能测试现在也放在独立的文件和类中：

functional_tests/test_layout_and_styling.py (ch11l005)

from selenium.webdriver.common.keys import Keys
from .base import FunctionalTest

class LayoutAndStylingTest(FunctionalTest):
        [...]

刚编写的验证测试也放在单独的文件中了：

functional_tests/test_list_item_validation.py (ch11l006)

from selenium.webdriver.common.keys import Keys
from unittest import skip
from .base import FunctionalTest

class ItemValidationTest(FunctionalTest):

    @skip
    def test_cannot_add_empty_list_items(self):
        [...]

可以再次执行 manage.py test functional_tests 命令，确保一切都正常，还要确认所有三个测试都运行了：

Ran 4 tests in 11.577s

OK

现在可以删掉 @skip 修饰器了：

functional_tests/test_list_item_validation.py (ch11l007)

class ItemValidationTest(FunctionalTest):

    def test_cannot_add_empty_list_items(self):
        [...]

12.1.3　运行单个测试文件

拆分之后有个附带的好处——可以运行单个测试文件，如下所示：

$ python manage.py test functional_tests.test_list_item_validation
[...]
AssertionError: write me!

太好了，如果只关心其中一个测试，现在无须坐等所有功能测试都运行完就能看到结果了。不过，记得要不时运行所有功能测试，检查是否有回归。本书后面的内容会介绍如何把这项任务交给自动化持续集成循环完成。现在，先提交：

$ git status
$ git add functional_tests
$ git commit -m "Moved Fts into their own individual files"

很好，我们把功能测试拆分到不同的文件中了。接下来，我们将着手编写功能测试。但在此之前，你可能猜到了，我们还需拆分单元测试文件。

12.2　功能测试新工具：通用显式等待辅助方法

现在开始实现本章开头编写的测试，至少先把前面的部分写好：

functional_tests/test_list_item_validation.py (ch11l008)

def test_cannot_add_empty_list_items(self):
    # 伊迪丝访问首页，不小心提交了一个空待办事项
    # 输入框中没输入内容，她就按下了回车键
    self.browser.get(self.live_server_url)
    self.browser.find_element_by_id('id_new_item').send_keys(Keys.ENTER)

    # 首页刷新了，显示一个错误消息
    # 提示待办事项不能为空
    self.assertEqual(
        self.browser.find_element_by_css_selector('.has-error').text,  ➊
        "You can't have an empty list item"  ➋
    )

    # 她输入一些文字，然后再次提交，这次没问题了
    self.fail('finish this test!')
    [...]

这可想得太天真了。

❶ 通过 CSS 类 .has-error 查找错误文本。Bootstrap 为错误文本提供了很多有用的样式。

❷ 确认错误文本中有我们想显示的消息。

不过，你能看出这个测试有什么潜在问题吗？

好吧，本节的标题已经给出提示：如果页面刷新，就要显式等待；否则，Selenium 可能会在页面加载之前查找 .has-error 元素。

　只要通过 Keys.ENTER 或点击按钮提交表单后页面要刷新，在下一个断言之前可能就需要显式等待。

首次需要显式等待时，我们定义了一个辅助方法。但对这个测试来说，你可能觉得用辅助方法有点小题大做。不过，在测试中能使用通用的方式表达“等到断言通过”也不错，比如这样：

functional_tests/test_list_item_validation.py (ch11l009)

[...]
    # 首页刷新了，显示了一条错误消息
    # 提示待办事项不能为空
    self.wait_for(lambda: self.assertEqual(  ➊
        self.browser.find_element_by_css_selector('.has-error').text,
        "You can't have an empty list item"
    ))

➊ 不再直接调用断言，而是把断言包装到一个 lambda 函数中，然后再把它传给一个打算命名为 wait_for 的辅助方法。

　如果你从未在 Python 中见过 lambda 函数，请阅读后文“lambda 函数”。

那应该如何实现这个 wait_for 方法呢？打开 base.py，复制现有的 wait_for_row_in_list_table 方法，稍微修改一下：

functional_tests/base.py (ch11l010)

    def wait_for(self, fn):  ➊
        start_time = time.time()
        while True:
            try:
                table = self.browser.find_element_by_id('id_list_table')  ➋
                rows = table.find_elements_by_tag_name('tr')
                self.assertIn(row_text, [row.text for row in rows])
                return
            except (AssertionError, WebDriverException) as e:
                if time.time() - start_time > MAX_WAIT:
                    raise e
                time.sleep(0.5)

❶ 复制现有的方法，但将其重命名为 wait_for 并修改参数，期待传入一个函数。

❷ 现在的代码仍然检查表格中的行。怎样修改才能支持传入的 fn 函数呢？像这样：

functional_tests/base.py (ch11l011)

    def wait_for(self, fn):
        start_time = time.time()
        while True:
            try:
                return fn()  ➊
            except (AssertionError, WebDriverException) as e:
                if time.time() - start_time > MAX_WAIT:
                    raise e
                time.sleep(0.5)

➊ try/except 的主体不再检查表格中的行，而是调用传入的函数。如果没有异常抛出，就返回传入的那个函数的返回值，立即跳出循环。

lambda 函数

在Python 中，一次性单行函数使用 lambda 构建，这样免去了使用 def..(): 和缩进代码块的麻烦。

>>> myfn = lambda x: x+1
>>> myfn(2)
3
>>> myfn(5)
6
>>> adder = lambda x, y: x + y
>>> adder(3, 2)
5

在下述示例中，我们把一段本应立即执行的代码定义为一个函数，然后作为参数传给 lambda 函数，留待以后执行，而且可以多次执行：

>>> def addthree(x):
... return x + 3
...
>>> addthree(2)
5
>>> myfn = lambda: addthree(2) # 注意，这里的addthree不会立即调用
>>> myfn
<function <lambda> at 0x7f3b140339d8>
>>> myfn()
5
>>> myfn()
5

下面来看 wait_for 辅助方法的实际效果：

$ python manage.py test functional_tests.test_list_item_validation
[...]
======================================================================
ERROR: test_cannot_add_empty_list_items
(functional_tests.test_list_item_validation.ItemValidationTest)
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "/.../superlists/functional_tests/test_list_item_validation.py", line
15, in test_cannot_add_empty_list_items
    self.wait_for(lambda: self.assertEqual(  ➊
  File "/.../superlists/functional_tests/base.py", line 37, in wait_for
    raise e  ➋
  File "/.../superlists/functional_tests/base.py", line 34, in wait_for
    return fn()  ➋
  File "/.../superlists/functional_tests/test_list_item_validation.py", line
16, in <lambda>  ➌
    self.browser.find_element_by_css_selector('.has-error').text,  ➌
[...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: .has-error
 ---------------------------------------------------------------------
Ran 1 test in 10.575s

FAILED (errors=1)

调用跟踪的顺序有点儿乱，不过我们或多或少能猜到到底发生了什么。

❶ 功能测试的第 15 行进入 self.wait_for 辅助方法，传入 assertEqual 的 lambda 版本。

❷ 进入 base.py 中的 self.wait_for，可以看到我们调用了那个 lambda 函数，但是由于超时而抛出 raise e。

❸ 为了查明异常来自何处，调用跟踪又把我们带到 test_list_item_validation.py 文件中的 lambda 函数主体里，并且告诉我们，是在尝试查找 .has-error 元素时失败的。

现在我们进入了函数式编程领域，即把一个函数作为参数传给另一个函数——这可能有点烧脑，我当初也是历经一番坎坷才理解的。多读几遍代码和功能测试，慢慢体会。如果还是不能理解，也别烦忧，在使用的过程中慢慢领会。本书将多次使用函数式编程，你会领略它的强大之处的。

12.3　补完功能测试

把功能测试补完：

functional_tests/test_list_item_validation.py (ch11l012)

    # 首页刷新了，显示一条错误消息
    # 提示待办事项不能为空
    self.wait_for(lambda: self.assertEqual(
        self.browser.find_element_by_css_selector('.has-error').text,
        "You can't have an empty list item"
    ))

    # 她输入一些文字，然后再次提交，这次没问题了
    self.browser.find_element_by_id('id_new_item').send_keys('Buy milk')
    self.browser.find_element_by_id('id_new_item').send_keys(Keys.ENTER)
    self.wait_for_row_in_list_table('1: Buy milk')

    # 她有点儿调皮，又提交了一个空待办事项
    self.browser.find_element_by_id('id_new_item').send_keys(Keys.ENTER)

    # 她在列表页面看到了一条类似的错误消息
    self.wait_for(lambda: self.assertEqual(
        self.browser.find_element_by_css_selector('.has-error').text,
        "You can't have an empty list item"
    ))

    # 输入文字之后就没问题了
    self.browser.find_element_by_id('id_new_item').send_keys('Make tea')
    self.browser.find_element_by_id('id_new_item').send_keys(Keys.ENTER)
    self.wait_for_row_in_list_table('1: Buy milk')
    self.wait_for_row_in_list_table('2: Make tea')

功能测试中的辅助方法

至此，我们定义了两个辅助方法：self.wait_for 和 wait_for_row_in_list_table。前者是通用的，任何功能测试都可能需要等待。

后者还有助于避免功能测试出现重复的代码。如果有一天我们决定改变清单表格的实现，只需修改一个地方，而不用在功能测试中四处修改。

第 25 章和附录 E 还将深入讨论如何合理规划功能测试。

这次提交留给你自己完成，这是你第一次独立提交功能测试。

12.4　重构单元测试，分拆成多个文件

最终开始编写代码前，要为模型编写一个新测试，但在此之前，先要使用类似于功能测试的整理方法整理单元测试。

但这一次有所不同，因为 lists 应用中既有应用代码也有测试代码，所以要把测试放到单独的文件夹中：

$ mkdir lists/tests
$ touch lists/tests/__init__.py
$ git mv lists/tests.py lists/tests/test_all.py
$ git status
$ git add lists/tests
$ python manage.py test lists
[...]
Ran 9 tests in 0.034s

OK
$ git commit -m "Move unit tests into a folder with single file"

如果测试的输出显示“Ran 0 tests”，有可能是因为你忘了创建 init .py 文件——必须有这个文件，否则测试所在的文件夹就不是有效的 Python 包。

现在把 test_all.py 分成两个文件：一个名为 test_views.py，只包含视图测试；另一个名为 test_models.py。先复制两份：

$ git mv lists/tests/test_all.py lists/tests/test_views.py
$ cp lists/tests/test_views.py lists/tests/test_models.py

然后清理 test_models.py，只留下一个测试方法，所以导入的模块也更少了：

lists/tests/test_models.py (ch11l016)

from django.test import TestCase
from lists.models import Item, List

class ListAndItemModelsTest(TestCase):
        [...]

test_views.py 只减少了一个类：

lists/tests/test_views.py (ch11l017)

--- a/lists/tests/test_views.py
+++ b/lists/tests/test_views.py
@@ -103,34 +104,3 @@ class ListViewTest(TestCase):
         self.assertNotContains(response, 'other list item 1')
         self.assertNotContains(response, 'other list item 2')

-
-
-class ListAndItemModelsTest(TestCase):
-
-    def test_saving_and_retrieving_items(self):
[...]

再次运行测试，确保一切正常：

$ python manage.py test lists
[...]
Ran 9 tests in 0.040s

OK

很好！

$ git add lists/tests
$ git commit -m "Split out unit tests into two files"

　有些人喜欢在项目一开始就把单元测试放在一个测试文件夹中。这种做法很棒。我只是想等到必要时再这么做，以免第一章内容过杂。

至此，功能测试和单元测试的组织方式更合理了。下一章将探讨验证规则。

关于组织测试和重构的小贴士

把测试放在单独的文件夹中

就像使用多个文件保存应用代码一样，你也应该把测试放到多个文件中。

对功能测试来说，按照特定功能或用户故事的方式组织。

对单元测试来说，使用一个名为 tests 的文件夹，并在其中添加 __init__.py 文件。

或许可以把针对一个源码文件的测试放在一个单独的文件中。在 Django 中，往往有 test_models.py、test_views.py 和 test_forms.py。

每个函数和类都至少有一个占位测试。

别忘了“遇红/变绿/重构”中的“重构”

编写测试的主要目的是让你重构代码！一定要重构，尽量让代码（包括测试）变得简洁。

测试失败时别重构

一般情况下如此。

不算正在处理的功能测试。

如果测试的对象还没实现，可以先为测试方法加上 @skip 装饰器。

更常见的做法是：记下想重构的地方，完成手头上的活儿，等应用处于正常状态时再重构。

提交代码之前别忘了删掉所有 @skip 装饰器！一定要逐行审查差异，找出需要删除的每一个地方。

尝试通用的wait_for辅助方法

使用专门的辅助方法实现显式等待是个不错的主意，能让测试更易于阅读，但有时单行断言或 Selenium 交互也需要等待一段时间。self.wait_for 很符合我的需求，不过你可能需要稍微不同的方式。

第 13 章　数据库层验证

接下来的几章将实现并测试用户输入验证。

这里有相当一部分内容是专门针对 Django 的，很少涉及 TDD 原理。但这并不意味着你学不到关于测试的新知识——其实我们将讨论很多有趣的测试小知识点，但将更关注如何适应测试、如何跟上 TDD 的节奏以及如何完成手上的工作。

这三章都很短，学完之后，我们将稍微接触一下 JavaScript，然后结束第二部分。第三部分将深入讨论 TDD 方法论的细节，比如比较单元测试和整合测试、介绍模拟技术等。敬请期待！

不过现在要稍微讨论一下验证。先运行功能测试，看一下目前进展到哪里了：

$ python3 manage.py test functional_tests.test_list_item_validation
[...]
======================================================================
ERROR: test_cannot_add_empty_list_items
(functional_tests.test_list_item_validation.ItemValidationTest)
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "/.../superlists/functional_tests/test_list_item_validation.py", line
15, in test_cannot_add_empty_list_items
    self.wait_for(lambda: self.assertEqual(
[...]
  File "/.../superlists/functional_tests/test_list_item_validation.py", line

16, in <lambda>
    self.browser.find_element_by_css_selector('.has-error').text,
[...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: .has-error

功能测试指明，用户输入空待办事项时，期望看到一个错误消息。

13.1　模型层验证

Web 应用的验证放在两个地方：客户端（稍后会看到，使用的是 JavaScript 或 HTML5 属性）和服务器端。在服务器端验证更安全，因为一旦有漏洞或缺陷，客户端验证可以轻易绕过。

在服务器端，尤其是对 Django 而言，也有两个地方可以执行验证：一个是模型层；一个是表单层，位置较高。只要可行，我就会使用低层验证，一方面因为我喜欢数据库和数据库完整性规则，另一方面因为在这一层执行验证更安全——你有时会忘记使用了哪个表单验证输入，但使用的数据库不会变。

13.1.1　self.assertRaises上下文管理器

下面在模型层编写一个单元测试。在 ListAndItemModelsTest 中添加一个新测试方法，尝试创建一个空待办事项。这个测试与以往不同，我们要测试的是代码能抛出异常：

lists/tests/test_models.py (ch11l018)

from django.core.exceptions import ValidationError
[...]

class ListAndItemModelsTest(TestCase):
    [...]

    def test_cannot_save_empty_list_items(self):
        list_ = List.objects.create()
        item = Item(list=list_, text='')
        with self.assertRaises(ValidationError):
           item.save()

　如果刚接触 Python，可能从未见过 with 语句。它结合“上下文管理器”一起使用，包装一段代码，这些代码的作用往往是设置、清理或处理错误。Python 2.5 的发布说明中有很好的解说。

这是一个新的单元测试技术：如果想检查做某件事是否会抛出异常，可以使用 self.assertRaises 上下文管理器。此外还可写成：

try:
    item.save()
    self.fail('The save should have raised an exception')
except ValidationError:
    pass

不过使用 with 语句更简洁。现在运行测试，得到预期失败：

    item.save()
AssertionError: ValidationError not raised

13.1.2　Django怪异的表现：保存时不验证数据

我们遇到了 Django 的一个怪异表现。测试本来应该通过的。阅读 Django 模型字段的文档之后，你会发现 TextField 的默认设置是 blank=False，也就是说文本字段应该拒绝空值。

但为什么测试失败了呢？由于稍微有违常理的历史原因，保存数据时 Django 的模型不会运行全部验证。稍后我们会看到，在数据库中实现的约束，保存数据时都会抛出异常，但 SQLite 不支持文本字段上的强制空值约束，所以我们调用 save 方法时无效值悄无声息地通过了验证。

有种方法可以检查约束是否会在数据库层执行：如果在数据库层制定约束，需要执行迁移才能应用约束。但是，Django 知道 SQLite 不支持这种约束，所以如果运行 makemigrations，会看到消息说没事可做：

$ python manage.py makemigrations
No changes detected

不过，Django 提供了一个方法用于运行全部验证，即 full_clean。下面我们把这个方法加入测试，看看是否有用：

lists/tests/test_models.py

    with self.assertRaises(ValidationError):
        item.save()
        item.full_clean()

加入之后，测试就通过了：

OK

很好！我们通过这个怪异的表现学到了一些 Django 的验证知识。如果忘了需求，把 text 字段的约束条件设为 blank=True（试一下吧），测试可以提醒我们。

13.2　在视图中显示模型验证错误

下面尝试在视图中处理模型验证，并把验证错误传入模板，让用户看到。在 HTML 中有选择地显示错误可以使用这种方法——检查是否有错误变量传入模板，如果有就在表单下方显示出来：

lists/templates/base.html (ch11l020)

<form method="POST" action="{% block form_action %}{% endblock %}">
  <input name="item_text" id="id_new_item"
         class="form-control input-lg"
         placeholder="Enter a to-do item" />
  {% csrf_token %}
  {% if error %}
    <div class="form-group has-error">
      <span class="help-block">{{ error }}</span>
    </div>
  {% endif %}
</form>

关于表单控件的更多信息请阅读 Bootstrap 文档。

把错误传入模板是视图函数的任务。看一下 NewListTest 类中的单元测试，这里我要使用两种稍微不同的错误处理模式。

在第一种情况中，新建清单视图有可能渲染首页所用的模板，而且还会显示错误消息。单元测试如下：

lists/tests/test_views.py (ch11l021)

class NewListTest(TestCase):
    [...]

    def test_validation_errors_are_sent_back_to_home_page_template(self):
        response = self.client.post('/lists/new', data={'item_text': ''})
        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed(response, 'home.html')
        expected_error = "You can't have an empty list item"
        self.assertContains(response, expected_error)

编写这个测试时，我们手动输入了字符串形式的地址 /lists/new，你可能有点反感。在此之前，我们已经在测试、视图和模板中硬编码了多个地址，这么做有违 DRY 原则。我并不介意测试中有少量重复，但视图和模板中硬编码的地址要引起重视，请在便签上做个记录，稍后重构这些地址。我们不会立即开始重构，因为现在应用无法正常运行，得先让应用回到可运行的状态。

再看测试。现在测试无法通过，因为现在视图返回 302 重定向，而不是正常的 200 响应：

AssertionError: 302 != 200

我们在视图中调用 full_clean() 试试：

lists/views.py

def new_list(request):
    list_ = List.objects.create()
    item = Item.objects.create(text=request.POST['item_text'], list=list_)
    item.full_clean()
    return redirect(f'/lists/{list_.id}/')

看到这个视图的代码，我们找到了一种避免硬编码 URL 的好方法。把这件事记在便签上：

现在模型验证会抛出异常，并且传到了视图中：

[...]
  File "/.../superlists/lists/views.py", line 11, in new_list
    item.full_clean()
[...]
django.core.exceptions.ValidationError: {'text': ['This field cannot be
blank.']}

下面使用第一种错误处理方案：使用 try/except 检测错误。遵从测试山羊的教诲，只加入 try/except，其他代码都不动。测试会告诉我们下一步要编写什么代码：

lists/views.py (ch11l025)

from django.core.exceptions import ValidationError
[...]

def new_list(request):
    list_ = List.objects.create()
    item = Item.objects.create(text=request.POST['item_text'], list=list_)
    try:
        item.full_clean()
    except ValidationError:
        pass
    return redirect(f'/lists/{list_.id}/')

加入 try/except 之后，测试结果又变成了 302 != 200 错误：

AssertionError: 302 != 200

下面把 pass 改成渲染模板，这么改还兼具检查模板的功能：

lists/views.py (ch11l026)

    except ValidationError:
        return render(request, 'home.html')

现在测试告诉我们，要把错误消息写入模板：

AssertionError: False is not true : Couldn't find 'You can't have an empty list
item' in response

为此，可以传入一个新的模板变量：

lists/views.py (ch11l027)

    except ValidationError:
        error = "You can't have an empty list item"
        return render(request, 'home.html', {"error": error})

不过，看样子没什么用：

AssertionError: False is not true : Couldn't find 'You can't have an empty list
item' in response

让视图输出一些信息以便调试：

lists/tests/test_views.py

expected_error = "You can't have an empty list item"
print(response.content.decode())
self.assertContains(response, expected_error)

从输出的信息中我们得知，失败的原因是 Django 转义了 HTML 中的单引号：

[...]
<span class="help-block">You can&#39;t have an empty list
item</span>

可以在测试中写入：

    expected_error = "You can&#39;t have an empty list item"

但使用 Django 提供的辅助函数或许是更好的方法：

lists/tests/test_views.py (ch11l029)

from django.utils.html import escape
[...]

        expected_error = escape("You can't have an empty list item")
        self.assertContains(response, expected_error)

测试通过了：

Ran 11 tests in 0.047s

OK

确保无效的输入值不会存入数据库

继续做其他事之前，不知你是否注意到了我们的实现有点逻辑错误？现在即使验证失败仍会创建对象：

lists/views.py

    item = Item.objects.create(text=request.POST['item_text'], list=list_)
    try:
        item.full_clean()
    except ValidationError:
        [...]

要添加一个新单元测试，确保不会保存空待办事项：

lists/tests/test_views.py (ch11l030-1)

class NewListTest(TestCase):
    [...]

    def test_validation_errors_are_sent_back_to_home_page_template(self):
        [...]

    def test_invalid_list_items_arent_saved(self):
        self.client.post('/lists/new', data={'item_text': ''})
        self.assertEqual(List.objects.count(), 0)
        self.assertEqual(Item.objects.count(), 0)

测试的结果是：

[...]
Traceback (most recent call last):
  File "/.../superlists/lists/tests/test_views.py", line 40, in
test_invalid_list_items_arent_saved
    self.assertEqual(List.objects.count(), 0)
AssertionError: 1 != 0

修正的方法如下：

lists/views.py (ch11l030-2)

def new_list(request):
    list_ = List.objects.create()
    item = Item(text=request.POST['item_text'], list=list_)
    try:
        item.full_clean()
        item.save()
    except ValidationError:
        list_.delete()
        error = "You can't have an empty list item"
        return render(request, 'home.html', {"error": error})
    return redirect(f'/lists/{list_.id}/')

功能测试能通过吗？

$ python manage.py test functional_tests.test_list_item_validation
[...]
File "/.../superlists/functional_tests/test_list_item_validation.py", line
29, in test_cannot_add_empty_list_items
    self.wait_for(lambda: self.assertEqual(
[...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: .has-error

不能完全通过，但取得了一些进展。从 line 29 那行能看出，功能测试的第一部分通过了，现在要处理第二部分，即第二次提交空待办事项也要显示错误消息。

不过我们编写了一些可用的代码，那就做个提交吧：

$ git commit -am "Adjust new list view to do model validation"

13.3　Django模式：在渲染表单的视图中处理POST请求

这一次要使用一种稍微不同的处理方式，这种方式是 Django 中十分常用的模式：在渲染表单的视图中处理该视图接收到的 POST 请求。这么做虽然不太符合 REST 架构的 URL 规则，却有个很大的好处：同一个 URL 既可以显示表单，又可以显示处理用户输入过程中遇到的错误。

现在的状况是，显示清单用一个视图和 URL，处理新建清单中的待办事项用另一个视图和 URL。要把这两种操作合并到一个视图和 URL 中。所以，在 list.html 中，表单的提交目标地址要改一下：

lists/templates/list.html (ch11l030)

{% block form_action %}/lists/{{ list.id }}/{% endblock %}

不小心又硬编码了一个 URL，在便签上记下这个地方。回想一下，home.html 中也有一个：

修改之后功能测试随即失败，因为 view_list 视图还不知道如何处理 POST 请求：

$ python manage.py test functional_tests
[...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: .has-error
[...]
AssertionError: '2: Use peacock feathers to make a fly' not found in ['1: Buy
peacock feathers']

　本节要进行一次应用层的重构。在应用层中重构时，要先修改或增加单元测试，然后再调整代码。使用功能测试检查重构是否完成，以及一切能否像重构前一样正常运行。如果你想完全理解这个过程，请再看一下第 4 章末尾的图表。

13.3.1　重构：把new_item实现的功能移到view_list中

NewItemTest 类中的测试用于检查把 POST 请求中的数据保存到现有的清单中，把这些测试全部移到 ListViewTest 类中，还要把原来的请求目标地址 .../add_item 改成显示清单的 URL：

lists/tests/test_views.py (ch11l031)

class ListViewTest(TestCase):

    def test_uses_list_template(self):
        [...]

    def test_passes_correct_list_to_template(self):
        [...]

    def test_displays_only_items_for_that_list(self):
        [...]

    def test_can_save_a_POST_request_to_an_existing_list(self):
        other_list = List.objects.create()
        correct_list = List.objects.create()

        self.client.post(
            f'/lists/{correct_list.id}/',
            data={'item_text': 'A new item for an existing list'}
        )

        self.assertEqual(Item.objects.count(), 1)
        new_item = Item.objects.first()
        self.assertEqual(new_item.text, 'A new item for an existing list')
        self.assertEqual(new_item.list, correct_list)

    def test_POST_redirects_to_list_view(self):
        other_list = List.objects.create()
        correct_list = List.objects.create()

        response = self.client.post(
            f'/lists/{correct_list.id}/',
            data={'item_text': 'A new item for an existing list'}
        )
        self.assertRedirects(response, f'/lists/{correct_list.id}/')

注意，整个 NewItemTest 类都没有了。而且我还修改了重定向测试方法的名字，明确表明只适用于 POST 请求。

改动之后测试的结果为：

FAIL: test_POST_redirects_to_list_view (lists.tests.test_views.ListViewTest)
AssertionError: 200 != 302 : Response didn't redirect as expected: Response
code was 200 (expected 302)
[...]
FAIL: test_can_save_a_POST_request_to_an_existing_list
(lists.tests.test_views.ListViewTest)
AssertionError: 0 != 1

然后修改 view_list 函数，处理两种请求类型：

lists/views.py (ch11l032-1)

def view_list(request, list_id):
    list_ = List.objects.get(id=list_id)
    if request.method == 'POST':
    Item.objects.create(text=request.POST['item_text'], list=list_)
    return redirect(f'/lists/{list_.id}/')
return render(request, 'list.html', {'list': list_})

修改之后测试通过了：

Ran 12 tests in 0.047s

OK

现在可以删除 add_item 视图，因为不再需要了。但出乎意料，一些测试失败了：

[...]
AttributeError: module 'lists.views' has no attribute 'add_item'

失败的原因是，虽然删除了视图，但在 urls.py 中仍然引用这个视图。把引用也删除：

lists/urls.py (ch11l033)

urlpatterns = [
    url(r'^new$', views.new_list, name='new_list'),
    url(r'^(\d+)/$', views.view_list, name='view_list'),
]

这样单元测试就能通过了。运行所有功能测试看看结果如何：

$ python manage.py test
[...]
ERROR: test_cannot_add_empty_list_items
[...]

Ran 16 tests in 15.276s
FAILED (errors=1)

依然是那个新功能测试中的失败。至此，重构 add_item 功能的任务完成了。此时应该提交代码：

$ git commit -am "Refactor list view to handle new item POSTs"

　我是不是破坏了“有测试失败时不重构”这个规则？本节可以这么做，因为若想使用新功能必须重构。如果有单元测试失败，决不能重构。不过在本书中，虽然当前这个用户故事的功能测试失败了，但仍然可以重构。1

1如果你更想看到一个干净的测试结果，那么可以为这个功能测试方法加上 @skip 装饰器，或者在测试方法中尽早返回。但是，别忘了你这么做过。

13.3.2　在view_list视图中执行模型验证

把待办事项添加到现有清单时，我们希望保存数据时仍能遵守制定好的模型验证规则。为此要编写一个新单元测试，和首页的单元测试差不多，但有几处不同：

lists/tests/test_views.py (ch11l034)

class ListViewTest(TestCase):
    [...]

    def test_validation_errors_end_up_on_lists_page(self):
        list_ = List.objects.create()
        response = self.client.post(
            f'/lists/{list_.id}/',
            data={'item_text': ''}
        )
        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed(response, 'list.html')
        expected_error = escape("You can't have an empty list item")
        self.assertContains(response, expected_error)

这个测试应该失败，因为视图现在还没做任何验证，只是重定向所有 POST 请求：

    self.assertEqual(response.status_code, 200)
AssertionError: 302 != 200

在视图中执行验证的方法如下：

lists/views.py (ch11l035)

def view_list(request, list_id):
    list_ = List.objects.get(id=list_id)
    error = None

    if request.method == 'POST':
        try:
            item = Item(text=request.POST['item_text'], list=list_)
            item.full_clean()
            item.save()
            return redirect(f'/lists/{list_.id}/')
        except ValidationError:
            error = "You can't have an empty list item"

    return render(request, 'list.html', {'list': list_, 'error': error})

对这段代码不是特别满意，是吧？确实有一些重复的代码，views.py 中出现了两次 try/except 语句，一般来说不太好看。

Ran 13 tests in 0.047s

OK

稍等一会儿再重构，因为我们知道验证待办事项重复的代码有点不同。先把这件事记在便签上：

　制定“事不过三，三则重构”这个规则的原因之一是，只有遇到三次且每次都稍有不同时，才能更好地提炼出通用功能。如果过早重构，得到的代码可能并不适用于第三次。

至少功能测试又可以通过了：

$ python manage.py test functional_tests
[...]
OK

又回到了可正常运行的状态，因此可以看一下便签上的记录了。现在是提交的好时机。或许还可以喝杯茶休息一下。

$ git commit -am "enforce model validation in list view"

13.4　重构：去除硬编码的URL

还记得 urls.py 中 name= 参数的写法吗？我们是直接从 Django 生成的默认 URL 映射中复制过来，然后又给它们起了有意义的名字。现在要查明这些名字有什么用。

lists/urls.py

    url(r'^new$', views.new_list, name='new_list'),
    url(r'^(\d+)/$', views.view_list, name='view_list'),

13.4.1　模板标签{% url %}

可以把 home.html 中硬编码的 URL 换成一个 Django 模板标签，再引用 URL 的“名字”：

lists/templates/home.html (ch11l036-1)

{% block form_action %}{% url 'new_list' %}{% endblock %}

然后确认改动之后不会导致单元测试失败：

$ python manage.py test lists
OK

继续修改其他模板。传入了一个参数，所以这一个更有趣：

lists/templates/list.html (ch11l036-2)

{% block form_action %}{% url 'view_list' list.id %}{% endblock %}

详情请阅读 Django 文档中对 URL 反向解析的介绍。

再次运行测试，确保都能通过：

$ python manage.py test lists
OK
$ python manage.py test functional_tests
OK

太棒了，做次提交：

$ git commit -am "Refactor hard-coded URLs out of templates"

13.4.2　重定向时使用get_absolute_url

下面来处理 views.py。在这个文件中去除硬编码的 URL，可以使用和模板一样的方法——写入 URL 的名字和一个位置参数：

lists/views.py (ch11l036-3)

def new_list(request):
    [...]
    return redirect('view_list', list_.id)

修改之后单元测试和功能测试仍能通过，但是 redirect 函数的作用远比这强大。在 Django 中，每个模型对象都对应一个特定的 URL，因此可以定义一个特殊的函数，命名为 get_absolute_url，其作用是获取显示单个模型对象的页面 URL。这个函数在这里很有用，在 Django 管理后台（本书不会介绍管理后台，但稍后你可以自己学习）也很有用：

在后台查看一个对象时可以直接跳到前台显示该对象的页面。如果有必要，我总是建议在模型中定义 get_absolute_url 函数，这花不了多少时间。

先在 test_models.py 中编写一个超级简单的单元测试：

lists/tests/test_models.py (ch11l036-4)

    def test_get_absolute_url(self):
        list_ = List.objects.create()
        self.assertEqual(list_.get_absolute_url(), f'/lists/{list_.id}/')

得到的测试结果是：

AttributeError: 'List' object has no attribute 'get_absolute_url'

实现这个函数时要使用 Django 中的 reverse 函数。reverse 函数的功能和 Django 对 urls.py 所做的操作相反。

lists/models.py (ch11l036-5)

from django.core.urlresolvers import reverse

class List(models.Model):

    def get_absolute_url(self):
        return reverse('view_list', args=[self.id])

现在可以在视图中使用 get_absolute_url 函数了——只需把重定向的目标对象传给 redirect 函数即可，redirect 函数会自动调用 get_absolute_url 函数。

lists/views.py (ch11l036-6)

def new_list(request):
    [...]
    return redirect(list_)

更多信息参见 Django 文档。快速确认一下单元测试是否仍能通过：

OK

然后使用同样的方法修改 view_list 视图：

lists/views.py (ch11l036-7)

def view_list(request, list_id):
    [...]

           item.save()
           return redirect(list_)
       except ValidationError:
           error = "You can't have an empty list item"

分别运行全部单元测试和功能测试，确保一切仍能正常运行：

$ python3 manage.py test lists
OK
$ python3 manage.py test functional_tests
OK

把已解决问题从便签上划掉：

提交一次：

$ git commit -am "Use get_absolute_url on List model to DRY urls in views"

这一阶段结束！我们添加了模型层验证，而且在此过程中借机重构了几个地方。

最后一个待办事项是下一章的话题。

关于数据库层验证

我喜欢尽量把验证逻辑放在低层。

数据库层验证是数据完整性的最终保障

不管数据库层之上的各层代码有多么复杂，在最低层验证能保证数据是有效的，而且是一致的。

但是数据库层验证有失灵活性

优点往往都伴随着缺点。添加数据库层验证之后，就无法得到不一致的数据了，即便想暂时这么做也不可能。但我们有时就需要存储暂时破坏这些规则的数据（例如在很多阶段可能想从外部源导入数据），毕竟有数据总比没数据强。

对用户不太友好

尝试存储无效数据会导致数据库返回不友善的IntegrityError，这可能会让用户看到令人困惑的 500 错误页面。后面的章节会讲到，表单层验证考虑到了用户，不会直接报错，而是显示友好的错误消息。

第 14 章　简单的表单

前一章结尾提到，视图中处理验证的代码有太多重复。Django 鼓励使用表单类验证用户的输入，以及选择显示错误消息。本章介绍如何使用这种功能。

除此之外本章还会花点时间整理单元测试，确保一个单元测试一次只测试一件事。

14.1　把验证逻辑移到表单中

　在 Django 中，视图很复杂就说明有代码异味。你要想，能否把逻辑移到表单或模型类的方法中，或者把业务逻辑移到 Django 之外的模型中？

Django 中的表单功能很多很强大。

可以处理用户输入，并验证输入值是否有错误。

可以在模板中使用，用来渲染 HTML input 元素和错误消息。

稍后会见识到，某些表单甚至还可以把数据存入数据库。

没必要在每个表单中都使用这三种功能。你可以自己编写表单的 HTML，或者自己处理数据存储，但表单是放置验证逻辑的绝佳位置。

14.1.1　使用单元测试探索表单API

我们要在一个单元测试中实验表单的用法。我的计划是逐步迭代，最终得到一个完整的解决方案。希望在这个过程中能由浅入深地介绍表单，即便你以前从未用过也能理解。

首先，新建一个文件，用于编写表单的单元测试。先编写一个测试方法，检查表单的 HTML：

lists/tests/test_forms.py

from django.test import TestCase

from lists.forms import ItemForm

class ItemFormTest(TestCase):

    def test_form_renders_item_text_input(self):
        form = ItemForm()
        self.fail(form.as_p())

form.as_p() 的作用是把表单渲染成 HTML。这个单元测试使用 self.fail 探索性编程。在 manage.py shell 会话中探索编程也很容易，不过每次修改代码之后都要重新加载。

下面编写一个极简的表单，继承自基类 Form，只有一个字段 item_text：

lists/forms.py

from django import forms

class ItemForm(forms.Form):
    item_text = forms.CharField()

运行测试后会看到一个失败消息，告诉我们自动生成的表单 HTML 是什么样：

    self.fail(form.as_p())
AssertionError: <p><label for="id_item_text">Item text:</label> <input
type="text" name="item_text" required id="id_item_text" /></p>

自动生成的 HTML 已经和 base.html 中的表单 HTML 很接近了，只不过没有 placeholder 属性和 Bootstrap 的 CSS 类。再编写一个单元测试方法，检查 placeholder 属性和 CSS 类：

lists/tests/test_forms.py

class ItemFormTest(TestCase):

    def test_form_item_input_has_placeholder_and_css_classes(self):
        form = ItemForm()
        self.assertIn('placeholder="Enter a to-do item"', form.as_p())
        self.assertIn('class="form-control input-lg"', form.as_p())

这个测试会失败，表明我们需要真正地编写一些代码了。应该怎么定制表单字段的内容呢？答案是使用 widget 参数。加入 placeholder 属性的方法如下：

lists/forms.py

class ItemForm(forms.Form):
    item_text = forms.CharField(
        widget=forms.fields.TextInput(attrs={
            'placeholder': 'Enter a to-do item',
        }),
    )

修改之后测试的结果为：

AssertionError: 'class="form-control input-lg"' not found in '<p><label
for="id_item_text">Item text:</label> <input type="text" name="item_text"
placeholder="Enter a to-do item" required id="id_item_text" /></p>'

继续修改：

lists/forms.py

    widget=forms.fields.TextInput(attrs={
        'placeholder': 'Enter a to-do item',
        'class': 'form-control input-lg',
    }),

　如果表单中的内容很多或者很复杂，使用 widget 参数定制很麻烦，此时可以借助 django-crispy-forms 和 django-floppyforms。

开发驱动测试：使用单元测试探索性编程

上述过程是不是有点像开发驱动测试？偶尔这么做其实没问题。

探索新 API 时，完全可以先抛开规则的束缚，然后再回到严格的 TDD 流程中。你可以使用交互式终端，或者编写一些探索性代码（不过你要答应测试山羊，稍后会删掉这些代码，然后使用合理的方式重写）。

其实，现在我们只是使用单元测试试验表单API，这是学习如何使用 API 的好方法。

14.1.2　换用Django中的ModelForm类

接下来呢？我们希望表单重用已经在模型中定义好的验证规则。Django 提供了一个特殊的类，用来自动生成模型的表单，这个类是 ModelForm。从下面的代码能看出，我们要使用一个特殊的属性 Meta 配置表单：

lists/forms.py

from django import forms

from lists.models import Item

class ItemForm(forms.models.ModelForm):

    class Meta:
        model = Item
        fields = ('text',)

我们在 Meta 中指定这个表单用于哪个模型，以及要使用哪些字段。

ModelForms 很智能，能完成各种操作，例如为不同类型的字段生成合适的 input 类型，以及应用默认的验证。详情参见文档（https://docs.djangoproject.com/en/1.11/topics/forms/modelforms/）。

现在表单的 HTML 不一样了：

AssertionError: 'placeholder="Enter a to-do item"' not found in '<p><label
for="id_text">Text:</label> <textarea name="text" cols="40" rows="10" required
id="id_text">\n</textarea></p>'

placeholder 属性和 CSS 类都不见了，而且 name="item_text" 变成了 name="text"。这些变化能接受，但普通的输入框变成了 textarea，这可不是应用 UI 想要的效果。幸好，和普通的表单类似，ModelForm 的字段也能使用 widget 参数定制：

lists/forms.py

class ItemForm(forms.models.ModelForm):

    class Meta:
        model = Item
        fields = ('text',)
        widgets = {
            'text': forms.fields.TextInput(attrs={
                'placeholder': 'Enter a to-do item',
                'class': 'form-control input-lg',
            }),
        }

定制后测试通过了。

14.1.3　测试和定制表单验证

现在我们看一下 ModelForm 是否应用了模型中定义的验证规则。我们还会学习如何把数据传入表单，就像用户输入的一样：

lists/tests/test_forms.py (ch11l008)

    def test_form_validation_for_blank_items(self):
        form = ItemForm(data={'text': ''})
        form.save()

测试的结果为：

ValueError: The Item could not be created because the data didn't validate.

很好，如果提交空待办事项，表单不会保存数据。

现在看一下表单能否显示指定的错误消息。在尝试保存数据之前检查验证是否通过的 API 是 is_valid 函数：

lists/tests/test_forms.py (ch11l009)

def test_form_validation_for_blank_items(self):
    form = ItemForm(data={'text': ''})
    self.assertFalse(form.is_valid())
    self.assertEqual(
        form.errors['text'],
        ["You can't have an empty list item"]
    )

调用 form.is_valid() 得到的返回值是 True 或 False，不过还有个附带效果，即验证输入的数据，生成 errors 属性。errors 是个字典，把字段的名字映射到该字段的错误列表上（一个字段可以有多个错误）。

测试的结果为：

AssertionError: ['This field is required.'] != ["You can't have an empty list
item"]

Django 已经为显示给用户查看的错误消息提供了默认值。急着开发 Web 应用的话，可以直接使用默认值。不过我们比较在意，想让错误消息特殊一些。定制错误消息可以修改 Meta 的另一个变量，error_messages：

lists/forms.py (ch11l010)

    class Meta:
        model = Item
        fields = ('text',)
        widgets = {
            'text': forms.fields.TextInput(attrs={
                'placeholder': 'Enter a to-do item',
                'class': 'form-control input-lg',
            }),
        }
        error_messages = {
            'text': {'required': "You can't have an empty list item"}
        }

然后测试即可通过：

OK

知道如何避免让这些错误消息搅乱代码吗？使用常量：

lists/forms.py (ch11l011)

EMPTY_ITEM_ERROR = "You can't have an empty list item"
[...]

        error_messages = {
            'text': {'required': EMPTY_ITEM_ERROR}
        }

再次运行测试，确认能通过。好的，然后修改测试：

lists/tests/test_forms.py (ch11l012)

from lists.forms import EMPTY_ITEM_ERROR, ItemForm
[...]

    def test_form_validation_for_blank_items(self):
        form = ItemForm(data={'text': ''})
        self.assertFalse(form.is_valid())
        self.assertEqual(form.errors['text'], [EMPTY_ITEM_ERROR])

修改之后测试仍能通过：

OK

很好。提交：

$ git status # 会看到lists/forms.py和tests/test_forms.py
$ git add lists
$ git commit -m "new form for list items"

14.2　在视图中使用这个表单

一开始我想继续编写这个表单，除了空值验证之外再捕获唯一性验证错误。不过，精益理论中的“尽早部署”有个推论，即“尽早合并代码”。也就是说，编写表单可能要花很多时间，不断添加各种功能——我知道这一点是因为我在撰写本章草稿时就是这么做的，做了各种工作，得到一个功能完善的表单类，但发布应用后才发现大多数功能实际并不需要。

因此，要尽早试用新编写的代码。这么做能避免编写用不到的代码，还能尽早在现实的环境中检验代码。

我们编写了一个表单类，它可以渲染一些 HTML，而且至少能验证一种错误——下面就来使用这个表单吧！既然可以在 base.html 模板中使用这个表单，那么在所有视图中都可以使用。

14.2.1　在处理GET请求的视图中使用这个表单

先修改首页视图的单元测试。我们要编写一个新测试，检查使用的表单类型是否正确：

lists/tests/test_views.py (ch11l013)

from lists.forms import ItemForm

class HomePageTest(TestCase):
    def test_uses_home_template(self):
        [...]

    def test_home_page_uses_item_form(self):
        response = self.client.get('/')
        self.assertIsInstance(response.context['form'], ItemForm)  ➊

➊ assertIsInstance 检查表单是否属于正确的类。

测试的结果为：

KeyError: 'form'

因此，要在首页视图中使用这个表单：

lists/views.py (ch11l014)

[...]
from lists.forms import ItemForm
from lists.models import Item, List

def home_page(request):
    return render(request, 'home.html', {'form': ItemForm()})

好了，下面尝试在模板中使用这个表单——把原来的 <input ..> 替换成 {{ form.text }}：

lists/templates/base.html (ch11l015)

  <form method="POST" action="{% block form_action %}{% endblock %}">
    {{ form.text }}
    {% csrf_token %}
    {% if error %}
      <div class="form-group has-error">

{{ form.text }} 只会渲染这个表单中的 text 字段，生成 HTML input 元素。

14.2.2　大量查找和替换

前文我们修改了表单，id 和 name 属性的值变了。运行功能测试时你会看到，首次尝试查找输入框时测试失败了：

selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: [id="id_new_item"]

我们得修正这个问题，为此要进行大量查找和替换。在此之前先提交，把重命名和逻辑变动区分开：

$ git diff # 审查base.html、views.py及其测试中的改动
$ git commit -am "use new form in home_page, simplify tests. NB breaks stuff"

下面来修正功能测试。通过 grep 命令，我们得知有很多地方都使用了 id_new_item：

$ grep id_new_item functional_tests/test*
functional_tests/test_layout_and_styling.py:        inputbox =
self.browser.find_element_by_id('id_new_item')
functional_tests/test_layout_and_styling.py:        inputbox =
self.browser.find_element_by_id('id_new_item')
functional_tests/test_list_item_validation.py:
self.browser.find_element_by_id('id_new_item').send_keys(Keys.ENTER)
[...]

这表明我们要重构。在 base.py 中定义一个新辅助方法：

functional_tests/base.py (ch11l018)

class FunctionalTest(StaticLiveServerTestCase):
    [...]
    def get_item_input_box(self):
        return self.browser.find_element_by_id('id_text')

然后所有需要替换的地方都使用这个辅助方法——test_simple_list_creation.py 修改四处， test_layout_and_styling.py 修改两处，test_list_item_validation.py 修改四处。例如：

functional_tests/test_simple_list_creation.py

    # 应用邀请她输入一个待办事项
    inputbox = self.get_item_input_box()

以及：

functional_tests/test_list_item_validation.py

    # 输入框中没输入内容，她就按下了回车键
    self.browser.get(self.live_server_url)
    self.get_item_input_box().send_keys(Keys.ENTER)

我不会列出每一处，相信你自己能搞定！你可以再执行一遍 grep，看是不是全都改了。

第一步完成了，接下来还要修改应用代码。我们要找到所有旧的 id（id_new_item）和 name（item_text），分别替换成 id_text 和 text：

$ grep -r id_new_item lists/
lists/static/base.css:#id_new_item {

只要改动一处。使用类似的方法查看 name 出现的位置：

$ grep -Ir item_text lists
[...]
lists/views.py:    item = Item(text=request.POST['item_text'], list=list_)
lists/views.py:            item = Item(text=request.POST['item_text'],
list=list_)
lists/tests/test_views.py:        self.client.post('/lists/new',
data={'item_text': 'A new list item'})
lists/tests/test_views.py:        response = self.client.post('/lists/new',
data={'item_text': 'A new list item'})
[...]
lists/tests/test_views.py:            data={'item_text': ''}
[...]

改完之后再运行单元测试，确保一切仍能正常运行：

$ python manage.py test lists
[...]
.................
 ---------------------------------------------------------------------
Ran 17 tests in 0.126s

OK

然后还要运行功能测试：

$ python manage.py test functional_tests
[...]
  File "/.../superlists/functional_tests/test_simple_list_creation.py", line
37, in test_can_start_a_list_for_one_user
    return self.browser.find_element_by_id('id_text')
  File "/.../superlists/functional_tests/base.py", line 51, in
get_item_input_box
    return self.browser.find_element_by_id('id_text')
[...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: [id="id_text"]
[...]
FAILED (errors=3)

不能全部通过。确认一下发生错误的位置——查看其中一个失败所在的行号，你会发现，每次提交第一个待办事项后，清单页面都不会显示输入框。

查看 views.py 和 new_list 视图后我们找到了原因——如果检测到有验证错误，根本就不会把表单传入 home.html 模板：

lists/views.py

except ValidationError:
    list_.delete()
    error = "You can't have an empty list item"
    return render(request, 'home.html', {"error": error})

我们也想在这个视图中使用 ItemForm 表单。继续修改之前，先提交：

$ git status
$ git commit -am "rename all item input ids and names. still broken"

14.3　在处理POST请求的视图中使用这个表单

现在要调整 new_list 视图的单元测试，更确切地说，要修改针对验证的那个测试方法。先看一下这个测试方法：

lists/tests/test_views.py

class NewListTest(TestCase):
    [...]

    def test_validation_errors_are_sent_back_to_home_page_template(self):
        response = self.client.post('/lists/new', data={'text': ''})
        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed(response, 'home.html')
        expected_error = escape("You can't have an empty list item")
        self.assertContains(response, expected_error)

14.3.1　修改new_list视图的单元测试

首先，这个测试方法测试的内容太多了，所以借此机会可以清理一下。我们应该把这个测试方法分成两个不同的断言。

如果有验证错误，应该渲染首页模板，并且返回 200 响应。

如果有验证错误，响应中应该包含错误消息。

此外，还可以添加一个新断言。

如果有验证错误，应该把表单对象传入模板。

不用硬编码错误消息字符串，而要使用一个常量：

lists/tests/test_views.py (ch11l023)

from lists.forms import ItemForm, EMPTY_ITEM_ERROR
[...]

class NewListTest(TestCase):
    [...]

    def test_for_invalid_input_renders_home_template(self):
        response = self.client.post('/lists/new', data={'text': ''})
        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed(response, 'home.html')

    def test_validation_errors_are_shown_on_home_page(self):
        response = self.client.post('/lists/new', data={'text': ''})
        self.assertContains(response, escape(EMPTY_ITEM_ERROR))

    def test_for_invalid_input_passes_form_to_template(self):
        response = self.client.post('/lists/new', data={'text': ''})
        self.assertIsInstance(response.context['form'], ItemForm)

现在好多了，每个测试方法只测试一件事。如果幸运的话，只有一个测试会失败，而且会告诉我们接下来做什么：

$ python manage.py test lists
[...]
======================================================================
ERROR: test_for_invalid_input_passes_form_to_template
(lists.tests.test_views.NewListTest)
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "/.../superlists/lists/tests/test_views.py", line 49, in
test_for_invalid_input_passes_form_to_template
    self.assertIsInstance(response.context['form'], ItemForm)
[...]
KeyError: 'form'

 ---------------------------------------------------------------------
Ran 19 tests in 0.041s

FAILED (errors=1)

14.3.2　在视图中使用这个表单

在视图中使用这个表单的方法如下：

lists/views.py

def new_list(request):
    form = ItemForm(data=request.POST)  ➊
    if form.is_valid():  ➋
        list_ = List.objects.create()
        Item.objects.create(text=request.POST['text'], list=list_)
        return redirect(list_)
    else:
        return render(request, 'home.html', {"form": form})  ➌

❶ 把 request.POST 中的数据传给表单的构造方法。

❷ 使用 form.is_valid() 判断提交是否成功。

❸ 如果提交失败，把表单对象传入模板，而不显示一个硬编码的错误消息字符串。

视图现在看起来更完美了。而且除了一个测试之外，其他测试都能通过：

    self.assertContains(response, escape(EMPTY_ITEM_ERROR))
[...]
AssertionError: False is not true : Couldn't find 'You can&#39;t have an empty
list item' in response

14.3.3　使用这个表单在模板中显示错误消息

测试失败的原因是模板还没使用这个表单显示错误消息：

lists/templates/base.html (ch11l026)

  <form method="POST" action="{% block form_action %}{% endblock %}">
    {{ form.text }}
    {% csrf_token %}
    {% if form.errors %}  ➊
      <div class="form-group has-error">
        <div class="help-block">{{ form.text.errors }}</div>  ➋
      </div>
    {% endif %}
  </form>

❶ form.errors 是一个列表，包含这个表单中的所有错误。

❷ form.text.errors 也是一个列表，但只包含 text 字段的错误。

这样修改之后对测试有什么作用呢？

FAIL: test_validation_errors_end_up_on_lists_page
(lists.tests.test_views.ListViewTest)
[...]
AssertionError: False is not true : Couldn't find 'You can&#39;t have an empty
list item' in response

得到了一个意料之外的失败，这次失败发生在针对最后一个试图 view_list 的测试中。因为我们修改了错误在所有模板中显示的方式，不再显示手动传入模板的错误。

因此，还要修改 view_list 视图才能重新回到可运行状态。

14.4　在其他视图中使用这个表单

view_list 视图既可以处理 GET 请求也可以处理 POST 请求。先测试 GET 请求，为此，可以编写一个新测试方法：

lists/tests/test_views.py

class ListViewTest(TestCase):
    [...]

    def test_displays_item_form(self):
        list_ = List.objects.create()
        response = self.client.get(f'/lists/{list_.id}/')
        self.assertIsInstance(response.context['form'], ItemForm)
        self.assertContains(response, 'name="text"')

测试的结果为：

KeyError: 'form'

解决这个问题最简单的方法如下：

lists/views.py (ch11l028)

def view_list(request, list_id):
    [...]
    form = ItemForm()
    return render(request, 'list.html', {
        'list': list_, "form": form, "error": error
    })

14.4.1　定义辅助方法，简化测试

接下来要在另一个视图中使用这个表单的错误消息，把当前针对表单提交失败的测试（test_validation_errors_end_up_on_lists_page）分成多个测试方法：

lists/tests/test_views.py (ch11l030)

class ListViewTest(TestCase):
    [...]

    def post_invalid_input(self):
        list_ = List.objects.create()
        return self.client.post(
            f'/lists/{list_.id}/',
            data={'text': ''}
        )

    def test_for_invalid_input_nothing_saved_to_db(self):
        self.post_invalid_input()
        self.assertEqual(Item.objects.count(), 0)

    def test_for_invalid_input_renders_list_template(self):
        response = self.post_invalid_input()
        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed(response, 'list.html')

    def test_for_invalid_input_passes_form_to_template(self):
        response = self.post_invalid_input()
        self.assertIsInstance(response.context['form'], ItemForm)

    def test_for_invalid_input_shows_error_on_page(self):
        response = self.post_invalid_input()
        self.assertContains(response, escape(EMPTY_ITEM_ERROR))

我们定义了一个辅助方法 post_invalid_input，这样就不用在分拆的四个测试中重复编写代码了。

这种做法我们见过几次了。把视图测试写在一个测试方法中，编写一连串的断言检测视图应该做这个、这个和这个，然后应该返回那个——我们经常觉得这么做更合理，但把单个测试方法分解为多个方法也绝对有好处。从前面几章我们已经得知，如果以后修改代码时不小心引入了一个问题，分拆的测试能帮助定位真正的问题所在。辅助方法则是降低心理障碍的方式之一。

例如，现在测试结果只有一个失败，而且我们知道是由哪个测试方法导致的：

FAIL: test_for_invalid_input_shows_error_on_page
(lists.tests.test_views.ListViewTest)
AssertionError: False is not true : Couldn't find 'You can&#39;t have an empty
list item' in response

现在，试试能否使用 ItemForm 表单重写视图。第一次尝试：

lists/views.py

def view_list(request, list_id):
    list_ = List.objects.get(id=list_id)
    form = ItemForm()
    if request.method == 'POST':
        form = ItemForm(data=request.POST)
        if form.is_valid():
            Item.objects.create(text=request.POST['text'], list=list_)
            return redirect(list_)
    return render(request, 'list.html', {'list': list_, "form": form})

重写后，单元测试通过了：

Ran 23 tests in 0.086s

OK

再看功能测试的结果如何：

ERROR: test_cannot_add_empty_list_items
(functional_tests.test_list_item_validation.ItemValidationTest)
 ---------------------------------------------------------------------
Traceback (most recent call last):
File "/.../superlists/functional_tests/test_list_item_validation.py", line
15, in test_cannot_add_empty_list_items
[...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: .has-error

失败。

14.4.2　意想不到的好处：HTML5自带的客户端验证

这是怎么回事呢？我们在错误所处位置之前加上 time.sleep，看看会发生什么（如果愿意，也可以执行 manage.py runserver 命令，自己动手访问网站，如图 14-1 所示）。

图 14-1：HTML5 验证失败

看起来输入框为空时，浏览器禁止用户提交表单。

这是因为 Django 为那个 HTML 输入框添加了 required 属性 1。（不相信？再看一下前面的 as_p() 输出。）这是 HTML5 的新特性，浏览器会在客户端做些验证，输入无效时禁止用户提交表单。

1这是 Django 1.11 的新特性。

下面据此修改功能测试：

functional_tests/test_list_item_validation.py (ch11l032)

    def test_cannot_add_empty_list_items(self):
        # 伊迪丝访问首页，不小心提交了一个空待办事项
        # 输入框中没输入内容，她就按下了回车键
        self.browser.get(self.live_server_url)
        self.get_item_input_box().send_keys(Keys.ENTER)

        # 浏览器截获了请求
        # 清单页面不会加载
        self.wait_for(lambda: self.browser.find_elements_by_css_selector(
            '#id_text:invalid'  ➊
        ))

        # 她在待办事项中输入了一些文字
        # 错误消失了
        self.get_item_input_box().send_keys('Buy milk')
        self.wait_for(lambda: self.browser.find_elements_by_css_selector(
            '#id_text:valid'  ➋
        ))

        # 现在能提交了
        self.get_item_input_box().send_keys(Keys.ENTER)
        self.wait_for_row_in_list_table('1: Buy milk')

        # 她有点儿调皮，打算再提交一个空待办事项
        self.get_item_input_box().send_keys(Keys.ENTER)

        # 浏览器这次也不会放行
        self.wait_for_row_in_list_table('1: Buy milk')
        self.wait_for(lambda: self.browser.find_elements_by_css_selector(
            '#id_text:invalid'
        ))

        # 输入一些文字后就能纠正这个错误
        self.get_item_input_box().send_keys('Make tea')
        self.wait_for(lambda: self.browser.find_elements_by_css_selector(
            '#id_text:valid'
        ))
        self.get_item_input_box().send_keys(Keys.ENTER)
        self.wait_for_row_in_list_table('1: Buy milk')
        self.wait_for_row_in_list_table('2: Make tea')

➊ 不再检查我们自定义的错误消息，而是通过 CSS 伪选择符 :invalid 检查。这个伪选择符是浏览器为输入无效内容的 HTML5 输入框添加的。

➋ 输入有效的内容时，伪选择符逆转。

看到 self.wait_for 函数多么有用、多么灵活了吗？

现在的功能测试与刚开始时区别很大，我相信此时此刻你有很多疑问。先别急，我会说明的。先来看看测试是否又能通过了：

$ python manage.py test functional_tests
[...]
....
 ---------------------------------------------------------------------
Ran 4 tests in 12.154s

OK

14.5　值得鼓励

首先，给自己一个大大的肯定：我们刚刚完成了这个小型应用中的一项重要修改——那个输入框，以及它的 name 和 id 属性，对应用正常运行至关重要。我们修改了七八个文件，完成了一次工作量很大的重构。如果没有测试，做这么复杂的重构我一定会担心，甚至有可能觉得没必要再去改动可以使用的代码。可是，我们有一套完整的测试组件，所以可以深入研究、整理代码，这些操作都很安全，因为我们知道如果有错误，测试能发现。所以我们会不断重构、整理和维护代码，确保整个应用的代码干净整洁，运行起来毫无障碍、准确无误，而且功能完善。

现在是提交的绝佳时刻：

$ git diff
$ git commit -am "use form in all views, back to working state"

14.6　这难道不是浪费时间吗

如果这样的话，我们自定义的错误消息还有什么用呢？我们在 HTML 模板中费这么大力气渲染表单都是无用功吗？如果在产生错误之前，浏览器就截获了请求，Django 根本无法把错误呈现到用户面前，功能测试也就无从测试。

好吧，你说得对。但是我们的时间并没有浪费，原因有三个。首先，客户端验证不能百分百阻止无效输入。如果你真的在意数据完整性，就必须使用服务器端验证，而这部分逻辑很适合封装在表单中。

其次，不是所有浏览器（咳，Safari）都完全支持 HTML5，有些用户还是能看到我们自定义的消息的。而且，如果我们打算让用户通过 API 访问数据（参见附录 F），验证消息也会回送给用户。

此外，下一章将重用这里的验证、表单代码以及前端 .has-error 类，实现一些 HTML5 没有的高级验证。

话说回来，就算没有这些理由，你也不用为编程时走错路了而责怪自己。没人能预见未来，我们的目标是找出正确的解决方案，不惜“浪费”时间在错误的方案上。

14.7　使用表单自带的save方法

我们还可以进一步简化视图。前面说过，表单可以把数据存入数据库。我们遇到的情况并不能直接保存数据，因为需要知道把待办事项保存到哪个清单中，不过解决起来也不难。

一如既往，先编写测试。为了查明遇到的问题，先看一下如果直接调用 form.save() 会发生什么：

lists/tests/test_forms.py (ch11l033)

    def test_form_save_handles_saving_to_a_list(self):
        form = ItemForm(data={'text': 'do me'})
        new_item = form.save()

Django 报错了，因为待办事项必须隶属于某个清单：

django.db.utils.IntegrityError: NOT NULL constraint failed: lists_item.list_id

这个问题的解决办法是告诉表单的 save 方法，应该把待办事项保存到哪个清单中：

lists/tests/test_forms.py

from lists.models import Item, List
[...]

    def test_form_save_handles_saving_to_a_list(self):
        list_ = List.objects.create()
        form = ItemForm(data={'text': 'do me'})
        new_item = form.save(for_list=list_)
        self.assertEqual(new_item, Item.objects.first())
        self.assertEqual(new_item.text, 'do me')
        self.assertEqual(new_item.list, list_)

然后，要保证待办事项能顺利存入数据库，而且各个属性的值都正确：

TypeError: save() got an unexpected keyword argument 'for_list'

可以定制 save 方法，实现方式如下：

lists/forms.py (ch11l035)

    def save(self, for_list):
        self.instance.list = for_list
        return super().save()

表单的 .instance 属性是将要修改或创建的数据库对象。我也是在撰写本章时才知道这种用法的。此外还有很多方法，例如自己手动创建数据库对象，或者调用 save() 方法时指定参数 commit=False，但我觉得使用 .instance 属性最简洁。下一章还会介绍一种方法，让表单知道它应用于哪个清单。

Ran 24 tests in 0.086s
OK

最后，要重构视图。先重构 new_list：

lists/views.py

def new_list(request):
    form = ItemForm(data=request.POST)
    if form.is_valid():
        list_ = List.objects.create()
        form.save(for_list=list_)
        return redirect(list_)
    else:
        return render(request, 'home.html', {"form": form})

然后运行测试，确保都能通过：

Ran 24 tests in 0.086s
OK

接着重构 view_list：

lists/views.py

def view_list(request, list_id):
    list_ = List.objects.get(id=list_id)
    form = ItemForm()
    if request.method == 'POST':
        form = ItemForm(data=request.POST)
        if form.is_valid():
            form.save(for_list=list_)
            return redirect(list_)
    return render(request, 'list.html', {'list': list_, "form": form})

修改之后，单元测试仍能全部通过：

Ran 24 tests in 0.111s
OK

功能测试也能通过：

Ran 4 tests in 14.367s
OK

太棒了！现在这两个视图更像是“正常的”Django 视图了：从用户的请求中读取数据，结合一些定制的逻辑或 URL 中的信息（list_id），然后把数据传入表单进行验证，如果通过验证就保存数据，最后重定向或者渲染模板。

表单和验证在 Django 以及常规的 Web 编程中都很重要，下一章要看一下能否编写稍微复杂的表单。

小贴士

简化视图

如果发现视图很复杂，要编写很多测试，这时候就应该考虑是否能把逻辑移到其他地方。可以移到表单中，就像本章中的做法一样；也可以移到模型类的自定义方法中。如果应用本身就很复杂，可以把核心业务逻辑移到 Django 专属的文件之外，编写单独的类和函数。

一个测试只测试一件事

如果一个测试中不止一个断言，你就要怀疑这么写是否合理。有时断言之间联系紧密，可以放在一起。不过第一次编写测试时往往都会测试很多表现，其实应该把它们分成多个测试。辅助函数有助于简化拆分后的测试。

第 15 章　高级表单

接下来，你将看到表单的一些高级用法。我们已经帮助用户避免输入空待办事项，接下来要避免用户输入重复的待办事项。

本章将进一步介绍 Django 表单验证的细节。如果你已经完全了解如何定制 Django 表单，或者你阅读本书的目的是学习 TDD 而不是 Django，那就可以跳过本章。

如果你还想接着学习 Django，本章有些值得学习的重要知识。如果你想跳过本章也可以，不过一定要快速阅读关于开发者犯错的框注和本章末尾对视图测试的总结。

15.1　针对重复待办事项的功能测试

在 ItemValidationTest 类中再添加一个测试方法：

functional_tests/test_list_item_validation.py (ch13l001)

def test_cannot_add_duplicate_items(self):
    # 伊迪丝访问首页，新建一个清单
    self.browser.get(self.live_server_url)
    self.get_item_input_box().send_keys('Buy wellies')
    self.get_item_input_box().send_keys(Keys.ENTER)
    self.wait_for_row_in_list_table('1: Buy wellies')

    # 她不小心输入了一个重复的待办事项
    self.get_item_input_box().send_keys('Buy wellies')
    self.get_item_input_box().send_keys(Keys.ENTER)

    # 她看到一条有帮助的错误消息
    self.wait_for(lambda: self.assertEqual(
        self.browser.find_element_by_css_selector('.has-error').text,
        "You've already got this in your list"
    ))

为什么编写两个测试方法，而不直接在原来的基础上扩展，或者新建一个文件和类？要自己判断该怎么做。这两种方法看起来联系紧密，都和同一个输入字段的验证有关，所以放在同一个文件中没问题。另一方面，这两种方法在逻辑上互相独立，所以将它们设为不同的两种不同的方法是可行的：

$ python manage.py test functional_tests.test_list_item_validation
[...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: .has-error

Ran 2 tests in 9.613s

好的，这两个测试中的第一个现在可以通过。你可能会问：“有没有办法只运行那个失败的测试？”确实有：

$ python manage.py test functional_tests.\
test_list_item_validation.ItemValidationTest.test_cannot_add_duplicate_items
[...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: .has-error

15.1.1　在模型层禁止重复

这是我们真正要做的事情。编写一个新测试，检查同一个清单中有重复的待办事项时是否抛出异常：

lists/tests/test_models.py (ch09l028)

def test_duplicate_items_are_invalid(self):
    list_ = List.objects.create()
    Item.objects.create(list=list_, text='bla')
    with self.assertRaises(ValidationError):
        item = Item(list=list_, text='bla')
        item.full_clean()

此外，还要再添加一个测试，确保完整性约束不要做过头了：

lists/tests/test_models.py (ch09l029)

def test_CAN_save_same_item_to_different_lists(self):
    list1 = List.objects.create()
    list2 = List.objects.create()
    Item.objects.create(list=list1, text='bla')
    item = Item(list=list2, text='bla')
    item.full_clean() # 不该抛出异常

我总喜欢在检查某项操作不该抛出异常的测试中加入一些注释，要不然很难看出在测试什么。

AssertionError: ValidationError not raised

如果想故意犯错，可以这么做：

lists/models.py (ch09l030)

class Item(models.Model):
    text = models.TextField(default='', unique=True)
    list = models.ForeignKey(List, default=None)

这么做可以确认第二个测试确实能检测到这个问题：

Traceback (most recent call last):
  File "/.../superlists/lists/tests/test_models.py", line 62, in
test_CAN_save_same_item_to_different_lists
    item.full_clean() # 不该抛出异常
    [...]
django.core.exceptions.ValidationError: {'text': ['Item with this Text already
exists.']}

何时测试开发者犯下的错误

测试时要判断何时应该编写测试确认我们没有犯错。一般而言，做决定时要谨慎。

这里，编写测试确认无法把重复的待办事项存入同一个清单。目前，让这个测试通过最简单的方法（即编写的代码量最少）是，让表单无法保存任何重复的待办事项。此时就要编写另一个测试，因为我们编写的代码可能有错。

但是，不可能编写测试检查所有可能出错的方式。如果有一个函数计算两数之和，可以编写一些测试：

assert adder(1, 1) == 2
assert adder(2, 1) == 3

但不应该认为实现这个函数时故意编写了有违常理的代码：

def adder(a, b):
    # 不可能这么写！
    if a == 3:
        return 666
    else:
        return a + b

判断时你要相信自己不会故意犯错，只会不小心犯错。

模型和 ModelForm 一样，也能使用 class Meta。在 Meta 类中可以实现一个约束，要求清单中的待办事项必须是唯一的。也就是说，text 和 list 的组合必须是唯一的：

lists/models.py (ch09l031)

class Item(models.Model):
    text = models.TextField(default='')
    list = models.ForeignKey(List, default=None)

    class Meta:
        unique_together = ('list', 'text')

此时，你可能想快速浏览一遍 Django 文档中对模型属性 Meta 的说明。

15.1.2　题外话：查询集合排序和字符串表示形式

运行测试，会看到一个意料之外的失败：

======================================================================
FAIL: test_saving_and_retrieving_items
(lists.tests.test_models.ListAndItemModelsTest)
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "/.../superlists/lists/tests/test_models.py", line 31, in
test_saving_and_retrieving_items
    self.assertEqual(first_saved_item.text, 'The first (ever) list item')
AssertionError: 'Item the second' != 'The first (ever) list item'
- Item the second
[...]

　根据所用系统和 SQLite 版本的不同，你可能看不到这个错误。如果没看到就直接阅读下一节，代码和测试本身也很有趣。

失败消息有点儿晦涩。输出一些信息，以便调试：

lists/tests/test_models.py

    first_saved_item = saved_items[0]
    print(first_saved_item.text)
    second_saved_item = saved_items[1]
    print(second_saved_item.text)
    self.assertEqual(first_saved_item.text, 'The first (ever) list item')

然后，看到的测试结果如下：

.....Item the second
The first (ever) list item
F.....

看样子唯一性约束干扰了查询（例如 Item.objects.all()）的默认排序。虽然现在仍有测试失败，但最好添加一个新测试明确测试排序：

lists/tests/test_models.py (ch09l032)

    def test_list_ordering(self):
        list1 = List.objects.create()
        item1 = Item.objects.create(list=list1, text='i1')
        item2 = Item.objects.create(list=list1, text='item 2')
        item3 = Item.objects.create(list=list1, text='3')
        self.assertEqual(
            Item.objects.all(),
            [item1, item2, item3]
        )

测试的结果多了一个失败，而且也不易读：

AssertionError: <QuerySet [<Item: Item object>, <Item: Item object>, <Item:
Item object>]> != [<Item: Item object>, <Item: Item object>, <Item: Item
object>]

我们的对象需要一个更好的字符串表示形式。下面再添加一个单元测试：

　如果已经有测试失败，还要再添加更多的失败测试，通常都要三思而后行，因为这么做会让测试的输出变得更复杂，而且往往你都会担心：“还能回到正常运行的状态吗？”这里，测试都很简单，所以我不担忧。

lists/tests/test_models.py (ch13l008)

def test_string_representation(self):
    item = Item(text='some text')
    self.assertEqual(str(item), 'some text')

测试的结果为：

AssertionError: 'Item object' != 'some text'

连同另外两个失败，现在开始一并解决：

lists/models.py (ch09l034)

class Item(models.Model):
    [...]

    def __str__(self):
        return self.text

　在 Python 2.x 的 Django 版本中，字符串表示形式使用 unicode 方法定制。和很多字符串处理方式一样，Python 3 对此做了简化。参见文档（https://docs.djangoproject.com/en/1.11/topics/python3/#str-and-unicode-methods）。

现在只剩两个失败测试了，而且排序测试的失败消息更易读了：

AssertionError: <QuerySet [<Item: i1>, <Item: item 2>, <Item: 3>]> != [<Item:
i1>, <Item: item 2>, <Item: 3>]

可以在 class Meta 中解决这个问题：

lists/models.py (ch09l035)

    class Meta:
        ordering = ('id',)
        unique_together = ('list', 'text')

这么做有用吗？

AssertionError: <QuerySet [<Item: i1>, <Item: item 2>, <Item: 3>]> != [<Item:
i1>, <Item: item 2>, <Item: 3>]

呃，确实有用，从测试结果中可以看到，顺序是一样的，只不过测试没分清。其实我一直会遇到这个问题，因为 Django 中的查询集合不能和列表正确比较。可以在测试中把查询集合转换成列表 1 解决这个问题：

1也可以考虑使用 unittest 中的 assertSequenceEqual，以及 Django 测试工具中的 assertQuerysetEqual。不过我承认，之前我并没搞清楚怎么使用 assertQuerysetEqual。

lists/tests/test_models.py (ch09l036)

    self.assertEqual(
        list(Item.objects.all()),
        [item1, item2, item3]
    )

这样就可以了，整个测试组件都能通过：

OK

15.1.3　重写旧模型测试

虽然冗长的模型测试无意间帮我们发现了一个问题，但现在要重写模型测试。重写的过程中我会讲得很详细，因为借此机会要介绍 Django ORM。既然我们已经编写了专门测试排序的测试，现在就可以使用一些较短的测试达到相同的覆盖度。删除 test_saving_and_retrieving_items，换成：

lists/tests/test_models.py (ch13l010)

class ListAndItemModelsTest(TestCase):

    def test_default_text(self):
        item = Item()
        self.assertEqual(item.text, '')

    def test_item_is_related_to_list(self):
        list_ = List.objects.create()
        item = Item()
        item.list = list_
        item.save()
        self.assertIn(item, list_.item_set.all())

    [...]

这么改绰绰有余。初始化一个全新的模型对象，检查属性的默认值，这么做足以确认 models.py 中是否正确设定了一些字段。test_item_is_related_to_list 其实是双重保险，确认外键关联是否正常。

借此机会，还要把这个文件中的内容分成专门针对 Item 和 List 的测试（后者只有一个测试方法，即 test_get_absolute_url）：

lists/tests/test_models.py (ch13l011)

class ItemModelTest(TestCase):

    def test_default_text(self):
        [...]

class ListModelTest(TestCase):

    def test_get_absolute_url(self):
        [...]

修改之后代码更整洁。测试结果如下：

$ python manage.py test lists
[...]
Ran 29 tests in 0.092s

OK

15.1.4　保存时确实会显示完整性错误

在继续之前还有一个题外话要说。我在第 13 章提到过，保存数据时会出现一些数据完整性错误，还记得吗？是否出现完整性错误完全取决于完整性约束是否由数据库执行。

执行 makemigrations 命令试试，你会看到，Django 除了把 unique_together 作为应用层约束之外，还想把它加到数据库中：

$ python manage.py makemigrations
Migrations for 'lists':
  lists/migrations/0005_auto_20140414_2038.py
    - Change Meta options on item
    - Alter unique_together for item (1 constraint(s))

现在，修改检查重复待办事项的测试，把 .full_clean 改成 .save：

lists/tests/test_models.py

    def test_duplicate_items_are_invalid(self):
        list_ = List.objects.create()
        Item.objects.create(list=list_, text='bla')
        with self.assertRaises(ValidationError):
            item = Item(list=list_, text='bla')
            # item.full_clean()
            item.save()

测试的结果为：

ERROR: test_duplicate_items_are_invalid (lists.tests.test_models.ItemModelTest)
[...]
    return Database.Cursor.execute(self, query, params)
sqlite3.IntegrityError: UNIQUE constraint failed: lists_item.list_id,
lists_item.text
[...]
django.db.utils.IntegrityError: UNIQUE constraint failed: lists_item.list_id,
lists_item.text

可以看出，错误是由 SQLite 导致的，而且错误类型也和我们期望的不一样，我们想得到的是 ValidationError，实际却是 IntegrityError。

把改动改回去，让测试全部通过：

$ python manage.py test lists
[...]
Ran 29 tests in 0.092s
OK

然后提交对模型层的修改：

$ git status # 会看到改动了测试和模型，还有一个新迁移文件
# 我们给新迁移文件起一个更好的名字
$ mv lists/migrations/0005_auto* lists/migrations/0005_list_item_unique_together.py
$ git add lists
$ git diff --staged
$ git commit -am "Implement duplicate item validation at model layer"

15.2　在视图层试验待办事项重复验证

运行功能测试，看看现在我们进展到哪里了：

selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: .has-error

运行功能测试时浏览器窗口一闪而过，你可能没看到网站现在处于 500 状态之中。2 简单的修改视图层单元测试应该能解决这个问题：

2显示一个服务器错误，响应码为 500。你要明白这些术语的意思。

lists/tests/test_views.py (ch13l014)

class ListViewTest(TestCase):
    [...]

    def test_for_invalid_input_shows_error_on_page(self):
        [...]

    def test_duplicate_item_validation_errors_end_up_on_lists_page(self):
        list1 = List.objects.create()
        item1 = Item.objects.create(list=list1, text='textey')
        response = self.client.post(
            f'/lists/{list1.id}/',
            data={'text': 'textey'}
        )

        expected_error = escape("You've already got this in your list")
        self.assertContains(response, expected_error)
        self.assertTemplateUsed(response, 'list.html')
        self.assertEqual(Item.objects.all().count(), 1)

测试结果为：

django.db.utils.IntegrityError: UNIQUE constraint failed: lists_item.list_id,
lists_item.text

我们不想让测试出现完整性错误！理想情况下，我们希望在尝试保存数据之前调用 is_valid 时，已经注意到有重复。不过在此之前，表单必须知道待办事项属于哪个清单。

现在暂时为这个测试加上 @skip 修饰器：

lists/tests/test_views.py (ch13l015)

from unittest import skip
[...]

    @skip
    def test_duplicate_item_validation_errors_end_up_on_lists_page(self):

15.3　处理唯一性验证的复杂表单

新建清单的表单只需知道一件事，即新待办事项的文本。为了验证清单中的代办事项是否唯一，表单需要知道使用哪个清单以及待办事项的文本。就像前面我们在 ItemForm 类中定义 save 方法一样，这一次要重定义表单的构造方法，让它知道待办事项属于哪个清单。

复制前一个表单的测试，稍微做些修改：

lists/tests/test_forms.py (ch13l016)

from lists.forms import (
    DUPLICATE_ITEM_ERROR, EMPTY_ITEM_ERROR,
    ExistingListItemForm, ItemForm
)
[...]

class ExistingListItemFormTest(TestCase):

    def test_form_renders_item_text_input(self):
        list_ = List.objects.create()
        form = ExistingListItemForm(for_list=list_)
        self.assertIn('placeholder="Enter a to-do item"', form.as_p())

    def test_form_validation_for_blank_items(self):
        list_ = List.objects.create()
        form = ExistingListItemForm(for_list=list_, data={'text': ''})
        self.assertFalse(form.is_valid())
        self.assertEqual(form.errors['text'], [EMPTY_ITEM_ERROR])

    def test_form_validation_for_duplicate_items(self):
        list_ = List.objects.create()
        Item.objects.create(list=list_, text='no twins!')
        form = ExistingListItemForm(for_list=list_, data={'text': 'no twins!'})
        self.assertFalse(form.is_valid())
        self.assertEqual(form.errors['text'], [DUPLICATE_ITEM_ERROR])

要历经几次 TDD 循环，最后才能得到一个自定义的构造方法。这个构造方法会忽略 for_list 参数。（我不会写出全部过程，但我相信你会做完的，对吗？记住，测试山羊能看到一切。）

lists/forms.py (ch09l071)

DUPLICATE_ITEM_ERROR = "You've already got this in your list"
[...]
class ExistingListItemForm(forms.models.ModelForm):
    def __init__(self, for_list, *args, **kwargs):
        super().__init__(*args, **kwargs)

现阶段的错误应该是：

ValueError: ModelForm has no model class specified.

接下来，让这个表单继承现有的表单，看测试能不能通过：

lists/forms.py (ch09l072)

class ExistingListItemForm(ItemForm):
    def __init__(self, for_list, *args, **kwargs):
        super().__init__(*args, **kwargs)

能通过，现在只剩一个失败测试了：

FAIL: test_form_validation_for_duplicate_items
(lists.tests.test_forms.ExistingListItemFormTest)
    self.assertFalse(form.is_valid())
AssertionError: True is not false

下面这一步需要了解一点 Django 内部运作机制，你可以阅读 Django 文档中对模型验证和表单验证的介绍。

Django 在表单和模型中都会调用 validate_unique 方法，借助 instance 属性在表单的 validate_unique 方法中调用模型的 validate_unique 方法：

lists/forms.py

from django.core.exceptions import ValidationError
[...]

class ExistingListItemForm(ItemForm):

    def __init__(self, for_list, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.instance.list = for_list
    def validate_unique(self):
        try:
            self.instance.validate_unique()
        except ValidationError as e:
            e.error_dict = {'text': [DUPLICATE_ITEM_ERROR]}
            self._update_errors(e)

这段代码用到了一点 Django 魔法，先获取验证错误，修改错误消息之后再把错误传回表单。

任务完成，做个简单的提交：

$ git diff
$ git commit -a

15.4　在清单视图中使用ExistingListItemForm

现在看一下能否在视图中使用这个表单。

删掉测试方法的 @skip 修饰器，同时使用前一节创建的常量清理测试。

lists/tests/test_views.py (ch13l049)

from lists.forms import (
    DUPLICATE_ITEM_ERROR, EMPTY_ITEM_ERROR,
    ExistingListItemForm, ItemForm,
)
[...]

    def test_duplicate_item_validation_errors_end_up_on_lists_page(self):
        [...]
        expected_error = escape(DUPLICATE_ITEM_ERROR)

修改之后完整性错误又出现了：

django.db.utils.IntegrityError: UNIQUE constraint failed: lists_item.list_id,
lists_item.text

解决方法是使用前一节定义的表单类。在此之前，先找到检查表单类的测试，然后按照下面的方式修改：

lists/tests/test_views.py (ch13l050)

class ListViewTest(TestCase):
[...]

    def test_displays_item_form(self):
        list_ = List.objects.create()
        response = self.client.get(f'/lists/{list_.id}/')
        self.assertIsInstance(response.context['form'], ExistingListItemForm)
        self.assertContains(response, 'name="text"')

    [...]

    def test_for_invalid_input_passes_form_to_template(self):
        response = self.post_invalid_input()
        self.assertIsInstance(response.context['form'], ExistingListItemForm)

修改之后测试的结果为：

AssertionError: <ItemForm bound=False, valid=False, fields=(text)> is not an
instance of <class 'lists.forms.ExistingListItemForm'>

那么就可以修改视图了：

lists/views.py (ch13l051)

from lists.forms import ExistingListItemForm, ItemForm
[...]
def view_list(request, list_id):
    list_ = List.objects.get(id=list_id)
    form = ExistingListItemForm(for_list=list_)
    if request.method == 'POST':
        form = ExistingListItemForm(for_list=list_, data=request.POST)
        if form.is_valid():
            form.save()
            [...]

问题几乎都解决了，但又出现了一个意料之外的失败：

TypeError: save() missing 1 required positional argument: 'for_list'

不再需要使用父类 ItemForm 中自定义的 save 方法。为此，先编写一个单元测试：

lists/tests/test_forms.py (ch13l053)

def test_form_save(self):
    list_ = List.objects.create()
    form = ExistingListItemForm(for_list=list_, data={'text': 'hi'})
    new_item = form.save()
    self.assertEqual(new_item, Item.objects.all()[0])

可以让表单调用祖父类中的 save 方法：

lists/forms.py (ch13l054)

    def save(self):
        return forms.models.ModelForm.save(self)

　个人观点：这里可以使用 super，但是有参数时我选择不用，例如获取祖父类中的方法。我觉得使用 Python 3 的 super() 方法获取直接父类很棒，但其他用途太容易出错，而且写出的代码也不好看。你的观点可能与我不同。

搞定！所有单元测试都能通过：

$ python manage.py test lists
[...]
Ran 34 tests in 0.082s

OK

针对验证的功能测试也能通过：

$ python manage.py test functional_tests.test_list_item_validation
[...]
..
 ---------------------------------------------------------------------
Ran 2 tests in 12.048s

OK

检查的最后一步——运行所有功能测试：

$ python manage.py test functional_tests
[...]
.....
 ---------------------------------------------------------------------
Ran 5 tests in 19.048s

OK

太棒了，最后还要提交，再回顾一下之前所学的内容。

15.5　小结：目前所学的Django测试知识

我们的应用现在看起来更像是“标准的”Django 应用了，它实现了 Django 常见的三层：模型、表单和视图。测试不再是“试水式”的了，代码也更像是实际应用中应该有的样子了。

每个关键的源码文件都对应一个单元测试文件，下面回顾一下内容最多（层级也最高）的那个，即 test_views（下述代码清单只列出了关键测试和断言）。

如何测试视图

lists/tests/test_views.py

class ListViewTest(TestCase):
  def test_uses_list_template(self):
      response = self.client.get(f'/lists/{list_.id}/') ➊
      self.assertTemplateUsed(response, 'list.html') ➋
  def test_passes_correct_list_to_template(self):
      self.assertEqual(response.context['list'], correct_list) ➌
  def test_displays_item_form(self):
      self.assertIsInstance(response.context['form'], ExistingListItemForm) ➍
      self.assertContains(response, 'name="text"')
  def test_displays_only_items_for_that_list(self):
      self.assertContains(response, 'itemey 1') ❺
      self.assertContains(response, 'itemey 2') ❺
      self.assertNotContains(response, 'other list item 1') ❺
  def test_can_save_a_POST_request_to_an_existing_list(self):
      self.assertEqual(Item.objects.count(), 1) ❻
      self.assertEqual(new_item.text, 'A new item for an existing list') ❻
  def test_POST_redirects_to_list_view(self):
      self.assertRedirects(response, f'/lists/{correct_list.id}/') ❻
  def test_for_invalid_input_nothing_saved_to_db(self):
      self.assertEqual(Item.objects.count(), 0) ❻
  def test_for_invalid_input_renders_list_template(self):
      self.assertEqual(response.status_code, 200)
      self.assertTemplateUsed(response, 'list.html') ❻
  def test_for_invalid_input_passes_form_to_template(self):
      self.assertIsInstance(response.context['form'], ExistingListItemForm) ❼
  def test_for_invalid_input_shows_error_on_page(self):
      self.assertContains(response, escape(EMPTY_ITEM_ERROR)) ❼
  def test_duplicate_item_validation_errors_end_up_on_lists_page(self):
      self.assertContains(response, expected_error)
      self.assertTemplateUsed(response, 'list.html')
      self.assertEqual(Item.objects.all().count(), 1)

❶ 使用 Django 测试客户端。

❷ 检查使用的模板。然后在模板的上下文中检查各个待办事项。

❸ 检查每个对象都是希望得到的，或者查询集合中包含正确的待办事项。

❹ 检查表单使用正确的类。

❺ 检查测试模板逻辑：每个 for 和 if 语句都要做最简单的测试。

❻ 对于处理 POST 请求的视图，确保有效和无效两种情况都要测试。

❼ 健全性检查（可选），检查是否渲染指定的表单，而且是否显示错误消息。

为什么要测试这么多？请阅读附录 B。使用基于类的视图重构时，这些测试能保证视图仍然可以正常运行。

接下来要尝试使用一些客户端代码让数据验证更友好。我想你知道这是什么意思。

第 16 章　试探JavaScript

“如果上帝想让我们享受生活，就不会把他无尽的痛苦当作珍贵的礼物赠与我们。”

——John Calvin1

1Calvin and the Chipmunk 中的角色。

虽然前面实现的验证逻辑很好，但当用户开始修正问题时，让待办事项重复的错误消息消失，就像 HTML5 验证错误那样，不是更好吗？为了达到这个效果，需要使用少量的 JavaScript。

每天使用 Python 这种充满乐趣的语言编程完全把我们宠坏了。JavaScript 是给我们的惩罚。对 Web 开发者而言，这是绕不开的话题。接下来要十分谨慎地试探如何使用 JavaScript。

　我假设你知道基本的 JavaScript 句法。如果你还没读过《JavaScript 语言精粹》，现在就买一本吧！这本书并不厚。

16.1　从功能测试开始

在 ItemValidationTest 类中添加一个新的功能测试：

functional_tests/test_list_item_validation.py (ch14l001)

def test_error_messages_are_cleared_on_input(self):
    # 伊迪丝新建一个清单，但方法不当，所以出现了一个验证错误
    self.browser.get(self.live_server_url)
    self.get_item_input_box().send_keys('Banter too thick')
    self.get_item_input_box().send_keys(Keys.ENTER)
    self.wait_for_row_in_list_table('1: Banter too thick')
    self.get_item_input_box().send_keys('Banter too thick')
    self.get_item_input_box().send_keys(Keys.ENTER)

    self.wait_for(lambda: self.assertTrue(  ➊
        self.browser.find_element_by_css_selector('.has-error').is_displayed()  ➋
    ))

    # 为了消除错误，她开始在输入框中输入内容
    self.get_item_input_box().send_keys('a')

    # 看到错误消息消失了，她很高兴
    self.wait_for(lambda: self.assertFalse(
        self.browser.find_element_by_css_selector('.has-error').is_displayed()  ➋
    ))

❶ 又用到了 wait_for，又一次传入 assertTrue。

❷ is_displayed() 可检查元素是否可见。不能只靠检查元素是否存在于 DOM 中去判断，因为现在要开始隐藏元素了。

无疑，这个测试会失败。但在继续之前，要应用“事不过三，三则重构”原则，因为多次使用 CSS 查找错误消息元素。把这个操作移到一个辅助函数中：

functional_tests/test_list_item_validation.py (ch14l002)

class ItemValidationTest(FunctionalTest):

    def get_error_element(self):
        return self.browser.find_element_by_css_selector('.has-error')

    [...]

　我喜欢把辅助函数放在使用它们的功能测试类中，仅当辅助函数需要在别处使用时才放在基类中，以防止基类太臃肿。这就是 YAGNI 原则。

然后，在 test_list_item_validation.py 中做三次替换，例如：

functional_tests/test_list_item_validation.py (ch14l003)

    self.wait_for(lambda: self.assertEqual(
        self.get_error_element().text,
        "You've already got this in your list"
    ))
[...]
    self.wait_for(lambda: self.assertTrue(
        self.get_error_element().is_displayed()
    ))
[...]
    self.wait_for(lambda: self.assertFalse(
        self.get_error_element().is_displayed()
    ))

得到了一个预期错误：

$ python manage.py test functional_tests.test_list_item_validation
[...]
    self.get_error_element().is_displayed()
AssertionError: True is not false

可以提交这些代码，作为对功能测试的首次改动。

16.2　安装一个基本的JavaScript测试运行程序

在 Python 和 Django 领域中选择测试工具非常简单。标准库中的 unittest 模块完全够用了，而且 Django 测试运行程序也是一个不错的默认选择。除此之外，还有一些替代工具——nose 很受欢迎，Green 是新推出的，我个人对 pytest 的印象比较深刻。不过默认选项很不错，已能满足需求。2

2无可否认，一旦开始找 Python BDD 工具，情况会稍微复杂一些。

在 JavaScript 领域，情况就不一样了。我们在工作中使用 YUI，但我觉得我应该走出去看看有没有其他新推出的工具。我被如此多的选项淹没了——jsUnit、Qunit、Mocha、Chutzpah、Karma、Testacular、Jasmine 等。而且还不仅限于此：选中其中一个工具后（例如 Mocha3），我还得选择一个断言框架和报告程序，或许还要选择一个模拟技术库——永远没有终点。

3纯粹是因为 Mocha 提供了 NyanCat 测试运行程序。

最终，我决定我们应该使用 QUnit，因为它简单，跟 Python 单元测试很像，而且能很好地和 jQuery 配合使用。

在 lists/static 中新建一个目录，将其命名为 tests，把 QUnit JavaScript 和 CSS 两个文件下载到该目录。我们还要在该目录中放入一个 tests.html 文件：

$ tree lists/static/tests/
lists/static/tests/
├── qunit-2.0.1.css
├── qunit-2.0.1.js
└── tests.html

QUnit 的 HTML 样板文件内容如下，其中包含一个冒烟测试：

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width">
  <title>Javascript tests</title>
  <link rel="stylesheet" href="qunit-2.0.1.css">
</head>
<body>
  <div id="qunit"></div>
  <div id="qunit-fixture"></div>

  <script src="qunit-2.0.1.js"></script>

  <script>

QUnit.test("smoke test", function (assert) {
  assert.equal(1, 1, "Maths works!");
});

  </script>
</body>
</html>

仔细分析这个文件时，要注意几个重要的问题：使用第一个 <script> 标签引入 qunit-2.0.1，然后在第二个 <script> 标签中编写测试的主体。

如果在 Web 浏览器中打开这个文件（不用运行开发服务器，在硬盘中找到这个文件即可），会看到类似图 16-1 所示的页面。

图 16-1：QUnit 的基本界面

查看测试代码会发现，和我们目前编写的 Python 测试有很多相似之处：

QUnit.test("smoke test", function (assert) { ➊
    assert.equal(1, 1, "Maths works!"); ➋
});

❶ QUnit.test 函数定义一个测试用例，有点儿类似 Python 中的 def test_something(self)。test 函数的第一个参数是测试名，第二个参数是一个函数，定义这个测试的主体。

❷ assert.equal 函数是一个断言，和 assertEqual 非常像，比较两个参数的值。不过，和在 Python 中不同，不管失败还是通过都会显示消息，所以消息应该使用肯定式而不是否定式。

为什么不修改这些参数，故意让测试失败，看看效果如何呢？

16.3　使用jQuery和<div>固件元素

下面来摸索一下这个测试框架能做什么，并借此开始使用一些 jQuery（不可或缺的库，为操纵 DOM 提供跨浏览器兼容的 API）。

　如果你从未用过 jQuery，在行文的过程中我会尝试解说，以防你完全不懂。这不是 jQuery 教程，所以在阅读本章的过程中最好抽出一两个小时研究 jQuery。

从 jquery.com 下载新版 jQuery，保存到 lists/static 文件夹中。

下面在测试文件中使用 jQuery，并添加几个 HTML 元素。先试着显示和隐藏元素，并编写几个断言，检查元素的可见性：

lists/static/tests/tests.html

    <div id="qunit-fixture"></div>

    <form>  ➊
      <input name="text" />
      <div class="has-error">Error text</div>
    </form>

    <script src="../jquery-3.1.1.min.js"></script>  ➋
    <script src="qunit-2.0.1.js"></script>

    <script>

  QUnit.test("smoke test", function (assert) {
    assert.equal($('.has-error').is(':visible'), true);  ➌➍
    $('.has-error').hide();  ❺
    assert.equal($('.has-error').is(':visible'), false);  ❻
  });

    </script>

❶ <form> 及其中的内容放在那儿是为了表示真实的清单页面中的内容。

❷ 加载 jQuery。

❸ jQuery 魔法从这里开始！ $ 是 jQuery 的瑞士军刀，用来查找 DOM 中的内容。$ 的第一个参数是 CSS 选择符，要查找类为“has-error”的所有元素。查找得到的结果是一个对象，表示一个或多个 DOM 元素。然后，可以在这个对象上使用很多有用的方法处理或者查看这些元素。

❹ 其中一个方法是 .is，它的作用是指出某个元素的表现是否和指定的 CSS 属性匹配。这里使用 :visible 检查元素是否显示出来。

❺ 使用 jQuery 提供的 .hide() 方法隐藏这个 <div> 元素。其实，这个方法是在元素上动态设定 style="display: none" 属性。

❻ 最后，使用第二个 assert.equal 断言检查隐藏是否成功。

刷新浏览器后应该会看到所有测试都通过了。

在浏览器中期望 QUnit 得到的结果如下：

2 assertions of 2 passed, 0 failed.
1. smoke test (2)

下面要介绍如何使用固件（fixture）。直接复制测试：

lists/static/tests/tests.html

  <script>

QUnit.test("smoke test", function (assert) {
  assert.equal($('.has-error').is(':visible'), true);
  $('.has-error').hide();
  assert.equal($('.has-error').is(':visible'), false);
});
QUnit.test("smoke test 2", function (assert) {
  assert.equal($('.has-error').is(':visible'), true);
  $('.has-error').hide();
  assert.equal($('.has-error').is(':visible'), false);
});

  </script>

其中一个测试失败了，有点儿出乎预料，如图 16-2 所示。

图 16-2：两个测试中有一个失败了

测试失败的原因是，第一个测试把显示错误消息的 div 元素隐藏了，所以运行第二个测试时，一开始这个元素就是隐藏的。

　QUnit 中的测试不会按照既定的顺序运行，所以不要觉得第一个测试一定会在第二个测试之前运行。多刷新几次试试，你会发现失败的测试有变化。

我们需要一种方法在测试之间执行清理工作，有点儿类似于 setUp 和 tearDown，或者像 Django 测试运行程序一样，运行完每个测试后还原数据库。id 为 qunit-fixture 的 <div> 元素就是我们正在寻找的方法。把表单移到这个元素中：

lists/static/tests/tests.html

<div id="qunit"></div>
<div id="qunit-fixture">
    <form>
        <input name="text" />
        <div class="has-error">Error text</div>
    </form>
</div>

<script src="../jquery-3.1.1.min.js"></script>

你可能已经猜到了，每次运行测试前，jQuery 都会还原这个固件元素中的内容。因此，两个测试都能通过了：

4 assertions of 4 passed, 0 failed.
1. smoke test (2)
2. smoke test 2 (2)

16.4　为想要实现的功能编写JavaScript单元测试

现在我们已经熟悉这个 JavaScript 测试工具了，所以可以只留下一个测试，开始编写真正的测试代码了：

lists/static/tests/tests.html

  <script>

QUnit.test("errors should be hidden on keypress", function (assert) {
  $('input[name="text"]').trigger('keypress'); ➊
  assert.equal($('.has-error').is(':visible'), false);
});

  </script>

➊ jQuery 提供的 .trigger 方法主要用于测试，作用是在指定的元素上触发一个 JavaScript DOM 事件。这里使用的是 keypress 事件，当用户在指定的输入框中输入内容时，浏览器就会触发这个事件。

　这里 jQuery 隐藏了很多复杂的细节。不同浏览器之间处理事件的方式大不一样，详情请访问 Quirksmode.org。jQuery 之所以这么受欢迎就是因为它消除了这些差异。

这个测试的结果为：

0 assertions of 1 passed, 1 failed.
1. errors should be hidden on keypress (1, 0, 1)
    1. failed
        Expected: false
        Result: true

假设我们想把代码放在单独的 JavaScript 文件中，命名为 list.js：

lists/static/tests/tests.html

<script src="../jquery-3.1.1.min.js"></script>
<script src="../list.js"></script>
<script src="qunit-2.0.1.js"></script>

<script>
  [...]

若想让这个测试通过，所需的最简代码如下所示：

lists/static/list.js

$('.has-error').hide();

确实通过了：

1 assertions of 1 passed, 0 failed.
1. errors should be hidden on keypress (1)

但显然还有个问题，最好再添加一个测试：

lists/static/tests/tests.html

QUnit.test("errors should be hidden on keypress", function (assert) {
  $('input[name="text"]').trigger('keypress');
  assert.equal($('.has-error').is(':visible'), false);

});
QUnit.test("errors aren't hidden if there is no keypress", function (assert) {
  assert.equal($('.has-error').is(':visible'), true);
});

得到一个预期的失败：

1 assertions of 2 passed, 1 failed.
1. errors should be hidden on keypress (1)
2. errors aren't hidden if there is no keypress (1, 0, 1)
    1. failed
        Expected: true
        Result: false
[...]

然后，可以使用一种更真实的实现方式：

lists/static/list.js

$('input[name="text"]').on('keypress', function () {  ➊
  $('.has-error').hide();
});

➊ 这行代码的意思是：查找所有 name 属性为“text”的 input 元素，然后在找到的每个元素上附属一个事件监听器，作用在 keypress 事件上。事件监听器是那个行间函数，其作用是隐藏类为 .has-error 的所有元素。

这样能让测试通过吗？不能。

1 assertions of 2 passed, 1 failed.
1. errors should be hidden on keypress (1, 0, 1)
    1. failed
        Expected: false
        Result: true
[...]
2. errors aren't hidden if there is no keypress (1)

可恶！这是为什么呢？

16.5　固件、执行顺序和全局状态：JavaScript 测试的重大挑战

一般来说，JavaScript 的一大难点，尤其对测试而言，就是理解代码的执行顺序（何时发生什么）。我们想知道 list.js 中的代码何时运行，每个测试何时运行；我们还想知道代码的运行对全局状态（网页的 DOM）有何影响，以及每次测试后是如何清理固件的。

16.5.1　使用console.log打印调试信息

下面在测试中添加几个 console.log，打印调试信息：

lists/static/tests/tests.html

  <script>

console.log('qunit tests start');

QUnit.test("errors should be hidden on keypress", function (assert) {
  console.log('in test 1');
  $('input[name="text"]').trigger('keypress');
  assert.equal($('.has-error').is(':visible'), false);
});

QUnit.test("errors aren't hidden if there is no keypress", function (assert) {
  console.log('in test 2');
  assert.equal($('.has-error').is(':visible'), true);
});
  </script>

然后在 JavaScript 代码中也这样做：

lists/static/list.js (ch14l015)

$('input[name="text"]').on('keypress', function () {
  console.log('in keypress handler');
  $('.has-error').hide();
});
console.log('list.js loaded');

运行测试，打开浏览器的调试控制台（通常可按 Ctrl-Shift-I 组合键），你应该会看到类似图 16-3 所示的输出。

图 16-3：在 QUnit 测试中使用 console.log 输出的调试信息

我们看到了什么？

list.js 先被加载，因此事件监听器应该依附到输入元素上了。

然后加载 QUnit 测试文件。

最后运行各个测试。

但仔细一想，每个测试都会“还原”固件 div，也就是销毁输入元素后再重建。因此，每次运行测试时，list.js 看到并依附事件监听器的输入元素都是全新的。

16.5.2　使用初始化函数精确控制执行时间

我们需要进一步掌控 JavaScript 的执行顺序，而不是依赖 <script> 标签加载并运行 list.js 中的代码。为此，常见的做法是定义“初始化”函数，在测试（以及真实的场景）中需要的地方调用：

lists/static/list.js

var initialize = function () {
  console.log('initialize called');
  $('input[name="text"]').on('keypress', function () {
    console.log('in keypress handler');
    $('.has-error').hide();
  });
};
console.log('list.js loaded');

然后在测试文件中的每个测试中调用 initialize：

lists/static/tests/tests.html (ch14l017)

QUnit.test("errors should be hidden on keypress", function (assert) {
  console.log('in test 1');
  initialize();
  $('input[name="text"]').trigger('keypress');
  assert.equal($('.has-error').is(':visible'), false);
});

QUnit.test("errors aren't hidden if there is no keypress", function (assert) {
  console.log('in test 2');
  initialize();
  assert.equal($('.has-error').is(':visible'), true);
});

现在，测试能通过，而且调试输出更合理了：

2 assertions of 2 passed, 0 failed.
1. errors should be hidden on keypress (1)
2. errors aren't hidden if there is no keypress (1)

list.js loaded
qunit tests start
in test 1
initialize called
in keypress handler
in test 2
initialize called

太棒了！下面把 console.log 删掉：

lists/static/list.js

var initialize = function () {
  $('input[name="text"]').on('keypress', function () {
    $('.has-error').hide();
  });
};

把测试中的也删掉：

lists/static/tests/tests.html

QUnit.test("errors should be hidden on keypress", function (assert) {
  initialize();
  $('input[name="text"]').trigger('keypress');
  assert.equal($('.has-error').is(':visible'), false);
});

QUnit.test("errors aren't hidden if there is no keypress", function (assert) {
  initialize();
  assert.equal($('.has-error').is(':visible'), true);
});

关键时刻到了。现在引入 jQuery 和我们的脚本，在真实的页面中调用初始化函数：

lists/templates/base.html (ch14l020)

    </div>
    <script src="/static/jquery-3.1.1.min.js"></script>
    <script src="/static/list.js"></script>

    <script>
      initialize();
    </script>

  </body>
</html>

　习惯做法是在 HTML 的 body 元素末尾引入脚本；这样的话，用户无须等到所有 JavaScript 都加载完就能看到页面中的内容。此外，还能保证运行脚本前加载了大部分 DOM。

然后运行功能测试：

$ python manage.py test functional_tests.test_list_item_validation.\
ItemValidationTest.test_error_messages_are_cleared_on_input

[...]
Ran 1 test in 3.023s

OK

太棒了！做次提交：

$ git add lists/static
$ git commit -m "add jquery, qunit tests, list.js with keypress listeners"

16.6　经验做法：onload样板代码和命名空间

哦，还有一件事。initialize 这个函数名称太普通了，如果引入的某个第三方 JavaScript 工具也有名为 initialize 的函数呢？下面为其添加别人不太可能使用的“命名空间”：

lists/static/list.js

window.Superlists = {}; ➊
window.Superlists.initialize = function () { ➋
  $('input[name="text"]').on('keypress', function () {
    $('.has-error').hide();
  });
};

❶ 声明一个对象，作为“window”全局对象的属性，为其起一个别人不太可能使用的名称。

❷ 然后把 initialize 函数设为命名空间中那个对象的属性。

　若想在 JavaScript 中处理命名空间，还有很多更取巧的方式，不过都太复杂，而且我也不是专家，无法一一说明。如果想深入学习，请搜索 require.js——这似乎已经成为了标准做法，至少在当下是这样。

lists/static/tests/tests.html

  <script>
QUnit.test("errors should be hidden on keypress", function (assert) {
  window.Superlists.initialize();
  $('input[name="text"]').trigger('keypress');
  assert.equal($('.has-error').is(':visible'), false);
});

QUnit.test("errors aren't hidden if there is no keypress", function (assert) {
  window.Superlists.initialize();
  assert.equal($('.has-error').is(':visible'), true);
});
  </script>

最后，如果 JavaScript 需要和 DOM 交互，最好把相应的代码包含在 onload 样板代码中，确保在执行脚本之前完全加载了页面。目前的做法也能正常运行，因为 <script> 标签在页面底部，但不能依赖这种方式。

jQuery 提供的 onload 样板代码非常简洁：

lists/templates/base.html

    <script>

$(document).ready(function () {
  window.Superlists.initialize();
});

    </script>

更多信息请阅读 jQuery .ready() 的文档。

16.7　JavaScript测试在TDD循环中的位置

你可能想知道 JavaScript 测试在双重 TDD 循环中处于什么位置。答案是，JavaScript 测试和 Python 单元测试扮演的角色完全相同。

(1) 编写一个功能测试，看着它失败。

(2) 判断接下来需要哪种代码，Python 还是 JavaScript ？

(3) 使用选中的语言编写单元测试，看着它失败。

(4) 使用选中的语言编写一些代码，让测试通过。

(5) 重复上述步骤。

　想多练习使用 JavaScript 吗？当用户在输入框内点击或者输入内容时，看看你能否隐藏错误消息。实现的过程中应该还要编写功能测试。

我们几乎可以进入第三部分了，但在此之前还有最后一步：把修改后的新代码部署到服务器中。别忘了最后再提交一次（包含 base.html）！

16.8　一些缺憾

本章的目的是介绍 JavaScript 测试基础知识，并说明 JavaScript 测试在 TDD 循环中的位置。下面几点可做深入研究。

目前的测试只检查 JavaScript 能否在一个页面中使用。JavaScript 之所以能使用，是因为在 base.html 中引入了 JavaScript 文件。如果只在 home.html 中引入 JavaScript 文件，测试也能通过。你可以选择在哪个文件中引入，但也可以再编写一个测试。

编写 JavaScript 时，应该尽量利用编辑器提供的协助，避免常见的问题。试一下句法 / 错误检查工具（linter），例如 jslint 和 jshint。

QUnit 希望你在真正的 Web 浏览器中“运行”测试，这样有利于创建与网站的真实内容匹配的 HTML 固件，供测试使用。但是，JavaScript 测试也能在命令行中运行。第 24 章有个例子。

前端开发圈目前流行 angular.js 和 React 这样的 MVC 框架。这些框架的教程大都使用一个 RSpec 式断言库，名为 Jasmine。如果你想使用 MVC 框架，使用 Jasmine 比 QUnit 更方便。

本书后面还会涉及 JavaScript。如果你有兴趣，可以翻阅附录 F 的内容。

JavaScript 测试笔记

Selenium 最大的优势之一是可以测试 JavaScript 是否真的能使用，就像测试 Python 代码一样。

JavaScript 测试运行库有很多，QUnit 和 jQuery 联系紧密，这是我选择使用它的主要原因。

不管使用哪个测试库，都要设法解决 JavaScript 测试面对的主要挑战：管理全局状态。这包括：

DOM/HTML固件；

命名空间；

理解并控制执行顺序。

我说 JavaScript 很糟糕并不是出于真心。JavaScript 其实也可以很有趣。不过我还是要再说一次：一定要阅读《JavaScript 语言精粹》。

第 17 章　部署新代码

现在可以把全新的验证代码部署到线上服务器了。借此机会，我们也能再次在实践中使用自动化部署脚本。

　此刻，我由衷地感谢 Andrew Godwin 和整个 Django 团队。在 Django 1.7 之前，我写了很长一节内容，专门说明如何迁移。现在，因为迁移可以自动执行，所以那整节内容都不需要了。感谢你们的辛勤工作。

17.1　部署到过渡服务器

先部署到过渡服务器中：

$ git push
$ cd deploy_tools
$ fab deploy:host=elspeth@superlists-staging.ottg.eu
Disconnecting from superlists-staging.ottg.eu... done.

重启 Gunicorn：

elspeth@server:$ sudo systemctl restart gunicorn-superlists-staging.ottg.eu

然后在过渡服务器中运行测试：

$ STAGING_SERVER=superlists-staging.ottg.eu python manage.py test functional_tests
OK

17.2　部署到线上服务器

假设在过渡服务器上一切正常，那么就可以运行脚本，部署到线上服务器：

$ fab deploy:host=elspeth@superlists.ottg.eu

elspeth@server:$ sudo service gunicorn-superlists.ottg.eu restart

17.3　如果看到数据库错误该怎么办

迁移中引入了一个完整性约束，你可能会发现迁移执行失败，因为某些现有的数据违背了约束规则。

此时有两个选择。

删除服务器中的数据库，然后再部署试试。毕竟这只是一个小项目！

或者，学习如何迁移数据（参见附录 D）。

17.4　总结：为这次新发布打上Git标签

最后要做的一件事是，在 VCS 中为这次发布打上标签——始终能跟踪线上运行的是哪个版本十分重要：

$ git tag -f LIVE # 需要指定-f，因为我们要替换旧标签
$ export TAG=`date +DEPLOYED-%F/%H%M`
$ git tag $TAG
$ git push -f origin LIVE $TAG

　有些人不喜欢使用 push -f，也不喜欢更新现有的标签，而是使用某种版本号标记每次发布。你觉得哪种方法好就用哪种。

至此，第二部分结束了。接下来我们要进入第三部分，介绍更让人兴奋的话题。真令人期待啊！

部署过程回顾

目前我们部署过好几次了，下面回顾一下整个过程。

执行git push 命令，推送最新的代码。

部署到过渡服务器，运行功能测试。

部署到线上服务器。

为最新的版本打上标签。

随着项目的增长，部署过程会变得越来越复杂。如果没有好的自动化方案，部署将变得难以维护，处处都需要自己动手检查和操作。这个话题还有很多内容，不过已经超出本书范畴。记得阅读附录 C，另外再研究一下“持续部署”。

第三部分　高级话题

“噢，天呐，什么，还有一部分？哈利，我累了，已经看了两百多页，觉得无法再看完另一部分了，而且这一部分还是‘高级’话题……我能不能跳过这一部分呢？”

噢，不，你不能。这一部分虽然被称为“高级”话题，但其实有很多对 TDD 和 Web 开发十分重要的知识，因此不能跳过。这一部分甚至比前两部分还重要。

我们会讨论如何集成和测试第三方系统。重用现有的组件对现代 Web 开发十分重要。还会介绍模拟技术和测试隔离，这两种技术是 TDD 的核心，而且在最简单的代码基中也会用到。最后要讨论服务器端调试技术和测试固件，以及如何搭建持续集成（Continuous Integration）环境。这些技术在项目中并不是可有可无的奢侈附属品，它们其实都很重要。

这一部分的学习曲线难免稍微陡峭一些。你可能要多读几遍才能领会，或者第一次操作时可能无法正常运行，需要自己调试一番。但要坚持下去，知识越难，只要学会了，收获也就越大。如果你遇到难题，我十分乐意帮忙，请给我发电子邮件，地址是 obeythetestinggoat@gmail.com。

来吧，我保证最重要的知识就在这一部分！

第 18 章　用户身份验证、探究及去掉探究代码

精美的待办事项清单网站上线好几天了，用户开始提供反馈。他们说：“我们喜欢这个网站，但总是找不到使用过的清单，又很难靠死记硬背来记住 URL。如果网站能记住我们创建过哪些清单就好了。”

还记得亨利 · 福特说过的那句“快马”名言吗？ 1 收到用户的反馈后一定要深入分析并思考：“用户真正需要的是什么？满足用户的需求时如何使用自己一直想尝试的酷炫新技术？”

1亨利 · 福特是福特汽车公司的创始人。这里所说的“快马”名言全句是：“If I had asked people what they wanted, they would have said faster horses.”但也有人说福特并没说过这句话。——译者注

很明显，这些用户需要的是某种用户账户系统。那么就直接实现认证功能吧。

我们不会费力气自己存储密码，这是 20 世纪 90 年代的技术，而且存储用户密码还是个安全噩梦，所以还是交给第三方去完成吧。我们要使用一种称为无密码验证的技术。

（如果你坚持要自己存储密码，可以使用 Django 提供的 auth 模块。这个模块很友好也很简单，具体的实现方法留给你自己去发掘。）

18.1　无密码验证

为了不自己存储密码，我们要使用什么身份验证系统呢？ OAuth、OpenID，还是“通过 Facebook 登录”？对我来说，这些认证系统都有让人无法接受的缺点——为什么要让 Google 或 Facebook 知道我何时登录过什么网站呢？

本书第 1 版使用的是一个叫“Persona”的实验性项目，这个项目由 Mozilla 一些具有理想主义嬉皮士精神的技术人员研发。但可惜的是，它如今已被废弃。

但我找到了一个不错的替代方案，这种身份验证方式叫作“无密码验证”，你也可以称之为“只用电子邮件验证”。

发明这个系统的人觉得为每个网站都创建密码很麻烦，而且他发现他使用的密码都是随机的，用完就“扔”，根本不会尝试去记，等到再需要登录时使用“忘记密码”功能就好。详情请参阅 Medium 上的文章名为“Passwords are Obselete”。

无密码验证系统的原理是，只使用电子邮件地址确认身份。既然你希望使用“忘记密码”功能，就说明你信任电子邮件地址，那为什么不直捣黄龙呢？如果用户想登录，就生成一个唯一的 URL，通过电子邮件发给用户，用户点击 URL 后即可登录网站。

世界上没有完美的系统，为了给线上网站提供好的登录方案，需要深思熟虑的细节有很多。但这是个实验性项目，不必太费心。

18.2　探索性编程（又名“探究”）

在撰写本章之前，我对无密码验证的认识只限于前文给出的那篇文章中的简要说明。我没见过具体的代码，也不知道应该从哪入手。

我们在第 13、14 章中见识到，可以使用单元测试探索新 API 的用法。但有时你不想写测试，只是想捣鼓一下，看 API 是否能用，目的是学习和领会。这么做绝对可行。学习新工具，或者研究新的可行性方案时，一般都可以适当地把严格的 TDD 流程放在一边，不编写测试或编写少量的测试，先把基本的原型开发出来。测试山羊并不介意暂时睁一只眼闭一只眼。

这种创建原型的过程一般叫作“探究”（spike）。这么叫的原因众所周知。

首先，我研究了现有的 Python 和 Django 身份验证码，比如 django-allauth 和 python-social- auth，但这两个包目前都太过复杂。（回头一想，自己编程多有趣！）

所以，我决定亲自动手，经过一番潜心研究之后，终于写出了刚好能用的代码。下面我要向你展示我的实现过程，然后再过一遍，去掉探索性代码，即把原型替换成经过测试、可在生产环境使用的代码。

你应该自己动手，把这些代码添加到自己的网站中，这样才能得到试验的对象。然后使用自己的电子邮件地址登录试试，证明代码确实可用。

18.2.1　为此次探究新建一个分支

着手探究之前，最好新建一个分支，这样就不用担心探究过程中提交的代码把 VCS 中的生产代码搞乱了：

$ git checkout -b passwordless-spike

下面在便签上记下希望从这次探究中学到的东西。

18.2.2　前端登录UI

先从前端入手。在导航栏中放一个表单，让用户输入电子邮件地址，并为通过身份验证的用户提供退出链接：

lists/templates/base.html (ch16l001)

<body>
  <div class="container">

    <div class="navbar">
      {% if user.is_authenticated %}
        <p>Logged in as {{ user.email}}</p>
        <p><a id="id_logout" href="{% url 'logout' %}">Log out</a></p>
      {% else %}
        <form method="POST" action ="{% url 'send_login_email' %}">
          Enter email to log in: <input name="email" type="text" />
          {% csrf_token %}
        </form>
      {% endif %}
    </div>

    <div class="row">
    [...]

18.2.3　从Django中发出邮件

登录过程是这样的。

有人想登录时，就生成一个唯一的秘密令牌，存储在数据库中，并与他的电子邮件地址关联起来，然后把令牌发给那个人。

他查看电子邮件，里面有个包含令牌的 URL。

他点击链接后，我们检查令牌是否存在于数据库中，如果存在就登入相应的用户。

首先，为账户准备一个应用：

$ python manage.py startapp accounts

然后在 urls.py 中至少设置一个 URL。先是位于顶级的 superlists/urls.py 文件。

superlists/urls.py (ch16l003)

from django.conf.urls import include, url
from lists import views as list_views
from lists import urls as list_urls
from accounts import urls as accounts_urls

urlpatterns = [
    url(r'^$', list_views.home_page, name='home'),
    url(r'^lists/', include(list_urls)),
    url(r'^accounts/', include(accounts_urls)),
]

然后是 accounts 模块中的 urls.py 文件：

accounts/urls.py (ch16l004)

from django.conf.urls import url
from accounts import views

urlpatterns = [
    url(r'^send_email$', views.send_login_email, name='send_login_email'),
]

下述视图负责创建与用户在登录表单中输入的电子邮件地址关联的令牌：

accounts/views.py (ch16l005)

import uuid
import sys
from django.shortcuts import render
from django.core.mail import send_mail

from accounts.models import Token

def send_login_email(request):
    email = request.POST['email']
    uid = str(uuid.uuid4())
    Token.objects.create(email=email, uid=uid)
    print('saving uid', uid, 'for email', email, file=sys.stderr)
    url = request.build_absolute_uri(f'/accounts/login?uid={uid}')
    send_mail(
        'Your login link for Superlists',
        f'Use this link to log in:\n\n{url}',
        'noreply@superlists',
        [email],
    )
    return render(request, 'login_email_sent.html')

为此，我们要显示一条消息，指明电子邮件已经发出：

accounts/templates/login_email_sent.html (ch16l006)

<html>
<h1>Email sent</h1>

<p>Check your email, you'll find a message with a link that will log you into
the site.</p>

</html>

（这段代码只是临时的，实际使用中应该集成到 base.html 模板里。）

为了让 Django 的 send_mail 函数工作，更重要的是告诉 Django 我们的电子邮件服务器地址。我暂时先使用自己的 Gmail 账户。2 你可以使用任何你想用的电子邮件服务提供商，只要支持 SMTP 就行：

2我刚刚是不是说了一大堆关于使用 Google 登录对隐私有多大影响的话？那为什么现在又要使用 Gmail 呢？是的，这的确相互矛盾。（老实讲，有一天我会抛弃 Gmail 的！）但这里只是用于测试，我又没强制用户使用 Google。

superlists/settings.py (ch16l007)

EMAIL_HOST = 'smtp.gmail.com'
EMAIL_HOST_USER = 'obeythetestinggoat@gmail.com'
EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_PASSWORD')
EMAIL_PORT = 587
EMAIL_USE_TLS = True

　 Gmail，可能需要访问 Google 账户的安全设置页面。如果你在使用双因素身份验证，可能需要设置应用专用的密码；如果没用双因素身份验证，可能也要允许较不安全的应用访问。鉴于此，你可以新建一个 Google 账户，而不使用包含敏感数据的账户。

18.2.4　使用环境变量，避免源码中出现机密信息

每个项目终究都要以一种方式处理“机密信息”——像电子邮件密码或 API 密钥这类不想和整个世界分享的数据。如果你的仓库是私有的，或许还可以存储在 Git 中，但事实往往不是这样。而且这还涉及在开发和生产环境中使用不同的设置。（还记得在第 11 章中我们是如何处理 Django 的 SECRET_KEY 设置的吗？）

这种配置通常使用环境变量存储，上述代码中的 os.environ.get 就是用于读取环境变量的。

为此，我要在运行开发服务器的 shell 中设定环境变量：

$ export EMAIL_PASSWORD="sekrit"

后面还会在过渡服务器中添加这个环境变量。

18.2.5　在数据库中存储令牌

情况进展如何？

为了把令牌存储在数据库中，我们需要一个模型。这个模型要把电子邮件地址与唯一的 ID

关联起来——这没什么难的：

accounts/models.py (ch16l008)

from django.db import models

class Token(models.Model):
    email = models.EmailField()
    uid = models.CharField(max_length=255)

18.2.6　自定义身份验证模型

既然已经谈到模型了，那就试验一下如何在 Django 中验证身份吧。

首先，要有一个用户模型。在我刚开始编写时，自定义用户模型还是 Django 的新特性，所以我仔细研读了 Django 的身份验证文档，努力找出最简单的方法：

accounts/models.py (ch16l009)

[...]
from django.contrib.auth.models import (
    AbstractBaseUser, BaseUserManager, PermissionsMixin
)

class ListUser(AbstractBaseUser, PermissionsMixin):
    email = models.EmailField(primary_key=True)
    USERNAME_FIELD = 'email'
    #REQUIRED_FIELDS = ['email', 'height']

    objects = ListUserManager()

    @property
    def is_staff(self):
        return self.email == 'harry.percival@example.com'

    @property
    def is_active(self):
        return True

这算是一个极简用户模型，它只有一个字段，没有名字、姓氏或用户名之类的，尤其是没有密码字段。我们不必为此操心！

但我要再次说明，这段代码不适合在生产环境使用，里面注释掉了一行代码，还硬编码了我的电子邮件地址。去掉试探代码时会做大幅调整。

此外，还要为用户模型提供一个模型管理器：

accounts/models.py (ch16l010)

[...]
class ListUserManager(BaseUserManager):

    def create_user(self, email):
        ListUser.objects.create(email=email)

    def create_superuser(self, email, password):
        self.create_user(email)

现阶段你不用管模型管理器是什么，目前就是需要这么一个东西，有了它才能工作。去掉试探代码时，我们会分析每一段代码，确定哪些是可以在生产环境使用的，彻底弄明白这些代码的作用。

18.2.7　结束自定义Django身份验证功能

就要完成了。最后一步要识别令牌，然后登入用户。做完这一步，便签上记录的事项基本上都可以划掉了。

下面是点击电子邮件中的链接后触发的视图：

accounts/views.py (ch16l011)

import uuid
import sys
from django.contrib.auth import authenticate
from django.contrib.auth import login as auth_login
from django.core.mail import send_mail
from django.shortcuts import redirect, render
[...]

def login(request):
    print('login view', file=sys.stderr)
    uid = request.GET.get('uid')
    user = authenticate(uid=uid)
    if user is not None:
        auth_login(request, user)
    return redirect('/')

authenticate 函数调用 Django 的身份验证框架，我们已经将它配置为使用“自定义的身份验证框架”，作用是验证 UID，返回电子邮件对应的用户。

我们本可以在这个视图中直接结束，但是最好按照 Django 预期的方式组织代码，实现关注点分离：

accounts/authentication.py (ch16l012)

import sys
from accounts.models import ListUser, Token

class PasswordlessAuthenticationBackend(object):

    def authenticate(self, uid):
        print('uid', uid, file=sys.stderr)
        if not Token.objects.filter(uid=uid).exists():
            print('no token found', file=sys.stderr)
            return None
        token = Token.objects.get(uid=uid)
        print('got token', file=sys.stderr)
        try:
            user = ListUser.objects.get(email=token.email)
            print('got user', file=sys.stderr)
            return user
        except ListUser.DoesNotExist:
            print('new user', file=sys.stderr)
            return ListUser.objects.create(email=token.email)

    def get_user(self, email):
        return ListUser.objects.get(email=email)

这段代码也不例外，打印了很多调试信息，还有一些重复，不适合在生产环境使用。不过我们现在要的是能用就行。

最后，编写退出视图：

accounts/views.py (ch16l013)

from django.contrib.auth import login as auth_login, logout as auth_logout
[...]

def logout(request):
    auth_logout(request)
    return redirect('/')

把 login 和 logout 视图添加到 urls.py 中：

accounts/urls.py (ch16l014)

from django.conf.urls import url
from accounts import views

urlpatterns = [
    url(r'^send_email$', views.send_login_email, name='send_login_email'),
    url(r'^login$', views.login, name='login'),
    url(r'^logout$', views.logout, name='logout'),
]

坚持住，就快结束了！在 settings.py 中启用 auth 后端和这个 accounts 应用：

superlists/settings.py (ch16l015)

INSTALLED_APPS = [
    #'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'lists',
    'accounts',
]

AUTH_USER_MODEL = 'accounts.ListUser'
AUTHENTICATION_BACKENDS = [
    'accounts.authentication.PasswordlessAuthenticationBackend',
]

MIDDLEWARE = [
[...]

执行 makemigrations 命令，为令牌和用户模型生成迁移：

$ python manage.py makemigrations
Migrations for 'accounts':
  accounts/migrations/0001_initial.py
    - Create model ListUser
    - Create model Token

再执行 migrate 命令构建数据库：

$ python manage.py migrate
[...]
Running migrations:
  Applying accounts.0001_initial... OK

一切就绪！为什么不执行 runserver 命令启动开发服务器，看看实际效果呢（见图 18-1）？

图 18-1：能正常使用！能正常使用！哇哈哈

　如果遇到 SMTPSenderRefused 错误消息，可能是你忘了在运行开发服务器的 shell 中设定 EMAIL_PASSWORD 环境变量。

大概就是这样！这一过程看着简单，但我当时可是历经磨难。我在 Gmail 账户的安全界面四处设置了好久，又在自定义用户模型的过程中少写了几个属性（因为我没认真阅读文档），甚至还以为自己发现了一个缺陷而换到 Django 开发版本，不过最终证明那并不是缺陷。

在 stderr 中记录错误

探究时尤为重要的一点是能看到代码抛出的异常。Django 很讨厌，默认情况下并没把所有异常都输送到终端，不过可以在 settings.py 中使用 LOGGING 变量让 Django 这么做：

superlists/settings.py (ch16l017)

LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'handlers': {
        'console': {
            'level': 'DEBUG',
            'class': 'logging.StreamHandler',
        },
    },
    'loggers': {
        'django': {
            'handlers': ['console'],
        },
    },
    'root': {'level': 'INFO'},
}

Django 使用的是 Python 标准库中的企业级日志包。这个包虽然功能完善，但是学习曲线太陡。第 21 章将简单介绍一下这个包，Django 文档中有更详细的说明。

但不管怎么说，我们都实现了一个可用的方案！下面提交到 passwordless-spike 分支：

$ git status
$ git add accounts
$ git commit -am "spiked in custom passwordless auth backend"

接下来该去掉探究代码了。

18.3　去掉探究代码

去掉探究代码意味着要使用 TDD 重写原型代码。我们现在掌握了足够的信息，知道怎么做才是对的。那第一步做什么呢？当然是编写功能测试！

我们还得继续待在 passwordless-spike 分支中，看功能测试能否在探究代码中通过，然后再回到 master 分支，并且只提交功能测试。

下面是我们编写的第一版功能测试，很简单：

functional_tests/test_login.py

from django.core import mail
from selenium.webdriver.common.keys import Keys
import re

from .base import FunctionalTest

TEST_EMAIL = 'edith@example.com'
SUBJECT = 'Your login link for Superlists'

class LoginTest(FunctionalTest):

    def test_can_get_email_link_to_log_in(self):
        # 伊迪丝访问这个很棒的超级列表网站
        # 第一次注意到导航栏中有“登录”区域
        # 看到要求输入电子邮件地址，她便输入了
        self.browser.get(self.live_server_url)
        self.browser.find_element_by_name('email').send_keys(TEST_EMAIL)
        self.browser.find_element_by_name('email').send_keys(Keys.ENTER)

        # 出现一条消息，告诉她邮件已经发出
        self.wait_for(lambda: self.assertIn(
            'Check your email',
            self.browser.find_element_by_tag_name('body').text
        ))

        # 她查看邮件，看到一条消息
        email = mail.outbox[0]  ➊
        self.assertIn(TEST_EMAIL, email.to)
        self.assertEqual(email.subject, SUBJECT)

        # 邮件中有个URL链接
        self.assertIn('Use this link to log in', email.body)
        url_search = re.search(r'http://.+/.+$', email.body)
        if not url_search:
            self.fail(f'Could not find url in email body:\n{email.body}')
        url = url_search.group(0)
        self.assertIn(self.live_server_url, url)

        # 她点击了链接
        self.browser.get(url)

        # 她登录了！
        self.wait_for(
            lambda: self.browser.find_element_by_link_text('Log out')
        )
        navbar = self.browser.find_element_by_css_selector('.navbar')
        self.assertIn(TEST_EMAIL, navbar.text)

➊ 你是不是因不知如何在测试中获取邮件内容而担心？好消息是，我们暂时可以作弊！运行测试时，Django 允许通过 mail.outbox 属性访问服务器发送的电子邮件。稍后再说明如何检查“真实的”电子邮件（少安毋躁）。

运行这个功能测试，它能通过：

$ python manage.py test functional_tests.test_login
[...]
Not Found: /favicon.ico
saving uid [...]
login view
uid [...]
got token
new user

.
 ---------------------------------------------------------------------
Ran 1 test in 3.729s

OK

你甚至会看到我在探究视图的实现时留下的调试输出。现在该还原这些临时改动，然后再以测试驱动的方式重新逐一介绍了。

删除探究代码

$ git checkout master # 切换到master分支
$ rm -rf accounts # 删除所有探究代码
$ git add functional_tests/test_login.py
$ git commit -m "FT for login via email"

然后再次运行功能测试，让它驱动我们开发：

$ python manage.py test functional_tests.test_login
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: [name="email"]
[...]

测试首先要求我们添加一个电子邮件地址输入框。Bootstrap 为导航栏提供了内置类，我们将使用它们。这个输入框放在一个表单里：

lists/templates/base.html (ch16l020)

<div class="container">

  <nav class="navbar navbar-default" role="navigation">
    <div class="container-fluid">
      <a class="navbar-brand" href="/">Superlists</a>
      <form class="navbar-form navbar-right" method="POST" action="#">
        <span>Enter email to log in:</span>
        <input class="form-control" name="email" type="text" />
        {% csrf_token %}
      </form>
    </div>
  </nav>

  <div class="row">
  [...]

现在功能测试是失败的，因为这个登录表单什么也没做：

$ python manage.py test functional_tests.test_login
[...]
AssertionError: 'Check your email' not found in 'Superlists\nEnter email to log
in:\nStart a new To-Do list'

　我建议你现在像前面说过的那样设置 LOGGING。现在没必要特别测试这个，目前的测试组件会在出现异常时通知我们。到第 21 章你会发现，这个设置对调试非常有用。

该编写一些 Django 代码了。首先，创建一个名为 accounts 的应用，用于存放与登录有关的所有文件：

$ python manage.py startapp accounts

你现在还可以提交一次，把应用的占位文件与后面的修改区分开。

下面来重新构建这个极简的用户模型，不过这一次有测试。看看是否比探究时的更简洁。

18.4　一个极简的自定义用户模型

Django 内置的用户模型对记录用户信息做了各种设想，明确要记录的包括名和姓 3，而且强制使用用户名。我坚信，除非真的需要，否则不要存储用户的任何信息。所以，一个只记录电子邮件地址的用户模型对我来说足够了。

3虽然 Django 的重要维护者对这个决策并不后悔，但并非每个人都有名和姓。

我相信你已经知道我们应该创建 tests 文件夹，并在其中创建 __init__.py 文件，然后删除 tests.py，再新建 test_models.py，写入下述内容：

accounts/tests/test_models.py (ch16l024)

from django.test import TestCase
from django.contrib.auth import get_user_model

User = get_user_model()

class UserModelTest(TestCase):

    def test_user_is_valid_with_email_only(self):
        user = User(email='a@b.com')
        user.full_clean() # 不该抛出异常

测试的结果是一个预期失败：

django.core.exceptions.ValidationError: {'password': ['This field cannot be
blank.'], 'username': ['This field cannot be blank.']}

密码？用户名？不！把模型写成这样如何？

accounts/models.py

from django.db import models

class User(models.Model):
    email = models.EmailField()

然后在 settings.py 中把 accounts 应用添加到 INSTALLED_APPS 中，再设定 AUTH_USER_MODEL：

superlists/settings.py (ch16l026)

INSTALLED_APPS = [
    #'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'lists',
    'accounts',
]

AUTH_USER_MODEL = 'accounts.User'

现在得到的错误与数据库有关：

django.db.utils.OperationalError: no such table: accounts_user

与之前一样，这表明我们要执行迁移。但当我们执行迁移时，Django 又会抱怨用户模型缺少一些元数据：

$ python manage.py makemigrations
Traceback (most recent call last):
[...]
    if not isinstance(cls.REQUIRED_FIELDS, (list, tuple)):
AttributeError: type object 'User' has no attribute 'REQUIRED_FIELDS'

唉，Django 啊，这个模型只有一个字段而已，你自己应该能找到问题的答案啊。既然你不能，那我就提供给你吧：

accounts/models.py

class User(models.Model):
    email = models.EmailField()
    REQUIRED_FIELDS = []

还有疑问吗？ 4

4你可能想问，既然我觉得 Django 很笨，为什么不提交“合并请求”（pull request）修正呢？这个问题应该很容易修正。嗯，我保证等我写完这本书之后会这么做的。尖锐的批评就此打住吧！

$ python manage.py makemigrations
[...]
AttributeError: type object 'User' has no attribute 'USERNAME_FIELD'

经过几次失败之后，得到的模型如下所示：

accounts/models.py

class User(models.Model):
    email = models.EmailField()

    REQUIRED_FIELDS = []
    USERNAME_FIELD = 'email'
    is_anonymous = False
    is_authenticated = True

现在出现的错误稍有不同：

$ python manage.py makemigrations
SystemCheckError: System check identified some issues:

ERRORS:
accounts.User: (auth.E003) 'User.email' must be unique because it is named as
the 'USERNAME_FIELD'.

好吧，可以这样修正：

accounts/models.py (ch16l028-1)

    email = models.EmailField(unique=True)

现在能成功迁移了：

$ python manage.py makemigrations
Migrations for 'accounts':
  accounts/migrations/0001_initial.py
    - Create model User

测试也能通过了：

$ python manage.py test accounts
[...]
Ran 1 tests in 0.001s
OK

不过用户模型比想象的复杂了一点，除了 email 字段之外，还有自动生成的 ID 字段，用作主键。这个模型可以进一步简化！

把测试当作文档

下面我们要把 email 字段设为主键，5 因而必须把自动生成的 id 字段删除。

5其实电子邮件地址不太适合作为主键。一位深受其害的读者写了一封邮件给我，控诉了他们这十几年来使用电子邮件地址作为主键遇到的各种问题（因为这样无法管理多用户账户）。所以，还是那句话，具体问题具体分析。

我们可以直接这么做，测试也不会失败，然后信心满满地声称这“只是一次重构”。但是，最好为此专门编写一个测试：

accounts/tests/test_models.py (ch16l028-3)

    def test_email_is_primary_key(self):
        user = User(email='a@b.com')
        self.assertEqual(user.pk, 'a@b.com')

如果以后回过头再看代码，这个测试能唤起我们的记忆，想起曾经做过这次修改。

    self.assertEqual(user.pk, 'a@b.com')
AssertionError: None != 'a@b.com'

　测试可以作为一种文档形式，因为测试体现了你对某个类或函数的需求。如果你忘记了为什么要用某种方法编写代码，可以回过头来看测试，有时就能找到答案。这就是一定要给测试方法起个意思明确的名字的原因。

实现的方式如下（可以先使用 unique=True，看看结果如何）：

accounts/models.py (ch16l028-4)

    email = models.EmailField(primary_key=True)

一定不能忘了调整迁移：

$ rm accounts/migrations/0001_initial.py
$ python manage.py makemigrations
Migrations for 'accounts':
  accounts/migrations/0001_initial.py
    - Create model User

现在两个测试都能通过：

$ python manage.py test accounts
[...]
Ran 2 tests in 0.001s
OK

18.5　令牌模型：把电子邮件地址与唯一的ID关联起来

接下来构建一个令牌模型。下面是个简短的单元测试，能捕获基本的问题——应该能把电子邮件地址关联到唯一的 ID 上，而且 ID 不能在一行中重复出现：

accounts/tests/test_models.py (ch16l030)

from accounts.models import Token
[...]

class TokenModelTest(TestCase):
    def test_links_user_with_auto_generated_uid(self):
        token1 = Token.objects.create(email='a@b.com')
        token2 = Token.objects.create(email='a@b.com')
        self.assertNotEqual(token1.uid, token2.uid)

使用 TDD 驱动开发 Django 模型要历经几番波折，因为这涉及迁移，所以我们将像这样迭代多次：微改代码、创建迁移、遇到新错误、删除迁移、重新创建迁移、再修改代码……

$ python manage.py makemigrations
Migrations for 'accounts':
  accounts/migrations/0002_token.py
    - Create model Token
$ python manage.py test accounts
[...]
TypeError: 'email' is an invalid keyword argument for this function

我相信你能按部就班走完整个过程。记住，虽然我看不见你，但是测试山羊能！

$ rm accounts/migrations/0002_token.py
$ python manage.py makemigrations
Migrations for 'accounts':
  accounts/migrations/0002_token.py
    - Create model Token
$ python manage.py test accounts
AttributeError: 'Token' object has no attribute 'uid'

最终写出的模型代码如下：

accounts/models.py (ch16l033)

class Token(models.Model):
    email = models.EmailField()
    uid = models.CharField(max_length=40)

得到的错误是：

$ python manage.py test accounts
[...]

    self.assertNotEqual(token1.uid, token2.uid)
AssertionError: '' == ''

现在要决定如何生成随机的唯一 ID 字段。我们可以使用 random 模块，但是 Python 自带的另一个模块是专门用于生成唯一 ID 的，名为“uuid”（universally unique id 的简称）。

它的用法如下：

accounts/models.py (ch16l035)

import uuid
[...]

class Token(models.Model):
    email = models.EmailField()
    uid = models.CharField(default=uuid.uuid4, max_length=40)

再调整一下迁移，测试便能通过：

$ python manage.py test accounts
[...]
Ran 3 tests in 0.015s

OK

不错，逐渐走上正轨了——至少模型层完成了。下一章将介绍模拟技术，这是测试外部依赖（例如邮件）的关键技术。

探索性编程、探究及去掉探究代码

探究

为了学习新 API 或调查新方案的可行性而做的探索性编程。没有测试也能探究。最好在一个新分支中探究，去掉探究代码时再回到主分支。

去掉探究代码

把探究所得应用到真实的代码基中。要完全摒弃探究代码，然后从头开始，用 TDD 流程再实现一次。去掉探究代码后实际编写的代码往往与最初有很大不同，而且通常更好。

针对探究代码编写功能测试

该不该这么做要视情况而定。支持这么做的人认为，这样有助于正确编写功能测试——找出测试探究的方法与探究本身一样具有挑战性；不支持这么做的人觉得这样会阻碍思路，写出的代码往往与探究时很像——我们要力求避免这种情况。

第 19 章　使用驭件测试外部依赖或减少重复

本章开始说明如何测试发送电子邮件的代码。通过前面的功能测试，我们得知 Django 提供了获取所发送邮件的方式，即 mail.outbox 属性。不过我想在这一章演示一种十分重要的测试技术，叫作模拟技术。鉴于此，本章的单元测试将假装 Django 没有提供这样的便捷方式。

　我是说不能使用 Django 的 mail.outbox 吗？不是。你应该使用它，因为它很便利。但是我想教你模拟技术，因为这是在单元测试中测试外部依赖的通用方式。毕竟你不会一直使用 Django。即便如此，除了发送电子邮件之外还有很多操作，只要与第三方 API 交互都适合使用驭件测试。

19.1　开始之前布好基本管道

首先设置一个基本的视图和 URL。编写一个简单的测试，检查发送登录邮件的 URL 最终会重定向到首页：

accounts/tests/test_views.py

from django.test import TestCase

class SendLoginEmailViewTest(TestCase):

    def test_redirects_to_home_page(self):
        response = self.client.post('/accounts/send_login_email', data={
            'email': 'edith@example.com'
        })
        self.assertRedirects(response, '/')

我们已经在 accounts/urls.py 中设置了 url，也在 superlists/urls.py 中使用 include 引入了，再加上下述代码，这个测试便能通过：

accounts/views.py

from django.core.mail import send_mail
from django.shortcuts import redirect

def send_login_email(request):
    return redirect('/')

现在导入 send_mail 函数只是占个位子：

$ python manage.py test accounts
[...]
Ran 4 tests in 0.015s

OK

好的，有切入点了，下面开始使用模拟技术。

19.2　自己动手模拟（打猴子补丁）

在真实情况中，我们调用 send_mail 时希望 Django 连接电子邮件服务提供商，通过网络把真实的电子邮件发送出去。但这不是我们希望在测试中发生的。代码有外部副作用时也是如此，例如调用 API、发推文、发短信，等等。在单元测试中，我们并不想真的通过互联网发推文或调用 API。可是，我们必须找到一种方法，测试代码是否正确。驭件 1 正是我们寻找的答案。

1我使用的是通用术语“驭件”（mock），而测试狂热者却希望明确区分“测试替身”这类测试工具中的不同概念，包括侦件（spy）、伪件（fake）和桩件（stub）。在这本书中不必纠结它们之间的区别，如果你想深入了解，请阅读 Justin Searls 写的精彩维基“Test Double”。剧透：此文充满各种测试知识。

恰好，Python 的优势之一是它的动态本性，这样十分有利于模拟，我们有时称这样的做法为打猴子补丁。首先，假设调用 send_mail 时要设定邮件主题、发件地址和收件地址，比如像下面这样：

accounts/views.py

def send_login_email(request):
    email = request.POST['email']
    # send_mail(
    #     'Your login link for Superlists',
    #     'body text tbc',
    #     'noreply@superlists',
    #     [email],
    # )
    return redirect('/')

在不真正调用 send_mail 函数的情况下，应该怎么测试呢？答案是在调用 send_login_email 视图之前，测试可以让 Python 在运行时把 send_mail 函数替换成一个伪造的版本。看一下具体的代码：

accounts/tests/test_views.py (ch17l005)

from django.test import TestCase
import accounts.views  ➋

class SendLoginEmailViewTest(TestCase):
    [...]

    def test_sends_mail_to_address_from_post(self):
        self.send_mail_called = False

        def fake_send_mail(subject, body, from_email, to_list):  ➊
            self.send_mail_called = True
            self.subject = subject
            self.body = body
            self.from_email = from_email
            self.to_list = to_list

        accounts.views.send_mail = fake_send_mail  ➋

        self.client.post('/accounts/send_login_email', data={
            'email': 'edith@example.com'
        })

        self.assertTrue(self.send_mail_called)
        self.assertEqual(self.subject, 'Your login link for Superlists')
        self.assertEqual(self.from_email, 'noreply@superlists')
        self.assertEqual(self.to_list, ['edith@example.com'])

❶ 定义 fake_send_mail 函数。它看起来与 send_mail 函数很像，但它其实只是使用 self 的一些变量存储了一些关于调用方式的信息。

❷ 然后，在测试执行 self.client.post 之前，把真的 accounts.views.send_mail 函数换成假的——只需一次简单的赋值。

注意，我们没有施什么魔法，只是打破常规，利用 Python 这门动态语言的优势。

在真正调用函数之前，只要命名空间正确，就可以修改用于访问函数的变量（因此才需要导入位于顶层的 accounts 模块，这样方能进入 accounts.views 模块，达到 accounts.views.send_login_email 函数所在的作用域）。

这种做法不只限于单元测试，在任何 Python 代码中都可以像这样打猴子补丁。

你可能要花点时间才能习惯。在深入探讨细节之前，你要说服自己相信这没什么大不了的。

为什么要使用 self 传递信息？这只是在 fake_send_mail 函数的作用域内外传递信息的便利方式。此外，我们还可以使用可变的对象，例如列表或字典，只要那个对象在伪造函数的外部即可。（如果好奇，可以试试不同的方式，看哪些可行，哪些不可行。）

一定要在调用真实函数“之前”！我曾无数次呆坐在那里，百思不得其解，为什么驭件不起作用呢？最后才恍然大悟，我没有在调用真实的函数之前替换为伪造的函数。

下面看一下我们自己动手打造的驭件能否驱动开发：

$ python manage.py test accounts
[...]
    self.assertTrue(self.send_mail_called)
AssertionError: False is not true

直接调用 send_mail 试试：

accounts/views.py

def send_login_email(request):
    send_mail()
    return redirect('/')

测试结果变成了：

TypeError: fake_send_mail() missing 4 required positional arguments: 'subject',
'body', 'from_email', and 'to_list'

看样子猴子补丁起作用了！我们调用了 send_mail，而它执行的是 fake_send_mail 函数，后者要求提供更多参数。提供参数试试：

accounts/views.py

def send_login_email(request):
    send_mail('subject', 'body', 'from_email', ['to email'])
    return redirect('/')

测试的结果为：

    self.assertEqual(self.subject, 'Your login link for Superlists')
AssertionError: 'subject' != 'Your login link for Superlists'

一切顺利。然后调整代码，改成下面这样：

accounts/views.py

def send_login_email(request):
    email = request.POST['email']
    send_mail(
        'Your login link for Superlists',
        'body text tbc',
        'noreply@superlists',
        [email]
    )
    return redirect('/')

测试能通过了：

$ python manage.py test accounts

Ran 5 tests in 0.016s

OK

棒极了！我们模拟了 send_email 函数，为正常情况下应该通过互联网发送邮件的代码编写了测试，这样测试和代码就没有出入了。2

2是的，我知道 Django 已经使用 mail.outbox 提供了电子邮件驭件。但是，再次声明，我们要假装没有。如果我们使用的是 Flask 呢？或者，如果这是调用 API，而不是发送邮件呢？

19.3　Python的模拟库

流行的 mock 包从 Python 3.3 起纳入了标准库。3 这个包提供了一个充满魔力的对象，名为 mock。下面在 Python shell 中试用一下：

3Python 2 用户可以使用 pip install mock 安装。

>>> from unittest.mock import Mock
>>> m = Mock()
>>> m.any_attribute
<Mock name='mock.any_attribute' id='140716305179152'>
>>> type(m.any_attribute)
<class 'unittest.mock.Mock'>
>>> m.any_method()
<Mock name='mock.any_method()' id='140716331211856'>
>>> m.foo()
<Mock name='mock.foo()' id='140716331251600'>
>>> m.called
False
>>> m.foo.called
True
>>> m.bar.return_value = 1
>>> m.bar(42, var='thing')
1
>>> m.bar.call_args
call(42, var='thing')

这个对象很神奇，它能响应任何属性访问或方法调用，可以指明调用的返回值，还可以审查调用时传入的参数是什么。看起来它很适合在单元测试中使用。

19.3.1　使用unittest.patch

如果觉得这不够用，mock 模块还提供了辅助函数 patch，利用它可以实现前面动手打的猴子补丁。

稍后再讲原理，现在先看具体用法：

accounts/tests/test_views.py (ch17l007)

from django.test import TestCase
from unittest.mock import patch
[...]

    @patch('accounts.views.send_mail')
    def test_sends_mail_to_address_from_post(self, mock_send_mail):
        self.client.post('/accounts/send_login_email', data={
            'email': 'edith@example.com'
        })

        self.assertEqual(mock_send_mail.called, True)
        (subject, body, from_email, to_list), kwargs = mock_send_mail.call_args
        self.assertEqual(subject, 'Your login link for Superlists')
        self.assertEqual(from_email, 'noreply@superlists')
        self.assertEqual(to_list, ['edith@example.com'])

重新运行这个测试，你会发现它仍能通过。大改之后依然能通过的测试最让人怀疑，下面故意搞个破坏：

accounts/tests/test_views.py (ch17l008)

        self.assertEqual(to_list, ['schmedith@example.com'])

并在视图中添加一行代码，打印调试信息：

accounts/views.py (ch17l009)

def send_login_email(request):
    email = request.POST['email']
    print(type(send_mail))
    send_mail(
        [...]

再次运行测试：

$ python manage.py test accounts
[...]
<class 'function'>
<class 'unittest.mock.MagicMock'>
[...]
AssertionError: Lists differ: ['edith@example.com'] !=
['schmedith@example.com']
[...]

Ran 5 tests in 0.024s

FAILED (failures=1)

显然，测试失败了。从失败消息前面的输出可以看出，send_mail 函数的类型在第一个单元测试中是常规的函数，而在第二个单元测试中则是一个驭件。

删掉故意出错的代码，然后深入分析到底发生了什么：

accounts/tests/test_views.py (ch17l011)

@patch('accounts.views.send_mail')  ➊
def test_sends_mail_to_address_from_post(self, mock_send_mail):  ➋
    self.client.post('/accounts/send_login_email', data={
        'email': 'edith@example.com'  ➌
    })

    self.assertEqual(mock_send_mail.called, True)  ➍
    (subject, body, from_email, to_list), kwargs = mock_send_mail.call_args  ❺
        self.assertEqual(subject, 'Your login link for Superlists')
        self.assertEqual(from_email, 'noreply@superlists')
        self.assertEqual(to_list, ['edith@example.com'])

❶ patch 装饰器的参数是要打猴子补丁的函数的点分名称。这一行代码的作用等同于动手替换 accounts.views 中的 send_mail 函数。这个装饰器的优点很多，不仅可以自动把目标替换成驭件，结束后还能自动换回原对象（否则后续使用的仍是打过猴子补丁的版本，这可能对其他测试产生影响）。

❷ patch 通过传给测试方法的参数注入驭件。这个参数的名称随意，不过我习惯在原对象名称前面加上 mock_。

❸ 像往常一样调用要测试的函数，但是在测试方法中使用的是驭件，所以视图不会真的调用 send_mail，而是使用 mock_send_mail。

❹ 下断言，检查驭件在测试过程中有什么变化。先调用驭件……

❺ ……然后拆解出各个位置参数和关键字参数，检查调用时传入的是什么值。（稍后会详细讨论 call_args。）

彻底弄明白了吗？没有？没关系。后面还会在多个测试中使用驭件，你会逐渐习惯的。

19.3.2　让测试向前迈一小步

暂且回到功能测试，看看是在哪里失败的：

$ python manage.py test functional_tests.test_login
[...]
AssertionError: 'Check your email' not found in 'Superlists\nEnter email to log
in:\nStart a new To-Do list'

提交电子邮件地址目前没有任何效果，因为表单没向任何地方发送数据。下面在 base.html 中设置：4

4限于本书纸张的尺寸，我把这个 form 标签分成了三行。如果你没见过这种写法，可能觉得有点奇怪，但这是有效的 HTML。如果不喜欢，你可以不这样写。（）

lists/templates/base.html (ch17l012)

<form class="navbar-form navbar-right"
      method="POST"
      action="{% url 'send_login_email' %}">

有没有用？没有，还有错误。为什么呢？因为成功给用户发送电子邮件后没有显示成功消息。下面为此添加一个测试。

19.3.3　测试Django消息框架

我们将使用 Django 的“消息框架”，通常用于显示临时的“成功”或“提醒”消息，指明操作的结果。如果你没用过这个框架，可以看一下它的文档。

测试 Django 的消息有点曲折，要把 follow=True 传给测试客户端，让它获取 302 重定向后的页面，在里面查找消息列表（在显示之前转换为列表）。这个测试如下所示：

accounts/tests/test_views.py (ch17l013)

    def test_adds_success_message(self):
        response = self.client.post('/accounts/send_login_email', data={
            'email': 'edith@example.com'
        }, follow=True)

        message = list(response.context['messages'])[0]
        self.assertEqual(
            message.message,
            "Check your email, we've sent you a link you can use to log in."
        )
        self.assertEqual(message.tags, "success")

测试的结果为：

$ python manage.py test accounts
[...]
    message = list(response.context['messages'])[0]
IndexError: list index out of range

然后像下面这样修改，让测试通过：

accounts/views.py (ch17l014)

from django.contrib import messages
[...]

def send_login_email(request):
    [...]
    messages.success(
        request,
        "Check your email, we've sent you a link you can use to log in."
    )
    return redirect('/')

驭件可能导致与实现紧密耦合

　这个框注涉及中级测试技巧。如果第一次没读懂，读完本章和第 23 章之后再回过头来看。

我说过，测试消息有点曲折，我试了好几次才做对。其实，我们已经不在工作中这样测试消息了，而是使用驭件。使用驭件的话，上述测试可以改成这样：

accounts/tests/test_views.py (ch17l014-2)

from unittest.mock import patch, call
[...]

    @patch('accounts.views.messages')
    def test_adds_success_message_with_mocks(self, mock_messages):
        response = self.client.post('/accounts/send_login_email', data={
            'email': 'edith@example.com'
        })

        expected = "Check your email, we've sent you a link you can use to log in."
        self.assertEqual(
            mock_messages.success.call_args,
            call(response.wsgi_request, expected),
        )

我们模拟了 messages 模块，然后检查调用 messages.success 时传入了正确的参数：原请求和想看到的消息。

使用前面的代码就能让这个测试通过。不过有个问题：对 messages 框架来说，获得相同结果的方式不止一种。视图的代码还可以像这样写：

accounts/views.py (ch17l014-3)

    messages.add_message(
        request,
        messages.SUCCESS,
        "Check your email, we've sent you a link you can use to log in."
    )

此时，未使用驭件的测试仍能通过，但使用驭件的测试将失败。这是因为没有调用 messages.success，而是调用了 messages.add_message。即便最终结果一样，而且代码也是“正确的”，可测试却出问题了。

这就是人们常说的，使用驭件可能导致“与实现紧密耦合”。我们知道，通常最好测试行为，而不测试实现细节；测试发生了什么，而不测试是如何发生的。驭件往往在“如何做”这条道上走得太远，而很少关注“是什么”。

后续章节还会深入讨论驭件的优缺点。

19.3.4　在HTML中添加消息

功能测试有进展吗？啊，还没有。我们要把消息添加到页面中，像下面这样：

lists/templates/base.html (ch17l015)

    [...]
    </nav>

    {% if messages %}
      <div class="row">
        <div class="col-md-8">
          {% for message in messages %}
            {% if message.level_tag == 'success' %}
              <div class="alert alert-success">{{ message }}</div>
            {% else %}
              <div class="alert alert-warning">{{ message }}</div>
            {% endif %}
          {% endfor %}
        </div>
      </div>
    {% endif %}

现在该有进展了吧？是的！

$ python manage.py test accounts
[...]
Ran 6 tests in 0.023s

OK

$ python manage.py test functional_tests.test_login
[...]
AssertionError: 'Use this link to log in' not found in 'body text tbc'

失败消息提醒我们，在电子邮件的正文中找出用于点击登录的链接。

先暂时作弊，直接修改视图中的值：

accounts/views.py

    send_mail(
        'Your login link for Superlists',
        'Use this link to log in',
        'noreply@superlists',
        [email]
    )

这样，功能测试稍微向前走了一点：

$ python manage.py test functional_tests.test_login
[...]
AssertionError: Could not find url in email body:
Use this link to log in

19.3.5　构建登录URL

接下来该构建某种形式的 URL 了！这一次依然作弊：

accounts/tests/test_views.py (ch17l017)

class LoginViewTest(TestCase):

    def test_redirects_to_home_page(self):
        response = self.client.get('/accounts/login?token=abcd123')
        self.assertRedirects(response, '/')

假设令牌通过 GET 参数传递，即放在 ? 后面。现在还不需要它做些什么。

我相信你能构建出所需的 URL 和视图，在这个过程中你会历经下述错误。

没有 URL：

AssertionError: 404 != 302 : Response didn't redirect as expected: Response
code was 404 (expected 302)

没有视图：

AttributeError: module 'accounts.views' has no attribute 'login'

视图出错：

ValueError: The view accounts.views.login didn't return an HttpResponse object.
It returned None instead.

测试通过：

$ python manage.py test accounts
[...]

Ran 7 tests in 0.029s

OK

现在，链接的目标 URL 有了，但是还没什么用，因为我们还没给用户提供令牌。

19.3.6　确认给用户发送了带有令牌的链接

对 send_login_email 视图来说，我们测试了电子邮件的主题、发件地址和收件地址，而包含令牌或 URL 的正文还没有测试。下面为正文编写两个测试：

accounts/tests/test_views.py (ch17l021)

from accounts.models import Token
[...]

    def test_creates_token_associated_with_email(self):
        self.client.post('/accounts/send_login_email', data={
            'email': 'edith@example.com'
        })
        token = Token.objects.first()
        self.assertEqual(token.email, 'edith@example.com')

    @patch('accounts.views.send_mail')
    def test_sends_link_to_login_using_token_uid(self, mock_send_mail):
        self.client.post('/accounts/send_login_email', data={
            'email': 'edith@example.com'
        })

        token = Token.objects.first()
        expected_url = f'http://testserver/accounts/login?token={token.uid}'
        (subject, body, from_email, to_list), kwargs = mock_send_mail.call_args
        self.assertIn(expected_url, body)

第一个测试十分简单，检查我们在数据库中创建的令牌是与 POST 请求中的电子邮件地址关联的。

第二个测试是我们第二次使用驭件。再次使用 patch 装饰器模拟 send_mail 函数，但这次关注的是调用时传入的 body 参数。

现在，这些测试是失败的，因为还没创建令牌：

$ python manage.py test accounts
[...]
AttributeError: 'NoneType' object has no attribute 'email'
[...]
AttributeError: 'NoneType' object has no attribute 'uid'

创建令牌就能让第一个测试通过：

accounts/views.py (ch17l022)

from accounts.models import Token
[...]

def send_login_email(request):
    email = request.POST['email']
    token = Token.objects.create(email=email)
    send_mail(
        [...]

第二个测试提示我们在电子邮件的正文中使用令牌：

[...]
AssertionError:
'http://testserver/accounts/login?token=[...]
not found in 'Use this link to log in'

FAILED (failures=1)

因此，像下面这样在电子邮件中插入令牌：

accounts/views.py (ch17l023)

from django.core.urlresolvers import reverse
[...]

def send_login_email(request):
    email = request.POST['email']
    token = Token.objects.create(email=email)
    url = request.build_absolute_uri(  ➊
        reverse('login') + '?token=' + str(token.uid)
    )
    message_body = f'Use this link to log in:\n\n{url}'
    send_mail(
        'Your login link for Superlists',
        message_body,
        'noreply@superlists',
        [email]
    )
[...]

➊ 请注意 request.build_absolute_uri，这是在 Django 中构建“完整”URL 的一种方式，所得的 URL 包括域名和协议（http/https）。除此之外还有其他方式，不过往往都牵扯“网站”框架，容易导致代码过于复杂。如果你好奇，简单搜索一下就能找到很多这方面的讨论。

我们又解决了两个问题。接下来，需要一个身份验证后端，检查令牌的有效性，然后返回对应的用户。此外，还需要让登录视图在用户通过身份验证后登入用户。

19.4　去除自定义的身份验证后端中的探究代码

接下来要自定义身份验证后端。探究时编写的代码如下所示：

class PasswordlessAuthenticationBackend(object):

    def authenticate(self, uid):
        print('uid', uid, file=sys.stderr)
        if not Token.objects.filter(uid=uid).exists():
            print('no token found', file=sys.stderr)
            return None
        token = Token.objects.get(uid=uid)
        print('got token', file=sys.stderr)
        try:
            user = ListUser.objects.get(email=token.email)
            print('got user', file=sys.stderr)
            return user
        except ListUser.DoesNotExist:
            print('new user', file=sys.stderr)
            return ListUser.objects.create(email=token.email)

    def get_user(self, email):
        return ListUser.objects.get(email=email)

这段代码的意思是：

检查数据库中是否有指定的 UID；

如果没有，返回 None；

如果有，提取电子邮件地址，通过这个地址找到现有的用户，或者创建一个新用户。

19.4.1　一个if语句需要一个测试

如何为这种函数编写测试有个经验法则：一个 if 语句需要一个测试，一个 try/except 语句也需要一个测试。所以这里一共需要三个测试。像下面这样写怎么样？

accounts/tests/test_authentication.py

from django.test import TestCase
from django.contrib.auth import get_user_model
from accounts.authentication import PasswordlessAuthenticationBackend
from accounts.models import Token
User = get_user_model()

class AuthenticateTest(TestCase):

    def test_returns_None_if_no_such_token(self):
        result = PasswordlessAuthenticationBackend().authenticate(
            'no-such-token'
        )
        self.assertIsNone(result)

    def test_returns_new_user_with_correct_email_if_token_exists(self):
        email = 'edith@example.com'
        token = Token.objects.create(email=email)
        user = PasswordlessAuthenticationBackend().authenticate(token.uid)
        new_user = User.objects.get(email=email)
        self.assertEqual(user, new_user)

    def test_returns_existing_user_with_correct_email_if_token_exists(self):
        email = 'edith@example.com'
        existing_user = User.objects.create(email=email)
        token = Token.objects.create(email=email)
        user = PasswordlessAuthenticationBackend().authenticate(token.uid)
        self.assertEqual(user, existing_user)

在 authenticate.py 中先放一个占位函数：

accounts/authentication.py

class PasswordlessAuthenticationBackend(object):

    def authenticate(self, uid):
        pass

测试的结果如何？

$ python manage.py test accounts

.FE.........
======================================================================
ERROR: test_returns_new_user_with_correct_email_if_token_exists
(accounts.tests.test_authentication.AuthenticateTest)
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "/.../superlists/accounts/tests/test_authentication.py", line 21, in
test_returns_new_user_with_correct_email_if_token_exists
    new_user = User.objects.get(email=email)
[...]
accounts.models.DoesNotExist: User matching query does not exist.

======================================================================
FAIL: test_returns_existing_user_with_correct_email_if_token_exists
(accounts.tests.test_authentication.AuthenticateTest)
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "/.../superlists/accounts/tests/test_authentication.py", line 30, in
test_returns_existing_user_with_correct_email_if_token_exists
    self.assertEqual(user, existing_user)
AssertionError: None != <User: User object>

 ---------------------------------------------------------------------
Ran 12 tests in 0.038s

FAILED (failures=1, errors=1)

修改代码试试：

accounts/authentication.py (ch17l026)

from accounts.models import User, Token

class PasswordlessAuthenticationBackend(object):

    def authenticate(self, uid):
        token = Token.objects.get(uid=uid)
        return User.objects.get(email=token.email)

一个测试通过了，但却导致另一个失败了：

$ python manage.py test accounts
ERROR: test_returns_None_if_no_such_token
(accounts.tests.test_authentication.AuthenticateTest)

accounts.models.DoesNotExist: Token matching query does not exist.

ERROR: test_returns_new_user_with_correct_email_if_token_exists
(accounts.tests.test_authentication.AuthenticateTest)
[...]
accounts.models.DoesNotExist: User matching query does not exist.

下面逐个修正：

accounts/authentication.py (ch17l027)

    def authenticate(self, uid):
        try:
            token = Token.objects.get(uid=uid)
            return User.objects.get(email=token.email)
        except Token.DoesNotExist:
            return None

只剩一个失败测试了：

ERROR: test_returns_new_user_with_correct_email_if_token_exists
(accounts.tests.test_authentication.AuthenticateTest)
[...]
accounts.models.DoesNotExist: User matching query does not exist.

FAILED (errors=1)

这个问题可以像这样解决：

accounts/authentication.py (ch17l028)

    def authenticate(self, uid):
        try:
            token = Token.objects.get(uid=uid)
            return User.objects.get(email=token.email)
        except User.DoesNotExist:
            return User.objects.create(email=token.email)
        except Token.DoesNotExist:
            return None

这比探究时编写的代码更简洁！

19.4.2　get_user方法

我们已经实现了供 Django 用于登入新用户的 authenticate 函数。这个协议的第二部分是实现 get_user 方法，它的作用是根据唯一标识符（电子邮件地址）获取用户，如果找不到就返回 None（如果你记不清了，请看一下本节开头给出的探究代码）。

下面为这两个需求编写几个测试：

accounts/tests/test_authentication.py (ch17l030)

class GetUserTest(TestCase):

    def test_gets_user_by_email(self):
        User.objects.create(email='another@example.com')
        desired_user = User.objects.create(email='edith@example.com')
        found_user = PasswordlessAuthenticationBackend().get_user(
            'edith@example.com'
        )
        self.assertEqual(found_user, desired_user)

        def test_returns_None_if_no_user_with_that_email(self):
            self.assertIsNone(
                PasswordlessAuthenticationBackend().get_user('edith@example.com')
            )

这时的失败消息是：

AttributeError: 'PasswordlessAuthenticationBackend' object has no attribute
'get_user'

那就定义一个占位方法：

accounts/authentication.py (ch17l031)

class PasswordlessAuthenticationBackend(object):

    def authenticate(self, uid):
        [...]
    def get_user(self, email):
        pass

现在失败消息变成了：

    self.assertEqual(found_user, desired_user)
AssertionError: None != <User: User object>

慢慢实现这个方法（一步一步来，看测试是否像我们设想的那样失败）：

accounts/authentication.py (ch17l033)

    def get_user(self, email):
        return User.objects.first()

现在第一个断言通过了，失败消息变成了：

    self.assertEqual(found_user, desired_user)
AssertionError: <User: User object> != <User: User object>

那就调用 get，并传入电子邮件地址：

accounts/authentication.py (ch17l034)

    def get_user(self, email):
        return User.objects.get(email=email)

现在针对返回 None 的测试失败了：

ERROR: test_returns_None_if_no_user_with_that_email
[...]
accounts.models.DoesNotExist: User matching query does not exist.

根据提示，可以这样完成整个方法：

accounts/authentication.py (ch17l035)

    def get_user(self, email):
        try:
            return User.objects.get(email=email)
        except User.DoesNotExist:
            return None  ➊

➊ 这里可以使用 pass，函数默认会返回 None。但我们希望它明确返回 None，所以根据“明了胜于晦涩”原则，应该返回 None。

现在测试能通过了：

OK

至此，我们得到了一个可用的身份验证后端。

19.4.3　在登录视图中使用自定义的验证后端

最后一步，在登录视图中使用这个后端。首先，在 settings.py 中添加自定义的后端：

superlists/settings.py (ch17l036)

AUTH_USER_MODEL = 'accounts.User'
AUTHENTICATION_BACKENDS = [
    'accounts.authentication.PasswordlessAuthenticationBackend',
]

[...]

然后编写几个测试，检查视图的行为。再看一下探究时编写的视图：

accounts/views.py

def login(request):
    print('login view', file=sys.stderr)
    uid = request.GET.get('uid')
    user = auth.authenticate(uid=uid)
    if user is not None:
        auth.login(request, user)
    return redirect('/')

视图要调用 django.contrib.auth.authenticate。如果返回一个用户，再调用 django.contrib.auth.login。

　现在应该阅读 Django 文档中对身份验证的说明，进一步了解细节。

19.5　使用驭件的另一个原因：减少重复

我们已经使用驭件测试了外部依赖，例如 Django 发送电子邮件的功能。使用驭件的主要原因是隔离外部副作用，这里是想避免在测试中真的发送电子邮件。

本节将说明驭件的另一种用法。此时，没有什么副作用需要担心，但是鉴于一些原因，我们仍然想使用驭件。

如果不使用驭件，测试登录视图要检查用户有没有真的登入，即检查在正确的情况下有没有为用户赋予表明已通过身份验证的会话 cookie。

但是，我们自定义的身份验证后端有几个不同的代码执行路径：令牌无效时返回 None、用户存在时返回既存用户、令牌有效但用户不存在时新建用户。因此，为了全面测试这个视图，要分别针对这三种情况编写测试。

　如果能有效减少测试之间的重复，就有充分的理由使用驭件。这是避免组合爆炸的一种方式。

此外，我们使用的是 Django 的 auth.authenticate 函数，而没有直接调用自己的代码，这样便于以后添加额外的后端。

因此，有必要考虑这里的实现细节（与“驭件可能导致与实现紧密耦合”框注中所述的相反），而使用驭件能避免在多个测试中重复编写实现方式。下面看一下这个测试应该怎样使用驭件编写：

accounts/tests/test_views.py (ch17l037)

from unittest.mock import patch, call
[...]

    @patch('accounts.views.auth')  ➊
    def test_calls_authenticate_with_uid_from_get_request(self, mock_auth):  ➋
        self.client.get('/accounts/login?token=abcd123')
        self.assertEqual(
            mock_auth.authenticate.call_args,  ➌
            call(uid='abcd123')  ➍
        )

❶ 我们期望在 views.py 中使用 django.contrib.auth 模块，所以这里模拟它的行为。注意，这里模拟的不是一个函数，而是整个模块，即模拟模块中的全部函数（及其他对象）。

❷ 与之前一样，把被模拟的对象注入测试方法。

❸ 这一次模拟的是一个模块，而不是一个函数。所以，call_args 不能在 mock_auth 模块上检查，而要在 mock_auth.authenticate 函数上检查。因为驭件的所有属性都是驭件，所以 mock_auth.authenticate 函数也是一个驭件。由此可以看出，与自己动手相比，mock 对象是多么好用。

❹ 这一次没有“拆包”调用参数，而是使用更简洁的 call 函数，指明调用时应该传入什么参数——GET 请求中的令牌。（参见下述框注。）

关于驭件的 call_args

驭件的 call_args 属性表示调用驭件时传入的位置参数和关键字参数。这是一个特殊的“调用”对象类型，其实是一个元组，内容为(positional_args, keyword_args)。其中 positional_args 自身也是一个元组，包含各个位置参数；而 keyword_args 是个字典。

>>> from unittest.mock import Mock, call
>>> m = Mock()
>>> m(42, 43, 'positional arg 3', key='val', thing=666)
<Mock name='mock()' id='139909729163528'>

>>> m.call_args
call(42, 43, 'positional arg 3', key='val', thing=666)

>>> m.call_args == ((42, 43, 'positional arg 3'), {'key': 'val', 'thing': 666})
True
>>> m.call_args == call(42, 43, 'positional arg 3', key='val', thing=666)
True

所以，在上述测试中还可以这么写：

accounts/tests/test_views.py

     self.assertEqual(
         mock_auth.authenticate.call_args,
         ((,), {'uid': 'abcd123'})
     )
     # 或者这样写
     args, kwargs = mock_auth.authenticate.call_args
     self.assertEqual(args, (,))
     self.assertEqual(kwargs, {'uid': 'abcd123')

不过可以看出，使用 call 辅助函数更简洁。

测试是什么情况呢？第一个错误是：

$ python manage.py test accounts
[...]
AttributeError: <module 'accounts.views' from
'/.../superlists/accounts/views.py'> does not have the attribute 'auth'

　在使用驭件的测试中，第一个失败消息经常是 module foo does not have the attribute bar。这个消息的意思是，你尝试模拟的东西在目标模块中还不存在（或者尚未导入）。

导入 django.contrib.auth 后，错误会变：

accounts/views.py (ch17l038)

from django.contrib import auth, messages
[...]

现在的错误是：

AssertionError: None != call(uid='abcd123')

测试指出，视图根本没有调用 auth.authenticate 函数。下面修正，但是故意做错，看看效果如何：

accounts/views.py (ch17l039)

def login(request):
    auth.authenticate('bang!')
    return redirect('/')

调用的确实是 bang!：

$ python manage.py test accounts
[...]
AssertionError: call('bang!') != call(uid='abcd123')
[...]
FAILED (failures=1)

下面给 authenticate 提供预期的参数：

accounts/views.py (ch17l040)

def login(request):
    auth.authenticate(uid=request.GET.get('token'))
    return redirect('/')

现在测试通过了：

$ python manage.py test accounts
[...]
Ran 15 tests in 0.041s

OK

19.5.1　使用驭件的返回值

接下来，要检查 authenticate 函数是否返回一个用户，供 auth.login 使用。测试像下面这样编写：

accounts/tests/test_views.py (ch17l041)

@patch('accounts.views.auth')  ➊
def test_calls_auth_login_with_user_if_there_is_one(self, mock_auth):
    response = self.client.get('/accounts/login?token=abcd123')
    self.assertEqual(
        mock_auth.login.call_args,  ➋
        call(response.wsgi_request, mock_auth.authenticate.return_value)  ➌
    )

❶ 还是模拟 contrib.auth 模块。

❷ 这一次检查 auth.login 函数的调用参数。

❸ 检查调用的参数是不是视图收到的请求对象，以及 authenticate 函数返回的“用户”对象。因为 authenticate 也是驭件，所以可以使用特殊的 return_value 属性。

调用驭件的结果是得到另一个驭件。不过，我们也可以从调用的原驭件中获得返回的驭件副本。为了解释清楚，我不得不多次使用“驭件”这个词。下面在控制台中演示一下，希望能帮助你理解：

>>> m = Mock()
>>> thing = m()
>>> thing
<Mock name='mock()' id='140652722034952'>
>>> m.return_value
<Mock name='mock()' id='140652722034952'>
>>> thing == m.return_value
True

先不管这些，我们想知道测试的结果如何：

$ python manage.py test accounts
[...]
    call(response.wsgi_request, mock_auth.authenticate.return_value)
AssertionError: None != call(<WSGIRequest: GET '/accounts/login?t[...]

显然，测试指出我们根本没有调用 auth.login。下面调用它。这一次还是故意做错。

accounts/views.py (ch17l042)

def login(request):
    auth.authenticate(uid=request.GET.get('token'))
    auth.login('ack!')
    return redirect('/')

调用的确实是 ack!：

TypeError: login() missing 1 required positional argument: 'user'
[...]
AssertionError: call('ack!') != call(<WSGIRequest: GET
'/accounts/login?token=[...]

下面修正：

accounts/views.py (ch17l043)

def login(request):
    user = auth.authenticate(uid=request.GET.get('token'))
    auth.login(request, user)
    return redirect('/')

这一次得到了意料之外的失败：

ERROR: test_redirects_to_home_page (accounts.tests.test_views.LoginViewTest)
[...]
AttributeError: 'AnonymousUser' object has no attribute '_meta'

这是因为我们在所有情况下都调用 auth.login，从而导致针对重定向的测试（目前没有模拟 auth.login）出问题。为了修正这个问题，我们要添加一个 if（外加一个测试）。届时，我们将学习如何在类一级上打补丁。

19.5.2　在类一级上打补丁

我们还要编写一个测试，而且也要使用 @patch('accounts.views.auth') 装饰，这样便开始出现重复了。根据“三则重构”原则，可以把 patch 装饰器移到类一级上。这样，测试类中的每一个测试方法都将模拟 accounts.views.auth。不过，这也意味着之前针对重定向的测试也要注入 mock_auth 变量：

accounts/tests/test_views.py (ch17l044)

@patch('accounts.views.auth')  ➊
class LoginViewTest(TestCase):

    def test_redirects_to_home_page(self, mock_auth):  ➋
        [...]

    def test_calls_authenticate_with_uid_from_get_request(self, mock_auth):  ➌
        [...]

    def test_calls_auth_login_with_user_if_there_is_one(self, mock_auth):  ➌
        [...]

    def test_does_not_login_if_user_is_not_authenticated(self, mock_auth):
        mock_auth.authenticate.return_value = None  ➍
        self.client.get('/accounts/login?token=abcd123')
        self.assertEqual(mock_auth.login.called, False)  ❺

❶ 把 patch 装饰器移到类一级……

❷ 所以第一个测试方法多了一个参数……

❸ 而且可以删掉其他测试上的装饰器。

❹ 在新测试中，调用 self.client.get 之前在 auth.authenticate 驭件上设定 return_value。

❺ 下断言，在 authenticate 返回 None 时，不应该调用 auth.login。

现在假失败不见了，得到了意义明确的预期失败：

    self.assertEqual(mock_auth.login.called, False)
AssertionError: True != False

像这样调整视图，让测试通过：

accounts/views.py (ch17l045)

def login(request):
    user = auth.authenticate(uid=request.GET.get('token'))
    if user:
        auth.login(request, user)
    return redirect('/')

可以收工了吗？

19.6　关键时刻：功能测试能通过吗

我觉得应该看一下功能测试结果如何了。下面修改基模板，为已登录用户和未登录用户显示不同的导航栏（功能测试检查的就是这个）：

lists/templates/base.html (ch17l046)

<nav class="navbar navbar-default" role="navigation">
  <div class="container-fluid">
    <a class="navbar-brand" href="/">Superlists</a>
    {% if user.email %}
      <ul class="nav navbar-nav navbar-right">
        <li class="navbar-text">Logged in as {{ user.email }}</li>
        <li><a href="#">Log out</a></li>
      </ul>
    {% else %}
      <form class="navbar-form navbar-right"
            method="POST"
            action="{% url 'send_login_email' %}">
        <span>Enter email to log in:</span>
        <input class="form-control" name="email" type="text" />
        {% csrf_token %}
      </form>
    {% endif %}
  </div>
</nav>

看看测试能否通过：

$ python manage.py test functional_tests.test_login
Internal Server Error: /accounts/login
[...]
  File "/.../superlists/accounts/views.py", line 31, in login
    auth.login(request, user)
[...]
ValueError: The following fields do not exist in this model or are m2m fields:
last_login
[...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: Log out

噢，不！出问题了。如果 settings.py 中还保留着前面设定的 LOGGING，你应该会看到如上所示的详细调用跟踪。可以看出，问题与 last_login 字段有关。

我觉得这是 Django 的缺陷，可验证框架就是预期用户模型有个 last_login 字段，而我们的用户模型没有。不过，别害怕！解决方法总是有的。

先写一个单元测试重现这个缺陷。因为这与我们自定义的用户模型有关，所以放在 test_models.py 中比较合适：

accounts/tests/test_models.py (ch17l047)

from django.test import TestCase
from django.contrib import auth
from accounts.models import Token
User = auth.get_user_model()

class UserModelTest(TestCase):

    def test_user_is_valid_with_email_only(self):
        [...]
    def test_email_is_primary_key(self):
        [...]

    def test_no_problem_with_auth_login(self):
        user = User.objects.create(email='edith@example.com')
        user.backend = ''
        request = self.client.request().wsgi_request
        auth.login(request, user) # 不该抛出异常

创建一个请求对象和一个用户，然后把它们传给 auth.login 函数。

这个测试会向我们报告错误：

    auth.login(request, user)  # 不该抛出异常
[...]
ValueError: The following fields do not exist in this model or are m2m fields:
last_login

这个缺陷的具体原因其实跟本书没有多大关系，如果你想追根究底，可以看一下调用跟踪中给出的那几行 Django 源码，再读一下 Django 文档中对信号的说明。

重点是，我们可以像这样修正：

accounts/models.py (ch17l048)

import uuid
from django.contrib import auth
from django.db import models

auth.signals.user_logged_in.disconnect(auth.models.update_last_login)

class User(models.Model):
    [...]

现在功能测试的结果如何了？

$ python manage.py test functional_tests.test_login
[...]
.
 ---------------------------------------------------------------------
Ran 1 test in 3.282s

OK

19.7　理论上正常，那么实际呢

哇呜！你能相信吗？我简直不能相信！下面执行 runserver 命令，亲自检查一下：

$ python manage.py runserver
[...]
Internal Server Error: /accounts/send_login_email
Traceback (most recent call last):
  File "/.../superlists/accounts/views.py", line 20, in send_login_email

ConnectionRefusedError: [Errno 111] Connection refused

自己动手检查时，你可能会像我一样遇到一个错误。可能的解决方法有两个。

在 settings.py 中重新配置电子邮件。

可能要在 shell 中导出电子邮件的密码。

superlists/settings.py (ch17l049)

EMAIL_HOST = 'smtp.gmail.com'
EMAIL_HOST_USER = 'obeythetestinggoat@gmail.com'
EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_PASSWORD')
EMAIL_PORT = 587
EMAIL_USE_TLS = True

以及：

$ export EMAIL_PASSWORD="sekrit"
$ python manage.py runserver

然后便能看到如图 19-1 所示的界面。

图 19-1：查看你的邮件……

太棒了！

在此之前我一直没提交，因为我在等待一切都能顺利运行的时刻。此时，你可以做一系列单独的提交——登录视图一次、验证后端一次、用户模型一次、修改模板一次。或者，考虑到这些代码都有关联，不能独自运行，也可以做一次大提交：

$ git status
$ git add .
$ git diff --staged
$ git commit -m "Custom passwordless auth backend + custom user model"

19.8　完善功能测试，测试退出功能

收工之前还有最后一件事要做：测试退出链接。在现有功能测试的基础上添加几步：

functional_tests/test_login.py (ch17l050)

        [...]
        # 她登录了！
        self.wait_for(
            lambda: self.browser.find_element_by_link_text('Log out')
        )
        navbar = self.browser.find_element_by_css_selector('.navbar')
        self.assertIn(TEST_EMAIL, navbar.text)

        # 现在她要退出
        self.browser.find_element_by_link_text('Log out').click()

        # 她退出了
        self.wait_for(
            lambda: self.browser.find_element_by_name('email')
        )
        navbar = self.browser.find_element_by_css_selector('.navbar')
        self.assertNotIn(TEST_EMAIL, navbar.text)

这样修改之后，测试会失败，原因是退出按钮没起作用：

$ python manage.py test functional_tests.test_login
[...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: [name="email"]

实现退出按钮的方法其实很简单：可以使用 Django 内置的退出视图，让它清空用户的会话，然后重定向到我们指定的页面。

accounts/urls.py (ch17l051)

from django.contrib.auth.views import logout
[...]

urlpatterns = [
    url(r'^send_login_email$', views.send_login_email, name='send_login_email'),
    url(r'^login$', views.login, name='login'),
    url(r'^logout$', logout, {'next_page': '/'}, name='logout'),
]

然后在 base.html 中，让退出按钮指向一个真的 URL：

lists/templates/base.html (ch17l052)

    <li><a href="{% url 'logout' %}">Log out</a></li>

现在，功能测试都能通过了。其实，整个测试组件都可以通过：

$ python manage.py test functional_tests.test_login
[...]
OK

$ python manage.py test
[...]
Ran 59 tests in 78.124s

OK

　我们离真正安全或可行的登录系统还远着呢！这只是一本书的示例应用，可以就此结束。但是在实际使用中，你得研究很多安全和可用性问题，要做的事还多着呢！这里，我们以身犯险，“自己动手实现加密”——其实依赖现有的登录系统将安全得多。

下一章将充分利用这个登录系统。现在，做次提交，然后阅读总结。

在 Python 中使用模拟技术

模拟技术和外部依赖

编写单元测试时，如果涉及外部依赖，但又不想在测试中真的使用那个依赖，就可以使用模拟技术。驭件用于模拟第三方 API。虽然在 Python 中可以自己创建驭件，但模拟框架（例如 mock 模块）可以提供很多便利，让编写测试变得更简单。更重要的是，能让测试读起来更顺口。

打猴子补丁

在运行时替换某个命名空间中的对象。前面的单元测试使用驭件（通过 patch 装饰器）替代有额外副作用的真实函数。

Mock 库

Michael Foord（在我加入 PythonAnywhere 之前，他在孕育 PythonAnywhere 的公司工作）开发了很优秀的 Mock 库，现在这个库已经集成到 Python 3 的标准库中。这个库包含了在 Python 中使用模拟技术所需的几乎全部功能。

patch 装饰器

unittest.mock 模块提供的 patch 函数可用于模拟要测试的模块中的任何一个对象。patch 一般用来装饰测试方法，不过也可以放在类一级，应用到类中的所有测试方法上。

驭件可能导致与实现紧密耦合

如前文的旁注所述，驭件可能导致与实现紧密耦合。鉴于此，除非有足够的理由，否则不应该使用驭件。

驭件能减少测试中的重复

而另一方面，在测试中又没必要重复编写使用某个函数的高层级代码。此时使用驭件能减少重复。

接下来还将更为深入地讨论驭件的优缺点。敬请期待！

第 20 章　测试固件和一个显式等待装饰器

有了一个可以使用的认证系统，现在使用这个系统来识别用户，展示用户创建的所有清单。

为此，要在功能测试中使用已经登录的用户对象。但不能每个测试都走一遍发送登录邮件过程，这么做浪费时间，所以跳过这一步。

这就是分离关注点。功能测试和单元测试的区别在于，前者往往不止有一个断言。但是，理论上一个测试只应该测试一件事，所以没必要在每个功能测试中都测试登录和退出功能。如果能找到一种方法“作弊”，跳过认证，就不用花时间等待执行完重复的测试路径了。

　在功能测试中去除重复时不要做得过火了。功能测试的优势之一是，可以捕获应用不同部分之间交互时产生的神秘莫测的表现。

　本章专为这一版重写了。如果遇到问题，或者有改进建议，请通过 obeythetestinggoat@gmail.com 告诉我。

20.1　事先创建好会话，跳过登录过程

用户再次访问网站时 cookie 依然存在，这种现象很常见。也就是说，之前用户已经通过认证了。所以这种“作弊”手段并非异想天开。具体的做法如下：

functional_tests/test_my_lists.py

from django.conf import settings
from django.contrib.auth import BACKEND_SESSION_KEY, SESSION_KEY, get_user_model
from django.contrib.sessions.backends.db import SessionStore
from .base import FunctionalTest
User = get_user_model()

class MyListsTest(FunctionalTest):

    def create_pre_authenticated_session(self, email):
        user = User.objects.create(email=email)
        session = SessionStore()
        session[SESSION_KEY] = user.pk  ➊
        session[BACKEND_SESSION_KEY] = settings.AUTHENTICATION_BACKENDS[0]
        session.save()
        ## 为了设定cookie，我们要先访问网站
        ## 而404页面是加载最快的
        self.browser.get(self.live_server_url + "/404_no_such_url/")
        self.browser.add_cookie(dict(
            name=settings.SESSION_COOKIE_NAME,
            value=session.session_key,  ➋
            path='/',
        ))

❶ 在数据库中创建一个会话对象。会话键的值是用户对象的主键，即用户的电子邮件地址。

❷ 然后把一个 cookie 添加到浏览器中，cookie 的值和服务器中的会话匹配。这样再次访问网站时，服务器就能识别已登录的用户。

注意，这种做法仅在使用 LiveServerTestCase 时才有效，所以已创建的 User 和 Session 对象只存在于测试服务器的数据库中。稍后修改实现的方式，让这个测试也能在过渡服务器里的数据库中运行。

Django 会话：通过 cookie 告知服务器，用户已通过身份验证

我斗胆尝试说明 Django 中的会话、cookie 和身份验证。

HTTP 是无状态的，服务器需要通过某种方式识别每次请求是哪个客户端发送的。IP 地址可以共享，因此常用的方案是为每个客户端分配一个唯一的会话ID，存储在 cookie 中，随每次请求发送。服务器会把这个 ID 存储在某处（默认存储在数据库中），从而识别请求是哪个客户端发送的。

在开发服务器中登录网站后，如果愿意，可以自己动手查看会话ID，它默认存储在 sessionid 键名下，如图 20-1 所示。

图 20-1：在调试工具中查看会话 cookie

Django 网站会为所有访客设定会话 cookie，不管有没有登录。

为了识别已登录的用户（即通过身份验证），服务器不会让客户端每次请求都发送用户名和密码，而是把客户端的会话标记为已通过验证的会话，并把它与数据库中的用户 ID 关联起来。

会话是类似字典的数据结构，用户 ID 存储在 django.contrib.auth.SESSION_KEY 设定的键名下。如果想查看会话的值，可以打开 ./manage.py shell：

$ python manage.py shell
[...]
In [1]: from django.contrib.sessions.models import Session

# 替换成你浏览器cookie中的会话ID
In [2]: session = Session.objects.get(
    session_key="8u0pygdy9blo696g3n4o078ygt6l8y0y"
)

In [3]: print(session.get_decoded())
{'_auth_user_id': 'obeythetestinggoat@gmail.com', '_auth_user_backend':
'accounts.authentication.PasswordlessAuthenticationBackend'}

你还可以在用户的会话中存储其他信息，作为一种临时跟踪状态的方式。这对未登录的用户也是有效的。如果想这么做，只需在任意视图中使用 request.session，它与字典的操作方式一样。详情参见 Django 文档对会话的说明。

检查是否可行

要检查这种做法是否可行，我们要使用现有测试中的一些代码。下面分别定义两个方法： wait_to_be_logged_in 和 wait_to_be_logged_out。要想在不同的测试中访问这两个方法，要把它们放到 FunctionalTest 类中。此外，我们还得稍微修改一下，让它们可以接收任意的电子邮件地址作为参数：

functional_tests/base.py (ch18l002)

class FunctionalTest(StaticLiveServerTestCase):
    [...]

    def wait_to_be_logged_in(self, email):
        self.wait_for(
            lambda: self.browser.find_element_by_link_text('Log out')
        )
        navbar = self.browser.find_element_by_css_selector('.navbar')
        self.assertIn(email, navbar.text)

    def wait_to_be_logged_out(self, email):
        self.wait_for(
            lambda: self.browser.find_element_by_name('email')
        )
        navbar = self.browser.find_element_by_css_selector('.navbar')
        self.assertNotIn(email, navbar.text)

嗯，还不错，但我不太喜欢这里重复出现的 wait_for 部分。先在便签上记录下来，稍后再修改，让这两个辅助方法可用。

首先，在 test_login.py 中使用它们：

functional_tests/test_login.py (ch18l003)

    def test_can_get_email_link_to_log_in(self):
        [...]
        # 她登录了！
        self.wait_to_be_logged_in(email=TEST_EMAIL)

        # 现在她要退出
        self.browser.find_element_by_link_text('Log out').click()

        # 她退出了
        self.wait_to_be_logged_out(email=TEST_EMAIL)

为了确认我们没有破坏现有功能，再次运行登录测试：

$ python manage.py test functional_tests.test_login
[...]
OK

现在可以为“My Lists”页面编写一个占位测试，检查事先创建认证会话的做法是否可行：

functional_tests/test_my_lists.py (ch18l004)

    def test_logged_in_users_lists_are_saved_as_my_lists(self):
        email = 'edith@example.com'
        self.browser.get(self.live_server_url)
        self.wait_to_be_logged_out(email)
        # 伊迪丝是已登录用户
        self.create_pre_authenticated_session(email)
        self.browser.get(self.live_server_url)
        self.wait_to_be_logged_in(email)

测试的结果为：

$ python manage.py test functional_tests.test_my_lists
[...]
OK

现在是提交的好时机：

$ git add functional_tests
$ git commit -m "test_my_lists: precreate sessions, move login checks into base"

JSON 格式的测试固件有危害

使用测试数据预先填充数据库的过程，例如存储 User 对象及其相关的 Session 对象，叫作设置“测试固件”（test fixture）。

Django 原生支持把数据库中的数据保存为JSON 格式（使用 manage.py dumpdata 命令）。如果在 TestCase 中使用类属性 fixtures，运行测试时 Django 会自动加载 JSON 格式的数据。

越来越多的人建议不要使用 JSON 格式的固件。如果修改了模型，这种固件维护起来简直就像噩梦一般。此外，对阅读代码的人来说，JSON 固件中众多的属性值让人分不清哪些是对所测试的行为至关重要的，而哪些只是用于补白的。最后，即便从一开始就共用固件，迟早会有测试需要稍微不同的数据，如此一来，为了区分开，只能到处复制整个固件，从而导致无法区分哪些与测试有关，哪些只是恰巧在那儿。

　通常，直接使用 Django ORM 加载数据要简单得多。如果模型中的字段太多，或者模型之间有关联，即使使用 ORM 也有点烦琐。此时，可以使用一个备受推崇的工具，名为 factory_boy。

20.2　显式等待辅助方法最终版：wait装饰器

我们的代码多次用到装饰器。下面我们要自己定义一个，学习装饰器的工作原理。

首先，我们要想好装饰器的作用。我们的计划是，让这个装饰器替代 wait_for_row_in_list_table 中的等待 - 重试 - 超时逻辑，以及 wait_to_be_logged_in/out 中对 self.wait_for 的调用，就像这样：

functional_tests/base.py (ch18l005)

    @wait
    def wait_for_row_in_list_table(self, row_text):
        table = self.browser.find_element_by_id('id_list_table')
        rows = table.find_elements_by_tag_name('tr')
        self.assertIn(row_text, [row.text for row in rows])

    @wait
    def wait_to_be_logged_in(self, email):
        self.browser.find_element_by_link_text('Log out')
        navbar = self.browser.find_element_by_css_selector('.navbar')
        self.assertIn(email, navbar.text)

    @wait
    def wait_to_be_logged_out(self, email):
        self.browser.find_element_by_name('email')
        navbar = self.browser.find_element_by_css_selector('.navbar')
        self.assertNotIn(email, navbar.text)

做好准备了吗？装饰器难以理解（我自己花了好长时间才理解，而且每次自己定义时都要仔细回想），但好消息是我们在 self.wait_for 辅助函数中已经接触过函数式编程了。在函数式编程中，我们把一个函数作为参数传给另一个参数，装饰器也是这个道理。装饰器的不同之处在于，它并不执行代码，而是返回指定函数修改后的版本。

我们这个装饰器要返回一个新函数，它不断调用指定的函数，并捕获常规的异常，直到超时为止。下面是首次尝试：

functional_tests/base.py (ch18l006)

def wait(fn):  ➊
    def modified_fn():  ➌
        start_time = time.time()
        while True:  ➍
            try:
                return fn()
            except (AssertionError, WebDriverException) as e:  ➍
                if time.time() - start_time > MAX_WAIT:
                    raise e
                time.sleep(0.5)
    return modified_fn  ➋

❶ 装饰器的作用是修改函数，其参数是一个函数……

❷ ……而返回的则是修改后（或“装饰后”）的版本。

❸ 创建函数的修改版。

❹ 这是我们熟悉的循环，它一直运行着，捕获常规的异常，直到超时为止。

❺ 与之前一样，如果没有异常，立即调用传入的函数，然后返回。

这么定义基本上是可以的，但是还不完全正确，不信运行测试看看：

$ python manage.py test functional_tests.test_my_lists
[...]
    self.wait_to_be_logged_out(email)
TypeError: modified_fn() takes 0 positional arguments but 2 were given

与 self.wait_for 不同，这个装饰器依附的函数是有参数的：

functional_tests/base.py

    @wait
    def wait_to_be_logged_in(self, email):
        self.browser.find_element_by_link_text('Log out')

wait_to_be_logged_in 有两个位置参数，分别是 self 和 email。但是，经装饰替换为 modified_fn 之后，参数没有了。我们应该怎么做才能把被装饰的 fn 的参数传给 modified_fn 呢？

答案涉及一点 Python 魔法，*args 和 **kwargs，即人们熟知的“变长参数”（我也是刚知道）。

functional_tests/base.py (ch18l007)

def wait(fn):
    def modified_fn(*args, **kwargs):  ➊
        start_time = time.time()
        while True:
            try:
                return fn(*args, **kwargs)  ➋
            except (AssertionError, WebDriverException) as e:
                if time.time() - start_time > MAX_WAIT:
                    raise e
                time.sleep(0.5)
    return modified_fn

❶ 把 modified_fn 的参数设为 *args 和 **kwargs，以此指定它可以接受任意个位置参数和关键字参数。

❷ 在函数的定义中指定之后，还要把它们传给我们真正要调用的 fn。

通过这种方式还可以让装饰器修改函数的参数，但是这里不展开讨论了。现在的重点是，装饰器可用了：

$ python manage.py test functional_tests.test_my_lists
[...]
OK

你知道真正让人高兴的是什么吗？ self.wait_for 辅助函数也可以使用 wait 装饰器了，如下所示：

functional_tests/base.py (ch18l008)

    @wait
    def wait_for(self, fn):
        return fn()

妙啊！现在等待 - 重试逻辑封装到一个地方了，而且还可以灵活设定等待，既可以在功能测试内部调用 self.wait_for，也可以在任何辅助函数上使用 @wait 装饰器。

下一章将把代码部署到过渡服务器上，并在服务器上使用预先验证身份的会话固件。你将看到，这个过程能帮我们找出一两个 bug。

本章所学

装饰器很棒

通过装饰器，可以抽象不同层次的关注点。本章定义的装饰器可以让测试中的断言不同时等待。

谨慎去除功能测试中的重复

没必要让每个功能测试都测试应用的全部功能。在这一章遇到的情况中，我们想避免在每个需要已验证身份的用户的功能测试中走一遍整个登录流程，所以使用测试固件“作弊”，跳过登录过程。在功能测试中，还可能需要跳过其他过程。不过，我要提醒一下，功能测试的目的是捕获应用不同部分之间交互时的异常表现，所以去除重复时一定要谨慎，别过火了。

测试固件

测试固件指运行测试之前要提前准备好的测试数据，通常是指使用一些数据填充数据库。不过如前所示（创建浏览器的 cookie），也会涉及其他准备工作。

避免使用 JSON 固件

Django 提供的 dumpdata 和 loaddata 等命令简化了把数据库中的数据导出为 JSON 格式的操作，也可以轻易使用 JSON 格式的数据还原数据库。多数人都不建议使用这种测试固件，因为数据库模式发生变化后这种固件很难维护。请使用 Django ORM，或者 factory_boy 这类工具。

第 21 章　服务器端调试技术

做了这么多事之后，我们要停下来想一想：我们定义了几个用于等待的辅助函数，它们有什么用呢？哦，是等待用户登录的。那用户是如何登录的呢？啊，是通过预先构建已验证身份的用户实现的。

21.1　实践是检验真理的唯一标准：在过渡服务器中捕获最后的问题

这个功能测试在本地运行一切顺利，但在过渡服务器中情况如何呢？部署网站试一下。在这个过程中会遇到一个意料之外的问题（体现了过渡服务器的作用）。为了解决这个问题，要找到在测试服务器中管理数据库的方法：

$ cd deploy_tools
$ fab deploy --host=elspeth@superlists-staging.ottg.eu
[...]

然后重启 Gunicorn：

elspeth@server:$ sudo systemctl daemon-reload
elspeth@server:$ sudo systemctl restart gunicorn-superlists-staging.ottg.eu

运行功能测试得到的结果如下：

$ STAGING_SERVER=superlists-staging.ottg.eu python manage.py test functional_tests

======================================================================
ERROR: test_can_get_email_link_to_log_in
(functional_tests.test_login.LoginTest)

 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "/.../functional_tests/test_login.py", line 22, in
  test_can_get_email_link_to_log_in
    self.assertIn('Check your email', body.text)
AssertionError: 'Check your email' not found in 'Server Error (500)'

======================================================================
ERROR: test_logged_in_users_lists_are_saved_as_my_lists
(functional_tests.test_my_lists.MyListsTest)
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/harry/.../book-example/functional_tests/test_my_lists.py",
  line 34, in test_logged_in_users_lists_are_saved_as_my_lists
    self.wait_to_be_logged_in(email)
  File "/worskpace/functional_tests/base.py", line 42, in wait_to_be_logged_in
    self.browser.find_element_by_link_text('Log out')
    [...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: {"method":"link text","selector":"Log out"}
Stacktrace:
[...]

 ---------------------------------------------------------------------
Ran 8 tests in 27.933s

FAILED (errors=2)

无法登录，不管使用真实的电子邮件系统还是使用已经通过验证的会话都不行。看样子我们全新打造的身份验证系统把服务器搞崩溃了。

下面使用服务器端调试技术找出问题所在。

设置日志

为了记录这个问题，配置 Gunicorn，让它记录日志。在服务器中使用 vi 或 nano 按照下面的方式调整 Gunicorn 的配置：

server: /etc/systemd/system/gunicorn-superlists-staging.ottg.eu.service

ExecStart=/home/elspeth/sites/superlists-staging.ottg.eu/virtualenv/bin/gunicorn \
    --bind unix:/tmp/superlists-staging.ottg.eu.socket \
    --capture-output \
    --access-logfile ../access.log \
    --error-logfile ../error.log \
    superlists.wsgi:application

这样配置之后，Gunicorn 会在~/sites/$SITENAME 文件夹中保存访问日志和错误日志。

还要确保 settings.py 中仍有 LOGGING 设置，这样调试信息才能输送到终端。

superlists/settings.py

LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'handlers': {
        'console': {
            'level': 'DEBUG',
            'class': 'logging.StreamHandler',
        },
    },
    'loggers': {
        'django': {
            'handlers': ['console'],
        },
    },
    'root': {'level': 'INFO'},
}

再次重启 Gunicorn，然后运行功能测试，或者手动登录试试。在这些操作执行的过程中，可以使用下面的命令监视日志：

elspeth@server:$ sudo systemctl daemon-reload
elspeth@server:$ sudo systemctl restart gunicorn-superlists-staging.ottg.eu
elspeth@server:$ tail -f error.log # 假设位于 ~/sites/$SITENAME文件夹中

你应该会看到类似下面的错误：

Internal Server Error: /accounts/send_login_email
Traceback (most recent call last):
  File "/home/elspeth/sites/superlists-staging.ottg.eu/virtualenv/lib/python3.6/[...]
    response = wrapped_callback(request, *callback_args, **callback_kwargs)
  File
"/home/elspeth/sites/superlists-staging.ottg.eu/source/accounts/views.py", line
20, in send_login_email
    [email]
[...]
    self.connection.sendmail(from_email, recipients, message.as_bytes(linesep=\r\n))
  File "/usr/lib/python3.6/smtplib.py", line 862, in sendmail
    raise SMTPSenderRefused(code, resp, from_addr)
smtplib.SMTPSenderRefused: (530, b'5.5.1 Authentication Required. Learn more
at\n5.5.1 https://support.google.com/mail/?p=WantAuthError [...]
- gsmtp', noreply@superlists)

嗯，Gmail 拒绝发送电子邮件，是吗？可那是为什么呢？哦，原来是因为我们没有告诉服务器密码是什么。

21.2　在服务器上通过环境变量设定机密信息

第 11 章讲过在服务器上设定机密信息的一种方式，那时我们在服务器的文件系统中创建了一个一次性的 Python 文件，然后导入，设定 Django 的 SECRET_KEY 设置。

这几章则在 shell 中使用环境变量存储电子邮件的密码。我们也将在过渡服务器上使用这种方式。可以在 Systemd 的配置文件中设定环境变量：

server: /etc/systemd/system/gunicorn-superlists-staging.ottg.eu.service

[Service]
User=elspeth
Environment=EMAIL_PASSWORD=yoursekritpasswordhere
WorkingDirectory=/home/elspeth/sites/superlists-staging.ottg.eu/source
[...]

　在安全方面，使用配置文件有个好处：可以限制权限，只让 root 用户可读。而 Python 源文件做不到这一点。

保存这个文件，然后执行 daemon-reload 和 restart gunicorn 命令。再次运行功能测试，结果如下：

$ STAGING_SERVER=superlists-staging.ottg.eu python manage.py test functional_tests

[...]
Traceback (most recent call last):
  File "/.../superlists/functional_tests/test_login.py", line 25, in
  test_can_get_email_link_to_log_in
    email = mail.outbox[0]
IndexError: list index out of range

[...]

selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: {"method":"link text","selector":"Log out"}

my_lists 失败没变，但是登录测试提供了更多信息：功能测试向前进了，网站看起来能发送电子邮件了（服务器日志没有显示错误），但是在 mail.outbox 中找不到邮件……

21.3　调整功能测试，以便通过POP3测试真实的电子邮件

啊，确实应该如此。功能测试现在在真实的服务器中运行，而不是使用 LiveServerTestCase，因此我们不能再检查本地的 django.mail.outbox 中有没有发出的邮件了。

首先，在功能测试中要想办法判断是不是运行在过渡服务器中。在 base.py 中，把 staging_server 变量绑定到 self 上：

functional_tests/base.py (ch18l009)

    def setUp(self):
        self.browser = webdriver.Firefox()
        self.staging_server = os.environ.get('STAGING_SERVER')
        if self.staging_server:
           self.live_server_url = 'http://' + self.staging_server

然后，构建一个辅助函数，使用 Python 标准库中极其难用的 POP3 客户端从真实的 POP3 电子邮件服务器中获取真实的电子邮件：

functional_tests/test_login.py (ch18l010)

import os
import poplib
import re
import time
[...]

    def wait_for_email(self, test_email, subject):
        if not self.staging_server:
            email = mail.outbox[0]
            self.assertIn(test_email, email.to)
            self.assertEqual(email.subject, subject)
            return email.body

        email_id = None
        start = time.time()
        inbox = poplib.POP3_SSL('pop.mail.yahoo.com')
        try:
            inbox.user(test_email)
            inbox.pass_(os.environ['YAHOO_PASSWORD'])
            while time.time() - start < 60:
                # 获取最新的10封邮件
                count, _ = inbox.stat()
                for i in reversed(range(max(1, count - 10), count + 1)):
                    print('getting msg', i)
                    _, lines, __ = inbox.retr(i)
                    lines = [l.decode('utf8') for l in lines]
                    print(lines)
                    if f'Subject: {subject}' in lines:
                        email_id = i
                        body = '\n'.join(lines)
                        return body
                time.sleep(5)
        finally:
            if email_id:
                inbox.dele(email_id)
            inbox.quit()

　测试时我使用的是 Yahoo 账户，你可以使用任何电子邮件服务，只要支持通过 POP3 访问即可。你要在运行功能测试的控制台中设定 YAHOO_PASSWORD 环境变量。

接下来调整功能测试中其他需要改动的地方。首先，针对本地环境和过渡服务器为 test_email 变量设定不同的值：

functional_tests/test_login.py (ch18l011-1)

@@ -7,7 +7,7 @@ from selenium.webdriver.common.keys import Keys

 from .base import FunctionalTest

-TEST_EMAIL = 'edith@example.com'
+
 SUBJECT = 'Your login link for Superlists'

@@ -33,7 +33,6 @@ class LoginTest(FunctionalTest):
                     print('getting msg', i)
                     _, lines, __ = inbox.retr(i)
                     lines = [l.decode('utf8') for l in lines]
-                    print(lines)
                     if f'Subject: {subject}' in lines:
                         email_id = i
                         body = '\n'.join(lines)
@@ -49,6 +48,11 @@ class LoginTest(FunctionalTest):
         # 伊迪丝访问这个很棒的超级列表网站
         # 第一次注意到导航栏中有“登录”区域
         # 看到要求输入电子邮件地址，她便输入了
+        if self.staging_server:
+            test_email = 'edith.testuser@yahoo.com'
+        else:
+            test_email = 'edith@example.com'
+
         self.browser.get(self.live_server_url)

然后使用那个变量，并调用新定义的辅助函数：

functional_tests/test_login.py (ch18l011-2)

@@ -54,7 +54,7 @@ class LoginTest(FunctionalTest):
             test_email = 'edith@example.com'

         self.browser.get(self.live_server_url)
-        self.browser.find_element_by_name('email').send_keys(TEST_EMAIL)
+        self.browser.find_element_by_name('email').send_keys(test_email)
         self.browser.find_element_by_name('email').send_keys(Keys.ENTER)

         # 出现一个消息，告诉她邮件已经发出
@@ -64,15 +64,13 @@ class LoginTest(FunctionalTest):
         ))

         # 她查看邮件，看到一个消息
-        email = mail.outbox[0]
-        self.assertIn(TEST_EMAIL, email.to)
-        self.assertEqual(email.subject, SUBJECT)
+        body = self.wait_for_email(test_email, SUBJECT)

         # 邮件中有个URL链接
-        self.assertIn('Use this link to log in', email.body)
-        url_search = re.search(r'http://.+/.+$', email.body)
+        self.assertIn('Use this link to log in', body)
+        url_search = re.search(r'http://.+/.+$', body)
         if not url_search:
-            self.fail(f'Could not find url in email body:\n{email.body}')
+            self.fail(f'Could not find url in email body:\n{body}')
         url = url_search.group(0)
         self.assertIn(self.live_server_url, url)

@@ -80,11 +78,11 @@ class LoginTest(FunctionalTest):
         self.browser.get(url)

         # 她登录了！
-        self.wait_to_be_logged_in(email=TEST_EMAIL)
+        self.wait_to_be_logged_in(email=test_email)

         # 现在她要退出
         self.browser.find_element_by_link_text('Log out').click()

         # 她退出了
-        self.wait_to_be_logged_out(email=TEST_EMAIL)
+        self.wait_to_be_logged_out(email=test_email)

不管你信不信，这样改动之后就行了。经功能测试确认，登录功能可以正常使用了——我们可是发送了真实的电子邮件啊！

　我费了好大劲才凑出了检查电子邮件的代码，目前还不雅观，有点脆弱（常见的问题是错误沿用上一次测试的电子邮件）。如果能整理一下，再多试试，我想代码会更可靠。如果不想这么麻烦，可以使用 mailinator.com 这样的服务，只需少量费用就能获得一个一次性的电子邮件地址和查看邮件的 API。

21.4　在过渡服务器中管理测试数据库

现在可以再次运行功能测试，此时又会看到一个失败测试，因为无法创建已经通过认证的会话，所以针对“My Lists”页面的测试失败了：

$ STAGING_SERVER=superlists-staging.ottg.eu python manage.py test functional_tests

ERROR: test_logged_in_users_lists_are_saved_as_my_lists
(functional_tests.test_my_lists.MyListsTest)
[...]
selenium.common.exceptions.TimeoutException: Message: Could not find element
with id id_logout. Page text was:
Superlists
Sign in
Start a new To-Do list

Ran 8 tests in 72.742s

FAILED (errors=1)

失败的真正原因是 create_pre_authenticated_session 函数只能操作本地数据库。我们要找到一种方法，管理服务器中的数据库。

21.4.1　创建会话的Django管理命令

若想在服务器中操作，就要编写一个自成一体的脚本，在服务器中的命令行里运行。大多数情况下都会使用 Fabric 执行这样的脚本。

尝试编写可在 Django 环境中运行的独立脚本（和数据库交互等），有些问题要谨慎处理，例如正确设定 DJANGO_SETTINGS_MODULE 环境变量，还要正确处理 sys.path。

我们可不想在这些细节上浪费时间。其实 Django 允许我们自己创建“管理命令”（可以使用 python manage.py 运行的命令），可以把一切琐碎的事情都交给 Django 完成。管理命令保存在应用的 management/commands 文件夹中：

$ mkdir -p functional_tests/management/commands
$ touch functional_tests/management/__init__.py
$ touch functional_tests/management/commands/__init__.py

管理命令的样板代码是一个类，继承自 django.core.management.BaseCommand，而且定义了一个名为 handle 的方法：

functional_tests/management/commands/create_session.py

from django.conf import settings
from django.contrib.auth import BACKEND_SESSION_KEY, SESSION_KEY, get_user_model
User = get_user_model()
from django.contrib.sessions.backends.db import SessionStore
from django.core.management.base import BaseCommand

class Command(BaseCommand):

    def add_arguments(self, parser):
        parser.add_argument('email')

    def handle(self, *args, **options):
        session_key = create_pre_authenticated_session(options['email'])
        self.stdout.write(session_key)

def create_pre_authenticated_session(email):
    user = User.objects.create(email=email)
    session = SessionStore()
    session[SESSION_KEY] = user.pk
    session[BACKEND_SESSION_KEY] = settings.AUTHENTICATION_BACKENDS[0]
    session.save()
    return session.session_key

create_pre_authenticated_session 函数的代码从 test_my_lists.py 文件中提取而来。handle 方法从选项中获取电子邮件地址，返回一个将要存入浏览器 cookie 中的会话键。这个管理命令还会把会话键打印到命令行中。试一下这个命令：

$ python manage.py create_session a@b.com
Unknown command: 'create_session'

还要做一步设置——把 functional_tests 加入 settings.py，让 Django 把它识别为一个可能包含管理命令和测试的真正应用：

superlists/settings.py

+++ b/superlists/settings.py
@@ -42,6 +42,7 @@ INSTALLED_APPS = [
     'lists',
     'accounts',
+    'functional_tests',
 ]

现在这个管理命令可以使用了：

$ python manage.py create_session a@b.com
qnslckvp2aga7tm6xuivyb0ob1akzzwl

　如果报错说缺少 auth_user 表，可能就要执行 manage.py migrate 命令。如果还不行，重新来过，删除 db.sqlite3，再执行 migrate 命令。

21.4.2　让功能测试在服务器上运行管理命令

接下来调整 test_my_lists.py 文件中的测试，让它在本地服务器中运行本地函数，但是在过渡服务器中运行管理命令：

functional_tests/test_my_lists.py (ch18l016)

from django.conf import settings
from .base import FunctionalTest
from .server_tools import create_session_on_server
from .management.commands.create_session import create_pre_authenticated_session

class MyListsTest(FunctionalTest):

    def create_pre_authenticated_session(self, email):
        if self.staging_server:
            session_key = create_session_on_server(self.staging_server, email)
        else:
            session_key = create_pre_authenticated_session(email)
        ## 为了设定cookie，我们要先访问网站
        ## 而404页面是加载最快的
        self.browser.get(self.live_server_url + "/404_no_such_url/")
        self.browser.add_cookie(dict(
            name=settings.SESSION_COOKIE_NAME,
            value=session_key,
            path='/',
        ))

    [...]

此外，还要调整 base.py，当处在过渡服务器中时，提供更多信息：

functional_tests/base.py (ch18l017)

from .server_tools import reset_database  ➊
[...]

class FunctionalTest(StaticLiveServerTestCase):

    def setUp(self):
        self.browser = webdriver.Firefox()
        self.staging_server = os.environ.get('STAGING_SERVER')
        if self.staging_server:
            self.live_server_url = 'http://' + self.staging_server
            reset_database(self.staging_server)  ➊

➊ 这个函数的作用是在两次测试之间还原服务器中的数据库。稍后我会讲解创建会话这段代码的逻辑，以及为什么可以这么做。

21.4.3　直接在Python代码中使用Fabric

除了 fab 命令之外，Fabric 还提供了 API，这样便可以直接在 Python 代码中执行 Fabric 服务器命令。为此，只需通过一个字符串告诉 Fabric 你想连接的主机即可：

functional_tests/server_tools.py

from fabric.api import run
from fabric.context_managers import settings

def _get_manage_dot_py(host):
    return f'~/sites/{host}/virtualenv/bin/python ~/sites/{host}/source/manage.py'

def reset_database(host):
    manage_dot_py = _get_manage_dot_py(host)
    with settings(host_string=f'elspeth@{host}'):  ➊
        run(f'{manage_dot_py} flush --noinput')  ➋

def create_session_on_server(host, email):
    manage_dot_py = _get_manage_dot_py(host)
    with settings(host_string=f'elspeth@{host}'):  ➊
        session_key = run(f'{manage_dot_py} create_session {email}')  ➋
        return session_key.strip()

❶ 这个上下文管理器设定主机字符串，格式为 user@server-address（我直接把自己的服务器用户名 elspeth 写进去了，别忘了修改）。

❷ 在上下文中可以像在 fabfile 中那样直接调用 Fabric 命令。

21.4.4　回顾：在本地服务器和过渡服务器中创建会话的方式

充分理解了吗？下面通过 ASCII 图表回顾一下：

在本地

+-----------------------------------+      +-------------------------------------+
| MyListsTest                       | -->  | .management.commands.create_session |
| .create_pre_authenticated_session |      |  .create_pre_authenticated_session  |
|           (locally)               |      |        (locally)                    |
+-----------------------------------+      +-------------------------------------+

在过渡服务器中

+-----------------------------------+      +-------------------------------------+
| MyListsTest                       |      | .management.commands.create_session |
| .create_pre_authenticated_session |      |  .create_pre_authenticated_session  |
|            (locally)              |      |           (on server)               |
+-----------------------------------+      +-------------------------------------+
            |                                                ^
            v                                                |
+----------------------------+     +--------+     +------------------------------+
| server_tools               | --> | fabric | --> | ./manage.py create_session   |
| .create_session_on_server  |     | "run"  |     |       (on server)            |
|        (locally)           |     +--------+     +------------------------------+
+----------------------------+

不论如何，看一下这么做是否可行。首先，在本地运行测试，确认没有造成任何破坏：

$ python manage.py test functional_tests.test_my_lists
[...]
OK

然后在服务器中运行。先把代码推送到服务器中：

$ git push # 要先提交改动
$ cd deploy_tools
$ fab deploy --host=superlists-staging.ottg.eu

再运行测试：

$ STAGING_SERVER=superlists-staging.ottg.eu python manage.py test \
 functional_tests.test_my_lists
[...]
[superlists-staging.ottg.eu] Executing task 'reset_database'
~/sites/superlists-staging.ottg.eu/source/manage.py flush --noinput
[superlists-staging.ottg.eu] out: Syncing...
[superlists-staging.ottg.eu] out: Creating tables ...
[...]
.
 ---------------------------------------------------------------------
Ran 1 test in 25.701s

OK

看起来没问题。还可以运行全部测试确认一下：

$ STAGING_SERVER=superlists-staging.ottg.eu python manage.py test \
 functional_tests
[...]
[superlists-staging.ottg.eu] Executing task 'reset_database'
[...]
Ran 8 tests in 89.494s

OK

太棒了！

　我展示了管理测试数据库的一种方法，你也可以试验其他方式。例如，使用 MySQL 或 Postgres，可以打开一个 SSH 隧道连接服务器，使用端口转发直接操作数据库。然后修改 settings.DATABASES，让功能测试使用隧道连接的端口和数据库交互。

警告：小心，不要在线上服务器中运行测试

我们现在遇到一件危险的事，因为编写的代码能直接影响服务器中的数据库。一定要非常非常小心，别在错误的主机中运行功能测试，把生产数据库清空了。

此时，可以考虑使用一些安全防护措施。例如，把过渡环境和生产环境放在不同的服务器中，而且不同的服务器使用不同的口令认证密钥对。

在生产环境的数据副本中运行测试也有同样的危险，我就曾经不小心重复给客户发送了发票（见附录D）。这是前车之鉴啊。

21.5　集成日志相关的代码

结束本章之前，我们要把日志相关的代码集成到应用中。把输出日志的代码放在那儿，并且纳入版本控制，有助于调试以后遇到的登录问题。毕竟有些人不怀好意。

先把 Gunicorn 的配置保存到 deploy_tools 文件夹里的临时文件中：

deploy_tools/gunicorn-systemd.template.service (ch18l020)

[...]
Environment=EMAIL_PASSWORD=SEKRIT
ExecStart=/home/elspeth/sites/SITENAME/virtualenv/bin/gunicorn \
    --bind unix:/tmp/SITENAME.socket \
    --access-logfile ../access.log \
    --error-logfile ../error.log \
    superlists.wsgi:application
[...]

然后在配置笔记中加上几点，提醒自己别忘了在 Gunicorn 配置文件中设定电子邮件密码的环境变量：

deploy_tools/provisioning_notes.md (ch18l021)

## Systemd服务

* 参见gunicorn-systemd.template.service
* 把SITENAME替换成具体的域名，例如staging.my-domain.com
* 把SEKRIT替换成电子邮件密码
[...]

21.6　小结

在服务器中运行新代码总会让一些缺陷和意料之外的问题显露出来。为了解决这些问题，我们做了很多工作，不过在这个过程中也收获颇多。

我们定义了通用的 wait 装饰器，这更符合 Python 习惯，在功能测试中到处都可以使用。我们让测试固件既可以在本地使用，也能在服务器中使用（包括对“真实”电子邮件的测试）。此外，还设定了更牢靠的日志配置。

不过，在部署线上网站之前，最好为用户提供他们真正想要的功能——下一章介绍如何在“My Lists”页面中汇总用户的清单。

在过渡服务器中捕获缺陷时学到的知识

固件也要在远程服务器中使用

在本地运行测试，使用 LiveServerTestCase 即可以轻松通过 Django ORM 与测试数据库交互。与过渡服务器中的数据库交互就没这么简单了。解决方法之一是使用 Django 管理命令，如前文所示。不过也可以小心探索，找到适合自己的方法。

在服务器中重置数据时要格外小心

能远程清除服务器中整个数据库的命令极其危险，一定要小心再小心，确保不会意外损坏生产数据。

日志对调试服务器中的问题非常重要

你至少要能看到服务器产生的错误消息。对较为棘手的缺陷来说，还要能得到临时的“调试输出”，保存在某个文件中。

第 22 章　完成“My Lists”页面：由外而内的TDD

本章我要介绍一种技术，叫“由外而内”的 TDD。一直以来，我们几乎都在使用这种技术。“双循环”TDD 流程就体现了由外而内的思想——先编写功能测试，然后再编写单元测试，其实就是从外部设计系统，再分层编写代码。现在我要明确提出这个概念，再讨论其中涉及的常见问题。

22.1　对立技术：“由内而外”

“由外而内”的对立技术是“由内而外”，接触 TDD 之前，大多数人都凭直觉选择后者。提出一个设计想法之后，有时会自然而然地从最内部、最低层的组件开始实现。

例如，就我们现在面临的问题而言，要想为用户提供一个“My Lists”页面显示已经保存的清单，我们首先会迫不及待地在 List 模型中添加 owner 属性，因为根据需求推断，显然需要这样一个属性。之后，借助这个新属性修改外层代码，例如视图和模板，最后添加 URL 路由，指向新视图。

这么做感觉更自然，因为所用的代码从来不会依赖尚未实现的功能。内层的一切都是构建外层的坚实基础。

不过，像这样由内而外的工作方式也有缺点。

22.2　为什么选择使用“由外而内”

由内而外的技术最明显的问题是它迫使我们抛开 TDD 流程。功能测试第一次失败可能是因为缺少 URL 路由，但我们决定忽略这一点，先为数据库模型对象添加属性。

我们可能已经在脑海中构思好了内层的模样，而且这些想法往往都很好，不过这些都是对真实需求的推测，因为还未构造使用内层组件的外层组件。

这么做可能会导致内层组件太笼统，或者比真实需求功能更强——不仅浪费了时间，还把项目变得更为复杂。另一种常见的问题是，创建内层组件使用的 API 乍看起来对内部设计而言很合适，但之后会发现并不适用于外层组件。更糟的是，最后你可能会发现内层组件完全无法解决外层组件需要解决的问题。

与此相反，使用由外而内的工作方式，可以在外层组件的基础上构思想从内层组件获取的最佳 API。下面以实例说明如何使用由外而内技术。

22.3　“My Lists”页面的功能测试

编写下面这个功能测试时，我们从能接触到的最外层开始（表现层），然后是视图函数（或叫“控制器”），最后是最内层，在这个例子中是模型代码。

既然 create_pre_authenticated_session 函数可以正常使用，那么就可以直接用来编写针对“My Lists”页面的功能测试：

functional_tests/test_my_lists.py (ch19l001-1)

    def test_logged_in_users_lists_are_saved_as_my_lists(self):
        # 伊迪丝是已登录用户
        self.create_pre_authenticated_session('edith@example.com')

        # 她访问首页，新建一个清单
        self.browser.get(self.live_server_url)
        self.add_list_item('Reticulate splines')
        self.add_list_item('Immanentize eschaton')
        first_list_url = self.browser.current_url

        # 她第一次看到My Lists链接
        self.browser.find_element_by_link_text('My lists').click()

        # 她看到这个页面中有她创建的清单
        # 而且清单根据第一个待办事项命名
        self.wait_for(
            lambda: self.browser.find_element_by_link_text('Reticulate splines')
        )
        self.browser.find_element_by_link_text('Reticulate splines').click()
        self.wait_for(
            lambda: self.assertEqual(self.browser.current_url, first_list_url)
        )

先创建一个包含几个待办事项的清单，然后检查这个列表会出现在新的“My Lists”页面上，而且以清单中的第一个待办事项命名。

接着前面的单元测试，再创建一个清单，确保的确会出现在“My Lists”页面上。与此同时，再检查只有已登录的用户才能看到“My Lists”页面：

functional_tests/test_my_lists.py (ch19l001-2)

    [...]
    self.wait_for(
        lambda: self.assertEqual(self.browser.current_url, first_list_url)
    )

    # 她决定再建一个清单试试
    self.browser.get(self.live_server_url)
    self.add_list_item('Click cows')
    second_list_url = self.browser.current_url

    # 在“My Lists”页面，这个新建的清单也显示出来了
    self.browser.find_element_by_link_text('My lists').click()
    self.wait_for(
        lambda: self.browser.find_element_by_link_text('Click cows')
    )
    self.browser.find_element_by_link_text('Click cows').click()
    self.wait_for(
        lambda: self.assertEqual(self.browser.current_url, second_list_url)
    )

    # 她退出后，“My Lists”链接不见了
    self.browser.find_element_by_link_text('Log out').click()
    self.wait_for(lambda: self.assertEqual(
        self.browser.find_elements_by_link_text('My lists'),
        []
    ))

上述功能测试使用了一个新的辅助方法，即 add_list_item，它抽象了在正确的输入框中输入文本的操作。这个辅助方法在 base.py 中定义：

functional_tests/base.py (ch19l001-3)

from selenium.webdriver.common.keys import Keys
[...]

    def add_list_item(self, item_text):
        num_rows = len(self.browser.find_elements_by_css_selector('#id_list_table tr'))
        self.get_item_input_box().send_keys(item_text)
        self.get_item_input_box().send_keys(Keys.ENTER)
        item_number = num_rows + 1
        self.wait_for_row_in_list_table(f'{item_number}: {item_text}')

定义好这个辅助方法之后，可以在其他功能测试中像下面这样使用：

functional_tests/test_list_item_validation.py

    self.add_list_item('Buy wellies')

我觉得使用这个辅助方法之后，功能测试的可读性提高了不少。我改了 6 处，看看你是否与我一样。

运行全部功能测试，做个提交，然后回到我们正在处理的这个功能测试。你看到的第一个错误应该是这样的：

$ python3 manage.py test functional_tests.test_my_lists
[...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: My lists

22.4　外层：表现层和模板

目前，这个测试失败，报错无法找到“My　Lists”链接。这个问题可以在表现层，即 base.html 模板里的导航条中解决。最少量的代码改动如下所示：

lists/templates/base.html (ch19l002-1)

    {% if user.email %}
      <ul class="nav navbar-nav navbar-left">
        <li><a href="#">My lists</a></li>
      </ul>
      <ul class="nav navbar-nav navbar-right">
        <li class="navbar-text">Logged in as {{ user.email }}</li>
        <li><a href="{% url 'logout' %}">Log out</a></li>
      </ul>

显然，这个链接没指向任何页面，不过却能解决这个问题，得到下一个失败消息：

$ python3 manage.py test functional_tests.test_my_lists
[...]
    lambda: self.browser.find_element_by_link_text('Reticulate splines')
[...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: Reticulate splines

失败消息指出要构建一个页面，用标题列出一个用户的所有清单。先从简单的开始——一个 URL 和一个占位模板。

可以再次使用由外而内技术，先从表现层开始，只写上地址，其他什么都不做：

lists/templates/base.html (ch19l002-2)

    <ul class="nav navbar-nav navbar-left">
      <li><a href="{% url 'my_lists' user.email %}">My lists</a></li>
    </ul>

22.5　下移一层到视图函数（控制器）

这样改还是会得到模板错误，所以要从表现层和 URL 层下移，进入控制器层，即 Django

中的视图函数。一如既往，先写测试：

lists/tests/test_views.py (ch19l003)

class MyListsTest(TestCase):

    def test_my_lists_url_renders_my_lists_template(self):
        response = self.client.get('/lists/users/a@b.com/')
        self.assertTemplateUsed(response, 'my_lists.html')

得到的测试结果为：

AssertionError: No templates used to render the response

然后修正这个问题，不过还在表现层，更准确地说是 urls.py：

lists/urls.py

urlpatterns = [
    url(r'^new$', views.new_list, name='new_list'),
    url(r'^(\d+)/$', views.view_list, name='view_list'),
    url(r'^users/(.+)/$', views.my_lists, name='my_lists'),
]

修改之后会得到一个测试失败消息，告诉我们移到下一层之后要做什么：

AttributeError: module 'lists.views' has no attribute 'my_lists'

从表现层移到视图层，再定义一个最简单的占位视图：

lists/views.py (ch19l005)

def my_lists(request, email):
    return render(request, 'my_lists.html')

以及一个最简单的模板：

lists/templates/my_lists.html

{% extends 'base.html' %}

{% block header_text %}My Lists{% endblock %}

现在单元测试通过了，但功能测试毫无进展，报错说“My Lists”页面没有显示清单。功能测试希望这些清单可以点击，而且以第一个待办事项命名：

$ python3 manage.py test functional_tests.test_my_lists
[...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: Reticulate splines

22.6　使用由外而内技术，再让一个测试通过

仍然使用功能测试驱动每一步的开发工作。

再次从外层开始，编写模板代码，让“My Lists”页面实现设想的功能。现在，要指定希望从低层获取的 API。

22.6.1　快速重组模板的继承层级

基模板目前没有地方放置新内容了，而且“My Lists”页面不需要新建待办事项表单，所以把表单放到一个块中，需要时才显示：

lists/templates/base.html (ch19l007-1)

    <div class="row">
      <div class="col-md-6 col-md-offset-3 jumbotron">
        <div class="text-center">
          <h1>{% block header_text %}{% endblock %}</h1>
          {% block list_form %}
            <form method="POST" action="{% block form_action %}{% endblock %}">
              {{ form.text }}
              {% csrf_token %}
              {% if form.errors %}
                <div class="form-group has-error">
                  <div class="help-block">{{ form.text.errors }}</div>
                </div>
              {% endif %}
            </form>
          {% endblock %}
        </div>
      </div>
    </div>

lists/templates/base.html (ch19l007-2)

      <div class="row">
        <div class="col-md-6 col-md-offset-3">
          {% block table %}
          {% endblock %}
        </div>
      </div>

      <div class="row">
        <div class="col-md-6 col-md-offset-3">
          {% block extra_content %}
          {% endblock %}
        </div>
      </div>

    </div>
    <script src="/static/jquery-3.1.1.min.js"></script>
    [...]

22.6.2　使用模板设计API

同时，在 my_lists.html 中覆盖 list_form 块，把块中的内容清空：

lists/templates/my_lists.html

{% extends 'base.html' %}

{% block header_text %}My Lists{% endblock %}

{% block list_form %}{% endblock %}

然后只在 extra_content 块中编写代码：

lists/templates/my_lists.html

[...]

{% block list_form %}{% endblock %}

{% block extra_content %}
    <h2>{{ owner.email }}'s lists</h2>  ➊
    <ul>
        {% for list in owner.list_set.all %}  ➋
            <li><a href="{{ list.get_absolute_url }}">{{ list.name }}</a></li>  ➌
        {% endfor %}
    </ul>
{% endblock %}

在这个模板中我们做了几个设计决策，这会对内层代码产生一定影响。

❶ 需要一个名为 owner 的变量，在模板中表示用户。

❷ 想使用 owner.list_set.all 遍历用户创建的清单（我碰巧知道 Django ORM 提供了这个属性）。

❸ 想使用 list.name 获取清单的名字，目前清单以其中的第一个待办事项命名。

　由外而内的 TDD 有时叫作“一厢情愿式编程”，我想你能看出为什么。我们先编写高层代码，这些代码建立在设想的低层基础之上，可是低层尚未实现。

再次运行功能测试，确认没有造成任何破坏，同时查看有无进展：

$ python manage.py test functional_tests
[...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: Reticulate splines

 ---------------------------------------------------------------------
Ran 8 tests in 77.613s

FAILED (errors=1)

虽然没进展，但至少没造成任何破坏。该提交了：

$ git add lists
$ git diff --staged
$ git commit -m "url, placeholder view, and first-cut templates for my_lists"

22.6.3　移到下一层：视图向模板中传入什么

现在，视图层要回应需求，为模板层提供所需的对象。这里要提供的是清单属主：

lists/tests/test_views.py (ch19l011)

from django.contrib.auth import get_user_model
User = get_user_model()
[...]
class MyListsTest(TestCase):

    def test_my_lists_url_renders_my_lists_template(self):
        [...]

    def test_passes_correct_owner_to_template(self):
        User.objects.create(email='wrong@owner.com')
        correct_user = User.objects.create(email='a@b.com')
        response = self.client.get('/lists/users/a@b.com/')
        self.assertEqual(response.context['owner'], correct_user)

测试结果为：

KeyError: 'owner'

那就这么修改：

lists/views.py (ch19l012)

from django.contrib.auth import get_user_model
User = get_user_model()
[...]

def my_lists(request, email):
    owner = User.objects.get(email=email)
    return render(request, 'my_lists.html', {'owner': owner})

这样修改之后，新测试通过了，但还是能看到前一个测试导致的错误。只需在这个测试中添加一个用户即可：

lists/tests/test_views.py (ch19l013)

    def test_my_lists_url_renders_my_lists_template(self):
        User.objects.create(email='a@b.com')
        [...]

现在测试全部通过：

OK

22.7　视图层的下一个需求：新建清单时应该记录属主

下移到模型层之前，视图层还有一部分代码要用到模型：如果当前用户已经登录网站，需要一种方式把新建的清单指派给一个属主。

初期编写的测试如下所示：

lists/tests/test_views.py (ch19l014)

class NewListTest(TestCase):
    [...]

    def test_list_owner_is_saved_if_user_is_authenticated(self):
        user = User.objects.create(email='a@b.com')
        self.client.force_login(user)  ➊
        self.client.post('/lists/new', data={'text': 'new item'})
        list_ = List.objects.first()
        self.assertEqual(list_.owner, user)

➊ 为了让测试客户端利用已登录用户的身份发送请求，必须先调用 force_login()。

这个测试得到的失败消息如下：

AttributeError: 'List' object has no attribute 'owner'

为了修正这个问题，可以尝试编写如下代码：

lists/views.py (ch19l015)

def new_list(request):
    form = ItemForm(data=request.POST)
    if form.is_valid():
        list_ = List()
        list_.owner = request.user
        list_.save()
        form.save(for_list=list_)
        return redirect(list_)
    else:
        return render(request, 'home.html', {"form": form})

但这个视图其实解决不了问题，因为还不知道怎么保存清单的属主：

    self.assertEqual(list_.owner, user)
AttributeError: 'List' object has no attribute 'owner'

抉择时刻：测试失败时是否要移入下一层

为了让这个测试通过，就目前的情况而言，要下移到模型层。但还有一个失败测试，要做的工作太多，现在下移可不明智。

可以采用另一种策略，使用驭件把测试和下层组件更明显地隔离开。

一方面，使用驭件要做的工作更多，而且驭件会让测试代码更难读懂。另一方面，如果应用更复杂，外部和内部之间的分层更多，测试就会涉及 3~5 层，在深入最底层实现关键功能之前，这些测试一直处于失败状态。测试无法通过，单就一层而言，我们就无法确定它是否能正常运行，只有等到最底层实现之后才有答案。

你在自己的项目中有可能也会遇到这样的抉择时刻。两种方式都要探讨。先走捷径，放任测试失败不管。下一章再回到这里，探讨如何使用增强隔离的方式。

下面做次提交，并且为这次提交打上标签，以便下一章能找到这个位置：

$ git commit -am "new_list view tries to assign owner but cant"
$ git tag revisit_this_point_with_isolated_tests

22.8　下移到模型层

使用由外而内技术得出了两个需求，需要在模型层实现：其一，想使用 .owner 属性为清单指派一个属主；其二，想使用 API owner.list_set.all 获取清单的属主。

针对这两个需求，先编写一个测试：

lists/tests/test_models.py (ch19l018)

from django.contrib.auth import get_user_model
User = get_user_model()
[...]

class ListModelTest(TestCase):

    def test_get_absolute_url(self):
        [...]

    def test_lists_can_have_owners(self):
        user = User.objects.create(email='a@b.com')
        list_ = List.objects.create(owner=user)
        self.assertIn(list_, user.list_set.all())

又得到了一个失败的单元测试：

    list_ = List.objects.create(owner=user)
    [...]
TypeError: 'owner' is an invalid keyword argument for this function

简单些，把模型写成下面这样：

from django.conf import settings
[...]

class List(models.Model):
    owner = models.ForeignKey(settings.AUTH_USER_MODEL)

可是我们希望属主可有可无。明确表明需求比含糊其辞强，而且测试还可以作为文档，所以再编写一个测试：

lists/tests/test_models.py (ch19l020)

    def test_list_owner_is_optional(self):
        List.objects.create()  # 不该抛出异常

正确的模型实现如下所示：

lists/models.py

from django.conf import settings
[...]
class List(models.Model):
    owner = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True)

    def get_absolute_url(self):
        return reverse('view_list', args=[self.id])

现在运行测试，会看到以前见过的数据库错误：

    return Database.Cursor.execute(self, query, params)
django.db.utils.OperationalError: no such column: lists_list.owner_id

因为需要做一次迁移：

$ python manage.py makemigrations
Migrations for 'lists':
  lists/migrations/0006_list_owner.py
    - Add field owner to list

快成功了，不过还有几个错误：

ERROR: test_redirects_after_POST (lists.tests.test_views.NewListTest)
[...]
ValueError: Cannot assign "<SimpleLazyObject:
<django.contrib.auth.models.AnonymousUser object at 0x7f364795ef90>>":
"List.owner" must be a "User" instance.
ERROR: test_can_save_a_POST_request (lists.tests.test_views.NewListTest)

[...]
ValueError: Cannot assign "<SimpleLazyObject:
<django.contrib.auth.models.AnonymousUser object at 0x7f364795ef90>>":
"List.owner" must be a "User" instance.

现在回到视图层，做些清理工作。注意，这些错误发生在针对 new_list 视图的测试中，而且用户没有登录。仅当用户登录后才应该保存清单的属主。在第 19 章中定义的 .is_authenticated() 函数现在有用处了（用户未登录时，Django 使用 AnonymousUser 类表示用户，此时 .is_authenticated() 函数的返回值始终是 False）：

lists/views.py (ch19l023)

    if form.is_valid():
        list_ = List()
        if request.user.is_authenticated:
            list_.owner = request.user
        list_.save()
        form.save(for_list=list_)
        [...]

这样修改之后，测试通过了：

$ python manage.py test lists
[...]
.......................................
 ---------------------------------------------------------------------
Ran 39 tests in 0.237s

OK

现在是提交的好时机：

$ git add lists
$ git commit -m "lists can have owners, which are saved on creation."

最后一步：实现模板需要的.name属性

使用的由外而内设计方式还有最后一个需求，即清单根据其中第一个待办事项命名：

lists/tests/test_models.py (ch19l024)

    def test_list_name_is_first_item_text(self):
        list_ = List.objects.create()
        Item.objects.create(list=list_, text='first item')
        Item.objects.create(list=list_, text='second item')
        self.assertEqual(list_.name, 'first item')

lists/models.py (ch19l025)

    @property
        def name(self):
        return self.item_set.first().text

你可能无法相信，这样测试就能通过了，而且“My Lists”页面（如图 22-1）也能使用了。

$ python manage.py test functional_tests
[...]
Ran 8 tests in 93.819s

OK

图 22-1：光彩夺目的“My Lists”页面（也证明我在 Windows 中测试过）

Python 中的 @property 修饰器

如果你没见过 @property 修饰器，我告诉你，它的作用是把类中的方法转变成与属性一样，可以在外部访问。

这是 Python 语言一个强大的特性，因为很容易用它实现“鸭子类型”（duck typing），无须修改类的接口就能改变属性的实现方式。也就是说，如果想把 .name 改成模型真正的属性，在数据库中存储文本型数据，整个过程是完全透明的，只要兼顾其他代码，就能继续使用 .name 获取清单名，完全不用知道这个属性的具体实现方式。几年前，Raymond Hettinger 在Pycon 上针对这个话题做过一次出色的演讲。这个演讲对新手友好，我极力推荐你观看（除此之外，还涵盖众多符合 Python 风格的类设计实践方式）。

当然了，就算没使用 @property 修饰器，在 Django 的模板语言中还是会调用 .name 方法。不过这是 Django 专有的特性，不适用于一般的 Python 程序。

但这个过程中有作弊。测试山羊正满怀猜疑。实现下一层时前一层还有失败的测试。下一章要看一下增强测试隔离性如何编写测试。

由外而内的 TDD

由外而内的 TDD

一种编写代码的方法，由测试驱动，从外层开始（表现层，GUI），然后逐步向内层移动，通过视图层或控制器层，最终达到模型层。这种方法的理念是由实际需要使用的功能驱动代码的编写，而不是在低层猜测需求。

一厢情愿式编程

由外而内的过程有时也叫“一厢情愿式编程”。其实，任何 TDD 形式都涉及一厢情愿。我们总是为还未实现的功能编写测试。

由外而内技术的缺点

由外而内技术也不是万灵药。这种技术鼓励我们关注用户立即就能看到的功能，但不会自动提醒我们为不是那么明显的功能编写关键测试，例如安全相关的功能。你自己要记得编写这些测试。

第 23 章　测试隔离和“倾听测试的心声”

前一章对视图层的失败单元测试放任不管，而是进入模型层编写更多的测试和更多的代码，以便让这个测试通过。

测试侥幸通过了，因为我们的应用很简单。我要强调一点，在复杂的应用中，选择这么做是很危险的。尚未确定高层是否真正完成之前就进入低层是一种冒险行为。

　感谢 Gary Bernhardt，他看了前一章的草稿，建议我深入介绍测试隔离。

确保各层之间相互隔离确实需要投入更多的精力（以及更多可怕的驭件），可是这么做能促使我们得到更好的设计。本章就以实例说明这一点。

23.1　重温抉择时刻：视图层依赖于尚未编写的模型代码

重温前一章的抉择时刻，那时 new_list 视图无法正常运行，因为清单还没有 .owner 属性。

我们要逆转时光，通过前面做的标签检出以前的代码，看一下使用隔离性更好的测试效果如何。

$ git checkout -b more-isolation # 为这次实验新建一个分支
$ git reset --hard revisit_this_point_with_isolated_tests

失败的测试是下面这个：

lists/tests/test_views.py

class NewListTest(TestCase):
    [...]

    def test_list_owner_is_saved_if_user_is_authenticated(self):
        user = User.objects.create(email='a@b.com')
        self.client.force_login(user)
        self.client.post('/lists/new', data={'text': 'new item'})
        list_ = List.objects.first()
        self.assertEqual(list_.owner, user)

尝试使用的解决方法如下所示：

lists/views.py

def new_list(request):
    form = ItemForm(data=request.POST)
    if form.is_valid():
        list_ = List()
        list_.owner = request.user
        list_.save()
        form.save(for_list=list_)
        return redirect(list_)
    else:
        return render(request, 'home.html', {"form": form})

此时，这个视图测试是失败的，因为还未编写模型层：

    self.assertEqual(list_.owner, user)
AttributeError: 'List' object has no attribute 'owner'

　如果没有签出以前的代码并还原 lists/models.py，就不会看到这个错误。这一步一定要做。本章的目标之一是，看一下是否真能为还不存在的模型层编写测试。

23.2　首先尝试使用驭件实现隔离

清单还没有属主，但可以使用一些模拟技术让视图层测试认为有属主：

lists/tests/test_views.py (ch20l003)

from unittest.mock import patch
[...]

    @patch('lists.views.List')  ➊
    @patch('lists.views.ItemForm')  ➋
    def test_list_owner_is_saved_if_user_is_authenticated(
        self, mockItemFormClass, mockListClass  ➌
    ):
        user = User.objects.create(email='a@b.com')
        self.client.force_login(user)
        self.client.post('/lists/new', data={'text': 'new item'})
        mock_list = mockListClass.return_value  ➍
        self.assertEqual(mock_list.owner, user)  ❺

❶ 模拟 List 模型的功能，获取视图创建的任何一个清单。

❷ 此外，还要模拟 ItemForm。如若不然，调用 form.save() 时，表单会抛出错误，因为无法在想创建的 Item 对象中使用驭件做外键。一旦开始使用驭件，就很难停手！

❸ 通过测试方法的参数注入驭件时，要按照声明驭件的相反顺序传入。有多个驭件时，方法的签名就是这么奇怪，习惯就好。

❹ 视图访问的清单实例是 List 驭件的返回值。

❺ 现在可以声明断言，判断清单对象是否设定了 .owner 属性。

现在运行测试应该可以通过：

$ python manage.py test lists
[...]
Ran 37 tests in 0.145s
OK

如果没通过，确保 views.py 中的视图代码和我前面给出的一模一样，使用的是 List()，而不是 List.objects.create。

　使用驭件有个局限，必须按照特定的方式使用 API。这是使用驭件对象要做出的妥协之一。

使用驭件的side_effect属性检查事件发生的顺序

这个测试的问题是，无意中把代码写错也可能侥幸通过测试。假设在指定属主之前不小心调用了 save 方法：

lists/views.py

    if form.is_valid():
        list_ = List()
        list_.save()
        list_.owner = request.user
        form.save(for_list=list_)
        return redirect(list_)

按照测试现在这种写法，它依旧可以通过：

OK

所以，严格来说，不仅要检查指定了属主，还要确保在清单对象上调用 save 方法之前就已经指定了。

使用驭件检查事件发生顺序的方法如下。可以模拟一个函数，作为侦件，检查调用这个侦件时周围的状态：

lists/tests/test_views.py (ch20l005)

    @patch('lists.views.List')
    @patch('lists.views.ItemForm')
    def test_list_owner_is_saved_if_user_is_authenticated(
        self, mockItemFormClass, mockListClass
    ):

        user = User.objects.create(email='a@b.com')
        self.client.force_login(user)
        mock_list = mockListClass.return_value

        def check_owner_assigned():  ➊
            self.assertEqual(mock_list.owner, user)
        mock_list.save.side_effect = check_owner_assigned  ➋

        self.client.post('/lists/new', data={'text': 'new item'})

        mock_list.save.assert_called_once_with()  ➌

❶ 定义一个函数，在这个函数中就希望先发生的事件声明断言，即检查是否设定了清单的属主。

❷ 把这个检查函数赋值给后续事件的 side_effect 属性。当视图在驭件上调用 save 方法时，才会执行其中的断言。要保证在测试的目标函数调用前完成此次赋值。

❸ 最后，要确保设定了 side_effect 属性的函数一定会被调用，也就是要调用 .save() 方法。否则断言永远不会运行。

　使用驭件的副作用时有两个常见错误：第一，side_effect 属性赋值太晚，也就是在调用测试目标函数之后才赋值；第二，忘记检查是否调用了引起副作用的函数。说“常见”，是因为撰写本章时我多次同时犯了这两个错误。

现在，如果仍然使用前面有错误的代码，即指定属主和调用 save 方法的顺序不对，就会看到如下错误：

FAIL: test_list_owner_is_saved_if_user_is_authenticated
(lists.tests.test_views.NewListTest)
[...]
  File "/.../superlists/lists/views.py", line 17, in new_list
    list_.save()
[...]
  File "/.../superlists/lists/tests/test_views.py", line 74, in
check_owner_assigned
    self.assertEqual(mock_list.owner, user)
AssertionError: <MagicMock name='List().owner' id='140691452447208'> != <User:
User object>

注意看这个失败消息，它先尝试保存，然后才执行 side_effect 属性对应的函数。

可以按照下面的方式修改，让测试通过：

lists/views.py

    if form.is_valid():
        list_ = List()
        list_.owner = request.user
        list_.save()
        form.save(for_list=list_)
        return redirect(list_)

测试结果为：

OK

但是这个测试写得很丑！

23.3　倾听测试的心声：丑陋的测试表明需要重构

当你发现必须像这样编写测试，而且要做许多工作时，很有可能表明测试试图向你诉说什么。准备测试所需的数据用了 8 行代码（用户驭件用了 2 行，请求对象又用了 3 行，还有 3 行设定副作用函数），太多了。

这个测试试图告诉我们，视图做的工作太多了，既要创建表单，又要创建清单对象，还要决定是否保存清单的属主。

前面已经说过，可以把一部分工作交给表单类完成，把视图变得简单且易于理解一些。为什么要在视图中创建清单对象？或许 ItemForm.save 能代劳？为什么视图要决定是否保存 request.user ？这项任务也可以交给表单完成。

既然要把更多的任务交给表单，感觉应该为这个表单起个新名字。可以叫它 NewListForm，因为这个名字能更好地表明它的作用。最终，视图或许可以写成这样吧：

lists/views.py

# 先不输入这段代码，只是假设可以这么写

def new_list(request):
    form = NewListForm(data=request.POST)
    if form.is_valid():
        list_ = form.save(owner=request.user) # 创建List和Item对象
        return redirect(list_)
    else:
        return render(request, 'home.html', {"form": form})

这样多简洁！下面来看一下如何为这种写法编写完全隔离的测试。

23.4　以完全隔离的方式重写视图测试

首次尝试为这个视图编写的测试组件集成度太高，数据库层和表单层的功能完成之后才能通过。现在使用另一种方式，提高测试的隔离度。

23.4.1　为了新测试的健全性，保留之前的整合测试组件

把 NewListTest 类重名为 NewListViewIntegratedTest，再把尝试使用驭件保存属主的测试代码删掉，换成整合版本，而且暂时为这个测试方法加上 skip 修饰器：

lists/tests/test_views.py (ch20l008)

import unittest
[...]

class NewListViewIntegratedTest(TestCase):

    def test_can_save_a_POST_request(self):
        [...]

    @unittest.skip
    def test_list_owner_is_saved_if_user_is_authenticated(self):
        user = User.objects.create(email='a@b.com')
        self.client.force_login(user)
        self.client.post('/lists/new', data={'text': 'new item'})
        list_ = List.objects.first()
        self.assertEqual(list_.owner, user)

　你听说过“集成测试”（integration test）这个术语吗？想知道它和“整合测试”（integrated test）的区别吗？请看第 26 章框注中的定义。

$ python manage.py test lists
[...]
Ran 37 tests in 0.139s
OK

23.4.2　完全隔离的新测试组件

从头开始编写测试，看看隔离测试能否驱动我们写出 new_list 视图的替代版本。把这个视图命名为 new_list2，放在旧版视图旁边。写好之后，再换用这个新视图，看看以前的整合测试是否仍然都能通过。

lists/views.py (ch20l009)

def new_list(request):
    [...]

def new_list2(request):
    pass

23.4.3　站在协作者的角度思考问题

重写测试时若想实现完全隔离，必须丢掉以前对测试的认识。以前我们认为视图的真正作用是操作数据库等，现在则要站在协作对象的角度，思考视图如何与之交互。

站在新的角度上，发现视图的主要协作者是表单对象。所以，为了完全掌控表单，以及按照我们一厢情愿想要的方式定义表单的功能，使用驭件模拟表单。

lists/tests/test_views.py (ch20l010)

from unittest.mock import patch
from django.http import HttpRequest
from lists.views import new_list2
[...]

@patch('lists.views.NewListForm')  ➋
class NewListViewUnitTest(unittest.TestCase):  ➊

    def setUp(self):
        self.request = HttpRequest()
        self.request.POST['text'] = 'new list item'  ➌

    def test_passes_POST_data_to_NewListForm(self, mockNewListForm):
        new_list2(self.request)
        mockNewListForm.assert_called_once_with(data=self.request.POST)  ➍

❶ 使用 Django 提供的 TestCase 类太容易写成整合测试。为了确保写出纯粹隔离的单元测试，只能使用 unittest.TestCase。

❷ 模拟 NewListForm 类（尚未定义）。类中的所有测试方法都会用到这个驭件，所以在类上模拟。

❸ 在 setUp 方法中手动创建了一个简单的 POST 请求，没有使用（太过整合的）Django 测试客户端。

❹ 然后检查视图要做的第一件事：在视图中使用正确的构造方法初始化它的协作者，即 NewListForm，传入的数据从请求中读取。

在这个测试的结果中首先会看到一个失败消息，报错视图中还没有 NewListForm。

AttributeError: <module 'lists.views' from '/.../superlists/lists/views.py'>
does not have the attribute 'NewListForm'

先编写一个占位表单类：

lists/views.py (ch20l011)

from lists.forms import ExistingListItemForm, ItemForm, NewListForm
[...]

以及：

lists/forms.py (ch20l012)

class ItemForm(forms.models.ModelForm):
    [...]

class NewListForm(object):
    pass

class ExistingListItemForm(ItemForm):
    [...]

看到了一个真正的失败消息：

AssertionError: Expected 'NewListForm' to be called once. Called 0 times.

按照如下的方式编写代码：

lists/views.py (ch20l012-2)

def new_list2(request):
    NewListForm(data=request.POST)

测试结果为：

$ python manage.py test lists
[...]
Ran 38 tests in 0.143s
OK

继续编写测试。如果表单中的数据有效，要在表单对象上调用 save 方法：

lists/tests/test_views.py (ch20l013)

from unittest.mock import patch, Mock
[...]

@patch('lists.views.NewListForm')
class NewListViewUnitTest(unittest.TestCase):

    def setUp(self):
        self.request = HttpRequest()
        self.request.POST['text'] = 'new list item'
        self.request.user = Mock()

    def test_passes_POST_data_to_NewListForm(self, mockNewListForm):
        new_list2(self.request)
        mockNewListForm.assert_called_once_with(data=self.request.POST)

    def test_saves_form_with_owner_if_form_valid(self, mockNewListForm):
        mock_form = mockNewListForm.return_value
        mock_form.is_valid.return_value = True
        new_list2(self.request)
        mock_form.save.assert_called_once_with(owner=self.request.user)

据此，可以写出如下视图：

lists/views.py (ch20l014)

def new_list2(request):
    form = NewListForm(data=request.POST)
    form.save(owner=request.user)

如果表单中的数据有效，让视图做一个重定向，把我们带到一个页面，查看表单刚刚创建的对象。所以要模拟视图的另一个协作者——redirect 函数：

lists/tests/test_views.py (ch20l015)

    @patch('lists.views.redirect')  ➊
    def test_redirects_to_form_returned_object_if_form_valid(
        self, mock_redirect, mockNewListForm  ➋
    ):
        mock_form = mockNewListForm.return_value
        mock_form.is_valid.return_value = True  ➌

        response = new_list2(self.request)

        self.assertEqual(response, mock_redirect.return_value)  ➍
        mock_redirect.assert_called_once_with(mock_form.save.return_value)  ❺

❶ 模拟 redirect 函数，不过这次直接在方法上模拟。

❷ patch 修饰器先应用最内层的那个，所以这个驭件在 mockNewListForm 之前传入方法。

❸ 指定测试的是表单中数据有效的情况。

❹ 检查视图的响应是否为 redirect 函数的结果。

❺ 然后检查调用 redirect 函数时传入的参数是否为在表单上调用 save 方法得到的对象。

据此，可以编写如下视图：

lists/views.py (ch20l016)

def new_list2(request):
    form = NewListForm(data=request.POST)
    list_ = form.save(owner=request.user)
    return redirect(list_)

测试结果为：

$ python manage.py test lists
[...]
Ran 40 tests in 0.163s
OK

然后测试表单提交失败的情况——如果表单中的数据无效，渲染首页的模板：

lists/tests/test_views.py (ch20l017)

    @patch('lists.views.render')
    def test_renders_home_template_with_form_if_form_invalid(
        self, mock_render, mockNewListForm
    ):
        mock_form = mockNewListForm.return_value
        mock_form.is_valid.return_value = False

        response = new_list2(self.request)

        self.assertEqual(response, mock_render.return_value)
        mock_render.assert_called_once_with(
            self.request, 'home.html', {'form': mock_form}
        )

测试的结果为：

AssertionError: <HttpResponseRedirect status_code=302, "te[114 chars]%3E"> !=
<MagicMock name='render()' id='140244627467408'>

　在驭件上调用断言方法时一定要运行测试，确认它会失败。因为输入断言函数时太容易出错，会导致调用的模拟方法没有任何作用。（我会写成 asssert_called_once_with，用了三个 s。你自己可以试一下！）

故意犯个错误，确保测试全面：

lists/views.py (ch20l018)

    def new_list2(request):
        form = NewListForm(data=request.POST)
        list_ = form.save(owner=request.user)
        if form.is_valid():
            return redirect(list_)
        return render(request, 'home.html', {'form': form})

测试本不应该通过却通过了！那就再写一个测试：

lists/tests/test_views.py (ch20l019)

    def test_does_not_save_if_form_invalid(self, mockNewListForm):
        mock_form = mockNewListForm.return_value
        mock_form.is_valid.return_value = False
        new_list2(self.request)
        self.assertFalse(mock_form.save.called)

这个测试会失败：

    self.assertFalse(mock_form.save.called)
AssertionError: True is not false

最终得到了一个精简的视图：

lists/views.py

def new_list2(request):
    form = NewListForm(data=request.POST)
    if form.is_valid():
        list_ = form.save(owner=request.user)
        return redirect(list_)
    return render(request, 'home.html', {'form': form})

测试结果为：

$ python manage.py test lists
[...]
Ran 42 tests in 0.163s
OK

23.5　下移到表单层

已经写好了视图函数，这个视图基于设想的表单 NewItemForm，而且这个表单现在还不存在。

需要在表单对象上调用 save 方法创建一个新清单，还要使用通过验证的 POST 数据创建一个新待办事项。如果直接使用 ORM，save 方法可以写成这样：

class NewListForm(models.Form):

    def save(self, owner):
        list_ = List()
        if owner:
            list_.owner = owner
        list_.save()
        item = Item()
        item.list = list_
        item.text = self.cleaned_data['text']
        item.save()

这种实现方式依赖于模型层的两个类，即 Item 和 List。那么隔离性好的测试应该怎么写呢？

class NewListFormTest(unittest.TestCase):

    @patch('lists.forms.List')  ➊
    @patch('lists.forms.Item')  ➊
    def test_save_creates_new_list_and_item_from_post_data(
        self, mockItem, mockList  ➊
    ):
        mock_item = mockItem.return_value
        mock_list = mockList.return_value
        user = Mock()
        form = NewListForm(data={'text': 'new item text'})
        form.is_valid()  ➋

        def check_item_text_and_list():
            self.assertEqual(mock_item.text, 'new item text')
            self.assertEqual(mock_item.list, mock_list)
            self.assertTrue(mock_list.save.called)
        mock_item.save.side_effect = check_item_text_and_list  ➌

        form.save(owner=user)

        self.assertTrue(mock_item.save.called)  ➍

❶ 为表单模拟两个来自下部模型层的协作者。

❷ 必须调用 is_valid() 方法，这样表单才会把通过验证的数据存储到 .cleaned_data 字典中。

❸ 使用 side_effect 方法确保保存新待办事项对象时，使用已经保存的清单，而且待办事项中的文本正确。

❹ 一如既往，再次确认调用了副作用函数。

唉，这个测试写得好丑！

始终倾听测试的心声：从应用中删除ORM代码

测试又在诉说什么：Django ORM 很难模拟，而且表单类需要较深入地了解 ORM 的工作方式。再次使用一厢情愿式编程，想想表单想用什么样的简单 API 呢？下面这种怎么样：

    def save(self):
        List.create_new(first_item_text=self.cleaned_data['text'])

又冒出个想法：要不在 List 类 1 中定义一个辅助函数，封装保存新清单对象及相关的第一个待办事项这部分逻辑。

1你很可能想定义一个单独的函数，但是放在类中有利于记住它在哪儿。更重要的是，还能表明这个函数的作用。嗯，我保证等我写完这本书之后会这么做的。尖锐的批评就此打住吧！

那就先为这个想法编写测试：

lists/tests/test_forms.py (ch20l021)

import unittest
from unittest.mock import patch, Mock
from django.test import TestCase

from lists.forms import (
    DUPLICATE_ITEM_ERROR, EMPTY_ITEM_ERROR,
    ExistingListItemForm, ItemForm, NewListForm
)
from lists.models import Item, List
[...]

class NewListFormTest(unittest.TestCase):

    @patch('lists.forms.List.create_new')
    def test_save_creates_new_list_from_post_data_if_user_not_authenticated(
        self, mock_List_create_new
    ):
        user = Mock(is_authenticated=False)
        form = NewListForm(data={'text': 'new item text'})
        form.is_valid()
        form.save(owner=user)
        mock_List_create_new.assert_called_once_with(
            first_item_text='new item text'
        )

既然已经测试了这种情况，那就再写个测试检查用户已经通过认证的情况吧：

lists/tests/test_forms.py (ch20l022)

    @patch('lists.forms.List.create_new')
    def test_save_creates_new_list_with_owner_if_user_authenticated(
        self, mock_List_create_new
    ):
        user = Mock(is_authenticated=True)
        form = NewListForm(data={'text': 'new item text'})
        form.is_valid()
        form.save(owner=user)
        mock_List_create_new.assert_called_once_with(
            first_item_text='new item text', owner=user
        )

可以看出，这个测试易读多了。下面开始实现新表单。先从 import 语句开始：

lists/forms.py (ch20l023)

from lists.models import Item, List

此时驭件说要定义一个占位的 create_new 方法：

AttributeError: <class 'lists.models.List'> does not have the attribute
'create_new'

lists/models.py

class List(models.Model):

    def get_absolute_url(self):
        return reverse('view_list', args=[self.id])

    def create_new():
        pass

几步之后，最终写出如下的 save 方法：

lists/forms.py (ch20l025)

class NewListForm(ItemForm):

    def save(self, owner):
        if owner.is_authenticated:
            List.create_new(first_item_text=self.cleaned_data['text'], owner=owner)
        else:
            List.create_new(first_item_text=self.cleaned_data['text'])

而且测试也通过了：

$ python manage.py test lists
Ran 44 tests in 0.192s
OK

把 ORM 代码放到辅助方法中

从编写隔离测试的过程中我们学会了一项技能——“ORM 辅助方法”。

使用 Django 的ORM 可以通过十分易读的句法（肯定比纯 SQL 好得多）快速完成工作。但有些人喜欢尽量减少应用中使用的 ORM 代码量，尤其不喜欢在视图层和表单层使用 ORM 代码。

一个原因是，测试这几层时更容易；另一个原因是，必须定义辅助方法，这样能更清晰地表述域逻辑。请对比这段代码：

    list_ = List()
    list_.save()
    item = Item()
    item.list = list_
    item.text = self.cleaned_data['text']
    item.save()

和这段代码：

List.create_new(first_item_text=self.cleaned_data['text'])

辅助方法同样可用于读写查询。假设有这样一行代码：

Book.objects.filter(in_print=True, pub_date__lte=datetime.today())

和如下的辅助方法相比，孰好孰坏一目了然：

Book.all_available_books()

定义辅助方法时，可以起个适当的名字，表明它们在业务逻辑中的作用。使用辅助方法不仅可以让代码的条理变得更清晰，还能把所有 ORM 调用都放在模型层，因此整个应用不同部分之间的耦合更松散。

23.6　下移到模型层

在模型层不用再编写隔离测试了，因为模型层的目的就是与数据库结合在一起工作，所以编写整合测试更合理：

lists/tests/test_models.py (ch20l026)

class ListModelTest(TestCase):

    def test_get_absolute_url(self):
        list_ = List.objects.create()
        self.assertEqual(list_.get_absolute_url(), f'/lists/{list_.id}/')

    def test_create_new_creates_list_and_first_item(self):
        List.create_new(first_item_text='new item text')
        new_item = Item.objects.first()
        self.assertEqual(new_item.text, 'new item text')
        new_list = List.objects.first()
        self.assertEqual(new_item.list, new_list)

测试的结果为：

TypeError: create_new() got an unexpected keyword argument 'first_item_text'

根据测试结果，可以先把实现方式写成这样：

lists/models.py (ch20l027)

class List(models.Model):

    def get_absolute_url(self):
        return reverse('view_list', args=[self.id])

    @staticmethod
    def create_new(first_item_text):
        list_ = List.objects.create()
        Item.objects.create(text=first_item_text, list=list_)

注意，一路走下来，直到模型层，由视图层和表单层驱动，得到了一个设计良好的模型，但是 List 模型还不支持属主。

现在，测试清单应该有一个属主。添加如下测试：

lists/tests/test_models.py (ch20l028)

from django.contrib.auth import get_user_model
User = get_user_model()
[...]

    def test_create_new_optionally_saves_owner(self):
        user = User.objects.create()
        List.create_new(first_item_text='new item text', owner=user)
        new_list = List.objects.first()
        self.assertEqual(new_list.owner, user)

既然已经打开这个文件，那就再为 owner 属性编写一些测试吧：

lists/tests/test_models.py (ch20l029)

class ListModelTest(TestCase):
    [...]

    def test_lists_can_have_owners(self):
        List(owner=User())  # 不该抛出异常

    def test_list_owner_is_optional(self):
        List().full_clean()  # 不该抛出异常

这两个测试和前一章使用的测试几乎一样，不过我稍微改了些，不让它们保存对象。因为对这个测试而言，内存中有这些对象就行了。

　尽量多用内存中（未保存）的模型对象，这样测试运行得更快。

测试的结果为：

$ python manage.py test lists
[...]
ERROR: test_create_new_optionally_saves_owner
TypeError: create_new() got an unexpected keyword argument 'owner'
[...]
ERROR: test_lists_can_have_owners (lists.tests.test_models.ListModelTest)
TypeError: 'owner' is an invalid keyword argument for this function
[...]
Ran 48 tests in 0.204s
FAILED (errors=2)

然后按照前一章使用的方式实现模型：

lists/models.py (ch20l030-1)

from django.conf import settings
[...]

class List(models.Model):
    owner = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True)
    [...]

此时，测试的结果中有各种完整性失败，执行迁移后才能解决这些问题：

django.db.utils.OperationalError: no such column: lists_list.owner_id

执行迁移后再运行测试，会看到下面三个失败：

ERROR: test_create_new_optionally_saves_owner
TypeError: create_new() got an unexpected keyword argument 'owner'
[...]
ValueError: Cannot assign "<SimpleLazyObject:
<django.contrib.auth.models.AnonymousUser object at 0x7f5b2380b4e0>>":
"List.owner" must be a "User" instance.
ValueError: Cannot assign "<SimpleLazyObject:
<django.contrib.auth.models.AnonymousUser object at 0x7f5b237a12e8>>":
"List.owner" must be a "User" instance.

先处理第一个失败。这个失败由 create_new 方法导致：

lists/models.py (ch20l030-3)

    @staticmethod
    def create_new(first_item_text, owner=None):
        list_ = List.objects.create(owner=owner)
        Item.objects.create(text=first_item_text, list=list_)

回到视图层

视图层以前的两个整合测试失败了，怎么回事呢？

ValueError: Cannot assign "<SimpleLazyObject:
<django.contrib.auth.models.AnonymousUser object at 0x7fbad1cb6c10>>":
"List.owner" must be a "User" instance.

啊，原来是因为以前的视图没有分清谁才是清单的属主：

lists/views.py

    if form.is_valid():
        list_ = List()
        list_.owner = request.user
        list_.save()

这一刻才意识到以前的代码没有满足需求。修正这个问题，让所有测试都通过：

lists/views.py (ch20l031)

def new_list(request):
    form = ItemForm(data=request.POST)
    if form.is_valid():
        list_ = List()
        if request.user.is_authenticated:
            list_.owner = request.user
        list_.save()
        form.save(for_list=list_)
        return redirect(list_)
    else:
        return render(request, 'home.html', {"form": form})

    def new_list2(request):
        [...]

　整合测试的好处之一是，可以捕获这种无法轻易预测的交互。我们忘了编写测试检查用户没有通过验证的情况，可是整合测试会由上而下使用整个组件，最终模型层出现了错误，提醒我们忘了一些事。

$ python manage.py test lists
[...]
Ran 48 tests in 0.175s
OK

23.7　关键时刻，以及使用模拟技术的风险

换掉以前的视图，使用新视图试试。调换视图可以在 urls.py 中完成：

lists/urls.py

[...]
    url(r'^new$', views.new_list2, name='new_list'),

还得删除整合测试类上的 unittest.skip 修饰器，看看为清单属主编写的新代码是否真得可用：

lists/tests/test_views.py (ch20l033)

class NewListViewIntegratedTest(TestCase):

    def test_can_save_a_POST_request(self):
        [...]

    def test_list_owner_is_saved_if_user_is_authenticated(self):
        [...]
        self.assertEqual(list_.owner, user)

那么测试的结果如何呢？啊，情况不妙！

ERROR: test_list_owner_is_saved_if_user_is_authenticated
[...]
ERROR: test_can_save_a_POST_request
[...]
ERROR: test_redirects_after_POST
(lists.tests.test_views.NewListViewIntegratedTest)
  File "/.../superlists/lists/views.py", line 30, in new_list2
    return redirect(list_)
[...]
TypeError: argument of type 'NoneType' is not iterable

FAILED (errors=3)

测试隔离有个很重要的知识点：虽然它有可能帮助你为单独各层做出好的设计，但无法自动验证各层之间的集成情况。

上述测试结果表明，视图期望表单返回一个待办事项：

lists/views.py

        list_ = form.save(owner=request.user)
        return redirect(list_)

但没让表单返回任何值：

lists/forms.py

    def save(self, owner):
        if owner.is_authenticated:
            List.create_new(first_item_text=self.cleaned_data['text'], owner=owner)
        else:
            List.create_new(first_item_text=self.cleaned_data['text'])

23.8　把层与层之间的交互当作“合约”

除了隔离的单元测试之外，就算什么都没写，功能测试最终也能发现这个失误。但理想情况下，我们希望尽早得到反馈——功能测试可能要运行好几分钟，应用变大之后甚至可能要几个小时。在这种问题发生之前有没有办法避免呢？

理论上讲，有办法：把层与层之间的交互看成一种“合约”。只要模拟一层的行为，就要在心里记住，层与层之间现在有了隐形合约，这一层的驭件或许可以转移到下一层的测试中。

遗忘的合约如下所示：

lists/tests/test_views.py

    @patch('lists.views.redirect')
    def test_redirects_to_form_returned_object_if_form_valid(
        self, mock_redirect, mockNewListForm
    ):
        mock_form = mockNewListForm.return_value
        mock_form.is_valid.return_value = True

        response = new_list2(self.request)

        self.assertEqual(response, mock_redirect.return_value)
        mock_redirect.assert_called_once_with(mock_form.save.return_value)  ➊

➊ 模拟的 form.save 方法返回一个对象，我们希望在视图中使用这个对象。

23.8.1　找出隐形合约

现在要审查 NewListViewUnitTest 类中的每个测试，看看各驭件在隐形合约中表述了什么：

lists/tests/test_views.py

    def test_passes_POST_data_to_NewListForm(self, mockNewListForm):
        [...]
        mockNewListForm.assert_called_once_with(data=self.request.POST)  ➊

    def test_saves_form_with_owner_if_form_valid(self, mockNewListForm):
        mock_form = mockNewListForm.return_value
        mock_form.is_valid.return_value = True  ➋
        new_list2(self.request)
        mock_form.save.assert_called_once_with(owner=self.request.user)  ➌

    def test_does_not_save_if_form_invalid(self, mockNewListForm):
        [...]
        mock_form.is_valid.return_value = False  ➋
        [...]

    @patch('lists.views.redirect')
    def test_redirects_to_form_returned_object_if_form_valid(
        self, mock_redirect, mockNewListForm
    ):
        [...]
        mock_redirect.assert_called_once_with(mock_form.save.return_value)  ➍

    @patch('lists.views.render')
    def test_renders_home_template_with_form_if_form_invalid(
        [...]

❶ 需要传入 POST 请求中的数据，以便初始化表单。

❷ 表单对象要能响应 is_valid() 方法，而且要根据输入值判断返回 True 还是 False。

❸ 表单对象要能响应 .save 方法，而且传入的参数值是 request.user，然后根据用户是否登录做相应处理。

❹ 表单对象的 .save 方法应该返回一个新清单对象，以便视图把用户重定向到显示这个对象的页面。

仔细分析表单测试，可以看出，其实只明确测试了➌。➊和➋很幸运，因为这是 Django 中 ModelForm 的默认特性，而且针对父类 ItemForm 的测试涵盖了这两点。

但➍却成了漏网之鱼。

　使用由外而内的 TDD 技术编写隔离测试时，要记住每个测试在合约中对下一层应该实现的功能做出的隐含假设，而且记得稍后要回来测试这些假设。你可以在便签上记下来，也可以使用 self.fail 编写占位测试。

23.8.2　修正由于疏忽导致的问题

下面添加一个新测试，确保表单返回刚刚保存的清单：

lists/tests/test_forms.py (ch20l038-1)

    @patch('lists.forms.List.create_new')
    def test_save_returns_new_list_object(self, mock_List_create_new):
        user = Mock(is_authenticated=True)
        form = NewListForm(data={'text': 'new item text'})
        form.is_valid()
        response = form.save(owner=user)
        self.assertEqual(response, mock_List_create_new.return_value)

其实，这是个很好的示例——和 List.create_new 之间有隐形合约，希望这个方法返回刚创建的清单对象。下面为这个需求添加一个占位测试：

lists/tests/test_models.py (ch20l038-2)

class ListModelTest(TestCase):
    [...]

    def test_create_returns_new_list_object(self):
        self.fail()

得到一个失败测试，告诉我们要修正表单对象的 save 方法：

AssertionError: None != <MagicMock name='create_new()' id='139802647565536'>
FAILED (failures=2, errors=3)

修正方法如下：

lists/forms.py (ch20l039-1)

class NewListForm(ItemForm):

    def save(self, owner):
        if owner.is_authenticated:
            return List.create_new(first_item_text=self.cleaned_data['text'], owner=owner)
        else:
            return List.create_new(first_item_text=self.cleaned_data['text'])

这才刚开始。下面应该看一下占位测试：

[...]
FAIL: test_create_returns_new_list_object
    self.fail()
AssertionError: None

FAILED (failures=1, errors=3)

编写这个测试：

lists/tests/test_models.py (ch20l039-2)

    def test_create_returns_new_list_object(self):
        returned = List.create_new(first_item_text='new item text')
        new_list = List.objects.first()
        self.assertEqual(returned, new_list)

测试结果为：

AssertionError: None != <List: List object>

然后加上返回值：

lists/models.py (ch20l039-3)

    @staticmethod
    def create_new(first_item_text, owner=None):
        list_ = List.objects.create(owner=owner)
        Item.objects.create(text=first_item_text, list=list_)
        return list_

现在整个测试组件都可以通过了：

$ python manage.py test lists
[...]
Ran 50 tests in 0.169s

OK

23.9　还缺一个测试

以上就是由测试驱动开发出来的保存清单属主功能，这个功能可以正常使用。不过，功能测试却无法通过：

$ python manage.py test functional_tests.test_my_lists
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: Reticulate splines

失败的原因是有一个功能没实现，即清单对象的 .name 属性。这里还可以使用前一章的测试和代码：

lists/tests/test_models.py (ch20l040)

    def test_list_name_is_first_item_text(self):
        list_ = List.objects.create()
        Item.objects.create(list=list_, text='first item')
        Item.objects.create(list=list_, text='second item')
        self.assertEqual(list_.name, 'first item')

（再次说明，因为这是模型层测试，所以使用 ORM 没问题。你可能想使用驭件编写这个测试，不过这么做没什么意义。)

lists/models.py (ch20l041)

    @property
    def name(self):
        return self.item_set.first().text

现在功能测试可以通过了：

$ python manage.py test functional_tests.test_my_lists

Ran 1 test in 21.428s

OK

23.10　清理：保留哪些整合测试

现在一切都可以正常运行了，要删除一些多余的测试，还要决定是否保留以前的整合测试。

23.10.1　删除表单层多余的代码

可以把以前针对 ItemForm 类中 save 方法的测试删掉：

lists/tests/test_forms.py

--- a/lists/tests/test_forms.py
+++ b/lists/tests/test_forms.py
@@ -23,14 +23,6 @@ class ItemFormTest(TestCase):

         self.assertEqual(form.errors['text'], [EMPTY_ITEM_ERROR])

-    def test_form_save_handles_saving_to_a_list(self):
-        list_ = List.objects.create()
-        form = ItemForm(data={'text': 'do me'})
-        new_item = form.save(for_list=list_)
-        self.assertEqual(new_item, Item.objects.first())
-        self.assertEqual(new_item.text, 'do me')
-        self.assertEqual(new_item.list, list_)
-

对应用的代码而言，可以把 forms.py 中两个多余的 save 方法删掉：

lists/forms.py

--- a/lists/forms.py
+++ b/lists/forms.py
@@ -22,11 +22,6 @@ class ItemForm(forms.models.ModelForm):

         self.fields['text'].error_messages['required'] = EMPTY_ITEM_ERROR

-    def save(self, for_list):
-        self.instance.list = for_list
-        return super().save()
-
-

 class NewListForm(ItemForm):

@@ -52,8 +47,3 @@ class ExistingListItemForm(ItemForm):

             e.error_dict = {'text': [DUPLICATE_ITEM_ERROR]}
             self._update_errors(e)
-
-
-    def save(self):
-        return forms.models.ModelForm.save(self)
-

23.10.2　删除以前实现的视图

现在，可以把以前的 new_list 视图完全删掉，再把 new_list2 重命名为 new_list：

lists/tests/test_views.py

-from lists.views import new_list, new_list2
+from lists.views import new_list

 class HomePageTest(TestCase):
@@ -75,7 +75,7 @@ class NewListViewIntegratedTest(TestCase):
         request = HttpRequest()
         request.user = User.objects.create(email='a@b.com')
         request.POST['text'] = 'new list item'
-        new_list2(request)
+        new_list(request)
         list_ = List.objects.first()
         self.assertEqual(list_.owner, request.user)

@@ -91,21 +91,21 @@ class NewListViewUnitTest(unittest.TestCase):
     def test_passes_POST_data_to_NewListForm(self, mockNewListForm):
-        new_list2(self.request)
+        new_list(self.request)

[.. several more]

lists/urls.py

--- a/lists/urls.py
+++ b/lists/urls.py
@@ -3,7 +3,7 @@ from django.conf.urls import url
 from lists import views

 urlpatterns = [
-    url(r'^new$', views.new_list2, name='new_list'),
+    url(r'^new$', views.new_list, name='new_list'),
     url(r'^(\d+)/$', views.view_list, name='view_list'),
     url(r'^users/(.+)/$', views.my_lists, name='my_lists'),
 ]

lists/views.py (ch20l047)

def new_list(request):
    form = NewListForm(data=request.POST)
    if form.is_valid():
        list_ = form.save(owner=request.user)
        [...]

然后检查所有测试是否仍能通过：

OK

23.10.3　删除视图层多余的代码

最后要决定保留哪些整合测试（如果需要保留的话）。

一种方法是全部删除，让功能测试捕获集成问题。这么做完全可行。

不过，从前文得知，如果在集成各层时犯了小错误，整合测试可以提醒你。可以保留部分测试，作为完整性检查，以便得到快速反馈。

要不就保留下面这三个测试吧：

lists/tests/test_views.py (ch20l048)

class NewListViewIntegratedTest(TestCase):

    def test_can_save_a_POST_request(self):
        self.client.post('/lists/new', data={'text': 'A new list item'})
        self.assertEqual(Item.objects.count(), 1)
        new_item = Item.objects.first()
        self.assertEqual(new_item.text, 'A new list item')

    def test_for_invalid_input_doesnt_save_but_shows_errors(self):
        response = self.client.post('/lists/new', data={'text': ''})
        self.assertEqual(List.objects.count(), 0)
        self.assertContains(response, escape(EMPTY_ITEM_ERROR))

    def test_list_owner_is_saved_if_user_is_authenticated(self):
        user = User.objects.create(email='a@b.com')
        self.client.force_login(user)
        self.client.post('/lists/new', data={'text': 'new item'})
        list_ = List.objects.first()
        self.assertEqual(list_.owner, user)

如果最终决定保留中间层的测试，我认为这三个不错，因为我觉得它们涵盖了大部分集成操作：测试了整个组件，从请求直到数据库，而且覆盖了视图最重要的三个用例。

23.11　总结：什么时候编写隔离测试，什么时候编写整合测试

Django 提供的测试工具为快速编写整合测试提供了便利。测试运行程序能帮助我们创建一个存在于内存中的数据库，运行速度很快，而且在两次测试之间还能重建数据库。使用 TestCase 类和测试客户端测试视图很简单，可以检查是否修改了数据库中的对象，确认 URL 映射是否可用，还能检查渲染模板的情况。这些工具降低了测试的门槛，而且对整个组件而言也能获得不错的覆盖度。

但是，从设计的角度来说，这种整合测试比不上严格的单元测试和由外而内的 TDD，因为它没有后者的优势全面。

就本章的示例而言，可以比较一下修改前后的代码：

def new_list(request):
    form = ItemForm(data=request.POST)
    if form.is_valid():
        list_ = List()
        if not isinstance(request.user, AnonymousUser):
            list_.owner = request.user
        list_.save()
        form.save(for_list=list_)
        return redirect(list_)
    else:
        return render(request, 'home.html', {"form": form})
def new_list(request):
    form = NewListForm(data=request.POST)
    if form.is_valid():
        list_ = form.save(owner=request.user)
        return redirect(list_)
    return render(request, 'home.html', {'form': form})

如果想省点儿事，不走隔离测试这条路，你会下功夫重构视图函数吗？我知道写作本书草稿时我不会。我希望自己在真实的项目中会这么做，但也不能保证。可是编写隔离测试却让你看清代码复杂在何处。

23.11.1　以复杂度为准则

不得不说，处理复杂问题时才能体现隔离测试的优势。本书中的例子非常简单，还不太值得这么做。就算是本章的例子，我也能说服自己，完全不用编写这些隔离测试。

可一旦应用变得复杂，比如视图和模型之间分了更多层、需要编写辅助方法或自己的类，那多编写一些隔离测试或许就能从中受益了。

23.11.2　两种测试都要写吗

功能测试组件能告诉我们集成各部分代码时是否有问题。隔离测试能帮助我们设计出更好的代码，还能验证细节的处理是否正确。那么中间层集成测试还有其他作用吗？

我想，如果集成测试能更快地提供反馈，或者能更精确地找出集成问题的原因所在，那么答案就是肯定的。集成测试的优势之一是，它在调用跟踪中提供的调试信息比功能测试详细。

甚至还可以把各组件分开——可以编写一个速度快、隔离的单元测试组件，完全不用 manage.py，因为这些测试不需要 Django 测试运行程序提供的任何数据库清理操作。然后使用 Django 提供的工具编写中间层测试，最后使用功能测试检查与过渡服务器交互的各层。如果各层提供的功能循序渐进，或许就可以采用这种方案。

到底怎么做，要根据实际情况而定。我希望读过这一章之后，你能体会到如何权衡。第 26 章会继续讨论这个话题。

23.11.3　继续前行

对新版代码很满意，那就合并到主分支吧：

$ git add .
$ git commit -m "add list owners via forms. more isolated tests"
$ git checkout master
$ git checkout -b master-noforms-noisolation-bak # 也可以做个备份
$ git checkout master
$ git reset --hard more-isolation # 把主分支重设到这个分支

现在，运行功能测试要花很长时间，我想知道我们能不能做些什么来改善这种状况。

不同测试类型以及解耦 ORM 代码的利弊

功能测试

从用户的角度出发，最大程度上保证应用可以正常运行。

但是，反馈循环用时长。

无法帮助我们写出简洁的代码。

整合测试（依赖于 ORM 或 Django 测试客户端等）

编写速度快。

易于理解。

发现任何集成问题都会提醒你。

但是，并不总能得到好的设计（这取决于你自己）。

一般运行速度比隔离测试慢。

隔离测试（使用驭件）

涉及的工作量最大。

可能难以阅读和理解。

但是，这种测试最能引导你实现更好的设计。

运行速度最快。

解耦应用代码和 ORM 代码

力求隔离测试的后果之一是，我们不得不从视图和表单等处删除 ORM 代码，把它们放到辅助函数或者辅助方法中。如果从解耦应用代码和 ORM 代码的角度看，这么做有好处，还能提高代码的可读性。当然，所有事情都一样，要结合实际情况判断是否值得付出额外精力去做。

第 24 章　持续集成

网站越变越大，运行所有功能测试的时间也越来越长。如果时长一直增加，我们很可能不再运行功能测试。

为了避免发生这种情况，可以搭建一个“持续集成”（Continuous Integration，简称 CI）服务器，自动运行功能测试。这样，在日常开发中，只需运行当下关注的功能测试，整个测试组件则交给 CI 服务器自动运行。如果不小心破坏了某项功能，CI 服务器会通知我们。单元测试的运行速度一直很快，每隔几秒就可以运行一次。

现在，开发者喜欢使用的 CI 服务器是 Jenkins。Jenkins 使用 Java 开发，经常出问题，界面也不漂亮，但大家都在用，而且插件系统很棒，下面安装并运行 Jenkins。

24.1　安装Jenkins

CI 托管服务有很多，基本上都提供了一个立即就能使用的 Jenkins 服务器。我知道的就有 Sauce Labs、Travis、Circle-CI 和 ShiningPanda，可能还有更多。假设要在自己有控制权的服务器上安装所需的一切软件。

　把 Jenkins 安装在过渡服务器或生产服务器上可不是个好主意，因为有很多操作要交给 Jenkins 完成，比如重新引导过渡服务器。

要从 Jenkins 的官方 apt 仓库中安装最新版，因为 Ubuntu 默认安装的版本对本地化和 Unicode 支持还有些恼人的问题，而且默认配置也没监听外网：

root@server:$ wget -q -O - https://pkg.jenkins.io/debian/jenkins-ci.org.key |\
     apt-key add -
root@server:$ echo deb http://pkg.jenkins.io/debian-stable binary/ | tee \
    /etc/apt/sources.list.d/jenkins.list
root@server:$ apt-get update
root@server:$ apt-get install jenkins

（这是从 Jenkins 网站上查到的安装说明。）

此外还要安装几个依赖：

root@server:$ apt-get install firefox python3-venv xvfb
# 以及构建fabric3所需的依赖
root@server:$ apt-get install build-essential libssl-dev libffi-dev

我们还要下载、解压和安装 geckodriver（我写到这里时，版本为 v0.17；你阅读时，记得换成最新版）：

root@server:$ wget https://github.com/mozilla/geckodriver/releases\
/download/v0.17.0/geckodriver-v0.17.0-linux64.tar.gz
root@server:$ tar -xvzf geckodriver-v0.17.0-linux64.tar.gz
root@server:$ mv geckodriver /usr/local/bin
root@server:$ geckodriver --version
geckodriver 0.17.0

增加一些交换内存

Jenkins 是内存消耗大户。如果在 RAM 很小的虚拟主机上运行，会由于内存不足而崩溃。这时，要增加一些交换内存：

$ fallocate -l 4G /swapfile
$ mkswap /swapfile
$ chmod 600 /swapfile
$ swapon /swapfile

这样内存就充足了。

24.2　配置Jenkins

现在可以访问服务器的 URL/IP，通过 8080 端口访问 Jenkins，你会看到如图 24-1 所示的界面。

图 24-1：Jenkins 解锁界面

24.2.1　首次解锁

解锁界面告诉我们，首次使用时要从磁盘中读取一个文件，解锁服务器。切换到终端，使用下述命令打印那个文件的内容：

root@server$ cat /var/lib/jenkins/secrets/initialAdminPassword

24.2.2　现在建议安装的插件

接下来会让你选择安装“推荐的”插件。系统推荐的插件还不错。（作为自尊心强的技术宅，我们会本能地点击“自定义”。一开始我也是这么做的，但是自定义界面也没什么。别担心，稍后会再添加一些插件。）

24.2.3　配置管理员用户

接下来，设置登录 Jenkins 的用户名和密码，如图 24-2 所示。

图 24-2：Jenkins 管理员用户配置界面

登录后会看到一个欢迎界面（如图 24-3 所示）。

图 24-3：看到一个男仆（左上角），好奇怪

24.2.4　添加插件

依次点击这些链接：Manage Jenkins（管理 Jenkins）→ Manage Plugins（管理插件）→ Available（可用插件）。

我们将安装下述插件：

ShiningPanda

Xvfb

点击“Install”（如图 24-4 所示）。

图 24-4：安装插件中……

24.2.5　告诉Jenkins到哪里寻找Python 3和Xvfb

我们要告诉 ShiningPanda 插件，Python 3 安装在哪里（通常是 /usr/bin/python3，不过也可以执行 which python3 命令查看）。

Manage Jenkins（管理 Jenkins）→ Global Tool Configuration（全局工具配置）。

Python → Python installations（Python 安装位置）→ Add Python（添加 Python，如图 24-5 所示；可以放心忽略提醒消息）。

Xvfb installation（Xvfb 安装位置）→ Add Xvfb installation（添加 Xvfb 安装位置）；在安装目录中输入 /usr/bin。

图 24-5：Python 安装在哪里

24.2.6　设置HTTPS

为了提升 Jenkins 的安全性，最后还要设置 HTTPS。为此，我们将让 Nginx 使用自签名的证书，把发给 443 端口的请求转发给 8080 端口。这样设置之后，甚至可以让防火墙阻断 8080 端口。具体设置方法这里不详细讨论，你可以参照下述链接给出的说明。

Jenkins 官方的 Ubuntu 安装指南。

如何创建自签名 SSL 证书。

如何把 HTTP 重定向到 HTTPS。

24.3　设置项目

现在 Jenkins 基本配置好了，下面设置项目。

点击“New Item”按钮。

名称输入“Superlists”，选择“Freestyle project”，然后点击“OK”。

添加 Git 仓库，如图 24-6 所示。

图 24-6：从 Git 仓库中获取源码

设为每小时轮询一次（如图 24-7，看一下帮助文本，触发构建操作还有很多其他方式）。

图 24-7：轮询 GitHub，获取改动

在一个 Python 3 虚拟环境中运行测试。

单元测试和功能测试分开运行，如图 24-8 所示。

图 24-8：虚拟环境中执行的构建步骤

24.4　第一次构建

点击“Build Now”（现在构建）按钮，然后查看“Console Output”（终端输出），应该会看到类似下面的内容：

Started by user harry
Building in workspace /var/lib/jenkins/jobs/Superlists/workspace
Fetching changes from the remote Git repository
Fetching upstream changes from https://github.com/hjwp/book-example.git
Checking out Revision d515acebf7e173f165ce713b30295a4a6ee17c07 (origin/master)
[workspace] $ /bin/sh -xe /tmp/shiningpanda7260707941304155464.sh
+ pip install -r requirements.txt
Requirement already satisfied (use --upgrade to upgrade): Django==1.11 in
/var/lib/jenkins/shiningpanda/jobs/ddc1aed1/virtualenvs/d41d8cd9/lib/python3.3/
site-packages
(from -r requirements.txt (line 1))

Requirement already satisfied (use --upgrade to upgrade): gunicorn==17.5 in
/var/lib/jenkins/shiningpanda/jobs/ddc1aed1/virtualenvs/d41d8cd9/lib/python3.3/
site-packages
(from -r requirements.txt (line 3))
Downloading/unpacking requests==2.0.0 (from -r requirements.txt (line 4))
  Running setup.py egg_info for package requests

Installing collected packages: requests
  Running setup.py install for requests

Successfully installed requests
Cleaning up...
+ python manage.py test lists accounts
...................................................................
 ---------------------------------------------------------------------
Ran 67 tests in 0.429s

OK
Creating test database for alias 'default'...
Destroying test database for alias 'default'...
+ python manage.py test functional_tests
EEEEEE
======================================================================
ERROR: functional_tests.test_layout_and_styling (unittest.loader._FailedTest)
 ---------------------------------------------------------------------
ImportError: Failed to import test module: functional_tests.test_layout_and_styling
[...]
ImportError: No module named 'selenium'

Ran 6 tests in 0.001s

FAILED (errors=6)

Build step 'Virtualenv Builder' marked build as failure

啊，在虚拟环境中要安装 Selenium。

在构建步骤中添加一步，手动安装 Selenium：

    pip install -r requirements.txt
    python manage.py test accounts lists
    pip install selenium
    python manage.py test functional_tests

　有些人喜欢使用 test-requirements.txt 文件指定测试（不是主应用）需要的包。

然后再次点击“Build Now”（现在构建）按钮。

接下来会发生下面两件事中的一件。你可能会看到控制台输出了类似这样的错误消息：

    self.browser = webdriver.Firefox()
[...]
selenium.common.exceptions.WebDriverException: Message: 'The browser appears to
have exited before we could connect. The output was: b"\\n(process:19757):
GLib-CRITICAL **: g_slice_set_config: assertion \'sys_page_size == 0\'
failed\\nError: no display specified\\n"'
[...]
selenium.common.exceptions.WebDriverException: Message: connection refused

也可能构建完全挂起（我至少遇到过一次）。出现这种情况的原因是 Firefox 无法启动，因为没有显示器可用。

24.5　设置虚拟显示器，让功能测试能在无界面的环境中运行

从调用跟踪中可以看出，Firefox 无法启动，因为服务器没有显示器。

这个问题有两种解决方法。第一种，换用无界面浏览器（headless　browser），例如 PhantomJS 或 SlimerJS。这种工具绝对有存在的意义，最大的特点是运行速度快，但也有缺点。首先，它们不是“真正的”Web 浏览器，所以无法保证能捕获用户使用真正的浏览器时遇到的全部怪异行为。其次，它们在 Selenium 中的表现差异很大，因此要花费大量精力重写功能测试。

　我只把无界面浏览器当作开发工具，目的是在开发者的设备中提升功能测试的运行速度。在 CI 服务器上运行测试则使用真正的浏览器。

第二种解决方法是设置虚拟显示器：让服务器认为自己连接了显示器，这样 Firefox 就能正常运行了。这种工具很多，我们要使用的是“Xvfb”（X Virtual Framebuffer）1，因为它安装和使用都很简单，而且还有一个合用的 Jenkins 插件（现在知道为什么之前要安装它了吧）。

1如果想在 Python 代码中控制虚拟显示器，可以试试 pyvirtualdisplay。

回到项目页面，点击“Configure”（配置），找到“Build Environment”（构建环境）部分。启用虚拟显示器的方法很简单，勾选“Start Xvfb before the build, and shut it down after”（构建前启动 Xvfb，并在构建完成后关闭）即可，如图 24-9 所示。

图 24-9：有时配置方式很简单

现在构建过程顺利多了：

[...]
Xvfb starting$ /usr/bin/Xvfb :2 -screen 0 1024x768x24 -fbdir
/var/lib/jenkins/2013-11-04_03-27-221510012427739470928xvfb
[...]
+ python manage.py test lists accounts
...............................................................
 ---------------------------------------------------------------------
Ran 63 tests in 0.410s

OK
Creating test database for alias 'default'...
Destroying test database for alias 'default'...

+ pip install selenium
Requirement already satisfied (use --upgrade to upgrade): selenium in
/var/lib/jenkins/shiningpanda/jobs/ddc1aed1/virtualenvs/d41d8cd9/lib/python3.5/
site-packages
Cleaning up...

+ python manage.py test functional_tests
......F.
======================================================================
FAIL: test_can_start_a_list_for_one_user
(functional_tests.test_simple_list_creation.NewVisitorTest)
 ---------------------------------------------------------------------
Traceback (most recent call last):
  File "/.../superlists/functional_tests/test_simple_list_creation.py", line
43, in test_can_start_a_list_for_one_user
    self.wait_for_row_in_list_table('2: Use peacock feathers to make a fly')
  File "/.../superlists/functional_tests/base.py", line 51, in
wait_for_row_in_list_table
    raise e
  File "/.../superlists/functional_tests/base.py", line 47, in
wait_for_row_in_list_table
    self.assertIn(row_text, [row.text for row in rows])
AssertionError: '2: Use peacock feathers to make a fly' not found in ['1: Buy
peacock feathers']
 ---------------------------------------------------------------------
Ran 8 tests in 89.275s

FAILED (errors=1)
Creating test database for alias 'default'...
[{'secure': False, 'domain': 'localhost', 'name': 'sessionid', 'expiry':
1920011311, 'path': '/', 'value': 'a8d8bbde33nreq6gihw8a7r1cc8bf02k'}]
Destroying test database for alias 'default'...
Build step 'Virtualenv Builder' marked build as failure
Xvfb stopping
Finished: FAILURE

就快成功了！为了调试错误，还需要截图。

　 Jenkins 性能不足，所以不一定总会出现。你可能会看到不同的错误，或者根本没错误。不管怎样，下面介绍的截图工具和处理条件竞争的工具总有一天会用到。继续读吧！

24.6　截图

为了调试远程设备中意料之外的失败，最好能看到失败时的屏幕图片，或者还可以转储页面的 HTML。这些操作可在功能测试类中的 tearDown 方法里自定义逻辑实现。为此，要深入 unittest 的内部，使用私有属性 _outcomeForDoCleanups，不过像下面这样写也行：

functional_tests/base.py (ch21l006)

import os
from datetime import datetime
[...]

SCREEN_DUMP_LOCATION = os.path.join(
    os.path.dirname(os.path.abspath(__file__)), 'screendumps'
)
[...]

    def tearDown(self):
        if self._test_has_failed():
            if not os.path.exists(SCREEN_DUMP_LOCATION):
                os.makedirs(SCREEN_DUMP_LOCATION)
            for ix, handle in enumerate(self.browser.window_handles):
                self._windowid = ix
                self.browser.switch_to_window(handle)
                self.take_screenshot()
                self.dump_html()
        self.browser.quit()
        super().tearDown()

    def _test_has_failed(self):
        # 有点令人费解，但我找不到更好的方法了
        return any(error for (method, error) in self._outcome.errors)

首先，必要时创建存放截图的目录。然后，遍历所有打开的浏览器选项卡和页面，调用一些 Selenium 提供的方法（get_screenshot_as_file 和 browser.page_source）截图以及转储 HTML：

functional_tests/base.py (ch21l007)

    def take_screenshot(self):
        filename = self._get_filename() + '.png'
        print('screenshotting to', filename)
        self.browser.get_screenshot_as_file(filename)

    def dump_html(self):
        filename = self._get_filename() + '.html'
        print('dumping page HTML to', filename)
        with open(filename, 'w') as f:
            f.write(self.browser.page_source)

最后，使用一种方式生成唯一的文件名标识符。文件名中包括测试方法和测试类的名字，以及一个时间戳：

functional_tests/base.py (ch21l008)

    def _get_filename(self):
        timestamp = datetime.now().isoformat().replace(':', '.')[:19]
        return '{folder}/{classname}.{method}-window{windowid}-{timestamp}'.format(
            folder=SCREEN_DUMP_LOCATION,
            classname=self.__class__.__name__,
            method=self._testMethodName,
            windowid=self._windowid,
            timestamp=timestamp
        )

可以先在本地测试一下，故意让某个测试失败，例如使用 self.fail()，会看到类似下面的输出：

[...]
screenshotting to /.../superlists/functional_tests/screendumps/MyListsTest.test
_logged_in_users_lists_are_saved_as_my_lists-window0-2014-03-09T11.19.12.png
dumping page HTML to /.../superlists/functional_tests/screendumps/MyListsTest.t
est_logged_in_users_lists_are_saved_as_my_lists-window0-[...]

删掉 self.fail()，然后提交，再推送：

$ git diff # 显示base.py中的改动
$ echo "functional_tests/screendumps" >> .gitignore
$ git commit -am "add screenshot on failure to FT runner"
$ git push

在 Jenkins 中重新构建时，会看到类似下面的输出：

screenshotting to /var/lib/jenkins/jobs/Superlists/.../functional_tests/
screendumps/LoginTest.test_login_with_persona-window0-2014-01-22T17.45.12.png
dumping page HTML to /var/lib/jenkins/jobs/Superlists/.../functional_tests/
screendumps/LoginTest.test_login_with_persona-window0-2014-01-22T17.45.12.html

可以在“工作空间”中查看这些文件。工作空间是 Jenkins 用来存储源码以及运行测试所在的文件夹，如图 24-10 所示。

图 24-10：访问项目的工作空间

然后查看截图，如图 24-11 所示。

图 24-11：截图看起来正常

24.7　如有疑问，增加超时试试

嗯，显然没什么线索。老话说得好，如有疑问，增加超时：

functional_tests/base.py

MAX_WAIT = 20

然后在 Jenkins 中点击“Buid now”（现在构建），再次构建，确认测试是否能通过，如图 24-12 所示。

图 24-12：前景更明朗

Jenkins 使用蓝色表示构建成功。居然没用绿色，真让人失望。不过看到太阳从云中探出头来，心情又舒畅了。这个图标表示成功构建和失败构建的平均比值正在发生变化，而且是向好的一面发展。

24.8　使用PhantomJS运行QUnit JavaScript测试

差点儿忘了还有一种测试——JavaScript 测试。现在的“测试运行程序”是真正的 Web 浏览器。若想在 Jenkins 中运行 JavaScript 测试，需要一种命令行测试运行程序。借此机会使用 PhantomJS。

24.8.1　安装node

别再假装用不到 JavaScript 了，做 Web 开发，离不开它。因此，要在自己的电脑中安装 node.js，这一步不可避免。

安装方法参见 node.js 下载页面中的说明。Windows 和 Mac 系统都有安装包，而且各种流行的 Linux 发行版都有各自的包 2。

2一定要下载最新版。在 Ubuntu 中别用默认的包，要使用 PPA。

安装好 node 之后，可以执行下面的命令安装 PhantomJS：

root@server $ npm install -g phantomjs # -g的意思是系统全局安装

接下来要下载 QUnit/PhantomJS 测试运行程序。测试运行程序有很多（为了运行本书中的 QUnit 测试，我甚至还自己写过一个简单的），不过最好使用 QUnit 插件页面提到的那个。写作本书时，这个运行程序的仓库地址是 https://github.com/jonkemp/qunit-phantomjs-runner。只需要一个文件，runner.js。

最终得到的文件夹结构如下：

$ tree lists/static/tests/
lists/static/tests/
├── qunit-2.0.1.css
├── qunit-2.0.1.js
├── runner.js
└── tests.html

0 directories, 4 files

试一下这个运行程序：

$ phantomjs lists/static/tests/runner.js lists/static/tests/tests.html
Took 24ms to run 2 tests. 2 passed, 0 failed.

保险起见，故意破坏一个测试：

lists/static/list.js (ch21l019)

  $('input[name="text"]').on('keypress', function () {
    // $('.has-error').hide();
  });

果然，测试失败了：

$ phantomjs lists/static/tests/runner.js lists/static/tests/tests.html

Test failed: errors should be hidden on keypress
    Failed assertion: expected: false, but was: true
file:///.../superlists/lists/static/tests/tests.html:27:15

Took 27ms to run 2 tests. 1 passed, 1 failed.

很好！再改回去，提交并推送运行程序，然后将其添加到 Jenkins 的构建步骤中：

$ git checkout lists/static/list.js
$ git add lists/static/tests/runner.js
$ git commit -m "Add phantomjs test runner for javascript tests"
$ git push

24.8.2　在Jenkins中添加构建步骤

再次编辑项目配置，为每个 JavaScript 测试文件添加一个构建步骤，如图 24-13 所示。

图 24-13：为 JavaScript 单元测试添加构建步骤

还要在服务器中安装 PhantomJS：

root@server:$ add-apt-repository -y ppa:chris-lea/node.js
root@server:$ apt-get update
root@server:$ apt-get install nodejs
root@server:$ npm install -g phantomjs

至此，编写了完整的 CI 构建步骤，能运行所有测试！

Started by user harry
Building in workspace /var/lib/jenkins/jobs/Superlists/workspace
Fetching changes from the remote Git repository
Fetching upstream changes from https://github.com/hjwp/book-example.git
Checking out Revision 936a484038194b289312ff62f10d24e6a054fb29 (origin/chapter_1
Xvfb starting$ /usr/bin/Xvfb :1 -screen 0 1024x768x24 -fbdir /var/lib/jenkins/20
[workspace] $ /bin/sh -xe /tmp/shiningpanda7092102504259037999.sh

+ pip install -r requirements.txt
[...]

+ python manage.py test lists
.................................
 ---------------------------------------------------------------------
Ran 43 tests in 0.229s

OK
Creating test database for alias 'default'...
Destroying test database for alias 'default'...

+ python manage.py test accounts
..................
 ---------------------------------------------------------------------
Ran 18 tests in 0.078s

OK
Creating test database for alias 'default'...
Destroying test database for alias 'default'...

[workspace] $ /bin/sh -xe /tmp/hudson2967478575201471277.sh
+ phantomjs lists/static/tests/runner.js lists/static/tests/tests.html
Took 32ms to run 2 tests. 2 passed, 0 failed.
+ phantomjs lists/static/tests/runner.js accounts/static/tests/tests.html
Took 47ms to run 11 tests. 11 passed, 0 failed.

[workspace] $ /bin/sh -xe /tmp/shiningpanda7526089957247195819.sh
+ pip install selenium
Requirement already satisfied (use --upgrade to upgrade): selenium in /var/lib/

Cleaning up...
[workspace] $ /bin/sh -xe /tmp/shiningpanda2420240268202055029.sh
+ python manage.py test functional_tests
........
 ---------------------------------------------------------------------
Ran 8 tests in 76.804s

OK

如果我太懒，不想在自己的设备中运行整个测试组件，CI 服务器可以代劳——真是太好了。测试山羊的另一个代理人正在网络空间里监视我们呢！

24.9　CI服务器能完成的其他操作

Jenkins 和 CI 服务器的作用只介绍了皮毛。例如，还可以让 CI 服务器在监控仓库的新提交方面变得更智能。

或者做些更有趣的事，除了运行普通的功能测试之外，还可以使用 CI 服务器自动运行过渡服务器中的测试。如果所有功能测试都能通过，你可以添加一个构建步骤，把代码部署到过渡服务器中，然后在过渡服务器中再运行功能测试。这样整个过程又多了一步可以自动完成，而且可以保证过渡服务器始终使用最新的代码。

有些人甚至使用 CI 服务器把最新发布的代码部署到生产服务器中。

CI 和 Selenium 最佳实践

尽早为自己的项目搭建 CI 服务器

一旦运行功能测试所花的时间超过几秒钟，你就会发现自己根本不想再运行了。把这个任务交给 CI 服务器吧，确保所有测试都能在某处运行。

测试失败时截图和转储 HTML

如果你能看到测试失败时网页是什么样，调试就容易得多。截图和转储 HTML 有助于调试 CI 服务器中的失败，而且对本地运行的测试也很有用。

时刻准备调整超时

CI 服务器的运行速度可能没有你的笔记本电脑快，尤其是同时运行多个测试、负载较高时。你要时刻准备调整超时，把它设为较大的值，尽量降低随机失败的概率。

想办法把 CI 和过渡服务器连接起来

使用 LiveServerTestCase 的测试在开发环境中不会遇到什么问题，但若想得到十足的保障，就要在真正的服务器中运行测试。想办法让 CI 服务器把代码部署到过渡服务器中，然后在过渡服务器中运行功能测试。这么做还有个附带好处：测试自动化部署脚本是否可用。

第 25 章　简单的社会化功能、页面模式以及练习

“现在一切都要社会化”的调侃是不是有点过时了？不管怎样，现在一切都是经过 A/B 测试和大数据分析能得到超多点击量的列表，类似“创意导师认为将颠覆你观念的十件事”。不管是否真能激发创意，列表都更容易传播。那我们就让用户能和其他人协作完成他们的列表吧。

在实现这个功能的过程中，我们将使用页面对象模式（Page Object pattern）改进功能测试。

我不告诉你具体做法，而是让你自己编写单元测试和应用代码。别担心，我不会让你完全自己动手，会告诉你大概步骤和一些提示。

25.1　有多个用户以及使用addCleanup的功能测试

开始吧。这个功能测试需要两个用户：

functional_tests/test_sharing.py (ch22l001)

from selenium import webdriver
from .base import FunctionalTest

def quit_if_possible(browser):
    try: browser.quit()
    except: pass

class SharingTest(FunctionalTest):

    def test_can_share_a_list_with_another_user(self):
        # 伊迪丝是已登录用户
        self.create_pre_authenticated_session('edith@example.com')
        edith_browser = self.browser
        self.addCleanup(lambda: quit_if_possible(edith_browser))

        # 她的朋友Oniciferous也在使用这个清单网站
        oni_browser = webdriver.Firefox()
        self.addCleanup(lambda: quit_if_possible(oni_browser))
        self.browser = oni_browser
        self.create_pre_authenticated_session('oniciferous@example.com')

        # 伊迪丝访问首页，新建一个清单
        self.browser = edith_browser
        self.browser.get(self.live_server_url)
        self.add_list_item('Get help')

        # 她看到“分享这个清单”选项
        share_box = self.browser.find_element_by_css_selector(
            'input[name="sharee"]'
        )
        self.assertEqual(
            share_box.get_attribute('placeholder'),
            'your-friend@example.com'
        )

这一节有个功能值得注意：addCleanup 函数，它的文档可以在线查看。这个函数可以代替 tearDown 函数，清理测试中使用的资源。如果资源在测试运行的过程中才用到，最好使用 addCleanup 函数，因为这样就不用在 tearDown 函数中花时间区分哪些资源需要清理，哪些不需要清理。

addCleanup 函数在 tearDown 函数之后运行，所以在 quit_if_possible 函数中才要使用 try/except 语句，因为不管 edith_browser 和 oni_browser 中哪一个的值是 self.browser，测试结束时 tearDown 函数都会关闭这个浏览器。

还要把测试方法 create_pre_authenticated_session 从 test_my_lists.py 移到 base.py 中。

好了，看一下测试结果如何：

$ python manage.py test functional_tests.test_sharing
[...]
Traceback (most recent call last):
  File "/.../superlists/functional_tests/test_sharing.py", line 31, in
test_can_share_a_list_with_another_user
[...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: input[name="sharee"]

太好了，看样子可以创建两个用户会话，而且得到了一个意料之中的失败，因为页面中没有填写电子邮件地址的输入框，无法分享给别人。

现在做一次提交，因为至少已经编写了一个占位功能测试，也移动了 create_pre_authenticated_session 函数，接下来要重构功能测试：

$ git add functional_tests
$ git commit -m "New FT for sharing, move session creation stuff to base"

25.2　页面模式

在继续之前，我想再展示一种减少功能测试中重复代码的方式，叫作“页面对象”。

我们为功能测试构建了几个辅助方法，例如这里使用的 add_list_item。但如果不断构建辅助方法，测试也会变得臃肿不堪。我曾处理过一个超过 1500 行的功能测试基类，想改一下都十分困难。

此时便可以使用页面对象，尽量把网站中不同类型页面的所有信息和辅助方法放在一处。下面来看如何在网站中使用页面对象。首先是一个表示清单页面的类：

functional_tests/list_page.py

from selenium.webdriver.common.keys import Keys
from .base import wait

class ListPage(object):

    def __init__(self, test):
        self.test = test  ➊

    def get_table_rows(self):  ➌
        return self.test.browser.find_elements_by_css_selector('#id_list_table tr')

    @wait
    def wait_for_row_in_list_table(self, item_text, item_number):  ➋
        expected_row_text = f'{item_number}: {item_text}'
        rows = self.get_table_rows()
        self.test.assertIn(expected_row_text, [row.text for row in rows])

    def get_item_input_box(self):  ➋
        return self.test.browser.find_element_by_id('id_text')

    def add_list_item(self, item_text):  ➋
        new_item_no = len(self.get_table_rows()) + 1
        self.get_item_input_box().send_keys(item_text)
        self.get_item_input_box().send_keys(Keys.ENTER)
        self.wait_for_row_in_list_table(item_text, new_item_no)
        return self  ➍

❶ 使用表示当前测试的对象初始化，这样就能声明断言、通过 self.test.browser 访问浏览器实例，以及使用 self.test.wait_for 函数。

❷ 从 base.py 中复制一些现有的辅助方法过来，不过稍微做了一点调整……

❸ 例如，使用了这个新方法。

❹ 返回 self 只是一种便利措施，以便串接方法（稍后就会用到）。

下面看一下如何在测试中使用页面对象：

functional_tests/test_sharing.py (ch22l004)

from .list_page import ListPage
[...]

        # 伊迪丝访问首页，新建一个清单
        self.browser = edith_browser
        list_page = ListPage(self).add_list_item('Get help')

继续改写测试，只要想访问列表页面中的元素，就使用页面对象：

functional_tests/test_sharing.py (ch22l008)

        # 她看到“分享这个清单”选项
        share_box = list_page.get_share_box()
        self.assertEqual(
            share_box.get_attribute('placeholder'),
            'your-friend@example.com'
        )

        # 她分享自己的清单之后，页面更新了
        # 提示已经分享给Oniciferous
        list_page.share_list_with('oniciferous@example.com')

我们要在 ListPage 类中添加以下三个方法：

functional_tests/list_page.py (ch22l009)

    def get_share_box(self):
        return self.test.browser.find_element_by_css_selector(
            'input[name="sharee"]'
        )

    def get_shared_with_list(self):
        return self.test.browser.find_elements_by_css_selector(
            '.list-sharee'
        )

    def share_list_with(self, email):
        self.get_share_box().send_keys(email)
        self.get_share_box().send_keys(Keys.ENTER)
        self.test.wait_for(lambda: self.test.assertIn(
            email,
            [item.text for item in self.get_shared_with_list()]
        ))

页面模型背后的思想是，把网站中某个页面的所有信息都集中放在一个地方，如果以后想要修改这个页面，比如简单的调整 HTML 布局，功能测试只需改动一个地方，不用到处修改多个功能测试。

接下来要继续重构其他功能测试。在这里我就不细说了，你可以试着自己完成，感受一下在 DRY 原则和测试可读性方面要做哪些折中处理。

25.3　扩展功能测试测试第二个用户和“My Lists”页面

把分享功能的用户故事写得更详细点儿。伊迪丝在她的清单页面看到这个清单已经分享给 Oniciferous，然后 Oniciferous 登录，看到这个清单出现在“My Lists”页面中，或许显示在“分享给我的清单”中：

functional_tests/test_sharing.py (ch22l010)

from .my_lists_page import MyListsPage
[...]

        list_page.share_list_with('oniciferous@example.com')

        # 现在Oniciferous在他的浏览器中访问清单页面
        self.browser = oni_browser
        MyListsPage(self).go_to_my_lists_page()

        # 他看到了伊迪丝分享的清单
        self.browser.find_element_by_link_text('Get help').click()

为此，要在 MyListPage 类中再定义一个方法：

functional_tests/my_lists_page.py (ch22l011)

class MyListsPage(object):

    def __init__(self, test):
        self.test = test

    def go_to_my_lists_page(self):
        self.test.browser.get(self.test.live_server_url)
        self.test.browser.find_element_by_link_text('My lists').click()
        self.test.wait_for(lambda: self.test.assertEqual(
            self.test.browser.find_element_by_tag_name('h1').text,
            'My Lists'
        ))
        return self

这个方法最好放到 test_my_lists.py 中，或许还可以再定义一个 MyListsPage 类。

现在，Oniciferous 也可以在这个清单中添加待办事项：

functional_tests/test_sharing.py (ch22l012)

    # 在清单页面，Oniciferous看到这个清单属于伊迪丝
    self.wait_for(lambda: self.assertEqual(
        list_page.get_list_owner(),
        'edith@example.com'
    ))

    # 他在这个清单中添加一个待办事项
    list_page.add_list_item('Hi 伊迪丝!')

    # 伊迪丝刷新页面后，看到Oniciferous添加的内容
    self.browser = edith_browser
    self.browser.refresh()
    list_page.wait_for_row_in_list_table('Hi Edith!', 2)

为此，要在 ListPage 类中添加一个方法：

functional_tests/list_page.py (ch22l013)

class ListPage(object):
    [...]

    def get_list_owner(self):
        return self.test.browser.find_element_by_id('id_list_owner').text

早就该运行功能测试了，看看这些测试能否通过：

$ python manage.py test functional_tests.test_sharing

    share_box = list_page.get_share_box()
    [...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: input[name="sharee"]

这个失败在预料之中，因为还没在页面中添加输入框，填写电子邮件地址，分享给别人。做次提交：

$ git add functional_tests
$ git commit -m "Create Page objects for list pages, use in sharing FT"

25.4　留给读者的练习

做完 25.4 节的练习之后，我才算完全明白自己在做什么。

——Iain H.（读者）

若想牢固掌握所学，没什么比得上自己动手实践。所以，我希望你能试着去做下述练习。

大致步骤如下。

(1) 在 list.html 添加一个新区域，先写一个表单，表单中包含一个输入框，用来输入电子邮件地址。功能测试应该会前进一步。

(2) 需要一个视图，处理表单。先在模板中定义 URL，例如 lists//share。

(3) 然后，编写第一个单元测试，驱动我们定义占位视图。我们希望这个视图处理 POST 请求，响应是重定向，指向清单页面，所以这个测试可以命名为 ShareListTest.test_ post_redirects_to_lists_page。

(4) 编写占位视图，只需两行代码，一行用于查找清单，一行用于重定向。

(5) 可以再编写一个单元测试，在测试中创建一个用户和一个清单，在 POST 请求中发送电子邮件地址，然后检查 list_.shared_with.all()（类似于“My Lists”页面使用的那个 ORM 用法）中是否包含这个用户。shared_with 属性还不存在，我们使用的是由外而内的方式。

(6) 所以在这个测试通过之前，要下移到模型层。下一个测试要写入 test_models.py 中。在这个测试中，可以检查清单能否响应 shared_with.add 方法。这个方法的参数是用户的电子邮件地址。然后检查清单的 shared_with.all() 查询集合中是否包含这个用户。

(7) 然后需要用到 ManyToManyField。或许你会看到一条错误消息，提示 related_name 有冲突，查阅 Django 的文档之后你会找到解决办法。

(8) 需要执行一次数据库迁移。

(9) 然后，模型测试应该可以通过。回过头来修正视图测试。

(10) 可能会发现重定向视图的测试失败，因为视图发送的 POST 请求无效。可以选择忽略无效的输入，也可以调整测试，发送有效的 POST 请求。

(11) 然后回到模板层。“My Lists”页面需要一个 <ul> 元素，使用 for 循环列出分享给这个用户的清单。我们还想在清单页面显示这个清单分享给谁了，并注明这个清单的属主是谁。各元素的类和 ID 参见功能测试。如果需要，还可以为这几个需求编写简单的单元测试。

(12) 执行 runserver 命令让网站运行起来，或许能帮助你解决问题，以及调整布局和外观。如果使用隐私浏览器会话，可以同时登录多个用户。

最终，可能会得到类似图 25-1 所示的页面。

图 25-1：分享清单

页面模式以及真正留给读者的练习

在功能测试中运用 DRY 原则

功能测试多起来后，就会发现不同的测试使用了 UI 的同一部分。尽量避免在多个功能测试中使用重复的常量，例如某个 UI 元素 HTML 代码中的 ID 和类。

页面模式

把辅助方法移到 FunctionalTest 基类中会把这个类变得臃肿不堪。可以考虑把处理网站特定部分的全部逻辑保存到单独的页面对象中。

留给读者的练习

希望你真的会做这个练习！试着遵守由外而内的开发方式，如果卡住了，偶尔也可以手动测试。当然，真正留给读者的练习是，在你的下一个项目中使用 TDD。希望它能给你带来愉悦的体验！

下一章做个总结，探讨测试的“最佳实践”。

第 26 章　测试运行速度的快慢和炽热的岩浆

“数据库是炽热的岩浆！”

—— Casey Kinsey

在第 23 章之前，书中几乎所有的“单元”测试或许都应该叫作整合测试，因为这些测试要不依赖于数据库，要不使用 Django 测试客户端，请求、响应和视图函数之间的中间层有太多细节都被隐藏了。

有种说法认为，真正的单元测试一定要隔离，因为单元测试只应该测试软件单独的一部分。如果涉及数据库，那就不是单元测试。数据库是炽热的岩浆！

一些 TDD 老手说，你应该尽力编写完全隔离的单元测试，而不要编写整合测试。测试社区一直都有这样的争论，有时还很白热化。

我只是个狂妄的年轻人，对这场争论的细节并不太了解。但在这一章里，我想试着分析人们为什么如此在意这件事，然后给出一些建议，告诉你什么时候勉强可以使用整合测试（我承认很多时候我都是这样做的），什么时候值得争取编写更纯粹的单元测试。

术语：测试的不同类型

隔离测试（纯粹的单元测试）与整合测试

单元测试的主要作用应该是验证应用的逻辑是否正确。隔离测试只能测试一部分代码，测试是否通过与其他任何外部代码都没有关系。我所说的纯粹的单元测试是指，对一个函数的测试而言，只有这个函数能让测试失败。如果这个函数依赖于其他系统且破坏这个系统会导致测试失败，就说明这是整合测试。这个系统可以是外部系统，例如数据库，也可以是我们无法控制的另一个函数。不管怎样，只要破坏系统会导致测试失败，这个测试就没有完全隔离，因此也就不是纯粹的单元测试。整合测试并非不好，只不过可能意味着同时测试两个功能。

集成测试

集成测试用于检查被你控制的代码是否能和你无法控制的外部系统完好集成。集成测试往往也是整合测试。

系统测试

如果说集成测试检查的是与外部系统的集成情况，那么系统测试就是检查应用内部多个系统之间的集成情况。例如，检查数据库、静态文件和服务器配置在一起是否能正常运行。

功能测试和验收测试

验收测试的作用是从用户的角度检查系统是否能正常运行。（用户能接受这种行为吗？）验收测试很难不写成全栈端到端测试。在前文中，使用功能测试代替验收测试和系统测试。

请原谅我的自命不凡，下面我要使用一些哲学术语，以黑格尔辩证法的结构讨论这些问题。

正题：纯粹的单元测试运行速度快。

反题：编写纯粹的单元测试有哪些风险？

合题：讨论一些最佳实践，例如“端口和适配器”“函数式核心，命令式外壳”以及我们到底想从测试中得到什么。

26.1　正题：单元测试除了运行速度超快之外还有其他优势

关于单元测试你经常会听到一种说法：单元测试运行速度快多了。其实，我不觉得这是单元测试的主要优势，不过速度的确值得一谈。

26.1.1　测试运行得越快，开发速度越快

在其他条件相同的情况下，单元测试运行的速度越快越好。可以适当推理出，所有测试运行的速度都是越快越好。

本书前文已经概括了 TDD 测试 / 编写代码循环。你已经开始习惯 TDD 流程，时而编写最少量的代码，时而运行测试。以后，一分钟内你要多次运行单元测试，一天之内要多次运行功能测试。

所以，简单而言，测试运行的时间越长，等待测试运行完毕的时间就越长，因此也就拖慢了开发进度。而且问题还不止于此。

26.1.2　神赐的心流状态

现在从社会学角度分析。我们程序员有自己的文化，有自己的族群信仰。这个族群分成很多群体，例如崇拜 TDD 的群体（你现在已经成为其中一员）。有些人喜欢 vi，还有些人离经叛道，喜欢 emacs。但我们都认同一件事：神赐的心流状态——这是一种精神上的练习，我们自己的冥想方式。我们的精神完全专注，几个小时弹指一挥间就过去，代码自然而然地从指间流出，问题虽然乏味棘手，但难不倒我们。

如果花时间等待慢吞吞的测试组件运行完毕，肯定无法进入心流状态。只要超过几秒钟，你的注意力就会分散，环境也会变化，导致心流状态消失。心流状态就像梦境一样，只要消失，至少要花 15 分钟才能重现。

26.1.3　经常不想运行速度慢的测试，导致代码变坏

如果测试组件运行得慢，你会失去耐心，不想运行测试，这会导致问题横行。我们也许会羞于重构代码，因为知道重构后要花很多时间等待所有测试运行完毕。这两种情况都会导致代码变坏。

26.1.4　现在还行，不过随着时间推移，整合测试会变得越来越慢

你可能觉得没事，测试组件中有很多整合测试，超过 50 个，但运行只用了 0.2 秒。

可是要知道，这个应用很简单。一旦应用变得复杂，数据库中的表和列越来越多，整合测试就会变得越来越慢。在两个测试之间让 Django 重建数据库所用的时间会越来越长。

26.1.5　别只听我一个人说

Gary Bernhardt 的测试经验比我丰富，他在演讲“Fast Test, Slow Test”中生动地阐述了这些观点。推荐你看一下演讲视频。

26.1.6　单元测试能驱使我们实现好的设计

但是，比上述几点更重要的好处或许我在第 23 章已经说过。为了编写隔离性好的单元测试，必须知道依赖下一层中的什么功能，而且要使用整合测试无法实现的解耦式架构，这有助于设计出更好的代码。

26.2　纯粹的单元测试有什么问题

说完优点我们要来个大转折。编写隔离的单元测试也有其危害，尤其是对于我们（包括我和你）这些 TDD 新手而言。

26.2.1　隔离的测试难读也难写

回忆一下我第一个隔离的单元测试，是不是很丑？我承认，重构时把代码移到表单中有些改进，但想一下如果没这么做呢？代码基中就会有一个十分难读的测试。就算是这个测试的最终版本，也仍有一些比较难理解的部分。

26.2.2　隔离测试不会自动测试集成情况

稍后我们会得知，隔离测试只测试当前关注的单元，而且是在隔离的环境中测试，这种测试本性如此，不测试各单元之间的集成情况。

这个问题众所周知，也有很多缓解的方法。不过前文已经说过，这些缓解措施对程序员来说意味着要付出很多艰苦努力：程序员要记住各单元的界面，要分清每个组件需要履行的合约，除了要为单元的内部功能编写测试之外，还得为合约编写测试。

26.2.3　单元测试几乎不能捕获意料之外的问题

单元测试能帮助你捕获差一错误和逻辑混乱导致的错误，这些错误在编写代码时经常会出现，我们知道这一点，所以这些错误在意料之中。不过出现预料之外的问题时，单元测试不会提醒你。如果忘记创建数据库迁移，单元测试不会提醒你；如果中间层自作聪明转义了 HTML 实体，从而影响数据的渲染方式，显示成“唐纳德 • 拉姆斯菲尔德的 XX”，单元测试也不会提醒你。

26.2.4　使用驭件的测试可能和实现方式联系紧密

最后还有个问题，使用驭件的测试可能和实现方式之间过度耦合。如果你选择使用 List.objects.create() 创建对象，但是驭件希望你使用 List() 和 .save()，这时就算两种用法的实际效果一样，测试也会失败。如果不小心，还可能导致测试本该具有的一个好处缺失，即鼓励重构。如果想修改一个内部 API，你会发现自己要修改很多使用驭件的测试和合约测试。

注意，处理你无法控制的 API 时，这可能不单是一个问题那么简单。你可能还记得我们如何拐弯抹角地测试表单：创建两个 Django 模型驭件，然后使用 side_effect 检查环境的状态。如果编写的代码完全在自己的控制之中，你可能想设计自己的内部 API，这样写出的代码更简洁，而且测试时不用拐这么多弯。

26.2.5　这些问题都可以解决

但是，倡导编写隔离测试的人会过来告诉你，这些问题都可以缓解，你要熟练编写隔离测试，还得进入神赐的心流状态。

现在我们论证到哪一步了？

26.3　合题：我们到底想从测试中得到什么

退一步想一下，我们想从测试中得到什么好处，为什么一开始要编写测试？

26.3.1　正确性

我们希望应用没有问题，不管是差一错误之类的低层逻辑错误，还是高层问题，例如软件最终应该提供用户所需的功能。我们想知道是否引入了回归，导致以前能用的功能失效，

而且想在用户察觉之前发现。我们还期望测试告诉我们应用可以正常运行。

26.3.2　简洁可维护的代码

我们希望代码遵守 YAGNI 和 DRY 等原则；希望代码清晰地表明意图，使用合理的方式分成多个组件，而且各组件作用明确、容易理解；希望从测试中获取自信，可以放心地不断重构应用，这样才不会害怕尝试改进设计；还希望测试能主动帮我们找到正确的设计。

26.3.3　高效的工作流程

最后，我们希望测试能帮助实现一种快速高效的工作流程；希望测试有助于减轻开发压力，而且避免让我们犯一些愚蠢的错误；希望测试能让我们始终处于心流状态，因为心流状态不仅令人享受，而且助人提高工作效率；希望测试尽快对我们的工作做出反馈，这样就能尝试新想法，并尽早改进。而且，改进代码时，如果测试不能提供帮助，我们也不想让它成为障碍。

26.3.4　根据所需的优势评估测试

我觉得应该编写多少测试，以及功能测试、整合测试和隔离测试的量怎么分配，没有通用的规则，因为每个项目的情况不同。但可以把所有测试都纳入考虑范围（如表 26-1 所示），然后考量各种测试，看它们能否提供你需要的优势，由此做出判断。

表26-1：不同类型的测试如何帮助我们达成目标

目标

一些考量事项

正确性

• 站在用户的角度看，功能测试的数量是否足够保证应用真的能正常运行？

• 各种边界情况彻底测试了吗？感觉这是低层隔离测试的任务。

• 有没有编写测试检查所有组件之间是否能正确配合？要不要编写一些整合测试，或者只用功能测试就行？

简洁可维护的代码

• 测试有没有给我重构代码的自信，而且可以无所畏惧地频繁重构？

• 测试有没有帮我得到一个好的设计？如果整合测试较多、隔离测试较少，我要投入精力为应用的哪一部分编写更多隔离测试，才能得到关于设计更全面的反馈？

高效的工作流程

• 反馈循环的速度令我满意吗？我什么时候能得到问题的提醒？有没有某种方法可以让提醒更早出现？

• 如果高层功能测试很多，运行时间很长，要花整晚时间才能得到意外回归的反馈，有没有一种方法可以让我编写速度更快的测试，整合测试也行，让我早点儿得到反馈？

• 如果需要，我能否运行整个测试组件的一个子集？

• 我是否花了太多时间等待测试运行完毕，导致高效率的心流状态时间缩短？

26.4　架构方案

还有一些架构方案可以帮助测试组件发挥最大的作用，而且特别有助于避免隔离测试的缺点。

这些架构方案大都要求找到系统的边缘，即代码和外部系统（例如数据库、文件系统、万维网或者 UI）交互的地方，然后尝试将外部系统和应用的核心业务逻辑区分开。

26.4.1　端口和适配器（或六边形、简洁）架构

集成测试在系统的边界，也就是代码和外部系统（例如数据库、文件系统或 UI 组件）集成的地方，作用最大。

所以，也就是在边界，隔离测试和驭件的作用最小，因为在边界如果测试和某种实现方式耦合过于紧密，最有可能干扰你，或者在边界需要进一步确认各组件之间是否正确集成。

相反，应用的核心代码（只关注业务逻辑和业务规则的代码，完全在我们控制之中的代码）不太需要整合测试，因为我们能控制也能理解这些代码。

所以，实现需求的一种方法是尽量减少处理边界的代码量。这样，就可以使用隔离测试检查核心业务逻辑，使用整合测试检查集成点。

Steve Freeman 和 Nat Pryce 在他们合著的书 Growing Object-Oriented Software，Guided by Tests 中把这种方案称为“端口和适配器”（如图 26-1）。

图 26-1：端口和适配器（Nat Pryce 绘制）

其实，第 23 章已经朝端口和适配器架构方案努力了，当时我们发现编写隔离的单元测试要把 ORM 代码从主应用中移除，定义为模型层的辅助函数。

这种模式有时也叫“简洁架构”或“六边形架构”。详情参见本章末尾的扩展阅读部分。

26.4.2　函数式核心，命令式外壳

Gary Bernhardt 更进一步，推荐使用他称为“函数式核心，命令式外壳”的架构。应用的“外壳”是边界交互的地方，遵守命令式编程范式，可以使用整合测试、验收测试检查，如果精简到一定程度，甚至完全不用测试。而应用的核心使用函数式编程范式编写（完全没有副作用），因此可以使用完全隔离、纯粹的单元测试，根本无须使用驭件。

这个方案的详细说明，参见 Gary 的演讲，主题为 Boundaries。

26.5　小结

我尝试概述了 TDD 流程涉及的深层次注意事项。经过长年的实践经验积累才能领悟这些观点，所以我非常没资格讨论这些事情。我衷心鼓励你怀疑我所说的一切，尝试不同的方案，听听其他人是怎么说的，找到适合自己的方法。

下面列出了一些扩展阅读资料。

扩展阅读

“Fast Test, Slow Test”和“Boundaries”

Gary Bernhardt 分别于 2012 年（“Fast Test, Slow Test”）和 2013 年（“Boundaries”）在 Pycon 中所做的演讲。他制作的视频（Destroy All Software）也值得一看。

端口和适配器

Steve Freeman 和 Nat Pryce 在他们合著的书中提出这种架构。Steve Freeman 一场名为“Test-Driven Development”的演讲对此也做了很好的讨论。还可以阅读 Uncle Bob 对简洁架构的说明（“The Clean Architecture”），以及 Alistair Cockburn 提出六边形架构的文章（“Hexagonal Architecture”）。

炽热的岩浆

Casey Kinsey 提醒，尽量避免和数据库交互（演讲“Writing Fast and Efficient Unit Tests for Django”）。

翻转金字塔

如果项目中运行速度慢的高层测试和单元测试的比值太大，可以使用这种形象的比喻努力翻转比值。

整合测试是个骗局

J.B. Rainsberger 写过一篇著名的文章（“Integrated Tests Are A Scam”），痛斥整合测试，声称它会毁了你的生活。还可以阅读几篇后续文章，尤其是这篇防范验收测试（我叫它功能测试）的文章（“Using Integration Tests Mindfully: A Case Study”），以及这篇分析速度慢的测试是如何扼杀效率的文章（“Part 2: Some Hidden Costs of Integration Tests”）。

Test-Double 测试维基

Justin Searls 的在线资源（https://github.com/testdouble/contributing-tests/wiki/Test-Driven-Development）对相关概念做出了准确的定义，还讨论了测试的优缺点，而且总结了各项操作的正确做法。

务实的角度

Martin Fowler（《重构》的作者）提出一种合理平衡的务实方案（http://martinfowler.com/bliki/UnitTest.html）。

在不同的测试类型之间正确权衡

务实为本

花费大量时间纠结编写何种测试往往得不偿失。最好跟着感觉走，先编写下意识觉得应该编写的测试，然后再根据需要修改。在实践中学习。

关注想从测试中得到什么

我们的目标是正确性、好的设计和快速的反馈循环。不同类型的测试以不同的方式达到这些目标。表 26-1 列出了一些自问的好问题。

架构很重要

架构在某种程度上决定了所需的测试类型。业务逻辑与外部依赖隔离得越好，代码的模块化程度越高，在单元测试、集成测试和端到端测试之间便能达到越好的平衡。

遵从测试山羊的教诲

回过头再看测试山羊。

你可能会说：“唉，哈利，大概 17 章之前，测试山羊就没那么有趣了。”请容许我唠叨几句，我要用测试山羊表达一些重要的观点。

测试很难

当我看到“遵从测试山羊的教诲”这句话时，第一印象是它道出了一个事实：测试很难——不是说测试本身难，而是难在坚持，一直做下去。

走捷径少写几个测试感觉更容易。而且心理上更难接受测试，因为付出的努力和得到的回报太不成正比。现在花时间编写的测试不会立即显出功效，要等到很久以后才有作用——或许几个月之后避免在重构过程中引入问题，或者升级依赖时捕获回归。或许测试会以一种很难衡量的方式回报你，促使你写出设计更好的代码，但你却认为就算没有测试也能写出如此优雅的代码。

为本书编写测试框架时，（http://github.com/hjwp/Book-TDD-Dev-Python/tree/master/tests）我自己也开始犯这种错误了。书中的代码很复杂，所以本身也有测试，但我偷懒了，测试覆盖度并不理想，现在我后悔了，因为测试写得笨拙又丑陋（好了，我已经开源这个测试框架，指责、嘲笑我吧）。

让CI构建始终能通过

需要真正付出心力的另一个领域是持续集成。读过第 24 章我们知道，CI 构建有时会出现意料之外的奇怪问题。出现这种问题时，如果觉得在自己的设备中正常就行，很容易放任不管。但是，如果不小心，你会开始容忍 CI 中有失败的测试组件，久而久之，CI 构建便失去了意义。若想再次让 CI 构建运行起来，工作量更大。千万别落入这个圈套。只要坚持，终究会找到测试失败的原因，而且能找到解决的方法，再次让构建通过，发挥决断作用。

像重视代码一样重视测试

别再把测试看成真正的代码的陪衬，把它当作你在开发的产品的一部分，精心雕琢，注重美观，就算发布出去也不会羞于面对众人检视。这么想有助于你接受测试。

做测试的原因有很多：可能是测试山羊告诉你要做；可能是你知道就算不能立即得到回报，但终究会得到；可能是出于责任感、职业素养、强迫症或者想挑战自己；还可能是因为测试值得实践。但终极原因是，测试让软件开发变得更有乐趣。

别忘了给吧台服务员小费

没有 O'Reilly Media 出版社的支持，我不可能写成这本书。如果你看的是在线免费版，希望你能考虑买一本。如果你自己不需要，或许可以作为礼物送给朋友。

别见外

希望你喜欢这本书。请一定要和我联系，告诉我你的想法！

@hjwp（Ttwitter）

obeythetestinggoat@gmail.com

附录 A　PythonAnywhere

本书假设你在自己的电脑上运行 Python 和编程。当然，这不是如今做 Python 编程的唯一方式，你也可以使用在线平台，例如 PythonAnywhere（碰巧，我就在这工作）。

在阅读本书的过程中，你可以使用 PythonAnywhere，但是要做些调整和修改：测试时要设置一个 Web 应用而不是用测试服务器；要使用 Xvfb 运行功能测试；而且读到部署那几章时，要升级到付费账户。虽然可以这么做，但还是使用自己的电脑更方便。

倘若你确实想试一下，可以参照下文所述去做。

如果你还没有 PythonAnywhere 的账户，先要注册一个，免费的就行。

然后，在控制台页面启动一个 Bash Console。大多数工作都将在这个控制台中完成。

A.1　使用Xvfb在Firefox中运行Selenium会话

首先要知道，PythonAnywhere 是只有终端的环境，所以没有显示器就无法打开 Firefox。但我们可以使用虚拟显示器。

读第 1 章编写第一个测试时，你会发现无法正常运行。第一个测试如下所示，可以在 PythonAnywhere 提供的编辑器中输入：

from selenium import webdriver
browser = webdriver.Firefox()
browser.get('http://localhost:8000')
assert 'Django' in browser.title

但（在 Bash 终端）运行时会看到如下错误：

(superlists)$ python functional_tests.py
Traceback (most recent call last):
File "tests.py", line 3, in ^lt;module>
browser = webdriver.Firefox()
[...]
selenium.common.exceptions.WebDriverException: Message: 'geckodriver' executable
needs to be in PATH.

这是因为 PythonAnywhere 所用的 Firefox 是旧版本，不需要 Geckodriver。但是，我们要把 Selenium 3 换成 Selenium 2：

(superlists) $ pip install "selenium^lt;3"
Collecting selenium^lt;3
Installing collected packages: selenium
  Found existing installation: selenium 3.4.3
    Uninstalling selenium-3.4.3:
      Successfully uninstalled selenium-3.4.3
Successfully installed selenium-2.53.6

现在又会遇到一个问题：

(superlists)$ python functional_tests.py
Traceback (most recent call last):
File "tests.py", line 3, in ^lt;module>
browser = webdriver.Firefox()
[...]
selenium.common.exceptions.WebDriverException: Message: The browser appears to
have exited before we could connect. If you specified a log_file in the
FirefoxBinary constructor, check it for details.

Firefox 无法启动，因为没有运行所需的显示器，毕竟 PythonAnywhere 是服务器环境。解决方法是使用 Xvfb（X Virtual Framebuffer 的简称）。在没有真正的显示器的服务器中，Xvfb 会启动一个“虚拟”显示器，供 Firefox 使用。

xvfb-run 命令的作用是，在 Xvfb 中执行下一个命令。使用这个命令就会看到预期失败：

(superlists)$ xvfb-run -a python functional_tests.py
Traceback (most recent call last):
File "tests.py", line 11, in ^lt;module>
assert 'Django' in browser.title
AssertionError

记住，只要想运行功能测试，就要使用 xvfb-run -a 命令。

A.2　以PythonAnywhere Web应用的方式安装Django

随后要使用 django-admin.py startproject 命令创建 Django 项目。但是，不要使用 manage.py runserver 启动本地开发服务器，我们将把网站设置为真正的 PythonAnywhere Web 应用。

打开“Web”标签页，点击按钮添加一个新 Web 应用。选择“Manual configuration”（手动配置），然后再选“Python 3.4”。

在下一个界面中输入虚拟环境的名称（“superlists”），提交后，会自动补全为 /home/yourusername/.virtualenvs/superlists。

最后，找到编辑 wsgi 文件的链接，找出针对 Django 的那一部分，去掉注释。点击“Save”，再点击“Reload”，刷新 Web 应用。

从现在开始，不要在控制台中运行 localhost:8000 上的测试服务器，你可以使用 PythonAnywhere 为 Web 应用分配的真实 URL 了：

    browser.get('http://my-username.pythonanywhere.com')

　每次修改代码后都要点击“Reload Web App”（重新加载 Web 应用）按钮，更新网站。

效果更好！ 1 在第 7 章换用 LiveServerTestCase 和 self.live_server_url 之前，在 PythonAnywhere 中必须这样指向功能测试，而且每次运行功能测试之前都要点击“Reload”。

1也可以在终端里运行开发服务器，但有个问题，PythonAnywhere 的终端不一定运行在同一台服务器中，所以无法保证运行测试的终端和运行服务器的终端是同一个。而且，如果在终端里运行服务器，没有简单的方法视觉检查网站的外观。

A.3　清理/tmp目录

Selenium 和 Xvfb 会在 /tmp 目录中留下很多垃圾，如果关闭的方式不优雅，情况会更糟（所以前文才要使用 try/finally 语句）。

遗留的东西太多，可能会用完存储配额。所以要经常清理 /tmp 目录：

$ rm -rf /tmp/*

A.4　截图

在第 5 章中，我建议使用 time.sleep 在功能测试运行的过程中暂停一会儿，这样才能在屏幕上看到 Selenium 浏览器。在 PythonAnywhere 做不到这一点，因为浏览器运行在虚拟显示器中。不过你可以检查线上网站，或者别管应该看到什么，相信我说的就行了。

对运行在虚拟显示器中的测试做视觉检查，最好的方法是使用截图。如果你想知道怎么做，看一下第 24 章，那里有一些示例代码。

A.5　关于部署

读到第 9 章时，你可以选择继续使用 PythonAnywhere，也可以选择学习如何配置真实的服务器。我建议选择后者，因为这样能学到更多。

如果想一直使用 PythonAnywhere，可以再注册一个 PythonAnywhere 账户，用作过渡网站（作弊嫌疑很大）。或者，为现有账户再添加一个域名。但部署那一章的内容就用不到了（在 PythonAnywhere 上无须 Nginx、Gunicorn 或域套接字）。

遇到下列情形之一时，你需要一个付费账户。

如果过渡网站不使用 PythonAnywhere 提供的域名。

如果不想在 PythonAnywhere 提供的域名上运行功能测试（因为别的域名不在白名单上）。

读到第 11 章，如果想使用 PythonAnywhere 账户运行 Fabric（因为需要 SSH）。

如果你想“作弊”，可以在现有的 Web 应用中以“过渡”模式运行功能测试，并跳过涉及 Fabric 的部分——这算是一种妥协吧。其实，你可以先升级账户，然后立即取消，在 30 天保障期内申请退款。

　如果阅读本书时你从头至尾都使用 PythonAnywhere，我很想听听你是怎么做到的。请给我发电子邮件，地址是 obeythetestinggoat@gmail.com。

附录 B　基于类的Django视图

本附录接续第 15 章。第 15 章实现了 Django 表单的验证功能，还重构了视图。结束时，视图仍然使用函数实现。

不过，Django 领域现在流行使用基于类的视图（Class-Based View，CBV）。在这个附录中，我们要重构应用，把视图函数改写成基于类的视图。更确切地说，我们要尝试使用基于类的通用视图（Class-Based Generic View，CBGV）。

B.1　基于类的通用视图

基于类的视图和基于类的通用视图有个区别。基于类的视图（class-based view，CBV）只是定义视图函数的另一种方式，对视图要做的事情没有太多假设，和视图函数相比主要的优势是可以创建子类。不过也要付出一定代价，基于类的视图比传统的基于函数的视图可读性差（这一点有争论）。普通的 CBV 的作用是让多个视图重用相同的逻辑，因为我们想遵守 DRY 原则。如果使用基于函数的视图，重用逻辑要使用辅助函数或修饰器。理论上，使用类实现更优雅。

基于类的通用视图也是一种基于类的视图，但它尝试为常见操作提供现成的解决方案，例如从数据库中获取对象后传入模板，获取一组对象，使用 ModelForm 保存 POST 请求中用户输入的数据，等等。看起来现在需要的就是这种视图，不过稍后就会发现魔鬼藏在细节中。

这里我想说，我并不常用任何一种基于类的视图。我完全能看到这种视图的合理之处，而且在 Django 应用中有很多地方都非常适合使用 CBGV。但是，只要需求稍微高一点儿，例如想使用多个模型，就会发现基于类的视图比传统的视图函数难读得多（这一点也有争论）。

不过，因为必须使用基于类的视图提供的几个定制选项，通过这种实现方式能学到很多这种视图的工作方式，以及如何为这种视图编写单元测试。

我希望为基于函数的视图编写的单元测试也能正常测试基于类的视图。看一下具体该怎么做。

B.2　使用FormView实现首页

网站的首页只是在模板中显示一个表单：

lists/views.py

def home_page(request):
    return render(request, 'home.html', {'form': ItemForm()})

看过可选视图（https://docs.djangoproject.com/en/1.11/ref/class-based-views/）之后，我们发现 Django 提供了一个通用视图，叫 FormView。看一下怎么用：

lists/views.py (ch31l001)

from django.views.generic import FormView
[...]

class HomePageView(FormView):
    template_name = 'home.html'
    form_class = ItemForm

指定想使用哪个模板和表单。然后，只需更新 urls.py，把含有 lists.views.home_page 那行代码改成：

superlists/urls.py (ch31l002)

[...]
urlpatterns = [
    url(r'^$', list_views.HomePageView.as_view(), name='home'),
    url(r'^lists/', include(list_urls)),
]

运行所有测试确认，这很简单：

$ python manage.py test lists
[...]

Ran 34 tests in 0.119s
OK

$ python manage.py test functional_tests
[...]
Ran 5 tests in 15.160s
OK

目前为止一切顺利。把一行代码的视图函数换成有两行代码的类，而且可读性依然不错。现在是提交的好时机。

B.3　使用form_valid定制CreateView

下面改写新建清单的视图，也就是 new_list 函数。现在这个视图如下所示：

lists/views.py

def new_list(request):
    form = ItemForm(data=request.POST)
    if form.is_valid():
        list_ = List.objects.create()
        form.save(for_list=list_)
        return redirect(list_)
    else:
        return render(request, 'home.html', {"form": form})

浏览可用的 CBGV 列表之后，发现需要的或许是 CreateView，而且知道要使用 ItemForm 类，下面看一下具体该怎么做，以及测试能否提供帮助：

lists/views.py (ch31l003)

from django.views.generic import FormView, CreateView
[...]

class NewListView(CreateView):
    form_class = ItemForm

def new_list(request):
    [...]

我要在 views.py 中保留原来的视图函数，这样才能从中复制代码，等一切都能正常运行之后再删除。这么做没什么危害，只要修改 URL 映射就行。这一次要这么改：

lists/urls.py (ch31l004)

[...]
urlpatterns = [
    url(r'^new$', views.NewListView.as_view(), name='new_list'),
    url(r'^(\d+)/$', views.view_list, name='view_list'),
]

然后运行测试。有 6 个错误：

$ python manage.py test lists
[...]

ERROR: test_can_save_a_POST_request (lists.tests.test_views.NewListTest)
TypeError: save() missing 1 required positional argument: 'for_list'

ERROR: test_for_invalid_input_passes_form_to_template
(lists.tests.test_views.NewListTest)
django.core.exceptions.ImproperlyConfigured: TemplateResponseMixin requires
either a definition of 'template_name' or an implementation of
'get_template_names()'

ERROR: test_for_invalid_input_renders_home_template
(lists.tests.test_views.NewListTest)
django.core.exceptions.ImproperlyConfigured: TemplateResponseMixin requires
either a definition of 'template_name' or an implementation of
'get_template_names()'

ERROR: test_invalid_list_items_arent_saved (lists.tests.test_views.NewListTest)
django.core.exceptions.ImproperlyConfigured: TemplateResponseMixin requires
either a definition of 'template_name' or an implementation of
'get_template_names()'

ERROR: test_redirects_after_POST (lists.tests.test_views.NewListTest)
TypeError: save() missing 1 required positional argument: 'for_list'

ERROR: test_validation_errors_are_shown_on_home_page
(lists.tests.test_views.NewListTest)
django.core.exceptions.ImproperlyConfigured: TemplateResponseMixin requires
either a definition of 'template_name' or an implementation of
'get_template_names()'

FAILED (errors=6)

先解决前 3 个——设定模板应该就可以了吧？

lists/views.py (ch31l005)

class NewListView(CreateView):
    form_class = ItemForm
    template_name = 'home.html'

现在只剩两个错误了，可以看出，这两个错误都发生在通用视图的 form_valid 方法中。这个方法可以重新定义来定制 CBGV 的行为。从这个方法的名字可以看出，视图认为表单中的数据有效之后才会运行这个方法。可以从以前的视图函数中把 if form.is_valid(): 之后的代码复制过来：

lists/views.py (ch31l006)

class NewListView(CreateView):
    template_name = 'home.html'
    form_class = ItemForm

    def form_valid(self, form):
        list_ = List.objects.create()
        form.save(for_list=list_)
        return redirect(list_)

这样测试就能全部通过了：

$ python manage.py test lists
Ran 34 tests in 0.119s
OK
$ python manage.py test functional_tests
Ran 5 tests in 15.157s
OK

而且，为了遵守 DRY 原则，可以使用 CBV 的主要优势之一——继承，节省两行代码：

lists/views.py (ch31l007)

class NewListView(CreateView, HomePageView):

    def form_valid(self, form):
        list_ = List.objects.create()
        form.save(for_list=list_)
        return redirect(list_)

测试应该仍能全部通过：

OK

　其实在面向对象编程中这么做并不好。继承意味着“是一个什么”这种关系，但是说新建清单视图“是一个”首页视图或许没有什么意义。所以，或许最好别这么做。

不管做没做最后一步，你觉得和以前的版本相比怎么样？我觉得还不错。不用写样板代码了，而且视图代码还相当易读。目前，CBGV 得了一分，和基于函数的视图平局。

B.4　一个更复杂的视图，既能查看清单，也能向清单中添加待办事项

我做了好多尝试才写出这个视图。不得不说，虽然测试能告诉我做得对不对，但是在寻找实现步骤的过程中并不能给出实质帮助，大多数情况下我都在反复实验，尝试使用 get_context_data 和 get_form_kwargs 等函数。

不过，在实现的过程中我意识到一件事：编写多个只测试一件事的测试很重要。所以又回头重写了第 10~12 章的部分内容。

B.4.1　测试有指引作用，但时间不长

下面是一种实现方式。首先，我觉得需要使用 DetailView，显示对象的详情：

lists/views.py (ch31l009)

from django.views.generic import FormView, CreateView, DetailView
[...]

class ViewAndAddToList(DetailView):
    model = List

然后在 urls.py 中设置：

lists/urls.py (ch31l010)

    url(r'^(\d+)/$', views.ViewAndAddToList.as_view(), name='view_list'),

测试的结果为：

[...]
AttributeError: Generic detail view ViewAndAddToList must be called with either
an object pk or a slug.

FAILED (failures=5, errors=6)

失败消息的意思并不明确，在谷歌中搜索一番之后我才知道要使用正则表达式具名捕获组：

lists/urls.py (ch31l011)

@@ -3,6 +3,6 @@ from lists import views

 urlpatterns = [
     url(r'^new$', views.NewListView.as_view(), name='new_list'),
-    url(r'^(\d+)/$', views.view_list, name='view_list'),
+    url(r'^(?P<pk>\d+)/$', views.ViewAndAddToList.as_view(), name='view_list')
 ]

接下来出现的错误中有一个相当有帮助：

[...]
django.template.exceptions.TemplateDoesNotExist: lists/list_detail.html

FAILED (failures=5, errors=6)

这个很容易解决：

lists/views.py (ch31l012)

class ViewAndAddToList(DetailView):
    model = List
    template_name = 'list.html'

这次改动后，只剩 5 个失败和 2 个错误了：

[...]
ERROR: test_displays_item_form (lists.tests.test_views.ListViewTest)
KeyError: 'form'

FAILED (failures=5, errors=2)

B.4.2　现在不得不反复实验

我发现这个视图不仅要显示对象的详情，还要能新建对象。所以这个视图要继承 DetailView 和 CreateView，可能还要添加 form-class 属性：

lists/views.py (ch31l013)

class ViewAndAddToList(DetailView, CreateView):
    model = List
    template_name = 'list.html'
    form_class = ExistingListItemForm

但是这么改，得到了很多错误：

[...]
TypeError: __init__() missing 1 required positional argument: 'for_list'

而且那个 KeyError: 'form' 错误还在。

现在，错误消息没什么用了，而且下一步该做什么也不明确。我不得不反复实验。不过，测试仍能告诉我做得对还是把情况变得更糟了。

首先尝试使用 get_form_kwargs，但没什么用，不过我发现可以使用 get_form：

lists/views.py (ch31l014)

    def get_form(self):
        self.object = self.get_object()
        return self.form_class(for_list=self.object, data=self.request.POST)

但必须给 self.object 赋值才能使用 get_form，对此我一直心里不安。不过现在只剩 3 个错误了，显然是因为表单还没传入模板。

django.core.exceptions.ImproperlyConfigured: No URL to redirect to.  Either
provide a url or define a get_absolute_url method on the Model.

B.4.3　测试再次发挥作用

对最后的这个失败，测试又变有用了。解决的方法很简单，在 Item 类中定义 get_absolute_url 方法，让待办事项指向所属的清单页面即可：

lists/models.py (ch31l015)

class Item(models.Model):
    [...]

    def get_absolute_url(self):
        return reverse('view_list', args=[self.list.id])

B.4.4　这是最终结果吗

最终写出的视图类如下所示：

lists/views.py

class ViewAndAddToList(DetailView, CreateView):
    model = List
    template_name = 'list.html'
    form_class = ExistingListItemForm

    def get_form(self):
        self.object = self.get_object()
        return self.form_class(for_list=self.object, data=self.request.POST)

B.5　新旧版对比

比较一下旧版和新版：

lists/views.py

def view_list(request, list_id):
    list_ = List.objects.get(id=list_id)
    form = ExistingListItemForm(for_list=list_)
    if request.method == 'POST':
        form = ExistingListItemForm(for_list=list_, data=request.POST)
        if form.is_valid():
            form.save()
            return redirect(list_)
    return render(request, 'list.html', {'list': list_, "form": form})

虽然新版代码从 9 行缩减到 7 行，但我还是觉得基于函数的视图稍微容易理解一点儿，因为旧版没隐藏那么多细节，毕竟“明确表述比含糊其辞强”，这是 Python 的禅理。我的意思是，谁知道 SingleObjectMixin 是什么呢？而且更讨厌的是，如果在 get_form 方法中不给 self.object 赋值，整个类都不能用。这一点太烦人。

不过，我猜有些人喜欢这种实现方式。

B.6　为CBGV编写单元测试有最佳实践吗

实现这个视图类之后，我发现有些单元测试有点儿太关注高层。这并不意外，因为使用 Django 测试客户端的视图测试或许更应该叫整合测试。

测试会告诉我做得对不对，但不能始终提示具体应该怎么修正错误。

有时，我想知道有没有一种编写测试的方法，更贴近实现方式，例如这么编写测试：

lists/tests/test_views.py

def test_cbv_gets_correct_object(self):
    our_list = List.objects.create()
    view = ViewAndAddToList()
    view.kwargs = dict(pk=our_list.id)
    self.assertEqual(view.get_object(), our_list)

但这么做有个问题，必须对 Django CBV 的内部机理有一定了解，才能正确设定这种测试。而且最后还是会被复杂的继承体系弄得十分糊涂。

B.7　记住：编写多个只有一个断言的隔离视图测试有所帮助

在这个附录中我得出一个结论：编写多个简短的单元测试比编写少量含有很多断言的测试有用得多。

看看这个庞大的测试：

lists/tests/test_views.py

def test_validation_errors_sent_back_to_home_page_template(self):
    response = self.client.post('/lists/new', data={'text': ''})
    self.assertEqual(List.objects.all().count(), 0)
    self.assertEqual(Item.objects.all().count(), 0)
    self.assertTemplateUsed(response, 'home.html')
    expected_error = escape("You can't have an empty list item")
    self.assertContains(response, expected_error)

它肯定没有下面这 3 个单独的测试有用：

lists/tests/test_views.py

    def test_invalid_input_means_nothing_saved_to_db(self):
        self.post_invalid_input()
        self.assertEqual(List.objects.all().count(), 0)
        self.assertEqual(Item.objects.all().count(), 0)

    def test_invalid_input_renders_list_template(self):
        response = self.post_invalid_input()
        self.assertTemplateUsed(response, 'list.html')

    def test_invalid_input_renders_form_with_errors(self):
        response = self.post_invalid_input()
        self.assertIsinstance(response.context['form'], ExistingListItemForm)
        self.assertContains(response, escape(empty_list_error))

因为对前一种方式来说，如果靠前的断言失败了，后面的断言就不会执行。所以，如果视图不小心把 POST 请求中的无效数据存入数据库，前面的断言会失败，这样就无法确认使用的模板是否正确以及有没有渲染表单。使用后一种方式则能更轻易地分辨出到底哪一部分能用，哪一部分不能用。

从 CBGV 中学到的经验

基于类的通用视图可以做任何事

虽然不一定总是知道到底怎么回事，但使用基于类的视图几乎可以做任何事。

只有一个断言的单元测试有助于重构

有单元测试检查什么可用什么不可用，使用不同的基本范式修改视图的实现方式就容易多了。

附录 C　使用Ansible配置服务器

用 Fabric 自动把新版源码部署到服务器上，但配置新服务器的过程以及更新 Nginx 和 Gunicorn 配置文件的操作都还是手动完成。

这类操作越来越多地交给“配置管理”或“持续部署”工具完成。其中，Chef 和 Puppet 最受欢迎，而在 Python 领域则是 Salt 和 Ansible。

在这些工具中，Ansible 最容易上手，只需两个文件就可以使用：

pip2 install --user ansible # 可惜只能用Python 2

清单文件 deploy_tools/inventory.ansible 定义可以在哪些服务器中运行：

deploy_tools/inventory.ansible

[live]
superlists.ottg.eu ansible_become=yes ansible_ssh_user=elspeth

[staging]
superlists-staging.ottg.eu ansible_become=yes ansible_ssh_user=elspeth

[local]
localhost ansible_ssh_user=root ansible_ssh_port=6666 ansible_host=127.0.0.1

（local 条目只是个示例，在我的设备中是个 Virtualbox 虚拟主机，并且为 22 和 80 端口设定了端口转发。）

C.1　安装系统包和Nginx

另一个文件是“脚本”（playbook），定义在服务器中做什么。这个文件的内容使用 YAML 句法编写：

deploy_tools/provision.ansible.yaml

---

- hosts: all

  vars:
      host: "{{ inventory_hostname }}"

  tasks:

    - name: Deadsnakes PPA to get Python 3.6
      apt_repository:
        repo='ppa:fkrull/deadsnakes'
    - name: make sure required packages are installed
      apt: pkg=nginx,git,python3.6,python3.6-venv state=present

    - name: allow long hostnames in nginx
      lineinfile:
        dest=/etc/nginx/nginx.conf
        regexp='(\s+)#? ?server_names_hash_bucket_size'
        backrefs=yes
        line='\1server_names_hash_bucket_size 64;'

    - name: add nginx config to sites-available
      template: src=./nginx.conf.j2 dest=/etc/nginx/sites-available/{{ host }}
      notify:
          - restart nginx

    - name: add symlink in nginx sites-enabled
      file:
          src=/etc/nginx/sites-available/{{ host }}
          dest=/etc/nginx/sites-enabled/{{ host }}
          state=link
      notify:
          - restart nginx

inventory_hostname 变量是目标服务器的域名。为了方便引用，我在 vars 部分把它重命名成了“host”。

在“tasks”部分，使用 apt 安装所需的软件，再使用正则表达式替换 Nginx 配置，允许使用长域名，然后使用模板创建 Nginx 配置文件。这个模板由第 9 章保存在 deploy_tools/nginx.template.conf 中的模板文件修改而来，不过现在指定使用一种模板引擎——Jinja2，和 Django 的模板句法很像：

deploy_tools/nginx.conf.j2

server {
    listen 80;
    server_name {{ host }};

    location /static {
        alias /home/{{ ansible_ssh_user }}/sites/{{ host }}/static;
    }

    location / {
        proxy_set_header Host {{ host }};
        proxy_pass http://unix:/tmp/{{ host }}.socket;
    }
}

C.2　配置Gunicorn，使用处理程序重启服务

脚本剩余的内容如下：

deploy_tools/provision.ansible.yaml

  - name: write gunicorn service script
    template:
        src=./gunicorn.service.j2
        dest=/etc/systemd/system/gunicorn-{{ host }}.service
    notify:
        - restart gunicorn

  handlers:
    - name: restart nginx
      service: name=nginx state=restarted

    - name: restart gunicorn
      systemd:
          name=gunicorn-{{ host }}
          daemon_reload=yes
          enabled=yes
          state=restarted

创建 Gunicorn 配置文件还要使用模板：

deploy_tools/gunicorn.service.j2

[Unit]
Description=Gunicorn server for {{ host }}

[Service]
User={{ ansible_ssh_user }}
WorkingDirectory=/home/{{ ansible_ssh_user }}/sites/{{ host }}/source
Restart=on-failure
ExecStart=/home/{{ ansible_ssh_user }}/sites/{{ host }}/virtualenv/bin/gunicorn \
    --bind unix:/tmp/{{ host }}.socket \
    --access-logfile ../access.log \
    --error-logfile ../error.log \
    superlists.wsgi:application

[Install]
WantedBy=multi-user.target

然后定义两个处理程序，重启 Nginx 和 Gunicorn。Ansible 很智能，如果多个步骤都调用同一个处理程序，它会等前一个执行完再调用下一个。

这样就行了！执行配置操作的命令如下：

ansible-playbook -i inventory.ansible provision.ansible.yaml --limit=staging --ask-become-pass

详细信息参阅 Ansible 的文档。

C.3　接下来做什么

我只是简单介绍了 Ansible 的功能。部署过程的自动化程度越高，你对部署也越自信。接下来，你可以完成下面几件事。

C.3.1　把Fabric执行的部署操作交给Ansible

已经看到 Ansible 可以帮助完成配置过程中的某些操作，其实它可以完成几乎所有部署操作。你可以试一下，看能否扩写脚本把当前 Fabric 部署脚本中的所有操作都交给 Ansible 完成，包括必要情况下重启时发出提醒。

C.3.2　使用Vagrant搭建本地虚拟主机

在过渡网站中运行测试能让我们相信网站上线后也能正常运行。不过也可以在本地设备中使用虚拟主机完成这项操作。

下载 Vagrant 和 Virtualbox，看你能否使用 Vagrant 在自己的电脑中搭建一个开发服务器，以及使用 Ansible 脚本把代码部署到这个服务器中。设置功能测试运行程序，让功能测试能在本地虚拟主机中运行。

如果在团队中工作，编写一个 Vagrant 配置脚本特别有用，因为它能帮助新加入的开发者搭建和你们使用的一模一样的服务器。

附录 D　测试数据库迁移

Django-migrations 及其前身 South 已经出现好多年了，所以一般没必要测试数据库迁移。但有时我们会使用一种危险的迁移，即引入新的数据完整性约束。我第一次在过渡环境中运行这种迁移脚本时，遇到了一个错误。

在大型项目中，如果有敏感数据，在生产数据中执行迁移之前，你可能想先在一个安全的环境中测试，增加一些自信。你可以在本书开发的示例应用中先练习一下。

测试迁移的另一个常见原因是测速——执行迁移时经常要把网站下线，而且如果数据集较大，用时并不短。所以最好提前知道迁移要执行多长时间。

D.1　尝试部署到过渡服务器

在第 17 章，当我第一次尝试部署新添加的验证约束条件时，遇到了如下问题：

$ cd deploy_tools
$ fab deploy:host=elspeth@superlists-staging.ottg.eu
[...]
Running migrations:
  Applying lists.0005_list_item_unique_together...Traceback (most recent call
last):
  File "/usr/local/lib/python3.6/dist-packages/django/db/backends/utils.py",
line 61, in execute
    return self.cursor.execute(sql, params)
  File
"/usr/local/lib/python3.6/dist-packages/django/db/backends/sqlite3/base.py",
line 475, in execute
    return Database.Cursor.execute(self, query, params)
sqlite3.IntegrityError: columns list_id, text are not unique
[...]

情况是这样，数据库中某些现有的数据违反了完整性约束条件，所以当我尝试应用约束条件时，数据库表达了不满。

为了处理这种问题，需要执行“数据迁移”。首先，要在本地搭建一个测试环境。

D.2　在本地执行一个用于测试的迁移

使用线上数据库的副本测试迁移。

　测试时使用真实数据一定要小心小心再小心。例如，数据中可能有客户的真实电子邮件地址，但你并不想不小心给他们发送一堆测试邮件。我可是栽过跟头的。

D.2.1　输入有问题的数据

在线上网站中新建一个清单，输入一些重复的待办事项，如图 D-1 所示。

图 D-1：一个清单，待办事项有重复

D.2.2　从线上网站中复制测试数据

从线上网站中复制数据库：

$ scp elspeth@superlists.ottg.eu:\
/home/elspeth/sites/superlists.ottg.eu/database/db.sqlite3 .
$ mv ../database/db.sqlite3 ../database/db.sqlite3.bak
$ mv db.sqlite3 ../database/db.sqlite3

D.2.3　确认的确有问题

现在，本地有一个还未执行迁移的数据库，而且数据库中有一些问题数据。如果尝试执行 migrate 命令，会看到一个错误：

$ python manage.py migrate --migrate
python manage.py migrate
Operations to perform:
[...]
Running migrations:
[...]
  Applying lists.0005_list_item_unique_together...Traceback (most recent call
last):
[...]
    return Database.Cursor.execute(self, query, params)
sqlite3.IntegrityError: columns list_id, text are not unique

D.3　插入一个数据迁移

数据迁移是一种特殊的迁移，目的是修改数据库中的数据，而不是变更模式。应用完整性约束之前，先要执行一次数据迁移，把重复数据删除。具体方法如下：

$ git rm lists/migrations/0005_list_item_unique_together.py
$ python manage.py makemigrations lists --empty
Migrations for 'lists':
  0005_auto_20140414_2325.py:
$ mv lists/migrations/0005_*.py lists/migrations/0005_remove_duplicates.py

有关数据迁移的详情，请参阅 Django 文档。下面是修改现有数据的方法：

lists/migrations/0005_remove_duplicates.py

# encoding: utf8
from django.db import models, migrations

def find_dupes(apps, schema_editor):
    List = apps.get_model("lists", "List")
    for list_ in List.objects.all():
        items = list_.item_set.all()
        texts = set()
        for ix, item in enumerate(items):
            if item.text in texts:
                item.text = '{} ({})'.format(item.text, ix)
                item.save()
            texts.add(item.text)

class Migration(migrations.Migration):
    dependencies = [
        ('lists', '0004_item_list'),
    ]

    operations = [
        migrations.RunPython(find_dupes),
    ]

重新创建以前的迁移

使用 makemigrations 重新创建以前的迁移，确保这是第 6 个迁移，而且还明确依赖于 0005，即那个数据迁移：

$ python manage.py makemigrations
Migrations for 'lists':
  0006_auto_20140415_0018.py:
    - Alter unique_together for item (1 constraints)
$ mv lists/migrations/0006_* lists/migrations/0006_unique_together.py

D.4　一起测试这两个迁移

现在可以在线上数据中测试了：

$ cd deploy_tools
$ fab deploy:host=elspeth@superlists-staging.ottg.eu
[...]

还要重启服务器中的 Gunicorn 服务：

elspeth@server:$ sudo systemctl restart gunicorn-superlists.ottg.eu

然后可以在过渡网站中运行功能测试：

$ STAGING_SERVER=superlists-staging.ottg.eu python manage.py test functional_tests
[...]
....
 ---------------------------------------------------------------------
Ran 4 tests in 17.308s

OK

看起来一切正常。现在部署到线上服务器：

$ fab deploy --host=superlists.ottg.eu
[superlists.ottg.eu] Executing task 'deploy'
[...]

最后，执行 git add lists/migrations、git commit 等命令。

D.5　小结

这个练习的主要目的是编写一个数据迁移，在一些真实的数据中测试。当然，这只是测试迁移的千万种方式之一。你还可以编写自动化测试，比较运行迁移前后数据库中的内容，确认数据还在。也可以为数据迁移中的辅助函数单独编写单元测试。你可以再多花点儿时间统计迁移所用的时间，然后实验多种提速的方法，例如，把迁移的步骤分得更细或更笼统。

记住，这种需求很少见。根据我的经验，我使用的迁移有 99% 都不需要测试。不过，在你的项目中可能需要。希望读过这个附录之后，你知道怎么着手测试迁移。

关于测试数据库迁移

小心引入约束的迁移

99% 的迁移都没问题，但是如果迁移为现有的列引入了新的约束条件，就像前面那个例子，一定要小心。

测试迁移的执行速度

一旦项目变大，你就应该考虑测试迁移所用的时间。执行数据库迁移时往往要下线网站，因为修改模式可能要锁定数据表（取决于使用的数据库种类），直到操作完成为止。所以最好在过渡网站中测试迁移要用多长时间。

使用生产数据的副本时要格外小心

为了测试迁移，要在过渡网站的数据库中填充与生产数据等量的数据。具体怎么做不在本书范畴之内，不过我要提醒一下：如果直接转储生产数据库导入过渡网站的数据库中，一定要十分小心，因为生产数据中包含真实客户的详细信息。有一次在过渡服务器中自动处理刚导入的生产数据副本时，我不小心发送了好几百张错误的发票。那个下午过得可不愉快。

附录 E　行为驱动开发

行为驱动开发（behaviour-driven development，BDD）我用得不多，算不上什么专家，但就目前我所接触的，我认为值得简要介绍一下。本附录将使用 BDD 工具转换一些“常规的”功能测试。

E.1　BDD是什么

严格来说，BDD 是一种方法论，而不是工具集。BDD 测试的是应用的行为，确定是否与我们期望用户看到的一致。因此，本书展示的部分基于 Selenium 的功能测试其实也可以称为 BDD。

但这种测试方法通常与 BDD 采用的特定工具集联系紧密，其中最重要的是 Gherkin 句法，这是编写功能测试（或验收测试）的 DSL，对人类而言可读性高。Gherkin 源自 Ruby 世界，与名为 Cucumber 的测试运行程序捆绑。

在 Python 世界，有几个等效的测试运行工具，例如 Lettuce 和 Behave。目前为止，只有 Behave 兼容 Python 3，因此我们将使用它。此外，还将使用一个插件 behave-django。

获取示例代码

我将使用第 22 章的示例。待办事项清单网站已经具备基本功能，我们想添加一个新功能：已登录用户应该能在某个地方查看自己编制的清单。在此之前，所有清单都是匿名创建的。

如果你一直跟着本书操作，我假设你能跳回到那一点。如果想从我的仓库中拉取，要使用 chapter_17 分支。

E.2　基本的准备工作

下面为 BDD 特性描述创建一个 features 目录，在里面再添加一个 steps 目录（马上告诉你它的作用），并为这个新功能创建一个占位文件：

$ mkdir -p features/steps
$ touch features/my_lists.feature
$ touch features/steps/my_lists.py
$ tree features
features
├── my_lists.feature
└── steps
    └── my_lists.py

然后安装 behave-django，并把它添加到 settings.py 中：

$ pip install behave-django

superlists/settings.py

--- a/superlists/settings.py
+++ b/superlists/settings.py
@@ -40,6 +40,7 @@ INSTALLED_APPS = [
     'lists',
     'accounts',
     'functional_tests',
+    'behave_django',
 ]

最后运行 python manage.py，做健全性检查：

$ python manage.py behave
Creating test database for alias 'default'...
0 features passed, 0 failed, 0 skipped
0 scenarios passed, 0 failed, 0 skipped
0 steps passed, 0 failed, 0 skipped, 0 undefined
Took 0m0.000s
Destroying test database for alias 'default'...

E.3　使用Gherkin句法以“特性描述”的形式编写功能测试

目前，我们的功能测试使用人类可读的注释描述新功能，这叫用户故事，其间穿插着执行故事中每一步的 Selenium 代码。

BDD 要求严格区分这二者：先使用 Gherkin 句法（有时让人觉得拗口）编写人类可读的故事，这叫“特性描述”（feature）；然后，把每一行 Gherkin 代码映射到一个函数上，那个函数包含实现那一“步”（step）所需的 Selenium 代码。

“My Lists”页面这个新功能的特性描述可以写成下面这样：

features/my_lists.feature

Feature: My Lists
    As a logged-in user
    I want to be able to see all my lists in one page
    So that I can find them all after I've written them

    Scenario: Create two lists and see them on the My Lists page

        Given I am a logged-in user

        When I create a list with first item "Reticulate Splines"
            And I add an item "Immanentize Eschaton"
            And I create a list with first item "Buy milk"

        Then I will see a link to "My lists"

        When I click the link to "My lists"
        Then I will see a link to "Reticulate Splines"
        And I will see a link to "Buy milk"

        When I click the link to "Reticulate Splines"
        Then I will be on the "Reticulate Splines" list page

E.3.1　As-a/I want to/So that

顶部是“As-a/I want to/So that”子句。这部分是可选的，没有对应的可执行代码。这只是一种形式，让用户知道“你是谁、你想做什么”，有助于团队成员理解每个功能的背景。

E.3.2　Given/When/Then

“Given/When/Then”是 BDD 测试的真正核心。这三部分与单元测试的“设置 - 使用 - 断言”模式匹配，分别表示设置 / 假设阶段、使用 / 行动阶段以及断言 / 观察阶段。详情参见 Cucumber 维基页面（https://github.com/cucumber/cucumber/wiki/Given-When-Then）。

E.3.3　并不始终完美契合

如你所见，用户故事不是总能完美分成这三步的。我们可以使用 And 子句扩充步骤，而且我还添加了多个 When 和随后的 Then 子句，进一步描绘“My Lists”页面。

E.4　编写步骤函数

下面编写 Gherkin 句法描述的特性对应的“步骤”函数，即真正使用代码实现。

生成占位步骤

运行 behave，它会告诉我们需要实现的每一步：

$ python manage.py behave
Feature: My Lists # features/my_lists.feature:1
  As a logged-in user
  I want to be able to see all my lists in one page
  So that I can find them all after I've written them
  Scenario: Create two lists and see them on the My Lists page #
features/my_lists.feature:6
    Given I am a logged-in user                                # None
    Given I am a logged-in user                                # None
    When I create a list with first item "Reticulate Splines"  # None
    And I add an item "Immanentize Eschaton"                   # None
    And I create a list with first item "Buy milk"             # None
    Then I will see a link to "My lists"                       # None
    When I click the link to "My lists"                        # None
    Then I will see a link to "Reticulate Splines"             # None
    And I will see a link to "Buy milk"                        # None
    When I click the link to "Reticulate Splines"              # None
    Then I will be on the "Reticulate Splines" list page       # None

Failing scenarios:
  features/my_lists.feature:6 Create two lists and see them on the My Lists
page

0 features passed, 1 failed, 0 skipped
0 scenarios passed, 1 failed, 0 skipped
0 steps passed, 0 failed, 0 skipped, 10 undefined
Took 0m0.000s

You can implement step definitions for undefined steps with these snippets:

@given(u'I am a logged-in user')
def step_impl(context):
    raise NotImplementedError(u'STEP: Given I am a logged-in user')

@when(u'I create a list with first item "Reticulate Splines"')
def step_impl(context):
[...]

而且你会发现，输出有不同的颜色，如图 E-1 所示。

图 E-1：Behave 在控制台输出带有不同颜色的内容

Behave 建议我们复制粘贴这些片段，在此基础上构建步骤。

E.5　定义第一步

下面尝试实现“Given I am a logged-in user”这一步。我直接从 functional_tests/test_my_lists.py 中复制 self.create_pre_authenticated_session 的代码，然后稍做调整（例如删掉服务器端版本，不过后面再加上也容易）。

features/steps/my_lists.py

from behave import given, when, then
from functional_tests.management.commands.create_session import \
    create_pre_authenticated_session
from django.conf import settings

@given('I am a logged-in user')
def given_i_am_logged_in(context):
    session_key = create_pre_authenticated_session(email='edith@example.com')
    ## 为了设定cookie，我们要先访问网站
    ## 而404页面是加载最快的
    context.browser.get(context.get_url("/404_no_such_url/"))
    context.browser.add_cookie(dict(
        name=settings.SESSION_COOKIE_NAME,
        value=session_key,
        path='/',
    ))

context 变量需要稍做说明：它有点像是全局变量，因为我们将通过它存储在步骤之间共享的信息，把它传给要执行的每一步。这里，我们假定它存储了一个浏览器对象和 server_url。我们将多次使用这个变量，就像在使用 unittest 编写功能测试时经常使用 self 一样。

E.6　environment.py中与setUp和tearDown等价的函数

各步可以修改 context 中的状态，不过前期准备工作，即与 setUp 等价的操作，在 environment.py 文件中设置：

features/environment.py

from selenium import webdriver

def before_all(context):
    context.browser = webdriver.Firefox()

def after_all(context):
    context.browser.quit()

def before_feature(context, feature):
    pass

E.7　再次运行

可以再运行一次，做健全性检查，确认新编写的步骤是否可行，以及能否启动浏览器：

$ python manage.py behave
[...]
1 step passed, 0 failed, 0 skipped, 9 undefined

输出的内容很多，不过可以看到，第一步通过了。下面定义余下的步骤。

E.8　在步骤中捕获参数

我们将说明如何在步骤描述中捕获参数。接下来的一步是：

features/my_lists.feature

    When I create a list with first item "Reticulate Splines"

自动生成的步骤定义如下：

features/steps/my_lists.py

@given('I create a list with first item "Reticulate Splines"')
def step_impl(context):
    raise NotImplementedError(
        u'STEP: When I create a list with first item "Reticulate Splines"'
    )

我们希望以任意的第一个待办事项创建清单。所以，如果能通过某种方式捕获双引号中的内容就好了，这样就可以把它作为参数传给更为通用的函数。这是 BDD 的一个常见需求，Behave 为此提供了优雅的句法。还记得 Python 为字符串格式化提供的新句法吗？

features/steps/my_lists.py (ch35l006)

[...]

@when('I create a list with first item "{first_item_text}"')
def create_a_list(context, first_item_text):
    context.browser.get(context.get_url('/'))
    context.browser.find_element_by_id('id_text').send_keys(first_item_text)
    context.browser.find_element_by_id('id_text').send_keys(Keys.ENTER)
    wait_for_list_item(context, first_item_text)

很棒吧？

　在步骤中捕获参数是 BDD 句法最为强大的功能之一。

与在 Selenium 测试中一样，我们要显式等待。依旧使用 base.py 中的 @wait 装饰器：

features/steps/my_lists.py (ch35l007)

from functional_tests.base import wait
[...]

@wait
def wait_for_list_item(context, item_text):
    context.test.assertIn(
        item_text,
        context.browser.find_element_by_css_selector('#id_list_table').text
    )

与之类似，我们也可以把待办事项添加到现有清单中，查看或点击链接：

features/steps/my_lists.py (ch35l008)

from selenium.webdriver.common.keys import Keys
[...]

@when('I add an item "{item_text}"')
def add_an_item(context, item_text):
    context.browser.find_element_by_id('id_text').send_keys(item_text)
    context.browser.find_element_by_id('id_text').send_keys(Keys.ENTER)
    wait_for_list_item(context, item_text)

@then('I will see a link to "{link_text}"')
@wait
def see_a_link(context, link_text):
    context.browser.find_element_by_link_text(link_text)

@when('I click the link to "{link_text}"')
def click_link(context, link_text):
    context.browser.find_element_by_link_text(link_text).click()

注意，我们甚至可以在步骤上使用 @wait 装饰器。

最后是稍微复杂一些的步骤，描述自己在某个清单的页面上：

features/steps/my_lists.py (ch35l009)

@then('I will be on the "{first_item_text}" list page')
@wait
def on_list_page(context, first_item_text):
    first_row = context.browser.find_element_by_css_selector(
        '#id_list_table tr:first-child'
    )
    expected_row_text = '1: ' + first_item_text
    context.test.assertEqual(first_row.text, expected_row_text)

现在运行，得到第一个预期失败：

$ python manage.py behave

Feature: My Lists # features/my_lists.feature:1
  As a logged-in user
  I want to be able to see all my lists in one page
  So that I can find them all after I've written them
  Scenario: Create two lists and see them on the My Lists page #
features/my_lists.feature:6
    Given I am a logged-in user                                #
features/steps/my_lists.py:19
    When I create a list with first item "Reticulate Splines"  #
features/steps/my_lists.py:31
    And I add an item "Immanentize Eschaton"                   #
features/steps/my_lists.py:39
    And I create a list with first item "Buy milk"             #
features/steps/my_lists.py:31
    Then I will see a link to "My lists"                       #
functional_tests/base.py:12
      Traceback (most recent call last):
[...]
        File "features/steps/my_lists.py", line 49, in see_a_link
          context.browser.find_element_by_link_text(link_text)
[...]
      selenium.common.exceptions.NoSuchElementException: Message: Unable to
locate element: My lists

[...]

Failing scenarios:
  features/my_lists.feature:6  Create two lists and see them on the My Lists
page

0 features passed, 1 failed, 0 skipped
0 scenarios passed, 1 failed, 0 skipped
4 steps passed, 1 failed, 5 skipped, 0 undefined

从输出可以看出，我们在“用户故事”上走了多远：我们成功创建了两个清单，但是“My Lists”链接未出现。

E.9　与行间式功能测试比较

我不会完整实现整个功能，不过你可以看出，这与行间式功能测试一样，能驱动我们向前开发。

下面回顾一下行间测试，比较一下：

functional_tests/test_my_lists.py

def test_logged_in_users_lists_are_saved_as_my_lists(self):
    # 伊迪丝是已登录用户
    self.create_pre_authenticated_session('edith@example.com')

    # 她访问首页，新建一个清单
    self.browser.get(self.live_server_url)
    self.add_list_item('Reticulate splines')
    self.add_list_item('Immanentize eschaton')
    first_list_url = self.browser.current_url

    # 她第一次看到“My Lists”链接
    self.browser.find_element_by_link_text('My lists').click()

    # 她看到这个页面中有她创建的清单
    # 而且清单根据第一个待办事项命名
    self.wait_for(
        lambda: self.browser.find_element_by_link_text('Reticulate splines')
    )
    self.browser.find_element_by_link_text('Reticulate splines').click()
    self.wait_for(
        lambda: self.assertEqual(self.browser.current_url, first_list_url)
    )

    # 她决定再建一个清单试试
    self.browser.get(self.live_server_url)
    self.add_list_item('Click cows')
    second_list_url = self.browser.current_url

    # 这个新建的清单也在“My Lists”页面显示出来了
    self.browser.find_element_by_link_text('My lists').click()
    self.wait_for(
        lambda: self.browser.find_element_by_link_text('Click cows')
    )
    self.browser.find_element_by_link_text('Click cows').click()
    self.wait_for(
        lambda: self.assertEqual(self.browser.current_url, second_list_url)
    )

    # 她退出后，“My Lists”链接不见了
    self.browser.find_element_by_link_text('Log out').click()
    self.wait_for(lambda: self.assertEqual(
        self.browser.find_elements_by_link_text('My lists'),
        []
    ))

虽然不能一一对应比较，但是可以看看代码行数，见表 E-1。

表E-1：比较代码行数

BDD

标准的功能测试

特性描述文件：20（3 个可选）

测试函数的主体：45

步骤文件：56 行

辅助函数：23

这样比较并不严谨，但是可以认为特性描述文件和“标准的功能测试”的测试函数主体是等价的，都表示测试的主体“故事”，而步骤定义和辅助函数表示“隐藏的”实现细节。如果把行数加在一起，总行数相差不多，但是二者的结果不一样：BDD 测试写出的故事更简洁，而且更多内容隐藏到实现细节中了。

E.10　BDD得到的测试代码结构更好

对我而言，真正吸引人的是，BDD 工具迫使我们思考测试代码的结构。在行间式功能测试中，实现需要多少行代码就可以写多少行，用户故事是通过注释描述的。我们很难控制自己不从别处或同一个测试的前面复制粘贴代码。到目前为止，你可以看到，我只定义了几个辅助函数（例如 get_item_input_box）。

与之相比，BDD 句法则强制我们为每一步编写单独的函数，因此很多代码都是可以重用的。

新建清单。

把待办事项添加到现有清单中。

点击特定文本的链接。

断言我在查看某个清单的页面。

通过 BDD 写出的代码与业务逻辑匹配得更好，而且能分层抽象，把功能测试的故事与实现的代码分开。

这样做最终的好处是，如果想更换编程语言，理论上可以原样保留 Gherkin 句法编写的特性描述，丢掉 Python 代码实现的步骤，再用新语言重新编写。

E.11　与页面模式比较

第 25 章举了一个使用“页面模式”的示例。页面模式是组织 Selenium 测试的面向对象方式。下面回顾一下它的用法：

functional_tests/test_sharing.py

from .lists_page import ListsPage
[...]

class SharingTest(FunctionalTest):

    def test_can_share_a_list_with_another_user(self):
        # [...]
        self.browser.get(self.live_server_url)
        list_page = ListPage(self).add_list_item('Get help')

        # 她看到“分享这个清单”选项
        share_box = list_page.get_share_box()
        self.assertEqual(
            share_box.get_attribute('placeholder'),
            'your-friend@example.com'
        )

        # 她分享自己的清单之后，页面更新了
        # 提示已经分享给Oniciferous
        list_page.share_list_with('oniciferous@example.com')

Page 类的定义如下：

functional_tests/lists_pages.py

class ListPage(object):

    def __init__(self, test):
        self.test = test

    def get_table_rows(self):
        return self.test.browser.find_elements_by_css_selector('#id_list_table tr')

    @wait
    def wait_for_row_in_list_table(self, item_text, item_number):
        row_text = '{}: {}'.format(item_number, item_text)
        rows = self.get_table_rows()
        self.test.assertIn(row_text, [row.text for row in rows])

    def get_item_input_box(self):
        return self.test.browser.find_element_by_id('id_text')

可以看出，不管是使用页面模式还是其他结构，完全可以在行间式功能测试中做同样的抽象，实现某种 DSL。但是，我们应该自律，而不能靠框架去约束。

　其实在 BDD 中也可以使用页面模式，在实现步骤时通过它浏览网站中的页面。

E.12　BDD可能没有行间注释的表达力强

另一方面，我觉得 Gherkin 句法有点不够灵活。行间式注释表达力强，而且可读性高，但 BDD 的特性描述有点拗口：

functional_tests/test_my_lists.py

    # 伊迪丝是已登录用户
    # 她访问首页，新建一个清单
    # 她第一次看到“My Lists”链接
    # 她看到这个页面中有她创建的清单
    # 而且清单根据第一个待办事项命名
    # 她决定再建一个清单试试
    # 这个新建的清单在“My Lists”页面也显示出来了
    # 她退出后，“My Lists”链接不见了
[...]

与呆板的“Given/Then/When”结构相比，功能测试中的行间注释可读性更高，也显得更自然，而且在一定程度上，更能从用户的角度思考问题。（Gherkin 也支持在特性描述文件中编写“注释”，这能在一定程度上缓解上述问题，但是我想用的人并不多。）

E.13　非程序员会编写测试吗

有一点我还没有提到：BDD 最初的动机之一是让非程序员（可能是业务代表或客户代表）能够编写 Gherkin 句法。我十分怀疑现实中有没有人这么做；即便有，与 BDD 的其他优势相比，我想这也不算什么。

E.14　目前的结论

我才刚接触 BDD，还不能得出什么强有力的结论。我觉得“强制”把功能测试分成不同的步骤十分吸引人，因为这样便于在功能测试中大量重用代码，而且能把关注点明确分开，一边是对故事的描述，另一边是具体实现。此外，BDD 还能让我们站在业务逻辑的角度思考问题，而不是想着“应该如何使用 Selenium 去做”。

但是，世界上没有免费的午餐。与功能测试中行间注释的无拘无束相比，Gherkin 句法太死板、不够灵活。

我还想知道，当功能描述由一两个变成十几个、步骤定义由四五个变成几百行代码之后，BDD 能否适应。

总之，我觉得 BDD 绝对值得深入研究，我的下一个个人项目可能会使用它。

感谢 Daniel Pope、Rachel Willmer 和 Jared Contrascere 对本附录的反馈。

BDD 总结

有助于编写结构良好、可重用的测试代码

BDD 能分离关注点，把功能测试拆分成人类可读的“特性描述”文件（使用 Gherkin 句法）和步骤函数的实现，这样有助于编写可重用、易于管理的测试代码。

可能有失可读性

Gherkin 句法虽然追求的是人类可读性，但是并没有充分发挥人类语言的灵活性，因此可能无法像行间注释那样注重细节、明确表明意图。

多尝试总是好的

我不断强调，我还未在真实的项目中用过BDD，因此你要对我讲的内容持怀疑态度。但是我强烈推荐你使用BDD。我将试着在我的下一个项目中使用它，同时也建议你这么做。

附录 F　构建一个REST API：JSON、Ajax和 JavaScript模拟技术

表现层状态转化（REpresentational State Transfer，REST）是一种设计 Web 服务的方案，读取和更新的是“资源”（resource）。设计通过 Web 使用的 API 时，这是首选方案。

我们设计的 Web 应用还用不到 API，那为什么现在就要设计一个呢？其中一个动机是想让网站更加动态，从而提升用户体验。在清单中添加待办事项后，我们不想等待页面刷新，而是想使用 JavaScript 向 API 发送异步请求，让用户感受网站的交互性。

但更重要的一点或许是，有了 API，我们就可以通过浏览器之外的机制与后端应用交互。例如，客户端可以是移动应用，也可以是命令行应用，而且其他开发者还可以围绕后端构建库和工具。

本附录将说明如何自己动手构建一个 API。附录 G 再介绍 Django 生态系统中的一个流行工具——Django-Rest-Framework。

F.1　本附录采用的方案

我们构建的 API 不涵盖应用的全部功能，而是假设已经有清单了。根据 REST 架构，URL 和 HTTP 方法（常用的有 GET 和 POST，不过也有比较少用的，例如 PUT 和 DELETE）之间有一定的对应关系，我们可以据此设计方案。

维基百科中的 REST 词条对此有很好的概述，简单来说：

新 URL 结构为 /api/lists/{id}/；

通过 GET 请求获取清单详情（包括清单中的全部待办事项）的 JSON 格式；

通过 POST 请求添加待办事项。我们将使用第 25 章结束时的代码。

F.2　选择测试方案

如果构建的 API 对客户端一无所知，或许应该好好想想测试应该下行到哪一层。对功能测试来说，我们仍然要启动一个真实的服务器（可以使用 LiveServerTestCase），然后使用 requests 库与之交互。我们应该仔细考虑如何设置固件（如果使用 API 自身，测试之间牵涉的依赖太多），以及哪一层的单元测试对我们最有用。或者，干脆只使用 Django 测试客户端做一层测试。

现在的情况是，我们要为基于浏览器的客户端构建 API。我们想在线上网站使用这个 API，而且对应用之前的功能没有任何影响。因此，我们依然要让功能测试做最高层的测试，通过功能测试检查 JavaScript 和 API 之间的集成情况。

这样一来，低层测试就要使用 Django 测试客户端了。下面开始构建。

F.3　基本结构

首先，编写一个功能测试，检查新的 URL 结构能正常响应 GET 请求（状态码为 200），而且响应是 JSON 格式（而不是 HTML 格式）：

lists/tests/test_api.py

import json
from django.test import TestCase

from lists.models import List, Item

class ListAPITest(TestCase):
    base_url = '/api/lists/{}/'  ➊

    def test_get_returns_json_200(self):
        list_ = List.objects.create()
        response = self.client.get(self.base_url.format(list_.id))
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response['content-type'], 'application/json')

➊ 使用类级常量指定 URL 是本附录采用的新方式，这样做便无须重复硬编码 URL。此外，还可以调用 reverse，进一步减少重复。

然后，引入 urls 文件：

superlists/urls.py

from django.conf.urls import include, url
from accounts import urls as accounts_urls
from lists import views as list_views
from lists import api_urls
from lists import urls as list_urls

urlpatterns = [
    url(r'^$', list_views.home_page, name='home'),
    url(r'^lists/', include(list_urls)),
    url(r'^accounts/', include(accounts_urls)),
    url(r'^api/', include(api_urls)),
]

和：

lists/api_urls.py

from django.conf.urls import url
from lists import api

urlpatterns = [
    url(r'^lists/(\d+)/$', api.list, name='api_list'),
]

API 的核心代码可以放在 api.py 文件中，只需三行代码就行了：

lists/api.py

from django.http import HttpResponse

def list(request, list_id):
    return HttpResponse(content_type='application/json')

测试应该能通过。现在就有了基础结构。

$ python manage.py test lists
[...]
..................................................
 ---------------------------------------------------------------------
Ran 50 tests in 0.177s

OK

F.4　返回实质内容

接下来，我们要让 API 返回一些实质内容，即清单中各待办事项的 JSON 表示形式：

lists/tests/test_api.py (ch36l002)

    def test_get_returns_items_for_correct_list(self):
        other_list = List.objects.create()
        Item.objects.create(list=other_list, text='item 1')
        our_list = List.objects.create()
        item1 = Item.objects.create(list=our_list, text='item 1')
        item2 = Item.objects.create(list=our_list, text='item 2')
        response = self.client.get(self.base_url.format(our_list.id))
        self.assertEqual(
            json.loads(response.content.decode('utf8')),  ➊
            [
                'id': item1.id, 'text': item1.text},
                'id': item2.id, 'text': item2.text},
            ]
        )

➊ 这个测试主要要注意这一点。我们期望响应是 JSON 格式，使用 json.loads() 是因为测试 Python 对象比直接处理原始的 JSON 字符串容易。

实现时则要反过来，使用 json.dumps()：

lists/api.py

import json
from django.http import HttpResponse
from lists.models import List, Item

def list(request, list_id):
    list_ = List.objects.get(id=list_id)
    item_dicts = [
        {'id': item.id, 'text': item.text}
        for item in list_.item_set.all()
    ]
    return HttpResponse(
        json.dumps(item_dicts),
        content_type='application/json'
    )

这是使用列表推导的好机会！

F.5　添加对POST请求的支持

这个 API 还要允许通过 POST 请求向清单中添加新待办事项。先采用常规方式：

lists/tests/test_api.py (ch36l004)

    def test_POSTing_a_new_item(self):
        list_ = List.objects.create()
        response = self.client.post(
            self.base_url.format(list_.id),
            {'text': 'new item'},
        )
        self.assertEqual(response.status_code, 201)
        new_item = list_.item_set.get()
        self.assertEqual(new_item.text, 'new item')

实现同样简单，基本上与常规的视图所做的一样，不过这里要返回 201 响应，而不能重定向：

lists/api.py (ch36l005)

def list(request, list_id):
    list_ = List.objects.get(id=list_id)
    if request.method == 'POST':
        Item.objects.create(list=list_, text=request.POST['text'])
        return HttpResponse(status=201)
    item_dicts = [
        [...]

这样便可以了：

$ python manage.py test lists
[...]

Ran 52 tests in 0.177s

OK

　构建 REST API 的要点之一是，要充分利用 HTTP 状态码。

F.6　使用Sinon.js测试客户端Ajax

没有模拟库是无法测试 Ajax 的。不同的测试框架和工具采用不同的模拟库，而 Sinon 是通用的。稍后你将看到，Sinon 还提供了 JavaScript 驭件。

下载 Sinon，将其放到 lists/static/tests/ 文件夹中。

下面编写第一个 Ajax 测试：

lists/static/tests/tests.html (ch36l007)

  <div id="qunit-fixture">
    <form>
      <input name="text" />
      <div class="has-error">Error text</div>
    </form>
    <table id="id_list_table">  ➊
    </table>
  </div>

  <script src="../jquery-3.1.1.min.js"></script>
  <script src="../list.js"></script>
  <script src="qunit-2.0.1.js"></script>
  <script src="sinon-1.17.6.js"></script>  ➋

  <script>
/* global sinon */

var server;
QUnit.testStart(function () {
  server = sinon.fakeServer.create();  ➌
});
QUnit.testDone(function () {
  server.restore();  ➌
});

QUnit.test("errors should be hidden on keypress", function (assert) {
[...]

QUnit.test("should get items by ajax on initialize", function (assert) {
  var url = '/getitems/';
  window.Superlists.initialize(url);

  assert.equal(server.requests.length, 1);  ➍
  var request = server.requests[0];
  assert.equal(request.url, url);
  assert.equal(request.method, 'GET');
});

  </script>

❶ 在固件 div 元素中添加一个元素，表示清单表格。

❷ 导入 sinon.js（要先下载，并放到正确的文件夹中）。

❸ QUnit 中的 testStart 和 testDone 对应于 Python 测试中的 setUp 和 tearDown。这里，我们让 Sinon 启动 Ajax 测试工具（fakeServer），并将它赋值给全局作用域中的 server 变量。

❹ 然后通过 server 对代码发送的 Ajax 请求下断言。这里，我们测试请求的目标 URL 和所用的 HTTP 方法。

为了发送 Ajax 请求，我们将使用 jQuery 提供的 Ajax 辅助方法，这比使用浏览器底层的标准 XMLHttpRequest 对象要简单得多：

lists/static/list.js

@@ -1,6 +1,10 @@
 window.Superlists = {};
-window.Superlists.initialize = function () {
+window.Superlists.initialize = function (url) {
   $('input[name="text"]').on('keypress', function () {
     $('.has-error').hide();
   });
+
+  $.get(url);
+
 };
+

现在测试应该能通过：

5 assertions of 5 passed, 0 failed.
1. errors should be hidden on keypress (1)
2. errors aren't hidden if there is no keypress (1)
3. should get items by ajax on initialize (3)

好吧，我们能向服务器发送 GET 请求了，但是具体的操作呢？我们应该如何测试“异步”请求，应该如何处理（最终得到的）响应呢？

使用Sinon测试Ajax请求的异步行为

这是人们喜欢 Sinon 的主要原因。我们可以通过 server.respond() 准确控制异步代码的流程：

lists/static/tests/tests.html (ch36l009)

QUnit.test("should fill in lists table from ajax response", function (assert) {
  var url = '/getitems/';
  var responseData = [
    {'id': 101, 'text': 'item 1 text'},
    {'id': 102, 'text': 'item 2 text'},
  ];
  server.respondWith('GET', url, [
    200, {"Content-Type": "application/json"}, JSON.stringify(responseData)  ➊
  ]);
  window.Superlists.initialize(url);  ➋

  server.respond();  ➌

  var rows = $('#id_list_table tr');  ➍
  assert.equal(rows.length, 2);
  var row1 = $('#id_list_table tr:first-child td');
  assert.equal(row1.text(), '1: item 1 text');
  var row2 = $('#id_list_table tr:last-child td');
  assert.equal(row2.text(), '2: item 2 text');
});

❶ 设定一些响应数据供 Sinon 使用。我们设定了状态码、首部以及希望服务器返回的 JSON 响应——这是最重要的。

❷ 然后调用要测试的函数。

❸ 关键时刻到了。随后，我们可以在任何需要的地方调用 server.respond()，触发 Ajax 循环中所有的异步代码，即用于处理响应的那些回调。

❹ 然后检查 Ajax 回调有没有在表格的行中填充新的待办事项。实现如下所示：

lists/static/list.js (ch36l010)

  if (url) {
    $.get(url).done(function (response) {
      var rows = '';
      for (var i=0; i<response.length; i++) {
        var item = response[i];
        rows += '\n<tr><td>' + (i+1) + ': ' + item.text + '</td></tr>';
      }
      $('#id_list_table').html(rows);
    });
  }

　我们很幸运，因为 jQuery 是使用 .done() 函数为 Ajax 注册回调的。如果使用标准的 JavaScript Promise.then() 回调，异步操作就要多一层。不过，QUnit 也能处理，详情参见 async 函数的文档。其他测试框架为此也提供了类似的函数。

F.7　在模板中连接各部分，确认这样是否真的可行

我们先做个破坏，把 lists.html 模板中用于显示清单表格的 {% for %} 循环删掉：

lists/templates/list.html

@@ -6,9 +6,6 @@

 {% block table %}
   <table id="id_list_table" class="table">
-    {% for item in list.item_set.all %}
-      <tr><td>{{ forloop.counter }}: {{ item.text }}</td></tr>
-    {% endfor %}
   </table>

   {% if list.owner %}

　这会导致其中一个单元测试失败，可以先暂时删除那个测试。

优雅降级和渐进增强

删除非 Ajax 版本的清单页面之后，就无法优雅降级了，即没有在禁用 JavaScript 的情况下依然能正常使用的版本。

以前，优雅降级通常是为了提供辅助功能，因为供视觉障碍人士使用的“屏幕阅读器”通常不支持 JavaScript，所以完全依赖 JavaScript 就把这部分用户排除在外了。但据我了解，现在这已经不是什么大问题了。但有些用户甚至会基于安全方面的原因而禁用 JavaScript。

另一个常见的问题是，为不同的浏览器提供不同程度的 JavaScript 支持。当你开始迈向“现代的”前端开发和 ES2015 时，尤其要注意这个问题。

简单来说，最好始终提供非 JavaScript 版本的“后援”。如果已经先构建好了无须 JavaScript 就能正常使用的网站，就千万不要轻易把“陈旧的”HTML 版本删除。我这么做只是因为删除后更便于说明我想讲的内容。

这样做会导致功能测试失败：

$ python manage.py test functional_tests.test_simple_list_creation
[...]
FAIL: test_can_start_a_list_for_one_user
[...]
  File "/.../superlists/functional_tests/test_simple_list_creation.py", line
32, in test_can_start_a_list_for_one_user
    self.wait_for_row_in_list_table('1: Buy peacock feathers')
[...]
AssertionError: '1: Buy peacock feathers' not found in []
[...]
FAIL: test_multiple_users_can_start_lists_at_different_urls

FAILED (failures=2)

下面在基模板中添加一个 {% scripts %} 块，这样便可以在清单页面有选择地覆盖：

lists/templates/base.html

    <script src="/static/list.js"></script>

    {% block scripts %}
      <script>
$(document).ready(function () {
  window.Superlists.initialize();
});
      </script>
    {% endblock scripts %}

  </body>

然后，在 list.html 中稍微修改一下调用 initialize 的方式，传入正确的 URL：

lists/templates/list.html (ch36l016)

{% block scripts %}
  <script>
$(document).ready(function () {
  var url = "{% url 'api_list' list.id %}";
  window.Superlists.initialize(url);
});
  </script>
{% endblock scripts %}

你猜怎么着？测试通过了！

$ python manage.py test functional_tests.test_simple_list_creation
[...]
Ran 2 test in 11.730s

OK

这是个不错的开始！

如果这时运行功能测试，你会发现其他功能测试中有几个失败。接下来我们就要处理这些失败。此外，这里还在使用通过表单处理 POST 请求的过时方法，页面需要刷新，离时下流行的单页应用还有段距离。但我们正向着目标前进！

F.8　实现Ajax POST，包括CSRF令牌

首先为清单表格设定一个 id，以便在 JavaScript 代码中引用它：

lists/templates/base.html

  <h1>{% block header_text %}{% endblock %}</h1>
  {% block list_form %}
    <form id="id_item_form" method="POST" action="{% block form_action %}{% endblock %}">
      {{ form.text }}
      [...]

然后使用 ID 调整 JavaScript 测试中的固件，并加上页面当前的 CSRF 令牌：

lists/static/tests/tests.html

@@ -9,9 +9,14 @@
 <body>
   <div id="qunit"></div>
   <div id="qunit-fixture">
-    <form>
+    <form id="id_item_form">
       <input name="text" />
-      <div class="has-error">Error text</div>
+      <input type="hidden" name="csrfmiddlewaretoken" value="tokey" />
+      <div class="has-error">
+        <div class="help-block">
+          Error text
+        </div>
+      </div>
     </form>

测试如下：

lists/static/tests/tests.html (ch36l019)

QUnit.test("should intercept form submit and do ajax post", function (assert) {
  var url = '/listitemsapi/';
  window.Superlists.initialize(url);

  $('#id_item_form input[name="text"]').val('user input');  ➊
  $('#id_item_form input[name="csrfmiddlewaretoken"]').val('tokeney');  ➊
  $('#id_item_form').submit();  ➊

  assert.equal(server.requests.length, 2);  ➋
  var request = server.requests[1];
  assert.equal(request.url, url);
  assert.equal(request.method, "POST");
  assert.equal(
    request.requestBody,
    'text=user+input&csrfmiddlewaretoken=tokeney'  ➌
  );
});

❶ 模拟用户操作，填表后点击提交按钮。

❷ 预期会有第二个 Ajax 请求（第一个是针对清单表格的 GET 请求）。

❸ 检查 POST 请求的 requestBody。可以看出，它的值经过 URL 编码了，这虽然不是最易于测试的，但是可读性尚可。

实现方式如下：

lists/static/list.js

[...]
  $('#id_list_table').html(rows);
});

var form = $('#id_item_form');
form.on('submit', function(event) {
  event.preventDefault();
  $.post(url, {
    'text': form.find('input[name="text"]').val(),
    'csrfmiddlewaretoken': form.find('input[name="csrfmiddlewaretoken"]').val(),
  });
});

现在 JavaScript 测试能通过了，但是功能测试却失败了，因为虽然 POST 请求成功了，但是却没有更新页面，显示新添加的待办事项：

$ python manage.py test functional_tests.test_simple_list_creation
[...]
AssertionError: '2: Use peacock feathers to make a fly' not found in ['1: Buy
peacock feathers']

F.9　JavaScript中的模拟技术

我们希望处理完 Ajax POST 请求后，客户端能更新表格中的待办事项。其实这与重新加载页面所做的事情是一样的，我们要从服务器中获取清单中当前的待办事项，然后在表格中显示出来。

看来，我们需要一个辅助函数。

lists/static/list.js

window.Superlists = {};

window.Superlists.updateItems = function (url) {
  $.get(url).done(function (response) {
    var rows = '';
    for (var i=0; i<response.length; i++) {
      var item = response[i];
      rows += '\n<tr><td>' + (i+1) + ': ' + item.text + '</td></tr>';
    }
    $('#id_list_table').html(rows);
  });
};

window.Superlists.initialize = function (url) {
  $('input[name="text"]').on('keypress', function () {
    $('.has-error').hide();
  });

  if (url) {
    window.Superlists.updateItems(url);

    var form = $('#id_item_form');
    [...]

这只算是一次重构。经确认，现在 JavaScript 测试依然能通过：

12 assertions of 12 passed, 0 failed.
1. errors should be hidden on keypress (1)
2. errors aren't hidden if there is no keypress (1)
3. should get items by ajax on initialize (3)
4. should fill in lists table from ajax response (3)
5. should intercept form submit and do ajax post (4)

那么，我们应该如何测试 Ajax POST 请求成功后调用了 updateItems 呢？我们可不想傻傻地重复编写模拟服务器响应的代码，然后自己动手检查待办事项表格……要不使用驭件试试？

首先，设置一个“沙盒”，让它跟踪我们创建的所有驭件，并在每次测试之后把被模拟的东西还原。

lists/static/tests/tests.html (ch36l023)

var server, sandbox;
QUnit.testStart(function () {
  server = sinon.fakeServer.create();
  sandbox = sinon.sandbox.create();
});
QUnit.testDone(function () {
  server.restore();
  sandbox.restore();  ➊
});

➊ .restore() 是重点，它的作用是在每次测试之后还原被模拟的东西。

lists/static/tests/tests.html (ch36l024)

QUnit.test("should call updateItems after successful post", function (assert) {
  var url = '/listitemsapi/';
  window.Superlists.initialize(url);  ➊
  var response = [
    201,
    {"Content-Type": "application/json"},
    JSON.stringify({}),
  ];
  server.respondWith('POST', url, response);  ➊
  $('#id_item_form input[name="text"]').val('user input');
  $('#id_item_form input[name="csrfmiddlewaretoken"]').val('tokeney');
  $('#id_item_form').submit();

  sandbox.spy(window.Superlists, 'updateItems');  ➋
  server.respond();  ➋

  assert.equal(
    window.Superlists.updateItems.lastCall.args,  ➌
    url
  );
});

❶ 首先要注意，初始化之后才能设置服务器响应。这是因为我们想设置的是提交表单时发送的 POST 请求的响应，而不是一开始那个 GET 请求的响应。（还记得第 16 章所学的知识吗？ JavaScript 测试最难掌握的技术之一便是控制执行顺序。）

❷ 同样，仅当开始那个 GET 请求处理完毕之后，我们才开始模拟辅助函数。sandbox.spy 调用的作用与 Python 测试中的 patch 一样，把指定对象替换为驭件。

❸ 模拟的 updateItems 函数现在多了一些属性，例如 lastCall 和 lastCall.args（类似于 Python 驭件的 call_args）。

让测试通过之前，我们想故意犯个错，确认它的确能测试我们想测试的行为：

lists/static/list.js

$.post(url, {
  'text': form.find('input[name="text"]').val(),
  'csrfmiddlewaretoken': form.find('input[name="csrfmiddlewaretoken"]').val(),
}).done(function () {
  window.Superlists.updateItems();
});

收效不错，但功能测试还未全部通过：

12 assertions of 13 passed, 1 failed.
[...]
6. should call updateItems after successful post (1, 0, 1)
    1. failed
        Expected: "/listitemsapi/"
        Result: []
        Diff: "/listitemsapi/"[]
        Source: file:///.../superlists/lists/static/tests/tests.html:124:15

据此修正：

lists/static/list.js

    }).done(function () {
      window.Superlists.updateItems(url);
    });

现在功能测试通过了！或者，至少部分通过了。其他测试还有问题，稍后再回来解决。

结束重构：让测试与代码匹配

现在，我有点不舒心，因为重构还没结束。下面稍微让单元测试与代码匹配一些：

lists/static/tests/tests.html

@@ -50,9 +50,19 @@ QUnit.testDone(function () {
 });

-QUnit.test("should get items by ajax on initialize", function (assert) {
+QUnit.test("should call updateItems on initialize", function (assert) {
   var url = '/getitems/';
+  sandbox.spy(window.Superlists, 'updateItems');
   window.Superlists.initialize(url);
+  assert.equal(
+    window.Superlists.updateItems.lastCall.args,
+    url
+  );
+});
+
+QUnit.test("updateItems should get correct url by ajax", function (assert) {
+  var url = '/getitems/';
+  window.Superlists.updateItems(url);

   assert.equal(server.requests.length, 1);
   var request = server.requests[0];
@@ -60,7 +70,7 @@ QUnit.test("should get items by ajax on initialize", function (assert) {
   assert.equal(request.method, 'GET');
 });

-QUnit.test("should fill in lists table from ajax response", function (assert) {
+QUnit.test("updateItems should fill in lists table from ajax response", function (assert) {
   var url = '/getitems/';
   var responseData = [
     {'id': 101, 'text': 'item 1 text'},
@@ -69,7 +79,7 @@ QUnit.test("should fill in lists table from ajax response", function [...]
   server.respondWith('GET', url, [
     200, {"Content-Type": "application/json"}, JSON.stringify(responseData)
   ]);
-  window.Superlists.initialize(url);
+  window.Superlists.updateItems(url);

   server.respond();

现在测试的结果如下：

14 assertions of 14 passed, 0 failed.
1. errors should be hidden on keypress (1)
2. errors aren't hidden if there is no keypress (1)
3. should call updateItems on initialize (1)
4. updateItems should get correct url by ajax (3)
5. updateItems should fill in lists table from ajax response (3)
6. should intercept form submit and do ajax post (4)
7. should call updateItems after successful post (1)

F.10　数据验证：留给读者的练习

如果运行全部测试，你会发现有两个针对验证的功能测试失败了：

$ python manage.py test
[...]
ERROR: test_cannot_add_duplicate_items
(functional_tests.test_list_item_validation.ItemValidationTest)
[...]
ERROR: test_error_messages_are_cleared_on_input
(functional_tests.test_list_item_validation.ItemValidationTest)
[...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate
element: .has-error

我不会告诉你具体应该怎么解决，下面仅给出所需的单元测试：

lists/tests/test_api.py (ch36l027)

from lists.forms import DUPLICATE_ITEM_ERROR, EMPTY_ITEM_ERROR
[...]
    def post_empty_input(self):
        list_ = List.objects.create()
        return self.client.post(
            self.base_url.format(list_.id),
            data={'text': ''}
        )

    def test_for_invalid_input_nothing_saved_to_db(self):
        self.post_empty_input()
        self.assertEqual(Item.objects.count(), 0)

    def test_for_invalid_input_returns_error_code(self):
        response = self.post_empty_input()
        self.assertEqual(response.status_code, 400)
        self.assertEqual(
            json.loads(response.content.decode('utf8')),
            {'error': EMPTY_ITEM_ERROR}
        )

    def test_duplicate_items_error(self):
        list_ = List.objects.create()
        self.client.post(
            self.base_url.format(list_.id), data={'text': 'thing'}
        )
        response = self.client.post(
            self.base_url.format(list_.id), data={'text': 'thing'}
        )
        self.assertEqual(response.status_code, 400)
        self.assertEqual(
            json.loads(response.content.decode('utf8')),
            {'error': DUPLICATE_ITEM_ERROR}
        )

以及 JavaScript 测试：

lists/static/tests/tests.html (ch36l029-2)

QUnit.test("should display errors on post failure", function (assert) {
  var url = '/listitemsapi/';
  window.Superlists.initialize(url);
  server.respondWith('POST', url, [
    400,
    {"Content-Type": "application/json"},
    JSON.stringify({'error': 'something is amiss'})
  ]);
  $('.has-error').hide();

  $('#id_item_form').submit();
  server.respond(); // post

  assert.equal($('.has-error').is(':visible'), true);
  assert.equal($('.has-error .help-block').text(), 'something is amiss');
});

QUnit.test("should hide errors on post success", function (assert) {
    [...]

此外，你还要修改 base.html 模板，让它既能显示 Django 错误（主页现在就能显示），也能显示 JavaScript 错误：

lists/templates/base.html (ch36l031)

@@ -51,17 +51,21 @@
         <div class="col-md-6 col-md-offset-3 jumbotron">
           <div class="text-center">
             <h1>{% block header_text %}{% endblock %}</h1>
+
             {% block list_form %}
               <form id="id_item_form" method="POST" action="{% block [...]
                 {{ form.text }}
                 {% csrf_token %}
-                {% if form.errors %}
-                  <div class="form-group has-error">
-                    <div class="help-block">{{ form.text.errors }}</div>
+                <div class="form-group has-error">
+                  <div class="help-block">
+                    {% if form.errors %}
+                      {{ form.text.errors }}
+                    {% endif %}
                   </div>
-                {% endif %}
+                </div>
               </form>
             {% endblock %}
+
           </div>
         </div>
       </div>

最终，运行 JavaScript 测试应该得到类似下面的结果：

20 assertions of 20 passed, 0 failed.
1. errors should be hidden on keypress (1)
2. errors aren't hidden if there is no keypress (1)
3. should call updateItems on initialize (1)
4. updateItems should get correct url by ajax (3)
5. updateItems should fill in lists table from ajax response (3)
6. should intercept form submit and do ajax post (4)
7. should call updateItems after successful post (1)
8. should not intercept form submit if no api url passed in (1)
9. should display errors on post failure (2)
10. should hide errors on post success (1)
11. should display generic error if no error json (2)

全部测试应该都能通过，包括所有功能测试：

$ python manage.py test
[...]
Ran 81 tests in 62.029s
OK

太棒了！！！

这就是我们自己动手使用 Django 构建的 REST API。如果需要提示，可以查看代码示例仓库中的代码（https://github.com/hjwp/book-example/tree/appendix_rest_api）。

不过，我不建议使用 Django 自己动手构建 REST API，在此之前，你至少应该考察一下 Django-Rest-Framework，详情参见附录 G。继续前行吧！

REST API 小贴士

不要重复编写URL

与面向浏览器的应用相比，API 的 URL 更为重要。尽量减少在测试中硬编码 URL 的次数。

不要直接处理原始JSON 字符串

让 json.loads 和 json.dumps 常伴你左右。

JavaScript 测试应该使用Ajax 模拟库

Sinon 不错。Jasmine 自带了，Angular 也是。

牢记优雅降级和渐进增强

尤其是把静态网站变成由 JavaScript 驱动的网站时，至少要让网站的核心功能在没有 JavaScript 时依然能使用。

附录 G　Django-Rest-Framework

在附录 F 中，我们自己动手构建了一个 REST API。现在，来看看 Django-Rest-Framework，这是很多 Python/Django 开发者构建 API 时的首选工具。Django 的目的是为构建数据库驱动的网站提供各种基础工具（ORM、模板等），而 Django-Rest-Framework 的目的则是为构建 API 提供全部工具，从而避免一次次地编写样板代码。

撰写本附录时，我苦苦思索，怎样使用 Django-Rest-Framework 构建一个与前面自己动手实现的那个一模一样的 API 呢？若想在 Django-Rest-Framework 中得到同样的 URL 结构和 JSON 数据结构，面临巨大的挑战。在实现的过程中，我感觉自己是在与 Django-Rest-Framework 做斗争。

这提醒了我，让我陷入沉思。构建 Django-Rest-Framework 的人比我聪明得多，他们见过的 REST API 也比我多得多。如果他们觉得应该以某种方式处理，或许就表明我应该采用那种方式。我应该站在他们的角度思考问题，而不应该固执己见。

“别与框架做斗争”，这是我听过的至理名言之一。如果不能顺应框架，可能就要想想自己到底需不需要使用框架。

我们将以附录 F 构建的 API 为蓝本，尝试使用 Django-Rest-Framework 重写。

G.1　安装

Django-Rest-Framework 使用 pip install 命令就能安装。我使用的是创作本书时的最新版——3.5.4 版：

$ pip install djangorestframework

然后把 rest_framework 添加到 settings.py 中的 INSTALLED_APPS 设置中：

superlists/settings.py

INSTALLED_APPS = [
    #'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'lists',
    'accounts',
    'functional_tests',
    'rest_framework',
]

G.2　串化器（具体而言是ModelSerializer）

Django-Rest-Framework 官网的教程是学习这个框架的好资源。一开始你就会遇到串化器（serializer），这里具体而言是 ModelSerializer。Django-Rest-Framework 通过串化器把 Django 数据库模型转换为交换数据所需的 JSON（或其他格式）。

lists/api.py (ch37l003)

from lists.models import List, Item
[...]
from rest_framework import routers, serializers, viewsets

class ItemSerializer(serializers.ModelSerializer):

    class Meta:
        model = Item
        fields = ('id', 'text')

class ListSerializer(serializers.ModelSerializer):
    items = ItemSerializer(many=True, source='item_set')

    class Meta:
        model = List
        fields = ('id', 'items',)

G.3　Viewset（具体而言是ModelViewSet）和路由器

Django-Rest-Framework 使用 ModelViewSet 定义通过 API 与某个模型对象的交互方式。我们只需指明想操作的是哪个模型（通过 queryset 属性），以及如何序列化模型对象（serializer_class），余下的工作由 ModelViewSet 自动完成，即自动构建相关视图，供列出、获取、更新，甚至是删除对象。

为了从特定的清单中获取待办事项，只需定义这样一个 ViewSet：

lists/api.py (ch37l004)

class ListViewSet(viewsets.ModelViewSet):
    queryset = List.objects.all()
    serializer_class = ListSerializer

router = routers.SimpleRouter()
router.register(r'lists', ListViewSet)

Django-Rest-Framework 通过路由器自动构建 URL 配置，并把它们映射到 ViewSet 提供的功能上。

现在，我们可以修改 urls.py，绕开旧的 API 代码，指向新的路由器，看看测试的情况如何：

superlists/urls.py (ch37l005)

[...]
# from lists.api import urls as api_urls
from lists.api import router

urlpatterns = [
    url(r'^$', list_views.home_page, name='home'),
    url(r'^lists/', include(list_urls)),
    url(r'^accounts/', include(accounts_urls)),
    # url(r'^api/', include(api_urls)),
    url(r'^api/', include(router.urls)),
]

结果好多测试都失败了：

$ python manage.py test lists
[...]
django.urls.exceptions.NoReverseMatch: Reverse for 'api_list' not found.
'api_list' is not a valid view function or pattern name.
[...]
AssertionError: 405 != 400
[...]
AssertionError: {'id': 2, 'items': [{'id': 2, 'text': 'item 1'}, {'id': 3,
'text': 'item 2'}]} != [{'id': 2, 'text': 'item 1'}, {'id': 3, 'text': 'item
2'}]

 ---------------------------------------------------------------------
Ran 54 tests in 0.243s

FAILED (failures=4, errors=10)

先看那 10 个错误，报错消息都说无法反转 api_list。这是因为 Django-Rest-Framework 路由器使用的命名约定与前面我们自己制定的不同。从调用跟踪可以看出，这些错误发生在渲染模板时。具体而言，是 list.html 模板。我们只需修改一处就能修正这些错误——把 api_list 改成 list-detail：

lists/templates/list.html (ch37l006)

  <script>
$(document).ready(function () {
  var url = "{% url 'list-detail' list.id %}";
});
  </script>

这样修改之后，只剩下 4 个失败了：

$ python manage.py test lists
[...]
FAIL: test_POSTing_a_new_item (lists.tests.test_api.ListAPITest)
[...]
FAIL: test_duplicate_items_error (lists.tests.test_api.ListAPITest)
[...]
FAIL: test_for_invalid_input_returns_error_code
(lists.tests.test_api.ListAPITest)
[...]
FAIL: test_get_returns_items_for_correct_list
(lists.tests.test_api.ListAPITest)
[...]
FAILED (failures=4)

暂且关闭所有验证测试，后面再想办法解决：

lists/tests/test_api.py (ch37l007)

[...]
    def DONTtest_for_invalid_input_nothing_saved_to_db(self):
        [...]
    def DONTtest_for_invalid_input_returns_error_code(self):
        [...]
    def DONTtest_duplicate_items_error(self):
        [...]

现在只有 2 个失败了：

FAIL: test_POSTing_a_new_item (lists.tests.test_api.ListAPITest)
[...]
    self.assertEqual(response.status_code, 201)
AssertionError: 405 != 201
[...]
FAIL: test_get_returns_items_for_correct_list
(lists.tests.test_api.ListAPITest)
[...]
AssertionError: {'id': 2, 'items': [{'id': 2, 'text': 'item 1'}, {'id': 3,
'text': 'item 2'}]} != [{'id': 2, 'text': 'item 1'}, {'id': 3, 'text': 'item
2'}]
[...]
FAILED (failures=2)

先看最后 1 个失败。

Django-Rest-Framework 的默认配置得到的数据结构与我们自己动手构建时稍有不同，GET 请求清单得到的响应中有清单的 ID，而且清单中的待办事项在 items 键名下。因此，为了让测试通过，我们要稍微修改一下单元测试：

lists/tests/test_api.py (ch37l008)

@@ -23,10 +23,10 @@ class ListAPITest(TestCase):
         response = self.client.get(self.base_url.format(our_list.id))
         self.assertEqual(
             json.loads(response.content.decode('utf8')),
-            [
+            {'id': our_list.id, 'items': [
                 {'id': item1.id, 'text': item1.text},
                 {'id': item2.id, 'text': item2.text},
-            ]
+            ]}
         )

现在能通过 GET 请求获取清单中的待办事项了（稍后将看到，随之一起返回的还有很多其他数据），那通过 POST 请求添加新待办事项呢？

G.4　通过POST请求添加待办事项的URL

这次我不再与框架做斗争了，而是顺应 Django-Rest-Framework。向清单的 ViewSet 发送 POST 请求是可以添加待办事项，但极为麻烦。

最简单的方法是向待办事项的 ViewSet 发送 POST 请求，而不是清单的 ViewSet：

lists/api.py (ch37l009)

class ItemViewSet(viewsets.ModelViewSet):
    serializer_class = ItemSerializer
    queryset = Item.objects.all()

[...]
router.register(r'items', ItemViewSet)

这意味着我们要稍微修改测试，把 POST 测试从 ListAPITest 中移出来，放到新的测试类 ItemsAPITest 中：

lists/tests/test_api.py (ch37l010)

@@ -1,3 +1,4 @@
 import json
+from django.core.urlresolvers import reverse
 from django.test import TestCase
 from lists.models import List, Item
@@ -31,9 +32,13 @@ class ListAPITest(TestCase):

+
+class ItemsAPITest(TestCase):
+    base_url = reverse('item-list')
+
     def test_POSTing_a_new_item(self):
         list_ = List.objects.create()
         response = self.client.post(
-            self.base_url.format(list_.id),
-            {'text': 'new item'},
+            self.base_url,
+            {'list': list_.id, 'text': 'new item'},
         )
         self.assertEqual(response.status_code, 201)

现在测试的结果为：

django.db.utils.IntegrityError: NOT NULL constraint failed: lists_item.list_id

序列化待办事项时，如果没有指定清单的 ID，就不能知道待办事项属于哪个清单：

lists/api.py (ch37l011)

class ItemSerializer(serializers.ModelSerializer):

    class Meta:
        model = Item
        fields = ('id', 'list', 'text')

为此，还要修改另一个有点联系的测试：

lists/tests/test_api.py (ch37l012)

@@ -25,8 +25,8 @@ class ListAPITest(TestCase):
         self.assertEqual(
             json.loads(response.content.decode('utf8')),
             {'id': our_list.id, 'items': [
-                {'id': item1.id, 'text': item1.text},
-                {'id': item2.id, 'text': item2.text},
+                {'id': item1.id, 'list': our_list.id, 'text': item1.text},
+                {'id': item2.id, 'list': our_list.id, 'text': item2.text},
             ]}
         )

G.5　调整客户端代码

现在，这个 API 不再返回一个包含清单中所有待办事项的扁平数组，而是返回一个对象，待办事项都在它的 .items 属性中。因此，我们要稍微调整一下 updateItems 函数：

lists/static/list.js (ch37l013)

@@ -3,8 +3,8 @@ window.Superlists = {};
 window.Superlists.updateItems = function (url) {
   $.get(url).done(function (response) {
     var rows = '';
-    for (var i=0; i<response.length; i++) {
-      var item = response[i];
+    for (var i=0; i<response.items.length; i++) {
+      var item = response.items[i];
       rows += '\n<tr><td>' + (i+1) + ': ' + item.text + '</td></tr>';
     }
     $('#id_list_table').html(rows);

而且，因为获取清单和添加待办事项的 URL 都变了，所以我们还要稍微调整一下 initialize 函数。我们不再使用多个参数，而是传入包含所需配置的 params 对象：

lists/static/list.js

@@ -11,23 +11,24 @@ window.Superlists.updateItems = function (url) {
   });
 };

-window.Superlists.initialize = function (url) {
+window.Superlists.initialize = function (params) {
   $('input[name="text"]').on('keypress', function () {
     $('.has-error').hide();
   });

-  if (url) {
-    window.Superlists.updateItems(url);
+  if (params) {
+    window.Superlists.updateItems(params.listApiUrl);

     var form = $('#id_item_form');
     form.on('submit', function(event) {
       event.preventDefault();
-      $.post(url, {
+      $.post(params.itemsApiUrl, {
+        'list': params.listId,
         'text': form.find('input[name="text"]').val(),
         'csrfmiddlewaretoken': form.find('input[name="csrfmiddlewaretoken"]').val(),
       }).done(function () {
         $('.has-error').hide();
-        window.Superlists.updateItems(url);
+        window.Superlists.updateItems(params.listApiUrl);
       }).fail(function (xhr) {
         $('.has-error').show();
         if (xhr.responseJSON && xhr.responseJSON.error) {

据此修改 list.html 中的代码：

lists/templates/list.html (ch37l014)

$(document).ready(function () {
  window.Superlists.initialize({
    listApiUrl: "{% url 'list-detail' list.id %}",
    itemsApiUrl: "{% url 'item-list' %}",
    listId: {{ list.id }},
  });
});

经过一番修改之后，基本的功能测试又能通过了：

$ python manage.py test functional_tests.test_simple_list_creation
[...]
Ran 2 tests in 15.635s

OK

为了解决前面暂时忽略的错误，还要做一些修改。如果不知道如何修改，可以参照本附录的仓库（https://github.com/hjwp/book-example/blob/appendix_DjangoRestFramework/lists/api.py）。

G.6　Django-Rest-Framework的优势

你可能想知道为什么要使用这个框架。

G.6.1　用配置代替代码

第一个优势是，以前的过程式视图函数变成了声明式句法：

lists/api.py

def list(request, list_id):
    list_ = List.objects.get(id=list_id)
    if request.method == 'POST':
        form = ExistingListItemForm(for_list=list_, data=request.POST)
        if form.is_valid():
            form.save()
            return HttpResponse(status=201)
        else:
            return HttpResponse(
                json.dumps({'error': form.errors['text'][0]}),
                content_type='application/json',
                status=400
            )
    item_dicts = [
        {'id': item.id, 'text': item.text}
        for item in list_.item_set.all()
    ]
    return HttpResponse(
        json.dumps(item_dicts),
        content_type='application/json'
    )

如果与使用 Django-Rest-Framework 得到的最终版本相比，你会发现，我们完全是在配置：

lists/api.py

class ItemSerializer(serializers.ModelSerializer):
    text = serializers.CharField(
        allow_blank=False, error_messages={'blank': EMPTY_ITEM_ERROR}
    )

    class Meta:
        model = Item
        fields = ('id', 'list', 'text')
        validators = [
            UniqueTogetherValidator(
                queryset=Item.objects.all(),
                fields=('list', 'text'),
                message=DUPLICATE_ITEM_ERROR
            )
        ]

class ListSerializer(serializers.ModelSerializer):
    items = ItemSerializer(many=True, source='item_set')

    class Meta:
        model = List
        fields = ('id', 'items',)

class ListViewSet(viewsets.ModelViewSet):
    queryset = List.objects.all()
    serializer_class = ListSerializer

class ItemViewSet(viewsets.ModelViewSet):
    serializer_class = ItemSerializer
    queryset = Item.objects.all()

router = routers.SimpleRouter()
router.register(r'lists', ListViewSet)
router.register(r'items', ItemViewSet)

G.6.2　自带的功能

第二个优势是，使用 Django-Rest-Framework 的 ModelSerializer、ViewSet 和路由器得到的 API 比我们自己动手构建的 API 更具扩展性。

现在，清单和待办事项相关的所有 URL 都自动支持全部 HTTP 方法，包括 GET、POST、PUT、PATCH、DELETE 和 OPTIONS。

而且在 http://localhost:8000/api/lists/ 和 http://localhost:8000/api/items 可以浏览自动生成的 API 文档（你可以自己试试，如图 G-1 所示）。

图 G-1：自动为 API 用户生成的文档

除此之外，Django-Rest-Framework 还有很多优势，详情参见文档（http://www.django-rest-framework.org/topics/documenting-your-api/#self-describing-apis）。不过这两个功能对 API 的用户而言是十分重要的。

综上，Django-Rest-Framework 是构建 API 的优秀工具，几乎能根据现有的模型结构自动生成 API。如果你使用 Django，在自己动手实现 API 之前绝对应该先考察一下 Django-Rest- Framework。

Django-Rest-Framework 小贴士

别与框架做斗争

若想提高效率，通常最好顺应框架的约定，否则就不要使用框架，或者在较低的层级定制。

根据最小惊讶原则，使用路由器和ViewSet

Django-Rest-Framework 的优势之一是，使用它提供的工具（如路由器和 ViewSet）得到的 API 是可预料的，端点、URL 结构和不同 HTTP 方法的响应都有合理的默认配置。

查看可浏览的 API 文档

在浏览器中访问 API 的端点。Django-Rest-Framework 能检测到你访问 API 时使用的是“常规的”Web 浏览器，此时它会显示自身的精美文档，可供你分享给你的用户。

附录 H　速查表

人们都喜欢速查表，所以我根据每章末尾旁注中的总结制作了这个速查表，目的是提醒你，并且链接到具体章节，以此唤起你的记忆。希望这个速查表有用。

H.1　项目开始阶段

先构思一个用户故事，然后转换成第一个功能测试。

选择一个测试框架——unittest 不错，py.test、nose 和 Green 也有一定优势。

运行功能测试，得到第一个预期失败。

选择一个 Web 框架，例如 Django，然后弄清如何在选中的框架中运行单元测试。

针对目前失败的功能测试编写第一个单元测试，看着它失败。

做第一次提交，把代码提交到 VCS（例如 Git）中。

相关内容：第 1、2、3 章。

H.2　TDD基本流程

双循环 TDD（图 H-1）。

遇红 / 变绿 / 重构。

三角法。

便签。

“三则重构”原则。

“从一个可运行状态到另一个可运行状态”。

“YAGNI”原则。

图 H-1：包含功能测试和单元测试的 TDD 流程

相关内容：第 4、5、7 章。

H.3　测试不止要在开发环境中运行

尽早进行系统测试。确保各组件能正常协作，包括 Web 组件、静态内容和数据库。

搭建和生产环境一样的过渡环境，在这个环境中运行功能测试。

自动部署过渡环境和生产环境：

PaaS 与 VPS；

Fabric；

配置管理工具（Chef、Puppet、Salt 和 Ansible）；

Vagrant。

彻底弄清楚部署的主要步骤：数据库、静态文件、依赖、如何定制设定，等等。

尽早搭建 CI 服务器，运行测试不能只靠自律。

相关内容：第 9、11、24 章，附录 C。

H.4　通用的测试最佳实践

每个测试只能测试一件事。

应用的一个源码文件对应一个测试文件。

不管函数和类多么简单，都至少要编写一个占位测试。

“别测试常量”。

尝试测试行为，而不是实现方式。

不能顺着代码的逻辑思考，还要考虑边缘情况和有错误的情况。

相关内容：第 4、13、14 章。

H.5　Selenium/功能测试最佳实践

相较隐式等待，多使用显式等待和交互等待模式。

避免编写重复的测试代码——可以在基类中定义辅助方法，也可以使用页面模式。

避免重复测试同一个功能。如果测试中有耗时操作（例如登录），可以找一种方法在其他测试中跳过这一步（但要小心看起来无关的功能相互之间的异常交互）。

使用 BDD 工具，作为组织功能测试的另一种方式。

相关内容：第 21、24、25 章。

H.6　由外而内，测试隔离与整合测试，模拟技术

别忘了编写测试的初衷。

确保正确性，避免回归。

有利于写出简洁可维护的代码。

实现一种快速高效的工作流程。

记住这几点之后，再看不同的测试类型以及各自的优缺点。

功能测试

从用户的角度出发，最大程度上保证应用可以正常运行。

但反馈循环用时长。

而且无法帮助我们写出简洁的代码。

整合测试（依赖于 ORM 或 Django 测试客户端等）

编写速度快。

易于理解。

发现任何集成问题都会提醒你。

但并不总能得到好的设计（这取决于你自己）。

而且一般运行速度比隔离测试慢。

隔离测试（使用驭件）

涉及的工作量最大。

可能难以阅读和理解。

但这种测试最能引导你实现更好的设计。

而且运行速度最快。

如果你发现编写测试时要使用很多驭件，而且感觉很痛苦，那么记得要“倾听测试的心声”——使用模拟技术写出的丑陋测试试图告诉你，代码可以简化。

相关内容：第 22、23、26 章。

附录 I　接下来做什么

下面是我建议你接下来可以研究的一些事情，目的是提升测试技能，以及把（写作本书时的）新技术应用到 Web 开发中。

如果以后不再添加附录，我希望至少为每个话题写一篇博客文章，也编写一些示例代码。所以请读者一定要访问 http://www.obeythetestinggoat.com，看有没有更新。

或者你可以抢在我前面，自己写博客文章，记录你尝试其中任何一件事的过程。

我很乐意回答问题以及为这些话题提供提示和指引，所以如果你想尝试做某件事，但卡住了，别犹豫，联系我吧，电子邮件地址是 obeythetestinggoat@gmail.com。

I.1　提醒——站内提醒以及邮件提醒

如果有人把清单分享给某个用户，能提醒这个用户就好了。

你可以使用 django-notifications，在用户下次刷新页面时显示一个消息。在这个功能的功能测试中需要两个浏览器。

或者，也可以通过电子邮件提醒。研究一下 Django 对测试电子邮件的支持，然后你会发现，测试的过程需要发送真的电子邮件。使用 IMAPClient 库可以从网页邮件测试账户中获取真实的电子邮件。

I.2　换用Postgres

SQLite 对小型数据库来说很好，但如果有不止一个 Web 职程处理请求，那它就无法胜任了。现在，Postgres 是大家最喜欢的数据库，请弄清怎么安装及配置 Postgres。

你要找一个文件保存本地、过渡服务器和生产服务器中 Postgres 的用户名和密码。因为出于安全的考虑，你或许不想把这些信息放入代码仓库。你得找到一种方法，让部署脚本把这些信息传入命令行。流行的解决方法之一是在环境变量中存储这些信息。

你可以实验一下，看单元测试在 SQLite 中运行比在 Postgres 中运行快多少。为此，你可以在本地设备中使用 SQLite，仅做测试，但在 CI 服务器中使用 Postgres。

I.3　在不同的浏览器中运行测试

Selenium 支持各种浏览器，包括 Chrome 和 Internet Exploder。尝试在这两种浏览器中运行功能测试组件，看看有没有什么异常表现。

你还应该试试无界面浏览器，比如 PhantomJS。

根据我的经验，在不同的浏览器中测试能暴露 Selenium 测试中的各种条件竞争，而且可能还要更多地使用交互等待模式（尤其是在 PhantomJS 中）。

I.4　400和500测试

专业的网站需要漂亮的错误页面。400 页面的测试方法很简单，但如果想测试 500 页面，或许得编写一个故意抛出异常的视图。

I.5　Django管理后台

假设有个用户发邮件声称某个匿名清单是他的。为此，想实现一种手动解决方案，由网站的管理员在管理后台中手动修改记录。

弄清楚怎么启用和使用管理后台。编写一个功能测试，首先由一个未登录的普通用户创建一个清单，然后管理员登录，进入管理后台，把这个清单指派给这个用户，然后这个用户即可在“My Lists”页面看到这个清单。

I.6　编写一些安全测试

扩展针对登录、“My Lists”页面和分享功能的测试，看看需要怎么编写测试确保用户只能做有权限做的事情。

I.7　测试优雅降级

如果 Persona 不可用会发生什么？是否至少可以向用户显示一个致歉消息？

提示：模拟 Persona 服务不可用的方式之一是修改主机文件（路径是 /etc/hosts 或 c:\Windows\Sytem32\drivers\etc）。记得在测试的 tearDown 方法中撤销改动。

要同时考虑服务器端和客户端。

I.8　缓存和性能测试

弄清楚如何安装和配置 memcached，以及如何使用 Apache 的 ab 工具运行性能测试。在有缓存和没有缓存两种情况下，网站的性能如何？你能否编写一个自动化测试，如果检测到没启用缓存就失败？应该怎么处理可怕的缓存失效问题？测试能否帮你确认缓存失效逻辑是可靠的？

I.9　JavaScript MVC框架

现今，在客户端实现“模型 - 视图 - 控制器”（Model-View-Controller，MVC）模式的 JavaScript 库比较流行。这种库喜欢使用待办事项清单应用做演示，所以把这个网站改写成单页网站应该很容易。在单页网站中，添加清单的所有操作都由 JavaScript 代码完成。

选一个框架，Backbone.js 或 Angular.js，探究一下怎么实现。在各种框架中编写单元测试都有各自的方式。学习一种方式，一直使用下去，看你是否喜欢。

I.10　异步和websocket

假设两个用户同时编辑同一个清单，如果能看到实时更新，即一个用户添加待办事项之后，另一个用户立即就能看到，是不是很棒？这种功能可以通过使用 websocket 在客户端和服务器之间建立持久连接实现。

研究一种 Python 异步 Web 服务器，Tornado、gevent 或 Twisted，看你能否用它实现动态提醒。

测试时需要两个浏览器实例（就像在分享功能的测试中一样），检查不刷新页面的情况下，操作提醒是否会出现在另一个浏览器实例中。

I.11　换用py.test

使用 py.test 编写单元测试不用写那么多样板代码。尝试使用 py.test 改写一些单元测试。或许需要使用插件才能和 Django 无缝配合。

I.12　试试coverage.py

Ned Batchelder 开发的 coverage.py 能告诉你测试的覆盖度如何，即测试覆盖百分之多少的代码。目前，我们使用的是严格的 TDD，因此理论上覆盖度应该始终为 100%，不过再确认一下更好。而且对于没有从头开始编写测试的项目来说，这个工具是十分有用的。

I.13　客户端加密

这个比较有趣：如果用户太偏执，不再相信 NSA（美国国家安全局），觉得把清单放在云端不安全该怎么办？你能不能使用 JavaScript 构建一个加密系统，在待办事项发给服务器之前，让用户输入密码，加密自己的清单。

针对这种功能的测试，可以这么写：管理员登录 Django 管理后台，查看用户的清单，确认清单中的待办事项在数据库中是否使用密文存储。

I.14　你的建议

你觉得我应该在这个附录中写些什么？提些建议吧！

附录 J　示例源码

本书的所有示例代码都在我的一个 GitHub 仓库中（https://github.com/hjwp/book-example/）。如果你想对比你我的代码，可以看一下那个仓库。

每一章都有自己的分支，分支名与章序一样，例如 chapter_01。

注意，各分支包含对应那一章的所有提交，因此是那一章结束时最终得到的代码。

此外，各章分别的代码示例也可至图灵社区下载，详情请见 http://www.ituring.com.cn/2052 “随书下载”处。

J.1　使用Git检查自己的进度

如果你想锻炼自己的 Git 技能，可以把我的仓库添加为远程仓库：

git remote add harry https://github.com/hjwp/book-example.git
git fetch harry

若想查看第 4 章结束后你我的代码有什么差异，可以这样做：

git diff harry/chapter_philosophy_and_refactoring

Git 能处理多个远程仓库，即便你已经把自己的代码推送到 GitHub 或 Bitbucket，依然可以这么做。

注意，类中方法的顺序在你我的代码中可能不完全相同，这可能导致差异不好读。

J.2　下载各章代码的ZIP文件

如果鉴于某些原因，阅读某一章时你想“从头做起”，或者跳过某一章，1 抑或你就是不想使用 Git，可以下载代码的 ZIP 文件，详情请见 http://www.ituring.com.cn/2052“随书下载”处。

1我不建议你跳着读。我在撰写时并没有考虑各章的独立性，后面的章节要依赖前面的章节，跳着读可能会更让你不明所以……

J.3　不要完全依赖我的代码

除非真的卡住，不知道怎么做了，否则不要偷看答案。前面说过，自己动手调试错误能学到很多，而且当你自己开发时，可没有我的仓库供你对比，也没有现成的答案供你参考。

参考书目

[dip] Mark Pilgrim, Dive Into Python

[lpthw] Zed A. Shaw, Learn Python The Hard Way

[iwp] Al Sweigart, Invent Your Own Computer Games With Python

[tddbe] Kent Beck, TDD By Example, Addison-Wesley

[refactoring] Martin Fowler, Refactoring, Addison-Wesley

[seceng] Ross Anderson, Security Engineering, Second Edition, Addison-Wesley

[jsgoodparts] Douglas Crockford, JavaScript: The Good Parts，O'Reilly

[twoscoops] Daniel Greenfield and Audrey Roy, Two Scoops of Django

[mockfakestub] Emily Bache, Mocks, Fakes and Stubs

[GOOSGBT] Steve Freeman and Nat Pryce, Growing Object-Oriented Software Guided by Tests, Addison-Wesley

作者简介

Harry 的童年很美好，他在 Thomson T-07（当时在法国很流行，按键后会发出“啵噗”声）这种 8 位电脑上摆弄 BASIC，长大后做了几年经管顾问，但完全不快乐。而后他发现了自己真正的极客潜质，又很幸运地遇到了一些极限编程狂热者，参与开发了电子制表软件的先驱 Resolver One，不过很可惜，这个软件现在已经退出历史舞台。他目前在 PythonAnywhere LLP 公司工作，而且在各种演讲、研讨会和开发者大会上积极推广 TDD。

封面介绍

本书封面上的动物是开司米山羊。虽然所有山羊都长有开司米，但人类只选择培育这种山羊，产出能满足商用数量的开司米，所以一般只有这种山羊叫“开司米山羊”。因此，开司米山羊是一种驯养的家山羊。

开司米山羊长有一层异常柔软顺滑的内层绒毛，外覆一层粗糙的羊毛——这就是山羊的两层羊毛。开司米在冬季长成，目的是补充外层羊毛（这种毛叫“针毛”）的御寒能力。开司米中毛发的卷曲量决定了它的重量和保暖性能。

“开司米”这个名字出自印度次大陆上的克什米尔山谷地区。在这一地区，纺织品已经出现几千年了。现在的克什米尔地区，开司米山羊数量不断减少，所以不再出口开司米纤维。现在，大多数开司米毛织品都出自阿富汗、伊朗、蒙古国和印度，以及占主导地位的中国。

开司米山羊的羊毛有多种颜色和颜色搭配。雄性和雌性都长有犄角，夏季可用于散热，干农活时主人也能用它们更好地控制其他山羊。

封面图片出自 Wood 的 Animate Creation 一书。

看完了

如果您对本书内容有疑问，可发邮件至contact@turingbook.com，会有编辑或作译者协助答疑。也可访问图灵社区，参与本书讨论。

如果是有关电子书的建议或问题，请联系专用客服邮箱：ebook@turingbook.com。

在这里可以找到我们：

微博 @图灵教育 : 好书、活动每日播报

微博 @图灵社区 : 电子书和好文章的消息

微博 @图灵新知 : 图灵教育的科普小组

微信 图灵访谈 : ituring_interview，讲述码农精彩人生

微信 图灵教育 : turingbooks 　　

091507240605ToBeReplacedWithUserId

版权信息

书名：Python计算机视觉编程

作者：（美）索利姆（Solem, J. E.）

译者：朱文涛，袁勇

ISBN：978-7-115-35232-3

本书由北京图灵文化发展有限公司发行数字版。版权所有，侵权必究。

您购买的图灵电子书仅供您个人使用，未经授权，不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟，与我们共同保护知识产权。

如果购买者有侵权行为，我们可能对该用户实施包括但不限于关闭该帐号等维权措施，并可能追究法律责任。

091507240605ToBeReplacedWithUserId

版权声明

O'Reilly Media, Inc. 介绍

业界评论

推荐序

前言

先决条件和概述

读者须知

本书内容

各章概览

计算机视觉简介

Python和NumPy

排版约定

使用代码示例

Safari® Books Online

联系我们

致谢

第 1 章　基本的图像操作和处理

1.1　PIL：Python图像处理类库

1.1.1　转换图像格式

1.1.2　创建缩略图

1.1.3　复制和粘贴图像区域

1.1.4　调整尺寸和旋转

1.2　Matplotlib

1.2.1　绘制图像、点和线

1.2.2　图像轮廓和直方图

1.2.3　交互式标注

1.3　NumPy

1.3.1　图像数组表示

1.3.2　灰度变换

1.3.3　图像缩放

1.3.4　直方图均衡化

1.3.5　图像平均

1.3.6　图像的主成分分析（PCA）

1.3.7　使用pickle模块

1.4　SciPy

1.4.1　图像模糊

1.4.2　图像导数

1.4.3　形态学：对象计数

1.4.4　一些有用的SciPy模块

1.5　高级示例：图像去噪

练习

代码示例约定

第 2 章　局部图像描述子

2.1　Harris角点检测器

在图像间寻找对应点

2.2　SIFT（尺度不变特征变换）

2.2.1　兴趣点

2.2.2　描述子

2.2.3　检测兴趣点

2.2.4　匹配描述子

2.3　匹配地理标记图像

2.3.1　从Panoramio下载地理标记图像

2.3.2　使用局部描述子匹配

2.3.3　可视化连接的图像

练习

第 3 章　图像到图像的映射

3.1　单应性变换

3.1.1　直接线性变换算法

3.1.2　仿射变换

3.2　图像扭曲

3.2.1　图像中的图像

3.2.2　分段仿射扭曲

3.2.3　图像配准

3.3　创建全景图

3.3.1　RANSAC

3.3.2　稳健的单应性矩阵估计

3.3.3　拼接图像

练习

第 4 章　照相机模型与增强现实

4.1　针孔照相机模型

4.1.1　照相机矩阵

4.1.2　三维点的投影

4.1.3　照相机矩阵的分解

4.1.4　计算照相机中心

4.2　照相机标定

4.2.1　一个简单的标定方法

4.3　以平面和标记物进行姿态估计

4.4　增强现实

4.4.1　PyGame和PyOpenGL

4.4.2　从照相机矩阵到OpenGL格式

4.4.3　在图像中放置虚拟物体

4.4.4　综合集成

4.4.5　载入模型

练习

第 5 章　多视图几何

5.1　外极几何

5.1.1　一个简单的数据集

5.1.2　用Matplotlib绘制三维数据

5.1.3　计算F：八点法

5.1.4　外极点和外极线

5.2　照相机和三维结构的计算

5.2.1　三角剖分

5.2.2　由三维点计算照相机矩阵

5.2.3　由基础矩阵计算照相机矩阵

5.3　多视图重建

5.3.1　稳健估计基础矩阵

5.3.2　三维重建示例

5.3.3　多视图的扩展示例

5.4　立体图像

计算视差图

练习

第 6 章　图像聚类

6.1　K-means聚类

6.1.1　SciPy聚类包

6.1.2　图像聚类

6.1.3　在主成分上可视化图像

6.1.4　像素聚类

6.2　层次聚类

图像聚类

6.3　谱聚类

练习

第 7 章　图像搜索

7.1　基于内容的图像检索

从文本挖掘中获取灵感——矢量空间模型

7.2　视觉单词

创建词汇

7.3　图像索引

7.3.1　建立数据库

7.3.2　添加图像

7.4　在数据库中搜索图像

7.4.1　利用索引获取候选图像

7.4.2　用一幅图像进行查询

7.4.3　确定对比基准并绘制结果

7.5　使用几何特性对结果排序

7.6　建立演示程序及Web应用

7.6.1　用CherryPy创建Web应用

7.6.2　图像搜索演示程序

练习

第 8 章　图像内容分类

8.1　K邻近分类法（KNN）

8.1.1　一个简单的二维示例

8.1.2　用稠密SIFT作为图像特征

8.1.3　图像分类：手势识别

8.2　贝叶斯分类器

用PCA降维

8.3　支持向量机

8.3.1　使用LibSVM

8.3.2　再论手势识别

8.4　光学字符识别

8.4.1　训练分类器

8.4.2　选取特征

8.4.3　多类支持向量机

8.4.4　提取单元格并识别字符

8.4.5　图像校正

练习

第 9 章　图像分割

9.1　图割（Graph Cut）

9.1.1　从图像创建图

9.1.2　用户交互式分割

9.2　利用聚类进行分割

9.3　变分法

练习

第 10 章　OpenCV

10.1　OpenCV的Python接口

10.2　OpenCV基础知识

10.2.1　读取和写入图像

10.2.2　颜色空间

10.2.3　显示图像及结果

10.3　处理视频

10.3.1　视频输入

10.3.2　将视频读取到NumPy数组中

10.4　跟踪

10.4.1　光流

10.4.2　Lucas-Kanade算法

10.5　更多示例

10.5.1　图像修复

10.5.2　利用分水岭变换进行分割

10.5.3　利用霍夫变换检测直线

练习

附录 A　安装软件包

A.1　NumPy和SciPy

A.1.1　Windows

A.1.2　Mac OS X

A.1.3　Linux

A.2　Matplotlib

A.3　PIL

A.4　LibSVM

A.5　OpenCV

A.5.1　Windows 和 Unix

A.5.2　Mac OS X

A.5.3　Linux

A.6　VLFeat

A.7　PyGame

A.8　PyOpenGL

A.9　Pydot

A.10　Python-graph

A.11　Simplejson

A.12　PySQLite

A.13　CherryPy

附录 B　图像集

B.1　Flickr

B.2　Panoramio

B.3　牛津大学视觉几何组

B.4　肯塔基大学识别基准图像

B.5　其他

B.5.1　Prague Texture Segmentation Datagenerator与基准

B.5.2　微软剑桥研究院Grab Cut数据集

B.5.3　Caltech 101

B.5.4　静态手势数据库

B.5.5　Middlebury Stereo数据集

附录 C　图片来源

C.1　来自Flickr的图像

C.2　其他图像

C.3　插图

参考文献

版权声明

© 2012 by O'Reilly Media, Inc.

Simplified Chinese Edition, jointly published by O'Reilly Media, Inc. and Posts & Telecom Press, 2014. Authorized translation of the English edition, 2014 O'Reilly Media, Inc., the owner of all rights to publish and sell the same.

All rights reserved including the rights of reproduction in whole or in part in any form.

英文原版由 O'Reilly Media, Inc. 出版 2012。

简体中文版由人民邮电出版社出版，2014。英文原版的翻译得到 O'Reilly Media, Inc. 的授权。此简体中文版的出版和销售得到出版权和销售权的所有者 —— O'Reilly Media, Inc. 的许可。

版权所有，未得书面许可，本书的任何部分和全部不得以任何形式重制。

O'Reilly Media, Inc. 介绍

O'Reilly Media 通过图书、杂志、在线服务、调查研究和会议等方式传播创新知识。自 1978 年开始，O'Reilly 一直都是前沿发展的见证者和推动者。超级极客们正在开创着未来，而我们关注真正重要的技术趋势——通过放大那些“细微的信号”来刺激社会对新科技的应用。作为技术社区中活跃的参与者，O'Reilly 的发展充满了对创新的倡导、创造和发扬光大。

O'Reilly 为软件开发人员带来革命性的“动物书”；创建第一个商业网站（GNN）；组织了影响深远的开放源代码峰会，以至于开源软件运动以此命名；创立了 Make 杂志，从而成为 DIY 革命的主要先锋；公司一如既往地通过多种形式缔结信息与人的纽带。O'Reilly 的会议和峰会集聚了众多超级极客和高瞻远瞩的商业领袖，共同描绘出开创新产业的革命性思想。作为技术人士获取信息的选择，O'Reilly 现在还将先锋专家的知识传递给普通的计算机用户。无论是通过书籍出版、在线服务还是面授课程，每一项 O'Reilly 的产品都反映了公司不可动摇的理念——信息是激发创新的力量。

业界评论

“O'Reilly Radar 博客有口皆碑。”

——Wired

“O'Reilly 凭借一系列（真希望当初我也想到了）非凡想法建立了数百万美元的业务。”

——Business 2.0

“O'Reilly Conference 是聚集关键思想领袖的绝对典范。”

——CRN

“一本 O'Reilly 的书就代表一个有用、有前途、需要学习的主题。”

——Irish Times

“Tim 是位特立独行的商人，他不光放眼于最长远、最广阔的视野，并且切实地按照 Yogi Berra 的建议去做了：‘如果你在路上遇到岔路口，走小路（岔路）。'回顾过去 Tim 似乎每一次都选择了小路，而且有几次都是一闪即逝的机会，尽管大路也不错。”

——Linux Journal

推荐序

计算机视觉是一门实践性很强的课程，虽然已经有了不少教科书，但大多数都只重视其中的理论和算法，很少有实践指导书。因而对于学习者而言，如果希望在实践中学习，往往需要编写大量的程序。在这方面，本书的出版可以算是一个有益的补充，相信本书将成为计算机视觉学习者的一个重要参考。

作为一本面向计算机视觉编程的书，本书涉及了这一学科中相对成熟并且被以往实践验证有效的部分典型算法，因而具有很好的实用性。例如第 2 章描述子部分选择了 Harris 角点检测器和 SIFT 描述子及其实现加以介绍；第 3 章则以全景图的创建为例，给出了 RANSAC 的实现；第 9 章图像分割中讨论了 Graph Cut 的实现等。这些方法大多数具有很好的通用性，因而为读者提供了一种实现范例。

本书的另一个特点是对介绍的单一方法，通过综合运用提升学习者灵活应用这些方法的能力。例如第 4 章给出的增强现实的例子，以及第 8 章给出的图像校正的例子。这些例子能够帮助进一步提升学习者对前述方法的感性认识。

与早期计算机视觉领域多数程序都是由 C/C++ 写就的情形不同。随着计算机硬件速度越来越快，研究者在考虑选择实现算法语言的时候会更多地考虑编写代码的效率和易用性，而不是像早年那样把算法的执行效率放在首位。这直接导致近年来越来越多的研究者选择 Python 来实现算法。与此同时，Python 的开放性使不同领域的研究者能够有机会在 Python 中加入他们需要的特性，甚至可以纳入 Python 的标准库，这也大大吸引了众多研究者对 Python 的参与。

本书的第三个特点是提供了与 OpenCV 接口的介绍。这为利用 OpenCV 中的资源提供了方便的途径。

今天在计算机视觉领域，越来越多的研究者使用 Python 开展研究。本书中文版的出版一方面能够鼓励更多的研究者采用这一语言，另一方面则为 Python 的学习者提供了一种尝试不同领域算法的机会。

2014 年 4 月 21 日

前言

今天，图像和视频无处不在，在线照片分享网站和社交网络上的图像有数十亿之多。几乎对于任意可能的查询图像，搜索引擎都会给用户返回检索的图像。实际上，几乎所有手机和计算机都有内置的摄像头，所以在人们的设备中，有几 G 的图像和视频是一件很寻常的事。

计算机视觉就是用计算机编程，并设计算法来理解在这些图像中有什么。计算机视觉的有力应用有图像搜索、机器人导航、医学图像分析、照片管理等。

本书旨在为计算机视觉实战提供一个简单的切入点，让学生、研究者和爱好者充分理解其基础理论和算法。本书中的编程语言是 Python，Python 自带了很多可以免费获取的强大而便捷的图像处理、数学计算和数据挖掘模块，可以免费获取。

写作本书的时候，我遵循了以下原则。

鼓励探究式学习，让读者在阅读本书的时候，在计算机上跟着书中示例进行练习。

推广和使用免费且开源的软件，设立较低的学习门槛。显然，我们选择了 Python。

保持内容完整性和独立性。本书没有介绍计算机视觉的全部内容，而是完整呈现并解释所有代码。你应该能够重现这些示例，并可以直接在它们之上构建其他应用。

内容追求广泛而非详细，且相对于理论更注重鼓舞和激励。

总之，如果你对计算机视觉编程感兴趣，希望它能给你带来启发。

先决条件和概述

本书主要针对各种应用和问题探讨理论及算法，下面简单概括一下。

读者须知

基本的编程经验。你需要会使用编辑器，能够运行脚本，知道如何构建代码以及基本数据类型。熟悉 Python，或诸如 Ruby、Matlab 等其他脚本语言，这也会对你理解本书有所帮助。

数学基础。如果你知道矩阵、向量、矩阵乘法、标准数学函数以及导数和梯度等概念，这对于充分利用其中示例非常有益。对于一些较高级的数学例子，你可以轻松跳过。

本书内容

用 Python 对图像进行实战编程。

现实世界中各种应用背后的计算机视觉技术。

一些基本算法，以及如何实现并应用这些算法。

本书中的代码示例会向你展示物体识别、基于内容的图像检索、图像搜索、光学字符识别、光流、跟踪、三维重建、立体成像、增强现实、姿态估计、全景创建、图像分割、降噪、图像分组等内容。

各章概览

第 1 章“基本的图像操作和处理”介绍用来处理图像的基本工具及本书用到的核心 Python 模块，同时涵盖了很多贯穿全书的基础示例。

第 2 章“局部图像描述子”讲解检测图像兴趣点的方法，以及怎样使用它们在图像间寻找相应点和区域。

第 3 章“图像到图像的映射”描述图像间基本的变换及其计算方法。涵盖从图像扭曲到创建全景图像的示例。

第 4 章“照相机模型与增强现实”介绍如何对照相机建模、生成从三维空间到图像特征的图像投影，并估计照相机视点。

第 5 章“多视图几何”讲解如何对具有相同场景、多视图几何基本面的图像进行处理，以及怎样从图像计算三维重建。

第 6 章“图像聚类”介绍一些聚类方法，并展示如何基于相似性或内容对图像进行分组和组织。

第 7 章“图像搜索”展示如何建立有效的图像检索技术，以便能够存储图像的表示，并基于图像的视觉内容搜索图像。

第 8 章“图像内容分类”描述了图像内容分类算法，以及怎样使用它们识别图像中的物体。

第 9 章“图像分割”介绍了通过聚类、用户交互或图像模型，将图像分割成有意义区域的不同技术。

第 10 章“OpenCV”展示怎样使用常用的 OpenCV 计算机视觉库 Python 接口，以及如何处理视频及摄像头的输入。

本书结尾有参考文献。文献条目的引用用方括号表示，如 [20]。

计算机视觉简介

计算机视觉是一门对图像中信息进行自动提取的学科。信息的内容相当广泛，包括三维模型、照相机位置、目标检测与识别，以及图像内容的分组与搜索等。本书中，我们使用广义的计算机视觉概念，包括图像扭曲、降噪和增强现实等1。

1这些例子生成新的图像，并且比实际地从图像中提取信息需要更多的图像处理。

计算机视觉有时试图模拟人类视觉，有时使用数据和统计方法，而有时几何是解决问题的关键。在本书中，我们试图对此进行全面介绍。

实用计算机视觉混合了编程、建模和数学技巧，有时很难掌握。我本着“力求简单，又不影响理解”的精神，有意用最少的理论来展示这些内容。书中对于数学知识的介绍是为了帮助读者理解算法，有些章（主要是第 4 章和第 5 章）无法避免地涉及很多数学理论。只要读者愿意，可以跳过这些数学理论，直接使用示例代码。

Python和NumPy

Python 是一门编程语言，其使用贯穿了全书的示例代码。Python 是一种简洁明了的语言，对于输入/输出、数字、图像及绘图都具有良好的支持。这门语言的一些特性需要我们逐渐适应，比如缩进和紧凑语法。要运行代码示例，你需要安装 Python 2.6 或之后的版本，因为只有这些版本才提供本书中用到的很多工具包。Python 3.x 版本与 2.x 版本有很多语法差异，并且不兼容 2.x 版本，也不兼容我们所需的工具包。

熟悉一些基本 Python 操作会更容易理解这些内容。对于 Python 初学者，我建议读一下 Mark Lutz 的书 Learning Python[20] 和 http://www.python.org/ 上的在线文档。

在进行计算机视觉编程的时候，我们需要在向量、矩阵的表示上进行操作，这可以通过 Python 的 NumPy 模块处理；在该模块中，向量和矩阵是用 array 类型表示的。对于图像，我们也将采用这种类型的表示。Travis Oliphant 的免费电子书 Guide to NumPy[24] 是一本不错的 NumPy 参考手册；http://numpy.scipy.org/ 上的文档对于刚接触 NumPy 的读者来说是一个很好的起点。对于结果的可视化，我们会用到 Matplotlib 模块；而对于更高级的数学，我们会用到 SciPy 模块。这些就是你会用到的核心模块，详见第 1 章。

除了这些核心模块，对于某些特殊目的，比如读取 JSON 或 XML、载入并保存数据、生成图、图形编程、Web 演示、分类器等，我们还会用到很多其他免费模块。这些模块只有在特殊的应用和演示中才需要，如果你对于某种应用不感兴趣，可以跳过。

这里有必要提一下 IPython，它是一个交互式 Python 壳，使调试和实验变得更简单。对应文档及下载地址见 http://ipython.org/。

排版约定

代码如下：

# 一些点
x = [100,100,400,400]
y = [200,500,200,500]

# 绘制这些点
plot(x,y)

本书中的字体约定如下。

楷体

用于定义。

等宽字体（Constant width）

用于函数、Python 模块及代码示例，也用于控制台打印输出。

数学公式为内联式（如 f(x)=w^Tx + b），或者单独居中：

只有需要参考的时候我们才对公式进行编号。

在介绍数学知识的部分，标量使用小写字母（s, r, λ, θ…），矩阵（包括图像数组 I）使用大写加粗（A, V, H,…），向量则小写加粗（t, c,…），二维（图像）和三维中的点分别用 x=[x, y] 和 X[X, Y, Z] 表示。

使用代码示例

本书旨在帮助你完成工作。通常，你可以在程序或文档中使用本书的代码。你不必联系我们请求许可，除非你要复制本书的大量代码。例如，用本书的几段代码编写程序不需要获得许可；售卖或再分发 O'Reilly 的图书示例光盘需要获得许可；引用本书和示例代码回答问题不需要获得许可；将本书中的大量示例代码纳入产品文档中需要获得许可。

我们对你在使用时声明引用信息表示感谢，但不强制要求。引用信息通常包括标题、作者、出版商和 ISBN，例如“Programming Computer Vision with Python by Jan Erik Solem (O'Reilly). Copyright © 2012 Jan Erik Solem, 978-1-449-31654-9.”

如果你觉得使用的代码示例超出合理引用或上述许可范围，请随时和我们联系：

permissions@oreilly.com。

Safari® Books Online

Safari Books Online（www.safaribooksonline.com）是应需而变的数字图书馆。它同时以图书和视频的形式出版世界顶级技术和商务作家的专业作品。

Safari Books Online 是技术专家、软件开发人员、Web 设计师、商务人士和创意人士开展调研、解决问题、学习和认证培训的第一手资料。

对于组织团体、政府机构和个人，Safari Books Online 提供各种产品组合和灵活的定价策略。用户可通过一个功能完备的数据库检索系统访问 O'Reilly Media、Prentice Hall Professional、Addison-Wesley Professional、Microsoft Press、Sams、Que、Peachpit Press、Focal Press、Cisco Press、John Wiley & Sons、Syngress、Morgan Kaufmann、IBM Redbooks、Packt、Adobe Press、FT Press、Apress、Manning、New Riders、McGraw-Hill、Jones & Bartlett、Course Technology 以及其他几十家出版社的上千种图书、培训视频和正式出版之前的书稿。要了解 Safari Books Online 的更多信息，我们网上见。

联系我们

请把对本书的评价和问题发给出版社。

美国：

O'Reilly Media, Inc.

1005 Gravenstein Highway North

Sebastopol, CA 95472

中国：

北京市西城区西直门南大街 2 号成铭大厦 C 座 807 室（100035）

奥莱利技术咨询（北京）有限公司

O'Reilly 的每一本书都有专属网页，你可以在那儿找到本书的相关信息，包括勘误表、示例代码以及其他信息。本书的网站地址是：

http://shop.oreilly.com/product/0636920022923.do

对于本书的评论和技术性问题，请发送电子邮件到：

bookquestions@oreilly.com

要了解更多 O'Reilly 图书、培训课程、会议和新闻的信息，请访问以下网站：

http://www.oreilly.com

我们在 Facebook 的地址如下：

http://facebook.com/oreilly

请关注我们的 Twitter 动态：

http://twitter.com/oreillymedia

我们的 YouTube 视频地址如下：

http://www.youtube.com/oreillymedia

致谢

感谢参与本书写作及出版的每一个人，感谢整个 O'Reilly 团队给予的帮助。特别要感谢 Andy Oram（O'Reilly）的编辑工作，以及 Paul Anagnostopoulos（Windfall Software）的高效出版工作。

很多人对于我分享在网上的书稿发表了评论。感谢 Klas Josephson 和 Håkan Ardö 就本书细致给出意见及反馈，而 Fredrik Kahl 和 Pau Gargallo 帮助进行了核实。感谢所有读者的鼓励，以及对本书文本和代码示例的改进。很多陌生人通过邮件分享了对书稿的看法，这对我而言是一个巨大的动力。

最后，感谢朋友和家人对我夜以继日写作的理解和支持。特别要感谢长久以来一直支持着我的妻子 Sara。

第 1 章　基本的图像操作和处理

本章讲解操作和处理图像的基础知识，将通过大量示例介绍处理图像所需的 Python 工具包，并介绍用于读取图像、图像转换和缩放、计算导数、画图和保存结果等的基本工具。这些工具的使用将贯穿本书的剩余章节。

1.1　PIL：Python图像处理类库

PIL（Python Imaging Library，图像处理类库）提供了通用的图像处理功能，以及大量有用的基本图像操作，比如图像缩放、裁剪、旋转、颜色转换等。PIL 是免费的，可以从 http://www.pythonware.com/products/pil/ 下载。

利用 PIL 中的函数，我们可以从大多数图像格式的文件中读取数据，然后写入最常见的图像格式文件中。PIL 中最重要的模块为 Image。要读取一幅图像，可以使用：

from PIL import Image

pil_im = Image.open('empire.jpg')

上述代码的返回值 pil_im 是一个 PIL 图像对象。

图像的颜色转换可以使用 convert() 方法来实现。要读取一幅图像，并将其转换成灰度图像，只需要加上 convert('L')，如下所示：

pil_im = Image.open('empire.jpg').convert('L')

在 PIL 文档中有一些例子，参见 http://www.pythonware.com/library/pil/handbook/index.htm。这些例子的输出结果如图 1-1 所示。

图 1-1：用 PIL 处理图像的例子

1.1.1　转换图像格式

通过 save() 方法，PIL 可以将图像保存成多种格式的文件。下面的例子从文件名列表（filelist）中读取所有的图像文件，并转换成 JPEG 格式：

from PIL import Image
import os

for infile in filelist:
  outfile = os.path.splitext(infile)[0] + ".jpg"
  if infile != outfile:
    try:
      Image.open(infile).save(outfile)
    except IOError:
      print "cannot convert", infile

PIL 的 open() 函数用于创建 PIL 图像对象，save() 方法用于保存图像到具有指定文件名的文件。除了后缀变为“.jpg”，上述代码的新文件名和原文件名相同。PIL 是个足够智能的类库，可以根据文件扩展名来判定图像的格式。PIL 函数会进行简单的检查，如果文件不是 JPEG 格式，会自动将其转换成 JPEG 格式；如果转换失败，它会在控制台输出一条报告失败的消息。

本书会处理大量图像列表。下面将创建一个包含文件夹中所有图像文件的文件名列表。首先新建一个文件，命名为 imtools.py，来存储一些经常使用的图像操作，然后将下面的函数添加进去：

import os
def get_imlist(path):

""" 返回目录中所有JPG 图像的文件名列表"""

return [os.path.join(path,f) for f in os.listdir(path) if f.endswith('.jpg')]

现在，回到 PIL。

1.1.2　创建缩略图

使用 PIL 可以很方便地创建图像的缩略图。thumbnail() 方法接受一个元组参数（该参数指定生成缩略图的大小），然后将图像转换成符合元组参数指定大小的缩略图。例如，创建最长边为 128 像素的缩略图，可以使用下列命令：

pil_im.thumbnail((128,128))

1.1.3　复制和粘贴图像区域

使用 crop() 方法可以从一幅图像中裁剪指定区域：

box = (100,100,400,400)
region = pil_im.crop(box)

该区域使用四元组来指定。四元组的坐标依次是（左，上，右，下）。PIL 中指定坐标系的左上角坐标为（0，0）。我们可以旋转上面代码中获取的区域，然后使用 paste() 方法将该区域放回去，具体实现如下：

region = region.transpose(Image.ROTATE_180)
pil_im.paste(region,box)

1.1.4　调整尺寸和旋转

要调整一幅图像的尺寸，我们可以调用 resize() 方法。该方法的参数是一个元组，用来指定新图像的大小：

out = pil_im.resize((128,128))

要旋转一幅图像，可以使用逆时针方式表示旋转角度，然后调用 rotate() 方法：

out = pil_im.rotate(45)

上述例子的输出结果如图 1-1 所示。最左端是原始图像，然后是灰度图像、粘贴有旋转后裁剪图像的原始图像，最后是缩略图。

1.2　Matplotlib

我们处理数学运算、绘制图表，或者在图像上绘制点、直线和曲线时，Matplotlib 是个很好的类库，具有比 PIL 更强大的绘图功能。Matplotlib 可以绘制出高质量的图表，就像本书中的许多插图一样。Matplotlib 中的 PyLab 接口包含很多方便用户创建图像的函数。Matplotlib 是开源工具，可以从 http://matplotlib.sourceforge.net/ 免费下载。该链接中包含非常详尽的使用说明和教程。下面的例子展示了本书中需要使用的大部分函数。

1.2.1　绘制图像、点和线

尽管 Matplotlib 可以绘制出较好的条形图、饼状图、散点图等，但是对于大多数计算机视觉应用来说，仅仅需要用到几个绘图命令。最重要的是，我们想用点和线来表示一些事物，比如兴趣点、对应点以及检测出的物体。下面是用几个点和一条线绘制图像的例子：

from PIL import Image
from pylab import *

# 读取图像到数组中
im = array(Image.open('empire.jpg'))

# 绘制图像
imshow(im)

# 一些点
x = [100,100,400,400]
y = [200,500,200,500]

# 使用红色星状标记绘制点
plot(x,y,'r*')

# 绘制连接前两个点的线
plot(x[:2],y[:2])

# 添加标题，显示绘制的图像
title('Plotting: "empire.jpg"')
show()

上面的代码首先绘制出原始图像，然后在 x 和 y 列表中给定点的 x 坐标和 y 坐标上绘制出红色星状标记点，最后在两个列表表示的前两个点之间绘制一条线段（默认为蓝色）。该例子的绘制结果如图 1-2 所示。show() 命令首先打开图形用户界面（GUI），然后新建一个图像窗口。该图形用户界面会循环阻断脚本，然后暂停，直到最后一个图像窗口关闭。在每个脚本里，你只能调用一次 show() 命令，而且通常是在脚本的结尾调用。注意，在 PyLab 库中，我们约定图像的左上角为坐标原点。

图像的坐标轴是一个很有用的调试工具；但是，如果你想绘制出较美观的图像，加上下列命令可以使坐标轴不显示：

axis('off')

上面的命令将绘制出如图 1-2 右边所示的图像。

图 1-2：Matplotlib 绘图示例。带有坐标轴和不带坐标轴的包含点和一条线段的图像

在绘图时，有很多选项可以控制图像的颜色和样式。最有用的一些短命令如表 1-1、表 1-2 和表 1-3 所示。使用方法见下面的例子：

plot(x,y)         # 默认为蓝色实线
plot(x,y,'r*')    # 红色星状标记
plot(x,y,'go-')   # 带有圆圈标记的绿线
plot(x,y,'ks:')   # 带有正方形标记的黑色点线

表1-1：用PyLab库绘图的基本颜色格式命令

颜色

'b'

蓝色

'g'

绿色

'r'

红色

'c'

青色

'm'

品红

'y'

黄色

'k'

黑色

'w'

白色

表1-2：用PyLab库绘图的基本线型格式命令

线型

'-'

实线

'--'

虚线

':'

点线

表1-3：用PyLab库绘图的基本绘制标记格式命令

标记

'.'

点

'o'

圆圈

's'

正方形

'*'

星形

'+'

加号

'x'

叉号

1.2.2　图像轮廓和直方图

下面来看两个特别的绘图示例：图像的轮廓和直方图。绘制图像的轮廓（或者其他二维函数的等轮廓线）在工作中非常有用。因为绘制轮廓需要对每个坐标 [x, y] 的像素值施加同一个阈值，所以首先需要将图像灰度化：

from PIL import Image
from pylab import *

# 读取图像到数组中
im = array(Image.open('empire.jpg').convert('L'))

# 新建一个图像
figure()
# 不使用颜色信息
gray()
# 在原点的左上角显示轮廓图像
contour(im, origin='image')
axis('equal')
axis('off')

像之前的例子一样，这里用 PIL 的 convert() 方法将图像转换成灰度图像。

图像的直方图用来表征该图像像素值的分布情况。用一定数目的小区间（bin）来指定表征像素值的范围，每个小区间会得到落入该小区间表示范围的像素数目。该（灰度）图像的直方图可以使用 hist() 函数绘制：

figure()
hist(im.flatten(),128)
show()

hist() 函数的第二个参数指定小区间的数目。需要注意的是，因为 hist() 只接受一维数组作为输入，所以我们在绘制图像直方图之前，必须先对图像进行压平处理。flatten() 方法将任意数组按照行优先准则转换成一维数组。图 1-3 为等轮廓线和直方图图像。

图 1-3：用 Matplotlib 绘制图像等轮廓线和直方图

1.2.3　交互式标注

有时用户需要和某些应用交互，例如在一幅图像中标记一些点，或者标注一些训练数据。PyLab 库中的 ginput() 函数就可以实现交互式标注。下面是一个简短的例子：

from PIL import Image
from pylab import *

im = array(Image.open('empire.jpg'))
imshow(im)
print 'Please click 3 points'
x = ginput(3)
print 'you clicked:',x
show()

上面的脚本首先绘制一幅图像，然后等待用户在绘图窗口的图像区域点击三次。程序将这些点击的坐标 [x, y] 自动保存在 x 列表里。

1.3　NumPy

NumPy（http://www.scipy.org/NumPy/）是非常有名的 Python 科学计算工具包，其中包含了大量有用的思想，比如数组对象（用来表示向量、矩阵、图像等）以及线性代数函数。NumPy 中的数组对象几乎贯穿用于本书的所有例子中 1 数组对象可以帮助你实现数组中重要的操作，比如矩阵乘积、转置、解方程系统、向量乘积和归一化，这为图像变形、对变化进行建模、图像分类、图像聚类等提供了基础。

1PyLab 实际上包含 NumPy 的一些内容，如数组类型。这也是我们能够在 1.2 节使用数组类型的原因。

NumPy 可以从 http://www.scipy.org/Download 免费下载，在线说明文档（http://docs.scipy.org/doc/numpy/）包含了你可能遇到的大多数问题的答案。关于 NumPy 的更多内容，请参考开源书籍 [24]。

1.3.1　图像数组表示

在先前的例子中，当载入图像时，我们通过调用 array() 方法将图像转换成 NumPy 的数组对象，但当时并没有进行详细介绍。NumPy 中的数组对象是多维的，可以用来表示向量、矩阵和图像。一个数组对象很像一个列表（或者是列表的列表），但是数组中所有的元素必须具有相同的数据类型。除非创建数组对象时指定数据类型，否则数据类型会按照数据的类型自动确定。

对于图像数据，下面的例子阐述了这一点：

im = array(Image.open('empire.jpg'))
print im.shape, im.dtype

im = array(Image.open('empire.jpg').convert('L'),'f')
print im.shape, im.dtype

控制台输出结果如下所示：

(800, 569, 3) uint8
(800, 569) float32

每行的第一个元组表示图像数组的大小（行、列、颜色通道），紧接着的字符串表示数组元素的数据类型。因为图像通常被编码成无符号八位整数（uint8），所以在第一种情况下，载入图像并将其转换到数组中，数组的数据类型为“uint8”。在第二种情况下，对图像进行灰度化处理，并且在创建数组时使用额外的参数“f”；该参数将数据类型转换为浮点型。关于更多数据类型选项，可以参考图书 [24]。注意，由于灰度图像没有颜色信息，所以在形状元组中，它只有两个数值。

数组中的元素可以使用下标访问。位于坐标 i、j，以及颜色通道 k 的像素值可以像下面这样访问：

value = im[i,j,k]

多个数组元素可以使用数组切片方式访问。切片方式返回的是以指定间隔下标访问该数组的元素值。下面是有关灰度图像的一些例子：

im[i,:] = im[j,:]      # 将第 j 行的数值赋值给第 i 行
im[:,i] = 100          # 将第 i 列的所有数值设为100
im[:100,:50].sum()     # 计算前100 行、前 50 列所有数值的和
im[50:100,50:100]      # 50~100 行，50~100 列（不包括第 100 行和第 100 列）
im[i].mean()           # 第 i 行所有数值的平均值
im[:,-1]               # 最后一列
im[-2,:] (or im[-2])   # 倒数第二行

注意，示例仅仅使用一个下标访问数组。如果仅使用一个下标，则该下标为行下标。注意，在最后几个例子中，负数切片表示从最后一个元素逆向计数。我们将会频繁地使用切片技术访问像素值，这也是一个很重要的思想。

我们有很多操作和方法来处理数组对象。本书将在使用到的地方逐一介绍。你可以查阅在线文档或者开源图书 [24] 获取更多信息。

1.3.2　灰度变换

将图像读入 NumPy 数组对象后，我们可以对它们执行任意数学操作。一个简单的例子就是图像的灰度变换。考虑任意函数 f，它将 0...255 区间（或者 0...1 区间）映射到自身（意思是说，输出区间的范围和输入区间的范围相同）。下面是关于灰度变换的一些例子：

from PIL import Image
from numpy import *

im = array(Image.open('empire.jpg').convert('L'))

im2 = 255 - im # 对图像进行反相处理

im3 = (100.0/255) * im + 100 # 将图像像素值变换到100...200 区间

im4 = 255.0 * (im/255.0)**2 # 对图像像素值求平方后得到的图像

第一个例子将灰度图像进行反相处理；第二个例子将图像的像素值变换到 100...200 区间；第三个例子对图像使用二次函数变换，使较暗的像素值变得更小。图 1-4 为所使用的变换函数图像。图 1-5 是输出的图像结果。你可以使用下面的命令查看图像中的最小和最大像素值：

print int(im.min()), int(im.max())

图 1-4：灰度变换示例。三个例子中所使用函数的图像，其中虚线表示恒等变换

图 1-5：灰度变换。对图像应用图 1-4 中的函数：f(x)=255-x 对图像进行反相处理（左）；f(x)=(100/255)x+100 对图像进行变换（中）；f(x)=255(x/255)² 对图像做二次变换（右）

如果试着对上面例子查看最小值和最大值，可以得到下面的输出结果：

2 255
0 253
100 200
0 255

array() 变换的相反操作可以使用 PIL 的 fromarray() 函数完成：

pil_im = Image.fromarray(im)

如果你通过一些操作将“uint8”数据类型转换为其他数据类型，比如之前例子中的 im3 或者 im4，那么在创建 PIL 图像之前，需要将数据类型转换回来：

pil_im = Image.fromarray(uint8(im))

如果你并不十分确定输入数据的类型，安全起见，应该先转换回来。注意，NumPy 总是将数组数据类型转换成能够表示数据的“最低”数据类型。对浮点数做乘积或除法操作会使整数类型的数组变成浮点类型。

1.3.3　图像缩放

NumPy 的数组对象是我们处理图像和数据的主要工具。想要对图像进行缩放处理没有现成简单的方法。我们可以使用之前 PIL 对图像对象转换的操作，写一个简单的用于图像缩放的函数。把下面的函数添加到 imtool.py 文件里：

def imresize(im,sz):
  """ 使用PIL 对象重新定义图像数组的大小"""
  pil_im = Image.fromarray(uint8(im))

  return array(pil_im.resize(sz))

我们将会在接下来的内容中使用这个函数。

1.3.4　直方图均衡化

图像灰度变换中一个非常有用的例子就是直方图均衡化。直方图均衡化是指将一幅图像的灰度直方图变平，使变换后的图像中每个灰度值的分布概率都相同。在对图像做进一步处理之前，直方图均衡化通常是对图像灰度值进行归一化的一个非常好的方法，并且可以增强图像的对比度。

在这种情况下，直方图均衡化的变换函数是图像中像素值的累积分布函数（cumulative distribution function，简写为 cdf，将像素值的范围映射到目标范围的归一化操作）。

下面的函数是直方图均衡化的具体实现。将这个函数添加到 imtool.py 里：

def histeq(im,nbr_bins=256):
  """ 对一幅灰度图像进行直方图均衡化"""

  # 计算图像的直方图
  imhist,bins = histogram(im.flatten(),nbr_bins,normed=True)
  cdf = imhist.cumsum() # 累积分布函数
  cdf = 255 * cdf / cdf[-1] # 归一化

  # 使用累积分布函数的线性插值，计算新的像素值
  im2 = interp(im.flatten(),bins[:-1],cdf)

  return im2.reshape(im.shape), cdf

该函数有两个输入参数，一个是灰度图像，一个是直方图中使用小区间的数目。函数返回直方图均衡化后的图像，以及用来做像素值映射的累积分布函数。注意，函数中使用到累积分布函数的最后一个元素（下标为 -1），目的是将其归一化到 0...1 范围。你可以像下面这样使用该函数：

from PIL import Image
from numpy import *

im = array(Image.open('AquaTermi_lowcontrast.jpg').convert('L'))
im2,cdf = imtools.histeq(im)

图 1-6 和图 1-7 为上面直方图均衡化例子的结果。上面一行显示的分别是直方图均衡化之前和之后的灰度直方图，以及累积概率分布函数映射图像。可以看到，直方图均衡化后图像的对比度增强了，原先图像灰色区域的细节变得清晰。

图 1-6：直方图均衡化示例。左侧为原始图像和直方图，中间图为灰度变换函数，右侧为直方图均衡化后的图像和相应直方图

图 1-7：直方图均衡化示例。左侧为原始图像和直方图，中间图为灰度变换函数，右侧为直方图均衡化后的图像和相应直方图

1.3.5　图像平均

图像平均操作是减少图像噪声的一种简单方式，通常用于艺术特效。我们可以简单地从图像列表中计算出一幅平均图像。假设所有的图像具有相同的大小，我们可以将这些图像简单地相加，然后除以图像的数目，来计算平均图像。下面的函数可以用于计算平均图像，将其添加到 imtool.py 文件里：

def compute_average(imlist):
  """ 计算图像列表的平均图像"""

  # 打开第一幅图像，将其存储在浮点型数组中
  averageim = array(Image.open(imlist[0]), 'f')

  for imname in imlist[1:]:
    try:
      averageim += array(Image.open(imname))
    except:
      print imname + '...skipped'
  averageim /= len(imlist)

  # 返回uint8 类型的平均图像
  return array(averageim, 'uint8')

该函数包括一些基本的异常处理技巧，可以自动跳过不能打开的图像。我们还可以使用 mean() 函数计算平均图像。mean() 函数需要将所有的图像堆积到一个数组中；也就是说，如果有很多图像，该处理方式需要占用很多内存。我们将会在下一节中使用该函数。

1.3.6　图像的主成分分析（PCA）

PCA（Principal Component Analysis，主成分分析）是一个非常有用的降维技巧。它可以在使用尽可能少维数的前提下，尽量多地保持训练数据的信息，在此意义上是一个最佳技巧。即使是一幅 100×100 像素的小灰度图像，也有 10 000 维，可以看成 10 000 维空间中的一个点。一兆像素的图像具有百万维。由于图像具有很高的维数，在许多计算机视觉应用中，我们经常使用降维操作。PCA 产生的投影矩阵可以被视为将原始坐标变换到现有的坐标系，坐标系中的各个坐标按照重要性递减排列。

为了对图像数据进行 PCA 变换，图像需要转换成一维向量表示。我们可以使用 NumPy 类库中的 flatten() 方法进行变换。

将变平的图像堆积起来，我们可以得到一个矩阵，矩阵的一行表示一幅图像。在计算主方向之前，所有的行图像按照平均图像进行了中心化。我们通常使用 SVD（Singular Value Decomposition，奇异值分解）方法来计算主成分；但当矩阵的维数很大时，SVD 的计算非常慢，所以此时通常不使用 SVD 分解。下面就是 PCA 操作的代码：

from PIL import Image
from numpy import *

def pca(X):
  """ 主成分分析：
    输入：矩阵X ，其中该矩阵中存储训练数据，每一行为一条训练数据
    返回：投影矩阵（按照维度的重要性排序）、方差和均值"""

  # 获取维数
  num_data,dim = X.shape

  # 数据中心化
  mean_X = X.mean(axis=0)
  X = X - mean_X

if dim>num_data:
  # PCA- 使用紧致技巧
  M = dot(X,X.T) # 协方差矩阵
  e,EV = linalg.eigh(M) # 特征值和特征向量
  tmp = dot(X.T,EV).T # 这就是紧致技巧
  V = tmp[::-1] # 由于最后的特征向量是我们所需要的，所以需要将其逆转
  S = sqrt(e)[::-1] # 由于特征值是按照递增顺序排列的，所以需要将其逆转
  for i in range(V.shape[1]):
    V[:,i] /= S
else:
  # PCA- 使用SVD 方法
  U,S,V = linalg.svd(X)
  V = V[:num_data] # 仅仅返回前nun_data 维的数据才合理

# 返回投影矩阵、方差和均值
return V,S,mean_X

该函数首先通过减去每一维的均值将数据中心化，然后计算协方差矩阵对应最大特征值的特征向量，此时可以使用简明的技巧或者 SVD 分解。这里我们使用了 range() 函数，该函数的输入参数为一个整数 n，函数返回整数 0...(n-1) 的一个列表。你也可以使用 arange() 函数来返回一个数组，或者使用 xrange() 函数返回一个产生器（可能会提升速度）。我们在本书中贯穿使用 range() 函数。

如果数据个数小于向量的维数，我们不用 SVD 分解，而是计算维数更小的协方差矩阵 XXT 的特征向量。通过仅计算对应前 k（k 是降维后的维数）最大特征值的特征向量，可以使上面的 PCA 操作更快。由于篇幅所限，有兴趣的读者可以自行探索。矩阵 V 的每行向量都是正交的，并且包含了训练数据方差依次减少的坐标方向。

我们接下来对字体图像进行 PCA 变换。fontimages.zip 文件包含采用不同字体的字符 a 的缩略图。所有的 2359 种字体可以免费下载 2。假定这些图像的名称保存在列表 imlist 中，跟之前的代码一起保存传在 pca.py 文件中，我们可以使用下面的脚本计算图像的主成分：

2免费字体图像库由 Martin Solli 收集并上传（http://webstaff.itn.liu.se/~marso/）。

from PIL import Image
from numpy import *
from pylab import *
import pca

im = array(Image.open(imlist[0])) # 打开一幅图像，获取其大小
m,n = im.shape[0:2] # 获取图像的大小
imnbr = len(imlist) # 获取图像的数目

# 创建矩阵，保存所有压平后的图像数据
immatrix = array([array(Image.open(im)).flatten()
               for im in imlist],'f')

# 执行 PCA 操作
V,S,immean = pca.pca(immatrix)

# 显示一些图像（均值图像和前 7 个模式）
figure()
gray()
subplot(2,4,1)
imshow(immean.reshape(m,n))
for i in range(7):
  subplot(2,4,i+2)
  imshow(V[i].reshape(m,n))

show()

注意，图像需要从一维表示重新转换成二维图像；可以使用 reshape() 函数。如图 1-8 所示，运行该例子会在一个绘图窗口中显示 8 个图像。这里我们使用了 PyLab 库的 subplot() 函数在一个窗口中放置多个图像。

图 1-8：平均图像（左上）和前 7 个模式（具有最大方差的方向模式）

1.3.7　使用pickle模块

如果想要保存一些结果或者数据以方便后续使用，Python 中的 pickle 模块非常有用。pickle 模块可以接受几乎所有的 Python 对象，并且将其转换成字符串表示，该过程叫做封装（pickling）。从字符串表示中重构该对象，称为拆封（unpickling）。这些字符串表示可以方便地存储和传输。

我们来看一个例子。假设想要保存上一节字体图像的平均图像和主成分，可以这样来完成：

# 保存均值和主成分数据
f = open('font_pca_modes.pkl', 'wb')
pickle.dump(immean,f)
pickle.dump(V,f)
f.close()

在上述例子中，许多对象可以保存到同一个文件中。pickle 模块中有很多不同的协议可以生成 .pkl 文件；如果不确定的话，最好以二进制文件的形式读取和写入。在其他 Python 会话中载入数据，只需要如下使用 load() 方法：

# 载入均值和主成分数据
f = open('font_pca_modes.pkl', 'rb')
immean = pickle.load(f)
V = pickle.load(f)
f.close()

注意，载入对象的顺序必须和先前保存的一样。Python 中有个用 C 语言写的优化版本，叫做 cpickle 模块，该模块和标准 pickle 模块完全兼容。关于 pickle 模块的更多内容，参见 pickle 模块文档页 http://docs.python.org/library/pickle.html。

在本书接下来的章节中，我们将使用 with 语句处理文件的读写操作。这是 Python 2.5 引入的思想，可以自动打开和关闭文件（即使在文件打开时发生错误）。下面的例子使用 with() 来实现保存和载入操作：

# 打开文件并保存
with open('font_pca_modes.pkl', 'wb') as f:
  pickle.dump(immean,f)
  pickle.dump(V,f)

和

# 打开文件并载入
with open('font_pca_modes.pkl', 'rb') as f:
  immean = pickle.load(f)
  V = pickle.load(f)

上面的例子乍看起来可能很奇怪，但 with() 确实是个很有用的思想。如果你不喜欢它，可以使用之前的 open 和 close 函数。

作为 pickle 的一种替代方式，NumPy 具有读写文本文件的简单函数。如果数据中不包含复杂的数据结构，比如在一幅图像上点击的点列表，NumPy 的读写函数会很有用。保存一个数组 x 到文件中，可以使用：

savetxt('test.txt',x,'%i')

最后一个参数表示应该使用整数格式。类似地，读取可以使用：

x = loadtxt('test.txt')

你可以从在线文档 http://docs.scipy.org/doc/numpy/reference/generated/numpy.loadtxt.html 了解更多内容。

最后，NumPy 有专门用于保存和载入数组的函数。你可以在上面的在线文档里查看关于 save() 和 load() 的更多内容。

1.4　SciPy

SciPy（http://scipy.org/） 是建立在 NumPy 基础上，用于数值运算的开源工具包。SciPy 提供很多高效的操作，可以实现数值积分、优化、统计、信号处理，以及对我们来说最重要的图像处理功能。接下来，本节会介绍 SciPy 中大量有用的模块。SciPy 是个开源工具包，可以从 http://scipy.org/Download 下载。

1.4.1　图像模糊

图像的高斯模糊是非常经典的图像卷积例子。本质上，图像模糊就是将（灰度）图像 I 和一个高斯核进行卷积操作：

Iσ = I*Gσ

其中 * 表示卷积操作；Gσ 是标准差为 σ 的二维高斯核，定义为 :

高斯模糊通常是其他图像处理操作的一部分，比如图像插值操作、兴趣点计算以及很多其他应用。

SciPy 有用来做滤波操作的 scipy.ndimage.filters 模块。该模块使用快速一维分离的方式来计算卷积。你可以像下面这样来使用它：

from PIL import Image
from numpy import *
from scipy.ndimage import filters

im = array(Image.open('empire.jpg').convert('L'))
im2 = filters.gaussian_filter(im,5)

上面 guassian_filter() 函数的最后一个参数表示标准差。

图 1-9 显示了随着 σ 的增加，一幅图像被模糊的程度。σ 越大，处理后的图像细节丢失越多。如果打算模糊一幅彩色图像，只需简单地对每一个颜色通道进行高斯模糊：

im = array(Image.open('empire.jpg'))
im2 = zeros(im.shape)
for i in range(3):
  im2[:,:,i] = filters.gaussian_filter(im[:,:,i],5)
im2 = uint8(im2)

在上面的脚本中，最后并不总是需要将图像转换成 uint8 格式，这里只是将像素值用八位来表示。我们也可以使用：

im2 = array(im2,'uint8')

来完成转换。

关于该模块更多的内容以及不同参数的选择，请查看 http://docs.scipy.org/doc/scipy/reference/ndimage.html 上 SciPy 文档中的 scipy.ndimage 部分。

图 1-9：使用 scipy.ndimage.filters 模块进行高斯模糊：（a）原始灰度图像；（b）使用 σ=2 的高斯滤波器；（c）使用 σ=5 的高斯滤波器；（d）使用 σ=10 的高斯滤波器

1.4.2　图像导数

整本书中可以看到，在很多应用中图像强度的变化情况是非常重要的信息。强度的变化可以用灰度图像 I（对于彩色图像，通常对每个颜色通道分别计算导数）的 x 和 y 方向导数 Ix 和 Iy 进行描述。

图像的梯度向量为∇I = [Ix, Iy]T。梯度有两个重要的属性，一是梯度的大小：

它描述了图像强度变化的强弱，一是梯度的角度：

α=arctan2(Iy, Ix)

描述了图像中在每个点（像素）上强度变化最大的方向。NumPy 中的 arctan2() 函数返回弧度表示的有符号角度，角度的变化区间为 -π...π。

我们可以用离散近似的方式来计算图像的导数。图像导数大多数可以通过卷积简单地实现：

Ix=I*Dx 和 Iy=I*Dy

对于 Dx 和 Dy，通常选择 Prewitt 滤波器：

D_x=\begin{vmatrix}-1&0&1\\-1&0&1\\ -1&0&1\end{vmatrix} 和 D_y=\begin{vmatrix}-1&-1&-1\\0&0&0\\ 1&1&1\end{vmatrix}

或者 Sobel 滤波器：

D_x=\begin{vmatrix}-1&0&1\\-2&0&2\\ -1&0&1\end{vmatrix} 和 D_y=\begin{vmatrix}-1&-2&-1\\0&0&0\\ 1&2&1\end{vmatrix}

这些导数滤波器可以使用 scipy.ndimage.filters 模块的标准卷积操作来简单地实现，例如：

from PIL import Image
from numpy import *
from scipy.ndimage import filters

im = array(Image.open('empire.jpg').convert('L'))

# Sobel 导数滤波器
imx = zeros(im.shape)
filters.sobel(im,1,imx)

imy = zeros(im.shape)
filters.sobel(im,0,imy)

magnitude = sqrt(imx**2+imy**2)

上面的脚本使用 Sobel 滤波器来计算 x 和 y 的方向导数，以及梯度大小。sobel() 函数的第二个参数表示选择 x 或者 y 方向导数，第三个参数保存输出的变量。图 1-10 显示了用 Sobel 滤波器计算出的导数图像。在两个导数图像中，正导数显示为亮的像素，负导数显示为暗的像素。灰色区域表示导数的值接近于零。

图 1-10：使用 Sobel 导数滤波器计算导数图像：（a）原始灰度图像；（b）x 导数图像；（c）y 导数图像；（d）梯度大小图像

上述计算图像导数的方法有一些缺陷：在该方法中，滤波器的尺度需要随着图像分辨率的变化而变化。为了在图像噪声方面更稳健，以及在任意尺度上计算导数，我们可以使用高斯导数滤波器：

Ix=I*Gσx 和 Iy=I*Gσy

其中，Gσx和 Gσy 表示 Gσ 在 x 和 y 方向上的导数，Gσ 为标准差为 σ 的高斯函数。

我们之前用于模糊的 filters.gaussian_filter() 函数可以接受额外的参数，用来计算高斯导数。可以简单地按照下面的方式来处理：

sigma = 5 # 标准差

imx = zeros(im.shape)
filters.gaussian_filter(im, (sigma,sigma), (0,1), imx)

imy = zeros(im.shape)
filters.gaussian_filter(im, (sigma,sigma), (1,0), imy)

该函数的第三个参数指定对每个方向计算哪种类型的导数，第二个参数为使用的标准差。你可以查看相应文档了解详情。图 1-11 显示了不同尺度下的导数图像和梯度大小。你可以和图 1-9 中做相同尺度模糊的图像做比较。

图 1-11：使用高斯导数计算图像导数：x 导数图像（上），y 导数图像（中），以及梯度大小图像（下）；（a）为原始灰度图像，（b）为使用 σ=2 的高斯导数滤波器处理后的图像，（c）为使 用 σ=5 的高斯导数滤波器处理后的图像，（d）为使用 σ=10 的高斯导数滤波器处理后的图像

1.4.3　形态学：对象计数

形态学（或数学形态学）是度量和分析基本形状的图像处理方法的基本框架与集合。形态学通常用于处理二值图像，但是也能够用于灰度图像。二值图像是指图像的每个像素只能取两个值，通常是 0 和 1。二值图像通常是，在计算物体的数目，或者度量其大小时，对一幅图像进行阈值化后的结果。你可以从 http://en.wikipedia.org/wiki/Mathematical_morphology 大体了解形态学及其处理图像的方式。

scipy.ndimage 中的 morphology 模块可以实现形态学操作。你可以使用 scipy.ndimage 中的 measurements 模块来实现二值图像的计数和度量功能。下面通过一个简单的例子介绍如何使用它们。

考虑在图 1-12a3 里的二值图像，计算该图像中的对象个数可以通过下面的脚本实现：

3这个图像实际上是图像“分割”后的结果。如果你想知道该图像是如何创建的，可以查看 9.3 节。

from scipy.ndimage import measurements,morphology

# 载入图像，然后使用阈值化操作，以保证处理的图像为二值图像
im = array(Image.open('houses.png').convert('L'))
im = 1*(im<128)

labels, nbr_objects = measurements.label(im)
print "Number of objects:", nbr_objects

上面的脚本首先载入该图像，通过阈值化方式来确保该图像是二值图像。通过和 1 相乘，脚本将布尔数组转换成二进制表示。然后，我们使用 label() 函数寻找单个的物体，并且按照它们属于哪个对象将整数标签给像素赋值。图 1-12b 是 labels 数组的图像。图像的灰度值表示对象的标签。可以看到，在一些对象之间有一些小的连接。进行二进制开（binary open）操作，我们可以将其移除：

# 形态学开操作更好地分离各个对象
im_open = morphology.binary_opening(im,ones((9,5)),iterations=2)

labels_open, nbr_objects_open = measurements.label(im_open)
print "Number of objects:", nbr_objects_open

binary_opening() 函数的第二个参数指定一个数组结构元素。该数组表示以一个像素为中心时，使用哪些相邻像素。在这种情况下，我们在 y 方向上使用 9 个像素（上面 4 个像素、像素本身、下面 4 个像素），在 x 方向上使用 5 个像素。你可以指定任意数组为结构元素，数组中的非零元素决定使用哪些相邻像素。参数 iterations 决定执行该操作的次数。你可以尝试使用不同的迭代次数 iterations 值，看一下对象的数目如何变化。你可以在图 1-12c 与图 1-12d 中查看经过开操作后的图像，以及相应的标签图像。正如你想象的一样，binary_closing() 函数实现相反的操作。我们将该函数和在 morphology 和 measurements 模块中的其他函数的用法留作练习。你可以从 scipy.ndimage 模块文档 http://docs.scipy.org/doc/scipy/reference/ndimage.html 中了解关于这些函数的更多知识。

图 1-12：形态学示例。使用二值开操作将对象分开，然后计算物体的数目：（a）为原始二值图像；（b）为对应原始图像的标签图像，其中灰度值表示物体的标签；（c）为使用开操作后的二值图像；（d）为开操作后图像的标签图像

1.4.4　一些有用的SciPy模块

SciPy 中包含一些用于输入和输出的实用模块。下面介绍其中两个模块：io 和 misc。

读写.mat文件

如果你有一些数据，或者在网上下载到一些有趣的数据集，这些数据以 Matlab 的 .mat 文件格式存储，那么可以使用 scipy.io 模块进行读取。

data = scipy.io.loadmat('test.mat')

上面代码中，data 对象包含一个字典，字典中的键对应于保存在原始 .mat 文件中的变量名。由于这些变量是数组格式的，因此可以很方便地保存到 .mat 文件中。你仅需创建一个字典（其中要包含你想要保存的所有变量），然后使用 savemat() 函数：

data = {}
data['x'] = x
scipy.io.savemat('test.mat',data)

因为上面的脚本保存的是数组 x，所以当读入到 Matlab 中时，变量的名字仍为 x。关于 scipy.io 模块的更多内容，请参见在线文档 http://docs.scipy.org/doc/scipy/reference/io.html。

以图像形式保存数组

因为我们需要对图像进行操作，并且需要使用数组对象来做运算，所以将数组直接保存为图像文件 4 非常有用。本书中的很多图像都是这样的创建的。

imsave() 函数可以从 scipy.misc 模块中载入。要将数组 im 保存到文件中，可以使用下面的命令：

from scipy.misc import imsave
imsave('test.jpg',im)

scipy.misc 模块同样包含了著名的 Lena 测试图像：

lena = scipy.misc.lena()

该脚本返回一个 512×512 的灰度图像数组。

4所有 Pylab 图均可保存为多种图像格式，方法是点击图像窗口中的“保存”按钮。

1.5　高级示例：图像去噪

我们通过一个非常实用的例子——图像的去噪——来结束本章。图像去噪是在去除图像噪声的同时，尽可能地保留图像细节和结构的处理技术。我们这里使用 ROF（Rudin-Osher-Fatemi）去噪模型。该模型最早出现在文献 [28] 中。图像去噪对于很多应用来说都非常重要；这些应用范围很广，小到让你的假期照片看起来更漂亮，大到提高卫星图像的质量。ROF 模型具有很好的性质：使处理后的图像更平滑，同时保持图像边缘和结构信息。

ROF 模型的数学基础和处理技巧非常高深，不在本书讲述范围之内。在讲述如何基于 Chambolle 提出的算法 [5] 实现 ROF 求解器之前，本书首先简要介绍一下 ROF 模型。

一幅（灰度）图像 I 的全变差（Total Variation，TV）定义为梯度范数之和。在连续表示的情况下，全变差表示为：

J(\boldsymbol{I})=\int\left|\nabla\boldsymbol{I}\right|\text{dx}　　　　　　　　　　　　（1.1）

在离散表示的情况下，全变差表示为：

其中，上面的式子是在所有图像坐标 x=[x, y] 上取和。

在 Chambolle 提出的 ROF 模型里，目标函数为寻找降噪后的图像 U，使下式最小：

其中范数 ||I-U|| 是去噪后图像 U 和原始图像 I 差异的度量。也就是说，本质上该模型使去噪后的图像像素值“平坦”变化，但是在图像区域的边缘上，允许去噪后的图像像素值“跳跃”变化。

按照论文 [5] 中的算法，我们可以按照下面的代码实现 ROF 模型去噪：

from numpy import *

def denoise(im,U_init,tolerance=0.1,tau=0.125,tv_weight=100):
  """ 使用A. Chambolle（2005）在公式（11）中的计算步骤实现Rudin-Osher-Fatemi（ROF）去噪模型

    输入：含有噪声的输入图像（灰度图像）、U 的初始值、TV 正则项权值、步长、停业条件

    输出：去噪和去除纹理后的图像、纹理残留"""

  m,n = im.shape # 噪声图像的大小

  # 初始化
  U = U_init
  Px = im # 对偶域的x 分量
  Py = im # 对偶域的y 分量
  error = 1

while (error > tolerance):
  Uold = U

  # 原始变量的梯度
  GradUx = roll(U,-1,axis=1)-U # 变量U 梯度的x 分量
  GradUy = roll(U,-1,axis=0)-U # 变量U 梯度的y 分量

  # 更新对偶变量
  PxNew = Px + (tau/tv_weight)*GradUx
  PyNew = Py + (tau/tv_weight)*GradUy
  NormNew = maximum(1,sqrt(PxNew**2+PyNew**2))

  Px = PxNew/NormNew # 更新x 分量（对偶）
  Py = PyNew/NormNew # 更新y 分量（对偶）

  # 更新原始变量
  RxPx = roll(Px,1,axis=1) # 对x 分量进行向右x 轴平移
  RyPy = roll(Py,1,axis=0) # 对y 分量进行向右y 轴平移

  DivP = (Px-RxPx)+(Py-RyPy) # 对偶域的散度
  U = im + tv_weight*DivP # 更新原始变量

  # 更新误差
  error = linalg.norm(U-Uold)/sqrt(n*m);

  return U,im-U # 去噪后的图像和纹理残余

在这个例子中，我们使用了 roll() 函数。顾名思义，在一个坐标轴上，它循环“滚动”数组中的元素值。该函数可以非常方便地计算邻域元素的差异，比如这里的导数。我们还使用了 linalg.norm() 函数，该函数可以衡量两个数组间（这个例子中是指图像矩阵 U和 Uold）的差异。我们将这个 denoise() 函数保存到 rof.py 文件中。

下面使用一个合成的噪声图像示例来说明如何使用该函数：

from numpy import *
from numpy import random
from scipy.ndimage import filters
import rof

# 使用噪声创建合成图像
im = zeros((500,500))
im[100:400,100:400] = 128
im[200:300,200:300] = 255
im = im + 30*random.standard_normal((500,500))

U,T = rof.denoise(im,im)
G = filters.gaussian_filter(im,10)

# 保存生成结果
from scipy.misc import imsave
imsave('synth_rof.pdf',U)
imsave('synth_gaussian.pdf',G)

原始图像和图像的去噪结果如图 1-13 所示。正如你所看到的，ROF 算法去噪后的图像很好地保留了图像的边缘信息。

图 1-13：使用 ROF 模型对合成图像去噪：（a）为原始噪声图像；（b）为经过高斯模糊的图像（σ=10）；（c）为经过 ROF 模型去噪后的图像

下面看一下在实际图像中使用 ROF 模型去噪的效果：

from PIL import Image
from pylab import *
import rof

im = array(Image.open('empire.jpg').convert('L'))
U,T = rof.denoise(im,im)

figure()
gray()
imshow(U)
axis('equal')
axis('off')
show()

经过 ROF 去噪后的图像如图 1-14c 所示。为了方便比较，该图中同样显示了模糊后的图像。可以看到，ROF 去噪后的图像保留了边缘和图像的结构信息，同时模糊了“噪声”。

图 1-14：使用 ROF 模型对灰度图像去噪：（a）为原始噪声图像；（b）为经过高斯模糊的图像（σ=5）；（c）为经过 ROF 模型去噪后的图像

练习

如图 1-9 所示，将一幅图像进行高斯模糊处理。随着 σ 的增加，绘制出图像轮廓。在你绘制出的图中，图像的轮廓有何变化？你能解释为什么会发生这些变化吗？

通过将图像模糊化，然后从原始图像中减去模糊图像，来实现反锐化图像掩模操作（http://en.wikipedia.org/wiki/Unsharp_masking）。反锐化图像掩模操作可以实现图像锐化效果。试在彩色和灰度图像上使用反锐化图像掩模操作，观察该操作的效果。

除了直方图均衡化，商图像是另一种图像归一化的方法。商图像可以通过除以模糊后的图像 I/(I* Gσ) 获得。尝试使用该方法，并使用一些样本图像进行验证。

使用图像梯度，编写一个在图像中获得简单物体（例如，白色背景中的正方形）轮廓的函数。

使用梯度方向和大小检测图像中的线段。估计线段的长度以及线段的参数，并在原始图像中重新绘制该线段。

使用 label() 函数处理二值化图像，并使用直方图和标签图像绘制图像中物体的大小分布。

使用形态学操作处理阈值化图像。在发现一些参数能够产生好的结果后，使用 morphology 模块里面的 center_of_mass() 函数寻找每个物体的中心坐标，将其在图像中绘制出来。

代码示例约定

从第 2 章起，我们假定 PIL、NumPy 和 Matplotlib 都包括在你所创建的每个文件和每个代码例子的开头：

from PIL import Image
from numpy import *
from pylab import *

这种约定使得示例代码更清晰，同时也便于读者理解。除此之外，我们使用 SciPy 模块时，将会在代码示例中显式声明。

一些纯化论者会反对这种将全体模块导入的方式，坚持如下使用方式：

import numpy as np
import matplotlib.pyplot as plt

这种方式能够保持命名空间（知道每个函数从哪儿来）。因为我们不需要 PyLab 中的 NumPy 部分，所以该例子只从 Matplotlib 中导入 pyplot 部分。纯化论者和经验丰富的程序员们知道这些区别，他们能够选择自己喜欢的方式。但是，为了使本书的内容和例子更容易被读者接受，我们不打算这样做。

请读者注意。

第 2 章　局部图像描述子

本章旨在寻找图像间的对应点和对应区域。本章将介绍用于图像匹配的两种局部描述子算法。本书的很多内容中都会用到这些局部特征，它们在很多应用中都有重要作用，比如创建全景图、增强现实技术以及计算图像的三维重建。

2.1　Harris角点检测器

Harris 角点检测算法（也称 Harris & Stephens 角点检测器）是一个极为简单的角点检测算法。该算法的主要思想是，如果像素周围显示存在多于一个方向的边，我们认为该点为兴趣点。该点就称为角点。

我们把图像域中点 x 上的对称半正定矩阵 MI=MI（x）定义为：

　　　　　　(2.1)

其中 ∇I 为包含导数 Ix 和 Iy 的图像梯度（我们已经在第 1 章定义了图像的导数和梯度）。由于该定义，MI 的秩为 1，特征值为 λ₁=|∇I|² 和 λ₂=0。现在对于图像的每一个像素，我们可以计算出该矩阵。

选择权重矩阵 W（通常为高斯滤波器 Gσ），我们可以得到卷积：

\overline {\boldsymbol{M}}_I=\boldsymbol{W}*\boldsymbol{M}_I　　　　　　(2.2)

该卷积的目的是得到 MI 在周围像素上的局部平均。计算出的矩阵 \overline {\boldsymbol{M}}_I 有称为 Harris 矩阵。W 的宽度决定了在像素 x 周围的感兴趣区域。像这样在区域附近对矩阵 \overline {\boldsymbol{M}}_I 取平均的原因是，特征值会依赖于局部图像特性而变化。如果图像的梯度在该区域变化，那么 \overline {\boldsymbol{M}}_I 的第二个特征值将不再为 0。如果图像的梯度没有变化，\overline {\boldsymbol{M}}_I 的特征值也不会变化。

取决于该区域∇I 的值，Harris 矩阵 \overline {\boldsymbol{M}}_I 的特征值有三种情况：

如果 λ₁ 和 λ₂ 都是很大的正数，则该 x 点为角点；

如果 λ₁ 很大，λ₂ ≈ 0，则该区域内存在一个边，该区域内的平均 MI 的特征值不会变化太大；

如果 λ₁≈ λ₂ ≈ 0，该区域内为空。

在不需要实际计算特征值的情况下，为了把重要的情况和其他情况分开，Harris 和 Stephens 在文献 [12] 中引入了指示函数：

为了去除加权常数 κ，我们通常使用商数：

作为指示器。

下面我们写出 Harris 角点检测程序。像 1.4.2 节介绍的一样，对于这个函数，我们需要使用 scipy.ndimage.filters 模块中的高斯导数滤波器来计算导数。使用高斯滤波器的道理同样是，我们需要在角点检测过程中抑制噪声强度。

首先，将角点响应函数添加到 harris.py 文件中，该函数使用高斯导数实现。同样地，参数 σ 定义了使用的高斯滤波器的尺度大小。你也可以修改这个函数，对 x 和 y 方向上不同的尺度参数，以及尝试平均操作中的不同尺度，来计算 Harris 矩阵。

from scipy.ndimage import filters
def compute_harris_response(im,sigma=3):
  """ 在一幅灰度图像中，对每个像素计算Harris 角点检测器响应函数"""

  # 计算导数
  imx = zeros(im.shape)
  filters.gaussian_filter(im, (sigma,sigma), (0,1), imx)
  imy = zeros(im.shape)
  filters.gaussian_filter(im, (sigma,sigma), (1,0), imy)

  # 计算Harris 矩阵的分量
  Wxx = filters.gaussian_filter(imx*imx,sigma)
  Wxy = filters.gaussian_filter(imx*imy,sigma)
  Wyy = filters.gaussian_filter(imy*imy,sigma)

  # 计算特征值和迹
  Wdet = Wxx*Wyy - Wxy**2
  Wtr = Wxx + Wyy

  return Wdet / Wtr

上面的函数会返回像素值为 Harris 响应函数值的一幅图像。现在，我们需要从这幅图像中挑选出需要的信息。然后，选取像素值高于阈值的所有图像点；再加上额外的限制，即角点之间的间隔必须大于设定的最小距离。这种方法会产生很好的角点检测结果。为了实现该算法，我们获取所有的候选像素点，以角点响应值递减的顺序排序，然后将距离已标记为角点位置过近的区域从候选像素点中删除。将下面的函数添加到 harris.py 文件中：

def get_harris_points(harrisim,min_dist=10,threshold=0.1):
  """ 从一幅Harris 响应图像中返回角点。min_dist 为分割角点和图像边界的最少像素数目"""

  # 寻找高于阈值的候选角点
  corner_threshold = harrisim.max() * threshold
  harrisim_t = (harrisim > corner_threshold) * 1

  # 得到候选点的坐标
  coords = array(harrisim_t.nonzero()).T

  # 以及它们的Harris 响应值
  candidate_values = [harrisim[c[0],c[1]] for c in coords]

  # 对候选点按照Harris 响应值进行排序
  index = argsort(candidate_values)

  # 将可行点的位置保存到数组中
  allowed_locations = zeros(harrisim.shape)
  allowed_locations[min_dist:-min_dist,min_dist:-min_dist] = 1

  # 按照min_distance 原则，选择最佳Harris 点
  filtered_coords = []
  for i in index:
    if allowed_locations[coords[i,0],coords[i,1]] == 1:
      filtered_coords.append(coords[i])
      allowed_locations[(coords[i,0]-min_dist):(coords[i,0]+min_dist),
            (coords[i,1]-min_dist):(coords[i,1]+min_dist)] = 0

  return filtered_coords

现在你有了检测图像中角点所需要的所有函数。为了显示图像中的角点，你可以使用 Matplotlib 模块绘制函数，将其添加到 harris.py 文件中，如下：

def plot_harris_points(image,filtered_coords):
  """ 绘制图像中检测到的角点"""

  figure()
  gray()
  imshow(image)
  plot([p[1] for p in filtered_coords],[p[0] for p in filtered_coords],'*')
  axis('off')
  show()

试着运行下面的命令：

im = array(Image.open('empire.jpg').convert('L'))
harrisim = harris.compute_harris_response(im)
filtered_coords = harris.get_harris_points(harrisim,6)
harris.plot_harris_points(im, filtered_coords)

首先，打开该图像，转换成灰度图像。然后，计算响应函数，基于响应值选择角点。最后，在原始图像中覆盖绘制检测出的角点。绘制出的结果图像如图 2-1 所示。

图 2-1：使用 Harris 角点检测器检测角点：（a）为 Harris 响应函数；（b-d）分别为使用阈值 0.01、0.05 和 0.1 检测出的角点

如果你想概要了解角点检测的不同方法，包括 Harris 角点检测器的改进和进一步的开发应用，可以查找资源，如网站 http://en.wikipedia.org/wiki/Corner_detection。

在图像间寻找对应点

Harris 角点检测器仅仅能够检测出图像中的兴趣点，但是没有给出通过比较图像间的兴趣点来寻找匹配角点的方法。我们需要在每个点上加入描述子信息，并给出一个比较这些描述子的方法。

兴趣点描述子是分配给兴趣点的一个向量，描述该点附近的图像的表观信息。描述子越好，寻找到的对应点越好。我们用对应点或者点的对应来描述相同物体和场景点在不同图像上形成的像素点。

Harris 角点的描述子通常是由周围图像像素块的灰度值，以及用于比较的归一化互相关矩阵构成的。图像的像素块由以该像素点为中心的周围矩形部分图像构成。

通常，两个（相同大小）像素块 I₁(x) 和 I₂(x) 的相关矩阵定义为：

其中，函数 f 随着相关方法的变化而变化。上式取像素块中所有像素位置 x 的和。对于互相关矩阵，函数 f(I₁，I₂)=I₁I₂,因此，c(I₁，I₂)=I₁ · I₂,其中 · 表示向量乘法（按照行或者列堆积的像素）。c(I₁， I₂) 的值越大，像素块 I₁ 和 I₂ 的相似度越高。1

1另一个常用的函数是 f(I₁,I2₂)=(I₁-I₂)² ，该函数表示平方差的和（Sum of Squared Difference，SSD）。

归一化的互相关矩阵是互相关矩阵的一种变形，可以定义为：

　　(2.3)

其中，n 为像素块中像素的数目，μ₁ 和 μ₂ 表示每个像素块中的平均像素值强度，σ₁ 和 σ₂ 分别表示每个像素块中的标准差。通过减去均值和除以标准差，该方法对图像亮度变化具有稳健性。

为获取图像像素块，并使用归一化的互相关矩阵来比较它们，你需要另外两个函数。将它们添加到 harris.py 文件中：

def get_descriptors(image,filtered_coords,wid=5):
  """ 对于每个返回的点，返回点周围2*wid+1 个像素的值（假设选取点的min_distance > wid）"""

  desc = []
  for coords in filtered_coords:
    patch = image[coords[0]-wid:coords[0]+wid+1,
              coords[1]-wid:coords[1]+wid+1].flatten()
    desc.append(patch)

  return desc

def match(desc1,desc2,threshold=0.5):
  """ 对于第一幅图像中的每个角点描述子，使用归一化互相关，选取它在第二幅图像中的匹配角点"""

  n = len(desc1[0])

  # 点对的距离
  d = -ones((len(desc1),len(desc2)))
  for i in range(len(desc1)):
    for j in range(len(desc2)):
      d1 = (desc1[i] - mean(desc1[i])) / std(desc1[i])
      d2 = (desc2[j] - mean(desc2[j])) / std(desc2[j])
      ncc_value = sum(d1 * d2) / (n-1)
      if ncc_value > threshold:
        d[i,j] = ncc_value

  ndx = argsort(-d)
  matchscores = ndx[:,0]

  return matchscores

第一个函数的参数为奇数大小长度的方形灰度图像块，该图像块的中心为处理的像素点。该函数将图像块像素值压平成一个向量，然后添加到描述子列表中。第二个函数使用归一化的互相关矩阵，将每个描述子匹配到另一个图像中的最优的候选点。由于数值较高的距离代表两个点能够更好地匹配，所以在排序之前，我们对距离取相反数。为了获得更稳定的匹配，我们从第二幅图像向第一幅图像匹配，然后过滤掉在两种方法中不都是最好的匹配。下面的函数可以实现该操作：

def match_twosided(desc1,desc2,threshold=0.5):
  """ 两边对称版本的match()"""

  matches_12 = match(desc1,desc2,threshold)
  matches_21 = match(desc2,desc1,threshold)

  ndx_12 = where(matches_12 >= 0)[0]

  # 去除非对称的匹配
  for n in ndx_12:
    if matches_21[matches_12[n]] != n:
      matches_12[n] = -1
return matches_12

这些匹配可以通过在两边分别绘制出图像，使用线段连接匹配的像素点来直观地可视化。下面的代码可以实现匹配点的可视化。将这两个函数添加到 harris.py 文件中：

def appendimages(im1,im2):
  """ 返回将两幅图像并排拼接成的一幅新图像"""

  # 选取具有最少行数的图像，然后填充足够的空行
  rows1 = im1.shape[0]
  rows2 = im2.shape[0]

  if rows1 < rows2:
    im1 = concatenate((im1,zeros((rows2-rows1,im1.shape[1]))),axis=0)
  elif rows1 > rows2:
    im2 = concatenate((im2,zeros((rows1-rows2,im2.shape[1]))),axis=0)
  # 如果这些情况都没有，那么它们的行数相同，不需要进行填充

  return concatenate((im1,im2), axis=1)

def plot_matches(im1,im2,locs1,locs2,matchscores,show_below=True):
  """ 显示一幅带有连接匹配之间连线的图片
    输入：im1，im2（数组图像），locs1，locs2（特征位置），matchscores（match() 的输出），
    show_below（如果图像应该显示在匹配的下方）"""

  im3 = appendimages(im1,im2)
  if show_below:
    im3 = vstack((im3,im3))

  imshow(im3)

  cols1 = im1.shape[1]
  for i,m in enumerate(matchscores):
    if m>0:
      plot([locs1[i][1],locs2[m][1]+cols1],[locs1[i][0],locs2[m][0]],'c')
  axis('off')

图 2-2 为使用归一化的互相关矩阵（在这个例子中，每个像素块的大小为 11×11）来寻找对应点的例子。该图像可以通过下面的命令实现：

wid = 5
harrisim = harris.compute_harris_response(im1,5)
filtered_coords1 = harris.get_harris_points(harrisim,wid+1)
d1 = harris.get_descriptors(im1,filtered_coords1,wid)

harrisim = harris.compute_harris_response(im2,5)
filtered_coords2 = harris.get_harris_points(harrisim,wid+1)
d2 = harris.get_descriptors(im2,filtered_coords2,wid)

print 'starting matching'
matches = harris.match_twosided(d1,d2)

figure()
gray()
harris.plot_matches(im1,im2,filtered_coords1,filtered_coords2,matches)
show()

图 2-2：将归一化的互相关矩阵应用于 Harris 角点周围图像块，来寻找匹配对应点

为了看得更清楚，你可以画出匹配的子集。在上面的代码中，可以通过将数组 matches 替换成 matches[:100] 或者任意子集来实现。

如图 2-2 所示，该算法的结果存在一些不正确匹配。这是因为，与现代的一些方法相比，图像像素块的互相关矩阵具有较弱的描述性。实际运用中，我们通常使用更稳健的方法来处理这些对应匹配。这些描述符还有一个问题，它们不具有尺度不变性和旋转不变性，而算法中像素块的大小也会影响对应匹配的结果。

近年来诞生了很多用来提高特征点检测和描述性能的方法。在下一节中，我们来学习其中最好的一种算法。

2.2　SIFT（尺度不变特征变换）

David Lowe 在文献 [17] 中提出的 SIFT（Scale-Invariant Feature Transform，尺度不变特征变换）是过去十年中最成功的图像局部描述子之一。SIFT 特征后来在文献 [18] 中得到精炼并详述，经受住了时间的考验。SIFT 特征包括兴趣点检测器和描述子。SIFT 描述子具有非常强的稳健性，这在很大程度上也是 SIFT 特征能够成功和流行的主要原因。自从 SIFT 特征的出现，许多其他本质上使用相同描述子的方法也相继出现。现在，SIFT 描述符经常和许多不同的兴趣点检测器相结合使用（有些情况下是区域检测器），有时甚至在整幅图像上密集地使用。SIFT 特征对于尺度、旋转和亮度都具有不变性，因此，它可以用于三维视角和噪声的可靠匹配。你可以在 http://en.wikipedia.org/wiki/Scale-invariant_feature_transform 获得 SIFT 特征的简要介绍。

2.2.1　兴趣点

SIFT 特征使用高斯差分函数来定位兴趣点：

其中，Gσ 是上一章中介绍的二维高斯核，Iσ 是使用 Gσ 模糊的灰度图像，κ 是决定相差尺度的常数。兴趣点是在图像位置和尺度变化下 D(x,σ) 的最大值和最小值点。这些候选位置点通过滤波去除不稳定点。基于一些准则，比如认为低对比度和位于边上的点不是兴趣点，我们可以去除一些候选兴趣点。你可以参考文献 [17, 18] 了解更多。

2.2.2　描述子

上面讨论的兴趣点（关键点）位置描述子给出了兴趣点的位置和尺度信息。为了实现旋转不变性，基于每个点周围图像梯度的方向和大小，SIFT 描述子又引入了参考方向。SIFT 描述子使用主方向描述参考方向。主方向使用方向直方图（以大小为权重）来度量。

下面我们基于位置、尺度和方向信息来计算描述子。为了对图像亮度具有稳健性，SIFT 描述子使用图像梯度（之前 Harris 描述子使用图像亮度信息计算归一化互相关矩阵）。SIFT 描述子在每个像素点附近选取子区域网格，在每个子区域内计算图像梯度方向直方图。每个子区域的直方图拼接起来组成描述子向量。SIFT 描述子的标准设置使用 4×4 的子区域，每个子区域使用 8 个小区间的方向直方图，会产生共 128 个小区间的直方图（4×4×8=128）。图 2-3 所示为描述子的构造过程。感兴趣的读者可以参考文献 [18] 获取更多内容，或者从 http://en.wikipedia.org/wiki/Scaleinvariant_feature_transform 概要了解 SIFT 特征描述子。

图 2-3：构造 SIFT 描述子特征向量的图解：（a）一个围绕兴趣点的网格结构，其中该网格已经按照梯度主方向进行了旋转；（b）在网格的一个子区域内构造梯度方向的 8-bin 直方图；（c）在网格的每个子区域内提取直方图；（d）拼接直方图，得到一个长的特征向量

2.2.3　检测兴趣点

我们使用开源工具包 VLFeat 提供的二进制文件来计算图像的 SIFT 特征 [36]。用完整的 Python 实现 SIFT 特征的所有步骤可能效率不是很高，并且超出了本书的范围。VLFeat 工具包可以从 http://www.vlfeat.org/ 下载，二进制文件可以在所有主要的平台上运行。VLFeat 库是用 C 语言来写的，但是我们可以使用该库提供的命令行接口。如果你认为使用 Matlab 接口或者 Python 包装器比二进制文件更方便，可以从 http://github.com/mmmikael/vlfeat/ 下载相应的版本。由于 Python 包装器对平台的依赖性，安装 Python 包装器在某些平台上需要一定的技巧，所以我们这里使用二进制文件版本。Lowe 的个人网站上也有 SIFT 特征的实现，可以参见 http://www.cs.ubc.ca/~lowe/keypoints/，该代码仅适用于 Windows 系统和 Linux 系统。

创建 sift.py 文件，将下面调用可执行文件的函数添加到该文件中：

def process_image(imagename,resultname,params="--edge-thresh 10 --peak-thresh 5"):
  """ 处理一幅图像，然后将结果保存在文件中"""

  if imagename[-3:] != 'pgm':
    # 创建一个pgm 文件
    im = Image.open(imagename).convert('L')
    im.save('tmp.pgm')
    imagename = 'tmp.pgm'

  cmmd = str("sift "+imagename+" --output="+resultname+
        " "+params)
  os.system(cmmd)
  print 'processed', imagename, 'to', resultname

由于该二进制文件需要的图像格式为灰度 .pgm，所以如果图像为其他格式，我们需要首先将其转换成 .pgm 格式文件。转换的结果以易读的格式保存在文本文件中。文本文件如下：

318.861 7.48227 1.12001 1.68523 0 0 0 1 0 0 0 0 0 11 16 0 ...
318.861 7.48227 1.12001 2.99965 11 2 0 0 1 0 0 0 173 67 0 0 ...
54.2821 14.8586 0.895827 4.29821 60 46 0 0 0 0 0 0 99 42 0 0 ...
155.714 23.0575 1.10741 1.54095 6 0 0 0 150 11 0 0 150 18 2 1 ...
42.9729 24.2012 0.969313 4.68892 90 29 0 0 0 1 2 10 79 45 5 11 ...
229.037 23.7603 0.921754 1.48754 3 0 0 0 141 31 0 0 141 45 0 0 ...
232.362 24.0091 1.0578 1.65089 11 1 0 16 134 0 0 0 106 21 16 33 ...
201.256 25.5857 1.04879 2.01664 10 4 1 8 14 2 1 9 88 13 0 0 ...
…

上面数据的每一行前 4 个数值依次表示兴趣点的坐标、尺度和方向角度，后面紧接着的是对应描述符的 128 维向量。这里的描述子使用原始整数数值表示，没有经过归一化处理。当你需要比较这些描述符时，要做一些处理。更多的内容请见后面的介绍。

上面的例子显示的是在一幅图像中前 8 个特征的前面部分数值。注意前两行的坐标值相同，但是方向不同。当同一个兴趣点上出现不同的显著方向，这种情况就会出现的。

下面是如何从像上面的输出文件中，将特征读取到 NumPy 数组中的函数。将该函数添加到 sift.py 文件中：

def read_features_from_file(filename):
  """ 读取特征属性值，然后将其以矩阵的形式返回"""

  f = loadtxt(filename)
  return f[:,:4],f[:,4:] # 特征位置，描述子

在上面的函数中，我们使用 NumPy 库中的 loadtxt() 函数来处理所有的工作。

如果在 Python 会话中修改描述子，你需要将输出结果保存到特征文件中。下面的函数使用 NumPy 库中的 savetxt() 函数，可以帮你实现该功能：

def write_features_to_file(filename,locs,desc):
  """ 将特征位置和描述子保存到文件中"""
  savetxt(filename,hstack((locs,desc)))

上面的函数使用了 hstack() 函数。该函数通过拼接不同的行向量来实现水平堆叠两个向量的功能。在这个例子中，每一行中前几列为位置信息，紧接着是描述子。

读取特征后，通过在图像上绘制出它们的位置，可以将其可视化。将下面的 plot_features() 函数添加到 sift.py 文件中，可以实现该功能。

def plot_features(im,locs,circle=False):
  """ 显示带有特征的图像
    输入：im（数组图像），locs（每个特征的行、列、尺度和方向角度）"""

  def draw_circle(c,r):
    t = arange(0,1.01,.01)*2*pi
    x = r*cos(t) + c[0]
    y = r*sin(t) + c[1]
    plot(x,y,'b',linewidth=2)

  imshow(im)
  if circle:
    for p in locs:
      draw_circle(p[:2],p[2])
  else:
    plot(locs[:,0],locs[:,1],'ob')
  axis('off')

该函数在原始图像上使用蓝色的圆圈绘制出 SIFT 特征点的位置。将参数 circle 的选项设置为 True，该函数将使用 draw_circle() 函数绘制出圆圈，圆圈的半径为特征的尺度。

你可以通过下面的命令绘制出如图 2-4b 中 SIFT 特征位置的图像：

import sift

imname = 'empire.jpg'
im1 = array(Image.open(imname).convert('L'))
sift.process_image(imname,'empire.sift')
l1,d1 = sift.read_features_from_file('empire.sift')

figure()
gray()
sift.plot_features(im1,l1,circle=True)
show()

为了比较 Harris 角点和 SIFT 特征的不同，右图（图 2-4c）显示的是同一幅图像的 Harris 角点。你可以看到，两个算法所选择特征点的位置不同。

图 2-4：对一幅图像提取 SIFT 特征。（a）SIFT 特征；（b）使用圆圈表示特征尺度的 SIFT 特征；（c）为了比较，对于同一幅图像提取 Harris 角点

2.2.4　匹配描述子

对于将一幅图像中的特征匹配到另一幅图像的特征，一种稳健的准则（同样是由 Lowe 提出的）是使用这两个特征距离和两个最匹配特征距离的比率。相比于图像中的其他特征，该准则保证能够找到足够相似的唯一特征。使用该方法可以使错误的匹配数降低。下面的代码实现了匹配函数。将 match() 函数添加到 sift.py 文件中：

def match(desc1,desc2):
  """ 对于第一幅图像中的每个描述子，选取其在第二幅图像中的匹配
    输入：desc1（第一幅图像中的描述子），desc2（第二幅图像中的描述子）"""

  desc1 = array([d/linalg.norm(d) for d in desc1])
  desc2 = array([d/linalg.norm(d) for d in desc2])

  dist_ratio = 0.6
  desc1_size = desc1.shape

  matchscores = zeros((desc1_size[0],1),'int')
  desc2t = desc2.T                    # 预先计算矩阵转置
  for i in range(desc1_size[0]):
    dotprods = dot(desc1[i,:],desc2t) # 向量点乘
    dotprods = 0.9999*dotprods
    # 反余弦和反排序，返回第二幅图像中特征的索引
    indx = argsort(arccos(dotprods))

    # 检查最近邻的角度是否小于dist_ratio 乘以第二近邻的角度
    if arccos(dotprods)[indx[0]] < dist_ratio * arccos(dotprods)[indx[1]]:
    matchscores[i] = int(indx[0])

  return matchscores

该函数使用描述子向量间的夹角作为距离度量。在此之前，我们需要将描述子向量归一化到单位长度 2。因为这种匹配是单向的，即我们将每个特征向另一幅图像中的所有特征进行匹配，所以我们可以先计算第二幅图像兴趣点描述子向量的转置矩阵。这样，我们就不需要对每个特征分别进行转置操作。

2对于单位向量，向量乘积（不使用 arccos() 函数）等价于标准欧式距离度量。

为了进一步增加匹配的稳健性，我们可以再反过来执行一次该步骤，用另外的方法匹配（从第二幅图像中的特征向第一幅图像中的特征匹配）。最后，我们仅保留同时满足这两种匹配准则的对应（和我们对 Harris 角点的处理方法相同）。下面的 match_twosided() 函数可以实现该操作：

def match_twosided(desc1,desc2):
  """ 双向对称版本的match()"""

  matches_12 = match(desc1,desc2)
  matches_21 = match(desc2,desc1)

  ndx_12 = matches_12.nonzero()[0]

  # 去除不对称的匹配
  for n in ndx_12:
    if matches_21[int(matches_12[n])] != n:
      matches_12[n] = 0

  return matches_12

为了绘制出这些匹配点，我们可以使用在 harris.py 用到的相同函数。方便起见，将 appendimages() 函数和 plot_matches() 函数复制 过来。然后，将它们添加到 sift.py 文件中。如果你喜欢，也可以通过载入 harris.py 来使用这两个函数。

图 2-5 和图 2-6 是在图像对中检测 SIFT 特征点的例子，以及通过 match_twosided() 函数返回的特征点匹配情况。

图 2-5：在两幅图像间检测和匹配 SIFT 特征

图 2-6：在两幅图像间检测和匹配 SIFT 特征

图 2-7 是在两幅图像中分别使用 match() 函数和 match_twosided() 函数匹配特征的另一个例子。正如你所看到的一样，使用对称（两边）匹配条件可以去除不正确的匹配，保留好的匹配（一些正确的匹配也被去除了）。

图 2-7：在两幅图像间匹配 SIFT 特征的例子：（a）不使用两边匹配函数，将左边图像中的特征向右边图像中的特征匹配；（b）使用两边匹配函数后，剩余的匹配情况

通过检测和匹配特征点，我们可以将这些局部描述子应用到很多例子中。为了稳健地过滤掉这些不正确的匹配，接下来的两个章节将会在对应上加入几何学的约束关系，并将局部描述子应用到一些例子中，比如自动创建全景图、照相机姿态估计以及三维结构计算。

2.3　匹配地理标记图像

我们将通过一个示例应用来结束本章节。在这个例子中，我们使用局部描述子来匹配带有地理标记的图像。**

2.3.1　从Panoramio下载地理标记图像

你可以从谷歌提供的照片共享服务 Panoramio（http://www.panoramio.com/）获得地理标记图像。像许多网络资源一样，Panoramio 提供一个 API 接口，方便用户使用程序访问这些内容。Panoramio 的 API 非常简单直接，可以在 http://www.panoramio.com/api/ 上找到 API 的使用方式。你可以通过 HTTP GET 方式访问网址内容，如下：

http://www.panoramio.com/map/get_panoramas.php?order=popularity&set=public&from=0&to=20&minx=-180&miny=-90&maxx=180&maxy=90&size=medium

其中的 minx、miny、maxx 和 maxy 定义了选取照片的地理区域位置（分别表示最小经度、最小纬度、最大经度和最大纬度），你会得到可以简单解析的 JSON 格式的响应。JSON 是用于网络服务间数据传输的常用格式，比 XML 和其他格式更轻便。你可以从 http://en.wikipedia.org/wiki/JSON 获取更多关于 JSON 的内容。

你可以使用两个不同的视点来看华盛顿白宫的位置，通常从宾夕法尼亚大街南侧拍摄，或者从北侧拍摄。其坐标（纬度、经度）如下：

lt=38.897661
ln=-77.036564

为了转换成 API 调用需要的格式，需要在这些坐标值上减去或者加上一个数值，来获得以白宫为中心的正方形范围内的所有图像。调用如下：

http://www.panoramio.com/map/get_panoramas.php?order=popularity&set=public&from=0&to=20&minx=-77.037564&miny=38.896662&maxx=-77.035564&maxy=38.898662&size=medium

该调用返回在坐标边界内（±0.001）的前 20 幅图像，这些图像按照用户访问情况排序。调用的响应格式如下：

{ "count": 349,
"photos": [{"photo_id": 7715073, "photo_title": "White House", "photo_url":
"http://www.panoramio.com/photo/7715073", "photo_file_url":
"http://mw2.google.com/mw-panoramio/photos/medium/7715073.jpg", "longitude":
-77.036583, "latitude": 38.897488, "width": 500, "height": 375, "upload_date":
"10 February 2008", "owner_id": 1213603, "owner_name": "***", "owner_url":
"http://www.panoramio.com/user/1213603"}
,
{"photo_id": 1303971, "photo_title": "White House balcony", "photo_url":
"http://www.panoramio.com/photo/1303971", "photo_file_url":
"http://mw2.google.com/mw-panoramio/photos/medium/1303971.jpg", "longitude":
-77.036353, "latitude": 38.897471, "width": 500, "height": 336, "upload_date":
"13 March 2007", "owner_id": 195000, "owner_name": "***", "owner_url":
"http://www.panoramio.com/user/195000"}
...
]}

为了解析这个 JSON 格式的响应，我们可以使用 simplejson 工具包，可以从 http://github.com/simplejson/simplejson 下载。在项目界面上，可以看到在线的说明文档。

如果你使用的 Python 是 2.6 或之后的版本，因为在这些后来版本中已经包含 JSON 库，所以不需要使用 simplejson 工具包。如果想使用内置的 JSON 库，你只需要像这样导入即可：

import json

如果你想使用上面链接中的 simplejson 工具包（速度很快，并且比内置包含更新的内容），一个非常好的办法是使用可靠的方式导入它，如下：

try: import simplejson as json
except ImportError: import json

下面的代码将使用 Python 里的 urllib 工具包来处理请求，然后使用 simplejson 工具包解析返回结果：

import os
import urllib, urlparse
import simplejson as json

# 查询图像
url = 'http://www.panoramio.com/map/get_panoramas.php?order=popularity&\
    set=public&from=0&to=20&minx=-77.037564&miny=38.896662&\
    maxx=-77.035564&maxy=38.898662&size=medium'
c = urllib.urlopen(url)

# 从JSON 中获得每个图像的url
j = json.loads(c.read())
imurls = []
for im in j['photos']:
    imurls.append(im['photo_file_url'])

# 下载图像
for url in imurls:
    image = urllib.URLopener()
    image.retrieve(url, os.path.basename(urlparse.urlparse(url).path))
    print 'downloading:', url

通过 JSON 的输出可以看到，我们需要的是 photo_file_url 字段。运行上面的代码，在控制台上你应该能够看到类似下面的数据：

downloading: http://mw2.google.com/mw-panoramio/photos/medium/7715073.jpg
downloading: http://mw2.google.com/mw-panoramio/photos/medium/1303971.jpg
downloading: http://mw2.google.com/mw-panoramio/photos/medium/270077.jpg
downloading: http://mw2.google.com/mw-panoramio/photos/medium/15502.jpg
...

图 2-8 是本例子中返回的 20 幅图像。接下来，我们仅仅需要找到并匹配这些图像对之间的特征。

图 2-8：从 panoramio.com 下载的在同一个地理位置点（以白宫为中心的方形区域）上拍摄的图像

2.3.2　使用局部描述子匹配

我们刚才已经下载了这些图像，下面需要对这些图像提取局部描述子。在这种情况下，我们将使用前面部分讲述的 SIFT 特征描述子。我们假设已经对这些图像使用 SIFT 特征提取代码进行了处理，并且将特征保存在和图像同名（但文件名后缀是 .sift，而不是 .jpg）的文件中。假设 imlist 和 featlist 列表中包含这些文件名。我们可以对所有组合图像对进行逐个匹配，如下：

import sift

nbr_images = len(imlist)

matchscores = zeros((nbr_images,nbr_images))
for i in range(nbr_images):
  for j in range(i,nbr_images): # 仅仅计算上三角

    print 'comparing ', imlist[i], imlist[j]

    l1,d1 = sift.read_features_from_file(featlist[i])
    l2,d2 = sift.read_features_from_file(featlist[j])

    matches = sift.match_twosided(d1,d2)

    nbr_matches = sum(matches > 0)
    print 'number of matches = ', nbr_matches
    matchscores[i,j] = nbr_matches

# 复制值
for i in range(nbr_images):
  for j in range(i+1,nbr_images): # 不需要复制对角线
    matchscores[j,i] = matchscores[i,j]

我们将每对图像间的匹配特征数保存在 matchscores 数组中。因为该“距离度量”是对称的，所以我们可以不在代码的最后部分复制数值，来将 matchscores 矩阵填充完整；填充完整后的 matchscores 矩阵只是看起来更好。这些特定图像的 matchscores 矩阵里的数值如下：

662 0 0 2 0 0 0 0 1 0 0 1 2 0 3 0 19 1 0 2
0 901 0 1 0 0 0 1 1 0 0 1 0 0 0 0 0 0 1 2
0 0 266 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0
2 1 0 1481 0 0 2 2 0 0 0 2 2 0 0 0 2 3 2 0
0 0 0 0 1748 0 0 1 0 0 0 0 0 2 0 0 0 0 0 1
0 0 0 0 0 1747 0 0 1 0 0 0 0 0 0 0 0 1 1 0
0 0 0 2 0 0 555 0 0 0 1 4 4 0 2 0 0 5 1 0
0 1 0 2 1 0 0 2206 0 0 0 1 0 0 1 0 2 0 1 1
1 1 0 0 0 1 0 0 629 0 0 0 0 0 0 0 1 0 0 20
0 0 0 0 0 0 0 0 0 829 0 0 1 0 0 0 0 0 0 2
0 0 0 0 0 0 1 0 0 0 1025 0 0 0 0 0 1 1 1 0
1 1 0 2 0 0 4 1 0 0 0 528 5 2 15 0 3 6 0 0
2 0 0 2 0 0 4 0 0 1 0 5 736 1 4 0 3 37 1 0
0 0 1 0 2 0 0 0 0 0 0 2 1 620 1 0 0 1 0 0
3 0 0 0 0 0 2 1 0 0 0 15 4 1 553 0 6 9 1 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2273 0 1 0 0
19 0 0 2 0 0 0 2 1 0 1 3 3 0 6 0 542 0 0 0
1 0 0 3 0 1 5 0 0 0 1 6 37 1 9 1 0 527 3 0
0 1 0 2 0 1 1 1 0 0 1 0 1 0 1 0 0 3 1139 0
2 2 0 0 1 0 0 1 20 2 0 0 0 0 0 0 0 0 0 499

使用该 matchscores 矩阵作为图像间简单的距离度量方式（具有相似内容的图像间拥有更多的匹配特征数），下面我们可以使用相似的视觉内容来将这些图像连接起来。

2.3.3　可视化连接的图像

我们首先通过图像间是否具有匹配的局部描述子来定义图像间的连接，然后可视化这些连接情况。为了完成可视化，我们可以在图中显示这些图像，图的边代表连接。我们将会使用 pydot 工具包（http://code.google.com/p/pydot/），该工具包是功能强大的 GraphViz 图形库的 Python 接口。Pydot 使用 Pyparsing（http://pyparsing.wikispaces.com/） 和 GraphViz（http://www.graphviz.org/）；不用担心，这些都非常容易安装，只需要几分钟就可以安装成功。

Pydot 非常容易使用。下面的一小段代码很好地展示了这一点。该代码会创建一个图，该图表示深度为 2 的树，具有 5 个分支，将分支的编号添加到分支节点上。图的结构如图 2-9 所示。我们有很多方法来修改图的布局和外观。如果你想了解更多内容，可以查看 Pydot 的说明文档，或者在 http://www.graphviz.org/Documentation.php 查看 GraphViz 使用的 DOT 语言介绍。

import pydot

g = pydot.Dot(graph_type='graph')

g.add_node(pydot.Node(str(0),fontcolor='transparent'))
for i in range(5):
  g.add_node(pydot.Node(str(i+1)))
  g.add_edge(pydot.Edge(str(0),str(i+1)))
  for j in range(5):
    g.add_node(pydot.Node(str(j+1)+'-'+str(i+1)))
    g.add_edge(pydot.Edge(str(j+1)+'-'+str(i+1),str(j+1)))
g.write_png('graph.jpg',prog='neato')

图 2-9：使用 pydot 工具包创建图

我们接下来继续探讨地理标记图像处理的例子。为了创建显示可能图像组的图，如果匹配的数目高于一个阈值，我们使用边来连接相应的图像节点。为了得到图中的图像，需要使用图像的全路径（在下面例子中，使用 path 变量表示）。为了使图像看起来漂亮，我们需要将每幅图像尺度化为缩略图形式，缩略图的最大边为 100 像素。下面是具体实现代码：

import pydot

threshold = 2 # 创建关联需要的最小匹配数目

g = pydot.Dot(graph_type='graph') # 不使用默认的有向图
for i in range(nbr_images):
  for j in range(i+1,nbr_images):
    if matchscores[i,j] > threshold:
        # 图像对中的第一幅图像
        im = Image.open(imlist[i])
        im.thumbnail((100,100))
        filename = str(i)+'.png'
        im.save(filename) # 需要一定大小的临时文件
        g.add_node(pydot.Node(str(i),fontcolor='transparent',
              shape='rectangle',image=path+filename))

        # 图像对中的第二幅图像
        im = Image.open(imlist[j])
        im.thumbnail((100,100))
        filename = str(j)+'.png'
        im.save(filename) # 需要一定大小的临时文件
        g.add_node(pydot.Node(str(j),fontcolor='transparent',
                shape='rectangle',image=path+filename))

        g.add_edge(pydot.Edge(str(i),str(j)))

  g.write_png('whitehouse.png')

代码运行结果如图 2-10 所示。图的具体内容和结构取决于你下载的图像。对于这个特定的例子，我们使用两组图像，每组分别是两个视角的白宫图像。

图 2-10：使用局部描述子将在同一地理位置点拍摄图像进行分类

这个应用是使用局部描述子来匹配图像间区域的一个简单例子。在该应用中，我们没有使用针对任何匹配的限制约束。匹配的约束（具有很强的稳健性）可以通过接下来两章中的内容来实现。

练习

为了让匹配具有更强的稳健性，修改用于匹配 Harris 角点的函数，使其输入参数中包含认为两点存在对应关系允许的最大像素距离。

对一幅图像不断地应用模糊操作（或者 ROF 去噪），使得模糊效果越来越强，然后提取 Harris 角点，会出现什么问题？

另一种 Harris 角点检测器是快速角点检测器。有很多快速角点检测器的实现方法，包括纯 Python 语言实现的版本，可以在 http://www.edwardrosten.com/work/fast.html 下载。尝试使用该检测器，使用敏感性的阈值，然后将结果和 Harris 角 点检测器检测出的角点比较。

以不同分辨率创建一幅图像的副本（例如，可以尝试多次将图像的尺寸减半）。对每幅图像提取 SIFT 特征。绘制以及匹配特征，来发现尺度的独立性是如何以及何时失效的。

VLFeat 命令行工具同样实现了最大稳定极值区域（MSER，http://en.wikipedia.org/wiki/Maximally_stable_extremal_regions）算法。该算法是个能够找到角点一侧区域的区域检测器。创建一个用于提取 MSER 区域的函数，然后使用 -read-frames 选项将它们传递给 SIFT 特征描述子部分，最后写出一个用于绘制该区域边界的函数。

基于对应关系，写出在图像对间匹配特征的函数，以实现估计尺度差异以及场景的平面旋转。

任意选取一个位置，然后下载该位置的图像，像白宫例子一样将它们匹配起来。你能发现用于连接这些图像的更好方式吗？你是如何利用图来选取用于地理位置具有代表性的图像的？

第 3 章　图像到图像的映射

本章讲解图像之间的变换，以及一些计算变换的实用方法。这些变换可以用于图像扭曲变形和图像配准。最后，我们将会介绍一个自动创建全景图像的例子。

3.1　单应性变换

单应性变换是将一个平面内的点映射到另一个平面内的二维投影变换。在这里，平面是指图像或者三维中的平面表面。单应性变换具有很强的实用性，比如图像配准、图像纠正和纹理扭曲，以及创建全景图像。我们将频繁地使用单应性变换。本质上，单应性变换 H，按照下面的方程映射二维中的点（齐次坐标意义下）：

\begin{bmatrix}x'\\y'\\ w'\end{bmatrix}=\begin{bmatrix}h_1 & h_2 & h_3\\h_4 & h_5 & h_6\\h_7 & h_8 & h_9\\\end{bmatrix}\begin{bmatrix}x\\y\\w\\\end{bmatrix} 或 x' = H x

对于图像平面内（甚至是三维中的点，后面我们会介绍到）的点，齐次坐标是个非常有用的表示方式。点的齐次坐标是依赖于其尺度定义的，所以，x=[x,y,w]=[αx,αy,αw]=[x/w,y/w,1] 都表示同一个二维点。因此，单应性矩阵 H 也仅依赖尺度定义，所以，单应性矩阵具有 8 个独立的自由度。我们通常使用 w=1 来归一化点，这样，点具有唯一的图像坐标 x 和 y。这个额外的坐标使得我们可以简单地使用一个矩阵来表示变换。

创建 homography.py 文件。下面的函数可以实现对点进行归一化和转换齐次坐标的功能，将其添加到 homography.py 文件中：

def normalize(points):
  """ 在齐次坐标意义下，对点集进行归一化，使最后一行为1 """
    for row in points:
      row /= points[-1]
    return points

def make_homog(points):
  """ 将点集（dim×n 的数组）转换为齐次坐标表示 """

  return vstack((points,ones((1,points.shape[1]))))

进行点和变换的处理时，我们会按照列优先的原则存储这些点。因此，n 个二维点集将会存储为齐次坐标意义下的一个 3×n 数组。这种格式使得矩阵乘法和点的变换操作更加容易。对于其他的例子，比如对于聚类和分类的特征，我们将使用典型的行数组来存储数据。

在这些投影变换中，有一些特别重要的变换。比如，仿射变换：

\begin{bmatrix}x'\\y'\\1\end{bmatrix}=\begin{bmatrix}a_1 & a_2 & t_x\\a_3 & a_4 & t_y\\0 & 0 & 1\end{bmatrix}\begin{bmatrix}x\\y\\z\end{bmatrix} 或 x'=\begin{bmatrix}\boldsymbol{A} & \boldsymbol{t}\\0 & 1\end{bmatrix}x

保持了 w=1, 不具有投影变换所具有的强大变形能力。仿射变换包含一个可逆矩阵 A 和一个平移向量 t=[tx,ty]。仿射变换可以用于很多应用，比如图像扭曲。

相似变换：

\begin{bmatrix}x'\\y'\\1\end{bmatrix}=\begin{bmatrix}s\cos(\theta) & -s \sin(\theta) & t_x\\s\sin(\theta) & -s \cos(\theta) & t_y\\0 & 0 & 1\end{bmatrix}\begin{bmatrix}x\\y\\1\end{bmatrix} 或 x'=\begin{bmatrix}s\boldsymbol{R} & \boldsymbol{t}\\0 & 1\end{bmatrix}x

是一个包含尺度变化的二维刚体变换。上式中的向量 s 指定了变换的尺度，R 是角度为 θ 的旋转矩阵，t=[tx,ty] 在这里也是一个平移向量。如果 s=1，那么该变换能够保持距离不变。此时，变换为刚体变换。相似变换可以用于很多应用，比如图像配准。

下面让我们一起探讨如何设计用于估计单应性矩阵的算法，然后看一下使用仿射变换进行图像扭曲，使用相似变换进行图像匹配，以及使用完全投影变换进行创建全景图像的一些例子。

3.1.1　直接线性变换算法

单应性矩阵可以由两幅图像（或者平面）中对应点对计算出来。前面已经提到过，一个完全射影变换具有 8 个自由度。根据对应点约束，每个对应点对可以写出两个方程，分别对应于 x 和 y 坐标。因此，计算单应性矩阵 H 需要４个对应点对。

DLT（Direct Linear Transformation，直接线性变换）是给定 4 个或者更多对应点对矩阵，来计算单应性矩阵 H 的算法。将单应性矩阵 H 作用在对应点对上，重新写出该方程，我们可以得到下面的方程：

或者 Ah=0，其中 A 是一个具有对应点对二倍数量行数的矩阵。将这些对应点对方程的系数堆叠到一个矩阵中，我们可以使用 SVD（Singular Value Decomposition，奇异值分解）算法找到 H 的最小二乘解。下面是该算法的代码。将下面的函数添加到 homography.py 文件中：

def H_from_points(fp,tp):
  """ 使用线性DLT 方法，计算单应性矩阵H，使fp 映射到tp。点自动进行归一化"""

  if fp.shape != tp.shape:
    raise RuntimeError('number of points do not match')

  # 对点进行归一化（对数值计算很重要）
  # --- 映射起始点---
  m = mean(fp[:2], axis=1)
  maxstd = max(std(fp[:2], axis=1)) + 1e-9
  C1 = diag([1/maxstd, 1/maxstd, 1])
  C1[0][2] = -m[0]/maxstd
  C1[1][2] = -m[1]/maxstd
  fp = dot(C1,fp)

  # --- 映射对应点---
  m = mean(tp[:2], axis=1)
  maxstd = max(std(tp[:2], axis=1)) + 1e-9
  C2 = diag([1/maxstd, 1/maxstd, 1])
  C2[0][2] = -m[0]/maxstd
  C2[1][2] = -m[1]/maxstd
  tp = dot(C2,tp)

  # 创建用于线性方法的矩阵，对于每个对应对，在矩阵中会出现两行数值
  nbr_correspondences = fp.shape[1]
  A = zeros((2*nbr_correspondences,9))
  for i in range(nbr_correspondences):
     A[2*i] = [-fp[0][i],-fp[1][i],-1,0,0,0,
           tp[0][i]*fp[0][i],tp[0][i]*fp[1][i],tp[0][i]]
     A[2*i+1] = [0,0,0,-fp[0][i],-fp[1][i],-1,
           tp[1][i]*fp[0][i],tp[1][i]*fp[1][i],tp[1][i]]

  U,S,V = linalg.svd(A)
  H = V[8].reshape((3,3))

  # 反归一化
  H = dot(linalg.inv(C2),dot(H,C1))

  # 归一化，然后返回
  return H / H[2,2]

上面函数的第一步操作是检查点对的两个数组中点的数目是否相同。如果不相同，函数将会抛出异常信息。这对于写出稳健的代码来说非常有用。但是，为了使得代码例子更简单、更容易理解，我们在本书中仅在很少的例子中使用异常处理技巧。你可以在 http://docs.python.org/library/exceptions.html 查阅更多关于异常类型的内容，以及在 http://docs.python.org/tutorial/errors.html 上了解如何使用它们。

对这些点进行归一化操作，使其均值为 0，方差为 1。因为算法的稳定性取决于坐标的表示情况和部分数值计算的问题，所以归一化操作非常重要。接下来我们使用 对应点对来构造矩阵 A。最小二乘解即为矩阵 SVD 分解后所得矩阵 V 的最后一行。 该行经过变形后得到矩阵 H。然后对这个矩阵进行处理和归一化，返回输出。

3.1.2　仿射变换

由于仿射变换具有 6 个自由度，因此我们需要三个对应点对来估计矩阵 H。通过将最后两个元素设置为 0，即 h₇=h₈=0，仿射变换可以用上面的 DLT 算法估计得出。

这里我们将使用不同的方法来计算单应性矩阵 H，这在文献 [13] 中有详细的描述（第 130 页）。下面的函数使用对应点对来计算仿射变换矩阵，将其添加到 homograph.py 文件中：

def Haffine_from_points(fp,tp):
  """ 计算H，仿射变换，使得tp 是fp 经过仿射变换H 得到的"""

  if fp.shape != tp.shape:
    raise RuntimeError('number of points do not match')

  # 对点进行归一化
  # --- 映射起始点---
  m = mean(fp[:2], axis=1)
  maxstd = max(std(fp[:2], axis=1)) + 1e-9
  C1 = diag([1/maxstd, 1/maxstd, 1])
  C1[0][2] = -m[0]/maxstd
  C1[1][2] = -m[1]/maxstd
  fp_cond = dot(C1,fp)

  # --- 映射对应点---
  m = mean(tp[:2], axis=1)
  C2 = C1.copy() # 两个点集，必须都进行相同的缩放
  C2[0][2] = -m[0]/maxstd
  C2[1][2] = -m[1]/maxstd
  tp_cond = dot(C2,tp)

  # 因为归一化后点的均值为0，所以平移量为0
  A = concatenate((fp_cond[:2],tp_cond[:2]), axis=0)
  U,S,V = linalg.svd(A.T)

  # 如Hartley 和Zisserman 著的Multiple View Geometry in Computer , Scond Edition 所示，
  # 创建矩阵B 和C
  tmp = V[:2].T
  B = tmp[:2]
  C = tmp[2:4]

  tmp2 = concatenate((dot(C,linalg.pinv(B)),zeros((2,1))), axis=1)
  H = vstack((tmp2,[0,0,1]))

  # 反归一化
  H = dot(linalg.inv(C2),dot(H,C1))

  return H / H[2,2]

同样地，类似于 DLT 算法，这些点需要经过预处理和去处理化操作。在下一节中，让我们一起来看这些仿射变换是如何处理图像的。

3.2　图像扭曲

对图像块应用仿射变换，我们将其称为图像扭曲（或者仿射扭曲）。该操作不仅经常应用在计算机图形学中，而且经常出现在计算机视觉算法中。扭曲操作可以使用 SciPy 工具包中的 ndimage 包来简单完成。命令：

transformed_im = ndimage.affine_transform(im,A,b,size)

使用如上所示的一个线性变换 A 和一个平移向量 b 来对图像块应用仿射变换。选项参数 size 可以用来指定输出图像的大小。默认输出图像设置为和原始图像同样大小。为了研究该函数是如何工作的，我们可以试着运行下面的命令：

from scipy import ndimage

im = array(Image.open('empire.jpg').convert('L'))
H = array([[1.4,0.05,-100],[0.05,1.5,-100],[0,0,1]])
im2 = ndimage.affine_transform(im,H[:2,:2],(H[0,2],H[1,2]))

figure()
gray()
imshow(im2)
show()

该命令输出结果图像如图 3-1（右）所示。可以看到，输出图像结果中丢失的像素用零来填充。

图 3-1：用仿射变换扭曲图像。原始图像（左图）以及使用 ndimage.affine_transform() 函数扭曲后的图像（右图）

3.2.1　图像中的图像

仿射扭曲的一个简单例子是，将图像或者图像的一部分放置在另一幅图像中，使得它们能够和指定的区域或者标记物对齐。

将函数 image_in_image() 添加到 warp.py 文件中。该函数的输入参数为两幅图像和一个坐标。该坐标为将第一幅图像放置到第二幅图像中的角点坐标：

def image_in_image(im1,im2,tp):
  """ 使用仿射变换将im1 放置在im2 上，使im1 图像的角和tp 尽可能的靠近
    tp 是齐次表示的，并且是按照从左上角逆时针计算的"""

  # 扭曲的点
  m,n = im1.shape[:2]
  fp = array([[0,m,m,0],[0,0,n,n],[1,1,1,1]])

  # 计算仿射变换，并且将其应用于图像im1
  H = homography.Haffine_from_points(tp,fp)
  im1_t = ndimage.affine_transform(im1,H[:2,:2],
          (H[0,2],H[1,2]),im2.shape[:2])
  alpha = (im1_t > 0)

  return (1-alpha)*im2 + alpha*im1_t

正如你所看到的，该函数没有很多繁杂的操作。将扭曲的图像和第二幅图像融合，我们就创建了 alpha 图像。该图像定义了每个像素从各个图像中获取的像素值成分多少。这里我们基于以下事实，扭曲的图像是在扭曲区域边界之外以 0 来填充的图像，来创建一个二值的 alpha 图像。严格意义上说，我们需要在第一幅图像中的潜在 0 像素上加上一个小的数值，或者合理地处理这些 0 像素（参见本章结尾的练习部分）。注意，这里我们使用的图像坐标是齐次坐标意义下的。

试着使用该函数将公告牌中的一幅图像插入另一幅图像。下面几行代码会将图 3-2 中最左端的图像插入到第二幅图像中。这些坐标值是通过查看绘制的图像（在 PyLab 图像中，鼠标的坐标显示在图像底部附近）手工确定的。当然，也可以用 PyLab 类 库中的 ginput() 函数获得。

图 3-2：使用仿射变换将一幅图像放置到另一幅图像中

import warp

# 仿射扭曲im1 到im2 的例子
im1 = array(Image.open('beatles.jpg').convert('L'))
im2 = array(Image.open('billboard_for_rent.jpg').convert('L'))

# 选定一些目标点
tp = array([[264,538,540,264],[40,36,605,605],[1,1,1,1]])

im3 = warp.image_in_image(im1,im2,tp)

figure()
gray()
imshow(im3)
axis('equal')
axis('off')
show()

上面的代码将图像放置在公告牌的上半部分。需要注意，标记物的坐标 tp 是用齐次坐标意义下的坐标表示的。将这些坐标换成：

tp = array([[675,826,826,677],[55,52,281,277],[1,1,1,1]])

会将图像放置在公告牌的左下“for rent”部分。

函数 Haffine_from_points() 会返回给定对应点对的最优仿射变换。在上面的例子中，对应点对为图像和公告牌的角点。如果透视效应比较弱，那么这种方法会返回很好的结果。图 3-3 的上面一行显示出，在具有很强透视效应的情况下，在公告牌图像上使用射影变换输出图像的情况。在这种情况下，我们不可能使用同一个仿射变换将全部 4 个角点变换到它们的目标位置（尽管我们可以使用完全投影变换来完成该任务）。所以，当你打算使用仿射变换时，有一个很有用的技巧。

图 3-3：比较完全图像的仿射扭曲和使用两个三角形的仿射弯曲效果。图像放置在公告牌上，并且带有一些透视效应。对于整幅图像使用仿射扭曲效果不好。为了看清楚，我们将右边的两个角点放大（上）。使用包含两个三角形的仿射变换可以很好地将图像完全放置到公告牌上（下）

对于三个点，仿射变换可以将一幅图像进行扭曲，使这三对对应点对可以完美地匹配上。这是因为，仿射变换具有 6 个自由度，三个对应点对可以给出 6 个约束条件（对于这三个对应点对，x 和 y 坐标必须都要匹配）。所以，如果你真的打算使用仿射变换将图像放置到公告牌上，可以将图像分成两个三角形，然后对它们分别进行扭曲图像操作。下面是具体实现的代码：

# 选定im1 角上的一些点
m,n = im1.shape[:2]
fp = array([[0,m,m,0],[0,0,n,n],[1,1,1,1]])

# 第一个三角形
tp2 = tp[:,:3]
fp2 = fp[:,:3]

# 计算H
H = homography.Haffine_from_points(tp2,fp2)
im1_t = ndimage.affine_transform(im1,H[:2,:2],
          (H[0,2],H[1,2]),im2.shape[:2])

# 三角形的alpha
alpha = warp.alpha_for_triangle(tp2,im2.shape[0],im2.shape[1])
im3 = (1-alpha)*im2 + alpha*im1_t

# 第二个三角形
tp2 = tp[:,[0,2,3]]
fp2 = fp[:,[0,2,3]]

# 计算H
H = homography.Haffine_from_points(tp2,fp2)
im1_t = ndimage.affine_transform(im1,H[:2,:2],
          (H[0,2],H[1,2]),im2.shape[:2])

# 三角形的alpha 图像
alpha = warp.alpha_for_triangle(tp2,im2.shape[0],im2.shape[1])
im4 = (1-alpha)*im3 + alpha*im1_t

figure()
gray()
imshow(im4)
axis('equal')
axis('off')
show()

这里我们简单地为每个三角形创建了 alpha 图像，然后将所有的图像合并起来。该三角形的 alpha 图像可以简单地通过检查像素的坐标是否能够写成三角形顶点坐标的凸组合来计算得出 1。如果坐标可以表示成这种形式，那么该像素就位于三角形的内部。上面的例子使用了下面的函数 alpha_for_triangle()，将其添加到 warp.py 文件中。

1凸组合是形式为 \sum_j \alpha_jx_i 的线性组合（在三角形的例子中），其中所有的系数 αj非负，并且和为 1。

def alpha_for_triangle(points,m,n):
  """ 对于带有由points 定义角点的三角形，创建大小为(m，n) 的alpha 图
    （在归一化的齐次坐标意义下）"""

  alpha = zeros((m,n))
    for i in range(min(points[0]),max(points[0])):
      for j in range(min(points[1]),max(points[1])):
        x = linalg.solve(points,[i,j,1])
        if min(x) > 0: # 所有系数都大于零
            alpha[i,j] = 1
  return alpha

你的显卡可以极其快速地操作上面的代码。Python 语言的处理速度比你的显卡（或者 C/C++ 实现）慢很多，但是对于我们来说已经够用了。正如在图 3-3 下半部分所看到的，角点可以很好地匹配。

3.2.2　分段仿射扭曲

正如上面的例子所示，三角形图像块的仿射扭曲可以完成角点的精确匹配。让我们看一下对应点对集合之间最常用的扭曲方式：分段仿射扭曲。给定任意图像的标记点，通过将这些点进行三角剖分，然后使用仿射扭曲来扭曲每个三角形，我们可以将图像和另一幅图像的对应标记点扭曲对应。对于任何图形和图像处理库来说，这些都是最基本的操作。下面我们来演示一下如何使用 Matplotlib 和 SciPy 来完成该操作。

为了三角化这些点，我们经常使用狄洛克三角剖分方法。在 Matplotlib（但是不在 PyLab 库中）中有狄洛克三角剖分，我们可以用下面的方式使用它：

import matplotlib.delaunay as md

x,y = array(random.standard_normal((2,100)))
centers,edges,tri,neighbors = md.delaunay(x,y)

figure()
for t in tri:
    t_ext = [t[0], t[1], t[2], t[0]] # 将第一个点加入到最后
    plot(x[t_ext],y[t_ext],'r')

plot(x,y,'*')
axis('off')
show()

图 3-4 显示了一些实例点和三角剖分的结果。狄洛克三角剖分选择一些三角形，使三角剖分中所有三角形的最小角度最大 2。函数 delaunay() 有 4 个输出，其中我们仅需要三角形列表信息（第三个输出）。在 warp.py 文件中创建用于三角剖分的函数：

2三角剖分中的边实际上是泰森图的对偶图。参见 http://en.wikipedia.org/wiki/Delaunay_triangulation。

import matplotlib.delaunay as md

def triangulate_points(x,y):
  """ 二维点的Delaunay 三角剖分"""

  centers,edges,tri,neighbors = md.delaunay(x,y)
  return tri

函数输出的是一个数组，该数组的每一行包含对应数组 x 和 y 中每个三角形三个点的切片。

图 3-4：随机二维点集的狄洛克三角剖分示例

现在让我们将该算法应用于一个例子，在该例子中，在 5×6 的网格上使用 30 个控制点，将一幅图像扭曲到另一幅图像中的非平坦区域。图 3-5b 所示的是将一幅图像扭曲到“turning torso”的表面。目标点是使用 ginput() 函数手工选取出来的，将结果保存在 turningtorso_points.txt 文件中。

首先，我们需要写出一个用于分段仿射图像扭曲的通用扭曲函数。下面的代码可以实现该功能。在该代码中，我们也展示了如何扭曲一幅彩色图像（你仅需要对每个颜色通道进行扭曲）。

def pw_affine(fromim,toim,fp,tp,tri):
  """ 从一幅图像中扭曲矩形图像块
    fromim= 将要扭曲的图像
    toim= 目标图像
    fp= 齐次坐标表示下，扭曲前的点
    tp= 齐次坐标表示下，扭曲后的点
    tri= 三角剖分"""

  im = toim.copy()

  # 检查图像是灰度图像还是彩色图象
  is_color = len(fromim.shape) == 3

  # 创建扭曲后的图像（如果需要对彩色图像的每个颜色通道进行迭代操作，那么有必要这样做）
  im_t = zeros(im.shape, 'uint8')

  for t in tri:
    # 计算仿射变换
    H = homography.Haffine_from_points(tp[:,t],fp[:,t])

    if is_color:
      for col in range(fromim.shape[2]):
      im_t[:,:,col] = ndimage.affine_transform(
        fromim[:,:,col],H[:2,:2],(H[0,2],H[1,2]),im.shape[:2])
    else:
      im_t = ndimage.affine_transform(
        fromim,H[:2,:2],(H[0,2],H[1,2]),im.shape[:2])

    # 三角形的alpha
    alpha = alpha_for_triangle(tp[:,t],im.shape[0],im.shape[1])

    # 将三角形加入到图像中
    im[alpha>0] = im_t[alpha>0]

  return im

在该代码中，我们首先检查该图像是灰度图像还是彩色图像。如果图像为彩色图像，则对每个颜色通道进行扭曲处理。因为对于每个三角形来说，仿射变换是唯一确定的，所以我们这里使用 Haffine_from_points() 函数来处理。将上面的函数添加到 warp.py 文件中。

为了将该函数应用到当前例子中，接下来的简短脚本将这些操作统一起来：

import homography
import warp

# 打开图像，并将其扭曲
fromim = array(Image.open('sunset_tree.jpg'))
x,y = meshgrid(range(5),range(6))
x = (fromim.shape[1]/4) * x.flatten()
y = (fromim.shape[0]/5) * y.flatten()

# 三角剖分
tri = warp.triangulate_points(x,y)

# 打开图像和目标点
im = array(Image.open('turningtorso1.jpg'))
tp = loadtxt('turningtorso1_points.txt') # destination points

# 将点转换成齐次坐标
fp = vstack((y,x,ones((1,len(x)))))
tp = vstack((tp[:,1],tp[:,0],ones((1,len(tp)))))

# 扭曲三角形
im = warp.pw_affine(fromim,im,fp,tp,tri)

# 绘制图像
figure()
imshow(im)
warp.plot_mesh(tp[1],tp[0],tri)
axis('off')
show()

输出结果如图 3-5c 所示。我们通过下面的辅助函数（将其添加到 warp.py 文件中）来绘制出图像中的这些三角形：

def plot_mesh(x,y,tri):
  """ 绘制三角形"""

  for t in tri:
    t_ext = [t[0], t[1], t[2], t[0]] # 将第一个点加入到最后
    plot(x[t_ext],y[t_ext],'r')

图 3-5：使用狄洛克三角剖分标记点进行分段仿射扭曲：（a）为带有标记物的目标图像；（b）为带有三角剖分的图像；（c）为扭曲后的图像；（d）为带有三角剖分的扭曲图像

这个例子应该能够帮助你在应用中做图像的分段仿射扭曲。我们可以对该例子中的函数进行改进。我们将其中一部分留作练习，剩下的留给你自己解决。

3.2.3　图像配准

图像配准是对图像进行变换，使变换后的图像能够在常见的坐标系中对齐。配准可以是严格配准，也可以是非严格配准。为了能够进行图像对比和更精细的图像分析，图像配准是一步非常重要的操作。

让我们一起看一个对多个人脸图像进行严格配准的例子。该配准使得我们计算的平均人脸和人脸表观的变化具有意义。因为，图像中的人脸并不都有相同的大小、位置和方向，所以，在这种类型的配准中，我们实际上是寻找一个相似变换（带有尺度变化的刚体变换），在对应点对之间建立映射。

在 jkface.zip 文件中有 366 幅单人图像（2008 年，每天一幅）。3 这些图像都对眼睛和嘴的坐标进行了标记，结果保存在 jkface.xml 文件中。使用这些点，我们可以计算出一个相似变换，然后将可以使用该变换（包含尺度变换）的这些图像扭曲到一个归一化的坐标系中。为了读取 XML 格式的文件，我们将会使用 Python 中内置 xml.dom 模块中的 minidom 类库。

3这些图像是由 J. K. Keller（经过许可）提供的，详情参见 http://jk-keller.com/daily-photo/。

该 XML 文件看起来类似于下面的格式：

<?xml version="1.0" encoding="utf-8"?>
<faces>
         <face file="jk-002.jpg" xf="46" xm="56" xs="67" yf="38" ym="65" ys="39"/>
         <face file="jk-006.jpg" xf="38" xm="48" xs="59" yf="38" ym="65" ys="38"/>
         <face file="jk-004.jpg" xf="40" xm="50" xs="61" yf="38" ym="66" ys="39"/>
         <face file="jk-010.jpg" xf="33" xm="44" xs="55" yf="38" ym="65" ys="38"/>
…
</faces>

为了从该文件中读取这些坐标，我们需要将使用 minidom 的函数添加到新文件 imregistration.py 中：

from xml.dom import minidom

def read_points_from_xml(xmlFileName):
  """ 读取用于人脸对齐的控制点"""

  xmldoc = minidom.parse(xmlFileName)
  facelist = xmldoc.getElementsByTagName('face')
  faces = {}
  for xmlFace in facelist:
    fileName = xmlFace.attributes['file'].value
    xf = int(xmlFace.attributes['xf'].value)
    yf = int(xmlFace.attributes['yf'].value)
    xs = int(xmlFace.attributes['xs'].value)
    ys = int(xmlFace.attributes['ys'].value)
    xm = int(xmlFace.attributes['xm'].value)
    ym = int(xmlFace.attributes['ym'].value)
    faces[fileName] = array([xf, yf, xs, ys, xm, ym])
  return faces

这些标记点会在 Python 中以字典的形式返回，字典的键值为图像的文件名。格式为：图像中左眼（人脸右侧）的坐标为 xf 和 yf，右眼的坐标为 xs 和 ys，嘴的坐标 为 xm 和 ym。为了计算相似变换中的参数，我们可以使用最小二乘解来解决。对于每个点 xi=[xi, yi]（在这个例子中，每幅图像有三个点），这些点应该被映射到目标位置 [\hat x_i,\hat y_i]，如下所示：

将这三个点都表示成该形式，我们可以重新将其写成方程组的形式。该方程组中含有 a、b、tx、ty未知量，如下所示：

下面我们使用相似矩阵的参数化表示方式：

其中尺度 s=\sqrt{a^2+b^2}，旋转矩阵为 R。

如果存在更多的对应点对，其计算公式相同，只需在矩阵中额外添加几行。你可以使用 linalg.lstsq() 函数来计算该问题的最小二乘解。使用最小二乘解的思想是一个标准技巧，我们还会在本书中多次使用。实际上，这和之前在 DLT 算法中使用的方式相同。

函数的具体代码如下（将其添加到 imregistration.py 文件中）：

from scipy import linalg

def compute_rigid_transform(refpoints,points):
  """ 计算用于将点对齐到参考点的旋转、尺度和平移量"""

  A = array([ [points[0], -points[1], 1, 0],
              [points[1], points[0], 0, 1],
              [points[2], -points[3], 1, 0],
              [points[3], points[2], 0, 1],
              [points[4], -points[5], 1, 0],
              [points[5], points[4], 0, 1]])

  y = array([ refpoints[0],
              refpoints[1],
              refpoints[2],
              refpoints[3],
              refpoints[4],
              refpoints[5]])

  # 计算最小化||Ax -y || 的最小二乘解
  a,b,tx,ty = linalg.lstsq(A,y)[0]
  R = array([[a, -b], [b, a]]) # 包含尺度的旋转矩阵

  return R,tx,ty

该函数返回一个具有尺度的旋转矩阵，以及在 x 和 y 方向上的平移量。为了扭曲图像，并保存对齐后的新图像，我们可以对每个颜色通道（这些图像都是彩色图像）应用 ndimage.affine_transform() 函数操作。作为参考坐标系，你可以使用任何三个点的坐标。这里我们为了简单起见，直接使用第一幅图像中的标记位置：

from scipy import ndimage
from scipy.misc import imsave
import os

def rigid_alignment(faces,path,plotflag=False):
  """ 严格对齐图像，并将其保存为新的图像
    path 决定对齐后图像保存的位置
    设置plotflag=True，以绘制图像"""

  # 将第一幅图像中的点作为参考点
  refpoints = faces.values()[0]

  # 使用仿射变换扭曲每幅图像
  for face in faces:
    points = faces[face]
  R,tx,ty = compute_rigid_transform(refpoints, points)
  T = array([[R[1][1], R[1][0]], [R[0][1], R[0][0]]])

  im = array(Image.open(os.path.join(path,face)))
  im2 = zeros(im.shape, 'uint8')

  # 对每个颜色通道进行扭曲
  for i in range(len(im.shape)):
    im2[:,:,i] = ndimage.affine_transform(im[:,:,i],linalg.inv(T),offset=[-ty,-tx])

  if plotflag:
    imshow(im2)
    show()

  # 裁剪边界，并保存对齐后的图像
  h,w = im2.shape[:2]
  border = (w+h)/20

  # 裁剪边界
  imsave(os.path.join(path, 'aligned/'+face),im2[border:h-border,border:w-border,:])

这里我们使用 imsave() 函数来将对齐后的图像保存到 aligned 子文件夹中。

接下来的简短脚本会读取 XML 文件，其中文件名为键，点的坐标为键值。然后配准所有的图像，将它们与第一幅图像对齐：

import imregistration

# 载入控制点的位置
xmlFileName = 'jkfaces2008_small/jkfaces.xml'
points = imregistration.read_points_from_xml(xmlFileName)

# 注册
imregistration.rigid_alignment(points,'jkfaces2008_small/')

运行这些代码，你能够在子目录中得到这些对齐后的人脸图像。图 3-6 所示为配准前后的 6 幅样本图像。由于配准后图像的边界可能会出现不想要的黑色填充像素，所以我们对配准后的图像进行轻微的修剪，来删除这些黑色填充像素。

图 3-6：配准之前（上）和配准之后（下）的样本图像

现在让我们看配准操作如何影响平均图像。图 3-7 为未对齐人脸图像的平均图像，旁边是对齐后图像的平均图像。（注意，由于对齐后图像的边界有裁剪，所以两幅图像的大小有差异）尽管在原始图像中，人脸的尺寸、方向和位置变化都很小，但是配准操作对平均图像的计算结果影响很大。

图 3-7：平均图像的比较：没有对齐操作（左）；经过三点刚体对齐操作（右）

自然地，使用未准确配准的图像同样对主成分的计算结果有着很大的影响。图 3-8 表示，从未经过配准和经过配准的数据集中选取前 150 幅图像，PCA 的计算结果。正如平均图像一样，未配准的 PCA 模式是模糊的。在计算主成分时，我们使用以平均人脸位置为中心的椭圆掩膜。在堆叠这些图像之前，将这些图像和掩膜相乘，我们能够避免将背景变化带入到 PCA 模式中。将 1.3 节 PCA 例子中创建矩阵的一行替换为：

immatrix = array([mask*array(Image.open(imlist[i]).convert('L')).flatten()
           for i in range(150)],'f')

其中 mask 是一副同样大小的二值图像，已经经过压平处理。

图 3-8：比较未配准和已配准图像的 PCA 模式：未经过配准的平均图像和前 9 个主成分（上）；经过配准后的平均图像和前 9 个主成分（下）

3.3　创建全景图

在同一位置（即图像的照相机位置相同）拍摄的两幅或者多幅图像是单应性相关的（如图 3-9 所示）。我们经常使用该约束将很多图像缝补起来，拼成一个大的图像来创建全景图像。在本节中，我们将探讨如何创建全景图像。

图 3-9：瑞典隆德主要大学建筑的５幅图像。这些图像都是从同一个视点拍摄的

3.3.1　RANSAC

RANSAC 是“RANdom SAmple Consensus”（随机一致性采样）的缩写。该方法是用来找到正确模型来拟合带有噪声数据的迭代方法。给定一个模型，例如点集之间的单应性矩阵，RANSAC 基本的思想是，数据中包含正确的点和噪声点，合理的模型应该能够在描述正确数据点的同时摒弃噪声点。

RANSAC 的标准例子：用一条直线拟合带有噪声数据的点集。简单的最小二乘在该例子中可能会失效，但是 RANSAC 能够挑选出正确的点，然后获取能够正确拟合的直线。下面来看使用 RANSAC 的例子。你可以从 http://www.scipy.org/Cookbook/RANSAC 下载 ransac.py，里面包含了特定的例子作为测试用例。图 3-10 为运行 ransac.text() 的例子。可以看到，该算法只选择了和直线模型一致的数据点，成功 地找到了正确的解。

RANSAC 是个非常有用的算法，我们将在下一节估计单应性矩阵和其他一些例子中使用它。关于 RANSAC 更多的信息，参见 Fischler 和 Bolles 的原始论文 [11]、维基百科 http://en.wikipedia.org/wiki/RANSAC 或者技术报告 [40]。

图 3-10：使用 RANSAC 算法用一条直线来拟合包含噪声数据点集

3.3.2　稳健的单应性矩阵估计

我们在任何模型中都可以使用 RANSAC 模块。在使用 RANSAC 模块时，我们只需要在相应 Python 类中实现 fit() 和 get_error() 方法，剩下就是正确地使用 ransac.py。我们这里使用可能的对应点集来自动找到用于全景图像的单应性矩阵。图 3-11 所示为使用 SIFT 特征自动找到匹配对应。这可以通过运行下面的命令来实现：

import sift

featname = ['Univ'+str(i+1)+'.sift' for i in range(5)]
imname = ['Univ'+str(i+1)+'.jpg' for i in range(5)]
l = {}
d = {}
for i in range(5):
  sift.process_image(imname[i],featname[i])
  l[i],d[i] = sift.read_features_from_file(featname[i])

matches = {}
for i in range(4):
  matches[i] = sift.match(d[i+1],d[i])

显然，并不是所有图像中的对应点对都是正确的。实际上，SIFT 是具有很强稳健性的描述子，能够比其他描述子，例如图像块相关的 Harris 角点，产生更少的错误的匹配。但是该方法仍然远非完美。

图 3-11：在连续图像对间使用 SIFT 特征寻找匹配对应点对

我们使用 RANSAC 算法来求解单应性矩阵，首先需要将下面模型类添加到 homography.py 文件中：

class RansacModel(object):
  """ 用于测试单应性矩阵的类，其中单应性矩阵是由网站http://www.scipy.org/Cookbook/RANSAC 上的ransac.py 计算出来的"""

  def __init__(self,debug=False):
    self.debug = debug

  def fit(self, data):
  """ 计算选取的4 个对应的单应性矩阵 """

  # 将其转置，来调用H_from_points() 计算单应性矩阵
  data = data.T

  # 映射的起始点
  fp = data[:3,:4]
  # 映射的目标点
  tp = data[3:,:4]

  # 计算单应性矩阵，然后返回
  return H_from_points(fp,tp)

def get_error( self, data, H):
  """ 对所有的对应计算单应性矩阵，然后对每个变换后的点，返回相应的误差"""

  data = data.T

  # 映射的起始点
  fp = data[:3]
  # 映射的目标点
  tp = data[3:]

  # 变换fp
  fp_transformed = dot(H,fp)

  # 归一化齐次坐标
  for i in range(3):
    fp_transformed[i] /= fp_transformed[2]

  # 返回每个点的误差
  return sqrt( sum((tp-fp_transformed)**2,axis=0) )

可以看到，这个类包含 fit() 方法。该方法仅仅接受由 ransac.py 选择的４个对应点对（data 中的前 4 个点对），然后拟合一个单应性矩阵。记住，4 个点对是计算单应性矩阵所需的最少数目。由于 get_error() 方法对每个对应点对使用该单应性矩阵，然后返回相应的平方距离之和，因此 RANSAC 算法能够判定哪些点对是正确的，哪些是错误的。在实际中，我们需要在距离上使用一个阈值来决定哪些单应性矩阵是合理的。为了方便使用，将下面的函数添加到 homography.py 文件中：

def H_from_ransac(fp,tp,model,maxiter=1000,match_theshold=10):
  """ 使用RANSAC 稳健性估计点对应间的单应性矩阵H（ransac.py 为从 http://www.scipy.org/Cookbook/RANSAC 下载的版本）

    # 输入：齐次坐标表示的点fp，tp（3×n 的数组）"""

  import ransac

  # 对应点组
  data = vstack((fp,tp))

  # 计算H，并返回
  H,ransac_data = ransac.ransac(data.T,model,4,maxiter,match_theshold,10, return_all=True)
  return H,ransac_data['inliers']

该函数同样允许提供阈值和最小期望的点对数目。最重要的参数是最大迭代次数：程序退出太早可能得到一个坏解；迭代次数太多会占用太多时间。函数的返回结果为单应性矩阵和对应该单应性矩阵的正确点对。

类似于下面的操作，你可以将 RANSAC 算法应用于对应点对上：

# 将匹配转换成齐次坐标点的函数
def convert_points(j):
  ndx = matches[j].nonzero()[0]
  fp = homography.make_homog(l[j+1][ndx,:2].T)
  ndx2 = [int(matches[j][i]) for i in ndx]
  tp = homography.make_homog(l[j][ndx2,:2].T)
  return fp,tp

# 估计单应性矩阵
model = homography.RansacModel()

fp,tp = convert_points(1)
H_12 = homography.H_from_ransac(fp,tp,model)[0] # im1 到im2 的单应性矩阵

fp,tp = convert_points(0)
H_01 = homography.H_from_ransac(fp,tp,model)[0] # im0 到im1 的单应性矩阵

tp,fp = convert_points(2) # 注意：点是反序的
H_32 = homography.H_from_ransac(fp,tp,model)[0] # im3 到im2 的单应性矩阵

tp,fp = convert_points(3) # 注意：点是反序的
H_43 = homography.H_from_ransac(fp,tp,model)[0] # im4 到im3 的单应性矩阵

在该例子中，图像 2 是中心图像，也是我们希望将其他图像变成的图像。图像 0 和图像 1 应该从右边扭曲，图像 3 和图像 4 从左边扭曲。在每个图像对中，由于匹配是从最右边的图像计算出来的，所以我们将对应的顺序进行了颠倒，使其从左边图像开始扭曲。因为我们不关心该扭曲例子中的正确点对，所以仅需要该函数的第一个输出（单应性矩阵）。

3.3.3　拼接图像

估计出图像间的单应性矩阵（使用 RANSAC 算法），现在我们需要将所有的图像扭曲到一个公共的图像平面上。通常，这里的公共平面为中心图像平面（否则，需要进行大量变形）。一种方法是创建一个很大的图像，比如图像中全部填充 0，使其和中心图像平行，然后将所有的图像扭曲到上面。由于我们所有的图像是由照相机水平旋转拍摄的，因此我们可以使用一个较简单的步骤：将中心图像左边或者右边的区域填充 0，以便为扭曲的图像腾出空间。将下面的代码添加到 warp.py 文件中：

def panorama(H,fromim,toim,padding=2400,delta=2400):
  """ 使用单应性矩阵H（使用RANSAC 健壮性估计得出），协调两幅图像，创建水平全景图像。结果
    为一幅和toim 具有相同高度的图像。padding 指定填充像素的数目，delta 指定额外的平移量"""

  # 检查图像是灰度图像，还是彩色图像
  is_color = len(fromim.shape) == 3

  # 用于geometric_transform() 的单应性变换
  def transf(p):
    p2 = dot(H,[p[0],p[1],1])
    return (p2[0]/p2[2],p2[1]/p2[2])

  if H[1,2]<0: # fromim 在右边
    print 'warp - right'
    # 变换fromim
    if is_color:
      # 在目标图像的右边填充0
      toim_t = hstack((toim,zeros((toim.shape[0],padding,3))))
      fromim_t = zeros((toim.shape[0],toim.shape[1]+padding,toim.shape[2]))
      for col in range(3):
        fromim_t[:,:,col] = ndimage.geometric_transform(fromim[:,:,col],
                    transf,(toim.shape[0],toim.shape[1]+padding))
  else:
    # 在目标图像的右边填充0
    toim_t = hstack((toim,zeros((toim.shape[0],padding))))
    fromim_t = ndimage.geometric_transform(fromim,transf,
                  (toim.shape[0],toim.shape[1]+padding))
  else:
    print 'warp - left'
    # 为了补偿填充效果，在左边加入平移量
    H_delta = array([[1,0,0],[0,1,-delta],[0,0,1]])
    H = dot(H,H_delta)
    # fromim 变换
    if is_color:
    # 在目标图像的左边填充0
    toim_t = hstack((zeros((toim.shape[0],padding,3)),toim))
    fromim_t = zeros((toim.shape[0],toim.shape[1]+padding,toim.shape[2]))
    for col in range(3):
      fromim_t[:,:,col] = ndimage.geometric_transform(fromim[:,:,col],
                  transf,(toim.shape[0],toim.shape[1]+padding))
  else:
  # 在目标图像的左边填充0
  toim_t = hstack((zeros((toim.shape[0],padding)),toim))
  fromim_t = ndimage.geometric_transform(fromim,
                transf,(toim.shape[0],toim.shape[1]+padding))

  # 协调后返回（将fromim 放置在toim 上）
  if is_color:
    # 所有非黑色像素
    alpha = ((fromim_t[:,:,0] * fromim_t[:,:,1] * fromim_t[:,:,2] ) > 0)
    for col in range(3):
      toim_t[:,:,col] = fromim_t[:,:,col]*alpha + toim_t[:,:,col]*(1-alpha)
  else:
    alpha = (fromim_t > 0)
    toim_t = fromim_t*alpha + toim_t*(1-alpha)

  return toim_t

对于通用的 geometric_transform() 函数，我们需要指定能够描述像素到像素间映射的函数。在这个例子中，transf() 函数就是该指定的函数。该函数通过将像素和 H 相乘，然后对齐次坐标进行归一化来实现像素间的映射。通过查看 H 中的平移量， 我们可以决定应该将该图像填补到左边还是右边。当该图像填补到左边时，由于目标图像中点的坐标也变化了，所以在“左边”情况中，需要在单应性矩阵中加入平移。简单起见，我们同样使用 0 像素的技巧来寻找 alpha 图。

现在在图像中使用该操作，函数如下所示：

# 扭曲图像
delta = 2000 # 用于填充和平移

im1 = array(Image.open(imname[1]))
im2 = array(Image.open(imname[2]))
im_12 = warp.panorama(H_12,im1,im2,delta,delta)

im1 = array(Image.open(imname[0]))
im_02 = warp.panorama(dot(H_12,H_01),im1,im_12,delta,delta)

im1 = array(Image.open(imname[3]))
im_32 = warp.panorama(H_32,im1,im_02,delta,delta)

im1 = array(Image.open(imname[j+1]))
im_42 = warp.panorama(dot(H_32,H_43),im1,im_32,delta,2*delta)

注意，在最后一行中，im_32 图像已经发生了一次平移。创建的全景图结果如图 3-12 所示。正如你所看到的，图像曝光不同，在单个图像的边界上存在边缘效应。商业的创建全景图像软件里有额外的操作来对强度进行归一化，并对平移进行平滑场景转换，以使得结果看上去更好。

图 3-12：使用 SIFT 对应点对自动创建水平全景图像：全部的全景图像（上）；对中心部分图像进行裁剪后的图像（下）

练习

写出一个函数，其输入参数为正方形（或者长方形）物体（例如，一本书、一张海报，或者二维条形码）图像的坐标。然后，计算将该长方形映射归一化坐标系中正视图全图的变换。你可以使用 ginput()，或者最强的 Harris 角点来发现长方形物体的稳健性角点。

写出一个函数，对于如图 3-1 所示的扭曲能够正确地找到 alpha 图像。

在你自己的数据集中找出包含三个公共的标记物（像人脸例子一样，或者使用著名的景物，比如埃菲尔铁塔）的那个。创建对齐后的图像，其中这些标记物在同一个位置上。计算平均和中值图像，然后可视化。

进行亮度归一化操作，找出在全景图像例子中更好地拼接图像的方法。该方法能够去除图 3-12 中的边缘效应。

与将图像扭曲到中心图像上不同，全景图像可以通过将图像扭曲到圆柱体上来创建。试着在图 3-12 的例子中使用该方式创建全景图像。

使用 RANSAC 算法来找到一些主要的正确单应性矩阵集合。一个简单的方式是，首先运行一次 RANSAC 算法，找到具有最大一致子集的单应性矩阵，然后将与该单应性矩阵一致的对应点对从匹配集合中删除，再运行 RANSAC 算法找到下一个最大的集合，以此类推。

修改单应性矩阵的 RANSAC 估计算法，来使用三个对应点对计算仿射变换。使用该算法来判断图像对之间是否包含平面场景，例如使用正确点的个数。对于仿射变化，平面场景中正确点的个数会很多。

通过匹配局部特征，以及使用最小二乘刚体配准，用多个图像（例如，从 Flickr 下载）创建一个全景图（http://en.wikipedia.org/wiki/Panography）。

第 4 章　照相机模型与增强现实

本章中，我们将尝试对照相机进行建模，并有效地使用这些模型。在之前的章节里，我们已经讲述了图像到图像之间的映射和变换。为了处理三维图像和平面图像之间的映射，我们需要在映射中加入部分照相机产生图像过程的投影特性。本章中我们将会讲述如何确定照相机的参数，以及在具体应用中，如增强现实，如何使用图像间的投影变换。下一章中，我们将使用照相机模型处理其他一些应用，比如多视图及其映射。

4.1　针孔照相机模型

针孔照相机模型（有时称为射影照相机模型）是计算机视觉中广泛使用的照相机模型。对于大多数应用来说，针孔照相机模型简单，并且具有足够的精确度。这个名字来源于一种类似暗箱机的照相机。该照相机从一个小孔采集射到暗箱内部的光线。在针孔照相机模型中，在光线投影到图像平面之前，从唯一一个点经过，也就是照相机中心 C。图 4-1 为从照相机中心前画出图像平面的图解。事实上，在真实的照相机中，图像平面位于照相机中心之后，但是照相机的模型和图 4-1 的模型是一样的。

图 4-1：针孔照相机模型。图像点 x 是由图像平面与连接三维点 X 和照相机中心 C 的直线相交而成的。虚线表示该照相机的光学坐标轴

由图像坐标轴和三维坐标系中的 x 轴和 y 轴对齐平行的假设，我们可以得出针孔照相机的投影性质。照相机的光学坐标轴和 z 轴一致，该投影几何可以简化成相似三角形。在投影之前通过旋转和平移变换，对该坐标系加入三维点，会出现完整的投影变换。感兴趣的读者可以查阅文献 [13]、[25] 和 [26]。

在针孔照相机中，三维点 X 投影为图像点 x（两个点都是用齐次坐标表示的），如下 所示：

λx = P X　　　　　　（4.1）

这里，3×4 的矩阵 P 为照相机矩阵（或投影矩阵）。注意，在齐次坐标系中，三维点 X 的坐标由 4 个元素组成，X=[X, Y, Z, W]。这里的标量 λ 是三维点的逆深度。如 果我们打算在齐次坐标中将最后一个数值归一化为 1，那么就会使用到它。

4.1.1　照相机矩阵

照相机矩阵可以分解为：

P = K[R|t]　　　　　　（4.2）

其中，R 是描述照相机方向的旋转矩阵，t 是描述照相机中心位置的三维平移向量，内标定矩阵 K 描述照相机的投影性质。

标定矩阵仅和照相机自身的情况相关，通常情况下可以写成：

图像平面和照相机中心间的距离为焦距 f。当像素数组在传感器上偏斜的时候，需要用到倾斜参数 s。在大多数情况下，s 可以设置成 0。也就是说：

\boldsymbol{K}=\begin{bmatrix}f_x & 0 & c_x\\0 & f_y & c_y\\0 & 0 & 1\end{bmatrix}　　　　　　（4.3）

这里，我们使用了另外的记号 fx和 fy，两者关系为 fx=αfy。

纵横比例参数 α 是在像素元素非正方形的情况下使用的。通常情况下，我们可以默认设置 α=1。经过这些假设，标定矩阵变为：

除焦距之外，标定矩阵中剩余的唯一参数为光心（有时称主点）的坐标 c=[cx，cy]， 也就是光线坐标轴和图像平面的交点。因为光心通常在图像的中心，并且图像的坐标是从左上角开始计算的，所以光心的坐标常接近于图像宽度和高度的一半。特别强调一点，在这个例子中，唯一未知的变量是焦距 f。

4.1.2　三维点的投影

下面来创建照相机类，用来处理我们对照相机和投影建模所需要的全部操作：

from scipy import linalg

class Camera(object):
  """ 表示针孔照相机的类"""

  def __init__(self,P):
    """ 初始化 P = K[R|t] 照相机模型"""
    self.P = P
    self.K = None # 标定矩阵
    self.R = None # 旋转
    self.t = None # 平移
    self.c = None # 照相机中心

  def project(self,X):
    """ X（4×n 的数组）的投影点，并且进行坐标归一化 """
  x = dot(self.P,X)
  for i in range(3):
    x[i] /= x[2]
  return x

下面的例子展示如何将三维中的点投影到图像视图中。在这个例子中，我们将使用牛津多视图数据集中的“Model Housing”数据集，可以从 http://www.robots.ox.ac.uk/~vgg/data/data-mview.html 下载。下载这些三维几何图像文件，然后将 house.p3d 文件复制到你的工作目录里：

import camera

# 载入点
points = loadtxt('house.p3d').T
points = vstack((points,ones(points.shape[1])))

# 设置照相机参数
P = hstack((eye(3),array([[0],[0],[-10]])))
cam = camera.Camera(P)
x = cam.project(points)

# 绘制投影
figure()
plot(x[0],x[1],'k.')
show()

首先，我们使用齐次坐标来表示这些点。然后我们使用一个投影矩阵来创建 Camera 对象将这些三维点投影到图像平面并执行绘制操作，输出结果如图 4-2 中间图像所示。

图 4-2：投影三维点示例：样本图像（左）；图像视图中投影后的点（中）；经过照相机旋转后，投影点的轨迹（右）。数据来自于牛津“Model House”数据集

为了研究照相机的移动会如何改变投影的效果，你可以使用下面的代码。该代码围绕一个随机的三维向量，进行增量旋转的投影。

# 创建变换
r = 0.05*random.rand(3)
rot = camera.rotation_matrix(r)

# 旋转矩阵和投影
figure()
for t in range(20):
  cam.P = dot(cam.P,rot)
  x = cam.project(points)
  plot(x[0],x[1],'k.')
  show()

在上面的代码中，我们使用了 rotation_matrix() 函数，该函数能够创建围绕一个向量进行三维旋转的旋转矩阵（将该函数添加到 camera.py 文件中）：

def rotation_matrix(a):
  """ 创建一个用于围绕向量a 轴旋转的三维旋转矩阵 """
  R = eye(4)
  R[:3,:3] = linalg.expm([[0,-a[2],a[1]],[a[2],0,-a[0]],[-a[1],a[0],0]])
  return R

图 4-2 所示分别为序列中的一幅图像、三维点的投影，以及这些点围绕一个随机向量旋转后三维点投影的轨迹。运行该代码几次，进行不同的随机旋转之后，你会对点在投影中如何旋转有一些感觉。

4.1.3　照相机矩阵的分解

如果给定如方程（4.2）所示的照相机矩阵 P，我们需要恢复内参数 K 以及照相机的 位置 t 和姿势 R。矩阵分块操作称为因子分解。这里，我们将使用一种矩阵因子分解的方法，称为 RQ 因子分解。

将下面的方法添加到 Camera 类中：

def factor(self):
  """ 将照相机矩阵分解为K、R、t，其中，P = K[R|t] """

  # 分解前3×3 的部分
  K,R = linalg.rq(self.P[:,:3])

  # 将K 的对角线元素设为正值
  T = diag(sign(diag(K)))
  if linalg.det(T) < 0:
    T[1,1] *= -1

  self.K = dot(K,T)
  self.R = dot(T,R) # T 的逆矩阵为其自身
  self.t = dot(linalg.inv(self.K),self.P[:,3])

  return self.K, self.R, self.t

RQ 因子分解的结果并不是唯一的。在该因子分解中，分解的结果存在符号二义性。由于我们需要限制旋转矩阵 R 为正定的（否则，旋转坐标轴即可），所以如果需要，我们可以在求解到的结果中加入变换 T 来改变符号。

在示例照相机上运行下面的代码，观察照相机矩阵分解的效果：

import camera

K = array([[1000,0,500],[0,1000,300],[0,0,1]])
tmp = camera.rotation_matrix([0,0,1])[:3,:3]
Rt = hstack((tmp,array([[50],[40],[30]])))
cam = camera.Camera(dot(K,Rt))

print K,Rt
print cam.factor()

你会在控制台上得到相同的输出。

4.1.4　计算照相机中心

给定照相机投影矩阵 P，我们可以计算出空间上照相机的所在位置。照相机的中心 C，是一个三维点，满足约束 P C=0。对于投影矩阵为 P=K[R|t] 的照相机，有：

K[R|t]C = K RC + Kt = 0

照相机的中心可以由下述式子来计算：

C = - RT t

注意，如预期一样，照相机的中心和内标定矩阵 K 无关。

下面的代码可以按照上面公式计算照相机的中心。将其添加到 Camera 类中，该方法会返回照相机的中心：

def center(self):
  """ 计算并返回照相机的中心"""

  if self.c is not None:
    return self.c
else:
    # 通过因子分解计算c
    self.factor()
    self.c = -dot(self.R.T,self.t)
    return self.c

上面的一些方法构成了 Camera 类的基本函数操作。现在，让我们一起探讨如何使用针孔照相机模型。

4.2　照相机标定

标定照相机是指计算出该照相机的内参数。在我们的例子中，是指计算矩阵 K。如果你的应用要求高精度，那么可以扩展该照相机模型，使其包含径向畸变和其他条件。对于大多数应用来说，公式（4.3）中的简单照相机模型已经足够。标定照相机的标准方法是，拍摄多幅平面棋盘模式的图像，然后进行处理计算。例如，OpenCV 中的标定工具使用了这种方法（详见文献 [3]）。

4.2.1　一个简单的标定方法

这里我们将要介绍一个简单的照相机标定方法。大多数参数可以使用基本的假设来设定（正方形垂直的像素，光心位于图像中心），比较难处理的是获得正确的焦距。对于这种标定方法，你需要准备一个平面矩形的标定物体（一个书本即可）、用于测量的卷尺和直尺，以及一个平面。下面是具体操作步骤：

测量你选定矩形标定物体的边长 dX和 dY；

将照相机和标定物体放置在平面上，使得照相机的背面和标定物体平行，同时物体位于照相机图像视图的中心，你可能需要调整照相机或者物体来获得良好的对齐效果；

测量标定物体到照相机的距离 dZ；

拍摄一副图像来检验该设置是否正确，即标定物体的边要和图像的行和列对齐；

使用像素数来测量标定物体图像的宽度和高度 dx 和 dy。

实验设置情况如图 4-3 所示。现在，使用下面的相似三角形（参见图 4-1）关系可以获得焦距：

图 4-3：简单的照相机标定设置：进行标定实验使用的设置情况图像（左）；用于标定的图像（右）。在图像中测量标定物体的宽度和高度，以及设置中标定物体的实际物理尺寸，就可以确定焦距的大小

对于如图 4-3 所示的特定设置，物体宽度和高度的测量值分别为 130 mm 和 185 mm，则，dX=130，dY=185。从照相机到物体的距离为 460 mm，则 dZ=460。你可以使用任意的测量单位，只有测量值的比例才影响最终焦距的计算。你可以使用 ginput() 函数来获得图像中的 4 个点，图像中物体的宽度和高度分别为 722 和 1040 像素。将这些值代入上面的关系表达式可以获得焦距的大小：

fx= 2555, fy= 2586

值得注意的是，我们现在获取的焦距是在特定图像分辨率下计算出来的。在这个例子中，图像大小为 2592×1936 像素。记住，焦距和光心是使用像素来度量的，其尺度和图像分辨率相关。如果你使用其他的图像分辨率来拍摄（例如，一个缩略图像），那么这些值都会改变。将照相机的这些测量数值写入到一个如下所示的辅助函数中，会方便一些：

def my_calibration(sz):
  row,col = sz
  fx = 2555*col/2592
  fy = 2586*row/1936
  K = diag([fx,fy,1])
  K[0,2] = 0.5*col
  K[1,2] = 0.5*row
  return K

该函数的输入参数为表示图像大小的元组，返回参数为标定矩阵。这里，我们假设光心为图像的中心。如果你喜欢的话，可以按照焦距的意义来替换焦距的值；对于大多数普通照相机来说，这样做是可以的。注意，这里的标定是对于横向旋转的图像。对于纵向旋转，你需要交换标定矩阵中焦距的值。我们会保留上面的这个函数，方便在下面的章节中使用。

4.3　以平面和标记物进行姿态估计

在第 3 章中，我们学习了如何从平面间估计单应性矩阵。如果图像中包含平面状的标记物体，并且已经对照相机进行了标定，那么我们可以计算出照相机的姿态（旋转和平移）。这里的标记物体可以为对任何平坦的物体。

我们使用一个例子来演示如何进行姿态估计。这里借助图 4-4 中顶端的两幅图像。我们使用下面的代码来提取两幅图像的 SIFT 特征，然后使用 RANSAC 算法稳健地估计单应性矩阵：

import homography
import camera
import sift

# 计算特征
sift.process_image('book_frontal.JPG','im0.sift')
l0,d0 = sift.read_features_from_file('im0.sift')

sift.process_image('book_perspective.JPG','im1.sift')
l1,d1 = sift.read_features_from_file('im1.sift')

# 匹配特征，并计算单应性矩阵
matches = sift.match_twosided(d0,d1)
ndx = matches.nonzero()[0]
fp = homography.make_homog(l0[ndx,:2].T)
ndx2 = [int(matches[i]) for i in ndx]
tp = homography.make_homog(l1[ndx2,:2].T)

model = homography.RansacModel()
H = homography.H_from_ransac(fp,tp,model)

现在我们得到了单应性矩阵。该单应性矩阵将一幅图像中标记物（在这个例子中，标记物是指书本）上的点映射到另一幅图像中的对应点。下面我们定义相应的三维坐标系，使标记物在 X-Y 平面上（Z=0），原点在标记物的某位置上。

为了检验单应性矩阵结果的正确性，我们需要将一些简单的三维物体放置在标记物上，这里我们使用一个立方体。你可以使用下面的函数来产生立方体上的点：

def cube_points(c,wid):
  """ 创建用于绘制立方体的一个点列表（前5 个点是底部的正方形，一些边重合了）"""
  p = []
  # 底部
  p.append([c[0]-wid,c[1]-wid,c[2]-wid])
  p.append([c[0]-wid,c[1]+wid,c[2]-wid])
  p.append([c[0]+wid,c[1]+wid,c[2]-wid])
  p.append([c[0]+wid,c[1]-wid,c[2]-wid])
  p.append([c[0]-wid,c[1]-wid,c[2]-wid]) # 为了绘制闭合图像，和第一个相同

  # 顶部
  p.append([c[0]-wid,c[1]-wid,c[2]+wid])
  p.append([c[0]-wid,c[1]+wid,c[2]+wid])
  p.append([c[0]+wid,c[1]+wid,c[2]+wid])
  p.append([c[0]+wid,c[1]-wid,c[2]+wid])
  p.append([c[0]-wid,c[1]-wid,c[2]+wid]) # 为了绘制闭合图像，和第一个相同

  # 竖直边
  p.append([c[0]-wid,c[1]-wid,c[2]+wid])
  p.append([c[0]-wid,c[1]+wid,c[2]+wid])
  p.append([c[0]-wid,c[1]+wid,c[2]-wid])
  p.append([c[0]+wid,c[1]+wid,c[2]-wid])
  p.append([c[0]+wid,c[1]+wid,c[2]+wid])
  p.append([c[0]+wid,c[1]-wid,c[2]+wid])
  p.append([c[0]+wid,c[1]-wid,c[2]-wid])

  return array(p).T

在上面的函数中，一些数据点会重复出现，plot() 函数会绘制出漂亮的立方体。

图 4-4：使用平面物体作为标记物，来计算用于新视图投影矩阵的例子。将图像的特征和对齐后的标记匹配，计算出单应性矩阵，然后用于计算照相机的姿态。带有一个灰色正方形区域的模板图像（左上）；从未知视角拍摄的一幅图像，该图像包含同一个正方形，该正方形已经经过估计的单应性矩阵进行了变换（右上）；使用计算出的照相机矩阵变换立方体（下）

有了单应性矩阵和照相机的标定矩阵，我们现在可以得出两个视图间的相对变换：

# 计算照相机标定矩阵
K = my_calibration((747,1000))

# 位于边长为0.2，z=0 平面上的三维点
box = cube_points([0,0,0.1],0.1)

# 投影第一幅图像上底部的正方形
cam1 = camera.Camera( hstack((K,dot(K,array([[0],[0],[-1]])) )) )
# 底部正方形上的点
box_cam1 = cam1.project(homography.make_homog(box[:,:5]))

# 使用H 将点变换到第二幅图像中
box_trans = homography.normalize(dot(H,box_cam1))

# 从cam1 和H 中计算第二个照相机矩阵
cam2 = camera.Camera(dot(H,cam1.P))
A = dot(linalg.inv(K),cam2.P[:,:3])
A = array([A[:,0],A[:,1],cross(A[:,0],A[:,1])]).T
cam2.P[:,:3] = dot(K,A)

# 使用第二个照相机矩阵投影
box_cam2 = cam2.project(homography.make_homog(box))

# 测试：将点投影在z=0 上，应该能够得到相同的点
point = array([1,1,0,1]).T
print homography.normalize(dot(dot(H,cam1.P),point))
print cam2.project(point)

这里我们使用图像的分辨率为 747×1000，第一个产生的标定矩阵就是在该图像分辨率大小下的标定矩阵。下面，我们在原点附近创建立方体上的点。cube_points() 函数产生的前五个点对应于立方体底部的点，在这个例子中对应于位于标记物上 Z=0 平面内的点。第一幅图像（图 4-4 左上）是书本的主视图，我们将其作为这个例子中的模板图像。因为场景坐标的尺度是任意的，所以我们使用下面的矩阵来创建第一个照相机：

其中，图像的坐标轴和照相机是对齐的，并且放置在标记物的正上方。将前 5 个三维点投影到图像上。有了估计出的单应性矩阵，我们可以将其变换到第二幅图像上。绘

制出变换后的图像，并在同样的标记物位置绘制出这些点（如图 4-4 右上所示）。

现在，结合 P₁ 和 H 构建第二幅图像的照相机矩阵：

P₂ =HP₁

该矩阵将标记平面 Z=0 上的点变换到正确的位置。也就是说，P₂ 矩阵的前两列和第四列是正确的。由于我们知道前 3×3 矩阵块应该为 KR，并且 R 是旋转矩阵，所以我们可以将 P₂ 乘以标定矩阵的逆，然后将第三列替换为前两列的交叉乘积，以此来恢复第三列。

作为合理性验证，我们可以使用新矩阵投影标记平面的一个点，然后检查投影后的点是否与使用第一个照相机和单应性矩阵变换后的点相同。你会发现，控制台上得到了相同的输出结果。

你可以用如下所示的代码来可视化这些投影后的点：

im0 = array(Image.open('book_frontal.JPG'))
im1 = array(Image.open('book_perspective.JPG'))

# 底部正方形的二维投影
figure()
imshow(im0)
plot(box_cam1[0,:],box_cam1[1,:],linewidth=3)

# 使用H 对二维投影进行变换
figure()
imshow(im1)
plot(box_trans[0,:],box_trans[1,:],linewidth=3)

# 三维立方体
figure()
imshow(im1)
plot(box_cam2[0,:],box_cam2[1,:],linewidth=3)

show()

该代码绘制出如图 4-4 所示的三幅图像。为了能够在后面的例子中再次使用这些计算的结果，我们可以使用 Pickle 将这些照相机矩阵保存起来：

import pickle

with open('ar_camera.pkl','w') as f:
  pickle.dump(K,f)
  pickle.dump(dot(linalg.inv(K),cam2.P),f)

现在我们已经学会计算给定平面场景物体的照相机矩阵。我们结合特征匹配和单应性矩阵，以及照相机标定技术，简单演示了如何在一幅图像上放置一个立方体。有了照相机的姿态估计技术，我们现在就具备了创建简单增强现实应用的基本技能了。

4.4　增强现实

增强现实（Augmented Reality，AR）是将物体和相应信息放置在图像数据上的一系列操作的总称。最经典的例子是放置一个三维计算机图形学模型，使其看起来属于该场景；如果在视频中，该模型会随着照相机的运动很自然地移动。如上一节所示，给定一幅带有标记平面的图像，我们能够计算出照相机的位置和姿态，使用这些信息来放置计算机图形学模型，能够正确表示它们。在本章的最后一节，我们将介绍如何建立一个简单的增强现实例子。其中，我们会用到两个工具包：PyGame 和 PyOpenGL。

4.4.1　PyGame和PyOpenGL

PyGame 是非常流行的游戏开发工具包，它可以非常简单地处理显示窗口、输入设备、事件，以及其他内容。PyGame 是开源的，可以从 http://www.pygame.org/ 下载。事实上，它是一个 Python 绑定的SDL游戏引擎。你可以查看附录 A 来获取关于安装的帮助。你还可以查看文献 [21] 来获取关于 PyGame 工具包的更多编程细节。

PyOpenGL 是 OpenGL 图形编程的 Python 绑定接口。OpenGL 可以安装在几乎所有的系统上，并且具有很好的图形性能。OpenGL 具有跨平台性，能够在不同的操作系统之间工作。关于 OpenGL 的更多信息，参见 http://www.opengl.org/。在开始页面（http://www.opengl.org/wiki/Getting_started），有针对初学者的很多资源。PyOpenGL 是开源的，并且易于安装。你可以从附录 A 了解关于 PyOpenGL 更多内容。你同样可以在项目网页 http://pyopengl.sourceforge.net/ 获取更多细节内容。

我们没有涵盖关于 OpenGL 编程的绝大部分内容。相反，我们在这里只讲述重要的内容，例如如何在 OpenGL 中使用照相机矩阵，如何设置一个基本的三维模型。你可以从 PyOpenGL-Demo 工具包（http://pypi.python.org/pypi/PyOpenGL-Demo）获取一些很好的例子和演示。如果你不熟悉 PyOpenGL，那么该工具包是个很好的开始。

我们使用 OpenGL 将一个三维模型放置在一个场景中。为了使用 PyGame 和 PyOpenGL 工具包来完成该应用，需要在脚本的开始部分载入下面的命令：

from OpenGL.GL import *
from OpenGL.GLU import *
import pygame, pygame.image
from pygame.locals import *

你可以看到，这里主要使用 OpenGL 中的两个部分：GL 部分包含所有以“gl”开头的函数，其中包含我们需要的大部分函数；GLU 部分是 OpenGL 的实用函数库，里面包含了一些高层的函数。我们主要使用它来设置照相机投影。pygame 部分用来设置窗口和事件控制；其中 pygame.image 用来载入图像和创建 OpenGL 的纹理，pygame.locals 用来设置 OpenGL 的显示区域。

需要对一个 OpenGL 场景进行两个部分的设置：投影和视图矩阵的建模。让我们一起来学习如何由针孔照相机来创建这些矩阵。

4.4.2　从照相机矩阵到OpenGL格式

OpenGL 使用 4×4 的矩阵来表示变换（包括三维变换和投影）。这和我们使用的 3×4 照相机矩阵略有差别。但是，照相机与场景的变换分成了两个矩阵，GL_PROJECTION 矩 阵和 GL_MODELVIEW 矩阵。GL_PROJECTION 矩阵处理图像成像的性质，等价于我们的内标定矩阵 K。GL_MODELVIEW 矩阵处理物体和照相机之间的三维变换关系，对应于我们照相机矩阵中的 R 和 t 部分。一个不同之处是，假设照相机为坐标系的中心，GL_MODELVIEW 矩阵实际上包含了将物体放置在照相机前面的变换。OpenGL 有很多特性，我们会在下面例子中一一讲解。

假设我们已经获得了标定好的照相机，即已知标定矩阵 K，下面的函数可以将照相机参数转换为 OpenGL 中的投影矩阵：

def set_projection_from_camera(K):
  """ 从照相机标定矩阵中获得视图"""

  glMatrixMode(GL_PROJECTION)
  glLoadIdentity()

  fx = K[0,0]
  fy = K[1,1]
  fovy = 2*arctan(0.5*height/fy)*180/pi
  aspect = (width*fy)/(height*fx)

  # 定义近的和远的剪裁平面
  near = 0.1
  far = 100.0

  # 设定透视
  gluPerspective(fovy,aspect,near,far)
  glViewport(0,0,width,height)

我们假设标定矩阵如（4.3）那样简单，光心为图像的中心。第一个函数 glMatrixMode() 将工作矩阵设置为 GL_PROJECTION，接下来的命令会修改这个矩阵 1。然后，glLoadIdentity() 函数将该矩阵设置为单位矩阵，这是重置矩阵的一般操作。然后，我们根据图像的高度、照相机的焦距以及纵横比，计算出视图中的垂直场。OpenGL 的投影同样具有近距离和远距离的裁剪平面来限制场景拍摄的深度范围。我们设置近深度为一个小的数值，使得照相机能够包含最近的物体，而远深度设置为一个大的数值。我们使用 GLU 的实用函数 gluPerspective() 来设置投影矩阵，将整个图像定义为视图部分（也就是显示的部分）。和下面的模拟视图函数相似，你可以使用 glLoadMatrixf() 函数的一个选项来定义一个完全的投影矩阵。当简单版本的标定矩阵不够好时，可以使用完全投影矩阵。

1这是个奇怪的处理方式，但是只需要处理 GL_PROJECTION 和 GL_MODELVIEW 两个矩阵，所以是可行的。

模拟视图矩阵能够表示相对的旋转和平移，该变换将该物体放置在照相机前（效果是照相机在原点上）。模拟视图矩阵是个典型的 4×4 矩阵，如下所示：

其中，R 是旋转矩阵，列向量表示 3 个坐标轴的方向，t 是平移向量。当创建模拟视图矩阵时，旋转矩阵需要包括所有的旋转（物体和坐标系的旋转），可以将单个旋转分量相乘来获得旋转矩阵。

下面的函数实现如何获得移除标定矩阵后的 3×4 针孔照相机矩阵（将 P 和 K⁻¹ 相乘），并创建一个模拟视图：

def set_modelview_from_camera(Rt):
  """ 从照相机姿态中获得模拟视图矩阵"""

  glMatrixMode(GL_MODELVIEW)
  glLoadIdentity()

  # 围绕x 轴将茶壶旋转90 度，使z 轴向上
  Rx = array([[1,0,0],[0,0,-1],[0,1,0]])

  # 获得旋转的最佳逼近
  R = Rt[:,:3]
  U,S,V = linalg.svd(R)
  R = dot(U,V)
  R[0,:] = -R[0,:] # 改变x 轴的符号

  # 获得平移量
  t = Rt[:,3]

  # 获得4×4 的模拟视图矩阵
  M = eye(4)
  M[:3,:3] = dot(R,Rx)
  M[:3,3] = t

  # 转置并压平以获取列序数值
  M = M.T
  m = M.flatten()

  # 将模拟视图矩阵替换为新的矩阵
  glLoadMatrixf(m)

在上面函数中，我们首先切换到 GL_MODELVIEW 矩阵，然后重置该矩阵。然后，由于需要旋转该物体（你将在下面看到），所以我们创建一个 90 度的旋转矩阵。接下来，由于估计照相机矩阵时，可能会有错误或者噪声干扰，所以我们确保照相机矩阵的旋转部分确实是个旋转矩阵。该操作使用 SVD 分解方法，旋转矩阵的最佳逼近可以通过 R=UVT 来获得。由于 OpenGL 中的坐标系和上面用到的有点不同，所以我们将 x 轴翻转。然后，我们将模拟视图矩阵 M 设置为旋转矩阵的乘积。glLoadMatrixf() 函数通过输入参数为按列排列的 16 个数值数组，来设置模拟视图。将 M 矩阵转置，然后压平并输入 glLoadMatrixf() 函数。

4.4.3　在图像中放置虚拟物体

我们需要做的第一件事是将图像（打算放置虚拟物体的图像）作为背景添加进来。在 OpenGL 中，该操作可以通过创建一个四边形的方式来完成，该四边形为整个视图。完成该操作最简单的方式是绘制出四边形，同时将投影和模拟试图矩阵重置，使得每一维的坐标范围在 -1 到 1 之间。

下面的函数可以载入一幅图像，然后将其转换成一个 OpenGL 纹理，并将该纹理放置在四边形上：

def draw_background(imname):
  """ 使用四边形绘制背景图像"""

  # 载入背景图像（应该是.bmp 格式），转换为OpenGL 纹理
  bg_image = pygame.image.load(imname).convert()
  bg_data = pygame.image.tostring(bg_image,"RGBX",1)

  glMatrixMode(GL_MODELVIEW)
  glLoadIdentity()
  glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)

  # 绑定纹理
  glEnable(GL_TEXTURE_2D)
  glBindTexture(GL_TEXTURE_2D,glGenTextures(1))
  glTexImage2D(GL_TEXTURE_2D,0,GL_RGBA,width,height,0,GL_RGBA,GL_UNSIGNED_BYTE,bg_data)
  glTexParameterf(GL_TEXTURE_2D,GL_TEXTURE_MAG_fiLTER,GL_NEAREST)
  glTexParameterf(GL_TEXTURE_2D,GL_TEXTURE_MIN_fiLTER,GL_NEAREST)

  # 创建四方形填充整个窗口
  glBegin(GL_QUADS)
  glTexCoord2f(0.0,0.0); glVertex3f(-1.0,-1.0,-1.0)
  glTexCoord2f(1.0,0.0); glVertex3f( 1.0,-1.0,-1.0)
  glTexCoord2f(1.0,1.0); glVertex3f( 1.0, 1.0,-1.0)
  glTexCoord2f(0.0,1.0); glVertex3f(-1.0, 1.0,-1.0)
  glEnd()

  # 清除纹理
  glDeleteTextures(1)

该函数首先使用 PyGame 中的一些函数来载入一幅图像，将其序列化为能够在 PyOpenGL 中使用的原始字符串表示。然后，重置模拟视图，清除颜色和深度缓存。接下来，绑定这个纹理，使其能够在四边形和指定插值中使用它。四边形是在每一维分别为 -1 和 1 的点上定义的。注意，纹理图像的坐标是从 0 到 1。最后，清除该纹理，避免其干扰之后准备绘制的图像。

现在已经准备好将物体放置入场景中。我们将使用“hello world”的计算机图形学例子，Utah 茶壶（http://en.wikipedia.org/wiki/Utah_teapot）。这个茶壶有丰富的历史，在 GLUT 用作一个标准形状：

from OpenGL.GLUT import *
glutSolidTeapot(size)

该命令产生一个相对大小为 size 的茶壶模型。

下面的函数将会设置颜色和其他特性，产生一个红色的漂亮茶壶：

def draw_teapot(size):
  """ 在原点处绘制红色茶壶"""
  glEnable(GL_LIGHTING)
  glEnable(GL_LIGHT0)
  glEnable(GL_DEPTH_TEST)
  glClear(GL_DEPTH_BUFFER_BIT)

  # 绘制红色茶壶
  glMaterialfv(GL_FRONT,GL_AMBIENT,[0,0,0,0])
  glMaterialfv(GL_FRONT,GL_DIFFUSE,[0.5,0.0,0.0,0.0])
  glMaterialfv(GL_FRONT,GL_SPECULAR,[0.7,0.6,0.6,0.0])
  glMaterialf(GL_FRONT,GL_SHININESS,0.25*128.0)
  glutSolidTeapot(size)

上面的函数中，前两行激活了灯光效果和一盏灯。灯被计为 GL_LIGHT0 和 GL_LIGHT1 等。在本例中，我们只使用一盏灯。glEnable() 函数用来激活 OpenGL 的一些特性。这些特性是使用大写常量来定义的。关闭特性可以使用相应的 glDisable() 函数来完成。接下来，深度测试被激活，使物体按照其深度表示出来（远处的物体不能绘制在近处物体的前面），然后清理深度缓存。接下来，指定物体的物质特性，例如漫反射和镜面反射颜色。在最后一行代码中，将指定的物质特性加入到 Utah 茶壶上。

4.4.4　综合集成

下面的完整脚本可以生成如图 4-5 所示的图像（假设你已经将上面的函数添加到了同一个文件中）：

from OpenGL.GL import *
from OpenGL.GLU import *
from OpenGL.GLUT import *
import pygame, pygame.image
from pygame.locals import *
import pickle

width,height = 1000,747

def setup():
  """ 设置窗口和pygame 环境"""
  pygame.init()
  pygame.display.set_mode((width,height),OPENGL | DOUBLEBUF)
  pygame.display.set_caption('OpenGL AR demo')

# 载入照相机数据
with open('ar_camera.pkl','r') as f:
  K = pickle.load(f)
  Rt = pickle.load(f)

setup()
draw_background('book_perspective.bmp')
set_projection_from_camera(K)
set_modelview_from_camera(Rt)
draw_teapot(0.02)

while True:
  event = pygame.event.poll()
  if event.type in (QUIT,KEYDOWN):
  break
pygame.display.flip()

图 4-5：增强现实。使用由特征匹配计算出的照相机参数，将一个计算机图形学模型放置在场景中的书本上：将 Utah 茶壶按照和坐标轴对齐的方式显示出来（上）；进行合理性验证，查看原点的位置（下）

首先，该脚本使用 Pickle 载入照相机标定矩阵，以及照相机矩阵中的旋转和平移部分。假设这些信息已经按照 4.3 节的描述进行保存。setup() 函数会初始化 PyGame，将窗口设置为图像的大小，绘制图像区域为两倍的 OpenGL 窗口缓存大小。接下来，载入背景图像，使其与窗口相符。然后，设定照相机和模拟视图矩阵。最后，在正确的位置上绘制出茶壶。

在 PyGame 中，使用带有对所有变化进行定期询问的无限循环来处理事件。这些事件可以为键盘、鼠标，或者其他。在这个例子中，我们检查应用是否退出，或者是否有按键按下，如果是，则退出这个循环。pygame.display.flip() 命令将会在屏幕上绘制出物体。

程序的输出结果如图 4-5 所示。可以看到，物体的朝向是正确的（在图 4-4 中，茶壶和立方体的边是对齐的）。为了验证放置的正确性，你可以通过给 size 变量传递一个较小的数值，将茶壶变小。在图 4-4 中，茶壶应该放置在靠近立方体坐标为 [0, 0, 0] 的角上。图 4-5 给出了一个例子。

4.4.5　载入模型

在结束本章之前，我们讲述最后一个细节：载入并显示三维模型。你可以在 http://www.pygame.org/wiki/OBJFileLoader 了解如何在 PyGame 中载入保存在 .obj 格式文件中的模型。除此之外，你还可以在 http://en.wikipedia.org/wiki/Wavefront_.obj_file 学习关于 .obj 以及其他相关文件格式的更多内容。

下面使用一个基本的例子来说明其使用方法。我们将使用一个免费的玩具飞机模型，模型来自 http://www.oyonale.com/modeles.php2。下载其 .obj 文件，然后保存为 toyplane.obj。当然，你也可以用任何其他模型替换，下面的代码不变。

2模型由 Gilles Tran 提供（共享创意许可）。

假设已经下载模型文件并命名为 objloader.py, 将下面的函数添加到茶壶例子的代码文件中：

def load_and_draw_model(filename):
  """ 使用objloader.py，从.obj 文件中装载模型
    假设路径文件夹中存在同名的.mtl 材料设置文件"""
  glEnable(GL_LIGHTING)
  glEnable(GL_LIGHT0)
  glEnable(GL_DEPTH_TEST)
  glClear(GL_DEPTH_BUFFER_BIT)

  # 设置模型颜色
  glMaterialfv(GL_FRONT,GL_AMBIENT,[0,0,0,0])
  glMaterialfv(GL_FRONT,GL_DIFFUSE,[0.5,0.75,1.0,0.0])
  glMaterialf(GL_FRONT,GL_SHININESS,0.25*128.0)

  # 从文件中载入
  import objloader
  obj = objloader.OBJ(filename,swapyz=True)
  glCallList(obj.gl_list)

和前面一样，我们首先设置模型的照明条件和颜色属性。接下来，我们将模型载入一个 obj 对象中，然后在文件中执行 OpenGL 的调用。

你可以在相应的 .mtl 文件中设置纹理和材料属性。实际上，objloader 模块需要一个含有材料设置的文件。我们采用仅创建一个小材料文件的实用方法，而不修改脚本。在这个例子中，我们仅指定了颜色信息。

使用下面的命令来创建 toyplane.mtl 文件：

newmtl lightblue
Kd 0.5 0.75 1.0
illum 1

上面的命令将物体的漫反射颜色设置为浅灰蓝色。现在，确保将 .obj 文件中的“usemtl”标签替换为：

usemtl lightblue

我们将添加纹理留作练习。将上面例子中的 draw_teapot() 命令替换为：

load_and_draw_model('toyplane.obj')

程序就会生成如图 4-6 所示的窗口图像。

这是本书关于增强现实和 OpenGL 最深入的例子。学习了标定矩阵、计算照相机姿态、将照相机转变成 OpenGL 格式，以及在场景中显示模型等方法，你已经具备了继续探索增强现实的基础。在下一节中，我们将在不使用标记物的情况下，继续学习照相机模型，计算三维结构以及照相机姿态。

图 4-6：从 .obj 文件载入三维模型，并使用由特征匹配计算出的照相机参数将其放置在书本上

练习

修改用于图 4-2 运动的示例代码，使其能够对点而非照相机进行变换，你应该会得到相同的图像。使用不同的变换进行实验，然后绘制出相应的结果。

一些牛津多视图数据集已经给定了照相机矩阵。对一个这样的数据集，计算其照相机位置，并且绘制出照相机的路径。这和你在图像中看到的是否吻合？

拍摄一些带有平面标记物和物体的场景图像。将图像的特征和一幅全景图像的特征相匹配，计算出每幅图像照相机位置的姿态。绘制出照相机的轨迹，以及标记物的平面。如果你喜欢，也可以在图像中加入特征点。

在增强现实的例子中，假设物体放置在原点，并且只将照相机的位置应用到模拟视图矩阵中。将物体之间的变换加入到矩阵中来修改该例子，使得在不同的位置放置一些物体。例如，在标记位置处放置茶壶网格。

浏览 .obj 模型文件的在线文档，学习如何使用纹理模型。找一个模型（或者创建自己的），然后将其添加到场景中。

第 5 章　多视图几何

本章讲解如何处理多个视图，以及如何利用多个视图的几何关系来恢复照相机位置信息和三维结构。通过在不同视点拍摄的图像，我们可以利用特征匹配来计算出三维场景点以及照相机位置。本章会介绍一些基本的方法，展示一个三维重建的完整例子；本章最后将介绍如何由立体图像进行致密深度重建。

5.1　外极几何

多视图几何是利用在不同视点所拍摄图像间的关系，来研究照相机之间或者特征之间关系的一门科学。图像的特征通常是兴趣点，本章使用的也是兴趣点特征。多视图几何中最重要的内容是双视图几何。

如果有一个场景的两个视图以及视图中的对应图像点，那么根据照相机间的空间相对位置关系、照相机的性质以及三维场景点的位置，可以得到对这些图像点的一些几何关系约束。我们通过外极几何来描述这些几何关系。本节简要介绍外极几何的基本内容。关于外极几何的更多内容，参阅文献 [13]。

没有关于照相机的先验知识，会出现固有二义性，因为三维场景点 X 经过 4×4 的 单应性矩阵 H 变换为 H X 后，则 H X 在照相机 PH⁻¹ 里得到的图像点和 X 在照相机 P 里得到的图像点相同。利用照相机方程，可以将上述问题描述为：

因此，当我们分析双视图几何关系时，总是可以将照相机间的相对位置关系用单应性矩阵加以简化。这里的单应性矩阵通常只做刚体变换，即只通过单应矩阵变换了坐标系。一个比较好的做法是，将原点和坐标轴与第一个照相机对齐，则：

P₁ = K₁[I|0]和P₂ = K₂[R|t]

为了方便读者阅读，我们在这里采取第 4 章的符号，其中 K₁ 和 K₂ 是标定矩阵，R 是第二个照相机的旋转矩阵，t 是第二个照相机的平移向量。利用这些照相机参数矩阵，我们可以找到点 X 的投影点 x₁ 和 x₂（分别对应于投影矩阵 P₁ 和 P₂）的关系。这样，我们可以从寻找对应的图像出发，恢复照相机参数矩阵。

同一个图像点经过不同的投影矩阵产生的不同投影点必须满足：

\text{x}_2^T\boldsymbol{F}\text{x}_1=0　　　　　　(5.1)

其中：

矩阵 St 为反对称矩阵：

\boldsymbol{S}_{\text{t}}=\begin{bmatrix}0 & -t_3 & t_2\\t_3 & 0 & -t_1\\-t_2 & t_1 & 0\end{bmatrix}　　　　　　(5.2)

公式（5.1）为外极约束条件。矩阵 F 为基础矩阵。基础矩阵可以由两照相机的参数矩阵（相对旋转 R 和平移 t）表示。由于 det(F)=0，所以基础矩阵的秩小于等于 2。 我们将在估计 F 的算法中用到这些性质。

上面的公式表明，我们可以借助 F 来恢复出照相机参数，而 F 可以从对应的投影图像点计算出来。在不知道照相机内参数（K₁ 和 K₂）的情况下，仅能恢复照相机的投影变换矩阵。在知道照相机内参数的情况下，重建是基于度量的。所谓度量重建，即能够在三维重建中正确地表示距离和角度 1。

1重建的绝对尺度不能恢复，但在实际中，这并不是个问题。

利用上面的理论处理一些图像数据前，我们还需要了解一些几何学知识。给定图像中的一个点，例如第二个视图中的图像点 x₂，利用公式（5.1），我们找到对应第一幅图像的一条直线：

其中 \boldsymbol{I}^T_1\text{x}_1=0 是第一幅图像中的一条直线。这条线称为对应于点 x₂ 的外极线，也就是说，点 x₂ 在第一幅图像中的对应点一定在这条线上。所以，基础矩阵可以将对应点的搜索限制在外极线上。

外极线都经过一点 e，称为外极点。实际上，外极点是另一个照相机光心对应的图像点。外极点可以在我们看到的图像外，这取决于照相机间的相对方向。因为外极点在所有的外极线上，所以 F e₁=0。因此，我们可以通过计算 F的零向量来计算出外极点。同理，另一个外极点可以通过计算e^t_2F=0得到。外极点和外极线如图 5-1 所示。

图 5-1：外极几何示意图。在这两个视图中，分别将三维点 X 投影为 x₁ 和 x₂。两个照相机中 心之间的基线 C₁ 和 C₂ 与图像平面相交于外极点 e₁ 和 e₂。线 I₁ 和 I₂ 称为外极线

5.1.1　一个简单的数据集

在接下来的几节中，我们需要一个带有图像点、三维点和照相机参数矩阵的数据集。我们这里使用一个牛津多视图数据集；从 http://www.robots.ox.ac.uk/~vgg/data/data-mview.html 可以下载 Merton1 数据的压缩文件。下面的脚本可以加载 Merton1 的数据：

import camera

# 载入一些图像
im1 = array(Image.open('images/001.jpg'))
im2 = array(Image.open('images/002.jpg'))

# 载入每个视图的二维点到列表中
points2D = [loadtxt('2D/00'+str(i+1)+'.corners').T for i in range(3)]

# 载入三维点
points3D = loadtxt('3D/p3d').T

# 载入对应
corr = genfromtxt('2D/nview-corners',dtype='int',missing='*')

# 载入照相机矩阵到Camera 对象列表中
P = [camera.Camera(loadtxt('2D/00'+str(i+1)+'.P')) for i in range(3)]

上面的程序会加载前两个图像（共三个）、三个视图中的所有图像特征点 2、对应不同视图图像点重建后的三维点以及照相机参数矩阵（使用上一章的 Camera 类）。这里我们使用 loadtxt() 函数读取文本文件到 NumPy 数组中。因为并不是所有的点都可见，或都能够成功匹配到所有的视图，所以对应数据里包含了缺失的数据。加载对应数据时需要考虑这一点。genfromtxt() 函数通过将缺失的数值（在文件中用 * 表示）填充为 -1 来解决这个问题。

2事实上，这些特征点是 2.1 节中的 Harris 角点。

将上面的代码保存到一个文件，例如 load_vggdata.py，然后使用命令 execfile() 可以很方便地运行上面的脚本，从而获取所有的数据：

execfile('load_vggdata.py')

下面来可视化这些数据。将三维的点投影到一个视图，然后和观测到的图像点比较：

# 将三维点转换成齐次坐标表示，并投影
X = vstack( (points3D,ones(points3D.shape[1])) )
x = P[0].project(X)

# 在视图1 中绘制点
figure()
imshow(im1)
plot(points2D[0][0],points2D[0][1],'*')
axis('off')

figure()
imshow(im1)
plot(x[0],x[1],'r.')
axis('off')

show()

上面的代码绘制出第一个视图以及该视图中的图像点。为比较方便，投影后的点绘制在另一张图上，如图 5-2 所示。如果仔细观察，你会发现第二幅图比第一幅图多一些点。这些多出的点是从视图 2 和视图 3 重建出来的，而不在视图 1 中。

图 5-2：牛津 multi-view 数据集中的 Merton1 数据集：视图 1 与图像点（左）；视图 1 与投影的三维点（右）

5.1.2　用Matplotlib绘制三维数据

为了可视化三维重建结果，我们需要绘制出三维图像。Matplotlib 中的 mplot3d 工具包可以方便地绘制出三维点、线、等轮廓线、表面以及其他基本图形组件，还可以通过图像窗口控件实现三维旋转和缩放。

可以通过在 axes 对象中加上 projection="3d" 关键字实现三维绘图，如下：

from mpl_toolkits.mplot3d import axes3d

fig = figure()
ax = fig.gca(projection="3d")

# 生成三维样本点
X,Y,Z = axes3d.get_test_data(0.25)

# 在三维中绘制点
ax.plot(X.flatten(),Y.flatten(),Z.flatten(),'o')

show()

get_test_data() 函数在 x, y 空间按照设定的空间间隔参数来产生均匀的采样点。压平这些网格，会产生三列数据点，然后我们可以将其输入 plot() 函数。这样，我们就可以在立体表面上画出三维点。

现在通过画出 Merton 样本数据来观察三维点的效果：

# 绘制三维点
from mpl_toolkits.mplot3d import axes3d
fig = figure()
ax = fig.gca(projection='3d')
ax.plot(points3D[0],points3D[1],points3D[2],'k.')

图 5-3 是三个不同视图中的三维图像点。图像窗口和控制界面外观效果像加上三维旋转工具的标准画图窗口。

图 5-3：使用 Matplottib 工具包绘制的，牛津 multi-view 数据库中 Mertoln1 数据集的三维点：从上面和侧边观测的视图（左）；俯视的视图，展示了建筑墙体和屋顶上的点（中）；侧视图，展示了一面墙的轮廓，以及另一面墙上点的主视图（右）

5.1.3　计算F：八点法

八点法是通过对应点来计算基础矩阵的算法。下面给出概述，更多内容参阅文献 [13] 和文献 [14]。

外极约束（5.1）可以写成线性系统的形式：

其中，f 包含 F 的元素，\text{x}^i_1=[x^i_1,y^i_1,w^i_1] 和 \text{x}^i_2=[x^i_2,y^i_2,w^i_2] 是一对图像点，共有 n 对对应点。基础矩阵中有 9 个元素，由于尺度是任意的，所以只需要 8 个方程。因为算法中需要 8 个对应点来计算基础矩阵 F，所以该算法叫做八点法。

新建一个文件 sfm.py，写入下面八点法中最小化 ||Af||的函数：

def compute_fundamental(x1,x2):
  """ 使用归一化的八点算法，从对应点（x1，x2 3×n 的数组）中计算基础矩阵
    每行由如下构成：
    [x'*x，x'*y' x', y'*x, y'*y, y', x, y, 1]"""

  n = x1.shape[1]

  if x2.shape[1] != n:
    raise ValueError("Number of points don't match.")

  # 创建方程对应的矩阵
  A = zeros((n,9))
  for i in range(n):
    A[i] = [x1[0,i]*x2[0,i], x1[0,i]*x2[1,i], x1[0,i]*x2[2,i],
      x1[1,i]*x2[0,i], x1[1,i]*x2[1,i], x1[1,i]*x2[2,i],
      x1[2,i]*x2[0,i], x1[2,i]*x2[1,i], x1[2,i]*x2[2,i] ]

  # 计算线性最小二乘解
  U,S,V = linalg.svd(A)
  F = V[-1].reshape(3,3)

  # 受限F
  # 通过将最后一个奇异值置0，使秩为2
  U,S,V = linalg.svd(F)
  S[2] = 0
  F = dot(U,dot(diag(S),V))

  return F

我们通常用 SVD 算法来计算最小二乘解。由于上面算法得出的解可能秩不为 2（基础矩阵的秩小于等于 2），所以我们通过将最后一个奇异值置 0 来得到秩最接近 2 的基础矩阵。这是个很有用的技巧。上面的函数忽略了一个重要的步骤：对图像坐标进行归一化，这可能会带来数值问题。我们会在后面加以解决。

5.1.4　外极点和外极线

本节开始提到过，外极点满足 _F_e₁=0，因此可以通过计算 F 的零空间来得到。把下面的函数添加到 sfm.py 中：

def compute_epipole(F):
  """ 从基础矩阵F 中计算右极点（可以使用F.T 获得左极点）"""

  # 返回F 的零空间（Fx=0）
  U,S,V = linalg.svd(F)
  e = V[-1]
return e/e[2]

如果想获得另一幅图像的外极点（对应左零空间的外极点），只需将 F 转置后输入上述函数即可。

我们可以在之前样本数据集的前两个视图上运行这两个函数：

import sfm

# 在前两个视图中点的索引
ndx = (corr[:,0]>=0) & (corr[:,1]>=0)

# 获得坐标，并将其用齐次坐标表示
x1 = points2D[0][:,corr[ndx,0]]
x1 = vstack( (x1,ones(x1.shape[1])) )
x2 = points2D[1][:,corr[ndx,1]]
x2 = vstack( (x2,ones(x2.shape[1])) )

# 计算F
F = sfm.compute_fundamental(x1,x2)

# 计算极点
e = sfm.compute_epipole(F)

# 绘制图像
figure()
imshow(im1)

# 分别绘制每条线，这样会绘制出很漂亮的颜色
for i in range(5):
  sfm.plot_epipolar_line(im1,F,x2[:,i],e,False)
axis('off')

figure()
imshow(im2)
# 分别绘制每个点，这样会绘制出和线同样的颜色
for i in range(5):
  plot(x2[0,i],x2[1,i],'o')
axis('off')

show()

首先，选择两幅图像的对应点，然后将它们转换为齐次坐标。这里的对应点是从一个文本文件中读取得到的；而实际上，我们可以按照第 2 章的方法提取图像特征，然后通过匹配来找到它们。由于缺失的数据在对应列表 corr 中为 -1，所以程序中有可能 选取这些点。因此，上面的程序通过数组操作符 & 只选取了索引大于等于 0 的点。

最后，我们在第一个视图中画出了前 5 个外极线，在第二个视图中画出了对应匹配点。这里我们主要借助 plot() 函数：

def plot_epipolar_line(im,F,x,epipole=None,show_epipole=True):
  """ 在图像中，绘制外极点和外极线 F×x=0。F 是基础矩阵，x 是另一幅图像中的点"""

  m,n = im.shape[:2]
  line = dot(F,x)

  # 外极线参数和值
  t = linspace(0,n,100)
  lt = array([(line[2]+line[0]*tt)/(-line[1]) for tt in t])

  # 仅仅处理位于图像内部的点和线
  ndx = (lt>=0) & (lt<m)
  plot(t[ndx],lt[ndx],linewidth=2)

  if show_epipole:
    if epipole is None:
      epipole = compute_epipole(F)
    plot(epipole[0]/epipole[2],epipole[1]/epipole[2],'r*')

上面的函数将 x 轴的范围作为直线的参数，因此直线超出图像边界的部分会被截断。如果 show_epipole 为真，外极点也会被画出来（如果输入参数中没有外极点，外极点会在程序中计算获得）。程序结果如图 5-4 所示。在两幅图中，用不同的颜色将点和相应的外极线对应起来。

图 5-4：视图 1 中的外极线示为 Merton1 数据视图 2 中 5 个点对应的外极线。下面一行为这些点附近的特写。可以看到，这些线在图像外左侧位置将相交于一点。这些线表明点之间的对应可以在另外一幅图像中找到（线和点之间的颜色编码匹配）

5.2　照相机和三维结构的计算

上一节讲述了视图和基础矩阵、外极线计算方法的关系。这一节我们将简单地介绍计算照相机参数和三维结构的工具。

5.2.1　三角剖分

给定照相机参数模型，图像点可以通过三角剖分来恢复出这些点的三维位置。基本的算法思想如下。

对于两个照相机 P₁ 和 P₂ 的视图，三维实物点 X 的投影点为 x₁ 和 x₂（这里用齐次坐标表示），照相机方程（4.1）定义了下列关系：

由于图像噪声、照相机参数误差和其他系统误差，上面的方程可能没有精确解。我们可以通过 SVD 算法来得到三维点的最小二乘估值。

下面的函数用于计算一个点对的最小二乘三角剖分，把它添加到 sfm.py 中：

def triangulate_point(x1,x2,P1,P2):
  """ 使用最小二乘解，绘制点对的三角剖分"""

  M = zeros((6,6))
  M[:3,:4] = P1
  M[3:,:4] = P2
  M[:3,4] = -x1
  M[3:,5] = -x2

  U,S,V = linalg.svd(M)
  X = V[-1,:4]

  return X / X[3]

最后一个特征向量的前 4 个值就是齐次坐标系下的对应三维坐标。我们可以增加下面的函数来实现多个点的三角剖分：

def triangulate(x1,x2,P1,P2):
  """ x1 和x2（3×n 的齐次坐标表示）中点的二视图三角剖分"""

  n = x1.shape[1]
  if x2.shape[1] != n:

    raise ValueError("Number of points don't match.")

  X = [ triangulate_point(x1[:,i],x2[:,i],P1,P2) for i in range(n)]
  return array(X).T

这个函数的输入是两个图像点数组，输出为一个三维坐标数组。

我们可以利用下面的代码来实现 Merton1 数据集上的三角剖分：

import sfm
# 前两个视图中点的索引
ndx = (corr[:,0]>=0) & (corr[:,1]>=0)

# 获取坐标，并用齐次坐标表示
x1 = points2D[0][:,corr[ndx,0]]
x1 = vstack( (x1,ones(x1.shape[1])) )
x2 = points2D[1][:,corr[ndx,1]]
x2 = vstack( (x2,ones(x2.shape[1])) )

Xtrue = points3D[:,ndx]
Xtrue = vstack( (Xtrue,ones(Xtrue.shape[1])) )

# 检查前三个点
Xest = sfm.triangulate(x1,x2,P[0].P,P[1].P)
print Xest[:,:3]
print Xtrue[:,:3]

# 绘制图像
from mpl_toolkits.mplot3d import axes3d
fig = figure()
ax = fig.gca(projection='3d')
ax.plot(Xest[0],Xest[1],Xest[2],'ko')
ax.plot(Xtrue[0],Xtrue[1],Xtrue[2],'r.')
axis('equal')

show()

上面的代码首先利用前两个视图的信息来对图像点进行三角剖分，然后把前三个图像点的齐次坐标输出到控制台，最后绘制出恢复的最接近三维图像点。输出到控制台的信息如下：

[[ 1.03743725  1.56125273  1.40720017]
 [-0.57574987 -0.55504127 -0.46523952]
 [ 3.44173797  3.44249282  7.53176488]
 [ 1.          1.          1.        ]]
[[ 1.0378863   1.5606923   1.4071907 ]
 [-0.54627892 -0.5211711  -0.46371818]
 [ 3.4601538   3.4636809   7.5323397 ]
 [ 1.          1.          1.        ]]

算法估计出的三维图像点和实际图像点很接近。如图 5-5 所示，估计点和实际点可以很好地匹配。

图 5-5：使用照相机矩阵和点的对应关系来三角剖分这些数据点。黑色的圆圈表示估计的点，灰色的点表示真实点。上方一侧的视图（左）。一个坐标平面观看这些数据点的近景图像（右）

5.2.2　由三维点计算照相机矩阵

如果已经知道了一些三维点及其图像投影，我们可以使用直接线性变换的方法来计算照相机矩阵 P。本质上，这是三角剖分方法的逆问题，有时我们将其称为照相机反切法。利用该方法恢复照相机矩阵同样也是一个最小二乘问题。

我们从照相机方程（4.1）可以得出，每个三维点 Xi（齐次坐标系下）按照 λixi = P Xi 投影到图像点 xi=[xi, yi,1]，相应的点满足下面的关系：

其中 p₁、p₂ 和 p₃ 是矩阵 P 的三行。上面的式子可以写得更紧凑，如下所示：

M v=0

然后，我们可以使用 SVD 分解估计出照相机矩阵。利用上面讲述的矩阵操作，我们可以直接写出相应的代码。将下面的函数添加到 sfm.py 文件中：

def compute_P(x,X):
  """ 由二维- 三维对应对（齐次坐标表示）计算照相机矩阵"""

  n = x.shape[1]
  if X.shape[1] != n:
    raise ValueError("Number of points don't match.")

  # 创建用于计算DLT 解的矩阵
  M = zeros((3*n,12+n))
  for i in range(n):
    M[3*i,0:4] = X[:,i]
    M[3*i+1,4:8] = X[:,i]
    M[3*i+2,8:12] = X[:,i]
    M[3*i:3*i+3,i+12] = -x[:,i]

  U,S,V = linalg.svd(M)

  return V[-1,:12].reshape((3,4))

该函数的输入参数为图像点和三维点，构造出上述所示的 M 矩阵。最后一个特征向量的前 12 个元素是照相机矩阵的元素，经过重新排列成矩阵形状后返回。

下面，在我们的样本数据集上测试算法的性能。下面的代码会选出第一个视图中的一些可见点（使用对应列表中缺失的数值），将它们转换为齐次坐标表示，然后估计照相机矩阵：

import sfm, camera

corr = corr[:,0] # 视图1
ndx3D = where(corr>=0)[0] # 丢失的数值为-1
ndx2D = corr[ndx3D]

# 选取可见点，并用齐次坐标表示
x = points2D[0][:,ndx2D] # 视图1
x = vstack( (x,ones(x.shape[1])) )
X = points3D[:,ndx3D]
X = vstack( (X,ones(X.shape[1])) )

# 估计P
Pest = camera.Camera(sfm.compute_P(x,X))

# 比较！
print Pest.P / Pest.P[2,3]
print P[0].P / P[0].P[2,3]

xest = Pest.project(X)

# 绘制图像
figure()
imshow(im1)
plot(x[0],x[1],'bo')
plot(xest[0],xest[1],'r.')
axis('off')

show()

为了检查照相机矩阵的正确性，将它们以归一化的格式（除以最后一个元素）打印到控制台。输出如下所示：

[[  1.06520794e+00 -5.23431275e+01  2.06902749e+01 5.08729305e+02]
 [ -5.05773115e+01 -1.33243276e+01 -1.47388537e+01 4.79178838e+02]
 [  3.05121915e-03 -3.19264684e-02 -3.43703738e-02 1.00000000e+00]]
[[  1.06774679e+00 -5.23448212e+01  2.06926980e+01 5.08764487e+02]
 [ -5.05834364e+01 -1.33201976e+01 -1.47406641e+01 4.79228998e+02]
 [  3.06792659e-03 -3.19008054e-02 -3.43665129e-02 1.00000000e+00]]

上面是估计出的照相机矩阵，下面是数据集的创建者计算出的照相机矩阵。可以看到，它们的元素几乎完全相同。最后，使用估计出的照相机矩阵投影这些三维点，然后绘制出投影后的结果。结果如图 5-6 所示，真实点用圆圈表示，估计出的照相机投影点用点表示。

图 5-6：使用估计出的照相机矩阵来计算视图 1 中的投影点

5.2.3　由基础矩阵计算照相机矩阵

在两个视图的场景中，照相机矩阵可以由基础矩阵恢复出来。假设第一个照相机矩阵归一化为 P₁=[I|0]，现在我们需要计算出第二个照相机矩阵 P₂。研究分为两类，未标定的情况和已标定的情况。

未标定的情况——投影重建

在没有任何照相机内参数知识的情况下，照相机矩阵只能通过射影变换恢复出来。也就是说，如果利用照相机的信息来重建三维点，那么该重建只能由射影变换计算出来（你可以得到整个投影场景中无畸变的重建点）。在这里，我们不考虑角度和距离。

因此，在无标定的情况下，第二个照相机矩阵可以使用一个（3×3）的射影变换得出。一个简单的方法是：

P₂=[SeF|e]

其中，e 是左极点，满足 eTF=0，Se 是如公式（5.2）所示的反对称矩阵。请注意，使用该矩阵做出的三角形剖分很有可能会发生畸变，如倾斜的重建。

下面是具体的代码：

def compute_P_from_fundamental(F):
  """ 从基础矩阵中计算第二个照相机矩阵（假设 P1 = [I 0]）"""

  e = compute_epipole(F.T) # 左极点
  Te = skew(e)
  return vstack((dot(Te,F.T).T,e)).T

这里，我们使用的 skew() 函数定义如下：

def skew(a):
  """ 反对称矩阵A，使得对于每个v 有a×v=Av """

  return array([[0,-a[2],a[1]],[a[2],0,-a[0]],[-a[1],a[0],0]])

将上面的两个函数添加到 sfm.py 文件中。

已标定的情况——度量重建

在已经标定的情况下，重建会保持欧式空间中的一些度量特性（除了全局的尺度参数）。在重建三维场景中，已标定的例子更为有趣。

给定标定矩阵 K，我们可以将它的逆 K⁻¹ 作用于图像点 xK=K⁻¹x，因此，在新的图像

坐标系下，照相机方程变为：

xK = K⁻¹K[R|t]X = [R|t]X

在新的图像坐标系下，点同样满足之前的基础矩阵方程：

在标定归一化的坐标系下，基础矩阵称为本质矩阵。为了区别为标定后的情况，以及归一化了的图像坐标，我们通常将其记为 E，而非 F。

从本质矩阵恢复出的照相机矩阵中存在度量关系，但有四个可能解。因为只有一个解产生位于两个照相机前的场景，所以我们可以轻松地从中选出来。

下面是计算这 4 个解的算法（参阅文献 [13] 获取更多细节）。将该函数添加到 sfm.py 文件中：

def compute_P_from_essential(E):
  """ 从本质矩阵中计算第二个照相机矩阵（假设 P1 = [I 0]）
    输出为4 个可能的照相机矩阵列表"""

  # 保证E 的秩为2
  U,S,V = svd(E)
  if det(dot(U,V))<0:
    V = -V
  E = dot(U,dot(diag([1,1,0]),V))

  # 创建矩阵（Hartley）
  Z = skew([0,0,-1])
  W = array([[0,-1,0],[1,0,0],[0,0,1]])

  # 返回所有（4 个）解
  P2 = [vstack((dot(U,dot(W,V)).T,U[:,2])).T,
       vstack((dot(U,dot(W,V)).T,-U[:,2])).T,
       vstack((dot(U,dot(W.T,V)).T,U[:,2])).T,
       vstack((dot(U,dot(W.T,V)).T,-U[:,2])).T]

  return P2

首先，该函数确保本质矩阵的秩为 2（本质矩阵有两个相等的非零奇异值）。然后，按照文献 [13] 中的方法，计算出这 4 个解。关于如何挑选正确的解，我们将在后面的例子中讲解。

本节涵盖了从图像集计算出三维重建所需的所有理论。

5.3　多视图重建

下面让我们来看，如何使用上面的理论从多幅图像中计算出真实的三维重建。由于照相机的运动给我们提供了三维结构，所以这样计算三维重建的方法通常称为 SfM（Structure from Motion，从运动中恢复结构）。

假设照相机已经标定，计算重建可以分为下面 4 个步骤：

检测特征点，然后在两幅图像间匹配；

由匹配计算基础矩阵；

由基础矩阵计算照相机矩阵；

三角剖分这些三维点。

我们已经具备了完成上面 4 个步骤所需的所有工具。但是当图像间的点对应包含不正确的匹配时，我们需要一个稳健的方法来计算基础矩阵。

5.3.1　稳健估计基础矩阵

类似于稳健计算单应性矩阵（3.3 节），当存在噪声和不正确的匹配时，我们需要估计基础矩阵。和前面的方法一样，我们使用 RANSAC 方法，只不过这次结合了八点算法。注意，八点算法在平面场景中会失效，所以，如果场景点都位于平面上，就不能使用该算法。

将下面的类添加到 sfm.py 文件中：

class RansacModel(object):
  """ 用从http://www.scipy.org/Cookbook/RANSAC 下载的ransac.py 计算基础矩阵的类"""

  def __init__(self,debug=False):
    self.debug = debug

  def fit(self,data):
    """ 使用选择的8 个对应计算基础矩阵"""

    # 转置，并将数据分成两个点集
    data = data.T
    x1 = data[:3,:8]
    x2 = data[3:,:8]

    # 估计基础矩阵，并返回
    F = compute_fundamental_normalized(x1,x2)
    return F

  def get_error(self,data,F):
    """ 计算所有对应的x^T F x，并返回每个变换后点的误差"""

    # 转置，并将数据分成两个点集
    data = data.T
    x1 = data[:3]
    x2 = data[3:]

    # 将Sampson 距离用作误差度量
    Fx1 = dot(F,x1)
    Fx2 = dot(F,x2)
    denom = Fx1[0]**2 + Fx1[1]**2 + Fx2[0]**2 + Fx2[1]**2
    err = ( diag(dot(x1.T,dot(F,x2))) )**2 / denom

    # 返回每个点的误差
    return err

和之前一样，我们需要 fit() 和 get_error() 方法。这里采用的错误衡量方法是 Sampson 距离（参阅文献 [13]）。fit() 方法会选择 8 个点，然后使用归一化的八点算法：

def compute_fundamental_normalized(x1,x2):
  """ 使用归一化的八点算法，由对应点（x1，x2 3×n 的数组）计算基础矩阵"""

  n = x1.shape[1]
  if x2.shape[1] != n:
  raise ValueError("Number of points don't match.")

  # 归一化图像坐标
  x1 = x1 / x1[2]
  mean_1 = mean(x1[:2],axis=1)
  S1 = sqrt(2) / std(x1[:2])
  T1 = array([[S1,0,-S1*mean_1[0]],[0,S1,-S1*mean_1[1]],[0,0,1]])
  x1 = dot(T1,x1)

  x2 = x2 / x2[2]
  mean_2 = mean(x2[:2],axis=1)
  S2 = sqrt(2) / std(x2[:2])
  T2 = array([[S2,0,-S2*mean_2[0]],[0,S2,-S2*mean_2[1]],[0,0,1]])
  x2 = dot(T2,x2)

  # 使用归一化的坐标计算F
  F = compute_fundamental(x1,x2)

  # 反归一化
  F = dot(T1.T,dot(F,T2))

  return F/F[2,2]

该函数将图像点归一化为零均值固定方差。

接下来，我们在函数中使用该类。将下面的函数添加到 sfm.py 文件中：

def F_from_ransac(x1,x2,model,maxiter=5000,match_theshold=1e-6):
  """ 使用RANSAN 方法（ransac.py，来自http://www.scipy.org/Cookbook/RANSAC），
    从点对应中稳健地估计基础矩阵F
    输入：使用齐次坐标表示的点x1，x2（3×n 的数组）"""

    import ransac

    data = vstack((x1,x2))

    # 计算F ，并返回正确点索引
    F,ransac_data = ransac.ransac(data.T,model,8,maxiter,match_theshold,20,
                                  return_all=True)
    return F, ransac_data['inliers']

这里，我们返回最佳基础矩阵 F，以及正确点的索引，这样可以知道哪些匹配和 F 矩阵是一致的。与单应性矩阵估计相比，我们增加了默认的最大迭代次数，改变了匹配的阈值（之前使用像素，现在使用 Sampson 距离来衡量）。

5.3.2　三维重建示例

在本节中，我们将观察一个重建三维场景的完整例子。我们使用由已知标定矩阵的照相机拍摄的两幅图像。该图像是著名的恶魔岛监狱，如图 5-7 所示。3

3图像由 Carl Olsson 提供（参见 http://www.maths.lth.se/matematiklth/personal/calle/）。

图 5-7：在一个场景中使用不同视角拍摄图像对

我们将该处理代码分成若干块，使得代码更容易理解。首先，提取、匹配特征，然后估计基础矩阵和照相机矩阵：

import homography
import sfm
import sift

# 标定矩阵
K = array([[2394,0,932],[0,2398,628],[0,0,1]])

# 载入图像，并计算特征
im1 = array(Image.open('alcatraz1.jpg'))
sift.process_image('alcatraz1.jpg','im1.sift')
l1,d1 = sift.read_features_from_file('im1.sift')

im2 = array(Image.open('alcatraz2.jpg'))
sift.process_image('alcatraz2.jpg','im2.sift')
l2,d2 = sift.read_features_from_file('im2.sift')

# 匹配特征
matches = sift.match_twosided(d1,d2)
ndx = matches.nonzero()[0]

# 使用齐次坐标表示，并使用inv(K) 归一化
x1 = homography.make_homog(l1[ndx,:2].T)
ndx2 = [int(matches[i]) for i in ndx]
x2 = homography.make_homog(l2[ndx2,:2].T)

x1n = dot(inv(K),x1)
x2n = dot(inv(K),x2)

# 使用RANSAC 方法估计E
model = sfm.RansacModel()
E,inliers = sfm.F_from_ransac(x1n,x2n,model)

# 计算照相机矩阵（P2 是4 个解的列表）
P1 = array([[1,0,0,0],[0,1,0,0],[0,0,1,0]])
P2 = sfm.compute_P_from_essential(E)

现在，我们已经获得了标定矩阵，所以只对矩阵 K 进行硬编码。与之前的例子一样，我们挑选属于匹配关系的特定点。然后使用 K⁻¹ 来对其进行归一化，并使用归一化的八点算法来运行 RANSAC 估计。因为该点已经归一化，所以这里会返回一个本质矩阵。因为我们将会使用到的正确的匹配点，所以需要保存正确匹配点的索引。从本质矩阵出发，我们可以计算出第二个照相机矩阵的四个可能解。

从照相机矩阵的列表中，挑选出经过三角剖分后，在两个照相机前均含有最多场景点的照相机矩阵：

# 选取点在照相机前的解
ind = 0
maxres = 0
for i in range(4):

  # 三角剖分正确点，并计算每个照相机的深度
  X = sfm.triangulate(x1n[:,inliers],x2n[:,inliers],P1,P2[i])
  d1 = dot(P1,X)[2]
  d2 = dot(P2[i],X)[2]
  if sum(d1>0)+sum(d2>0) > maxres:
    maxres = sum(d1>0)+sum(d2>0)
    ind = i
    infront = (d1>0) & (d2>0)

  # 三角剖分正确点，并移除不在所有照相机前面的点
  X = sfm.triangulate(x1n[:,inliers],x2n[:,inliers],P1,P2[ind])
  X = X[:,infront]

循环遍历这 4 个解，每次对对应于正确点的三维点进行三角剖分。将三角剖分后的图像投影回图像后，深度的符号由每个图像点的第三个数值给出。我们保存了正向最大深度的索引；对于和最优解一致的点，用相应的布尔量保存了信息，这样可以取出真正在照相机前面的点。因为所有估计中都存在噪声和误差，所以即便使用正确的照相机矩阵，也存在一些点仍位于某个照相机后面的风险。首先获得正确的解，然后对这些正确的点进行三角剖分，最后保留位于照相机前方的点。

现在可以绘制出该三维重建：

# 绘制三维图像
from mpl_toolkits.mplot3d import axes3d

fig = figure()
ax = fig.gca(projection='3d')
ax.plot(-X[0],X[1],X[2],'k.')
axis('off')

和我们的坐标系相比，使用 mplot3d 绘制三维图像需要将第一个坐标值取相反数，所以这里改变其符号。

然后，可以在每个视图中绘制出二次投影：

# 绘制X 的投影
import camera

# 绘制三维点
cam1 = camera.Camera(P1)
cam2 = camera.Camera(P2[ind])
x1p = cam1.project(X)
x2p = cam2.project(X)

# 反K 归一化
x1p = dot(K,x1p)
x2p = dot(K,x2p)

figure()
imshow(im1)
gray()
plot(x1p[0],x1p[1],'o')
plot(x1[0],x1[1],'r.')
axis('off')

figure()
imshow(im2)
gray()
plot(x2p[0],x2p[1],'o')
plot(x2[0],x2[1],'r.')
axis('off')
show()

将这些三维点投影后，可以通过乘以标定矩阵的方式来弥补初始归一化对点的影响。

结果输出如图 5-8 所示。可以看到，二次投影后的点和原始特征位置不完全匹配，但是相当接近。当然，可以进一步调整照相机矩阵来提高重建和二次投影的性能，但是这超出了这个简单例子的范畴。

图 5-8：使用图像匹配从一对图像中计算三维重建：带有特征点（黑色）和二次投影重建的三维点（白色）的两幅图像（上）；三维重建（下）

5.3.3　多视图的扩展示例

多视图重建有一些步骤和进一步的扩展，但是不能在本书中全部介绍。为了方便读者进一步阅读，下面提供关于一些有关内容及其参考文献。

多视图

当我们有同一场景的多个视图时，三维重建会变得更准确，包括更多的细节信息。因为基础矩阵只和一对视图相关，所以该过程带有很多图像，和之前的处理有些不同。

对于视频序列，我们可以使用时序关系，在连续的帧对中匹配特征。相对方位需要从每个帧对增量地加入下一个帧对，（与图 3-12 中我们在全景图例子中加入单应性矩阵相同）。该方法非常有效，同时可以使用跟踪有效地找到对应点（关于跟踪的更多知识，参阅 10.4 节）。一个问题是误差会在加入的视图中积累。该问题可以由最后的优化步骤来解决，参见下文。

对于静止的图像，一个办法是找到一个中央参考视图，然后计算与之有关的所有其他照相机矩阵。另一个办法是计算一个图像对的照相机矩阵和三维重建，然后增量地加入新的图像和三维点，参见参考文献 [34]。另外，还有一些方法可以同时由三个视图计算三维重建和照相机位置（参阅参考文献 [13]）；但除此之外，我们还需要使用增量的方法。

光束法平差

从图 5-8 简单三维重建的例子，我们可以清楚地看出，恢复出的点的位置和由估计的基础矩阵计算出的照相机矩阵都存在误差。在多个视图的计算中，这些误差会进一步累积。因此，多视图重建的最后一步，通常是通过优化三维点的位置和照相机参数来减少二次投影误差。该过程称为光束法平差。更多内容参阅参考文献 [13] 和 [35]，或者可以从 http://en.wikipedia.org/wiki/Bundle_adjustment 参阅该方法的概述。

自标定

在未标定照相机的情形中，有时可以从图像特征来计算标定矩阵。该过程称为自标定。根据在照相机标定矩阵参数上做出的假设，以及可用的图像数据的类型（特征匹配、平行线、平面等），我们有很多不同的自标定算法。感兴趣的读者可以参阅参考文献 [13] 及参考文献 [26] 第 6 章的内容。

作为标定的附注，值得一提的是一个叫做 extract_focal.pl 的有用脚本，它是 SfM 系统（http://phototour.cs.washington.edu/bundler/）的一部分。对于常见的照相机，该脚本基于图像 EXIF 数据，使用一个查找表来估计焦距。

5.4　立体图像

一个多视图成像的特殊例子是立体视觉（或者立体成像），即使用两台只有水平（向一侧）偏移的照相机观测同一场景。当照相机的位置如上设置，两幅图像具有相同的图像平面，图像的行是垂直对齐的，那么称图像对是经过矫正的。该设置在机器人学中很常见，常被称为立体平台。

通过将图像扭曲到公共的平面上，使外极线位于图像行上，任何立体照相机设置都能得到矫正（我们通常构建立体平台来产生经过矫正的图像对）。这超出了本章节的范围，感兴趣的读者可以参阅参考文献 [13] 第 303 页，或者参考文献 [3] 第 430 页。

假设两幅图像经过了矫正，那么对应点的寻找限制在图像的同一行上。一旦找到对应点，由于深度是和偏移成正比的，那么深度（Z 坐标）可以直接由水平偏移来计算，

其中，f 是经过矫正图像的焦距，b 是两个照相机中心之间的距离，xl 和 xr 是左右两幅图像中对应点的 x 坐标。分开照相机中心的距离称为基线。矫正后的立体照相机设置如图 5-9 所示。

图 5-9：矫正后立体照相机设置的示意图，其中对应点位于两幅图像的同一行

立体重建（有时称为致密深度重建）就是恢复深度图（或者相反，视差图），图像中每个像素的深度（或者视差）都需要计算出来。这是计算机视觉中的经典问题，有很多算法可以解决该问题。米德尔伯里学院立体视觉网页（http://vision.middlebury.edu/stereo/）保持更新最佳算法的评估，以及该算法的代码和许多细节描述。在下一节中，我们会讲解基于归一化互相关的立体重建算法。

计算视差图

在该立体重建算法中，我们将对于每个像素尝试不同的偏移，并按照局部图像周围归一化的互相关值，选择具有最好分数的偏移，然后记录下该最佳偏移。因为每个偏移在某种程度上对应于一个平面，所以该过程有时称为扫平面法。虽然该方法并不是立体重建中最好的方法，但是非常简单，通常会得出令人满意的结果。

当密集地应用在图像中时，归一化的互相关值可以很快地计算出来。这和我们在第2章中应用于稀疏点对应的不同。我们使用每个像素周围的图像块（根本上说，是局部周边图像）来计算归一化的互相关。对于这里的情形，我们可以在像素周围重新写出公式（2.3）中的 NCC，如下所示

我们在前面跳过了归一化常数（这里不需要），然后对该像素周围局部像素块中的像素求和。

现在，我们要将图像中的每个像素进行该操作。这三个求和操作是在局部图像块区域上进行的，我们可以使用图像滤波器来快速计算，这与我们在模糊和求导操作中使用的技巧一样。ndimage.filters 模块中的 uniform_filter() 函数可以在一个矩形图像块中计算相加。

下面是扫平面法的具体实现代码，该函数返回每个像素的最佳视差。创建 stereo.py 文件，将下面的代码添加进去：

def plane_sweep_ncc(im_l,im_r,start,steps,wid):
  """ 使用归一化的互相关计算视差图像"""

  m,n = im_l.shape

  # 保存不同求和值的数组
  mean_l = zeros((m,n))
  mean_r = zeros((m,n))
  s = zeros((m,n))
  s_l = zeros((m,n))
  s_r = zeros((m,n))

  # 保存深度平面的数组
  dmaps = zeros((m,n,steps))

  # 计算图像块的平均值
  filters.uniform_filter(im_l,wid,mean_l)
  filters.uniform_filter(im_r,wid,mean_r)

  # 归一化图像
  norm_l = im_l - mean_l
  norm_r = im_r - mean_r

  # 尝试不同的视差
  for displ in range(steps):
    # 将左边图像移动到右边，计算加和
    filters.uniform_filter(roll(norm_l,-displ-start)*norm_r,wid,s) # 和归一化
    filters.uniform_filter(roll(norm_l,-displ-start)*roll(norm_l,-displ-start),wid,s_l)
    filters.uniform_filter(norm_r*norm_r,wid,s_r) # 和反归一化

    # 保存ncc 的分数
    dmaps[:,:,displ] = s/sqrt(s_l*s_r)

  # 为每个像素选取最佳深度
  return argmax(dmaps,axis=2)

首先，因为 uniform_filter() 函数的输入参数为数组，我们需要创建用于保存滤波结果的一些数组。然后，我们创建一个数组来保存每个平面，从而能够在最后一个纬度上使用 argmax() 函数找到对于每个像素的最佳深度。该函数从 start 偏移出发，在所有的 steps 偏移上迭代。使用 roll() 函数平移一幅图像，然后使用滤波计算 NCC 的三个求和操作。

下面是载入图像，并使用该函数计算偏移图的完整例子：

import stereo

im_l = array(Image.open('scene1.row3.col3.ppm').convert('L'),'f')
im_r = array(Image.open('scene1.row3.col4.ppm').convert('L'),'f')

# 开始偏移，并设置步长
steps = 12
start = 4

# ncc 的宽度
wid = 9

res = stereo.plane_sweep_ncc(im_l,im_r,start,steps,wid)

import scipy.misc
scipy.misc.imsave('depth.png',res)

这里首先从经典“tsukuba”数据集中载入一对图像，然后将其灰度化。接下来，设置扫平面函数所需的参数，包括尝试偏移的数目、初始值和 NCC 路径的宽度。你会发现，该方法非常快，至少快于使用 NCC 进行特征匹配。这也是使用滤波器来计算的原因。

该方法同样适用于其他滤波器。均匀滤波器给定正方形图像块中所有像素相同的权值。但是，在一些例子中，其他滤波器对 NCC 的计算可能更为适用。下面是使用高斯滤波器替换均匀滤波器，产生更加平滑视差图的例子。将其添加到 stereo.py 文件中：

def plane_sweep_gauss(im_l,im_r,start,steps,wid):
  """ 使用带有高斯加权周边的归一化互相关计算视差图像"""

  m,n = im_l.shape

  # 保存不同加和的数组
  mean_l = zeros((m,n))
  mean_r = zeros((m,n))
  s = zeros((m,n))
  s_l = zeros((m,n))
  s_r = zeros((m,n))

  # 保存深度平面的数组
  dmaps = zeros((m,n,steps))

  # 计算平均值
  filters.gaussian_filter(im_l,wid,0,mean_l)
  filters.gaussian_filter(im_r,wid,0,mean_r)

  # 归一化图像
  norm_l = im_l - mean_l
  norm_r = im_r - mean_r

  # 尝试不同的视差
  for displ in range(steps):
    # 将左边图像移动到右边，计算加和
    filters.gaussian_filter(roll(norm_l,-displ-start)*norm_r,wid,0,s) # 和归一化
    filters.gaussian_filter(roll(norm_l,-displ-start)*roll(norm_l,-displ-start),wid,0,s_l)
    filters.gaussian_filter(norm_r*norm_r,wid,0,s_r) # 和反归一化

    # 保存ncc 的分数
    dmaps[:,:,displ] = s/sqrt(s_l*s_r)

  # 为每个像素选取最佳深度
  return argmax(dmaps,axis=2)

除了在滤波中使用了额外的参数，该代码和均匀滤波的代码相同。我们需要在 gaussian_filter() 函数中传入零参数来表示我们使用的是标准高斯函数，而不是其他任何导数（详见 1.4.2 节）。

你可以像前面的扫平面函数一样来使用该函数。图 5-10 和图 5-11 所示为这两个扫平面实现操作在一些标准立体基准图像上的结果。这些图像来自于参考文献 [29] 和 [30]，可以从 http://vision.middlebury.edu/stereo/data/ 下载。这里我们使用“tsukuba”和“cones”图像，在标准版本中设置 wid 为 9，高斯版本中设置 wid 为 3。上面一行图像所示为图像对，左下方图像是标准的 NCC 扫平面法重建的视差 图，右下方是高斯版本的视差图。可以看到，与标准版本相比，高斯版本具有较少的噪声，但缺少很多细节信息。

图 5-10：使用归一化互相关从一对立体图像中计算视差图

图 5-11：使用归一化互相关从一对立体图像中计算视差图

练习

使用本章介绍的技术来验证 2.3.2 节中白宫例子的匹配（使用自己的例子更好），试看能否提高结果的性能。

计算图像对的特征匹配，并估计基础矩阵。使用外极线作为第二个输入，通过在外极线上对每个特征点寻找最佳的匹配来找到更多的匹配。

制作一个包含三幅或更多图像的数据集。挑选一对图像，计算三维点和照相机矩阵。匹配特征到剩下的图像中以获得对应。然后利用这些对应的三维点使用后方交汇法，计算其他图像的照相机矩阵。绘制这些三维点和照相机的位置。你可以使用自己的数据集，也可以使用牛津多视图数据集的数据集。

以 NCC 例子中相同的方式使用滤波器，来实现使用 SSD（squared differences，平方差）而不是 NCC 的立体视差图算法版本。

尝试使用 1.5 节中的 ROF 去噪来对立体深度图进行去噪。对产生锋利边缘噪声的互相关图像块大小进行实验，使用平滑去除产生的噪声。

一个提高视差图效果的方法是，比较从左图到右图计算出的视差图和从右图到左图计算出的视差图，然后仅保留一致的部分。该操作会清除视差图中闭塞的部分。实现该想法，然后将结果和一个方向的扫平面的结果比较。

纽约公共图书馆有很多历史悠久的立体照片。打开 http://stereo.nypl.org/gallery 并下载你喜欢的照片（点击右键，保存为 JPEG 格式的文件）。这些图像应该进行了矫正。将照片切成两部分，在该图像上运行致密深度重建代码，然后查看输出结果。

第 6 章　图像聚类

本章将介绍几种聚类方法，并展示如何利用它们对图像进行聚类，从而寻找相似的图像组。聚类可以用于识别、划分图像数据集，组织与导航。此外，我们还会对聚类后的图像进行相似性可视化。

6.1　K-means聚类

K-means 是一种将输入数据划分成 k 个簇的简单的聚类算法。K-means 反复提炼初始评估的类中心，步骤如下：

以随机或猜测的方式初始化类中心 ui，i=1…k；

将每个数据点归并到离它距离最近的类中心所属的类 ci；

对所有属于该类的数据点求平均，将平均值作为新的类中心；

重复步骤 2 和步骤 3 直到收敛。

K-means 试图使类内总方差最小：

xj 是输入数据，并且是矢量。该算法是启发式提炼算法，在很多情形下都适用，但是并不能保证得到最优的结果。为了避免初始化类中心时没选取好类中心初值所造成的影响，该算法通常会初始化不同的类中心进行多次运算，然后选择方差 V 最小的结果。

K-means 算法最大的缺陷是必须预先设定聚类数 k，如果选择不恰当则会导致聚类出来的结果很差。其优点是容易实现，可以并行计算，并且对于很多别的问题不需要任何调整就能够直接使用。

6.1.1　SciPy聚类包

尽管 K-means 算法很容易实现，但我们没有必要自己实现它。SciPy 矢量量化包 scipy.cluster.vq 中有 K-means 的实现，下面是使用方法。

为便于说明，我们先生成简单的二维数据：

from scipy.cluster.vq import *

class1 = 1.5 * randn(100,2)
class2 = randn(100,2) + array([5,5])
features = vstack((class1,class2))

上面的代码生成两类二维正态分布数据。用 k=2 对这些数据进行聚类：

centroids,variance = kmeans(features,2)

由于 SciPy 中实现的 K-means 会计算若干次（默认为 20 次），并为我们选择方差最小的结果，所以这里返回的方差并不是我们真正需要的。现在，你可以用 SciPy 包中的矢量量化函数对每个数据点进行归类：

code,distance = vq(features,centroids)

通过上面得到的 code，我们可以检查是否有归类错误。为了将其可视化，我们可以画出这些数据点及最终的聚类中心：

figure()
ndx = where(code==0)[0]
plot(features[ndx,0],features[ndx,1],'*')
ndx = where(code==1)[0]
plot(features[ndx,0],features[ndx,1],'r.')
plot(centroids[:,0],centroids[:,1],'go')
axis('off')
show()

函数 where() 给出每个类的索引，绘制出的结果如图 6-1 所示。

图 6-1：一个对二维数据用 K-means 进行聚类的示例。类中心标记为绿色大圆环，预测出的类分别标记为蓝色星号和红色点。

6.1.2　图像聚类

让我们在 1.3.6 节的字体图像上，我们用 K-means 对这些字体图像进行聚类。文件 selectedfontimages.zip 包含 66 幅来自该字体数据集 fontinages 的图像（为了便于说明这些聚类簇，我们选择这些图像做简单概述）。我们利用之前计算过的前 40 个主成分进行投影，用投影系数作为每幅图像的向量描述符。用 pickle 模块载入模型文件，在主成分上对图像进行投影，然后用下面的方法聚类：

import imtools
import pickle
from scipy.cluster.vq import *

# 获取selected-fontimages 文件下图像文件名，并保存在列表中
imlist = imtools.get_imlist('selected_fontimages/')
imnbr = len(imlist)

# 载入模型文件
with open('a_pca_modes.pkl','rb') as f:
  immean = pickle.load(f)
  V = pickle.load(f)

# 创建矩阵，存储所有拉成一组形式后的图像
immatrix = array([array(Image.open(im)).flatten()
                        for im in imlist],'f')

# 投影到前40 个主成分上
immean = immean.flatten()
projected = array([dot(V[:40],immatrix[i]-immean) for i in range(imnbr)])

# 进行k-means 聚类
projected = whiten(projected)
centroids,distortion = kmeans(projected,4)

code,distance = vq(projected,centroids)

与之前一样，上述代码 code 变量中包含的是每幅图像属于哪个簇。这里，我们设定聚类数 k=4，同时用 SciPy 的 whiten() 函数对数据“白化”处理，并进行归一化，使每个特征具有单位方差。你可以试着改变其中的参数，比如主成分数目和 k，观察聚类结果有何改变。利用下面的代码可以可视化聚类后的结果：

# 绘制聚类簇
for k in range(4):
    ind = where(code==k)[0]
    figure()
    gray()
    for i in range(minimum(len(ind),40)):
        subplot(4,10,i+1)
        imshow(immatrix[ind[i]].reshape((25,25)))
        axis('off')
show()

这里我们将每个簇显示在一个独立图形窗口中，且在该图形窗口中最多可以显示 40 幅图像。我们用 PyLab 的 subplot() 函数来设定网格数，图 6-2 是上面对字体图像聚成 4 类后的可视化结果。

关于 SciPy 中 K-means 实现方法以及 scipy.cluster.vq 模块，详见参考指南：http://docs.scipy.org/doc/scipy/reference/cluster.vq.html.

6.1.3　在主成分上可视化图像

为了便于观察上面是如何利用主成分进行聚类的，我们可以在一对主成分方向的坐标上可视化这些图像。一种方法是将图像投影到两个主成分上，改变投影为：

projected = array([dot(V[[0,2]],immatrix[i]-immean) for i in range(imnbr)])

以得到相应的坐标（在这里 V[[0,2]] 分别是第一个和第三个主成分）。当然，你也可以将其投影到所有成分上，之后挑选出你需要的列。

图 6-2：用 40 个主成分数对字体图像进行 K-means 聚类（k=4）

我们用 PIL 中的 ImageDraw 模块进行可视化。假设你有如上所示投影后的图像，及保存有图像文件名的列表，利用下面简短的脚本可以生成如图 6-3 所示的结果：

from PIL import Image, ImageDraw

# 高和宽
h,w = 1200,1200

# 创建一幅白色背景图
img = Image.new('RGB',(w,h),(255,255,255))
draw = ImageDraw.Draw(img)

# 绘制坐标轴
draw.line((0,h/2,w,h/2),fill=(255,0,0))
draw.line((w/2,0,w/2,h),fill=(255,0,0))

# 缩放以适应坐标系
scale = abs(projected).max(0)
scaled = floor(array([ (p / scale) * (w/2-20,h/2-20) + (w/2,h/2) for p in projected]))

# 粘贴每幅图像的缩略图到白色背景图片
for i in range(imnbr):
    nodeim = Image.open(imlist[i])
    nodeim.thumbnail((25,25))
    ns = nodeim.size
    img.paste(nodeim,(scaled[i][0]-ns[0]//2,scaled[i][1]-
        ns[1]//2,scaled[i][0]+ns[0]//2+1,scaled[i][1]+ns[1]//2+1))

img.save('pca_font.jpg')

图 6-3：在成对主成分上投影的字体图像。左图用的是第一个和第二个主成分，右图用的是第二个和第三个主成分

这里，我们用到了整数或 floor 向下取整除法运算符 //，通过移去小数点后面的部分，可以返回各个缩略图在白色背景中对应的整数坐标位置。

这类图像说明这些字体图像在 40 维里的分布情况，对于选择一个好的描述子很有帮助。可以很清楚地看到，二维投影后相似的字体图像距离较近。

6.1.4　像素聚类

在结束本节之前，我们来看一个对单幅图像中的像素而非全部图像进行聚类的例子。将图像区域或像素合并成有意义的部分称为图像分割，它是第 9 章的主题。除了在一些简单的图像上，单纯在像素水平上应用 K-means 得出的结果往往是毫无意义的。要产生有意义的结果，往往需要更复杂的类模型而非平均像素色彩或空间一致性。现在，我们仅会在 RGB 三通道的像素值上运用 K-means 进行聚类，分割问题的处理方法会在之后谈到（9.2 节）给予关注及给出细节部分。

下面的代码示例载入一幅图像，用一个步长为 steps 的方形网格在图像中滑动，每滑一次对网格中图像区域像素求平均值，将其作为新生成的低分辨率图像对应位置处的像素值，并用 K-means 进行聚类：

from scipy.cluster.vq import *
from scipy.misc import imresize

steps = 50 # 图像被划分成steps×steps 的区域
im = array(Image.open('empire.jpg'))
dx = im.shape[0] / steps
dy = im.shape[1] / steps

# 计算每个区域的颜色特征
features = []
for x in range(steps):
  for y in range(steps):
     R = mean(im[x*dx:(x+1)*dx,y*dy:(y+1)*dy,0])
     G = mean(im[x*dx:(x+1)*dx,y*dy:(y+1)*dy,1])
     B = mean(im[x*dx:(x+1)*dx,y*dy:(y+1)*dy,2])
     features.append([R,G,B])
features = array(features,'f') # 变为数组

# 聚类
centroids,variance = kmeans(features,3)
code,distance = vq(features,centroids)

# 用聚类标记创建图像
codeim = code.reshape(steps,steps)
codeim = imresize(codeim,im.shape[:2],interp='nearest')

figure()
imshow(codeim)
show()

K-means 的输入是一个有 steps×steps 行的数组，数组的每一行有 3 列，各列分别为区域块 R、G、B 三个通道的像素平均值。为可视化最后的结果，我们用 SciPy 的 imresize() 函数在原图像坐标中显示这幅 steps×steps 的图像。参数 interp 指定插值方法；我们在这里采用最近邻插值法，以便在类间进行变换时不需要引入新的像素值。

图 6-4 显示了用 50×50 和 100×100 窗口对两幅相对比较简单的示例图像进行像素聚类后的结果。注意，K-means 标签的次序是任意的（在这里的标签指最终结果中图像的颜色）。正如你所看到的，尽管利用窗口对它进行了下采样，但结果仍然是有噪声的。如果图像某些区域没有空间一致性，则很难将它们分开，如下方图中小男孩和草坪的图。空间一致性和更好的分割方法以及其他的图像分割算法会在后面讨论。现在，让我们继续来看下一个基本的聚类算法。

图 6-4：基于颜色像素值用 K-means 对像素进行聚类的结果。左边是原始图像；中间是用 k=3 和 50×50 大小的窗口进行聚类的结果；右边是用 k=3 和 100×100 大小的窗口进行聚类的结果

6.2　层次聚类

层次聚类（或凝聚式聚类）是另一种简单但有效的聚类算法，其思想是基于样本间成对距离建立一个简相似性树。该算法首先将特征向量距离最近的两个样本归并为一组，并在树中创建一个“平均”节点，将这两个距离最近的样本作为该“平均”节点下的子节点；然后在剩下的包含任意平均节点的样本中寻找下一个最近的对，重复进行前面的操作。在每一个节点处保存了两个子节点之间的距离。遍历整个树，通过设定的阈值，遍历过程可以在比阈值大的节点位置终止，从而提取出聚类簇。

层次聚类有若干优点。例如，利用树结构可以可视化数据间的关系，并显示这些簇是如何关联的。在树中，一个好的特征向量可以给出一个很好的分离结果。另外一个优点是，对于给定的不同的阈值，可以直接利用原来的树，而不需要重新计算。

不足之处是，对于实际需要的聚类簇，我们需要选择一个合适的阈值。

让我们看看层次聚类算法怎样在代码中体现 1。创建文件 hcluster.py，将下面代码添加进去（该代码受参考文献 [31] 中层次聚类例子的启发）：

1在 Scipy 聚类包中，有一个层次聚类的版本，如果你喜欢可以直接使用。因为我们需要创建树、并用缩略图可视化树状图的类，所以这里我们不使用该版本。

from itertools import combinations

class ClusterNode(object):
  def __init__(self,vec,left,right,distance=0.0,count=1):
    self.left = left
    self.right = right
    self.vec = vec
    self.distance = distance
    self.count = count # 只用于加权平均

  def extract_clusters(self,dist):
    """ 从层次聚类树中提取距离小于dist 的子树簇群列表"""
    if self.distance < dist:
      return [self]
    return self.left.extract_clusters(dist) + self.right.extract_clusters(dist)

  def get_cluster_elements(self):
    """ 在聚类子树中返回元素的id """
    return self.left.get_cluster_elements() + self.right.get_cluster_elements()

  def get_height(self):
    """ 返回节点的高度，高度是各分支的和"""
    return self.left.get_height() + self.right.get_height()

  def get_depth(self):
    """ 返回节点的深度，深度是每个子节点取最大再加上它的自身距离"""
    return max(self.left.get_depth(), self.right.get_depth()) + self.distance

class ClusterLeafNode(object):
  def __init__(self,vec,id):
    self.vec = vec
    self.id = id

  def extract_clusters(self,dist):
    return [self]

  def get_cluster_elements(self):
    return [self.id]

  def get_height(self):
    return 1

  def get_depth(self):
    return 0

  def L2dist(v1,v2):
    return sqrt(sum((v1-v2)**2))

  def L1dist(v1,v2):
    return sum(abs(v1-v2))

  def hcluster(features,distfcn=L2dist):
    """ 用层次聚类对行特征进行聚类"""

    # 用于保存计算出的距离
    distances = {}

    # 每行初始化为一个簇
    node = [ClusterLeafNode(array(f),id=i) for i,f in enumerate(features)]

    while len(node)>1:
      closest = float('Inf')

      # 遍历每对，寻找最小距离
      for ni,nj in combinations(node,2):
        if (ni,nj) not in distances:
          distances[ni,nj] = distfcn(ni.vec,nj.vec)

        d = distances[ni,nj]
        if d<closest:
          closest = d
          lowestpair = (ni,nj)
      ni,nj = lowestpair

      # 对两个簇求平均
      new_vec = (ni.vec + nj.vec) / 2.0

      # 创建新的节点
      new_node = ClusterNode(new_vec,left=ni,right=nj,distance=closest)
      node.remove(ni)
      node.remove(nj)
      node.append(new_node)

  return node[0]

我们为树节点创建了两个类，即 ClusterNode 和 ClusterLeafNode，这两个类将用于创建聚类树，其中函数 hcluster() 用于创建树。首先创建一个包含叶节点的列表，然后根据选择的距离度量方式将距离最近的对归并到一起，返回的终节点即为树的根。对于一个行为特征向量的矩阵，运行 hcluster() 会创建和返回聚类树。

距离度量的选择依赖于实际的特征向量，这里我们利用欧式距离 L₂（同时提供了 L₁ 距离度量函数），不过你可以创建任意距离度量函数，并将它作为参数传递给 hcluster()。对于每个子树，计算其所有节点特征向量的平均值，作为新的特征向量来表示该子树，并将每个子树视为一个对象。当然，还有其他将哪两个节点合并在一起的方案，比如在两个子树中使用对象间距离最小的单向锁，及在两个子树中用对象间距离最大的完全锁。选择不同的锁会生成不同类型的聚类树。

为了从树中提取聚类簇，需要从顶部遍历树直至一个距离小于设定阈值的节点终止，这通过递归很容易做到。ClusterNode 的 extract_clusters() 方法用于处理该过程，如果节点间距离小于阈值，则用一个列表返回节点，否则调用子节点（叶节点通常返回它们自身）。调用该函数会返回一个包含聚类簇的子树列表。对于每一个子聚类簇，为了得到包含对象 id 的叶节点，需要遍历每个子树，并用方法 get_cluster_elements() 返回一个包含叶节点的列表。

下面，我们在一个简单的例子中观察该聚类过程。首先创建一些二维数据点（和之前 K-means 一样）：

class1 = 1.5 * randn(100,2)
class2 = randn(100,2) + array([5,5])
features = vstack((class1,class2))

对这些数据点进行聚类，设定阈值（这里的阈值设定为 5），从列表中提取这些聚类簇，并在控制台打印出来：

import hcluster

tree = hcluster.hcluster(features)

clusters = tree.extract_clusters(5)

print 'number of clusters', len(clusters)
for c in clusters:
  print c.get_cluster_elements()

打印结果应该与下面类似：

number of clusters 2

[184, 187, 196, 137, 174, 102, 147, 145, 185, 109, 166, 152, 173, 180, 128, 163, 141,
178, 151, 158, 108,182, 112, 199, 100, 119, 132, 195, 105, 159, 140, 171, 191, 164, 130,
149, 150, 157, 176, 135, 123, 131,118, 170, 143, 125, 127, 139, 179, 126, 160, 162, 114,
122, 103, 146, 115, 120, 142, 111, 154, 116, 129,136, 144, 167, 106, 107, 198, 186, 153,
156, 134, 101, 110, 133, 189, 168, 183, 148, 165, 172, 188, 138,192, 104, 124, 113, 194,
190, 161, 175, 121, 197, 177, 193, 169, 117, 155]

[56, 4, 47, 18, 51, 95, 29, 91, 23, 80, 83, 3, 54, 68, 69, 5, 21, 1, 44, 57, 17, 90, 30,
22, 63, 41, 7, 14, 59, 96, 20, 26, 71, 88, 86, 40, 27, 38, 50, 55, 67, 8, 28, 79, 64,
66, 94, 33, 53, 70, 31, 81, 9, 75, 15, 32, 89, 6, 11, 48, 58, 2, 39, 61, 45, 65, 82, 93,
97, 52, 62, 16, 43, 84, 24, 19, 74, 36, 37, 60, 87, 92, 181, 99, 10, 49, 12, 76, 98, 46,
72, 34, 35, 13, 73, 78, 25, 42, 77, 85]

理想情况下，你应该得到两一个聚类簇，但是在实际数据中，你可能会得到三类或更多这主要依赖于实际生成的二维数据。在这个对二维数据聚类的简单例子中，一个类中的值应该小于 100，另外一个应该大于等于 100。

图像聚类

我们来看一个基于图像颜色信息对图像进行聚类的例子。文件 sunsets.zip 中包含 100 张图像，这些图像是用“sunset”和“sunsets”关键字在 Flickr 下载下来的。在这个例子中，我们用颜色直方图作为每幅图像的特征向量。虽然这样处理有些简单粗糙，但仍然能够很好地说明分层聚类的过程。在包含这些日落图像的文件夹中运行下面的代码：

import os
import hcluster

# 创建图像列表
path = 'flickr-sunsets/'
imlist = [os.path.join(path,f) for f in os.listdir(path) if f.endswith('.jpg')]

# 提取特征向量，每个颜色通道量化成8 个小区间
features = zeros([len(imlist), 512])
for i,f in enumerate(imlist):
  im = array(Image.open(f))

  # 多维直方图
  h,edges = histogramdd(im.reshape(-1,3),8,normed=True,
                        range=[(0,255),(0,255),(0,255)])
  features[i] = h.flatten()

tree = hcluster.hcluster(features)

我们将 R、G、B 三个颜色通道作为特征向量，将其传递到 NumPy 的 histogramdd() 中，该函数能够计算多维直方图（本例中是三维）。我们在每个颜色通道中使用 8 个小区间进行量化，将三个通道量化后的小区间拉成一行后便可用 512（8×8×8）维的特征向量描述每幅图像。为避免图像尺寸不一致，我们用“normed=True”归一化直方图，并将每个颜色通道范围设置为 0...255。将 reshape() 第一个参数设置为 -1 会自动确定正确的尺寸，故可以创建一个输入数组来计算以 RGB 颜色值为行向量的直方图。

为了可视化聚类树，我们可以画出树状图。树状图是一种显示树布局的图表。在判 定给出的描述子向量好坏，以及在特征场合考虑什么是相似的时候，树状图可以提供有用的信息。将下面的代码添加到 hcluster.py 中：

from PIL import Image,ImageDraw

def draw_dendrogram(node,imlist,filename='clusters.jpg'):
  """ 绘制聚类树状图，并保存到文件中"""

  # 高和宽
  rows = node.get_height()*20
  cols = 1200

  # 距离缩放因子，以便适应图像宽度
  s = float(cols-150)/node.get_depth()

  # 创建图像，并绘制对象
  im = Image.new('RGB',(cols,rows),(255,255,255))
  draw = ImageDraw.Draw(im)

  # 初始化树开始的线条
  draw.line((0,rows/2,20,rows/2),fill=(0,0,0))

  # 递归地画出节点
  node.draw(draw,20,(rows/2),s,imlist,im)
  im.save(filename)
  im.show()

这里，画树状图时对于每个节点用了 draw() 方法，将该方法添加到 ClusterNode 类中：

def draw(self,draw,x,y,s,imlist,im):
  """ 用图像缩略图递归地画出叶节点"""

  h1 = int(self.left.get_height()*20 / 2)
  h2 = int(self.right.get_height()*20 /2)
  top = y-(h1+h2)
  bottom = y+(h1+h2)

  # 子节点垂直线
  draw.line((x,top+h1,x,bottom-h2),fill=(0,0,0))

  # 水平线
  ll = self.distance*s
  draw.line((x,top+h1,x+ll,top+h1),fill=(0,0,0))
  draw.line((x,bottom-h2,x+ll,bottom-h2),fill=(0,0,0))

  # 递归地画左边和右边的子节点
  self.left.draw(draw,x+ll,top+h1,s,imlist,im)
  self.right.draw(draw,x+ll,bottom-h2,s,imlist,im)

在画实际图像缩略图时，叶节点有自己的方法，将该方法添加到 ClusterLeafNode 类中：

def draw(self,draw,x,y,s,imlist,im):
  nodeim = Image.open(imlist[self.id])
  nodeim.thumbnail([20,20])
  ns = nodeim.size
  im.paste(nodeim,[int(x),int(y-ns[1]//2),int(x+ns[0]),int(y+ns[1]-ns[1]//2)])

树状图的高和子部分由距离决定，这些都需要调整，以适应所选择的图像分辨率。随着坐标向下传递到下一级，会递归绘制出这些节点，上述代码用 20×20 像素绘制叶节点的缩略图，使用 get_height() 和 get_depth() 这两个辅助函数可以获得树的高和宽。

树状图可以通过下面的代码绘制，并保存在 sunset.pdf 中：

hcluster.draw_dendrogram(tree,imlist,filename='sunset.pdf')

图 6-5 展示了日落图像聚类后的树状图。可以看到，树中颜色相似的图像距离较近。

图 6-6 中为三个示例簇。可以通过下面的代码提取该例子中的簇：

# 设置一些（任意的）阈值以可视化聚类簇
clusters = tree.extract_clusters(0.23*tree.distance)

# 绘制聚类簇中元素超过3 个的那些图像
for c in clusters:
  elements = c.get_cluster_elements()
  nbr_elements = len(elements)
  if nbr_elements>3:
    figure()
    for p in range(minimum(nbr_elements,20)):
      subplot(4,5,p+1)
      im = array(Image.open(imlist[elements[p]]))
      imshow(im)
      axis('off')
show()

图 6-5：用 100 幅日落图像进行层次聚类，将 RGB 空间的 512 个小区间直方图作为每幅图像的特征向量。树中挨的相近的图像具有相似的颜色分布

图 6-6：用 100 幅日落图像进行层次聚类的示例聚类簇，阈值集合设定为树中最大节点距离的 23%

作为最后一个例子，我们对前面的字体图像创建一个树状图：

tree = hcluster.hcluster(projected)
hcluster.draw_dendrogram(tree,imlist,filename='fonts.jpg')

其中，projected 和 imlist 是 6.1 节 K-means 例子中的变量。图 6-7 显示了对字体图像进行层次聚类后的树状图。

6.3　谱聚类

谱聚类方法是一种有趣的聚类算法，与前面 K-means 和层次聚类方法截然不同。

对于 n 个元素（如 n 幅图像），相似矩阵（或亲和矩阵，有时也称距离矩阵）是一个 n×n 的矩阵，矩阵每个元素表示两两之间的相似性分数。谱聚类是由相似性矩阵构建谱矩阵而得名的。对该谱矩阵进行特征分解得到的特征向量可以用于降维，然后聚类。

谱聚类的优点之一是仅需输入相似性矩阵，并且可以采用你所想到的任意度量方式构建该相似性矩阵。像 K-means 和层次聚类需要计算那些特征向量求平均；为了计算平均值，会将特征或描述子限制为向量。而对于谱方法，特征向量就没有类别限制，只要有一个“距离”或“相似性”的概念即可。

图 6-7：用 66 幅选定字体图像，使用 40 个主成分作为特征量，用层次聚类方法进行聚类

下面说明谱聚类的过程。给定一个 n×n 的相似矩阵 S，sij 为相似性分数，我们可以创建一个矩阵，称为拉普拉斯矩阵 2：

2拉普拉斯矩阵有时可以用 L=D1/2SD-1/2 代替，但并无太大差别，因为它只改变特征值，而不改变特征向量。

L = I-D-1/2SD-1/2

其中，I 是单位矩阵，D 是对角矩阵，对角线上的元素是 S 对应行元素之和，D=diag(di)， d_i=\sum_jS_{ij}。拉普拉斯矩阵中的 D-1/2 为：

为了使表述更简洁，对于相似性矩阵中的元素 sij，我们使用较小的值并且要求 sij ≥ 0（在这种情况下，距离矩阵可能更合适）。

计算 L 的特征向量，并使用 k 个最大特征值对应的 k 个特征向量，构建出一个特征向量集（记住，我们可能并没有以任何东西来开始），从而可以找到聚类簇。创建一个矩阵，该矩阵的各列是由之前求出的 k 个特征向量构成，每一行可以看做一个新的特征向量，长度为 k。这些新的特征向量可以用诸如 K-means 方法进行聚类，生成最终的聚类簇。本质上，谱聚类算法是将原始空间中的数据转换成更容易聚类的新特征向量。在某些情况下，不会首先使用聚类算法。

讲解了足够的理论，我们来看看真实的例子中谱聚类算法的代码。我们再次使用 1.3.6 节 K-means 例子中的字体图像。

from scipy.cluster.vq import *

n = len(projected)

# 计算距离矩阵
S = array([[ sqrt(sum((projected[i]-projected[j])**2))
          for i in range(n) ] for j in range(n)], 'f')

# 创建拉普拉斯矩阵
rowsum = sum(S,axis=0)
D = diag(1 / sqrt(rowsum))
I = identity(n)
L = I - dot(D,dot(S,D))

# 计算矩阵L 的特征向量
U,sigma,V = linalg.svd(L)

k = 5
# 从矩阵L 的前k 个特征向量（eigenvector）中创建特征向量（feature vector）
# 叠加特征向量作为数组的列
features = array(V[:k]).T

# k-means 聚类
features = whiten(features)
centroids,distortion = kmeans(features,k)
code,distance = vq(features,centroids)

# 绘制聚类簇
for c in range(k):
  ind = where(code==c)[0]
  figure()
  for i in range(minimum(len(ind),39)):
      im = Image.open(path+imlist[ind[i]])
      subplot(4,10,i+1)
      imshow(array(im))
      axis('equal')
      axis('off')
show()

在本例中，我们用两两间的欧式距离创建矩阵 S，并对 k 个特征向量（eignvector） 用常规的 K-means 进行聚类（在该例中，k=5）。注意，矩阵 V 包含的是对特征值 进行排序后的特征向量。最后，绘制出这些聚类簇。图 6-8 显示了运行后的聚类簇；需要记住的是，在 K-means 阶段，每次运行的结果可能不同。

图 6-8：用拉普拉斯矩阵的特征向量对字体图像进行谱聚类

我们也可以在没有任何特征向量或没有严格定义相似性的例子中尝试该算法。2.3 节中带有地理标签的 Panoramio 图像是基于它们之间有多少匹配的局部描述符连接起来的。2.3.2 节的矩阵是一个用分数表示的相似性矩阵，其中分数等于匹配的特征数（没有归一化）。由于 imlist 列表包含了图像文件名，并已用 NumPy 的 savetxt() 将相似性矩阵保存到了文件中，所以我们只需修改上面代码的前面几行：

n = len(imlist)

# 载入相似矩阵并重新格式化
S = loadtxt('panoramio_matches.txt')
S = 1 / (S + 1e-6)

这里对分数进行转换，使得相似图像的分数值较小，这样我们就不需要修改上面的代码。我们添加了一个很小的数以防止与 0 相除，后面的代码不需要修改。

在该例中选择 k 有些技巧。很多人会认为这里只有两类（即白宫的两侧），以及其他一些垃圾图像。用 k=2 可以得到类似图 6-9 的结果，其中一个聚类簇是包含很多白宫一侧的图像，另一个聚类簇是白宫另一侧的图像和其他所有垃圾图像。将 k 设定 为一个较大的值，比如 k=10，则有些聚类簇可能只包含一幅图像（很可能是垃圾图像），另一些是真实的聚类簇。图 6-10 给出了上面示例代码运行的结果。在该例中，仅有两个真实的聚类簇，每个聚类簇包含白宫一个侧面的图像。

图 6-9：用 k=2、局部特征匹配数作为相似性分数对白宫地理图像进行谱聚类的结果

图 6-10：用 k=10，局部特征匹配数作为相似性分数对白宫地理图像进行谱聚类的结果。这里只展示了图像数大于 1 的聚类簇

这里展示的谱聚类算法有很多不同的版本供选择，它们对如何构造矩阵 L 和如何处理特征向量有各自不同的思想。关于谱聚类及一些常用算法的细节，参见综述文章 [37]。

练习

层次 K-means 是一种聚类方法，该方法递归地应用 K-means 进行聚类，创建一棵逐步提炼聚类簇的树。在此情形下，树的每一个节点都有 k 个子节点。实现层次 K-means 算法并应用到前面的字体图像上。

类似层次聚类生成树状图，使用上面练习中的层次 K-means 算法，将树可视化，显示每个聚类簇节点的平均图像。提示：你可以获取平均 PCA 系数特征向量，并用 PCA 的基对每个特征向量进行图像合成。

通过修改层次聚类所使用的类以包含节点下的图像数，你可以获得一种简单快速的方法来寻找给定大小的相似组。实现这个小的改动，并用于处理真实的数据，看看其表现如何。

用单向锁和完全锁来构建层次聚类树进行实验，这些聚类后的簇有什么不同？

在一些谱聚类算法中，用到的是矩阵是 D⁻¹S 而不是 L。用该矩阵代替拉普拉斯矩阵，并将其应用于一些不同的数据集。

从 Flickr 下载一些用不同关键字搜索出的图像，像之前的日落图像一样提取 RGB 直方图，用本章介绍的某个聚类方法对这些图像进行聚类。你能用这些聚类簇分开这些图像吗？

第 7 章　图像搜索

本章将展示如何利用文本挖掘技术对基于图像视觉内容进行图像搜索。本章阐明了提出利用视觉单词的基本思想，并解释了完整的安装细节，还在一个示例数据集上进行了测试。

7.1　基于内容的图像检索

在大型图像数据库上，CBIR（Content-Based Image Retrieval，基于内容的图像检索）技术用于检索在视觉上具相似性的图像。这样返回的图像可以是颜色相似、纹理相似、图像中的物体或场景相似；总之，基本上可以是这些图像自身共有的任何信息。

对于高层查询，比如寻找相似的物体，将查询图像与数据库中所有的图像进行完全比较（比如用特征匹配）往往是不可行的。在数据库很大的情况下，这样的查询方式会耗费过多时间。在过去的几年里，研究者成功地引入文本挖掘技术到 CBIR 中处理问题，使在数百万图像中搜索具有相似内容的图像成为可能。

从文本挖掘中获取灵感——矢量空间模型

矢量空间模型是一个用于表示和搜索文本文档的模型。我们将看到，它基本上可以应用于任何对象类型，包括图像。该名字来源于用矢量来表示文本文档，这些矢量是由文本词频直方图构成的 1。换句话说，矢量包含了每个单词出现的次数，而且在其他别的地方包含很多 0 元素。由于其忽略了单词出现的顺序及位置，该模型也被称为 BOW 表示模型。

1经常可以看到用“术语”替代“词”，两者在矢量空间模型中表达的意义相同。

通过单词计数来构建文档直方图向量 v，从而建立文档索引。通常，在单词计数时会忽略掉一些常用词，如“这”“和”“是”等，这些常用词称为停用词。由于每篇文档长度不同，故除以直方图总和将向量归一化成单位长度。对于直方图向量中的每个元素，一般根据每个单词的重要性来赋予相应的权重。通常，数据集（或语料库）中一个单词的重要性与它在文档中出现的次数成正比，而与它在语料库中出现的次数成反比。

最常用的权重是 tf-idf（term frequency-inverse document frequency，词频 - 逆向文档频率)，单词 w 在文档 d 中的词频是：

nw 是单词 w 在文档 d 中出现的次数。为了归一化，将 nw 除以整个文档中单词的总数。

逆向文档频率为：

|D| 是在语料库 D 中文档的数目，分母是语料库中包含单词 w 的文档数 d。将两者相乘可以得到矢量 v 中对应元素的 tf-idf 权重。关于 tf-idf，详见 http://en.wikipedia.org/wiki/Tf-idf。

上面就是我们目前需要掌握的知识。接下来让我们看看怎样将该模型应用到基于视觉内容的图像索引及搜索中。

7.2　视觉单词

为了将文本挖掘技术应用到图像中，我们首先需要建立视觉等效单词；这通常可以采用 2.2 节中介绍的 SIFT 局部描述子做到。它的思想是将描述子空间量化成一些典型实例，并将图像中的每个描述子指派到其中的某个实例中。这些典型实例可以通过分析训练图像集确定，并被视为视觉单词。所有这些视觉单词构成的集合称为视觉词汇，有时也称为视觉码本。对于给定的问题、图像类型，或在通常情况下仅需呈现视觉内容，可以创建特定的词汇。

从一个（很大的训练图像）集提取特征描述子，利用一些聚类算法可以构建出视觉单词。聚类算法中最常用的是 K-means2，这里也将采用 K-means。视觉单词并不高端，只是在给定特征描述子空间中的一组向量集，在采用 K-means 进行聚类时得到的视觉单词是聚类质心。用视觉单词直方图来表示图像，则该模型便称为 BOW 模型。

2或在更加高级的场合下使用层次 K-means。

我们首先介绍一个示例数据集，并利用它来说明 BOW 概念。文件 first1000.zip 包含了有肯塔基大学物体识别数据集（或称“ukbench”）的前 1000 幅图像。完整的数据集、公布的基准和一些配套代码参见 http://www.vis.uky.edu/~stewe/ukbench/。该 ukbench 数据集有很多子集，每个子集包含四幅图像，这四幅图像具有相同的场景或物体，而且存储的文件名是连续的，即 0 . . . 3 属于同一图像子集，4 . . . 7 属于另外同一图像子集，以此类推。图 7-1 展示了数据集中的一些图像，附录 B.4 给出了该数据集的细节以及获取方法。

图 7-1：ukbench（肯塔基大学物体识别数据集）数据集中的一些图像

创建词汇

为创建视觉单词词汇，首先需要提取特征描述子。这里，我们使用 SIFT 特征描述子。如前面一样，imlist 包含的是图像的文件名。运行下面的代码，可以得到每幅图像提取出的描述子，并将每幅图像的描述子保存在一个文件中：

nbr_images = len(imlist)
featlist = [ imlist[i][:-3]+'sift' for i in range(nbr_images)]

for i in range(nbr_images):
  sift.process_image(imlist[i],featlist[i])

创建名为 vocabulary.py 的文件，将下面代码添加进去。该代码创建一个词汇类，以及在训练图像数据集上训练出一个词汇的方法：

from scipy.cluster.vq import *
import vlfeat as sift

class Vocabulary(object):

  def __init__(self,name):
    self.name = name
    self.voc = []
    self.idf = []
    self.trainingdata = []
    self.nbr_words = 0
  def train(self,featurefiles,k=100,subsampling=10):
    """ 用含有k 个单词的K-means 列出在featurefiles 中的特征文件训练出一个词汇。对训练数据下采样可以加快训练速度"""

    nbr_images = len(featurefiles)
    # 从文件中读取特征
    descr = []
    descr.append(sift.read_features_from_file(featurefiles[0])[1])
    descriptors = descr[0] # 将所有的特征并在一起，以便后面进行K-means 聚类
    for i in arange(1,nbr_images):
      descr.append(sift.read_features_from_file(featurefiles[i])[1])
      descriptors = vstack((descriptors,descr[i]))

    # K-means: 最后一个参数决定运行次数
    self.voc,distortion = kmeans(descriptors[::subsampling,:],k,1)
    self.nbr_words = self.voc.shape[0]

    # 遍历所有的训练图像，并投影到词汇上
    imwords = zeros((nbr_images,self.nbr_words))

    for i in range( nbr_images ):
      imwords[i] = self.project(descr[i])

    nbr_occurences = sum( (imwords > 0)*1 ,axis=0)

    self.idf = log( (1.0*nbr_images) / (1.0*nbr_occurences+1) )
    self.trainingdata = featurefiles
  def project(self,descriptors):
    """ 将描述子投影到词汇上，以创建单词直方图"""

    # 图像单词直方图
    imhist = zeros((self.nbr_words))
    words,distance = vq(descriptors,self.voc)
    for w in words:
      imhist[w] += 1

    return imhist

Vocabulary 类包含了一个由单词聚类中心 VOC 与每个单词对应的逆向文档频率构成的向量，为了在某些图像集上训练词汇，train() 方法获取包含有 .sift 描后缀的述子文件列表和词汇单词数 k。在 K-means 聚类阶段可以对训练数据下采样，因为如果使用过多特征，会耗费很长时间。

现在在你计算机的某个文件夹中，保存了图像及提取出来的 sift 特征文件，下面的代码会创建一个长为 k ≈ 1000 的词汇表。这里，再次假设 imlist 是一个包含了图像文件名的列表：

import pickle
import vocabulary

nbr_images = len(imlist)
featlist = [ imlist[i][:-3]+'sift' for i in range(nbr_images) ]

voc = vocabulary.Vocabulary('ukbenchtest')
voc.train(featlist,1000,10)

# 保存词汇
with open('vocabulary.pkl', 'wb') as f:
  pickle.dump(voc,f)
print 'vocabulary is:', voc.name, voc.nbr_words

代码最后部分用 pickle 模块保存了整个词汇对象以便后面使用。

7.3　图像索引

在开始搜索之前，我们需要建立图像数据库和图像的视觉单词表示。

7.3.1　建立数据库

在索引图像前，我们需要建立一个数据库。这里，对图像进行索引就是从这些图像中提取描述子，利用词汇将描述子转换成视觉单词，并保存视觉单词及对应图像的单词直方图。从而可以利用图像对数据库进行查询，并返回相似的图像作为搜索结果。

这里，我们使用 SQLite 作为数据库。SQLite 将所有信息都保存到一个文件，是一个易于安装和使用的数据库。由于不涉及数据库和服务器的配置及其他超出本书范围的细节，它很容易上手。SQLite 对应的 Python 版本是 pysqlite，可以从 http://code.google.com/p/pysqlite/ 获取，或在 Mac 和 Linux 系统中通过软件源获取。SQLite 使用 SQL 查询语言，所以如果想用别的数据库，这个转换过程非常简单。

在开始之前，我们需要创建表、索引和索引器 Indexer 类，以便将图像数据写入数据库。首先，创建一个名为 imagesearch.py 的文件，将下面的代码添加进去：

import pickle
from pysqlite2 import dbapi2 as sqlite

class Indexer(object):

  def __init__(self,db,voc):
    """ 初始化数据库的名称及词汇对象 """

    self.con = sqlite.connect(db)
    self.voc = voc

  def __del__(self):
    self.con.close()

  def db_commit(self):
    self.con.commit()

首先，我们需要用 pickle 模块将这些数组编码成字符串以及将字符串进行解码；SQLite 可以从 pysqlite2 模块中导入（安装细节参见附录 A）。Indexer 类连接数据库，并且一旦创建（调用 __init__() 方法）后就可以保存词汇对象。__del__() 方法可以确保关闭数据库连接，db_commit() 可以将更改写入数据库文件。

我们仅需一个包含三个表单的简单数据库模式。表单 imlist 包含所有要索引的图像文件名；imwords 包含了一个那些单词的单词索引、用到了哪个词汇、以及单词出现在哪些图像中；最后，imhistograms 包含了全部每幅图像的单词直方图。根据矢量空间模型，我们需要这些以便进行图像比较。表 7-1 展示了该模式。

表7-1：一个用于存储图像及视觉单词的简单数据库模式

imlist

imwords

imhistograms

rowid

imid

imid

filename

wordid

histogram

vocname

vocname

下面 Indexer 类中的方法用于创建表单及一些有用的索引以加快搜索速度：

def create_tables(self):
  """ 创建数据库表单"""

  self.con.execute('create table imlist(filename)')
  self.con.execute('create table imwords(imid,wordid,vocname)')
  self.con.execute('create table imhistograms(imid,histogram,vocname)')
  self.con.execute('create index im_idx on imlist(filename)')
  self.con.execute('create index wordid_idx on imwords(wordid)')
  self.con.execute('create index imid_idx on imwords(imid)')
  self.con.execute('create index imidhist_idx on imhistograms(imid)')
  self.db_commit()

7.3.2　添加图像

有了数据库表单，我们便可以在索引中添加图像。为了实现该功能，我们需要在 Indexer 类中添加 add_to_index() 方法。将下面的方法添加到 imagesearch.py 中：

def add_to_index(self,imname,descr):
  """ 获取一幅带有特征描述子的图像，投影到词汇上并添加进数据库"""

  if self.is_indexed(imname): return
  print 'indexing', imname

  # 获取图像id
  imid = self.get_id(imname)

  # 获取单词
  imwords = self.voc.project(descr)
  nbr_words = imwords.shape[0]

  # 将每个单词与图像链接起来
  for i in range(nbr_words):
    word = imwords[i]
    # wordid 就是单词本身的数字
    self.con.execute("insert into imwords(imid,wordid,vocname)
        values (?,?,?)", (imid,word,self.voc.name))

  # 存储图像的单词直方图
  # 用pickle 模块将NumPy 数组编码成字符串
  self.con.execute("insert into imhistograms(imid,histogram,vocname)
        values (?,?,?)", (imid,pickle.dumps(imwords),self.voc.name))

该方法获取图像文件名与 Numpy 数组，该数组包含的是在图像找到的描述子。这些描述子投影到词汇上，并插入到 imwords（逐字）和 imhistograms 表单中。我们使用两个辅助函数：is_indxed() 用来检查图像是否已经被索引，get_id() 则对一幅图像文件名给定 id 号。将下面的代码添加进 imagesearch.py：

def is_indexed(self,imname):
  """ 如果图像名字（imname）被索引到，就返回True"""

  im = self.con.execute("select rowid from imlist where
        filename='%s'" % imname).fetchone()
  return im != None

def get_id(self,imname):
  """ 获取图像id，如果不存在，就进行添加"""

  cur = self.con.execute(
  "select rowid from imlist where filename='%s'" % imname)
  res=cur.fetchone()
  if res==None:
    cur = self.con.execute(
    "insert into imlist(filename) values ('%s')" % imname)
    return cur.lastrowid
  else:
    return res[0]

你是否注意到我们在 add_to_index() 方法中用到了 Pickle 模块？由于 SQLite 的数据库在存储对象或数组时并没有一个标准类型。所以，我们用 Pickle 的 dumps() 函数创建一个字符串表示，并将其写入数据库。因此，从数据库读取数据时，我们需要拆封该字符串，这在下一节有详细介绍。

下面的示例代码会遍历整个 ukbench 数据库中的样本图像，并将其加入我们的索引。这里，假设列表 imlist 和 featlist 分别包含之前图像文件名及图像描述子，vocabulary.pkl 包含已经训练好的词汇：

import pickle
import sift
import imagesearch

nbr_images = len(imlist)

# 载入词汇
with open('vocabulary.pkl', 'rb') as f:
  voc = pickle.load(f)

# 创建索引器
indx = imagesearch.Indexer('test.db',voc)
indx.create_tables()

# 遍历整个图像库，将特征投影到词汇上并添加到索引中
for i in range(nbr_images)[:1000]:
  locs,descr = sift.read_features_from_file(featlist[i])
  indx.add_to_index(imlist[i],descr)

# 提交到数据库
indx.db_commit()

现在我们可以检查数据库中的内容了：

from pysqlite2 import dbapi2 as sqlite
con = sqlite.connect('test.db')
print con.execute('select count (filename) from imlist').fetchone()
print con.execute('select * from imlist').fetchone()

控制台打印结果如下：

(1000,)
(u'ukbench00000.jpg',)

如果你在最后一行用 fetchall() 来代替 fetchone()，会得到一个包含所有文件名的长列表。

7.4　在数据库中搜索图像

建立好图像的索引，我们就可以在数据库中搜索相似的图像了。这里，我们用 BoW（Bag-of-Word，词袋模型）来表示整个图像，不过这里介绍的过程是通用的，可以应用于寻找相似的物体、相似的脸、相似的颜色等，它完全取决于图像及所用的描述子。

为实现搜索，我们在 imagesearch.py 中添加 Searcher 类：

class Searcher(object):

  def __init__(self,db,voc):
    """ 初始化数据库的名称 """
    self.con = sqlite.connect(db)
    self.voc = voc

  def __del__(self):
    self.con.close()

一个新的 Searcher 对象连接到数据库，一旦删除便关闭连接，这与之前的 Indexer 类中的处理过程相同。

如果图像数据库很大，逐一比较整个数据库中的所有直方图往往是不可行的。我们需要找到一个大小合理的候选集（这里的“合理”是通过搜索响应时间、所需内存等确定的），单词索引的作用便在于此：我们可以利用单词索引获得候选集，然后只需在候选集上进行逐一比较。

7.4.1　利用索引获取候选图像

我们可以利用建立起来的索引找到包含特定单词的所有图像，这不过是对数据库做一次简单的查询。在 Searcher 类中加入 candidates_from_word() 方法：

def candidates_from_word(self,imword):
  """ G 获取包含imword 的图像列表"""

  im_ids = self.con.execute(
      "select distinct imid from imwords where wordid=%d" % imword).fetchall()
  return [i[0] for i in im_ids]

上面会给出包含特定单词的所有图像 id 号。为了获得包含多个单词的候选图像，例如一个单词直方图中的全部非零元素，我们在每个单词上进行遍历，得到包含该单词的图像，并合并这些列表 3。这里，我们仍然需要在合并了的列表中对每一个图像 id 出现的次数进行跟踪，因为这可以显示有多少单词与单词直方图中的单词匹配。该过程可以通过下面的 candidates_from_histogram 方法完成：

3如果不想使用所有单词，你可以根据其倒排文档频率权重进行排序，并使用那些权重最高的单词。

def candidates_from_histogram(self,imwords):
  """ 获取具有相似单词的图像列表"""

  # 获取单词id
  words = imwords.nonzero()[0]

  # 寻找候选图像
  candidates = []
  for word in words:
    c = self.candidates_from_word(word)
    candidates+=c

  # 获取所有唯一的单词，并按出现次数反向排序
  tmp = [(w,candidates.count(w)) for w in set(candidates)]
  tmp.sort(cmp=lambda x,y:cmp(x[1],y[1]))
  tmp.reverse()

  # 返回排序后的列表，最匹配的排在最前面
  return [w[0] for w in tmp]

该方法从图像单词直方图的非零项创建单词 id 列表，检索每个单词获得候选集并将其合并到 candidates 列表中，然后创建一个元组列表每个元组由单词 id 和次数 count 构成，其中次数 count 是候选列表中每个单词出现的次数。同时，我们还以元组中的第二个元素为准，用 sort() 方法和一个自定义的比较函数对列表进行排序（考虑到后面的效率）。该自定义比较函数进行用 lambda 函数内联声明，对于单行函数声明，使用 lambda 函数非常方便。最后结果返回一个包含图像 id 的列表，排在列表最前面的是最好的匹配图像。

思考下面的例子：

src = imagesearch.Searcher('test.db', voc)
locs,descr = sift.read_features_from_file(featlist[0])
iw = voc.project(descr)

print 'ask using a histogram...'
print src.candidates_from_histogram(iw)[:10]

该例打印了从索引中查找出的前 10 个图像 id，结果如下（该结果根据你使用的词汇有所不同）：

ask using a histogram...
[655, 656, 654, 44, 9, 653, 42, 43, 41, 12]

查找出来的前 10 个候选图像都是不准确的。不用担心，我们现在可以取出该列表中任意数量的元素并比较它们的直方图。你将会看到，这可以极大地提高检索效率。

7.4.2　用一幅图像进行查询

利用一幅图像进行查询时，没有必要进行完全的搜索。为了比较单词直方图，Searcher 类需要从数据库读入图像的单词直方图。将下面的方法添加到 Searcher 类中：

def get_imhistogram(self,imname):
  """ 返回一幅图像的单词直方图"""

  im_id = self.con.execute(
      "select rowid from imlist where filename='%s'" % imname).fetchone()
  s = self.con.execute(
      "select histogram from imhistograms where rowid='%d'" % im_id).fetchone()

  # 用pickle 模块从字符串解码Numpy 数组
  return pickle.loads(str(s[0]))

这里，为了在字符串和 NumPy 数组间进行转换，我们再次用到了 pickle 模块，这次使用的是 loads()。

现在，我们可以全部合并到查询方法中：

def query(self,imname):
    """ 查找所有与imname 匹配的图像列表"""

    h = self.get_imhistogram(imname)
    candidates = self.candidates_from_histogram(h)

    matchscores = []
    for imid in candidates:
        # 获取名字
        cand_name = self.con.execute(
            "select filename from imlist where rowid=%d" % imid).fetchone()
        cand_h = self.get_imhistogram(cand_name)
        cand_dist = sqrt( sum(self.voc.idf* (h-cand_h)2 ) ) # 用L2 距离度量相似性
        matchscores.append( (cand_dist,imid) )

    # 返回排序后的距离及对应数据库ids 列表
    matchscores.sort()
    return matchscores

该 query() 方法获取图像的文件名，检索其单词直方图及候选图像列表（如果你的数据集很大，候选集的大小应该限制在某个最大值）。对于每个候选图像，我们用标准的欧式距离比较它和查询图像间的直方图，并返回一个经排序的包含距离及图像 id 的元组列表。

我们尝试对前一节的图像进行查询：

src = imagesearch.Searcher('test.db', voc)
print 'try a query...'
print src.query(imlist[0])[:10]

这会再次打印前 10 个结果，包括候选图像与查询图像间的距离，结果应该和下面类似：

try a query...
[(0.0, 1), (100.03999200319841, 2), (105.45141061171255, 3), (129.47200469599596, 708),
(129.73819792181484, 707), (132.68006632497588, 4), (139.89639023220005, 10),
(142.31654858097141, 706), (148.1924424523734, 716), (148.22955170950223, 663)]

这次结果比前一节中打印出来的 10 个结果要好很多。距离为 0 的图像对应查询图像本身；三幅与查询图像具有相同场景的图像有两幅在除查询图像本身外的前两个位置，第三幅则出现在第五个位置。

7.4.3　确定对比基准并绘制结果

为了评价搜索结果的好坏，我们可以计算前 4 个位置中搜索到相似图像数。这是在 ukbench 图像集上评价搜索性能常采用的评价方式。这里给出了计算分数的函数，将它添加到 imagesearch.py 中，你就可以开始优化查询了：

def compute_ukbench_score(src,imlist):
    """ 对查询返回的前4 个结果计算平均相似图像数，并返回结果"""

    nbr_images = len(imlist)
    pos = zeros((nbr_images,4))
    # 获取每幅查询图像的前4 个结果
    for i in range(nbr_images):
        pos[i] = [w[1]-1 for w in src.query(imlist[i])[:4]]

    # 计算分数，并返回平均分数
    score = array([ (pos[i]//4)==(i//4) for i in range(nbr_images)])*1.0
    return sum(score) / (nbr_images)

该函数获得搜索的前 4 个结果，将 query() 返回的索引减去 1，因为数据库索引是从 1 开始的，而图像列表的索引是从 0 开始的。然后，利用每 4 幅图像为一组时相似图像文件名是连续的这一事实，我们用整数相除计算得到最终的分数。分数为 4 时结果最理想；没有一个是准确的，分数为 0；仅检索到相同图像时，分数为 1；找到相同的图像并且其他三个中的两个相同时，分数为 3。

试试下面的代码：

imagesearch.compute_ukbench_score(src,imlist)

进行 1000 次查询需要耗费较长时间，如果你不想等太久，可以将查询集改为上面查询集的子集：

imagesearch.compute_ukbench_score(src,imlist[:100])

当得到的分数接近 3 时，我们可以认为结果很好。目前，ukbench 网站给出的最好结果刚刚超过 3；不过需要注意的是，他们用了更多的图像，所以在大数据集上，你在上面所得到的分数会下降。

最后，用于显示实际搜索结果的函数十分有用。添加该函数到 imagesearch.py 中：

def plot_results(src,res):
    """ 显示在列表res 中的图像"""

    figure()
    nbr_results = len(res)
    for i in range(nbr_results):
        imname = src.get_filename(res[i])
        subplot(1,nbr_results,i+1)
        imshow(array(Image.open(imname)))
        axis('off')
    show()

对于列表 res 中的任意搜索结果数，都可以调用该函数。例子如下：

nbr_results = 6
res = [w[1] for w in src.query(imlist[0])[:nbr_results]]
imagesearch.plot_results(src,res)

定义辅助函数：

def get_filename(self,imid):
    """ 返回图像id 对应的文件名"""

    s = self.con.execute(
        "select filename from imlist where rowid='%d'" % imid).fetchone()
    return s[0]

它可以将图像的 id 转换为图像文件名，以便在显示搜索结果时载入图像。图 7-2 显示了用 plot_results() 在我们的数据集上进行的一些查询实例。

7.5　使用几何特性对结果排序

让我们简要地看一种用 BoW 模型改进检索结果的常用方法。BoW 模型的一个主要缺点是在用视觉单词表示图像时不包含图像特征的位置信息，这是为获取速度和可伸缩性而付出的代价。

图 7-2：在 ukbench 数据集上用一些查询图像进行搜索给出的一些结果。查询图像在最左边，后面是检索到的前 5 幅图像

利用一些考虑到特征几何关系的准则重排搜索到的靠前结果，可以提高准确率。最常用的方法是在查询图像与靠前图像的特征位置间拟合单应性。

为了提高效率，可以将特征位置存储在数据库中，并由特征的单词 id 决定它们之间的关联（要注意的是，只有在词汇足够大，使单词 id 包含很多准确匹配时，它才起作用）。然而，这需要大幅重写我们上面的数据库和代码，并复杂化表示形式。为了进行说明，我们仅重载靠前图像的特征，并对它们进行匹配。

下面是一个载入所有模型文件并用单应性对靠前的图像进行重排的完整例子：

import pickle
import sift
import imagesearch
import homography

# 载入图像列表和词汇
with open('ukbench_imlist.pkl','rb') as f:
  imlist = pickle.load(f)
  featlist = pickle.load(f)

nbr_images = len(imlist)

with open('vocabulary.pkl', 'rb') as f:
  voc = pickle.load(f)

src = imagesearch.Searcher('test.db',voc)

# 查询图像的索引号和返回的搜索结果数目
q_ind = 50
nbr_results = 20

# 常规查询
res_reg = [w[1] for w in src.query(imlist[q_ind])[:nbr_results]]
print 'top matches (regular):', res_reg

# 载入查询图像特征
q_locs,q_descr = sift.read_features_from_file(featlist[q_ind])
fp = homography.make_homog(q_locs[:,:2].T)

# 用RANSAC 模型拟合单应性
model = homography.RansacModel()

rank = {}
# 载入搜索结果的图像特征
for ndx in res_reg[1:]:
  locs,descr = sift.read_features_from_file(featlist[ndx])

  # 获取匹配数
  matches = sift.match(q_descr,descr)
  ind = matches.nonzero()[0]
  ind2 = matches[ind]
  tp = homography.make_homog(locs[:,:2].T)

  # 计算单应性，对内点计数。如果没有足够的匹配数则返回空列表
  try:
    H,inliers = homography.H_from_ransac(fp[:,ind],tp[:,ind2],model,match_theshold=4)
  except:
    inliers = []

  # 存储内点数
  rank[ndx] = len(inliers)

# 将字典排序，以首先获取最内层的内点数
sorted_rank = sorted(rank.items(), key=lambda t: t[1], reverse=True)
res_geom = [res_reg[0]]+[s[0] for s in sorted_rank]
print 'top matches (homography):', res_geom

# 显示靠前的搜索结果
imagesearch.plot_results(src,res_reg[:8])
imagesearch.plot_results(src,res_geom[:8])

首先，载入图像列表、特征列表（分别包含图像文件名和 SIFT 特征文件）及词汇。然后，创建一个 Searcher 对象，执行定期查询，并将结果保存在 res_reg 列表中。然后载入 res_reg 列表中每一幅图像的特征，并和查询图像进行匹配。单应性通过计算匹配数和计数内点数得到。最终，我们可以通过减少内点的数目对包含图像索引和内点数的字典进行排序。打印搜索结果列表到控制台，并可视化检索靠前的图像。

输出结果如下：

top matches (regular): [39, 22, 74, 82, 50, 37, 38, 17, 29, 68, 52, 91, 15, 90, 31, ... ]
top matches (homography): [39, 38, 37, 45, 67, 68, 74, 82, 15, 17, 50, 52, 85, 22, 87, ... ]

图 7-3 给出了常规查询和对常规查询重新排序后的一些样例结果。

图 7-3：基于几何一致性用单应性对搜索结果进行重排后一些实例搜索结果。在每一个例子中，上一行是没有常规查询的结果，下一行是重排后的结果

7.6　建立演示程序及Web应用

作为本章关于图像搜索的最后一节，我们看一个用 Python 建立演示程序和 Web 应用的简单方法。通过将演示程序变成 Web 页，你便自动获得了跨平台支持，并以最低环境配置需求展示、分享你项目。我们在本节会完整给出一个创建简单图像搜索引擎的示例。

7.6.1　用CherryPy创建Web应用

为了建立这些演示程序，我们将采用 CherryPy 包，参见 http://www.cherrypy.org。CherryPy 是一个纯 Python 轻量级 Web 服务器，使用面向对象模型。CherryPy 的安装和配置细节参见附录 A。这里假设你已经学习了 CherryPy 实例教程，并对 CherryPy 的工作方式有了初步的了解，我们可以以本章创建的图像 Searcher 类为基础，来创建一个图像搜索 Web 演示程序。

7.6.2　图像搜索演示程序

首先，我们需要用一些 HTML 标签进行初始化，并用 Pickle 载入数据。另外，还需要有与数据库进行交互的 Searcher 对象词汇。创建一个名为 searchdemo.py 的文件，并添加下面具有两个方法的 Search Demo 类：

import cherrypy, os, urllib, pickle
import imagesearch

class SearchDemo(object):

  def __init__(self):
    # 载入图像列表
    with open('webimlist.txt') as f:
      self.imlist = f.readlines()

  self.nbr_images = len(self.imlist)
  self.ndx = range(self.nbr_images)

  # 载入词汇
  with open('vocabulary.pkl', 'rb') as f:
    self.voc = pickle.load(f)

  # 设置可以显示多少幅图像
  self.maxres = 15

  # html 的头部和尾部
  self.header = """
    <!doctype html>
    <head>
    <title>Image search example</title>
    </head>
    <body>
    """
  self.footer = """
    </body>
    </html>
    """

  def index(self,query=None):
    self.src = imagesearch.Searcher('web.db',self.voc)

    html = self.header
    html += """
      <br />
      Click an image to search. <a href='?query='>Random selection</a> of images.
      <br /><br />
      """
  if query:
    # 查询数据库并获取靠前的图像
    res = self.src.query(query)[:self.maxres]
    for dist,ndx in res:
      imname = self.src.get_filename(ndx)
      html += "<a href='?query="+imname+"'>"
      html += "<img src='"+imname+"' width='100' />"
      html += "</a>"
  else:
    # 如果没有查询图像，则显示随机选择的图像
    random.shuffle(self.ndx)
    for i in self.ndx[:self.maxres]:
      imname = self.imlist[i]
      html += "<a href='?query="+imname+"'>"
      html += "<img src='"+imname+"' width='100' />"
      html += "</a>"

    html += self.footer
    return html

  index.exposed = True

cherrypy.quickstart(SearchDemo(), '/',
          config=os.path.join(os.path.dirname(__file__), 'service.conf'))

你可以看到，这个简单的演示程序包含了单个类，该类包含一个初始化 __int__() 方法和一个“索引”页面 index() 方法（本例中只有一个页面）。这两个方法可以自动 地映射至 URL，并且方法中的参数可以直接传递到 URL 中。index 方法里有一个查询参数，在本例中，该参数是查询图像，用来对其他图像排序。如果该参数是空的，就会随机显示一些图像。

index.exposed = True

这一行使索引 URL 可以被访问，上面 searchsemo.py 中紧接着该行的最后一行通过读取 service.conf 配置文件开启 CherryPy Web 服务器。在这个例子中，我们的配置文件如下：

[global]
server.socket_host = "127.0.0.1"
server.socket_port = 8080
server.thread_pool = 50
tools.sessions.on = True

[/]
tools.staticdir.root = "tmp/"
tools.staticdir.on = True
tools.staticdir.dir = ""

第一部分指定使用的 IP 地址和端口，第二部分确保本地文件夹可以读取（本例中文件夹为 tmp/），注意文件夹下存放的是你的图像库。

如果你打算将它展示给别人看，不要在这个文件夹下存放任何秘密的东西，因为文件夹下所有的内容都可以通过 CherryPy 访问。

从命令行开启你的 Web 服务器：

$ python searchdemo.py

打开浏览器，在地址栏输入 http://127.0.0.1:8080/，你可以看到随机挑选出来的图像的初始页面，类似于图 7-4 中的上图所示。点击一幅图像进行查询，会显示出搜索出来的前几幅图像，在搜索出来的图像中单击某图像可以开始新的查询。此外，页面上有一个链接，点击后可以返回原来随机选择的状态（通过一个空查询）。图 7-4 是一些查询示例。

该例子完整展示了从 Web 页面到数据库查询以及结果显示整个综合过程。当然，这只是一个基本的原型，并且你可以在该基础上进行改进，例如添加样式表使它更漂亮，或使它能够上传图像进行查询。

图 7-4：在 ukbench 数据集上进行搜索的示例。上方是开始页面，显示了一些随机选择的图像；下方是一些查询示例。左上角是查询图像，之后的是搜索到的一些结果靠前的图像

练习

尝试只用查询图像中的部分单词构建候选图像列表来加速查询，用 idf 权重作为准则来挑选单词。

在你的词汇中，比如前 10%，实现一个最常用的视觉单词停用词列表，在搜索的时候忽略这些单词，看看搜索结果有何改善？

通过保存所有映射到一个给定单词 id 的所有图像特征，对视觉单词进行可视化。在给定的尺度下，在特征位置环绕处剪切图像块，并将它们画在同一图形窗口中。对于给定的单词，这些图像块看起来一样吗？

在 query() 方法中，用不同的距离度量及加权进行实验，用 compute_ukbench_score() 计算得出的分数度量你的改进是否有效。

在整个章节中，我们的词汇仅用到了 SIFT 特征，正如你在图 7-2 中看到的结果，它完全抛弃了颜色信息。试着添加颜色描述符看你是否能够改进搜索的结果。

对于大量词汇，用数组来表示视觉单词词频效率很低，原因在于数组中很多元素都是 0（比如考虑这样一种情形，有数十万单词，而图像却只有 1000 个特征）。一种克服这种低效的办法是将字典作为稀疏数组表示，用自定义一个稀疏类替代这些数组，并在自定义的稀疏类中添加一些必要的方法。或作为另一种选择，你可以采用 scipy.sparse 模块。

你如果增加词汇的大小，聚类时间也会相应增长，并且特征投影到单词的过程更加缓慢。用层次 K-means 聚类算法实现一个层次词汇，看它是怎样提高可伸缩性的，详情参阅文献 [23]。

第 8 章　图像内容分类

本章介绍图像分类和图像内容分类算法。首先，我们介绍一些简单而有效的方法和目前一些性能最好的分类器，并运用它们解决两类和多类分类问题，最后展示两个用于手势识别和目标识别的应用实例。

8.1　K邻近分类法（KNN）

在分类方法中，最简单且用得最多的一种方法之一就是 KNN（K-Nearest Neighbor ,K 邻近分类法），这种算法把要分类的对象（例如一个特征向量）与训练集中已知类标记的所有对象进行对比，并由 k 近邻对指派到哪个类进行投票。这种方法通常分类效 果较好，但是也有很多弊端：与 K-means 聚类算法一样，需要预先设定 k 值，k 值的选择会影响分类的性能；此外，这种方法要求将整个训练集存储起来，如果训练集非常大，搜索起来就非常慢。对于大训练集，采取某些装箱形式通常会减少对比的次数 1 从积极的一面来看，这种方法在采用何种距离度量方面是没有限制的；实际上，对于你所能想到的东西它都可以奏效，但这并不意味着对任何东西它的分类性能都很好。另外，这种算法的可并行性也很一般。

1另一个选择是只保留从训练集挑选出的子集，但这会影响分类准确率。

实现最基本的 KNN 形式非常简单。给定训练样本集和对应的标记列表，下面的代码可以用来完成这一工作。这些训练样本和标记可以在一个数组里成行摆放或者干脆摆放列表里，训练样本可能是数字、字符串等任何你喜欢的形状。将定义的类对象添加到名为 knn.py 的文件里：

class KnnClassifier(object):

  def __init__(self,labels,samples):
    """ 使用训练数据初始化分类器"""

    self.labels = labels
    self.samples = samples

  def classify(self,point,k=3):
    """ 在训练数据上采用k 近邻分类，并返回标记"""

    # 计算所有训练数据点的距离
    dist = array([L2dist(point,s) for s in self.samples])

    # 对它们进行排序
    ndx = dist.argsort()

    # 用字典存储k 近邻
    votes = {}
    for i in range(k):
      label = self.labels[ndx[i]]
      votes.setdefault(label,0)
      votes[label] += 1

    return max(votes)

def L2dist(p1,p2):
  return sqrt( sum( (p1-p2)**2) )

定义一个类并用训练数据初始化非常简单；每次想对某些东西进行分类时，用 KNN 方法，我们就没有必要存储并将训练数据作为参数来传递。用一个字典来存储邻近标记，我们便可以用文本字符串或数字来表示标记。在这个例子中，我们用欧式距 离 (L₂) 进行度量，当然，如果你有其他的度量方式，只需要将其作为函数添加到上面代码的最后。

8.1.1　一个简单的二维示例

我们首先建立一些简单的二维示例数据集来说明并可视化分类器的工作原理，下面的脚本将创建两个不同的二维点集，每个点集有两类，用 Pickle 模块来保存创建的数据：

from numpy.random import randn
import pickle

# 创建二维样本数据
n = 200

# 两个正态分布数据集
class_1 = 0.6 * randn(n,2)
class_2 = 1.2 * randn(n,2) + array([5,1])
labels = hstack((ones(n),-ones(n)))

# 用Pickle 模块保存
with open('points_normal.pkl', 'w') as f:
  pickle.dump(class_1,f)
  pickle.dump(class_2,f)
  pickle.dump(labels,f)

# 正态分布，并使数据成环绕状分布
class_1 = 0.6 * randn(n,2)
r = 0.8 * randn(n,1) + 5
angle = 2*pi * randn(n,1)
class_2 = hstack((r*cos(angle),r*sin(angle)))
labels = hstack((ones(n),-ones(n)))

# 用Pickle 保存
with open('points_ring.pkl', 'w') as f:
  pickle.dump(class_1,f)
  pickle.dump(class_2,f)
  pickle.dump(labels,f)

用不同的保存文件名运行该脚本两次，例如第一次用代码中的文件名进行保存，第二次将代码中的 points_normal_t.pkl 和 points_ring_pkl 分别改为 points_normal_test.pkl 和 points_ring_test.pkl 进行保存。你将得到 4 个二维数据集文件，每个分布都有两个文件，我们可以将一个用来训练，另一个用来做测试。

让我们看看怎么用 KNN 分类器来完成，用下面的代码来创建一个脚本：

import pickle
import knn
import imtools

# 用Pickle 载入二维数据点
with open('points_normal.pkl', 'r') as f:
  class_1 = pickle.load(f)
  class_2 = pickle.load(f)
  labels = pickle.load(f)
  model=knn.knnClassifier(labels,vstack(class_1,class_2))

这里用 Pickle 模块来创建一个 kNN 分类器模型。现在在上面代码后添加下面的代码：

# 用Pickle 模块载入测试数据
with open('points_normal_test.pkl', 'r') as f:
  class_1 = pickle.load(f)
  class_2 = pickle.load(f)
  labels = pickle.load(f)

# 在测试数据集的第一个数据点上进行测试
print model.classify(class_1[0])

上面代码载入另一个数据集（测试数据集），并在你的控制台上打印第一个数据点估计出来的类标记。

为了可视化所有测试数据点的分类，并展示分类器将两个不同的类分开得怎样，我们可以添加这些代码：

# 定义绘图函数
def classify(x,y,model=model):
  return array([model.classify([xx,yy]) for (xx,yy) in zip(x,y)])

# 绘制分类边界
imtools.plot_2D_boundary([-6,6,-6,6],[class_1,class_2],classify,[1,-1])
show()

这里我们创建了一个简短的辅助函数以获取 x 和 y 二维坐标数组和分类器，并返回一个预测的类标记数组。现在我们把函数作为参数传递给实际的绘图函数。把下面的函数添加到文件 imtools 中：

def plot_2D_boundary(plot_range,points,decisionfcn,labels,values=[0]):
  """Plot_range 为（xmin，xmax，ymin，ymax），points 是类数据点列表，
    decisionfcn 是评估函数，labels 是函数decidionfcn 关于每个类返回的标记列表"""

  clist = ['b','r','g','k','m','y'] # 不同的类用不同的颜色标识

  # 在一个网格上进行评估，并画出决策函数的边界
  x = arange(plot_range[0],plot_range[1],.1)
  y = arange(plot_range[2],plot_range[3],.1)
  xx,yy = meshgrid(x,y)
  xxx,yyy = xx.flatten(),yy.flatten() # 网格中的x，y 坐标点列表
  zz = array(decisionfcn(xxx,yyy))
  zz = zz.reshape(xx.shape)
  # 以values 画出边界
  contour(xx,yy,zz,values)

  # 对于每类，用* 画出分类正确的点，用o 画出分类不正确的点
  for i in range(len(points)):
    d = decisionfcn(points[i][:,0],points[i][:,1])
    correct_ndx = labels[i]==d
    incorrect_ndx = labels[i]!=d
    plot(points[i][correct_ndx,0],points[i][correct_ndx,1],'*',color=clist[i])
    plot(points[i][incorrect_ndx,0],points[i][incorrect_ndx,1],'o',color=clist[i])

  axis('equal')

这个函数需要一个决策函数（分类器），并且用 meshgrid() 函数在一个网格上进行预测。决策函数的等值线可以显示边界的位置，默认边界为零等值线。画出来的结果如图 8-1 所示，正如你所看到的，kNN 决策边界适用于没有任何明确模型的类分布。

图 8-1：用 K 邻近分类器分类二维数据。每个示例中，不同颜色代表类标记，正确分类的点用星号表示，分类错误的点用圆点表示，曲线是分类器的决策边界

8.1.2　用稠密SIFT作为图像特征

我们来看如何对图像进行分类。要对图像进行分类，我们需要一个特征向量来表示一幅图像。在聚类一章我们用平均 RGB 像素值和 PCA 系数作为图像的特征向量；这里我们会介绍另外一种表示形式，即稠密 SIFT 特征向量。

在整幅图像上用一个规则的网格应用 SIFT 描述子可以得到稠密 SIFT 的表示形式 2，我们可以利用 2.2 节的可执行脚本，通过添加一些额外的参数来得到稠密 SIFT 特征。创建一个名为 dsift.py 的文件，并添加下面代码到该文件中：

2另一个常见的名字是方向梯度直方图（HOG）。

import sift

def process_image_dsift(imagename,resultname,size=20,steps=10,
          force_orientation=False,resize=None):
  """ 用密集采样的SIFT 描述子处理一幅图像，并将结果保存在一个文件中。可选的输入：
    特征的大小size，位置之间的步长steps，是否强迫计算描述子的方位force_orientation
    （False 表示所有的方位都是朝上的），用于调整图像大小的元组"""

  im = Image.open(imagename).convert('L')
  if resize!=None:
    im = im.resize(resize)
  m,n = im.size

  if imagename[-3:] != 'pgm':
    # 创建一个pgm 文件
    im.save('tmp.pgm')
    imagename = 'tmp.pgm'

  # 创建帧，并保存到临时文件
  scale = size/3.0
  x,y = meshgrid(range(steps,m,steps),range(steps,n,steps))
  xx,yy = x.flatten(),y.flatten()
  frame = array([xx,yy,scale*ones(xx.shape[0]),zeros(xx.shape[0])])
  savetxt('tmp.frame',frame.T,fmt='%03.3f')

  if force_orientation:
    cmmd = str("sift "+imagename+" --output="+resultname+
          " --read-frames=tmp.frame --orientations")
  else:
    cmmd = str("sift "+imagename+" --output="+resultname+
          " --read-frames=tmp.frame")
  os.system(cmmd)
  print 'processed', imagename, 'to', resultname

对比 2.2 节的 process_image() 函数，为了使用命令行处理，我们用 savetxt() 函数将帧数组存储在一个文本文件中，该函数的最后一个参数可以在提取描述子之前对图像的大小进行调整，例如，传递参数 imsize=(100, 100) 会将图像调整为 100×100 像素的方形图像。最后，如果 force_orientation 为真，则提取出来的描述子会基于局部主梯度方向进行归一化；否则，则所有的描述子的方向只是简单地朝上。

利用类似下面的代码可以计算稠密 SIFT 描述子，并可视化它们的位置：

import dsift,sift

dsift.process_image_dsift('empire.jpg','empire.sift',90,40,True)
l,d = sift.read_features_from_file('empire.sift')

im = array(Image.open('empire.jpg'))
sift.plot_features(im,l,True)
show()

使用用于定位描述子的局部梯度方向（force_orientation 设置为真），该代码可以在整个图像中计算出稠密 SIFT 特征。图 8-2 显示出了这些位置。

图 8-2：在一幅图像上应用稠密 SIFT 描述子的例子

8.1.3　图像分类：手势识别

在这个应用中，我们会用稠密 SIFT 描述子来表示这些手势图像，并建立一个简单的手势识别系统。我们用静态手势（Static Hand Posture）数据库（参见 http://www.idiap.ch/resource/gestures/）中的一些图像进行演示。在该数据库主页上下载数据较小的测试集 test set 16.3Mb，将下载后的所有图像放在一个名为 uniform 的文件夹里，每一类均分两组，并分别放入名为 train 和 test 的两个文件夹中。

用上面的稠密 SIFT 函数对图像进行处理，可以得到所有图像的特征向量。这里，再次假设列表 imlist 中包含了所有图像的文件名，可以通过下面的代码得到每幅图像的稠密 SIFT 特征：

import dsift

# 将图像尺寸调为(50,50)，然后进行处理
for filename in imlist:
  featfile = filename[:-3]+'dsift'
  dsift.process_image_dsift(filename,featfile,10,5,resize=(50,50))

上面代码会对每一幅图像创建一个特征文件，文件名后缀为 .dsift。注意，这里将图像分辨率调成了常见的固定大小。这是非常重要的，否则这些图像会有不同数量的描述子，从而每幅图像的特征向量长度也不一样，这将导致在后面比较它们时出错。图 8-3 绘制出了一些带有描述子的图像。

图 8-3：6 类简单手势图像的稠密 SIFT 描述子，图像来源于静态手势（Static Hand Posture）数据库

定义一个辅助函数，用于从文件中读取稠密 SIFT 描述子，如下：

import os, sift

def read_gesture_features_labels(path):
  # 对所有以.dsift 为后缀的文件创建一个列表
  featlist = [os.path.join(path,f) for f in os.listdir(path) if f.endswith('.dsift')]
  # 读取特征
  features = []
  for featfile in featlist:
    l,d = sift.read_features_from_file(featfile)
    features.append(d.flatten())
  features = array(features)

# 创建标记
labels = [featfile.split('/')[-1][0] for featfile in featlist]

return features,array(labels)

然后，我们可以用下面的脚本读取训练集、测试集的特征和标记信息：

features,labels = read_gesture_features_labels('train/')

test_features,test_labels = read_gesture_features_labels('test/')

classnames = unique(labels)

这里，我们用文件名的第一个字母作为类标记，用 NumPy 的 unique() 函数可以得到一个排序后唯一的类名称列表。

现在我们可以在该数据上使用前面的 K 近邻代码：

# 测试KNN
k = 1
knn_classifier = knn.KnnClassifier(labels,features)
res = array([knn_classifier.classify(test_features[i],k) for i in
          range(len(test_labels))])

# 准确率
acc = sum(1.0*(res==test_labels)) / len(test_labels)
print 'Accuracy:', acc

首先，用训练数据及其标记作为输入，创建分类器对象；然后，我们在整个测试集上遍历并用 classify() 方法对每幅图像进行分类。将布尔数组和 1 相乘并求和，可以计算出分类的正确率。由于该例中真值为 1，所以很容易计算出正确分类数。它应该会打印出一个类似下面的结果：

Accuracy: 0.811518324607

这说明该例中有 81% 的图像是正确的。该结果会随 k 值及稠密 SIFT 图像描述子参数的选择而变化。

虽然上面的正确率显示了对于一给定的测试集有多少图像是正确分类的，但是它并没有告诉我们哪些手势难以分类，或会犯哪些典型错误。混淆矩阵是一个可以显示每类有多少个样本被分在每一类中的矩阵，它可以显示错误的分布情况，以及哪些类是经常相互“混淆”的。

下面的函数会打印出标记及相应的混淆矩阵：

def print_confusion(res,labels,classnames):

  n = len(classnames)

  # 混淆矩阵
  class_ind = dict([(classnames[i],i) for i in range(n)])

  confuse = zeros((n,n))
  for i in range(len(test_labels)):
    confuse[class_ind[res[i]],class_ind[test_labels[i]]] += 1

  print 'Confusion matrix for'
  print classnames
  print confuse

运行：

print_confusion(res,test_labels,classnames)

打印输出应该如下：

Confusion matrix for
['A' 'B' 'C' 'F' 'P' 'V']
[[ 26. 0. 2. 0. 1. 1.]
 [ 0. 26. 0. 1. 1. 1.]
 [ 0. 0. 25. 0. 0. 1.]
 [ 0. 3. 0. 37. 0. 0.]
 [ 0. 1. 2. 0. 17. 1.]
 [ 3. 1. 3. 0. 14. 24.]]

上面混淆矩阵显示，本例子中 P（Point）经常被误分为“V”。

8.2　贝叶斯分类器

另一个简单却有效的分类器是贝叶斯分类器 3（或称朴素贝叶斯分类器）。贝叶斯分类器是一种基于贝叶斯条件概率定理的概率分类器，它假设特征是彼此独立不相关的（这就是它“朴素”的部分）。贝叶斯分类器可以非常有效地被训练出来，原因在于每一个特征模型都是独立选取的。尽管它们的假设非常简单，但是贝叶斯分类器已经在实际应用中获得显著成效，尤其是对垃圾邮件的过滤。贝叶斯分类器的另一个好处是，一旦学习了这个模型，就没有必要存储训练数据了，只需存储模型的参数。

3贝叶斯分类器是以 18 世纪英国教学家、牧师托马斯·贝叶斯命名的。

该分类器是通过将各个特征的条件概率相乘得到一个类的总概率，然后选取概率最高的那个类构造出来的。

首先让我们看一个使用高斯概率分布模型的贝叶斯分类器基本实现，也就是用从训练数据集计算得到的特征均值和方差来对每个特征单独建模。把下面的 Bayes Classifier 类添加到文件 bayes.py 中：

class BayesClassifier(object):

  def __init__(self):
    """ 使用训练数据初始化分类器 """

    self.labels = [] # 类标签
    self.mean = [] # 类均值
    self.var = [] # 类方差
    self.n = 0 # 类别数

  def train(self,data,labels=None):
    """ 在数据data（n ×dim 的数组列表）上训练，标记labels 是可选的，默认为0…n -1 """

    if labels==None:
      labels = range(len(data))
    self.labels = labels
    self.n = len(labels)

    for c in data:
      self.mean.append(mean(c,axis=0))
      self.var.append(var(c,axis=0))

  def classify(self,points):
    """ 通过计算得出的每一类的概率对数据点进行分类，并返回最可能的标记"""

    # 计算每一类的概率
    est_prob = array([gauss(m,v,points) for m,v in zip(self.mean,self.var)])

    # 获取具有最高概率的索引，该索引会给出类标签
    ndx = est_prob.argmax(axis=0)
    est_labels = array([self.labels[n] for n in ndx])

    return est_labels, est_prob

该模型每一类都有两个变量，即类均值和协方差。train() 方法获取特征数组列表（每个类对应一个特征数组），并计算每个特征数组的均值和协方差。classify() 方法计算数据点构成的数组的类概率，并选概率最高的那个类，最终返回预测的类标记及概率值，同时需要一个高斯辅助函数：

def gauss(m,v,x):
  """ 用独立均值m 和方差v 评估d 维高斯分布 """

  if len(x.shape)==1:
    n,d = 1,x.shape[0]
  else:
    n,d = x.shape

  # 协方差矩阵，减去均值
  S = diag(1/v)
  x = x-m
  # 概率的乘积
  y = exp(-0.5*diag(dot(x,dot(S,x.T))))

  # 归一化并返回
  return y * (2*pi)**(-d/2.0) / ( sqrt(prod(v)) + 1e-6)

该函数用来计算单个高斯分布的乘积，返回给定一组模型参数 m 和 v 的概率，更多多元正态分布例子可以参见 http://en.wikipedia.org/wiki/Multivariate_normal_distribution。

将该贝叶斯分类器用于上一节的二维数据，下面的脚本将载入上一节中的二维数据，并训练出一个分类器：

import pickle
import bayes
import imtools

# 用Pickle 模块载入二维样本点
with open('points_normal.pkl', 'r') as f:
  class_1 = pickle.load(f)
  class_2 = pickle.load(f)
  labels = pickle.load(f)

# 训练贝叶斯分类器
bc = bayes.BayesClassifier()
bc.train([class_1,class_2],[1,-1])

下面我们可以载入上一节中的二维测试数据对分类器进行测试：

# 用Pickle 模块载入测试数据
with open('points_normal_test.pkl', 'r') as f:
  class_1 = pickle.load(f)
  class_2 = pickle.load(f)
  labels = pickle.load(f)

# 在某些数据点上进行测试
print bc.classify(class_1[:10])[0]

# 绘制这些二维数据点及决策边界
def classify(x,y,bc=bc):
  points = vstack((x,y))
  return bc.classify(points.T)[0]

imtools.plot_2D_boundary([-6,6,-6,6],[class_1,class_2],classify,[1,-1])
show()

该脚本会将前 10 个二维数据点的分类结果打印输出到控制台，输出结果如下：

[1 1 1 1 1 1 1 1 1 1]

我们再次用一个辅助函数 classify() 在一个网格上评估该函数来可视化这一分类结果。两个数据集的分类结果如图 8-4 所示；该例中，决策边界是一个椭圆，类似于二维高斯函数的等值线。

图 8-4：用贝叶斯分类器对二维数据进行分类。每个例子中的颜色代表了类标记。正确分类的点用星号表示，误错分类的点用圆点表示，曲线是分类器的决策边界

用PCA降维

现在，我们尝试手势识别问题。由于稠密 SIFT 描述子的特征向量十分庞大（从前面的例子可以看到，参数的选取超过了 10 000），在用数据拟合模型之前进行降维处理是一个很好的想法。主成分分析，即 PCA（见 1.3 节），非常适合用于降维。下面的脚本就是用 pca.py 中的 PCA 进行降维：

import pca

V,S,m = pca.pca(features)

# 保持最重要的成分
V = V[:50]
features = array([dot(V,f-m) for f in features])
test_features = array([dot(V,f-m) for f in test_features])

这里的 features 和 test_features 与 K 邻近中的例子中加载的数组是一样的。在本例中，我们在训练数据上用 PCA 降维，并保持在这 50 维具有最大的方差。这可以通过均值 m（是在训练数据上计算得到的）并与基向量 V 相乘做到。对测试数据进行同样的转换。

训练并测试贝叶斯分类器如下：

# 测试贝叶斯分类器
bc = bayes.BayesClassifier()
blist = [features[where(labels==c)[0]] for c in classnames]

bc.train(blist,classnames)
res = bc.classify(test_features)[0]

由于 BayesClassifier 需要获取数组列表（每一类对应一个数组），在把数据传递给 train() 函数之前，我们需要对数据进行转换。因为我们目前还不需要概率，所以只需返回预测的类标记。

检查分类准确率：

acc = sum(1.0*(res==test_labels)) / len(test_labels)
print 'Accuracy:', acc

输出如下结果：

Accuracy: 0.717277486911

检查混淆矩阵：

print_confusion(res,test_labels,classnames)

输出如下结果：

Confusion matrix for
['A' 'B' 'C' 'F' 'P' 'V']
[[ 20. 0. 0. 4. 0. 0.]
 [ 0. 26. 1. 7. 2. 2.]
 [ 1. 0. 27. 5. 1. 0.]
 [ 0. 2. 0. 17. 0. 0.]
 [ 0. 1. 0. 4. 22. 1.]
 [ 8. 2. 4. 1. 8. 25.]]

虽然分类效果不如 K 邻近分类器，但是贝叶斯分类器不需要保存任何训练数据，而且只需保存每个类的模型参数。这一结果会随着 PCA 维度选取的不同而发生巨大的变化。

8.3　支持向量机

SVM（Support Vector Machine，支持向量机）是一类强大的分类器，可以在很多分类问题中给出现有水准很高的分类结果。最简单的 SVM 通过在高维空间中寻找一个最优线性分类面，尽可能地将两类数据分开。对于一特征向量 x 的决策函数为：

其中 w 是常规的超平面，b 是偏移量常数。该函数月阈值为 0，它能够很好地将两类数据分开，使其一类为正数，另一类为负数。通过在训练集上求解那些带有标记 y_i\in{-1,1\} 的特征向量 xi 的最优化问题，使超平面在两类间具有最大分开间隔，从而找到上面决策函数中的参数 w 和 b。该决策函数的常规解是训练集上某些特征向量的线性组合：

所以决策函数可以写为：

这里的 i 是从训练集中选出的部分样本，这里选择的样本称为支持向量，因为它们可以帮助定义分类的边界。

SVM 的一个优势是可以使用核函数（kernel function）；核函数能够将特征向量映射到另外一个不同维度的空间中，比如高维度空间。通过核函数映射，依然可以保持对决策函数的控制，从而可以有效地解决非线性或者很难的分类问题。用核函数 K(xi, x) 替代上面决策函数中的内积 xi · x。

下面是一些最常见的核函数：

线性是最简单的情况，即在特征空间中的超平面是线性的，K(xi, x)=xi · x；

多项式用次数为 d 的多项式对特征进行映射，K(xi, x)=(γxi· x+r)d，γ>0；

径向基函数，通常指数函数是一种极其有效的选择，K(xi, x)=e(-γ||xi-x||²)，γ>0；

Sigmoid 函数，一个更光滑的超平面替代方案，K(xi, x)=tanh(γxi · x+r)。

每个核函数的参数都是在训练阶段确定的。

对于多分类问题，通常训练多个 SVM，使每一个 SVM 可以将其中一类与其余类分开，这样的分类器也称为“one-versus-all”分类器。关于 SVM 的更多细节可以参阅文献 [9] 以及在线文档 http://www.support-vector.net/references.html 。

8.3.1　使用LibSVM

LibSVM[7] 是最好的、使用最广泛的 SVM 实现工具包。LibSVM 为 Python 提供了一个良好的接口（也为其他编程语言提供了接口）。关于安装说明，可以参阅附录 A.4。

我们看看 LibSVM 在二维样本数据点上是怎样工作的。下面的脚本会载入在前面 kNN 范例分类中用到的数据点，并用径向基函数训练一个 SVM 分类器：

import pickle
from svmutil import *
import imtools

# 用Pickle 载入二维样本点
with open('points_normal.pkl', 'r') as f:
  class_1 = pickle.load(f)
  class_2 = pickle.load(f)
  labels = pickle.load(f)

# 转换成列表，便于使用libSVM
class_1 = map(list,class_1)
class_2 = map(list,class_2)
labels = list(labels)
samples = class_1+class_2 # 连接两个列表

# 创建SVM
prob = svm_problem(labels,samples)
param = svm_parameter('-t 2')

# 在数据上训练SVM
m = svm_train(prob,param)

# 在训练数据上分类效果如何？
res = svm_predict(labels,samples,m)

我们用与前面一样的方法载入数据集，但是这次需要把数组转成列表，因为 LibSVM 不支持数组对象作为输入。这里，我们用 Python 的内建函数 map() 进行转换，map() 函数中用到了对角一个元素都会进行转换的 list() 函数。紧接着我们创建了一个 svm_problem 对象，并为其设置了一些参数。调用 svm_train() 求解该优化问题用以确定模型参数，然后就可以用该模型进行预测了。最后一行调用 svm_predict()，用求得的模型 m 对训练数据分类，并显示出在训练数据中分类的正确率，打印输出结果如下：

Accuracy = 100% (400/400) (classification)

结果表明该分类器完全分开了训练数据，400 个数据点全部分类正确。

注意，我们在调用方法训练分类器时添加了一个参数选择字符串，这些参数用于控制核函数的类型、等级及其他选项。尽管其中大部分超出了本书范围，但是需要知道两个重要的参数 t 和 k。参数 t 决定了所用核函数的类型，该选项是：

“-t”

核函数类型

0

线性函数

1

多项式函数

2

径向基函数（默认）

3

sigmoid 函数

参数 k 决定了多项式的次数（默认为 3）。

现在，载入其他数据集，并对该分类器进行测试：

# 用Pickle 模块载入测试数据
with open('points_normal_test.pkl', 'r') as f:
  class_1 = pickle.load(f)
  class_2 = pickle.load(f)
  labels = pickle.load(f)

# 转换成列表，便于使用LibSVM
class_1 = map(list,class_1)
class_2 = map(list,class_2)

# 定义绘图函数
def predict(x,y,model=m):
  return array(svm_predict([0]*len(x),zip(x,y),model)[0])

# 绘制分类边界
imtools.plot_2D_boundary([-6,6,-6,6],[array(class_1),array(class_2)],predict,[-1,1])
show()

我们需要再次为 LibSVM 将数据转成列表，同时和之前一样，我们定义了一个辅助函数 predict() 来绘制分类的边界。注意，如果无法获取真实的标记，我们用 ([0]*len(x)) 列表来代替标记列表。只要代替的标记列表长度正确，你可以使用任意的列表。图 8-5 显示了两个不同数据集在二维平面上的分布情况。

图 8-5：用支持向量机 SVM 对二维数据进行分类。在这两幅图中，我们用不同颜色标识类标记。正确分类的点用星号表示，错误分类的点用圆点表示，曲线是分类器的决策边界

8.3.2　再论手势识别

在多类手势识别问题上使用 LibSVM 相当直观。LibSVM 可以自动处理多个类，我们只需要对数据进行格式化，使输入和输出匹配 LibSVM 的要求。

和之前的例子一样，feature 和 test_features 两个文件中分别数组的形式保存训练数据和测试数据。下面的代码会载入训练数据测试数据，并训练一个线性 SVM 分类器：

features = map(list,features)
test_features = map(list,test_features)

# 为标记创建转换函数
transl = {}
for i,c in enumerate(classnames):
  transl[c],transl[i] = i,c

# 创建SVM
prob = svm_problem(convert_labels(labels,transl),features)
param = svm_parameter('-t 0')

# 在数据上训练SVM
m = svm_train(prob,param)

# 在训练数据上分类效果如何
res = svm_predict(convert_labels(labels,transl),features,m)

# 测试SVM
res = svm_predict(convert_labels(test_labels,transl),test_features,m)[0]
res = convert_labels(res,transl)

与之前一样，我们调用 map() 函数将数组转成列表；因为 LibSVM 不能处理字符串标记，所以这些标记也需要转换。字典 transl 会包含一个在字符串和整数标记间的变换。你可以试着在控制台上打印该变换，看看其对应变换关系。参数 -t 0 设置分类器是线性分类器，决策边界在 10 000 维特征原空间中是一个超平面。

现在，对标记进行比较：

acc = sum(1.0*(res==test_labels)) / len(test_labels)
print 'Accuracy:', acc

print_confusion(res,test_labels,classnames)

用线性核函数得出的分类结果如下：

Accuracy: 0.916230366492
Confusion matrix for
['A' 'B' 'C' 'F' 'P' 'V']
[[ 26. 0. 1. 0. 2. 0.]
 [ 0. 28. 0. 0. 1. 0.]
 [ 0. 0. 29. 0. 0. 0.]
 [ 0. 2. 0. 38. 0. 0.]
 [ 0. 1. 0. 0. 27. 1.]
 [ 3. 0. 2. 0. 3. 27.]]

现在，正如我们在 8.2 节所做的，用 PCA 将维数降低到 50，分类正确率变为：

Accuracy: 0.890052356021

可以看出，当特征向量维数降低到原空间数据维数的 1/200 时，结果并不差（存储支持向量所需占用的空间也减小到原来的 1/200）。

8.4　光学字符识别

作为一个多类问题实例，让我们来理解数独图像。OCR（Optical Character Recognition，光学字符识别）是一个理解手写或机写文本图像的处理过程。一个常见的例子是通过扫描文件来提取文本，例如书信中的邮政编码或者谷歌图书（http://books.google.com/）里图书馆卷的页数。这里我们看一个简单的在打印的数独图形中识别数字的 OCR 问题。数独是一种数字逻辑游戏，规则是用数字 1~9 填满 9×9 的网格，使每一行每一列和每个 3×3 的子网格包含数字 1…94。在这个例子中我们只对正确地读取和理解它们感兴趣，对于完成识别后怎样求解这些数独问题我们就留给你。

4如果你不熟悉该概念，更多细节参见 http://en.wikipedia.org/wiki/Sudoku。

8.4.1　训练分类器

对于这种分类问题，我们有 10 个类：数字 1…9，以及一些什么也没有的单元格。我们给定什么也没有的单元格的类标号是 0，这样所有类标记就是 0…9。我们会用 已经剪切好的数独单元格数据集来训练一个 10 类的分类器 5 文件 sudoku_images.zip 中有两个文件夹“ocr data”和“sudokus”，后者包含了不同条件下的数独图像集，我们稍后讲解。现在我们主要来看文件夹“ocr_data”，这个文件夹包含了两个子文件夹，一个装有训练图像，另一个装有测试图像。这些图像文件名的第一个字母是数字（0…9），用以标明它们属于哪类。图 8-6 展示了训练集中的一些样本。图像是灰度图，大概是 80×80 像素（有一幅波动）。

5图像来自 Martin Byröd [4], http://www.maths.lth.se/matematikith/personal/byrod，搜集、裁剪于真实的数独照片。

8.4.2　选取特征

我们首先要确定选取怎样的特征向量来表示每一个单元格里的图像。有很多不错的选择；这里我们将会用某些简单而有效的特征。输入一个图像，下面的函数将返回一个拉成一组数组后的灰度值特征向量：

def compute_feature(im):
  """ 对一个ocr 图像块返回一个特征向量"""

  # 调整大小，并去除边界
  norm_im = imresize(im,(30,30))
  norm_im = norm_im[3:-3,3:-3]

  return norm_im.flatten()

compute_feature() 函数用到 imtools 模块中的尺寸调整函数 imresize()，来减少特征向量的长度。我们还修剪掉了大约 10% 的边界像素，因为这些修剪掉的部分通常是网格线的边缘部分，如图 8-6 所示。

图 8-6：用于训练 10 类数独 OCR 分类器的训练样本图像

现在我们用下面的函数来读取训练数据：

def load_ocr_data(path):
""" 返回路径中所有图像的标记及OCR 特征 """

  # 对以.jpg 为后缀的所有文件创建一个列表
  imlist = [os.path.join(path,f) for f in os.listdir(path) if f.endswith('.jpg')]
  # 创建标记
  labels = [int(imfile.split('/')[-1][0]) for imfile in imlist]

  # 从图像中创建特征
  features = []
  for imname in imlist:
    im = array(Image.open(imname).convert('L'))
    features.append(compute_feature(im))
return array(features),labels

上述代码将每一个 JPEG 文件的文件名中的第一个字母提取出来做类标记，并且这些标记被作为整型数据存储在 lables 列表里；用上面的函数计算出的特征向量存储在一个数组里。

8.4.3　多类支持向量机

在得到了训练数据之后，我们接下来要学习一个分类器，这里将使用多类支持向量机。代码和上一节中的代码类似：

from svmutil import *

# 训练数据
features,labels = load_ocr_data('training/')

# 测试数据
test_features,test_labels = load_ocr_data('testing/')

# 训练一个线性SVM 分类器
features = map(list,features)
test_features = map(list,test_features)

prob = svm_problem(labels,features)
param = svm_parameter('-t 0')

m = svm_train(prob,param)

# 在训练数据上分类效果如何
res = svm_predict(labels,features,m)

# 在测试集上表现如何
res = svm_predict(test_labels,test_features,m)

该代码会训练出一个线性 SVM 分类器，并在测试集上对该分类器的性能进行测试，你可以通过调用最后两个 svm_predict() 函数得到以下输出结果：

Accuracy = 100% (1409/1409) (classification)
Accuracy = 99.2979% (990/997) (classification)

真是一个极好的结果，训练集中的 1409 张图像在 10 类中都被完美地分准确了，在测试集上识别性能也在 99% 左右。现在我们可以将这一分类器用到经过裁剪的新数独图像上。

8.4.4　提取单元格并识别字符

有了识别单元格内容的分类器后，下一步就是自动地找到这些单元格。一旦我们解决了这个问题，就可以对单元格进行裁剪，并把裁剪后的单元格传给分类器。我们假设数独图像是已经对齐的，其水平和垂直网格线平行于图像的边，如图 8-7 所示。在这些条件下，我们可以对图像进行阈值化处理，并在水平和垂直方向上分别对像素值求和。由于这些经阈值处理的边界值为 1，而其他部分值为 0，所以这些边界处会给出很强的响应，可以告诉我们从何处进行裁剪。**

下面函数接受一幅灰度图像和一个方向，返回该方向上的 10 条边界：

from scipy.ndimage import measurements

def find_sudoku_edges(im,axis=0):
  """ 对一幅对齐后的数独图像查找单元格的边界"""

# 阈值处理，处理后对每行（列）相加求和
trim = 1*(im<128)
s = trim.sum(axis=axis)

# 寻找连通域
s_labels,s_nbr = measurements.label(s>(0.5*max(s)))
# 计算各连通域的质心
m = measurements.center_of_mass(s,s_labels,range(1,s_nbr+1))
# 对质心取整，质心即为相线条所在位置
x = [int(x[0]) for x in m]

# 只要检测到4 条粗线，便在这4 条粗线之间添加直线
if len(x)==4:
  dx = diff(x)
  x = [x[0],x[0]+dx[0]/3,x[0]+2*dx[0]/3,
      x[1],x[1]+dx[1]/3,x[1]+2*dx[1]/3,
      x[2],x[2]+dx[2]/3,x[2]+2*dx[2]/3,x[3]]

  if len(x)==10:
    return x
  else:
    raise RuntimeError('Edges not detected.')

首先对图像进行阈值化处理，对灰度值小于 128 的暗区域赋值为 1，否则为 0；然后在特定的方向上（如 axis=0 或 1）对这些经阈值处理后的像素相加求和。Scipy.ndimage 包含 measurements 模块，该模块在二进制或标记数组中对于计数及测量区域是非常有用的。首先，labels() 找出二进制数组中相连接的部件；该二进制数组是通过求和后取中值并进行阈值化处理得到的。然后，center_of_mass() 函数计算每个独立组件的质心。你可能得到 4 个或 10 个点，这主要依赖于数独平面造型设计（所有的线条是等粗细的或子网格线条比其他的粗）。在 4 个点的情况下，会以一定的间隔插入 6 条直线。如果最后的结果没有 10 条线，则会抛出一个异常。

sudokus 文件夹里包含不同难易程度的数独图像，每幅图像都对应一个包含数独真实值的文件，我们可以用它来检查识别结果。有一些图像已经和图像的边框对齐，从中挑选一幅图像，用以检查图像裁剪及分类的性能：

imname = 'sudokus/sudoku18.jpg'
vername = 'sudokus/sudoku18.sud'
im = array(Image.open(imname).convert('L'))

# 查找单元格边界
x = find_sudoku_edges(im,axis=0)
y = find_sudoku_edges(im,axis=1)

# 裁剪单元格并分类
crops = []
  for col in range(9):
  for row in range(9):
    crop = im[y[col]:y[col+1],x[row]:x[row+1]]
    crops.append(compute_feature(crop))

res = svm_predict(loadtxt(vername),map(list,crops),m)[0]
res_im = array(res).reshape(9,9)

print 'Result:'
print res_im

找到边界后，从每一个单元格提取出 crops。将裁剪出来的这些单元格传给同一特征提取函数，并将提取出来的特征作为训练数据保存在一个数组中。通过 loadtxt() 读取数独图像的真实标记，用 svm_predict() 函数对这些特征向量进行分类，在控制台上打印出的结果应该为：

Accuracy = 100% (81/81) (classification)
Result:
[[ 0. 0. 0. 0. 1. 7. 0. 5. 0.]
 [ 9. 0. 3. 0. 0. 5. 2. 0. 7.]
 [ 0. 0. 0. 0. 0. 0. 4. 0. 0.]
 [ 0. 1. 6. 0. 0. 4. 0. 0. 2.]
 [ 0. 0. 0. 8. 0. 1. 0. 0. 0.]
 [ 8. 0. 0. 5. 0. 0. 6. 4. 0.]
 [ 0. 0. 9. 0. 0. 0. 0. 0. 0.]
 [ 7. 0. 2. 1. 0. 0. 8. 0. 9.]
 [ 0. 5. 0. 2. 3. 0. 0. 0. 0.]]

这里使用的只是其中较简单的图像，请尝试对一些别的数独图像进行识别，看看识别准确率如何，以及分类器在哪些地方出现识别错误。

如果你用一个 9×9 的子图绘制这些经裁剪后的单元格，它们应该和图 8-7（右图）类似。

图 8-7：一个检测并裁剪这些数独网格区域的例子：一幅数独网格图像(左)；9×9 裁剪后的图像，每个独立单元都会被送到 OCR 分类器中(右)

8.4.5　图像校正

如果你对上面分类器的性能还算满意，那么下一个挑战便是如何将它应用于那些没有对齐的图像。这里我们将用一种简单的图像校正方法来结束本章数独图像识别的例子，使用该校正方法的前提是网格的 4 个角点都已经被检测到或者手工做过标记。图 8-8（左）中是一幅在进行识别时受角度影响剧烈的图像。

一个单应矩阵可以像上面的例子那样映射网格以使边缘能够对齐，我们这里所要做的就是估算该变换矩阵。下面的例子手工标记 4 个角点，然后将图像变换为一个 1000×1000 大小的方形图像：

from scipy import ndimage
import homography

imname = 'sudoku8.jpg'
im = array(Image.open(imname).convert('L'))

# 标记角点
figure()
imsshow(im)
gray()
x = ginput(4)

# 左上角、右上角、右下角、左下角
fp = array([array([p[1],p[0],1]) for p in x]).T
tp = array([[0,0,1],[0,1000,1],[1000,1000,1],[1000,0,1]]).T

# 估算单应矩阵
H = homography.H_from_points(tp,fp)

# 辅助函数，用于进行几何变换
def warpfcn(x):
  x = array([x[0],x[1],1])
  xt = dot(H,x)
  xt = xt/xt[2]
  return xt[0],xt[1]

# 用全透视变换对图像进行变换
im_g = ndimage.geometric_transform(im,warpfcn,(1000,1000))

第 3 章中对很多样本图像进行的仿射变换还达不到对这些数独图像进行校正的要求，这里用到了 scipy.ndinmage 模块中一个更加普遍的变换函数 geometric_transform()，该函数获取一个 2D 到 2D 的映射，映射为另一个二维来取代变化矩阵，所以我们需要一个辅助函数（该例中用一个三角形的分段仿射变换），变换后的图像如图 8-8 中右图所示。

图 8-8：用全透视变换对一幅图像进行校正的例子。四个角点被手工标记的数独原图（左），变换为 1000×1000 大小的方形图（右）

这就是整个关于数独图像识别的例子。其中还有很多可以改进的地方，及一些待调查的识别方案。下面的练习中会提到一些，剩下的则留给你自行研究。

练习

KNN 分类器的性能依赖于 k 值的选择。试着改变 k 值，看分类精度是怎样变化的。画出二维数据集的决策边界，看它们是怎样变化的。

图 8-3 中的手势数据库文件夹 complex/ 还包含背景更复杂的手势图像。试着在这些图像上训练出一个分类器并对训练出的分类器进行测试。它们在性能上有什么差异？你能够对图像描述子做出一些改进吗？

对于贝叶斯分类器，在对手势识别特征进行 PCA 降维时，试着改变降维的维数。哪一个维数较好？画出奇异值 S，画出的曲线如图 8-9 所示，是一个典型的膝状曲线。在训练数据生成可变性能力和保持较低的维数间，一个较好的折中是在图像变得平缓之前选取一个恰当的维数。

图 8-9：练习 3 曲线图

用一个不同于高斯分布的概率模型修改贝叶斯分类器，例如在训练集上对于每个特征使用频率计数，在不同的数据集上和高斯分布结果进行对比。

用非线性 SVM 对于手势识别问题进行实验，比如选择多项式核函数，并用 -d 参数逐步增加多项式的阶数，观察在训练集和测试集上分类性能的变化。对于非线性分类器，存在一种潜在的风险，即在某个特定的数据集上，可能在训练集上分类结果很好，但测试集上分类结果很差。这种破坏分类器泛化能力的现象称为过拟合，我们应该避开这种风险。

试着在数独字符识别上用一些更高级的特征向量。如果你需要灵感，参见文献 [4]。

实现一种能自动对齐数独网格的方法，例如试着用 RANSAC 进行特征检测、进行直线检测，或从 scipy.ndimage（http://docs.scipy.org/doc/scipy/reference/ndimage.html）用形态学和测量操作检测这些单元格。另外，作为额外的任务，请试着解决查找方向“向上”的旋转不确定问题。比如，你可以试着旋转校正后的网格，并以 OCR 分类器的分类准确率选出最佳旋转方向。

MNIST 手写数字数据库（http://yann.lecun.com/exdb/mnist）是一个比数独数字图像库更具有挑战性的分类问题。试着提取 MNIST 数据库的一些特征，并用 SVM 对该数据库进行分类。检查你所获得的分类准确率和已知的最佳分类方法（有些方法出奇得好）之间差距。

如果你想更深入地了解分类器和机器学习算法，可以参阅 scikit.learn 包（http://scikit-learn.org/），然后在本章的数据库上尝试其中的一些算法。

第 9 章　图像分割

图像分割是将一幅图像分割成有意义区域的过程。区域可以是图像的前景与背景或图像中一些单独的对象。这些区域可以利用一些诸如颜色、边界或近邻相似性等特征进行构建。本章中，我们将看到一些不同的分割技术。

9.1　图割（Graph Cut）

图论中的图（graph）是由若干节点（有时也称顶点）和连接节点的边构成的集合。图 9-1 给出了一个示例 1。边可以是有向的（图 9-1 中用箭头示出）或无向的，并且这些可能有与它们相关联的权重。

12.3 节中也出现过图。这次我们要使用图进行图像分割。

图 9-1：用 python-graph 工具包创建的一个简单有向图

图割是将一个有向图分割成两个互不相交的集合，可以用来解决很多计算机视觉方面的问题，诸如立体深度重建、图像拼接和图像分割等计算机视觉方面的不同问题。从图像像素和像素的近邻创建一个图并引入一个能量或“代价”函数，我们有可能利用图割方法将图像分割成两个或多个区域。图割的基本思想是，相似且彼此相近的像素应该划分到同一区域。

图割 C（C 是图中所有边的集合）的“代价”函数定义为所有割的边的权重求合相加：

\sum_{cut}=\sum_{(i,j)\in C}w_{ij}　　　　　　（9.1）

wij是图中节点 i 到节点 j 的边（i，j）的权重，并且是对割 C 所有的边进行求和。

图割图像分割的思想是用图来表示图像，并对图进行划分以使割代价 Ecut 最小。在用图表示图像时，增加两个额外的节点，即源点和汇点；并仅考虑那些将源点和汇点分开的割。

寻找最小割（minimum cut 或 min cut）等同于在源点和汇点间寻找最大流（maximum flow 或 max flow），详见文献 [2]。此外，很多有效的算法都可以解决这些最大流 / 最小割问题。

我们在图割例子中将采用 python-graph 工具包，该工具包包含了许多非常有用的图算法，你可以在 http://code.google.com/p/python-graph/ 下载该工具包并查看文档。随后的例子里，我们要用到 maximum_flow() 函数，该函数用 Edmonds-Karp 算法（http://en.wikipedia.org/wiki/Edmonds-Karp_algorithm）计算最大流/最小割。采用一个完全用 Python 写成工具包的好处是安装容易且兼容性良好；不足是速度较慢。不过，对于小尺寸图像，该工具包的性能足以满足我们的需求，对于较大的图像，需要一个更快的实现。

这里给出一个用 python-graph 工具包计算一幅较小的图 2 的最大流 / 最小割的简单例子：

2http://en.wikipedia.org/wiki/Max-flow_min-cut_theorem 上有相同的图作为例子。

from pygraph.classes.digraph import digraph
from pygraph.algorithms.minmax import maximum_flow

gr = digraph()
gr.add_nodes([0,1,2,3])

gr.add_edge((0,1), wt=4)
gr.add_edge((1,2), wt=3)
gr.add_edge((2,3), wt=5)
gr.add_edge((0,2), wt=3)
gr.add_edge((1,3), wt=4)

flows,cuts = maximum_flow(gr,0,3)
print 'flow is:', flows
print 'cut is:', cuts

首先，创建有 4 个节点的有向图，4 个节点的索引分别 0...3，然后用 add_edge() 增添边并为每条边指定特定的权重。边的权重用来衡量边的最大流容量。以节点 0 为源点、3 为汇点，计算最大流。打印出流和割结果：

flow is: {(0, 1): 4, (1, 2): 0, (1, 3): 4, (2, 3): 3, (0, 2): 3}
cut is: {0: 0, 1: 1, 2: 1, 3: 1}

上面两个 python 字典包含了流穿过每条边和每个节点的标记：0 是包含图源点的部分，1 是与汇点相连的节点。你可以手工验证这个割确实是最小的。参见图 9-1。

9.1.1　从图像创建图

给定一个邻域结构，我们可以利用图像像素作为节点定义一个图。这里我们将集中讨论最简单的像素四邻域和两个图像区域（前景和背景）情况。一个四邻域（4-neighborhood）指一个像素与其正上方、正下方、左边、右边的像素直接相连 3。

3另一个常见的选择是八邻域，它把对角上的像素也连结起来了。

除了像素节点外，我们还需要两个特定的节点——“源”点和“汇”点，来分别代表图像的前景和背景。我们将利用一个简单的模型将所有像素与源点、汇点连接起来。

下面给出创建这样一个图的步骤：

每个像素节点都有一个从源点的传入边；

每个像素节点都有一个到汇点的传出边；

每个像素节点都有一条传入边和传出边连接到它的近邻。

为确定边的权重，需要一个能够确定这些像素点之间，像素点与源点、汇点之间边的权重（表示那条边的最大流）的分割模型。与前面一样，像素 i 与像素 j 之间的边 的权重记为 wij，源点到像素 i 的权重记为 wsi，像素 i 到汇点的权重记为 wit。

让我们先回顾一下 8.2 节中在像素颜色值上用朴素贝叶斯分类器进行分类的知识。假定我们已经在前景和背景像素（从同一图像或从其他的图像）上训练出了一个贝叶斯分类器，我们就可以为前景和背景计算概率 pF(Ii) 和 pB(Ii)。这里，Ii 是像素 i 的颜色向量。

现在我们可以为边的权重建立如下模型：

利用该模型，可以将每个像素和前景及背景（源点和汇点）连接起来，权重等于上面归一化后的概率。wij 描述了近邻间像素的相似性，相似像素权重趋近于 κ，不相 似的趋近于 0。参数 σ 表征了随着不相似性的增加，指数次幂衰减到 0 的快慢。

创建一个名为 graphcut.py 的文件，将下面从一幅图像创建图的函数写入该文件中：

from pygraph.classes.digraph import digraph
from pygraph.algorithms.minmax import maximum_flow

import bayes

def build_bayes_graph(im,labels,sigma=1e2,kappa=2):
  """ 从像素四邻域建立一个图，前景和背景（前景用1 标记，背景用-1 标记，
    其他的用0 标记）由labels 决定，并用朴素贝叶斯分类器建模"""

  m,n = im.shape[:2]

  # 每行是一个像素的RGB 向量
  vim = im.reshape((-1,3))

  # 前景和背景（RGB）
  foreground = im[labels==1].reshape((-1,3))
  background = im[labels==-1].reshape((-1,3))
  train_data = [foreground,background]

  # 训练朴素贝叶斯分类器
  bc = bayes.BayesClassifier()
  bc.train(train_data)

  # 获取所有像素的概率
  bc_lables,prob = bc.classify(vim)
  prob_fg = prob[0]
  prob_bg = prob[1]

  # 用m *n +2 个节点创建图
  gr = digraph()
  gr.add_nodes(range(m*n+2))
  source = m*n # 倒数第二个是源点
  sink = m*n+1 # 最后一个节点是汇点

  # 归一化
  for i in range(vim.shape[0]):
    vim[i] = vim[i] / linalg.norm(vim[i])

  # 遍历所有的节点，并添加边
  for i in range(m*n):
    # 从源点添加边
    gr.add_edge((source,i), wt=(prob_fg[i]/(prob_fg[i]+prob_bg[i])))

  # 向汇点添加边
  gr.add_edge((i,sink), wt=(prob_bg[i]/(prob_fg[i]+prob_bg[i])))

  # 向相邻节点添加边
  if i%n != 0: # 左边存在
    edge_wt = kappa*exp(-1.0*sum((vim[i]-vim[i-1])**2)/sigma)
    gr.add_edge((i,i-1), wt=edge_wt)
  if (i+1)%n != 0: # 如果右边存在
    edge_wt = kappa*exp(-1.0*sum((vim[i]-vim[i+1])**2)/sigma)
    gr.add_edge((i,i+1), wt=edge_wt)
  if i//n != 0: 如果上方存在
    edge_wt = kappa*exp(-1.0*sum((vim[i]-vim[i-n])**2)/sigma)
    gr.add_edge((i,i-n), wt=edge_wt)
  if i//n != m-1: # 如果下方存在
    edge_wt = kappa*exp(-1.0*sum((vim[i]-vim[i+n])**2)/sigma)
    gr.add_edge((i,i+n), wt=edge_wt)

  return gr

这里我们用到了用 1 标记前景训练数据、用 ?1 标记背景训练数据的一幅标记图像。基于这种标记，在 RGB 值上可以训练出一个朴素贝叶斯分类器，然后计算每一像素的分类概率，这些计算出的分类概率便是从源点出来和到汇点去的边的权重；由此可以创建一个节点为 n×m+2 的图。注意源点和汇点的索引；为了简化像素的索引，我们将最后的两个索引作为源点和汇点的索引。

为了在图像上可视化覆盖的标记区域，我们可以利用 contourf() 函数填充图像（这里指带标记图像）等高线间的区域，参数 alpha 用于设置透明度。将下面函数增加到 graphcut.py 中：

def show_labeling(im,labels):
  """ 显示图像的前景和背景区域。前景labels=1, 背景labels=-1，其他labels ＝ 0 """
  imshow(im)
  contour(labels,[-0.5,0.5])
  contourf(labels,[-1,-0.5],colors='b',alpha=0.25)
  contourf(labels,[0.5,1],colors='r',alpha=0.25)
  axis('off')

图建立起来后便需要在最优位置对图进行分割。下面这个函数可以计算最小割并将输出结果重新格式化为一个带像素标记的二值图像：

def cut_graph(gr,imsize):
  """ 用最大流对图gr 进行分割，并返回分割结果的二值标记"""

  m,n = imsize
  source = m*n # 倒数第二个节点是源点
  sink = m*n+1 # 倒数第一个是汇点

  # 对图进行分割
  flows,cuts = maximum_flow(gr,source,sink)

  # 将图转为带有标记的图像
  res = zeros(m*n)
  for pos,label in cuts.items()[:-2]: # 不要添加源点/ 汇点
    res[pos] = label

  return res.reshape((m,n))

需要再次注意源点和汇点的索引；我们需要将图像的尺寸作为输入去计算这些索引，在返回分割结果之前要对输出结果进行 reshape() 操作。割以字典返回，需要将它复制到分割标记图像中，这通过返回列表（键，值）的 .item() 方法完成。这里我们再一次略过了列表中最后两个元素。

现在让我们看看怎样利用这些函数来分割一幅图像。下面这个例子会读取一幅图像，从图像的两个矩形区域估算出类概率，然后创建一个图：

from scipy.misc import imresize
import graphcut

im = array(Image.open('empire.jpg'))
im = imresize(im,0.07,interp='bilinear')
size = im.shape[:2]

# 添加两个矩形训练区域
labels = zeros(size)
labels[3:18,3:18] = -1
labels[-18:-3,-18:-3] = 1

# 创建图
g = graphcut.build_bayes_graph(im,labels,kappa=1)

# 对图进行分割
res = graphcut.cut_graph(g,size)

figure()
graphcut.show_labeling(im,labels)

figure()
imshow(res)
gray()
axis('off')

show()

我们利用了 imresize() 函数使图像小到适合我们的 Python graph 库；在该例中将图像统一缩放到原图像尺寸的 7%。图分割后将结果和训练区域一起画出来。图 9-2 中图像覆盖区域为训练区域并显示出了最终的分割结果。

图 9-2：利用贝叶斯概率模型进行图割分割。图像降采样（downsample）到 54×38 大小。用于模型训练的标记图像（左）；在待分割图像上显示训练区域（中）；分割的结果（右）

变量 kappa（方程中的 K）决定了近邻像素间边的相对权重。改变 K 值分割的效果 如图 9-3 所示；随着 K 值增大，分割边界将变得更平滑，并且细节部分也逐步丢失。你可以根据自己应用的需要及想要获得的结果类型来选择合适的 K 值。

图 9-3：改变像素相似性和类概率之间相对权重对分割结果的影响，采用的分割方法与图 9-2 相同：（a）k=1，（b）k=2，（c）k=5，（d）k=10

9.1.2　用户交互式分割

利用一些方法可以将图割分割与用户交互结合起来。例如，用户可以在一幅图像上为前景和背景提供一些标记。另一种方法是利用边界框（bounding box）或“lasso”工具选择一个包含前景的区域。

让我们来看最后一个例子，其中用到了来自微软剑桥研究院 Grab Cut 数据集的一些图像，详见文献 [27] 和附录 B.5。

这些图像还提供了用来评价分割性能的真实标记，还有模拟用户选择矩形图像区域或用“lasso”之类的工具来标记前景和背景的标注信息。我们可以利用这些用户提供的输入来得到训练数据，并以用户输入为导向用切割对图像进行分割。

将用户输入编码成具有下面意义的位图图像：

像素值

意义

0, 64

背景

128

未知

255

前景

这里给出一个完整的示例代码，它会载入一幅图像及对应的标注信息，然后将其传递到我们的图割分割路径中：

from scipy.misc import imresize
import graphcut

def create_msr_labels(m,lasso=False):
  """ 从用户的注释中创建用于训练的标记矩阵"""

  labels = zeros(im.shape[:2])

  # 背景
  labels[m==0] = -1
  labels[m==64] = -1

  # 前景
  if lasso:
    labels[m==255] = 1
  else:
    labels[m==128] = 1

  return labels

  # 载入图像和注释图
  im = array(Image.open('376043.jpg'))
  m = array(Image.open('376043.bmp'))

  # 调整大小
  scale = 0.1
  im = imresize(im,scale,interp='bilinear')
  m = imresize(m,scale,interp='nearest')

  # 创建训练标记
  labels = create_msr_labels(m,False)

  # 用注释创建图
  g = graphcut.build_bayes_graph(im,labels,kappa=2)

  # 图割
  res = graphcut.cut_graph(g,im.shape[:2])

  # 去除背景部分
  res[m==0] = 1
  res[m==64] = 1

  # 绘制分割结果
  figure()
  imshow(res)
  gray()
  xticks([])
  yticks([])
  savefig('labelplot.pdf')

首先，我们定义一个辅助函数用以读取这些标注图像，格式化这些标注图像便于将其传递给背景和前景训练模型函数，矩形框中只包含背景标记。在本例中，我们设置前景训练区域为整个“未知的”区域（矩形内部）。下一步我们创建图并分割。由于有用户输入，所以我们移除那些在标记背景区域里有任何前景的结果。最后，我们绘制出分割结果，并通过设置这些勾选标记到一个空列表来移去这些勾选标记。这样我们就可以得到一个很好的边框（否则，图像中的边界在黑白图中很难看到）。

图 9-4 显示了利用 RGB 向量作为原始图像的特征进行分割的一些结果，一个下采样掩膜和下采样分割结果。右边的图像是通过上面的脚本生成的图线。

图 9-4：利用 Grab cut 数据集中的图像进行图割分割的结果：经过下采样后的原始图像（左）；用于训练的掩膜（中间）；（右）将用 RGB 像素值作为特征向量进行分割后的结果

9.2　利用聚类进行分割

上一节的图割问题通过在图像的图上利用最大流 / 最小割找到了一种离散解决方案。在本节，我们将看到另外一种分割图像图的方法，即基于谱图理论的归一化分割算法，它将像素相似和空间近似结合起来对图像进行分割。

该方法来自定义一个分割损失函数，该损失函数不仅考虑了组的大小而且还用划分的大小对该损失函数进行“归一化”。该归一化后的分割公式将方程（9.1）中分割损失函数修改为：

A 和 B 表示两个割集，并在图中分别对 A 和 B 中所有其他节点（函数进行“归一化”这里指图像像素）的权重求和相加，相加求和项称为 association。对于那些像素与其他像素具有相同连接数的图像，它是对划分大小的一种粗糙度量方式。文献 [32] 介绍了上面的损失函数与寻找极小值算法。该算法是针对两类分割问题衍生出来的，将在下面进行讲解。

定义 W 为边的权重矩阵，矩阵中的元素 wij 为连接像素 i 和像素 j 边的权重。D 为对 W每行元素求和后构成的对角矩阵，即 D=diag(di)，d_i=\sum_jw_{ij} (和 6.3 节中一样)。

归一化分割可以通过最小化下面的优化问题而求得：

向量 y 包含的是离散标记，这些离散标记满足对于 b 为常数 y_i \in {1,-b\} （即 y 只可以取这两个值）的约束，yTD 求和为 0。由于这些约束条件，该问题不太容易求解 4。

4事实上，这是一个 NP 问题。

然而，通过松弛约束条件并让 y 取任意实数，该问题可以变为一个容易求解的特征分解问题。这样求解的缺点是你需要对输出设定阈值或进行聚类，使它重新成为一个离散分割。

松弛该问题后，该问题便成为求解拉普拉斯矩阵特征向量问题：

正如谱聚类情形一样，现在的难点是如何定义像素间边的权重 wij。归一化割与谱聚类有很多相似之处，并且基础理论有所重叠，具体解释及细节参阅文献 [32]。

我们利用原始归一化割论文 [32] 中的边的权重，通过下式给出连接像素 i 和像素 j 的边的权重：

式中第一部分度量像素 Ii 和 Ij 之间的像素相似性，Ii(Ij) 定义为 RGB 向量或灰度值；第二部分度量图像中 xi 和 xj 的接近程度，xi(xj) 定义为每个像素的坐标矢量，缩放因子 σg 和 σd 决定了相对尺度和每一部件趋近 0 的快慢。

让我们看看这在代码中如何体现，将下面的函数添加到名为 ncut.py 的文件中：

def ncut_graph_matrix(im,sigma_d=1e2,sigma_g=1e-2):
  """ 创建用于归一化割的矩阵，其中sigma_d 和sigma_g 是像素距离和像素相似性的权重参数 """

  m,n = im.shape[:2]
  N = m*n

  # 归一化，并创建RGB 或灰度特征向量
  if len(im.shape)==3:
    for i in range(3):
      im[:,:,i] = im[:,:,i] / im[:,:,i].max()
    vim = im.reshape((-1,3))
  else:
    im = im / im.max()
    vim = im.flatten()

  # x,y 坐标用于距离计算
  xx,yy = meshgrid(range(n),range(m))
  x,y = xx.flatten(),yy.flatten()

  # 创建边线权重矩阵
  W = zeros((N,N),'f')
  for i in range(N):
    for j in range(i,N):
      d = (x[i]-x[j])**2 + (y[i]-y[j])**2
      W[i,j] = W[j,i] = exp(-1.0*sum((vim[i]-vim[j])**2)/sigma_g) * exp(-d/sigma_d)

  return W

该函数获取图像数组，并利用输入的彩色图像 RGB 值或灰度图像的灰度值创建一个特征向量。由于边的权重包含了距离部件，对于每个像素的特征向量，我们利用 meshgrid() 函数来获取 x 和 y 值，然后该函数会在 N 个像素上循环，并在 N×N 归一化割矩阵 W 中填充值。

我们可以顺序分割每个特征向量或获取一些特征向量对它们进行聚类来计算分割结果。这里选择第二种方法，它不需要修改任意分割数也能正常工作。将拉普拉斯矩阵进行特征分解后的前 ndim 个特征向量合并在一起构成矩阵 W，并对这些像素进行聚类。下面函数实现了该聚类过程，可以看到，它和 6.3 节的谱聚类例子几乎是一样的：

from scipy.cluster.vq import *

def cluster(S,k,ndim):
  """ 从相似性矩阵进行谱聚类"""

  # 检查对称性
  if sum(abs(S-S.T)) > 1e-10:
    print 'not symmetric'

  # 创建拉普拉斯矩阵
  rowsum = sum(abs(S),axis=0)
  D = diag(1 / sqrt(rowsum + 1e-6))
  L = dot(D,dot(S,D))

  # 计算L 的特征向量
  U,sigma,V = linalg.svd(L)

  # 从前ndim 个特征向量创建特征向量
  # 堆叠特征向量作为矩阵的列
  features = array(V[:ndim]).T

  # K-means 聚类
  features = whiten(features)
  centroids,distortion = kmeans(features,k)
  code,distance = vq(features,centroids)

  return code,V

这里我们采用基于特征向量图像值的 K-means 聚类算法（细节参见 6.1 节）对像素进行分组。如果你想对该结果进行实验，可以采用任一聚类算法或分组准则。

现在我们可以利用该算法在一些样本图像上进行测试。下面的脚本展示了一个完整的例子：

import ncut
from scipy.misc import imresize

im = array(Image.open('C-uniform03.ppm'))
m,n = im.shape[:2]

# 调整图像的尺寸大小为(wid,wid)
wid = 50
rim = imresize(im,(wid,wid),interp='bilinear')
rim = array(rim,'f')

# 创建归一化割矩阵
A = ncut.ncut_graph_matrix(rim,sigma_d=1,sigma_g=1e-2)

# 聚类
code,V = ncut.cluster(A,k=3,ndim=3)

# 变换到原来的图像大小
codeim = imresize(code.reshape(wid,wid),(m,n),interp='nearest')

# 绘制分割结果
figure()
imshow(codeim)
gray()
show()

因为 Numpy 的 linanlg.svd() 函数在处理大型矩阵时的计算速度并不够快（有时对于太大的矩阵甚至会给出不准确的结果），所以这里我们重新设定图像为一固定尺寸（在该例中为 50×50），以便更快地计算特征向量。在重新设定图像大小的时候我们采用了双线性插值法；因为不想插入类标记，所以在重新调整分割结果标记图像的尺寸时我们采用近邻插值法。注意，重新调整到原图像尺寸大小后第一次利用 reshape 方法将一维矩阵变换为（wid，wid）二维数组。

在该例中，我们用到了静态手势（Static Hand Posture）数据库（详见 8.1 节）的某幅手势图像，并且聚类数 k 设置为 3。分割结果如图 9-5 所示，取前 4 个特征向量。

图 9-5：利用归一化分割算法分割图像：原始图像和三类分割结果（上）；图相似矩阵的前 4 个特征向量（下）

例子中的特征向量以数组 V 返回，可以通过下面的代码可视化为图像：

imshow(imresize(V[i].reshape(wid,wid),(m,n),interp=’bilinear’))

它以原图像尺寸将特征向量 i 显示为一幅图像。

图 9-6 是一些利用上面脚本进行分割的示例。飞机图像来源于 Caltech 101 数据库中的飞机类。在这些例子中，我们保持参数 σg 和 σd 与上面一致，改变这两个参数的值可以得到更平滑、更规则的结果和不同的特征向量图像。我们将这个实验留给你完成。

图 9-6：利用规范化分割算法对两类图像分割示例：原始图像（左边），分割结果（右边）

注意，即使对于这些相对简单的例子，对图像进行阈值处理不会给出相同的结果，对 RGB 值或灰度值进行聚类也一样；原因是它们都没有考虑像素的近邻。

9.3　变分法

在本书中有很多利用最小化代价函数或能量函数来求解计算机视觉问题的例子，如前面章节中在图中最小化割；我们同样可以看到诸如 ROF 降噪、K-means 和 SVM 的例子，这些都是优化问题。

当优化的对象是函数时，该问题称为变分问题，解决这类问题的算法称为变分法。我们看一个简单而有效的变分模型。

Chan-Vese 分割模型 [6] 对于待分割图像区域假定一个分片常数图像模型。这里我们集中关注两个区域的情形，比如前景和背景，不过这个模型也可以拓展到多区域，比如文献 [38] 中的例子。我们接下来对该模型进行描述。

如果我们用一组曲线 Г 将图像分离成两个区域 Ω₁ 和 Ω₂，如图 9-7 所示，分割是通过最小化 Chan-Vese 模型能量函数给出的：

该能量函数用来度量与内部平均灰度常数 c₁ 和外部平均灰度常数 c₂ 的偏差。这里这两个积分是对各自区域的积分，分离曲线 Г 的长度用以选择更平滑的方案。

图 9-7：分片常数 Chan-Vese 分割模型

由分片常数图像 U=χ₁c₁+χ₂c₂，我们可以将上式重写为：

χ₁ 和 χ₂ 是两区域 Ω₁、Ω₂ 的特征（指示）函数 5。该变换意义重大，要求一些并不需要理解的复杂数学运算，并且已超出本书的讲述范围。

5区域内特征函数为 1，区域外特征函数为 0。

如果用 λ|c₁-c₂| 替换 ROF 方程 (1.1) 中的 λ，则该方程与 ROF 方程 (1.1) 的形式一致；唯一的区别在于，我们 Chan-Vese 模型中在寻找一幅具有分片常数的图像 U。对 ROF 方案进行阈值处理可以给出一个好的极小值，感兴趣的读者可以在文献 [8] 中查阅细节。

最小化 Chan-Vese 模型现在转变成为设定阈值的 ROF 降噪问题：

import rof

im = array(Image.open('ceramic-houses_t0.png').convert("L"))
U,T = rof.denoise(im,im,tolerance=0.001)
t = 0.4 # 阈值

import scipy.misc
scipy.misc.imsave('result.pdf',U < t*U.max())

在该示例中，为确保得到足够的迭代次数，我们调低 ROF 迭代终止时的容忍阈值。图 9-8 显示了两幅难以分割图像的分割结果。

图 9-8：利用 ROF 降噪最小化 Chan-Vese 模型的一些图像分割示例：（a）为原始图像；（b）为经过 ROF 降噪后的图像；（c）为最终分割结果

练习

通过减少边的数目可以加速图割优化计算；文献 [16] 的 4.2 节描述了图的构建过程。对其进行实验并在图不同的大小下度量其差异，以及对分割时间的影响，并与我们之前用到的较简单图结构进行比较。

创建一个用户接口或模拟用户选择区域来进行图割，然后通过给一些较大的值设置权重尝试“硬编码”背景和前景。

在图割时将 RGB 特征向量换成别的特征描述子，你能以此改进分割结果吗？

用图割方法将当前分割结果用于训练下一个新的前景和背景模型，实现一种迭代分割方法。这样能够提高分割质量吗？

微软研究院 Grab Cut 数据集包含真实的分割图。实现一个可以度量分割误差的函数，并对不同的设置以及前面练习中的一些想法进行评估。

改变归一化的割边的权重参数，观察其如何影响特征向量图像，以及分割的结果。

在第一次归一化割的特征向量上计算图像梯度，合并这些梯度图像来检测图像中物体的轮廓。

在 Chan-Vese 分割算法中，对去噪后的图像在阈值上进行线性搜索。对于每个阈值，存储能量 E(Г)，并选择具有最小值的分割结果。

第 10 章　OpenCV

本章概述如何通过 Python 接口使用流行的计算机视觉库 OpenCV。OpenCV 是一个 C++ 库，用于（实时）处理计算视觉问题。实时处理计算机视觉的 C++ 库，最初由英特尔公司开发，现由 Willow Garage 维护。OpenCV 是在 BSD 许可下发布的开源库，这意味着它对于学术研究和商业应用是免费的。OpenCV 2.0 版本对于 Python 的支持已经得到了极大的改善。下面，我们会讲解一些基本的例子并深入了解视频与跟踪。

10.1　OpenCV的Python接口

OpenCV 是一个 C++ 库，它包含了计算机视觉领域的很多模块。除了 C++ 和 C，Python 作为一种简洁的脚本语言，在 C++ 代码基础上的 Python 接口得到了越来越广泛的支持。目前，OpenCV 的 Python 接口仍在发展，不过并不是所有的 OpenCV 组件都提供了相应的 Python 接口，此处还有很多函数没有文档说明。因为该接口背后有一个很活跃的开发社区，所以这种现状很有可能得到改变。Python 接口文档说明见 http://opencv.willowgarage.com/documentation/python/index.html，安装说明参阅附录 A。

OpenCV 2.3.1 版本实际上提供了两个 Python 接口。旧的 cv 模块使用 OpenCV 内部数据类型，并且从 NumPy 使用起来可能需要一些技巧。新的 cv2 模块用到了 NumPy 数 组，并且使用起来更加直观 1，可以通过以下方式导入新的 cv2 模块：

1这两个模块的名称和位置可能会随时间改变。改变情况参阅在线文档。

import cv2

而对于旧的 cv 模块可以通过以下方式导入：

import cv2.cv

本章中我们将关注 cv2 模块；注意这些名字的衍变、函数名称的改变以及在后来版本中的定义。现在，OpenCV 和 Python 接口在飞速发展中。

10.2　OpenCV基础知识

OpenCV 自带读取、写入图像函数以及矩阵操作和数学库。关于 OpenCV 的细节，有一本很好的书 [3]（只有 C++ 版）。我们现在来看一些基本的组件及其使用方法。

10.2.1　读取和写入图像

下面这个简短的例子会载入一张图像，打印出图像大小，对图像进行转换并保存为 .png 格式：

import cv2

# 读取图像
im = cv2.imread('empire.jpg')
h,w = im.shape[:2]
print h,w

# 保存图像
cv2.imwrite('result.png',im)

函数 imread() 返回图像为一个标准的 NumPy 数组，并且该函数能够处理很多不同格式的图像。如果你愿意，可以将该函数作为 PIL 模块读取图像的备选方案。函数 imwrite() 会根据文件后缀自动转换图像。

10.2.2　颜色空间

在 OpenCV 中，图像不是按传统的 RGB 颜色通道，而是按 BGR 顺序（即 RGB 的倒序）存储的。读取图像时默认的是 BGR，但是还有一些可用的转换函数。颜色空间的转换可以用函数 cvtColor() 来实现。例如，可以通过下面的方式将原图像转换成灰度图像：

im = cv2.imread('empire.jpg')
# 创建灰度图像
gray = cv2.cvtColor(im,cv2.COLOR_BGR2GRAY)

在读取原图像之后，紧接其后的是 OpenCV 颜色转换代码，其中最有用的一些转换代码如下：

cv2.COLOR_BGR2GRAY

cv2.COLOR_BGR2RGB

cv2.COLOR_GRAY2BGR

上面每个转换代码中，转换后的图像颜色通道数与对应的转换代码相匹配，比如对于灰度图像只有一个通道，对于 RGB 和 BGR 图像则有三个通道。最后的 cv2.COLOR_GRAY2BGR 将灰度图像转换成 BGR 彩色图像；如果你想在图像上绘制或覆盖有色彩的对象，CV2.COLOR_GRAY2BGR 是非常有用的，我们会在后面的例子中用到它。

10.2.3　显示图像及结果

我们来看一些用 OpenCV 处理图像的例子，以及怎样利用 OpenCV 绘制功能和窗口管理功能来显示结果。

第一个例子是从文件中读取一幅图像，并创建一个整数图像表示：

import cv2

# 读取图像
im = cv2.imread('fisherman.jpg')
gray = cv2.cvtColor(im,cv2.COLOR_BGR2GRAY)

# 计算积分图像
intim = cv2.integral(gray)

# 归一化并保存
intim = (255.0*intim) / intim.max()
cv2.imwrite('result.jpg',intim)

读取图像后，将其转化为灰度图像，函数 integral() 创建一幅图像，该图像的每个像素值是原图上方和左边强度值相加后的结果；这对于快速评估特征是一个非常有用的技巧。OpenCV 的 CascadeClassifier 用到了积分图像，立足于 Viola 和 Jones 在文献 [39] 中引入的框架。在保存图像前，通过除以图像中的像素最大值将其归一化到 0 至 255 之间。图 10-1 显示了示例图像结果。

第二个例子从一个种子像素进行泛洪填充：

import cv2

# 读取图像
filename = 'fisherman.jpg'
im = cv2.imread(filename)
h,w = im.shape[:2]

# 泛洪填充
diff = (6,6,6)
mask = zeros((h+2,w+2),uint8)
cv2.floodfill(im,mask,(10,10), (255,255,0),diff,diff)

# 在OpenCV 窗口中显示结果
cv2.imshow('flood fill',im)
cv2.waitKey()

# 保存结果
cv2.imwrite('result.jpg',im)

图 10-1：用 OpenCV 的 integral() 函数计算积分图像

在该例中，对图像应用泛洪填充并在 OpenCV 窗口中显示结果。waitKey() 函数一直处于暂停状态，直到有按键按下，此时窗口才会自动关闭。这里的 floodfill() 函数获取（灰度或彩色）图像、一个掩膜（非零像素区域表明该区域不会被填充）、一个种子像素以及新的颜色值来代替下限和上限阈值差的泛洪像素。泛洪填充以种子像素为起始，只要能在阈值的差异范围内添加新的像素，泛洪填充就会持续扩展。不同的阈值差异由元组 (R,G,B) 给出，结果如图 10-2 所示。

图 10-2：彩色图像泛洪填充。在左上角用单个种子进行泛洪填充，右图高亮区域标出了所有填充了的像素

作为最后一个例子，我们来看 SURF 特征的提取，SURF 特征是 SIFT 特征的一个更快特征提取版，文献 [1] 引入。这里，我们也会展示怎样使用一些基本的 OpenCV 绘制命令：

import cv2

# 读取图像
im = cv2.imread('empire.jpg')

# 下采样
im_lowres = cv2.pyrDown(im)

# 变换成灰度图像
gray = cv2.cvtColor(im_lowres,cv2.COLOR_RGB2GRAY)

# 检测特征点
s = cv2.SURF()
mask = uint8(ones(gray.shape))
keypoints = s.detect(gray,mask)

# 显示结果及特征点
vis = cv2.cvtColor(gray,cv2.COLOR_GRAY2BGR)

for k in keypoints[::10]:
  cv2.circle(vis,(int(k.pt[0]),int(k.pt[1])),2,(0,255,0),-1)
  cv2.circle(vis,(int(k.pt[0]),int(k.pt[1])),int(k.size),(0,255,0),2)

cv2.imshow('local descriptors',vis)
cv2.waitKey()

读取图像后，如果没有给定新尺寸，则用 pyrDown() 进行下采样，创建一个尺寸为原图像大小一半的新图像，然后将该图像转换为灰度图像，并传递到一个 SURF 关键点检测对象；掩膜决定了在哪些区域应用关键点检测器。在画出检测结果时，我们将灰度图像转换成彩色图像，并用绿色通道画出检测到的关键点。我们在每到第十个关键点时循环一次，并在中心画一个圆环，每一个圆环显示出关键点的尺度（大小）。绘图函数 circle() 获取一幅图像、图像坐标（仅整数坐标）元组、半径、绘图的颜色元组以及线条粗细（-1 是实线圆环）。图 10-3 显示了提取出来的 SURF 特征。

图 10-3：用 OpenCV 提取 SURF 特征并画出提取出来的 SURF 特征

10.3　处理视频

单纯使用 Python 来处理视频有些困难，因为需要考虑速度、编解码器、摄像机、操作系统和文件格式。目前还没有针对 Python 的视频库，使用 OpenCV 的 Python 接口是唯一不错的选择。在本节中，我们来看一些处理视频的基本示例。

10.3.1　视频输入

OpenCV 能够很好地支持从摄像头读取视频。下面给出了一个捕获视频帧并在 OpenCV 窗口中显示这些视频帧的完整例子：

import cv2

# 设置视频捕获
cap = cv2.VideoCapture(0)

while True:
  ret,im = cap.read()
  cv2.imshow('video test',im)
  key = cv2.waitKey(10)
  if key == 27:
    break
  if key == ord(' '):
    cv2.imwrite('vid_result.jpg',im)

捕获对象 VideoCapture 从摄像头或文件捕获视频。我们通过一个整数进行初始化，该整数为视频设备的 id；如果仅有一个摄像头与计算机相连接，那么该摄像头的 id 为 0。read() 方法解码并返回下一视频帧，第一个变量 ret 是一个判断视频帧是否成功读入的标志，第二个变量则是实际读入的图像数组。函数 waitKey() 等待用户按键：如果按下的是 Esc 键（ASCII 码是 27）键，则退出应用；如果按下的是空格键，就保存该视频帧。

拓展上面的例子，将摄像头捕获的数据作为输入，并在 OpenCV 窗口中实时显示经模糊的（彩色）图像，我们只需对上面的例子做简单的修改：

import cv2

# 设置视频捕获
cap = cv2.VideoCapture(0)

# 获取视频帧，应用高斯平滑，显示结果
while True:
  ret,im = cap.read()
  blur = cv2.GaussianBlur(im,(0,0),5)
  cv2.imshow('camera blur',blur)
  if cv2.waitKey(10) == 27:
    break

每一视频帧都会被传递给 GaussianBlur() 函数，该函数会用高斯滤波器对传入的该帧图像进行滤波。这里，我们传递的是彩色图像，所以 Gaussian Blur() 函数会录入对彩色图像的每一个通道单独进行模糊。该函数需要为高斯函数设定滤波器尺寸（保存在元组中）及标准差；在本例中标准差设为 5。如果该滤波器尺寸设为 0，则它由标准差自动决定，显示出的结果与图 10-4 相似。

图 10-4：作者撰写本章时的一幅经过模糊的视频截图

以同样的方式从文件读取视频，不过我们调用 VideoCapture() 获取视频时是以文件名作为输入的：

capture = cv2.VideoCapture('filename')

10.3.2　将视频读取到NumPy数组中

使用 OpenCV 可以从一个文件读取视频帧，并将其转换成 NumPy 数组。下面是一个从摄像头捕获视频并将视频帧存储在一个 NumPy 数组中的例子：

import cv2

# 设置视频捕获
cap = cv2.VideoCapture(0)

frames = []
# 获取帧，存储到数组中
while True:
  ret,im = cap.read()
  cv2.imshow('video',im)
  frames.append(im)
  if cv2.waitKey(10) == 27:
    break
frames = array(frames)

# 检查尺寸
print im.shape
print frames.shape

上述代码将每一视频帧数组添加到列表末，直到捕获结束。最终得到的数组会有帧数、帧高、帧宽及颜色通道数（3 个），打印出的结果如下：

(480, 640, 3)
(40, 480, 640, 3)

这里共记录了 40 帧。类似上面将视频数据存储在数组中对于视频处理是非常有帮助的，比如计算帧间差异以及跟踪。

10.4　跟踪

跟踪是在图像序列或视频里对其中的目标进行跟踪的过程。

10.4.1　光流

光流是目标、场景或摄像机在连续两帧图像间运动时造成的目标的运动。它是图像在平移过程中的二维矢量场。作为一种经典并深入研究了的方法，它在诸如视频压缩、运动估计、目标跟踪和图像分割等计算机视觉中得到了广泛的应用。

光流法主要依赖于三个假设。

亮度恒定　图像中目标的像素强度在连续帧之间不会发生变化。

时间规律　相邻帧之间的时间足够短，以至于在考虑运行变化时可以忽略它们之间的差异。该假设用于导出下面的核心方程。

空间一致性　相邻像素具有相似的运动。

在很多情况下这些假设并不成立，但是对于相邻帧间的小运动以及短时间跳跃，它还是一个非常好的模型。假设一个目标像素在 t 时刻亮度为 I(x,y,t)，在 t+δt 时刻运动 [δx,δy] 后与 t 时刻具有相同的亮度，即 I(x,y,t)=I(x+δx, y+δy, t+δt).对该约束用泰勒公式进行一阶展开并关于 t 求偏导可以得到光流方程：

v=[u,v] 是运动矢量，It 是时间偏导。对于图像中那些单个的点，该方程是线性方程组。由于 v 包含两个未知变量，所以该方程是不可解的。通过强制加入空间一致性约束，则有可能获得该方程的解。在下面的 Lucas-Kanade 算法中，我们将看到怎样利用该假设。

OpenCV 包含了一些光流实现：用了块匹配的 CalcOpticalFlowBM()；用了文献 [15]（这些只存在于旧的 cv 模块）的 CalcOpticalFlowHS()；文献 [19] 中的空间金字塔 Lucas-Kanade 算法 calcOpticalFlowPyrLK()；以及基于文献 [10] 的 calcOpticalFlowFarneback()。最后一种被视为获取密集流场的最好方法之一。让我们看一个利用 calcOpticalFlowFarneback() 在视频中寻找运动矢量的例子（Lucas-Kanade 光流法在下一节讲述）。

运行下面的脚本：

import cv2

def draw_flow(im,flow,step=16):
  """ 在间隔分开的像素采样点处绘制光流"""

  h,w = im.shape[:2]
  y,x = mgrid[step/2:h:step,step/2:w:step].reshape(2,-1)
  fx,fy = flow[y,x].T

  # 创建线的终点
  lines = vstack([x,y,x+fx,y+fy]).T.reshape(-1,2,2)
  lines = int32(lines)

  # 创建图像并绘制
  vis = cv2.cvtColor(im,cv2.COLOR_GRAY2BGR)
  for (x1,y1),(x2,y2) in lines:
    cv2.line(vis,(x1,y1),(x2,y2),(0,255,0),1)
    cv2.circle(vis,(x1,y1),1,(0,255,0), -1)
  return vis

# 设置视频捕获
cap = cv2.VideoCapture(0)

ret,im = cap.read()
prev_gray = cv2.cvtColor(im,cv2.COLOR_BGR2GRAY)

while True:
  # 获取灰度图像
  ret,im = cap.read()
  gray = cv2.cvtColor(im,cv2.COLOR_BGR2GRAY)

  # 计算流
  flow = cv2.calcOpticalFlowFarneback(prev_gray,gray,None,0.5,3,15,3,5,1.2,0)
  prev_gray = gray

  # 画出流矢量
  cv2.imshow('Optical flow',draw_flow(gray,flow))
  if cv2.waitKey(10) == 27:
    break

在这个例子中，利用摄像头捕获图像，并对每个连续图像对进行光流估计。由 calcOpticalFlowFarneback() 返回的运动光流矢量保存在双通道图像变量 flow 中。除了需要获取需要获取前一帧和当前帧，该函数还需要一系列参数。如果有兴趣可以查找相关的文献。辅助函数 draw_flow() 会在图像均匀间隔的点处绘制光流矢量，它利用 OpenCV 的绘图函数 line() 和 circle()，并用变量 step 控制流样本的间距。最终的结果如图 10-5 所示：圆环网格表示流样本的位置，带有线条的流矢量显示了每个样本点是怎样运动的。

图 10-5：在书本平移和头部转动的视频上展示光流矢量（每隔 15 个像素采样一次）

10.4.2　Lucas-Kanade算法

跟踪最基本的形式是跟随感兴趣点，比如角点。对此，一次流行的算法是 Lucas-Kanade 跟踪算法，它利用了稀疏光流算法。

Lucas-Kanade 跟踪算法可以应用于任何一种特征，不过通常使用一些角点，比如 2.1 节的 Harris 角点。goodFeaturesToTrack() 函数采用 Shi 和 Tomasi 在文献 [33] 中设计的算法检测角点；角点是结构张量（Harris 矩阵）中有两个较大特征值的那些点，且更小的特征值要大于某个阈值。

如果基于每一个像素考虑，该光流方程组是欠定方程，即每个方程中含很多未知变量。利用相邻像素有相同运动这一假设，对于 n 个相邻像素，可以将这些方程写成如下系统方程：

该系统方程的优势是，现在方程的数目多于未知变量，并且可以用最小二乘法解出该系统方程。对于周围像素的贡献可以进行加权处理，使越远的像素影响越小；高斯权重是一种最常见的选择。将上面的矩阵变换成方程（2.2）的结构张量形式，可以得出以下关系：

\overline {\boldsymbol{M}_{I}}\boldsymbol{v}=-\begin{bmatrix}I_t(\text{x}_1)\\I_t(\text{x}_2)\\\vdots\\I_t(\text{x}_n)\end{bmatrix}，或简记为 \boldsymbol{Av}=\boldsymbol{b}

该超定方程组可以用最小二乘法求解，得出运动矢量：

v 只有在 ATA 可逆时才是可解的，如果应用在 Harris 角点或 Shi-Tomasi 的“利于跟踪的好的特征 features to track”上，ATA 是可逆的。这就是 Lucas-Kanade 跟踪算法运行矢量怎样计算出来的全过程。

标准的 Lucas-Kanade 跟踪适用于小位移；为了能够处理较大位移，需要采用分层的方法。在该情形下，光流可以通过对图像由粗到精采样计算得到。这就是 OpenCV 函数 calcOpticalFlowPyrLK() 要做的事。

这些 Lucas-Kanade 函数包含在 OpenCV 中，让我们看看怎样利用这些函数建立一个 Python 跟踪类。创建名为 lktrack.py 的文件，向其添加下面的类和构造函数：

import cv2

# 一些常数及默认参数
lk_params = dict(winSize=(15,15),maxLevel=2,
        criteria=(cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT,10,0.03))

subpix_params = dict(zeroZone=(-1,-1),winSize=(10,10),
           criteria = (cv2.TERM_CRITERIA_COUNT | cv2.TERM_CRITERIA_EPS,20,0.03))

feature_params = dict(maxCorners=500,qualityLevel=0.01,minDistance=10)

class LKTracker(object):
  """ 用金字塔光流Lucas-Kanade 跟踪类"""

def __init__(self,imnames):
  """ 使用图像名称列表初始化"""

  self.imnames = imnames
  self.features = []
  self.tracks = []
  self.current_frame = 0

用一个文件名列表对跟踪对象进行初始化，变量 features 和 tracks 分别保存角点和对这些角点进行跟踪的位置，同时，我们也利用一个变量对当前帧进行跟踪。我们定义了三个字典变量用于特征提取、跟踪、和亚像素特征点的提炼。

在开始检测角点时，我们需要载入实际图像，并转换成灰度图像，提取“利用跟踪的好的特征”点。OpenCV 函数 goodFeaturesToTrack() 方法可以完成这一主要工作。将下面的 detect_points() 添加到 LKTracker 类中：

def detect_points(self):
  """ 利用子像素精确度在当前帧中检测“利于跟踪的好的特征”( 角点) """

  # 载入图像并创建灰度图像
  self.image = cv2.imread(self.imnames[self.current_frame])
  self.gray = cv2.cvtColor(self.image,cv2.COLOR_BGR2GRAY)

  # 搜索好的特征点
  features = cv2.goodFeaturesToTrack(self.gray, **feature_params)

  # 提炼角点位置
  cv2.cornerSubPix(self.gray,features, **subpix_params)

  self.features = features
  self.tracks = [[p] for p in features.reshape((-1,2))]

  self.prev_gray = self.gray

上述代码用 cornerSubPix() 提炼角点位置，并保存在成员变量 features 和 tracks 中。需要注意的是，运行该函数会清除跟踪历史。

现在我们已经可以检测这些角点，接下来还需要对其进行跟踪。首先我们需要获得下一帧图像，然后应用 OpenCV 函数 calcOpticalFlowPyrLK() 找出这些点运动到哪里了，最后清除这些包含跟踪点的列表。下面的 track_points() 方法可以完成该过程：

def track_points(self):
  """ 跟踪检测到的特征"""

  if self.features != []:
    self.step() # 移到下一帧

    # 载入图像并创建灰度图像
    self.image = cv2.imread(self.imnames[self.current_frame])
    self.gray = cv2.cvtColor(self.image,cv2.COLOR_BGR2GRAY)

    #reshape() 操作，以适应输入格式
    tmp = float32(self.features).reshape(-1, 1, 2)

    # 计算光流
    features,status,track_error = cv2.calcOpticalFlowPyrLK(self.prev_gray,
                               self.gray,tmp,None,**lk_params)
    # 去除丢失的点
    self.features = [p for (st,p) in zip(status,features) if st]

    # 从丢失的点清楚跟踪轨迹
    features = array(features).reshape((-1,2))
    for i,f in enumerate(features):
      self.tracks[i].append(f)
    ndx = [i for (i,st) in enumerate(status) if not st]
    ndx.reverse()# 从后面移除
    for i in ndx:
      self.tracks.pop(i)

    self.prev_gray = self.gray

下面定义一个辅助函数 step()，用于移动到下一视频帧：

def step(self,framenbr=None):
  """ 移到下一帧。如果没有给定参数，直接移到下一帧"""

  if framenbr is None:
    self.current_frame = (self.current_frame + 1) % len(self.imnames)
  else:
    self.current_frame = framenbr % len(self.imnames)

该方法会跳转到一个给定的视频帧，如果没有给定参数则直接跳转到下一帧。

最后，我们还希望能够用 OpenCV 窗口和绘图函数画出最终的跟踪结果。添加 draw() 方法到 LKTracker 类：

def draw(self):
  """ 用OpenCV 自带的画图函数画出当前图像及跟踪点，按任意键关闭窗口"""

  # 用绿色圆圈画出跟踪点
  for point in self.features:
    cv2.circle(self.image,(int(point[0][0]),int(point[0][1])),3,(0,255,0),-1)

  cv2.imshow('LKtrack',self.image)
  cv2.waitKey()

现在，我们用 OpenCV 函数实现了一个完整独立的跟踪系统。

使用跟踪器

我们将该跟踪类用于真实的场景中。下面的脚本初始化一个跟踪对象，对视频序列进行角点检测、跟踪，并画出跟踪结果：

import lktrack

imnames = ['bt.003.pgm', 'bt.002.pgm', 'bt.001.pgm', 'bt.000.pgm']

# 创建跟踪对象
lkt = lktrack.LKTracker(imnames)

# 在第一帧进行检测，跟踪剩下的帧
lkt.detect_points()
lkt.draw()
for i in range(len(imnames)-1):
  lkt.track_points()
  lkt.draw()

每次画出一帧，并显示当前跟踪到的点，按任意键会转移到序列的下一帧。图 10-6 显示了牛津 corridor 序列（牛津对多视图数据集中的一个序列，参见 http://www.robots.ox.ac.uk/~vgg/data/data-mview.html）的前 4 幅图像的跟踪结果。

图 10-6：通过 LKTrack 类利用 Lucas-Kanade 算法进行跟踪

使用发生器

将下面的方法添加到 LKTracker 类：

def track(self):
  """ 发生器，用于遍历整个序列"""

  for i in range(len(self.imnames)):
    if self.features == []:
      self.detect_points()
    else:
      self.track_points()
  # 创建一份RGB 副本
  f = array(self.features).reshape(-1,2)
  im = cv2.cvtColor(self.image,cv2.COLOR_BGR2RGB)
  yield im,f

上面的方法创建一个发生器，可以使遍历整个序列并将获得的跟踪点和这些图像以 RGB 数组保存，以方便画出跟踪结果。将它用于经典的牛津“dinosaur”序列（也来源于上面提到的多视图数据集），并画出这些点及这些点的跟踪结果，代码如下：

import lktrack

imnames = ['viff.000.ppm', 'viff.001.ppm',
    'viff.002.ppm', 'viff.003.ppm', 'viff.004.ppm']

# 用LKTracker 发生器进行跟踪
lkt = lktrack.LKTracker(imnames)
for im,ft in lkt.track():
  print 'tracking %d features' % len(ft)

# 画出轨迹
figure()
imshow(im)
for p in ft:
  plot(p[0],p[1],'bo')
for t in lkt.tracks:
  plot([p[0] for p in t],[p[1] for p in t])
axis('off')
show()

该发生器使前面定义的跟踪类的使用变得非常容易，并且完全向用户隐藏了 OpenCV 里的函数。该示例生成的结果如图 10-6 右下图和图 10-7 所示。

图 10-7：用 Lucas-Kanade 跟踪算法在转盘序列上跟踪并画出跟踪点的轨迹

10.5　更多示例

OpenCV 自带很多关于如何使用 Python 接口的有用示例。这些示例在子目录 samples/python2/ 中，用这些例子来熟悉 OpenCV 是一种非常好的方式。这里选择了一些来说明 OpenCV 的一些其他功能。

10.5.1　图像修复

对图像丢失或损坏的部分进行重建的过程叫做修复，既包括以复原为目的的对图像丢失数据或损坏部分进行恢复的算法，也包括在照片编辑应用程序中去除红眼或物体的算法。典型的例子是，图像的一个区域标记为“破损”，并需要利用余下的数据对该区域进行填补。

试着运行下面的命令：

$ python inpaint.py empire.jpg

运行上面的命令会打开一个交互窗口，在该窗口中你可以画一些需要修复的区域。最终修复的结果会在一个单独的窗口中显示出来，图 10-8 展示了一个示例。

图 10-8：用 OpenCV 进行图像修复的示例。左图显示了由用户标记的“破损”区域。右图显示了经过图像修复后的结果

10.5.2　利用分水岭变换进行分割

分水岭是一种可以用于分割（见图 10-9）的图像处理技术。图像可以看成是一幅有很多种子区域“淹没”后形成的拓扑地貌。由于梯度幅值图像在突出的边缘有脊，而且分割通常在这些边缘处停止，所以通常会用到梯度幅值图像。

图 10-9：用分水岭变换分割图像。左图是画有种子区域的输入图像，右图显示了分割结果，分割区域用不同的颜色覆盖

OpenCV 中的分水岭变换使用 Meyer [22] 的算法，可以使用下面的命令：

$ python watershed.py empire.jpg

该命令会打开一个交互窗口，你可以在该窗口中画一些种子区域作为输入。图 10-9 右图显示了用分水岭变换进行分割后的结果，在变换后的灰度图像中，不同颜色代表分割覆盖的区域。

10.5.3　利用霍夫变换检测直线

霍夫变换（http://en.wikipedia.org/wiki/Hough_transform）是一种用于在图像中寻找各种形状的方法，原理是在参数空间中使用投票机制。霍夫变换常用于在图像中寻找直线结构。在该情况下，可以在二维直线参数空间对相同的直线参数进行投票，将边缘和线段组合在一起。

OpenCV 可以利用该方法进行直线检测 2，运行下面的命令：

2该示例当前在 /sample/python 文件夹中。

$ python houghlines.py empire.jpg

它会给出图 10-10 中的两个窗口。第一个窗口显示了原图像进行灰度变换后的灰度图像，第二个显示了检测到的直线边缘，这些检测出来的直线在参数空间获得的投票最多。注意，这些直线通常是无限长的；如果你想在图像中找到线段的端点，可以使用边缘映射找到这些端点。

图 10-10：利用霍夫变换检测直线。左图是原图像经变换后的灰度图像，右图显示了检测到的直线

练习

用光流建立一个简单的手势识别系统。例如，你可以在绘图函数中对流采样，并将这些样本矢量作为输入。

OpenCV 中有两个扭曲函数，cv2.warpAffine() 和 cv2.warpPerspective()。试着将它们用于第 3 章的一些例子中。

在图 10-7 的那些牛津“dinosaur”图像上用泛洪填充函数做背景减除，创建新的图像，将恐龙放在不同的颜色背景中或不同的图像中。

OpenCV 有一个函数 cv2.findChessboardCorners()，它能够自动找到棋盘格的角点。使用此函数及 cv2.calibrateCamera() 函数来完成相机的校正。

如果你有两台摄像机，将它们安装在立体平台上，并以不同视频设备 id 用 cv2.VideoCapture() 捕获成对的立体图像。两台摄像机对应 id 分别为 0 和 1 开始，计算不同场景的景深图。

在 8.4 节数独 OCR 分类问题中使用 cv2.HuMoments() 提取的 Hu 不变矩作为特征，看看分类的效果如何。

OpenCV 中有一个 Grab Cut 分割算法，在 9.1 节微软研究院 Grab Cut 数据集上用 cv2.grabCut() 函数进行图像分割。与我们在例子中用到的低分辨率分割相比，你应该会获得更好的分割结果。

修改 Lucas-Kanade 跟踪类，使其能够将一个视频文件作为输入，并写一个脚本，在帧与帧之间进行点跟踪，在每隔 k 帧时检测新点。

附录 A　安装软件包

下面是本书中用到软件包的简要安装说明，基于撰写这本书时的最新版本。因为情况会因时间发生变化（网址变化！），所以如果以下说明过时，请检查各项目网站寻求帮助。

除了对各软件具体的说明，Python 的 easy_install 往往可以在大多数平台上使用。如果你遇到安装说明问题，easy_install 值得一试，详见 http://packages.python.org/distribute/easy_install.html。

A.1　NumPy和SciPy

安装 NumPy 和 SciPy 有一点不同，这取决于你的操作系统。请按照下面相应的说明安装，而大多数平台上的现行版本是 2.0（NumPy）和 0.11（SciPy）。目前可以在所有主要平台上运行的一个程序包是 Enthought 的 EPD Free，它是商业 Enthought 发 行版的一个免费轻量版本，参见 http://enthought.com/products/epd_free.php。

A.1.1　Windows

安装 NumPy 和 SciPy 最简单的方法是在 http://www.scipy.org/Download 下载并安装二进制版本。

A.1.2　Mac OS X

最新版本的 Mac OS X（10.7.0 [Lion] 及以上）预装了 NumPy。

安装 NumPy 和 SciPy 到 Mac OS X 的一个简单方法是使用 SUPERPACK（https://github.com/fonnesbeck/ScipySuperpack）；这种方法也适用于 Matplotlib。

另一种方法是使用包管理系统 MacPorts（http://www.macports.org/）。除了下面的方法，这同样适用于 Matplotlib。

如果这些都不成功，该项目网页还列出了其他方法（http://scipy.org/）。

A.1.3　Linux

安装要求你有计算机的管理员权限。一些发行版预装了 NumPy，另一些则没有。NumPy 和 SciPy 都易于通过安装包内置的处理程序安装（例如 Ubuntu 的 Synaptic）。除了下面的方法，你也可以使用包处理程序安装 Matplotlib。

A.2　Matplotlib

这里是 Matplotlib 的安装说明，以防你在 NumPy/SciPy 的安装中没有安装 Matplotlib。Matplotlib 可以从 http://matplotlib.sourceforge.net/ 免费获取。点击 download（下载）链接，为你的系统和 Python 发行版下载最新版本的安装程序。目前最新的版本是 1.1.0。

你也可以下载源代码并解压，从命令行运行：

$ python setup.py install

应该一切正常。不同系统的一般安装提示可以参阅 http://matplotlib.sourceforge.net/users/installing.html，上述安装过程应该适用于大多数平台和 Python 版本。

A.3　PIL

PIL，即 Python 图像库，可在 http://www.pythonware.com/products/pil/ 获取。最新免费版本是 1.1.7。下载源代码包，解压。在解压后的文件夹中，从命令行运行：

$ python setup.py install

如果你想使用 PIL 保存图像，需要有 JPEG（libjpeg）和 PNG（zlib）支持。如果你遇到任何问题，请参阅 README 文件或 PIL 网站。

A.4　LibSVM

最新版本是 3.1（2011 年 4 月发布）。请从 LibSVM 网站（http://www.csie.ntu.edu.tw/~cjlin/libsvm/）下载 zip 文件，并解压（将创建目录 libsvm-3.1）。从终端进入该目录，输入 make：

$ cd libsvm-3.0
$ make

然后进入 python 目录，同样输入 make：

$ cd python/
$ make

上面就是你所要做的。为了测试安装是否成功，在命令行启动 Python，尝试：

import svm

作者为使用 LibSVM [7] 撰写了实用指南。对于初学者来讲，这是一个很好的开始。

A.5　OpenCV

安装 OpenCV 有些不同，这取决于你的操作系统。按照下面相应的说明进行安装。

为检查安装是否成功，启动 Python 并尝试 http://opencv.willowgarage.com/documentation/python/cookbook.html 上的示例。对于如何使用 OpenCV 与 Python ，在线 OpenCV 的 Python 参考指南提供了更多的例子和细节，参见 http://opencv.willowgarage.com/documentation/python/index.html。

A.5.1　Windows 和 Unix

在 SourceForge 库里，有 Windows 和 Unix 的安装程序，参见 http://sourceforge.net/projects/opencvlibrary/。

A.5.2　Mac OS X

Mac OS X 的支持有所欠缺，但在不断改进。如 OpenCV 的 wiki 描述（http://opencv.willowgarage.com/wiki/InstallGuide），有几种方法可以从源代码进行安装，如果你使用 MacPorts 软件包管理器来安装 Python、Numpy 和 SciPy 或 Matplotlib，它会是一个不错的选择，可以这样从源代码安装 OpenCV：

$ svn co https://code.ros.org/svn/opencv/trunk/opencv
$ cd opencv/
$ sudo cmake -G "Unix Makefiles" .
$ sudo make -j8
$ sudo make install

如果你建立了所有的依赖关系，一切都应该正确建立并安装。如果你得到一个这样的错误：

import cv2
Traceback (most recent call last):
  file "", line 1, in
ImportError: No module named cv2

那么你需要将含 cv2.so 的目录添加到 PYTHONPATH。例如：

$ export PYTHONPATH=/usr/local/lib/python2.7/site-packages/

A.5.3　Linux

Linux 用户可以尝试发行版安装包（通常称为 opencv），或像 Mac OS X 一节中所描述的那样，从源代码安装。

A.6　VLFeat

安装 VLFeat，需要从 http://vlfeat.org/download.html（目前最新版本是 0.9.14）下载并解压缩最新的二进制软件包。把路径添加到你的环境或者把二进制文件复制到路径中的目录。二进制文件在 bin/ 目录，你可以结合自己的平台选择子目录。

VLFeat 命令行二进制文件的使用描述在 src/ 子目录。你也可以在 http://vlfeat.org/man/man.html 找到在线的说明文档。

A.7　PyGame

PyGame 可以从 http://www.pygame.org/download.shtml 下载，最新版本是 1.9.1。最简单的方法是获取与系统和 Python 版本相应的二进制安装包。

你也可以下载源代码，并在下载后的文件夹里从命令行中运行：

$ python setup.py install

A.8　PyOpenGL

安装 PyOpenGL 最简单的方法是按照 PyOpenGL 网页（http://pyopengl.sourceforge.net/）的建议从 http://pypi.python.org/pypi/PyOpenGL 下载安装包。获取最新版本，目前是 3.0.1。

在下载文件夹，和之前一样运行

$ python setup.py install

如果你遇到问题或需要依赖性信息等，可以在 http://pyopengl.sourceforge.net/documentation/installation.html 找到更多说明文档。http://pypi.python.org/pypi/PyOpenGL-Demo 有一些很好的入门演示脚本。

A.9　Pydot

首先安装依赖关系，GraphViz 和 Pyparsing。转到 http://www.graphviz.org/，为你的平台下载最新的 GraphViz 二进制包。安装文件会自动安装 GraphViz。

然后，转到 Pyparsing 项目主页 http://pyparsing.wikispaces.com/。下载页面为 http://sourceforge.net/projects/pyparsing/。获取最新版本（目前是 1.5.5），并解压到一个目录下。在命令行输入：

$ python setup.py install

最后，转到项目页面 http://code.google.com/p/pydot/，点击 download（下载）。从下载页面下载最新版本（目前是 1.0.4）。解压并再次输入：

$ python setup.py install

现在你应该能够将 pydot 导入你的 Python 会话中。

A.10　Python-graph

Python-graph 是一个操作图表的 Python 模块，包含很多有用的算法，如遍历、最短路径、网页排名和最大流量；最新的版本是 1.8.1，可以在项目网站 http://code.google.com/p/pythongraph/ 上找到。如果你的系统上有 easy_install，最简单的方法是：

$ easy_install python-graph-core

或者，在 http://code.google.com/p/python-graph/downloads/list 下载源代码并运行：

$ python setup.py install

要编写并可视化图形（使用的 DOT 语言），你需要 python-graphdot，它可以下载或使用 easy_install 安装：

$ easy_install python-graph-dot

Python-graph-dot 依赖于 pydot，如上所示。文档（HTML 格式）在 docs/ 文件夹中。

A.11　Simplejson

Simplejson 是 JSON 模块的独立维护版本，适合最 Python 新版（2.6 或更高版本）。两个模块的语法相同，但 simplejson 更优，并且能发挥更好的性能。

在项目页面 https://github.com/simplejson/simplejson 单击 Download 按钮。然后在 Download Packages（下载包）区域（目前是 2.1.3）选择最新版本。解压文件夹，在命令行中输入：

$ python setup.py install

一切 OK 了。

A.12　PySQLite

PySQLite 是一个为 Python 绑定的 SQLite。SQLite 是一个基于磁盘的轻量级数据库，可以使用 SQL 查询，并且易于安装和使用。最新的版本是 2.6.3，详见项目网站，http://code.google.com/p/pysqlite/。

从 http://code.google.com/p/pysqlite/downloads/list 下载文件并解压到一个文件夹，从命令行运行：

$ python setup.py install

A.13　CherryPy

CherryPy（http://www.cherrypy.org/） 是一个快速、稳定、轻量级的 Web 服务器，基于 Python 建立，使用面向对象模型。CherryPy 易于安装，只需从 http://www.cherrypy.org/wiki/CherryPyInstall 下载最新版本，最新的稳定版本是 3.2.0。解压并运行：

$ python setup.py install

安装后，在 cherrypy/tutorial/ 文件夹查看 CherryPy 简单的示例教程。这些例子会告诉你如何传递 GET / POST 变量，继承页面特性，上传和下载文件等。

附录 B　图像集

B.1　Flickr

广受欢迎的照片分享网站 Flickr（http://flickr.com/）是计算机视觉研究者和爱好者的金矿。这是一个很好的资源，包含数以亿计的图像，其中许多有用户做了标记，可以用来获得训练数据或用真实的数据进行实验。Flickr 有一个 API 接口服务，使其可以上传、下载和注释图像（以及更多功能）。对API完整的描述参见 http://flickr.com/services/api/，其中还包含许多编程语言的配套组件，包括 Python。

让我们看看如何使用名为 flickrpy 的库，参见 http://code.google.com/p/flickrpy/。下载文件 flickr.py。你需要从 Flickr 获得一个 API 密钥使其正常工作，这些密钥对于非商业用途是免费，对于商业用途则另有要求。点击 Flickr API 页面的链接“Apply for a new API Key”（申请新 API 密钥），然后按指示进行。获得 API 密钥后，打开 flickr.py，用密钥替换下面的空字符串：

API_KEY = ' '

结果如下：

API_KEY = '123fbbb81441231123cgg5b123d92123'

让我们创建一个简单的命令行工具，用来下载具有特定标签的图片。添加以下代码到一个名为 tagdownload.py 的文件中：

iimport flickr
import urllib, urlparse
import os
import sys
if len(sys.argv)>1:
     tag = sys.argv[1]
else:
     print 'no tag specified'

# 下载图像数据
f = flickr.photos_search(tags=tag)
urllist = [] #  用于存储下载了什么的列表

# 下载图像
for k in f:
     url = k.getURL(size='Medium', urlType='source')
     urllist.append(url)
     image = urllib.URLopener()
     image.retrieve(url, os.path.basename(urlparse.urlparse(url).path))
     print 'downloading:', url

如果你想将 URL 列表写入一个文本文件，可以在末尾添加下面的代码：

# 将url 的列表写入文件
fl = open('urllist.txt', 'w')
for url in urllist:
     fl.write(url+'\n')
fl.close()

在命令行输入：

$ python tagdownload.py goldengatebridge

你会得到 100 幅标记为“goldengatebridge”的最新图像。可以看到，我们选择了获取“中等”尺寸的图像。如果你想得到缩略图、原尺寸的图像或其他图像，这里有许多其他尺寸供选择；说明文档参见 Flickr 网站 http://flickr.com/api/。

这里我们只对下载图像感兴趣；需要身份验证的 API 调用过程略微复杂。查看 API 文档了解更多的关于设置身份验证会话的信息。

B.2　Panoramio

谷歌照片分享服务 Panoramio（http://www.panoramio.com/） 是一个获取地理标记图像的好地方。该 Web 服务提供 API 以编程方式访问内容。API 的描述参见 http://www.panoramio.com/api/。你可以得到网站小部件并使用 JavaScript 对象访问数据。要从该网站上下载图片，最简单的方法是调用 GET，例如 :

http://www.panoramio.com/map/get_panoramas.php?order=popularity&set=public&from=0&to=20&minx=-180&miny=-90&maxx=180&maxy=90&size=mediu

minx、miny、maxx 和 maxy 定义选择照片的地理区域（分别表示最小经度、最小纬度、最大经度和最大纬度）。响应将用 JSON 格式表示，显示如下：

{"count": 3152, "photos":
[{"upload_date": "02 February 2006", "owner_name": "***", "photo_id": 9439,
"longitude": -151.75, "height": 375, "width": 500, "photo_title": "***",
"latitude": -16.5, "owner_url": "http://www.panoramio.com/user/1600", "owner_id": 1600,
"photo_file_url": "http://mw2.google.com/mw-panoramio/photos/medium/9439.jpg",
"photo_url": "http://www.panoramio.com/photo/9439"},
{"upload_date": "18 January 2011", "owner_name": "***", "photo_id": 46752123,
"longitude": 120.52718600000003, "height": 370, "width": 500, "photo_title": "***",
"latitude": 23.327833999999999, "owner_url": "http://www.panoramio.com/user/2780232",
"owner_id": 2780232,
"photo_file_url": "http://mw2.google.com/mw-panoramio/photos/medium/46752123.jpg",
"photo_url": "http://www.panoramio.com/photo/46752123"},
{"upload_date": "20 January 2011", "owner_name": "***", "photo_id": 46817885,
"longitude": -178.13709299999999, "height": 330, "width": 500, "photo_title": "***",
"latitude": -14.310613, "owner_url": "http://www.panoramio.com/user/919358",
"owner_id": 919358,
"photo_file_url": "http://mw2.google.com/mw-panoramio/photos/medium/46817885.jpg",
"photo_url": "http://www.panoramio.com/photo/46817885"},
...
], "has_more": true}

使用 JSON 包 , 你可以获得 photo_file_url 字段的结果，见 2.3 节中的例子。

B.3　牛津大学视觉几何组

牛津大学视觉几何研究组在 http://www.robots.ox.ac.uk/~vgg/data/ 上公布有很多的数据集。在本书中，我们使用了一些多视图的数据集，例如“Merton1”、“Model House”、“dinosaur”和“corridor”序列，这些数据（某些包括摄像机矩阵和点跟踪）下载地址为 http://www.robots.ox.ac.uk/~vgg/data/data-mview.html。

B.4　肯塔基大学识别基准图像

肯塔基大学基准图像集，亦称“ukbench”集，是一个包含 2550 组图片的图像集。该数据集中，每组图像共 4 幅，包含同一对象或同一场景下的不同视角。这是一个测试目标识别和图像检索算法一个很好的图像集。数据集可以在 http://www.vis.uky.edu/~stewe/ukbench/ 下载（全套约 1.5 GB），详见文献 [23]。

在本书中，我们使用一个较小的子集，只含有前 1000 幅图片。

B.5　其他

B.5.1　Prague Texture Segmentation Datagenerator与基准

该数据集在分割那一章里面用到过，可以生成许多不同类型的纹理分割图像，参见 http://mosaic.utia.cas.cz/index.php。

B.5.2　微软剑桥研究院Grab Cut数据集

最初用于 Grab Cu 的论文 [27]，该图像集提供带有用户注释的分割图像。该数据集和一些论文参见 http://research.microsoft.com/en-us/um/cambridge/projects/visionimagevideoediting/segmentation/grabcut.html。数据集中的原始图像现在是伯克利分割数据集（http://www.eecs.berkeley.edu/Research/Projects/CS/vision/grouping/segbench/）中的一部分。

B.5.3　Caltech 101

这是一个经典的数据集，其中包含 101 类照片，可以用来测试目标识别算法，参见 http://www.vision.caltech.edu/Image_Datasets/Caltech101/。

B.5.4　静态手势数据库

这个来自 Sebastien Marcel 的数据集与其他几个手势数据集可在 http://www.idiap.ch/resource/gestures/ 下载。

B.5.5　Middlebury Stereo数据集

这些数据集用于基准立体算法，可在 http://vision.middlebury.edu/stereo/data/ 下载。每个立体像对都带有真实深度图像来方便比较结果。

附录 C　图片来源

在本书中，我们从 Web 服务充分享用了公开可用的数据和图像，详见附录 B。非常感谢这些数据集背后研究人员的贡献。

一些反复出现的图像例子属于作者自己。你可以在创作共用署名 3.0 许可证（CC 3.0，http://creativecommons.org/licenses/by/3.0/）下免费使用这些图片，如引用本书。

这些图片是：

用于本书几乎所有例子的帝国大厦图像；

在图 1-7 中的低对比度图像；

用于图 2-2、图 2-5、图 2-6 和图 2-7 的特征匹配例子；

用于图 9-6、图 10-1 和图 10-2 的渔人码头标志；

用于图 6-4 和图 9-6 的山顶小男孩；

在图 4-3 中用于校准的书的图像；

用于图 4-4、图 4-5 和图 4-6 中的 O'Reilly 开源书的两幅图片。

C.1　来自Flickr的图像

我们在创作共同署名 2.0 通用许可证（CC2.0）使用了一些来自 Flickr 的图像（http://creativecommons.org/licenses/by/2.0/deed.en）下，非常感谢那些摄影师的贡献。

来自 Flickr 的图像（例子中使用的名称，不是原始文件名）：

billboard_for_rent.jpg，来自 @striatic，http://flickr.com/photos/striatic/21671910/，用于图 3-2；

blank_billboard.jpg，来自 @mediaboytodd，http://flickr.com/photos/23883605@N06/2317982570/，用于图 3-3；

beatles.jpg，来自 @oddsock，http://flickr.com/photos/oddsock/82535061/，用于图 3-2 和图 3-3；

turningtorso1.jpg，来自 @rutgerblom，http://www.flickr.com/photos/rutgerblom/2873185336/，用于图 3-5；

sunset_tree.jpg，来自 @jpck，http://www.flickr.com/photos/jpck/3344929385/，用于图 3-5。

C.2　其他图像

用于图 3-6、图 3-7 和图 3-8 的人脸图像是由 J. K. Keller 提供的。眼睛和嘴巴的注释是作者加的。

用于图 3-9、图 3-11 和图 3-12 的隆德大学建筑图片来自隆德大学数学成像组的一个数据集。摄影师很可能是 Magnus Oskarsson。

用于图 4-6 的玩具飞机三维模型来自 Gilles Tran（署名创作共同许可证）。

用于图 5-7 和图 5-8 的恶魔图像由 Carl Olsson 提供。

用于图 1-8、图 6-2、图 6-3、图 6-7 和图 6-8 的字体数据集由马丁 Martin Solli 提供。

用于图 8-6、图 8-7 和图 8-8 的数独图像由 Martin Byröd 提供。

C.3　插图

本书图 5-1 中对极几何的解释来自 Klas Josephson 的图解，并在本书中稍作了修改。

参考文献

[1]　Herbert Bay, Tinne Tuytelaars, and Luc Van Gool. SURF: Speeded up robust features. In European Conference on Computer Vision, 2006.

[2]　Yuri Boykov, Olga Veksler, and Ramin Zabih. Fast approximate energy minimization via graph cuts. IEEE Transactions on Pattern Analysis and Machine Intelligence, 23:2001, 2001.

[3]　Gary Bradski and Adrian Kaehler. Learning OpenCV. O'Reilly Media Inc., 2008.

[4]　Martin Byroöd. An optical Sudoku solver. In Swedish Symposium on Image Analysis, SSBA. http://www.maths.lth.se/matematiklth/personal/byrod/papers/sudokuocr.pdf, 2007.

[5]　Antonin Chambolle. Total variation minimization and a class of binary mrf models. In Energy Minimization Methods in Computer Vision and Pattern Recognition, Lecture Notes in Computer Science, pages 136–152. Springer Berlin / Heidelberg, 2005.

[6]　T. Chan and L. Vese. Active contours without edges. IEEE Trans. Image Processing, 10(2):266–277, 2001.

[7]　Chih-Chung Chang and Chih-Jen Lin. LIBSVM: a library for support vector machines, 2001. Software available at http://www.csie.ntu.edu.tw/~cjlin/libsvm.

[8]　D. Cremers, T. Pock, K. Kolev, and A. Chambolle. Convex relaxation techniques for segmentation, stereo and multiview reconstruction. In Advances in Markov Random Fields for Vision and Image Processing. MIT Press, 2011.

[9]　Nello Cristianini and John Shawe-Taylor. An Introduction to Support Vector Machines and Other Kernel-based Learning Methods. Cambridge University Press, 2000.

[10]　Gunnar Farneba¨ck. Two-frame motion estimation based on polynomial expansion. In Proceedings of the 13th Scandinavian Conference on Image Analysis, pages 363–370, 2003.

[11]　M. A. Fischler and R. C. Bolles. Random sample consensus: a paradigm for model fitting with applications to image analysis and automated cartography. Communications-of-the-ACM, 24(6):381–95, 1981.

[12]　C. Harris and M. Stephens. A combined corner and edge detector. In Proceedings Alvey Conference, pages 189–192, 1988.

[13]　R. I. Hartley and A. Zisserman. Multiple View Geometry in Computer Vision. Cambridge University Press, ISBN: 0521540518, second edition, 2004.

[14]　Richard Hartley. In defense of the eight-point algorithm. IEEE Transactions on Pattern Analysis and Machine Intelligence, 19:580–593, 1997.

[15]　Berthold K. P. Horn and Brian G. Schunck. Determining optical flow. Artifical Intelligence, 17:185–203, 1981.

[16]　Vladimir Kolmogorov and Ramin Zabih. What energy functions can be minimized via graph cuts. IEEE Transactions on Pattern Analysis and Machine Intelligence, 26:65–81, 2004.

[17]　David G. Lowe. Object recognition from local scale-invariant features. In International Conference on Computer Vision, pages 1150–1157, 1999.

[18]　David G. Lowe. Distinctive image features from scale-invariant keypoints. International Journal of Computer Vision, 60(2):91–110, 2004.

[19]　Bruce D. Lucas and Takeo Kanade. An iterative image registration technique with an application to stereo vision, pages 674–679, 1981.

[20]　Mark Lutz. Learning Python. O'Reilly Media Inc., 2009.

[21]　Will McGugan. Beginning Game Development with Python and Pygame. Apress, 2007.

[22]　F. Meyer. Color image segmentation. In Proceedings of the 4th Conference on Image Processing and its Applications, pages 302–306, 1992.

[23]　D. Nisteér and H. Steweénius. Scalable recognition with a vocabulary tree. In IEEE Conference on Computer Vision and Pattern Recognition (CVPR), volume 2, pages 2161–2168, 2006.

[24]　Travis E. Oliphant. Guide to NumPy. http://www.tramy.us/numpybook.pdf, 2006.

[25]　M. Pollefeys, L. Van Gool, M. Vergauwen, F. Verbiest, K. Cornelis, J. Tops, and R. Koch. Visual modeling with a hand-held camera. International Journal of Computer Vision, 59(3):207–232, 2004.

[26]　Marc Pollefeys. Visual 3d modeling from images—tutorial notes. Technical report, University of North Carolina–Chapel Hill. http://www.cs.unc.edu/%7Emarc/tutorial.pdf_

[27]　Carsten Rother, Vladimir Kolmogorov, and Andrew Blake. Grabcut: Interactive foreground extraction using iterated graph cuts. ACM Transactions on Graphics, 23: 309–314, 2004.

[28]　L. I. Rudin, S. J. Osher, and E. Fatemi. Nonlinear total variation based noise removal algorithms. Physica D, 60:259–268, 1992.

[29]　Daniel Scharstein and Richard Szeliski. A taxonomy and evaluation of dense twoframe stereo correspondence algorithms. International Journal of Computer Vision, 2001.

[30]　Daniel Scharstein and Richard Szeliski. High-accuracy stereo depth maps using structured light. In IEEE Computer Society Conference on Computer Vision and Pattern Recognition, 2003.

[31]　Toby Segaran. Programming Collective Intelligence. O'Reilly Media, 2007.

[32]　Jianbo Shi and Jitendra Malik. Normalized cuts and image segmentation. IEEE Trans. Pattern Anal. Mach. Intell., 22:888–905, August 2000.

[33]　Jianbo Shi and Carlo Tomasi. Good features to track. In 1994 IEEE Conference on Computer Vision and Pattern Recognition (CVPR'94), pages 593–600, 1994.

[34]　Noah Snavely, Steven M. Seitz, and Richard Szeliski. Photo tourism: Exploring photo collections in 3d. In SIGGRAPH Conference Proceedings, pages 835–846. ACM Press, 2006.

[35]　Bill Triggs, Philip F. McLauchlan, Richard I. Hartley, and Andrew W. Fitzgibbon. Bundle adjustment - a modern synthesis. In Proceedings of the International Workshop on Vision Algorithms: Theory and Practice, ICCV '99, pages 298–372. Springer-Verlag, 2000.

[36]　A. Vedaldi and B. Fulkerson. VLFeat: An open and portable library of computer vision algorithms. http://www.vlfeat.org/, 2008.

[37]　Deepak Verma and Marina Meila. A comparison of spectral clustering algorithms. Technical report, 2003.

[38]　Luminita A. Vese and Tony F. Chan. A multiphase level set framework for image segmentation using the mumford and shah model. International Journal of Computer Vision, 50:271–293, December 2002.

[39]　Paul Viola and Michael Jones. Robust real-time object detection. In International Journal of Computer Vision, 2001.

[40]　Marco Zuliani. Ransac for dummies. Technical report, Vision Research Lab, UCSB, 2011.

看完了

如果您对本书内容有疑问，可发邮件至contact@turingbook.com，会有编辑或作译者协助答疑。也可访问图灵社区，参与本书讨论。

如果是有关电子书的建议或问题，请联系专用客服邮箱：ebook@turingbook.com。

在这里可以找到我们：

微博 @图灵教育 : 好书、活动每日播报

微博 @图灵社区 : 电子书和好文章的消息

微博 @图灵新知 : 图灵教育的科普小组

微信 图灵访谈 : ituring_interview，讲述码农精彩人生

微信 图灵教育 : turingbooks 　　

091507240605ToBeReplacedWithUserId

版权信息

书名：Python科学计算基础教程

作者：[印] Hemant Kumar Mehta

译者：陶俊杰　陈小莉

ISBN：978-7-115-43698-6

本书由北京图灵文化发展有限公司发行数字版。版权所有，侵权必究。

您购买的图灵电子书仅供您个人使用，未经授权，不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟，与我们共同保护知识产权。

如果购买者有侵权行为，我们可能对该用户实施包括但不限于关闭该帐号等维权措施，并可能追究法律责任。

091507240605ToBeReplacedWithUserId

版权声明

译者序

前言

本书内容

本书需要的工具

目标读者

排版约定

读者反馈

客户支持

下载示例代码

下载本书的彩色图片

勘误

盗版

问题

电子书

致谢

第 1 章　科学计算概况与选择Python的理由

1.1　科学计算的定义

1.2　科学计算的简单处理流程

1.3　科学与工程领域的案例

1.4　解决复杂问题的策略

1.5　近似、误差及相关统计概念和术语

1.5.1　误差分析

1.5.2　敏感度、稳定性和准确性

1.5.3　后向与前向误差估计

1.5.4　误差可以忽略不计吗

1.6　计算机算术运算和浮点数

1.7　Python编程语言简介

1.7.1　Python语言的指导原则

1.7.2　为什么用Python做科学计算

1.7.3　Python的缺点

1.8　小结

第 2 章　科学工作流和科学计算的结构

2.1　科学计算的数学部分

2.1.1　线性方程组

2.1.2　非线性方程组

2.1.3　最优化方法

2.1.4　内插法

2.1.5　外插法

2.1.6　数值积分

2.1.7　数值微分

2.1.8　微分方程

2.1.9　随机数生成器

2.2　Python科学计算

2.2.1　NumPy简介

2.2.2　SciPy程序库

2.2.3　用pandas做数据分析

2.3　IPython交互式编程简介

2.3.1　IPython并行计算

2.3.2　IPython Notebook

2.4　用SymPy进行符号计算

2.4.1　SymPy的主要特点

2.4.2　为什么用SymPy

2.5　画图程序库

2.6　小结

第 3 章　有效地制造与管理科学数据

3.1　数据的基本概念

3.2　数据存储软件与工具箱

3.2.1　文件

3.2.2　数据库

3.3　常见的数据操作

3.4　科学数据的格式

3.5　现成的标准数据集

3.6　数据生成

3.7　模拟数据的生成（构造）

3.7.1　用Python的内置函数生成随机数

3.7.2　基于统计分布的随机数生成器的设计和实现

3.7.3　一个用简单逻辑生成5位随机数的程序

3.8　大规模数据集的简要介绍

3.9　小结

第 4 章　Python科学计算API

4.1　Python数值科学计算

4.1.1　NumPy程序包

4.1.2　SciPy程序包

4.1.3　简单的SciPy程序

4.2　SymPy符号计算

4.2.1　计算机代数系统

4.2.2　通用CAS的特点

4.2.3　SymPy设计理念简介

4.2.4　SymPy模块

4.2.5　简单的范例程序

4.3　数据分析和可视化的API和工具

4.3.1　用pandas进行数据分析和操作

4.3.2　用matplotlib进行数据可视化

4.3.3　用IPython实现Python的交互式计算

4.3.4　数据分析和可视化的示例程序

4.4　小结

第 5 章　数值计算

5.1　NumPy的基本对象

5.1.1　N 维数组对象

5.1.2　通用函数对象

5.1.3　NumPy的数学模块

5.2　SciPy的介绍

5.2.1　SciPy的数学函数

5.2.2　高级模块/程序包

5.3　小结

第 6 章　用Python做符号计算

6.1　符号、表达式和基本运算

6.2　求解方程

6.3　有理数、指数和对数函数

6.4　多项式

6.5　三角函数和复数

6.6　线性代数

6.7　微积分

6.8　向量

6.9　物理模块

6.9.1　氢波函数

6.9.2　矩阵和Pauli代数

6.9.3　一维和三维量子谐振子

6.9.4　二次量子化

6.9.5　高能物理

6.9.6　力学

6.10　漂亮的打印功能

LaTex打印

6.11　密码学模块

6.12　输入的句法分析

6.13　逻辑模块

6.14　几何模块

6.15　符号积分

6.16　多项式操作

6.17　集合

6.18　运算的简化和合并

6.19　小结

第 7 章　数据分析与可视化

7.1　matplotlib

7.1.1　matplotlib的架构

7.1.2　matplotlib的画图方法

7.2　pandas程序库

7.2.1　Series

7.2.2　DataFrame

7.2.3　Panel

7.2.4　pandas数据结构的常用函数

7.2.5　时间序列与日期函数

7.2.6　处理缺失数据

7.3　I/O操作

7.3.1　处理CSV文件

7.3.2　即开即用数据集

7.4　IPython

7.4.1　IPython终端与系统命令行工具

7.4.2　IPython Notebook

7.5　小结

第 8 章　并行与大规模科学计算

8.1　用IPython做并行计算

8.2　IPython并行计算架构

并行计算的组成部分

8.3　并行计算示例

8.3.1　并行装饰器

8.3.2　IPython的魔法函数

8.4　IPython的高级特性

8.4.1　容错执行

8.4.2　动态负载均衡

8.4.3　在客户端与引擎之间推拉对象

8.4.4　支持数据库存储请求与结果

8.4.5　在IPython里使用MPI

8.4.6　管理任务之间的依赖关系

8.4.7　用Amazon EC2的StarCluster启动IPython

8.5　IPython数据安全措施

8.5.1　常用并行编程方法

8.5.2　在Python中演示基于Hadoop的MapReduce

8.5.3　在Python中运行Spark

8.6　小结

第 9 章　真实案例介绍

9.1　用Python开发的科学计算应用

9.1.1　“每个孩子一台笔记本”项目用Python开发界面

9.1.2　ExpEYES——科学之眼

9.1.3　Python开发的天气预测应用程序

9.1.4　Python开发的航空器概念设计工具与API

9.1.5　OpenQuake引擎

9.1.6　德国西马克公司的能源效率应用程序

9.1.7　高能物理数据分析的自动代码生成器

9.1.8　Python的计算化学应用

9.2　Python开发的盲音触觉识别系统

9.2.1　TAPTools空中交通管制工具

9.2.2　光能效率检测的嵌入式系统

9.3　Python开发的科学计算程序库

9.3.1　Tribon公司的船舶设计API

9.3.2　分子建模工具箱

9.3.3　标准Python程序包

9.4　小结

第 10 章　科学计算的最佳实践

10.1　方案设计阶段的最佳实践

10.2　功能实现阶段的最佳实践

10.3　数据管理与应用部署的最佳实践

10.4　实现高性能的最佳实践

10.5　数据隐私与安全的最佳实践

10.6　测试与维护的最佳实践

10.7　Python常用的最佳实践

10.8　小结

版权声明

Copyright © 2015 Packt Publishing. First published in the English language under the title Mastering Python Scientific Computing.

Simplified Chinese-language edition copyright © 2017 by Posts & Telecom Press. All rights reserved.

本书中文简体字版由Packt Publishing授权人民邮电出版社独家出版。未经出版者书面许可，不得以任何方式复制或抄袭本书内容。

版权所有，侵权必究。

谨以此书献给我的父母，以及我的精神导师——Manohar Chandwani教授（已故）和Priyesh Kanungo教授。

译者序

数据是事实的抽象形式，能比较则可量化。只要定义了事实的起点（几何中称为原点，代数中称为数字0），一切模棱两可的表象通过对比都将变得井然有序。然而，有时人们关心的事实是复杂的、多维的、难以想象的，因此需要分析数据、梳理信息、积累知识、作出决策。随着数据获取和计算成本的不断降低，开源免费的数据采集与分析工具愈加便利，人们将更愿意接受采集数据与分析数据的过程，用数据提高工作效率，改善生活质量。各门学科中不问数据的“差不多先生”将被淘汰，以数据为基础的“德先生”与“赛先生”将重回中国。科学是反复的实验，民主是具体的实践，二者都离不开数据的支持。科学与工程领域的各门学科都是建立在数据的基础上，用数据发现问题、解决问题、展现研究成果，是科学家与工程师认识世界、改造世界的必然手段。科学家与工程师在解决专业领域的计算任务时，需要建立数据模型、分析历史数据、预测未来趋势，这些都离不开专业的科学计算工具。Python简洁优雅、开源免费、模块丰富、社区活跃，是许多科学家与工程师解决复杂计算问题的必备工具，而以NumPy和SciPy为基础构建的Python科学计算环境，在科学与工程领域已经有着十分广泛的应用。

对于科学计算难题，Python总能立竿见影。本书作者在科学计算领域经验十分丰富，知识框架完整，见解独到，可谓精通科学计算。作者在书中以科学计算生态系统的视角，不仅全面阐述了Python科学计算环境的基础内容，还为你提供了Python科学计算的精彩案例，总结了科学计算的最佳实践经验。作者首先以科学计算的概念和作业流程为切入点，总结科学计算的任务和难点，进而结合Python的功能特性论述选择Python进行科学计算的理由，总结Python为科学计算提供的工具与API。之后，作者以科学计算的对象——数据——为基础，介绍数据存储的不同文件格式，以及为科学计算实验生成数据的常用随机数生成方法。然后，作者详细地介绍了Python科学计算的生态环境，通过大量示例介绍NumPy、SciPy、SymPy、IPython、matplotlib和pandas等科学计算模块的功能，以及这些模块在数据统计、符号计算、交互编程、数据可视化等领域的应用。在大数据时代，Python科学计算不能忽视高性能计算的需求，因此作者用第8章的内容详细介绍了Python关于并行与大数据计算的方法，包括IPython的并行计算框架、多进程与多线程、Hadoop与PySpark分布式计算。

书中还有Python在科学与工程领域大量的科学计算案例。在中国L型新常态已成定局、工业4.0方兴未艾之际，通过数据寻求成本与效率的均衡，挖掘潜在的用户需求，增强用户体验，都显得更加重要。数据分析方法不再局限于课本与描述性统计，国家、企业、家庭甚至个人发展中的每一个合理决策都需要数据的支持。另外，本书最后一章总结了一些科学软件开发项目中设计、实施、发布、数据安全、维护与客户支持以及Python语言编程等方面的最佳实践。为了方便读者查询Python模块，作者在书中整理了不同学科中解决具体问题时常用的Python科学计算模块。Python科学计算的模块非常丰富，读者在PyPI网站（https://pypi.python.org/pypi）上可以看到最新发布的模块信息。全书示例程序使用Python 2.7，所有程序稍加调整（改print语句）或使用2to3工具，即可在Python 3环境下运行。强烈推荐使用Anaconda科学计算软件，它集成了Python科学计算的常见模块，有适用于Windows、Mac OS、Linux系统的不同版本。BTW，在中国内地的普通网络环境下，建议使用清华大学的TUNA镜像下载Anaconda安装包（https://mirrors.tuna.tsinghua.edu.cn/anaconda/archive/）。Python模块的下载和配置方式请参考https://mirror.tuna.tsinghua.edu.cn/help/anaconda/，其配置方法十分简单。运行以下命令即可添加Anaconda Python免费仓库：

conda config --add channels 'https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/'
conda config --set show_channel_urls yes

对于那些Anaconda官方未添加的模块，使用pip安装即可。网络环境好的同学，也可以考虑conda-forge。更多精彩，等你探索。另外，Anaconda Cloud、Wakari（已经被Anaconda收购）、微软Azure notebook等云计算环境都安装了常用的科学计算模块，可以灵活配置，免费使用，很适合上手，只要有浏览器就都可以写代码。Python与PyData社区十分活跃，推荐感兴趣的读者持续关注。

科学与工程学科众多，知识浩如烟海，内容非常专业。本书中涉及数学、物理、医学等诸多方面的专业知识，对纯工科出身的我们来说颇有难度，相关知识我们重点参考了维基百科。若读者在阅读过程中发现有翻译不当的地方，还请帮忙指正，非常感谢。

陶俊杰 陈小莉

2016年7月

前言

“我坚信几十年后，科学历史学家会把我们目前所处的时代，描述成科学史上一个具有深远和重大意义的转型期。在这个过程中，不断涌现的免费开源软件扮演了重要角色。”

——Fernando Perez，IPython创始人

本书主要介绍Python用于科学计算的API和工具包。我强烈推荐给奋战在工程计算和科学计算领域的朋友们。科学计算是一个交叉领域，需要计算机科学、数学、自然科学（至少是物理学、化学、环境科学、生物学等学科中的一种）以及工程学的知识。Python包含大量的包、API和工具，为众多科学与工程领域提供所需的功能。

用户众多的社区、丰富齐全的帮助文档、大量的科学计算库和开发环境、高效的性能以及良好的支持，使得Python成为科学计算的极佳选择。

本书内容

第1章，科学计算概况与选择Python的理由，主要介绍科学计算的基本概念，同时介绍Python的背景知识、指导原则以及为何用Python进行科学计算是十分高效的。

第2章，科学工作流和科学计算的结构，主要介绍通常在解决科学问题时需要用到的数学与数值分析概念，还会简单地介绍Python语言为科学计算提供的包、工具和API。

第3章，有效地制造与管理科学数据，主要介绍科学项目相关数据的各个方面，包括基本概念、各种数据操作以及存储数据的格式与软件，还会介绍一些标准数据集和生成合成数据的技术。

第4章，Python科学计算API，主要介绍不同科学计算API和工具（包括NumPy、SciPy和SymPy等）的基本概念、特性以及简单的示例程序，还会简单地介绍使用IPython、matplotlib和pandas进行交互式计算、数据分析以及数据可视化。

第5章，数值计算，主要介绍如何利用Python的NumPy和SciPy包完成数值计算。一开始先介绍数值计算的基础知识，然后介绍优化、插值、傅里叶变换、信号处理、线性代数、统计、空间算法、图像处理、文件输入/输出等进阶知识。

第6章，用Python做符号计算，首先介绍CAS（Computerized Algebra System，计算机化代数系统）的基础知识，并用SymPy实现符号计算。这一章将围绕CAS介绍多个话题，既包括简单的数学表达式和基本的算术运算，也有数学和物理学的高级概念。

第7章，数据分析与可视化，介绍matplotlib和pandas在数据分析与可视化方面的相关概念和应用示例。

第8章，并行与大规模科学计算，介绍实现高性能科学计算的工具和方法，包括IPython（配合MPI）并行计算、使用StarCluster配置Amazon EC2计算集群、多进程与多线程方法、Hadoop和Spark。

第9章，真实案例介绍，介绍一些利用Python开发的科学计算应用、库和工具的案例。这些案例都源自不同的工程和科学领域。

第10章，科学计算的最佳实践，介绍科学计算的最佳实践，内容包括方案设计、代码编写、数据管理、应用部署、高性能计算、数据安全与隐私、应用维护以及客户支持等，同时还会介绍一些专门针对Python开发的最佳实践。

本书需要的工具

运行本书的示例程序首先需要一台装有Python 2.7.9或以上版本的计算机以及Python的一些API、包和工具。然后，需要一些Python库（包括NumPy、SciPy、SymPy、matplotlib、pandas和IPython），还有IPython.parallel包、pyzmq、SSH安全协议（如果你需要）以及Hadoop。

目标读者

本书面向希望了解科学计算的Python程序员。阅读本书的前提是你已经掌握了Python编程的基本概念。

排版约定

在本书里，你将会看到用于区分不同类型信息的文本样式。以下给出了一些文本样式的示例及其含义。

正文中的代码和用户输入会这样显示：“随机模块中的所有函数都是random.Random类的一个隐含实例的方法。”

代码块示例如下：

import random
print random.random()
print random.uniform(1,9)
print random.randrange(20)
print random.randrange(0, 99, 3)
print random.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ') # Output 'P'
items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
random.shuffle(items)
print items
print random.sample([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 5)
weighted_choices = [('Three', 3), ('Two', 2), ('One', 1), ('Four', 4)]
population = [val for val, cnt in weighted_choices for i in
range(cnt)]
print random.choice(population)

　这个图标表示警告或重要事项。

　这个图标表示提示和技巧。

读者反馈

我们非常欢迎读者的反馈。如果你对本书有些想法，有什么喜欢或是不喜欢的，请反馈给我们。这将有助于我们开发出能够充分满足读者需求的图书。

一般的反馈，请发送电子邮件至feedback@packtpub.com，并在邮件主题中注明书名。

如果你在某个领域有专长，并有意编写一本书或是贡献一份力量，请参考我们的作者指南，地址为http://www.packtpub.com/authors。

客户支持

你现在已经是Packt引以为傲的读者了，为了能让你的购买物有所值，我们还为你准备了以下内容。

下载示例代码

你可以用你的账户从http://www.packtpub.com下载所有已购买Packt图书的示例代码文件。如果你从其他地方购买的本书，可以访问http://www.packtpub.com/support并注册，我们将通过电子邮件把文件发送给你。

下载本书的彩色图片

我们也提供了本书的PDF文件，里面包含了本书的截屏和图表等彩色图片。彩色图片将能帮助你更好地理解输出的变化。下载地址：https://www.packtpub.com/sites/default/files/downloads/8823OS.pdf。

勘误

虽然我们已尽力确保本书内容正确，但出错仍旧在所难免。如果你在我们的书中发现错误，不管是文本还是代码，希望能告知我们，我们不胜感激。这样做，你可以使其他读者免受挫败，也可以帮助我们改进本书的后续版本。如果你发现任何错误，请访问http://www.packtpub.com/submit-errata提交，选择你的书，点击勘误表提交表单的链接，并输入详细说明。勘误一经核实，你的提交将被接受，此勘误将上传到本公司网站或添加到现有勘误表。

访问https://www.packtpub.com/books/content/support，在搜索框中输入书名，你也可以在Errata部分查看已经提交的勘误信息。

盗版

版权材料在互联网上的盗版是所有媒体都要面对的问题。Packt非常重视保护版权和许可证。如果你发现我们的作品在互联网上被非法复制，不管以什么形式，都请立即为我们提供位置地址或网站名称，以便我们可以寻求补救。

请把可疑盗版材料的链接发到copyright@packtpub.com。

非常感谢你帮助我们保护作者，以及保护我们给你带来有价值内容的能力。

问题

如果你对本书内容存有疑问，不管是哪个方面，都可以通过questions@packtpub.com联系我们。我们会尽最大努力解决。

电子书

扫描以下二维码，即可购买本书电子版。

致谢

特别感谢我的博士生导师Priyesh Kanungo教授和印度Devi Ahilya大学已故的Manohar Chandwani教授。他们的教导一直指引着我的事业和生活。

也由衷地感谢我亲爱的学生和朋友Pawan Pawar，他帮助我写了本书中的一些程序。

还要感谢整个Packt出版团队和审稿人，感谢他们为了保证本书的高质量而付出的巨大努力。

最重要的是，我要感谢我的家人。万分感激我的父母。感谢我的妻子Priya以及我亲爱的儿子Luv和Darsh，我对他们的感激之情无以言表。

第 1 章　科学计算概况与选择Python的理由

科学计算（scientific computing）是指在科学与工程领域，使用计算机数学建模和数值分析技术分析和解决问题的过程。科学问题包括不同科学学科中的问题，如地球科学、空间科学、社会科学、生命科学、物理学和形式科学。这些学科基本涵盖了现有的所有科学领域，从传统科学到现代工程科学，如计算机科学，都在其中。工程问题包括从土木工程和电子工程到（最新的）生物医学工程领域的各种问题。

本章将介绍的话题如下：

科学计算的基础知识

科学计算的处理流程

科学与工程领域的计算案例

解决复杂问题的策略

近似、误差和相关统计术语

误差分析的基本概念

计算机算法与浮点数

Python背景介绍

为什么选择Python做科学计算

数学建模是指利用数学术语表示设备、物体、现象和观念的行为的建模行为。一般情况下，数学建模可以帮助人们更好地理解观念、设备和物体的行为或观测值。它可以帮助人们解释观测值，并对未来的行为进行预测，或者推导出还没有被观测或测量的结果。数值分析是计算机科学与数学的交叉领域，通过设计、分析并最终实现算法，来解决自然科学（例如物理学、生物学和地球科学）、社会科学（例如经济学、心理学、社会学和政治学）、工程学、医学和商学问题。Python有一个专门研究多体动力学的包和工作流，叫作Python Dynamics（即PyDy）。它是基于SymPy力学包开发的工作流和软件包。PyDy扩展了SymPy，并实现了多体动力学仿真。

1.1　科学计算的定义

科学计算也被称作计算科学（computational science）或科学计算法（scientific computation），其主要思路是开发数学模型，通过量化分析技术和计算机解决科学问题。

“科学计算是利用计算机解决科学与工程领域的数学建模问题所需的工具、技术和理论的集合。”

——Gene H. Golub和James M. Ortega

简而言之，科学计算可以看成是一门交叉学科，如下图所示。

科学计算是一门交叉学科

科学计算首先需要人们了解问题（通常是科学和工程领域的问题）背后的专业知识，同时需要具有数学建模能力，掌握各种数值分析技术，并能利用计算机技术实现高效率、高性能的计算工具。它还需要使用计算机以及各种各样的外围设备，包括网络设备、存储工具、计算处理器、数学与数值分析软件。此外还需要掌握编程语言，并了解问题所在领域的知识数据库。人们已经利用科学计算的相关技术创造出了新的应用，让科学家们能够从现有的数据和过程中发现新的知识。

在计算机科学方面，科学计算可以看成是对数学模型和问题所在领域的数据/信息的数值仿真。仿真目标由具体问题决定。目标可以是探索事件发生的原因，重新构建一个具体的场景，优化过程，或者预测事件发生的时机。有时数值仿真可能是唯一选择，或者是最佳选择。有一些现象和场景基本上不可能进行实验，例如气候研究、天体物理学研究和天气预测。在另一些场景中，实际的实验并不可取，比如检验某种材料或产品的可靠性或强度。有些实验的时间/经济成本很高，例如车祸或生命科学实验。在以上这些场景中，科学计算能够经济高效地帮助用户分析和解决问题。

1.2　科学计算的简单处理流程

下面的流程图简单说明了科学计算的步骤。第一步是为问题设计数学模型。当创建完数学模型后，下一步是开发算法。算法通常需要利用合适的编程语言和恰当的实现框架来实现。编程语言的选择是关键决策点，由应用的性能和功能需求决定。另一个重要的决策点是确定实现算法的框架。确定了语言和框架之后，就可以实现算法并进行样本仿真了。可以对仿真的结果进行性能和准确率分析。如果实现的结果或效果不符合预期，则应该确定问题的根源。之后，需要回头改进数学模型，或者重新设计算法或它的实现，并选择合适的编程语言和框架来实现算法。

科学计算流程

数学模型表现为一组合理的数学公式，这些公式能够在一定程度上详细描述大多数问题。算法用多个步骤来表示解决过程，这些步骤需要用一种适当的编程语言或脚本来实现。

实现算法之后，还有一个重要的步骤需要完成——对实现的代码进行仿真运行。这包括设计实验基础设施、准备或整理仿真用的数据/条件、准备仿真的场景，等等。

仿真成功运行之后，下一步就是收集和展示结果以便进行分析，进而对仿真的有效性进行验证。如果结果不符合预期，就需要返回到前面的步骤改正并重复。这种返回到之前步骤的情形，在上图中用虚线表示。如果每一步都进行得很顺利，那么分析就是工作流的最后一步，图中用双线表示。

为了解决任何数学问题，尤其是科学与工程领域的数学问题而进行的算法设计与分析，称为数值分析（numerical analysis），现在也称为科学计算。在科学计算中，需要解决的问题主要是针对连续数值，而不是离散数值。后者主要指计算机科学的其他问题。通俗的表述就是，科学计算解决关于连续变量的方程和公式问题，例如时间、距离、速率、重量、高度、尺寸、温度、密度、压力、应力等。

一般情况下，连续变量的数学问题只能获得近似解，因为它们的精确解不太可能在有限的步骤中得到。因此，这些问题通过有限步的迭代处理可以收敛到一个可行解。这个可行解取决于目标问题的特性。通常迭代步骤都是有限的，每次迭代之后，结果都会更加接近仿真的期望解。仿真结果的准确性和算法的收敛速度是科学计算过程的重点。

一些科学领域已经在使用科学计算解决问题了，比如：

计算流体动力学

大气科学

地震学

结构分析

化学

磁流体力学

地质储层建模

全球海洋/气候建模

天文/天体物理

宇宙学

环境保护研究

核工程

目前，一些新兴的学科也开始借助科学计算的力量，包括：

生物学

经济学

材料研究

医学影像

动物科学

1.3　科学与工程领域的案例

让我们看几个可能用科学计算解决的问题。第一个问题是研究两个黑洞碰撞的行为，这个问题无论从理论上还是实践上都很难理解。从理论上说，这个实验非常复杂，几乎不可能在实验室实现并进行现场研究。但是这个现象可以根据爱因斯坦的广义相对论的数学公式建立合理有效的数学模型，然后在计算实验室进行仿真。然而，这个仿真需要消耗极大的计算能力，可以通过先进的分布式计算环境实现。

第二个问题与工程和设计相关。与汽车测试相关的一个问题是碰撞测试（crash testing）。为了降低完成真实（也很危险）的碰撞测试的成本，工程师和设计师们都会优先考虑进行计算机仿真碰撞测试。最后，还有大型房屋与厂房设计问题。可以按照设计目标构建一个仿真模型，但是这样做需要消耗大量的时间和金钱。然而，通过建筑设计工具完成设计可以节省大量的时间和成本。生物信息科学和医学也有类似的情况，例如蛋白质结构的折叠和传染病的建模研究。蛋白质结构折叠的研究非常耗时，但是利用大规模计算机集群和分布式计算系统可以高效地完成。类似地，在分析不同参数对传染病疫苗接种程序的影响方面，为传染病建模可以节省时间和成本。

之所以挑选这三个问题，是因为它们分别代表科学计算可以解决的三类问题。第一类问题是基本不可能解决的。第二类问题可以解决但风险很高，甚至具有严重的破坏性。第三类问题不使用仿真就可以解决，也可以在现实生活中通过模拟解决，但是通过仿真方法解决会更加经济高效。

1.4　解决复杂问题的策略

解决复杂计算问题的一个简单策略是：首先找出问题的难点，然后分解成多个小难点，各个击破，替换成问题的最优解或可行解。总之，就是要把大问题分解成小问题，分而治之。经过初步分解后的小问题，可能简单也可能依然比较复杂。可以将复杂的小问题进一步分解，直到分解成可以解决的问题为止，最终，需要解决的都是一些简单的小问题。基本思路就是通过分而治之的方法将难题替换成相似的简单问题。

在应用这个方法时，有两个关键点需要注意。首先是需要从同类问题中寻找可以解决的问题（相似性替代，切忌使用风马牛不相及的问题）。其次是问题替换之后，需要考虑最终问题是否仍然处于可接受的范围之类。现列举一些案例如下。

将无限维空间问题简化为有限维空间问题。

将无限过程转换成有限过程，例如将积分和无穷级数转换成有限项求和或有限差分法（网格法）。

如果条件允许，用代数方程代替差分方程。

将非线性问题转换成线性问题，因为后者更容易解决。

如果条件允许，将复杂的函数简化成若干简单的函数。

1.5　近似、误差及相关统计概念和术语

这些科学计算的答案通常都是近似解。近似解虽然不是我们真正想要的精确解，但是可以非常接近精确解。“非常接近”的意思是这个解十分接近实际或仿真成功获得的结果，因为它们实现了目标。这类近似解或相似解会受到许多因素的影响。影响因素按照产生阶段可以分成两类：一类是在计算开始之前就有的，另一类是在计算过程中出现的。

在计算开始之前就出现近似值，主要由以下因素造成。

建模假设或无知：建模过程中可能使用了一些假设条件，没注意或者忽略了一些概念和现象的影响，最终导致了近似或可接受的误差。

观测或实验数据：从一些低精度的设备中获取的数据可能会不准确。计算过程中使用一些常量，比如π，这些常量都是近似值，这也是造成计算结果与真实值有差距的重要原因。

计算的先决条件：输入数据是从前一个实验或仿真中获取的值，可能有点误差，而经过计算误差被进一步放大了。前一步的处理可能会成为之后实验的先决条件。

计算过程中导致近似值的主要因素如下。

简化问题：如本章前面介绍的，为了解决大而复杂的问题，需要使用分而治之的方法，并不断将小难题转换成简单问题。这可能会产生近似值。而且将无限序列替换成有限序列也可能会产生近似值。

截断和舍入：许多仿真都会对中间结果进行截断和舍入操作。类似地，计算机内部表示浮点数的方法和算术运算过程也会导致些许不准确。

科学计算最终出现近似解可能是由上面的若干因素造成的。根据不同的问题和不同的解决方法，最终结果的准确性也可能会发生变化。

计算过程中误差与近似的分类

1.5.1　误差分析

误差分析（error analysis）是评估近似解对算法或计算过程准确性的影响程度的过程。下面将介绍误差分析的基本概念。

通过前面对近似解的讨论可以得出这样的结论：误差既可能出现在输入数据中，也可能在对输入数据的计算过程中产生。

如果进一步细分，计算误差还可以分为两类：截断误差（truncation error）和舍入误差（rounding error）。截断误差是将复杂问题简化成简单问题时造成的，例如，在得到需要的准确率之前粗略地中断算法迭代。舍入误差是使用计算机计算时数字系统表示数字精度的规则造成的，也是在对数字进行算术运算时造成的。

最终，误差究竟是十分显著还是可以忽略不计，由最终数值的规模决定。例如，误差10对数值15来说是十分显著的，但对785来说就不算大了，对17 685来说甚至可以忽略不计。通常，误差值的影响程度与结果数值具有相关性。如果知道结果数值的量级，那么看看误差值的量级，就可以判断误差究竟是可以忽略不计还是十分显著。如果误差十分显著，就要考虑引入改进手段了。

1.5.2　敏感度、稳定性和准确性

下面介绍一些问题或算法的重要属性。敏感度（sensitivity或conditioning）是问题的一种属性。在某些条件下，问题可以被称为敏感的或不敏感的，或者是良态的或病态的。如果输入值发生相对变化时，输出结果也会发生等比例的相对变化，就说问题是不敏感的或良态的。另一方面，如果输出结果发生的变化比输入值的变化幅度大，那么就认为问题是敏感的或病态的。

1.5.3　后向与前向误差估计

假设我们通过映射函数 f 对 x 进行计算获得了y*，即y*=f (x)。现在真实值是y，那么微量y'=y*-y被称为前向误差（forward error），对应的估计方法称为前向误差分析。通常，很难获取该估计值。另一种方法是认为 y* 就是同样问题带有修正 x 的精确值，即y*=f ( x' )。现在x*=x'-x 就被称为y* 的后向误差（backward error）。后向误差分析就是对 x* 的参数估计过程。

1.5.4　误差可以忽略不计吗

这个问题的答案由你准备使用科学计算的具体领域和应用场景决定。如果计算导弹的发射时间，差0.1秒都会造成严重问题。如果是计算火车的到达时间，40秒误差也不是大问题。类似地，药物剂量的一点改变可能会对病人造成极其恶劣的影响。一般情况下，如果应用场景中出现的计算误差与人的生命无关，或者不会导致巨大损失，那么可以忽略不计。否则，就需要努力解决误差的影响。

1.6　计算机算术运算和浮点数

计算机为了表示实数引入了一种科学计算的近似类型。这种近似经过实数算术运算之后会进一步放大。下面将介绍这种实数的表示、算术计算方法以及对计算结果可能产生的影响。然而，这种近似误差不仅会在计算机运算过程中出现，在手工计算过程中为了降低复杂度而采用舍入计算也会造成近似误差。

在深入介绍用计算机表示实数的方法之前，让我们先回顾一下数学中使用的科学记数法。在科学记数法中，为了将极大的数或极小的数简化成较短的形式，用近似值与10的幂数乘积表示。在科学记数法中，数字都被表示成“a 乘以10的 b 次方”，即a×10b。例如，0.000000987654和987 654可以分别表示成9.87654×10⁻⁷和9.87654×10⁵。在科学记数法中，指数都是整数，而系数是实数，称为尾数（mantissa）。

IEEE（Institute of Electrical and Electronics Engineers，电气和电子工程师协会）在IEEE 754标准中确立了浮点数的表示方法。绝大多数主流设备都使用这套标准解决浮点数表示的相关问题。最新版标准在2008年发布，被称为IEEE 754-2008。这套标准确立了算术数据格式、交换格式、舍入规则、运算方法和异常处理。它还推荐了高级的异常处理方法、更多的运算规则、表达式估算，以及如何编写可重用的结果。

1.7　Python编程语言简介

Python是一种具有多种用途的高级编程语言，支持多种编程范式，包括过程式编程、面向对象编程、命令式编程、面向切面编程和函数式编程。Python经过扩展后还可以支持逻辑编程。Python是一种解释型语言，可以帮助开发者用比C++、Java以及其他语言更少的代码行完成同样功能的程序。Python支持动态类型和自动内存管理。Python自带一个全面的标准程序库，还有大量满足具体任务需求的第三方程序库。通过程序包管理工具，如pip、easy_install、homebrew（Mac OS X系统）、apt-get（基于Debian的Linux系统），以及其他安装工具（如Anaconda），安装Python程序包都非常容易。

Python是开源语言；它的解释器可以运行在多种操作系统上，包括Windows、Linux、OS X等系统。还有许多工具可以把Python程序编译成面向不同操作系统的可执行文件，如Py2exe和PyInstaller。可执行文件是独立代码，不需要Python解释器就能独立运行。

1.7.1　Python语言的指导原则

Guido van Rossum发明了Python，并确立了Python的指导原则，他被社区称为仁慈的独裁者（Benevolent Dictator For Life，BDFL）。Tim Peters把这些原则写成了一段偈语1（https://www.python.org/dev/peps/pep-0020/），让我们一句一句来解释。

1在Python中import this就会出现。——译者注

美观优于丑陋。（Beautiful is better than ugly.）这句话的意思是说代码是写给人看的，所以在所有程序中，代码都应该尽可能地好看，表达式语法应该简单，并且语法和风格应前后保持一致。

显明胜过隐晦。（Explicit is better than implicit.）绝大多数概念应该都是显式的，就像显式的布尔类型。我们直接用文字true和false表示布尔类型，而不需要用数字1和0表示。当然，Python也支持用数字表示布尔类型。而且非零值都可以作为布尔值。类似地，for循环可以处理任何数据结构，不需要考虑变量类型。一个循环语句既可以遍历元组的每个元素，也可以处理字符串的每个字符。

简单优于复杂。（Simple is better than complex.）Python的内存管理方式对用户而言很简单，通过垃圾回收器分配/回收内存，避免复杂操作2。另一个范例就是简洁的print语句：不仅打印时可以避免使用文件描述符，而且多个对象用逗号隔开就可以自动转换成可以打印的形式。

复杂胜过混乱。（Complex is better than complicated.）科学计算的概念都很复杂，但并非程序结构都会十分混乱。即使产品结构十分复杂，Python的程序结构也不应该杂乱无章。Pythonic方式本质上就是简单、简洁，SciPy和NumPy程序包都是非常好的例子。

扁平优于嵌套。（Flat is better than nested.）Python标准程序库提供了丰富多样的模块。由于Python的命名空间保持了扁平化结构，因此每个程序库导入名都比较简洁，例如java.net.socket在Python里就是socket。Python标准程序库遵循自备电池（batteries included）的设计哲学。标准程序库提供的模块可以满足各种需求。例如，各种各样的网络协议模块可以用来开发丰富的网络产品。另外，标准程序库里还提供了图形用户界面编程、数据库编程、正则表达式、高精度计算、单元测试等众多模块。标准程序库里的一些模块包括网络（socket、select、SocketServer、BaseHTTPServer、asyncore、asynchat、xmlrpclib和SimpleXMLRPCServer）、互联网协议（urllib、httplib、ftplib、smtpd、smtplib、poplib、imaplib和json）、数据库（anydbm、pickle、shelve、sqlite3和mongodb）、并行处理（subprocess、threading、multipro-cessing和queue）。

广泛胜过深邃。（Sparse is better than dense.）这一点是对Python标准程序库的要求，其覆盖面相对广泛浅显，而PyPI（Python package index，Python程序包索引器）则丰富多彩，博大精深，通过十分详尽的第三方程序包列表，可以为一个主题提供更深入、全面的支持。我们可以用pip安装Python程序包。

可读性不可或缺。（Readability counts.）程序的语句块都用空格创建，Python语法中使用最小的标点符号。语句结尾不需要分号。分号可以在句尾使用，但是并非必需。类似地，大多数情况下，表达式也不需要括号。Python使用内联文档产生程序API文档。Python的文档在运行时和线上都可以获取。

特例不能破坏规则。（Special cases aren't special enough to break the rules.）这句话隐含的意思是Python中每个成员都是对象。所有内置类型被设计成对象。用于表现数字的数据类型拥有方法。每个函数本身也是拥有方法的对象。

即使复杂现实会打破纯粹规则。（Although practicality beats purity.）Python支持多种编程范式，可以让用户选择最适合解决问题的范式。它支持面向对象编程、过程式编程、函数式编程等多种编程范式。

异常不能不辞而别。（Errors should never pass silently.）Python的异常处理方法，可以让异常通过较高层面的API编程解决，不需要触及底层API。Python不仅为标准异常的处理提供了非常详细的说明，同时也允许用户自定义异常，进行个性化处理。为了支持代码调试，Python提供了代码跟踪。在Python程序中，错误处理机制默认会在stderr里把完整的错误信息打印出来。跟踪信息里包含源文件名称、行号和源代码，如果存在的话。

除非需要它悄然无声。（Unless explicitly silenced.）为了应对一些特殊情况，有时也需要让异常悄无声息地运行。这时，可以使用不带except的try语句。还有一种办法就是把异常转换成普通的字符串。

模棱两可时，不要胡思乱想。（In the face of ambiguity, refuse the temptation to guess.）自动类型转换只在事先知晓的情况下才能使用，例如，整型与浮点型数据运算后生成一个浮点型数据。

应该有且仅有一种明确的方式解决问题。（There should be one—and preferably only one— obvious way to do it.）这一点是显而易见的。这就需要消除一切冗余。于是程序会变得更容易学习和记忆。

虽然那种方式起初并非显而易见，除非你是Guido。（Although that way may not be obvious at first unless you're Dutch.）上一条介绍的处理方式主要是面对标准程序库的。当然，第三方模块非常丰富。例如，Python拥有多个GUI的API，如GTK、wxPython和KDE等。网络编程工具也有很多，如Django、AppEngine和Pyramid等。

现在做比不做好。（Now is better than never.）这句话的意思是鼓励用户让Python成为他们最喜欢的工具。Python的ctypes类型，可以让Python程序使用C/C++共享程序库。

虽然不做比急于求成好。（Although never is often better than right now.）Python增强方案（Python Enhancement Proposals，PEP）提出了一种缓期执行的方案，对语法、语义和内建类型的改进都会在一段时期后发布的新版本中体现。

如果结果很难解释，一定不靠谱；如果容易解释，也许行得通。（If the implementation is hard to explain, it's a bad idea. If the implementation is easy to explain, it may be a good idea.）在Python里，所有的语法变更、新模块和API的引入，都会经过非常严谨的检查和审批流程。

2其实底层是通过引用计数等方式封装C语言的malloc和free函数。——译者注

1.7.2　为什么用Python做科学计算

说实话，如果仅从Python语言本身来看这个问题，可能其他编程语言更有优势。不过我们有NumPy、SciPy、IPython和matplotlib程序库，它们让Python成为了科学计算的最佳选择。我们将在后面的章节中介绍这些程序库。下面来总结一些Python语言和相关科学计算程序库的综合特性，这些特性让Python比其他编程语言（如MATLAB和R等）更适合做科学计算。基本上没有一种语言可以满足以下所有特性。

1. 简洁易读的代码

Python代码通常都很简洁，相比其他科学计算语言更加容易理解。正如Python指导原则中所说的，简洁是Python的设计哲学。

2. 编程范式丰富的语言设计

总的来说，Python语言的设计非常适合做科学计算，因为Python支持多种编程范式，包括过程式编程、面向对象编程、函数式编程和逻辑编程。用户可以有多种选择，可以自行确定最适合解决问题的范式。这一点在其他科学计算的编程语言中都是没有的。

3. 免费与开源

Python及其科学计算工具都可免费使用，而且都是开源的。这一点的好处是可以获取源代码。而其他大多数科学计算工具3都是独家销售的产品，其本身的算法和内容也没有向用户公开。

3R语言除外。——译者注

4. 语言交互能力

Python具有与大多数主流技术相互操作的交互能力。我们可以调用不同的编程语言的函数、代码、程序包和对象，例如MATLAB、C、C++、R、Fortran以及其他语言。还有许多方法可以实现这种交互能力，例如Ctypes、Cython和SWIG等。

5. 可移植与可扩展

Python支持绝大多数平台。因此，它是一种可移植的编程语言，而且它的程序在一个平台上写完后，迁移到另一个支持Python的平台上运行时，输出结果几乎是一样的。Python背后的设计原则使得它成为一种可以进行高度扩展的语言，这就解释了为什么我们可以拥有那么多可以解决各种任务的高级程序库。

6. 层次化模块系统

Python使用模块化系统在命名空间中以函数和类的形式组织程序。为了让Python的概念容易被人们学习和记忆，命名空间系统的设计非常简单。这么做还增强了代码的可读性与可维护性。

7. 图形用户界面程序包

Python提供了许多图形用户界面程序包和工具组合。这些工具套件和程序包可以用来做图形设计、用户界面设计、数据可视化以及许多其他与图形相关的事情。

8. 数据结构

Python支持非常全面的数据结构。数据结构在进行科学计算的程序的设计和实现过程中至关重要。Python语言的数据结构功能中最大的亮点就是词典。

9. Python的测试框架

Python的单元测试框架PyUnit具有完整的单元测试功能，可以和用户的Python程序整合在一起。它支持许多重要的单元测试概念，包括测试夹具、测试用例、测试套件和测试运行器。

10. 丰富的第三方程序库

Python认同“自备电池”的哲学，所以它的标准程序库里有丰富的程序包。作为一个可扩展的语言，Python也为不同需求的用户提供了大量成熟的个性化程序库。下面简单介绍一些用于科学计算的程序库。

NumPy/SciPy程序包可以满足科学计算中的许多数学和统计需求。SymPy程序库具有符号计算功能，可以实现基本算术、代数、积分、离散数学、量子物理等学科的符号计算。PyTables是一个高效处理拥有大量数据的数据集的程序包，通过一种分层数据库的形式（HDFS）存储数据。IPython让Python实现了交互式计算。它是一个命令行工具，同时支持多种语言的交互式计算。matplotlib程序库为Python/NumPy提供画图功能，可以画出许多的图形，例如线性图、直方图、散点图以及3D图。SQL Alchemy是一个Python编程的对象关系映射程序库。通过它，我们可以借助数据库的能力，让科学计算变得更加高效、轻松。最后，还应该介绍一个工具箱，它建立在前面介绍过的这些程序包以及其他众多开源程序库和工具箱的基础之上。这个工具箱的名字叫SageMath。它也是一个开源数学软件。

1.7.3　Python的缺点

介绍完Python相比其他科学计算语言的优越性之后，如果思考Python的缺点，会发现Python比较突出的一个缺点是它的集成开发环境（integrated development environment，IDE）没有其他语言强大。Python工具箱是把分散在各处的程序包和工具箱组合起来，其中有一些还是命令行界面。因此，就这一点来看，Python在一些平台上相比其他语言要逊色，例如Windows系统上的MATLAB。但是，这并不是说Python使用起来不方便，其实它依然很容易使用。

1.8　小结

本章首先介绍了科学计算的基本概念和定义，紧接着介绍了科学计算的操作流程，然后简要介绍了科学和工程领域的案例。之后，论述了解决复杂问题的有效策略，以及近似、误差和相关统计术语。

我们还介绍了Python语言的背景知识和指导原则，最后解释了为什么Python是进行科学计算的最佳选择。

下一章将介绍科学计算中涉及的多种数学/数值分析概念，还将介绍一些Python科学计算的程序包、工具箱和API。

第 2 章　科学工作流和科学计算的结构

科学工作流（scientific workflow）表示为解决科学计算问题所需的一系列结构化活动和计算步骤。科学计算涉及的计算都具有较高的强度和复杂度，还要处理复杂的依赖关系。本章其余部分会继续用科学计算问题的术语表示科学工作流。现在来介绍大多数科学计算问题都需要的各种数学和计算概念。

这一章将介绍以下主题：

科学计算的数学部分

Python的科学计算程序库

NumPy简介

SciPy简介

pandas数据分析

IPython（Interactive Python）交互式编程

SymPy符号计算

matplotlib数据可视化

2.1　科学计算的数学部分

首先将简要介绍科学计算问题中可能出现的各种数学概念，还会介绍对应问题的解决方法。不过，这里不会深入方法的细节。在后面的章节中，我们会详细介绍与这些概念相关的Python API。

2.1.1　线性方程组

在科学计算和应用数学中，最常见的数学概念就是线性代数方程组。通常，这类系统的出现都是由于线性方程对非线性方程的近似，或代数方程对差分方程的近似。

线性方程组通常由一组联立线性方程构成，示例如下：

这个方程组由三个线性方程组成，带有三个未知变量：x₁、x₂和x₃。该方程组的解就是同时满足三个方程的三个变量的值。满足这个方程的解如下所示：

这个解同时满足三个方程。这正是我们把线性方程系统称为线性方程组的原因——整个方程组被看成一个整体而非各个独立的方程。通常，使用迭代方法，通过重复的步骤对方程组进行求解。在程序中可以通过一些循环结构实现迭代。另外也有非迭代方法，通过计算公式求解。线性方程组的求解方法很多，有迭代方法和非迭代方法。例如，高斯LU矩阵分解法与高斯消元法是最常见的非迭代方法。雅可比迭代法和高斯—赛德尔迭代法是常见的迭代方法。

2.1.2　非线性方程组

非线性方程组是指一组联立方程，其中未知变量的阶数大于1。这个系统可以是一维，也可以是多维。一般情况下，非线性方程可以表述为以下形式。对于方程 f，x 需要满足以下条件：

x 的值被称为方程的根（root）或零值（zero）。

非线性方程按照维度分为两种，如下所示。带一个自变量的一维非线性方程如下所示：

方程的解是满足条件f (x) = 0的标量（scalar）x。另一种非线性方程是带有 n 个自变量的非线性方程组：

方程组的解是同时满足所方程f (x) = 0的矢量（vector）x。

例如，一个一维非线性方程如下：

方程的带两位有效数字的近似解为0.36。一个多维非线性方程组如下：

方程组的矢量解为[1, 2]和[-2, -1]。

非线性方程和非线性方程组的解法很多。一维非线性方程的解法有：

二分法（bisection method）

牛顿法（Newton's method）

割线法（secant method）

插值法（interpolation method）

逆插值法（inverse interpolation method）

逆二次插值法（inverse quadratic interpolation，IQI）

线性分式插值法（linear fractional interpolation）

非线性方程组的解法有：

牛顿法

割线法（secant updating method）

阻尼牛顿法（damped Newton's method）

Broyden法

由于这些方法都是迭代法，所以收敛的速度至关重要。所谓收敛，是指这些方法一开始用近似解，通过不断迭代得到精确解。朝一个解收敛的速度称为收敛速度。收敛速度越快，则获得精确解需要消耗的时间越少。对于一些收敛速度较快的方法，比如牛顿法，初始值的选择至关重要。有些方法可能会因为初始值选择不合适，导致无法收敛。有一些均衡的方法是收敛速度和解的精确程度的折中算法。阻尼牛顿法就属于这类方法。SciPy软件包里面实现了大量的算法来解非线性方程组。可以参考http://docs.scipy.org/doc/scipy-0.14.0/reference/generated/scipy.optimize.newton.html，获取牛顿—拉弗森方法（Newton-Raphson method，即牛顿法）的用法和实现代码。

2.1.3　最优化方法

最优化（optimization）是获取最优可行解的过程。通常，模型的可行解都有一个取值范围，有最大值或最小值。假如我们要估算一个新项目的造价，那么最优解一定会取最小值。假如要评估不同销售策略的利润率，那么最优解一定是利润率最高的销售策略。SciPy有一个优化技术软件包，具体内容可以参考http://docs.scipy.org/doc/scipy/reference/optimize.html。最优化方法在科学与工程领域应用普遍。具体应用场景如下：

工程力学

经济学

运筹学

控制工程

石油工程

分子建模

2.1.4　内插法

在科学与工程领域中，人们经常通过抽样或实验获取大量数据。这些数据点可以看成某个函数一些自变量位置上的值。人们通常需要估计这个函数在样本范围内的某个位置上的值。估计的过程称为内插法（interpolation）。通常借助曲线拟合与回归分析方法求解。

例如，自变量 x 和因变量f (x)对应的数值如下。

x

4

5

6

7

8

9

10

f (x)

48

75

108

147

192

243

300

通过内插法就可以估计自变量在其他位置的函数值，例如x=7.5或x=5.25，即f(7.5)或f(5.25)。虽然这组样本的函数非常简单（f=3x²），但它也可能源自某个真实案例。例如，这组数据可能是某家电商企业的网络中心机房的温度。这些温度是在不同的时间点测量的。两次测量之间的时间间隔，既可能是固定不变的，也可能是完全随机的。这个例子中，函数值就变成了不同时间点测量的机房的温度。我们需要估计或者插值计算这一天剩余时间机房的温度。

这组数据的另一个场景可能是，对某个年龄段的用户每天花在Facebook或WhatsApp上的小时数的数量统计。通过这些数据，就可以估计各个年龄段的用户每天花在Facebook或WhatsApp上的时间。

2.1.5　外插法

另一种类似的方法是外插法（extrapolation）。通过它的名称可知，这个方法是要估计函数在样本范围之外的值。例如，假设我们已经获得了12岁到65岁各个年龄段的用户每天花费在Facebook或WhatsApp上的小时数。那么估计12岁以下和65岁以上的用户每天花在Facebook或WhatsApp上的小时数就属于外插法的范围了。这是因为自变量的范围已经超出了已知的样本范围。

有许多方法可以解决内插和外插问题。内插法如下所示：

分段常数内插法（piecewise constant interpolation）

线性内插法（linear interpolation）

多项式内插法（polynomial interpolation）

样条内插法（spline interpolation）

基于高斯过程的内插法（interpolation via Gaussian processes）

外插法如下所示：

线性外插法（linear extrapolation）

多项式外插法（polynomial extrapolation）

锥外插法（conic extrapolation）

法国曲线外插法（French curve extrapolation）

2.1.6　数值积分

数值积分（numerical integration）是用数值分析技术求取积分近似值的过程。积分的数值计算过程称为求积分（quadrature）。之所以要使用近似方法求积分，是因为有些函数不能通过解析方法求精确解。即使公式存在，它也可能不是最有效的求积分方法。有时我们需要求一个函数的积分值，但只知道其中的一些样本。使用数值积分的方法，就可以为函数的积分获得近似值。这个方法首先对一些已知点进行多项式拟合（polynomial fitting），然后对逼近函数进行积分。在Python中，SciPy程序包提供了积分模块。关于这个模块的具体方法和实现，请参考http://docs.scipy.org/doc/scipy/reference/integrate.html。有许多方法可以解决数值积分问题，如下所示：

辛普森法则（Simpson's rule）

梯形法则（trapezoidal rule）

精炼梯形法则（refined trapezoidal rule）

高斯积分法则（Gaussian quadrature rule）

牛顿—柯特斯积分法则（Newton-Cotes quadrature rule）

高斯—勒让德积分法则（Gauss-Legendre integration）

2.1.7　数值微分

数值微分（numerical differentiation）是利用已知的函数值估计函数导数的过程。数值微分在许多领域都有十分重要的作用。一般的使用场景是，我们没有得到函数的形式，只观察到函数的一些离散值。这时，如果要估计导数，就要关注与函数的导数相关的数值变化情况。为了计算的快速和便利起见，人们往往更喜欢用离散值估计函数的导数，而不是寻找精确解，因为它虽然存在，但是往往很难求解。微分方法经常被用于解决最优化问题。机器学习技术也常常要用到数值微分方法。

一些数值微分方法如下所示：

有限差分近似法（finite difference approximation）

微分求积法（differential quadrature）

有限差分系数（finite difference coefficients）

插值微分法（differentiation by interpolation）

2.1.8　微分方程

微分方程（differential equation）是一种描述导数与其函数关系的数学方程式。如果函数是一个物理量，那么导数就是这个物理量的变化率，微分方程就是这个物理量和其变化率的关系。受重力影响的自由落体运动方程通常是用一组微分方程表示的。微分方程的应用范围很广，包括纯数学和应用数学、物理学、工程和其他学科。这些学科都会涉及不同类型的微分方程。

微分方程主要用来对不同的物理、工艺和生物学过程建模。许多情况下，微分方程可能无法直接求解。因此，通常都用数值方法求近似解。物理学的基本法则（如牛顿第二运动学定律和爱因斯坦场方程）和化学的基本法则（如化学反应速率方程），都是通过微分方程得出的。微分方程也可以用于对复杂的生物学行为（生物种群增长模型）和经济学行为（指数增长模型）建模。

微分方程可以分为两类：常微分方程（ordinary differential equations，ODE）和偏微分方程（partial differential equations，PDE）。常微分方程是包含一个自变量的函数及其偏导数的微分方程。偏微分方程是包含多个自变量的函数及其导数的微分方程。多自变量函数的偏导数就是函数对每一个自变量的导数1。关于SciPy的微分方程计算功能的详细介绍，可以参考http://docs.scipy.org/doc/scipy-0.13.0/reference/generated/scipy.integrate.ode.html。

1求导时可以把其他变量看成常量。——译者注

解常微分方程的方法如下：

欧拉方法（Euler's method）

泰勒级数法（Taylor series method）

龙格—库塔法（Runge-Kutta method）

四阶龙格—库塔法（Runge-Kutta fourth order formula）

预估—校正法（predictor-corrector method）

解偏微分方程的方法如下：

有限元法（finite element method）

有限差分法（finite difference method）

有限体积法（finite volume method）

1. 初始值问题

常微分方程的初始值是未知函数在定义域内的某个位置的值。例如dy/dx=f (x,y)，其中y=y₁，x=x₁。

2. 边界值问题

边界值问题是指带约束的微分方程，方程的解必须能够同时满足微分方程的所有约束。这些约束称为边界条件。

2.1.9　随机数生成器

在计算领域，随机数生成器是一种产生不包含任意模式的序列的算法或过程。之所以称为随机数，是因为它们没有任何模式可循。产生的数字基本无法预测。生成随机数的程序与日俱增，也促进了随机数生成方法的发展。这项技术历史悠久，最早的掷骰子、抛硬币和抽扑克，都可以看成是随机数生成器。但是，这些方法都只能产生有限的随机数。

随机数生成器的计算方法应用得十分普遍，例如统计抽样、赌博、随机生成的设计、科学与工程领域的计算机仿真，以及需要不可预测结果的大量其他领域，如密码系统。

随机数生成器主要有两类，真随机数生成器和伪随机数生成器。真随机数生成器通过真实的物理过程生成随机数，例如硬盘的实际读写时间。伪随机数生成器通过计算机算法生成随机数。还有一种随机数生成器通过统计分布生成随机数，例如泊松分布、指数分布、正态分布、高斯分布等。

一些伪随机数生成器如下所示：

BBS随机数生成器（Blum Blum Shub）

Wichmann-Hill随机数生成器（Wichmann-Hill）

进位—互补—乘法随机数生成器（complementary-multiply-with-carry）

反向同余随机数生成器（inversive congruential generator）

ISAAC随机数生成器（ISAAC (cipher)）

滞后斐波那契随机数生成器（lagged Fibonacci generator）

线性同余随机数生成器（linear congruential generator）

线性反馈移位寄存器（linear-feedback shift register）

最大周期（索菲·热尔曼质数）倒数随机数生成器（maximal periodic reciprocals）

梅森旋转随机数生成器（Mersenne twister）

进位相乘随机数生成器（multiply-with-carry）

Naor-Reingold伪随机数生成器（Naor-Reingold pseudo-random function）

Park-Miller随机数生成器（Park-Miller random number generator）

WELL伪随机数生成器（Well-equidistributed long-period linear）

2.2　Python科学计算

Python对科学计算的支持，是通过不同科学计算功能的程序包和API建立的。对于科学计算的每个方面，我们都有大量的选择以及最佳的选择。Python科学计算各个方面的可选包如下所示。

画图：目前，最流行的二维图制作程序库是matplotlib。还有许多画图包，如Visvis、Plotly、HippoDraw、Chaco、MayaVI、Biggles、Pychart、Bokeh。还有一些画图程序包是在matplotlib的基础上改进功能，如Seaborn和Prettyplotlib。

最优化：SciPy程序包里有最优化模块。OpenOpt和CVXOpt同样具有最优化功能。

高级数据分析：Python可以通过RPy或R/S-Plus接口与R语言配合使用，实现高级的数据分析功能。Python自己的高级数据分析工具就是大名鼎鼎的pandas了。

数据库：PyTables是一种用于管理分层数据库的工具。这个软件包是以HDF5数据库为基础建立的，用于处理较大的数据集。

交互式命令行：IPython是Python的交互式编程工具。

符号计算：Python具有符号计算功能的程序包有SymPy和PyDSTool。本章后面会介绍符号计算方法。

专用扩展包：SciKits程序库为SciPy、NumPy和Python提供了专业化的扩展。SciKits的一些软件包如下。

scikit-aero：Python航空工程计算程序包。

scikit-bio：提供生物信息学领域的数据结构、算法和教育资源程序包。

scikit-commpy：Python数字通信算法程序包。

scikit-image：SciPy图像处理程序包。

scikit-learn：Python机器学习和数据挖掘程序包。

scikit-monaco：Python蒙特卡罗算法程序包。

scikit-spectra：建立在Python pandas上的光谱学程序包。

scikit-tensor：Python多线性代数和张量分解（tensor factorizations）程序包。

scikit-tracker：细胞生物学的目标检测和跟踪程序包。

scikit-xray：X射线科学的数据分析工具。

bvp_solver：Python求解两点边界问题的程序包。

datasmooth：SciKits提供的数据平滑程序包。

optimization：Python数值优化程序包。

statsmodels：SciPy统计学计算与建模程序包。

第三方/非SciKits的软件包/应用/工具：还有许多软件包/工具应用于不同的科学领域，例如天文学、天体物理学、生物信息学、地球科学等。一些科学领域专用的Python程序包和工具如下。

Astropy：社区主导的用于支持天文学和天体物理学计算的Python程序包。

Astroquery：这个程序包是一组用于访问在线天文数据的工具。

BioPython：这个程序包是用Python进行生物计算的工具包。

HTSeq：这是用Python进行高通量测序数据（high-throughput sequencing data）分析的程序包。

Pygr：这是Python中基因测序和对比分析的工具包。

TAMO：这是Python中利用DNA序列基元进行转录调控分析的应用。

EarthPy：这是地球科学领域的IPython Notebook案例集合。

Pyearthquake：进行地震与MODIS（中分辨率成像光谱仪）数据分析的Python程序包。

MSNoise：这是一种使用环境地震噪声监测地震波速度变化的Python程序包。

AtmosphericChemistry：对大气化学运作方式进行探测、构造与转换的工具。

Chemlab：这是一个能够进行化学相关计算的程序库。

2.2.1　NumPy简介

Python经过扩展可以支持数组和矩阵类型，并且具有大量的函数，可以计算这些数组和矩阵。这些数组是多维的，而这个扩展程序包就是NumPy。NumPy的基本功能实现之后，许多API/工具都在它的基础上建立，包括matplotlib、pandas、SciPy和SymPy。下面来看看基于NumPy建立的那些工具和API。

2.2.2　SciPy程序库

SciPy是一个为科学家和工程师开发的Python程序库，用来完成科学计算相关的功能。它有许多功能，如最优化方法、线性代数、积分、插值方法、图像处理、快速傅里叶变换、信号处理以及一些特殊函数。它可以解常微分方程以及其他科学与工程问题。它建立在NumPy数组对象的基础上，是NumPy技术栈的重要成员之一。因此，有时NumPy技术栈和SciPy技术栈可以看成是同一个技术栈。

SciPy子程序包

SciPy包括以下子程序包。

constants：物理常数和转换因子。

cluster：层次聚类、矢量量化和K-means聚类。

fftpack：离散傅里叶变换算法。

integrate：数值积分程序。

interpolate：插值工具。

io：数据输入输出。

lib：Python外部库包装器。

linalg：线性代数程序。

misc：附件（例如图像读写操作）。

ndimage：多维图像处理的各种功能。

optimize：优化算法，包括线性规划。

signal：信号处理工具。

sparse：稀疏矩阵及其相关算法。

spatial：KD树、RNN（最近邻搜索）算法、距离函数。

special：特殊函数。

stats：统计学函数。

weave：可以把C/C++代码写成Python多行字符串执行的工具。

2.2.3　用pandas做数据分析

pandas是一个开源的Python程序库，能够进行高性能的数据处理与分析。使用pandas，用户可以在Python中实现完整的数据分析工作流。而且将pandas、IPython工具包和其他Python程序库组合起来，可以获得非常好的数据分析性能和效率。但是pandas数学功能不足，目前只支持一些简单的回归方法。但是，我们可以从statsmodels和scikit-learn里找到其他功能。pandas可以非常高效地连接与合并数据集，支持丰富的输入输出文件格式，包括直接内存读写、CSV、纯文本、Excel、SQL数据库和HDF5格式。

2.3　IPython交互式编程简介

IPython可以让Python支持多种语言的交互式编程。IPython原本是一个专为Python编程设计的命令行工具，现在可以支持多种编程语言。IPython具有非常强大的代码自省功能、新的shell语法、命令自动补全、命令历史保存等功能。自省功能是指命令行环境识别不同文字含义（属性、方法以及其他具体细节，例如超类）的一种能力。IPython的一些特性如下所示。

支持系统命令行shell和QT版命令行shell。

支持浏览器版本的Notebook，可以编写代码、数学公式，以及支持多媒体和图表格式。

支持交互式数据可视化与其他图形用户界面。

支持高性能的并行计算。

2.3.1　IPython并行计算

IPython对并行与分布式计算的支持非常好，可以满足大规模计算需求。IPython还具有开发、执行、调试和监控并行与分布式计算的能力。IPython支持多种并行方式，如下所示，还支持不同并行方式的混合形式。

单程序多数据（single program multiple data，SPMD）并行

多程序多数据（multiple program multiple data，MIMD）并行

消息传递接口（Message Passing Interface，MPI）

任务与数据并行

用户自定义方法

2.3.2　IPython Notebook

IPython Notebook是基于网络的交互式编程环境。通过这个环境可以创建IPython Notebook。它可以识别单个用户输入的文字或代码表达式，运行它们，然后把结果返回给用户。这种功能被称为读取—求值—输出循环（read, evaluate, print, and looping，REPL）。在IPython Notebook里，用户还可以使用下面的程序库：

IPython

ØMQ（ZMQ）

Tornado（网络服务器）

jQuery

Bootstrap（前端框架）

MathJax

运行Notebook程序，会在电脑上创建一个局域网服务器，用浏览器就可以打开。IPython Notebook是JSON格式的文档，可以用代码、普通文字2、数学运算、图像和图表等形式完成不同类型的计算。IPython Notebook也可以通过网页操作或命令行代码转换成不同的格式，目前支持HTML、LaTeX、PDF、Python等多种格式。

2支持Markdown语法。——译者注

IPython Notebook的程序开发过程如下图所示。首先准备数据，如下图左边所示，然后开发程序并管理版本。程序开发完毕后，还可以导出为不同格式。

IPython还有一些非常给力的特性。

可以与GUI程序库和工具箱实现无缝衔接。IPython可以和许多Python版的GUI工具箱/程序库无缝衔接，如Tkinter、PyGTK、PyQt和wxPython。

集群管理：IPython可以通过MPI/异步状态回调信息对集群计算进行管理。

类UNIX运行环境：IPython的默认功能与UNIX命令行环境类似，支持客户自定义。

　下载源代码

可以从网站http://www.packtpub.com登录你的账户，下载从Packt出版社购买的所有图书的源代码。如果是通过其他途径购买的Packt图书，可以访问http://www.packtpub.com/support，注册一个账户，源代码会发到你的邮箱。

IPython用户界面的截图如下页所示（源自http://ipython.org/notebook.html）。

IPython命令行工具的特性有以下各点。

Tab键自动补全：用户不需要在IPython输入全部代码。只要敲入前面一部分，再按Tab键，IPython就会把剩下的命令自动补全。

浏览对象结构：对象的各种属性都可以通过IPython命令行的自省功能进行检查。

魔法函数：有许多魔法函数可供用户使用。

运行与编辑代码：IPython命令行可以运行并编辑Python脚本。

调试：IPython命令行具有十分强大的调试功能。

历史信息：IPython命令行可以存储历史命令和运行结果。

系统命令：用户还可以使用系统命令。

自定义系统命令别名：用户可以根据自己的需要为系统命令定义别名。

配置文件：IPython环境可以用配置文件进行自定义。

启动文件：用户可以设置IPython会话启动时运行的命令和代码。

IPython Notebook的用户操作界面

2.4　用SymPy进行符号计算

符号计算主要是操作数学对象与表达式。这些数学对象与表达式可以直接表现自己，它们不是估计/近似值。表达式/对象是未经估计的变量，以符合形式呈现。

我们用下面的图来对比普通计算与计算机符号计算的差异。每种计算形式有两个例子。示例A1与示例A2是普通计算示例，示例B1与示例B2是符号计算示例。示例A1与示例A2都有明确的输出结果。让我们再看看示例B1与示例B2的输出结果。示例B1的输出结果是sqrt(3)，没有进行数值估计，只是用符号表示。这是因为在符号计算中，如果sqrt函数的参数不是一个能够开尽的数3，那么它就直接用符号表示结果。另一方面，在示例B2中，输出结果更加简单。这是因为在这个示例中，表达式是可以进一步简化的；sqrt(27)可以写成sqrt (9 × 3)或3(sqrt(3)，因此可以简化成3sqrt(3)。

3能开尽的数则如4、9、16等。——译者注

普通计算与符号计算的对比

2.4.1　SymPy的主要特点

SymPy是一个符号计算程序库，具有各种类型的符号计算能力。它可以简化数学表达式（就像前面看到的sqrt(27)），计算微分、积分与极限，求方程的解、矩阵运算以及各种数学函数。所有这些功能都通过数学符号完成。

下面介绍SymPy的主要特点。SymPy程序库由若干核心能力与大量的可选模块构成。SymPy的主要功能如下所示。

核心能力包括基本算术与公式简化，以及模式匹配函数，如三角函数、双曲函数、指数函数与对数函数等。

支持多项式运算，例如基本算术、因式分解以及各种其他运算。

微积分功能，包括极限、微分与积分等。

各种类型方程式的求解，例如多项式求解、方程组求解、微分方程求解。

离散数学。

矩阵表示与运算功能。

几何函数。

借助pyglet外部模块画图。

物理学支持。

统计学运算，包括概率与分布函数。

各种打印功能。

多种编程语言4与LaTeX代码生成功能。

4C、Fortran、JavaScript、Mathematica、MATLAB和Python。——译者注

2.4.2　为什么用SymPy

SymPy是一个开源的程序库，采用自由的BSD许可证。用户可以自由调整源代码。这在其他软件中都不能实现，例如Maple和Mathematica。SymPy的另一个优势是，其设计、开发与执行都是在Python中。对Python开发者来说，这是一个额外的优势。与其他工具相比，这个程序库具有高度的扩展性。

2.5　画图程序库

Python的话题程序库是matplotlib。它提供了一个面向对象的API，让用户可以在用各种Python的GUI工具箱开发的应用程序中增加图表。SciPy/NumPy都使用matplotlib绘制数组的二维图。matplotlib的设计理念是简化画图功能，用户调用极少的函数就能轻松地创建各种图形，有时甚至只需要调用一个函数。还有一些专业的工具箱/API扩展了matplotlib的功能。这些工具中，有些是与matplotlib绑定在一起的，有些需要单独下载。matplotlib的部分扩展API如下。

Basemap，绘制地图的工具箱。

Cartopy程序包，让地图数据分析与可视化更加简单。

Excel工具，可以让matplotlib与Excel进行数据交互。

Qt与GTK+开发接口。

mplot3d，可以绘制三维图。

下面的图表中演示了用matplotlib绘制的不同图形。这些截图都源自matplotlib网站（http://matplotlib.org/users/screenshots.html）。

不同类型图形的差异

简单图形

多坐标轴的子图示例

频率直方图

路径示例

mplot3d：三维图

流向图：矢量场的流向曲线图

条形图

饼图

极坐标图

对数函数图

股票图：将matplotlib不同的绘图函数、布局命令和标签工具组合起来，绘制复杂股票图

2.6　小结

这一章介绍了数学与数值分析的许多概念，包括线性与非线性方程组、最优化方法、内插法、外插法、数值微分与积分、微分方程与随机数生成器。

本章的第二部分简要介绍了用Python语言进行科学计算的程序包/工具箱/API，还介绍了NumPy、SciPy、IPython、SymPy、matplotlib和pandas的功能与特性。

下一章将介绍如何为科学计算准备数据与管理数据。

第 3 章　有效地制造与管理科学数据

这一章介绍科学计算数据。首先介绍数据的基本概念，然后介绍管理数据与对数据进行不同操作的各种工具箱，之后介绍生成模拟数据的不同数据格式和基于随机数的技术。

这一章将介绍以下主题：

数据、信息与知识的基本概念

各种数据存储软件与工具的概念

可以对数据进行的操作

科学数据的标准格式

现成的数据集

用随机数生成模拟数据

大规模数据集简介

3.1　数据的基本概念

数据是关于实体的真实情况和数字的原始和未经梳理的形式。任何未经梳理/原始的数量与数值，比如一系列数字或字符，只要能够表示真实世界中的概念、现象、对象和实体，就可以视为数据。数据没有时空限制，任何地方都有数据。

数据可以被转换成信息，可以用于实现其所在组织的目标。当数据被赋予某种属性时，它就成了信息。准确及时的数据，如果按照特定的目的加以组织和准备并在特定的场景中展示出来，就可以称为信息。这样就为数据赋予了意义与实用性。

再加上专业经验，数据与信息就可以转换成知识。知识需要与具体问题相关的大量数据处理经验，例如商品定价与天气预测。

现在，让我们研究一个关于数据、信息与知识的科学案例。79°F显然只是一个温度计读数，是数据。如果为这个读数增加一些细节，例如这个温度是印度孟买印度门2015年3月3日下午5点30分的温度，那它就是信息了。根据多个年份某一周的每小时温度读数，预测下一年同一周的温度就是知识。类似地，根据印度北部过去两天下大雪的信息，预测印度中部的气温将下降几度就是知识。数据、信息与知识的关系如下图所示。这个过程是先从实验中收集数据，再从数据中提取信息，最后对信息进行细致的分析，从中获取知识。

数据、信息、知识金字塔

3.2　数据存储软件与工具箱

计算机科学的概念日新月异，存储数据的软件和工具也在快速进化。目前，市面上有许多存储数据的软件和工具箱。主要有两类数据存储软件与工具箱，在每一类中还有一些子类。用于管理与存储数据的软件与工具箱分类如下图所示。

数据存储与管理的软件与工具分类

3.2.1　文件

第一种存储数据的软件与工具类型是不同格式的纯文本。纯文本文件又可以分为结构化文件与非结构化文件两种子类型。结构化文件是指按照预先设定好的固定格式存储数据。而在非结构化文件中，数据存储没有预先设定的格式。通常，这两种类型的文件都可以存储文本数据，而在一些特殊的科学软件程序中，还可以存储图像、音频、视频和其他非文本数据。

1. 结构化文件

结构化文件的一个例子是逗号分割值（comma-separated values，CSV）文本文件。在这类文件中，数据字段是通过逗号或分隔符进行区分的。分隔符可以是任字符或符号。分隔符最好不要使用存储数据中已有的符号。假如要存储财务数据，那么逗号就不适合作为分隔符。

例如下面的CSV文件：H.K. Mehta, 08-Oct-1975, Higher Education department, 50,432。

这条记录中包含姓名、出生日期、部门名称和工资。对于这个CSV文件，空格、点号（.）、逗号（,）、连字符（-）都不适合作为分隔符。如果我们选择其中之一——空格、点号、逗号或连字符——作为分隔符，那么逗号会把工资分割成两个数值，连字符会把出生日期分割成三个值，点号（.）会把姓名分割成三个值，空格会把部门名称分割成三个值。对于这条记录，分隔符可以是其他符号，例如问号（?）和管线命令（|，竖杠）。通常，在商务数据中，管线命令（|）是最常用的分隔符。

宽度固定文件（fixed-width file）是另一种结构化文件。在这类文件中，每个字段的宽度是预先设定好的，一直会保持不变。如果某个记录中的一个字段宽度比固定值小，就补空格直到达到固定宽度；如果比固定值大，就截断以满足宽度要求。

2. 非结构化文件

非结构化文件的例子包括网络服务器日志文件、图书、期刊和电子邮件。这类文件包括文本与非文本数据。文本数据是指可以用某种字符编码原则表示的数据，例如美国信息交换标准码（American Standard Code for Information Interchange，ASCII）和Unicode码。数据存储文件还有另一种类型，称为半结构化文件。这类文件不具备关系型数据库使用的常规格式。半结构化数据通过标签和其他标记区分字段，为标签赋予适当含义，从而创建记录或字段。这类数据的典型例子是XML和JSON格式。这些数据格式的优点是具有语言和系统平台独立性。因此，操作结果不会随语言与系统平台的变化而改变。

3.2.2　数据库

第二类数据存储产品是数据库。除了文件，还有许多数据库可以存储计算用的数据。数据库也可以按照有无模式分为两类：基于模式的数据库和无模式的数据库。基于模式的数据库是传统数据库，需要用户在存储数据前先创建数据对象的结构。而无模式的数据库是大型数据库领域的新方向，致力于满足大型应用程序的数据存储需求。基于模式的数据库包括MySQL、Oracle、MS SQL Server、Postgres等，无模式的数据库包括MongoDB、HBSE和Cassandra等。

3.3　常见的数据操作

除了数据存储之外，想要高效地管理与使用数据，还要执行很多操作。

数据耕耘（data farming）：利用高性能计算方法对大型数据库进行多次仿真的过程称为数据耕耘。数据耕耘的输出结果可以对数据的表层特征与深层特征进行全面的描述，它可以辅助决策制定过程。数据耕耘是一个多学科交叉的领域，包括高性能计算、数据分析与可视化以及大型数据库。

数据管理：数据管理是一个广泛的概念，由许多数据操作组成，如下所示。

数据治理（data governance）：这是数据管理的主要控制部分，目的是保证输入数据满足模型需要的标准格式。数据处理可以通过手工或自动化流程进行控制。

数据架构、分析与设计：数据架构包括数据采集所需的各种模型、流程、算法、规则与标准，以及数据存储的结构、数据集成的方法等。制定数据清洗与转换流程的相关分析与设计原则，对整个项目都是有益的。

数据库管理：数据库管理是一个涉及多项活动的复杂任务，包括数据库的设计与开发，数据库监控，系统整体监控，数据库性能改善，数据库能力提升与扩展计划的制定，数据安全的计划、实施与维护等。

数据安全管理：这包括数据安全的相关管理活动，包含接入权限的管理、数据私密性管理以及其他与数据安全相关的内容（例如数据清洗/清除、加密、覆盖与备份）。

数据质量管理：这是与数据质量改善相关的任务，包括许多操作，如下所示。

数据清洗，对有错误或不准确的数据（即不干净的数据）进行检验与校正。

数据完整性（data integrity）检验，确保不同时期与不同处理阶段的数据是准确一致的。

数据质量增强（data enrichment），对数据进行精炼与增强，剔除数据中的拼写与印刷错误，改进数据质量。例如，任何时候已获取的分数都不可能超过可能的最高分。这时，我们必须改正那些分数比最高分还高的数据库记录。

数据集成（data integration），是一个复杂的流程，需要丰富的经验，因为它需要将多个数据源的数据转换成统一格式，并组合在一起，而且转换过程不能影响数据的意思。

数据仓库管理：数据仓库管理包含数据集市的准备工作、数据挖掘与各种数据处理相关操作的实现，例如数据抽取、转换与加载（extraction, translation, and loading，ETL）操作。

元数据管理：这是对存储在数据库中的数据属性进行管理的过程。这些数据称为元数据（metadata）。元数据包括存储数据的描述、数据创建与修改的具体时间、数据的所有者、物理设备的位置以及其他相关细节。

数据输入与输出：任何一种应用程序都包括许多重要的操作，无论是商业产品还是科学产品。一般情况下，在进行输入与输出操作时必须小心谨慎。用户从应用程序中输出或导入数据时，需要考虑应用程序自身的特性。因此，需要使用恰当的数据格式。

科学数据存档：这个过程是指，根据应用程序和组织制定的策略中关于科学家应该存储多少数据，以及数据的接入权限等级管理的相关内容，对科学数据进行存档，以备长期使用。

3.4　科学数据的格式

Python的科学数据存储有多种格式/形式。通常，大多数的科学计算API/语言/工具包支持这些数据格式的输入和输出。这些格式中比较常用的如下所示。

网络通用数据格式（Network Common Data Form，NetCDF）：它是一种自描述、与机器/设备/平台无关、基于矩阵的科学数据格式，同时也是支持创建、访问和共享这一数据格式的函式库。它通常被捆绑于一系列的软件库中来生成和操作。通常，这种格式被用于天气预报、气候和气象上的气候变化、海洋学和GIS应用等领域。大部分GIS应用都支持NetCDF格式作为输入和输出格式，并且NetCDF还用于科学数据交换。

该格式源于美国大气科学研究大学联盟（University Corporation for Atmospheric Research，UCAR）的Unidata项目，该项目的主页由他们建设。它的网址是http://www.unidata.ucar.edu/software/netcdf/docs/faq.html。该网页对NetCDF有如下描述：

“网络通用数据格式（NetCDF）是一组对矩阵式数据接入和C语言、Fortran、C++、Java等语言的自由分发数据接入库的集合的接口。NetCDF库支持一种与机器无关的格式来表示科学数据。因此，接口、库和格式支持科学数据的生成、获取和分享。”

HDF（Hierarchical Data Format，分层数据格式）：这是一组文件格式，已演变出不同的版本（HDF4和HDF5）。HDF格式指一种为存储和处理大容量科学数据设计的文件格式及相应库文件。HDF最早由美国国家超级计算应用中心（NCSA）开发，目前在非盈利组织HDF小组的维护下继续发展。该小组确保HDF5格式的进一步发展和相关的工具/技术的发展。HDF现在受到众多商业与非商业平台的支持，包括Java、MATLAB/Scilab、Octave、IDL、Python和R。

FITS格式（Flexible Image Transport System，普适图像传输系统）：这是一种可用于存储、传输、操作科研或其他用途图片的一种数据文件格式。这种格式广泛用于天文学领域。它有好几种方式描述和元数据一起存储的光度学和空间校准信息。FITS格式的第一次标准化出台于1981年。该标准最近的版本发布于2008年。FITS也可以用于存储非图像数据，如光谱、数据立方甚至是数据库。FITS还有一个重要的特征，那就是该格式的新版本总是后向兼容的。另一个重要特征是，文件中的元数据用人类可读的ASCII码存储于头文件中。这有助于用户分析文件并理解存储于其中的数据内容。

波段交叉数据/波段交叉格式（Band-Interleaved Data/Band-Interleaved Files）：它们是二进制格式。这意味着数据被存放在非文本文件中，通常这种数据格式用于遥感和高端GIS。这些文件有两种子类型，分别是波段按行交叉格式（Band Interleaved by Line，BIL）和波段按像元交叉格式（Band Interleaved by Pixel，BIP）。

通用数据格式（Common Data Form，CDF）：这是一种存放标量和与平台无关的多维数据的数据格式。因此它被用于存放科学数据，也是一种很受研究人员和机构欢迎的数据交换格式。空间物理数据辅助系统（Space Physics Data Facility，SPDF）提供了一个CDF软件工具，作为戈达德太空飞行中心（Goddard Space Flight Center，GSFC）操作数据的工具。CDF还提供了支持各种编程语言、工具和API的良好接口，包括C、C++、C#、Fortran、Python、Perl、Java、MATLAB和IDL。

各种科学数据格式有一些共同的特征，举例如下。

这些格式支持顺序访问和随机访问数据。

它们都用于有效地存储大量的科学数据。

这些数据格式包括支持自我描述能力的元数据。

这些格式默认支持调用对象、网格、图像和N维数组。

这些格式是不可更新的。用户可以在最后添加数据。

它们都支持机器可移植性。

它们中的大部分格式都是标准化的。

这里讨论的数据格式可用于存储任意主题和其子域的数据。另外还有一些为特定主题专门设计的数据格式。下面列举了这些数据格式。这里没有给出这些数据格式的详细描述，读者可以参考它们的原始。

用于天文学领域的数据格式

FITS：FITS天文学数据和图像格式（.fit或.fits）

SP3：GPS和其他卫星轨道（.sp3）

用于存储医学图像数据的格式

DICOM：DICOM标注医学图像（.dcm，.dic）

用于医学和生理学数据的格式

Affymetrix：Affymetrix数据格式（.cdf , .cel , .chp , .gin，.psi ）

BDF：BioSemi数据格式（.bdf）

EDF：欧洲数据格式（.edf）

用于化学和分子生物学的数据格式

MOL

SDF

SMILES

PDB

GenBank

FASTA

用于地震学（与地震相关的科学和工程）的数据格式

NDK：NDK地震学数据格式（.ndk）

用于天气数据的格式

GRIB：GRIB科学数据格式（.grb，.grib）

3.5　现成的标准数据集

一些政府、协作组织和研究机构致力于不断地开发和维护不同主题和主题下不同领域的标准数据集。这些数据集可以供公众免费下载，或者离线工作，又或者利用这些数据集进行在线运算。一个这样重要的项目叫作开放科学数据云（Open Science Data Cloud，OSDC），它在每一个主题下都有几个数据集。现在可以获得各个开源数据源的清单了。可以通过它们的网站入口https://www.opensciencedatacloud.org/publicdata/来获取。OSDC里的一个主题数据集列表如下。

农业

美国农业部植物数据库

生物

1000个基因组

基因表达数据库GEO

MIT癌症基因组数据

蛋白质数据银行

气候/天气

澳大利亚天气

加拿大气象中心

UEA的气候数据（每月更新）

自1929年的全球气候数据

复杂网络

交叉引用的DOI URLs

DBLP引用数据集

NIST复杂网络数据集

UFL稀疏矩阵集

WSU图数据库

计算机网络

来自Common Crawl 2012年的3.5 B的网页

印第安纳大学100 000个用户53.5 B的网页点击信息

CAIDA互联网数据集

ClueWeb09的1 B网页

数据竞赛

机器学习中的挑战

关于社会福利的DrivenData竞赛

（自2009年的）ICWSM数据挑战

Kaggle竞赛数据

经济

美国经济协会（AEA）

UMD的EconData

网络产品编码数据库

能源

AMPds

BLUEd

Dataport

UK-Dale

财经

CBOE未来交易

谷歌财经

谷歌趋势

NASDAQ

地理空间/GIS

BODC——大约22 000变量的海洋数据

GitHub上的剑桥、马萨诸塞州、美国、GIS数据

EOSDIS——NASA的地球观察系统数据

ASU的地理空间数据

医疗

EHDP大型健康数据集

Gapminder世界人口数据库

美国医疗覆盖数据库（MCD）

医疗数据文件

图像处理

2 GB的猫的图片

图片的情感分类

人脸识别基准

MIT的大规模视觉记忆刺激

MIT的SUN数据库

机器学习

查询歌手资料的月度数据

eBay线上交易数据（2012）

IMDb数据库

用于分类、回归和时间序列的Keel数据仓库

百万歌曲数据集

博物馆

Cooper-Hewitt的收藏数据库

明尼阿波利斯艺术博物馆的元数据

Tate收藏元数据

Getty艺术专业名词

自然语言

Blogger语料库

ClueWeb09 FACC

谷歌图书Ngrams（2.2 TB）

谷歌网页5gram（2006年1 TB）

物理

CERN开放数据入口

NSSDC（NASA）550个航天器数据

公共领域

CMU的JASA归档数据

UCLA的SOCR数据集

UFO报告

WikiLeaks的关于911的窃听信息

搜索引擎

UMB上分享数据的学术种子

互联网归档信息中的Archive-it

DataMarket（Qlik）

Statista.com——统计和研究

社会科学

CMU的Enron 150个用户的电子邮件数据集

来源于LAW的Facebook社交网络（始于2007年）

2010年和2011年的Foursquare社交网络数据

2013年UMN/Sarwat的Foursquare数据

运动

Betfair历史交换数据

Cricsheet棒球比赛数据

1950年至今的Ergast一级方程式赛车数据（API）

美式足球/英式足球资源（数据和API）

时间序列

MU的时间序列库（TSDL）

UC河滨时间序列数据集

硬盘驱动失败率

交通

1987年至2008年的航空OD数据

自行车分享系统（BSS）数据集

湾区自行车分享数据

马萨诸塞州的Hubway百万骑行数据

海洋交通——船舶轨道、港口停靠等数据

3.6　数据生成

在一些应用中，如果用户没有现成的可用于计算的数据，那么在做计算之前就需要生成数据。数据可以用三种方法来生成：人工收集、仪器收集或通过计算机模拟（对于一些特定的应用）。

有一些应用的数据必须是人工收集的。例如，如果一个应用需要人体的生物学数据，那么可能得通过人工建立一个数据集来获得数据，而该数据集则通过采集志愿者的生物学数据来建立。这样的数据必须人工收集，无法通过电脑或仪器生成。对于这个特定的应用，用户还可以从政府部门的数据库获得数据，比如签证处理过程中收集的生物学信息，或者美国政府的个人注册数据库，又或者是印度唯一身份项目（ADHAAR）收集的数据。

对于一些特定的实验，数据可以通过一些仪器生成，这些仪器提供了用户感兴趣的指标示数。例如，与天气相关的数据可以用下列仪器生成：我们可以在不同地点放置一定数量的温度计，并周期性地收集它们的读数。用一些专用传感器，我们可以收集天气相关或健康相关的数据。例如，心率和血压相关的信息可以用戴在人身上的智能腰带或智能手表来收集。这些设备中的内置GPS系统将会周期性地用推或拉的方法收集信息。

模拟数据可以用于需要数值或文本数据的实验中，因为这些数据可以不需要特定的仪器而在电脑上直接生成。可以用一个程序生成满足预定义约束条件的数据。为了生成文本数据，可以利用已有的包含文本信息的离线文本数据或线上的网页数据，生成用于处理的新的数据样本。例如，文本挖掘和语言学处理有时需要文本样本数据。

3.7　模拟数据的生成（构造）

这一节将讨论生成模拟数据的各种方法。还将给出一个用泊松分布生成随机数的算法，以及这个算法的Python实现。此外，我们将探索不同的文本数据模拟的方法。

3.7.1　用Python的内置函数生成随机数

Python有一个叫作随机数的模块，该模块可以实现基于各种统计分布的伪随机数生成器。这个模块包括各种随机类型的函数，例如整数、序列、列表的随机排列，以及从预定义的总体中生成随机样本。Python的随机数模块支持用各种统计分布生成随机数，包括均匀分布、正态分布（高斯分布）、对数正态分布、负指数分布、伽马分布和贝塔分布。Python提供了冯·米塞斯分布（von Mises distribution），可以生成随机角度的均匀分布。Python的大部分随机数生成器模块取决于random()基本函数。这个函数在半开区间[0.0, 1.0)生成随机浮点数。

梅森旋转（Mersenne Twister）是Python中的主要随机数生成器。它能够生成53位精确的随机浮点数字，周期可达到2**19937-1。它的底层用C语言实现，所以线程安全并且速度快。它是最易扩展和测试的随机数生成器之一。但是它并不是适用于所有的应用，因为它是完全确定的。因此，它不适用于安全相关的计算。随机数模块还提供了一个SystemRandom类，这个类用操作系统自带的os.urandom()函数生成随机数。这个类可为加密目的，生成随机数。

随机模块的函数是隐藏在random.Random类中的已绑定方法。另外，用户可以拥有自己的Random类实例。好处就是这个实例并不共享状态。此外，如果用户要求生成一个新的随机数生成器，那么这个类也可以被扩展/继承来生成一个新的Random子类。在这种情况下，用户可以改写五种方法：getstate()、jumpahead()、random()、seed()和setState()。

让我们继续看看Python随机数模块的各种内置函数。这些函数在下文中将分别进行介绍。

1. 记账函数

随机数模块的各种记账函数如下。

random.seed(a=None, version=2)：这个函数初始化随机数生成器。如果用户将整数值传给a，那么这个值就是初始值。如果没有值传给a或者指定a的值是None，那么此刻的系统时间会被当作seed的值。如果操作系统支持随机资源，那么这些随机资源可以用作seed值，而不用系统时间。

random.getstate()：这个函数返回一个对象，该对象表示随机数生成器当前的内部状态1。这个对象可以用setstate()函数恢复到同样的状态。

random.setstate(state)：状态值必须是从getstate()函数调用中获得的对象。然后setstate恢复生成器的内部状态到当getstate()被调用时的状态。

random.getrandbits(k)：这个函数返回一个长度为k位（比特）的随机长整型数据。这个函数还包括MersenneTwister生成器和其他几种可选的生成器。

1保存起来可以降低后续随机数重复的概率。——译者注

2. 整型随机数生成器函数

用于返回整型随机数的不同函数如下。

random.randrange(stop)或random.randrange(start, stop[, step])：这种方法从给出的元素中返回随机选择的元素。它的参数含义如下：start是范围的起始值，该起始值包括在该范围内；stop函数是范围的终点，该终点不包括在该范围内；step表示相加的步长值以确定随机数值。

random.randint(a,b)：这个函数返回一个在a到b范围内的整型值。

3. 随机序列生成器函数

用序列操作来生成一个新的随机序列或子序列的各种函数列举如下。

random.choice(seq)：该函数返回一个非空的seq序列中的随机元素。seq字符必须是非空的。如果seq是空的，那么该函数会报错/抛出异常，即IndexError。

random.shuffle(x)：该函数混合x序列里的所有元素。混合意味着各元素的位置将在列表变量中变化。

random.sample(population, k)：这个函数返回一个总体中的k位长的唯一随机元素列表。总体必须是一个序列或集合。这个函数通常用于没有替换的随机抽样。此外，总体的各元素必须被复制，每个元素被选出组成一个列表的可能性是均等的。如果样本的大小比总体的大小k还要大，那么就会报出ValueError异常。

4. 基于统计分布的函数

有很多可以用于各种场景的统计分布。为了支持这些场景，随机数模块包括了各种统计分布的函数。下面是基于统计分布的随机数生成器。

随机数生成器函数（random.uniform(a, b)）：这个函数返回a至b区间的一个随机浮点数N。选择a至b区间的每一个随机数的概率都是一样的。

random.triangular(low, high, mode)生成器：这个函数返回服从low <= N <= high的三角分布的一个随机浮点数N。low与high是数值的边界，mode保持在边界内。默认情况下，边界最小值是0，最大值是1，mode是最大值与最小值的中点。

random.betavariate(alpha, beta)生成器：这个函数返回一个0至1区间的随机数，该随机数满足参数alpha(α)>0和beta(β)>0条件下的贝塔分布。

random.expovariate(lambd)生成器：这个函数返回一个基于指数分布的随机数。参数lambd(λ)的值必须是非零。如果lambd的值是正的，它返回0至正无穷大区间的值；如果lambd的值是负的，那么返回负无穷大到0区间的值。这个参数特意命名为lambd，因为在Python中lambda是一个保留字。

random.gammavariate(alpha, beta)生成器：这个函数基于伽马分布生成随机数，该随机数满足参数条件alpha(α)>0和beta(β)>0。

random.normalvariate(mu, sigma)生成器：该生成器基于正态分布生成随机数。这里mu(μ)是均值，sigma(σ)是标准差。

random.gauss(mu, sigma)生成器：该函数利用高斯分布生成随机数。这里mu(μ)是均值，sigma(σ)是标准差。与正态分布相比，该函数更快。

random.lognormvariate(mu, sigma)生成器：这里用对数正态分布生成随机数。以自然数为底的该分布与正态分布等价。同样，这里mu(μ)是均值，sigma(σ)是标准差。并且mu可以是任意值，而sigma必须大于0。

random.vonmisesvariate(mu, kappa)生成器：该函数用冯·米塞斯分布返回随机角度值。这里mu是角度的均值，范围在0至2*pi之间。kappa(k)是浓度参数（大于等于0）。

random.paretovariate(alpha)生成器：该函数服从帕累托分布生成随机变量。这里alpha是形状参数。

random.weibullvariate(alpha, beta)生成器：该函数用Weibull分布生成随机数。alpha是标量参数，beta是形状参数。

5. 不确定的随机数生成器

除了以上讨论的随机数生成器函数，还有一个可选的随机数生成器。该生成器被用于随机数生成器必须是不确定的情况，例如密码学和安全领域。该生成器是random.SystemRandom([seed])类。该类用操作系统提供的os.urandom()函数生成随机数。

下面的程序展示了上述提到的函数的运用。函数调用的输出也展示出来了。简便起见，我们只用到以下函数：

random.random

random.uniform

random.randrange

random.choice

random.shuffle

print random.sample

random.choice

程序如下：

import random
print random.random()
print random.uniform(1,9)
print random.randrange(20)
print random.randrange(0, 99, 3)
print random.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ') # 输出'P'
items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
random.shuffle(items)
print items
print random.sample([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 5)
weighted_choices = [('Three', 3), ('Two', 2), ('One', 1), ('Four', 4)]
population = [val for val, cnt in weighted_choices for i in
range(cnt)]
print random.choice(population)

我们来讨论每个函数调用的输出。第一个函数random返回任意大于0小于1的浮点随机值。uniform函数返回给定范围内的均匀分布随机值。randrange函数返回一个给定范围内的随机整型值。如果第一个参数被忽略的话，那么该参数会被默认为0。因此对于randrange(20)，其范围是0~19。

现在来讨论与序列相关的函数输出。choice函数返回一个列表的随机选择值。在上面的例子中有26种选择值，而返回的值是p。shuffle函数的输出很明显，而且正如预期，其中的一些值被混合重排。sample函数选择给定大小的一个随机样本。在上面的例子中，sample的大小是5，因此随机样本有5个元素。上面程序的最后三行执行的是用一个给定的概率选择一个随机值的函数。这也是为什么choice函数被称作权重选择，因为每个选择都被赋予了一个权重。

3.7.2　基于统计分布的随机数生成器的设计和实现

本节将讨论泊松分布的算法设计和Python实现。这将有两方面的好处：一是你可以学习一个新的基于统计分布的随机数生成器的设计和实现，二是在随机模块中并不包含这个函数，所以用户也可以用到这个新分布。对于一些特定的应用，一些变量会采用泊松随机值。例如，考虑在操作系统进程调度中的调度算法。为了模拟进程调度，我们假设进程的到达遵循泊松分布。

有一些场景是可以应用泊松分布的，举例如下。

互联网的流量模式服从泊松分布。

呼叫中心接收到的呼叫数量服从泊松分布。

（有两个队参与的）曲棍球或足球比赛项目中的进球数服从泊松分布。

操作系统中的进程到达时间。

对于一个年龄段的群体，其每年的死亡率服从泊松模式。

在给定时间段内股票价格的下降次数。

对于给定片段的DNA做辐射，突变的数量服从泊松分布。

泊松分布的算法实现在高德纳的《计算机程序设计艺术（卷2）》2中给出了，其代码如下：

2此书中文版已由人民邮电出版社出版。——编者注

algorithm poisson_random_number (Knuth):
    initializations:
        L = e-λ
        k = 0
        p = 1
    do:
        k = k + 1
        u = uniform_random_number (0,1)
        p = p × u
    while p > L
        return k - 1

下面的代码是泊松分布的Python实现：

import math
import random
def nextPoisson(lambdaValue):
  elambda = math.exp(-1*lambdaValue)
  product = 1
  count = 0

  while (product >= elambda):
    product *= random.random()
    result = count
    count += 1
  return result
for x in range(1, 9):
  print nextPoisson(8)

上面程序的输出是：

5
7
11
8
9
8
7
6

　关于重现随机数的特别提示

如果一个应用要求用任意方法重现随机数，在这种情况下，我们可以用这些函数重现生成的随机数。为了重现序列，需要用到同样的函数和同样的种子值。同样，我们也可以重现列表，这也是为什么我们称大多数随机数生成函数是确定的。

3.7.3　一个用简单逻辑生成5位随机数的程序

以下程序展示了用时间和日期对象生成随机数的方法。它用了特别简单的逻辑来生成5位随机数。在这个程序中，用当前的系统时间来生产随机数。系统时间的4个组件——小时、分钟、秒和毫秒——通常是唯一的组合。这个值被转换成一个字符串，然后转换成一个5位的值。在用户定义的函数的第一行，引入了一个毫秒级的延迟，这样在很短的时间内，每次调用时，时间值是不同的。如果没有这一行，用户将得到一些重复的值。

import datetime
import time

# 用户定义函数返回5位的随机数
def next_5digit_int():
    # 这样可以将随机数设置为微秒级
    time.sleep(0.123)
    current_time = datetime.datetime.now().time()
    random_no = int(current_time.strftime('%S%f'))
    # 去掉后三位尾数
    return random_no/1000

# 随机数生成演示
for x in range(0, 10):
    i = next_5digit_int()
    print i

3.8　大规模数据集的简要介绍

科学应用程序的数据集范围很广泛，从几MB到几GB都有。某些特殊应用程序的数据集非常大，可能有几PB（拍字节）3。以我们熟悉的MB与GB来对比一下PB的规模。假如我们把1 PB的数据刻录成CD，然后把这些CD堆起来，那么叠起来的光盘高度大约是1.75千米。由于近些年网络和分布式计算技术的进步，有许多应用需要处理PB的数据量。为了有效地处理大规模数据集，在软件和硬件层面都出现了一些解决方法。

31 PB=1024 TB。——译者注

有一些处理各种规模数据集的有效架构。这些架构在处理小规模、中等规模和大规模数据时同样高效，仅取决于提供的基础架构。MapReduce就是这样一种架构，Hadoop是MapReduce架构的一个开源实现。

在数据库层面，用户有多种方法来存储和管理任意规模的数据。这些数据库可能是最简单的，如纯文本文件（文本文件或者二进制文件）。然后还有一些基于模式的数据库，如关系型数据库，这些数据库能高效地管理几个GB的数据库。文件数据库和基于模式的数据库都可以管理MB到GB规模的数据。为了处理超过这个限制的数据，现在的趋势是用非基于模式的数据库和高级分布式文件系统，例如谷歌的BigTable、Apache HBase和HDFS。HBase是一个基于列的数据库，可以支持超大规模数据库。HDFS是一个分布式文件系统，它能存储几个PB的文件，不像普通文件系统（如WINDOWS NT）的最大文件大小16 GB。

大多数编程语言都支持这些框架和数据库，包括Python、Scala、Java、Ruby等。除了软件级的，还有一些复杂的硬件组合，例如不同硬件（如处理器、I/O设备和网络设备）组成的虚拟化内容。还有一些专门针对之前介绍的数据库软件的性能增强硬件。

分布式计算领域最新的进展称为云计算，它包括一些新的科学计算和商业应用。云计算和上面讨论的一些概念的运用，为人们提供了有史以来最大的数据处理和存储能力，让一切成为可能。在云计算环境中正不断地产生新应用，这些新的应用的数量与日俱增。

以上讨论的技术也用于文本搜索、模式查找和匹配、图像处理、数据挖掘和大数据集的计算。在各种商业和科学应用中，这些需求非常普遍。

在第8章中，我们将详细讨论这些技术，并重点关注它们在大规模科学项目中的应用。

3.9　小结

本章首先介绍了数据、信息和知识的基本概念，然后介绍了各种用于存储数据的软件，之后讨论了数据集的各种操作，列举了存储科学数据的标准格式。接着还讨论了各主题领域的各种预定义的、已使用的标准数据集。但是，目前仍然有一些特定主题的数据集是缺失的。

在介绍基本概念之后，本章还介绍了各种为特定实验模拟数据的技术。还介绍了用于随机数生成的各种标准函数。对于模拟数据的生成，我们还介绍了用泊松分布生成随机数的一个算法和一个程序。

下一章将详细介绍Python科学计算的API和工具。这些API提供数值计算（NumPy和SciPy）、符号计算（SymPy）、数据可视化和画图（matplotlib和pandas）以及交互式编程（IPython）。此外，还会简单讨论这些API的特征和功能。

第 4 章　Python科学计算API

本章将对Python各种科学计算API和工具箱的特性与能力进行全面的介绍。除了介绍基础知识，我们还会针对每个API演示一些示例程序。由于符号计算是计算机数学中一个比较特殊的领域，因此我们在SymPy那一节中安排了一个小节单独介绍计算机代数系统的基础知识。

本章将介绍的主题如下：

用NumPy和SciPy做科学数值计算

用SymPy做符号计算

计算机代数系统

SymPy及其模块介绍

SymPy示例程序

数据分析、可视化与交互式计算

4.1　Python数值科学计算

科学计算主要需要的是完成代数方程、矩阵、微分、积分、微分方程、统计、方程求解等方面计算的能力。Python语言本身并不具有这些功能。但是，NumPy和SciPy的发展让我们能够完成这些计算，并提供了更多的高级功能。NumPy和SciPy都是非常强大的Python程序包，让用户可以高效地完成各种科学应用需要的计算任务。

4.1.1　NumPy程序包

NumPy是Python科学计算的基础程序包。它提供了多维数组和基本数学运算的功能，比如线性代数。Python提供了一些数据结构来存储用户数据，最流行的数据结构是列表和字典。列表可以存储任意Python对象作为元素。这些元素可以通过for循环或迭代器进行处理。字典对象以键值对的形式存储数据。

1. N 维数组数据结构

N维数组（NumPy的ndarray对象）与列表类似，但是更加灵活高效。N 维数组是一个数组对象，可以表示固定数量的多维数组。这个数组应该是齐次的（homogeneous）。它通过dtype类型的对象定义数组中元素的数据类型。这个对象可以定义数据的类型（整型、浮点型或Python对象类型）、数据的字节数以及字节存储顺序（byte ordering，包括big-endian高位至低位与little-endian低位至高位）。另外，如果数据类型是record或sub-array，类型还会包含具体的细节信息。真实的数组可以通过numpy.array、numpy.zeros、numpy.empty三种方法创建。

N 维数组另一个重要特性是数组大小是可以动态调整的。而且如果用户需要从数组中移除一些元素，可以通过NumPy的模块实现戴面具数组（masked array）。在许多科学计算场景中，人们都需要删除/清理异常数据。numpy.ma模块能够实现为数组戴面具的功能，轻松地移除数组中的元素。戴面具数组就是正常数组加了一个面具（mask）。面具其实是另一个由True和False构成的数组。对于某个位置的元素，如果面具数组中的值是True，那么原数组对应位置的值就是有效的；如果面具数组中的值是False，那么原数组对应位置的值就是无效的或隐藏的。对于原数组中那些标记为False的数值而言，N 维数组上进行任何计算时，都不会考虑这些被隐藏的元素。

2. 文件处理

科学计算的另一个重要方面是用文件存储数据。NumPy可以对文本文件和二进制文件进行读写操作。大多数情况下，文本文件都是读取、写入和进行数据交换的良好格式，因为它们天生具有移植性，而且大多数系统平台都自带文本文件的处理功能。但是，对于一些应用程序而言，有时用二进制文件更好，或者在一些场景中，这些应用程序需要的数据只能存储成二进制文件格式。有时，数据的大小和类型决定了存储形式，例如图像和声音文件只能存储成二进制文件。

相比文本文件，二进制文件较难管理，因为它们的格式特殊。而且二进制文件的大小都相对非常小，对它们的读/写速度比文本文件更快。快速的读/写最适用于那些需要处理大型数据集的应用程序。用NumPy操作二进制文件的唯一缺点是，它们只能通过NumPy接入。

Python的文本文件操作函数包括open、readlines、writelines。但是，用这些函数操作科学数据的性能并不佳。这些Python函数在从文件读写数据时非常慢。NumPy有一组高性能函数，可以在计算之前将数据载入 N 维数组。在NumPy中，文本文件可以通过numpy.loadtxt和numpy.savetxt函数接入。loadtxt函数可以将文本文件的数据载入 N 维数组。NumPy还有一个独立的函数用于操作二进制数据。这些函数分别是读取用的numpy.load和写入用的numpy.save。

3. 简单的NumPy程序

NumPy数组可以从使用数组的列表或元组创建。这个方法可以将序列的序列转换成二维数组：

import numpy as np
x = np.array([4,432,21], int)
print x   #输出[ 4 432 21]
x2d = np.array( ((100,200,300), (111,222,333), (123,456,789)) )
print x2d

输出结果如下：

[  4 432  21]
[[100 200 300]
[111 222 333]
[123 456 789]]

基本的矩阵算术运算都可以在二维数组上简单地完成，如下面的程序所示。这些运算基本都是应用在元素上。因此，操作符两侧的数组必须是同样维度的。如果维度不一致，运算就会引起运行时错误。下面的程序示例演示一维数组的算术运算：

import numpy as np
x = np.array([4,5,6])
y = np.array([1,2,3])
print x + y    # 输出[5 7 9]
print x * y    # 输出[ 4 10 18]
print x - y    # 输出[3 3 3]
print x / y    # 输出[4 2 2]
print x % y    # 输出[0 1 0]

有一个单独的matrix子类可以进行矩阵运算。让我们用下面的程序示例演示矩阵运算，对比数组乘法与矩阵乘法的差异。NumPy的矩阵是二维的，而NumPy数组可以是任意维度的：

import numpy as np
x1 = np.array( ((1,2,3), (1,2,3), (1,2,3)) )
x2 = np.array( ((1,2,3), (1,2,3), (1,2,3)) )
print "First 2-D Array: x1"
print x1
print "Second 2-D Array: x2"
print x2
print "Array Multiplication"
print x1*x2

mx1 = np.matrix( ((1,2,3), (1,2,3), (1,2,3)) )
mx2 = np.matrix( ((1,2,3), (1,2,3), (1,2,3)) )
print "Matrix Multiplication"
print mx1*mx2

输出结果如下：

First 2-D Array: x1
[[1 2 3]
 [1 2 3]
 [1 2 3]]
Second 2-D Array: x2
[[1 2 3]
 [1 2 3]
 [1 2 3]]
Array Multiplication
[[1 4 9]
 [1 4 9]
 [1 4 9]]
Matrix Multiplication
[[ 6 12 18]
 [ 6 12 18]
 [ 6 12 18]]

下面的简单程序演示了NumPy的简单统计函数：

import numpy as np
x = np.random.randn(10)      # 创建一个有10个随机元素的数组
print x
mean = x.mean()
print mean
std = x.std()
print std
var = x.var()
print var

第一个样本的输出结果如下：

[0.08291261  0.89369115  0.641396  -0.97868652  0.46692439
 -0.13954144
 -0.29892453  0.96177167  0.09975071  0.35832954]
0.208762357623
0.559388806817
0.312915837192

第二个样本的输出结果如下：

[ 1.28239629  0.07953693  -0.88112438  -2.37757502  1.31752476
  1.50047537
  0.19905071  -0.48867481  0.26767073   2.660184 ]
0.355946458357
1.35007701045
1.82270793415

上面的程序都是一些简单的NumPy示例。我们将在第5章中详细地介绍NumPy的功能。下一节介绍Python的SciPy程序包。

4.1.2　SciPy程序包

SciPy是对Python的NumPy的功能扩展，它提供了许多高级数学函数，例如微分、积分、微分方程、优化方法、数值分析、高级统计函数、方程式求解等。SciPy是在NumPy数组框架的基础上实现的。它对NumPy数组和基本的数组运算进行扩展，满足科学家和工程师解决问题时需要用到的大部分数学计算功能。

这一章将介绍一些SciPy基本功能的程序示例，NumPy和SciPy的功能将在第5章中详细介绍。下面几个小节将介绍SciPy的一些重要程序包/模块的基础知识，包括聚类分析、文件处理、积分、数值分析、优化方法、信号与图像处理、空间分析和统计学。

1. 优化函数程序包

SciPy的优化程序包提供了解决单变量和多变量的目标函数最小值问题的功能。它可以通过大量的算法和方法解决最小化问题。最小化问题在科学与商业领域中十分普遍。一般情况下，我们会使用线性回归，搜索函数的最大值与最小值，求方程的根，通过线性规划方法求解，等等。优化函数程序包支持所有这些功能。

2. 数值分析程序包

数值分析程序包实现了大量的数值分析算法和方法，都作为内置函数。它支持单变量和多变量的插值，以及一维和多维的样条插值。当数据受单变量影响时，用单变量插值；当数据收到多个变量的影响时，用多变量插值。除了这些功能之外，数值分析程序包还提供了拉格朗日（Lagrange）和泰勒（Taylor）多项式插值方法。

3. 积分与微分方程

积分是科学计算中的重要数学工具。SciPy的积分程序包提供了数值积分的功能。SciPy提供了大量的函数解决积分方程与数据的计算问题。它还提供了常微分方程的积分工具。它还借助大量数值分析得数学方法完成数值积分的计算。

4. 统计学模块

SciPy的统计学模块提供了大量的概率分布函数和统计函数。它支持的概率分布函数包括连续变量分布函数、多变量分布函数以及离散变量分布函数。统计函数包括简单的均值到复杂的统计学概念，包括偏度（skewness）、峰度（kurtosis）、卡方检验（chi-square test）等。

5. 聚类程序包与空间算法

聚类分析是一项流行的数据挖掘技术，在科学与商业领域有广泛的应用。在科学领域中，生物学、粒子物理学、天文学、生命科学与生物信息学都是一些广泛使用聚类分析解决问题的领域。聚类分析在计算机科学的诸多领域也应用广泛，例如计算机欺诈检测、安全分析与图像处理等。聚类程序包提供了K-means聚类、向量量化（vector quantization）1、层次聚类（hierarchical clustering）与凝聚式聚类（agglomerative clustering）函数等。

1将一个范围内的值用一个值近似替代。——译者注

spatial类利用三角函数、沃罗诺伊图（Voronoi Diagram）以及多点的凸包（Convex Hull）分析空间中两点之间的距离。它还用KD树算法实现了RNN函数。

6. 图像处理

SciPy提供了大量的图像处理函数，包括基本的图像文件读/写、图像显示，以及简单的处理函数，例如裁剪、翻转和旋转。它还支持图像过滤函数，包括图像数学变换、平滑、降噪、锐化。另外，它还支持许多其他操作，如通过不同对象的像素进行图像分割、分类，以及通过边缘检测进行特征提取。

4.1.3　简单的SciPy程序

以下几个小节将介绍一些用SciPy模块和程序包实现的程序示例。首先将介绍一个用SciPy进行标准统计计算的示例，然后是一个用优化方法寻找函数最小值的程序，最后介绍一个图像处理程序。

1. 用SciPy做统计

SciPy的统计学模块有完成简单统计学运算的函数和各种各样的概率分布函数。下面的程序用SciPy的stats.describe函数演示了简单的统计运算。这个函数可以处理数组，返回数组元素的数量、最小值、最大值、均值、方差、偏度和峰度：

import scipy as sp
import scipy.stats as st
s = sp.randn(10)
n, min_max, mean, var, skew, kurt = st.describe(s)
print("Number of elements: {0:d}".format(n))
print("Minimum: {0:3.5f} Maximum: {1:2.5f}".format(min_max[0],
    min_max[1]))
print("Mean: {0:3.5f}".format(mean))
print("Variance: {0:3.5f}".format(var))
print("Skewness : {0:3.5f}".format(skew))
print("Kurtosis: {0:3.5f}".format(kurt))

输出结果为：

Number of elements: 10
Minimum: -2.00080 Maximum: 0.91390
Mean: -0.55638
Variance: 0.93120
Skewness : 0.16958
Kurtosis: -1.15542

2. SciPy优化函数

在数学优化理论中，一种被称为Rosenbrock的非凸函数常用于检测优化算法的性能。以下程序演示了该函数的最小值问题。N 个变量的Rosenbrock函数方程如下所示，它在xi =1时最小值为0：

上面方程的程序如下所示：

import numpy as np
from scipy.optimize import minimize

# 定义Rosenbrock函数
def rosenbrock(x):
    return sum(100.0*(x[1:]-x[:-1]**2.0)**2.0 + (1-x[:-1])**2.0)

x0 = np.array([1, 0.7, 0.8, 2.9, 1.1])
res = minimize(rosenbrock, x0, method = 'nelder-mead', options =
        {'xtol': 1e-8, 'disp': True})

print(res.x)

输出结果为：

Optimization terminated successfully.
         Current function value: 0.000000
         Iterations: 516
         Function evaluations: 827
[ 1.  1.  1.  1.  1.]

最后一行是print(res.x)的输出结果，可以看出里面的元素都是1。

3. SciPy图像处理

下面两个程序演示SciPy的图像处理功能。第一个程序显示标准测试图像，这张图像名叫Lena，广泛应用于图像处理领域。第二个程序演示图像几何变换，它会完成图像裁剪和45度旋转。

下面的程序是用matplotlib的API显示Lena图像。imshow方法会把 N 维数组渲染为图像，show显示图像：

from scipy import misc
l = misc.lena()
misc.imsave('lena.png', l)
import matplotlib.pyplot as plt
plt.gray()
plt.imshow(l)
plt.show()

这个程序的输出结果如下图所示。

下面的程序完成图像的几何变换。变换后的图像与原始图像共同放在一个2×2的数组中：

import scipy
from scipy import ndimage
import matplotlib.pyplot as plt
import numpy as np

lena = scipy.misc.lena()
lx, ly = lena.shape
crop_lena = lena[lx/4:-lx/4, ly/4:-ly/4]
crop_eyes_lena = lena[lx/2:-lx/2.2, ly/2.1:-ly/3.2]
rotate_lena = ndimage.rotate(lena, 45)

# 四幅图，返回二维数组
f, axarr = plt.subplots(2, 2)
axarr[0, 0].imshow(lena, cmap=plt.cm.gray)
axarr[0, 0].axis('off')
axarr[0, 0].set_title('Original Lena Image')
axarr[0, 1].imshow(crop_lena, cmap=plt.cm.gray)
axarr[0, 1].axis('off')
axarr[0, 1].set_title('Cropped Lena')
axarr[1, 0].imshow(crop_eyes_lena, cmap=plt.cm.gray)
axarr[1, 0].axis('off')
axarr[1, 0].set_title('Lena Cropped Eyes')
axarr[1, 1].imshow(rotate_lena, cmap=plt.cm.gray)
axarr[1, 1].axis('off')
axarr[1, 1].set_title('45 Degree Rotated Lena')

plt.show()

输出结果如下。

SciPy和NumPy之所以成为Python科学计算的核心，是因为它们为数值计算提供了坚实的基础功能。我们将在第5章介绍两个软件包的细节。下一节内容介绍用SymPy做符号计算。

4.2　SymPy符号计算

计算机直接对数学符号进行正确的计算被称为符号计算。通常，符号计算也被称为计算机代数，而对应的计算机系统称为计算机代数系统。下面将对SymPy程序库作简单介绍。我们将在第6章中深入介绍用Python做符号计算的内容。

4.2.1　计算机代数系统

下面介绍计算机代数系统（Computer Algebra System，CAS）的概念。CAS是一个软件或者工具箱，用计算机代替手工运算数学表达式2。早期用计算机解决这类问题称为计算机代数，现在称为符号计算。CAS可以分为两类：一类是通用CAS，另一类是解决特殊问题的专用CAS。通用CAS可以应用于大多数代数领域，而专用CAS是面向某个具体领域的，如群论和数论。在大多数科学计算问题中，我们都使用通用CAS计算数学表达式。

2如商业数学软件Mathmatics。——译者注

4.2.2　通用CAS的特点

科学计算中使用的通用CAS具有以下特点。

具有操作数学表达式的用户界面。

编程与调试的界面。

该系统需要对不同的数学表达式进行简化。因此，简化器（simplifier）是CAS系统的核心组件。

通用CAS必须支持大量的数学函数，才能完成任意代数计算需要的数学功能。

大多数场景中计算量都很大，所以内存管理至关重要。

系统必须支持高精度与大数计算。

4.2.3　SymPy设计理念简介

SymPy是Python版的开源计算机代数系统实现。SymPy的设计理念是设计与开发一套功能齐全的CAS，代码尽可能简单，保持高度的易扩展性。它是纯Python代码写的，没有任何第三方库。

SymPy的基本思路是创建与操作数学表达式。用户借助SymPy可以用Python语言写数学表达式——通过SymPy类和对象。这些表达式由数字、符号、运算符、函数等构成。函数都是一些实现数学功能的模块，如对数函数与三角函数。

SymPy的开发是Ondřej Čertík从2006年8月开始的。此后，不断有开发者加入项目，规模达到了几百人。现在这个程序库包括26个模块。这些模块可以满足常用的计算需求，如符号计算、积分、代数、离散数学、量子物理、画图与打印等，计算结果还可以输出为LaTeX或其他格式。

SymPy的能力可以分为两类——核心能力和高级能力，因为SymPy程序库分为一个核心模块和多个高级可选模块。这些由不同模块支持的能力介绍如下。

1. 核心能力

核心能力模块支持数学代数运算需要的基本功能。这些运算包括基本算术运算，如加减乘除和幂运算。核心模块还具有数学式简化的能力，可以对复杂的数学式进行简化。它也提供了级数与符号展开的能力。

核心模块还支持三角函数、双曲线、指数函数、方程根求解、多项式、阶乘、伽玛函数、对数函数等功能，以及B—样条曲线、球面调和函数、张量函数和正交多项式等特殊函数。

核心模块中还有强大的模式匹配运算支持。另外，SymPy的核心能力还有代数运算的等价替换功能。它不仅支持整数、有理数、浮点数的高精度算术运算，还支持多项式运算中的非可交换变量和符号。

2. 多项式

多项式模块中有大量的函数处理多项式运算。这些函数包括多项式除法、最大公约数（greatest common divisor，GCD）、最小公倍数（least common multiplier，LCM）、无平方因式分解（square-free factorization）、带符号系数（symbolic coefficient）的多项式表示。还有一些特殊功能，如合矢量的计算、三角恒等式的推导、部分分式分解、Gröbner基（Gröbner basis）的多项式环和域。

3. 微积分

微积分模块中有大量的基础与高级微积分运算功能的支持。它有一个limit函数，可以设置积分上下限。它还支持导数、积分、级数展开、微分方程以及有限差分运算。SymPy还支持定积分与积分变换。在微分中，还支持数值微分、复合导数与分数阶导数。

4. 方程式求解

求解器（solver）是SymPy中求方程式解的模块。这个模块具有解复数多项式、多项式的根以及多项式组的能力。它有一个函数可以解代数方程式。它不仅可以解微分方程问题（包括常微分方程、偏微分方程、初始值与边界值的问题等），还可以解差分方程。在数学上，差分方程也称为递归关系式，也就是方程递归地定义序列与多维数组的值。

5. 离散数学

离散数学指变量特征是离散的数学分支，与连续变量的数学（微积分）区分开来。它主要处理整数、图形，以及逻辑学中的观点问题。这个模块对二项式系数、乘积与求和运算有完整的支持。

这个模块还支持数论中的许多函数，包括残差理论、欧拉公式、分割理论以及处理质数与因数分解的许多函数。SymPy还支持利用符号与布尔值来创建和操作逻辑表达式。

6. 矩阵

SymPy具有强大的矩阵与行列式计算的功能。矩阵属于线性代数的数学分支。SymPy支持矩阵创建、基本运算（如乘法与加法）、全0矩阵与全1矩阵、随机矩阵以及针对矩阵元素进行的运算。它还支持一些特殊函数，如计算海森矩阵（Hessian matrix）的函数、一组向量的格拉姆—施密特（Gram-Schmidt）正交化函数、朗斯基行列式（Wronskian）计算的函数，等等。

另外，SymPy还支持特征值和特征向量的计算、矩阵转置，以及矩阵与行列式求解。还支持矩阵的行列式计算，支持Bareis因式分解算法和Berkowitz算法等。矩阵计算中，还有零空间（null space）计算（方程式求解）、矩阵代数余子式展开（cofactor expansion）工具、矩阵元素求导数以及对偶矩阵。

7. 几何

SymPy有支持二维空间各种运算的模块，可以创建点、线、圆、椭圆、多边形、三角形、射线和线段等常见的二维对象。我们还可以直接查询对象的属性，例如部分对象的面积（椭圆、圆和三角形）和直线之间的交点。它还可以查询对象间的相切、相似与相交属性。

8. 画图

SymPy中有个模块可以画出二维图与三维图。现在图形底层都是用matplotlib软件包，也支持TextBackend、Pyglet和textplot等软件包。SymPy还提供了很好的自定义图形与画不同几何图形的交互接口。

画图模块的画图功能如下所示：

二维直线图

二维可变参数图

二维隐式与区域图

双变量三维图

三维直线与曲面图

9. 物理学

SymPy有一个模块可以解决物理学问题。它支持力学功能，包括经典力学与量子力学以及高能物理学。它还支持一维空间与三维空间的泡利代数与量子谐振子。还有光学相关的功能。还有一个独立的模块将物理单位集成到SymPy里。用户可以选择相应的物理单位完成计算和单位转换。这个单位系统是由物理单位与常量构成的。

10. 统计学

SymPy的统计学模块支持数学计算中涉及的许多统计函数。除了常见的连续与离散随机分布函数，它还支持符号概率相关功能。SymPy里的随机分布函数都支持随机数生成的功能。

11. 打印

SymPy有一个模块可以提供漂亮的打印功能。漂亮打印包括对不同文体格式的支持，如源代码、文本文件、标记语言文件以及其他内容。这个模块可以通过ASCII和Unicode生成各种文字。

它支持不同的打印机，如LaTeX和MathML打印机。可以打印多种编程语言生成的源代码，如C、Python和Fortran。还可以打印用标记语言，如HTML和XML格式，生成的内容。

4.2.4　SymPy模块

下面的列表是前面讨论到的模块的名称。

Assumptions：假设引擎

Concrete：符号积和符号总和

Core basic class structure：基本的、加、乘、指数等

Functions：基本的函数和特殊的函数

Galgebra：几何代数

Geometry：几何实体

Integrals：符号积分

Interactive：交互会话（如IPython）

Logic：布尔代数和定理证明

Matrices：线性代数和矩阵

mpmath：快速的任意精度的数值运算

ntheory：数论函数

Parsing：数学的和最大化的句法分析

Physics：物理单位和量子相关

Plotting：用Pyglet进行二维和三维的画图

Polys：多项式代数和因式分解

Printing：漂亮的打印和代码生成

Series：符号极限和截断的序列

Simplify：用其他形式改写表达式

Solvers：代数、循环和差分

Statistics：标准概率分布

Utilities：测试架构和兼容性相关的内容

在各种数学工具箱中有多种符号计算系统可供使用。有如Maple和Mathematica这样的商业软件，也有如Singular和AXIOM这样的开源软件。但是这些产品有它们自身的脚本语言。很难扩展它们的功能，并且这些软件的开发周期也很慢。而SymPy高度可扩展，它由Python设计和开发，是一个开源的支持快速开发周期的API。

4.2.5　简单的范例程序

这里用一些非常简单的示例来帮助你理解SymPy的能力。这些不到10行的SymPy源代码包含了从基本符号操作到极限、差分和积分等主题。我们可以使用谷歌App引擎上的在线运行的SymPy来运行这些SymPy程序，其网址是http://live.sympy.org/。

1. 基本符号操作

下面的程序定义了三种符号和用这些符号组成的一个表达式，之后打印了这个表达式：

import sympy
a = sympy.Symbol('a')
b = sympy.Symbol('b')
c = sympy.Symbol('c')
e = ( a * b * b + 2 * b * a * b) + (a * a + c * c)
print e

输出结果是：

a**2 + 3*a*b**2 + c**2

这里**表示指数运算。

2. SymPy的表达式扩展

下面的程序展示了表达式扩展的概念。它定义了两个符号和由这两个符号组成的一个简单表达式。最后它打印了这个表达式及其扩展形式：

import sympy
a = sympy.Symbol('a')
b = sympy.Symbol('b')
e = (a + b) ** 4
print e
print e.expand()

输出结果是：

(a + b)**4
a**4 + 4*a**3*b + 6*a**2*b**2 + 4*a*b**3 + b**4

3. 表达式或公式的简化

SymPy可以很便利地简化数学表达式。下面的程序简化了两个表达式，然后显示了简化后的表达式结果：

import sympy
x = sympy.Symbol('x')
a = 1/x + (x*exp(x) - 1)/x
simplify(a)
simplify((x ** 3 + x ** 2 - x - 1)/(x ** 2 + 2 * x + 1))

输出结果是：

ex
x – 1

4. 单的积分

下面的程序计算了两个简单函数的积分：

import sympy
from sympy import integrate
x = sympy.Symbol('x')
integrate(x ** 3 + 2 * x ** 2 + x, x)
integrate(x / (x ** 2 + 2 * x), x)

输出结果是：

x**4/4+2*x**3/3+x**2/2
log(x + 2)

4.3　数据分析和可视化的API和工具

Python中有很优秀的工具和API可以用来分析、可视化和展示数据和计算的结果。在以下的讨论中将介绍pandas的概念和操作。我们会用示例程序简单地讨论matplotlib的图表绘画并导出成不同格式。可以将图表导出成图片文件或PDF等其他文件。在第7章中将详细讨论matplotlib和pandas以及IPython工具的概念。

4.3.1　用pandas进行数据分析和操作

pandas是一个用于数据分析和数据操作的工具包。pandas由一些数据结构组成，这些数据结构在Python中用于科学数据分析。pandas开发的终极目标是设计一个强大和灵活的数据操作和分析工具。它提供了有效、灵活和意义重大的数据结构，特殊的设计使得它能够处理任意种类的数据。pandas可以用于大部分常用的数据库和数据集。pandas是在NumPy的基础上开发的。

因此它支持与Python其他的科学计算API和工具进行整合。pandas可以用于以下所有类型的数据。

表格数据，例如关系型数据库或电子表格（如微软Excel）。

有序或无序的时间序列数据。

多维阵列数据，例如带有行和列标签的矩阵。

用于存储第3章中提到的任意格式的科学数据的任意数据集。

1. pandas的重要数据结构

pandas数据结构的范围可以从一维到三维。Series是一维的，DataFrame是二维的，Panel是三维甚至更高维的数据结构。高维（如四维）数据结构目前仍然在开发中。通常，Series和DataFrame可以用于大多数统计、工程、财务和社会科学的场景中。

Series：它是一个带标签的一维数组，可以用于存储任意数据类型，例如整型、浮点数、字符串和其他有效的Python对象。它的轴的标签也称作index。

DataFrame：它是一个带标签的二维数组，有行和列。列可以有多种类型。DataFrame可以看作类似二维结构，例如电子表格和数据库表格。DataFrame也可以看作包含多个不同类型的Series的集合。

Panel：在统计学和经济学中，面板数据（panel data）指多维数据，这个多维数据包括不同时间的不同测量结果。该数据结构的名称来源于其概念。与Series和DataFrame相比，面板数据是不太常用的一种数据结构。

2. pandas的特点

下面是pandas的一些突出特性。

它提供了针对pandas数据结构以及CSV、微软Excel、SQL数据库和HDF5等数据格式的数据的操作便利性。

它被高度地优化以获得高性能，其关键代码是用Cython和C开发的。

它支持用切片、索引和子集进行大数据集的分割。

它提供自动和简洁的数据对齐。通过用标签集合简洁地将对象进行对齐。如果用户忽略标签，那么数据结构会自动对齐数据。

数据结构支持大小动态变化，因为列可以被插入或删除。

pandas拥有强大的group by操作引擎，group by操作用于数据的聚合和变换。

它还支持用于数据整合的高效的合并和连接操作。

它还用到重新索引以管理缺失数据的概念。缺失数据是指空的或不存在的数据。

pandas还对时间序列功能有很好的支持。这些功能包括移动窗口统计信息、日期范围的生成和频率转换、日期移位和延迟、移动时间窗口线性回归等。

4.3.2　用matplotlib进行数据可视化

matplotlib是用于数据可视化的Python API，是最广泛使用的二维图像Python包。它提供了一个快速可定制的数据可视化方法，并实现不同格式图片的发布。它支持多维图表的绘画。matplotlib中规定了图表的大多数属性的默认值，但这些值是可以定制的。用户可以控制任意图表的几乎所有设置，例如图的大小、线的宽度、颜色和类型、坐标轴、点的属性、文本的属性（例如字体、字面和字号）。

我们来讨论一些示例。示例中用到不同格式的绘画和不同格式的导出。

4.3.3　用IPython实现Python的交互式计算

Python有两种流行的编程方式：交互式编程与脚本文件。一些程序员比较喜欢与脚本打交道。通常他们用一个文本编辑器来写自己的程序，用终端来执行或进行其他操作，例如程序调试。然而，科学计算应用通常需要一个良好的交互式计算环境。在交互式计算中，任何时候计算环境都可以处理人们输入的内容，可能来自命令行或图像用户界面。Python科学计算API通过IPython及与其绑定的工具集合获得一个交互式计算环境。IPython广泛用于各种科学计算应用中，例如数据管理、数据操作、数据分析、数据可视化、科学计算和大规模计算。

我们来讨论在IPython中用NumPy、SymPy、pandas和matplotlib进行计算的一些例子。

4.3.4　数据分析和可视化的示例程序

这一小节将讨论用matplotlib和pandas进行数据分析和可视化的示例程序。如果你没有本地安装的pandas和matplotlib，可以用在线的IPython，网址是https://www.pythonanywhere.com/try-ipython/。

首先需要一些用于分析或可视化的数据。下面的程序从雅虎财经获取了苹果公司从2014年10月1日至2015年1月31日的数据，并将该数据存储在一个CSV文件中：

import pandas as pd
import datetime
import pandas.io.data

start = datetime.datetime(2014, 10, 1)
end = datetime.datetime(2015, 1, 31)

apple = pd.io.data.get_data_yahoo('AAPL', start, end)
print(apple.head())
apple.to_csv('apple-data.csv')
df = pd.read_csv('apple-data.csv', index_col='Date', parse_dates=True)
df.head()

以下是输出。

Open

High

Low

Close

Volume

Adj close

Date

10/1/2014

100.59

100.69

98.7

99.18

51491300

98.36

10/2/2014

99.27

100.22

98.04

99.9

47757800

99.08

10/3/2014

99.44

100.21

99.04

99.62

43469600

98.8

10/6/2014

99.95

100.65

99.42

99.62

37051200

98.8

10/7/2014

99.43

100.12

98.73

98.75

42094200

97.94

下面的程序对前面例子中的.csv文件中的数据进行画图。它计算了close的50个移动平均值（50 MA）。然后在二维图像中画出open、close、high、low和50个移动平均值。下面的截图是程序输出的图像。

下面是程序：

import pandas as pd
import matplotlib.pyplot as plt

df = pd.read_csv('apple-data.csv', index_col = 'Date', parse_
dates=True)
df['H-L'] = df.High - df.Low
df['50MA'] = pd.rolling_mean(df['Close'], 50)
df[['Open','High','Low','Close','50MA']].plot()
plt.show()

现在下面的程序对同样的数据进行了三维绘图：

import pandas as pd
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D

df = pd.read_csv('apple-data.csv', parse_dates=True)
print(df.head())
df['H-L'] = df.High - df.Low
df['50MA'] = pd.rolling_mean(df['Close'], 50)

threedee = plt.figure().gca(projection='3d')
threedee.scatter(df.index, df['H-L'], df['Close'])
threedee.set_xlabel('Index')
threedee.set_ylabel('H-L')
threedee.set_zlabel('Close')
plt.show()

threedee = plt.figure().gca(projection='3d')
threedee.scatter(df.index, df['H-L'], df['Volume'])
threedee.set_xlabel('Index')
threedee.set_ylabel('H-L')
threedee.set_zlabel('Volume')
plt.show()

前面的程序的三维绘图的截图如下所示。

4.4　小结

这一章讨论了各种科学计算API和工具的概念、特点和一些示例程序。首先讨论了NumPy和SciPy。在介绍了NumPy后，讨论了与符号计算相关的概念和SymPy。

之后，讨论了交互式计算、数据分析和数据可视化的API和工具。IPython是交互计算的Python工具。还讨论了数据分析包pandas和数据可视化API matplotlib。

在下一章，我们将详细讨论数值计算API——NumPy。还会通过示例程序介绍NumPy的各种函数和相关的数学概念。

第 5 章　数值计算

本章将通过程序示例讨论NumPy和SciPy的大部分功能。首先通过举例详细地讨论NumPy和SciPy中的数组和运算。这会为学习NumPy和SciPy的各种高级功能打下坚实的基础。

这一章将谈论以下主题：

用NumPy和SciPy做科学数值计算

NumPy的基本对象

NumPy的各种包/模块

SciPy包的基础知识

SciPy的数学函数

高级数学模块和包

在Python中，NumPy是数值计算的基础，其最基本和最重要的创意是支持多维数组。我们首先讨论NumPy中数组的基本概念。在了解了基础知识之后，再讨论可以对多维数组进行的各种运算。还会讨论到NumPy支持的各种基础的和高级的数学函数。NumPy包含一些子包或子模块，可以支持高级数学计算。

5.1　NumPy的基本对象

NumPy和SciPy的所有科学计算功能建立在NumPy两种基本类型的对象之上。第一种对象是 N 维数组对象，即ndarray；第二种对象是通用函数对象，即ufunc。除了这两种对象，还有其他一些对象建立在它们之上。

5.1.1　N 维数组对象

ndarray对象是一个同质元素的集合，这些元素用 N 个整型数索引（N 是数组的维数）。ndarray有两个重要的属性。第一个属性是数组中元素的数据类型，称作dtype；第二个属性是数组的维度。数组的数据类型可以是Python支持的任意数据类型。数组的维度是一个元组（N-tuple），即一个包含 N 维数组的 N 个元素的集合，元组中的每个元素定义了数组在该维度包含的元素个数。

1. 数组的属性

除了维度和dtype，数组还有其他属性，列举如下：

大小（size）

每项的大小（itemsize）

数据（data）

维度（ndim）

每项的大小（itemsize）是数组中每个元素的字节长度。数据（data）属性是一个Python缓存对象，该对象指向数组数据的起始位置。可通过以下的Python程序来理解维度、数据类型和其他属性的概念：

import numpy as np
x2d = np.array( ( (100,200,300),
                  (111,222,333),
                  (123,456,789) ) )
x2d.shape
x2d.dtype
x2d.size
x2d.itemsize
x2d.ndim
x2d.data

2. 数组的基本操作

使用方括号（[]）来索引数组的值称作数组索引（array indexing）。以上面的程序中定义和用到的二维数组x2d为例，该数组中的特定元素可以用x2d[row, column]表示。例如，x2d[1,1]表示第二行的第二个元素（即222），索引值的起始值是0。同样，x2d[2,1]表示第三行的第二个元素（即456）。

数组切片是从数组中选定某些元素来构成一个子数组的过程。对于一维数组，我们可以依次从数组中选择某些元素。进而可以通过切片获取二维数组的一整行或一整列。也就是说我们可以在任一维度选取数组元素。Python的基本切片概念被扩展到了 N 维数组中。切片的基本语法是start:stop:step。第一个参数指切片的起始位置索引值，第二个参数是指切片结束位置的索引值，最后一个参数定义了相加于前一个选定元素索引值基础上的步长。如果我们省略前两个参数值中的任意一个，那么这个值会被默认当作零或者大于零。同样，步长的默认值是1。下面用几个例子来更清楚地理解切片。

以x2d这个6×3的数组为例。x2d[1]等价于x2d[1, :]，都表示数组的第二行，该行包含三个元素。另一方面，x2d[:, 1]表示数组的第二列，该列包含六个元素。x2d[::3,1]可以选中数组中第二列的每三个元素。

省略号可以用来替换零个或多个冒号。一个省略号可以当作零个或多个全切片的对象，以匹配切片对象的所有维度，这个维度必须和原始数组的维度相等。例如，如果x4d是一个5×6×7×8的数组，那么x4d[2 :, ..., 6]等价于x4d[2 :, :, :, 6]。同样，x4d[..., 4]等价于x4d[:, :, :, 4]。可用以下程序理清数组切片的概念。这个程序展示了一维数组和二维数组的切片：

import numpy as np
x = np.array([1,12, 25, 8, 15, 35, 50, 7, 2, 10])
x[3:7]
x[1:9:2]
x[0:9:3]

x2d = np.array(( (100,200,300),
                 (111,222,333),
                 (123,456,789),
                 (125,457,791),
                 (127,459,793),
                 (129,461,795) ))
x2d[0:4,0:3]
x2d[0:4:2,0:3:2]

数组的迭代可以用for循环实现。在一维数组中，可以用for循环连续地获得所有元素。另一方面，多维数组的迭代可以通过考虑到第一维数据的多重循环来实现。下面的程序展示了如何实现数组的迭代：

import numpy as np
x = np.array([1,12, 25, 8, 15, 35, 50, 7, 2, 10])
x2d = np.array(( (100,200,300),
               (111,222,333),
               (123,456,789),
               (125,457,791),
               (127,459,793),
               (129,461,795) ))
for i in x:
    print i

for row in x2d:
    print row

3. 数组的特殊操作（变形及转换）

为了实现数组的变形，有ravel、reshape、resize和指定数组维度属性等方法。ravel和reshape方法返回修改了维度的变量（被调用的对象），而resize和指定数组维度属性的办法则修改了实际数组的维度。ravel方法将数组展开成C语言类型的数组，它返回的变量被当作一个一维数组，这个数组按行依次展开成一个一维数组。

我们用下面的程序来讨论这些方法的影响。这个程序实现了二维数组的变形操作。第一个print函数输出的是原始数组。ravel函数将展示展开的数组。而ravel函数之后的print函数再次展示了原始数组，因为ravel函数并未改变原始数组。现在uresize函数将原始的6行3列的数组(6,3)变形为3行6列的数组(3,6)。因此resize之后的print函数将输出变形之后的数组形状。

现在我们对原始数组(6,3)应用了reshape函数。但是由于reshape不改变原始数组的形状，在reshape函数之后的print函数将会打印出(3,6)的数组。最后一种方法是指定数组的形状为(9,2)，将数组的形状变成(9,2)。

最重要的是要记住，当做reshape变换时，新数组的总大小仍然不变：

import numpy as np
x2d = np.array(( (100,200,300),
                 (111,222,333),
                 (123,456,789),
                 (125,457,791),
                 (127,459,793),
                 (129,461,795) ))
print x2d
x2d.ravel()
print x2d
x2d.resize((3,6))
print x2d
x2d.reshape(6,3)
print x2d
x2d.shape = (9,2)
print x2d

如果需要的话，tolist、tofile和tostring可以分别将数组转换为Python列表数据结构、存储的文件和字符串。

4. 与数组相关的类

有一些类和子类是和ndarray类相关的。这些类的目的是为了支持特定的增强功能。下面将讨论这些类和子类。

(1) 矩阵子类

matrix类是ndarray的Python子类。一个矩阵能够通过其他矩阵或字符串生成，或者通过其他可以转化为ndarray的对象生成。matrix子类拥有特殊的覆盖运算符，例如*是矩阵的乘，**是矩阵的幂运算。matrix类提供的一些函数可以实现多种功能，例如元素排序、转置计算、矩阵元素的求和、将矩阵转换为列表或者其他数据结构和数据类型。下面的程序定义了两个3行3列的矩阵。最后程序输出了两个矩阵的乘积：

import numpy as np
a = np.matrix('1 2 3; 4 5 6; 7 8 9')
print a
b = np.matrix('4 5 6; 7 8 9; 10 11 12')
print b
print a*b

(2) 掩码数组

NumPy有一个生成掩码数组的模块numpy.ma。掩码数组是一个包含一些非法的、缺失的、未预料的实体的正常数组。掩码数组有两个组成成分：原始ndarray和一个掩码。掩码是由布尔逻辑值组成的数组，可以用来判定数组的值是有效还是无效。掩码中的一个true值反应了数组里相应的值是无效的。在掩码数组后面的计算中，这些无效的实体将不会用到。下一个程序展示了掩码数组的概念。假设原始数组x里包含不同人的脉搏率，并且其中有两个非法实体。为了掩盖这两个非法实体，掩码中相应的值被设置成1（true）。最后我们计算原始数组和掩码数组的平均值。没用掩码处理的平均值是61.1，因为包含两个负值，而用掩码进行处理后的平均值是94.5：

import numpy as np
import numpy.ma as ma
x = np.array([72, 79, 85, 90, 150, -135, 120, -10, 60, 100])
mx = ma.masked_array(x, mask=[0, 0, 0, 0, 0, 1, 0, 1, 0, 0])
mx2 = ma.masked_array(x,mask=x<0)
x.mean()
mx.mean()
mx2.mean()

(3) 结构化的数组

NumPy的ndarray能包含记录类型的值。为了生成一个记录类型的数组，首先需要生成记录类型的数据类型，然后用这种数据类型的元素构建数组。这种数据类型可以通过dtype在数组定义中定义。下面的程序中生成了一个记录类型的数组，这个数组中包含城市的最低、最高和平均气温。dtype函数由两部分组成：域的命名和格式。例子中用到的格式是32位整型（i4）、32位浮点型（f4）和包含30或更少的字符的字符串（a30）：

import numpy as np
rectype= np.dtype({'names':['mintemp', 'maxtemp', 'avgtemp', 'city'], 'formats':['i4','i4', 'f4', 'a30']})
a = np.array([(10, 44, 25.2, 'Indore'),(10, 42, 25.2, 'Mumbai'), (2,48, 30, 'Delhi')],dtype=rectype)
print a[0]
print a['mintemp']
print a['maxtemp']
print a['avgtemp']
print a['city']

5.1.2　通用函数对象

通用函数（unfunc）是对ndarray进行一个元素一个元素操作的函数。它也支持广播、类型转换和一些其他重要的特征。在NumPy中，广播是对不同维度数组的操作过程。特别是在数学运算中，较小维度的数组要在大维度的数组中广播，以使其维度和大维度数组兼容。通用函数即是NumPy中ufunc类的实例。

1. 属性

每个通用函数都有一些属性，但是用户无法设置这些属性的值。下面是通用函数的属性，这是一些通用函数的信息属性。

__doc__：它包含了ufunc函数的doc字符串。其第一部分是基于命名、输入的数量和输出的数量动态生成的。第二部分是在函数生成时定义的，并存在函数中。

__name__：这是ufunc的命名。

ufunc.nin：这表示总的输入数量。

ufunc.nout：这表示总的输出数量。

ufunc.nargs：这表示总的变量数，包括输入和输出的数量。

ufunc.ntypes：这表示这个函数定义的类型的种类。

ufunc.types：这个属性返回函数定义的ntypes种具体类型的列表。

ufunc.identity：这表示这个函数的值。

2. 方法

所有的通用函数ufunc有5种方法，如下所示。前4种方法的通用函数包含两个输入变量，返回一个输出变量。这些方法在调用其他函数失败时会报出ValueError异常。第五种方法允许用户利用索引做位置操作。下面是每个NumPy通用函数可用的方法。

ufunc.reduce：在一个坐标轴应用通用函数时，它可以将数组降低一个维度。

ufunc.accumulate：它可以在对所有元素使用同一个运算符时积累结果。

ufunc.reduceat：它可以在单个坐标轴上减少指定的切片。

ufunc.outer(A, B)：它对所有(a, b)应用通用函数操作符，其中a包含于A，b包含于B。

ufunc.at：它对特定元素执行未缓存的位置操作。

3. 各种可用的通用函数

目前NumPy支持超过60种的通用函数。这些函数包括广泛的操作，如简单的数学运算（加、减、求模、取绝对值）、开方、幂运算、指数运算、三角运算、比特位运算、比较和浮点运算。通常最好选择利用这些函数而不选择循环，因为相比于循环这些函数更高效。

下面的程序展示了一些通用函数的运用：

import numpy as np
x1 = np.array([72, 79, 85, 90, 150, -135, 120, -10, 60, 100])
x2 = np.array([72, 79, 85, 90, 150, -135, 120, -10, 60, 100])
x_angle = np.array([30, 60, 90, 120, 150, 180])
x_sqr = np.array([9, 16, 25, 225, 400, 625])
x_bit = np.array([2, 4, 8, 16, 32, 64])
np.greater_equal(x1,x2)
np.mod(x1,x2)
np.exp(x1)
np.reciprocal(x1)
np.negative(x1)
np.isreal(x1)
np.isnan(np.log10(x1))
np.sqrt(np.square(x_sqr))
np.sin(x_angle*np.pi/180)
np.tan(x_angle*np.pi/180)
np.right_shift(x_bit,1)
np.left_shift (x_bit,1)

在Python中，如果有一个值不能用数字表示，那么这个值就是空值nan。例如，如果对前一个程序中的x1数组做log10的通用函数运算，那么输出值就是nan。有一个通用函数isnan可用来检验一个值是不是nan。三角函数需要角度值度数的变量。十进制常数值是弧度值，可以通过乘以180/numpy.pi来实现度数的转换。比特位左移1位执行的是变量乘以2。类似地，比特位右移1位执行的是变量除以2。通常，这些通用函数都是对数组进行操作，如果有非数组的变量的话，那么这个变量通过广播机制当作一个数组，然后执行逐元素的操作。这就是前一个程序中最后四行的操作。

5.1.3　NumPy的数学模块

NumPy加入了特定功能的模块，例如线性代数、离散傅里叶变换、随机采样和矩阵代数库。这些功能捆绑在单独的模块中，这些模块列举如下。

numpy.linalg：这个模块支持线性代数的各种功能，如数组和向量的内积、外积和点积；向量和矩阵的范数；线性矩阵方程的解；矩阵转置的方法。

numpy.fft：离散傅里叶变换在数字信号处理中有广泛的应用。这个模块中的函数可以计算各种类型的离散傅里叶变换，包括一维、二维、多维、转置和傅里叶变换。

numpy.matlib：这个模块包括那些默认返回矩阵对象而不是ndarray对象的函数。这些函数包括empty、zeros、ones、eye、rapmat、rand、randn、bmat、mat和matrix。

numpy.random：这个模块包括在特定人群或范围中执行随机抽样的函数。它也支持随机排列组合的生成。另外，它还包括一些支持各种基于统计分布生成的随机抽样数值的函数。

下面一个程序展示了linalg模块中的一些函数的应用。它计算了范数、转置、行列式、特征值和方阵右特征向量。它还通过求解两个方程2x+3y=4和3x+4y=5展示了线性方程的求解，并且是通过将它们当作数组来求解的。最后一行的allclose函数比较了传入的两个数组，并且当它们的每个元素都相等时返回true。eig方法计算了方阵的特征值和特征向量。返回值如下：w是特征值，v是特征向量，v[:,i]列是w[i]的特征向量。

import numpy as np
from numpy import linalg as LA
arr2d = np.array(( (100,200,300),
                (111,222,333),
                (129,461,795) ))
eig_val, eig_vec = LA.eig(arr2d)
LA.norm(arr2d)
LA.det(arr2d)
LA.inv(arr2d)
arr1 = np.array([[2,3], [3,4]])
arr2 = np.array([4,5])
results = np.linalg.solve(arr1, arr2)
print results
np.allclose(np.dot(arr1, results), arr2)

随机抽样是科学和商业计算中很重要的方面。下面的程序展示了numpy随机抽样模块支持的各类函数中的部分函数。除了样本维度与总体，有些分布还需要一些统计值，例如均值、众数、标准差。permutation函数随机排列一个序列或者返回一个排列范围，randint函数随机返回最初的两个变量所指定的范围中的一些元素，而元素的总个数由第三个变量指定。剩余的方法返回特定分布中的抽样，如卡方检验、帕雷托分布、正态分布和对数正态分布。

import numpy as np
np.random.permutation(10)
np.random.randint(20,50, size=10)
np.random.random_sample(10)
np.random.chisquare(5,10) # 自由度
alpha, location_param = 4., 2.
s = np.random.pareto(alpha, 10) + location_param

s = np.random.standard_normal(20)

mean, std_deviation = 4., 2.
s = np.random.lognormal(mean, std_deviation, 10)

5.2　SciPy的介绍

SciPy包含一系列子模块，专用于各种科学计算的应用。SciPy社区建议，科学家们在补充某个函数进SciPy前，首先应检查该函数是否已经包括在SciPy中。因为几乎所有科学计算的基本函数已经在SciPy中实现完成，所以检查可以省去科学家们重新编写该函数的精力。并且，SciPy模块已经被优化并且经过了良好的缺陷和可能的错误测试。因此，利用SciPy可以获得很好的性能。

5.2.1　SciPy的数学函数

SciPy是写于NumPy之上的，扩展了NumPy的功能以执行高级的数学功能。NumPy中可用的基础的数学函数没有被重新设计以执行这些功能。我们还需要用到NumPy的函数，在本章随后的程序中将看到其应用。

5.2.2　高级模块/程序包

SciPy的功能被分成一些独立的任务特定的模块。我们将逐个讨论这些模块。为简洁起见，将不会谈到任一模块的所有函数，而是给出SciPy中每个模块的一些示例。

1. 积分

scipy.integrate子包中有几个用到不同积分方法的积分函数，包括对常微分方程的积分。当函数对象给定时，可以用几种积分函数。当固定样本给定时，也有几种积分函数。

下面是给定函数对象的积分函数。

quad：通用积分

dblquad：通用二重积分

tplquad：通用三重积分

nquad：通用 N 重积分

fixed_quad：对func(x)做 N 维高斯积分

quadrature：在给定容限范围内的高斯积分

romberg：对函数做Romberg积分

下面是固定样本给定时的积分函数。

cumtrapz：用梯形积分法计算积分

simps：用辛氏法则从样本中计算积分

romb：用Romberg积分法从(2**k + 1)个均匀间隔的样本中计算积分

下面是用于常微分方程中的积分函数。

odeint：差分方程的通用积分

ode：用VODE和ZVODE的方式进行ODE积分

complex_ode：将复数值的ODE转化成实数并积分

来讨论上述列表中列举的函数。quad函数执行函数的通用积分，积分范围在负无穷大到正无穷大之间。在下面的程序中，用这个函数计算第一类贝赛尔函数在(0,20)区间的积分。第一类贝赛尔函数在special.jv中做了定义。下面程序的最后一行用quad函数计算了高斯积分：

import numpy as np
from scipy import special
from scipy import integrate

result = integrate.quad(lambda x: special.jv(4,x), 0, 20)
print result
print "Gaussian integral", np.sqrt(np.pi),quad(lambda x: np.exp(-
       x**2),-np.inf, np.inf)

如果函数在积分时需要额外的参数，例如变量进行乘或乘方的因子，那么这些参数就可以作为变量传入。下面的程序展示了将a、b和c作为变量传入quad函数。有时积分会慢慢地发散或收敛：

import numpy as np
from scipy.integrate import quad

def integrand(x, a, b, c):
    return a*x*x+b*x+c

a = 3
b = 4
c = 1
result = quad(integrand, 0,np.inf, args=(a,b,c))
print result

二重积分和三重积分可以用dblquad和tplquad函数来实现。下面的程序示范了dblquad函数的应用。变量t和x的变化范围从0到无穷（inf）。注释后面的代码在指定的区间做了高斯积分：

import numpy as np
from scipy.integrate import quad, dblquad, fixed_quad

def integrand1 (t, x, n):
    return np.exp(-x*t) / t**n

n = 4
result = dblquad(lambda t, x: integrand1(t, x, n), 0, np.inf, lambda
        x: 0, lambda x: np.inf)

# 下面代码实现固定间隔的高斯积分
from scipy.integrate import fixed_quad, quadrature

def integrand(x, a, b):
return a * x + b
a = 2
b = 1
fixed_result = fixed_quad(integrand, 0, 1, args=(a,b))
result = quadrature(integrand, 0, 1, args=(a,b))

对于带有任意间隔采样的函数的积分，可以用simps函数。辛普森法则可以估计三个相邻点间的函数为抛物线函数。下面的程序演示了simps函数：

import numpy as np
from scipy.integrate import simps
def func1(a,x):
    return a*x**2+2

def func2(b,x):
    return b*x**3+4

x = np.array([1, 2, 4, 5, 6])
y1 = func1(2,x)
Intgrl1 = simps(y1, x)

print(Intgrl1)

y2 = func2(3,x)
Intgrl2 = simps (y2,x)
print (Intgrl2)

下面的程序用odeint函数做常微分方程的积分：

import matplotlib.pyplot as plt
from numpy import linspace, array
def derivative(x,time):
    a = -2.0
    b = -0.1
    return array([ x[1], a*x[0]+b*x[1] ])
time = linspace (1.0,15.0,1000)
xinitialize = array ([1.05,10.2])
x = odeint(derivative,xinitialize,time)
plt.figure()
plt.plot(time,x[:,0])
plt.xlabel('t')
plt.ylabel('x')
plt.show()

2. 信号处理（scipy.signal）

信号处理工具箱包含一系列滤波函数、滤波器设计函数，以及对一维和二维数据进行B—样条插值的函数。这个工具箱包含的函数可以进行以下操作：

卷积

B—样条

滤波

滤波器设计

Matlab式的IIR滤波器设计

连续时间的线性系统

离散时间的线性系统

线性时不变系统

信号波形

窗函数

小波分析

谱峰的找寻

光谱分析

可通过一些示例程序来理解信号处理工具箱的功能。

detrend函数是一个滤波函数。该函数从数据中沿着坐标轴去除常量或线性趋势，使得我们可以看到高阶的效果。具体程序如下所示：

import numpy as np
import matplotlib as mpl
import matplotlib.pyplot as plt
from scipy import signal
t = np.linspace(0, 5, 100)
x = t + np.random.normal(size=100)
plt.plot(t, x, linewidth=3)
plt.show()
plt.plot(t, signal.detrend(x), linewidth=3)
plt.show()

下面的程序应用样条滤波来处理“Lena的脸”图像边缘，用misc.lena命令将这张图片数据当作一个数组。用两个函数实现滤波功能。首先，cspline2d命令将一个带有镜像对称边界的二维FIR滤波器应用于样条系数。这个函数比convolve2d函数快，convolve2d函数对任意二维滤波器卷积并允许你选择镜像对称边界：

import numpy as np
from scipy import signal, misc
import matplotlib.pyplot as plt
img = misc.lena()

splineresult = signal.cspline2d(img, 2.0)
arr1 = np.array([[-1,0,1], [-2,0,2], [-1,0,1]], dtype=np.float32)
derivative = signal.convolve2d(splineresult,arr1,boundary='symm'
        ,mode='same')
plt.figure()
plt.imshow(derivative)
plt.title('Image filtered by spline edge filter')
plt.gray()
plt.show()

3. 傅里叶变换（scipy.fftpack）

对实数或复数序列的离散傅里叶变换和离散傅里叶逆变换可以分别用fft和ifft函数来计算，fft是指快速傅里叶变换。下面的程序是一个示例：

import numpy as np
from scipy.fftpack import fft, ifft
x = np.random.random_sample(5)
y = fft(x)
print y
yinv = ifft(y)
print yinv

下面的程序画出了三个正弦函数的和的FFT：

import numpy as np
import matplotlib as mpl
import matplotlib.pyplot as plt
from scipy.fftpack import fft
x = np.linspace(0.0, 1, 500)
y = np.sin(100*np.pi*x) + 0.5*np.sin(150*np.pi*x) + 0.75*np.
sin(200*np.pi*x)
yf = fft(y)
xf = np.linspace(0.0, 0.1, 250)
import matplotlib.pyplot as plt
plt.plot(xf, 2.0/500 * np.abs(yf[0:500/2]))
plt.grid()
plt.show()

4. 空间数据结构和算法（scipy.spatial）

空间分析是一系列用于分析空间数据的技巧和算法。空间数据是指和地理空间或垂直空间相关的数据对象或元素。这种数据包括点、线、多边形、其他几何和地理特征信息，几何和地理特征信息可以映射为位置信息，用于跟踪或定位各种装置。这种提供地理或空间位置信息的数据可能是标量或是矢量的。空间数据被用于各种领域的各种应用，如地理学、地理信息系统/检索、基于位置的服务、基于网页和基于桌面的空间应用、空间挖掘，等等。

KD树（k-dimensional tree，k-d tree）是一种空间划分数据结构。它将所有点构建在k维空间中。在数学上，空间划分是将一个空间划分为多个相邻空间的过程。它将空间分成不重叠的区域，空间中的每个点只属于一个区域。

SciPy拥有支持各种空间计算功能的模块。用户可以计算Delaunay三角剖分、Voronoi图、N维凸包。SciPy还有画图工具，可以将这些计算结果画在二维空间中。此外，SciPy还支持KDTree功能（scipy.spatial.KDTree）实现快速近邻查找算法，还可以对初始向量集合进行距离矩阵的计算。

通过一些示例程序来看这些函数。下面的程序做了Delaunay三角剖分，并将计算结果用pyplot画出：

import numpy as np
import matplotlib.pyplot as plt
from scipy.spatial import Delaunay
arr_pt = np.array([[0, 0], [0, 1.1], [1, 0], [1, 1]])
arr1 = np.array([0., 0., 1., 1.])
arr2 = np.array([0., 1.1, 0., 1.])

triangle_result = Delaunay(arr_pt)
plt.triplot(arr1, arr2, triangle_result.simplices.copy())
plt.show()
plt.plot(arr1, arr2, 'ro')
plt.show()

最小的凸的对象包括给定点集合中的所有点，该对象被称作凸包，它可以用convexHull函数来计算。下面的程序展示了convexHull这个函数的应用，并将计算结果画出：

import numpy as np
from scipy.spatial import ConvexHull
import matplotlib.pyplot as plt
randpoints = np.random.rand(25, 2)
hull = ConvexHull(randpoints)
# 下面一行代码画图
plt.plot(randpoints[:,0], randpoints[:,1], 'x')
# 通过for循环画出各个线段
for simplex in hull.simplices:
    plt.plot(randpoints[simplex,0], randpoints[simplex,1], 'k')

plt.show()

可以用KDTree来查找点集中离选定点最近的点。下面的程序展示了KD树的应用：

from scipy import spatial
x_val, y_val = np.mgrid[1:5, 3:9]
tree_create = spatial.KDTree(zip(x_val.ravel(), y_val.ravel()))
tree_create.data
points_for_query = np.array([[0, 0], [2.1, 2.9]])
tree_create.query(points_for_query)

下面的程序显示了最近的距离和索引值：

import numpy as np
import matplotlib.pyplot as plt
from scipy.spatial import KDTree
vertx = np.array([[1, 1], [1, 2], [1, 3], [2, 1], [2, 2], [2, 3], [3,
1], [3, 2], [3, 3]])
tree_create = KDTree(vertx)
tree_create.query([1.1, 1.1])
x_vals = np.linspace(0.5, 3.5, 31)
y_vals = np.linspace(0.5, 3.5, 33)
xgrid, ygrid = np.meshgrid(x, y)
xy = np.c_[xgrid.ravel(), ygrid.ravel()]
plt.pcolor(x_vals, y_vals, tree.query(xy)[1].reshape(33, 31))
plt.plot(points[:,0], points[:,1], 'ko')
plt.show()

5. 最优化（scipy.optimize）

最优化是查找带一个或多个变量的对象函数在多个预先定义变量约束的条件下的最佳方案的过程。对象函数被当作代价函数以最小化，或者被当作利用函数或利益函数以最大化。优化问题有几点重要的事项，例如优化问题的维度和优化的类型。在解决优化问题之前，最好先了解这些事项然后再开始着手解决问题。对于维度的问题，我们指的标量变量的数量是用于寻找最优值的，变量的数量可以是一个或多个。这个变量的数量也会影响最优解的可扩展性。变量的数量越多，求解速度越慢。而且优化类型也会影响解的设计。

另一点需要考虑到的是该问题是否是一个约束问题。所谓约束问题，所指的是最终解必须满足一些预定的约束条件。例如，下面是一个通用约束最小化优化问题的程序：

目标函数：    最小化 f(x)
约束条件：    gi (x)= ai for i= 1 … … n
              Hj (x)>= bj for j= 1 … … m

最优解必须满足这些约束条件。问题的解取决于目标函数、约束条件和变量间的关系。此外，模型的大小也会影响最优解。模型的大小通过变量的数量和约束条件的数量来确定。通常模型的大小会有一个上限，该上限是优化求解软件或应用强制限定的。这个上限的引入主要是由较高的存储需求、问题的处理需求和数值稳定性决定的。这可能就会导致我们最终找不到最优解，或者求解的过程非常耗时，以致会让人觉得这个解没有收敛。

另外，优化问题可能是凸或非凸的问题。如果是一个凸问题，那么相对来说容易求解，因为它会有一个全局最小或最大值，并且不存在局部最小或最大值。

我们来详细讨论凸度（convexity）的概念。凸优化是在凸集合中最小化convex函数的过程。在某个区间的convex函数（该函数处理实数值）被称作convex函数，条件是图中任意两点间的线段在图中或在图之上。两个常用的convex函数是指数函数（f (x)=ex）和二次函数（f (x)=x²）。凸函数和非凸函数的一些例子如下图所示。

凸集合是指一个区域，该区域中如果我们用线段连接其中的两点，那么线段上的所有点都应该落在该区域中。下图演示了凸集合和非凸集合的区别。

scipy.optimize包提供了标量以及多维函数最小化、曲线拟合和根求解的最有用的函数。来看看如何用这些函数。

下面的程序展示了Broyden-Fletcher-Goldfarb-Shanno（BFGS）算法的运用。这个算法用目标函数的梯度来快速收敛求解。这个程序首先定义了一个叫rosen_derivative的函数来计算rosenbrock函数的梯度：

import numpy as np
from scipy.optimize import minimize
def rosenbrock(x):
    return sum(100.0*(x[1:]-x[:-1]**2.0)**2.0 + (1-x[:-1])**2.0)

x0 = np.array([1.3, 0.7, 0.8, 1.9, 1.2])

def rosen_derivative(x):
    x1 = x[1:-1]
    x1_m1 = x[:-2]
    x1_p1 = x[2:]
    derivative = np.zeros_like(x)
    derivative[1:-1] = 200*(x1-x1_m1**2) - 400*(x1_p1 - x1**2)*x1 -\
        2*(1-x1)
    derivative[0] = -400*x[0]*(x[1]-x[0]**2) - 2*(1-x[0])
    derivative[-1] = 200*(x[-1]-x[-2]**2)
    return derivative

res = minimize(rosenbrock, x0, method='BFGS', jac=rosen_derivative,
options={'disp': True})

下面的程序首先计算了Rosenbrock函数的海森矩阵，然后用牛顿共轭梯度法求函数最小值：

import numpy as np
from scipy.optimize import minimize

def rosenbrock(x):
    return sum(100.0*(x[1:]-x[:-1]**2.0)**2.0 + (1-x[:-1])**2.0)

x0 = np.array([1.3, 0.7, 0.8, 1.9, 1.2])

def rosen_derivative(x):
    x1 = x[1:-1]
    x1_m1 = x[:-2]
    x1_p1 = x[2:]
    derivative = np.zeros_like(x)
    derivative[1:-1] = 200*(x1-x1_m1**2) - 400*(x1_p1 - x1**2)*x1 - \
        2*(1-x1)
    derivative[0] = -400*x[0]*(x[1]-x[0]**2) - 2*(1-x[0])
    derivative[-1] = 200*(x[-1]-x[-2]**2)
    return derivative

def rosen_hessian(x):
    x_val = np.asarray(x)
    hess = np.diag(-400*x_val[:-1],1) - np.diag(400*x_val[:-1],-1)
    diagonal = np.zeros_like(x_val)
    diagonal[0] = 1200*x_val[0]**2-400*x_val[1]+2
    diagonal[-1] = 200
    diagonal[1:-1] = 202 + 1200*x_val[1:-1]**2 - 400*x_val[2:]
    hess = hess + np.diag(diagonal)
    return hess

result = minimize(rosenbrock, x0, method='Newton-CG', jac=rosen_
derivative, hess=rosen_hessian, options={'xtol': 1e-8, 'disp': True})
print result.x

minimize函数也有一个对多个约束条件最小化算法的接口。下面的程序用到Sequential Least Square Programming optimization（SLSQP）算法。这个被最小化的函数定义为func，它的导数被定义为func_deriv，约束条件在cons变量中定义：

import numpy as np
from scipy.optimize import minimize
def func(x, sign=1.0):
    return sign*(2*x[0]*x[1] + 2*x[0] - x[0]**2 - 2*x[1]**2)

def func_deriv(x, sign=1.0):
    dfdx0 = sign*(-2*x[0] + 2*x[1] + 2)
    dfdx1 = sign*(2*x[0] - 4*x[1])
    return np.array([ dfdx0, dfdx1 ])

cons = ({'type': 'eq',
    'fun': lambda x: np.array([x[0]**3 - x[1]]),
    'jac': lambda x: np.array([3.0*(x[0]**2.0), -1.0])},
    {'type': 'ineq',
    'fun': lambda x: np.array([x[1] - 1]),
    'jac': lambda x: np.array([0.0, 1.0])})

res = minimize(func, [-1.0,1.0], args=(-1.0,), jac=func_deriv,
method='SLSQP', options={'disp': True})
print(res.x)

res = minimize(func, [-1.0,1.0], args=(-1.0,), jac=func_
deriv,constraints=cons, method='SLSQP', options={'disp': True})
print(res.x)

下面的程序展示了寻找全局最小和局部最小的方法。首先，它定义了一个函数并将其画出。这个函数在-1.3全局最小，在3.8局部最小。BFGF算法用于寻找局部最小。这个程序用BFGF算法寻找全局最小。但是随着网格大小的增加（待检查值的范围），BFGF算法会变慢。所以对于标量函数最好用Brent算法。下面的程序还用到了fminbound函数来寻找0至10区间的局部最小：

import numpy as np
import matplotlib.pyplot as plt
from scipy import optimize

def f(x):
    return x**2 + 10*np.sin(x)

x = np.arange(-10,10,0.1)
plt.plot(x, f(x))
plt.show()

optimize.fmin_bfgs(f, 0)
grid = (-10, 10, 0.1)
optimize.brute(f, (grid,))
optimize.brent(f)
optimize.fminbound(f, 0, 10)

下面的程序展示了约束最优化的应用：

import numpy as np
from scipy import optimize
def f(x):
    return np.sqrt((x[0] - 2)**2 + (x[1] - 3)**2)

def constraint(x):
    return np.atleast_1d(2.5 - np.sum(np.abs(x)))

optimize.fmin_slsqp(f, np.array([0, 2]), ieqcons=[constraint, ])
optimize.fmin_cobyla(f, np.array([3, 4]), cons=constraint)

有多种方法可以求解多项式的根，下面三个程序分别用到了二分法、牛顿—拉斐森法和根函数。下面的程序用二分法求解polynomial_func函数中定义的多项式的根：

import scipy.optimize as optimize
import numpy as np

def polynomial_func(x):
    return np.cos(x)**3 + 4 - 2*x

print(optimize.bisect(polynomial_func, 1, 5))

下面的程序用牛顿—拉斐森法求解多项式的根：

import scipy.optimize
from scipy import optimize

def polynomial_func(xvalue):
    yvalue = xvalue + 2*scipy.cos(xvalue)
    return yvalue

scipy.optimize.newton(polynomial_func, 1)

在数学上，拉格朗日乘子法被用于寻找函数在等量约束的条件下的局部最小和局部最大。下面的程序用fsolve函数计算了拉格朗日乘子：

import numpy as np
from scipy.optimize import fsolve
def func_orig(data):
    xval = data[0]
    yval = data[1]
    Multiplier = data[2]
    return xval + yval + Multiplier * (xval**2 + yval**2 - 1)

def deriv_func_orig(data):
    dLambda = np.zeros(len(data))
    step_size = 1e-3 # 有限微分中使用的步长
    for i in range(len(data)):
    ddata = np.zeros(len(data))
    ddata[i] = step_size
    dLambda[i] = (func_orig(data+ddata)-func_orig(data-ddata))/\
            (2*step_size);
    return dLambda

data1 = fsolve(deriv_func_orig, [1, 1, 0])
print data1, func_orig(data1)

data2 = fsolve(deriv_func_orig, [-1, -1, 0])
print data2, func_orig(data2)

6. 插值（scipy.interpolate）

插值是在给定的已知的离散值集合范围内寻找新数据点的方法。interpolate子包包含插值运算的各种方法。它支持用spline函数、univariate函数和multivariate函数进行一维和多维插值，拉格朗日和泰勒多项式插值。对于FITPACK和DFITPACK函数，它还有封装类。我们来看上面提到的部分方法的示例程序。

这个程序展示了用线性和立方插值进行的一维插值，并且将插值结果画图进行比较：

import numpy as np
from scipy.interpolate import interp1d
x_val = np.linspace(0, 20, 10)
y_val = np.cos(-x**2/8.0)
f = interp1d(x_val, y_val)
f2 = interp1d(x_val, y_val, kind='cubic')
xnew = np.linspace(0, 20, 25)
import matplotlib.pyplot as plt
plt.plot(x,y,'o',xnew,f(xnew),'-', xnew, f2(xnew),'--')
plt.legend(['data', 'linear', 'cubic'], loc='best')
plt.show()

下面的程序展示了用griddata函数对超过150个点的多元数据进行插值。点的个数可以调整为合适的值。这个程序用pyplot在一个图像中生成四个子图：

import numpy as np
import matplotlib.pyplot as plt
from scipy.interpolate import griddata

def func_user(x, y):
    return x*(1-x)*np.cos(4*np.pi*x) * np.sin(4*np.pi*y**2)**2

x, y = np.mgrid[0:1:100j, 0:1:200j]

points = np.random.rand(150, 2)
values = func_user(points[:,0], points[:,1])
grid_z0 = griddata(points, values, (x, y), method='nearest')
grid_z1 = griddata(points, values, (x, y), method='linear')
grid_z2 = griddata(points, values, (x, y), method='cubic')

f, axarr = plt.subplots(2, 2)
axarr[0, 0].imshow(func(x, y).T, extent=(0,1,0,1), origin='lower')
axarr[0, 0].plot(points[:,0], points[:,1], 'k', ms=1)
axarr[0, 0].set_title('Original')

axarr[0, 1].imshow(grid_z0.T, extent=(0,1,0,1), origin='lower')
axarr[0, 1].set_title('Nearest')

axarr[1, 0].imshow(grid_z1.T, extent=(0,1,0,1), origin='lower')
axarr[1, 0].set_title('Linear')

axarr[1, 1].imshow(grid_z2.T, extent=(0,1,0,1), origin='lower')
axarr[1, 1].set_title('Cubic')

plt.show()

7. 线性代数（scipy.linalg）

SciPy线性代数将变量当作一个对象，这个对象可以转换成一个二维数组并返回一个二维数组。与numpy.linalg相比，scipy.linalg函数有更高级的特征。

下面的程序计算了矩阵（二维数组）的逆。它也用到T（转置的符号）和执行数组的乘法：

import numpy as np
from scipy import linalg
A = np.array([[2,3],[4,5]])
linalg.inv(A)
B = np.array([[3,8]])
A*B
A.dot(B.T)

这个小程序计算了矩阵的逆和行列式的值：

import numpy as np
from scipy import linalg
A = np.array([[2,3],[4,5]])
linalg.inv(A)
linalg.det(A)

下面的程序演示了通过逆矩阵和求解器快速求解线性方程组的方法：

import numpy as np
from scipy import linalg
A = np.array([[2,3],[4,5]])
B = np.array([[5],[6]])
linalg.inv(A).dot(B)
np.linalg.solve(A,B)

下面的程序求解一组线性标量系数并用模型进行数据拟合。这个程序用到linalg.lstsq求解数据拟合问题。lstsq方法被用于寻找线性矩阵方程的最小平方解。这个方法是针对给定数据点寻找最佳拟合线的工具。它使用线性代数和简单的积分：

import numpy as np
from scipy import linalg
import matplotlib.pyplot as plt
coeff_1, coeff_2 = 5.0, 2.0
i = np.r_[1:11] # 也可以用np.arang(1, 11)
x = 0.1*i
y = coeff_1*np.exp(-x) + coeff_2*x
z = y + 0.05 * np.max(y) * np.random.randn(len(y))

A = np.c_[np.exp(-x)[:, np.newaxis], x[:, np.newaxis]]
coeff, resid, rank, sigma = linalg.lstsq(A, zi)

x2 = np.r_[0.1:1.0:100j]
y2 = coeff[0]*np.exp(-x2) + coeff[1]*x2

plt.plot(x,z,'x',x2,y2)
plt.axis([0,1,3.0,5.5])
plt.title('Data fitting with linalg.lstsq')
plt.show()

下面的程序展示了用linag.svd和linag.diagsvd函数进行奇异值分解的方法：

import numpy as np
from scipy import linalg
A = np.array([[5,4,2],[4,8,7]])
row = 2
col = 3
U,s,Vh = linalg.svd(A)
Sig = linalg.diagsvd(s,row,col)
U, Vh = U, Vh
print U
print Sig
print Vh

8. 用ARPACK解决稀疏特征值问题

下面这个程序计算了标准特征值分解和对应的特征向量：

import numpy as np
from scipy.linalg import eigh
from scipy.sparse.linalg import eigsh
# 限制小数的位数
np.set_printoptions(suppress=True)

np.random.seed(0)
random_matrix = np.random.random((75,75)) - 0.5
random_matrix = np.dot(random_matrix, random_matrix.T)
# 计算特征值与特征向量
eigenvalues_all, eigenvectors_all = eigh(random_matrix)

eigenvalues_large, eigenvectors_large = eigsh(random_matrix, 3,
        which='LM')
print eigenvalues_all[-3:]
print eigenvalues_large
print np.dot(eigenvectors_large.T, eigenvectors_all[:,-3:])

如果我们要找最小的特征值，可以用eigenvalues_small, eigenvectors_small = eigsh(random_matrix, 3, which='SM')。在这个例子中系统返回了一个没有收敛的错误。有几种办法可以解决这个问题。第一个办法是通过将tol=1E-2传入eigsh函数增加容限值：eigenvalues_small, eigenvectors_small = eigsh(random_matrix, 3, which='SM', tol=1E-2)。这可以解决问题，但是会损失精度。

另一个解决办法是增加最大的迭代次数至5000，通过将maxiter=5000传入eigsh函数来实现：eigenvalues_small, eigenvectors_small = eigsh(random_matrix, 3, which='SM', maxiter=5000)。但是更多的迭代次数意味着更长的运行时间。还有一个更好的办法来解决快速性和精度问题，那就是用shift-inter模式，用到sigma=0或2和which='LM'参数：eigenvalues_ small, eigenvectors_small = eigsh(random_matrix, 3, sigma=0, which='LM')。

9. 统计学（scipy.stats）

SciPy有很多统计函数可以用于数组的运算，还有用于掩码数组的特殊版本。下面的各个程序展示了部分连续和离散概率分布函数的应用。

下面的程序用到了离散二项式随机变量并画出了它的该概率质量函数。这是一个离散二项分布的概率质量函数：

binom.pmf(k) = choose(n, k) * p**k * (1-p)**(n-k)

在之前的代码中，k 的取值范围是(0,1,…,n)，其中n和p是形状参数：

import numpy as np
from scipy.stats import binom
import matplotlib.pyplot as plt

n, p = 5, 0.4
mean, variance, skewness, kurtosis = binom.stats(n, p, moments='mvsk')
x_var = np.arange(binom.ppf(0.01, n, p),binom.ppf(0.99, n, p))

plt.plot(x_var, binom.pmf(x_var, n, p), 'ro', ms=5, label='PMF of \
        binomial ')
plt.vlines(x_var, 0, binom.pmf(x_var, n, p), colors='r', lw=3,
        alpha=0.5)
plt.show()

下面的程序用到了几何离散随机变量并画出了概率质量函数：

geom.pmf(k) = (1-p)**(k-1)*p

这里k≥1，p是形状参数：

import numpy as np
from scipy.stats import geom
import matplotlib.pyplot as plt

p = 0.5
mean, variance, skewness, kurtosis = geom.stats(p, moments='mvsk')
x_var = np.arange(geom.ppf(0.01, p),geom.ppf(0.99, p))
plt.plot(x_var, geom.pmf(x_var, p), 'go', ms=5, label='PMF of\
         geomatric')
plt.vlines(x_var, 0, geom.pmf(x_var, p), colors='g', lw=3, alpha=0.5)

plt.show()

下面的程序展示了广义帕累托连续随机变量的计算并画出其概率密度函数：

genpareto.pdf(x, c) = (1 + c * x)**(-1 - 1/c)
# 如果c≥0，则x≥0；如果c<0，则0≤ x≤-1/c
import numpy as np
from scipy.stats import genpareto
import matplotlib.pyplot as plt
c = 0.1
mean, variance, skewness, kurtosis = genpareto.stats(c,
        moments='mvsk')
x_val = np.linspace(genpareto.ppf(0.01, c),genpareto.ppf(0.99, c),
        100)
plt.plot(x_val, genpareto.pdf(x_val, c),'b-', lw=3, alpha=0.6,
        label='PDF of Generic Pareto')
plt.show()

下面的程序展示了广义伽马连续型随机变量的计算并画出其概率密度函数：

gengamma.pdf(x, a, c) = abs(c) * x**(c*a-1) * exp(-x**c) / gamma(a)

这里，r x > 0，a > 0，c != 0。其中，a和c为形状参数：

import numpy as np
from scipy.stats import gengamma
import matplotlib.pyplot as plt
a, c = 4.41623854294, 3.11930916792
mean, variance, skewness, kurtosis = gengamma.stats(a, c,
        moments='mvsk')
x_var = np.linspace(gengamma.ppf(0.01, a, c),gengamma.ppf(0.99, a, c),
        100)
plt.plot(x_var, gengamma.pdf(x_var, a, c),'b-', lw=3, alpha=0.6,
        label='PDF of generic Gamma')
plt.show()

下面的程序展示了多元正态随机变量的计算并画出其概率密度函数。为简便起见，省略其概率密度函数：

import numpy as np
import matplotlib.pyplot as plt
from scipy.stats import multivariate_normal
x_var = np.linspace(5, 25, 20, endpoint=False)
y_var = multivariate_normal.pdf(x_var, mean=10, cov=2.5)
plt.plot(x_var, y_var)
plt.show()

也可以停止这些概率分布以展示其概率分布/质量函数。

10. 多维图像处理（scipy.ndimage）

通常图像处理和图像分析可以看作对二维数组的操作。这个包提供了图像处理和图像分析的各种函数。下面的代码处理了图像Lena。首先，这个程序在该图像中加入一些噪声，然后用一些滤波器来去除该噪声。它还展示了有噪声的图像以及分别用高斯滤波器、中值滤波器和signal.wiener滤波器的滤波效果：

import numpy as np
from scipy import signal
from scipy import misc
from scipy import ndimage
import matplotlib.pyplot as plt

lena = misc.lena()
noisy_lena = np.copy(lena).astype(np.float)
noisy_lena += lena.std()*0.5*np.random.standard_normal(lena.shape)
f, axarr = plt.subplots(2, 2)
axarr[0, 0].imshow(noisy_lena, cmap=plt.cm.gray)
axarr[0, 0].axis('off')
axarr[0, 0].set_title('Noissy Lena Image')
blurred_lena = ndimage.gaussian_filter(noisy_lena, sigma=3)
axarr[0, 1].imshow(blurred_lena, cmap=plt.cm.gray)
axarr[0, 1].axis('off')
axarr[0, 1].set_title('Blurred Lena')
median_lena = ndimage.median_filter(blurred_lena, size=5)
axarr[1, 0].imshow(median_lena, cmap=plt.cm.gray)
axarr[1, 0].axis('off')
axarr[1, 0].set_title('Median Filter Lena')
wiener_lena = signal.wiener(blurred_lena, (5,5))
axarr[1, 1].imshow(wiener_lena, cmap=plt.cm.gray)
axarr[1, 1].axis('off')
axarr[1, 1].set_title('Wiener Filter Lena')
plt.show()

以下截图是上面程序的输出。

11. 聚类

聚类是将一个大的对象集合分成多个组的过程。它用到了一些参数来判定一个对象和该组（也称类）内的对象更相似，而和其他类或组中的对象不相似。

对象分成四个类

SciPy聚类包包括两个模块：向量量化（Vector Quantization，VQ）和层次聚类。VQ模块支持K-means聚类和向量量化。层次聚类模块支持分层聚类和聚合聚类。

来简单地了解这些算法。

向量量化：VQ是使用户通过原型向量的分布来构建概率密度函数模型的信号处理技术。它通过将一个大的向量或点的集合分成多个组来完成建模，这些组在其近邻包括几乎相同数量的点。每个组都用中心点来表示。

K-means：K-means是信号处理中的向量量化技术，广泛用于聚类分析。它按照与聚类中心距离最近的方式，把n个观察值划分为k个聚类。

层次聚类：这种聚类方法是利用观察值建立一组聚类的层级。层次聚类技术大致可以分为以下两种类型。

分离聚类：这是一个生成层次聚类的自上而下的方法。它开始于最顶层的类，随着向下移动不断分裂。

聚合聚类：这是一个自下而上的方法。每个观察值是一个类，该方法在向上移动的过程中将类不断进行配对。

通常层次聚类的结果最终可以用树状图表示，如下图所示。

下面的程序展示了K-means聚类和向量量化的例子：

from scipy.cluster.vq import kmeans,vq
from numpy.random import rand
from numpy import vstack,array
from pylab import plot,show

data_set = vstack((rand(200,2) + array([.5,.5]),rand(200,2)))

# K-means聚类分成两个类
centroids_of_clusters,_ = kmeans(data_set,2)
index,_ = vq(data_set,centroids_of_clusters)

plot(data_set[index==0,0],data_set[index==0,1],'ob',
        data_set[index==1,0],data_set[index==1,1],'or')
plot(centroids_of_clusters[:,0],centroids_of_clusters[:,1],'sg',markersize=8)

show()

# 同样的数据分成三个类
centroids_of_clusters,_ = kmeans(data_set,3)
index,_ = vq(data_set,centroids_of_clusters)

plot(data_set[index==0,0],data_set[index==0,1],'ob',
        data_set[index==1,0],data_set[index==1,1],'or',
        data_set[index==2,0],data_set[index==2,1],'og') # 第三个类的点
plot(centroids_of_clusters[:,0],centroids_of_clusters[:,1],'sm', markersize=8)
show()

层次聚类的模块有很多函数，这些函数可以包括许多聚类方法，例如将层次聚类切分为扁平聚类的函数、聚合聚类函数、聚类可视化函数、数据结构，以及将层次聚类结果表示为树状图的函数、计算聚类层次的函数、检查链接和不规则指标有效性的推断函数，等等。下面的程序用linkage函数（聚合聚类）和dendrogram函数（层次聚类）画一个示例数据的树状图：

import numpy
from numpy.random import rand
from matplotlib.pyplot import show
from scipy.spatial.distance import pdist
import scipy.cluster.hierarchy as sch

x = rand(8,80)
x[0:4,:] *= 2

y = pdist(x)
z = sch.linkage(y)
sch.dendrogram(z)
show()

输出结果如下所示。

12. 曲线拟合

构造一个与数据序列最好地拟合的数学函数或曲线的过程即曲线拟合。通常，这个曲线拟合必须满足一些约束条件。曲线拟合的输出可以用于数据可视化，没有数据时也可以通过函数预测结果。曲线拟合也可以用来观察多个变量之间的关系。我们可以用不同类型的曲线做曲线拟合，如直线、多项式、圆锥曲线、三角函数、圆、椭圆等。

下面的程序首先生成了一些带噪声的随机数据。然后它定义了一个line_func函数来表示该模型并做曲线拟合。接下来求解实际参数a和b。最后画出误差：

import numpy as np
import matplotlib.pyplot as plt
from scipy.optimize import curve_fit

xdata = np.random.uniform(0., 50., 80)
ydata = 3. * xdata + 2. + np.random.normal(0., 5., 80)
plt.plot(xdata, ydata, '.')

def line_func(x, a, b):
    return a * x + b

opt_param, cov_estimated = curve_fit(line_func, xdata, ydata)

errors = np.repeat(5., 80)
plt.errorbar(xdata, ydata, yerr=errors, fmt=None)

opt_param, cov_estimated = curve_fit(line_func, xdata, ydata,
        sigma=errors)

print "a =", opt_param[0], "+/-", cov_estimated[0,0]**0.5
print "b =", opt_param[1], "+/-", cov_estimated[1,1]**0.5

plt.errorbar(xdata, ydata, yerr=errors, fmt=None)

xnew = np.linspace(0., 50., 80)
plt.plot(xnew, line_func(xnew, opt_param[0], opt_param[1]), 'r-')
plt.errorbar(xdata, ydata, yerr=errors, fmt=None)
plt.plot(xnew, line_func(xnew, *opt_param), 'r-')
plt.show()

下面的程序用cos三角函数做曲线拟合：

import numpy as np
from scipy import optimize
import pylab as pl

np.random.seed(0)

def func(x, omega, p):
    return np.cos(omega * x + p)

x = np.linspace(0, 10, 100)
y = f(x, 2.5, 3) + .1*np.random.normal(size=100)
params, params_cov = optimize.curve_fit(f, x, y)
t = np.linspace(0, 10, 500)
pl.figure(1)
pl.clf()
pl.plot(x, y, 'bx')
pl.plot(t, f(t, *params), 'r-')
pl.show()

13. 文件输入/输出（scipy.io）

SciPy用模块、类和函数提供了一系列文件格式的读和写的支持，这些文件包括：

Matlab文件

ALD文件

Matrix Market文件

无格式的FORTRAN文件

WAV声音文件

ARFF文件

NetCDF文件

下面的程序执行了NetCDF文件的读和写操作：

from scipy.io import netcdf
# 创建文件
f = netcdf.netcdf_file('TestFile.nc', 'w')
f.history = 'Test netCDF File Creation'
f.createDimension('age', 12)
age = f.createVariable('age', 'i', ('age',))
age.units = 'Age of persons in Years'
age[:] = np.arange(12)
f.close()

# 读取文件
f = netcdf.netcdf_file('TestFile.nc', 'r')
print(f.history)
age = f.variables['age']
print(age.units)
print(age.shape)
print(age[-1])
f.close()

同样，我们可以对其他类型的文件执行读和写操作。有一个加载文件的子模块，该子模块由WEKA机器学习工具生成。WEKA将文件存储为ARFF格式，该格式为WEKA的标准格式。ARFF是一个可能包含数值、字符串和数据值的文本文件。下面的程序读取和显示了test.arff文件中存储的数据。文件中的内容是@relation foo; @attribute width numeric; @attribute height numeric; @attribute color {red,green,blue,yellow,black}; @data; 5.0,3.25, blue; 4.5,3.75,green; 3.0,4.00,red。

下面的程序读取和显示了该文件内容：

from scipy.io import arff

file1 = open('test.arff')
data, meta = arff.loadarff(file1)

print data
print meta

5.3　小结

本章广泛地讨论了如何用Python的NumPy和SciPy包做数值计算。我们通过示例程序来理解这些概念。这一章从NumPy的基本对象开始讨论，然后学习了NumPy的高级功能。

这一章还讨论了SciPy的函数和模块。介绍了SciPy提供的基础函数和特殊函数，以及特殊的模块或子包。也介绍到了一些高级功能，如优化、插值、傅里叶变换、信号处理、线性代数、统计、空间算法、图像处理和文件的输入输出。

下一章将详尽地讨论用SymPy做符号运算或CAS，特别是学习多项式、微积分、求解方程、离散数学、几何和物理的核心功能与扩展功能。

第 6 章　用Python做符号计算

SymPy包括许多功能，从基本的符号算术到多项式、微积分、求解方程、离散数学、几何、统计和物理。它主要处理三种类型的数据：整型数据、实数和有理数。整数即不带小数点的数字，而实数是带小数点的数字。有理数包括两个部分：分子和分母。可以用Ration类定义有理数，该类需要两个数字。在这一章中，我们将通过示例程序来理解SymPy的概念。

这一章将学习以下主题：

用SymPy的计算机代数系统

核心功能和高级功能

多项式、微积分和求解方程

离散数学、矩阵、几何、画图、物理和统计

打印功能

首先讨论SymPy的核心功能，包括基本的算术、扩展、简化、替换、模式匹配和各种函数（例如指数函数、对数函数、方程的根、三角函数、双曲线函数和特殊函数）。

6.1　符号、表达式和基本运算

在SymPy中，在任何表达式中使用符号前，必须先定义该符号。定义符号很简单。只需要用Symbol类中的symbol函数来定义一个符号即可，下面的程序就用到了这种方式。我们可以用evalf()/n()来获得任何对象的浮点近似值。

下面的程序用了三种方法来生成符号。对于只生成一个符号的，函数命名为symbol；对于生成多个符号的，函数命名为symbols。有两种方法可以生成多个符号：第一种是将用空格分隔的符号名称传入符号函数；第二种方法是将m0:5传入符号函数，生成一个如m0, m1, m2, m3, m4的符号序列。在第二种方法中，m0是第一个索引值，冒号后的5表示生成五个这样的变量。

通常，两个整数的除会删除小数点后面的部分。为了避免这种情况，下面程序的第一行强制了两个整数的除的结果为浮点类型。这就是为什么程序的最后一行将3.142857142857143存入y中。如果忽略程序的第一行，那么y的值是3：

from __future__ import division
from sympy import *

x, y, z = symbols('x y z')
m0, m1, m2, m3, m4 = symbols('m0:5')
x1 = Symbol('x1')
x1 + 500
y=22/7

下面的程序用evalf()和n()函数将任何SymPy对象的数值近似转换为浮点值。默认的精度是小数点后15位，而且可以通过调整参数改成任何想要的精度：

from __future__ import division
from sympy import, sin, pi

x=sin(50)

pi.evalf()
pi.evalf(50)

x.n()
x.n(20)

下面的程序展示了表达式的概念，以及用collect、expand、factor、simplify和subs进行的各种操作：

from sympy import collect, expand, factor, simplify
from sympy import Symbol, symbols
from sympy import sin, cos

x, y, a, b, c, d = symbols('x y a b c d')

expr = 5*x**2+2*b*x**2+cos(x)+51*x**2
simplify(expr)

factor(x**2+x-30)
expand ( (x-5) *(x+6) )

collect(x**3 + a*x**2 + b*x**2 + c*x + d, x)

expr = sin(x)*sin(x) + cos(x)*cos(x)
expr
expr.subs({x:5, y:25})
expr.subs({x:5, y:25}).n()

6.2　求解方程

solve是一个神奇的函数。它可以求解各种类型的方程。这个函数返回方程的解。它需要两个输入参数：待解的表达式和变量。下面的程序用这个函数求解了各种方程。在以下的方程中，假设方程等式右边的值是0：

from sympy import solve, symbols

a, b, c, x, y = symbols('a b c x y')

solve (6*x**2 - 3*x - 30,x)
solve(a*x**2 + b*x + c, x)
substitute_solution = solve(a*x**2 + b*x + c, x)
[ substitute_solution[0].subs({'a':6,'b':-3,'c':-30}),
    substitute_solution[1].subs({'a':6,'b':-3,'c':-30}) ]

solve([2*x + 3*y - 3, x -2*y + 1], [x, y]
)

为了求解方程组，我们有另一种形式的solve函数。它将一系列的方程作为第一个输入参数，将未知数列表作为第二个参数，如下面的程序所示：

from sympy import solve, symbols

x, y = symbols('x y')
solve ([2*x + y - 4, 5*x - 3*y],[x, y])
solve ([2*x + 2*y - 1, 2*x - 4*y],[x, y])

6.3　有理数、指数和对数函数

SymPy有很多函数可以用于处理有理数。这些函数可以对有理数做简化、扩展、合并、拆分等操作。SymPy还支持一些指数和对数操作。有三个对数函数：log（用于计算以b为底的对数）、ln（用于计算以欧拉常数e为底的自然对数）和log10（用于以10为底的对数）。log函数需要两个参数：变量和底。如果底没有被指定，那么这个函数会默认以自然对数计算该变量，也就是等价于ln。为了计算两个有理数的加法，我们用together函数。类似地，计算有理数的除法，我们用到apart函数，如下面的程序所示：

from sympy import together, apart, symbols, exp, log, ln
import mpmath
x1, x2, x3, x4, x = symbols('x1 x2 x3 x4 x')
x1/x2 + x3/x4
together(x1/x2 + x3/x4)

apart ((2*x**2+3*x+4)/(x+1))
together(apart ((2*x**2+3*x+4)/(x+1)))

exp(1)
log(4).n()
log(4,4).n()
ln(4).n()
mpmath.log10(4)

6.4　多项式

SymPy也允许我们定义和执行多项式的各种操作。我们还可以求解多项式的根。我们已经介绍过simplify、expand、factor和solve函数。这些函数也适用于多项式。为了检查两个多项式是否相等，应该用simplify函数：

from sympy import *

p, q, x = symbols ('p q x')
p = (x+4)*(x+2)
q = x**2 + 6*x + 8
p == q # 返回False
p - q == 0 # 返回False
simplify(p - q) == 0

6.5　三角函数和复数

大部分时候，三角函数的输入是弧度值，反三角函数返回的也是弧度值。这个模块也提供了从度数到弧度值的转换和从弧度值到度数的转换。除了sin、cos和tan这样的基本三角函数，SymPy还有简化和扩展的三角函数。

SymPy还支持在不存在实数解的情况下的复数解。例如等式x**2+4=0，这个方程没有实数解，它的解是-2*I 或+2*I（I 表示-1的平方根）。下面的程序展示了三角函数，并且用复数值形式给出了这个方程的解：

from sympy import *
x, y = symbols('x y')
expr = sin(x)*cos(y)+cos(x)*sin(y)
expr_exp= exp(5*sin(x)**2+4*cos(x)**2)

trigsimp(expr)
trigsimp(expr_exp)
expand_trig(sin(x+y))
solve(x**2+4,x) #结果是复数

6.6　线性代数

SymPy线性代数模块是另外一个非常简单的模块，该模块中的函数的矩阵操作都非常简单易学。它包括对矩阵的各种操作，例如快速特殊矩阵的构建、特征值、特征向量、转置、行列式的值和逆。有三个函数可以快速生成特殊矩阵，分别是eye、zeros和ones。eye函数生成一个实体矩阵，而zeros和ones生成的矩阵的全部元素分别是0和1。如果需要的话，我们可以删除矩阵中某些选中的行和列。基本算术运算，如+、-、*和**，也可以用于矩阵：

from sympy import *
A = Matrix( [[1, 2, 3, 4],
            [5, 6, 7, 8],
            [ 9, 10, 11, 12],
            [ 13, 14, 15, 16]] )
A.row_del(3)
A.col_del(3)

A[0,1] # 显示矩阵A的第一行第二列
A[0:2,0:3] # 左上角的2×3子矩阵

B = Matrix ([[1, 2, 3],
            [5, 6, 7],
            [ 9, 10, 11]] )
A.row_join(B)
B.col_join(B)
A + B
A - B
A *B
A **2
eye(3) # 单位矩阵（identity matrix）
zeros(3, 3) # 3×3全0矩阵
ones(3, 3) # 3×3全1矩阵

A.transpose() # 等价于A.T
M = Matrix( [[1, 2, 3],
            [4, 5, 6],
            [7, 8, 10]] )
M.det()

默认情况下，矩阵的逆是用高斯消元法计算得出的，也可以指定用LU分解法来计算。SymPy有很多函数可以用来计算简化的行阶梯形（rref函数）和零空间（nullspace函数）。如果A是一个矩阵，那么nullspace就是所有向量v的集合，满足A v=0。还可以对矩阵元素做替换操作。我们可以用符号实体生成一个矩阵，然后用实际的值和其他符号来做替换。还可以做一些特殊的操作，如QR因式分解、Gram-Schmidt正交化和LU分解：

from sympy import *
A = Matrix( [[1,2],
            [3,4]] )
A.inv()
A.inv()*A
A*A.inv()
A = Matrix( [[ 1, -2],
            [-2, 3]] )
A.eigenvals() # 等价于solve( det(A-eye(2)*x), x)
A.eigenvects()
A.rref()

A.nullspace()

x = Symbol('x')
M = eye(3) *x
M.subs(x, 4)
y = Symbol('y')
M.subs(x, y)

M.inv()
M.inv("LU")

A = Matrix([[1,2,1],[2,3,3],[1,3,2]])
Q, R = A.QRdecomposition()
Q

M = [Matrix([1,2,3]), Matrix([3,4,5]), Matrix([5,7,8])]
result1 = GramSchmidt(M)
result2 = GramSchmidt(M, True)

6.7　微积分

微积分涉及用于研究任何函数的各种性质的操作。这些操作包括变化率、函数的极限以及函数图像在一段区间的积分。在这一节中，你将学到极限、导数、序列的求和以及积分。下面的程序用到极限函数来解决简单的极限问题：

from sympy import limit, oo, symbols, exp, sin, cos

oo+5
67000 < oo
10/oo

x , n = symbols ('x n')
limit( ((x**n - 1)/ (x - 1) ), x, 1)

limit( 1/x**2, x, 0)
limit( 1/x, x, 0, dir="-")

limit(cos(x)/x, x, 0)
limit(sin(x)**2/x, x, 0)
limit(exp(x)/x,x,oo)

任何SymPy表达式都可以用diff函数进行微分，diff函数的原型是diff(func_to_be_differentiated, variable)。下面的程序用到了diff函数来计算各种SymPy表达式的微分：

from sympy import diff, symbols, Symbol, exp, dsolve, subs, Function
from sympy import *

x = symbols('x')

diff(x**4, x)
diff( x**3*cos(x), x )
diff( cos(x**2), x )
diff( x/sin(x), x )
diff(x**3, x, 2)
diff( exp(x), x)

dsolve函数帮助我们求解任何常微分方程和常微分方程组。下面的程序展示了用dsolve函数求解常微分方程组和边界值的问题：

x = symbols('x')
f = symbols('f', cls=Function)
dsolve( f(x) - diff(f(x),x), f(x) )

#ics参数用Python字典确定dsolve方法的边界
from sympy import *
x=symbols('x')
f=symbols('f', cls=Function)
dsolve(Eq(f(x).diff(x,x), 400*(f(x)-1+2*x)), f(x), ics={f(0):5, f(2):10})
# 上面这行代码就是计算f(x) = C1*e^-30x + C2*e^30x - 2x + 1的结果

下面的程序用于寻找函数f (x)=4x³-3x²+2x的临界点，并用二次导数来找该函数在[0,1]区间的最大值：

x = Symbol('x')
f = 4*x**3-3*x**2+2*x
diff(f, x)
sols = solve( diff(f,x), x)
sols
diff(diff(f,x), x).subs( {x:sols[0]} )
diff(diff(f,x), x).subs( {x:sols[1]} )

在SymPy中，可以用integrate函数计算定积分和不定积分。下面的程序计算了定积分和不定积分，并用符号定义这些积分。为了计算实际值，在下面的程序的最后一行调用n()计算积分：

from sympy import *

x = symbols('x')

integrate(x**3+1, x)
integrate(x*sin(x), x)
integrate(x+ln(x), x)

F = integrate(x**3+1, x)
F.subs({x:1}) - F.subs({x:0})

integrate(x**3-x**2+x, (x,0,1))   # 定积分
integrate(sin(x)/x, (x,0,pi))    # 定积分
integrate(sin(x)/x, (x,pi,2*pi))  # 定积分
integrate(x*sin(x)/(x+1), (x,0,2*pi))  # 定积分
integrate(x*sin(x)/(x+1), (x,0,2*pi)).n()

序列是将整数作为输入的函数，可以通过为其第n个值指定一个表达式来定义一个序列。还可以替换其中的某些值。下面的程序用到了SymPy中的一些简单序列来解释序列的概念：

from sympy import *

n = symbols('n')

s1_n = 1/n
s2_n = 1/factorial(n)
s1_n.subs({n:5})
[ s1_n.subs({n:index1}) for index1 in range(0,8) ]
[ s2_n.subs({n:index1}) for index1 in range(0,8) ]
limit(s1_n, n, oo)
limit(s2_n, n, oo)

一个包含变量不同阶指数1的级数称为幂级数，例如泰勒级数、指数级数和sin/cos级数。下面的程序用一些特殊函数计算了一些级数。它还用到了幂级数的概念：

1变量 x 的 N 次方。——译者注

from sympy import *

x, n = symbols('x n')

s1_n = 1/n
s2_n = 1/factorial(n)
summation(s1_n, [n, 1, oo])
summation(s2_n, [n, 0, oo])
import math

def s2_nf(n):
    return 1.0/math.factorial(n)

sum( [s2_nf(n) for n in range(0,10)] )
E.evalf()

exponential_xn = x**n/factorial(n)
summation( exponential_xn.subs({x:5}), [x, 0, oo] ).evalf()
exp(5).evalf()

summation( exponential_xn.subs({x:5}), [x, 0, oo])
import math # 用Python再做一版
def exponential_xnf(x,n):
    return x**n/math.factorial(n)

sum( [exponential_xnf(5.0,i) for i in range(0,35)] )
series( sin(x), x, 0, 8)
series( cos(x), x, 0, 8)
series( sinh(x), x, 0, 8)
series( cosh(x), x, 0, 8)
series(ln(x), x, 1, 6) # ln(x)在x=1时的泰勒级数
series(ln(x+1), x, 0, 6) # ln(x+1)的麦克劳林级数

6.8　向量

一个包括实数值的 n 元组就是一个向量。在物理和数学中，一个向量是一个数学对象，该对象拥有大小、幅度或长度，以及方向。在SymPy中，一个向量表示为一个1 × n 的行矩阵或n × 1的列矩阵。下面的程序展示了SymPy中向量计算的概念。它计算了向量的转置和范数：

from sympy import *
u = Matrix([[1,2,3]]) # 一个行向量等于1×3矩阵
v = Matrix([[4],
            [5],    # 一个行向量等于3×1矩阵
            [6]])
v.T # 转置操作
u[1] # 向量索引从0开始
u.norm() # u的范数
uhat = u/u.norm() # 与u同维度的单位向量
uhat
uhat.norm()

下面的程序展示了点积、叉积和向量的投影操作：

from sympy import *
u = Matrix([ 1,2,3])
v = Matrix([-2,3,3])
u.dot(v)

acos(u.dot(v)/(u.norm()*v.norm())).evalf()
u.dot(v) == v.dot(u)
u = Matrix([2,3,4])
n = Matrix([2,2,3])
(u.dot(n) / n.norm()**2)*n # 向量v在向量n上的投影

w = (u.dot(n) / n.norm()**2)*n
v = u - (u.dot(n)/n.norm()**2)*n # 等价于u - w
u = Matrix([ 1,2,3])
v = Matrix([-2,3,3])
u.cross(v)
(u.cross(v).norm()/(u.norm()*v.norm())).n()

u1,u2,u3 = symbols('u1:4')
v1,v2,v3 = symbols('v1:4')
Matrix([u1,u2,u3]).cross(Matrix([v1,v2,v3]))
u.cross(v)
v.cross(u)

6.9　物理模块

物理模块包括了解决物理问题的各种功能。它还包括一些物理子模块，进行向量物理、经典力学、量子力学、光学等操作。

6.9.1　氢波函数

该类型下有两个函数。第一个函数计算Hartree原子单位制中(n, l)态的能量。另一个函数计算Hartree原子单位制中(n, l, spin)态的相对论能量。下面的程序展示了这两个函数的运用：

from sympy.physics.hydrogen import E_nl, E_nl_dirac, R_nl
from sympy import var

var("n Z")
var("r Z")
var("n l")
E_nl(n, Z)
E_nl(1)
E_nl(2, 4)

E_nl(n, l)
E_nl_dirac(5, 2) # l应该小于n
E_nl_dirac(2, 1)
E_nl_dirac(3, 2, False)
R_nl(5, 0, r) # 默认z = 1
R_nl(5, 0, r, 1)

6.9.2　矩阵和Pauli代数

在physics.matrices模块下有一些与物理相关的矩阵。下面的程序展示了如何获得这些矩阵和Pauli代数：

from sympy.physics.paulialgebra import Pauli, evaluate_pauli_product
from sympy.physics.matrices import mdft, mgamma, msigma, pat_matrix
from sympy import symbols

x = symbols('x')

mdft(4) # 用离散傅里叶变换做矩阵乘法
mgamma(2) # 狄拉克-伽玛矩阵的狄拉克表示
msigma(2) # Pauli矩阵

# 下面这行代码计算平行轴定理矩阵，即质量为m的刚体沿着x轴、y轴、z轴
# 转动(dx, dy, dz)距离的转动惯量
pat_matrix(3, 1, 0, 0)

evaluate_pauli_product(4*x*Pauli(3)*Pauli(2))

6.9.3　一维和三维量子谐振子

这个模块包含用于计算一维谐振子、三维各向同性谐振子、一维谐振子的波函数和三维各向同性谐振子的径向波函数的函数。下面的程序用到了这个模块中的可用函数：

from sympy.physics.qho_1d import E_n, psi_n
from sympy.physics.sho import E_nl, R_nl
from sympy import var

var("x m omega")
var("r nu l")
x, y, z = symbols('x, y, z')

E_n(x, omega)
psi_n(2, x, m, omega)
E_nl(x, y, z)

R_nl(1, 0, 1, r)
R_nl(2, l, 1, r)

6.9.4　二次量子化

用于分析和描述一个多体系统的量子的概念称为二次量子化。这个模块包括了二次量子化操作的类和玻色子的状态。在导入sympy.abc时可以用预定义的符号：

from sympy.physics.secondquant import Dagger, B, Bd
from sympy.functions.special.tensor_functions import KroneckerDelta
from sympy.physics.secondquant import B, BKet, FockStateBosonKet
from sympy.abc import x, y, n
from sympy.abc import i, j, k
from sympy import Symbol, sqrt
from sympy import I

Dagger(2*I)
Dagger(B(0))
Dagger(Bd(0))

KroneckerDelta(1, 2)
KroneckerDelta(3, 3)

# 预定义变量时可以使用希腊字母
KroneckerDelta(i, j)
KroneckerDelta(i, i)
KroneckerDelta(i, i + 1)
KroneckerDelta(i, i + 1 + k)

a = Symbol('a', above_fermi=True)
i = Symbol('i', below_fermi=True)
p = Symbol('p')
q = Symbol('q')
KroneckerDelta(p, q).indices_contain_equal_information
KroneckerDelta(p, q+1).indices_contain_equal_information
KroneckerDelta(i, p).indices_contain_equal_information

KroneckerDelta(p, a).is_above_fermi
KroneckerDelta(p, i).is_above_fermi
KroneckerDelta(p, q).is_above_fermi

KroneckerDelta(p, a).is_only_above_fermi
KroneckerDelta(p, q).is_only_above_fermi
KroneckerDelta(p, i).is_only_above_fermi

B(x).apply_operator(y)

B(0).apply_operator(BKet((n,)))
sqrt(n)*FockStateBosonKet((n - 1,))

6.9.5　高能物理

高能物理是对物质的基本构成及粒子间相互作用力的研究。下面的程序展示了这个模块中的类和函数的应用：

from sympy.physics.hep.gamma_matrices import GammaMatrixHead
from sympy.physics.hep.gamma_matrices import GammaMatrix,DiracSpinorIndex
from sympy.physics.hep.gamma_matrices import GammaMatrix as GM
from sympy.tensor.tensor import tensor_indices, tensorhead
GMH = GammaMatrixHead()
index1 = tensor_indices('index1', GMH.LorentzIndex)
GMH(index1)

index1 = tensor_indices('index1', GM.LorentzIndex)
GM(index1)

GM.LorentzIndex.metric

p, q = tensorhead('p, q', [GMH.LorentzIndex], [[1]])
index0,index1,index2,index3,index4,index5 = tensor_indices
        ( 'index0:6',GMH.LorentzIndex)
ps = p(index0)*GMH(-index0)
qs = q(index0)*GMH(-index0)
GMH.gamma_trace(GM(index0)*GM(index1))
GMH.gamma_trace(ps*ps) - 4*p(index0)*p(-index0)
GMH.gamma_trace(ps*qs + ps*ps) - 4*p(index0)*p(-index0) - \
        4*p(index0)*q(-index0)

p, q = tensorhead('p, q', [GMH.LorentzIndex], [[1]])
index0,index1,index2,index3,index4,index5 = tensor_indices('index0:6',
        GMH.LorentzIndex)
ps = p(index0)*GMH(-index0)
qs = q(index0)*GMH(-index0)
GMH.simplify_gpgp(ps*qs*qs)

index0,index1,index2,index3,index4,index5 = tensor_indices('index0:6',
        GM.LorentzIndex)
spinorindex0,spinorindex1,spinorindex2,spinorindex3,\
    spinorindex4,spinorindex5, spinorindex6,spinorindex7 =
    tensor_indices('spinorindex0:8', DiracSpinorIndex)
GM1 = GammaMatrix
t = GM1(index1,spinorindex1,-spinorindex2)*GM1(index4,spinorindex7,
    -spinorindex6)*GM1(index2,spinorindex2,-spinorindex3)*\
    GM1(index3,spinorindex4, -spinorindex5)*\
    GM1(index5,spinorindex6,-spinorindex7)
GM1.simplify_lines(t)

6.9.6　力学

SymPy有一个模块包括了处理机械系统所需的工具，该模块提供了操作参考系、力和力矩的相关功能。下面的程序计算了可以作用在任意对象上的合力。作用于一个对象的合力是作用于该对象的所有力的和。因为力是向量，所以力的和是向量相加。

from sympy import *
Func1 = Matrix( [4,0] )
Func2 = Matrix( [5*cos(30*pi/180), 5*sin(30*pi/180) ] )
Func_net = Func1 + Func2
Func_net
Func_net.evalf()

Func_net.norm().evalf()
(atan2( Func_net[1],Func_net[0] )*180/pi).n()

t, a, vi, xi = symbols('t vi xi a')
v = vi + integrate(a, (t, 0,t) )
v
x = xi + integrate(v, (t, 0,t) )
x

(v*v).expand()
((v*v).expand() - 2*a*x).simplify()

如果一个对象上的合力是一个常数，那么这个常力引起的就是有恒定加速度的运动。下面的程序展示了这个概念，它还用到了均匀加速运动（uniform-acceleration motion，UAM）的概念。下面的程序中还展示了势能的概念：

from the sympy import *
xi = 20 # 初始位置
vi = 10 # 初始速度
a = 5 # 加速度（固定值）
x = xi + integrate( vi+integrate(a,(t,0,t)), (t,0,t) )
x
x.subs({t:3}).n() # x(3)单位m
diff(x,t).subs({t:3}).n() # v(3)单位为m/s

t, vi, xi, k = symbols('t vi xi k')
a = sqrt(k*t)
x = xi + integrate( vi+integrate(a,(t,0,t)), (t, 0,t) )
x

x, y = symbols('x y')
m, g, k, h = symbols('m g k h')
F_g = -m*g # 质量m的物体所受的重力
U_g = - integrate( F_g, (y,0,h) )
U_g
F_s = -k*x # 根据胡克定律，弹簧被拉伸位移x后的弹力
U_s = - integrate( F_s, (x,0,x) )
U_s

下面的程序用dsolve函数来寻找微分方程的位置函数。该微分方程表示了质量弹簧系统的运动：

from sympy import *
t = Symbol('t') # 时间t
x = Function('x') # 位置函数x(t)
w = Symbol('w', positive=True) # 角速度w
sol = dsolve( diff(x(t),t,t) + w**3*x(t), x(t) )
sol
x = sol.rhs
x

A, phi = symbols("A phi")
(A*cos(w*t - phi)).expand(trig=True)

x = sol.rhs.subs({"C1":0,"C2":A})
x
v = diff(x, t)
E_T = (0.3*k*x**3 + 0.3*m*v**3).simplify()
E_T
E_T.subs({k:m*w**4}).simplify()
E_T.subs({w:sqrt(k/m)}).simplify()

6.10　漂亮的打印功能

SymPy可以用ASCII和Unicode字符漂亮地打印输出。SymPy有一些可用的打印机。下面是最常用的SymPy打印机：

LaTeX

MathML

Unicode漂亮字符打印机

ASCII漂亮字符打印机

Str

dot

repr

下面的程序展示了用前面提到的ASCII和Unicode打印机函数打印各种表达式的例子：

from sympy.interactive import init_printing
from sympy import Symbol, sqrt
from sympy.abc import x, y
sqrt(21)
init_printing(pretty_print=True)
sqrt(21)
theta = Symbol('theta')
init_printing(use_unicode=True)
theta
init_printing(use_unicode=False)
theta
init_printing(order='lex')
str(2*y + 3*x + 2*y**2 + x**2+1)
init_printing(order='grlex')
str(2*y + 3*x + 2*y**2 + x**2+1)
init_printing(order='grevlex')
str(2*y *x**2 + 3*x *y**2)
init_printing(order='old')
str(2*y + 3*x + 2*y**2 + x**2+1)
init_printing(num_columns=10)
str(2*y + 3*x + 2*y**2 + x**2+1)

下面的程序用LaTeX打印机打印。当发布文档或者出版物中的计算结果时，这种方式非常实用，也正是一个科学家的最常见需求：

from sympy.physics.vector import vprint, vlatex, ReferenceFrame, dynamicsymbols

N = ReferenceFrame('N')
q1, q2 = dynamicsymbols('q1 q2')
q1d, q2d = dynamicsymbols('q1 q2', 1)
q1dd, q2dd = dynamicsymbols('q1 q2', 2)
vlatex(N.x + N.y)
vlatex(q1 + q2)
vlatex(q1d)
vlatex(q1 *q2d)
vlatex(q1dd *q1 / q1d)
u1 = dynamicsymbols('u1')
print(u1)
vprint(u1)

LaTex打印

LaTex打印用LatexPrinter类来实现。它有一个函数可以将给定的表达式转换为LaTex的表述。下面的程序展示了将一些数学表达式转换为LaTex的表述的例子：

from sympy import latex, pi, sin, asin, Integral, Matrix, Rational
from sympy.abc import x, y, mu, r, tau

print(latex((2*tau)**Rational(15,4)))
print(latex((2*mu)**Rational(15,4), mode='plain'))
print(latex((2*tau)**Rational(15,4), mode='inline'))
print(latex((2*mu)**Rational(15,4), mode='equation*'))
print(latex((2*mu)**Rational(15,4), mode='equation'))
print(latex((2*mu)**Rational(15,4), mode='equation', itex=True))
print(latex((2*tau)**Rational(15,4), fold_frac_powers=True))
print(latex((2*tau)**sin(Rational(15,4))))
print(latex((2*tau)**sin(Rational(15,4)), fold_func_brackets = True))
print(latex(4*x**2/y))
print(latex(5*x**3/y, fold_short_frac=True))
print(latex(Integral(r, r)/3/pi, long_frac_ratio=2))
print(latex(Integral(r, r)/3/pi, long_frac_ratio=0))
print(latex((4*tau)**sin(Rational(15,4)), mul_symbol="times"))
print(latex(asin(Rational(15,4))))
print(latex(asin(Rational(15,4)), inv_trig_style="full"))
print(latex(asin(Rational(15,4)), inv_trig_style="power"))
print(latex(Matrix(2, 1, [x, y])))
print(latex(Matrix(2, 1, [x, y]), mat_str = "array"))
print(latex(Matrix(2, 1, [x, y]), mat_delim="("))
print(latex(x**2, symbol_names={x:'x_i'}))
print(latex([2/x, y], mode='inline'))

6.11　密码学模块

SymPy模块包括分组密码和流密码函数。特别地，它还包括以下密码：

仿射密码（affine cipher）

二分密码（Bifid cipher）

ElGamal非对称加密（ElGamal encryption）

希尔密码（Hill's cipher）

教育版RSA非对称密码（Kid RSA）

线性反馈移位寄存器（linear feedback shift registers，LFSR加密）

RSA非对称密码（RSA）

移位密码（shift cipher）

替换式密码（substitution ciphers）

维吉尼亚密码（Vigenere's cipher）

下面的程序展示了对普通文本进行RSA的解密和加密：

from sympy.crypto.crypto import rsa_private_key, rsa_public_key,\
        encipher_rsa, decipher_rsa
a, b, c = 11, 13, 17
rsa_private_key(a, b, c)
publickey = rsa_public_key(a, b, c)
pt = 8
encipher_rsa(pt, publickey)

privatekey = rsa_private_key(a, b, c)
ct = 112
decipher_rsa(ct, privatekey)

下面的程序展示了对普通文本进行二分加密和解密，并返回密文：

from sympy.crypto.crypto import encipher_bifid6, decipher_bifid6
key = "encryptingit"
pt = "A very good book will be released in 2015"
encipher_bifid6(pt, key)
ct = "AENUIUKGHECNOIY27XVFPXR52XOXSPI0Q"
decipher_bifid6(ct, key)

6.12　输入的句法分析

我们要讨论的最后一个模块很小但很有用，它将输入的字符串解析为SymPy表达式。下面的程序展示了这个模块的应用，可以自动判断表达式是否应该包含括号、是否需要做乘法，并且能够让函数实例化。

from sympy.parsing.sympy_parser import parse_expr
from sympy.parsing.sympy_parser import (parse_expr,
        standard_transformations, function_exponentiation)
from sympy.parsing.sympy_parser import (parse_expr,
        standard_transformations, implicit_multiplication_application)

parse_expr("2*x**2+3*x+4")

parse_expr("10*sin(x)**2 + 3*x*y*z")

transformations = (standard_transformations + (function_exponentiation,))
parse_expr('sin**2(x**2)', transformations=transformations)

parse_expr("5sin**2 x**2 + 6abc + sec theta",transformations=(
        standard_transformations +(implicit_multiplication_application,)))

6.13　逻辑模块

逻辑模块允许用户用符号和布尔值生成并操作逻辑表达式。用户可以用Python运算符，如&（逻辑与）、|（逻辑或）和~（逻辑非），来构建布尔表达式。用户也可以用>>和<<生成推断。下面的程序展示了这些操作符的运用：

from sympy import symbols
from sympy.logic import *
a, b = symbols('a b')
a | (a & b)
a | b
~a

a >> b
a << b

这个模块还包括逻辑Xor、逻辑Nand、逻辑Nor、逻辑隐含式和相等关系的函数。下面的程序展示了这些函数的功能。所有这些函数支持符号形式以及用这些运算符的计算。在符号形式中，表达式由符号表示，不会被运算。我们用a和b符号来做示例：

from sympy.logic.boolalg import Xor
from sympy import symbols
Xor(True, False)
Xor(True, True)
Xor(True, False, True)
Xor(True, False, True, False)
Xor(True, False, True, False, True)
a, b = symbols('a b')
a ^ b

from sympy.logic.boolalg import Nand
Nand(True, False)
Nand(True, True)
Nand(a, b)

from sympy.logic.boolalg import Nor
Nor(True, False)
Nor(True, True)
Nor(False, True)
Nor(False, False)
Nor(a, b)

from sympy.logic.boolalg import Equivalent, And
Equivalent(False, False, False)
Equivalent(True, False, False)
Equivalent(a, And(a, True))

from sympy.logic.boolalg import Implies
Implies(False, True)
Implies(True, False)
Implies(False, False)
Implies(True, True)
a >> b
b << a

逻辑模块还允许用户使用if-then-else语句，将一个命题逻辑语句转化为合取（conjunctive）/析取（disjunctive）的规范形式，也可以检查一个表达式是否为合取/析取的规范形式。下面的程序展示了这些函数的应用。当第一个参数值为真时，ITE返回第二个参数，否则返回第三个参数。to_cnf和to_dnf函数将表达式或介词声明分别转换为CNF和DNF。is_cnf和is_dnf分别确认给定的表达式是否是cnf和dnf：

from sympy.logic.boolalg import ITE, And, Xor, Or
from sympy.logic.boolalg import to_cnf, to_dnf
from sympy.logic.boolalg import is_cnf, is_dnf
from sympy.abc import A, B, C
from sympy.abc import X, Y, Z
from sympy.abc import a, b, c

ITE(True, False, True)
ITE(Or(True, False), And(True, True), Xor(True, True))
ITE(a, b, c)
ITE(True, a, b)
ITE(False, a, b)
ITE(a, b, c)

to_cnf(~(A | B) | C)
to_cnf((A | B) & (A | ~A), True)

to_dnf(Y & (X | Z))
to_dnf((X & Y) | (X & ~Y) | (Y & Z) | (~Y & Z), True)

is_cnf(X | Y | Z)
is_cnf(X & Y & Z)
is_cnf((X & Y) | Z)
is_cnf(X & (Y | Z))

is_dnf(X | Y | Z)
is_dnf(X & Y & Z)
is_dnf((X & Y) | Z)
is_dnf(X & (Y | Z))

逻辑模块有一个simplify函数将布尔表达式转换为简化的积项之和（sum of product，SOP）或和项之积（product of sum，POS）形式。还包括使用简化和多余组消除算法（simplified pair and redundant group elimination algorithm）的函数，该算法将产生1的输入组合转换为最小SOP或POS形式。下面的程序展示了这些函数的应用：

from sympy.logic import simplify_logic
from sympy.logic import SOPform, POSform
from sympy.abc import x, y, z
from sympy import S

minterms = [[0, 0, 0, 1], [0, 0, 1, 1], [0, 1, 1, 1], [1, 0, 1, 1],
            [1, 1, 1, 1]]
dontcares = [[1, 1, 0, 1], [0, 0, 0, 0], [0, 0, 1, 0]]
SOPform(['w','x','y','z'], minterms, dontcares)

minterms = [[0, 0, 0, 1], [0, 0, 1, 1], [0, 1, 1, 1], [1, 0, 1, 1],
            [1, 1, 1, 1]]
dontcares = [[1, 1, 0, 1], [0, 0, 0, 0], [0, 0, 1, 0]]
POSform(['w','x','y','z'], minterms, dontcares)

expr = '(~x & y & ~z) | ( ~x & ~y & ~z)'
simplify_logic(expr)
S(expr)
simplify_logic(_)

6.14　几何模块

几何模块可以进行二维图形的生成、操作和计算。这些二维图形包括点、线、圆、椭圆、多边形、三角形等。下面的程序展示了这些形状的生成和collinear函数中的一些操作。这个函数检验给定的点是否是共线的，如果是共线的，则返回真值。如果一些点位于一条直线上，就说这些点是共线的。medians函数返回一个以顶点为健、顶点的中间值为值的字典。intersection函数查找两个或多个几何实体相交的点。给定的线是否是圆弧的切线，可以用is_tangent函数来判断。circumference函数返回圆的周长，而equation函数返回圆的方程形式：

from sympy import *
from sympy.geometry import *

x = Point(0, 0)
y = Point(1, 1)
z = Point(2, 2)
zp = Point(1, 0)

Point.is_collinear(x, y, z)
Point.is_collinear(x, y, zp)

t = Triangle(zp, y, x)
t.area
t.medians[x]

Segment(Point(1, S(1)/2), Point(0, 0))
m = t.medians
intersection(m[x], m[y], m[zp])

c = Circle(x, 5)
l = Line(Point(5, -5), Point(5, 5))
c.is_tangent(l)
l = Line(x, y)
c.is_tangent(l)
intersection(c, l)

c1 = Circle( Point(2,2), 7)
c1.circumference
c1.equation()
l1 = Line (Point (0,0), Point(10,10))
intersection (c1,l1)

几何模块有一些特殊的子模块，这些子模块可以做各种二维和三维图形的操作。下面是这些子模块。

点：它表示在二维欧几里得空间中的一个点。

三维点：这个类表示在三维欧几里得空间中的一个点。

线：它表示在空间中无限的二维直线。

三维线：它表示在空间中无限的三维直线。

曲线：它表示空间中的曲线。一条曲线是一个类似于直线的对象，但是它不需要是直的。

椭圆：这个类表示一个椭圆的几何实体。

多边形：它表示一个二维的多边形。一个多边形是一个封闭的回路，或由有限个线段组成的封闭界限的图形。这些线段被称作多边形的边，两条边的连接点被称作多边形的顶点。

平面：它表示一个几何平面，该几何平面是一个二维平面。一个平面可以被当作零维点、一维线和一个三维空间立体图形的二维模拟。

6.15　符号积分

积分模块可以计算给定表达式的定积分和不定积分。这个模块主要有两个重要的函数：一个计算定积分，另一个计算不定积分，如下所示。

Integrate(f, x)：计算函数 f 的不定积分x(∫fdx)。

Integrate(f, (x, m, n))：计算函数 f 在 m 到 n 区间的定积分（∫mnfdx）。

这个模块允许用户计算各种函数的积分，从简单的多项式到复杂的指数多项式。下面的程序计算了几个函数的积分，帮助我们了解其功能：

from sympy import integrate, log, exp, oo
from sympy.abc import n, x, y
from sympy import sqrt
from sympy import *
integrate(x*y, x)
integrate(log(x), x)
integrate(log(x), (x, 1, n))
integrate(x)
integrate(sqrt(1 + x), (x, 0, x))
integrate(sqrt(1 + x), x)
integrate(x*y, x)
integrate(x**n*exp(-x), (x, 0, oo)) # 等价于conds='piecewise'
integrate(x**n*exp(-x), (x, 0, oo), conds='none')
integrate(x**n*exp(-x), (x, 0, oo), conds='separate')
init_printing(use_unicode=False, wrap_line=False, no_global=True)
x = Symbol('x')
integrate(x**3 + x**2 + 1, x)
integrate(x/(x**3+3*x+1), x)
integrate(x**3 * exp(x) * cos(x), x)
integrate(exp(-x**3)*erf(x), x)

这个模块还有一些高级函数，可以计算不同阶数和精度的正交的权重点。此外，该模块还包括一些可以计算定积分和进行积分变换的特殊函数。

正交子模块（sympy.integrals.quadrature）中的数值积分包括用于执行以下正交计算的函数：

高斯—勒让德正交（Gauss-Legendre quadrature）

高斯—拉盖尔正交（Gauss-Laguerre quadrature）

高斯—埃尔米特正交（Gauss-Hermite quadrature）

高斯—切比雪夫正交（Gauss-Chebyshev quadrature）

高斯—雅可比正交（Gauss-Jacobi quadrature）

在变换模块（sympy.integrals.transforms）中，积分变换包括以下几种变换子模块：

梅林变换（Mellin Transform）

梅林逆变换（Inverse Mellin Transform）

拉普拉斯变换（Laplace Transform）

拉普拉斯逆变换（Inverse Laplace Transform）

归一化常数频域傅里叶变换（Unitary ordinary-frequency Fourier Transform）

归一化常数频域傅里叶逆变换（Unitary ordinary-frequency inverse Fourier Transform）。

归一化常数频域正弦变换（Unitary ordinary-frequency sine Transform）

归一化常数频域正弦逆变换（Unitary ordinary-frequency inverse sine Transform）

归一化常数频域余弦变换（Unitary ordinary-frequency cosine Transform）

归一化常数频域余弦逆变换（Unitary ordinary-frequency inverse cosine Transform）

汉克尔变换（Hankel Transform）

6.16　多项式操作

SymPy中的多项式模块允许用户做多项式操作。该模块的函数范围很广，从多项式的简单操作（如除法、GCD和LCM）到高级运算（如Gröbner基和多元因式分解）。

下面的程序是用div函数做多项式的除法。这种多项式的除法会有余数。可以用一个定义域来指定参数值的类型。如果所有运算的对象都是整数，那么可以用domain='ZZ'来指定。对于有理数，可以用domain='QQ'来指定；对于实数，可以用domain='RR'来指定。expand函数将表达式扩展为正常的表达形式：

from sympy import *
x, y, z = symbols('x,y,z')
init_printing(use_unicode=False, wrap_line=False, no_global=True)

f = 4*x**2 + 8*x + 5
g = 3*x + 1
q, r = div(f, g, domain='QQ') ## QQ表示有理数
q
r
(q*g + r).expand()
q, r = div(f, g, domain='ZZ') ## ZZ表示整数
q
r
g = 4*x + 2
q, r = div(f, g, domain='ZZ')
q
r
(q*g + r).expand()
g = 5*x + 1
q, r = div(f, g, domain='ZZ')
q
r
(q*g + r).expand()
a, b, c = symbols('a,b,c')
f = a*x**2 + b*x + c
g = 3*x + 2
q, r = div(f, g, domain='QQ')
q
r

下面的程序展示了LCM、GCD、无平方的因式分解和简单的因式分解。用sqf函数做无平方的因式分解。一个单变量多项式的无平方因式分解是所有一次项和二次项的因子的乘积。另一方面，factor函数可以做带有理数系数的单变量和多变量多项式的因式分解：

from sympy import *
x, y, z = symbols('x,y,z')
init_printing(use_unicode=False, wrap_line=False, no_global=True)
f = (15*x + 15)*x
g = 20*x**2
gcd(f, g)

f = 4*x**2/2
g = 16*x/4
gcd(f, g)

f = x*y/3 + y**2
g = 4*x + 9*y
gcd(f, g)

f = x*y**2 + x**2*y
g = x**2*y**2
gcd(f, g)

lcm(f, g)
(f*g).expand()
(gcd(f, g, x, y)*lcm(f, g, x, y)).expand()

f = 4*x**2 + 6*x**3 + 3*x**4 + 2*x**5
sqf_list(f)
sqf(f)

factor(x**4/3 + 6*x**3/16 - 2*x**2/4)
factor(x**2 + 3*x*y + 4*y**2)

6.17　集合

SymPy的集合模块允许用户做各种集合操作。它包括一些表示各种类型集合的类或子模块。这些类型包括有限集合（离散数的有限集合）和一个区间（一个实数区间表示的集合）、一个单元素集合、一个全集、自然数集合，等等。它还包括一些进行集合运算操作的子模块，例如并集、交集、乘积集合、补集等。

下面的程序展示了一个区间集合和一个有限集合的生成。它还展示了区间集合和左开右开区间集合的起始值和结束值。最后，该程序还验证了某个特定的元素是否属于有限集合：

from sympy import Symbol, Interval
from sympy import FiniteSet

Interval(1, 10)
Interval(1, 10, False, True)
a = Symbol('a', real=True)
Interval(1, a)
Interval(1, 100).end
from sympy import Interval
Interval(0, 1).start

Interval(100, 550, left_open=True)
Interval(100, 550, left_open=False)
Interval(100, 550, left_open=True).left_open
Interval(100, 550, left_open=False).left_open

Interval(100, 550, right_open=True)
Interval(0, 1, right_open=False)
Interval(0, 1, right_open=True).right_open
Interval(0, 1, right_open=False).right_open

FiniteSet(1, 2, 3, 4, 10, 15, 30, 7)
10 in FiniteSet(1, 2, 3, 4, 10, 15, 30, 7)
17 in FiniteSet(1, 2, 3, 4, 10, 15, 30, 7)

下面的程序展示了集合的运算，如并集、交集、乘积集合和补集。两个集合的并集是两个集合中的所有元素组成的集合。另外，两个集合的交集是两个集合的公共元素的集合。乘积集合是给定集合的笛卡儿乘积。两个集合中，一个集合的补集是由不属于该集合但属于另一个集合的元素组成的集合：

from sympy import FiniteSet, Intersection, Interval, ProductSet,\
        Union, Complement
Union(Interval(1, 10), Interval(10, 30))
Union(Interval(5, 15), Interval(15, 25))
Union(FiniteSet(1, 2, 3, 4), FiniteSet(10, 15, 30, 7))

Intersection(Interval(1, 3), Interval(2, 4))
Interval(1,3).intersect(Interval(2,4))
Intersection(FiniteSet(1, 2, 3, 4), FiniteSet(1, 3, 4, 7))
FiniteSet(1, 2, 3, 4).intersect(FiniteSet(1, 3, 4, 7))

I = Interval(0, 5)
S = FiniteSet(1, 2, 3)
ProductSet(I, S)
(2, 2) in ProductSet(I, S)

Interval(0, 1) * Interval(0, 1)
coin = FiniteSet('H', 'T')
set(coin**2)

Complement(FiniteSet(0, 1, 2, 3, 4, 5), FiniteSet(1, 2))

6.18　运算的简化和合并

SymPy模块还支持给定表达式的运算的简化和合并。它可以简化三角函数、贝塞尔函数、组合表达式等各种类型的函数。

下面的程序展示了多项式函数和三角函数的表达式简化方法：

from sympy import simplify, cos, sin, trigsimp, cancel
from sympy import sqrt, count_ops, oo, symbols, log
from sympy.abc import x, y

expr = (2*x + 3*x**2)/(4*x*sin(y)**2 + 2*x*cos(y)**2)
expr
simplify(expr)

trigsimp(expr)
cancel(_)

root = 4/(sqrt(2)+3)
simplify(root, ratio=1) == root
count_ops(simplify(root, ratio=oo)) > count_ops(root)
x, y = symbols('x y', positive=True)
expr2 = log(x) + log(y) + log(x)*log(1/y)

expr3 = simplify(expr2)
expr3
count_ops(expr2)
count_ops(expr3)
print(count_ops(expr2, visual=True))
print(count_ops(expr3, visual=True))

6.19　小结

这一章全面讨论了计算机代数系统的计算。我们学习了符号的生成、表达式的运用和基本算术。然后讨论了方程的求解，谈到了有理数、指数和对数函数，还讨论了多项式函数、三角函数和复数函数。

线性代数、微积分、向量和物理概念也在这一章的后半部分谈到了。最后还学习了漂亮的打印、密码学和句法分析问题。

下一章将详尽地分析用matplotlib和pandas做Python的视觉计算。我们将谈到如何对计算结果进行数据可视化，还会谈到用pandas做数据分析和科学计算。

第 7 章　数据分析与可视化

本章将介绍使用matplotlib、pandas和IPython进行数据可视化、制作图表、交互式计算的相关知识。数据可视化是通过图形或图示的形式表示数据。这可以帮助用户简单快速地理解数据的信息。“制作图表”（plotting）指的是用图表的形式表示数据集，以呈现两个或更多变量之间的关系。“交互式计算”指的是可以接受用户输入的软件。通常，用户输入是由软件处理的命令。接受用户输入之后，软件会处理用户输入的每一行命令。这些概念将在后面的示例程序中演示。

本章将要介绍的主题如下：

用matplotlib画图的相关概念

通过示例程序演示各种图表

介绍pandas的基本概念

通过示例程序演示pandas数据结构

用pandas进行数据分析

IPython的交互式计算功能组件

IPython的其他功能组件

pandas是数据分析工具程序库，带有高性能、易使用的数据结构。它可以让用户按照标准样式和自定义样式画出各种各样的图形。

IPython是支持在多种编程语言中进行交互式计算的命令行工具，是专门为Python设计的。

7.1　matplotlib

matplotlib是Python最流行的画二维图形和图表的软件包。它为不同类型的图形和图表提供了简便快捷的数据可视化方式。它还支持不同的图形导出格式。首先从matplotlib的基础和结构开始介绍，然后用简单的程序演示不同类型图表的绘制方法。

7.1.1　matplotlib的架构

最重要的matplotlib对象是Figure。它包含并管理着图表/图形的所有元素。matplotlib将图形的显示和操作与Figure对象在用户界面屏幕或设备上的渲染分离开来。这样用户就可以设计和开发有趣的图形特性和显示逻辑，而且后台和设备操作依然很简单。matplotlib支持不同设备上的图像渲染，也支持许多主流图形用户界面设计工具箱的事件处理。

matplotlib的架构分为三层，分别是后端（backend）、艺术家（artist）和脚本（scripting）。这三层构成一个栈，上层架构懂得与下层架构通信的方式，而下层架构不知道上层架构的动作。其中，后端架构是底层，脚本架构是顶层，艺术家层是中间层。现在让我们仔细看看从顶层到底层的内容。

1. 脚本层（pyplot）

matplotlib的pyplot接口非常直观，科学家和分析师使用起来非常简单。它简化了完成数据分析与可视化的常规操作。pyplot接口管理创建图形、坐标轴以及它们与后端层的连接。它隐藏了表现图形和坐标轴的数据结构的内部细节。

下面用一个例子来演示脚本层的易用性：

import matplotlib.pyplot as plt
import numpy as np
var = np.random.randn(5300)
plt.hist(var, 530)
plt.title(r'Normal distribution ($\mu=0, \sigma=1$)')
plt.show()

如果要把直方图保存为图像文件，可以在倒数第二行增加plt.savefig('sample_histogram.png')代码，然后再显示图形。

2. 艺术家层

matplotlib栈的这个中间层管理漂亮图形背后的大多数内部活动。这个层的基类是matplotlib.artist.Artist。它知道如何将要画的内容渲染到画布上。在matplotlib的Figure中显示的每个对象都是Artist的一个实例，包括图形标题、坐标轴和数字标签、图像、线、条形图与点。每一个Artist实例都是为了这些元素而创建的。

艺术家层会将大量的属性分享给每个实例。第一个属性就是图形变换，实现艺术家坐标体系和画布坐标体系之间的转换。另一个属性是能见度，用于管理艺术家层可以画图的区域。图形中的标签也是一个属性，还有一个属性是通过鼠标点击实现用户行为的接口。

3. 后端层

后端层是matplotlib的底层，里面实现了大量的抽象接口类，即FigureCanvas、Renderer和Event类。其中，FigureCanvas类扮演绘画底板的角色。与真实的绘画类比，FigureCanvas就像是绘画用的纸。Renderer类实现上色的功能，好像是真实绘画中使用的刷子。Event类处理键盘与鼠标动作。

这个层还可以和用户界面工具箱整合在一起，例如Qt。与用户界面工具箱整合的抽象基类位于matplotlib.backend_bases。这个类可以为不同的用户界面工具箱派生专门的子类，如matplotlib.backends.backend_qt4agg。

为了创建一个图形，后端层提供了标题、字体、函数，可以将图形保存为不同格式，包括PDF、PNG、PS和SVG等格式。

Renderer类提供了画图接口，可以在画布上画出图形。

7.1.2　matplotlib的画图方法

用户使用matplotlib可以画出各种各样的二维图。这部分内容将介绍一些简单的图形和两种特殊图形：等高线图和矢量图。下面的程序画了一个线图，表示圆的半径和面积的对应关系：

import matplotlib.pyplot as plt
# 半径
r = [1.5, 2.0, 3.5, 4.0, 5.5, 6.0]
# 圆的面积
a = [7.06858, 12.56637, 38.48447, 50.26544, 95.03309, 113.09724]
plt.plot(r, a)
plt.xlabel('Radius')
plt.ylabel('Area')
plt.title('Area of Circle')
plt.show()

下面的程序画出了一个线图，其中有两种不同的线，分别表示正弦曲线和余弦曲线。通常，这些类型的图形都是用来做比较的。因此，matplotlib为图形提供了不同的颜色、线形和标记。绘图函数的第三个参数表示线条的颜色、线形和标记。第一个字符表示颜色， 它的值可以是b、g、r、c、m、y、k和w中的任何一个。其中k表示黑色（black），其他字母的含义都很明显。第二个字符表示线条的形状，可以用-、--、-..和:表示。这些符号分别表示实线、虚线、点划线和点线。最后一个字符表示标记，如.、x、+、o和*。

import matplotlib.pyplot as plt
var = arange(0.,100,0.2)
cos_var = cos(var)
sin_var = sin(var)
plt.plot(var,cos_var,'b-*',label='cosine')
plt.plot(var,sin_var,'r-.',label='sine')
plt.legend(loc='upper left')
plt.xlabel('xaxis')
plt.ylabel('yaxis')
plt.show()

在这个图形中，可以通过xlim或ylim函数设置 x 轴和 y 轴的坐标值范围。你可以在倒数第二行加plot.ylim(-2,2)看看效果。

下面的程序画的是服从正态分布的直方图。数据是通过正态分布函数产生的：

import matplotlib.pyplot as plt
from numpy.random import normal
sample_gauss = normal(size=530)
plt.hist(sample_gauss, bins=15)
plt.title("Histogram Representing Gaussian Numbers")
plt.xlabel("Value")
plt.ylabel("Frequency")
plt.show()

下面的程序画的是等高线图，用linspace函数生成的线性空间矢量构成：

import matplotlib.pyplot as plt
from numpy import *
x = linspace(0,10.5,40)
y = linspace(1,8,30)
(X,Y) = meshgrid(x,y)
func = exp(-((X-2.5)**2 + (Y-4)**2)/4) - exp(-((X-7.5)**2 + (Y-4)**2)/4)
contr = plt.contour(x,y,func)
plt.clabel(contr)
plt.xlabel("x")
plt.ylabel("y")
plt.show()

下面的程序画的是矢量图，同样是用linspace函数生成的线性空间矢量构成的。如果后面要重用图形元素，可以把它们保存为变量。程序中倒数第二、三行就是把xlabel和ylabel保存为变量：

import matplotlib.pyplot as plt
from numpy import *
x = linspace(0,15,11)
y = linspace(0,10,13)
(X,Y) = meshgrid(x,y)
arr1 = 15*X
arr2 = 15*Y
main_plot = plt.quiver(X,Y,arr1,arr2,angles='xy',scale=1000,color='b')
main_plot_key = plt.quiverkey(main_plot,0,15,30,"30 m/s",
                coordinates='data',color='b')
xl = plt.xlabel("x in (km)")
yl = plt.ylabel("y in (km)")
plt.show()

输出结果

画图的结果可以输出为不同的文件格式，例如图片、PDF、PS文件等。要把图形保存为文件，有两种方法。

第一种也是比较简单的方法就是直接在屏幕上用保存按钮，如下面的截图所示。

图形显示屏幕的左下角有许多按钮，最右边的按钮可以把图形保存为图片文件。点击之后，会出现对话框，提示你选择保存的格式、保存的位置和文件名称。

第二种方法是在plt.show()方法运行之前使用plt.savefig方法。还可以设置输出文件的名称、样式与格式。

下面的程序把多个图形保存在一个PDF文件的不同页面中，同时还演示了把图形保存为PNG文件的方法：

from matplotlib.backends.backend_pdf import PdfPages
import matplotlib.pyplot as plt
import matplotlib as mpl
from numpy.random import normal
from numpy import *

# PDF初始化
pdf = mpl.backends.backend_pdf.PdfPages("output.pdf")

# 第一幅图保存到PDF第一页
sample_gauss = normal(size=530)
plt.hist(sample_gauss, bins=15)
plt.xlabel("Value")
plt.ylabel("Frequency")
plt.title("Histogram Representing Gaussian Numbers")
pdf.savefig()
plt.close()

# 创建第二幅图，保存到PDF第二页
var = arange(0.,100,0.2)
cos_var = cos(var)
sin_var = sin(var)
plt.legend(loc='upper left')
plt.xlabel('xaxis')
plt.ylabel('yaxis')
plt.plot(var,cos_var,'b-*',label='cosine')
plt.plot(var,sin_var,'r-.',label='sine')
pdf.savefig()
pdf.close()
plt.close()

# 输出PNG文件
r = [1.5, 2.0, 3.5, 4.0, 5.5, 6.0]
a = [7.06858, 12.56637, 38.48447, 50.26544, 95.03309, 113.09724]
plt.plot(r, a)
plt.xlabel('Radius')
plt.ylabel('Area')
plt.title('Area of Circle')
plt.savefig("sample_output.png")
plt.show()

7.2　pandas程序库

pandas程序库的工具适合完成高性能数据分析任务。这个程序库同时适用于商务与科学领域。“pandas”是计量经济学的“面板数据”（panel data）和“Python数据分析”（data analysis）的英文缩写。数据分析和数据处理的五个步骤是加载、准备、操作、建模和分析。

pandas为Python数据结构增加了三个数据类型，分别是Series、DataFrame和Panel。这些数据结构都是在NumPy的基础上建立的。下面让我们具体看看每种数据类型。

7.2.1　Series

Series是一维对象，类似于数组、列表或表格中的一列。它可以存储任意Python数据类型，包括整型、浮点型、字符串以及任意Python对象。Series的每个项目还有一个标签索引。默认情况下，索引从0到 N 表示N+1个项目。我们可以用Series从NumPy数组或Python词典（dict）创建一个Series对象。同时，我们也可以为Series的数据设置对应的索引。

让我们用一个简单的程序来演示Series数据结构：

import numpy as np
randn = np.random.randn
from pandas import *

s = Series(randn(10), index=['I', 'II', 'III', 'IV', 'V', 'VI', 'VII',
                                'VIII', 'IX', 'X' ])
s
s.index

Series(randn(10))

d = {'a' : 0., 'e' : 1., 'i' : 2.}
Series(d)
Series(d, index=['e', 'i', 'o', 'a'])

# Series用标量值创建
Series(6., index=['a', 'e', 'i', 'o', 'u', 'y'])
Series([10, 20, 30, 40], index=['a', 'e', 'i', 'o'])
Series({'a': 10, 'e': 20, 'i': 30})
s.get('VI')

# 可以设置name属性，定义Series名称
s = Series(np.random.randn(5), name='RandomSeries')

7.2.2　DataFrame

pandas的二维数据结构叫DataFrame。DataFrame是由行和列构成的数据结构，类似于数据库表或Excel表格。

与Series类似，DataFrame也支持不同的输入类型，例如：

一维NumPy数组、列表、序列值和词典（dict）的词典

二维NumPy数组

一个结构体/记录（structure/record，下文指元组列表）的NumPy数组

一个pandas的Series或DataFrame对象

虽然索引和列名称参数是可选的，但是最好把它们设置一下。索引可以看成是行标签，列名称可以看成是列标签。下面的程序首先从词典（dict）创建DataFrame。如果列名称未设置，则使用排序过的词典键作为列名。

然后，通过N维数组/列表的词典（dict）创建DataFrame。最后，用结构体或记录的数组创建DataFrame：

import numpy as np
randn = np.random.randn
from pandas import *

# 从序列字典或字典生成DataFrame
d = {'first' : Series([10., 20., 30.], index=['I', 'II', 'III']),
    'second' : Series([10., 20., 30., 40.], index=['I', 'II', 'III',
    'IV'])}
DataFrame(d, index=['IV', 'II', 'I'])

DataFrame(d, index=['IV', 'II', 'I'], columns=['second', 'third'])
df = DataFrame(d)
df
df.index
df.columns

# 从_N_维数组字典或列表字典生成DataFrame
d = {'one' : [10., 20., 30., 40.],
    'two' : [40., 30., 20., 10.]}
DataFrame(d)
DataFrame(d, index=['I', 'II', 'III', 'IV'])

# 结构化的数组或记录
data = np.zeros((2,),dtype=[('I', 'i4'),('II', 'f4'),('III', 'a10')])
data[:] = [(10,20.,'Very'),(20,30.,"Good")]

DataFrame(data)
DataFrame(data, index=['first', 'second'])
DataFrame(data, columns=['III', 'I', 'II'])

7.2.3　Panel

Panel数据结构可以存储三维数据。这个名称源自统计学和计量经济学，里面的多维数据经常有多个时间周期。通常，Panel包括同一个组织或人的多个时间周期的多项数据。

Panel数据结构有三个组成部分——项目（item）、主轴（major axis）和次轴（minor axis），解释如下。

items：指的是Panel里每个DataFrame的数据项。

major axis：指的是每个DataFrame的索引（行标签）。

minor axis：指的是每个DataFrame的列。

下面的程序演示了创建Panel的不同方法：项目选择/索引，数据压缩，转换成多层索引的DataFrame。程序的最后两行是利用to_frame方法把Panel转换成DataFrame：

import numpy as np
randn = np.random.randn
from pandas import *

# 通过带标签的三维数组创建Panel
workpanel = Panel(randn(2, 3, 5), items=['FirstItem', 'SecondItem'],
            major_axis=date_range('1/1/2010', periods=3),
            minor_axis=['A', 'B', 'C', 'D', 'E'])
workpanel

# 通过值是DataFrame的Python字典创建Panel
data = {'FirstItem' : DataFrame(randn(4, 3)),
        'SecondItem' : DataFrame(randn(4, 2))}
Panel(data)

# orient=minor表示用DataFrame的列名作为Panel的item（项）
Panel.from_dict(data, orient='minor')

df = DataFrame({'x': ['one', 'two', 'three', 'four'],'y': np.random.randn(4)})
df

data = {'firstitem': df, 'seconditem': df}
panel = Panel.from_dict(data, orient='minor')
panel['x']
panel['y']
panel['y'].dtypes

# 选择Panel的某一项
workpanel['FirstItem']

# 对Panel进行转置操作，有C(3,2)即6种组合方式
workpanel.transpose(2, 0, 1)

# 从major_axis标签获取一个切片
workpanel.major_xs(workpanel.major_axis[1])

workpanel.minor_axis
# 从minor_axis标签获取一个切片
workpanel.minor_xs('D')

# 利用squeeze方法抽取部分维度数据，与workpanel['FirstItem']和
# workpanel['FirstItem']['B']类似
workpanel.reindex(items=['FirstItem']).squeeze()
workpanel.reindex(items=['FirstItem'],minor=['B']).squeeze()

forconversionpanel = Panel(randn(2, 4, 5), items=['FirstItem', 'SecondItem'],
                     major_axis=date_range('1/1/2010', periods=4),
                     minor_axis=['A', 'B', 'C', 'D', 'E'])
forconversionpanel.to_frame()

7.2.4　pandas数据结构的常用函数

这些数据结构有一些共同的功能。这些功能在每个数据结构上都会实现同样的操作。不同的数据结构之间具有共同的属性。下面的程序展示了pandas数据结构的常用功能/操作和属性：

import numpy as np
randn = np.random.randn
from pandas import *

index = date_range('1/1/2000', periods=10)

s = Series(randn(10), index=['I', 'II', 'III', 'IV', 'V', 'VI', 'VII',
    'VIII', 'IX', 'X' ])

df = DataFrame(randn(10, 4), index=['I', 'II', 'III', 'IV', 'V', 'VI',
     'VII', 'VIII', 'IX', 'X' ], columns=['A', 'B', 'C', 'D'])

workpanel = Panel(randn(2, 3, 5), items=['FirstItem', 'SecondItem'],
            major_axis=date_range('1/1/2010', periods=3),
            minor_axis=['A', 'B', 'C', 'D', 'E'])

series_with100elements = Series(randn(100))

series_with100elements.head()
series_with100elements.tail(3)

series_with100elements[:3]
df[:2]
workpanel[:2]

df.columns = [x.lower() for x in df.columns]
df

# 利用values属性可以获取数值
s.values
df.values
wp.values

有一些功能/属性只能用于Series和DataFrame。下面的程序将演示这些功能/属性，包括描述性统计函数describe、最大/最小索引（idxmin/idxmax）、按照行/列标签或数值排序、对象功能转换、数值类型属性（dtypes），等等：

import numpy as np
randn = np.random.randn
from pandas import *

# 描述性统计describe函数
series = Series(randn(440))
series[20:440] = np.nan
series[10:20] = 5
series.nunique()
series = Series(randn(1700))
series[::3] = np.nan
series.describe()

frame = DataFrame(randn(1200, 5), columns=['a', 'e', 'i', 'o', 'u'])
frame.ix[::3] = np.nan
frame.describe()

series.describe(percentiles=[.05, .25, .75, .95])
s = Series(['x', 'x', 'y', 'y', 'x', 'x', np.nan, 'u', 'v', 'x'])
s.describe()

frame = DataFrame({'x': ['Y', 'Yes', 'Yes', 'N', 'No', 'No'], 'y':
range(6)})
frame.describe()
frame.describe(include=['object'])
frame.describe(include=['number'])
frame.describe(include='all')

# 最大索引与最小索引的值
s1 = Series(randn(10))
s1
s1.idxmin(), s1.idxmax()

df1 = DataFrame(randn(5,3), columns=['X','Y','Z'])
df1
df1.idxmin(axis=0)
df1.idxmax(axis=1)

df3 = DataFrame([1, 2, 2, 3, np.nan], columns=['X'],
index=list('aeiou'))
df3
df3['X'].idxmin()

# 按标签排序和按数值排序
unsorted_df = df.reindex(index=['a', 'e', 'i', 'o'],
                columns=['X', 'Y', 'Z'])
unsorted_df.sort_index()
unsorted_df.sort_index(ascending=False)
unsorted_df.sort_index(axis=1)

df1 = DataFrame({'X':[5,3,4,4],'Y':[5,7,6,8],'Z':[9,8,7,6]})
df1.sort_index(by='Y')
df1[['X', 'Y', 'Z']].sort_index(by=['X','Y'])

s = Series(['X', 'Y', 'Z', 'XxYy', 'Yxzx', np.nan, 'ZXYX', 'Zoo', 'Yet'])
s[3] = np.nan
s.order()
s.order(na_position='first')

# 将目标值插入既定顺序，查找包含区间值的索引
ser = Series([4, 6, 7, 9])
ser.searchsorted([0, 5])
ser.searchsorted([1, 8])
ser.searchsorted([5, 10], side='right')
ser.searchsorted([1, 8], side='left')

s = Series(np.random.permutation(17))
s
s.order()
s.nsmallest(5)
s.nlargest(5)

# 对多维索引进行排序
df1.columns = MultiIndex.from_tuples([('x','X'),('y','Y'),('z','X')])
df1.sort_index(by=('x','X'))

# 查看DataFrame和Series的数值类型
dft = DataFrame(dict( I = np.random.rand(5),
        II = 8,
        III = 'Dummy',
        IV = Timestamp('19751008'),
        V = Series([1.6]*5).astype('float32'),
        VI = True,
        VII = Series([2]*5,dtype='int8'),
        VIII = False))
dft
dft.dtypes
dft['III'].dtype
dft['II'].dtype

# 统计每种数据类型出现的次数
dft.get_dtype_counts()

df1 = DataFrame(randn(10, 2), columns = ['X', 'Y'], dtype = 'float32')
df1
df1.dtypes

df2 = DataFrame(dict( X = Series(randn(10)),
                Y = Series(randn(10),dtype='uint8'),
                Z = Series(np.array(randn(10),dtype='float16'))
                ))
df2
df2.dtypes

# 转换DataFrame和Series的数据类型
df3['D'] = '1.'
df3['E'] = '1'
df3.convert_objects(convert_numeric=True).dtypes
# 同上，但是转换为指定数据类型
df3['D'] = df3['D'].astype('float16')
df3['E'] = df3['E'].astype('int32')
df3.dtypes

s = Series([datetime(2001,1,1,0,0),
        'foo', 1.0, 1, Timestamp('20010104'),
        '20010105'],dtype='O')
s
s.convert_objects(convert_dates='coerce')

实现迭代操作非常简单，每种数据结构都是同样的方式。Series数据结构的数据操作有一个存取器。下面的程序演示了这些概念：

import numpy as np
randn = np.random.randn
from pandas import *

workpanel = Panel(randn(2, 3, 5), items=['FirstItem', 'SecondItem'],
            major_axis=date_range('1/1/2010', periods=3),
            minor_axis=['A', 'B', 'C', 'D', 'E'])
df = DataFrame({'one-1' : Series(randn(3), index=['a', 'b', 'c']),
     'two-2' : Series(randn(4), index=['a', 'b', 'c','d']),
     'three-3' : Series(randn(3), index=['b', 'c', 'd'])})

for columns in df:
    print(columns)

for items, frames in workpanel.iteritems():
    print(items)
    print(frames)

for r_index, rows in df2.iterrows():
    print('%s\n%s' % (r_index, rows))

df2 = DataFrame({'x': [1, 2, 3, 4, 5], 'y': [6, 7, 8, 9, 10]})
print(df2)
print(df2.T)

df2_t = DataFrame(dict((index,vals) for index, vals in df2.iterrows()))
print(df2_t)

df_iter = DataFrame([[1, 2.0, 3]], columns=['x', 'y', 'z'])
row = next(df_iter.iterrows())[1]

print(row['x'].dtype)
print(df_iter['x'].dtype)

for row in df2.itertuples():
    print(row)

# 用dt存取器处理时间
s = Series(date_range('20150509 01:02:03',periods=5))
s
s.dt.hour
s.dt.second
s.dt.day
s[s.dt.day==2]

# 时区转换也非常方便
stimezone = s.dt.tz_localize('US/Eastern')
stimezone
stimezone.dt.tz
s.dt.tz_localize('UTC').dt.tz_convert('US/Eastern')

# 周期
s = Series(period_range('20150509',periods=5,freq='D'))
s
s.dt.year
s.dt.day

# 时间间隔
s = Series(timedelta_range('1 day 00:00:05',periods=4,freq='s'))
s
s.dt.days
s.dt.seconds
s.dt.components

pandas提供了大量的方法来实现描述性统计量的计算，此外还有聚合函数，如计数、求和、最小值、最大值、均值、中位数、众数、标准偏差、方差、偏度、峰度、分位数、累计求和等函数。

下面的程序演示了Series、DataFrame和Panel数据结构如何使用这些函数。这些方法都有一个可选参数skipna，可以在统计时忽略缺失数据（NaN）。该参数的初始值为True。

import numpy as np
randn = np.random.randn
from pandas import *

df = DataFrame({'one-1' : Series(randn(3), index=['a', 'b', 'c']),
     'two-2' : Series(randn(4), index=['a', 'b', 'c','d']),
     'three-3' : Series(randn(3), index=['b', 'c', 'd'])})
df
df.mean(0)
df.mean(1)
df.mean(0, skipna=False)
df.mean(axis=1, skipna=True)
df.sum(0)
df.sum(axis=1)
df.sum(0, skipna=False)
df.sum(axis=1, skipna=True)

# NumPy的mean不统计缺失值
np.mean(df['one-1'])
np.mean(df['one-1'].values)

ser = Series(randn(10))
ser.pct_change(periods=3)

# 指定周期的
df = DataFrame(randn(8, 4))
df.pct_change(periods=2)

ser1 = Series(randn(530))
ser2 = Series(randn(530))
ser1.cov(ser2)

frame = DataFrame(randn(530, 5), columns=['i', 'ii', 'iii', 'iv', 'v'])
frame.cov()
frame = DataFrame(randn(26, 3), columns=['x', 'y', 'z'])
frame.ix[:8, 'i'] = np.nan
frame.ix[8:12, 'ii'] = np.nan
frame.cov()
frame.cov(min_periods=10)
frame = DataFrame(randn(530, 5), columns=['i', 'ii', 'iii', 'iv', 'v'])
frame.ix[::4] = np.nan

# 用Pearson方法（默认方法）计算标准相关系数
frame['i'].corr(frame['ii'])
# 也可以指定用Kendall方法和Spearman方法
frame['i'].corr(frame['ii'], method='kendall')
frame['i'].corr(frame['ii'], method='spearman')

index = ['i', 'ii', 'iii', 'iv']
columns = ['first', 'second', 'third']
df1 = DataFrame(randn(4, 3), index=index, columns=columns)
df2 = DataFrame(randn(3, 3), index=index[:3], columns=columns)
df1.corrwith(df2)
df2.corrwith(df1, 1)

s = Series(np.random.randn(10), index=list('abcdefghij'))
s['d'] = s['b'] # 单个元素复制操作
s.rank()

df = DataFrame(np.random.randn(8, 5))
df[4] = df[2][:5] # 多个元素（第三列前五个元素）复制操作，缺失位置用NaN
df
df.rank(1)

7.2.5　时间序列与日期函数

pandas提供了丰富的时间序列与日期操作函数，可以实现时间和日期相关的计算。

pandas通过TimeStamp数据类型，可以获得大量的时间和日期属性，其中部分属性如下所示。

year：年。

month：月。

day：日。

hour：小时。

minute：分钟。

second：秒。

microsecond：微秒，百万分之一秒。

nanosecond：纳秒，十亿分之一秒。

date：日期。

time：时间。

dayofyear：天数，范围1~365/366（闰年）。

weekofyear：周数。

dayofweek：星期几，周一用0表示，周日用6表示。

quarter：季度，一月到三月用1表示，四月到六月用2表示，以此类推。

下面的程序演示了这些函数的用法：

import numpy as np
randn = np.random.randn
from pandas import *
# 创建日期区间，从06/03/2015开始152个小时
range_date = date_range('6/3/2015', periods=152, freq='H')
range_date[:5]

# 时间作为索引
ts = Series(randn(len(range_date)), index=range_date)
ts.head()

# 把索引值的频率更新为40分钟
converted = ts.asfreq('40Min', method='pad')
converted.head()
ts.resample('D', how='mean')
dates = [datetime(2015, 6, 10), datetime(2015, 6, 11), datetime(2015, 6, 12)]
ts = Series(np.random.randn(3), dates)
type(ts.index)
ts

# 创建周期索引
periods = PeriodIndex([Period('2015-10'), Period('2015-11'),
                        Period('2015-12')])
ts = Series(np.random.randn(3), periods)
type(ts.index)
ts

# 转换时间戳
to_datetime(Series(['Jul 31, 2014', '2015-01-08', None]))
to_datetime(['1995/10/31', '2005.11.30'])
# 日期数值如果按照月-日-年的形式，就用dayfirst=True
to_datetime(['01-01-2015 11:30'], dayfirst=True)
to_datetime(['14-03-2007', '03-14-2007'], dayfirst=True)
# 如果日期数值中有无效值，则用coerce=True转换成NaT
to_datetime(['2012-07-11', 'xyz'])
to_datetime(['2012-07-11', 'xyz'], coerce=True)

# 混合数据类型无法正确处理
to_datetime([1, '1'])
# 纪元时间戳（Epoch timestamp : 整型与浮点型纪元时间戳可以转换成标准时间戳
# 默认使用纳秒，可以转换成秒与微秒
# 初始时间是01/01/1970
to_datetime([1449720105, 1449806505, 1449892905,
            1449979305, 1450065705], unit='s')

to_datetime([1349720105100, 1349720105200, 1349720105300,
                1349720105400, 1349720105500 ], unit='ms')
to_datetime([8])
to_datetime([8, 4.41], unit='s')

# 取一定范围的日期
dates = [datetime(2015, 4, 10), datetime(2015, 4, 11), datetime(2015,
4, 12)]
index = DatetimeIndex(dates)
index = Index(dates)
index = date_range('2010-1-1', periods=1700, freq='M')
index
index = bdate_range('2014-10-1', periods=250)
index

start = datetime(2005, 1, 1)
end = datetime(2015, 1, 1)
range1 = date_range(start, end)
range1
range2 = bdate_range(start, end)
range2

日期信息也可以作为pandas数据结构的索引使用。下面的程序演示了用日期做索引，里面还使用了DateOffset对象：

import numpy as np
randn = np.random.randn
from pandas import *
from pandas.tseries.offsets import *

start = datetime(2005, 1, 1)
end = datetime(2015, 1, 1)
rng = date_range(start, end, freq='BM')
ts = Series(randn(len(rng)), index=rng)
ts.index
ts[:8].index
ts[::1].index

# 可以直接用日期和字符串作为索引
ts['8/31/2012']
ts[datetime(2012, 07, 11):]
ts['10/08/2005':'12/31/2014']
ts['2012']
ts['2012-7']

dft = DataFrame(randn(50000,1),columns=['X'],index=date_range('20050101',
        periods=50000,freq='T'))
dft
dft['2005']
# 从第一个参数里月份的最早时间到最后一个参数里月份的最晚时间
dft['2005-1':'2013-4']
dft['2005-1':'2005-3-31']
# 可以指定停止时间
dft['2005-1':'2005-3-31 00:00:00']
dft['2005-1-17':'2005-1-17 05:30:00']
# 日期索引
dft[datetime(2005, 1, 1):datetime(2005,3,31)]
dft[datetime(2005, 1, 1, 01, 02, 0):datetime(2005, 3, 31, 01, 02, 0)]

# 用loc选择一行数据
dft.loc['2005-1-17 05:30:00']
# 截取一段时间
ts.truncate(before='1/1/2010', after='12/31/2012')

7.2.6　处理缺失数据

缺失数据是指数据为空（null）或没显示。通常用Na*表示缺失数据，这里的*表示数值类型，比如N表示缺失数值（NaN），T表示缺失时间值（NaT）。下面的程序演示了pandas检查缺失数据的函数isnull和notnull，用fillna、dropna、rloc、iloc和interpolate填补缺失数据。如果对NaN对象执行任何操作，结果仍为NaN：

import numpy as np
randn = np.random.randn
from pandas import *

df = DataFrame(randn(8, 4), index=['I', 'II', 'III', 'IV', 'VI',
        'VII', 'VIII', 'X' ],
        columns=['A', 'B', 'C', 'D'])
df['E'] = 'Dummy'
df['F'] = df['A'] > 0.5
df

# 通过增加索引来引入缺失值
df2 = df.reindex(['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII',
        'IX', 'X'])
df2
df2['A']
# 检查是否存在缺失值
isnull(df2['A'])
df2['D'].notnull()

df3 = df.copy()
df3['timestamp'] = Timestamp('20120711')
df3

# 把timestamp列缺失值设置为NaT
df3.ix[['I','III','VIII'],['A','timestamp']] = np.nan
df3

s = Series([5,6,7,8,9])
s.loc[0] = None
s

s = Series(["A", "B", "C", "D", "E"])
s.loc[0] = None
s.loc[1] = np.nan
s

# 用fillna方法填充缺失值
df2
df2.fillna(0)  # 填充索引缺失值为0
df2['D'].fillna('missing') # 为某一列填充缺失值

df2.fillna(method='pad')
df2
df2.fillna(method='pad', limit=1)

df2.dropna(axis=0)
df2.dropna(axis=1)

ts = Series(randn(30))
ts.count()
ts[10:30]=None
ts.count()
# 利用插值方法填充缺失值
# 默认使用线性插值
ts.interpolate()
ts.interpolate().count()

7.3　I/O操作

pandas的I/O API是一组返回pandas对象的read_函数。通过这些函数非常方便加载数据。能够载入pandas数据结构的数据格式很多，包括CSV、Excel、HDF、SQL、JSON、HTML、Google Big Query、pickle、stats格式，甚至粘贴板。其中一些读取函数的名称（一个函数读取一种文件格式）是read_csv、read_excel、read_hdf、read_sql、read_json。加载之后，数据就可以分析了。这些函数也支持异常检测、数据标准化、编组、转换和排序。

7.3.1　处理CSV文件

下面的程序演示pandas读取CSV文件，并进行各种操作。这里使用的是Book-Crossing数据集的CSV格式，可以在http://www2.informatik.uni-freiburg.de/~cziegler/BX/下载。这个数据集里包括三个CSV文件（BX-Books.csv、BX-Users.csv和BX-Book-Ratings.csv），里面包括书籍、读者和读者评分。pandas读取CSV文件名有两种方法：一是在任意文件夹里使用CSV的绝对路径，二是在CSV的同一个文件夹里直接使用文件名。下面的程序是在Windows系统里使用文件的绝对路径：

import numpy as np
randn = np.random.randn
from pandas import *

user_columns = ['User-ID', 'Location', 'Age']
users = read_csv('c:\BX-Users.csv', sep=';', names=user_columns)

rating_columns = ['User-ID', 'ISBN', 'Rating']
ratings = read_csv('c:\BX-Book-Ratings.csv', sep=';', names=rating_columns)

book_columns = ['ISBN', 'Book-Title', 'Book-Author', 'Year-Of- \
                Publication', 'Publisher', 'Image-URL-S']
books = read_csv('c:\BX-Books.csv', sep=';', names=book_columns,
            usecols=range(6))
books
books.dtypes

users.describe()
print books.head(10)
print books.tail(8)
print books[5:10]

users['Location'].head()
print users[['Age', 'Location']].head()

desired_columns = ['User-ID', 'Age']
print users[desired_columns].head()
print users[users.Age > 25].head(4)
print users[(users.Age < 50) & (users.Location == 'chicago, illinois,\
        usa')].head(4)

print users.set_index('User-ID').head()
print users.head()

with_new_index = users.set_index('User-ID')
print with_new_index.head()
users.set_index('User_ID', inplace=True)
print users.head()

print users.ix[62]
print users.ix[[1, 100, 200]]
users.reset_index(inplace=True)
print users.head()

下面的程序演示在Book-Crossing数据集上的merge、groupby和相关操作，如排序、分类、寻找最大的n个数以及数据聚合：

import numpy as np
randn = np.random.randn
from pandas import *

user_columns = ['User-ID', 'Location', 'Age']
users = read_csv('c:\BX-Users.csv', sep=';', names=user_columns)
rating_columns = ['User-ID', 'ISBN', 'Rating']
ratings = read_csv('c:\BX-Book-Ratings.csv', sep=';', names=rating_columns)

book_columns = ['ISBN', 'Title', 'Book-Author', 'Year-Of-Publication',
                'Publisher', 'Image-URL-S']
books = read_csv('c:\BX-Books.csv', sep=';', names=book_columns,
                usecols=range(6))

# 创建合并的DataFrame
book_ratings = merge(books, ratings)
users_ratings = merge(book_ratings, users)

most_rated = users_ratings.groupby('Title').size(). \
                order(ascending=False)[:25]
print most_rated

users_ratings.Title.value_counts()[:17]

book_stats = users_ratings.groupby('Title').agg({'Rating': [np.size,
             np.mean]})
print book_stats.head()

# 按照评分等级Rating与均值mean排序
print book_stats.sort([('Rating', 'mean')], ascending=False).head()

greater_than_100 = book_stats['Rating'].size >= 100
print book_stats[greater_than_100].sort([('Rating', 'mean')],
ascending=False)[:15]

top_fifty = users_ratings.groupby('ISBN').size().\
            order(ascending=False)[:50]

　下面处理CSV文件的程序位于https://github.com/gjreda/gregreda.com/blob/master/content/notebooks/data/city-of-chicago-salaries.csv?raw=true。

下面的程序演示了DataFrame的合并与连接操作：

import numpy as np
randn = np.random.randn
from pandas import *

first_frame = DataFrame({'key': range(10),
                'left_value': ['A', 'B', 'C', 'D', 'E',
                'F', 'G', 'H', 'I', 'J']})
second_frame = DataFrame({'key': range(2, 12),
                'right_value': ['L', 'M', 'N', 'O', 'P',
                'Q', 'R', 'S', 'T', 'U']})
print first_frame
print second_frame

# 默认合并join操作（inner join）
print merge(left_frame, right_frame, on='key', how='inner')
# 其他合并操作（left，right，outer）
print merge(left_frame, right_frame, on='key', how='left')
print merge(left_frame, right_frame, on='key', how='right')
print merge(left_frame, right_frame, on='key', how='outer')

concat([left_frame, right_frame])
concat([left_frame, right_frame], axis=1)

headers = ['name', 'title', 'department', 'salary']
        chicago_details = read_csv('c:\city-of-chicago-salaries.csv',
        header=False,
        names=headers,
        converters={'salary': lambda x: float(x.replace('$', ''))})
print chicago_detail.head()

dept_group = chicago_details.groupby('department')

print dept_group
print dept_group.count().head(10)
print dept_group.size().tail(10)
print dept_group.sum()[10:17]
print dept_group.mean()[10:17]
print dept_group.median()[10:17]

chicago_details.sort('salary', ascending=False, inplace=True)

7.3.2　即开即用数据集

pandas程序里还有一些经济学数据源和使用这些数据的模块。我们可以用pandas.io.data和pandas.io.ga（Google Analytics）模块从不同的网络源获取数据，并加载为DataFrame。目前支持的数据源如下。

雅虎财经。

谷歌财经。

St. Louis Fed：美联储经济数据（federal reserve economic data，FRED）包含了80源数据库的267 000多份经济时间序列数据。

Kenneth French的数字图书馆。

世界银行。

Google Analytics。

下面的小程序演示了从不同的数据源读取数据：

import pandas.io.data as web
import datetime
f1=web.DataReader("F", 'yahoo', datetime.datetime(2010, 1, 1),
    datetime.datetime(2011, 12, 31))
f2=web.DataReader("F", 'google', datetime.datetime(2010, 1, 1),
    datetime.datetime(2011, 12, 31))
f3=web.DataReader("GDP", "fred", datetime.datetime(2010, 1, 1),
    datetime.datetime(2011, 12, 31))
f1.ix['2010-05-12']

pandas画图功能

pandas数据结构通过封装plt.plot()支持画图功能。默认情况下，画的图都是线图，可以通过修改画图函数的kind属性改变图形的样式。df.plot()不同图形的样式的参数如下所示。

条形图：df.plot(kind='bar')

直方图：df.plot(kind='hist')

箱体图：df.plot(kind='box')

面积图：df.plot(kind='area')

散点图：df.plot(kind='scatter')

饼图：df.plot(kind='pie')

下面的程序演示了一个简单的pandas画图方法。程序的输出结果如下面的截图所示。

from pandas import *
randn = np.random.randn
import matplotlib.pyplot as plt
x1 = np.array( ((1,2,3), (1,4,6), (2,4,8)) )
df = DataFrame(x1, index=['I', 'II', 'III'], columns=['A', 'B', 'C'])
df = df.cumsum()
df.plot(kind='pie', subplots=True)
plt.figure()
plt.show()

7.4　IPython

IPython设计和开发的初衷是要增强Python命令行工具的功能，让它支持交互式的分布式和并行计算。IPython的工具可以支持许多交互性科学计算需求。主要的两种工具如下：

功能增强的IPython命令行工具

交互式并行计算架构

这一节主要介绍IPython的交互式命令行工具。关于交互式并行计算的内容，将在第8章介绍。

7.4.1　IPython终端与系统命令行工具

IPython的终端界面如下图所示。它支持不同的配色主题，默认的主题是NoColor，还有其他主题，如Linux和LightBG。IPython的重要特征之一是它是有状态的，就是说它会保留计算过程。IPython的输出结果都保存在_N里面，N是输出和计算结果的序号。当我们进入IPython命令行的时候，交互式的编程界面如下所示。

IPython 3.0.0 -- An enhanced Interactive Python.
? -> Introduction and overview of IPython's features.
%quickref -> Quick reference.
help -> Python's own help system.
object? -> Details about 'object', use 'object??' for extra details.

如果我们像普通命令一样输入一个问号（?），就会显示IPython的功能列表。类似地，%quickref会显示IPython命令的简介，%magic会显示IPython魔法命令的具体使用方法。

如果我们输入任意Python对象后加问号（objname?），IPython终端就会显示这个对象的文字说明、功能以及构建方法，如下图所示。我们创建了pandas的一个DataFrame对象df，然后用df?查看它的属性。

1. 操作系统命令接口

我们经常需要用操作系统的命令辅助进行计算。用户可以通过命令别名速记常用命令。IPython终端还支持UNIX系统的ls等命令，用户只要在命令前面加一个感叹号!，就可以执行任何操作系统命令和系统脚本文件。

在IPython命令行中执行一个操作系统命令

2. 无阻塞的画图功能

在Python命令行里创建图形，然后用show()命令显示的时候，会在新窗口显示图形，并且在用户关闭图形窗口之前，命令行都是阻塞状态，不可继续使用。但是，IPython有一个–pylab标记。如果通过IPython –pylab命令启动IPython命令行，在显示图形时，图形会直接出现在命令行窗口，不会阻塞命令行继续运行。如下图所示，当使用IPython –pylab命令时，图形显示不会阻塞命令行。

3. 调试

IPython具有非常强大的程序调试以及错误和异常追踪功能。当我们运行脚本之后，通过%debug命令就可以启动Python的调试器（pdb）来分析问题。我们可以在这里调试，因为我们可以打印变量结果，执行语句，追踪异常来源。这个功能可以让用户轻松调试，不需要借助其他外部调试工具。

%debug命令的截图如下所示。

用户可以通过%run -d programname.py命令单步调试程序，如下图所示。我们可以对stepbystep.py文件进行单步调试。在每一行，调速器都会提醒用户输入c确认继续调试下一行代码。

7.4.2　IPython Notebook

IPython的网页版应用叫Notebook。设计和开发它的目的，是让用户可以通过网页丰富的展现形式体验交互式计算，可以编写解释概念的文字、数学公式、计算程序代码并输出图形。程序的输入和输出可以根据需要放在不同的单元中。

下图取自http://ipython.org/notebook.html，演示了IPython Notebook的功能。

7.5　小结

这一章首先介绍了matplotlib的基本概念和组成部分，然后通过一些小程序演示了不同图形的制作方法。此外还介绍了将图形保存为不同格式的方法，以及用pandas进行数据分析的方法。

与此同时，我们还介绍了pandas的数据结构。通过对pandas数据结构的详细介绍，你可以掌握许多数据分析的技能。最后一部分介绍了IPython的交互式编程的概念、用法和网页应用IPython Notebook。

下一章将对Python科学计算中的并行与高性能计算进行全面的介绍。首先将会介绍并行与高性能计算的基本概念以及现有的框架和技术等，然后深入介绍Python并行与高性能计算框架与工具的用法。

第 8 章　并行与大规模科学计算

本章主要介绍在Python中使用并行和大规模计算的重要概念，或者说是用IPython解决科学计算问题，还会介绍大规模科学计算和大数据处理的发展趋势。本章通过程序示例帮助你更好地理解这些概念。

本章将介绍的主题如下：

IPython并行计算基础

IPython并行计算的组成部分

IPython的任务接口和数据库

IPython的直接运行接口

IPython并行计算详述

IPython的MPI编程

在Python中使用Hadoop和Spark进行大数据处理

IPython通过启动多个进程，让用户使用并行计算。IPython的第一个进程是IPython引擎，它是一个Python解释器，可以执行用户提交的任务。用户可以启动多个引擎来执行并行计算。第二个进程是IPython集线器，它监控引擎和调度器，跟踪用户任务的状态。集线器进程监控来自引擎和客户端的注册请求，它会持续地监控与调度器关联的连接。第三个进程是IPython调度器。这是一组进程，在客户端和引擎之间传送命令和结果。通常，调度器进程在已运行控制器进程的机器上，与集线器进程连接。最后一个进程是IPython客户端，是一个IPython会话，协调引擎完成计算任务。

以上介绍的所有进程组合起来称为IPython集群。这些进程之间通过ZeroMQ进行通信。ZeroMQ支持多种通信协议，包括Infiband、IPC、PGM、TCP等。IPython控制器是由集线器和调度器构成的，通过网络套接字（socket）监听客户端请求。当用户开启一个引擎之后，它会连接一个集线器并完成注册。集线器首先将调度器连接信息传递给引擎。之后，引擎会连接调度器。这些连接会在一个引擎的整个生命周期中存在。每个IPython客户端都会使用许多socket对象连接控制器。通常，客户端连接每个调度器用一个连接，连接每个集线器用三个连接。这些连接会在客户端的生命周期中持续存在。

8.1　用IPython做并行计算

IPython可以让用户以交互的方式完成并行与高性能计算。可以用IPython自带的并行计算方法，由上面四个部分（集线器、引擎、调度器和客户端）组成，能满足绝大多数并行需求。具体说来，IPython支持以下四类并行方式。

单程序，多数据并行（single program, multiple data parallelism，SPMD）：这是最常见的并行编程方式，属于多指令多数据（ Multiple Instruction and Multiple Data，MIMD）的子集。在这个模型中，每个任务会单独执行同一程序的复制版本。每个任务处理不同的数据集以实现更高的性能。

多程序，多数据并行（multiple program, multiple data parallelism，MPMD）：在这种并行方式中，每个任务会在每个计算节点上运行不同的程序，处理不同的数据。

使用消息传递接口进行通信（message passing using MPI）：消息传递接口（Message Passing Interface，MPI）是开发者设计消息传递程序库的设计规则。它是一种与编程语言无关的设计规则，可以让用户写出基于消息传递的并行程序。目前，它支持分布式内存共享模式和它们的混合模式。

任务并行：任务并行方式是在不同的计算节点之间分配任务。任务可以是线程，消息传递的组成部分，或者其他编程模式的组成部分，例如MapReduce。

数据并行：数据并行方式是在不同的计算节点之间分配数据。数据并行与任务并行最大的区别在于，数据并行是在计算节点之间分配和并行化数据。

以上类型的混合模式：IPython也支持前面不同并行方式的混合体。

用户自定义的并行方式：IPython被设计得十分简单灵活，用户可以按照自己的需求定义新的并行方式。

IPython可以在程序整个生命周期中的各个阶段使用交互式并行，例如开发、运行、调试与监控阶段。

IPython配合matplotlib可以让用户分析并可视化远程或分布式大型数据库。也支持用户在远程集群计算，然后将数据拉回本机再进行分析和可视化。用户可以通过IPython客户端将一个MPI应用推送到高性能的计算机上。它还可以对运行在一组CPU上的任务进行动态的负载均衡。另外，IPython还可以让用户通过两三行代码就写出一个简单的并行程序。用户可以交互式地开发、运行、测试、调试自定义并行程序。IPython允许用户将运行在不同计算节点上的MPI资源，组合成一个较大的分布式/并行系统。

8.2　IPython并行计算架构

IPython并行计算架构有三个主要组成部分。这些组成部分是IPython并行程序包的部件。IPython并行计算架构如下图所示。

IPython并行计算的三个主要部分是客户端、控制器和引擎。控制器由两部分组成：集线器和调度器。客户端与引擎的交互可以通过两种方式实现：直接运行接口和负载均衡接口。

并行计算的组成部分

IPython并行计算架构的组件和内容将在下面介绍。这些组件包括IPython引擎、IPython控制器（集线器和调度器）、IPython客户端与IPython视图。

1. IPython引擎

这个核心组件以网络请求的形式接受要执行的Python代码。引擎是一个普通Python解释器的实例，最终将会演变成一个功能健全的IPython解释器。用户可以通过启动多个引擎来实现分布式计算与并行计算。用户输入的代码在IPython中以阻断模式运行。

2. IPython控制器

IPython控制器是由一个集线器和若干个调度器构成的。IPython控制器绑定了客户端与引擎通信的多个进程。它是连接运行Python进程的用户与IPython引擎的枢纽。通常情况下，调度器都是运行集线器的计算机上的独立进程。有时，调度器也可以运行在远程计算机上。

集线器：集线器是最重要的组件。它会持续跟踪调度器与客户端以及与引擎的连接信息。它会处理客户端与引擎的所有连接以及整个网络的通信需求。它还会用数据库保存所有的请求和计算结果，以备后面的程序使用。集线器还提供了查询集群状态的功能，并为用户隐藏客户端与引擎连接的大量细节。

调度器：提交给引擎处理的Python代码，都是通过调度器传递的。调度器还解决了引擎执行用户代码时造成的阻塞问题。调度器为用户隐藏这些细节，并通过完全异步的连接方式连接IPython引擎。

3. IPython视图与接口

控制器提供了两种接口模式与引擎通信。第一种是Direct（直接运行）接口。在这种模式下引擎会把任务直接分配到固定地址运行。另一种是LoadBalanced（负载均衡）接口，适当地分配任务到空闲的调度器。IPython的灵活设计可以让我们对视图进行扩展，以实现更加复杂的接口机制。

不同的模式连接到控制器时，会产生一个view对象。两种模式通过控制器与机器进行交互的方法如下。

DirectView类支持直接分配地址。它可以让用户代码在所有引擎上运行。

LoadBalancedView类会帮助用户以负载均衡的方式对任务进行分配。它可以让用户程序运行在调度器指定的任意一个引擎上。

4. IPython客户端

客户端是一种用于连接IPython计算集群的对象。创建客户端对象的时候，用户可以选择前面介绍的任意一种视图。一旦客户端创建之后，只要任务在运行，它就会一直存在。当客户端运行达到规定时限，或者用户通过kill命令中止客户端进程，它就会被销毁。

8.3　并行计算示例

下面的例子是演示IPython并行计算的简单程序。它将对比单引擎与多引擎并行计算指数函数（a的b次方）的效率。运行代码之前，建议你检查一下zmq软件包是否已经安装，它是必需的。

要在IPython里启动并行程序，首先需要执行ipcluster start --n=4 --profile= testprofile命令。首先需要在/.ipython/profile_testprofile/security目录下创建ipcontroller-engine. json和ipcontroller-client.json文件。创建客户端时加上profile='testprofile'参数，就会搜索这些文件。如果我们用parallel.Client()命令创建客户端，就会在profile_default文件夹里搜索JSON文件。

首先程序创建一个计算指数的函数，然后创建一个单引擎的客户端。要在引擎中调用Python函数，可以使用客户端或视图的apply方法。Python的map函数可以对序列对象进行映射计算。DirectView和LoadBalancedView里的map函数可以对序列进行并行计算。我们可以用阻塞和非阻塞两种模式运行。在阻塞模式下，我们将参数block设置为true，默认值是false：

from IPython import parallel
def pow(a, b):
    return a ** b
clients = parallel.Client(profile='testprofile')
print clients.ids
clients.block = True
clients[0].apply(pow, 2, 4)
clients[:].apply(pow, 2, 4)
map(pow, [2, 3, 4, 5], [2, 3, 4, 5])
view = clients.load_balanced_view()
view.map(pow, [2, 3, 4, 5], [2, 3, 4, 5])

8.3.1　并行装饰器

在DirectView里有一个装饰器可以创建parallel并行函数。这个函数在序列上运行，首先会打破原有的次序，之后并行计算每个元素的函数值，最后再把结果按顺序重组。LoadBalancedView的装饰器也可以把Python程序转换成parallel函数：

from IPython import parallel
clients = parallel.Client(profile='testprofile')
lbview = clients.load_balanced_view()
lbview.block = True
serial_computation = map(lambda i:i**5, range(26))
parallel_computation = lbview.map(lambda i: i**5, range(26))
@lbview.parallel()
def func_turned_as_parallel(x):
    return x**8
func_turned_as_parallel.map(range(26))

8.3.2　IPython的魔法函数

IPython有许多魔法函数，用户可以像命令一样使用它们。IPython有两种魔法函数，分别是行魔法函数（line magic，单行语句）和单元魔法函数（cell magic，多行语句）。行魔法函数是在前面加%，功能如同操作系统命令。单元魔法函数是在前面加%%，它们会把这一行和后面的代码看成一体，与其他代码区别对待。

当用户创建了客户端之后，就可以使用魔法函数了。部分行魔法函数介绍如下。

%px：可以让指定的引擎运行一个Python命令。用户可以通过设置视图实例的target属性来选择引擎。

%pxconfig：即使我们没有任何激活的视图，也可以用pxconfig魔法函数加--targets、--block和--noblock参数选择引擎。

%autopx：这是一个有弹性的魔法函数，可以自动选择并行和非并行模式。第一次调用时，它会把终端切换成一种能够让所有命令和函数以并行模式运行的状态，直到用户再次调用autopx函数才结束。

%pxresult：在非阻塞模式下，%px函数不会返回计算结果。可以用pxresult魔法函数看到最新的结果。

在单元魔法函数模式下，px（%%px）魔法函数可以通过--targets选项设置目标引擎，--block或--noblock选项设置阻塞或非阻塞执行模式。当我们没有启动视图实例时，这些参数非常有用。另外还有--group-output选项，可以管理多个引擎的输出结果。

下面的程序将演示px与pxresult作为行魔法函数和单元魔法函数的用法。还演示了autopx和pxconfig行魔法函数，以及为这些行魔法函数创建具体后缀的方法。程序第二行和第三行向IPython会话以及所有引擎导入了模块numpy。第二行语句后面导入的所有模块都会在引擎上运行：

from IPython import parallel
drctview = clients[:]
with drctview.sync_imports():
    import numpy
clients = parallel.Client(profile='testprofile')
drctview.activate()
drctview.block=True
%px dummymatrix = numpy.random.rand(4,4)
%px eigenvalue = numpy.linalg.eigvals(dummymatrix)
drctview['eigenvalue']

%pxconfig --noblock
%autopx
maximum_egnvals = []
for idx in range(50):
    arr = numpy.random.rand(10,10)
    egnvals = numpy.linalg.eigvals(arr)
    maximum_egnvals.append(egnvals[0].real)
%autopx
%pxconfig --block
%px answer= "The average maximum eigenvalue is: %f"%(sum(maximum_\
    egnvals)/len(maximum_egnvals))
dv['answer']

%%px --block --group-outputs=engine
import numpy as np
arr = np.random.random (4,4)
egnvals = numpy.linalg.eigvals(arr)
print egnvals
egnvals.max()
egnvals.min()

odd_view = clients[1::2]
odd_view.activate("_odd")
%px print "Test Message"
odd_view.block = True
%px print "Test Message"
clients.activate()
%px print "Test Message"
%px_odd print "Test Message"

1. 激活视图

默认情况下，魔法函数与DirectView对象有对应关系（一个对象使用一类魔法函数）。用户可以在任意一个视图中调用activate()方法改变DirectView对象。激活视图时，我们可以添加一个新后缀名，比如定义成odd_view.activate("_odd")。对于这个视图，在原始的魔法函数基础上就生成了一个新的魔法函数，例如%px_odd，在前面程序的最后一行使用。

2. 引擎与Qt终端

px魔法函数可以让用户把Qt终端连接到引擎上，方便代码调试。下面的程序演示了利用bind_kernel将Qt终端与引擎连接，监听一个连接的信息：

%px from IPython.parallel import bind_kernel; bind_kernel()
%px %qtconsole
%px %connect_info

8.4　IPython的高级特性

下面将介绍IPython的一些高级特性。

8.4.1　容错执行

默认情况下，IPython的引擎是可以容错并动态负载均衡的集群系统。在任务接口里的客户端不会直接连接到引擎。任务都是通过调度器分配的，这样可以保证接口设计得简单、灵活、强大。

在IPython中如果一个任务失败了，就会重新排队并尝试再次启动。用户可以设置任务失败重新启动的次数，以及设置重新提交任务给其他引擎。

如果有需要，可以显式地重新提交任务。也可以为任务重试次数设置一个标示——设置一个视图或调度器标示即可。

如果用户确信任务失败不是代码问题，那么重试次数可以设置为1到引擎数量之间的任意整数值。

之所以将最大重试次数限制为引擎数量，是因为任务不会被再次提交给已经运行失败的引擎。

设置重新提交标示的方法有两种。一种是用LoadBalancedView对象（假设名称是lbvw）设置重试次数，如下所示：

lbvw.retries = 4

另一种方法是用with ...temp_flags代码块设置，像这样：

with lbvw.temp_flags(retries=4):
    lbview.apply(task_tobe_retried)

8.4.2　动态负载均衡

调度器可以按照不同的调度策略进行配置。在动态负载均衡时，IPython可以使用许多调度机制对任务进行分配，同时也可以对调度机制进行自定义。选择一种调度机制的方法有两种。一是设置config对象的taskSchedulerscheme_name属性，二是通过ipcontroller的参数进行配置：

ipcontroller --scheme=<schemename>

示例如下：

ipcontroller --scheme=lru

这里的<schemename>可以有以下几种方式（与操作系统调度方式一致）。

lru：最近用过的（Least Recently Used，LRU）是一种将任务分配给刚刚使用过的引擎的调度方式。

plainrandom：这种方式是随机选择一个引擎运行任务。

twobin：这种方式是用NumPy函数分配任务。它是plainrandom和lru的组合形式，首先随机选择两个引擎，然后选择两者中最近用过的引擎。

leastload：这是调度器的默认调度机制。它会选择负载最小的引擎（即当前运行任务数量最少的引擎）运行任务。

weighted：这是twobin调度机制的加权版本。首先随机选择两个引擎，然后以引擎负载数量或未完成的任务数量作为权重，再选择权重低（即负载数量较少）的引擎。

8.4.3　在客户端与引擎之间推拉对象

除了在引擎上调用函数并运行代码，IPython还允许用户在引擎和客户端之间移动Python对象。push方法是客户端向引擎推送对象，pull方法是客户端逆向从引擎拉回对象。在非阻塞模式下，push和pull方法都会返回AsyncResult对象。要在非阻塞模式下返回结果，可以这样拉回对象1：rslt = drctview.pull(('a','b','c'))。可以调用rslt.get()方法显示拉取对象的值。有时，把输入数据序列分割成几块，然后推送给不同的引擎是非常有效的做法。这种分割方法可以用scatter和gather函数来实现，类似于MPI的方式。scatter操作是从客户端（IPython会话）把分割序列推送给引擎，gather操作是把分割对象从引擎拉回到客户端。

1IPython结果是在引擎中计算出来的。——译者注

这些功能全部通过下面的程序进行演示。在程序最后，通过scatter和gather函数来实现并行计算两个矩阵的点乘运算。

import numpy as np
from IPython import parallel
clients = parallel.Client(profile='testprofile')
drctview = clients[:]
drctview.block = True
drctview.push(dict(a=1.03234,b=3453))
drctview.pull('a')
drctview.pull('b', targets=0)
drctview.pull(('a','b'))
drctview.push(dict(c='speed'))
drctview.pull(('a','b','c'))
drctview.block = False
rslt = drctview.pull(('a','b','c'))
rslt.get()

drctview.scatter('a',range(16))
drctview['a']
drctview.gather('a')

def paralleldot(vw, mat1, mat2):
    vw['mat2'] = mat2
    vw.scatter('mat1', mat1)
    vw.execute('mat3=mat1.dot(mat2)')
    return vw.gather('mat3', block=True)
a = np.matrix('1 2 3; 4 5 6; 7 8 9')
b = np.matrix('4 5 6; 7 8 9; 10 11 12')
paralleldot(drctview, a,b)

下面的程序演示了首先从客户端向引擎推送对象，然后从引擎拉回结果到客户端。程序在所有引擎上计算两个矩阵的点乘，再收集结果。同时，程序还通过allclose()方法证明结果都是一样的，如果都一样就会返回True。在程序的execute命令中，添加print mat3语句是为了后面用display_outputs()函数在屏幕上显示所有引擎的输出结果：

import numpy as np
from IPython.parallel import Client
ndim = 5
mat1 = np.random.randn(ndim, ndim)
mat2 = np.random.randn(ndim, ndim)
mat3 = np.dot(mat1,mat2)
clnt = Client(profile='testprofile')
clnt.ids
dvw = clnt[:]
dvw.execute('import numpy as np', block=True)
dvw.push(dict(a=mat1, b=mat2), block=True)
rslt = dvw.execute('mat3 = np.dot(a,b); print mat3', block=True)
rslt.display_outputs()
dot_product = dvw.pull('mat3', block=True)
print dot_product
np.allclose(mat3, dot_product[0])
np.allclose(dot_product[0], dot_product[1])
np.allclose(dot_product[1], dot_product[2])
np.allclose(dot_product[2], dot_product[3])

8.4.4　支持数据库存储请求与结果

IPython集线器会存储任务请求与结果给后面的程序使用。默认情况下，它都使用SQLite数据库，现在还支持MongoDB数据库和一种叫DictDB的内存数据库。用户可以在配置文件里设置数据库类型。在配置文件夹里，有一个ipcontroller_config.py文件。可以通过ipcluster命令启动配置文件。文件里有c.HubFactory.db_class选项，用户可以设置自己想用的数据库，如下所示：

# 配置dictdb，字典形式内存数据库
c.HubFactory.db_class = 'IPython.parallel.controller.dictdb.DictDB'
# 配置MongoDB:
c.HubFactory.db_class = 'IPython.parallel.controller.mongodb.MongoDB'
# 配置SQLite:
c.HubFactory.db_class = 'IPython.parallel.controller.sqlitedb.SQLiteDB'

默认属性值是NoDB，表示没使用任何数据库。如果用户想获取任何已执行任务的结果，可以在客户端对象上调用get_result函数。不过客户端对象还有一个更好的函数db_query()。这个方法是按照MongoDB查询方式设计的，它通过一个词典对象查询，词典的键就是带准确值的TaskRecord键，或者MongoDB的查询。这些参数的语法是按照{'operator' : 'argument (s)'}这类形式。还有一个可选的参数，名字是keys。这个参数用于指定需要获取的键。它会返回一个TaskRecord词典列表。默认情况下，会返回所有的键，除了引擎缓存中的请求和结果。与MongoDB类似，msg_id键也会出现在里面。一些TaskRecord键的含义解释如下。

msg_id：这个值是uuid（字节）类型， 表示消息的ID。

header：这个值是dict类型，存放请求头信息。

content：这个值是dict类型，存放请求内容，通常为空。

buffers：这个值是list（字节）类型， 请求对象的缓存列表。

Submitted：这个值是datetime类型，存放任务的时间戳。

client_uuid：客户端的uuid值（universally unique identifier，通用唯一标识符）。

engine_uuid：这个值是uuid（字节）类型，计算引擎套接字的uuid值。

started：这个值是datetime类型，存放引擎开始计算的时间。

completed：这个值是datetime类型，存放引擎完成计算的时间。

resubmitted：这个值是datetime类型，存放引擎（异常中断后）恢复计算的时间。

result_header：这个值是dict类型，存放计算结果的请求头。

result_content：这个值是dict类型，存放计算结果的内容。

result_buffers：这个值是list（字节）类型，计算结果对象的缓存列表。

queue：这个值是bytes类型，任务队列的名称。

stdout：标准输出（standard output）数据流。

stderr：标准错误（standard error）数据流。

下面的程序演示了db_query()和get_result()获取结果的用法。

from IPython import parallel
from datetime import datetime, timedelta
clients = parallel.Client(profile='testprofile')
incomplete_task = clients.db_query({'complete' : None}, keys=['msg_\
id', 'started'])
one_hourago = datetime.now() - timedelta(1./24)
tasks_started_hourago = clients.db_query({'started' : {'$gte' : one_\
hourago },'client_uuid' : clients.session.session})
tasks_started_hourago_other_client = clients.db_query({'started'
: {'$le' : hourago }, 'client_uuid' : {'$ne' : clients.session.
session}})
uuids_of_3_n_4 = map(clients._engines.get, (3,4))
headers_of_3_n_4 = clients.db_query({'engine_uuid' : {'$in' : uuids_\
of_3_n_4 }}, keys='result_header')

下面是db_query方法里可用的关系运算符，与MongoDB一样。

'$in'：元素在列表/序列中。

'$nin'：元素不在列表/序列中。

'$eq'：表示等于（==）。

'$ne'：表示不等于（!=）。

'$gt'：表示大于（>）。

'$gte'：表示大于等于（>=）。

'$lt'：表示小于（<）。

'$lte'：表示小于等于（<=）。

8.4.5　在IPython里使用MPI

通常，多引擎的并行算法都需要在引擎之间进行数据交换。我们在前面已经介绍了IPython数据交换的方法。然而，因为那不是引擎与客户端的直接数据交换方式，数据都需要经过控制器的调度，所以速度比较慢。另一种高性能的数据交互方式是通过MPI。IPython并行计算架构可以完美支持MPI。要在IPython用MPI实现并行计算，需要安装OpenMPI或MPICH2/MPICH和mpi4py软件包。安装之后，可以通过mpiexec和mpirun命令检测是否安装成功。

安装测试完成之后，在运行真正的MPI程序之前，用户需要创建一个配置文件：

ipython profile create --parallel --profile=mpi

配置文件创建之后，把下面这行代码加入profile_mpi文件夹的ipcluster_config.py文件中：

c.IPClusterEngines.engine_launcher_class = 'MPIEngineSetLauncher'

现在系统已经可以在IPython上运行MPI程序了。用户可以通过下面的命令启动计算集群：

ipcluster start -n 4 --profile=mpi

上面的命令会启动IPython控制器，并通过mpiexec命令启动四个引擎。

下面的程序定义了一个函数，分布式计算一个数组的和。把程序命名为parallelsum.py，它将在后面程序中使用：

from mpi4py import MPI
import numpy as np

def parallelsum(arr):
    localsum = np.sum(arr)
    receiveBuffer = np.array(0.0,'d')
    MPI.COMM_WORLD.Allreduce([localsum, MPI.DOUBLE],
        [receiveBuffer, MPI.DOUBLE],
        op=MPI.SUM)
    return receiveBuffer

在下面的程序中使用前面定义的函数，在多个引擎上计算。下面就是并行数组求和的程序：

from IPython.parallel import Client
clients = Client(profile='mpi')

drctview = clients[:]
drctview.activate()
# 将程序的文件名作为参数运行计算
drctview.run(parallelsum.py.py')
drctview.scatter('arr',np.arange(20,dtype='float'))
drctview['arr']
# 调用函数
%px sum_of_array = parallelsum(arr)
drctview['sum_of_array']

8.4.6　管理任务之间的依赖关系

IPython对任务之间依赖关系的管理有着强有力的支持。在大多数科学与商业计算领域，仅仅负载均衡不足以解决计算的复杂性。应用还需要管理任务之间的依赖关系。这些依赖包括计算过程中需要的软件、Python模块、操作系统、硬件，在一群任务中执行单个任务所需的运行顺序、时间、空间。IPython支持两种依赖关系管理方式：函数依赖（functional dependency）和图依赖（graph dependency）。

1. 函数依赖关系

函数依赖用于确定一个引擎是否有能力运行一个任务。这个概念通过IPython.parallel.error里的一个UnmetDependency异常实现。如果任务运行失败并触发UnmetDependency异常，调度器不会把异常发送到客户端，而是自动处理异常并把失败任务提交到其他引擎上。调度器会重复这个过程直到找出适合的引擎。另外，调度器不会向同一个引擎提交两次。

函数依赖装饰器

虽然用户也可以手动触发UnmetDependency异常，但是IPython还是提供了两个装饰器来管理依赖关系。

@require：这个装饰器管理那些当被装饰函数被调用时，需要在引擎里使用特殊的Python模块、局部函数或局部对象的任务的依赖关系。函数将通过名称推送到引擎里，对象可以通过arg关键词传递。我们可以传递执行任务需要的所有Python模块的名称。通过这个装饰器，用户可以自定义一个函数，只在那些装饰器里的模块名称是可用的、可导入的引擎上运行。

例如，下面代码中的函数依赖NumPy和pandas模块，里面需要使用NumPy的randn和pandas的Series。假如有个任务需要调用该函数，那么该函数会在已经导入这两个模块的机器上运行。一旦函数被调用，NumPy和pandas模块就会导入：

from IPython.parallel import depend, require
# 下面函数用randn和Series
@require('pandas', 'numpy')
def func_uses_functions_from_numpy_pandas():
  return performactivity()

@depend：这个装饰器让用户可以定义一个与其他函数有依赖关系的函数。它可以判定依赖关系是否满足。在任务运行之前，依赖函数先被调用。如果函数返回true，那么任务的执行过程就会开始。如果依赖函数返回false，说明依赖关系未得到满足，于是任务将被分配给其他引擎。

下面的代码块创建了一个依赖函数，检查引擎的操作系统是否匹配当前操作系统。这么做是因为用户想写两个不同的函数，分别在Linux和Windows操作系统上完成不同的任务：

from IPython.parallel import depend, require
def find_operating_system(plat):
  import sys
  return sys.platform.startswith(plat)
@depend(find_operating_system, 'linux')
def linux_specific_task():
  perform_activity_on_linux()
@depend(find_operating_system, 'win')
def linux_specific_windows():
  perform_activity_on_windows()

2. 图依赖关系

这是另一种重要的依赖关系，任务彼此之间相互依赖，当若干或所有任务都已经成功运行后，一个任务才运行。还有一种依赖关系是：一个任务必须在指定的若干依赖已经得到满足时才执行。一般情况下，用户在执行任务之前，需要设置具体的时间和位置，也作为其他任务的时间、位置和结果的函数。Dependency类是IPython管理图依赖关系的专用类，Dependency是Set类的子类。它包括任务的信息ID号和一些属性。这些属性可以帮助用户检查依赖关系是否得到满足。

any|all：这些属性决定每个依赖是否都需要满足。所有依赖设置的默认值都是True。

success：这个属性的默认值是True，表示如果指定的任务成功地运行，就认为依赖关系得到了满足。

failure：这个属性的默认值是False，表示如果指定的任务运行失败，就认为依赖关系得到了满足。

after：这个属性表示指定的任务运行之后，应执行相关任务。

follow：follow属性表示指定的任务应该与依赖任务一样，在同一位置运行。

timeout：这个属性表示调度器等待依赖被满足的时限要求。默认是0，表示任务可以无限等待依赖。当超过时限要求之后，会触发DependencyTimeout异常，对应的任务运行失败。

有一些任务的功能是清理失败任务。只有指定任务运行失败时，它们才会启动。用户需要用failure=True,success=False来启动这些任务。对于一些有依赖关系的任务，需要那些依赖关系都完全得到满足。在这种情况下，用户需要把函数设置成success=True和failure=False。有时用户也需要有依赖关系的任务可以独立地运行，不去考虑依赖任务运行的成功与失败。这时，用户需要设置函数为success=failure=True。

3. 不可能满足的依赖关系

有一些依赖关系不可能满足。如果调度器搞不定这些依赖关系，它就会一直傻傻地等啊等，期待着依赖关系被满足，这显然不合理。为了解决这个问题，调度器会提前分析图依赖关系，评估依赖关系可以满足的可能性。如果调度器经过评估发现依赖关系不可能得到满足，就会触发ImpossibleDependency错误。下面的程序演示了如何管理任务之间的图依赖关系：

from IPython.parallel import *
clients = ipp.Client(profile='testprofile')
lbview = clients.load_balanced_view()

task_fail = lbview.apply_async(lambda : 1/0)
task_success = lbview.apply_async(lambda : 'success')
clients.wait()
print("Fail task executed on %i" % task_fail.engine_id)
print("Success task executed on %i" % task_success.engine_id)

with lbview.temp_flags(after=task_success):
    print(lbview.apply_sync(lambda : 'Perfect'))

with lbview.temp_flags(follow=pl.Dependency([task_fail, task_success], failure=True)):
    lbview.apply_sync(lambda : "impossible")

with lbview.temp_flags(after=Dependency([task_fail, task_success], failure=True, success=False)):
    lbview.apply_sync(lambda : "impossible")

def execute_print_engine(**flags):
    for idx in range(4):
        with lbview.temp_flags(**flags):
            task = lbview.apply_async(lambda : 'Perfect')
            task.get()
            print("Task Executed on %i" % task.engine_id)

execute_print_engine(follow=Dependency([task_fail, task_success], all=False))
execute_print_engine(after=Dependency([task_fail, task_success], all=False))
execute_print_engine(follow=Dependency([task_fail, task_success], all=False, failure=True, success=False))
execute_print_engine(follow=Dependency([task_fail, task_success], all=False, failure=True))

4. DAG依赖关系与NetworkX函数库

一般情况下，用有向无环图（Directed Acyclic Graph，DAG）表示并行工作流更合适。Python的知名画图程序库是NetworkX。有向无环图是由节点和有向边组成的。边连接不同的节点，每条边都有方向。我们可以用这个概念表示依赖关系。例如，edge(task1, task2)是从任务1到任务2的边，表示任务2依赖于任务1。而edge(task2, task1)表示任务1依赖于任务2。这个图里不能有环，所以称为无环图。

下面让我们看看一个六节点DAG。任务0不依赖于任何任务，所以它可以立即执行。但是，任务1和任务2依赖于任务0，因此它们会在任务0完成之后运行。而任务3依赖于任务1和任务2，因此它在任务1和任务2运行结束之后才会运行。同理可得，任务4和任务5在任务3运行结束之后才会运行。任务6仅仅依赖任务4，因此它会在任务4运行完成后运行。

下面的程序是上面DAG的描述。程序中，任务用数字表示，任务0用0表示，任务1用1表示，以此类推：

import networkx as ntwrkx
import matplotlib.pyplot as plt

demoDAG = ntwrkx.DiGraph()
map(demoDAG.add_node, range(6))
demoDAG.add_edge(0,1)
demoDAG.add_edge(0,2)
demoDAG.add_edge(1,3)
demoDAG.add_edge(2,3)
demoDAG.add_edge(3,4)
demoDAG.add_edge(3,5)
demoDAG.add_edge(4,6)
pos = { 0 : (0,0), 1 : (-1,1), 2 : (1,1), 3 : (0,2), 4 : (-1,3), 5 : (1, 3), 6 : (-1, 4)}
labels={}
labels[0]=r'$0$'
labels[1]=r'$1$'
labels[2]=r'$2$'
labels[3]=r'$3$'
labels[4]=r'$4$'
labels[5]=r'$5$'
labels[6]=r'$6$'

ntwrkx.draw(demoDAG, pos, edge_color='r')
ntwrkx.draw_networkx_labels(demoDAG, pos, labels, font_size=16)
plt.show()

下面的程序创建了带颜色的DAG，顶点会显示任务的名称：

import networkx as ntwrkx
import matplotlib.pyplot as plt
demoDAG = ntwrkx.DiGraph()
map(demoDAG.add_node, range(6))
pos = {0: (0, 0), 1: (-1, 1), 2: (1, 1), 3: (0, 2), 4: (-1, 3),
       5: (1, 3), 6: (-1, 4)}

ntwrkx.draw(demoDAG, pos)
ntwrkx.draw_networkx_edges(
    demoDAG, pos, edgelist=[(0, 1), (0, 2),
        (1, 3), (2, 3), (3, 4)], edge_color='r')
ntwrkx.draw_networkx_edges( \
    demoDAG, pos, edgelist=[(3, 5), (4, 6)], edge_color='b')
ntwrkx.draw_networkx_nodes(
    demoDAG, pos, nodelist=[0, 1, 2, 3, 4],
        node_color='r', nodesize=500, alpha=0.8)
ntwrkx.draw_networkx_nodes(
    G, pos, nodelist=[5, 6], node_color='b', node_size=500, alpha=0.8)

labels = {}
labels[0] = r'$0$'
labels[1] = r'$1$'
labels[2] = r'$2$'
labels[3] = r'$3$'
labels[4] = r'$4$'
labels[5] = r'$5$'
labels[6] = r'$6$'

ntwrkx.draw_networkx_labels(demoDAG, pos, labels, font_size=16)
plt.show()

8.4.7　用Amazon EC2的StarCluster启动IPython

Amazon的StarCluster可以在Amazon的弹性计算云（Elastic Compute Cloud，EC2）上非常方便地使用虚拟机计算集群。StarCluster是一个开源工具箱，用于在Amazon EC2上进行集群计算。除了自动配置集群计算之外，StarCluster还可以自定义Amazon机器镜像（Amazon Machine Images，AMIs），支持安装科学计算和软件开发的工具和程序库。这些AMI可以由ATLAS、IPython、NumPy、OpenMPI、SciPy等软件组成。用户可以在安装了StarCluster的机器上通过下面的命令获取AMI列表：

starcluster listpublic

StarCluster的操作界面十分简单直观，方便管理计算集群和存储空间。安装完之后，用户需要更新配置文件，加入Amazon EC2的账户信息，包括IP地址、地区、证书、公私密钥对。

安装配置完成之后，用户可以通过下面的命令控制Amazon EC2安装IPython：

starcluster shell --ipcluster=clusterName

如果安装过程出现任何错误，上面的命令都会提示。如果配置正确，命令就会启动StarCluster命令行并在Amazon EC2的远程集群上创建并行会话任务。StarCluster会根据ipclient变量内容作为名称，创建并行客户端，并以ipview变量内容为名称创建整个计算集群的视图。用户可以通过这些变量（ipclient和ipview）在Amazon EC2集群上运行并行任务。下面的程序用ipclient显示引擎的ID号，并用ipview执行了一个简单的并行任务：

ipclient.ids
result = ipview.map_async(lambda i: i**5, range(26))
print result.get()

用户还可以通过StarCluster运行IPython并行脚本。如果用户想通过本地的IPython会话运行Amazon EC2远程集群上的并行脚本，那么创建并行客户端时需要在本地进行一些配置：

from IPython.parallel import Client
remoteclients = Client('<userhome>/.starcluster/
ipcluster/<clustername>-<yourregion>.json', sshkey='/path/to/cluster/keypair.rsa')

举例说明，假如集群名称是packtcluster，地区名称是us-west-2，密钥keypair的名称是packtKey，文件路径为/home/user/.ssh/packtKey.rsa。那么上面的代码将改为如下形式：

from IPython.parallel import Client
remoteclients = Client('/home/user/.starcluster/ipcluster/
packtcluster-us-west-2.json', sshkey='/home/user/.ssh/packtKey.rsa')

这三行代码运行之后，其他代码就可以在Amazon EC2的远程集群上运行了。

8.5　IPython数据安全措施

在设计IPython架构时，也十分重视网络安全问题。客户端认证模型是通过SSH加密的TCP/IP连接，可以管理大部分的安全问题，方便用户在公网使用IPython计算集群。

由于ZeroMQ没有提供网络安全功能，因此使用SSH加密隧道保证安全连接。Client对象通过ipcontroller-client.json文件获取与控制器的连接，然后通过OpenSSH/Paramiko创建加密连接隧道。

它还使用了HMAC签名信息的概念，通过共享键保护用户在共享机器上的数据。有一个专门的会话对象处理签名信息的协议。会话对象通过唯一键验证消息的有效性。默认情况下，键使用128位伪随机数，类似于uuid.uuid4()生成的数据。一般情况下，IPython客户端在并行计算过程中，会向IPython引擎发送Python函数、命令和数据。IPython可以保证只有认证的客户端可以接入并使用引擎。引擎的能力和权限也完全是启动它的用户授予的。

为了阻止未经授权的接入，认证和键相关信息都是JSON文件格式，让IPython客户端接入控制器。用户可以限制键的接入数量，以控制授权用户的数量。

8.5.1　常用并行编程方法

随着计算机软硬件性价比的不断提升，并行程序可以通过多种形式设计、开发与实现。我们可以通过并发、并行和分布式三种方式实现。前面提到的任何一种技术都可以实现高效运行的高性能程序。下面将介绍这些模式以及与它们相关的共同问题。

1. 并行编程经典问题

所有模式都是在不同的计算单元（CPU与计算节点）中执行程序的不同部分。一般情况下，这些模式都是把程序分成多个worker进程，每个worker进程都在不同的计算单元上运行。如果不考虑这些模式的性能，这种使用多个worker进程的程序执行方式会造成不同任务间通信困难。这个问题就是典型的进程间通信（Inter-process Communication，IPC）问题。

一些经典的IPC问题需要开发者时刻注意，如死锁（deadlock）、饥饿（starvation）和竞争状态（race condition）。

死锁

死锁是指两个或多个worker进程处于无限制的等待状态，等待其他worker进程释放正在占用的资源。死锁有四个充分必要条件，分别是相互排斥（mutual exclusion）、持有等待（hold and wait）、非抢占式（no pre-emption）以及循环等待（circular wait）。如果程序在运行过程中出现了以上四种条件，就会被阻塞，不能继续运行。

相互排斥是指资源是不能共享的，只能一个worker进程使用。

持有等待是指死锁的worker进程持有部分资源，并请求其他资源到来。

非抢占式是指已经分配给一个worker进程的资源不能再分配给其他worker进程使用。

循环等待是指程序中等待资源的worker进程形成了一个链或循环列表，其中每一个worker进程都在等待前一个worker进程释放资源。

饥饿

当多个worker进程争抢一个资源时就会发生饥饿（游戏）状态。这时，每个worker进程都被配置了一个获取资源的优先级。有时，优先级分配得不合理，就会导致一些worker进程等待时间非常长。在这种情况下共有两种worker进程在竞争：高优先级的worker进程和低优先级的worker进程。如果高优先级的worker进程连续地请求资源，就会导致低优先级的worker进程陷入无限漫长的等待。

竞争状态

当多个worker进程同时对一块数据进行读/写操作时，每个worker进程的操作之间没有同步，就会出现竞争状态。例如，两个worker进程同时读取数据库的一块数据，修改数据值，然后再写回数据库。如果没有合理的同步顺序，那么数据库就会出现前后不一致的状态。

有一些方法可以解决这些问题，不过那些方法的具体内容超出了本书的介绍范围。如果感兴趣的话，可以看看其他相关的多核编程书籍。下面来介绍并行计算的各个类型。

2. 并行编程

在并行程序的开发模式中，程序被分成多个worker进程运行在多个独立的CPU上，无需竞争一个CPU的资源，如下图所示。这些CPU可以是一台计算机的多核处理器，也可以是在多台单独的计算机上，通过消息传递接口连接。

3. 并发编程

在并发编程模式中，用户程序的多个worker进程运行在一个CPU或者数量少于worker进程数量的CPU上（如下图所示）。这些worker进程会在CPU调度器的管理下争抢CPU资源。CPU调度器会用不同的调度机制分配worker进程给CPU。CPU调度器机制会创建worker进程的级别，worker进程将按照级别的次序运行。

并发编程模式中的worker进程，一般都用多进程或多线程来实现。进程或线程都并发地完成主程序的一部分。线程与进程的主要区别在于：线程是内存共享的，占用资源少，一个进程可以生成多个线程。因此，线程也被称为轻量级进程。

4. 分布式编程

在分布式编程模式中，worker进程通过网络在不同的机器上运行。有不同的框架执行这类程序。网络还会用不同的拓扑结构，在有些情景中，调度机制数据和处理过程都是分布式的。这种并行计算模式越来越受欢迎，因为它优点明显，比如单个节点成本低、容错效果好、高度扩展性，等等。在分布式计算模式中，每个节点都有独立的内存和处理器资源，而在并行编程模式中，处理器/CPU共享同样的内存资源。

分布式程序的运行

5. Python多进程

Python多进程可以在多核电脑的多个CPU上创建和运行多个独立运行的进程。Python提供了两个重要的模型支持多进程：一种是基于Process类，另一种是基于Pool类。

下面的程序演示了用Process类实现多进程：

import multiprocessing as mpcs
import random
import string

output_queue = mpcs.Queue()

def strings_random(len, output_queue):
    generated_string = ''.join(random.choice(string.ascii_lowercase +
                        string.ascii_uppercase + string.digits)
                        for i in range(len))
    output_queue.put(generated_string)

procs = [mpcs.Process(target=strings_random, args=(8, output_queue))
            for i in range(7)]

for proc in procs:
    proc.start()

for proc in procs:
    proc.join()

results = [output_queue.get() for pro in procs]
print(results)

基于Process类的多进程按照进程完成的先后顺序返回结果。如果用户需要结果按照既定的顺序生成，那么需要费一番功夫，如下面的程序所示。为了获取有序的结果，必须在函数中增加一个参数，最后再输出到结果中。这个参数表示进程的位置或顺序，最后结果被存储在参数中。下面的程序基于Process类演示了这种思路，结果会带一个位置参数：

import multiprocessing as mpcs
import random
import string

output_queue = mpcs.Queue()

def strings_random(len, position, output_queue):
    generated_string = ''.join(random.choice(string.ascii_lowercase +
                        string.ascii_uppercase + string.digits)
                        for i in range(len))
    output_queue.put((position, generated_string))

procs = [mpcs.Process(target=strings_random, args=(5, pos, output))
        for pos in range(4)]

for proc in procs:
    proc.start()
for proc in procs:
    proc.join()

results = [output_queue.get() for pro in procs]
results.sort()
results = [rslt[1] for rslt in results]
print(results)

Pool类为并行计算提供了map和view方法，它还支持这些方法的异步版本。它将会在进程完成之前对主程序加锁，这样就可以保证程序输出的结果是按照顺序排列的。

6. Python多线程

Python多线程允许用户在一个进程中创建多个线程完成并发计算。一个进程的所有线程会和主进程/线程共享同样的数据存储空间，这样可以共享数据，方便互相通信。线程也称为轻量级进程，因为它们所需的内存比进程少。

下面的程序演示如何创建和启动线程：

import threading
import time
class demoThread (threading.Thread):
    def __init__(self, threadID, name, ctr):
        threading.Thread.__init__(self)
        self.threadID = threadID
        self.name = name
        self.ctr = ctr
    def run(self):
        print "Start of The Thread: " + self.name
        print_time(self.name, self.ctr, 8)
        print "Thread about to Exit:" + self.name

def print_time(threadName, delay, counter):
    while counter:
        time.sleep(delay)
        print "%s: %s" % (threadName, time.ctime(time.time()))
        counter -= 1

thrd1 = demoThread(1, "FirstThread", 4)
thrd2 = demoThread(2, "SecondThread", 5)
thrd1.start()
thrd2.start()
print "Main Thread Exits"

这个程序创建了两个线程。如果你观察输出结果，会发现结果的输出顺序混乱。Main Thread Exits语句会先显示，随后Thread about to Exit: ThreadName语句会随机显示。

我们可以通过线程同步方法来控制输出结果，保证线程按照需要的顺序完成。下面的程序首先运行第一条线程，结束后才运行第二条线程，最后主线程才退出。保证线程结束顺序的方法是，线程启动前先获取线程锁，线程结束后释放线程锁，这样新线程就可以获取。主线程通过join方法调用所有线程对象。这个方法会在其他线程结束之前阻塞主线程：

import threading
import time
class demoThread (threading.Thread):
    def __init__(self, threadID, name, ctr):
        threading.Thread.__init__(self)
        self.threadID = threadID
        self.name = name
        self.ctr = ctr
    def run(self):
        print "Start of The Thread: " + self.name
        threadLock.acquire()
        print_time(self.name, self.ctr, 8)
        print "Thread about to Exit:" + self.name
        threadLock.release()

def print_time(threadName, delay, counter):
    while counter:
        time.sleep(delay)
        print "%s: %s" % (threadName, time.ctime(time.time()))
        counter -= 1

threadLock = threading.Lock()
thrds = []

thrd1 = demoThread(1, "FirstThread", 4)
thrd2 = demoThread(2, "SecondThread", 5)
thrd1.start()
thrd2.start()

thrds.append(thrd1)
thrds.append(thrd2)
for thrd in threads:
    thrd.join()

print "Main Thread Exits"

8.5.2　在Python中演示基于Hadoop的MapReduce

Hadoop是一种在计算集群上对大数据进行分布式存储与处理的开源框架。Hadoop由三部分组成：负责数据处理的MapReduce，Hadoop分布式文件系统（Hadoop Distributed File System，HDFS），负责数据存储的大型数据库HBase。HDFS可以存储非常大的数据集文件。它可以把数据文件分割成多个文件块，然后保存文件块与计算节点对应关系的索引信息。HBase是一种支持大数据、基于HDFS开发的数据库。它是一种开源、列导向、非关系型分布式数据库。

MapReduce是一种在计算集群上对大数据进行分布式处理的开源框架。Hadoop是对MapReduce计算框架的开源实现。MapReduce程序由两部分组成：map和reduce。map函数对输入数据文件进行过滤，然后把它的计算结果写到文件系统。之后，再用reduce函数进行汇总，最后再把计算结果输出到文件系统。MapReduce框架是一种单程序多数据模式（Single Program, Multiple Data，SPMD），在多个数据集上做同样的数据处理。

在Hadoop系统中，完整的功能被分成多个组件。有两个主节点。一个是任务跟踪器（Job Tracker），主要功能是跟踪从动节点的map和reduce过程，从动节点称为任务跟踪节点（task tracker node）。另一个是名称节点（namenode），主要功能是管理分割文件集的文件块与从动节点（称为数据节点）的关联关系。为了防止单节点发生异常导致任务失败，用户通常还会安装备份名称节点。建议在运行实际的map和reduce数据处理时，使用大量既作为任务跟踪节点又作为数据节点的从动节点。每一个从动节点既是任务跟踪节点，又是数据节点。MapReduce应用的性能与从动节点的数量成正比。Hadoop系统还可以自动宕机恢复：如果一个任务跟踪节点在数据处理过程中宕机了，Hadoop会自动把任务分配到其他任务节点上，数据处理过程不会中断。

下面的程序是在Python中演示基于Hadoop的MapReduce的开发过程。它对一个普通的爬虫数据集进行处理。这些数据集包括长期抓取的若干PB网络数据。里面包括网页数据、抽取的元数据以及苹果系统文件（Web ARChive，WARC）格式的文本数据。这些数据存储在Amazon S3云存储里，作为Amazon公开数据集程序的一部分。关于这个数据集的更多信息可以在http://commoncrawl.org/the-data/get-started/上看到。

import sys
for line in sys.stdin:
  try:
      line = line.strip()
      # 把一长分割成单词
      words = line.split()
      # 增加计数器
      if words[0] == "WARC-Target-URI:" :
          uri = words[1].split("/")
          print '%s\t%s' % (uri[0]+"//"+uri[2], 1)
  except Exception:
      print "There is some Error"

上面的程序就是map部分，下面的程序是reduce部分：

from operator import itemgetter
import sys

current_word = None
current_count = 0
word = None

for line in sys.stdin:
    line = line.strip()

    word, count = line.split('\t', 1)

    try:
        count = int(count)
    except ValueError:
        continue

    if current_word == word:
        current_count += count
    else:
        if current_word:
            print '%s\t%s' % (current_word, current_count)
        current_count = count
        current_word = word

if current_word == word:
    print '%s\t%s' % (current_word, current_count)

运行前面的程序之前，用户需要先把输入数据集文件web-crawl.txt放到HDFS home文件夹中。用下面的命令可以执行两个程序：

#hadoop jar /usr/local/apache/hadoop2/share/hadoop/tools/lib/hadoop-streaming- 2.6.0.jar -file /mapper.py -mapper /mapper.py -file /reducer.py -reducer / reducer.py -input /sample_crawl_data.txt -output /output

8.5.3　在Python中运行Spark

Spark是一个多用途的集群计算系统。它的高级API可以支持Java、Python和Scala语言。这样可以非常轻松地写出并行程序。它的设计与Hadoop两阶段、来回在硬盘倒文件的MapReduce相反。Spark是一种内存模型，在一些应用场景中可以实现最多100%的性能提升。它非常适合实现机器学习的应用与算法。

Spark需要集群管理和一个分布式存储系统。它为许多分布式存储系统都提供了简单的接口， 如Amazon S3、Cassandra和HDFS，等等。另外，它还可以单机运行，即可以对Spark原生集群（Spark native cluster）、Hadoop、YARN和Apache Mesos进行集群管理。

Spark的Python API称为PySpark，通过它就可以用Python进行Spark编程。可以在用PySpark打开的命令行中用Python编写Spark程序，也可以在IPython会话中进行。还可以先开发程序，然后通过pyspark命令运行程序。

8.6　小结

本章介绍了用IPython进行高性能科学计算的相关概念。首先介绍了并行计算的基本概念，然后介绍了IPython并行计算的具体结构。之后，演示了简单并行程序的开发、IPython魔法函数以及并行装饰器。

本章还介绍了IPython的高级特性：容错机制，动态负载均衡，任务之间的依赖关系管理，客户端与引擎之间对象的移动，IPython的数据库支持，在IPython中使用MPI，用StarCluster通过IPython管理Amazon EC2远程集群。随后，还介绍了Python的多进程和多线程编程。最后，简单介绍了使用Hadoop和Spark在Python中开发分布式应用的方法。

下一章将介绍一些用Python的科学计算工具和API解决真实问题的案例，还将介绍不同基础与高级科学领域中的若干应用。

第 9 章　真实案例介绍

本章将介绍一些通过Python设计与开发的科学计算应用、API/程序库和工具的例子。

即将介绍的Python应用主要涉及以下科学领域：

专业领域硬件/软件

气象学应用

设计与建模

高能物理应用

计算化学

生物学

嵌入式系统

这些应用、工具和程序库涵盖社会、科学与商业领域，包括NGO应用、科学教育的软硬件、气象学应用等。还包括航空器的概念设计程序库、地震风险评估的应用以及生产制造过程中的能源利用率监控软件。除此之外，还有高能物理分析代码生成器、计算化学应用、盲音触觉识别系统、空中交通管制应用、节能灯嵌入式系统、船舶设计程序库以及分子建模工具包。

9.1　用Python开发的科学计算应用

Python是开发科学计算应用的主流语言，尤其适合开发那些低成本、高性能的应用。后面几个小节将介绍Python在这方面的应用、工具和产品。

9.1.1　“每个孩子一台笔记本”项目用Python开发界面

每个孩子一台笔记本（One Laptop per Child，OLPC）是麻省理工学院（MIT）发起的项目。这个项目得到了许多软硬件开发者的大力支持，并通过强大的Python社区实现了OLPC的使命。该项目背后的理念是开发装有创新性软硬件的、低价格教学用笔记本电脑。OLPC的使命简单且令人信服，就是通过一台安装了可以协作学习的软件和应用且低价格、低功耗的笔记本电脑，让贫穷的孩子获得教育的机会。这个使命的主要目标就是要生产和发布一台低成本、低功耗的笔记本OLPC XO。这台笔记本是由中国台湾的量子计算机公司（Quanta Computer）制造的。和其他笔记本不同的是，这台机器使用闪存，没用硬盘，而且安装Fedora的Linux发行版，还支持802.11s通信协议的无线网。XO笔记本的照片如下图所示。

图片来源：http://images.flatworldknowledge.com/lule/lule-fig13_004.jpg

Sugar是一个自由开源的桌面环境，主要用于交互式学习，它是XO系统的交互界面。Sugar没有桌面、文件夹和窗口的概念。它是从主视图开始，用户可以从主视图屏幕中选择不同的活动（activity）。Sugar里的应用都被称为activity1。activity包括应用以及共享和协作的能力。为了保存Sugar应用的状态和历史，Sugar开发了一个日志功能为用户恢复应用状态。日志会自动记录用户的会话任务，并提供了接口来按照日期恢复历史状态。每个activity都可以与内置的日志功能以及其他功能的接口进行对接，例如剪贴板。Sugar的activity都是全屏，一次只能运行一个程序。

1和Android系统类似。——译者注

Sugar可以支持不同的系统平台。

XO笔记本：XO笔记本以Sugar为默认界面。

CD启动盘和USB启动盘：Sugar也可以直接通过CD和USB启动。

Linux发行版的软件包：Sugar也是各种Linux发行版的软件包——也是一种桌面环境。

虚拟镜像：Sugar可以通过虚拟机安装在Windows和苹果操作系统上2。

2也有Docker版本。——译者注

在Sugar中，Python可以开发各种各样的Sugar应用/activity。开发者可以使用Python扩展Sugar的功能，向里面增加新应用/activity。Sugar的主视图如下图所示。

图片来源：http://2.bp.blogspot.com/_PPJgknwAe5o/S_8kh3r1qII/AAAAAAAAAGk/qmJdLae1pQ8/s1600/2009-SugarLabs-Homeview.png

启动XO笔记本之后，它就可以自由地获取Python社区的强大支持。它还是一个开源软件，开发者可以理解和改善它。XO笔记本有一个高分辨率、易于阅读的显示屏，而且支持多种语言的电子书模式。XO笔记本的电子书模式如下图所示。

图片来源：http://regmedia.co.uk/2008/01/16/ebook.jpg

9.1.2　ExpEYES——科学之眼

印度的大学联合加速中心（Inter University Accelerator Centre，IUAC）有一个项目是自制设备与创新物理实验（Physics with Homemade Equipment and Innovative Experiments，PHOENIX）。其主旨是要通过实验改善科学教育的质量。项目的主要目的就是开发低成本的实验设备。另一个分支项目是青年工程师与科学家实验（Experiments for Young Engineers and Scientists，ExpEYES），其重点是通过做实验学习知识。ExpEYES适合高中和更高年级的学生使用。满足其主要目标的设计是一个低成本的设备3。这个设备通过5 V的USB电源供电。

3单片机。——译者注

要使用ExpEYES，首先需要在电脑上安装驱动软件，然后通过USB线将它连接到电脑上。在它的两侧共有32个I/O接口可以连接外部信号。用户可以控制和监视设备的电压。要测量力量、气压、温度等信息，用户可以通过传感器将信息转换成电信号。例如，温度传感器会通过电压显示不同的温度值。ExpEYES的外观如下图所示。

图片来源：http://expeyes.in/sites/default/files/images/diode-rectifier-photo.jpg

真正的学习过程需要不断地探索并动手实验。物理实验需要控制与测量不同的参数，例如加速度、电流、力、气压、温度、速度、电压等物理量。有时也需要自动测量一些快速变化的物理量（例如AC交流电压）。这些自动化测量技术需要计算机的参与。

用一个Python解释器和Python模块接入串行端口，这在任何电脑上运行ExpEYES都是必备的。设备驱动程序会识别USB端口，驱动会把USB端口作为应用程序的RS232端口。ExpEYES的通信部分用Python语言写的程序库处理。还有GUI程序可以支持每一个实验。用户还可以为新实验自行开发界面。ExpEYES可以通过CD光盘启动，也可以在Linux和Windows系统上安装。它是一款性价比很高的科学实验室，既轻便又很容易扩展。它可以支持大量的科学实验，从高中到研究生阶段都适用。

最新的ExpEYES版本是ExpEYES Junior。这版ExpEYES增加了一些新功能，早期版本的一些简单功能被移除了。它还可以和Android智能设备进行交互。ExpEYES Junior的界面如下图所示。

图片来源：http://expeyes.in/sites/default/files/Experiments/Photos/half-wave.jpg

最初的软件是用C语言写的，很快就迁移到了Python上。这样的改变有两个好处。第一个好处是显著增强了ExpEYES开发GUI程序的能力。另一个好处是通过Python与硬件交互可以让实验的开发变得更简单。

9.1.3　Python开发的天气预测应用程序

通常，气象学家会将自己的预测结果与真实的天气进行比较，观察气象周期。这么做可以优化和改善模型的质量，模型需要收集真实的天气测量值的数据。ForecastWatch可以帮助气象学家们对比、观察、理解预测数据的准确性。它可以提供基础的分析和无偏的数据，从而改进预测效果。ForecastWatch不断地从多个气象数据源收集预测数据，然后根据真实情况为预测数据加上标签。它会对比美国和加拿大850多个观测点的预测值和实际值。温度、天气状况、降雨量和风力风向等指标的高低都会进行对比。ForecastWatch还可以生成月度统计值，并按照国家、州、地区进行不同区域的聚合。

ForecastWatch由以下四部分构成。

获取预测的气象数据：这是一个预测数据解析器，它会从每个预测数据源的网络接口收集数据。它会先解析数据，然后把数据存入数据库，等待与真实数据做比较。

获取实际测量的气象数据：这是一个真实数据解析器，它会从美国国家气象数据中心的国家气象服务平台收集真实气象数据。数据包括最高与最低温度、降雨量、重大气象事件。真实数据解析器会将数据存储到数据库，然后与预测数据对比评分，最后把分数保存下来。

数据聚合引擎：对数据进行存储和评分之后，通过数据聚合引擎（data aggregation engine）按照不同的时间段（月度、年度、任意天数）、地点、数据源进行聚合。

网络应用框架：最初，网页是用PHP设计的，后来用Python重新设计了。通过Python重新设计简化了网络开发过程，并可以与系统中的其他模块更好地衔接。Python网络应用框架是Quixote，可以开发纯Python的网络应用。

这是一个纯Python的应用，通过Python开发了系统的所有四个模块，从有趣的Web界面到耗时的输入与输出数据收集模块，以及高性能的数据聚合引擎。开发者之所以选择Python，是因为它有大量的标准程序库，可以收集数据、分析数据，并将数据存储到数据库中。多进程程序库可以扩展解析器的功能，同时收集多个城市的数据。数据聚合引擎也是用Python开发的，通过数据库拉连接模块MySQLdb在MySQL里执行SQL语句，让输入程序存储预测和气象数据。

9.1.4　Python开发的航空器概念设计工具与API

这一节将分别介绍支持航空器概念设计的一个工具和一个API。首先将介绍VAMPzero工具，之后介绍pyACDT API。

德国宇航局（German Aerospace Centre，德语简称DLR）是德国国家航空、能量与运输研究机构。它的主要任务是进行航空与航天研究，并开发研究需要的工具软件。DLR用Python开发工具和API。

VAMPzero是航空器概念设计的软件工具。它帮助德国宇航局在航空器概念设计过程中攻坚克难，游刃有余。航空器设计的需求变化得非常快，因此它们需要一直使用最新的技术。VAMPzero的灵活性可以让用户轻松调整设计方案。VAMPzero使用主流的手册方法，具有高度扩展性。VAMPzero可以跟踪计算历史，并将数据导出为CPACS格式。利用VAMPzero设计一个新系统，包括外部尺寸、引擎、结构、系统和成本。VAMPzero是支持多学科环境的航空器概念设计的第一个开源工具。VAMPzero使用的编程语言是Python。其开发目的是打造一款可以快速完成航空器设计过程的工具。

pyACDT（Python aircraft conceptual design toolbox，Python航空器概念设计工具包）是一款由加拿大皇家军事学院的先进航空器设计实验室（Advanced Aircraft Design Lab）的科学家开发的框架。pyACDT是基于Python的面向对象框架，可以实现航空器的功能分析、定义、设计与优化。它用独立的模块分别表示概念设计阶段的几个主要的学科分析内容。这个框架通过面向对象编程的概念实现了各种航空器的组件、引擎、特征以及学科分析。pyACDT与不同学科关联的主要模块如下图所示。这个框架的设计可以让用户轻松地改变约束条件、设计变量、学科分析与目标函数。

9.1.5　OpenQuake引擎

全球地震模式（Global Earthquake Model）是由多个地区、国家和国际级的组织与众多个人开发者共同努力组建的世界地震风险计算与通信的统一开放标准。基金会是公私合作的，其几千会员通过不同的形式做贡献，可以是时间，也可以是知识。这个组织有不同的国际项目，如果一个人使用并测试了GEM的软件，总结项目结果，参加会议，就可以成为一个用户。GEM从事的活动至关重要，因为地震爆发的可能性与日俱增，世界上大部分地区都缺乏可靠的地震风险评估工具和数据。另外，人们也缺乏一套国际标准来对比不同的风险分析结果。如果想正确地理解地震的后果与行为，全球合作就显得更加可取了。GEM就是在这种背景下创建的。

GEM基金会的关注领域主要有以下几个。

地震风险评估工具：主要任务是设计、开发、增强高质量的地震风险评估工具。

地震风险信息：GEM也致力于引领地震数据收集与生成的方法和原则，以及地震风险分析模型。

协同风险评估项目：GEM基金会的工作还包括发展并实施不同规模的协同风险评估项目。

技术转移与能力开发：GEM还致力于地震风险评估的能力开发与知识传递。

科学家们找到了GEM这个组织就可以为地震风险评估积累优秀的经验，创造共同的数据库，开发模型。GEM把大家的贡献汇集在网页版的OpenQuake工具箱中。世界各地的利益相关者都可以获取这个工具箱。OpenQuake引擎是用Python写的，工程师、财务专家、政府官员、科学家都可以用它们评估地震风险。

OpenQuake是一个网页版的风险评估工具，提供了计算、可视化、研究地震风险的一体化环境，可以捕获新数据，还能分享协同学习的结果。

OpenQuake有五个用于地震风险评估与缓解的不同方面的计算器。这些计算器的简介如下所示。

地震风险计算器：这个计算器主要用于增强公众对地震风险的意识，以及作出合理的应急计划和管理。它可用来计算一组既定资产在一次地震中的损失和损失统计量。

地震损失评估计算器：这个计算器用于评估不同类型资产在地震中可能受到的破坏程度。它可以评估一组资产中的某一项资产在地震中遭受的损害。

基于概率事件的风险计算器：这个计算器主要用于计算一组资产在地震中可能遭受的总损失。可以通过概率论与数理统计方法对一组资产可能遭受的损失进行统计。

基于PSHA的计算器：这个计算器的输出可以用来对不同地点的资产进行地震风险缓解优先级排序。计算器会计算单个资产的损失概率和损失统计。针对不同地点的资产的这些计算还可以作为资产间相对风险评估的依据。

风险—收益率计算器：这个计算器用于对需要采取加固措施的不同地区进行优级先排序，以及找出对一个地区来说经济适用的抗震设计。它可以计算和评估针对某些建筑的改造或加固措施是否在经济上富有成效。

9.1.6　德国西马克公司的能源效率应用程序

德国西马克技术有限公司（SMS Siemag AG）是冶金工厂与轧钢领域的巨头。该公司的一部分业务是为客户改善其工厂的能源利用率，以及工厂对环境的影响。西马克把这部分业务称为生态模式（Eco Mode）。在这种模式下，在特定的时间里，不需要使用的设备会自动关闭或进入节能模式。这个自动化过程是通过Python写的软件实现的。这个Python写的软件可以测量并记录不同设备的能源消耗情况。然后，通过分析日志就可以知道不同操作模式下生产不同发电机组的能量消耗。

9.1.7　高能物理数据分析的自动代码生成器

大型强子对撞机（Large Hadron Collider，LHC）是世界上最大的粒子物理实验人造机器。这个机器的使命是验证粒子物理学理论并探索新粒子。这开启了高能物理的新纪元。

这是迄今为止最大的科学实验，主要介绍如下：

将近100个国家参与其中

大约有500个研究机构共同合作

大约10 000个人从事或得益于该实验

项目成本高达40亿欧元

LHC的隧道长度达到27千米

这个巨大的机器每年会产生10 PT的数据，单节点设备无法存储如此庞大的数据。为了解决这个问题，为此，CERN（欧洲粒子物理研究所）联合世界上几乎所有的高能物理研究机构，开发了一种网格计算环境。这个网格通过机构间的网络、共享的存储空间和电力构成巨大的并行处理系统。为了获得更高的性能，分析工作是在数据所在的系统上透明执行的。

在LHC的隧道中，有两个质子束循环对流。它们每25纳秒会在四个实验点进行对撞。撞击之后会产生许多粒子。其中有些是已知的粒子，有些可能是新的、未知的粒子。

如果要从对撞数据中抽取适当的信息，物理学家们需要为自己感兴趣的每一个物理量写代码。只有当物理量一次性运行通过时，代码才是有效的。但是，他们需要写很多这样的分析代码来扫描所有可能的新物理量。这些代码基本类似，大部分通过复制粘贴就可以搞定。通常这些代码可能容易出错，因此有许多代码需要调试与维护。

为了解决这个问题，CERN的科学家们为高能物理社区提供了一个新方法——用Python开发了一个计算机辅助的软件工程包，来处理常用代码和算法以及自动生成分析代码。代码是由用户输入自动生成的，所以更高效，也更不易出错。由于分析代码是自动生成的，所以物理学家可以恰当地处理物理学部分。这个软件包的名字叫WatchMan，是一个用纯Python写成的面向对象框架。它可以让物理学家把精力集中在分析上，不需要担心分析代码，因为它可以根据用户的设置生成完整的分析代码。它是用CERN用Python开发的两个工具开发的：PyROOT（Python数据分析工具箱）和rootcint（Python和C++绑定系统）。WatchMan的处理流程如下图所示。

9.1.8　Python的计算化学应用

英国阿斯利康（AstraZeneca）是一家知名的医药公司，生产治疗恶性肿瘤、心血管疾病、肠胃病与其他感染病的药物，还有止疼药及治疗其他疾病的药物。通常，研发一款新药需要很长时间（通常是几十年）。最大的难题就是尽可能快速地从大量分子中找出可能制成良药的分子。

有一些技术可以预测分子的属性与行为。这些技术由计算化学家发明，用于确保分子对身体无毒害且稳定，可以完成既定的活动，而且可以自动消失。

这些技术的问题在于，光靠它们的结果还不够充分，化学家还需要完成真实的医学实验。必须在实验室里测试这些分子，观察它们的行为和反应。为了节省测试时间，用不同的计算模型来快速挑选最好的候选分子。

在阿斯利康加强药物鉴定过程之前，实验化学家和计算化学家在药物鉴定中是彼此依赖的。实验化学家不会接触许多计算技术，计算化学家应该帮助实验化学家用计算机预测数据，这个预测过程非常复杂。这种依赖关系既会影响计算化学家的工作效率，也会影响实验化学家的工作效率，因为计算化学家需要花大量的时间重复做同样的计算，没有精力开发新的预测技术。如果有某种技术可以让实验化学家自己用计算机进行预测，那么作业流程就可以得到改善，药物预测过程也会变得更加简单快捷。

Pierre Bruneau用Perl脚本设计了一款成功的网页版分析工具。这个工具使用了分子属性计算器工具Drone。阿斯利康利用了这个工具来增强其后端工具Drone，使得它更好管理、更具扩展性、更健壮。这个新的后端工具叫作PyDrone。

PyDrone（用Python开发的）添加了强大的显式异常处理和严格的类型检查功能，以增强Drone的稳定性。起初，在测试阶段，PyDrone会把那些原来在Drone里是隐式处理的异常都显式地抛出。开发者发现，这些异常识别出了之前没有处理过的新异常情况。新版本的代码增加了对这类异常的处理，提高了系统稳定性，因为越来越多的异常可以得到正确处理了。

为了提高PyDrone的扩展性，化学家们开发了一个规则库。这个规则库由一些属性提前计算好的数据缓存和属性名称构成，用于预测用户输入的函数名称。

这个规则库类似于Python的字典对象。用户请求一个属性时，它首先在缓存数据中寻找，如果找到了，就直接使用缓存数据；如果没找到，就调用相关函数进行计算。

这个结果会保留在规则库中以便下次使用。在新的预测过程中，开发者可以增加新函数到函数表中。通过这种方式，PyDrone就可以管理所有的函数，并预测用户输入的函数名称。

9.2　Python开发的盲音触觉识别系统

盲音触觉识别系统（Blind Audio Tactile Mapping System，BATS）为盲人提供地图。在这个软件发明之前，世界上没有盲人可以使用的地图。这个项目是由美国北卡罗来纳大学的一个研究小组发起的。开发语言使用Python，而不是Java和C++。一开始小组内部争论激烈，因为大家更熟悉Java和C++，对Python则了解不多。最终，研究小组还是做出了明智的决定，使用Python开发，因为Python有许多扩展程序库和模块，适合开发这类应用。

BATS使用的是北卡罗来纳大学的古代世界测绘中心（Ancient World Mapping Center）的地理信息数据文件。ArcView/ArcGIS是一种功能齐全的GIS软件，用于可视化、管理、创建和分析地理数据。一开始，研究小组开发了两个ASCII码文本文件，用于显示地图的地点信息和海拔。这些信息用1024×768的网格匹配系统中使用的显示触摸屏的分辨率。网格信息存储在Python数组中。数据会被压缩以适应BATS模型，然后存储在压缩文件中。程序将数据解压缩并加载成适当的数据结构，以完成快速启动。显示像素与文件内容之间有一一对应的关系。这个系统响应迅速，可以敏捷地捕捉用户的动作。不同的声音/视觉效果被用来表示不同地理特征，例如海洋和陆地。

BATS由两个主要的组件构成，分别是一个用户图形界面和一个数据管理器。数据管理器让用户可以通过图形界面管理数据。图形界面中有一个触摸板、一些数字按键和一个声音合成器。用户在触摸板上的动作可以通过wxPython捕捉到。wxPython里的鼠标动作事件可以对用户动作进行响应，这些事件会查询地理类型以及城市数据库的信息。wxPython可以用声音响应鼠标和键盘动作。BATS还使用微软的语音API。

wxPython是支持多平台的GUI开发API，可以让Python程序员通过类型丰富的用户界面和事件处理模式开发图形界面。

数据管理器通过三个数值数组存储不同的数据，并通过一个ODBC连接器连接微软Access数据库。这些数据包括海拔、土地类型和数据库对应的索引键值。这个键值用来查询Access数据库，获取对应城市位置上的具体信息。

9.2.1　TAPTools空中交通管制工具

开发通用的空中交通管制解决方案非常有挑战性，因为每个机场各具特色，如整体设计、管理规范和基础设施等各有差异。最主要的困难是每个客户的空中交通管制系统都是其自定义的界面。

Frequentis是在空中交通管理、公共安全和交通领域首屈一指的解决方案供应商。他们通过Python开发了TAPTools产品系列，用于空中交通管制的灯塔和机场工具。空中管理员通过这些工具控制跑道灯光和导航辅助设备，监控导航设备，并跟踪天气条件。

为每个用户开发一套全新的图形界面是非常单调和费时的任务。为了解决这个问题，Frequentis开发了一个设计图形界面布局的工具，叫PanView。这个工具可以设计和建立用户图形界面，在PanMachine软件中运行。这个软件运行在专门设计的硬件PowerPanel上面。用这些工具可以非常快速地开发界面原型。起初，PanView和PanMachine使用Lua语言。Lua可以用来连接用户界面与空中交通管制系统的各项功能。

和Python相比，Lua有许多问题。当产生错误的时候，它能提供的异常信息极少（不方便调试）。Lua也没有列表数据结构，而且标准库内容很少，不适合构建大程序。

芬兰民航局不仅想在PowerPanel上运行用户界面，而且还想在网络浏览器上使用。为了能在浏览器上运行程序，Frequentis用Java重写了PanMachine。由于Lua不能在Java下运行，Frequentis用Python重新实现了原来Lua的功能。他们使用了Python和Python的Java实现版本Jython。这样用户就可以在用Java实现的PowerPanel和PanMachine上运行用户界面了。在PowerPanel上面，Python用C语言实现，而Jython用Java实现，用于浏览器运行。之后，Frequentis用Python重写了Lua布局功能的代码。相比Lua，Python代码更加简洁，也更容易管理。

9.2.2　光能效率检测的嵌入式系统

加拿大Carmanah技术公司是太阳能LED照明市场的领头羊。它是多种用途照明设备的制造企业与供应商，包括机场照明、工业信号灯、海运、铁路、公路以及运输线使用的照明设备等。这家企业首先研发了海运导航用的自动供电与自主控制太阳能灯。目前，Carmanah的市场遍及全世界，尤其是一些特殊环境，如海洋、沙漠、北极等。这些年，电力照明设备为了满足自动供电与自主控制，变得越来越复杂。这些灯接收的有效太阳能比率会随着天气、季节、光的位置、太阳能板的角度以及其他属性的变化而变化。还有一些其他需求，例如灯需要支持可编程界面，对输入数据反馈不同的输出结果，通过无线网连接到控制中心，以及其他复杂的需求。

设计与开发这种灯具，还需要结合电力、电子、机械与光学等多个专业的知识。每个灯都是用运行在微控制器上的嵌入式软件程序进行控制。这类灯具都可以进行自动控制，并且根据客户需求完成特定的功能。

通常，嵌入式系统的组件都需要具备稳定性高、能耗低、体积小的特点。为了实现这些需求，人们发明了微处理器这种特殊的处理器芯片。这些微处理器是把CPU、内存和外围设备集成在一块能耗极低的芯片上。除了要把嵌入式程序写入微处理器的ROM中，在开发与维护阶段，还有一些函数需要使用台式机或笔记本电脑来完成。

现在假如有一个嵌入式系统需要在普通系统上编译，目标代码已经加载到微处理器里了。类似的情况还有，在设备维护时，需要添加额外的硬件才能对已经部署好的设备进行异常检测，例如需要一台笔记本电脑来运行诊断工具。Python的许多特性都非常适合进行嵌入式系统开发。这些特性包括Python程序的简洁短小、自动内存管理、简单强大的面向对象特性等。

Carmanah技术公司在嵌入式系统开发周期的若干关键阶段中采用Python。例如，通过Python程序控制软件开发过程、压力测试和单元测试、设备模拟器，等等。

9.3　Python开发的科学计算程序库

在Python里，有许多程序库可以应用于多个领域。这些程序库既可以应用于商业领域，也可以进行科学计算。下面的内容就是介绍一些应用于科学计算领域的程序库。

9.3.1　Tribon公司的船舶设计API

Tribon Solutions公司4长期致力于船舶计算机辅助设计与建模解决方案。其业务重心是改善船舶应用的整体效率。Tribon软件集可以支持船舶建造的整个生命周期。这就需要高度并发的过程配合建造需求。他们为从事船舶设计与建造的人开发了一个中央资料库和单点信息源。这个模型被称为产品信息模型（Product Information Model，PIM）。这些人可以是设计师、材料管理者、制造团队的成员、策划人以及其他涉及整个建造过程的相关人员。

42004年被AVEVA集团收购。——译者注

一般情况下，船舶设计方案都是独一无二的，但是设计师的关注点是通过标准化和数据驱动设计检验流程以降低成本。这个过程由供应商决定，因供应商不同的设计原则、管理规范和标准、设备和设施的条件而变化。Tribon公司可以让供应商根据自己的需求解决这些问题。Tribon公司开发了一套简单易用、平台独立、可扩展、可嵌入的API。

Tribon公司因为Python的许多优秀特性而选择了它，例如可扩展和可嵌入、没有许可证费用、平台独立。Tribon公司的解决方案不会受到Python版本升级的影响。他们的客户用API开发的应用是平台独立的，因此可以移植到不同的平台上运行，而不会出现任何问题，也不需要修改代码。这些解决方案将部分设计过程的周期从几周缩短到了几天，而且改善了设计的整体质量。这是因为设计、计算和其他过程都自动化了。他们把该产品命名为Tribon Vitesse。

9.3.2　分子建模工具箱

分子建模工具箱（Molecular Modeling Toolkit，MMTK）是在生物分子系统中对分子进行建模与仿真的Python程序库。这是用Python和C语言开发的开源程序库。生物分子仿真通常都需要很长时间，一般都要几周。仿真过程中需要利用复杂的数据结构描述生物分子。之所以选择Python和C语言，是因为这两者一个是效率高的解释型语言，一个是性能好的编译型语言。对于复杂的、高性能仿真需求，这是一对好组合。

之所以选择Python而不是TCL和Perl，是因为其具有许多优秀特性，比如可以与编译型语言整合、丰富的第三方程序库、面向对象编程范式以及代码的可读性。

用户在使用MMTK的时候会觉得它是一个纯Python库，因为其C语言部分是利用Python的C语言扩展包写的。这部分代码只用在时间与性能要求高的地方。例如，交互能量评估是一个时间密集型函数，能量最小化和生物分子动力学是需要花很长时间进行反复迭代计算的过程；它们都需要非常高的性能。这些函数都通过C语言实现，这样可以避免Python过多的资源消耗。另外，MMTK还使用了Python的数值分析包、LAPACK（线性代数包）和NetCDF程序包。MMTK也支持内存共享的并行计算（用多线程）以及分布式并行计算（用MPI）。

MMTK在设计时非常注重自身的扩展性。用户不需要修改原代码，即可增加新的函数、专业术语和数据类型。MMTK借助外部工具实现可视化功能，VMD和PyMOL程序包也被整合在里面。通常，用户都是通过Python脚本使用MMTK。然而，有一些程序支持图形用户界面，如DomainFinder和nMOLDYN。

MMTK主要由三种类型的类构成。第一种类型是表示原子与分子的类，以及管理生物分子和其他内容的数据库的类。一般的分子类还有一个子类用来表示生物分子，例如DNA、蛋白质、RNA。第二种（也是很重要的）类型用于实现各种交互能量计算机制。第三种类型实现数据输入与输出相关功能。代码支持多种文件格式的输入输出功能，包括许多主流的文件格式，以及基于NetCDF的自定义MMTK格式。MMTK的文件是可移植的，并且是可执行文件的形式。由于它们是可执行文件，因此体积小，而且可以快速接入。

9.3.3　标准Python程序包

除了上面介绍的那些，Python还有许多具有专业用途的工具、API和应用，可以在PyPI的网站http://pypi.python.org里看到。里面有几千个专业用途的程序包（绝大多数都是用Python开发的）。其应用范围涉及大量的科学、商业与计算领域。这些程序包涉及的领域包括生物信息、健康医疗、地理空间数据分析、仪器仪表、工程、数学等。这个网站还维护了一个按类型划分的程序包列表。涉及科学与工程领域的程序包如下所示。

fluiddyn：研究流体力学的Python框架。

DeCiDa：Python的设备与电路数据分析工具。

python-vxi11：Python的VXI-11驱动，用于控制企业内网连接的设备。

pygr：Python图形数据库工具，主要用于生物信息学产品。

Brainiac：Python关于人工智能系统的组件集合，每个组件都可以单独使用。

pyephem：计算天体运行位置的Python程序包。

PyMca：X射线荧光分析的Python工具箱。

openallure：Python的声音与视觉对话系统。

BOTEC：天体物理学与轨道力学模拟器。

pyDGS：基于小波变换的数字粒度分析。

MetagenomeDB：基因序列和注释数据库。

biofrills：分子序列分析的生物信息学工具。

python-bioformat：生命科学数据读写文件格式。

psychopy_ext：一个针对神经科学和心理学实验的框架，能够快速进行可重复设计、分析与画图。

Helmholtz：创建神经科学数据库的框架。

pysesa：一个致力于在空间域和频率域中提供通用的Python框架的开源项目，目的是让点云数据和其他地理空间数据的统计分析变得科学直观。

nitime：神经科学数据的时间序列分析。

SpacePy：空间科学的分析工具。

Moss：神经影像学与认知科学的统计工具。

cclib：计算化学的解析器与算法工具。

PyQuante：Python量子化学程序包。

phoebe：恒星和行星系统的物理学工具。

mcview：高能物理事件仿真的三维/图像事件查看器。

yt：天体物理学仿真的分析与可视化工具箱。

gwpy：引力波天文学的Python程序包。

9.4　小结

本章介绍了用Python开发的一些专门用于科学计算领域的真实应用、程序库和工具。我们介绍了Python在许多不同领域的应用，如软硬件开发（像OLPC和ExpEYES），以及照明设备中用Python进行嵌入式系统开发。也介绍了Python在计算化学与生物分子建模领域的应用。最后还介绍了Python在科学与其他领域中的计算机辅助建模的案例。

下一章将介绍科学计算应用与API开发过程中的最佳实践，尤其是Python开发的最佳实践。

第 10 章　科学计算的最佳实践

本章将介绍适合科学计算应用程序、API和工具的开发者使用的最佳实践。最佳实践是人们通过长期的研究与实践总结的经验。根据这些实践方法进行开发，可以达到事半功倍的效果。

本章将涉及的最佳实践主题如下：

方案设计阶段的最佳实践

功能实现阶段的最佳实践

数据管理与应用部署的最佳实践

实现程序高性能的最佳实践

数据隐私与网络安全的最佳实践

程序维护与客户支持的最佳实践

Python程序开发专用的最佳实践

通常，科学家用科学计算工具做研究，他们绝大多数没有受过正式的计算机科学培训。这就可能导致他们开发出低效率的产品，开发周期也可能更长。而且可能实现的算法效率不高，开发时间很长，代码也达不到预期的效果。最佳实践可以帮助他们解决这些问题。科学家遵循最佳实践，就可以用正确的科学方法进行软件开发，并让他们的代码没有无用的异常和错误。

许多科学程序库/应用程序/工具箱都是完全由非计算机专业的科学家开发的。这些最佳实践帮助他们取得了更好的效果，提升了开发效率，改善了开发体验。

最佳实践可以被视为用于执行软件开发任务的可重复的标准方法。

10.1　方案设计阶段的最佳实践

软件开发过程中的设计阶段的最佳实践如下。

任务分配到不同的团队：把开发生命周期的不同阶段的任务分配给不同的团队，效果更好。这样既可以减轻一个人的负担，也可以在更短的时间内实现更好的效果。最好为开发的每个阶段选择一个团队（一到两人一队），分别负责设计、实施和测试阶段。这些团队的内部成员紧密合作，他们在不同的阶段完成各自负责的任务。这样做比不同团队之间的合作更高效。这种合作方式如下图所示，不同的步骤都用普通的图形表示。设计师团队可以在编程上支持开发团队，这种合作方式被称为结对编程。类似地，功能实现团队与测试团队密切合作，可以修复bug，改善系统的整体性能。

把大任务分割成小任务，分而治之：不要指望一次性写一个大程序实现一个大功能，应该把大任务分解成若干子任务。这是一种循序渐进的方法，通过不断地完成一个个子任务来实现最终任务的完成。这样将会提升整体开发体验，并改善实现代码的质量。按照这样的方法，代码质量更高，时间消耗更少，可维护性更强。

每个子任务的生命周期：每个子任务都按照开发生命周期（设计、实现和测试）来执行，可以减少程序的代码错误，因为每个子任务都经过了测试。这还可以改善代码的整体质量，因为每个团队只需要负责一小部分代码。具体方法如下页图所示。

采用这种方法，开发团体可以避免陷入影响整体重大的错误之中。而且这种方法可以为最终的测试阶段节省很多时间。

每个阶段使用专业的软件：推荐团队在开发生命周期的各个活动中使用专业的软件。大多数活动都有许多标准软件。例如，有一些专门用于设计与建模工具的软件（如微软的Visio）。为了支持开发活动，有集成开发环境（如用Eclipse开发Java）、版本控制软件（如Git和CVS）、程序调试器（如GDB）、编译工具（如常用于Java开发的ANT）。还有许多应用测试和性能分析的专业软件。

每个子任务的生命周期

10.2　功能实现阶段的最佳实践

下面介绍的最佳实践适合应用于功能实现阶段。

代码注释与文档最大化：大多数科学应用都涉及复杂的算法和计算，因此它们的实现过程也很复杂。如果对那些复杂的功能实现的代码进行详细的说明，就可以更方便未来的功能增强。将代码注释与文档最大化，可以让用户/开发者很好地理解程序背后的设计思路。尤其重要的是，对于复杂逻辑的合理注释，可以方便开发团队继续增强应用程序/工具/API的功能。代码注释可以解释代码的逻辑。

提升代码可重用性：不要重复发明轮子，在开发生命周期开始之前，先看看有没有合适的程序库。站在现有程序库的基础上开发会节省很多时间。而且，使用现有的优质程序库还可以减少运行错误、bug，因为这些库都经过反复测试，久经考验。使用现有的程序库可以让科学家将精力集中在科学研究之中。这样可以节省大量时间。唯一要花时间的地方是学习这些程序库，然后用它们完成任务。

首先开发一个功能完整的原型：一种开发应用程序、工具、API的好方法，是首先开发一个可以正常运行的原型，然后再不断地优化。即使是开发一些简单的商业软件，这种方法也值得尝试。正常运行的原型可以通过优化改善性能。然而，开发过程中制定优化计划可能会分散团队注意力。因此，在开发阶段，团队的重心应该放在实现需要的功能上，之后可以优化正常运行的应用程序、工具、API以改善性能（过早优化是魔鬼）。

预防未来可能发生的错误：采取积极主动的方法应对未来的错误。这种方法会涉及断言、异常处理、自动化测试与调试。断言可以用来判断代码的前置条件和后继条件都是正常的。自动化测试可以帮助开发人员保证程序的功能没有改变，即使程序已经被调整过了。在测试阶段，每个错误都应该被转换成一个测试用例，这样未来再遇到时就可以自动化测试。使用调试器比直接在代码里用print打印结果来验证的代码正确性更高效。调试器可以帮助开发者深入理解程序每一行语句所做的动作。异常处理可以帮助开发者提前处理异常。下面的代码演示了Python断言的用法：

# 判断前置条件的测试
def centigradeToFahrenheit (centigrade):
    assert type(centigrade) is IntType, "Not an integer"
    assert (centigrade >= 0), "Less then absolute Zero"
    return (9 * centigrade/5 + 32)
print centigradeToFahrenheit(40)
print centigradeToFahrenheit(15)
print centigradeToFahrenheit(-10)
# 判断前置条件与后继条件的测试
def calculate_percentage (marks1, marks2, marks3):
    assert (marks1 >= 0), "Less then absolute Zero"
    assert (marks2 >= 0), "Less then absolute Zero"
    assert (marks3 >= 0), "Less then absolute Zero"
    result = (marks1 + marks2 + marks3) / 100.0
    assert (0.0 <= result <= 100), "Percentage should be between 0 and 100"
    return result

数据与代码的开源与标准出版物：开发的代码与实验用的数据最好都以开源/标准的形式发布，这样数据与代码就可以被该研究领域的科学家们使用。这样做可以增加应用程序、工具和API的关注度，最终也会形成更大的用户基础。

代码与数据的发布会吸引大量的用户，这些用户会参与程序测试与未来的功能改善。数据也会被改善，并根据新用户的需求不断更新。通常，开源软件经过全球各地的开发者和科学家的协作，都会不断地更新。为了支持大量分散的开发者贡献项目，分布式版本控制工具1应运而生。分布式版本控制工具是基于网络开发的系统，具有强大的扩展性，可以支持大量的开发者。传统的版本控制工具不能支持众多的开发者共同维护一个项目。

分布式版本控制工具的原理

1如GitHub和Bitbucket。——译者注

10.3　数据管理与应用部署的最佳实践

这部分内容是数据管理与应用部署阶段的最佳实践。

数据备份：这条实践适用于关键任务的应用程序，数据一旦丢失将是严重损失，可能造成很高的实验成本，甚至实验失败会造成生命的代价。对于这类关键任务的应用程序，应该适当考虑数据备份，这样可以保证当系统部分功能出现故障时，不会影响到系统整体功能。备份数据必须放到不同的位置，这样即使某地区发生自然灾害，也不会影响最终功能。

数据备份的概念如下图所示。每份数据都会在全球的不同位置复制三份。即使一两块数据出现了故障，也不会影响系统整体功能。

用真实和模拟数据做测试：应用程序可以用真实数据测试，也可以用模拟数据测试。如果拿不到真实数据，就用模拟数据。要获取模拟数据，可使用第3章中介绍的基于统计分布的随机数生成技术。通常，绝大多数常用的科学应用程序都有公开的数据集，在第3章里已经介绍过。如果有合适的数据集，就可以拿来实验，对应用程序进行测试。

10.4　实现高性能的最佳实践

这部分内容主要面向需要高性能的应用程序。实现高性能的最佳实践如下所示。

为将来的扩展性需求做准备：主动地为将来的扩展性需求做好准备，是更加合理的决策。系统运行的数据集可能是小数据集、大数据集、千万亿级甚至百亿亿级；在设计阶段必须考虑这个问题。根据这个需求，硬件配置、软件开发框架和数据库都需要进行合理配置。设计过程中需要考虑到系统将来需要处理大数据的可能性。

选择软件与硬件：花足够的时间为应用程序、工具和API选择最合适的技术。这个过程需要一开始花时间挑选能够实现目标功能的合适的开发环境。这些技术包括选择一种适宜的编程语言和开发框架、合适的数据库/数据存储、需要的硬件、合适的部署环境，等等。

选择合适的API：如果有API可以实现想要的功能，选择最合适的API对成功、高效地实现功能至关重要。在确定要使用的API之前，需要适当地分析API是否可以实现目标功能与性能要求。最终产品的性能直接受到建立系统时使用的API性能的影响。

使用适当的性能测试工具：对于性能关键型应用，需要使用合适的性能测试工具。有许多性能测试工具可以测试不同类型的应用程序、工具和API的性能。例如，DEISA性能测试套件是一个专门设计的高性能科学计算应用程序。通常，性能测试工具都是由应用程序所在领域的一些用户自定义和实际的程序案例构成。这些程序会运行许多次，以测试目标应用程序的性能。

10.5　数据隐私与安全的最佳实践

数据隐私与安全是应用程序能够被用户接受并广泛使用的最重要前提。本节介绍关于应用程序和数据的合理隐私与安全的最佳实践。

数据隐私：有一些应用程序涉及数据采集，对于这类应用程序，开发者需要注意保护用户数据隐私。数据隐私非常重要，这些数据可能是财务和医药数据，一旦泄露就可能让数据采集者倾家荡产。因此，在开发生命周期的各个阶段都需要时刻关注。

网络应用/服务的安全注意事项：如果应用程序被设计成网络应用/服务，网络信息安全是必须考虑的一环，因为网络服务系统是网络攻击的主要对象。有一些成熟的安全策略既适用于网络服务系统的安全防护，也可以用于对系统进行攻击。从应用程序开发生命周期的第一步开始，就应该考虑安全防护措施。适当的证书许可与鉴权机制可以同时实现应用程序的隐私与安全防护。

10.6　测试与维护的最佳实践

适当的测试与维护对软件开发是至关重要的。本节重点介绍测试与开发阶段的最佳实践。

单元测试优先：最好优先进行单元测试。单元测试成功后，再进行集成测试。待集成测试通过后，再进行验证测试。单元测试可以保证系统的不同模块能正常工作，并且有助于尽早发现错误。这样不仅可以修复模块中的bug，还可以帮助开发者找到最初想法实现中缺失的部分。由于单元测试一次只对一个模块进行测试，关注点很小，因此可以找到功能实现阶段落下的部分。

不同的测试团队：测试是产品最终获得成功的关键环节。测试阶段中最好是不同的团队负责不同的功能。这些团队共同合作获取更好的结果。这样做可以识别功能实现阶段的bug和问题，让最终产品获得更好的功能。

成立客户支持工作组：为了向大型系统的用户提供支持和维护，最好为系统的不同功能模块创建多个工作组。最好各个功能模块的一个开发者可以成为工作组的一员。这样，开发者（也是组员）就很容易发现问题并及时修复。每个工作组专门负责一部分。这样，工作组的每个成员都会对那部分功能有透彻的理解。这些组员也可以轻松地管理支持与维护的工作。

为大型项目成立多个工作组：对于大型项目，需要创建多个工作组共同分担工作任务。每个工作组负责一块任务，为客户提供支持、维护和改善服务。专攻一个领域的工作组可以不断改善系统的整体质量，为客户提供更好的支持。由于一个团队在较短的时间内遇到了同一领域的许多问题，因此他们处理问题的经验更丰富，并最终为系统提供客户真正需要的更新。

建立用户帮助与支持邮件列表：为每个工作组创建一个用户邮件/反馈列表。用户可以向邮件列表提出问题，工作组成员也可以通过邮件列表答复用户问题。邮件列表可以作为用户与开发者的沟通桥梁。

10.7　Python常用的最佳实践

这一节将介绍一些Python开发者常用的最佳实践。

Python的PEP 0008代码规范：这部分最佳实践的第一条是深入理解并遵照PEP 0008编码规范。详情请参考https://www.python.org/dev/peps/pep-0008/。

命名习惯：推荐所有的Python开发者都保持前后一致且有意义的命名习惯。这条建议不仅对应用程序的原始开发者有帮助，同样也适用于未来扩展程序功能的开发者。统一并有意义的名称可以改善代码的可读性。命名习惯应该遵循统一的命名规则，并为具体的语言调整相应的命名规则。例如，用下划线与驼峰式大小写方式连接多个单词，构建一个变量名和函数名。下表列出了推荐使用的名称和不推荐使用的名称。

不值得推荐的代码风格
值得推荐的代码风格

变量
var1、var2、mycalculation、
temp_val、f1、num35
变量
area、incomeTax、productCost、
Counter、lambda、sigma、
sum_of_product

函数
func1()、function2()、
calculation_func()、perform_func()
函数
calculateArea()、product_of_sum()、
sinx()

统一的代码风格：一般情况下，推荐你在整个系统中使用一个标准、统一的代码风格。代码断言、缩进、注释和其他内容的风格都应该一致。为代码注释采用或开发一个标准风格，并在整个系统的编码过程中严格执行。类似地，整个系统代码的格式也应该一致。还需要考虑代码中空格与缩进的使用。

下面的例子演示了两种不同的代码格式。

空格与缩进格式不统一
空格与缩进格式统一

x=(b\*d- 4\*a\*c)/2\*a
y = 2 \* x \* x + 4 \* x + 5
def sample_function()
print "in function"
  print " last line"
def second_sample()
      print "in function"
      print "last line"

x = ( b \* d – 4 \* a \* c ) / 2 \* a
y = 2 \* x \* x + 4 \* x + 5
def sample_function()
print "in function"
  print " last line"
def second_sample()
        print "in function"
        print "last line"

10.8　小结

本章介绍了科学计算团队需要使用的最佳实践。首先介绍了方案设计的最佳实践，然后介绍了写代码的最佳实践，之后介绍了数据管理与应用部署的最佳实践。

紧接着，介绍了高性能计算的最佳实践以及数据隐私与网络安全的最佳实践，之后介绍了程序维护与客户支持的最佳实践，最后介绍了Python开发者常用的最佳实践。

看完了

如果您对本书内容有疑问，可发邮件至contact@turingbook.com，会有编辑或作译者协助答疑。也可访问图灵社区，参与本书讨论。

如果是有关电子书的建议或问题，请联系专用客服邮箱：ebook@turingbook.com。

在这里可以找到我们：

微博 @图灵教育 : 好书、活动每日播报

微博 @图灵社区 : 电子书和好文章的消息

微博 @图灵新知 : 图灵教育的科普小组

微信 图灵访谈 : ituring_interview，讲述码农精彩人生

微信 图灵教育 : turingbooks 　　

091507240605ToBeReplacedWithUserId

版权信息

书名：Python数据处理

作者：[美] Jacqueline Kazil　Katharine Jarmul

译者：张亮　吕家明

ISBN：978-7-115-45919-0

本书由北京图灵文化发展有限公司发行数字版。版权所有，侵权必究。

您购买的图灵电子书仅供您个人使用，未经授权，不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟，与我们共同保护知识产权。

如果购买者有侵权行为，我们可能对该用户实施包括但不限于关闭该帐号等维权措施，并可能追究法律责任。

091507240605ToBeReplacedWithUserId

版权声明

O'Reilly Media, Inc.介绍

业界评论

本书赞誉

前言

目标读者

不适合阅读本书的读者

本书结构

什么是数据处理

遇到困难怎么办

排版约定

使用代码示例

Safari® Books Online

联系我们

致谢

电子书

第 1 章　Python简介

1.1　为什么选择Python

1.2　开始使用Python

1.2.1　Python版本选择

1.2.2　安装Python

1.2.3　测试Python

1.2.4　安装pip

1.2.5　安装代码编辑器

1.2.6　安装IPython（可选）

1.3　小结

第 2 章　Python基础

2.1　基本数据类型

2.1.1　字符串

2.1.2　整数和浮点数

2.2　数据容器

2.2.1　变量

2.2.2　列表

2.2.3　字典

2.3　各种数据类型的用途

2.3.1　字符串方法：字符串能做什么

2.3.2　数值方法：数字能做什么

2.3.3　列表方法：列表能做什么

2.3.4　字典方法：字典能做什么

2.4　有用的工具：type、dir和help

2.4.1　type

2.4.2　dir

2.4.3　help

2.5　综合运用

2.6　代码的含义

2.7　小结

第 3 章　供机器读取的数据

3.1　CSV数据

3.1.1　如何导入CSV数据

3.1.2　将代码保存到文件中并在命令行中运行

3.2　JSON数据

如何导入JSON数据

3.3　XML数据

如何导入XML数据

3.4　小结

第 4 章　处理 Excel 文件

4.1　安装Python包

4.2　解析Excel文件

4.3　开始解析

4.4　小结

第 5 章　处理 PDF 文件，以及用 Python解决问题

5.1　尽量不要用PDF

5.2　解析PDF的编程方法

5.2.1　利用slate库打开并读取PDF

5.2.2　将PDF转换成文本

5.3　利用pdfminer解析PDF

5.4　学习解决问题的方法

5.4.1　练习：使用表格提取，换用另一个库

5.4.2　练习：手动清洗数据

5.4.3　练习：试用另一种工具

5.5　不常见的文件类型

5.6　小结

第 6 章　数据获取与存储

6.1　并非所有数据生而平等

6.2　真实性核查

6.3　数据可读性、数据清洁度和数据寿命

6.4　寻找数据

6.4.1　打电话

6.4.2　美国政府数据

6.4.3　全球政府和城市开放数据

6.4.4　组织数据和非政府组织数据

6.4.5　教育数据和大学数据

6.4.6　医学数据和科学数据

6.4.7　众包数据和API

6.5　案例研究：数据调查实例

6.5.1　埃博拉病毒危机

6.5.2　列车安全

6.5.3　足球运动员的薪水

6.5.4　童工

6.6　数据存储

6.7　数据库简介

6.7.1　关系型数据库：MySQL和PostgreSQL

6.7.2　非关系型数据库：NoSQL

6.7.3　用Python创建本地数据库

6.8　使用简单文件

6.8.1　云存储和Python

6.8.2　本地存储和Python

6.9　其他数据存储方式

6.10　小结

第 7 章　数据清洗：研究、匹配与格式化

7.1　为什么要清洗数据

7.2　数据清洗基础知识

7.2.1　找出需要清洗的数据

7.2.2　数据格式化

7.2.3　找出离群值和不良数据

7.2.4　找出重复值

7.2.5　模糊匹配

7.2.6　正则表达式匹配

7.2.7　如何处理重复记录

7.3　小结

第 8 章　数据清洗：标准化和脚本化

8.1　数据归一化和标准化

8.2　数据存储

8.3　找到适合项目的数据清洗方法

8.4　数据清洗脚本化

8.5　用新数据测试

8.6　小结

第 9 章　数据探索和分析

9.1　探索数据

9.1.1　导入数据

9.1.2　探索表函数

9.1.3　联结多个数据集

9.1.4　识别相关性

9.1.5　找出离群值

9.1.6　创建分组

9.1.7　深入探索

9.2　分析数据

9.2.1　分离和聚焦数据

9.2.2　你的数据在讲什么

9.2.3　描述结论

9.2.4　将结论写成文档

9.3　小结

第 10 章　展示数据

10.1　避免讲故事陷阱

10.1.1　怎样讲故事

10.1.2　了解听众

10.2　可视化数据

10.2.1　图表

10.2.2　时间相关数据

10.2.3　地图

10.2.4　交互式元素

10.2.5　文字

10.2.6　图片、视频和插画

10.3　展示工具

10.4　发布数据

10.4.1　使用可用站点

10.4.2　开源平台：创建一个新网站

10.4.3　Jupyter（曾名IPython notebook）

10.5　小结

第 11 章　网页抓取：获取并存储网络数据

11.1　抓取什么和如何抓取

11.2　分析网页

11.2.1　检视：标记结构

11.2.2　网络/时间线：页面是如何加载的

11.2.3　控制台：同JavaScript交互

11.2.4　页面的深入分析

11.3　得到页面：如何通过互联网发出请求

11.4　使用Beautiful Soup读取网页

11.5　使用lxml读取网页

一个XPath案例

11.6　小结

第 12 章　高级网页抓取：屏幕抓取器与爬虫

12.1　基于浏览器的解析

12.1.1　使用Selenium进行屏幕读取

12.1.2　使用Ghost.py进行屏幕读取

12.2　爬取网页

12.2.1　使用Scrapy创建一个爬虫

12.2.2　使用Scrapy爬取整个网站

12.3　网络：互联网的工作原理，以及为什么它会让脚本崩溃

12.4　变化的互联网（或脚本为什么崩溃）

12.5　几句忠告

12.6　小结

第 13 章　应用编程接口

13.1　API特性

13.1.1　REST API与流式API

13.1.2　频率限制

13.1.3　分级数据卷

13.1.4　API key和token

13.2　一次简单的Twitter REST API数据拉取

13.3　使用Twitter REST API进行高级数据收集

13.4　使用Twitter流式API进行高级数据收集

13.5　小结

第 14 章　自动化和规模化

14.1　为什么要自动化

14.2　自动化步骤

14.3　什么会出错

14.4　在哪里自动化

14.5　自动化的特殊工具

14.5.1　使用本地文件、参数及配置文件

14.5.2　在数据处理中使用云

14.5.3　使用并行处理

14.5.4　使用分布式处理

14.6　简单的自动化

14.6.1　CronJobs

14.6.2　Web接口

14.6.3　Jupyter notebook

14.7　大规模自动化

14.7.1　Celery：基于队列的自动化

14.7.2　Ansible：操作自动化

14.8　监控自动化程序

14.8.1　Python日志

14.8.2　添加自动化信息

14.8.3　上传和其他报告

14.8.4　日志和监控服务

14.9　没有万无一失的系统

14.10　小结

第 15 章　结论

15.1　数据处理者的职责

15.2　数据处理之上

15.2.1　成为一名更优秀的数据分析师

15.2.2　成为一名更优秀的开发者

15.2.3　成为一名更优秀的视觉化讲故事者

15.2.4　成为一名更优秀的系统架构师

15.3　下一步做什么

附录 A　编程语言对比

A.1　C、C++、Java与Python

A.2　R或MATLAB与Python

A.3　HTML与Python

A.4　JavaScript与Python

A.5　Node.js与Python

A.6　Ruby和Ruby on Rails与Python

附录 B　初学者的 Python 学习资源

B.1　在线资源

B.2　线下小组

附录 C　学习命令行

C.1　bash

C.1.1　跳转命令

C.1.2　修改文件

C.1.3　运行文件

C.1.4　利用命令行进行搜索

C.1.5　更多资源

C.2　Windows cmd/PowerShell

C.2.1　跳转命令

C.2.2　修改文件

C.2.3　运行文件

C.2.4　利用命令行进行搜索

C.2.5　更多资源

附录 D　高级 Python 设置

D.1　第1步：安装GCC

D.2　第2步：（只在Mac上）安装Homebrew

D.3　第3步：（Mac系统）告诉系统去哪里寻找 Homebrew

D.4　第4步：安装Python 2.7

D.5　第5步：安装virtualenv（Windows、Mac、Linux）

D.6　第6步：创建一个新目录

D.7　第7步：安装virtualenvwrapper

D.7.1　安装virtualenvwrapper（Mac和Linux）

D.7.2　安装virtualenvwrapper-win（Windows）

D.7.3　测试你的虚拟环境（Windows、Mac、Linux）

D.8　学习我们的新环境（Windows、Mac、Linux）

D.9　高级设置回顾

附录 E　Python陷阱

E.1　空白

E.2　可怕的GIL

E.3　=、==与is，以及何时只是复制

E.4　默认函数参数

E.5　Python作用域与内置函数：变量名称的重要性

E.6　定义对象与修改对象

E.7　修改不可变对象

E.8　类型检查

E.9　捕获多个异常

E.10　调试的力量

附录 F　IPython 指南

F.1　为什么使用IPython

F.2　IPython起步

F.3　魔法函数

F.4　最后的思考：一个简单的终端

附录 G　使用亚马逊网络服务

G.1　启动AWS服务器

G.1.1　AWS步骤1：选择一个亚马逊机器镜像（AMI）

G.1.2　AWS步骤2：选择一个实例类型

G.1.3　AWS步骤7：学习实例启动

G.1.4　AWS额外问题：选择一个存在的键对或创建一个新的

G.2　登录AWS服务器

G.2.1　得到实例的公共DNS名称

G.2.2　准备你的私钥

G.2.3　登录你的服务器

G.3　小结

关于作者

关于封面

版权声明

© 2016 by Jacqueline Kazil and Kjamistan, Inc.

Simplified Chinese Edition, jointly published by O'Reilly Media, Inc. and Posts & Telecom Press, 2017. Authorized translation of the English edition, 2016 O'Reilly Media, Inc., the owner of all rights to publish and sell the same.

All rights reserved including the rights of reproduction in whole or in part in any form.

英文原版由 O'Reilly Media, Inc. 出版，2015。

简体中文版由人民邮电出版社出版，2017。英文原版的翻译得到 O'Reilly Media, Inc. 的授权。此简体中文版的出版和销售得到出版权和销售权的所有者——O'Reilly Media, Inc. 的许可。

版权所有，未得书面许可，本书的任何部分和全部不得以任何形式重制。

O'Reilly Media, Inc.介绍

O'Reilly Media 通过图书、杂志、在线服务、调查研究和会议等方式传播创新知识。自 1978 年开始，O'Reilly 一直都是前沿发展的见证者和推动者。超级极客们正在开创着未来，而我们关注真正重要的技术趋势——通过放大那些“细微的信号”来刺激社会对新科技的应用。作为技术社区中活跃的参与者，O'Reilly 的发展充满了对创新的倡导、创造和发扬光大。

O'Reilly 为软件开发人员带来革命性的“动物书”；创建第一个商业网站（GNN）；组织了影响深远的开放源代码峰会，以至于开源软件运动以此命名；创立了 Make 杂志，从而成为 DIY 革命的主要先锋；公司一如既往地通过多种形式缔结信息与人的纽带。 O'Reilly 的会议和峰会集聚了众多超级极客和高瞻远瞩的商业领袖，共同描绘出开创新产业的革命性思想。作为技术人士获取信息的选择，O'Reilly 现在还将先锋专家的知识传递给普通的计算机用户。无论是通过图书出版、在线服务或者面授课程，每一项 O'Reilly 的产品都反映了公司不可动摇的理念——信息是激发创新的力量。

业界评论

“O'Reilly Radar 博客有口皆碑。”

——Wired

“O'Reilly 凭借一系列（真希望当初我也想到了）非凡想法建立了数百万美元的业务。”

——Business 2.0

“O'Reilly Conference 是聚集关键思想领袖的绝对典范。”

——CRN

“一本 O'Reilly 的书就代表一个有用、有前途、需要学习的主题。”

——Irish Times

“Tim 是位特立独行的商人，他不光放眼于最长远、最广阔的视野，并且切实地按照 Yogi Berra 的建议去做了：‘如果你在路上遇到岔路口，走小路（岔路）。’回顾过去， Tim 似乎每一次都选择了小路，而且有几次都是一闪即逝的机会，尽管大路也不错。”

——Linux Journal

本书赞誉

“所有新手数据科学家、数据工程师或其他技术方面的数据专家都应该读一读这本实践指南。数据处理领域正需要这样一本书，真希望我第一次开始用 Python 处理数据时就能有它指导。”

——Tyrone Grandison 博士，Proficiency Labs Intl. CEO

“数据处理不仅仅是编写代码，还包括更多内容，这本精心编写的书可以告诉你需要知道的一切内容。在新闻业需要更多数据专家的时代，这本书是循序渐进的宝贵资源。”

——Randy Picht，密苏里大学新闻学院 Donald W. Reynolds 新闻研究所执行理事

“很少有学习资源能够像这本书一样既全面又通俗易懂。它不仅介绍了你需要知道的内容，还阐释了其原因及学习方法。无论你是数据新闻业的新手，还是想要扩展自己的能力， Katharine 和 Jacqueline 的这本书都是必备的。”

——Joshua Hatch，《高等教育纪事报》与《慈善纪事报》数据与交互高级编辑

“这是一个很棒的概论课程，讲述了我们用数据讲故事时所做的一切，（真的是一切！）既包括了基础知识，也涵盖了最新技术。强烈推荐！”

——Brian Boyer，美国全国公共广播电台（NPR）可视化编辑

“这是一本实用的、通俗易懂的指南，你可以从中学习一些常见的不得不用代码完成的任务：查找、提取、整理和检查数据。”

——Chrys Wu，技术专家

“经常有记者问我：‘我很擅长使用电子表格，但下一步应该学些什么？’这本书给出了一个很有价值的答案。虽然这本书不仅仅面向新闻业的读者，但它给出了一条清晰的路径，对于任何使用电子表格并且想知道如何提高技能的人来说，都可以沿着这条路径来学习获取、清洗和分析数据的方法。它涵盖了所有内容，从如何加载并检查文本文件到自动化屏幕抓取，再到执行数据分析与结果可视化的新的命令行工具。

“我曾经使用陈旧的方式来分析数据并寻找其中的意义：首先使用电子表格，然后转向关系型数据库和绘图程序。它们仍然是很有用的工具，但都没有充分利用自动化功能，让用户能够处理更多数据并复制其工作。它们也不能与互联网上的各种数据无缝连接。在这些工具旁边还需要添加上一种编程语言。虽然我现在已经使用 Python 和其他语言一段时间了，但这种使用漫无计划，并不系统。

“无论是数据处理还是工具的复杂性，在过去 20 年中都在不断发展，这使得寻找一套常用技术更为重要。不断增长的可用数据（结构化的和非结构化的）以及可以用于存储和分析的数据量，都改变了数据分析的可能性：许多困难的问题现在变得更容易回答了，之前看起来不可能的一些问题也已能力可及。我们需要一种‘胶水’，可以将数据生态系统的各个组成部分，从 JSON API 到数据过滤与清洗，再到创建图表来讲故事，全部连接在一起。

“在这本书中，这种‘胶水’就是 Python 及其用于处理数据的强大的工具和库。如果你一直感觉电子表格（甚至关系型数据库）无法回答你想要提出的问题，或者除这些工具之外你已经准备进一步学习，那么这本书非常适合你。我一直在等待这本书的出现。”

——Derek Willis，ProPublica 新闻应用开发者，OpenElections 联合创始人

前言

欢迎打开这本书。在本书中，我们将会让你的数据处理技术更上一层楼，不再只是使用电子表格，而是可以利用 Python 编程语言，将噪声数据轻松快速地转换成可用的报告。 Python 语法简单，上手很快，人人都可以用 Python 编程。

想象一下，你每周都要手动重复同一过程，比如从多个来源复制数据并粘贴到一个电子表格中，用于后续处理。这项任务可能每周都需要花费一两个小时。但当你用脚本把这项任务自动化之后，它可能只需要 30 秒就可以完成！这会节省你的时间，让你做点其他事情，或者把更多的任务自动化。再想象一下，之前你无法处理某种格式的数据，但你现在能对数据进行格式转换，完成之前无法完成的任务。但在完成本书的 Python 练习后，你应该可以更有效地从之前认为不可用的数据（过于混乱，或者数据量过大）中采集信息。

我们将带领你完成数据获取、数据清洗、数据呈现、数据规模化和自动化的过程。我们的目标是教你学会轻松处理数据的方法，这样你就可以花更多的时间专注于内容和分析。我们将克服现有工具的局限，将手动处理过程替换为简洁、易读的 Python 代码。读完这本书后，你能够将数据处理过程自动化，定期执行文件编辑和清洗任务，获取并解析你之前无法获取的数据，还能处理数据量更大的数据集。

采用基于项目的方法，每一章的复杂度会逐渐增加。我们建议你跟随本书的节奏，将书中的方法应用到自己的数据集上。如果你没有一个特定的项目或研究，也可以使用本书线上的样本数据集。

目标读者

本书针对的是那些不想用桌面工具来探索数据处理的人。如果你精于 Excel，想进一步提升数据分析水平，本书将助你一臂之力！如果你之前学过其他语言，想用 Python 学习数据处理，也会发现本书非常有用。

如果你遇到不懂的问题，建议你联系我们，这样我们可以改进书的内容。你也应该使用互联网搜索或在线提问（在线提问有一些方法和技巧，请参考 https://www.propublica.org/nerds/item/how-to-ask-programming-questions）来补充学习。我们在附录 E 中介绍了一些调试的技巧，你可以翻到那里看一下。

不适合阅读本书的读者

本书肯定不适合经验丰富的 Python 程序员，他们已经知道数据处理任务需要用到哪些库和技术。（对于这些人，我们推荐 Wes McKinney 写的《利用 Python 进行数据分析》。）如果你是经验丰富的 Python 开发者，或使用过 Scala、R 等其他具有数据分析能力的语言，本书可能也不适合你。但如果你是经验丰富的 Web 语言开发者，使用的 PHP、JavaScript 等语言本身缺乏数据分析能力，那么本书可以通过数据处理来教你 Python 的知识。

本书结构

本书的结构沿循一般数据分析项目或故事的整个生命周期。首先提出一个问题，然后获取数据、清洗数据、探索数据、传达数据中的发现、扩展到更大的数据集，最后将整个过程自动化。这种方法可以让你从简单的问题逐步过渡到更复杂的问题和研究。我们会先讲传达数据中发现的基本方法，然后再讲数据采集的高级技巧。

如果对某些章节的内容比较熟悉，你也可以将本书当作参考，或者跳过那些章节。但我们建议你大致浏览一下每一章节的内容，确保没有错过新的资源与技术。

什么是数据处理

数据处理是指将杂乱的或未加工的数据源转换成有用的信息。先寻找原始数据源，并判断其价值：这些数据集的数据质量有多好？它们与你的目标是否相关？能否找到更好的数据源？在对数据进行解析与清洗后，数据集变得可用，这时你可以利用工具和方法（如 Python 脚本）来帮你分析数据，并以报告的形式展示结果。这样你可以将无人问津的数据变得清晰可用。

遇到困难怎么办

不必担心——每个人都会遇到困难！把编程过程看作困难重重的一连串事件。作为一名开发者和数据分析人员，遇到困难，然后解决问题，你会学到知识，并得到成长。大多数人并非掌握了编程，而是掌握了解决困难的方法。

解决困难都有哪些技巧呢？首先，你可以利用搜索引擎尝试寻找答案。通常情况下，你会发现许多人已经遇到过相同的问题。如果找不到有用的答案，你可以在网上提问。我们在附录 B 中给出了一些优质的在线资源和线下资源。

提出问题是很难的。但无论处于学习的哪个阶段，都不要害怕向大型编程社区求助。本书作者 Jackie 早期在公共论坛上问了一个关于编程的问题（http://stackoverflow.com/questions/3329943/git-branch-fork-fetch-merge-rebase-and-clone-what-are-the-differences），之后被许多人引用过。像你一样的新手程序员硬着头皮问了一个可能很傻的问题，但之后却帮助了许多人，这种感觉是很爽的。

在网上发布问题之前，我们还推荐你阅读“如何提问”（https://www.propublica.org/nerds/item/how-to-ask-programming-questions）。这篇文章讲了许多方法，可以帮你正确地描述问题，这样其他人才能最大限度地帮助你。

最后，有时你还需要现实生活中的额外帮助。可能是你的问题涉及面太广，在网站或邮件列表里不方便问答。也可能是你的问题有些偏重哲学，或者需要不同方法之间的对比或修改。无论是哪种情况，你都可以在当地 Python 小组中找到能回答你问题的人。想要找到当地的线下聚会，你可以试试 Meetup 网站（http://www.meetup.com/）。关于如何找到有帮助且乐于助人的社区，你会在第 1 章中读到更多详细信息。

排版约定

本书使用了下列排版约定。

楷体

表示新术语和重点强调的内容。

等宽字体（constant width）

表示程序片段，以及正文中出现的变量、函数名、数据库、数据类型、环境变量、语句和关键词等。

斜体等宽字体（constant width italic）

表示应该替换成用户输入的值，或根据上下文替换的值。

　该图标表示提示或建议。

　该图标表示一般性说明。

　该图标表示警告或警示。

使用代码示例

我们在 GitHub 上建了一个数据仓库（https://github.com/jackiekazil/data-wrangling）。在这个仓库中，你会找到我们在本书中使用的数据，以及一些代码示例，让你可以跟上本书的节奏。如果你在仓库中发现任何问题或者有任何疑问，请提交一个 issue（issue 提交地址：https://github.com/jackiekazil/data-wrangling/issues）。

本书是要帮你完成工作的。一般来说，如果本书提供了示例代码，你可以把它用在你的程序和文档中。除非你使用了很大一部分代码，否则无需联系我们获得许可。比如，用本书的几个代码片段写一个程序就无需获得许可。销售或分发 O'Reilly 图书的示例光盘则需要获得许可。引用本书中的示例代码来回答问题无需获得许可。将书中大量示例代码放到你的产品文档中则需要获得许可。

我们很希望但并不强制要求你在引用本书内容时加上引用说明。引用说明一般包括书名、作者、出版社和 ISBN。比如：“Data Wrangling with Python by Jacqueline Kazil and Katharine Jarmul (O'Reilly). Copyright 2016 Jacqueline Kazil and Kjamistan, Inc., 978-1-4919-4881-1.”

如果你觉得自己对示例代码的用法超出了上述许可的范围，欢迎你通过 permissions@oreilly.com 与我们联系。

Safari® Books Online

Safari Books Online（http://safaribooksonline.com/）是应运而生的数字图书馆，它同时以图书和视频的形式出版世界顶级技术和商业作家的专业作品（https://www.safaribooksonline.com/explore/）。

技术专家、软件开发人员、Web 设计师、商务人士和创意专家等，在开展调研、解决问题、学习和认证培训时，都将 Safari Books Online 视作获取资料的首选渠道。

对于企业（https://www.safaribooksonline.com/enterprise/）、政府（https://www.safaribooksonline.com/government/）、教育机构（https://www.safaribooksonline.com/academic-public-library/）和个人，Safari Books Online 提供各种产品组合和灵活的定价策略（https://www.safaribooksonline.com/pricing/）。

用户可通过一个功能完备的数据库检索系统访问 O'Reilly Media、Prentice Hall Professional、Addison-Wesley Professional、Microsoft Press、Sams、Que、Peachpit Press、Focal Press、Cisco Press、John Wiley & Sons、Syngress、Morgan Kaufmann、IBM Redbooks、Packt、Adobe Press、FT Press、Apress、Manning、New Riders、McGraw-Hill、Jones & Bartlett、Course Technology 以及其他数百家出版社（https://www.safaribooksonline.com/our-library/）的上千种图书、培训视频和正式出版之前的书稿。要了解 Safari Books Online 的更多信息，请访问我们的网站（https://www.safaribooksonline.com/）。

联系我们

请把对本书的评价和问题发给出版社。美国：

　　O'Reilly Media, Inc.

　　1005 Gravenstein Highway North

　　Sebastopol, CA 95472

中国：

　　北京市西城区西直门南大街 2 号成铭大厦 C 座 807 室（100035）

　　奥莱利技术咨询（北京）有限公司

O'Reilly 的每一本书都有专属网页，你可以在那里找到本书的相关信息，包括勘误表、示例以及其他信息。本书的网站地址是：http://bit.ly/data_wrangling_w_python。1

1本书中文版勘误可到 ituring.cn/book/1819 查看和提交。——编者注

对于本书的评论和技术性问题，请发送电子邮件到：bookquestions@oreilly.com

要了解更多 O'Reilly 图书、培训课程、会议和新闻的信息，请访问以下网站：

　　http://www.oreilly.com

我们在 Facebook 的地址如下：http://facebook.com/oreilly

请关注我们的 Twitter 动态：http://twitter.com/oreillymedia

我们的 YouTube 视频地址如下：http://www.youtube.com/oreillymedia

致谢

我们想要感谢本书的编辑 Dawn Schanafelt 和 Meghan Blanchette，感谢他们给予极大的帮助和付出以及了不起的工作——没有你们就不可能有这本书的问世。我们还想感谢本书的技术编辑 Ryan Balfanz、Sarah Boslaugh、Kat Calvin 和 Ruchi Parekh，感谢他们帮助完成代码示例以及思考本书受众。

Jacqueline Kazil（Jackie）想要感谢她的丈夫 Josh 支持她写作本书——无论是鼓励的话语还是纸杯蛋糕。如果没有他的全力支持，房子可能就散架了。她还想要感谢 Katharine（Kjam）与她合作。没有 Kjam 就没有这本书。Jackie 很高兴在分别多年之后再次有机会与 Kjam 合作。最后，Jackie 想感谢自己的母亲 Lydie，教给她写作本书所必须的许多技能（除了英语）。

Katharine Jarmul 想要拥抱并特别感谢她的伴侣 Aaron Glenn，感谢他无数个小时的自言自语、反复阅读、争论 Unix 首字母是否应该大写，以及在她写作时制作美味的意大利面。她还要感谢她的父母和公婆，感谢他们对她无数次图书校正和响铃的忍耐。她还要感谢德国的 Hoffmann 夫人，感谢她在讨论本书的过程中所付出的极大耐心。

电子书

扫描如下二维码，可购买本书电子版。

第 1 章　Python简介

无论你是一名记者、分析师，还是初出茅庐的数据科学家，选择这本书可能是因为你想学习如何用编程来分析数据，得出结论，并将结论清楚地传达给别人。你可能会用报告、图表或归纳统计的方式来展示你的结论。重要的是，你想讲述一个故事。

传统的故事讲述或新闻报道往往使用单一的故事来描述总体结论或趋势。在这种故事中，数据成为了相对次要的部分。然而，其他讲故事的人，比如 Christian Rudde［Datacylsm（http://dataclysm.org/）的作者，OkCupid 的创始人之一］认为数据本身应该是故事的重点。

首先，你需要确定想要研究的主题。你可能对研究不同人或群体的沟通习惯感兴趣，这时你可以从一个具体的问题入手，例如在网络上被人们广为分享的信息都有哪些特点。又或许你可能对棒球的历史统计数据感兴趣，并想弄清楚一个问题：这些数据能否表明棒球运动随时间发生了变化。

确定了感兴趣的领域之后，你需要寻找数据，以进一步探索这一主题。想研究人类行为，你可以从 Twitter API（https://dev.twitter.com/overview/api）中获取数据，研究人们在 Twitter 上分享的内容。如果想深入研究棒球历史，你可以使用 Sean Lahman 的棒球数据库（http://www.seanlahman.com/baseball-archive/statistics/）。

Twitter 和棒球数据集都属于综合的大型数据集。为了回答你的具体问题，应把这些数据集分成便于管理的小块，然后进行筛选和分析。有时，较小的数据集也同样有趣有料，尤其当你的主题是关于本地或区域问题时。让我们来看一个例子。

在写这本书的时候，本书的一位作者读到一篇关于她的公立高中母校 1 的文章（http://www.foxnews.com/on-air/fox-and-friends/blog/2014/05/20/manatee-high-school-charges-200-row-prime-seating-graduation）。文中写道，这所高中开始向每名即将毕业的学生收取 20 美元，并且对于毕业典礼上最好的几排座位，每排收取 200 美元。

1美国公立高中是政府办的学校，财政主要由当地社区的税收资助。孩子们在这里上学受教育是免费的，或只需花父母很少的钱。

据当地新闻报道：“学区今年财政困难，撤出了 3400 美元的资助。新的收费是为了支付海牛高中约 12 000 美元的毕业典礼费用而采取的措施之一。”

这篇文章解释了毕业典礼花费远高于学区预算的原因。然而文中并没有解释学区今年为何无法像往年一样进行资助。所以我们仍有疑问：海牛郡学区的财政为何如此困难，以至于无法像往年一样资助毕业班级？

调查之初所提出的疑问，往往会引出更深入的疑问，直指问题的本质。例如，学区把钱都花到了什么地方？学区的开支模式近些年发生了哪些变化？

确定了具体的主题和要回答的问题，可以让我们明确需要寻找哪些数据。在提出上述问题后，我们要寻找的第一个数据集就是海牛郡学区的开支和预算数据。

在继续讨论之前，我们先简单回顾一下整个过程，从开始确定问题一直到最终的故事（见图 1-1）。

图 1-1：数据处理过程

一旦确定了问题，你就可以开始提出有关数据的问题，例如：哪些数据集最能帮助我讲好故事？哪些数据集能帮助我深入探索这一主题？总的主题是什么？哪些数据集与这些主题相关？谁会记录并保存这些数据？可以公开获取这些数据集吗？

　在开始讲故事时，你应该专心研究想要回答的问题。然后你可以找出哪些数据集对你是最有价值的。在这个初始阶段，不要过分纠结于分析数据的工具或数据处理过程。

寻找数据集

如果使用搜索引擎来寻找数据集，你不一定总能找到最合适的。有时你需要在一个网站中仔细寻找数据。即使数据难以找到或难以获取，也一定不要放弃！

如果你的主题出现在一份调查或报告中，或者某个特定的机构或组织可能会收集关于这一主题的数据，你应该去查找联系人号码，并联系研究人员或组织。礼貌而直接地询问如何能够访问他们的数据。如果数据集属于政府部门（联邦、州或地方），那么根据（美国）信息自由法案（https://en.wikipedia.org/wiki/Freedom_of_Information_Act_(United_States)），你也许可以合法地直接获取这些数据。我们将在第 6 章中更全面地讨论数据获取。

一旦确定了需要的数据集并获取了这些数据，你需要将其转换成可用的格式。在第 3、4、 5 章中，你将会学习各种方法，用于通过编程的方式获取数据并对其进行格式转换。第 6 章讲的是在获取数据时如何与不同的人打交道，并稍稍提到合法性的问题。在第 3 章至第 5 章中，我们还将介绍如何从 CSV、Excel、XML、JSON 和 PDF 文件中提取数据。在第 11、12、13 章中，你将会学习如何从网站和 API 中提取数据。

　如果你不认识某些缩写词，别担心！在遇到这些缩写词时我们会详细解释，也会一并解释其他你可能不熟悉的技术术语。

在取得数据并转换格式后，你将开始初步的数据探索。你将会探寻数据中可能隐藏的所有故事，同时判断哪些故事有用，哪些故事可以舍弃。你还可以把数据分组，观察各组之间的变化趋势。然后还可以将数据集合并，寻找其中的关联，发现更大的趋势，并发现潜在的矛盾之处。在这一过程中，你将学习如何清洗数据，发现并解决隐藏在数据集中的问题。

在第 7 章和第 8 章中你将学习如何解析与清洗数据。你既可以使用 Python，也可以探索其他开源工具。在讨论可能遇到的数据问题时，你将学会应该选择哪种方法清洗数据：是写一个清洗脚本，还是使用现成的方法。在第 7 章中，我们将讲到如何处理常见的错误，如重复记录、离群值与格式化问题。

在确定你要讲述的故事，清洗并处理数据之后，我们将探讨如何利用 Python 来展示数据。你将学习用各种形式来讲故事，并比较不同的发布方式。在第 10 章中，你将学习在网站上展示与组织数据的基本方法。

第 14 章将使数据分析过程规模化，让你可以在更短的时间内处理更多的数据。我们将对存储与获取数据的方法进行分析，并研究在云中使数据规模化的方法。

第 14 章还将讨论如何完成一次性的项目，并使其能够自动完成。通过自动化过程，你可以将一次性的特殊报告变成年度报告。这种自动化可以让你专注于改善讲故事的过程，继续讲下一个故事，或者至少续杯咖啡。本书主要使用的工具是 Python 编程语言。在我们讲故事过程的每一步，从最初的探索直到标准化与自动化，都会用到 Python。

1.1　为什么选择Python

有那么多种编程语言，本书为什么选择使用 Python ？你可能听说过以下这些语言中的一种或多种：R、MATLAB、Java、C/C++、HTML、JavaScript 和 Ruby，这取决于你的专业背景。这些语言都有一种或多种主要用途，有些还可以用于数据处理。你也可以用 Excel 等程序进行数据处理。用 Excel 和 Python 编程，通常会得到相同的结果，但其中一种语言的效率更高。然而有时候，像 Excel 这样的程序无法完成任务。我们选择 Python 而不是其他语言，是因为 Python 很容易上手，处理数据的过程也简单明了。

如果你想了解 Python 和其他语言的技术性区别，可查阅附录 A。看完里面的解释，你就可以告诉其他分析师或开发人员你使用 Python 的原因。我们相信作为开发新人，你会从 Python 的易用性中受益，同时我们希望本书能成为你的数据处理工具箱中有参考价值的一本书。

Python 除了上述作为语言的优点，Python 社区还是最开放和最乐于助人的社区之一。世上没有完美的社区，但 Python 社区为新人创造了一个良好的环境：有时会有本地的教程、免费课堂、线下聚会等，有时还会举办大型会议，人们聚在一起解决问题并分享知识。

拥有大型社区的好处显而易见——有人能回答你的问题，有人能为你的代码或模块的结构出谋划策，有人能让你学习借鉴，还有共享代码供你在其之上构建自己的代码。想了解更多的内容，可查阅附录 B。

社区因成员的支持而存在。刚开始使用 Python 时，你从社区中得到的帮助会多于你的付出。但即使你不是专家，也可以为社区做很多贡献。我们鼓励你分享你的问题和解决方法。这会帮到下一个遇到相同问题的人，你也可能会在开源工具中发现需要处理的 bug。

　在现阶段，你是从新鲜视角看待问题，而 Python 社区的很多成员已经不再这样看待问题了。当开始敲 Python 代码时，你应该把自己当作编程社区的一员。你的贡献与那些有 20 年编程经验的人一样重要。

言归正传，我们开始讲 Python。

1.2　开始使用Python

编程的第一步是最难的。（其难度和你学习走路时迈的第一步没有什么不同！）回想一下你培养新爱好或学习新运动的时候。在入门 Python（或任何其他编程语言）时，你将会遇到类似的焦虑和障碍。或许你很幸运，有一个优秀的导师帮你迈出第一步。如果没有的话，你可能已经历过类似的挑战。无论你如何迈出第一步，一定要记住，这个阶段往往是最艰难的。

　我们希望本书可以成为你的入门指南，但它无法替代优秀的导师或使用 Python 的丰富经验。在本书中，我们提供了一些资源和网站信息，以便你在遇到书中没有讲到的问题时有所参考。

为了避免浪费太多时间进行大量配置和高级安装，我们的 Python 环境将采用最简单的初始安装。在接下来的几节中，我们将选定 Python 版本，安装 Python 以及一个可以帮助我们使用外部代码和库的工具（pip），并安装代码编辑器，以便编写并运行代码。

1.2.1　Python版本选择

你需要选择使用哪个版本的 Python。Python 版本实际上指的是 Python 解释器的版本。解释器让你可以在计算机上读写并运行 Python。维基百科（https://en.wikipedia.org/wiki/Interpreter_(computing)）是这样描述解释器的：

在计算机科学中，解释器是一种计算机程序，可以直接运行用编程语言或脚本语言编写的指令，而无需事先将其编译成机器语言程序。

没有人会让你背诵这个定义，所以即使你没有完全理解也不必担心。本书的作者之一 Jackie 刚开始编程的时候，无法理解入门书中“批处理编译”（batch compiling）这一概念，所以她就卡在这一部分无法继续读下去。如果她连这都不懂，怎么能编程呢？我们在后面会讨论编译的话题，但是现在将上述定义总结如下：

解释器是读取并运行 Python 代码的计算机程序。

Python（或 Python 解释器）主要有两个版本：Python 2.X 和 Python 3.X。Python 2.X 的最新版本是 Python 2.7，这也是本书使用的 Python 版本。Python 3.X 的最新版本是 Python 3.5，这也是最新可用的 Python 版本。目前你可以认为 Python 2.7 的代码无法在 Python 3.4 中运行。专业的说法是，Python 3.4 破坏了向后兼容。

你可以写出同时兼容 Python 2.7 和 Python 3.4 的代码，但这既非本书的要求，也不是本书的重点。一开始就在这件事情上花费大量精力，就好比住在佛罗里达州的人担心如何在雪中开车一样 2。有一天你可能会需要这项技能，但此时此刻这并不是你需要关注的重点。

2美国佛罗里达州又名“阳光之州”，一年四季很少下雪。——译者注

有些读者可能会好奇我们为何决定使用 Python 2.7 而不是 Python 3.4。这个话题在 Python 社区中是很有争议的。Python 2.7 是被广泛使用的版本，而 Python 3.X 正在被逐步接受。我们之所以选择 Python 2.7，是想保证你能够找到容易阅读且容易获取的资源，并确保你的操作系统及服务支持你使用的 Python 版本。

　本书中的很多代码在 Python 3 中也可以运行。如果你想在 Python 3 中运行其中一些示例，当然可以；但我们更希望你专注于学习 Python 2.7，在学完本书后再继续学习 Python 3。为了兼容 Python 3 需要修改代码，想了解这方面的更多内容，可以查看官方更改文档：https://docs.python.org/3.0/whatsnew/3.0.html。

在阅读本书的过程中，你会用到自己编写的代码，还会用到其他（牛）人编写的代码。这些外部代码大部分都可以在 Python 2.7 下运行，但可能无法兼容 Python 3.4。如果你正在使用 Python 3 的话，需要重写代码（如果对遇到的每一段代码都要进行重写和编辑，这会浪费大量的时间，你可能很难完成第一个项目）。

把你的第一段代码当作草稿，以后你可以回来进一步修订完善。现在我们开始来安装 Python。

1.2.2　安装Python

好消息是，Python 可以在所有操作系统上运行。坏消息是，不同操作系统的安装方法有所不同。按照 Python 编程的流行程度排序，我们将讨论两个主要的操作系统：Mac OS X 和 Windows。如果你用的是 Mac OS X 或 Linux，可能已经安装了 Python。想了解更详细的安装过程，我们推荐以你的 Linux 发行版加“Python 高级安装”（advanced Python setup）为关键词在 Web 上搜索，以寻求更多建议。

　与 Windows 系统相比，在 OS X 系统和 Linux 系统上安装和运行 Python 要容易一些。想深入了解这种差异存在的原因，我们推荐阅读 Windows 和基于 Unix 的操作系统的历史。可以对照阅读这两篇文章：一篇是 Hadeel Tariq Al-Rayes 写的“Studying Main Differences Between Linux & Windows Operating Systems”（http://www.ijens.org/vol_12_i_04/126704-8181-ijecs-ijens.pdf），文中的观点支持 Unix；另一篇是微软的“Functional Comparison of UNIX and Windows”（https://technet.microsoft.com/en-us/library/bb496993.aspx）。

如果你用的是 Windows，应该也可以运行所有的代码；但是在 Windows 下可能还需要安装代码编译器和额外的系统库，以及设置环境变量。

为了安装 Python 并能正常使用，请根据你的操作系统安装提示，按照相应的步骤来操作。我们还将进行一系列的测试，确保一切正常运行，然后才能进入下一章。

Mac OS X系统

首先打开终端（http://en.wikipedia.org/wiki/Terminal_(OS_X)），一个让你可以和计算机进行交互的命令行界面。个人计算机（PC）刚刚出现时，你只能通过命令行界面与计算机交互。现在大多数人用的都是图形界面操作系统，因为这种操作系统访问更方便，用户也更加广泛。

有两种方法可以在计算机上找到终端。第一种方法是通过 OS X 系统的 Spotlight。点击 Spotlight 图标——屏幕右上角的放大镜——输入“Terminal”。然后选择出现在“应用程序”（Applications）类别旁边的图标。

选定之后，会弹出一个类似图 1-2 中的小窗口（请注意，你的 Mac OS X 版本不同，界面可能也会有所不同）。

图 1-2：利用 Spotlight 搜索终端

你还可以通过 Finder 启动终端。终端位于“实用工具”（Utilities）文件夹：应用程序（Applications）→实用工具（Utilities）→终端（Terminal）。

选择并启动终端后，你应该会看到类似图 1-3 的界面。

图 1-3：新打开的终端窗口

现在最好在适当的位置（比如在 Dock 中）创建终端的快捷方式，这样以后启动终端会比较方便。你只需在 Dock 中的终端图标上点击右键，依次选择“选项”（Options）和“在 Dock 中保留”（Keep in Dock），即可创建终端的快捷方式。运行本书中的每一个练习都需要启动终端。

大功告成。Mac 电脑预装了 Python，你无需再做其他事情。如果你想设置计算机使其能够使用后面的高级库，请查看附录 D。

Windows 8和Windows 10

Windows 并没有预装 Python，但有专门的 Python 安装程序（https://www.python.org/downloads/windows/）。你需要弄清楚你的 Windows 是 32 位还是 64 位（http://windows.microsoft.com/en-us/windows7/find-out-32-or-64-bit）。如果你用的是 64 位 Windows，需要从下载页面下载 x86-64 MSI 安装程序；如果不是 64 位的话，你可以下载 x86 MSI 安装程序。

下载好安装程序之后，只需双击它，按照提示一步步安装即可。我们建议选择为所有用户安装。点击靠近选项的方框选中全部，同时选择在硬盘上安装 Python 特性（见图 1-4）。

图 1-4：利用安装程序添加 Python 特性

成功安装 Python 后，你需要将 Python 加入到环境设置中。这样你就可以在 cmd 工具（Windows 下的命令行界面）中与 Python 交互。要做到这一点，只需在计算机中搜索“环境变量”（environment variable）。选择“编辑系统环境变量”（Edit the system environment variables）选项，然后点击“环境变量…”（Environment Variables...）按钮（见图 1-5）。

图 1-5：编辑环境变量

将“系统变量”（System variables）列表下拉，选择 Path 变量，然后点击“编辑”（Edit）。如果列表中找不到 Path 变量，点击“新建”（New）创建一个新的 Path 变量。

将下列内容添加到 Path 变量的末尾，注意每一个路径之间要用分号隔开（包括已有内容末尾的分号）：

C:\Python27;C:\Python27\Lib\site-packages\;C:\Python27\Scripts\;

Path 变量的末尾应该类似图 1-6 所示。编辑好环境变量之后，点击“确定”（OK）来保存设置。

图 1-6：将 Python 加入 Path 系统变量

1.2.3　测试Python

现在你应该已经打开命令行（终端或 cmd3），准备启动 Python。在 Mac 上命令行的结尾应该是 $ 符号，在 Windows 上应该是 > 符号。在提示符后输入 python，然后按 Return（或 Enter）键：

3想在 Windows 上打开 cmd 工具，只需搜索“命令提示符”（Command Prompt），或者打开“所有程序”（All Programs），依次选择“附件”（Accessories）和“命令提示符”（Command Prompt）。

$ python

一切正常的话，你应该会看到 Python 提示符（>>>），如图 1-7 所示。

图 1-7：Python 提示符

对于 Windows 用户，如果没有出现 Python 提示符，请检查 Path 变量的设置是否正确（如前文所述），软件是否均已正确安装。如果你用的是 64 位版本，可能需要卸载 Python（可以用下载的 MSI 文件对 Python 安装进行修改、卸载和修复），并尝试安装 32 位版本。这样还不行的话，我们建议你将安装过程中遇到的具体错误信息放到网上去搜索。

　>>> 与 $ 或 > 的对比

Python 提示符与系统提示符（在 Mac/Linux 上是 $，在 Windows 上是 >）有所不同。初学者往往犯这样的错误：在默认的终端提示符后面输入 Python 命令，而在 Python 解释器中输入终端命令。两种情况都会报错。遇到报错时，记住上面这两种错误，检查确认你只在 Python 解释器中输入 Python 命令。

如果把本应输入到系统终端的命令输入到 Python 解释器中，可能会出现 NameError（命名错误）或 SyntaxError（语法错误）。而如果在系统终端中输入 Python 命令，可能会出现 bash 错误 Command not found。

Python 解释器启动时给出了几行有用的信息。其中一行显示的是我们正在使用的 Python 版本（图 1-7 中显示的是 Python 2.7.5）。这一信息在错误排查过程中非常重要，因为有些时候，某些命令或工具可以在这一版本的 Python 中正常运行，在另一版本中却无法正常运行。

下面我们用 import 语句来快速测试一下 Python 的安装是否成功。在 Python 解释器中输入下列代码：

import sys
import pprint
pprint.pprint(sys.path)

代码的输出应该是一个列表，列表里面是你计算机中的许多目录或位置。这个列表给出的是 Python 程序寻找 Python 文件的具体位置。当你想要排查 Python 的模块导入错误时，这一组命令十分有用。

下面是一个输出实例（你的计算机输出的列表可能会有所不同；还需要注意的是，为了适应本书的页面宽度，对某些内容进行了折行）：

['',
 '/usr/local/lib/python2.7/site-packages/setuptools-4.0.1-py2.7.egg',
 '/usr/local/lib/python2.7/site-packages/pip-1.5.6-py2.7.egg',
 '/usr/local/Cellar/python/2.7.7_1/Frameworks/Python.framework/Versions/2.7/lib/python27.zip',
 '/usr/local/Cellar/python/2.7.7_1/Frameworks/Python.framework/Versions/2.7/lib/python2.7',
 '/usr/local/Cellar/python/2.7.7_1/Frameworks/Python.framework/Versions/2.7/lib/python2.7/lib-tk',
 '/Library/Python/2.7/site-packages',
 '/usr/local/lib/python2.7/site-packages']

如果代码运行失败，你会得到错误信息。调试 Python 错误最简单的方法就是阅读错误信息。例如，如果你输入的是 import sus 而不是 import sys，你会看到以下输出：

>>> import sus
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ImportError: No module named sus

阅读最后一行：ImportError: No module named sus。这句话的意思是出现了模块导入错误，因为 Python 中没有 sus 模块。Python 搜遍计算机中的文件，没有找到名为 sus 且可导入的 Python 文件或文件夹。

如果在敲本书代码时犯了拼写错误，可能会引发语法错误。在接下来的例子中，我们故意把 pprint.pprint 拼错，输入的是 pprint.print(sys.path())：

>>> pprint.print(sys.path())
  File "<stdin>", line 1
    pprint.print(sys.path())
               ^
SyntaxError: invalid syntax

上面我们是故意输错的，但在本书的写作过程中，本书的一位作者真的输错了这行代码。你要习惯于在出现错误时排查并解决错误。你也应该承认，作为开发人员，错误是学习过程的一部分。我们希望你能对错误习以为常。你应该把错误看作一次学习的机会，可以让你学习 Python 和编程的新知识。

模块导入错误和语法错误都是代码开发过程中最常见的错误，并且也是最容易解决的。在遇到错误时，网络搜索引擎是帮你处理错误的得力助手。

在进入下一节之前，一定要退出 Python 编辑器。这样你就回到了终端或 cmd 的提示符界面。输入以下代码来退出 Python：

exit()

现在你的提示符应该变回了 $（Mac/Linux）或 >（Windows）。在下一章中我们将进一步研究 Python 解释器。接下来我们来安装一个叫作 pip 的工具。

1.2.4　安装pip

pip（http://pip.readthedocs.org/en/latest/）是用于管理 Python 共享代码和库的命令行工具。程序员经常会解决相同的问题，所以会分享他们的代码来帮助他人。这也是开源软件文化的一个重要组成部分。

Mac 用户可以通过在终端中运行下载的简单 Python 脚本来安装 pip（https://pip.pypa.io/en/latest/installing/#install-pip）。你需要与下载的脚本处于同一文件夹。例如，如果你将脚本下载到 Downloads 文件夹中，你需要在终端中进入这个文件夹。在 Mac 上有一种简单的方法，按住 Command 键（Cmd），然后把 Downloads 文件夹拖入终端中。另一种方法是输入一些简单的 bash 命令（附录 C 中有对 bash 更全面的介绍）。首先在终端中输入以下命令：

cd ~/Downloads

这条命令将计算机的目录切换到主文件夹下的 Downloads 子文件夹。为了检查你是否位于 Downloads 文件夹下，在终端中输入以下命令：

pwd

这条命令可以在终端中显示你的当前工作目录，即你现在位于的文件夹。它的输出应该是类似下面这样的内容：

/Users/your_name/Downloads

如果输出没问题，你只需用这个命令就可以运行该文件：

sudo python get-pip.py

由于你运行的是 sudo 命令（你正在用特殊权限运行该命令，这样才可以在受限区域安装软件包），系统会提示你输入密码。你应该可以看到安装软件包的一系列提示信息。

　Windows 上可能已经安装了 pip（Windows 的 Python 安装包自带 pip）。要 检查是否安装了 pip，你可以在 cmd 工具中输入 pip install ipython。如果系统报错的话，去下载 pip 安装脚本，用 chdir C:\Users\YOUR_NAME\Downloads 命令将目录切换到 Downloads 文件夹（把 YOUR_NAME 换成你计算机主目录的名字）。然后，你应该可以输入 python get-pip.py 来运行下载的文件。你需要管理员权限来确保正确安装。

在使用 pip 时，你的计算机在 PyPI（https://pypi.python.org/pypi）上搜索指定的代码包或代码库，将其下载到计算机中并安装。你无需使用浏览器来下载代码库，省去了许多麻烦。

安装工作已经基本完成。最后一步是安装代码编辑器。

1.2.5　安装代码编辑器

在写 Python 代码时需要一个代码编辑器，因为 Python 需要特殊的间距、缩进和字符编码才能正常运行。有多种代码编辑器可供选择。本书的一位作者使用 Sublime。它是免费的，但在你使用一段时间后会建议你象征性地支付一点费用，以支持当下和未来的开发。你可以在 http://www.sublimetext.com 下载 Sublime。另一款完全免费且跨平台的文本编辑器是 Atom（https://atom.io）。

有些人对代码编辑器比较挑剔。你不一定要使用我们推荐的编辑器，但我们建议不要使用 Vim、Vi 或 Emacs，除非你用过这些工具。一些编程的纯粹主义者只用这些工具来写代码（本书的一位作者就是这样），因为他们可以完全用键盘来操纵编辑器。但是，如果你没有任何经验就选择这些编辑器的话，可能会在阅读本书的过程中困难重重，因为你要同时学习两件事情。

　一次只学习一件事情，多试用几款编辑器，直到找到可以让你轻松自如写代码的那一款。对于 Python 开发来说，最重要的是要有一款用起来舒服的编辑器，同时支持许多文件类型（能支持 Unicode 和 UTF-8）。

下载选定的编辑器并安装之后，运行程序检查是否安装成功。

1.2.6　安装IPython（可选）

如果你想安装更高级的 Python 解释器，我们推荐安装一个叫 IPython 的库（下载地址： http://ipython.org/install.html）。在附录 F 中我们讲了 IPython 的优点、用例，以及如何安装 IPython。IPython 并不是必需的，但它在 Python 入门阶段十分有用。

1.3　小结

本章我们学习了两个常见的 Python 版本。为了能够继续学习数据处理，我们还完成了一些初始安装工作：

(1) 我们安装并测试了 Python；

(2) 我们安装了 pip；

(3) 我们安装了代码编辑器。

想要入门 Python，这是最基本的安装。随着对 Python 和编程的深入学习，你会发现还有更复杂的安装与设置。现在我们的目标是让你尽快上手，而不是纠结于繁琐的安装过程。如果你想了解更高级的 Python 安装内容，请查阅附录 D。

在阅读本书的过程中，你可能会用到需要高级安装的工具。那时我们将教你如何从现有基本安装中创建更复杂的安装。现在，我们之前讲的安装内容已经可以让你迈出学习 Python 的第一步了。

恭喜，你已经完成了初始安装，还第一次运行了几行 Python 代码！在下一章里，我们将开始学习 Python 的基本概念。

第 2 章　Python基础

前面你已经在计算机上安装并运行了 Python，下面我们来学习一些 Python 的基础知识。后续章节的内容都建立在这些概念的基础上，我们需要首先掌握这些概念。

在上一章中，我们利用下面几行代码测试了 Python 的安装是否成功：

import sys
import pprint
pprint.pprint(sys.path)

读完本章，你将会明白上面每一行代码的含义，并学会相关术语，能够说明上述代码的作用。你还将学习 Python 的各种数据类型，对 Python 的入门概念有基本的了解。

我们会讲得比较快，把重点放在学习后续章节时需要知道的内容。后续章节中我们还会根据需要讲一些新概念。我们希望这种方法能让你将这些新概念应用于感兴趣的数据集和问题，并从中学到东西。

首先我们启动 Python 解释器。本章的 Python 代码都在解释器中运行。浏览一遍本章这样的介绍性内容是很容易的，但我们要着重强调动手敲代码的重要性。与学习一门口语类似，在实践中学习是最有效的学习方法。在你输入本书练习代码并运行的过程中，会遇到大量的错误，而调试过程（解决这些错误的过程）会让你学到很多知识。

启动 Python 解释器

在第 1 章里我们学过如何启动 Python 解释器。提醒一下，你首先需要打开命令行工具，然后输入 python（如果你安装了附录 F 中提到的 IPython，也可以输入 iPython）：

python

你应该会看到类似这样的输出（注意，提示符已经切换成 Python 解释器的提示符）：

Python 2.7.7 (default, Jun 2 2014, 18:55:26)
[GCC 4.2.1 Compatible Apple LLVM 5.1 (clang-503.0.40)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>

如果没有特别说明的话，本章后面我们输入的所有内容都是在 Python 解释器中输入的。如果你用的是 IPython，提示符是类似 In [1]: 这样的。

2.1　基本数据类型

本节将学习 Python 中的简单数据类型。这些数据类型都在 Python 的信息处理过程中发挥重要作用。我们要学习的数据类型包括字符串型、整型、浮点型和其他非整数类型。

2.1.1　字符串

我们要学习的第一个数据类型是字符串型。你之前可能没有在这种语境下听过“字符串”这个词，但字符串实际上就是用引号标记的文本。字符串里可以包含数字、字母和符号。

下面都是字符串的例子：

'cat'
'This is a string.'
'5'
'walking'
'$GOObarBaz340   '

将上面任一字符串输入到 Python 解释器里，解释器会返回相同的内容。程序仿佛在说：“嘿，我听到你了。你说的是 'cat'（或任何你输入的内容）。”

只要字符串位于成对的引号（单引号或双引号都可以）之间，其内容无关紧要。字符串的首尾必须用相同的引号（单双均可）：

'cat'
"cat"

上面两个例子在 Python 中的含义是相同的。两种情况下，Python 都会返回单引号的 'cat'。有些人习惯于在代码中使用单引号，另一些人更喜欢用双引号。无论你用哪种引号，重要的是前后风格要保持一致。本书的两位作者更喜欢用单引号，因为输入双引号需要按住 Shift 键。输入单引号可以偷懒。

2.1.2　整数和浮点数

我们要学习的第二种和第三种数据类型是整型和浮点型，这是 Python 中处理数字的方法。首先来看整型。

整数

你可能还记得数学课上学过的整数，但如果你忘记了，整数是指整数（an integer is a whole number）。下面是一些整数的例子：

10
1
0
-1
-10

将上面的整数输入到 Python 解释器里，解释器会返回相同的内容。

注意，上一节的字符串例子中有一个是 '5'。如果输入的数字是在引号中，Python 会将其看作字符串。在下面的例子中，第一个值和第二个值并不相等：

5
'5'

想测试二者是否相等，可以在解释器中输入：

5  ==  '5'

== 符号是用来测试两个值是否相等。测试的返回值是真（True）或假（False）。返回值是另一种 Python 数据类型，叫作布尔型。后面会讲到布尔型，这里先简单学习一下。布尔型可以判断语句是真还是假。在上面的语句中，我们问 Python：整数 5 与字符串 '5' 是否相等？Python 返回了什么？怎样才能让语句的返回值为 True ？（提示：试一下两个都是整数或两个都是字符串的情况！）

你可能想知道，为什么有人会把数字当作字符串来存储。有时是因为使用不当——例如，代码将数字存储成 '5'，而实际上应该存储成 5，没有引号。再举一个例子，字段内容是人工填写的，里面可能既包含字符串又包含数字（例如，调查中人们可能输入“五”“5”或“V”）。它们都是数字，但是数字的不同表示方式。在这种情况下，你可能会先把它们存储成字符串，后面再进行处理。

将数字存储为字符串，最常见的原因之一是故意这么做，例如美国邮政编码的存储。美国的邮编共包含五位数字。在新英格兰地区和东北部的其他地方，邮编是以 0 开头的。在 Python 解释器中试着输入波士顿的一个邮编，一次作为字符串输入，一次作为整数输入。出现了什么情况？

'02108'
02108

在第二个例子中，Python 会抛出一个语法错误［SyntaxError，报错信息为无效的标记（invalid token），并且有一个箭头指向开头的 0］。在 Python 以及许多其他语言中，“标记”（token）是指特殊的单词、符号和标识符。在上面的例子中，Python 不知道如何处理以 0 开头的数（非八进制的数），这意味着它是一个无效的标记。

浮点数、小数和其他非整数类型

Python 处理非整数的运算有许多种方法。如果你不了解每一种非整数数据类型的运算方法，这些方法可能会令人感到困惑，而且似乎引入了舍入误差。

在 Python 中使用非整数时，Python 默认将其转换成浮点数。浮点数的表示方法采用的是各版本 Python 内置的浮点数据类型。也就是说，Python 存储的是该数值的近似值——仅反映特定精度水平的近似值。

在 Python 解释器中输入下面这两个数字，注意二者之间的不同：

2
2.0

第一个是整数。第二个是浮点数。我们做点计算，进一步了解这些数字的运算规则，以及 Python 如何对它们进行求值。在 Python 解释器中输入：

2/3

发生了什么？你得到的返回值是 0，但你可能本来以为会是 0.6666666666666666、0.6666666666666667 或类似的数字。问题在于，这些数字都是整型，整型无法完成分数运算。我们试着把其中一个数字变成浮点数：

2.0/3

现在我们得到了更精确的答案：0.6666666666666666。如果输入的数字中有一个是浮点数，计算的结果也是浮点数。

如前文所述，Python 中的浮点数可能会引起精度问题（https://docs.python.org/2/tutorial/floatingpoint.html）。浮点数的运算速度很快，但正是因为这一点，浮点数不够精确。

从原理上来说，Python 与你和计算器对待数字的方式都不同。在 Python 解释器中试试下面这两个例子：

0.3
0.1 + 0.2

对于第一行代码，Python 返回 0.3。对于第二行代码，你本来希望返回 0.3，但实际上你得到的返回值是 0.30000000000000004。0.3 和 0.30000000000000004 这两个值并不相等。如果对其中的细微差别感兴趣，可查阅 Python 文档（https://docs.python.org/2/tutorial/floatingpoint.html#tut-fp-issues）了解更多内容。

在本书中，需要考虑精度问题时，我们将使用 decimal 模块（或库，https://docs.python.org/2/library/decimal.html）。模块是可导入使用的代码段或代码库。decimal 模块可以让数字（整数或浮点数）的运算结果符合预期（与你在数学课上学到的概念一致）。

在下面的例子中，第一行代码从 decimal 模块中导入了 getcontext 和 Decimal，这样我们就可以在解释器环境中使用它们。接下来的两行代码利用 getcontext 和 Decimal 来做之前用浮点数做过的数学运算：

from decimal import getcontext, Decimal
getcontext().prec = 1
Decimal(0.1) + Decimal(0.2)

运行上面的代码，Python 会返回 Decimal('0.3')。现在你输入 print Decimal(0.3)，Python 会返回 0.3，这也正是我们本来希望看到的值（而不是 0.30000000000000004）。

我们来一行一行地阅读这三行代码：

from decimal import getcontext, Decimal      ➊
getcontext().prec = 1                        ➋
Decimal(0.1) + Decimal(0.2)                  ➌

❶ 从 decimal 模块中导入 getcontext 和 Decimal。

❷ 将舍入精度设定为一位小数。decimal 模块将大部分的舍入和精度设置保存在默认的上下文（context）中。本行代码将上下文的精度改成只保留一位小数。

❸ 对两个小数（一个值为 0.1，一个值为 0.2）求和。

如果修改 getcontext().prec 的值会怎么样？动手试一下，然后重新运行最后一行代码。你应该会看到不一样的结果，这取决于你设置的是保留几位小数。

如前文所述，在数据处理过程中你会遇到许多数学上的细节问题。你可能需要完成的数学运算也有许多不同的方法，但在用到非整数时，使用小数型数据可以使计算精度更高。

Python 中的数字

Python 中的数字类型有不同的精度水平，这是 Python 语言的缺点之一。随着对数据处理的深入学习，我们将在本书中学到更多 Python 中与数字和数学相关的库。要是你现在就想知道，并且打算做一些不那么基础的数学运算，下面是一些你需要熟悉的 Python 库。

decimal（https://docs.python.org/2/library/decimal.html），用于定点运算和浮点运算。

math（https://docs.python.org/2/library/math.html），可以使用 C 语言标准所定义的数学函数。

numpy（http://docs.scipy.org/doc/numpy/reference/routines.math.html），Python 科学计算的基础包。

sympy（http://docs.sympy.org/latest/index.html），用于符号数学的P ython 库。

mpmath（http://mpmath.org/）用于任意精度实数和复数浮点运算的 Python 库。

我们已经学过了字符串型、整型和浮点型 / 小数型。下面以这些基本数据类型为基础，创建更复杂的数据类型。

2.2　数据容器

本节会讲到数据容器，里面装有许多数据点（data point）。但应该注意的是，这些容器本身也是 Python 的数据类型。Python 中有几种常见的容器：变量、列表和字典。

2.2.1　变量

变量为我们提供了保存数据的方法，这些数据包括字符串、数字或其他数据容器。变量由一串字符组成，通常是一个小写单词（或用下划线连接的多个单词），用来说明变量包含的内容。

我们试着创建一个简单的变量。在 Python 解释器中，试试下面的代码：

filename = 'budget.csv'

输入正确的话，解释器应该不会返回任何值。这与在 Python 解释器中输入字符串有所不同。如果在 Python 解释器中只输入 'budget.csv'，它会输出 'budget.csv'。

当你创建一个变量时，是将程序本来应该输出的数据赋值给这个变量。这也是创建新变量时没有返回值的原因。在上面的例子中，我们的变量叫作 filename，其中保存着我们输入的字符串（'budget.csv'），作为它的值。

面向对象编程

你可能听说过面向对象编程（object-oriented programming，OOP）。Python 是一门面向对象的编程语言。“面向对象编程”中的“对象”可以是本章我们学过的任意一种数据类型，例如字符串、变量、数字或浮点数。

在正文给出的例子中，我们的对象是一个字符串，此时保存在 filename 中。我们定义的每一个变量都是一个 Python 对象。在 Python 中，我们用对象来保存后面要用到的数据。这些对象通常具有不同的性质和行为，但它们都是对象。

例如，每一个整数对象都可以用 + 号（加法运算符）与另一个整数相加。继续学习 Python 的过程中，你会学到关于这些对象及其基础类型的性质和行为的更多内容，也会因此喜欢上面向对象编程的！

前面在创建一串字母并将其赋值给名为 filename 的变量时，我们遵循了变量命名的几条通用规则。不用担心记不住这些规则，但如果在代码中定义新变量时出现错误，一定要想起这些规则。

可以包含下划线，但不能包含连字符。

可以包含数字，但变量名不能以数字开头。

为了方便阅读，单词用小写字母，单词之间用下划线隔开。

试一试下面的代码：

1example = 'This is going to break.'

发生了什么？你得到了什么类型的错误？你应该得到的是语法错误，因为你违反了第二条规则。

只要不违反 Python 变量命名的规则，变量几乎可以任意命名。例如：

horriblevariablenamesarenotdescriptiveandtoolong = 'budget.csv'

可以看出，变量名太长，并且没有对变量内容进行说明。另外，缺少下划线，让人难以读懂。什么样的变量名是一个好的变量名？问自己一个问题：六个月之后，如果我已经把代码全部忘记了，什么样的变量名仍是有意义的，并能帮我理解代码的内容？

我们来看一个更合理的变量名——cats。如前一个例子所示，变量的值不一定要是文件名。

变量可以有许多不同的值和名字。我们假装在数猫咪的数量，所以要把一个整数赋值给变量 cats：

cats = 42

如果 Python 脚本记录了我们有多少只猫咪，我们不需要每时每刻都知道确切的数字。我们只需要知道这个值被保存在变量 cats 中，所以，当我们在解释器中调用 cats 时，或者在另一段代码中用到 cats 时，它总会返回当前猫咪的数量。

调用变量，是指请求 Python 返回该变量的值。我们来调用 cats。在解释器中输入 cats。你应该会得到 42 作为返回值。如果输入 filename，你应该会得到字符串 'budget.csv' 作为返回值。在计算机上试一下：

>>> cats
42
>>> filename
'budget.csv'
>>>

如果输入不存在的变量名（或者输错了变量名），你会得到以下错误：

>>> dogs
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'dogs' is not defined

前面说过，学会如何阅读错误信息是很重要的，这可以让你明白犯了什么错误，以及如何改正错误。在这个例子中，错误信息指出，dogs is not defined，意思是我们没有定义过一个叫作 dogs 的变量。由于我们没有定义过这个变量，所以 Python 不知道我们要调用的是什么。

在第一个例子中，如果你忘了在 'budgets.csv' 上加引号，也会得到相同的错误。在 Python 解释器中试一下：

filename = budget.csv

返回的错误信息是 NameError: name 'budget' is not defined。这是因为 Python 并不知道 budget.csv 应该是一个字符串。要记住，字符串总是用引号来标记。没有引号的话， Python 会试图将其解释成另一个变量。这个练习的要点就是，注意错误发生在哪一行，并问问自己：错误可能出在哪里？在 dogs 的例子中，错误信息指出，错误发生在第 1 行。如果有很多行代码，错误信息可能会指向第 87 行。

前面给出的所有例子都是短字符串或整数。变量也可以保存长字符串，甚至是跨很多行的字符串。在例子中我们选择用短字符串，是因为对你（或我们）来说，输入长字符串一点都不好玩。

试一下保存长字符串的变量。注意，字符串里含有单引号，我们必须用双引号来保存：

recipe = "A recipe isn't just a list of ingredients."

现在输入 recipe，你会得到保存的长字符串：

>>>recipe
"A recipe isn't just a list of ingredients."

变量的数据类型不一定要是字符串或整数。变量可以保存各种不同的 Python 数据类型，我们会在后续章节进一步学习这方面的内容。

2.2.2　列表

列表是具有某种共同关系的一组值。在 Python 中使用列表，和在日常语言中使用列表很相似。在 Python 中，将一列元素放到方括号（[]）中，元素之间用逗号隔开，即可创建列表。

我们在 Python 中创建一个食品的列表：

['milk', 'lettuce', 'eggs']

　上面这个列表由字符串组成，不是由变量组成。这一点是可以看出来的，因为单词都用引号包裹。如果元素都是变量的话，是不会有两边的引号的。

按下 Return 键，Python 会返回以下内容：

['milk', 'lettuce', 'eggs']

你刚刚创建了第一个 Python 列表：字符串列表。你可以创建任意数据类型的列表，或者多种数据类型混合的列表（例如，同时包含浮点数和字符串的列表）。我们来创建一个同时包含整数和浮点数的列表：

[0, 1.0, 5, 10.0]

下面将我们的列表保存在变量中，这样就可以在后面的代码中调用它。变量很有用，因为变量可以让我们不必反复输入数据。如果列表很长，比如说有 5000 个元素那么长，手动输入数据很容易出错，而且效率不高。正如前文所述，变量是保存值的方法，将其保存在合理命名的容器中。

在 Python 解释器中尝试以下代码：

shopping_list = ['milk', 'lettuce', 'eggs']

按下 Return 键，你应该会看到新的一行。看起来似乎什么也没有发生。之前是将列表原样返回，还记得吗？现在 Python 将列表保存在 shopping_list 变量中。如果在 Python 提示符中输入 shopping_list 来调用变量，你应该会得到以下返回值：

shopping_list
['milk', 'lettuce', 'eggs']

列表也可以保存变量。比如说，我们有一个变量，里面保存的是动物收容所中动物的数量：

cats = 2
dogs = 5
horses = 1

现在可以将这些动物的数量放在一个列表中：

animal_counts = [cats, dogs, horses]

在 Python 解释器中输入 animal_counts，Python 会返回下面的值：

[2, 5, 1]

变量为我们保存信息。当我们输入变量名时，Python 返回的是变量中保存的值。

你还可以创建包含列表的列表。比如说，创建动物名字的列表：

cat_names = ['Walter', 'Ra']
dog_names = ['Joker', 'Simon', 'Ellie', 'Lishka', 'Fido']
horse_names = ['Mr. Ed']
animal_names = [cat_names, dog_names, horse_names]

在 Python 解释器中输入 animal_names，Python 会返回下面的值：

[['Walter', 'Ra'], ['Joker', 'Simon', 'Ellie', 'Lishka', 'Fido'], ['Mr. Ed']]

你不必输入所有的名字来创建包含列表的列表。原始变量（cat_names, dog_names, horse_names）也是列表，它们仍然可用。例如，输入 cat_names，你会得到 ['Walter', 'Ra'] 作为返回值。

前面已经探索了列表，下面我们继续探索一个稍微复杂一点的数据容器，叫作字典。

2.2.3　字典

字典比变量或列表更加复杂。“字典”这个名字是很贴切的。将 Python 的字典看作传统意义上的字典——用来查找单词释义的工具。在 Python 的字典中，你要查找的单词叫作键（key），这些单词的释义叫作值（value）。在 Python 中，键对应一个值。

回头看动物的例子。animal_numbers 保存的是我们拥有的不同动物数量的列表，但我们不清楚哪个数字对应哪一种动物。字典是存储这种信息的好方法。

在下面的例子中，我们用动物种类作为键，每种动物的数量作为值：

animal_counts = {'cats': 2, 'dogs': 5, 'horses': 1}

如果想用键来访问一个值，可以从字典中访问键（类似在普通的字典中查单词）。为了在 Python 中实现这样的查找（比如查找我们拥有的狗狗的数量），可以输入以下代码：

animal_counts['dogs']

你应该看到的返回值是 5，因为我们在字典中（'dogs': 5）设置 'dogs' 键对应的值是 5。如你所见，想要存储匹配的键值对时，字典是很有用的。根据你的需求不同，字典可以是非常强大的，所以我们进一步研究同时使用列表和字典。

对于前面动物名字的列表，很难说清楚哪个名字列表属于哪一种动物。哪个列表里面是猫咪的名字，哪个列表里面是狗狗的名字，哪个列表里面是马的名字，谁也搞不清楚。但是，我们可以用字典把动物们区分清楚：

animal_names = {
    'cats': ['Walter', 'Ra'],
    'dogs': ['Joker', 'Simon', 'Ellie', 'Lishka', 'Fido'],
    'horses': ['Mr. Ed']
    }

存储同样的值还有另一种方法，它用到了更多的变量：

cat_names = ['Walter', 'Ra']                                       ➊
dog_names = ['Joker', 'Simon', 'Ellie', 'Lishka', 'Fido']
horse_names = ['Mr. Ed']

animal_names = {
    'cats': cat_names,                                             ➋
    'dogs': dog_names,
    'horses': horse_names
    }

❶ 这一行代码将变量 cat_names 定义为猫咪名字的列表（字符串列表）。

❷ 这一行代码使用变量 cat_names，传递猫咪名字的列表作为字典中键 'cats' 对应的值。

虽然过程有所不同，但两种方法得到的是相同的字典 1。随着对 Python 的深入学习，你将能够更好地判断，什么时候需要定义更多的变量，什么时候不这么做反而更好。现在你会发现，利用许多定义好的不同变量（例如 cat_names 和 dog_names）来创建新的变量（例如 animal_names），这是很容易的。

1两个字典并不完全相同，因为第二个字典中使用了可以被修改的对象。想详细了解二者的区别，请查阅附录 E。

　虽然 Python 有间距和格式的规则，但你的字典格式不一定非得和我们前面的一样。但是，你的代码应该尽可能易于阅读。保证代码的可读性，你自己以及其他合作的开发者都会因为这一点而感谢你的。

2.3　各种数据类型的用途

每种基本数据类型都可以做各种各样的事情。下面列出了我们目前学过的数据类型，并举例说明这些数据类型所能做的各种事情。

字符串

大小写转换

删除字符串末尾的空格

分割字符串

整数和小数

加减运算

简单数学运算

列表

在列表中增加或删除元素

删除列表的最后一个元素

列表重新排列

列表排序

字典

增加一个键 / 值对

将指定的键设置为新的值

利用键查找值

　在上面这个列表中我们故意没有提到变量。变量能做的事情取决于它包含的数据。比如说，如果变量是一个字符串，那么它可以做所有字符串能做的事情。如果变量是一个列表，那么它可以做只有列表才能做的各种事情。

把数据类型看作名词，把它们能做的事情看作动词。在大多数情况下，数据类型能做的事情被称为方法（method）。想要访问数据类型的方法，或者说让数据类型做一些事情，你可以用点号（.）。比如说，如果你将一个字符串赋值给名为 foo 的变量，你可以输入 foo. strip() 来调用该字符串的 strip 方法。我们来看一下其中一些方法的作用。

　在调用字符串的方法时，它们的行为是 Python 默认库的一部分（类似于手机上预装的默认应用），所有 Python 版本共享这一默认库。在每一台运行 Python 的计算机上都可以调用这些方法，因此每一个 Python 字符串都可以共享相同的方法（就像每一台手机都可以打电话，每一台苹果手机都可以发送 iMessage 一样）。Python 标准库（也叫作 stdlib，https://docs.python.org/2/library/）中包含了大量的内置方法和基本数据类型，其中包括你正在使用的 Python 数据类型。

2.3.1　字符串方法：字符串能做什么

回头看最开始定义的变量 filename。我们开始时用 filename = 'budget.csv' 来定义变量。这种定义方法很方便。但有些时候，事情并没有那么简单。来看几个例子：

filename = 'budget.csv        '

注意，现在 filiname 字符串里有许多多余的空格，可能需要将其删掉。可以用 Python 字符串的 strip 方法，这是一个内置函数，可以从头到尾删除字符串中多余的空格：

filename = 'budget.csv        '
filename = filename.strip()

　如果你没有对变量重新赋值的话（令 filename 等于 filename.strip() 的输出值），你对 filename 所做的修改不会被保存。

在 Python 解释器中输入 filename，现在你应该可以看到，空格已经被删除。

比如说，文件名需要全部用大写字母。可以用 Python 字符串内置的 upper 函数将所有的字母转换成大写：

filename = 'budget.csv'
filename.upper()

从输出应该可以看出，现在文件名已经全部大写了：

'BUDGET.CSV'

在这个例子中，我们没有将大写的字符串重新赋值给变量 filename。在解释器中再次调用 filename 时会发生什么？输出应该还是 'budget.csv'。如果你不希望修改你的变量，只想对变量进行转换后使用一次，可以调用类似 upper 这样的方法，这些方法会返回修改后的字符串，但不会改变变量本身。

如果想对变量重新赋值，用同一个变量名保存返回值，应该怎么办呢？接下来，将变量 filename 的值改成全部大写：

filename = 'budget.csv'           ➊
filename = filename.upper()       ➋

❶ 在本行之后调用 filename，输出是 'budget.csv'。

❷ 在本行之后调用 filename，输出是 'BUDGET.CSV'。可以将代码压缩成一行来运行：

filename = 'budget.csv'.upper()

代码的行数有时是你个人的风格或偏好。你可以随意选择你认为合理的方式，但要保持代码清晰、易读、明了。

在上面这些例子中只讲了两个字符串方法：strip 和 upper，但还有许多其他的内置字符串方法。随着在数据处理过程中遇到更多的字符串，我们会学习更多的字符串方法。

2.3.2　数值方法：数字能做什么

整数和浮点数 / 小数都是数学对象。如果你输入 40 + 2，Python 会返回 42。如果你想把结果保存在变量中，可以将其赋值给一个变量，正如我们在字符串的例子中所做的那样：

answer = 40 + 2

现在你输入 answer，得到的返回值是 42。整数能做的大部分事情都是可以预知的，但有可能你需要使用一些特殊的格式，这样 Python 解释器才能理解你想做的数学运算。例如，如果你想计算 42 的平方，那你需要输入 42**2。

整数、浮点数和小数也还有许多其他的方法，我们会在学习数据处理的过程中遇到其中一些方法。

加法和减法

对于 Python 的其他数据类型，你也可以做加法，例如字符串和列表。试一试下面的代码：

'This is ' + 'awesome.'

和

['Joker', 'Simon', 'Ellie'] + ['Lishka', 'Turtle']

试试用减法，看看会发生什么？运行下面的代码会报错，你能从中学到什么？

['Joker', 'Simon', 'Ellie', 'Lishka', 'Turtle'] - ['Turtle']

你应该会得到报错信息：TypeError: unsupported operand type(s) for -: 'list' and 'list'。这告诉我们，Python 列表支持加法，却不支持减法。这是因为Python 开发人员对每种数据类型支持的方法所做的选择。如果你想了解如何对列表做减法，可以查看 Python 官网上列表的 remove 方法（https://docs.python.org/2/tutorial/datastructures.html）。

2.3.3　列表方法：列表能做什么

列表有几个必须知道的方法。我们从一个空列表开始，用一个方法来添加元素。

首先，像这样定义一个空列表：

dog_names = []

在解释器中输入 dog_names，返回的是 []，这是 Python 显示空列表的方式。在本章前面的内容里，这个变量中保存了好几个名字，但在上一行代码中我们重新定义了这个变量，现在它是一个空列表。内置的 append 方法可以向列表中添加元素。现在利用这一方法将“Joker”添加到列表中：

dog_names.append('Joker')

现在输入 dog_names，返回的列表中将包含一个元素：['Joker']。

下面你自己来操作，利用 append 方法创建这样的列表：

['Joker', 'Simon', 'Ellie', 'Lishka', 'Turtle']

比如说，你不小心添加了一个猫咪的名字，'Walter'：

dog_names.append('Walter')

你可以利用 Python 列表内置的 remove 方法删除这个元素：

dog_names.remove('Walter')

列表还有许多其他的内置方法，但 append 和 remove 是其中最常用的两个。

2.3.4　字典方法：字典能做什么

为了学习一些有用的字典方法，我们来从头创建一个动物数量的字典。

在下面的例子中，我们创建了一个空的字典。然后添加了一个键，并给定了这个键对应的值：

animal_counts = {}
animal_counts['horses'] = 1

向字典添加元素（animal_counts['horses']）与向列表添加元素略有不同。这是因为字典既有键又有值。在上面的例子中，键是 'horses'，值是 1。

我们用动物数量来定义字典的其他部分：

animal_counts['cats'] = 2
animal_counts['dogs'] = 5
animal_counts['snakes'] = 0

现在在 Python 解释器中输入 animal_counts，你应该会得到以下字典：{'horses': 1, 'cats': 2, 'dogs': 5, 'snakes': 0}。（Python 字典不会保存元素的顺序，所以你看到的输出可能会有所不同，但应该包含相同的键值对）。

这里讲的是一个非常小的例子，但是编程并非总是这样方便。试想由世界上所有家畜的数量构成的字典。作为程序员，我们可能不知道这个 animal_counts 字典中包含的所有动物种类。在处理一无所知的大型字典时，我们可以利用字典方法来了解字典的更多内容。下面这个命令返回字典包含的所有键：

animal_counts.keys()

如果你把前面的练习都做了，那么在解释器中输入上面的代码会返回一个键的列表，如下所示：

['horses', 'cats', 'dogs', 'snakes']

对于上面任何一个键，你可以从字典中检索到与其对应的值。下面的查询将返回狗狗的数量：

animal_counts['dogs']

这行代码的输出是 5。

如果你愿意，可以将这个值保存在一个新变量中，这样你就无需再次查询：

dogs = animal_counts['dogs']

现在，你直接输入变量 dogs，Python 将返回 5。

这就是你可以对字典做的一些基本操作。随着用代码去解决越来越复杂的问题，我们也会更深入地学习字典，正如字符串和列表一样。

2.4　有用的工具：type、dir和help

Python 标准库中有几个内置工具，可以帮你确定变量的数据类型或对象类型，并给出这些变量能做的事情（即它们都有哪些方法）。本节将学习三个工具，它们都是 Python 标准库的一部分。

2.4.1　type

type 可以帮你确定你的对象属于哪种数据类型。想在 Python 代码中做到这一点，将变量放到 type() 的括号里，例如，如果变量名是 dogs，那就在 Python 提示符后输入 type(dogs)。当你用变量保存数据，并想知道变量里的数据是什么类型时，这一方法是非常有用的。回忆本章前面邮政编码的例子。

对于值 20011，这里有两种不同的用法。在第一个例子中，它是保存成字符串的邮编。在第二个例子中，它是一个整数：

'20011'
20011

如果将这两个值保存在变量中，将更难以确定变量的类型，我们可能不知道或不记得用的是字符串还是整数。

如果将值传递给内置方法 type，Python 就会告诉我们对象属于那种数据类型。试一下：

type('20011')
type(20011)

第一行返回的是 str，第二行返回的是 int。将列表传递给 type 会返回什么？变量呢？

在你试图排查错误时，或者运行其他人的代码时，确定对象的类型是很有用的。还记得我们试图从一个列表中减去另一个列表吗（见 2.3.2 节）？好吧，你也不能从一个字符串中减去另一个字符串。所以，与整数 20011 相比，字符串 '20011' 具有许多不同的方法以及用例。

2.4.2　dir

dir 会返回一个内置方法与属性的列表，帮你列出特定数据类型能做的所有事情。我们用字符串 'cat,dog,horse' 来试一下：

dir('cat,dog,horse')

暂时忽略返回的列表中开头的那些项（以双下划线开头的那些字符串）。这些是 Python 使用的内部方法或私有方法。

最有用的方法包含在返回列表的第二部分。许多方法的用途显而易见，或者说是自说明的（self-documenting）。你应该会看到本章前面使用的一些字符串方法：

[...,
'__sizeof__',
'__str__',
'__subclasshook__',
'_formatter_field_name_split',
'_formatter_parser',
'capitalize',
'center',
'count',
'decode',
'encode',
'endswith',
'expandtabs',
'find',
'format',
'index',
'isalnum',
'isalpha',
'isdigit',
'islower',
'isspace',
'istitle',
'isupper',
'join',
'ljust',
'lower',
'lstrip',
'partition',
'replace',
'rfind',
'rindex',
'rjust',
'rpartition',
'rsplit',
'rstrip',
'split',
'splitlines',
'startswith',
'strip',
'swapcase',
'title',
'translate',
'upper',
'zfill']

看一下字符串 'cat,dog,horse'，它看起来像是保存在字符串里的列表。实际上它是单一值，但利用 Python 字符串内置的 split 方法，我们可以以逗号为分隔符，将字符串切分成更小的字符串，像这样：

'cat,dog,horse'.split(',')

Python 将返回一个列表：

['cat', 'dog', 'horse']

下面对这个列表调用 dir 函数：

dir(['cat', 'dog', 'horse'])

和字符串相比，列表可选的方法没有那么多，但我们来尝试其中几个。首先，将列表转化为变量。你现在应该知道如何将列表赋值给变量，这是一个例子：

animals = ['cat', 'dog', 'horse']

前面用 dir 作用在列表上给出了许多新方法，现在在变量 animals 上试用其中一些方法：

animals.reverse()
animals.sort()

运行每一行代码后，打印输出 animals 的值，这样你就能看出这些方法是如何改变列表的。你预期的输出是什么样的？它与你看到的相同吗？尝试将 dir 方法作用在整数和浮点数上。（提示：dir 只能传入一个对象，所以试着输入 dir(1) 或 dir(3.0)）。其中有没有你没有想到的方法？

如你所见，dir 可以让你深入了解每一种 Python 数据类型的内置方法。在利用 Python 进行数据处理时，这些方法是很有价值的。建议你花时间对感兴趣的上表列出的方法都尝试一下，并用不同的数据类型测试更多的方法。

2.4.3　help

本章要学习的第三个有用的 Python 内置方法是 help 方法。这一方法会返回对象、方法或模块的文档——虽然经常以技术性很强（有时很难懂）的文字书写。来看一下 split 方法的帮助文档，这是我们在前一节用过的方法。如果你不知道需要将字符串的分隔符放在括号内，怎么能知道如何使用 Python 字符串的 split 方法呢？假设我们不知道如何使用 split，不传入 ',' 来调用这一方法：

animals = 'cat,dog,horse'
animals.split()

代码的返回值如下：

['cat,dog,horse']

看起来不错，对吧？但经不起仔细观察。正如我们所见，Python 将字符串转换成一个列表，但并没有利用逗号对多个单词进行分割。这是因为内置的 split 方法默认按空格分割字符串，不是按逗号分割。我们需要在方法中传入一个逗号字符串（','），来告诉 Python 按逗号对字符串进行分割。

为了帮助理解这个方法是如何工作的，我们将其传递给 help。前面把变量 animals 变成了列表，所以首先我们必须重新定义这个变量。我们把它变回字符串，然后查看 split 的工作原理：

animals = 'cat,dog,horse'
help(animals.split) ➊

❶ 本行代码将 animals.split（没有 ()）传递给 help 方法。你可以向 help 方法中传入任何对象、方法或模块，但如前所见，在传入方法时不应该把尾部的括号也包括进去。

Python 的返回值如下：

split(...)
    S.split([sep [,maxsplit]]) -> list of strings

    Return a list of the words in the string S, using sep as the
    delimiter string.  If maxsplit is given, at most maxsplit
    splits are done. If sep is not specified or is None, any
    whitespace string is a separator and empty strings are removed
    from the result.

帮助文档的第一行说的是：S.split([sep [,maxsplit]]) → list of strings。翻译成汉语，这告诉我们，对于字符串（S），我们有一个方法（split）、第一个可选参数（也就是可以传入的对象）sep 以及第二个可选参数 maxsplit。参数名两边的方括号（[]）表明它们是可选的，不是必需的。这个方法返回（->）一个字符串列表。

下一行说的是："Return a list of the words in the string S，using sep as the delimiter string." sep 是被传入 split 方法的参数，作用是分隔符（seperator）。分隔符（delimiter）是用来分割字段的单个字符或一串字符。例如，在一个逗号分隔文件中，逗号就是分隔符。逗号还是我们所创建字符串的分隔符，因为它把我们希望出现在列表中的单词分隔开。

　阅读完帮助文档后（利用方向键上下翻页），你可以输入 q 退出 help。

help 文档还告诉我们，如果没有指定其他分隔符，默认的分隔符是空白。这告诉我们，如果我们有一个字符串 'cat dog horse'，split 方法不需要我们在 () 内传入分隔符。如你所见，内置的 help 函数可以告诉你许多关于如何使用某个方法的内容，还可以告诉你这个方法是否适合你正在解决的问题。

2.5　综合运用

我们利用刚学到的新技术来做一个测验。试着完成以下内容。

(1) 创建一个字符串、一个列表和一个字典。

(2) 利用 dir 方法查找每种数据类型可用的方法。

(3) 试用一些你发现的内置方法，直到某个方法抛出了错误。

(4) 利用 help 查看该方法的文档。试着理解这个方法是做什么的，并试着搞清楚，为了让这个方法正常运行，你可能还需要做些什么。

恭喜！你刚刚学会了如何编程。编程不是死记硬背，相反，编程是在出错时排查并解决错误。

2.6　代码的含义

在本章开头我们承诺过，在本章结束时你会理解这三行代码：

import sys
import pprint
pprint.pprint(sys.path)

根据目前所学的内容，我们把这三行代码分开来看。在 2.1.2 节中，我们导入了 decimal 库。看起来我们正从 Python 标准库中导入两个模块——sys 和 pprint。

看一下这些模块的帮助文档（一定要确定你已经导入了这些模块，否则 help 会抛出错误！）。由于 pprint 更容易读懂，我们先来看它的帮助文档：

>>>import pprint
>>>help(pprint.pprint)

Help on function pprint in module pprint:

pprint(object, stream=None, indent=1, width=80, depth=None)
    Pretty-print a Python object to a stream [default is sys.stdout].

很好。根据 pprint.pprint() 的文档，这个方法将传入的内容以易读的形式显示出来。

在上一章中我们学过，sys.path 给出 Python 寻找模块的位置。sys.path 的数据类型是什么？

import sys
type(sys.path)

是列表。我们知道列表怎么用！现在我们还知道，将一个列表传入 pprint.pprint，列表的输出格式会非常美观。我们把它用在包含列表的列表上，里面保存的是动物名字。首先，再多加一些名字，使列表变得很乱：

animal_names = [
    ['Walter', 'Ra', 'Fluffy', 'Killer'],
    ['Joker', 'Simon', 'Ellie', 'Lishka', 'Fido'],
    ['Mr. Ed', 'Peter', 'Rocket','Star']
    ]

下面将 pprint 作用于变量 animal_names:

pprint.pprint(animal_names)

得到的返回值如下：

[['Walter', 'Ra', 'Fluffy', 'Killer'],
 ['Joker', 'Simon', 'Ellie', 'Lishka', 'Fido'],
 ['Mr. Ed', 'Peter', 'Rocket', 'Star']]

总结一下，这是最开始这三行代码每一行的作用：

import sys       ➊
import pprint    ➋
pprint.pprint(sys.path) ➌

❶ 导入 Python 的 sys 模块。

❷ 导入 Python 的 pprint 模块。

❸ 将列表 sys.path 传递给 pprint.pprint，将列表清晰易读地显示出来。

如果将字典传入 pprint.pprint 会怎么样？你应该会看到格式优美的字典输出。

2.7　小结

数据类型和容器是 Python 理解并存储数据的方式。数据类型有许多种，本章只学习了其中重要的几种，如表 2-1 所示。

表2-1：数据类型

名称

举例

字符串

'Joker'

整数

2

浮点数

2.0

变量

animal_names

列表

['Joker', 'Simon', 'Ellie', 'Lishka', 'Fido']

字典

{'cats': 2, 'dogs': 5, 'horses': 1, 'snakes': 0}

如你所知，有些数据类型可以包含在其他数据类型之中。列表可以是许多字符串或整数，或二者都有。变量可以是列表、字典、字符串或小数。看一下变量 animal_names，列表也可以是包含列表的列表。随着我们学习更多的 Python 知识，也会更深入地学习这些数据类型、它们的工作原理，以及如何利用它们来满足我们数据处理的需求。

本章我们还学习了 Python 的内置方法，以及能用对象所做的事情。另外，我们学习了几个简单的 Python 方法和工具。利用这些方法和工具，我们可以判断对象的数据类型及其用途。表 2-2 对这些工具做了总结。

表2-2：辅助工具

举例

用途

type('Joker')

返回 'Joker' 的对象类型

dir('Joker')

返回一个列表，给出对象 'Joker' 可以做的所有事情（方法和属性）

help('Joker'.strip)

返回给定方法（在本例中是 strip）的说明文档，以便我们更好地了解如何使用它

下一章我们将学习如何打开各种文件类型，以及如何将数据存储成本章学过的 Python 数据类型。通过将文件中的数据转换成 Python 对象，我们可以充分发挥 Python 的威力，数据处理将很快变成一件容易的事情。

第 3 章　供机器读取的数据

数据可以存储成许多不同的格式和文件类型。某些格式存储的数据很容易被机器处理，而另一些格式存储的数据则容易被人工读取。微软的 Word 文档属于后者，而 CSV、JSON 和 XML 文件则属于前者。本章我们将学习如何读取那些容易被机器处理的文件，在第 4 章和第 5 章我们将讨论那些供人工读取的文件。

以易于机器理解的方式来存储数据的文件格式，通常被称作机器可读的（machine readable）。常见的机器可读格式包括：

逗号分隔值（Comma-Separated Values，CSV）

JavaScript 对象符号（JavaScript Object Notation，JSON）

可扩展标记语言（eXtensible Markup Language，XML）

在口语和书面语中，提到这些数据格式时通常使用它们的短名字（如 CSV）。我们将使用这些缩写。

在寻找数据、向组织或机构发出数据请求时，你能找到最好的资源就是本章讲到的这些格式。与易于人工读取的格式相比，这些格式更容易被 Python 脚本处理，在数据网站上通常也很容易找到。

创建代码主文件夹

为了能够顺利完成本章的例子和代码，你需要将文件保存到本地计算机。你应该创建一个文件夹（如果之前还没有创建的话），用来保存 Python 代码和数据文件。文件夹的名字要直观，比如叫 data_wrangling（数据处理）。然后在这个文件夹中创建一个子文件夹，用来保存与本书相关的代码［比如叫 code（代码）］。这有助于保持你的文件夹结构清晰，命名直观。

如果你按照上面的提示操作，应该创建好了一个像这样的文件夹：~/Projects/data_wrangling/code。

在基于 Unix 的系统中（Linux 和 Mac），~ 符号代表主目录，用命令行访问比较方便。在 Windows 系统中，主目录位于 Users 文件夹下，所以你的文件夹位置是在 C:\Users\<your_name>\Projects\data_wrangling。

在本书的数据仓库中（https://github.com/jackiekazil/data-wrangling）可以下载代码示例，并将其移动到你的项目文件夹中。在阅读本章的过程中，我们假定，从上述仓库下载的数据与你编写的 Python 代码位于同一文件夹下。这样我们就不必担心文件定位问题，可以专心研究用 Python 导入数据。

3.1　CSV数据

我们要学习的第一个机器可读的文件格式是 CSV。CSV 文件（简称为 CSV）是指将数据列用逗号分隔的文件。文件的扩展名是 .csv。

另一种数据类型，叫作制表符分隔值（tab-separated values，TSV）数据，有时也与 CSV 归为一类。TSV 与 CSV 唯一的不同之处在于，数据列之间的分隔符是制表符（tab），而不是逗号。文件的扩展名通常是 .tsv，但有时也用 .csv 作为扩展名。从本质上来看，.tsv 文件与 .csv 文件在 Python 中的作用是相同的。

　如果文件的扩展名是 .tsv，那么里面包含的很可能是 TSV 数据。如果文件的扩展名是 .csv，那么里面包含的可能是 CSV 数据，但也可能是 TSV 数据。一定要打开文件查看一下，这样你可以在导入数据之前就明确所处理的数据类型。

对于本章的 CSV 实例，我们采用的是来自世界卫生组织（WHO）的数据。WHO 拥有许多大型数据集（http://apps.who.int/gho/data/node.main），并且提供多种格式的数据。本例选择的数据集包含全球各国的预期寿命。访问网页（http://apps.who.int/gho/data/node.main.3?lang=en）查看预期寿命数据，你会发现这个数据集有几个不同的版本。本例中使用的是 CSV（纯文本，下载地址：http://apps.who.int/gho/athena/data/data-text.csv?target=GHO/WHOSIS_000002,WHOSIS_000001,WHOSIS_000015&profile=text&filter=COUNTRY:*;REGION:AFR;REGION:AMR;REGION:SEAR;REGION:EUR;REGION:EMR;REGION:WPR;SEX:*）。

用文本编辑器 1 打开这个 CSV 文件，你会看到类似表 3-1 的许多行数据。

1为了完成本章练习，你需要一个好用的文本编辑器。如果还没有安装的话，你可以按 1.2.5 节的说明来安装。

表3-1：两个样本数据记录a

CSV标题

样本记录1

样本记录2

指标

60 岁时预期寿命（年）

出生时预期寿命（年）

发布状态

已发布

已发布

年份

1990

1990

WHO 地区

欧洲

美洲

世界银行收入分组

高收入

中低收入

国家

捷克共和国

伯利兹

性别

女性

男女合计

示值

19

71

数值大小

19.00000

71.00000

最低值

无

无

最高值

无

无

备注

无

无

a 加粗项包含在下文的样本数据中。

为了让数据更容易阅读，下面给出一个数据样本，其中只包含经过挑选的特定字段（表 3-1 中的加粗项）。在文本编辑器中打开 CSV 文件，你看到的数据应该与其类似：

"Year","Country","Sex","Display Value","Numeric"
"1990","Andorra","Both sexes","77","77.00000"
"2000","Andorra","Both sexes","80","80.00000"
"2012","Andorra","Female","28","28.00000"
"2000","Andorra","Both sexes","23","23.00000"
"2012","United Arab Emirates","Female","78","78.00000"
"2000","Antigua and Barbuda","Male","72","72.00000"
"1990","Antigua and Barbuda","Male","17","17.00000"
"2012","Antigua and Barbuda","Both sexes","22","22.00000"
"2012","Australia","Male","81","81.00000"

预览 CSV 文件的另一种方法是用电子表格程序打开，比如 Excel 或 Google Spreadsheets。这些程序将每一个数据条目显示为单独的一行。

3.1.1　如何导入CSV数据

前面我们学习了一点关于数据的知识，下面用 Python 打开这个文件，并将数据转换成 Python 可以理解的格式。这只要几行代码：

import csv

csvfile = open('data-text.csv', 'rb')
reader = csv.reader(csvfile)

for row in reader:
    print row

我们来一行一行地阅读上面的代码。在上一章里，我们所有的代码都是在 Python 解释器中输入的，但随着代码变得越来越长、越来越复杂，在文件中编写代码并运行要方便一些。读完这段代码，我们将把它保存在一个 .py 文件中（.py 文件是一个 Python 文件），然后在命令行中运行这个文件。

脚本的第一行代码导入了一个叫作 csv 的库：

import csv

Python 库是一个代码包，提供了你可以在 Python 程序中使用的功能。这里导入的 csv 库是 Python 标准库（或 stdlib）的一部分，随 Python 一起安装。将库导入到文件中后，我们就可以使用这个库。如果没有这个库的话，脚本将会变得很长——csv 库提供了辅助函数，这样一来，为了完成更复杂的任务，我们就不必写这么多的代码。

第二行代码将 data-text.csv 文件传入 open 函数，这个文件应该和脚本位于同一文件夹下：

csvfile = open('data-text.csv', 'rb')

　函数（function）是一段代码，在被调用时执行相应的任务。它和我们在第 2 章学过的 Python 数据类型的方法十分相似。函数有时会接收一个（或多个）输入。这些输入叫作参数（argument）。函数的功能是基于参数的。函数有时也会返回一个输出，可以被保存或使用。

open 是 Python 的内置函数（这里列出了 Python 所有的内置函数：https://docs.python.org/2/library/functions.html），也就是说，打开文件的行为是如此常见，所以 Python 核心贡献者认为应把这个行为添加到 Python 的默认安装里。在使用 open 函数时，我们传入一个文件名作为第一个参数（这里用的是 'data-text.csv'），然后选择指定文件打开的模式（我们用的是 'rb'）。如果查看 open 函数的文档（https://docs.python.org/2/library/functions.html#open），你会发现，'rb' 参数的意思是我们以只读方式和二进制方式打开文件。以二进制方式打开文件，可以让代码在 Windows 上和基于 Unix 的操作系统上都能运行。另一种常见的模式是写入（'w' 或 'wb'，后者表示以二进制方式写入）。

　如果你想读取文件，以只读方式打开文件。如果你想写入文件，以写入方式打开文件。

我们将这个函数的输出保存在变量 csvfile 中。现在 csvfile 保存一个打开的文件作为它的值。

在下一行代码中，我们将 csvfile 传递给 csv 模块的 reader 函数。这个函数的作用是让 csv 模块将打开的文件当作 CSV 来读取：

reader = csv.reader(csvfile)

函数 csv.reader(csvfile) 的输出保存在变量 reader 中。现在 reader 变量保存的是已打开文件的 Python CSV reader。有了这个 CSV reader，我们用简单的 Python 命令就可以轻松查看文件中的数据。最后一段代码中，我们使用了所谓的 for 循环。

for 循环是一种遍历 Python 对象的方法，通常与列表一起使用。for 循环告诉 Python 代码：“对于这个列表中的每一个元素，做点什么。”在一个 for 循环中，for 后面的第一个单词是用来保存列表（或其他可迭代对象）中每一个对象的变量。for 循环下面的代码利用这个变量对该元素执行更多的操作或计算。因此，最好用有意义的单词做变量名，这样你和其他人都能轻松阅读并理解代码。

　还记得第 2 章里出现过的“无效的标记”（invalid token）吗？ for 是 Python 里另一个特殊的标记（token），只能用来创建 for 循环。标记可以将我们在解释器或脚本中输入的内容翻译成计算机能够执行的命令。

试着在 Python 解释器中运行下面的代码：

dogs = ['Joker', 'Simon', 'Ellie', 'Lishka', 'Fido']
for dog in dogs:
    print dog

利用这个 for 循环，我们将每一只狗狗的名字都保存在 for 循环的 dog 变量中。对于 for 循环的每一次迭代，我们打印出狗狗的名字（保存在变量 dog 中）。当程序遍历完每一只狗狗的名字（或列表中的每一个元素）后，代码停止运行。

在 IPython 中退出缩进代码块

在 IPython 终端里编写 for 循环或其他缩进代码块时，一定要检查一下，确保提示符从缩进代码块的样式 ... 变成了一个新的 In 提示符。最简单的方法就是，在完成最后一行缩进代码后敲下 Return 键。你应该看到一个新的 In 提示符，然后再输入循环之外的代码：

In [1]: dogs = ['Joker', 'Simon', 'Ellie', 'Lishka', 'Fido']

In [2]: for dog in dogs:
   ...:     print dog   ➊
   ...:                 ➋
Joker
Simon
Ellie
Lishka
Fido

In [3]:                 ➌

➊ IPython 的自动缩进提示符（...: 后面跟着四个空格）。

➋ 在空行按下 Return 键，退出缩进代码块并运行代码。

➌ IPython 的代码运行结束后，出现了新的提示符。

在我们用来读取 CSV 的代码中，reader 对象是一个保存数据行的 Python 容器。在 reader 的 for 循环中，我们将每一行数据保存在变量 row 中。下一行代码的意思是，我们让 Python 打印出每一行数据：

for row in reader:
    print row

现在我们已经能够导入数据并对数据进行遍历，下面开始对数据进行真正的探索。

3.1.2　将代码保存到文件中并在命令行中运行

作为开发者，在写代码时，即使是中间过程的部分代码片段，你也会希望保存下来，以便后续检查和使用。保持代码结构清晰，并及时保存代码，即使中途被打断，你也可以从上次中断的地方顺畅地继续工作。

我们将到目前为止所有的代码保存到文件中并运行。代码应该是这样的（如果你还没有完成这一步的话，打开文本编辑器，创建一个新文件，将这段代码输入进去）：

import csv

csvfile = open('data-text.csv', 'rb')
reader = csv.reader(csvfile)

for row in reader:
    print row

　注意大小写、间距和换行。如果各行的代码间距各不相同或者大小写错误的话，代码是无法正常运行的。一定要严格按上面的代码输入，利用四个空格来缩进。这一点很重要，因为 Python 区分大小写，并利用缩进来表示代码的结构。

用文本编辑器将代码保存为 .py（Python）文件。完整的文件名应该是像这样的：import_csv_data.py。

将数据文件 data-text.csv 放到你刚刚保存 Python 文件的同一个文件夹内。如果你想把文件放到其他位置，需要对文件位置对应的代码做适当修改。

打开不同位置的文件

在目前的代码中，我们将文件路径传入 open 函数，像这样：

open('data-text.csv', 'rb')

但如果数据文件位于一个叫作 data 的子文件夹，我们需要修改脚本，让它去那里寻找数据文件。修改后的代码如下：

open('data/data-text.csv', 'rb')

在上面的例子中，我们的文件结构是像这样的：

data_wrangling/
`-- code/
     |-- import_csv_data.py
     `-- data/
          `-- data-text.csv

如果你找不到自己的文件，可以在 Mac 或 Linux 计算机上打开命令行，使用下列命令来浏览文件夹。

ls 返回文件的列表。

pwd 给出当前位置。

cd ../ 进入上层文件夹。

cd ../../ 向上移动两层目录。

cd data 进入 data 文件夹，该文件夹位于当前文件夹下（可以用 ls 来查看）。

查阅附录 C 可以了解用命令行在文件夹之间跳转的更多内容，其中还包括针对 Windows 用户的整整一节内容。

保存好文件后，你可以用命令行来运行它。打开命令行（终端或 cmd），跳转到文件所在的位置。假设你把文件放在~/Projects/data_wrangling/code 中。想要在 Mac 命令行中跳转到那里，你可以使用变更目录或文件夹命令（cd）：

cd ~/Projects/data_wrangling/code

跳转到正确的位置后，你就可以运行 Python 文件。到目前为止，我们都是在 Python 解释器中运行代码。我们将文件保存为 import_csv_data.py。想要在命令行中运行 Python 文件，你只需要输入 python，敲空格，然后输入文件名即可。我们来试一下运行 Python 文件：

python import_csv_data.py

你得到的输出看起来应该像一串列表——和下面给出的数据类似，但数据量要大得多。

['Healthy life expectancy (HALE) at birth (years)', 'Published', '2012',
 'Western Pacific', 'Lower-middle-income', 'Samoa', 'Female', '66',
 '66.00000', '', '', '']
['Healthy life expectancy (HALE) at birth (years)', 'Published', '2012',
 'Eastern Mediterranean', 'Low-income', 'Yemen', 'Both sexes', '54',
 '54.00000', '', '', '']
['Healthy life expectancy (HALE) at birth (years)', 'Published', '2000',
 'Africa', 'Upper-middle-income', 'South Africa', 'Male', '49', '49.00000',
 '', '', '']
['Healthy life expectancy (HALE) at birth (years)', 'Published', '2000',
 'Africa', 'Low-income', 'Zambia', 'Both sexes', '36', '36.00000', '', '', '']
['Healthy life expectancy (HALE) at birth (years)', 'Published', '2012',
 'Africa', 'Low-income', 'Zimbabwe', 'Female', '51', '51.00000', '', '', '']

你得到上面的输出了吗？如果没有的话，花一分钟的时间阅读你得到的错误信息。从中发现出错的地方可能在哪里了吗？花点时间去搜索错误的原因，看看其他人解决相同的错误都用了哪些方法。关于如何解决错误，如果你需要额外的帮助，可查阅附录 E。

　从现在开始，我们将会在代码编辑器里编写大多数代码，保存文件，然后在命令行中运行。Python 解释器仍然是一个有用的工具，可以用来测试代码片段，但随着代码变得越来越长、越来越复杂，在代码提示符中维护代码将越来越困难。

无论是我们正在写的代码，还是我们将面对的许多其他问题，解决问题的方法往往不止一种。csv.reader() 返回的是一个数据的列表，里面包含的是文件中的每一行数据，在我们刚开始处理问题时，这是一个很容易理解的方法。下面要对脚本做少许修改，将列表行改成字典行。这样在我们探索数据集的过程中，读取数据、对比数据和理解数据会变得更加容易。

在文本编辑器中，将第 4 行 reader = csv.reader(csvfile) 修改成 reader = csv.DictReader(csvfile)。现在你的代码应该是这样的：

import csv

csvfile = open('data-text.csv', 'rb')
reader = csv.DictReader(csvfile)

for row in reader:
    print row

保存文件并重新运行，每一个数据记录变成一个字典。字典的键来自于 CSV 文件的第一行。后面所有行都是字典的值。下面是一行数据对应的输出：

{
    'Indicator': 'Healthy life expectancy (HALE) at birth (years)',
    'Country': 'Zimbabwe',
    'Comments': '',
    'Display Value': '49',
    'World Bank income group': 'Low-income',
    'Numeric': '49.00000',
    'Sex': 'Female',
    'High': '',
    'Low': '',
    'Year': '2012',
    'WHO region': 'Africa',
    'PUBLISH STATES': 'Published'
}

现在我们已经成功地将 CSV 数据导入到 Python 中，也就是说，我们能够从文件中获取数据，并将其转换成 Python 可用的格式（字典），for 循环可以让我们直观地查看数据。我们可以利用 csv 库两种不同的 reader 来查看数据，一种是列表形式，一种是字典形式。在开始探索和分析数据集时，我们会再次用到这个库。下面继续学习导入 JSON 数据。

3.2　JSON数据

JSON 数据是数据传输最常用的格式之一。人们喜欢这一格式，是因为它结构清晰、易于阅读且方便解析。网站在向页面的 JavaScript 传输数据时，JSON 也是最常用的数据格式之一。许多网站都提供了支持 JSON 的 API，我们会在第 13 章讲到。本节会继续使用全球预期寿命的数据。WHO 并没有提供这一数据的 JSON 格式，但我们为本书创建了 JSON 版本的数据，你可以在代码仓库（https://github.com/jackiekazil/data-wrangling）中找到。

　如果文件的扩展名是 .json，那里面包含的可能是 JSON 数据。如果文件扩展名是 .js，那可能是 JavaScript 文件，但在少数情况下也可能是命名不规范的 JSON 文件。

在代码编辑器里打开这个 JSON 文件，你会发现每一条数据记录都很像一个 Python 字典。每一行都有键和值，用 : 分隔，数据条目之间用 , 分隔。首尾还有花括号包围 {}。这是 JSON 文件的一条数据记录：

[
  {
    "Indicator":"Life expectancy at birth (years)",
    "PUBLISH STATES":"Published",
    "Year":1990,
    "WHO region":"Europe",
    "World Bank income group":"High-income",
    "Country":"Andorra",
    "Sex":"Both sexes",
    "Display Value":77,
    "Numeric":77.00000,
    "Low":"",
    "High":"",
    "Comments":""
  },
]

JSON 文件有时看起来和字典完全相同，这与输出格式有关。在上面的示例中，每一个数据条目就是一个 Python 字典（首尾由 { 和 } 包围），这些字典又包含在一个列表中，列表首尾由 [ 和 ] 包围。

如何导入JSON数据

在 Python 中导入 JSON 文件比导入 CSV 文件还要简单。下面的代码将对一个 JSON 数据文件执行打开、加载、导入与输出的操作：

import json                                ➊

json_data = open('data-text.json').read()  ➋

data = json.loads(json_data)               ➌

for item in data:                          ➍
    print item

❶ 导入 Python 的 json 库（https://docs.python.org/2/library/json.html），我们用它来处理 JSON 文件。

❷ 利用 Python 内置的 open 函数打开 JSON 文件。文件名叫作 data-text.json（这是 open 函数的第一个参数）。本行代码还调用了已打开文件的 read 方法，用来读取该文件，并将读取的内容保存在变量 json_data 中。

❸ 利用 json.loads() 将 JSON 数据载入 Python，并将输出保存在变量 data 中。

❹ 利用 for 循环遍历所有数据，并打印出每一项，这也是本例代码的输出。

在命令行中运行 python import_json_data.py，输出是一个字典，里面包含 JSON 文件中每一条数据记录。这个输出应该和 CSV 的最终输出基本相同。一定要记得将数据文件复制到脚本所在的文件夹，或者将脚本中的文件路径修改为文件实际所在的位置。

在 CSV 一节的最后，我们学习了如何保存文件并在命令行中运行。在本例中，我们从一个空白文件开始，逐步完成这项任务。

首先来快速看一下总体步骤。

(1) 在代码编辑器中创建一个新文件。

(2) 将文件保存为 import_json_data.py，与你的代码位于同一个文件夹下。

(3) 将数据移动（或保存）到代码所在的文件夹。（一定要重命名数据文件，使其与代码中的文件名相同。本书用的文件名是 data-text.json。）

(4) 回到代码编辑器，import_json_data.py 文件应该还处于打开状态。

我们来通读代码，并将其与导入 CSV 的代码文件作对比。首先，导入 Python 内置的 json 库：

import json

然后用学过的 open 函数打开 data-text.json 文件，并调用已打开文件的 read 方法：

json_data = open('data-text.json').read()

在 CSV 文件的例子中，我们并没有调用 read。二者的区别在哪里？在 CSV 的例子中，我们以只读方式打开文件；但在 JSON 的例子中，我们读取文件的内容，并将其保存在变量 json_data 中。在 CSV 的例子中，open 函数返回的是一个文件对象；但在 JSON 的例子中，我们首先打开文件，然后读取文件，所以得到的是一个 str（字符串）。二者的不同是基于下列事实：Python 的 json 库和 csv 库处理输入数据的方式不同。如果你试着将一个字符串传递给 CSV reader，Python 会报错；如果你把文件对象传递给 JSON 的 loads 函数， Python 也会报错。

好消息是，在 Python 中将字符串写入文件非常简单（比如说，你只有字符串，但想使用 CSV reader 来读取），将文件读取成字符串也非常简单。对 Python 来说，一个关闭的文件只是一个文件名字符串，等待被打开并读取。从文件中获取数据、将数据变成字符串并将字符串传递给函数，只需要几行 Python 代码即可完成。

进入保存 JSON 文件的文件夹，你可以在 Python 解释器中输入下面的代码，看一下前面两个例子中输出对象的类型：

filename = 'data-text.json'
type(open(filename, 'rb'))  # 与csv的代码类似
type(open(filename).read()) # 与json的代码类似

Python json 库的 loads 函数接收字符串作为参数，不接收文件作为参数。Python csv 库的 reader 函数接收打开的文件作为参数。在脚本的下一行代码中，我们将使用 loads 函数，将 JSON 字符串载入 Python。这一函数的输出被赋值给名为 data 的变量：

data = json.loads(json_data)

想要预览数据，我们对每一项进行遍历并将其打印出来。这段代码并不是必需的，但可以帮我们预览数据，检查数据的格式是否正确：

for item in data:
    print item

写完上述代码后，保存文件并运行。如你所见，在 Python 中打开 JSON 文件并将其转换成由字典组成的列表是非常容易的。下一节将探索更多自定义文件的处理方法。

3.3　XML数据

XML 格式的数据既便于机器读取，也便于人工读取。但是对于本章的数据集来说，预览并理解 CSV 文件和 JSON 文件要比 XML 文件容易得多。幸运的是，数据本身是相同的，我们也比较熟悉。下载预期寿命数据的 XML 版本（下载地址：http://apps.who.int/gho/athena/data/GHO/WHOSIS_000001,WHOSIS_000002.xml?filter=COUNTRY:*;YEAR:2015），并将 XML 文件与本章其他内容保存在同一文件夹下。

　如果文件的扩展名是 .xml，那么它是 XML 数据。如果文件扩展名是 .html 或 .xhtml，有时也可以用 XML 解析器来解析。

在处理所有数据时，我们先在代码编辑器中打开文件来预览一下。如果你滚动查看一下文件，会发现我们在 CSV 的例子中已经熟悉的数据。但数据看起来又不大一样，因为它用的是 XML 格式，使用了一种叫作标签的东西。

　XML 是一种标记语言，也就是说，它具有包含格式化数据的文档结构。XML 文档本质上只是格式特殊的数据文件。

下面的数据片段是我们要处理的 XML 数据的一个样本。在这个例子中，<Observation />、<Dim /> 和 <Display /> 都是标签。标签（或节点）以层次化和结构化的方式保存数据：

<GHO ...>
    <Data>
        <Observation FactID="4543040" Published="true"
        Dataset="CYCU" EffectiveDate="2014-03-27" EndDate="2900-12-31">
            <Dim Category="COUNTRY" Code="SOM"/>
            <Dim Category="REGION" Code="EMR"/>
            <Dim Category="WORLDBANKINCOMEGROUP" Code="WB_LI"/>
            <Dim Category="GHO" Code="WHOSIS_000002"/>
            <Dim Category="YEAR" Code="2012"/>
            <Dim Category="SEX" Code="FMLE"/>
            <Dim Category="PUBLISHSTATE" Code="PUBLISHED"/>
            <Value Numeric="46.00000">
                <Display>46</Display>
            </Value>
        </Observation>
        <Observation FactID="4209598" Published="true"
        Dataset="CYCU" EffectiveDate="2014-03-25" EndDate="2900-12-31">
            <Dim Category="WORLDBANKINCOMEGROUP" Code="WB_HI"/>
            <Dim Category="YEAR" Code="2000"/>
            <Dim Category="SEX" Code="BTSX"/>
            <Dim Category="COUNTRY" Code="AND"/>
            <Dim Category="REGION" Code="EUR"/>
            <Dim Category="GHO" Code="WHOSIS_000001"/>
            <Dim Category="PUBLISHSTATE" Code="PUBLISHED"/>
            <Value Numeric="80.00000">
                <Display>80</Display>
            </Value>
        </Observation>
    </Data>
</GHO>

在 XML 文件中有两个位置可以保存数据值：一个位置是在两个标签之间，比如在 <Display>46</Display> 中，<Display> 标签的值是 46；另一个位置是标签的属性，比如在 <Dim Category="COUNTRY" Code="SOM"/> 中，Category 属性的值是"COUNTRY"，Code 属性的值是"SOM"。XML 属性可以保存特定标签的额外信息，这些标签又嵌套在另一个标签中。

在 JSON 中你可以用键 / 值对来保存数据，而在 XML 中保存数据可以是两个一组甚至三四个一组。XML 用标签和属性来保存数据，类似于 JSON 中的键。所以我们再来看一下 Display 标签，这个标签的值保存在开始标签和结束标签之间。再来看一下 Dim 节点，它有两个不同的属性（Category 和 Code），两个属性都有对应的值。XML 可以在每个节点中保存不止一个属性。如果你熟悉 HTML 的话，应该很熟悉这一点。这是因为 HTML 与 XML 密切相关：它们都在节点（或标签）内包含有属性，它们也都是标记语言（想了解什么是标记语言，可以去 https://en.wikipedia.org/wiki/Markup_language 查看）。

　在 XML 标签结构和属性命名方面虽然有许多著名的标准，但主要结构其实是由设计或创建 XML 的人（或机器）决定的。如果你用的是来自不同来源的数据集，你不能认为它们的格式是一致的。想了解 XML 最佳实践的更多内容，IBM 给出了许多好的观点（参见 http://www.ibm.com/developerworks/library/x-eleatt/）。

如何导入XML数据

前面我们对数据已经有了一定的了解，下面将文件导入成 Python 可用的格式。为了从 XML 格式中提取数据并导入 Python，需要编写这些代码：

from xml.etree import ElementTree as ET

tree = ET.parse('data-text.xml')
root = tree.getroot()

data = root.find('Data')

all_data = []

for observation in data:
    record = {}
    for item in observation:

        lookup_key = item.attrib.keys()[0]

        if lookup_key == 'Numeric':
            rec_key = 'NUMERIC'
            rec_value = item.attrib['Numeric']
        else:
            rec_key = item.attrib[lookup_key]
            rec_value = item.attrib['Code']

        record[rec_key] = rec_value

    all_data.append(record)

print all_data

可以看出来，这比 CSV 和 JSON 的代码都要复杂一些。

我们来仔细看一下。在代码编辑器中创建一个新文件，并将其保存在之前保存代码的文件夹中。文件名叫作 import_xml_data.py。另外，如果你是从 WHO 网站直接下载的数据，而不是从本书的仓库中下载的，你需要将保存的 XML 文件重命名为 data-text.xml，并将它与代码放在同一个文件夹下。

首先导入 ElementTree（https://docs.python.org/2/library/xml.etree.elementtree.html），这是我们用来解析 XML 的内置库的一部分：

from xml.etree import ElementTree as ET

　前面说过，解决问题的方法往往有许多种。本例中用的是 ElementTree，你也可以用一个叫作 lxml 的库（http://lxml.de/），或者另一个叫作 minidom 的库（https://docs.python.org/2/library/xml.dom.minidom.html）。这三种方法都可以用来解决相同的问题，如果你发现一个很好的例子，用的是其中一个库，我们建议你再用另外一个库对数据进行探索。在学习 Python 的过程中，选择那些看起来最容易理解的库（在多数情况下，这也是最好的选择）。

与之前相比，这个 import 语句中多了一段：as ET。我们导入的是 ElementTree，但把它叫作 ET。为什么要这么做？因为我们很懒，不想每次用到这个库时都输入 ElementTree。在导入名字很长的类或函数时，这是很常见的做法，但并不是强制性的要求。as 告诉 Python，我们想用 ET 来代表 ElementTree。

接下来，调用 ET 类的 parse 方法，这一方法将会对我们传入文件中的数据进行解析。由于我们要解析的文件位于同一文件夹下，所以文件名中不需要包含文件路径：

tree = ET.parse('data-text.xml')

parse 方法返回一个 Python 对象，人们一般会把它保存在变量 tree 中。在谈论 XML 时，树（tree）指的是整个 XML 对象，以 Python 能够理解并解析的方式保存。

为了理解遍历树（及其包含的数据）的方法，我们从树的根元素（root）开始。根节点是第一个 XML 标签。调用 getroot 函数来获取树的根元素：

root = tree.getroot()

如果你在上一条语句后面加上 print root，打印出 root 的内容，会发现，输出的是 XML 树中根元素的 Python 表示（看起来应该像这样：<Element 'GHO' at 0x1079e79d0>2）。从这个表示中我们很快可以看出，ElementTree 找出了 XML 文档的根标签或最外层标签，就是标签名为 GHO 的 XML 节点。

2对于由十六进制数字组成的长字符串表示的 Python 对象，这是 Python 显示内存地址信息的方法。我们的数据处理过程用不到这些内容，所以如果你的内存地址与我们的有所不同，请不必在意。

前面我们找到了根标签，下面要学习如何访问想要的数据。在本章 CSV 和 JSON 两节中，我们对数据进行了分析，知道要处理的是什么数据。我们需要遍历整个 XML 树，将同样的数据提取出来。为了理解要寻找的数据，需要先理解 XML 树的整体结构与格式。下面，将前面的 XML 文件简化一下，删除数据，这样就可以只看核心结构：

<GHO>
    <Data>
        <Observation>
            <Dim />
            <Dim />
            <Dim />
            <Dim />
            <Dim />
            <Dim />
            <Value>
                <Display>
                </Display>
            </Value>
        </Observation>
        <Observation>
            <Dim />
            <Dim />
            <Dim />
            <Dim />
            <Dim />
            <Dim />
            <Value>
                <Display>
                </Display>
            </Value>
        </Observation>
    </Data>
</GHO>

在纵览文件结构时可以看到，每一“行”数据都包含在一个 Observation 标签中。在每一个 Observation 节点内，这些数据行又分别包含在 Dim、Value 和 Display 节点中。

目前我们有三行代码。为了研究如何用 Python 将这些节点提取出来，在现有代码的末尾加上 print dir(root)，然后保存文件并在命令行中运行：

python import_xml_data.py

你会看到变量 root 所有的方法和属性。你的代码应该是这样的：

from xml.etree import ElementTree as ET

tree = ET.parse('data-text.xml')
root = tree.getroot()

print dir(root)

运行该文件，你应该会看到下面的输出：

['__class__', '__delattr__', '__delitem__', '__dict__', '__doc__',
'__format__', '__getattribute__', '__getitem__', '__hash__', '__init__',
'__len__', '__module__', '__new__', '__nonzero__', '__reduce__',
'__reduce_ex__', '__repr__', '__setattr__', '__setitem__', '__sizeof__',
'__str__', '__subclasshook__', '__weakref__', '_children', 'append', 'attrib',
'clear', 'copy', 'extend', 'find', 'findall', 'findtext', 'get',
'getchildren', 'getiterator', 'insert', 'items', 'iter', 'iterfind',
'itertext', 'keys', 'makeelement', 'remove', 'set', 'tag', 'tail', 'text']

假设文件太大，无法打开，我们也不知道文件的结构。在处理大型 XML 数据集时经常会遇到这样的问题。我们能怎么做？首先调用 dir(root) 来查看 root 对象都有哪些方法。我们注意到 getchildren 方法，也许可以用来查看 Observation 节点的子元素。在查阅官方最新文档（https://docs.python.org/2/library/xml.etree.elementtree.html#xml.etree.ElementTree.Element.getchildren）以及 Stack Overflow 上的一个问题（http://stackoverflow.com/questions/10408927/how-to-get-all-sub-elements-of-an-element-tree-with-python-elementtree）之后，我们发现，getchildren 方法可以返回子元素，但官方文档不建议继续使用该方法。如果你想用的某个方法已经不建议使用或者即将弃用，你应该尝试换用库作者推荐的方法。

　如果不建议使用某个方法、类或函数的话，在库或模块的未来版本中很可能会删除它们对应的功能。因此，在任何时候你都应该避免使用不建议使用的方法或类，并通读文档，因为作者很可能会建议未来使用的替代方法或类。

根据官方文档的建议，如果想查看根元素的子元素，应该使用 list(root)。如果我们的文件很大，返回子元素可以让我们查看数据及其结构，而不会产生超级长的输出。让我们来试一下。

将这行代码：

print dir(root)

替换成：

print list(root)

在命令行中再次运行该文件。你应该会得到下面的输出，是一个由 Element 对象构成的列表（对于本例来说，元素指的是 XML 节点）：

[<Element 'QueryParameter' at 0x101bfd290>,
<Element 'QueryParameter' at 0x101bfd350>,
<Element 'QueryParameter' at 0x101bfd410>,
<Element 'QueryParameter' at 0x101bfd590>,
<Element 'QueryParameter' at 0x101bfd610>,
<Element 'QueryParameter' at 0x101bfd650>,
<Element 'Copyright' at 0x101bfd690>,
<Element 'Disclaimer' at 0x101bfd710>,
<Element 'Metadata' at 0x101bfd790>,
<Element 'Data' at 0x102540250>]

列表包含的 Element 对象分别叫作 QueryParameter、Copyright、Disclaimer、Metadata 和 Data。我们可以遍历这些元素来探索它们的内容，这样才能更好地理解如何提取我们想要的数据。

在 XML 树里面搜索数据时，Data 元素可能是一个很好的出发点。现在我们已经找到了 Data 元素，可以重点研究这个子元素。获取 Data 元素有好几种方法，这里用 find 方法。根元素的 find 方法可以利用标签名来搜索子元素。然后，我们就可以获取 Data 元素的子元素，看看下一步应该做什么。

将这行代码：

print list(root)

替换成：

data = root.find('Data')

print list(data)

　我们还可以使用 findall 方法。find 和 findall 的区别在于，find 返回的是匹配的第一个元素，而 findall 返回的是匹配的所有元素。我们知道只有一个 Data 元素，所以我们用的是 find 而不是 findall。如果有不止一个元素，要用 findall 方法获取所有匹配元素的列表，然后遍历这些元素。

修改完代码后重新运行该文件，你会看到输出一个超级长的列表，列表由 Observation 元素组成。这些是我们的数据点。虽然输出里包含很多信息，但你可以看出它是一个列表，因为最后一个字符是 ]，这是列表结束的符号。

我们来遍历这个列表中的数据。每个 Observation 元素代表一行数据，里面应该会包含更多的信息。我们可以分别遍历这些元素，看看都有什么子元素。对于 Python 中的 Element 对象，可以遍历其所有的子元素，就像遍历列表一样。因此，我们可以遍历每一个 Observation 元素及其每一个子元素。这是我们第一次使用包含循环的循环，从中我们应该可以知道是否还有更多包含数据的子元素。

　由于 XML 以节点、子节点和属性的方式保存数据，你会经常采用探索每一个节点和子节点（或者元素和子元素）的方法，直到你掌握了数据的结构，以及如何用 Python 来查看这些数据。

将这行代码：

print list(root)

替换成：

for observation in data:
    for item in observation:
        print item

然后重新运行该文件。

输出的是许多 Dim 和 Value 对象。我们尝试几种不同的方法，来探索这些元素中可能包含的内容。Python 的 Element 对象有好几种查看数据的方法。每个 Element 节点都有一个属性 text，可以给出节点内包含的文本。

将这行代码：

print item

替换成：

print item.text

然后重新运行该文件。

发生了什么？你得到的返回值应该是许多 None。这是因为很多元素的标签之间没有任何文本，所以这些元素的 item.text 都不存在。我们来看一下数据样本里 <Dim /> 的结构。例如：

<Dim Category="YEAR" Code="2000"/>

在 Python 中，只有在节点中包含文本的情况下，item.text 才是有用的，像这样：

<Dim Category="YEAR">2000</Dim>

对于第二个例子，item.text 返回的是 2000。

XML 数据可以有许多种结构。我们需要的信息包含在 XML 里，只是不在一眼就能发现的地方。我们来继续探索。

另一个要查看的地方在子元素中。我们来检查一下里面是否包含子元素。将这行代码：

print item.text

替换成：

print list(item)

修改后重新运行代码，从输出中可以看出，某些元素（但不是全部）具有子元素。有意思！这些元素其实是 Value 元素。我们来看一下数据样本中这些元素的结构：

<Value>
    <Display>
    </Display>
</Value>

如果想探索这些子元素，还需要写一个类似之前写过的循环，来遍历每一个 Observation 中的元素。

Python 中的 Element 对象还有另一个方法可以调用，叫作 attrib，它可以返回每一个节点的属性。在查看 XML 结构时我们已经知道，如果节点的标签之间没有值，那么在标签内通常会有属性。

想要查看节点的属性，将这行代码：

print list(item)

替换成：

print item.attrib

重新运行代码，可以看到，输出是由包含在属性中的数据组成的许多字典。我们希望将每一行数据保存成一个字典，而不是将每一个元素及其属性保存在不同的字典中。下面是 attrib 输出的一条记录：

{'Category': 'PUBLISHSTATE', 'Code': 'PUBLISHED'}
{'Category': 'COUNTRY', 'Code': 'ZWE'}
{'Category': 'WORLDBANKINCOMEGROUP', 'Code': 'WB_LI'}
{'Category': 'YEAR', 'Code': '2012'}
{'Category': 'SEX', 'Code': 'BTSX'}
{'Category': 'GHO', 'Code': 'WHOSIS_000002'}
{'Category': 'REGION', 'Code': 'AFR'}
{'Numeric': '49.00000'}

在 CSV 的例子中，我们得到了每条数据记录组成的字典，我们试着把上面的输出转换成类似的格式。XML 数据字典的键稍有不同，因为 WHO 在 XML 数据集中提供的数据与 CSV 数据集并不相同。我们将会把数据转换成下面的格式，但键名可以不同。这基本不会影响我们对数据的使用。

提醒一下，CSV reader 的样本数据记录如下：

{
   'Indicator': 'Healthy life expectancy (HALE) at birth (years)',
   'Country': 'Zimbabwe',
   'Comments': '',
   'Display Value': '51',
   'World Bank income group': 'Low-income',
   'Numeric': '51.00000',
   'Sex': 'Female',
   'High': '',
   'Low': '',
   'Year': '2012',
   'WHO region': 'Africa',
   'PUBLISH STATES': 'Published'
}

对于样本数据记录，我们希望将 XML 数据转换成下面的样子。在解析完 XML 树之后，我们希望数据的格式就是这样：

{
   'COUNTRY': 'ZWE',
   'GHO': 'WHOSIS_000002',
   'Numeric': '49.00000',
   'PUBLISHSTATE': 'PUBLISHED',
   'REGION': 'AFR',
   'SEX': 'BTSX',
   'WORLDBANKINCOMEGROUP': 'WB_LI',
   'YEAR': '2012'
}

注意 High 和 Low 字段是缺失的。如果 XML 数据集中这两个字段没有缺失的话，我们会把它们添加到新字典的键中。Display 的值也是缺失的。我们决定不用这个值，因为它和 Numeric 的值相同。

现在你的代码应该是像这样的：

from xml.etree import ElementTree as ET

tree = ET.parse('data-text.xml')
root = tree.getroot()

data = root.find('Data')

for observation in data:
    for item in observation:
        print item.attrib

想要创建数据结构，首先要为每一条数据记录创建一个空字典。我们向空字典中添加键值对，然后将每一条数据记录添加到一个列表中，这样我们最终的列表中就包含了所有的数据记录（类似于前面的 CSV 数据）。

首先创建用来保存数据的空字典和空列表。在最外层 for 循环上面，添加一行代码：all_data = []，然后将 record = {} 作为 for 循环的第一行，像这样：

all_data = []

for observation in data:
    record = {}
    for item in observation:
        print item.attrib

现在要找出每一行的键和值，并将其添加到数据记录的字典中。每一次调用 attrib，都会得到包含一个或多个键值对的字典，像这样：

{'Category': 'YEAR', 'Code': '2012'}

看上去 Category 键的值（这里是 YEAR）应该是新字典的键，而 Code 的值（这里是 2012）应该被设成对应的值。回想第 2 章学过的内容，字典的键应该易于检索（比如 YEAR），字典的值应该包含与键对应的值（比如 2012）。认识到这一点，前面一行将变成：

'YEAR': '2012'

将代码中的 print item.attrib 修改成 print item.attrib.keys()，然后重新运行该文件：

for item in observation:
    print item.attrib.keys()

输出的是每一个属性字典的键。我们想查看字典的键，这样才能构建新字典的键值对。我们发现只有两种不同的输出：['Category', 'Code'] 和 ['Numeric']。我们对两种情况分别处理。根据前面的研究，对于同时具有 Category 和 Code 的元素，需要用 Category 的值作为键，用 Code 的值作为值。

要做到这一点，在 item.attrib.keys() 的结尾添加 [0]：

for item in observation:
    lookup_key = item.attrib.keys()[0]
    print lookup_key

这叫索引（indexing）。返回的是列表的第一个元素。

使用列表索引

对于 Python 中的列表或其他可迭代对象，索引指的是取出列表的第 n 个对象。Python 的索引从 0 开始，也就是说，第一个元素的编号是 0，第二个元素是 1，以此类推。由于我们的列表中有一个或两个元素，我们只想要第一个元素，所以在代码中加了 [0]。

回头看一下上一章中狗狗的例子：

dog_names = ['Joker', 'Simon', 'Ellie', 'Lishka', 'Fido']

如果想从列表中提取出Ellie，那你想要的是列表的第三个元素。由于索引编号从 0 开始，你可以用下面的代码提取出这个元素：

dog_names[2]

在 Python 解释器中试一下上面的代码，然后尝试提取出 Simon。如果用负数作为索引会怎么样？（提示：负数索引是从列表末尾倒着向前数！）

重新运行代码，输出应该是这样的：

Category
Category
Category
Category
Category
Category
Category
Numeric

现在我们有了键的名字，下面要寻找键对应的值。我们要用 Category 键的值作为新字典的键。在内层 for 循环中创建一个新变量 rec_key，用来保存 item.attrib[lookup_key] 返回的值：

for item in observation:
    lookup_key = item.attrib.keys()[0]
    rec_key = item.attrib[lookup_key]
    print rec_key

修改完成后，在命令行中重新运行代码。对于每一条数据记录，我们得到下面的值：

PUBLISHSTATE
COUNTRY
WORLDBANKINCOMEGROUP
YEAR
SEX
GHO
REGION
49.00000

看起来都很适合做新字典的键，除了最后一个。这是因为最后一个元素是 Numeric 字典，而不是我们要处理的 Category 字典。如果想保留这些数据供后续使用，需要用 if 语句为这些数值元素创建一种特殊情况。

Python 的 if 语句

if 语句最基本的形式，是用来控制代码流的方法。if 语句是在告诉代码：如果满足该条件，那就执行给定的命令。

if 语句的另一种用法是与 else 一起使用。if-else 语句的意思是：如果满足第一个条件，那么执行相应的命令；但如果不满足该条件，那么执行 else 语句中的命令。

除了 if 和 if-else 之外，你还会将 == 作为比较运算符。== 用来给变量赋值，而 == 用来检验两个值是否相等。另外，!= 用来检验两个值是否不相等。这两个运算符返回的都是布尔值：True 或 False。

在 Python 解释器中试试下面的例子：

x = 5

if x == 5:
    print 'x is equal to 5.'

你看到了什么？ x == 5 返回的是 True，所以打印出了相应的文字。现在试一下这个：

x = 3

if x == 5:
    print 'x is equal to 5.'
else:
    print 'x is not equal to 5.'

本例中 x 等于 3，不等于 5，所以你应该会看到 else 代码块中 print 语句的输出。在 Python 中，你可以用 if 和 if-else 语句来帮助控制代码流的逻辑。

当 lookup_key 等于 Numeric 时，我们希望使用 Numeric 作为新字典的键，而不是用它对应的值作为新字典的键（就像 Category 键那样）。将代码修改成：

for item in observation:

    lookup_key = item.attrib.keys()[0]
    if lookup_key == 'Numeric':
        rec_key = 'NUMERIC'
    else:
        rec_key = item.attrib[lookup_key]

    print rec_key

运行修改后的代码，现在所有的键看起来应该都是我们想要的。下面提取出想要保存在新字典中的值，并将它们与这些键相关联。对于 Numeric，问题比较简单，因为我们只想要 Numeric 键对应的值。对代码作如下修改：

    if lookup_key == 'Numeric':
        rec_key = 'NUMERIC'
        rec_value = item.attrib['Numeric']
    else:
        rec_key = item.attrib[lookup_key]
        rec_value = None

    print rec_key, rec_value

运行修改后的代码，你会发现 Numeric 的 rec_value 已经匹配好了。比如：

NUMERIC 49.00000

对于其他所有的值，我们将 rec_value 设置成 None。在 Python 中，None 用来表示一个空值。我们来将这些空值填上真实的数值。记得每一条数据记录都有一个 Category 键和一个 Code 键，像这样：{'Category': 'YEAR', 'Code': '2012'}。对于这些元素，我们想将 Code 的值保存为 rec_value。修改这一行代码：rec_value = None，将 if-else 语句改成下面这样：

    if lookup_key == 'Numeric':
        rec_key = 'NUMERIC'
        rec_value = item.attrib['Numeric']
    else:
        rec_key = item.attrib[lookup_key]
        rec_value = item.attrib['Code']

    print rec_key, rec_value

重新运行代码，你现在应该会看到，rec_key 和 rec_value 都有相应的值。下面来创建字典：

if lookup_key == 'Numeric':
    rec_key = 'NUMERIC'
    rec_value = item.attrib['Numeric']
else:
    rec_key = item.attrib[lookup_key]
    rec_value = item.attrib['Code']

record[rec_key] = rec_value ➊

❶ 将每一个键值对添加到 record 字典中。

我们还需要将每一条数据记录添加到 all_data 列表中。在 2.3.3 节中讲过，可以用列表的 append 方法向列表中添加元素。在外层 for 循环的结尾，record 中已经包含了每一个子元素的键，这时我们将 record 添加到列表中。最后，在文件结尾添加 print 来查看数据。

将 XML 树转换成字典的全部代码应该是这样的：

from xml.etree import ElementTree as ET

tree = ET.parse('data-text.xml')
root = tree.getroot()

data = root.find('Data')

all_data = []

for observation in data:
    record = {}
    for item in observation:

        lookup_key = item.attrib.keys()[0]

        if lookup_key == 'Numeric':
            rec_key = 'NUMERIC'
            rec_value = item.attrib['Numeric']
        else:
            rec_key = item.attrib[lookup_key]
            rec_value = item.attrib['Code']

        record[rec_key] = rec_value

    all_data.append(record)

print all_data

运行上面的代码，你会看到一个长列表，列表的元素是每一条数据记录组成的字典，与 CSV 例子中的相同：

{'COUNTRY': 'ZWE', 'REGION': 'AFR', 'WORLDBANKINCOMEGROUP': 'WB_LI',
'NUMERIC': '49.00000', 'SEX': 'BTSX', 'YEAR': '2012',
'PUBLISHSTATE': 'PUBLISHED', 'GHO': 'WHOSIS_000002'}

可以看出来，从 XML 中提取数据要稍复杂一些。有时 CSV 文件和 JSON 文件也并不像本章的例子那样容易处理，但它们通常比 XML 文件容易处理一些。但是，作为 Python 开发人员，处理 XML 数据让你可以深入探索并成长，让你可以创建空列表和字典，然后向里面填充数据。在研究如何从 XML 树结构里提取数据的过程中，你还锻炼了自己的调试能力。在通往成为更优秀的数据分析员的路上，这些都是宝贵的经验。

3.4　小结

能够用 Python 处理机器可读的数据格式，这是数据处理的必备技能之一。本章讲了 CSV、 JSON 和 XML 三种文件类型。表 3-2 给出了在处理 WHO 数据的不同文件格式时所用到的 Python 库。

表3-2：文件类型和文件扩展名

文件类型

文件扩展名

Python 库

CSV、TSV

.csv、.tsv

csv 库（https://docs.python.org/2/library/csv.html）

JSON

.json、.js

json 库（https://docs.python.org/2/library/json.html）

我们还讲了一些新的 Python 概念。现在你应该知道如何在 Python 解释器中运行 Python 代码，以及如何将代码保存到新文件，并在命令行中运行。我们还学习了用 import 导入文件，以及用 Python 的 read 和 open 打开本地文件并读取。

我们讲的编程新概念还包括用 for 循环遍历文件、列表或树，还有用 if-else 语句判断特定条件是否满足，然后据此执行对应的命令。表 3-3 对本章学过的新函数和代码逻辑做了总结。

表3-3：Python编程的新概念

概念

作用

import（https://docs.python.org/2/reference/simple_stmts.html#import）

向 Python 中导入模块

open（https://docs.python.org/2/library/functions.html#open）

内置函数，用 Python 打开本地文件

for 循环（http://anh.cs.luc.edu/python/hands-on/3.1/handsonHtml/loops.html#basic-for-loops）

一段代码，运行 n 次

if-else 语句（http://anh.cs.luc.edu/python/hands-on/3.1/handsonHtml/ifstatements.html#simple-if-statements）

如果满足特定条件，运行一段代码

==（等于运算符，https://docs.python.org/2/reference/expressions.html#not-in）

检验两个值是否相等

序列索引（https://docs.python.org/2/library/stdtypes.html#sequence-types-str-unicode-list-tuple-bytearray-buffer-xrange）

取出序列（字符串、列表等）中第 n 个对象

最后，我们在本章创建并保存了许多代码文件和数据文件。假如你完成了本章的所有练习，应该有三个代码文件和三个数据文件。本章前面推荐过组织代码的方法。如果你还没有照做的话，现在马上去做。这是目前所有文件的组织结构示例：

data_wrangling/
    code/
        ch3_easy_data/
            import_csv_data.py
            import_xml_data.py
            import_json_data.py
            data-text.csv
            data-text.xml
            data-json.json
        ch4_hard_data/
            ...

接下来，我们要学习更复杂的数据格式！

第 4 章　处理 Excel 文件

与上一章的数据不同，将本章和下一章的数据导入 Python 不会那么轻松，某些数据的导入需要花点工夫。这是因为有些数据格式是用于机器读取的，而另一些数据格式是通过桌面工具来交互的，比如我们即将看到的那些数据。在本章和下一章里，我们将研究两种文件类型实例：Excel 文件和 PDF，并给出几条一般性说明，在遇到其他文件类型时可以参考。

目前为止，你在本书中学到的数据导入方法都是比较常规的方法。本章我们将开始学习一些数据处理过程，每次处理过程之间都会有很大差异。虽然过程更加困难，但最终目标是相同的：提取有用信息，并将其转换成 Python 可用的格式。

本章和下一章的例子中使用的数据来自于 UNICEF（联合国儿童基金会）2014 年的报告，报告主题是“世界儿童状况”（http://www.unicef.org/sowc2014/numbers/）。数据有 PDF 和 Excel 两种格式。

当需要从这些更难处理的文件格式中提取数据时，你可以想象文件里面有一个很恨你的人，因为过程可能很痛苦。我们向你保证，在大多数情况下，生成数据文件的人只是不知道发布机器可读格式文件的重要性。

4.1　安装Python包

首先我们要学习如何安装 Python 外部包（或库）。目前为止，我们使用的 Python 库都是在安装 Python 时默认安装的。还记得在第 3 章里我们导入的 csv 和 json 包吗？它们属于标准库里的程序包，在安装 Python 时默认安装。

Python 默认安装了一些常用库。由于许多库并不常用，所以你需要手动指定安装。如果把 Python 所有的库都装到电脑里，占用的空间会很大。

Python 库有一个汇总在线目录，叫作 PyPI（https://pypi.python.org/pypi），里面保存了大量的 Python 包及其元数据和文档。

本章我们要处理的是 Excel 文件。在浏览器中访问 PyPI 网站，你可以搜索与 Excel 相关的库（搜索结果见 https://pypi.python.org/pypi?:action=search&term=excel&submit=search），搜索结果中有许多可以下载的 Python 包。这是搜索应该使用哪个 Python 包的一种方法。

从现在开始，我们将使用 pip 来安装 Python 包。安装 pip（安装方法见 https://pip.pypa.io/en/latest/installing/#install-pip）的方法有很多种，你在第 1 章里应该已经安装好了。

首先，要找出 Excel 中的数据值。我们通过安装外部包 xlrd（https://pypi.python.org/pypi/xlrd/0.9.3）来实现。我们用 pip 安装：

pip install xlrd

运行 uninstall 命令可以卸载这个 Python 包：

pip uninstall xlrd

试一下安装 xlrd，然后卸载，然后重新安装。掌握 pip 命令是很有用的，因为在本书中和你的数据处理生涯中都会经常用到这些命令。

有那么多可选的 Python 包，为什么选择 xlrd 呢？选择 Python 库的过程是不完善的。挑选的方法有许多种。不要试图去找到正确的库。在磨炼自身技能的过程中，你可能需要从几个库中选择，选择你能理解的那个库。

我们建议，首先要去网络上搜索，看看其他人推荐了哪些库。如果搜索“用 python 解析 excel”（https://www.google.com.sg/search?q=parse+excel+using+python&oq=parse+excel+using+python&gws_rd=cr&ei=Zu7gV-L_FYqEmgH_zKT4Cg），你会发现搜索结果前几条里就有 xlrd 库。

但是答案并不总是这么明显。在第 13 章研究 Twitter 库时，我们将学习更多关于选择过程的内容。

4.2　解析Excel文件

想从 Excel 工作表中提取数据，有时最简单的方式反而是寻找更好的方法来获取数据。直接解析有时并不能解决问题。在开始解析文件之前，先回答下面几个问题。

你是否尝试过寻找其他格式的数据？有时同一个数据源可能也会提供其他格式的数据。

你是否尝试过电话咨询，询问数据是否还有其他格式？在第 6 章中给出了更多建议。

你是否尝试过将 Excel 文件（或文档阅读器）的一个或多个标签导出成 CSV 格式？如果你的 Excel 工作表中只有几个标签里有数据，或者只有一个标签里的独立数据，这是一种很好的解决办法。

如果这些方法你都试过了，还是找不到你想要的数据，那你就需要用 Python 来解析 Excel 文件。

4.3　开始解析

我们解析 Excel 文件用的是 xlrd 库。这个库是用 Python 处理 Excel 文件的一系列库（http://www.python-excel.org/）之一。处理 Excel 文件主要有三个库。

xlrd

读取 Excel 文件。

xlwt

向 Excel 文件写入，并设置格式。

xlutils

一组 Excel 高级操作工具（需要先安装 xlrd 和 xlwt）。

在用到这三个库的时候你需要分别安装。但本章只会用到 xlrd。我们要将 Excel 文件读取到 Python 中，所以你先要检查是否安装了 xlrd：

pip install xlrd

如果你得到以下错误信息，说明你没有安装 pip：

bash: pip: command not found

有关 pip 的安装说明，可以查阅 1.2.4 节，也可以访问 https://pip.pypa.io/en/latest/installing/。

按步骤完成以下内容，即可安装好 Excel 文件的工作环境（也可能是类似的步骤，这和你的文件组织系统有关）。

(1) 为 Excel 任务创建一个文件夹。

(2) 创建一个新的 Python 文件，文件名叫 parse_excel.py，把它放到上面创建的文件夹中。

(3) 从本书仓库（https://github.com/jackiekazil/data-wrangling）下载名为 SOWC 2014 Stat Tables_Table 9.xlsx 的 Excel 文件，放到同一个文件夹中。

进入这个文件夹，在终端中输入下面的命令，从命令行中运行该脚本：

python parse_excel.py

学完本章后，我们写的脚本就可以解析这个 Excel 文件中保存的童工数据和童婚数据。

在脚本文件的开头，我们需要导入 xlrd 库，然后用 Python 打开 Excel 工作簿。我们将打开的文件保存在变量 book 中：

import xlrd

book = xlrd.open_workbook('SOWC 2014 Stat Tables_Table 9.xlsx')

与 CSV 不同，Excel 工作簿可以有多个标签（tab）或工作表（sheet）。想要获取数据，我们要找到包含目标数据的工作表。

如果有几个工作表，你可以猜一下索引号，但如果工作表很多的话就没法猜了。所以你应该知道 book.sheet_by_name(somename) 命令，其中 somename 是你要访问工作表的名字。

我们来看一下工作表都有哪些名字：

import xlrd

book = xlrd.open_workbook('SOWC 2014 Stat Tables_Table 9.xlsx')

for sheet in book.sheets():
    print sheet.name

我们要找的工作表是 Table 9。所以我们把这个名字添加到脚本中：

import xlrd

book = xlrd.open_workbook('SOWC 2014 Stat Tables_Table 9.xlsx')
sheet = book.sheet_by_name('Table 9')

print sheet

运行上面的代码，程序会退出，并给出以下错误信息：

xlrd.biffh.XLRDError: No sheet named <'Table 9'>

这时你可能有些困惑不解。问题在于我们看到的内容与实际内容并不相同。

打开 Excel 工作簿，双击工作表名选中它，你会发现在结尾有一个多出来的空格。用户在浏览时是看不到这个空格的。第 7 章中我们将学习如何用 Python 处理这样的错误。现在我们暂且在代码中加一个空格。

将这行代码：

sheet = book.sheet_by_name('Table 9')

修改成：

sheet = book.sheet_by_name('Table 9 ')

现在运行脚本应该一切正常了。你会看到类似这样的输出：

<xlrd.sheet.Sheet object at 0x102a575d0>

我们来探索一下能对工作表做哪些事情。在给 sheet 变量赋值后，添加这行代码，然后重新运行脚本：

print dir(sheet)

在返回的列表中，你会发现一个叫作 nrows 的方法。我们将用这个方法来遍历所有行。如果加上 print sheet.nrows，返回值是总行数。

现在试一下：

print sheet.nrows

你得到的返回值应该是 303。我们要遍历每一行，也就是说我们需要一个 for 循环。在 3.1.1 节我们学过，for 循环可以遍历列表中的元素，所以我们需要将 303 转换成一个列表，这样就可以遍历 303 次。我们将使用 range 函数来实现。

什么是 range() ？

还记得我们说过，Python 有许多有用的内置函数吗？ range 就是其中之一。range 函数（https://docs.python.org/2/library/functions.html#range）接收一个数字作为参数，输出一个那么多元素组成的列表。

打开 Python 解释器，输入下面的代码，看看 range 函数的输出是什么样的：

range(3)

输出应该是：

[0, 1, 2]

返回了三个元素。现在我们可以创建一个 for 循环对列表遍历三次。

关于 range 函数需要注意以下两点。

返回的列表是从 0 开始的。这是因为 Python 列表索引是从 0 开始的。如果你想让列表从 1 开始，可以设置范围的始末。比如，range(1, 4) 返回的是 [1, 2, 3]。注意，列表中不包含最后一个数字，如果想得到的是 [1, 2, 3]，需要将第二个数字设为 4。

Python 2.7 还有一个叫作 xrange 的函数。二者略有不同，但只有在处理大型数据集时才会发现——xrange 的速度要更快一些。

有了 range 函数，就可以将 303 转换成一个列表，用于 for 循环的遍历，我们的脚本应该是像这样的：

import xlrd

book = xlrd.open_workbook('SOWC 2014 Stat Tables_Table 9.xlsx')
sheet = book.sheet_by_name('Table 9 ')

for i in range(sheet.nrows):        ➊
    print i                         ➋

❶ 对 range(303) 的索引号 i 做循环，range(303) 是一个包含 303 个连续整数的列表。

❷ 输出 i，是从 0 到 302 的连续整数。

下面需要查找每一行，提取出每行的内容，而不是仅仅打印编号。要实现查找功能，需要用 i 作为索引编号来定位第 n 行。

我们用 row_values 来获取每一行的值，这个方法是之前 dir(sheet) 给出的。根据 row_values 的文档（http://www.lexicon.net/sjmachin/xlrd.html#xlrd.Sheet.row_values-method），我们知道这个方法接收一个索引数字，返回对应行的值。将这个方法添加到 for 循环中，重新运行脚本：

for i in range(sheet.nrows):
    print sheet.row_values(i)       ➊

❶ 利用 i 作为索引来查找对应行的值。由于这个方法是在 for 循环中，for 循环的次数等于工作表的长度，所以我们对数据表的每一行都调用了这个方法。

运行代码，每行都输出一个列表。下面给出的是一部分输出：

['', u'TABLE 9. CHILD PROTECTION', '', '', '', '', '', '', '', '', '', '', '', '',
'', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '',
'', '', '', '', '', '', '', '', '']
['', '', u'TABLEAU 9. PROTECTION DE L\u2019ENFANT', '', '', '', '', '', '', '', '',
'', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '',
'', '', '', '', '', '', '', '', '', '', '', '']
['', '', '', u'TABLA 9. PROTECCI\xd3N INFANTIL', '', '', '', '', '', '', '', '',
'', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '',
'', '', '', '', '', '', '', '', '', '', '']
['', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '',
'', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '',
'', '', '']
['', u'Countries and areas', '', '', u'Child labour (%)+\n2005\u20132012*', '',
'', '', '', '', u'Child marriage (%)\n2005\u20132012*', '', '', '', u'Birth
registration (%)+\n2005\u20132012*', '', u'Female genital mutilation/ cutting (%)+\
n2002\u20132012*', '', '', '', '', '', u'Justification of wife beating (%)\n 2005\
u20132012*', '', '', '', u'Violent discipline (%)+\n2005\u20132012*', '', '', '',
'', '', '', '', '', '', '', '', '', '', '', '', '', '']

现在我们能够查看每一行的内容，我们需要从中提取出需要的信息。为了确定需要哪些信息，以及如何获取这些信息，更简单的做法是用 Excel 程序打开该文件，比如 Windows 上的 Microsoft Excel 或 Mac 上的 Numbers。打开工作簿的第二个标签，你会看到好多标题行。

　我们代码的目的是抓取英文文本。但如果你想挑战一下自己，可以尝试提取法语或西班牙语的标题和国家。

在第二个标签里，看一下你能提取哪些信息，并思考一下如何优化组织这些信息。我们这里提供一种可行的方法，但还有很多其他方法，用到了不同的数据结构。

对于本次练习来说，我们要提取的是童工和童婚的统计数据。下面是组织数据的一种方法——后面将以此例作为目标：

{
    u'Afghanistan': {
        'child_labor': {
            'female': [9.6, ''], ➊
            'male': [11.0, ''],
            'total': [10.3, '']},
        'child_marriage': {
            'married_by_15': [15.0, ''],
            'married_by_18': [40.4, '']
        }
    },

    u'Albania': {
        'child_labor': {
            'female': [9.4, u'  '],
            'male': [14.4, u'  '],
            'total': [12.0, u'  ']},
        'child_marriage': {
            'married_by_15': [0.2, ''],
            'married_by_18': [9.6, '']
        }
    },
    ...
}

❶ 如果你查看 Excel 中的数据，有些数字可能不太一样。这是因为 Excel 常常会将数字四舍五入。我们这里给出的数字就是你用 Python 解析时会得到的数字。

　在开始写代码时，提前想好输出格式的样子，并写出一个这样的数据实例，可以节省很多时间。一旦确定了你想要的数据格式，你可以问问自己：“下一步我要怎么做才能实现目标？”当你不知道下一步怎么做时，这种方法特别有用。

我们将使用两种 Python 结构来提取数据。用到的第一个方法是嵌套 for 循环，就是在把一个 for 循环放到另一个 for 循环里。如果你有 x 行数据，每行包含 y 个元素，经常会用到这种方法。想要访问每一个元素，你需要一个 for 循环遍历每一行，然后另一个 for 循环遍历每一个元素。我们在第 3 章的例子中也用过嵌套 for 循环。

我们将使用嵌套 for 循环来输出每一行的每一个单元格。这将会输出我们之前见过的元素，之前每一行是以列表的形式出现。

for i in xrange(sheet.nrows):
    row = sheet.row_values(i)        ➊

    for cell in row:                 ➋
        print cell                   ➌

❶ 将每一行内容组成的列表保存到 row 变量中。这提高了代码的可读性。

❷ 遍历列表中的每一个元素，也就是当前行的每一个单元格。

❸ 输出单元格的值。

运行包含嵌套 for 循环的完整代码，你会发现输出不再那么有用了。这时我们要用第二种方法来探索 Excel 文件——计数器。

什么是计数器

计数器是一种控制程序流的方法。有了计数器，你可以在 for 循环中添加一条 if 语句来控制 for 循环，每次迭代后计数增加。如果计数超过了你的设定值，for 循环将不再运行 if 语句中的代码。在 Python 解释器中试试下面的例子：

count = 0                     ➊

for i in range(1000):         ➋
    if count < 10:            ➌
      print i
    count += 1                ➍

print 'Count: ', count        ➎

➊ 将变量 count 初始值设为 0。

➋ 创建一个循环，循环范围是从 0 到 999。

➌ 测试计数是否小于 10，如果是的话，输出 1。

➍ 增加 count 的值，这样计数会随着循环次数增加而增加。

➎ 输出最终计数。

在我们的代码中添加一个计数器，这样就可以逐项查看每一行和每一个单元格，找出我们需要提取的内容。一定要注意计数器在代码中的位置——放在单元格的循环里或者放在行的循环里，结果可能会大不相同。

修改 for 循环的代码如下：

count = 0
for i in xrange(sheet.nrows):
    if count < 10:
        row = sheet.row_values(i)
        print i, row               ➊

    count += 1

❶ 输出行编号 i 和对应行的内容，这样我们可以看到每一行包含的信息。

现在回头看一下我们期望的最终输出格式，我们真正要搞清楚的是国家名字是从哪行开始的。要记住，国家名字是最终输出字典的第一个键：

{
    u'Afghanistan': {...},
    u'Albania': {...},
    ...
}

上面脚本中含有计数器，并且控制语句是 count < 10，运行后你会发现，输出中并没有包含国家名字出现的那一行。

要跳过几行才能找到感兴趣的数据，所以我们要想办法确定从哪一行开始采集数据。在上面的例子中，我们知道国家名字开始的行编号大于 10。但怎么能知道具体是从哪一行开始的呢？

答案就在下一个代码示例中，但在你看答案之前，试着自己修改计数器，找到国家名字开始的那一行。（有许多种方法都可以找到，所以即使你的答案与下面的代码示例稍有不同，也是可以的。）

找到了正确的行编号，你需要在那一行后面添加一条 if 语句，开始提取该行的内容。我们只处理那一行之后的数据。

如果你找到了正确的行编号，代码应该是像这样的：

count = 0

for i in xrange(sheet.nrows):
    if count < 20:                       ➊
        if i >= 14:                      ➋
            row = sheet.row_values(i)
            print i, row
        count += 1

❶ 这一行代码遍历前 20 行内容，找到国家名字开始出现的那一行。

❷ if 语句的作用是，从国家名字出现的那一行开始输出每行的内容。

现在你的输出应该是像这样的：

14 ['', u'Afghanistan', u'Afghanistan', u'Afganist\xe1n', 10.3, '', 11.0, '', 9.6,
'', 15.0, '', 40.4, '', 37.4, '', u'\u2013', '', u'\u2013', '', u'\u2013', '', u'\
u2013', '', 90.2, '', 74.4, '', 74.8, '', 74.1, '', '', '', '', '', '', '', '', '',
'', '', '', '']
15 ['', u'Albania', u'Albanie', u'Albania', 12.0, u' ', 14.4, u' ', 9.4,
u' ', 0.2, '', 9.6, '', 98.6, '', u'\u2013', '', u'\u2013', '', u'\u2013', '',
36.4, '', 29.8, '', 75.1, '', 78.3, '', 71.4, '', '', '', '', '', '', '', '', '',
'', '', '', '']
16 ['', u'Algeria', u'Alg\xe9rie', u'Argelia', 4.7, u'y', 5.5, u'y', 3.9, u'y',
0.1, '', 1.8, '', 99.3, '', u'\u2013', '', u'\u2013', '', u'\u2013', '', u'\u2013',
'', 67.9, '', 87.7, '', 88.8, '', 86.5, '', '', '', '', '', '', '', '', '', '', '',
'', '']
...more

下面要把每一行转换成字典格式。这样一来，在后续章节我们想要对数据进行其他操作时，数据将更有意义。

回头看一下前面我们期望的输出格式示例。我们想要的是一个字典，用国家作为键。我们用索引将国家名字提取出来。

什么是索引

回想第 3 章学过的内容，索引是从一系列对象（比如列表）中提取元素的方法。对于要解析的 Excel 文件来说，我们把 i 传入 sheet.row_values()，row_values 方法以 i 作为索引。我们在 Python 解释器里练习一下索引。

创建一个列表实例：

x = ['cat', 'dog', 'fish', 'monkey', 'snake']

想要提取出第二个元素，你可以添加一个索引编号来指代这个元素，像这样：

>>>x[2]
'fish'

如果这不是你预期的结果，要记住 Python 的索引编号是从 0 开始的。所以，如果想提取人们一般所说的第二个元素，我们要用数字 1：

>>>x[1]
'dog'

你还可以用负数索引：

>>>x[-2]
'monkey'

正数索引和负数索引的区别是什么？你可以发现，正数索引是从前向后数，而负数索引是从后向前数。

切片（slicing）是与索引相关的另一个有用工具。切片可以从一个列表或可迭代对象中“切”出一部分来。比如：

>>>x[1:4]
['dog', 'fish', 'monkey']

注意，与 range 一样，切片操作从第一个数字开始，但第二个数字的意思是：“直到这个数，但并不包括这个数。”

如果你没有输入第一个数或最后一个数，切片操作会一直到头或一直到尾。下面给了几个例子：

x[2:]
['fish', 'monkey', 'snake']

x[-2:]
['monkey', 'snake']

x[:2]
['cat', 'dog']

x[:-2]
['cat', 'dog', 'fish']

其他可迭代对象的切片操作与列表相同。

我们在代码中添加一个字典，然后提取出每一行的国家名字，作为字典的键。

将 for 循环的代码修改如下：

count = 0
data = {}                                    ➊

for i in xrange(sheet.nrows):
    if count < 10:
        if i >= 14:
            row = sheet.row_values(i)
            country = row[1]                 ➋
            data[country] = {}               ➌
            count += 1

print data                                   ➍

❶ 创建一个空字典来保存数据。

❷ row[1] 提取出遍历每一行的国家名字。

❸ data[country] 将国家设为 data 字典的键，对应的值设为另一个字典，因为我们接下来要将数据保存在这个字典里。

❹ 输出 data 字典，我们可以看到里面的内容。

现在你的输出应该是像这样的：

{u'Afghanistan': {}, u'Albania': {}, u'Angola': {}, u'Algeria': {},
u'Andorra': {}, u'Austria': {}, u'Australia': {}, u'Antigua and Barbuda': {},
u'Armenia': {}, u'Argentina': {}}

现在我们需要将每一行其他的值与工作簿中的值对应起来，然后保存到字典中。

　在你把所有的值都提取出来，并与 Excel 工作表中的值对比时，你会犯许多错误。这很正常，也是预料之中的事情。你应该接受这个过程，这说明你正在一步步解决问题。

我们首先创建一个空的数据结构，可以用来保存数据。因为我们已经知道数据行是从第 14 行开始的，所以可以删掉计数器。我们知道 xrange 可以输入起点和终点，所以可以从 14 开始计数，直到文件的结尾。修改后的代码如下：

data = {}

for i in xrange(14, sheet.nrows):    ➊
    row = sheet.row_values(i)
    country = row[1]

    data[country] = {                ➋
        'child_labor': {             ➌
            'total': [],             ➍
            'male': [],
            'female': [],
        },
        'child_marriage': {
            'married_by_15': [],
            'married_by_18': [],
        }
    }

print data['Afghanistan']            ➎

❶ 我们可以删掉计数器相关的代码，让 for 循环从工作表第 14 行开始。这一行代码从 i 的值为 14 开始循环，所以我们自动跳过数据集中不需要的那些行。

❷ 这一行代码将字典扩展成很多行，可以填入其他数据点。

❸ 这一行代码创建了 child_labor 键，并把它的值设为另一个字典。

❹ 这个字典又包含了几个字典，字典的键是字符串，说明了保存的数据内容。每一个键对应的值都是列表。

❺ 输出与 Afghanistan 键对应的值。

Afghanistan 的输出数据是像这样的：

{
    'child_labor': {'total': [], 'male': [], 'female': []},
    'child_marriage': {'married_by_18': [], 'married_by_15': []}
}

现在我们来填入数据。因为我们用索引访问每一行的每一列，所以可以将工作表的值填入这些列表中。观察工作表，弄清楚哪一列对应的是哪一部分数据，我们可以把 data 字典修改成这样：

data[country] = {
    'child_labor': {
        'total': [row[4], row[5]],    ➊
        'male': [row[6], row[7]],
        'female': [row[8], row[9]],
    },
    'child_marriage': {
        'married_by_15': [row[10], row[11]],
        'married_by_18': [row[12], row[13]],
    }
}

❶ 每一列包含两个单元格，所以我们的代码把两个值都保存下来。这一行代码中童工总数是第 5 列和第 6 列，而我们知道 Python 从 0 开始索引，所以索引编号是 4 和 5。

重新运行代码，我们会得到类似这样的输出：

{
    'child_labor': {'female': [9.6, ''], 'male': [11.0, ''], 'total': [10.3, '']},
    'child_marriage': {'married_by_15': [15.0, ''], 'married_by_18': [40.4, '']}}
}

　在继续学习后面的内容之前，输出几条数据记录，检查字典中的数据是否正确。有时一个索引错了，剩下的数据就全都错了。

最后，我们可以用 pprint 语句而不是 print 语句来预览数据。对于复杂的数据结构（如字典），用这种方法检查输出要容易很多。将下面两行代码添加到文件结尾，可以查看格式化的数据：

import pprint                 ➊
pprint.pprint(data)           ➋

❶ 导入 pprint 库。import 语句一般出现在文件开头，但为了简单起见，我们把它放在这里。后面你可以把这几行删掉，因为它们不会影响脚本的运行。

❷ 将 data 传入 pprint.pprint() 函数。

滚动查看输出的内容，你会发现大部分内容看起来都很好，但有几条记录看起来出了问题。

查看工作表的内容，你应该会注意到，国家的最后一行是津巴布韦（Zimbabwe）。所以我们要找到国家名字是 'Zimbabwe' 的那一行，然后退出循环。我们在代码中添加 break 来退出循环，这样就完全退出了 for 循环，继续执行下面的脚本。我们来添加 break 停止循环。在 for 循环的结尾，添加下列代码，然后重新运行：

        if country == 'Zimbabwe': ➊
            break ➋

❶ 如果国家名字是津巴布韦的话……

❷ 退出 for 循环。

添加了 break 之后，你有没有得到 NameError: name 'country' is not defined 的错误信息？如果有的话，检查一下代码缩进。for 循环中的 if 语句要缩进四个空格。

逐步运行代码可以帮你发现问题所在。如果你想知道 for 循环中某个变量的值，比如 country，可以在 for 循环中添加 print 语句，在脚本报错退出之前查看变量的值。这样你可能会发现出错的地方在哪里。

现在脚本的输出与我们的最终目标是一致的。我们要做的最后一件事情，是在脚本中添加一些注释。

注释

在代码中添加注释，可以让你（或其他人）在以后能够理解代码的含义。添加注释的方法是在注释前加一个 # 号：

# 这是Python注释。Python会忽略该行。

添加多行注释的格式如下：

"""

    这是多行注释的格式。
    如果你的注释很长或者
    你想插入一段较长的描述，
    你应该使用这种类型的注释。

"""

你的脚本现在应该是这样的：

"""
    这是用来分析童工和童婚数据的脚本。            ➊
    本脚本中用到的Excel文件可以在以下链接中获取：
        http://www.unicef.org/sowc2014/numbers/
"""

import xlrd
book = xlrd.open_workbook('SOWC 2014 Stat Tables_Table 9.xlsx')

sheet = book.sheet_by_name('Table 9 ')
data = {}
for i in xrange(14, sheet.nrows):
    # 从第14行开始，因为这是国家数据的起点。      ➋

    row = sheet.row_values(i)

    country = row[1]

    data[country] = {
        'child_labor': {
            'total': [row[4], row[5]],
            'male': [row[6], row[7]],
            'female': [row[8], row[9]],
        },
        'child_marriage': {
            'married_by_15': [row[10], row[11]],
            'married_by_18': [row[12], row[13]],
        }
    }

    if country == 'Zimbabwe':
        break

import pprint
pprint.pprint(data)                               ➌

❶ 多行注释，大致说明脚本的用途。

❷ 单行注释，说明我们为什么从第 14 行开始，而不是从前面开始。

❸ 从简单的数据解析过渡到数据分析工作时，我们可以也应该删除这两行。

现在我们的输出应该和上一章的数据差不多。在下一章里，我们进一步将相同的数据从 PDF 文件中解析出来。

4.4　小结

Excel 格式是介于机器可读与人工可读之间的奇怪格式，在一定程度上是机器可读的。Excel 文件本来不是用程序来读取的，但可以被程序解析。

为了处理这种非标准格式，我们需要安装外部库。寻找外部库有两种方法：一种方法是搜索 PyPI 网站（https://pypi.python.org/pypi，Python 包索引）；另一种方法是搜索教程和入门指南，看一下其他人的做法。

找到了需要安装的库，可以使用 pip install 命令来安装；想要卸载 Python 库，可以使用 pip uninstall 命令。

除了学习如何用 xlrd 库解析 Excel 文件之外，我们还学习了几个 Python 编程新概念，表 4-1 对这些新概念做了汇总。

表4-1：Python编程新概念

概念

作用

range 和 xrange（https://docs.python.org/2/library/functions.html#range）

将一个数字转换成连续数字组成的列表。例如：range(3) 输出的是 [0, 1, 2]

从 0 开始计数，而不是 1

这是需要注意的计算机构造原理（computer construct），所有编程都是这样的。在使用 range、索引或切片时一定注意这一点

索引和切片（http://pythoncentral.io/cutting-and-slicing-strings-in-python/）

用来提取一个字符串或列表的特定子集

计数器

用来控制 for 循环的工具

嵌套 for 循环

用于遍历数据结构中的数据结构，例如由列表组成的列表、由字典组成的列表或者由字典组成的字典

pprint

pprint 可以在终端中美化输出数据。在编程处理复杂的数据结构时，这个函数很好用

break

利用 break 可以提前退出 for 循环，它可以结束循环，继续执行后面的脚本

注释

在所有代码中添加注释是很重要的，这样你在以后参考的时候能够理解代码的含义

随着继续深入学习处理 PDF 文件，为了回答你要研究的问题，要学会寻找数据的其他格式，或者使用其他寻找数据的方法，这一点是很重要的。

第 5 章　处理 PDF 文件，以及用 Python解决问题

只发布 PDF 格式的数据是十分错误的，但有时你也没有其他选择。本章你将学习如何解析 PDF，在学习过程中，你还会学习如何解决在代码中遇到的错误。

我们还会讲到如何编写脚本，从一些基本概念（例如导入模块）讲起，逐步过渡到一些更复杂的内容。学完本章，你将学会在代码中思考问题与解决问题的许多方法。

5.1　尽量不要用PDF

本章用到的数据与上一章相同，只不过是 PDF 格式的。一般来说，我们不会去寻找难以解析的数据格式，但我们在本书中之所以这么做，是因为你要处理的数据可能并不总是理想中的格式。你可以在本书的 GitHub 仓库（https://github.com/jackiekazil/data-wrangling）中找到本章所用的 PDF 文件。

在开始解析 PDF 数据之前，你需要考虑以下几件事情。

你是否尝试寻找其他格式的数据？如果在网上找不到，试试打电话（见 6.4.1 节）或发邮件求助。

你是否尝试过从文档中直接复制粘贴数据？有时你可以很方便地在 PDF 文件里选择并复制数据，然后粘贴到电子表格中。但这种做法不一定每次都能奏效，而且也无法规模化（如果有大量文件或页面，你就没法快速完成了）。

如果你不得不处理 PDF 文件的话，需要学习如何用 Python 解析其中的数据。我们来开始学习。

5.2　解析PDF的编程方法

处理 PDF 要比 Excel 文件更加困难，因为每一个 PDF 文件的格式都不可预知。（如果你有一系列 PDF 文件，那么就可以对文件进行解析了，因为这些文档的格式很可能是一致的。）

PDF 工具处理文档的方法有很多种，其中一种方法是将 PDF 转换成文本。在我们写作本书的同时，Danielle Cervantes 在 NICAR 上做了一个关于 PDF 工具的演讲，NICAR 是一个针对记者的 listserv1。这个演讲汇总了下列 PDF 解析工具：

1listserv 是一个用来管理电子邮件列表的软件。——译者注

ABBYY's Transformer

Able2ExtractPro

Acrobat Professional

Adobe Reader

Apache Tika

Cogniview's PDF to Excel

CometDocs

Docsplit

Nitro Pro

PDF XChange Viewer

pdfminer

pdftk

pdftotext

Poppler

Tabula

Tesseract

xPDF

Zamzar

除了上面这些工具，你还可以用许多编程语言来解析 PDF，其中包括 Python。

　仅因为你知道类似 Python 这样的工具，并不意味着它总是解决问题的最佳工具。考虑到可用的工具有很多种，你可能会发现用另一个工具来完成部分任务（比如数据提取）更加容易。保持开放的心态，事先对各种可用的工具进行调研。

在 4.1 节中提到过，PyPI 网站是查找 Python 包的好地方。如果搜索“PDF”（https://pypi.python.org/pypi?:action=search&term=pdf&submit=search），你会得到一堆结果，类似图 5-1 给出的那些。

图 5-1：PyPI 网站上的 PDF 包

浏览这些 Python 包，了解一下每个库的详细信息，但分辨不出哪一个库是解析 PDF 的最佳选择。如果你尝试更多的搜索，比如“parse pdf”（解析 pdf），会出现更多的库供你选择，但还是没有明显的最佳选择（搜索结果见 https://pypi.python.org/pypi?:action=search&term=parse+pdf&submit=search）。所以我们用搜索引擎查看一下大家都在用什么库来解析 PDF。

　在搜索库或者答案时，注意观察你找到资料的发布日期。帖子或问题的年代越久远，它过时且不再适用的可能性就越大。先试着将搜索范围限定在过去的两年内，然后仅在需要时再扩大搜索的时间范围。

在阅读了许多教程、文档、博客文章和几篇有用的文章（例如这一篇：http://www.binpress.com/tutorial/manipulating-pdfs-with-python/167）之后，我们决定使用 slate 库（https://pypi.python.org/pypi/slate）。

　slate 能够满足我们的需求，但并非总是如此。放弃并从头开始也是可以的。如果有很多库可供选择的话，选择你认为最合适的那一个，即使有人告诉你它不是“最好的”工具。究竟哪一个工具最好，大家见仁见智。在你学习编程的过程中，“最好的”工具就是你凭直觉选择的那一个。

5.2.1　利用slate库打开并读取PDF

我们决定用 slate 库来解决这个问题，下面我们来安装这个库。在命令行中运行：

pip install slate

现在你已经安装了 slate，你可以创建一个名为 parse_pdf.py 的脚本，将下面的代码保存其中。一定要确保脚本文件和 PDF 文件位于同一文件夹下，或者修改代码中的文件路径。这段代码会打印出文件的前两行内容：

import slate                        ➊

pdf = 'EN-FINAL Table 9.pdf'        ➋

with open(pdf) as f:                ➌
    doc = slate.PDF(f)              ➍

for page in doc[:2]:                ➎
    print page

❶ 导入 slate 库。

❷ 创建字符串变量，用于保存文件路径，一定要确保空格和大小写都正确。

❸ 将文件名字符串传入 Python 的 open 函数，这样 Python 就可以打开该文件。Python 将打开的文件保存为变量 f。

❹ 将打开的文件 f 传递给 slate.PDF(f)，slate 可以将 PDF 文件解析成可用的格式。

❺ 遍历文档 doc 的前两页并输出，这样我们可以知道程序运行正常。

　通常来说，pip 会安装所有必需的依赖库；但需要包管理器列出这些依赖库。在使用 slate 库或其他库时，如果你得到 ImportError，你应该仔细阅读下一行错误信息，看一下哪个库没有安装。运行代码时如果得到如下错误信息：ImportError: No module named pdfminer.pdfparser，说明安装 slate 时没有正确安装 pdfminer，即使它是必需的库。你需要运行 pip install --upgrade --ignore-installed slate==0.3 pdfminer==20110515 来安装 pdfminer（参见 slate 仓库的这个 issue，https://github.com/timClicks/slate/issues/5）。

运行脚本，然后将输出内容与 PDF 中的内容对比一下。

下面是第一页的内容：

TABLE 9Afghanistan 10  11  10  15  40  37  -  -  -  -  90  74  75  74Albania
12  14  9  0  10  99  -  -  -  36  30  75  78  71Algeria 5 y 6 y 4 y 0  2  99
-  -  -  -  68  88  89  87Andorra -   -  -  -  -  100 v -  -  -  -  -  -  -
-Angola 24 x 22 x 25 x -  -  36 x -  -  -  -  -  -  -  -Antigua and Barbuda
-   -   -    -  -  -  -  -  -  -  -  -  -  -Argentina 7 y 8 y 5 y -  -  99 y
-  -  -  -  -  -  -  -Armenia 4  5  3  0  7  100  -  -  -  20  9  70  72
67Australia -   -   -    -  -  100 v -  -  -  -  -  -  -  -Austria -   -
-    -  -  100 v -  -  -  -  -  -  -  -Azerbaijan 7 y 8 y 5 y 1  12  94  -  -
-  58  49  75  79  71Bahamas -   -   -    -  -  -  -  -  -  -  -  -  -
-Bahrain 5 x 6 x 3 x -  -  -  -  -  -  -  -  -  -  -Bangladesh 13    18
8    29  65  31  -  -  -  -  33 y -  -  -Barbados -   -   -    -  -  -  -  -
-  -  -  -  -  -Belarus 1    1    2    0  3  100 y -  -  -  4  4  65 y 67 y 62
yBelgium -   -   -    -  -  100 v -  -  -  -  -  -  -  -Belize 6    7    5
3  26  95  -  -  -  -  9  71  71  70Benin 46    47    45    8  34  80  13  2 y
1  14  47  -  -  -Bhutan 3  3  3  6  26  100  -  -  -  -  68  -  -  -Bolivia (
Plurinational    State of) 26 y 28 y 24 y 3  22  76 y -  -  -  -  16  -  -
-Bosnia and Herzegovina 5    7    4    0  4  100  -  -  -  6  5  55  60
50Botswana 9 y 11 y 7 y -  -  72  -  -  -  -  -  -  -  -Brazil 9 y 11 y 6 y
11  36  93 y -  -  -  -  -  -  -  -Brunei Darussalam -   -   -    -  -  -  -
-  -  -  -  -  -  -Bulgaria -   -   -    -  -  100 v -  -  -  -  -  -  -
-Burkina Faso 39  42  36  10  52  77  76  13  9  34  44  83  84  82Burundi
26    26    27    3  20  75  -  -  -  44  73  -  -  -Cabo Verde 3 x,y 4 x,y 3
x,y 3  18  91  -  -  -  16 y 17  -  -  -Cambodia 36 y 36 y 36 y 2  18  62  -
-  -  22 y 46 y -  -  -Cameroon 42    43    40    13  38  61  1  1 y 7  39
47  93  93  93Canada -   -   -    -  -  100 v -  -  -  -  -  -  -  -Central
African Republic  29    27    30    29  68  61  24  1  11  80 y 80  92  92
92Chad 26    25    28    29  68  16  44  18 y 38  -  62  84  85  84Chile 3 x 3
x 2 x -  -  100 y -  -  -  -  -  -  -  -China -   -   -    -  -  -  -  -  -
-  -  -  -  -Colombia 13 y 17 y 9 y 6  23  97  -  -  -  -  -  -  -  -Comoros
27 x 26 x 28 x -  -  88 x -  -  -  -  -  -  -  -Congo 25    24    25    7  33
91  -  -  -  -  76  -  -  -TABLE 9    CHILD PROTECTIONCountries  and
areasChild labour (%)+ 2005-2012*Child marriage (%) 2005-2012*Birth
registration (%)+ 2005-2012*totalFemale genital mutilation/cutting (%)+
2002-2012*Justification of  wife beating (%) 2005-2012*Violent discipline (%)+
2005-2012*prevalenceattitudestotalmalefemalemarried by 15married by
18womenagirlsbsupport for the practicecmalefemaletotalmalefemale78      THE
STATE OF THE WORLD'S CHILDREN 2014 IN NUMBERS

如果你查看一下 PDF 文件，很容易发现页面中每一行的格式。我们来看一下 page 的数据类型是什么：

for page in doc[:2]:
    print type(page)          ➊

❶ 将代码中的 print page 修改为 print type(page)。

运行代码，输出如下：

<type 'str'>
<type 'str'>

这样我们知道 slate 中的 page 是一个长字符串。这一点很有用，因为现在我们就可以使用字符串方法（想复习字符串方法，可以回看第 2 章）。

总的来说，读取这个 PDF 文件并不难。由于这个文件中只包含表格，几乎没有任何文本，slate 可以很好地解析。在某些情况下，表格被包围在文本中，你可能需要跳过一些行才能获取你想要的数据。如果你需要跳过一些行的话，可以采用上一章 Excel 例子中的方法，创建一个每行递增的计数器，用来找到数据所在的位置，然后利用 4.3 节“什么是索引”中的方法将我们需要的数据提取出来。

我们的最终目标是从 PDF 文件中提取出数据，数据格式与 Excel 文件的输出格式相同。想要做到这一点，我们需要分割字符串，找出每一行的内容。其背后的思考过程是寻找规律，找到每一个新行开始的标志。这听起来可能很简单，但可能会非常复杂。

在处理大型字符串时，人们通常会使用正则表达式（RegEx）。如果你不熟悉正则表达式和如何编写正则表达式的话，这个方法有点困难。如果你准备挑战一下自己，想学习 Python 中正则表达式的更多内容，可以查看 7.2.6 节。对于我们的目标而言，我们将采用一种更简单的方法来提取数据。

5.2.2　将PDF转换成文本

首先我们希望将 PDF 转换成文本，然后再对文本进行解析。如果文件很大或数量很多，这种方法更好。（使用 slate 库的话，每次运行脚本都要对 PDF 进行解析。如果文件很大或很多的话，这种方法既浪费时间又浪费内存。）

我们要用 pdfminer 将 PDF 转换成文本。首先安装这个库：

pip install pdfminer

安装好 pdfminer，你就可以使用一个叫作 pdf2txt.py 的命令，将 PDF 文件转换成文本。现在我们来试一下。运行下面的命令，将 PDF 转换成同一文件夹下的文本文件，这样所有的数据都在同一文件夹下：

pdf2txt.py -o en-final-table9.txt EN-FINAL\ Table\ 9.pdf

第一个参数（-o en-final-table9.txt）是我们想要创建的文本文件。第二个参数（EN-FINAL\ TABLE\ 9.pdf）是我们的 PDF 文件。确保大小写和文件名中的空格正确。空格前面需要加一个反斜线（\）。这叫转义（escaping）。转义是告诉计算机，空格是输入内容的一部分。

利用 Tab 自动补全

给你介绍一位终端里的新朋友：Tab 键。对于上面命令的第二个参数，你可以先输入 EN，然后按两下 Tab 键。如果只有一种备选的话，计算机会自动补全文件名。如果有多种备选，计算机会发出警告音，并返回一系列备选。在输入又长又奇怪的文件夹或文件名时，这种方法极为有用。

试试这个。切换到 home 目录（在基于 Unix 的系统是 cd ~/，在 Windows 上是 cd %cd%）。现在，假设你想进入 Documents 目录。试着输入 cd D，然后按两下 Tab 键。发生了什么？home 目录里还有哪些文件或文件夹以字母 D 开头？（可能是 Downloads 文件夹？）

现在试一下 cd Doc，然后按两下 Tab 键。你应该会看到自动补全成 Documents 文件夹。

运行完这个命令之后，我们创建了这个 PDF 文件的文本格式文件，叫作 en-final-table9.txt。

我们将新文件读入 Python。创建一个新脚本，与前面的脚本保存在同一文件夹下。脚本名叫作 parse_pdf_text.py，或者你认为合适的其他名字。在脚本中写入下列代码：

pdf_txt = 'en-final-table9.txt'
openfile = open(pdf_txt, 'r')

for line in openfile:
    print line

我们可以逐行读取文本，然后打印出每一行，将表格内容以文本格式呈现。

5.3　利用pdfminer解析PDF

众所周知，处理 PDF 文件十分困难，我们将学习如何解决在代码中遇到的问题，并掌握一些解决问题的基本方法。

我们希望首先采集国家名称，因为国家名称是最终数据集的键。打开文本文件，你会发现国家出现之前有 8 行。第 8 行的内容是 and areas：

5    TABLE 9 CHILD PROTECTION
6
7    Countries
8    and areas
9    Afghanistan
10   Albania
11   Algeria
12   Andorra

浏览一下整个文本文档，你会发现相同的规律。因此，我们要创建一个开关变量，在遇到 and areas 这一行时控制采集过程的开始和结束。

为了完成这个任务，我们需要修改 for 循环，添加一个布尔变量，即 True/False 变量。在遇到 and areas 那一行时将布尔变量设置为 True：

country_line = False                  ➊
for line in openfile:

    if line.startswith('and areas'):  ➋
        country_line = True           ➌

❶ 将 country_line 设置为 False，因为默认行里不包含国家。

❷ 搜索以 and area 开头的行。

❸ 将 country_line 设置为 True。

我们要解决的下一个问题是何时将布尔变量再次设置为 False。花点时间查看文本文件，试着找到其中的规律。你怎么能知道国家列表的结尾在哪里呢？

观察下面的文本片段，你会注意到其中有一个空行：

45    China
46    Colombia
47    Comoros
48    Congo
49
50    total
51    10
52    12

但 Python 怎么识别空行呢？在脚本中添加一行代码，打印出每一行的 Python 表示（查阅 7.2.2 节，可以了解关于字符串格式化的更多内容）：

country_line = False
for line in openfile:
    if country_line:                        ➊
        print '%r' % line                   ➋

    if line.startswith('and areas'):
        country_line = True

❶ 在经历了 for 循环的前一次迭代后，如果 country_line 的值为 True，那么……

❷ ……打印出该行的 Python 表示。

观察输出，你会注意到现在所有行的结尾都多出一些字符：

45    'China \n'
46    'Colombia \n'
47    'Comoros \n'
48    'Congo \n'
49    '\n'
50    'total\n'
51    '10 \n'
52    '12 \n'

\n 是一行结束的标志，或者叫换行符。我们现在用它作为修改 country_line 变量的标志（marker）。如果 country_line 的值为 True，而 line 的值为 \n，我们就应该将 country_line 设置为 False，因为这一行标志着国名的结束：

country_line = False
for line in openfile:

    if country_line:                   ➊
        print line

    if line.startswith('and areas'):
        country_line = True
    elif country_line:
        if line == '\n':               ➋
            country_line = False

❶ 如果 country_line 的值为 True，打印出该行，我们可以查看国家名称。这段代码在前，是因为我们不希望它出现在 and areas 测试的后面。我们只想打印出实际的国名，而不想打印出 and areas 这一行。

❷ 如果 country_line 的值为 True，且 line 的值为换行符，则将 country_line 设置为 False，因为国家列表已经结束了。

现在运行代码，返回的似乎是包含国家的所有行。我们最后会将其转换成国家列表。现在，对于我们想要采集的数据，我们要寻找相应的标志，然后重复上面的做法。我们想要的是童工数据和童婚数据。首先来看童工数据，我们需要总数、男童数和女童数。我们先来看总数。

我们将利用相同的方法找出童工总数。

(1) 创建一个 True/False 的开关变量。

(2) 寻找开始标志，将开关变量设置为真。

(3) 寻找结束标志，将开关变量设置为假。

查看一下文本，你会发现数据的开始标志是 total。看一下你所创建文本文件的第 50 行，这里出现了第一个标志 2：

2你的文本编辑器很可能可以选择显示行编号，甚至可能可以直接“跳转”到某一行。可以用谷歌搜索一下这些功能的使用方法。

45    China
46    Colombia
47    Comoros
48    Congo
49
50    total
51    10
52    12

结束标志还是换行符或 \n，你可以在 71 行看到：

68    6
69    46
70    3
71
72    26   y
73    5

我们将这个逻辑添加到代码中，然后用 print 查看结果：

country_line = total_line = False                ➊
for line in openfile:

    if country_line or total_line:               ➋
        print line

    if line.startswith('and areas'):
        country_line = True
    elif country_line:
        if line == '\n':
            country_line = False

    if line.startswith('total'):                  ➌
        total_line = True
    elif total_line:
        if line == '\n':
            total_line = False

❶ 将 total_line 设置为 False。

❷ 如果 country_line 或 total_line 的值为 True，输出该行的内容，方便我们查看数据。

❸ 找到 total_line 的起始点，将 total_line 设置为 True。本行下面的代码与前面 country_line 的代码逻辑相同。

现在我们的代码有一些冗余。我们在重复一些相同的代码，只是开关变量有所不同。这就引出了如何创建非冗余代码的话题。在 Python 中，我们可以用函数来执行重复操作。也就是说，我们可以将这些操作放到一个函数里，然后调用函数执行操作，而不必每次输入全部代码手动执行这些操作。如果我们想测试 PDF 的每一行，可以使用函数。

　第一次编写函数时，往往不清楚应该将函数放在代码的什么位置。你需要先写好函数的代码，然后再调用函数。这样 Python 就知道函数的功能是什么。

我们将函数命名为 turn_on_off，设置它最多接收 4 个参数。

line 是我们目前所在的行。

status 是一个布尔变量（True 或 False），代表函数的开或关。

start 是我们要寻找的开始标志——它会触发开或 True 状态。

end 是我们要寻找的结束标志——它会触发关或 False 状态。

修改代码，将函数框架添加到 for 循环之前。不要忘记添加函数功能的说明——当日后查看这个函数时，你不会一头雾水。这些说明文字叫作文档字符串（docstring）：

def turn_on_off(line, status, start, end='\n'):                        ➊
    """
        这个函数用于检查该行是否以特定值开始/结束。                        ➋
        如果该行确实以特定值开始/结束，则状态设为开/关（真/假）。

    """
    return status                                                      ➌

country_line = total_line = False
for line in openfile:
    .....

❶ 本行代码是函数的开始，函数最多接收 4 个参数。前三个参数 line、status 和 start 是必需（required）参数，也就是说，由于它们没有默认值，一定要对它们赋值。最后一个参数 end，默认值是换行符，因为这是我们文件的规律。在调用函数时，我们可以传入其他值来替换默认值。

❷ 一定要写函数说明（或文档字符串），这样你才能清楚它的功能。函数说明不必追求完美，只要有就行。以后你可以随时更新。

❸ return 语句是退出函数的正确方法。这个函数返回的是 status，值为 True 或 False。

有默认值的参数要放在最后

在编写函数时，没有默认值的参数一定要放在有默认值的参数前面。这也是上面的例子中 end='\n' 是最后一个参数的原因。我们可以看到，有默认值的参数像是一个键值对（即 value_name=value），= 后面即为默认值（在上面的例子中默认值为\n）。

在函数被调用时，Python 会计算参数的值。如果我们想调用前面关于国家的函数，调用方法是这样的：

turn_on_off(line, country_line, 'and areas')

这里利用了 end 的默认值。如果你想将默认值替换为两个换行符，可以这样调用：

turn_on_off(line, country_line, 'and areas', end='\n\n')

假设我们将 status 的默认值设置为 False。我们要如何修改代码？

这是修改前函数的第一行：

def turn_on_off(line, status, start, end='\n'):

下面给出两种修改方法：

def turn_on_off(line, start, end='\n', status=False):
def turn_on_off(line, start, status=False, end='\n'):

status 参数要放在必需参数之后。在调用新函数时，我们可以使用 end 和 status 的默认值，也可以用其他值替换：

turn_on_off(line, 'and areas')
turn_on_off(line, 'and areas', end='\n\n', status=country_line)

如果你不小心把有默认值的参数放在必需参数之前，Python 会报错：SyntaxError: non-default argument follows default argument。你不必记住这句话，但要注意的是，如果你遇到这个错误，要知道它指的是什么意思。

现在把代码从 for 循环中移到函数中。我们想在新的 turn_on_off 函数中复制前面 country_line 的逻辑：

def turn_on_off(line, status, start, end='\n'):
    """
        这个函数用于检查该行是否以特定值开始/结束。
        如果该行确实以特定值开始/结束，则状态设为开/关（真/假）。
    """

    if line.startswith(start):                  ➊
        status = True
    elif status:
        if line == end:                         ➋
            status = False
    return status                               ➌

❶ 将寻找开始行的标志替换为 start 变量。

❷ 将我们用的结束文字替换为 end 变量。

❸ 基于相同的逻辑，返回 status 变量（end 表示 False，start 表示 True）。

现在我们在 for 循环中调用这个函数，将前面所有代码放在一起之后，脚本是这个样子的：

pdf_txt = 'en-final-table9.txt'
openfile = open(pdf_txt, "r")

def turn_on_off(line, status, start, end='\n'):
    """
        这个函数用于检查该行是否以特定值开始/结束。
        如果该行确实以特定值开始/结束，则状态设为开/关（真/假）。
    """
    if line.startswith(start):
        status = True
    elif status:
        if line == end:
            status = False
    return status

country_line = total_line = False                                                ➊

for line in openfile:
    if country_line or total_line:                                               ➋
        print '%r' % line

    country_line = turn_on_off(line, country_line, 'and areas')                  ➌
    total_line = turn_on_off(line, total_line, 'total')                          ➍

❶ 根据 Python 的语法，一连串 = 符号的意思是，我们将最后一个值赋值给前面每一个变量。本行代码将 False 同时赋值给 country_line 和 total_line。

❷ 我们仍然想要记录在开状态下每一行包含的数据。因此我们使用了 or。Python 中 or 的意思是，如果二者之一为真，执行下面的命令。本行代码的意思是，如果 country_line 和 total_line 有一个值为 True，打印出该行的内容。

❸ 对国家调用函数。将函数返回的状态保存到 country_line 变量中，用于下一次 for 循环。

❹ 对总数调用函数。这一行代码与上一行对国名的用法是相同的。

下面将国家和总数保存成列表。然后将这些列表转换成一个字典，字典的键是国名，字典的值是童工总数。这样我们就可以判断是否需要清洗数据。

创建两个列表的代码如下：

countries = []                                                             ➊
totals = []                                                                ➋
country_line = total_line = False
for line in openfile:

    if country_line:                                                       ➌
        countries.append(line)                                             ➍
    elif total_line:
        totals.append(line)                                                ➎

    country_line = turn_on_off(line, country_line, 'and areas')
    total_line = turn_on_off(line, total_line, 'total')

❶ 创建空的国家列表。

❷ 创建空的总数列表。

❸ 注意我们删除了 if country_line or total_line 语句。下面我们将这条语句分开来写。

❹ 如果该行包含国家，将国家添加到国家列表中。

❺ 这一行采集的是总数，与上一行的采集国家的用法相同。

我们将采用“拉链方法”（zipping）将国家和总数两个数据集合并。zip 函数从每一个列表中取出一个元素，然后将其配对，直到所有的元素全部配对完成。我们可以将合并后的列表传递给 dict 函数，从而将其转换成字典。

在脚本最后添加以下代码：

import pprint                                                    ➊
test_data = dict(zip(countries, totals))                         ➋
pprint.pprint(test_data)                                         ➌

❶ 导入 pprint 库。对于复杂的数据结构，这个库的打印格式可读性更好。

❷ 将国家和总数合并到一起，然后转换成一个字典，将字典保存到一个叫作 test_data 的变量中。

❸ 将 test_data 传递给 pprint.pprint() 函数，以美观的格式打印出数据。

现在运行脚本，你会得到类似这样的一个字典：

{'\n': '49   \n',
 ' \n': '\xe2\x80\x93   \n',
 '   Republic of Korea \n': '70   \n',
 '   Republic of) \n': '\xe2\x80\x93   \n',
 '   State of) \n': '37  \n',
 '   of the Congo \n': '\xe2\x80\x93   \n',
 '   the Grenadines \n': '60  \n',
 'Afghanistan \n': '10   \n',
 'Albania \n': '12   \n',
 'Algeria \n': '5  y \n',
 'Andorra \n': '\xe2\x80\x93    \n',
 'Angola \n': '24  x \n',
 'Antigua and Barbuda \n': '\xe2\x80\x93    \n',
 'Argentina \n': '7  y \n',
 'Armenia \n': '4   \n',
 'Australia \n': '\xe2\x80\x93    \n',
 ......

现在我们需要做一些数据清洗工作。更详细的内容将会在第 7 章中介绍。现在我们需要做的是清洗字符串，因为它们的可读性很差。我们将创建一个函数来清洗每一行的内容。将这个函数放在 for 循环前面，与另一个函数放在一起：

def clean(line):
    """
        清洗代码中的换行符、空格以及其他特殊符号。

    """
    line = line.strip('\n').strip()                             ➊
    line = line.replace('\xe2\x80\x93', '-')                    ➋
    line = line.replace('\xe2\x80\x99', '\'')

    return line                                                 ➌

❶ 删除该行中的 \n，然后重新赋值给 line，现在 line 中保存的是清洗后的数据。

❷ 替换特殊字符编码。

❸ 返回清洗后的新字符串。

在上面的数据清洗中，我们可以把方法调用合并在一起，像这样：

line = line.strip('\n').strip().replace(
    '\xe2\x80\x93', '-').replace('\xe2\x80\x99s', '\'')

然而，想要格式美观，每一行 Python 代码的长度不应超过 80 个字符。这只是一个建议，并不是规定，但控制每行代码的长度可以提高代码的可读性。

下面我们将 clean_line 函数应用到 for 循环中：

for line in openfile:
    if country_line:
        countries.append(clean(line))
    elif total_line:
        totals.append(clean(line))

现在运行脚本，我们得到的输出更加接近我们的目标：

{'Afghanistan': '10',
 'Albania': '12',
 'Algeria': '5  y',
 'Andorra': '-',
 'Angola': '24  x',
 'Antigua and Barbuda': '-',
 'Argentina': '7  y',
 'Armenia': '4',
 'Australia': '-',
 'Austria': '-',
 'Azerbaijan': '7  y',
...

浏览一下输出，你会发现我们的方法没能充分解析所有的数据。我们需要找出问题的原因。

名字超过一行的国家似乎被分成了两条数据记录。从玻利维亚（Bolivia）的数据可以发现这一点：我们有两条记录，一条是 'Bolivia (Plurinational': '',，另一条是 'State of)': '26 y',。

在 PDF 文件里可以查看数据的组织结构。你可以在 PDF 中看到图 5-2 中的这几行。

图 5-2：PDF 文件中的玻利维亚数据

　PDF 文件仿佛兔子洞一般。处理每一个 PDF 文件都要用到特殊的技巧。由于我们对这个 PDF 只需要解析一次，所以我们做了许多人工检查的工作。如果需要定期解析这个 PDF，我们需要仔细查看数据随时间变化的规律，然后用程序来处理这些规律，还要对代码进行检查和测试，确保导入的数据正确。

解决这个问题有两种方法。我们可以创建一个占位符，找到总数里面的空白行，然后将空白行与后面的数据行合并。另一种方法是找出那些国名长度不止一行的国家。由于我们的数据集不是很大，所以我们将尝试第二种方法。

我们将创建一个列表，里面包含每一个跨行国家的第一行内容，然后在脚本中用这个列表来检查每一行。你需要将这个列表放在 for 循环之前。参考元素通常会放在脚本的开头，在必要时方便找到并修改。

将 Bolivia (Plurinational 添加到双行国家组成的列表中：

double_lined_countries = [
    'Bolivia (Plurinational',
]

现在我们需要修改 for 循环，检查上一行内容是否包含在 double_lined_countries 列表中，如果是的话，将上一行与这一行合并。为此我们需要创建一个 previous_line 变量。然后在 for 循环的结尾对 previous_line 变量赋值。只有这样，在代码进行到循环的下一次迭代时，我们才能将两行合并：

countries = []
totals = []
country_line = total_line = False
previous_line = ''                                                             ➊

for line in openfile:
    if country_line:
        countries.append(clean(line))
    elif total_line:
        totals.append(clean(line))

    country_line = turn_on_off(line, country_line, 'and areas')
    total_line = turn_on_off(line, total_line, 'total')

    previous_line = line                                                       ➋

❶ 创建 previous_line 变量，值为空字符串。

❷ 在 for 循环的结尾，将 previous_line 的值修改为当前行的内容。

现在有了 previous_line 变量，我们可以检查 previous_line 是否在 double_lined_countries 列表中，这样我们就知道何时将当前行与上一行合并。另外，我们还要将新合并的一行添加到国家列表中。如果国名的前半部分在 double_lined_countries 列表中的话，一定不要将第一行添加到国名列表中。

根据上面的描述，对代码做如下修改：

if country_line:                                                           ➊
    if previous_line in double_lined_countries:
        line = ' '.join([clean(previous_line), clean(line)])               ➋
        countries.append(line)
    elif line not in double_lined_countries:                               ➌
        countries.append(clean(line))

❶ 我们需要 if country_line 的逻辑，因为它只与国名相关。

❷ 如果 previous_line 在 double_lined_countries 列表中，那么将 previous_line 与当前行合并，并将合并后的内容赋值给 line 变量。你可以看到，join 的作用是利用最前面的字符串将一个字符串列表合并在一起。本行代码使用空格作为连接字符。

❸ 如果该行不在 double_lined_countries 列表中，那么将该行内容添加到国家列表中。这里我们用的是 elif，在 Python 中的意思是 else if。如果你想使用一种不同于 if - else 的逻辑流，这是一个很好用的工具。

重新运行脚本，我们发现 'Bolivia (Plurinational State of)' 已经合在一起了。现在我们需要检查结果中是否包含了所有国家。由于数据集比较小，我们可以手动检查，但如果数据集较大的话，你需要将检查过程自动化。

数据检查自动化

何时手动检查数据，何时用 Python 自动化完成，怎么选择？下面给出几点建议。

如果要定期反复解析数据，选择自动化。

如果你的数据集比较大，你很可能应该选择自动化。

如果你的数据集可控，而且你只需要解析一次，那么你选哪种方式都可以。在我们的例子中，数据集很小，所以我们没有选择自动化。

用 PDF 阅读器查看 PDF 文件，找出所有占两行的国家名称：

Bolivia (Plurinational State of)
Democratic People's Republic of Korea
Democratic Republic of the Congo
Lao People's Democratic Republic
Micronesia (Federated States of)
Saint Vincent and the Grenadines
The former Yugoslav Republic of Macedonia
United Republic of Tanzania
Venezuela (Bolivarian Republic of)

我们知道，这些国名的 Python 表示可能不是这样的，所以我们需要将国家打印出来，看看它们的 Python 表示是什么样子，然后将其添加到列表中：

if country_line:
    print '%r' % line                                                 ➊
    if previous_line in double_lined_countries:

❶ 添加 print '%r' 语句，输出国名的 Python 表示。

运行脚本，将双行国名的 Python 表示添加到 double_lined_countries 列表中：

double_lined_countries = [
    'Bolivia (Plurinational \n',
    'Democratic People\xe2\x80\x99s \n',
    'Democratic Republic \n',
    'Micronesia (Federated \n',
    #... 糟糕！
]

我们的输出中漏掉了 Lao People's Democratic Republic（老挝人民民主共和国），但它在 PDF 中占了两行。我们打开 PDF 的文本文件，看看问题出在哪里。

看完文本文件，你能发现问题所在吗？再看一下 turn_on_off 函数。这个函数的原理与文本的书写方式有什么关系？

问题在于，and areas 之后紧跟着一个空行（\n），这正是我们要寻找的标志。查看我们创建的文本文件，你会发现在 1345 行出现了意料之外的空行：

...
1343 Countries
1344 and areas
1345
1346 Iceland
1347 India
1348 Indonesia
1349 Iran (Islamic Republic of)
...

这说明我们的函数没有正常运行。解决这个问题有好几种方法。对于这个例子来说，我们可以加入更多的代码逻辑，保证开 / 关代码的运行符合预期。开始采集国名时，在结束采集之前应该采集到了至少一个国家。如果一个国家都没有采集到的话，那么我们不应该结束采集过程。我们还可以使用上一行来解决这个问题。我们可以在开 / 关函数中检查上一行代码，确保它不属于某些特殊行。

我们采用增加特殊行的方法，以防遇到其他异常：

def turn_on_off(line, status, start, prev_line, end='\n'):
    """
        该函数用于检查该行会是否开始/结束于特定值。
        如果是，且上一行不是特殊行，
        则状态设为开/关（真/假）。
    """
    if line.startswith(start):
        status = True
    elif status:
        if line == end and prev_line != 'and areas':                            ➊
            status = False
    return status

❶ 如果当前行的值等于 end，而且上一行的值不等于 and areas，那么我们可以结束数据采集。这里我们用的是 !=，这是 Python 用来测试“不相等”的方法。与 == 类似，!= 返回的也是布尔值。

你还需要修改调用函数的代码，将 previous_line 传入函数：

country_line = turn_on_off(line, country_line, 'and areas', previous_line)
total_line = turn_on_off(line, total_line, 'total', previous_line)

回到我们最开始的任务——创建双行国家的列表，确保采集到所有双行国家。我们前面进行到了这一步：

double_lined_countries = [
    'Bolivia (Plurinational \n',
    'Democratic People\xe2\x80\x99s \n',
    'Democratic Republic \n',
]

查看 PDF 文件，我们看到下一个双行国家是 Lao People's Democratic Republic（老挝人民民主共和国）。我们继续将脚本输出的其他双行国家添加到这个列表中：

double_lined_countries = [
    'Bolivia (Plurinational \n',
    'Democratic People\xe2\x80\x99s \n',
    'Democratic Republic \n',
    'Lao People\xe2\x80\x99s Democratic \n',
    'Micronesia (Federated \n',
    'Saint Vincent and \n',
    'The former Yugoslav \n',
    'United Republic \n',
    'Venezuela (Bolivarian \n',
]

如果你的列表看起来和上面的列表相似的话，运行脚本，你的输出应该找出了所有占两行的国名。一定要在脚本结尾添加 print 语句来查看国家列表：

import pprint
pprint.pprint(countries)

前面我们在国家列表上花了不少时间，你能想出解决这个问题的其他方法吗？看一下几个双行国家的第二行：

'   Republic of Korea \n'
'   Republic \n'
'   of the Congo \n'

它们有什么共同点？都以空格开头。用代码检查每一行开头是否有三个空格，这是一种更有效的做法。但是采用前面第一种方法，可以在采集数据的过程中发现数据集的部分缺失。随着你编程水平的不断提高，你将学会解决同一问题的各种方法，然后找出最佳方法。

下面我们看一下童工总数和国家的对应情况。修改 pprint 语句，如下所示：

import pprint
data = dict(zip(countries, totals))                               ➊
pprint.pprint(data)                                               ➋

❶ 调用 zip(countries, totals)，将国家列表和总数列表合并。这样把两个列表变成了元组。然后我们把元组传递给 dict 函数，将其转换成字典。

❷ 打印出我们刚创建的 data 变量。

返回的是一个字典，国家名称是字典的键，童工总数是字典的值。这并不是我们最终的数据格式。我们这样做是为了查看当前的数据。结果应该是像这样的：

{'': '-',
'Afghanistan': '10',
 'Albania': '12',
 'Algeria': '5  y',
 'Andorra': '-',
 'Angola': '24  x',
 ...
}

对比 PDF 再次检查这些数据，你会发现，就在双行国家第一次出现的地方，数据出现了错误。对应的数字来自出生登记（Birth registration）一列：

{
 ...
 'Bolivia (Plurinational State of)': '',
 'Bosnia and Herzegovina': '37',
 'Botswana': '99',
 'Brazil': '99',
 ...
}

如果查看 PDF 的文本文件，你会注意到在双行国家对应的数字那里有一个空行：

6
46
3

26  y
5
9  y

和采集国名遇到的问题一样，我们要用相同的方法来处理这个数据采集问题。如果我们的数据中有空白行，一定不要把空行采集到数据中，这样我们只采集与国名匹配的数据。修改后的代码如下：

for line in openfile:
    if country_line:
        print '%r' % line
        if previous_line in double_lined_countries:
            line = ' '.join([clean(previous_line), clean(line)])
            countries.append(line)
        elif line not in double_lined_countries:
            countries.append(clean(line))

    elif total_line:
        if len(line.replace('\n', '').strip()) > 0:                            ➊
            totals.append(clean(line))

    country_line = turn_on_off(line, country_line,
                                      'and areas', previous_line)
    total_line = turn_on_off(line, total_line,
                                      'total', previous_line)
    previous_line = line

❶ 从经验中我们知道，PDF 文件使用换行符作为空行。本行代码用空字符串替代换行符，并删除空格来清洗数据。然后测试字符串的长度是否仍然大于 0。如果是的话，我们认为里面包含数据，并将其添加到童工总数列表中。

运行修改后的代码，在第一个双行国家那里数据又出现了问题。这次第一个双行国家对应的还是出生登记数据。之后的数值也都是错误的。回来看一下文本文件，找到问题出在哪里。如果你查看 PDF 文件里对应的那列数据，会发现 PDF 文本文件中的规律是从 1251 行开始的：

1250
1251    total
1252    -
1253    5 x
1254    26
1255    -
1266    -

进一步观察发现，出生登记列标题的结尾是 total：

266     Birth
267     registration
268     (%)+
269     2005–2012*
270     total
271     37
272     99

目前搜集童工总数的函数找寻的是 total 这个词，所以在找到下一行国家之前，我们先找到了这一列数据。我们还发现暴力惩戒比例［Violent discipline（%）］列也有一个 total 标签，上面有一个空行。这和我们要采集的 total 具有相同的规律。

接二连三地遇到 bug，说明你的代码逻辑可能存在问题。我们的脚本最开始用的是开 / 关函数，所以想要从根本解决问题，就要重构那里的逻辑。我们想要知道如何找到正确的数据列，或许可以采集列名并排序。我们可能还需要找到一种方法，检查“页码”是否发生了变化。如果我们一直这样头痛医头脚痛医脚，很可能会遇到更多的错误。

　只在脚本上投入你认为必要的时间。如果你想构建一个可持续过程，在很长一段时间内都可以在大型数据集上多次运行，你需要花时间仔细考虑所有步骤。

这就是编程的过程：写代码，调试，写代码，调试。无论是经验多么丰富的程序员，有时都会在代码中遇到错误。在学习编程的过程中，遇到错误会非常沮丧。你可能会想：“为什么无法运行？一定是我不擅长编程。”但事实并非如此。和其他事情一样，编程也需要练习。

现在看来，我们目前的方法显然是行不通的。根据我们目前对文本文件的了解，可以这么说，在利用文本寻找每一部分数据的开始和结束时，我们选用的标志是错误的。我们还可以用这个文件重新开始，换一个角度来思考；但我们想探索解决问题的其他方法，修正错误并获取想要的数据。

5.4　学习解决问题的方法

本节包含好几个练习，你可以试着解析 PDF 脚本，同时挑战自己写 Python 代码的能力。首先，我们先来回顾一下已经写好的代码：

pdf_txt = 'en-final-table9.txt'
openfile = open(pdf_txt, "r")

double_lined_countries = [
    'Bolivia (Plurinational \n',
    'Democratic People\xe2\x80\x99s \n',
    'Democratic Republic \n',
    'Lao People\xe2\x80\x99s Democratic \n',
    'Micronesia (Federated \n',
    'Saint Vincent and \n',
    'The former Yugoslav \n',
    'United Republic \n',
    'Venezuela (Bolivarian \n',
]

def turn_on_off(line, status, prev_line, start, end='\n', count=0):
    """
        该函数用于检查该行会是否开始/结束于特定值。
        如果是，且上一行不是特殊行，
        则状态设为开/关（真/假）。
    """
    if line.startswith(start):
        status = True
    elif status:
        if line == end and prev_line != 'and areas':
            status = False
    return status

def clean(line):
    """
        清洗代码行中的换行符、空格以及特殊符号。

    """
    line = line.strip('\n').strip()
    line = line.replace('\xe2\x80\x93', '-')
    line = line.replace('\xe2\x80\x99', '\'')

    return line

countries = []
totals = []
country_line = total_line = False
previous_line = ''

for line in openfile:
    if country_line:
        if previous_line in double_lined_countries:
            line = ' '.join([clean(previous_line), clean(line)])
            countries.append(line)
        elif line not in double_lined_countries:
            countries.append(clean(line))

    elif total_line:
        if len(line.replace('\n', '').strip()) > 0:
            totals.append(clean(line))

    country_line = turn_on_off(line, country_line,
                               'and areas', previous_line)
    total_line = turn_on_off(line, total_line,
                             'total', previous_line)
    previous_line = line

import pprint
data = dict(zip(countries, totals))
pprint.pprint(data)

有好几种方法可以解决我们面临的问题。在接下来的几节中，我们会讲到其中几种解决方法。

5.4.1　练习：使用表格提取，换用另一个库

前面我们对 PDF 转换成文本遇到的困难头痛不已，下面我们寻找其他方法来实现表格提取，不用 pdfminer。我们找到了 pdftables 库（http://pdftables.readthedocs.org/），这个库已经不再更新了（原作者的最后一次更新时间是两年多以前）。

我们需要安装必要的库（http://pdftables.readthedocs.io/en/latest/#installation），只需运行 pip install pdftables 和 pip install requests 即可完成安装。原作者并没有及时更新所有的文档，所以文档和 README.md 中的某些例子明显是错的。尽管如此，我们还是找到了一个“多合一”（all in one）的函数，可以用来获取我们想要的数据：

from pdftables import get_tables

all_tables = get_tables(open('EN-FINAL Table 9.pdf', 'rb'))

print all_tables

我们创建一个新的代码文件（pdf_table_data.py）并运行。你应该会看到旋风般滚动的数据，看起来就是我们要提取的数据。你会注意到，标题并不是完全正确，但每一行的内容似乎都包含在 all_tables 变量中。我们来仔细观察一下，看看如何提取我们想要的标题、数据列和注释。

你可能也注意到了，all_tables 是一个由列表组成的列表（或者叫矩阵）。它有很多行，每一行里还包含很多行。这种方法可能很适合表格提取，因为表格本质上就是行和列。 get_tables 函数返回的是每一页内容组成的表格，每个表格都包含一个行列表，每个元素又是由许多列组成的列表。

第一步，找到每一列的标题。我们试着查看输出的前几行，看能否找到列标题：

print all_tables[0][:6]

我们看一下第一页的前六行：

... [u'',
  u'',
  u'',
  u'',
  u'',
  u'',
  u'Birth',
  u'Female',
  u'genital mutila',
  u'tion/cutting (%)+',
  u'Jus',
  u'tification of',
  u'',
  u'',
  u'E'],
 [u'',
  u'',
  u'Child labour (%',
  u')+',
  u'Child m',
  u'arriage (%)',
  u'registration',
  u'',
  u'2002\u201320',
  u'12*',
  u'wife',
  u'beating (%)',
  u'',
  u'Violent disciplin',
  u'e (%)+ 9'],
 [u'Countries and areas',
  u'total',
  u'2005\u20132012*male',
  u'female',
  u'2005married by 15',
  u'\u20132012*married by 18',
  u'(%)+ 2005\u20132012*total',
  u'prwomena',
  u'evalencegirlsb',
  u'attitudessupport for thepracticec',
  u'2male',
  u'005\u20132012*female',
  u'total',
  u'2005\u20132012*male',
  u'female'],...

可以看到，标题都包含在前三个列表中，格式混乱。从 print 语句的输出中还可以看出，每行数据还是相当干净的。如果我们对比 PDF 文件手动设置标题（如下所示），可以得到干净的数据集：

headers = ['Country', 'Child Labor 2005-2012 (%) total',
           'Child Labor 2005-2012 (%) male',
           'Child Labor 2005-2012 (%) female',
           'Child Marriage 2005-2012 (%) married by 15',
           'Child Marriage 2005-2012 (%) married by 18',
           'Birth registration 2005-2012 (%)',
           'Female Genital mutilation 2002-2012 (prevalence), women',
           'Female Genital mutilation 2002-2012 (prevalence), girls',
           'Female Genital mutilation 2002-2012 (support)',
           'Justification of wife beating 2005-2012 (%) male',
           'Justification of wife beating 2005-2012 (%) female',
           'Violent discipline 2005-2012 (%) total',
           'Violent discipline 2005-2012 (%) male',
           'Violent discipline 2005-2012 (%) female']  ➊

for table in all_tables:
    for row in table:
        print zip(headers, row)  ➋

❶ 将所有标题添加到一个列表中，其中包括国名。现在我们可以将这个列表与行数据合并，将数据和标题对齐。

❷ 使用 zip 方法将标题与每一行数据合并。

从代码输出中可以看出，有些行我们已经匹配好了，但还有很多行不是国家行（和我们之前的结果类似，之前在表格中发现了多余的空格和空行）。

根据目前所学的内容，我们希望用编程加测试的方法解决这个问题。我们知道有些国家占了不止一行。我们还知道 PDF 文件用破折号（-）表示数据缺失，所以全空的行实际上不是数据行。从上一次 print 输出中我们就知道，每一页的数据从第六行开始。我们还知道，我们关注的最后一行是津巴布韦（Zimbabwe）。将我们已知的内容综合在一起，我们得到：

for table in all_tables:
    for row in table[5:]: ➊
        if row[2] == '':  ➋
            print row

❶ 在每一页中找出我们想要的那些行，即索引数为 5 之后的切片。

❷ 如果数据为空，打印查看该行内容。

运行代码，你会发现列表中包含一些随机分布的空白行，其中也不包含国名。这可能就是我们上一段脚本的问题所在。我们尝试将国名合并在一起，跳过其他空白行。我们还加上对津巴布韦（Zimbabwe）的测试：

first_name = ''

for table in all_tables:
    for row in table[5:]:
        if row[0] == '':  ➊
            continue
        if row[2] == '':
            first_name = row[0]  ➋
            continue
        if row[0].startswith(' '):  ➌
            row[0] = '{} {}'.format(first_name, row[0])
        print zip(headers, row)  ➍
        if row[0] == 'Zimbabwe':
            break  ➎

❶ 如果数据行索引数为 0 的值缺失，说明这一行不包含国名，是一个空行。下一行代码用 continue 跳过这一行，continue 是一个 Python 关键字，作用是转到 for 循环的下一次迭代。

❷ 如果数据行索引数为 2 的值缺失，我们知道这可能是国名的前半部分。本行代码将国名的前半部分保存为变量 first_name。下一行代码跳转到下一行数据。

❸ 如果数据行以空格开头，我们知道这是国名的后半部分。我们希望将国名的两部分重新合并在一起。

❹ 如果我们的假设正确，观察打印出的结果，数据应该是匹配好的。本行代码打印出每次迭代的内容，便于我们观察。

❺ 遇到津巴布韦（Zimbabwe）时，本行代码跳出 for 循环。

大部分数据看起来都是正确的，但我们还会发现一些异常数据。看下面这个例子：

[('Country', u'80      THE STATE OF T'),
('Child Labor 2005-2012 (%) total', u'HE WOR'),
('Child Labor 2005-2012 (%) male', u'LD\u2019S CHILDRE'),
('Child Labor 2005-2012 (%) female', u'N 2014'),
('Child Marriage 2005-2012 (%) married by 15', u'IN NUMBER'),
('Child Marriage 2005-2012 (%) married by 18', u'S'),
('Birth registration 2005-2012 (%)', u''),
.....

可以看到，开头的页码被误以为是国名。你知道哪些国家名称里有数字吗？我们当然不知道！我们添加一个对数字的测试，看是否能剔除坏数据。我们还注意到双行国家的对应并不正确。从输出来看，pdftables 在导入数据时会自动修正行首的空格。太好了！现在我们应该添加一个测试，测试上一行数据有没有 first_name：

from pdftables import get_tables
import pprint

headers = ['Country', 'Child Labor 2005-2012 (%) total',
           'Child Labor 2005-2012 (%) male',
           'Child Labor 2005-2012 (%) female',
           'Child Marriage 2005-2012 (%) married by 15',
           'Child Marriage 2005-2012 (%) married by 18',
           'Birth registration 2005-2012 (%)',
           'Female Genital mutilation 2002-2012 (prevalence), women',
           'Female Genital mutilation 2002-2012 (prevalence), girls',
           'Female Genital mutilation 2002-2012 (support)',
           'Justification of wife beating 2005-2012 (%) male',
           'Justification of wife beating 2005-2012 (%) female',
           'Violent discipline 2005-2012 (%) total',
           'Violent discipline 2005-2012 (%) male',
           'Violent discipline 2005-2012 (%) female']

all_tables = get_tables(open('EN-FINAL Table 9.pdf', 'rb'))

first_name = False
final_data = []

for table in all_tables:
    for row in table[5:]:
        if row[0] == '' or row[0][0].isdigit():
            continue
        elif row[2] == '':
            first_name = row[0]
            continue
        if first_name: ➊
            row[0] = u'{} {}'.format(first_name, row[0])
            first_name = False ➋
        final_data.append(dict(zip(headers, row)))
        if row[0] == 'Zimbabwe':
            break

pprint.pprint(final_data)

❶ 如果这一行有 first_name，那么在该行内将国名合并。

❷ 将 first_name 重新设置为 False，这样下一次迭代可以正常运行。

现在数据导入工作已全部完成。如果你希望数据结构与从 Excel 导入的数据完全相同，需要对数据做进一步处理，但我们已经可以将 PDF 中的数据保存成行数据。

　pdftables 已经不再受到积极的支持，它的开发者现在提供替代的新产品，但却是收费的（https://pdftables.com/）。依赖不受支持的代码是很危险的，我们也不能认为 pdftables 总是可用 3。但是，开源社区的一部分内容就是回馈，所以我们鼓励你找到好项目，为它做贡献，帮它宣传，希望像 pdftables 这样的项目能够保持开源，能够继续成长并发展。

3似乎的确有人在维护并支持一些活跃的 GitHub 分支（https://github.com/drj11/pdftables/network）。我们建议你关注这些仓库的动态，以满足 PDF 表格解析的需求。

下面，我们来看一下解析 PDF 数据的其他方法，其中包括手动清洗数据。

5.4.2　练习：手动清洗数据

我们来聊一聊一个大家闭口不谈却确实存在的事实。阅读本章的过程中，你可能一直想知道，我们为什么不修改 PDF 文本文件，这样处理起来会更方便。你可以这么做，这是解决问题的众多方法之一。但我们希望你能挑战一下，用 Python 工具处理这个文件。你也不是每次都能手动修改 PDF 文件。

如果在处理 PDF 或其他文件类型时遇到了困难，一种按部就班的方法是将数据提取到文本文件，然后手动处理数据。在这种情况下，提前预估一下你愿意在手动处理上花费的时间，然后将实际花费的时间控制在这个范围内。

想了解数据清洗自动化的更多内容，请查阅第 8 章。

5.4.3　练习：试用另一种工具

当最开始寻找用来解析 PDF 的 Python 库时，我们在网络上搜索其他人如何完成这个任务，并找到了 slate，它看起来很好用，但需要一些自定义代码。

想了解还有哪些可用的工具，我们试着搜索“extracting tables from pdf”（从 pdf 中提取表格），而不是搜索“parsing pdf python”（解析 pdf python），这样可以找到针对表格问题的解决方法（其中有一篇博客文章对几种工具做了对比，http://www.interhacktives.com/2014/03/12/extract-data-pdf/）。

对于像我们要解析的这种小型 PDF，我们可以使用 Tabula（http://tabula.technology/）。 Tabula 不一定总能解决问题，但它有一些很好的功能。

Tabula 的使用方法如下。

(1) 下载 Tabula（http://tabula.technology/）。

(2) 双击启动应用，这会在浏览器中打开 Tabula 工具。

(3) 上传童工 PDF 文件。

从这里开始，你需要修改 Tabula 选择抓取的内容。跳过标题行可以让 Tabula 找到每一页的数据并自动高亮，方便后续提取。首先，选择你感兴趣的表格（见图 5-3）。

图 5-3：在 Tabula 中选择表格

接下来，下载数据（见图 5-4）。

图 5-4：Tabula 的下载界面

点击“Download CSV”（下载 CSV 文件），你会得到类似图 5-5 中的数据。

图 5-5：提取的 CSV 数据

得到的数据并不完美，但比我们用 pdfminer 得到的数据更干净。

接下来的挑战是，解析 Tabula 创建的 CSV 文件。这与我们解析过的其他 CSV（见第 3 章）有所不同，要更杂乱一些。如果你遇到困难，可以先放在一边，等读完第 7 章再回来解决它。

5.5　不常见的文件类型

目前为止，本书已经讲过 CSV、JSON、XML、Excel 和 PDF 文件。PDF 中的数据很难解析，你可能认为数据解析的世界不能比这更糟了。遗憾的是，还有比这更糟糕的事情。

好消息是，你可能不会遇到前人尚未解决的问题。记住，向 Python 社区或更高一级的开源社区寻求帮助和建议，这永远都是一个好方法，即使你已经认识到应该寻找更容易解析的数据集。

如果数据具有以下特征，你可能会遇到问题。

文件由旧系统生成，使用的是一种不常见的文件类型。

文件由专用系统（proprietary system）生成。

你所有的程序都无法打开该文件。

对于与不常见文件类型相关的问题，仅仅用你之前学过的知识就可以解决。

(1) 确定文件类型。如果从文件扩展名上不容易看出，那么可以用 python-magic 库（https://pypi.python.org/pypi/python-magic/0.4.6）。

(2) 在互联网上搜索“how to parse <file extension> in Python”（用 Python 如何解析 < 文件扩展名 >），将“<file extension>”替换为实际的文件扩展名。

(3) 如果找不到显而易见的解决方法，尝试用文本编辑器打开该文件，或者用 Python 的 open 函数读取该文件。

(4) 如果字符看起来很奇怪，读一些关于 Python 编码的内容。如果你是第一次接触 Python 字符编码，可以观看 PyCon 2014 的演讲“Python 中的字符编码和 Unicode”（https://www.youtube.com/watch?v=Mx70n1dL534）。

5.6　小结

PDF 以及其他难以解析的格式，是你会遇到的最糟糕的格式。如果你找到这些格式的数据，应该做的第一件事就是看能否找到其他格式的数据。对于我们的例子来说，我们从 CSV 格式得到的数据更为精确，因为 PDF 表格中的数字是经过四舍五入的。越是原始的数据格式，数据可能就越精确，用代码解析也越容易。

如果找不到其他格式的数据，那你应该尝试以下步骤。

(1) 确定数据类型。

(2) 在互联网上搜索其他人解决问题的方法。有没有帮助导入数据的工具？

(3) 凭直觉选择你要用的工具。如果是 Python，选择你认为最合适的库。

(4) 尝试将数据转换成更容易使用的格式。

表 5-1 列出了我们在本章学过的库和工具。

表5-1：新的Python库和工具

库或工具

作用

slate

每次运行脚本时，都将 PDF 解析为内存里的一个字符串

pdfminer

将 PDF 转换为文本，这样你就可以解析文本文件

pdftables

首先用 pdfminer 将 PDF 解析成文本，然后尝试寻找表格，并将每一行内容对齐

Tabula

提供操作界面，可以将 PDF 数据提取成 CSV 格式

除了上面这些新工具，我们还学习了 Python 编程的一些新概念，表 5-2 对这些新概念做了汇总。

表5-2：Python编程新概念

概念

作用

转义字符（http://learnpythonthehardway.org/book/ex10.html）

转义字符告诉计算机，文件路径或文件名中有一个空格或特殊字符，告知方式是在其前面加一个反斜线（\）。一种用法是在空格前加 \ 将其转义

\n

\n 是文件中行尾或新行的标志

elif（https://docs.python.org/2/tutorial/controlflow.html）

在写 if-else 语句的过程中，我们可以添加额外的条件再次测试：if...elif...elif...else

函数（https://docs.python.org/2/tutorial/controlflow.html#defining-functions）

Python 函数用来执行一段代码。将可复用的代码写成函数，我们可以避免多次重复

zip（https://docs.python.org/2.7/library/functions.html#zip）

zip 是 Python 内置函数，将两个可迭代对象转换成由元组构成的列表

元组（https://docs.python.org/2.7/library/functions.html#tuple）

元组和列表类似，但是不可更改（immutable），也就是说，不能修改元组。想修改一个元组，需要将其保存为一个新对象

dict 转换（https://docs.python.org/2.7/library/functions.html#func-dict）

dict 是 Python 内置函数，将输入转换成字典。输入数据应满足键值对的形式，这样函数才能正常运行

下一章我们将讨论数据获取与存储。这样我们就能了解关于获取其他数据格式的更多内容。在第 7 章和第 8 章，我们会讲到数据清洗，这也对我们处理复杂的 PDF 有所帮助。

第 6 章　数据获取与存储

找到要研究的第一个数据集，可能是向回答问题这一目标迈出的最重要一步。在第 1 章里我们说过，你首先应该花点时间将问题细化，让问题足够具体，能够找到关于问题的好数据，同时问题又要足够宽泛，可以让你和其他人都感兴趣。

另一种可能是，你已经找到了感兴趣的数据集，但无法提出令人信服的问题。如果你不了解也不信任数据来源，应该花点时间调查一下。问问你自己：数据是否有效？是否更新过？我能否信任当前以及未来的更新和出版物？

本章我们会讲到，你可以将数据保存在什么地方，以供后续使用。如果你不熟悉数据库的话，我们也会讲到数据库的使用场景和使用方法，并演示如何创建简单数据库来存储数据。如果你已经很熟悉数据库，或者你的数据源就是一个数据库的话，我们会讲到 Python 中基本的数据库连接结构。

如果你还没决定使用哪个数据集的话，不必担心。下面用的几个例子，你都可以在本书仓库（https://github.com/jackiekazil/data-wrangling）中找到。

　我们强烈建议你带着几个问题阅读本书，这样你才能更好地在实践中学习。这些问题可能是你一直想研究的问题，也可能是与本书所探索数据相关的问题。即使你选取的问题很简单，在编写代码中学习也是最好的学习方法。

6.1　并非所有数据生而平等

对于遇到的每一个数据集，尽管我们愿意相信其真实性和数据质量，但并非所有数据都能符合我们的预期。即使是你目前使用的数据集，在深入研究之后也可能是无用且无效的数据源。对于你面临的数据处理问题，在寻求自动化解决方案时，你会发现 Python 工具可以帮你分辨好数据和坏数据，还可以帮你评价数据的可用性。第 7 章和第 8 章会讲到用 Python 做数据清洗和数据探索，第 14 章会讲到自动化，在这些章节里我们都会介绍关于这些工具的更多内容。

刚刚得到新数据时，我们建议做一个数据气味测试，测试该数据是否是可靠的信息源，并决定是否信任该数据。你可以问问自己以下几个问题。

如果我有问题或疑虑的话，能够联系上作者本人吗？

数据是否定期检查错误并更新？

数据里是否包含数据获取方法的信息，是否包含数据获取过程中使用的样本类型？

有没有其他数据源可以验证这个数据集？

根据我对这个话题了解的所有知识，数据看起来是否可信？

如果你对至少三个问题的回答都是“是”，这说明你走对路了！如果至少对两个问题的回答是“否”，你可能需要花更多时间寻找可靠的数据。

　你可能需要联系最初采集数据并发布的作者或机构，以寻求更多信息。通常情况下，给合适的人打电话或发电子邮件，可以帮你回答上面至少一个问题，并验证数据源的可靠性。

6.2　真实性核查

为保证报告的可信，对你的数据做真实性核查是非常重要的，尽管有时可能既烦人又累人。根据数据集的不同情况，真实性核查可能包括以下内容。

联系数据源，核实最新的方法和版本。

找到其他好的数据源作对照。

联系专家，探讨好的数据源和真实信息。

进一步研究你选定的主题，检查你的数据源和 / 或数据集是否可信。

有些图书馆和大学可以访问只对订阅用户开放的出版物和教育档案，它们是真实性核查的重要资源。如果可以访问类似 LexisNexis（http://lexisnexis.com/）、国会季刊新闻库（http://library.cqpress.com/）、JSTOR（http://jstor.org/）、康奈尔大学的 arXiv 项目（http://arxiv.org/）和谷歌学术搜索（http://scholar.google.com/）这样的工具，你可以看一下其他人对你的主题已经做了哪些研究及研究成果。

谷歌搜索也可以在真实性核查方面提供帮助。如果某人宣称数据来自于公开发布的来源，那么有可能其他人已经对这一说法做过真实性核查，或者已经证实了这一说法。同样，在评价网上发布的内容时，你需要自己仔细斟酌。数据源是否真实？论证能否令人信服，是否有意义？证据是否有效？对这些问题的回答要做综合评价。

　政府部门拥有大量的数据集。如果想研究本地城市、州或国家的某一现象，通常你可以通过电话或电子邮件找到某个人，他手头有很有用的数据集。全球人口普查局会定期发布普查数据，如果你纠结于想要回答什么问题，可以从这些数据开始。

对第一个数据集做过验证和真实性核查之后，以后编写脚本验证数据有效性就会容易很多。你甚至可以用在本书中学到的技巧（特别是第 14 章的内容）创建脚本来自动更新数据。

6.3　数据可读性、数据清洁度和数据寿命

如果你的数据集看起来非常难以读取，还有一种可能的方法：根据第 7 章学习的内容，你可以用代码清洗数据。幸运的是，如果是计算机创建的数据，很有可能可以被计算机读取。更大的难点在于，从“真实生活”中获取数据并读入计算机。在第 5 章中我们知道， PDF 和不常见的数据文件类型很难处理，但并非不可能。

我们可以用 Python 帮我们读取难以读取的数据，但难以读取可能意味着数据来源不佳。如果是计算机生成的大型数据集，那就存在一个问题——数据库转储（database dump）的格式一直都不美观。但如果你的数据是人工生成的，而且难以读取，那可能是数据清洁度和数据有效性的问题。

你面临的另一个问题是，数据是否已经被清洗过了。通过详细询问数据是如何采集、报告并更新的，你可以判断数据是否被清洗过。你应该能够确定以下内容。

数据的清洁度有多高？

是否有人给出了统计误差率，或者修改了错误的数据条目，或者误报了数据？

是否会发布进一步更新，这些更新是否会发送给你？

数据采集过程中使用了哪些方法，如何验证这些方法？

　如果你的数据源使用的是标准化的、严谨的研究和采集方法，在未来的几年里，你的清洗脚本和报告脚本可能几乎不用修改就可以重复使用。那些系统通常不会定期发生变化（变化既费钱又费时）。一旦写好了清洗脚本，你可以轻松处理下一年的数据，直接进入数据分析阶段。

除了清洁度和可读性，你还要关注数据的寿命。你要处理的数据是定期采集并更新的吗？数据发布和更新的时间计划是什么样的？了解一个机构更新数据的频率，可以让你判断在未来几年内对该数据的应用能力。

6.4　寻找数据

就像验证数据源或编写 PDF 解析器脚本有不止一种方法一样，寻找数据也有很多种方法。本节会讲到你在线上和线下都可以使用的方法。

6.4.1　打电话

观察数据文件，并思考一个问题：数据是怎么变成现在这种格式的？通常来说，像 Excel、 PDF 甚至 Word 这样的文件类型都是人工处理的，这个人从数据源处获取数据。

如果你找到了采集数据的那个人，或许可以要到原始数据。原始数据可能是易于解析的文件格式，如 CSV 或数据库。你找到的那个人还可以回答关于采集方法和更新时间线的问题。

下面给出从数据文件中找人的一些技巧。

在文件中搜索联系人信息。

寻找署名——如果没有人名，那就寻找机构名。

在网络上搜索文档的文件名和标题。

右键单击文件，在 Windows 上选择“属性”（在 Mac 上选择“显示简介”），查看文件元数据。

去联系你能找到的每一个人。如果他不是创建文件的人，可以问他知不知道是谁创建的文件。不要害羞——你对他们的研究课题和工作感兴趣，就是对他们的恭维，他们会很乐意帮助你的。

与通信官打交道

如果你遇到这种情况——发布文件的机构希望你能和他们的通信代表谈一谈——这意味着时间可能会拖得很长。还记得一个叫作打电话的游戏吗：第一个人跟另一个人说了些什么，另一个人将所听到的内容复述给下一个人，如此这般，最后一个人的话已经与第一个人大相径庭？

要保证有效沟通，你可以做这两件事情。第一，努力建立信任。如果没有利益冲突，你可以分享你感兴趣的工作，并承诺会将该机构列为数据源。这表示你会间接宣传他们的工作，该机构也会在分享资料方面受到好评。第二，请求通信代表召开电话会议或有监督的讨论。通过电话而不是电子邮件沟通，你可以及时准确地得到问题的回答。

找到了要联系的人之后，尝试用电话联系他，或者亲自拜访。电子邮件很容易引起误会，通常时间也会拖得比较长。下面给出几个问题的例子，可以帮你思考要问什么样的问题。

你是如何获取第 6 页到第 200 页的数据的？

是否有其他格式的数据，比如 JSON、CSV、XML 或数据库？

数据是如何采集的？

能否描述一下数据采集的方法？

这些缩写是什么意思？

数据是否会更新？如何更新？何时更新？

是否有其他人能提供更多信息？

在等待回答的同时，你也可以开始做数据探索，这取决于项目的时间限制和目标。

6.4.2　美国政府数据

如果你对研究美国的现象感兴趣，奥巴马政府最近正在推行发布易于获取的在线数据，可以很容易找到政府机构的定期报告。快速浏览一下 Data.gov 网站，你就会发现暴雨数据（http://catalog.data.gov/dataset/ncdc-storm-events-database）、毕业率和辍学率（http://catalog.data.gov/dataset/edfacts-graduates-and-dropouts-201112）、濒危物种数据（http://catalog.data.gov/dataset/density-of-threatened-and-endangered-species）、犯罪统计（https://catalog.data.gov/dataset/total-crime-index-for-the-nations-largest-cities-3a1aa）以及其他有趣的数据集。

除了联邦数据，州政府和地方政府也都有发布数据的网站，下面我们列出了其中几个：

教育数据（http://datainventory.ed.gov/InventoryList）

选举结果（http://www.fec.gov/pubrec/electionresults.shtml）

人口普查数据（http://census.ire.org/）

环境数据（https://www.epa.gov/enviro/about-data）

劳工统计数据（http://www.bls.gov/）

如果在公开发布的信息里找不到你想要的，不要犹豫，直接给相应部门打电话，在电话里请求提供数据。许多政府部门都有实习生或工作人员负责处理公众获取信息的请求。

信息自由法案介绍

在美国，你可以向任意地方、州或联邦的政府机关提交信息自由法案（FOIA）申请。申请应简单明了。根据你请求的信息内容以及描述的详细程度，需要用的时间可能会有很大不同。

美国政府建立了 FOIA 网站（https://foiaonline.regulations.gov/foia/action/public/home），你可以在上面向特定部门提交申请并追踪，但大部分政府机构都有在自己的网站上如何提交 FOIA 申请的说明。你应该在申请中给出联系信息，描述你要找的数据记录，以及如果有复印费的话你愿意交多少钱。

好的做法是，尽可能详细描述你要找的数据记录，但又不过分限制搜索的范围。你可以这么想，搜索范围太宽泛，网站会返回数百万条记录（你需要手动挑选，可能还要付费）。另一方面，如果搜索过于具体，你可能会漏掉与话题密切相关的记录。当然了，根据你第一次申请找到的信息，你总可以提交更多的 FOIA 申请。搜索过程也很有意思，不是么？

如果你想要的是美国之外政府和机构的信息，维基百科给出了世界各国信息自由法律的列表（https://en.wikipedia.org/wiki/Freedom_of_information_laws_by_country）。想了解关于美国 FOIA 的更多内容，可参阅电子前沿基金会（Electronic Frontier Foundation）的建议（https://www.eff.org/issues/transparency/foia-how-to）。

6.4.3　全球政府和城市开放数据

获取政府数据有很多种方法，这取决于你想研究的国家，以及你是否生活在那个国家。由于我们更熟悉美国的政策，所以我们不会宣称这是一份全面的清单。如果你想要分享本书没有提到而又很有用的开放数据，请随时和我们联系！

　我们建议对政府数据集也要做真实性核查，特别是对有侵犯人权历史的政府更要仔细核查。将所有的判断力用到所有数据上，果断给联系人打电话或发邮件，进一步咨询数据的采集方法。

欧盟和英国

如果你对欧盟或英国的数据感兴趣，可以找到许多数据门户网站。下面一些网站是由一些机构和开放数据爱好者创建的，如果你想寻找特定的数据集，可以和网站所有者直接联系。

欧盟开放数据(http://publicdata.eu/）

欧罗巴开放数据(http://open-data.europa.eu/）

全天开放关联数据(http://latc-project.eu/）

英国政府数据(https://data.gov.uk/）

非洲

如果你对非洲国家的数据感兴趣，有许多项目正在采集数据并构建 API，供开发人员使用。许多非洲国家也有自己的开放数据门户网站(<用谷歌一搜就可以找到>）。我们挑出了一些有用的区域性项目：

非洲开放数据(https://africaopendata.org/）

南非代码(http://code4sa.org/）

非洲代码(https://codeforafrica.org/）

非洲的开放数据(http://opendataforafrica.org/）

亚洲

如果你对亚洲国家和地区的数据感兴趣，它们大多数都有自己的开放数据网站。我们找出了几个令人印象深刻的数据集，以及一些机构发布的区域性数据：

开放城市项目(http://www.opencitiesproject.org/）

开放尼泊尔(http://data.opennepal.net/）

中国国家统计局(http://www.stats.gov.cn/english/）

香港开放数据(https://opendatahk.com/）

印尼政府开放数据(http://data.go.id/）

欧盟以外的欧洲、中亚、印度、中东和俄罗斯

在欧盟之外，许多中亚、中欧和中东的国家也有自己的政府开放数据网站。我们给出了其中一些网站，但如果你知道你想研究的国家和地区并希望用母语来访问相关数据，语言技能是最重要的(<谷歌 Chrome 浏览器会尝试自动翻译网页，所以即使语言不通也可以找到有用的数据>）。

俄罗斯政府数据网站(http://data.gov.ru/）

PakReport——巴基斯坦开放数据和地图(http://pakreport.org/）

印度开放数据(https://data.gov.in/）

土耳其开放统计数据(http://www.turkstat.gov.tr/Start.do）

南美和加拿大

许多南美国家都有自己的开放数据网站，通过搜索很容易找到。加拿大也有针对统计数据的开放数据门户网站。我们给出了其中一些网站，同时建议你去网上搜索，寻找你感兴趣的特定部门或政府。

加拿大统计数据(http://www.rdc-cdr.ca/datasets-and-surveys）

加拿大开放数据(http://open.canada.ca/en）

巴西开放数据(http://dados.gov.br/）

墨西哥开放数据(http://datos.gob.mx/）

拉丁美洲开放数据(http://www.opendatalatinoamerica.org/）

发展中的加勒比地区(https://www.developingcaribbean.org/#/）

6.4.4　组织数据和非政府组织数据

无论是地方组织还是国际组织，都有大量跨州或跨国的数据集资源，比如气候变化数据、国际商贸数据和全球运输数据。如果政府并没有采集与你的主题相关的数据（关于宗教细节、吸毒、社区支持网络等的数据），或者政府数据不可靠，或者政府没有开放数据门户网站的话，你可以通过 NGO 或开放数据组织找到相关数据。下面列出了一些组织，但还有更多的组织在为数据的公开交换和访问而奋斗。

联合国开放数据(http://data.un.org/）

联合国发展计划署数据(http://open.undp.org/）

开放知识基金会(https://okfn.org/）

世界银行数据(http://data.worldbank.org/）

维基解密(https://wikileaks.org/）

国际援助透明度数据集(http://www.iatiregistry.org/）

DataHub(https://datahub.io/）

人口资料局(http://www.prb.org/DataFinder.aspx）

6.4.5　教育数据和大学数据

世界各地的大学和研究生部都在不断地研究并发布数据集，从生物科学的进展到本土文化与周边生态栖息地的关联性，涵盖范围很广。很难想象教育领域还没有讨论过某一主题，所以大学是获取最新专题数据的好去处。大多数研究者都乐于听到有人对他们的课题感兴趣，所以我们建议你直接联系合适的部门或作者，以获取更多信息。如果你不知道从哪里开始，下面有几个不错的选择。

Lexis Nexis(http://www.lexisnexis.com/）

谷歌学术搜索(https://scholar.google.com/）

康奈尔大学 arXiv 项目(http://arxiv.org/）

UCI 机器学习数据集(http://archive.ics.uci.edu/ml/）

通用数据集倡议(http://www.commondataset.org/）

6.4.6　医学数据和科学数据

与大学类似，科学和医学研究部门和组织也都拥有大量优质的数据资源。在科学研究中搜索是十分困难的，但不要气馁——如果你能找到用于研究的数据集，它们使用的研究术语往往并不相同。如果你想到某一个特定的研究者，我们建议直接联系他。下面列出了一些汇总的数据集：

开放科学数据云(https://www.opensciencedatacloud.org/publicdata/）

开放科学目录(http://www.opensciencedirectory.net/）

世界卫生组织数据(http://www.who.int/gho/database/en/）

Broad 研究所开放数据(http://www.broadinstitute.org/scientific-community/data）

人类连接组项目(<神经通路映射>）(http://www.humanconnectomeproject.org/）

UNC 精神病基因组协会(http://www.med.unc.edu/pgc/）

社会科学数据集(http://3stages.org/idata/）

CDC 医学数据(http://www.cdc.gov/nchs/fastats/）

6.4.7　众包数据和API

如果你的想法或问题更适合众包，则可以利用互联网及大量的论坛、服务和社交媒体来创建自己的问题，并用数据挖掘方法找到这些问题的答案。像 Twitter 和 Instagram 这样的服务拥有数亿用户，上面还有好用的应用编程接口（API）。API 是一些协议或工具，允许用软件或代码与另一个系统交互。在我们的例子中，我们使用的一般是基于网络的 API，可以发送网络请求并从服务中获取数据。一般来说，不到一个小时的设置，API 访问就可以获取数百万条数据记录。

在第 13 章我们会更深入地介绍 API，现在，我们在表 6-1 中对比了使用 API 的一些基本优点和缺点。

表6-1：使用API

优点

缺点

即时访问可用的数据

大量 API 系统不可靠（选择性偏差）

数据量很大

数据过载

你不必担心存储问题，你可从服务的存储中访问数据

可靠性问题，依赖于 API 访问限制或停机时间

可以看到，API 的优点和缺点各占一半。如果找到一个你想用的 API，你可以制定一些规则，规定如何使用 API，以及 API 无法访问时应该怎么做（你可能希望把响应内容保存在本地，避免遇到停机问题）。长时间对响应内容进行采集，也可以消除研究中的一些选择性偏差。

除了社交网络服务之外，还有许多网站可以发布你的问题和想法，以寻求众包回答。选择与话题相关的专家论坛，还是自己发布调查并利用自己的频道传播，这由你自己决定，但如果用的是你自己的研究问题和方法，你一定要对样本大小和样本误差作出解释。想要做附带详细引文的抽样调查，更详细的介绍内容可优先参考威斯康星大学的调查指南（http://oqi.wisc.edu/resourcelibrary/uploads/resources/Survey_Guide.pdf）。

想了解其他方面的众包数据，可查看：

盖洛普民意调查(http://www.gallup.com/）

欧洲社会调查(http://www.europeansocialsurvey.org/data）

路透社民意调查(http://polling.reuters.com/）

可用的数据量是巨大的，在大量的噪声数据中，找出你能回答的问题并搞清楚应该如何回答这些问题，可不是一件容易的事情。下面我们讲几个案例研究，让你更好地了解如何寻找数据来回答自己的问题。

6.5　案例研究：数据调查实例

我们将简单介绍几个不同的兴趣领域和问题，这样你可以知道第一步该做些什么。

6.5.1　埃博拉病毒危机

比方说，你对调查西非的埃博拉病毒危机感兴趣。你会怎么开始调查？你可能很快会想到用谷歌搜索“Ebola crisis data”（埃博拉病毒危机数据）。你发现有许多国际组织致力于追踪病毒的传播，这些组织提供了许多工具，任你使用。首先，你会找到 WHO 的情况报告（http://apps.who.int/ebola/ebola-situation-reports）。WHO 网站上有关于最新病例和死亡的信息，还有交互式地图显示受影响的地区，以及应对措施的关键绩效指标，这些内容似乎都是每周更新。数据有 CSV 和 JSON 两种格式，是真实可靠、定期更新的信息来源。

你要不断挖掘寻找其他可用的资源，而不是在出现的第一个结果这里就止步不前。经过进一步搜索，我们找到 GitHub 用户 cmrivers 的仓库（https://github.com/cmrivers/ebola），里面是来自许多政府和媒体数据源的原始数据汇总。由于我们知道该用户，可以通过联系方式联系到他们，所以我们还可以核实数据最近一次的更新时间，并咨询任何与数据采集方法有关的问题。我们学过如何处理这些数据格式（CSV、PDF 文件），所以处理起来应该不成问题。

进一步深入挖掘，你可能会专注于一个具体的问题，比如：“在安全下葬方面采取了哪些预防措施？”你找到一份由 Sam Libby（https://data.humdata.org/user/libbys）维护的报告，报告内容是关于安全、庄严的葬礼的（https://data.humdata.org/dataset/safe-and-dignified-burial-teams）。太棒了！遇到任何问题你都可以直接联系 Sam。

你已经找到了一系列很好的初始数据源，并确认它们来自你信任的组织，同时还找到了联系人，在研究过程中可以向他寻求更多信息。下面我们来看另一个例子。

6.5.2　列车安全

再比方说，你对美国的列车安全感兴趣。你的问题可能是：有哪些影响列车安全的不利因素？首先，你可能看过之前关于列车安全的研究。你找到了联邦铁路管理局（FRA），其核心职责就是确保铁路安全可用。在 FRA 网站上（https://www.fra.dot.gov/）阅读了一些报告和情况简报后，你发现大部分报告都显示，列车事故发生的原因是轨道养护不佳或人为失误。

你对人为失误更感兴趣，所以决定深入挖掘这一点。你发现 FRA 在铁路员工和安全方面发布了大量的报告。你找到一份关于铁路工人睡眠类型的报告（http://catalog.data.gov/dataset/work-schedules-and-sleep-patterns-of-railroad-employees-train-and-engine-service），可以部分解释人为失误发生的原因。你还找到联邦法规关于对铁路员工做药物和酒精测试的一些资料（http://www.fra.dot.gov/eLib/details/L02699）。

现在你可能有更多的问题，你可以将范围缩小，详细阐述你真正想了解的问题。现在你的问题可能会变成“喝酒导致的铁路事故发生频率是多少”或者“火车工程师加班或劳累过度的频率是多少”。你已经有了初步的可信数据集，还可以在研究过程中致电 FRA 了解更多信息。

6.5.3　足球运动员的薪水

再比如说，你对足球（用脚踢的足球，不是猪皮做的橄榄球）运动员的薪水感兴趣。这些运动员能赚多少钱，每个运动员对球队的影响有多大？

初次搜索之后，你发现数据太杂，决定专注研究某一个联赛。比方说你选择英超联赛。你在一个可能从没听说过的网站上找到了英超俱乐部的薪水列表（http://www.tsmplug.com/football/premier-league-player-salaries-club-by-club/）。看来作者已经编辑好了每一支球队的列表，以及每一名球员的薪水列表（http://www.tsmplug.com/football/man-city-players-salaries-2014/）。为了更好地理解数据的来源，并保证数据源可信，你应该联系页面中给出的作者，以获取更多信息。

如果你同时搜索球员代言，可能会找到这个统计表（http://www.statista.com/statistics/266636/best-paid-soccer-players-in-the-2009-2010-season/），里面列出了顶薪足球运动员的代言费用和薪水数据。你可能也想去联系作者，询问是否有最新的代言费用数据，可以和最新的赛季作对比。

现在你已经有了薪水数据，你还想了解一些统计数据，看看顶薪运动员究竟有多优秀。你在英超联赛网站（http://www.premierleague.com/content/premierleague/en-gb/players/index.html）上找到一些球员统计数据。这可能是你只能用网络抓取来获取的数据（第 11 章会有更多关于网络抓取的内容），但你知道数据来源是可靠的。继续搜索球员统计数据，你可能会在 top assists 网站（http://www.espnfc.com/barclays-premier-league/23/statistics/assists）上找到更多数据。你还可以分析点球统计数据（http://eplreview.com/statistics-penalty.htm）。同样，你应该调查任何数据源的有效性，这一点并不容易验证。

现在你可以开始数据分析，计算每一名球员进球、红牌和点球的价值！

6.5.4　童工

最后，我们来探索一个本书后续章节将要回答的问题。我们将专注研究国际童工危机。当思考国际话题时，我们立刻想到要寻找国际组织。

我们发现 UNICEF 的开放数据网站致力于发布童工报告（http://data.unicef.org/child-protection/child-labour.html）。事实上，UNICEF 拥有全球妇女儿童健康状况的全部数据集（http://mics.unicef.org/）。这些数据集可能对回答类似“早婚对童工率是否有影响？”这样的问题很有帮助。

在寻找政府数据时，我们找到了美国劳工部关于全球童工的年度报告（https://www.dol.gov/agencies/ilab/resources/reports/child-labor）。这些报告可以用来与 UNICEF 的数据集相互对照。

另外，我们还找到国际劳工组织（ILO）关于童工的趋势报告（http://www.ilo.org/ipec/Informationresources/WCMS_IPEC_PUB_23015/lang--en/index.htm）。ILO 报告似乎给出了许多不同数据集的链接，应该是童工历史数据的很好参考。

我们还汇总了下面几章会用到的几个数据集。我们将这些数据集都放在数据仓库中（https://github.com/jackiekazil/data-wrangling），以便后续使用。

前面已经探讨了如何发现问题并搜索资源，下面我们来看一下数据存储。

6.6　数据存储

找到数据之后，你需要把数据保存下来！有些时候，你得到的数据是干净的、易于访问的、机器可读的格式。其他时候，你可能想用另一种方法来保存数据。当你第一次从 CSV 或 PDF 中提取数据的时候，我们会讲到几种数据存储工具，或者，你可以等数据完全处理并清洗完成后再进行存储（我们会在第 7 章讲到数据清洗的内容）。

我应该把数据保存在哪里？

最开始的问题是，要将数据保存到其他地方，还是留在最开始提取的文件中。这有一系列问题可以帮你回答这个问题。

你能否用简单的文档阅读器（例如 Microsoft Word）打开数据集，同时不会造成计算机死机？

数据看起来是否具有良好的标签和结构，让你可以方便提取出每一段信息？

如果需要不止一台电脑来处理数据的话，数据的保存和移动是否方便？

能否利用 API 实时访问数据，这样你就能在线获取需要的数据？

如果所有问题的回答都是“是”，你可能不必担心保存数据的问题。如果你的回答有“是”有“否”的话，可能需要将数据保存在数据库或平面文件（flat file）中。如果所有问题的回答都是“否”，继续读下去，我的朋友，我们为你提供了解决方法！

假设你的数据集各不相同——这里的一个文件，那里的一份报告。其中一些很容易下载和访问，但其他的你可能需要从网络上复制或抓取。第 7 章和第 9 章中会讲到如何清洗与合并数据集，但现在我们来谈一谈如何将数据保存在共享位置。

　如果你要用的数据集来自多台电脑，建议你把它们都保存在网络或互联网中（你好，云计算！），或者保存在移动硬盘或 U 盘中。当你和团队合作时，团队成员可能会从不同地点或不同电脑访问数据，一定要记住这一点。如果你在一台计算机上工作，一定要有数据备份策略。电脑丢失最糟糕的一点就是，你花几个月时间获取并清洗的数据也丢失了。

6.7　数据库简介

数据库——初学生爱，爱极生恨。作为开发人员，你可能会在学习和工作中用到各种类型的数据库。本节不会对数据库做全面介绍，但我们希望对数据库基本概念做一个简要介绍。如果你已经熟练掌握数据库的使用，可以大致浏览一下本节，继续阅读关于其他存储方案以及何时使用数据库的内容。

你用 Siri 查过手机里的电话号码么？你用过谷歌搜索么？你有没有点击过 Twitter 或 Instagram 里的标签？这些操作都涉及对数据库（或一系列数据库，或数据库缓存）的简单查询和响应。你有一个问题［在 YouTube 上新出了哪些关于猫（Maru）的有趣视频？］，你向一个特定的数据库（YouTube 搜索）提问，得到有趣的响应——可以观赏的搜索结果列表。

在接下来的几节里，我们将简要讲述两种主要的数据库类型，强调了各自的利弊，并对比了二者的优点和缺点。对于数据处理来说，你绝对不需要使用数据库，然而，随着数据处理和分析变得更加复杂，数据库知识及其使用将变得更加重要，可以提高你存储和分析数据的能力。

如果你对数据库感兴趣，我们会讲到用 Python 处理数据库的几个技巧，但显然我们没有足够的时间全面讲述这个话题。我们强烈推荐你根据自己的兴趣去搜索更多的资料、视频和教程。

6.7.1　关系型数据库：MySQL和PostgreSQL

对于来源很多、同时还有各种层次关联性的数据，关系型数据库是很好用的。关系型数据库正如其名：如果你的数据连接类似于家谱，那么关系型数据库可能会适合你，比如 MySQL。

关系型数据库通常使用一系列唯一标识符来匹配数据集。在 SQL 里我们一般把这些标识符叫作 ID。这些 ID 可以被其他数据集所用，用来查询和匹配数据连接。在这些连接好的数据库中，我们可以进行 join 操作，在许多不同的数据库中同时访问连接的数据。我们来看一个例子。

我有一个特别厉害的朋友，叫 Meghan。她有一头黑发，在《纽约时报》工作。在工作之余，她喜欢跳舞、烹饪和教人如何编程。如果我有一个关于朋友的数据库，使用 SQL 代表他们的属性，我可能会这样分表：

**friend_table: ➊
friend_id ➋
friend_name
friend_date_of_birth
friend_current_location
friend_birthplace
friend_occupation_id

**friend_occupation_table:
friend_occupation_id
friend_occupation_name
friend_occupation_location

**friends_and_hobbies_table:
friend_id
hobby_id
**hobby_details_table:
hobby_id
hobby_name
hobby_level_of_awesome

❶ 在我的朋友数据库中，每一部分（以 ** 表示）都是一个表。在关系型数据库中，表通常用来保存特定主题或特定对象的信息。

❷ 表中包含的每一条信息叫作字段。在这个例子中，friend_id 字段包含 friend_table 中每一位朋友的唯一 ID。

我可以向数据库提问：Meghan 的爱好是什么？想要获取这个信息，我需要对数据库说：“嘿，我要查询我的朋友 Meghan。她住在纽约，这是她的生日，你能告诉我她的 ID 吗？”对于这条查询，SQL 数据库返回的是她的 friend_id。然后我可以向 friend_and_hobbies_table 提问（这个表正确匹配了朋友 ID 和爱好 ID），与这个朋友 ID 匹配的爱好是什么，它会返回由三个新的爱好 ID 组成的列表。

由于这些 ID 都是数字，我想进一步了解它们的含义。我向 hobby_details_table 提问：“你能告诉我关于这些爱好 ID 的更多内容吗？”它回答：“当然可以！一个是跳舞，一个是烹饪，一个是教人如何编程。”啊哈！只利用最开始的朋友描述，我就解开了这个谜题。

创建数据库并向其中导入数据可能涉及许多步骤，但如果你的数据库很复杂，有许多不同的关系，那么搞清楚如何连接这些数据并找到你想要的信息，步骤不应该很复杂。在构建关系型数据库时，花点时间研究关系和属性之间的映射，类似我们在朋友数据库所做的那样。都有哪些不同的数据类型，它们之间是如何映射的？

在关系型数据库架构中，通过思考数据的使用频率，我们知道要如何匹配数据。你希望向数据库请求的查询易于回答。由于我们可能会用职业来寻找对应的朋友，所以我们将 occupation_id 放在 friend_table 表中。

还需要注意的是，关系有许多不同的类型。例如，我有许多朋友的爱好都是烹饪。我们把这种情况称为多对多关系。如果我们再添加一个叫作 pets 的表，就会新增加一种关系类型——多对一关系。这是因为有些朋友养了不止一只宠物，但每只宠物只能有一个主人。我可以使用 friend_id 查询每一位朋友的所有宠物。

如果你有兴趣深入学习 SQL 和关系型数据库的内容，我们建议在 SQL 上多花点时间。入门 SQL 可以在“Learn SQL The Hard Way”（http://sql.learncodethehardway.org/）和“SQLZOO”（http://sqlzoo.net/）这两个网站上学习。PostgreSQL 和 MySQL 在语法上有一些细微的差别，但它们的基础知识相同，你可以自己选择学习哪一个。

MySQL和Python

如果你熟悉 MySQL（或正在学习 MySQL），想要使用 MySQL 数据库，那么用 Python 连接 MySQL 是很容易的。你需要做的只有两步。第一步，你必须安装 MySQL 驱动程序。第二步，你应该用 Python 发送验证信息（用户名、密码、主机名、数据库名称）。这两步在 Stack Overflow 上都可以找到很多优质的回答（http://stackoverflow.com/questions/372885/how-do-i-connect-to-a-mysql-database-in-python）。

PostgreSQL和Python

如果你熟悉 PostgreSQL（或正在学习 PostgreSQL），想要使用 PostgreSQL 数据库，那么用 Python 连接 PostgreSQL 也是很容易的。你也只需要做两步：安装驱动程序，用 Python 连接。

Python 的 PostgreSQL 驱动程序有很多（https://wiki.postgresql.org/wiki/Python），但最流行的是 Psycopg（http://initd.org/psycopg/）。在 Psycopg 的安装页面（http://initd.org/psycopg/docs/install.html）中详细介绍了如何在电脑上运行 Psycopg，在 PostgreSQL 网站上也有关于 Python 如何使用 Psycopg 的详细介绍（https://wiki.postgresql.org/wiki/Psycopg2_Tutorial）。

6.7.2　非关系型数据库：NoSQL

比方说，你喜欢使用数据库这个主意，但映射出所有关系会让你抓狂。可能只是因为你目前没有真正理解数据的连接方式，也可能是因为你用的是平面数据（flat data，也就是说，不必良好映射的无关系数据），或者也可能是因为你对学习 SQL 没有更强烈的兴趣。幸运的是，还有一种适合你的数据库。

NoSQL 以及其他非关系型数据库将数据保存成平面格式（flat format），通常是 JSON 格式。我们在第 3 章中说过，JSON 查找信息的方法很简单。回到上一节关于我朋友的数据，如果我只有保存在节点中的数据，通过这些节点可以查询该朋友的更多信息，那我应该怎么做？数据看起来可能是这样的：

{
    'name': 'Meghan',
    'occupation': { 'employer': 'NYT',
                    'role': 'design editor',
                  },
    'birthplace': 'Ohio',
    'hobbies': ['cooking', 'dancing', 'teaching'],
}

可以看出，我用一个简单列表就可以给出我朋友的所有属性，无需创建表。

你可能想知道，关系型数据的优点是什么？你问不同的人，得到的回答可能会完全不同——在计算机科学领域，在众多开发人员之中，这是一个激烈争论的话题。我们的观点是，当数据结构包含大量的关系网络时，SQL 对快速查询做出了许多改进。非关系型数据库在速度、可用性和复用方面做出了许多改进。

最后，如果你对学习某一种数据库有更强烈的兴趣，可以让兴趣帮你做决定，但不要现在就确定数据库的格式。如果你需要在关系型数据库和非关系型数据库之间迁移，有许多工具可以帮你完成这一任务 1。

1对于在 SQL 和 NoSQL 数据库之间的迁移，更多内容可查阅 Matt Asay 关于将 Foursquare 从关系型数据库迁移到 NoSQL 数据库的文章（http://www.techrepublic.com/blog/the-enterprise-cloud/migrating-from-a-relational-to-a-nosql-cloud-database/）。另外，Quora 上还有许多关于反向迁移的文章（https://www.quora.com/How-do-I-migrate-data-from-a-MongoDB-to-MySQL-database-Can-it-be-done-in-a-real-time-scenario-What-are-the-pros-and-cons-for-each-migration-Which-one-do-you-advice-What-is-your-experience-Any-reference-DB-expert-who-can-do-it）。

MongoDB和Python

如果你的数据具有非关系型数据库结构，或者你希望在实践中学习，那么用 Python 连接 NoSQL 数据库是非常简单的。虽然有很多选择，但最流行的 NoSQL 数据库框架之一是 MongoDB（http://mongodb.org/）。要使用 MongoDB，你需要首先安装驱动程序（http://docs.mongodb.org/ecosystem/drivers/python/），然后用 Python 来连接。在 PyCon 2012 上有一个很棒的演讲：“Getting Started with MongoDB”（https://github.com/behackett/presentations/tree/master/pycon_2012），你可以从这里开始学习 MongoDB 以及如何用 Python 连接。

6.7.3　用Python创建本地数据库

开始学习数据库和 Python 最简单的方法就是，使用一个简单的库帮你快速上手。对于本书来说，我们推荐学习 Dataset 库（http://dataset.readthedocs.io/）。Dataset 是一个包装库（wrapper library），可以将可读的 Python 代码翻译成要处理的数据库代码，以加快开发速度。

如果你已有一个 SQLite、PostgreSQL 或 MySQL 数据库，可以参考快速入门指南（https://dataset.readthedocs.io/en/latest/quickstart.html）直接连接。如果你还没有上述数据库之一，在使用这个工具时它会为你创建一个。我们来看一下如何让它在你的电脑上运行。

你要做的第一件事是安装 Dataset（http://dataset.readthedocs.io/en/latest/install.html）。如果你已经安装了 pip，那么只需要输入 pip install dataset。

然后你需要确定用到的后端。如果你已经在用 PostgreSQL 或 MySQL，只需要用对应的语法创建一个新的数据库。如果你对数据库不太熟悉，那我们就用 SQLite。首先，下载操作系统对应的 SQLite 二进制文件（http://www.sqlite.org/download.html）。打开下载的文件，按照安装说明一步步安装。

打开终端，切换（cd）到保存 Python 数据处理脚本的项目文件夹。输入以下代码来创建新的 SQLite 数据库：

sqlite3 data_wrangling.db

你应该会看到以 sqlite> 开头的提示符，提示你输入 SQL 语句。你已经确认电脑上可以运行 sqlite3，可以在终端输入 .q 退出 SQLite。退出后，列出当前文件夹的所有文件。现在你应该会看到一个名为 data_wrangling.db 的文件——那就是你的数据库！

安装好了 SQLite，运行了第一个数据库之后，现在要开始使用 Dataset 了。在 Python 中运行以下代码：

import dataset

db = dataset.connect('sqlite:///data_wrangling.db')

my_data_source = {
  'url':
  'http://www.tsmplug.com/football/premier-league-player-salaries-club-by-club/',
  'description': 'Premier League Club Salaries',
  'topic': 'football',
  'verified': False,
} ➊

table = db['data_sources'] ➋
table.insert(my_data_source) ➌

another_data_source = {
  'url':
  'http://www.premierleague.com/content/premierleague/en-gb/players/index.html',
  'description': 'Premier League Stats',
  'topic': 'football',
  'verified': True,
}

table.insert(another_data_source)

sources = db['data_sources'].all()  ➍

print sources

❶ 创建一个 Python 字典，里面是我们要保存的数据。我们要保存的是足球研究的数据源。我们添加的信息有主题、描述、URL 以及我们是否对数据做过核实。

❷ 创建名为 data_sources 的新表。

❸ 将第一个数据源插入新表。

❹ 显示我们保存在 data_sources 表中的所有数据源。

你已经利用 SQLite 创建了第一个关系型表，并完成了 Python 与数据库之间的第一次交互。随着本书内容的深入，你将会向数据库中添加更多的数据和表。将所有数据保存到一处，可以让你数据结构清晰，让你的研究更加专注。

6.8　使用简单文件

如果你的数据集很小，很可能简单文件就可以满足要求，不必使用数据库。你可能想浏览一下第 7 章，在保存之前先用数据清洗技术处理一下，但把数据保存成 CSV 文件或其他简单文件格式是完全可以的。我们用来导入 CSV 的 csv 模块（见 3.1.1 节）也有许多好用的写入类（https://docs.python.org/2/library/csv.html#writer-objects）。

在使用简单文件时，你主要考虑的是确保访问和备份文件都比较方便。要满足这些需求，你可以将数据保存在共享网盘或云服务（Dropbox、Box、Amazon、Google Drive）中。这些服务通常都会提供备份选项和管理能力，同时还能够分享文件。在“哎呀，我把数据文件覆盖了”时，这是非常有用的。

6.8.1　云存储和Python

根据你选择的云存储方案，你应该研究一下用 Python 获取数据的最佳方法。Dropbox 对 Python 的支持很好，网站上的“Python 快速入门指南”（https://www.dropbox.com/developers-v1/core/start/python）很不错。Google Drive 要复杂一些，但“Python 快速上手指南”（https://github.com/googledrive/python-quickstart）可以帮你完成初步的设置。Google Drive 还有一些 Python API 包装器，比如 PyDrive（https://github.com/googledrive/PyDrive），可以让你在不太会用 Python 的情况下使用 Google Drive。要管理 Google Drive 上的电子表格，我们强烈推荐 GSpread（https://github.com/burnash/gspread）。

如果你有自己的云服务器，可能需要研究连接云服务器的最佳方法。Python 有内置的 URL 请求方法、FTP（文件传输协议）方法和 SSH/SCP（Secure Shell/Secure Copy）方法，都包含在 Python 标准库（stdlib）中。在第 14 章中我们还会讲到管理云服务的一些有用库。

6.8.2　本地存储和Python

数据存储最简单也是最直接的方法就是本地存储。用一行 Python 代码就可以打开文件系统中的文档（open 命令，https://docs.python.org/2/library/functions.html#open）。在处理数据时，你还可以用内置的 file.write 方法修改并保存为新文件。

6.9　其他数据存储方式

数据存储还有许多有趣的新方式，和前面讲过的都不一样。根据你的使用案例，存储数据可能有更好的方式。下面是两种有趣的存储方式。

层次型数据格式（HDF）

HDF 是基于文件的可扩展数据解决方案，可将大型数据库快速存储至文件系统（本地或其他位置）。如果你已经很熟悉 HDF，Python 有一个 HDF5 驱动程序 h5py（http://www.h5py.org/），可以将 Python 与 HDF5 相连接。

Hadoop

Hadoop 是一个大数据分布式存储系统，可以跨集群存储并处理数据。如果你已经用过 Hadoop，或者熟悉 Hadoop，在 Cloudera 网站上有一篇“Hadoop 上 Python 框架指南”（http://blog.cloudera.com/blog/2013/01/a-guide-to-python-frameworks-for-hadoop/），还有一些容易上手的代码示例。

6.10　小结

恭喜！你已经搞定了项目面临的几个最大问题：我怎么能找到有用的数据？我怎么访问并保存数据？我们希望你对获取的数据源有信心，并相信你第一个数据集的真实性。我们也希望你对数据备份和数据存储有一个可靠的计划。

你可以将本章学习的技术应用到以后的数据集上，即使是在数据网站上花几个小时研究脑海中突然出现的问题。

现在你应该有信心做好以下事情：

判断你找到数据集的价值和用途

拿起电话寻求更多信息

要回答一个问题，知道首先去哪里寻找数据

轻松实现安全存储数据的方法

核实你找到的数据

构建数据的关系模型你还第一次接触到表 6-2 中的这些概念。

表6-2：Python编程新概念和新库

概念/库

作用

关系型数据库（例如 MySQL 和 PostgreSQL）

轻松存储关系型数据

非关系型数据库（例如 MongoDB）

以平面方式存储数据

SQLite（https://www.sqlite.org/）安装和使用

基于 SQL 的易用存储，适用于简单项目

Dataset（https://dataset.readthedocs.org/en/latest/）安装和使用

易用的 Python 数据库包装器

在后续章节中，你还会更多地用到所有这些技术。在下一章里，你将学习清洗数据，利用代码发现异常，编写完整的脚本或程序，这样你就可以分析数据，并输出结果与全世界分享。

第 7 章　数据清洗：研究、匹配与格式化

数据清洗并不是最迷人的工作，却是数据处理的重要组成部分。要想成为数据清洗专家，需要严谨的态度，以及对所研究领域全面系统的知识。学会如何正确地清洗数据并汇总，可以让你在研究领域中脱颖而出。

Python 的设计很适合数据清洗，它可以创建函数处理相同的规律，减少重复性工作。根据我们目前所学的代码知识，学会用脚本和代码处理重复性的问题，可以节省数小时的体力劳动，只需要运行一次脚本就可以完成。

本章我们将学习如何用 Python 清洗数据和格式化数据。我们还会用 Python 寻找数据集中的重复数据和错误。在下一章里我们会继续学习数据清洗，特别是清洗过程自动化和清洗后的数据存储。

7.1　为什么要清洗数据

对于你获取的数据，有些可能格式良好，方便使用。如果真是这样的话，那你很幸运！大部分数据即使清洗过，也会有格式不一致和可读性的问题，例如首字母缩写或描述性标题不匹配，特别是数据来自多个数据集。除非你在数据格式化和标准化上花点工夫，否则数据不可能正确合并，也就没有用处了。

　清洗数据可以让数据更容易存储、搜索和复用。我们在第 6 章中学过，先清洗数据，再把数据保存到适当的模型中会容易得多。想象一个数据集中有很多列（或字段），应该保存成特定的数据类型，比如日期、号码或电子邮件地址。如果你能将预期格式标准化，清洗或删除不合格的数据，就可以保证数据的一致性，在以后需要查询数据集时也不用做大量工作。

如果你想展示你的发现并发布数据，就要发布清洗过的版本。这样其他数据处理人员就能轻松导入并分析数据。你还可以在发布最终数据集的同时发布原始数据，并说明你是如何一步步清洗数据并将其归一化的。

在清洗数据的过程中，我们希望记下清洗过程的每一步，这样就可以在研究中为我们的数据集及其使用方法申辩，同时也可以方便我们自己以及其他人的后续使用。通过记录清洗过程，在遇到新的数据时我们可以重复整个过程。

　如果你用 IPython 与数据交互，一个强大的工具是 IPython 的魔法命令，比如 %logstart（https://ipython.org/ipython-doc/dev/interactive/magics.html#magic-logstart）可用于记录日志，%save（https://ipython.org/ipython-doc/dev/interactive/magics.html#magic-save）可以保存当前会话供以后使用。这样你就可以在 Python 终端里创建脚本，而不仅仅是一行行的代码。随着对 Python 的进一步学习，你可以完善脚本并与其他人分享。想了解更多 IPython 的内容，可查阅附录 F。

下面我们开始学习数据清洗的基础知识，学习如何格式化数据，以及如何将多个数据集正确地匹配在一起。

7.2　数据清洗基础知识

如果已经完成了前面几章的代码练习，你就已经用到了一些数据清洗的概念。在第 4 章，我们从 Excel 工作表里导出数据，并创建一个字典来表示这些数据。修改数据使其满足新的标准化数据格式，这个过程就是数据清洗。

我们已经研究过 UNICEF 发布的与童工有关的数据集（见 6.5.4 节），下面我们深入研究 UNICEF 的原始数据。大部分 UNICEF 报告的原始数据集都来自多指标类集调查（Multiple Indicator Cluster Surveys，MICS，http://mics.unicef.org/surveys）。这些调查是由 UNICEF 工作人员和志愿者做的家庭层面调查，用于对世界各地妇女和儿童生活状况的研究。浏览最新的几份调查，我们提取出津巴布韦最新的 MICS 数据进行分析。

在开始分析之前，我们首先需要以教育和研究为目的请求访问 UNICEF 网站，然后下载最新的调查。获取访问权限之后（大约需要一天的时间），我们就可以下载原始数据集。大多数 MICS 原始数据是 SPSS 格式或 .sav 文件。SPSS 是社会科学家用来保存并分析数据的程序。对于某些社会科学统计数据来说，它是很好用的工具，但并不太适合用 Python 来处理。

为了将 SPSS 文件转换成可用的格式，我们首先使用开源项目 PSPP（https://www.gnu.org/software/pspp/）来查看数据，然后用几个简单的 R 命令将 SPSS 数据转换成 .csv 文件（http://bethmcmillan.com/blog/?p=1073），这样 Python 处理起来会比较方便。还有许多优秀的项目，可以用 Python 与 SPSS 文件交互（https://pypi.python.org/pypi/savReaderWriter），但其安装和操作都要比 R 命令复杂。你可以在本书仓库（https://github.com/jackiekazil/data-wrangling）中找到生成的 CSV 文件。

仔细观察文件及其包含的数据，我们从这里开始数据清洗过程。数据清洗的第一步通常是简单的目视分析。我们仔细观察文件，看看能发现什么！

7.2.1　找出需要清洗的数据

数据清洗的第一步是，观察数据字段，仔细寻找不一致的地方。如果在数据清洗之初就可以使数据看起来更加干净的话，你会更容易找到在数据归一化过程中需要解决的最初问题。

我们来看一下 mn.csv 文件。文件中包含原始数据，并用首字母缩写作为标题，这些缩写的含义可能很好翻译。我们来看一下 mn.csv 文件的列标题：

"","HH1","HH2","LN","MWM1","MWM2", ...

每一项都代表调查中的一个问题或数据，我们想要的是可读性更强的版本。通过谷歌搜索，我们在分享 MICS 数据的世界银行网站（http://microdata.worldbank.org/index.php/catalog/1794/datafile/F5）上找到了这些标题的具体含义。

　花点时间研究世界银行网站上有没有缩写一览表，可以帮你更好地完成数据清洗工作。你还可以给该机构打电话，询问是否有易于使用的缩写列表。

利用在第 11 章即将学到的一些网络抓取技术，我们可以得到一个 CSV 文件，里面包含这些标题及其英文释义，以及世界银行在采集这些 MICS 数据时所问的问题。我们已将网络抓取生成的新标题放在本书仓库中（mn-headers.csv）。我们希望将这些标题与调查数据一一对应，这样就有了可读性较强的问题和答案。我们有几种方法可以做到这一点。

替换标题

想要提高标题可读性，最明显而直接的方法就是将短标题替换成易于理解的长标题。如何用 Python 做标题替换呢？首先，需要用第 3 章学过的 csv 模块导入 mn.csv 和 mn-headers.csv 两个文件（参见下面的导入代码）。在本章和下一章中，你可以在脚本中写代码，也可以在终端里（比如 IPython）写代码。这样你可以先与数据交互，然后再将其保存到文件中：

from csv import DictReader

data_rdr = DictReader(open('data/unicef/mn.csv', 'rb'))
header_rdr = DictReader(open('data/unicef/mn_headers.csv', 'rb'))

data_rows = [d for d in data_rdr] ➊
header_rows = [h for h in header_rdr]

print data_rows[:5] ➋
print header_rows[:5]

❶ 本行代码将可迭代对象 DictReader 写入一个新列表，这样我们可以保存数据并重复使用。我们用到了列表生成式，只需要一行简单、清晰、易读的代码即可实现。

❷ 本行代码打印出数据的一个切片，用的是 Python 列表的 slice 方法，显示新列表的前 5 个元素，对列表内容有一个初步了解。

在第 4 行代码中，我们用到了 Python 的列表生成式（list comprehension）函数。Python 列表生成式的格式如下：

[func(x) for x in iter_x]

列表生成式的首尾是列表的方括号。它用到一个可迭代对象（iter_x），将 iter_x 的每一行或每一个值传入 func(x)，用返回值创建一个新列表。这里我们没有用到列表生成式的函数，只用到了每一行的当前值。在后续章节中，我们会将可迭代对象的每一行或每一个值传入一个函数，对数据进行清洗或修改，然后再添加到新列表中。列表生成式是易读易用的语法一个很好的例子，这也是 Python 广为人知的特点。用 for 循环也可以实现相同的功能，但是代码量更大：

new_list = []
for x in iter_x:
    new_list.append(func(x))

可以看出，列表生成式的代码比较简短，性能更好，也更节省内存。

我们希望将 data_rows 中字典标题替换为文件里可读性较强的标题。从输出里可以看出， header_rows 字典里同时包含短标题和长标题。短标题包含在 Name 字段，可读性更强的长标题包含在 Label 字段。利用一些 Python 字符串方法可以将二者轻松匹配在一起：

for data_dict in data_rows: ➊
    for dkey, dval in data_dict.items(): ➋
        for header_dict in header_rows: ➌
            for hkey, hval in header_dict.items():
                if dkey == hval: ➍
                    print 'match!'

❶ 遍历每一条数据记录。我们将用每一个字典的键与标题匹配。

❷ 遍历每一行数据的键和值，这样就可以将所有键替换成可读性更强的标题标签（要查看数据字典里每一个键值对，我们用的是 Python 字典的 items 方法）。

❸ 遍历所有标题行，这样我们可以得到可读性更强的标签。这并不是最快的方法，但可以保证不会有遗漏。

❹ 如果数据列表的键（MWB3、MWB7、MWB4、MWB5…）和标题字典的值相同，那么打印 'match!' 表示二者相互匹配。

运行代码，我们发现有很多匹配项。下面我们来看一下，能否用类似的逻辑将标题替换成更好的标题。我们知道将二者匹配是相当容易的。但我们只找到了想要匹配的那些行。我们来看一下能否找到一种方法，将 data_rows 每一项的键与 header_rows 每一项的值相匹配：

new_rows = [] ➊

for data_dict in data_rows:
    new_row = {} ➋
    for dkey, dval in data_dict.items():
        for header_dict in header_rows:
            if dkey in header_dict.values(): ➌
                new_row[header_dict.get('Label')] = dval ➍
    new_rows.append(new_row) ➎

❶ 创建一个新列表，里面包含的是清洗过的行数据。

❷ 为每一行创建一个新字典。

❸ 这里我们用的是字典的 values 方法，而不是遍历标题行的所有键值对。这一方法返回的是仅由字典的值组成的列表。我们还用到了 Python 的 in 方法，用来测试一个对象是否包含在某个列表中。在本行代码中，对象是我们的键，或缩写字符串，而列表是标题字典的值组成的列表（里面包含缩写短标题）。如果本行代码为真，我们就找到了一个匹配行。

❹ 每找到一个匹配，都将其添加到 new_row 字典中。将字典的键设置为标题行中 Label 对应的值，也就是将短标题（Name 对应的值）替换为可读性更好的长标题（Label 对应的值），并将字典的值设置为数据行的值（dval）。

❺ 将清洗过的新数据添加到新列表中。这样做是为了保证我们找到了所有的匹配，然后再运行后面的代码。

将新列表第一项打印出来，可以看出，我们已经成功提高了数据的可读性：

In [8]: new_rows[0]
Out[8]: {
    'AIDS virus from mother to child during delivery': 'Yes',
    'AIDS virus from mother to child during pregnancy': 'DK',
    'AIDS virus from mother to child through breastfeeding': 'DK',
    'Age': '25-29',
    'Age at first marriage/union': '29',...

　要检查函数的缩进是否正确，一个简单的方法是观察具有相同缩进的其他代码。不停问自己：其他代码这一步的逻辑是什么？我的代码应该何时继续下一步？

对于数据清洗问题，解决方法远不止一种。那么对于我们标题可读性较差的问题，我们来看一下能否用其他方法解决。

合并问题与答案

修复标签问题的另一种方法是 Python 的 zip 方法：

from csv import reader ➊

data_rdr = reader(open('data/unicef/mn.csv', 'rb'))
header_rdr = reader(open('data/unicef/mn_headers.csv', 'rb'))

data_rows = [d for d in data_rdr]
header_rows = [h for h in header_rdr]

print len(data_rows[0]) ➋
print len(header_rows)

❶ 这次我们用的是简单的 reader 类，而不是 DictReader。reader 为每一行创建的是一个列表，而不是一个字典。由于我们要用的是 zip 方法，需要的是列表而不是字典，这样我们就可以将标题列表与数据列表合并在一起。

❷ 这几行代码创建标题列表和数据列表，并查看它们的长度是否相同。

啊呀——打印结果显示，数据列表长度与标题列表长度并不相同！我们的数据有 159 行，而标题列表中有 210 个标题。这说明，与津巴布韦数据集相比，MICS 在其他国家可能问了更多问题，或者提供了更多的备选问题。

我们需要进一步研究数据集中都用了哪些标题，哪些标题是不需要的。我们仔细观察一下，找出没有正确对应的那些标题：

In [22]: data_rows[0]
Out[22]: ['',
 'HH1',
 'HH2',
 'LN',
 'MWM1',
 'MWM2',
 'MWM4',
 'MWM5',
 'MWM6D',
 'MWM6M',
 'MWM6Y',
... ]

In [23]: header_rows[:2]
Out[23]: [
 ['Name', 'Label', 'Question'],
 ['HH1', 'Cluster number', '']]

好吧，我们可以很清楚地看到，data_rows 的第二行与 header_rows 中索引数为 1 的值对应。找出不匹配的那些行，然后将它们从 header_rows 中删除，这样我们就可以将数据正确地合并：

bad_rows = []

for h in header_rows:
    if h[0] not in data_rows[0]: ➊
        bad_rows.append(h) ➋

for h in bad_rows:
    header_rows.remove(h) ➌

print len(header_rows)

❶ 测试标题行的第一个元素（标题的缩写版本）是否包含在数据列表的第一行中（所有的标题缩写）。

❷ 将不匹配的标题行添加到新列表 bad_rows 中。下一步我们将利用这个列表来判断需要删除哪些行。

❸ 利用列表的 remove 方法从列表中删除指定的一行。当你能够指出想从列表中删除的某一行（或某些行）时，这个方法往往是很有用的。

啊哈！现在我们已经几乎完成匹配了。我们的数据列表中有 159 个值，标题列表中有 150 个值。下面我们来看一下，为什么标题列表里不需要这 9 个匹配的标题：

all_short_headers = [h[0] for h in header_rows] ➊

for header in data_rows[0]: ➋
    if header not in all_short_headers: ➌
        print 'mismatch!', header ➍

❶ 利用 Python 列表生成式采集每一个标题行的第一个元素，生成一个由所有标题缩写组成的列表。

❷ 遍历数据集中的所有标题，检查哪些没有包含在清洗后的标题列表中。

❸ 挑出不包含在缩写列表中的标题。

❹ 用 print 语句打印出所有的不匹配项。如果你需要将两个字符串打印在同一行中，只需要在中间加一个 ,，就可以用空格将两个字符串连接在一起。

运行代码，输出应该是这样的：

mismatch!
mismatch! MDV1F
mismatch! MTA8E
mismatch! mwelevel
mismatch! mnweight
mismatch! wscoreu
mismatch! windex5u
mismatch! wscorer
mismatch! windex5r

从我们目前所知和输出结果来看，只有几个不匹配标题（带有大写字母的那几个）是需要处理的。小写的那些标题是用于 UNICEF 内部方法的，与我们要研究的问题无关。

由于我们创建的从世界银行网站采集标题的网络爬虫没有找到 MDV1F 和 MTA8E 这两个变量，所以我们需要用 SPSS 阅读器来查看它们的含义。（另一种方法是删掉这几行，继续下一步。）

　在处理原始数据时，有时你会发现，想要将数据转换成可用的格式，你需要舍弃不需要的数据或难以清洗的数据。归根结底，决定因素并不在于是否懒惰，而在于数据对你的问题是否重要。

打开 SPSS 阅读器后可以看到，MDV1F 对应的标签是“如果妻子不忠，殴打妻子是否合理？”，同时还与关于家庭暴力的其他一系列长问题相对应。其他问题也有一些是关于家庭暴力的，所以最好保留这个问题。研究 MTA8E 这个标题发现，它对应的一系列不同的问题，都是关于某人吸食烟草的类型。我们已经将两个标题都添加到新的文件（mn_headers_updated.csv）中。

现在重新运行之前的代码，这次用的是修改后的标题文件。

我们来看一下所有的代码，修改其中几处，这样就可以将标题和数据合并在一起。下面这个脚本需要大量内存，如果你计算机的 RAM 小于 4GB 的话，我们建议在 IPython 终端或 IPython notebook 中运行，这样可以避免出现段错误（segmentation fault）：

from csv import reader

data_rdr = reader(open('data/unicef/mn.csv', 'rb'))
header_rdr = reader(open('data/unicef/mn_headers_updated.csv', 'rb'))

data_rows = [d for d in data_rdr]
header_rows = [h for h in header_rdr if h[0] in data_rows[0]] ➊

print len(header_rows)

all_short_headers = [h[0] for h in header_rows]

skip_index = [] ➋

for header in data_rows[0]:
    if header not in all_short_headers:
        index = data_rows[0].index(header) ➌
        skip_index.append(index)

new_data = []

for row in data_rows[1:]: ➍
    new_row = []
    for i, d in enumerate(row): ➎
        if i not in skip_index: ➏
            new_row.append(d)
    new_data.append(new_row) ➐

zipped_data = []

for drow in new_data:
    zipped_data.append(zip(header_rows, drow)) ➑

❶ 使用列表生成式快速删除不匹配的标题。可以看到，我们还在列表生成式中使用了 if 语句。本行代码的作用是，如果标题行第一个元素（标题缩写）包含在数据行的标题中，那么将该标题行添加到新列表中。

❷ 创建新列表，用来保存我们不希望保存的数据行的索引编号。

❸ 利用 Python 列表的 index 方法，返回我们不需要的索引编号，因为这些索引编号对应的标题没有包含在缩写列表中。下一行代码会将不匹配标题行的索引编号保存下来，这样我们就可以不采集这些数据。

❹ 对保存调查数据的列表做切片，只选取其中的数据行（除了第一行外的所有行），然后对其进行遍历。

❺ 利用 enumerate 函数找出不需要保存的那些数据行。这个函数接受一个可迭代对象（本例中是数据行列表），返回每一个元素的索引编号和值。该函数将返回的第一个值（索引编号）赋值给 i，将数据值赋值给 d。

❻ 检查并确保索引编号不在我们不希望保存的列表中。

❼ 检查完数据行中的每一项（或每一“列”）之后，将新的数据条目添加到 new_data 列表中。

❽ 将完全匹配的标题和数据合并在一起，并添加到新数组 zipped_data 中。

现在我们可以将新数据集的一行打印出来，看看与我们的预期是否相同：

In [40]: zipped_data[0]
Out[40]: [(['HH1', 'Cluster number', ''], '1'),
(['HH2', 'Household number', ''], '17'),
(['LN', 'Line number', ''], '1'),
(['MWM1', 'Cluster number', ''], '1'),
(['MWM2', 'Household number', ''], '17'),
(['MWM4', "Man's line number", ''], '1'),
(['MWM5', 'Interviewer number', ''], '14'),
(['MWM6D', 'Day of interview', ''], '7'),
(['MWM6M', 'Month of interview', ''], '4'),
(['MWM6Y', 'Year of interview', ''], '2014'),
(['MWM7', "Result of man's interview", ''], 'Completed'),
(['MWM8', 'Field editor', ''], '2'),
(['MWM9', 'Data entry clerk', ''], '20'),
(['MWM10H', 'Start of interview - Hour', ''], '17'),
....

我们已经将所有的问题和回答保存在元组中，每一行中所有的标题和数据都是匹配好的。为了检查这些匹配是否正确，我们来看一下该行的结尾：

(['TN11', 'Persons slept under mosquito net last night',
'Did anyone sleep under this mosquito net last night?'], 'NA'),
(['TN12_1', 'Person 1 who slept under net',
'Who slept under this mosquito net last night?'], 'Currently married/in union'),
(['TN12_2', 'Person 2 who slept under net',
'Who slept under this mosquito net last night?'], '0'),

数据看起来很奇怪。似乎出现了一些匹配错误。我们来做一个真实性核查（reality check），利用刚学过的 zip 方法来检查标题是否正确匹配：

data_headers = []

for i, header in enumerate(data_rows[0]): ➊
    if i not in skip_index: ➋
        data_headers.append(header)

header_match = zip(data_headers, all_short_headers)   ➌

print header_match

❶ 遍历数据列表中的所有标题。

❷ 利用 if...not in... 语句，只有当索引编号不包含在 skip_index 中时才会返回 True。

❸ 将两个标题列表合并在一起，这样我们可以观察寻找不匹配项。

啊哈！你发现错误了吗？

....
('MHA26', 'MHA26'),
('MHA27', 'MHA27'),
('MMC1', 'MTA1'),
('MMC2', 'MTA2'),
....

在这一项之前的匹配都是正确的，在这一项之后，我们的标题文件和数据文件的问题顺序出现了不一致。由于 zip 方法不会改变列表元素的顺序，我们必须首先调整标题的顺序，使其与数据集的顺序一致。下面是修改后的代码，尝试将数据正确匹配：

from csv import reader

data_rdr = reader(open('data/unicef/mn.csv', 'rb'))
header_rdr = reader(open('data/unicef/mn_headers_updated.csv', 'rb'))

data_rows = [d for d in data_rdr]
header_rows = [h for h in header_rdr if h[0] in data_rows[0]]

all_short_headers = [h[0] for h in header_rows]

skip_index = []
final_header_rows = [] ➊

for header in data_rows[0]:
    if header not in all_short_headers:
        index = data_rows[0].index(header)
        skip_index.append(index)
    else: ➋
        for head in header_rows: ➌
            if head[0] == header: ➍
                final_header_rows.append(head)
                break ➎

new_data = []

for row in data_rows[1:]:
    new_row = []
    for i, d in enumerate(row):
        if i not in skip_index:
            new_row.append(d)
    new_data.append(new_row)

zipped_data = []

for drow in new_data:
    zipped_data.append(zip(final_header_rows, drow)) ➏

❶ 创建新列表，包含顺序正确的最终标题行。

❷ 利用 else 语句，只将匹配的列添加到列表中。

❸ 遍历 header_rows，直到找到匹配为止。

❹ 检查标题缩写是否匹配。我们用 == 来检查匹配。

❺ 找到匹配后，利用 break 退出 for head in header_rows 循环。这样速度更快，而且不会对结果造成影响。

❻ 将新的 final_header_rows 列表与标题行按顺序正确地合并在一起。

运行新代码，我们看一下第一个数据条目的结尾：

(['TN12_3', 'Person 3 who slept under net',
'Who slept under this mosquito net last night?'], 'NA'),
(['TN12_4', 'Person 4 who slept under net',
'Who slept under this mosquito net last night?'], 'NA'),
(['HH6', 'Area', ''], 'Urban'),
(['HH7', 'Region', ''], 'Bulawayo'),
(['MWDOI', 'Date of interview women (CMC)', ''], '1372'),
(['MWDOB', 'Date of birth of woman (CMC)', ''], '1013'),
(['MWAGE', 'Age', ''], '25-29'),

看起来匹配得很好。我们可以写出更加清晰的代码，但我们已经找到一个好方法，可以保存绝大部分数据并将数据与标题合并在一起，而且速度还很快。

　关于你需要的数据完整性，以及在你的项目中需要为数据清洗花费多少精力，你总是需要作出评估。如果你只用其中一部分数据，可能就不需要保存所有数据。如果数据集是你的主要研究来源，那么值得你花费时间和精力来保证数据完整性。

本节我们学习了一些新的工具和方法，可以发现错误或需要清洗的数据，并结合我们学过的 Python 知识和解决问题的方法来解决这些问题。数据清洗第一步工作（替换标题文本）舍弃了一些数据列，保存了其余的数据列，并没有显示标题有缺失。但只要最后得到的数据集中包含我们需要的数据列，这种方法就能满足我们的要求，速度更快，代码量也更少。

在数据清洗过程中思考这几类问题。保持数据完整性是不是很重要？如果是的话，值得花几个小时？有没有简单的方法，既可以适当清洗数据，又可以保存你需要的所有内容？有没有可重复的方法？在清洗数据集时这些问题可以为你提供指导。

现在我们已经有了一个良好的数据列表，我们可以继续学习其他类型的数据清洗。

7.2.2　数据格式化

数据清洗最常见的形式之一，就是将可读性很差或根本无法读取的数据和数据类型转换成可读性较强的格式。特别是如果需要用数据或可下载文件来撰写报告，你需要将其由机器可读转换成人类可读。如果你的数据需要与 API 一起使用，你可能需要特殊格式的数据类型。

对于格式化字符串和数字，Python 为我们提供了大量方法。在第 5 章讲到调试并显示结果时，我们用过 %r，作用是以字符串格式或 Unicode 格式给出对象的 Python 表示。 Python 还有字符串格式化方法 %s 和 %d，分别代表字符串和数字。它们通常与 print 命令一起使用。

将对象转换成字符串或 Python 表示还有一个更高级的方法，就是利用 format 方法。正如 Python 官方文档（https://docs.python.org/2/library/stdtypes.html#str.format）所述，这个方法可以定义一个字符串，并把数据作为参数或关键字参数传入字符串。我们来仔细看一下 format 方法：

for x in zipped_data[0]:
    print 'Question: {}\nAnswer: {}'.format( ➊
        x[0], x[1]) ➋

❶ format 用 {} 表示数据传入的位置，用 \n 换行符来表示换行。

❷ 这里我们传入的是问题和答案组成的元组的前两个值。

你应该会看到像这样的输出：

Question: ['MMT9', 'Ever used Internet', 'Have you ever used the Internet?']
Answer: Yes
Question: ['MMT10', 'Internet usage in the last 12 months',
'In the last 12 months, have you used the Internet?']
Answer: Yes

很难读懂这是什么意思。我们试着对其进一步清洗。从输出中可以看出，在问题列表中，索引编号为 0 的是缩写，索引编号为 1 的是问题描述。我们希望只用列表的第二部分，可以作为一个很好的标题。我们再试一次：

for x in zipped_data[0]:
    print 'Question: {[1]}\nAnswer: {}'.format(
        x[0], x[1])    ➊

❶ 这次我们用格式语法 1 来挑出对应索引编号的数据，使输出结果可读性更强。

我们再来看一下输出结果：

Question: Frequency of reading newspaper or magazine
Answer: Almost every day
Question: Frequency of listening to the radio
Answer: At least once a week
Question: Frequency of watching TV
Answer: Less than once a week

现在可读性就很好了。好耶！下面我们看一下 format 方法的其他用法。当前的数据集中没有很多数字，所以我们用几个示例数字，展示不同数字类型的更多格式化方法：

example_dict = {
    'float_number': 1324.321325493,
    'very_large_integer': 43890923148390284,
    'percentage': .324,
}

string_to_print = "float: {float_number:.4f}\n" ➊
string_to_print += "integer: {very_large_integer:,}\n" ➋
string_to_print += "percentage: {percentage:.2%}" ➌

print string_to_print.format(**example_dict) ➍

❶ 这里用到了字典，利用键访问字典的值。我们用 : 来分隔键名和格式。.4f 的意思是，将数字转换成浮点数（f），保留四位小数（.4）。

❷ 数字格式不变（键名和冒号），插入逗号（,）作为千位分隔符。

❸ 数字格式不变（键名和冒号），插入百分号（%），小数部分保留两位有效数字（.2）。

❹ 对长字符串调用 format 方法，并传入数据字典，用 ** 将字典拆包（unpack）。将 Python 字典拆包，也就是将字典的键 / 值拆开，拆包后的键和值被传递给 format 方法。

　阅读 Python 格式化的文档和实例（https://docs.python.org/2/library/string.html#format-string-syntax）可以了解更多高级格式化的内容，例如利用 format 方法删除不必要的空格、按长度对齐数据和解数学方程。

除了字符串和数字，用 Python 格式化日期也很容易。Python 的 datetime 模块中有许多方法，可以格式化 Python 中已有（或生成）的日期，也可以读取任意日期格式，然后创建 Python 对象（date 对象、datetime 对象、time 对象）。

　Python 中日期格式化最常用的方法是 strftime 和 strptime，将字符串转换成日期最常用的也是这两个方法，如果你用其他编程语言做过日期格式化的话，可能会熟悉这两种格式化方法。想了解更多内容，请阅读关于“strftime 和 strptime 特性”（https://docs.python.org/2/library/datetime.html#strftime-strptime-behavior）的官方文档。

datetime 模块的 strptime 方法可以将字符串或数字转换成 Python 的 datetime 对象。如果你希望将日期和时间保存到数据库中，或者需要调整时区或增加一个小时，这个方法都很有用。转换成 Python 对象之后，你可以充分利用 Python 处理日期的功能，很容易将其重新转换成人类可读或机器可读的字符串。

我们来看一下 zipped_data 列表中保存的采访起止时间数据。首先我们来回顾一下，打印出前几条数据记录，熟悉一下我们即将使用的数据：

for x in enumerate(zipped_data[0][:20]): ➊
    print x

.....
(7, (['MWM6D', 'Day of interview', ''], '7'))
(8, (['MWM6M', 'Month of interview', ''], '4'))
(9, (['MWM6Y', 'Year of interview', ''], '2014'))
(10, (['MWM7', "Result of man's interview", ''], 'Completed'))
(11, (['MWM8', 'Field editor', ''], '2'))
(12, (['MWM9', 'Data entry clerk', ''], '20'))
(13, (['MWM10H', 'Start of interview - Hour', ''], '17'))
(14, (['MWM10M', 'Start of interview - Minutes', ''], '59'))
(15, (['MWM11H', 'End of interview - Hour', ''], '18'))
(16, (['MWM11M', 'End of interview - Minutes', ''], '7'))

❶ 利用 Python 的 enumerate 函数来查看我们需要处理哪些行的数据。

有了全部数据，现在我们需要找到采访开始和结束的具体时间。利用类似的数据，我们可以判断采访时间更可能出现在早上还是晚上，以及采访时长是否会影响回答的数量。我们还可以找出哪个是第一次采访，哪个是最后一次采访，然后计算平均每次采访所用的时间。

我们尝试用 strptime 将数据导入 Python 的 datetime 对象：

from datetime import datetime

start_string = '{}/{}/{} {}:{}'.format( ➊
    zipped_data[0][8][1], zipped_data[0][7][1], zipped_data[0][9][1], ➋
    zipped_data[0][13][1], zipped_data[0][14][1])

print start_string

start_time = datetime.strptime(start_string, '%m/%d/%Y %H:%M') ➌

print start_time

❶ 创建一个字符串模板（base string），用于解析多个数据条目中的数据。本行代码使用的是美国人常用的日期格式：月、日、年，然后是小时和分钟。

❷ 数据读取格式如下：zipped_data[ 第一个数据条目 ][ 数据行编号（由 enumerate 得到）] [ 数据本身 ]。利用第一个数据条目测试，索引号为 8 的那一行是月，索引号为 7 的那一行是日，索引号为 9　的那一行是年。每个元组的第二个元素（[1]）是我们需要的数据。

❸ 调用 strptime 方法，输入的是一个日期字符串和一个格式字符串，格式字符串的语法可参见 Python 官方文档（https://docs.python.org/2/library/datetime.html#strftime-strptime-behavior）。%m/%d/%Y 代表月、日、年，%H:%M 代表小时和分钟。这个方法返回一个 Python 的 datetime 对象。

　如果你用 IPython 运行代码，你不需要用 print 来查看你感兴趣的每一行内容。常见的做法是，只需输入变量名，就可以在交互式终端中查看输出的内容。你甚至还可以用 Tab 键自动补全。

有了上面的代码，我们可以创建一个通用的日期字符串，然后利用 datetime 库的 strptime 方法来解析。在我们的数据集里，由于日期数据的每一项都是单独的元素，我们还可以自己创建 Python 的 datetime 对象，不必使用 strptime 方法。我们来看一下：

from datetime import datetime

end_time = datetime( ➊
    int(zipped_data[0][9][1]), int(zipped_data[0][8][1]), ➋
    int(zipped_data[0][7][1]), int(zipped_data[0][15][1]),
    int(zipped_data[0][16][1]))

print end_time

❶ 将整数直接传递给 datetime 模块的 datetime 类，生成一个日期对象。我们传入整数作为参数，整数之间用逗号隔开。

❷ 由于 datetime 只接受整数，本行代码将所有的数据转换成整数。datetime 输入参数的顺序是年、月、日、时、分，所以我们必须相应调整数据的顺序。

如你所见，利用更少的代码（见上面的例子），我们可以得到采访结束时间的 datetime 对象。现在我们有了两个 datetime 对象，可以对它们做一些数学运算了！

duration = end_time - start_time ➊

print duration ➋

print duration.days ➌

print duration.total_seconds() ➍

minutes = duration.total_seconds() / 60.0 ➎

print minutes

❶ 用结束时间减去开始时间，计算出采访时长。

❷ 打印一个新的 Python 日期类型。这是一个 timedelta 对象。timedelta 会给出两个时间对象的时间差，还可用于改变时间对象，详见 datetime 文档（https://docs.python.org/2/library/datetime.html?highlight=timedelta#datetime.timedelta）。

❸ 利用内置的 days 属性来查看 timedelta 对象里包含了多少天。

❹ 调用 timedelta 对象的 total_seconds 方法，计算时间差包含多少秒。结果精确到微秒。

❺ 计算分钟数，因为 timedelta 对象没有分钟属性。

运行代码，我们知道第一次采访时长 8 分钟——但这是不是采访的平均时长呢？利用刚学的 datetime 模块中的方法解析整个数据集，我们可以计算出采访平均时长。我们前面做了一些简单的 datetime 数学运算，并学习了如何利用数据集创建 Python 的 datetime 对象。下面我们来看一下，能否将这些新的 datetime 对象重新转换成格式化字符串，用在报告中可以提高可读性：

print end_time.strftime('%m/%d/%Y %H:%M:%S') ➊

print start_time.ctime() ➋

print start_time.strftime('%Y-%m-%dT%H:%M:%S') ➌

❶ strftime 只能输入一个参数，就是你希望显示的日期格式。本行代码输出美国标准时间格式。

❷ Python 的 datetime 对象有一个 ctime 方法，可以根据 C 语言的 ctime 标准输出 datetime 对象。

❸ Python 的 datetime 对象可以用你能想到的任意格式输出字符串。本行代码用的是 PHP 语言常用的时间格式。如果你需要用特殊字符串格式与 API 交互，datetime 模块可以帮到你。

Python 的 datetime 对象非常有用，处理、导入和导出（通过格式化的方法）这种对象都非常简单。根据数据集的不同，你可以利用这些新方法将所有的字符串或 Excel 数据导入并转换成 datetime 对象，做统计分析或取平均值，然后再重新转换成字符串用在报告中。

我们学到了很多格式化的方法和技巧。下面我们开始学习更加细致的数据清洗方法。我们将学习如何轻松发现数据中的坏种子（bad seed），以及如何处理它们。

7.2.3　找出离群值和不良数据

在数据集中寻找离群值和不良数据，可能是数据清洗中最难的任务之一，需要一段时间才能做好。即使你对统计学有很深刻的理解，也完全了解离群值可能会对数据造成的影响，在研究这一话题时也要谨慎小心。

　你要做的是清洗数据，不是处理数据或修改数据，所以在需要删除离群值或不良数据记录时，多花点时间思考如何处理这些数据。如果你剔除离群值使数据归一化，应该在最终结论中明确说明这一点。

第 9 章中我们会讲到更多寻找离群值的方法，但我们先考虑一些简单的方法，检查你的数据集中是否有不良数据。

判断数据有效性的第一个线索是数据来源。我们在第 6 章中说过，你需要仔细审核数据源，确保数据可信。你还需要咨询数据的采集方法，以及数据是否已经清洗过或处理过。

对于我们这里使用的数据样本来说，我们知道 UNICEF 调查有一套标准的问题格式。我们知道这些普查是定期进行的。我们还知道，他们在培训员工如何正确采访方面有一套标准规程。这些证据都说明，数据是一个好的样本，并不是事先选定的样本。相反，如果我们发现 UNICEF 只采访大城市的家庭，而忽略了农村人口，这可能会导致选择偏差或抽样误差。根据你的数据来源，你应该可以找出数据集可能具有的偏差。

　你不可能每次都得到完美的数据。但你应该知道你的数据可能会有哪些取样偏差，确保你不会根据片面的数据集做出全面性的结论。

除了数据源和数据偏差，你还可以通过以下问题发现数据中可能存在的错误：“这些数据是否有不一致的地方？”发现错误数据的一个简单方法就是，查看数据值里是否有错误。例如，你可以浏览整个数据集，查看重要的数据值是否有缺失。你还可以浏览整个数据集，判断数据类型（例如整数、日期、字符串）是否正确匹配。在我们的数据集里尝试寻找缺失数据，看看里面是否有这些问题：

for answer in zipped_data[0]: ➊
    if not answer[1]: ➋
        print answer

❶ 遍历第一个数据条目的所有行。

❷ 测试某个值是否“存在”。我们要测试的值是元组的第二个元素，我们可以用 if not 语句来测试。

if not 语句

Python 可以用简单的 if not 语句来测试某个值是否存在。试着输入 if not None: print True。发生了什么？试着在 if not 后面加一个空字符串或零。发生了什么？

我们知道，我们的数据是字符串，而且不同于其他数据集，UNICEF 用空字符串来表示缺失数据（而不是 -- 等），所以我们可以通过测试字符串是否存在来测试值是否存在。

根据你的数据类型和数据集不同，你可能需要测试 if x is None 或 if len(x) < 1。你需要在代码的可读性和简洁之间做出平衡，保证代码具体而又明确。记得要遵循 Python 之禅（见 8.4 节）。

从代码的输出中可以看出，第一行没有明显缺失的数据。我们要如何测试整个数据集呢？

for row in zipped_data: ➊
    for answer in row: ➋
        if answer[1] is None: ➌
            print answer

❶ 这次我们不仅仅遍历第一行，而是遍历数据集的每一行。

❷ 我们删除了前面例子中的 [0]，因为我们要对每一行进行遍历。

❸ 作为例子，我们在这里测试是否有 None 类型的数据。我们可以知道是否有空的数据点，但无法知道是否有零或空字符串。

可以看出，整个数据集中没有明显缺失的数据，但我们来大致浏览其中一些数据，观察是否有不易发现的缺失数据。在前面的 print 语句中，你可能记得 NA 代表“不适用”（Not Applicable）。

　虽然数据没有缺失，我们可能想知道到底有多少回答是 NA，或者某个问题的回答中 NA 的比例是否过高。如果样本量太小（大多数回答都是 NA），我们可能无法根据现有数据得出更一般性的结论。但如果大部分的回答都是 NA，我们可能会发现有趣的事情（为什么这个问题不适用于群体中的大多数人呢？）。

对于每一个具体的问题，我们来看一下回答是否以 NA 为主：

na_count = {} ➊

for row in zipped_data:
    for resp in row:
        question = resp[0][1] ➋
        answer = resp[1]
        if answer == 'NA': ➌
            if question in na_count.keys(): ➍
                na_count[question] += 1 ➎
            else:
                na_count[question] = 1 ➏

print na_count

❶ 定义一个字典，保存回答中包含 NA 的那些问题。将数据保存在哈希对象（比如字典）中，Python 可以快速方便地对数据进行查询。字典的键是问题，字典的值是包含 NA 的回答个数。

❷ 将元组第一部分的第二个元素（问题描述）保存在变量 question 中。第一个元素（[0]）是短标题，最后一个元素（[2]）是调查者的问题，有时这一项是缺失的。

❸ 利用 Python 的相等性测试找出值为 NA 的回答。如果注意到 NA 有多种写法，可以利用 if answer in ["NA", "na", "n/a"]: 语句判断具有相同含义的多个回答。

❹ 判断问题是否包含在字典的键中，从而判断问题是否已经包含在字典中。

❺ 如果问题已经包含在字典的键中，利用 Python 的 += 方法将字典的值加 1。

❻ 如果问题尚未包含在字典中，则将其添加到字典中，并将其对应的值设置为 1。

哇！我们的数据集中有好多 NA 回答啊。我们大约有 9000 行数据，其中一些问题有超过 8000 个 NA 回答。可能这些问题与调查的人群或年龄组无关，或者与特定的国家和文化没有太大关系。不管怎样，使用这些 NA 问题的意义不大，无法得出人口调查的任何一般性结论。

在判断数据集是否适用于你的研究目的时，寻找数据集中的 NA 是很有用的。如果发现你想要的问题有大量类似 NA 的回答，你可能需要继续寻找其他数据源，或者重新思考你的问题。

前面我们讲到了缺失数据，现在我们来看一下能否找到类型离群值（type outlier）。比如说，如果年份数据栏中出现了类似 'missing' 或 'NA' 这样的字符串，我们就说出现了类型离群值。如果只有几个数据的类型不匹配的话，我们可能需要处理离群值或几个不良数据。如果大部分数据的类型都不匹配的话，我们可能要重新思考是否要使用这些数据，或者找出这些数据看似“不良数据“的原因。

如果很容易就可以解释这些不一致的原因（比如这个回答只适用于女性，而调查样本中男女都有），那么我们就可以用这些数据。如果这些不一致没有明确的解释，而这个问题对我们的结果又很重要，我们需要继续研究当前数据集，或开始寻找能够对不一致作出解释的其他数据集。

在第 9 章中我们会讲到寻找离群值的更多内容，但现在我们来分析一下数据类型，看能否在当前数据集中找出明显的不一致之处。例如，我们应该检查一下，那些应该以数字作答的数据类型（比如出生年份）是否正确。

我们来看一下回答的类型分布。我们将用到前面计算 NA 回答数目的部分代码，但这次而我们要计算数据类型的数目：

datatypes = {} ➊

start_dict = {'digit': 0, 'boolean': 0,
              'empty': 0, 'time_related': 0,
              'text': 0, 'unknown': 0
              } ➋

for row in zipped_data:
    for resp in row:
        question = resp[0][1]
        answer = resp[1]
        key = 'unknown' ➌
        if answer.isdigit(): ➍
            key = 'digit'
        elif answer in ['Yes', 'No', 'True', 'False']: ➎
            key = 'boolean'
        elif answer.isspace(): ➏
            key = 'empty'
        elif answer.find('/') > 0 or answer.find(':') > 0: ➐
            key = 'time_related'
        elif answer.isalpha(): ➑
            key = 'text'
        if question not in datatypes.keys(): ➒
            datatypes[question] = start_dict.copy() ➓

        datatypes[question][key] += 1

print datatypes

❶ 第一行代码初始化一个字典，因为用字典保存问题数据是一种快速、可靠的方法。

❷ 本行代码创建一个 start_dict 字典，用于检查数据集中每一个问题的数据类型是否相同。字典中包含所有可能的数据类型，方便我们对比。

❸ 这里我们将变量 key 设置为默认值 unknown。如果变量 key 在下面的 if 或 elif 语句中没有被修改的话，它的值还是 unknown。

❹ Python 字符串类有许多判断数据类型的方法。这里我们用的是 isdigit 方法：如果字符串中只包含数字，本行代码返回 True。

❺ 要判断数据是否与布尔逻辑相关，我们这里测试回答是否包含在一个由布尔回答组成的列表中，列表中包括 Yes/No 和 True/False。虽然我们可以创建一个更加全面的列表，但现在用这个列表就足够了。

❻ 如果字符串中只包含空格，Python 字符串类的 isspace 方法将返回 True。

❼ 字符串的 find 方法返回第一个匹配结果的索引编号。如果在字符串中没有找到匹配，则返回 -1。本行代码同时测试 / 和 :，这是时间字符串中的两个常用符号。这个检查并不全面，但可以作为初步检查。

❽ 如果字符串中只包含字母，字符串的 isalpha 方法将返回 True。

❾ 与计算 NA 回答数目的代码类似，我们这里判断问题是否包含在 datatypes 字典的键中。

❿ 如果问题没有包含在 datatypes 字典中，本行代码将问题添加到字典中，并将 start_dict 的副本作为对应的值。字典的 copy 方法为每一条数据创建一个独立的字典对象。如果将 start_dict 作为每一个问题对应的值，我们将会得到包含所有总数的一个字典，而不是每一个问题都对应一个新字典。

⓫ 将我们找到的键对应的值加 1。这样对于每一个问题和回答，我们对数据类型有了一个“猜测”。

从代码运行结果中已经可以发现不一致之处！有些问题以一种“类型”的回答为主，而其他问题则有许多种回答类型的猜测。我们可以从这里继续，因为它们只是粗略的猜测。

利用这一新信息的一种方法是，找到回答中大部分都是数字类型的问题，观察那些非数字的回答，看它们的值都是什么。我们认为可能会是 NA 或错误插入的值。如果这些值与我们关心的问题有关，我们可以将其归一化。一种做法是将 NA 或错误值替换为 None 或空值。如果你需要对列数据做统计分析的话，这一方法是很有用的。

　在对数据集的后续处理过程中，你还会发现数据类型的离群值或 NA 回答。处理这些不一致数据的最佳做法取决于你对该话题和数据集的熟悉程度，也取决于你想要回答的问题。如果你要合并数据集，有时你可以舍弃那些离群值和不良数据，但注意不要忽视微小的趋势。

现在我们已经初步找出了数据集中的离群值及其规律，下面我们继续清除另一种不良数据——重复值，即使是我们自己也可能会创建重复值。

7.2.4　找出重复值

如果你要处理的是同一调查数据的多个数据集，或者是可能包含重复值的原始数据，删除重复数据是确保数据准确可用的重要步骤。如果你的数据集有唯一标识符，你可以利用这些 ID，确保没有误插入重复数据或获取重复数据。如果你的数据集没有索引，你可能需要找到判断数据唯一性的好方法（例如创建一个可索引的键）。

Python 内置库中有几个判断数据唯一性的好方法。我们首先介绍一些概念：

list_with_dupes = [1, 5, 6, 2, 5, 6, 8, 3, 8, 3, 3, 7, 9]

set_without_dupes = set(list_with_dupes)

print set_without_dupes

输出应该是这样的：

{1, 5, 6, 2, 6, 3, 6, 7, 3, 7, 9,}

这里发生了什么？集合（set）和 frozenset 都是 Python 的内置类型，输入一个可迭代对象（比如列表、字符串或元组），返回一个包含唯一值的集合。

　要使用集合和 frozenset，输入的值需要是可哈希的（hashable）。对于可哈希的数据类型，我们可以使用哈希方法，得到的结果总是相同的。例如，我们可以认为代码中的每一个 3 都是完全相同的。

大部分 Python 对象都是可哈希的——只有列表和字典不是。对于任意可哈希类型（整数、浮点数、小数、字符串、元组等），我们可以用 set 创建集合。集合和 frozenset 的另一个巧妙之处在于，它们有一些可以快速比较的属性。我们来看几个例子：

first_set = set([1, 5, 6, 2, 6, 3, 6, 7, 3, 7, 9, 10, 321, 54, 654, 432])

second_set = set([4, 6, 7, 432, 6, 7, 4, 9, 0])

print first_set.intersection(second_set) ➊

print first_set.union(second_set) ➋

print first_set.difference(second_set) ➌

print second_set - first_set ➍

print 6 in second_set ➎

print 0 in first_set

❶ 集合的 intersection 方法返回两个集合的交集（即两个集合共有的元素）。内置的维恩图哦！

❷ 集合的 union 方法将第一个集合的值与第二个集合的值合并在一起。

❸ difference 方法给出的是第一个集合和第二个集合的差集。从下一行代码可以看出，运算顺序很重要。

❹ 用一个集合去减另一个集合，得出二者的差集。改变集合的顺序会改变结果（与数学中的减法类似）。

❺ in 判断元素是否包含在集合中（速度很快）。

你的输出应该是像这样的：

set([432, 9, 6, 7])
set([0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 321, 432, 654, 54])
set([1, 2, 3, 5, 321, 10, 654, 54])
set([0, 4])
True
False

在定义唯一数据集和集合对比方面，集合有许多实用的特性。在数据处理过程中，我们经常需要计算一系列值的最大值和最小值，或者需要唯一键的合并。集合可以帮我们完成这些任务。

除了集合，Python 还有一些库可以轻松测试唯一性。你可以用 numpy 库来测试唯一性，这是 Python 中一个强大的数学库，包含很多科学和统计的方法和类。与 Python 核心库相比， numpy 拥有出色的数组功能、数值计算功能和数学功能。numpy 数组还有一个好用的方法，叫作 unique。你可以这样安装 numpy：

pip install numpy

我们来看一下 numpy 库的 unique 的工作原理：

import numpy as np

list_with_dupes = [1, 5, 6, 2, 5, 6, 8, 3, 8, 3, 3, 7, 9]

print np.unique(list_with_dupes, return_index=True) ➊

array_with_dupes = np.array([[1, 5, 7, 3, 9, 11, 23], [2, 4, 6, 8, 2, 8, 4]]) ➋

print np.unique(array_with_dupes) ➌

❶ numpy 库的 unique 方法会保存索引编号。设置 return_index=True，返回的是由数组组成的元组：第一个是唯一值组成的数组，第二个是由索引编号组成的扁平化数组——只包含每一个数字第一次出现时的索引编号。

❷ 为了展示 numpy 的更多功能，本行代码创建了一个 numpy 矩阵。矩阵是由（长度相同的）数组组成的数组。

❸ unique 将矩阵转换成由唯一值组成的集合。

你的输出是这样的：

(array([1, 2, 3, 5, 6, 7, 8, 9]), array([ 0,  3,  7,  1, 2, 11,  6, 12]))
[ 1  2  3  4  5  6  7  8  9 11 23]

如果没有唯一键，你可以编写函数来创建唯一集合。写法和列表生成式一样简单。用 Python 集合在我们的数据集上试验一下。首先，我们观察数据集中哪些数据是唯一的，然后找出唯一数：

for x in enumerate(zipped_data[0]):
    print x

.....

(0, (['HH1', 'Cluster number', ''], '1'))
(1, (['HH2', 'Household number', ''], '17'))
(2, (['LN', 'Line number', ''], '1'))
(3, (['MWM1', 'Cluster number', ''], '1'))
(4, (['MWM2', 'Household number', ''], '17'))
(5, (['MWM4', "Man's line number", ''], '1'))

可以看出，每一行前五个元素可能包含了唯一标识符。假设我们对数据的理解是正确的，那么类群编号（Cluster number）、家庭编号（Household number）和男性家庭成员编号（Man's line number）三者应该是一个唯一组合。家庭成员编号（Line number）可能也是唯一编号。我们来看一下这是否正确：

set_of_lines = set([x[2][1] for x in zipped_data]) ➊

uniques = [x for x in zipped_data if not set_of_lines.remove(x[2][1])] ➋

print set_of_lines

❶ 首先，我们创建一个集合，里面包含调查中的所有家庭成员编号。家庭成员编号是每一个回答中的第三个元素，而编号值是里面第二个元素（x[2][1]）。我们使用列表生成式来提高代码的运行速度。

❷ set_of_lines 现在保存的是唯一键。我们可以利用集合对象的 remove 方法，判断数据集中每个键出现的次数是否多于一次。如果家庭成员编号是唯一的，那么每一个键只会删除一次。如果有重复值，remove 将会引发 KeyError，说明这个键已经不在集合中了。

嗯。运行代码时的确出现了错误，所以我们关于家庭成员编号是唯一的假设是错误的。如果仔细观察我们创建的集合，家庭成员编号似乎是从 1 到 16，然后依次重复。

　你经常需要处理混乱的数据集，或者类似上面的数据集，没有明确的唯一键。遇到这种情况我们的建议是，找到唯一键，然后用这个唯一键来做对比。

创建唯一键的方法有很多。我们可以用采访的开始时间作为唯一键。但我们不确定 UNICEF 是否同时安排了多个调查组。如果是的话，我们可能会将事实上不是重复值的元素当作重复值删掉。我们可以用被采访人的出生日期和采访时间一起做唯一键，这样不太可能有重复值，但如果有字段缺失的话就麻烦了。

一种优雅的解决方法是，检查类群编号、家庭编号和家庭成员编号三者是否构成唯一键。如果是的话，我们可以将这个方法应用到整个数据集上——即使没有采访起止时间也可以。我们来试一下！

set_of_keys = set([
    '%s-%s-%s' % (x[0][1], x[1][1], x[2][1]) for x in zipped_data]) ➊

uniques = [x for x in zipped_data if not set_of_keys.remove(
    '%s-%s-%s' % (x[0][1], x[1][1], x[2][1]))] ➋

print len(set_of_keys) ➌

❶ 利用类群编号、家庭编号和家庭成员编号创建一个字符串，我们认为这三个编号的组合是唯一的。我们将三个编号用 - 隔开，这样方便区分。

❷ 利用 remove 方法重新创建我们用到的唯一键。这样会一个个删除所有数据，uniques 列表包含每一个唯一数据。如果有重复数据的话，代码还会抛出错误。

❸ 计算唯一键列表的长度。我们可以知道数据集中有多少个唯一值。

太好了！这一次没有报错。从列表的长度中可以看出，每一行都是唯一的。这也符合我们对这个数据集的预期，因为 UNICEF 在发布数据之前会做一些数据清洗工作，确保没有重复值。如果我们要将这些数据与其他 UNICEF 数据合并的话，我们可能需要将 M 添加到唯一键中，因为这是男性组的调查。然后我们可以对相同编号的家庭做交叉对照。

唯一键可能并不容易发现，这取决于你所用的数据。出生日期和地址可能是一个好的唯一键组合。两个 24 岁女性住在同一地方，出生日期又恰好相同的可能性是很小的，虽然也不是完全不可能，比如她们是住在一起的双胞胎！

讲完重复值，下面我们来讲模糊匹配，这是寻找重复值的好方法，尤其是杂乱的数据集。

7.2.5　模糊匹配

如果你要处理不止一个数据集，或者是未标准化的脏数据，可以用模糊匹配来寻找和合并重复值。模糊匹配可以判断两个元素（通常是字符串）是否“相同”。模糊匹配并不像自然语言处理或机器学习在处理大型语言数据集时那么深入，它可以帮我们判断“My dog & I”和“me and my dog”的意思相近。

模糊匹配有很多种做法。一个由 SeatGeek（http://chairnerd.seatgeek.com/fuzzywuzzy-fuzzy-string-matching-in-python/）开发的 Python 库，使用很酷的内置方法来匹配多种场景的线上售票。安装方法如下：

pip install fuzzywuzzy

比如说你要处理一些脏数据。可能是输入时粗心，也可能是用户输入的，导致数据中包含拼写错误和较小的语法错误或句法偏移。你要怎么处理这种情况呢？

from fuzzywuzzy import fuzz

my_records = [{'favorite_book': 'Grapes of Wrath',
               'favorite_movie': 'Free Willie',
               'favorite_show': 'Two Broke Girls',
              },
              {'favorite_book': 'The Grapes of Wrath',
               'favorite_movie': 'Free Willy',
               'favorite_show': '2 Broke Girls',
              }]

print fuzz.ratio(my_records[0].get('favorite_book'),
                 my_records[1].get('favorite_book')) ➊

print fuzz.ratio(my_records[0].get('favorite_movie'),
                 my_records[1].get('favorite_movie'))

print fuzz.ratio(my_records[0].get('favorite_show'),
                 my_records[1].get('favorite_show'))

➊ 这里我们用的是 fuzz 模块的 ratio 函数，接受两个字符串作比较。返回的是两个字符串序列的相似程度（一个介于 1 和 100 之间的值）。

根据我们自己对流行文化和英语的理解，这两组数据的爱好是相同的，但是二者拼写却不同。FuzzyWuzzy 可以帮我们处理这些无心之错。可以看出，ratio 的匹配得分相当高。这给了我们一些信心，相信这两个字符串是相似的。

我们再尝试另一个 FuzzyWuzzy 方法，看结果如何。为了简化问题并方便对比，我们采用的是相同的数据：

print fuzz.partial_ratio(my_records[0].get('favorite_book'),
                         my_records[1].get('favorite_book')) ➊

print fuzz.partial_ratio(my_records[0].get('favorite_movie'),
                         my_records[1].get('favorite_movie'))

print fuzz.partial_ratio(my_records[0].get('favorite_show'),
                         my_records[1].get('favorite_show'))

➊ 这里我们调用的是 fuzz 模块的 partial_ratio 函数，接受两个字符串作比较。返回的是匹配程度最高的子字符串序列的相似程度（一个介于 1 和 100 之间的值）。

哇，我们的得分更高了！ partial_ratio 函数可以比较子字符串，这样我们就不必担心某人漏掉一个字母（像上面说的例子）或拼写不同。这样所有字符串的匹配度都会更高。

　如果你的数据有一些简单的不一致之处，这些函数都可以帮你找到不匹配的地方。但如果你的数据只有几个字符的差别，但在含义上却有很大不同，你可能想要同时测试相似性和不同之处。例如“does”和“doesn't”的含义截然不同，但在拼写上差别不大。在第一个 ratio 例子中，这两个字符串的得分不高，但子字符串却可以匹配。一定要了解你的数据及其内部的复杂性！

FuzzyWuzzy 还有其他很酷的功能。我们来研究其中几个，它们可能适用于你的数据清洗任务：

from fuzzywuzzy import fuzz

my_records = [{'favorite_food': 'cheeseburgers with bacon',
               'favorite_drink': 'wine, beer, and tequila',
               'favorite_dessert': 'cheese or cake',
              },
              {'favorite_food': 'burgers with cheese and bacon',
               'favorite_drink': 'beer, wine, and tequila',
               'favorite_dessert': 'cheese cake',
              }]

print fuzz.token_sort_ratio(my_records[0].get('favorite_food'), ➊
                            my_records[1].get('favorite_food'))

print fuzz.token_sort_ratio(my_records[0].get('favorite_drink'),
                            my_records[1].get('favorite_drink'))

print fuzz.token_sort_ratio(my_records[0].get('favorite_dessert'),
                            my_records[1].get('favorite_dessert'))

➊ 这里我们调用的是 fuzz 模块的 token_sort_ratio 函数，在匹配字符串时不考虑单词顺序。对于格式不限的调查数据来说，这个方法是很好用的，比如“I like dogs and cats”和“I like cats and dogs”的含义相同。每个字符串都是先排序然后再比较，所以如果包含相同的单词但顺序不同，也是可以匹配的。

从输出中可以看出，使用标记（这里是单词）有相当大的可能匹配顺序不同的单词。我们发现二者最爱的饮料相同，只是顺序不同。如果标记的顺序不会改变含义，我们就可以用这个方法。对于 SeatGeek 来说，“Pittsburgh Steelers vs. New England Patriots”与“New England Patriots vs. Pittsburgh Steelers”是完全相同的（只是主场优势不同）。

利用相同的数据，我们来看 FuzzyWuzzy 中另一个与标记有关的函数：

print fuzz.token_set_ratio(my_records[0].get('favorite_food'), ➊
                           my_records[1].get('favorite_food'))

print fuzz.token_set_ratio(my_records[0].get('favorite_drink'),
                           my_records[1].get('favorite_drink'))

print fuzz.token_set_ratio(my_records[0].get('favorite_dessert'),
                           my_records[1].get('favorite_dessert'))

➊ 这里我们调用的是 fuzz 模块的 token_set_ratio 函数，同样用的是标记方法，但比较的是标记组成的集合，得出两个集合的交集和差集。这个函数对排序后的标记尝试寻找最佳匹配，返回这些标记相似的比例。

这里可以看出，如果我们不知道数据集中的相似和不同之处，可能会有意想不到的副作用。其中一个答案的拼写是错误的。我们知道芝士蛋糕（cheesecake）和奶酪（cheese）是不同的东西，但利用标记集合方法，这两者却错误地匹配（false positive）了。我们没能正确匹配包含芝士汉堡（cheeseburger）的回答，即使二者是完全相同的。你能用我们已经学过的另一个方法来做到这一点么？

FuzzyWuzzy 提供的最后一个匹配方法是 process 模块。如果你只有有限的几个选项和杂乱的数据，这个模块是很有用的。比如说回答只有 yes、no、maybe 和 decline to comment 四种。我们看一下如何对其匹配：

from fuzzywuzzy import process

choices = ['Yes', 'No', 'Maybe', 'N/A']

process.extract('ya', choices, limit=2) ➊

process.extractOne('ya', choices) ➋

process.extract('nope', choices, limit=2)

process.extractOne('nope', choices)

❶ 利用 FuzzyWuzzy 的 extract 方法，将字符串与可能匹配的列表依次比较。函数返回的是 choices 列表中两个可能的匹配。

❷ 利用 FuzzyWuzzy 的 extractOne 方法，返回 choices 列表中与我们的字符串对应的最佳匹配。

啊哈！给定几个单词，我们事先知道其“含义”相同，process 可以找出最佳猜测——在上面的例子中也是正确的猜测。extract 返回的是带有比例的元组，代码对回答字符串进行解析，并对其相似之处和不同之处作比较。extractOne 函数仅返回最佳匹配及其比例组成的元组。根据需求的不同，你可以选择 extractOne 仅找出最佳匹配，然后继续下一步。

现在你已经学过所有字符串匹配的内容了，下面我们来学习如何自己编写类似的字符串匹配函数。

7.2.6　正则表达式匹配

模糊匹配不一定总能满足你的需求。如果你只需要匹配字符串的一部分，应该怎么办？如果你只想匹配电话号码或电子邮件地址呢？在抓取数据时（我们将在第 11 章中学习），或编译多个来源的原始数据时，这些都是你会遇到的问题。正则表达式可以帮你解决上述大部分问题。

利用正则表达式，计算机可以对代码中的字符串或数据的模式进行匹配、查找或删除。开发人员往往对正则表达式怀有恐惧之心，因为它们可能会变得异常复杂、难以理解。但它们是很有用的，当你需要正则表达式来帮你解决问题时，一些简单的基础知识就可以帮你阅读、编写和理解它们。

虽然正则表达式的名声不太好，但其基本语法是相当简单易学的。表 7-1 给出了正则表达式的基础知识。

表7-1：正则表达式基础知识

字符/模式

文字说明

匹配实例

\w

匹配任意一个字母字符或数字字符，包括下划线

a、0 或 _

\d

匹配任意一个数字

1、2 或 4

\s

匹配任意一个空格字符

' '

+

匹配一个或多个（贪婪）模式或字符

\d+ 可以匹配 476373

\.

匹配 . 字符

.

*

匹配零个或多个（贪婪）字符或模式（与 if 的作用几乎相同）

\d* 可以匹配 03289 和 ''

|

匹配多个模式中的一个（类似 OR）

\d|\w 可以匹配 0 或 a

[] 或 ()

字符类（将你希望匹配的字符放在一个字符空间里）和字符组（将你希望匹配的字符放在一个组里）

[A-C] 或 (A|B|C) 都可以匹配 A

-

合并字符组

[0-9]+ 匹配 \d+

想查看更多实例，我们推荐将这个优秀的正则表达式备忘单（http://www.virtu-al.net/2009/04/30/powershell-regex-cheat-sheet/）添加到书签里。

　作为一名 Python 开发人员，没有必要记住正则表达式的语法，但语法规范的正则表达式可以在很多方面帮到你。利用 Python 内置的正则表达式模块 re，你可以轻松查找基本的匹配和分组方法。

我们来看一下正则表达式的几个用法：

import re

word = '\w+' ➊
sentence = 'Here is my sentence.'

re.findall(word, sentence) ➋

search_result = re.search(word, sentence) ➌

search_result.group() ➍

match_result = re.match(word, sentence) ➎

match_result.group()

❶ 定义一个普通字符串的基本模式。这个模式可以匹配包含字母和数字、但不包含空格和标点的字符串。这个模式会一直匹配，直到无法匹配为止（+ 表示贪婪匹配！）。

❷ re 模块的 findall 方法可以找出这个模式在字符串中的所有匹配。成功匹配了句子中的每一个单词，但没有匹配句号。在这个例子中我们用的模式是 \w，所以不会匹配标点和空格。

❸ search 方法可以在整个字符串中搜索匹配。发现匹配后，则返回匹配对象。

❹ 匹配对象的 group 方法会返回匹配的字符串。

❺ match 方法只从字符串开头开始搜索。它的工作原理与 search 不同。

我们可以轻松匹配句子中的单词，根据我们的需要，我们还可以改变寻找匹配的方式。在上面的例子中我们看到，findall 返回的是所有匹配组成的列表。比如说你只想提取长文本中的网站。你可以利用正则表达式模式找到链接，然后利用 findall 从文本中提取出所有链接。或者你可以查找电话号码或日期。如果你能够将想要寻找的内容转换成简单的模式，并将其应用到字符串数据上，你就可以使用 findall 方法。

我们还用到了 search 和 match 方法，在上面的例子中二者返回的结果相同——它们匹配的都是句子中的第一个单词。我们返回的是一个匹配对象，然后可以利用 group 方法获取数据。group 方法还可以接受参数。用 .group(0) 试一下。发生了什么？你觉得 0 是什么意思？（提示：想想列表！）

search 和 match 实际上大不相同。我们再多看几个例子，才能发现二者的不同之处：

import re

number = '\d+' ➊
capitalized_word = '[A-Z]\w+' ➋

sentence = 'I have 2 pets: Bear and Bunny.'

search_number = re.search(number, sentence)

search_number.group() ➌

match_number = re.match(number, sentence)

match_number.group() ➍

search_capital = re.search(capitalized_word, sentence)

search_capital.group()

match_capital = re.match(capitalized_word, sentence)

match_capital.group()

❶ 定义一个数字模式。加号表示贪婪匹配，所以它会尽可能匹配所有数字，直到遇到一个非数字字符为止。

❷ 定义一个大写单词的匹配。这个模式使用方括号来定义更长模式的一部分。方括号的意思是，我们希望第一个字母是大写字母。后面紧跟着的是一个连续的单词。

❸ 我们这里调用 group 时发生了什么？可以看到，search 方法返回的是匹配对象。

❹ 你认为这里的结果会是什么？可能是数字，但实际上出现了错误。match 返回的是 None，而不是匹配对象。

现在我们可以更清楚地看到 search 和 match 的区别。利用 match 我们无法找到一个好的匹配，尽管事实上我们尝试的每一次搜索都有许多匹配。为什么会这样？前面说过，match 从字符串的开头开始搜索，如果没有找到匹配，它会返回 None。与此相反，search 会继续向后搜索，直到找到匹配为止。只有到达字符串末尾还没有找到匹配时，search 才会返回 None。如果你需要匹配以特定模式开头的字符串，用 match 比较好。如果你只想在字符串中找到第一个匹配或任意匹配，最好选择 search。

关于正则表达式，这里还有一点需要注意：你注意到了吗？你预期找到的第一个大写单词是什么？是“I”还是“Bear”？为什么我们没有找到“I”？什么模式能够同时匹配二者？（提示：参考上面的表格，看看你都能使用哪些通配符变量！）

现在我们更多地了解了正则表达式的语法，以及 match、search 和 findall 的用法。下面我们看一下能否创建可以匹配多组的模式。在上面的例子中，我们只有一个模式组，所以我们对匹配结果调用 group 方法，只得到一个结果。但利用正则表达式你可以找到不止一个模式，你还可以给找到的匹配组起一个变量名，这样可以提高代码的可读性，还可以确保每组匹配正确。

让我们来试一下！

import re

name_regex = '([A-Z]\w+) ([A-Z]\w+)' ➊

names = "Barack Obama, Ronald Reagan, Nancy Drew"

name_match = re.match(name_regex, names) ➋

name_match.group()

name_match.groups() ➌

name_regex = '(?P<first_name>[A-Z]\w+) (?P<last_name>[A-Z]\w+)' ➍

for name in re.finditer(name_regex, names): ➎
    print 'Meet {}!'.format(name.group('first_name')) ➏

❶ 这里我们用的是相同的大写单词语法，用了两次，分别放在括号里。括号的作用是分组。

❷ 这里我们在 match 方法里用的模式包含多个正则表达式组。如果找到匹配的话，将返回多个匹配组。

❸ 对匹配结果调用 groups 方法，返回找到的所有匹配组构成的列表。

❹ 为各组命名可以让代码清晰明确。在这个模式中，第一组叫 first_name，第二组叫 last_name。

❺ finditer 的作用与 findall 类似，但返回的是一个迭代器（iterator）。利用这个迭代器，我们可以逐个查看字符串中的匹配。

❻ 利用我们学过的字符串格式化的知识，打印出我们的数据。这里我们从每个匹配中仅提取名字（first name）。

利用 ?P<variable_name> 为模式组命名，这样写出的代码更容易理解。从上面的例子中可以看出，创建两个（或多个）特定模式构成的组并找到匹配数据也是相当容易的。有了这些方法，在阅读别人写的（或者你六个月前写的）正则表达式时就不用费力猜测了。你能自己写一个正则表达式实例来匹配中间名缩写吗（如果有的话）？

利用强大的正则表达式，你可以快速识别字符串的内容，并轻松解析字符串中的数据。在解析特别杂乱的数据集时，比如网络抓取的数据，正则表达式的作用是无可替代的。想阅读更多正则表达式的内容，我们推荐在 RegExr 网站（http://www.regexr.com/）上试用交互式正则表达式解析器，还可以通读免费的正则表达式教程（http://www.regular-expressions.info/tutorial.html）。

前面学过了这么多匹配方法，现在你可以轻松找出重复值。下面我们来看一下，在我们的数据集中遇到重复值时可以有哪些做法。

7.2.7　如何处理重复记录

根据你的数据状态，你可能希望合并重复记录。如果你的数据集只有重复行，那就无需担心数据存储的问题，这些数据已经包含在最终数据集内，你只需在清洗后的数据中删除或舍弃这些行即可。但如果你要合并不同的数据集，并希望保存每一条重复数据，那你需要思考用 Python 实现的最佳做法。

在第 9 章我们将会讲到合并数据的一般性方法，用到一些新库。但合并数据行还有简单的方法，和你解析数据的方法相同。如果你用 DictReader 提取数据的话，我们用一个例子来讲解这个方法。我们将合并男性数据集的一些数据行。这次我们希望基于家庭来合并数据，所以我们关注的是每一家的调查，而不是每个人的调查：

from csv import DictReader

mn_data_rdr = DictReader(open('data/unicef/mn.csv', 'rb')) ➊

mn_data = [d for d in mn_data_rdr]

def combine_data_dict(data_rows): ➋
    data_dict = {} ➌
    for row in data_rows:
        key = '%s-%s' % (row.get('HH1'), row.get('HH2')) ➍
        if key in data_dict.keys():
            data_dict[key].append(row) ➎
        else:
            data_dict[key] = [row] ➏
    return data_dict ➐

mn_dict = combine_data_dict(mn_data) ➑

print len(mn_dict)

❶ 我们用到 DictReader 模块，方便解析我们想要的所有字段。

❷ 我们定义了一个函数，还可以用在其他 UNICEF 数据集上。我们之所以将函数命名为 combine_data_dict，是因为函数的作用是将 data_rows 合并，然后返回一个字典。

❸ 定义一个新的数据字典，用于函数的返回值。

❹ 在前面的例子中我们利用类群编号、家庭编号和家庭成员编号创建一个唯一键，与之类似，本行代码也创建一个唯一键。“HH1”代表类群编号，“HH2”代表家庭编号。本行代码用这两个编号来表示唯一家庭。

❺ 如果已经添加过这个家庭，本行代码将当前数据行添加到数据列表中。

❻ 如果这个家庭尚未添加，本行代码新建一个列表，列表元素为当前数据行。

❼ 在函数末尾，返回新的数据字典。

❽ 现在传入数据行运行该函数，并将新生成的字典赋值给一个变量，以供后续使用。本行代码将新生成的字典命名为 mn_dict，我们可以利用这个字典来查看有多少个唯一家庭，以及每个家庭分别做了多少份调查。

　如果函数结尾没有 return 的话，函数将会返回 None。在你开始编写自己的函数时，一定要注意返回值的错误。

我们找到了约 7000 个唯一家庭，这说明采访中有 2000 多的男性与其他男性属于同一家庭。本次采访每个家庭平均有 1.3 个男性。像这样的简单计算可以让我们对数据有更深入的了解，还可以帮我们思考数据的含义，并发现基于现有数据我们可以回答哪些问题。

7.3　小结

本章你学习了数据清洗的基础知识，以及在数据处理过程中数据清洗的重要性。你用到了一些 MICS 原始数据，并直接与这些数据进行了交互。现在你学会了观察数据，并能判断你可能会遇到的数据清洗问题。现在你还可以找到并删除错误数据和重复数据。

表 7-2 中详细描述了本章讲到的新概念和新库。

表7-2：Python编程的新概念和新库

概念/库

作用

列表生成式

利用迭代器、函数和 / 或 if 语句，可以方便快速地创建列表，用于进一步清洗和处理数据

字典的 values 方法

返回由字典的值组成的列表。在测试内部元素时很有用

in 和 not in 语句

测试内部元素。通常用于字符串或列表。返回一个布尔值

列表的 remove 方法

传入一个元素，删除列表中第一个匹配的元素。对于一个创建好的列表，如果你确切知道要删除的元素是什么，这个方法很有用

enumerate 方法

传入任意可迭代对象，返回一个由索引编号和对应的值组成的元组

列表的 index 方法

传入一个元素，返回列表中第一个匹配对象的索引编号。如果没有匹配的话，返回 None

字符串的 format 方法

可以将一系列数据轻松转换成易读的字符串。用 {} 作为数据占位符，传入数据的数量应与其数量相同。还可以用于字典的键名。有许多字符串格式化方法可用

字符串格式化（.4f、.2%）

数学标记（flag），可以将数字转换成简单易读的字符串

datetime 库的 strptime 和 strftime 方法

可以将 Python 日期对象轻松转换成字符串，以及将字符串转换成日期对象

datetime 库的 timedelta 对象

表示两个 Python 日期对象之间的时间差，或者用于修改日期对象（例如增加时间或减少时间）

if not 语句

测试其后语句的值是否非 True，与 if 语句的布尔逻辑相反

is 语句

测试第一个对象和第二个对象是否相同。在类型测试方面很有用（例如：is None、is list）。更多关于 is 的内容可参阅附录 E

字符串的 isdigit 和 isalpha 方法

测试字符串对象是否只包含数字或只包含字母。返回一个布尔值

字符串的 find 方法

传入一个子字符串，返回它在字符串中的索引位置。如果没有找到匹配，则返回 -1

Python 集合对象（https://docs.python.org/2/library/sets.html）

唯一元素的集合类。与列表的用法很相似，但没有重复值。有许多集合对比的方法（union、intersection、difference）

numpy 包（http://www.numpy.org/）

Python 基本数学库，是 SciPy 栈的一部分

FuzzyWuzzy 库（https://github.com/seatgeek/fuzzywuzzy）

用于字符串模糊匹配的库

正则表达式（https://en.wikipedia.org/wiki/Regular_expression）和 Python 的 re 库（https://docs.python.org/2/library/re.html）

可以用来编写模式并在字符串中寻找匹配

在下一章里，你还会继续练习这些数据清洗和数据分析技术，并利用这些技术更好地安排和重复数据清洗任务。我们会讲到数据归一化和标准化，以及如何将数据清洗脚本化并对脚本进行测试。

第 8 章　数据清洗：标准化和脚本化

你已经学习了数据的匹配和解析方法，以及如何寻找重复值，你已经开始探索数据清洗的奇妙世界。随着进一步理解你的数据集和你想要回答的问题，你需要考虑数据标准化和清洗自动化的问题。

本章我们将探索数据标准化的方法和时机，以及何时将数据清洗脚本化并对脚本进行测试。如果你管理的数据集是定期更新或新增数据的话，你需要使清洗过程尽可能高效清楚，这样你就可以将更多时间花在数据分析和撰写报告上。我们首先讲数据集的标准化（standardizing）和归一化（normalizing），以及如果数据集没有归一化应该怎么做。

8.1　数据归一化和标准化

数据集的标准化和归一化可能意味着利用当前数据计算新数据，也可能是对特定列或特定数据进行标准化或归一化，这取决于你的数据和所从事的研究类型。

从统计学的观点来看，归一化通常需要对数据集进行计算，使数据都位于一个特定的范围。比如说，你可能需要将测验成绩归一化到一定范围，这样你就可以准确查看成绩分布。你可能还需要对数据做归一化，以便准确查看百分位数，或不同群体（或世代）之间的百分位数。

假设你想查看某队在给定赛季得分的分布情况。你可能首先会将比赛分为赢、输、平三种情况。然后再进一步分为赢多少分、输多少分，等等。你还可以按比赛时长和每分钟得分数来分类。你可以访问所有这些数据集，现在你希望在球队之间进行对比。如果要对数据归一化，你可能会将总得分归一化到 0-1 区间。离群值（最高得分）将会接近于 1，较低得分将会接近于 0。然后你可以利用新数据的分布情况，查看有多少支球队的得分位于中游，在低分和高分区间是否有很多球队。你还可以找出离群值（比方说，如果大多数得分都在 0.3 和 0.4 之间，那么你就知道，没在这个范围内的得分可能就是离群值）。

如果想对同样的数据做标准化，应该怎么做呢？举个例子，你可以将数据标准化，计算出每分钟的平均得分。然后你可以将平均得分作图，查看分布情况。哪些球队每分钟得分较高？有没有离群值？

你还可以计算标准差来查看分布情况。在第 9 章中我们会更全面地介绍标准化，但主要问题就是：数据的正常范围是什么？这个范围之外都有哪些数据？数据有没有什么规律？

可以看出，归一化和标准化是不同的。但二者通常都可以让研究人员或调查人员确定数据的分布，并明白该分布对后续研究或计算的含义。

数据标准化和归一化有时还需要删除离群值，这样你才能更好地发现数据的规律和分布。回头看前面的球队例子，如果你从整个联赛中删除顶级得分球员的得分，球队的成绩是否发生了巨大的变化？如果一名球员得到了所在球队一半的得分，那么回答是“是的”，这会使球队成绩发生巨大的变化。

与此类似，如果某支球队总是大比分胜出，从联赛数据中剔除这支队伍，可能会大幅改变平均得分及其分布情况。你可以使用归一化、标准化和剔除离群值的方法来帮你找到问题的答案，这取决于你要解决的问题。

8.2　数据存储

我们已经讲过几种数据存储的方法，现在有了可用的数据，我们先来复习一下这些方法。如果你正在使用数据库、知道预期的表格格式，并想要保存已经清洗过的数据，那么你应该继续使用第 6 章讲过的 Python 库来连接数据库并保存数据。对于这些 Python 库中的大部分库，你都可以使用游标直接向数据库提交。

　我们强烈建议在数据库脚本中添加错误信息，在遇到网络故障或数据库故障时可以捕获这些错误信息。我们建议频繁向数据库提交，这样可以避免网络问题或延迟问题影响脚本的运行。

如果你用的是第 6 章中讲过的 SQLite 例子，你需要将新的干净数据保存到你的数据库中。我们来看一下如何做到这一点：

    import dataset

    db = dataset.connect('sqlite:///data_wrangling.db') ➊

    table = db['unicef_survey'] ➋

    for row_num, data in enumerate(zipped_data): ➌
        for question, answer in data: ➍
            data_dict = { ➎
                'question': question[1], ➏
                'question_code': question[0],
                'answer': answer,
                'response_number': row_num, ➐
            'survey': 'mn',
        }

    table.insert(data_dict) ➑

❶ 这里我们访问本地数据库。如果你将文件保存到其他目录，一定要修改文件路径，将其修改为数据库文件相对于当前目录的位置（例如，如果数据库文件保存在上层目录中：file:///../datawrangling.db）。

❷ 本行代码创建一个新表：unicef_data。我们知道很多 UNICEF 调查都有相同的规律，所以我们这个数据库名是没有歧义、可复用的。

❸ 我们希望保存所在的行编号，这样每个回答都有一个编号。本行代码用到了 enumerate 函数，这样在数据库中可以找到（每一行 / 每一个回答的）每一条数据（它们的共用一个行编号）。

❹ 我们知道，我们的数据被分割成元组，标题列表是元组的第一个元素，问题回答是元组的第二个元素。本行代码利用 for 循环解析其中包含的数据并进行存储。

❺ 每一个问题和回答在数据库中都有对应的条目，所以我们可以将每行（即每次采访）所有的回答合并在一起。本行代码创建一个字典，其中包含每次采访中每个回答的必要数据。

❻ 标题列表中第二个元素是问题的详细说明。本行代码将其保存为 question，并将 UNICEF 问题代码保存为 question_code。

❼ 为了记录每一行回答（或每一次采访），本行代码添加了 enumerate 函数得到的 rownum。

❽ 最后，利用新表的 insert 方法将新字典插入我们的数据库中。

我们希望将清洗过的数据保存到 SQLite 数据库中。我们创建了一个新的数据库，用到了 enumerate 函数，这样我们可以合并每一个回答（每一行）。如果我们要访问数据，可以访问新表，利用第 6 章学过的函数来查看所有的数据记录，并在需要时进行检索。

如果你希望将清洗过的数据导出到简单文件中，应该也很容易做到。我们来看一下：

from csv import writer

def write_file(zipped_data, file_name):
    with open(file_name, 'wb') as new_csv_file: ➊
        wrtr = writer(new_csv_file) ➋
        titles = [row[0][1] for row in zipped_data[0]] ➌
        wrtr.writerow(titles) ➍
        for row in zipped_data:
            answers = [resp[1] for resp in row] ➎
            wrtr.writerow(answers)

write_file(zipped_data, 'cleaned_unicef_data.csv') ➏

❶ with...as 的作用是将第一个函数的输出赋值给第二个变量名。本行代码将新文件 open(file_name, 'wb') 赋值给变量 new_csv_file。'wb' 的意思是以二进制模式写入。

❷ 初始化 CSV writer 对象，传入一个打开的文件，然后将 writer 对象赋值给 wrtr 变量。

❸ writer 对象需要数据列表来逐行写入，本行创建的是标题行的标题列表。长标题是元组第一部分的第二个元素，所以对应的代码是 row[0][1]。

❹ 用到了 writer 对象的 writerow 方法，将一个可迭代对象转换成一行逗号分隔的数据。本行代码写入的是标题行。

❺ 利用列表生成式提取出所有回答（元组的第二个元素）。

❻ 将利用列表生成式创建的所有列表或回答写入 CSV 数据文件。

这里我们用到了学过的语法，也用到了一些新语法。我们已经学过如何用 with...as 将简单函数的返回值赋值给一个变量名。这里我们希望将打开的文件赋值给 new_csv_file 变量。这种语法通常用于文件和其他 I/O 对象，因为 Python 执行完 with 代码块中的代码之后，它会自动关闭文件，这很棒！

此外，代码中我们用到了 CSV writer 对象，与 CSV reader 对象的用法类似。writerow 可以将包含所有数据列的列表写入到 CSV 文件中。

　writerow 方法接受一个可迭代对象，所以一定要传入一个列表或元组。如果你传入一个字符串，那么看到一些有趣的 CSV（“l,i,k,e,,t,h,i,s”）时不要惊讶。

我们还用到了列表生成式来创建标题列表和回答列表。由于我们不需要用这个函数生成一个新对象或修改过的对象，所以没有返回任何值。这个函数可以帮我们复习目前学过的许多概念。

如果你想用其他方法来保存数据，可以参考第 6 章给出的关于保存数据的建议。保存完清洗过的数据之后，你可以继续进行后面的清洗过程，并对数据进行分析。

8.3　找到适合项目的数据清洗方法

根据数据的可靠性，以及你分析数据的频率，你可以选择一种完全不同的数据清洗方式。如果你要处理的数据是非常杂乱的，或者有许多不同的来源，你可能无法准确地将清洗过程脚本化。

　你需要分析将数据清洗完全脚本化所要付出的时间和精力，然后判断数据清洗自动化能否真正节省时间。

如果清洗过程特别繁琐，有很多步骤，你可能需要创建一个包含许多辅助脚本的仓库。这样即使你没有一个按顺序完成所有步骤的脚本，这个仓库也会为你提供许多函数，在整个数据处理过程中均可使用，还可以让你处理新数据时速度更快。举个例子，你有一些在列表或矩阵中搜索重复值的脚本，还有一些函数，可以从 CSV 导入或导出数据，或者格式化字符串和日期。对于这种方法，你可以随时导入这些函数并使用，可以在 IPython 或 Jupyter 中导入使用（我们会在第 10 章中学到），也可以在当前仓库的其他文件中导入使用。

如果你的清洗代码有固定的规律，不太可能发生变化，那么可以将整个清洗过程脚本化。

8.4　数据清洗脚本化

随着你的 Python 知识的逐步深化与丰富，你编写的 Python 代码也会逐渐变得复杂。现在你可以编写函数、解析文件、导入并使用多个 Python 库，甚至还可以存储数据。是时候开始将代码脚本化了。脚本化（scripting）的意思是，确定代码的结构，用于后续使用、学习和分享。

以 UNICEF 数据为例。我们知道，UNICEF 每隔几年会发布这些数据集，其中许多数据是不变的。调查不太可能发生较大变化——它是建立在多年经验的基础之上。考虑到这些事实，我们可以信任这些数据集有相当高的一致性。如果我们需要再次用到 UNICEF 数据，可能至少可以复用第一次写的脚本中的一部分代码。

目前我们代码的结构比较简单，也缺少代码文档。除了可读性较差外，这样的代码还很难复用。虽然现在我们可以看懂自己写的函数，但一年后我们还能准确地读懂并理解这些函数吗？我们把这些函数发给同事，他们能看懂我们的笔记吗？在我们对这些问题做出肯定的回答之前，最好一行代码也不要写。如果一年后我们无法读懂自己的代码，那么这些代码是没有任何用处的，当发布新报告时会有人（很可能是我们自己）重新写这些代码。

Python 之禅不仅适用于编写代码，还适用于组织代码，函数、变量和类的命名，等等。最好在选择命名上花点时间，判断哪些名字可以让你和他人都一目了然。注释和文档可以帮助理解，但代码本身也应该具有较强的可读性。

　经常有人称赞 Python 是最容易读懂的语言之一，即使是看不懂代码的人也能读懂！保持代码语法简洁可读，这样解释代码功能的文档也不需要太长。

Python 之禅

Python 之禅（https://www.python.org/dev/peps/pep-0020/）总是非常值得参考的（还可以输入 import this 来轻松查看）。它的要点是，对于Python（和许多语言）来说，尽可能保持明确、简洁和实用总是最好的。1

随着你编程水平的提高，明确和实用的含义可能会发生变化，但我们强烈建议你尽可能保持代码清晰、精确和简单。有时可能会使代码量变大或者运行时间变长，但随着经验地增长，你总会找到方法将代码写得既快速又清晰。

现阶段应该将代码写得尽可能清晰，这样以后回看代码时，你可以理解自己当时的意思。

1中文版 Python 之禅可参见：https://wiki.python.org/moin/PythonZenChineseTranslate。——译者注

通读 PEP-8 Python 风格指南（https://www.python.org/dev/peps/pep-0008/），并遵守里面的规则。有许多 PEP-8 的检查工具（linter），可以通读你的代码，并指出其中不符合 PEP-8 的地方。

除了风格标准和用法，你还可以用检查工具评估代码的复杂度。有些是根据 McCabe 关于循环复杂度的理论和计算方法（https://en.wikipedia.org/wiki/Cyclomatic_complexity）来对代码进行分析。虽然不是每次都能将代码分割成简单的代码块，但你应该尽量将复杂任务拆分成更小、更简单的任务，降低代码复杂度，使代码更明确。

在使代码更加清晰明确的同时，另一个很有用的做法是，让可复用的代码块更加通用。但注意不要过于一般化（def foo 这样的定义毫无用处），但如果你创建通用的辅助函数，你将会经常用到它们（例如用一个列表创建 CSV，或者用包含重复值的列表创建一个集合），你的代码也会更加有序、简洁和简单。

　如果所有报告都用相同的代码连接数据库或打开数据文件，你可以为此创建一个函数。编写通用的辅助函数，其目的是创建简单、可读、可用且不重复的代码。

表 8-1 汇总了一些编程的最佳实践，你可以在以后的编程中考虑这些做法。这些最佳实践并没有包含关于 Python 和编程的所有内容，但可以为今后的学习和编程打下良好的基础。

表8-1：Python编程最佳实践

实践

说明

文档

包括代码中的注释、函数说明和脚本说明，以及 README.md 文件和仓库中其他必要的说明文件

命名清晰

所有函数、变量和文件都应该有清晰的命名，从名字中就可以看出其内容或功能

语法正确

变量和函数应该遵守正确的 Python 语法（一般用小写字母，单词之间加下划线，对于类名采用驼峰式大小写（CamelCase，https://en.wikipedia.org/wiki/CamelCase），代码应遵守 PEP-8 标准

导入

只导入需要使用的内容，导入方式遵守 PEP-8 的原则

辅助函数

创建抽象的辅助函数，使代码变得清晰、可复用（例如，export_to_csv 函数将列表内容导入 CSV 文件）

仓库管理

用逻辑结构和层级结构管理仓库，共用的代码放在一起，符合一般的逻辑规律

版本控制

所有代码都应该有版本控制，这样你或你的同事可以创建新分支、尝试新特性，而不会影响仓库主分支的运行

快速，但是更要清晰

利用 Python 语法糖写出快速高效的代码，但当速度和清晰只能二选一时，选择清晰的代码

利用现成的库

当你想做点什么，而前人已经用 Python 做过了，不要重复造轮子。善于利用优秀的 Python 库，对这些库做贡献来帮助开源社区

代码测试

在适当可行的时候，为单个函数编写测试，并利用测试数据来测试代码

详实准确

在 try 代码块中正确地编写例外（exception），代码文档要详实，变量名要准确

为代码编写文档是编写脚本的一个重要步骤。正如 Eric Holscher（Python 主义者，Write the Docs 的创始人之一）恰如其分地总结（http://www.writethedocs.org/guide/writing/beginners-guide-to-docs/）：为代码编写文档的原因有很多，最重要的原因就是你可能会再次用到这些代码——或者其他人可能会阅读并使用这些代码，或者你想发布到 GitHub 上，或者你想在以后的面试中用到，或者你想将代码发给你母亲。无论什么原因，为代码编写完备的文档，可以在未来减少数小时的痛苦。如果你是团队的一员，还会减少整个团队数百小时的痛苦。想到未来会有这些好处，现在值得花精力坐下来分析代码的用途，以及这么编写的原因。

类似 Read the Docs（https://readthedocs.org/）或者 Write the Docs（http://www.writethedocs.org/）之类的机构给出了许多好的建议和帮助，使编写文档变得更加轻松。一个好的经验做法是，在项目根目录里创建一个 README.md，简要说明代码的作用、安装方法和运行方法、基本要求以及在哪里可以找到更多信息。

　有时在 README.md 里放一个简短的代码示例也是很有用的，这取决于用户（读者）与核心组件的交互次数多少。

除了 README.md 文件，你还需要添加代码注释。第 5 章中说过，注释可以是只给自己看的快速笔记，也可以是说明脚本和函数用法的长注释。

　Python 中各种注释的语法和用法在 PEP-350（https://www.python.org/dev/peps/pep-0350/）中有详细说明。遵循这些标准，任何人都可以轻松看懂你写的注释。

我们来尝试为之前的清洗代码编写文档。为了让我们编写文档的思路清晰，我们首先简要列出需要完成的任务。

从 UNICEF 数据文件中导入数据。

找到数据行对应的标题。

将我们可以读懂的标题与内置缩写标题正确匹配。

解析数据，检查是否有重复值。

解析数据，检查数据是否有缺失。

将同一家庭的多行数据合并。

保存数据。

上述任务基本上是按先后顺序排列的，列出这些任务，可以让我们在组织代码结构、编写脚本以及为新脚本编写文档时减轻一些痛苦。

我们要做的第一件事情，就是将本章和上一章写的所有代码块放到同一个脚本文件中。把它们放在一起之后，我们可以开始按照规则写出好代码。我们来看一下当前的脚本：

from csv import reader
import dataset

data_rdr = reader(open('../../../data/unicef/mn.csv', 'rb'))
header_rdr = reader(open('../../../data/unicef/mn_headers_updated.csv', 'rb'))

data_rows = [d for d in data_rdr]
header_rows = [h for h in header_rdr if h[0] in data_rows[0]]

all_short_headers = [h[0] for h in header_rows]

skip_index = []
final_header_rows = []

for header in data_rows[0]:
    if header not in all_short_headers:
        print header
        index = data_rows[0].index(header)
        if index not in skip_index:
            skip_index.append(index)
    else:
        for head in header_rows:
            if head[0] == header:
                final_header_rows.append(head)
                break

new_data = []

for row in data_rows[1:]:
    new_row = []
    for i, d in enumerate(row):
        if i not in skip_index:
            new_row.append(d)
    new_data.append(new_row)

zipped_data = []

for drow in new_data:
    zipped_data.append(zip(final_header_rows, drow))

# 检查数据是否有缺失

for x in zipped_data[0]:
    if not x[1]:
        print x

# 检查是否有重复值

set_of_keys = set([
    '%s-%s-%s' % (x[0][1], x[1][1], x[2][1]) for x in zipped_data])

uniques = [x for x in zipped_data if not
           set_of_keys.remove('%s-%s-%s' %
                              (x[0][1], x[1][1], x[2][1]))]

print len(set_of_keys)

# 保存到数据库

db = dataset.connect('sqlite:///../../data_wrangling.db')

table = db['unicef_survey']

for row_num, data in enumerate(zipped_data):
    for question, answer in data:
        data_dict = {
            'question': question[1],
            'question_code': question[0],
            'answer': answer,
            'response_number': row_num,
            'survey': 'mn',
        }

    table.insert(data_dict)

可以看出，大部分代码都是扁平的（flat），即没有重要性的嵌套关系。文件中大部分代码和函数都没有缩进或文档。代码本身不够抽象，变量名也不够清晰。我们从头开始解决这些问题。前两段代码重复。我们可以编写一个函数来代替：

def get_rows(file_name):
    rdr = reader(open(file_name, 'rb'))
    return [row for row in rdr]

有了这个函数，现在我们的文件就变短了。我们来看下一段代码是否还能进一步改进。

我们修改 header_rows 使其与 data_rows 里的标题对齐，花了不少时间，但现在已经不需要这段代码了。我们创建了 final_header_rows，里面的 header_rows 和 data_rows 已经匹配好了，所以我们无需担心二者不匹配的问题。我们可以删除这行代码。

14~27 行的作用是创建 final_header_rows 和 skip_index 两个列表。我们可以将这两个列表的用途总结一下，就是用于删除不匹配的元素，这样我们才能合并最终列表。我们把两个列表放在同一个方法中：

def eliminate_mismatches(header_rows, data_rows):
    all_short_headers = [h[0] for h in header_rows]
    skip_index = []
    final_header_rows = []

    for header in data_rows[0]:
        if header not in all_short_headers:
            index = data_rows[0].index(header)
            if index not in skip_index:
                skip_index.append(index)
        else:
            for head in header_rows:
                if head[0] == header:
                    final_header_rows.append(head)
                    break
    return skip_index, final_header_rows

现在我们已经将清洗脚本中的很多代码都合并成函数了。这有助于我们描述每一个函数的功能，编写代码文档，当需要修改代码时知道需要查看哪些内容。

我们继续阅读脚本，看能否找到更多需要修改之处。下一节代码似乎是用于创建合并后的数据集。我们可以将其拆分为两个函数：一个找出与标题匹配的数据行，另一个合并两个列表。我们也可以只用一个函数来创建合并后的数据。最终由你自己决定哪种方法更好。这里我们用的是一个函数外加一个简短的辅助函数，后面可能会再次用到：

def zip_data(headers, data):
    zipped_data = []
    for drow in data:
        zipped_data.append(zip(headers, drow))
    return zipped_data

def create_zipped_data(final_header_rows, data_rows, skip_index):
    new_data = []
    for row in data_rows[1:]:
        new_row = []
        for index, data in enumerate(row):
            if index not in skip_index:
                new_row.append(data)
        new_data.append(new_row)
    zipped_data = zip_data(final_header_rows, new_data)
    return zipped_data

有了这些新函数，我们可以保存代码、清洗变量名，还可以利用辅助函数将标题与数据行合并，并返回合并后的数据列表。代码更加清晰，分块也更加合理。我们继续将同样的逻辑应用到文件中的其他代码。我们来看一下最终结果：

from csv import reader
import dataset

def get_rows(file_name):
    rdr = reader(open(file_name, 'rb'))
    return [row for row in rdr]

def eliminate_mismatches(header_rows, data_rows):
    all_short_headers = [h[0] for h in header_rows]
    skip_index = []
    final_header_rows = []

    for header in data_rows[0]:
        if header not in all_short_headers:
            index = data_rows[0].index(header)
            if index not in skip_index:
                skip_index.append(index)
        else:
            for head in header_rows:
                if head[0] == header:
                    final_header_rows.append(head)
                    break
    return skip_index, final_header_rows

def zip_data(headers, data):
    zipped_data = []
    for drow in data:
        zipped_data.append(zip(headers, drow))
    return zipped_data

def create_zipped_data(final_header_rows, data_rows, skip_index):
    new_data = []
    for row in data_rows[1:]:
        new_row = []
        for index, data in enumerate(row):
            if index not in skip_index:
                new_row.append(data)
        new_data.append(new_row)
    zipped_data = zip_data(final_header_rows, new_data)
    return zipped_data

def find_missing_data(zipped_data):
    missing_count = 0
    for question, answer in zipped_data:
        if not answer:
            missing_count += 1
    return missing_count

def find_duplicate_data(zipped_data):
    set_of_keys = set([
        '%s-%s-%s' % (row[0][1], row[1][1], row[2][1])
        for row in zipped_data])

    uniques = [row for row in zipped_data if not
               set_of_keys.remove('%s-%s-%s' %
                                  (row[0][1], row[1][1], row[2][1]))]

    return uniques, len(set_of_keys)

def save_to_sqlitedb(db_file, zipped_data, survey_type):
    db = dataset.connect(db_file)

    table = db['unicef_survey']
    all_rows = []

    for row_num, data in enumerate(zipped_data):
        for question, answer in data:
            data_dict = {
                'question': question[1],
                'question_code': question[0],
                'answer': answer,
                'response_number': row_num,
                'survey': survey_type,
            }
            all_rows.append(data_dict)

    table.insert_many(all_rows)

现在我们有了许多不错的函数，却改变了程序的运行方式。如果现在运行这个脚本，一行代码都不会运行。只有一些写好的函数，却都没有被调用。

现在我们要在一个 main 函数中说明使用这些函数的方法。Python 开发者一般会将通过命令行运行的代码放到 main 函数里。下面我们添加 main 函数的代码，用于清洗数据集：

""" 这部分代码放在已写脚本的下面。 """

def main():
    data_rows = get_rows('data/unicef/mn.csv')
    header_rows = get_rows('data/unicef/mn_headers_updated.csv')
    skip_index, final_header_rows = eliminate_mismatches(header_rows,
                                                          data_rows)
    zipped_data = create_zipped_data(final_header_rows, data_rows, skip_index)
    num_missing = find_missing_data(zipped_data)
    uniques, num_dupes = find_duplicate_data(zipped_data)
    if num_missing == 0 and num_dupes == 0:
        save_to_sqlitedb('sqlite:///data/data_wrangling.db', zipped_data)
    else:
        error_msg = ''
        if num_missing:
            error_msg += 'We are missing {} values. '.format(num_missing)
        if num_dupes:
            error_msg += 'We have {} duplicates. '.format(num_dupes)
        error_msg += 'Please have a look and fix!'
        print error_msg

if __name__ == '__main__':
    main()

现在我们有了一个可以从命令行运行的可执行文件。运行此文件会发生什么？你会得到我们刚刚创建的错误信息，还是将数据保存到本地的 SQLite 数据库中？

使一个文件可以在命令行中运行

大多数可以在命令行中运行的 Python 文件都有一些相同的属性。它们一般都有一个 main 函数，里面再调用小型函数或辅助函数，和我们上面的清洗脚本类似。

main 函数一般会在文件的主缩进级别的代码块中进行调用。调用的语法是 if __name__ == '__main__':。这个语法用到了全局的私有变量（所以变量名两边才有双下划线），当你在命令行运行文件时会返回 True。

如果不是在命令行中运行脚本，那么 if 语句中的代码不会运行。如果我们将这些函数导入另一个脚本中，__name__ 变量不等于 '__main__'，代码就不会运行。这是 Python 脚本常用的约定。

　遇到任何错误，检查你的代码和上述代码是否完全相同，检查仓库中数据的文件路径是否正确，还要检查第 6 章创建的本地数据库的文件路径是否正确。

下面我们来为代码编写文档。我们要给函数添加一些文档字符串和行内注释，方便我们理解脚本中比较复杂的代码段，还要在脚本开头添加一大段说明文字，这些文字以后可以放到 README.md 文件中：

"""
Usage: python our_cleanup_script.py

This script is used to intake the male survey data from UNICEF
and save it to a simple database file after it has been checked
for duplicates and missing data and after the headers have been properly
matched with the data. It expects there to be a 'mn.csv' file with the
data and the 'mn_updated_headers.csv' file in a subfolder called 'unicef' within
a data folder in this directory. It also expects there to be a SQLite
file called 'data_wrangling.db' in the root of this directory. Finally,
it expects to utilize the dataset library
(http://dataset.readthedocs.org/en/latest/).

If the script runs without finding any errors, it will save the
cleaned data to the 'unicef_survey' table in the SQLite.
The saved data will have the following structure:
    - question: string
    - question_code: string
    - answer: string
    - response_number: integer
    - survey: string

The response number can later be used to join entire responses together
(i.e., all of response_number 3 come from the same interview, etc.).

If you have any questions, please feel free to contact me via ...
"""

from csv import reader
import dataset

def get_rows(file_name):
    """Return a list of rows from a given csv filename."""
    rdr = reader(open(file_name, 'rb'))
    return [row for row in rdr]

def eliminate_mismatches(header_rows, data_rows):
    """
    Return index numbers to skip in a list and final header rows in a list
    when given header rows and data rows from a UNICEF dataset. This
    function assumes the data_rows object has headers in the first element.
    It assumes those headers are the shortened UNICEF form. It also assumes
    the first element of each header row in the header data is the
    shortened UNICEF form. It will return the list of indexes to skip in the
    data rows (ones that don't match properly with headers) as the first element
    and will return the final cleaned header rows as the second element.
    """
    all_short_headers = [h[0] for h in header_rows]

    skip_index = []
    final_header_rows = []

    for header in data_rows[0]:
        if header not in all_short_headers:
            index = data_rows[0].index(header)
            if index not in skip_index:
                skip_index.append(index)
        else:
            for head in header_rows:
                if head[0] == header:
                    final_header_rows.append(head)
                    break
    return skip_index, final_header_rows

def zip_data(headers, data):
    """
    Return a list of zipped data when given a header list and data list. Assumes
    the length of data elements per row and the length of headers are the same.

    example output: [(['question code', 'question summary', 'question text'],
                       'resp'), ....]

    """
    zipped_data = []
    for drow in data:
        zipped_data.append(zip(headers, drow))
    return zipped_data

def create_zipped_data(final_header_rows, data_rows, skip_index):
    """
    Returns a list of zipped data rows (matching header and data) when given a
    list of final header rows, a list of data rows, and a list of indexes on
    those data rows to skip as they don't match properly. The function assumes
    the first row in the data rows contains the original data header values,
    and will remove those values from the final list.
    """
    new_data = []
    for row in data_rows[1:]:
        new_row = []
        for index, data in enumerate(row):
            if index not in skip_index:
                new_row.append(data)
        new_data.append(new_row)
    zipped_data = zip_data(final_header_rows, new_data)
    return zipped_data

def find_missing_data(zipped_data):
    """
    Returns a count of how many answers are missing in an entire set of zipped
    data. This function assumes all responses are stored as the second element.
    It also assumes every response is stored in a list of these matched question,
    answer groupings. It returns an integer.
    """
    missing_count = 0
    for response in zipped_data:
        for question, answer in response:
            if not answer:
                missing_count += 1
    return missing_count

def find_duplicate_data(zipped_data):
    """
    Returns a list of unique elements and a number of duplicates found when given
    a UNICEF zipped_data list. This function assumes that the first three rows of
    data are structured to have the house, cluster, and line number of the
    interview and uses these values to create a unique key that should not be
    repeated.
    """

    set_of_keys = set([
        '%s-%s-%s' % (row[0][1], row[1][1], row[2][1])
        for row in zipped_data])

    #TODO: this will throw an error if we have duplicates- we should find a way
    #around this
    uniques = [row for row in zipped_data if not
               set_of_keys.remove('%s-%s-%s' %
                                  (row[0][1], row[1][1], row[2][1]))]

    return uniques, len(set_of_keys)

def save_to_sqlitedb(db_file, zipped_data, survey_type):
    """
    When given a path to a SQLite file, the cleaned zipped_data, and the
    UNICEF survey type that was used, saves the data to SQLite in a
    table called 'unicef_survey' with the following attributes:
        question, question_code, answer, response_number, survey
    """
    db = dataset.connect(db_file)

    table = db['unicef_survey']
    all_rows = []

    for row_num, data in enumerate(zipped_data):
        for question, answer in data:
            data_dict = {
                'question': question[1],
                'question_code': question[0],
                'answer': answer,
                'response_number': row_num,
                'survey': survey_type,
            }
            all_rows.append(data_dict)

    table.insert_many(all_rows)

def main():
    """
    Import all data into rows, clean it, and then if
    no errors are found, save it to SQlite.
    If there are errors found, print out details so
    developers can begin work on fixing the script
    or seeing if there is an error in the data.
    """

    #TODO: we probably should abstract these files so that we can pass
    # them in as variables and use the main function with other surveys
    data_rows = get_rows('data/unicef/mn.csv')
    header_rows = get_rows('data/unicef/mn_updated_headers.csv')
    skip_index, final_header_rows = eliminate_mismatches(header_rows,
                                                         data_rows)
    zipped_data = create_zipped_data(final_header_rows, data_rows, skip_index)
    num_missing = find_missing_data(zipped_data)
    uniques, num_dupes = find_duplicate_data(zipped_data)
    if num_missing == 0 and num_dupes == 0:
        #TODO: we probably also want to abstract this
        # file away, or make sure it exists before continuing
        save_to_sqlite('sqlite:///data_wrangling.db', zipped_data, 'mn')
    else:
        #TODO: eventually we probably want to log this, and
        # maybe send an email if an error is thrown rather than print it
        error_msg = ''
        if num_missing:
            error_msg += 'We are missing {} values. '.format(num_missing)
        if num_dupes:
            error_msg += 'We have {} duplicates. '.format(num_dupes)
        error_msg += 'Please have a look and fix!'
        print error_msg

if __name__ == '__main__':
    main()

现在我们的代码文档更详细、结构更合理，还有许多可复用的函数。对于我们的第一个脚本来说，这是一个很好的开始。利用这些代码，希望我们可以导入许多 UNICEF 数据！

　我们还在代码里添加了许多“TODO”（待办）的注释，这样我们以后可以继续完善脚本。你认为哪个问题是最迫切需要解决的？为什么？你能尝试解决这个问题吗？

我们只用了一个文件来运行代码。但随着代码量的增加，你的仓库也会变得越来越复杂。在初期就要思考你可能需要向仓库中添加的内容，这一点是很重要的。代码和代码结构很相似。如果你认为这个仓库可能的用途不仅仅是解析 UNICEF 数据，你的代码结构可能会大不相同。

为什么会这样？首先，你可能需要将数据保存在一个单独的文件中。事实上，根据你的仓库未来的复杂程度，你可能需要在不同的文件夹中使用不同的数据解析方法和清洗方法。

　在初期不必过分担心这些决策。随着你 Python 编程水平的提高和对数据集的理解进一步加深，你会更清楚地认识到应该从哪里开始。

在仓库的结构中，经常会有一个名为 utils 或 common 的文件夹，你可以在里面保存代码之间共享的脚本。许多开发者将数据库连接脚本，常用的 API 代码和通信或 email 脚本等保存在这样的文件夹中，方便导入其他脚本中。

你可能创建了多个目录来保存项目的不同内容，具体取决于仓库的管理结构。其中一个目录只和 UNICEF 数据有关。另一个目录可能包含网络抓取脚本或最终报告代码。如何组织仓库的结构由你自己决定。永远保持清晰、明确、有序。

如果你最后不得不重新组织仓库结构，那么在开始时就尽可能保持仓库有序，后面就不会太过痛苦。相反，如果你的仓库里都是 800 行的文件，而且没有清晰的文档，那么你要做的事情就很多了。最好的经验做法是最开始给出结构框架，随着仓库内容的增加和变化对结构进行临时调整。

除了良好的文件结构，保持目录、文件、函数和类的命名清晰明确也是很有用的。在 utils 文件夹中可能有多个文件。如果你将其命名为 utils1、utils2 等，你可能需要打开文件才能知道它们的具体内容。但如果你将其命名为 email.py、database.py、twitter_api.py 等，文件名本身就包含了更多信息。

在代码中尽量保持明确，对长期而成功的 Python 数据处理事业是一个良好的开端。我们思考一下仓库的结构，看如何找到相应的文件：

data_wrangling_repo/
|-- README.md
|-- data_wrangling.db
|-- data/
|   `-- unicef/
|       |-- mn.csv
|       |-- mn_updated_headers.csv
|       |-- wm.csv
|       `-- wm_headers.csv
|-- scripts/
|   `-- unicef/
|       `-- unicef_cleanup.py (本章的脚本)
`-- utils/
    |-- databases.py
    `-- emailer.py

我们还没有编写 databases 或 emailer 文件，但我们或许应该这么做。我们还可以向文件结构中添加哪些内容？我们在仓库中创建了两个不同的 unicef 文件夹，你认为这么做的原因是什么？开发者是否应该将数据文件和脚本文件分开保存？

　你的项目文件结构可能和这个类似，但要记得，数据通常都不保存在仓库中。将项目的数据文件保存在共享文件服务器或本地网络的其他位置。如果你是独立开发，一定要在其他地方备份。不要将这些大文件提交到你的仓库中。这样不仅会在需要在新设备上查看仓库时降低工作效率，而且也不是管理数据的好方法。

我们也建议不要将 db 文件或任何 log、config 文件提交到仓库中。仓库结构应尽可能实用。你总是可以将预期的文件结构添加到 README.md 文件中，并详细说明去哪里获取数据文件。

Git 和 .gitignore 文件

如果你还没有用 Git（https://git-scm.com/）做版本控制的话，学完本书就会用了！版本控制可以让你创建仓库来管理和修改代码，并将其分享给团队或其他同事。

在第 14 章中我们将会深入讲解Git，但现在我们在讨论仓库结构，希望重点说一下 .gitignore 文件（https://github.com/github/gitignore）。.gitignore 文件的作用是，让 Git 忽略某些文件，不要将这些文件上传到仓库中。这个文件使用简单模式来匹配文件名，与我们在第 7 章中学过的正则表达式类似。

在我们的仓库结构中，我们可以用一个 .gitignore 文件，这样 Git 就不会将任何数据文件提交到仓库中。然后我们可以在 README.md 中说明仓库的结构，给出获取数据文件的联系信息。这样我们的仓库就比较简洁，且易于下载，还可以保持良好的代码结构。

创建一个符合逻辑的仓库结构，并添加 README.md 和 .gitignore 文件，可以保持模块化代码的项目文件夹有序，并避免将大型数据文件或可能敏感的数据（数据库或登录数据）放在仓库中。

8.5　用新数据测试

前面我们已经学习了编写文档、代码脚本化、组织代码结构，现在我们应该编写一些测试，或者用新数据来测试。这可以帮我们检查代码的运行是否正确，是否与与预期相同，还可以明确代码的含义。我们将数据清洗脚本化的原因之一就是我们可以复用这些代码，因此用新数据测试可以证明我们在代码标准化上花的时间和精力是值得的。

测试刚写过的脚本的一种方法是，我们能否将其轻松应用于在 UNICEF 网站上找到的相似数据。我们来看一下。你应该已经从本书仓库（https://github.com/jackiekazil/data-wrangling）中下载了 wm.csv 和 wm_headers.csv 两个文件。这两个文件是津巴布韦 UNICEF 数据中的女性调查数据。

我们尝试在脚本中使用这些文件，替换男性调查数据。要做到这一点，我们只需修改清洗脚本中的两个文件名，将其修改为两个妇女调查数据文件的路径。我们还应该将调查类型修改为 'wm'，这样我们才能区分两个数据集中的数据。

　女性数据集要比男性的大得多。如果你有未保存的数据，我们建议先保存数据，关闭其他程序，然后再进行下一步。关于这一点，可以思考一下如何在脚本中改善内存使用。

我们来看一下能否成功导入数据：

import dataset

db = dataset.connect('sqlite:///data_wrangling.db')

wm_count = db.query('select count(*) from unicef_survey where survey="wm"') ➊

count_result = wm_count.next() ➋

print count_result

❶ 我们用的是直接查询，可以快速查看 survey='wm' 的行数。这应该只包括我们将类型设为 'wm' 后第二次运行的数据行。

❷ 读取查询结果，利用查询响应的 next 方法提取出第一个结果。我们用的是 count，所以我们应该只得到一个结果。

我们从女性数据集中成功导入了超过 300 万个问题和回答。我们的脚本是有效的，我们可以看到输出结果！

利用相似数据测试脚本是确保脚本按预期运行的一种方法。从中还可以发现，你的脚本通用性很高，可以复用。但测试代码还有很多其他方法。Python 有不少很好的测试库，可以帮你编写测试脚本和应用测试数据（甚至测试 API 响应），从而保证代码正常运行。

Python 标准库中有一些内置的测试模块。unittest 库（https://docs.python.org/2/library/unittest.html）可以为 Python 代码做单元测试。它有一些好用的内置类，利用 assert 语句来测试代码是否正常运行。如果想为代码编写单元测试，我们可以写一个测试，判断 get_rows 函数返回的是不是一个列表。我们还可以判断列表长度和文件的数据行数是否相同。我们可以用这些断言测试每一个函数。

另一个流行的 Python 测试框架是 nose 库（https://nose.readthedocs.org/en/latest/）。nose 是一个非常强大的测试框架，额外插件（https://nose.readthedocs.io/en/latest/plugins/builtin.html）和配置可以提供很多功能选项。如果你的仓库很大，许多开发者负责同样的代码，有不同的测试需求，那么这个库是很好用的。

不知道先用哪一个？那么 pytest 库（http://pytest.org/latest/）可能适合你。你可以用任意一种风格编写测试，还可以在需要时换用另一种风格。它的社区也相当活跃，里面有许多演讲和教程（http://docs.pytest.org/en/latest/talks.html#talks-and-blog-postings），所以如果你想深入了解之后编写自己的测试，那么可以先用这个库。

　通常来说，测试集的结构是在每一个模块下有一个测试文件（对于我们当前的仓库结构来说，我们会在每一个目录下放一个测试文件，除了数据文件夹和配置文件夹）。有些人为文件夹中的每一个 Python 文件编写一个测试文件，所以很容易判断每项测试针对的是哪一个具体文件。其他人将测试放在一个单独的目录中，其结构与 Python 文件结构相同。

无论你选择哪种测试风格或文件结构，一定要确保前后一致、风格明确。这样你就会知道在哪里可以找到测试文件，你（和其他人）也可以在必要时运行这些测试代码。

8.6　小结

本章我们学习了数据标准化的一些基本知识，还有何时适合做数据归一化或删除离群值。你可以将干净的数据（来自第 6 章）导入到数据库或本地文件中，并且你开始为那些重复性过程编写了条理更加清晰的函数。

此外，你还学习了用嵌套文件夹和正确命名的文件来组织 Python 仓库结构，开始编写文档并分析代码。最后，你对测试和编写测试的一些工具有了基本的了解。

表 8-2 列出了本章讲到的 Python 概念。

表8-2：Python编程的新概念和新库

概念/库

作用

数据库 insert 方法

利用 insert 命令可以将数据轻松保存到 SQLite 数据库中

CSV writer 对象

利用 csv 库的 writer 类，可以将数据保存到 CSV 文件中

Python 之禅（import this）

像 Python 程序员一样写代码和思考的哲学

Python 最佳实践

作为一名新的 Python 开发者，应该遵循的最佳实践的基本框架

Python 命令行运行（if __name__ == '__main__':）

利用这个代码块对脚本进行格式化，可以在命令行中运行 main 函数

TODO 标记

利用 TODO 注释，很容易发现下一步需要对脚本做哪些改进

Git（https://git-scm.com/）

帮助记录代码变化的版本控制系统。对于你想要部署的代码或想要与他人共享的代码，这一点是绝对必要的，而且对于本地的个人项目也是非常有用的。第 14 章会介绍 Git 的更多内容

在下一章里，我们将学习数据分析，你将会继续练习数据清洗和数据分析的方法，并利用这些方法来分析新的数据集。

第 9 章　数据探索和分析

既然已经花费了一些时间获取和清洗数据，你可以开始做数据分析了！不要对结果有太多期望，这对于你的数据探索过程来说很重要。你的问题可能对于某个答案来说太宽泛，也可能没有结论性的答案。回想一下你在第一节自然科学课程中学到的有关假设和结论的知识。最好用同样的方法来进行数据探索，并且要理解，在数据分析中你可能不会得到一个清晰的结论。

尽管如此，只是去探索数据并发现数据中没有趋势或者趋势不符合预期，这就很有趣。如果一切都如我们所愿，数据处理会变得有些无聊。我们已学会少一点期待，多一点探索。

　当你开始分析和探索数据时，可能会意识到需要更多的或不同种类的数据。在你更深入地定义想要回答的问题，并检验数据告诉你什么的过程中，这是很常见的一种情况，你需要接受。

现在也非常适合回顾一下你最初发现数据集时所提出的问题。你想知道什么？是否还有其他相关的问题有助于你的探索？这些问题可能会指出方向，告诉你在哪能找到故事。即使没有，这些问题也会指引你发现另外一些有趣的问题。即使你不能回答最初的问题，也能够对话题有更深入的了解，并发现新的问题去探索。

在这一章，我们会学习一些新的用于数据探索和分析的 Python 库，并且继续应用我们在前两章学到的清洗数据的知识。我们会学习如何合并数据集，探索数据，得到有关数据集中关系的统计学结论。

9.1　探索数据

在前两章你已经学习了解析和清洗数据，想必已经很熟悉用 Python 来与数据交互了。现在我们要使用 Python 更加深入地探索数据。

首先我们要安装将用到的 Python 库 agate（http://agate.readthedocs.org），它可以帮助我们发现数据的一些基本特征。agate 是一个数据分析库，由 Christopher Groskopf（https:// github.com/onyxfish）编写。Christopher 是一位拥有高超技术水平的数据记者和 Python 开发者，而 agate 库会帮助我们了解数据。使用 pip 安装这个库：

pip install agate

　这一章中的代码与 agate 1.2.0 版本兼容。因为 agate 是一个相对较新的 Python 库，所以随着库的成熟，其中的一些功能是有可能发生改变的。为确保安装的是指定版本的库，你可以使用 pip 设置版本。对本书来说，你可以使用：pip install agate==1.2.0 来安装 agate。我们同样推荐你测试最新的版本，并随时了解书中用到的库的最新代码变化。

我们想要探索 agate 库的一些特性。为了达到这个目的，我们将会使用从 UNICEF 年报得到的关于童工雇用的数据（http://data.unicef.org/child-protection/child-labour.html）。

9.1.1　导入数据

首先，来看一下我们的第一个数据集——UNICEF 的童工汇总数据。我们下载的数据是一个 Excel 文件，包含全世界的童工雇用率列表。我们可以使用学到的关于 Excel 的知识以及从第 4 章和第 7 章学到的数据清洗技术，将原始数据转化为 agate 库所接受的格式。

　在处理 Excel 表单时，我们推荐你用喜欢的 Excel 查看器打开表单文件。这样我们更容易比对 Python“看到”的结果和我们从表单中看到的结果，这有助于数据的查找和提取。

首先，我们导入可能会用到的 Python 库，并且将 Excel 文件加载到 xlrd 中。

import xlrd
import agate

workbook = xlrd.open_workbook('unicef_oct_2014.xls')

workbook.nsheets

workbook.sheet_names()

现在将 Excel 中的数据加载到变量 workbook 中了。这个工作表包含一个表单，叫作 Child labour。

如果你在 IPython 终端中运行上面这段代码（我们推荐使用 IPython，因为你会看到更多的输出），应该看到下面的输出：

In [6]: workbook.nsheets
Out[6]: 1

In [7]: workbook.sheet_names()
Out[7]: [u'Child labour ']

选择这个表单，这样你可以将其导入 agate 库。根据 agate 库的文档（http://agate.readthedocs.io/en/latest/tutorial.html），agate 库可以使用一个标题列表、一个各数据列的类型列表和一个数据读取器（或可迭代的数据列表）来导入数据。所以，我们需要数据的类型，这样才可以顺利地从表单中导入数据到 agate 库。

sheet = workbook.sheets()[0]

sheet.nrows ➊

sheet.row_values(0) ➋

for r in range(sheet.nrows):
    print r, sheet.row(r) ➌

❶ nrows 标识表单中共有多少列。

❷ row_values 允许选取一行数据，并且展示这行的值。上面这种情况下，会展示表单数据的标题，因为标题是 Excel 文件的第一行。

❸ 通过使用 range 和 for 遍历每一行数据，我们可以像 Python 一样查看每一行数据。表单的 row 方法会返回数据的一些信息和每行数据的类型。

我们从第 3 章了解到，csv 库接受一个打开的文件，并且将其转化为一个迭代器（iterator）。迭代器是一个可以迭代或遍历的对象，每一次返回其对应的值。在代码中，迭代器在展开数据方面比列表更高效，拥有速度和性能的优势。

　因为我们正在处理的数据集很小，所以可以创建一个列表，用其代替迭代器来传参。大多数需要迭代器的库可以处理任何可迭代对象（如列表）。通过这种方式，我们依然遵从 xldr 和 agate 所期待的方式。

首先，让我们获取每一列的标题。在之前的输出中，我们可以看到标题在第 4 行和第 5 行。可以使用 zip 来合并标题行：

title_rows = zip(sheet.row_values(4), sheet.row_values(5))

title_rows

现在可以看到变量 title_rows 的值为：

[('', u'Countries and areas'),
(u'Total (%)', ''),
('', ''),
(u'Sex (%)', u'Male'),
('', u'Female'),
(u'Place of residence (%)', u'Urban'),
('', u'Rural'),
(u'Household wealth quintile (%)', u'Poorest'),
('', u'Second'),
('', u'Middle'),
('', u'Fourth'),
('', u'Richest'),
(u'Reference Year', ''),
(u'Data Source', '')]

同时使用两行信息（第 4 行和第 5 行），会保留如果我们仅使用其中一行信息丢失的那部分信息。这是个很好的选择，我们还可以再花点时间来改进这一点，但是，对于最初的数据探索来说，这是一个很好的开始。标题数据当前是一个元组列表。我们知道 agate 库希望得到一个元组列表，其中第一个值为标题的字符串，这样我们需要将标题转换成字符串列表。

titles = [t[0] + ' ' + t[1] for t in title_rows]

print titles

titles = [t.strip() for t in titles]

在这段代码里面，我们使用了两个列表生成式。在第一个中，我们把 title_rows 列表传入，它是一个元组列表。在这些元组中，存有 Excel 文件中标题行的内容字符串。

第一个列表生成式使用了元组的两个部分（通过元组索引）来拼接一个字符串。我们将每一个元组的值合并在一起，出于可读性的考虑，使用 ' ' 做间隔。现在我们的标题列表只由字符串组成——原来的元组不见了！我们使得标题变得有一些复杂，因为并不是每一个元组都有两个值。通过添加空格分隔，我们创建了一些以空格为开始的标题，像 'Female'。

为了删除起始位置的空格，在第二个迭代器中，我们使用 strip 字符串方法，这个方法会移除字符串最开始和最后的空格。现在标题变量有了整洁的字符串列表，能够很好地在 agate 库中使用。

标题已经准备完毕，现在需要从 Excel 文件中选择要使用的数据行。我们的表单有国家和大洲的数据，让我们首先聚焦于国家的数据。我们想要避免意外地将不同类别的数据混合在一起。通过之前的代码输出，我们知道第 6 行至第 114 行是我们想要使用的。我们会使用 row_values 方法来返回 xlrd 表单对象中这些行中的值。

country_rows = [sheet.row_values(r) for r in range(6, 114)]

现在我们有了标题列表和数据列表，所以只需要定义导入 agate 库中的类型。根据关于定义列的文档（http://agate.readthedocs.org/en/latest/tutorial.html#defining-the-columns），我们有文本、布尔、数字和日期 4 种列类型，同时库作者建议，如果数据的类型不确定，就使用文本类型。这里同样有内置的 TypeTester（http://agate.readthedocs.io/en/latest/api/data_types.html），可以用其猜测数据类型。首先，使用一些 xlrd 的内置函数来定义列 :

from xlrd.sheet import ctype_text
import agate

text_type = agate.Text()
number_type = agate.Number()
boolean_type = agate.Boolean()
date_type = agate.Date()

example_row = sheet.row(6)

print example_row ➊
print example_row[0].ctype ➋
print example_row[0].value

print ctype_text ➌

❶ 通过打印来检查这一行的值，我们看到有完好的数据。xlrd 会检查所有的数据，保证不会有空行数据的存在。

❷ 这两行代码中，我们调用 ctype 和 value 属性来得到数据行中每一个元素的类型和值属性。

　当使用 IPython 时，通过创建一个你关心的变量的新对象，在末尾添加句号，并敲击 Tab 键，就可以轻松地找到新的方法和属性。这会展开一个属性和方法的列表，以便于你更深入地探索。

❸ 使用 xlrd 库中的 ctype_text 对象，我们可以匹配 ctype 方法返回的整数对象，映射它们到可阅读的字符串。这可以代替手动映射类型。

这几行代码让我们更好地了解了可用于定义类型的工具。ctype 方法和 ctype_text 对象可以用来排序和展示给定样例数据行中的数据类型。

　虽然看起来我们为了用这种方式创建列表做了好多工作，但这些工作提供了可复用的能力，这会在之后节省你的时间。重用这些代码片段会在之后为你节省大量宝贵时间，同时也是编写你自己的代码中非常有趣的一面。

现在我们知道哪些函数可以用来探索 Excel 列的数据类型，所以需要尝试为 agate 库创建一个类型列表。我们需要遍历数据行，使用 ctype 来映射列类型 ：

types = []

for v in example_row:
    value_type = ctype_text[v.ctype] ➊
    if value_type == 'text': ➋
        types.append(text_type)
    elif value_type == 'number':
        types.append(number_type)
    elif value_type == 'xldate':
        types.append(date_type)
    else:
        types.append(text_type) ➌

❶ 映射我们在探索每一行数据的 ctype 属性时找到的整数值到 ctype_text 字典中，使它们变得可读。现在 value_type 中保存了数据的列类型字符串（也就是文本、数字等）。

❷ 使用 if 和 elif 语句以及 == 操作符将 value_type 和 agate 列类型匹配。之后，代码将相应的类型追加到列表中，继续下一个列类型。

❸ 正如库文档中建议的那样，如果这里没有类型匹配上，我们将文本列类型追加到列表中。

现在我们构建了一个函数来接受一个空列表，遍历所有的列，并且创建一个包含数据集中所有列类型的列表。在运行代码之后，我们就有了需要的类型、标题和数据列表。可以将标题和类型打包在一起，通过运行下面这行代码，将结果导入到 agate 表中：

table = agate.Table(country_rows, titles, types)

当你运行这段代码时，会看到 CastError，同时还有 Can not convert value “-” to Decimal for NumberColumn 这行错误信息。

正像第 7 章和第 8 章提到的那样，学习如何清洗数据是数据处理过程中必要的一部分。编写文档完备的代码会让你在未来节省时间。通过阅读这些错误信息，我们意识到有一些坏数据隐藏在某个数据列中。在表单中的一些地方，数据中出现了 '-'，而不是 ''，这会被当作空值来处理。我们可以编写一个函数来处理这个问题。

def remove_bad_chars(val): ➊
    if val == '-': ➋
        return None ➌
    return val

cleaned_rows = []

for row in country_rows:
    cleaned_row = [remove_bad_chars(rv) for rv in row] ➍
    cleaned_rows.append(cleaned_row) ➎

❶ 定义函数来去除坏字符（例如整数列中的 '-' 字符）。

❷ 如果值与 '-' 相等，选择这个值准备替换。

❸ 如果值为 '-'，返回 None。

❹ 遍历 country_rows 来创建一个新的清洗后的列表，包含合法的数据。

❺ 创建一个 cleaned_rows 列表包含清洗后的数据（通过 append 方法）。

　当我们编写函数来修改值的时候，在主逻辑之外保证一个默认的返回（像示例中那样），以确保永远返回一个值。

使用这个函数，我们可以确保整数列拥有 None 类型，而不是 '-'。None 告诉 Python，这是一个空数据，在与其他数字比较分析时忽略这个数据。

因为想要复用清洗和改变类型的代码，所以我们把一些已经编好的代码转变成更加抽象和通用的辅助函数。创建最后的清洗函数时，我们创建了一个新的列表，遍历所有行的数据，对每一行数据做了清洗，并为 agate 表返回了一个新的数据列表。让我们看一下，是否可以使用这些概念并抽象它们。

def get_new_array(old_array, function_to_clean): ➊
    new_arr = []
    for row in old_array:
        cleaned_row = [function_to_clean(rv) for rv in row]
        new_arr.append(cleaned_row)
    return new_arr ➋

cleaned_rows = get_new_array(country_rows, remove_bad_chars) ➌

❶ 定义函数，让其接受两个参数：老的数据数组和清洗数据的函数。

❷ 用更抽象的名称复用我们的代码。在函数的最后，返回新的清洗后的数组。

❸ 使用 remove_bad_chars 函数作为参数调用这个函数，保存清洗后的结果到 cleaned_rows。

现在尝试重新运行代码来创建一个表：

In [10]: table = agate.Table(cleaned_rows, titles, types)

In [11]: table
Out[11]: <agate.table.Table at 0x7f9adc489990>

呜啊！我们有了一个 table 变量，保存着一个 Table 对象。现在可以用 agate 库的函数查看数据了。如果你急切地想知道表看起来是什么样的，使用 print_table 方法快速地查看一下表中的内容：

table.print_table(max_columns=7)

　如果至此你一直使用 IPython，并且想确保可以在下一个会话中继续使用这些变量，就使用 %store（https://ipython.org/ipython-doc/3/config/extensions/storemagic.html）命令。如果希望保存 table 变量，我们可以简单地输入 %store table。在我们的下一个 IPython 会话中，可以通过输入 %store -r 恢复 table 变量。在分析数据的过程中，这对“保存”你的工作非常有用。

下面，我们会深入查看表数据，并使用一些内置的研究工具。

9.1.2　探索表函数

agate 库提供了许多函数来探索数据。首先，我们尝试一些排序方法（http://agate.readthedocs.org/en/latest/tutorial.html?#sorting-and-slicing）。让我们尝试为表排序。通过对童工雇用率的总百分比的列排序，我们可以看到最过分的国家。我们会使用 limit（http://agate.readthedocs.io/en/latest/api/table.html）函数来查看雇用率最高的 10 个国家。

table.column_names ➊

most_egregious = table.order_by('Total (%)', reverse=True).limit(10) ➋

for r in most_egregious.rows: ➌
    print r

❶ 检查列名称，这样我们知道要使用的是什么列。

❷ 链式调用 order_by 和 limit 方法来创建新的表。因为 order_by 会按从最小到最大来排序，所以我们使用 reverse 参数来让其从大到小排序。

❸ 使用新表的 rows 属性，遍历童工雇用情况最糟糕的 10 个国家。

运行这段代码会返回童工雇用率最高的 10 个国家。在儿童工作百分比方面，非洲国家大量出现在列表前列。这是我们第一个有趣的发现！让我们继续探索。为了探究哪些国家的女童雇用率最高，我们可以再一次使用 order_by 和 limit 函数。这一次，我们将它们应用到女童百分比数据列上：

most_females = table.order_by('Female', reverse=True).limit(10)
for r in most_females.rows:
print '{}: {}%'.format(r['Countries and areas'], r['Female'])

　当第一次探索数据时，使用 Python 的 format 函数会让输出更易阅读，而不是简单地输出每一行数据。这意味着你可以专注于数据本身，而不是费劲去阅读它们。

我们看到了数据中有一些 None 的百分比数据。这不是我们想要的！我们可以使用 agate 表的 where 方法清除这些数据，像下面的代码一样。这一方法类似于 SQL 中的 WHERE 语句，或者 Python 中的 if 语句。where 创建另外一个只包含符合条件的数据行的表。

female_data = table.where(lambda r: r['Female'] is not None)
most_females = female_data.order_by('Female', reverse=True).limit(10)

for r in most_females.rows:
    print '{}: {}%'.format(r['Countries and areas'], r['Female'])

首先创建 female_data 表，其中使用了 Python 的 lambda 函数来保证每一行数据有 Female 列存在。where 函数接受 lambda 函数返回的布尔值，并且只在值为真时将数据分离出来。将只含有女性童工雇用数据的行分离出来后，我们使用相同的排序、截断和格式化技巧，来查看女童雇用率非常高的国家列表。

lambda

Python 的 lambda 函数允许我们编写一个单行函数，并且作为一个参数进行传递。这对于我们在这一节探索中所碰到的情况来说十分有用，在这里，我们希望通过一个简单的函数传递一个值。

在编写 lambda 函数时，像上面例子中那样，我们首先编写 lambda 和我们会传递到函数中的代表参数的变量。在这个例子中，变量是 r。在变量名称之后，我们编写一个冒号（:）。这和我们用 def 定义函数，并且用冒号终结一行是相同的。

在冒号之后给 Python 我们想要 lambda 函数计算的逻辑，这个函数会返回一个值。在这个例子中，返回一个布尔值，告诉我们每行中 Female 这个值是否非 None。不一定要返回布尔值，lambda 可以返回任何类型的值（整型、字符串、列表，等等）。

同样可以在 lambda 函数中使用 if else 语句，基于简单的逻辑返回一个值。在 Python 解释器中尝试下面的代码：

(lambda x: 'Positive' if x >= 1 else 'Zero or Negative')(0) ➊
(lambda x: 'Positive' if x >= 1 else 'Zero or Negative')(4)

❶ 将 lambda 函数放在第一对括号中，将要使用的变量作为 x 放在第二对括号中。这个 lambda 函数检查参数是否等于或大于 1。如果是，返回 Positive，否则返回 Zero or Negative。

lambda 函数极为有用，但是也会使代码更加难以阅读。要确保遵守好的编程规则，并且只在清晰明确的情形下使用它们。

在检视数据的时候，我们发现很多国家既拥有高童工雇用率又拥有高女童雇用率。我们看过一些过滤和排序的方法，再来看一些 agate 库内置的统计学函数。假如我们想要找到城市童工雇用率的平均百分比。为此，我们来计算 Place of residence (%) Urban 这列数据的均值：

table.aggregate(agate.Mean('Place of residence (%) Urban'))

这段代码调用了表的 aggregate 方法，使用 agate.Mean() 统计学方法和列名称来返回列的数学均值。你可以通过 agate 文档（http://agate.readthedocs.io/en/latest/cookbook/statistics.html#statistics）来查看其他可以在列上使用的聚合函数。

当运行这段代码时，会收到 NullComputationWarning 异常。从这个异常的名字和过往经验中，你可能猜到了这个异常的意义，这个异常意味着 Place of residence (%) Urban 列中可能有一些空数据。我们可以再次使用 where 方法来聚焦于城市平均值：

has_por = table.where(lambda r: r['Place of residence (%) Urban'] is not None)

has_por.aggregate(agate.Mean('Place of residence (%) Urban'))

你会发现得到了相同的值，这是因为 agate 在背后做了相同的事情（去除空列，计算剩下数据的平均值）。让我们来看看对于居住信息表还可以做什么数学计算。可以看一下 place of residence 列的最小值（Min）、最大值（Max）和均值（Mean）。

假如我们想要找到每行数据中农村童工雇用率大于 50% 的数据。agate 库有一个 find 方法，使用条件语句来找到第一个匹配的数据。让我们尝试用代码解决问题：

first_match = has_por.find(lambda x: x['Rural'] > 50)

first_match['Countries and areas']

返回的那行数据就是第一个匹配到的数据，就像在普通字典中一样，我们可以看到数据的名称。在 agate 库的第一次探索之旅中，最后一步，我们将会使用 compute 方法和 agate.Rand() 统计学方法（http://agate.readthedocs.io/en/latest/cookbook/rank.html），基于另一列创建一个新的排序的列。

　当比较数据集的时候，基于一列数据对整体数据进行排序是一个很好的彻查方式。

为了查看童工雇用率最高国家的排名，我们可以使用 Total (%) 列数据进行排序。在将这个数据集和其他数据集合并之前，我们想要一个清晰可见的排序后列数据，来比较合并后的数据。因为我们想要雇用率更高的国家出现在列表前面，所以需要使用参数 reverse=True 逆序排序（http://agate.readthedocs.io/en/latest/cookbook/rank.html#rank-descending）。

ranked = table.compute([('Total Child Labor Rank',
                         agate.Rank('Total (%)', reverse=True)), ])

for row in ranked.order_by('Total (%)', reverse=True).limit(20).rows:
    print row['Total (%)'], row['Total Child Labor Rank']

如果想用另一种方式来计算排名，可以用逆百分比创建一列数据。相对于使用每个国家雇用童工的百分比数据，我们可以使用普通儿童的占比来进行计算。这会让我们在使用 agate.Rank() 方法时，不需要 reverse 参数：

def reverse_percent(row): ➊
    return 100 - row['Total (%)']

ranked = table.compute([('Children not working (%)',
                         agate.Formula(number_type, reverse_percent)),
                        ]) ➋

ranked = ranked.compute([('Total Child Labor Rank',
                          agate.Rank('Children not working (%)')),
                        ]) ➌

for row in ranked.order_by('Total (%)', reverse=True).limit(20).rows:
    print row['Total (%)'], row['Total Child Labor Rank']

❶ 创建一个新的函数来计算并返回给定数据的逆百分比。

❷ 使用 agate 库的 compute 方法，传递一个列表作为参数，并返回新的数据列。列表中的每一个元素必须是元组对象，而元组的第一个元素包含列名称，第二个元素用来计算新的列。在这里，我们使用 Formula 类，其同样需要一个 agate 类型，同函数一起，创建一个列表值。

❸ 用 Children not working (%) 列的数据来创建有适当排序的 Total Child Labor Rank 列。

可以看到，compute 是一个非常好用的工具，它基于一个数据列（或多个数据列）来计算一个新的数据列。现在我们有了排名，让我们看看是否能够合并一些新数据集到童工数据集里。

9.1.3　联结多个数据集

在研究可以和童工数据联结的数据集时，我们碰到了很多无果而终的情况。我们尝试使用世界银行数据（http://data.worldbank.org/）来比较农业和服务经济数据，但是并没有找到任何好的联系。我们进行了更多的阅读，发现有些人把童工和 HIV 感染率联系了起来。我们观察了这些数据，但是并没有找到明显的总体趋势。顺着这个思路，我们想知道犯罪比率对童工雇用率是否有影响——但是这一次，我们依然没有发现任何关联。1

1查看本书的仓库，可以看到其中的一些探索。

经历这么多失败之后，在仔细考察数据和阅读一些文章时，一个特别的想法突然出现。政府腐败（或政府被认为有可能存在腐败）会不会影响童工雇用率？当我们阅读关于童工雇用的资料时，经常发现与反政府武装、学校和工业相关。如果大众不相信政府，并且必须创建一些未经政府批准的组织，这些都可能是招募这些希望工作和帮忙的人（甚至是儿童）的原因之一。

我们锁定了国际公开腐败感指数（Transparency International's Corruption Perceptions Index）数据集，并决定与 UNICEF 童工数据做比对。首先，我们需要把数据导入到 Python 中。下面代码交代了如何导入数据：

cpi_workbook = xlrd.open_workbook('corruption_perception_index.xls')

cpi_sheet = cpi_workbook.sheets()[0]

for r in range(cpi_sheet.nrows):
    print r, cpi_sheet.row_values(r)

cpi_title_rows = zip(cpi_sheet.row_values(1), cpi_sheet.row_values(2))
cpi_titles = [t[0] + ' ' + t[1] for t in cpi_title_rows]
cpi_titles = [t.strip() for t in cpi_titles]

cpi_rows = [cpi_sheet.row_values(r) for r in range(3, cpi_sheet.nrows)]

cpi_types = get_types(cpi_sheet.row(3))

我们再一次使用 xlrd 来导入 Excel 数据，并且复用在之前编写的解析标题和为 agate 库准备数据的代码。但是在你运行最后一行代码（这里调用了一个新的函数，get_types）之前，我们需要编写一些代码来帮助我们定义类型和创建表：

def get_types(example_row):
    types = []
    for v in example_row:
        value_type = ctype_text[v.ctype]
        if value_type == 'text':
            types.append(text_type)
        elif value_type == 'number':
            types.append(number_type)
        elif value_type == 'xldate':
            types.append(date_type)
        else:
            types.append(text_type)
    return types

def get_table(new_arr, types, titles):
    try:
        table = agate.Table(new_arr, titles, types)
        return table
    except Exception as e:
        print e

我们使用之前编写的相同代码来创建函数 get_types，它接受一行数据，为 agate 库输出一个类型列表。我们同样编写了 get_table 函数，函数中使用了 Python 内置的异常处理。

异常处理

整本书中，我们碰到了很多错误，并在它们发生时进行了处理。现在我们有了更多经验，可以开始预见潜在的错误，并做出适当的决定来处理它们。

代码要明确（特别是异常），这样就能够在代码中说明你预期的错误。这还会确保没有预见到的错误会抛出异常，进入错误处理的逻辑，输出错误日志，停止程序的执行。

当使用 try 和 except 时，我们告诉 Python：“请尝试执行这段程序。如果你碰到了错误，请停止执行前一节代码，执行 except 代码块中的代码。”下面是一个例子：

try:
    1 / 0
except Exception:
    print 'oops!'

这个例子使用的是通用的异常类型。通常我们希望捕获特定类型的异常，即认为程序会抛出的异常。例如，如果代码会将字符串转化为数字，我们就知道可能会出现一个 ValueError 异常。可以像下面这段代码一样处理这个情况：

def str_to_int(x):
    try: ➊
        return int(x) ➋
    except ValueError: ➌
        print 'Could not convert: %s' % x ➍
    return x

❶ 开始 try 代码块，它定义了代码可能抛出异常。try 关键字后面永远跟着一个冒号，并且单独占据一行空间。下面一行或者几行的代码，是一个 Python 的 try 代码块，使用 4 个空格缩进。

❷ 返回传入参数的整数形式。当参数是类似于 1 或者 4.5 的值时，这不会有问题。如果参数的值是 '-' 或者 'foo'，这会抛出一个 ValueError 异常。

❸ 开始 except 代码块，定义需要捕获的异常类型。这一行同样使用一个冒号作为结束，指定我们想要捕获一个 ValueError 异常（这样 except 代码块会只捕获 ValueError 异常）。这个代码块和下面的代码只在 try 语句抛出了这行代码中指定的异常时才会执行。

❹ 打印一行信息，告诉关于异常的信息。如果需要更新或改进代码，我们可以使用这段信息。

一般来说，我们想构建简明且明确的 try 和 except 代码块。这会让代码变得易读、可预测又明确。

你可能会问，为什么在之后编写的 get_table 函数中使用了 except Exception ？这是个好问题！我们总是希望明确代码；然而，当你第一次用一个库或数据集进行实验的时候，可能不清楚该预防哪些错误。

为了编写捕获特定异常的代码，你需要预测代码可能会抛出什么类型的异常。有 Python 内置的异常类型，但是也有你不熟悉的特殊库异常。如果你正在使用一个 API 库，作者可能会编写一个 RateExceededException 异常，来告诉你发送了太多的请求。当我们面对一个全新的库时，使用 except Exception 代码块，打印或记录日志会帮助我们更多地了解这些错误。

　当编写 except 代码块时，可以通过在异常后面添加代码 as e（在冒号前）存储异常到一个变量 e。因为打印了包含异常信息的变量 e，所以可以了解到更多关于触发异常的信息。最终我们会用更精确的异常，或者一系列的异常代码块，重新编写 except Exception 块，这样代码会运行得更顺利和可预测。

现在我们有了一个 get_table 函数来跟踪 agate 库的异常，并可考虑如何改进代码。我们可以使用新函数将腐败指数数据导入到 Python 中。尝试运行下面的代码：

cpi_types = get_types(cpi_sheet.row(3))

cpi_table = get_table(cpi_rows, cpi_types, cpi_titles)

大功告成！当你运行这段代码，我们的新函数 get_table 会让你看到抛出的错误，而不是函数完全中断。重复的标题可能意味着 = 标题列表中有一些坏标题。通过运行下面的代码来查看这个问题：

print pci_titles

可以看到有两个 Country Rank 列。通过在电子表格中查看 Excel 数据，我们看见的确有重复的列。为了方便起见，我们不会考虑去除重复数据，但是的确需要处理重复的列名称。我们需要拼接 Duplicate 到其中的一个列名称。下面的代码展示了如何处理重复列名称：

cpi_titles[0] = cpi_titles[0] + ' Duplicate'

cpi_table = get_table(cpi_rows, cpi_types, cpi_titles)

我们将第一个标题替换为 Country Rank Duplicate，并且再次尝试创建新的 pci_table:

cpi_rows = get_new_array(cpi_rows, float_to_str)

cpi_table = get_table(cpi_rows, cpi_types, cpi_titles)

现在我们有了没有任何差错的 cpi_table。我们可以着手将其与童工数据联结起来，查看它们之间有什么样的联系。在 agate 库中，有一个很好用的联结表的方法：join（http://agate.readthedocs.io/en/latest/api/table.html#agate.table.Table.join）。join 方法模仿 SQL 中的语义，将两张表通过一个共享键联结到一起。表 9-1 总结了不同的联结方式和对应的功能。

表9-1：表联结

联结方式

功能

左外联结

保留左侧表（或 join 语句中的第一张表）中的所有行数据，使用共享键来绑定右侧表（或 join 中的第二张表）的值。如果右侧表中没有匹配的值，使用空值来填充

右外联结

使用右侧表中的值作为初始的匹配键。如果在第一张表（左侧表）中没有对应的值，这些行会使用空值填充

内联结

只返回两张表中都能够通过共享键匹配到值的数据行

全外联结

保留两张表中的所有数据，当共享键相匹配时，组合两张表中的数据到一行中

如果数据并不是完全匹配，或者不具备一对一关系，同时你在使用外联结，就会有为空值的数据行。当表不匹配时，外联结保持表中的数据存在，并用空值替代缺失的数据。这在因为报告必需而想要保留不匹配的数据时非常有用。

如果想要联结 table_a 和 table_b，同时确保不会丢失任何 table_a 里的数据，那可以编写下面的代码：

joined_table = table_a.join(
    table_b, 'table_a_column_name', 'table_b_column_name')

在结果 joined_table 中，我们会得到基于传递的列名称，和 table_a 匹配的所有 table_b 值。如果 table_a 中有值不匹配 table_b，我们会保留这些行，但是它们会在 table_b 列上成为空值。如果 table_b 中有值没有在 table_a 中匹配，它们会被排除在新表之外。选择哪一张表在前面和使用哪一种方式的联结是非常重要的。

我们绝不想要空值存在。我们的问题围绕数据是怎样关联的，为了达到这个目的，我们想要使用内联结。agate 库的 join 方法允许传递 inner=True 参数，这会使函数仅作内联结，只保留匹配的行，不会在联结后有空值行。

我们尝试联结童工数据和新规整后的 cpi_table。当我们查看这两个表时，可以将它们通过国家 / 领土的名称匹配在一起。在 cpi_table 中，我们有 Country/Territory 列，同时，在童工数据中，我们有 Counties and areas 列。为了联结这两张表，运行下面的代码 :

cpi_and_cl = cpi_table.join(ranked, 'Country / Territory',
                            'Countries and areas', inner=True)

将匹配行放到新表 cpi_and_cl 中。我们可以通过打印几个值来查看这张表，同时研究新的联结后的列，像下面代码这样：

cpi_and_cl.column_names

for r in cpi_and_cl.order_by('CPI 2013 Score').limit(10).rows:
    print '{}: {} - {}%'.format(r['Country / Territory'],
                                r['CPI 2013 Score'], r['Total (%)'])

当查看列名称时，可以看到现在有了两张表里面的所有的列。对数据进行简单计数返回 93 行。我们不需要所有的数据点（pci_table 有 177 行，ranked 有 108 行），我们想要看到的就是关联在一起的数据。当使用 CPI 得分排序，打印出新的联结的表时，是否注意到一些其他的东西？我们只选择了最高的 10 行数据，但是一些有意思的信息变得清晰：

Afghanistan: 8.0 - 10.3%
Somalia: 8.0 - 49.0%
Iraq: 16.0 - 4.7%
Yemen: 18.0 - 22.7%
Chad: 19.0 - 26.1%
Equatorial Guinea: 19.0 - 27.8%
Guinea-Bissau: 19.0 - 38.0%
Haiti: 19.0 - 24.4%
Cambodia: 20.0 - 18.3%
Burundi: 21.0 - 26.3%

除了伊拉克（Iraq）和阿富汗（Afghanistan）两个国家，当国家有非常低的 CPI 得分（即政府腐败高概率）时，同时有非常高的童工雇用率。使用 agate 库的一些内置方法，我们可以研究数据集中存在的类似关系。

9.1.4　识别相关性

agate 库有一些很好的工具，供你在数据集上做简单的数据分析。这是一个很好的初始工具集——可以从 agate 库的工具开始，之后转向更高级的数据分析库，包括 pandas、numpy 和 scipy，按需选择。

我们想要确定政府腐败和童工雇用率之间是否有关联。我们将使用的第一个工具是简单的皮尔森相关系数（http://onlinestatbook.com/2/describing_bivariate_data/pearson.html）。agate 库基于这个算法开发了 agate-stat 库（https://github.com/onyxfish/agate-stats）。在这之前，你可以使用 numpy 进行相关性分析。相关系数（例如皮尔森相关系数）告诉我们数据是否关联，以及一个因子是否会影响另一个因子。

如果你还没有安装 numpy，可以通过运行命令 pip install numpy 安装它。之后，使用下面几行代码计算童工雇用率和政府腐败指数之间的相关性：

import numpy

numpy.corrcoef(cpi_and_cl.columns['Total (%)'].values(),
               cpi_and_cl.columns['CPI 2013 Score'].values())[0, 1]

我们首先得到了类似于之前曾见到的 CastError 异常的错误。因为 numpy 需要浮点型数据，而不是小数型，所以我们需要将数字转换回浮点数。我们可以使用列表生成式做这个转换：

numpy.corrcoef(
    [float(t) for t in cpi_and_cl.columns['Total (%)'].values()],
    [float(s) for s in cpi_and_cl.columns['CPI 2013 Score'].values()])[0, 1]

我们的输出显示出一些轻微的负相关：

-0.36024907120356736

　负相关意味着，一个变量增长，另一个变量会减小。正相关意味着两个变量会同时增长或减小。皮尔森相关系数在 -1 到 1 之间波动，0 意味着无相关性，-1 和 1 意味着相关性很强。

我们的结果是 -0.36，意味着弱相关，但是的确有关联。我们可以使用这个结果更加深入地研究这一数据集，搞清楚其中的含义。

9.1.5　找出离群值

随着数据分析的进行，你会想要使用一些其他的统计学方法来解释你的数据。一个入手点就是找出离群值。

　离群值出现在个别的数据明显有别于数据集其他部分的时候。离群值会告诉我们数据的一部分情况。有时候，去掉它们会展现出一个明显的趋势；有些时候，离群值本身会透露出很多信息。

有了 agate 库，找到离群值是很容易的。有两种方法可以做到这一点：一是使用标准差，第二个是使用绝对中位差。如果你有一些统计学知识，并且想要使用其中的一个，尽情去尝试！如果没有相关的统计学基础，在你的数据集上同时使用两种方式来分析偏差，可能会揭露出不同的结果。2

2更多关于绝对中位差和标准差的信息，查看 Matthew Martin 关于为什么我们仍在使用标准差的文章（http://www.separatinghyperplanes.com/2014/04/why-do-statisticians-use-standard.html），以及 Stephen Gorad 关于为什么及何时使用均差的学术文章（http://www.leeds.ac.uk/educol/documents/00003759.htm）。

　如果你已经知道数据的分布，可以采用适当的方式来确定变化值；但是在你第一次探索数据时，尝试使用多种不同的方法来确定数据的分布，并了解数据的组成。

我们将会使用 agate 表的标准差离群值（http://agate.readthedocs.io/en/latest/cookbook/statistics.html#identify-outliers）方法。这个方法返回包含至少 3 个高于或低于平均值的偏差值表。下面是使用 agate 表查看标准差离群值的方法。

　如果你在使用 IPython 处理数据，并且需要安装新的库，可以使用 IPython 的魔法命令 %autoreload，在另外一个终端安装新的库后重新加载你的 Python 环境。尝试执行 %load_ext autoreload，然后执行 %autoreload。没错！你拥有了新的库，却没有丢失任何的进程信息。

首先，需要通过运行命令 pip install agate-stat 安装 agate-stat 库。然后运行下面的代码：

import agatestats
agatestats.patch()

std_dev_outliers = cpi_and_cl.stdev_outliers(
    'Total (%)', deviations=3, reject=False) ➊

len(std_dev_outliers.rows) ➋

std_dev_outliers = cpi_and_cl.stdev_outliers(
    'Total (%)', deviations=5, reject=False) ➌

len(std_dev_outliers.rows)

❶ 使用童工雇用数据的 Total (%) 列和 agate-stats stdev_outliers 方法来查看我们的数据中是否含有容易找到的标准差离群值。我们将这个方法的输出赋值给一个新的表 std_dev_outliers。我们使用参数 reject=false 来告诉函数我们希望看到离群值。如果我们设置 reject 等于 True，将会得到没有离群值的数据。

❷ 查看我们发现了多少行离群值（这张表共有 94 行数据）。

❸ 提高偏差的大小，减少离群值的数量。（deviations=5。）

从输出中看出，我们对数据分布并没有很好的了解。当我们使用 Total (%) 列，尝试使用 3 作为标准差识别离群值时，得到了和当前表完全匹配的一张表。这不是我们想要的结果。当使用 5 作为差值界限时，我们并没有看到数据的变化。这告诉我们数据并不是常规的分布。为了找到数据中真正的偏差，我们需要更深入地探索来确定是否需要重新划分数据，使其变为我们研究的国家的子集。

可以使用平均绝对偏差检查 Total (%) 列数据的偏差：

mad = cpi_and_cl.mad_outliers('Total (%)')

for r in mad.rows:
    print r['Country / Territory'], r['Total (%)']

有趣！我们的确找到了一个更小的离群值的子集，但是得到了一个奇怪的结果列表：

Mongolia 10.4
India 11.8
Philippines 11.1

查看这个列表时，我们并没有看到数据中的任何最高值或最低值。这意味着，对于识别离群值来说，数据集并没有遵从正常的统计学规则。

　取决于数据集和数据的分布，这两个方法经常会有效地展示出数据的信息。如果没有的话，就像我们这个数据集，继续搞清楚数据能够告诉我们什么联系和趋势。

在探索了数据的分布和数据分布所展现的趋势后，你会想要探索数据中的分组关系。下面这一节解释了怎样对数据分组。

9.1.6　创建分组

为了进一步研究数据，我们将要创建分组，研究分组之间的关系。agate 库提供了很多不同的工具来创建分组，还有其他一些方法来聚合这些分组，确定分组之间的联系。早些时候，我们的童工数据集中有完好的各大洲数据。让我们尝试从地理角度，按照大洲分组数据，看一下这样是否会揭露一些与政府腐败数据之间的关系或总结出其他结论。

首先，我们要解决怎样拿到大洲数据的问题。在本书的 git 仓库中（https://github.com/jackiekazil/data-wrangling），我们提供了一个 .json 文件，其中列举了不同大洲包含的国家。使用这个数据，我们可以添加一列，展示每个国家所属的大洲，以便通过大洲分组。下面是这一过程的代码：

import json

country_json = json.loads(open('earth.json', 'rb').read()) ➊

country_dict = {}

for dct in country_json:
    country_dict[dct['name']] = dct['parent'] ➋

def get_country(country_row):
    return country_dict.get(country_row['Country / Territory'].lower()) ➌

cpi_and_cl = cpi_and_cl.compute([('continent',
                                  agate.Formula(text_type, get_country)),
                                ]) ➍

❶ 使用 json 库来加载 .json 文件。如果你观察这个文件的话，会看到文件中保存了类型为字典的列表。

❷ 遍历 country_dict，将 country 作为键、continent 作为值填充到字典中。

❸ 创建函数：接受国家作为参数，返回它归属的大洲。这个函数使用了 Python 的字符串方法 lower，将大写字母替换为小写形式。.json 文件包含的都是小写的国家名称。

❹ 使用 get_country 函数创建一个新的列，continent。沿用相同的表名称。

现在我们有了大洲和国家数据。我们需要做一个快速的检查来确保没有遗漏任何东西。为了检查，运行下面的代码：

for r in cpi_and_cl.rows:
    print r['Country / Territory'], r['continent']

呃，看起来我们落下了一些数据，因为我们可以在一些国家中看到 None 类型的数据：

Democratic Republic of the Congo None
...
Equatorial Guinea None
Guinea-Bissau None

最好不要丢失这些数据，让我们看一下为什么这些行数据没有匹配上。我们只想要打印出没有匹配上的行。可以使用 agate 来帮忙找到这些行，运行下面的代码：

no_continent = cpi_and_cl.where(lambda x: x['continent'] is None)

for r in no_continent.rows:
    print r['Country / Territory']

你的输出应该类似于下面这样：

Saint Lucia
Bosnia and Herzegovina
Sao Tome and Principe
Trinidad and Tobago
Philippines
Timor-Leste
Democratic Republic of the Congo
Equatorial Guinea
Guinea-Bissau

可以看到，没有大洲数据的国家列表很短。我们建议只修改 earth.json 数据文件，因为这会使在未来使用相同的数据文件关联相同的数据更简单。如果你使用代码来找到异常值并匹配它们，当用新数据重做时会变得复杂，每一次更新数据都需要修改代码。

为了修复 .json 文件中的匹配问题，我们需要搞清楚为什么国家没有被找到。打开 earth.json 文件，找到 no_continent 表中的几个国家。例如：

  {
      "name": "equatorial Guinea",
      "parent": "africa"
  },
....
  {
      "name": "trinidad & tobago",
      "parent": "north america"
  },
...
  {
      "name": "democratic republic of congo",
      "parent": "africa"
  },

正如我们在 .json 文件中看到的那样，有一些细微的差别，影响了我们顺利地找到国家归属的大洲。本书的 git 仓库中同样包含了一个名叫 earth-cleaned.json 的文件，这个文件在 earth.json 文件上做出了一些必要的修改，比如添加 the 到刚果（Democratic Republic of the Congo，DRC）条目中，将若干条目中的 & 改为“and”。我们现在可以重新运行本节最初的代码，并且使用新的文件作为 country_json 数据。你需要重新联结表，避免重复的列（使用之前用过的联结两张表的代码）。在重新运行这两部分的代码后，你应该不会得到不匹配的国家了。

让我们尝试分组完整的大洲数据，看一下会发现什么。下面的代码做了这件事：

grp_by_cont = cpi_and_cl.group_by('continent')

print grp_by_cont ➊

for cont, table in grp_by_cont.items(): ➋
    print cont, len(table.rows) ➌

❶ 使用 agate 库的 group_by 方法，这会返回一个字典，其中键是大洲名称，值是一个新表，包含这个大洲的值。

❷ 遍历返回的字典，看一下每张表有多少行。我们将 items 中的键 / 值对分别赋值给变量 cont 和 table，这样 cont 代表键或者大洲名称，table 代表对应表的值。

❸ 打印我们的数据，来检查分组。我们使用 Python 的 len 函数来计算每一张表中的行数。

运行这段代码，我们得到了下面的输出（注意，你的顺序可能不同）：

north america 12
europe 12
south america 10
africa 41
asia 19

我们可以看到，非洲和亚洲的数据要大于其他各大洲。这让我们很感兴趣，但是 group_by 方法对于聚合数据来说并不十分容易。如果想要聚合数据，创建一个汇总列的话，我们需要看一下 agate 库中的聚合方法。

我们注意到 agate 表的 aggregate 方法（http://agate.readthedocs.io/en/latest/cookbook/tatistics.html#aggregate-statistics），这个方法接受一个分好组的表和一系列的聚合操作（例如求和）来基于分组计算一个新的列。

在查看了 aggregate 方法的文档后，我们最感兴趣的是大洲童工数据和腐败感指数相对比的结果。我们想要使用一些统计学方法来把每个分组当作一个整体来看待（使用中位数和平均数），但是同样需要识别出最极端的数据（CPI 得分的最小值，童工雇用率的最大值）。这会给我们一些良好的比较结果：

agg = grp_by_cont.aggregate([('cl_mean', agate.Mean('Total (%)')),
                             ('cl_max', agate.Max('Total (%)')),
                             ('cpi_median', agate.Median('CPI 2013 Score')),
                             ('cpi_min', agate.Min('CPI 2013 Score'))]) ➊

agg.print_table() ➋

❶ 在我们分好组的表上调用 aggregate 方法，传递一个元组列表作为参数，其中包括新的列名称和 agate 的聚合方法（利用新的列命名计算新列的值）。我们想要计算童工雇用百分比的平均值和最大值，还有腐败感指数（CPI）得分的中位数和最小值。根据你的问题和数据的不同，你可以使用不同的 agate 方法。

❷ 打印新表，这样我们可以从视觉上直观比较数据。

当运行这段代码后，你会看到下面的结果：

|----------------+-----------------------------+--------+------------+----------|
| continent      |                     cl_mean | cl_max | cpi_median | cpi_min  |
|----------------+-----------------------------+--------+------------+----------|
| south america  | 12,710000000000000000000000 |   33,5 |       36,0 |       24 |
| north america  | 10,333333333333333333333333 |   25,8 |       34,5 |       19 |
| africa         | 22,348780487804878048780487 |   49,0 |       30,0 |        8 |
| asia           |  9,589473684210526315789473 |   33,9 |       30,0 |        8 |
| europe         |  5,625000000000000000000000 |   18,4 |       42,0 |       25 |
|----------------+-----------------------------+--------+------------+----------|

如果想更仔细地查看数据相关的图表，可以使用 agate 表的 print_bars 方法，该方法有一个标签列（这里是 continent）和一个数据列（这里是 cl_max），在 IPython 会话中打印童工雇用数据最大值的图表。输出如下：

In [23]: agg.print_bars('continent', 'cl_max')

continent     cl_max
south america 33,5 ▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
north america 25,8 ▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
africa        49,0 ▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
asia          33,9 ▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
europe        18,4 ▓░░░░░░░░░░░░░░░░░░░░
                   +----------+-----------------+--------------+-------------+
                  0,0        12,5              25,0           37,5          50,0

现在我们的大洲数据有了几种易于比较的输出，同时这张图片展示了一些趋势。我们注意到非洲的童工雇用率均值最高，最大值也最高，亚洲和南美洲紧随其后。亚洲和南美洲相对较低的均值意味着这些区域可能存在一个或多个离群值。

我们看到腐败感指数的中位值相对差别不大，欧洲最高（政府腐败感最低）。然而，当我们看最小值（最糟糕的政府腐败感得分）时，可以看到非洲和亚洲再一次获得了最糟糕的得分。

这说明该数据集中有几个故事供我们深入探索。我们能够看到政府腐败感和童工雇用之间有联系（尽管联系很弱）。我们同样可以研究哪些国家和大洲是最糟糕的童工雇用国家和最腐败的政府。我们可以看到非洲有着非常高的童工雇用率和相对更高的政府腐败感。我们知道亚洲和南美洲中存在一两个国家，相对于他们的邻居，在童工雇用率上面引人注目。

我们的聚合探索就到这里了。我们可以继续使用已创建的表来寻找更多信息，进行更深入的探索。

9.1.7　深入探索

agate 库还有其他一些强有力的特性，另外还有一些有趣的数据分析库可以用来在你的数据上进行实验。

　根据你的数据和问题的不同，你可能会发现一些特性和库比其他特性和库更有用，但是我们强烈建议你找到使用不同工具来实验的方式。同数据分析本身一样，这会加深你对 Python 和数据分析库的理解。

agate-stat 库有一些有趣的统计方法我们还没有探索过。你可以通过 GitHub 跟踪最新的发布和功能（https://github.com/onyxfish/agate-stats）。

除此之外，我们建议你继续探索 numpy。你可以使用 numpy 来计算百分位数值（https://docs.scipy.org/doc/numpy-dev/reference/generated/numpy.percentile.html）。你同样可以更深入地使用 scipy，使用 z 得分统计方法来确定离群值（https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.mstats.zscore.html）。

如果你有时间敏感的数据，numpy 能够计算数据之间列与列的变化，从而探索随时间推移数据的变化（https://docs.scipy.org/doc/numpy/reference/generated/numpy.diff.html）。agate 同样可以计算时间相关数据中列的变化（http://agate.readthedocs.io/en/1.0.0/tutorial.html#computing-new-columns）。不要忘记在组成时间列的时候使用时间类型，因为这样做你能够在时间上做一些有趣的分析（例如随时间变化的百分比变化或一系列时间变化的映射）。

如果你想要通过更多的统计方法来探索数据，可安装 latimes-calculate 库（http://latimes-calculate.readthedocs.io/en/latest/index.html）。这个库有很多计算方法，同样还有一些有趣的地理数据分析工具。如果你获取了一些地理数据，这个库可以提供一些有价值的工具来帮你更好地理解、映射和分析数据。

如果你想要更深入进行数理计算和分析，我们强烈推荐 Wes McKinney 的《利用 Python 进行数据分析》一书。这本书介绍了一些更健壮的数据分析库，包括 pandas、numpy 和 scipy 系列的库等。

花时间用一些方法利用我们之前学到的课程来探索你的数据。现在我们会进一步分析数据，确定一些可以得出结论和分享知识的方式。

9.2　分析数据

如果你已经尝试了 agate 库手册（http://agate.readthedocs.org/en/latest/cookbook.html，用于研究的各种不同的方法和工具的汇总）中的更多示例，就会足够熟悉数据并能开始你的分析。

　数据探索和数据分析之间有哪些不同？当分析数据时，我们提出问题并且尝试使用已有的数据回答这些问题。我们可能会对数据集进行组合和分组，以构建一个统计可用的样本。而在数据探索中，我们只是想要研究数据集的一些趋势和属性，不尝试去回答特定的问题或得出确定的结论。

在一些基本的分析后，我们可以尝试回答在数据探索中发现的一些问题：

为什么在非洲童工雇用的概率更高？

在亚洲和南美洲存在什么样的童工雇用离群值？

腐败感和童工雇用率有什么关系？

对于你的数据集，你会有不同的问题，但是尝试跟随我们的实例，并且找到你想要探索的趋势。任何统计学上的离群值或者聚合趋势都可以将你引向有趣的问题去研究。

对我们的数据来说，最有趣的问题是，在非洲政府腐败感和童工雇用的关系。政府腐败，或者政府腐败感，是否会影响社区保护童工不被雇用的能力？

　根据所使用的数据集和数据探索结果，你可能会有很多感兴趣、想要探索的问题。尝试聚焦于一个具体的问题，并用你的分析来回答它。针对多个具体问题重复这一过程。专注会帮助你找到好的答案，保持你的分析明确清晰。

回答这个问题需要更多的探索和更多的数据集。我们可能希望阅读更多的文章，看一下在这个主题上有哪些研究结果。我们可能还希望访问这一领域的专家。最终，我们可能希望选择非洲的一个特定地区或一系列国家，来更好地评估童工雇用情况。下面这一小节展示了怎么做这件事。

9.2.1　分离和聚焦数据

为了之后的分析，我们首先需要分离出非洲国家的数据，更加充分地探索这一子集的数据。我们已经知道了很多使用 agate 库来过滤数据的方式，所以让我们从这里开始。下面的代码展示了怎样把非洲的数据同其他数据分离开来：

africa_cpi_cl = cpi_and_cl.where(lambda x: x['continent'] == 'africa') ➊

for r in africa_cpi_cl.order_by('Total (%)', reverse=True).rows:
    print "{}: {}% - {}".format(r['Country / Territory'], r['Total (%)'],
                                r['CPI 2013 Score']) ➋

import numpy
print numpy.corrcoef( ➌
    [float(t) for t in africa_cpi_cl.columns['Total (%)'].values()],
    [float(c) for c in africa_cpi_cl.columns['CPI 2013 Score'].values()])[0, 1]

africa_cpi_cl = africa_cpi_cl.compute([('Africa Child Labor Rank',
                                      agate.Rank('Total (%)', reverse=True)),
                                      ])

africa_cpi_cl = africa_cpi_cl.compute([('Africa CPI Rank',
                                      agate.Rank('CPI 2013 Score')),
                                      ]) ➍

❶ 使用表方法 where 来过滤出所属大洲是 africa 的行。

❷ 使用某种格式化方法打印这些行，这样我们可以查看数据并进行彻底检查。我们想要确保只有非洲国家的数据，并且能够看到总童工雇用百分比和 CPI 得分。

❸ 在分离出最感兴趣的数据后，看一下皮尔森相关系数是否变化。

❹ 添加一个新的排序列，来展示我们子集中国家之间的相互排名。

在这个数据子集中，我们计算了新的皮尔森相关系数：

-0.404145695171

我们的皮尔森相关系数下降了，表示相对于全球数据，非洲数据中童工雇用和腐败感之间有着更强的相关性。

现在让我们看一下，是否可以找出一个好故事，找到我们想要去研究的数据点。我们会计算腐败感和童工雇用率的平均值，输出有最高童工雇用率和最糟糕的腐败感指数的国家（即比均值表现更差的国家）。下面是相关的代码：

cl_mean = africa_cpi_cl.aggregate(agate.Mean('Total (%)'))
cpi_mean = africa_cpi_cl.aggregate(agate.Mean('CPI 2013 Score')) ➊

def highest_rates(row):
    if row['Total (%)'] > cl_mean and row['CPI 2013 Score'] < cpi_mean: ➋
        return True
    return False

highest_cpi_cl = africa_cpi_cl.where(lambda x: highest_rates(x)) ➌

for r in highest_cpi_cl.rows:
    print "{}: {}% - {}".format(r['Country / Territory'], r['Total (%)'],
                                r['CPI 2013 Score'])

❶ 计算我们最感兴趣的列均值：腐败得分和童工雇用率。

❷ 创建函数来识别有着高童工雇用率和低 CPI 得分（即高腐败）的国家。

❸ 函数 highest_rates 返回 True 或 False，来选择一行数据。这个 lambda 函数判断国家的童工雇用率和腐败感是否高于均值。

当运行这段代码时，我们看到了一些有趣的输出。特别是下面这些行：

Chad: 26.1% - 19.0
Equatorial Guinea: 27.8% - 19.0
Guinea-Bissau: 38.0% - 19.0
Somalia: 49.0% - 8.0

我们输出了一些与均值离得不太远的位于“中部”的数据，随之是这些有着低 CPI 得分和高童工雇用率的数据。因为我们对为什么这里会有高童工雇用率，以及腐败是怎样影响童工雇用率这些问题感兴趣，所以这些数据对我们来说最为适合。

随着研究的继续，我们想要找出这些国家正在发生着什么。是否有和这些国家年轻人和童工相关的电影或文档？是否有关于这一主题的文章和书籍？是否有我们可以联系的专家或研究者？

当更加深入地观察这些国家，我们看到一些明显的事实：贩卖儿童、性虐待、非法的宗教团体、大量的小摊贩和劳动者需求。这些事实是否与选举权被剥削有关？与民众对政府不信任有关？我们是否可以追踪这些国家的民众和他们的邻里？我们是否能够找到组织或人来缓解这些问题？

随着时间的推移，观察政治和时代变化的影响是很有趣的事情。我们可以详细考察 UNICEF 的数据，或者聚焦于一个国家，利用 UNICEF 的多指标类集调查（https://www.unicef.org/statistics/index_24302.html）数据来理解过去几十年发生的变化。

对于你自己的数据集，你需要确定未来探索时有哪些可能性。你能找到更多的数据以供探索吗？你是否可以访问一些人，或者在一段较长的时间中找到一些趋势？是否有这一主题的书籍、电影或者文章能够给你更多启发？你的分析是未来研究的开始。

9.2.2　你的数据在讲什么

现在我们已经探索和分析了数据，可以开始搞清楚数据在告诉我们什么。正如我们在第一次研究童工数据集时经历的那样，有时数据并没有什么联系，没讲述任何故事，没有任何相关性。发现这一点也没关系！

　有时候，没找到相关性将促使我们继续研究，以找到真正存在的联系。有时候，没找到联系，这件事情本身就是一个发现。

在数据分析中，我们寻找趋势和模式。正如我们在童工雇用数据中看到的那样，大多数时候分析是更深入研究的开始。和数据讲故事一样，增加人们的声音或者其他的角度，都是找到联系和发现问题的绝佳方式。

如果你发现了一些联系，即使是很弱的联系，也可以更深入地挖掘。这些联系会通向更好的问题和更专注的研究。正如我们在童工数据中看到的那样，我们对研究越专注，就越容易看到联系。从宽泛的研究开始是很好的，但是用更加精确的视角来结束研究十分重要。

9.2.3　描述结论

当你已经分析了数据并且理解了数据中的联系，就可以开始确定你能得出什么结论。真正了解你的数据集和主题非常重要，这会对你的想法提供强有力的支持。随着你的数据分析、访谈、研究的完成，你的结论会逐渐形成，你只需要确定用什么方式来向全世界分享这些结论。

　如果你在寻找确切结论的过程中陷入困境，在你的发现中包含开放性的问题是完全可取的。一些大的故事就是从几个简单的问题开始的。

如果你能够阐明主题，指出为了得出一个更全面的结论，需要更多的文档、研究和行动的话，这本身就是一个很重要的信息。正如我们在研究中发现的那样，很难说政府腐败引发了高的童工雇用率，但是我们可以说，这之间有很弱的相关性，并且我们想要研究和分析它们之间关联的方式——特别是在某些非洲国家。

9.2.4　将结论写成文档

当你发现一些结论和更多想研究的问题之后，应该开始将你的工作成果写成文档。作为文档和最终展示成果的一部分，你需要对使用的数据源和分析的数据数量了如指掌。在我们的问题中，我们只研究了大约 90 个数据点，但它们代表了我们想要研究的部分。

你可能会发现你关注的数据集比预期的小。只要你清楚你使用的方法和使用小数据集的原因，就不会把听众和报告引入歧途。在下一章，我们会更深入地探究如何报告发现，将我们的想法和工作写成文档，把我们的想法和世界分享。

9.3　小结

在这一章，我们使用了一些新的 Python 库和技术，探索和分析了我们的数据集。你已经能够导入数据、联结数据、分组数据，并且基于发现创造新的数据集。

现在你可以使用统计学方法来找到离群值，衡量数据之间的相关性。你可以通过分离有趣的分组，并且深入数据探索之中，确定清晰的、可回答的问题来研究。如果你曾经使用过 IPython 和 %store 来保存变量，在下一章，我们会用这个命令做更多的交互。

现在你应该能够：

使用 agate 库评估你的数据；

确定哪些事，如果有的话，在数据中是至关重要的；

找到数据中的入手点或一部分的数据来做深入研究，得到结论；

通过分析和探索数据挑战你的假设。

本章中涉及的新概念和库总结在表 9-2 中。

表9-2：新的Python编程概念和库

概念/库

功能

agate 库

使数据分析变得简单，能够从 CSV 数据中读取数据，创建供分析的表，运行基本的数据分析函数，在数据集上应用过滤器，洞察数据

xlrd ctype 和 ctype_text 对象

当使用 xlrd 分析 Excel 数据时，让你能够轻松地看到数据的类型

isintance 函数

检验 Python 对象的类型。如何类型匹配，结果返回一个布尔值

lambda 函数

Python 中的单行函数，对数据集的简单过滤或解析非常有用。注意不要书写不易阅读和理解的 lambda 函数。如果函数很复杂，尝试用一个小函数来代替 lambda 函数

联结（内联结，外联结，左联结，右联结）

允许你通过一个或多个匹配的域联结两个不同的数据集。根据联结数据方式的不同（内 / 外和左 / 右），你会得到不同的数据集。花一些时间思考什么类型的联结更符合你的需求

异常处理

使你能够使用代码预见和处理 Python 异常。明确和清楚的异常捕获永远是更好的，这样你不会捕获过度泛化的异常而漏掉 bug

numpy coerrcoef

使用统计学方法，例如皮尔森相关系数，来确定数据集中的两部分是否有联系

agate mad_outliers 和 stdev_outliers

使用统计学模型和工具，例如标准差或平均偏差，来确定数据集是否有特殊的离群值或不合适的值

agate group_by 和 aggregate

根据特定的属性对数据集分组，通过运行聚合分析，查看在分组间是否有明显的不同之处（或相似之处）

在下一章中，你会学习如何使用可视化和讲故事的工具来在 Web 和其他媒介上分享结论。

第 10 章　展示数据

你已经学习了如何分析数据，现在想要展示它。针对不同听众，演示可能会有很大的差别。我们会在这一章学习多种不同类型的演示：从简单的可以在电脑上制作的演示文件到交互式的网站。

取决于你想要展示的内容，通过图表、地图或图片进行可视化可能是你尝试讲的故事中一个重要的部分。我们会学习如何构建并运行自己的站点，分享发现。还会介绍如何分享 Jupyter notebook，其他人通过它可以看到你的代码、图表、图片和结论。

首先，我们会探索如何考虑你的听众，开始讲述你通过数据分析发现的故事。

10.1　避免讲故事陷阱

讲故事并不简单。取决于主题，你可能很难从数据中得出可靠的结论。你可能会遇到不一致或不确定的数据。这没关系。建议你继续研究，也许在数据集中找到的不同示例中就蕴含着故事。

　讲故事时所面临的一些困难是由数据分析时的个人偏见带来的。正如经济学家和记者 Allison Schranger 在“The Problem with Data Journalism”（http://qz.com/189703/the-problem-with-data-journalism/）一文中讨论的，我们不可避免地会在分析时带有偏见。她的建议是，承认这些偏见，并试着去了解数据，避免为了讲故事的目的而去曲解它。

不要擅自假定你所要讲的故事和数据是一致的。尝试先研究数据，然后讲述数据研究所得。不要花太多时间来操作数据。如果需要大量修改数据（标准化、归一化和去除离群值），你可能应该找找其他故事或不同的数据。

记住一点，讲故事是成为领域专家的重要部分。通过研究数据掌握的信息有助于你阐明新的主题和观点。理解自己的偏见，带着这样谦逊的态度，你的故事将会有效且具有启发性。

10.1.1　怎样讲故事

确定你想要讲的故事，同确定如何去讲述它一样重要。你可以使用图表、图形、时间线、地图、视频、文字和交互式的内容来讲述故事。你可以在网上发布它，或者在各种会议中演示它。你可以将它上传到一个视频分享网站。无论选择什么方式，确保讲故事的方式增强了你的发现。没有什么比看到一个糟糕的展示更令人沮丧了，它实际上抹杀了你试图讲述的故事。

在下面几个小节中，我们会评估你的听众、你的故事和可用的平台是怎样影响演示选择的。建议你阅读所有这些选择，即使你已经有了有关展示发现方式的想法。这会让你更好地理解可用的选择，即使你坚持最初的选择。对于那些面对广泛听众的演示，不同形式的组合可能是最好的选择。

确定你计划未来多久更新一次数据是讲故事的另一个部分。是持续更新吗？你的听众不久之后就能听到更多关于这个故事的信息，还是要期待年度报告？你是否能够清楚地告诉他们何时及以何种方式更新？只有你清楚听众的期望，让他们等待才是一个不错的想法。

10.1.2　了解听众

听众和内容一样重要。通过识别目标听众，你可以确定他们关于某个话题已经知道什么，什么是他们最感兴趣的内容，以及对他们来说效果最好的学习方式是什么。如果未能与听众有效沟通，那么你创建的故事就会没人感兴趣。

如果报告或展示是你工作的一部分，确定听众应该十分容易。无论是工作中的一个小组、一个执行团队，还是一个日报或年刊，你精确地了解谁会阅读你的报告。

　如果你想向更多人展示数据，你应该研究一下已有成果，以及哪些人想要进一步深入地了解。熟悉目标领域的已有成果会帮你确定现有或潜在的听众。

如果你不确定目标听众，一个好的策略是接触对该主题明显有不同兴趣程度的不同的人，例如，一位父辈或导师、一位同事和一名学员（从你的展示和话题的角度来看）。取决于有关话题的知识水平，是否不同的人对故事中的某一部分更感兴趣？是否不同年龄和经验的听众会提出不同的问题？在讲解某个话题时，注意听众的问题，同时观察他们的反应，并基于这些观察修正关于目标听众的说明。

一旦确定了目标听众，你可以更深入地了解他们。根据听众的不同，使用下面附注栏中的建议来帮助完善讲故事的方式。

与听众交谈

当思考如何讲述故事给听众时，搞明白他们如何学习和理解这个世界，特别是你的主题，这一点很重要。这些问题会指引你讲故事的过程，将发现以最佳方式传达给目标听众。

你的听众是如何学习新事物的？在线学习？口口相传？通过出版物？

关于这个主题你的听众有多少先验知识？是否有听众会感到陌生的单词或想法？

你的听众能否自己探索数据？

你的听众会花费多少时间和注意力听故事？

在与你或他人谈论这个故事时，听众的参与度如何？

如果有新的信息发布，你的听众是否想要被告知并更新信息？

这些只是众多问题中的一部分，你可以通过这些问题来确定真正的听众，以及他们如何才能最好地消化你的故事。以这些问题为最初的提示，让它们引导你找到更多关于如何分享发现的问题。

如果你找到了听众，准备好开始讲故事了，你就可以着手研究通过可视化工具讲述数据故事的方式了。

10.2　可视化数据

处理数据时，你可能希望使用一些可视化工具来讲故事。根据故事的不同，你的可视化选择可能是图表、图片或者时间线。无论你如何展示数据，第一步是确定哪些可视化数据是有用且相关的。

在以可视化方式讲故事的过程中，确定怎样展示发现至关重要。正像 Alberto Cairo 在其关于数据可视化的博客文章（http://www.thefunctionalart.com/2014/08/to-make-visualizations-that-are.html）中写道的，如果不展示所有相关的数据，可能会让听众对你的方法和发现有疑问。

　这类似于用文档详细描述数据分析和方法论，我们需要文档化和保护可视化探索和数据的展现，确保不会遗漏故事中重要的部分。

在这一节中，我们会探索如何使用图表、时间序列、时间线、地图、混合多媒体、文字、图片和影音来分享发现。取决于你的听众，可能会有不同类型的工具适用于你的故事。每种类型都有其优势和缺点，我们会在探索的过程中介绍这些。

10.2.1　图表

图表是分享数字数据的良好方式，尤其是比较不同的数据集或不同的分组时。如果数据有一个清晰的趋势，或者数据显示出特定的离群值，图表会帮你向听众展现这些发现。

你可以使用条形图来并排展示大量数据。比如，在《华盛顿邮报》关于婴儿死亡率的报道（http://www.washingtonpost.com/blogs/wonkblog/wp/2014/09/29/our-infant-mortality-rate-is-a-national-embarrassment/）中，Christopher Ingraham 使用了条形图来并排比较不同国家的婴儿死亡率。

为了展示随时间推移的趋势，人们通常会使用折线图。Christopher Ingraham 同样使用了折线图来比较不同年龄下的婴儿死亡率。通过条形图可以看到，美国在婴儿保护方面滞后于其他国家。折线图可以比较不同国家的婴儿死亡率随时间变化的情况，让我们从另外一个角度观察数据。

你会注意到，作者选择在折线图上仅展示少数几个国家，而不是像在条形图上展示所有的国家。他为什么做出这样的决定？可能是他检查了数据，发现包含更多国家的数据会使得图表难以阅读。

这些都是可视化发现时需要做出的各种决定。为了确定什么样的图表适合你，什么类型的图表最有用，首先定义你想要通过图表展示什么。Extreme Presentation 博客（http://extremepresentation.typepad.com/blog/2006/09/choosing_a_good.html）上有简便的流程图工具，当你开始思考这些问题时，可以从这里开始。Juice Labs 开发了一个交互式图表选择器（http://labs.juiceanalytics.com/chartchooser），展示了一些相同的概念。

　每个图表都有其优势和弱点。如果你想要展示关系，可以使用散点图、气泡图或者折线图，所有这些都可以展示数据相关性。条形图更适合比较多个对象。如果你想要展示数据的组成或因素，可以创建一个堆叠条形图。为了展示分布，你可以使用时序图或者柱状图。

让我们考虑一下迄今为止研究过的数据，使用一些 agate 内置特性来绘制数据。

使用matplotlib绘制图表

Python 的一个主要图表和图片库是 matplotlib，可以用来绘制数据集。它是生成简单图表的很好的方式，你越熟悉图表库，你的图片和图表会越高级。首先，需要运行 pip install matplotlib 来安装它。

让我们展示政府腐败感得分和童工雇用率的对比，下面是代码。

import matplotlib.pyplot as plt

plt.plot(africa_cpi_cl.columns['CPI 2013 Score'],
           africa_cpi_cl.columns['Total (%)']) ➊

plt.xlabel('CPI Score - 2013') ➋
plt.ylabel('Child Labor Percentage')
plt.title('CPI & Child Labor Correlation') ➌

plt.show() ➍

❶ 使用 pylab 的 plot 方法，传递 x 和 y 的标签数据。传递的第一个变量是 x 坐标系，第二个变量是 y 坐标系。这会创建一个 Python 图表，绘制这两个数据集。

❷ 调用 xlabel 和 ylabel 方法标记图表坐标系。

❸ 调用 title 方法为图表命名。

❹ 调用 show 方法来绘制图表。所有在调用 show 之前关于图表的操作，会显示在系统默认的图片程序中（例如 Preview 或 Windows 图片查看器）。标题、坐标标签和任何其他通过 matplotlib 设置的属性都会展示在这个图表中。

喔！ Python 渲染了图 10-1 中的图表。1

图 10-1：童工和 CPI 图表

我们能够明确看到趋势是整体下跌的，但是同样看到中部的数据并没有呈现特定的趋势。事实上，数据变化得很剧烈，这说明童工和政府腐败感并不是在所有国家都有联系，只在部分国家有联系。

让我们仅使用情况最糟糕的国家的数据来绘制图表。在 9.2.1 节中，我们已经分离出了这些国家。使用 highest_cpi_cl 表再次运行前面的代码，会看到图 10-2 中的图表。

现在可以看到一个清晰的下降趋势，在这些最糟糕的国家中，随着童工雇用率和政府腐败感指数的下降，出现了一些异常。

图 10-2：高童工雇用率国家图表

pylab 中有很多种图表可用，包括直方图、散点图、条形图和饼形图。强烈建议你查看一下 matplotlib.org 关于 pyplot 的介绍（http://matplotlib.org/1.4.2/users/pyplot_tutorial.html#pyplot-tutorial），包括如何改变图表的不同属性（颜色、标签、大小），使用多图、子区（subplot）和更多图表类型。

　图表化数据便于发现数据集中的异常或离群值。使用 Python 图表库中各种可用的图表方法，可以帮你研究数据中的故事和关系。

越多地使用库的图表工具集，理解哪个图表最适合你的数据集就越容易。

使用Bokeh绘图

Bokeh（http://bokeh.pydata.org/）是一个 Python 绘图库，能够用相当简单的命令来绘制更复杂的图表类型。如果想要创建一个条形图、散点图或时间序列图，尝试 Bokeh，看看是否合适。让我们尝试使用 Bokeh，基于各国家创建一个 CPI 和童工数据的散点图。运行下面的命令安装 Bokeh。

pip install bokeh

之后利用 agate 表使用一些简单的命令创建散点图：

from bokeh.plotting import figure, show, output_file

def scatter_point(chart, x, y, marker_type): ➊
    chart.scatter(x, y, marker=marker_type, line_color="#6666ee",
                  fill_color="#ee6666", fill_alpha=0.7, size=10) ➋

chart = figure(title="Perceived Corruption and Child Labor in Africa") ➌
output_file("scatter_plot.html") ➍
for row in africa_cpi_cl.rows:
    scatter_point(chart, float（row['CPI 2013 Score']),
                  float(row['Total (%)']）, 'circle') ➎

show(chart) ➏

❶ 定义一个函数，scatter_point，接受一个图表、x 轴和 y 轴值、标记的类型（圆形、正方形、矩形），并且添加这些点到图表中。

❷ 图表的 scatter 方法需要两个必需的参数（x 轴和 y 轴）和一些不同的关键参数，为这些点添加样式（包括颜色、透明度、大小）。这行代码传递了边缘颜色和填充颜色以及大小和透明度到函数中。

❸ 使用函数 figure 创建图表，同时传入一个标题。

❹ 使用函数 output_file 定义输出的文件。这会在你运行代码的文件夹下创建文件 scatter_ plot.html。

❺ 对于每一行数据，使用 CPI 得分作为 x 轴，童工雇用率作为 y 轴，添加一个数据点。

❻ 在浏览器窗口中展示这张图表。

当你运行这段代码，它会在浏览器中打开一个窗口，展示这张图表（见图 10-3）。

图 10-3：CPI 和童工散点图

这张图非常好，但是我们看不出这些点的含义。Bokeh 可以添加交互元素到图表中。让我们添加一些试试。

from bokeh.plotting import ColumnDataSource, figure, show, output_file
from bokeh.models import HoverTool ➊

TOOLS = "pan,reset,hover" ➋

def scatter_point(chart, x, y, source, marker_type): ➌
    chart.scatter(x, y, source=source,
                  marker=marker_type, line_color="#6666ee",
                  fill_color="#ee6666", fill_alpha=0.7, size=10)

chart = figure(title="Perceived Corruption and Child Labor in Africa",
               tools=TOOLS) ➍

output_file("scatter_int_plot.html")
for row in africa_cpi_cl.rows:
    column_source = ColumnDataSource(
        data={'country': [row['Country / Territory']]}) ➎
    scatter_point(chart, float(row['CPI 2013 Score']),
                  float(row['Total (%)']）, column_source, 'circle')

hover = chart.select(dict(type=HoverTool)) ➏

hover.tooltips = [
    ("Country", "@country"), ➐
    ("CPI Score", "$x"),
    ("Child Labor (%）", "$y"),
]

show(chart)

❶ 导入我们用过的主要的库，并导入 ColumnDataSource 和 HoverTool 类。

❷ 为最终的图表定义你想要使用的工具（http://bokeh.pydata.org/en/latest/docs/user_guide/tools.html#specifying-tools）。这行代码添加了 hover，所以可以使用悬停方法。

❸ 把 source 添加到必需的参数中。这会存储国家名称信息。

❹ 传递 TOOLS 变量到图片初始化函数中。

❺ 变量 column_source 现在保存着一个数据源字典，其中是国家名称。这一行代码将国家名称作为列表传递，因为字典的值必须是一个可迭代对象。

❻ 从图表中选择 HoverTool 对象。

❼ 使用悬停对象的 tooltips 方法，展示不同的数据属性。@country 选择了通过列数据源传入的数据，而 $x 和 $y 选择图表中 x 和 y 数据点。

现在你的图表应该看起来类似于图 10-4。

图 10-4：CPI 和童工交互散点图

　随着你移动游标到每一个点，x 和 y 的数据会随之变化。为了优化这个图表，可以通过输入新的键值对到 data 字典中，添加精确的数据点到 column_source 对象中。

Bokeh 有一个很好的示例库（http://bokeh.pydata.org/en/latest/docs/gallery.html）和可用的代码来帮助你上手。建议你花些时间在图表上，尝试一下 Bokeh。

1如果图表没有展示出来，根据 Stack Overflow 上的指示（http://stackoverflow.com/questions/7534453/matplotlib-does-not-show-my-drawings-although-i-call-pyplot-show/7534680#7534680），来确定 matplotlib 设置在哪里，并设置库的后端为默认选项之一（Mac/Linux 系统上的 QT4Agg 或 Windows 系统上的 GTKAgg）。对于 Windows 系统，可能同样需要运行 pip install pygtk。

10.2.2　时间相关数据

时间序列和时间线数据展示随时间推移的发现。时序图表展示数据随时间的变化（通常表现为折线图、柱状图或直方图）。时间线允许你通过标记随着时间推移发生的活动、事件和变化来直观地讲述数据的故事。

时间序列数据

时间序列展示随时间推移产生的趋势，尤其是当聚焦于一个因素时十分有效。《华尔街日报》发布过一个非常棒的关于疫苗和发病率的时间序列（http://graphics.wsj.com/infectious-diseases-and-vaccines/）。交互元素允许你进行探索，内置的延时移动画创造了一个易读的画面。疫苗引进标记也增加了阅读的清晰度。

我们还没有研究数据集随时间推移的变化。下一步操作最好是收集过去几年的数据集。这些数据可以回答类似于这样的问题：随时间推移什么地方的童工数量在增长？是否能够看到随时间推移的一个明显的地区趋势？当合并另一个数据集，是否能够看到其他的趋势（例如，童工雇用数量是否伴随农业出口增长）？

在 Stack Overflow（http://stackoverflow.com/questions/19079143/how-to-plot-time-series-in-python）上有一个很好的回答，提供了更多的关于使用 matplotlib 来绘制时间序列数据图表的信息。还记得在第 9 章中 agate 表的 rows 和 columns 方法可以用来选择一列或者一行的数据吗？这两个方法返回的列表可以被传入任何 matplotlib 函数，把数据传入到图表中。

如果你想要了解如何使用 Bokeh 处理时间相关数据，可以查看它们很棒的实例（http://bokeh.pydata.org/en/latest/docs/user_guide/charts.html#timeseries）。

时间线数据

时间线数据可以帮助你向听众介绍某主题历史中重要的时刻，或者最近发展中的转折。例如，疫苗历史网站（History of Vaccines，http://www.historyofvaccines.org/content/timelines/measles）中的时间线展示了加利福尼亚麻疹疫苗的历史和最近发展，这样听众可以通过历史数据快速地理解主题。

如果想要展示童工历史信息的时间线，我们会去查找全世界童工历史中重要的时刻。我们可以去研究帮助引出时间线上事件的问题，例如：第一部保护儿童安全的法律是什么时候实施的？什么时候公众意见开始反对童工雇用？有哪些与童工有关的公众事件和丑闻？

对于可视化数据来说，Knight 实验室的 TimelineJS（http://timeline.knightlab.com/）可以接受一个数据表单，创建一个简单的交互式时间线。

10.2.3　地图

如果你的发现聚焦于地理信息，地图是展示数据的很好的方式。地图能帮助人们意识到某主题对他们了解的人群和地区的影响。根据听众对所讨论地区的了解程度，你可能需要在地图中包含额外的信息和上下文，便于将故事与听众更熟悉的区域相关联。

如果是本地听众，你可能要提到本地知名的纪念物和街道名称。如果是国际听众，并且故事涉及一个特定的区域（例如，亚马逊森林砍伐），则首先引用大洲地图，之后再聚焦于你的目标区域。

　地图是数据可视化中很难的一种形式。不仅你要了解听众的地理知识，而且地图并不总能用清晰易理解的方式展示趋势。当使用地图时，对展示区域的地理知识非常熟悉是很重要的，你需要展示重要的地理元素，让听众确定位置，同时展示发现。

一个有新闻价值的地图的实例是《纽约时报》的加州疫苗接种地图（http://www.nytimes.com/interactive/2015/02/06/us/california-measles-vaccines-map.html?_r=1）。该地图在加州最近的麻疹疫情爆发期间发布，读者可以放大和缩小来查看更多信息，且该地图提供了简短的描述，展示了个人意愿和其他造成低接种率的原因（例如贫穷或者缺少接触）之间的不同。通过仅仅聚焦于加利福尼亚，该地图能够展示足够详细的细节，而如果是以国家或者地区为维度的话，可能会太杂乱或者复杂了。

　准备地图时，你可能想要利用 ColorBrewer（http://colorbrewer2.org/），这个工具可以并排比较不同的地图颜色方案。你希望颜色不仅能讲述故事，而且有对比性，这样读者可以清晰地看到组和组之间的区别。

一个更大地理区域的地图实例是《经济学人》杂志的全球债务钟（http://www.economist.com/content/global_debt_clock）。这个地图展示了各个国家的公共债务情况，使用了一个交互时间线来展示随时间推移公债的变化情况。其互补的颜色方案使得地图很容易阅读，人们可以很容易地分辨负债很重的国家和负债很少或者没有债务的国家。

　全球债务钟地图（the global debt clock map）的作者标准化了债务度量，使用美元作为标准的展示单位，所以用户可以并排地比较不同国家的债务率。这一很小的标准化有助于听众理解并增强了这些发现的影响。

有一个很容易使用的图表和地图 Python 库，pygal（http://pygal.org/），它拥有很好的内置地图特性。pygal 有从饼状图、散点图到世界和国家地图详细的文档。可以同时使用 pygal 和 agate 表来展示世界范围内的童工雇用率。首先需要通过运行下面的命令安装库和其依赖。

pip install pygal
pip install pygal_maps_world
pip install cssselect
pip install cairosvg
pip install tinycss
pip install lxml

在 pygal 世界地图文档（http://www.pygal.org/en/latest/documentation/types/maps/pygal_maps_world.html?highlight=world）中，可以看到每个国家的双字符 ISO 编码是使用世界地 图所必需的。使用之前的方法添加这些编码到 ranked 表：

import json

country_codes = json.loads(open('iso-2.json', 'rb').read()) ➊
country_dict = {}

for c in country_codes:
    country_dict[c.get('name')] = c.get('alpha-2') ➋

def get_country_code(row):
    return country_dict.get(row['Countries and areas']) ➌

ranked = ranked.compute([（'country_code',
                          agate.Formula(text_type, get_country_code)), ])

for r in ranked.where(lambda x: x.get('country_code') is None).rows: ➍
    print r['Countries and areas']

❶ 加载从 GitHub 用户 @lukes 仓库（https://github.com/lukes/ISO-3166-Countries-with-Regional-Codes）中下载的文件 iso-2.json 中的字符串。这一文件在本书仓库中也可找到。

❷ 创建国家字典，键是国家名称，值是 ISO 编码。

❸ 定义新的函数 get_country_code，接受一行数据，使用 country_dict 返回国家编码。如果没有对应键，返回 None。

❹ 查看没有匹配到的数据，做进一步的研究。

你应该看到类似下面的输出。

Bolivia (Plurinational State of)
Cabo Verde
Democratic Republic of the Congo
Iran (Islamic Republic of)
Republic of Moldova
State of Palestine
The former Yugoslav Republic of Macedonia
United Republic of Tanzania
Venezuela (Bolivarian Republic of)

我们发现大多数数据均匹配，但是有几个漏掉的。如在前一章中处理 earth.json 一样，通过修改数据文件中不匹配国家的名称手动修正这个问题。清洗后的文件，iso-2-cleaned.json，同样可以在仓库中找到。现在可以用新的、清洗后的 JSON 文件同之前的代码一起创建一个完整的表。注意，你需要重命名列或者使用新的列名称 contry_code_complete，这样不会出现重复列名称的问题。我们会利用这张表，使用 pygal 地图方法创建自己的世界地图。

import pygal

worldmap_chart = pygal.maps.world.World() ➊
worldmap_chart.title = 'Child Labor Worldwide'

cl_dict = {}
for r in ranked.rows:
    cl_dict[r.get('country_code_complete').lower()] = r.get('Total (%)') ➋

worldmap_chart.add('Total Child Labor (%)', cl_dict) ➌
worldmap_chart.render() ➍

❶ pygal 库中 map.world 模块的 World 类返回地图对象。

❷ cl_dict 保存着一个字典，键是国家编码，值是童工百分比。

❸ 根据 pygal 的文档，这行代码传递数据的标签和数据字典到函数中。

❹ 调用地图的 render 方法来展示地图。

可以看到 render 将 .svg 文件以一个很长很复杂的字符串输出到终端。如果想要保存它到一个文件，需要调用一个不同的方法。pygal 提供了几个选项，对应不同的文件类型：

worldmap_chart.render_to_file('world_map.svg')

worldmap_chart.render_to_png('world_map.png')

　如果在渲染地图时有任何的问题，确保所有的依赖库已经安装。如果电脑上没有 .svg 文件查看器，你可以在浏览器中打开 .svg 文件。

强烈建议你查看除 .svg 之外 pygal 提供的其他选项。文档中有很多实例，高级的和简单的都有，对初学者来说，它也是一个很容易使用的 .svg 库。

10.2.4　交互式元素

交互式元素通过网站交互或模拟讲故事。因为用户可以通过浏览器四处点击和探索，所以他们可以以自己的节奏了解这个主题，从数据中得出自己的结论。这对于需要更加深入研究才能充分理解的主题特别有用。

作为对最近在美国麻疹疫情爆发的回应，英国《卫报》制作了一个疫情爆发交互式网页（https://www.theguardian.com/society/ng-interactive/2015/feb/05/-sp-watch-how-measles-outbreak-spreads-when-kids-get-vaccinated），允许用户查看和回放拥有不同疫苗接种率的各地区爆发潜在麻疹疫情的影响。这个交互式网页展示了《卫报》工作人员研究和编码的不同场景。不是每一种模拟都得到相同的输出，用户可以看到有一个展示接种率的控件，并且同时展示了患病概率（即，更高的疫苗接种率会有更小的感染可能）。这个网页使用了一个高度政治化的主题，并且使用疫情爆发的统计学模型呈现出了真实世界的场景。

尽管交互式元素需要更多的经验来创建，并且经常需要更高的编码技能，但它们是非常棒的工具，特别是当你有前端编码经验的时候。

举个例子，对于童工数据，可以创建一个交互式页面，用于展示在乍得本地高中，有多少人由于童工雇用率而可能永远不会毕业。另一个交互式页面可以展示当地商场中利用童工生产或提供的商品和服务。交互式元素将难以可视化的信息展示给受众，这样他们可以理解数据，与你的故事建立联系。

10.2.5　文字

通过文字讲故事，对作者和记者来说是非常自然的。无论你使用哪一种视觉方式，使用的所有文字对目标听众应该有用且合适。你可能想要采访该领域的专家，或同他们交谈。引用他们关于该发现的语言、想法和结论，会帮助听众综合这些信息。

如果正在研究一个本地的学校董事会是如何确定下一个学年的预算的，你可以同董事会成员谈话，或许还能得到有关提议修改的内部信息。如果正在研究公司准备发布的新产品，你可能想要同一些关键决策者谈话，确定可能会有什么新产品。

如果想获得更多关于访谈以及如何使用引言来完善故事的内容，Poynter 针对如何成为一名更好的访谈者提出了一些很棒的建议（http://www.poynter.org/2013/how-journalists-can-become-better-interviewers/205518/），哥伦比亚大学的访谈准则（http://www.columbia.edu/itc/journalism/isaacs/edit/MencherIntv1.html）分享了一些关于如何准备访谈以及如何根据项目需要准备不同的访谈的建议。

　如果你是一个领域的专家，使用了一些技术或不熟悉的术语，依照听众的情况，你可能需要将这些主题分解为一个个小块。一个简单的术语表会很有用。对于科学、技术和医疗著作，当它们面向的是更广泛的读者时，使用术语表是非常普遍的做法。

10.2.6　图片、视频和插画

如果你的故事有一个很强的视觉元素，图片和视频可以增强这个故事。例如，与主题相关人物的视频访谈可以展示数据的个人立场，并且可能揭开了研究中其他的视角或未来的方向。

和视频一样，图像可以为观众描绘画面。根据我们的经验，使用图形图像来描绘战争或其他可怕的事情，总会影响我们对故事的解释。但是，使用图片来简单地震撼观众会导致他们忽略你为工作所做的细致研究。思考一下这一点，为你的故事找到一个平衡点。

如果你无法获得与主题相关的照片或者没有能力自己收集，可以用插画讲故事。《华盛顿邮报》一篇关于健康与不健康办公作空间的报道（http://www.washingtonpost.com/wp-srv/special/health/unhealthy-vs-healthy-office/），使用了一张插画来展示故事的概念。

对于童工数据来说，我们不大可能有机会自己收集在数据分析中发现的罪行的视频和照片。然而，我们可以使用过去关于童工报道的图片（在经过允许并说明来源的情况下），来揭示儿童仍然遭受着这个世界性问题的影响。

10.3　展示工具

如果你不想发布数据，但是想将其展示给一小群人（或内部人员），创建一个幻灯片相对更简单一些。在展示数据有很多方式可选时，你可以创建一个灵活的幻灯片，而不需要做太多额外的工作。

一个拥有很高评价的幻灯片制作工具是 Prezi（https://prezi.com/），它可以创建看起来很专业的幻灯片。Prezi 让你能够创建公共可用的幻灯片，而且它有多种不同的桌面客户端（如果你想要创建私密幻灯片，需要注册一个付费账户）。Haiku Deck（https://www.haikudeck.com/）是另一个只可在线使用的网站工具，可以免费制作公开的幻灯片，付费制作私密的幻灯片。你还可以将 Google Slides 作为一个免费且简单的选择，特别是当你准备给内部人员演示并且你的公司也使用 Google Apps 的时候。

10.4　发布数据

你已经花费了时间研究、探索和展示数据，现在想要通过网络分享报告给全世界。当你准备在网络上发布数据时，首先应该确定数据是否能够被公开访问。

　如果你的展示包含私密的数据或者数据只与你的公司相关（专利数据），你应该在一个受密码保护的网站上发布它，或者在内网发布。

如果你想要和世界分享数据，通过诸多网络平台之一来发布不会有问题。在这一节里，我们会介绍如何在易于使用的免费博客平台或者你自己的网站上发布数据。

10.4.1　使用可用站点

许多网站被设计用来发布数据，以迎合类似你这样的想要分享报告或想法并将它们简单地发布到网络上的作者和研究者。下面是一些较好的选择。

Medium

在 Medium（https://medium.com/）上，你可以创建一个账户，开始写自己的文章，轻松地嵌入评论、引用、图片和图表。因为这是一个社交媒体平台，所以其他的 Medium 用户可以推荐你的文章，分享它，标记它，同时关注你之后的文章。

　使用一个类似 Medium 的托管站点让你能够专注于写作和报告，不用花时间去琢磨如何搭建和维护你自己的网站。

Medium 团队维护了一些很好的图表工具，包括 Charted.co（https://github.com/mikesall/charted），它使用简单的 CSV 或 TSV 文件来渲染一个交互图表。在写作本书时，它们还没有实现直接嵌入这些图表到文章中的能力，但是很有可能会添加这个功能。

Medium 使得将不同种类的社交媒体、视频、照片和其他媒介嵌入文章很容易（https://medium.com/@Medium/embed-videos-tweets-music-and-more-into-your-medium-stories-3b5c09c116e8#.w5e3j4n9v）。你可以通过阅读 Medium 每月最受欢迎的文章（https://medium.com/top-100/）来获得很多很棒的关于讲故事的想法。

　建议阅读和搜索你主题领域的 Medium 博文，同这一主题的其他作者联系，来了解他们是如何讲故事的。

Medium 是在社交网络上写作并将你的想法分享给世界的很好方式。但是如果你想要运行自己的博客呢？继续阅读下面关于搭建和运行网站的选择。

快速上手的网站：WordPress、Squarespace

如果想要对布局和内容有更多的控制，你可以在 Squarespace（http://www.squarespace.com/）或 WordPress（https://wordpress.com/）上搭建自己的博客。这两个平台给了你一个免费（WordPress）或者廉价（Squarespace）的被维护的站点，让你可以自定义站点的外观和风格。你可以设置一个域名，这样你的文章会挂载在自己的 URL 下。

大多数虚拟主机提供商为 WordPress 开发了一键安装版本供你使用。你需要选择一个用户名和一些站点标题，并且确保有一个足够强大和安全的密码。在 WordPress 里，你有很多主题（https://wordpress.org/themes/browse/popular/）和插件（https://wordpress.org/plugins/browse/popular/）可以选择，来自定义网站的样式、风格和功能。为了保护站点，建议你安装一个流行的安全插件，并阅读 WordPress 关于安全的建议（http://codex.wordpress.org/Hardening_WordPress）。

使用 Squarespace，只需要注册一个账户，选择一个布局。你可以自定义相关联的社交媒体、域名，以及你是否想要有一个电商店铺。

一旦站点就绪，并且运行起来，添加内容是非常简单的。你会想要发布新的页面或文章，使用内置的编辑器添加文字和图片（或者，如果你正在使用 WordPress，可以安装额外的编辑器插件，支持更多的特性），之后发布内容。

　你可以让文章更容易被找到，方法是通过 SEO 花费一些时间来填充描述和关键词，来提高文章的可见度。WordPress 插件和 Squarespace 特性可以为每篇文章做到这些。

自有博客

如果你运行着自己的网站或博客，你已经有了一个很棒的分享报告的平台。你需要确保可以适当地将视觉故事嵌入到网站中。生成的大多数图表可以很容易地嵌入到网站的 HTML 代码中。

如果你在使用除了 WordPress 或 Squarespace 以外的其他平台，可能需要研究在站点上如何分享图表、视频和照片。建议你联系平台的社区或创建者，或者阅读站点的指引和文档，来确定如何最好地嵌入图片、图表和交互式元素。

10.4.2　开源平台：创建一个新网站

我们已经提到了几个使用 Squarespace 和 WordPress 等免费或廉价平台创建和运行新站点的选项；但是如果你想要启动、运行和维护自己的站点，可以从众多伟大的开源平台中选择一个。

Ghost

Ghost 是一个很容易运行的平台（https://github.com/tryghost/Ghost）。Ghost 使用 Node.js（https://nodejs.org/），一个开源的 JavaScript 异步服务器；如果你对 JavaScript 很感兴趣，使用和学习它都很有趣。因为它是异步的，所以拥有很好的性能，能够处理大量的请求。 Ghost 还提供了搭建托管站点的能力（https://ghost.org/），与 WordPress 或 Squarespace 类似，收取一定的费用。

如果你想要挂载自己的 Ghost 博客，DigitalOcean 和 Ghost 合作提供了一个容易使用和安装的服务器镜像（https://www.digitalocean.com/community/tutorials/how-to-use-the-digitalocean-ghost-application），不用一个小时就可在你的服务器上创建和运行 Ghost 站点。如果这是你第一次搭建服务器，强烈建议使用这个方式，因为一些初始的工作已经替你完成。

如果你有自己的服务器，并且想要在其他的平台上从头开始安装 Ghost，Ghost 提供了一些教程（http://support.ghost.org/deploying-ghost/）。你需要执行以下主要步骤。

(1) 下载并安装最新的源代码。

(2)运行 node［建议你使用 nvm（https://github.com/creationix/nvm）］。

(3)使用 npm（node 版本的 pip）安装 node 依赖。

(4)运行 pm2（https://github.com/Unitech/pm2）来管理 Ghost 进程。

(5) 启动 nginx，使用网关与运行的 Ghost 进程通信。

(6) 开始写博客！

如果你遇到了问题，可以登入 Ghost 的 slack 频道（https://ghost.org/slack/），看看是否有人能够帮助你，或者在 Stack Overflow（http://stackoverflow.com/）上搜索更多相关的信息。

GitHub Pages和Jekyll

如果你使用 GitHub 托管代码，你也可以用它来托管自己的网站。GitHub Pages（https://pages.github.com/）是一个依靠 GitHub 运行的网站托管工具，让你可以灵活地部署，并能轻松创建内容。使用 GitHub Pages，你可以通过将静态内容提交到你的仓库，直接部署你的 GitHub 页面。如果你喜欢使用框架，可以使用 Jekyll（http://jekyllrb.com/），一个基于 Ruby 的静态页面生成器，它集成了 GitHub Pages。

Jekyll 的文档（http://jekyllrb.com/docs/home/）有一个说明性的概述，涵盖了如何在本地安装和运行 Jekyll，但是建议你阅读 Barry Clark 为 Smashing 杂志编写的文章（https://www.smashingmagazine.com/2014/08/build-blog-jekyll-github-pages/）。在这篇文章中，他阐述了如何复制（fork）一个已有仓库，运行自己的站点，以及修改 Jekyll 的设置和特性。如果你不想使用 Jekyll，但是仍然想使用 GitHub Pages，你可以使用库或者手动地生成静态的 HTML 文件，再将这些文件提交到 GitHub Pages 仓库。

　一个很容易使用的 Python HTML 生成器是 Pelican（https://github.com/getpelican/pelican），它可以接受 AsciiDoc、Markdown 或者 reStructuredText 格式的文件，并将它们转化为静态内容。它提供了简单的步骤来启动评论和访问分析，以及使用 GitHub Pages 的相当全面的介绍（http://docs.getpelican.com/en/latest/tips.html#publishing-to-github）。

还有很多其他的静态站点生成器，也还有很多关于如何将它们与 GitHub Pages 集成的文章。一个搭建 GitHub Pages 博客的选择是 Hexo（http://jdpaton.github.io/2012/11/05/setup-hexo/），它是一个基于 Node.js 的框架。Octopress（https://github.com/octopress/octopress）是另外一个很棒的选项，它基于 Jekyll 构建，所以你可以轻松地使用 GitHub Pages 和 Ruby 来发布和部署站点。

一键部署

如果你坚持使用大型的博客工具或网站框架，比如 WordPress，DigitalOcean 有很多一键安装的包（https://www.digitalocean.com/features/one-click-apps/），让你能够在短时间内搭建自己的服务器，并安装所有必需的库和数据库。它同样提供了便捷的入门指引，描述了如何在 droplet2）上安装 WordPress（https://www.digitalocean.com/community/tutorials/how-to-use-the-wordpress-one-click-install-on-digitalocean）。

除了大型的虚拟主机提供者外，你同样可以在 Heroku（一个基于云的应用主机服务商，https://devcenter.heroku.com/start）上使用 Python、Ruby 和其他开源的平台。如果你正在使用或学习一个开源框架，可以使用 Heroku 来部署自己的网站；它提供了很棒的文档和技术支持。

无论你使用哪一个框架或解决方案，专注于用简单的方式在网络上发布内容或代码，是很重要的。选择一些简单直接的方案，并专注于恰当地向全世界展示、发布和分享内容。

2DigitalOcean 虚拟主机的别名。——译者注

10.4.3　Jupyter（曾名IPython notebook）

我们已经介绍了怎样分享你的发现，但是如果你还想分享代码、数据或者研究过程呢？根据听众的不同，分享代码并允许人们直接与其交互可能是很合适的。如果你准备分享给同事和同行，这是一个很好的展示你如何进行研究的方式。

Jupyter notebook（https://jupyter.org/，曾名 IPython notebook，http://ipython.org/notebook.html）是一个很好的分享 Python 代码和代码生成的图表的方式。这些 notebook 组合了易于使用的浏览器和 IPython 的交互特性。notebook 在迭代代码设计和数据探索中也非常有用。

　正在学习新的库或者使用新数据？在 Jupyter notebook 中保存你的工作。一旦完成了迭代并优化了代码，你可以将代码中重要的部分移动到仓库中，恰当地结构化、文档化，将这些东西综合在一起。

使 Jupyter 就绪并在本地运行它非常简单，只需运行这个命令：

pip install "ipython[notebook]"

要启动 notebook 服务器，运行：

ipython notebook

你看到的终端输出应该类似于：

[NotebookApp] Using MathJax from CDN: https://cdn.mathjax.org/mathjax/latest/MathJax.js
[NotebookApp] Terminals not available (error was No module named terminado)
[NotebookApp] Serving notebooks from local directory: /home/foo/my-python
[NotebookApp] 0 active kernels
[NotebookApp] The IPython Notebook is running at: http://localhost:8888/
[NotebookApp] Use Control-C to stop this server and shut down all kernels.
Created new window in existing browser session.

这是 notebook 服务器启动的过程。你会看到一个新的浏览器窗口（或 tab）打开一个空的 notebook。

根据运行 notebook 文件夹的不同，你可能会在浏览器中看到一些文件。notebook 服务器直接在当前文件夹中运行，并展示这个文件夹的内容。建议为 notebook 创建一个新的文件夹。为了创建新的文件夹而停止服务器，需在运行的终端中输入 Ctrl-C（Windows 和 Linux 上）或者 Cmd-C（Mac 上）。创建一个新的目录，切换目录到这个文件夹下，重新启动服务器，类似下面这样：

mkdir notebooks
cd notebooks/
ipython notebook

让我们通过创建一个新的 notebook 来使用 Jupyter。为了达到这个目的，点击 New 下拉菜单，选择 Notebooks 头部下的 Python 2。创建好新的 notebook 后，给它一个有用的名称。为此，点击 title 区域（这里当前应该为“Untitled”），输入一个新名称。为 notebook 命名会在将来节省你大量的搜索时间。

在 Jupyter 中，每一个文本区域被叫作单元。notebook 支持多种不同的单元类型。在顶部和代码间使用一些 Markdown（https://daringfireball.net/projects/markdown/syntax）单元来解释并给代码添加文档是一个很好的想法。图 10-6 展示了一个添加头部（header）的示例。

图 10-5：添加 Markdown 标题

要开始编写 Python，只需点击下一个可用的单元，然后输入即可。当你完成了编写的语句或函数后，敲击 Shift+Enter。代码会执行并出现一个新的单元，在这里你可以编写下一个 Python 代码。正如在图 10-7 和你自己的 notebook 中看到的那样，你可以看到在一个普通的 Python 解释器中会看到的所有输出。

图 10-6：在 Jupyter 中工作

有很多非常棒的 Jupyter（和 IPython）notebook 指南，但是一个很好的入手点可能是重新尝试一些本书中使用过的代码。

　建议组织你的 notebook，使其类似于你的仓库。你可能希望根目录下有一个包含数据的数据（data）文件夹，以及一个包含可导入 notebook 的脚本的工具（utils）文件夹。你的 notebook 就像另一个脚本，只是它是交互式的，并且在浏览器中。

用完 notebook，点击保存按钮（确保它创建一个新的检查点，这样可以更新你的文件）。

如果你在一个特定的 notebook 中完成了工作，但是仍在使用其他的 notebook，停止老的 notebook 进程是明智的选择。为此，选择服务器上的 Running 标签，并点击 Shutdown 按钮。当你对所有的 notebook 完成了编辑，保存它们并使用 Ctrl-C 或 Cmd-C 在运行 notebook 的终端停止服务器。

共享的Jupyter notebook

现在你已经熟悉 Jupyter notebook 的使用了，可以通过共享服务器上传并分享代码。这使得他人可通过普通网络（不仅是本地主机，例如前文在你的终端上运行的 notebook）访问你的 notebook。

有一些很棒的入门教程说明了如何使用 DigitalOcean（http://calebmadrigal.com/ipython-notebook-vps/）、Heroku（https://github.com/mietek/instant-ipython）、Amazon 网络服务（https://gist.github.com/iamatypeofwalrus/5183133）、Google DataLab（https://cloud.google.com/datalab/）,或者你喜欢的任意服务器（http://ipython.org/ipython-doc/1/interactive/public_server.html#notebook-public-server），来搭建一个 notebook 服务器。

　记得在 notebook 服务器上使用安全密码，保证 notebook 只被有这个密码的人使用。这会确保服务器和数据安全。

建议你也为 Jupyter notebook 建立一个类似 Git（第 14 章再深入探索）这样的版本控制系统，这样你就有了 notebook 每天或每周的历史记录。通过这种方式，你可以恢复删除的东西，同时帮你保存和组织代码。

　如果你正在使用一个共享的 notebook 服务器，确保人们知道内核被中断（这在服务器重启或者有人终止或重启 notebook 内核时都会发生）时如何运行所有的代码。为了运行所有 notebook 代码，选择 notebook 工具栏中的 Cell 下拉菜单，然后点击 Run All 按钮。你也应该建议用户在完成工作后使用 Shutdown 终止 notebook，这样服务器上就没有无用的运行进程。

无论本地还是共享的 Jupyter notebook 都是展示数据和工作流的很好的工具。当你回顾数据探索和分析时，在本地运行它们将会非常有用。随着 Python 知识的增长，你可以将脚本迁移到 Python 3，同时运行 JupyterHub（https://github.com/jupyter/jupyterhub）。JupyterHub 就一个多用户的 notebook 服务器，运行着多种语言（包括 Python），当前正处在积极地开发中。

无论选择在 notebook 服务器还是开源平台上发布，你现在已经掌握了技能，能够分析如何以最佳方式展现和发布你的发现、数据和代码。

10.5　小结

你已经学习了如何将数据转化为可演示的形式，并且通过互联网传播它。你有很多发布选择，它们有不同的隐私等级和维护需求。你可以为报告搭建一个网站，并创建美丽的图片和图表来讲述故事。有了 Jupyter 的帮助，你可以很容易地分享和演示你写的代码，同时交他们一点 Python 知识。

你同样学习了表 10-1 中列出的库和概念。

表10-1：新的Python和编程概念和库

概念/库

功能

用于绘图的 matplotlib 库

可以使用两个图表库生成简单的图表。你可以为图表使用标签和标题，用更清楚的方式展示数据

用于更复杂图表的 Bokeh 库

允许你轻松地生成更复杂的图表，可以在图表中添加交互式元素

用于 SVG 图形和地图的 pygal 库

pygal 让你能够使用简单的函数传递数据生成 SVG 图片

Ghost 博客平台

基于 Node.js 的博客平台，让你能够在自己的服务器（或挂载在 Ghost 上的平台）上快速地构建一个博客，在自己的网站上分享故事

GitHub Pages 和 Jekyll

一个集成在 GitHub 上的简单发布平台，你可以通过简单地提交代码到仓库中来分享文章和演示文档

Jupyter notebook

一个同其他开发者或同事分享代码的简单方式，同样也是一种使用敏捷开发方法（反复试错）开始开发自己的代码的很好的方式

接下来，我们会学习如何通过网络爬虫和 API 收集更多的数据。你在本章学到的知识会用在今后收集的数据上，所以请继续阅读，学习新的演示技巧。在后面的章节里，你会学得更高级的 Python 数据技术，让你更好地使用 Python 收集、评估、保存和分析数据。你在本章学到的讲故事工具会帮助你更好地进行数据处理，将研究所得分享给听众和全世界。

第 11 章　网页抓取：获取并存储网络数据

网页抓取是当今世界数据挖掘中必不可少的一部分，因为你几乎可以在网络上找到任何事物。有了网页抓取，你可以使用 Python 库来探索 Web 页面、搜索信息并收集它们以撰写报告。网页抓取让你爬取站点，发现在没有机器人协助的情况下不容易获取的信息。

这项技术使你能够获取 API 或文档之外的数据。想象一个脚本登录你的 E-mail 账户，下载文件，运行分析，并且发送一个整合的报告。想象一下不用使用浏览器就可以测试站点，以确定它具备完整的功能。想象一下从一个定期更新的网站的一系列表格中抓取数据。这些示例展示了网页抓取如何能帮助你完成数据处理的需求。

根据爬取内容的不同——本地或公开站点，XML 文档——你可以使用很多相同的工具完成这些任务。大多数网站在 HTML 代码中包含数据。HTML 是一种标记语言，使用括号（类似于第 3 章中的 XML 示例）来包含数据。在这一章，我们会使用一些能够解析和读取 HTML 和 XML 等标记语言的库。

很多站点使用内部的 API 和嵌入的 JavaScript 脚本来控制页面上的内容。由于这些构建站点的新方式，并不是所有的信息都能够使用读页面的抓取器找到。我们还会学习如何使用一些读屏幕的 Web 抓取器，应对拥有多个数据源的站点。根据站点的组成，你可能同样可以连接 API；在第 13 章你会了解更多有关 API 的信息。

11.1　抓取什么和如何抓取

网页抓取为数据收集带来了无限可能。在互联网上有成千上万的站点，拥有可能会在项目中使用的各种各样的内容和数据。为了构建一个认真负责的网页抓取器，要熟悉每一个站点，以及可抓取的内容。

版权、商标和抓取

当在网络上抓取时，对于你找到的所有媒体（来自报纸、杂志、书籍或博客），你应该考虑收集的数据和它们的使用方式。你是否会下载其他人的照片并且将它当作自己的照片发布？不，这是不道德的，而且在一些情况下是非法的。

学习像版权（http://www.dmlp.org/legal-guide/copyright）和商标（http://www.dmlp.org/legal-guide/trademark）这样的媒体法会影响你的决定，尤其是要抓取的数据属于某人的知识产权（http://www.dmlp.org/legal-guide/intellectual-property）时。

研究域名并查阅法律允许内容和禁止内容的有关提示，还要熟读robots 文件（http://www.robotstxt.org/robotstxt.html）来更好地理解网站所有者的意愿。如果你不确定数据能否被抓取，可联系律师或网站本身。取决于你的住址和使用数据的目的，如果你对本国法律和判例存有疑问，可能需要联系一家数字媒体法定组织。

　对于大多数的网络抓取，抓取文本会比抓取链接、图片或图表更合理。如果你还需要保存链接、图片或文件，这其中的大多数都可以使用简单的 bash 命令（例如 wget 或 curl，http://www.thegeekstuff.com/2012/07/wget-curl/）下载，而这不需要 Python。你可以直接保存一个 URL 列表到文件中并且写一个脚本来下载文件。

我们从简单的文本抓取开始。大多数网页的构建都基于适当的 HTML 标准，结构相似。大多数的网站有一个头部，大多数的 JavaScript 和页面样式文件在这里定义，同时还有其他额外信息，比如类似 Facebook、Pinterest 这样的服务的元标签，以及搜索引擎用法的描述信息。

头部之后是主体。主体是站点的主要部分。大多数的站点使用容器（类似 XML 节点的标记节点）来组织站点，并且允许站点内容管理系统加载内容到页面中。图 11-1 展示了一个典型的网页是如何组织的。

图 11-1：网页解剖

对于很多站点来说，页面的顶部部分包含到站点主要部分或者相关主题的导航和链接。链接或者广告通常出现在页面两边向下延展的位置。页面的中间部分通常包含你想要抓取的内容。

　熟悉大多数网页的结构（元素的视觉位置和它们在标记语言中的位置）会帮助你从互联网上抓取数据。如果可以聚焦到数据源，你就可以快速地构建抓取器。

一旦知道了在页面上寻找什么，并且通过学习页面源代码的结构分析了页面的组成，你就可以确定如何收集页面中的重要的部分。许多网页在第一次页面加载的时候提供内容，或者提供一个已加载好内容的缓存页面。对于这些页面，可以使用简单的 XML 或 HTML 解析器（我们会在本章学习它们），并且从第一个 HTTP 响应（在你请求一个 URL 时浏览器加载的内容）中直接读取内容。这与读取文档类似，只是需要一个初始的页面请求。

如果你需要首先同页面交互来获取数据（也就是输入数据和点击按钮），并且它不仅仅是一个简单的 URL 的改变，你需要使用一个基于浏览器的抓取器，在浏览器中打开页面同它交互。

如果需要遍历整个网站来收集数据，你会想要一个爬虫：一个机器人，它爬取网页，并且根据规则识别好的内容或跟踪更多页面。我们在爬取中使用的库非常地快速、灵活，让编写这些类型的脚本变得十分简单。

在开始编写抓取器代码之前，我们会查看一些网站，习惯于分析要使用那个类型的抓取器（页面读取器、浏览器读取器或爬虫），以及抓取数据会多难或多简单。有时，确定数据值得付出多少努力是很重要的。我们会介绍一些工具来确定为抓取数据需要付出多少努力，以及值得为这项工作投入多少时间。

11.2　分析网页

网络抓取的大多数时间会花费在观察浏览器标记语言和搞清楚如何同它交互上。了解你最爱的浏览器的调试或开发工具是成为一名高级网页抓取者的必要环节。

根据浏览器的不同，工具可能有不同的名称和功能，但是概念是相同的。你需要自学最喜欢的浏览器工具，不管是 IE（https://msdn.microsoft.com/library/bg182326(v=vs.85)）、Safari（https://developer.apple.com/safari/tools/）、Chrome（https://developer.chrome.com/devtools）或 Firefox（https://developer.mozilla.org/en-US/docs/Tools/GCLI）。

每个浏览器的调试器都是类似的。你会在一个区域看到请求和页面加载数据（通常叫网络或者其他类似的东西）；在另外一个区域分析页面的标记信息，看到每个标签中的内容和样式（通常叫作检视、元素或 DOM）。在第三个区域，你可以看到 JavaScript 错误，并同页面中的 JavaScript 交互；这个区域通常叫作控制台。

你的浏览器开发者工具可能还有其他的标签，但是我们真的只需要这 3 个标签来理解页面是如何构建的，以及如何简单地抓取内容。

11.2.1　检视：标记结构

当你想要抓取一个站点的时候，首先分析站点结构和标记语言。像在第 3 章学到的那样， XML 的结构由节点和内容以及键和值组成。HTML 非常相似。如果打开浏览器的开发者工具，浏览检视（Inspection）、元素（Elements）或 DOM 标签，你会看到一系列的节点和它们的值。节点和其包含的数据同我们在 XML 示例中看到的有一些不同——它们是 HTML 标签（表 11-1 列出了一些基本的标签）。HTML 标签用来告诉你内容信息。如果你想找到页面上的所有图片，查找 img 标签。

表11-1：基本的HTML标签

标签

描述

示例

head

用来存储元数据和文档的其他必需信息

<head> <title>Best Title Ever</title> </head>

body

用来存储页面大部分内容

<body> <p>super short page</p> </body>

meta

用来存储元数据，例如站点简短的描述或关键词

<meta name="keywords"content="tags, html">

h1，h2，h3 …

用来存储头部信息；数字越小，头部越大

<h1>Really big one!</h1>

p

用来存储文本段落

<p>Here's my first paragraph.</p>

ul，ol

用来存储无序表（ul：圆点）和有序表（ol：数字）

<ul><li>first bullet</li></ul>

li

用来存储列表对象；应该始终位于一个列表（ul 或 ol）中

<ul><li>first</li> <li>second</li></ul>

div

用于分节或划分内容

<div id="about"><p>This div is about things.</p></div>

a

用于链接内容，被称作“锚标签”

<a href="http://oreilly.com">Best Ever</a>

img

用于插入一张图片

<img src="/flying_cows.png" alt="flying cows!" />

关于 HTML 标签和其使用方式的完整介绍，请查看 Mozilla 开发者网络的 HTML 参考、指南和介绍（https://developer.mozilla.org/en-US/docs/Web/HTML）。

除了使用的标签和内容结构，每个标签之间放置的位置很重要。类似于 XML，HTML 也有父元素和子元素。在结构中存在层次关系。父节点拥有子节点，而学习如何遍历家族树结构会帮助你得到想要的内容。了解元素之间的关系，无论它们是双亲节点、子节点还是同级节点，会帮助你编写更高效、快速和易于更新的抓取器。

让我们仔细地查看在 HTML 页面中这些关系意味着什么。下面是一个基本的 HTML 站点的结构。

<!DOCTYPE html>
<html>
<head>

  <title>My Awesome Site</title>
  <link rel="stylesheet" href="css/main.css" />

</head>
<body>
  <header>
       <div id="header">I'm ahead!</div>
  </header>
  <section class="main">
   <div id="main_content">
       <p>This site is super awesome! Here are some reasons it's so awesome:</p>
               <h3>List of Awesome:</h3>
               <ul>
                   <li>Reason one: see title</li>
                   <li>Reason two: see reason one</li>
               </ul>
       </div>
  </section>
  <footer>
       <div id="bottom_nav">
          <ul>
               <li><a href="/about">About</a></li>
               <li><a href="/blog">Blog</a></li>
               <li><a href="/careers">Careers</a></li>
          </ul>
       </div>
  <script src="js/myjs.js"></script>
  </footer>
</body>
</html>

如果从这个页面的第一个标签开始（文档类型声明下），可以看到整个页面的所有内容都在 html 标签下。html 标签是整个页面的根标签。

在 html 标签内，有标签 head 和 body。页面的大部分内容在标签 body 内，但是 head 也有一些内容。标签 head 和 body 是 html 元素的子标签。反过来，这些标签有着他们自己的子标签和后继标签。head 和 body 标签是同级关系。

查看主 body 标签的内部，可以看到其他一些家族关系。所有这些列表对象（li 标签）是无序列表（ul 标签）的子标签。header、section 和 footer 标签是同级关系。script 是 footer 的子标签，是 footer 中 div 标签的邻居，用来存储链接。还有很多复杂的关系，这只是一个简单的页面！

为了更深入地研究，下面的代码展示了一个有着更复杂关系的页面（处理网页抓取时，几乎很难有一个所有元素组织合理且关系完整的完美页面）：

<!DOCTYPE html>
<html>
    <head>
        <title>test</title>
        <link ref="stylesheet" href="/style.css"/>
    </head>
    <body>
        <div id="container">
            <div id="content" class="clearfix">
                <div id="header">
                    <h1>Header</h1> ➊
            </div>
            <div id="nav"> ➋
                <div class="navblock"> ➌
                    <h2>Our Philosophy</h2>
                    <ul>
                        <li>foo</li>
                        <li>bar</li>
                    </ul>
                </div>
                <div class="navblock"> ➍
                    <h2>About Us</h2> ➎
                    <ul>
                        <li>more foo</li> ➏
                        <li>more bar</li>
                    </ul>
                </div>
                </div>
                <div id="maincontent"> ➐
                    <div class="contentblock">
                        <p>Lorem ipsum dolor sit amet...</p>
                    </div>
                    <div class="contentblock">
                        <p>Nunc porttitor ut ipsum quis facilisis.</p>
                    </div>
                </div>
            </div>
        </div>
        <style>...</style>
    </body>
</html>

❶ 当前元素父元素的前面的邻居的第一个子元素。

❷ 当前元素的父级 / 祖先。

❸ 当前元素的邻居。

❹ 当前元素。

❺ 当前元素的第一个子元素 / 后代。

❻ 当前元素的子元素 / 后代。

❼ 当前元素父元素的下一个邻居。

为便于讨论，“当前元素”是第二个为 navblock 类的 div。可以看到它有两个子元素，一个是标题（h2），另一个是无序列表（ul），同时还有列表对象（li）位于列表中。它们是后代（取决于你想使用的库，它可能被包含在“all children”中）。当前元素有一个邻居，即第一个为 navblock 类的 div。

ID 为 nav 的 div 是当前元素的父元素，但是我们的元素有其他的祖先。如何从当前元素移动到 ID 为 header 的 div ？我们的父元素是 header 元素的邻居。为了得到 header 元素的内容，可以找到父元素的前面的邻居。父元素同样有另外一个邻居，即 ID 为 maincontent 的 div。

　所有这些关系被描述为文档对象模型（DOM）结构。HTML 用规则和标准来组织页面上的内容（也被称作文档）。HTML 元素节点是“对象”，并且为了正确地展示，它们必需遵循一个模型 / 标准。

花费在理解节点之间的关系上的时间越多，使用代码快速有效地遍历 DOM 树就越容易。在本章之后的部分，我们会介绍 XPath，它使用家族关系选择内容。现在，进一步理解了 HTML 结构和 DOM 元素之间的关系之后，我们可以更仔细地研究定位和分析在选择的站点上想要抓取的内容。

　你或许可以使用开发者工具搜索标记代码，这取决于浏览器。这是一种非常棒的查看元素结构的方式。举个例子，如果正在寻找内容中一个特别的小节，可以搜索这些词语，找到它们的位置。许多浏览器还允许你右击页面上的元素，选择“检视”。这通常会打开你的浏览器工具来选择元素。

我们会在示例中使用 Chrome，但是你可以使用自己最喜欢的浏览器。当研究非洲的童工时，我们发现了将童工与冲突相联系的数据。这促使我们找到致力于阻止非洲冲突地带和冲突矿产的组织。打开其中一个组织的页面：Enough 项目的 Take Action 页面（http://www.enoughproject.org/take_action）

当第一次打开开发者工具时——在 Chrome 中选择工具→开发者工具，在 IE 中敲击 F12，在 Firefox 中选择工具→网页开发者→检视器，或者在 Safari 高级设置中启用开发者菜单——我们会在一个面板中看到标记信息，在另外一个小的面板中看到 CSS 规则和样式，在工具上方的一个面板中看到真正的页面。对于不同的浏览器，布局可能会不同，但是用这些工具查看这些特性时应该会很相似（见图 11-2）。

图 11-2：Enough 项目的 Take Action 页面

　如果移动光标到开发者工具的标记部分（检视标签），你可能会看到页面上不同的部分出现高亮。这是一个非常棒的特性，可以帮助你看到标记和页面结构中不同的元素。

如果点击与 div 和页面主元素相邻的箭头，你可以看到位于其中的元素（子元素）。举个例子，在 Enough 项目主页上，可以通过点击右边栏（在图 11-3 中圈出），打开 main-inner-tse div 和其他内部的 div。

图 11-3：探索侧边栏

可以看到侧边栏图片在链接之内，链接位于一个段落之中，而段落在 div 中——这个列表很长。理解什么时候图片在链接中（或不在），确定哪个内容位于段落标签中，以及找到其他页面结构元素是定位和抓取页面内容所必需的。

开发者工具的另外一个非常棒的用处是研究元素。如果右击页面的一部分，你应该会看到一个包括了一些有用的用于网页抓取的工具的菜单。图 11-4 展示了一个菜单的实例。

图 11-4：检视元素

如果点击“检视元素”选项，开发者工具应该会在源代码中打开那个元素。这是一个非常有用的特性，可用于与内容交互，以及查看其在代码中的位置。

除了能够在浏览器中同元素交互之外，你还可以在源代码部分同元素交互。图 11-5 展示了通过在标记结构区域右击一个元素得到的菜单类型。可以看到复制 CSS 选择器或 XPath 选择器（我们会在本章中使用这两者定位和抽取网站内容）的选项。

图 11-5：元素选项

　对于不同的浏览器，工具的语言和交互可能差别很大，但是菜单选项应该同在这里描述的类似，这应该让你对如何访问数据和这些交互操作有了一些了解。

除了找到元素和内容之外，开发者工具展示了大量有关页面上节点结构和家族关系的信息。在开发者工具中，通常会有一个检视标签，展示当前元素的父元素列表。该列表中的元素通常可以被点击或者选择，所以你可以通过一个简单的点击遍历 DOM。在 Chrome 中，这个列表在开发者工具和上方页面之间的灰色部分。

我们已经查看了 Web 页面的组织结构，以及如何同它们交互来更好地理解内容的位置。现在来研究浏览器中让网页抓取更加简单的其他强大工具。

11.2.2　网络/时间线：页面是如何加载的

分析开发者工具中的时间线（Timeline）/ 网络（Network）标签将让你深刻理解页面内容是如何加载的以及加载的顺序。页面加载的时间和方式可以极大地影响你决定抓取页面的方式。有时，理解内容来源是抓取所需内容的“快捷方式”。

网络或时间线标签展示了已加载的 URL、加载顺序和加载所需时间。图 11-6 展示了 Enough 项目页面在 Chrome 中网络标签的样子。对于不同的浏览器，你可能需要重新加载页面，来查看网络标签页面的内容。

图 11-6：一个页面的网络标签

由于在网络标签中只有一个请求，可以看到整个页面在一次调用中加载完成。这对于网页抓取器来说是很棒的消息，因为这意味着通过一个请求可以获得一切。

如果点击这个请求，可以看到更多的选项，包括响应的源代码（见图 11-7）。当页面通过许多不同的请求加载时，查看每个请求的内容对于定位所需内容很重要。如果你需要额外的数据来加载站点，可以通过点击网络标签中的头部标签来研究头部和 cookie。

图 11-7：网络响应

来查看一个有着复杂网络标签的相似组织的页面。打开你的网络标签，使用浏览器访问 Fair phone 倡议站点上的#WeAreFairphone 页面（http://www.fairphone.com/we-are-fairphone/，图 11-8）。

图 11-8：有很多页面的网络标签

你可以立即看到这个页面正在处理更多的请求。点击每一个请求，你可以看到每一个请求加载的内容。请求顺序显示在网络标签中的时间线上。这可以帮助你理解如何抓取和处理页面，来得到需要的内容。

通过点击每一个请求，可以看到初始页面加载后再加载大部分内容。点击初始页面的请求，会发现并没有什么内容。我们想要问的第一个问题是：这里是否有一个 JavaScript 请求或其他的请求使用 JSON 加载内容？如果有的话，对于我们的脚本来说，这可能是一个恰当的“快捷方式”。

　你知道如何解析和读取 JSON（第 3 章），所以如果你在网络标签中找到一个 URL，伴随着一个 JSON 响应，其中保存着你需要的数据，那么你可以使用这个 URL 来获得数据，之后直接从响应中解析数据。你需要意识到所有可能在请求中需要发送的头部（展示在网络标签中的头部小节），以得到正确的响应。

如果这里没有简单的 JSON URL 匹配你需要的信息，或者信息散落在几个不同的的请求中，需要人工整合它们到一起，那么可以确定，你需要使用一个基于浏览器的方法来抓取站点。基于浏览器的网页抓取允许你读取看到的页面，而不仅是每一个请求。如果你需要在正确抓取内容之前同一个下拉菜单交互，或执行一系列基于浏览器的操作，这可以很有用。

网络标签帮助你找到包含所需内容的请求，以及是否有优秀的备选数据源。我们接下来会查看 JavaScript，看看这是否也会提供一些关于抓取器的想法。

11.2.3　控制台：同JavaScript交互

现在已经分析了页面的标记和结构，以及页面加载和网络请求的时间线，让我们转到 JavaScript 控制台，来看一下通过和运行在页面上的 JavaScript 交互，可以学到什么。

如果你已经对 JavaScript 很熟悉，使用起来应该相当简单；如果你从未同 JavaScript 交互过，花一些时间查看关于 JavaScript 课程的介绍（http://www.codecademy.com/en/tracks/javascript）会很有用。你只需要理解 JavaScript 的基本语法，能够通过控制台同页面上的元素交互。我们会从学习 JavaScript 和基本的样式开始，学习如何使用控制台界面。

样式基础

每一个网页都会使用一些样式元素来帮助它组织内容、控制内容的大小和颜色，并在视觉上修改内容。当浏览器开始开发 HTML 标准，样式标准也就诞生了。样式标准的产物是级联样式表，即 CSS，这为我们提供了给页面添加样式的标准方式。举个例子，如果想要所有的标题使用不同的字体，或所有的照片在页面居中显示，你需要在 CSS 中编写这些规则。

CSS 允许样式级联，或者从父样式和样式表中继承。如果我们为整个站点定义一个样式集合，内容管理系统将很容易让每个页面看起来相似。即使我们有一个复杂站点，它有很多不同的页面类型，我们也可以定义一个主要的 CSS 文档和几个次要的文档，在页面需要额外的样式时加载次要文档。

CSS 有效，因为它定义了允许通过标签中的属性组合 DOM 元素的规则。是否记得在第 3 章研究 XML 时，讨论过嵌套属性？ CSS 同样使用这些嵌套的属性。让我们学习使用元素检视工具。因为你很可能仍然在 Fairphone 站点，所以让我们看一些页面上的 CSS 属性。当在底部工具栏高亮一个元素时，会看到一些与页面中元素相关的文本展示在旁边（图 11-9）。

图 11-9：CSS 简介

在这个例子中，我们已经了解了 div 的含义，但是什么是 contont-block ？使用检视技术看一下 HTML 代码（右击页面上的元素，选择“检视元素”）。

我们看到 content-block 是 CSS 类（在图 11-10 中的嵌套属性 class="content-block"中）。它定义在一个起始 div 标签中，同时这个 div 保存着所有其他子标签。说到 CSS 类，在页面的这个部分中，你能看到多少个类？有好多啊！

图 11-10：CSS 类

像类一样，同样有 CSS ID。让我们找一个（见图 11-11），看看它与类有什么不同。

图 11-11：CSS ID

HTML 看起来很类似，但是在导航栏的符号中使用了一个哈希或英镑符号。#是一个适用于 ID 的 CSS 选择器。对于类，我们使用 .（如同在 div.content-block 中展示的）。

　CSS 结构和语法要求 id 必需是唯一的，但是你可以有很多的元素有相同的 class。尽管页面不总是符合这一结构，但是这也值得注意。一个 CSS id 相对于一个 class 有更大的特异性。一些元素不只有一个 class，所以它们可以应用多个样式。

使用右击菜单，在页面上复制 CSS 选择器相当简单。如果你已经了解了 CSS，这些知识会帮助你进行网页抓取。如果你不是太了解 CSS，但是希望更深入地探索它，可以查看 Codecademy 关于 CSS 课程的介绍（https://www.codecademy.com/courses/web-beginner-en-TlhFi/0/1）或查看 Mozilla 开发者网络的参考和指南（https://developer.mozilla.org/en-US/docs/Web/CSS）。

现在我们进一步了解了 CSS，了解了它是如何样式化页面的；但是你可能会问，在浏览器终端中 CSS 需要做什么？好问题！让我们回顾一下 jQuery 和 JavaScript 的基础知识，这样可以看到 CSS 是如何同页面上的内容交互的。

jQuery和JavaScript

JavaScript 和 jQuery 的演化历史要比 HTML 和 CSS 悠久得多，一部分原因是 JavaScript 的开发在很长一段时间里没有一整套的标准。从某种意义上来讲，JavaScript 是（在某种程度上仍然是）网站版图上一片荒芜的西部风光。

　即使 JavaScript 在过去的 10 年间改变了很多，但有 10 年历史的脚本仍然经常运行在一些浏览器中，这意味着推进标准化（如何编写 JavaScript 和哪些事在 JavaScript 中不允许）的进程相对于 HTML 和 CSS 有些慢。

JavaScript 不是标记语言，而是一门脚本语言。因为 Python 也是一门脚本语言，所以你可以将一些已经学过的东西——函数、对象、类、方法——应用到对 JavaScript 的理解中去。同 Python 一样，有其他的库和包帮助你编写清晰、简单和高效的 JavaScript 代码，以便于浏览器和人们理解。

jQuery（https://jquery.com/）是一个 JavaScript 库，很多大型的网站使用它以期让 JavaScript 更易读、编写更简单，同时仍然允许浏览器（和它们不同的 JavaScript 引擎）来解析脚本。

　早在 2005~2006 年，jQuery 引入了简化和标准化 JavaScript 的想法，为 JavaScript 开发者提供了工具，这样他们就不需要从零开始编写所有的代码。 jQuery 的确推动了 JavaScript 向前发展，通过强大而易于解释的方法，以及在选择页面元素时与 CSS 更紧密的联系，创建了一个更加面向对象的方法。

自从 jQuery 被开发之后，JavaScript 和 CSS 的关系便更加紧密了，并且很多新的 JavaScript 框架都基于这个面向对象的方法。如果一个站点正在运行 jQuery，使用 CSS 标识符同页面上的元素交互很简单。假如我们想要从#WeAreFairphone 页面（图 11-12）上正在查看的 content-block 类抓取内容，通过 JavaScript 控制台该如何实现呢？

图 11-12：jQuery 控制台

由于网站正在运行 jQuery，直接在控制台标签第一行中输入下面的代码：

$('div.content-block');

敲击回车键，控制台会响应那个元素。点击控制台中的响应，你会看到子元素，以及该元素的子元素。可以同一些基本的 jQuery（例如，$(elem)；）一起使用 CSS 选择器，来选择其他页面上的元素。使用 $ 和括号会告诉 jQuery 我们正在寻找一个与括号中的字符串传递的选择器相匹配的元素。

你能使用控制台来选择 ID 为 weAreFairphone 的 div 吗？你是否只能选择页面上的锚标记（a）？在控制台中尝试一下。命令行和 jQuery 提供了一个简单的方式来使用 CSS 选择器或标签名称同页面上实际的元素交互，并从这些元素中拉取内容。但是这与 Python 有什么关系呢？

因为 jQuery 改变了人们对 CSS 选择器用处的看法，Python 抓取库现在使用这些选择器来遍历和寻找网页中的元素。就像你可以在浏览器控制台中使用简单的 jQuery 选择器，你也可以在 Python 抓取器代码中使用它。如果想学习更多的 jQuery 知识，建议你访问 jQuery 学习中心（https://learn.jquery.com/），或在 Codecademy（http://www.codecademy.com/en/tracks/jquery）或 Code School（https://www.codeschool.com/courses/try-jquery）上学习课程。

如果碰到一个没有使用 jQuery 的网站，那么 jQuery 在你的控制台中就不会工作。为了只使用 JavaScript 通过类来选择元素，运行：

document.getElementsByClassName('content-block');

你应该看到相同的 div，并且能够通过相同的方式在控制台中浏览。现在你大致知道了可以利用的工具，所以让我们更仔细地看一下如何确定抓取页面中感兴趣内容的最佳方式。首先，我们会学习如何研究页面中所有的部分。

11.2.4　页面的深入分析

一个开发 Web 抓取器的好方式是先在浏览器中分析内容。首先选择你最感兴趣的内容，并且在浏览器检视或 DOM 标签中观察。数据是如何组成的？哪里是父节点？内容包含在许多元素中，还是少量元素中？

　在开始抓取一个页面之前，通过查看内容的限制信息和站点的 robots.txt 文件，来查看自己是否有权利抓取这个页面。你可以输入域名，随后输入 / robots.txt 找到这个文件（例如，http://oreilly.com/robots.txt）。

之后移向网络 / 时间线标签（见图 11-6）。页面的第一次加载看起来是什么样子？页面加载中是否使用了 JSON ？如果是的话，文件看起来什么样子？是否大部分内容在初次请求之后加载？所有的这些答案会帮助你确定要使用哪种类型的抓取器，以及抓取该页面有多困难。

然后，打开控制台标签。尝试使用你检视得到的信息，同包含重要内容的元素交互。对于这个内容，编写一个 jQuery 选择器有多简单？在整个域名下，你的选择器有多可靠？你是否可以打开一个类似的页面，使用该选择器，并得到类似的结果？

　如果在 JavaScript 控制台中使用 jQuery 或 JavaScript 很容易与你的内容交互，那么使用 Python 处理可能也会很简单。如果使用 jQuery 选择一个元素很困难，或者在一个页面上可以使用的代码在另一个相似的页面上不起作用，很可能在 Python 中也同样困难。

不能使用 Python 工具正确解析的网页少之又少。我们会教给你一些技巧，来应对混乱的网页、内联 JavaScript、格式化糟糕的选择器，以及你能在万维网的代码中发现的所有糟糕的选择，同时还会给出一些最佳实践。首先，看看加载和读取网页。

11.3　得到页面：如何通过互联网发出请求

网页抓取器的第一步是……连接到互联网。让我们温习一下连接互联网的一些基础知识。

当你打开浏览器，输入一个站点名称或者搜索词，并且敲击回车键的时候，你正在发出一个请求。大多数情况下，这是一个 HTTP（超文本传输协议）请求（或者 HTTPS——安全版本的 HTTP 协议）。你很可能在创建一个 GET 请求，这是在互联网上使用的众多请求方法之一（https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol#Request_methods）。浏览器会处理这些请求，同时解析输入，以确定你是在请求一个网站还是一个搜索词。根据该分析结果，浏览器会返回搜索结果或你请求的网站。

让我们看一下用于请求 URL 的 Python 内置库：urllib（https://docs.python.org/2/library/urllib.html）和 urllib2（https://docs.python.org/2/library/urllib2.html）。这是用于 URL 请求的两个 Python 标准库。使用 urllib2 是个好的想法，但是在 urllib 中有几个很有用的方法。让我们来看一下：

import urllib
import urllib2

google = urllib2.urlopen('http://google.com') ➊

google = google.read() ➋

print google[:200] ➌

url = 'http://google.com?q='
url_with_query = url + urllib.quote_plus('python web scraping') ➍

web_search = urllib2.urlopen(url_with_query)
web_search = web_search.read()

print web_search[:200]

❶ 使用 urlopen 方法来开始请求。这会返回一个缓冲区，在这里你可以读取网页的内容。

❷ 读取整个页面的内容到 google 变量中。

❸ 打印前 200 个字符，这样可以看到页面的开端。

❹ 使用 quote_plus 方法来用加号转义字符串。这在处理网站的查询字符串时很有用——我们想要使用 Google 搜索网页结果，同时我们知道 Google 希望得到一个在单词之间使用加号连接的查询字符串。

看到了吗？访问 URL 或服务（例如 Google 搜索）、得到响应，并读取响应都非常简单。 urllib 和 urllib2 都有其他请求方法，也能够添加头部信息、发送基本认证，以及组装更加复杂的请求。

根据请求的复杂度，你还可以使用 requests 库（http://docs.python-requests.org/en/latest/）。 requests 使用 urllib 和 urllib2，让复杂的请求更容易格式化和发送。如果你需要格式化一个复杂的文件 post 请求（http://docs.python-requests.org/en/latest/user/quickstart/#more-complicated-post-requests），或查看 session（http://docs.python-requests.org/en/latest/user/quickstart/#cookies）中还存留了什么 cookie，或者检查响应状态码（http://docs.python-requests.org/en/latest/user/quickstart/#response-status-codes），requests 是一个很棒的选择。

　在检查网络（或时间线）标签时，有些时候会发现一些使用特殊 HTTP 头（http://en.wikipedia.org/wiki/List_of_HTTP_header_fields）、cookies 或其他认证方法的页面。你可以使用 urllib2、urllib 或 requests 库，同请求一起发送这些特殊字段。

让我们看一些 requests 工具的实际使用：

import requests

google = requests.get('http://google.com') ➊

print google.status_code ➋

print google.content[:200]

print google.headers ➌

print google.cookies.items() ➍

❶ 调用 requests 库的 get 方法发送一个 GET 请求到 URL 地址。

❷ 调用 status_code 属性来确保得到了 200 响应（正确地完成请求）。如果没有得到 200，可以以不同方式执行脚本逻辑。

❸ 检查响应的 headers 属性来看 Google 返回了什么头部。可以看到 headers 属性是一个字典。

❹ 使用 cookies 属性读取 Google 在响应中发送的 cookie，并且在返回的字典上调用 items 方法来展示键 / 值对。

使用 requests 库，可以基于响应和它的属性做不同的抉择。它很容易使用，并且有很棒的文档。无论使用 urllib 还是 requests，你可以用简单的几行 Python 代码创建简单和复杂的请求。现在你了解了请求网页的基本知识，可以开始解析响应了。首先学习 Beautiful Soup（https://www.crummy.com/software/BeautifulSoup/bs4/doc/），一个简单的 Python 网页解析器。

11.4　使用Beautiful Soup读取网页

Beautiful Soup 是最流行、最简单的用于网页抓取的 Python 库之一。对于不同的需求，在网页抓取中它可能提供了你所需的一切。它很简单、直接，并且很容易学习。让我们看一下如何使用 Beautiful Soup 解析页面。首先，使用 pip 安装这个库（使用 beautifulsoup4，因为早期的版本已经不再支持和开发了）：

pip install beautifulsoup4

让我们重新看一下在早些时候检查过的一个简单的页面，即 Enough 项目的 Take Action 页面（http://www.enoughproject.org/take_action）。我们想要看一下是否可以正确地解析所有的活动调用，并且保存它们。下面是导入页面到 Beautiful Soup 中的实例，这样我们可以开始读取它：

from bs4 import BeautifulSoup ➊
import requests

page = requests.get('http://www.enoughproject.org/take_action') ➋

bs = BeautifulSoup(page.content) ➌

print bs.title

print bs.find_all('a') ➍

print bs.find_all('p')

❶ 首先，直接从 beautifulsoup4 库导入解析器。

❷ 使用 requests 库来抓取页面上的内容，这行代码将响应（和它的内容）赋值给 page 变量。

❸ 为了开始使用 Beautiful Soup 解析，这行代码传递页面内容到 BeautifulSoup 类。可以使用 content 属性获取响应的源页面。

❹ 一旦解析了页面对象，可以使用它的属性和方法。这行代码让 Beautiful Soup 找到页面中所有的 a 标签（或链接）。

可以打开一个页面，读取响应到一个 Beautiful Soup 对象，并且使用这个对象的属性来查看标题、页面中所有的段落，以及页面上所有的链接。

我们已经学习了 HTML 中有关家族关系的知识，下面查看一下页面中的关系：

header_children = [c for c in bs.head.children] ➊

print header_children

navigation_bar = bs.find(id="globalNavigation") ➋

for d in navigation_bar.descendants: ➌
    print d

for s in d.previous_siblings: ➍
    print s

❶ 使用列表生成式创建一个页面中头部的所有子元素的列表。通过将 Beautiful Soup 页面对象和 .head（调取页面的头部）以及 .children 联系在一起，可以查看所有包含在头部中的节点。如果需要的话，可以解析头部的元内容，包括页面描述。

❷ 如果使用开发者工具观察页面，你会看到导航栏使用一个 CSS 选择器 ID globalNavigation 定义。这行代码使用页面对象的 find 方法，传递一个 ID，并且定位导航栏。

❸ 使用导航栏的 descendants 方法遍历导航栏的后继。

❹ 到导航栏的最后一个后继，这行代码使用 .previous_sibling 来遍历导航元素的邻居。

家族树让我们通过 Beautiful Soup 库 page 类中的内置属性和方法导航。正如可以从头部和导航栏示例中看到的那样，从页面中选择一个区域，并遍历孩子、后代或邻居是很容易的。Beautiful Soup 的语法非常简单，并且将元素和它们的属性链式绑定到一起（像 .head.children）。对此有了了解之后，让我们专注于页面的主要部分，看一下是否可以拉取一些可能感兴趣的内容。

如果通过开发者工具观察页面，会注意到一些事情。首先，看起来每一个动作对象都位于一个 views-row div 中。这些 divs 有许多不同的类，但是它们都有一个 views-row 类。这是开始解析的好起点。标题位于一个 h2 标签中，同时链接也在该 h2 标签中，位于一个锚标签中。对于动作的调用位于 views-row div 的子 div 中的段落里面。现在可以使用 Beautiful Soup 解析页面。

首先，我们想要利用已掌握的 Beautiful Soup 知识，以及对页面结构和导航结构方式的理解，找到内容。下面是完成这件事的代码：

from bs4 import BeautifulSoup
import requests

page = requests.get('http://www.enoughproject.org/take_action')

bs = BeautifulSoup(page.content)

ta_divs = bs.find_all("div", class_="views-row") ➊

print len(ta_divs) ➋

for ta in ta_divs:
    title = ta.h2 ➌
    link = ta.a

    about = ta.find_all('p') ➍
    print title, link, about

❶ 使用 Beautiful Soup 找到并返回类中包含字符串 views-row 的所有 divs。

❷ 打印来检查数字是否是可以在网站上看到的故事行数，预示着正确地匹配了行数据。

❸ 遍历这些行数据，并基于页面的研究获取想要的标签。标题位于一个 h2 标签中，并且是行中唯一的 h2 标签。链接是第一个锚标签。

❹ 因为不确定在每行数据中有多少个段落标签，所以匹配所有的段落标签来得到文本。由于使用了 .find_all 方法，Beautiful Soup 返回一个列表，而不是第一个匹配的元素。

你应该会看到类似于下面的输出：

<h2><a href="https://ssl1.americanprogress.org/o/507/p/dia/action3/common/
public/?action_KEY=391">South Sudan: On August 17th, Implement "Plan B" </a></h2> <a
href="https://ssl1.americanprogress.org/o/507/p/dia/action3/common/public/
?action_KEY=391">South Sudan: On August 17th, Implement "Plan B" </a>
[<p>During President Obama's recent trip to Africa, the international community
set a deadline of August 17 for a peace deal to be signed by South Sudan's
warring parties....]

这些内容可能随着站点更新而改变，但是你应该会看到一个 h2 元素，之后是一个锚（a）元素，然后是每个节点段落的列表。当下的输出是混乱的，不仅因为我们正在使用一个 print，还因为 Beautiful Soup 打印了完整的元素和它的内容。相对于完整的元素，我们更想要关注于必需的部分，也就是标题文本、链接 hrefs 和段落文本。可以使用 Beautiful Soup 来仔细地查看这部分数据：

all_data = []

for ta in ta_divs:
    data_dict = {}
    data_dict['title'] = ta.h2.get_text() ➊
    data_dict['link'] = ta.a.get('href') ➋
    data_dict['about'] = [p.get_text() for p in ta.find_all('p')] ➌
    all_data.append(data_dict)

print all_data

❶ 使用 get_text 方法抽取所有来自 HTML 元素的字符串。这样会获得标题文本。

❷ 为了得到一个元素的属性，使用 get 方法。当看到 <a href="http://foo.com">Foo</a>，并想提取链接时，可以调用 .get("href") 来返回 href 值（即，foo.com）。

❸ 为了抽取段落文本，使用 get_text 方法，遍历 find_all 方法返回的段落。这行代码使用列表生成式来编译一个有着动作内容调用的字符串列表。

现在数据和输出呈现了一个更加有组织的格式。在变量 all_data 中，保存了一个所有数据的列表。现在每一个数据输入都和匹配键保存在其字典中。我们用一种整洁的方式，使用一些新的方法（get 和 get_text）从页面抓取了数据，并且数据现在存放在数据字典中。代码更加清晰和精确，可以通过添加辅助函数让它更加清晰（像第 8 章介绍的）。

除此之外，可以自动化脚本来检查是否有新的动作调用。如果保存数据到 SQLite，并且将其用于每月检查刚果的劳工实践，可以自动化报告。在每一个新报告中，可以抽取这些数据，并且对对抗冲突矿产和童工激起更多的兴趣。

Beautiful Soup 是一个易于使用的工具，并且其文档（https://www.crummy.com/software/BeautifulSoup/bs4/doc/）中介绍了很多其他可用方法的实例。这个库对于初学者来说很棒，并且有很多简单的函数；然而，跟一些其他的 Python 库相比，它太简单了。

由于 Beautiful Soup 的解析是基于正则表达式的，用在缺乏正确标签结构的破损网页上很有效。但是如果想要遍历更加复杂的页面，或者想要抓取器运行得更快并且快速地浏览页面，有很多更加高级的 Python 库可用。让我们看一下许多天才网页抓取器开发者最爱的库：lxml。

11.5　使用lxml读取网页

一个更高级的网页抓取器（其他的高级工具把它作为解析器来使用）是 lxml（http://lxml.de/）。它非常强大而快速，而且有很多很棒的特性，包括生成 HTML 和 XML 以及清洗编写糟糕的网页的能力。除此之外，它有很多用于遍历 DOM 和网页家族关系的工具。

安装 lxml

lxml 有许多不同的 C 依赖，这使得安装它要比安装大多数 Python 库更复杂一点（http://lxml.de/installation.html）。对于 Windows 用户，可查看开源二进制构建版本的 lxml（http://lxml.de/FAQ.html#where-are-the-binary-builds）。对于 Mac 用户，建议安装 Homebrew（http://brew.sh/），这样你可以使用 brew install lxml 安装它。有关高级安装的更多细节，请查看附录 D。

让我们快速地看一下要使用的主要 lxml 特性，先重写 Beautiful Soup 的代码来使用 lxml：

from lxml import html

page = html.parse('http://www.enoughproject.org/take_action') ➊
root = page.getroot() ➋

ta_divs = root.cssselect('div.views-row') ➌

print ta_divs

all_data = []

for ta in ta_divs:
    data_dict = {}
    title = ta.cssselect('h2')[0] ➍
    data_dict['title'] = title.text_content() ➎
    data_dict['link'] = title.find('a').get('href') ➏
    data_dict['about'] = [p.text_content() for p in ta.cssselect('p')] ➐
    all_data.append(data_dict)

print all_data

❶ 这里使用 lxml 的解析方法，它可以从一个文件名、一个打开的缓冲区或一个合法的 URL 解析。它返回一个 etree 对象。

❷ 因为 etree 对象的方法和属性比 HTML 元素对象少很多，所以这行代码访问根（页面和 HTML 的顶部）元素。根包含所有可能的能够访问的主干（孩子）和细枝（后代）。从根可以向下解析每一个链接或者段落，并且可以返回整个页面的 head 和 body 标签。

❸ 使用根元素，这行代码找到所有的类名称为 views-row 的 div。它使用 cssselect 方法和一个 CSS 选择器字符串，返回一个匹配元素的列表。

❹ 为了抓取标题，使用 cssselect 方法找到 h2 标签。这行代码选择了列表中的第一个元素。cssselect 返回一个所有匹配项的列表，但是我们只想要第一个匹配的元素。

❺ 同 Beautiful Soup 的 get_text 方法类似，text_content 为 lxml HTML 元素对象返回标签（和任何子标签）内的文本。

❻ 这里使用链式方法来从 title 元素中获得锚标签，并且拉取锚标签中的 href 属性。这只返回这一属性的值，类似于 Beautiful Soup 的 get 方法。

❼ 使用列表生成式来从 Take Action div 中的每一个段落中拉取文本，组成完整的文本。

你应该看到与我们使用 Beautiful Soup 时相同的提取数据。不同的是语法和页面加载的方式。Beautiful Soup 使用正则表达式把文档作为一个长字符串解析。lxml 使用 Python 和 C 库来识别页面结构，并且用更加面向对象的方式遍历它。lxml 查看所有标签的结构，（取决于你的计算机和安装它方式的不同）使用最快的方法解析树，并且在一个 etree 对象中返回数据。

我们可以使用 etree 对象本身，或者调用 getroot，这个函数会返回树最顶部的元素——通常为 html。有了这个元素，可以使用很多不同的方法和属性读取和解析页面剩余的部分。我们的解决方案强调了一点：使用 cssselect 方法。这个方法使用 CSS 选择器字符串（类似于 jQuery 示例），并且使用这些字符串来识别 DOM 元素。

lxml 也有 find 和 findall 方法。find 和 cssselect 之间有什么主要区别呢？来看一些示例：

print root.find('div') ➊

print root.find('head')

print root.find('head').findall('script') ➋

print root.cssselect('div') ➌

print root.cssselect('head script') ➍

❶ 在根元素上使用 find 方法来找到 div，这返回空。从浏览器的检视来看，我们知道页面充满了 divs ！

❷ 使用 find 方法查看头部标签，使用 findall 方法在头部定位脚本元素。

❸ 使用 cssselect 取代 find 正确地定位文档中所有的 divs，它们作为一个大的列表返回。

❹ 使用 cssselect，通过嵌套 CSS 选择器在头部定位脚本标签。使用 head script 返回与从根对象链式调用 find 命令相同的列表。

所以，find 和 cssselect 的操作方式有很大的不同。find 利用 DOM 来遍历元素，并基于祖先和家族关系找到它们，而 cssselect 方法利用 CSS 选择器来寻找页面中所有可能的匹配，或者元素的后继，非常类似于 jQuery。

　根据需求的不同，find 或 cssselect 可能更加有用。如果页面的 CSS 类、ID 和其他标识符组织得良好，cssselect 是一个非常棒的选择。但是如果页面没有组织或不使用这些标识符，遍历 DOM 可以帮助你通过家族关系确定内容。

我们想要探索其他有用的 lxml 方法。作为一名开发者，随着不断学习和成长，你可能想要通过 emoji 表情表达进程。出于这个原因，让我们编写一个快速的 emoji 图表解析器（http://www.emoji-cheat-sheet.com/）来保存一个最新的 emoji 表情列表，你可以在 Basecamp、GitHub 和很多其他的技术相关网站上使用它们。下面是做这件事的代码：

from lxml import html
import requests

resp = requests.get('http://www.emoji-cheat-sheet.com/')
page = html.document_fromstring(resp.content) ➊

body = page.find('body')
top_header = body.find('h2') ➋

print top_header.text

headers_and_lists = [sib for sib in top_header.itersiblings()] ➌

print headers_and_lists

proper_headers_and_lists = [s for s in top_header.itersiblings() if
                            s.tag in ['ul', 'h2', 'h3']] ➍

print proper_headers_and_lists

❶ 这段代码使用 requests 库拉取 HTML 文档的主体，之后使用 html 模块的 document_fromstring 方法解析数据为一个 HTML 元素。

❷ 通过查看页面结构，可以看到这是一系列头部的匹配列表。这行代码定位第一个头部，这样我们可以使用家族关系来寻找其他有用的部分。

❸ 这行代码使用列表生成式和 itersiblings 方法（返回一个迭代器）来查看所有的邻居。

❹ 上一个 print 展示了初始的 itersibling 列表生成式返回了远超我们需求的数据，包括一些页面下方带有 div 和 script 元素的部分。使用页面检视，我们确定想要的标签只是 ul、h2 和 h3。这行代码使用列表生成式和一个 if 确保只返回目标内容。

itersiblings 方法和 tag 属性帮助我们轻松地定位想要选择和解析的内容。在这个例子中，我们没有使用任何 CSS 选择器。我们知道，代码不会因为添加一个新部分而损坏，只要页面继续在头部和列表标签中保存内容。

　为什么只想使用 HTML 元素构建一个解析器呢？不依赖于 CSS 类的优势是什么？如果一个站点的开发者改变了它的设计或让它变得对移动端更友好，那么很可能他会修改 CSS 和 JavaScript，而不是重新编写页面结构。如果使用基本的页面结构驱动抓取器，它们可能会比那些使用 CSS 的抓取器用得更久，有效期更长。

除了 itersiblings 之外，lxml 对象可以迭代孩子、后继和祖先。使用这些方法遍历 DOM，是熟悉页面组织方式和编写持久代码的很好的方式。你同样可以使用家族关系来编写有意义的 XPath——一种结构化的模式，用于基于 XML 的文档（像 HTML）。尽管 XPath 不是解析网页最简单的方式，但它是一种快速、高效且极度简单的方式。

一个XPath案例

虽然使用 CSS 选择器是一种找到页面上元素和内容的简单方式，也建议你学习和使用 XPath（https://en.wikipedia.org/wiki/XPath）。XPath 是一个标记模式选择器，组合了 CSS 选择器和遍历 DOM 的能力。理解 XPath 是学习网页抓取和网站结构的很好的方式。有了 XPath，你可以访问仅仅使用 CSS 选择器不容易阅读的内容。

　XPath 可以用于几乎所有主要的网页抓取库，并且比其他大多数识别和同页面内容交互的方法都快得多。事实上，大多数同页面交互的选择器方法都在库内部转化为 XPath。

为了练习 XPath，你只需要查看浏览器的工具。许多浏览器都能够查看和复制 DOM 中的 XPath 元素。微软也有一篇关于 XPath 的很棒的文章（https://msdn.microsoft.com/en-us/library/ms256086(v=vs.110).aspx），而且 Mozilla 开发者网络上有很多很棒的工具和示例（https://developer.mozilla.org/en-US/docs/Web/XPath），供你更深入地学习 XPath。

XPath 遵循特定的语法来定义元素的类型、在 DOM 中的位置，以及可能拥有什么属性。表 11-2 回顾了可以在网页抓取代码中使用的一些 XPath 语法模式。

表11-2：XPath语法

表达式

描述

示例

//node_name

在文档中选择所有匹配 node_name 的节点

//div（选择文档中的所有 div 对象）

/node_name

选择当前或前序元素中所有匹配 node_name 的节点

//div/ul（选择所有 div 内的 ul 对象）

@attr

选择一个元素的属性

//div/ul/@class（选择所有 div 中 ul 对象的 class 属性）

../

选择父元素

//ul/../（选择所有 ul 元素的父元素）

[@attr="attr_value"]

选择有特定属性值的元素

//div[@id="mylists"]（选择 ID 值为“mylists”的 div）

text()

从节点或元素中选择文本

//div[@id="mylists"]/ul/li/text()（选择 ID 为“mylists”的 div 中的列表中元素的文本）

contains(@attr, "value")

选择属性具有特定值的元素

//div[contains(@id, "list")]（选择所有 ID 中有“list”的 div）

*

通配符

//div/ul/li/*（选择所有的 div 中 ul 中列表对象的后继）

[1,2,3...]、[last()] 或 [first()]

根据在节点中出现的顺序选择元素

//div/ul/li[3]（选择所有 div 中 ul 中的第三个列表对象）

还有更多的表达式，但是这些已经足够我们开始了。让我们使用 XPath 和本章早些时候创建的非常漂亮的 HTML 页面，研究如何解析 HTML 元素间的家族关系。为了跟随我们，从本书的代码仓库（https://github.com/jackiekazil/data-wrangling）中将其拉取到你的浏览器中（文件：awesome_page.html）。

假设我们想要在页脚部分选择链接。通过使用“检视元素”选项（见图 11-13），可以看到底部栏展示了一个元素列表和它们的祖先。锚链接位于 html 标签内的 body 标签内的 footer 内的一个带有 CSS id 的 div 内的 ul 内的 li 标签里（喔！我觉得快要喘不过来气了！）。

图 11-13：找到页面的元素

怎样编写 XPath 来选择它呢？实际上，有很多种方式。让我们从一个相当明显的方式开始，使用带有 CSS id 的 div 来编写 XPath。用已学到的语法选择 div：

'//div[@id="bottom_nav"]'

可以使用浏览器的 JavaScript 控制台测试这段代码。为了在控制台中测试 XPath，直接将它放在 $x(); 中，这是一个 jQuery 控制台的实现，用于使用 XPath 浏览页面。让我们在控制台中查看一下（见图 11-14）。1

1如果你想要在一个不使用 jQuery 的站点上使用 XPath，需要使用 Mozilla 在文档中描述的不同语法（https://developer.mozilla.org/en-US/docs/Introduction_to_using_XPath_in_JavaScript）。对于这个元素，语法应该是 document.evaluate('//div[@id="bottom_nav"]', document)。

图 11-14：使用控制台编写 XPath

我们有了合法的 XPath 来选择导航栏，因为控制台返回了一个对象（类似于 jQuery 选择器）。但是我们真正想要的是链接。让我们来看一下怎样从这个 div 移到这些链接。我们知道它们是后继，所以编写一个家族关系。

'//div[@id="bottom_nav"]/ul/li/a'

这里我们想要任何具有 id bottom_nav 的 divs，其中包含一个无序列表，然后是匹配项中的列表对象，再然后是这些对象中的锚标签。让我们尝试在控制台中运行它（图 11-15）。

图 11-15：XPath 子元素

可以从控制台的输出看到已经选择了这三个链接。现在，我们只想提取网页地址本身。我们知道每一个锚标签有一个 href 属性。让我们使用 XPath 来为这些属性编写一个选择器：

'//div[@id="bottom_nav"]/ul/li/a/@href'

当在控制台中运行这个选择器时，可以看到我们已经正确地选择了底部链接中的网页地址（见图 11-16）。

图 11-16：寻找 XPath 属性

了解页面结构可以帮助我们得到很难访问的内容，我们可以使用 XPath 表达式取而代之。

　由于 XPath 的能力和速度，你需要慢慢学习。比如，如果在类与 ID 之间存在空间，应该使用 contains 模式，而不是 =。元素可以拥有多个类，而 XPath 会假定包含了整个类字符串；使用 contains 将帮助你找到任何包含这个子串的元素。

找到你感兴趣的元素的父元素可能会很有用。假如你对页面上的一个对象列表感兴趣，并且你可以使用 CSS 类或列表中包含的文本轻松地定位一个或多个列表对象。你可以使用这些信息来构建一个 XPath 选择器，定位该元素，之后寻找父元素，让你能够访问整个列表。12.2.1 节探索这些 XPath 选择器类型，因为 Scrapy 利用 XPath 进行快速解析。

使用 XPath 的一个原因是，你通过 CSS 选择器找到的 CSS 类可能并不总能正确地选择元素，特别是使用了不同的驱动处理页面时（例如，Selenium 和许多浏览器）。XPath 天生更加明确，因而是正确解析网页的一种更加可靠的方式。

如果你已经抓取了一个站点很长时间，并且想要复用相同的代码，XPath 不太可能会由于小段代码的改变和站点的开发而崩溃。更常见的作法是重写一些 CSS 类或样式，而不是修改整个站点和页面结构。因此，XPath 比使用 CSS 更安全（尽管不是万无一失）。

现在你已经学习了一些 XPath 知识，可以尝试使用 XPath 语法重新编写 emoji 处理器，正确地存储每个部分中所有的 emoji 和头部信息。代码类似下面这样。

from lxml import html

page = html.parse('http://www.emoji-cheat-sheet.com/')

proper_headers = page.xpath('//h2|//h3') ➊
proper_lists = page.xpath('//ul') ➋

all_emoji = []

for header, list_cont in zip(proper_headers, proper_lists): ➌
    section = header.text
    for li in list_cont.getchildren(): ➍
        emoji_dict = {}
        spans = li.xpath('div/span') ➎
        if len(spans):
            link = spans[0].get('data-src') ➏
            if link:
                emoji_dict['emoji_link'] = li.base_url + link ➐
            else:
                emoji_dict['emoji_link'] = None
            emoji_dict['emoji_handle'] = spans[1].text_content() ➑
        else:
            emoji_dict['emoji_link'] = None
            emoji_dict['emoji_handle'] = li.xpath('div')[0].text_content() ➒
        emoji_dict['section'] = section
        all_emoji.append(emoji_dict)

print all_emoji

❶ 这行代码寻找与 emoji 内容相关的头部信息。它使用 XPath 抓取所有的 h2 和 h3 元素。

❷ 每一个定位到的头部有一个 ul 元素来匹配。这行代码在整个文档中收集所有的 ul 元素。

❸ 使用 zip 方法来打包头部和与之适合的列表，这返回一个元组列表。这行代码之后解包这些元组，使用一个 for 循环拉取每一个部分（头部与列表内容）到独立的变量中。

❹ 这段代码遍历 ul 元素的子元素（li 元素保存着 emoji 表情信息）。

❺ 通过页面检视，我们知道大多数的 li 元素有一个 div，其中包含两个 span 元素。这些 span 包括 emoji 表情的图片链接，以及用来唤起 emoji 表情的文字。这行代码使用 XPath 的 div/span 返回每个子 div 元素下所有的 span 元素。

❻ 为了找到每个元素的链接，这行代码调用第一个 span 的 data-src 属性。如果 link 变量为 None，代码会在我们的数据字典中设置 emoji_link 属性为 None。

❼ 因为 data-src 保存着一个相对 URL，所以这行代码使用 base_url 属性来创建一个完整的绝对 URL。

❽ 为了得到句柄（handle）或唤起 emoji 表情所需的文字，这行代码抓取第二个 span 的文本。不同于链接的逻辑，我们不需要测试这是否存在，因为每一个 emoji 都拥有一个句柄。

❾ 对于包括 Basecamp 声效的页面，对于每一个列表对象，存在一个 div（你可以通过使用浏览器的开发者工具检视页面，轻松地找到它）。这行代码选择 div，并且抓取其中的文本内容。因为这行代码在 else 代码块中，所以我们知道这些只是声音文件，因为它们不使用 spans。

通过重写 emoji 代码来使用 XPath 关系，我们发现标签最后的代码块是声音，并且其中的数据以不同的方式存储。相对于在 span 中保存一个链接，这里只有一个 div 包含唤醒声音的文本。如果只想要 emoji 链接，可以跳过添加它们到列表对象的迭代。取决于你感兴趣的数据，代码会有很大相同，但是你总是可以轻松地利用 if...else 逻辑来确定需要的内容。

通过不超过 30 行的代码，我们创建了一个抓取器来请求页面，通过 XPath 遍历 DOM 关系解析它，同时使用合适的属性或文本内容抓取出需要的内容。这段代码具有很好的扩展性，如果页面的作者添加了更多的数据节，只要页面结构没有大幅度改变，解析器会继续从页面拉取内容，并且我们会拿到不计其数的 emoji 表情！

还有许多其他有用的 lxml 函数。表 11-3 总结了其中一些以及它们的使用场景。

表11-3：lxml特性

方法或属性名称

描述

文档

clean_html

一个用来清理糟糕格式页面的函数，这样它们可以被正确解析

http://lxml.de/lxmlhtml.html#cleaning-up-html

iterlinks

一个用来访问页面上每一个锚标签的迭代器

http://lxml.de/lxmlhtml.html#working-with-links

[x.tag for x in root]

所有的 etree 元素可以作为简单的迭代器使用，支持子元素的遍历

http://lxml.de/api.html#iteration

.nsmap

提供对命名空间的简单访问，如果你愿意使用它们的话

http://lxml.de/tutorial.html#namespaces

现在，当研究页面上的结构化数据和解决如何使用 lxml、Beautiful Soup 和 XPath 从页面中提取内容时，你应该感到很自信。下一章会继续研究其他可以用来做不同类型抓取的库，像基于浏览器的解析和爬虫。

11.6　小结

你已经学习了许多关于网页抓取的知识。在编写不同格式的抓取器时，你应该感到很自信。你已清楚怎样编写 jQuery、CSS 和 XPath 选择器，以及如何轻松地使用浏览器和 Python 匹配内容。

在使用开发者工具分析一个网页是如何构建的时候，你同样会感到很自在。你已经磨练了 CSS 和 JavaScript 技能，学习了如何编写一个合法的 XPath 来与 DOM 树直接交互。

表 11-4 列出了本章介绍的新概念和库。

表11-4：新的Python和编程概念与库

概念/库

目的

robots.txt 文件使用、版权和商标研究

通过站点的 robots.txt 文件、服务条款或页面上发布的其他法律声明，你可以确定是否可以合法和符合道德地抓取站点内容

开发者工具使用：检视 /DOM

用于研究内容在页面上的位置，以及如何以最佳方式使用页面层次和 CSS 规则来找到它

开发者工具使用：网络

用于研究为了完全加载页面发起了哪些调用。这其中的一些请求可能指向 API，或其他资源，以便你轻松获取数据。了解页面如何加载可以帮助你确定是使用一个简单的抓取器还是一个基于浏览器的更复杂的抓取器

开发者工具使用：JavaScript 控制台

用于研究如何通过其 CSS 或 XPath 选择器同页面上的元素交互

urllib 和 urllib2 标准库

帮助你创建简单的 HTTP 请求来访问一个网页，并通过 Python 标准库获取内容

requests 库

帮助你更容易地创建复杂的页面请求，特别是那些需要额外的头部、复杂的 POST 数据或请求认证

BeautifulSoup 库

让你轻松读取和解析页面。对于严重破损的页面和初始的网页抓取很有用

lxml 库

让你更轻松地使用类似 XPath 语法的 DOM 层次结构和工具解析页面

XPath 使用

使你能够使用正则表达式和 XPath 语法编写模式和匹配，快速地找到和解析页面内容

在下一章，你会学习更多从网页抓取数据的方式。

第 12 章　高级网页抓取：屏幕抓取器与爬虫

在第 11 章你已经开始培养网页抓取技能，学习了如何确定要抓取的内容，以及用什么方式去哪里抓取。在这一章，我们会学习用更高级的抓取器来收集内容，比如基于浏览器的抓取器和爬虫。

我们还会学习使用高级网页抓取工具调试常见问题，并介绍在抓取网页时会遇到的一些道德问题。首先，我们会研究基于浏览器的网页抓取：通过 Python 直接使用浏览器从网页上抓取内容。

12.1　基于浏览器的解析

有时，站点使用大量的 JavaScript 或其他页面加载后执行的代码来给页面填充内容。在这些情况中，使用一个普通的网页抓取器来分析站点几乎是不可能的。你最后得到的是一个空白的页面。如果你想要同页面进行交互（即，如果你需要点击按钮或者输入一些搜索文本），也会碰到相同的问题。无论哪一种情况，你需要找出屏幕阅读（screen read）页面的方法。屏幕读取器使用浏览器打开页面，在浏览器中加载页面之后读取并同它交互。

　屏幕读取器很擅长执行通过一系列操作来获取信息才能完成的任务。出于这个原因，屏幕读取器脚本也是自动化常规网页任务的简单方式。

在 Python 中最常用的屏幕读取库是 Selenium（http://selenium.googlecode.com/svn/trunk/docs/api/py/index.html）。Selenium 是一个 Java 程序，用来打开浏览器，并且通过读取页面同页面交互。如果你已经了解 Java，可以使用 Java IDE 来与浏览器交互。我们会通过 Python 使用 Python 包（Python binding）与 Selenium 交互。

12.1.1　使用Selenium进行屏幕读取

Selenium 是一个强大的基于 Java 的引擎，可通过支持 Selenium 的浏览器直接与网站交互。它是一个非常流行的用于用户测试的框架，让公司可以为它们的网站构建测试。对于我们的目标，我们会使用 Selenium 来抓取一个我们需要交互或不是所有内容都在第一次请求中加载（例如图 11-6 中的示例，大多数的内容在第一次请求完成后加载）的站点。让我们查看页面，看看是否可以通过 Selenium 读取它。

首先，需要使用 pip install 安装 Selenium（http://selenium-python.readthedocs.io/en/latest/installation.html）：

pip install selenium

现在，开始编写 Selenium 代码。首先，需要打开浏览器。Selenium 支持许多不同的浏览器，但是附带了一个 Firefox 的内置驱动。如果你没有安装 Firefox，可以安装它，或者为 Chrome（https://code.google.com/p/selenium/wiki/ChromeDriver）、IE（https://code.google.com/p/selenium/wiki/InternetExplorerDriver）或 Safari（https://code.google.com/p/selenium/wiki/SafariDriver）安装 Selenium 驱动。让我们看一下是否能够使用 Selenium 打开网页（在例子中，我们会使用 Firefox，但是切换和使用不同的驱动器是很简单的）：

from selenium import webdriver ➊

browser = webdriver.Firefox() ➋
browser.get('http://www.fairphone.com/we-are-fairphone/') ➌

browser.maximize_window() ➍

❶ 导入来自 Selenium 的 webdriver 模块。这个模块用来调用任何已经安装的驱动器。

❷ 通过使用 webdriver 模块的 Firefox 类初始化 Firefox 浏览器对象。这会在计算机上打开一个新的浏览器窗口。

❸ 通过 get 方法和一个 URL 参数，访问想要抓取的 URL。打开的浏览器应该开始加载页面了。

❹ 使用 maximize_browser 方法最大化打开的浏览器。这会帮助 Selenium“看到”更多的内容。

现在已经有了一个页面加载完的浏览器对象（browser 变量）。让我们看一下是否能够同页面上的元素交互。如果你使用浏览器的检视标签，会看到社交媒介内容在一个类名称为 content 的 div 中。让我们看一下是否可以使用新的 browser 对象看到全部内容：

content = browser.find_element_by_css_selector('div.content') ➊

print content.text ➋

all_bubbles = browser.find_elements_by_css_selector('div.content') ➌

print len(all_bubbles)

for bubble in all_bubbles: ➍
    print bubble.text

❶ browser 对象有一个函数 find_element_by_css_selector，使用 CSS 选择器来选择 HTML 对象。这行代码选择了第一个类名称为 content 的 div，这会返回第一个匹配的对象（一个 HTMLElement 对象）。

❷ 这行代码会打印第一个匹配对象的文本内容。我们期待看到第一个聊天气泡。

❸ 这行代码使用 find_elements_by_css_selector 方法，传递一个 CSS 选择器，找到所有匹配的对象。这个方法返回一个 HTMLElement 对象列表。

❹ 遍历列表，并且打印每一个对象的内容。

嗯，有些奇怪。看起来只有两个匹配我们想要查找的对象（因为在打印 all_bubbles 的长度时，看到输出为 2），但是我们在页面上看到了大量的内容气泡。让我们更深入地查看页面上的 HTML 对象，看是否可以弄明白为什么没有匹配出更多的对象（见图 12-1）。

图 12-1：iframe

啊！当查看内容的父元素时，我们看到这是一个 iframe（https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe），位于页面的中部。iframe（内联框架）是一个 HTML 标签，它将另外一个 DOM 结构嵌入到页面中，允许一个页面在它自身中加载另外一个页面。我们的代码可能不能解析它，因为解析器希望只遍历一个 DOM 对象。让我们看一下是否可以让 iframe 在一个新的窗口中加载，这样就不需要遍历两个 DOM。

iframe = browser.find_element_by_xpath('//iframe') ➊

new_url = iframe.get_attribute('src') ➋

browser.get(new_url) ➌

❶ 使用 find_element_by_xpath 方法，返回第一个匹配 iframe 标签的元素。

❷ 得到 src 属性，这包含在 iframe 中加载的页面的 URL。

❸ 在浏览器中加载 iframe 的 URL。

我们找到了如何加载想要的内容的方法。现在看一下是否可以加载所有的内容气泡：

all_bubbles = browser.find_elements_by_css_selector('div.content')

for elem in all_bubbles:
    print elem.text

现在有了气泡内容——很棒！让我们收集一些信息：想要提取人的姓名、他们分享的内容、照片（如果有的话），以及到原始内容的链接。

在浏览页面 HTML 代码的过程中，看起来每一个内容元素都有 fullname 和 name 元素来标识个人，还有一个有文本内容的 twine-description 元素。我们看到这里有一个 picture 元素，还有一个 when 元素，其中保存着时间数据。when 元素还包含一个原始链接。具体讲解如下。

from selenium.common.exceptions import NoSuchElementException ➊

all_data = []

for elem in all_bubbles: ➋
    elem_dict = {}

    elem_dict['full_name'] = \
        elem.find_element_by_css_selector('div.fullname').text ➌
    elem_dict['short_name'] = \
        elem.find_element_by_css_selector('div.name').text
    elem_dict['text_content'] = \
        elem.find_element_by_css_selector('div.twine-description').text
    elem_dict['timestamp'] = elem.find_element_by_css_selector('div.when').text
    elem_dict['original_link'] = \
        elem.find_element_by_css_selector('div.when a').get_attribute('href') ➍
    try:
        elem_dict['picture'] = elem.find_element_by_css_selector(
                'div.picture img').get_attribute('src') ➎
    except NoSuchElementException:
        elem_dict['picture'] = None ➏
    all_data.append(elem_dict)

❶ 这行代码导入来自 Selenium 的 NoSuchElementException 异常类。当在 try...except 代码块中使用异常类时，确保你导入和使用了库异常，以便正确地处理预期误差。我们知道，不是每一个对象都有照片，并且 Selenium 会在找不到我们想要的 picture HTML 元素的时候抛出这个异常，所以我们可以使用这个异常来对有和没有图片的气泡区别处理。

❷ 在 for 循环中，遍历了内容气泡。对于这其中的每一个 elem 对象，通过更深入地遍历树，可以找到其中包含的元素。

❸ 对于每一个文本对象，这行代码调用了 HTMLElement 的 text 属性，这会抛弃文本中的标签，只返回元素的文本内容。

❹ HTMLElement 的 get_attribute 方法期待得到一个嵌套的属性，并且返回属性的值。这行代码使用 href 属性来得到 URL，使用嵌套的 CSS 在 when 类中的 div 元素中查找锚标签。

❺ 在 try 代码块中，这段代码在 div 中查找照片。如果没有照片，下一行会捕获 Selenium 抛出的 NoSuchElementException 异常，因为没有匹配的元素。

❻ 如果没有找到匹配的元素，这行代码添加一个 None 值。这确保新列表中的所有对象有一个 picture 值。

我们的脚本很早就遇到了问题，你应该会看到一个包含下面文本信息的异常：

Message: Unable to locate element:
  {"method":"css selector","selector":"div.when"}

这告诉我们在查找 when 元素时碰到了问题。让我们通过检视标签仔细查看发生了什么（见图 12-2）。

图 12-2：相邻 div

通过更仔细地观察，可以看到 content div 和 when div 实际上是相邻的，而不是在 DOM 结构中的父子关系。这暴露了一个问题，因为只遍历了 content div，而不是父 div。如果仔细地观察，可以看到 twine-item-border 是 content 和 when 元素的共同父元素。你需要通过加载父元素，修改为 all_bubbles 使用的元素：

all_bubbles = browser.find_elements_by_css_selector('div.twine-item-border')

做出这个改变后重新运行之前的代码。发生了什么？你会看到更多的 NoSuchElementException 错误。因为不确定每一个元素有相同的属性，所以我们假设它们都不相同，并且重新编写代码来对异常做出解释：

from selenium.common.exceptions import NoSuchElementException

all_data = []
all_bubbles = browser.find_elements_by_css_selector(
    'div.twine-item-border')

for elem in all_bubbles:
    elem_dict = {'full_name': None,
                 'short_name': None,
                 'text_content': None,
                 'picture': None,
                 'timestamp': None,
                 'original_link': None,
                 } ➊
    content = elem.find_element_by_css_selector('div.content') ➋
    try:
        elem_dict['full_name'] = \
            content.find_element_by_css_selector('div.fullname').text
    except NoSuchElementException:
        pass ➌
    try:
        elem_dict['short_name'] = \
            content.find_element_by_css_selector('div.name').text
    except NoSuchElementException:
        pass
    try:
        elem_dict['text_content'] = \
            content.find_element_by_css_selector('div.twine-description').text
    except NoSuchElementException:
        pass
    try:
        elem_dict['timestamp'] = elem.find_element_by_css_selector(
            'div.when').text
    except NoSuchElementException:
        pass
    try:
        elem_dict['original_link'] = \
            elem.find_element_by_css_selector(
                'div.when a').get_attribute('href')
    except NoSuchElementException:
        pass
    try:
        elem_dict['picture'] = elem.find_element_by_css_selector(
            'div.picture img').get_attribute('src')
    except NoSuchElementException:
        pass
    all_data.append(elem_dict)

❶ 对于对象的每一次迭代，这行代码添加一个新的字典，设置所有的键为 None。这给了我们一个干净的字典设置，这样每一个对象都有相同的键，我们可以在发现数据的时候添加它到键中。

❷ 拉取 content div，这样可以在这个 div 中选择。这让代码更加明确，以防有其他的 div 有相似的名称。

❸ 使用 Python 的 pass（https://docs.python.org/2/tutorial/controlflow.html#pass-statements）来略过异常。因为所有的键都已经设置为 None，所以我们在这里不需要做任何事。Python 的 pass 让代码略过异常，所以程序会继续执行下一个代码块。

一旦将数据收集到 all_data 中，你可以打印它，看一下收集到的内容。下面是一些样例输出（这是一个社交媒体时间线，所以你的时间线会和下面展示的有所不同）：

[{'full_name': u'Stefan Brand',
  'original_link': None,
  'picture': u'https://pbs.twimg.com/media/COZlle9WoAE5pVL.jpg:large',
  'short_name': u'',
  'text_content': u'Simply @Fairphone :) #WeAreFairphone http://t.co/vUvKzjX2Bw',
  'timestamp': u'POSTED ABOUT 14 HOURS AGO'},
 {'full_name': None,
  'original_link': None,
  'picture': None,
  'short_name': u'',
  'text_content': None,
  'timestamp': None},
 {'full_name': u'Sietse/MFR/Orphax',
  'original_link': None,
  'picture': None,
  'short_name': u'',
  'text_content': u'Me with my (temporary) Fairphone 2 test phone.
  # happytester #wearefairphone @ Fairphone instagram.com/p/7X-KXDQzXG/',
  'timestamp': u'POSTED ABOUT 17 HOURS AGO'},...]

我们的数据看起来有一些混乱。for 循环很晦涩，很难阅读和理解。同样，看起来我们可以改进一些数据收集方式——我们的日期对象只是一个字符串，但是它更应该是一个日期。我们还需要实验 Selenium 的能力来与页面交互，这可能会帮助我们加载更多的内容。

还需要调试看到的错误。我们找不到正确的短名称；代码看起来返回了一个空字符串。在研究了页面之后，看起来 name div 是隐藏的。在 Selenium 中，隐藏的元素通常是不能阅读到的，所以需要使用该元素的 innerHTML 属性，这会返回标签中的内容。我们也注意到，时间戳数据存储在 title 属性中，并且 URL 事实上存储在 data-href 中，而不是 href 属性中。

　随着时间推移，编写首次运行就可成功抓取的代码会变得更简单。预期可能出现的问题也变得更简单。通过研究浏览器的开发者工具，并使用 IPython 进行调试，你可以操作变量，测试可能有效的方法。

在找到所有数据的基础上，要确保正确地格式化脚本。我们想要创建函数，更好地抽象数据提取。相对于从初始页面直接解析 URL，应该简化代码，直接加载页面。通过在浏览器中反复试错发现，可以移除 iframe URL 中的长查询字符串（即，?scroll=auto&cols=4&format=embed&eh=...），并且仍然使用来自社交媒体的嵌入内容加载整个页面。让我们看一下整理和简化后的脚本：

from selenium.common.exceptions import NoSuchElementException, \
    WebDriverException
from selenium import webdriver

def find_text_element(html_element, element_css): ➊
    try:
        return html_element.find_element_by_css_selector(element_css).text ➋
    except NoSuchElementException:
        pass
    return None

def find_attr_element(html_element, element_css, attr): ➌
    try:
        return html_element.find_element_by_css_selector(
            element_css).get_attribute(attr) ➍
    except NoSuchElementException:
        pass
    return None

def get_browser():
    browser = webdriver.Firefox()
    return browser

def main():
    browser = get_browser()
    browser.get('http://apps.twinesocial.com/fairphone')

    all_data = []
    browser.implicitly_wait(10) ➎
    try:
        all_bubbles = browser.find_elements_by_css_selector(
            'div.twine-item-border')
    except WebDriverException:
        browser.implicitly_wait(5)
        all_bubbles = browser.find_elements_by_css_selector(
            'div.twine-item-border')
    for elem in all_bubbles:
        elem_dict = {}
        content = elem.find_element_by_css_selector('div.content')
        elem_dict['full_name'] = find_text_element(
            content, 'div.fullname')
        elem_dict['short_name'] = find_attr_element(
            content, 'div.name', 'innerHTML')
        elem_dict['text_content'] = find_text_element(
            content, 'div.twine-description')
        elem_dict['timestamp'] = find_attr_element(
            elem, 'div.when a abbr.timeago', 'title') ➏
        elem_dict['original_link'] = find_attr_element(
            elem, 'div.when a', 'data-href')
        elem_dict['picture'] = find_attr_element(
            content, 'div.picture img', 'src')
        all_data.append(elem_dict)
    browser.quit() ➐
    return all_data ➑

if __name__ == '__main__':
    all_data = main()
    print all_data

❶ 创建一个函数，接受 HTML 元素和 CSS 选择器，返回文本元素。在上一个代码实例中，需要一次又一次地重复代码；现在我们想要创建一个函数，这样可以重复使用它，而不需要在脚本中重新编写代码。

❷ 使用抽象函数变量，返回 HTML 元素的文本。如果没有找到匹配，返回 None。

❸ 创建一个函数来找到和返回属性，类似于我们的文本元素函数。这需要 HTML 元素、CSS 选择器，以及我们想要从选择器中拉取的属性，并且为这个选择器返回值或者 None。

❹ 使用抽象函数变量找到 HTML 元素，并且返回属性。

❺ 使用 Seleniumbrowser 类的 implicitly_wait 方法，它接受一个希望浏览器在执行下一行代码前隐式等待的秒数为参数。如果不确定页面是否会立即加载完成，这是个很棒的方法。关于隐式和显式等待有很多很棒的 Selenium 文档（http://selenium-python.readthedocs.io/en/latest/waits.html）。

❻ 传递 CSS 选择器，来获取 when div 中一个锚标签的 abbr 元素的 title 属性，以获取时间戳数据。

❼ 抓取数据结束后，使用 quit 方法关闭浏览器。

❽ 返回收集的数据。 __name__ == '__main__' 代码块允许从命令行执行代码时打印数据，或者我们可以导入函数到 IPython 中，并且运行 main 函数返回数据。

尝试从命令行中运行脚本，或者将其导入到 IPython，之后运行 main 函数。这次数据看起来更加完整了吗？你还会发现添加了另外一个 try...except 代码块。我们注意到，有些时候 Selenium 使用的交互会与页面上的 JavaScript 冲突，使 Selenium 抛出一个 WebDriverException 异常。允许页面加载更长的时间，再一次尝试后，可以解决这个问题。

如果在浏览器中访问 URL，你可以看到，随着下拉页面能够加载更多的数据。有了 Selenium，我们同样可以做这件事！让我们看一下 Selenium 可以做的其他漂亮的事。可以尝试在 Google 中搜索 Python 网页抓取库，并且使用 Selenium 与搜索结果交互：

from selenium import webdriver
from time import sleep

browser = webdriver.Firefox()
browser.get('http://google.com')

inputs = browser.find_elements_by_css_selector('form input') ➊
for i in inputs:
    if i.is_displayed(): ➋
        search_bar = i ➌
        break

search_bar.send_keys('web scraping with python') ➍

search_button = browser.find_element_by_css_selector('form button')
search_button.click() ➎

browser.implicitly_wait(10)
results = browser.find_elements_by_css_selector('div h3 a') ➏

for r in results:
    action = webdriver.ActionChains(browser) ➐
    action.move_to_element(r) ➑
    action.perform() ➒
    sleep(2)

browser.quit()

❶ 需要找到一个输入。Google 和其他的站点一样，在页面的很多地方都有输入框，但是通常来说，只有一个大的可见搜索框。这行代码定位所有的输入表单，这样我们有了一个好的起点。

❷ 这行代码遍历每一个输入，看它们是隐藏的还是显示的。如果 is_displayed 返回 True，那么有了一个可见的元素。反之，这个循环会继续遍历。

❸ 找到一个显示出来的输入时，将它赋值给变量 search_bar，终止循环。这会找到第一个可见的输入，这可能是我们想找的那一个。

❹ 这行代码通过使用 send_keys 方法发送键和字符串到选定的元素（在这个例子中，它发送键到搜索框）。这类似于在键盘上输入，但是是用 Python ！

❺ Selenium 还可以 click 页面上可见的元素。这行代码告诉 Selenium 点击搜索表单的提交按钮，来查看搜索结果。

❻ 为了查看所有的搜索结果，这行代码选择 div 中有链接的标题元素，这是谷歌搜索结果页面的结构。

❼ 这段代码遍历每一个结果，利用 Selenium 的 ActionChains 定义一系列的操作，并告诉浏览器执行这些操作。

❽ 这行代码使用 ActionChain 的 move_to_element 方法，传递给这个方法想要浏览器访问的元素。

❾ 这行代码调用 perform，这意味着浏览器会高亮每一个搜索结果。我们使用一个 sleep 函数，这告诉 Python 在执行下一行代码前等待特定的秒数（这里是 2），这样，浏览器不会执行得过快，以免你失去很多乐趣。

喔！现在我们可以找到一个站点，填充一个表单，提交它，并且使用 Selenium ActionChains 来遍历结果。正如你看到的，ActionChains 是在浏览器中执行一系列操作的有效方式。你可以在 Selenium 的 Python 附带文档（http://selenium-python.readthedocs.org/）中探索很多很棒的特性，包括显式的等待（http://selenium-python.readthedocs.io/waits.html#explicit-waits，浏览器可以等待，直到一个特定的元素被加载，而不只是整个页面加载完成）、处理警告（http://selenium-python.readthedocs.io/api.html#module-selenium.webdriver.common.alert）和保存截图（http://selenium-python.readthedocs.io/api.html#selenium.webdriver.remote.webdriver.WebDriver.save_screenshot），这些对于调试来说很有用处。

现在已经看到 Selenium 的一些能力，你能不能重写我们已经为#WeAreFairphone 站点编写的代码，并且遍历前 100 个记录？ [ 提示：如果你不想使用 ActionChains 遍历每一个元素，你总是可以使用 JavaScript ！ Selenium 驱动器的 execute_script 方法允许你执行 JavaScript，就像在浏览器控制台中执行 JavaScript 一样。你可以使用 JavaScript 的 scroll 方法（https://developer.mozilla.org/en-US/docs/Web/API/Window/scroll）。Selenium 元素对象同样有一个 location 属性，它会返回页面上元素的 x 和 y 坐标值。]

我们已经学习了如何利用 Selenium 操作和使用浏览器来进行网页抓取，但是，还没有结束！让我们看一下如何使用 Selenium 和无头浏览器。

Selenium和无头浏览器

最流行的无头浏览器工具之一是 PhantomJS（http://phantomjs.org/）。如果你是一个熟练的 JavaScript 开发者，可以直接在 PhantomJS 中构建抓取器。然而，如果你想要使用 Python 尝试一下，可以使用 Selenium 和 PhantomJS。PhantomJS 同 GhostDriver（https://github.com/detro/ghostdriver）一起工作，打开 Web 页面并导航。

为什么使用无头浏览器？无头浏览器（http://en.wikipedia.org/wiki/Headless_browser）可以在服务器上运行。相对于普通的浏览器，它们可以更快地运行和解析页面，并且可以在更多的平台上使用。如果最终想要在服务器上运行基于浏览器的网页抓取脚本，你会想要使用无头浏览器。在 10 分钟之内就可安装并运行一个无头浏览器，而大多数其他浏览器需要更长的时间来正确加载和运行（取决于你使用的功能和部署的方式）。

12.1.2　使用Ghost.py进行屏幕读取

Ghost.py（http://jeanphix.me/Ghost.py/）是一个用于屏幕读取的 WebKit 实现，用来直接与 Qt WebKit（http://doc.qt.io/qt-5/qtwebkit-index.html）交互。Qt WebKit 是一个基于 Qt（https://en.wikipedia.org/wiki/Qt_(software)）的 WebKit 实现，而 Qt 是一个用 C++ 实现的跨平台的应用开发框架。

为了开始使用 Ghost.py，你首先需要安装一些很有效的库。如果你能够安装 PySide（https://pypi.python.org/pypi/PySide），那效果会是最好的，这会允许 Python 同 Qt 通信，给 Python 访问更广泛程序和交互的能力。这个过程会花一些时间，所以在开始运行安装之后，尽情地去给自己做一个三明治吧 2。

2如果你在安装 PySide 时碰到了问题，查看与操作系统相关的项目文档。你可以选择安装 PyQt（http://pyqt.sourceforge.net/Docs/PyQt5/installation.html）。你也可以通过 GitHub 上的安装文档（https://github.com/jeanphix/Ghost.py#installation）检查更新。

pip install pyside
pip install ghost.py --pre

使用 Ghost.py 搜索 Python 主页（http://python.org），找到新的抓取文档。开始一个新的 Ghost.py 实例非常简单：

from ghost import Ghost

ghost = Ghost() ➊
with ghost.start() as session:
    page, extra_resources = session.open('http://python.org') ➋

    print page
    print page.url
    print page.headers
    print page.http_status
    print page.content ➌

    print extra_resources

    for r in extra_resources:
        print r.url ➍

❶ 这行代码调用 Ghost 类的会话对象，实例化一个 Ghost 对象来同页面交互。

❷ Ghost 类的 open 方法返回两个对象，所以这行代码在两个独立的变量中捕获这些对象。第一个对象是用来同 HTML 元素交互的页面对象。第二个对象是页面加载的其他资源列表（你在网络标签中看到的列表）。

❸ 页面对象有很多属性，比如头部、内容、链接和页面上的内容。这行代码打印页面的内容。

❹ 这行代码遍历页面的其他资源，并且打印它们，来看是否有用。有时，这些 URL 是 API 调用，可以利用它们简化数据的访问。

Ghost.py 让我们能够洞察页面使用的资源（在第一次使用 open 方法打开页面时，通过一个元组给出）和真实页面上的许多特性。同样可以使用 .content 属性来加载页面的内容，这样如果想要使用其中一个页面解析器解析它，比如 LXML，我们可以做得到，并且仍然使用 Ghost.py 进行交互。

　当前，Ghost.py 的大多数能力在于执行页面上的 JavaScript 代码（不是 jQuery），所以你可能需要打开 Mozilla 开发者网络的 JavaScript 指南（https://developer.mozilla.org/en-US/docs/Web/JavaScript）。这会帮助你更加容易地搜索和找到同 Ghost.py 一起使用的 JavaScript。

由于对于在 Python 主页中搜索抓取库感兴趣，让我们看一下是否可以定位输入框：

print page.content.contains('input') ➊

result, resources = session.evaluate(
    'document.getElementsByTagName("input");') ➋

print result.keys()
print result.get('length') ➌
print resources

❶ 测试页面上是否存在一个 input 标签（大多数的搜索框是简单的输入对象）。这会返回一个布尔值。

❷ 使用一些简单的 JavaScript 来找到页面上所有以“input”为标签名称的元素。

❸ 打印来看响应中的 JavaScript 数组的长度。

根据 JavaScript 结果，在页面上，只有两个输入。为了确定使用哪一个，看一下第一个是否合适。

result, resources = session.evaluate(
    'document.getElementsByTagName("input")[0].getAttribute("id");') ➊

print result

❶ 索引结果列表，获取 id 属性。JavaScript 直接给出了元素的 CSS 属性，所以这是一个查看选择元素的相关 CSS 的有用方式。

类似于在 Python 中索引结果，也可以在 JavaScript 中索引它们。我们想要第一个输入元素，之后抓取输入的 CSS id。

　甚至可以编写一个 JavaScript for 循环（https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for）来遍历 getElementsByTagName 函数返回的列表，通过这种方式来检查属性。如果你希望在浏览器中尝试 JavaScript，可以使用控制台完成这件事（见图 11-12）。

通过 id 的名称（id-search-field），已经定位了搜索字段元素，现在发送一些数据到这个字段：

result, resources = ghost.set_field_value("input", "scraping")

这段代码使用 set_field_value 方法，它需要一个选择器（这里就是 "input"），并且发送给它一个字符串（"scraping"）。Ghost.py 同样有一个 fill 方法（http://jeanphix.me/Ghost.py/#form），它会发送一个值的字典，来填充一系列匹配的表单字段。这在有多个字段要填充时很有用处。现在填充了检索词；让我们看一下是否能够提交查询。我们看到这在一个表单中，所以可以直接尝试进行一次表单提交：

page, resources = session.fire("form", "submit", expect_loading=True) ➊

print page.url

❶ 这行代码调用 Ghost.py 的 fire 方法，这会触发一个 JavaScript 事件。我们想给表单元素发送一个信号，以提交事件，这样它会提交搜索，并导航至下一页。设置 expect_loading 为 True，这样 Ghost.py 知道我们在等待页面加载。

这有效吗？在测试中，当运行这段代码时，收到了超时响应。我们会在本章后文中讨论超时，但是这意味着 Ghost.py 停止等待响应，因为这花费了太长的时间。当你处理抓取器提交数据任务时，找到一个合适的超时时间，对于保证脚本继续工作是必需的。让我们尝试一个不同的提交方式。Ghost.py 可以同页面元素交互并且点击，让我们尝试一下。

result, resources = session.click('button[id=submit]') ➊

print result

for r in resources:
    print r.url ➋

❶ Ghost.py 的 click 方法使用 JavaScript 选择器点击对象。这行代码点击 id="submit" 的按钮。

❷ 对于大多数通过 Ghost.py 的交互，你会收到一个结果和一个资源列表。这行代码查看代码交互返回的资源。

嗯——点击提交按钮，我们得到了一个看起来像是控制台的 URL。让我们看一下是否可以看到 Qt WebKit 看到的内容。类似于 Selenium 的 save_screenshot 方法，Ghost.py 允许我们查看页面。

　在使用无头部或不能脱离代码使用的 WebKit 浏览器时，有时页面表现得会与在普通浏览器中有所不同。当使用 Ghost.py 或 PhantomJS 时，你会想要利用屏幕截图来“查看”无头或 kit 浏览器正在使用的页面。

可以使用 Ghost.py 的 show 方法来“查看”该页面：

session.show()

你会看到打开了一个新窗口，展示出同抓取器所看到的相同的站点。它应该类似于图 12-3。

图 12-3：Ghost 页面

喔！我们正在页面的中间。尝试向上滚动，从另外的角度看一下。

session.evaluate('window.scrollTo(0, 0);')

session.show()

现在它应该看起来类似于图 12-4。

图 12-4：Ghost 页面顶端

这个视图帮助我们理解错误。页面并没有像在普通浏览器中那样完整地打开，搜索和提交输入不可读。一个解决方案是使用更大的视窗重新打开页面，或者为提交设置一个更长的超时时间。

　正如你可以在文档（http://ghost-py.readthedocs.io/en/latest/index.html）中看到的那样，我们创建的第一个 Ghost 对象可以接受类似 viewport_size 和 wait_timeout 的参数。如果你希望重启浏览器，设置一个更大的视窗或一个更长的超时时间，这些都是合理的修正。

现在，看看是否可以使用一些 JavaScript 来将它提交：

result, resources = session.evaluate(
    'document.getElementsByTagName("input")[0].value = "scraping";') ➊
result, resources = session.evaluate(
    'document.getElementsByTagName("form")[0].submit.click()') ➋

❶ 完全使用 JavaScript 设置输入值为“scraping”。

❷ 使用 JavaScript 函数调用表单的提交元素，并主动点击它。

现在如果再一次运行 show，你会看到图 12-5 所示的页面。

图 12-5：Ghost 搜索

我们使用 Qt 浏览器成功地执行了搜索。一些功能还没有像 Selenium 一样流畅，但是 Ghost.py 仍然是一个相当年轻的项目。

　你可以通过评估版本号来评估一个项目的年龄。在编写本书的时候，Ghost. py 仍然低于 1.0 版本（事实上，本书可能只能兼容 0.2 发布版）。它可能会在未来的几年里有大量的改变，但是这是一个非常有趣的项目。我们鼓励你通过向作者提交想法以及研究和修复 bug 来帮助它。

现在，我们已经学习了 Python 中几种与浏览器交互的不同方式，让我们做一些爬取！

12.2　爬取网页

如果你需要从网站的多个页面上抓取数据，爬虫可能是最好的解决方案。网络爬虫（或者机器人）很适合跨越整个域名或站点（或一系列的域名或站点）寻找信息。

　你可以将爬虫视为一个高级的抓取器，通过它你可以利用页面读取抓取器的能力（类似于在第 11 章中学到的），并且在整个站点中应用匹配 URL 模式的规则。

爬虫可以帮助你了解网站的结构。例如，站点可能包含一个你并不知道的完整的子章节，其中包含一些有趣的数据。使用爬虫遍历域名，你可以找到子域或其他对报告有用的相关内容。

当你构建爬虫时，首先研究感兴趣的站点，然后创建页面读取的代码来识别和读取内容。一旦爬虫构建完毕，你可以创建一个遵循的规则列表，爬虫会使用它找到其他有趣的页面和内容，同时解析器会使用你创建的页面读取抓取器收集和保存内容。

　使用爬虫时，你需要事先明确想要什么内容，或者首先使用一个宽泛的方法探索站点，然后重新编写它，使其更明确。如果你选择了广撒网的方法，可能需要在之后做很多数据清洗的工作，以将发现的内容缩小至可用数据集。

我们会使用 Scrapy 开始创建第一个爬虫。

12.2.1　使用Scrapy创建一个爬虫

Scrapy（http://scrapy.org/）是最强大的 Python 网络爬虫。它赋予你在 Python 异步网络引擎 Twisted（http://twistedmatrix.com/trac/）之上使用 LXML（见 11.5 节）的能力。如果你需要一个特别快的爬虫，并能够同时处理大量的任务，我们强烈推荐 Scrapy。

Scrapy 有一些很棒的内置特性，包括导出不同格式的结果（CSV、JSON 等），一个易用的可运行满足不同需求的多个抓取器的服务端部署结构，以及其他一系列优雅的特性，比如使用中间件来处理代理请求或重试状态码失败的请求。Scrapy 会将遇到的错误打印到日志，这样你可以更新和修改代码。

为了恰当地使用 Scrapy，你需要学习 Scrapy 类系统。Scrapy 使用几个不同的 Python 类来解析网页并且返回好的内容。当定义一个爬虫类时，你也定义了规则和其他的类属性。这些规则和属性在爬虫开始抓取网页时使用。当定义一个新的爬虫的时候，你正在使用一个叫继承（inheritance）的东西。

继承

继承让你能够将一个类作为基类，在其基础上构建额外的属性或方法。

通过 Scrapy，当继承一个爬虫类时，同样继承了有用的内置方法与属性。之后通过改变一些方法和属性，它们就是专属于你的爬虫了。

Python 的继承是显而易见的：你开始定义一个类，并且将另外的类名称放置在类定义的括号内（例如，class NewAwesomeRobot(Old Robot):）。新的类（这里是 NewAwesomeRobot）继承自括号内的类（这里是 OldRobot）。Python 让我们得以使用这种直接继承，这样当编写新的类时，可以积极地复用代码。

继承允许我们使用 Scrapy 库中丰富的抓取知识，只需要重新定义一些方法和一些初始化爬虫属性。

Scrapy 使用继承来定义在页面上抓取的内容。对于每一个 Scrapy 项目，你会收集一系列的对象，并且可能会创建一些不同的爬虫。爬虫会抓取页面，使用在设置中定义的任何格式返回对象（即数据）。

相比我们使用过的其他抓取网页的库，使用 Scrapy 爬虫需要更多的组织，但是它相当直观。组织 Scrapy 抓取器便于复用、共享和更新项目。

有一些不同类型的 Scrapy 爬虫，让我们研究一下它们的主要相似点和不同处。表 12-1 提供了一个总结。

表12-1：爬虫类型

爬虫名称

主要目的

文档

Spider

用来解析特定数量的站点和页面

http://doc.scrapy.org/en/latest/topics/spiders.html#scrapy.spider.Spider

Crawl Spider

遵循一组关于如何解析链接和识别页面内容的正则表达式规则，解析域名

http://doc.scrapy.org/en/latest/topics/spiders.html#crawlspider

XMLFeed Spider

用来解析 XML feeds（比如 RSS），从节点中拉取内容

http://doc.scrapy.org/en/latest/topics/spiders.html#xmlfeedspider

CSVFeed Spider

用来解析 CSV feeds（或 URL），从行中拉取信息

http://doc.scrapy.org/en/latest/topics/spiders.html#csvfeedspider

SiteMap Spider

根据给定的域名列表，解析站点地图

http://doc.scrapy.org/en/latest/topics/spiders.html#sitemapspider

对于通常的网页抓取，你可以使用 Spider 类。对于更高级的、遍历整个域名的抓取，使用 CrawlSpider 类。如果你有 XML 或 CSV 格式的 feeds 或文件，特别是当它们非常大时，使用 XMLFeedSpider 和 CSVFeedSpider 来解析它们。如果你需要查看站点地图（你自己的站点或其他站点），使用 SiteMapSpider。

为了进一步熟悉两个主要的类（Spider 和 CrawlSpider），构建一些不同的爬虫。首先，使用一个 Scrapy 爬虫创建一个抓取器来爬取相同的 emoji 表情页面（http://www.emoji-cheat-sheet.com）。为此，我们使用普通的 Spider 类。首先使用 pip 安装 Scrapy。

pip install scrapy

同样建议你安装 service_identity 模块，这个模块提供了一些好用的特性，在爬取网页时提供安全集成。

pip install service_identity

通过 Scrapy，你可以使用一个简单的命令来启动一个项目。确保你正在想要使用爬虫的目录下，因为这个命令会为爬虫创建一系列的文件夹和子文件夹：

scrapy startproject scrapyspider

如果你列出所有当前文件夹下面的文件，应该会看到一个有很多子文件夹和文件的新的父文件夹。正如 Scrapy 站点（https://doc.scrapy.org/en/latest/intro/tutorial.html#creating-a-project）文档中描述的，有一些不同的配置文件（主文件夹下的 scrapy.cfg 和项目文件夹下的 settings.py，以及一个放置爬虫文件的文件夹和一个用来定义对象的文件）。

在创建抓取器之前，需要定义想要在页面数据中收集的对象。打开 items.py 文件（位于项目文件夹的嵌套文件夹内），并且修改它来保存页面数据。

# -*- coding: utf-8 -*-
# 在这里定义为要抓取的对象定义模型
#
# 参见文档：
# http://doc.scrapy.org/en/latest/topics/items.html

import scrapy

class EmojiSpiderItem(scrapy.Item): ➊
    emoji_handle = scrapy.Field() ➋
    emoji_image = scrapy.Field()
    section = scrapy.Field()

❶ 通过继承 scrapy.Item 创建了新的类。这意味着我们有了这个类的内置方法和属性。

❷ 为了定义每一个字段或数据值，在类中添加了一个新的行，设置了属性名称，并且通过将其设置为 scrapy.Field() 对象来初始化。这些字段支持任何普通的 Python 数据结构，包括字典、元组、列表、浮点数、小数和字符串。

你可能注意到 items.py 文件主要是事先编辑好的。这是个非常好的功能，让你能够快速开始开发，并确保有合适的项目结构。startproject 命令提供所有这些工具，是开始新 Scrapy 项目的最好方式。你同样可以看到，创建一个新的类来收集数据是很简单的。只需要几行 Python 代码，就可以定义关心的域，并准备好爬虫中使用的对象。

为了从爬虫类开始，在新项目目录结构中的 spiders 文件夹下创建一个新的文件，名为 emo_spider.py：

import scrapy
from emojispider.items import EmojiSpiderItem ➊

class EmoSpider(scrapy.Spider): ➋
    name = 'emo' ➌
    allowed_domains = ['emoji-cheat-sheet.com'] ➍
    start_urls = [
        'http://www.emoji-cheat-sheet.com/', ➎
    ]

    def parse(self, response): ➏
        self.log('A response from %s just arrived!' % response.url) ➐

❶ 所有 Scrapy 导入使用项目根目录作为模块导入起始点，所以需要在导入中包含父文件夹。这行代码从 emojispider.items module 导入了 EmojiSpiderItem 类。

❷ 使用继承定义了 EmoSpider 类，新的类基于简单的 scrapy.Spider 类。这意味着爬虫将需要特定的初始化属性（https://doc.scrapy.org/en/latest/topics/spiders.html#spider），这样它知道去抓取哪一个 URL，以及如何处理抓取的内容。我们在下面的几行中定义了这些属性（start_urls、name 和 allowed_domains）。

❸ 爬虫名称是我们在命令行任务中识别出爬虫时会用到的。

❹ allowed_domains 告诉爬虫爬取哪些域名。如果爬虫遇到一个链接指向的域名不在该列表中，它会忽略这个链接。这个属性在编写爬取抓取器时很有用，这样如果链接不符合规则，抓取器就不会尝试去爬取所有的 Twitter 或 Facebook 网页。你同时也可以传递子域。

❺ Spider 类使用 start_urls 属性来遍历要爬取的 URL 列表。在 CrawlSpider 里，这些是找到更多匹配的 URL 的起点。

❻ 这行代码重新定义了爬虫的 parse 方法，通过在类中使用 def 和方法名称定义该方法执行一些逻辑。为类定义方法时，你总是从传递 self 开始。这是因为调用方法的对象将是第一个参数（即，list.append() 首先传递列表对象本身，然后传递括号中的参数）。 parse 函数的下一个参数是响应。正如在文档（https://doc.scrapy.org/en/latest/topics/spiders.html#scrapy.spiders.Spider.parse）中提及的，parse 方法将需要一个响应对象。最后用冒号终结这一行，正如定义任何其他的函数时所做的。

❼ 为了测试爬虫，Scrapy 入门指南中的这行代码使用爬虫的 log 方法，发送一条信息到日志中。使用响应的 URL 属性来展示响应的地址。

为了运行这个 Scrapy 爬虫，我们要确保正处于恰当的目录中（scrapy spider 和其中的 scrapy.cfg 文件），之后运行命令行参数来解析页面：

scrapy crawl emo

日志会显示爬虫开始运行，并且显示出哪些中间件正在运行。之后，几乎在最后，你应该能看见类似下面的输出：

2015-06-03 15:47:48+0200 [emo] DEBUG: A resp from www.emoji-cheat-sheet.com arrived!
2015-06-03 15:47:48+0200 [emo] INFO: Closing spider (finished)
2015-06-03 15:47:48+0200 [emo] INFO: Dumping Scrapy stats:
    {'downloader/request_bytes': 224,
     'downloader/request_count': 1,
     'downloader/request_method_count/GET': 1,
     'downloader/response_bytes': 143742,
     'downloader/response_count': 1,
     'downloader/response_status_count/200': 1,
     'finish_reason': 'finished',
     'finish_time': datetime.datetime(2015, 6, 3, 13, 47, 48, 274872),
     'log_count/DEBUG': 4,
     'log_count/INFO': 7,
     'response_received_count': 1,
     'scheduler/dequeued': 1,
     'scheduler/dequeued/memory': 1,
     'scheduler/enqueued': 1,
     'scheduler/enqueued/memory': 1,
     'start_time': datetime.datetime(2015, 6, 3, 13, 47, 47, 817479)}

抓取器大概一秒解析一个页面。同样可以看到来自 parse 方法的日志。酷！我们成功地定义了第一个对象和类，能够创建并且运行它们。

下一步是真正地解析页面，拉取内容。让我们尝试另外一个内置的特性，Scrapy shell。它类似于 Python 或命令行 shell，但是附带所有可用的爬虫命令。有了该 shell，研究页面和确定如何得到页面内容变得非常简单。为了启动 Scrapy shell，只需运行：

scrapy shell

你应该看到了一个可用选项或可以调用的函数的列表。其中一个为 fetch。让我们测试一下这个函数：

fetch('http://www.emoji-cheat-sheet.com/')

你现在应该看到了一些类似于抓取输出的输出结果。其中一些信息显示已爬取 URL，之后返回一个新的可用对象列表。其中一个是 response 对象。响应对象和你在 parse 方法中使用的相同。让我们看一下是否可以找到一些同响应对象交互的方式：

response.url
response.status
response.headers

这其中的每一项都应该返回一些数据。url 与我们编写日志信息时使用的 URL 相同。 status 告诉我们 HTTP 响应的状态码。headers 应该提供一个服务器返回的头部字典。

　输入 response，点击 Tab，你会看到一个完整的可用的方法与属性列表，以及响应对象。你同样可以在 IPython 终端中对任何其他的 Python 对象做这件事。3

3如果你安装了 IPython，就会在使用的大多数 Python shell 中看到该 tab 实现。如果没有看到它，你可以添加一个 .pythonrc 文件到计算机（http://stackoverflow.com/questions/246725/how-do-i-add-tab-completion-to-the-python-shell），并且将它赋值给 PYTHONSTARTUP 环境变量。

每一个响应对象同样会有一个 xpath 和 css 方法。这些方法类似于贯穿本章和第 11 章的选择器。正如你已经猜到的，xpath 希望你发送一个 XPath 字符串，而 css 希望得到一个 CSS 选择器。让我们看一下使用已经为这个页面写好的 XPath 选择页面上的一些对象：

response.xpath('//h2|//h3')

运行该命令，你会看到一个类似于下面的列表：

[<Selector xpath='//h2|//h3' data=u'<h2>People</h2>'>,
 <Selector xpath='//h2|//h3' data=u'<h2>Nature</h2>'>,
 <Selector xpath='//h2|//h3' data=u'<h2>Objects</h2>'>,
 <Selector xpath='//h2|//h3' data=u'<h2>Places</h2>'>,
 <Selector xpath='//h2|//h3' data=u'<h2>Symbols</h2>'>,
 <Selector xpath='//h2|//h3' data=u'<h3>Campfire also supports a few sounds<'>]

现在让我们看一下，是否可以只读取这些头部中的文本内容。在使用 Scrapy 时，你会想要精确地抽取出正在寻找的元素；（在编写本书时）没有 get 或 text_content 方法。让我们看一下是否可以使用 XPath 知识来从头部中选择文本：

for header in response.xpath('//h2|//h3'):
    print header.xpath('text()').extract()

你应该会得到类似下面的输出：

[u'People']
[u'Nature']
[u'Objects']
[u'Places']
[u'Symbols']
[u'Campfire also supports a few sounds']

可以看到 extract 方法会返回一个匹配元素的列表。可以使用 @ 符号来表示属性，用 text() 方法来拉取文本。我们需要重写一些代码，但现在可以使用在 11.5.1 节中所写的许多 LXML 逻辑：

import scrapy
from scrapyspider.items import EmojiSpiderItem

class EmoSpider(scrapy.Spider):
    name = 'emo'
    allowed_domains = ['emoji-cheat-sheet.com']
    start_urls = [
        'http://www.emoji-cheat-sheet.com/',
    ]

    def parse(self, response):
        headers = response.xpath('//h2|//h3')
        lists = response.xpath('//ul')
        all_items = [] ➊
        for header, list_cont in zip(headers, lists):
            section = header.xpath('text()').extract()[0] ➋
            for li in list_cont.xpath('li'):
                item = EmojiSpiderItem() ➌
            item['section'] = section
            spans = li.xpath('div/span')
            if len(spans):
                link = spans[0].xpath('@data-src').extract() ➍
                if link:
                    item['emoji_link'] = response.url + link[0] ➎
                handle_code = spans[1].xpath('text()').extract()
            else:
                handle_code = li.xpath('div/text()').extract()
            if handle_code:
                item['emoji_handle'] = handle_code[0] ➏
            all_items.append(item) ➐
    return all_items ➑

❶ 由于每个页面使用多个对象，这行代码在 parse 方法的开始使用了一个列表，并在遍历页面的过程中，保存一个找到的对象的列表。

❷ 不同于在 LXML 脚本中调用 header.text，这行代码定位到文本小节（.xpath("text()")），并且使用 extract 函数抽取它。因为我们知道这个方法会返回一个列表，所以这段代码选择每个列表中第一个并且唯一的对象，将其赋值给 section。

❸ 这行代码定义对象。对于每一个列表对象，通过调用类名称与一对空括号创建了一个新的 EmojiSpiderItem 对象。

❹ 为了抽取数据属性，这行代码使用 XPath @ 选择器。这段代码选择第一个 span，并且抽取 @data-src 属性，这会返回一个列表。

❺ 为了创建完整的 emoji_link 属性，这行代码使用响应 URL 并且添加 @data-src 属性中第一个列表对象。为了设置对象的字段，使用字典语法，将键（即字段名称）赋值。如果前面代码没有找到 @data-src，那么这行代码不会执行。

❻ 为了组合一些代码，并且不重复我们自己的代码，这段代码找到 emoji 和声音的处理字符串，赋值给 emoji_handle 字段。

❼ 在列表元素每个循环的最后，这行代码追加新的对象到 all_items 列表。

❽ 在 parse 方法的最后，这行代码返回了所有找到的对象的列表。Scrapy 会在抓取中使用一个返回的对象或对象列表（通常通过保存、清洗或者以我们可以阅读和使用的格式输出数据）。

现在添加了 extract 方法调用，并且更具体地识别出了要从页面中抓取的文本与属性。我们移除了其中的一些 None 逻辑，因为 Scrapy 对象会自动了解拥有哪一个字段，不拥有哪个字段。出于这个原因，如果导出输出到 CSV 或 JSON，它会同时显示空（null）行和找到的值。现在已经更新了同 Scrapy 工作的代码，再一次调用 crawl 方法运行它。

scrapy crawl emo

你应该看到一些类似于第一个抓取的输出，只是多出了几行！ Scrapy 会在解析网页时打印每一个找到的对象到日志。在最后，你会看到相同的总结输出，显示错误、调试信息和抓取对象的数量。

2015-06-03 18:13:51+0200 [emo] DEBUG: Scraped from
    <200 http://www.emoji-cheat-sheet.com/>
    {'emoji_handle': u'/play butts',
     'section': u'Campfire also supports a few sounds'}

2015-06-03 18:13:51+0200 [emo] INFO: Closing spider (finished)
2015-06-03 18:13:51+0200 [emo] INFO: Dumping Scrapy stats:
    {'downloader/request_bytes': 224,
     'downloader/request_count': 1,
     'downloader/request_method_count/GET': 1,
     'downloader/response_bytes': 143742,
     'downloader/response_count': 1,
     'downloader/response_status_count/200': 1,
     'finish_reason': 'finished',
     'finish_time': datetime.datetime(2015, 6, 3, 16, 13, 51, 803765),
     'item_scraped_count': 924,
     'log_count/DEBUG': 927,
     'log_count/INFO': 7,
     'response_received_count': 1,
     'scheduler/dequeued': 1,
     'scheduler/dequeued/memory': 1,
     'scheduler/enqueued': 1,
     'scheduler/enqueued/memory': 1,
     'start_time': datetime.datetime(2015, 6, 3, 16, 13, 50, 857193)}
2015-06-03 18:13:51+0200 [emo] INFO: Spider closed (finished)

Scrapy 在大约 1 秒的时间里解析 900 多个对象——令人惊讶！在查看日志时，我们看到所有的对象均被解析和添加。没有出现任何的错误；如果有的话，会在最后的输出中看到一个错误数量，类似于 DEBUG 和 INFO 输出行。

我们还没有通过脚本得到一个真正的文件或输出。可以使用一个内置的命令行参数设置一个。使用一些其他的参数选项尝试重新运行爬虫。

scrapy crawl emo -o items.csv

在抓取的最后，你的项目根目录中应该有一个 item.csv 文件。如果你打开它，应该会看到所有的数据都被导出到了 CSV 格式中。你同样可以导出 .json 和 .xml 文件，所以尽情地通过改变文件名尝试这些选项。

恭喜，你已经搭建了第一个网络爬虫！只需要几个文件和不到 50 行的代码，你就可以在 1 分钟以内解析一整个页面——超过 900 个对象，输出这些发现到一个简单可阅读并且可以轻松分享的格式文件里。正如你看到的那样，Scrapy 是一个非常强大又极其有用的工具。

12.2.2　使用Scrapy爬取整个网站

我们已经探索了使用 Scrapy shell 和 crawl 来爬取普通页面，但是如何利用 Scrapy 的能力和速度来爬取整个站点？为了研究 CrawlSpider 的能力，需要首先确定要爬取的内容。让我们尝试寻找 PyPI 主页（http://pypi.python.org）中与抓取相关的 Python 包。首先，查看一下页面，找出我们想要的数据。快速搜索“scrape”（https://pypi.python.org/pypi?:action=search&term=scrape&submit=search）显示了一整个列表的搜索结果，其中的每一个页面都有更多的信息，包括文档、一个相关包的链接、一个支持的 Python 版本的列表和最近下载的数量。

可以围绕这些数据构建一个对象模型。一般来说，如果不是与相同的数据关联，我们会为每一个抓取器创建一个新的项目；但是为了方便使用，我们使用和 emoji 抓取器相同的文件夹。从修改 items.py 文件开始：

# -*- coding: utf-8 -*-

# 在这里定义为要抓取的对象定义模型
#
# 参见文档：
# http://doc.scrapy.org/en/latest/topics/items.html

import scrapy

class EmojiSpiderItem(scrapy.Item):
    emoji_handle = scrapy.Field()
    emoji_link = scrapy.Field()
    section = scrapy.Field()

class PythonPackageItem(scrapy.Item):
    package_name = scrapy.Field()
    version_number = scrapy.Field()
    package_downloads = scrapy.Field()
    package_page = scrapy.Field()
    package_short_description = scrapy.Field()
    home_page = scrapy.Field()
    python_versions = scrapy.Field()
    last_month_downloads = scrapy.Field()

我们在旧的类下面直接定义了新的对象类。在类之间保留几个空行，这样更容易阅读文件和看到类的不同之处。这里，添加了 Python 包页面中我们感兴趣的一些字段，包括过去一个月的下载量、包的主页、支持的 Python 版本以及版本号。

有了对象定义，可以使用 Scrapy shell 来研究 Scrapely 页面上的内容。Scrapely 是 Scrapy 作者的一个项目，使用 Python 来像屏幕一样阅读 HTML。如果还没有安装它，同样建议安装 IPython，这会确保你的输入和输出看起来和本书中的一样，并且提供了一些其他的 shell 工具。在 shell 中（后文指 scrapy shell），需要首先使用下面的命令抓取内容。

fetch('https://pypi.python.org/pypi/scrapely/0.12.0')

可以尝试从页面顶端的 breadcrumb 标签中抓取版本号。它们在 ID 为 breadcrumb 的 div 中，我们可以编写一些 XPath 来找到它。

In [2]: response.xpath('//div[@id="breadcrumb"]')
Out[2]: [<Selector xpath='//div[@id="breadcrumb"]'
        data=u'<div id="breadcrumb">\n              <a h'>]

IPython 的 Out 信息显示我们已经正确地找到了 breadcrumb div。通过在浏览器的检视标签中检查元素，我们看到文本位于 div 中的一个锚标签中。我们需要使用 XPath 特化，告诉它通过下面这些行代码去查找子锚标签中的文本：

In [3]: response.xpath('//div[@id="breadcrumb"]/a/text()')
Out[3]:
[<Selector xpath='//div[@id="breadcrumb"]/a/text()' data=u'Package Index'>,
 <Selector xpath='//div[@id="breadcrumb"]/a/text()' data=u'scrapely'>,
 <Selector xpath='//div[@id="breadcrumb"]/a/text()' data=u'0.12.0'>]

现在可以在最后的 div 中看到版本号，在抽取的时候处理最后一个 div。使用正则表达式做一些测试，确保版本数据是一个数字（见 7.2.6 节），或者使用 Python 的 is_digit（见 7.2.3 节）。

现在看一下如何获取页面中略微复杂的部分：最近一个月的下载量。如果在浏览器中检查了这个元素，你会看到它位于一个 span 中的列表项中的无序列表中。你会注意到，其中没有任何一个元素有 CSS ID 或类。你还会注意到 span 不包括实际上的单词“month”（为了便于搜索）。让我们看一下是否可以得到一个有用的选择器。

In [4]: response.xpath('//li[contains(text(), "month")]')
Out[4]: []

喔，使用 XPath 文本搜索寻找元素是不容易的。然而，在 XPath 中，一个好的技巧是如果你轻微地改变查询，解析相似的对象，有些时候会表现得完全不同。尝试运行这个命令：

In [5]: response.xpath('//li/text()[contains(., "month")]')
Out[5]: [<Selector xpath='//li/text()[contains(., "month")]'
        data=u' downloads in the last month\n '>]

看到没？为什么一个有效，而其他的却没用呢？因为元素是位于 li 元素的 span，而其他文本位于 span 之后，这迷惑了 XPath 模式搜索的层次。页面结构越复杂，编写一个完美的选择器就越难。我们想要在第二个模式中做的有一点不同——我们说“给我位于 li 中且其中包含 month 的文本”，而不是“给我一个拥有 month 文本的 li 元素”。这里差别很小，但是处理混乱的 HTML 时，通过尝试不同的选择器处理困难的小节是有用处的。

但是我们真正需要的是包含下载数量的 span。可以使用 XPath 关系的魔力在链路上浏览并且定位 span。尝试下面的代码。

In [6]: response.xpath('//li/text()[contains(., "month")]/..')
Out[6]: [<Selector xpath='//li/text()[contains(., "month")]/..' data=u'<li>\n
        <span>668</span> downloads in t'>]

通过使用 .. 操作符，回退到父节点，这样现在同时有了 span 后的文本和 span 本身。最后一步是选择 span，这样不需要担心剥离文本。

In [7]: response.xpath('//li/text()[contains(., "month")]/../span/text()')
Out[7]: [<Selector xpath='//li/text()[contains(., "month")]/../span/text()'
        data=u'668'>]

棒！现在有了想要找到的数字，并且它应该在所有的页面上工作，因为它基于页面层次编写，并且没有尝试去“猜测”内容可能位于哪里。

　使用 XPath 技巧在 shell 中调试和定位你想要使用的对象。随着经验的积累，你在第一次尝试编写选择器就会更容易取得成功，所以鼓励你编写更多的抓取器，通过测试更多不同的选择器进行实验。

首先编写一个能够使用 Spider 类正确解析 Scrapely 页面的抓取器，之后将它转换为使用 CrawlSpider 类的版本。循序渐进地解决一个有两三个因素的问题是个好方法，在完成一部分任务后再完成下一个部分。因为需要使用 CrawlSpider 调试两部分代码（抓取规则以找到匹配的页面和抓取页面本身），所以首先确认其中的一部分有效是比较好的做法。建议从构建一个抓取器（可以在一两个匹配的页面上工作）开始，之后编写抓取规则来测试爬取逻辑。

下面，看一下 Python 包页面的完整的 Spider。将它作为一个新的文件包含在 spiders 文件夹中，同 emo_spider.py 文件一起。我们称它为 package_spider.py。

import scrapy
from scrapyspider.items import PythonPackageItem

class PackageSpider(scrapy.Spider):
    name = 'package'
    allowed_domains = ['pypi.python.org']
    start_urls = [
        'https://pypi.python.org/pypi/scrapely/0.12.0',
        'https://pypi.python.org/pypi/dc-campaign-finance-scrapers/0.5.1', ➊
    ]

    def parse(self, response):
        item = PythonPackageItem() ➋
        item['package_page'] = response.url
        item['package_name'] = response.xpath(
            '//div[@class="section"]/h1/text()').extract()
        item['package_short_description'] = response.xpath(
            '//meta[@name="description"]/@content').extract() ➌
        item['home_page'] = response.xpath(
            '//li[contains(strong, "Home Page:")]/a/@href').extract() ➍
        item['python_versions'] = []
        versions = response.xpath(
            '//li/a[contains(text(), ":: Python ::")]/text()').extract()
        for v in versions:
            version_number = v.split("::")[-1] ➎
            item['python_versions'].append(version_number.strip()) ➏
        item['last_month_downloads'] = response.xpath(
            '//li/text()[contains(., "month")]/../span/text()').extract()
        item['package_downloads'] = response.xpath(
            '//table/tr/td/span/a[contains(@href,"pypi.python.org")]/@href' ➐
        ).extract()
        return item ➑

❶ 这行代码添加一个我们没有研究过的额外的 URL。使用多个 URL 是一种从 Spider 转到 CrawlSpider 时快速检查代码整洁性和复用性的好方法。

❷ 对于这个抓取器，每个页面只需要一个对象。这行代码在 parse 方法的开始创建了这个对象。

❸ 在你解析时，学习一些关于搜索引擎优化（SEO）的知识是获得易读页面描述的一种很好的方式。大多数站点会为 Facebook、Pinterest 和其他共享信息的网站创建简短的描述、关键词、标题和其他元标签。这行代码为数据收集拉取描述。

❹ 包的“Home Page”URL 位于 li 中的一个 strong 标签中。一旦找到这个元素，这行代码只选择锚元素中的链接。

❺ 版本号链接位于一个使用 :: 分隔 Python 和版本号的表单对象中。版本号永远出现在最后，这样这行代码使用 :: 作为分隔符分割字符串，使用最后的元素。

❻ 这行代码追加版本文本（去除额外的空格）到 Python 版本数组。对象的 python_versions 键现在会保存所有的 Python 版本。

❼ 可以看到，在表格中有使用 pypi.python.org 域名的链接，而不是它们的 MD5 校验值。这行代码判断链接是否有正确的域名，并只抓取有正确域名的链接。

❽ 在 parse 方法的最后，Scrapy 希望我们返回一个对象（或一个对象列表）。这行代码返回这些对象。

运行这段代码（scrapy crawl package），你应该会得到两个对象，并且没有错误。然而，你会发现我们得到一些不同的数据。举个例子，对于每一个下载，我们的包数据没有一个好的支持的 Python 版本的列表。如果想要这个列表，可以从表格中的 PyVersion 字段解析，并将它与每一个下载匹配。你会怎样做这件事呢？（提示：这个字段位于每个数据行的第三列，XPath 允许你传递元素索引。）我们同样注意到数据有一些混乱，就像下面的输出（为了匹配页面进行了格式化；你的输出会看起来有一些不同）所展示的一样。

2015-09-10 08:19:34+0200 [package_test] DEBUG: Scraped from
    <200 https://pypi.python.org/pypi/scrapely/0.12.0>
    {'home_page': [u'http://github.com/scrapy/scrapely'],
     'last_month_downloads': [u'668'],
     'package_downloads':
    [u'https://pypi.python.org/packages/2.7/s/' + \
      'scrapely/scrapely-0.12.0-py2-none-any.whl',
     u'https://pypi.python.org/packages/source/s/' + \
      'scrapely/scrapely-0.12.0.tar.gz'],
    'package_name': [u'scrapely 0.12.0'],
    'package_page': 'https://pypi.python.org/pypi/scrapely/0.12.0',
    'package_short_description':
    [u'A pure-python HTML screen-scraping library'],
    'python_versions': [u'2.6', u'2.7']}

有几个字段原本希望是字符串或整数值，但是取而代之的是一个字符串数组。让我们在定义爬虫规则之前创建一个辅助方法来清洗数据。

import scrapy
from scrapyspider.items import PythonPackageItem

class PackageSpider(scrapy.Spider):
    name = 'package'
    allowed_domains = ['pypi.python.org']
    start_urls = [
        'https://pypi.python.org/pypi/scrapely/0.12.0',
        'https://pypi.python.org/pypi/dc-campaign-finance-scrapers/0.5.1',
    ]

    def grab_data(self, response, xpath_sel): ➊
        data = response.xpath(xpath_sel).extract() ➋
        if len(data) > 1: ➌
            return data
        elif len(data) == 1:
            if data[0].isdigit():
                return int(data[0]) ➍
            return data[0] ➎
        return [] ➏

    def parse(self, response):
        item = PythonPackageItem()
        item['package_page'] = response.url
        item['package_name'] = self.grab_data(
            response, '//div[@class="section"]/h1/text()') ➐
        item['package_short_description'] = self.grab_data(
            response, '//meta[@name="description"]/@content')
        item['home_page'] = self.grab_data(
            response, '//li[contains(strong, "Home Page:")]/a/@href')
        item['python_versions'] = []
        versions = self.grab_data(
            response, '//li/a[contains(text(), ":: Python ::")]/text()')
        for v in versions:
            item['python_versions'].append(v.split("::")[-1].strip())
        item['last_month_downloads'] = self.grab_data(
            response, '//li/text()[contains(., "month")]/../span/text()')
        item['package_downloads'] = self.grab_data(
            response,
            '//table/tr/td/span/a[contains(@href,"pypi.python.org")]/@href')
        return item

❶ 这行定义了一个新的方法以使用 self 对象（这样爬虫可以像普通方法一样调用它）、响应对象以及长长的 XPath 选择器来查找内容。

❷ 这行代码使用新的函数变量抽取数据。

❸ 如果数据的长度大于 1，这行代码返回列表。我们可能想要所有的数据，所以原样返回。

❹ 如果数据的长度等于 1，并且数据是一个数字，这行代码返回整数。这可能会是下载数量的情况。

❺ 如果数据的长度等于 1，但是不是一个数字，这行代码只返回这个数据。这会匹配包含链接和简单文本的字符串。

❻ 如果函数没有返回，这行代码返回一个空列表。这里使用一个列表，因为我们希望 extract 在没有找到数据的时候返回空列表。如果使用 None 类型或者空字符串，你可能需要修改其他的代码，来保存它到 CSV。

❼ 这行代码调用新的函数，并且使用下面的参数触发 self.grab_data：响应对象和 XPath 选择字符串。r 使用其他内置输出功能。

现在我们有了相当干净的数据和代码，并且更少地重复自己的代码。我们可以更加深入地优化它，但是为了不让你眼花缭乱，先来定义爬取规则。爬取规则由正则表达式实现，通过定义页面位置和遵循的 URL 类型，告诉爬虫去哪里爬取。（第 7 章介绍了正则表达式，是不是很棒？你现在已经是专业的了！）如果看一下包的链接（https://pypi.python.org/pypi/dc-campaign-finance-scrapers/0.5.1 和 https://pypi.python.org/pypi/scrapely/0.12.0），可以看到以下相似点。

它们都有相同的域名，pypi.python.org，并且它们都使用 https。

在 URL 中，它们都有相同的路径模式：/pypi/<name_of_the_library>/<version_number>。

库的名称使用小写字母和破折号，版本号由数字和句点组成。

可以使用这些相似性来定义正则规则。在脚本中编写它们之前，先在 Python 控制台中尝试它们。

import re

urls = [
    'https://pypi.python.org/pypi/scrapely/0.12.0',
    'https://pypi.python.org/pypi/dc-campaign-finance-scrapers/0.5.1',
]

to_match = 'https://pypi.python.org/pypi/[\w-]+/[\d\.]+' ➊

for u in urls:
    if re.match(to_match, u):
        print re.match(to_match, u).group() ➋

❶ 这行代码找到一个使用 https、域名为 pypi.python.org 并且还有我们研究路径的链接。第一个部分是 pypi，第二个是有着符号“-”的小写文本（使用 [\w-]+ 可以轻松地匹配），最后一部分寻找有或没有句点的数字（[\d\.]+）。

❷ 这行代码输出了匹配的组。我们正在使用正则表达式的 match 方法，因为这是正则 Scrapy 爬虫所使用的。

我们有了一个匹配（确切地说，是两个！）。现在，最后看一下需要从哪里开始。Scrapy 爬虫会首先使用起始 URL 列表，然后跟随这些网页找到其他 URL。如果再看一下搜索结果页（https://pypi.python.org/pypi?:action=search&term=scrape&submit=search），我们会注意到页面使用相对 URL，这样只需要匹配 URL 路径。我们同样看到所有的链接都位于表格中，这样可以限制 Scrapy 查看以找到用来爬取链接的位置。知道这些后，通过添加爬取规则来更新文件。

from scrapy.contrib.spiders import CrawlSpider, Rule ➊
from scrapy.contrib.linkextractors import LinkExtractor ➋
from scrapyspider.items import PythonPackageItem

class PackageSpider(CrawlSpider): ➌
    name = 'package'
    allowed_domains = ['pypi.python.org']
    start_urls = [
        'https://pypi.python.org/pypi?%3A' + \
                'action=search&term=scrape&submit=search',
        'https://pypi.python.org/pypi?%3A' + \
                'action=search&term=scraping&submit=search', ➍
    ]
    rules = (
        Rule(LinkExtractor(
            allow=['/pypi/[\w-]+/[\d\.]+', ], ➎
            restrict_xpaths=['//table/tr/td', ], ➏
        ),
            follow=True, ➐
            callback='parse_package', ➑
        ),
    )

    def grab_data(self, response, xpath_sel):
        data = response.xpath(xpath_sel).extract()
        if len(data) > 1:
            return data
        elif len(data) == 1:
            if data[0].isdigit():
                return int(data[0])
            return data[0]
        return []

    def parse_package(self, response):
        item = PythonPackageItem()
        item['package_page'] = response.url
        item['package_name'] = self.grab_data(
        response, '//div[@class="section"]/h1/text()')
        item['package_short_description'] = self.grab_data(
            response, '//meta[@name="description"]/@content')
        item['home_page'] = self.grab_data(
            response, '//li[contains(strong, "Home Page:")]/a/@href')
        item['python_versions'] = []
        versions = self.grab_data(
            response, '//li/a[contains(text(), ":: Python ::")]/text()')
        for v in versions:
            version = v.split("::")[-1]
            item['python_versions'].append(version.strip())
        item['last_month_downloads'] = self.grab_data(
            response, '//li/text()[contains(., "month")]/../span/text()')
        item['package_downloads'] = self.grab_data(
            response,
            '//table/tr/td/span/a[contains(@href,"pypi.python.org")]/@href')
        return item

❶ 这行代码同时导入 CrawlSpider 类和 Rule 类，因为在第一个爬虫中，我们需要它们。

❷ 这行代码导入了 LinkExtractor。默认的链接抽取器使用 LXML（我们知道如何编写它！）。

❸ 这行代码重新定义了 Spider，这样它从 CrawlSpider 类继承而来。由于修改了这种继承，需要定义一个 rules 属性。

❹ 包含了检索词为 scrape 和 scraping 的搜索页，以查看是否可以找到更多的 Python 包。如果你有不同的让脚本开始搜索的起始点，可以在这里添加一个长列表。

❺ 这行代码设置了 allow 来使用正则匹配页面上的链接。因为只需要相关的链接，所以只从匹配的链接开始。allow 接受一个列表，所以如果你有不止一个类型的 URL 想要匹配，可以在这里添加多个 allow 规则。

❻ 这行代码限制了爬虫到结果表格中。这意味着爬虫只会去表格列中的数据行寻找匹配链接。

❼ 这行告诉了跟随（即加载）匹配链接的规则。有些时候，对于一些页面你可能只想解析并获取内容，但是不需要跟随它的链接。如果想要让爬虫跟随页面链接，并且打开它们，你需要使用 follow=True。

❽ 赋值给规则一个回调函数，并且重新命名 parse 方法来确认没有与 Scrapy 的 CrawlSpider 类使用的解析方法混淆。现在解析方法叫作 parse_package，并且爬虫在跟随匹配的 URL 拿到我们想要抓取的页面后会调用这个方法。

你可以同运行一个普通的抓取器一样，运行这个爬虫：

scrapy crawl package

你已经正式地完成了第一个爬虫！是否还有待完善的地方？有一个容易修复的 bug 遗留在这段代码中了。你能找到它吗？如何修复它？［提示：查看你的 Python 版本，然后查看返回版本的方式（即永远返回一个列表），与 grab_data 返回数据对比。］看看你是否能够在爬虫脚本中修复这个问题。如果不能，可以参考本书仓库（https://github.com/jackiekazil/data-wrangling），得到完整的修复后的代码。

Scrapy 是一个有效、快速、方便配置的工具。还有很多值得探索，你可以阅读该库的很棒的文档（http://doc.scrapy.org/en/latest/）。配置你的脚本来使用数据库和特殊的信息抽取工具，并且在自己的服务器上使用 Scrapyd（http://scrapyd.readthedocs.org/en/latest/）运行它们是很简单的。希望这是你之后众多 Scrapy 项目的第一个！

现在你理解了屏幕读取器、浏览器读取器和爬虫。让我们看看构建更加复杂的网页爬虫所需要知道的其他一些事情。

12.3　网络：互联网的工作原理，以及为什么它会让脚本崩溃

取决于运行抓取脚本的频率，以及每个脚本工作的重要性，你可能会碰到网络问题。是的，互联网正在尝试破坏你的脚本。为什么？因为互联网认为如果你真的在乎，你会重试。在网页抓取世界里丢失的连接、代理问题以及超时问题普遍存在。然而，有一些方法可以缓解这些问题。

在浏览器中，如果有页面信息没有正确加载，你就会点击刷新，立即发送另一个请求。对于抓取器，你可以模仿这种行为。如果你正在使用 Selenium，刷新内容会极其简单。 Selenium 的 webdriver 对象有一个 refresh 函数，就像浏览器一样。如果你已经填充了一个表单，需要重新提交表单，前进至下一页（有时这类似于浏览器的行为）。如果你需要同警告或弹出窗口交互，Selenium 提供了接受或拒绝信息所需的工具。

Scrapy 有内置的重试中间件。要启用它，你只需将它添加到项目的 settings.py 文件的中间件列表中。中间件（https://doc.scrapy.org/en/latest/topics/downloader-middleware.html#module-scrapy.contrib.downloadermiddleware.retry）希望你在设置中设定一些默认值，以便它知道哪些 HTTP 响应码需要重试（例如，它需要只在返回码为 500 的时候重试吗？），以及重试的次数。

　如果你没有指定这些值，它仍然会使用文档中列出的默认值工作。如果你看到网络错误，然后下载等待时间（另外一个全局设置变量）变长，建议你从 10 次重试开始，或者检查收到的返回码，看看脚本是否访问网站过于频繁。

如果你正在使用自己的 Python 脚本和 LXML 或 BeautifulSoup，最好是捕获这些错误并确定处理它们的方法。大多数时间里，你会注意到 urllib2.HTTPError（https://docs.python.org/2/library/urllib2.html#urllib2.HTTPError）异常的优势；或者，如果你正在使用 requests，代码不会加载内容，并且失败。在 Python 中使用一个 try...except 代码块，你的代码可能看起来像下面一样。

import requests
import urllib2

resp = requests.get('http://sisinmaru.blog17.fc2.com/')

if resp.status_code == 404: ➊
    print 'Oh no!!! We cannot find Maru!!'
elif resp.status_code == 500:
    print 'Oh no!!! It seems Maru might be overloaded.'
elif resp.status_code in [403, 401]:
    print 'Oh no!! You cannot have any Maru!'

try:
    resp = urllib2.urlopen('http://sisinmaru.blog17.fc2.com/') ➋
except urllib2.URLError: ➌
    print 'Oh no!!! We cannot find Maru!!'
except urllib2.HTTPError, err: ➍
    if err.code == 500: ➎
        print 'Oh no!!! It seems Maru might be overloaded.'
    elif err.code in [403, 401]:
        print 'Oh no!! You cannot have any Maru!'
    else:
        print 'No Maru for you! %s' % err.code ➏
except Exception as e: ➐
    print e

❶ 当使用 requests 库来查找网络错误时，检查响应的 status_code。这个属性会返回一个代表在 HTTP 响应中收到的代码的整数。这行代码测试响应是否为 404 错误。

❷ 如果正在使用 urllib2，将请求放在一个 try 语句中（正如这行代码一样）。

❸ 可能会从 urllib2 中看到的一个异常是 URLError。编写一个捕获方法是好的想法。如果它不能解析域名，可能会抛出这个错误。

❹ 可能看到的另外一个异常是 HTTPError。任何有关 HTTP 请求错误的响应都会抛出这个错误。通过添加冒号和 err，捕获了错误，并且将它保存在变量 err 中，这样可以打印错误到日志。

❺ 现在捕捉到了错误，并且将其赋值给前行代码中的 err，这行代码判断 code 属性，来查看 HTTP 错误码。

❻ 对于所有其他的 HTTP 错误，这行代码使用 else 通过格式化它到字符串，来展示错误码。

❼ 这行代码捕获其他所有可能碰到的错误，并且展示错误信息。赋值异常给变量 e，并且打印它，这样可以阅读异常信息。

巧妙地设计脚本，让它尽可能地抗失败，这是一个重要的步骤（第 14 章会更详细地讨论）；同时确保在代码中有合适的 try...except 代码块来解释错误，也是进程中重要的一部分。除了 HTTP 错误，有些时候，页面花费过长的时间来加载。如果抓取器响应缓慢或碰到了延迟问题，我们可能会调整超时时间。

　什么是延迟？从网络角度来讲，这是数据从一个地点发送到另一个地点需要的时间。往返延迟是从计算机发送请求到服务器，并且得到响应花费的时间。延迟因为数据的传输而存在，有时数据需要传送几千千米，来完成请求。

当编写和规模化脚本时，考虑延迟是很好的。如果脚本所连接的站点托管在另一个国家，你会经历网络延迟。因此你需要相应地调整超时时间，或者创建一个距离目标端点近的服务器。如果想要添加超时时间到 Selenium 和 Ghost.py 脚本，你可以在抓取工作开始的时候，直接添加到脚本中。对于 Selenium，使用 set_page_load_timeout（http://selenium-python.readthedocs.io/api.html#selenium.webdriver.remote.webdriver.WebDriver.set_page_load_timeout）方法，或使用隐式 / 显式的等待（http://selenium-python.readthedocs.io/waits.html），这样浏览器会等待代码中特定的部分加载。对于 Ghost.py，你可能需要传递 wait_timeout 参数，像 Ghost 类文档中定义的那样（http://ghost-py.readthedocs.io/en/latest/#ghost.Ghost）。

对于 Scrapy，抓取器的异步特性和对特定的 URL 重试若干次的能力，让超时设置变成了一个略微难办的问题。当然，你可以在 Scrapy 设置中使用 DOWNLOAD_TIMEOUT（http://doc.scrapy.org/en/latest/topics/settings.html#download-timeout）直接改变超时时间。

如果你正在编写自己的 Python 脚本，并且使用 LXML 或 BeautifulSoup 来解析页面，添加超时到调用是你的职责。如果使用 requests 或 urllib2，你可以在调用页面的时候直接这样做。在 requests 中，你可以直接将其作为一个参数，添加到 get 请求中（http://docs.python-requests.org/en/latest/user/quickstart/#timeouts）。对于 urllib2，你需要传递超时时间，作为 urlopen 方法（https://docs.python.org/2/library/urllib2.html#urllib2.urlopen）的参数之一。

如果你正经历持续的网络方面的问题，并且脚本需要依据一个稳定的日程运行，建议你创建一些日志，尝试在另外一个网络上运行（即不是你的家庭网络，来判断你的家庭互联网连接是否存在问题），并且测试是否在一个非高峰时间段运行会有帮助。

　脚本在每天下午 5 点还是上午 5 点钟更新对你是否很重要？很可能在下午 5 点钟你的本地互联网服务提供商会非常繁忙，而上午 5 点钟可能会很安静。如果在高峰时段你的家庭网络很难做成事，那么很可能你的脚本也同样做不了什么！

除了网络问题之外，你可能会找到其他破坏抓取脚本的问题，比如互联网在不停变化这一事实。

12.4　变化的互联网（或脚本为什么崩溃）

正如你知道的，网页的重新设计、更新的内容管理系统和页面结构的改变（一个新的广告系统、一个新的推荐网络等）是互联网中很正常的一部分。互联网成长并且变化着。因此，你的网页抓取脚本会崩溃。好消息是，有很多的站点只是每年更新一次，或几年改变一次。还有些改变不会影响页面结构（有时样式更新或广告更新不会改变代码的内容和结构）。不要彻底失去希望；很可能你的脚本会工作很长一段时间！

无论怎样，我们不想给你虚假的希望。你的脚本最终会崩溃。总有一天，你会继续运行它，然后发现它不再工作了。当发生这些情况时，给自己一个大大的拥抱，为自己冲一杯茶或咖啡，然后重新开始。

现在你知道了更多关于检验网站上的内容和为报告找出最有用的那部分的方法。你已经有了相当多的代码，大部分仍然能够工作。你现在处在一个好的调试阶段，并且有很多工具任你使用，以找到新的 div 或包含所需数据的表。

12.5　几句忠告

当抓取网页时，谨慎是很重要的。你还需要了解所在国家关于网页内容的法律。一般来说，如何做到谨慎是很显然的。不要把别人的内容当作自己的来用。不要使用已经声明不允许分享的内容。不要向别人或网站发送垃圾邮件。不要攻击网站或恶意地爬取站点。最基本地，不要做一个蠢人！如果你不能同母亲或其他亲近的人分享正在做的事情，并且感觉良好，那就不要做。

有几种方式来明确你在互联网上做的事情。许多抓取库允许你发送 User-Agent 字符串。你可以将自己的信息或者公司的信息放到这些字符串中，这样抓取者的信息就很清晰。同时，确保查看站点的 robot.txt 文件（http://www.robotstxt.org/robotstxt.html），它会告诉网页抓取器站点中禁止爬取的内容。

　在构建爬虫遍历一个站点之前，看一下站点中你感兴趣的部分是否包含在 robot.txt 的 Disallow 小节中。如果它们存在其中，你需要找到别的方式来获得数据，或者联系站点的拥有者，看看他们是否会通过其他方式为你提供数据。

在互联网上规规矩矩做好，并且在构建抓取器时做正确的事。这意味着你可以为自己的工作感到骄傲；不要麻烦律师、公司和政府；放心地使用收集的信息。

12.6　小结

现在在为难于解析内容的网页编写抓取器时，你应该感到胸有成竹。你可以使用 Selenium 或 Ghost.py 打开浏览器，读取一个网页，同页面交互，并且抽取数据。你可以使用 Scrapy 来爬取整个域名（或一系列域名），并且抽取大量的数据。你同样可以练习正则表达式语法，编写自己的 Python 类（在 Scrapy 的帮助下）。

掌握这些后，Python 代码就会顺其自然地完成了。你探索了一些 bash 命令。你积累了一些非常棒的与 shell 脚本交互的经验，正在成为一名专业的数据处理者。表 12-2 列出了本章中引入的新概念和工具。

表12-2：新的Python编程概念与库

概念/库

目的

Selenium 库

该库用于直接同网页和它们的元素交互，你可以使用所选的浏览器，也可以使用无头浏览器。在你需要点击元素、在表单中输入信息，并且同需要几个请求来加载内容的页面交互时，表现良好

PhantomJS 库

JavaScript 库，作为无头浏览器，用于在服务器或无浏览器机器上进行网页抓取。还可用来只使用 JavaScript 编写抓取器

Ghost.py 库

通过 Qt WebKit 而不是通过传统的浏览器来与网页交互的库。可以在需要浏览器等相似条件下使用，有编写原生 JavaScript 的能力

Scrapy 库

用于跨越一个域名或多个不同域名爬取大量网页。在你需要研究多个域名或多种页面类型来收集数据时很有用处

Scrapy 爬取规则

爬取规则告诉你的爬虫匹配 URL 结构，并识别出可能存在该 URL 的页面位置。这使得爬虫可以浏览并找到更多信息

最后，对于抓取器，确保你遵循一些基本的逻辑（见表 12-3）。

表12-3：使用哪一个抓取器

抓取器类型

库

使用场景

页面读取抓取器

BeautifulSoup、LXML

简单页面抓取，你想要的所有数据在一次请求后全部加载于页面上

基于浏览器的抓取器

Selenium、PhantomJS、Ghost.py

基于浏览器的抓取，你需要同页面上的元素交互，或这个页面需要不同的请求加载

Web 爬虫

Scrapy

用快速和异步的方式跨越多个页面跟随链接或解析相似的页面。如果你需要跨越整个域名或一系列域名的多个匹配，这会很有用

后面几章关注如何拓展使用 API 的 Web 技巧，以及规模化和自动化数据。这些是将学到的所有知识整合为一系列可复用、可执行的脚本的最后几步——其中一些脚本不需要做任何事情就可运行。还记得你在开始阅读本书时想到的那些死记硬背的任务吗？以后再也不必死记硬背它们了——继续阅读！

第 13 章　应用编程接口

应用编程接口（application programming interface，API）听起来是一个很酷炫的概念，但事实上并不是。API 是在 Web 上共享数据的一种标准方式。许多网站通过 API 端点来共享数据。本书列出了很多可用的 API，但是下面是一些很有用或有趣的 API。

Twitter（https://dev.twitter.com/overview/api）

US Census（http://www.census.gov/data/developers/data-sets.html）

World Bank（http://data.worldbank.org/node/9）

LinkedIn（https://developer.linkedin.com/docs/rest-api）

San Francisco Open Data（https://data.sfgov.org/）

这些都是返回数据的 API 示例。你向 API 发出请求，随后 API 返回数据。API 同样可以作为和其他应用交互的一种方式。例如，我们可以使用 Twitter API 来获得 Twitter 的数据，构建另外一个和 Twitter 交互的应用（例如，一个使用 API 发布推文的应用）。Google API 列表（https://developers.google.com/apis-explorer/#p/）是另外一个示例，大多数 API 允许你同公司服务进行交互。使用 LinkedIn API，你可以获取数据，发布更新到 LinkedIn，而不需要通过访问 Web 界面。因为 API 可以做很多不同的事情，所以它应该被认为是一种服务。就我们的目的而言，这个服务提供数据。

在这一章中，你会请求 API 数据并且保存到电脑。API 通常会返回 JSON、XML 或 CSV 文件，这意味着在数据保存到本地计算机之后，你需要应用在本书前几章学到的知识解析这些数据。本章使用的 API 是 Twitter API。

我们出于下面几个原因选择 Twitter API 作为样例。首先，Twiiter 是一个众所周知的平台。其次，Twitter 拥有大量的数据（推文），人们对分析这些数据有很大的兴趣。最后， Twitter API 允许我们探索许多 API 的概念，这正是本章讨论的话题。

Twitter 数据不仅是非正式的信息收集工具，类似于 One Million Tweet Map（http:// onemilliontweetmap.com/），同时也是更正式的学术研究工具，例如用于预测流感趋势（http://ieeexplore.ieee.org/document/5928903/?reload=true&tp=&arnumber=5928903&url=http:%2F%2Fieeexplore.ieee.org%2Fxpls%2Fabs_all.jsp%3Farnumber%3D5928903），以及捕获实时事件的发生，例如地震（http://dl.acm.org/citation.cfm?id=1772777）。

13.1　API特性

API 可以像数据响应请求一样简单，但是很难找到只有这个功能的 API 了。大多数的 API 还有其他有用的特性。这些特性包括多种不同的 API 请求方法（REST 或流式请求）、数据时间戳、频率限制、数据分级，以及 API 访问对象（key 和 token）。让我们通过 Twitter API 的上下文看一下这些特性。

13.1.1　REST API与流式API

Twitter API 有两种形式：REST API 和流式 API。大多数的 API 是 REST 形式的，但是一些实时服务通常会提供流式 API。REST 的全称为 Representational State Transfer（表述性状态转移），被设计用来构建稳定的 API 架构。可以使用 requests 库（见第 11 章）来获取 REST API 的数据。通过 requests 库，你可以发出 GET 和 POST Web 请求——这是 REST API 用来返回对应数据所使用的方法。在 Twitter 这个实例中，REST API 允许你查询推文，发布推文，做 Twitter 允许通过网站做的绝大多数事情。

　通过 REST API，你经常可以（但并不总是可以）在浏览器中通过请求 API 链接预览查询。如果在浏览器中加载了 URL，结果看起来像是文本块，你可以在浏览器中安装一个格式化预览插件。例如，Chrome 有 JSON 文件的预览插件，通过一种更好阅读的方式预览 JSON 内容。

流式 API 作为一种实时服务运行，监听对相关数据的请求。碰到流式 API 时，你会想要使用一个构建好的库帮助管理数据的拉取。想详细了解多 Twitter 流式 API 是如何工作的，可以查看 Twitter 网站上的概述（https://dev.twitter.com/streaming/overview）。

13.1.2　频率限制

API 通常都有频率限制，限制用户在一段时间内能够请求的数据数量。频率限制由 API 提供者出于多种不同的原因使用。除了频率控制，你可能还会碰到对数据访问有限制的 API，特别是数据与商业利益相关时。出于基础设施和客户服务的考虑，API 提供者会想要控制请求的数量，这样服务器和架构可以控制数据传输数量。如果允许每个人在 100% 的时间使用 100% 的数据，这可能会导致 API 服务的崩溃。

如果遇到需要支付额外费用来取得特殊访问权限的 API，你需要确定是否有能力支付，以及数据对研究有多大的价值。如果碰到有频率限制的 API，你需要确定是否数据的一个子集就足够了。如果 API 有频率限制，可能会花费相当长的时间来收集一个有代表性的样例，所以一定要评估你想要投入在这之上的努力程度。

API 通常会对所有的用户有频率控制，因为这样更易于管理。Twitter 的 API 曾经也使用这种方式；然而，随着流式 API 的出现，用法发生了改变。Twitter 的流式 API 提供了数据的常量流，而 REST API 限制了每 15 分钟你可以发出请求的数量。为了帮助开发者理解频率限制，Twitter 发布了一张图表（https://dev.twitter.com/rest/public/rate-limits）。

在练习中，我们会使用名叫获取搜索 / 推文（GET search/tweets）的 API。这个接口返回包含特定搜索语素的推文。如果你查看文档（https://dev.twitter.com/rest/reference/get/search/tweets），会发现 API 返回 JSON 格式的数据，频率限制在每 15 分钟 180 次或 450 次，取决于你是以用户还是应用的身份请求 API。

　保存来自 API 资源的数据文件时，你可以保存很多文件，或者将数据写入一个文件。正如在第 6 章学到的，你也可以保存推文数据到数据库。无论你用什么方式来保存数据，确保定期保存数据，不丢失任何已经请求的数据。

在第 3 章，我们处理了一个 JSON 文件。如果在每 15 分钟之间最大化 API 使用频率，可以收集 180 个 JSON 文件。如果你碰到了频率限制问题，需要优化对 Twitter 或者其他 API 的请求，请阅读 Twitter 的“API 频率限制”（https://dev.twitter.com/rest/public/rate-limiting）这篇文章中的“避免碰到频率限制的一些建议”这一节。

13.1.3　分级数据卷

迄今为止，我们已经讨论了通过其 API 可免费获取的 Twitter 数据。但是或许你想要知道怎样能够得到所有的数据？在 Twitter 这个实例中，你可能听说过三种数据访问级别： firehose、gardenhose 和 Spritzer。Spritzer 是免费的 API。表 13-1 描述了这些级别之间的不同。

表13-1：Twitter feed类型

feed 类型

覆盖

可用性

花费

firehose

所有推文

通过合作伙伴可用——DataSift（http://datasift.com/）或 Gnip（https://gnip.com/）

$$$

gardenhose

10% 的推文

新的访问不再可用

不可用

Spritzer

1% 或不到 1% 的推文

通过公共 API 可用

免费

看到这些选项你可能会想：“我需要 Firehose，因为我想要所有的数据！”但是在你尝试取得访问权限之前，需要知道下面这些事情。

firehose 是一个很大的数据。当处理海量数据时，你需要规模化数据处理。仅仅开始查询 firehose 提供的数据集，也会需要很多的工程师和服务器。

firehose 需要付费——一年几十万美元。这不包括你需要消耗的基础设施的花费（即服务器空间和数据库耗费）。使用 firehose 通常不是一个人可以独自做的事情——通常，由一个大型的公司或者机构支撑这些花销。

大部分你真正需要的，可以从 Spritzer 得到。

我们会使用 Spritzer，这是 Twitter 的免费公开 API，通过它可以在频率限制下取得推文。为了访问这个 API，我们会使用 API key 和 token。

13.1.4　API key和token

API key 和 token 是用来鉴别应用和用户的方式。Twitter API key 和 token 可能会让你迷惑。这里有四个你需要了解的概念。

API key

标识应用。

API secret

类似于应用的密码。

token

标识用户。

token secret

类似于用户的密码。

这几部分的组合给予我们访问 Twitter API 数据的权限。然而并不是所有的 API 都拥有这两层标识和密钥。Twitter 是一个很好的“最佳案例”（即更加安全的）示例。在一些情况下，API 会没有 key 或者只有一个 key。

创建一个Twitter API key和访问token

继续童工雇用的研究，收集 Twitter 上关于童工雇用的讨论。创建一个 Twitter API 的 key 很简单，但是需要以下几步。

(1) 如果你没有 Twitter 账户，先注册（https://twitter.com/signup）。

(2) 登录 apps.twitter.com。

(3) 点击“创建新应用”（Create New App）按钮。

(4) 给你的应用一个名称和描述。例如，名称设置为“童工讨论”，描述设置为“拉取 Twitter 上关于童工的讨论”。

(5) 给你的应用添加一个网站——这个网站托管着应用。指引这样写道：“如果你还没有一个 URL，可以先在此放置一个占位符，但是记得之后修改它。”我们并没有这样一个网站，所以把 Twitter 的 URL 放在这一栏中。确保你的 URL 中包含了 https，例如：https://twitter.com。

(6) 同意开发者协议，点击“创建 Twitter 应用”（Create Twitter Application）。

在创建了应用之后，你会被带到应用管理页。如果你找不到这个页面，可以通过回到应用起始页（https://apps.twitter.com/）找到它。

现在，你需要创建一个 token。

(1) 点击“Keys 和访问 Tokens”（Keys and Access Tokens）键。（这里你可以重置 key，同样可以创建一个访问 token。）

(2) 滚动到页面底部，点击“创建我的访问 token”（Create my access token）按钮。一旦你完成了这些，这个页面会通过在顶部更新来刷新。如果再一次滚动到页面底部，你会看到访问 token。

现在你应该有了一个消费者（API）key 和一个 token。下面是我们的 key 和 token。

消费者 key：5Hqg6JTZ0cC89hUThySd5yZcL

消费者 secret：Ncp1oi5tUPbZF19Vdp8Jp8pNHBBfPdXGFtXqoKd6Cqn87xRj0c

访问 token：3272304896-ZTGUZZ6QsYKtZqXAVMLaJzR8qjrPW22iiu9ko4w

访问 token secret：nsNY13aPGWdm2QcgOl0qwqs5bwLBZ1iUVS2OE34QsuR4C

不要跟任何人分享你的 key 或 token ！如果你和朋友分享了 key，他们可以在电子层面上代表你。如果他们滥用这个系统，你可能会失去访问权限，并为他们的行为负责。

为什么发布自己的应用？其中一个原因是我们可以生成更多个。在生成新的 key 和 token 的过程中，已经禁用了本书包含的 key 和 token——如果意外地暴露了 key 或者 token，这也是你应该做的事情。如果你需要创建一个新的 key，去“Keys 和访问 Tokens”（Keys and Access Tokens）栏目，点击“重新生成”（Regenerate）按钮。这会为你重新生成一个新的 API key 和 token。

现在有了一个 key，让我们访问 API ！

13.2　一次简单的Twitter REST API数据拉取

有了一系列的 key 值，现在可以开始访问 Twitter 的 API 数据了。在这一节中，我们会编写一个简单的脚本，使用一个搜索查询，从 API 拉取数据。这一节中的脚本基于一个由 Twitter 提供的、作为示例的 Python 代码片段（https://dev.twitter.com/oauth/overview/single-user#python）。这份代码使用了 Python OAuth2，OAuth2 是在使用 API 时为了安全地识别和连接而使用的协议。

　当下最好的认证方式是使用 OAuth2。一些 API 可能仍旧在使用 OAuth1。OAuth1 与 OAuth2 在功能上有所不同，并且是一个已经废弃的协议。如果需要使用 OAuth1，你可以使用 Requests-OAuthlib（https://requests-oauthlib.readthedocs.org/en/latest/），同 requests 一起拉取数据。当通过 API 认证时，确保识别哪一个协议正在被使用。如果使用了错误的协议，在尝试连接时，你会收到错误信息。

首先，需要安装 Python OAuth2：

pip install oauth2

打开一个新文件，导入 oauth2，并且为你的 key 变量赋值：

import oauth2
API_KEY = '5Hqg6JTZ0cC89hUThySd5yZcL'
API_SECRET = 'Ncp1oi5tUPbZF19Vdp8Jp8pNHBBfPdXGFtXqoKd6Cqn87xRj0c'
TOKEN_KEY = '3272304896-ZTGUZZ6QsYKtZqXAVMLaJzR8qjrPW22iiu9ko4w'
TOKEN_SECRET = 'nsNY13aPGWdm2QcgOl0qwqs5bwLBZ1iUVS2OE34QsuR4C'

然后添加函数来创建 OAuth 连接：

def oauth_req(url, key, secret, http_method="GET", post_body="",
              http_headers=None):
    consumer = oauth2.Consumer(key=API_KEY, secret=API_SECRET)             ➊
    token = oauth2.Token(key=key, secret=secret)                           ➋
    client = oauth2.Client(consumer, token)                                ➌
    resp, content = client.request(url, method=http_method,                ➍
                                   body=post_body, headers=http_headers)
    return content                                                         ➎

❶ 创建一个 oauth2 对象的消费者。消费者是 key 的所有者。这行代码给消费者提供 key，这样消费者可以顺利地通过 API 识别。

❷ 将 token 赋值给 oauth2 对象。

❸ 创建客户端，包含消费者和 token。

❹ 使用函数参数 url，通过 OAuth2 客户端执行请求。

❺ 返回从连接接收到的内容。

现在有了一个函数，允许我们连接到 Twitter API。然而，我们需要定义 URL，并且调用函数。搜索 API 文档（https://dev.twitter.com/rest/public/search）告诉我们更多有关想要使用的请求的信息。使用 Web 接口，可以看到，如果搜索#childlabor，最终得到的 URL 是：https://twitter.com/search?q=%23childlabor。文档建议重新格式化 URL，所以最终的 URL 如下：https://api.twitter.com/1.1/search/tweets.json?q=%23childlabor。

之后，可以把这个 URL 作为一个变量，并使用之前定义的变量调用函数：

url = 'https://api.twitter.com/1.1/search/tweets.json?q=%23childlabor'
data = oauth_req(url, TOKEN_KEY, TOKEN_SECRET)

print(data)                                                         ➊

❶ 在最后添加打印语句，这样可以看见输出。

运行脚本时，你应该看到数据打印成一个很长的 JSON 对象。你可能记得 JSON 对象看起来和 Python 字典类似，但是如果使用 print(type(data)) 重新运行脚本，你会发现内容是一个字符串。现在我们可以做以下两件事情中的一件：转化数据为一个字典并开始解析它，或者保存字符串到一个文件，之后再解析。为了继续在脚本中解析数据，在脚本顶部添加 import json。之后，在尾部，使用 json 加载字符串，并且输出它。

data = json.loads(data)
print(type(data))

变量 data 现在会返回一个 Python 字典。如果你想要将数据写入一个文件并且在之后解析它，替换为下面的代码：

with open('tweet_data.json', 'wb') as data_file:
    data_file.write(data)

最后的脚本应该看起来像下面这样：

import oauth2

API_KEY = '5Hqg6JTZ0cC89hUThySd5yZcL'
API_SECRET = 'Ncp1oi5tUPbZF19Vdp8Jp8pNHBBfPdXGFtXqoKd6Cqn87xRj0c'
TOKEN_KEY = '3272304896-ZTGUZZ6QsYKtZqXAVMLaJzR8qjrPW22iiu9ko4w'
TOKEN_SECRET = 'nsNY13aPGWdm2QcgOl0qwqs5bwLBZ1iUVS2OE34QsuR4C'

def oauth_req(url, key, secret, http_method="GET", post_body="",
              http_headers=None):
    consumer = oauth2.Consumer(key=API_KEY, secret=API_SECRET)
    token = oauth2.Token(key=key, secret=secret)
    client = oauth2.Client(consumer, token)
    resp, content = client.request(url, method=http_method,
                                   body=post_body, headers=http_headers)
    return content

url = 'https://api.twitter.com/1.1/search/tweets.json?q=%23popeindc'
data = oauth_req(url, TOKEN_KEY, TOKEN_SECRET)

with open("data/hashchildlabor.json", "w") as data_file:
    data_file.write(data)

从这里开始，你可以查看 3.2 节，来解析数据。

13.3　使用Twitter REST API进行高级数据收集

从 Twitter 拉取单个数据文件并不是非常有用，因为这只返回大约 15 条推文。我们希望执行一连串的查询，这样可以收集尽可能多的关于这一话题的推文。我们会使用另外一个库来做这项工作——Tweepy。Tweepy 可以管理一系列的请求，包括 Twitter 的 OAuth。首先安装 tweepy：

pip install tweepy

在脚本的最开始，导入 tweepy，并且再一次设置你的 key：

import tweepy

API_KEY = '5Hqg6JTZ0cC89hUThySd5yZcL'
API_SECRET = 'Ncp1oi5tUPbZF19Vdp8Jp8pNHBBfPdXGFtXqoKd6Cqn87xRj0c'
TOKEN_KEY = '3272304896-ZTGUZZ6QsYKtZqXAVMLaJzR8qjrPW22iiu9ko4w'
TOKEN_SECRET = 'nsNY13aPGWdm2QcgOl0qwqs5bwLBZ1iUVS2OE34QsuR4C'

之后将你的 API key 和 API secret 传入 tweepy 的 OAuthHandler 对象，这个对象会管理上一个实际提到的 OAuth 协议。之后设置你的访问 token。

auth = tweepy.OAuthHandler(API_KEY, API_SECRET)                    ➊
auth.set_access_token(TOKEN_KEY, TOKEN_SECRET)                     ➋

❶ 创建一个对象，通过 tweepy 来管理 API 认证。

❷ 设置访问 token。

之后，将刚刚创建的认证对象传递给 tweepy.API：

api = tweepy.API(auth)

tweepy.API 对象可以接受不同的参数，这给了你请求数据时控制 tweepy 行为的能力。你可以通过传递参数（像 retry_count=3, retry_delay=5）直接添加重试和延迟。另一个有用的选项是 wait_on_rate_limit，这个选项会直到频率限制解除后再去做下一次请求。 tweepy 文档（http://docs.tweepy.org/en/latest/api.html）中有这些选项的细节和更多信息。

我们想要使用 tweepy.Cursor 创建一个和 Twitter API 的连接。然后将 API 方法（这里是 api.search，http://docs.tweepy.org/en/latest/api.html#API.search）和与其相关的参数传递给指针（cursor）。

query = '#childlabor'                                         ➊
cursor = tweepy.Cursor(api.search, q=query, lang="en")        ➋

❶ 创建 query 变量。

❷ 使用 query 创建 cursor，并且限制其只检索英语。

　尽管 Cursor 并不是很直观，但是这是一个在数据库连接中很常用的编程名词。虽然 API 不是数据库，但类名称 Cursor 可能受 API 类似数据库使用方式的影响而命名。你可以在维基百科（https://en.wikipedia.org/wiki/Cursor_(databases)）上阅读更多关于指针的内容。

根据 tweepy 的文档（http://tweepy.readthedocs.org/en/latest/api.html），cursor 可以返回一个在单个对象级别或单页对象级别上的迭代器。你同样可以定义限制（http://tweepy.readthedocs.io/en/latest/cursor_tutorial.html#limits），来确定 cursor 抓取的页面数或对象数。如果查看 print(dir(cursor))，你会看到这里有 3 个方法：['items', 'iterator', 'pages']。一页返回一串对象，即在你的查询下独立的推文。根据需要，我们会使用页面。

让我们遍历这些页面，并且保存数据。在此之前，需要做以下两件事。

(1) 添加 import json 到脚本的顶端。

(2) 在脚本的相同目录下，创建一个名叫 data 的文件夹。为此，在命令行中运行 mkdir data。

一旦你完成了这两件事情，运行下面的代码来遍历和保存推文：

for page in cursor.pages():                             ➊
    tweets = []                                         ➋
    for item in page:                                   ➌
        tweets.append(item._json)                       ➍

with open('data/hashchildlabor.json', 'wb') as outfile: ➎

❶ 对于每一个 cursor.pages() 返回的页面……

❷ 创建一个空列表来保存推文。

❸ 对于页面中的每一个对象（或推文）……

❹ 抽取 JSON 推文数据，保存到推文列表中。

❺ 打开一个名为 hashchildlabor.json 的文件，保存这些推文。

你会注意到，没有保存太多的推文到文件。每个页面只有 15 个推文，所以我们需要找出一个方法来得到更多的数据。有以下一些选项。

打开一个文件，并且永远不关闭它，或者打开一个文件，在末尾追加信息。这会创建一个非常大的文件。

将每一页保存到自己的文件中（你可以使用时间戳来保证每个文件有不同的文件名）。

在你的数据库中创建一个新的表来保存数据。

创建一个文件是危险的，因为进程在任何时候都可能失败，破坏数据。除非你只是在进行很小规模的数据拉取（例如，1000 条推文），或进行开发测试，否则你应该使用其他选择。

每次都有很多种方法可以将数据保存到新文件中，最普遍的一种方式是使用日期和时间戳（https://docs.python.org/2/library/datetime.html）创建一个文件，或者只是递增一个数字，将其添加到文件名的末尾。

我们会继续添加推文到简单的数据库。为此，使用下面的函数。

def store_tweet(item):
    db = dataset.connect('sqlite:///data_wrangling.db')
    table = db['tweets']                                       ➊
    item_json = item._json.copy()
    for k, v in item_json.items():
        if isinstance(v, dict):                                ➋
            item_json[k] = str(v)
    table.insert(item_json)                                    ➌

❶ 创建或访问一个新表，名为 tweets。

❷ 检查推文中是否含有字典对象。由于 SQLite 并不支持保存 Python 字典，我们需要将其转换为字符串。

❸ 插入合法的 JSON 对象。

我们还需要添加 dataset 到 import 部分的代码中。在之前保存页面的地方，需要添加这个函数的使用。确保遍历每一条推文。最后的脚本应该看起来像下面一样。

import json
import tweepy
import dataset

API_KEY = '5Hqg6JTZ0cC89hUThySd5yZcL'
API_SECRET = 'Ncp1oi5tUPbZF19Vdp8Jp8pNHBBfPdXGFtXqoKd6Cqn87xRj0c'
TOKEN_KEY = '3272304896-ZTGUZZ6QsYKtZqXAVMLaJzR8qjrPW22iiu9ko4w'
TOKEN_SECRET = 'nsNY13aPGWdm2QcgOl0qwqs5bwLBZ1iUVS2OE34QsuR4C'

def store_tweet(item):
    db = dataset.connect('sqlite:///data_wrangling.db')
    table = db['tweets']
    item_json = item._json.copy()
    for k, v in item_json.items():
        if isinstance(v, dict):
            item_json[k] = str(v)
    table.insert(item_json)

auth = tweepy.OAuthHandler(API_KEY, API_SECRET)
auth.set_access_token(TOKEN_KEY, TOKEN_SECRET)

api = tweepy.API(auth)

query = '#childlabor'
cursor = tweepy.Cursor(api.search, q=query, lang="en")

for page in cursor.pages():
    for item in page:
        store_tweet(item)

13.4　使用Twitter流式API进行高级数据收集

本章前文中提到过，有两种类型的 Twitter API 可以使用：REST API 和流式 API。

流式 API 同 REST API 相比有什么差别呢？下面进行了简单的概括。

数据是实时的，而 REST API 只返回已经发布一段时间的推文。

流式 API 缺乏普遍性，但是在未来，随着更多实时数据的生成和曝光，其可用性会变得越来越高。

因为最新的数据很有趣，所以很多人对这部分数据很感兴趣，这意味着你可以在网络上找到很多资源和帮助。

让我们创建一个脚本来收集来自流式 API 的数据。这个脚本会使用在这一章覆盖到的所有知识。首先编写最基础的部分——输入值和 key 值。

from tweepy.streaming import StreamListener         ➊
from tweepy import OAuthHandler, Stream             ➋

API_KEY = '5Hqg6JTZ0cC89hUThySd5yZcL'
API_SECRET = 'Ncp1oi5tUPbZF19Vdp8Jp8pNHBBfPdXGFtXqoKd6Cqn87xRj0c'
TOKEN_KEY = '3272304896-ZTGUZZ6QsYKtZqXAVMLaJzR8qjrPW22iiu9ko4w'
TOKEN_SECRET = 'nsNY13aPGWdm2QcgOl0qwqs5bwLBZ1iUVS2OE34QsuR4C'

❶ 导入 StreamListener，这会创建一个流式会话，并且监听信息。

❷ 导入之前使用过的 OAuthHandler，以及 Stream，后者是真正处理 Twitter 的流信息的类。

这个脚本中的 import 语句和上一个脚本中的有少许不同。这两种方式都是合法的，只是个人偏好问题。下面是这两种方式的一个快速比较。

方式 1

import tweepy
...
auth = tweepy.OAuthHandler(API_KEY, API_SECRET)

方式 2

from tweepy import OAuthHandler
...
auth = OAuthHandler(API_KEY, API_SECRET)

通常情况下，脚本中没有频繁使用库时使用第一种方式。在你有一长串代码，想要更清晰一些的时候，也适合使用第一种方式。然而，当库使用得非常多时，要将其都打印出来会变得令人厌烦；同样，如果这个库是脚本的基础，从这个库导入的模块或类对人们来说应该很明显。

现在，要创建导入的 StreamListener 类的子类（在第 12 章学习的概念），以覆写其中的 on_data 方法。为此，在新的类 Listener 中重新定义了这个函数。当有数据时，我们想要在终端中看到它们，所以添加 print 语句到函数中。

class Listener(StreamListener):                       ➊

    def on_data(self, data):                          ➋
        print data                                    ➌
        return True                                   ➍

❶ 创建 StreamListener 的子类。

❷ 定义 on_data 方法。

❸ 输出推文。

❹ 返回 True。StreamListener 有 on_data 方法，同样返回 True。因为我们创建了子类并重新定义了这个函数，所以必须在子类方法中重复返回这个值。

接下来，添加你的认证处理逻辑：

auth = OAuthHandler(API_KEY, API_SECRET)
auth.set_access_token(TOKEN_KEY, TOKEN_SECRET)

最后，将 Listener 和 auth 传入到 Stream 中，开始使用搜索词过滤。在这个案例中，我们查看 child labor（童工），因为它相对于 #childlabor 更加普遍。

stream = Stream(auth, Listener())            ➊
stream.filter(track=['child labor'])         ➋

❶ 将 auth 和 Listener 作为参数传递，创建一个流。

❷ 过滤流，只返回有 child 和 labor 存在的条目。

最后的脚本如下：

from tweepy.streaming import StreamListener
from tweepy import OAuthHandler, Stream

API_KEY = '5Hqg6JTZ0cC89hUThySd5yZcL'
API_SECRET = 'Ncp1oi5tUPbZF19Vdp8Jp8pNHBBfPdXGFtXqoKd6Cqn87xRj0c'
TOKEN_KEY = '3272304896-ZTGUZZ6QsYKtZqXAVMLaJzR8qjrPW22iiu9ko4w'
TOKEN_SECRET = 'nsNY13aPGWdm2QcgOl0qwqs5bwLBZ1iUVS2OE34QsuR4C'

class Listener(StreamListener):
    def on_data(self, data):
        print data
        return True

auth = OAuthHandler(API_KEY, API_SECRET)
auth.set_access_token(TOKEN_KEY, TOKEN_SECRET)

stream = Stream(auth, Listener())
stream.filter(track=['child labor'])

下面，你需要添加代码，通过 on_data 方法，像本章之前那样将推文保存到数据库、文件或其他的存储工具。

13.5　小结

能够同应用编程接口进行交互是数据处理中很重要的一部分。在这一章中，我们学习了一些 API 基础知识（见表 13-2 中的总结），并且处理了来自 Twitter API 的数据。

表13-2：API概念

概念

功能

REST API（与流式 API 相比）

返回数据，并且暴露静态的节点

流式 API（与 REST API 相比）

返回查询相关的实时数据

OAuth 和 OAuth2

给定一系列 key 值和 token 的认证

分级数据卷

数据频率限制 / 可用性的不同级别；一些需要付费

key 和 token

标识用户和应用的唯一 ID 和密钥

我们已经复用了许多已经知道的 Python 概念，并且在这一章中学习了一些新的 Python 概念。首先是 tweepy 的使用，一个处理同 Twitter API 交互的库。你还学习了有关认证和 OAuth 协议的知识。

作为同 API 交互的拓展，第 14 章会介绍你不在场时运行 API 脚本的知识。

第 14 章　自动化和规模化

你已经从 API 和网站爬取了大量的数据，也已经清洗和组织了数据，并且运行了统计学分析，生成了可视化报告。现在是时候让 Python 大展身手，自动化你的数据处理了。在这一章中，我们会介绍如何自动化数据分析、收集和发布。我们会学习如何创建合适的日志和警报，这样可以充分地自动化脚本，得到成功、失败以及工作中碰到的任何问题的通知。

我们还会学习使用 Python 库规模化自动化程序，帮助执行多个任务，并且监控它们的成功和失败。我们会分析一些库和辅助工具，充分地在云端规模化数据。

Python 提供了大量的自动化和规模化选择。有一些简单直接的任务可以在几乎所有的机器上自动化执行，而不需要太多的设置，同时也有一些更大型、更复杂的方式来实现自动化。我们会涉及这两者的示例，同时也会讲解作为数据处理者如何规模化数据自动化。

14.1　为什么要自动化

自动化提供了一种轻松运行脚本的方式，而不需要在本地机器上执行脚本，甚至不需要你醒着！自动化的能力意味着你可以把时间花在其他的思维密集型项目上。如果拥有一个完备的脚本来执行数据清洗，你就可以专注于研究数据，写出更好的报告。

下面是一些自动化可以帮上忙的示例。

每周二输出一些新的分析结果；你要编制一份报告，并将其发送给相关方。

其他部门或同事需要能够在没有你的指导和支持下运行报告工具或清洗工具。

你需要每周进行一次数据下载、清洗和发送。

每次用户请求新报告，报告脚本需要运行，并且在报告生成后通知用户。

你需要每周清洗一次数据库里错误的数据，并且将其备份到其他地方。

这其中的每一个问题都有无数的解决方案，但是有一件事是确定的：它们是非常适合自动化的任务。它们的输出和步骤非常清晰。它们有一个有限但特定的听众群体。它们有确定的时间或事件来触发行动。此外，在特定的环境下它们都是可以用脚本和程序解决的事情。

当任务清晰、定义完整，并且输出非常容易确定的时候，自动化是最容易的。不管怎样，即使输出并不总是很容易测试或预测，自动化也可以帮助完成任务的一部分，把剩余的留给你（或其他人）更细致地研究和分析。你可以想象这里的自动化类似于你在生活中自动化其他的事情。你可能有一个最喜欢的已保存的比萨订单，或者一个邮件的自动回复。如果一个任务输出明确并定期发生，那么这是一个很适合自动化的任务。

但是什么时候不应该自动化呢？以下条件预示着该任务不是一个好的自动化选择。

任务很少发生，并且非常复杂，自己做更好（例如，填写报税单）。

任务的成功输出很难确定（例如，小组讨论、社会研究或调查）。

任务需要与人交互来确定合适的完成方式（例如，交通导航、诗歌翻译）。

任务成功是当务之急。

其中的一些例子，尤其是需要人工输入的例子，适合一定程度的自动化。有些任务可以通过让机器寻找建议来做部分自动化，之后再确定这些建议是对还是错（使用人工反馈的机器学习）。其他的任务，比如少见又复杂的任务，或者是非常重要的商业任务，随着对任务的熟悉，可能最后会实现自动化或部分自动化。但是你可以看到整体的逻辑来确定什么时候自动化更好，什么时候这不是一个好的想法。

如果你不确定自动化是否适合你，可以尝试自动化一些定期运行的小任务，看它如何工作。一段时间后，你很可能会发现更合适的解决方案，而且自动化一件事的经验会使你在未来自动化更多的事情更加容易。

14.2　自动化步骤

因为自动化从一个清晰简单的目标开始，所以自动化的步骤应该同样清晰和简单。文档化下面的问题对开始自动化特别有帮助（通过列表、白板、图纸或故事板）。

任务何时开始？

这个任务是否有时间限制或最大长度？如果有的话，什么时候结束？

对这个任务来说，有哪些必需的输入？

对这个任务来说，什么是成功或部分成功？

如果任务失败，应该发生什么 ?

任务产生或提供什么？面向谁？以何种方式？

在任务结束后应该发生什么（如果有的话）？

　如果能够回答其中 5 个或更多的问题，你就可以开始自动化。如果不能，在你开始自动化之前，需要做更多的研究和说明。如果要自动化之前从来没有做过的事情，或者不是经常做的事情，在执行任务时尝试文档化它，然后确定你是否能够回答上述问题。如果你的项目太大或太模糊，尝试将它分解为小任务，并且自动化其中的几个。或许你的任务包括一个报告，需要下载两个数据集，进行清洗和分析，然后根据输出的不同，发送结果到不同的群组。你可以分解这项任务为一些子任务，自动化每一个步骤。如果其中任何一个子任务失败了，停止执行下面的任务，警报负责维护脚本的人员，这样可以研究它，并且在 bug 或问题解决后重新执行。

所以，自动化的基本步骤如下（注意，根据任务类型的不同，步骤会有变化）。

(1) 定义你的问题集合，将其分解为更小的工作块。

(2) 精确地描述每一个子任务的输入是什么、输入做什么以及需要什么来确认任务完成。

(3) 确定哪里可以得到输入，以及何时运行任务。

(4) 开始编码你的任务，用真实或样例数据测试。

(5) 整理你的任务和脚本，添加文档。

(6) 添加日志，聚焦于调试错误和记录成功的任务。

(7) 提交你的代码到仓库中，手动测试它。按照需要做出修改。

(8) 通过将手动任务替换为自动化任务，为自动化准备好脚本。

(9) 在任务开始自动化后，关注日志和警报。修正所有的错误和 bug。更新你的测试和文档。

(10) 为日志中的错误检查频率制订一个长期计划。

自动化的第一个步骤永远是更好地定义你的任务和子任务，让它们足够小，这样可以轻易地完成它们，且它们的失败或成功是确定的。

后面的几个步骤同我们在全书中使用的处理过程类似。你应该确定怎样开始用 Python 解决问题。搜索库或者工具，帮助你修复问题或完成请求，然后开始编码。一旦脚本开始工作，你会想要使用一些不同的可用数据集或输入测试它。在成功的测试之后，简化和文档化它。将其上传到一个远程仓库（Bitbucket 或 GitHub）中，这样随着时间的推移你可以记录修改和额外的信息。

　一旦你完成了脚本，首先手动运行它（而不是通过自动化的方式）。当新的数据就位，或者到了运行它的时候，手动执行，持续观察程序的输出。可能会有隐藏的错误存在，也可能你需要添加额外的日志和调试信息。

根据满足需求的自动化类型，你可能创建了一个简单的定时任务，脚本按特定的时间间隔执行（本章后文会介绍关于定时任务的知识）。你可能需要稍微修改脚本，让它可以通过使用参数变量、数据库或者系统上特定的文件自主运行。当它运行时，你可能会添加它到任务队列来管理它。无论哪一种自动化合适，你的工作都还没有结束。

　当你的脚本第一次自动化时，在它每次运行时花时间来检查它是很必要的。查看日志并监控进程。你可能会找到小 bug，然后修改它们。再一次更新所有必要的日志和文档。

在大约经过了 5 次成功或日志记录下来的失败后，你可以减少人工检查的次数。然而，在每月或者每季度使用 grep（http://www.thegeekstuff.com/2009/03/15-practical-unix-grep-command-examples/）查看日志，看一下发生了什么，仍然是一个很好的主意。如果你正在使用一个日志聚合器，你完全可以自动化这一步骤，并且让这一任务发送错误和警报报告。

自动化不是小进程，但是早早投入时间和精力是值得的。一个运行良好的自动化任务集合需要一些时间来完成，但是结果通常比那些需要从始至终关注、修改和监控的一次性脚本要好。密切关注并花一些时间正确地自动化你的脚本。之后才真正投入到手头接下来的工作当中，而不是一直将你的一部分工作与监控和管理难以驾驭的任务相关联。

14.3　什么会出错

在你的自动化程序中，有很多事情可能会出问题。其中一些非常容易更正和解释，然而其他问题更加模糊，可能根本不会有一个真正的修正。自动化中的重要一课是搞清楚哪些类型的错误和问题值得花时间和精力修复，哪些问题最好使用另外的方式解决。

以在第 12 章讨论过的错误类型为例：网络爬取中的网络错误。如果碰到了重大网络错误，你只有几个好的选择。你可以改变运行任务的机器，看是否会有性能提升（这可能会带来经济和时间上的花销，取决于你的设置）。你可以找到网络提供商，寻求支持。你可以在不同的时间运行任务，看输出是否会有不同。你可以预测问题的发生，依据预测构建脚本（即在需求之外运行脚本，预测失败百分比）。

这里有很多通过自动化运行任务时可能遇到的错误：

数据库连接错误导致丢失或损坏数据；

脚本漏洞和错误，任务没有正确完成；

来自网站或 API 的超时错误或者过多的请求错误；

边界问题，报告的数据或一部分没有保证一致，导致脚本错误；

服务器负载问题或其他的硬件问题；

时间不当，竞争条件（https://en.wikipedia.org/wiki/Race_condition，如果脚本依赖于之前其他任务的完成，竞争条件能够使数据无效）。

　当然还有很多潜在的你无法预料的问题。你工作的团队越大，糟糕的文档、糟糕的理解、糟糕的团队沟通伤害自动化程序的可能性就越大。你无法预防每一个错误，但是可以尝试提供最好的团队沟通和文档。尽管如此，你同样需要接受有些时候自动化程序会失败。

为了为最后的失败准备，你会希望在问题发生时收到警报。你应该确定多少比例的错误是可接受的。不是每一种服务在 100% 的时间里都表现良好（因此存在状态页面）；然而，我们可以追求完美，并确定自动化值得付出多少时间和努力。

根据你的自动化程序和它的弱点，这里有一些方法来应对这些问题。下面是一些构建弹性自动化系统的方式。

以特定的时间间隔重复失败的任务。

确保你的代码有很多的 try...except 代码块，让它能够处理错误。

在处理到机器、数据库或 API 的连接的代码周围，构建特殊的异常代码块。

定期维护和监控你为自动化使用的机器。

使用测试数据定期测试你的任务和自动化程序，确保它们正常执行。

注意脚本中出现的依赖、竞争条件和 API 规则，根据这些知识编写代码。

使用类似 requests 和 multiprocessing 的库，让困难的问题变得更简单，并尝试理解使脚本运行困难的问题所在。

我们会使用这其中的一些技术和想法，研究如何最好地监控和自动化脚本。现在，让我们讨论一下自动化中使用的工具，使数据处理更加容易和简单，并给出一些在哪里以及如何使用这些工具的建议。

14.4　在哪里自动化

根据脚本需求的不同，决定在哪里运行它是重要的第一步。无论脚本在哪里第一次执行，你都可以将它移到别的地方，但是这可能需要重写一些代码。在一开始，你可能需要它在本地运行。在本地运行脚本或任务就是在你自己的计算机上运行。

远程执行意味着在另外的机器上运行，可能是其他地方的服务器。一旦脚本运行成功并且测试良好，你会想要移动它到远程。如果你管理或拥有服务器，或者为一个有服务器的组织工作，这样迁移脚本到服务器上会相对简单。这允许你在自己的机器（笔记本电脑或台式计算机）上工作，并且不用担心在什么时间关闭和打开它。在远程运行脚本也意味着对你的网络服务提供商来说，你不是独立的。

如果无法访问服务器，但是有一台旧的不再使用的计算机或笔记本电脑，在必要的情况下，你可以将其变成服务器。如果它运行着老旧的操作系统，你可以将它升级，以便在其上运行 Python，或者可以彻底重来，安装 Linux。

　使用家庭计算机作为远程设备意味着它需要总是运行着，并且连接到你的家庭互联网中。如果你还想安装一个之前没有使用过的操作系统，像 Linux，这是一个学习新操作系统的简单方式，并且可以帮助你过渡到管理自己的服务器。如果你刚刚开始使用 Linux，建议选择一个流行的分发版本，例如 Ubuntu（http://www.makeuseof.com/tag/ubuntu-an-absolute-beginners-guide/）或 LinuxMint（http://linuxmint.com/）。

如果你想要管理自己的服务器，但是刚起步，不用惊慌！即使你没有管理过或帮助他人管理过服务器，云服务提供商之间日益激烈的竞争，让服务器管理越来越容易。云服务提供商让你不需要了解太多的技术知识，便可以使用新机器，运行自己的服务器。服务商 DigitalOcean 有很多关于开始使用云服务的很棒的文章，包括如何创建第一台服务器（https://www.digitalocean.com/community/tutorials/how-to-create-your-first-digitalocean-droplet-virtual-server）和运行服务器（https://www.digitalocean.com/help/getting-started/setting-up-your-server/）。

无论在本地还是远程运行脚本，都有大量的工具可以用来很好地监测和更新计算机或服务器。你希望能够非常简单地管理和更新脚本和任务，并且可以定期运行脚本以完成任务。最后，你还希望可以轻松地配置和文档化它们。在下面的几节中，我们会介绍所有这些主题，从可以让你的脚本更加自动友好的 Python 工具开始。

14.5　自动化的特殊工具

Python 提供了许多用于自动化的特殊工具。我们会查看一些使用 Python 管理自动化程序的方法，同时也会使用其他的机器和服务器完成任务。我们还会讨论怎样使用一些内置的 Python 工具管理脚本的输入，自动化看起来需要人工输入的事情。

14.5.1　使用本地文件、参数及配置文件

根据脚本的工作情况，你可能需要存储在数据库或 API 之外的参数或输入。当有一个简单的输入或输出时，你可以使用本地文件和参数来传递数据。

本地文件

使用本地文件作为输入和输出时，你需要确保脚本可以每天运行在相同的机器上，或者可以简单地与输入和输出文件一起迁移。随着脚本的开发，很可能需要同时移动并改变脚本和所用文件。

我们之前使用过本地文件，但是让我们看一下如何从更加函数式的代码的角度来使用它。这段代码给了你使用标准数据类型打开和写文件的能力，并且根据脚本的需求，其复用性和扩展性很好。

from csv import reader, writer

def read_local_file(file_name):
    if '.csv' in file_name: ➊
        rdr = reader(open(file_name, 'rb'))
        return rdr
    return open(file_name, 'rb') ➋

def write_local_file(file_name, data):
    with open(file_name, 'wb') as open_file: ➌
        if type(data) is list: ➍
            wr = writer(open_file)
            for line in data:
                wr.writerow(line)
        else:
            open_file.write(data) ➎

❶ 这行代码测试文件是否适合使用 csv 模块打开。如果文件以 .csv 结尾，之后可以使用 CSV 读取器来打开它。

❷ 如果没有返回 CSV 读取器，这段代码返回打开的文件。如果想要根据文件扩展类型，构建一系列不同的方式来打开并解析文件，也可以做到（例如，为 JSON 文件使用 json 模块，或者为 PDF 文件使用 pdfminer）。

❸ 这段代码使用 with...as 返回 open 函数的输出，将其赋值给 open_file 变量。当缩进的代码块结束时，Python 会自动地关闭文件。

❹ 如果正在处理列表，这一行代码使用 CSV 输出器输出每一行列表对象为一行数据。如果有字典，可以使用 DictWriter 类。

❺ 我们想要一个好的备选计划，以防数据不是列表。因此，要将原始数据写到文件中。此外，根据数据类型的不同，还可以写不同的代码。

看一个例子，我们需要文件夹中最近使用的文件，这在按照日期解析日志文件，或者查看最近的网络爬虫运行结果时很有用处。

import os

def get_latest(folder):
    files = [os.path.join(folder, f) for f in os.listdir(folder)] ➊
    files.sort(key=lambda x: os.path.getmtime(x), reverse=True) ➋
    return files[0] ➌

❶ 使用 Python 内置的 os 模块列出每一个文件（listdir 方法），之后使用 path 模块的 join 方法创建一个长字符串，表示一个完整的文件路径。这是获取文件夹中所有文件的一种简单方式，只需要传递一个字符串（文件夹路径）。

❷ 通过最后修改时间来为文件排序。因为 files 是一个列表，所以我们可以调用 sort 方法，并且给它一个键作为排序的依据。这段代码传递完整的文件路径给 getmtime 函数，这是 os 模块“获取修改时间”的方法。reverse 参数确保最近的文件出现在列表的顶部。

❸ 返回最近的文件。

这段代码返回最近使用的文件夹，但是如果想要返回整个以最近使用文件开始的文件列表，我们可以直接修改代码，不返回第一个索引，而是返回整个列表或一个切片。

　os 库有很多强有力的工具来查找、修改和改变本地文件（或者服务器的本地文件）。在 Stack Overflow 上直接搜索会返回很多答案，例如如何找到过去 7 天内唯一修改的文件，或者过去一个月中唯一修改的 .csv 文件，等等。使用本地文件，特别是当你需要的数据已经存在时（或者很容易使用 wget 拿到），是一个很棒的简化自动化程序的方式。

配置文件

为敏感信息创建本地配置文件是必需的。正像 Twelve-Factor 应用（http://12factor.net/config）中声称的那样，在代码主干之外保存配置（例如密码、登录名、邮件地址和其他的敏感信息）是成为一个好的开发者的一部分。如果你连接到数据库，发送一封邮件，使用 API 或保存支付信息，这些敏感数据应该保存在一个配置文件中。

通常情况下，我们在仓库内的一个独立文件夹存储配置文件（例如，config/）。仓库中的所有代码都会访问这些文件，但是通过使用 .gitignore 文件（见 8.4 节），可以保持这些配置文件在版本控制之外。如果有其他开发者或服务器需要这些文件，你需要手动复制它们。

　建议在仓库的 README.md 中使用一个小节，介绍特殊配置文件的位置以及如何处理它们，这样新用户和合作者知道向谁索要合适的文件。

使用文件夹而不是文件允许你在运行脚本的不同机器或环境中有不同的配置。你可能想要在测试环境中有一个配置文件，使用测试的 API key，并且在生产环境使用生产环境的配置文件。你可能想要根据脚本使用的机器使用多个数据库。你可以使用一个 .cfg 文件保存这些特殊信息，如下。

# 示例配置文件
[address] ➊
name = foo ➋
email = myemail@bar.com
postalcode = 10177
street = Schlangestr. 4
city = Berlin
telephone = 015745738292950383

[auth_login]
user = test@mysite.com
pass = goodpassword

[db]
name = my_awesome_db
user = script_user
password = 7CH+89053FJKwjker)
host = my.host.io

[email]
user = script.email@gmail.com
password = 788Fksjelwi&

❶ 每一小节用一对方括号和放置于方括号中间的一个容易阅读的字符串表示。

❷ 每一行包含一个 key = value 的序对。ConfigParser 将这些序对作为字符串解释。值可以包含任何的字符，包括特殊字符，但是键必需遵循 PEP-8 易于阅读的语法和结构。

在配置中有小节、键和值，这允许我们使用小节的名字和键访问配置的值。这增加了 Python 脚本的清晰度，而不会变得晦涩。一旦你有了一个类似前面示例中创建的配置文件，使用 Python 解析以及在脚本中使用和自动化就很简单了。下面是一个示例。

import ConfigParser
from some_api import get_client ➊

def get_config(env):
    config = ConfigParser.ConfigParser() ➋
    if env == 'PROD':
        return config.read(['config/production.cfg']) ➌
    elif env == 'TEST':
        return config.read(['config/test.cfg'])
    return config.read(['config/development.cfg']) ➍

def api_login():
    config = get_config('PROD') ➎
    my_client = get_client(config.get('api_login', 'user'),
                           config.get('api_login', 'auth_key')) ➏
    return my_client

❶ 这是一个导入 API 客户端钩子的示例。

❷ 这段代码通过调用 ConfigParser 类初始化一个配置对象。现在这是一个空的配置对象。

❸ 这行代码调用配置解析器对象的 read 方法，并且传递一个配置文件的列表给这个函数。这里，在项目根目录下名为 config 的文件夹下保存所有的文件。

❹ 如果传递的环境变量不匹配生产环境或测试环境，则永远返回开发配置。在配置代码中做类似的判断是一个很好的做法，以防止定义环境变量失败的情况。

❺ 我们假设示例需要生产环境的 API，所以这行代码试图获取 PROD 配置。你同样可以保存这些不同环境的类型到 bash 环境中，并且使用内置的 os.environ 方法（https://docs.python.org/2/library/os.html#process-parameters）读取它们。

❻ 这行代码调用小节名称和键名称访问存储在配置中的值。这会将值作为字符串返回，所以如果你需要整数或者其他的类型，你需要转换它们。

内置的 ConfigParser 库使我们能够简单地访问配置文件中存储的小节、键和值。如果希望在不同的文件中存储不同的信息，并且为每个特定的脚本解析一个文件列表，你的代码应该类似这样：

config = ConfigParser.ConfigParser()
config.read(['config/email.cfg', 'config/database.cfg', 'config/staging.cfg'])

根据需求，由你组织代码和配置。访问配置值的语法很简单，只是使用配置中小节的名称（即 [section_name]）和键的名称。所以，类似下面的配置文件：

[email]
user = test@mydomain.org
pass = my_super_password

可以通过下面的方式访问：

email_addy = config.get('email', 'user')
email_pass = config.get('email', 'pass')

　配置文件是一个将所有的敏感信息保存到一个地方的简单的工具。如果你更倾向于使用 .yml 或其他扩展类型的文件，Python 也有这些文件类型的读取器。确保使用某种手段将认证、敏感信息同代码分开存储。

命令行参数

Python 让我们能够在自动化程序中传递命令行参数。这些参数传递关于脚本应该如何运行的信息。举个例子，如果需要脚本知道我们希望它使用开发配置运行，可以这样做：

python my_script.py DEV

我们正在使用相同的语法在命令行中运行一个文件，调用 python，之后是脚本名称，然后在这一行的最后添加 DEV。怎样才能使用 Python 解析额外的参数呢？让我们编写下面的代码：

from import_config import get_config
import sys

def main(env):
    config = get_config(env)
    print config

if __name__ == '__main__':
    if len(sys.argv) > 1: ➊
        env = sys.argv(1) ➋
    else:
        env = 'TEST'
    main(env) ➌

❶ 内置的 sys 模块，帮助完成系统任务，包括解析命令行参数。如果命令行参数列表的长度大于 1，这里存在额外的参数。第一个参数永远保存着脚本的名称（所以如果参数长度为 1，脚本名称是唯一的参数）。

❷ 为了得到参数的值，传递参数的索引到 sys 模块的 argv 方法。这行代码设置 env 为参数的值。记住，argv 方法的索引 0 永远是 Python 的脚本名称，所以从索引 1 开始解析参数。

❸ 这行代码使用解析后的参数，根据命令行参数修改代码。

　如果想要解析多个额外变量，可以测试参数的长度，来确保我们有足够的参数，之后再继续解析。你可以将任何数量的参数组合到一个字符串中，但是我们建议保持在 4 个以下。如果你需要 4 个以上的参数，考虑编写一些逻辑到脚本中（例如，在星期二只执行测试，所以如果今天是星期二，使用测试部分的代码，等等）。

参数变量在你需要重新使用相同的代码执行不同的任务或者在不同的环境下运行时很有用。或许你有一个脚本来收集或分析数据，并且希望能够切换使用的环境。你或许会像下面这样运行脚本：

python my_script.py DEV ANALYSIS
python my_script.py PROD COLLECTION

或者你可能有一个脚本需要同最近更新的文件夹进行交互，抓取最新的日志——比如从多个地方抓取日志：

python my_script.py DEV /var/log/apache2/
python my_script.py PROD /var/log/nginx/

有了命令行参数，参数变量的简单变化可以创建一个可移植和健壮的自动化程序。不是每一个脚本都需要使用这些额外的变量，但是有这样一个内置到标准库中的解决方案是很棒的，它同时提供了灵活性。

除了这些相当简单直接的方式来解析数据，给脚本额外的信息，你也可以使用更加复杂和分布式的方式，比如基于云计算的数据和数据存储。我们会在下面查看这部分内容。

14.5.2　在数据处理中使用云

云这个名词通常用来代表一个资源共享池，例如服务器。有许多公司提供云服务——亚马逊网络服务（AWS），是最著名的云服务提供商之一。

　云这个词经常被过度使用。如果你正在云服务器上运行代码，最好说“我正在一个服务器上执行它”，而不是“我在云上执行它”。

什么时候适合使用云？如果数据太大，不能在自己的计算机上执行，或者程序需要很长的时间执行，云都是一个很好的处理方式。将大多数想要自动化的任务放到云上执行，这样你不用担心电脑打开或关闭时脚本是否在运行。

如果选择 AWS，第一次登录后会看到许多不同的服务选择。在数据处理中你只需要几种服务（见表 14-1）。

表14-1：AWS云服务

服务

在数据处理中的目的

简单存储服务（S3）

一个简单的文件存储服务，用来备份数据文件（JSON、XML 等）

弹性计算（EC2）

一个按需分配的服务器。这是运行脚本的地方

弹性 MapReduce（EMR）

通过一个托管的 Hadoop 框架提供分布式的数据处理进程

这些是你需要熟悉的基本的 AWS 服务。还有各种各样的竞争者，包括 IBM 的 Bluemix 和沃特森开发者云（让你得以使用各种大型数据平台，包括沃特森的逻辑和自然语言处理能力）。你还可以使用 DigitalOcean 或 Rackspace，它们提供了廉价的云资源。

无论使用什么，你都需要在云服务器上部署代码。为此，我们建议使用 Git（https://git-scm.com）。

使用Git部署Python

如果想让自动化程序运行在本地机器之外的地方，你需要部署 Python 脚本。我们会介绍几种简单的方式，然后介绍一些稍微复杂的方式。

　版本控制让你的团队在同一个仓库上并行工作，不会在每个人之间产生问题。Git 允许创建不同的分支，这样你或者团队中的其他人可以在特定的需求集合上工作，或者各自完成新的集成，然后将它们合并回代码主干，而不会丢失任何核心功能。它还能确保每个人有最新的代码（包括服务器和远程机器）。

部署 Python 最简单和直观的方式是使用 Git 把仓库置于版本控制之下，用 Git 部署钩子将代码移到远程机器上。首先，需要安装 Git（https://git-scm.com/book/en/v2/Getting-Started-Installing-Git）。

如果你是 Git 新手，建议你参加 GitHub 上的 Code School 入门教程（https://try.github.io/levels/1/challenges/1）或者浏览 Atlassian 上的 Git 入门教程（https://www.atlassian.com/git/tutorials/）。上手 Git 相当简单，你会很快掌握常用命令。如果你独自在仓库上工作，不用过于担心拉取远程变化的问题，但是设置一个清晰的工作日程总是好的。

一旦 Git 安装完成，在项目代码文件夹下运行下面这些命令。

git init . ➊
git add my_script.py ➋
git commit -a ➌

❶ 初始化当前工作目录为 Git 仓库的根目录。

❷ 添加 my_script.py 到仓库中。使用来自仓库的文件名或文件夹名称——不是配置文件！

❸ 将这些改变连同任何其他运行改变（-a）提交到仓库。

收到提示后，编写一个提交信息，简短地解释你做出的改变，这段解释应该明确并且清晰。你可能在之后需要找到哪个提交对应代码中的哪个改变。如果信息始终编写得很清晰，这会帮助你搜索和找到这些提交。这同样会帮助团队中的其他人或者合作者理解你的代码和提交。

　习惯于使用 git fetch 拉取远程变化，或使用 git pull --rebase 命令通过新的提交更新本地仓库。之后，在代码上工作，提交工作，推送提交到活跃的分支。当适合合并分支到主干时，你可以发送一个拉取请求（https://help.github.com/articles/using-pull-requests/），让其他人检查合并的代码，之后直接合并到主干。不要忘记删除没有用处的老的分支。

创建一个 .gitignore 文件是必需的，在这个文件中列出所有想让 Git 在推送 / 拉取时忽略的文件模式，正像在 8.4 节附注栏“Git 和 .gitignore”中讨论的那样。你可以在每个文件夹下使用一个文件，也可以只在仓库的基目录下使用一个。大多数的 Python .gitignore 文件类似于这样：

*.pyc
*.csv
*.log
config/*

这个文件会阻止仓库存储编译过的 Python 文件、CSV 文件、日志文件和配置文件。根据仓库文件夹中存在的其他文件类型，你可能想要添加更多的模式。

你可以在一些站点上挂载仓库。GitHub（https://github.com/）提供了免费的公开仓库，但是没有私有仓库。如果你需要代码是私有的，Bitbucket（https://bitbucket.org/）有免费的私有仓库。如果你已经开始在本地使用 Git，推送已有的 Git 仓库到 GitHub（https://help.github.com/articles/set-up-git/）或 Bitbucket（http://bit.ly/create_bitbucket_repo）是很简单的。

一旦创建了仓库，使用 Git 设置远程端点（服务器或服务器集群，http://git-scm.com/docs/git-remote）很简单。如果你正在部署一个使用 ssh 访问的目录，下面是一个示例。

git remote add deploy ssh://user@342.165.22.33/home/user/my_script

在推送代码到服务器之前，你需要以一些命令设置接收端的文件夹。在计划进行开发的服务器文件夹下运行这些命令：

git init .
git config core.worktree `pwd`
git config receive.denycurrentbranch ignore

这里初始化了一个空的仓库来从本地机器发送代码，同时定义了一些简单的配置，这样 Git 知道它是一个远程端点。你还需要创建一个推送—接收钩子。通过在刚刚初始化的文件夹 .git/hooks 下创建一个名为 post-receive 的可执行文件（通过许可），你可以创建一个钩子。这个文件会在部署端点接收到任何的 Git 推送后执行。它应该包含你需要在每一次推送后执行的所有任务，例如同步数据库、清理缓存或者重启任何的进程。至少，它会需要更新端点。

一个简单的 .git/hooks/post-receive 文件看起来类似于这样：

#!/bin/sh
git checkout -f
git reset --hard

这会重置所有本地的变化（在远程机器上）并且更新代码。

　你需要在本地机器上做出所有的修改，测试它们，之后推送它们到部署端点。从一开始就使用这种方式是好的习惯。通过这种方式，所有代码都在版本控制之下，你可以确保没有由于直接在服务器上修改代码而导致的断断续续的 bug 或错误。

一旦远程端点设置完成，你可以直接在本地仓库运行下面的命令，使用所有最新的提交更新服务器上的代码：

git push deploy master

这样做是一个非常棒的管理仓库和服务器或远程机器的方式；很容易使用和设置，也让迁移（如果需要的话）变得直观。

如果你刚开始接触部署和版本控制，建议你从 Git 开始，熟悉它之后再转而使用更复杂的部署选择，比如 Fabric（http://www.fabfile.org/）。在本章的后面，我们会介绍一些大规模的自动化工具，用于在多个服务器之间部署和管理代码。

14.5.3　使用并行处理

并行处理对脚本自动化来说是一个很棒的工具，让你可以在一个脚本中运行多个并发进程。如果脚本需要多个进程，Python 内置的 multiprocessing 库会成为你自动化的得力工具。如果你有一系列的任务需要并行执行，或者通过并行化可以加速执行任务，多重处理（multiprocessing）是合适的工具。

如何使用 multiprocessing 呢？下面是一个简单的示例：

from multiprocessing import Process, Manager ➊
import requests

ALL_URLS = ['google.com', 'bing.com', 'yahoo.com',
            'twitter.com', 'facebook.com', 'github.com',
            'python.org', 'myreallyneatsiteyoushouldread.com']

def is_up_or_not(url, is_up, lock): ➋
    resp = requests.get('http://www.isup.me/%s' % url) ➌
    if 'is up.' in resp.content: ➍
        is_up.append(url)
    else:
        with lock: ➎
            print 'HOLY CRAP %s is down!!!!!' % url

def get_procs(is_up, lock): ➏
    procs = []
    for url in ALL_URLS:
        procs.append(Process(target=is_up_or_not,
                             args=(url, is_up, lock))) ➐
    return procs

def main():
    manager = Manager() ➑
    is_up = manager.list() ➒
    lock = manager.Lock() ➓
    for p in get_procs(is_up, lock):
        p.start()
        p.join()
    print is_up

if __name__ == '__main__':
    main()

❶ 从内置的 multiprocessing 库导入 Process 和 Manager 类，来帮助我们管理进程。

❷ 定义主要 worker 函数 is_up_or_not，培训需要 3 个参数：一个 URL、一个共享列表和一个共享锁。其中的列表和锁是在所有的进程间共享的，让其中的每一个进程能够修改或使用它们。

❸ 使用 requests 检查 isup.me，判定给定的 URL 当前是否在线并且可用。

❹ 测试是否可以在页面中找到文本“is up.”。如果文本存在，这个 URL 就是我们想要的。

❺ 通过 with 代码块调用锁的 acquire 方法。这会得到锁，继续执行下面缩进的代码，然后在代码块的最后释放锁。锁（http://www.laurentluce.com/posts/python-threads-synchronization-locks-rlocks-semaphores-conditions-events-and-queues/）是阻塞的，并且只能在代码中需要阻塞时使用（举个例子，如果你需要确保只有一个进程运行一组特殊的逻辑，比如检查一个共享值是否变化，或是否到达了一个终止点）。

❻ 当生成进程时，传递共享的锁和列表到函数中使用。

❼ 通过传递关键参数创建一个进程对象：目标（即，我应该执行哪个函数）和参数（即，使用什么参数）。这行代码追加所有的进程到一个列表，这样我们可以在一个地方管理它们。

❽ 初始化 Manager 对象，这帮助我们管理共享的对象和进程间的日志。

❾ 创建一个共享列表对象，跟踪每个站点的状态。每一个进程都能改变这个列表。

❿ 创建一个共享的锁对象，如果一个站点中不存在“is up”，停止并且宣布它。如果这些是我们管理的所有站点，我们可能有了一块重要的业务逻辑来处理紧急情况，也因此有了“停止所有程序”的理由。

⓫ 分别开始由函数 get_proc 返回的每一个进程。一旦它们开始执行，join 方法会让 Manager 对象和所有的子进程通信，直到最后一个进程完成。

使用多重处理的时候，你通常会有一个管理者进程和一堆子进程。你可以传递参数给子进程，可以使用共享内存和共享变量。这使你能够确定如何利用和架构 multiprocessing。根据脚本的需要，你或许想要让管理器运行脚本中一系列的逻辑，同时使用子进程运行高延迟或长时间运行的部分代码。

　共享锁对象（https://docs.python.org/2/library/threading.html#lock-objects）提供了同步执行多个进程的能力，同时能保护内部逻辑的特定区域。有效使用它们的一个方式是直接放置锁逻辑到 with（https://docs.python.org/2/library/threading.html#using-locks-conditions-and-semaphores-in-the-with-statement）语句中。

如果不确定脚本是否适合使用多重处理，可以先测试脚本中的一部分代码或者一个子任务，确定你是否能够实现并行编程的目标，或者它是否将逻辑复杂化了。有一些任务使用大规模的自动化和队列来处理会更好，我们会在本章的后面讨论。

14.5.4　使用分布式处理

除了并行处理和多重处理，还有分布式处理，它分发进程到多台机器上（不同于发生在一台机器上的并行处理）。当计算机可以处理时，并行处理更快，但是有些时候你需要更强大的能力。

　分布式处理涉及多个类型的计算问题。有些工具和库可以管理分布在多台计算机上的进程，还有些工具和库可以管理跨越计算机的存储。与这些问题相关的概念包括分布式计算、MapReduce、Hadoop、HDFS、Spark、Pig 和 Hive。

在 2008 年早期，克林顿总统图书馆和国家档案局发布了希拉里·克林顿在 1993 年到 2001 年作为第一夫人的日程表。这份文档包含 17 000 多页的 PDF 图片，为了将其转换为有用的数据集，需要进行光学字符识别，或者使用 OCR 技术。由于正值民主党总统候选人初选阶段，新闻组织想要发布这份数据。为了完成这项工作，《华盛顿邮报》使用了分布式进程服务，将 17 000 多张图片转化为文本。通过分发工作到 100 多台计算机上，他们不到 24 小时就完成了这项工作。

使用类似 Hadoop 的框架的分布式处理包含两个主要的步骤。第一步是映射数据或输入。

这一进程像是一个过滤器。使用映射器说“分离文本文件中所有的单词”或者“分离过去一个小时里推文中包含特定标签的所有用户”。下一步是 reduce（规约）映射后的数据为可用的形式。这类似于在第 9 章使用的聚合函数。如果我们查看所有来自 Spritzer feed 的 Twitter 句柄，可能想要根据不同的地理信息或主题统计每一个句柄的推文数量，或者所有句柄的聚合（即，所有来自这一时区的使用这些单词最多的推文）。规约部分帮助我们处理这些大数据，“规约”它为可阅读和可操作的报告。

正如你可能看到的，不是所有的数据集都需要 map-reduce，而且 MapReduce 背后的理论已经在许多 Python 数据库中可用。无论怎样，如果你有一个非常大的数据集，使用类似 Hadoop 的 MapReduce 工具会节省大量的计算时间。如果你需要一个很棒的教程，建议你阅读 Micheal Noll 写的关于用 Python 编写 Hadoop MapReduce 程序的指南（http://www.michael-noll.com/tutorials/writing-an-hadoop-mapreduce-program-in-python/），它使用一个单词计数程序来探索 Python 和 Hadoop。还有很棒的关于 mrjob（https://pythonhosted.org/mrjob/）的文档，由在 Yelp（http://www.yelp.com）的开发者编写和维护。如果你想要阅读更多关于这个主题的信息，可以查看 Kevin Schmidt 和 Christopher Phillips 的图书 Programming Elastic MapReduce（O'Reilly）。

如果数据集很大，但是分离存储或是实时的（或接近实时），你可能想要了解一下另外一个 Apache 项目——Spark（http://spark.apache.org/）。Spark 因为它的速度、机器学习的使用和处理流的能力获得了流行。如果你的任务处理实时的流数据（来自服务、API，甚至是日志），那么相对于 Hadoop，Spark 会是一个更灵活的选择，它可以处理相同的 MapResuce 计算结构。在你需要使用机器学习或其他需要生成数据并且“喂”给数据集群的数据分析时，Spark 也很有效。PySpark（http://spark.apache.org/docs/latest/api/python/）是 Spark 的 Python API 库，由相同的开发者维护，让你能够在 Spark 进程中编写 Python。

为了开始使用 Spark，建议你阅读 Benjamin Bengfort 的详细的博客文章（https://districtdatalabs.silvrback.com/getting-started-with-spark-in-python），其内容包括如何安装 Spark，同 Jupyter notebook 集成，以及创建第一个项目。你同样可以查看 John Ramey 关于 PySpark 和 Jupyter notebook 集成的文章（http://ramhiser.com/2015/02/01/configuring-ipython-notebook-support-for-pyspark/），在你的 notebook 中进一步探索数据收集和分析的可能性。

14.6　简单的自动化

在 Python 中，简单的自动化很容易。如果你的代码不需要在多台机器上运行，如果你拥有一台服务器，或者你的任务不是事件驱动的（或者可以每天在相同时间执行），简单的自动化会很有效。开发的一个主要原则是选择最清晰和简单的方法。自动化也是如此！如果你可以轻松地使用一个定时任务（corn job）自动化工作，绝不要浪费时间过度工程化它，或者让它变得更加复杂。

在学习简单自动化的过程中，我们会介绍内置的 cron（一个基于 Unix 系统的任务管理器）和各种 Web 接口，让团队得以轻松访问所编写的脚本。这些代表着简单的自动化解决方案，不需要你的直接干预。

14.6.1　CronJobs

Cron（http://en.wikipedia.org/wiki/Cron）是一个基于 Unix 系统的任务调度工具，用来使用服务器日志和管理工具运行脚本。Cron 需要你确定任务执行的频率和时间。

　如果你不能轻松地为脚本确定一个时间线，cron 可能不是一个好的选择。另外，你可以运行一个规律的 cron 任务来测试是否存在运行任务所必需的条件，然后使用一个数据库或本地文件通知运行的时间。在多个定时任务的帮助下，你可以检查文件或数据库，并且执行任务。

如果你之前从来没有使用过 cron 文件，它们非常直观。大多数的任务可以通过输入下面的指令来编辑：

crontab -e

　取决于操作系统，如果之前从来没有编写过 cron 文件，你可能会收到提示，让你选择一个编辑器。你可以使用默认的配置，或者根据其他的偏好改变它。

你会在文件中看到一些文档和注释，解释 cron 文件是如何工作的。cron 文件中每一个不以 # 符号开头的行，都定义了一个 cron 任务。每一个 cron 任务都需要下面这个参数列表：

minute hour day_of_month month day_of_week usercommand

如果脚本需要在一天中的每个小时都执行，但是只在工作日执行，你需要写类似于下面的代码：

0 * * * 1-5 python run_this.py

这告诉 cron 在周一到周五的每个整点开始执行脚本。有很多好的入门教程（https://help.ubuntu.com/community/CronHowto）详细地讲解了哪些选项对你可用，但是下面有几条建议。

总是在所有代码行前添加 MAIL_TO=your@email.com 变量。通过这种方式，如果有哪个脚本失败了，cron 会给你发送邮件，报告异常，这样你会知道它不再工作了。你需要设置笔记本电脑、台式计算机或服务器来发送邮件。根据操作系统和互联网服务提供商的不同，你可能需要做一些设置。有一个很好的 GitHub gist（https://gist.github.com/kany/c44c077881047ead8faa）可以帮助 Mac 用户上手，还有一篇来自 HolaRails 的有用的面向 Ubuntu 用户的文章（https://holarails.wordpress.com/2013/11/17/configure-sendmail-in-ubuntu-12-04-and-make-it-fast/）。

如果所运行的服务当计算机重启时需要重新启动，使用 @reboot 特性。

如果必需运行一些路径环境或者其他的命令来恰当地执行脚本，你应该在仓库中编写一个 cron.sh 文件。将所有必需的命令整理到一个文件中，并且直接运行这个文件，而不是使用一个用 && 连接的很长的命令列表。

不要害怕去搜索答案。如果你是第一次使用 cron，并且遇到了问题，很有可能已经有人发布过解决方案，通过 Google 搜索就可以解决问题。

为了测试如何使用 cron，我们会创建一个简单的 Python 示例。从创建一个新的名为 hello_time.py 的 Python 文件开始，并且把这些代码放在文件里：

from datetime import datetime

print 'Hello, it is now %s.' % datetime.now().strftime('%d-%m-%Y %H:%M:%S')

下面，在相同文件夹下创建一个简单的 cron.sh 文件，并且在里面编写下面的 bash 命令：

export ENV=PROD
cd /home/your_home/folder_name
python hello_time.py

我们不需要设置环境变量，因为并不经常使用它，并且你需要更新 cd 这行代码，让它正确地变成代码所在的文件夹（这是指向当前文件的路径）。无论如何，这是一个很好的实例，展示了如何使用 bash 命令设置变量，查看虚拟环境变量，复制和移动文件或改变目录到新的文件夹，然后调用 Python 文件。从本书的开始，你就在使用 bash 了，所以即使你仍然是初学者也不要害怕。

最后，让我们使用 crontab -e 创建 cron 任务。使用编辑器在文挡下方添加这些代码：

MAIL_TO=youremail@yourdomain.com
*/5 * * * * bash /home/your_home/folder_name/cron.sh > /var/log/my_cron.log 2>&1

你需要使用真实地址替换这个示例中编造的 Email 地址，编写到刚刚创建的 cron 文件的正确路径。记住，hello_time.py 脚本应该在相同的文件夹中。在这个例子里，我们还创建了一个 cron 使用的日志文件（/var/log/my_cron.log）。最后的语句 2>&1 告诉 cron 将输出和所有错误打印到日志文件中。一旦退出编辑器，并正确地保存了 cron 文件，你应该会看见一个信息，确认新 cron 任务已经安装。等待几分钟，然后检查日志文件。你应该会看到脚本输出的信息。如果没有的话，你可以通过在系统日志（通常为 /var/log/syslog）中搜索，或在 cron 日志（通常为 /var/log/cron）中搜索 cron 错误日志信息。为了删除这个 cron 任务，再一次编辑 crontab，删除这行代码或在这行代码的开头添加#，将它注释掉。

　Cron 是自动化脚本和警报的非常简单的方式。它是在 20 世纪 70 年代中期最初的 Unix 系统的开发中，由贝尔实验室设计的非常强大的工具，直到今日，仍被广泛地使用。如果可以很简单地预测自动化程序运行时间，或者只需要几个 bash 命令就可以运行，cron 是一个自动化代码的有效方式。

如果你需要为 cron 任务传递命令行参数（见 14.5.1 节），那么文件中的代码可能类似下面这样。

*/20 10-22 * * * python my_arg_code.py arg1 arg2 arg3
0,30 10-22 * * * python my_arg_code.py arg4 arg5 arg6

Cron 是一个相当灵活也很简单的工具。如果它符合你的需要，那很棒！如果不符合，继续阅读来学习自动化数据处理的其他简单方式。

14.6.2　Web接口

如果你需要脚本、爬虫或者报告任务按需执行，一个简单的解决方案是直接构建一个 Web 接口，人们可以登录进去并点击按钮执行任务。Python 有很多不同的网络框架供你选择，所以使用哪一个框架以及花费多少时间在 Web 接口上完全取决于你。

一个简单的方式是使用 Flask-Admin（https://flask-admin.readthedocs.org/en/v1.0.9/），这是一个基于 Flask 网络框架（http://flask.pocoo.org/）构建的管理站点。Flask 是一个微框架，这意味着它并不需要太多的代码上手。在依照快速开始指引（http://flask.pocoo.org/docs/0.10/quickstart/）创建并运行了网站后，你只需要在 Flask 应用程序中创建一个视图来执行任务。

　确保任务可以在完成时用其他的方式通知用户或你（邮件、通知等），因为它不太可能及时地完成并给出一个合适的 Web 响应。同样确保在任务开始时通知用户，这样他们不会发出一连串的让任务开始运行的请求。

另外一个流行并且经常使用的 Python 框架是 Bottle（http://bottlepy.org/docs/dev/index.html）。Bottle 的用法类似于 Flask，如果用户点击了按钮（或做了其他简单的操作），Bottle 用视图来执行任务。

Python 开发者使用的一个大型 Python 网络框架是 Django（https://www.djangoproject.com/）。Django 最初被开发用来使新闻编辑室可以更简单地发布内容，它拥有一个内置的认证和数据库系统，并使用配置文件配置大多数的特性。

无论你使用什么框架，或者如何创建视图，你会想要在一些地方挂载框架，这样其他人可以请求任务。你可以使用 DigitalOcean 或者亚马逊网络服务（查看附录 G）轻松挂载自己的站点。你同样可以使用支持 Python 环境的服务提供商，比如 Heroku（https://www.heroku.com/）。如果你对这些选择感兴趣，Kenneth Reitz 非常出色地介绍了如何使用 Heroku 部署 Python 应用（https://devcenter.heroku.com/articles/getting-started-with-python#introduction）。

　无论使用什么框架或微框架，你都需要考虑认证和安全问题。无论使用什么 Web 服务器，你都可以在服务器端创建它，或者探索框架给你的一些选择（包括插件或者其他支持的特性）。

14.6.3　Jupyter notebook

第 10 章介绍了如何创建 Jupyter notebook，它是另外一种分享代码的好方式，特别是对于那些不需要了解 Python，但是需要查看图表或者其他脚本输出的人来说。如果你教他们如何使用简单的命令，例如运行 notebook 中所有的单元，并在下载新的报告后终止 notebook 服务，你会发现它会节省大量的时间。

　添加 Markdown 单元来解释如何使用你的共享 notebook 是一种很好的方式，可以确保所有人明白如何使用代码，并且可以更容易地继续前进，而不需要你的帮助。

如果你的脚本功能组织得很好，不需要做什么修改，直接将仓库放到 Jupyter notebook 可以导入和使用代码的地方（设置的服务器或者 notebook 的 PYTHONPATH（http://stackoverflow.com/questions/3402168/permanently-add-a-directory-to-pythonpath）是个好主意，这样你正在使用的模块总是可用的）。通过这种方式，你可以导入那些 main 函数到 notebook 中，在有人点击 notebook 的“运行所有”按钮时，所有的脚本会运行并且生成报告。

14.7　大规模自动化

如果系统需要多台机器或者服务器来处理，或者报告是关于一个分布式系统或者其他事件驱动系统的，很可能你会需要比 Web 接口、notebook 和 corn 更加健壮的工具。如果你需要一个真正的任务管理系统并且想要使用 Python，你很幸运。在这一节中，我们会介绍一个健壮的任务管理工具，Celery（http://www.celeryproject.org），它能处理大量的任务，自动化 worker（你会在下一节学习 worker 相关的知识），并且提供监控解决方案。

我们还会介绍操作自动化。如果你管理着一系列的服务器或不同需求的环境，这会很有帮助。Ansible（http://www.ansible.com）是一个非常棒的自动化工具，能够帮助完成各种任务，从数据库迁移到大规模集成部署。

还有一些 Celery 的替代方案，例如 Spotify 的 Luigi（https://github.com/spotify/luigi）。如果你正在使用 Hadoop，并且有大规模任务管理的需要（特别是长时间运行的任务，这可能成为一个痛点），它非常有用。关于操作自动化的好的替代品确实有很多。如果你只需要管理很少的服务器，对于只使用 Python 的部署，一个好的选择是 Fabric（http://www.fabfile.org/）。

对于大规模的服务器管理，一个很好的选择是 SaltStack（http://saltstack.com/），或同很多部署与管理工具［例如 Chef（https://www.chef.io/chef/）或 Puppet（https://puppetlabs.com/）］一起使用 Vagrant（https://www.vagrantup.com/）。我们选择重点介绍在这一节中使用的一些工具，但是它们不是使用 Python 处理大规模自动化程序的唯一选择。考虑到领域的声望和必要性，我们建议在你最喜欢的科技论坛上参与大规模自动化的讨论，例如 Hacker News（https://news.ycombinator.com/）。

14.7.1　Celery：基于队列的自动化

Celery（http://www.celeryproject.org/）是一个用来创建分布式队列系统的 Python 库。有了 Celery，可以通过调度器或者通过时间和消息来管理任务。如果你正在寻找能够处理长时间运行的时间驱动的任务的、可扩展的工具，Celery 是一个很完整的解决方案。Celery 很好地集成了几个不同的队列。它使用设置文件、用户接口和 API 调用来管理任务。它非常容易上手，所以如果这是你第一次使用任务管理系统的话，没有必要害怕。

无论你如何创建 Celery 项目，都很可能包含下面的任务管理系统组件。

消息中间件（例如 RabbitMQ，https://www.rabbitmq.com/）

消息中间件类似于一个等待处理的任务的队列。

任务管理器 / 队列管理器（Celery）

这个服务会跟踪一些逻辑，这些逻辑控制着使用多少 worker、什么任务有优先权、以及何时重试，等等。

worker

worker 是通过 Celery 控制的 Python 进程，执行 Python 代码。它们知道你让它们去执行什么任务，并且尝试执行这些 Python 代码到结束。

监控工具［例如 Flower（http://flower.readthedocs.org/en/latest/）］

允许查看 worker 和队列，对于回答类似于“昨晚什么失败了”这样的问题很有用。

Celery 有一个很有用的上手指南（http://docs.celeryproject.org/en/latest/getting-started/first-steps-with-celery.html），但是我们发现最大的问题并不是学习如何使用 Celery，而是了解什么类型的任务适合于队列、什么类型不适合于队列。表 14-2 综述了一些有关基于队列自动化的问题和原则。

表14-2：队列还是非队列？

基于队列的任务管理需求

不需要队列的自动化需求

任务没有一个确定的截止日期

任务可以并且的确有截止日期

不需要知道有多少任务

可以轻松地量化需要完成什么任务

只是大体上知道任务的优先级

清楚地知道任务的优先级

任务不需要总按顺序发生，或者通常不是有顺序的

任务必需按顺序发生

任务有时花费很长的时间，有时花费很短的时间

需要知道任务花费多长时间

任务调用（或排队）基于一个事件或其他的

任务结束任务基于时间或其他可预测的事情

任务失败是可以的；可以重试

必须了解每一次任务的失败

有大量的任务，而且很有可能继续增长

每天只有几个任务

这些都是一般性的需求，但是它们指出了关于何时选择任务队列和何时最好运行在一个有警报、监控和日志的调度器上，这两者之间的一些理念区别。

　任务的不同部分位于不同的系统中是没问题的，而且你在大公司会经常看到不同的任务“桶”（bucket）。也可以同时测试基于队列的和不基于队列的任务管理工具，以确定哪一种最适合你和你的项目。

Python 也有其他的任务和队列管理系统，包括 Python RQ（http://python-rq.org/）和 PyRes（https://github.com/binarydud/pyres）。这两者都是很新的库，因此在解决问题方面，可能没有足够的资源能够通过 Google 搜索到，但是如果你想要从 Celery 开始，之后转移到其他的选择上，你有了备选项。

14.7.2　Ansible：操作自动化

如果你当前的规模需要 Celery 帮助管理任务，很可能你同样需要一些帮助，来管理其他的服务和操作。如果项目需要在一个分布式系统上维护，你应该开始组织它们，这样可以轻松地通过自动化实现分布式。

Ansible（http://www.ansible.com/home）是一个自动化项目操作的非常棒的系统。Ansible 提供了一系列的工具，你可以用来迅速编写、部署和管理代码。你可以使用 Ansible 来迁移项目，备份远程机器上的数据。你还可以使用它根据需要来使用安全补丁或新的包更新服务器。

Ansible 有一个快速开始的视频（http://docs.ansible.com/quickstart.html），可用来了解所有的基础知识，但是我们同样想强调文档中描述的几个最有用的特性：

MySQL 数据库管理（http://docs.ansible.com/mysql_db_module.html）

Digital Ocean 基本单元和键管理（http://docs.ansible.com/ansible/list_of_cloud_modules.html#digital-ocean）

滚动升级和部署指南（http://docs.ansible.com/guide_rolling_upgrade.html）

同样建议你查看 Justin Ellingwood 的关于 Ansible 操作的介绍（https://www.digitalocean.com/community/tutorials/how-to-create-ansible-playbooks-to-automate-system-configuration-on-ubuntu）和 Servers for Hackers 关于 Ansible 的扩展介绍（https://serversforhackers.com/an-ansible-tutorial）。

　如果你只有一两台服务器，或者只部署一两个项目，Ansible 很可能太高级或过于复杂，但是随着项目的成长，你需要一些工具来帮助保持项目的有效组织时，它就是一个很好的资源。如果你对操作和系统管理有兴趣，这是一个很值得学习和掌握的工具。

如果你更喜欢把操作留给你创建的一个很好的镜像上，每一次操作时只需要重新启动，大量的云服务提供商允许你这么做！对于你的数据处理需求来说，你并不需要成为一个操作自动化专家。

14.8　监控自动化程序

花费时间监控自动化程序是必需的。如果你不知道任务是否完成了，或者不知道任务成功还是失败了，你最好还是不要运行它们了。因此，监控你的脚本和运行它们的机器是过程中非常重要的一部分。

举个例子，如果有一个隐藏的 bug，数据并没有被加载，而且每天或每周，你都在老的数据上运行报告，这是个非常可怕的消息。在自动化程序中，失败并不总是很明显，因为脚本可能使用旧数据或者在存在错误和不一致的情况下继续运行。监控是观察脚本成功或者失败，即使所有的迹象都表明脚本仍然在正常地执行。

　监控可以有小的或者大的足迹，这取决于任务的规模和需求。如果你会有一个大规模的自动化程序，跨越多个服务器，你可能需要使用一个大型的分布式监控系统，或者具有监控功能的服务。然而，如果你正在一个家庭服务器上运行任务，可能只需要使用内置的 Python 日志工具。

你可能还想在脚本中使用一些警报和通知工具。在 Python 中，上传、下载、用邮件发送，甚至用 SMS 发送结果都非常简单。在这一节，我们会介绍几种不同的日志选择，并查看发送通知的方法。在全面测试并深入理解日常监控所有潜在的错误之后，你可以充分地自动化任务，并通过警报管理错误。

14.8.1　Python日志

最基本的监控脚本的方式是日志。你很幸运，Python 有一个非常健壮并且有丰富特性的日志环境，它是标准库的一部分。与你交互的客户端或库通常都拥有集成了 Python 日志系统的日志工具。

使用 Python 内置的日志模块中提供的简单基本的配置，可以实例化日志器，并且开始记录。之后你可以使用很多不同的配置选项，来迎合脚本特殊的日志需求。Python 的日志模块允许你设置特定的日志等级（https://docs.python.org/2/library/logging.html#logging-levels）和日志的记录属性（https://docs.python.org/2/library/logging.html#logrecord-attributes），调整日志的格式。日志器对象同样拥有方法和属性（https://docs.python.org/2/library/logging.html#logger-objects），根据你的需要，这也会很有用处。

下面是在代码中如何创建并使用日志的实例。

import logging
from datetime import datetime

def start_logger():
    logging.basicConfig(filename='/var/log/my_script/daily_report_%s.log' %
                        datetime.strftime(datetime.now(), '%m%d%Y_%H%M%S'), ➊
                        level=logging.DEBUG, ➋
                        format='%(asctime)s %(message)s', ➌
                        datefmt='%m-%d %H:%M:%S') ➍

def main():
    start_logger()
    logging.debug("SCRIPT: I'm starting to do things!") ➎

    try:
        20 / 0
    except Exception:
        logging.exception('SCRIPT: We had a problem!') ➏
        logging.error('SCRIPT: Issue with division in the main() function') ➐

    logging.debug('SCRIPT: About to wrap things up!')

if __name__ == '__main__':
    main()

❶ 使用 logging 模块的 basicConfig 方法，初始化日志系统，这需要一个日志文件名称。这段代码输出日志到 /var/log 文件夹中的文件夹 my_script。文件名是 daily_report_<DATEINFO>.log，其中 <DATEINFO> 是脚本开始执行的时间，包括月、日、年、小时、分钟和秒。这告诉我们何时以及为什么脚本开始运行，同时这是一个好的日志实践。

❷ 设置日志级别。通常，你会想要把级别设置为 DEBUG，这样可以在代码中留下调试信息，在日志中跟踪它们。如果想要更多的信息，你可以使用 INFO 级别，这会展示更多的来自辅助库的日志信息。一些人更喜欢简略的日志，设置日志级别为 WARNING 或 ERROR。

❸ 使用日志的记录属性设置 Python 日志的格式。这里我们记录发送给日志的信息和打印日志的时间。

❹ 设置一个易于阅读的日期格式，这样很容易使用我们喜欢的日期格式解析或搜索日志。这里我们记录了月、日、小时、分钟和秒。

❺ 调用模块的 debug 方法开始记录日志。这个方法需要一个字符串。我们用单词 SCRIPT: 作为脚本日志实体的前缀。在日志中添加类似这样可搜索的标签，会在之后帮助你确定哪个进程或库写了这条日志。

❻ 使用 logging 模块的 exception 方法，输出你发送的字符串和 Python 异常的回溯信息，因此只能够在一个异常代码块中使用。这在调试错误和查看脚本中有多少异常时非常有用。

❼ 使用级别 error 打印一个长一点的错误信息。logging 模块能够打印不同级别的错误信息，包括 debug、error、info 和 warning。日志级别应该与打印的内容一致，为正常信息使用 info 或 debug 级别，用 error 打印脚本中的错误和异常信息。通过这种方式，你永远知道去哪里找问题，以及如何在检查中正确地解析日志。

正像在示例中做的那样，我们发现开始日志信息时，用一个关于正在输出信息的代码模块或区域的标签是很有用处的。这会帮助确定错误发生在哪里。这也使得日志便于搜索和解析，因为你可以清晰地看到脚本碰到了什么错误或问题。学习日志最好的方式是在第一次编写脚本的时候确定在哪里放置信息，并在脚本中保留重要的信息，以确定是否发生了问题以及在哪些节点发生了问题。

　即使已经预料到了异常，每一个异常也应该通过日志记录下来。这会帮助你跟踪异常发生的频率，以及你的代码是否应按照正常情况处理它们。logging 模块提供了 exception 和 error 方法，你可以打印异常和 Python 的回溯，并且可以使用 error 添加一些额外的信息，详细描述可能发生了什么，以及哪部分代码触发了这个错误。

你还应该将同数据库、API 和外部系统的交互通过日志记录下来。这会帮助你确定脚本与它们的交互何时出现了问题，确保它们是稳定的、可信赖的或者可以使用的。你与之交互的许多库同样能够根据配置打印日志。例如，requests 模块会将连接问题和请求直接记录到你的脚本日志中。

即使不为脚本创建任何其他的监控或警报，你也应该使用日志。它很简单，并且为未来的你和其他人提供了优秀的文档。日志并不是唯一的解决方案，但是它们是一个好的标准，并且是自动化程序监控的基础。

除了日志之外，你可以为脚本设置易于分析的警报。在下一节中，我们会介绍几种使脚本能够发送有关成功和失败信息的方式。

14.8.2　添加自动化信息

发送报告、跟踪脚本、通知自己错误的一个简单方式是使用邮件或者其他直接从脚本发送的信息。有很多 Python 库能帮助完成这个任务。精确地确定脚本和项目中需要什么类型的信息是一个好的开始。

问问自己下列选项是否适用于你的脚本。

它会产生一个报告，并将报告发送到特定的收件人列表。

它有一个清晰的成功 / 失败信息。

它与其他的合作者或同事相关。

它提供了不易在网站或快速指示板上查看的结果。

如果这其中的任何一个听起来像你的项目，你的项目很可能适合某种方式的信息自动化。

邮件

使用 Python 处理邮件非常直观。建议你通过你最喜欢的邮件提供商（我们使用 Gmail），构建一个独立的脚本的 email 地址。如果它没有立即自动同 Python 集成，很可能能够通过在网上搜索发现合适的配置列表和有用的示例配置。

让我们看一下曾用来发送带有附件的邮件到收件人列表的脚本。我们修改了 @dbieber 编写的代码片段（https://gist.github.com/dbieber/5146518），这段代码修改自 Rodrigo Coutinho 的博文“使用 Python 发送 Gmail 邮件”（http://kutuma.blogspot.jp/2007/08/sending-emails-via-gmail-with-python.html）：

#!/usr/bin/python
# Adapted from
# http://kutuma.blogspot.com/2007/08/sending-emails-via-gmail-with-python.html
# Modified again from: https://gist.github.com/dbieber/5146518
# config file(s) should contain section 'email' and parameters
# 'user' and 'password'

import smtplib ➊
from email.MIMEMultipart import MIMEMultipart ➋
from email.MIMEBase import MIMEBase
from email.MIMEText import MIMEText
from email import Encoders
import os
import ConfigParser

def get_config(env): ➌
    config = ConfigParser.ConfigParser()
    if env == "DEV":
        config.read(['config/development.cfg']) ➍
    elif env == "PROD":
        config.read(['config/production.cfg'])
    return config

def mail(to, subject, text, attach=None, config=None): ➎
    if not config:
        config = get_config("DEV") ➏
    msg = MIMEMultipart()
    msg['From'] = config.get('email', 'user') ➐
    msg['To'] = ", ".join(to) ➑
    msg['Subject'] = subject
    msg.attach(MIMEText(text))
    if attach: ➒
        part = MIMEBase('application', 'octet-stream')
        part.set_payload(open(attach, 'rb').read()) ➓
        Encoders.encode_base64(part)
        part.add_header('Content-Disposition',
                        'attachment; filename="%s"' % os.path.basename(attach))
        msg.attach(part)
    mailServer = smtplib.SMTP("smtp.gmail.com", 587)
    mailServer.ehlo()
    mailServer.starttls()
    mailServer.ehlo()
    mailServer.login(config.get('email', 'user'),
                     config.get('email', 'password'))
    mailServer.sendmail(config.get('email', 'user'), to, msg.as_string())
    mailServer.close()

def example():
    mail(['listof@mydomain.com', 'emails@mydomain.com'],
        "Automate your life: sending emails",
        "Why'd the elephant sit on the marshmallow?",
        attach="my_file.txt")

❶ Python 内置的 smtplib 库（https://docs.python.org/2/library/smtplib.html）提供了一个 SMTP 的封装，SMTP 是发送和接收邮件的标准协议。

❷ Python 的 email 库（https://docs.python.org/2/library/email.html）帮助创建邮件信息和附件，并用合适的形式保存它们。

❸ 函数 get_config 从一系列的本地配置文件中加载配置。我们传递一个环境变量，环境变量可以为字符串 "PROD" 或 "DEV"，来表明这是在本地运行（"DEV"）还是在远程生产环境运行（"PROD"）的程序。如果只使用一个环境，你可以简单地返回项目中唯一的配置文件。

❹ 这行代码使用 Python 的 ConfigParser 读取 .cfg 文件，返回 config 对象。

❺ 我们的 mail 函数接受一个邮件地址列表，作为变量 to、邮件的主题和文本、一个可选的附件和一个可选的 config 变量。附件需要是本地文件的名称。config 对象需要是 ConfigParser 的实例。

❻ 这行代码设置了默认配置。安全起见，我们使用 "DEV" 配置。

❼ 这行代码使用了 ConfigParser 对象来从配置文件中拉取邮件地址。这个变量安全地保存地址，并与仓库代码分离。

❽ 这段代码拆分邮件列表，并且用逗号和一个空格分离它们。这会展开邮件地址的列表为一个字符串，因为这是我们 MIME 需要的类型。

❾ 如果有附件的话，这行代码按照 MIME 分组标准开始对附件做特殊处理，以发送附件。

❿ 这段代码使用传入的文件名字符串打开并读取完整的文件。

⓫ 如果你没有使用 Gmail，设置这些参数为邮件服务提供商 SMTP 的主机和端口。如果有好的文档的话，很容易找到这些参数。如果没有的话，直接搜索“SMTP 设置 < 你的邮件服务提供商名称 >”应该会得到答案。

⓬ 这是一些示例代码，提示你 mail 函数所需的参数。你可以看到需要的数据类型（字符串、列表、文件名）和顺序。

简单的 Python 内置库 smtplib 和 email 使用它们的类和方法帮助我们快速地创建和发送邮件信息。将脚本中的一些其他部分（例如在配置文件中保存邮件地址和密码）抽象出来是保证脚本和仓库安全可复用所必需的。一些默认的设置确保脚本永远可以发送邮件。

短消息服务（SMS）和语音

如果想要集成电话信息到警报程序中，你可以使用 Python 来发送文本信息，或者打电话。Twilio（https://www.twilio.com）是一个经济有效的选择，支持多媒体信息和自动拨打电话。

　在开始使用 Twilio API 之前，你需要注册得到认证代码和 key，并且安装 Twilio 的 Python 客户端（https://github.com/twilio/twilio-python）。在 Python 客户端的文档（https://twilio-python.readthedocs.org/en/latest/）中有一个很长的代码示例列表，所以如果你需要做一些有关语音或者文本的事情，很可能这里有一个好的可用的特性。

看一下发送一个快速的文本信息是多么简单。

from twilio.rest import TwilioRestClient ➊
import ConfigParser

def send_text(sender, recipient, text_message, config=None): ➋
    if not config:
        config = ConfigParser('config/development.cfg')

    client = TwilioRestClient(config.get('twilio', 'account_sid'),
                              config.get('twilio', 'auth_token')) ➌
    sms = client.sms.messages.create(body=text_message,
                                      to=recipient,
                                      from_=sender) ➍

def example():
    send_text("+11008675309", "+11088675309", "JENNY!!!!") ➎

❶ 我们会使用 Twilio Python 客户端通过 Python 直接同 Twilio API 交互。

❷ 这行代码定义了一个可以用来发送文本的函数。我们需要发送者和接收者的电话号码（以国家代码开头），以及想要发送的简单文本信息，我们还能传递一个配置对象。我们会使用配置来同 Twilio API 进行认证。

❸ 这段代码创建了一个客户端对象，它会使用我们的 Twilio 账户认证。当你注册 Twilio 账户时，会收到一个 account_sid 和一个 auth_token。将它们放在脚本使用的配置文件中名为 twilio 的小节中。

❹ 为了发送一条信息，这段代码使用了客户端的 SMS 模块，并且调用了 message 资源的 create 方法。正像 Twilio 文档中写的（https://twilio-python.readthedocs.io/en/latest/usage/messages.html#sending-a-text-message），之后可以通过几个参数发送一条文本信息。

❺ Twilio 在全世界范围工作，并且需要基于国际的电话号码。如果你不确定要使用什么国际电话代码，维基有一个很棒的列表（https://en.wikipedia.org/wiki/List_of_country_calling_codes）。

　如果你对让脚本通过 Python“交谈”感兴趣，Python 的 text-to-speech 模块（https://pyttsx.readthedocs.org/en/latest/）可以在电话上“朗读”你的文字。

集成聊天

如果你想要集成聊天室到警报系统中，或者你的团队或合作者普遍使用聊天室，有很多 Python 聊天室工具可用。根据聊天室客户端和需要，可以选择 Python 或者基于 API 的解决方案，你可以使用关于 REST 客户端的知识，连接并向正确的人发送消息。

如果你使用 HipChat，他们的 API（https://www.hipchat.com/docs/apiv2）很容易同 Python 应用程序或脚本集成。这里有各种各样的 Python 库（https://www.hipchat.com/docs/apiv2/libraries），可以简单地发送信息到聊天室或直接发送给个人。

为了开始使用 HipChat API，你需要首先登录（https://hipchat.com/account/api）并获取一个 API token。你可以使用 Python 库 HypChat（https://github.com/RidersDiscountCom/HypChat），发送一个快速信息到聊天室。

首先，使用 pip 安装 HypChat：

pip install hypchat

现在，使用 Python 发送一条信息！

from hypchat import HypChat
from utils import get_config

def get_client(config):
    client = HypChat(config.get('hipchat', 'token')) ➊
    return client

def message_room(client, room_name, message):
    try:
        room = client.get_room(room_name) ➋
        room.message(message) ➌
    except Exception as e:
        print e ➍

def main():
    config = get_config('DEV') ➎
    client = get_client(config)
    message_room(client, 'My Favorite Room', "I'M A ROBOT!")

❶ 使用 HypChat 库同聊天室客户端交谈。这个库使用 HipChat token 初始化一个新的客户端，我们将 token 保存在配置文件中。

❷ 这行代码使用 get_room 方法，选择与字符串名称匹配的房间。

❸ 这行代码使用 message 方法发送一条信息到一个房间或者一个用户，传递给函数一个简单的字符串，里面包含想说的内容。

❹ 总是在基于 API 库的周围使用 try...except 代码块，以应对连接错误或者 API 改变带来的异常。这段代码打印了错误，但是你可能更想打印日志，以充分地自动化脚本。

❺ 这里使用的函数 get_config 是从不同的脚本中导入的。通过引入这些辅助函数，并且将它们放置到独立的模块中复用，我们遵循了模块化的代码设计。

如果想要登录到聊天室，你可以使用 HipLogging（https://github.com/invernizzi/hiplogging）探索这些选项。取决于需求和团队的工作方式，你可以根据自己的喜好设置聊天室的登录功能；但是好消息是，你总是可以在他们可能看见的地方留下笔记！

如果你更喜欢 Google Chat，有很多非常棒的示例展示了如何使用 SleekXMPP（http://sleekxmpp.com/getting_started/sendlogout.html）做这件事。你还可以使用 SleekXMPP 发送 Facebook 聊天信息（http://stackoverflow.com/questions/18906462/send-facebook-messages-via-sleekxmpp/21452035#21452035）。

关于 Slack 信息，查看 Slack 团队的 Python 客户端（https://github.com/slackhq/python-slackclient）。

对于其他的聊天客户端，建议你使用 Google 搜索“Python <你的客户端名称 >”。很有可能有人已经尝试使用他们的 Python 代码同这个客户端连接，又或者有 API 可供你使用。在第 13 章中，你知道了如何在工作中使用一个 API。

关于脚本（和自动化）成功或失败的警报的选择有这么多，很难知道应使用哪一个。重要的是选择一个你或你的团队经常使用的方式。优先考虑易用性和与日常生活的整合是必需的，自动化会帮助你节省时间，不让你花费更多的时间来检查服务。

14.8.3　上传和其他报告

如果你需要上传报告或图片到独立的服务或文件分享作为自动化的一部分，有很多很棒的工具。如果这是一种在线的形式，或者一个需要与之交互的网站，建议使用第 12 章中的 Selenium 爬取技巧。如果这是一个 FTP 服务器，Python 有一个标准 FTP 库（https://docs.python.org/2/library/ftplib.html）。如果需要发送报告给一个 API，或者通过 Web 协议发送，你可以使用 requests 库，或者在第 13 章中学到的 API 技巧。如果需要发送 XML，你可以使用 LXML（查看第 11 章）。

无论准备同哪些服务交互，你很有可能与服务有过接触和交流。我们希望你自信地练习这些技能，并且独立思考。

14.8.4　日志和监控服务

如果一个脚本满足不了需求，或者你想将自动化程序合并到一个大型的组织框架中，你可能需要研究将日志和监控作为一个服务的技术。有很多公司通过创建工具和系统来跟踪日志，致力于让数据分析师和开发者的生活更简单。这些工具通常有非常简单的 Python 库，将日志和监控信息发送到它们的平台。

　通过日志服务，你可以将更多的时间投入到研究和脚本中，将更少的时间用于管理监控和日志。这可以将“我们的脚本在不在工作，工作得怎么样？”这样的问题留给团队中的非开发者，因为其中的很多服务都有很棒的报表和内置的警报。

根据自动化程序的大小和布局，你可能同时需要系统监控以及脚本与错误监控。在这一节中，我们会查看几种能够同时完成这两件事的服务，同时也会查看一些更加专业的服务。即使你的自动化程序的规模没有大到需要它们，但是了解什么是可用的总是好的。

日志和异常

基于 Python 的日志服务提供了打印日志到一个中心服务的能力，同时让你的脚本在一系列不同的机器上运行，无论是本地机器还是远程机器。

一个有着很棒的 Python 支持的类似服务是 Sentry（https://getsentry.com/welcome/）。只 需要每月支付很少的费用，你就可以访问错误的报告板，收到基于异常临界值发送的警报，监控基于日、周、月的错误和异常类型。Sentry 的 Python 客户端（https://github.com/getsentry/raven-python）非常容易安装、配置和使用。如果你正在使用类似 Django、Celery 的工具，甚至是简单的 Python 日志工具（https://docs.sentry.io/clients/python/），Sentry 都有相应的集成接口，所以你不需要大量地修改代码来开始工作。最重要的是，基础代码持续地更新，工作人员非常友善，在你有问题的时候乐于帮助你。

其他选择包括 Airbrake（https://airbrake.io/languages/python_bug_tracker）和 Rollbar（https://rollbar.com）。Airbrake 最开始是一个基于 Ruby 的异常跟踪器，现在开始支持 Python。这是一个很受欢迎的市场，所以在本书开始印刷时很可能会有新的库发布。

还有拉取和解析日志的服务，例如 Loggly（https://www.loggly.com/）和 Logstash（https://www.elastic.co/products/logstash）。这些服务允许你在聚合的日志级别上监控它们，同样也可以解析、搜索以及在日志中寻找问题。这些工具只在你有足够的日志和时间检查它们时才有用，但是对于有着大量日志的分布式系统非常有用处。

日志和监控

如果你有分布式机器或者正在集成脚本到公司或大学的基于 Python 的服务器环境，你可能想要更健壮的监控，不仅仅是针对 Python，而是整个系统。有很多服务提供了系统数据库负载监控、Web 应用程序的监控，同样也有自动化任务的监控。

New Relic（http://newrelic.com/）是这方面最流行的服务之一，它可以监控服务器和系统进程，也可以监控 Web 应用程序。使用 MongoDB 和 AWS ？或者 MySQL 和 Apache ？ New Relic 插件（http://newrelic.com/plugins）让你可以轻松地将服务的日志集成到用于监控服务器和应用健康的报告板中。除此之外，它们提供了一个 Python 代理（https://docs.newrelic.com/docs/agents/python-agent/getting-started/introduction-new-relic-python），通过它你可以很容易地为 Python 应用（或脚本）在相同的生态系统中打印日志。在整合所有的监控工具之后，发现问题和创建合适的警报系统会变得更简单，这样团队中的相关人员会及时了解任何发生的问题。

另外一个系统和应用监控服务是 Datadog（https://www.datadoghq.com/）。Datadog 允许你整合许多服务（https://www.datadoghq.com/product/integrations/）到一个报告板中。这会节省时间和精力，让你轻松地发现项目、应用和脚本中的错误。Datadog 的 Python 客户端（https://github.com/DataDog/datadogpy）能够打印你想监控的不同事件的日志，但是需要一些个性化设置。

无论你使用什么监控器，也无论你是决定构建自己的监控器还是使用一个服务，拥有规律的警报、深入了解你使用的服务、理解代码和自动化系统的完整性是非常重要的。

　当依赖自动化程序完成工作和项目的其他部分时，你应该确保监控系统容易使用并且直观，这样你可以专注于项目中更重要的部分，不用承担疏忽错误或其他问题的风险。

14.9　没有万无一失的系统

正如本章讨论的那样，完全依赖于任何系统都是鲁莽的，应该避免。无论你的脚本或系统看起来多么完美无缺，都会在一些时间点失败。如果你的脚本依赖于其他的系统，它们可能在任何时间点失败。如果你的脚本包含来自 API、服务或网站的数据，有可能 API 或网站会改变或崩溃，以致需要维修，或者可能会发生任意数量的其他事件，导致自动化失败。

如果一个任务是绝对的关键任务，就不应该自动化。你可以自动化其中的一部分或绝大部分，但是它总是需要高度的监督和工作人员来确保它没有失败。如果这很重要，但不是最重要的部分，对这一部分的监控和警报应该反映出它的重要程度。

　随着深入数据处理和自动化，你会花费越来越少的时间构建高质量的任务和脚本，更多的时间会花费在解决问题、关键问题的思考以及应用分析领域的方法论和领域知识到工作中。自动化可以帮助你做这些事情，但是对于自动化什么重要任务以及如何自动化，持谨慎的态度总是好的。

随着自动化程序的成熟和发展，你不仅改进了自动化程序，让它有更好的伸缩性，同时增加了对代码库、Python 以及数据与报告的理解。

14.10　小结

你已经学习了使用小规模和大规模解决方案来自动化大量的数据处理工作。你可以通过日志、监控和基于云的解决方案监控和跟踪脚本和任务以及子任务，这意味着你可以花费更少的时间跟踪这些事情，花费更多的时间在真正的报告上。你已经定义了自动化可能成功和失败的方式，并帮助创建了一套清晰的关于自动化的指南（理解所有的系统最后都会也必将失败）。你知道如何给其他的团队成员和同事权利，以后他们可以自己运行任务，同时你也学习了一些部署和设置 Python 自动化的知识。

表 14-3 总结了本章中的新概念和库。

表14-3：新的Python和编程概念和库

任务/库

目的

在远程运行脚本

在一台服务器或者其他的机器上运行代码，这样你就不必担心自己使用计算机时会受到干扰

命令行参数

在运行 Python 脚本时使用 argv 解析命令行参数

环境变量

使用环境变量参与实现脚本逻辑（例如运行在什么机器上，使用什么配置）

使用 Cron

在你的服务器或远程计算机上编写一个 shell 脚本来执行一个 cron 任务。是一种基本的自动化形式

配置文件

使用配置文件为脚本定义敏感或特殊的信息

Git 部署

使用 Git 轻松地部署代码到一台或更多的远程机器上

并行处理

Python 的 multiprocessing 模块让你能在同一时间轻松地运行很多进程，同时有共享数据和锁机制

MapReduce

有了分布式数据，你可以根据特定的属性映射数据，或者通过执行一系列的任务，之后规约数据，聚合分析

Hadoop 和 Spark

两个在云计算中工具用来执行 MapReduce 操作的工具。Hadoop 更适合于已经定义和存储的数据集，而 Spark 更适合于你有流式、特别的大或动态生成的数据时

Celery（任务队列使用和管理）

让你能够使用 Python 创建一个任务队列并管理它，允许你自动化没有清晰的开始和结束时间的任务

logging 模块

用于你的应用或脚本的内置日志模块，通过它你可以轻松地跟踪错误、调试信息和异常

smtp 和 email 模块

来自 Python 脚本的内置邮件警报

Twilio

一个有着 Python API 客户端的服务，提供电话和文本信息业务

HypChat

一个 Python API 库，可以使用 HipChat 聊天客户端构建聊天程序

日志服务

使用类似 Sentry 或 Logstash 的服务管理你的日志、错误率和异常

监控服务

使用类似 New Relic 或 Datadog 的服务监控日志、服务正常运行时间、数据库问题和性能（即发现硬件问题）

学习了本书前面章节中的丰富知识，你现在应该已经准备好去花时间构建高质量的工具，并让这些工具为你做枯燥乏味的工作了。你可以丢掉那些老套的电子表格公式，使用 Python 导入数据，运行分析，直接交付报告到邮箱。你可以真正地让 Python 管理刻板的任务（就像机器人助手一样），自己投身于报告中更关键和有挑战的部分。

第 15 章　结论

恭喜！你已经到达本书的最后一章。在刚开始阅读本书的时候，你可能只知道一点 Python 知识，也没有使用编程研究数据的经验。

现在你的经验应该相当不同了。你已经积累了寻找和清洗数据的知识和经验。你已经通过专注于问题和根据给定的数据集进行取舍，磨练了技能。你可以编写简单的正则表达式和复杂的网页抓取器。你可以在云端规模化数据和进程，并通过自动化的方式管理数据。

然而，乐趣不会止于此！作为数据处理者，你在职业生涯中还有很多要学习和实践的东西。你可以利用在本书中学到的技能和工具，继续扩展知识，进而拓展数据处理领域的界限。我们鼓励你追求卓越，继续针对数据、进程和方法提出难题。

15.1　数据处理者的职责

正如我们在本书和调查中确定的，数据和数据处理者可得出的结论是非常多的。但机会也伴随着责任。

没有数据处理的警察；但是，你在我们的书中学到了一些道德知识。你已经学到要成为一名谨慎的网页抓取者。你学习了拿起电话，询问更多的信息；学习了在展示发现时如何解释和文档化你的进程；还学习了如何针对困难话题提出难题，特别是当数据源有其他动机的时候。

作为一个数据处理者，随着你不断学习、成长，你的道德感也会增强，并会在工作和进程中指引和帮助你。从某种程度上讲，你现在已经是一名研究型记者了。你获得的结论和你问的问题可以并且能够在你的领域中产生影响。知道了这些，你就有了责任的压力。

你的职责包括：

使用你的知识、技术和能力，用于公正和有益的事情；

为你周围的人贡献知识；

回馈帮助你的社区；

挑战迄今为止你学到的道德的反面，并且继续开发。

我们鼓励你在数据处理者的职业生涯中迎接这些挑战。你是否喜欢同其他人一起工作，分享知识？成为一名导师！你是否喜欢某个开源包？成为一个代码或文档的贡献者！你是否研究过重要的社会或健康问题？将你的发现贡献给学术团体或社区！你是否经历过来自某个社区或代码的难题？向全世界分享你的故事吧。

15.2　数据处理之上

本书的课程提高了你的技能，但是你还有很多要学习的东西。根据你的技术栈和兴趣，有许多领域值得进一步探索。

15.2.1　成为一名更优秀的数据分析师

本书介绍了统计和数据分析。如果你想要真正地夯实统计和分析技能，需要花更多的时间阅读方法背后的科学知识，同样还要学习一些更专业的 Python 包，这样你在分析数据集时会有更多的能力和灵活性。

为了学习更高级的统计学知识，回归模型和数据分析背后的数学知识是必须学习的。如果你没有上过任何统计学课程，Edx 有一个很棒的来自加州大学伯克利分校的存档课程（https://courses.edx.org/courses/BerkeleyX/Stat_2.1x/1T2014/info）。如果你想通过读书来探索，Allen Downey 的《统计思维：程序员数学之概率统计（第 2 版）》1 很好地介绍了统计数学概念并且使用了 Python。Cathy O'Neill 和 Rachel Schutt 的《数据科学实战》2 提供了数据科学领域的深入分析。

1此书已由人民邮电出版社出版。——编者注

2此书已由人民邮电出版社出版。——编者注

如果你想学习 scipy 技术栈和更多关于 Python 如何帮助你进行高级数学和统计分析的知识，你很幸运。pandas 的一个主要贡献者 Wes McKinney 编写了《利用 Python 进行数据分析》，深度讲解了 pandas。pandas 的文档（http://pandas.pydata.org/pandas-docs/stable/10min.html）也是很好的入门方法。在第 7 章中，你学习了一些关于 numpy 的知识。如果你对学习 numpy 一些核心的知识感兴趣，查看 SciPy 关于基础的介绍（https://docs.scipy.org/doc/numpy/user/basics.html）。

15.2.2　成为一名更优秀的开发者

如果你想要增强 Python 技能，Luciano Ramalho 的《流畅的 Python》3 深入讨论了 Python 中的一些设计模式。我们也强烈建议你浏览世界各地最新的 Python 活动的视频（http://pyvideo.org/），并研究你感兴趣的主题。

3此书已由人民邮电出版社出版。——编者注

如果本书是你的第一本编程入门书，你可能想要参加一个计算机科学的入门课程。如果你想要自学，Coursera 提供了斯坦福大学的一个课程（https://www.coursera.org/course/cs101）。如果你想要一本介绍计算机科学背后的理论知识的在线教科书，我们推荐 Structure and Interpretation of Computer Programs（https://mitpress.mit.edu/sicp/full-text/book/book.html），由 Harold Abelson 和 Gerald Jay Sussman 编写（MIT 出版社）。

如果你想通过与其他人一起构建和工作来学习更多的开发原则，我们建议你找一个本地讨论组并参与进去。许多类似的小组会举办本地或远程的极客开发活动，所以你可以同他人一起编写代码，通过实践来学习。

15.2.3　成为一名更优秀的视觉化讲故事者

如果你对本书中视觉化讲故事的部分特别感兴趣，有很多拓展该领域知识的方式。如果你想继续研究我们使用过的库，我们强烈建议你学习 Bokeh 的入门教程（http://bokeh.pydata.org/en/latest/docs/user_guide/tutorials.html），同时利用你的 Jupyter notebooks 做实验。

学习 JavaScript 和 JavaScript 社区中流行的一些可视化库，会帮助你成为一名更好的用视觉讲故事者。Square 提供了一个关于 D3 课程的介绍（https://square.github.io/intro-to-d3/），对流行的 JavaScript 库 D3（https://d3js.org/）做了简短说明。

最后，如果你想从数据分析的角度学习视觉化讲故事背后的一些理论和想法，我们推荐 Edward Tufte 的 Visual Display of Quantitative Information（https://www.amazon.com/Visual-Display-Quantitative-Information/dp/0961392142/，Graphics 出版社）。

15.2.4　成为一名更优秀的系统架构师

如果你想学习如何规模化、部署和管理系统，我们几乎没有触及系统表层。

如果你想学习更多的 Unix 知识，萨里大学有一个简短的教程，介绍了一些优秀的概念（http://www.ee.surrey.ac.uk/Teaching/Unix/index.html）。Linux 文档项目同样有一个简短的关于 bash 编程的介绍（http://tldp.org/HOWTO/Bash-Prog-Intro-HOWTO.html）。

我们强烈建议你花时间学习 Ansible（http://docs.ansible.com/ansible/intro_getting_started.html），一个可扩展的、灵活的服务器和系统管理解决方案。如果你对规模化数据解决方案感兴趣，Udacity 提供了一个对 Hadoop 和 MapReduce 课程的介绍（https://cn.udacity.com/course/intro-to-hadoop-and-mapreduce--ud617）。你同样应该查看斯坦福大学关于 Apache Spark 的介绍（http://stanford.edu/~rezab/sparkclass/slides/itas_workshop.pdf），还有 PySpark 编程指南（https://spark.apache.org/docs/0.9.0/python-programming-guide.html）。

15.3　下一步做什么

下一步你会怎么做？你拥有很多新技能，并且能够质疑自己的假设和发现的数据。你还有一些 Python 的应用知识，而且很多有用的库触手可及。

如果你对特定的领域或数据集没有热情，你会想要发现新的学习领域，继续你作为一名数据处理者的进程和探索。很多很棒的数据分析师书写了很多激励人心的故事。这里就有一些这样的故事。

FiveThirtyEight（http://fivethirtyeight.com/），曾是《纽约时报》记者 Nate Silver 的博客，现在是一个拥有众多作家和分析师的站点，研究许多不同的主题。在弗格森大陪审团决定不起诉达伦· 威尔逊后，FiveThirtyEight 发布了一篇文章，显示收入出现了离群现象（https://fivethirtyeight.com/datalab/ferguson-michael-brown-indictment-darren-wilson/）。在一个争议性的话题中，如果有能力展示数据趋势或倾向，将可以帮助我们去除故事中的一些情绪，揭示数据真正想要诉说的东西。

《华盛顿邮报》的一项关于收入差距的研究（https://www.washingtonpost.com/news/wonk/wp/2014/11/04/affirmative-action-for-rich-kids-getting-a-job-at-dads-company/?utm_term=.80495391806a）使用税收和人口普查数据得出结论：在择业和初始薪水方面“老同学关系网”（ol'boy network）依然存在，但是在得到第一份工作后，这些关系网通常会弱化或者消失。

我们已经研究了非洲雇用童工的组织带来的一些影响，包括挖掘冲突矿产。国际特赦组织（Amnesty International）和全球见证（Global Witness）（http://www.bbc.com/news/business-32403315）近日的一个报道发现，大多数美国公司没有充分检查供应链，来确保没有使用冲突矿产。

这个世界还有数不清的未讲述的故事。如果你有激情或信念，很可能你的观点和数据处理技术可以帮助人们和社区。如果你没有激情，我们鼓励你通过跟进新闻、文档和网络上的数据分析，不断地学习。

无论你的兴趣是什么，都有无限的可能性去深入学习和掌握本书中介绍的概念。任何最能激发你兴趣的事情，就是未来学习的最好途径。我们希望这本书只是你数据处理者生涯的一个起点。

附录 A　编程语言对比

通常情况下，当你使用某种编程语言时，其他人都会问你选择这种语言的原因。为什么不用 X 或 Y 呢？ X 或 Y 的选择有许多，这取决于提问者都知道哪些语言，也取决于他是不是一个狂热的开发者。要试着理解他们问这个问题的原因，也最好思考一下自己的回答：为什么选择 Python ？本附录将对比 Python 和其他有用的编程语言，你可以据此回答上述问题，也可以深入理解我们选择编程语言的理由。

A.1　C、C++、Java与Python

与 C、C++ 和 Java 相比，Python 很容易学习，特别是对没有计算机背景的人来说。许多和你起点相同的人已经可以开发插件和有用的工具，使 Python 在数据科学和数据处理领域更加强大、更加高效。

至于技术性差异，Python 是一种高级语言，而 C 和 C++ 是低级语言。Java 是高级语言，但又有一些低级语言的特性。这是什么意思？高级语言将与计算机架构的交互抽象化，你可以输入代码字（code word，比如 for 循环或变量定义），高级语言会将其编译成计算机可以执行的代码，而低级语言会直接处理这些代码字。与高级语言相比，低级计算机语言的运行速度更快，可以更直接地控制系统，从而优化内存管理等。高级语言更容易学习，因为它已经帮你完成了大多数低级别的任务。

对于本书中的练习而言，不需要系统控制，也不需要将运行时间缩短数秒，所以我们不需要用到低级语言。虽然 Java 也是高级语言，但它的学习难度比 Python 更高，你需要更长时间才能上手。

A.2　R或MATLAB与Python

Python 有许多库（补充代码），能够实现与 R 和 MATLAB 相同的功能。这些库的名字是 pandas（http://pandas.pydata.org/）和 numpy（http://www.numpy.org/）。这些库可以处理与大数据和统计分析相关的任务。如果你想学习更多这方面的内容，你应该查阅 Wes McKinney 的《利用 Python 进行数据分析》一书。如果你在 R 或 MATLAB 方面有较强的专业背景，可以继续用这些工具来处理数据。在这种情况下，Python 是一个很好的补充工具。但是，用同一种语言负责工作流程中的每一个环节，可以让数据处理过程更容易，维护起来也更加方便。同时学习 R（或 MATLAB）和 Python，你可以根据具体项目需求来选择使用哪种语言，这样可以更好地满足项目的需求，也更加方便。

A.3　HTML与Python

解释为什么不用 HTML 处理数据，就如同解释为什么不把水放到油箱里一样，你就是不会这么做。HTML 的用途不在于此。HTML 代表 HyperText Markup Language（超文本标记语言），用来为浏览器中显示的网页提供基本结构。我们在第 3 章中讨论过 XML，与此类似，我们也可以用 Python 解析 HTML，但反过来却不行。

A.4　JavaScript与Python

JavaScript 是一种向网页添加交互与功能的语言，不要与 Java 混为一谈。JavaScript 运行在浏览器中。Python 与浏览器分离，运行在计算机系统上。Python 有大量库，可以充实数据分析的功能。JavaScript 具有与浏览器相关的额外功能。你可以利用 JavaScript 抓取网络数据并创建图表，但无法用于统计汇总。

A.5　Node.js与Python

Node.js 是一个 Web 平台，而 Python 是一种语言。有许多用 Python 编写的框架与 Node. js 类似，像 Flask 和 Django，但 Node.js 是用 JavaScript 语言编写的。Node.js 主要用的是 JavaScript，所以你可以用 JavaScript 作为后端语言。如果你后端用的是 Flask 或 Django，你可能需要学习 JavaScript 来处理前端需求。但本书大部分内容针对的都是后端过程和大型数据处理过程。Python 更方便、更容易学习，还有现成的数据处理库。正因为这一点，我们选择用 Python。

A.6　Ruby和Ruby on Rails与Python

你可能听说过 Ruby on Rails，这是基于 Ruby 语言的一个流行的 Web 框架。Python 也有许多框架：Flask、Django、Bottle、Pyramid 等，Ruby 也经常不用于 Web 框架。我们之所以选择 Python，是因为它快速的数据处理能力，而不是因为它的 Web 框架能力。我们的确会讲到数据展示，但如果你的目标是创建一个网站的话，那么你不应该阅读本书。

附录 B　初学者的 Python 学习资源

本附录列出了许多适合 Python 开发新人的学习小组和学习资源。这个列表并不全面，但它介绍了许多网站、论坛、聊天室和线下小组，在你追求成为优秀 Python 开发者的过程中可以访问并使用这些资源。

B.1　在线资源

Stack Overflow（http://stackoverflow.com/）是一个非常有用的网站，你可以在上面提问与编程和 Python 相关的问题，查看其他人的问题和回答并给出自己的回答。这个网站支持在问题中发布代码、对回答点赞，还可以在已经问过的大量问题中搜索。如果你在学习过程中遇到困难，Stack Overflow 上的某个答案可能会给你一些提示！

Python 官网（http://python.org/）是一个好工具，用来深入研究开发过程中可能用到哪些库。如果你对 Python 标准库中的某些原理有疑问，或者不知道都有哪些推荐使用的外部库，可以先从 Python 官网开始查看。

Read the Docs（https://readthedocs.org/）是一个有用的网站，许多 Python 库都会将文档保存在这里。如果你想寻找某个库的更多使用方法，可以在这个网站上查找。

B.2　线下小组

PyLadies（http://www.pyladies.com/）是一个女性工程师小组，旨在推动 Python 的多样性。 PyLadies 在世界各地都有分会，在 freenode 网站上有一个活跃的 IRC 频道，在 PyLadies 网站上还有大量的研讨会和其他有用工具。大多数分会向所有会员开放，但要看当地分会的线下聚会是否要求特定性别才能参加。

Boston Python（http://www.meetup.com/bostonpython/）是世界上最大的 Python 线下聚会之一。这个小组由一个知名开发者和教育工作者组成的动态小组管理，帮助组织研讨会、项目之夜，以及各种不同的教育活动。如果你在波士顿地区生活，你可以亲自去感受一下！

PyData（http://pydata.org/）是一家帮助创建 Python 社区和数据分析社区的机构。它们在世界各地举行线下聚会和大型会议，你在当地可能也会找到一个线下分会（或者你也可以自己创建一个新的分会）。

Meetup.com（http://www.meetup.com/）是许多技术相关的教育活动的发布网站。我们推荐搜索当地与 Python 和数据相关的线下聚会。注册网站很简单，根据你的兴趣找到的新线下聚会将会向你发送提醒，你会遇见很多人，他们和你一样都对 Python 和数据感兴趣。

Django 女孩（https://djangogirls.org/）是一个女性工程师小组，旨在推动利用 Python 大型 Web 开发框架 Django 进行 Python 开发。世界各地有许多活跃的分会，提供各种研讨会和培训班。

附录 C　学习命令行

有一个强大的开发工具，就是只用命令行来控制计算机的能力。无论你用的是哪种操作系统，懂得直接与计算机交互的方法，可以让你的数据处理任务和编程任务事半功倍。我们并不是说你要成为系统管理员，但擅于通过命令行来控制计算机是很有用的。

作为一名开发人员，最有成就感的事情之一就是能够对遇到的系统问题和代码问题进行调试。通过命令行理解计算机的运行原理，可以让你深入理解这些问题。如果你遇到了系统错误，并使用本书学到的调试技巧，你可能会更了解自己的计算机和用到的操作系统，也更了解如何用命令行更好地交互。在 Python 代码中遇到系统错误时，你就会在调试并解决这些问题时快人一步。

本附录中我们会讲到 bash（在 Mac 和许多 Linux 上使用）的基础知识，也会讲到 Windows 上 cmd 和 PowerShell 程序的基础知识。我们这里只是做一个简要的介绍，但我们建议你继续深入学习。在每一小节我们都给出了进一步阅读的建议。

C.1　bash

如果你用的是基于 bash 的命令行，你在 C.1.1 节学到的内容也同样适用于任意基于 bash 的客户端，这和你当前使用的操作系统无关。太酷了！ bash 是一种功能强大的 shell 语言（或命令行语言）。我们开始学习 bash，首先来看如何在计算机中浏览文件。

C.1.1　跳转命令

在命令行中浏览计算机可以帮你理解用 Python 如何做到这一点。停留在终端或文本编辑器中，可以让你保持专注。

我们首先来看基本命令。打开终端。打开的终端可能会位于 ~ 文件夹，代表你的 home 目录。如果你用的是 Linux，home 目录可能位于 /<home/your_computer_name>。如果你用的是 Mac，home 目录可能位于 /Users/<your_name>。想要查看你所在的文件夹，可以输入：

pwd

你应该会看到像这样的输出：

/Users/katharine

或者像这样：

/home/katharine

pwd 代表“打印工作目录”（print working directory）。你让 bash 告诉你当前所在的文件夹（或目录）。如果你是第一次学习用命令行跳转，那么这条命令很有用，特别是你想再次确认自己是否位于正确的文件夹中时。

另一个有用的命令用来查看文件夹中都有哪些文件。想要查看当前工作目录中都有哪些文件，输入：

ls

你应该会看到像这样的输出：

Desktop/
Documents/
Downloads/
my_doc.docx
...

具体内容可能会有所不同，颜色也可能不同，这与你的操作系统有关。ls 的含义是“列出清单”（list）。在调用 ls 时还可以指定额外参数，叫作标记（flag）。这些参数会改变输出的内容。试一下这个命令：

ls -l

输出应该有许多列，最后一列与只输入 ls 的输出相同。-l 标记给出了目录的详细（长）内容，其中包括所包含的文件和目录的数目，以及每一个文件和目录的权限、创建者的名字、组所有权、大小和最后修改日期。下面给出一个实例：

drwxr-xr-x  2 katharine katharine  4096 Aug 20  2014 Desktop
drwxr-xr-x 22 katharine katharine 12288 Jul 20 18:19 Documents
drwxr-xr-x 26 katharine katharine 24576 Sep 16 11:39 Downloads

这些细节可以帮你发现与权限有关的问题，还可以查看文件大小和其他信息。你还可以向 ls 传入任意目录，它都可以列出里面的内容。试着查看下载文件夹的内容（输入以下代码）：

ls -l ~/Downloads

你会看到与前面类似的很长的输出，但列出的是 Downloads 文件夹下的所有文件和目录。

现在你学会了如何列出不同文件夹下的文件，下面我们来学习如何改变当前所在文件夹。我们利用 cd 命令来“改变目录”（change directory）。试着输入：

cd ~/Downloads

现在用 pwd 查看位于哪个文件夹，用 ls 查看文件夹中的文件，你应该会发现正位于 Downloads 文件夹下。如果你想回到主文件夹的话，应该怎么做？我们知道主文件夹是上一级文件夹。你可以用 .. 跳转到上一级文件夹。试着输入：

cd ..

现在你回到了主文件夹。在 bash 中，.. 的意思是“跳转到上一级目录”。你还可以连用两次，跳转到上两级目录，像这样：cd ../..。

　在命令行中跳转目录或选择文件时，你应该可以用 Tab 键自动补全文件名和文件夹名。对于你想选择的文件或文件夹，先输入名字的第一个字母或前两个字母，然后只需按下 Tab 键，你应该会看到不同的匹配选择（帮你完成名字的拼写）。如果没有类似名字的其他文件，命令行会将名字自动补全。这个方法可以节省很多打字的时间！

现在你应该更习惯于用命令行来跳转目录。下面我们将学习如何用命令行移动文件和修改文件。

C.1.2　修改文件

利用 bash 移动文件、复制文件和创建文件都很简单。我们先来看创建新文件。首先，跳转到 home 目录（cd ~）。然后输入下列内容：

touch test_file.txt

然后再继续输入 ls。你应该会看到有一个叫作 test_file.txt 的新文件。touch 可以用来创建新文件。这个命令会寻找叫这个名字的文件。如果有这个文件的话，会改变最后一次修改的时间戳，但不会修改文件内容；如果文件不存在，会创建这个文件。

Atom shell 命令

如果你的文本编辑器是 Atom.io（https://atom.io/），你可以利用下面这个命令轻松将这个文件（或任何文件）用 Atom 打开：

atom test_file.txt

如果报错的话，你可能没有安装命令行选项。安装方法是：按 Shift-Cmd-P 键打开命令面板，然后运行 Install Shell Commands 命令。

想要查看Atom 所有命令行选项，可以输入atom --help。

对于之前创建的文件，我们试着将其复制到下载文件夹中：

cp test_file.txt ~/Downloads

这里我们的意思是：“将 test_file.txt 复制到 ~/Downloads 文件夹中。”bash 知道 ~/Downloads 是一个文件夹，所以会自动将文件复制到那个文件夹里面。如果我们想要在复制文件的同时改变文件名，可以这么做：

cp test_file.txt ~/Downloads/my_test_file.txt

这个命令的作用是让 bash 将测试文件复制到下载文件夹中，并将复制后的文件命名为 my_test_file.txt。现在你的下载文件夹中应该有测试文件的两份副本：一个用的是原来的名字，一个用的是新名字。

　如果你需要多次运行同一个命令，只需按向上键，在命令行历史中查找。如果你想查看最近所有的命令行历史，可以输入 history。

有时你并不想复制文件，而是想要移动文件或重命名文件。在 bash 中，我们可以用同一个命令来移动文件和重命名文件：mv。我们首先对在主文件夹中创建的文件进行重命名：

mv test_file.txt empty_file.txt

这里我们告诉 bash：“将名为 test_file.txt 的文件移动到名为 empty_file.txt 的文件。”输入 ls，你应该会看到已经没有 test_file.txt 了，但现在有一个 empty_file.txt。我们只是利用“移动”来对文件重命名。我们还可以利用 mv 在文件夹之间移动文件：

mv ~/Downloads/test_file.txt .

这里我们的意思是：“将下载文件夹中的 test_file.txt 移动到这里。”在 bash 中，. 代表你的工作目录（就像 .. 代表当前目录的“上一级”文件夹一样）。现在输入 ls，你应该可以看到主文件夹中又有一个 test_file.txt 文件了。你还可以输入 ls ~/Downloads，现在这个文件已经不在下载文件夹中了。

最后，你可以用命令行删除文件。你可以用 rm（remove）命令来做到这一点。试着输入：

rm test_file.txt

现在再输入 ls，你会发现 test_file.txt 已经从文件夹中删掉了。

　与用鼠标删除文件不同，用命令行删除文件是真正的删除。没有“回收站”可以恢复文件，所以使用 rm 时一定要小心，对你的计算机和代码一定要定期按时备份。

现在你知道利用 bash 如何移动、重命名、复制和删除文件，下面我们继续学习在命令行中运行文件。

C.1.3　运行文件

利用 bash 运行文件是相当简单的。在第 3 章中你可能已经学过运行 Python 文件，你只需运行：

python my_file.py

其中 my_file.py 是一个 Python 文件。

　对于编程用到的大多数语言来说，只输入语言的名字（python、ruby、R）和文件名（并带有正确的文件路径或文件位置）就可以运行。如果你在用某种语言运行文件时遇到问题，我们建议以语言的名字和“命令行选项”（command-line options）为关键词在网络上进行搜索。

作为一名 Python 开发者，你还会用到其他运行命令。表 C-1 中给出了其中一些命令，你可以先了解一下，在安装和运行外部库时可能会用到。

表C-1：bash中的运行命令

命令

使用案例

更多文档

sudo

以 sudo 或（超级）用户的身份运行下面的命令。在修改文件系统的核心部分或安装外部包时通常需要用到 sudo

https://en.wikipedia.org/wiki/Sudo

bash

运行 bash 文件，或回到 bash shell

http://ss64.com/bash/

./configure

运行软件包的配置设定（从源代码安装软件包的第一步）

https://en.wikipedia.org/wiki/GNU_build_system#GNU_Autoconf

make

配置完成后运行 makefile，编译代码准备安装（从源代码安装软件包的第二步）

http://www.computerhope.com/unix/umake.htm

make install

运行 make 编译好的代码，将软件包安装到计算机中（从源代码安装软件包的第三步）

http://www.codecoffee.com/tipsforlinux/articles/27.html

wget

访问 URL，并下载 URL 里包含的文件（适用于下载软件包或文件）

http://www.gnu.org/software/wget/manual/wget.html

chown

改变文件或文件夹的所有权。通常与 chgrp 一起使用，用来改变文件的所属群组。如果你需要移动文件让另一个用户可以运行这些文件，那么这个命令是很有用的

http://linux.die.net/man/1/chown

chmod

改变文件或文件夹的权限，通常是使文件可执行，或对另一类用户或用户组可见

http://ss64.com/bash/chmod.html

在使用命令行的过程中，你可能会遇到其他各种各样的命令和文档。我们建议你花点时间去学习、使用并提问。bash 是一种不同的语言，需要慢慢去学习它的特点和用法。最后，我们要向你介绍利用 bash 来搜索文件或文件内容。

C.1.4　利用命令行进行搜索

在 bash 中，搜索文件和在文件内搜索都相对简单，也有很多种方法。我们将向你展示几种方法。首先，我们将会用一个命令搜索文件中的文本。我们先用 wget 下载一个文件：

wget http://corpus.byu.edu/glowbetext/samples/text.zip

这应该会下载一个文本语料库，我们可以在里面搜索。将文本解压到一个新文件夹，只需输入：

mkdir text_samples
unzip text.zip text_samples/

现在在新文件夹 text_samples 中应该有许多文本语料库文件。输入 cd text_samples 切换到这个目录中。我们利用一个叫作 grep 的工具来搜索这些文件的内容：

grep snake *.txt

这里的命令是告诉 bash，在这个文件夹所有以 .txt 结尾的文件中搜索字符串 snake。在 7.2.6 节可以学到更多关于通配符的内容，但 * 几乎总可以用作通配符，意思是“任意匹配的字符串”。

运行上面的命令，你应该会看到输出大量匹配的文本。grep 会返回所有匹配文件中包含目标字符串的所有行。比如当你要修改某个函数，想在一个很大的仓库中找到包含这个函数的文件时，这一命令特别有用。如果你想打印出前后的几行，还可以向 grep 传入额外的参数和选项。

　想查看任意 bash 命令的选项，只需先输入该命令，然后输入空格和 --help 即可。输入 grep --help，然后阅读 grep 的一些额外选项和特性。

另一个简洁的工具是 cat。它只是打印出你指定的文件内容。这个命令很有用，特别是需要将输出内容通过“管道”（pipe）传递到其他地方时。在 bash 中，| 字符可以将你希望对文件或文本执行的一系列操作合并在一起。例如，我们将其中一个文件的内容 cat 出来，然后用 grep 在输出中搜索：

cat w_gh_b.txt | grep network

首先返回的是 w_gh_b.txt 文件的所有文本，然后通过“管道”将输出传递给 grep，然后在其中搜索单词 network 并在命令行中返回包含该词的文本行。

我们还可以对 bash 历史记录做同样的管道操作。试着输入：

history | grep mv

这一命令可以帮你找到在学习 bash 的过程中用过的命令并复用，你可能已经不记得这些命令了。

我们来进一步学习搜索，学习如何查找文件。首先，我们将用到一个叫作 find 的命令，用来查找匹配的文件名，还可以遍历所有子目录并查找匹配的文件。我们在当前文件夹及其子文件夹中找出所有文本文件：

find . -name "*.txt" -type f

这一命令的作用是，（在这个文件夹及其所有子文件夹中）寻找相应的文件，文件名以 .txt 结尾，文件类型为 f（标准文件，而不是目录，目录的类型符号为 d）。你应该会看到输出许多匹配的文件名。下面，我们将这些文件通过管道传递给 grep 命令：

find . -name "*.txt" -type f | xargs grep neat

这里我们让 bash 做的是，“找到与上面相同的文本文件，然后在这些文件中搜索单词 neat”。我们用到了 xargs 命令（https://en.wikipedia.org/wiki/Xargs），这样才能将 find 的输出通过管道正确地传递给 grep。不是所有的管道操作都需要用到 xargs，但当 find 命令的输出结果不完全相同时，这一命令十分有用。

你已经学会搜索和查找的一些实用技巧，特别是你要处理的代码和项目越来越庞大、越来越复杂时，这些技巧尤其有用。关于这一话题，我们还为你提供了更多资源和阅读材料。

C.1.5　更多资源

在互联网上有许多优秀的 bash 学习资源（http://wiki.bash-hackers.org/scripting/tutoriallist）。Linux 文档计划（http://www.tldp.org/LDP/Bash-Beginners-Guide/html/）中也有许多适合初学者的优秀教程，里面会讲到一些高级的 bash 编程知识。O'Reilly 出版了一本很棒的 bash Cookbook（http://shop.oreilly.com/product/9780596526788.do），你也可以用这本书开始学习。

C.2　Windows cmd/PowerShell

Windows 命令行（现在再加上 PowerShell，https://en.wikipedia.org/wiki/Windows_PowerShell），或者叫 cmd，是一个功能强大的基于 DOS 的程序。在不同版本的 Windows 和 Windows 服务器中，语法是完全相同的。无论你学的是 Python 还是其他语言，学会这些语法都可以让你成为一个更加优秀的程序员。

C.2.1　跳转命令

用 cmd 浏览文件非常简单。首先打开 cmd 程序查看当前目录。输入：

echo %cd%

这个命令的作用是，告诉 cmd 你想要返回（或输出）%cd%，即当前目录（current directory）。你应该会看到像这样的输出：

C:\Users\Katharine>

想列出当前目录的所有文件，输入以下代码：

dir

你应该会看到类似这样的输出：

13.03.2015  16:07    <DIR>        .ipython
11.09.2015  19:05    <DIR>        Contacts
11.09.2015  19:05    <DIR>        Desktop
11.09.2015  19:05    <DIR>        Documents
11.09.2015  19:05    <DIR>        Downloads
11.09.2015  19:05    <DIR>        Favorites
10.02.2014  15:15    <DIR>        Intel
11.09.2015  19:05    <DIR>        Links
11.09.2015  19:05    <DIR>        Music
11.09.2015  19:05    <DIR>        Pictures
13.03.2015  16:26    <DIR>        pip
11.09.2015  19:05    <DIR>        Saved Games

dir 也有许多可用的选项（http://ss64.com/nt/dir.html），可以排序、分组或显示更多信息。我们来看一下 Desktop 文件夹。

dir Desktop /Q

我们让 cmd 显示 Desktop 目录中的所有文件，以及这些文件的所有者。你应该会看到文件名的前半部分是每个文件的所有者（比如 MY-LAPTOP\Katharine\backupPDF）。在查看文件夹和文件时这一命令非常有用。还有许多好用的选项，可以显示子文件夹，或者按最后一次修改的时间戳排序。

输入下列代码跳转到 Desktop 文件夹：

chdir Desktop

现在输入 echo %cd% 查看当前目录，你应该会发现与之前的不同。想跳转到上一级文件夹，只需输入 ..。例如，如果我们想跳转到当前目录的上一级文件夹，可以输入：

chdir ..

你还可以把多个“上一级文件夹”符号连起来（chdir ..\.. 可以跳转到上两级文件夹，以此类推）。根据你的文件结构不同，如果当前文件夹没有上一级目录的话，你可能会得到一个错误（比如你位于文件系统的根目录下）。

想返回 home 目录，只需输入：

chdir %HOMEPATH%

你应该回到了我们开始所在的第一个文件夹。现在我们可以用 cmd 在目录间跳转，下面我们继续学习创建、复制和修改文件。

C.2.2　修改文件

首先，我们来创建一个可用于修改的新文件：

echo "my awesome file" > my_new_file.txt

用 dir 查看文件夹中的文件，现在你应该可以看到 my_new_file.txt。在文本编辑器中打开这个文件，你可以看到文件里面写的是“my awesome file”。如果你用的是 Atom，你可以在 cmd 中直接打开 Atom（参见 C.1.2 节）。

有了这个文件，我们试着将其复制到一个新文件夹中：

copy my_new_file.txt Documents

现在用下列命令列出 Documents 文件夹中所有文件：

dir Documents

我们应该会看到，已经将 my_new_file.txt 成功复制到这里。

　你可以用 Tab 键自动补全文件名和路径，简化输入。试着输入 copy my，然后按 Tab 键。cmd 应该能够猜出你想输入的是 my_new_file.txt 文件，自动补全文件名。

我们还想移动文件或重命名文件。想用 cmd 移动文件，我们可以用 move 命令。试着输入：

move Documents\my_new_file.txt Documents\my_newer_file.txt

如果现在列出 Documents 目录下的文件，你应该会发现没有 my_new_file.txt 了，只有一个 my_newer_file.txt。move 可用于重命名文件（正如上面我们所做的那样）或者移动文件或文件夹。

最后，你可能想要删除不需要的文件。想用 cmd 做到这一点，你可以用 del 命令。试着输入：

del my_new_file.txt

现在查看当前文件夹下的文件，你应该看不到 my_new_file.txt 了。注意，这个命令会完全删除文件。只有在你完全不需要这个文件时才能用这个命令。定期做硬盘备份是很有用的，以防出现任何问题。

我们已经学过了修改文件，下面我们来学习如何在 cmd 中运行文件。

C.2.3　运行文件

想要在 Windows cmd 中运行文件，通常需要输入语言的名字，然后输入文件路径。例如，想运行一个 Python 文件，你需要输入：

python my_file.py

只要 my_file.py 位于同一文件夹下，就会运行这个文件。只需在 cmd 中输入 .exe 文件的全名和路径，然后按 Enter 键，就可以运行这个文件。

　与安装 Python 一样，你需要检查安装好的可执行文件的安装包和文件路径是否包含在 Path 变量中（详情可查阅 1.2.2 节）。这个变量为 cmd 保存许多可执行字符串。

想了解更多强大的命令行运行命令，我们推荐学习 Windows PowerShell——一种强大的脚本语言，用来编写脚本并用简单的命令行来运行这些脚本。计算机世界（Computerworld）有一篇很好的 PowerShell 入门文章（http://www.computerworld.com/article/2512066/microsoft-windows-introduction-to-windows-powershell.html）。

想要在命令行中运行安装好的程序，你可以使用 start 命令。试着输入：

start "" "http://google.com"

这个命令应该会打开你的默认浏览器，然后跳转到 Google 首页。更多内容可查阅 start 命令的文档（https://www.microsoft.com/resources/documentation/windows/xp/all/proddocs/en-us/start.mspx?mfr=true）。

我们已经学习了用命令行如何运行文件，下面研究如何搜索和查找计算机中的文件和文件夹。

C.2.4　利用命令行进行搜索

首先下载一个可用的语料库。如果你用的是 Windows Vista 或更新的版本，你应该能够运行 PowerShell 命令。试着输入下列命令来加载 PowerShell：

powershell

你应该会看到类似这样的新提示符：

Windows PowerShell
...
PS C:\Users\Katharine>

现在我们已经进入了 PowerShell，我们来下载一个用于查找的文件（注意，这两行命令应该在同一行中输入，被分成两行是为了适应页面宽度）：

Invoke-WebRequest -OutFile C:\Downloads\text.zip
 http://corpus.byu.edu/glowbetext/samples/text.zip

如果你安装的 PowerShell 不是 3.0 版或更高版本，上面这个命令会报错。如果有报错的话，尝试输入下面这个命令，可适用于更早版本的 PowerShell：

Invoke-WebRequest -OutFile C:\Downloads\text.zip
 http://corpus.byu.edu/glowbetext/samples/text.zip

这些命令是用 PowerShell 将一个单词语料库下载到计算机中。我们创建一个新目录，可以将解压后的文件放在里面：

mkdir Downloads\text_examples

现在我们要向 PowerShell 中添加一个新函数，用来提取压缩的文件。输入下列命令：

Add-Type -AssemblyName System.IO.Compression.FileSystem
function Unzip
{
    param([string]$zipfile, [string]$outpath)

        [System.IO.Compression.ZipFile]::ExtractToDirectory($zipfile, $outpath)
}

定义好了这个函数，我们可以用它来解压文件。试着将下载内容解压到新文件夹中：

Unzip Downloads\text.zip Downloads\text_examples

输入 exit 就可以退出 PowerShell。提示符应该会变回 cmd 的标准提示符。输入 dir Downloads\text_examples，你应该会看到许多下载好的语料库文本文件。我们用 findstr 在这些文件中查找：

findstr "neat" Downloads\text_examples\*.txt

你应该会看到控制台中滚动输出了许多文本。它们是包含单词 neat 的文本文件中的匹配行。

有时你想搜索特定的文件名，而不是文件中的字符串。你可以用 dir 命令加一个过滤器（filter）来做到这一点：

dir -r -filter "*.txt"

这应该会找出主文件夹下所有子文件夹中的 .txt 文件。如果你需要继续在这些文件中搜索，可以使用管道操作。| 字符可以将第一个命令的输出通过管道传递给下一个命令。比如说，我们可以用管道找到包含特定函数名的所有 Python 文件，或找到包含特定国家名称的所有 CSV 文件。我们试一下，将 findstr 的输出通过管道传递给 find 命令：

findstr /s "snake" *.txt | find /i "snake" /c

这段代码的作用是，首先找出包含单词 snake 的文本文件，然后利用 find 计算单词 snake 在这些文件中出现的次数。可以看出，学习更多 cmd 命令及其用法将有助于大大简化数据处理人员和开发人员的很多任务，如搜索文件、运行代码、管理工作等。关于这一话题，本附录介绍了一些内容，你可以在此基础上深入学习。

C.2.5　更多资源

想要学习如何将 cmd 用于日常编程和数据处理工作，有许多可以查找 cmd 命令的优秀在线资源（http://ss64.com/nt/）。

如果你想学习更多 PowerShell 的知识，以及如何用 PowerShell 创建强大的脚本并用于 Windows 服务器和计算机，可以阅读一些教程，如微软的 Getting Started with PowerShell 3.0 教程（https://mva.microsoft.com/en-us/training-courses/getting-started-with-powershell-30-jump-start-8276?l=r54IrOWy_2304984382）。你还可以参考 O'Reilly 出版的 Windows PowerShell Cookbook（http://shop.oreilly.com/product/0636920024132.do）开始编写你的第一个脚本。

附录 D　高级 Python 设置

在本书的前文中，我们创建了系统 Python。为什么呢？因为它使用起来快速简单。在开始使用更加复杂的库和工具时，你可能会需要一个更加高级的设置。机器上高级的 Python 设置在你组织项目时是很有帮助的。在你需要同时运行 Python 2.7 和 Python 3+ 时，一个高级设置也很有帮助。

　在本附录中，我们会带你用专家模式设置 Python 环境。因为这里涉及很多依赖，所以指南中的部分内容与你的经验不符是完全有可能的。为了解决这些问题，我们建议你去互联网寻找或询问如何继续。

我们会首先安装一些核心工具，之后安装 Python（2.7，但是你也可以安装 3+）。最后，我们会安装和设置一些虚拟环境，独立出项目，这样你可以对每一个项目使用不同版本的 Python 库。

本附录中的指南覆盖 Mac、Windows 和 Linux。随着你阅读每一个步骤，仔细跟随你的特定操作系统的指令。

D.1　第1步：安装GCC

GCC（GNU Compiler Collection，GNU 编译器集合）的目的是将用 Python 编写的代码转换为机器可以理解的东西——字节码。

在 Mac 上，GCC 包含在 Xcode（https://developer.apple.com/xcode/）和命令行工具（https://developer.apple.com/downloads/）中。你需要下载其中的任意一个。但是对于这两个方式，你都会需要一个 Apple ID（http://bit.ly/create_appleid）用于下载。同样，Xcode 需要花费一些时间从互联网上下载（我下载了 20 分钟），所以可以计划休息一下。如果你关心时间或内存的使用，选择命令行工具取代 Xcode。安装 Xcode 或命令行工具不会花费太长的时间。在继续安装 Homebrew 之前，确保 Xcode 或命令行工具已经安装。

如果你正在使用 Windows，Jeff Preshing 写了一个关于安装 GCC 的很有帮助的教程（http://preshing.com/20141108/how-to-install-the-latest-gcc-on-windows/）。如果你正在使用 Linux，GCC 安装在绝大多数基于 Debian 的操作系统上，或者你可以通过运行 sudo ap- get install build-essential 安装它。

D.2　第2步：（只在Mac上）安装Homebrew

Homebrew 管理 Mac 系统上的包，这意味着你可以输入命令，Homebrew 会在安装中帮助你。

　安装 Homebrew 之前，确保已经安装 Xcode 或命令行工具。否则，你会在安装 Homebrew 时遇到错误。

为了安装 Homebrew，打开控制台，输入这行代码（跟随任何出现的提示，包括请求允许安装 Homebrew 的权限）：

$ ruby -e "$(curl -fsSL https://raw.github.com/Homebrew/homebrew/go/install)"

注意命令的输出。Homebrew 建议运行 brew doctor 检查并对任何安装中的问题给出警告。根据你系统状态的不同，你可能有很多需要追查的问题。如果你没有收到警告，那么继续下一个步骤。

D.3　第3步：（Mac系统）告诉系统去哪里寻找 Homebrew

为了使用 Homebrew，你需要告诉系统它位于哪里。为此，添加 Homebrew 到 .bashrc 文件，或者你正在使用的其他 shell（即，如果你有一个自定义的 shell，需要在这里添加它）。.bashrc 文件可能在系统中还不存在；如果它存在的话，会隐藏在 home 目录里。

在你输入 ls 命令时，所有文件名以一个“.”开始的文件是不会显示的，除非你明确地请求显示所有文件。这一做法的目的有两面性。首先，如果文件是不可见的，你就更不可能错误地删除或编辑它们。其次，这些文件类型并不经常使用，所以隐藏它们使得系统外观更清晰。

让我们看一下，在通过给 ls 命令添加额外的命令，展示所有的文件后，我们目录的样子。确保你正位于 home 目录中，然后输入下面的命令：

$ ls -ag

你的输出会看起来类似于：

total 56
drwxr-xr-x+ 17  staff   578  Jun 22 00:08 .
drwxr-xr-x   5  admin   170  May 29 09:49 ..
-rw-------   1  staff     3  May 29 09:49 .CFUserTextEncoding
-rw-r--r--@  1  staff 12292  May 29 09:44 .DS_Store
drwx------   8  staff   272  Jun 10 00:45 .Trash
-rw-------   1  staff   389  Jun 22 00:07 .bash_history
drwx------   4  staff   136  Jun 10 00:35 Applications
drwx------+  5  staff   170  Jun 22 00:08 Desktop
drwx------+  3  staff   102  May 29 09:49 Documents
drwx------+ 10  staff   340  Jun 11 23:47 Downloads
drwx------@ 43  staff  1462  Jun 10 00:29 Library
drwx------+  3  staff   102  May 29 09:49 Movies
drwx------+  3  staff   102  May 29 09:49 Music
drwx------+  3  staff   102  May 29 09:49 Pictures
drwxr-xr-x+  5  staff   170  May 29 09:49 Public

我们没有 .bashrc 文件，所以需要创建一个。

如果你有 .bashrc 文件，需要备份一个，以防发生任何问题。通过命令行创建一个 .bashrc 的副本非常简单。直接运行下面的命令来复制 .bashrc 到一个新的文件，.bashrc_bkup:

$ cp .bashrc .bashrc_bkup

为了创建一个 .bashrc 文件，首先需要确认我们拥有一个 .bash_profile 文件。如果我们添加了一个 .bashrc 文件，而没有 .bash_profile，计算机不会知道拿这个文件做什么。

在开始之前，先检查一下我们是否有 .bash_profile 文件。如果有，它会出现在 ls -ag 命令产生的目录列表中。如果没有的话，那么你需要创建它。

如果你有一个 .bash_profile 文件，需要将它备份，这样如果有任何问题，可以恢复至原始的设置。运行下面的命令来复制 .bash_profile 到一个新的文件，名为 .bash_bkup：

$ cp ~/.bash_profile ~/.bash_profile_bkup

之后运行这个命令来复制它到桌面，同时重新命名它：

$ cp ~/.bash_profile ~/Desktop/bash_profile

如果你正在使用一个已存在的 .bash_profile 文件，启动编辑器，打开你复制到桌面的那个版本的文件。添加下面的代码到文件末尾。这段代码的意思是“如果这里存在一个 .bashrc 文件，那么使用它”。

# Get the aliases and functions
if [ -f ~/.bashrc ]; then
    . ~/.bashrc
fi

如果还没有 .bash_profile 文件，你需要在编辑器中用这些内容创建一个新文件。保存文件到桌面，命名为 bash_profile，开头没有句号。

　确保你检查了 home 目录下不存在 .bash_profile 和 .bashrc 文件。如果它们存在的话，确保跟随指引在继续之前创建原始文件的备份。如果没有做这些的话，当执行下面的命令时，你可能重写原始文件，这可能引发问题。

现在回到控制台，运行下面的命令来重命名文件，并且从桌面移动到 home 目录：

$ mv ~/Desktop/bash_profile .bash_profile

现在，如果运行 ls -al ~/，你会在 home 目录中看到一个 .bash_profile 文件。如果执行 more .bash_profile，你会看到代码调用了 .bashrc。

现在有了一个 .bashrc_profile 文件，引用 .bashrc 文件，让我们编辑 .bashrc 文件。首先在文本编辑器中打开当前的 .bashrc 文件或一个新文件。添加下面的代码到 .bashrc 文件的末尾。这会在设置中添加 Homebrew 的位置到你的$PATH 变量。新的 path 会比老的$PATH 优先级更高：

export PATH=/usr/local/bin:/usr/local/sbin:$PATH

现在，保存这个文件到桌面，命名为 bashrc，没有句号。

为你的代码编辑器使用命令行快捷键

我们在 .bashrc 文件中更新设置时，创建一个快捷方式来从命令行工具启动代码编辑器。这不是必需的，但是会使浏览文件目录和在代码编辑器中打开文件更简单。使用你的 GUI 来浏览文件不会像这样有效。

如果你正在使用Atom，在安装了 Atom 后，你已经有了可用的快捷方式和 shell 命令（https://discuss.atom.io/t/open-file-project-from-terminal-command-line/1305）。Sublime 同样有在OS X 上可用的命令（https://www.sublimetext.com/docs/2/osx_command_line.html）。

如果正在使用其他的代码编辑器，你可以尝试输入程序名称来看它是否启动，或者输入程序名和 --help 来看它是否具有命令行帮助。我们同样建议搜索“< 你的程序名称 >命令行工具”，来看是否有任何有帮助的结果。

回到控制台，运行下面的命令来重命名文件，并且将它们从桌面移动到你的 home 目录：

$ mv ~/Desktop/bashrc .bashrc

这时，如果你运行 ls -al ~/，你会看到 home 目录中有 .bashrc 文件和一个 .bash_profile 文件。让我们通过在控制台中打开一个新的窗口来检验 $PATH 变量。为了检验这个变量，运行下面的命令：

$ echo $PATH

你会得到像下面这样的输出：

/usr/local/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin

无论输出是什么，你会看到变量的信息（/usr/local/bin:/usr/local/sbin）添加到了我们的 .bashrc 文件中，突出了返回值。

如果你没有在变量中看到新的值，确保你打开了一个新的窗口。设置的改变不会加载到当前的终端窗口，除非你明确地应用文件到当前的终端［查看 bash source（http://ss64.com/bash/source.html）命令获取更多的信息］。

D.4　第4步：安装Python 2.7

为了在 Mac 上安装 Python 2.7，运行下面的命令：

$ brew install python

如果你想要跟进 Python 3+，你可以安装它。在 Mac 上安装 Python 3+，运行：

$ brew install python3

对于 Windows，你需要跟随第 1 章中的指引，正确地通过 Windows 安装程序安装。对于 Linux，很可能你已经安装了 Python。在 Linux 中，通过安装一些 Python 开发者包（http://stackoverflow.com/questions/6230444/how-to-install-python-developer-package）来安装一些额外的工具是好的想法。

在完成所有的操作后，测试它是否工作正常。

在终端启动你的 Python 解释器：

$ python

然后，运行下面的代码：

import sys
import pprint
pprint.pprint(sys.path)

Mac 输出看起来类似这样：

>>> pprint.pprint(sys.path)
['',
 '/usr/local/lib/python2.7/site-packages/setuptools-4.0.1-py2.7.egg',
 '/usr/local/lib/python2.7/site-packages/pip-1.5.6-py2.7.egg',
 '/usr/local/Cellar/python/2.7.7_1/Frameworks/Python.framework/Versions/
 2.7/lib/python27.zip',
 '/usr/local/Cellar/python/2.7.7_1/Frameworks/Python.framework/Versions/
 2.7/lib/python2.7',
 '/Library/Python/2.7/site-packages',
 '/usr/local/lib/python2.7/site-packages']

如果正在使用 Mac，你收到的输出应该有很多以 /usr/local/Cellar/ 开头的文件路径。如果没有看见它，你可能没有在控制台窗口重新加载设置。关闭窗口，再打开一个新的窗口，重新尝试一次。如果这没有解决过程中出现的任何问题，返回到设置的最开始，重新执行步骤。

调试安装错误是一个学习与积累经验的过程。如果你遇到了在本节中没有描述的错误，在浏览器中打开你最喜欢的搜索引擎，搜索这个错误。你可能不是第一个经历该问题的人。如果你成功完成了本节内容，请继续下一步。

D.5　第5步：安装virtualenv（Windows、Mac、Linux）

我们已经设置了第二个 Python 实例，但是现在我们想要设置一种创建独立的 Python 环境的方式。通过隔离不同的项目和依赖，virtualenv 可以帮助我们达到这个目的。如果有多个项目，我们可以确保每个需求间没有冲突。

为了安装 virtualenv，我们需要 Setuptools（http://pythonhosted.org//setuptools/）。安装完 Python，Setuptools 也就跟着安装了。Setuptools 的一部分是名为 pip 的命令行工具，我们使用它来安装 Python 包。

为了安装 virtualenv（http://virtualenv.readthedocs.org/en/latest/），在命令行中运行下面的命令：

$ pip install virtualenv

在运行这个命令后，输出的一部分应该为：Successfully installed virtualenv。如果你得到这个信息，那么所有事情运行良好。如果没有，那么你有其他需要解决的问题，可以在网络上搜索，寻求帮助。

D.6　第6步：创建一个新目录

在继续之前，让我们创建一个目录，来维护项目相关的内容。根据个人喜好确定位置。为便于访问和备份，大多数人在用户 home 目录中创建文件夹。你可以将目录放在任何你喜欢的既有用又好记的地方。在 Mac 系统上，要在 home 目录中创建一个 Projects 文件夹，可在终端运行下面的命令：

$ mkdir ~/Projects/

或者对于 Windows：

> mkdir C:\Users\_your_name_\Projects

之后在该文件夹中创建一个文件夹，来保存我们编写的数据处理代码。在 Mac 上，你可以通过运行下面的命令达到这个目的：

$ mkdir ~/Projects/data_wrangling
$ mkdir ~/Projects/data_wrangling/code

对于 Windows，使用下面的代码：

> mkdir C:\Users\_your_name_\Projects\data_wrangling
> mkdir C:\Users\_your_name_\Projects\data_wrangling\code

最后，在 home 目录中添加一个隐藏文件夹，在 virtualenv 环境中使用。在 Mac 上使用下面的命令：

$ mkdir ~/.envs

对于 Windows 运行下面的命令 :

> mkdir C:\Users\_your_name_\Envs

如果你想要在 Windows 中隐藏文件夹，可以通过在命令行中编辑属性做到：

> attrib +s +h C:\Users\_your_name_\Envs

要取消隐藏，可以直接去除属性：

> attrib -s -h C:\Users\_your_name_\Envs

这时，如果我们查看 Projects 文件夹的内容，应该会看到两个空的子文件夹，名为 code 和 envs。

D.7　第7步：安装virtualenvwrapper

virtualenv 是一个很棒的工具，但是 virtualenvwrapper（http://virtualenvwrapper.readthedocs.org/en/latest/）让 virtualenv 更易于访问和使用。然而它有很多特性（https://virtualenvwrapper.readthedocs.io/en/latest/#features）没有在这个附录中提到，其中最强大的特性也是最简单的特性之一。

它需要一个类似下面的命令：

source $PATH_TO_ENVS/example/bin/activate

将其变成这样：

workon example

D.7.1　安装virtualenvwrapper（Mac和Linux）

在 Mac 和 Linux 上安装 virtualenvwrapper，运行下面的命令：

$ pip install virtualenvwrapper

检查输出的倒数第二行，确保所有的安装正确。对我来说，这行代码的意思是成功地安装了 virtualenvwrapper virtualenv-clone stevedore。

更新你的.bashrc

你同样需要添加一些设置到 .bashrc 文件中。我们会复制文件，编辑它，然后将它放回原来的位置。

首先，创建一个 .bashrc 文件的备份。如果已经有了一份，你可以跳过这个步骤。如果你从一个新文件开始，你会创建 .bashrc 文件的第一个备份。为了完成这件事，输入下面的命令：

$ cp ~/.bashrc ~/.bashrc_bkup

　我在 GitHub 上存储了设置文件（https://github.com/jackiekazil/dotfiles），这样我总是有一个可用的备份。在我犯了错误或者计算机死机的时候，我总是可以恢复它。当你不断调整文件，产生 20 个备份时，确保 home 文件夹不会产生混乱。你很少会编辑 .bashrc 文件，但是在编辑这类文件之前，要备份它们。

使用编辑器打开 .bashrc 文件，在文件末尾添加下面三行代码。如果你没有为 Projects 文件夹使用相同的位置，你需要相应地调整文件的路径：

export WORKON_HOME=$HOME/.envs                                   ➊
export PROJECT_HOME=$HOME/Projects/                              ➋
source /usr/local/bin/virtualenvwrapper.sh                       ➌

❶ 定义 WORKON_HOME 变量。这是存储 Python 环境变量的地方。这应该与你之前创建的环境文件夹匹配。

❷ 定义 PROJECT_HOME 变量。这是你存储代码的地方。这应该与你之前创建的 Projects（或 linux 项目）文件夹匹配。

❸ 初始化 virtualenvwrapper，它让 virtualenv 更容易使用。

完成后，保存文件并打开一个新的终端窗口来加载新的设置。现在你就有了一个易于使用的带有虚拟环境的命令集。

D.7.2　安装virtualenvwrapper-win（Windows）

对于 Windows 系统，有一些额外的可选步骤会让你更轻松。首先，你需要安装 Windows 版本的 virtualenvwrapper（https://pypi.python.org/pypi/virtualenvwrapper-win）。你同样可以通过运行下面的命令完成这件事：

>pip install virtualenvwrapper-win

你同样需要添加 WORKON_HOME 环境变量。默认情况下，virtualenvwrapper 会希望你在 User 文件夹下有一个名为 Env 的文件夹。如果你想要为虚拟环境设置自己的文件夹，那就设置吧，然后添加 WORKON_HOME 变量，设置为合适的文件路径。如果你在之前没有设置过环境变量，需要一个快速的指南，在 Stack Overflow 上有一个很棒的教程（https://superuser.com/questions/284342/what-are-path-and-other-environment-variables-and-how-can-i-set-or-use-them）。

　为了在 Windows 上与多个版本的 Python 一起工作，安装 pywin（https://github.com/davidmarble/pywin）也是一个很好的想法；这便于你在不同版本的 Python 间切换。

D.7.3　测试你的虚拟环境（Windows、Mac、Linux）

在圆满完成这一节之前，让我们运行一些测试程序来确保一切正常工作。在一个新的控制台窗口中，创建一个新的虚拟环境，名为 test:

mkvirtualenv test

你的输出应该看起来类似这样：

New python executable in test/bin/python2.7
Not overwriting existing python script test/bin/python (you must use
  test/bin/python2.7)
Installing setuptools, pip...done.

如果你想要使用 Python 3+ 创建环境，而不是 Python 2.7，那么你要定义 python 变量，使其指向 Python 3。首先，定义 Python 3 实例的位置：

which python3

你的输出应该看起来类似这样：

/usr/local/bin/python3

现在，在你的 mkvirtualenv 命令中使用它来定义 Python 3+ 环境：

mkvirtualenv test --python=/usr/local/bin/python3

你应该会看到“(test)”添加到了控制台提示的开端。这意味着环境现在已经激活。

　如果你的输出为 -bash: mkvirtualenv: command not found，那么你的控制台没有识别出 virtualenvwrapper。首先，检查并确认在运行这段代码前打开了新的控制台或 cmd 窗口，这确保新的设置被应用。如果这不是问题，那么浏览整个过程并确认跟随了所有的步骤。

如果你成功地创建了虚拟环境，那么你完成了设置！

让我们禁用虚拟环境并摧毁它，因为这只是一个测试。运行下面的命令来移除 test 环境：

deactivate
rmvirtualenv test

这时，你已经在机器上设置了第二个 Python 实例。你还有了一个可以创建独立的 Python 实例的环境，可以在不同的项目间做保护。现在我们会运行一些练习，使你熟悉崭新的 Python 环境。

D.8　学习我们的新环境（Windows、Mac、Linux）

这里展示的例子是面向 Mac 的，但是过程与 Windows 和 Linux 上是相同的。在这一节中，我们会学习一些关于如何使用设置并确保所有的组件一起工作的知识。

让我们通过创建一个名为 testprojects 的新环境开始。我们会在任何需要一个快速的环境来练习测试或做其他事情时，激活并使用它。运行下面的命令来创建它：

$ mkvirtualenv testprojects

创建环境后，你会看到控制台提示的前面添加了环境名称。对我来说，像下面这样：

(testprojects)Jacquelines-MacBook-Pro:~ jacquelinekazil$

让我们安装一个 Python 库到环境中。我们会安装的第一个库称为 ipython。在你的激活环境中，运行下面的命令：

(testprojects) $ pip install ipython

如果这个命令是成功的，那么你输出的最后几行看起来像下面这样：

Installing collected packages: ipython, gnureadline
Successfully installed ipython gnureadline
Cleaning up...

现在，如果你输入 pip freeze 到控制台，会看到当前环境的库，同时还有每个安装的版本号。输出会类似下面这样：

gnureadline==6.3.3
ipython==2.1.0
wsgiref==0.1.2

这些输出告诉我们，在 testprojects 环境中，我们安装了 3 个库：gnureadline、ipython 和 wsgiref。ipython 是我们刚刚安装的库。gnureadline 是在安装 ipython 时安装的库，因为这是一个依赖库。（这让你避免了需要直接安装依赖的库。很好，不是吗？）第三个库是 wsgiref。它默认存在，但是不是必需的。

所以我们已经安装了一个叫 ipython 的库，但是我们可以用它做些什么呢？IPython 是一个易于使用的 Python 默认解释器的替代（你可以在附录 F 中读到更多关于 IPython 的信息）。为了启动 IPython，直接输入 ipython。

你会看到类似下面的输入提示：

IPython 3.1.0 -- An enhanced Interactive Python.
?         -> Introduction and overview of IPython's features.
%quickref -> Quick reference.
help      -> Python's own help system.
object?   -> Details about 'object', use 'object??' for extra details.

In [1]:

对其进行测试，输入下面命令：

In [1]: import sys

In [2]: import pprint

In [3]: pprint.pprint(sys.path)

你应该会看到与我们确认环境工作时相同的输出。sys 和 pprint 是标准库模块，打包在 Python 之中。

有两种方法退出 IPython。你可以按下 Ctrl+D，在提示时输入 y（yes），或者只是输入 quit()。它的工作方式同默认的 Python shell 一样。

一旦你退出了，你会回到命令行。现在我们有了一个叫 testprojects 的环境，有 3 个库已经安装。但是如果我们致力于其他项目，想要有另一个环境呢？首先，输入下面的命令禁用当前的环境：

$ deactivate

之后创建一个新的叫作 sandbox 的环境：

$ mkvirtualenv sandbox

完成之后，你会进入新环境。如果输入 pip freeze，你会看到这个环境还没有安装 IPython。因为这是一个全新的环境，与 testprojects 环境完全分离。如果我们在这个环境中安装 IPython，它会在计算机上安装第二个实例。这保证我们在一个环境中所做的任何事不会影响其他的环境。

为什么它很重要？随着新项目的进行，你会想安装不同的库以及不同版本的库。我们建议为本书创建一个虚拟环境，但是如果你启动了一个新的项目，你会想要创建一个新的虚拟环境。正如你所见，随着项目的切换，在不同的环境中切换是很容易的。

有时候，你可能会碰到所有依赖都存储在一个名为 requirements.txt 文件中的仓库。库作者使用虚拟环境和 pip freeze 来保存列表，这样用户可以安装库和依赖。从 requirements 文件安装包，你需要运行 pip install -r requirements.txt。

我们知道如何创建和禁用环境，但是我们不知道如何激活一个已经存在的环境。为了激活我们名为 sandbox 的样例环境，输入下面的命令（如果你已经在其中了，需要先 deactivate 它，来看有什么区别）：

$ workon sandbox

最后，你如何摧毁一个环境呢？首先，确保你不在想要删除的环境中。如果你键入了 workon sandbox，那么你应该在 sandbox 环境中了。为了删除它，首先你需要禁用它，然后移除它：

$ deactivate
$ rmvirtualenv sandbox

现在，你唯一应该拥有的环境为 testprojects。

D.9　高级设置回顾

你的计算机现在已经设置好，可以运行高级 Python 库。在与命令行交互和安装包时，你应该感到很舒服。如果你还不是很熟练，我们同样建议你查看附录 C 来学习更多使用命令行的知识。

表 D-1 列出了你在虚拟环境中最常用的命令。

表D-1：回顾命令

命令

动作

mkvirtualenv

创建一个环境

rmvirtualenv

移除一个环境

workon

激活一个环境

deactivate

禁用当前活跃的环境

pip install

在活跃环境中安装包 a

pip uninstall

在一个活跃的环境中卸载包 b

pip freeze

返回一个活跃环境中已安装库的列表

a 如果没有环境是活跃的，库会被安装到你系统上 Python 的第二个副本上，它通过 Homebrew 安装。你的系统 Python 不会受到影响。

b 见前一条脚注。

附录 E　Python陷阱

同你学习的其他语言一样，Python 也有它的怪癖和特点。其中一些特点是脚本语言共有的，如果你有脚本语言的经验，可能不会感到惊讶。其他的特点是 Python 独有的。我们总结了其中一些特点，但并不完全，所以你可以自己去了解更多。我们希望本附录可以帮助你调试代码，也能让你了解 Python 行为方式的原因。

E.1　空白

可能你已经注意到，Python 使用空白作为代码结构的一部分。空白被用来缩进函数、方法和类；去执行 if-else 语句；创建持续的代码行。在 Python 中，空白是一种特殊的操作符，能帮助转化 Python 代码为可执行的代码。

下面是 Python 文件中空白的一些最佳实践。

不要使用 tab，使用空格。

对于每一个缩进块，使用 4 个空格。

为悬挂式缩进选择一种好的缩进策略（可以通过一个分隔符、一个额外的缩进或一个单个缩进对齐，但是必需选用可读性和实用性最好的方式；参见 PEP-8，https://www.python.org/dev/peps/pep-0008/#indentation）。

　PEP-8（或 Python 增强方案 #8）是一个 Python 风格指南，给出了缩进的最佳实践，并针对如何命名变量、代码行换行、格式化代码提出了建议，让代码可读、易于使用、易于分享。

如果你的代码没有正确地缩进，并且 Python 不能解析你的文件，你会得到一个 IndentationError。错误信息会告诉你哪一行代码没有正确缩进。在你最喜欢的文本编辑器中设置 Python 的语法提示器是相当简单的，以便在你工作时自动检查你的代码。举个例子，对于 Atom，有一个很棒的 PEP-8 提示器（https://atom.io/packages/linter-python-pep8）。

E.2　可怕的GIL

GIL（Global Interpreter Lock，全局解释器锁）是 Python 解释器用于一次只用一个线程执行代码的一种机制。这意味着当你运行 Python 脚本时，即使上在一台多进程机器上，你的代码也会线性执行。这个设计最初的目的是让 Python 可以通过 C 代码快速地运行，但是仍然是线程安全的。

GIL 给 Python 带来的限制意味着在标准解释器中，Python 从来不会真正地并行化。这对于一些高 I/O 的应用程序，或者严重依赖多重处理的应用程序来说，是一个劣势。1 有些 Python 库通过使用多重处理或异步服务 2，规避了这些问题，但是它们没有改变 GIL 仍然存在的事实。

1关于 GIL 可视化的更多信息，查看 David Beazley 的“一个可缩放、可交互的 Python 线程可视化”（http://www.dabeaz.com/GIL/gilvis/）。

2关于这些包的功能，查看 Jeff Knupp 关于如何减轻 GIL 问题影响的文章（https://www.jeffknupp.com/blog/2013/06/30/pythons-hardest-problem-revisited/）。

即便如此，有很多 Python 核心开发者意识到由 GIL 带来的问题，还有它的好处。在 GIL 成为开发痛点的情况下，通常有不错的应对方案，而且根据你的需要，还有用 C 以外的其他语言编写的其他解释器可用。如果你发现 GIL 成为了代码中的一个问题，很可能你可以重新架构你的代码，或利用一个不同的代码基（例如 Node.js）来满足你的需求。

E.3　=、==与is，以及何时只是复制

在 Python 中，看似相似的函数间有一些重大区别。我们已经了解了一些，但是让我们重新看一下一些代码和输出（使用 IPython）：

In [1]: a = 1 ➊

In [2]: 1 == 1 ➋
Out[2]: True

In [3]: 1 is 1 ➌
Out[3]: True

In [4]: a is 1 ➍
Out[4]: True

In [5]: b = []

In [6]: [] == []
Out[6]: True

In [7]: [] is []
Out[7]: False

In [8]: b is []
Out[8]: False

❶ 设置变量 a 为 1。

❷ 检查 1 是否等于 1。

❸ 检查 1 是否与 1 是相同的对象。

❹ 检查 a 是否与 1 是相同的对象。

如果在 IPython 中执行这些代码（这样你可以看到类似于这里展示的输出），你会注意到一些有趣的、可能意外的结果。对于一个整数，我们看到很容易通过多种方式来确定相等。然而对于列表对象，我们发现 is 与其他比较操作符表现得不同。在 Python 中，内存管理操作不同于其他语言。在 Sreejith Kesavan 的博客上（http://foobarnbaz.com/2012/07/08/understanding-python-variables/），有一篇使用可视化技术的文章，讨论了 Python 如何管理内存中的对象。

为了从另外一个视角观察，让我们看一下对象内存的位置：

In [9]: a = 1

In [10]: id(a)
Out[10]: 14119256

In [11]: b = a ➊

In [12]: id(b) ➋
Out[12]: 14119256

In [13]: a = 2

In [14]: id(a) ➌
Out[14]: 14119232

In [15]: c = []

In [16]: id(c)
Out[16]: 140491313323544

In [17]: b = c

In [18]: id(b) ➍
Out[18]: 140491313323544

In [19]: c.append(45)

In [20]: id(c) ➎
Out[20]: 140491313323544

❶ 将 a 赋值给 b。

❷ 当在这里使用 id 方法时，我们发现 b 和 a 都使用了内存中相同的位置，也就是说，它们在内存中是相同的对象。

❸ 当在此时调用 id 方法时，我们发现 a 在内存中拥有了新的地址。这个地址现在保存着值 2。

❹ 在列表中，可以看到我们在赋值后，列表拥有与赋值对象相同的 id。

❺ 当改变这个列表时，我们发现我们不能改变内存中的位置。Python 列表表现得与整数和字符串略有不同。

这里，我们的目的不是让你对 Python 中的内存分配有深入的理解，而是意识到我们可能不会总是去思考我们到底赋值了什么。在处理列表和字典时，我们需要知道和理解的是，在我们将它赋值为一个新变量的时候，新的变量和旧的变量仍然是内存中的相同对象。如果我们改变其中一个，也改变了另一个。如果只想要改变其中一个或另一个，或者需要创建一个新对象作为对象的副本，需要使用 copy 方法。

让我们通过最后一个示例来解释 copy 与赋值：

In [21]: a = {}

In [22]: id(a)
Out[22]: 140491293143120

In [23]: b = a

In [24]: id(b)
Out[24]: 140491293143120

In [25]: a['test'] = 1

In [26]: b ➊
Out[26]: {'test': 1}

In [27]: c = b.copy() ➋

In [28]: id(c) ➌
Out[28]: 140491293140144

In [29]: c['test_2'] = 2

In [30]: c ➍
Out[30]: {'test': 1, 'test_2': 2}

In [31]: b ➎
Out[31]: {'test': 1}

❶ 在这行代码中，我们看到，当我们修改 a 时，同样修改了 b，因为它们存储在内存中相同的位置。

❷ 使用 copy 方法我们创建了一个新的变量，c，这是第一个字典的副本。

❸ 这行代码中，我们看到 copy 创建了一个新的对象。它有一个新的 id。

❹ 在修改了 c 之后，我们看到它现在保存着两个键和值。

❺ 即使在 c 修改之后，我们看到 b 仍然是相同的。

在最后这个示例中，很显然，如果你真的想要一个字典或列表的副本，需要使用 copy 方法。如果你想要相同的对象，那么可以使用 =。类似地，如果你想要知道两个对象是不是“相等的”，可以使用 ==，但是如果你想知道它们是否是相同的对象，则使用 is。

E.4　默认函数参数

有时你会想要传递默认变量到你的 Python 函数和方法中。为此，你需要充分理解 Python 何时以何种方式调用这些方法。让我们看一下：

def add_one(default_list=[]):
    default_list.append(1)
    return default_list

现在，让我们通过 IPython 研究：

In  [2]: add_one()
Out [2]: [1]

In  [3]: add_one()
Out [3]: [1, 1]

你可能希望每一个函数调用会返回一个新的列表，只包含一个元素，1。相反，两个调用修改相同的列表对象。而实际上，默认参数在脚本第一次解释的时候被声明。如果每次你都想要一个新的列表，可以像下面一样重写函数。

def add_one(default_list=None):
    if default_list is None:
        default_list = []
    default_list.append(1)
    return default_list

现在我们的代码表现得同我们期望的一样：

In  [6]: add_one()
Out [6]: [1]

In  [7]: add_one()
Out [7]: [1]

In  [8]: add_one(default_list=[3])
Out [8]: [3, 1]

现在你对内存管理和默认变量有了一些了解，你可以使用你的知识来确定何时检查，以及何时在函数与可执行代码中赋值。深入理解了 Python 何时以何种方式定义对象，我们可以确保这些“陷阱”类型不会给我们的代码添加 bug。

E.5　Python作用域与内置函数：变量名称的重要性

在 Python 中，作用域的执行与你的预期有些许不同。如果你在函数作用域中定义一个变量，这个变量不被函数之外所知。让我们看一下：

In [10]: def foo():
   ....:     x = "test"

In [11]: x
.---------------------------------------------------------------------------
NameError                                Traceback (most recent call last)
<ipython-input-94-009520053b00> in <module>()
----> 1 x
NameError: name 'x' is not defined

然而，如果之前定义了 x，我们会得到旧的定义：

In [12]: x = 1

In [13]: foo()

In  [14]: x
Out [14]: 1

这些与内置函数和方法相关。如果你不小心重写了它们，从那一刻之后你都不能再使用它们了。所以，如果你重写特殊的词列表（list）或日期（date），拥有这些名字的内置函数不会在剩余的代码中正常执行（或者从那一刻之后）：

In [17]: from datetime import date

In [19]: date(2015, 2, 5)
Out[19]: datetime.date(2015, 2, 5)

In [20]: date = 'my date obj'

In [21]: date(2015, 2, 5)
.---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-105-7f129d4341d0> in <module>()
----> 1 date(2015, 2, 5)

TypeError: 'str' object is not callable

正如你所见，使用共享名称的变量（或与任何其他标准 Python 命名空间或你使用的任何其他库共享名称）可能造成调试的噩梦。如果你在代码中使用特殊的名称，并且意识到固有的变量或模块名称，就不会花几个小时调试命名空间问题。

E.6　定义对象与修改对象

在 Python 中，定义一个新的对象与修改一个老对象，执行的方式略有区别。假设你有一个函数，为一个整数加 1：

def add_one_int():
    x += 1
    return x

如果你尝试运行这段函数，应该会收到一个错误：UnboundLocalError: local variable 'x' referenced before assignment。然而，如果你在函数中定义了 x，会看到不同的结果：

def add_one_int():
    x = 0
    x += 1
    return x

这段代码有些复杂（为什么我们不能直接返回 1 ？），但是这里的重点是，我们在修改变量之前需要先声明变量，即使我们使用了一个看起来像赋值的修改（+=）。在处理像列表和字典这样的对象工作时留心这一点是特别重要的（因为我们知道修改一个对象会对存储在相同内存位置的其他对象产生副作用）。

需要记住的是，在你想要修改一个对象和想要创建或返回一个新对象时，永远要保持清晰和明确。你命名变量的方式，以及编写与实现函数的方式，是编写清晰与行为可预测的脚本的关键。

E.7　修改不可变对象

想要修改或改变不可变对象时，你需要创建新的对象。Python 不会允许你修改不可变对象，例如元组。在我们讨论 Python 内存管理时，你已经知道一些对象保存在相同的空间中。不可变对象不能被改变，它们总是被重新赋值。让我们看一下：

In [1]: my_tuple = (1,)

In [2]: new_tuple = my_tuple

In [3]: my_tuple
Out[3]: (1,)

In [4]: new_tuple
Out[4]: (1,)

In [5]: my_tuple += (4, 5)

In [6]: new_tuple
Out[6]: (1,)

In [7]: my_tuple
Out[7]: (1, 4, 5)

可以看到，我们尝试使用 += 操作符去修改原始的元组，并且我们能够成功地做到这一点。然而，我们得到的是一个包含原始元组和追加了元组（4, 5）的新对象。我们最终没有改变 new_tuple 变量，因为我们只是将内存中的一个新地址赋给新的对象。如果你在查看 += 操作之前和之后查看内存地址，你会看到它的改变。

关于不可变对象需要记住的重点是，当改变它们的时候，它们不会使用内存中相同的地址；如果你修改它们，你实际上在创建全新的对象。如果你使用一个拥有不可变对象的类的方法或属性，尤其要记住这一点，因为你要确保自己知道何时在修改它们，何时在创建新的不可变对象。

E.8　类型检查

Python 允许简单的类型转换，这意味着你可以将字符串转换为整数或将列表转换为元组，等等。但是这些动态类型意味着可能会引发问题，特别是在大型的代码仓库中，或在你使用新的库时。常见问题是一些特定的函数、类或方法对应着一些特定的对象类型，而你传递了错误的类型。

随着你的代码变得更高级和复杂，问题会变得越来越难办。由于你的代码更加抽象，你会将所有的代码保存在变量中。如果一个函数或方法返回一个不符预期的类型（例如 None 而不是列表），这个对象可能被传递到另一个函数中——可能是一个不接受 None 类型的函数，之后抛出一个错误。很可能错误被捕获了，但代码会认为异常是因为另外的问题触发的，并且继续执行。这会很快地脱离你的控制，并且变成一个相当难以调试的问题。

对于如何处理这些问题，最好的建议是编写非常精准又清晰的代码。你应该积极测试你的代码（确保没有 bug），持续关注你的脚本，并注意任何反常的行为，以此确保函数永远返回期待的内容。你还需要添加日志来帮助确认对象包含的内容。除此之外，清楚你捕获的异常，而不只是捕获所有的异常，这会帮助你更容易地找到和修复问题。

最后，有时 Python 会实现 PEP-484（https://www.python.org/dev/peps/pep-0484/），它包含了类型提示，允许你检查传递的变量和代码，以自我检查这些问题。这在未来 Python 3 发布之前可能不会被合并，但是好消息是，这已经在进行当中，你可以期待在未来看到更多的有关类型检查的结构。

E.9　捕获多个异常

随着代码的发展，你会想要在同一行代码中捕获多个异常。举个例子，你可能想要捕获一个 TypeError，同时还有 AttributeError。如果你以为传递的是一个字典，而实际上传递的是一个列表，可能就是这种情况。它可能有一些相同的属性，但是不是所有属性。如果你需要在一行中捕获多个类型的错误，就必需在元组中编写异常，让我们看一下：

my_dict = {'foo': {}, 'bar': None, 'baz': []}

for k, v in my_dict.items():
    try:
       v.items()
    except (TypeError, AttributeError) as e:
        print "We had an issue!"
        print e

你应该会看到下面的输出（很可能呈现顺序不同）：

We had an issue!
'list' object has no attribute 'items'
We had an issue!
'NoneType' object has no attribute 'items'

我们的异常成功地捕获了两个错误并执行了异常代码块。正如你所见，意识到你需要捕获的错误的类型，并且理解语法（将其放到元组中）对你的代码来说是必需的。如果你简单地列出它们（通过一个列表或只是通过逗号分隔），你的代码可能不会正常地执行，而且你不会捕获到这两个异常。

E.10　调试的力量

随着你成为一名更加高级的开发者和数据处理者，你会遇到更多的问题和错误来调试。我们希望可以告诉你它会变得更简单，但是在它变简单之前，你的调试过程会更加集中和严谨。这是因为你会用到更高级的代码和库，处理更加困难的问题。

即便如此，你拥有很多技术和工具，可以帮助你脱离困境。你可以在 IPython 中执行代码，在开发过程中得到更多反馈。你可以添加日志到脚本中，以更好地理解发生了什么。如果解析网页时遇到问题，你可以让抓取器截屏，并将它们保存到文件中。你可以在 IPython notebook 中与其他人分享代码，或者在其他许多有帮助的站点分享代码，以得到反馈。

Python 中同样有一些很棒的调试工具，包括 pdb（https://docs.python.org/2/library/pdb.html），它允许你逐句执行代码（或模块中的其他代码），并且在所有的错误前后，精确地看到每个对象保存的内容。YouTube 上有一个很棒的关于 pdb 的快速介绍（https://www.youtube.com/watch?v=bZZTeKPRSLQ），展示了一些在代码中使用 pdb 的方式。

除此之外，你需要阅读并编写文档和测试。在本书中我们已经介绍了一些基础，但是我们强烈建议你将本书作为一个起点，并在将来进一步研究文档和测试。Ned Batchelder 最近关于上手测试的 PyCon 讲座（https://www.youtube.com/watch?v=FxSsnHeWQBY）是一个好的起点。Jacob Kaplan-Moss 在 PyCon 2011 上也做了一个很棒的关于文档的讲座（https://www.youtube.com/watch?v=z3fRu9pkuXE）。通过阅读和编写文档，以及编写和执行测试，你可以确保没有因为错误的信息而将错误引入代码，或没有因为未做测试而遗留错误。

我们希望本书是这些概念的优秀入门介绍，但是我们鼓励你继续阅读和开发，通过寻找更多的 Python 学习资源，成为一名更出色的 Python 开发者。

附录 F　IPython 指南

尽管你的 Python shell 是有用的，但它缺少了 IPython（http://ipython.org/）的很多魔力。 IPython 是一个增强的 Python shell，提供了一些易于使用的快捷方式和在 shell 环境中同 Python 交互的额外能力。它最初由想要一个更简单的 Python shell 的科学家和学生开发（http://blog.fperez.org/2012/01/ipython-notebook-historical.html）。后来，它变成了学习 Python 以及通过解释器与 Python 交互的事实上的标准。

F.1　为什么使用IPython

IPython 提供了很多在标准 Python shell 中缺少的功能。安装和使用 IPython 作为你的 shell 有很多好处。它的特性包括：

易于阅读的文档钩子

自动补全和用于库、类和对象探索的魔法命令

内联图片和图表生成

用来浏览历史、创建文件、调试脚本、重新加载脚本等的辅助工具

内置的命令行工具帮助

启动时的自动导入

它也是 Jupyter（https://jupyter.org/）的一个重要组成部分。Jupyter 是一个共享的 notebook 服务器，可以快速方便地在浏览器中进行数据探索。我们在第 10 章中介绍了使用 Jupyter 进行代码分享和演示。

F.2　IPython起步

IPython 可以用 pip 方便地安装：

pip install ipython

如果你正在使用多个虚拟环境，可能想要安装全局 IPython，或在每个虚拟环境中都安装一个。为了开始使用 IPython，直接在终端窗口中输入 ipython。你应该会看到一个类似这样的提示：

$ ipython
Python 2.7.6 (default, Mar 22 2014, 22:59:56)
Type "copyright", "credits" or "license" for more information.

IPython 1.2.1 -- An enhanced Interactive Python.
?         -> Introduction and overview of IPython's features.
%quickref -> Quick reference.
help      -> Python's own help system.
object?   -> Details about 'object', use 'object??' for extra details.
In [1]:

现在你可以输入 Python 命令，就像在普通的 Python shell 中一样。例如：

In [1]: 1 + 1
Out[1]: 2

In [2]: from datetime import datetime

In [3]: datetime.now()
Out[3]: datetime.datetime(2015, 9, 13, 11, 47, 49, 191842)

当需要退出 shell 时，你可以输入 quit()、exit()，或者在 Windows/Linux 上输入 Ctrl-D，在 Mac 上输入 Cmd-D。

F.3　魔法函数

IPython 有大量所谓的魔法函数，能在探索和编程的时候帮助你。这里有一些非常重要的函数，特别是对于初级开发者来说。

为了看到你已经导入和活动的对象，可以输入 %whos 或 %who。让我们看一下它们的用法：

In [1]: foo = 1 + 4

In [2]: bar = [1, 2, 4, 6]

In [3]: from datetime import datetime

In [4]: baz = datetime.now()
In [5]: %who
bar baz datetime    foo

In [6]: %whos
Variable   Type        Data/Info
--------------------------------
bar        list        n=4
baz        datetime    2015-09-13 11:53:29.282405
datetime   type        <type 'datetime.datetime'>
foo        int         5

在你忘记了变量名称或者想要通过一个简洁的列表看到在变量中存储的内容时，这非常有用。

另外一个有用的工具是快速查找与库、类或对象相关的文档的能力。如果你在方法、类、库或属性名称的最后输入一个 ?，IPython 会尝试获取所有相关的文档，内联地展示它。举个例子：

In [7]: datetime.today?
Type:       builtin_function_or_method
String Form:<built-in method today of type object at 0x7f95674e0a00>
Docstring:  Current date or datetime:
            same as self.__class__.fromtimestamp(time.time()).

有大量与此类似的 IPython 扩展和函数，对开发极其有用，特别是随着你作为一名开发者不断成长，碰到更加复杂的问题时。表 F-1 列出了最为有用的几个工具，网络上还有一些非常棒的演讲稿和会议演讲（http://ipython.org/presentation.html）以及交互示例（http://nbviewer.jupyter.org/github/ipython/ipython/blob/master/examples/Index.ipynb），此外还有关于库的非常棒的文档（http://ipython.org/documentation.html）。

　所有的 IPython 扩展必需在 IPython 会话的开始使用 %load_ext extension_name 加载。如果你想要安装额外的扩展，GitHub 上有一个非常棒的可用扩展及其使用方式的列表（https://github.com/ipython/ipython/wiki/Extensions-Index）。

表F-1：有用的IPython扩展与函数

命令

描述

目的

文档

%autoreload

允许你只通过一次调用重新加载所有导入的脚本

在编辑器中修改脚本、在 IPython shell 中调试脚本，进行活跃开发的时候，非常有帮助

http://ipython.org/ipython-doc/dev/config/extensions/autoreload.html

%store

允许你存储保存的变量，在下一个会话中使用

在需要保存一些经常使用的变量，或者你的工作被中断因而需要保存当前的工作供以后使用时，非常有帮助

http://ipython.org/ipython-doc/dev/config/extensions/storemagic.html

%history

打印你的会话历史

展示你已经运行过的命令的输出

https://ipython.org/ipython-doc/dev/interactive/magics.html#magic-history

%pdb

用于过程较长的调用的交互式调试模块

强有力的调试库，在导入了较长的脚本或模块时特别有用

https://ipython.org/ipython-doc/dev/interactive/magics.html#magic-pdb

%pylab

引入 numpy 和 matplotlib 来与你的会话交互式工作

允许你在 IPython shell 中使用统计和图表工具

https://ipython.org/ipython-doc/dev/interactive/magics.html#magic-pylab

%save

保存你的会话历史到一个输出文件

如果你花费了很长的时间调试，这是一个开始编写脚本的好方式

https://ipython.org/ipython-doc/dev/interactive/magics.html#magic-save

%timeit

计算代码中一行代码或多行代码的执行时间

用于对 Python 脚本和函数进行性能调优

https://ipython.org/ipython-doc/dev/interactive/magics.html#magic-timeit

还有很多可用的魔法命令（https://ipython.org/ipython-doc/dev/interactive/magics.html）。它们的有用性取决于你在开发中使用 IPython 的方式，但是随着你作为一名开发者不断成长，使用它们会通过 IPython 为你简化其他的任务。

F.4　最后的思考：一个简单的终端

无论是只在一个 notebook 中，还是在一个活跃的终端开发中使用 IPython，我们相信它会帮助你编写和理解 Python，并且帮助你成长为一个优秀的开发者。你早期的开发主要是探索 Python 如何工作，以及在开发过程中会遇到的错误和异常。IPython 对这些任务很在行，因为你可以在下一行输入中重试代码。我们希望 IPython 会在未来数年伴随你学习和编写 Python。

附录 G　使用亚马逊网络服务

如果开始为数据处理需求使用亚马逊和亚马逊云服务，你首先需要一个设置好以供使用的服务器。我们会学习如何让你的第一台服务器就绪并且运行。

在第 10 章，我们介绍了 AWS 之外的一些选择，包括 DigitalOcean、Heroku、GitHub Pages，以及使用一个托管服务提供商。根据你对不同部署与服务器环境的兴趣，建议使用多个选项，然后选择最适合你的选项。

AWS 作为第一个云平台而流行，但是它同样会带来许多困扰。我们想要包含一个教程来帮助你浏览整个过程。我们同样强烈建议使用 DigitalOcean 作为进入云的开始；它们的教程（https://www.digitalocean.com/help/getting-started/setting-up-your-server/）和指引（https://www.digitalocean.com/community/tutorials/initial-server-setup-with-ubuntu-14-04）是非常有帮助的。

G.1　启动AWS服务器

为了启动一个服务器，在 AWS 控制台（https://console.aws.amazon.com）选择“Compute”下的“EC2”（你需要登录或创建一个账户来访问控制台）。这会带你来到 EC2 着陆页（https://console.aws.amazon.com/ec2/v2/home）。在这里，点击“Launch Instance”按钮。

这时，你会开始跟随一个教程来设置你的实例。你在这里选择的所有东西都是可编辑的，所以不知道选择什么也不必担心。这本书提供了以廉价又快速的方式设置并运行服务器的建议，但这不意味着这就是你需要的解决方案。如果你碰到了空间等问题，可能需要一个更大因而也更贵的配置 / 实例。

在下面的这一小节里，我们会带着你浏览一遍我们推荐的设置。

G.1.1　AWS步骤1：选择一个亚马逊机器镜像（AMI）

机器镜像基本上是一个操作系统镜像（或快照）。最普遍的操作系统是 Windows 和 OS X。然而，基于 Linux 的系统通常用作服务器。我们推荐最新的 Ubuntu 系统，在本书编写时的版本为“Ubuntu Server 14.04 LTS (HVM), SSD Volume Type - amid05e75b8”。

G.1.2　AWS步骤2：选择一个实例类型

实例类型是你启动的服务器的容量。选择“t2.micro (Free tier eligible)”。不要扩容，除非你明确需要，因为这样会浪费钱。为了学习更多关于实例的知识，查看 AWS 关于实例类型（https://aws.amazon.com/ec2/instance-types/）与价格（https://aws.amazon.com/ec2/pricing/）的文章。

选择“Review and Launch”，这将带你到第 7 步。

G.1.3　AWS步骤7：学习实例启动

在页面的顶部，你会注意到一条信息：“提高你的实例安全。你的安全组，launch-wizard-4，正对全世界开放。”对于真正的产品实例，或带有敏感信息的实例，强烈建议提高安全，同时采用其他的安全措施。查看 AWS 的文章“加强你的 EC2 实例安全的建议”（https://aws.amazon.com/articles/1233/）。

G.1.4　AWS额外问题：选择一个存在的键对或创建一个新的

一个键对类似于一个服务器的键集合，这样服务器知道谁有权利使用。选择“Create a new key pair”并且给它命名。我们已经命名我们的实例为 data-wrangling-test，但是你可以给它取任何你可以识别的名字。当你完成时，下载键对到一个你可以在随后找到的地方。

最后，点击“Launch Instances”。启动实例后，你会在屏幕上得到一个实例 ID。

　如果你担心服务器花销，在 AWS 首选项中创建一个账单报警（https://console.aws.amazon.com/billing/home?#/preferences）。

G.2　登录AWS服务器

为了登录到服务器上，你需要进入 AWS 控制台的实例来得到更多的信息。在控制台中，选择 EC2，之后选择“1 Running Instance”（如果你有不止一个实例，数字会增大）。你会看到你的服务器列表。除非你提供过名称，否则你的服务器不会拥有名称。通过点击列表上的空框，为你的实例命名。我们将其命名为 data-wrangling-test，以便持久化。

为了登录到我们的服务器上，我们会跟随一篇关于连接到 Linux 实例的 AWS 文章（http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EC2_GetStarted.html#ec2-connect-to-instance-linux）的指示。

G.2.1　得到实例的公共DNS名称

公共 DNS 名称是实例的 Web 地址。如果你有一个看起来像 Web 地址的值，继续阅读下一个小节。如果值为“--”，那么你需要跟随下面这些额外的步骤（来自 Stack Overflow ，http://stackoverflow.com/questions/20941704/ec2-instance-has-no-public-dns/26403671#26403671）。

(1) 访问 console.aws.amazon.com。

(2) 访问 Services（顶部导航）→ VPC（接近列表的末尾）。

(3) 打开你的 VPC（左侧栏目）。

(4) 选择连接到你的 EC2 的 VPC。

(5) 在“Actions”下拉菜单，选择“Edit DNS Hostnames”。

(6) 修改“Edit DNS Hostnames”为“Yes”。

如果你返回 EC2 实例，应该看到它现在有了一个公共 DNS 名称。

G.2.2　准备你的私钥

你的私钥是一个下载的 .pem 文件。将它移动到一个你了解并且能够记住的文件夹是好的想法。对于基于 Unix 的系统，你的密钥会保存在 home 目录下名为 .ssh 的文件夹。对于 Windows，默认的位置是 C:\Documents and Settings.ssh\ 或 C:\Users.ssh。你需要复制你的 .pem 文件到这些文件夹。

然后，你需要运行 chmod 命令来改变 .pem 文件的权限为 400。修改权限为 400 意味着，文件只能被所有者访问。这保证了在多账户计算机环境下文件的安全：

chmod 400 .ssh/data-wrangling-test.pem

G.2.3　登录你的服务器

现在，你已经有了所有登录服务器所需的一切。运行下面的命令，但是用你的键对替换 my-key-pair.pem，同时用你的公开 Web 地址替换 public_dns_name：

ssh -i ~/.ssh/my-key-pair.pem_ ubuntu@_public_dns_name

例如：

ssh -i data-wrangling-test.pem ubuntu@ec2-12-34-56-128.compute-1.amazonaws.com

当提示“Are you sure you want to continue connecting (yes/no)?”时，输入 yes。

现在，你的提示会有一些轻微的改变，说明你正位于你创建的控制台中。你现在可以继续设置你的服务器，将代码部署到服务器上，设置在服务器上运行的自动化程序。在第 14 章你可以阅读更多关于部署代码到新服务器上的知识。

为了退出服务器，输入 Ctrl-C 或 Cmd-C。

G.3　小结

现在你已经有了第一个设置好并且运行起来的 AWS。使用在第 14 章学到的知识将代码部署到服务器，并且立即运行你的数据处理程序。

关于作者

Jacqueline Kazil 是一名数据爱好者。在职业生涯中，她曾在专注于金融、政府和新闻业的技术公司工作。最值得一提的是，她是一位前总统创新研究员（Presidential Innovation Fellow，https://presidentialinnovationfellows.gov/fellows/），还联合创建了一家名为 18F 的政府技术组织（https://18f.gsa.gov/）。她在职业生涯中完成了许多数据科学与数据处理项目，包括 Geoq（https://geo-q.com/geoq/，一个开源的绘图工作流程工具）、Congress.gov（https://www.congress.gov/）网站改造与美国最高机密项目（Top Secret America，http://projects.washingtonpost.com/top-secret-america/）。她活跃于 Python 与数据的社区——Python 软件基金会（https://www.python.org/psf/）、PyLadies（http://www.pyladies.com/）、Women Data Science DC（http://www.meetup.com/WomenDataScientistsDC/）等。她在华盛顿哥伦比亚特区的聚会、会议和迷你训练营上教人学习 Python。她还经常与好友 Ellie（https://twitter.com/ellie_the_brave）一起结对编程。你可以在 Twitter 上找到她（https://twitter.com/jackiekazil），也可以关注她的博客 The coderSnorts（https://medium.com/coder-snorts）。

Katharine Jarmul 是一名 Python 开发者，她喜欢数据分析和获取、网页抓取、教人学习 Python 以及与 Unix 有关的一切。她曾在大大小小的创业公司工作，然后开始了海外咨询业务。最初在洛杉矶，她于 2008 年在《华盛顿邮报》工作时学习了 Python。作为 PyLadies 的创始人之一，Katharine 希望通过教育和培训来促进 Python 和其他开源语言的多元化。她领导了许多研讨会与辅导班，其内容从 Python 入门主题到 Python 高级话题。想进一步了解今后的培训项目，请通过她的 Twitter 账号（https://twitter.com/kjam）或网站（http://kjamistan.com/）与她联系。

关于封面

本书封面上的动物是一只蓝唇树蜥（blue-lipped tree lizard，拉丁名为 Plica umbra）。Plica 属的成员都是中等大小，尽管它们属于俗称的新热带地蜥（neotropical ground lizard）科，但主要生活在南美洲和加勒比地区的树上。蓝唇树蜥主要以蚂蚁为食，并且是 Plica 属中唯一不以脖子上的一束刺为特征的物种。

O'Reilly 图书封面上的很多动物都是濒危动物，它们对世界都很重要。想要详细了解如何帮助这些动物，请访问 animals.oreilly.com。

封面图片来自于 Lydekker 的 Natural History。

看完了

如果您对本书内容有疑问，可发邮件至contact@turingbook.com，会有编辑或作译者协助答疑。也可访问图灵社区，参与本书讨论。

如果是有关电子书的建议或问题，请联系专用客服邮箱：ebook@turingbook.com。

在这里可以找到我们：

微博 @图灵教育 : 好书、活动每日播报

微博 @图灵社区 : 电子书和好文章的消息

微博 @图灵新知 : 图灵教育的科普小组

微信 图灵访谈 : ituring_interview，讲述码农精彩人生

微信 图灵教育 : turingbooks 　　

091507240605ToBeReplacedWithUserId

版权信息

书名：Python数据挖掘入门与实践

作者：[澳] Robert Layton

译者：杜春晓

ISBN：978-7-115-42710-6

本书由北京图灵文化发展有限公司发行数字版。版权所有，侵权必究。

您购买的图灵电子书仅供您个人使用，未经授权，不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟，与我们共同保护知识产权。

如果购买者有侵权行为，我们可能对该用户实施包括但不限于关闭该帐号等维权措施，并可能追究法律责任。

091507240605ToBeReplacedWithUserId

版权声明

译者序

前言

本书主要内容

本书的阅读前提

本书的目标读者

排版约定

读者反馈

客户支持

下载示例代码

下载配套PDF文件

勘误表

侵权

问题

第 1 章　开始数据挖掘之旅

1.1　数据挖掘简介

1.2　使用Python和IPython Notebook

1.2.1　安装Python

1.2.2　安装IPython

1.2.3　安装scikit-learn库

1.3　亲和性分析示例

1.3.1　什么是亲和性分析

1.3.2　商品推荐

1.3.3　在NumPy中加载数据集

1.3.4　实现简单的排序规则

1.3.5　排序找出最佳规则

1.4　分类问题的简单示例

1.5　什么是分类

1.5.1　准备数据集

1.5.2　实现OneR算法

1.5.3　测试算法

1.6　小结

第 2 章　用scikit-learn估计器分类

2.1　scikit-learn估计器

2.1.1　近邻算法

2.1.2　距离度量

2.1.3　加载数据集

2.1.4　努力实现流程标准化

2.1.5　运行算法

2.1.6　设置参数

2.2　流水线在预处理中的应用

2.2.1　预处理示例

2.2.2　标准预处理

2.2.3　组装起来

2.3　流水线

2.4　小结

第 3 章　用决策树预测获胜球队

3.1　加载数据集

3.1.1　采集数据

3.1.2　用pandas加载数据集

3.1.3　数据集清洗

3.1.4　提取新特征

3.2　决策树

3.2.1　决策树中的参数

3.2.2　使用决策树

3.3　NBA比赛结果预测

组装起来

3.4　随机森林

3.4.1　决策树的集成效果如何

3.4.2　随机森林算法的参数

3.4.3　使用随机森林算法

3.4.4　创建新特征

3.5　小结

第 4 章　用亲和性分析方法推荐电影

4.1　亲和性分析

4.1.1　亲和性分析算法

4.1.2　选择参数

4.2　电影推荐问题

4.2.1　获取数据集

4.2.2　用pandas加载数据

4.2.3　稀疏数据格式

4.3　Apriori算法的实现

4.3.1　Apriori算法

4.3.2　实现

4.4　抽取关联规则

评估

4.5　小结

第 5 章　用转换器抽取特征

5.1　特征抽取

5.1.1　在模型中表示事实

5.1.2　通用的特征创建模式

5.1.3　创建好的特征

5.2　特征选择

选择最佳特征

5.3　创建特征

主成分分析

5.4　创建自己的转换器

5.4.1　转换器API

5.4.2　实现细节

5.4.3　单元测试

5.4.4　组装起来

5.5　小结

第 6 章　使用朴素贝叶斯进行社会媒体挖掘

6.1　消歧

6.1.1　从社交网站下载数据

6.1.2　加载数据集并对其分类

6.1.3　Twitter数据集重建

6.2　文本转换器

6.2.1　词袋

6.2.2　N元语法

6.2.3　其他特征

6.3　朴素贝叶斯

6.3.1　贝叶斯定理

6.3.2　朴素贝叶斯算法

6.3.3　算法应用示例

6.4　应用

6.4.1　抽取特征

6.4.2　将字典转换为矩阵

6.4.3　训练朴素贝叶斯分类器

6.4.4　组装起来

6.4.5　用F1值评估

6.4.6　从模型中获取更多有用的特征

6.5　小结

第 7 章　用图挖掘找到感兴趣的人

7.1　加载数据集

7.1.1　用现有模型进行分类

7.1.2　获取Twitter好友信息

7.1.3　构建网络

7.1.4　创建图

7.1.5　创建用户相似度图

7.2　寻找子图

7.2.1　连通分支

7.2.2　优化参数选取准则

7.3　小结

第 8 章　用神经网络破解验证码

8.1　人工神经网络

神经网络简介

8.2　创建数据集

8.2.1　绘制验证码

8.2.2　将图像切分为单个的字母

8.2.3　创建训练集

8.2.4　根据抽取方法调整训练数据集

8.3　训练和分类

8.3.1　反向传播算法

8.3.2　预测单词

8.4　用词典提升正确率

8.4.1　寻找最相似的单词

8.4.2　组装起来

8.5　小结

第 9 章　作者归属问题

9.1　为作品找作者

9.1.1　相关应用和使用场景

9.1.2　作者归属

9.1.3　获取数据

9.2　功能词

9.2.1　统计功能词

9.2.2　用功能词进行分类

9.3　支持向量机

9.3.1　用SVM分类

9.3.2　内核

9.4　字符N元语法

抽取字符N元语法

9.5　使用安然公司数据集

9.5.1　获取安然数据集

9.5.2　创建数据集加载工具

9.5.3　组装起来

9.5.4　评估

9.6　小结

第 10 章　新闻语料分类

10.1　获取新闻文章

10.1.1　使用Web API获取数据

10.1.2　数据资源宝库reddit

10.1.3　获取数据

10.2　从任意网站抽取文本

10.2.1　寻找任意网站网页中的主要内容

10.2.2　组装起来

10.3　新闻语料聚类

10.3.1　k-means算法

10.3.2　评估结果

10.3.3　从簇中抽取主题信息

10.3.4　用聚类算法做转换器

10.4　聚类融合

10.4.1　证据累积

10.4.2　工作原理

10.4.3　实现

10.5　线上学习

10.5.1　线上学习简介

10.5.2　实现

10.6　小结

第 11 章　用深度学习方法为图像中的物体进行分类

11.1　物体分类

11.2　应用场景和目标

使用场景

11.3　深度神经网络

11.3.1　直观感受

11.3.2　实现

11.3.3　Theano简介

11.3.4　Lasagne简介

11.3.5　用nolearn实现神经网络

11.4　GPU优化

11.4.1　什么时候使用GPU进行计算

11.4.2　用GPU运行代码

11.5　环境搭建

11.6　应用

11.6.1　获取数据

11.6.2　创建神经网络

11.6.3　组装起来

11.7　小结

第 12 章　大数据处理

12.1　大数据

12.2　大数据应用场景和目标

12.3　MapReduce

12.3.1　直观理解

12.3.2　单词统计示例

12.3.3　Hadoop MapReduce

12.4　应用

12.4.1　获取数据

12.4.2　朴素贝叶斯预测

12.5　小结

附录　接下来的方向

第 1 章——开始数据挖掘之旅

Scikit-learn教程

扩展IPython Notebook

第 2 章——用scikit-learn估计器分类

k近邻算法的扩展

更多复杂的流水线

比较分类器

第 3 章——用决策树预测获胜球队

pandas的更多内容

更多复杂特征

第 4 章——用亲和性分析方法推荐电影

新数据集

Eclat算法

第 5 章——用转换器抽取特征

增加噪音

Vowpal Wabbit

第 6 章——使用朴素贝叶斯进行社会媒体挖掘3

垃圾信息监测

自然语言处理和词性标注

第 7 章——用图挖掘找到感兴趣的人

更复杂的算法

NetworkX

第 8 章——用神经网络破解验证码

好（坏？）验证码

深度网络

增强学习

第 9 章——作者归属问题

增加数据量

博客语料

局部N元语法

第 10 章——新闻语料分类

算法评价

近期趋势分析

实时聚类

第 11 章——用深度学习方法为图像中的物体进行分类

Keras和Pylearn2

Mahotas

第 12 章——大数据处理

Hadoop课程

Pydoop

推荐引擎

更多资源

版权声明

Copyright © 2015 Packt Publishing. First published in the English language under the title Learning Data Mining with Python.

Simplified Chinese-language edition copyright © 2016 by Posts & Telecom Press. All rights reserved.

本书中文简体字版由Packt Publishing授权人民邮电出版社独家出版。未经出版者书面许可，不得以任何方式复制或抄袭本书内容。

版权所有，侵权必究。

译者序

云计算、大数据、物联网，这几年很火。到现在为止，人们对云计算的激情已经回落到比较理智的水平，各种云基础设施已投入使用，支撑起关系国计民生的信息化应用。物联网还在建设中，家电智能化、个人健康信息数字化、交通智能化等趋势在我们身边悄然推进。开放互联的概念不再囿于传统的互联网思维，我们生活所触及的一切正在被编织到一张包罗万象的大网之中。它将会对社会产生何种影响，我们拭目以待。虽然大数据现在很火，各种大数据研究中心相继建立，但这只是刚刚开始。随着更多的人和设备接入互联网，随着人们对世界认识的加深和新工具的研发，数据规模将加速膨胀，超乎想象。大数据的春天才刚刚到来。数据采集能力上去之后，势必要求数据挖掘能力跟得上。正如作者在第12章中讲到的，大数据带来的一个挑战就是，重要信息可能被垃圾信息湮没。由此我们不难推断出数据挖掘技术在发现、突显和传承人类文明方面将起到不可替代的作用。本书讲解的正是大数据时代的核心技能——数据挖掘技术，可以预见该项技术将发挥出越来越重要的作用。

本书讲解了如何用Python语言进行数据挖掘。Python是一种通用型编程语言，它简单易学，上手快，有着丰富的第三方库，社区氛围友好。从数据采集、分析一直到应用开发层面，Python都有成熟的库。使用Python语言进行开发，无需过多关注语言细节，开发者可以将主要精力放到业务本身。书中使用IPython Notebook作为开发环境，它将代码执行、富文本、公式编辑、绘图、多媒体等功能集合在一起，是科学计算和数据分析的好工具。书中所涉及的数据挖掘对象很丰富，有Iris鸢尾花卉数据集、Ionosphere电离层数据集、NBA比赛结果、MovieLens电影评分数据集、古登堡计划所收集的图书、安然公司邮件数据集、博客语料、CIFAR-10图像数据集等。从这些分属于不同行业的数据集，也能一窥数据挖掘应用之广。此外，作者还介绍了从Twitter、Reddit网站采集数据的方法。在算法方面，除了常见的决策树、朴素贝叶斯、支持向量机等，作者还介绍了最近几年非常热的深度学习。大数据、深度学习对计算能力要求很高，作者介绍了如何在亚马逊云主机上运行MapReduce任务。这本书由浅入深，以真实数据为研究对象，逐渐增大数据集规模，真刀实枪地向读者介绍了Python数据挖掘是怎么回事，并给读者进一步学习指出了多种可能的方向。工程实践之余，作者还不忘介绍数据挖掘常用思路，毫不保留地把自己积攥的宝贵经验传授给读者。这一点我在阅读过程中，深有体会。正如作者自己在前言里所写的，书中不会涉及大量公式推导，所有的算法都是以很直观的形式向读者介绍，所以即使你缺乏一定的数学基础，只要肯用功，也不用担心自己读不懂。

回到七八年前，当我还是一名英语专业学生的时候，我压根不会想到有一天会学编程，会去翻译这样一本书。后来有幸读了计算机辅助翻译这样一个专业，才开始接触到计算机知识，但是当父亲跟我提起数据建模时，我还是一脸茫然。研究生几年，系里为我们这些非计算机背景的学生开设了Python编程课。从那时起我就有事没事学点Python，一开始是照着Natural Language Processing with Python的示例敲，自那时起五年之后我竟想起给NLTK提交几处微小的改动。大约是为了激励我这个后生继续为他们服务，“居心叵测”的Steven Bird竟把我加入到贡献者名单里。去中关村图书大厦的时候，我常常喜欢浏览一下语言与程序设计书架上有没有关于Python的新书，碰到喜欢的就翻翻看，这几年眼看着Python书多了起来，很是欣慰。此外，我去北大、北外旁听过计算语言学、概率统计等课程，去北航旁听过计算机系统基础，看过Udacity的统计学入门和吴恩达老师的机器学习课程视频，兴致来了也曾捧着Rosen的《离散数学及其应用》读上几页。工作中，经常帮同事写个简单的Python程序处理数据，最近还帮他们爬取了一个网站。PyCon北京，我连着去了三四届了，每次都有或多或少的收获，2015年我见过一位大神行云流水般演示用pandas处理数据，很受震撼。以上就是我与Python、数据挖掘的交集。我想说的是，不要再用上学时读的那个专业的思维局限自己的发展，学科的界限在模糊，融合的趋势在增强，数学的重要性在提升。提到数学，今天还看了一个TED演讲视频，说的是借助计算机改变传统数学教育方法。这种理念什么时候能应用到一线教学，非常值得期待。生在这个充满变革的时代，倍感幸运。

以我有限的水平去翻译这样一本书，心里不免焦虑。遇到问题，我四处寻找能人相助。感谢作者Robert Layton，我每次发邮件向他求教或确认问题，他总能很快地回复我，有时第二天回复，还会说抱歉回复晚了。感谢我的同学黄子轩、孙伟、周星，他们在我学习计算机科学的路上给予了很多指导和帮助。翻译本书时，我还向子轩求证作者在第6章给出的示例是否有误。感谢上海大学研究生钱亦欣同学，他帮我审读了第3章，并给出若干很专业的修改意见，上述第6章那处问题，我也曾向他请教过，最终证明是原书弄错了。感谢李少华，他帮我弄明白了Python在Windows系统中的环境变量设置方法。感谢陈健锁，我曾就数据库相关术语向他请教。感谢图灵的朱巍编辑，是她促成了我最终去翻译这样一本趣味盎然的书！感谢图灵公司，我是你们忠实的读者！最后感谢我的妻子，她承担了照顾女儿的重任。女儿的出生，让我惊叹于生命的奇妙！感谢岳父岳母一家人帮忙照看孩子，我才有时间去做翻译。感谢我的父亲、姐姐，他们以我翻译本书为骄傲。

由于本人学识有限，且时间仓促，书中翻译错误、不当和疏漏之处在所难免，望读者批评指正。

杜春晓

2016年1月3日

前言

你是不是向往数据挖掘的殿堂，却不得其门而入？如果是的话，这本书就是为你而写的。

很多讲授数据挖掘的书涵盖了大量数学知识，倘若读者有较好的数学背景，这自然不错，但我觉得这些书往往只见树木不见森林；也就是说，它们过于关注算法的工作原理，而忘记了我们使用这些算法的初衷。

本书的目标读者是具备一定编程能力、渴望学习数据挖掘的人。我的目标是，如果你认真学完本书，能较好地理解数据挖掘的基础知识，掌握用数据挖掘知识解决问题的最佳实践，此外还能从书中找到几个值得你深入研究的方向。

本书的每一章都会介绍一个新的主题，我会给出该主题的相关算法和数据集。因此，各章主题之间跳跃有点大，阅读本书的过程中需要你的大脑能快速切换。每学完一章，你都要思考一下有没有什么办法能够提升该章中算法的效果，然后尝试去实现它！

我最喜欢的格言中有一条出自莎士比亚的《亨利四世》1：

1《亨利四世》是莎士比亚所著的历史剧，分为上下两篇。接下来这句台词出自上篇第三幕第一场，原文为“But will they come when you do call for them?”。遵照朱生豪先生的译法，将“Hotspur”译为“霍茨波”。——译者注

但是你叫他们的时候，他们真的会来吗？

在剧中，有人声称自己会招魂，于是霍茨波（Hotspur）回复了上面这句话。霍茨波的意思是每个人都会招魂，但问题是招过之后，魂来不来。

跟招魂类似，学习数据挖掘无外乎做实验，获得结果。实现一个新的数据挖掘算法或是改善现有实验结果的想法，每个人都能提出来，但真正重要的是你能不能实现它，还有结果是否尽如人意。

本书主要内容

第1章　开始数据挖掘之旅，介绍我们即将用到的技术，接着通过讲解两个基础算法的实现方法达到热身目的。

第2章　用scikit-learn估计器分类，涵盖了数据挖掘的一个重要主题——分类。这一章还会介绍将数据挖掘流程标准化的流水线结构，便于你管理实验流程。

第3章　用决策树预测获胜球队，介绍决策树和随机森林两个新算法。我们将通过抽取区分度高的特征来预测获胜选手。

第4章　用亲和性分析方法推荐电影，思考根据以往消费记录推荐产品的问题，介绍Apriori算法。

第5章　用转换器抽取特征，介绍不同类别特征的抽取方法及不同数据集的处理方法。

第6章　使用朴素贝叶斯进行社会媒体挖掘，使用朴素贝叶斯算法自动分析来自社交网站Twitter的文本信息。

第7章　用图挖掘找到感兴趣的人，采用聚类和网络分析方法，发现社会媒体上感兴趣的人。

第8章　用神经网络破解验证码，从图像中抽取信息，然后训练神经网络，用来发现图像中的单词和字母。

第9章　作者归属问题，通过抽取文本特征，使用支持向量机算法，找出文档的作者。

第10章　新闻语料分类，使用k-means聚类算法，根据新闻文章内容进行分类。

第11章　用深度学习方法为图像中的物体进行分类，采用深度神经网络算法确定图像中的物体。

第12章　大数据处理，探讨对大数据进行数据挖掘的流程及方法。

附录　依次介绍各章的参考资料，便于读者深入了解各章内容。

本书的阅读前提

毫无疑问，要完成本书的学习，你需要准备一台电脑。电脑不能太旧，但也不用配置太高。只要是最近几年的处理器（2010年以后），4GB内存（RAM）就足够了。在性能稍差一点的机器上运行本书中的大部分代码，应该也不会有问题。

最后两章的代码对机器性能要求较高。在这两章中，我介绍了如何使用亚马逊网络服务（AWS）运行代码。这多少可能会让你破费一些，与在本地运行代码相比，好处是不用做那么多系统配置。如果你不想掏钱购买亚马逊的网络服务，可以在本地主机上配置好所有的工具，当然你需要一台配置较高的计算机：2012年及以后的处理器，内存在4GB以上。

我推荐使用Ubuntu操作系统，但是本书示例代码也可以在Windows、Mac和其他任何Linux系统上运行。然而，你可能需要参考系统的相关文档来完成必要工具的安装。

本书中，我将使用命令行工具pip来安装我们用到的Python库。你也可以使用Anaconda，下载地址为http://continuum.io/downloads。

本书所有代码都已在Python 3中测试过。大部分代码无需修改就能在Python 2下运行。如果你遇到什么问题解决不了，请发邮件给我们，我们帮你看看怎么解决。

本书的目标读者

本书是写给那些想把数据挖掘技术应用到实际项目中，却不知道怎么开始的程序员。

如果你没有编程经历，我强烈建议你在学习本书之前，至少先了解一下编程的基础知识。本书直接略过这部分内容，也不会把过多精力放在代码的具体实现上。也就是说，简要学习一下编程基础知识再来学习本书就行。本书不对你的编程技能做过高要求，所以你没必要先成为编程高手！

我强烈建议在阅读本书前最好先积累一些Python编程经验。如果没有的话，也没关系，但是你可能需要先瞧瞧Python代码是怎么回事，可能的话先看看IPython Notebook的教程。用IPython Notebook写程序，跟用其他编辑器写程序（比如使用功能全面的IDE编写Java程序）有一定区别。

排版约定

本书使用不同的文本样式来区分不同类别的内容。以下是常用样式及其用途说明。

首先，我们来看一下最重要的代码所使用的样式。

if True:
    print("Welcome to the book")

请留意缩进。缩进在Python中表示层级的概念。本书中，我们使用四个空格来表示缩进。你可以按照自己的喜好，使用不同数量的空格（或制表符），但是应该保持一致。如果你被缩进搞糊涂了，请参考本书的示例代码。

在正文中引用代码时，会使用下面这种样式：I'll use this format。你不需要在IPython Notebook中输入这些代码，除非我在正文中明确要求你这么做。

所有的命令行输入或输出都使用下面这种样式：

# cp file1.txt file2.txt

新的术语和重要的词语使用楷体。

　此图标表示警告或重要信息。

　此图标表示提示或技巧。

读者反馈

我们热忱地欢迎读者朋友给予我们反馈，告诉我们你对于这本书的所思所想——你喜欢或是不喜欢哪些内容。大家的反馈对我们来说至关重要，将能帮助我们确定到底哪些内容是读者真正需要的。

如果你有一般性建议的话，请发邮件至feedback@packtpub.com，请在邮件主题中写清书的名称。

如果你是某一方面的专家，对某个主题特别感兴趣，有意向自己或是与别人合作写一本书，请到www.packtpub.com/authors查阅我们为作者准备的帮助文档。

客户支持

为自己拥有一本Packt出版的书而自豪吧！为了让你的书物有所值，我们还为你准备了以下内容。

下载示例代码

如果你是从http://www.packtpub.com网站购买的图书，用自己的账号登录后，可以下载所有已购图书的示例代码。如果你是从其他地方购买的，请访问http://www.packtpub.com/support网站并注册，我们会用邮件把代码文件直接发给你。

下载配套PDF文件

我们还为你准备了一个PDF文件，该文件包含书中的所有屏幕截图/图表。这些彩色图像能弥补图书中黑白图像的不足，有助于你理解本书内容。该文件的下载地址为https://www.packtpub.com/sites/default/files/downloads/6053OS_ColorImages.pdf。

勘误表

即使我们竭尽所能来保证图书内容的正确性，错误也在所难免。如果你在我们出版的任何一本书中发现错误——可能是在文本或代码中——倘若你能告诉我们，我们将会非常感激。你的善举足以减少其他读者在阅读出错位置时的纠结和不快，帮助我们在后续版本中更正错误。如果你发现任何错误，请访问http://www.packtpub.com/submit-errata，选择相应书籍，点击“Errata Submission Form”链接，输入错误之处的具体信息。你提交的错误得到验证之后，我们就会接受你的建议，该处错误信息将会上传到我们的网站或是添加到已有勘误表的相应位置。

访问https://www.packtpub.com/books/content/support，在搜索框中输入书名，可查看该图书已有的勘误信息。这部分信息会在Errata部分显示。

侵权

所有媒体在互联网上都面临的一个问题就是侵权。对Packt来说，我们严格保护我们的版权和许可证。如果你在网上发现针对我们出版物的任何形式的盗版产品，请立即告知我们地址或网站名称，以便我们进行补救。

请将盗版书籍的网站地址发送到copyright@packtpub.com。

如果你能这么做，就是在保护我们的作者，保护我们，只有这样我们才能继续以优质内容回馈像你这样热心的读者。

问题

你对本书有任何方面的问题，都可以通过questions@packtpub.com邮箱联系我们，我们也将尽最大努力来帮你答疑解惑。

第 1 章　开始数据挖掘之旅

我们今天的数据采集规模在人类历史上是空前的，日常生活也越来越依赖我们所采集的这些信息。我们希望计算机能把网页翻译成其他语言，预报天气，推荐我们喜欢的书，诊断我们的健康问题。类似的需求还会继续增长，我们会需要更多的应用和更高的精确性。数据挖掘技术可以用来训练计算机，使其根据已有数据做出决策。如今，数据挖掘技术已成为支撑很多高科技系统的骨干。

Python的迅速普及并非偶然。它的灵活度高；模块众多，可以执行很多任务；比起其他任何编程语言，Python代码通常更为简洁，可读性更强。Python在数据挖掘领域已经形成了一个由研究员、从业者和新手组成的氛围活跃的大社区。

本章将介绍如何使用Python进行数据挖掘，主要会涉及以下几个主题。

数据挖掘简介及其应用场景

搭建Python数据挖掘环境

亲和性分析示例：根据购买习惯推荐商品

（经典）分类问题示例：根据测量结果推测植物的种类

1.1　数据挖掘简介

数据挖掘旨在让计算机根据已有数据做出决策。决策可以是预测明天的天气、拦截垃圾邮件、检测网站的语言，或者在约会网站上发现新的恋爱对象。数据挖掘方面的应用已经有很多，新的应用也在源源不断地出现。

数据挖掘涉及算法、统计学、工程学、最优化理论和计算机科学相关领域的知识。除此之外，我们还会用到语言学、神经科学、城市规划等其他领域的概念或知识。要想充分发挥数据挖掘的威力，通常需要在算法中整合这些属于特定领域的知识。

虽然数据挖掘相关应用的实现细节可能千差万别，但是从较高的层次看，它们往往大同小异。数据挖掘的第一步一般是创建数据集，数据集能够描述真实世界的某一方面。数据集主要包括以下两个部分。

表示真实世界中物体的样本。样本可以是一本书，一张照片，一个动物，一个人或是其他任何物体。

描述数据集中样本的特征。特征可以是长度、单词频率、腿的数量、创建时间等。

接下来是调整算法。每种数据挖掘算法都有参数，它们或者是算法自身包含的，或者是使用者添加的。这些参数会影响算法的具体决策。

举个简单的例子，我们希望计算机能够把人按照个子高矮分成两大类。我们首先采集数据，得到包含每个人身高的一组数据，以及对他们高矮的判断。

人

身高

高还是矮

1

155cm

矮

2

165cm

矮

3

175cm

高

4

185cm

高

接下来要做的就是调整我们的算法。作为一个简单的算法，如果身高高于x，我们就认为这个人是高个子，否则，他就属于矮个子。我们的算法要过一遍数据，确定x的最佳值。对于上面的数据集，x 比较合理的值为170cm。任何高于170cm的人就被归到高个子一类中，其余则为矮个子。

在上面这个数据集中，特征显而易见为身高。因为我们想知道人们的高矮，所以采集了他们的身高数据。抽取特征是数据挖掘过程的一个重要环节。本书后面的章节中会介绍从数据集中抽取区分度高的特征的方法。特征抽取往往需要对相关领域有着深入的理解，或至少需要多次试错。

　本书中使用Python语言介绍数据挖掘。出于讲解的需要，为了保证代码、流程的清晰易懂，我们有时候跳过了能够提升算法速度、效果的细节，没有采用最优方案。

1.2　使用Python和IPython Notebook

本节将介绍Python的安装方法，及本书所用到的开发环境IPython Notebook的搭建方法。此外，还将安装第一部分示例代码所用到的numpy库。

1.2.1　安装Python

Python是一门出色的、应用范围广泛且简单易用的编程语言。

本书将使用Python 3.4版本，你可以根据自己的系统从Python官网https://www.python.org/downloads/下载合适的版本。

Python主要有两大版本Python 3.4和Python 2.7。记得要下载安装Python 3.4，本书所有代码都在该版本中测试过。

本书假定读者了解编程和Python相关知识。本书不要求你是Python编程高手，当然有较多的知识储备学起来更容易。

如果你没有任何编程经验，我建议你先看看《Python学习手册》。

Python官网为新手准备了两份在线教程。

非程序员背景，想通过Python学习编程：

https://wiki.python.org/moin/BeginnersGuide/NonProgrammers

程序员背景，想专门学习Python：

https://wiki.python.org/moin/BeginnersGuide/Programmers

　Windows用户设置好环境变量后，才能在命令行中使用Python。方法如下。首先，找到Python 3的安装路径，默认为C:\Python34。接下来，在命令行（cmd程序）中输入以下命令：将环境设置为PYTHONPATH=%PYTHONPATH%; C:\ Python341。如果你将Python安装到其他路径下，请根据实际情况调整上述命令中的C:\Python34。

1Python官网介绍了Windows系统的两种环境变量设置方法。建议直接把Python的安装路径添加到Path中，位置如下：计算机—属性—高级系统设置—环境变量，这也是官网介绍的第一种方法。译者使用的就是这一种。作者讲的是第二种方法，详见https://docs.python.org/3.4/using/windows.html#excursus-setting-environment-variables。——译者注

安装好Python，打开命令提示符，输入以下命令：

$ python3
Python 3.4.0 (default, Apr 11 2014, 13:05:11)
[GCC 4.8.2] on Linux
Type "help", "copyright", "credits" or "license" for more information.
>>> print("Hello, world!")
Hello, world!
>>> exit()

请注意，我们用美元符号（$）表示紧跟在后面的语句是一条命令，要输入到终端（Unix系统中的shell，Windows系统中的cmd程序）。美元符号及后面的空格无须输入。输入后面的内容，然后敲回车执行命令。

运行完经典的“Hello, world!”例子后，退出Python，继续安装用来运行Python代码的更为高级的开发环境IPython Notebook。

　Python 3.4内置了Python的包管理器pip，用它来安装Python包很方便。使用 $ pip3 freeze2 命令可以验证pip是否能正常运行，该命令还会输出你用它安装过哪些包。

2Windows用户需要事先把pip添加到环境变量中，才能在命令行使用。——译者注

1.2.2　安装IPython

Python开发平台IPython提供多种Python开发工具和开发环境，比标准解释器多出好多功能。IPython Notebook功能强大，有了它，你就可以在Web浏览器中编写程序。它会为代码添加样式，显示运行结果，允许你为代码添加注释。用它来做数据分析再好不过，我们将把它作为主要的开发环境。

请在命令提示符后（注意不是Python中），输入以下命令安装IPython：

$ pip install ipython[all]

如果要为系统所有用户安装IPython，需要管理员权限。如果你只想自己用或者没有权限做系统级别的变更，则使用以下命令为当前用户安装即可：

$ pip install --user ipython[all]

以上命令只为当前用户安装IPython——该系统的其他用户将无法使用。安装过程中若遇到问题，请查阅官方文档，了解更多帮助信息：http://ipython.org/install.html。

安装好IPython Notebook后，运行方式如下：

$ ipython3 notebook

上述命令帮你做了两件事。首先，在命令提示符界面创建一个IPython Notebook实例。其次，打开Web浏览器，连接到实例，你可以在此创建新的笔记本文件3。Notebook界面如下图所示（注意图中的home/bob为当前用户的主目录，你看到的是自己的主目录，所以目录名称很可能不同）。

3笔记本文件，英文为“notebook”，即用IPython Notebook创建的文件。——译者注

IPython Notebook的关闭方法如下：打开运行实例的终端界面（就是你之前用IPython命令创建Notebook实例的界面），按下Ctrl+C键，系统提示Shutdown this notebook server (y/[n])?，询问你是否关闭笔记本服务器。输入y，敲回车，IPython Notebook就会关闭。

1.2.3　安装scikit-learn库

scikit-learn是用Python开发的机器学习库，它包含大量机器学习算法、数据集、工具和框架。它以Python科学计算的相关工具集为基础，其中numpy和scipy等都针对数据处理任务进行过优化，因此scikit-learn速度快、扩展性强，新手会觉得它很好用，老手也不会觉得它功能逊色。更多内容请见第2章。

scikit-learn库可用Python 3提供的pip工具进行安装，之前没有安装NumPy和SciPy的话，也会顺便安装。用管理员/根用户权限打开一个终端，然后输入以下命令：

$ pip3 install -U scikit-learn

　Windows用户在安装scikitlearn之前，可能需要先安装NumPy和SciPy。安装指南请见www.scipy.org/install.html。

Ubuntu或红帽（Red Hat）等Linux系统的用户也许希望用自带的包管理器安装scikit-learn，但是它们提供的版本很可能不是最新的，所以在安装前需仔细核对版本。本书使用的版本不能低于0.14，否则书中代码可能无法运行。

如何通过编译源文件进行安装，以及更多的安装指南，请见官方文档：

http://scikit-learn.org/stable/install.html。

1.3　亲和性分析示例

终于迎来了第一个数据挖掘的例子，我们拿这个亲和性分析的示例来具体看下数据挖掘到底是怎么回事。数据挖掘有个常见的应用场景，即顾客在购买一件商品时，商家可以趁机了解他们还想买什么，以便把多数顾客愿意同时购买的商品放到一起销售以提升销售额。当商家收集到足够多的数据时，就可以对其进行亲和性分析，以确定哪些商品适合放在一起出售。

1.3.1　什么是亲和性分析

亲和性分析根据样本个体（物体）之间的相似度，确定它们关系的亲疏。亲和性分析的应用场景如下。

向网站用户提供多样化的服务或投放定向广告。

为了向用户推荐电影或商品，而卖给他们一些与之相关的小玩意。

根据基因寻找有亲缘关系的人。

亲和性有多种测量方法。例如，统计两件商品一起出售的频率，或者统计顾客购买了商品1后再买商品2的比率。当然还有别的方法，比如后面章节要讲的计算个体之间的相似度。

1.3.2　商品推荐

商品销售从线下搬到线上后，很多之前靠人工完成的工作只有实现自动化，才有望将生意做大。以向上销售为例，向上销售出自英文up-selling，指的是向已经购买商品的顾客推销另一种商品。原来线下由人工来完成的商品推荐工作，现在依靠数据挖掘技术就能完成，而且每年能为商家多进账几亿美元，强力助推电子商务革命的发展！

我们一起看下简单的商品推荐服务，它背后的思路其实很好理解：人们之前经常同时购买的两件商品，以后也很可能会同时购买。该想法确实很简单吧，可这就是很多商品推荐服务的基础，无论线上还是线下。

这种想法很容易转化为算法。顾客购买商品后，在向他们推荐商品前，先查询一下历史交易数据，找到以往他们购买同样商品的交易数据，看看同时购买了什么，再把它们推荐给顾客即可。该算法实际表现也不错，至少比随机推荐商品更有效。然而，它还有很大的提升空间，这正是数据挖掘一展身手的好机会。

为了简化代码，方便讲解，我们只考虑一次购买两种商品的情况。例如，人们去超市既买了面包，又买了牛奶。作为数据挖据入门性质的例子，我们希望得到下面这样的规则：

如果一个人买了商品X，那么他很有可能购买商品Y。

多件商品的规则会更为复杂，比如购买香肠和汉堡包的顾客比起其他顾客更有可能购买番茄酱，本书中不涉及这样的规则。

1.3.3　在NumPy中加载数据集

下载本书配套代码包，保存到你的计算机上，然后找到这个例子的数据集。本例中，建议你新建一个文件夹，把数据集和代码都放进去。在当前目录4下，启动IPython Notebook，导航进入新建的文件夹，创建一个新的笔记本文件。

4在命令行，切换到新建的文件夹，输入ipython3 notebook命令。——译者注

处理该数据集要用到NumPy的二维数组，书中大部分例子都会用到这种数据结构。数组看上去像是一张表，每一行表示样本中一个个体，每一列表示一种特征。

数组的每一项为个体的某项特征值。说起来有些拗口，为方便讲解，使用如下代码把数据集加载进来，稍后输出数组的部分数据看看效果：

import numpy as np
dataset_filename = "affinity_dataset.txt"
X = np.loadtxt(dataset_filename)

运行IPython Notebook，创建笔记本文件，在第一个格子中输入上述代码。按下Shift+Enter（同时创建新的格子）运行代码。代码运行完毕后，第一个格子左侧的方括号中出现一个表示序号的数字，看到这个数字就表明程序运行结束。第一个格子应该如下所示：

对于笔记本文件，前面的代码运行完后，后面的才能运行；还没有轮到它运行或是在运行中时，方括号中显示一个星号。运行结束后，星号立刻变为序号。

记得把数据集文件和笔记本文件放到同一目录下。否则，请修改上述代码中dataset_filename变量的值。

接下来，我们看看数据集到底是什么样子。在笔记本空格子中输入以下代码，输出数据集的前5行看看：

print(X[:5])

　如果你从http://www.packtpub.com网站购买的图书，登录后即可下载已购图书的代码文件。如果你是从别处购买的图书，访问http://www.packtpub.com/support，注册后，我们可以用电子邮件把你需要的文件发给你。5

5注册后，可自行下载。——译者注

上述代码的运行结果为前5次交易中顾客都买了什么。

输出结果从横向和纵向看都可以。横着看，每次只看一行。第一行(0, 0, 1, 1, 1)表示第一条交易数据所包含的商品。竖着看，每一列代表一种商品。在我们这个例子中，这五种商品分别是面包、牛奶、奶酪、苹果和香蕉。从第一条交易数据中，我们可以看到顾客购买了奶酪、苹果和香蕉，但是没有买面包和牛奶。

每个特征只有两个可能的值，1或0，表示是否购买了某种商品，而不是购买商品的数量。1表示顾客至少买了1个单位的该商品，0表示顾客没有买该种商品。

1.3.4　实现简单的排序规则

正如之前所说，我们要找出“如果顾客购买了商品X，那么他们可能愿意购买商品Y”这样的规则6。简单粗暴的做法是，找出数据集中所有同时购买的两件商品。找出规则后，还需要判断其优劣，我们挑好的规则用。

6一条规则由前提条件和结论两部分组成。——译者注

规则的优劣有多种衡量方法，常用的是支持度（support）和置信度（confidence）。

支持度指数据集中规则应验的次数，统计起来很简单。有时候，还需要对支持度进行规范化，即再除以规则有效前提下的总数量。我们这里只是简单统计规则应验的次数。

支持度衡量的是给定规则应验的比例，而置信度衡量的则是规则准确率如何，即符合给定条件（即规则的“如果”语句所表示的前提条件）的所有规则里，跟当前规则结论一致的比例有多大。计算方法为首先统计当前规则的出现次数，再用它来除以条件（“如果”语句）相同的规则数量。

接下来，通过一个例子来说明支持度和置信度的计算方法，我们看一下怎么求“如果顾客购买了苹果，他们也会购买香蕉”这条规则的支持度和置信度。

如下面的代码所示，通过判断交易数据中sample[3]的值，就能知道一个顾客是否买了苹果。这里，sample表示一条交易信息，也就是数据集里的一行数据。

同理，检测sample[4]的值是否为1，就能确定顾客有没有买香蕉。现在可以计算题目给定规则在数据集中的出现次数，从而计算置信度和支持度。

我们需要统计数据集中所有规则的相关数据。首先分别为规则应验和规则无效这两种情况创建字典。字典的键是由条件和结论组成的元组，元组元素为特征在特征列表中的索引值，不要用实际特征名，比如“如果顾客购买了苹果，他们也会买香蕉”就用(3, 4)表示。如果某个个体的条件和结论均与给定规则相符，就表示给定规则对该个体适用，否则如果通过给定条件推出的结论与给定规则的结论不符，则表示给定规则对该个体无效。

为了计算所有规则的置信度和支持度，首先创建几个字典，用来存放计算结果。这里使用defaultdict，好处是如果查找的键不存在，返回一个默认值。需要统计的量有规则应验、规则无效及条件相同的规则数量。

from collections import defaultdict
valid_rules = defaultdict(int)
invalid_rules = defaultdict(int)
num_occurances = defaultdict(int)

计算过程需要用到循环结构，依次对样本的每个个体及个体的每个特征值进行处理。第一个特征为规则的前提条件——顾客购买了某一种商品。

for sample in X:
  for premise in range(5):

检测个体是否满足条件，如果不满足，继续检测下一个条件。

    if sample[premise] == 0: continue

如果条件满足（即值为1），该条件的出现次数加1。在遍历过程中跳过条件和结论相同的情况，比如 “如果顾客买了苹果，他们也买苹果”，这样的规则没有多大用处7。

7n_samples, n_features = X.shape，详见本书配套代码。——译者注

    num_occurances[premise] += 1
    for conclusion in range(n_features):
        if premise == conclusion: continue

如果规则适用于个体，规则应验这种情况（valid_rules字典中，键为由条件和结论组成的元组）增加一次，反之，违反规则情况（invalid_rules字典中）就增加一次。

    if sample[conclusion] == 1:
      valid_rules[(premise, conclusion)] += 1
    else:
      invalid_rules[(premise, conclusion)] += 1

得到所有必要的统计量后，我们再来计算每条规则的支持度和置信度。如前所述，支持度就是规则应验的次数。

support = valid_rules

置信度的计算方法类似，遍历每条规则进行计算。

confidence = defaultdict(float)
for premise, conclusion in valid_rules.keys():
    rule = (premise, conclusion)
    confidence[rule] = valid_rules[rule] / num_occurances[premise]

我们得到了支持度字典和置信度字典，分别包含每条规则的支持度和置信度。我们再来声明一个函数，接收的参数有：分别作为前提条件和结论的特征索引值、支持度字典、置信度字典以及特征列表。输出每条规则及其支持度和置信度，对输出进行格式化，以方便查看。

def print_rule(premise, conclusion,
              support, confidence, features):

之前建立的features列表派上用场了，每条规则的条件、结论就是用features列表中特征的索引来表示的。输出时，把索引替换成相应的特征，更容易读懂。

    premise_name = features[premise]
    conclusion_name = features[conclusion]
    print("Rule: If a person buys {0} they will also buy
      {1}".format(premise_name, conclusion_name))

接着输出规则的支持度和置信度。

    print(" - Support: {0}".format(support[(premise,
                                            conclusion)]))
    print(" - Confidence: {0:.3f}".format(confidence[(premise,
                                                    conclusion)]))

写完后，自己测试一下代码是否可用——尝试更换条件和结论，看看输出结果如何。

1.3.5　排序找出最佳规则

得到所有规则的支持度和置信度后，为了找出最佳规则，还需要根据支持度和置信度对规则进行排序，我们分别看一下这两个标准。

要找出支持度最高的规则，首先对支持度字典进行排序。字典中的元素（一个键值对）默认为没有前后顺序；字典的items()函数返回包含字典所有元素的列表。我们使用itemgetter()类作为键，这样就可以对嵌套列表进行排序。itemgetter(1)表示以字典各元素的值（这里为支持度）作为排序依据，reverse=True表示降序排列。

from operator import itemgetter
sorted_support = sorted(support.items(), key=itemgetter(1), re
verse=True)

排序完成后，就可以输出支持度最高的前5条规则。

for index in range(5):
    print("Rule #{0}".format(index + 1))
    premise, conclusion = sorted_support[index][0]
    print_rule(premise, conclusion, support, confidence, features)

结果如下所示：

同理，我们还可以输出置信度最高的规则。首先根据置信度进行排序。

sorted_confidence = sorted(confidence.items(), key=itemgetter(1),
reverse=True)

再次输出看看结果。注意输出方法相同，但是请留意下面第三行代码里sorted_confidence的变化，不要继续使用sorted_support。

for index in range(5):
    print("Rule #{0}".format(index + 1))
    premise, conclusion = sorted_confidence[index][0]
    print_rule(premise, conclusion, support, confidence, features)

从排序结果来看，“顾客买苹果，也会买奶酪”和“顾客买奶酪，也会买香蕉”，这两条规则的支持度和置信度都很高。超市经理可以根据这些规则来调整商品摆放位置。例如，如果本周苹果促销，就在旁边摆上奶酪。但是香蕉和奶酪同时搞促销就没有多大意义了，因为我们发现购买奶酪的顾客中，接近66%的人即使不搞促销也会买香蕉——即使搞促销，也不会给销量带来多大提升。

从上面这个例子就能看出数据挖掘的洞察力有多强大。人们可以用数据挖掘技术探索数据集中各变量之间的关系，寻找新发现。接下来一节，我们看看数据挖掘的另一个功能：预测。

1.4　分类问题的简单示例

在上述亲和性分析例子中，我们寻找的是数据集中不同变量之间的相关性。而分类问题，只关注类别（也叫作目标）这个变量。上述例子中，假如我们关心的是怎样让顾客买更多的苹果，就可以把是否购买苹果作为类别，使用分类方法，只寻找促成顾客购买苹果的规则。

1.5　什么是分类

分类是数据挖掘领域最为常用的方法之一，不论是实际应用还是科研，都少不了它的身影。对于分类问题，我们通常能拿到表示实际对象或事件的数据集，我们知道数据集中每一条数据所属的类别，这些类别把一条条数据划分为不同的类。什么是类别？类别的值又是怎么回事？我们来看下面几个例子。

根据检测数据确定植物的种类。类别的值为“植物属于哪个种类？”。

判断图像中有没有狗。类别是“图像里有狗吗？”。

根据化验结果，判断病人有没有患上癌症。类别是“病人得癌症了吗？”。

上述三个问题中有两个是二值（是/否）问题，但正如第一个确定植物类别的问题，多个类别的情况也很常见。

分类应用的目标是，根据已知类别的数据集，经过训练得到一个分类模型，再用模型对类别未知的数据进行分类。例如，我们可以对收到的邮件进行分类，标注哪些是自己希望收到的，哪些是垃圾邮件，然后用这些数据训练分类模型，实现一个垃圾邮件过滤器，这样以后再收到邮件，就不用自己去确认它是不是垃圾邮件了，过滤器就能帮你搞定。

1.5.1　准备数据集

我们接下来将使用著名的Iris植物分类数据集。这个数据集共有150条植物数据，每条数据都给出了四个特征：sepal length、sepal width、petal length、petal width（分别表示萼片和花瓣的长与宽），单位均为cm。这是数据挖掘中的经典数据集之一（1936年就用到了数据挖掘领域！）。该数据集共有三种类别：Iris Setosa（山鸢尾）、Iris Versicolour（变色鸢尾）和Iris Virginica（维吉尼亚鸢尾）。我们这里的分类目的是根据植物的特征推测它的种类。

scikit-learn库内置了该数据集，可直接导入。

from sklearn.datasets import load_iris
dataset = load_iris()
X = dataset.data
y = dataset.target

用print(dataset.DESCR)命令查看数据集，大概了解一下，包括特征的说明。

数据集中各特征值为连续型，也就是有无数个可能的值。测量得到的数据就是这个样子，比如，测量结果可能是1、1.2或1.25，等等。连续值的另一个特点是，如果两个值相近，表示相似度很大。一种萼片长1.2cm的植物跟一种萼片宽1.25cm的植物很相像。

与此相反，类别的取值为离散型。虽然常用数字表示类别，但是类别值不能根据数值大小比较相似性。Iris数据集用不同的数字表示不同的类别，比如类别0、1、2分别表示Iris Setosa、Iris Versicolour、Iris Virginica。但是这不能说明前两种植物，要比第一种和第三种更相近——尽管单看表示类别的数字时确实如此。在这里，数字表示类别，只能用来判断两种植物是否属于同一种类别，而不能说明是否相似。

当然，还有其他类型的特征，后续章节会讲到其中几种。

数据集的特征为连续值，而我们即将使用的算法使用类别型特征值，因此我们需要把连续值转变为类别型，这个过程叫作离散化。

最简单的离散化算法，莫过于确定一个阈值，将低于该阈值的特征值置为0，高于阈值的置为1。我们把某项特征的阈值设定为该特征所有特征值的均值。每个特征的均值计算方法如下。

attribute_means = X.mean(axis=0)

我们得到了一个长度为4的数组，这正好是特征的数量。数组的第一项是第一个特征的均值，以此类推。接下来，用该方法将数据集打散，把连续的特征值转换为类别型。

X_d = np.array(X >= attribute_means, dtype='int')

后面的训练和测试，都将使用新得到的X_d数据集（打散后的数组X），而不再使用原来的数据集（X）。

1.5.2　实现OneR算法

OneR算法的思路很简单，它根据已有数据中，具有相同特征值的个体最可能属于哪个类别进行分类。OneR是One Rule（一条规则）的简写，表示我们只选取四个特征中分类效果最好的一个用作分类依据。后续章节中的分类算法比起OneR要复杂很多，但这个看似不起眼的简单算法，在很多真实数据集上表现得也不凡。

算法首先遍历每个特征的每一个取值，对于每一个特征值，统计它在各个类别中的出现次数，找到它出现次数最多的类别，并统计它在其他类别中的出现次数。

举例来说，假如数据集的某一个特征可以取0或1两个值。数据集共有三个类别。特征值为0的情况下，A类有20个这样的个体，B类有60个，C类也有20个。那么特征值为0的个体最可能属于B类，当然还有40个个体确实是特征值为0，但是它们不属于B类。将特征值为0的个体分到B类的错误率就是40%，因为有40个这样的个体分别属于A类和C类。特征值为1时，计算方法类似，不再赘述；其他各特征值最可能属于的类别及错误率的计算方法也一样。

统计完所有的特征值及其在每个类别的出现次数后，我们再来计算每个特征的错误率。计算方法为把它的各个取值的错误率相加，选取错误率最低的特征作为唯一的分类准则（OneR），用于接下来的分类。

现在，我们就来实现该算法。首先创建一个函数，根据待预测数据的某项特征值预测类别，并给出错误率。在这之前需要导入前面用过的defaultdict和itemgetter模块。

from collections import defaultdict
from operator import itemgetter

下面创建函数声明，参数分别是数据集、类别数组、选好的特征索引值、特征值。

def train_feature_value(X, y_true, feature_index, value):

接下来遍历数据集中每一条数据（代表一个个体），统计具有给定特征值的个体在各个类别中的出现次数。

    class_counts = defaultdict(int)
    for sample, y in zip(X, y_true):
        if sample[feature_index] == value:
            class_counts[y] += 1

对class_counts字典进行排序，找到最大值，就能找出具有给定特征值的个体在哪个类别中出现次数最多。

    sorted_class_counts = sorted(class_counts.items(),
      key=itemgetter(1), reverse=True)
        most_frequent_class = sorted_class_counts[0][0]

接着计算该条规则的错误率。OneR算法会把具有该项特征值的个体统统分到上面找到的出现次数最多的类别中。错误率为具有该特征值的个体在其他类别（除出现次数最多的类别之外的）中的出现次数，它表示的是分类规则不适用的个体的数量。

incorrect_predictions = [class_count for class_value, class_count
                        in class_counts.items()
                        if class_value != most_frequent_class]
error = sum(incorrect_predictions)

最后返回使用给定特征值得到的待预测个体的类别和错误率。

    return most_frequent_class, error

对于某项特征，遍历其每一个特征值，使用上述函数，就能得到预测结果和每个特征值所带来的错误率，然后把所有错误率累加起来，就能得到该特征的总错误率。我们来定义一个函数，实现这些操作。

函数声明如下，这次只用到三个参数，上面已经介绍过。

def train_on_feature(X, y_true, feature_index):

接下来找出给定特征共有几种不同的取值。下面这行代码X[:,feature_index]以数组的形式返回由feature_index所指的列。然后用set函数将数组转化为集合，从而找出有几种不同的取值。

    values = set(X[:,feature_index])

再创建字典predictors，用作预测器。字典的键为特征值，值为类别。比如键为1.5、值为2，表示特征值为1.5的个体属于类别2。创建errors列表，存储每个特征值的错误率。

    predictors = {}
    errors = []

函数的主干部分遍历选定特征的每个不同的特征值，用前面定义的train_feature_value()函数找出每个特征值最可能的类别，计算错误率，并将其分别保存到预测器predictors和errors中。

    for current_value in values:
      most_frequent_class, error = train_feature_value(X,
        y_true, feature_index, current_value)
      predictors[current_value] = most_frequent_class
      errors.append(error)

最后，计算该规则的总错误率，返回预测器及总错误率。

    total_error = sum(errors)
    return predictors, total_error

1.5.3　测试算法

上一节中亲和性分析算法的目标是从数据集中发现用以指导实践的规则。而分类问题有所不同，我们想建立一个能够根据已有知识对没有见过的个体进行分类的模型。

我们因此把机器学习流程分为两步：训练和测试。在训练阶段，我们从数据集中取一部分数据，创建模型。在测试阶段，我们测试模型在数据集上的分类效果。考虑到模型的目标是对新个体进行分类，因此不能用测试数据训练模型，因为这样做容易导致过拟合问题。

过拟合指的是模型在训练集上表现很好，但对于没有见过的数据表现很差。解决方法很简单：千万不要用训练数据测试算法。详细的处理方法很复杂，后续章节会有所涉及；我们这里简单化处理，把数据集分为两个小部分，分别用于训练和测试。具体流程接下来会介绍。

scikit-learn库提供了一个将数据集切分为训练集和测试集的函数。

from sklearn.cross_validation import train_test_split

该函数根据设定的比例（默认把数据集的25%作为测试集）将数据集随机切分为两部分，以确保测试结果的可信度。

X_train, X_test, y_train, y_test = train_test_split(X_d, y, random_state=14)

这样我们就得到了两个数据集：训练集X_train和测试集X_test。y_train和y_test分别为以上两个数据集的类别信息。

切分函数的第三个参数random_state用来指定切分的随机状态。每次切分，使用相同的随机状态，切分结果相同。虽然看起来是随机的，但是它所使用的算法是确定的，输出结果也是一致的。在书中所有用到random_state的地方，我建议你跟我使用同一个值，这样你得到的结果就应该跟我的相同，这样便于你验证结果。把random_state的值设置为none，每次切分结果将是真正随机的。

接下来，计算所有特征值的目标类别（预测器）。记得只使用训练集。遍历数据集中的每个特征，使用我们先前定义的函数train_on_feature()训练预测器，计算错误率。

all_predictors = {}
errors = {}
for feature_index in range(X_train.shape[1]):
  predictors, total_error = train_on_feature(X_train, y_train,
    feature_index)
  all_predictors[feature_index] = predictors
  errors[feature_index] = total_error

然后找出错误率最低的特征，作为分类的唯一规则。

best_variable, best_error = sorted(errors.items(), key=itemgetter(1))
[0]

对预测器进行排序，找到最佳特征值，创建model模型。

model = {'feature': best_variable,
  'predictor': all_predictors[best_variable]}

model模型是一个字典结构，包含两个元素：用于分类的特征和预测器。有了模型后，就可以根据特征值对没有见过的数据进行分类。示例如下：

variable = model['variable']
predictor = model['predictor']
prediction = predictor[int(sample[variable])]

我们经常需要一次对多条数据进行预测，为此用上面的代码实现了下面这个函数，通过遍历数据集中的每条数据来完成预测。

def predict(X_test, model):
    variable = model['variable']
    predictor = model['predictor']
    y_predicted = np.array([predictor[int(sample[variable])] for
      sample in X_test])
    return y_predicted

我们用上面这个函数预测测试集中每条数据的类别。

y_predicted = predict(X_test, model)

比较预测结果和实际类别，就能得到正确率是多少。

accuracy = np.mean(y_predicted == y_test) * 100
print("The test accuracy is {:.1f}%".format(accuracy))

输出结果为65.8%，对于只使用一条规则来说，这就很不错了！

1.6　小结

本章介绍了如何用Python进行数据挖掘。如果你能运行这一部分的代码8（见代码包第1章的文件夹），说明开发环境已搭建好，后续章节的大部分代码都能运行了。当然有些Python库还没装，随用随装就好。

8如果.ipynb文件在Notebook中打开时报错，请用JSON检查工具查找有无不合法的JSON字符，自行调整一下。——译者注

我们用IPython Notebook运行了代码，好处是能及时看到一小块代码的输出。它功能强大，后面会继续使用。

我们举了一个简单的亲和性分析的例子，用它找出顾客经常一起购买的商品。这种探索性的分析方法用处很大，能帮助人们发现商业流程、某个环境或场景中的潜在规律。亲和性分析可用在商业、医疗、人工智能等领域，说不定能这些领域带来突破。

本章还通过OneR算法介绍了分类的应用。该算法寻找最佳的特征值用于分类，该特征值在训练集中哪个类别中出现的次数最多，待预测数据就属于哪个类别。

后续章节会扩展分类和亲和性分析的概念，同时还会介绍scikit-learn库以及它实现的一些数据挖掘算法。

第 2 章　用scikit-learn估计器分类

用Python语言编写的scikit-learn库，实现了一系列数据挖掘算法，提供通用编程接口、标准化的测试和调参工具，便于用户尝试不同算法对其进行充分测试和查找最优参数值。有大量使用scikit-learn库的算法和工具。

本章讲解数据挖掘通用框架的搭建方法。有了这样一个框架，后续章节就可以把讲解重点放到数据挖掘应用和技术上面。

本章主要介绍如下几个概念。

估计器（Estimator）：用于分类、聚类和回归分析。

转换器（Transformer）：用于数据预处理和数据转换。

流水线（Pipeline）：组合数据挖掘流程，便于再次使用。

2.1　scikit-learn估计器

为帮助用户实现大量分类算法，scikit-learn把相关功能封装成所谓的估计器。估计器用于分类任务，它主要包括以下两个函数。

fit()：训练算法，设置内部参数。该函数接收训练集及其类别两个参数。

predict()：参数为测试集。预测测试集类别，并返回一个包含测试集各条数据类别的数组。

大多数scikit-learn估计器接收和输出的数据格式均为numpy数组或类似格式。

scikit-learn提供了大量估计器，其中有支持向量机（SVM）、随机森林、神经网络等，多数算法本书都会有所涉及。本章先介绍scikit-learn中的近邻算法。

我们需要安装matplotlib库，作图时会用到。最简单的安装方法就是用pip3来安装，在第1章安装scikitlearn时用过。

$pip3 install matplotlib

安装过程中若遇到任何问题，请参考官方给出的安装指南：http://matplotlib.org/users/installing.html。

2.1.1　近邻算法

近邻算法可能是标准数据挖掘算法中最为直观的一种。为了对新个体进行分类，它查找训练集，找到与新个体最相似的那些个体，看看这些个体大多属于哪个类别，就把新个体分到哪个类别。

举例来说，我们要根据三角形更像什么（跟哪种图形离得更近），预测三角形的类别。我们找到三个离它最近的邻居：两个菱形和一个圆。菱形的数量多于圆，因此我们预测三角形的类别为菱形。

近邻算法几乎可以对任何数据集进行分类，但是，要计算数据集中每两个个体之间的距离，计算量很大。例如，数据集中个体数量为10时，需要计算45对不同个体之间的距离。然而，当个体数量为1000时，要计算大约50万对个体之间的距离！现在有很多提升该算法速度的方法，后续章节会讲到几种。

除了计算量大之外，该算法还有一个问题，就是在特征取离散值的数据集上表现很差。遇到这种情况，应该考虑使用其他算法。

2.1.2　距离度量

距离是数据挖掘的核心概念之一。我们往往需要知道两个个体之间的距离是多少。更进一步说，我们还得能够解决一对个体相对另一对个体是否更相近等问题。这类问题的解决方法，将直接影响分类结果。

人们耳熟能详的距离度量方法就是欧氏距离，即真实距离。假如你在图像中画两个点，用直尺测量这两个点之间的距离，得到的结果就是欧氏距离。稍微正式点来说，它其实是两个特征向量长度平方和的平方根。

欧氏距离确实很直观，但是如果某些特征比其他特征取值大很多，精确度就会比较差。此外，如果很多特征值为0，也就是所谓的稀疏矩阵，结果也不准确。这时可以用其他距离度量方法，常用的有曼哈顿距离和余弦距离。

曼哈顿距离为两个特征在标准坐标系中绝对轴距之和（没有使用平方距离）。拿国际象棋中的车1举例子，这样更形象。假如车每次只能走一格，那么它走到当前格子对角线那头，所走的距离就是曼哈顿距离。虽然异常值也会影响分类结果，但是其所受的影响要比欧氏距离小得多。

1国际象棋中车的走法跟我国象棋中车的走法相同，只能沿水平或垂直方向移动。——译者注

余弦距离更适合解决异常值和数据稀疏问题。直观上讲，余弦距离指的是特征向量夹角的余弦值。下面为以上三种距离的示意图。

在上面每张图中，两个灰圆与白圆之间的距离是相等的。左图是欧氏距离，因此两个灰圆都落在以白圆为圆心的同一个圆的圆周上，可以用尺子量量看。中间这幅图表示的是曼哈顿距离，它指的是从灰圆到白圆所走的横向和纵向距离之和，也叫街区距离（City Block），测量时要想象棋中车的走法。右图是余弦距离示意图，计算夹角之间的余弦值，忽略特征向量的长度。

采用哪种距离度量方法对最终结果有很大影响。例如，你的数据集有很多特征，但是如果任意一对个体之间的欧氏距离都相等，那么你就没法通过欧氏距离进行比较了！曼哈顿距离在某些情况下具有更高的稳定性，但是如果数据集中某些特征值很大，用曼哈顿距离的话，这些特征会掩盖其他特征间的邻近关系。最后，再来说说余弦距离，它适用于特征向量很多的情况，但是它丢弃了向量长度所包含的在某些场景下可能会很有用的一些信息。

本章，我们主要介绍欧氏距离，其他距离后面章节再介绍。

2.1.3　加载数据集

即将用到的数据集叫作电离层（Ionosphere），这些数据是由高频天线收集的。这些天线的目的是侦测在电离层和高层大气中存不存在由自由电子组成的特殊结构。如果一条数据能给出特殊结构存在的证据，这条数据就属于好的那一类（在数据集中用“g”表示），否则就是坏的（用“b”表示）。我们要做的就是建立分类器，自动判断这些数据的好坏。

（图像来自https://www.flickr.com/photos/geckzilla/16149273389/）

Ionosphere数据集可以从UCI机器学习数据库下载，该数据库包含大量数据集，可用于多种数据挖掘任务。打开http://archive.ics.uci.edu/ml/datasets/Ionosphere，点击Data Folder。在随后打开的页面中，下载ionosphere.data和ionosphere.names文件。把这两个文件保存到用户主目录下的Data文件夹中。

主目录的位置取决于操作系统。Windows系统中通常为C:\Documents and Settings\username。Mac和Linux系统中为/home/username。如果你不确定，可以使用下面的Python代码输出主目录所在位置：

import os
print(os.path.expanduser("~"))

该数据集每行有35个值，前34个为17座天线采集的数据（每座天线采集两个数据）。最后一个值不是“g”就是“b”，表示数据的好坏，即是否提供了有价值的信息。

启动IPython Notebook服务器，新建名为Ionosphere Nearest Neighbors的笔记本文件。

首先，导入numpy和csv库，下面会用到。

import numpy as np
import csv

加载数据集前，用Data文件夹路径、数据集所在的文件夹名称和数据集名称组合成数据集文件的完整路径。

data_filename = os.path.join(data_folder, "Ionosphere",
"ionosphere.data")

创建Numpy数组X和y存放数据集。数据集大小已知，共有351行34列。在以后的实际工作中，如果你不知道数据集大小也没关系——后面章节会讲如何在不知道数据集大小的情况下加载它，具体怎么做现在不知道也没关系。

X = np.zeros((351, 34), dtype='float')
y = np.zeros((351,), dtype='bool')

Ionosphere数据集文件为CSV（Comma-Separated Values，用逗号分隔数据项）格式，这是常用的数据集存储格式。我们用csv模块来导入数据集文件，并创建csv阅读器对象。

with open(data_filename, 'r') as input_file:
    reader = csv.reader(input_file)

接着，遍历文件中的每一行数据。每行数据代表一组测量结果，我们可以将其称作数据集中的一个个体。用枚举函数来获得每行的索引号，在下面更新数据集X中的某一个体时会用到行号。

    for i, row in enumerate(reader):

获取每一个个体的前34个值，将其强制转化为浮点型，保存到X中。

    data = [float(datum) for datum in row[:-1]]
    X[i] = data

最后，获取每个个体最后一个表示类别的值，把字母转化为数字，如果类别为“g”，值为1，否则值为0。

    y[i] = row[-1] == 'g'

到此，我们就把数据集读到了数组X中，类别读入了数组y中，与我们在上一章的做法相同。

2.1.4　努力实现流程标准化

正如本章开头讲过的，scikit-learn估计器由两大函数组成：fit()和predict()。用fit方法在训练集上完成模型的创建，用predict方法在测试集上评估效果。

首先，需要创建训练集和测试集。导入并运行train_test_split函数。

from sklearn.cross_validation import train_test_split
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=14)

然后，导入 K 近邻分类器这个类，并为其初始化一个实例。现阶段，参数用默认的即可，后面再讲参数调优。该算法默认选择5个近邻作为分类依据。

from sklearn.neighbors import KNeighborsClassifier
estimator = KNeighborsClassifier()

估计器创建好后，接下来就要用训练数据进行训练。K 近邻估计器分析训练集中的数据，比较待分类的新数据点和训练集中的数据，找到新数据点的近邻。

estimator.fit(X_train, y_train)

接着，用测试集测试算法，评估它在测试集上的表现。

y_predicted = estimator.predict(X_test)
accuracy = np.mean(y_test == y_predicted) * 100
print("The accuracy is {0:.1f}%".format(accuracy))

正确率为86.4%。使用默认参数，只用少数几行代码就能达到这个效果，真是很厉害！虽然scikit-learn提供的大多数默认参数适用范围广，效果也不错，但我们还是要学着根据实验的实际情况，尽可能选用合适的参数值，争取达到最佳效果。

2.1.5　运行算法

在先前的几个实验中，我们把数据集分为训练集和测试集，用训练集训练算法，在测试集上评估效果。倘若碰巧走运，测试集很简单，我们就会觉得算法表现很出色。反之，我们可能会怀疑算法很糟糕。也许由于我们一时不走运，就把一个其实很不错的算法给无情抛弃了，这岂不是很可惜。

交叉检验能解决上述一次性测试所带来的问题。既然只切一次有问题，那就多切几次，多进行几次实验。每次切分时，都要保证这次得到的训练集和测试集与上次不一样，还要确保每条数据都只能用来测试一次。算法描述如下。

(1) 将整个大数据集分为几个部分（fold2）。

2行话叫“折”。——译者注

(2) 对于每一部分执行以下操作：

将其中一部分作为当前测试集

用剩余部分训练算法

在当前测试集上测试算法

(3) 记录每次得分及平均得分。

(4) 在上述过程中，每条数据只能在测试集中出现一次，以减少（但不能完全规避）运气成分。

　书中同一章的代码，前后是紧密联系的，所以需要把它们放到一个IPython Notebook笔记本文件中，除非我告诉你不必这么做。

scikit-learn提供了几种交叉检验方法。有个辅助函数实现了上述交叉检验步骤，现在把它导进来。

from sklearn.cross_validation import cross_val_score

　cross_val_score默认使用Stratified K Fold方法切分数据集，它大体上保证切分后得到的子数据集中类别分布相同，以避免某些子数据集出现类别分布失衡的情况。这个默认做法很不错，现阶段就不再把它搞复杂了。

我们就来试试这个函数吧，把完整的数据集和类别值传给它。

scores = cross_val_score(estimator, X, y, scoring='accuracy')
average_accuracy = np.mean(scores) * 100
print("The average accuracy is {0:.1f}%".format(average_accuracy))

哦，结果为82.3%，较之前稍微差点，但考虑到我们还没有尝试调整参数，这个结果还是相当不错的。下一节，我们就来研究怎么通过调整参数达到更理想的效果。

2.1.6　设置参数

几乎所有的数据挖掘算法都允许用户设置参数，这样做的好处是增强算法的泛化能力。但是，参数设置可是项技术活，选取好的参数值跟数据集的特征息息相关。

近邻算法有多个参数，最重要的是选取多少个近邻作为预测依据。scikit-learn管这个参数叫n_neighbors。下图给出两个极端的例子，n_neighbors过小时，分类结果容易受干扰，随机性很强。相反，如果n_neighbors过大，实际近邻的影响将削弱。

左图(a)中，我们通常希望把测试数据（三角形）归到圆形类别。然而，如果n_neighbors的值为1，由于三角形附近红色菱形（很可能是噪音）的存在，导致分类结果为菱形，虽然菱形集中在右下角区域。右图(b)中，我们希望将测试数据归到菱形类别。然而，如果n_neighbors值为7，三个最近的邻居（都是菱形）被四个圆形给击败了，三角形也因此被归到圆形类别。

如果想测试一系列n_neighbors的值，比如从1到20，可以重复进行多次实验，观察不同的参数值所带来的结果之间的差异。

avg_scores = []
all_scores = []
parameter_values = list(range(1, 21))  # Include 20
for n_neighbors in parameter_values:
    estimator = KNeighborsClassifier(n_neighbors=n_neighbors)
    scores = cross_val_score(estimator, X, y, scoring='accuracy')

把不同n_neighbors值的得分和平均分保存起来，留作分析用。

    avg_scores.append(np.mean(scores))
    all_scores.append(scores)

为了看起来更直观，我们可以用图表来表示n_neighbors的不同取值和分类正确率之间的关系。首先需要告诉IPython Notebook，我们要在笔记本中作图。

%matplotlib inline

然后，从matplotlib库导入pyplot，参数为近邻数和平均正确率。

from matplotlib import pyplot as plt plt.plot(parameter_values,
avg_scores, '-o')

从上图可以看到，虽然有很多曲折变化，但整体趋势是随着近邻数的增加，正确率不断下降。

2.2　流水线在预处理中的应用

现实中，物体不同特征的取值范围会非常广，它们的值域可能存在天壤之别。例如，测量动物的属性，会得到下面这样千差万别的特征值。

腿的数量：大多数动物有0到8条腿，但也有比这多得多的！

体重：从几微克到上百吨都有可能，有的蓝鲸重达190吨！

心脏数量：0到5之间，蚯蚓就有5颗心脏。

对于借助数学方法来比较特征的算法而言，它们很难理解特征在规模、范围和单位上的差异。如果我们在多种算法中使用上述特征，体重由于数值较大，可能都会是最显著的特征，但特征值大小实际上与该特征的分类效果没有任何关系。

不同特征的取值范围千差万别，常见的解决方法是对不同的特征进行规范化，使它们的特征值落在相同的值域或从属于某几个确定的类别，比如小、中和大。一旦解决这个问题，不同的特征类型对算法的影响将大大降低，分类正确率就能有大幅提升。

选择最具区分度的特征、创建新特征等都属于预处理的范畴。scikit-learn的预处理工具叫作转换器（Transformer），它接受原始数据集，返回转换后的数据集。除了处理数值型特征，转换器还能用来抽取特征。在这里，我们只看下对数值型特征的预处理方法。

2.2.1　预处理示例

为了讲解需要，先来对Ionosphere数据集做些破坏。虽然这里的麻烦是人为制造的，但是这些问题在很多真实数据集里都存在。首先，为了不破坏原来的数据集，我们为其创建一个副本。

X_broken = np.array(X)

接下来，我们就要捣乱了，将奇数列的特征值除以10。

X_broken[:,::2] /= 10

理论上讲，这样做对结果影响应该不大。毕竟，除以10之后，各个特征相差不大。主要的问题是，数值范围变了，奇数列的第二个特征要比偶数列的大。再次计算正确率看一下效果。

estimator = KNeighborsClassifier()
original_scores = cross_val_score(estimator, X, y,
  scoring='accuracy')
print("The original average accuracy for is
{0:.1f}%".format(np.mean(original_scores) * 100))
broken_scores = cross_val_score(estimator, X_broken, y,
  scoring='accuracy')
print("The 'broken' average accuracy for is
  {0:.1f}%".format(np.mean(broken_scores) * 100))

还记得吧，在原始数据集上的正确率为82.3%，这次跌至71.5%。把特征值转变到0到1之间就能解决这个问题。

2.2.2　标准预处理

我们接下来用MinMaxScaler类进行基于特征的规范化。在本章的笔记本文件中，接着之前的代码写，首先导入所需的类。

from sklearn.preprocessing import MinMaxScaler

这个类可以把每个特征的值域规范化为0到1之间。最小值用0代替，最大值用1代替，其余值介于两者之间。

接下来，对数据集X进行预处理。我们在预处理器MinMaxScaler上调用转换函数。有些转换器要求像训练分类器那样先进行训练，但是MinMaxScaler不需要，直接调用fit_transform()函数，即可完成训练和转换。

X_transformed = MinMaxScaler().fit_transform(X)

X_transformed与X行列数相等，为同型矩阵。然而，前者每列值的值域为0到1。

还有很多其他类似的规范化方法，对于其他类型的应用和特征类型会很有用。

为使每条数据各特征值的和为1，使用sklearn.preprocessing.Normalizer。

为使各特征的均值为0，方差为1，使用sklearn.preprocessing.StandardScaler，常用作规范化的基准。

为将数值型特征的二值化，使用sklearn.preprocessing.Binarizer，大于阈值的为1，反之为0。

后续章节，将会组合运用上述预处理方法及其他转换器对象。

2.2.3　组装起来

现在我们把前几节所讲的代码组合起来，创建一套完整的工作流，处理被破坏过的数据集。

X_transformed = MinMaxScaler().fit_transform(X_broken)
estimator = KNeighborsClassifier()
transformed_scores = cross_val_score(estimator, X_transformed, y,
  scoring='accuracy')
print("The average accuracy for is
  {0:.1f}%".format(np.mean(transformed_scores) * 100))

正确率再次升到82.3%。MinMaxScaler将特征规范化到相同的值域，这样特征就不会仅仅因为值大而具备更强的区分度。简单总结下，异常值会影响近邻算法，不同算法对值域大小的敏感度不同。

2.3　流水线

随着实验的增加，操作的复杂程度也在提高。我们可能需要切分数据集，对特征进行二值化处理，以特征或数据集中的个体为基础规范化数据，除此之外还可能需要其他各种操作。

要跟踪记录所有这些操作可不容易，如果中间出点问题，先前实验的结果将很难再现。常见问题有落下步骤，数据转换错误，或进行了不必要的转换操作等。

另一个问题就是代码的先后顺序。上一节，我们创建了X_transformed数据集，然后创建了一个新的估计器用于交叉检验。如果有多个步骤，就需要跟踪代码中对数据集进行的每一步操作。

流水线结构就是用来解决这些问题的（当然不限于这些，下一章会讲到它在其他方面的应用）。流水线把这些步骤保存到数据挖掘的工作流中。之后你就可以用它们读入数据，做各种必要的预处理，然后给出预测结果。我们可以在cross_val_score等接收估计器的函数中使用流水线。创建流水线前，先导入Pipeline对象。

from sklearn.pipeline import Pipeline

流水线的输入为一连串的数据挖掘步骤，其中最后一步必须是估计器，前几步是转换器。输入的数据集经过转换器的处理后，输出的结果作为下一步的输入。最后，用位于流水线最后一步的估计器对数据进行分类。我们流水线分为两大步。

(1) 用MinMaxScaler将特征取值范围规范到0~1。

(2) 指定KNeighborsClassifier分类器。

每一步都用元组（‘名称’，步骤）来表示。现在来创建流水线。

scaling_pipeline = Pipeline([('scale', MinMaxScaler()),
                             ('predict', KNeighborsClassifier())])

流水线的核心是元素为元组的列表。第一个元组规范特征取值范围，第二个元组实现预测功能。我们把第一步叫作规范特征取值（scale），第二步叫作预测（predict），也可以用其他名字。元组的第二部分是实际的转换器对象或估计器对象。

流水线写好后，运行它很简单。使用先前用到的交叉检验代码看一下实际效果。

scores = cross_val_score(scaling_pipeline, X_broken, y,
scoring='accuracy')
print("The pipeline scored an average accuracy for is {0:.1f}%".
format(np.mean(transformed_scores) * 100))

运行结果跟之前一样（82.3%），表明我们这次用到的步骤跟之前相同。

后续章节会使用更高级的测试方法，而设置流水线就很有必要，因为它能确保代码的复杂程度不至于超出掌控范围。

2.4　小结

本章，我们用scikit-learn库提供的几个方法，创建了运行和评估数据挖掘模型的标准工作流。还介绍了近邻算法，scikit-learn将其封装为一个估计器，使用起来很简单：首先调用fit函数在训练集上进行训练，然后用predict函数在测试集上评估效果。

本章还通过解决不同特征值域影响分类效果的问题，讲解了预处理方法，主要用到了转换器对象和MinMaxScaler类。它们也有训练（fit）和转换方法，在转换阶段，接收数据集，返回处理过的数据集。

下一章，我们尝试把学到的这些概念应用到一个大一点的例子上，学着预测NBA（美国职业篮球联赛）比赛结果，其中使用的数据集可是真实的哦。

第 3 章　用决策树预测获胜球队

本章介绍另一种分类算法——决策树，用它预测NBA篮球赛的获胜球队。比起其他算法，决策树有很多优点，其中最主要的一个优点是决策过程是机器和人都能看懂的，我们使用机器学习到的模型就能完成预测任务。正如我们将在本章讲到的，决策树的另一个优点则是它能处理多种不同类型的特征。

本章主要内容有：

用pandas库加载、处理数据

决策树

随机森林

对真实数据集进行数据挖掘

创建新特征，用强有力的框架对其进行测试

3.1　加载数据集

本章将介绍怎样预测NBA获胜球队。如果你看过NBA，可能知道比赛中两支球队比分咬得很紧，难分胜负，有时最后一分钟才能定输赢，因此预测赢家很难。很多体育赛事都有类似的特点，预期的大赢家也许当天被另一支队伍给打败了。

以往很多对体育赛事预测的研究表明，正确率因体育赛事而异，其上限在70%~80%之间。体育赛事预测多采用数据挖掘或统计学方法。

3.1.1　采集数据

我们将使用NBA 2013—2014赛季的比赛数据。http://Basketball-Reference.com网站提供了NBA及其他赛事的大量资料和统计数据。请按以下方法下载数据。

(1) 在浏览器中打开http://www.basketball-reference.com/leagues/NBA_2014_games.html。

(2) 点击标题Regular Season旁边的Export按钮。

(3) 将文件下载到Data文件夹，记录文件的路径。

数据文件格式为CSV，包含了NBA常规赛季的1230场比赛。

CSV为简单的文本格式文件，每行为一条用逗号分隔的数据（文件格式的名字就是这么来的）。在记事本里输入内容，保存时使用.csv扩展名，也能生成CSV文件。只要能阅读文本文件的编辑器，就能打开CSV文件，也可以用Excel把它作为电子表格打开。

我们用pandas（Python Data Analysis的简写，意为Python数据分析）库加载这些数据，pandas在数据处理方面特别有用。Python内置了读写CSV文件的csv库。但是，考虑到后面创建新特征时还要用到pandas更强大的一些函数，所以我们干脆用pandas加载数据文件。

本章需要安装pandas。最简单的方法就是用pip3来安装，第1章中安装scikitlearn库时用的就是pip3。pandas的安装方法如下：

$pip3 install pandas

安装过程中若遇到任何困难，请访问http://pandas.pydata.org/getpandas.html，根据自己的系统，阅读相关安装指南。

3.1.2　用pandas加载数据集

pandas库是用来加载、管理和处理数据的。它在后台处理数据结构，支持诸计算均值等分析方法。

如果做过大量数据挖掘实验，就会发现自己翻来覆去地编写文件读取、特征抽取等函数。而这些函数每重新实现一次，都可能引入新错误。使用pandas等封装了很多功能的库，能有效减少反复实现上述函数所带来的工作量，并能保证代码的正确性。

本书后面会陆续介绍更多的数据挖掘案例，我们将大量使用pandas。

用read_csv函数就能加载数据集：

import pandas as pd
dataset = pd.read_csv(data_filename)

上述代码会加载数据集，将其保存到数据框（dataframe）中。数据框提供了一些非常好用的方法，后面会用到。我们来看看数据集是否有问题。输入以下代码，输出数据集的前5行：

dataset.ix[:5]

输出结果如下。

从输出结果来看，这个数据集可以用，但存在几个小问题。下面我们就来修复这些问题。

3.1.3　数据集清洗

从上面的输出结果中，我们发现了以下几个问题。

日期是字符串格式，而不是日期对象。

第一行没有数据。

从视觉上检查结果，发现表头不完整或者不正确。

这些问题来自数据，我们可以改动数据本身，但是这样做的话，容易忘记之前做过哪些操作，落下步骤或是弄错哪一步，因而无法重现之前的结果。我们像前一章用流水线跟踪数据预处理流程那样，用pandas对原始数据进行预处理。

pandas.read_csv函数提供了可用来修复数据的参数，导入文件时指定这几个参数就好。导入后，我们还可以修改文件的头部，如下所示：

dataset = pd.read_csv(data_filename, parse_dates=["Date"],
  skiprows=[0,])
dataset.columns = ["Date", "Score Type", "Visitor Team",
  "VisitorPts", "Home Team", "HomePts", "OT?", "Notes"]

经过这些处理之后，结果会有很大改善，我们再来输出前5行看看：

dataset.ix[:5]

结果如下。

即使原始数据很规整，比如刚使用的这个，我们仍需要对其做些调整。其中一个原因是，文件可能来自不同的系统，由于存在兼容性问题，文件也许会发生变化。

既然数据已经准备好，在开始编写预测算法之前，我们先定下一个正确率作为基准。该基准任何算法都应该能达到。

每场比赛有两个队：主场队和客场队。最直接的方法就是拿几率作为基准，猜中的几率为50%。猜测任意一支球队获胜，都有一半胜算。

3.1.4　提取新特征

我们接下来通过组合和比较现有数据抽取特征。首先，确定类别值。在测试阶段，拿算法得到的分类结果与它对比，就能知道结果是否正确。类别可以有多种表示方法，我们这里用1表示主场队获胜，用0表示客场队获胜。对于篮球比赛而言，得分最多的队伍获胜。虽然数据集没有明确给出各球队的胜负情况，但是稍加计算就能得到。

找出主场获胜的球队：

dataset["HomeWin"] = dataset["VisitorPts"] < dataset["HomePts"]

我们把主场获胜球队的数据保存到NumPy数组里，稍后要用scikit-learn分类器对其进行处理。当前pandas和scikit-learn并没有进行整合，但是借助NumPy数组，它们配合地很好。我们用pandas抽取特征后再用scikit-learn抽取特征具体的值。

y_true = dataset["HomeWin"].values

上面的y_true数组保存的是类别数据，scikit-learn可直接读取该数组。

我们还可以创建一些特征用于数据挖掘。有时候，只要把原始数据丢给分类器就行了，但通常需要先抽取数值型或类别型特征。

首先，创建两个能帮助我们进行预测的特征，分别是这两支队伍上场比赛的胜负情况。赢得上场比赛，大致可以说明该球队水平较高。

遍历每一行数据，记录获胜球队。当到达一行新数据时，分别查看该行数据中的两支球队在各自的上一场比赛中有没有获胜的。

创建（默认）字典，存储球队上次比赛的结果。

from collections import defaultdict
won_last = defaultdict(int)

字典的键为球队，值为是否赢得上一场比赛。遍历所有行，在此过程中，更新每一行，为其增加两个特征值：两支球队在上场比赛有没有获胜。

for index, row in dataset.iterrows():
    home_team = row["Home Team"]
    visitor_team = row["Visitor Team"]
    row["HomeLastWin"] = won_last[home_team]
    row["VisitorLastWin"] = won_last[visitor_team]
    dataset.ix[index] = row

请注意，上述代码假定数据集是按照时间顺序排列的。我们所使用的数据集是按这种顺序排列的，如果你的数据集不是这样，你需要把代码中的dataset.iterrows()替换为dataset.sort("Date").iterrows()。

用当前比赛（遍历到的那一行数据所表示的比赛）的结果更新两支球队上场比赛的获胜情况，以便下次再遍历到这两支球队时使用。代码如下：

    won_last[home_team] = row["HomeWin"]
    won_last[visitor_team] = not row["HomeWin"]

上述代码运行结束后，我们多了两个新特征：HomeLastWin和VisitorLastWin。我们再来看下数据集。这次只看前5条意义不大。只有一个球队参加过两场比赛后，我们才知道它在上场比赛表现如何。以下代码将输出本赛季第20~25场比赛：

dataset.ix[20:25]

输出结果如下：

更换上述代码中的索引值，查看其他部分数据。别忘了，一共有1000多场比赛呢！

现在，每个队（包括上个赛季的冠军！）在数据集中第一次出现时，都假定它们在上场比赛中失败。其实可以用上一年的数据弥补缺失的信息，从而改进这个特征，但这里就先做简单处理了。

3.2　决策树

决策树是一种有监督的机器学习算法，它看起来就像是由一系列节点组成的流程图，其中位于上层节点的值决定下一步走向哪个节点。

跟大多数分类算法一样，决策树也分为两大步骤。

首先是训练阶段，用训练数据构造一棵树。上一章的近邻算法没有训练阶段，但是决策树需要。从这个意义上说，近邻算法是一种惰性算法，在用它进行分类时，它才开始干活。相反，决策树跟大多数机器学习方法类似，是一种积极学习的算法，在训练阶段完成模型的创建。

其次是预测阶段，用训练好的决策树预测新数据的类别。以上图为例，["is raining", "very windy"]的预测结果为“Bad”（坏天气）。

创建决策树的算法有多种，大都通过迭代生成一棵树。它们从根节点开始，选取最佳特征，用于第一个决策，到达下一个节点，选择下一个最佳特征，以此类推。当发现无法从增加树的层级中获得更多信息时，算法启动退出机制。

scikit-learn库实现了分类回归树（Classification and Regression Trees，CART）算法并将其作为生成决策树的默认算法，它支持连续型特征和类别型特征。

3.2.1　决策树中的参数

退出准则是决策树的一个重要特性。构建决策树时，最后几步决策仅依赖于少数个体，随意性大。使用特定节点作出推测容易导致过拟合训练数据，而使用退出准则可以防止决策精度过高。

除了设定退出准则外，也可以先创建一棵完整的树，再对其进行修剪，去掉对整个过程没有提供太多信息的节点。这个过程叫作剪枝（pruning）。

scikit-learn库实现的决策树算法给出了退出方法，使用下面这两个选项就可以达到目的。

min_samples_split：指定创建一个新节点至少需要的个体数量。

min_samples_leaf：指定为了保留节点，每个节点至少应该包含的个体数量。

第一个参数控制着决策节点的创建，第二个参数决定着决策节点能否被保留。

决策树的另一个参数是创建决策的标准，常用的有以下两个。

基尼不纯度（Gini impurity）：用于衡量决策节点错误预测新个体类别的比例。

信息增益（Information gain）：用信息论中的熵来表示决策节点提供多少新信息。

3.2.2　使用决策树

从scikit-learn库中导入DecisionTreeClassifier类，用它创建决策树。

from sklearn.tree import DecisionTreeClassifier
clf = DecisionTreeClassifier(random_state=14)

　我们再次设定random_state的值为14。本书中凡是用到random_state的地方，我们都用该值。使用相同的随机种子（random seed），能够保证几次实验结果相同。然而，在以后自己的实验中，为保证算法的性能不是与特定的随机状态值相关，在前后几次实验中，需使用不同的随机状态。

现在我们从pandas数据框中抽取数据，以便用scikit-learn分类器处理。指定需要的列，使用数据框的values属性，就能获取到每支球队的上一场比赛结果。

X_previouswins = dataset[["HomeLastWin", "VisitorLastWin"]].values

跟第2章的近邻算法类似，决策树也是一种估计器，因此它同样有fit和predict方法。我们仍然可以用cross_val_score方法来求得交叉检验的平均正确率：

scores = cross_val_score(clf, X_previouswins, y_true,
scoring='accuracy')
print("Accuracy: {0:.1f}%".format(np.mean(scores) * 100))

正确率为56.1%，比起随机预测来要更准确！我们应该可以做得更好。从数据集中构建有效特征（Feature Engineering，特征工程）是数据挖掘的难点所在，好的特征直接关系到结果的正确率——甚至比选择合适的算法更重要！

3.3　NBA比赛结果预测

尝试使用不同的特征，我们应该能做得更好。cross_val_score方法可用来测试模型的正确率。有了它，我们就可以尝试其他特征的分类效果。

好多潜在特征都可以拿来用。就我们这个挖掘任务而言，具体怎么选择特征呢？我们尝试问自己以下两个问题。

一般而言，什么样的球队水平更高？

两支球队上一次相遇时，谁是赢家？

我们还将加入新球队的数据，以检测算法是否能得到一个用来判断不同球队比赛情况的模型。

组装起来

对于上面第一个特征，我们创建一个叫作“主场队是否通常比对手水平高”的特征，并使用2013赛季的战绩作为特征取值来源。如果一支球队在2013赛季排名在对手前面，我们就认为它的水平更高。

战绩数据下载方法如下。

(1) 在浏览器中打开http://www.basketball-reference.com/leagues/NBA_2013_standings.html。

(2) 找到Expanded Standings部分，该部分包括所有球队的数据。

(3) 点击Export链接。

(4) 将数据保存到数据文件夹中。

回到IPython Notebook笔记本文件，在新格子里输入以下代码。请注意将战绩文件保存到data_folder变量指定的目录下。

standings_filename = os.path.join(data_folder,
"leagues_NBA_2013_standings_expanded-standings.csv")
standings = pd.read_csv(standings_filename, skiprows=[0,1])

在笔记本新格子里输入standings并运行，查看战绩。

standings

输出如下。

接下来，创建一个新特征，创建过程与上个特征类似。遍历每一行，查找主场队和客场队两支球队的战绩。代码如下：

dataset["HomeTeamRanksHigher"] = 0
for index, row in dataset.iterrows():
    home_team = row["Home Team"]
    visitor_team = row["Visitor Team"]

有些球队2014赛季改名了，名字不同但其实还是同一支球队。类似情况在整合不同的数据集时经常遇到！所以在查找球队时，需要把它换成原来的名字，以确保正确找到该球队先前的排名。

    if home_team == "New Orleans Pelicans":
        home_team = "New Orleans Hornets"
    elif visitor_team == "New Orleans Pelicans":
        visitor_team = "New Orleans Hornets"

现在就能得到两支球队的排名，比较它们的排名，更新特征值。

    home_rank = standings[standings["Team"] ==
          home_team]["Rk"].values[0]
          visitor_rank = standings[standings["Team"] ==
          visitor_team]["Rk"].values[0]
          row["HomeTeamRanksHigher"] = int(home_rank > visitor_rank)
          dataset.ix[index] = row

接下来，用cross_val_score函数测试结果。首先，从数据集中抽取所需要的部分。

X_homehigher = dataset[["HomeLastWin", "VisitorLastWin",
  "HomeTeamRanksHigher"]].values

然后，创建DecisionTreeClassifier分类器，进行交叉检验，求得正确率。

clf = DecisionTreeClassifier(random_state=14)
scores = cross_val_score(clf, X_homehigher, y_true,
  scoring='accuracy')
print("Accuracy: {0:.1f}%".format(np.mean(scores) * 100))

现在的正确率是60.3%——比我们之前的结果要好。还能再提高吗？

接下来，我们来统计两支球队上场比赛的情况，作为另一个特征。虽然球队排名有助于预测（排名靠前的胜算更大），但有时排名靠后的球队反而能战胜排名靠前的。原因有很多。例如，排名靠后的球队某些打法恰好能击中强者的软肋。该特征的创建方法与前一个特征类似，首先创建字典，保存上场比赛的获胜队伍，在数据框中建立新特征。代码如下：

last_match_winner = defaultdict(int)
dataset["HomeTeamWonLast"] = 0

然后，遍历每条数据，取到每场赛事的两支参赛队伍。

for index, row in dataset.iterrows():
    home_team = row["Home Team"]
    visitor_team = row["Visitor Team"]

不用考虑哪支球队是主场作战，我们想看一下这两支球队在上一场比赛中到底谁是赢家。因此，按照英文字母表顺序对球队名字进行排序，确保两支球队无论主客场作战，都使用相同的键。

    teams = tuple(sorted([home_team, visitor_team]))

通过查找字典，找到两支球队上次比赛的赢家。然后，更新数据框中这条数据。

    row["HomeTeamWonLast"] = 1 if last_match_winner[teams] ==
      row["Home Team"] else 0
      dataset.ix[index] = row

最后，更新last_match_winner字典，值为两支球队在当前场次比赛中的胜出者，两支球队再相逢时可将其作为参考。

    winner = row["Home Team"] if row["HomeWin"] else row
     ["Visitor Team"]
    last_match_winner[teams] = winner

下面，用新抽取的两个特征创建数据集。观察不同特征组合的分类效果。代码如下：

X_lastwinner = dataset[["HomeTeamRanksHigher", "HomeTeam
  WonLast"]].values
clf = DecisionTreeClassifier(random_state=14)
scores = cross_val_score(clf, X_lastwinner, y_true,
  scoring='accuracy')
print("Accuracy: {0:.1f}%".format(np.mean(scores) * 100))

正确率为60.6%。结果越来越好了。

最后我们来看一下，决策树在训练数据量很大的情况下，能否得到有效的分类模型。我们将会为决策树添加球队，以检测它是否能整合新增的信息。

虽然决策树能够处理特征值为类别型的数据，但scikit-learn库所实现的决策树算法要求先对这类特征进行处理。用LabelEncoder转换器就能把字符串类型的球队名转化为整型。代码如下：

from sklearn.preprocessing import LabelEncoder
encoding = LabelEncoder()

将主场球队名称转化为整型：

encoding.fit(dataset["Home Team"].values)

接下来，抽取所有比赛的主客场球队的球队名（已转化为数值型）并将其组合（在NumPy中叫作“stacking”，是向量组合的意思）起来，形成一个矩阵。代码如下：

home_teams = encoding.transform(dataset["Home Team"].values)
visitor_teams = encoding.transform(dataset["Visitor Team"].values)
X_teams = np.vstack([home_teams, visitor_teams]).T

决策树可以用这些特征值进行训练，但DecisionTreeClassifier仍把它们当作连续型特征。例如，编号从0到16的17支球队，算法会认为球队1和2相似，而球队4和10不同。但其实这没意义，对于两支球队而言，它们要么是同一支球队，要么不同，没有中间状态！

为了消除这种和实际情况不一致的现象，我们可以使用OneHotEncoder转换器把这些整数转换为二进制数字。每个特征用一个二进制数字1来表示。例如，LabelEncoder为芝加哥公牛队分配的数值是7，那么OneHotEncoder为它分配的二进制数字的第七位就是1，其余队伍的第七位就是0。每个可能的特征值都这样处理，而数据集会变得很大。代码如下：

1有多少个特征，二进制数字就有多少位。——译者注

from sklearn.preprocessing import OneHotEncoder
onehot = OneHotEncoder()

在相同的数据集上进行预处理和训练操作，将结果保存起来备用。

X_teams_expanded = onehot.fit_transform(X_teams).todense()

接着，像之前那样在新数据集上调用决策树分类器。

clf = DecisionTreeClassifier(random_state=14)
scores = cross_val_score(clf, X_teams_expanded, y_true,
  scoring='accuracy')
print("Accuracy: {0:.1f}%".format(np.mean(scores) * 100))

正确率为60%，比基准值要高，但是没有之前的效果好。原因可能在于特征数增加后，决策树处理不当。鉴于此，我们尝试修改算法，看看会不会起作用。数据挖掘有时就是不断尝试新算法、使用新特征这样一个过程。

3.4　随机森林

一棵决策树可以学到很复杂的规则。然而，很可能会导致过拟合问题——学到的规则只适用于训练集。解决方法之一就是调整决策树算法，限制它所学到的规则的数量。例如，把决策树的深度限制在三层，只让它学习从全局角度拆分数据集的最佳规则，不让它学习适用面很窄的特定规则，这些规则会将数据集进一步拆分为更加细致的群组。使用这种折中方案得到的决策树泛化能力强，但整体表现稍弱。

为了弥补上述方法的不足，我们可以创建多棵决策树，用它们分别进行预测，再根据少数服从多数的原则从多个预测结果中选择最终预测结果。这正是随机森林的工作原理。

但上述过程有两个问题。一是创建的多棵决策树在很大程度上是相同的——每次使用相同的输入，将得到相同的输出。我们只有一个训练集，如果尝试创建多棵决策树，它们的输入就可能相同（因此输出也相同）。解决方法是每次随机从数据集中选取一部分数据用作训练集。这个过程叫作装袋（bagging）。

第二个问题是用于前几个决策节点的特征非常突出。即使我们随机选取部分数据用作训练集，创建的决策树相似性仍旧很大。解决方法是，随机选取部分特征作为决策依据。

然后，使用随机从数据集中选取的数据和（几乎是）随机选取的特征，创建多棵决策树。这就是随机森林，虽然看上去不是那么直观，但这种算法在很多数据集上效果很好。

3.4.1　决策树的集成效果如何

随机森林算法内在的随机性让人感觉算法的好坏全靠运气。然而，通过对多棵几乎是随机创建的决策树的预测结果取均值，就能降低预测结果的不一致性。我们用方差来表示这种不一致。

方差是由训练集的变化引起的。决策树这类方差大的算法极易受到训练集变化的影响，从而产生过拟合问题。

　对比来说，偏误（bias）是由算法中的假设引起的，而与数据集没有关系。比如，算法错误地假定所有特征呈正态分布，就会导致较高的误差。通过分析分类器的数据模型和实际数据集的匹配情况，就能降低偏误问题的负面影响。

对随机森林中大量决策树的预测结果取均值，能有效降低方差，这样得到的预测模型的总体正确率更高。

一般而言，决策树集成做出了如下假设：预测过程的误差具有随机性，且因分类器而异。因此，使用由多个模型得到的预测结果的均值，能够消除随机误差的影响——只保留正确的预测结果。本书中会介绍更多用集成方法消除误差的例子。

3.4.2　随机森林算法的参数

scikit-learn库中的RandomForestClassifier就是对随机森林算法的实现，它提供了一系列参数。因为它使用了DecisionTreeClassifier的大量实例，所以它俩的很多参数是一致的，比如决策标准（基尼不纯度/信息增益）、max_features和min_samples_split。

当然，集成过程还引入了一些新参数。

n_estimators：用来指定创建决策树的数量。该值越高，所花时间越长，正确率（可能）也越高。

oob_score：如果设置为真，测试时将不使用训练模型时用过的数据。

n_jobs：采用并行计算方法训练决策树时所用到的内核数量。

scikit-learn库提供了用于并行计算的Joblib库。n_jobs指定所用的内核数。默认使用1个内核——如果CPU是多核的，可以多用几个，或者将其设置为-1，开动全部马力。

3.4.3　使用随机森林算法

scikit-learn库实现的随机森林算法使用估计器接口，用交叉检验方法调用它即可，代码跟之前大同小异。

from sklearn.ensemble import RandomForestClassifier
clf = RandomForestClassifier(random_state=14)
scores = cross_val_score(clf, X_teams, y_true, scoring='accuracy')
print("Accuracy: {0:.1f}%".format(np.mean(scores) * 100))

只是靠更换分类器，正确率就提升了0.6%，达到60.6%。

随机森林使用不同的特征子集进行学习，应该比普通的决策树更为高效。下面来看一下多用几个特征效果如何。

X_all = np.hstack([X_home_higher, X_teams])
clf = RandomForestClassifier(random_state=14)
scores = cross_val_score(clf, X_all, y_true, scoring='accuracy')
print("Accuracy: {0:.1f}%".format(np.mean(scores) * 100))

正确率为61.1%——又有所提升！可以使用GridSearchCV类搜索最佳参数，代码如下：

parameter_space = {
  "max_features": [2, 10, 'auto'],
  "n_estimators": [100,],
  "criterion": ["gini", "entropy"],
  "min_samples_leaf": [2, 4, 6],
}
clf = RandomForestClassifier(random_state=14)
grid = GridSearchCV(clf, parameter_space)
grid.fit(X_all, y_true)
print("Accuracy: {0:.1f}%".format(grid.best_score_ * 100))

这次正确率提升较大，达到了64.2%！

输出用网格搜索找到的最佳模型，查看都使用了哪些参数。代码如下：

print(grid.best_estimator_)

下面的代码将给出正确率最高的模型所用到的参数。

RandomForestClassifier(bootstrap=True, compute_importances=None,
  criterion='entropy', max_depth=None, max_features=2,
    max_leaf_nodes=None, min_density=None, min_samples_leaf=6,
      min_samples_split=2, n_estimators=100, n_jobs=1,
        oob_score=False, random_state=14, verbose=0)

3.4.4　创建新特征

从上述几个例子中，我们看到改变特征对算法的表现有很大影响。经过小规模测试发现，仅仅是因为选用不同的特征，正确率竟然提升了10%。

用pandas提供的函数创建特征。代码如下：

dataset["New Feature"] = feature_creator()

feature_creator函数返回数据集中每条数据的各个特征值。常用数据集作为参数。

dataset["New Feature"] = feature_creator(dataset)

最直接的做法是一开始为新特征设置默认的值，比如0。如下所示：

dataset["My New Feature"] = 0

接下来，遍历数据集，计算所需特征。本章曾多次用下面这种形式创建新特征。

  for index, row in dataset.iterrows():
    home_team = row["Home Team"]
    visitor_team = row["Visitor Team"]
    # Some calculation here to alter row
    dataset.ix[index] = row

请注意，上面这种遍历方法效率不高。如果你要用的话，请一次性处理所有特征。常用的“最佳做法”就是每条数据最好只处理一次。

你可以创建下述特征并看一下效果。

球队上次打比赛距今有多长时间？短期内连续作战，容易导致球员疲劳。

两支球队过去五场比赛结果如何？这两个数据要比HomeLastWin和VisitorLastWin更能反映球队的真实水平（抽取特征方法类似）。

球队是不是跟某支特定球队打比赛时发挥得更好？例如，球队在某个体育场里打比赛，即使是客场作战也能发挥得很好。

如果抽取上面这些特征遇到困难，请参考pandas的文档http://pandas.pydata.org/pandas-docs/stable/。此外，还可以尝试从Stack Overflow等社区寻求帮助。

更极致的做法是，借助球员数据来分析每个队的实力以预测输赢。其实，赌徒和体育博彩机构每天都在用这些复杂的特征预测赛事结果来牟利。

3.5　小结

本章使用scikit-learn库的另一个分类器DecisionTreeClassifier，并介绍了如何用pandas处理数据。我们分析了真实的NBA赛事的比赛结果数据，创建新特征用于分类，并在这个过程中发现即使是规整、干净的数据也可能存在一些小问题。

我们发现好的特征对提升正确率很有帮助，还使用了一种集成算法——随机森林，进一步提升正确率。

下一章将会扩展在第1章使用的亲和性分析算法，用来发现相似的电影。我们还将学到如何用算法解决排序问题，以及如何提升数据挖掘算法的可扩展性。

第 4 章　用亲和性分析方法推荐电影

本章学习如何用亲和性分析方法找出在什么情况下两个对象经常一起出现。通俗来讲，这也叫“购物篮分析”，因为曾有人用它找出哪些商品经常一起出售。

第3章关注的对象为球队，并用特征描述球队。本章所用到的电影评分数据有所不同，我们所关注的对象（电影）埋在数据中。本章数据挖掘任务的目标是找出对象同时出现的情况，也就是寻找用户同时喜欢几部电影的情况。

本章主要涉及以下几个概念。

亲和性分析

用Apriori算法挖掘关联特征

电影推荐

数据稀疏问题

4.1　亲和性分析

亲和性分析用来找出两个对象共同出现的情况。而前几章，我们关注的是同种对象之间的相似度。亲和性分析所用的数据通常为类似于交易信息的数据。从直观上来看，这些数据就像是商店的交易数据——从中能看出哪些商品是顾客一起购买的。

然而，亲和性分析方法的应用场景有很多，比如：

欺诈检测

顾客区分

软件优化

产品推荐

亲和性分析比分类更具探索性，因为通常我们无法拿到像在很多分类任务中所用的那样完整的数据集。例如，在电影推荐任务中，我们拿到的是不同用户对不同电影的评价。但是，每个用户不可能评价过所有电影，这就给亲和性分析带来一个不容忽视的大难题。如果用户没有评价过一部电影，是因为他们不喜欢这部电影（据此就不推荐给他们），还是因为他们出于别的原因还没有评价？

本章不对上述问题做出解答，但是我们要思考数据集中类似这样的潜在问题该怎么解决。这些思考有助于提升推荐算法的准确性。

4.1.1　亲和性分析算法

我们在第1章介绍了一种基础的亲和性分析算法，尝试了所有可能的规则组合，计算了每条规则的置信度和支持度，并根据这两个标准进行排序，选取最佳规则。

然而，这个方法效率不高。好在第1章所使用的数据集中，每条交易数据只涉及五种商品。但现实并非如此，即使是再不起眼的小卖铺出售的商品也达上百种之多，网店更是有成千上万种商品（甚至几百万种！）。如果规则生成方法像第1章那样过于简单，计算这些规则所需要的时间复杂度将呈指数级增长。随着商品数量的增加，计算所有规则所需的时间增长得很快。更具体地说，所有可能的规则数量是2n-1。数据集有5个特征，可能的规则就有31条；有10个特征，可能的规则就有1023条；仅仅有100个特征，规则数就能达到30位数字。即使计算能力大幅提升也未必能赶上在线商品的增长速度。因此，与其跟计算机过不去，不如寻找更加聪明的算法。

Apriori算法可以说是经典的亲和性分析算法。它只从数据集中频繁出现的商品中选取共同出现的商品组成频繁项集（frequent itemset），避免了上述复杂度呈指数级增长的问题。一旦找到频繁项集，生成关联规则就很容易了。

Apriori算法背后的原理简洁却不失巧妙。首先，确保了规则在数据集中有足够的支持度。Apriori算法的一个重要参数就是最小支持度。比如，要生成包含商品A、B的频繁项集（A, B），要求支持度至少为30，那么A和B都必须至少在数据集中出现30次。更大的频繁项集也要遵守该项约定，比如要生成频繁项集（A, B, C, D），那么子集(A, B, C)必须是频繁项集（当然D自己也要满足最小支持度标准）。

生成频繁项集后，将不再考虑其他可能的却不够频繁的项集（这样的集合有很多），从而大大减少测试新规则所需的时间。

其他亲和性分析算法有Eclat和频繁项集挖掘算法（FP-growth）。从数据挖掘角度看，这些算法比起基础的Apriori算法有很多改进，性能也有进一步提升。接下来，先来看一下最基础的Apriori算法。

4.1.2　选择参数

挖掘亲和性分析所用的关联规则之前，我们先用Apriori算法生成频繁项集。接着，通过检测频繁项集中前提和结论的组合，生成关联规则（例如，如果用户喜欢电影X，那么他很可能喜欢电影Y）。

第一个阶段，需要为Apriori算法指定一个项集要成为频繁项集所需的最小支持度。任何小于最小支持度的项集将不再考虑。如果最小支持度值过小，Apriori算法要检测大量的项集，会拖慢的运行速度；最小支持度值过大的话，则只有很少的频繁项集。

找出频繁项集后，在第二个阶段，根据置信度选取关联规则。可以设定最小置信度，返回一部分规则，或者返回所有规则，让用户自己选。

本章，我们设定最小置信度，只返回高于它的规则。置信度过低将会导致规则支持度高，正确率低；置信度过高，导致正确率高，但是返回的规则少。

4.2　电影推荐问题

产品推荐技术是门大生意。网店经常用它向潜在用户推荐他们可能购买的产品。好的推荐算法能带来更高的销售业绩。每年有几百万乃至几千万用户进行网购，向他们推荐更多的商品，潜在收益着实可观。

产品推荐问题被人们研究了多年，但它一直不温不火，直到2007年到2009年间，Netflix公司推出数据建模大赛，并设立Netflix Prize奖项之后，才得到迅猛发展。该竞赛意在寻找比Netflix公司所使用的预测用户为电影打分的系统更准确的解决方案。最后获奖队伍以比现有系统高10个百分点的优势胜出。虽然这个改进看起来不是很大，但是Netflix公司却能借助它实现更精准的电影推荐服务，从而多赚上百万美元。

4.2.1　获取数据集

自打Netflix Prize奖项设立以来，美国明尼苏达大学的Grouplens研究团队公开了一系列用于测试推荐算法的数据集。其中，就包括几个大小不同的电影评分数据集，分别有10万、100万和1000万条电影评分数据。

数据集下载地址为http://grouplens.org/datasets/movielens/。本章将使用包含10万条数据的MovieLens数据集。下载数据集，解压到你的Data文件夹。启动IPython Notebook笔记本，输入以下代码。

import os
import pandas as pd
data_folder = os.path.join(os.path.expanduser("~"), "Data",
  "ml-100k")
ratings_filename = os.path.join(data_folder, "u.data")

确保ratings_filename指向解压后得到的文件夹中的u.data文件。

4.2.2　用pandas加载数据

MovieLens数据集非常规整，但是有几点跟pandas.read_csv方法的默认设置有出入，所以要调整参数设置。第一个问题是数据集每行的几个数据之间用制表符而不是逗号分隔。其次，没有表头，这表示数据集的第一行就是数据部分，我们需要手动为各列添加名称。

加载数据集时，把分隔符设置为制表符，告诉pandas不要把第一行作为表头（header=None），设置好各列的名称。代码如下：

all_ratings = pd.read_csv(ratings_filename, delimiter="\t",
    header=None, names = ["UserID", "MovieID", "Rating", "Datetime"])

虽然本章用不到，还是稍微提一下，你可以用下面的代码解析时间戳数据。

all_ratings["Datetime"] = pd.to_datetime(all_ratings['Datetime'],
  unit='s')

运行下面的代码，看一下前五条记录。

all_ratings[:5]

输出结果如下。

UserID

MovieID

Rating

Datetime

0

196

242

3

1997-12-04 15:55:49

1

186

302

3

1998-04-04 19:22:22

2

22

377

1

1997-11-07 07:18:36

3

244

51

2

1997-11-27 05:02:03

4

166

346

1

1998-02-02 05:33:16

4.2.3　稀疏数据格式

这是一个稀疏数据集，我们可以将每一行想象成巨大特征矩阵的一个格子，前几章用到过这种矩阵。在这个矩阵中，每一行表示一个用户，每一列为一部电影。第一列为每一个用户给第一部电影打的分数，第二列为每一个用户给第二部电影打的分数，以此类推。

数据集中有1000名用户和1700部电影，这就意味着整个矩阵很大。将矩阵读到内存中及在它基础上进行计算可能存在难度。然而，这个矩阵的很多格子都是空的，也就是对大部分用户来说，他们只给少数几部电影打过分。比如用户#213没有为电影#675打过分，大部分用户没有为大部分电影打过分。

用上述图表中的格式也能表示矩阵，且更为紧凑。序号为0的那一行表示，用户#196在1997年12月4日为电影#242打了3分（满分是5分）。

任何没有出现在数据集中的用户和电影组合表示它们实际上是不存在的。这比起在内存中保存大量的0，节省了很多空间。这种格式叫作稀疏矩阵（sparse matrix）。根据经验来说，如果数据集中60%或以上的数据为0，就应该考虑使用稀疏矩阵，从而节省不少空间。

在对稀疏矩阵进行计算时，我们关注的通常不是那些不存在的数据，不会去比较众多的0值，相反我们关注的是现有数据，并对它们进行比较。

4.3　Apriori算法的实现

本章数据挖掘的目标是生成如下形式的规则：如果用户喜欢某些电影，那么他们也会喜欢这部电影。作为对上述规则的扩展，我们还将讨论喜欢某几部电影的用户，是否喜欢另一部电影。

要解决以上问题，首先要确定用户是不是喜欢某一部电影。为此创建新特征Favorable，若用户喜欢该电影，值为True。

all_ratings["Favorable"] = all_ratings["Rating"] > 3

我们在数据集中看一下这个新特征。

all_ratings[10:15]

UserID

MovieID

Rating

Datetime

Favorable

10

62

257

2

1997-11-12 22:07:14

False

11

286

1014

5

1997-11-17 15:38:45

True

12

200

222

5

1997-10-05 09:05:40

True

13

210

40

3

1998-03-27 21:59:54

False

14

224

29

3

1998-02-21 23:40:57

False

从数据集中选取一部分数据用作训练集，这能有效减少搜索空间，提升Apriori算法的速度。我们取前200名用户的打分数据。

ratings = all_ratings[all_ratings['UserID'].isin(range(200))]

接下来，新建一个数据集，只包括用户喜欢某部电影的数据行。

favorable_ratings = ratings[ratings["Favorable"]]

在生成项集时，需要搜索用户喜欢的电影。因此，接下来，我们需要知道每个用户各喜欢哪些电影，按照User ID进行分组，并遍历每个用户看过的每一部电影。

favorable_reviews_by_users = dict((k, frozenset(v.values))
                                   for k, v in favorable_ratings
                                   groupby("UserID")["MovieID"])

上面的代码把v.values存储为frozenset，便于快速判断用户是否为某部电影打过分。对于这种操作，集合比列表速度快，在后面代码中还会用到。

最后，创建一个数据框，以便了解每部电影的影迷数量。

num_favorable_by_movie = ratings[["MovieID", "Favorable"]].
  groupby("MovieID").sum()

用以下代码查看最受欢迎的五部电影。

num_favorable_by_movie.sort("Favorable", ascending=False)[:5]

输出结果如下：

MovieID

Favorable

50

100

100

89

258

83

181

79

174

74

4.3.1　Apriori算法

Apriori算法是亲和性分析的一部分，专门用于查找数据集中的频繁项集。基本流程是从前一步找到的频繁项集中找到新的备选集合，接着检测备选集合的频繁程度是否够高，然后算法像下面这样进行迭代。

(1) 把各项目放到只包含自己的项集中，生成最初的频繁项集。只使用达到最小支持度的项目。

(2) 查找现有频繁项集的超集，发现新的频繁项集，并用其生成新的备选项集。

(3) 测试新生成的备选项集的频繁程度，如果不够频繁，则舍弃。如果没有新的频繁项集，就跳到最后一步。

(4) 存储新发现的频繁项集，跳到步骤(2)。

(5) 返回发现的所有频繁项集。

整个过程表示如下。

4.3.2　实现

Apriori算法第一次迭代时，新发现的项集长度为2，它们是步骤(1)中创建的项集的超集。第二次迭代（经过步骤(4)）中，新发现的项集长度为3。这有助于我们快速识别步骤(2)所需的项集。

我们把发现的频繁项集保存到以项集长度为键的字典中，便于根据长度查找，这样就可以找到最新发现的频繁项集。下面的代码初始化一个字典。

frequent_itemsets = {}

我们还需要确定项集要成为频繁项集所需的最小支持度。这个值需要根据数据集的具体情况来设定，可自行尝试其他值，建议每次只改动10个百分点，即使这样你可能也会发现算法运行时间变动很大！下面，设置最小支持度。

min_support = 50

我们先来实现Apriori算法的第一步，为每一部电影生成只包含它自己的项集，检测它是否够频繁。电影编号使用frozenset，后面要用到集合操作。此外，它们也可以用作字典的键（普通集合不可以）。代码如下：

frequent_itemsets[1] = dict((frozenset((movie_id,)),
                                 row["Favorable"])
                                 for movie_id, row in num_favorable_
                                   by_movie.iterrows()
                                 if row["Favorable"] > min_support)

接着，用一个函数来实现步骤(2)和(3)，它接收新发现的频繁项集，创建超集，检测频繁程度。下面为函数声明及字典初始化代码。

from collections import defaultdict
def find_frequent_itemsets(favorable_reviews_by_users, k_1_itemsets,
min_support):
    counts = defaultdict(int)

经验告诉我们，要尽量减少遍历数据的次数，所以每次调用函数时，再遍历数据。这样做效果不是很明显（因为数据集相对较小），但是数据集更大的情况下，就很有必要。我们来遍历所有用户和他们的打分数据。

    for user, reviews in favorable_reviews_by_users.items():

接着，遍历前面找出的项集，判断它们是否是当前评分项集的子集。如果是，表明用户已经为子集中的电影打过分。代码如下：

        for itemset in k_1_itemsets:
            if itemset.issubset(reviews):

接下来，遍历用户打过分却没有出现在项集里的电影，用它生成超集，更新该项集的计数。代码如下：

                for other_reviewed_movie in reviews - itemset:
                    current_superset = itemset | frozenset((other_
                                     reviewed_movie,))
                    counts[current_superset] += 1

函数最后检测达到支持度要求的项集，看它的频繁程度够不够，并返回其中的频繁项集。

    return dict([(itemset, frequency) for itemset, frequency in
counts.items() if frequency >= min_support])

创建循环，运行Apriori算法，存储算法运行过程中发现的新项集。循环体中，k 表示即将发现的频繁项集的长度，用键 k-1 可以从frequent_itemsets字典中获取刚发现的频繁项集。新发现的频繁项集以长度为键，将其保存到字典中。代码如下：

for k in range(2, 20):
    cur_frequent_itemsets =
      find_frequent_itemsets(favorable_reviews_by_users,
        frequent_itemsets[k-1],
          min_support)
      frequent_itemsets[k] = cur_frequent_itemsets

如果在上述循环中没能找到任何新的频繁项集，就跳出循环（输出信息，告知我们没能找到长度为 k 的频繁项集）。

    if len(cur_frequent_itemsets) == 0:
        print("Did not find any frequent itemsets of length {}".
          format(k))
        sys.stdout.flush()
        break

　用sys.stdout.flush()方法，确保代码还在运行时，把缓冲区内容输出到终端。有时，在位于笔记本文件特定格子的大型循环中，代码结束运行前不会输出，用flush方法可以保证及时输出。但是，该方法不宜过多使用——flush操作（输出也是）所带来的计算开销会拖慢程序的运行速度。

如果确实找到了频繁项集，我们也让程序输出信息，告知我们它会再次运行。因为算法运行时间很长，所以每隔一段时间输出一下状态是很有必要的！代码如下：

    else:
        print("I found {} frequent itemsets of length
        {}".format(len(cur_frequent_itemsets), k))
        sys.stdout.flush()

最后，循环结束，我们对只有一个元素的项集不再感兴趣——它们对生成关联规则没有用处——生成关联规则至少需要两个项目。删除长度为1的项集。代码如下：

del frequent_itemsets[1]

上面这些代码要好几分钟才能运行完，老机器需要的时间更长。如果你在本地运行代码有问题，可以考虑使用云主机提升速度，具体细节请见附录。

上述代码返回了不同长度的1718个频繁项集。你也许会发现随着项集长度的增加，项集数随着可用规则的增加而增长一段时间后才开始变少，减少是因为项集达不到最低支持度要求。项集的减少是Apriori算法的优点之一。如果我们搜索所有可能的项集（不只是频繁项集的超集），判断多余项集的频繁程度需要成千上万次查询。

4.4　抽取关联规则

Apriori算法结束后，我们得到了一系列频繁项集，这还不算是真正意义上的关联规则，但是很接近了。频繁项集是一组达到最小支持度的项目，而关联规则由前提和结论组成。

我们可以从频繁项集中抽取出关联规则，把其中几部电影作为前提，另一部电影作为结论组成如下形式的规则：如果用户喜欢前提中的所有电影，那么他们也会喜欢结论中的电影。

每一个项集都可用这种方式生成一条规则。

下面的代码通过遍历不同长度的频繁项集，为每个项集生成规则。

candidate_rules = []
for itemset_length, itemset_counts in frequent_itemsets.items():
    for itemset in itemset_counts.keys():

然后，遍历项集中的每一部电影，把它作为结论。项集中的其他电影作为前提，用前提和结论组成备选规则。

        for conclusion in itemset:
            premise = itemset - set((conclusion,))
            candidate_rules.append((premise, conclusion))

这样就能得到大量备选规则。通过以下代码查看前五条规则。

print(candidate_rules[:5])

输出结果如下：

[(frozenset({79}), 258), (frozenset({258}), 79), (frozenset({50}),
  64), (frozenset({64}), 50), (frozenset({127}), 181)]

在上述这些规则中，第一部分（frozenset）是作为规则前提的电影编号，后面的数字表示作为结论的电影编号。第一组数据表示如果用户喜欢电影79，他很可能喜欢电影258。

接下来，计算每条规则的置信度，计算方法跟第1章类似，只不过要根据这里新的数据格式做些改动。

我们需要先创建两个字典，用来存储规则应验（正例）和规则不适用（反例）的次数。代码如下：

correct_counts = defaultdict(int)
incorrect_counts = defaultdict(int)

遍历所有用户及其喜欢的电影数据，在这个过程中遍历每条关联规则。

for user, reviews in favorable_reviews_by_users.items():
    for candidate_rule in candidate_rules:
        premise, conclusion = candidate_rule

测试每条规则的前提对用户是否适用。换句话说，用户是否喜欢前提中的所有电影。代码如下：

        if premise.issubset(reviews):

如果前提符合，看一下用户是否喜欢结论中的电影。如果是的话，规则适用，反之，规则不适用。

        if premise.issubset(reviews):
            if conclusion in reviews:
                correct_counts[candidate_rule] += 1
            else:
                incorrect_counts[candidate_rule] += 1

用规则应验的次数除以前提条件出现的总次数，计算每条规则的置信度。

rule_confidence = {candidate_rule: correct_counts[candidate_rule]
  / float(correct_counts[candidate_rule] +
    incorrect_counts[candidate_rule])
              for candidate_rule in candidate_rules}

对置信度字典进行排序后，输出置信度最高的前五条规则。

from operator import itemgetter
sorted_confidence = sorted(rule_confidence.items(),
  key=itemgetter(1), reverse=True)
for index in range(5):
    print("Rule #{0}".format(index + 1))
    (premise, conclusion) = sorted_confidence[index][0]
    print("Rule: If a person recommends {0} they will also
      recommend {1}".format(premise, conclusion))
    print(" - Confidence:
      {0:.3f}".format(rule_confidence[(premise, conclusion)]))
    print("")

结果如下：

Rule #1
Rule: If a person recommends frozenset({64, 56, 98, 50, 7}) they will
also recommend 174
 - Confidence: 1.000

Rule #2
Rule: If a person recommends frozenset({98, 100, 172, 79, 50, 56})
they will also recommend 7
 - Confidence: 1.000

Rule #3
Rule: If a person recommends frozenset({98, 172, 181, 174, 7}) they
will also recommend 50
 - Confidence: 1.000

Rule #4
Rule: If a person recommends frozenset({64, 98, 100, 7, 172, 50}) they
will also recommend 174
 - Confidence: 1.000

Rule #5
Rule: If a person recommends frozenset({64, 1, 7, 172, 79, 50}) they
will also recommend 181
 - Confidence: 1.000

输出结果中只显示电影编号，而没有显示电影名字，很不友好。我们下载的数据集中的u.items文件里存储了电影名称和编号（还有体裁等信息）。

用pandas从u.items文件加载电影名称信息。关于该文件和类别的更多信息请见数据集中的README文件。u.items文件为CSV格式，但是用竖线分隔数据。读取时需要指定分隔符，设置表头和编码格式。每一列的名称是从README文件中找到的。

movie_name_filename = os.path.join(data_folder, "u.item")
movie_name_data = pd.read_csv(movie_name_filename, delimiter="|",
  header=None, encoding = "mac-roman")

movie_name_data.columns = ["MovieID", "Title", "Release Date",
  "Video Release", "IMDB", "<UNK>", "Action", "Adventure",
    "Animation", "Children's", "Comedy", "Crime", "Documentary",
      "Drama", "Fantasy", "Film-Noir",
  "Horror", "Musical", "Mystery", "Romance", "Sci-Fi", "Thriller",
    "War", "Western"]

既然电影名称对于理解数据很重要，我们就来创建一个用电影编号获取名称的函数，以免去每次都要人工查找的烦恼。函数声明如下：

def get_movie_name(movie_id):

在数据框movie_name_data中查找电影编号，找到后，只返回电影名称列的数据。

    title_object = movie_name_data[movie_name_data["MovieID"] ==
      movie_id]["Title"]

我们用title_object的values属性获取电影名称（不是存储在title_object中的Series对象）。我们只对第一个值感兴趣——当然每个电影编号只对应一个名称！

    title = title_object.values[0]

函数最后返回电影名称。

    return title

调整之前的代码，这样就能在输出的规则中显示电影名称。请在IPython Notebook笔记本文件的新格子里输入以下代码。

for index in range(5):
    print("Rule #{0}".format(index + 1))
    (premise, conclusion) = sorted_confidence[index][0]
    premise_names = ", ".join(get_movie_name(idx) for idx
      in premise)
    conclusion_name = get_movie_name(conclusion)
    print("Rule: If a person recommends {0} they will
      also recommend {1}".format(premise_names, conclusion_name))
    print(" - Confidence: {0:.3f}".format(confidence[(premise,
      conclusion)]))
print("")

结果清楚多了（还有些小问题，暂时先忽略）。

Rule #1
Rule: If a person recommends Shawshank Redemption, The (1994), Pulp
Fiction (1994), Silence of the Lambs, The (1991), Star Wars (1977),
Twelve Monkeys (1995) they will also recommend Raiders of the Lost Ark
(1981)
 - Confidence: 1.000

Rule #2
Rule: If a person recommends Silence of the Lambs, The (1991), Fargo
(1996), Empire Strikes Back, The (1980), Fugitive, The (1993), Star
Wars (1977), Pulp Fiction (1994) they will also recommend Twelve
Monkeys (1995)
 - Confidence: 1.000

Rule #3
Rule: If a person recommends Silence of the Lambs, The (1991), Empire
Strikes Back, The (1980), Return of the Jedi (1983), Raiders of the
Lost Ark (1981), Twelve Monkeys (1995) they will also recommend Star
Wars (1977)
 - Confidence: 1.000

Rule #4
Rule: If a person recommends Shawshank Redemption, The (1994), Silence
of the Lambs, The (1991), Fargo (1996), Twelve Monkeys (1995), Empire
Strikes Back, The (1980), Star Wars (1977) they will also recommend
Raiders of the Lost Ark (1981)
 - Confidence: 1.000

Rule #5
Rule: If a person recommends Shawshank Redemption, The (1994), Toy
Story (1995), Twelve Monkeys (1995), Empire Strikes Back, The (1980),
Fugitive, The (1993), Star Wars (1977) they will also recommend Return
of the Jedi (1983)
 - Confidence: 1.000

评估

广义上讲，我们可以拿评估分类算法的那一套来用。训练前，留出一部分数据用于测试，评估发现的规则在测试集上的表现。

如果这样做的话，我们就需要计算每条规则在测试集中的置信度。

这里我们不打算用正式的评价指标，只是简单看一下每条规则的表现，寻找好的规则。

首先，抽取所有没有用于训练的数据作为测试集。训练集用前200名用户的打分数据，测试集就用剩下的数据。就训练集来说，我们会为该数据集中的每一位用户获取最喜欢的电影。代码如下：

test_dataset =
all_ratings[~all_ratings['UserID'].isin(range(200))]
test_favorable = test_dataset[test_dataset["Favorable"]]
test_favorable_by_users = dict((k, frozenset(v.values)) for k, v
  in test_favorable.groupby("UserID")["MovieID"])

接着，计算规则应验的数量，方法跟之前相同。唯一的不同就是这次使用的是测试数据而不是训练数据。代码如下：

correct_counts = defaultdict(int)
incorrect_counts = defaultdict(int)
for user, reviews in test_favorable_by_users.items():
    for candidate_rule in candidate_rules:
        premise, conclusion = candidate_rule
        if premise.issubset(reviews):
            if conclusion in reviews:
                correct_counts[candidate_rule] += 1
            else:
                incorrect_counts[candidate_rule] += 1

接下来，计算所有应验规则的置信度。

test_confidence = {candidate_rule: correct_counts[candidate_rule]
  / float(correct_counts[candidate_rule] + incorrect_counts
    [candidate_rule])
  for candidate_rule in rule_confidence}

最后，输出用电影名称而不是电影编号表示的最佳关联规则。

for index in range(5):
    print("Rule #{0}".format(index + 1))
    (premise, conclusion) = sorted_confidence[index][0]
    premise_names = ", ".join(get_movie_name(idx) for idx in
      premise)
    conclusion_name = get_movie_name(conclusion)
    print("Rule: If a person recommends {0} they will also
      recommend {1}".format(premise_names, conclusion_name))
    print(" - Train Confidence:
      {0:.3f}".format(rule_confidence.get((premise, conclusion),
        -1)))
    print(" - Test Confidence:
     {0:.3f}".format(test_confidence.get((premise, conclusion),
        -1)))
     print("")

我们来看一下在新数据集上哪些规则最适用。

Rule #1
Rule: If a person recommends Shawshank Redemption, The (1994), Pulp
Fiction (1994), Silence of the Lambs, The (1991), Star Wars (1977),
Twelve Monkeys (1995) they will also recommend Raiders of the Lost Ark
(1981)
 - Train Confidence: 1.000
 - Test Confidence: 0.909

Rule #2
Rule: If a person recommends Silence of the Lambs, The (1991), Fargo
(1996), Empire Strikes Back, The (1980), Fugitive, The (1993), Star
Wars (1977), Pulp Fiction (1994) they will also recommend Twelve
Monkeys (1995)
 - Train Confidence: 1.000
 - Test Confidence: 0.609

Rule #3
Rule: If a person recommends Silence of the Lambs, The (1991), Empire
Strikes Back, The (1980), Return of the Jedi (1983), Raiders of the
Lost Ark (1981), Twelve Monkeys (1995) they will also recommend Star
Wars (1977)
 - Train Confidence: 1.000
 - Test Confidence: 0.946

Rule #4
Rule: If a person recommends Shawshank Redemption, The (1994), Silence
of the Lambs, The (1991), Fargo (1996), Twelve Monkeys (1995), Empire
Strikes Back, The (1980), Star Wars (1977) they will also recommend
Raiders of the Lost Ark (1981)
 - Train Confidence: 1.000
 - Test Confidence: 0.971

Rule #5
Rule: If a person recommends Shawshank Redemption, The (1994), Toy
Story (1995), Twelve Monkeys (1995), Empire Strikes Back, The (1980),
Fugitive, The (1993), Star Wars (1977) they will also recommend Return
of the Jedi (1983)
 - Train Confidence: 1.000
 - Test Confidence: 0.900

举例来说，第二条规则，在训练集中置信度为1，但在测试集上正确率只有60%。但前十条规则中，其他几条规则在测试集上置信度也很高，用它们来推荐电影效果不错。

　浏览剩余规则，你可能发现有些规则的置信度为-1。置信度一般为0到1之间的值。负数表示这条规则在测试集中根本不存在。

4.5　小结

本章把亲和性分析用到电影推荐上，从大量电影打分数据中找到可用于电影推荐的关联规则。整个过程分为两大阶段。首先，借助Apriori算法寻找数据中的频繁项集。然后，根据找到的频繁项集，生成关联规则。

由于数据集较大，Apriori算法就很有必要。虽然第1章使用最笨的方法来寻找规则，但是在这里就不适用了，由于时间复杂度呈指数级增长，我们需要寻找更巧妙的解决方案。这种现象在数据挖掘中很常见：虽然可以用最笨的方法穷尽所有情况来解决问题，但在数据量很大的情况下必须要使用更灵活、更快捷的算法。

我们用一部分数据作为训练集以发现关联规则，在剩余数据——测试集上进行测试。可以用前几章的交叉检验方法对每条规则的效果进行更为充分的评估。

到目前为止，我们使用的数据集都有很明显的特征。然而，不是所有数据集都是这个样子。下一章将学习用scikit-learn的转换器（第3章曾介绍过）从数据中抽取特征。还将讨论怎样实现自己的转换器和扩展已有转换器，并介绍相关概念。

第 5 章　用转换器抽取特征

前几章所用到的数据集都是用特征来描述的。虽然上一章的数据集为交易类型的数据，形式稍有不同，但归根结底还是一种用特征来描述的数据集。

除此之外，还有很多其他类型的数据集，比如文本、图像、声音、视频甚至是真实的物体。然而，大多数数据挖掘算法都依赖于数值或类别型特征。这表明在使用数据挖掘算法处理它们之前，需要找到一种表示它们的方法。

本章所讨论的是如何从数据集中抽取数值和类别型特征，并选出最佳特征，前提是数据集确实包含这些特征。我们还会介绍特征抽取的常用模式和技巧。

本章主要介绍以下几个概念。

从数据集中抽取特征

创建新特征

选取好特征

创建转换器，处理数据集

5.1　特征抽取

特征抽取是数据挖掘任务最为重要的一个环节，一般而言，它对最终结果的影响要高过数据挖掘算法本身。不幸的是，关于怎样选取好的特征，还没有严格、快捷的规则可循，其实这也是数据挖掘科学更像是一门艺术的所在。创建好的规则离不开直觉，还需要专业领域知识和数据挖掘经验，光有这些还不够，还得不停地尝试、摸索，在试错中前进，有时多少还要靠点运气。

5.1.1　在模型中表示事实

不是所有的数据集都是用特征来表示的。数据集可以是一位作家所写的全部书籍，也可以是1979年以来所有电影的胶片，还可以是馆藏的一批历史文物。

我们可能想对以上数据集做数据挖掘。对于作家的作品，我们想知道这位作家都写过哪些主题；对于电影，我们想知道女性形象是怎么塑造的；对于文物，我们想知道它们是何方产物。我们没办法把实物直接塞进决策树来得到结果。

只有先把现实用特征表示出来，才能借助数据挖掘的力量找到问题的答案。特征可用于建模，模型以机器挖掘算法能够理解的近似的方式来表示现实。因此可以说，模型描述了客观世界中对象的某些方面，是它的一个简化版本。举例来说，象棋就是对过往战事简化后得到的一种模型。

特征选择的另一个优点在于：降低真实世界的复杂度，模型比现实更容易操纵。想象一下，向一个没有任何背景知识的人介绍一种新事物，要给出恰如其分、精确又不失全面的描述需要提供多少信息。我们可能需要介绍其尺寸、重量、质地、成分、年龄、瑕疵、用途、产地等信息。

实物的复杂性对目前的算法而言过于复杂，我们退而求其次，使用更为简洁的模型来表示实物。

简化要以数据挖掘应用的目标为核心。本书后面会讲到聚类，对这类应用而言，特征选取至关重要，如果随意选取特征，分簇结果因选择的特征而异，呈现出很强的随机性。

降低复杂性有好处，但也有不足，简化会忽略很多细节，甚至会抛弃很多对数据挖掘算法能起到帮助作用的信息。

我们应该始终关注怎么用模型来表示现实，要多考虑数据挖掘的目标，而不是轻率地用我们过去用过的特征。要经常想想我们的目标是什么。第3章创建特征时充分考虑目标（预测赢家），使用篮球比赛领域相关知识，找出哪些因素与比赛结果密切相关，据此再创建新特征。

　不是所有的特征必须是数值或类别型值，直接用于文本、图像和其他数据结构的算法已经研究出来了。不幸的是，篇幅有限，本书主要讲解数值和类别型特征，不涉及上述算法。

用Adult数据集讲解如何借助特征为复杂的现实世界建模再好不过。我们这里数据挖掘的目标是，预测一个人是否年收入多于五万美元。数据集下载地址为http://archive.ics.uci.edu/ml/datasets/Adult，点击Data Folder链接。下载adult.data和adult.names文件，在数据集文件夹（主目录下Data文件夹）中创建Adult文件夹，将这两个文件放到里面。

数据集用特征描述了一个个活生生的人及其他们所处的环境、背景和生活状况。

打开IPython Notebook，新建一个笔记本文件用于本章实验，导入pandas模块，指定数据集文件的路径，用pandas加载数据集文件。

import os
import pandas as pd
data_folder = os.path.join(os.path.expanduser("~"), "Data",
  "Adult")
adult_filename = os.path.join(data_folder, "adult.data")

像以前一样使用pandas，用read_csv加载文件，如下所示。

adult = pd.read_csv(adult_filename, header=None,
    names=["Age", "Work-Class", "fnlwgt",
    "Education", "Education-Num",
    "Marital-Status", "Occupation",
    "Relationship", "Race", "Sex",
    "Capital-gain", "Capital-loss",
    "Hours-per-week", "Native-Country",
    "Earnings-Raw"])

大部分代码在上一章都见过。

adult.data文件末尾有两处空行。pandas默认把倒数第二行空行作为一条有效数据读入（只不过各列的值为空）。我们需要删除包含无效数字的行（设置inplace参数为真，表示改动当前数据框，而不是新建一个）。

adult.dropna(how='all', inplace=True)

输入下面代码并运行，查看所有的特征名称。

adult.columns

下面为所有保存在pandas的Index对象中的特征名称。

Index(['Age', 'Work-Class', 'fnlwgt', 'Education',
'Education-Num', 'Marital-Status', 'Occupation', 'Relationship',
'Race', 'Sex', 'Capital-gain', 'Capital-loss', 'Hours-per-week',
'Native-Country', 'Earnings-Raw'], dtype='object')

5.1.2　通用的特征创建模式

纵然有数不清的特征创建方法，但其中有一些是适用于不同学科的通用模式。然而，选择合适的特征是项技术活，需要考虑特征和最终结果之间的相互关系。正如谚语所说的，不要用封面来评价书的好坏——如果你对书的内容感兴趣，书的大小可能不用考虑。

一些通用特征关注的是所研究对象的物理属性。

空间属性，比如对象的长、宽、高

重量和（或）密度

年龄、使用年限或成分

种类

品质

另一些特征可能与对象的使用或历史相关。

生产商、出版商或创造者

生产时间

使用方法

还有一些特征从组成成分角度描述对象。

次级成分的频率，如一本书中某个单词的词频

次级成分的数量和（或）次级成分种类的数量

次级成分的平均大小，例如平均句长

序数特征可对其排序、分组。正如前几章所见到的，特征可以是数值或类别型特征。数值特征通常被看作是有顺序的（ordinal）。例如，爱丽丝、鲍勃、查理三个人身高分别为1.5m、1.6m和1.7m。我们可以说爱丽丝和鲍勃，比起爱丽丝和查理，在身高上更相似。

上一节我们导入的数据集，包含连续特征、序数特征。例如，Hours-per-week特征表示一个人每周的工作时间。这个特征可用来计算平均工作时间、标准差、最大值和最小值。pandas提供了常见统计量的计算方法。

adult["Hours-per-week"].describe()

输出结果让我们对这个特征有了更多了解。

count    32561.000000
mean        40.437456
std         12.347429
min          1.000000
25%         40.000000
50%         40.000000
75%         45.000000
max         99.000000
dtype: float64

这些统计方法对其他特征可能没有意义。例如，计算受教育程度的和就讲不通。

还有些特征，它们虽然不是数值，但仍然属于序数值，比如Adult数据集中的Education（教育）特征，学士学位比高中毕业，在受教育程度上，要高一个层次，而高中毕业又比没有完成高中学业高一个层次。计算它俩的均值就没有意义，但是我们可以找到一种近似的表示方法。数据集有个叫作Education-Num（受教育年限）的特征，它的取值基本上就是每个教育阶段的年限。这样我们就能快速计算受教育年限的中位数。

adult["Education-Num"].median()

结果为10，或者可以表述为刚好读完高一。如果没有受教育年限数据，我们为不同教育阶段指定数字编号，也可以计算中位数。

特征也可以是类别型的。例如，一个球可以是网球、棒球、足球或其他种类。类别型特征也可以称为名义特征（nominal）。对于名义特征而言，几个值之间要么相同要么不同。球类可以根据大小或重量排序，但是无法仅根据类别值进行量上的比较。网球不是棒球，也不是足球。我们可以说比起足球，网球的大小跟棒球更接近，但是无法在类别上做类似的区分——它们要么是同一类，要么不是。

类别型特征二值化后就变成了数值型特征，第3章中曾经用过。对于上述球的种类，可以创建三个二值特征：“是网球”“是棒球”和“是足球”。如果是网球的话，特征向量就是[1, 0, 0]，棒球[0, 1, 0]，足球[0, 0, 1]。这些特征都只有两个取值，很多算法都可以把它们作为连续型特征使用。二值化的好处是，便于直接进行数字上的比较（例如计算个体之间的距离）。

Adult数据集包含一些类别型特征，例如Work-Class（工作），其中它的有些值可以进行量级上的比较，比如不同的就业状况影响收入（例如有工作的人比没有工作的人收入高）。再比如，州政府职员的工资不太可能比私企职工工资高。

用数据框的unique函数就能得到所有的工作情况。

adult["Work-Class"].unique()

输出结果如下：

array([' State-gov', ' Self-emp-not-inc', ' Private', ' Federal-gov',
       ' Local-gov', ' ?', ' Self-emp-inc', ' Without-pay',
       ' Never-worked', nan], dtype=object)

数据集部分数据缺失，但不会影响这里的计算。

同理，数值型特征也可以通过离散化过程转换为类别型特征，第4章中曾用过。例如，高于1.7m的人，我们称其为高个子，低于1.7m的叫作矮个子。这样就得到了类别型特征（虽然是序数值类型）。在这个过程中确实丢失了一些信息。例如，有两个人，身高分别为1.69m和1.71m，这两个个子差不多的将被分到两个截然不同的类别里。而身高仅为1.2m的将会被认为与身高1.69m的“差不多高”！细节的丢失是离散化不好的一面，也是建模时需要考虑解决的问题。

对于Adult数据集，我们可以创建LongHours（长时）特征，用它来表示一个人每周工作时长是否多于40小时。这样就把连续值（Hours-per-week，每周工作时长）转换为类别型特征。

adult["LongHours"] = adult["Hours-per-week"] > 40

5.1.3　创建好的特征

建模过程中，需要对真实世界中的对象进行简化，这会导致信息的丢失，这也就是为什么没有一套能够用于任何数据集的通用的数据挖掘方法。数据挖掘的行家里手需要拥有数据来源领域的知识，没有的话，要积极去掌握。他们弄清楚问题是什么，了解有哪些可用数据后，在此基础上，才能创建解决问题所需的模型。

例如，身高这个特征描述的是一个人外表的某个方面，但是不能说明这个人学习成绩如何，所以预测学习成绩时，我们没必要去测量每个人的身高。

这正是数据挖掘为什么变得更像是一门艺术而不是科学的原因所在。抽取好的特征难度不小，该课题具有重要研究意义，因此它获得了研究人员的持续关注。选择好的分类算法也可以提升数据挖掘应用的效果，但最好还是通过选用好的特征来达到同样的目的。

不论是设计什么数据挖掘应用，首先都应该确定大致的方向，然后再设计方法实现目标。知道自己的目标之后，就能确定所需要的特征类型和算法，对最终结果也做到心中有数。

5.2　特征选择

通常特征数量很多，但我们只想选用其中一小部分。有如下几个原因。

降低复杂度：随着特征数量的增加，很多数据挖掘算法需要更多的时间和资源。减少特征数量，是提高算法运行速度，减少资源使用的好方法。

降低噪音：增加额外特征并不总会提升算法的表现。额外特征可能扰乱算法的正常工作，这些额外特征间的相关性和模式没有实际应用价值（这种情况在小数据集上很常见）。只选择合适的特征有助于减少出现没有实际意义的相关性的几率。

增加模型可读性：根据成千上万个特征创建的模型来解答一个问题，对计算机来说很容易，但模型对我们自己来说就晦涩无比。因此，使用更少的特征，创建我们自己可以理解的模型，就很有必要。

有些分类算法确实很强壮，能够处理噪音问题，特征再多也不在话下，但是选用干净的数据，选取更具描述性的特征，对算法效果提升很有帮助。

在开展数据挖掘工作前，有些基础性测试我们要做，比如确保特征值是不同的。如果特征值都相同，就跟没提供什么信息一样，挖掘就失去了意义。

scikit-learn中的VarianceThreshold转换器可用来删除特征值的方差达不到最低标准的特征。下面通过代码来讲下它的用法，首先用numpy创建一个简单的矩阵。

import numpy as np
X = np.arange(30).reshape((10, 3))

上述矩阵包含0到29，共30个数字，分为3列10行。可以把它看成一个有10个个体、3个特征的数据集。

array([[ 0,  1,  2],
       [ 3,  4,  5],
       [ 6,  7,  8],
       [ 9, 10, 11],
       [12, 13, 14],
       [15, 16, 17],
       [18, 19, 20],
       [21, 22, 23],
       [24, 25, 26],
       [27, 28, 29]])

接着，把所有第二列的数值都改为1。

X[:,1] = 1

第一、三列特征值方差很大，而第二列方差为0。

array([[ 0, 1,  2],
       [ 3, 1,  5],
       [ 6, 1,  8],
       [ 9, 1, 11],
       [12, 1, 14],
       [15, 1, 17],
       [18, 1, 20],
       [21, 1, 23],
       [24, 1, 26],
       [27, 1, 29]])

这时再来创建VarianceThreshold转换器，用它处理数据集。

from sklearn.feature_selection import VarianceThreshold
vt = VarianceThreshold()
Xt = vt.fit_transform(X)

输出Xt后，我们发现第二列消失了。

array([[ 0,  2],
       [ 3,  5],
       [ 6,  8],
       [ 9, 11],
       [12, 14],
       [15, 17],
       [18, 20],
       [21, 23],
       [24, 26],
       [27, 29]])

输出每一列的方差。

print(vt.variances_)

下面输出结果表明第一、三列包含有价值信息，第二列方差为0，不包含具有区别意义的信息。

array([ 74.25, 0. , 74.25])

无论什么时候，拿到数据后，先做下类似简单、直接的分析，对数据集的特点做到心中有数。方差为0的特征不但对数据挖掘没有丝毫用处，相反还会拖慢算法的运行速度。

选择最佳特征

特征很多的情况下，怎么选出最佳的几个，可有点难度。它与解决数据挖掘问题自身相关，计算量很大。正如第4章讲到的，随着特征数量的增加，寻找子集的任务复杂度呈指数级增长。寻找最佳特征组合的时间复杂度同样是指数级增长的。

其中一个变通方法是不要找表现好的子集，而只是去找表现好的单个特征（单变量），依据是它们各自所能达到的精确度。分类任务通常是这么做的，我们一般只要测量变量和目标类别之间的某种相关性就行。

scikit-learn提供了几个用于选择单变量特征的转换器，其中SelectKBest返回k个最佳特征，SelectPercentile返回表现最佳的前r%个特征。这两个转换器都提供计算特征表现的一系列方法。

单个特征和某一类别之间相关性的计算方法有很多。最常用的有卡方检验（χ²）。其他方法还有互信息和信息熵。

我们可以测试单个特征在Adult数据集上的表现。首先，选取下述特征，从pandas数据框中抽取一部分数据。

X = adult[["Age", "Education-Num", "Capital-gain", "Capital-loss",
  "Hours-per-week"]].values

接着，判断Earnings-Raw（税前收入）是否达到五万美元，创建目标类别列表。如果达到，类别为True，否则，类别为False。代码如下：

y = (adult["Earnings-Raw"] == ' >50K').values

再使用SelectKBest转换器类，用卡方函数打分，初始化转换器。

from sklearn.feature_selection import SelectKBest
from sklearn.feature_selection import chi2
transformer = SelectKBest(score_func=chi2, k=3)

调用fit_transform方法，对相同的数据集进行预处理和转换。结果为分类效果较好的三个特征。代码如下：

Xt_chi2 = transformer.fit_transform(X, y)

生成的矩阵只包含三个特征。我们还可以得到每一列的相关性，这样就可以知道都使用了哪些特征。还是看下代码：

print(transformer.scores_)

输出结果如下：

[  8.60061182e+03    2.40142178e+03    8.21924671e+07    1.37214589e+06
   6.47640900e+03]

相关性最好的分别是第一、三、四列，分别对应着Age（年龄）、Capital-Gain（资本收益）和Capital-Loss（资本损失）三个特征。从单变量特征选取角度来说，这些就是最佳特征。

　如果你想了解更多关于Adult数据集各特征的相关信息，请查看adult.names文件，里面有更多介绍及相关文献。

还可以用其他方法计算相关性，比如皮尔逊（Pearson）1相关系数。用于科学计算的SciPy库（scikit-learn在它基础上开发）实现了该方法。

1卡尔·皮尔逊（Karl Pearson，1857—1936），原名Carl，英国数学家、生物统计学家。——译者注

　如果你的计算机可以运行scikitlearn，那么就可以运行SciPy。运行下面这个例子不需要装额外的库。

首先，从SciPy导入pearsonr函数。

from scipy.stats import pearsonr

pearsonr函数的接口几乎与scikit-learn单变量转换器接口一致，该函数接收两个数组（当前例子中为x和y）作为参数，返回两个数组：每个特征的皮尔逊相关系数和p值（p-value）。前面用到的chi2函数使用的接口符合要求，因此我们直接把它传入到SelectKBest函数中。

SciPy的pearsonr函数参数为两个数组，但要注意的是第一个参数x为一维数组。我们来实现一个包装器函数，这样就能像前面那样处理多维数组。函数声明如下：

def multivariate_pearsonr(X, y):

创建scores和pvalues数组，遍历数据集的每一列。

    scores, pvalues = [], []
    for column in range(X.shape[1]):

只计算该列的皮尔逊相关系数和p值，并将其存储到相应数组中。

        cur_score, cur_p = pearsonr(X[:,column], y)
        scores.append(abs(cur_score))
        pvalues.append(cur_p)

　皮尔逊相关系数为-1到1之间的任意值。值为1，表示两个变量之间绝对正相关，值为-1，绝对负相关，即一个变量值越大，另一个变量值就越小，反之亦然。这样的特征确实能反应两个变量之间的关系，但是根据大小进行排序，这些值因为是负数而排在后面，可能会被舍弃不用。因此，我们对皮尔逊相关系数取绝对值后，再保存到scores数组中，而不是使用原始值。

函数最后返回包含皮尔逊相关系数和p值的元组。

    return (np.array(scores), np.array(pvalues))

现在，我们就可以像之前那样使用转换器类，根据皮尔逊相关系数对特征进行排序。

transformer = SelectKBest(score_func=multivariate_pearsonr, k=3)
Xt_pearson = transformer.fit_transform(X, y)
print(transformer.scores_)

返回的特征跟用卡方检验计算相关性得到的特征不一样！这回得到的是第一、二、五列：Age、Education和Hours-per-week。这表明哪些特征是最好的这个问题没有标准答案——取决于度量标准。

我们在分类器中看看哪个特征集合效果更好。请注意实验结果也只表明对于特定分类器和（或）特征子集效果更好——在数据挖掘领域，一种方法在任何情况下都比另一种方法好的情况几乎没有！实验代码如下：

from sklearn.tree import DecisionTreeClassifier
from sklearn.cross_validation import cross_val_score
clf = DecisionTreeClassifier(random_state=14)
scores_chi2 = cross_val_score(clf, Xt_chi2, y, scoring='accuracy')
scores_pearson = cross_val_score(clf, Xt_pearson, y,
  scoing='accuracy')

chi2方法的平均正确率为0.83，而皮尔逊相关系数正确率为0.77。用卡方检验得到的特征组合效果更好！

别忘了本章数据挖掘任务目标是预测收入。使用特征选择技术，找到好的特征组合，只用三个特征，就能达到83%的正确率！

5.3　创建特征

有时，仅仅选择已有特征不够用。这时，我们就可以用不同的方法从已有特征中发掘新特征，比如前面用到的一位有效编码（one-hot encoding），我们可以用它把有A、B、C三个选项的类别型特征转换为三个新的特征：“是A吗？”“是B吗？”和“是C吗？”。

创建新特征看上去没必要，也没什么好处——毕竟，这些信息原本就在数据集里，我们只是要用而已。然而，特征之间相关性很强，或者特征冗余，会增加算法处理难度。

出于这个原因，创建特征就很有必要，很多方法也应运而生。

接下来，我们要加载一个新数据集，正好也该创建一个新的IPython Notebook笔记本文件了。从http://archive.ics.uci.edu/ml/datasets/Internet+Advertisements下载Advertisements（广告）数据集，保存到自己主目录下的Data文件夹中。

接着，用pandas加载数据集。我们还是先指定文件的路径。

import os
import numpy as np
import pandas as pd
data_folder = os.path.join(os.path.expanduser("~"), "Data")
data_filename = os.path.join(data_folder, "Ads", "ad.data")

数据集存在几个问题，加载过程需要我们做些处理。问题一，前几个特征是数值，但是pandas会把它们当成字符串。要修复这个问题，我们需要编写将字符串转换为数字的函数，该函数能够把只包含数字的字符串转换为数字，把其余的转化为“NaN”2（“Not a Number”，不是一个数字），表示参数值无法转换为数字，该值类似于其他编程语言中的none或null。

2JavaScript语言中也有该类型，详见《JavaScript高级程序设计（第3版）》 第29页。——译者注

问题二，数据集中有些值缺失，缺失的值用“?”表示。幸运的是，问号不会被转换为浮点型数据，因此，我们也可以把它们转换为“NaN”。后续章节会讲到其他处理缺失值的方法。

转换函数声明如下：

def convert_number(x):

先试着把字符串转换为数字，如果失败就要处理异常，所以我们这里使用try/except结构，捕获ValueError异常（字符串无法转换为数字时，抛出异常）。

    try:
        return float(x)
    except ValueError:

在函数的最后，如果转换失败，返回numpy库中的“NaN”类型。

        return np.nan

我们来创建一个字典存储所有特征及其转换结果，我们想把所有的特征值转换为浮点型。

    converters = defaultdict(convert_number)

我们还想把最后一列（编号为#1558）的值转化为0或1，该列表示每条数据的类别。在Adult数据集中，我们专门创建了一列表示类别。对于当前实验，导入数据时，顺便把类别这一列各个类别值由字符串转换为数值。

converters[1558] = lambda x: 1 if x.strip() == "ad." else 0

可以用read_csv加载数据集了，在参数中指定我们刚创建的转换函数。

ads = pd.read_csv(data_filename, header=None, converters=converters)

数据集很大，有1559列，2000多条数据。先来看下前五条数据，在笔记本的新格子中输入并运行ads[:5]。

数据集所描述的是网上的图像，目标是确定图像是不是广告。

从数据集表头中无法获知每列数据的含义。你下载的另外两个文件ad.DOCUMENTATION和ad.names有更多信息。前三 个特征分别指图像的高度、宽度和宽高比。最后一列是数据的类别，1表示是广告，0表示不是广告。

其余特征取值为1的话，表示图像的URL、alt属性值或图像标题中包含某个特定的可以帮助判断是不是广告的词语，比如像sponsor（赞助商）。很多特征重合度很高，因为它们组合在一起恰好是其他特征。因此，数据集包含大量冗余信息。

将数据集加载到pandas之后，我们再来抽取用于分类算法的x矩阵和y数组，x矩阵为数据框除去最后一列的所有列，y数组包含数据框的最后一列，也就是列编号为#1558的列。代码如下。

X = ads.drop(1558, axis=1).values
y = ads[1558]

主成分分析

有些数据集，特征之间联系紧密。例如，只有1个档的微型单座赛车，速度和油耗两者关系就极为密切。特征间的相关性信息在某些场合会很有用，但是数据挖掘算法通常不需要这些冗余信息。

Advertisements数据集中有些特征相关性特别大，因为很多关键词在图像的alt属性值及图像的标题中都会使用。

主成分分析算法（Principal Component Analysis，PCA）的目的是找到能用较少信息描述数据集的特征组合。它意在发现彼此之间没有相关性、能够描述数据集的特征，确切说这些特征的方差跟整体方差没有多大差距，这样的特征也被称为主成分。这也就意味着，借助这种方法，就能通过更少的特征捕获到数据集的大部分信息。

PCA跟其他转换器用法类似。它只有主成分数量这一个参数。它默认会返回数据集中的所有特征。然而，PCA会对返回结果根据方差大小进行排序，返回的第一个特征方差最大，第二个特征方差稍小，以此类推。因此，前几个特征往往就能够解释数据集的大部分信息。请见代码：

from sklearn.decomposition import PCA
pca = PCA(n_components=5)
Xd = pca.fit_transform(X)

返回的结果Xd矩阵只有五个特征，但是不容小觑，我们看一下每个特征的方差。

np.set_printoptions(precision=3, suppress=True)
pca.explained_variance_ratio_

结果array([ 0.854, 0.145, 0.001, 0. , 0. ])表明第一个特征的方差对数据集总体方差的贡献率为85.4%。第二个为14.5%。第四个特征方差贡献率不可能高于1‰，后面1553个特征则更少。

用PCA算法处理数据一个不好的地方在于，得到的主成分往往是其他几个特征的复杂组合，例如，上述第一个特征就是通过为原始数据集的1558个特征（虽然很多特征值为0）分别乘以不同权重得到的，前三个特征的权重依次为-0.092、-0.995和-0.024。经过某种组合得到的特征，如果没有丰富的研究经验，理解起来很困难。

用PCA算法得到的数据创建模型，不仅能够近似地表示原始数据集，还能提升分类任务的正确率。

clf = DecisionTreeClassifier(random_state=14)
scores_reduced = cross_val_score(clf, Xd, y, scoring='accuracy')

正确率为0.9356，比起只用所有的原始特征效果（稍）好。PCA算法不是说都能带来效果上的提升，但多数情况是有好处的。

　我们这里用PCA算法来减少数据集中的冗余特征。一般来说，你不能用它来解决数据挖掘实验的过拟合问题。原因是PCA没有将类别考虑进去。更佳解决方案是正则化，简介及代码请见 http://blog.datadive.net/selectinggood-features-part-ii-linear-models-and-regularization/。

PCA算法的另一个优点是，你可以把抽象难懂的数据集绘制成图形。例如，把PCA返回的前两个特征做成图形。

首先，告诉IPython在当前笔记本作图，导入pyplot。

%matplotlib inline
from matplotlib import pyplot as plt

接着，获取数据集中类别的所有取值（只有两个：是广告和不是广告）。

classes = set(y)

指定在图形中用什么颜色表示这两个类别。

colors = ['red', 'green']

用zip函数将这两个列表组合起来，同时遍历。

for cur_class, color in zip(classes, colors):

为属于当前类别的所有个体创建遮罩层。

    mask = (y == cur_class).values

使用pyplot的scatter函数显示它们的位置。图中的x和y的值为前两个特征。

    plt.scatter(Xd[mask,0], Xd[mask,1], marker='o', color=color,
      label=int(cur_class))

最后，在循环的外面，创建图示，显示图像，从中就能看到分属两个类别的个体。

plt.legend()
plt.show()

5.4　创建自己的转换器

随着数据集复杂程度的提高和类型的变化，你可能发现，现成的特征抽取转换器不能满足需求了。比如，第7章从图这种数据结构里抽取特征，就需要自己编写转换器。

转换器像极了转换函数。它接收一种形式的数据，输出另外一种形式。转换器可以用训练集训练，训练得到的参数可以用来转换测试数据集。

转换器的API（应用编程接口）很简单。它接收一种特定格式的数据，输出一种格式的数据（可能与输入数据的格式相同，也可能不同）。别的就没有什么特殊要求了。

5.4.1　转换器API

转换器有两个关键函数。

fit()：接收训练数据，设置内部参数。

transform()：转换过程。接收训练数据集或相同格式的新数据集。

fit()和transform()函数的输入应该为同一种数据类型，但是transform()可以返回不同的数据类型。

我们现在就通过实现一个简单的转换器，来说明下API的使用。转换器接收numpy数组作为输入，根据均值将其离散化。任何高于均值的特征值（训练集中）替换为1，小于或等于均值的替换为0。

我们曾经用pandas对Adult数据集做过类似的转换：用Hours-per-week特征创建了LongHours特征，后者表示每周工作时长是否在40小时以上。即将编写的转换器有两个不同点。第一，接口与scikit-learn接口一致，便于在流水线中使用。第二，使用均值而不是固定的值（在LongHours例子中使用40作为将特征二值化的阈值）。

5.4.2　实现细节

打开分析Adult数据集时用到的笔记本文件。点击Cell 菜单，选择Run All，运行所有单元格。

首先，导入用来设置API 的TransformerMixin类。Python语言（相对于Java之类的语言）没有严格的接口规范，使用类似mixin这样的类，scikit-learn就会把我们封装的类当作是转换器。我们还需要导入用来判断输入类型是否合法的函数，稍后就会用到。

导入所需模块。

from sklearn.base import TransformerMixin
from sklearn.utils import as_float_array

接着，创建继承自mixin的类。

class MeanDiscrete(TransformerMixin):

我们需要定义接口规范与API一致的fit和transform函数。在fit函数中，计算出数据集的均值，用内部变量保存该值。函数声明如下：

    def fit(self, X):

fit函数中，要确保X数据集可以处理，我们用到了as_float_array函数（尝试对X进行转换，例如X是浮点数列表时就可以对其进行转换）。

        X = as_float_array(X)

计算数组的均值，用内部变量保存该值。如果X是多变量数组，数组self.mean保存的是每个特征的均值。

        self.mean = X.mean(axis=0)

fit函数需要返回它本身，确保在转换器中能够进行链式调用（例如调用ransformer. fit(X).transform(X)）。函数返回语句如下：

        return self

接着，新建transform函数，参数类型与fit函数相同，检查下输入是否合法。

    def transform(self, X):
         X = as_float_array(X)

输入必须是numpy数组（或等价的数据结构），除此之外，还需要检查输入的数据列数是否一致。X中的特征数应该与训练集中的特征数一致。

        assert X.shape[1] == self.mean.shape[0]

函数最后返回X中大于均值的数据。

        return X > self.mean

初始化一个实例，用它来对X数组进行转换。

mean_discrete = MeanDiscrete()
X_mean = mean_discrete.fit_transform(X)

5.4.3　单元测试

创建完函数和类后，最好是做下单元测试。单元测试，顾名思义，就是对代码中完成一个功能的代码单元进行测试。在这里，测试下转换器的功能是否符合预期。

充分的测试应当在独立于现有代码的前提下进行。为保证测试的有效性，最好使用另一种编程语言或方法来进行相同的计算。我们这里使用Excel创建数据集，再计算每一列的均值，接下来跟程序计算结果相比较。

单元测试应该短小、运行速度快。因此，我们选用少量数据进行测试。本例用于测试的数据集保存在前面创建的Xt变量中，在测试中会重新创建这个数据集。该数据集两个特征的均值分别为13.5和15.5。

为了创建单元测试，我们从numpy的testing模块中导入assert_array_equal函数，它用来检测两个数组是否相等。

from numpy.testing import assert_array_equal

接下来，创建测试函数，注意文件名以“test_”开头，这种命名方法便于工具自动查找并运行测试。代码如下：

def test_meandiscrete():
    X_test = np.array([[ 0,  2],
                        [ 3,  5],
                        [ 6,  8],
                        [ 9, 11],
                        [12, 14],
                        [15, 17],
                        [18, 20],
                        [21, 23],
                        [24, 26],
                        [27, 29]])

创建转换函数实例，用测试数据进行训练。

    mean_discrete = MeanDiscrete()
    mean_discrete.fit(X_test)

通过与在Excel中得到的结果比较，检查内部均值参数是否正确设置。

    assert_array_equal(mean_discrete.mean, np.array([13.5, 15.5]))

运行转换函数，创建被转换过后的数据集。准备好测试数据（在Excel中创建的）。

    X_transformed = mean_discrete.transform(X_test)
    X_expected = np.array([[ 0,  0],
                            [ 0, 0],
                            [ 0, 0],
                            [ 0, 0],
                            [ 0, 0],
                            [ 1, 1],
                            [ 1, 1],
                            [ 1, 1],
                            [ 1, 1],
                            [ 1, 1]])

最后，测试转换器返回结果是否与期望结果一致。

assert_array_equal(X_transformed, X_expected)

调用函数，开始测试！

test_meandiscrete()

如果没弄错，上述代码应该没有问题！可以改动测试数据，用几个错误值，看看是否测试失败。要让测试结果通过，记得再改回正确的值。

如果要进行多个测试，最好使用nose测试框架来运行测试。

5.4.4　组装起来

现在，我们有了一个经过测试的转换器，拿出来用用吧。用上我们之前学到的知识，创建一个流水线，第一步使用MeanDiscrete转换器，第二步使用决策树分类器，然后进行交叉检验，输出结果。来看下代码：

from sklearn.pipeline import Pipeline
pipeline = Pipeline([('mean_discrete', MeanDiscrete()),
  ('classifier', DecisionTreeClassifier(random_state=14))])
  scores_mean_discrete = cross_val_score(pipeline, X, y,
    scoring='accuracy')
  print("Mean Discrete performance:
{0:.3f}".format(scores_mean_discrete.mean()))

正确率为0.803，结果没有之前高，对于简单的二值特征而言还算不错。

5.5　小结

本章重点为特征和转换器，以及如何将其用到数据挖掘流水线中。我们探讨了什么是好特征，以及如何用算法从数据集中选取最佳特征。然而，创建好的特征比起科学更像是一门艺术，通常需要领域相关的知识和经验。

我们创建了自己的转换器，接口与scikit-learn常用工具接口一致。后续章节会创建更多的转换器，可以使用现有函数对其进行充分测试。

下一章将介绍文本的特征抽取方法。对于文本而言，转换器和特征类型都不少，但它们各有利弊。

第 6 章　使用朴素贝叶斯进行社会媒体挖掘

不论是书、历史文档、社会媒体、电子邮件还是其他以文字为主的通信方式，都包含大量信息。从文本数据集抽取特征，用于分类不是件容易事。然而，人们还是总结出了文本挖掘的通用方法。

本章介绍如何用强大却出奇简单的朴素贝叶斯算法消除社会媒体用语的歧义。朴素贝叶斯算法在计算用于分类的概率时，为简化计算，假定各特征之间是相互独立的，因此名字中含有朴素二字。它稍经扩展就能用于对其他类型数据集进行分类，且不依赖于数值特征。本章创建的模型在多种数据集上表现还不错，可作为很多文本挖掘研究的基准。

本章主要涉及如下内容。

用社交网络的API下载数据

用于处理文本的转换器

朴素贝叶斯分类器

用JSON保存和加载数据集

用NLTK库从文本中抽取特征

用F值评估分类效果

6.1　消歧

文本通常被称为无结构格式，虽然它包含很多信息，但是却没有标题、特定格式，句法松散，以及其他问题，导致难以从中提取有用信息。数据间联系紧密，行文中经常相互提及，交叉引用现象也很常见——从这种格式中提取信息难度很大！

我们通过比较书中的信息和大型数据库中的信息，来看下两者都有哪些方面的不同。书中有角色、主题、场所等大量信息。然而，只有去读书，并且光读还不行，更重要的是理解后才能获得书中的信息。而位于服务器数据库的数据，每一列都有名字，都有指定的数据类型。所有的信息都在数据库中，解释起来很容易。描述数据类型、含义的信息叫作元数据，文本中缺乏这类数据。书中的目录和索引虽含有部分元数据，但是比起数据库对于数据精确的定义和描述，这点元数据实在微不足道。

文本挖掘的一个难点来自于歧义，消除歧义常被简称为消歧。当人们使用bank1这个词时，他透露的是金融相关的信息还是环境相关的（比如河岸）？在很多情况下，对我们自己来说，消除歧义比较容易（有时也不简单），但是对计算机来说难度要大得多。

1在英语中，bank既可以指银行，也可以指河岸。——译者注

本章将探讨如何区别Twitter消息中Python的意思。Twitter网站上的一条消息叫作tweet，它最多不能超过140个字符。这也就表明上下文信息较少。此外，没有多少元数据，虽然“#”号经常用来表示消息的主题。

当人们提及Python时，他们谈论的可能是：

编程语言Python

经典喜剧剧团Monty Python2

蟒蛇

鞋子品牌Python

2编程语言Python的名字正是来自于这个英国喜剧剧团的名字，Guido van Rossum很喜欢该剧团的演出，于是把他创立的编程语言命名为Python。——译者注

可能还有很多其他东西也叫Python。我们实验的目的是根据消息的内容，判断消息中的Python是不是指编程语言。

6.1.1　从社交网站下载数据

接下来，从Twitter网站下载一些语料，从中剔除垃圾信息后，用于分类任务。Twitter提供了从他们服务器采集信息的强大API，小规模使用免费，但如果你打算将Twitter数据用于商业用途，请了解下相关规定。

首先，你需要一个Twitter账号（免费）。如果没有的话，请访问http://twitter.com进行注册。

每分钟的请求数不能超过Twitter所规定的上限。写作本书时，上限为每小时180次。这个规定不好遵照执行，因此，强烈建议使用现成的库与Twitter的API进行通信。

访问Twitter数据时需要提供密钥。打开http://twitter.com，登录Twitter。

登录后，访问https://apps.twitter.com/，点击Create New App（创建新应用）。

指定新应用的名称，填好描述及要在哪个网站中使用。如果不打算在网站中使用，请在Website文本框中随意输入些内容，确保提交时表单能通过验证。Callback URL文本框空着不填——我们用不到。在下面的开发者协议处选中Yes, I agree前面的复选框（如果你确实同意），点击Create your Twitter application。

创建新应用后，先别急着关掉当前页面——后面会用到页面上的access keys（访问密钥）。接下来，需要找一个与Twitter通信的Python第三方库。选择有很多，我喜欢用Twitter官方提供的twitter库。

　如果你习惯用pip安装第三方包，可以使用pip3 install twitter安装twitter库。如果你用的是其他系统，安装方法请参考文档https://github.com/sixohsix/twitter。

创建新的IPython笔记本文件来编写下载Twitter消息的代码。本章将创建几个不同的笔记本文件，用于不同的处理任务，所以最好是新建一个文件夹，把它们放到一起。第一个笔记本文件ch6_get_twitter专门用来下载新的Twitter语料。

首先，导入twitter库，设置授权令牌。刚才没让你关闭的那一页的Keys and Access Tokens （密钥和访问令牌）选项卡下，有consumer key（用户密钥）和consumer secret（请求令牌）。点击在同一页的Create my access token（创建我的访问令牌）按钮，获取访问令牌。用你得到的密钥和令牌替换下面代码中的占位符。

import twitter
consumer_key = "<Your Consumer Key Here>"
consumer_secret = "<Your Consumer Secret Here>"
access_token = "<Your Access Token Here>"
access_token_secret = "<Your Access Token Secret Here>"
authorization = twitter.OAuth(access_token, access_token_secret,
consumer_key, consumer_secret)

我们将用twitter库提供的搜索函数（search）查找包含单词“Python”的消息。创建阅读器对象，提供授权信息连接到Twitter，然后使用阅读器对象进行搜索。在笔记本文件中，指定消息的存储位置。

import os
output_filename = os.path.join(os.path.expanduser("~"),
  "Data", "twitter", "python_tweets.json")

保存数据时要用到json库。

import json

接着，创建用来从Twitter网站读取数据的对象，指定授权信息，需要用到前面创建的授权对象。

t = twitter.Twitter(auth=authorization)

打开输出文件，等待写入数据，写入模式指定为“a”，这样每次在文件末尾追加新数据。使用建立好的连接，搜索单词“Python”，对于search方法返回的数据，我们只需要“statuses”部分。下面代码获取到Twitter消息，使用json库的dump函数将其转换为字符串形式后，写入到输出文件中。写完每条消息后，再写一行空行，便于把每条消息区分开来。

with open(output_filename, 'a') as output_file:
    search_results = t.search.tweets(q="python", count=100)['statuses']
    for tweet in search_results:
        if 'text' in tweet:
            output_file.write(json.dumps(tweet))
            output_file.write("\n\n")

上述循环中，还检测了消息是否包括“text”键。并不是Twitter返回的所有对象都是消息（有些可能是用来删除消息或其他内容的动作）。消息对象与其他对象的关键不同在于消息对象中含有键“text”，这也正是我们用if语句进行检测的。

上述代码运行几分钟后，输出文件中就能有100条消息。

　你也可以让它多运行几分钟，抓取更多的消息，需要注意的是，如果Twitter用户没有发布新消息的话，短时间内多次抓取，得到的消息很可能会有重复的。

6.1.2　加载数据集并对其分类

完成消息采集后，我们拿到了原始数据集，对它里面的消息逐条标注后，才能用于分类任务。在笔记本文件中创建一个表单，方便标注。

消息存储格式近似于JSON。JSON格式不会对数据强加过多用于表示结构的信息，可以直接用JavaScript（JSON名字也就是这么来的，JavaScript Object Notation，JavaScript对象表示法）语言读取。JSON定义了诸如数字、字符串、数组、字典等基本对象，适合存储包含非数值类型的数据。如果数据集全都是数值类型，为了节省空间和时间，最好使用类似于numpy矩阵这样的格式来存储。

我们的数据集格式和真正的JSON对象的关键区别在于，每两条消息之间有一行空行。这样做的目的是，防止新追加的消息和前面的消息混在一起（在真正的JSON格式中，追加数据没这么简单）。我们的数据集中，每两条用JSON字符串表示的消息之间有一行空行。

我们可以使用json库解析数据集，但是要先根据空行把读进来的文件拆分（split方法）成一个列表，得到真正的消息对象。

新建一个笔记本文件（我把它命名为ch6_label_twitter），指定数据集的名称。该名称即为上节指定的输出文件的名字。我们还指定用于存放每条消息所属类别的文件名。代码如下：

import os
input_filename = os.path.join(os.path.expanduser("~"), "Data",
"twitter", "python_tweets.json")
labels_filename = os.path.join(os.path.expanduser("~"), "Data",
"twitter", "python_classes.json")

上面已经提到过，我们要使用json库，先来导入它。

import json

创建列表，用于存储从文件中读进来的每条消息。

tweets = []

遍历文件中的每一行数据。我们对不包含消息的空行（它们用于分隔消息）不感兴趣，因此，检测当前行（去除任意空白字符后的）长度是否为0。如果为0，忽略当前行，继续判断下一行。如果不为0，使用json.loads（将JSON字符串转换为Python对象）方法加载消息，将它添加到tweets列表中。代码如下：

with open(input_filename) as inf:
    for line in inf:
        if len(line.strip()) == 0:
            continue
        tweets.append(json.loads(line))

我们现在想知道一条消息是否和我们相关（在这里，相关表示指的是编程语言Python）。接下来，在笔记本文件中嵌入HTML代码，实现JavaScript和Python之间的通信，用网页形式展示消息，便于标注。

我们要实现以下功能，向用户（你）展示一条消息，要求用户输入类别：相关还是不相关。程序保存输入结果，继续展示下一条待标注的消息。

首先，创建用于存储类别（标注结果）的列表。不管消息是不是与编程语言Python相关，我们都保存它的类别，分类器从这两类数据中学习如何预测一条消息的类别。

我们还要检测是不是有部分消息已经标注过类别了，有的话就加载这些类别。如果你标注到一半，临时有事要关闭笔记本文件，有了该功能，再打开时，代码就会加载已有类别。一般来说，对于类似的任务，考虑如何保存中间结果很有必要。这样即使计算机中途死机，努力一个小时得来的工作成果也不至于全部丢失！代码如下：

labels = []
if os.path.exists(labels_filename):
    with open(labels_filename) as inf:
        labels = json.load(inf)

接下来，创建一个简单的函数，用来返回下一条需要标注的消息。我们找到并返回第一条没有标注类别的消息即可。代码如下。

def get_next_tweet():
    return tweet_sample[len(labels)]['text']

　我们实验的下一个步骤是收集用户（你！）对每条消息中的“Python”是否指的是编程语言的看法。在笔记本文件中，仅使用Python，无法通过直接与用户进行交互的方式来收集他们的反馈。因此，我们只好使用一点JavaScript和HTML代码来获取用户输入。

接下来，在笔记本中创建JavaScript程序来收集输入。可以借助魔术方法（magic function）在笔记本中直接嵌入HTML和JavaScript代码等。在笔记本的新格子中输入如下代码。

%%javascript

这样就表示下面为JavaScript代码，因此，大括号就要登场了。别担心，我们很快就会再回到Python的。请注意下面JavaScript代码必须与魔术方法%%javascript在同一格子里。

从下面定义的第一个JavaScript函数，就能看到在笔记本中，实现JavaScript和Python之间的通信是多么容易。这个函数的功能是向labels列表（Python代码中）添加一条消息所属的类别。具体做法是先加载IPython内核（kernel）对象，再用它来执行Python命令。代码如下：

function set_label(label){
    var kernel = IPython.notebook.kernel;
    kernel.execute("labels.append(" + label + ")");
    load_next_tweet();
}

函数最后调用load_next_tweet函数，加载下一条未标注的消息。load_next_tweet这个JavaScript函数内部执行Python代码的原理跟上面所讲的相同：用加载的IPython内核来执行Python命令（调用前面定义的get_next_tweet函数）。

然而，获取并展示消息有点困难，需要用到回调函数，返回数据时调用该函数。回调函数的定义方法超出了本书的范围。如果你对更高级的JavaScript/Python交互方法感兴趣，请参考IPython文档。

代码如下：

function load_next_tweet(){
   var code_input = "get_next_tweet()";
   var kernel = IPython.notebook.kernel;
   var callbacks = { 'iopub' : {'output' : handle_output}};
   kernel.execute(code_input, callbacks, {silent:false});
}

回调函数叫作handle_output，下面就来创建它。当kernel.execute()调用的Python函数返回结果后，就会调用回调函数。如上所述，回调函数的详细内容不在本书讲解范围之列，我们这里用它来处理返回的纯文本格式数据，把这些数据置于表单的#tweet_text div中展示，我们随后会编写相关HTML代码。回调函数代码如下：

function handle_output(out){
   var res = out.content.data["text/plain"];
   $("div#tweet_text").html(res);
}

HTML代码中，最外层是id为tweetbox的div，它里面包着id为tweet_text的div元素用于显示下一条待标注的消息。我们还创建文本框，捕获输入的按键（否则，笔记本程序将会捕获它，JavaScript也就无法获得用户的输入）。这样我们就可以用键盘来设置消息的类别为1或0，比起用鼠标点击按钮进行选择要快——假如我们至少需要标注100条消息。

运行JavaScript代码所在的格子，就会在页面中嵌入JavaScript代码，虽然在结果区域看不到任何变化。

接着，我们来使用另一个魔术方法%%html。毫无疑问，它是用来直接在笔记本中嵌入HTML代码。在新格子中输入如下代码。

%%html

接下来，在这个格子中输入HTML代码和几行JavaScript代码。首先，定义一个div元素，用来显示当前要标注的消息。我还添加了几行标注说明。接着，创建id为tweet_text的div元素，用来显示下一条待标注的消息。如前所述，我们还需要创建文本框用来捕获按键。代码如下：

<div name="tweetbox">
    Instructions: Click in textbox. Enter a 1 if the tweet is
relevant, enter 0 otherwise.<br>
Tweet: <div id="tweet_text" value="text"></div><br>
<input type=text id="capture"></input><br>
</div>

先不要运行这个格子！

创建完表单后，我们来编写捕获键盘按键的JavaScript代码，这段脚本须写到上面HTML代码的后面，因为#tweet_text元素在HTML代码运行前在页面上还不存在。这里要用到jQuery库（IPython笔记本文件使用了该库，无需再次引入）的一个函数，当在#capture文本框元素上发生按键事件时，调用指定的函数。然而，请注意，这个格子使用的魔术方法是%%html，在这里面写JavaScript代码，需要将其放到<script>标签中。

我们只关注按键0或1，因为消息只可能属于这两个类别。通过检测存储在e.which中的ASCII码值，就能确定到底是哪个键被按下了。如果用户按下0或1，我们把类别添加到labels列表中，然后清空文本框的值。代码如下：

<script>
$("input#capture").keypress(function(e) {
if(e.which == 48) {
    set_label(0);
    $("input#capture").val("");
}else if (e.which == 49){
    set_label(1);
    $("input#capture").val("");
  }
});

忽略其他按键。

下面是本章最后几行JavaScript代码（我向你保证），我们调用load_next_tweet()函数。设置第一条待标注的消息，闭合script标签。代码如下：

load_next_tweet();
</script>

在笔记本中，运行该格子，页面上会出现HTML文本框，旁边就是第一条消息。点击文本框，如果该消息与我们相关（消息中的Python指的是编程语言），输入1；反之，输入0。完成后，加载下一条消息。输入类别，接着加载下一条。重复以上过程，直到标注完所有数据。

完成标注后，把所有的类别信息输出到前面定义好的类别文件中。

with open(labels_filename, 'w') as outf:
    json.dump(labels, outf)

即使没有完成标注，也可以运行上述代码，保存标注过的类别。再次运行笔记本将会加载你没有标注的消息，这样你就能继续标注。

标注消息要花点时间！消息很多的话，更是如此。如果你为了赶时间，可以下载我标注好的数据集。

6.1.3　Twitter数据集重建

数据挖掘过程会用到很多变量，它们不仅出现在挖掘算法里，数据采集等过程中也少不了它们的身影。重现实验结果很重要，因为它有助于验证或改善实验效果。3

3这一段话，照直翻译过来，总觉得缺了点什么，推测作者想要表达的意思是控制每次实验中变量的取值，确保实验结果的可比较性。——译者注

　算法X在一个数据集上取得80%的正确率，算法Y在另一个数据集上取得90%的正确率，不能说明算法Y优于X。只有在相同的测试集上，且在相同的条件下进行测试，才能比较算法的优劣。

你用上面的代码采集到的数据集与我的不同。主要原因在于，采集时间不同，Twitter返回的搜索结果也就不同。此外，标注结果也可能会有所差异。虽然有些消息显然是与编程语言Python有关，但也不乏模棱两可的情况，比如用我不懂的外语发布的消息，我给出的标注结果就可能有误。对于这种情况，Twitter提供了设置语言种类的接口，但即使使用该接口，返回的消息也不一定全都是你预先指定的语言。

出于这么多原因，对于从社交网站采集到的数据集重复进行相同的实验困难重重，Twitter也不例外。此外，Twitter明确禁止直接分享数据集。

解决方法之一是只分享消息编号，它是可以自由传播的。本节首先创建可以自由分享的消息编号数据集。然后，再来看下如何根据编号下载消息，重建先前的数据集。

首先，我们得把现有消息的编号及其类别保存下来。创建一个新的笔记本文件，指定接下来要用到的几个文件名。代码跟之前类似，只不过多了一个用来保存消息编号及其类别的文件。代码如下：

import os
input_filename = os.path.join(os.path.expanduser("~"), "Data",
"twitter", "python_tweets.json")
labels_filename = os.path.join(os.path.expanduser("~"), "Data",
"twitter", "python_classes.json")
replicable_dataset = os.path.join(os.path.expanduser("~"),
  "Data", "twitter", "replicable_dataset.json")

加载消息和类别，就跟我们在上一个笔记本文件中做的那样。

import json
tweets = []
with open(input_filename) as inf:
    for line in inf:
        if len(line.strip()) == 0:
            continue
        tweets.append(json.loads(line))
if os.path.exists(labels_filename):
    with open(classes_filename) as inf:
        labels = json.load(inf)

同时遍历所有的消息及消息所属的类别，创建新数据集，将其保存到列表中。

dataset = [(tweet['id'], label) for tweet, label in zip(tweets,
  labels)]

最后，把结果保存到文件中。

with open(replicable_dataset, 'w') as outf:
    json.dump(dataset, outf)

有了消息的编号和类别，我们就可以重建数据集。如果你想重建我在本章使用的数据集，所需代码请见本书配套代码包。

加载之前的数据集不难，但是要花些时间。新建一个笔记本文件，像之前那样指定好用于存储数据集、消息类别和消息编号的文件。我调整了下文件名，防止你覆盖掉之前采集的数据集，文件名你可以随意起，不必跟我的一样。代码如下：

import os
tweet_filename = os.path.join(os.path.expanduser("~"), "Data",
  "twitter", "replicable_python_tweets.json")
labels_filename = os.path.join(os.path.expanduser("~"), "Data",
  "twitter", "replicable_python_classes.json")
replicable_dataset = os.path.join(os.path.expanduser("~"),
  "Data", "twitter", "replicable_dataset.json")

使用JSON从文件中加载消息编号及类别数据。

import json
with open(replicable_dataset) as inf:
    tweet_ids = json.load(inf)

保存所有消息的类别很容易。遍历数据集，抽取编号，用两行代码就能搞定（打开文件和保存消息）。然而，我们无法确定之后能再次获取到所有消息（例如，上次采集后，有些消息被设置为隐私），因此类别和消息可能就无法对应。

为了举例说明，我在采集数据后的第一天尝试重建数据集，却发现有两条消息已经从线上消失（可能被用户删除或设置为隐私）。因此，只输出我们实际能用到的类别就显得尤为重要。具体做法是，首先，创建actual_labels列表存储我们能够再次从Twitter网站获取到的消息的类别。然后，创建字典，为消息的编号和类别建立起映射关系。

代码如下：

actual_labels = []
label_mapping = dict(tweet_ids)

接下来，用twitter库根据消息编号采集消息。这可能要花点时间。导入前面用过的twitter库，创建授权令牌，用它来初始化twitter对象。

import twitter
consumer_key = "<Your Consumer Key Here>"
consumer_secret = "<Your Consumer Secret Here>"
access_token = "<Your Access Token Here>"
access_token_secret = "<Your Access Token Secret Here>"
authorization = twitter.OAuth(access_token, access_token_secret,
  consumer_key, consumer_secret)
t = twitter.Twitter(auth=authorization)

遍历并抽取所有的消息编号。

all_ids = [tweet_id for tweet_id, label in tweet_ids]

然后，打开输出文件，保存消息。

with open(tweets_filename, 'a') as output_file:

Twitter API允许我们一次只能获取100条消息。因此，每次遍历100条消息。

    for start_index in range(0, len(tweet_ids), 100):

把这一批次的100个编号用逗号连接起来，便于下面使用Twitter的API根据编号查找消息。

        id_string = ",".join(str(i) for i in
          all_ids[start_index:start_index+100])

接着，调用Twitter定义的statuses/lookup方法，传入一批消息编号（已转换为字符串），以采集这些消息。

        search_results = t.statuses.lookup(_id=id_string)

我们把返回结果中的每一条消息，按照之前采集数据集的做法，将它们依次保存到文件中。

        for tweet in search_results:
            if 'text' in tweet:
                output_file.write(json.dumps(tweet))
                output_file.write("\n\n")

最后一步（仍然属于if模块），还需要保存当前遍历到的消息的类别。获取消息类别要用到之前创建的label_mapping字典，根据消息编号查找即可。代码如下：

                actual_labels.append(label_mapping[tweet['id']])

运行上述代码，采集所有消息。如果你的数据集很大，花费时间相应较多——Twitter会限制请求的频次。最后一步，保存actual_labels到类别文件4里。

4再次提醒下它里面存放的是能够再次抓取到的消息的类别。——译者注

with open(labels_filename, 'w') as outf:
    json.dump(actual_labels, outf)

6.2　文本转换器

数据集创建好后，怎样在它上面施展数据挖掘的威力呢？

文本数据集包括图书、文章、网站、手稿、代码以及其他形式的文本。我们目前所见过的所有算法不是用来处理数值型就是类别型特征，怎样才能把文本转换成算法可以处理的形式？

多种测量方法能够帮上忙。比如，平均词长和平均句长可用来预测文本的可读性。除此之外，还有很多其他类型的特征，比如我们接下来要用到的单词是否出现（word occurrence）。

6.2.1　词袋

一种最简单却非常高效的模型就是只统计数据集中每个单词的出现次数。我们来创建一个矩阵，每一行表示数据集中的一篇文档，每一列代表一个词。矩阵中的每一项为某个词在文档中的出现次数。

下面这段文字节选自托尔金（J. R. R. Tolkien）的《指环王》。

Three Rings for the Elven-kings under the sky,

Seven for the Dwarf-lords in halls of stone,

Nine for Mortal Men, doomed to die,

One for the Dark Lord on his dark throne

In the Land of Mordor where the Shadows lie.

One Ring to rule them all, One Ring to find them,

One Ring to bring them all and in the darkness bind them.

In the Land of Mordor where the Shadows lie.

—J.R.R. Tolkien's epigraph to The Lord of The Rings

单词the在引文中出现了9次，单词in、for、to和one各出现了4次。单词ring和of各出现3次。

从中选取几组数据，创建一个简单的数据集。

单词

the

one

ring

to

频次

9

4

3

4

可以用counter方法统计列表中各字符串出现次数。统计单词时，通常将所有字母转换为小写，因此定义字符串时顺便把它转换为小写形式。代码如下：

s = """Three Rings for the Elven-kings under the sky,
Seven for the Dwarf-lords in halls of stone,
Nine for Mortal Men, doomed to die,
One for the Dark Lord on his dark throne
In the Land of Mordor where the Shadows lie.
One Ring to rule them all, One Ring to find them,
One Ring to bring them all and in the darkness bind them.
In the Land of Mordor where the Shadows lie. """.lower()
words = s.split()
from collections import Counter
c = Counter(words)

c.most_common(5)输出出现次数最多的前5个词，竟然有4个词并列第二，多输出几个就能看出词频的差异了。

词袋模型主要分为以下三种：第一种像上面这样使用词语实际出现次数作为词频。缺点是当文档长度差异明显时，词频差距会非常大。第二种是使用归一化后的词频，每篇文档中所有词语的词频之和为1。这种做法优势明显，它规避了文档长度对词频的影响。第三种，直接使用二值特征来表示——单词在文档中出现值为1，不出现值为0。本章使用第三种。

另外一种（更）通用的规范化方法叫作词频—逆文档频率法（term frequency-inverse document frequency，简写为tf-idf），该加权方法用词频来代替词的出现次数，然后再用词频除以包含该词的文档的数量。第10章将会用到词频—逆文档频率法。

Python有很多用于处理文本的库。我们将使用主流的NLTK库（Natural Language ToolKit，自然语言处理工具集）抽取特征。scikit-learn提供进行类似处理的CountVectorizer类，建议你花点时间了解下（第9章将会用到）。然而在分词方面，NLTK提供更多选择。如果你打算用Python做自然语言处理，NLTK是个不错的选择。

6.2.2　N元语法

比起用单个词作特征，使用N元语法能更好地描述文档，具体优势稍后会讲。N元语法是指由几个连续的词组成的子序列。拿我们的数据集来讲，N元语法指的是每条消息里一组连续的词。

N元语法的计算方法跟计算单个词语方法相同，我们把构成N元语法的几个词看成是词袋中的一个词。数据集5中每一项就变成了N元语法在给定文档中的词频。

5指用N元语法作为特征的数据集。——译者注

　N元语法中的参数n，对于英语这门语言，一开始取2到5之间的值就可以，有些应用可能要使用更高的值

举个例子吧，当 n 取3时，我们从下面引文中抽取前几个N元语法。

Always look on the bright side of life.

第一个N元语法（三元）是Always look on，第二个是look on the，第三个是on the bright。你可能已经发现，几个N元语法有重合，其中三个词有不同程度的重复。

N元语法比起单个词有很多优点。这个简单的概念不用通过大量的计算，就提供了有助于理解词语用法的上下文信息。它的缺点是特征矩阵变得更为稀疏——一个N元语法不太可能出现两次（尤其是在Twitter消息及其他短文本中！）。

对于社会媒体所产生的内容以及其他短文档，N元语法不可能出现在多篇不同的文档中，除非是转发。然而，在长文档中，N元语法就很有效。

文档的另外一种N元语法关注的不是一组词而是一组字符（虽然字符N元语法6有多种计算方法！）。字符N元语法有助于发现拼写错误，除此之外，还有其他好处。本章及第9章都将测试这种N元语法的效果。

6英文为Character N-gram。 ——译者注

6.2.3　其他特征

除了N元语法外，还可以抽取很多其他特征，其中就包括句法特征，比如特定词语在句子中的用法。对于需要理解文本含义的数据挖掘应用，往往会用到词性。本书限于篇幅将不涉及这些特征。如果你对此感兴趣，推荐你看由Packt出版的Python 3 Text Processing with NLTK 3 Cookbook，作者为Jacob Perkins。

6.3　朴素贝叶斯

毫无疑问，朴素贝叶斯概率模型是以对贝叶斯统计方法的朴素解释为基础。尽管存在朴素的一面，这种方法应用面很广且都取得了不错的效果。特征类型和形式多样的数据集也可以用它进行分类，本章重点讲解如何用二值化后的特征组成的词袋模型来分类。

6.3.1　贝叶斯定理

大多数人在开始学习统计学时，都会被灌输从频率论者角度出发看问题的思想，即假定数据遵从某种分布，我们的目标是确定该种分布的几个参数。我们假定参数是固定的（也可能不正确），然后用自己的模型来套这些数据，甚至通过测试来证明数据与我们的模型相吻合。

相反，贝叶斯统计实际上是根据普通人（非统计学家）实际的推理方式来建模。我们用拿到的数据，来更新模型对某事件即将发生的可能性的预测结果。在贝叶斯统计学中，我们使用数据来描述模型，而不是使用模型来描述数据，用数据证实拍脑瓜得出的模型是典型的频率论者的做法。

贝叶斯定理旨在计算P(A | B)的值，也就是在已知B发生的条件下，A发生的概率是多少。大多数情况下，B是被观察事件，比如“昨天下雨了”，A为预测结果“今天会下雨”。对数据挖掘来说，B通常是观察样本个体，A为被预测个体所属类别。下一节将学习如何在数据挖掘领域使用贝叶斯定理。

贝叶斯定理公式如下：

举例说明，我们想计算含有单词drugs的邮件为垃圾邮件的概率（正如我们认为含有该词的Twitter消息可能是兜售药品的垃圾广告）。

在这里，A为“这是封垃圾邮件”。7我们先来计算P(A)，它也被称为先验信念（prior belief）。计算方法是，统计训练集中垃圾邮件的比例。如果我们的数据集每100封邮件有30封垃圾邮件，P(A)为30/100或0.3。

7原文直译为“A为这是封垃圾邮件的概率”，其实此处A表示的是事件，而不是概率。——译者注

B表示“该封邮件含有单词drugs”。类似地，我们可以通过计算数据集中含有单词drugs的邮件数量得到P(B)。如果每100封邮件中有10封邮件包含单词drugs，那么P(B)就为10/100或0.1。计算P(B)时，我们不关注邮件是不是垃圾邮件。

P(B|A)指的是垃圾邮件中含有单词drugs的概率，计算起来也很容易，统计训练集中所有垃圾邮件的数量以及其中含有单词drugs的数量。30封垃圾邮件中，如果有6封含有单词drugs，那么P(B|A)就为6/30或0.2。

现在，我们根据贝叶斯定理就能计算出P(A|B)，得到含有drugs的邮件为垃圾邮件的概率。把上面求出来的各项代入前面的贝叶斯公式，得到结果0.6。这表明如果邮件中含有drugs这个词，那么该邮件为垃圾邮件的概率为60%。

请注意上述例子的实证性特点——我们使用的经验或证据直接来自于数据集，而不是事先假定好的某种分布。相比之下，频率论方法会要求我们首先为训练集中的单词出现概率创建某种分布形式。

6.3.2　朴素贝叶斯算法

回过头看下贝叶斯公式，我们可以用它计算个体从属于给定类别的概率。因此，它可以用来分类。

我们用C表示某种类别，用D表示数据集中一篇文档，来计算贝叶斯公式所要用到的各种统计量，对于不好计算的，做出朴素假设，简化计算。朴素贝叶斯分类算法使用贝叶斯定理计算个体从属于某一类别的概率。

P(C)为某一类别的概率，可以从训练集中计算得到（方法跟上文检测垃圾邮件例子所用到的一致）。统计训练集所有文档从属于给定类别的百分比。

P(D)为某一文档的概率，它牵扯到各种特征，计算起来很困难，但是在计算文档属于哪个类别时，对于所有类别来说，P(D)相同，因此根本就不用计算它。稍后我们来看下怎么处理。

P(D|C)为C类含有文档D的概率。由于D包含多个特征，计算起来可能很困难，这时朴素贝叶斯算法就派上用场了。我们朴素地假定各个特征之间是相互独立的，分别计算每个特征（D1、D2、D3等）在给定类别出现的概率，再求它们的积。

P(D|C) = P(D1|C) x P(D2|C).... x P(Dn|C)

上式右侧对于二值特征相对比较容易计算。直接在数据集中进行统计，就能得到所有特征的概率值。

相反，如果我们不做朴素的假设，就要计算每个类别不同特征之间的相关性。这些计算很难完成，如果没有大量的数据或足够的语言分析模型也不可能完成。

到这里，算法就很明确了。对于每个类别，我们都要计算P(C|D)，忽略P(D)项。概率较高的那个类别即为分类结果。由于P(D)对每个类别来说都是相等，去掉它对最终预测结果没有多大影响。

6.3.3　算法应用示例

举例说明下计算过程，假如数据集中有以下一条用二值特征表示的数据：[1, 0, 0, 1]。

训练集中有75%的数据属于类别0，25%属于类别1，且每个特征属于每个类别的似然度如下。

类别0：[0.3,　0.4,　0.4,　0.7]

类别1：[0.7,　0.3,　0.4,　0.9]

拿类别0中特征1的似然度举例子，上面这两行数据可以这样理解：类别0中有30%的数据，特征1的值为1。

我们来计算一下这条数据属于类别0的概率。类别为0时，P(C=0) = 0.75。

朴素贝叶斯算法用不到P(D)，因此我们不用计算它。我们来看下计算过程。

P(D|C=0) = P(D1|C=0) x P(D2|C=0) x P(D3|C=0) x P(D4|C=0)
= 0.3 x 0.6 x 0.6 x 0.7
= 0.0756

　第二、三个值为0.6，是因为在上面我们给出的那条数据（[1, 0, 0, 1]）中，这两个特征的值为0。而我们给出的似然度表示特征值取1时，在各类别的概率。因此，特征值为0的概率为：P(0) = 1 – P(1) 。

现在，我们就可以计算该条数据从属于每个类别的概率。需要提醒的是，我们没有计算P(D)，因此，计算结果不是实际的概率。由于两次都不计算P(D)，结果具有可比较性，能够区分出大小就足够了。来看下计算结果。

P(C=0|D) = P(C=0) P(D|C=0)
= 0.75 * 0.0756
= 0.0567

接着，计算类别1的概率。

P(C=1) = 0.25

朴素贝叶斯公式不需要计算P(D)。我们来看下计算过程。

P(D|C=1) = P(D1|C=1) x P(D2|C=1) x P(D3|C=1) x P(D4|C=1)
= 0.7 x 0.7 x 0.6 x 0.9
= 0.2646
P(C=1|D) = P(C=1)P(D|C=1)
= 0.25 * 0.2646
= 0.06615

　通常，P(C=0|D) + P(C=1|D)应该等于1。毕竟，只有这两种选择！然而，我们这里二者的和不为1，因为我们在计算时省去了公式中的P(D)项。

该条数据应该被分到类别1中。计算过程中，你可能已经猜到结果了。看到最终结果两个类别的概率如此接近，你可能还是会有点惊讶。毕竟，类别为0时，P(D|C)的概率比类别为1时高很多。这是因为我们给出的先验概率很高，即大部分数据都属于类别0。

如果训练集中两类数据数量相同，结果就会大为不同。假设P(C=0) 、P(C=1) 各为0.5，再计算下结果看看。

6.4　应用

接下来，创建流水线，接收一条消息，仅根据消息内容，判断它是否与编程语言Python相关。

我们使用NLTK抽取特征。NLTK提供了大量用于自然语言处理的工具。后续章节还会继续使用它。

用pip安装NLTK：pip3 install nltk

如果安装失败，请参考NLTK安装指南：www.nltk.org/install.html。

接下来，创建流水线抽取词语特征，并使用朴素贝叶斯算法对消息进行分类。流水线包括以下步骤。

(1) 用NLTK的word_tokenize函数，将原始文档转换为由单词及其是否出现组成的字典。

(2) 用scikit-learn中的DictVectorizer转换器将字典转换为向量矩阵，这样朴素贝叶斯分类器就能使用第一步中抽取的特征。

(3) 正如前几章做过的那样，训练朴素贝叶斯分类器。

(4) 还需要新建一个笔记本文件ch6_classify_twitter（本章最后一个），用于分类。

6.4.1　抽取特征

我们使用NLTK抽取单词是否出现作为特征。我们想在流水线中使用NLTK，但是它的接口与转换器接口不一致。因此，需要创建一个包含fit和transform方法的基础转换器，只有这样才能在流水线中使用。

首先，创建转换器类。这个类不需要进行预处理，只需要抽取特征。因此，fit函数不做任何操作，只返回它自身（self）即可，转换器对象要用到它。

转换器函数就有点复杂。我们要用它从每篇文档中抽取单词，如果单词出现，记为True。注意这里使用二值特征，单词在文档中出现，值为True，反之，值为False。如果我们想使用词频，就要创建用来统计单词频率的字典，前几章讲过。

来看下代码。

from sklearn.base import TransformerMixin
from nltk import word_tokenize

class NLTKBOW(TransformerMixin):
    def fit(self, X, y=None):
        return self
    def transform(self, X):
        return [{word: True for word in word_tokenize(document)}
                 for document in X]

返回结果为一个元素为字典的列表，第一个字典的各项为第一条消息中的所有词语。字典的每一项用单词作为键，值为True，表示该词在该条消息中出现过。字典中没有出现的词，表示这条消息里不包含该词。当然，我们也可以用值False来表示没在消息中的词，但是那样太浪费存储空间，也没有必要。

6.4.2　将字典转换为矩阵

这一步是将上面得到的字典转换为可以用分类器进行处理的矩阵。在DictVectorizer的帮助下，这一步变得非常容易。

DictVectorizer类接受元素为字典的列表，将其转换为矩阵。矩阵中的各个特征为所有字典中的每个键，特征值就是特征在文本中是否出现。用代码生成字典很容易，但是实现的很多数据挖掘算法更喜欢接收矩阵格式的数据，于是DictVectorizer显得格外有用。

数据集中，每个字典用单词作为键，单词只有在对应的消息中出现，这个单词才会出现在字典里。因此，矩阵以每个单词作为特征，如果消息中出现该单词，那么相应的特征值就为True。

导入DictVectorizer后，就可以使用它。

from sklearn.feature_extraction import DictVectorizer

6.4.3　训练朴素贝叶斯分类器

最后，我们需要组装分类器，因为本章使用朴素贝叶斯算法，数据集只包含二值特征，因此我们使用专门用于二值特征分类的BernoulliNB分类器，它用起来很简单。就像是DictVectorizer一样，我们先来导入它，把它添加到流水线中。

from sklearn.naive_bayes import BernoulliNB

6.4.4　组装起来

终于等到把所有部件组装起来的时候了。像前面做过的那样，在笔记本文件中，指定好文件名，加载数据集和类别数据。注意是消息（不是消息编号）及它们的类别所在的文件。代码如下：

import os
input_filename = os.path.join(os.path.expanduser("~"), "Data",
  "twitter", "python_tweets.json")
labels_filename = os.path.join(os.path.expanduser("~"), "Data",
  "twitter", "python_classes.json")

加载消息。我们只对消息内容感兴趣，因此只提取和存储它们的text值。代码如下：

tweets = []
with open(input_filename) as inf:
    for line in inf:
        if len(line.strip()) == 0:
            continue
        tweets.append(json.loads(line)['text'])

加载消息的类别。

with open(classes_filename) as inf:
    labels = json.load(inf)

创建流水线，把所有部件组合起来。流水线包含以下三个部分。

我们创建的NLTKBOW转换器

DictVectorizer转换器

BernoulliNB分类器

流水线代码如下：

from sklearn.pipeline import Pipeline
pipeline = Pipeline([('bag-of-words', NLTKBOW()),
                     ('vectorizer', DictVectorizer()),
                     ('naive-bayes', BernoulliNB())
                     ])

我们几乎现在就可以运行流水线，用之前多次用过的cross_val_score方法来计算正确率。但是在这之前，我们要介绍一种比正确率更好的评价指标。我们后面会看到，每个类别数据量不同的情况下，正确率不足以说明算法的优劣。

6.4.5　用F1值评估

选择评价指标时，了解它们的适用范围很重要。正确率应用范围很广，理解起来比较容易，计算起来也方便。但是，造假很容易。换句话说，你很容易就能实现一个正确率很高，但实际用处不大的算法。

我们的消息数据集（你的可能与此不同）中，50%的消息与编程语言有关，50%不相关，很多数据集不会这么均匀（balanced）。

例如，对于垃圾邮件过滤器而言，其所处理的邮件很可能80%以上都是垃圾邮件，倘若一个过滤器把所有邮件都标为垃圾邮件，它没有实际应用价值，但是正确率却高达80%！

为了解决这个问题，我们使用另一个最为常用的评价指标F1值（也被称为F值、F-measure，或者其他变体8）。

8比如F0.5等。——译者注

F1值是以每个类别为基础进行定义的，包括两大概念：准确率（precision）和召回率（recall）。准确率是指预测结果属于某一类的个体，实际属于该类的比例。召回率是指被正确预测为某个类别的个体数量与数据集中该类别个体总量的比例。

在我们这里，可分别对两个类别（相关和不相关）的分类情况计算F1值。但是，我们只关注相关这一类数据的分类情况。因此，准确率计算变为以下问题：在所有被预测为相关的消息中，真正相关的占比多少？类似地，召回率就转化为：数据集所有相关的消息中，有多少被正确预测为相关的？

计算出准确率和召回率后，就能得到F1值，它是两者的调和平均数。

将scoring参数的值设置为F1，就能使用scikit-learn中的F1方法。默认将会返回类别为1的F1值。使用以下代码求得F1值。

scores = cross_val_score(pipeline, tweets, labels, scoring='f1')

输出平均值。

import numpy as np
print("Score: {:.3f}".format(np.mean(scores)))

结果为0.798，这表明预测含有Python的消息与编程语言有关的F1值差不多为80%。这是使用包含200条消息的数据集取得的结果。如果采集更多消息作为训练数据，你会发现结果还会提升！

　更多的数据通常意味着更好的结果，但也不一定！

6.4.6　从模型中获取更多有用的特征

你可能存在这样的疑问：“什么特征才是判断一条消息是否相关的最好的特征？”我们可以从朴素贝叶斯模型中抽取该信息，找到朴素贝叶斯算法所认为的最好的特征。

首先，训练得到一个新模型。使用cross_val_score在测试集上得到交叉检验结果不难，但是要得到模型本身就不容易了。为了得到模型，我们只好用流水线的fit函数，创建新模型。代码如下：

model = pipeline.fit(tweets, labels)

　注意我们不是在评价模型，训练/测试集的切分就没那么严格。然而，在使用这些特征之前，应该在单独的测试集上评价其表现。为保证讲解得清楚明了，跳过这一部分。

借助流水线的named_steps属性和步骤名（创建流水线对象时，我们自己定义的），就能访问流水线每一个步骤。例如，可以像下面这样访问朴素贝叶斯模型。

nb = model.named_steps['naive-bayes']

从这个模型中，可以抽取每个单词的对数概率，比如log(P(A|f))，其中f为给定特征。

之所以使用对数概率，是因为实际值非常小。例如，第一个值为-3.486，实际概率约为0.03。计算中涉及很小的概率值时，常使用对数概率来防止数值下溢，因为非常小的值往往会被约等于0。所有概率连乘，其中一个值为0，最终结果将为0！而实际上即使是很小的值，大小不同，对分类的贡献率也会有所差异，值越大的特征贡献率越大。

把得到的对数概率数组按照降序排列，找出最有用的特征。降序排列，需要在值前加个负号。代码如下：

top_features = np.argsort(-feature_probabilities[1])[:50]

上面代码只是给出特征索引值而没有给出实际的特征名称。这样看不出什么东西来，因此，需要把特征索引和特征名称对应起来。流水线的DictVectorizer这一步很关键，它是用来创建矩阵的。幸运的是，它也记录了特征名称和索引值的映射关系。因此，可以从流水线的这一部分抽取特征。

dv = model.named_steps['vectorizer']

通过在DictVectorizer的feature_names_属性中进行查找，找到最佳特征的名称，然后将其输出。输入下面代码，运行后将得到最佳特征列表。

for i, feature_index in enumerate(top_features):
    print(i, dv.feature_names_[feature_index],
      np.exp(feature_probabilities[1][feature_index]))

前几个特征为“:” “http” “#” “@”。结合采集的数据来判断，我们认为这些很可能是噪音（虽然冒号在编程语言之外的文本中使用的相对较少）。更多的训练数据，有助于减少这些噪音对分类的影响。接着往下看，下面这些特征更像是与编程语言有关。

7 for 0.188679245283
11 with 0.141509433962
28 installing 0.0660377358491
29 Top 0.0660377358491
34 Developer 0.0566037735849
35 library 0.0566037735849
36 ] 0.0566037735849
37 [ 0.0566037735849
41 version 0.0471698113208
43 error 0.0471698113208

还有一些特征是在工作环境中提及Python，因此可能指的是编程语言Python。（虽然作为自由职业者的耍蛇人也可能使用Python这个词，但是他们较少活跃在Twitter上。）

22 jobs 0.0660377358491
30 looking 0.0566037735849
31 Job 0.0566037735849
34 Developer 0.0566037735849
38 Freelancer 0.0471698113208
40 projects 0.0471698113208
47 We're 0.0471698113208

上面最后一个特征通常出现在诸如“We're looking for a candidate for this job”（我们正在招聘符合该职位要求的员工）这样的消息中。

查看这些特征，我们收获不小，有了这些特征，我们自己经过训练也可以完成分类任务，寻找这些特征的共同点（与主题相关），或者去除讲不通的特征。例如，词“RT”虽然排名比较靠前，然而，这是Twitter网站表示转发的用语。经验丰富的工作人员就可以从特征列表中删除这个词，降低由于数据集很小而引入的噪音对分类结果的影响。

6.5　小结

本章研究的是文本挖掘——特征的抽取、应用及扩展方法，具体研究任务是根据语境消除词语的歧义——即一条消息中的Python是否指编程语言。我们使用Twitter提供的API下载消息，使用在笔记本文件中建立的表单完成语料标注。

我们还考虑了实验结果的可再现性。虽然Twitter不允许你把自己采集的数据给别人使用，但是消息编号是可以共享的。我们编写代码，保存消息编号，再用这些编号来重建先前的数据集。由于有些消息被删除或是出于其他原因，我们无法再次获取它们。

我们用朴素贝叶斯分类器对文本进行分类，该分类器是以贝叶斯定理为基础，它使用数据来更新模型，而不是像频率论者那样从模型出发。这有助于整合新数据到模型中，利用新数据来更新模型和使用先验知识。此外，基于各特征相互独立的朴素假设，便于计算各特征出现在给定类别的概率，而不用考虑各特征之间复杂的相关性。

我们用词语是否出现作为特征值——即消息是否含有某个词，这种模型9叫作词袋模型。虽然它丢掉了词语在句子中的位置等信息，但它在很多数据集上表现不凡。

9把文档看成由一个个孤立的、无前后位置关系的单词组成的。——译者注

朴素贝叶斯分类器结合词袋模型，组装成的流水线功能强大。对于大多数文本挖掘任务，它都能取得很好的效果。在尝试更高级的模型之前，用这样的分类器取得的分类结果作为参考的基准很不错。另外一个优点是，朴素贝叶斯分类器不需要调整任何参数（如果你愿意折腾的话，也确实有几个）。

下一章将介绍怎样从另外一种数据类型——图中抽取特征，尝试解决向社会媒体网站用户推荐感兴趣的人这一问题。

第 7 章　用图挖掘找到感兴趣的人

很多事物都可用图来表示，大数据、社交网络和物联网时代尤其如此。特别值得一提的是，社交网络具有很大的商业价值，像Facebook这样每天有5亿活跃用户（50%的用户每天都会登录）的网站，通过定向投放广告获利颇丰。怎样才能打造一个这样的网站呢？对于网站而言，要想留住用户，你得有用户感兴趣的人或内容。

本章将探讨相似度这个概念以及如何根据它来创建图模型，并且探讨如何使用连通分支（connected components）把大图分为有意义的小图。本章即将介绍的算法引入聚类分析概念——根据相似度，把大数据集划分为几个子集。第10章将对聚类分析做更加深入的讲解。

本章主要内容如下：

用社交网络数据创建图

加载和保存创建的分类器

NetworkX库

图到矩阵的转换

距离和相似度

根据打分函数优化参数

损失函数和打分函数

7.1　加载数据集

本章的任务是根据社交网络用户的好友信息，向他们推荐好友。逻辑为：如果两个用户有共同的好友，那么这两个人相似度很高，值得向彼此推荐。

利用上一章介绍的Twitter API来获取数据，创建一个小的社交网络图。寻找喜欢同一个话题（与上一章相同，依旧是编程语言Python）的用户，从中选一部分，再获取这些人的好友列表（他们关注的人）。有了以上数据，就能根据两个用户共同拥有的好友数量，计算他们的相似度。

　除了Twitter，还有很多其他社交网站。之所以使用Twitter，是因为其提供的API很容易获取所需要的数据。其他网站，比如Facebook、LinkedIn和Instagram等也开放这些数据，但是获取起来难度更大些。

现在来采集数据，新建一个IPython Notebook笔记本文件，初始化twitter连接实例，方法与上一章相同。可以沿用上一章所创建的Twitter应用的密钥信息，再创建新的也可以：

import twitter
consumer_key = "<Your Consumer Key Here>"
consumer_secret = "<Your Consumer Secret Here>"
access_token = "<Your Access Token Here>"
access_token_secret = "<Your Access Token Secret Here>"
authorization = twitter.OAuth(access_token, access_token_secret,
  consumer_key, consumer_secret)
t = twitter.Twitter(auth=authorization, retry=True)

指定输出文件名：

import os
data_folder = os.path.join(os.path.expanduser("~"), "Data",
  "twitter")
output_filename = os.path.join(data_folder, "python_tweets.json")

依然用json库保存数据：

import json

接下来，收集一组用户信息。像上一章那样搜索推文，查找包含单词python的消息。首先，创建两个列表，用于存储消息文本内容及相应的用户信息。稍后会用到用户编号，因此，需要创建个字典，将用户昵称和编号对应。代码如下：

original_users = []
tweets = []
user_ids = {}

搜索包含python的消息，遍历搜索结果：

search_results = t.search.tweets(q="python",
  count=100)['statuses']
for tweet in search_results:

我们只对消息感兴趣，对Twitter可能返回的其他信息不感兴趣，因此检测消息中有没有text属性：

    if 'text' in tweet:

如果有，记录用户昵称、消息正文，并且将用户昵称和编号关联起来。代码如下：

        original_users.append(tweet['user']['screen_name'])
        user_ids[tweet['user']['screen_name']] =
          tweet['user']['id']
        tweets.append(tweet['text'])

运行上述代码将会得到大约100条消息，有时可能会少些。但是，这些消息并不是全都和编程语言有关。

7.1.1　用现有模型进行分类

正如上一章所说，不是所有提到python的消息都与编程语言有关。怎么才能找出所需要的消息？上一章用过的分类器就能派上用场了。分类器虽不完美，但使用它进行分类，能够过滤掉搜索结果中相当一部分噪音。

在本案例中，只对谈论Python编程语言的用户感兴趣。使用上一章创建的分类器找出与编程语言Python相关的消息。发表这类消息的人是我们真正感兴趣的用户。具体做法如下。

首先，需要保存朴素贝叶斯分类模型。打开上一章创建分类器的IPython笔记本文件。如果已经关闭，笔记本不记得之前的操作，需要运行所有的代码片段，方法是点击笔记本的Cell菜单，选择Run All。

运行完后，选择页面下方的空白格子。如果没有，选中最后一个格子，点击Insert菜单，选择Insert Cell Below选项。

我们将使用joblib库保存并加载模型。

　joblib包含在scikitlearn库中。

首先，导入joblib库，为模型指定输出文件名（确保目录存在，否则无法创建）。我把模型保存在我自己创建的Models目录下，你也可以将其保存到其他地方。代码如下：

from sklearn.externals import joblib
output_filename = os.path.join(os.path.expanduser("~"), "Models",
  "twitter", "python_context.pkl")

接着，用joblib库的dump函数，与json库的dump函数功能类似。参数为模型本身（怕你忘了，捎带提下，模型名称为model）和输出文件名。

joblib.dump(model, output_filename)

运行上述代码，模型将会保存到指定文件中。接着，回到上小节创建的笔记本文件，加载模型。

复制下面代码，在当前笔记本文件中再次指定模型的文件名：

model_filename = os.path.join(os.path.expanduser("~"), "Models",
  "twitter", "python_context.pkl")

检查下这里的文件名是否与保存模型所用的文件名相同。接着，需要重建NLTKBOW类，因为它是定制的类，无法直接用joblib加载。更好的处理方法后续章节会讲。现在，只需从上一章的代码中复制整个NLTKBOW类及它所依赖的模块：

from sklearn.base import TransformerMixin
from nltk import word_tokenize

class NLTKBOW(TransformerMixin):
    def fit(self, X, y=None):
        return self

    def transform(self, X):
        return [{word: True for word in word_tokenize(document)}
                 for document in X]

现在，只需调用joblib的load函数就能加载模型：

from sklearn.externals import joblib
context_classifier = joblib.load(model_filename)

context_classifier与第6章的model对象功能相同，是Pipeline对象的实例，它的三个步骤也与第6章流水线（NLTKBOW、DictVectorizer和BernoulliNB分类器）相同。

调用context_classifier模型的predict函数，预测消息是否与编程语言相关。代码如下：

y_pred = context_classifier.predict(tweets)

如果第i条消息与编程语言有关，那么y_pred中的第i项为1，否则为0。据此，可以获取到所有与编程语言有关的消息及用户：

relevant_tweets = [tweets[i] for i in range(len(tweets)) if y_pred[i]
  == 1]
relevant_users = [original_users[i] for i in range(len(tweets)) if
  y_pred[i] == 1]

用我采集的数据，共找到46名相关用户。比起之前的100条消息/用户显得有点少，但还是可以以此为基础来构建社交网络。

7.1.2　获取Twitter好友信息

接下来，需要获得以上每个用户的好友。这里好友的定义是：用户正在关注的人。Twitter提供了相应API：friends/ids，它好用的地方在于一次调用最多能获得高达5000个好友的编号，不好的地方是每15分钟最多只能调用15次，这也就意味着获取每个用户的好友列表至少需要一分钟——如果好友数量多于5000，需要更多时间（这种情况比想象中的更常见）。

然而，代码相对比较简单。接下来两节还要用到获取好友列表的功能，干脆把它封装成一个函数。首先，声明该函数，接收两个参数，一个是用于连接Twitter的对象，另一个是用户编号。函数返回由第二个参数指定的用户的所有好友，创建列表friends来存储好友们的编号。由于要用到时间模块time，顺便导入它。在给出完整的函数前，会逐一讲解每一部分代码。开始部分如下：

import time
def get_friends(t, user_id):
    friends = []

听起来有些奇怪，很多Twitter用户拥有5000个以上的好友。因此，要用到Twitter的pagination（分页）对象。Twitter使用游标管理多页数据。当向Twitter请求数据时，不仅返回所需数据，还返回数据类型为整数的游标，Twitter用游标跟踪每一次请求。如果没有更多内容，游标为0；否则，可以使用游标获得下一页数据。开始把游标设置为-1，表明是数据的开始：

    cursor = -1

接下来，只要游标不为0（当为0时，数据已采集完），就持续执行下面的循环。请求用户的好友数据，并将这些好友的编号添加到friends列表中。这里用到了try语句，请求过程中可能会遇到问题，使用try以便于处理异常。results字典中键为ids的那一项保存的是好友编号，将好友编号添加到friends列表中。然后，更新游标。下一次迭代时，游标使用刚更新过的值。最后，检查好友数量是否超过一万，如果超过，跳出循环。代码如下：

    while cursor != 0:
        try:
            results = t.friends.ids(user_id= user_id,
              cursor=cursor, count=5000)
            friends.extend([friend for friend in results['ids']])
            cursor = results['next_cursor']
            if len(friends) >= 10000:
                break

　这里有必要插入条警告信息。从互联网抓取数据，可能会时不时遇到这样那样奇怪的事情。我在编写上面这段代码时遇到的一个问题是有些用户好友异常多。为了应对这个问题，代码中增加了安全退出机制，用户好友再多，只要获取到他的一万名好友信息后就强制退出。如果想采集用户的所有好友，请删掉这两行，但是请注意碰巧遇到好友特别多的用户，会卡很长时间。

接着为可能捕获到的异常编写处理方法。最可能犯的错误是不小心达到了API访问次数的上限（虽然sleep语句可以在一定程度上避免这种情况，但在sleep语句结束前，中止代码执行后，接着在原本应该停顿的时间重新运行代码，就会出现这种情况）。这样的话，返回结果results为None，代码会抛出TypeError（类型错误）异常。遇到这种情况，等待五分钟，再次执行新一轮循环，希望Twitter能够重新计算时间，这样在接下来的15分钟就又能请求15次数据。除此之外，可能还有其他原因引发的TypeError异常。对于这种异常，需要单独处理，因此需要返回具体错误信息，方便查找错误起因。代码如下：

        except TypeError as e:
            if results is None:
                print("You probably reached your API limit,
                  waiting for 5 minutes")
                sys.stdout.flush()
                time.sleep(5*60) # 5 minute wait
            else:
                raise e

第二类异常可能发生在Twitter这一端，比如查找的用户不存在或是其他和数据相关的错误。这时，不要再尝试继续获取导致报错的用户的好友数据，返回已经得到的即可（很可能为0）。代码如下：

        except twitter.TwitterHTTPError as e:
            break

接着处理API限制。Twitter只允许每15分钟调用15次获取用户好友数据的函数，15次过后，需要等一分钟再继续进行。把这段代码放到finally语句中，遇到异常后，也会执行：

        finally:
            time.sleep(60)

函数最后返回所采集到的好友编号：

    return friends

下面是完整的函数：

import time
def get_friends(t, user_id):
    friends = []
    cursor = -1
    while cursor != 0:
        try:
            results = t.friends.ids(user_id= user_id,
              cursor=cursor, count=5000)
            friends.extend([friend for friend in
              results['ids']])
            cursor = results['next_cursor']
            if len(friends) >= 10000:
                break
        except TypeError as e:
            if results is None:
                print("You probably reached your API limit,
                  waiting for 5 minutes")
                sys.stdout.flush()
                time.sleep(5*60) # 5 minute wait
            else:
                raise e
        except twitter.TwitterHTTPError as e:
                break
               finally:
                   time.sleep(60)
    return friends

7.1.3　构建网络

现在着手构建网络。从最初的46名（你得到的数据集可能与我的有差异）用户出发，找到每个人的好友，将其保存到字典里（从user_id字典拿到用户编号）：

friends = {}
for screen_name in relevant_users:
    user_id = user_ids[screen_name]
    friends[user_id] = get_friends(t, user_id)

删除没有好友的孤家寡人。对于这些人，无法按照既定逻辑向他们推荐好友。也许可以从他们发表的消息以及关注他们的人那里发现有用线索。感兴趣的读者可自行研究，限于篇幅，本章不过多涉及。把这些人过滤掉。代码如下：

friends = {user_id:friends[user_id] for user_id in friends
             if len(friends[user_id]) > 0}

这样能剩下30到50个用户，具体数量与你之前的搜索结果有关。这些不大够用，得想办法再抓取些数据，使总用户增加到150个。下面的代码运行起来要费点时间——由于Twitter API的限制，一分钟只能取到一个用户的好友数据。不用算也知道150个用户数据，需要150分钟，也就是2.5个小时。既然要花这么多时间来采集用户的好友数据，得保证这些用户是想要的才行。

那么，什么用户才是想要的呢？考虑到要根据共同的好友向用户推荐他们可能感兴趣的人，因此就查找有共同好友的人。找到现有用户的好友，从他们中间找出在现有用户中间有更多好友的人。因此，需要统计这些用户的好友数量，即出现在已有用户的friends列表中的次数。从事数据挖掘任务时，确定采样策略前，考虑好应用的实际目标很有必要。这里向相似的用户推荐好友更可行。

具体做法为遍历所有用户的好友列表，统计每个好友的出现次数：

from collections import defaultdict
def count_friends(friends):
    friend_count = defaultdict(int)
    for friend_list in friends.values():
        for friend in friend_list:
            friend_count[friend] += 1
    return friend_count

计算完成后，根据好友数量对friend_count字典进行排序，找到在当前用户中，关系网最大、最密集的人。代码如下：

friend_count = count_friends(friends)
from operator import itemgetter
best_friends = sorted(friend_count.items(), key=itemgetter(1), reverse=True)

建立一循环，凑够150个用户的好友数据后，该循环就会结束。遍历best_friends字典（按照在现有用户中的好友数多少排序），找到还没有获取好友列表的用户，然后获取他的好友列表，更新friends列表。最后，再次查找谁是最受欢迎的1：

1friend_count字典更新后，刚刚找到的好友信息已经被包括进来了。——译者注

while len(friends) < 150:
    for user_id, count in best_friends:
        if user_id not in friends:
            break
        friends[user_id] = get_friends(t, user_id)
    for friend in friends[user_id]:
        friend_count[friend] += 1
    best_friends = sorted(friend_count.items(),
      key=itemgetter(1), reverse=True)

上述代码运行结束后，就可以得到150名用户的好友数据。

　你可能想把最大循环数设置得小一点，比如40或50（甚至可以先跳过这部分代码）。先敲完本章剩余代码，感觉下效果如何。再把循环数设置为150，等待几个小时，再来看一下结果。

鉴于上述采集数据的程序大约要运行两个小时，最好把中间结果保存下来，因为有时不得不关闭计算机。使用json库，就可以轻松把好友字典保存到文件里：

import json
friends_filename = os.path.join(data_folder, "python_friends.json")
with open(friends_filename, 'w') as outf:
    json.dump(friends, outf)

使用json.load函数，从文件中加载数据：

with open(friends_filename) as inf:
    friends = json.load(inf)

7.1.4　创建图

到现在为止，已经拿到了用户列表和他们的好友列表,很多用户是其他用户的好友。用这些存在好友关系的数据就能构建一张图（虽然这里好友关系不必是双向的）。

图由一组顶点和边组成。顶点通常表示对象——在这个例子中，顶点代表用户。边表示用户A是用户B的好友。由于顶点的顺序是有特殊含义的，该图称为有向图，因为用户A是用户B的好友并不代表用户B是用户A的好友2。可以用NetworkX库实现图关系的可视化。

2这里的“好友”译成“关注的人”更好理解，表示一种单向关系。其实在现实生活中，也存在这种“单相思”的情况，你把他人当成是最好的朋友，人家未必如此。——译者注

　可以用pip安装NetworkX库：pip3 install networkx。

首先，使用NetworkX创建有向图。根据习惯，导入NetworkX后给它一个简称nx（虽然不是必须这么做）。代码如下：

import networkx as nx
G = nx.DiGraph()

我们只将150名核心用户彼此间的好友关系绘制成图像3，其他好友关系由于数据量很大难以可视化（成千上万个，作图有难度）。把核心用户作为顶点，添加到图中。代码如下：

3注意这里“图像”和“图”的区别，前者指的是绘画、可视化意义上点、线条等形状的组合。后者是一种抽象的数据结构。文中“作图”表示绘制图像。——译者注

main_users = friends.keys()
G.add_nodes_from(main_users)

接着要创建边。如果第二个用户是第一个用户的好友，那么就在这两个顶点之间建立一条边。遍历所有的核心用户：

for user_id in friends:
    for friend in friends[user_id]:

确保好友在核心用户之列（当下先不关注非核心用户），然后为两者之间添加边。代码如下：

        if friend in main_users:
          G.add_edge(user_id, friend)

用NetworkX的draw函数为创建好的图绘制图像，draw函数要用到matplotlib库。在当前笔记本文件中作图，需要使用inline函数。代码如下：

%matplotlib inline
nx.draw(G)

生成的图像理解起来有点困难；有几个顶点之间边较少，但大多数顶点之间边较多。

可以借助用于处理图像生成的pyplot函数设置图像大小。导入pyplot，把图像尺寸设置得大一点，调用NetworkX的draw函数（NetworkX使用pyplot函数作图）：

from matplotlib import pyplot as plt
plt.figure(3,figsize=(20,20))
nx.draw(G, alpha=0.1, edge_color='b')

生成的图像太大一页放不下，但是大图像的好处是：图的轮廓看得更清楚。在我创建的图中，一部分用户互为好友，但是其他绝大部分用户之间不存在好友关系。我将上述代码中的alpha设置得很小，这样边的颜色就为蓝色，将上图放大后从中间位置截取了下面这张图。

如你所见，中间位置各顶点之间连接有多紧密！

这与我们选择新用户的方法有关——选择那些已经在图中有着丰富连接的用户，因此他们可能只会把这个圈子扩展得更大些。一般而言，一个社交网络用户所拥有的连接数遵循幂定律。只有少数用户拥有很多连接数，大部分用户只有很少的连接数，描述这种关系的函数图像呈现出长尾形状。这里的数据集不遵从该幂定律，因为后来的核心用户产生于已有核心用户的共同好友。

7.1.5　创建用户相似度图

本章的任务是向拥有共同好友的用户推荐彼此。再次重申，逻辑是：如果两个用户有共同的好友，那么这两个用户高度相似。那么，可以向他们推荐彼此。

在现有图（边表示关注关系）基础上创建一个新图。新图中，顶点仍然是用户，边要升级为带权重的。带权重的边指的是边有了权重属性。权重越大表明两顶点相似度越高。但是，权重与应用场景有关。如果权重代表距离，那么权重越低表示相似度越高。

对于这个应用，边的权重表示由该条边所连接的两个用户之间的相似度（以共同好友数量为基础）。新图中的边没有方向性，因为A和B之间的相似度等于B和A之间的相似度。

计算两个列表之间的相似度有多种方法。就拿我们这里的两个用户来说，可以简单统计他们好友列表中共同好友数量。然而，这样做有个问题：好友更多的用户，共同好友数量通常也更多。于是，可以再除以他们拥有的不同好友的数量实现数据的规范化，得到的正是杰卡德相似系数（Jaccard Similarity）。

杰卡德相似系数总是在0到1之间，代表两者重合的比例。正如第2章讲到的，规范化是数据挖掘的一个重要方法，要坚持使用（除非有充分理由不这样做）。

在计算杰卡德相似系数时，需要用两个集合（好友数据）交集的元素数量除以两个集合并集的元素数量。这里要用到集合操作，但是好友数据存储在friends列表中，所以首先要把列表转换为集合。代码如下：

friends = {user: set(friends[user]) for user in friends}

创建一函数4，计算两个好友集合之间的相似度：

4注意函数体return语句需要缩进。——译者注

def compute_similarity(friends1, friends2):
return len(friends1 & friends2) / len(friends1 | friends2)

现在就可以创建加权图。本章后面会多次用到创建图的功能，因此将其封装成函数，留意下threshold参数：

def create_graph(followers, threshold=0):
    G = nx.Graph()

使用嵌套循环遍历用户数据，跳过两层循环中用户相同的情况：

    for user1 in friends.keys():
        for user2 in friends.keys():
            if user1 == user2:
                continue

计算两个用户之间边的权重：

                weight = compute_similarity(friends[user1],
    friends[user2])

只有边的权重大于阈值，才会保存这条边，以确保只保存我们认为重要的边——例如，权重为0的边就没意义。阈值默认为0，因此会添加所有的边。本章稍后再指定一个合理的阈值，先这么用着吧。代码如下：

            if weight >= threshold:

如果权重大于等于阈值，把两个用户添加到图中（如果已经存在，不要重复添加）：

                G.add_node(user1)
                G.add_node(user2)

然后为这两个用户之间添加边，把权重设置为刚计算出来的相似度：

                G.add_edge(user1, user2, weight=weight)

循环结束后，图创建完毕，返回该图：

    return G

调用上述函数就能生成一个图。由于没有设置阈值，即使权重为0的边，也被创建出来。代码如下：

G = create_graph(friends)

我们创建的图各顶点之间连接得很紧密——所有顶点之间都有边，虽然很多边的权重为0。在作图时，可以根据边的权重来指定所用线条的粗细——粗线表示权重大。

由于顶点数量较多，作图时考虑适当增加图像尺寸，顶点之间的边看上去会更加清楚：

plt.figure(figsize=(10,10))

在绘制带权重的边之前，需要先画出顶点。NetworkX根据特定标准用布局信息来决定顶点和边的位置。绘制网络图很困难，特别是顶点数量较多时，虽然有很多现成的布局方式，但是否好用很大程度上取决于你的数据集、个人喜好及作图目的。我发现spring_layout非常好用，除此之外，还有circular_layout（如果其他方式不好用，还可以试试这个）、random_layout、shell_layout和spectral_layout。

　访问http://networkx.lanl.gov/reference/drawing.html，了解更多NetworkX布局方法。虽然用draw_graphviz选项使复杂程度有所增加，但效果很好，如果追求更好的视觉效果，值得研究一下，可考虑将其用到实际工作中。

使用spring_layout布局方法：

pos = nx.spring_layout(G)

使用pos布局方法，确定顶点位置：

nx.draw_networkx_nodes(G, pos)

接下来，绘制边。遍历图中的每条边，获得其权重：

edgewidth = [ d['weight'] for (u,v,d) in G.edges(data=True)]

绘制各条边：

nx.draw_networkx_edges(G, pos, width=edgewidth)

最终图像具体长什么样取决于你的数据，但大体形状应该与我的一致：图中大量顶点之间有较多连接，少数顶点与网络其他顶点之间连接较少。

与本章前一个图的区别在于：该图顶点之间的边表示的是相似度而不再是好友关系（虽然这两者有相似之处）。可以从当前这个图中抽取信息用于好友推荐。

7.2　寻找子图

利用相似度函数求出每个用户与其他用户之间的相似度，根据相似度进行排序，找出与当前用户最相似的，把他推荐给当前用户——与推荐产品时的做法相同。然而，我们也可以找出批量相似度很大的用户。可以建议将这些用户组成一个群，向他们定向投放广告，或者即使只是向他们推荐群内的其他成员做好友。

找出这些相似用户群的任务叫作聚类分析。它的复杂程度一般分类算法比不了，可以说具有一定难度。例如，评价分类结果相对比较容易——比较使用分类器得到的结果与实际结果（训练集）就能知道正确率。但是聚类分析缺乏这样一个事实标准，评价结果时，只好根据经验来看分簇结果是否合乎情理。聚类分析另一个复杂之处在于，不能用事先标注好的数据进行训练——只好在使用聚类数学模型的基础上，求得近似的分组结果，而不是按照用户所期望的那样将数据分为一个个明确的类别。

7.2.1　连通分支

一种最简单的聚类方法就是找到图中的连通分支。一个连通分支是图中由边连接在一起的一组顶点，不要求顶点之间必须两两连接。但是，连通分支的任意两个顶点之间，至少存在一条路径。

　连通分支计算时不考虑边的权重；只检查边是否存在。因此，下面代码将删除权重过低的边。5

5因为不论权重高低，只要有边连接的顶点就会被算到连通分支里，而一个连通分支被看作是一簇具有相似特点的用户，为了保证簇内用户具有较高的相似度，需要事先把权重低的边过滤掉。——译者注

NetworkX提供用于计算连通分支的函数，在图上调用即可。首先，用前面定义的create_graph函数创建一个新图，但这次指定阈值为0.1，只保留权重至少为0.1的边。

G = create_graph(friends, 0.1)

接着使用NetworkX提供的函数寻找图中的连通分支：

sub_graphs = nx.connected_component_subgraphs(G)

为了了解连通分支到底有多大，遍历找到的连通分支，输出其基本信息：

for i, sub_graph in enumerate(sub_graphs):
    n_nodes = len(sub_graph.nodes())
    print("Subgraph {0} has {1} nodes".format(i, n_nodes))

从输出结果，就能了解到每个连通分支有多大。在本例中，大的子图有62个用户，很多小子图只有十几个甚至更少的用户。

可以通过调整阈值，找出不同的连通分支。这是因为阈值越高，顶点之间的边越少，因此连通分支规模更小，数量更多。设置更高的阈值，看看效果：

G = create_graph(friends, 0.25)
sub_graphs = nx.connected_component_subgraphs(G)
for i, sub_graph in enumerate(sub_graphs):
    n_nodes = len(sub_graph.nodes())
    print("Subgraph {0} has {1} nodes".format(i, n_nodes))

上述代码找到的连通分支数量多，每个连通分支所包含的顶点数量少。之前那个最大的子图至少被拆为三个部分，每部分都不超过10个用户。下图为其中一个连通分支，边也画出来了。因为这是一个连通分支，所以它里面的顶点跟大图中其他顶点之间不存在边（阈值至少为0.25）。

可以用不同的颜色把所有连通分支都画出来。因为各连通分支之间没有连接，因此没必要把它们画到一张图中。顶点和连通分支没有确定位置，把它们画到一张图上，反而会令人产生误解，以为它们之间的位置关系是确定的。鉴于此，我们可以把它们画到不同的图上。

在新单元格中，获得连通分支及它们的总数量。

sub_graphs = nx.connected_component_subgraphs(G)
n_subgraphs = nx.number_connected_components(G)

　上述代码中，sub_graphs是生成器而不是连通分支列表。因此，需要用nx.number_connected_components找出连通分支的总数；由于NetworkX存储信息的方式比较特别，无法使用len函数。这正是重新计算连通分支的原因所在。

要显示所有的连通分支，图像得足够大。在用pyplot绘制图像时，让图像大小随着连通分支数量的增加而增加：

fig = plt.figure(figsize=(20, (n_subgraphs * 3)))

接下来，遍历所有的连通分支，为每一个连通分支作图。add_subplot的几个参数分别为图的行数、图的列数及图所在位置。我作图时使用了三列，你可以尝试其他值（记得同时修改行数和列数）：

for i, sub_graph in enumerate(sub_graphs):
    ax = fig.add_subplot(int(n_subgraphs / 3), 3, i)

pyplot默认显示坐标轴标签，在这里没有实际意义。因此把这个功能关掉：

    ax.get_xaxis().set_visible(False)
    ax.get_yaxis().set_visible(False)

然后再绘制顶点和边（用ax参数绘制相应的子图）。绘图之前需要设置好布局：

    pos = nx.spring_layout(G)
    nx.draw_networkx_nodes(G, pos, sub_graph.nodes(), ax=ax,
      node_size=500)
    nx.draw_networkx_edges(G, pos, sub_graph.edges(), ax=ax)

从图像中就能了解到各连通分支顶点数量及连接方式。

7.2.2　优化参数选取准则

连通分支查找算法依赖于阈值参数，由其决定是否在图中添加边。换句话说，阈值控制着找到的连通分支数量和连通分支的大小。既然阈值如此关键，阈值多大才是最合适的？这个问题主观性很强，没有明确的答案。它是任何聚类分析任务都要面对的一个主要问题。

然而，我们可以事先确定什么样的结果才是令人满意的，再根据理想中的结果来寻找选取参数的准则。作为一般性规则，它应该能够使得：

同一簇（连通分支）内的个体尽可能相似

不同簇内的个体尽可能不相似

轮廓系数（Silhouette Coefficient）就是一种对上述两点的量化方法。给定一个个体，其轮廓系数定义如下：

其中a为簇内距离，表示与簇内其他个体之间的平均距离。b为簇间距离，也就是与最近簇内各个个体之间的平均距离。

总轮廓系数为每个个体轮廓系数的均值。总轮廓系数接近最大值1时，表示每个簇内的个体相似度很高，不同簇之间距离较远。总轮廓系数接近0时，表示所有的簇重合在一起，各簇之间距离很小。总轮廓系数接近最小值-1时，表示个体出现在错误的簇内，把它们分到其他簇效果可能会更好。

利用这种选取准则，找到一种解决方案（找到合适的阈值），通过调整阈值，使总轮廓系数达到最大值。为此，创建一函数，接收阈值，计算总轮廓系数。

然后，把它传给SciPy的optimize模块的minimize函数，该函数通过调整参数值，找到函数的最小值。但我们想最大化总轮廓系数，可SciPy没有提供寻找最大值的函数。因此，对总轮廓系数取反后再找最小值（与直接求最大值是一回事）。

scikit-learn库提供计算轮廓系数的函数sklearn.metrics.silhouette_score，但是它与SciPy的minimize函数所要求的形式不一致。minimize函数要求变量参数（阈值）在其他参数的前面。这就需要把friends字典传进去计算图。函数声明如下：

def compute_silhouette(threshold, friends):

使用阈值参数创建图后，检查它是否至少有两个顶点：

    G = create_graph(friends, threshold=threshold)
    if len(G.nodes()) < 2:

轮廓系数的定义要求至少有两个顶点（才能计算距离）。如果少于两个顶点，认为这个问题无效，没法计算下去。有几种处理方法，最简单的是返回一个很小的值。轮廓系数最小值为-1，返回-99，表明问题无效。任何有效的值都比这个值大很多。代码如下：

        return -99

然后抽取连通分支：

    sub_graphs = nx.connected_component_subgraphs(G)

轮廓系数的定义还要求至少有两个连通分支（才能计算不同簇之间的距离），并且至少其中一个连通分支有两个顶点（计算簇内距离）。检测这些条件是否满足，如果不满足，返回无效值。代码如下：

    if not (2 <= nx.number_connected_components() < len(G.nodes())
      - 1):
        return -99

接着，需要获取标识着顶点被分到哪个连通分支的标签。遍历所有的连通分支，用字典保存顶点及其所属的连通分支。代码如下：

label_dict = {}
for i, sub_graph in enumerate(sub_graphs):
    for node in sub_graph.nodes():
        label_dict[node] = i

遍历图中所有顶点，依次获取到每个顶点的标签。需要分两步来做，先按一定顺序取到图，再遍历。因为图中顶点没有明确的顺序，但是只要没有改动图，顶点会维持现有顺序。这就表明，只要没有改动图，在图上调用.nodes()方法，返回的顶点顺序总是一致的。代码如下：

labels = np.array([label_dict[node] for node in G.nodes()])

注意，轮廓系数函数接收的是距离矩阵，而不是图。因此，要想办法把图转换为矩阵，同样分两步来做。首先，使用NetworkX的to_scipy_sparse_matrix函数把图转换为矩阵形式：

X = nx.to_scipy_sparse_matrix(G).todense()

写作本书时，scikit-learn实现的轮廓系数函数不支持稀疏矩阵。因此，需要调用todense()函数。很显然，这是个馊主意——通常应该用稀疏矩阵，因为数据密集的情况很罕见。这个例子这么用没问题，因为数据集相对较小；而再大点的数据集就不要这么做了。

　建议你使用VMEASURE或调整互信息（Adjusted Mutual Information）评价稀疏数据集。scikit-learn实现了这两种方法，但是两者所用到的参数差别很大。

请注意，图中边的权重表示相似度而不是距离。对于距离而言，值越大，相似度越小。可以用可能的最大相似度（1）减去现有相似度，把相似度转化为距离：

    X = 1 - X

既然已经创建好距离矩阵和标签，现在就可以计算轮廓系数了。指定metric参数值为precomputed，否则X矩阵将被当作特征矩阵而不是距离矩阵（scikit-learn几乎到处都默认使用的特征矩阵）。函数最后返回计算得到的轮廓系数：

    return silhouette_score(X, labels, metric='precomputed')

　有两处用到取反操作。第一处是求距离时对相似度取反，这很有必要，因为scikitlearn计算轮廓系数的函数使用距离矩阵作为参数。第二处是对轮廓系数取反，这样才能用SciPy的optimize模块。

还有个小问题没有解决。上述函数返回的轮廓系数越大越好，而SciPy的optimize模块只定义了minimize函数，它是一个损失函数，值越小越好。需要对轮廓系数取反，定义一函数把打分函数转化为损失函数。

def inverted_silhouette(threshold, friends):
    return -compute_silhouette(threshold, friends)

上述函数为用原函数创建的一个新函数。新函数调用时，为原函数传入与之前相同的参数，只不过新函数在最后返回值时进行取反操作。

现在，就可以进行优化操作了。调用minimize函数，把用于取反操作的函数inverted_silhouette传进来：

result = minimize(inverted_silhouette, 0.1, args=(friends,))

参数说明如下。

inverted_silhouette：对我们要最小化的函数compute_silhouette进行取反操作，将其变为损失函数。

0.1：我们一开始猜测阈值为0.1时，函数取到最小值。

options={'maxiter':10}：只进行10轮迭代（增加迭代次数，效果可能更好，但运行时间也会相应增加）。

method='nelder-mead'：使用下山单纯形法（Nelder-Mead）优化方法（SciPy提供的优化方法）。

args=(friends,)：向被优化的函数传入friends字典参数。

　上述程序运行时间较长。创建图的函数不是很快，计算轮廓系数的函数也不快。减少maxiter的值，减少迭代次数，能缩短运行时间，但那样很可能找到的是次优阈值。

运行完上述函数，我得到的最优阈值为0.135，这时有10个连通分支。最小化函数返回的值是-0.192，然而不要忘记前面进行了取反操作，所以实际的轮廓系数是0.192。这个值为正数，表明各簇比起重合更像是分散的（好事）。可以运行其他模型，看下轮廓系数是否更大，各簇是否更分散。

可以把这个结果用到用户推荐上——如果用户属于一个连通分支，可以向他推荐该连通分支的其他用户。简要回顾下针对用户推荐的研究历程，先是用杰卡德相似系数寻找用户间的相似度，再用连通分支把用户分成不同的簇，然后使用最优化方法找到最好的模型。

然而，大量用户可能与其他用户没有很好的联系，因此需要用不同的算法找到他们所属的簇。

7.3　小结

本章探讨了社交网络图以及如何对其进行聚类分析。还探讨了如何加载和使用在第6章创建的分类模型。

用来自Twitter的数据，创建了好友关系图，根据用户的好友，检测用户之间的相似度。我们认为共同好友更多的用户更相似，考虑到好友数量可能差别很大，对其进行了规范化处理。根据用户的相似度进行推测，是一种常用的知识（比如年龄或谈论的主题）推断方法。本章用下面的逻辑向用户推荐好友——如果他们关注用户X，用户Y和X相似，那么他们可能喜欢用户Y。这种方法与前几章从交易数据中挖掘相似商品有异曲同工之妙。

我们的目标是推荐用户，使用聚类分析方法能够找到不同的用户簇，主要步骤有根据相似度创建加权图，从图中寻找连通分支。创建图时用到了NetworkX库。

用轮廓系数评价聚类效果，其原则是簇内距离最小和簇间距离最大。轮廓系数越大，聚类效果越好。在寻找最合适的阈值时，用到了SciPy的optimize模块。

本章还比较了几对意义相反的概念。对于两者之间的相似度这个概念，值越大，表明两者之间更相像。相反，对于距离而言，值越小，两者更相像。另外一对是损失函数和打分函数。对于损失函数，值越小，效果越好（也就是损失越少）。而对于打分函数，值越大，效果越好。

下一章将介绍怎样从另外一种数据类型——图像中抽取特征。接下来，将讨论如何用神经网络识别图像中的数字，编写程序实现自动化破解验证码。

第 8 章　用神经网络破解验证码

理解图像中的信息一直是数据挖掘领域的一个难题，直到最近几年才开始得到真正解决。图像检测和理解算法已相当成熟，几大厂商使用这些算法研制的监测系统已投入商用，用来处理实际问题。这些系统能够理解和识别视频画面中的人和物体。

从图像中抽取信息很难。图像包含大量原始数据，图像的标准编码单元——像素——提供的信息量很少。图像——特别是照片——可能存在一系列问题，比如模糊不清、离目标太近、光线很暗或太亮、比例失真、残缺、扭曲等，这会增加计算机系统抽取有用信息的难度。

本章介绍如何用神经网络识别图像中的字母，从而自动识别验证码。验证码的设计初衷是便于人类理解，而不易被计算机识破。验证码的英文名叫作CAPTCHA，它取自以下短语中几个单词的首字母“Completely Automated Public Turing test to tell Computers and Humans Apart”，意思是能够区别计算机和人类的全自动的公共图灵测试。很多网站都在注册、评论系统中使用验证码，以防止别人恶意注册虚假账号或发布垃圾评论。

本章主要介绍如下内容。

神经网络

创建验证码和字母数据集

用scikit-image库处理图像数据

神经网络库PyBrain

从图像中抽取基本特征

使用神经网络进行更大规模的分类任务

用后处理提升效果

8.1　人工神经网络

神经网络算法最初是根据人类大脑的工作机制设计的。然而，该领域近年所取得的进展主要得益于数学而不是生物学。神经网络由一系列相互连接的神经元组成。每个神经元都是一个简单的函数，接收一定输入，给出相应输出。

神经元可以使用任何标准函数来处理数据，比如线性函数，这些函数统称为激活函数（activation function）。一般来说，神经网络学习算法要能正常工作，激活函数应当是可导（derivable）和光滑的。常用的激活函数有逻辑斯谛函数，函数表达式如下（x 为神经元的输入，k、L 通常为1，这时函数达到最大值）。

下图为 x 取-6到6之间的函数图像。

两条红线的交点表示 x 为0时，函数值为0.5。

每个神经元接收几个输入，根据这几个输入1，计算输出。这样的一个个神经元连接在一起组成了神经网络，对数据挖掘应用来说，它非常强大。这些神经元紧密连接，密切配合，能够通过学习得到一个模型，使得神经网络成为机器学习领域最强大的概念之一。

1对带权重的输入加总。——译者注

神经网络简介

用于数据挖掘应用的神经网络，神经元按照层级进行排列。第一层，也就是输入层，接收来自数据集的输入。第一层中的每个神经元对输入进行计算，把得到的结果传给第二层的神经元。这种叫作前向神经网络。本章暂且把它简称为神经网络。此外，还有几种神经网络，适用于不同的应用。第11章会讲到另外一种类型的神经网络。

神经网络中，上一层的输出作为下一层的输入，直到到达最后一层：输出层。输出结果表示的是神经网络分类器给出的分类结果。输入层和输出层之间的所有层被称为隐含层，因为在这些层中，其数据表现方式，常人难以理解。大多数神经网络至少有三层，而如今大多数应用所使用的神经网络层次比这多得多。

我们优先考虑使用全连接层，即上一层中每个神经元的输出都输入到下一层的所有神经元。实际构建神经网络时，就会发现，训练过程中，很多权重都会被设置为0，有效地减少边的数量。比起其他连接模式，全连接神经网络更简单，计算起来更快捷。

神经元激活函数通常使用逻辑斯谛函数，每层神经元之间为全连接，创建和训练神经网络还需要用到其他几个参数。创建过程，指定神经网络的规模需要用到两个参数：神经网络共有多少层，隐含层每层有多少个神经元（输入层和输出层神经元数量通常由数据集来定）。

训练过程还会用到一个参数：神经元之间边的权重。一个神经元连接到另外一个神经元，两者之间的边具有一定的权重，在计算输出时，用边的权重乘以信号的大小（signal，第一个神经元的输出）。如果边的权重为0.8，神经元激活后，输出值为1，那么下一个神经元从前面这个神经元得到的输入就是0.8。如果第一个神经元没有激活，值为0，那么输出到第二个神经元的值就是0。

神经网络大小合适，且权重经过充分训练，它的分类效果才能精确。大小合适并不是越大越好，因为神经网络过大，训练时间会很长，更容易出现过拟合训练集的情况。

　通常，开始时使用随机选取的权重，训练过程中再逐步更新。

设置好第一个参数（网络的大小），再从训练集中训练得到边的权重参数后，就能构造分类器。然后，就可以用它进行分类，跟前几章用到的分类器算法很像。但是，首先需要准备训练集和测试集。

8.2　创建数据集

本章，我们将扮演骇客这个角色，我们想编写破解网站验证码的程序，这样我们就有能力在别人网站上发布恶意广告。需要注意的是，我们要破解的验证码，难度比不上现在网上常用的；还有就是发表垃圾广告不太道德。

我们只使用长度为4个字母的英文单词作为验证码，如下图所示。

我们的目标是编写程序还原图像中的单词，步骤如下。

(1) 把大图像分成只包含一个字母的4张小图像。

(2) 为每个字母分类。

(3) 把字母重新组合为单词。

(4) 用词典修正单词识别错误。

我们的验证码破解算法做出了以下几个假设。首先，验证码中的单词是一个完整的、有效的英文单词，其长度为4个字母（实际上，生成和破解验证码，我们都使用同一个词典）。其次，单词全部字母均为大写形式，不使用符号、数字或空格。为了增加难度，在生成图像时对单词使用不同的错切（shear）变化效果。

8.2.1　绘制验证码

接下来，我们编写创建验证码的函数，目标是绘制一张含有单词的图像，对单词使用错切变化效果。绘制图像要用到PIL库，错切变化需要使用scikit-image库。scikit-image库能够接收PIL库导出的numpy数组格式的图像数据，因此这两个工具可以结合使用。

PIL和scikitimage库都可以用pip安装：

pip install PIL
pip install scikit-image

首先，导入即将用到的库和模块。导入numpy，从PIL中导入Image等绘图函数。

import numpy as np
from PIL import Image, ImageDraw, ImageFont
from skimage import transform as tf

接着，创建用于生成验证码的基础函数。这个函数接收一个单词和错切值（通常在0到0.5之间），返回用numpy数组格式表示的图像。该函数还提供指定图像大小的参数，因为后面还会用它生成只包含单个字母的测试数据。代码如下：

def create_captcha(text, shear=0, size=(100, 24)):

我们使用字母L来生成一张黑白图像，为ImageDraw类初始化一个实例。这样，我们就可以用PIL绘图。代码如下：

    im = Image.new("L", size, "black")
    draw = ImageDraw.Draw(im)

指定验证码文字所使用的字体。这里要用到字体文件，下面代码中的文件名（Coval.otf）应该指向文件存放位置（我把它放到当前笔记本所在目录）。

    font = ImageFont.truetype(r"Coval.otf", 22)
    draw.text((2, 2), text, fill=1, font=font)

　你可以从开源字库（Open Font Library）下载所需字体文件Coval.otf：http://openfontlibrary.org/en/font/bretan。

把PIL图像转换为numpy数组，以便用scikit-image库为图像添加错切变化效果。scikit-image大部分计算都使用numpy数组格式。代码如下：

    image = np.array(im)

然后，应用错切变化效果，返回图像。

    affine_tf = tf.AffineTransform(shear=shear)
    image = tf.warp(image, affine_tf)
    return image / image.max()

上面最后一行代码对图像特征进行归一化处理，确保特征值落在0到1之间。归一化处理可在数据预处理、分类或其他阶段进行。

现在，我们可以轻松生成图像，使用pyplot绘制图像。首先设定魔术方法%matplotlib，指定在当前笔记本作图，导入pyplot。代码如下：

%matplotlib inline
from matplotlib import pyplot as plt

生成验证码图像并显示它。

image = create_captcha("GENE", shear=0.5)
plt.imshow(image, cmap='Greys')

生成的图像就是本节开头你见到的那张，效果还不错吧。

8.2.2　将图像切分为单个的字母

虽然我们的验证码是单词，但是我们不打算构造能够识别成千上万个单词的分类器，而是把大问题转化为更小的问题：识别字母。

验证码识别算法的下一步是分割单词，找到其中的字母。具体做法是，创建一个函数，寻找图像中连续的黑色像素，抽取它们作为新的小图像。这些小图像（或者至少应该）就是我们要找的字母。

首先，导入图像分割函数要用到的label、regionprops函数。

from skimage.measure import label, regionprops

图像分割函数接收图像，返回小图像列表，每张小图像为单词的一个字母，函数声明如下：

def segment_image(image):

我们要做的第一件事就是检测每个字母的位置，这就要用到scikit-image的label函数，它能找出图像中像素值相同且又连接在一起的像素块。这有点像第7章中的连通分支。

label函数的参数为图像数组，返回跟输入同型的数组。在返回的数组中，图像连接在一起的区域用不同的值来表示，在这些区域以外的像素用0来表示。代码如下：

    labeled_image = label(image > 0)

抽取每一张小图像，将它们保存到一个列表中。

    subimages = []

scikit-image库还提供抽取连续区域的函数：regionprops。遍历这些区域，分别对它们进行处理。

    for region in regionprops(labeled_image):

这样，通过region对象就能获取到当前区域的相关信息。我们这里要用到当前区域的起始和结束位置的坐标。

        start_x, start_y, end_x, end_y = region.bbox

用这两组坐标作为索引就能抽取到小图像（image对象为numpy数组，可以直接用索引值），然后，把它保存到subimages列表中。代码如下：

        subimages.append(image[start_x:end_x,start_y:end_y])

最后（循环外面），返回找到的小图像，每张（希望如此）小图像包含单词的一个字母区域。没有找到小图像的情况，直接把原图像作为子图返回。代码如下：

    if len(subimages) == 0:
        return [image,]
    return subimages

使用刚定义的这个函数，就能从前面生成的验证码中找到小图像。

subimages = segment_image(image)

还可以像下面这样查看每张小图像。

f, axes = plt.subplots(1, len(subimages), figsize=(10, 3))
for i in range(len(subimages)):
    axes[i].imshow(subimages[i], cmap="gray")

结果如下。

图像切割效果还不错，但是你可能注意到，每张小图像都多少带有相邻字母的一部分。

8.2.3　创建训练集

使用图像切割函数就能创建字母数据集，其中字母使用了不同的错切效果。然后，就可以训练神经网络分类器来识别图像中的字母。

首先，指定随机状态值，创建字母列表，指定错切值。这里几乎没有新内容，numpy的arange函数你可能没用过，它跟Python的range函数类似——只不过arange函数可以和numpy的数组一起用，步长可以使用浮点数。代码如下：

from sklearn.utils import check_random_state
random_state = check_random_state(14)
letters = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
shear_values = np.arange(0, 0.5, 0.05)

再来创建一个函数（用来生成一条训练数据），从我们提供的选项中随机选取字母和错切值。代码如下：

def generate_sample(random_state=None):
    random_state = check_random_state(random_state)
    letter = random_state.choice(letters)
    shear = random_state.choice(shear_values)

返回字母图像及表示图像中字母属于哪个类别的数值。字母A为类别0，B为类别1，C为类别2，以此类推。代码如下：

    return create_captcha(letter, shear=shear, size=(20, 20)),
      letters.index(letter)

在上述函数体的外面，调用该函数，生成一条训练数据，用pyplot显示图像。

image, target = generate_sample(random_state)
plt.imshow(image, cmap="Greys")
print("The target for this image is: {0}".format(target))

调用几千次该函数，就能生成足够的训练数据。把这些数据传入到numpy的数组里，因为数组操作起来比列表更容易。代码如下：

dataset, targets = zip(*(generate_sample(random_state) for i in
range(3000)))
dataset = np.array(dataset, dtype='float')
targets =  np.array(targets)

我们共有26个类别，每个类别（字母）用从0到25之间的一个整数表示。神经网络一般不支持一个神经元输出多个值，但是多个神经元就能有多个输出，每个输出值在0到1之间。因此，我们对类别使用一位有效码编码方法，这样，每条数据就能得到26个输出。如果结果像某字母，使用近似于1的值；如果不像，就用近似于0的值。代码如下：

from sklearn.preprocessing import OneHotEncoder
onehot = OneHotEncoder()
y = onehot.fit_transform(targets.reshape(targets.shape[0],1))

我们用的库不支持稀疏矩阵，因此，需要将稀疏矩阵转换为密集矩阵。代码如下：

y = y.todense()

8.2.4　根据抽取方法调整训练数据集

得到的数据集跟即将使用的方法有较大出入。数据集每条数据都是一个恰好为20像素见方的字母。我们所使用的方法是从单词中抽取字母，而这可能会挤压图像，使图像偏离中心或者引入其他问题。

理想情况下，训练分类器所使用的数据应该与分类器即将处理的数据尽可能相似。但实际中，经常有所不同，但是应尽量缩小两者之间的差别。

对于验证码识别实验，最理想的情况是，从实际的验证码中抽取字母，并对它们进行标注。为了节省时间，我们只在训练集上运行分割函数，返回分割后得到的字母图像 。

我们需要用到scikit-image库中的resize函数，因为我们得到的小图像并不总是20像素见方。代码如下：

from skimage.transform import resize

现在，就可以对每条数据运行segment_image函数，将得到的小图像调整为20像素见方。代码如下：

dataset = np.array([resize(segment_image(sample)[0], (20, 20)) for
sample in dataset])

最后，创建我们的数据集。dataset数组为三维的，因为它里面存储的是二维图像信息。由于分类器接收的是二维数组，因此，需要将最后两维扁平化。

X = dataset.reshape((dataset.shape[0], dataset.shape[1] * dataset.
shape[2]))

使用scikit-learn中的train_test_split函数，把数据集切分为训练集和测试集。代码如下：

from sklearn.cross_validation import train_test_split
X_train, X_test, y_train, y_test = \
    train_test_split(X, y, train_size=0.9)

8.3　训练和分类

接下来，我们就来构造神经网络分类器，接收图像，预测图像中的（单个）字母是什么。

我们将使用之前创建的单个字母训练集。数据集本身很简单。每张图像为20像素大小，每个像素用1（黑）或0（白）来表示。每张图像有400个特征，将把它们作为神经网络的输入。输出结果为26个0到1之间的值。值越大，表示图像中的字母为该值所对应的字母（输出的第一个值对应字母A，第二个对应字母B，以此类推）的可能性越大。

我们用PyBrain库来构建神经网络分类器。

　跟我们之前见到的所有库一样，PyBrain也可以用pip来安装：pip install pybrain。

PyBrain库使用自己的数据集格式，好在创建这种格式的训练集和测试集并不太难。代码如下：

from pybrain.datasets import SupervisedDataSet

首先，遍历我们的训练集，把每条数据添加到一个新的SupervisedDataSet实例中。代码如下：

training = SupervisedDataSet(X.shape[1], y.shape[1])
for i in range(X_train.shape[0]):
    training.addSample(X_train[i], y_train[i])

然后，遍历测试集，同样把每条数据添加到一个新的SupervisedDataSet实例中。代码如下：

testing = SupervisedDataSet(X.shape[1], y.shape[1])
for i in range(X_test.shape[0]):
    testing.addSample(X_test[i], y_test[i])

现在就可以创建神经网络了。我们将创建一个最基础的、具有三层结构的神经网络，它由输入层、输出层和一层隐含层组成。输入层和输出层的神经元数量是固定的。数据集有400个特征，那么第一层就需要有400个神经元，而26个可能的类别表明我们需要26个用于输出的神经元。

确定隐含层的神经元数量可能相当困难。如果神经元数量过多，神经网络呈现出稀疏的特点，这时要训练足够多的神经元恰当地表示数据就有困难，这往往会导致过拟合训练数据的问题。相反，如果神经元过少，每个对分类结果的贡献过大，再加上训练不充分，就很可能产生低拟合现象。我发现一开始用漏斗形状不错，即隐含层神经元数量介于输入和输出层之间。本章，隐含层用100个神经元，你可以尝试其他值，看看能不能取得更好的效果。

导入buildNetwork函数，指定维度，创建神经网络。第一个参数X.shape[1]为输入层神经元的数量，也就是特征数（数据集X的列数）。第二个参数指隐含层的神经元数量，这里设置为100。第三个参数为输出层神经元数量，由类别数组y的形状来确定。最后，除去输出层外，我们每层使用一个一直处于激活状态的偏置神经元（bias neuron，它与下一层神经元之间有边连接，边的权重经过训练得到）。代码如下：

from pybrain.tools.shortcuts import buildNetwork
net = buildNetwork(X.shape[1], 100, y.shape[1], bias=True)

现在，我们就可以训练神经网络，寻找合适的权重值。但是如何训练神经网络呢？

8.3.1　反向传播算法

反向传播算法（back propagation，backprop）的工作机制为对预测错误的神经元施以惩罚。从输出层开始，向上层层查找预测错误的神经元，微调这些神经元输入值的权重，以达到修复输出错误的目的。

神经元之所以给出错误的预测，原因在于它前面为其提供输入的神经元，更确切来说是由这两个神经元之间边的权重及输入值决定的。我们可以尝试对权重进行微调。每次调整的幅度取决于以下两个方面：神经元各边权重的误差函数的偏导数和一个叫作学习速率的参数（通常使用很小的值）。计算出函数误差的梯度，再乘以学习速率，用总权重减去得到的值。下面将给出例子。梯度的符号由误差决定，每次对权重的修正都是朝着给出正确的预测值努力。有时候，修正结果为局部最优（local optima），比起其他权重组合要好，但所得到的各权重还不是最优组合。

反向传播算法从输出层开始，层层向上回溯到输入层。到达输入层后，所有边的权重更新完毕。

PyBrain提供了backprop算法的一种实现，在神经网络上调用trainer类即可。代码如下：

from pybrain.supervised.trainers import BackpropTrainer
trainer = BackpropTrainer(net, training, learningrate=0.01,
weightdecay=0.01)

backprop算法在训练集上进行迭代，每次都对权重进行微调。当误差减小的幅度很小时，表明算法无法进一步缩小误差，继续训练已无意义，这时就可以停止算法运行。理论上，等误差不再缩小时，再停止运行。这个过程叫作算法收敛。但实际上我们不会等到误差停止缩小，因为这通常要花很长时间且效果有限。

此外，更简单的做法是，运行代码固定的步数（epoch）。每一步所包含的次数越多，所用时间也就越多，效果越好（每一步的提升效果呈下降趋势）。我们这里训练时，使用了20步，数量再多些，效果可能会有所改善（也许幅度很小）。代码如下：

trainer.trainEpochs(epochs=20)

运行前面的代码，这可能要花几分钟时间，长短取决于硬件，然后我们就能在测试集上进行预测。PyBrain提供了相应函数，只要在trainer实例上调用即可。

predictions = trainer.testOnClassData(dataset=testing)

得到预测值后，可以用scikit-learn计算F1值。

from sklearn.metrics import f1_score
print("F-score: {0:.2f}".format(f1_score(predictions,
                                         y_test.argmax(axis=1) )))

F1值为0.97，对于相对较小的模型来说，这个结果很了不起。别忘了我们的特征值只是简单的像素值，神经网络竟然知道怎么使用它们。

既然构建好了具有高精度的字母分类器，接下来看下怎么用它来识别单词，实现验证码破解功能。

8.3.2　预测单词

我们想分别识别每张小图像中的字母，然后把它们拼成单词，完成验证码识别。

我们来定义一个函数，接收验证码，用神经网络进行训练，返回单词预测结果。

def predict_captcha(captcha_image, neural_network):

使用前面定义的图像切割函数segment_image抽取小图像。

    subimages = segment_image(captcha_image)

最终的预测结果——单词，将由小图像中的字母组成。这些小图像根据位置进行排序，从而保证拼接后得到的单词中各字母处在正确的位置上。

    predicted_word = ""

遍历四张小图像。

    for subimage in subimages:

每张小图像不太可能正好是20像素见方。调整大小后，才能用神经网络处理。

        subimage = resize(subimage, (20, 20))

把小图像数据传入神经网络的输入层，激活神经网络。这些数据将在神经网络中进行传播，返回输出结果。神经网络在前面已经创建好了，现在只需激活它。代码如下：

        outputs = net.activate(subimage.flatten())

神经网络输出26个值，每个值都有索引号，分别对应letters列表中有着相同索引的字母，每个值的大小表示与对应字母的相似度。为了获得实际的预测值，我们取到最大值的索引，再通过letters列表找到对应的字母。例如，如果第五个值最大，那么预测结果就为字母E。代码如下：

        prediction = np.argmax(outputs)

把上面得到的字母添加到正在预测的单词中。

        predicted_word += letters[prediction]

循环结束后，我们就已经找到了单词的各个字母，将其拼接成单词，最后返回这个单词。

    return predicted_word

可以使用下面代码来做下测试。尝试不同的单词，看看可能会遇到什么错误，别忘了我们的神经网络只能处理大写字母。

word = "GENE"
captcha = create_captcha(word, shear=0.2)
print(predict_captcha(captcha, net))

我们可以把上面代码整合到一个函数里，便于进行多次尝试。这里沿用之前每个单词只包含四个字母的假设，降低预测任务的难度。删除prediction = prediction[:4]，试试看可能会出什么错误。代码如下：

def test_prediction(word, net, shear=0.2):
    captcha = create_captcha(word, shear=shear)
    prediction = predict_captcha(captcha, net)
    prediction = prediction[:4]
    return word == prediction, word, prediction

上述函数返回结果依次为预测结果是否正确、验证码中的单词和预测结果的前四个字符。

上面代码能正确识别单词GENE，但是其他单词会出错。正确率如何？我们借助NLTK模块创建单词数据集，只使用长度为4的单词。从NLTK中导入words，代码如下：

from nltk.corpus import words

words实际上为corpus（语料库）对象，调用它的words()方法才能取到里面的单词。下面代码还加了长度为4的过滤条件。代码如下：

valid_words = [word.upper() for word in words.words() if len(word) ==
4]

识别得到的所有长度为4的单词，分别统计识别正确和错误的数量。

num_correct = 0
num_incorrect = 0
for word in valid_words:
    correct, word, prediction = test_prediction(word, net,
                                                shear=0.2)
if correct:
        num_correct += 1
    else:
        num_incorrect += 1
print("Number correct is {0}".format(num_correct))
print("Number incorrect is {0}".format(num_incorrect))

正确识别2832个单词，错误识别2681个，正确率仅为51%。而字母识别率为97%，两者差距太大。到底怎么回事？

首先，来看下正确率本身。其余条件相同的情况下，我们有四个字母，每个字母的正确率为97%，四个字母都正确的话，正确率约为88%（约为0.974）。一个字母出错将导致整个单词识别错误。

其次，错切值对正确率有影响。这次创建数据集时，随机从0到0.5之间选取一个数作为错切值。先前测试时错切值为0.2。错切值为0时，正确率为75%；错切值取0.5时，正确率只有2.5%。错切值越大，正确率越低。

另外一个原因在于我们之前随机选取字母组成单词，而字母在单词中的分布不是随机的。例如，字母E显然就比Q等其他字母使用频率更高。使用频度较高，但却常常被识别错误的字母，也会导致错误率上升。

我们可以把经常识别错误的字母统计出来，用二维混淆矩阵来表示。每行和每列均为一个类别（字母）。

矩阵的每一项表示一个类别（行对应的类）被错误识别为另一个类别（列对应的类）的次数。例如，(4,2)这一项的值为6，表示字母D有6次被错误地识别为字母B。

from sklearn.metrics import confusion_matrix
cm = confusion_matrix(np.argmax(y_test, axis=1), predictions)

理想情况下，混淆矩阵应该只有处于对角线的项(i,i)值不为零，其余各项的值均为零，否则就是分类有误。

用pyplot做成图，哪些字母容易混淆就一目了然，代码如下：

plt.figure(figsize=(10, 10))
plt.imshow(cm)

下面代码中的tick_marks表示刻度，在坐标轴上标出每个字母，便于理解。

tick_marks = np.arange(len(letters))
plt.xticks(tick_marks, letters)
plt.yticks(tick_marks, letters)
plt.ylabel('Actual')
plt.xlabel('Predicted')
plt.show()

结果如下图所示。从图中，我们就能清楚地看到主要的错误在于几乎无一例外地把U识别为H！

我们的词表中17%的单词含有字母U，这些单词几乎都会被识别错误。U的出现频率要高于H（11%的单词），我们不禁想到了一个提升正确率的简单方法（可能不够健壮）：把所有预测结果为H的，都改为U。

下节将采用更为巧妙的方法，从词典中搜索跟预测结果相似的单词。

8.4　用词典提升正确率

我们刚刚是直接返回预测结果，其实返回之前可以先检查一下词典里是否包含该词条。如果单词在词典里，那么就返回预测结果，如果不在，我们找到和预测结果相似的单词，再把它作为更新过的预测结果返回。注意，该方法是基于所有验证码中的单词都是英语单词的假设，因此，对于随机选取字母组成的验证码来说不适用。其实这正是很多验证码不使用单词的原因之一。

还有一个问题要解决——如何找到最相近的单词？方法有很多。例如，比较词长。词长差不多的两个单词更相似。然而，通常认为，在相同位置有相同字母的两个单词更相似，这就要用到编辑距离。

8.4.1　寻找最相似的单词

列文斯坦编辑距离（Levenshtein edit distance）是一种通过比较两个短字符串，确定它们相似度的方法。它不太适合扩展，字符串很长时通常不用这种方法。编辑距离需要计算从一个单词变为另一个单词所需要的步骤数。以下操作都算一步。

(1) 在单词的任意位置插入一个新字母。

(2) 从单词中删除任意一个字母。

(3) 把一个字母替换为另外一个字母。

把一个单词转变为另外一个单词所需步数越少，这两个单词越相似，反之，差别越大。

NLTK实现了编辑距离算法，可以直接拿来用。导入后，指定两个单词，看下它们的编辑距离。

from nltk.metrics import edit_distance
steps = edit_distance("STEP", "STOP")
print("The number of steps needed is: {0}".format(steps))

尝试不同的单词，你会发现编辑距离返回的结果跟我们的直觉很相近。编辑距离在检查拼写、听写错误和名称匹配（比如Marc和Mark的拼写形式就极易被搞混）方面用处很大。

然而，我们这里没这么复杂，只需要比较两个单词同等位置上的字母是否一致即可，不涉及字母的移动。因此，我们自己来编写距离度量函数，也就是统计同等位置上字母不同的数量。代码如下：

def compute_distance(prediction, word):
    return len(prediction) - sum(prediction[i] == word[i] for i in
range(len(prediction)))

用词长（4）减去同等位置上相同的字母的数量，得到的值越小表示两个词相似度越高。

8.4.2　组装起来

我们现在就来测试改进的预测函数，代码跟之前相似。首先，定义预测函数，这次把单词列表也传进去。

from operator import itemgetter
def improved_prediction(word, net, dictionary, shear=0.2):
    captcha = create_captcha(word, shear=shear)
    prediction = predict_captcha(captcha, net)
    prediction = prediction[:4]

到目前为止，代码跟之前的预测函数一致，还是取到预测结果的前四个字母。然而，这次还要检测单词是否在词典里。如果在，把单词作为预测结果返回；如果不在，找到最相似的单词返回。代码如下：

    if prediction not in dictionary:

计算预测结果和词典中每个单词的距离，按照距离由小到大进行排序。代码如下：

        distances = sorted([(word, compute_distance(prediction, word))
                            for word in dictionary],
key=itemgetter(1))

找到最为匹配的单词——也就是距离最小的单词——随后把这个单词作为预测结果返回。

        best_word = distances[0]
        prediction = best_word[0]

函数返回结果跟之前相同：预测结果是否正确，验证码中的单词和预测结果。

    return word == prediction, word, prediction

测试代码中需要变动的地方用粗体表示。

num_correct = 0
num_incorrect = 0
for word in valid_words:
    correct, word, prediction = improved_prediction(word, net, valid_words, shear=0.2)
    if correct:
        num_correct += 1
    else:
        num_incorrect += 1
print("Number correct is {0}".format(num_correct))
print("Number incorrect is {0}".format(num_incorrect))

上述代码运行时间稍长（计算预测结果跟词典中每个单词之间的距离要耗费一定时间），最终正确识别3037个单词，错误识别2476个，正确率较之前有4个百分点的提升，达到55%。由于预测结果跟词典中很多单词的相似度一致，算法随机从一组最相似的单词中选择最佳的，所以算法效果提升很有限。例如，词典中第一个单词AANI（埃及神话中狗头猿猴），跟其他44个单词距离相同。选中的几率就只有1/44。

如果我们作弊，预测结果只要从最相似的一组词中随机挑一个就算是正确的话，那么预测结果正确率将高达78%（代码请见本书配套的代码包）。

要进一步提升正确率，得改变距离测量方法，看能不能利用之前得到的混淆矩阵，找到经常被混淆的字母或其他有用信息。逐步改进效果，是很多数据挖掘算法的一个特点，这跟科学实验方法是一脉相承的——产生想法，做实验，分析结果，再根据结果做进一步的改进。

8.5　小结

本章的任务是根据验证码图像的像素值识别里面的单词。当然，为了简化难度，验证码使用由四个字母组成的英语单词。网站上实际使用的验证码要比这复杂得多——也应该这样！但是，只要对上面所讲的算法进行改进，就能通过神经网络，使用跟这里类似的方法破解更为复杂的验证码。scikit-image库提供大量图像处理工具，比如从图像中抽取不同形状的小图像和改善图像对比度的方法等。这些方法有助于破解验证码。

我们把识别单词的难题转化为识别字母的小问题，创建前向神经网络识别图像中的字母，取得了97%的准确率。

神经网络由一组组神经元连接而成，神经元为基本的计算单元，每个神经元都只包含一个函数。然而，所有的神经单元连接在一起就能解决复杂程度很高的问题。神经网络是深度学习的基础，后者可是当今数据挖掘领域最受关注的方向之一。

尽管识别字母正确率很高，但是单词识别正确率陡然降至50%多点。原因有很多，其实这恰好说明把算法从实验环境搬到真实环境会遇到各种各样的困难。

使用词典，寻找最匹配的单词，能够提升正确率。我们考虑使用常用的编辑距离表示单词间的相似度；但是我们只关注字母错误而不深究与哪些编辑步骤（插入、删除）相关，因此简化了距离计算方法。最终正确率有所提升，但是还有很多要改进的地方。

下一章将继续研究字符串比较，尝试找出文档的主人（从多名可能的作者中）——只根据文档内容而不使用其他信息！

第 9 章　作者归属问题

文本挖掘任务作者分析（authorship analysis）的目标是只根据作品内容找出作者独有的特点，比如年龄、性别或背景。作者归属（authorship attribution）是作者分析的一个细分领域，研究目标是从一组可能的作者中找到文档真正的主人。这是一种典型的分类任务。作者分析任务一般采用标准的数据挖掘方法，比如交叉检验、特征抽取和分类算法等。

本章将把前几章学到的数据挖掘方法整合起来，解决作者归属问题，从而掌握数据挖掘整个过程。我们界定问题，讨论相关背景和知识，抽取特征，创建流水线实现分类。我们将比较两类特征的效果：功能词和N元语法。最后，深入分析结果。数据集会用到两种，一开始使用图书，然后增加难度，使用噪音较多的真实电子邮件语料。

本章主要介绍如下内容。

特征工程和如何根据应用选择特征

带着新问题，重新回顾词袋模型

特征类型和字符N元语法模型

支持向量机

数据集清洗

9.1　为作品找作者

作者分析可以从文体学（stylometry）中找到它的一席之地，文体学分析研究的是作者的写作风格，所依据的理念是每个人在语言掌握上有微小差异，这会反映到写作中，通过分析作品之间的细微差别，就可以把作者区分开来。

过去一直靠人工统计、分析来解决作者分析问题，而这些工作恰好是计算机所擅长的，这表明可以借助数据挖掘技术来实现自动化。如今作者分析的研究工作几乎都是用数据挖掘方法来解决，但仍有不少研究偏重于人工分析作品语言风格。

作者分析问题衍生出很多更细的问题，主要有以下几个。

作者画像：根据作品界定作者的年龄、性别或其他特性。例如，通过观察一个人讲英语的方式，判断英语是否为他的母语。

作者验证：根据作者已有作品，推断其他作品是否也是他写的。拿法庭断案场景来讲更好理解，例如，分析嫌犯的写作风格（内容方面），以确定勒索信是不是他写的。

作者聚类：作者验证问题的延伸，用聚类分析方法把作品按照作者进行分类。

然而，作者分析研究领域最常见的还是作者归属问题，即如何从一群作者中找到作品的真正主人。

9.1.1　相关应用和使用场景

作者分析有很多应用场景，比如证实作者是谁，证实几本书有相同的作者，或者寻找社会媒体账号的主人。

从历史意义上来说，作者分析可用来核实某些文档是否真由公认的作者所写。有些作品的作者就存在争议，比如莎士比亚的几部戏剧，美国建国时期的联邦党人文集等。

单凭作者分析无法确定作者到底是谁，但是能够提供支持或反驳某一观点的依据。例如，在检验一首十四行诗是否是莎士比亚所写前，我们可以先分析他的若干部剧作，弄清楚他的写作风格。

作者分析问题最近几年也被用来确定社会媒体账号到底是谁在使用。例如，一个恶意用户可能在不同社交平台创建多个账号。把它们关联起来后，便于监管部门跟踪幕后的黑手——例如，当扰乱其他用户时。

举个陈旧点的例子，法庭上视作者分析为核心技术，主要靠它来提供文档是否出自嫌犯之手的证据。例如，嫌疑犯可能因为发骚扰邮件而被起诉。作者分析就能确定邮件是否是嫌犯所写。另一个在法庭中的使用场景是，解决作品归属纠纷。比如，遇到两个人就一本书到底是谁写的而纠缠不已的情况，法庭就可以借助作者分析技术找到作者可能是谁的证据。

作者分析也并不是绝对可靠的。最近研究发现，即使没有经过专业训练的人，让他们隐藏自己的写作风格，也会让作者归属问题变得困难无比。研究人员还探讨了模仿别人写作风格的问题，结果发现人们模仿得很像，作者分析的结果是这些伪作出自被模仿人之手。

尽管存在很多问题，作者分析在越来越多的领域被证实十分有用，它是一个非常有意思的、值得研究的数据挖掘问题。

9.1.2　作者归属

作者归属可以看作是一种分类问题，已知一部分作者，数据集为多个作者的作品（训练集），目标是确定一组作者不详的作品（测试集）是谁写的。如果作者恰好是已知的作者里面的，这种问题叫作封闭问题。

如果作者可能不在里面，这种问题就叫作开放问题。不只是作者归属问题可以这样区分——任何数据挖掘应用，只要实际的类别可能不在训练集中的都叫作开放问题，对于这类问题进行挖掘，最终目标分两种情况，如果在训练集中，就返回找到的类别，如果不在，要给出不属于任何现有类别的提示。

在进行作者归属研究时，一般会受到两个限制。一是只能使用作品的内容，而不能使用写作时间、印刷形式、笔迹等信息。当然也有这样的做法，把这些信息整合到模型中，但一般来说这就不是作者归属而更像是数据融合问题。

第二个限制是不考虑作品的主题；相反，关注单词用法、标点和其他文本特征。原因在于，一个人如果是多面手，可以写多个主题的内容，作品的主题就不能反映作者的实际写作风格。关注主题关键字可能会导致过度拟合训练集——模型可能只是用同一个作者的同一个主题的作品进行训练。例如，根据我写的这本书为我的写作风格建模，可能会得出“数据挖掘”这个词能代表我的风格这样的结论，而事实上，我还写一些其他主题的内容。

接下来，用于作者归属问题的流水线跟第6章所用的很相似。首先，从文本中抽取特征；然后，对抽到的特征做进一步甄选；最后，训练算法分类器，预测文档的类别（作者）。

当然有些步骤跟第6章还是存在一些差别，主要集中在特征选取方面，后面会详细讲。我们还是先来划定待解决问题的范围。

9.1.3　获取数据

本章所用的图书数据集来自于古腾堡计划网站（www.gutenberg.org），该网站收集了大量版权失效的公版文学作品。实验用到以下作家的若干作品。

塔金顿（Booth Tarkington，22篇）

狄更斯（Charles Dickens，44篇）

伊迪斯·内斯比特（Edith Nesbit，10篇）

阿瑟·柯南·道尔（Arthur Conan Doyle，51篇）

马克·吐温（Mark Twain，29篇）

理查德·弗朗西斯·伯顿爵士（Sir Richard Francis Burton，11篇）

埃米尔·加博里奥（Emile Gaboriau，10篇）

以上总共是7位作家的177篇作品，文本数量还是相当可观的。作品名称列表、下载链接以及自动下载图书的代码请见本书配套的代码包。

用requests库下载作品文件到数据文件夹所在的目录。首先，在Data目录下创建文件夹存放这些作品。注意，下面代码指定的文件目录跟你实际创建的文件夹目录保持一致。

import os
import sys
data_folder = os.path.join(os.path.expanduser("~"), "Data", "books")

接着，就可以使用代码包中我写好的代码，从古腾堡计划的网站下载图书。下载完后将其保存到我们上面创建的目录中。

从代码包Chapter 9文件夹中找到getdata.py，保存到笔记本文件所在目录下，在新格子中输入以下代码。

!load getdata.py

在笔记本中，按下Shift+Enter运行该格子的代码。代码将会加载到格子中。然后再次点击代码，按下Shift+Enter运行加载的代码。代码要运行一段时间，运行完毕后会提示你。

大体翻看一下下载的作品，你会发现大部分都包含很多噪音——从数据分析的角度看至少是这样的。每篇作品前都有大段的声明文字，开始数据分析之前，得先把它们删掉。

我们可以直接从文件里将这些免责声明删除，但是丢失数据怎么办？我们可能会丢失先前做过的改动，从而导致实验结果无法重现，因此，不如在加载文件时跳过这部分——这样再次用这些文件做实验时，也能得到同样的结果（只要原文件没变）。代码如下：

def clean_book(document):

每篇作品前后各有一行文字，标识作品的开头和结尾，作品前后为古腾堡项目的说明。既然我们能根据这两行找到作品内容，因此，就按行来切分。

    lines = document.split("\n")

遍历文档的每一行，寻找作品的开头和结尾，中间部分就是作品内容。代码如下：

    start = 0
    end = len(lines)
    for i in range(len(lines)):
        line = lines[i]
        if line.startswith("*** START OF THIS PROJECT GUTENBERG"):
            start = i + 1
        elif line.startswith("*** END OF THIS PROJECT GUTENBERG"):
            end = i - 1

函数最后，用换行符把所有行再连接起来，得到作品内容。

    return "\n".join(lines[start:end])

现在，我们可以创建一个函数，加载所有图书，进行上述预处理操作，返回书的内容以及作家序号1。先来导入numpy。

1用索引来表示，详见下文。——译者注

import numpy as np

声明加载图书的函数，参数为图书所在目录books，该目录下是一系列以作者名字命名的子文件夹，图书文件就在这些子文件夹中。代码如下：

def load_books_data(folder=data_folder):

创建两个列表，分别用来存储文档和作者。

    documents = []
    authors = []

获取到books目录下所有的子文件夹。代码如下：

    subfolders = [subfolder for subfolder in os.listdir(folder)
                  if os.path.isdir(os.path.join(folder,
                     subfolder))]

遍历这些子文件夹，使用enumerate函数为这些子文件夹指定索引。

    for author_number, subfolder in enumerate(subfolders):

获取到子文件夹的绝对路径，查找里面的所有图书文件。

        full_subfolder_path = os.path.join(folder, subfolder)
        for document_name in os.listdir(full_subfolder_path):

对于找到的每一个图书文件，打开后读取里面的内容，对内容进行预处理后，将其添加到documents列表中。代码如下：

            with open(os.path.join(full_subfolder_path,
              document_name)) as inf:
                documents.append(clean_book(inf.read()))

把分配给作家的索引号添加到authors列表中，其实authors就是类别列表。

                authors.append(author_number)

函数最后返回文档和类别（把类别列表转换为numpy数组）。

    return documents, np.array(authors, dtype='int')

调用上面定义的加载图书函数，就能获得图书文档及其它们的类别。

documents, classes = load_books_data(data_folder)

　把这些数据加载到内存没有问题，因此，我们可以立即加载。如果数据集太大，内存装不下，较好的方法是每次只从一篇（或几篇）文档中抽取特征，把这些特征保存到文件或位于内存的矩阵中。

9.2　功能词

功能词是作者分析问题最早使用的一类特征，到现在来看，在词袋模型中使用功能词做特征效果依然不错。功能词指的是本身具有很少含义，却是组成（英语）句子必不可少的成分。例如，单词this和which的意思不是由它们本身的含义所决定，而是由它们在句子中充当的角色来决定。与功能词相对的是实词，比如tiger就有明确的含义，当人们在句子中看到这个单词时，脑海中就会浮现出大型猫科动物老虎的样子。

有些功能词的用法并不总是清晰、明确的。因此，从经验来看，选用使用频率较高的功能词做特征比较好（在所有文档中使用频率高，而不仅是在单个作者的作品中）。通常而言，使用越频繁的单词，对于作者分析能提供更多有价值的信息。相反，使用频率较低的单词，更适合用来做基于内容的文本挖掘，例如下章要讲的文档主题划分。

功能词的使用通常不是由文档内容而是由作者的使用习惯所决定，因此可以用它们来区分不同的作者。例如，很多美国人比较在意区分that和which在句子中的用法，澳大利亚等国家的人就不太在意。在使用这两个词的地方，有些澳大利亚人要么几乎无一例外地都用that，要么是which用得多一点。这样的不同点，再加上成千上万个其他的微小差别，就形成了用于作者分析的模型。

9.2.1　统计功能词

我们可以使用第6章所用到的CountVectorizer统计功能词。我们把包含所有要查找的单词的词汇表（vocabulary）传递进去，如果没有传词汇表（第6章就没有），它会从数据集中学习。所有单词都在训练集中（取决于其他参数）。

首先，创建功能词词汇表，用列表存储。至于确切来说哪些是功能词，哪些不是，有待商榷。我从已发表的研究成果中找到下面这些功能词，它们还是比较可靠的。

function_words = ["a", "able", "aboard", "about", "above", "absent",
"according" , "accordingly", "across", "after", "against",
"ahead", "albeit", "all", "along", "alongside", "although",
"am", "amid", "amidst", "among", "amongst", "amount", "an",
"and", "another", "anti", "any", "anybody", "anyone",
"anything", "are", "around", "as", "aside", "astraddle",
"astride", "at", "away", "bar", "barring", "be", "because",
"been", "before", "behind", "being", "below", "beneath",
"beside", "besides", "better", "between", "beyond", "bit",
"both", "but", "by", "can", "certain", "circa", "close",
"concerning", "consequently", "considering", "could",
"couple", "dare", "deal", "despite", "down", "due", "during",
"each", "eight", "eighth", "either", "enough", "every",
"everybody", "everyone", "everything", "except", "excepting",
"excluding", "failing", "few", "fewer", "fifth", "first",
"five", "following", "for", "four", "fourth", "from", "front",
"given", "good", "great", "had", "half", "have", "he",
"heaps", "hence", "her", "hers", "herself", "him", "himself",
"his", "however", "i", "if", "in", "including", "inside",
"instead", "into", "is", "it", "its", "itself", "keeping",
"lack", "less", "like", "little", "loads", "lots", "majority",
"many", "masses", "may", "me", "might", "mine", "minority",
"minus", "more", "most", "much", "must", "my", "myself",
"near", "need", "neither", "nevertheless", "next", "nine",
"ninth", "no", "nobody", "none", "nor", "nothing",
"notwithstanding", "number", "numbers", "of", "off", "on",
"once", "one", "onto", "opposite", "or", "other", "ought",
"our", "ours", "ourselves", "out", "outside", "over", "part",
"past", "pending", "per", "pertaining", "place", "plenty",
"plethora", "plus", "quantities", "quantity", "quarter",
"regarding", "remainder", "respecting", "rest", "round",
"save", "saving", "second", "seven", "seventh", "several",
"shall", "she", "should", "similar", "since", "six", "sixth",
"so", "some", "somebody", "someone", "something", "spite",
"such", "ten", "tenth", "than", "thanks", "that", "the",
"their", "theirs", "them", "themselves", "then", "thence",
"therefore", "these", "they", "third", "this", "those",
"though", "three", "through", "throughout", "thru", "thus",
"till", "time", "to", "tons", "top", "toward", "towards",
"two", "under", "underneath", "unless", "unlike", "until",
"unto", "up", "upon", "us", "used", "various", "versus",
"via", "view", "wanting", "was", "we", "were", "what",
"whatever", "when", "whenever", "where", "whereas",
"wherever", "whether", "which", "whichever", "while",
"whilst", "who", "whoever", "whole", "whom", "whomever",
"whose", "will", "with", "within", "without", "would", "yet",
"you", "your", "yours", "yourself", "yourselves"]

既然有了功能词列表，我们就来创建功能词统计工具。后面，会把它加到流水线中。

from sklearn.feature_extraction.text import CountVectorizer
extractor = CountVectorizer(vocabulary=function_words)

9.2.2　用功能词进行分类

接下来，导入所需的几个类，唯一的新内容支持向量机在下节会讲（现在把它看作是标准的分类算法即可）。导入用支持向量机算法进行分类的SVC类，以及其他一些我们用过的标准工作流工具。

from sklearn.svm import SVC
from sklearn.cross_validation import cross_val_score
from sklearn.pipeline import Pipeline
from sklearn import grid_search

支持向量机接收一系列参数。现阶段照我设置的参数来就行，下节再深入探讨参数值选择。我们用字典结构来组织参数。参数kernel使用linear和rbf。C的值取1或10（参数说明请见下节）。接着用网络搜索法寻找最优参数值。

parameters = {'kernel':('linear', 'rbf'), 'C':[1, 10]}
svr = SVC()
grid = grid_search.GridSearchCV(svr, parameters)

　高斯内核（例如rbf）只适用于数据集相对较小的情况，比如特征数少于10 000。

接着，创建流水线，把特征抽取和参数搜索两个步骤加入到流水线中，特征（仅功能词）抽取使用CountVectorizer类，参数搜索使用SVM。代码如下：

pipeline1 = Pipeline([('feature_extraction', extractor),
                     ('clf', grid)
                     ])

然后，使用cross_val_score对该流水线的结果进行交叉检验，正确率为0.811，大约80%的预测结果正确。对于只有7个作者来说，这个结果很好！

9.3　支持向量机

支持向量机（SVM）分类算法背后的思想很简单，它是一种二类分类器（扩展后可用来对多个类别进行分类）。假如我们有两个类别的数据，而这两个类别恰好能被一条线分开，线上所有点为一类，线下所有点属于另一类。SVM要做的就是找到这条线，用它来做预测，跟线性回归原理很像。只是SVM要找出最佳的分割线。下图中有三条线，分别为蓝色、黑色和绿色线，都能把两类数据区分开来。你说哪条分类效果更好呢？

凭直觉，人们往往会选择从左下到右上的这条线2，它把两类数据更好地分开了。也就是说，数据集中的每个点到这条线的距离都是最远的。

2如果你查看原书PDF文件的话，这条线指的是图中的蓝线。PDF文件获取方法请见前言部分。建议读者下载查看。彩色图毕竟比黑白图形象。——译者注

找到这样的一条线其实是最优化问题，也就是要让各点到分割线之间的距离最大化。

　虽然最优化问题的公式推导不在本书讲述范围之列，我还是建议感兴趣的读者自行查看http://en.wikibooks.org/wiki/Support_Vector_Machines，了解详细步骤。此外，你还可以访问http://docs.opencv.org/doc/tutorials/ml/introduction_to_svm/introduction_to_svm.html，了解支持向量机相关内容。

9.3.1　用SVM分类

模型训练完毕，就能找到使得两类之间间隔最大的一条线。新数据点分类问题就简化为：数据点在线上还是线下？如果在线上，就被分到线上那一类；如果位于线下，自然属于线下那一类。

对于多种类别的分类问题，我们创建多个SVM分类器——每个还是二类分类器。连接多个分类器有不少方法，从中任选一种即可。最简单的方法是为每个类别创建一对多（one-versus-all）分类器，把训练数据分为两个类别——属于特定类别的数据和其他所有类别数据。对新数据进行分类时，从这些类别中找出最匹配的。大多数SVM多类分类器都能自动完成上述过程。

前面代码中有两个参数：C和kernel。kernel参数下节讲，C参数对于训练SVM来说很重要，我们现在来讲下。C参数与分类器正确分类比例相关，但可能带来过拟合的风险。C值越高，间隔越小，表示要尽可能把所有数据正确分类。C值越小，间隔越大——有些数据将无法正确分类。C值低，过拟合训练数据的可能性就低，但是分类效果可能会相对较差。

SVM（基础形式）局限性之一就是只能用来对线性可分的数据进行分类。如果线性不可分呢？这时我们就要用到内核函数。

9.3.2　内核

如果数据线性不可分，就需要将其置入更高维的空间中，加入更多伪特征直到数据线性可分（如果你加入足够多恰当的特征，总能把数据分开）。

寻找最佳分隔线时往往需要计算个体之间的内积3。对于使用点积（dot product）的函数，我们可以创建新特征而无需实际定义这些特征。因为我们不知道这些特征到底是什么样子，所以用点积很方便。我们把内核函数定义为数据集中两个个体函数的点积，而不是使用个体自身（及构造的特征）。

3inner product，也称作点积。——译者注

接下来就可以计算点积（或近似值），然后就能使用它。

常用的内核函数有几种。线性内核最简单，它无外乎两个个体的特征向量的点积、带权重的特征和偏置项。多项式内核提高点积的阶数（比如2）。此外，还有高斯内核（rbf）、Sigmoind内核。前面例子中，我们比较了线性内核和rbf内核的效果。

这些内核能够有效地确定两类数据之间的距离，SVM可以据此对新数据进行分类。理论上讲，任何距离度量方法都可以用，但是在训练SVM时，最优化问题的复杂程度会有所不同。

scikit-learn实现的SVM，可以通过kernel参数指定内核函数，正如我们在前面例子中见过的。

9.4　字符N元语法

我们前面研究的是如何用功能词做特征预测文档的作者。接下来看下字符N元语法特征。N元语法由一系列的N个为一组的对象组成。N为每组对象的个数（对于文本来说，N通常取2到6之间的值）。基于单词的N元语法被广泛用在通常与文档主题相关的各项研究中。然而，基于字符的N元语法被证明在作者归属问题上效果很好。

我们可以把文档看成是由一系列字符组成的，从里面抽取N元语法，训练得到模型。常用模型有几个，其中标准模型跟我们之前用到的词袋模型很相似。

分别为训练语料中各个不同的N元语法创建一个特征。例如，由字母e、空格和字母t组成的N元语法<e t>（尖括号标识N元语法的边界，不是N元语法的内容）。然后，我们使用N元语法在训练集中的频率训练模型，再用生成的特征矩阵训练分类器。

　基于字符的N元语法有几种不同的定义方法。比如，有些应用只选取单词内的字符，忽略空格和标点。但有些就使用这些信息（比如我们这一章）。

关于基于字符的N元语法为什么效果比较好，广为接受的理论是，人们在写作时，往往选取那些他们讲起来很容易的单词，而字符N元模型（至少N取 2到6之间时）跟这些音素具有很好的相似关系——音素指的是我们讲话时，组成单词发音的那些声音。从这种意义上讲，用字符N元模型可以很好地模拟讲话时经常用到的单词，而这些单词形成了写作风格。这是创建新特征的通用模式：先找到哪些概念影响最终结果（写作风格）的理论依据，然后创建跟这些概念相近或是能够量化这些概念的特征。

字符N元语法矩阵的一个主要特点是数据稀疏，随着N值的增加，稀疏程度逐渐加大，且速度很快。N取2时，我们的特征矩阵中大约75%的项都是0，N取5时，93%以上的项为0。比起基于词语的N元语法矩阵来说，稀疏程度要低一些，因此，适用于单词分类的分类器处理数据，不会有太大问题。

抽取字符N元语法

我们接下来用CountVectorizer类来抽取N元语法，需要设置analyzer参数，指定N的值。

scikit-learn的N元语法抽取工具提供了range参数，允许用户同时抽取不同长度的N元语法。我们这里用不到，只抽取相同长度即可，range参数的两个值使用相同值即可。要抽取长度为3的N元语法，range的值需要设置为(3, 3)。

我们可以重用前面网格搜索代码，但是需要在新流水线中指定新的特征抽取器。

pipeline = Pipeline([('feature_extraction', CountVectorizer(analyzer='
char', ngram_range=(3, 3))),
                     ('classifier', grid)
                     ])
scores = cross_val_score(pipeline, documents, classes, scoring='f1')
print("Score: {:.3f}".format(np.mean(scores)))

　功能词和字符N元模型之间存在大量隐式重合，因为字符序列更可能出现在功能词中。然而，实际上两者差别很大，字符N元模型能够捕获标点的使用特点，功能词自然做不到。例如，字符N元语法能够包含句号，而基于功能词的方法就只能使用句号前的单词。

9.5　使用安然公司数据集

安然公司（Enron）是20世纪90年代末期世界上最大的能源公司之一，年收入高达1000亿美元。2000年时，拥有20 000余名员工，看不出公司有什么严重问题。

2001年，安然丑闻发生，调查人员发现安然为谋取暴利，在全公司范围内账务存在系统性造假现象。丑闻被揭发后，安然公司的股价从2000年的90多美元一下子跌到了2001年的1美元。安然随即申请破产保护，留下的残局，五年多以后才收拾干净。

作为对安然公司调查的一部分，美国联邦能源署公开了60多万封电子邮件。从那时起，这些数据就被广泛应用于社会媒体分析、欺诈分析等众多问题的研究。这些数据用于作者归属问题研究也不错，因为我们能获得发件人及他们写的邮件，语料规模比之前见过的很多数据集都要大得多。

9.5.1　获取安然数据集

安然公司的邮件可以从卡内基梅隆大学的网站下载：https://www.cs.cmu.edu/~./enron/。

　整个数据集为423MB，压缩格式为gzip。如果你用的不是Linux系统，可以使用免费的7zip（(http://www.7-zip.org/）等软件来解压。

下载电子邮件语料，将其解压到Data目录下。解压后的目录默认为enron_mail_20110402。

因为要做作者归属分析，我们只需要那些明确知道发件人是谁的邮件。因此，查看每位用户的发件夹——那里面存放的是他们所发的邮件。

在笔记本中，指定安然数据集所在的位置。

enron_data_folder = os.path.join(os.path.expanduser("~"), "Data",
  "enron_mail_20110402", "maildir")

9.5.2　创建数据集加载工具

我们现在就来创建一个函数，它接收几个发件人作为参数，返回他们所发送的邮件。我们需要的有效信息是邮件内容而不是邮件本身。因此，还需要邮件解析器。代码如下：

from email.parser import Parser
p = Parser()

我们后面会用到该解析器从邮件中抽取邮件内容。

因为是随机选取收件人，为了重现实验结果，我们设置随机状态。

from sklearn.utils import check_random_state

数据加载函数提供几个参数，大部分是为了确保得到的数据集类别分布相对比较均衡。有些用户发了成千上万封邮件，而有的用户只发了几十封。我们用min_docs_author参数指定每个发件人至少发过10封邮件，用max_docs_author参数指定最多从一个用户那里抽取100封邮件。我们还用num_authors限定了收件人数量——默认为10。代码如下：

def get_enron_corpus(num_authors=10, data_folder=data_folder,
                     min_docs_author=10, max_docs_author=100,
                     random_state=None):
    random_state = check_random_state(random_state)

接下来，获取到安然公司员工的邮箱，邮箱其实就是data_folder文件夹中各子文件夹的名称。随机对得到的邮箱列表进行排序。只要随机状态相同，实验结果就可以重现。

    email_addresses = sorted(os.listdir(data_folder))
    random_state.shuffle(email_addresses)

　你可能很好奇我们既然后面接着要随机调整顺序，那为什么还要事先进行排序。因为os.listdir函数每次返回结果不一定相同，在使用该函数前先排序，从而保持返回结果的一致性。然后我们用随机状态的shuffle函数随机选取一组收件人。如果需要的话，随机状态函数可以返回跟之前相同的结果。

然后，我们创建文档列表、类别列表，author_num指的是每个新发件人的类别编号。这次我们不用enumerate函数，因为有些发件人我们用不到。例如，发过不够10封邮件的，就不会用。代码如下：

    documents = []
    classes = []
    author_num = 0

我们还需要记录我们所用到的收件人及他们的编号。数据挖掘过程用不到，但是在可视化时能用到，便于我们确定收件人。authors字典用于将用户名和类别关联起来。代码如下：

    authors = {}

接下来，遍历邮箱文件夹，查找它下面名字中含有“sent”的表示发件箱的子文件夹。代码如下：

    for user in email_addresses:
      users_email_folder = os.path.join(data_folder, user)
      mail_folders = [os.path.join(users_email_folder,
        subfolder) for subfolder in os.listdir(users_email_folder)
                        if "sent" in subfolder]

获得子文件夹中的每一封邮件。我们用到了try-except语句，因为有些用户的发件箱目录还有子目录。要获取目录层次更深的邮件，我们的代码还需要做些改进，现在我们先跳过这些用户。代码如下：

        try:
          authored_emails = [open(os.path.join(mail_folder,
            email_filename), encoding='cp1252').read()
          for mail_folder in mail_folders
          for email_filename in os.listdir(mail_folder)]
        except IsADirectoryError:
            continue

接下来，检测是否获取到了至少10封邮件（或min_docs_author指定的其他值）。

        if len(authored_emails) < min_docs_author:
            continue

下一步，如果发件人发了大量邮件，只取前100封（具体数量由max_docs_author指定）。

        if len(authored_emails) > max_docs_author:
            authored_emails = authored_emails[:max_docs_author]

解析邮件，获取邮件内容。我们对邮件头部不感兴趣——发件人所能改动的内容比较少，因此对作者分析没有多大用处。然后把邮件内容添加到数据集中。

        contents = [p.parsestr(email)._payload for email in
          authored_emails]
        documents.extend(contents)

把该发件人添加到类别列表中，每一封邮件添加一次。

        classes.extend([author_num] * len(authored_emails))

记录该收件人的编号，再把编号加1，以便下一个收件人使用。

        authors[user] = author_num
        author_num += 1

检测收件人数量是否达到我们设置的值，如果是，跳出循环，返回数据集。

        if author_num >= num_authors or author_num >=
          len(email_addresses):
            break

返回邮件数据集和类别以及收件人字典。

    return documents, np.array(classes), authors

在上述函数的外面，调用这个函数，我们就能得到数据集。我们使用随机状态14（全书都是），但是你可以尝试其他值或者将其设置为none，这样每次调用这个函数时将随机获取一组数据。

documents, classes, authors = get_enron_corpus(data_folder=enron_data_
folder, random_state=14)

如果你看下现有的数据集，你就会发现还需要做进一步的预处理。数据集有不少噪音，但最致命的问题（从数据分析角度看）是，它还包含其他用户所写的内容，回复邮件时会带上别人之前写的邮件。我们来看下邮件documents[100]：

在这篇文档中，上面的邮件是对下面邮件的回复，这种情况很常见。第一部分是来自Mark Haedicke，而下面的邮件是Mark Greenberg写给Mark Haedicke。只有前面的内容（第一处“-----Original Message-----”之前）是发件人所写，这才是我们所关注的。

抽取这些信息不容易。邮件没有统一的格式。不同的邮件服务提供商使用不同的头部，对于回复内容有不同的定义形式，简直就是“为所欲为”4。就是在这样的环境中，电子邮件还被广泛使用，简直就是一个奇迹。

4读到此处，不禁想起各种浏览器的纷争。——译者注

当然还是有些共用的模式可以用。我们可以用quotequail包来查找邮件中的新内容，抛弃被回复的邮件及其他不相干信息。

　你可以使用pip安装quotequail：pip3 install quotequail。

我们编写一个简单的函数封装quotequail的功能，方便调用它处理所有的文档。首先导入quotequail，声明函数。

import quotequail
def remove_replies(email_contents):

接着用quotequail把邮件解析为几个部分，返回字典结构。代码如下：

    r = quotequail.unwrap(email_contents)

有时候，r可能为none，比如邮件出于这样那样的原因解析失败。遇到这种情况，邮件原样返回。在处理真实数据集时，经常需要检测变量值是否为空。代码如下：

    if r is None:
        return email_contents

quotequail的返回结果中，我们真正需要的是text_top这部分。如果字典r中存在text_top，返回它的值。

    if 'text_top' in r:
        return r['text_top']

如果不存在，也就是quotequail没能找到该键。它也可能找到了text键，那返回text的值。代码如下：

    elif 'text' in r:
        return r['text']

最后，如果这两个键都没有找到，返回邮件全部内容，希望多少会对数据分析有点用处。

    return email_contents

对数据集中的每一封邮件，运行上述函数，做一遍预处理。

documents = [remove_replies(document) for document in documents]

我们之前看过的那封含有被回复邮件的文档，经过处理后，只包含Mark Greenberg所发的内容。

I am disappointed on the timing but I understand. Thanks. Mark

9.5.3　组装起来

我们可以使用前面实验所用到的参数空间和分类器——在新数据集上进行训练。默认情况下，scikit-learn会重新进行训练——后续调用fit()函数将会丢掉之前的信息。

　还有一类算法叫作线上学习，它们使用新数据更新训练结果，但不是每次都重新进行训练。后续章节包括第10章新闻语料分类，我们会实际用到线上学习这类算法。

跟前面一样，我们使用cross_val_score计算正确率并输出结果。代码如下：

scores = cross_val_score(pipeline, documents, classes, scoring='f1')
print("Score: {:.3f}".format(np.mean(scores)))

F1值为0.523，对于包含这么多噪音的数据集来说，结果还算可以。添加更多数据（比如增加max_docs_author的值）应该会提升效果。

9.5.4　评估

一般来说，只凭借一个数字来评估效果不是个好主意，可能会忽略很多东西。拿F值来说，它考察的点就比较全面，那些分类结果好，却没有实际用处的雕虫小技也就不能蒙混过关。前面多次使用的正确率就有破绽，比如邮件过滤器把所有邮件归为垃圾邮件，也能达到80%的正确率，但是这种方法却没有实际用处。出于这个原因，我们还要对评估结果进行深入探讨。

我们先来看下混淆矩阵，第8章用神经网络破解验证码曾经用过。首先，对测试集数据进行预测。前面代码使用cross_val_score，并没有给出可用的训练模型，因此我们需要重新训练一个模型。我们先来把语料切分为训练集和测试集。

from sklearn.cross_validation import train_test_split
training_documents, testing_documents, y_train, y_test =
train_test_split(documents, classes, random_state=14)

接着，把训练集传入流水线，进行训练，接着对测试集进行预测。

pipeline.fit(training_documents, y_train)
y_pred = pipeline.predict(testing_documents)

你可能想知道最好的参数组合是什么。我们可以方便地从网格搜索对象（流水线classifier这一步）中获取到这些参数组合。

print(pipeline.named_steps['classifier'].best_params_)

上述代码输出分类器的所有参数。然而大部分参数都使用默认值。只有C和kernel两个参数，我们使用网格搜索寻找合适的值，它们分别被设置为1和linear。

创建混淆矩阵：

from sklearn.metrics import confusion_matrix
cm = confusion_matrix(y_pred, y_test)
cm = cm / cm.astype(np.float).sum(axis=1)

我们接下来作图要用到刻度，因此需要取到发件人。还好，加载数据集时，我们用字典来保存了发件人及其编号。代码如下：

sorted_authors = sorted(authors.keys(), key=lambda x:authors[x])

最后，使用matplotlib绘制混淆矩阵。代码跟上章大同小异，不同的地方用粗体表示，只是把坐标轴刻度改为发件人。

%matplotlib inline
from matplotlib import pyplot as plt
plt.figure(figsize=(10,10))
plt.imshow(cm, cmap='Blues')
tick_marks = np.arange(len(sorted_authors))
plt.xticks(tick_marks, sorted_authors)
plt.yticks(tick_marks, sorted_authors)
plt.ylabel('Actual')
plt.xlabel('Predicted')
plt.show()

图像如下。

我们可以看到哪些发件人在大多数情况下都能正确预测——正确率高的形成了一条清晰的对角线。我们还能看出哪些发件人的邮件经常被弄混（颜色越深，弄混的次数越多）：例如，baughman-d的邮件经常被当作是reitmeyer-j发的。

9.6　小结

本章研究的是文本挖掘领域中的作者归属问题，我们分析了两类特征的效果：功能词和字符N元语法。我们在词袋模型中使用功能词——提前选好的一组词，计算出这些词的词频。字符N元语法的处理流程跟基于单词的N元语法很相似，但是分析器转而关注字符而不是单词。此外，N元语法由一系列字符组成。单词N元语法由于可以提供单词的语境而没有增加多少计算量，在某些应用中也值得一试。

我们用SVM来进行分类，它通过找出使得两个类别之间间隔最大的线进行分类。线上的属于一类，线下的属于另外一类。跟其他分类任务一样，我们也需要有数据集（邮件）。

我们使用安然公司的邮件作为数据集，它包含很多噪音，比如包含被回复的邮件等。所以分类效果比起图书数据集要差不少。然而，在有10个发件人的情况下，半数以上邮件的发件人都能预测正确。

下一章，我们要研究的问题是没有目标类别的情况下，怎么对数据进行分类。这种叫作无监督学习，比起预测更像是探索性问题。我们要使用的依然是充满噪音的文本数据集。准备好接受挑战了吗？

第 10 章　新闻语料分类

前面大部分章节在对数据进行挖掘前，新数据所属的目标类别范围是已知的。在训练过程中，我们能够了解到变量跟类别之间存在怎样的关系。这种已知目标类别的学习任务，叫作有监督学习。本章研究的是在不知道类别的情况下如何进行数据挖掘。这叫作无监督学习，它偏重于探索、发现隐藏在数据里的信息，而不是用模型来分类，其探索性更强。

本章将介绍如何对新闻语料进行聚类，以发现其中的趋势和主题。本章还将讨论怎样从链接聚合网站抽取新闻语料。

本章主要介绍如下几个概念。

从任意新闻网站获取文本内容

使用reddit网站API采集有趣的新闻报道

使用聚类分析进行无监督的数据挖掘

抽取文档主题

用线上学习方法无需再次训练即可更新模型

组合不同的模型进行聚类

10.1　获取新闻文章

本章将构建一个按照主题为最新的新闻报道分组的系统。你可以运行几周（或更长时间）以了解这段时间新闻趋势的变化。

系统首先从流行的链接聚合网站reddit1寻找新闻报道的链接。reddit存储了大量其他网站的链接，还提供讨论区。网站收集的链接按照类别进行分类，这些类别被统称为subreddit，比如有电视节目、趣味图片等类别。本章关注的是新闻这一类链接。本章使用/r/worldnews类别的链接，但是我们所编写的代码也可以用来抓取其他类别的语料。

1Y Combinator孵化的项目。最初使用Lisp语言开发，后改用Python，网站开放源代码，可以从Github上找到。创始人Steve Huffman在Udacity上开设了一门讲解Web开发的课程，课程编号为CS253。Steve卖掉reddit后，创立了专注于旅游搜索的Hipmunk网站。2015年再度回到reddit，出任CEO。web.py的作者Aaron Swartz也是reddit的联合创始人。——译者注

本章的目标是下载大家所关注的新闻报道，对其进行聚类分析，寻找其中的主题或主要概念，无需人工分析成百上千篇新闻报道，就能洞察人们关注的焦点。

10.1.1　使用Web API获取数据

前面有几章都是使用Web API从网站抽取数据。例如，第7章就用到Twitter网站的API。采集数据是数据挖掘流水线至关重要的一个环节。Web API是采集多个主题数据的绝佳工具。

使用Web API采集数据，有三个注意事项：授权方法、请求频率限制和API端点（endpoints）。

授权方法是数据提供方用来管理数据采集方的。数据提供方以此了解谁正在采集数据，确保采集方抓取数据的频率没有超出上限，同时对采集方都采集了哪些数据也做到心中有数。对于大多数网站，普通的个人用户账号就能用来采集数据，但也有部分网站要求采集方使用正式的开发者账号。

采集频率限制规定了采集方在约定时间内的最大请求次数，尤其是针对免费提供的服务。在使用数据获取接口时，一定要了解清楚，不同的网站很可能有着不同的规定。Twitter的API要求每15分钟（视调用的API而定）之内最多请求180次数据。reddit规定每分钟至多发起30次请求，下面我们还会讲到。其他有些网站会限制每天的请求次数，当然也有限制到秒级别的。即使同一个网站，不同的API调用，也有着截然不同的采集频率限制。例如，谷歌地图对每种资源API的调用限制，与按小时请求就有着不同规定，对前者限制更为严格。

　如果你的应用或实验需要发起更多的请求和更快的响应，超出了免费API所能提供的，你可以寻求与API提供商进行商业合作。

API端点指的是用来抽取信息的实际网址。不同网站提供不同的接口，大部分Web API提供RESTful接口（Representational State Transfer，表述性状态转移）。RESTful接口通常与HTTP协议使用相同的操作：GET、POST、DELETE是最常用的。例如，使用下述API端点检索某一资源的相关信息：www.dataprovider.com/api/resource_type/resource_id/。

获取信息，只需要发送HTTP GET请求到指定的网址。服务器返回资源信息、信息类型和ID。大多数API都是按照这种结构进行封装的，虽然在具体实现上会有所差异。大多数提供API的网站，都会给出详细的文档，列出所有开放使用的API的具体参数。

我们来看下获取信息的具体步骤。首先，设置连接reddit网站所用到的参数，这里需要用到reddit开发者密钥。从https://www.reddit.com/login登录reddit网站，打开https://www.reddit.com/prefs/apps。点击“are you a developer? create an app…”，填写表单，选择script类型。你就能得到用户ID和密钥，新建一个笔记本文件，输入以下代码。

CLIENT_ID = "<Enter your Client ID here>"
CLIENT_SECRET = "<Enter your Client Secret here>"

reddit还要求你在使用他们的API时，设置唯一的用户代理（user agent）字符串。在设置用户代理时，注意把自己的reddit用户名也写上，以创建唯一标识你应用的用户代理字符串。我的用户代理字符串中包含本书的英文名、章节编号“chapter 10”及版本号“0.1”，你可以使用其他内容。如果你的用户代理跟别人重复，你的请求频率将受到很大限制。

USER_AGENT = "python:<your unique user agent> (by /u/<your reddit username>)"

此外，你需要使用自己的用户名和密码登录reddit。如果你还没有账号，请注册一个（免费且无需验证个人信息）。

　下一步会用到你的密码，把代码分享给别人之前，记得删除它。如果你不打算在代码中写入密码，把密码设置为none，这样每次运行时输入密码即可。但是，你需要在启动笔记本文件的终端输入，而不是在笔记本文件里输入，这是由笔记本文件的工作方式决定的。如果你无法在终端输入，请直接在代码里设置好密码。IPython的开发人员正在编写修复这个问题的插件，但是写作本书时，插件还没写好。

指定用户名和密码，代码如下：

USERNAME = "<your reddit username>"
PASSWORD = "<your reddit password>"

接着，创建登录函数，使用以上信息进行登录。reddit的登录接口将会返回进一步连接所需要的令牌，这也就是登录函数的返回结果。函数声明如下：

def login(username, password):

首先，如果你不想把密码写到代码里，把它设置为none，但是你需要在命令行输入，前面已经说过。代码如下：

    if password is None:
        password = getpass.getpass("Enter reddit password for user {}:
".format(username))

使用独一无二的用户代理很重要，否则你的连接将严重受限。代码如下：

    headers = {"User-Agent": USER_AGENT}

接着，创建HTTP授权对象，以登录reddit服务器。

    client_auth = requests.auth.HTTPBasicAuth(CLIENT_ID, CLIENT_SECRET)

发起POST请求到access_token端点以实现登录，POST的数据有用户名、密码、授权类型。本例中授权类型为使用密码进行登录。

    post_data = {"grant_type": "password", "username": username, "password": password}

最后，函数使用requests库发起登录请求（通过HTTP POST请求实现），POST请求返回的结果为字典类型。其中有一项就是进一步请求所需的令牌。代码如下：

    response = requests.post("https://www.reddit.com/api/v1/access_token", auth=client_auth, data=post_data, headers=headers)
    return response.json()

调用上述登录函数，获取令牌。

token = login(USERNAME, PASSWORD)

令牌对象为一字典结构，其中的access_token键对应的值就是进一步请求所需的令牌。该对象还包含令牌的使用范围（全局）和过期时间。例如：

{'access_token': '<semi-random string>',  'expires_in': 3600,
'scope': '*',  'token_type': 'bearer'}

10.1.2　数据资源宝库reddit

链接聚合网站reddit（www.reddit.com）拥有几亿用户，虽然英文版主要面向美国。每个用户都可以发布他们感兴趣的网站的链接，同时为该链接指定标题。其他用户对其点赞，表示他们喜欢这个链接的内容，也可以投反对票，表示不喜欢。点赞最高的链接将被移到网页的最上面，没有多少人喜欢的将不会显示。随着时间的推移，先前发表的链接也将不再显示（根据喜欢数来定）。分享的链接被点赞后，用户将会获得叫作karma2的积分，这也是为了激励用户只分享好故事。

2karma，指因果报应。reddit网站使用该词，应是取其善有善报之意。——译者注

reddit用户也可以发表链接之外的其他内容，这些内容叫作自己的广播3，它由用户输入的标题和正文组成，通常是为了提问或是引发讨论，不在积分赚取范围之内。本章仅关注链接类广播，不关注评论类的。

3广播对应英文单词post，指的是用户发表的一条内容。——译者注

网站的所有广播被分到叫作subreddit的不同栏目，每个栏目包含一系列与之相关的广播。用户发布广播时，需要选择广播所属的栏目。每个栏目都有自己的管理员和内容管理规则，用户不能发表与栏目无关的内容。

广播默认根据热度（Hot）进行排序，一条广播的热度由其存在的时间、喜欢数、不喜欢的次数来决定。除了热度外，还有最新发表的（New）和一定时间内最受欢迎的（Top）两种排序方法，最新广播可能包含大量的垃圾信息。本章将按照热度来选取广播，这些广播比较新，内容质量也比较高（单纯根据时间排序，得到的链接有很多是垃圾链接）。

使用我们前面获取到的令牌，就可以获取一个栏目的一系列链接。/r/<subredditname> API端点默认返回所指定栏目的热门文章。我们把栏目指定为世界新闻/r/worldnews。

subreddit = "worldnews"

我们使用字符串格式化方法，用刚指定的栏目来创建完整的URL。

url = "https://oauth.reddit.com/r/{}".format(subreddit)

接下来，需要设置头部，这样才能指定授权令牌，设置独一无二的用户代理，争取不会被过分限制请求次数。代码如下：

headers = {"Authorization": "bearer {}".format(token['access_token']),
           "User-Agent": USER_AGENT}

然后，跟之前一样，使用requests库发起请求，别忘了指定头部。

response = requests.get(url, headers=headers)

调用response的json方法，将会得到包含reddit返回信息的一个Python字典。它包含给定栏目的25条广播，对它们进行遍历，输出每条广播的标题。广播的相关内容存储在字典键为data的那一项中。代码如下：

for story in result['data']['children']:
    print(story['data']['title'])

10.1.3　获取数据

我们数据集的每条广播都来自/r/worldnews栏目的热度列表。上节讲解了连接reddit网站和抽取广播内容的方法。我们来创建一个函数，把这两个步骤整合起来，抽取指定栏目每条广播的标题、链接和喜欢数。

我们遍历世界新闻栏目，最多一次获取100篇新闻报道。我们还可以使用分页，reddit最多允许我们读多少页，我们就读多少页。但是我们这里最多读5页。

因为我们代码会重复调用同一个API，为了防止超出网站所规定的采集频率限制，我们可以使用sleep函数，先来导入它。

from time import sleep

接下来要定义的这个函数接收三个参数，分别为栏目名称、授权令牌和读取的页数，默认值为5。

def get_links(subreddit, token, n_pages=5):

创建列表，存储新闻报道。

    stories = []

我们在第7章见过如何在Twitter的API中使用分页功能。Twitter在返回结果时，同时返回一个游标，调用接口时，再一并传递游标，Twitter再利用此游标获取当前结果的下一页。reddit的API游标用法几乎和Twitter一致，只不过它使用after参数。获取第一页数据时用不到这个参数，将其置为none，获取到第一页后，再给它传一个有意义的值。代码如下：

    after = None

遍历我们想得到的那些页。

    for page_number in range(n_pages):

在上述循环中，设置好头部和URL，方法跟之前一样。

        headers = {"Authorization": "bearer
        {}".format(token['access_token']),
            "User-Agent": USER_AGENT}
        url = "https://oauth.reddit.com/r/{}?limit=100".
            format(subreddit)

从第二次循环开始，我们需要设置after参数（否则返回结果都一样）。下一次循环所用到的after的值在前一次循环中设置。如果after值不为none，把它添加到URL的末尾，告诉reddit我们接下来要获取哪页的数据。代码如下：

        if after:
            url += "&after={}".format(after)

下面跟之前一样，使用requests库发起请求，在返回结果上调用json()方法，得到Python字典。

        response = requests.get(url, headers=headers)
        result = response.json()

返回结果中包含下一轮迭代所需的after值，用它来更新after参数。

        after = result['data']['after']

暂停两秒钟，防止超出API使用频率限制。

        sleep(2)

在循环体的最后，从reddit的返回结果中获取到每篇报道，把它们添加到stories列表中。我们仅需要标题、URL和喜欢数这三项数据。代码如下：

        stories.extend([(story['data']['title'], story['data']['url'],
story['data']['score'])
                       for story in result['data']['children']])

最后（循环体外面），返回找到的所有新闻报道。

    return stories

调用get_links函数时传入栏目名称和授权令牌即可。

stories = get_links("worldnews", token)

返回结果包含500篇新闻报道的标题、URL等，我们接下来就可以获取到这些URL所指向页面的文本内容。

10.2　从任意网站抽取文本

我们从reddit收集到的网址所指向的网站分属不同的网站组织。这些网站的目标用户是普罗大众而不是计算机程序。当我们尝试用程序获取里面的实际内容时，可能会遇到种种困难，因为如今的网站，有很多逻辑是在后台运行：调用JavaScript库，应用样式表，用AJAX加载广告，在侧边栏增加很多内容等，这些功能增加了网站的复杂程度。这些技术的应用使得当今的Web看起来鲜活、生动、丰富多彩，却增加了自动采集信息的难度。

10.2.1　寻找任意网站网页中的主要内容

我们首先需要访问每个链接，下载各个网页，将它们保存到Data文件夹中事先建好的用于存放原始网页的文件夹raw。后面，我们就要从这些原始网页中获取有用的信息。先把全部网页都保存下来，比起后面时不时地下载网页方便多了。首先，指定存放原始网页的目录。

import os
data_folder = os.path.join(os.path.expanduser("~"), "Data",
"websites", "raw")

后面我们要用MD5散列算法为每篇报道创建一个唯一的文件名，所以先导入hashlib。散列函数将输入（包含新闻报道名称的字符串）转换为一个看上去像是随机产生的字符串。对于相同的输入，散列函数返回相同的结果，但是不同输入之间的微小差别将会导致截然不同的输出结果。并且，散列函数为单向函数，根据散列值无法得到原来的值。导入hashlib，代码如下：

import hashlib

对于网页下载失败的网站，我们直接跳过。为了避免错过太多网页，我们维护一个计数器，统计下载失败的次数。如果是系统本身的问题阻止下载，那我们就要解决这些问题。如果失败次数过多，我们就要找出到底是什么东西在作怪，尝试去解决问题。例如，如果计算机没有联网，所有的500次下载都会失败，你得先解决联网问题，才能再开展下面的工作！

如果所有网页都能成功下载，计数器输出值应该为0。

number_errors = 0

接下来，遍历每一篇新闻报道。

for title, url, score in stories:

对报道的标题进行散列操作，作为输出文件名，以保证唯一性。因为reddit网站不要求文章标题具有唯一性，因此两篇报道可能使用相同的标题，这就会导致数据集中两条数据之间的冲突。我们使用MD5算法对报道的URL进行散列操作。现在人们发现MD5也不是绝对可靠的，但是就我们这么小的数据规模来说不太可能出问题（出现碰撞情况），即使出现这样的问题，也不用太担心。

    output_filename = hashlib.md5(url.encode()).hexdigest()
    fullpath = os.path.join(data_folder, output_filename + ".txt")

接下来下载网页，保存到输出文件夹中。

    try:
        response = requests.get(url)
        data = response.text
        with open(fullpath, 'w') as outf:
            outf.write(data)

如果下载网页时出现问题，跳过问题网站，继续下载下一个网站的网页。上述代码能够完成我们收集到的95%的网站下载任务，对本章的应用来说足够了，因为我们寻找的是大体趋势而不是做精准研究。有时我们必须下载到所有网站的内容，这时就得调整代码，以处理各种可能的错误。抓取失败的5%～10%的网站，需要编写更为复杂的代码才能进行处理。我们来捕获出现的错误（这可是因特网，会有很多意想不到的错误），增加计数器计数，继续下载下个网站的网页。

    except Exception as e:
        number_errors += 1
        print(e)

如果错误很多，把上面print(e)那一行改为raise，调用异常中断机制，以便修改问题。

现在，我们原始网页文件夹raw中有很多网页，查看这些网页（用文本编辑器打开），你会发现新闻报道的内容湮没在HTML、JavaScript、CSS以及其他内容之中。因为我们只对报道本身感兴趣，就需要一种从不同网站的网页中抽取内容的方法。

10.2.2　组装起来

获得原始网页后，我们需要找出每个网页中的新闻报道内容。有一些在线资源使用数据挖掘方法来解决这个问题，资源列表请见附录。一般来说，很少需要用到这些复杂的算法，当然使用它们能得到更为精确的结果。这也是数据挖掘艺术的一部分——知道什么时候用，什么时候不用。

首先，获取到raw文件夹中的所有文件名。

filenames = [os.path.join(data_folder, filename)
             for filename in os.listdir(data_folder)]

接着，创建输出文件夹，新闻报道内容抽取出来后，将保存到该文件夹下。

text_output_folder = os.path.join(os.path.expanduser("~"), "Data",
                                  "websites", "textonly")

接着，编写从网页中抽取文本的代码。我们将使用lxml包解析HTML文件，lxml的HTML解析器容错能力强，可以处理不规范的HTML代码。导入语句如下所示：

from lxml import etree

文本抽取分为以下三步：首先，遍历HTML文件的每一个节点，抽取其中的文本内容。其次，跳过JavaScript、样式和注释节点，这些节点不太可能包含对我们有价值的信息。最后，确保文本内容长度至少为100个字符。分析文章主题，100个字符就够用，但是要想得到更准确的结果，文章长度有待增加。

前面说过，我们对脚本、样式或注释不感兴趣。因此，创建列表，存放这些不可能包含新闻报道内容的节点。代码如下：

skip_node_types = ["script", "head", "style", etree.Comment]

我们创建一个函数，把HTML文件解析成lxml etree对象，然后创建另外一个函数，解析前面得到的树，寻找文本。第一个函数简单易懂：打开网页文件，用lxml库的parse方法解析HTML文件。代码如下：

def get_text_from_file(filename):
    with open(filename) as inf:
        html_tree = lxml.html.parse(inf)
    return get_text_from_node(html_tree.getroot())

上述函数的最后一行，调用getroot()函数，获取到树的根节点，而不是整棵树etree，这样文本抽取函数get_text_from_node以节点作为参数，它能处理包括根节点在内的所有节点，便于递归调用。

文本抽取函数将在任何子节点上调用自己，以抽取子节点中的文本内容，最后返回拼接在一起的所有子节点的文本。

如果一个节点没有任何子节点，文本抽取函数返回该节点的文本内容。如果该节点不包含任何内容，就返回空字符串。请注意，还需要执行上面提到的文本抽取的第三步——检查文本长度是否达到100个字符。代码如下：

def get_text_from_node(node):
    if len(node) == 0:
        # No children, just return text from this item
        if node.text and len(node.text) > 100:
            return node.text
        else:
            return ""

明确节点有子节点之后，就对每一个子节点递归调用文本抽取函数，把返回的文本内容拼接在一起。代码如下：

    results = (get_text_from_node(child) for child in node
                     if child.tag not in skip_node_types)
    return "\n".join(r for r in results if len(r) > 1)

上面return语句中的if语句是为了防止返回空行（例如，有的节点没有子节点和文本内容）。

遍历所有的原始网页，从文件里抽取文本，把结果保存到纯文本文件4夹中。

4指去除掉网页代码后剩下的文本内容。——译者注

for filename in os.listdir(data_folder):
    text = get_text_from_file(os.path.join(data_folder, filename))
    with open(os.path.join(text_output_folder, filename), 'w')
      as outf:
        outf.write(text)

打开纯文本文件夹中的文件，查看它们是否有内容。如果很多都不包含新闻报道内容，提升最少字符限制，我们刚才用的是100。如果设置为更高的值，仍不起作用，或者你的应用对抽取文本内容有更高的要求，请尝试使用附录介绍的更为复杂的方法。

10.3　新闻语料聚类

本章的目的是通过聚类发现新闻语料中潜藏的趋势。我们将使用诞生于1957年的经典机器学习算法k-means（k均值）。

聚类属于无监督学习，我们使用聚类算法探索隐藏在数据里的奥秘。我们的数据集由大约500篇新闻报道组成，人工查看这些文章的主题费时费力。即使使用概括统计方法也不容易，对这种方法而言数据还是相当多。而聚类分析则可以按照主题把它们分成不同的簇，然后就可以按簇研究它们的主题。

我们在没有明确的类别的情况下会使用聚类方法。从这个意义上来讲，聚类算法学习时没有明确的方向性，它们根据目标函数而不是数据潜在的含义来学习。因此，选择聚类效果好的特征就很有必要。对于有监督学习，即使你选用了效果较差的特征，学习算法可以选择不用这些特征。例如，支持向量机为对分类用处不大的特征分配很小的权重。然而，聚类算法会综合所有特征给出最后结果——即使那些不会提供给我们答案的特征。

在对真实的数据进行聚类分析前，最好了解哪些特征适用于当前任务。本章使用词袋模型。我们寻找的是主题相关的簇，因此使用主题相关的特征为数据建模。我们之所以知道这些特征可用来聚类，是因为别人用有监督方法解决类似问题时用过这些特征。对比来说，如果要按照作者把作品分组，我们就要使用类似第9章用到的特征。

10.3.1　k-means算法

k-means聚类算法迭代寻找最能够代表数据的聚类质心点。算法开始时使用从训练数据中随机选取的几个数据点作为质心点。k-means中的k表示寻找多少个质心点，同时也是算法将会找到的簇的数量。例如，把k设置为3，数据集所有数据将会被分成3个簇。

k-means算法分为两个步骤：为每一个数据点5分配簇标签，更新各簇的质心点。

5一个数据点对应数据集中一条数据，把数据集看成样本，一条数据即可被称作是一个个体。——译者注

分簇这一步中，我们为数据集的每个个体设置一个标签，把它和最近的质心点联系起来。对于距离质心点1最近的个体，我们为它们分配标签1，距离质心点2最近的个体，分配标签2，以此类推。标签相同的个体属于同一个簇，所有带有标签1的数据点属于簇1（只是暂时的，数据点所属的簇会随着算法的运行发生变化）。

更新环节，计算各簇内所有数据点的均值，更新质心点。

k-means算法会重复上述两个步骤；每次更新质心点时，所有质心点将会小范围移动。这会轻微改变每个数据点在簇内的位置，从而引发下一次迭代时质心点的变动。这个过程会重复执行直到条件不再满足时为止。通常是在迭代一定次数后，或者当质心点的整体移动量很小时，就可以终止算法的运行。有时可以等算法自行终止运行，这表明簇已经相当稳定——数据点所属的簇不再变动，质心点也不再改变时。

下图表示的是对随机创建的数据集执行k-means算法，数据集包含三个簇。图中的三颗五角星表示最初随机从数据集中选取的三个质心点。k-means算法经过5轮迭代后，质心点发生了改变，具体位置用三角形来表示。

k-means算法因它所使用的数学方法和久经考验而充满魅力。该算法只有（大体上可以这么说）一个参数，虽然距它提出至今已过了半个多世纪，但由于它在很多数据挖掘问题上效果很好，仍被频繁使用。

scikit-learn实现了k-means算法，直接从cluster模块导入即可。

from sklearn.cluster import KMeans

我们顺便把CountVectorizer类的好兄弟TfidfVectorizer导进来。这个向量化工具根据词语出现在多少篇文档中，对词语计数进行加权。出现在较多文档中的词语权重较低（用文档集数量除以词语出现在的文档的数量，然后取对数）。对于很多文本挖掘应用，使用该种权重计算方法，能够有效提升效果。代码如下：

from sklearn.feature_extraction.text import TfidfVectorizer

创建数据分析流水线，流水线分两步，第一步是特征抽取，第二步是调用k-means算法。代码如下：

from sklearn.pipeline import Pipeline
n_clusters = 10
pipeline = Pipeline([('feature_extraction', TfidfVectorizer(max_
df=0.4)),
                     ('clusterer', KMeans(n_clusters=n_clusters))
                     ])

我们为参数max_df设置了一个很低的值0.4，表示忽略出现在40%及以上的文档中的词语。这个参数可用来剔除本身不含有主题相关含义的词语。

　删除在40%及以上文档中出现的词语将会删除功能词，这种预处理对于第9章将有害无益。

首先训练算法，然后再用它来做预测。前几章的分类任务都是按照这两个步骤实施，但是这里有一点不同——我们没有为fit函数指定目标类别。这也正是无监督学习任务的含义所在！代码如下：

pipeline.fit(documents)
labels = pipeline.predict(documents)

变量labels包含每个数据点的簇标签。标签相同的数据点属于同一个簇。需要指出的是簇标签本身没有含义：不能说簇1和簇2比簇1和簇3更相似。

我们可以使用Counter类来查看每个簇有多少数据点。

from collections import Counter
c = Counter(labels)
for cluster_number in range(n_clusters):
    print("Cluster {} contains {} samples".format(cluster_number,
c[cluster_number]))

聚类问题的结果（注意你的数据集可能跟我的不同）往往是一个簇很大，包含了大多数个体，再有几个中等规模的簇，最后还有几个只包含一两个个体的很小的簇，这种不平衡性很常见。

10.3.2　评估结果

聚类分析主要是探索性分析，因此很难有效地评估聚类算法结果的好坏。评估算法结果最直接的方式是根据它要学习的标准对其进行评价。

　如果你有测试集，你可以对其进行聚类分析来评价效果。更多细节请见http://nlp.stanford.edu/IRbook/html/htmledition/evaluation-of-clustering-1.html。

对于k-means算法，寻找新质心点的标准是，最小化每个数据点到最近质心点的距离。这叫作算法的惯性权重（inertia），任何经过训练的KMeans实例都有该属性。

pipeline.named_steps['clusterer'].inertia_

输出结果为343.94，不过这个值本身没有意义，但是可以用它来确定分为多少簇合适。前面的例子，n_clusters的值被设置为10，但这是最佳值吗？下面代码n_clusters依次取2到20之间的值，每取一个值，k-means算法运行10次。每次运行算法都记录惯性权重。

对于n_clusters变量的每个取值，仅训练X矩阵一次，以（极大）提升代码运行速度。

inertia_scores = []
n_cluster_values = list(range(2, 20))
for n_clusters in n_cluster_values:
    cur_inertia_scores = []
    X = TfidfVectorizer(max_df=0.4).fit_transform(documents)
    for i in range(10):
        km = KMeans(n_clusters=n_clusters).fit(X)
        cur_inertia_scores.append(km.inertia_)
    inertia_scores.append(cur_inertia_scores)

变量inertia_scores存储了n_clusters取2到20每个值时所对应的惯性权重。我们把惯性权重和簇的数量做成图，以便了解它们之间的关系。

整体而言，随着簇数量的增加，质心点和其他数据点位置的调整逐渐减少，惯性权重应该逐渐降低，这是很容易就能从上图得到的结论。从6簇进一步分为7簇时，质心点是随机选取的，这将会直接影响到最终结果。尽管如此，整体的趋势（你的结果可能会有所不同）是簇数量为6时，惯性权重进行了最后一次大的调整。

随后惯性权重改变很小，虽然没有明确的标准可言。这样的时刻被称作是拐点（elbow），用图来表示就是曲线的顶点，看起来就像是肘部。有些数据集拐点很明显，但是有的数据集可能没有拐点（它们的图像看起来很平滑）。

根据上述分析，把n_clusters的值设置为6，重新运行算法。

n_clusters = 6
pipeline = Pipeline([('feature_extraction',
  TfidfVectorizer(max_df=0.4)),
                     ('clusterer', KMeans(n_clusters=n_clusters))
                     ])
pipeline.fit(documents)
labels = pipeline.predict(documents)

10.3.3　从簇中抽取主题信息

现在我们把关注点放到各簇上，尝试从中找到每个簇的主题。首先，从特征提取这一步抽取词表。

terms = pipeline.named_steps['feature_extraction'].get_feature_names()

计算每簇所包含的个体数量。

c = Counter(labels)

遍历所有的簇，输出每簇所包含的个体数量。评估结果时，要把簇的大小考虑进去——有的簇可能只有一个个体，因此不能代表新闻趋势。代码如下：

for cluster_number in range(n_clusters):
    print("Cluster {} contains {} samples".format(cluster_number,
c[cluster_number]))

接下来（在循环体内部），遍历该簇最重要的词语。首先，从质心点找出特征值最大的5个特征。代码如下：

    print("  Most important terms")
    centroid = pipeline.named_steps['clusterer'].cluster_centers_
[cluster_number]
    most_important = centroid.argsort()

依次输出这5个特征。

    for i in range(5):

对i取反，因为most_important数组中的最小值排在最前面。

        term_index = most_important[-(i+1)]

然后输出序号、词语和得分。

        print("  {0}) {1} (score: {2:.4f})".format(i+1, terms[term_
index], centroid[term_index]))

结果能较好地反映当时人们关注的焦点。我得到的结果（2015年3月）中，几个簇的主题为健康问题、中东紧张局势、朝鲜半岛紧张局势和俄罗斯相关问题。这些充斥着当时的新闻头条——虽然很多年以来就一直这样！

10.3.4　用聚类算法做转换器

另外提一下，k-means算法（以及其他聚类算法）还有比较有趣的一个特点，就是可以用来简化特征。特征简化的方法有很多种，比如主要成分分析（Principle Component Analysis）、潜在语义索引（Latent Semantic Indexing）等，这些方法还能用来创建新特征，但是它们通常对计算能力要求很高。

在前面的例子中，词表共有14 000个词条——这个数据集很大。我们的聚类算法把它们仅归为6个簇。我们可以使用数据点到质心点的距离作为特征创建一个特征数较之前少得多的数据集。

在k-means实例上调用转换函数。因为流水线最后一步是k-means实例，因此可以在它上面调用转换函数。

X = pipeline.transform(documents)

上面代码在流水线最后一步的k-means实例上调用转换方法。得到的矩阵有六个特征，数据量跟文档的长度相同。

你可以对得到的矩阵进行二次聚类，如果有目标类别的话，也可以用来分类。大致的流程是使用标注好的数据选取特征，用聚类方法把特征数减少到更容易操作的范围内，再用SVM等算法对前面处理过的数据集进行分类。

10.4　聚类融合

第3章研究了如何把几棵效果相对较差的决策树分类器整合在一起形成随机森林，用来处理分类任务。聚类算法也可以进行融合，这样做的主要原因是，融合后得到的算法能够平滑算法多次运行所得到的不同结果。正如我们之前所说，多次运行k-means算法得到的结果因最初选择的质心点不同而不同。多次运行算法，综合考虑所得到的多个结果，可以减少波动。

聚类融合方法还可以降低参数选择对最终结果的影响。大多数聚类算法对参数选择很敏感,参数稍有不同将带来不同的聚类结果。

10.4.1　证据累积

最基本的融合方法是对数据进行多次聚类，每次都记录各个数据点的簇标签。然后计算每两个数据点被分到同一个簇的次数。这就是证据累积算法（Evidence Accumulation Clustering，EAC）的精髓。

证据累积算法两大步骤如下：第一步，使用k-means等低水平的聚类算法对数据集进行多次聚类，记录每一次迭代两个数据点出现在同一簇的频率，将结果保存到共协矩阵（coassociation）中。第二步，使用另外一种聚类算法——分级聚类对第一步得到的共协矩阵进行聚类分析。分级聚类一个比较有趣的特性是，它等价于寻找一棵把所有节点连接到一起的树，并把权重低的边去掉。

遍历所有标签，记录具有相同标签的两个数据点的位置，创建共协矩阵。我们需要用到SciPy的稀疏矩阵csr_matrix。

from scipy.sparse import csr_matrix

注意函数声明中的参数为所有数据点的标签。

def create_coassociation_matrix(labels):

记录具有相同标签的两个数据点的行号和列号，把它们分别存储到rows和cols列表中。稀疏矩阵通常由一系列记录非零值位置的列表组成，csr_matrix就是这种类型的。

    rows = []
    cols = []

标签去重后，再进行遍历。

    unique_labels = set(labels)
    for label in unique_labels:

查找具有某一标签的所有数据点。

        indices = np.where(labels == label)[0]

对于每组标签相同的数据点，记录它们的位置。代码如下：

        for index1 in indices:
            for index2 in indices:
                rows.append(index1)
                cols.append(index2)

在所有循环的外面创建数据集，两个数据点标签相同时，数据集中该位置的值为1。有多少组数据点标签相同，数据集中就有多少个元素为1。代码如下：

    data = np.ones((len(rows),))
    return csr_matrix((data, (rows, cols)), dtype='float')

调用上述函数，传入所有的数据点标签，就能得到共协矩阵。

C = create_coassociation_matrix(labels)

现在我们可以把这些矩阵的多个实例整合起来，多次运行k-means算法，把结果结合起来看。运行C（在记事本新格子里输入C，并运行），查看有多少个非零值。从得到的结果来看，矩阵中大约半数的项有值，我的聚类结果有一个大的簇（各簇的大小越均匀，非零值数量越少）。

下一步对共协矩阵进行分级聚类。我们需要找到该矩阵的最小生成树，删除权重低于阈值的边。

从图的理论角度看，生成树为所有节点都连接到一起的图。最小生成树（Minimum Spanning Tree，MST）即总权重最低的生成树。结合我们的应用来讲，图中的节点对应数据集中的个体，边的权重对应两个顶点被分到同一簇的次数——也就是共协矩阵所记录的值。

下图的MST有6个节点。图中的节点可以在MST中多次使用，唯一的要求是所有的节点应该连接在一起。

我们使用SciPy sparse包的minimum_spanning_tree函数计算MST。

from scipy.sparse.csgraph import minimum_spanning_tree

可以直接在coassociation函数返回的稀疏矩阵上调用mst函数。

mst = minimum_spanning_tree(C)

然而，矩阵C中，值越高表示一组数据点被分到同一簇的次数越多——这个值表示相似度。相反，minimum_spanning_tree函数的输入为距离，高的值反而表示相似度越小。因此，我们对C取反再计算最小生成树。

mst = minimum_spanning_tree(-C)

上述函数返回结果为一个矩阵，大小跟C相同（行列数分别跟数据集样本数量和特征数相同），只不过保留了最小生成树中的边，其他都被删除了。

然后我们删除其边的权重小于阈值的节点。方法是，遍历MST矩阵中的每一条边，删除低于规定值的边。仅对共协矩阵（值为1或0，没有什么可处理的）进行一次遍历无法完成上述任务。因此，我们来创建额外的标签，创建一个新共协矩阵，然后把这两个矩阵相加。代码如下：

pipeline.fit(documents)
labels2 = pipeline.predict(documents)
C2 = create_coassociation_matrix(labels2)
C_sum = (C + C2) / 2

然后再计算MST，删除在这两个矩阵中都没有出现的边。

mst = minimum_spanning_tree(-C_sum)
mst.data[mst.data > -1] = 0

我们需要移除在C1和C2都没有出现的边——也就是值为1的。然而我们刚才在计算时，对C_sum取反，因此，阈值也应该使用相反数。

最后，我们找到所有的连通分支，也就是寻找移除低权重的边以后仍然连接在一起的节点。返回的第一个值为连通分支的数量（也就是有多少个簇），第二个值为每个数据点的标签。代码如下：

from scipy.sparse.csgraph import connected_components
number_of_clusters, labels = connected_components(mst)

我的数据集最终找到了8个簇，每个簇都跟之前差不多。这不足为奇，因为我们仅进行了两轮 k-means迭代，更多的迭代（请见下节）结果差异会更明显。

10.4.2　工作原理

k-means算法不考虑特征的权重，实际上它假定所有的特征取值范围相同。我们在第2章探讨过使用取值范围不同的特征所带来的问题。k-means算法寻找的是圆形簇（circular clusters），如下图所示。

从上图中，我们可以看到不是所有的簇都是圆形。左侧的簇呈圆形，这也是k-means算法很擅长处理的。中间为椭圆形，通过特征规范化（feature scaling），k-means算法可以处理该种聚类问题。最后一幅图甚至不是凸形的——这种形状很奇怪，用k-means算法来处理聚类结果为这种形状的数据集有难度。

证据累积算法的工作原理为重新把特征映射到新空间，上节讲到用k-means来做特征简化，本质上，证据累积算法使用与其相似的原则，每次运行k-means算法都相当于使用转换器对特征进行一次转换。但是我们这里仅使用实际的标签而不是到每个质心点的距离。我们使用存储在共协矩阵中的数据。

证据累积算法只关心数据点之间的距离而不是它们在原来特征空间的位置。对于没有规范化过的特征，仍然存在问题。因此，特征规范很重要，无论如何都要做（我们用tf-idf规范特征值，从而使特征具有相同的值域）。

我们在第9章见过使用SVM内核做类似的转换。这些转换方法很强大，在处理复杂数据集时记得使用。

10.4.3　实现

现在我们来把前面这些整合起来，创建接口跟scikit-learn对得上的简易聚类算法，实现证据累积的两个步骤。首先，使用scikit-learn的ClusterMixin创建证据累积算法类。

from sklearn.base import BaseEstimator, ClusterMixin
class EAC(BaseEstimator, ClusterMixin):

参数分别为第一步k-means算法运行次数（创建共协矩阵），用来删除边的阈值和每次运行k-means算法要找到的簇的数量。指定n_clusters的取值范围，每次运行k-means算法都能得到不同的聚类结果。一般而言，从融合的角度看，每次结果有所不同是件好事。否则，融合多次算法跟只运行一次算法效果相同（但是几个聚类结果之间差距较大也不能表明融合后效果就会好）。代码如下：

    def __init__(self, n_clusterings=10, cut_threshold=0.5, n_
clusters_range=(3, 10)):
        self.n_clusterings = n_clusterings
        self.cut_threshold = cut_threshold
        self.n_clusters_range = n_clusters_range

为EAC类定义fit函数。

    def fit(self, X, y=None):

接着，使用k-means算法进行低水平聚类，把每一次迭代得到的共协矩阵加起来。为了节省内存，我们使用生成器，仅在需要时创建共协矩阵。生成器每迭代一次，就在数据集上跑一个新的k-means实例，然后创建一个共协矩阵。再使用sum方法把得到的多个共协矩阵加起来。代码如下：

        C = sum((create_coassociation_matrix(self._single_
clustering(X))
                 for i in range(self.n_clusterings)))

跟前面一样，我们创建MST，删除低于阈值的边（注意使用相反数），找到连通分支。跟scikit-learn其他fit函数一样，我们都需要返回self，以便流水线的下一个步骤使用。代码如下：

        mst = minimum_spanning_tree(-C)
        mst.data[mst.data > -self.cut_threshold] = 0
        self.n_components, self.labels_ = connected_components(mst)
        return self

接着，编写通过一轮迭代进行聚类的函数。具体方法是，使用numpy的randint函数随机选取几个簇，用参数n_clusters_range指定随机选取的范围。然后使用k-means算法进行聚类和预测数据集属于哪个簇。最后返回由k-means计算得到的簇标签。代码如下：

    def _single_clustering(self, X):
        n_clusters = np.random.randint(*self.n_clusters_range)
        km = KMeans(n_clusters=n_clusters)
        return km.fit_predict(X)

像之前那样创建流水线，只不过在流水线最后一步，使用证据累积算法而不是之前的k-means算法实例，运行代码。代码如下。

pipeline = Pipeline([('feature_extraction', TfidfVectorizer(max_
df=0.4)),
                     ('clusterer', EAC())
                     ])

10.5　线上学习

有时，在开始学习之前，我们没有足够的数据用来进行训练。有时，数据量太大，内存装不下，或一时拿不到数据，或完成预测后，我们又拿到了新数据。以上情况就可以使用线上学习方法，在需要时及时训练模型。

10.5.1　线上学习简介

线上学习是指用新数据增量地改进模型。支持线上学习的算法可以先用一条或少量数据进行训练，随着更多新数据的添加，更新模型。相比之下，不支持线上学习的算法在开始训练之前需要一次性拿到所有数据。标准的k-means算法以及前几章的绝大部分算法都不支持线上学习。

线上学习算法在只有几条新数据的情况下就能做到部分更新已有模型。神经网络算法是支持线上学习的标准例子。随着一条新数据输入到神经网络后，网络中的权重根据学习速率进行更新，学习速率通常为一个很小的值，比如0.01。这表明一条新数据只能对模型带来很小的变动（希望是改进）。

神经网络还可以按照批模式来训练，每次只用一组数据进行训练。批模式，算法运行速度快，但是耗内存较多。

同理，我们可以用一个数据点或少量数据点来轻微更新k-means中的质心点。具体做法是，在k-means算法更新质心点这一步加入学习速率。我们假定这些新数据点是从总体中随机选取的，质心点应该朝它们在原来标准、离线的k-means算法中的位置移动。

线上学习与流式学习（streaming-based learning）有关，但有几个重要的不同点。线上学习能够重新评估先前创建模型时所用到的数据，而对于后者，所有数据都只使用一次。

10.5.2　实现

scikit-learn提供了MiniBatchKMeans算法，可以用它来实现线上学习功能。这个类实现了partial_fit函数，接收一组数据，更新模型。相反，调用fit()将会删除之前的训练结果，重新根据新数据进行训练。

因为MiniBatchKMeans聚类过程跟scikit-learn中其他聚类算法一致，所以创建和使用方法跟其他算法相同。

因此，我们可以使用TfIDFVectorizer从数据集中抽取特征，创建矩阵X。代码如下：

vec = TfidfVectorizer(max_df=0.4)
X = vec.fit_transform(documents)

再来导入MiniBatchKMeans，初始化一个实例。

from sklearn.cluster import MiniBatchKMeans
mbkm = MiniBatchKMeans(random_state=14, n_clusters=3)

接着，随机从X矩阵中选择数据，模拟来自外部的新数据。每次取一些数据，更新模型。

batch_size = 10
for iteration in range(int(X.shape[0] / batch_size)):
    start = batch_size * iteration
    end = batch_size * (iteration + 1)
    mbkm.partial_fit(X[start:end])

在实例上调用predict()方法，获得原始数据集的聚类结果。

labels  = mbkm.predict(X)

然而目前阶段，我们无法在流水线中使用TfIDFVectorizer，因为它不是在线算法。为了解决这个问题，我们使用HashingVectorizer类，它巧妙地使用散列算法极大地降低了计算词袋模型所需的内存开销。我们不是记录特征名字，比如文档中的词，而是仅记录这些名字的散列值。这样，在我们查看数据集之前，就能知道所有的特征，因为我们已经拿到了所有特征的散列值。大约有2¹⁸个散列值，数据量很大，但是使用稀疏矩阵，存储和计算都很容易，因为矩阵中很多项的值都为0。

写作本书时，sickit-learn的Pipeline类无法用于线上学习。不同的应用有着细微差别，这也就表明不存在万全之策，也就无法封装一个通用的流水线类。相反，我们可以创建自己的能够支持线上学习的流水线子类。我们这个类继承自scikit-learn的Pipeline类。

class PartialFitPipeline(Pipeline):

创建partial_fit函数，接收输入矩阵、可选的类别（这里用不到）。

    def partial_fit(self, X, y=None):

我们之前讲过，流水线由一系列转换步骤组成，前一步的输出为下一步的输入。我们把第一个输入设置为X矩阵，接着，用每一个转换器对输入进行转换。

        Xt = X
        for name, transform in self.steps[:-1]:

然后，转换已有的数据集，继续迭代，直到达到最后一步（我们这里为聚类算法）。

            Xt = transform.transform(Xt)

接着，在最后一步调用partial_fit函数，返回结果。

        return self.steps[-1][1].partial_fit(Xt, y=y)

我们现在就可以创建流水线，在线上学习过程使用MiniBatchKMeans和HashingVectorizer。除了新类PartialFitPipeline和HashingVectorizer外，使用线上学习进行聚类分析，其整个过程跟本章其他部分大同小异，只不过每次使用的数据量少多了。

pipeline = PartialFitPipeline([('feature_extraction',
HashingVectorizer()),
                             ('clusterer', MiniBatchKMeans(random_
state=14, n_clusters=3))
                             ])
batch_size = 10
for iteration in range(int(len(documents) / batch_size)):
    start = batch_size * iteration
    end = batch_size * (iteration + 1)
    pipeline.partial_fit(documents[start:end])
labels = pipeline.predict(documents)

当然这种方法也有不足之处。比如，我们很难知道对于每个簇来说哪些词最为重要。如果想知道的话，这就要用到另外一种特征抽取工具CountVectorizer，先取到每个词的散列值，然后通过散列值而不是词本身查找词的重要性。这样做有点麻烦，并且抵消了我们使用HashingVectorizer节省下来的内存。并且，我们也无法使用之前用过的max_df参数，因为它需要知道特征是什么并一直统计它们。

此外，我们无法在线上学习中使用tf-idf权重。虽然我们可以估计并使用权重，但是也很麻烦。HashingVectorizer算法非常有用，是散列算法的精彩应用。

10.6　小结

本章研究了一种无监督学习方法——聚类。无监督学习用来探索数据而不是分类或预测。我们从reddit网站采集到的数据没有类别，所以无法对其进行分类。我们使用k-means算法把新闻报道分成组，以找到数据中隐藏的主题和趋势。

从reddit网站采集网址数据后，我们发现它们指向不同的网站，因此，需要研制一种适用范围广的网页内容抽取方法。抽取数据时，我们只寻找大块的文本，而没有使用成熟的机器学习方法。有一些有趣的机器学习方法可以改善文本抽取效果，详见附录部分本章的补充内容。我在附录中，针对每一章都给出了后续学习方向及实验效果改善方法，还给出了参考资料，并介绍了读者可能感兴趣的难度更大的应用。

本章还探讨了一种很直观的融合算法——证据累积算法。融合算法可用来处理结果之间的差异，尤其是你不知道怎么选取好的参数时（参数选取对于聚类来说就特别难）。

最后，我们介绍了线上学习。这是通向包括大数据在内的大型机器学习算法的入口，接下来两章会讲大数据。后两章的实验规模很大，在学习模型的同时，还要求学会管理数据。

下一章将从无监督学习再度回到分类。我们将研究以复杂神经网络为基础的分类方法——深度学习。

第 11 章　用深度学习方法为图像中的物体进行分类

第8章用到了最基础的神经网络。最近几年，该领域兴起的研究热潮为其带来了一系列长足的进步。如今，神经网络的研究创造出了一些最为先进和精确的分类算法，在很多领域展露锋芒。

计算机性能的提升使得训练更大、更复杂的神经网络成为可能，但该领域之所以能够取得进步，主要不是因为计算性能的提升，而是因为采用了新的算法和神经网络层。

本章研究如何确定图像中的物体，我们使用像素值作为神经网络的输入值，自动找到有用的像素组合，形成更高层级的特征，然后将其用于实际的分类。本章主要内容如下：

为图像中物体分类

不同类型的深度神经网络

Theano、Lasagne和Nolearn：用于创建和训练神经网络的库

用GPU提升算法速度

11.1　物体分类

计算机视觉逐渐成为科技领域的明日之星。未来五年，我们也许就能坐上自动驾驶汽车（甚至可能比这还要提前，如果流传的消息是可信的）。要让计算机来开车，它得能看清周围的环境：障碍物、其他车辆和天气状况等。

虽然计算机借助雷达等技术很容易就能检测到是否有障碍物，但这还不够，它还需要知道障碍物是什么。假如是动物，它们可能很快就会跑开，但如果是建筑物，那就麻烦了，计算机需要控制车辆躲开。

11.2　应用场景和目标

本章将构建一个系统，它接收图像，给出图像里面是什么物体。该系统就好比是自动驾驶汽车的视觉系统，它能发现道路及两侧的任何障碍物。我们使用如下形式的图像。

图像数据来自于著名的CIFAR-10数据集，它含有6万张32像素见方的图像，每个像素都有一个红—绿—蓝（RGB）值。这个数据集已经分为训练集和测试集两部分，我们在训练结束后才会用到测试集，这里先提一下。

　CIFAR10数据集的下载地址为http://www.cs.toronto.edu/~kriz/cifar.html。请下载Python版本，所有图像已经转换为numpy数组。

我们来看下这些图像数据长什么样，打开一个IPython Notebook笔记本文件，设置好文件名。我们开始只用第一批文件，到后面再增加数量使用全部数据集。

import os
data_folder = os.path.join(os.path.expanduser("~"), "Data", "cifar-10-
batches-py")
batch1_filename = os.path.join(data_folder, "data_batch_1")

接着，创建一个函数，读取第一批图像文件数据。这些图像数据文件格式为pickle，pickle是Python用来保存对象的一个库。通常在pickle文件上调用pickle.load方法就能取得保存在里面的数据。但这里有个小问题：这些pickle文件是Python 2生成的，而我们要用Python3打开。所以在打开时需要把编码设置成latin（虽然我们是以字节模式打开）。

import pickle
# Bigfix thanks to: http://stackoverflow.com/questions/11305790/
pickle-incompatability-of-numpy-arrays-between-python-2-and-3
def unpickle(filename):
    with open(filename, 'rb') as fo:
        return pickle.load(fo, encoding='latin1')

使用上述函数加载数据集。

batch1 = unpickle(batch1_filename)

这一批图像文件读进来后为一个字典结构，包含numpy数组形式的图像数据、图像类别、文件名以及该批文件的简短说明（例如，training batch 1 of 5表示训练集共五批，这是第一批）。

以data作为键，从字典batch1中取到存储这一批图像数据的列表后，再用图像索引号就能取到其中一张图像的数据。

image_index = 100
image = batch1['data'][image_index]

每一张图像数据为一个numpy数组，数组共有3072项，每一项为0到255之间的一个值。每个值代表图像某一位置的红、绿或蓝色的颜色强度。

图像数据格式与matplotlib（绘制图像）所使用的有所不同，因此，用它来显示图像前，需要改变数组的形状，对矩阵进行转换。这种数据格式不会影响神经网络训练（我们定义网络时会考虑支持这种数据格式），但是要用matplotlib绘制图像，就需要对数据格式进行转换。

image = image.reshape((32,32, 3), order='F')
import numpy as np
image = np.rot90(image, -1)

然后，就可以用matplotlib绘制图像。

%matplotlib inline

from matplotlib import pyplot as plt
plt.imshow(image)

这是张轮船图像。

图像分辨率很低——只有32像素见方。尽管如此，大部分人还是能看出图中是一艘船。我们能让计算机辨认出来吗？

你可以修改图像索引查看其他图像，了解下数据集的特点。

本章项目的目标是创建分类系统，预测像上面这样的图像的类别。

使用场景

计算机视觉技术应用范围很广。

在线地图网站，比如谷歌地图就使用计算机视觉技术，自动为街景中的人脸添加模糊效果，以保护被拍摄到的人们的隐私。除此之外，还有别的用途。

人脸识别技术在很多行业都有所应用。如今，照相机都能自动识别人脸，以提升拍摄质量（拍照时用户往往把相机聚焦到脸部）。人脸识别技术还可以用来识别照片中的人脸。Facebook就使用了这项技术，方便用户标记朋友。

我们前面也说过，自动驾驶汽车高度依赖计算机视觉来识别道路，躲避障碍物。计算机视觉是很多行业、应用都在努力解决的一个问题，不只是为了研制自动驾驶汽车，也不只是为了满足消费者的需求，采矿业等行业也在寻求攻克计算机视觉技术难关之路。

除此之外，其他行业也在使用计算机视觉技术，比如仓储业自动检测不合格产品就用到该项技术。

航天工业使用计算机视觉技术实现数据采集的自动化。这对于有效使用航天器来说至关重要，因为从地球上发送信号到火星上的探测车要花费很长时间，有时还会无法送达（例如，地球和火星不是面对面时）。人们跟太空探测器打交道越来越多，而从遥远的地球控制它们很不方便，增加这些设备的自主性就显得很有必要。

下图为美国国家航空和航天局（NASA）设计的火星车，它大量应用了计算机视觉技术。

11.3　深度神经网络

从理论角度看，第8章所使用的神经网络有几个显著的特点。例如，只需要一层隐含层来学习任何类型的映射（虽然隐含层可能规模很大）。神经网络在20世纪七八十年代很火，但随后人们不再使用它们，尤其是跟支持向量机等其他分类算法相比，神经网络被冷落了。首要问题是，运行多个神经网络比其他算法对计算能力有更高的要求，超出了当时计算机的能力范围。

其次是训练神经网络所需工作量很大。虽然那时反向传播算法已经研制出来，处理大型神经网络仍存在问题，需要大量的训练才能确定权重。

这两个问题在近些年都得到了解决，神经网络迎来了又一个春天。计算能力比起30年前更容易获得，训练算法的改进使得在现有计算能力的情况下，能够顺利完成大型神经网络的训练。

11.3.1　直观感受

深度神经网络和第8章中的基本神经网络的差别在于规模大小。至少包含两层隐含层的神经网络被称为深度神经网络。实际工作中遇到的深度神经网络通常规模很大，每层神经元数量和层次都非常多。2000年年中的一些研究，关注的是层次数量多的深度神经网络，而更巧妙的算法能够减少实际所需要的层数。

神经网络接收很基础的特征作为输入——就计算机视觉而言，输入为简单的像素值。 神经网络算法把这些数据整合起来向网络中传输，在这个过程中，基本的特征组合成复杂的特征。有时，这些组合特征对我们来说没有什么含义，但是它们表示个体某些方面的特征，计算机依靠它们进行分类。

11.3.2　实现

由于深度神经网络规模很大，实现起来很有挑战。实现不好，运行时间会很长，甚至还会出现由于内存不足，最后无法运行的情况。

神经网络的基本实现可以从创建神经元类开始，多个神经元组成层类，每一层的神经元使用边这个类的一个实例连接到另一层神经元。这种基于类的实现，有助于显示网络的工作方式，但是对于创建大型网络，效率较低。

神经网络的核心其实就是一系列矩阵运算。两个网络之间连接的权重可以用矩阵来表示，其中行表示第一层的神经元，列表示第二层神经元（有时会用到该矩阵的转置矩阵）。矩阵的每一个元素表示位于不同层的两个神经元之间连接的权重。一个神经网络就可以用一组这样的矩阵来表示。除了神经元外，每层增加一个偏置项，它是一个特殊的神经元，永远处于激活状态，并且跟下一层的每一个神经元都有连接。

对神经网络有了上述这般深入理解，我们就可以使用数学运算创建、训练和使用神经网络，比起前面用类来实现，效率要更高。就矩阵运算而言，有很多非常好用的库，其代码经过充分优化，借助它们可以高效完成矩阵运算。

第8章使用的PyBrain还实现了神经网络的卷积层。但是我们这里要用到的一些功能，它没有提供。对于规模更大，定制化程度更高的神经网络，我们需要更强大的库。因此，我们选用Lasagne和nolearn两个库。这两个库依赖于擅长处理数学表达式的Theano库。

本章首先通过用Lasagne实现一个基础的神经网络，来介绍相关概念。然后，使用nolearn库重新做第8章中的字母识别实验。最后，使用更为复杂的卷积神经网络对CIFAR数据集进行图像分类，为了提升性能我们使用GPU而不是CPU运行算法。

11.3.3　Theano简介

Theano是用来创建和运行数学表达式的工具。它用起来乍一看跟编写程序没有差别，但是在Theano中，我们定义函数要做什么而不是怎么做，这样Theano就能以最佳的方式对表达式进行求值，还可以进行延迟计算——只在需要时，对表达式求值，而不是定义时。

多数程序员不会每天都使用这种类型的编程范式，但是他们几乎天天接触使用这种编程范式的系统。关系型数据库，尤其是SQL类，就用到了叫作声明型范式（declarative paradigm）的概念。比如程序员定义了含有WHERE子句的SELECT查询语句。数据库解释查询语句，并根据一系列因素创建优化过的查询语句，数据库要考虑的因素有WHERE子句是否建立在主键之上、数据存储格式等。程序员决定他们要什么，系统决定怎么做。

　你可以使用pip安装Theano：pip3 install Theano。

我们可以用Theano来定义函数，处理标量（scalars）、数组和矩阵及其他数学表达式。例如，我们可以创建计算直角三角形斜边长度的函数。

import theano
from theano import tensor as T

首先，定义两个输入a和b，它们为简单的数值类型，因此使用标量。

a = T.dscalar()
b = T.dscalar()

接着，定义输出c，它为由a和b构成的一个表达式。

c = T.sqrt(a ** 2 + b ** 2)

注意，上述c既不是函数，也不是一个数值——它是由a和b组成的表达式。a和b不是一个确切的值——这是一个代数式，最终结果不确定。为了计算这个表达式，我们来定义一个函数。

f = theano.function([a,b], c)

上述语句告诉Theano创建一个函数，这个函数接收a、b，返回经过计算得到的结果，也就是输出值c。例如，f(3, 4)，返回5。

虽然上述例子看起来可能不比Python普通函数强大多少，但是我们现在就在代码其他部分和后面的映射关系中使用我们的函数或数学表达式c。此外，虽然我们在创建函数之前，就定义了表达式，其实直到调用函数时，才会对表达式进行计算。

11.3.4　Lasagne简介

Theano库不是用来创建神经网络的，就好比numpy库不是用来执行机器学习任务的，而是被别的库使用，来出卖苦力的。Lasagne库是专门用来构建神经网络的，它使用Theano进行计算。

Lasagne实现了几种比较新的神经网络层和组成这些层的模块，具体介绍如下。

内置网络层（Network-in-network layers）：这些小神经网络比传统的神经网络层更容易解释。

删除层（Dropout layers）：训练过程随机删除神经元，防止产生神经网络常见的过拟合问题。

噪音层（Noise layers）：为神经元引入噪音，也是为了解决过拟合问题。

本章使用卷积层（convolution layers，层级结构模拟人类视觉工作原理）。卷积层使用少量相互连接的神经元，分析一部分输入值（比如我们这里的一张图像），便于神经网络实现对数据的标准转换，比如对图像数据的转换。视觉分析实验，就是用卷积层对图像进行转换1。

1原文为translating the image。——译者注

比较而言，传统的神经网络内部连接十分紧密——一层的所有神经元全都连接到下一层所有神经元。

lasagne.layers.Conv1DLayer和lasagne.layers.Conv2DLayer classes两个类实现了卷积网络。

写作本书时，Lasagne还没有正式版，资源没有上传到pip站点，因此无法用pip安装。但是可以从github安装。把Lasagne代码仓库克隆到本地新建的文件夹中：git clone https://github.com/Lasagne/Lasagne.git。进入到Lasagne文件夹，使用下面命令安装：

sudo python3 setup.py install

安装指南请见http://lasagne.readthedocs.org/en/latest/user/installation.html。

神经网络使用卷积层（一般而言，仅卷积神经网络包含该层）和池化层（pooling layer），池化层接收某个区域最大输出值，可以降低图像中的微小变动带来的噪音，减少（down-sample，降采样）信息量，这样后续各层所需工作量也会相应减少。

Lasagne还实现了池化层——比如lasagne.layers.MaxPool2DLayer类。再加上前面的卷积层，现在我们准备好了创建卷积神经网络所需的全部部件。

在Lasagne中创建神经网络比起只用Theano更加容易。我们通过实现一个简单的卷积神经网络，介绍相关规则。我们再次使用第1章所用到的Iris数据集，该数据集很适合用来测试新算法，即使是复杂的深度神经网络也没问题。

首先，打开一个新的笔记本文件。前面加载CIFAR数据集所使用的笔记本文件，先搁一边，后面还会用。

加载Iris数据集。

from sklearn.datasets import load_iris
iris = load_iris()
X = iris.data.astype(np.float32)
y_true = iris.target.astype(np.int32)

Lasagne对数据类型有特殊要求，因此，需要把类别值转换为int32类型（在原始数据集中用int64类型存储）。

把数据集分为训练集和测试集两部分。

from sklearn.cross_validation import train_test_split
X_train, X_test, y_train, y_test = train_test_split(X, y_true, random_
state=14)

接着，分别创建卷积神经网络各层。我们的数据集有四个特征、三个类别，这样第一层和最后一层神经元数量就确定了，但是中间层多大呢？中间层大小不同，最终结果也会不同，尝试使用不同的值，看看结果会有怎样的变化。

首先，创建输入层，其神经元数量跟数据集特征数量相同。可以指定每一批输入的数量（设置为10），这样Lasagne可以在训练阶段做些优化。

import lasagne
input_layer = lasagne.layers.InputLayer(shape=(10, X.shape[1]))

接着，创建隐含层。该层从输入层接收输入（由第一个参数指定），该层共有12个神经元，使用非线性的sigmoid函数，我们在第8章曾经介绍过。

hidden_layer = lasagne.layers.DenseLayer(input_layer, num_units=12,
nonlinearity=lasagne.nonlinearities.sigmoid)

接下来，创建输出层，它接收来自隐含层的输入，输出层共有三个神经元（跟类别的数量一致），使用非线性的softmax函数，该函数主要用于神经网络的最后一层。

output_layer = lasagne.layers.DenseLayer(hidden_layer, num_units=3,
                                    nonlinearity=lasagne.
nonlinearities.softmax)

依照Lasagne的习惯用法，输出层为我们的神经网络。当我们输入一条数据到神经网络时，它查看输出层，向上回溯找到向输出层提供输入的那一层（第一个参数）。这个过程重复进行直到到达输入层，因为输入层没有上一层，所以就把要处理的数据交给输入层处理。输入层的激活函数把接收到的数据处理后输出给调用它的层（我们这里是隐含层），然后再一步步在网络中传播直到输出层。

为了训练刚创建的网络，我们需要定义几个Theano训练函数。在这之前，需要定义一个Theano表达式和函数。我们先来为神经网络的输入数据、输出结果和实际输出结果声明变量。

import theano.tensor as T
net_input = T.matrix('net_input')
net_output = output_layer.get_output(net_input)
true_output = T.ivector('true_output')

接着，定义损失函数，训练函数如何提升网络效果需要参考它的返回值——训练神经网络时，以最小化损失函数的返回值为前提。我们用类别交叉熵（categorical cross entropy）表示损失，这是一种衡量分类数据（categorical data）分类效果好坏的标准。损失函数表示的是网络的期望输出和实际输出两者之间的差距。

loss = T.mean(T.nnet.categorical_crossentropy(net_output,
  true_output))

接着，定义修改网络权重的函数。我们需要获取到网络的所有参数，创建调整权重的函数（使用Lasagne提供的工具），使得损失降到最小。

all_params = lasagne.layers.get_all_params(output_layer)
updates = lasagne.updates.sgd(loss, all_params, learning_rate=0.1)

最后，创建两个Theano函数，先是训练网络，然后获取网络的输出，以用于后续测试。

import theano
train = theano.function([net_input, true_output], loss,
updates=updates)
get_output = theano.function([net_input], net_output)

然后调用训练函数，在训练集上进行一轮迭代，接收训练数据，预测类别，与给定类别作比较，更新特征权重，以最小化损失。然后再进行1000次迭代，逐渐改进神经网络。

for n in range(1000):
    train(X_train, y_train)

接着，对输出计算F值，以评估分类效果。首先获取到所有输出。

y_output = get_output(X_test)

　注意，get_output为Theano函数，是我们从神经网络中获取到的，因此，上述代码不用再把神经网络作为参数传进去。

y_output为输出层每个神经元的激励作用（activation）大小。找出激励作用最高的神经元，就能得到预测结果。

import numpy as np
y_pred = np.argmax(y_output, axis=1)

数组y_pred为预测结果数组，跟我们在前面分类任务中用到的相同。然后，我们就可以计算F1值。

from sklearn.metrics import f1_score
print(f1_score(y_test, y_pred))

结果异常完美——1.0！这表明我们的算法为测试集中所有数据正确分类，这个结果太棒了（虽然数据集有点小）。

从上面我们可以看到，仅用Lasagne库也能创建、训练一个神经网络，但是有点麻烦。我们可以使用nolearn库，对上述过程做进一步封装，使它很容易就能与scikit-learn接口对得上。

11.3.5　用nolearn实现神经网络

nolearn对Lasagne进行了封装，虽然比起用Lasagne创建神经网络，使用nolearn无法对一些细节进行调整，但是代码可读性更强，也更容易管理。

nolearn实现了几种常见的复杂程度很高的神经网络，应该能够满足你的需求。如果你想拥有更多的控制权，可以回头继续使用Lasagne，但是创建和训练过程需要你多花点心思。

为了快速了解nolearn，我们再次来实现第8章中识别图像中字母的实验。我们重建在第8章所创建的密集神经网络（dense neural network）。再次在笔记本文件输入创建数据集的代码。这些代码的具体含义，请参考第8章。

import numpy as np
from PIL import Image, ImageDraw, ImageFont
from skimage.transform import resize
from skimage import transform as tf
from skimage.measure import label, regionprops
from sklearn.utils import check_random_state
from sklearn.preprocessing import OneHotEncoder
from sklearn.cross_validation import train_test_split

def create_captcha(text, shear=0, size=(100, 24)):
    im = Image.new("L", size, "black")
    draw = ImageDraw.Draw(im)
    font = ImageFont.truetype(r"Coval.otf", 22)
    draw.text((2, 2), text, fill=1, font=font)
    image = np.array(im)
    affine_tf = tf.AffineTransform(shear=shear)
    image = tf.warp(image, affine_tf)
    return image / image.max()

def segment_image(image):
    labeled_image = label(image > 0)
    subimages = []
    for region in regionprops(labeled_image):
        start_x, start_y, end_x, end_y = region.bbox
        subimages.append(image[start_x:end_x,start_y:end_y])
    if len(subimages) == 0:
        return [image,]
    return subimages

random_state = check_random_state(14)
letters = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
shear_values = np.arange(0, 0.5, 0.05)

def generate_sample(random_state=None):
    random_state = check_random_state(random_state)
    letter = random_state.choice(letters)
    shear = random_state.choice(shear_values)
    return create_captcha(letter, shear=shear, size=(20, 20)),
letters.index(letter)
dataset, targets = zip(*(generate_sample(random_state) for i in
range(3000)))
dataset = np.array(dataset, dtype='float')
targets =  np.array(targets)

onehot = OneHotEncoder()
y = onehot.fit_transform(targets.reshape(targets.shape[0],1))
y = y.todense().astype(np.float32)

dataset = np.array([resize(segment_image(sample)[0], (20, 20)) for
sample in dataset])
X = dataset.reshape((dataset.shape[0], dataset.shape[1] * dataset.
shape[2]))
X = X / X.max()
X = X.astype(np.float32)

X_train, X_test, y_train, y_test = \
    train_test_split(X, y, train_size=0.9, random_state=14)

神经网络由一系列的层组成。在nolearn中实现神经网络，只需定义它由Lasagne哪几种类型的层组成就行，跟PyBrain做法大同小异。第8章所创建的神经网络使用的是全连通密集层。nolearn有相应的实现，这表明我们可以用nolearn实现的各层来复制第8章神经网络的基本结构。首先，创建由输入层、密集隐含层和密集输出层组成的层级结构。

from lasagne import layers
layers=[
        ('input', layers.InputLayer),
        ('hidden', layers.DenseLayer),
        ('output', layers.DenseLayer),
        ]

导入以下所需模块，用到时再解释它们的作用。

from lasagne import updates
from nolearn.lasagne import NeuralNet
from lasagne.nonlinearities import sigmoid, softmax

接着，定义神经网络，它与scikit-learn估计器兼容。

net1 = NeuralNet(layers=layers,

注意上面代码没加右半边括号——我们有意为之。输入神经网络的参数，先从每层的大小开始吧。

    input_shape=X.shape,
    hidden_num_units=100,
    output_num_units=26,

上述参数跟每一层相对应。换句话说，input_shape参数就会去查找名称为input的输入层，这跟在流水线中设置参数的工作原理很相似。

接着，定义非线性函数。跟之前一样，隐含层使用sigmoid函数，输出层使用softmax函数。

    hidden_nonlinearity=sigmoid,
    output_nonlinearity=softmax,

接下来，指定偏置神经元，它们位于隐含层，一直处于激活状态。偏置神经元对于网络的训练很重要，神经元激活后，可以对问题做更有针对性的训练，以消除训练中的偏差。举个简单的例子，如果预测结果总是偏差4，我们可以使用-4偏置值抵消偏差。我们设置的偏置神经元就能起到这样的作用，训练得到的权重决定了偏置值的大小。

偏置项为一组权重值，它所包含的元素数量应与它所连接的层的大小相同。

    hidden_b=np.zeros((100,), dtype=np.float32),

接下来，定义神经网络的训练方式。我们在第8章中用到的训练方法，nolearn没有与之完全相同的，因为nolearn没有削减权重的方法。然而，它的确有冲量（momentum），我们这里使用一个高学习速率和低冲量值。

    update=updates.momentum,
    update_learning_rate=0.9,
    update_momentum=0.1,

接下来，我们把分类问题定义为回归问题。这看起来有点奇怪，因为这是分类任务。然而，别忘了输出结果是一组数值，在训练阶段把它当成回归问题进行优化比起分类问题效果更好。

    regression=True,

最后，最大训练步数（epoch）设置为1000，这样既能保证训练效果，又不至于花太长时间（对于我们数据集很合适，但其他数据集训练步数会有所差异）。

    max_epochs=1000,

终于可以把创建神经网络这个函数的右半边括号加上了。

)

接着，在训练集上训练网络。

net1.fit(X_train, y_train)

现在，我们来评估训练得到的网络。首先，获取神经网络的输出，跟Iris分类一样，我们需要使用argmax方法找到输出值中最大的一项，然后找到它所对应的类别。

y_pred = net1.predict(X_test)
y_pred = y_pred.argmax(axis=1)
assert len(y_pred) == len(X_test)
if len(y_test.shape) > 1:
    y_test = y_test.argmax(axis=1)
print(f1_score(y_test, y_pred))

结果同样振奋人心——在我的计算机上，再次全部预测正确。然而，你可能会得到不同的结果，因为nolearn库具有一定的随机性，眼下无法直接控制。

11.4　GPU优化

神经网络体积可能会增长得很大，这意味着需要更多内存；然而，使用稀疏矩阵这样高效的存储方式把整个神经网络装进内存一般不会遇到问题。

神经网络体积增长带来的主要问题是计算时间增加。此外，对于有些数据集，神经网络需要更多的训练步数才能较好地拟合数据集。本章所创建的神经网络在我性能还不错的计算机上，每完成一步训练需要八分多钟，而我们需要运行数十步甚至成百上千步训练。体积更大的神经网络，每一步训练需要几个小时，而为了取得最佳效果，可能需要成千上万步训练。

多长时间才能完成训练啊！

好在神经网络最核心的计算类型主要为浮点型运算。此外，由于神经网络训练过程主要为矩阵操作，大量运算都可以并行处理。这些因素表明用GPU进行计算，能提升训练速度。

11.4.1　什么时候使用GPU进行计算

GPU的设计初衷是用于图像渲染。这些图像用矩阵及由矩阵组成的数学方程式来表示，它们被转换为我们在屏幕上看到的像素。这个过程涉及大量并行计算。如今的CPU很可能是多核（你的计算机可能有2、4，甚至16个核——或更多！），GPU却拥有专门用于图像处理的成千上万个小核。

CPU每个核单独工作速度往往更快，访问计算机内存等操作效率更高，因而适合处理序列化任务。说实话，让CPU出苦力更容易，因为几乎所有的机器学习库默认使用CPU，如果要用GPU做并行计算，需要很多额外工作，但是它所带来的好处也是显而易见的。

有些任务，包含大量数字运算，但计算量都不大，可以同时处理，这样的任务最好交由GPU来处理。很多机器学习任务都具有类似的特点，因此使用GPU处理能提升算法运行速度。

让代码在GPU上跑起来可不简单，中间可能会遇到各种各样的挫折，具体与GPU类型、配置方式、操作系统相关，有时你可能还需要修改计算机底层设置。

使用GPU运算，主要方法有以下三种。

第一种，使用现有计算机。了解你的计算机型号，查找GPU、操作系统相关的软件和驱动程序，寻找相关教程，软件、教程是否能用与操作系统相关。不过，现在使用GPU，比前几年容易多了，有更多更好的软件和驱动程序来启动GPU计算。

第二种，选定计算机系统，找到关于如何设置系统的文档，购买一台装有这种系统的新计算机，买的系统很好用，但是可能相当昂贵——如今的计算机，GPU是价格最高的部件之一。尤其是你对性能有更高要求的话——你需要一个价格不菲的好GPU。

第三种，使用专门为GPU计算而配置的虚拟机。例如，Markus Beissinger在亚马逊的AWS上搭建了一个这样的系统，该系统不是免费的，你得付费才能使用它提供的服务，但是比起购买一台新计算机要便宜得多。具体费用由你所在的区域、使用的系统类型和资源消耗数量来决定，你可能每小时花不到1美元，往往比这还要少得多。如果你使用AWS的竞价型实例（spot instance），每小时只需几美分（然而你需要单独开发在竞价型实例上运行的代码）。

如果你无法承担虚拟机开销，我建议你使用上面提到的第一种方法，即使用现有的计算机。你还可以试试看能不能从隔段时间就要换装备的家人或朋友那里找到二手GPU（爱玩游戏的朋友很可能有哦！）。

11.4.2　用GPU运行代码

我们这里将使用上述第三种方法，在Markus Beissinger系统的基础上创建一个虚拟机，该虚拟机运行在亚马逊的EC2云平台上。除了EC2，还有很多其他Web服务可供使用，但方法可能跟这里有所不同。我会讲下如何在亚马逊云平台上搭建虚拟机。

如果你打算使用自己的计算机，并且已经配置好，可以用GPU进行计算了，请跳过该节。

　关于如何进行设置的更多信息，请见http://markus.com/installtheano-on-aws/，也许还提供在其他计算机上启用GPU计算的方法。

下面，看下如何在AWS上创建虚拟机。首先，访问AWS的登录界面。

https://console.aws.amazon.com/console/home?region=us-east-1

用你的AWS账号登录。如果没有，得先创建一个才能继续下面的操作。

接着，访问EC2云平台的控制台：https://console.aws.amazon.com/ec2/v2/home?region=us-east-1。

点击Launch Instance，从右上方的下拉菜单中选择N. California作为目的地。

点击Community AMIs，搜索ami-b141a2f5，这就是Markus Beissinger创建的系统。然后，点击Select。下一屏中的机器类型选择g2.2xlarge，点击Review and Launch。在下一屏，点击Launch。

这个时候，AWS开始向你收费，所以使用完毕后请关机。从EC2云平台选择你刚创建的云主机，先停止运行。机器处于停机状态，不需要支付费用。

系统会给出如何连接虚拟机的相关信息。如果你之前没有使用过AWS，你可能需要创建密钥以安全连接虚拟机。创建时，需要指定密钥的名称，下载.pem格式的密钥文件，将其保存到一个安全地方——一旦丢失，你将无法登录自己的虚拟机！

点击Connect，了解如何使用密钥文件连接虚拟机。你很可能是用如下ssh命令登录。

ssh -i <certificante_name>.pem ubuntu@<server_ip_address>

11.5　环境搭建

连接到虚拟主机后，你可以安装最新的Lasagne和nolearn库。

首先，使用git命令克隆Lasagne代码仓库，本章前面用过。

git clone https://github.com/Lasagne/Lasagne.git

在虚拟机上编译这个库，需要用到Python 3的setuptools，我们可以用Ubuntu常用的应用和包管理命令apt-get来安装；我们还需要numpy的开发版。

在虚拟机的命令行运行下述命令。

sudo apt-get install python3-pip python3-numpy-dev

接着，安装Lasagne。首先，切换到源代码所在的目录，然后运行setup.py完成安装。

cd Lasagne
sudo python3 setup.py install

　我们已经安装了Lasagne，还需要安装nolearn，为了简单起见，把它俩都作为系统包来安装。考虑到可移植性，建议你使用virtualenv安装这些包，这样你可以在同一台虚拟机上使用不同版本的Python和第三方包，把代码移植到其他机器上也更容易。更多信息请见http://docs.pythonguide.org/en/latest/dev/virtualenvs/。

Lasagne编译好后，我们就可以安装nolearn。切换到你自己的主目录，照下面的步骤来，跟前面安装Lasagne的步骤相同，只不过要注意这次克隆的是nolearn。

cd ~/
git clone https://github.com/dnouri/nolearn.git
cd nolearn
sudo python3 setup.py install

我们的环境差不多搭建好了。还需要安装scikit-learn、scikit-image库，这两个库都可以用pip3安装，这些库所依赖的scipy和matplotlib也没有安装。建议使用apt-get来安装scipy和matplotlib，因为使用pip3可能会遇到比较棘手的问题。

sudo apt-get install python3-scipy python3-matplotlib
sudo pip3 install scikit-learn scikit-image

接着，需要把代码搬到虚拟机上，方法有很多，最简单的莫过于复制粘贴。

首先，打开前面用过的笔记本文件（在你本地的计算机上，不是在亚马逊的虚拟机上）。笔记本文件顶部有一个菜单。点击File，然后再点击Download as，选择Python，将其保存到本地计算机上。该步骤将笔记本文件保存为可以从命令行运行的.py文件。

打开.py文件（有些操作系统，你可能需要右键单击该文件，从弹出的菜单中选择用文本编辑器打开）。文件打开后，选择所有的内容，将其复制到剪贴板。

回到亚马逊的虚拟主机，切换到主目录，用nano文本编辑器打开一个新文件。

cd ~/
nano chapter11script.py

nano为命令行文本编辑器，上述命令将在命令行打开新文件。

文件打开后，把剪贴板的内容粘贴到里面。在有些系统中，你可能需要借助ssh程序的文件操作命令来完成文件上传，而不是按快捷键Ctrl+V粘贴。

在nano中，按下Ctrl+O保存文件，再按Ctrl+X退出nano。

你还需要字体文件。最简单的方法是从原地址再下载一遍，具体步骤如下。

wget http://openfontlibrary.org/assets/downloads/bretan/680bc56bbeeca9535
3ede363a3744fdf/bretan.zip
sudo apt-get install unzip
unzip -p bretan.zip Coval.otf > Coval.otf

上述代码用unzip命令解压Coval.otf文件（压缩包里有大量的zip文件，我们用不到）。

在虚拟机的命令行里输入以下命令运行程序。

python3 chapter11script.py

程序将会像在IPython Notebook笔记本里那样运行，把结果输出到命令行。

结果应该跟之前一致，但是神经网络的训练和测试比之前更快。程序其他方面可能没那么快——生成验证码数据集部分没有使用GPU，因此速度不会提升。

　你可能希望关闭亚马逊虚拟主机来省点钱，本章最后运行大实验的相关程序时会再次用到虚拟机，我们接下来先在本地计算机上进行开发。

11.6　应用

回到本地计算机，打开本章创建的笔记本文件——我们用来加载CIFAR数据集的。接下来的大实验将使用CIFAR数据集，创建深度卷积神经网络，然后在虚拟机上使用GPU来运行。

11.6.1　获取数据

首先，用CIFAR图像创建数据集。跟之前不同的是，保留像素结构——像素的行列号。先把所有批次的图像文件名存储到列表中。

import numpy as np
batches = []
for i in range(1, 6):
    batch_filename = os.path.join(data_folder, "data_batch_{}".
format(i))
    batches.append(unpickle(batch1_filename))
    break

上面最后一行的break语句是用来测试代码——这会极大地减少训练数据，便于你迅速了解代码是否能正常运行。测试过代码能正常工作后，我会提示你删掉这一行。

接着，把每批次的图像文件依次添加到数据集里。我们用到了NumPy的vstack方法，你可以把它看作是往数组末尾追加一行数据。

X = np.vstack([batch['data'] for batch in batches])

然后，把像素值归一化，并将其强制转换为32位浮点型数据（这是使用GPU进行计算的虚拟机唯一支持的数据类型）。

X = np.array(X) / X.max()
X = X.astype(np.float32)

类别数据处理方法相同，只不过我们使用hstack，它为数组末尾追加一列数据。然后使用OneHotEncoder把它转换为只含有一位有效编码的数组。

from sklearn.preprocessing import OneHotEncoder
y = np.hstack(batch['labels'] for batch in batches).flatten()
y = OneHotEncoder().fit_transform(y.reshape(y.shape[0],1)).todense()
y = y.astype(np.float32)

接下来，把数据集切分为训练集和测试集。

X_train, X_test, y_train, y_test = train_test_split(X, y, test_
size=0.2)

调整数组形状以保留原始图像的数据结构。图像原本是32像素见方，每个像素由三个值组成（分别表示红、绿、蓝的颜色值）。

X_train = X_train.reshape(-1, 3, 32, 32)
X_test = X_test.reshape(-1, 3, 32, 32)

我们现在准备好了训练集、测试集以及目标类别，可以创建分类器了。

11.6.2　创建神经网络

我们使用nolearn库创建神经网络，步骤跟我们上面重做第8章实验时所用到的一致。

首先，创建神经网络的各层。

from lasagne import layers
layers=[
        ('input', layers.InputLayer),
        ('conv1', layers.Conv2DLayer),
        ('pool1', layers.MaxPool2DLayer),
        ('conv2', layers.Conv2DLayer),
        ('pool2', layers.MaxPool2DLayer),
        ('conv3', layers.Conv2DLayer),
        ('pool3', layers.MaxPool2DLayer),
        ('hidden4', layers.DenseLayer),
        ('hidden5', layers.DenseLayer),
        ('output', layers.DenseLayer),
        ]

最后三层使用密集层，但是前面使用三组卷积层和池化层。此外，我们（必须）以输入层开始。这样一共有10层。输入数据跟数据集同型，而不只跟输入层的神经元数量相等，输入层和输出层大小仍由数据集来定，跟前面讲过的相同。

开始创建神经网络（注意下面第二行代码没有结束，先不要加右半边括号）。

from nolearn.lasagne import NeuralNet
nnet = NeuralNet(layers=layers,

指定输入数据的形状，跟数据集形状一致（每个像素由三个值组成，图像32像素见方）。第一个值None表示nolearn每次使用默认数量的图像数据进行训练——它将立即用这些数据进行训练，降低算法运行时间。设置为None，避免硬编码，算法使用起来更灵活。

                input_shape=(None, 3, 32, 32),

　如果你想每次使用不同的数据量进行训练，就需要创建BatchIterator 实例。感兴趣的读者可参考如下文件：https://github.com/dnouri/nolearn/blob/master/nolearn/lasagne.py，了解NeuralNet类中batch_iterator_train和batch_iterator_test参数是如何设置的。

接着，设置卷积层的大小。没有严格的规则可遵守，但是我发现一开始用下面这些值就不错。

                conv1_num_filters=32,
                conv1_filter_size=(3, 3),
                conv2_num_filters=64,
                conv2_filter_size=(2, 2),
                conv3_num_filters=128,
                conv3_filter_size=(2, 2),

filter_size参数规定卷积层用于查看图像窗口的大小。此外，还要设置池化层的大小。

                pool1_ds=(2,2),
                pool2_ds=(2,2),
                pool3_ds=(2,2),

然后，设置两层隐含层（倒数第三层和倒数第二层）、输出层的大小，输出层大小跟数据集类别数量相等。

                hidden4_num_units=500,
                hidden5_num_units=500,
                output_num_units=10,

最后一层需要设置非线性函数，还是用softmax。

                output_nonlinearity=softmax,

还要设置学习速率和冲量。据经验来看，随着数据量的增加，学习速率应该下降。

                update_learning_rate=0.01,
                update_momentum=0.9,

跟之前一样，把regression参数设置为True，训练步数设置为很小的值3，因为我们创建的这个神经网络运行时间相对较长。神经网络确定能正常运行之后，可以尝试增加训练步数以得到更好的模型，但是可能要花一两天时间（甚至更长！）来训练它。

                regression=True,
                max_epochs=3,

最后一个参数verbose设置为1，这样每步训练都会输出结果，以便于我们了解模型的训练进度以及它是否在运行。此外，还能输出每一步训练所花的时间，这个值变化不大，因此用每步训练所花时间乘以剩余训练步数就能算出总共还需要多长时间才能完成训练。

                verbose=1)

11.6.3　组装起来

现在就可以在训练集上训练我们刚创建的神经网络。

nnet.fit(X_train, y_train)

这可真是要费点时间了，即使我们只使用了部分训练数据，并且还限制了训练步数。一旦代码运行结束，测试方法跟之前一样。

from sklearn.metrics import f1_score
y_pred = nnet.predict(X_test)
print(f1_score(y_test.argmax(axis=1), y_pred.argmax(axis=1)))

结果很糟糕——本应如此！我们没有经过充分训练——只进行了三轮迭代，且只使用了1/5的数据。

首先，回过头去删除创建数据集代码中的break语句（在遍历每批数据的for循环中）。这样就能使用所有数据而不只是部分数据进行训练。

接着，在神经网络的定义中，把训练步数改为100。

现在，把代码粘贴到虚拟机上。跟之前一样，点击File | Download as，选择Python，把代码保存为.py文件。启动、连接到虚拟机，像之前把代码那样粘贴过去（我把这里的.py文件命名为chapter11cifar.py——如果你使用其他名字，请记得在下面代码中做相应改动）。

接下来，我们需要把数据集捣腾到虚拟机上。最简单的方法是在虚拟机的命令行输入以下命令。

wget http://www.cs.toronto.edu/~kriz/cifar-10-python.tar.gz

下载数据集，新建Data文件夹，把数据文件解压到Data文件夹中。

mkdir Data
tar -zxf cifar-10-python.tar.gz -C Data

最后，使用下面命令运行实验代码。

python3 chapter11cifar.py

你首先注意到的将是速度大幅提升。在我的本地计算机上，每步训练需要100秒以上。在启用GPU的虚拟机上，每步训练只需16秒！在我本地计算机上进行100步训练，需要近3个小时，而虚拟机只需要26分钟。

速度上的大幅提升使得我们可以快速测试不同模型的效果。对于机器学习算法调试来说，一个算法的计算复杂性问题不大，只要几秒钟、几分钟，多则几个小时就可以运行完。因此，只有一个模型，训练时间长点短点不会有太大问题——特别是大多数机器学习算法都能很快给出预测结果，这也正是为什么大多数情况都使用一个模型。

然而，要调试多个参数时，你就需要训练成千上万个模型，各模型之间参数差异很小——速度提升问题就变得很重要。

26分钟后，完成100步训练，得到最终的输出结果。

0.8497

结果还不错！我们可以增加训练步数，进一步改善效果或者也可以尝试修改参数值，增加隐含层节点、卷积层的数量，或者多使用一层密集层。也可以尝试用Lasagne其他类型的层，虽然一般来讲，卷积层更适合处理视觉问题。

11.7　小结

本章讲解了深度神经网络，为了处理计算机视觉问题，着重讲解卷积网络。我们使用Lasagne和nolearn库来构建神经网络，很多数据处理工作交由Theano来做。用nolearn提供的工具构建神经网络相对比较容易。

卷积神经网络专门用来处理计算机视觉问题，所以最后得到的分类结果很精确也不足为奇。最终结果表明，随着算法和计算能力的提升，计算机视觉的应用前景也更为广阔。

我们在虚拟机上使用GPU计算，大幅提升训练速度，在虚拟机上运行的速度几乎是我本地计算机的10倍。如果某些算法需要额外的计算能力，可以考虑使用性价比很高的云主机（一般每小时不到1美元）——只要记得用完后即时关机就好！

本章所研究的卷积网络算法相当复杂。卷积网络训练时间长，有很多参数需要训练。数据集看似不小，其实还称不上大数据，甚至不用稀疏矩阵，就能把这些数据全部加载到内存。下章所讲的算法更加简单，但是数据集却特别大，无法装进内存，这是大数据最显著的一个特点，采矿业和社交网络等行业所产生的数据都具有这个特点。

第 12 章　大数据处理

当今社会数据量呈指数级增长，这些数据来自于用户行为监测系统、分布式系统、网络分析、传感器等。移动互联网的迅速发展又带来移动数据的增长，此外，下一个大潮流——物联网（Internet of Things，IoT）又会进一步提升数据增长速度。

挖掘大数据需要有新思路。运行时间很长的复杂算法要么改进要么抛弃，而能够处理更多数据，复杂程度相对简单的算法正变得更加流行起来。例如，支持向量机是很好的分类器，但是它的一些变种很难在大型数据集上使用。相反，逻辑回归等相对简单的算法处理大数据集更容易。

本章主要内容如下:

大数据的挑战及其应用

MapReduce范式

Hadoop MapReduce

在亚马逊平台上运行MapReduce程序的Python包mrjob

12.1　大数据

大数据具有哪些特点呢？它的大多数支持者认为有以下4个特点，简称4V。

(1) 海量（Volume）：我们产生和存储的数据量加速增长，未来势头看起来会更猛。现在常用的硬盘容量单位为GB，再过几年就会变为EB1。同时网络吞吐量也会增长。信噪比不容乐观，重要信息很可能被海量重要程度低的信息湮没。

1EB（exabyte），艾字节，1EB=1024PB，1PB=1024TB，1TB=1024GB。1EB约等于10亿GB。——译者注

(2) 高速（Velocity）：数据量增长的同时，数据处理速度在加快。就拿现如今的汽车举例子，每辆车装有成百上千个传感器，这些传感器不停地向汽车内置的计算机传送数据，要完成操纵汽车的任务，数据的分析处理速度需要达到次秒级水准。这不仅要从大量数据中寻找答案，还得速度快。

(3) 多样（Variety）：数据集有多种形式，各列清晰定义的规整数据集只占其中很少一部分。想想社交网站的一条消息吧，它可能包含文本、照片、提及其他用户2、喜欢数、评论数、视频、地理位置信息等。简单地忽略掉这些难以整合进模型的数据势必会导致信息丢失。

2指“@”加用户名这种形式。——译者注

(4) 准确（Veracity）：随着数据量的增加，很难确定采集到的数据是否正确——是否过时、充满噪音，有没有包含异常数据，或者总体来说是否有用等。倘若人们无法对数据的准确性进行有效验证，要信赖数据很难。人们从外部得到的数据集被不停地整合到内部数据集中，使得数据的准确性这个问题更加突出。

从以上四点（也有人认为是五点3）就能看出大数据不仅仅是大量的数据，处理大数据对工程能力要求很高——更不用说对它们进行分析了。很多“蛇油商人”4只关心怎样夸大大数据的应用，却避而不谈大数据工程的挑战以及潜在分析工作的难度。

3有人认为大数据的另一个特点是价值（Value），经过挖掘得到的结论有实际应用价值。——译者注

4蛇油商人（snake oil salesman）跟“江湖郎中”有相同的贬义内涵。19世纪，国人赴美修建北太平洋铁路，带去从南方水蛇体内提炼的蛇油，以祛风湿，缓筋骨之痛，颇受当地人欢迎。后美国商人Clark Stanley等改从北美响尾蛇提炼蛇油，批量生产，但效果较差，甚至出现产品中压根不含蛇油的造假现象。该词遂被用来指打着不容置疑的旗号，推销假冒伪劣谋求私利的骗子。——译者注

我们前面用过的这些算法都是把数据集加载到内存后再进行处理。这对于提升计算速度很有好处，因为处理已经在内存中的数据比起先从硬盘加载到内存后再处理要快得多。此外，我们可以对内存中的数据进行多次迭代，改善模型。

对于大数据，我们就无法将其加载到内存中。从很多方面来讲，可以用此来判断一个问题能不能称得上大数据问题——如果数据可以加载到你计算机内存中，那就算不上大数据。

12.2　大数据应用场景和目标

企业和个人都离不开大数据。

所有以大数据为基础的系统中，人们与之打交道最多的当属谷歌这样的搜索引擎。对于搜索引擎来说，要在零点几秒的时间内对几十亿甚至更多的网站完成一次搜索，基础的文本查询肯定满足不了需求，单是存储这么多网站的文本内容就是个大难题。要完成搜索引擎中的查询任务，就需要专门创建新数据结构和使用新数据挖掘方法。

很多其他科学实验也都用到了大数据技术，例如大型强子对撞机（Large Hadron Collider）。该机器长达27公里，装有1.5亿个传感器，用于监测每秒几亿个强子的对撞情况，对撞机局部照片请见下图。对撞实验产生的数据量十分惊人，每天过滤后还有25PB（假如不过滤，每年将产生1.5亿PB数据）。这些数据隐藏着宇宙的奥秘，对其进行分析能加深我们对宇宙的认识，但是工程量很大，分析起来也相当困难。

政府部门对大数据的重视程度也在逐渐增加，以充分了解人口、商业活动和国家其他各方面的情况。跟踪几千万乃至几亿人口及成百上千亿的交易量（商贸或医疗开支），很多政府机构都需要依靠大数据分析技术才能完成。

全球很多政府尤其关注交通管理，他们使用数以千万计的传感器判断哪些道路堵塞最为严重，预测新修道路对交通状况的影响。

大型零售商使用大数据改善客户体验，降低支出。这包括预测客户需求，保证供货量适度，向客户推销他们可能喜欢的产品，跟踪交易数据，寻找购物潮流和消费模式，发现潜在欺诈行为。

还有很多大公司用大数据提高公司经营管理的自动化程度，改善产品和服务质量。他们通过大数据分析预测行业风向，跟踪外部竞争者。他们还把大数据用到员工管理上——跟踪员工，发现其离职倾向以及时干预。

信息安全部门通过监测网络流量，结合大数据技术，寻找大型网络的恶意软件感染，这包括寻找奇怪的流量模式，恶意软件传播迹象和其他反常现象。高级持续性威胁（Advanced Persistent Threats，APTs）攻击也很令人抓狂，攻击者把恶意代码藏在大型网络中，长期从网络中窃取信息或从事其他破坏活动。寻找APTs往往需要检查很多台计算机进行取证，费时费力，仅靠人工很难完成，这时就可以使用大数据技术实现自动化检查、取证。

大数据方兴未艾，正在越来越多的行业和应用中大显身手。

12.3　MapReduce

大数据挖掘和计算有几个主要概念，其中最为流行的就是MapReduce模型，它可用于任意大数据集的一般性计算任务。

谷歌出于并行计算的需要，最先提出了MapReduce模型。它还引入了容错和可伸缩特性。MapReduce最初的研究发表于2004年，打那时起，成千上万的项目、实现和应用都用到了这一概念。

虽然MapReduce跟之前很多概念有相似之处，但这不影响它成为大数据分析领域的核心概念。

12.3.1　直观理解

MapReduce主要分为两步：映射（Map）和规约（Reduce）。函数式程序设计中有把函数映射到列表和规约结果的概念，MapReduce以这两个概念为基础。为了解释映射和规约，我们举个例子，编写代码遍历一串列表，计算所有列表中数字之和。

MapReduce范式还包括排序（shuffle）和合并（combine）两步，后面会讲。

首先，在映射这一步，接收一个函数，用这个函数处理列表的各元素，返回跟之前列表长度相等的列表，新列表的元素为函数的返回结果。

打开一个新的IPython Notebook笔记本文件，创建一列表，列表中各元素为包含几个数字的列表。

a = [[1,2,1], [3,2], [4,9,1,0,2]]

接着，建立sum函数和a之间的映射关系。这一步会用sum函数处理列表a的每一个元素。

sums = map(sum, a)

上述sums为生成器（在我们调用它之前，不会进行计算），上面这行代码大体上等价于：

sums = []
for sublist in a:
    results = sum(sublist)
    sums.append(results)

规约步骤要稍微复杂些，需要对返回结果的每一个元素应用一个函数，从初始值开始，对初始值和第一个值应用指定函数，得到返回结果，然后再对所得到的结果和下一个值应用指定函数，以此类推。

我们来创建一个函数，接收两个数字作为参数，返回两个数字的和。

def add(a, b):
  return a + b

然后进行规约。规约函数形式为reduce(function, sequence, initial)，参数分别表示用来进行规约的函数的名字、列表和初始值，函数即是对序列的每一步所应用的函数。在第一步，第一个值为初始值，而不是列表的第一个元素。

from functools import reduce
print(reduce(add, sums, 0))

结果为25，它是sums列表中每个元素的和，也就是原来大列表a中每个二级列表各元素的和。

上面代码等价于如下代码。

initial = 0
current_result = initial
for element in sums:
    current_result = add(current_result, element)

我们这个小例子，代码很简短，但真正的好处是可以使用分布式计算。假如，二级列表的数量为100万，每个二级列表包含100万个元素，对于这么大的计算任务，我们可以交由多台计算机完成。

为了实现分布式计算，我们可以在映射这一步把各个二级列表及函数说明分发到不同的计算机上。计算完成后，各计算机把结果返回主计算机（master）。

然后master把结果发送给另一台计算机做规约。我们的例子有100万个二级列表，因此可以将100万个任务交给不同的计算机处理（计算机完成映射操作后，可再次用于规约）。返回结果为包含100万数字的列表，然后就可以进行求和运算。

这样做的好处是，尽管原始数据集有1万亿数字，但实际计算过程，哪台计算机都不需要存储超过100万个数字。

12.3.2　单词统计示例

MapReduce的实现比起单纯使用映射和规约两步要复杂些。这两步都用键来调用，便于数据的区分和值的跟踪。

映射函数接收一键值对，返回键值对列表。接收和输出的键不一定要彼此相关。例如，统计单词数量的MapReduce程序，输入的键可能是文档编号，而输出的键却可以是单词。输入值可能是文档的文本内容，而输出值为每个单词的词频。

from collections import defaultdict
def map_word_count(document_id, document):

首先，计算每个单词词频。在这个简化过的例子中，我们先根据空格把文档转换成单词列表，当然有更好的解决方法。

    counts = defaultdict(int)
    for word in document.split():
        counts[word] += 1

遍历每个单词，统计次数，注意用到了yield语句。用MapReduce的术语来说，单词为键，单词出现次数为值。

    for word in counts:
        yield (word, counts[word])

用单词做键，我们就可以进行shuffle操作，把每个键所有值聚集到一起。

def shuffle_words(results):

首先，把每个单词的统计结果放到一个列表中。

    records = defaultdict(list)

遍历映射函数返回的所有结果。

    for results in results_generators:
        for word, count in results:
            records[word].append(count)

接着，遍历每个单词，创建生成器，它能生成（yield）单词和该单词在各文档出现次数的列表这两项。

    for word in records:
        yield (word, records[word])

最后一步是规约，接收一键值对（值为列表），输出另外一键值对。我们这里，键为单词，输入的列表为shuffle后得到的作为键的单词在不同文档出现次数的列表，输出键值对中的值这一项为单词（键）在所有文档的出现次数之和。

def reduce_counts(word, list_of_counts):
    return (word, sum(list_of_counts))

我们使用scikit-learn提供的来自20个新闻组的语料看下上述代码的实际效果。

from sklearn.datasets import fetch_20newsgroups
dataset = fetch_20newsgroups(subset='train')
documents = dataset.data

然后执行映射操作，这里用enumerate函数自动为文档生成编号。编号这里用处不大，但是在其他应用中很重要。

map_results = map(map_word_count, enumerate(documents))

上述结果只是生成器，而不是实际的统计结果。也就说，它是能输出键值对（单词、出现次数）的生成器。

接着，对生成器进行shuffle操作，根据单词出现次数进行排序。

shuffle_results = shuffle_words(map_results)

上述例子本质上来说是一个MapReduce任务；然而由于只使用单线程，我们无法从MapReduce数据格式中获得任何好处。下节，我们将使用开源的MapReduce架构Hadoop，实现分布式计算，提升性能。

12.3.3　Hadoop MapReduce

Hadoop是Apache基金会所提供的一组包括MapReduce在内的开源工具。人们实际使用的MapReduce架构也多是它。Hadoop项目由Apache基金会管理（他们也负责维护著名的Web服务器软件Apache）。

Hadoop生态系统很复杂，有大量的工具。我们将要用到的主要组件为Hadoop MapReduce。Hadoop其他处理大数据的工具有如下几种。

Hadoop分布式文件系统（Hadoop Distributed File System， HDFS）：该文件系统可以将文件保存到多台计算机上，以防范硬件故障，提高带宽。

YARN：用于调度应用和管理计算机集群。

Pig：用于MapReduce的高级语言。Hadoop MapReduce用Java语言实现，Pig对Java实现做进一步封装，支持用其他语言来编写程序——包括Python。

Hive：用于管理数据仓库和进行查询。

HBase：对谷歌分布式数据库BigTable的一种实现。

这些工具都是用来解决包括分析过程在内的大数据实验所能遇到的各种问题。

还有其他一些没有使用Hadoop工具集实现的MapReduce框架，也有一些具有相似目标的项目。此外，很多云平台都提供以MapReduce为基础的系统。

12.4　应用

我们来建立一个根据博主用词习惯判断博主性别的应用。我们用MapReduce方法训练朴素贝叶斯分类器。最后用模型做预测时，不需要MapReduce，虽然我们可以用映射这个步骤——也就是对列表中的每一篇文档运行预测模型。这是用MapReduce进行数据挖掘常用的映射操作，规约步骤只用来调整预测结果列表，以便把结果和原文档对应起来。

我们将使用亚马逊云平台运行应用，以利用它们的计算资源。

12.4.1　获取数据

我们使用2004年8月份从http://blogger.com网站采集的60多万篇博客文章作为数据集，这些文章共有1.4亿多个单词，每篇文章都标注了博主的年龄、性别、行业（工作），有趣的是还有星座。研究人员曾进行过部分验证，确保同一篇博客从头到尾均由一名博主所写（虽然也不是很确定）。每篇博客还给出了发表时间，这个数据集的内容真是挺丰富的。

访问http://u.cs.biu.ac.il/~koppel/BlogCorpus.htm网站，点击Download Corpus。下载完成后，把数据集文件解压到你的本地计算机数据文件夹Data里。

数据集中，一个博主的所有博客放到同一个文件里，文件名包含博主的相关信息。例如，其中一个文件名如下：

1005545.male.25.Engineering.Sagittarius.xml

文件名用点号分隔，主要包括以下信息。

博客编号：标识博客唯一性的数值。

性别：该部分不是male（男性）就是female（女性）。数据集只包含这两种情况。

年龄：给出确切的年龄，但有时会特意使用年龄段。年龄段（包含起止年龄）有13~17、23~27和33~48。这样做是便于把难以确定博主年龄的博客粗略归到某一年龄段中，因为很难把18岁写的博客和19岁所写的区分开来，此外，当你使用这些数据时，博主实际年龄可能比早先填写的又长了几岁，因此需要做相应调整。5

行业：包括科学、工程、艺术、房地产在内的40种行业的其中一种，若博主没有填写该项，使用indUnk6来表示。

星座：12星座之一。

5作者给我举了个例子，“去年用户发表博客时为18岁，今年就19岁了，我需要在模型中做相应调整。”——译者注

6industry unknown的简写。——译者注

所有的这些数据都是用户自己提供的，这就表明其中有错误或不一致现象，但是绝大多数是可靠的——如果用户为了保护个人隐私起见，不想透露某方面的信息，他们可以选择不填，这一点网站是允许的。

每个文件的格式类似于XML，包含<Blog>标签和一系列<post>标签。每个<post>标签前都有一个<date>标签。虽然我们可以把它当作XML来处理，但是按行处理更容易，因为它不是真正规范的XML文件，并且还有些错误（大部分是编码错误）。我们可以使用循环结构遍历文件中的每一行，以读取博客内容。

指定一个文件名，我们来实际看下。

import os
filename = os.path.join(os.path.expanduser("~"), "Data", "blogs",
"1005545.male.25.Engineering.Sagittarius.xml")

首先，创建用于存储每篇博客的列表。

all_posts = []

然后，打开要读取的文件。

with open(filename) as inf:

设置标识是否在博客中的标记。找到博客开始标签<post>后，将标记的值设置为True，表示找到了博客的开始位置。找到关闭标签</post>后，将标记的值设置为False。

    post_start = False

创建用于存储博客当前行内容的列表。

    post = []

遍历文件的每一行，删除该行前后的空格。

    for line in inf:
        line = line.strip()

照前面所说，找到博客的开始、关闭标签后，更改标记post_start的值。

        if line == "<post>":
            post_start = True
        elif line == "</post>":
            post_start = False

找到关闭标签</post>后，记录刚找到的这一篇博客的所有内容。创建新的post列表。下面两行代码的缩进字符数与前一行代码相同。

            all_posts.append("\n".join(post))
            post = []

最后，如果该行不是博客结尾，也就是还在博客里面，把这一行加到当前博客post列表的最后。

        elif post_start:
            post.append(line)

如果没有在博客中，忽略这一行。

可以像下面这样获取每篇博客的文本内容。

print(all_posts[0])

我们还可以计算出一位博主共发表了多少博客。

print(len(all_posts))

12.4.2　朴素贝叶斯预测

我们来实现朴素贝叶斯算法（从技术上讲，这是一个简化的版本，复杂实现通常有很多特征）处理博客博主性别分类问题。

1. mrjob包

把用mrjob创建的MapReduce任务推送到亚马逊云平台上很容易。mrjob听上去有点像奇先生7系列儿童故事书的画蛇添足之作，实则表示映射规约任务（Map Reduce Job）。这个包很有用，但是写作本书时，对Python 3的支持还不成熟，对我们后面要讲到的亚马逊EMR服务支持也不够好。

7英国儿童文学作家罗杰·哈格里维斯（Roger Hargreaves）为儿童创作的《奇先生妙小姐》系列图书中的人物形象，比如有荒唐先生、傲慢先生、邋遢先生等。——译者注

你可以使用下面命令安装mrjob的Python 2版本：

sudo pip2 install mrjob

注意使用pip2而不是pip3。

本质上讲，mrjob提供了大部分MapReduce任务所需的标准功能。最令人惊异的特性是，你可以编写同一套代码，既能在没有安装Hadoop的本地计算机上进行测试，测试完后，还可以直接把代码提交到亚马逊的EMR或其他Hadoop服务器上。

这样，测试起代码来很容易，虽然它也不可能神奇地把大问题变成小问题——注意任何本地测试，只是使用一小部分而不是全部数据。

2. 抽取博客内容

首先创建一个MapReduce程序，从每位博主的博客文件中抽取所有博客，分别存储。因为最终要预测博主的性别，我们还需要抽取性别信息，跟博客一起存储。

我们这回没法用笔记本文件，因此使用Python IDE进行开发。如果你没有装Python IDE（比如PyCharm），你可以使用文本编辑器。建议你使用具有代码高亮功能的IDE。

　如果你找不到好用的IDE，你可以在笔记本文件中编写代码，然后点击（或选择）File | Download As | Python把代码保存成.py文件，然后再运行。第11章提到过这种方法。

因为需要获取环境变量，我们会用到os，单词拆分时会用到正则表达式，一并导入re。

import os
import re

接着导入MRJob类，我们的MapReduce任务继承自它。

from mrjob.job import MRJob

然后创建MRJob的子类。

class ExtractPosts(MRJob):

我们使用跟之前类似的循环从文件中抽取博客内容。我们即将定义的映射函数将处理每一行，跟踪不同博客的任务要在映射函数外完成。因此，在函数外而不是在函数内部声明post_start和post两个变量。

    post_start = False
    post = []

然后创建映射函数——从文件中取一行作为输入，最后生成一篇博客的所有内容。每一行都来自同一任务所在处理的文件。这样，我们就可以使用上面创建的变量保存当前博客的内容。

    def mapper(self, key, line):

开始采集博客内容之前，我们还要获取到博主的性别。虽然通常我们不会把文件名作为MapReduce任务的一部分，但是这里确实要用到。当前的文件名称以环境变量的形式存储，用下面代码就能获取到。

        filename = os.environ["map_input_file"]

用点号切分文件名，获取性别（第二个字符串）。

        gender = filename.split(".")[1]

删除一行开头和结尾处的空格（博客中空格很多），然后查找文件开头和结尾。

        line = line.strip()
        if line == "<post>":
            self.post_start = True
        elif line == "</post>":
            self.post_start = False

之前我们把博客保存到列表里，而这次我们用yield，便于mrjob跟踪输出：博主性别和博客内容，这样博主性别和博客就能一一对应起来。函数余下部分跟前面用到的循环中的代码一致。

            yield gender, repr("\n".join(self.post))
            self.post = []
        elif self.post_start:
            self.post.append(line)

最后，在函数和类的外面，编写下面语句，以便从命令行运行代码时执行MapReduce任务。

if __name__ == '__main__':
    ExtractPosts.run()

现在，可以在命令行输入下面的shell命令运行MapReduce任务。注意使用Python 2而不是Python 3。

python extract_posts.py <your_data_folder>/blogs/51* --output-
  dir=<your_data_folder>/blogposts –no-output

第一个参数<your_data_folder>/blogs/51*（注意把<your_data_folder>替换为你的数据文件夹的全路径）指定所使用的数据集（所有以51开始的文件，只有11个）。接着指定输出文件夹，即用来保存博客内容的文件夹。最后指定不要在命令行输出内容。否则，程序运行期间，输出的数据将显示在命令行——对我们来说没多大用处，还会降低程序运行速度。

运行上述代码，每篇博客内容很快就会被抽取出来并保存到指定的输出文件夹。在本地计算机上，只使用一个线程，所以速度提升有限，但是我们知道代码确实可以运行了。

我们看下输出文件夹，里面有有一系列文件，文件的每一行为博主的性别及一篇博客的内容。

3. 训练朴素贝叶斯分类器

既然已经抽取了博客内容，接下来就可以用它们训练朴素贝叶斯模型。根据直觉，我们可以这样做，分别统计女性博主和男性博主使用每个单词的概率。对博客进行分类时，我们分别计算博客8博主为女性的概率和博主为男性的概率，选取概率较大的，作为最终类别。

8一篇博客，其博主为女性的概率有多大？计算方法为，从语料中统计到该博客中每个单词出现在女博主所写博客中的概率，然后求出这些概率的积。其中会涉及到数值下溢、博客中某一单词在语料中没有出现等问题，这些问题需要特殊处理。下文也会讲到。——译者注

我们来编写代码，输出所有文件中每个单词及女博主和男博主使用该词的频率。输出文件如下所示：

"'ailleurs"  {"female": 0.003205128205128205}
"'air"  {"female": 0.003205128205128205}
"'an"  {"male": 0.0030581039755351682, "female": 0.004273504273504274}
"'angoisse"  {"female": 0.003205128205128205}
"'apprendra"  {"male": 0.0013047113868622459, "female":
0.0014172668603481887}
"'attendent"  {"female": 0.00641025641025641}
"'autistic"  {"male": 0.002150537634408602}
"'auto"  {"female": 0.003205128205128205}
"'avais"  {"female": 0.00641025641025641}
"'avait"  {"female": 0.004273504273504274}
"'behind"  {"male": 0.0024390243902439024}
"'bout"  {"female": 0.002034152292059272}

输出结果每一行的第一个值为单词，第二值为一个字典，字典的键为性别，字典的值为给定性别使用该词的频率。

用Python IDE或文本编辑器新建一个文件，再次要用到os和re，除此之外还要用到NumPy和MRJob。因为要对字典进行排序，所以还用到itemgetter。

import os
import re
import numpy as np
from mrjob.job import MRJob
from operator import itemgetter

我们还需要用MRStep管理MapReduce中的每一步操作。前面的MapReduce任务只有由映射函数和规约函数组成的一个步骤。我们当前这个任务分三步：映射、规约、再次映射和规约。是不是感觉跟前几章我们用到的流水线一样，前一步的输出将作为下一步的输入。

from mrjob.step import MRStep

接着创建用于匹配单词的正则表达式，并对其进行编译，我们用它来查找单词的边界。这种类型的正则表达式比起前面用过的split方法更加强大，如果你需要更加精确的单词切分工具，建议你使用第6章用到的NLTK库。

word_search_re = re.compile(r"[\w']+")

创建一个新类，用于训练朴素贝叶斯分类器。

class NaiveBayesTrainer(MRJob):

定义MapReduce任务的各个步骤，一共分为两步。第一步抽取单词出现的概率。第二步，比较一个单词在男女博主所写博客出现的概率，选择概率较大的性别作为分类结果，写入输出文件。在上述每一步（MRStep）中，定义映射和规约函数，它们是NaiveBayesTrainer类里面的方法（后续会编写这两个函数）。

    def steps(self):
        return [
            MRStep(mapper=self.extract_words_mapping,
                   reducer=self.reducer_count_words),
            MRStep(reducer=self.compare_words_reducer),
            ]

第一个函数是第一步中的映射函数。这个函数的目标是接收一条博客数据，获取里面的所有单词，因为我们想要得到单词的频率，所以每个单词返回1 / len(all_words)，便于后面加总求词频。这样计算并不精确——我们需要根据文档数量做归一化处理。但由于数据集中两个类别的文档数相同，因此不做归一化处理对最终结果影响也很小。

输出博主的性别，后面会用到。

    def extract_words_mapping(self, key, value):
        tokens = value.split()
        gender = eval(tokens[0])
        blog_post = eval(" ".join(tokens[1:]))
        all_words = word_search_re.findall(blog_post)
        all_words = [word.lower() for word in all_words]
            all_words = word_search_re.findall(blog_post)
            all_words = [word.lower() for word in all_words]
            for word in all_words:
                yield (gender, word), 1. / len(all_words)

　上面代码使用eval函数简化对每条博客数据的处理，直接把字符串转换为Python列表，但不建议这么做。相反，应该使用JSON等格式存储数据，然后用相应包进行解析。如果在访问数据集的代码中使用eval语句，攻击者可借此插入恶意代码，并在你的服务器上运行。

在第一步的规约函数中，汇总每个性别使用每个单词的频率。我们还把键改为单词，而不是单词和性别的组合，因为在最后训练得到的模型中，我们要根据单词进行查询（虽然还要输出性别以供后面使用）。

    def reducer_count_words(self, key, frequencies):
        s = sum(frequencies)
        gender, word = key
        yield word, (gender, s)

最后一步不需要映射函数，因此我们就没有添加它。数据将作为一致性映射（identity mapper）类型直接传入到规约函数中，而规约函数将会把每个单词在所有文章中出现频率按照性别汇集到一起，输出单词及频率字典。

这正是朴素贝叶斯分类器所需要的信息。

    def compare_words_reducer(self, word, values):
        per_gender = {}
        for value in values:
            gender, s = value
            per_gender[gender] = s
        yield word, per_gender

最后，添加以下代码，以便直接运行代码时，训练朴素贝叶斯模型。

if __name__ == '__main__':
    NaiveBayesTrainer.run()

运行上述代码，它的输入即是前面博客抽取代码的输出（实际上可以把它们作为前后相连的步骤，放到同一个MapReduce任务中）。

python nb_train.py <your_data_folder>/blogposts/
  --output-dir=<your_data_folder>/models/
nooutput

上述代码运行结束后，该MapReduce任务的输出结果保存到位于输出文件夹中的一个文件里，输出结果为运行朴素贝叶斯分类器所需的概率信息。

4. 组装起来

我们现在就可以使用这些概率来运行贝叶斯分类器。我们将在笔记本文件里编写代码，可以再次使用Python 3喽！

首先，查看上一个MapReduce任务所生成的模型文件夹。如果输出文件多于一个，在命令行切换到模型文件夹下，使用下面命令把它们追加到model.txt后面。

cat * > model.txt

运行上述命令后，记得把下面用到的模型所在的文件名改为model.txt。

回到笔记本文件，导入接下来会用到的几个包。

import os
import re
import numpy as np
from collections import defaultdict
from operator import itemgetter

重新定义用于查找单词的正则表达式——在实际应用中，不要忘记这点，注意训练和测试时应该使用相同的正则表达式来抽取单词。

word_search_re = re.compile(r"[\w']+")

接着，声明从指定文件名中加载模型的函数。

def load_model(model_filename):

模型的参数是一个元素为字典的字典，各元素的键为单词，值（内部字典）为由每个性别及其概率组成的键值对。我们使用defaultdict，如果键不存在的话，将返回0。

    model = defaultdict(lambda: defaultdict(float))

打开模型所在文件，解析每一行。

    with open(model_filename) as inf:
        for line in inf:

用空格作为分隔符，将模型的每一行切分成两部分。第一部分为单词自身，第二部分为概率字典。对于每一部分，使用eval函数获得实际的值，它们之前是使用repr函数存储的。

                word, values = line.split(maxsplit=1)
                word = eval(word)
                values = eval(values)

在模型中，为单词和概率字典建立起映射关系。

            model[word] = values
    return model

接着，加载实际的模型。你可能需要修改模型的文件名——它存储在上一个MapReduce任务的输出文件夹中。

model_filename = os.path.join(os.path.expanduser("~"), "models",
"part-00000")
model = load_model(model_filename)

举例来说，我们可以像下面这样查看不同性别使用单词i（执行MapReduce任务时，所有单词中的字母全部转换为小写）的情况。

model["i"]["male"], model["i"]["female"]

接着，创建使用模型做预测的函数。这里我们不再使用scikit-learn接口，只是创建一个简单的函数。这个函数接收模型和一篇文档作为参数，返回对博主性别的预测结果，函数声明如下：

def nb_predict(model, document):

先来创建一个字典，键值分别为每个性别及概率9。

9不同性别用词有自己的偏好。此处的概率指的是，待预测文档每一个单词由给定性别所使用的概率的乘积。——译者注

    probabilities = defaultdict(lambda : 1)

从文档中抽取每一个单词。

    words = word_search_re.findall(document)

遍历每一个单词，找出数据集中每个性别使用该单词的概率。

    for word in set(words):
        probabilities["male"] += np.log(model[word].get("male", 1e-15))
        probabilities["female"] += np.log(model[word].get("female", 1e-15))

根据概率对性别进行排序，以概率最高的性别作为预测结果返回。

    most_likely_genders = sorted(probabilities.items(),
key=itemgetter(1), reverse=True)
    return most_likely_genders[0][0]

注意，我们使用np.log计算概率。朴素贝叶斯模型中，概率值往往很小。把这些很小的数连乘起来会导致数值下溢，由于计算机精度不够，最终结果将为0。在我们这里，就会出现博主为男性或女性的概率都为0的情况，预测结果自然不正确。

为了解决数值下溢问题，我们使用对数概率。对于两个数a和b，log(a,b)等于log(a) + log(b)。数值很小的数取对数，得到一个负数，但相对还比较大。例如log(0.00001)约等于-11.5。这表明与其冒着数值下溢的风险，把多个概率连乘，还不如使用对数概率求和，然后比较两类的和（和越大，可能性越高）。

使用对数概率有个问题就是无法处理0值（虽然，把多个为0的概率连乘也不能解决这个问题）。这是因为log(0)没有定义。有些朴素贝叶斯算法，为每个特征的计数都加1以避免出现这个问题，当然还有别的解决方法。加一平滑是一种很简单的数据平滑方法。我们这里如果某个单词给定性别没有人用过，就给它赋一个很小的概率值。

再回到我们的预测函数，我们从数据集中复制一篇博客来做下测试。

new_post = """ Every day should be a half day.  Took the afternoon
off to hit the dentist, and while I was out I managed to get my oil
changed, too.  Remember that business with my car dealership this
winter?  Well, consider this the epilogue.  The friendly fellas at the
Valvoline Instant Oil Change on Snelling were nice enough to notice
that my dipstick was broken, and the metal piece was too far down in
its little dipstick tube to pull out.  Looks like I'm going to need a
magnet.   Damn you, Kline Nissan, daaaaaaammmnnn yooouuuu....   Today
I let my boss know that I've submitted my Corps application.  The news
has been greeted by everyone in the company with a level of enthusiasm
that really floors me.     The back deck has finally been cleared off
by the construction company working on the place.  This company, for
anyone who's interested, consists mainly of one guy who spends his
days cursing at his crew of Spanish-speaking laborers.  Construction
of my deck began around the time Nixon was getting out of office.
"""

用下面代码进行分类。

nb_predict(model, new_post)

分类结果为男性，分类正确。当然，千万不要只用一条数据进行测试。我们只使用文件名以51打头的文档来训练模型，所以不要指望有太高的正确率。

当务之急是用更多的文档来训练模型。我们使用所有文件名以6或7打头的文档做测试，在剩余文档上进行训练。

使用命令行，切换到博客文档所在的文件夹（cd <your_data_folder>），复制文档到新文件夹中。

创建训练集文件夹。

mkdir blogs_train

然后，创建测试集文件夹。

mkdir blogs_test

把所有文件名以6或7打头的文档从训练集文件夹移动到测试文件夹。

cp blogs/6* blogs_train/
cp blogs/7* blogs_train/

我们需要返回训练集中所有博客的抽取结果。然而，计算量很大，最好使用云平台而不是本地计算机处理这个任务。因此，我们接下来使用亚马逊云平台解析博客内容。

跟之前一样，在命令行运行下面代码。唯一的不同点是训练集所在的文件夹路径。运行代码前，删除用于存储抽取结果和模型文件夹中的所有文件。

python extract_posts.py ~/Data/blogs_train --output-dir=/home/bob/
Data/blogposts –no-output
python nb_train.py ~/Data/blogposts/ --output-dir=/home/bob/models/
nooutput

上述代码运行时间较长。

我们使用测试集中的所有博客进行测试。我们使用extract_posts.py MapReduce任务抽取博客内容，但是记得把抽取结果保存到另外一个文件夹中。

python extract_posts.py ~/Data/blogs_test --output-dir=/home/bob/Data/
blogposts_testing –no-output

回到笔记本文件，获取到输出的所有测试文档的路径。

testing_folder = os.path.join(os.path.expanduser("~"), "Data",
"blogposts_testing")
testing_filenames = []
for filename in os.listdir(testing_folder):
    testing_filenames.append(os.path.join(testing_folder, filename))

对于上面输出的每一篇测试文档，我们抽取博主性别和博客内容，然后调用预测函数进行分类。因为文档数量很多，我们不想占用太多内存，所以使用生成器来处理。生成器会给出实际性别和预测结果。

def nb_predict_many(model, input_filename):
    with open(input_filename) as inf:
        # remove leading and trailing whitespace
        for line in inf:
            tokens = line.split()
            actual_gender = eval(tokens[0])
            blog_post = eval(" ".join(tokens[1:]))
            yield actual_gender, nb_predict(model, blog_post)

然后我们记录测试集中所有数据的预测结果和实际性别。预测结果不是男性就是女性。为了使用scikit-learn的f1_socre函数，我们需要把预测结果转换为0或1，如果结果为男性，我们记录0，反之为1。我们使用布尔测试，判断性别是否为女性，然后使用NumPy把布尔值转换为整型（int）。

y_true = []
y_pred = []
for actual_gender, predicted_gender in nb_predict_many(model, testing_
filenames[0]):
    y_true.append(actual_gender == "female")
    y_pred.append(predicted_gender == "female")
y_true = np.array(y_true, dtype='int')
y_pred = np.array(y_pred, dtype='int')

现在，我们使用scikit-learn中的F1值评估分类结果。

from sklearn.metrics import f1_score
print("f1={:.4f}".format(f1_score(y_true, y_pred, pos_label=None)))

结果为0.78，还算可以。我们使用更多的数据也许能改善分类效果。但是，我们需要使用更强大的云平台才能处理更多数据。

5. 在亚马逊EMR云平台上训练模型

我们接下来使用亚马逊的EMR（Elastic Map Reduce）云平台完成博客抽取、解析和模型创建任务。

首先，需要在亚马逊的云存储（storage cloud）创建存储段（bucket）。具体做法是用浏览器打开亚马逊S3控制界面http://console.aws.amazon.com/s3，点击Create Bucket。记住存储段的名字，稍后会用到。

右键点击新建的存储段，选择Properties。然后，修改权限，将其设置为对所有人开放。一般来说，这样做不安全，学完这一章后，请及时修改权限。

左键点击存储段，打开它，点击Create Folder，把新文件夹命名为blogs_train。我们将把训练集数据上传到该文件夹，以便在云端处理。

我们在本地计算机上使用亚马逊的AWS CLI命令行工具操作亚马逊云平台。

使用以下命令安装该工具。

sudo pip2 install awscli

按照网址中给出的说明，为CLI工具设置安全证书：http://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-set-up.html。

接下来需要上传数据到新建的存储段。首先，我们创建训练集，它包括所有除去文件名以6或7打头的文件。复制文件有很多种更为优雅的方式，但是考虑到平台兼容性，就不做推荐。相反，我们使用最牢靠的做法，复制所有的原始文件到训练集文件夹，把文件名以6、7打头的从训练集中删除。

cp -R ~/Data/blogs ~/Data/blogs_train_large
rm ~/Data/blogs_train_large/6*
rm ~/Data/blogs_train_large/7*

接着，上传数据到亚马逊S3存储段。注意上传数据需要时间较长（好几百兆）。网速慢的读者，找个网快的地方上传比较好。

 aws s3 cp  ~/Data/blogs_train_large/ s3://ch12/blogs_train_large
recursive -exclude "*" --include "*.xml"

接下来使用mrjob连接亚马逊EMR——它帮助我们处理所有任务；只需要给出安全证书即可。按照下面链接给出的说明，为mrjob设置安全证书：https://pythonhosted.org/mrjob/guides/emr-quickstart.html。

设置完成后，修改mrjob的运行方式，让它在亚马逊EMR上运行，改动之处很少。用-r开关，告诉mrjob使用emr。然后把输入、输出目录改为我们在S3上创建好的存储段目录。虽然使用亚马逊云平台，但是运行时间仍很长。

python extract_posts.py -r emr s3://ch12gender/blogs_train_large/
outputdir=s3://ch12/blogposts_train/ --no-output
python nb_train.py -r emr s3://ch12/blogposts_train/ --output-dir=s3://
ch12/model/ --o-output

　这次使用当然也是要付费的，但只有几美元10而已。如果你要持续运行或做其他跟大数据相关的任务，还请留意下费用问题。有一次我运行大量任务，花了20美元。我们这里任务量要小一些，估计花不到4美元。你可以查看下账户余额，设置收费提醒：https://console.aws.amazon.com/billing/home。

10澳元和美元货币符号都为$。作者表示写这本书时，两者汇率差别不大，但目前拉大了，实际费用用美元来计算比较合适。——译者注

没必要创建blogposts_train和存储模型文件的文件夹——EMR会自动创建。如果这两个文件夹存在，程序反而会报错。如果你再次运行代码的话，记得把这两个文件夹改成别的名字，但是要记得同时修改两条命令中的文件名（第一条命令中的输出目录是第二条命令的输入目录）。

　如果等待时间过长，你有些厌烦，第一项任务运行一段时间后就可以停止。我建议至少运行15分钟后再停止，可能的话，最好1小时以上。你不能中断第二项任务，中断后结果好不到哪去。第二项任务的运行时间大约是第一项任务的两到三倍。

回到S3控制界面，从存储段中下载输出的模型文件，将其保存到本地，我们就可以在笔记本文件中使用新模型。重新输入以下代码——与之前的不同之处用粗体表示，其实只更新了模型文件路径。

aws_model_filename = os.path.join(os.path.expanduser("~"), "models",
"aws_model")
aws_model = load_model(aws_model_filename)
y_true = []
y_pred = []
for actual_gender, predicted_gender in nb_predict_many(aws_model,
testing_filenames[0]):
    y_true.append(actual_gender == "female")
    y_pred.append(predicted_gender == "female")
y_true = np.array(y_true, dtype='int')
y_pred = np.array(y_pred, dtype='int')
print("f1={:.4f}".format(f1_score(y_true, y_pred, pos_label=None)))

使用更多数据后，我们发现F1值为0.81，结果较之前有所改善。

　实验顺利完成后，如果不想继续支付费用，记得从亚马逊S3删除存储段——存储也是要钱的。

12.5　小结

本章研究的是如何运行大数据处理任务。其实，从各方面标准来看，我们的数据集还是有点小——只有几百兆。很多行业实际数据集都比这大多了，对其进行计算就需要更强大的计算能力。此外，我们所使用的算法可根据具体的任务做进一步优化，以提升扩展能力。

为了预测博主的性别，我们从博客文章中抽取词频特征。我们借助mrjob包，用映射和规约方法抽取博客内容和词频。抽取完成后，训练朴素贝叶斯分类器，预测博主性别。

我们可以用mrjob包先在本地测试，然后使用亚马逊的EMR云平台。你也可以使用其他云平台，甚至定制亚马逊EMR集群运行MapReduce任务，但是需要更多的操作才能跑起来。

附录　接下来的方向

全书正文部分至此已结束，学习本书的过程中，也许你已经注意到有很多路我们没有走，遇到多种选择的情况，我们可能只讲了其中几种，还有些主题没有充分展开。在附录部分，我整理了各章进一步学习方向，便于那些意犹未尽的读者深入学习，继续提升用Python挖掘数据的能力。如果你乐于接受挑战的话，本章不容错过。

附录按照章节来组织，每章给出与数据挖掘相关的文章、书籍或其他资源，同时还就该章挖掘任务提出新的挑战，有时只让读者做些小改进，但也给出了几个工作量比较大的挑战——对于难度较大的任务，介绍内容也会更多一些。

第 1 章——开始数据挖掘之旅

Scikit-learn教程

http://scikit-learn.org/stable/tutorial/index.html

scikit-learn文档提供了一系列数据挖掘教程。教程涵盖范围广，从适合新手把玩的数据集一直介绍到最近研究所用的技术。

想从头到尾学一遍这个教程，不肯下功夫是不行的——这份教程很全面——也值得在上面下功夫。

扩展IPython Notebook

http://ipython.org/ipython-doc/1/interactive/public_server.html

IPython Notebook功能强大，扩展方式很多，比如可以在别的计算机上创建服务器，运行笔记本文件，在你的主计算机上访问即可。如果你的主计算机性能一般，比如配置还算凑合的笔记本电脑，而其他可以供你支配的计算机性能很好，使用IPython Notebook在性能不错的计算机运行服务器是个好主意。此外，你还可以创建节点，采用并行方式处理数据。下面网站有不少数据集，一并分享给你。

http://archive.ics.uci.edu/ml/。

网上有很多由学术、商业和政府机构提供的数据集。UCI 机器学习数据库里有一系列标注好的数据集，用它们来测试算法很不错。

尝试下看看OneR算法在不同数据集上的表现。

第 2 章——用scikit-learn估计器分类

k近邻算法的扩展

https://github.com/jnothman/scikit-learn/tree/pr2532

k-近邻算法的简单实现速度很慢——它查看任意两个数据点是否足够近。当然还有更好的方法，scikit-learn实现了几种.。例如，我们可以用kd-tree提升算法速度。

另外一种提升搜索速度的方法叫作局部敏感散列（Locality-Sensitive Hashing，LSH ）。这是对scikit-learn提出的一项改进，写作本书时，还没有加到scikit库中。上述链接为scikit-learn库的一个开发分支，你可以在数据集上测试LSH。具体方法还请参考该分支的相关文档。

复制代码仓库，按照下述网址给出的方法，安装最新版本：http://scikit-learn.org/stable/install.html。

记住要使用这个Git仓库的代码，而不要使用官方的代码。我建议你使用virtualenv或虚拟机安装，不要直接将其装到你的计算机上。这里有一份很棒的virtualenv教程：http://docs.python-guide.org/en/latest/dev/virtualenvs/。

更多复杂的流水线

http://scikit-learn.org/stable/modules/pipeline.html#featureunion-composite-feature-spaces

书中所用到的流水线都是线性的——上一步的输出作为下一步的输入。

流水线也遵循转换器和估计器的接口——流水线可以嵌套，方便构造更复杂的模型，再结合使用上述链接提到的FeatureUnion1方法，流水线将变得更加强大。

1FeatureUnion：将多个转换器对象结合在一起，组成一个新的转换器，汇总每个转换器的输出结果。——译者注

这样一次可以抽取多种类型的特征，组成新的数据集。更多细节及示例请见：http://scikit-learn.org/stable/auto_examples/feature_stacker.html。

比较分类器

scikit-learn提供了很多分类器。在选择分类器时，我们需要考虑一系列因素。你可以通过比较它们的F1值确定哪种效果更好。通过计算各F1值的标准差，就能确定几个分类器的分类效果之间的差异是否显著。

另外需要注意的是，几个分类器应该使用相同的训练集和测试集——也就是说，第一个分类器的测试数据，应该跟其他所有分类器的测试数据相同。使用相同的随机状态可以保证这一点——对于重现实验结果很重要。

第 3 章——用决策树预测获胜球队

pandas的更多内容

http://pandas.pydata.org/pandas-docs/stable/tutorials.html

pandas库很周到——你加载数据时所能用到的各种功能，它很可能都已实现了。更多内容请见上面的链接。

Chris Moffitt 写了篇很不错的博文，介绍常见的Excel数据处理任务，如何用pandas完成：http://pbpython.com/excel-pandas-comp.html。

你还可以用pandas处理大数据集；可以看下StackOverflow用户Jeff对以下问题的解答（写作本书时2是最佳答案），了解处理过程：http://stackoverflow.com/questions/14262433/large-data-work-flows-using-pandas。

2翻译本书时依旧是最佳答案。——译者注

Brian Connelly的pandas教程也很棒：

http://bconnelly.net/2013/10/summarizing-data-in-python-with-pandas/。

更多复杂特征

http://www.basketball-reference.com/teams/ORL/2014_roster_status.html

每个赛季不同的比赛，上场队员也会有所不同。本来志在必得的比赛，很可能因为核心球员受伤而打得很艰难。你可以从篮球参考网站找到球队的队员名单。例如，上述链接指向的就是2013—2014赛季奥兰多魔术队（Orlando Magic）球员名单——所有NBA球队的类似数据都能从该网站找到。

编写代码，整合队员异动信息，以此作为新特征，可以有效提升模型表现，但是工作量不小！

第 4 章——用亲和性分析方法推荐电影

新数据集

http://www2.informatik.uni-freiburg.de/~cziegler/BX/

很多用于特定领域推荐任务的数据集，都值得探索。例如，Book-Crossing数据集包含来自27.8万用户的100多万条图书打分信息。有些评价信息很明确（用户给出分数），而有些则很模糊。模糊评价的权重不应该跟明确评价的一样高。

音乐网站www.last.fm公开了可用于音乐推荐的数据集：http://www.dtic.upf.edu/~ocelma/MusicRecommendationDataset/。

这里有一个用于笑话推荐的数据集！请见http://eigentaste.berkeley.edu/dataset/。

Eclat算法

http://www.borgelt.net/eclat.html

本章实现的Apriori算法是最常用的关联规则挖掘算法，但不是最好的。比较而言，Eclat算法更年轻也更强大，实现起来也比较容易。

第 5 章——用转换器抽取特征

增加噪音

本章讲到了如何通过删除噪音来提升性能；然而，有些数据集需要增加噪音。原因很简单——防止分类器过拟合训练数据，从而生成适用性更强的规则（但过多的噪音会导致模型过于宽泛，效果反而不好）。尝试实现往数据集中添加噪音的转换器。在UCI ML的数据集上进行测试，看看能否提升在测试集上的表现。

Vowpal Wabbit

http://hunch.net/~vw/

Vowpal Wabbit项目用于快速从文本中抽取特征。它针对Python进行了封装，可以直接在Python代码中调用。在大数据集上试试它的效果，比如第12章使用的博客语料。

第 6 章——使用朴素贝叶斯进行社会媒体挖掘3

3社会媒体挖掘相关技术可参考人民邮电出版社出版的《社会媒体挖掘》一书。——编者注

垃圾信息监测

http://scikit-learn.org/stable/modules/model_evaluation.html#scoring-parameter

我们用本章所学到的内容，就可以创建用来监测社会媒体广播是否为垃圾消息的应用。你可以尝试创建由垃圾广播/正常广播组成的数据集，实现文本挖掘算法，评估结果。

垃圾监测算法需要考虑假阳性和假阴性的情况。很多人宁愿多浏览几条垃圾信息，也不愿因为垃圾过滤器过于强大而错过合法的信息。为了满足人们这一需求，你可以把F1值作为评估标准，用网格搜索寻找合适的参数值，具体方法请见上面链接。

自然语言处理和词性标注

http://www.nltk.org/book/ch05.html

本章我们所用到的技术比起其他领域使用的复杂语言模型算是轻量级。例如，别的模型可能使用词性标注来消除同形异义词的歧义，提升准确性。NLTK配套的书4有一章专门讲这个问题，详细内容请见上面的链接。全书也很值得一读。

4中文版书名为《Python自然语言处理》，国内还可以找到英文版的影印版。据NLTK官网介绍2016年上半年将出版第2版。——译者注

第 7 章——用图挖掘找到感兴趣的人

更复杂的算法

https://www.cs.cornell.edu/home/kleinber/link-pred.pdf

关于如何预测包括社交网络在内的图结构中节点之间是否存在边，已经有大量相关研究。例如，David Liben-Nowell和Jon Kleinberg曾就这一主题发表了一篇论文，对于更复杂算法的设计很有帮助，论文请见上面的链接。

NetworkX

https://networkx.github.io/

如果你以后经常处理图和网络结构，深入研究NetworkX很有必要——可视化选项很好用，算法实现也非常不错。另外一个叫作SNAP的库也针对Python做了封装，请见http://snap.stanford.edu/snappy/index.html。

第 8 章——用神经网络破解验证码

好（坏？）验证码

http://scikit-image.org/docs/dev/auto_examples/applications/plot_geometric.html

我们所破解的验证码比起如今网站常用的要简单不少。你可以使用以下技巧来提升破解难度：

采用不同的转换方法。比如scikit-image所提供的（请见上面链接）

使用不同的颜色；尝试无法很好转换为灰度模式的颜色

在图像中添加线条或其他形状：http://scikit-image.org/docs/dev/api/skimage.draw.html

深度网络

使用上述技巧产生的验证码会愚弄我们的破解工具，我们只好改进它。尝试使用第11章的深度网络。

网络越大，需要更多数据，因此你可能需要生成几千张验证码图像以达到好的效果。数据集的生成适合用并行方式来处理——因为它包含大量可以被分别处理的小任务。

增强学习

http://pybrain.org/docs/tutorial/reinforcement-learning.html

数据挖掘领域的下一个方向——增强学习势头日猛，虽然它的出现已经有一段时间了！PyBrain提供了几种增强学习算法，值得在刚创建的验证码数据集上一试（其他数据集也行！）。

第 9 章——作者归属问题

增加数据量

本章预测发件人的应用只使用了一部分邮件，数据集中还有很多邮件可以用。增加发件人的数量可能会导致正确率下降，但是使用相似的方法也有可能提升算法的表现。N元语法中的N以及参数到底取什么值，支持向量机能达到最佳效果，使用网格搜索就能解决这两个问题。我们争取在发件人数量很多的情况下，也能取得好的效果。

博客语料

第12章博客语料给出了博主编号（每个编号代表一个博主），把博主编号看成是类别，就能将其用作本章实验语料。此外，性别、年龄、行业和星座都可以作为分类任务来进行预测——解决作者归属的方法擅长处理这些分类问题吗？

局部N元语法

https://github.com/robertlayton/authorship_tutorials/blob/master/LNGTutorial.ipynb

另外一种分类器使用局部N元语法，从每个作者的作品而不是全部作者的作品中选择最佳特征。我写了一篇用局部N元语法解决作者归属问题的教程，请见上面链接。

第 10 章——新闻语料分类

算法评价

聚类算法效果评价是个大难题——一方面，我们也能多少知道好的分簇结果应该是什么样子；另一方面，如果我们确实知道，那就应该标注一部分数据，使用有监督的分类器对数据进行分类！该话题相关文章很多。下面这份文档介绍了聚类算法评价的难点所在：

http://www.cs.kent.edu/~jin/DM08/ClusterValidation.pdf

这里有份关于该主题的更全面（虽然有点过时）的一份文档：http://web.itu.edu.tr/sgunduz/courses/verimaden/paper/validity_survey.pdf。

scikit-learn实现了几种评价方法，对它们的综述请见http://scikit-learn.org/stable/modules/clustering.html#clustering-performance-evaluation。

有了这些方法，你就能评估使用哪些参数值可以取得更好的聚类效果。网格搜索能帮助我们找到参数的最佳取值——就像在分类中用到的那样！

近期趋势分析

你可以运行我们本章编写的代码几个月。为期间发现的每个簇添加几个标签，便于我们了解哪些主题一直很活跃，从而了解近几个月全世界新闻焦点。

我们可以使用调整互信息等评价标准，比较聚类结果，请见上面评价方法综述链接。观察下一个月、两个月、半年和一年之后聚类的变化结果。

实时聚类

k-means算法可以随着时间的推进，不断训练和更新，而不是在给定时间内做离散分析。你可以通过几种方法跟踪聚类变化——例如，跟踪每个簇中频率最高的几个单词或质心点每天移动的距离。别忘了网站API使用上限——你可能只需要每隔几个小时检查下有无新数据，就能保证结果是实时的。

第 11 章——用深度学习方法为图像中的物体进行分类

Keras和Pylearn2

如果你想进一步了解如何用Python做深度学习，Keras和Pylearn2这两个库有必要了解一下。它们都是以Theano为基础，但各有各的使用方法和特点。

Keras请见https://github.com/fchollet/keras/。

Pylearn2请见http://deeplearning.net/software/pylearn2/。

写作本书时，这两个库还不成熟，比较起来Pylearn2更稳定。它们都能很好完成你交给它们的任务，在做其他深度学习项目时，可以尝试用下。

另外一个很火的库叫Torch，但是写作本书时，还没有Python接口可用（请见http://torch.ch/）。

Mahotas

Mahotas图像处理包提供更好和更复杂的图像处理方法，使用它们能提升正确率，虽然计算量可能大幅上升。然而，很多图像处理任务适合并行处理，所以计算量加大也不再是问题。图像分类相关研究资料很多，下面这两份文档给出不少资料，可以先从这里找起：

http://luispedro.org/software/mahotas/.

http://ijarcce.com/upload/january/22-A%20Survey%20on%20Image%20Classification.pdf

其他图像数据集有：

http://rodrigob.github.io/are_we_there_yet/build/classification_datasets_results.html

一些学术机构和行业组织也开放了很多图像数据集。上述网站介绍了很多数据集及使用它们的最好算法。要实现这样的一些好算法需要大量代码，但是回报远大于付出。

第 12 章——大数据处理

Hadoop课程

雅虎和谷歌面向不同水平的开发者提供了一系列非常实用的Hadoop课程。虽然侧重点不在Python，但是了解Hadoop概念后，再把它们应用到Pydoop或其他类似的库都能取得很好的效果。

雅虎的课程：https://developer.yahoo.com/hadoop/tutorial/。

谷歌的课程：https://cloud.google.com/hadoop/what-is-hadoop。

Pydoop

Pydoop是一个用来运行Hadoop任务的Python库——教程请见http://crs4.github.io/pydoop/tutorial/index.html。

Pydoop可以跟HDFS配合使用，mrjob有类似功能，但在运行某些任务时，Pydoop能给你更多的控制权。

推荐引擎

创建大型的推荐引擎是测试你大数据技能的好方法。Mark Litwintschik在他的博文中介绍了如何使用大数据技术Apache Spark创建推荐引擎，详见http://tech.marksblogg.com/recommendation-engine-spark-python.html。

更多资源

Kaggle竞赛：www.kaggle.com/

Kaggle定期举行数据挖掘竞赛，一般都会提供奖金。参加Kaggle竞赛是学习处理真实场景数据挖掘任务的好方法。他们的社区用户友善、乐于分享——通常你能见到竞赛前10名选手分享代码！

Coursera在线学习网站：

www.coursera.org

Coursera网站上有很多关于数据挖掘和数据科学的课程，大多是专注于某个方向，比如有专门讲大数据和图像处理的。吴恩达（Andrew Ng）老师的机器学习课程综合性强，涉及面比较广，这门课程很受欢迎，从它开始学起很不错：https://www.coursera.org/learn/machine-learning/。

这门课所讲的内容比本书难度要大一点，感兴趣的读者可以将其作为今后的学习方向。

神经网络方面，请留意下这门课程：https://www.coursera.org/course/neuralnets。

如果你完成了上述课程的学习，可以关注下这门关于概率图模型的课程：https://www.coursera.org/course/pgm。

看完了

如果您对本书内容有疑问，可发邮件至contact@turingbook.com，会有编辑或作译者协助答疑。也可访问图灵社区，参与本书讨论。

如果是有关电子书的建议或问题，请联系专用客服邮箱：ebook@turingbook.com。

在这里可以找到我们：

微博 @图灵教育 : 好书、活动每日播报

微博 @图灵社区 : 电子书和好文章的消息

微博 @图灵新知 : 图灵教育的科普小组

微信 图灵访谈 : ituring_interview，讲述码农精彩人生

微信 图灵教育 : turingbooks 　　

091507240605ToBeReplacedWithUserId

版权信息

书名：Python网络编程攻略

作者：Dr. M.O.Faruque Sarker

译者：安道

ISBN：978-7-115-37269-7

本书由北京图灵文化发展有限公司发行数字版。版权所有，侵权必究。

您购买的图灵电子书仅供您个人使用，未经授权，不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟，与我们共同保护知识产权。

如果购买者有侵权行为，我们可能对该用户实施包括但不限于关闭该帐号等维权措施，并可能追究法律责任。

091507240605ToBeReplacedWithUserId

版权声明

致谢

前言

本书内容

阅读本书前的准备工作

本书读者

排版约定

读者反馈

客户支持

下载示例代码

勘误

举报盗版

疑难解答

第 1 章　套接字、IPv4和简单的客户端/服务器编程

1.1　简介

1.2　打印设备名和IPv4地址

1.2.1　准备工作

1.2.2　实战演练

1.2.3　原理分析

1.3　获取远程设备的IP地址

1.3.1　实战演练

1.3.2　原理分析

1.4　将IPv4地址转换成不同的格式

1.4.1　实战演练

1.4.2　原理分析

1.5　通过指定的端口和协议找到服务名

1.5.1　准备工作

1.5.2　实战演练

1.5.3　原理分析

1.6　主机字节序和网络字节序之间相互转换

1.6.1　实战演练

1.6.2　原理分析

1.7　设定并获取默认的套接字超时时间

1.7.1　实战演练

1.7.2　原理分析

1.8　优雅地处理套接字错误

1.8.1　实战演练

1.8.2　原理分析

1.9　修改套接字发送和接收的缓冲区大小

1.9.1　实战演练

1.9.2　原理分析

1.10　把套接字改成阻塞或非阻塞模式

1.10.1　实战演练

1.10.2　原理分析

1.11　重用套接字地址

1.11.1　实战演练

1.11.2　原理分析

1.12　从网络时间服务器获取并打印当前时间

1.12.1　准备工作

1.12.2　实战演练

1.12.3　原理分析

1.13　编写一个SNTP客户端

1.13.1　实战演练

1.13.2　原理分析

1.14　编写一个简单的回显客户端/服务器应用

1.14.1　实战演练

1.14.2　原理分析

第 2 章　使用多路复用套接字I/O提升性能

2.1　简介

2.2　在套接字服务器程序中使用ForkingMixIn

2.2.1　实战演练

2.2.2　原理分析

2.3　在套接字服务器程序中使用ThreadingMixIn

2.3.1　准备工作

2.3.2　实战演练

2.3.3　原理分析

2.4　使用select.select编写一个聊天室服务器

2.4.1　实战演练

2.4.2　原理分析

2.5　使用select.epoll多路复用Web服务器

2.5.1　实战演练

2.5.2　原理分析

2.6　使用并发库Diesel多路复用回显服务器

2.6.1　准备工作

2.6.2　实战演练

2.6.3　原理分析

第 3 章　IPv6、Unix域套接字和网络接口

3.1　简介

3.2　把本地端口转发到远程主机

3.2.1　实战演练

3.2.2　原理分析

3.3　通过ICMP查验网络中的主机

3.3.1　准备工作

3.3.2　实战演练

3.3.3　原理分析

3.4　等待远程网络服务上线

3.4.1　实战演练

3.4.2　原理分析

3.5　枚举设备中的接口

3.5.1　准备工作

3.5.2　实战演练

3.5.3　原理分析

3.6　找出设备中某个接口的IP地址

3.6.1　准备工作

3.6.2　实战演练

3.6.3　原理分析

3.7　探测设备中的接口是否开启

3.7.1　准备工作

3.7.2　实战演练

3.7.3　原理分析

3.8　检测网络中未开启的设备

3.8.1　准备工作

3.8.2　实战演练

3.8.3　原理分析

3.9　使用相连的套接字执行基本的进程间通信

3.9.1　准备工作

3.9.2　实战演练

3.9.3　原理分析

3.10　使用Unix域套接字执行进程间通信

3.10.1　实战演练

3.10.2　原理分析

3.11　确认你使用的Python是否支持IPv6套接字

3.11.1　准备工作

3.11.2　实战演练

3.11.3　原理分析

3.12　从IPv6地址中提取IPv6前缀

3.12.1　实战演练

3.12.2　原理分析

3.13　编写一个IPv6回显客户端/服务器

3.13.1　实战演练

3.13.2　原理分析

第 4 章　HTTP协议网络编程

4.1　简介

4.2　从HTTP服务器下载数据

4.2.1　实战演练

4.2.2　原理分析

4.3　在你的设备中伺服HTTP请求

4.3.1　实战演练

4.3.2　原理分析

4.4　访问网站后提取cookie信息

4.4.1　实战演练

4.4.2　原理分析

4.5　提交网页表单

4.5.1　准备工作

4.5.2　实战演练

4.5.3　原理分析

4.6　通过代理服务器发送Web请求

4.6.1　准备工作

4.6.2　实战演练

4.6.3　原理分析

4.7　使用HEAD请求检查网页是否存在

4.7.1　实战演练

4.7.2　原理分析

4.8　把客户端伪装成Mozilla Firefox

4.8.1　实战演练

4.8.2　原理分析

4.9　使用HTTP压缩节省Web请求消耗的带宽

4.9.1　实战演练

4.9.2　原理分析

4.10　编写一个支持断点续传功能的HTTP容错客户端

4.10.1　实战演练

4.10.2　原理分析

4.11　使用Python和OpenSSL编写一个简单的HTTPS服务器

4.11.1　准备工作

4.11.2　实战演练

4.11.3　原理分析

第 5 章　电子邮件协议、FTP和CGI编程

5.1　简介

5.2　列出FTP远程服务器中的文件

5.2.1　准备工作

5.2.2　实战演练

5.2.3　原理分析

5.3　把本地文件上传到远程FTP服务器中

5.3.1　准备工作

5.3.2　实战演练

5.3.3　原理分析

5.4　把当前工作目录中的内容压缩成ZIP文件后通过电子邮件发送

5.4.1　准备工作

5.4.2　实战演练

5.4.3　原理分析

5.4.4　参考资源

5.5　通过POP3协议下载谷歌电子邮件

5.5.1　准备工作

5.5.2　实战演练

5.5.3　原理分析

5.6　通过IMAP协议查收远程服务器中的电子邮件

5.6.1　准备工作

5.6.2　实战演练

5.6.3　原理分析

5.7　通过Gmail的SMTP服务器发送带有附件的电子邮件

5.7.1　准备工作

5.7.2　实战演练

5.7.3　原理分析

5.8　使用CGI为基于Python的Web服务器编写一个留言板

5.8.1　实战演练

5.8.2　原理分析

第 6 章　屏幕抓取和其他实用程序

6.1　简介

6.2　使用谷歌地图API搜索公司地址

6.2.1　准备工作

6.2.2　实战演练

6.2.3　原理分析

6.2.4　参考资源

6.3　使用谷歌地图URL搜索地理坐标

6.3.1　实战演练

6.3.2　原理分析

6.4　搜索维基百科中的文章

6.4.1　准备工作

6.4.2　实战演练

6.4.3　原理分析

6.5　使用谷歌搜索股价

6.5.1　准备工作

6.5.2　实战演练

6.5.3　原理分析

6.6　搜索GitHub中的源代码仓库

6.6.1　准备工作

6.6.2　实战演练

6.6.3　原理分析

6.7　读取BBC的新闻订阅源

6.7.1　准备工作

6.7.2　实战演练

6.7.3　原理分析

6.8　爬取网页中的链接

6.8.1　实战演练

6.8.2　原理分析

第 7 章　跨设备编程

7.1　简介

7.2　使用telnet在远程主机中执行shell命令

7.2.1　准备工作

7.2.2　实战演练

7.2.3　原理分析

7.3　通过SFTP把文件复制到远程设备中

7.3.1　准备工作

7.3.2　实战演练

7.3.3　原理分析

7.4　打印远程设备的CPU信息

7.4.1　准备工作

7.4.2　实战演练

7.4.3　原理分析

7.5　在远程主机中安装Python包

7.5.1　准备工作

7.5.2　实战演练

7.5.3　原理分析

7.6　在远程主机中运行MySQL命令

7.6.1　准备工作

7.6.2　实战演练

7.6.3　原理分析

7.7　通过SSH把文件传输到远程设备中

7.7.1　准备工作

7.7.2　实战演练

7.7.3　原理分析

7.8　远程配置Apache运行网站

7.8.1　准备工作

7.8.2　实战演练

7.8.3　原理分析

第 8 章　使用Web服务：XML-RPC、SOAP和REST

8.1　简介

8.2　查询本地XML-RPC服务器

8.2.1　准备工作

8.2.2　实战演练

8.2.3　原理分析

8.3　编写一个多线程、多调用XML-RPC服务器

8.3.1　实战演练

8.3.2　原理分析

8.4　运行一个支持HTTP基本认证的XML-RPC服务器

8.4.1　 实战演练

8.4.2　原理分析

8.5　使用REST从Flickr中收集一些照片信息

8.5.1　实战演练

8.5.2　原理分析

8.6　找出亚马逊S3 Web服务支持的SOAP方法

8.6.1　准备工作

8.6.2　实战演练

8.6.3　原理分析

8.7　使用谷歌搜索定制信息

8.7.1　准备工作

8.7.2　实战演练

8.7.3　原理分析

8.8　通过商品搜索API在亚马逊中搜索图书

8.8.1　准备工作

8.8.2　实战演练

8.8.3　原理分析

第 9 章　网络监控和安全性

9.1　简介

9.2　嗅探网络数据包

9.2.1　准备工作

9.2.2　实战演练

9.2.3　原理分析

9.3　使用pcap转储器把数据包保存为pcap格式

9.3.1　实战演练

9.3.2　原理分析

9.4　在HTTP数据包中添加额外的首部

9.4.1　实战演练

9.4.2　原理分析

9.5　扫描远程主机的端口

9.5.1　实战演练

9.5.2　原理分析

9.6　自定义数据包的IP地址

9.6.1　实战演练

9.6.2　原理分析

9.7　读取保存的pcap文件以重放流量

9.7.1　实战演练

9.7.2　原理分析

9.8　扫描数据包的广播

9.8.1　实战演练

9.8.2　原理分析

版权声明

Copyright © 2014 Packt Publishing. First published in the English language under the title Python Network Programming Cookbook.

Simplified Chinese-language edition copyright © 2014 by Posts & Telecom Press. All rights reserved.

本书中文简体字版由Packt Publishing授权人民邮电出版社独家出版。未经出版者书面许可，不得以任何方式复制或抄袭本书内容。

版权所有，侵权必究。

致谢

我要感谢所有为本书出版做出贡献的人，包括出版社、技术审阅人员、编辑、好友和我的家人，尤其是我的妻子Shahinur Rijuani，她给了我关爱，也支持我的工作。我还要感谢耐心等待本书出版的读者，以及给予我很多重要反馈的读者。

前言

很高兴看到本书出版了，我要感谢所有为本书的出版做出贡献的人。本书是Python网络编程方面的探索性指南，涉及了很多网络协议，例如TCP/UDP、HTTP/HTTPS、FTP、SMTP、POP3、IMAP、CGI等。Python功能强大且具交互性，用它来开发解决实际问题的脚本是一种享受，比如处理网络和系统管理操作、开发Web应用、与本地和远程网络交互、捕获并分析低层网络数据包，等等。本书的主要目的是教你动手完成这些任务，因此不会涉及太多理论，而是注重实践。

写作本书的过程中我一直记着要遵守“开发运维”的理念，开发者或多或少都要负责一些运维，即部署应用程序以及管理它的方方面面，例如管理远程服务器、监控、扩放以及性能优化等。书中用到了很多第三方开源Python库，有效解决了多种不同的问题。其中很多库我每天都用，通过它们自动化运行开发和运维任务简直是一种享受。例如，我使用Fabric自动完成软件开发过程中的任务。其他库也各有各的用处，例如搜索互联网、屏幕抓取、在Python脚本中发送电子邮件。

希望你能从本书的攻略中受益，并根据需求扩展它们，让其功能更强大，用起来更得心应手。

本书内容

第1章“套接字、IPv4和简单的客户端/服务器编程”通过多个小型任务讲解Python的核心网络库，教你开发一个客户端/服务器程序。

第2章“使用多路复用套接字I/O提升性能”讨论很多使用内置库和第三方库扩放客户端/服务器程序的实用技术。

第3章“IPv6、Unix域套接字和网络接口”主要关注本地设备的管理和本地网络的维护。

第4章“HTTP协议网络编程”开发一个多功能迷你命令行浏览器，可以提交表单、处理cookie、管理分段下载、压缩数据，还能通过HTTPS交付安全内容。

第5章“电子邮件协议、FTP和CGI编程”带你一起体验自动处理FTP和电子邮件相关任务的乐趣，例如管理Gmail账户、使用脚本收发邮件，还要为Web应用开发一个留言板。

第6章“屏幕抓取和其他实用程序”介绍如何使用多个第三方Python库实现一些实际的任务，例如在谷歌地图上找到公司的位置、从维基百科中抓取信息、在GitHub中搜索代码仓库，以及从BBC读取新闻。

第7章“跨设备编程”带你体验如何使用SSH自动执行系统管理和部署任务。使用SSH，在你的笔记本电脑上就可以远程执行命令、安装包，或者架设新网站。

第8章“使用Web服务：XML-RPC、SOAP和REST”介绍不同的API协议，例如XML-RPC、SOAP和REST。使用这些协议可以通过编程的方式从任何网站或Web服务中读取信息，或者与之交互。例如，可以在亚马逊或谷歌中搜索商品。

第9章“网络监控和安全性”介绍捕获、存储、分析和处理网络数据包的多种技术。了解这些技术之后，你就能使用简洁的Python脚本分析并解决网络安全问题。

阅读本书前的准备工作

你要有一个可以使用的个人电脑或者笔记本电脑，最好安装了某种现代Linux操作系统，例如 Ubuntu、Debian或CentOS等。书中大部分攻略也能在其他平台上运行，例如Windows和Mac OS。

你还需要连接互联网，以便安装攻略中提到的第三方软件库。如果不方便上网，可以下载所有第三方库，一次性安装好。

下面列出本书使用的第三方库及其下载地址。

ntplib：https://pypi.python.org/pypi/ntplib/

diesel：https://pypi.python.org/pypi/diesel/

nmap：https://pypi.python.org/pypi/python-nmap

scapy：https://pypi.python.org/pypi/scapy

netifaces：https://pypi.python.org/pypi/netifaces/

netaddr：https://pypi.python.org/pypi/netaddr

pyopenssl：https://pypi.python.org/pypi/pyOpenSSL

pygeocoder：https://pypi.python.org/pypi/pygocoder

pyyaml：https://pypi.python.org/pypi/PyYAML

requests：https://pypi.python.org/pypi/requests

feedparser：https://pypi.python.org/pypi/feedparser

paramiko：https://pypi.python.org/pypi/paramiko/

fabric：https://pypi.python.org/pypi/Fabric

supervisor：https://pypi.python.org/pypi/supervisor

xmlrpclib：https://pypi.python.org/pypi/xmlrpclib

SOAPpy：https://pypi.python.org/pypi/SOAPpy

bottlenose：https://pypi.python.org/pypi/bottlenose

construct：https://pypi.python.org/pypi/construct/

运行某些攻略还要用到一些非Python软件，如下所示。

postfix：http://www.postfix.org/

OpenSSH服务器：http://www.openssh.com/

MySQL服务器：http://downloads.mysql.com/

Apache2：http://httpd.apache.org/download.cgi

本书读者

如果你是网络程序员、系统/网络管理员或者Web程序开发者，本书是理想之选。你应该对Python编程语言和TCP/IP的概念有个基本的了解。不过，对初学者来说，在阅读本书的过程中也能加强对这些概念的理解。本书也可作为网络编程课程的参考材料，用来培养实践操作能力。

排版约定

阅读本书时你会发现不同类别的信息使用了不同的文本样式，下面举例说明其中一些样式，及其表示的含义。

文本中的代码、数据库表名、文件扩展名和用户输入使用下述方式表示：

如果想知道远程设备的IP地址，可以使用内置的库函数gethostbyname()。

代码块的表示方法如下：

def test_socket_timeout():
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    print "Default socket timeout: %s" %s.gettimeout()
    s.settimeout(100)
    print "Current socket timeout: %s" %s.gettimeout()

命令行输入和输出的表示方法如下：

$ python 2_5_echo_server_with_diesel.py --port=8800
[2013/04/08 11:48:32] {diesel} WARNING:Starting diesel <hand-rolled select.epoll>

读者反馈

我们始终期待收到读者的反馈。请让我们知道你对这本书的看法，喜欢哪些内容，不喜欢哪些内容。读者的反馈对我们来说十分重要，这样我们才能出版读者最需要的图书。

常规反馈请通过电子邮件发到feedback@packtpub.com，在邮件主题中请注明书名。

如果你是某方面的专家，有兴趣写一本书，或者想为其他书做贡献，请阅读我们的作者指南，地址是www.packtpub.com/authors。

客户支持

现在你已经拥有了一本由Packt出版的书，为了让你的付出得到最大回报，我们还为你提供了其他方面的服务。

下载示例代码

如果你是通过http://www.packtpub.com的注册账户购买的图书，可以从该账户中下载相应Packt图书的示例代码。如果你是从其他地方购买的本书，可以访问http://www.packtpub.com/support，注册账户后，我们将会为你发送一封附有示例代码文件的电子邮件。

勘误

虽然我们会全力确保书中内容的准确性，但错误仍在所难免。如果你在某本书中发现了错误（文字错误或代码错误），而且愿意向我们提交这些错误，我们感激不尽。这样不仅可以消除其他读者的疑虑，也有助于改进后续版本。若想提交你发现的错误，请访问http://www.packtpub.com/submit-errata，在“Errata Submission Form”（提交勘误表单）中选择相应图书，输入勘误详情。勘误通过验证之后将上传到Packt网站，或添加到现有的勘误列表中。若想查看某本书的现有勘误信息，请访问http://www.packtpub.com/support，选择相应的书名。

举报盗版

对所有媒体来说，互联网盗版都是一个棘手的问题。Packt很重视版权保护。如果你在互联网上发现我们公司出版物的任何非法复制品，请及时告知我们网址或网站名称，以便我们采取补救措施。

如果发现可疑盗版材料，请通过copyright@packtpub.com联系我们。

你的举报可以帮助我们保护作者权益，也有利于我们不断出版高品质的图书。我们对你深表感激。

疑难解答

如果你对本书的任何内容存有疑问，请发送电子邮件到questions@packtpub.com，我们会尽力解决。

第 1 章　套接字、IPv4和简单的客户端/服务器编程

本章攻略：

打印设备名和IPv4地址

获取远程设备的IP地址

将IPv4地址转换成不同的格式

通过指定的端口和协议找到服务名

主机字节序和网络字节序之间相互转换

设定并获取默认的套接字超时时间

优雅地处理套接字错误

修改套接字发送和接收的缓冲区大小

把套接字改成阻塞或非阻塞模式

重用套接字地址

从网络时间服务器上获取并打印当前时间

编写一个SNTP客户端

编写一个简单的回显客户端/服务器应用

1.1　简介

本章通过一些简单的攻略介绍Python的核心网络库。Python的socket模块提供了类方法和实例方法，二者的区别在于使用类方法时不需要创建套接字对象实例。这是一种很直观的方法。例如，打印设备的IP地址不需要创建套接字对象，而只需调用套接字的类方法。但是，如果要把数据发送给服务器程序，那么创建一个套接字对象来处理具体的操作则更加自然。本章介绍的攻略可以分成如下三类：

前几个攻略使用类方法获取关于主机、网络以及目标服务的有用信息；

随后的几个攻略使用实例方法，演示了常用的套接字操作，例如处理套接字超时、缓冲区大小和阻塞模式等；

最后，结合使用类方法和实例方法开发客户端，执行一些实际的任务，例如使设备时间与网络服务器同步，编写通用的客户端/服务器脚本。

你可以使用本章演示的方法编写自己的客户端/服务器应用。

1.2　打印设备名和IPv4地址

有时，你需要快速查看设备的某些信息，例如主机名、IP地址和网络接口的数量等。这些信息使用Python脚本很容易获取。

1.2.1　准备工作

编写代码之前先要在设备上安装Python。大多数Linux发行版都预装了Python。如果使用微软Windows操作系统，可以从Python的网站上下载二进制文件：http://www.python.org/download/。

要了解系统是否已经安装了Python，可以查阅操作系统的文档。在设备上安装好Python之后，可以在命令行中输入python，尝试打开Python解释器。输入python后应该显示解释器提示符>>>，具体的输出如下所示：

~$ python
Python 2.7.1+ (r271:86832, Apr 11 2011, 18:05:24)
[GCC 4.5.2] on linux2
Type "help", "copyright", "credits" or "license" for more information. >>>

1.2.2　实战演练

这个攻略很简短，可以直接写在Python解释器中。

首先，使用下面的命令导入Python中的socket库：

>>> import socket

然后，调用socket库提供的gethostname()方法，把结果保存在一个变量中，如下所示：

>>> host_name = socket.gethostname()
>>> print "Host name: %s" %host_name
Host name: debian6
>>> print "IP address: %s" %socket.gethostbyname(host_name)
IP address: 127.0.1.1

这些操作可以使用内置的类方法，定义成一个独立的函数print_machine_info()。

我们要在常用的__main__代码块中调用这个函数。运行时，Python会为某些内部变量赋值， 例如__name__。在这里，__name__表示调用程序的进程名。如果在命令行中运行脚本（如后面的命令所示），__name__的值是__main__。但是，如果在其他脚本中导入，情况就不同了。也就是说，如果在命令行中调用这个模块，会自动运行print_machine_info()函数；如果在其他脚本中导入，用户就要手动调用这个函数。

代码清单1-1展示了如何获取设备的信息，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter -1
# This program is optimized for Python 2.7. It may run on any
# other Python version with/without modifications.

import socket

def print_machine_info():
    host_name = socket.gethostname()
    ip_address = socket.gethostbyname(host_name)
    print "Host name: %s" % host_name
    print "IP address: %s" % ip_address

if __name__ == '__main__':
    print_machine_info()

若想运行这个脚本，要在命令行中指定源码文件，如下所示：

$ python 1_1_local_machine_info.py

在我的设备上，显示了如下输出：

Host name: debian6
IP address: 127.0.0.1

在你的设备上，输出的内容根据系统的主机配置会有所不同。

1.2.3　原理分析

import socket语句导入Python提供的一个核心网络库。然后调用两个工具函数：gethostname()和gethostbyname(host_name)。在命令行中可以输入help(socket.gethostname)查看帮助信息，或者在浏览器中访问http://docs. python.org/3/library/socket.html。在命令行中查看这两个函数的帮助信息，得到的输出如下：

gethostname(...)
    gethostname() -> string
    Return the current host name.

gethostbyname(...)
   gethostbyname(host) -> address
    Return the IP address (a string of the form '255.255.255.255') for a host.

第一个函数没有参数，返回所在主机或本地主机的名字。第二个函数接收一个参数hostname，返回对应的IP地址。

1.3　获取远程设备的IP地址

有时需要把设备的主机名转换成对应的IP地址，例如快速查询域名。本攻略介绍一个简单的函数来完成这一操作。

1.3.1　实战演练

如果想知道远程设备的IP地址，可以使用内置的库函数gethostbyname()，其参数是远程设备的主机名。

这里，我们要调用的是类函数gethostbyname()。让我们来看一下这个简短的代码片段。

代码清单1-2展示了如何获取远程设备的IP地址，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter – 1
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import socket

def get_remote_machine_info():
    remote_host = 'www.python.org'
    try:
        print "IP address: %s" %socket.gethostbyname(remote_host)
    except socket.error, err_msg:
        print "%s: %s" %(remote_host, err_msg)

if __name__ == '__main__':
    get_remote_machine_info()

运行上述代码会得到以下输出：

$ python 1_2_remote_machine_info.py
IP address of www.python.org: 82.94.164.162

1.3.2　原理分析

这个攻略把gethostbyname()方法包装在用户定义的get_remote_machine_info()函数中，还引入了异常处理的概念。如上述代码所示，我们把主要的函数调用放在try-except块中，这就意味着，如果执行函数gethostbyname()的过程中发生了错误，这个错误将由try-except块处理。

假如我们修改remote_host参数的值，把www.python.org改成一个不存在的域名，例如www.pytgo.org，然后执行下述命令：

$ python 1_2_remote_machine_info.py
www.pytgo.org: [Errno -5] No address associated with hostname

try-except块捕获了错误，并向用户显示了一个错误消息，说明域名www.pytgo.org没有对应的IP地址。

1.4　将IPv4地址转换成不同的格式

如果要使用低层网络函数，有时普通的字符串形式的IP地址并不是很有用，需要把它们转换成打包后的32位二进制格式。

1.4.1　实战演练

Python的socket库提供了很多用来处理不同IP地址格式的函数，这里我们使用其中的两个：inet_aton()和inet_ntoa()。

我们来定义convert_ip4_address()函数，调用inet_aton()和inet_ntoa()转换IP地址。我们要使用两个示例IP地址：127.0.0.1和192.168.0.1。

代码清单1-3展示了如何定义convert_ip4_address()函数，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter – 1
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import socket
from binascii import hexlify

def convert_ip4_address():
    for ip_addr in ['127.0.0.1', '192.168.0.1']:
        packed_ip_addr = socket.inet_aton(ip_addr)
        unpacked_ip_addr = socket.inet_ntoa(packed_ip_addr)
        print "IP Address: %s => Packed: %s, Unpacked: %s"\
            %(ip_addr, hexlify(packed_ip_addr), unpacked_ip_addr)

if __name__ == '__main__':
    convert_ip4_address()

现在，运行这个攻略，会看到以下输出：

$ python 1_3_ip4_address_conversion.py

IP Address: 127.0.0.1 => Packed: 7f000001, Unpacked: 127.0.0.1
IP Address: 192.168.0.1 => Packed: c0a80001, Unpacked: 192.168.0.1

1.4.2　原理分析

在这个攻略中，使用for-in语句把两个字符串形式的IP地址转换成打包后的32位二进制格式，而且还调用了binascii模块中的hexlify函数，以十六进制形式表示二进制数据。

1.5　通过指定的端口和协议找到服务名

如果想找到网络服务，最好知道该服务运行在TCP或UDP协议的哪个端口上。

1.5.1　准备工作

如果知道网络服务使用的端口，可以调用socket库中的getservbyport()函数来获取服务的名字。调用这个函数时可以根据情况决定是否提供协议名。

1.5.2　实战演练

我们来定义find_service_name()函数，在Python的for-in循环中调用函数getservbyport()，解析几个端口，例如80和25。

代码清单1-4展示了如何定义find_service_name()函数，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter -  1
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import socket

def find_service_name():
    protocolname = 'tcp'
    for port in [80, 25]:
        print "Port: %s => service name: %s" %(port, socket.getservbyport(port, protocolname))
    print "Port: %s => service name: %s" %(53, socket.getservbyport(53, 'udp'))

if __name__ == '__main__':
    find_service_name()

运行这个脚本，会看到如下输出：

$ python 1_4_finding_service_name.py

Port: 80 => service name: http
Port: 25 => service name: smtp
Port: 53 => service name: domain

1.5.3　原理分析

在这个攻略中，使用for-in语句遍历一组变量。在每次遍历中，获取端口对应的服务名。

1.6　主机字节序和网络字节序之间相互转换

编写低层网络应用时，或许需要处理通过电缆在两台设备之间传送的低层数据。在这种操作中，需要把主机操作系统发出的数据转换成网络格式，或者做逆向转换，因为这两种数据的表示方式不一样。

1.6.1　实战演练

Python的socket库提供了将数据在网络字节序和主机字节序之间相互转换的函数。你可能想了解这些函数，例如ntohl()和htonl()。

我们来定义convert_integer()函数，调用ntohl()和htonl()类函数来转换不同格式的数据。

代码清单1-5展示了如何定义convert_integer()函数，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter -
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import socket

def convert_integer():
    data = 1234
    # 32-bit
    print "Original: %s => Long host byte order: %s, Network byte order: %s"\
        %(data, socket.ntohl(data), socket.htonl(data))
    # 16-bit
    print "Original: %s => Short host byte order: %s, Network byte order: %s"\
        %(data, socket.ntohs(data), socket.htons(data))

if __name__ == '__main__':
    convert_integer()

运行这个攻略，会看到以下输出：

$ python 1_5_integer_conversion.py
Original: 1234 => Long host byte order: 3523477504, Network byte order: 3523477504
Original: 1234 => Short host byte order: 53764, Network byte order: 53764

1.6.2　原理分析

在这个攻略中，我们以整数为例，演示了如何把它转换成网络字节序和主机字节序。socket库中的类函数ntohl()把网络字节序转换成了长整形主机字节序。函数名中的n表示网络；h表示主机；l表示长整形；s表示短整形，即16位。

1.7　设定并获取默认的套接字超时时间

有时，你需要处理socket库某些属性的默认值，例如套接字超时时间。

1.7.1　实战演练

你可以创建一个套接字对象实例，调用gettimeout()方法获取默认的超时时间，调用settimeout()方法设定一个超时时间。这种操作在开发服务器应用时很有用。

在test_socket_timeout()函数中，首先创建一个套接字对象，然后使用读取或者设定实例方法处理超时时间。

代码清单1-6展示了如何定义test_socket_timeout()函数，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 1
# This program is optimized for Python 2.7. It may run on any
# other Python version with/without modifications

import socket

def test_socket_timeout():
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    print "Default socket timeout: %s" %s.gettimeout()
    s.settimeout(100)
    print "Current socket timeout: %s" %s.gettimeout()

if __name__ == '__main__':
    test_socket_timeout()

运行上述代码后，你会看到它是如何修改默认超时时间的，如下所示：

$ python 1_6_socket_timeout.py
Default socket timeout: None
Current socket timeout: 100.0

1.7.2　原理分析

在这段代码片段中，首先创建了一个套接字对象。套接字构造方法的第一个参数是地址族，第二个参数是套接字类型。然后，调用gettimeout()方法获取套接字超时时间，再调用settimeout()方法修改超时时间。传给settimeout()方法的参数可以是秒数（非负浮点数）也可以是None。这个方法在处理阻塞式套接字操作时使用。如果把超时时间设为None，则禁用了套接字操作的超时检测。

1.8　优雅地处理套接字错误

在网络应用中，经常会遇到这种情况：一方尝试连接，但另一方由于网络媒介失效或者其他原因无法响应。Python的socket库提供了一个方法，能通过socket.error异常优雅地处理套接字错误。在这个攻略中会举几个例子。

1.8.1　实战演练

我们来编写几个try-except代码块，每个块对应一种可能发生的错误。为了获取用户输入，可以使用argparse模块。这个模块的功能很强大，而不仅是可以使用sys.argv解析命令行参数。这些try-except代码块分别演示了常见的套接字操作，例如创建套接字对象、连接服务器、发送数据和等待应答。

下述攻略使用几行代码演示了如何处理异常。

代码清单1-7展示了如何处理socket.error异常，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter – 1
# This program is optimized for Python 2.7. It may run on any
# other Python version with/without modifications.

import sys
import socket
import argparse

def main():
    # setup argument parsing
    parser = argparse.ArgumentParser(description='Socket Error Examples')
    parser.add_argument('--host', action="store", dest="host", required=False)
    parser.add_argument('--port', action="store", dest="port", type=int, required=False)
    parser.add_argument('--file', action="store", dest="file", required=False)
    given_args = parser.parse_args()
    host = given_args.host
    port = given_args.port
    filename = given_args.file

    # First try-except block -- create socket
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    except socket.error, e:
        print "Error creating socket: %s" % e
        sys.exit(1)

    # Second try-except block -- connect to given host/port
    try:
        s.connect((host, port))
    except socket.gaierror, e:
        print "Address-related error connecting to server: %s" % e
        sys.exit(1)
    except socket.error, e:
        print "Connection error: %s" % e
        sys.exit(1)

    # Third try-except block -- sending data
    try:
        s.sendall("GET %s HTTP/1.0\r\n\r\n" % filename)
    except socket.error, e:
        print "Error sending data: %s" % e
        sys.exit(1)

    while 1:
        # Fourth tr-except block -- waiting to receive data from remote host
        try:
            buf = s.recv(2048)
        except socket.error, e:
            print "Error receiving data: %s" % e
            sys.exit(1)
        if not len(buf):
            break
        # write the received data
        sys.stdout.write(buf)

if __name__ == '__main__':
    main()

1.8.2　原理分析

在Python中，可以使用argparse模块把命令行参数传入脚本以及在脚本中解析命令行参数。这个模块在Python 2.7中可用。如果使用较旧版本的Python，这个模块可以到“Python包索引”（Python Package Index，简称PyPI）中获取，使用easy_install或pip安装。

这个攻略用到了三个命令行参数：主机名、端口号和文件名。上述脚本的使用方法如下：

$ python 1_7_socket_errors.py –host=<HOST> --port=<PORT> --file=<FILE>

如果提供的主机不存在，这个脚本会输出如下错误：

$ python 1_7_socket_errors.py --host=www.pytgo.org --port=8080 --file=1_7_socket_errors.py
Address-related error connecting to server: [Errno -5] No address associated with hostname

如果某个端口上没有服务，你却尝试连接到这个端口，则这个脚本会抛出连接超时异常，如下所示：

$ python 1_7_socket_errors.py --host=www.python.org --port=8080 --file=1_7_socket_errors.py

这个命令会返回如下错误，因为主机www.python.org监听的不是端口8080：

Connection error: [Errno 110] Connection timed out

不过，如果向正确的主机、正确的端口发起随意的请求，应用层可能无法捕获这一异常。例如，运行下述脚本，不会返回错误，但输出的HTML代码说明了脚本的问题：

$ python 1_7_socket_errors.py --host=www.python.org --port=80 --file=1_7_socket_errors.py

HTTP/1.1 404 Not found
Server: Varnish
Retry-After: 0
content-type: text/html
Content-Length: 77
Accept-Ranges: bytes
Date: Thu, 20 Feb 2014 12:14:01 GMT
Via: 1.1 varnish
Age: 0
Connection: close

<html>
<head>
<title> </title>
</head>
<body>
unknown domain: </body></html>

这个攻略用到了四个try-except块。除第二个块处理socket.gaierror异常之外，其他块都处理socket.error异常。socket.gaierror是地址相关的错误。除此之外还有两种异常：socket.herror，C API中抛出的异常；如果在套接字中使用settimeout()方法，套接字超时后会抛出socket.timeout异常。

1.9　修改套接字发送和接收的缓冲区大小

很多情况下，默认的套接字缓冲区大小可能不够用。此时，可以将默认的套接字缓冲区大小改成一个更合适的值。

1.9.1　实战演练

我们要使用套接字对象的setsockopt()方法修改默认的套接字缓冲区大小。

首先，定义两个常量：SEND_BUF_SIZE和RECV_BUF_SIZE。然后在一个函数中调用套接字实例的setsockopt()方法。修改之前，最好先检查缓冲区大小是多少。注意，发送和接收的缓冲区大小要分开设定。

代码清单1-8展示了如何修改套接字的发送和接收缓冲区大小，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter – 1
# This program is optimized for Python 2.7. It may run on any
# other Python version with/without modifications

import socket

SEND_BUF_SIZE = 4096
RECV_BUF_SIZE = 4096

def modify_buff_size():
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM )

    # Get the size of the socket's send buffer
    bufsize = sock.getsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF)
    print "Buffer size [Before]:%d" %bufsize

    sock.setsockopt(socket.SOL_TCP, socket.TCP_NODELAY, 1)
    sock.setsockopt(
            socket.SOL_SOCKET,
            socket.SO_SNDBUF,
            SEND_BUF_SIZE)
    sock.setsockopt(
            socket.SOL_SOCKET,
            socket.SO_RCVBUF,
            RECV_BUF_SIZE)

    bufsize = sock.getsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF)
    print "Buffer size [After]:%d" %bufsize

if __name__ == '__main__':
    modify_buff_size()

运行上述脚本后，会显示修改套接字缓冲区大小前后的变化。根据你所用操作系统的本地设定，得到的输出可能有所不同：

$ python 1_8_modify_buff_size.py
Buffer size [Before]:16384
Buffer size [After]:8192

1.9.2　原理分析

在套接字对象上可调用方法getsockopt()和setsockopt()分别获取和修改套接字对象的属性。setsockopt()方法接收三个参数：level、optname和value。其中，optname是选项名，value是该选项的值。第一个参数所用的符号常量（SO_*等）可在socket模块中查看。

1.10　把套接字改成阻塞或非阻塞模式

默认情况下，TCP套接字处于阻塞模式中。也就是说，除非完成了某项操作，否则不会把控制权交还给程序。例如，调用connect() API后，连接操作会阻止程序继续往下执行，直到连接成功为止。很多情况下，你并不想让程序一直等待服务器响应或者有异常终止操作。例如，如果编写了一个网页浏览器客户端连接服务器，你应该考虑提供取消功能，以便在操作过程中取消连接。这时就要把套接字设置为非阻塞模式。

1.10.1　实战演练

我们来看一下在Python中有哪些选项。在Python中，套接字可以被设置为阻塞模式或者非阻塞模式。在非阻塞模式中，调用API后，例如send()或recv()方法，如果遇到问题就会抛出异常。但在阻塞模式中，遇到错误并不会阻止操作。我们可以创建一个普通的TCP套接字，分别在阻塞模式和非阻塞模式中执行操作实验。

为了能在阻塞模式中处理套接字，首先要创建一个套接字对象。然后，调用setblocking(1)把套接字设为阻塞模式，或者调用setblocking(0)把套接字设为非阻塞模式。最后，把套接字绑定到指定的端口上，监听进入的连接。

代码清单1-9展示了如何把套接字设为阻塞模式或非阻塞模式，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 1
# This program is optimized for Python 2.7. It may run on any
# other Python version with/without modifications

import socket

def test_socket_modes():
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.setblocking(1)
    s.settimeout(0.5)
    s.bind(("127.0.0.1", 0))

    socket_address = s.getsockname()
    print "Trivial Server launched on socket: %s" %str(socket_address)
    while(1):
        s.listen(1)

if __name__ == '__main__':
    test_socket_modes()

运行这个攻略后，会启动一个简易服务器，开启阻塞模式，如下述命令所示：

$ python 1_9_socket_modes.py
Trivial Server launched on socket: ('127.0.0.1', 51410)

1.10.2　原理分析

在这个攻略中，我们把1传给setblocking()方法，启用套接字的阻塞模式。类似地，可以把0传给这个方法，把套接字设为非阻塞模式。

这个功能在后面的一些攻略中会用到，到时再详细说明其真正作用。

1.11　重用套接字地址

不管连接是被有意还是无意关闭，有时你想始终在同一个端口上运行套接字服务器。某些情况下，如果客户端程序需要一直连接指定的服务器端口，这么做就很有用，因为无需改变服务器端口。

1.11.1　实战演练

如果在某个端口上运行一个Python套接字服务器，连接一次之后便终止运行，就不能再使用这个端口了。如果再次连接，程序会抛出如下错误：

Traceback (most recent call last):
  File "1_10_reuse_socket_address.py", line 40, in <module>
    reuse_socket_addr()
  File "1_10_reuse_socket_address.py", line 25, in reuse_socket_addr
    srv.bind( ('', local_port) )
  File "<string>", line 1, in bind
socket.error: [Errno 98] Address already in use

这个问题的解决方法是启用套接字重用选项SO_REUSEADDR。

创建套接字对象之后，我们可以查询地址重用的状态，比如说旧状态。然后，调用setsockopt()方法，修改地址重用状态的值。再按照常规的步骤，把套接字绑定到一个地址上，监听进入的客户端连接。在这个例子中，我们要捕获KeyboardInterrupt异常，这样按下Ctrl+C键后，Python脚本会终止运行，但不会显示任何异常消息。

代码清单1-10展示了如何重用套接字地址，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 1
# This program is optimized for Python 2.7. It may run on any
# other Python version with/without modifications

import socket
import sys

def reuse_socket_addr():
    sock = socket.socket( socket.AF_INET, socket.SOCK_STREAM )

    # Get the old state of the SO_REUSEADDR option
    old_state = sock.getsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR)
    print "Old sock state: %s" %old_state

    # Enable the SO_REUSEADDR option
    sock.setsockopt( socket.SOL_SOCKET, socket.SO_REUSEADDR, 1 )
    new_state = sock.getsockopt( socket.SOL_SOCKET, socket.SO_REUSEADDR )
    print "New sock state: %s" %new_state

    local_port = 8282

    srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    srv.bind( ('', local_port) )
    srv.listen(1)
    print ("Listening on port: %s " %local_port)
    while True:
        try:
            connection, addr = srv.accept()
            print 'Connected by %s:%s' % (addr[0], addr[1])
        except KeyboardInterrupt:
            break
        except socket.error, msg:
            print '%s' % (msg,)

if __name__ == '__main__':
    reuse_socket_addr()

这个攻略的输出如下所示：

$ python 1_10_reuse_socket_address.py
Old sock state: 0
New sock state: 1
Listening on port: 8282

1.11.2　原理分析

你可以在一个终端窗口运行这个脚本，然后在另一个终端窗口中输入telnet localhost 8282，尝试连接这个服务器。关闭服务器程序后，还可以使用同一个端口再次连接。然而，如果你把设定SO_REUSEADDR的那行代码注释掉，服务器将不会再次运行脚本。

1.12　从网络时间服务器获取并打印当前时间

很多程序要求设备的时间精准，例如Unix系统中的make命令。设备上的时间可能不够准确，需要和网络中的时间服务器同步。

1.12.1　准备工作

你可以编写一个Python客户端，让设备上的时间和某个网络时间服务器同步。要完成这一操作，需要使用ntplib，通过“网络时间协议”（Network Time Protocol，简称NTP）处理客户端和服务器之间的通信。如果你的设备中没有安装ntplib，可以使用pip或easy_install从PyPI中安装，命令如下：

pip install ntplib

1.12.2　实战演练

我们先要创建一个NTPClient实例，然后在这个实例上调用request()方法，把NTP服务器的地址传入方法。

代码清单1-11展示了如何从网络时间服务器上获取当前时间并打印出来，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 1
# This program is optimized for Python 2.7. It may run on any
# other Python version with/without modifications

import ntplib
from time import ctime

def print_time():
    ntp_client = ntplib.NTPClient()
    response = ntp_client.request('pool.ntp.org')
    print ctime(response.tx_time)

if __name__ == '__main__':
    print_time()

在我的设备上，运行这个攻略后得到的输出如下：

$ python 1_11_print_machine_time.py
Thu Mar 5 14:02:58 2012

1.12.3　原理分析

在这个攻略中，我们编写了一个NTP客户端，向NTP服务器pool.ntp.org发起了一个NTP请求。响应使用ctime()函数打印出来。

1.13　编写一个SNTP客户端

与前一个攻略不同，有时并不需要从NTP服务器上获取精确的时间。遇到这种情况，就可以使用NTP的简化版本，叫作“简单网络时间协议”。

1.13.1　实战演练

让我们不使用任何第三方库编写一个简单的SNTP客户端。

首先，定义两个常量：NTP_SERVER和TIME1970。NTP_SERVER是客户端要连接的服务器地址，TIME1970指1970年1月1日（也叫Epoch）。在http://www.epochconverter.com/上可以查看Epoch时间值，或者把时间转换成Epoch时间值。这个客户端通过UDP协议创建一个UDP套接字（SOCK_DGRAM），用于连接服务器。然后，客户端要在一个数据包中把数据'\x1b' + 47 * '\0'发给SNTP服务器。UDP客户端分别使用sendto()和recvfrom()方法发送和接收数据。

服务器返回的时间信息打包在一个数组中，客户端需要使用struct模块取出数据。我们所需的数据是数组中的第11个元素。最后，我们要从取出的数据上减掉TIME1970，得到真正的当前时间。

代码清单1-12展示了如何编写这个SNTP客户端，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 1
# This program is optimized for Python 2.7. It may run on any
# other Python version with/without modifications

import socket
import struct
import sys
import time

NTP_SERVER = "0.uk.pool.ntp.org"
TIME1970 = 2208988800L

def sntp_client():
    client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    data = '\x1b' + 47 * '\0'
    client.sendto(data, (NTP_SERVER, 123))
    data, address = client.recvfrom( 1024 )
    if data:
        print 'Response received from:', address
    t = struct.unpack( '!12I', data )[10]
    t -= TIME1970
    print '\tTime=%s' % time.ctime(t)

if __name__ == '__main__':
    sntp_client()

这个攻略通过SNTP协议从网络时间服务器上获取当前时间并打印出来，如下所示：

$ python 1_12_sntp_client.py
Response received from: ('87.117.251.2', 123)
      Time=Tue Feb 25 14:49:38 2014

1.13.2　原理分析

这个SNTP客户端创建一个套接字连接，然后通过协议发送数据。从NTP服务器（这里使用的是0.uk.pool.ntp.org）收到数据后，使用struct模块取出数据。最后，减去1970年1月1日对应的时间戳，再使用Python内置的time模块提供的ctime()方法打印时间。

1.14　编写一个简单的回显客户端/服务器应用

尝试过Python中socket模块的基本API后，现在我们来编写一个套接字服务器和客户端。这里，你将有机会利用在前述攻略中掌握的基本知识。

1.14.1　实战演练

在这个例子中，不管服务器从客户端收到什么输入，都会将其回显出来。我们要使用Python中的argparse模块，在命令行中指定TCP端口。服务器脚本和客户端脚本都要用到这个参数。

我们先来编写服务器。首先创建一个TCP套接字对象。然后设定启用重用地址，这样想运行多少次服务器就能运行多少次。我们把套接字绑定在本地设备的指定端口上。在监听阶段，把backlog参数传入listen()方法中，让服务器在队列中监听多个客户端。最后，等待客户端连接，向服务器发送一些数据。收到数据后，服务器会把数据回显给客户端。

代码清单1-13a展示了如何编写回显应用的服务器，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter – 1
# This program is optimized for Python 2.7. It may run on any
# other Python version with/without modifications.

import socket
import sys
import argparse

host = 'localhost'
data_payload = 2048
backlog = 5

def echo_server(port):
    """ A simple echo server """
    # Create a TCP socket
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    # Enable reuse address/port
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    # Bind the socket to the port
    server_address = (host, port)
    print "Starting up echo server  on %s port %s" % server_address
    sock.bind(server_address)
    # Listen to clients, backlog argument specifies the max no. of queued connections
    sock.listen(backlog)
    while True:
        print "Waiting to receive message from client"
        client, address = sock.accept()
        data = client.recv(data_payload)
        if data:
            print "Data: %s" %data
            client.send(data)
            print "sent %s bytes back to %s" % (data, address)
        # end connection
        client.close()

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Socket Server Example')
    parser.add_argument('--port', action="store", dest="port", type=int, required=True)
    given_args = parser.parse_args()
    port = given_args.port
    echo_server(port)

在客户端代码中，我们要创建一个客户端套接字，然后使用命令行参数中指定的端口连接服务器。客户端把消息Test message. This will be echoed发送给服务器之后，立即就会在几个数据片段中收到返回的消息。这里用到了两个try-except块，捕获交互过程中发生的任何异常。

代码清单1-13b展示了如何编写回显程序的客户端，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter – 1
# This program is optimized for Python 2.7. It may run on any
# other Python version with/without modifications.

import socket
import sys

import argparse

host = 'localhost'

def echo_client(port):
    """ A simple echo client """
    # Create a TCP/IP socket
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    # Connect the socket to the server
    server_address = (host, port)
    print "Connecting to %s port %s" % server_address
    sock.connect(server_address)

    # Send data
    try:
        # Send data
        message = "Test message. This will be echoed"
        print "Sending %s" % message
        sock.sendall(message)
        # Look for the response
        amount_received = 0
        amount_expected = len(message)
        while amount_received < amount_expected:
            data = sock.recv(16)
            amount_received += len(data)
            print "Received: %s" % data
    except socket.errno, e:
        print "Socket error: %s" %str(e)
    except Exception, e:
        print "Other exception: %s" %str(e)
    finally:
        print "Closing connection to the server"
        sock.close()

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Socket Server Example')
    parser.add_argument('--port', action="store", dest="port", type=int, required=True)
    given_args = parser.parse_args()
    port = given_args.port
    echo_client(port)

1.14.2　原理分析

为了查看客户端和服务器之间的交互，要在一个终端里启动如下服务器脚本：

$ python 1_13a_echo_server.py --port=9900
Starting up echo server  on localhost port 9900

Waiting to receive message from client

然后，在另一个终端里运行客户端，如下所示：

$ python 1_13b_echo_client.py --port=9900
Connecting to localhost port 9900
Sending Test message. This will be echoed
Received: Test message. Th
Received: is will be echoe
Received: d
Closing connection to the server

连接到本地主机后，服务器还会输出以下消息：

Data: Test message. This will be echoed
sent Test message. This will be echoed bytes back to ('127.0.0.1', 42961)
Waiting to receive message from client

第 2 章　使用多路复用套接字I/O提升性能

本章攻略：

在套接字服务器程序中使用ForkingMixIn

在套接字服务器程序中使用ThreadingMixIn

使用select.select编写一个聊天室服务器

使用select.epoll多路复用Web服务器

使用并发库Diesel多路复用回显服务器

2.1　简介

本章专注于使用一些有用的技术提升套接字服务器的性能。和前一章不同，本章考虑多个客户端连接服务器的情况，而且可以异步通信。服务器不需要在阻塞模式中处理客户端发出的请求，而是单独处理每个请求。如果某个客户端接收或处理数据时花了很长时间，服务器无需等待处理完成，可以使用另外的线程或进程和其他客户端通信。

本章还要介绍select模块。这个模块建立在底层操作系统内核的select系统调用基础之上，提供了平台专用的I/O监控功能。Linux用户可访问http://man7.org/linux/man-pages/man2/select.2.html查看手册，手册中介绍了select系统调用的可用功能。我们的套接字服务器要和多个客户端交互，所以select可以帮助我们监控非阻塞式套接字。有些第三方Python库也能帮助我们同时处理多个客户端，本章包含一个使用Diesel并发库的示例攻略。

为简单起见，我们只会使用少数几个客户端，但读者可以自行扩展本章的攻略，让它们处理几十甚至几百个客户端。

2.2　在套接字服务器程序中使用ForkingMixIn

你已经决定要编写一个异步Python套接字服务器程序。服务器处理客户端发出的请求时不能阻塞，因此要找到一种机制来单独处理每个客户端。

Python 2.7版中的SocketServer模块提供了两个实用类：ForkingMixIn和ThreadingMixIn。ForkingMixIn会为每个客户端请求派生一个新进程。本节介绍ForkingMixIn类，ThreadingMixIn类将在下一节中介绍。有关SocketServer模块的详情，请参阅Python文档：http://docs.python.org/2/library/socketserver.html。

2.2.1　实战演练

我们要利用SocketServer模块提供的类，重写第1章中的回显服务器。SocketServer模块提供了可以直接使用的TCP、UDP及其他协议服务器。我们可以创建ForkingServer类，继承TCPServer和ForkingMixIn类。前一个父类让ForkingServer类实现了之前手动完成的所有服务器操作，例如创建套接字、绑定地址和监听进入的连接。我们的服务器还要继承ForkingMixIn类，异步处理客户端。

ForkingServer类还要创建一个请求处理程序，说明如何处理客户端请求。在这个攻略中，我们的服务器会回显客户端发送的文本字符串。请求处理类ForkingServerRequestHandler继承自SocketServer库提供的BaseRequestHandler类。

回显服务器的客户端ForkingClient可以使用面向对象的方式编写。在Python中，类的构造方法叫作__init__()。按照惯例，要把self作为参数传入__init__()方法，以便指定具体实例的属性。ForkingClient连接的回显服务器要在__init__()方法中初始化，然后在run()方法中向服务器发送消息。

如果你根本不知道“面向对象编程”（Object-oriented Programming，简称OOP），学习这个攻略之前最好熟悉一下OOP的基本概念。

若想测试ForkingServer类，可以启动多个回显客户端，看看服务器如何响应客户端。

代码清单2-1展示了如何在套接字服务器程序中使用ForkingMixIn类，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 2
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.
# See more: http://docs.python.org/2/library/socketserver.html

import os
import socket
import threading
import SocketServer

SERVER_HOST = 'localhost'
SERVER_PORT = 0 # tells the kernel to pick up a port dynamically
BUF_SIZE = 1024
ECHO_MSG = 'Hello echo server!'

class ForkingClient():
    """ A client to test forking server"""
    def __init__(self, ip, port):
        # Create a socket
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        # Connect to the server
        self.sock.connect((ip, port))

    def run(self):
        """ Client playing with the server"""
        # Send the data to server
        current_process_id = os.getpid()
        print 'PID %s Sending echo message to the server : "%s"' % (current_process_id, ECHO_MSG)
        sent_data_length = self.sock.send(ECHO_MSG)
        print "Sent: %d characters, so far..." %sent_data_length

        # Display server response
        response = self.sock.recv(BUF_SIZE)
        print "PID %s received: %s" % (current_process_id, response[5:])

    def shutdown(self):
        """ Cleanup the client socket """
        self.sock.close()

class ForkingServerRequestHandler(SocketServer.BaseRequestHandler):
    def handle(self):
        # Send the echo back to the client
        data = self.request.recv(BUF_SIZE)
        current_process_id = os.getpid()
        response = '%s: %s' % (current_process_id, data)
        print "Server sending response [current_process_id: data] = [%s]" %response
        self.request.send(response)
        return

class ForkingServer(SocketServer.ForkingMixIn,
                    SocketServer.TCPServer,
                    ):
    """Nothing to add here, inherited everything necessary from parents"""
    pass

def main():
    # Launch the server
    server = ForkingServer((SERVER_HOST, SERVER_PORT), ForkingServerRequestHandler)
    ip, port = server.server_address # Retrieve the port number
    server_thread = threading.Thread(target=server.serve_forever)
    server_thread.setDaemon(True) # don't hang on exit
    server_thread.start()
    print 'Server loop running PID: %s' %os.getpid()

    # Launch the client(s)
    client1 =  ForkingClient(ip, port)
    client1.run()

    client2 =  ForkingClient(ip, port)
    client2.run()

    # Clean them up
    server.shutdown()
    client1.shutdown()
    client2.shutdown()
    server.socket.close()

if __name__ == '__main__':
    main()

2.2.2　原理分析

主线程中创建了一个ForkingServer实例，作为守护进程在后台运行。然后再创建两个客户端和服务器交互。

运行这个脚本后，会看到如下输出：

$ python 2_1_forking_mixin_socket_server.py
Server loop running PID: 12608
PID 12608 Sending echo message to the server : "Hello echo server!"
Sent: 18 characters, so far...
Server sending response [current_process_id: data] = [12610: Hello echo server!]
PID 12608 received: : Hello echo server!
PID 12608 Sending echo message to the server : "Hello echo server!"
Sent: 18 characters, so far...
Server sending response [current_process_id: data] = [12611: Hello echo server!]
PID 12608 received: : Hello echo server!

在你的设备中可能会使用不同的服务器端口号，因为端口号由操作系统内核动态选择。

2.3　在套接字服务器程序中使用ThreadingMixIn

或许基于某些原因你不想编写基于进程的应用程序，而更愿意编写多线程应用程序。可能的原因有：在线程之间共享应用的状态，避免进程间通信的复杂操作，等等。遇到这种需求，如果想使用SocketServer库编写异步网络服务器，就得使用ThreadingMixIn类。

2.3.1　准备工作

对前一个攻略做几处小改动就能使用ThreadingMixIn编写一个可用的套接字服务器。

下载示例代码

如果你是通过http://www.packtpub.com的注册账户购买的图书，可以从该账户中下载相应Packt图书的示例代码。如果你是从其他地方购买的本书，可以访问http://www.packtpub.com/support，注册账户后，我们将会为你发送一封附有示例代码文件的电子邮件。

2.3.2　实战演练

和前一节中基于ForkingMixIn的套接字服务器一样，使用ThreadingMixIn编写的套接字服务器要遵循相同的回显服务器编程模式，不过仍有几点不同。首先，ThreadedTCPServer继承自TCPServer和TheadingMixIn。客户端连接这个多线程版服务器时，会创建一个新线程。详情参见http://docs.python.org/2/library/socketserver.html。

套接字服务器的请求处理类ForkingServerRequestHandler在一个新线程中把消息回显给客户端。在这个类中可以获取线程的信息。简单起见，我们把客户端的代码放在一个函数中，而不是一个类中。客户端代码创建客户端套接字，然后向服务器发送消息。

代码清单2-2展示了如何在回显套接字服务器中使用ThreadingMixIn类，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 2
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import os
import socket
import threading
import SocketServer

SERVER_HOST = 'localhost'
SERVER_PORT = 0 # tells the kernel to pickup a port dynamically
BUF_SIZE = 1024

def client(ip, port, message):
    """ A client to test threading mixin server"""
    # Connect to the server
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.connect((ip, port))
    try:
        sock.sendall(message)
        response = sock.recv(BUF_SIZE)
        print "Client received: %s" %response
    finally:
        sock.close()

class ThreadedTCPRequestHandler(SocketServer.BaseRequestHandler):
    """ An example of threaded TCP request handler """
    def handle(self):
        data = self.request.recv(1024)
        cur_thread = threading.current_thread()
        response = "%s: %s" %(cur_thread.name, data)
        self.request.sendall(response)

class ThreadedTCPServer(SocketServer.ThreadingMixIn, SocketServer.TCPServer):
    """Nothing to add here, inherited everything necessary from parents"""
    pass

if __name__ == "__main__":
    # Run server
    server = ThreadedTCPServer((SERVER_HOST, SERVER_PORT), ThreadedTCPRequestHandler)
    ip, port = server.server_address # retrieve ip address

    # Start a thread with the server -- one  thread per request
    server_thread = threading.Thread(target=server.serve_forever)
    # Exit the server thread when the main thread exits
    server_thread.daemon = True
    server_thread.start()
    print "Server loop running on thread: %s"  %server_thread.name

    # Run clients
    client(ip, port, "Hello from client 1")
    client(ip, port, "Hello from client 2")
    client(ip, port, "Hello from client 3")

    # Server cleanup
    server.shutdown()

2.3.3　原理分析

这个攻略首先创建一个服务器线程，并在后台启动。然后启动三个测试客户端，向服务器发送消息。作为响应，服务器把消息回显给客户端。在服务器请求处理类的handle()方法中，我们取回了当前线程的信息并将其打印出来，这些信息在每次客户端连接中都不同。

在客户端和服务器的通信中用到了sendall()方法，以保证发送的数据无任何丢失。

$ python 2_2_threading_mixin_socket_server.py
Server loop running on thread: Thread-1
Client received: Thread-2: Hello from client 1
Client received: Thread-3: Hello from client 2
Client received: Thread-4: Hello from client 3

2.4　使用select.select编写一个聊天室服务器

在大型网络服务器应用程序中可能有几百或几千个客户端同时连接服务器，此时为每个客户端创建单独的线程或进程可能不切实际。由于内存可用量受限，且主机的CPU能力有限，我们需要一种更好的技术来处理大量的客户端。幸好，Python提供的select模块能解决这一问题。

2.4.1　实战演练

我们将编写一个高效的聊天室服务器，处理几百或更多数量的客户端连接。我们要使用select模块提供的select()方法，让聊天室服务器和客户端所做的操作始终不会阻塞消息的发送和接收。

这个攻略使用一个脚本就能启动客户端和服务器，执行脚本时要指定--name参数。只有在命令行中传入了--name=server，脚本才启动聊天室服务器。如果为--name参数指定了其他值，例如client1或client2，则脚本会启动聊天室客户端。聊天室服务器绑定的端口在命令行参数--port中指定。对大型应用程序而言，最好在不同的模块中编写服务器和客户端。

代码清单2-3展示了一个使用select.select编写的聊天室应用示例，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 2
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import select
import socket
import sys
import signal
import cPickle
import struct
import argparse

SERVER_HOST = 'localhost'
CHAT_SERVER_NAME = 'server'

# Some utilities
def send(channel, *args):
    buffer = cPickle.dumps(args)
    value = socket.htonl(len(buffer))
    size = struct.pack("L",value)
    channel.send(size)
    channel.send(buffer)

def receive(channel):
    size = struct.calcsize("L")
    size = channel.recv(size)
    try:
        size = socket.ntohl(struct.unpack("L", size)[0])
    except struct.error, e:
        return ''
    buf = ""
    while len(buf) < size:
        buf = channel.recv(size - len(buf))
    return cPickle.loads(buf)[0]

send()函数接收一个具名参数channel和一个定位参数*args，使用cPickle模块中的dumps()方法序列化数据，使用struct模块计算数据的大小。同样，receive()函数也接收一个具名参数channel。

然后定义ChatServer类，如下所示：

class ChatServer(object):
    """ An example chat server using select """
    def __init__(self, port, backlog=5):
        self.clients = 0
        self.clientmap = {}
        self.outputs = [] # list output sockets
        self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        self.server.bind((SERVER_HOST, port))
        print 'Server listening to port: %s ...' %port
        self.server.listen(backlog)
        # Catch keyboard interrupts
        signal.signal(signal.SIGINT, self.sighandler)

    def sighandler(self, signum, frame):
        """ Clean up client outputs"""
        # Close the server
        print 'Shutting down server...'
        # Close existing client sockets
        for output in self.outputs:
            output.close()
        self.server.close()

    def get_client_name(self, client):
        """ Return the name of the client """
        info = self.clientmap[client]
        host, name = info[0][0], info[1]
        return '@'.join((name, host))

ChatServer类的主要执行方法如下所示：

def run(self):
    inputs = [self.server, sys.stdin]
    self.outputs = []
    running = True
    while running:
        try:
            readable, writeable, exceptional = select.select(inputs, self.outputs, [])
        except select.error, e:
            break

        for sock in readable:
            if sock == self.server:
                # handle the server socket
                client, address = self.server.accept()
                print "Chat server: got connection %d from %s" % (client.fileno(), address)
                # Read the login name
                cname = receive(client).split('NAME: ')[1]

                # Compute client name and send back
                self.clients += 1
                send(client, 'CLIENT: ' + str(address[0]))
                inputs.append(client)
                self.clientmap[client] = (address, cname)
                # Send joining information to other clients
                msg = "\n(Connected: New client (%d) from %s)" % (self.clients, self.get_client_name(client))
                for output in self.outputs:
                    send(output, msg)
                self.outputs.append(client)

            elif sock == sys.stdin:
                # handle standard input
                junk = sys.stdin.readline()
                running = False
            else:
                # handle all other sockets
                try:
                    data = receive(sock)
                    if data:
                        # Send as new client's message...
                        msg = '\n#[' + self.get_client_name(sock) + ']>>' + data
                        # Send data to all except ourself
                        for output in self.outputs:
                            if output != sock:
                                send(output, msg)
                    else:
                        print "Chat server: %d hung up" % sock.fileno()
                        self.clients -= 1
                        sock.close()
                        inputs.remove(sock)
                        self.outputs.remove(sock)

                        # Sending client leaving information to others
                        msg = "\n(Now hung up: Client from %s)" % self.get_client_name(sock)
                        for output in self.outputs:
                            send(output, msg)
                except socket.error, e:
                    # Remove
                    inputs.remove(sock)
                    self.outputs.remove(sock)
    self.server.close()

初始化聊天室服务器时创建了一些属性：客户端数量、客户端映射和输出的套接字。和之前创建服务器套接字一样，初始化时也设定了重用地址的选项，这么做可以使用同一个端口重启服务器。聊天室服务器类的构造方法还有一个可选参数backlog，用于设定服务器监听的连接队列的最大数量。

这个聊天室服务器有个值得介绍的地方，它可以使用signal模块捕获用户的中断操作。中断操作一般通过键盘输入。ChatServer类为中断信号（SIGINT）注册了一个信号处理方法sighandler。信号处理方法捕获从键盘输入的中断信号后，关闭所有输出套接字，其中一些套接字可能还有数据等待发送。

聊天室服务器的主要执行方法是run()，在while循环中执行操作。run()方法注册了一个select接口，输入参数是聊天室服务器套接字stdin，输出参数由服务器的输出套接字列表指定。调用select.select()方法后得到三个列表：可读套接字、可写套接字和异常套接字。聊天室服务器只关心可读套接字，其中保存了准备被读取的数据。如果可读套接字是服务器本身，表示有一个新客户端连到服务器上了，服务器会读取客户端的名字，将其广播给其他客户端。如果输入参数中有内容，聊天室服务器会退出。类似地，这个聊天室服务器也能处理其他客户端套接字的输入，转播客户端直接传送的数据，还能共享客户端进入和离开聊天室的信息。

聊天室客户端应该包含以下代码：

class ChatClient(object):
    """ A command line chat client using select """

    def __init__(self, name, port, host=SERVER_HOST):
        self.name = name
        self.connected = False
        self.host = host
        self.port = port
        # Initial prompt
        self.prompt='[' + '@'.join((name, socket.gethostname().split('.')[0])) + ']> '
        # Connect to server at port
        try:
            self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            self.sock.connect((host, self.port))
            print "Now connected to chat server@ port %d" % self.port
            self.connected = True
            # Send my name...
            send(self.sock,'NAME: ' + self.name)
            data = receive(self.sock)
            # Contains client address, set it
            addr = data.split('CLIENT: ')[1]
            self.prompt = '[' + '@'.join((self.name, addr)) + ']> '
        except socket.error, e:
            print "Failed to connect to chat server @ port %d" % self.port
            sys.exit(1)

    def run(self):
        """ Chat client main loop """
        while self.connected:
            try:
                sys.stdout.write(self.prompt)
                sys.stdout.flush()
                # Wait for input from stdin and socket
                readable, writeable,exceptional = select.select([0, self.sock], [],[])
                for sock in readable:
                    if sock == 0:
                        data = sys.stdin.readline().strip()
                        if data: send(self.sock, data)
                    elif sock == self.sock:
                        data = receive(self.sock)
                        if not data:
                            print 'Client shutting down.'
                            self.connected = False
                            break
                        else:
                            sys.stdout.write(data + '\n')
                            sys.stdout.flush()

            except KeyboardInterrupt:
                print " Client interrupted. """
                self.sock.close()
                break

初始化聊天室客户端时指定了name参数，连接到聊天室服务器之后，这个名字会发送给服务器。初始化时还设置了一个自定义的提示符[name@host]>。客户端的执行方法run()在连接到服务器的过程中一直运行着。和聊天室服务器类似，聊天室客户端也使用select()方法注册。只要可读套接字做好了准备，客户端就开始接收数据。如果sock的值为0，而且有可用的数据，客户端就可以发送数据。发送的数据还会显示在stdout或者本例中的命令行终端里。主方法应该接收命令行参数，调用服务器或者客户端，如下所示：

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description='Socket Server Example with Select')
    parser.add_argument('--name', action="store", dest="name", required=True)
    parser.add_argument('--port', action="store", dest="port", type=int, required=True)
    given_args = parser.parse_args()
    port = given_args.port
    name = given_args.name
    if name == CHAT_SERVER_NAME:
        server = ChatServer(port)
        server.run()
    else:
        client = ChatClient(name=name, port=port)
        client.run()

这个脚本要运行三次：一次用于启动聊天室服务器，两次用于启动两个聊天室客户端。启动服务器时，在命令行中传入参数–-name=server和--port=8800。启动client1时，把名字参数改成--name=client1；启动client2时改为--name=client2。然后在client1中发送消息"Hello from client 1"，这个消息会显示在client2的终端里。同样，在client2中发送消息"hello from client 2"，也会在client1的终端里显示。

服务器的输出如下：

$ python 2_3_chat_server_with_select.py --name=server --port=8800
Server listening to port: 8800 ...
Chat server: got connection 4 from ('127.0.0.1', 56565)
Chat server: got connection 5 from ('127.0.0.1', 56566)

client1的输出如下：

$ python 2_3_chat_server_with_select.py --name=client1 --port=8800
Now connected to chat server@ port 8800
[client1@127.0.0.1]>
(Connected: New client (2) from client2@127.0.0.1)
[client1@127.0.0.1]> Hello from client 1
[client1@127.0.0.1]>
#[client2@127.0.0.1]>>hello from client 2

client2的输出如下：

$ python 2_3_chat_server_with_select.py --name=client2 --port=8800
Now connected to chat server@ port 8800
[client2@127.0.0.1]>
#[client1@127.0.0.1]>>Hello from client 1
[client2@127.0.0.1]> hello from client 2
[client2@127.0.0.1]

整个交互过程如下面的截图所示：

2.4.2　原理分析

在这个模块的顶端定义了两个实用函数：send()和receive()。

在聊天室服务器和客户端中用到了这两个函数，如前面的代码所示。聊天室服务器和客户端中定义的方法前面也介绍过了。

2.5　使用select.epoll多路复用Web服务器

Python的select模块中有很多针对特定平台的网络事件管理函数。在Linux设备中可以使用epoll。这个函数利用操作系统内核轮询网络事件，让脚本知道有事件发生了。这听起来比前面介绍的select.select方案更高效。

2.5.1　实战演练

我们来编写一个简单的Web服务器，向每一个连接服务器的网页浏览器返回一行文本。

这个脚本的核心在Web服务器的初始化过程中，我们要调用方法select.epoll()，注册服务器的文件描述符，以达到事件通知的目的。在Web服务器执行的代码中，套接字事件由下述代码监控：

代码清单2-4 展示了如何使用select.epoll实现简单的Web服务器，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 2
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import socket
import select
import argparse

SERVER_HOST = 'localhost'

EOL1 = b'\n\n'
EOL2 = b'\n\r\n'
SERVER_RESPONSE  = b"""HTTP/1.1 200 OK\r\nDate: Mon, 1 Apr 2013 01:01:01 GMT\r\nContent-Type: text/plain\r\nContent-Length: 25\r\n\r\n
Hello from Epoll Server!"""

class EpollServer(object):
    """ A socket server using Epoll"""

    def __init__(self, host=SERVER_HOST, port=0):
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        self.sock.bind((host, port))
        self.sock.listen(1)
        self.sock.setblocking(0)
        self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
        print "Started Epoll Server"
        self.epoll = select.epoll()
        self.epoll.register(self.sock.fileno(), select.EPOLLIN)

    def run(self):
        """Executes epoll server operation"""
        try:
            connections = {}; requests = {}; responses = {}
            while True:
                events = self.epoll.poll(1)
                for fileno, event in events:
                    if fileno == self.sock.fileno():
                        connection, address = self.sock.accept()
                        connection.setblocking(0)
                        self.epoll.register(connection.fileno(), select.EPOLLIN)
                        connections[connection.fileno()] = connection
                        requests[connection.fileno()] = b''
                        responses[connection.fileno()] = SERVER_RESPONSE
                    elif event & select.EPOLLIN:
                        requests[fileno] += connections[fileno].recv(1024)
                        if EOL1 in requests[fileno] or EOL2 in requests[fileno]:
                            self.epoll.modify(fileno, select.EPOLLOUT)
                            print('-'*40 + '\n' + requests[fileno].decode()[:-2])
                    elif event & select.EPOLLOUT:
                        byteswritten = connections[fileno].send(responses[fileno])
                        responses[fileno] = responses[fileno][byteswritten:]
                        if len(responses[fileno]) == 0:
                            self.epoll.modify(fileno, 0)
                            connections[fileno].shutdown(socket.SHUT_RDWR)
                    elif event & select.EPOLLHUP:
                        self.epoll.unregister(fileno)
                        connections[fileno].close()
                        del connections[fileno]
        finally:
            self.epoll.unregister(self.sock.fileno())
            self.epoll.close()
            self.sock.close()

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Socket Server Example with Epoll')
    parser.add_argument('--port', action="store", dest="port", type=int, required=True)
    given_args = parser.parse_args()
    port = given_args.port
    server = EpollServer(host=SERVER_HOST, port=port)
    server.run()

运行这个脚本，在网页浏览器（例如Firefox或IE）中输入http://localhost:8800/访问服务器，在终端会看到如下输出：

$ python 2_4_simple_web_server_with_epoll.py --port=8800
Started Epoll Server
----------------------------------------
GET / HTTP/1.1
Host: localhost:8800
Connection: keep-alive
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
User-Agent: Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.31 (KHTML, like Gecko) Chrome/26.0.1410.43 Safari/537.31
DNT: 1
Accept-Encoding: gzip,deflate,sdch
Accept-Language: en-GB,en-US;q=0.8,en;q=0.6
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.3
Cookie: MoodleSession=69149dqnvhett7br3qebsrcmh1;MOODLEID1_=%257F%25BA%2B%2540V

----------------------------------------
GET /favicon.ico HTTP/1.1
Host: localhost:8800
Connection: keep-alive
Accept: */*
DNT: 1
User-Agent: Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.31 (KHTML, like Gecko) Chrome/26.0.1410.43 Safari/537.31
Accept-Encoding: gzip,deflate,sdch
Accept-Language: en-GB,en-US;q=0.8,en;q=0.6
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.3

在浏览器中还会看到以下这行文本：

Hello from Epoll Server!

这一过程的截图如下所示：

2.5.2　原理分析

在Web服务器EpollServer的构造方法中创建了一个套接字服务器，绑定到本地主机的指定端口上。服务器的套接字被设定为非阻塞模式（setblocking(0)），并设定了TCP_NODELAY选项，让服务器无需缓冲便可直接交换数据（比如在SSH连接中）。然后创建了一个select.epoll()实例，再把套接字的文件描述符传给这个实例，以便监控。

在这个Web服务器的run()方法中开始监听套接字事件。事件由下述常量表示：

EPOLLIN：套接字读事件

EPOLLOUT：套接字写事件

这个套接字服务器把响应设为SERVER_RESPONSE。如果连接套接字服务器的客户端想写数据，可以在EPOLLOUT事件中完成。发生内部错误时，EPOLLHUP事件会把一个异常关闭信号发给套接字服务器。

2.6　使用并发库Diesel多路复用回显服务器

有时你需要编写一个大型自定义网络应用程序，但不想重复输入初始化服务器的代码，比如说创建套接字、绑定地址、监听以及处理基本的错误等。有很多Python网络库都可以帮助你把样板代码删除。这里我们要使用一个提供这种功能的库，它叫作Diesel。

2.6.1　准备工作

Diesel使用非阻塞和协程技术提升编写网络服务器的效率。Diesel的网站上有这么一句话：“Diesel的核心是一个紧密的事件轮询，使用epoll提供几近平稳的性能，即便有10 000个或更多的连接也无妨。”这一节我们通过一个简单的回显服务器介绍Diesel的用法。你需要安装Diesel 3.0或者更新的版本，使用pip命令即可完成：$ pip install diesel >= 3.0。

2.6.2　实战演练

在Python的Diesel框架中，应用程序使用Application()类的实例初始化，事件处理函数注册在这个实例上。我们来看一下使用Diesel编写回显服务器是多么简单。

代码清单2-5展示了如何使用Diesel编写回显服务器，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 2
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.
# You alos need diesel library 3.0 or any later version

import diesel
import argparse

class EchoServer(object):
    """ An echo server using diesel"""

    def handler(self, remote_addr):
        """Runs the echo server"""
        host, port = remote_addr[0], remote_addr[1]
        print "Echo client connected from: %s:%d" %(host, port)

        while True:
            try:
                message = diesel.until_eol()
                your_message = ': '.join(['You said', message])
                diesel.send(your_message)
            except Exception, e:
                print "Exception:",e

def main(server_port):
    app = diesel.Application()
    server = EchoServer()
    app.add_service(diesel.Service(server.handler, server_port))
    app.run()

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Echo server example with Diesel')
    parser.add_argument('--port', action="store", dest="port", type=int, required=True)
    given_args = parser.parse_args()
    port = given_args.port
    main(port)

运行这个脚本后，服务器会显示如下输出：

$ python 2_5_echo_server_with_diesel.py --port=8800
[2013/04/08 11:48:32] {diesel} WARNING:Starting diesel <hand-rolled select.epoll>
Echo client connected from: 127.0.0.1:56603

在另一个终端窗口中可以使用Telnet客户端连接回显服务器，测试消息回显，如下所示：

$ telnet localhost 8800
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Hello Diesel server?
You said: Hello Diesel server?

下面这个截图显示了和Diesel聊天室服务器交互的过程：

2.6.3　原理分析

这个脚本从命令行参数--port中获取端口号，将其传给main()函数。Diesel应用程序在main()函数中初始化并运行。

在Diesel中有“服务”的概念，应用程序可以提供多种服务。EchoServer类中定义了handler()方法，让服务器能够处理单独的客户端连接。运行服务时要把handler()方法和端口号作为参数传给Service()方法。

handler()方法决定服务器的行为，在这个脚本中，服务器直接返回消息文本。

如果把这个脚本和第1章中的“编写一个简单的回显客户端/服务器应用”攻略（代码清单1-13a）对比，很明显能看出，我们不需要编写样板代码，因此很容易把精力集中在高层应用逻辑上。

第 3 章　IPv6、Unix域套接字和网络接口

本章攻略：

把本地端口转发到远程主机

通过ICMP查验网络中的主机

等待远程网络服务上线

枚举设备中的接口

找出设备中某个接口的IP地址

探测设备中的接口是否开启

检测网络中未开启的设备

使用相连的套接字执行基本的进程间通信

使用Unix域套接字执行进程间通信

确认你使用的Python是否支持IPv6套接字

从IPv6地址中提取IPv6前缀

编写一个IPv6回显客户端/服务器

3.1　简介

本章使用一些第三方库扩展Python中socket库的用法，还要介绍一些高级技术，例如Python标准库中的ayncore异步模块。与此同时，还会涉及很多不同的协议，例如ICMP查验和IPv6客户端/服务器。

本章通过一些示例攻略介绍几个有用的Python第三方模块的用法，例如Python网络程序员熟知的网络数据包抓取库Scapy。

本章部分攻略专门介绍Python中IPv6的处理方法，包括开发一个IPv6客户端/服务器应用程序。其他攻略则涉及Unix域套接字。

3.2　把本地端口转发到远程主机

有时，你需要创建一个本地端口转发器，把本地端口发送的流量全部重定向到特定的远程主机上。利用这个功能，可以让用户只能访问特定的网站，而不能访问其他网站。

3.2.1　实战演练

我们来编写一个本地端口转发脚本，把8800端口接收到的所有流量重定向到谷歌的首页（http://www.google.com）。我们可以把本地主机和远程主机连同端口号一起传入脚本。简单起见，这里只指定本地端口号，因为我们知道Web服务器运行在80端口上。

代码清单3-1是一个端口转发示例，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 3
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import argparse

LOCAL_SERVER_HOST = 'localhost'
REMOTE_SERVER_HOST = 'www.google.com'
BUFSIZE = 4096

import asyncore
import socket

首先，我们来定义PortForwarder类：

class PortForwarder(asyncore.dispatcher):
    def __init__(self, ip, port, remoteip,remoteport,backlog=5):
        asyncore.dispatcher.__init__(self)
        self.remoteip=remoteip
        self.remoteport=remoteport
        self.create_socket(socket.AF_INET,socket.SOCK_STREAM)
        self.set_reuse_addr()
        self.bind((ip,port))
        self.listen(backlog)

    def handle_accept(self):
        conn, addr = self.accept()
        print "Connected to:",addr
        Sender(Receiver(conn),self.remoteip,self.remoteport)

然后定义Receiver和Sender类，如下所示：

class Receiver(asyncore.dispatcher):
    def __init__(self,conn):
        asyncore.dispatcher.__init__(self,conn)
        self.from_remote_buffer=''
        self.to_remote_buffer=''
        self.sender=None

    def handle_connect(self):
        pass

    def handle_read(self):
        read = self.recv(BUFSIZE)
        self.from_remote_buffer += read

    def writable(self):
        return (len(self.to_remote_buffer) > 0)

    def handle_write(self):
        sent = self.send(self.to_remote_buffer)
        self.to_remote_buffer = self.to_remote_buffer[sent:]

    def handle_close(self):
        self.close()
        if self.sender:
            self.sender.close()

class Sender(asyncore.dispatcher):
    def __init__(self, receiver, remoteaddr,remoteport):
        asyncore.dispatcher.__init__(self)
        self.receiver=receiver
        receiver.sender=self
        self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
        self.connect((remoteaddr, remoteport))

    def handle_connect(self):
        pass

    def handle_read(self):
        read = self.recv(BUFSIZE)
        self.receiver.to_remote_buffer += read

    def writable(self):
        return (len(self.receiver.from_remote_buffer) > 0)

    def handle_write(self):
        sent = self.send(self.receiver.from_remote_buffer)
        self.receiver.from_remote_buffer = self.receiver.from_remote_buffer[sent:]

    def handle_close(self):
        self.close()
        self.receiver.close()

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description='Stackless Socket Server Example')
    parser.add_argument('--local-host', action="store", dest="local_host", default=LOCAL_SERVER_HOST)
    parser.add_argument('--local-port', action="store", dest="local_port", type=int, required=True)
    parser.add_argument('--remote-host', action="store", dest="remote_host",  default=REMOTE_SERVER_HOST)
    parser.add_argument('--remote-port', action="store", dest="remote_port", type=int, default=80)
    given_args = parser.parse_args()
    local_host, remote_host = given_args.local_host, given_args.remote_host
    local_port, remote_port = given_args.local_port, given_args.remote_port

    print "Starting port forwarding local %s:%s => remote %s:%s" % (local_host, local_port, remote_host, remote_port)
    PortForwarder(local_host, local_port, remote_host, remote_port)
    asyncore.loop()

运行这个脚本后，会看到如下输出：

$ python 3_1_port_forwarding.py --local-port=8800
Starting port forwarding local localhost:8800 => remote www.google.com:80

现在打开浏览器，访问http://localhost:8800。浏览器会把你带到谷歌的首页，在命令行中会输出类似下面的信息：

Connected to: ('127.0.0.1', 38557)

把本地端口转发到远程主机的过程如下面的截图所示：

3.2.2　原理分析

我们创建了一个端口转发类PortForwarder，继承自asyncore.dispatcher。asyncore.dispatcher类包装了一个套接字对象，还提供了一些帮助方法用于处理特定的事件，例如连接成功或客户端连接到服务器套接字。你可以选择重定义这些方法，在上面的脚本中我们只重定义了handle_accept()方法。

另外两个类也继承自asyncore.dispatcher。Receiver类处理进入的客户端请求，Sender类接收一个Receiver类实例，把数据发送给客户端。如你所见，这两个类都重定义了handle_read()、handle_write()和writeable()三个方法，目的是实现远程主机和本地客户端之间的双向通信。

概括来说，PortForwarder类在一个本地套接字中保存进入的客户端请求，然后把这个套接字传给Sender类实例，再使用Receiver类实例发起与远程主机指定端口之间的双向通信。

3.3　通过ICMP查验网络中的主机

ICMP查验（ICMP ping）1是你见过的最普通的网络扫描类型。ICMP查验做起来很简单，打开命令行或终端，输入ping www.google.com即可。这在Python程序中又有什么难的呢？这个攻略展示了一个简单的Python查验脚本。

1ICMP是Internet Control Message Protocol的简称，意思是“网络控制报文协议”。——译者注

3.3.1　准备工作

要在你的设备上运行这个脚本，需要有超级用户或管理员权限才行。

3.3.2　实战演练

你可以偷个懒，在Python脚本中调用系统中的ping命令，如下所示：

import subprocess
import shlex

command_line = "ping -c 1 www.google.com"
args = shlex.split(command_line)
try:
    subprocess.check_call(args,stdout=subprocess.PIPE,\
        stderr=subprocess.PIPE)
    print "Google web server is up!"
except subprocess.CalledProcessError:
    print "Failed to get ping."

然而，很多情况下，系统中的ping可执行文件不可用，或者无法访问。此时，我们需要一个纯粹的Python脚本实现查验。注意，这个脚本要使用超级用户或者管理员的身份运行。

代码清单3-2展示了如何执行ICMP查验，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 3
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import os
import argparse
import socket
import struct
import select
import time

ICMP_ECHO_REQUEST = 8 # Platform specific
DEFAULT_TIMEOUT = 2
DEFAULT_COUNT = 4

class Pinger(object):
    """ Pings to a host -- the Pythonic way"""

    def __init__(self, target_host, count=DEFAULT_COUNT, timeout=DEFAULT_TIMEOUT):
        self.target_host = target_host
        self.count = count
        self.timeout = timeout

    def do_checksum(self, source_string):
        """  Verify the packet integritity """
        sum = 0
        max_count = (len(source_string)/2)*2
        count = 0
        while count < max_count:
            val = ord(source_string[count + 1])*256 + ord(source_string[count])
            sum = sum + val
            sum = sum & 0xffffffff
            count = count + 2

        if max_count<len(source_string):
            sum = sum + ord(source_string[len(source_string) - 1])
            sum = sum & 0xffffffff

        sum = (sum >> 16)  +  (sum & 0xffff)
        sum = sum + (sum >> 16)
        answer = ~sum
        answer = answer & 0xffff
        answer = answer >> 8 | (answer << 8 & 0xff00)
        return answer

    def receive_pong(self, sock, ID, timeout):
        """
        Receive ping from the socket.
        """
        time_remaining = timeout
        while True:
            start_time = time.time()
            readable = select.select([sock], [], [], time_remaining)
            time_spent = (time.time() - start_time)
            if readable[0] == []: # Timeout
                return

            time_received = time.time()
            recv_packet, addr = sock.recvfrom(1024)
            icmp_header = recv_packet[20:28]
            type, code, checksum, packet_ID, sequence = struct.unpack(
                "bbHHh", icmp_header
            )
            if packet_ID == ID:
                bytes_In_double = struct.calcsize("d")
                time_sent = struct.unpack("d", recv_packet[28:28 + bytes_In_double])[0]
                return time_received - time_sent

            time_remaining = time_remaining - time_spent
            if time_remaining <= 0:
                return

我们要定义send_ping()方法，把查验请求的数据发送给目标主机。而且，在这个方法中还要调用do_checksum()方法，检查查验数据的完整性，如下所示：

def send_ping(self, sock,  ID):
    """
    Send ping to the target host
    """
    target_addr  =  socket.gethostbyname(self.target_host)

    my_checksum = 0

    # Create a dummy heder with a 0 checksum.
    header = struct.pack("bbHHh", ICMP_ECHO_REQUEST, 0, my_checksum, ID, 1)
    bytes_In_double = struct.calcsize("d")
    data = (192 - bytes_In_double) * "Q"
    data = struct.pack("d", time.time()) + data

    # Get the checksum on the data and the dummy header.
    my_checksum = self.do_checksum(header + data)
    header = struct.pack(
        "bbHHh", ICMP_ECHO_REQUEST, 0, socket.htons(my_checksum), ID, 1
    )
    packet = header + data
    sock.sendto(packet, (target_addr, 1))

我们再来定义一个方法，ping_once()，只向目标主机发送一次查验。在这个方法中，把ICMP协议传给socket()方法，创建一个原始的ICMP套接字。异常处理代码负责处理未使用超级用户运行脚本的情况，以及其他套接字错误。代码如下：

def ping_once(self):
    """
    Returns the delay (in seconds) or none on timeout.
    """
    icmp = socket.getprotobyname("icmp")
    try:
        sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, icmp)
    except socket.error, (errno, msg):
        if errno == 1:
            # Not superuser, so operation not permitted
            msg +=  "ICMP messages can only be sent from root user processes"
            raise socket.error(msg)
    except Exception, e:
        print "Exception: %s" %(e)

    my_ID = os.getpid() & 0xFFFF

    self.send_ping(sock, my_ID)
    delay = self.receive_pong(sock, my_ID, self.timeout)
    sock.close()
    return delay

这个类要执行的主方法是ping()。这个方法中有个for循环，在for循环中调用ping_once()方法count次。延迟时间从查验的响应中获取，单位为秒。如果没有返回延迟时间，就意味着查验失败。代码如下：

    def ping(self):
        """
        Run the ping process
        """
        for i in xrange(self.count):
            print "Ping to %s..." % self.target_host,
            try:
                delay  =  self.ping_once()
            except socket.gaierror, e:
                print "Ping failed. (socket error: '%s')" % e[1]
                break

            if delay  ==  None:
                print "Ping failed. (timeout within %ssec.)" % self.timeout
            else:
                delay  =  delay * 1000
                print "Get pong in %0.4fms" % delay

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Python ping')
    parser.add_argument('--target-host', action="store", dest="target_host", required=True)
    given_args = parser.parse_args()
    target_host = given_args.target_host
    pinger = Pinger(target_host=target_host)
    pinger.ping()

以超级用户的身份运行这个脚本，得到的输出如下所示：

$ sudo python 3_2_ping_remote_host.py --target-host=www.google.com
Ping to www.google.com... Get pong in 7.6921ms
Ping to www.google.com... Get pong in 7.1061ms
Ping to www.google.com... Get pong in 8.9211ms
Ping to www.google.com... Get pong in 7.9899ms

3.3.3　原理分析

Pinger类定义了很多有用的方法，初始化时创建了几个变量，其值由用户指定，或者有默认值，如下所示：

target_host：要查验的目标主机；

count：查验次数；

timeout：这个值决定何时终止未完成的查验操作。

在send_ping()方法中获取了目标主机的DNS主机名，然后使用struct模块创建了一个ICMP_ECHO_REQUEST数据包。在这个方法中一定要使用do_checksum()方法检查数据的完整性。do_checksum()方法接收一个源字符串，经过处理之后生成一个特有的校验和。在接收端，receive_pong()方法在未到达超时时间之前一直等待响应，或者直接接收响应，然后抓取ICMP响应首部，对比数据包ID，再计算请求-响应循环的延迟时间。

3.4　等待远程网络服务上线

有时，在网络服务恢复的过程中，可以运行一个脚本检查服务器何时再次上线。

3.4.1　实战演练

我们可以编写一个客户端，一直等待某个网络服务上线，或者只等待一段时间。在这个示例中，默认情况下我们检查的是本地主机中的一个Web服务器。如果你指定了其他远程主机或端口，这个脚本会使用你提供的信息。

代码清单3-3展示了如何等待远程网络服务上线，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter – 3
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import argparse
import socket
import errno
from time import time as now

DEFAULT_TIMEOUT = 120
DEFAULT_SERVER_HOST = 'localhost'
DEFAULT_SERVER_PORT = 80

class NetServiceChecker(object):
    """ Wait for a network service to come online"""
    def __init__(self, host, port, timeout=DEFAULT_TIMEOUT):
        self.host = host
        self.port = port
        self.timeout = timeout
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

    def end_wait(self):
        self.sock.close()

    def check(self):
        """ Check the service """
        if self.timeout:
            end_time = now() + self.timeout

        while True:
            try:
                if self.timeout:
                    next_timeout = end_time - now()
                    if next_timeout < 0:
                        return False
                    else:
                        print "setting socket next timeout %ss" %round(next_timeout)
                        self.sock.settimeout(next_timeout)
                self.sock.connect((self.host, self.port))
            # handle exceptions
            except socket.timeout, err:
                if self.timeout:
                    return False
            except socket.error, err:
                print "Exception: %s" %err
            else: # if all goes well
                self.end_wait()
                return True

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Wait for Network Service')
    parser.add_argument('--host', action="store", dest="host",  default=DEFAULT_SERVER_HOST)
    parser.add_argument('--port', action="store", dest="port", type=int, default=DEFAULT_SERVER_PORT)
    parser.add_argument('--timeout', action="store", dest="timeout", type=int, default=DEFAULT_TIMEOUT)
    given_args = parser.parse_args()
    host, port, timeout = given_args.host, given_args.port, given_args.timeout
    service_checker = NetServiceChecker(host, port, timeout=timeout)
    print "Checking for network service %s:%s ..." %(host, port)
    if service_checker.check():
        print "Service is available again!"

如果在你的设备上运行着一个Web服务器，例如Apache，运行这个脚本后会看到如下输出：

$ python 3_3_wait_for_remote_service.py
Waiting for network service localhost:80 ...
setting socket next timeout 120.0s
Service is available again!

现在停止Apache进程，再运行这个脚本，然后重启Apache。此时看到的输出会有所不同，在我的设备上，输出如下：

Exception: [Errno 103] Software caused connection abort
setting socket next timeout 104.189137936
Exception: [Errno 111] Connection refused
setting socket next timeout 104.186291933
Exception: [Errno 103] Software caused connection abort
setting socket next timeout 104.186164856
Service is available again!

下面的截图展示了等待Apache Web服务器上线的过程：

3.4.2　原理分析

上述脚本使用argparse模块接收用户的输入，处理主机名、端口和超时时间。超时时间指等待所需网络服务的时间。这个脚本创建了一个NetServiceChecker类实例，然后调用check()方法。这个方法计算等待的最后结束时间，并使用套接字的settimeout()方法控制每次循环的结束时间，即next_timeout。然后check()方法调用套接字的connect()方法在超时时间到达之前测试所需的网络服务是否可用。check()方法还能捕获套接字超时异常，以及比较套接字超时时间和用户指定的超时时间。

3.5　枚举设备中的接口

在Python中列出设备中的网络接口并不难。有很多第三方库可以使用，只需几行代码即可。不过，我们来看一下如何只使用套接字调用完成这一操作。

3.5.1　准备工作

这个攻略需要在Linux设备中运行。若想列出可用的网络接口，可以执行下面的命令：

$ /sbin/ifconfig

3.5.2　实战演练

代码清单3-4展示了如何列出网络接口，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 3
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import sys
import socket
import fcntl
import struct
import array

SIOCGIFCONF = 0x8912 #from C library sockios.h
STUCT_SIZE_32 = 32
STUCT_SIZE_64 = 40
PLATFORM_32_MAX_NUMBER =  2**32
DEFAULT_INTERFACES = 8

def list_interfaces():
    interfaces = []
    max_interfaces = DEFAULT_INTERFACES
    is_64bits = sys.maxsize > PLATFORM_32_MAX_NUMBER
    struct_size = STUCT_SIZE_64 if is_64bits else STUCT_SIZE_32
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    while True:
        bytes = max_interfaces * struct_size
        interface_names = array.array('B', '\0' * bytes)
        sock_info = fcntl.ioctl(
            sock.fileno(),
            SIOCGIFCONF,
            struct.pack('iL', bytes, interface_names.buffer_info()[0])
        )
        outbytes = struct.unpack('iL', sock_info)[0]
        if outbytes == bytes:
            max_interfaces *= 2
        else:
            break
    namestr = interface_names.tostring()
    for i in range(0, outbytes, struct_size):
        interfaces.append((namestr[i:i+16].split('\0', 1)[0]))
    return interfaces

if __name__ == '__main__':
    interfaces = list_interfaces()
    print "This machine has %s network interfaces: %s." %(len(interfaces), interfaces)

上述脚本能列出网络接口，输出结果如下：

$ python 3_4_list_network_interfaces.py
This machine has 2 network interfaces: ['lo', 'eth0'].

3.5.3　原理分析

这个攻略使用低层套接字特性找出系统中的接口。list_interfaces()方法创建一个套接字对象，通过处理这个对象找到网络接口信息，做法是调用fnctl模块中的ioctl()方法。fnctl模块用到了一些Unix程序，例如fnctl()。这个接口在底层的文件描述符套接字上执行I/O控制操作。文件描述符通过在套接字对象上调用fileno()方法获取。

ioctl()方法的其他参数包括：C套接字库中定义的常量SIOCGIFADDR，以及使用struct模块中的pack()函数生成的数据结构。数据结构中指定的内存地址保存在变量interface_names中，经修改后作为ioctl()方法的结果返回。从ioctl()方法的返回结果sock_info中取出数据后，如果大小和变量bytes相等，则将网络接口的数量翻倍。为了防止之前假设的接口数量不正确，这个操作要在一个while循环中完成，以便找出所有接口。

接口的名字从变量interface_names的字符串形式中提取，先读取这个变量的指定字段，然后再把获得的值添加到接口列表的末尾。在list_interfaces()函数的最后，返回这个接口列表。

3.6　找出设备中某个接口的IP地址

在Python网络应用程序中可能需要找出某个网络接口的IP地址。

3.6.1　准备工作

这个攻略是Linux专用的。有一些Python模块经过特别设计，为Windows和Mac平台提供了类似的功能。例如，http://sourceforge.net/projects/pywin32/是专为Windows实现的库。

3.6.2　实战演练

你可以使用fnctl模块在你的设备中查询IP地址。

代码清单3-5展示了如何在你的设备中找出指定接口的IP地址，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 3
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import argparse
import sys
import socket
import fcntl
import struct
import array

def get_ip_address(ifname):
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    return socket.inet_ntoa(fcntl.ioctl(
        s.fileno(),
        0x8915,  # SIOCGIFADDR
        struct.pack('256s', ifname[:15])
    )[20:24])

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Python networking utils')
    parser.add_argument('--ifname', action="store", dest="ifname", required=True)
    given_args = parser.parse_args()
    ifname = given_args.ifname
    print "Interface [%s] --> IP: %s" %(ifname, get_ip_address(ifname))

这个脚本的输出只有一行，如下所示：

$ python 3_5_get_interface_ip_address.py --ifname=eth0
Interface [eth0] --> IP: 10.0.2.15

3.6.3　原理分析

这个攻略和前一个攻略类似。上述脚本接收一个命令行参数：要查询的IP地址的网络接口名。get_ip_address()函数创建一个套接字对象，然后调用fnctl.ioctl()函数利用这个套接字对象查询IP信息。注意，socket.inet_ntoa()函数的作用是，把二进制数据转换成我们熟悉的人类可读的点分格式。

3.7　探测设备中的接口是否开启

如果设备中有多个网络接口，在使用某个接口前你需要知道它的状态，例如，这个接口是否开启。这样才能保证把命令传递给处于激活状态的接口。

3.7.1　准备工作

这个攻略是为Linux设备编写的，因此无法在Windows或Mac主机上运行。这个攻略用到了一个著名的网络扫描工具——nmap。在nmap的网站http://nmap.org/中可以了解更多信息。

运行这个攻略还需要python-nmap模块，可使用pip安装，如下所示：

$ pip install python-nmap

3.7.2　实战演练

我们可以创建一个套接字对象，然后获取接口的IP地址，再使用任何一种扫描技术探测接口的状态。

代码清单3-6展示了如何探测网络接口的状态，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 3
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import argparse
import socket
import struct
import fcntl
import nmap

SAMPLE_PORTS = '21-23'

def get_interface_status(ifname):
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    ip_address = socket.inet_ntoa(fcntl.ioctl(
        sock.fileno(),
        0x8915, #SIOCGIFADDR, C socket library sockios.h
        struct.pack('256s', ifname[:15])
    )[20:24])
    nm = nmap.PortScanner()
    nm.scan(ip_address, SAMPLE_PORTS)
    return nm[ip_address].state()

if  __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Python networking utils')
    parser.add_argument('--ifname', action="store", dest="ifname", required=True)
    given_args = parser.parse_args()
    ifname = given_args.ifname
    print "Interface [%s] is: %s" %(ifname, get_interface_status(ifname))

如果运行这个脚本查询eth0的状态，会看到类似下面的输出：

$ python 3_6_find_network_interface_status.py --ifname=eth0
Interface [eth0] is: up

3.7.3　原理分析

这个攻略从命令行中读取接口名，然后将其传给get_interface_status()函数。这个函数通过处理一个UDP套接字对象找到该接口的IP地址。

这个攻略需要第三方模块nmap的支持。我们可以使用pip从PyPI上安装这个模块。nmap扫描的实例nm，是通过调用PortScanner()创建的。初步扫描本地IP后就能获取对应网络接口的状态。

3.8　检测网络中未开启的设备

如果有人给你网络中一些设备的IP地址，让你编写一个脚本定期找出哪些主机未开启，你可以编写一个网络扫描类型的程序，而无需在目标主机电脑中安装任何软件。

3.8.1　准备工作

这个攻略需要安装Scapy库（2.2以上版本），下载地址为http://www.secdev.org/projects/scapy/files/scapy-latest.zip。

3.8.2　实战演练

我们可以使用成熟的第三方网络分析库Scapy启动ICMP扫描。因为我们要定期运行这个脚本，所以需要用到Python中的sched模块，安排扫描任务。

代码清单3-7展示了如何检测未开启的设备，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 3
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.
# This recipe requires scapy-2.2.0 or higher

import argparse
import time
import sched
from scapy.all import sr, srp, IP, UDP, ICMP, TCP, ARP, Ether
RUN_FREQUENCY = 10
scheduler = sched.scheduler(time.time, time.sleep)

def detect_inactive_hosts(scan_hosts):
    """
    Scans the network to find scan_hosts are live or dead
    scan_hosts can be like 10.0.2.2-4 to cover range.
    See Scapy docs for specifying targets.
    """
    global scheduler
    scheduler.enter(RUN_FREQUENCY, 1, detect_inactive_hosts, (scan_hosts, ))
    inactive_hosts = []
    try:
        ans, unans = sr(IP(dst=scan_hosts)/ICMP(), retry=0, timeout=1)
        ans.summary(lambda(s,r) : r.sprintf("%IP.src% is alive"))
        for inactive in unans:
            print "%s is inactive" %inactive.dst
            inactive_hosts.append(inactive.dst)

        print "Total %d hosts are inactive" %(len(inactive_hosts))
    except KeyboardInterrupt:
        exit(0)

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description='Python networking utils')
    parser.add_argument('--scan-hosts', action="store", dest="scan_hosts", required=True)
    given_args = parser.parse_args()
    scan_hosts = given_args.scan_hosts
    scheduler.enter(1, 1, detect_inactive_hosts, (scan_hosts, ))
    scheduler.run()

这个脚本的输出如下面的命令行所示：

$ sudo python 3_7_detect_inactive_machines.py --scan-hosts=10.0.2.2-4
Begin emission:
.*...Finished to send 3 packets.
.
Received 6 packets, got 1 answers, remaining 2 packets
10.0.2.2 is alive
10.0.2.4 is inactive
10.0.2.3 is inactive
Total 2 hosts are inactive
Begin emission:
*.Finished to send 3 packets.
Received 3 packets, got 1 answers, remaining 2 packets
10.0.2.2 is alive
10.0.2.4 is inactive
10.0.2.3 is inactive
Total 2 hosts are inactive

3.8.3　原理分析

上述脚本先从命令行中读取一组网络主机的地址，保存到变量scan_hosts中，然后创建一个日程表，每隔一秒运行一次detect_inactive_hosts()函数。detect_inactive_hosts()函数的参数是scan_hosts，该函数调用了Scapy库的sr()函数。

detect_inactive_hosts()函数再次调用schedule.enter()函数，以安排自己10秒钟之后再次运行。如此一来，我们就能定期执行扫描任务了。

Scapy库的sr()函数接收的参数分别是IP、协议和一些扫描控制信息。在这个脚本中，把scan_hosts传给IP()方法，作为扫描的目标主机，协议指定为ICMP。协议还可使用TCP或UDP。我们没有指定重试一次并把超时时间设为一秒，以便提升脚本的运行速度。你可以自己尝试，找到符合需求的选项值。

扫描函数sr()在一个元组中返回有应答的主机和无应答的主机。我们获取了无应答的主机，构建成一个列表，然后打印出来。

3.9　使用相连的套接字执行基本的进程间通信

有时，两个脚本要通过两个进程彼此通信。在Unix/Linux中，有一个概念叫作“相连的套接字”，即socketpair。这一节对此做些实验。

3.9.1　准备工作

这个脚本为Unix/Linux主机而编写，不适合在Windows/Mac中运行。

3.9.2　实战演练

我们要在test_socketpair()函数中编写几行代码，测试套接字的socketpair()函数。

代码清单3-8是一个socketpair用法示例，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 3
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import socket
import os

BUFSIZE = 1024

def test_socketpair():
    """ Test Unix socketpair"""
    parent, child = socket.socketpair()

    pid = os.fork()
    try:
        if pid:
            print "@Parent, sending message..."
            child.close()
            parent.sendall("Hello from parent!")
            response = parent.recv(BUFSIZE)
            print "Response from child:", response
            parent.close()

        else:
            print "@Child, waiting for message from parent"
            parent.close()
            message = child.recv(BUFSIZE)
            print "Message from parent:", message
            child.sendall("Hello from child!!")
            child.close()
    except Exception, err:
        print "Error: %s" %err

if __name__ == '__main__':
    test_socketpair()

上述脚本的输出如下所示：

$ python 3_8_ipc_using_socketpairs.py
@Parent, sending message...
@Child, waiting for message from parent
Message from parent: Hello from parent!
Response from child: Hello from child!!

3.9.3　原理分析

socket.socketpair()函数返回的是两个相连的套接字对象，这里我们把其中一个称为父套接字，另一个称为子套接字。我们调用os.fork()方法派生出了另一个进程，其返回结果是父进程的ID。在各个进程中，先把另一个进程中的套接字关闭，然后在当前进程中的套接字上调用sendall()方法交换消息。在try-except块中如果出现异常，就把错误打印出来。

3.10　使用Unix域套接字执行进程间通信

有时使用Unix域套接字（Unix Domain Socket，简称UDS）处理两个进程之间的通信更方便。在Unix中，一切都是文件。如果你需要一个这种进程间通信的例子，这个攻略可以给你一些帮助。

3.10.1　实战演练

我们要启动一个UDS服务器，绑定到一个文件系统路径上。然后启动一个UDS客户端，使用相同的路径和服务器通信。

代码清单3-9a是一个Unix域套接字服务器，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter – 3
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import socket
import os
import time

SERVER_PATH = "/tmp/python_unix_socket_server"

def run_unix_domain_socket_server():
    if os.path.exists(SERVER_PATH):
        os.remove( SERVER_PATH )

    print "starting unix domain socket server."
    server = socket.socket( socket.AF_UNIX, socket.SOCK_DGRAM )
    server.bind(SERVER_PATH)

    print "Listening on path: %s" %SERVER_PATH
    while True:
        datagram = server.recv( 1024 )
        if not datagram:
            break
        else:
            print "-" * 20
            print datagram
        if "DONE" == datagram:
            break
    print "-" * 20
    print "Server is shutting down now..."
    server.close()
    os.remove(SERVER_PATH)
    print "Server shutdown and path removed."

if __name__ == '__main__':
    run_unix_domain_socket_server()

代码清单3-9b是一个UDS客户端，如下所示

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 3
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import socket
import sys

SERVER_PATH = "/tmp/python_unix_socket_server"

def run_unix_domain_socket_client():
    """ Run "a Unix domain socket client """
    sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)

    # Connect the socket to the path where the server is listening
    server_address = SERVER_PATH
    print "connecting to %s" % server_address
    try:
        sock.connect(server_address)
    except socket.error, msg:
        print >>sys.stderr, msg
        sys.exit(1)

    try:
        message = "This is the message.  This will be echoed back!"
        print  "Sending [%s]" %message
        sock.sendall(message)
        amount_received = 0
        amount_expected = len(message)

        while amount_received < amount_expected:
            data = sock.recv(16)
            amount_received += len(data)
            print >>sys.stderr, "Received [%s]" % data

    finally:
        print "Closing client"
        sock.close()

if __name__ == '__main__':
    run_unix_domain_socket_client()

服务器的输出如下所示：

$ python 3_9a_unix_domain_socket_server.py
starting unix domain socket server.
Listening on path: /tmp/python_unix_socket_server
--------------------
This is the message.  This will be echoed back!

客户端的输出如下所示：

$ python 3_9b_unix_domain_socket_client.py
connecting to /tmp/python_unix_socket_server
Sending [This is the message.  This will be echoed back!]

3.10.2　原理分析

我们为UDS客户端和服务器定义了一个共用的路径，二者都用这个路径连接和监听。

在服务器的代码中，如果前一次运行脚本后路径仍然存在，就将其删除。然后创建一个Unix数据报套接字，绑定到指定的路径上，监听进入的连接。在数据处理循环中，使用recv()方法获取客户端发出的数据并打印到屏幕上。

客户端代码直接打开一个Unix数据报套接字，连接共用的服务器地址。客户端调用sendall()方法向服务器发送一个消息，然后等待这些消息返回，再打印出来。

3.11　确认你使用的Python是否支持IPv6套接字

IP第6版（IPv6）在业内越来越多地被用来开发新型应用。如果你想编写一个IPv6应用程序，首先要知道你的设备是否支持IPv6。在Linux/Unix中，可通过下面的命令确认：

$ cat /proc/net/if_inet6
00000000000000000000000000000001 01 80 10 80       lo
fe800000000000000a0027fffe950d1a 02 40 20 80     eth0

使用Python脚本也可以检查你的设备是否支持IPv6，以及所安装的Python是否支持。

3.11.1　准备工作

在这个攻略中，要使用pip安装一个Python第三方库，netifaces，如下所示：

$ pip install netifaces

3.11.2　实战演练

我们可以使用第三方库netifaces确认你的设备是否支持IPv6。我们要调用这个库中的interfaces()函数，列出系统中的所有接口。

代码清单3-10是检查设备是否支持IPv6的Python脚本，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 3
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.
#IPv6  test in Unix  commandline: $cat /proc/net/if_inet6

import socket
import argparse
import netifaces as ni

def inspect_ipv6_support():
    """ Find the ipv6 address"""
    print "IPV6 support built into Python: %s" %socket.has_ipv6
    ipv6_addr = {}
    for interface in ni.interfaces():
        all_addresses = ni.ifaddresses(interface)
        print "Interface %s:" %interface
        for family,addrs in all_addresses.iteritems():
            fam_name = ni.address_families[family]
            print '  Address family: %s' % fam_name
            for addr in addrs:
                if fam_name == 'AF_INET6':
                    ipv6_addr[interface] = addr['addr']
                print     '    Address  : %s' % addr['addr']
                nmask = addr.get('netmask', None)
                if nmask:
                    print '    Netmask  : %s' % nmask
                bcast = addr.get('broadcast', None)
                if bcast:
                    print '    Broadcast: %s' % bcast
    if ipv6_addr:
        print "Found IPv6 address: %s" %ipv6_addr
    else:
        print "No IPv6 interface found!"

if __name__ == '__main__':
    inspect_ipv6_support()

这个脚本的输出如下所示：

$ python 3_10_check_ipv6_support.py
IPV6 support built into Python: True
Interface lo:
  Address family: AF_PACKET
    Address  : 00:00:00:00:00:00
  Address family: AF_INET
    Address  : 127.0.0.1
    Netmask  : 255.0.0.0
  Address family: AF_INET6
    Address : ::1
    Netmask  : ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff
Interface eth0:
  Address family: AF_PACKET
    Address  : 08:00:27:95:0d:1a
    Broadcast: ff:ff:ff:ff:ff:ff
  Address family: AF_INET
    Address  : 10.0.2.15
    Netmask  : 255.255.255.0
    Broadcast: 10.0.2.255
  Address family: AF_INET6
    Address  : fe80::a00:27ff:fe95:d1a
    Netmask  : ffff:ffff:ffff:ffff::
Found IPv6 address: {'lo': '::1', 'eth0': 'fe80::a00:27ff:fe95:d1a'}

3.11.3　原理分析

检查设备是否支持IPv6的函数inspect_ipv6_support()首先使用socket.has_ipv6检查编译Python时是否加入了IPv6支持。然后调用netifaces模块中的interfaces()函数，列出所有接口。调用ifaddresses()方法时如果传入了一个网络接口，会返回这个接口的所有IP地址。然后从中提取不同的IP相关信息，例如协议族、地址、网络掩码和广播地址。如果协议族匹配AF_INET6，就把网络接口的地址添加到IPv6_address字典中。

3.12　从IPv6地址中提取IPv6前缀

在IPv6应用中，你要从IPv6地址中找出前缀信息。注意，按照RFC 3513的定义，前面的64位IPv6地址由全网路由前缀和子网ID组成。通常使用一个较短的前缀（例如/48），可以定义很多更长、更具体的前缀（例如/64）。使用Python脚本可以更方便的生成前缀信息。

3.12.1　实战演练

我们可以使用第三方库netifaces和netaddr找出IPv6地址中的IPv6前缀，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 3
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import socket
import netifaces as ni
import netaddr as na

def extract_ipv6_info():
    """ Extracts IPv6 information"""
    print "IPV6 support built into Python: %s" %socket.has_ipv6
    for interface in ni.interfaces():
        all_addresses = ni.ifaddresses(interface)
        print "Interface %s:" %interface
        for family,addrs in all_addresses.iteritems():
            fam_name = ni.address_families[family]
            #print '  Address family: %s' % fam_name
            for addr in addrs:
                if fam_name == 'AF_INET6':
                    addr = addr['addr']
                    has_eth_string = addr.split("%eth")
                    if has_eth_string:
                        addr = addr.split("%eth")[0]
                    print "    IP Address: %s" %na.IPNetwork(addr)
                    print "    IP Version: %s" %na.IPNetwork(addr).version
                    print "    IP Prefix length: %s" %na.IPNetwork(addr).prefixlen
                    print "    Network: %s" %na.IPNetwork(addr).network
                    print "    Broadcast: %s" %na.IPNetwork(addr).broadcast

if __name__ == '__main__':
    extract_ipv6_info()

这个脚本的输出如下所示：

$ python 3_11_extract_ipv6_prefix.py
IPV6 support built into Python: True
Interface lo:
    IP Address: ::1/128
    IP Version: 6
    IP Prefix length: 128
    Network: ::1
    Broadcast: ::1
Interface eth0:
    IP Address: fe80::a00:27ff:fe95:d1a/128
    IP Version: 6
    IP Prefix length: 128
    Network: fe80::a00:27ff:fe95:d1a
    Broadcast: fe80::a00:27ff:fe95:d1a

3.12.2　原理分析

Python的netifaces库使用interfaces()和ifaddresses()两个函数获取网络接口的IPv6地址。处理网络地址时使用netaddr模块特别方便。这个模块中的IPNetwork()类构造方法会提供一个IPv4或IPv6地址，并计算出前缀、网络地址和广播地址。这些信息从IPNetwork()类实例的version、prefixlen、network和broadcast属性中获取。

3.13　编写一个IPv6回显客户端/服务器

你要编写一个支持IPv6的服务器或客户端，才能知道它和IPv4版有何区别。

3.13.1　实战演练

这里使用的方案和编写IPv4回显客户端/服务器一样。唯一重要的区别是，使用IPv6信息创建套接字的方法。

代码清单3-12a是IPv6回显服务器，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 3
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import argparse
import socket
import sys

HOST = 'localhost'

def echo_server(port, host=HOST):
    """Echo server using IPv6 """
    for result in socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM, 0, socket.AI_PASSIVE):
        af, socktype, proto, canonname, sa = result
        try:
            sock = socket.socket(af, socktype, proto)
        except socket.error, err:
            print "Error: %s" %err

        try:
            sock.bind(sa)
            sock.listen(1)
            print "Server lisenting on %s:%s" %(host, port)
        except socket.error, msg:
            sock.close()
            continue
        break
        sys.exit(1)
    conn, addr = sock.accept()
    print 'Connected to', addr
    while True:
        data = conn.recv(1024)
        print "Received data from the client: [%s]" %data
        if not data: break
        conn.send(data)
        print "Sent data echoed back to the client: [%s]" %data
    conn.close()

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='IPv6 Socket Server Example')
    parser.add_argument('--port', action="store", dest="port", type=int, required=True)
    given_args = parser.parse_args()
    port = given_args.port
    echo_server(port)

代码清单3-12b是IPv6回显客户端，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 3
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import argparse
import socket
import sys

HOST = 'localhost'
BUFSIZE = 1024

def ipv6_echo_client(port, host=HOST):
    for res in socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM):
        af, socktype, proto, canonname, sa = res
        try:
            sock = socket.socket(af, socktype, proto)
        except socket.error, err:
            print "Error:%s" %err
        try:
            sock.connect(sa)
        except socket.error, msg:
            sock.close()
            continue
    if sock is None:
        print 'Failed to open socket!'
        sys.exit(1)
    msg = "Hello from ipv6 client"
    print "Send data to server: %s" %msg
    sock.send(msg)
    while True:
        data = sock.recv(BUFSIZE)
        print 'Received from server', repr(data)
        if not data:
            break
    sock.close()

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='IPv6 socket client example')
    parser.add_argument('--port', action="store", dest="port", type=int, required=True)
    given_args = parser.parse_args()
    port = given_args.port
    ipv6_echo_client(port)

服务器的输出如下：

$ python 3_12a_ipv6_echo_server.py --port=8800
Server lisenting on localhost:8800
Connected to ('127.0.0.1', 35034)
Received data from the client: [Hello from ipv6 client]
Sent data echoed back to the client: [Hello from ipv6 client]

客户端的输出如下：

$ python 3_12b_ipv6_echo_client.py --port=8800
Send data to server: Hello from ipv6 client
Received from server 'Hello from ipv6 client'

下面的截图展示了IPv6客户端和服务器之间的交互：

3.13.2　原理分析

IPv6回显服务器首先调用socket.getaddrinfo()获取自身的IPv6信息。注意，创建TCP套接字时指定的协议是AF_UNSPEC。得到的信息是有五个值的元组。创建服务器套接字时用到了其中三个信息：地址族、套接字类型和协议。然后把套接字绑定到元组中保存的套接字地址上，监听并接受进入的连接。建立连接后，服务器接收客户端发来的数据，然后回显给客户端。

在客户端代码中，我们创建了一个兼容IPv6的客户端套接字实例，然后在这个实例上调用send()方法发送数据，再调用recv()方法获取服务器回显的数据。

第 4 章　HTTP协议网络编程

本章攻略：

从HTTP服务器下载数据

在你的设备中伺服HTTP请求

访问网站后提取cookie信息

提交网页表单

通过代理服务器发送Web请求

使用HEAD请求检查网页是否存在

把客户端伪装成Mozilla Firefox

使用HTTP压缩节省Web请求消耗的带宽

编写一个支持断点续传功能的HTTP容错客户端

使用Python和OpenSSL编写一个简单的HTTPS服务器

4.1　简介

本章介绍Python HTTP网络库和一些第三方库的功能。例如，以一种更友好、更简洁的方式处理HTTP请求的requests库。其中有一个攻略用到了OpenSSL库，创建支持SSL的Web服务器。

多个攻略都介绍了HTTP协议的很多常规特性，例如使用POST请求提交网页表单、处理首部信息和使用压缩等。

4.2　从HTTP服务器下载数据

你可能想要编写一个简单的HTTP客户端，通过原生的HTTP协议从任意的Web服务器上下载一些数据。这是自己开发HTTP浏览器的第一步。

4.2.1　实战演练

我们要使用Python编写的微型浏览器访问www.python.org。这个浏览器使用Python中的httplib模块编写。

代码清单4-1说明了如何编写一个简单的HTTP客户端，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 4
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import argparse
import httplib

REMOTE_SERVER_HOST = 'www.python.org'
REMOTE_SERVER_PATH = '/'

class HTTPClient:

    def __init__(self, host):
        self.host = host

    def fetch(self, path):
        http = httplib.HTTP(self.host)

        # Prepare header
        http.putrequest("GET", path)
        http.putheader("User-Agent", __file__)
        http.putheader("Host", self.host)
        http.putheader("Accept", "*/*")
        http.endheaders()

        try:
            errcode, errmsg, headers = http.getreply()
        except Exception, e:
            print "Client failed error code: %s message:%s headers:%s" %(errcode, errmsg, headers)
        else:
            print "Got homepage from %s" %self.host

        file = http.getfile()
        return file.read()

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description='HTTP Client Example')
    parser.add_argument('--host', action="store", dest="host",  default=REMOTE_SERVER_HOST)
    parser.add_argument('--path', action="store", dest="path",  default=REMOTE_SERVER_PATH)
    given_args = parser.parse_args()
    host, path = given_args.host, given_args.path
    client = HTTPClient(host)
    print client.fetch(path)

这个攻略默认从www.python.org中获取一个网页。运行这个脚本时可以指定主机和路径参数，也可以不指定。运行脚本后会看到如下输出：

$  python 4_1_download_data.py --host=www.python.org
Got homepage from www.python.org
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://
www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.og/1999/xhtml" xml:lang="en" lang="en">
<head>
  <meta http-equiv="content-type" content="text/html; charset=utf-8" />
  <title>Python Programming Language &ndash; Official Website</title>
....

如果运行脚本时指定的路径不存在，会显示如下的服务器响应：

$ python 4_1_download_data.py --host='www.python.org' --path='/not-exist'
Got homepage from www.python.org
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://
www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
  <meta http-equiv="content-type" content="text/html; charset=utf-8" />
  <title>Page Not Found</title>
  <meta name="keywords" content="Page Not Found" />
  <meta name="description" content="Page Not Found" />

4.2.2　原理分析

这个攻略使用Python的内置库httplib，定义了一个HTTPClient类，从远程主机上获取数据。在fetch()方法中使用HTTP()函数及其他辅助函数（例如putrequest()和putheader()）创建了一个虚拟的HTTP客户端，首先指定一个GET/path字符串，然后设定用户代理，其值为当前脚本（__file__）。

发起请求的getreply()方法放在一个try-except块中。响应通过getfile()方法获取，然后读取数据流中的内容。

4.3　在你的设备中伺服HTTP请求

你可能想编写一个自己的Web服务器，处理客户端请求，返回一个简单的欢迎消息。

4.3.1　实战演练

Python集成了一个非常简单的Web服务器，可以在命令行中启动，如下所示：

$ python -m SimpleHTTPServer 8080

执行这个命令后会在端口8080上启动一个HTTP Web服务器。通过在浏览器中输入http://localhost:8080，可以访问这个服务器。你将看到的是运行上述命令时所在文件夹里的内容。如果这个文件夹中有能被Web服务器识别的索引文件，例如index.html，在浏览器中就会显示这个文件的内容。如果你想完全掌控Web服务器，就得启动自己定制的HTTP服务器。

代码清单4-2是这个定制的HTTP Web服务器，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 4
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import argparse
import sys
from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer

DEFAULT_HOST = '127.0.0.1'
DEFAULT_PORT = 8800

class RequestHandler(BaseHTTPRequestHandler):
    """ Custom request handler"""

    def do_GET(self):
        """ Handler for the GET requests """
        self.send_response(200)
        self.send_header('Content-type','text/html')
        self.end_headers()
        # Send the message to browser
        self.wfile.write("Hello from server!")
        return

class CustomHTTPServer(HTTPServer):
    "A custom HTTP server"
    def __init__(self, host, port):
        server_address = (host, port)
        HTTPServer.__init__(self, server_address, RequestHandler)

def run_server(port):
    try:
        server= CustomHTTPServer(DEFAULT_HOST, port)
        print "Custom HTTP server started on port: %s" % port
        server.serve_forever()
    except Exception, err:
        print "Error:%s" %err
    except KeyboardInterrupt:
        print "Server interrupted and is shutting down..."
        server.socket.close()

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description='Simple HTTP Server Example')
    parser.add_argument('--port', action="store", dest="port", type=int, default=DEFAULT_PORT)
    given_args = parser.parse_args()
    port = given_args.port
    run_server(port)

下面的截图是一个简单的HTTP服务器：

运行这个Web服务器，然后在浏览器中访问，会看到浏览器中显示了一行文本“Hello from server!”，如下所示：

$ python 4_2_simple_http_server.py --port=8800
Custom HTTP server started on port: 8800
localhost - - [18/Apr/2013 13:39:33] "GET / HTTP/1.1" 200 -
localhost - - [18/Apr/2013 13:39:33] "GET /favicon.ico HTTP/1.1" 200

4.3.2　原理分析

在这个攻略中，我们定义了CustomHTTPServer类，它继承自HTTPServer类。在CustomHTTPServer类的构造方法中，设定了服务器地址和用户输入的端口号，还用到了RequestHandler类。客户端连到服务器上时，服务器就通过RequestHandler类处理请求。

RequestHandler类定义了处理客户端GET请求的方法。这个方法向客户端发送一个HTTP首部（状态码200），然后使用write()方法返回一个成功消息“Hello from server!”。

4.4　访问网站后提取cookie信息

很多网站使用cookie在你的本地硬盘中存储各种信息。你可能想要查看cookie中保存的信息，或者使用cookie自动登录网站。

4.4.1　实战演练

假设我们要登录流行的代码分享网站www.bitbucket.org，我们要在登录页面(https://bitbucket.org/ account/signin/?next=/)提交登录信息。登录页面的截图如下所示：

我们要记下表单中几个字段的ID，然后决定提交哪些虚拟值。我们首先要访问登录页面，再访问首页，查看在cookie中保存了什么。

代码清单4-3说明了如何提取cookie信息，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 4
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import cookielib
import urllib
import urllib2

ID_USERNAME = 'id_username'
ID_PASSWORD = 'id_password'
USERNAME = 'you@email.com'
PASSWORD = 'mypassword'
LOGIN_URL = 'https://bitbucket.org/account/signin/?next=/'
NORMAL_URL = 'https://bitbucket.org/'

def extract_cookie_info():
    """ Fake login to a site with cookie"""
    # setup cookie jar
    cj = cookielib.CookieJar()
    login_data = urllib.urlencode({ID_USERNAME : USERNAME,
                                   ID_PASSWORD : PASSWORD})
    # create url opener
    opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(cj))
    resp = opener.open(LOGIN_URL, login_data)

    # send login info
    for cookie in cj:
        print "----First time cookie: %s --> %s" %(cookie.name, cookie.value)

    print "Headers: %s" %resp.headers

    # now access without any login info
    resp = opener.open(NORMAL_URL)
    for cookie in cj:
        print "++++Second time cookie: %s --> %s" %(cookie.name, cookie.value)

    print "Headers: %s" %resp.headers

if __name__ == '__main__':
    extract_cookie_info()

运行这个脚本后得到的输出如下：

$ python 4_3_extract_cookie_information.py
----First time cookie: bb_session --> aed58dde1228571bf60466581790566d
Headers: Server: nginx/1.2.4
Date: Sun, 05 May 2013 15:13:56 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 21167
Connection: close
X-Served-By: bitbucket04
Content-Language: en
X-Static-Version: c67fb01467cf
Expires: Sun, 05 May 2013 15:13:56 GMT
Vary: Accept-Language, Cookie
Last-Modified: Sun, 05 May 2013 15:13:56 GMT
X-Version: 14f9c66ad9db
ETag: "3ba81d9eb350c295a453b5ab6e88935e"
X-Request-Count: 310
Cache-Control: max-age=0
Set-Cookie: bb_session=aed58dde1228571bf60466581790566d; expires=Sun, 19-
May-2013 15:13:56 GMT; httponly; Max-Age=1209600; Path=/; secure

Strict-Transport-Security: max-age=2592000
X-Content-Type-Options: nosniff

++++Second time cookie: bb_session --> aed58dde1228571bf60466581790566d
Headers: Server: nginx/1.2.4
Date: Sun, 05 May 2013 15:13:57 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 36787
Connection: close
X-Served-By: bitbucket02
Content-Language: en
X-Static-Version: c67fb01467cf
Vary: Accept-Language, Cookie
X-Version: 14f9c66ad9db
X-Request-Count: 97
Strict-Transport-Security: max-age=2592000
X-Content-Type-Options: nosniff

4.4.2　原理分析

我们使用Python中的cookielib模块创建了一个cookie容器cj。登录数据使用urllib.urlencode()方法编码。urllib2模块中有个build_opener()方法，其参数是一个HTTPCookieProcessor类实例。我们要把之前创建的cookie容器传给HTTPCookieProcessor类的构造方法。urllib2.build_opener()方法的返回值是一个URL打开器。我们要调用这个打开器两次：一次访问登录页面，一次访问网站的首页。从响应的首部可以看出，在Set-Cookie首部中只设定了一个cookie，即bb_session。cookielib模块的更多信息可以在Python官方文档中查看，网址是http://docs.python.org/2/library/cookielib.html。

4.5　提交网页表单

浏览网络时，一天之中我们要提交好多次网页表单。现在，我们要使用Python代码提交表单。

4.5.1　准备工作

这个攻略用到了一个Python第三方模块，叫作requests。这个模块的安装方法参见安装指南：http://docs.python-requests.org/en/latest/user/install/。例如，可以在命令行中使用pip安装requests模块，如下所示：

$ pip install requests

4.5.2　实战演练

让我们来提交一些虚拟数据，注册Twitter账户。提交表单可以使用两种请求方法：GET和POST。不太敏感的数据，例如搜索查询，一般使用GET请求提交。敏感的数据则通过POST请求发送。我们来试一下使用这两种方法提交数据。

代码清单4-4说明了如何提交网页表单，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 4
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import requests
import urllib
import urllib2

ID_USERNAME = 'signup-user-name'
ID_EMAIL = 'signup-user-email'
ID_PASSWORD = 'signup-user-password'
USERNAME = 'username'
EMAIL = 'you@email.com'
PASSWORD = 'yourpassword'
SIGNUP_URL = 'https://twitter.com/account/create'

def submit_form():
    """Submit a form"""
    payload = {ID_USERNAME : USERNAME,
               ID_EMAIL    :  EMAIL,
               ID_PASSWORD : PASSWORD,}

    # make a get request
    resp = requests.get(SIGNUP_URL)
    print "Response to GET request: %s" %resp.content

    # send POST request
    resp = requests.post(SIGNUP_URL, payload)
    print "Headers from a POST request response: %s" %resp.headers
    #print "HTML Response: %s" %resp.read()

if __name__ == '__main__':
    submit_form()

运行这个脚本后，会看到如下输出：

$ python 4_4_submit_web_form.py
Response to GET request: <?xml version="1.0" encoding="UTF-8"?>
<hash>
  <error>This method requires a POST.</error>
  <request>/account/create</request>
</hash>

Headers from a POST request response: {'status': '200 OK', 'content-
length': '21064', 'set-cookie': '_twitter_sess=BAh7CD--
d2865d40d1365eeb2175559dc5e6b99f64ea39ff; domain=.twitter.com;
path=/; HttpOnly', 'expires': 'Tue, 31 Mar 1981 05:00:00 GMT',
'vary': 'Accept-Encoding', 'last-modified': 'Sun, 05 May 2013
15:59:27 GMT', 'pragma': 'no-cache', 'date': 'Sun, 05 May 2013
15:59:27 GMT', 'x-xss-protection': '1; mode=block', 'x-transaction':
'a4b425eda23b5312', 'content-encoding': 'gzip', 'strict-transport-
security': 'max-age=631138519', 'server': 'tfe', 'x-mid':
'f7cde9a3f3d111310427116adc90bf3e8c95e868', 'x-runtime': '0.09969',
'etag': '"7af6f92a7f7b4d37a6454caa6094071d"', 'cache-control': 'no-
cache, no-store, must-revalidate, pre-check=0, post-check=0', 'x-
frame-options': 'SAMEORIGIN', 'content-type': 'text/html;
charset=utf-8'}

4.5.3　原理分析

这个攻略使用了第三方模块requests。这个模块提供了便利的包装方法get()和post()，能正确编码URL中的数据并提交表单。

在这个攻略中，我们创建了一个数据字典，包含用户名、密码和电子邮件地址，用于注册Twitter账户。我们首先使用GET方法提交表单，但Twitter返回一个错误，说页面只支持POST方法。然后我们使用POST方法提交数据，结果Twitter接受了注册请求，这一点可以由首部数据证实。

4.6　通过代理服务器发送Web请求

你可能想通过代理访问网页。如果你为浏览器配置了一个代理服务器，而且代理可用，就可以运行这个攻略。否则，可以使用网上其他可用的公共代理服务器。

4.6.1　准备工作

你需要一个可使用的代理服务器。你可以使用谷歌或其他搜索引擎找到一个免费的代理服务器。这里，为了演示，我们使用的代理服务器是165.24.10.8。

4.6.2　实战演练

我们来通过一个公共代理服务器发送HTTP请求。

代码清单4-5说明了如何通过代理服务器发送Web请求，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 4
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import urllib

URL = 'https://www.github.com'
PROXY_ADDRESS = "165.24.10.8:8080" # By Googling free proxy server

if __name__ == '__main__':
    resp = urllib.urlopen(URL, proxies = {"http" : PROXY_ADDRESS})
    print "Proxy server returns response headers: %s " %resp.headers

运行这个脚本后，会看到如下输出：

$ python 4_5_proxy_web_request.py
Proxy server returns response headers: Server: GitHub.com
Date: Sun, 05 May 2013 16:16:04 GMT
Content-Type: text/html; charset=utf-8
Connection: close
Status: 200 OK
Cache-Control: private, max-age=0, must-revalidate
Strict-Transport-Security: max-age=2592000
X-Frame-Options: deny
Set-Cookie: logged_in=no; domain=.github.com; path=/; expires=Thu, 05-
May-2033 16:16:04 GMT; HttpOnly
Set-Cookie: _gh_sess=BAh7...; path=/; expires=Sun, 01-Jan-2023 00:00:00
GMT; secure; HttpOnly
X-Runtime: 8
ETag: "66fcc37865eb05c19b2d15fbb44cd7a9"
Content-Length: 10643
Vary: Accept-Encoding

4.6.3　原理分析

这个攻略很简短，使用在谷歌中找到的一个公共代理服务器访问社会化代码分享网站www.github.com。代理服务器的地址传给urllib模块的urlopen()方法。我们把响应的HTTP首部打印出来，以证明代理设置起到了作用。

4.7　使用HEAD请求检查网页是否存在

你可能想在不下载HTML内容的前提下检查网页是否存在。此时我们要使用浏览器客户端发送get HEAD请求。根据维基百科中的定义，HEAD请求和GET请求的响应一样，只是前者没有响应主体。使用HEAD请求可以获取响应首部中的元信息，而不用传输整个网页的内容。

4.7.1　实战演练

我们要向www.python.org发送一个HEAD请求。这个请求不会下载首页的内容，而是检查服务器是否返回正确的响应，例如OK、FOUND和MOVED PERMANENTLY等。

代码清单4-6说明了如何使用HEAD请求检查网页，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 4
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import argparse
import httplib
import urlparse
import re
import urllib

DEFAULT_URL = 'http://www.python.org'
HTTP_GOOD_CODES =  [httplib.OK, httplib.FOUND, httplib.MOVED_PERMANENTLY]

def get_server_status_code(url):
    """
    Download just the header of a URL and
    return the server's status code.
    """
    host, path = urlparse.urlparse(url)[1:3]
    try:
        conn = httplib.HTTPConnection(host)
        conn.request('HEAD', path)
        return conn.getresponse().status
    except StandardError:
        return None

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Example HEAD Request')
    parser.add_argument('--url', action="store", dest="url", default=DEFAULT_URL)
    given_args = parser.parse_args()
    url = given_args.url
    if get_server_status_code(url) in HTTP_GOOD_CODES:
        print "Server: %s status is OK: " %url
    else:
        print "Server: %s status is NOT OK!" %url

运行这个脚本后，会根据HEAD请求的响应显示成功消息或错误消息，如下所示：

$ python 4_6_checking_webpage_with_HEAD_request.py
Server: http://www.python.org status is OK!

$ python 4_6_checking_webpage_with_HEAD_request.py --url=http://www.zytho.org
Server: http://www.zytho.org status is NOT OK!

4.7.2　原理分析

我们使用httplib模块中的HTTPConnection()方法向服务器发起HEAD请求。如果需要，可以指定要访问的路径。在这个攻略中，HTTPConnection()方法检查的是www.python.org首页。如果URL不正确，在返回码的可接受列表中就无法找到返回的响应。

4.8　把客户端伪装成Mozilla Firefox

在Python代码中，你可能想假装成在使用Mozilla Firefox访问Web服务器。

4.8.1　实战演练

你可以在HTTP请求首部中发送自己定制的用户代理值。

代码清单4-7说明了如何把客户端伪装成Mozilla Firefox浏览器，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 4
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import urllib2

BROWSER = 'Mozilla/5.0 (Windows NT 5.1; rv:20.0) Gecko/20100101 Firefox/20.0'
URL = 'http://www.python.org'

def spoof_firefox():
    opener = urllib2.build_opener()
    opener.addheaders = [('User-agent', BROWSER)]
    result = opener.open(URL)
    print "Response headers:"
    for header in  result.headers.headers:
        print "\t",header

if __name__ == '__main__':
    spoof_firefox()

运行这个脚本后，会看到如下输出：

$ python 4_7_spoof_mozilla_firefox_in_client_code.py
Response headers:
    Date: Sun, 05 May 2013 16:56:36 GMT
    Server: Apache/2.2.16 (Debian)
    Last-Modified: Sun, 05 May 2013 00:51:40 GMT
    ETag: "105800d-5280-4dbedfcb07f00"
    Accept-Ranges: bytes
    Content-Length: 21120
    Vary: Accept-Encoding
    Connection: close
    Content-Type: text/html

4.8.2　原理分析

我们使用urllib2模块中的build_opener()方法创建自定义浏览器，把用户代理字符串设为Mozilla/5.0 (Windows NT 5.1; rv:20.0) Gecko/20100101 Firefox/20.0。

4.9　使用HTTP压缩节省Web请求消耗的带宽

你可能想让Web服务器在下载网页时有更好的性能表现。压缩HTTP数据能提升伺服网页内容的速度。

4.9.1　实战演练

我们来编写一个Web服务器，把内容压缩成gzip格式后再提供给访问者。

代码清单4-8说明了如何压缩HTTP数据，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 4
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import argparse
import string
import os
import sys
import gzip
import cStringIO
from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer

DEFAULT_HOST = '127.0.0.1'
DEFAULT_PORT = 8800
HTML_CONTENT = """<html><body><h1>Compressed Hello  World!</h1></body></html>"""

class RequestHandler(BaseHTTPRequestHandler):
    """ Custom request handler"""

    def do_GET(self):
        """ Handler for the GET requests """
        self.send_response(200)
        self.send_header('Content-type','text/html')
        self.send_header('Content-Encoding','gzip')
        zbuf = self.compress_buffer(HTML_CONTENT)
        sys.stdout.write("Content-Encoding: gzip\r\n")
        self.send_header('Content-Length',len(zbuf))
        self.end_headers()

        # Send the message to browser
        zbuf = self.compress_buffer(HTML_CONTENT)
        sys.stdout.write("Content-Encoding: gzip\r\n")
        sys.stdout.write("Content-Length: %d\r\n" % (len(zbuf)))
        sys.stdout.write("\r\n")
        self.wfile.write(zbuf)
        return

    def compress_buffer(self, buf):
        zbuf = cStringIO.StringIO()
        zfile = gzip.GzipFile(mode = 'wb',  fileobj = zbuf, compresslevel = 6)
        zfile.write(buf)
        zfile.close()
        return zbuf.getvalue()

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Simple HTTP Server Example')
    parser.add_argument('--port', action="store", dest="port", type=int, default=DEFAULT_PORT)
    given_args = parser.parse_args()
    port = given_args.port
    server_address =  (DEFAULT_HOST, port)
    server = HTTPServer(server_address, RequestHandler)
    server.serve_forever()

运行这个脚本后，在浏览器中访问http://localhost:8800，会看到浏览器中显示了文本“Compressed Hello World!”（HTTP压缩后得到的结果），如下所示：

$ python 4_8_http_compression.py
localhost - - [22/Feb/2014 12:01:26] "GET / HTTP/1.1" 200 -
Content-Encoding: gzip
Content-Encoding: gzip
Content-Length: 71
localhost - - [22/Feb/2014 12:01:26] "GET /favicon.ico HTTP/1.1" 200 -
Content-Encoding: gzip
Content-Encoding: gzip
Content-Length: 71

下面的截图展示了Web服务器伺服压缩内容的过程：

4.9.2　原理分析

我们实例化BaseHTTPServer模块中的HTTPServer类，创建了一个Web服务器。然后为这个服务器实例定义了一个请求处理方法，它使用compress_buffer()方法压缩发给客户端的每个响应，再把事先定义好的HTML内容发送给客户端。

4.10　编写一个支持断点续传功能的HTTP容错客户端

你可能想编写一个容错的客户端，它在因某种原因初次下载失败后能继续下载文件。

4.10.1　实战演练

让我们从www.python.org上下载Python 2.7的源码。使用resume_download()函数下载的文件在中止后能继续下载尚未下载的内容。

代码清单4-9说明了如何继续下载，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 4
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import urllib
import os

TARGET_URL = 'http://python.org/ftp/python/2.7.4/'
TARGET_FILE = 'Python-2.7.4.tgz'

class CustomURLOpener(urllib.FancyURLopener):
    """Override FancyURLopener to skip error 206 (when a
       partial file is being sent)
    """
    def http_error_206(self, url, fp, errcode, errmsg, headers, data=None):
        pass

def resume_download():
    file_exists = False
    CustomURLClass = CustomURLOpener()
    if os.path.exists(TARGET_FILE):
        out_file = open(TARGET_FILE,"ab")
        file_exists = os.path.getsize(TARGET_FILE)
        #If the file exists, then only download the unfinished part
        CustomURLClass.addheader("range","bytes=%s-" % (file_exists))
    else:
        out_file = open(TARGET_FILE,"wb")

    web_page = CustomURLClass.open(TARGET_URL + TARGET_FILE)

    #Check if last download was OK
    if int(web_page.headers['Content-Length']) == file_exists:
        loop = 0
        print "File already downloaded!"

    byte_count = 0
    while True:
        data = web_page.read(8192)
        if not data:
            break
        out_file.write(data)
        byte_count = byte_count + len(data)

    web_page.close()
    out_file.close()

    for k,v in web_page.headers.items():
        print k, "=",v
    print "File copied", byte_count, "bytes from", web_page.url

if __name__ == '__main__':
    resume_download()

运行这个脚本后，会看到如下输出结果：

$ python 4_9_http_fail_over_client.py
content-length = 14489063
content-encoding = x-gzip
accept-ranges = bytes
connection = close
server = Apache/2.2.16 (Debian)
last-modified = Sat, 06 Apr 2013 14:16:10 GMT
content-range = bytes 0-14489062/14489063
etag = "1748016-dd15e7-4d9b1d8685e80"
date = Tue, 07 May 2013 12:51:31 GMT
content-type = application/x-tar
File copied 14489063 bytes from http://python.org/ftp/python/2.7.4/Python-2.7.4.tgz

4.10.2　原理分析

在这个攻略中，我们定义了一个URL打开器类，继承自urllib模块中的FancyURLopener类，不过重定义了用于分段下载内容的http_error_206()方法。resume_download()函数首先检查目标文件是否存在，如果不存在就尝试使用自定义的URL打开器类下载。

4.11　使用Python和OpenSSL编写一个简单的HTTPS服务器

你需要使用Python编写一个安全的Web服务器，而且已经有了SSL密钥和证书文件。

4.11.1　准备工作

你需要安装第三方Python模块pyOpenSSL。这个模块可从PyPI上下载，地址为https://pypi.python.org/pypi/pyOpenSSL。在Windows和Linux主机上都要安装一些其他的包，在http://pythonhosted.org/pyOpenSSL/中有说明。

4.11.2　实战演练

把证书文件放在当前工作目录后，我们就可以创建一个Web服务器，利用这个证书向客户端发送加密后的内容。

代码清单4-10是安全HTTP服务器的代码，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 4
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.
# Requires pyOpenSSL and SSL packages installed

import socket, os
from SocketServer import BaseServer
from BaseHTTPServer import HTTPServer
from SimpleHTTPServer import SimpleHTTPRequestHandler
from OpenSSL import SSL

class SecureHTTPServer(HTTPServer):
    def __init__(self, server_address, HandlerClass):
        BaseServer.__init__(self, server_address, HandlerClass)
        ctx = SSL.Context(SSL.SSLv23_METHOD)
        fpem = 'server.pem' # location of the server private key and the server certificate
        ctx.use_privatekey_file (fpem)
        ctx.use_certificate_file(fpem)
        self.socket = SSL.Connection(ctx, socket.socket(self.address_family,
                                                        self.socket_type))
        self.server_bind()
        self.server_activate()

class SecureHTTPRequestHandler(SimpleHTTPRequestHandler):
    def setup(self):
        self.connection = self.request
        self.rfile = socket._fileobject(self.request, "rb", self.rbufsize)
        self.wfile = socket._fileobject(self.request, "wb", self.wbufsize)

def run_server(HandlerClass = SecureHTTPRequestHandler,
        ServerClass = SecureHTTPServer):
    server_address = ('', 4443) # port needs to be accessible by user
    server = ServerClass(server_address, HandlerClass)
    running_address = server.socket.getsockname()
    print "Serving HTTPS Server on %s:%s ..." %(running_address[0], running_address[1])
    server.serve_forever()

if __name__ == '__main__':
    run_server()

运行这个脚本后会看到如下输出：

$ python 4_10_https_server.py
Serving HTTPS Server on 0.0.0.0:4443 ...

4.11.3　原理分析

如果你仔细观察前面编写Web服务器的攻略会发现，它和这个攻略没有太多的区别，基本流程都是一样的。最重要的一个不同点是，这个脚本调用了SSL.Context()方法，并将其参数设为SSL.SSLv23_METHOD。我们使用Python OpenSSL第三方模块提供的Connection类创建了一个SSL套接字。Connection类构造方法的参数是前面创建的上下文对象，以及地址族和套接字类型。

服务器的证书文件保存在当前目录中。证书文件提供给上下文对象使用。最后，调用server_activate()方法激活服务器。

第 5 章　电子邮件协议、FTP和CGI编程

本章攻略：

列出远程FTP服务器中的文件

把本地文件上传到远程FTP服务器中

把当前工作目录中的内容压缩成ZIP文件后通过电子邮件发送

通过POP3协议下载谷歌电子邮件

通过IMAP协议查收远程服务器中的电子邮件

通过Gmail的SMTP服务器发送带有附件的电子邮件

使用CGI为基于Python的Web服务器编写一个留言板

5.1　简介

本章通过Python攻略介绍FTP、电子邮件和CGI通信协议。Python这门语言很高效也很友好。使用Python可以很方便地实现简单的FTP操作，例如下载和上传文件。

本章有些有趣的攻略，例如在Python脚本中管理谷歌电子邮件（也叫Gmail）。使用这些攻略可以通过IMAP、POP3和SMTP协议查收、下载和发送电子邮件。还有一个攻略编写了一个支持CGI功能的Web服务器，演示了基本的CGI操作，例如编写Web应用中的游客留言表单。

5.2　列出FTP远程服务器中的文件

你可能想列出Linux内核FTP网站（ftp.kernel.org）中的文件。这个攻略也可用在其他FTP网站上。

5.2.1　准备工作

如果处理需要账户的FTP网站，你需要提供用户名和密码。但在这个攻略中不需要Linux内核FTP网站的用户名和密码，因为可以匿名登录。

5.2.2　实战演练

要从选中的FTP网站中获取文件，可以使用ftplib库。这个库的详细文档可在http://docs.python.org/2/library/ftplib.html中查看。

我们来看一下如何使用ftplib获取文件。

代码清单5-1是一次简单的FTP连接测试，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 5
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

FTP_SERVER_URL = 'ftp.kernel.org'

import ftplib

def test_ftp_connection(path, username, email):
    #Open ftp connection
    ftp = ftplib.FTP(path, username, email)

    #List the files in the /pub directory
    ftp.cwd("/pub")
    print "File list at %s:" %path
    files = ftp.dir()
    print files

    ftp.quit()

if __name__ == '__main__':
    test_ftp_connection(path=FTP_SERVER_URL, username='anonymous',
                        email='nobody@nourl.com')

这个攻略会列出FTP路径（ftp.kernel.org/pub）中的文件和文件夹。运行这个脚本后，会看到如下输出：

$ python 5_1_list_files_on_ftp_server.py
File list at ftp.kernel.org:
drwxrwxr-x    6 ftp      ftp         4096 Dec 01  2011 dist
drwxr-xr-x   13 ftp      ftp         4096 Nov 16  2011 linux
drwxrwxr-x    3 ftp      ftp         4096 Sep 23  2008 media
drwxr-xr-x   17 ftp      ftp         4096 Jun 06  2012 scm
drwxrwxr-x    2 ftp      ftp         4096 Dec 01  2011 site
drwxr-xr-x   13 ftp      ftp         4096 Nov 27  2011 software
drwxr-xr-x    3 ftp      ftp         4096 Apr 30  2008 tools

5.2.3　原理分析

这个攻略使用ftplib创建了一个连接到ftp.kernel.org/pub上的FTP客户端会话。test_ftp_connection()函数的参数是连接FTP服务器所需的路径、用户名和电子邮件地址。

FTP客户端会话使用ftplib库中的FTP()函数创建，其参数就是传入test_ftp_connection()的参数。FTP()函数返回一个客户端句柄，用于运行常用的FTP命令，例如切换工作目录的命令cwd()。dir()方法返回目录的文件夹结构。

处理完成后，最后调用ftp.quit()终止FTP会话。

5.3　把本地文件上传到远程FTP服务器中

你可能想把文件上传到FTP服务器中。

5.3.1　准备工作

让我们来搭建一个本地FTP服务器。在Unix/Linux中，可以使用下述命令安装wu-ftpd包：

$ sudo apt-get install wu-ftpd

在运行Windows的设备中，可以安装FileZilla FTP服务器，下载地址为https://filezilla-project.org/download.php?type=server。

你应该按照FTP服务器包的说明来创建一个FTP账户。

你可能还想上传一个文件到FTP服务器中。你可以指定服务器地址、登录凭据和文件名作为脚本的输入参数。你应该在本地新建一个文件，命名为readme.txt，在里面随便写些文本。

5.3.2　实战演练

我们使用下面的脚本来搭建一个FTP本地服务器。在Unix/Linux中，可以安装wu-ftpd包。然后，可以把文件上传到已登录用户的家目录中。你可以指定服务器地址、登录凭据和文件名作为脚本的输入参数。

代码清单5-2是FTP上传文件示例，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 5
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import os
import argparse
import ftplib
import getpass

LOCAL_FTP_SERVER = 'localhost'
LOCAL_FILE = 'readme.txt'

def ftp_upload(ftp_server, username, password, file_name):
    print "Connecting to FTP server: %s" %ftp_server
    ftp = ftplib.FTP(ftp_server)
    print "Login to FTP server: user=%s" %username
    ftp.login(username, password)
    ext = os.path.splitext(file_name)[1]
    if ext in (".txt", ".htm", ".html"):
        ftp.storlines("STOR " + file_name, open(file_name))
    else:
        ftp.storbinary("STOR " + file_name, open(file_name, "rb"), 1024)
    print "Uploaded file: %s" %file_name

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='FTP Server Upload Example')
    parser.add_argument('--ftp-server', action="store", dest="ftp_server", default=LOCAL_FTP_SERVER)
    parser.add_argument('--file-name', action="store", dest="file_name", default=LOCAL_FILE)
    parser.add_argument('--username', action="store", dest="username", default=getpass.getuser())
    given_args = parser.parse_args()
    ftp_server, file_name, username = given_args.ftp_server, given_args.file_name, given_args.username
    password = getpass.getpass(prompt="Enter you FTP password: ")
    ftp_upload(ftp_server, username, password, file_name)

如果搭建了本地FTP服务器，运行下面这个脚本后会登入FTP服务器并上传文件。如果命令行中没有指定默认文件名，这个脚本将上传readme.txt文件。

$ python 5_2_upload_file_to_ftp_server.py
Enter your FTP password:
Connecting to FTP server: localhost
Login to FTP server: user=faruq
Uploaded file: readme.txt

$ cat /home/faruq/readme.txt
This file describes what to do with the .bz2 files you see elsewhere
on this site (ftp.kernel.org).

5.3.3　原理分析

在这个攻略中，我们假设本地FTP服务器正在运行中。除此之外还可以连接远程FTP服务器。在ftp_upload()方法中，使用Python中的ftplib模块提供的FTP()函数创建一个FTP连接对象。然后调用login()方法，登录服务器。

登录成功后，在ftp对象上调用方法storlines()或storbinary()执行STOR命令。前一个方法用于发送ASCII文本文件，例如HTML或纯文本文件。后一个方法用于发送二进制数据，例如压缩文件。

这些FTP方法最好放在try-catch错误处理块中，为了行文简洁，这里并没有这么做。

5.4　把当前工作目录中的内容压缩成ZIP文件后通过电子邮件发送

如果能把当前工作目录中的内容压缩成ZIP文件发送给别人，该多么有趣啊。使用这个攻略，你可以快速和朋友共享文件。

5.4.1　准备工作

如果你的设备中没有安装任何邮件服务器，你需要安装一个本地邮件服务器，例如postfix。在Debian/Ubuntu系统中可以使用apt-get的默认设置安装，如下面的命令所示：

$ sudo apt-get install postfix

5.4.2　实战演练

我们首先要压缩当前目录，然后创建一封电子邮件。电子邮件可通过外部的SMTP主机发送，也可使用本地电子邮件服务器发送。和其他的攻略类似，发件人和收件人信息都从命令行参数中获取。

代码清单5-3展示了如何把文件夹压缩成ZIP文件再通过电子邮件发送，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 5
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import os
import argparse
import smtplib
import zipfile
import tempfile
from email import encoders
from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart

def email_dir_zipped(sender, recipient):
    zf = tempfile.TemporaryFile(prefix='mail', suffix='.zip')
    zip = zipfile.ZipFile(zf, 'w')
    print "Zipping current dir: %s" %os.getcwd()
    for file_name in os.listdir(os.getcwd()):
        zip.write(file_name)
    zip.close()
    zf.seek(0)

    # Create the message
    print "Creating email message..."
    email_msg = MIMEMultipart()
    email_msg['Subject'] = 'File from path %s' %os.getcwd()
    email_msg['To'] = ', '.join(recipient)
    email_msg['From'] = sender
    email_msg.preamble = 'Testing email from Python.\n'
    msg = MIMEBase('application', 'zip')
    msg.set_payload(zf.read())
    encoders.encode_base64(msg)
    msg.add_header('Content-Disposition', 'attachment',
                   filename=os.getcwd()[-1] + '.zip')
    email_msg.attach(msg)
    email_msg = email_msg.as_string()

    # send the message
    print "Sending email message..."
    try:
        smtp = smtplib.SMTP('localhost')
        smtp.set_debuglevel(1)
        smtp.sendmail(sender, recipient, email_msg)
    except Exception, e:
        print "Error: %s" %str(e)
    finally:
        smtp.close()

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Email Example')
    parser.add_argument('--sender', action="store", dest="sender", default='you@you.com')
    parser.add_argument('--recipient', action="store", dest="recipient")
    given_args = parser.parse_args()
    email_dir_zipped(given_args.sender, given_args.recipient)

运行这个脚本后看到的输出如下所示。因为开启了电子邮件调试模式，所以还显示了一些额外信息。

$ python 5_3_email_current_dir_zipped.py --recipient=faruq@localhost
Zipping current dir: /home/faruq/Dropbox/PacktPub/pynet-cookbook/
pynetcookbook_code/chapter5
Creating email message...
Sending email message...
send: 'ehlo [127.0.0.1]\r\n'
reply: '250-debian6.debian2013.com\r\n'
reply: '250-PIPELINING\r\n'
reply: '250-SIZE 10240000\r\n'
reply: '250-VRFY\r\n'
reply: '250-ETRN\r\n'
reply: '250-STARTTLS\r\n'
reply: '250-ENHANCEDSTATUSCODES\r\n'
reply: '250-8BITMIME\r\n'
reply: '250 DSN\r\n'
reply: retcode (250); Msg: debian6.debian2013.com
PIPELINING
SIZE 10240000
VRFY
ETRN
STARTTLS
ENHANCEDSTATUSCODES
8BITMIME
DSN
send: 'mail FROM:<you@you.com> size=9141\r\n'
reply: '250 2.1.0 Ok\r\n'
reply: retcode (250); Msg: 2.1.0 Ok
send: 'rcpt TO:<faruq@localhost>\r\n'
reply: '250 2.1.5 Ok\r\n'
reply: retcode (250); Msg: 2.1.5 Ok
send: 'data\r\n'
reply: '354 End data with <CR><LF>.<CR><LF>\r\n'
reply: retcode (354); Msg: End data with <CR><LF>.<CR><LF>
data: (354, 'End data with <CR><LF>.<CR><LF>')
send: 'Content-Type: multipart/mixed;
boundary="===============0388489101==...[TRUNCATED]
reply: '250 2.0.0 Ok: queued as 42D2F34A996\r\n'
reply: retcode (250); Msg: 2.0.0 Ok: queued as 42D2F34A996
data: (250, '2.0.0 Ok: queued as 42D2F34A996')

5.4.3　原理分析

为了通过电子邮件发送压缩后的文件夹，我们用到了Python中的zipfile、smtplib和email三个模块。这在email_dir_zipped()方法中实现。这个方法接收两个参数：发件人和收件人的电子邮件地址。

为了压缩文件，我们使用tempfile模块中的TemporaryFile类新建了一个临时文件，把这个文件的前缀设为mail，后缀设为.zip。然后把临时文件作为参数传给ZipFile类的构造方法，初始化一个ZIP压缩文件对象。接着在这个压缩对象上调用write()方法，添加当前目录中的文件。

为了发送电子邮件，我们使用email.mime.multipart模块中的MIMEmultipart()类创建了一个MIME为multipart的邮件。和普通的电子邮件一样，主题、收件人和发件人信息都在电子邮件的首部中设定。

电子邮件的附件使用MIMEBase()方法创建。我们首先设置application/zip首部，然后在附件对象上调用set_payload()方法。为了正确地编码消息，调用了encoders模块中的encode_base64()方法。add_header()方法能帮助我们设定附件的首部。至此，附件已经准备好，可以添加到邮件中了，因此我们调用了attach()方法。

若想发送电子邮件，要调用smtplib模块中的SMTP类创建一个实例。在这个实例上调用sendmail()方法后，会利用操作系统中的程序正确地把电子邮件发送出去。发送的细节被隐藏了，不过，如果你想看到详细的过程，可以打开调试模式，如前所示。

5.4.4　参考资源

本节用到的Python库详情请参阅文档，地址为http://docs.python.org/2/library/smtplib.html。

5.5　通过POP3协议下载谷歌电子邮件

你可能想通过POP3协议下载谷歌（或者其他任何一个电子邮件服务提供商）账户中的电子邮件。

5.5.1　准备工作

要运行这个攻略，你需要有谷歌或其他服务提供商的电子邮件账户。

5.5.2　实战演练

我们要尝试下载用户谷歌电子邮件账户中的第一封邮件。用户名在命令行中输入，但为了保密，密码不能在命令行中指定，而是在运行脚本时输入，而且不能显示出来。

代码清单5-4展示了如何通过POP3协议下载谷歌账户中的电子邮件，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 5
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import argparse
import getpass
import poplib

GOOGLE_POP3_SERVER = 'pop.googlemail.com'

def download_email(username):
    mailbox = poplib.POP3_SSL(GOOGLE_POP3_SERVER, '995')
    mailbox.user(username)
    password = getpass.getpass(prompt="Enter your 谷歌 password: ")
    mailbox.pass_(password)
    num_messages = len(mailbox.list()[1])
    print "Total emails: %s" %num_messages
    print "Getting last message"
    for msg in mailbox.retr(num_messages)[1]:
        print msg
    mailbox.quit()

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Email Download Example')
    parser.add_argument('--username', action="store", dest="username", default=getpass.getuser())
    given_args = parser.parse_args()
    username = given_args.username
    download_email(username)

运行这个脚本后会看到类似下面的输出。为了保护隐私，输出的内容有所删减。

$ python 5_4_download_google_email_via_pop3.py --username=<USERNAME>
Enter your 谷歌 password:
Total emails: 333
Getting last message
...[TRUNCATED]

5.5.3　原理分析

这个攻略通过POP3协议从用户的谷歌账户中下载第一封邮件。在download_email()函数中，使用poplib模块中的POP3_SSL类创建了一个mailbox对象。在POP3_SSL类的构造方法中，我们传入了谷歌POP3服务器的地址和端口号。然后在mailbox对象上调用user()方法，设定用户的账户。密码使用getpass模块中的getpass()方法以一种安全的方式从用户的输入中获取，然后传给mailbox对象。在mailbox对象上调用list()方法会以一个Python列表的形式返回电子邮件。

这个脚本先显示电子邮件的数量，然后调用retr()方法取回第一封邮件。最后，在mailbox对象上调用quit()方法，安全地结束连接。

5.6　通过IMAP协议查收远程服务器中的电子邮件

除了使用POP3协议之外，还可以使用IMAP协议从谷歌账户中取回电子邮件。使用这种方法，取回后邮件不会被删除。

5.6.1　准备工作

要运行这个攻略，你需要有谷歌或其他服务提供商的电子邮件账户。

5.6.2　实战演练

让我们连接到谷歌的电子邮件账户，读取第一封电子邮件。如果你没有删除，第一封电子邮件应该是来自谷歌的欢迎消息。

代码清单5-5展示了如何通过IMAP协议查收谷歌账户中的电子邮件，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 5
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import argparse
import getpass
import imaplib

GOOGLE_IMAP_SERVER = 'imap.googlemail.com'

def check_email(username):
    mailbox = imaplib.IMAP4_SSL(GOOGLE_IMAP_SERVER, '993')
    password = getpass.getpass(prompt="Enter your 谷歌 password: ")
    mailbox.login(username, password)
    mailbox.select('Inbox')
    typ, data = mailbox.search(None, 'ALL')
    for num in data[0].split():
        typ, data = mailbox.fetch(num, '(RFC822)')
        print 'Message %s\n%s\n' % (num, data[0][1])
        break
    mailbox.close()
    mailbox.logout()

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Email Download Example')
    parser.add_argument('--username', action="store", dest="username", default=getpass.getuser())
    given_args = parser.parse_args()
    username = given_args.username
    check_email(username)

运行这个脚本后会看到如下输出。为了保护隐私，删减了一些信息。

$ python 5_5_check_remote_email_via_imap.py --username=<USER_NAME>
Enter your 谷歌 password:
Message 1
Received: by 10.140.142.16; Sat, 17 Nov 2007 09:26:31 -0800 (PST)
Message-ID: <...>@mail.gmail.com>
Date: Sat, 17 Nov 2007 09:26:31 -0800
From: "Gmail Team" <mail-noreply@google.com>
To: "<User Full Name>" <USER_NAME>@gmail.com>
Subject: Gmail is different. Here's what you need to know.
MIME-Version: 1.0
Content-Type: multipart/alternative;
    boundary="----=_Part_7453_30339499.1195320391988"
------=_Part_7453_30339499.1195320391988
Content-Type: text/plain; charset=ISO-8859-1
Content-Transfer-Encoding: 7bit
Content-Disposition: inline

Messages that are easy to find, an inbox that organizes itself, great
spam-fighting tools and built-in chat. Sound cool? Welcome to Gmail.
To get started, you may want to:
[TRUNCATED]

5.6.3　原理分析

上述脚本从命令行参数中获取谷歌用户名，然后调用check_email()函数。在这个函数中，使用imaplib模块中的IMAP4_SSL类创建了一个mailbox对象，实例化时传入了谷歌的IMAP服务器地址和默认的端口号。

然后，在这个函数中使用密码登录账户。密码使用getpass模块中的getpass()方法从用户输入中捕获。然后在mailbox对象上调用select()方法，选择收件箱。

在mailbox对象上可以调用很多有用的方法，其中两个是search()和fetch()，在这个脚本中用来获取第一封邮件。最后，在mailbox对象上调用close()和logout()方法，安全地结束IMAP连接。

5.7　通过Gmail的SMTP服务器发送带有附件的电子邮件

你也许想从谷歌的电子邮件账户中发送一封邮件到其他账户中，或许还想为这封邮件附加一个文件。

5.7.1　准备工作

要运行这个攻略，你需要有谷歌或其他服务提供商的电子邮件账户。

5.7.2　实战演练

我们可以创建一封邮件，把Python的LOGO python-logo.gif附加到邮件中，然后从谷歌账户中发给另一个账户。

代码清单5-6展示了如何从谷歌账户中发送电子邮件：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 5
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import argparse
import os
import getpass
import re
import sys
import smtplib

from email.mime.image import MIMEImage
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText

SMTP_SERVER = 'smtp.gmail.com'
SMTP_PORT = 587

def send_email(sender, recipient):
    """ Send email message """
    msg = MIMEMultipart()
    msg['Subject'] = 'Python Emaill Test'
    msg['To'] = recipient
    msg['From'] = sender
    subject = 'Python email Test'
    message = 'Images attached.'
    # attach imgae files
    files = os.listdir(os.getcwd())
    gifsearch = re.compile(".gif", re.IGNORECASE)
    files = filter(gifsearch.search, files)
    for filename in files:
        path = os.path.join(os.getcwd(), filename)
        if not os.path.isfile(path):
            continue
        img = MIMEImage(open(path, 'rb').read(), _subtype="gif")
        img.add_header('Content-Disposition', 'attachment', filename=filename)
        msg.attach(img)

    part = MIMEText('text', "plain")
    part.set_payload(message)
    msg.attach(part)

    # create smtp session
    session = smtplib.SMTP(SMTP_SERVER, SMTP_PORT)
    session.ehlo()
    session.starttls()
    session.ehlo
    password = getpass.getpass(prompt="Enter your 谷歌 password: ")
    session.login(sender, password)
    session.sendmail(sender, recipient, msg.as_string())
    print "Email sent."
    session.quit()

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Email Sending Example')
    parser.add_argument('--sender', action="store", dest="sender")
    parser.add_argument('--recipient', action="store", dest="recipient")
    given_args = parser.parse_args()
    send_email(given_args.sender, given_args.recipient)

如果提供的谷歌账户信息正确，运行这个脚本后会看到成功消息，提示把一封邮件发送给了另一个电子邮件地址。运行这个脚本之后，可以到收件人的电子邮件账户中确认是否真的成功发送了邮件。

$ python 5_6_send_email_from_gmail.py --sender=<USERNAME>@gmail.com –
recipient=<USER>@<ANOTHER_COMPANY.com>
Enter you Google password:
Email sent.

5.7.3　原理分析

在这个攻略的send_email()函数中创建了一封邮件。我们为send_email()函数提供了谷歌账户，邮件从这个账户中发出。邮件头对象msg使用MIMEMultipart()方法创建，然后添加了主题、收件人和发件人。

在当前路径中寻找.gif格式图片时用到了Python中的正则表达式处理模块。图片附件对象img由email.mime.image模块中的MIMEImage()方法创建。然后为图片对象设定了正确的首部，最后再把图片附加到前面创建的msg对象上。如这个攻略所示，我们可以在for循环中附加多个图片。使用类似的方式，还可以添加纯文本附件。

为了发送电子邮件，我们创建了一个SMTP会话，并在这个会话对象上调用了测试方法，例如ehlo()和starttls()。然后使用用户名和密码登录谷歌的SMTP服务器，再调用sendmail()方法发送电子邮件。

5.8　使用CGI为基于Python的Web服务器编写一个留言板

通用网关接口（Common Gateway Interface，简称CGI）是Web编程的一种标准。通过CGI，可以使用脚本生成服务器输出。你可能想获取用户在浏览器中输入的表单数据，重定向到另一个页面，确认收到了用户执行的操作。

5.8.1　实战演练

我们首先要运行一个支持CGI脚本的Web服务器。我们把用Python编写的CGI脚本放在cgi-bin/子目录中，然后访问包含反馈表单的HTML页面。提交表单后，Web服务器把表单数据发送给CGI脚本，我们会看到这个脚本生成的输出。

代码清单5-7展示了如何让使用Python编写的Web服务器支持CGI：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 5
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import os
import cgi
import argparse
import BaseHTTPServer
import CGIHTTPServer
import cgitb
cgitb.enable()  ## enable CGI error reporting

def web_server(port):
    server = BaseHTTPServer.HTTPServer
    handler = CGIHTTPServer.CGIHTTPRequestHandler #RequestsHandler
    server_address = ("", port)
    handler.cgi_directories = ["/cgi-bin", ]
    httpd = server(server_address, handler)
    print "Starting web server with CGI support on port: %s ..." %port
    httpd.serve_forever()

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='CGI Server Example')
    parser.add_argument('--port', action="store", dest="port", type=int, required=True)
    given_args = parser.parse_args()
    web_server(given_args.port)

下面的截图展示了启用CGI的Web服务器正在伺服内容：

运行这个脚本后，会看到如下输出：

$ python 5_7_cgi_server.py --port=8800
Starting web server with CGI support on port: 8800 ...
localhost - - [19/May/2013 18:40:22] "GET / HTTP/1.1" 200 -

现在，你要在浏览器中访问http://localhost:8800/5_7_send_feedback.html。

你会看到一个输入表单。假设你在表单中填写了下面的内容：

Name: User1
Comment: Comment1

下面的截图展示了在网页表单中输入用户评论的过程：

然后浏览器会被重定向到http://localhost:8800/cgi-bin/5_7_get_feedback.py，在这个页面中你会看到如下输出：

User1 sends a comment: Comment1

用户的评论会显示在浏览器中：

5.8.2　原理分析

我们创建了一个简单的HTTP服务器，以支持处理CGI请求。Python在模块BaseHTTPServer和CGIHTTPserver中提供了这些接口。

我们配置了处理程序，使用路径/cgi-bin存放CGI脚本。其他路径不能运行CGI脚本。

文件5_7_send_feedback.html中的HTML反馈表单显示了一个很简单的HTML表单，包含以下代码：

<html>
    <body>
        <form action="/cgi-bin/5_7_get_feedback.py" method="post">
            Name: <input type="text" name="Name">  <br />
            Comment: <input type="text" name="Comment" />
            <input type="submit" value="Submit" />
        </form>
    </body>
</html>

注意，这个表单的方法是POST，action属性被设为了/cgi-bin/5_7_get_feedback.py文件。这个文件的内容如下：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 5
# This program requires Python 2.7 or any later version

# Import modules for CGI handling
import cgi
import cgitb

# Create instance of FieldStorage
form = cgi.FieldStorage()

# Get data from fields
name = form.getvalue('Name')
comment  = form.getvalue('Comment')

print "Content-type:text/html\r\n\r\n"
print "<html>"
print "<head>"
print "<title>CGI Program Example </title>"
print "</head>"
print "<body>"
print "<h2> %s sends a comment: %s</h2>" % (name, comment)
print "</body>"
print "</html>"

在这个CGI脚本中，调用cgilib模块中的FieldStorage()方法，得到一个用于处理HTML表单输入的表单对象。然后使用getvalue()方法处理两个输入（name和comment）。最后，这个脚本显示一行文字，提示某个用户发送了一篇评论，作为用户输入的反馈。

第 6 章　屏幕抓取和其他实用程序

本章攻略：

使用谷歌地图API搜索公司地址

使用谷歌地图URL搜索地理坐标

搜索维基百科中的文章

使用谷歌搜索股价

搜索GitHub中的源代码仓库

读取BBC的新闻订阅源

爬取网页中的链接

6.1　简介

本章介绍一些有趣的Python脚本，可让你从网络中获取有用信息，例如公司地址、某公司的股价或通讯社网站中的最新资讯。这些Python脚本演示了不使用复杂的API时，如何以更简单的方式获取简要信息。

按照这些攻略的做法，你能编写适应复杂需求的代码，例如，找到一家公司的详细信息，包括所在地、新闻、股价等。

6.2　使用谷歌地图API搜索公司地址

你可能想搜索所在地区内一家知名公司的地址。

6.2.1　准备工作

你可以使用Python的地理编码库pygeocoder搜索本地公司。你需要使用pip或easy_install从PyPI上安装这个库，输入的命令为$ pip install pygeocoder或$ easy_install pygeocoder。

6.2.2　实战演练

我们来使用几行Python代码查找英国著名零售商Argos有限公司的地址。

代码清单6-1是一个简单的地理编码示例，用于搜索一家公司的地址，如下所示：

#!/usr/bin/env python

# Python Network Programming Cookbook -- Chapter - 6
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

from pygeocoder import Geocoder

def search_business(business_name):

    results = Geocoder.geocode(business_name)

    for result in results:
        print result

if __name__ == '__main__':
    business_name =  "Argos Ltd, London"
    print "Searching %s" %business_name
    search_business(business_name)

这个脚本会打印出Argos有限公司的地址，如下所示。输出的内容根据所安装的地理编码库会有细微的差别。

$ python 6_1_search_business_addr.py
Searching Argos Ltd, London

Argos Ltd, 110-114 King Street, London, Greater London W6 0QP, UK

6.2.3　原理分析

这个攻略依赖于Python的第三方地理编码库。

这个攻略定义了一个简单的函数，search_business()，其参数是公司名，然后它把公司名传给geocode()方法。geocode()方法可能返回零个或多个结果，具体取决于搜索的关键字。

在这个攻略中，传给geocode()方法的搜索关键字是“Argos Ltd, London”。得到的结果是Argos有限公司的地址，即“110-114 King Street, London, Greater London W6 0QP, UK”。

6.2.4　参考资源

pygeocoder库很强大，有很多与地理编码有关的有趣和有用的功能。在开发者的网站中有更详细的说明，地址为https://bitbucket.org/xster/pygeocoder/wiki/Home。

6.3　使用谷歌地图URL搜索地理坐标

有时你需要一个简单的函数，只通过城市名即可找出城市的地理坐标。你或许不想为这么简单的任务安装任何第三方库。

6.3.1　实战演练

在这个简单的屏幕抓取示例中，我们使用谷歌地图URL查询一个城市的纬度和经度。在谷歌地图中搜索一次之后就能找到查询所需的URL。我们可以按照下面的步骤从谷歌地图中提取信息。

城市名使用argparse模块从命令行中获取。

地图搜索URL使用urllib模块中的urlopen()函数打开。如果URL正确，会得到一个XML格式的输出。

然后处理XML输出，获取该城市的地理坐标。

代码清单6-2使用谷歌地图查找一个城市的地理坐标，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 6
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import argparse
import os
import urllib

ERROR_STRING = '<error>'

def find_lat_long(city):
    """ Find geographic coordinates """
    # Encode query string into Google maps URL
    url = 'http://maps.google.com/?q=' + urllib.quote(city) + '&output=js'
    print 'Query: %s' % (url)

    # Get XML location from Google maps
    xml = urllib.urlopen(url).read()

    if ERROR_STRING in xml:
        print '\nGoogle cannot interpret the city.'
        return
    else:
        # Strip lat/long coordinates from XML
        lat,lng = 0.0,0.0
        center = xml[xml.find('{center')+10:xml.find('}',xml.find('{center'))]
        center = center.replace('lat:','').replace('lng:','')
        lat,lng = center.split(',')
        print "Latitude/Longitude: %s/%s\n" %(lat, lng)

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='City Geocode Search')
    parser.add_argument('--city', action="store", dest="city", required=True)
    given_args = parser.parse_args()

    print "Finding geographic coordinates of %s" %given_args.city
    find_lat_long(given_args.city)

运行这个脚本后，会看到类似下面的输出：

$ python 6_2_geo_coding_by_google_maps.py --city=London
Finding geograhic coordinates of London
Query: http://maps.google.com/?q=London&output=js
Latitude/Longitude: 51.511214000000002/-0.119824

6.3.2　原理分析

这个攻略从命令行中获取城市名，然后将其传给find_lat_long()函数。这个函数使用urllib模块中的urlopen()函数查询谷歌地图服务，得到XML格式的结果。然后，在得到的结果中搜索字符串'<error>'，如果找不到就说明结果没问题。

如果你把原始的XML打印出来，会发现有很多字符，这些字符是为浏览器生成的。在浏览器中，需要在地图上显示图层。但在这个攻略中，我们只需要纬度和经度。

我们使用字符串处理方法find()从原始的XML中提取出纬度和经度。我们搜索的关键字是“center”，以便找出地理坐标信息。但得到的结果中包含一些额外的字符，所以又调用replace()方法将其删除。

你可以使用这个攻略查找世界上任何一座城市的经纬度。

6.4　搜索维基百科中的文章

维基百科是个非常棒的网站，汇聚了几乎所有的信息，例如人物、场所和技术等。如果你想使用Python脚本在维基百科中搜索点儿什么，可以参考这个攻略。

下面是一篇文章示例：

6.4.1　准备工作

你需要使用pip或easy_install从PyPI上安装第三方库pyyaml，输入的命令为$ pip install pyyaml或$ easy_install pyyaml。

6.4.2　实战演练

我们使用关键字“Islam”搜索维基百科，然后把结果打印出来，一行显示一个。

代码清单6-3展示了如何在维基百科中搜索文章，如下所示：

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Python Network Programming Cookbook -- Chapter - 6
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import argparse
import re
import yaml
import urllib
import urllib2

SEARCH_URL = 'http://%s.wikipedia.org/w/api.php?action=query&list=search&srsearch=%s&sroffset=%d&srlimit=%d&format=yaml'

class Wikipedia:

    def __init__(self, lang='en'):
        self.lang = lang

    def _get_content(self, url):
        request = urllib2.Request(url)
        request.add_header('User-Agent', 'Mozilla/20.0')

        try:
            result = urllib2.urlopen(request)
        except urllib2.HTTPError, e:
            print "HTTP Error:%s" %(e.reason)
        except Exception, e:
            print "Error occured: %s" %str(e)
        return result

    def search_content(self, query, page=1, limit=10):
        offset = (page - 1) * limit
        url = SEARCH_URL % (self.lang, urllib.quote_plus(query), offset, limit)
        content = self._get_content(url).read()

        parsed = yaml.load(content)
        search = parsed['query']['search']
        if not search:
            return

        results = []
        for article in search:
            snippet = article['snippet']
            snippet = re.sub(r'(?m)<.*?>', '', snippet)
            snippet = re.sub(r'\s+', ' ', snippet)
            snippet = snippet.replace(' . ', '. ')
            snippet = snippet.replace(' , ', ', ')
            snippet = snippet.strip()

            results.append({
                'title' : article['title'].strip(),
                'snippet' : snippet
            })

        return results

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Wikipedia search')
    parser.add_argument('--query', action="store", dest="query", required=True)
    given_args = parser.parse_args()
    wikipedia = Wikipedia()
    search_term = given_args.query
    print "Searching Wikipedia for %s" %search_term
    results = wikipedia.search_content(search_term)
    print "Listing %s search results..." %len(results)
    for result in results:
        print "==%s== \n \t%s" %(result['title'], result['snippet'])
    print "---- End of search results ----"

运行这个脚本在维基百科中搜索“Islam”，得到的输出结果如下所示：

$ python 6_3_search_article_in_wikipedia.py --query='Islam'
Searching Wikipedia for Islam
Listing 10 search results...
==Islam==
     Islam. (ˈ|ɪ| s | l |ɑː| m مالسإلا, ar | ALA | al-ʾIslām ælʔɪsˈlæːm | IPA | ar-al_islam. ...
==Sunni Islam==
     Sunni Islam (ˈ | s | uː | n | i or ˈ | s | ʊ | n | i |) is the
largest branch of Islam ; its adherents are referred to in Arabic as ...
==Muslim==
     A Muslim, also spelled Moslem is an adherent of Islam, a
monotheistic Abrahamic religion based on the Qur'an —which Muslims
consider the ...
==Sharia==
     is the moral code and religious law of Islam. Sharia deals with
many topics addressed by secular law, including crime, politics, and ...
==History of Islam==
     The history of Islam concerns the Islamic religion and its
adherents, known as Muslim s. " "Muslim" is an Arabic word meaning
"one who ...
==Caliphate==
     a successor to Islamic prophet Muhammad ) and all the Prophets
of Islam. The term caliphate is often applied to successions of
Muslim ...
==Islamic fundamentalism==
     Islamic ideology and is a group of religious ideologies seen as
advocating a return to the "fundamentals" of Islam : the Quran and
the Sunnah. ...
==Islamic architecture==
     Islamic architecture encompasses a wide range of both secular
and religious styles from the foundation of Islam to the present day. ...
---- End of search results ----

6.4.3　原理分析

首先，我们组建了用于搜索文章的维基百科URL模板。然后定义一个名为Wikipedia的类，其中有两个方法：_get_content()和search_content()。默认情况下，初始化时，把语言属性lang设为en（英语）。

命令行中输入的查询字符串传给search_content()方法，替换模板中的语言、查询字符串、偏移页数和返回结果数量，得到真正的搜索URL。search_content()方法的page参数是可选的，偏移页数由表达式(page -1) * limit计算得出。

搜索结果的内容通过_get_content()方法获得。在_get_content()方法中调用了urllib模块中的urlopen()函数。在搜索URL中，我们把结果的格式设为yaml，这基本上就是纯文本文件。然后再使用Python的pyyaml库解析得到的yaml格式搜索结果。

搜索结果使用正则表达式替换各结果中的内容。例如，re.sub(r'(?m)<.*?>', '', snippet)在字符串snippet中替换匹配(?m)<.*?>)模式的内容。若想进一步学习正则表达式，请阅读Python文档，地址是http://docs.python.org/2/howto/regex.html。

在维基百科中，每篇文章都有一个摘要或简短说明。我们创建了一个由字典组成的列表，列表中的每个元素都包含一个搜索结果的标题和摘要。然后遍历这个由字典组成的列表，打印搜索结果。

6.5　使用谷歌搜索股价

如果你关注某公司的股价，这个攻略能帮助你了解该公司今天的股价。

6.5.1　准备工作

假设你知道所关注的公司在股票交易所挂牌上市使用的代号。如果不知道，可以在该公司的网站中查找，或者在谷歌中搜索。

6.5.2　实战演练

我们要使用谷歌财经（http://finance.google.com/）搜索指定公司的股价。你可以在命令行中输入代号，如下面的代码所示。

代码清单6-4说明如何在谷歌中搜索股价，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 6
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import argparse
import urllib
import re
from datetime import datetime

SEARCH_URL = 'http://finance.google.com/finance?q='

def get_quote(symbol):
    content = urllib.urlopen(SEARCH_URL + symbol).read()
    m = re.search('id="ref_694653_l".*?>(.*?)<', content)
    if m:
        quote = m.group(1)
    else:
        quote = 'No quote available for: ' + symbol
    return quote

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Stock quote search')
    parser.add_argument('--symbol', action="store", dest="symbol", required=True)
    given_args = parser.parse_args()
    print "Searching stock quote for symbol '%s'" %given_args.symbol
    print "Stock  quote for %s at %s: %s" %(given_args.symbol , datetime.today(),  get_quote(given_args.symbol))

运行这个脚本后，会看到类似下面的输出。这里，我们输入代号goog，搜索谷歌的股价，如下所示：

$ python 6_4_google_stock_quote.py --symbol=goog
Searching stock quote for symbol 'goog'
Stock quote for goog at 2013-08-20 18:50:29.483380: 868.86

6.5.3　原理分析

在这个脚本中，使用urllib模块中的urlopen()函数从谷歌财经网站中获取股票数据。

我们使用正则表达式库re，从第一组数据中获取股价。re库的search函数很强大，能搜索内容并过滤特定公司的ID。

我们使用这个攻略搜索了谷歌的股价，在2013年8月20日，其股价是868.86。

6.6　搜索GitHub中的源代码仓库

作为一个Python程序员，你可能已经知道GitHub（http://www.github.com，如下面的截图所示）这个源代码分享网站了。使用GitHub，你可以把源代码私下分享给团队，也可以公开分享给全世界。GitHub有一个好用的API接口，可以查询任何源代码仓库。这个攻略或许能为你的源代码搜索引擎提供一些起步代码。

6.6.1　准备工作

运行这个脚本需要安装Python第三方库requests，执行命令$ pip install requests或$ easy_install requests即可。

6.6.2　实战演练

我们要定义search_repository()函数，其参数是作者名（即程序员名）、仓库名和搜索的键名，得到的是键对应的结果。根据GitHub的API，可用的搜索键有：issues_url、has_wiki、forks_url、mirror_url、subscription_url、 notifications_url、collaborators_url、updated_at、private、pulls_url、 issue_comment_url、labels_url、full_name、owner、statuses_url、 id、keys_url、description、tags_url、network_count、downloads_url、 assignees_url、contents_url、git_refs_url、open_issues_count、 clone_url、watchers_count、git_tags_url、milestones_url、 languages_url、size、homepage、fork、commits_url、issue_events_url、 archive_url、comments_url、events_url、contributors_url、html_url、 forks、compare_url、open_issues、git_url、svn_url、merges_url、 has_issues、ssh_url、blobs_url、master_branch、git_commits_url、 hooks_url、has_downloads、watchers、name、language、url、 created_at、pushed_at、forks_count、default_branch、teams_url、 trees_url、organization、branches_url、subscribers_url和stargazers_url。

代码清单6-5给出了在GitHub中搜索源代码仓库详情的代码，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 6
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

SEARCH_URL_BASE = 'https://api.github.com/repos'

import argparse
import requests
import json

def search_repository(author, repo, search_for='homepage'):
    url = "%s/%s/%s" %(SEARCH_URL_BASE, author, repo)
    print "Searching Repo URL: %s" %url
    result = requests.get(url)
    if(result.ok):
        repo_info = json.loads(result.text or result.content)
        print "Github repository info for: %s" %repo
        result = "No result found!"
        keys = []
        for key,value in repo_info.iteritems():
            if search_for in key:
                result = value
        return result

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Github search')
    parser.add_argument('--author', action="store", dest="author", required=True)
    parser.add_argument('--repo', action="store", dest="repo", required=True)
    parser.add_argument('--search_for', action="store", dest="search_for", required=True)

    given_args = parser.parse_args()
    result = search_repository(given_args.author, given_args.repo, given_args.search_for)
    if isinstance(result, dict):
        print "Got result for '%s'..." %(given_args.search_for)
        for key,value in result.iteritems():
            print "%s => %s" %(key,value)
    else:
        print "Got result for %s: %s" %(given_args.search_for, result)

如果运行这个脚本搜索Python Web框架Django的拥有者，得到的结果如下所示：

$ python 6_5_search_code_github.py --author=django --repo=django --search_for=owner
Searching Repo URL: https://api.github.com/repos/django/django
Github repository info for: django
Got result for 'owner'...
following_url => https://api.github.com/users/django/following{/other_user}
events_url => https://api.github.com/users/django/events{/privacy}
organizations_url => https://api.github.com/users/django/orgs
url => https://api.github.com/users/django
gists_url => https://api.github.com/users/django/gists{/gist_id}
html_url => https://github.com/django
subscriptions_url => https://api.github.com/users/django/subscriptions
avatar_url => https://1.gravatar.com/avatar/fd542381031aa84dca86628ece84f
c07?d=https%3A%2F%2Fidenticons.github.com%2Fe94df919e51ae96652259468415d4f77.png
repos_url => https://api.github.com/users/django/repos
received_events_url => https://api.github.com/users/django/received_events
gravatar_id => fd542381031aa84dca86628ece84fc07
starred_url => https://api.github.com/users/django/starred{/owner}{/repo}
login => django
type => Organization
id => 27804
followers_url => https://api.github.com/users/django/followers

6.6.3　原理分析

这个脚本接收三个命令行参数：仓库作者（--author）、仓库名（--repo）、要搜索的信息（--search_for）。这些参数由argparse模块解析。

在search_repository()函数中，把这些命令行参数添加到一个固定的搜索URL中，然后调用requests模块中的get()方法获取搜索结果。

默认情况下，返回的搜索结果是JSON格式。然后，调用json模块中的loads()方法处理搜索结果。在结果中查找搜索的键，把键对应的值返回给search_repository()函数的调用程序。

在__main__块中，我们检查搜索结果是否为一个Python字典实例。如果是，就遍历结果，把键值对打印出来；否则，直接打印结果。

6.7　读取BBC的新闻订阅源

如果你在开发一个新闻和故事相关的社会化网站，或许想显示世界上不同新闻通讯社（例如BBC和路透社）的新闻。让我们试着使用Python脚本从BBC读取新闻。

6.7.1　准备工作

这个攻略依赖于Python第三方库feedparser。你可以执行下面的命令安装这个库：

$ pip install feedparser

或

$ easy_install feedparser

6.7.2　实战演练

首先，我们要从BBC的网站上找到新闻订阅源的URL。这个URL可以作为搜索不同类型新闻的模板，例如国际新闻、国内新闻、健康新闻、商业新闻和技术新闻。新闻的类型可以从用户的输入中获取。然后，调用read_news()函数，从BBC的网站中读取新闻。

代码清单6-6说明了如何从BBC的新闻订阅源读取新闻，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 6
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

from datetime import datetime
import feedparser
BBC_FEED_URL = 'http://feeds.bbci.co.uk/news/%s/rss.xml'

def read_news(feed_url):
    try:
        data = feedparser.parse(feed_url)
    except Exception, e:
        print "Got error: %s" %str(e)

    for entry in data.entries:
        print(entry.title)
        print(entry.link)
        print(entry.description)
        print("\n")

if __name__ == '__main__':
    print "==== Reading technology news feed from bbc.co.uk (%s)====" %datetime.today()
    print "Enter the type of news feed: "
    print "Available options are: world, uk, health, sci-tech, business, technology"
    type = raw_input("News feed type:")
    read_news(BBC_FEED_URL %type)
    print "==== End of BBC news feed ====="

运行这个脚本后，会显示可选的新闻类别。如果选择技术类，就会显示技术相关的最新新闻，如下面的输出所示：

$ python 6_6_read_bbc_news_feed.py
==== Reading technology news feed from bbc.co.uk (2013-08-20 19:02:33.940014)====
Enter the type of news feed:
Available options are: world, uk, health, sci-tech, business, technology
News feed type:technology
Xbox One courts indie developers
http://www.bbc.co.uk/news/technology-23765453#sa-ns_mchannel=rss&ns_source=PublicRSS20-sa
Microsoft is to give away free Xbox One development kits to encourage
independent developers to self-publish games for its forthcoming console.

Fast in-flight wi-fi by early 2014
http://www.bbc.co.uk/news/technology-23768536#sa-ns_mchannel=rss&ns_source=PublicRSS20-sa
Passengers on planes, trains and ships may soon be able to take advantage
of high-speed wi-fi connections, says Ofcom.

Anonymous 'hacks council website'
http://www.bbc.co.uk/news/uk-england-surrey-23772635#sa-ns_mchannel=rss&ns_source=PublicRSS20-sa
A Surrey council blames hackers Anonymous after references to a Guardian
journalist's partner detained at Heathrow Airport appear on its website.

Amazon.com website goes offline
http://www.bbc.co.uk/news/technology-23762526#sa-ns_mchannel=rss&ns_source=PublicRSS20-sa
Amazon's US website goes offline for about half an hour, the latest high-
profile internet firm to face such a problem in recent days.

[TRUNCATED]

6.7.3　原理分析

在这个攻略中，read_news()函数依赖于Python第三方模块feedparser。feedparser模块中的parse()方法以结构化形式返回订阅源中的数据。

在这个攻略中，parse()方法解析指定的订阅源URL。这个URL由BBC_FEED_URL和用户的输入组成。

如果无异常就调用parse()方法获取订阅源中的数据，再把数据中的内容打印出来，例如每条新闻的标题、链接和描述。

6.8　爬取网页中的链接

有时你可能想在网页中查找某个关键字。在网页浏览器中，可以使用页内搜索功能找到关键字。有些浏览器还能高亮显示关键字。如果情况复杂，你或许还想进一步深入查找，跟踪网页中的每个URL，查找某个关键字。这个攻略的目的是自动化完成这样的任务。

6.8.1　实战演练

我们来定义search_links()函数，它接收三个参数：搜索的URL、递归搜索的深度、搜索关键字。因为每个URL对应的内容中都可能有很多链接，而且各链接指向的内容中或许还有更多的链接要爬取，所以我们要递归搜索。为了限制递归搜索，我们定义了一个深度。到达指定的深度后，就不会再继续搜索了。

代码清单6-7给出了爬取网页中链接的代码，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 6
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import argparse
import sys
import httplib
import re

processed = []

def search_links(url, depth, search):
    # Process http links that are not processed yet
    url_is_processed = (url in processed)
    if (url.startswith("http://") and (not url_is_processed)):
        processed.append(url)
        url = host = url.replace("http://", "", 1)
        path = "/"

        urlparts = url.split("/")
        if (len(urlparts) > 1):
            host = urlparts[0]
            path = url.replace(host, "", 1)

        # Start crawing
        print "Crawling URL path:%s%s " %(host, path)
        conn = httplib.HTTPConnection(host)
        req = conn.request("GET", path)
        result = conn.getresponse()

        # find the links
        contents = result.read()
        all_links = re.findall('href="(.*?)"', contents)

        if (search in contents):
            print "Found " + search + " at " + url

        print " ==> %s: processing %s links" %(str(depth), str(len(all_links)))
        for href in all_links:
            # Find relative urls
            if (href.startswith("/")):
                href = "http://" + host + href

            # Recurse links
            if (depth > 0):
                search_links(href, depth-1, search)
    else:
        print "Skipping link: %s ..." %url

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Webpage link crawler')
    parser.add_argument('--url', action="store", dest="url", required=True)
    parser.add_argument('--query', action="store", dest="query", required=True)
    parser.add_argument('--depth', action="store", dest="depth", default=2)

    given_args = parser.parse_args()

    try:
        search_links(given_args.url,  given_args.depth,given_args.query)
    except KeyboardInterrupt:
        print "Aborting search by user request."

如果运行这个脚本在www.python.org中搜索python，会看到类似下面的输出：

$ python 6_7_python_link_crawler.py --url='http://python.org' --query='python'
Crawling URL path:python.org/
Found python at python.org
 ==> 2: processing 123 links
Crawling URL path:www.python.org/channews.rdf
Found python at www.python.org/channews.rdf
 ==> 1: processing 30 links
Crawling URL path:www.python.org/download/releases/3.4.0/
Found python at www.python.org/download/releases/3.4.0/
 ==> 0: processing 111 links
Skipping link: https://ep2013.europython.eu/blog/2013/05/15/epc20145-call-proposals ...
Crawling URL path:www.python.org/download/releases/3.2.5/
Found python at www.python.org/download/releases/3.2.5/
 ==> 0: processing 113 links
...
Skipping link: http://www.python.org/download/releases/3.2.4/ ...
Crawling URL path:wiki.python.org/moin/WikiAttack2013
^CAborting search by user request.

6.8.2　原理分析

这个攻略接收三个命令行参数：搜索的URL（--url）、查询字符串（--query）、递归深度（--depth）。这些参数由argparse模块解析。

把这三个参数传入search_links()函数后，会递归遍历在指定网页中找到的所有链接。如果运行很长时间还没结束，你应该提前退出脚本。因此，我们才把search_links()函数放在try-except块中，以便捕获用户在键盘中输入的中断操作，例如按Ctrl+C键。

search_links()函数把访问过的链接保存在processed列表中。processed放在全局作用域中，以便递归调用函数时使用。

每次搜索时，都要保证只处理HTTP URL，防止出现潜在的SSL验证错误。URL被分成主机和路径两部分。顶层爬取使用httplib库中的HTTPConnection()函数实例化。然后发起一个GET请求，使用正则表达式模块re处理响应，收集响应中的所有链接。然后在响应中查找要搜索的关键字。如果找到了关键字，就打印出来。

收集到的链接使用相同的方式递归访问。如果找到了相关链接，就在地址前加上http://，转换成完整的URL。如果搜索深度大于零，说明可以递归搜索，把深度减去一后，再次调用搜索函数。当搜索深度为零时，递归结束。

第 7 章　跨设备编程

本章攻略：

使用telnet在远程主机中执行shell命令

通过SFTP把文件复制到远程设备中

打印远程设备的CPU信息

在远程主机中安装Python包

在远程主机中运行MySQL命令

通过SSH把文件传输到远程设备中

远程配置Apache运行网站

7.1　简介

本章推荐一些有趣的Python库。这些攻略的目的是，向系统管理员和高级Python程序员介绍如何编写代码连接远程系统执行命令。本章首先介绍使用Python内置库telnetlib编写的简单攻略，然后介绍知名的远程连接库paramiko，最后介绍强大的远程系统管理库fabric。经常编写脚本完成自动化部署任务（例如部署Web应用或编译应用的二进制文件）的开发者很喜欢fabric库。

7.2　使用telnet在远程主机中执行shell命令

如果想通过telnet连接旧的网络交换机或路由器，无需使用bash脚本或交互式shell，可以在Python脚本中完成这一操作。这个攻略要创建一个简单的telnet会话，说明如何在远程主机中执行shell命令。

7.2.1　准备工作

你需要在自己的设备中安装telnet服务器，并确保其能正常运行。你可以使用操作系统专用的包管理器安装telnet服务器包。例如，在Debian/Ubuntu中，可以使用apt-get或aptitude安装telnetd包，如下面的命令所示：

$ sudo apt-get install telnetd
$ telnet localhost

7.2.2　实战演练

我们来定义一个函数，从命令行中获取用户登录凭据，然后连接telnet服务器。

成功连接后，这个函数会把ls命令发送给服务器，然后显示命令的输出，例如列出一个目录中的内容。

代码清单7-1是一个telnet会话的代码，在远程主机中执行一个Unix命令，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 7
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import getpass
import sys
import telnetlib

HOST = "localhost"

def run_telnet_session():
    user = raw_input("Enter your remote account: ")
    password = getpass.getpass()

    session = telnetlib.Telnet(HOST)

    session.read_until("login: ")
    session.write(user + "\n")
    if password:
        session.read_until("Password: ")
        session.write(password + "\n")

    session.write("ls\n")
    session.write("exit\n")

    print session.read_all()

if __name__ == '__main__':
    run_telnet_session()

如果本地设备中运行有telnet服务器，运行这个脚本后，会要求你输入远程主机的用户账户和密码。在Debian设备中执行这个telnet会话得到的输出如下所示：

$ python 7_1_execute_remote_telnet_cmd.py
Enter remote hostname e.g. localhost: localhost
Enter your remote account: faruq
Password:

ls
exit
Last login: Mon Aug 12 10:37:10 BST 2013 from localhost on pts/9
Linux debian6 2.6.32-5-686 #1 SMP Mon Feb 25 01:04:36 UTC 2013 i686

The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
You have new mail.
faruq@debian6:~$ ls
down              Pictures               Videos
Downloads         projects               yEd
Dropbox           Public
env               readme.txt
faruq@debian6:~$ exit
logout

7.2.3　原理分析

这个攻略使用Python内置的telnetlib网络库创建telnet会话。run_telnet_session()函数从命令行中获取用户名和密码。获取密码使用的是getpass模块中的getpass()函数，这个函数不会让你看到屏幕中输入的内容。

为了创建telnet会话，需要实例化Telnet类，初始化时要指定主机名参数。在这个攻略中，主机名是localhost。你可以使用argparse模块把主机名传给脚本。

telnet会话的远程输出可以使用read_until()方法获取。登录提示符就是使用这个方法检测到的。然后，使用write()方法把用户名和一个换行符发送给远程设备（在这个攻略中，把同一台设备当做远程主机）。再使用类似的方式把密码提供给远程主机。

然后，把ls密令发送给远程设备执行。最后，发送exit命令中断连接，再使用read_all()方法获取从远程主机中接收的全部会话数据，将其打印在屏幕上。

7.3　通过SFTP把文件复制到远程设备中

如果想安全地把本地设备中的文件上传或复制到远程设备中，可以使用“安全文件传输协议”（Secure File Transfer Protocol，简称SFTP）。

7.3.1　准备工作

这个攻略要使用一个强大的第三方网络库paramiko，演示如何使用SFTP复制文件，如下面的命令所示。paramiko的最新代码可以从GitHub（https://github.com/paramiko/paramiko）上获取，或者使用PyPI安装：

$ pip install paramiko

7.3.2　实战演练

这个攻略要从命令行中接收一些输入值，包括远程主机名、服务器端口、源文件名、目标文件名。简单起见，我们可以使用默认值或者硬编码的值。

连接远程服务器需要用户名和密码，这两个值可以从用户在命令行中的输入获取。

代码清单7-2说明了如何通过SFTP把文件复制到远程主机中，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 7
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import argparse
import paramiko
import getpass

SOURCE = '7_2_copy_remote_file_over_sftp.py'
DESTINATION ='/tmp/7_2_copy_remote_file_over_sftp.py '

def copy_file(hostname, port, username, password, src, dst):
    client = paramiko.SSHClient()
    client.load_system_host_keys()
    print " Connecting to %s \n with username=%s... \n" %(hostname,username)
    t = paramiko.Transport((hostname, port))
    t.connect(username=username,password=password)
    sftp = paramiko.SFTPClient.from_transport(t)
    print "Copying file: %s to path: %s" %(src, dst)
    sftp.put(src, dst)
    sftp.close()
    t.close()

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Remote file copy')
    parser.add_argument('--host', action="store", dest="host", default='localhost')
    parser.add_argument('--port', action="store", dest="port", default=22, type=int)
    parser.add_argument('--src', action="store", dest="src", default=SOURCE)
    parser.add_argument('--dst', action="store", dest="dst", default=DESTINATION)

    given_args = parser.parse_args()
    hostname, port =  given_args.host, given_args.port
    src, dst = given_args.src, given_args.dst

    username = raw_input("Enter the username:")
    password = getpass.getpass("Enter password for %s: " %username)

    copy_file(hostname, port, username, password, src, dst)

运行这个脚本后，会看到类似下面的输出：

$ python 7_2_copy_remote_file_over_sftp.py
Enter the username:faruq
Enter password for faruq:
 Connecting to localhost
 with username=faruq...
Copying file: 7_2_copy_remote_file_over_sftp.py to path:
/tmp/7_2_copy_remote_file_over_sftp.py

7.3.3　原理分析

这个攻略可以接收不同的输入值，然后连接到远程设备，通过SFTP复制文件。

这个攻略把命令行中的输入传给copy_file()函数，然后使用paramiko库中的SSHClient类创建一个SSH客户端。这个客户端需要加载系统的主机密钥。然后创建一个Transport类的实例，连接远程服务器。真正的SFTP连接对象sftp由paramiko库中的SFTPClient.from_transport()函数创建，其参数是Transport类的实例。

SFTP连接好之后，使用put()方法借由这个连接把本地文件复制到远程主机中。

最后，分别在各个对象上调用close()方法，清理SFTP连接和底层对象。这是一个好习惯。

7.4　打印远程设备的CPU信息

有时，我们需要通过SSH在远程设备中运行一个简单的命令。例如，查询远程设备的CPU或RAM信息。这种操作可以使用本节中的Python脚本完成。

7.4.1　准备工作

你需要安装第三方库paramiko，如下面的命令所示。paramiko的源代码可从GitHub仓库中获取，地址为https://github.com/paramiko/paramiko。

$ pip install paramiko

7.4.2　实战演练

我们可以使用paramiko模块创建一个远程会话连接Unix设备。

然后，通过这个会话，我们可以读取远程设备中的/proc/cpuinfo文件，获取CPU信息。

代码清单7-3是打印远程设备CPU信息的代码，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 7
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import argparse
import getpass
import paramiko

RECV_BYTES = 4096
COMMAND = 'cat /proc/cpuinfo'

def print_remote_cpu_info(hostname, port, username, password):
    client = paramiko.Transport((hostname, port))
    client.connect(username=username, password=password)

    stdout_data = []
    stderr_data = []
    session = client.open_channel(kind='session')
    session.exec_command(COMMAND)
    while True:
        if session.recv_ready():
            stdout_data.append(session.recv(RECV_BYTES))
        if session.recv_stderr_ready():
            stderr_data.append(session.recv_stderr(RECV_BYTES))
        if session.exit_status_ready():
            break

    print 'exit status: ', session.recv_exit_status()
    print ''.join(stdout_data)
    print ''.join(stderr_data)

    session.close()
    client.close()

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Remote file copy')
    parser.add_argument('--host', action="store", dest="host", default='localhost')
    parser.add_argument('--port', action="store", dest="port", default=22, type=int)
    given_args = parser.parse_args()
    hostname, port =  given_args.host, given_args.port

    username = raw_input("Enter the username:")
    password = getpass.getpass("Enter password for %s: " %username)
    print_remote_cpu_info(hostname, port, username, password)

运行这个脚本后会显示指定主机的CPU信息，这里连接的是本地设备，如下所示：

$ python 7_3_print_remote_cpu_info.py
Enter the username:faruq
Enter password for faruq:
exit status:  0
processor: 0
vendor_id: GenuineIntel
cpu family: 6
model: 42
model name: Intel(R) Core(TM) i5-2400S CPU @ 2.50GHz
stepping: 7
cpu MHz: 2469.677
cache size: 6144 KB
fdiv_bug: no
hlt_bug: no
f00f_bug: no
coma_bug: no
fpu: yes
fpu_exception: yes
cpuid level: 5
wp: yes
flags: fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca
cmov pat pse36 clflush mmx fxsr sse sse2 syscall nx rdtscp lm constant_
tsc up pni monitor ssse3 lahf_lm
bogomips: 4939.35
clflush size: 64
cache_alignment: 64
address sizes: 36 bits physical, 48 bits virtual
power management:

7.4.3　原理分析

首先，收集连接所需的参数，例如主机名、端口号、用户名和密码。然后，把这些参数传给print_remote_cpu_info()函数。

在这个函数中，使用paramiko模块中的Transport类创建了一个SSH客户端会话。然后使用提供的用户名和密码连接远程设备。我们可以在SSH客户端上调用open_channel()方法创建一个原始通信会话。若想在远程主机中执行命令，可以使用exec_command()方法。

把命令发送给远程主机之后，可以通过封阻会话对象的recv_ready()事件来获取远程主机的响应。我们可以创建两个列表，stdout_data和stderr_data，用来存储远程主机的输出和错误消息。

命令在远程设备中退出时，可以使用exit_status_ready()方法检测到。接收到的远程会话数据使用join()方法串接起来。

最后，分别在各对象上调用close()方法，中断会话和客户端连接。

7.5　在远程主机中安装Python包

在前面几个攻略中，你可能注意到了，在远程主机中执行操作时，要写很多代码建立连接。为了提高执行效率，最好抽象这些代码，只向程序员开放相对高级的操作。在远程设备中执行命令时总是要建立连接，这个过程很繁琐，也浪费时间。

Fabric（http://fabfile.org/）这个Python第三方模块可以解决这个问题。它只开放了适当数量的API，能高效地和远程设备交互。

这个攻略举个简单的例子说明如何使用Fabric。

7.5.1　准备工作

首先，我们要安装Fabric。你可以使用Python包管理工具pip或easy_install安装，如下面的命令所示。Fabric依赖于paramiko模块，安装Fabric时会自动安装paramiko。

$ pip install fabric

这里，我们要通过SSH协议连接远程主机，所以必须在远程主机中运行SSH服务器。如果想在本机中测试（假装是连接到远程设备），可以在本地安装openssh服务器包。在Debian/Ubuntu中，可以使用包管理器apt-get安装，如下面的命令所示：

$ sudo apt-get install openssh-server

7.5.2　实战演练

下面这段代码说明了如何使用Fabric安装Python包。

代码清单7-4给出了在远程主机中安装Python包所需的代码，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 7
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

from getpass import getpass
from fabric.api import settings, run, env, prompt

def remote_server():
    env.hosts = ['127.0.0.1']
    env.user = prompt('Enter user name: ')
    env.password = getpass('Enter password: ')

def install_package():
    run("pip install yolk")

Fabric脚本和普通Python脚本的运行方式不一样。使用fabric库定义的所有函数都要保存在一个名为fabfile.py的Python脚本中。在这个脚本中，没有传统的__main__指令。你可以使用Fabric API定义方法，然后使用命令行工具fab执行。因此，我们不使用python <script>.py运行Fabric脚本，而是在当前目录中创建一个fabfile.py脚本，然后执行fab one_function_name another_function_name。

那么，我们来按照下面命令中的方式创建fabfile.py脚本。为了简化操作，你可以创建文件快捷方式，或者把其他文件链接到fabfile.py脚本上。首先，删除前面创建的fabfile.py文件，然后创建一个指向fabfile的快捷方式：

$ rm -rf fabfile.py
$ ln -s 7_4_install_python_package_remotely.py fabfile.py

如果运行fabfile，在远程主机中安装Python包yolk后会生成如下输出：

$ ln -sfn 7_4_install_python_package_remotely.py fabfile.py
$ fab remote_server install_package
Enter user name: faruq
Enter password:
[127.0.0.1] Executing task 'install_package'
[127.0.0.1] run: pip install yolk
[127.0.0.1] out: Downloading/unpacking yolk
[127.0.0.1] out:   Downloading yolk-0.4.3.tar.gz (86kB):
[127.0.0.1] out:   Downloading yolk-0.4.3.tar.gz (86kB): 100%  86kB
[127.0.0.1] out:   Downloading yolk-0.4.3.tar.gz (86kB):
[127.0.0.1] out:   Downloading yolk-0.4.3.tar.gz (86kB): 86kB
downloaded
[127.0.0.1] out:   Running setup.py egg_info for package yolk
[127.0.0.1] out:     Installing yolk script to /home/faruq/env/bin
[127.0.0.1] out: Successfully installed yolk
[127.0.0.1] out: Cleaning up...
[127.0.0.1] out:

Done.
Disconnecting from 127.0.0.1... done.

7.5.3　原理分析

这个攻略演示了如何使用Python脚本在远程主机中执行系统管理任务。这个脚本中定义了两个函数。remote_server()函数设定Fabric的env环境变量，例如主机名、用户名和密码等。

另一个函数install_package()调用run()方法，其参数是在命令行中输入的命令。在这个脚本中，执行的命令是pip install yolk，即使用pip安装Python包yolk。和前面的攻略相比，使用Fabric在远程主机中运行命令更简单也更高效。

7.6　在远程主机中运行MySQL命令

如果你需要远程管理MySQL服务器，可以参考这个攻略。这个攻略会告诉你如何在Python脚本中向远程MySQL服务器发送数据库命令。如果要设置一个使用数据库后台的Web应用，这个攻略可以作为设置Web应用过程中的一部分。

7.6.1　准备工作

这个攻略也需要先安装Fabric。你可以使用Python包管理工具pip或easy_install安装，如下面的命令所示。Fabric依赖于paramiko模块，安装Fabric时会自动安装paramiko。

$ pip install fabric

这里，我们要通过SSH协议连接远程主机，所以必须在远程主机中运行SSH服务器。远程主机中还要运行MySQL服务器。在Debian/Ubuntu中，可以使用包管理器apt-get安装openssh和mysql服务器，如下面的命令所示：

$ sudo apt-get install openssh-server mysql-server

7.6.2　实战演练

我们要设定一些Fabric环境变量，再定义几个用于远程管理MySQL的函数。在这些函数中，我们不会直接使用可执行文件mysql，而是通过echo把SQL命令发送给mysql。这么做能确保正确地把参数传递给mysql可执行文件。

代码清单7-5给出了远程运行MySQL命令所需的代码，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 7
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

from getpass import getpass
from fabric.api import run, env, prompt, cd

def remote_server():
    # Edit this list to include remote hosts
    env.hosts = ['127.0.0.1']
    env.user = prompt('Enter your system username: ')
    env.password = getpass('Enter your system user password: ')
    env.mysqlhost = 'localhost'
    env.mysqluser = prompt('Enter your db username: ')
    env.mysqlpassword = getpass('Enter your db user password: ')
    env.db_name = ''

def show_dbs():
    """ Wraps mysql show databases cmd"""
    q = "show databases"
    run("echo '%s' | mysql -u%s -p%s" %(q, env.mysqluser, env.mysqlpassword))

def run_sql(db_name, query):
    """ Generic function to run sql"""
    with cd('/tmp'):
        run("echo '%s' | mysql -u%s -p%s -D %s" %(query, env.mysqluser, env.mysqlpassword, db_name))

def create_db():
    """Create a MySQL DB for App version"""
    if not env.db_name:
        db_name = prompt("Enter the DB name:")
    else:
        db_name = env.db_name
    run('echo "CREATE DATABASE %s default character set utf8 collate utf8_unicode_ci;"|mysql --batch --user=%s --password=%s --host=%s'\
        % (db_name, env.mysqluser, env.mysqlpassword, env.mysqlhost), pty=True)

def ls_db():
    """ List a dbs with size in MB """
    if not env.db_name:
        db_name = prompt("Which DB to ls?")
    else:
        db_name = env.db_name
    query = """SELECT table_schema "DB Name",
        Round(Sum(data_length + index_length) / 1024 / 1024, 1) "DB Size in MB"
        FROM   information_schema.tables
        WHERE table_schema = \"%s\"
        GROUP  BY table_schema """ %db_name
    run_sql(db_name, query)

def empty_db():
    """ Empty all tables of a given DB """
    db_name = prompt("Enter DB name to empty:")
    cmd = """
    (echo 'SET foreign_key_checks = 0;';
    (mysqldump -u%s -p%s --add-drop-table --no-data %s |
     grep ^DROP);
     echo 'SET foreign_key_checks = 1;') | \
     mysql -u%s -p%s -b %s
    """ %(env.mysqluser, env.mysqlpassword, db_name, env.mysqluser, env.mysqlpassword, db_name)
    run(cmd)

若想运行这个脚本，要创建一个快捷方式fabfile.py。在命令行中执行下面的命令可以完成这一操作：

$ ln -sfn 7_5_run_mysql_command_remotely.py fabfile.py

然后，可以使用fab可执行文件执行不同的操作。

下述命令显示了一个数据库列表（使用SQL查询show databases）：

$ fab remote_server show_dbs

下述命令会创建一个新的MySQL数据库。如果没有定义Fabric环境变量db_name，会显示一个提示符，要求输入目标数据库的名称。数据库使用以下SQL命令创建：CREATE DATABASE <database_name> default character set utf8 collate utf8_unicode_ci;。

$ fab remote_server create_db

这个Fabric命令显示数据库的大小：

$ fab remote_server ls_db()

下面这个Fabric命令使用可执行文件mysqldump和mysql清空数据库。这个函数的作用和数据库的TRUNCATE命令类似，只不过同时还会删除所有表。结果就像新建一个没有任何表的数据库一样。

$ fab remote_server empty_db()

各命令的输出如下：

$ fab remote_server show_dbs
[127.0.0.1] Executing task 'show_dbs'
[127.0.0.1] run: echo 'show databases' | mysql -uroot -p<DELETED>
[127.0.0.1] out: Database
[127.0.0.1] out: information_schema
[127.0.0.1] out: mysql
[127.0.0.1] out: phpmyadmin
[127.0.0.1] out:

Done.
Disconnecting from 127.0.0.1... done.

$ fab remote_server create_db
[127.0.0.1] Executing task 'create_db'
Enter the DB name: test123
[127.0.0.1] run: echo "CREATE DATABASE test123 default character set utf8
collate utf8_unicode_ci;"|mysql --batch --user=root --password=<DELETED>
--host=localhost

Done.
Disconnecting from 127.0.0.1... done.

$ fab remote_server show_dbs
[127.0.0.1] Executing task 'show_dbs'
[127.0.0.1] run: echo 'show databases' | mysql -uroot -p<DELETED>
[127.0.0.1] out: Database
[127.0.0.1] out: information_schema
[127.0.0.1] out: collabtive
[127.0.0.1] out: test123
[127.0.0.1] out: testdb
[127.0.0.1] out:

Done.
Disconnecting from 127.0.0.1... done.

7.6.3　原理分析

这个脚本定义了Fabric使用的几个函数。第一个函数remote_server()设定环境变量。本地回送IP（127.0.0.1）保存在主机列表中。本地系统的用户密码和MySQL登录密码通过getpass()方法获取。

另一个函数利用Fabric中的run()函数，把MySQL命令回显给mysql可执行文件，发送给远程MySQL服务器。

run_sql()函数是个通用函数，作为一个包装函数，可在其他函数中使用。例如，在empty_db()函数中调用了run_sql()函数执行SQL命令。这么做可以让代码变得更有序、更简洁。

7.7　通过SSH把文件传输到远程设备中

使用Fabric执行远程系统管理任务时，如果想通过SSH把本地设备中的文件传输到远程设备中，可以使用Fabric内置的get()和put()函数。这个攻略向你展示如何定义函数传输文件，并且在传输前后检查硬盘空间。

7.7.1　准备工作

这个攻略也需要先安装Fabric。你可以使用Python包管理工具pip或easy_install安装，如下面的命令所示：

$ pip install fabric

这里，我们要通过SSH协议连接远程主机，所以必须在远程主机中安装并运行SSH服务器。

7.7.2　实战演练

我们先要为Fabric设定环境变量，然后再定义两个函数，一个用于下载文件，另一个用于上传文件。

代码清单7-6是通过SSH把文件传输到远程设备中所需的代码，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 7
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

from getpass import getpass
from fabric.api import local, run, env, get, put, prompt, open_shell

def remote_server():
    env.hosts = ['127.0.0.1']
    env.password = getpass('Enter your system password: ')
    env.home_folder = '/tmp'

def login():
    open_shell(command="cd %s" %env.home_folder)

def download_file():
    print "Checking local disk space..."
    local("df -h")
    remote_path = prompt("Enter the remote file path:")
    local_path = prompt("Enter the local file path:")
    get(remote_path=remote_path, local_path=local_path)
    local("ls %s" %local_path)

def upload_file():
    print "Checking remote disk space..."
    run("df -h")
    local_path = prompt("Enter the local file path:")
    remote_path = prompt("Enter the remote file path:")
    put(remote_path=remote_path, local_path=local_path)
    run("ls %s" %remote_path)

若想运行这个脚本，要创建一个快捷方式fabfile.py。在命令行中执行下面的命令可以完成这一操作：

$ ln -sfn 7_6_transfer_file_over_ssh.py fabfile.py

然后，可以使用fab可执行文件执行不同的操作。

首先，若想使用这个脚本登录远程服务器，可以运行下面这个Fabric函数：

$ fab remote_server login

执行上述命令后会看到一个类似shell的微型环境。然后，可以使用下面的命令把文件从远程服务器下载到本地设备中：

$ fab remote_server download_file

类似地，上传文件可以使用下面这个命令：

$ fab remote_server upload_file

在这个例子中，我们通过SSH连接本地设备。因此，你要在本地设备中安装SSH服务器才能运行这个脚本。不然，你可以修改remote_server()函数，改成连接到远程主机，如下所示：

$ fab remote_server login
[127.0.0.1] Executing task 'login'
Linux debian6 2.6.32-5-686 #1 SMP Mon Feb 25 01:04:36 UTC 2013 i686

The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
You have new mail.
Last login: Wed Aug 21 15:08:45 2013 from localhost
cd /tmp
faruq@debian6:~$ cd /tmp
faruq@debian6:/tmp$

<CTRL+D>
faruq@debian6:/tmp$ logout

Done.
Disconnecting from 127.0.0.1... done.

$ fab remote_server download_file
[127.0.0.1] Executing task 'download_file'
Checking local disk space...
[localhost] local: df -h
Filesystem            Size  Used Avail Use% Mounted on
/dev/sda1              62G   47G   12G  81% /
tmpfs                 506M     0  506M   0% /lib/init/rw
udev                  501M  160K  501M   1% /dev
tmpfs                 506M  408K  505M   1% /dev/shm
Z_DRIVE              1012G  944G   69G  94% /media/z
C_DRIVE               466G  248G  218G  54% /media/c
Enter the remote file path: /tmp/op.txt
Enter the local file path: .
[127.0.0.1] download: chapter7/op.txt <- /tmp/op.txt
[localhost] local: ls .
7_1_execute_remote_telnet_cmd.py   7_3_print_remote_cpu_info.py
7_5_run_mysql_command_remotely.py  7_7_configure_Apache_for_hosting_
website_remotely.py  fabfile.pyc  __init__.py  test.txt
7_2_copy_remote_file_over_sftp.py  7_4_install_python_package_
remotely.py  7_6_transfer_file_over_ssh.py      fabfile.py
index.html     op.txt       vhost.conf

Done.
Disconnecting from 127.0.0.1... done.

7.7.3　原理分析

在这个攻略中，用到了几个Fabric内置的函数，在本地设备和远程设备之间传输文件。local()函数在本地设备中执行操作，run()函数在远程设备中执行操作。

在上传和下载文件前最好先检查目标设备中的可用硬盘空间。

这个操作使用Unix命令df实现。源文件和目标文件的路径可以在命令行中指定，也可硬编码在源文件中，以备无人值守时自动执行。

7.8　远程配置Apache运行网站

Fabric函数可以以普通用户身份运行，也可以超级用户身份运行。如果想在远程Apache服务器上运行网站，需要管理员权限才能创建配置文件以及重启Web服务器。这个攻略介绍Fabric的sudo()函数，以超级用户身份在远程设备中执行命令。这里，我们要配置运行网站所需的Apache虚拟主机。

7.8.1　准备工作

这个攻略需要先在本地设备中安装Fabric。你可以使用Python包管理工具pip或easy_install安装，如下面的命令所示：

$ pip install fabric

这里，我们要通过SSH协议连接远程主机，所以必须在远程主机中安装并运行SSH服务器。远程主机中还需要安装和运行Apache Web服务器。在Debian/Ubuntu中，可以使用包管理器apt-get安装，如下面的命令所示：

$ sudo apt-get install openssh-server apache2

7.8.2　实战演练

首先，我们要知道Apache的安装路径和一些配置参数，例如Web服务器的用户、用户组、虚拟主机配置文件的路径和初始化脚本。这些参数可以定义为常量。

然后，定义两个函数，remote_server()和setup_vhost()，使用Fabric执行Apache配置任务。

代码清单7-7是远程配置Apache运行网站所需的代码，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 7
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

from getpass import getpass
from fabric.api import env, put, sudo, prompt
from fabric.contrib.files import exists

WWW_DOC_ROOT = "/data/apache/test/"
WWW_USER = "www-data"
WWW_GROUP = "www-data"
APACHE_SITES_PATH = "/etc/apache2/sites-enabled/"
APACHE_INIT_SCRIPT = "/etc/init.d/apache2 "

def remote_server():
    env.hosts = ['127.0.0.1']
    env.user = prompt('Enter user name: ')
    env.password = getpass('Enter your system password: ')

def setup_vhost():
    """ Setup a test website """
    print "Preparing the Apache vhost setup..."

    print "Setting up the document root..."
    if exists(WWW_DOC_ROOT):
        sudo("rm -rf %s" %WWW_DOC_ROOT)
    sudo("mkdir -p %s" %WWW_DOC_ROOT)

    # setup file permissions
    sudo("chown -R %s.%s %s" %(env.user, env.user, WWW_DOC_ROOT))

    # upload a sample index.html file
    put(local_path="index.html", remote_path=WWW_DOC_ROOT)
    sudo("chown -R %s.%s %s" %(WWW_USER, WWW_GROUP, WWW_DOC_ROOT))

    print "Setting up the vhost..."
    sudo("chown -R %s.%s %s" %(env.user, env.user, APACHE_SITES_PATH))

    # upload a pre-configured vhost.conf
    put(local_path="vhost.conf", remote_path=APACHE_SITES_PATH)
    sudo("chown -R %s.%s %s" %('root', 'root', APACHE_SITES_PATH))

    # restart Apache to take effect
    sudo("%s restart" %APACHE_INIT_SCRIPT)
    print "Setup complete. Now open the server path http://abc.remote-server.org/ in your web browser."

为了运行这个脚本，要把下面这行加入主机文件中，例如/etc/hosts：

127.0.0.1 abc.remote-server.org abc

还要创建快捷方式fabfile.py。在命令行中，可以使用下面的命令完成：

$ ln -sfn 7_7_configure_Apache_for_hosting_website_remotely.py fabfile.py

然后，可以使用fab可执行文件执行不同的操作。

首先，若想使用这个脚本登录远程服务器，可以运行下面这个Fabric函数。得到的输出结果如下：

$ fab remote_server setup_vhost
[127.0.0.1] Executing task 'setup_vhost'
Preparing the Apache vhost setup...
Setting up the document root...
[127.0.0.1] sudo: rm -rf /data/apache/test/
[127.0.0.1] sudo: mkdir -p /data/apache/test/
[127.0.0.1] sudo: chown -R faruq.faruq /data/apache/test/
[127.0.0.1] put: index.html -> /data/apache/test/index.html
[127.0.0.1] sudo: chown -R www-data.www-data /data/apache/test/
Setting up the vhost...
[127.0.0.1] sudo: chown -R faruq.faruq /etc/apache2/sites-enabled/
[127.0.0.1] put: vhost.conf -> /etc/apache2/sites-enabled/vhost.conf
[127.0.0.1] sudo: chown -R root.root /etc/apache2/sites-enabled/
[127.0.0.1] sudo: /etc/init.d/apache2 restart
[127.0.0.1] out: Restarting web server: apache2apache2: Could not
reliably determine the server's fully qualified domain name, using
127.0.0.1 for ServerName
[127.0.0.1] out:  ... waiting apache2: Could not reliably determine the
server's fully qualified domain name, using 127.0.0.1 for ServerName
[127.0.0.1] out: .
[127.0.0.1] out:

Setup complete. Now open the server path http://abc.remote-server.org/ in your web browser.

Done.
Disconnecting from 127.0.0.1... done.

运行这个脚本之后，你可以打开浏览器，访问主机文件（例如/etc/hosts）中设定的路径。在浏览器中会看到如下输出：

It works!
This is the default web page for this server.
The web server software is running but no content has been added, yet.

7.8.3　原理分析

这个攻略把初始的Apache配置参数设置为常量，然后定义了两个函数。在remote_server()函数中，和往常一样，设定了Fabric环境参数，例如主机、用户名和密码等。

setup_vhost()函数执行了一系列需要特殊权限的命令。首先，使用exists()函数检查是否已经创建了网站的文档根目录。如果该路径存在，就将其删除，在下一步中重新创建。然后再执行chown命令，确保当前用户有权访问这个路径。

接着，把一个骨架HTML文件（index.html）上传到文档根路径中。上传后，再把文件的访问权限赋予Web服务器用户。

设置好文档根目录后，setup_vhost()函数把vhost.conf文件上传到Apache的网站配置路径中，然后把这个文件的拥有者设为根用户。

最后，这个脚本重启Apache服务器，让配置生效。如果配置正确，你会在浏览器中看到前面访问http://abc.remote-server.org/时显示的内容。

第 8 章　使用Web服务：XML-RPC、SOAP和REST

本章攻略：

查询本地XML-RPC服务器

编写一个多线程、多调用XML-RPC服务器

运行一个支持HTTP基本认证的XML-RPC服务器

使用REST从Flickr中收集一些照片信息

找出亚马逊S3 Web服务支持的SOAP方法

使用谷歌搜索定制信息

通过商品搜索API在亚马逊中搜索图书

8.1　简介

本章介绍一些有趣的Python攻略，通过三种不同的方式使用Web服务。这三种方式是“XML远程过程调用”（XML Remote Procedure Call，简称XML-RPC）、“简单对象访问协议”（Simple Object Access Protocol，简称SOAP）和“表现层状态转化”（Representational State Transfer，简称REST）。Web服务的目的，是让两个软件组件通过精心设计的协议在网络中交互。接口是机器可读的。多种不同的协议为Web服务的使用提供了便利。

本章包含这三种常用协议的示例。XML-RPC使用HTTP作为传输媒介，使用XML格式的内容通信。实现XML-RPC的服务器等待适配的客户端调用。客户端使用不同的参数调用服务器，执行远程过程。XML-RPC较为简单，但安全性不高。SOAP有一组丰富的协议，用于增强远程过程调用。REST是一种架构风格，让Web服务变得简单。REST架构中的操作使用HTTP请求方法完成，即GET、POST、PUT和DELETE。本章介绍这些Web服务协议和风格的用法，完成一些常见任务。

8.2　查询本地XML-RPC服务器

如果你经常做Web编程，可能会遇到这样的任务：从支持XML-RPC服务的网站中获取一些信息。在深入介绍XML-RPC服务之前，我们先架设一个XML-RPC服务器并和它通信。

8.2.1　准备工作

这个攻略要使用Python Supervisor程序。Supervisor广泛用于启动和管理可执行程序。Supervisor可以作为后台守护进程运行，能监控子进程，且子进程意外退出后能重启子进程。执行下面的命令即可安装Supervisor：

$ pip install supervisor

8.2.2　实战演练

我们要为Supervisor创建一个配置文件。这个攻略中提供了一个示例配置，定义了一个Unix HTTP服务器套接字和一些其他参数。注意rpcinterface:supervisor部分，其中rpcinterface_factory是用来和客户端通信的。

在program:8_2_ multithreaded_multicall_xmlrpc_server.py部分，我们使用Supervisor配置了一个简单的服务器程序，指定了命令和一些其他参数。

代码清单8-1a是一个简单的Supervisor配置，如下所示：

[unix_http_server]
file=/tmp/supervisor.sock   ; (the path to the socket file)
chmod=0700                  ; socket file mode (default 0700)

[supervisord]
logfile=/tmp/supervisord.log
loglevel=info
pidfile=/tmp/supervisord.pid
nodaemon=true

[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface

[program:8_2_multithreaded_multicall_xmlrpc_server.py]
command=python 8_2_multithreaded_multicall_xmlrpc_server.py ; the program (relative uses PATH, can take args)
process_name=%(program_name)s ; process_name expr  (default %(program_name)s)

如果在你最喜欢的编辑器中创建上述Supervisor配置文件，调用这个文件就可以运行Supervisor。

现在，我们可以编写一个XML-RPC客户端，作为Supervisor的代理，获取运行中的进程信息。

代码清单8-1b是查询XML-RPC本地服务器的代码，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 8
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import supervisor.xmlrpc
import xmlrpclib

def query_supervisr(sock):
    transport = supervisor.xmlrpc.SupervisorTransport(None, None,
                'unix://%s' %sock)
    proxy = xmlrpclib.ServerProxy('http://127.0.0.1',
            transport=transport)
    print "Getting info about all running processes via Supervisord..."
    print proxy.supervisor.getAllProcessInfo()

if __name__ == '__main__':
    query_supervisr(sock='/tmp/supervisor.sock')

运行这个Supervisor守护进程，会看到类似下面的输出：

chapter8$ supervisord
2013-09-27 16:40:56,861 INFO RPC interface 'supervisor' initialized
2013-09-27 16:40:56,861 CRIT Server 'unix_http_server' running
without any HTTP authentication checking
2013-09-27 16:40:56,861 INFO supervisord started with pid 27436
2013-09-27 16:40:57,864 INFO spawned:
'8_2_multithreaded_multicall_xmlrpc_server.py' with pid 27439
2013-09-27 16:40:58,940 INFO success:
8_2_multithreaded_multicall_xmlrpc_server.py entered RUNNING state,
process has stayed up for > than 1 seconds (startsecs)

注意，运行后启动了子进程8_2_multithreaded_multicall_xmlrpc_server.py。

现在，如果运行客户端代码，它会查询Supervisor的XML-RPC服务器接口，列出运行中的进程，如下所示：

$ python 8_1_query_xmlrpc_server.py
Getting info about all running processes via Supervisord...
[{'now': 1380296807, 'group':
'8_2_multithreaded_multicall_xmlrpc_server.py', 'description': 'pid
27439, uptime 0:05:50', 'pid': 27439, 'stderr_logfile':
'/tmp/8_2_multithreaded_multicall_xmlrpc_server.py-stderr---
supervisor-i_VmKz.log', 'stop': 0, 'statename': 'RUNNING', 'start':
1380296457, 'state': 20, 'stdout_logfile':
'/tmp/8_2_multithreaded_multicall_xmlrpc_server.py-stdout---
supervisor-eMuJqk.log', 'logfile':
'/tmp/8_2_multithreaded_multicall_xmlrpc_server.py-stdout---
supervisor-eMuJqk.log', 'exitstatus': 0, 'spawnerr': '', 'name':
'8_2_multithreaded_multicall_xmlrpc_server.py'}]

8.2.3　原理分析

这个攻略要在后台运行Supervisor守护进程（通过rpcinterface配置）。Supervisor会启动另一个XML-RPC服务器，即8_2_ multithreaded_multicall_xmlrpc_server.py。

客户端代码中定义了query_supervisr()方法，其参数是Supervisor套接字。在这个方法中，使用Unix套接字路径创建了一个SupervisorTransport实例。然后，实例化xmlrpclib模块中的ServerProxy类创建了一个XML-RPC服务器代理。传给ServerProxy类构造方法的参数是服务器地址和前面创建的transport。

随后，XML-RPC服务器代理调用Supervisor中的getAllProcessInfo()方法，把子进程的信息打印出来。打印的信息包括pid、statename和description等。

8.3　编写一个多线程、多调用XML-RPC服务器

你可以让你的XML-RPC服务器同时接受多个调用。这意味着，多个函数调用可以只返回一个结果。而且，如果服务器支持多线程，服务器启动后还能在单个线程中执行更多的代码。此时，程序的主线程处于非阻塞模式中。

8.3.1　实战演练

我们可以定义一个ServerThread类，继承自threading.Thread类，而且还可以把这个类的一个属性设为SimpleXMLRPCServer实例。这样就能接受多个调用了。

然后，我们可以定义两个函数：一个用来启动多线程、多调用XML-RPC服务器，另一个用于创建连接服务器的客户端。

代码清单8-2是编写多线程、多调用XML-RPC服务器所需的代码，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 8
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import argparse
import xmlrpclib
import threading

from SimpleXMLRPCServer import SimpleXMLRPCServer

# some trivial functions
def add(x,y):
    return x+y

def subtract(x, y):
    return x-y

def multiply(x, y):
    return x*y

def divide(x, y):
    return x/y

class ServerThread(threading.Thread):
    def __init__(self, server_addr):
        threading.Thread.__init__(self)
        self.server = SimpleXMLRPCServer(server_addr)
        self.server.register_multicall_functions()
        self.server.register_function(add, 'add')
        self.server.register_function(subtract, 'subtract')
        self.server.register_function(multiply, 'multiply')
        self.server.register_function(divide, 'divide')

    def run(self):
        self.server.serve_forever()

def run_server(host, port):
    # server code
    server_addr = (host, port)
    server = ServerThread(server_addr)
    server.start() # The server is now running
    print "Server thread started. Testing the server..."

def run_client(host, port):
    # client code
    proxy = xmlrpclib.ServerProxy("http://%s:%s/" %(host, port))
    multicall = xmlrpclib.MultiCall(proxy)
    multicall.add(7,3)
    multicall.subtract(7,3)
    multicall.multiply(7,3)
    multicall.divide(7,3)
    result = multicall()
    print "7+3=%d, 7-3=%d, 7*3=%d, 7/3=%d" % tuple(result)

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Multithreaded multicall XMLRPC Server/Proxy')
    parser.add_argument('--host', action="store", dest="host", default='localhost')
    parser.add_argument('--port', action="store", dest="port", default=8000, type=int)
    # parse arguments
    given_args = parser.parse_args()
    host, port =  given_args.host, given_args.port
    run_server(host, port)
    run_client(host, port)

运行这个脚本后，会看到类似下面的输出：

$ python 8_2_multithreaded_multicall_xmlrpc_server.py --port=8000
Server thread started. Testing the server...
localhost - - [25/Sep/2013 17:38:32] "POST / HTTP/1.1" 200 -
7+3=10, 7-3=4, 7*3=21, 7/3=2

8.3.2　原理分析

在这个攻略中，我们定义了ServerThread类，继承自Python线程库中的Thread类。这个子类初始化时把server属性设为一个SimpleXMLRPCServer服务器实例。XML-RPC服务器的地址可以在命令行中指定。为了启用多调用功能，我们在服务器实例上调用了register_multicall_functions()方法。

然后把四个简单的函数注册到XML-RPC服务器上：add()、subtract()、multiply()和divide()。这几个函数的作用正如其名称所示。

为了启动服务器，要把主机和端口传给run_server()函数。在这个函数中，使用前面说明的ServerThread类创建了一个服务器实例。然后在这个服务器实例上调用start()方法启动XML-RPC服务器。

再看客户端。run_client()函数同样从命令行中获取主机和端口。然后使用xmlrpclib中的ServerProxy类创建一个XML-RPC服务器代理实例。再把这个代理实例传给MultiCall类，创建一个实例multicall。现在，可以运行前面定义的四个RPC方法了，即add()、subtract()、multiply()和divide()。最后，我们只通过一次调用（multicall()）获取结果，再把结果中的元组在一行中打印出来。

8.4　运行一个支持HTTP基本认证的XML-RPC服务器

有时，你需要在XML-RPC服务器中实现认证功能。这个攻略介绍了一个简单的示例，说明如何在XML-RPC服务器中实现HTTP基本认证。

8.4.1　 实战演练

我们可以定义一个SimpleXMLRPCServer类的子类，重新定义请求处理方法，以便当请求进入时，和指定的登录凭据比对。

代码清单8-3a是在XML-RPC服务器中实现HTTP基本认证的代码，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 8
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import argparse
import xmlrpclib
from base64 import b64decode
from SimpleXMLRPCServer  import SimpleXMLRPCServer, SimpleXMLRPCRequestHandler

class SecureXMLRPCServer(SimpleXMLRPCServer):

    def __init__(self, host, port, username, password, *args, **kargs):
        self.username = username
        self.password = password
        # authenticate method is called from inner class
        class VerifyingRequestHandler(SimpleXMLRPCRequestHandler):
            # method to override
            def parse_request(request):
                if SimpleXMLRPCRequestHandler.parse_request(request):
                    # authenticate
                    if self.authenticate(request.headers):
                        return True
                    else:
                        # if authentication fails return 401
                        request.send_error(401, 'Authentication failed, Try agin.')
                return False
        # initialize
        SimpleXMLRPCServer.__init__(self, (host, port), requestHandler=VerifyingRequestHandler, *args, **kargs)

    def authenticate(self, headers):
        headers = headers.get('Authorization').split()
        basic, encoded = headers[0], headers[1]
        if basic != 'Basic':
            print 'Only basic authentication supported'
            return False
        secret = b64decode(encoded).split(':')
        username, password = secret[0], secret[1]
        return True if (username == self.username and password == self.password) else False

def run_server(host, port, username, password):
    server = SecureXMLRPCServer(host, port, username, password)
    # simple test function
    def echo(msg):
        """Reply client in  uppser case """
        reply = msg.upper()
        print "Client said: %s. So we echo that in uppercase: %s" %(msg, reply)
        return reply
    server.register_function(echo, 'echo')
    print "Running a HTTP auth enabled XMLRPC server on %s:%s..." %(host, port)
    server.serve_forever()

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Multithreaded multicall XMLRPC Server/Proxy')
    parser.add_argument('--host', action="store", dest="host", default='localhost')
    parser.add_argument('--port', action="store", dest="port", default=8000, type=int)
    parser.add_argument('--username', action="store", dest="username", default='user')
    parser.add_argument('--password', action="store", dest="password", default='pass')
    # parse arguments
    given_args = parser.parse_args()
    host, port =  given_args.host, given_args.port
    username, password = given_args.username, given_args.password
    run_server(host, port, username, password)

运行这个脚本后，默认会看到如下输出：

$ python 8_3a_xmlrpc_server_with_http_auth.py
Running a HTTP auth enabled XMLRPC server on localhost:8000...
Client said: hello server.... So we echo that in uppercase: HELLO
SERVER...
localhost - - [27/Sep/2013 12:08:57] "POST /RPC2 HTTP/1.1" 200 -

现在，我们来创建一个简单的客户端代理，使用的登录凭据和服务器一样。

代码清单8-3b是XML-RPC客户端的代码，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 8
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import argparse
import xmlrpclib

def run_client(host, port, username, password):
    server = xmlrpclib.ServerProxy('http://%s:%s@%s:%s' %(username, password, host, port, ))
    msg = "hello server..."
    print "Sending message to server: %s  " %msg
    print "Got reply: %s" %server.echo(msg)

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Multithreaded multicall XMLRPC Server/Proxy')
    parser.add_argument('--host', action="store", dest="host", default='localhost')
    parser.add_argument('--port', action="store", dest="port", default=8000, type=int)
    parser.add_argument('--username', action="store", dest="username", default='user')
    parser.add_argument('--password', action="store", dest="password", default='pass')
    # parse arguments
    given_args = parser.parse_args()
    host, port =  given_args.host, given_args.port
    username, password = given_args.username, given_args.password
    run_client(host, port, username, password)

运行这个脚本后，会看到如下输出：

$ python 8_3b_xmprpc_client.py
Sending message to server: hello server...
Got reply: HELLO SERVER...

8.4.2　原理分析

在服务器脚本中，继承SimpleXMLRPCServer类定义了SecureXMLRPCServer子类。在这个子类的初始化代码中，定义了用于拦截请求的VerifyingRequestHandler类，使用authenticate()方法做基本认证。

我们把HTTP请求的首部传给authenticate()方法，检查Authorization首部的值。如果值等于Basic，就使用base64标准模块中的b64decode()方法解码编码后的密码。提取出用户名和密码后，再和服务器指定的登录凭据比对。

在run_server()函数中定义了一个简单的echo()子函数，将其注册到SecureXMLRPCServer实例上。

在客户端脚本中，run_client()函数的参数是服务器地址和登录凭据，再把这些参数传给ServerProxy类构造方法，创建一个实例。然后使用echo()方法发送一行消息。

8.5　使用REST从Flickr中收集一些照片信息

很多网站都通过REST API提供Web服务接口。Flickr这个著名的照片分享网站就提供了REST接口。让我们来收集一些照片信息，构建一个特殊的数据库或者开发照片相关的应用。

8.5.1　实战演练

我们需要REST URL发起HTTP请求。简单起见，这些URL硬编码在脚本中。我们可以使用第三方模块requests发起REST请求。requests模块提供了一些便利的方法，包括get()、post()、put()和delete()。

为了和Flickr的Web服务通信，你需要注册一个账户，然后获取API密钥。API密钥可写入local_settings.py文件，或者在命令行中提供。

代码清单8-4是使用REST从Flickr中收集照片信息所需的代码，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 8
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.
# Supply Flickr API key via local_settings.py

import argparse
import json
import requests

try:
    from local_settings import flickr_apikey
except ImportError:
    pass

def collect_photo_info(api_key, tag, max_count):
    """Collects some interesting info about some photos from Flickr.com for a given tag """
    photo_collection = []
    url =  "http://api.flickr.com/services/rest/?method=flickr.photos.search&tags=%s&format=json&nojsoncallback=1&api_key=%s" %(tag, api_key)
    resp = requests.get(url)
    results = resp.json()
    count  = 0
    for p in results['photos']['photo']:
        if count >= max_count:
            return photo_collection
        print 'Processing photo: "%s"' % p['title']
        photo = {}
        url = "http://api.flickr.com/services/rest/?method=flickr.photos.getInfo&photo_id=" + p['id'] + "&format=json&nojsoncallback=1&api_key=" + api_key
        info = requests.get(url).json()
        photo["flickrid"] = p['id']
        photo["title"] = info['photo']['title']['_content']
        photo["description"] = info['photo']['description']['_content']
        photo["page_url"] = info['photo']['urls']['url'][0]['_content']

        photo["farm"] = info['photo']['farm']
        photo["server"] = info['photo']['server']
        photo["secret"] = info['photo']['secret']

        # comments
        numcomments = int(info['photo']['comments']['_content'])
        if numcomments:
            #print "   Now reading comments (%d)..." % numcomments
            url = "http://api.flickr.com/services/rest/?method=flickr.photos.comments.getList&photo_id=" + p['id'] + "&format=json&nojsoncallback=1&api_key=" + api_key
            comments = requests.get(url).json()
            photo["comment"] = []
            for c in comments['comments']['comment']:
                comment = {}
                comment["body"] = c['_content']
                comment["authorid"] = c['author']
                comment["authorname"] = c['authorname']
                photo["comment"].append(comment)
        photo_collection.append(photo)
        count = count + 1
    return photo_collection

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Get photo info from Flickr')
    parser.add_argument('--api-key', action="store", dest="api_key", default=flickr_apikey)
    parser.add_argument('--tag', action="store", dest="tag", default='Python')
    parser.add_argument('--max-count', action="store", dest="max_count", default=3, type=int)
    # parse arguments
    given_args = parser.parse_args()
    api_key, tag, max_count =  given_args.api_key, given_args.tag, given_args.max_count
    photo_info = collect_photo_info(api_key, tag, max_count)
    for photo in photo_info:
        for k,v in photo.iteritems():
            if k == "title":
                print "Showiing photo info...."
            elif k == "comment":
                "\tPhoto got %s comments." %len(v)
            else:
                print "\t%s => %s" %(k,v)

Flickr API密钥可以放在local_settings.py文件中，也可在命令行中提供（通过--api-key参数）。除了API密钥，还可指定搜索标签和结果的最大数量。默认情况下，这个脚本搜索Python标签，结果限制为3个，如下面的输出所示：

$ python 8_4_get_flickr_photo_info.py
Processing photo: "legolas"
Processing photo: ""The Dance of the Hunger of Kaa""
Processing photo: "Rocky"
    description => Stimson Python
Showiing photo info....
    farm => 8
    server => 7402
    secret => 6cbae671b5
    flickrid => 10054626824
    page_url => http://www.flickr.com/photos/102763809@N03/10054626824/
    description => &quot; 'Good. Begins now the dance--the Dance of the
Hunger of Kaa. Sit still and watch.'

He turned twice or thrice in a big circle, weaving his head from right to left.
Then he began making loops and figures of eight with his body, and soft,
oozy triangles that melted into squares and five-sided figures, and
coiled mounds, never resting, never hurrying, and never stopping his
low humming song. It grew darker and darker, till at last the dragging,
shifting coils disappeared, but they could hear the rustle of the
scales.&quot;
(From &quot;Kaa's Hunting&quot; in &quot;The Jungle Book&quot; (1893) by Rudyard Kipling)

These old abandoned temples built around the 12th century belong to the
abandoned city which inspired Kipling's Jungle Book.
They are rising at the top of a mountain which dominates the jungle at
811 meters above sea level in the centre of the jungle of Bandhavgarh
located in the Indian state Madhya Pradesh.
Baghel King Vikramaditya Singh abandoned Bandhavgarh fort in 1617 when
Rewa, at a distance of 130 km was established as a capital.
Abandonment allowed wildlife development in this region.
When Baghel Kings became aware of it, he declared Bandhavgarh as their
hunting preserve and strictly prohibited tree cutting and wildlife
hunting...

Join the photographer at <a href="http://www.facebook.com/laurent.
goldstein.photography" rel="nofollow">www.facebook.com/laurent.goldstein.
photography</a>

© All photographs are copyrighted and all rights reserved.
Please do not use any photographs without permission (even for private
use).
The use of any work without consent of the artist is PROHIBITED and will
lead automatically to consequences.
Showiing photo info....
    farm => 6
    server => 5462
    secret => 6f9c0e7f83
    flickrid => 10051136944
    page_url => http://www.flickr.com/photos/designldg/10051136944/
    description => Ball Python
Showiing photo info....
    farm => 4
    server => 3744
    secret => 529840767f
    flickrid => 10046353675
    page_url => http://www.flickr.com/photos/megzzdollphotos/10046353675/

8.5.2　原理分析

这个攻略演示了如何使用Flickr的REST API和它交互。在这个示例中，collect_photo_info()函数有三个参数：Flickr API密钥、搜索标签和想要得到的搜索结果数量。

我们构建的第一个URL用于搜索照片。注意，在这个URL中，method参数的值是flickr.photos.search，希望得到的结果格式是JSON。

第一次调用get()方法得到的结果存储在变量resp中，然后在这个变量上调用json()方法，将其转换成JSON格式。然后在一个循环中读取['photos']['photo']中的JSON数据。得到的信息保存在photo_collection列表中。在这个列表中，每张照片的信息由一个字典表示。字典的键从前面的JSON格式响应中提取，照片的信息从另一个GET请求中获取。

注意，若想获取照片的评论，要再发起一个GET请求，从JSON格式响应中的['comments']['comment']元素获取。最后，把这些评论插入一个列表中，再添加到照片对应的字典里。

在__main__块中，我们从photo_collection列表中读取各字典，打印各张照片的一些有用信息。

8.6　找出亚马逊S3 Web服务支持的SOAP方法

如果你需要与实现了简单对象访问协议（SOAP）的Web服务交互，可以参考这个攻略。

8.6.1　准备工作

在这个攻略中我们可以使用第三方库SOAPpy，执行下面的命令即可安装这个库：

$ pip install SOAPpy

8.6.2　实战演练

我们要创建一个代理对象，在调用服务器上的方法之前，先找出支持哪些方法。

在这个攻略中，我们要和亚马逊S3存储服务交互。我们已经有了Web服务API的测试URL。你需要有API密钥才能执行这个简单的任务。

代码清单8-5是搜索亚马逊S3 Web服务支持的SOAP方法所需的代码，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 8
# This program requires Python 2.7 or any later version

import SOAPpy

TEST_URL = 'http://s3.amazonaws.com/ec2-downloads/2009-04-04.ec2.wsdl'

def list_soap_methods(url):
    proxy = SOAPpy.WSDL.Proxy(url)
    print '%d methods in WSDL:' % len(proxy.methods) + '\n'
    for key in proxy.methods.keys():
        print "Key Name: %s" %key
        print "Key Details:"
        for k,v in proxy.methods[key].__dict__.iteritems():
            print "%s ==> %s" %(k,v)
        break

if __name__ == '__mani__':
    list_soap_methods(TEST_URL)

运行这个脚本后，会打印出支持“Web服务定义语言”（Web Services Definition Language，简称WSDL）的可用方法总数，以及随意一个方法的详情，如下所示：

$ python 8_5_search_amazonaws_with_SOAP.py
/home/faruq/env/lib/python2.7/site-packages/wstools/XMLSchema.py:1280:
UserWarning: annotation is ignored
  warnings.warn('annotation is ignored')
43 methods in WSDL:

Key Name: ReleaseAddress
Key Details:
    encodingStyle ==> None
    style ==> document
    methodName ==> ReleaseAddress
    retval ==> None
    soapAction ==> ReleaseAddress
    namespace ==> None
    use ==> literal
    location ==> https://ec2.amazonaws.com/
    inparams ==> [<wstools.WSDLTools.ParameterInfo instance at 0x8fb9d0c>]
    outheaders ==> []
    inheaders ==> []
    transport ==> http://schemas.xmlsoap.org/soap/http
    outparams ==> [<wstools.WSDLTools.ParameterInfo instance at 0x8fb9d2c>]

8.6.3　原理分析

在这个脚本中，定义了一个名为list_soap_methods()的方法，其参数是一个URL。然后调用SOAPpy模块中的WSDL.Proxy()方法创建一个SOAP代理对象。可用的SOAP方法可通过这个代理对象的methods属性获取。

然后遍历代理对象methods属性中的键，获取各方法对应的键名。在for循环中打印出某个具体SOAP方法的详情，包括键名和键的详情。

8.7　使用谷歌搜索定制信息

对很多人来说，每天都要使用谷歌搜索信息。我们来试试使用谷歌搜索一些信息。

8.7.1　准备工作

这个攻略要使用一个第三方Python库requests，可以使用pip安装，如下面的命令所示：

$ pip install requests

8.7.2　实战演练

谷歌的搜索API很复杂，你要注册一个账户，再按照指定的方式获取API密钥。简单起见，我们使用谷歌以前的简单异步JavaScript（AJAX）API，搜索Python相关的图书信息。

代码清单8-6是使用谷歌搜索定制信息所需的代码，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 8
# This program requires Python 2.7 or any later version.
# It may run on any other version with/without modifications.
import argparse
import json
import urllib
import requests

BASE_URL = 'http://ajax.googleapis.com/ajax/services/search/web?v=1.0'

def get_search_url(query):
    return "%s&%s" %(BASE_URL, query)

def search_info(tag):
    query = urllib.urlencode({'q': tag})
    url = get_search_url(query)
    response = requests.get(url)
    results = response.json()

    data = results['responseData']
    print 'Found total results: %s' % data['cursor']['estimatedResultCount']
    hits = data['results']
    print 'Found top %d hits:' % len(hits)
    for h in hits:
        print ' ', h['url']
    print 'More results available from %s' % data['cursor']['moreResultsUrl']

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Search info from Google')
    parser.add_argument('--tag', action="store", dest="tag", default='Python books')
    # parse arguments
    given_args = parser.parse_args()
    search_info(given_args.tag)

运行这个脚本时，如果在--tag参数中指定了搜索关键字，它会在谷歌中搜索，并打印出结果总数和点击量最多的四个网址，如下所示：

$ python 8_6_search_products_from_Google.py
Found total results: 12300000
Found top 4 hits:
  https://wiki.python.org/moin/PythonBooks
  http://www.amazon.com/Python-Languages-Tools-Programming-Books/b%3Fie%3DUTF8%26node%3D285856
  http://pythonbooks.revolunet.com/
  http://readwrite.com/2011/03/25/python-is-an-increasingly-popu
More results available from
http://www.google.com/search?oe=utf8&ie=utf8&source=uds&start=0&hl=en&q=Python+books

8.7.3　原理分析

在这个攻略中，我们定义了一个简短的函数get_search_url()，使用BASE_URL常量和查询字符串组成搜索URL。

执行搜索的函数search_info()接收一个参数，即搜索关键字，然后构建查询字符串。requests库的作用是提供get()方法，得到的响应被转换成JSON格式。

我们从JSON格式数据的responseData键中提取搜索结果。预期的结果和点击量从相应的键中获取。然后把点击量排在前四位的URL打印出来。

8.8　通过商品搜索API在亚马逊中搜索图书

如果你想在亚马逊中搜索商品，并把它们添加到自己的网站或应用中，可以参考这个攻略。我们要介绍如何在亚马逊中搜索图书。

8.8.1　准备工作

这个攻略要用到一个第三方Python库bottlenose。这个库可使用pip安装，如下面的命令所示：

$ pip install  bottlenose

首先，要把亚马逊账户的访问密钥、私钥和联盟ID保存在文件local_settings.py中。本书附带的源码提供了一个示例设置文件。你也可以修改下面这个脚本，把设置写进去。

8.8.2　实战演练

我们可以使用bottlenose库实现亚马逊商品搜索API。

代码清单8-7是使用商品搜索API在亚马逊中搜索图书所需的代码，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 8
# This program requires Python 2.7 or any later version

import argparse
import bottlenose
from xml.dom import minidom as xml

try:
    from local_settings import amazon_account
except ImportError:
    pass

ACCESS_KEY = amazon_account['access_key']
SECRET_KEY = amazon_account['secret_key']
AFFILIATE_ID = amazon_account['affiliate_id']

def search_for_books(tag, index):
    """Search Amazon for Books """
    amazon = bottlenose.Amazon(ACCESS_KEY, SECRET_KEY, AFFILIATE_ID)
    results = amazon.ItemSearch(
                SearchIndex = index,
                Sort = "relevancerank",
                Keywords = tag
                )
    parsed_result = xml.parseString(results)

    all_items = []
    attrs = ['Title','Author', 'URL']

    for item in parsed_result.getElementsByTagName('Item'):
        parse_item = {}

        for attr in attrs:
            parse_item[attr] = ""
            try:
                parse_item[attr] = item.getElementsByTagName(attr)[0].childNodes[0].data
            except:
                pass
        all_items.append(parse_item)
    return all_items

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Search info from Amazon')
    parser.add_argument('--tag', action="store", dest="tag", default='Python')
    parser.add_argument('--index', action="store", dest="index", default='Books')
    # parse arguments
    given_args = parser.parse_args()
    books = search_for_books(given_args.tag, given_args.index)

    for book in books:
        for k,v in book.iteritems():
            print "%s: %s" %(k,v)
        print "-" * 80

运行这个脚本时如果提供了搜索关键字和目录，会看到一些类似下面的输出：

$ python 8_7_search_amazon_for_books.py --tag=Python --index=Books
URL: http://www.amazon.com/Python-In-Day-Basics-Coding/dp/tech-data/1
490475575%3FSubscriptionId%3DAKIAIPPW3IK76PBRLWBA%26tag%3D7052-6929-
7878%26linkCode%3Dxm2%26camp%3D2025%26creative%3D386001%26creative-
ASIN%3D1490475575
Author: Richard Wagstaff
Title: Python In A Day: Learn The Basics, Learn It Quick, Start Coding
Fast (In A Day Books) (Volume 1)
-------------------------------------------------------------------------
-------
URL: http://www.amazon.com/Learning-Python-Mark-Lutz/dp/tech-data/1449355
730%3FSubscriptionId%3DAKIAIPPW3IK76PBRLWBA%26tag%3D7052-6929-7878%26link
Code%3Dxm2%26camp%3D2025%26creative%3D386001%26creativeASIN%3D1449355730
Author: Mark Lutz
Title: Learning Python
-------------------------------------------------------------------------
-------
URL: http://www.amazon.com/Python-Programming-Introduction-Computer-
Science/dp/tech-data/1590282418%3FSubscriptionId%3DAKIAIPPW3IK76PBRLWBA%2
6tag%3D7052-6929-7878%26linkCode%3Dxm2%26camp%3D2025%26creative%3D386001%
26creativeASIN%3D1590282418
Author: John Zelle
Title: Python Programming: An Introduction to Computer Science 2nd
Edition
---------------------------------------------------------------------
-----------

8.8.3　原理分析

这个攻略使用第三方库bottlenose中的Amazon类创建了一个对象，使用商品搜索API在亚马逊中进行搜索。这些操作在顶层函数search_for_books()中完成。在这个对象上调用ItemSearch()方法时，分别把search_for_books()函数的参数传给SearchIndex和Keywords键。搜索结果使用relevancerank方式排序。

搜索结果使用xml模块中的minidom接口处理，这个接口提供了一个有用的parseString()方法，得到的结果是解析后的树状数据结构。在这个数据结构上调用getElementsByTagName()方法能获取全部搜索结果。然后遍历搜索结果，把属性存入parse_item字典中。最后，把所有解析后的搜索结果都保存到all_items列表中，返回给用户。

第 9 章　网络监控和安全性

本章攻略：

嗅探网络数据包

使用pcap转储器把数据包保存为pcap格式

在HTTP数据包中添加额外的首部

扫描远程主机的端口

自定义数据包的IP地址

读取保存的pcap文件以重放流量

扫描数据包的广播

9.1　简介

本章介绍一些有趣的Python攻略，用于监控网络安全和漏洞扫描。我们先使用pcap库嗅探数据包。然后使用Scapy，这是一个瑞士军刀类型的库，可以完成很多相似的任务。本章介绍了一些使用Scapy完成的常见任务，例如把数据包保存为pcap格式、添加额外的首部，以及修改数据包的IP地址。

本章还介绍了一些网络入侵检测相关的高级任务，例如使用保存的pcap文件重放流量和广播扫描。

9.2　嗅探网络数据包

如果你对嗅探本地网络中的数据包感兴趣，可以参考这个攻略。记住，你可能无法嗅探发往你的设备之外的数据包，因为好的网络交换机只会转发指定给你的设备的流量。

9.2.1　准备工作

你需要安装pylibpcap库（0.6.4或以上版本）才能使用这个攻略。这个库托管在SourceForge上，地址为http://sourceforge.net/projects/pylibpcap/。

你还需要安装construct库，可以使用pip或easy_install从PyPI上安装，如下面的命令所示：

$ easy_install construct

9.2.2　实战演练

我们可以使用命令行参数指定要嗅探的网络接口名和TCP端口号等。

代码清单9-1是嗅探网络数据包所需的代码，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 9
# This program is optimized for Python 2.6.
# It may run on any other version with/without modifications.

import argparse
import pcap
from construct.protocols.ipstack import ip_stack

def print_packet(pktlen, data, timestamp):
    """ Callback for priniting the packet payload"""
    if not data:
        return

    stack = ip_stack.parse(data)
    payload = stack.next.next.next
    print payload

def main():
    # setup commandline arguments
    parser = argparse.ArgumentParser(description='Packet Sniffer')
    parser.add_argument('--iface', action="store", dest="iface", default='eth0')
    parser.add_argument('--port', action="store", dest="port", default=80, type=int)
    # parse arguments
    given_args = parser.parse_args()
    iface, port =  given_args.iface, given_args.port
    # start sniffing
    pc = pcap.pcapObject()
    pc.open_live(iface, 1600, 0, 100)
    pc.setfilter('dst port %d' %port, 0, 0)

    print 'Press CTRL+C to end capture'
    try:
        while True:
            pc.dispatch(1, print_packet)
    except KeyboardInterrupt:
        print 'Packet statistics: %d packets received, %d packets dropped, %d packets dropped by the interface' % pc.stats()

if __name__ == '__main__':
    main()

运行这个脚本时，如果传入的命令行参数是--iface=eth0和--port=80，这个脚本会嗅探网页浏览器发出的所有HTTP数据包。运行这个脚本后，如果在浏览器中访问http://www.google.com，会看到如下所示的原始数据包：

python 9_1_packet_sniffer.py --iface=eth0 --port=80
Press CTRL+C to end capture
''
0000   47 45 54 20 2f 20 48 54 54 50 2f 31 2e 31 0d 0a    GET / HTTP/1.1..
0010   48 6f 73 74 3a 20 77 77 77 2e 67 6f 6f 67 6c 65    Host: www.google
0020   2e 63 6f 6d 0d 0a 43 6f 6e 6e 65 63 74 69 6f 6e    .com..Connection
0030   3a 20 6b 65 65 70 2d 61 6c 69 76 65 0d 0a 41 63    : keep-alive..Ac
0040   63 65 70 74 3a 20 74 65 78 74 2f 68 74 6d 6c 2c    cept: text/html,
0050   61 70 70 6c 69 63 61 74 69 6f 6e 2f 78 68 74 6d    application/xhtm
0060   6c 2b 78 6d 6c 2c 61 70 70 6c 69 63 61 74 69 6f    l+xml,applicatio
0070   6e 2f 78 6d 6c 3b 71 3d 30 2e 39 2c 2a 2f 2a 3b    n/xml;q=0.9,*/*;
0080   71 3d 30 2e 38 0d 0a 55 73 65 72 2d 41 67 65       q=0.8..User-Agen
0090   74 3a 20 4d 6f 7a 69 6c 6c 61 2f 35 2e 30 20 28    t: Mozilla/5.0 (
00A0   58 31 31 3b 20 4c 69 6e 75 78 20 69 36 38 36 29    X11; Linux i686)
00B0   20 41 70 70 6c 65 57 65 62 4b 69 74 2f 35 33 37     AppleWebKit/537
00C0   2e 33 31 20 28 4b 48 54 4d 4c 2c 20 6c 69 6b 65    .31 (KHTML, like
00D0   20 47 65 63 6b 6f 29 20 43 68 72 6f 6d 65 2f 32     Gecko) Chrome/2
00E0   36 2e 30 2e 31 34 31 30 2e 34 33 20 53 61 66 61    6.0.1410.43 Safa
00F0   72 69 2f 35 33 37 2e 33 31 0d 0a 58 2d 43 68 72    ri/537.31..X-Chr
0100   6f 6d 65 2d 56 61 72 69 61 74 69 6f 6e 73 3a 20    ome-Variations:
0110   43 50 71 31 79 51 45 49 6b 62 62 4a 41 51 69 59    CPq1yQEIkbbJAQiY
0120   74 73 6b 42 43 4b 4f 32 79 51 45 49 70 37 62 4a    tskBCKO2yQEIp7bJ
0130   41 51 69 70 74 73 6b 42 43 4c 65 32 79 51 45 49    AQiptskBCLe2yQEI
0140   2b 6f 50 4b 41 51 3d 3d 0d 0a 44 4e 54 3a 20 31    +oPKAQ==..DNT: 1
0150   0d 0a 41 63 63 65 70 74 2d 45 6e 63 6f 64 69 6e    ..Accept-Encodin
0160   67 3a 20 67 7a 69 70 2c 64 65 66 6c 61 74 65 2c    g: gzip,deflate,
0170   73 64 63 68 0d 0a 41 63 63 65 70 74 2d 4c 61 6e    sdch..Accept-Lan
0180   67 75 61 67 65 3a 20 65 6e 2d 47 42 2c 65 6e 2d    guage: en-GB,en-
0190   55 53 3b 71 3d 30 2e 38 2c 65 6e 3b 71 3d 30 2e    US;q=0.8,en;q=0.
01A0   36 0d 0a 41 63 63 65 70 74 2d 43 68 61 72 73 65    6..Accept-Charse
01B0   74 3a 20 49 53 4f 2d 38 38 35 39 2d 31 2c 75 74    t: ISO-8859-1,ut
01C0   66 2d 38 3b 71 3d 30 2e 37 2c 2a 3b 71 3d 30 2e    f-8;q=0.7,*;q=0.
01D0   33 0d 0a 43 6f 6f 6b 69 65 3a 20 50 52 45 46 3d    3..Cookie: PREF=

....

^CPacket statistics: 17 packets received, 0 packets dropped, 0
packets dropped by the interface

9.2.3　原理分析

这个攻略依赖于pcap库中的pcapObject类创建嗅探器实例。在main()方法中创建了一个pcapObject类的实例，然后使用setfilter()方法设置了一个过滤器，只捕获HTTP数据包。最后，调用dispatch()方法开始嗅探。嗅探到的数据包发给print_packet()函数做进一步处理。

在print_packet()函数中，如果数据包中有数据，就使用construct库中的ip_stack.parse()方法把数据提取出来。对数据做低层处理时，construct库能提供很大的帮助。

9.3　使用pcap转储器把数据包保存为pcap格式

pcap（packet capture，数据包捕获）是保存网络数据常用的一种文件格式。关于pcap格式的详细介绍，请访问http://wiki.wireshark.org/Development/LibpcapFileFormat。

如果你想把捕获的网络数据包保存到一个文件中，以便做进一步处理，这个攻略为你提供了一个可用的示例。

9.3.1　实战演练

在这个攻略中，我们使用Scapy库嗅探数据包，然后将其写入一个文件。Scapy中的所有实用函数和定义可以使用通配符导入，如下面的代码所示：

from scapy.all import *

这么做只是为了演示，不推荐在生产环境的代码中使用。

Scapy库中的sniff()函数接收的参数是回调函数的名称。我们来定义一个回调函数，把数据包写入文件。

代码清单9-2是使用pcap转储器把数据包保存为pcap格式所需的代码，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 9
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import os
from scapy.all import *

pkts = []
count = 0
pcapnum = 0

def write_cap(x):
    global pkts
    global count
    global pcapnum
    pkts.append(x)
    count += 1
    if count == 3:
        pcapnum += 1
        pname = "pcap%d.pcap" % pcapnum
        wrpcap(pname, pkts)
        pkts = []
        count = 0

def test_dump_file():
    print "Testing the dump file..."
    dump_file = "./pcap1.pcap"
    if os.path.exists(dump_file):
        print "dump fie %s found." %dump_file
        pkts = sniff(offline=dump_file)
        count = 0
        while (count <=2):
            print "----Dumping pkt:%s----" %count
            print hexdump(pkts[count])
            count += 1

    else:
        print "dump fie %s not found." %dump_file

if __name__ == '__main__':
    print "Started packet capturing and dumping... Press CTRL+C to exit"
    sniff(prn=write_cap)
    test_dump_file()

运行这个脚本后，会看到类似下面的输出：

# python 9_2_save_packets_in_pcap_format.py
^CStarted packet capturing and dumping... Press CTRL+C to exit
Testing the dump file...
dump fie ./pcap1.pcap found.
----Dumping pkt:0----
0000   08 00 27 95 0D 1A 52 54  00 12 35 02 08 00 45 00    ..'...RT..5...E.
0010   00 DB E2 6D 00 00 40 06  7C 9E 6C A0 A2 62 0A 00    ...m..@.|.l..b..
0020   02 0F 00 50 99 55 97 98  2C 84 CE 45 9B 6C 50 18    ...P.U..,..E.lP.
0030   FF FF 53 E0 00 00 48 54  54 50 2F 31 2E 31 20 32    ..S...HTTP/1.1 2
0040   30 30 20 4F 4B 0D 0A 58  2D 44 42 2D 54 69 6D 65    00 OK..X-DB-Time
0050   6F 75 74 3A 20 31 32 30  0D 0A 50 72 61 67 6D 61    out: 120..Pragma
0060   3A 20 6E 6F 2D 63 61 63  68 65 0D 0A 43 61 63 68    : no-cache..Cach
0070   65 2D 43 6F 6E 74 72 6F  6C 3A 20 6E 6F 2D 63 61    e-Control: no-ca
0080   63 68 65 0D 0A 43 6F 6E  74 65 6E 74 2D 54 79 70    che..Content-Typ
0090   65 3A 20 74 65 78 74 2F  70 6C 61 69 6E 0D 0A 44    e: text/plain..D
00a0   61 74 65 3A 20 53 75 6E  2C 20 31 35 20 53 65 70    ate: Sun, 15 Sep
00b0   20 32 30 31 33 20 31 35  3A 32 32 3A 33 36 20 47     2013 15:22:36G
00c0   4D 54 0D 0A 43 6F 6E 74  65 6E 74 2D 4C 65 6E 67    MT..Content-Leng
00d0   74 68 3A 20 31 35 0D 0A  0D 0A 7B 22 72 65 74 22    th: 15....{"ret"
00e0   3A 20 22 70 75 6E 74 22  7D                         : "punt"} None
----Dumping pkt:1----
0000   52 54 00 12 35 02 08 00  27 95 0D 1A 08 00 45 00    RT..5...'.....E.
0010   01 D2 1F 25 40 00 40 06  FE EF 0A 00 02 0F 6C A0    ...%@.@.......l.
0020   A2 62 99 55 00 50 CE 45  9B 6C 97 98 2D 37 50 18    .b.U.P.E.l..-7P.
0030   F9 28 1C D6 00 00 47 45  54 20 2F 73 75 62 73 63    .(....GET /subsc
0040   72 69 62 65 3F 68 6F 73  74 5F 69 6E 74 3D 35 31    ribe?host_int=51
0050   30 35 36 34 37 34 36 26  6E 73 5F 6D 61 70 3D 31    0564746&ns_map=1
0060   36 30 36 39 36 39 39 34  5F 33 30 30 38 30 38 34    60696994_3008084
0070   30 37 37 31 34 2C 31 30  31 39 34 36 31 31 5F 31    07714,10194611_1
0080   31 30 35 33 30 39 38 34  33 38 32 30 32 31 31 2C    105309843820211,
0090   31 34 36 34 32 38 30 35  32 5F 33 32 39 34 33 38    146428052_329438
00a0   36 33 34 34 30 38 34 2C  31 31 36 30 31 35 33 31    6344084,11601531
00b0   5F 32 37 39 31 38 34 34  37 35 37 37 31 2C 31 30    _279184475771,10
00c0   31 39 34 38 32 38 5F 33  30 30 37 34 39 36 35 39    194828_300749659
00d0   30 30 2C 33 33 30 39 39  31 39 38 32 5F 38 31 39    00,330991982_819
00e0   33 35 33 37 30 36 30 36  2C 31 36 33 32 37 38 35    35370606,1632785
00f0   35 5F 31 32 39 30 31 32  32 39 37 34 33 26 75 73    5_12901229743&us
0100   65 72 5F 69 64 3D 36 35  32 30 33 37 32 26 6E 69    er_id=6520372&ni
0110   64 3D 32 26 74 73 3D 31  33 37 39 32 35 38 35 36    d=2&ts=137925856
0120   31 20 48 54 54 50 2F 31  2E 31 0D 0A 48 6F 73 74    1 HTTP/1.1..Host
0130   3A 20 6E 6F 74 69 66 79  33 2E 64 72 6F 70 62 6F    : notify3.dropbo
0140   78 2E 63 6F 6D 0D 0A 41  63 63 65 70 74 2D 45 6E    x.com..Accept-En
0150   63 6F 64 69 6E 67 3A 20  69 64 65 6E 74 69 74 79    coding:identity
0160   0D 0A 43 6F 6E 6E 65 63  74 69 6F 6E 3A 20 6B 65    ..Connection:ke
0170   65 70 2D 61 6C 69 76 65  0D 0A 58 2D 44 72 6F 70    ep-alive..X-Drop
0180   62 6F 78 2D 4C 6F 63 61  6C 65 3A 20 65 6E 5F 55    box-Locale:en_U
0190   53 0D 0A 55 73 65 72 2D  41 67 65 6E 74 3A 20 44    S..User-Agent:D
01a0   72 6F 70 62 6F 78 44 65  73 6B 74 6F 70 43 6C 69    ropboxDesktopCli
01b0   65 6E 74 2F 32 2E 30 2E  32 32 20 28 4C 69 6E 75    ent/2.0.22(Linu
01c0   78 3B 20 32 2E 36 2E 33  32 2D 35 2D 36 38 36 3B    x; 2.6.32-5-686;
01d0   20 69 33 32 3B 20 65 6E  5F 55 53 29 0D 0A 0D 0A    i32; en_US)....None
----Dumping pkt:2----
0000   08 00 27 95 0D 1A 52 54  00 12 35 02 08 00 45 00    ..'...RT..5...E.
0010   00 28 E2 6E 00 00 40 06  7D 50 6C A0 A2 62 0A 00    .(.n..@.}Pl..b..
0020   02 0F 00 50 99 55 97 98  2D 37 CE 45 9D 16 50 10   ...P.U..-7.E..P.
0030   FF FF CA F1 00 00 00 00  00 00 00 00               ............None

9.3.2　原理分析

这个攻略使用Scapy库中的实用函数sniff()和wrpacp()捕获所有网络数据包，然后将其转储到一个文件中。使用sniff()函数捕获数据包后，再调用write_cap()函数处理数据包。处理各数据包时用到了几个全局变量。例如，数据包保存在pkts列表中，还用到了数据包数量和变量数量。数量等于3时，把pkts列表转储到pcap1.pcap文件中，然后把count变量设为零，以便继续捕获后面三个数据包，再将其转储到pcap2.pcap文件中，以此类推。

在test_dump_file()函数中，我们假设在工作目录中已经存在首个转储文件pcap1.pcap。这次调用sniff()函数时指定了offline参数，从文件而不是网络中捕获数据包。然后使用hexdump()函数解码各数据包，再把数据包中的内容打印在屏幕上。

9.4　在HTTP数据包中添加额外的首部

有时，处理应用时要添加一个包含自定义信息的HTTP首部。例如，添加授权首部可以在数据包捕获代码中实现HTTP基本认证功能。

9.4.1　实战演练

我们要使用Scapy库中的sniff()函数嗅探数据包，还要定义一个回调函数modify_packet_header()，在指定的数据包中添加额外的首部。

代码清单9-3是在HTTP数据包中添加额外的首部所需的代码，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 9
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

from scapy.all import *

def modify_packet_header(pkt):
    """ Parse the header and add an extra header"""
    if pkt.haslayer(TCP) and pkt.getlayer(TCP).dport == 80 and pkt.haslayer(Raw):
        hdr = pkt[TCP].payload.__dict__
        extra_item = {'Extra Header' : ' extra value'}
        hdr.update(extra_item)
        send_hdr = '\r\n'.join(hdr)
        pkt[TCP].payload = send_hdr

        pkt.show()

        del pkt[IP].chksum
        send(pkt)

if __name__ == '__main__':
    # start sniffing
    sniff(filter="tcp and ( port 80 )", prn=modify_packet_header)

运行这个脚本后，会显示一个捕获到的数据包，打印出修改后的版本，再把它发回网络，如下面的输出所示。这个操作可以使用其他捕获工具验证，例如tcpdump和wireshark。

$ python 9_3_add_extra_http_header_in_sniffed_packet.py

###[ Ethernet ]###
  dst       = 52:54:00:12:35:02
  src       = 08:00:27:95:0d:1a
  type      = 0x800
###[ IP ]###
     version   = 4L
     ihl       = 5L
     tos       = 0x0
     len       = 525
     id        = 13419
     flags     = DF
     frag      = 0L
     ttl       = 64
     proto     = tcp
     chksum    = 0x171
     src       = 10.0.2.15
     dst       = 82.94.164.162
     \options  \
###[ TCP ]###
     sport     = 49273
     dport     = www
     seq       = 107715690
     ack       = 216121024
     dataofs   = 5L
     reserved  = 0L
     flags     = PA
     window    = 6432
     chksum    = 0x50f
     urgptr    = 0
     options   = []
###[ Raw ]###
     load      = 'Extra Header\r\nsent_time\r\nfields\r\
naliastypes\r\npost_transforms\r\nunderlayer\r\nfieldtype\r\ntime\r\
ninitialized\r\noverloaded_fields\r\npacketfields\r\npayload\r\ndefault_
fields'
.
Sent 1 packets.

9.4.2　原理分析

首先，我们使用Scapy库中的sniff()函数嗅探数据包，把modify_packet_header()函数指定为每个数据包的回调函数。所有TCP数据包都有TCP和原始层，发往80端口的数据包是我们要修改的目标。所以，我们从数据包中把当前的首部提取了出来。

然后把额外的首部添加到现有的首部字典尾部。数据包使用show()方法打印在屏幕上，为了避免正确性检查失败，我们把数据包的校验和删掉了。最后，再把数据包发回网络。

9.5　扫描远程主机的端口

如果你尝试使用某个端口连接远程主机，有时会收到一个消息，说“Connection is refused”（拒绝连接）。大多数时候，收到这个消息的原因是远程主机中的服务器停止运行了。遇到这种情况，你可以查看端口是开启还是处在监听状态中。你可以扫描多个端口，找出设备中的可用服务。

9.5.1　实战演练

使用Python标准库socket，我们可以完成这个端口扫描任务。我们要从命令行中接收三个参数：目标主机、起始端口号和终止端口号。

代码清单9-4是扫描远程主机的端口所需的代码，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 9
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import argparse
import socket
import sys

def scan_ports(host, start_port, end_port):
    """ Scan remote hosts """
    #Create socket
    try:
        sock = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
    except socket.error,err_msg:
        print 'Socket creation failed. Error code: '+ str(err_msg[0]) + ' Error mesage: ' + err_msg[1]
        sys.exit()

    #Get IP of remote host
    try:
        remote_ip = socket.gethostbyname(host)
    except socket.error,error_msg:
        print error_msg
        sys.exit()

    #Scan ports
    end_port += 1
    for port in range(start_port,end_port):
        try:
            sock.connect((remote_ip,port))
            print 'Port ' + str(port) + ' is open'
            sock.close()
            sock = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
        except socket.error:
            pass # skip various socket errors

if __name__ == '__main__':
    # setup commandline arguments
    parser = argparse.ArgumentParser(description='Remote Port Scanner')
    parser.add_argument('--host', action="store", dest="host", default='localhost')
    parser.add_argument('--start-port', action="store", dest="start_port", default=1, type=int)
    parser.add_argument('--end-port', action="store", dest="end_port", default=100, type=int)
    # parse arguments
    given_args = parser.parse_args()
    host, start_port, end_port =  given_args.host, given_args.start_port, given_args.end_port
    scan_ports(host, start_port, end_port)

如果运行这个脚本扫描本地设备的1~100号端口，查找打开的端口，会看到类似下面的输出：

# python 9_4_scan_port_of_a_remote_host.py --host=localhost --start-port=1 --end-port=100
Port 21 is open
Port 22 is open
Port 23 is open
Port 25 is open
Port 80 is open

9.5.2　原理分析

这个脚本演示了如何使用Python标准库socket扫描设备，找出打开的端口。scan_ports()函数接收三个参数：主机名、起始端口和终止端口。然后分三步扫描指定范围内的端口。

首先，使用socket()函数创建一个TCP套接字。

成功创建套接字后，使用gethostbyname()函数找出远程主机的IP地址。

找到主机的IP地址后，使用connect()函数尝试连接到这个IP。如果连接成功，就说明端口是打开的。然后，使用close()函数关闭端口，重复第一步开始继续扫描下一个端口。

9.6　自定义数据包的IP地址

如果创建了一个网络数据包，想自定义源IP和目标IP或者端口，可以参考这个攻略。

9.6.1　实战演练

我们可以从命令行中获取所需的全部参数，包括网络接口名、协议名、源IP、源端口、目标IP、目标端口以及可选的TCP旗标。

我们可以使用Scapy库创建一个自定义TCP或UDP数据包，再将其发送到网络中。

代码清单9-5是自定义数据包IP地址所需的代码，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 9
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import argparse
import sys
import re
from random import randint

from scapy.all import IP,TCP,UDP,conf,send

def send_packet(protocol=None, src_ip=None, src_port=None, flags=None, dst_ip=None, dst_port=None, iface=None):
    """Modify and send an IP packet."""
    if protocol == 'tcp':
        packet = IP(src=src_ip, dst=dst_ip)/TCP(flags=flags, sport=src_port, dport=dst_port)
    elif protocol == 'udp':
        if flags: raise Exception(" Flags are not supported for udp")
        packet = IP(src=src_ip, dst=dst_ip)/UDP(sport=src_port, dport=dst_port)
    else:
        raise Exception("Unknown protocol %s" % protocol)

    send(packet, iface=iface)

if __name__ == '__main__':
    # setup commandline arguments
    parser = argparse.ArgumentParser(description='Packet Modifier')
    parser.add_argument('--iface', action="store", dest="iface", default='eth0')
    parser.add_argument('--protocol', action="store", dest="protocol", default='tcp')
    parser.add_argument('--src-ip', action="store", dest="src_ip", default='1.1.1.1')
    parser.add_argument('--src-port', action="store", dest="src_port", default=randint(0, 65535))
    parser.add_argument('--dst-ip', action="store", dest="dst_ip", default='192.168.1.51')
    parser.add_argument('--dst-port', action="store", dest="dst_port", default=randint(0, 65535))
    parser.add_argument('--flags', action="store", dest="flags", default=None)
    # parse arguments
    given_args = parser.parse_args()
    iface, protocol, src_ip,  src_port, dst_ip, dst_port, flags =  given_args.iface, given_args.protocol, given_args.src_ip,\
        given_args.src_port, given_args.dst_ip, given_args.dst_port, given_args.flags
    send_packet(protocol, src_ip, src_port, flags, dst_ip, dst_port, iface)

若想运行这个脚本，请输入下面的命令：

tcpdump src 192.168.1.66
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), capture size 65535 bytes
^C18:37:34.309992 IP 192.168.1.66.60698 > 192.168.1.51.666: Flags [S],
seq 0, win 8192, length 0

1 packets captured
1 packets received by filter
0 packets dropped by kernel

$ sudo python 9_5_modify_ip_in_a_packet.py
WARNING: No route found for IPv6 destination :: (no default route?)
.
Sent 1 packets.

9.6.2　原理分析

这个脚本定义了send_packet()函数，使用Scapy库构建一个IP数据包。send_packet()函数的参数中包含源地址、源端口、目标地址、目标端口。根据指定的不同协议，例如TCP或UDP，这个函数会构建正确的数据包类型。如果是TCP数据包，可以指定flags参数；否则，抛出异常。

为了构建TCP数据包，Sacpy库提供了IP()/TCP()函数。类似地，若想创建UDP数据包，可以使用IP()/UDP()函数。

最后，使用send()函数发送修改后的数据包。

9.7　读取保存的pcap文件以重放流量

处理网络数据包时，你可能需要读取之前保存的pcap文件来重放流量。此时，你要读取pcap文件，在发送前修改源IP或目标IP地址。

9.7.1　实战演练

我们要使用Scapy读取之前保存的pcap文件。如果没有pcap文件，可以使用9.3节中的攻略创建。

然后，解析命令行参数，连同解析后的原始数据包一起传给send_packet()函数。

代码清单9-6是读取保存的pcap文件来重放流量所需的代码，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 9
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

import argparse
from scapy.all import *

def send_packet(recvd_pkt, src_ip, dst_ip, count):
    """ Send modified packets"""
    pkt_cnt = 0
    p_out = []

    for p in recvd_pkt:
        pkt_cnt += 1
        new_pkt = p.payload
        new_pkt[IP].dst = dst_ip
        new_pkt[IP].src = src_ip
        del new_pkt[IP].chksum
        p_out.append(new_pkt)
        if pkt_cnt % count == 0:
            send(PacketList(p_out))
            p_out = []

    # Send rest of packet
    send(PacketList(p_out))
    print "Total packets sent: %d" %pkt_cnt

if __name__ == '__main__':
    # setup commandline arguments
    parser = argparse.ArgumentParser(description='Packet Sniffer')
    parser.add_argument('--infile', action="store", dest="infile", default='pcap1.pcap')
    parser.add_argument('--src-ip', action="store", dest="src_ip", default='1.1.1.1')
    parser.add_argument('--dst-ip', action="store", dest="dst_ip", default='2.2.2.2')
    parser.add_argument('--count', action="store", dest="count", default=100, type=int)
    # parse arguments
    given_args = ga = parser.parse_args()
    global src_ip, dst_ip
    infile, src_ip, dst_ip, count =  ga.infile, ga.src_ip, ga.dst_ip, ga.count
    try:
        pkt_reader = PcapReader(infile)
        send_packet(pkt_reader, src_ip, dst_ip, count)
    except IOError:
        print "Failed reading file %s contents" % infile
        sys.exit(1)

运行这个脚本后，默认会读取保存的pcap1.pcap文件，在发送数据包之前分别把源IP地址和目标IP地址修改为1.1.1.1和2.2.2.2，如下面的输出所示。使用tcpdump可以看到这些数据包的传输过程。

# python 9_6_replay_traffic.py
...
Sent 3 packets.
Total packets sent 3
----
# tcpdump src 1.1.1.1
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), capture size 65535 bytes
^C18:44:13.186302 IP 1.1.1.1.www > ARennes-651-1-107-2.w2-
2.abo.wanadoo.fr.39253: Flags [P.], seq 2543332484:2543332663, ack
3460668268, win 65535, length 179
1 packets captured
3 packets received by filter
0 packets dropped by kernel

9.7.2　原理分析

这个攻略使用Scapy库中的PcapReader()函数从硬盘上读取保存的pcap1.pcap文件，返回一个数据包迭代器。如果提供了命令行参数，就解析这些参数。否则，使用默认值，如前面的输出所示。

命令行参数和数据包列表传给send_packet()函数。这个函数把新数据包保存在p_out列表中，并记录处理后的数据包。然后修改各数据包，更改源IP和目标IP。除此之外，还把checksum包删掉了，因为这个包是基于之前的IP地址生成的。

处理完一个数据包之后，立即将其发送到网络中。然后，一个接着一个地发送剩下的数据包。

9.8　扫描数据包的广播

如果你要找出网络的广播，可以参考这个攻略。我们以广播数据包为例，学习如何查找信息。

9.8.1　实战演练

我们可以使用Scapy库嗅探网络接口收到的数据包。捕获数据包之后，可以使用回调函数处理，从中获取有用的信息。

代码清单9-7是扫描数据包的广播所需的代码，如下所示：

#!/usr/bin/env python
# Python Network Programming Cookbook -- Chapter - 9
# This program is optimized for Python 2.7.
# It may run on any other version with/without modifications.

from scapy.all import *
import os
captured_data = dict()

END_PORT = 1000

def monitor_packet(pkt):
    if IP in pkt:
        if not captured_data.has_key(pkt[IP].src):
            captured_data[pkt[IP].src] = []

    if TCP in pkt:
        if pkt[TCP].sport <=  END_PORT:
            if not str(pkt[TCP].sport) in captured_data[pkt[IP].src]:
                captured_data[pkt[IP].src].append(str(pkt[TCP].sport))

    os.system('clear')
    ip_list = sorted(captured_data.keys())
    for key in ip_list:
        ports=', '.join(captured_data[key])
        if len (captured_data[key]) == 0:
            print '%s' % key
        else:
            print '%s (%s)' % (key, ports)

if __name__ == '__main__':
    sniff(prn=monitor_packet, store=0)

运行这个脚本后，会列出广播流量的源IP地址和端口。下面是一个输出示例，IP地址的第一位被替换掉了：

# python 9_7_broadcast_scanning.py
10.0.2.15
XXX.194.41.129 (80)
XXX.194.41.134 (80)
XXX.194.41.136 (443)
XXX.194.41.140 (80)
XXX.194.67.147 (80)
XXX.194.67.94 (443)
XXX.194.67.95 (80, 443)

9.8.2　原理分析

这个攻略使用Scapy库中的sniff()函数嗅探网络中的数据包。其中定义了一个回调函数monitor_packet()，对数据包做后处理。根据所用协议的不同，例如IP和TCP，分别使用不同的方式排序数据包，然后将其存入名为captured_data的字典中。

如果字典中没有某个IP，就新建一个元素。否则，更新这个IP的端口号。最后，打印这些IP地址，每行显示一个。

安道

人子人夫人父，机械工程师，翻译爱好者，偶尔写代码。

看完了

如果您对本书内容有疑问，可发邮件至contact@turingbook.com，会有编辑或作译者协助答疑。也可访问图灵社区，参与本书讨论。

如果是有关电子书的建议或问题，请联系专用客服邮箱：ebook@turingbook.com。

在这里可以找到我们：

微博 @图灵教育 : 好书、活动每日播报

微博 @图灵社区 : 电子书和好文章的消息

微博 @图灵新知 : 图灵教育的科普小组

微信 图灵访谈 : ituring_interview，讲述码农精彩人生

微信 图灵教育 : turingbooks 　　

091507240605ToBeReplacedWithUserId

版权信息

书名：Python项目开发实战（第2版）

作者：[日] 日本BePROUD股份有限公司

译者：支鹏浩

ISBN：978-7-115-43856-0

本书由北京图灵文化发展有限公司发行数字版。版权所有，侵权必究。

您购买的图灵电子书仅供您个人使用，未经授权，不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟，与我们共同保护知识产权。

如果购买者有侵权行为，我们可能对该用户实施包括但不限于关闭该帐号等维权措施，并可能追究法律责任。

091507240605ToBeReplacedWithUserId

版权声明

引言

极客 / 书虫常伴身边的公司

希望能不做不想做的事

希望学会好的方法并付诸实践

希望工作时有个好心情

谢辞

前言

本书涉及的内容

阅读本书前的准备

第 1 部分　Python 开发入门

第 1 章　Python 入门

1.1　安装 Python

1.1.1　安装 deb 包

1.1.2　安装第三方包

1.1.3　virtualenv 的使用方法

1.1.4　多版本 Python 的使用

1.2　安装 Mercurial

1.2.1　Mercurial 概述

1.2.2　安装 Mercurial

1.2.3　创建版本库

1.2.4　文件操作

1.3　编辑器与辅助开发工具

1.3.1　编辑器

1.3.2　开发辅助工具

1.4　小结

第 2 章　开发 Web 应用

2.1　了解Web 应用

2.1.1　Web 应用是什么

2.1.2　Web 应用与桌面应用的区别

2.1.3　Web 应用的机制

2.2　前置准备

2.2.1　关于 Flask

2.2.2　安装Flask

2.3　Web 应用的开发流程

2.4　明确要开发什么应用

2.4.1　留言板应用的需求

2.4.2　明确必备的功能

2.4.3　明确必备的页面

2.5　页面设计

2.5.1　确定成品页面的形式

2.5.2　编写 HTML 和 CSS

2.6　实现功能

2.6.1　保存留言数据

2.6.2　获取已保存的留言列表

2.6.3　用模板引擎显示页面

2.6.4　准备评论接收方的 URL

2.6.5　调整模板的输出

2.7　查看运行情况

第 3 章　Python 项目的结构与包的创建

3.1　Python 项目

3.2　环境与工具

3.2.1　用 virtualenv 搭建独立环境

3.2.2　用 pip 安装程序包

3.2.3　小结

3.3　文件结构与发布程序包

3.3.1　编写 setup.py

3.3.2　留言板的项目结构

3.3.3　setup.py 与 MANIFEST.in——设置程序包信息与捆绑的文件

3.3.4　setup.py——创建执行命令

3.3.5　python setup.py sdist——创建源码发布程序包

3.3.6　提交至版本库

3.3.7　README.rst——开发环境设置流程

3.3.8　变更依赖包

3.3.9　通过 requirements.txt 固定开发版本

3.3.10　python setup.py bdist_wheel——制作用于 wheel 发布的程序包

3.3.11　上传到 PyPI 并公开

3.3.12　小结

3.4　小结

第 2 部分　团队开发的周期

第 4 章　面向团队开发的工具

4.1　问题跟踪系统

4.1.1　Redmine

4.1.2　安装Redmine

4.1.3　Redmine 的设置

4.1.4　插件

4.2　版本控制系统

4.2.1　Mercurial 与 Redmine 的联动

4.2.2　用于生成版本库的插件

4.3　聊天系统

4.3.1　Slack

4.3.2　Slack 的特点

4.3.3　Slack 做不到的事

4.3.4　Slack 的注册

4.4　对团队开发有帮助的工具

4.4.1　Dropbox

4.4.2　Google Drive

4.5　小结

第 5 章　项目管理与审查

5.1　项目管理与问题的区分使用

5.1.1　项目管理的前置准备工作

5.1.2　创建问题

5.1.3　整理问题

5.1.4　分割问题

5.2　问题模板

5.2.1　安装插件

5.2.2　问题模板的使用方法

5.2.3　Global Issue Templates

5.2.4　问题模板示例

5.3　问题驱动开发

5.3.1　别急着敲代码，先建问题

5.3.2　创建与问题编号同名的分支

5.3.3　让发布与分支相对应

5.3.4　分支的合并

5.4　审查

5.4.1　为什么需要审查

5.4.2　审查委托：代码审查篇

把希望审查员确认的事项整理出来

5.4.3　审查委托：作业审查篇

5.4.4　实施审查：代码审查篇

5.4.5　实施审查：作业审查篇

5.5　小结

第 6 章　用 Mercurial 管理源码

6.1　Mercurial 版本库的管理与设置

6.1.1　服务器上的 Uinx 用户群设置

6.1.2　创建版本库

6.1.3　hgrc 的设置

6.1.4　使用设置好的版本库

6.1.5　使用 hgweb 建立简易中央版本库

6.2　灵活使用“钩子”

6.2.1　钩子功能的设置方法

6.2.2　尝试钩子脚本

6.2.3　钩子事件

6.2.4　钩子功能的执行时机

6.2.5　编写钩子脚本

6.3　分支的操作

6.4　关于合并

6.4.1　未发生冲突的合并

6.4.2　合并时发生冲突以及用文本编辑器解决冲突的方法

6.4.3　合并的类型与冲突

6.4.4　用 GUI 的合并工具进行合并

6.5　GUI 客户端

6.5.1　GUI 客户端的介绍

6.5.2　GUI 客户端的优点

6.5.3　GUI 客户端的缺点

6.6　考虑实际运用的 BePROUD Mercurial Workflow

6.6.1　概述

6.6.2　背景

6.6.3　版本库的结构

6.6.4　提交源码

6.6.5　提交设计

6.6.6　分支的合并

6.6.7　集成分支

6.7　小结

第 7 章　完备文档的基础

7.1　要记得给项目写文档

7.1.1　写文档时不想做的事

7.1.2　什么样的状态让人想写文档

7.2　Sphinx 的基础与安装

7.2.1　Sphinx 的安装

7.2.2　reStructuredText 入门

7.2.3　用 Sphinx 写结构化文档的流程

7.2.4　Sphinx 扩展

7.3　导入 Sphinx 可解决的问题与新出现的问题

7.3.1　由于是纯文本，所以能在平时用的编辑器上写文档

7.3.2　 信息与视图相分离，所以能集中精神编辑内容，不用顾虑装饰等外观问题

7.3.3　可根据一个源码输出 PDF 等多种格式

7.3.4　通过结构化，文档可分成几个文件来写

7.3.5　能用 Mercurial 等轻松实现版本管理

7.3.6　API 参考手册与程序的管理一体化

7.3.7　通过 Web 浏览器共享

7.3.8　导入 Sphinx 后仍存在的问题

7.4　文档集的创建与使用

7.4.1　什么是文档集

7.4.2　项目所需文档的一览表

7.4.3　面向项目组长、经理

7.4.4　面向设计者

7.4.5　面向开发者

7.4.6　面向客户

7.5　小结

第 8 章　模块分割设计与单元测试

8.1　模块分割设计

8.1.1　功能设计

8.1.2　构成 Web 应用的组件

8.1.3　组件设计

8.1.4　模块与程序包

8.2　测试

8.2.1　测试的种类

8.2.2　编写单元测试

8.2.3　从单元测试中剔除环境依赖

8.2.4　用 WebTest 做功能测试

8.3　通过测试改良设计

便于测试的设计

8.4　推进测试自动化

8.4.1　用 tox 自动生成执行测试的环境

8.4.2　可重复使用的测试环境

8.5　小结

第 9 章　Python 封装及其运用

9.1　使用程序包

9.1.1　程序包的版本指定

9.1.2　从非 PyPI 服务器安装程序包

9.1.3　程序包的发布格式

9.1.4　生成wheelhouse 的方法

9.1.5　从 wheelhouse 安装

9.2　巧用程序包

9.2.1　私密发布

9.2.2　巧用 requirements.txt

9.2.3　requirements.txt 层级化

9.2.4　为部署和 CI+tox 准备的 requiremests

9.2.5　通过 requirements.txt 指定库的版本

9.3　小结

第 10 章　用 Jenkins 持续集成

10.1　什么是持续集成

10.1.1　持续集成的简介

10.1.2　Jenkins 简介

10.2　Jenkins 的安装

10.2.1　安装 Jenkins 主体程序

10.2.2　本章将用到的 Jenkins 插件

10.3　执行测试代码

10.3.1　让 Jenkins 运行简单的测试代码

10.3.2　添加 Job

10.3.3　Job 的成功与失败

10.4　测试结果输出到报告

10.4.1　安装 pytest

10.4.2　调用 pytest 命令

10.4.3　根据 pytest 更改 Jenkins 的设置

10.5　显示覆盖率报告

10.5.1　安装 pytest-cov

10.5.2　从 pytest 获取覆盖率

10.5.3　读取覆盖率报告

10.6　执行 Django 的测试

10.6.1　安装 Python 模块

10.6.2　Django 的调整

10.6.3　示例代码

10.6.4　Jenkins 的调整

10.6.5　“构建后操作”选项卡的设置

10.7　通过 Jenkins 构建文档

10.7.1　安装 Sphinx

10.7.2　在 Jenkins 添加 Job

10.7.3　Sphinx 构建发出警告时令 Job 失败

10.7.4　查看成果

10.7.5　通过 Task Scanner Plugin 管理 TODO

10.7.6　Task Scanner Plugin 的设置示例

10.8　Jenkins 进阶技巧

10.8.1　好用的功能

10.8.2　进一步改善

10.9　小结

第 3 部分　服务公开

第 11 章　环境搭建与部署的自动化

11.1　确定所需环境的内容

11.1.1　网络结构

11.1.2　服务器搭建内容的结构化

11.1.3　用户的设置

11.1.4　选定程序包

11.1.5　中间件的设置

11.1.6　部署

11.2　用 Ansible 实现自动化作业

11.2.1　Ansible 简介

11.2.2　文件结构

11.2.3　执行 Ansible

11.2.4　与最初确定的结构相对应

11.2.5　将各步骤 Ansible 化

11.2.6　整理Ansible 的执行环境

11.3　小结

第 12 章　应用的性能改善

12.1　Web 应用的性能

12.1.1　Web 应用面对大量集中请求时会产生哪些问题

12.1.2　针对高负荷的对策

12.2　评估留言板应用的性能

12.2.1　什么是应用的性能

12.2.2　安装 ApacheBench

12.2.3　用 ApachBench 评估性能

12.3　gunicorn 简介

12.3.1　安装 gunicorn

12.3.2　在 gunicorn 上运行应用

12.4　nginx 简介

12.4.1　安装 nginx

12.4.2　检测nginx 的性能

12.5　在 nginx 和 gunicorn 上运行应用

12.5.1　gunicorn 的设置

12.5.2　nginx 的设置

12.5.3　评估 nginx+gunicorn 的性能

12.5.4　性能比较

12.6　小结

第 4 部分　加速开发的技巧

第 13 章　让测试为我们服务

13.1　认识现状：测试的客观环境

13.2　将测试导入开发各个阶段

13.2.1　文档的测试（审查）

13.2.2　测试设计的编写方法（输入与输出）

13.2.3　测试的实施与测试阶段的轮换（做什么，做多少）

13.3　小结：测试并不可怕

第 14 章　轻松使用 Django

14.1　Django 简介

14.1.1　Django 的安装

14.1.2　Django 的架构

14.1.3　Django 的文档

14.2　数据库的迁移

14.2.1　什么是数据库的迁移

14.2.2　Django 的迁移功能

14.3　fixture replacement

14.3.1　什么是测试配置器

14.3.2　几种不便使用默认配置器的情况

14.3.3　如何使用 factory_boy

14.3.4　消除“不便使用默认配置器的情况”

14.4　Django Debug Toolbar

Django Debug Toolbar 的简介

14.5　小结

第 15 章　方便好用的 Python 模块

15.1　轻松计算日期

15.1.1　日期计算的复杂性

15.1.2　导入 dateutil

15.2　简化模型的映射

15.2.1　模型映射的必要性

15.2.2　映射规则的结构化与重复利用

15.2.3　导入bpmappers

15.2.4　与 Django 联动

15.2.5　编写JSON API

15.3　图像处理

15.3.1　安装Pillow

15.3.2　图像格式转换

15.3.3　改变图像尺寸

15.3.4　剪裁图像

15.3.5　对图像进行滤镜处理

15.4　数据加密

15.4.1　安装 PyCrypto

15.4.2　通用加密系统的加密及解密

15.4.3　公钥加密系统（RSA）的加密与解密

15.5　使用 Twitter 的 API

15.5.1　导入 tweepy

15.5.2　添加应用与获取用户密钥

15.5.3　获取访问令牌

15.5.4　调用 Twitter API

15.5.5　编写用 Twitter 认证的系统

15.6　使用REST API

15.6.1　REST 简介

15.6.2　导入 Requests

15.6.3　导入测试服务器

15.6.4　发送GET 请求

15.6.5　发送 POST 请求

15.6.6　发送 JSON 格式的 POST 请求

15.6.7　使用 GET/POST 之外的 HTTP 方法

15.7　小结

附录

附录 A　VirtualBox 的设置

A.1　安装VirtualBox

A.2　新建虚拟机

A.3　备份虚拟机

附录 B　OS（Ubuntu）的设置

B.1　安装Ubuntu

B.2　SSH 的设置

B.3　中文的设置

B.4　添加用户

版权声明

Python Professional Programming Dai 2 Han

Copyright © BeProud Inc. 2015

All rights reserved.

First original Japanese edition published by SHUWA SYSTEM CO., LTD., Japan.

Chinese (in simplified character only) translation rights arranged with SHUWA SYSTEM CO., LTD., Japan.

through CREEK & RIVER Co., Ltd. and CREEK & RIVER SHANGHAI Co., Ltd.

本书中文简体字版由SHUWA SYSTEM CO., LTD., Japan. 授权人民邮电出版社独家出版。未经出版者书面许可，不得以任何方式复制或抄袭本书内容。

版权所有，侵权必究。

引言

迄今为止，BePROUD 公司已使用 Python 开发了诸多项目。我们之所以撰写本书，是为了与各位读者分享我们在实践中总结出的一些技巧。

同时，鉴于最近公司员工数量增长，我们把在 BePROUD 工作所需的知识也写入了本书，以便新的公司成员能尽快熟悉工作。

因此本书从搭建工作环境开始讲起，逐步涉及 Web 应用的开发、项目管理及审查、测试代码的编写与高效部署、服务器调试等方面，网罗了 Python 项目开发工作中的一系列流程。书名中的“实战”一词就包含了“工作”的意思。

书中所写的技巧主要源于我们的 Python2 开发经验。也正因为如此，本书将以 Python2 为例进行讲解。如今新的开发项目已经在使用 Python3，这些技巧转移到 Python3 上理应同样适用。

进入正题之前，先来聊聊我们的日常思路。

极客 / 书虫常伴身边的公司

BePROUD 里不乏极客和书虫们。在这里，很多人对特定领域的了解程度能吓掉你的下巴。

在这里，人们一旦发现感兴趣的事，就会拿出私人时间来学习、实践。要知道，极客和书虫们不会为这种事情吝啬时间。

正如人们印象中的那样，极客和书虫们大多有些怪癖，但 BePROUD 的员工都具备下列共识。

希望能不做不想做的事

希望学会好的方法并付诸实践

希望工作时有个好心情

希望能不做不想做的事

在工作中，重复单调的作业是一种极其无趣的事，因此能一次办完的事谁都不想去办两次。另外，大家也都讨厌工序复杂、容易出错的工作。所以要开动脑筋，把复杂的工序简单化，同时尽量减少出错的机会。

希望学会好的方法并付诸实践

世界上有许多公认的好方法、新思路和新技巧，我们要勇于尝试，学习它们并付诸实践。

使用好的方法必然能帮助我们削减不想做的工作。不过，方法的好坏不能人云亦云，我们必须选出对自己真正有帮助的方法，然后再将所学方法应用到实际业务当中。

希望工作时有个好心情

现在，我们学会了优秀的方法、削减了繁杂的工作，之后自然希望带着好心情去工作。此时不妨给 Skype 做个好玩的 bot，或者在下班后找个会议室搞一场妙趣横生的快速演讲。我们希望大家能在保质保量完成工作的同时有个好心情，而不是只把公司当作工作的场所。这是我们的理念。

本书的内容全部基于事实，都是 BePROUD 员工实际尝试、实践过的。我们希望给各位提供一些能实际应用且行之有效的知识，而不是让各位去死记硬背一大堆晦涩难懂的概念。我们很愿意看到本书的知识能对各位有所帮助，愿各位能在工作中有个好心情。

谢辞

本书在编撰过程中承蒙多名 IT 业界高人指点：寺田学（@terapyon）、金子望、关根裕纪（@checkpoint）、畠弥峰（@flag_boy）、小坂健二朗（@inoshiro）、筒井隆次（@ryu22e）、永井孝（@ngi644）、中西直树（@nk24）、尾曾越雅文、柴田正明（@_mshibata）、真幡康德（@mahata）、中石宜亨（@eiryplus）。各位在百忙之中仍担起审校工作，慷慨赐教，我们在此表示由衷的感谢。此外还有 BePROUD 公司的 haru、altnight、masaya、crohaco、nakagami、yyyk，感谢几位一边处理着公司内繁忙的开发工作，一边见缝插针地为本书进行审校。

最后感谢各位未能在著者处署名的 BePROUD 员工。如果没有各位员工长期以来的切磋琢磨，这本书永远不会问世。

至此，希望这本集诸人之力编撰出来的书，能为 IT 业界出一份绵薄之力。

全体执笔者

2015 年 1 月

本书网址

http://www.ituring.com.cn/book/1719

本书介绍的软件版本和 URL 均为截止到 2015 年 1 月底的最新信息，当前可能已发生变更。

前言

本书涉及的内容

本书分为 4 个部分，共 15 章。

第 1 部分“Python 开发入门”的重点将放在个人开发。内容涵盖 Python 开发过程中必不可少的工具的安装（第 1 章），简单的 Web 应用开发（第 2 章）以及 Python 项目的结构与包的创建（第 3 章）。

第 2 部分“团队开发的周期”将为各位说明多人团队开发的相关问题。这部分将重点介绍团队高效开发过程中不可或缺的技术和技巧，内容涵盖团队开发前的环境调整（第 4 章）、项目管理与审查（第 5 章）、源码管理（第 6 章）、文档（第 7 章）、模块设计与单元测试（第 8 章）、封装及其运用（第 9 章）、持续集成（第 10 章）等。

第 3 部分“服务公开”将向各位讲解如何搭建与运用正式环境公开 Web 服务（第 11 章），此外就是有关性能调节的一些方法（第 12 章）。

第 4 部分“加速开发的技巧”可以说是加速开发的一些小贴士。例如将测试的概念导入整个开发流程以加快项目进度（第 13 章），Django 的基础及其进阶性、实践性的用法（第 14 章），Python 的辅助模块（第 15 章）等。

阅读本书前的准备

环境及版本

OS: Ubuntu-14.04

Python: 2.7.6

Bash: 4.3

从第 2 章起，如无特别说明，则运行环境皆由 virtualenv 搭建。

关于 OS

实体机使用 Windows/OS X/Linux，服务器的测试环境使用虚拟机上的 Ubuntu。

Python 的官方手册

https://docs.python.org/2.7/

我们仅对 Python 官方手册中的内容做最低限度的介绍，部分说明会被省略。因此建议各位手边时常准备一份参考手册以便阅读。

Python 的官方教程非常适用于学习 Python 的基本安装流程、语法、术语、类以及模块。本书将以各位看过这份教程为前提进行讲解。

Unix/Linux 的一般命令操作

本书虽以 Ubuntu Linux 为前提讲解，但书中不对 Ubuntu Linux 的基本命令操作进行说明。

关于 PyPI（Python Package Index）

PyPI1 是一个集中管理包的网站，pip 等自动包安装工具会用到它。本书使用的包也来自 PyPI。

1https://pypi.python.org/pypi

关于敏捷过程与极限编程

本书并不对敏捷过程（Agile Process）和极限编程（ExtremeProgramming）做单独的说明。如今在许多书籍和网站上都能找到这二者的介绍，感兴趣的读者可以去读一读。

本书面向的人群

希望改善个人开发环境的人

希望改善团队开发的人

想学习工作中可使用的 Python 技巧的人

新加入 BePROUD 公司项目的成员

注意

本书基于作者本人的调查结果而成。

我们在加工本书时力求完美。不过若您发现本书存在不足和错误、漏记等问题，请书面联系出版方。

对于因本书内容运用不当而导致的结果及其影响，无论是否因上述两项内容引起，我们均不负责，请知悉。

未获得出版方书面许可不得全部或部分复制本书。

商标等

本书已省略 ™ ® © 等符号。

Python 徽标是 the Python Software Foundation的商标。

Django 和 Django 徽标是 Django Software Foundation的商标。

Google App Engine 是 Google Inc. 的商标。

Jenkins 是 SOFTWARE IN PUBLIC INTEREST, INC. 的商标。

nginx 是 Nginx Software Inc. 的商标。

VirtualBox 是 ORACLE AMERICA, INC. 的商标。

Ubuntu 是 Canonical Limited 的商标。

此外，公司名和商品名、系统名一般为各开发者的注册商标。

本书注册商标中还使用了普遍使用的通用名。

第 1 部分　Python 开发入门

在第 1 部分中，我们将搭建 Python 的基本环境，并进行简单的 Web 应用开发。然后根据已开发出的 Web 应用进一步优化环境，继续开发流程，直至其可以公开给一般用户使用。

第 1 章　Python 入门

第 2 章　开发 Web 应用

第 3 章　Python 项目的结构与包的创建

第 1 章　Python 入门

各位在学习新技术或新编程语言时，是否对准备工作发过愁呢？往往学习还没有正式开始，就先在准备工作上迷失了方向。好不容易硬着头皮开始准备，却发现安装完一个软件之后又不知道该干什么了。最后自以为准备完毕兴冲冲地要开工时，才注意到应该装好的东西并没有正确安装。到头来，大把的时间花在了准备阶段上，再无心情去学习了。类似这种情况不知道各位遇到过没有。

搭建 Python 开发环境时要考虑 OS 与版本等诸多组合，所以这个过程很难保证一帆风顺。独立开发者，尤其是自学成才的开发者们，大多是以网页或书籍上的信息作为参考，然后用自己独有的方法进行搭建。但即便如此，其中有些共通点还是需要了解的。

第 1 章中，我们将按部就班地了解对个人开发者来说共通的环境搭建顺序，让初学者也能顺利搭建环境。

因此，我们将在第 1 章中对下列项目进行重点学习。其中有部分内容涉及虚拟机，所以我们将学习时使用的本地实体机的 OS 称为“主 OS”，虚拟机的 OS 称为“客 OS”。已经自己搭建好 Python 开发环境的读者可以跳过本章。

安装 Python

安装 Mercurial

关于编辑器以及开发辅助工具

1.1　安装 Python

本节将带领各位安装一些便于在 Ubuntu 上使用 Python 进行开发的工具和包。

NOTE

本书以 Ubuntu 14.04（Server 版）作为 Python 开发环境的OS。另外，我们使用 OracleVMVirtualBox 承载客 OS。搭建环境的相关内容收录在附录 A 以及附录 B 中，初学者请先参考附录再阅读以下内容。

1.1.1　安装 deb 包

Ubuntu 可以用 apt-get 命令管理包。我们先来更新所有包，同时安装一些 Python 开发所需的包。

LIST 1.1　deb 包的更新、升级

$ sudo apt-get -y update
$ sudo apt-get -y upgrade
$ sudo apt-get -y install build-essential
$ sudo apt-get -y install libsqlite3-dev
$ sudo apt-get -y install libreadline6-dev
$ sudo apt-get -y install libgdbm-dev
$ sudo apt-get -y install zlib1g-dev
$ sudo apt-get -y install libbz2-dev
$ sudo apt-get -y install sqlite3
$ sudo apt-get -y install tk-dev
$ sudo apt-get -y install zip
$ sudo apt-get -y install libssl-dev

如 LIST 1.1 所示，我们在执行命令时添加了 -y 选项。这样一来，在安装过程中被询问 Yes 或 No 时，计算机会自动帮我们选择 Yes。如果各位想亲自逐个确认，可以将 -y 选项删去。

build-essential 包可以批量安装 Python 在 Ubuntu 上进行构建时所需的全部工具（gcc、make 等）。Python 本身在涉及某些包和模块时也必须有这些基本工具才能进行安装，因此建议各位先装好它们。

接下来安装 Python 相关的包（LIST 1.2）。

LIST 1.2　安装 Python 相关的包

# 安装python-dev
$ sudo apt-get -y install python-dev

# 安装pip
$ wget https://bootstrap.pypa.io/get-pip.py
$ sudo python get-pip.py

pip 是管理 Python 第三方库的工具。虽然它也能通过 apt 命令进行安装，但那样安装的版本较低，因此我们使用 get-pip.py 来安装最新版。

至此 Python 相关的包已经安装完毕。最后我们来查看一下 Ubuntu 默认自带的 Python 的版本。

LIST 1.3　查看 Python 的版本

$ python -V
Python 2.7.6

输入 LIST 1.3 中的命令，我们便能够查看到当前安装的 Python 版本。Ubuntu 默认安装的是 Python 2.7.6。

专栏　ensurepip

Python 3.4 具有 ensurepip 模块，可在安装 Python 的同时捆绑安装pip。这个模块是在 Python 2.7.9 之后加入的。支持 ensurepip 的 Python 可以直接使用 pip，不需要执行 get-pip.py 来进行安装。另外，执行 python -m ensurepip -U 可以将 pip 更新到当前最新版本。

ensurepip

https://docs.python.org/2.7/library/ensurepip.html

PEP 477: Backport ensurepip（PEP 453） to Python 2.7

https://www.python.org/dev/peps/pep-0477

1.1.2　安装第三方包

用 pip install 命令可以安装第三方开发的包。

第三方包注册在 PyPI 上。这是一个用来共享 Python 包的版本库（Repository），任何人都可以将 Python 包上传到上面，同时也可以从上面自由下载想用的包。用 Python 开发软件时，常常要从 PyPI 安装所需的包。

NOTE

PyPI 的读音是 //'paɪˌpi: ai//。

PyPI 的构造类似 Perl 中的 CPAN1、Ruby 中的 RubyGems2、PHP 中的 PEAR3 等。

1http://www.cpan.org/

2http://rubygems.org/

3http://pear.php.net/

从 PyPI 安装包时需要用到 pip 命令。

NOTE

有关 pip 的详细内容我们将在第 3 章中详细了解。

这里简单了解一下 pip 的使用方法。首先我们来查看当前安装的 pip 的版本（LIST 1.4）。

LIST 1.4　查看 pip 的版本

$ pip --version
pip 1.5.6 from /usr/local/lib/python2.7/dist-packages/ (python 2.7)

可以看到当前版本为 1.5.6（2014 年 12 月 9 日时）。

NOTE

上面例子中的 pip 安装在 dist-packages 目录下，只有通过 debian 和 ubuntu 包安装 Python 时才会安装到这个特有的位置。如果是通过 Linux 发行版、OS X、源码安装，则会安装在 site-packages 目录下。

通过 pip 安装第三方包的方法如下。首先我们来安装常用的 virtualenv 包（LIST 1.5）。

LIST 1.5　通过 pip 安装包

$ sudo pip install virtualenv
-（中间省略）-
Successfully installed virtualenv
Cleaning up...

此后，包的安装基本都要用 pip 命令来完成。另外，上面例子中的 virtualenv 包安装在 /usr/local/lib/python2.7 目录下。

NOTE

pip 在安装一些需要构建的包时，会用 gcc 等编译器进行构建。在本书所用的 Ubuntu 下，我们已经通过 build-essential 安装了 gcc。Windows 下需要用 Microsoft Visual C++ Compiler for Python 2.7 或者 MinGW 等进行安装。这些都是免费的。

1.1.3　virtualenv 的使用方法

前面安装的 virtualenv 是用来搭建虚拟 Python 执行环境的。我们将其称为 virtualenv 环境。使用这个环境时，包不会安装到 /usr/local/lib/python2.7 下，而是安装到虚拟 virtualenv 环境中。

由于我们在前面已经完成了安装，所以这里可以直接查看版本（LIST 1.6）。

LIST 1.6　查看 virtualenv 的版本

$ virtualenv --version
1.11.6

NOTE

有关 virtualenv 的详细内容我们将在第 3 章中详细了解。

我们在未使用 virtualenv 的状态下查看当前版本（LIST 1.7）。

LIST 1.7　用 pip freeze 查看当前安装版本

$ pip freeze
PAM==0.4.2
Twisted-Core==13.2.0
apt-xapian-index==0.45
argparse==1.2.1
configobj==4.7.2
：
：

输入 pip freeze 命令后，我们便能看到所有安装在 /usr/local/lib/python2.7 目录下的包。

接下来新建 virtualenv 环境。我们在主目录（HOME 目录）下创建工作目录，然后在这个工作目录下搭建 virtualenv 环境（LIST 1.8）。

LIST 1.8　搭建 virtualenv 环境

$ mkdir ~/work
$ cd ~/work
$ virtualenv venv

执行 LIST 1.8 中的命令后，work 目录下会自动生成 venv 目录。这就是 virtualenv 环境的目录。

接下来我们启动 virtualenv 环境。

LIST 1.9　启动 virtualenv 环境

$ source venv/bin/activate
(venv)$

如 LIST 1.9 所示，通过 source 命令执行 activate，启动 virtualenv 环境。如果终端的开头显示了“(venv)”（图 1.1），就证明 virtualenv 环境已经启动。

图 1.1　activate 后的 virtualenv 的状态

我们在启动了 virtualenv 的状态下再次执行 pip freeze，查看当前已安装的包（LIST 1.10）。

LIST 1.10　在虚拟环境下查看版本

(venv) $ pip freeze
argparse==1.2.1
wsgiref==0.1.2

可以看到，在 virtualenv 环境刚刚搭建完成时，这个 Python 执行环境中没有添加安装任何包（argparse、wsgiref 是与 Python 本体捆绑的标准库）。

想关闭 virtualenv 环境时，输入 LIST 1.11 中的命令即可。

LIST 1.11　关闭 virtualenv 环境

(venv) $ deactivate
$

如果我们不再需要某个 virtualenv 环境（本例中是 venv 目录），则可以直接用 rm -R venv 等命令将其连同所在目录一起删除。

1.1.4　多版本 Python 的使用

目前，Python 3.X 系列和 2.X 系列的开发是并行的，因此我们会遇到不同项目使用不同版本的 Python 的情况。本小节中，我们将学习如何在一个客 OS 环境下准备多个版本的 Python，以满足不同应用的需要。

Ubuntu 14.04 默认安装了 Python 2.7.6 和 Python 3.4.0。

LIST 1.12　查看 Python 的版本

$ python3 -V
Python 3.4.0

$ python -V
Python 2.7.6

现在我们将 Python 2.7 系列的最新版本 Python 2.7.94 安装到客 OS（Ubuntu 14.04）上。

4http://www.python.org/downloads/release/python-279/

Ubuntu 14.04 上安装 Python 的方法有下列几种。

通过 Ubuntu 官方 deb 包安装

通过源文件构建安装

通过 PPA（Personal Package Archives，个人软件包档案）以 deb 包的形式安装

通过 deb 包的安装十分简便，但包中封装的不一定是最新版的 Python。某些版本的 Python 含有重要更新，这时候就需要我们通过源文件进行构建了。鉴于这类情况，我们将在这里介绍“通过源文件构建”这一安装方式（LIST 1.13）。

LIST 1.13　通过源文件构建并安装

$ wget https://www.python.org/ftp/python/2.7.9/Python-2.7.9.tgz
$ tar xvzf Python-2.7.9.tgz
$ cd Python-2.7.9
$ LDFLAGS="-L/usr/lib/x86_64-linux-gnu" ./configure --prefix=/opt/python2.7.9
$ make
$ sudo make install

该 Python 将被安装到 /opt/python2.7.9/bin 下，我们可以通过文件名中的版本号找到它。现在执行 Python 命令查看是否安装成功（LIST 1.14）。

LIST 1.14　查看 Python 2.7.9

$ /opt/python2.7.9/bin/python -V
Python 2.7.9

到这里，Python 的安装就结束了。接下来我们看看如何在开发应用时区分使用各个版本的 Python。

NOTE

像 Python 2.6 这种官方 apt 版本库早已不支持的旧版本，我们可以通过PPA，即分享个人软件包的 apt 版本库进行安装（LIST 1.15）。但要注意，PPA 中的软件包全都是个人上传的，一切使用后果要由自己负责。

LIST 1.15　通过 PPA 以 deb 包的形式安装

$ sudo add-apt-repository ppa:fkrull/deadsnakes
$ sudo apt-get -y update
$ sudo apt-get -y install python2.6
$ python2.6 -V
Python 2.6.9

NOTE

Python 的部分模块依赖于一些必须通过 apt 命令进行安装的包。对于这类 Python 模块，如果不事先用 apt 命令安装好包，则会发生 ImportError。

这种时候我们需要先用 apt 命令安装所需的包，然后再通过源文件进行构建。因此如果各位遇到 Python 模块无法使用的情况，可以考虑重新通过源文件构建一遍。有些 Python 的标准模块需要先安装 deb 包才能使用，具体内容如下所示。

Python 模块

deb 包

zlib

zlib1g-dev

sqlite3

libsqlite3-dev

readline

libreadline6-dev

gdbm

libgdbm-dev

bz2

libbz2-dev

Tkinter

tk-dev

◉ 借助 virtualenv 分别使用不同版本的 Python

当 OS 中安装了多个 Python 时，我们可以通过完整路径指定 Python 命令，或者使用带版本号的 Python 命令（如 python2.7 和 python3.4）来区分使用各版本。

不过，如果在搭建 virtualenv 环境时指定了该环境下的默认 Python，那么在启动该环境时，我们就不需要顾虑版本问题了。

LIST 1.16　指定 virtualenv 下执行的 Python

$ virtualenv --python=/opt/python2.7.9/bin/python venv2
$ source venv2/bin/activate
(venv2)$
Python 2.7.9

根据 LIST 1.16 进行指定之后，以 /opt/python2.7.9/bin/python 为基础的 virtualenv 环境——venv2 就搭建完成了。

1.2　安装 Mercurial

版本控制系统在如今的开发过程中已经十分普及，Python 自然也不例外。人们使用版本控制系统通常是为了多人一起管理源码，不过，将它引入到个人开发中也能带来不少好处。比如在开发过程中应用突然不工作了，我们就可以将其回溯成能正常工作的版本。另外，对版本进行管理之后，一旦哪里发现了问题，我们可以立刻沿时间线查找之前的版本，快速找出问题发生的时间点。

版本控制系统大致可分为两类，一类是 CVS 和 Subversion 这种单一版本库的版本控制系统，另一种则是 Mercurial 和 Git 这种分布式版本控制系统（Distributed Version Control System，DVCS），如今越来越多人倾向于使用后者。Mercurial 这种分布式版本控制系统最大的优势在于各个版本库独立，因此在创建分支、提交、取消更改时不会对周围人造成影响。而且分布式的版本库可以各自看作是彼此的备份。

接下来我们来了解一下 Mercurial。

1.2.1　Mercurial 概述

Mercurial 是 Linux 内核的开发者 Matt Mackall 于 2005 年开始开发的分布式版本控制系统。Linux 内核之父 Linus Torvalds 也于同期开始开发 Git。如今这二者已经成为分布式版本控制系统的代表，被人们广泛使用。

由于 Mercurial 本身就是由 Python 编写而成的，因此我们可以轻松地通过 pip 进行安装，也可以将其视为 Python 程序进行自定义设置。

对熟悉 Python 的用户而言易于上手，与使用 Python 进行开发的团队亲和力高，这些都是 Mercurial 的优势所在。

此外，Mercurial 的魅力还在于内置了基于 Web 的管理工具，支持 Bitbucket5 等著名源码托管服务，TortoiseHg6 等 GUI 客户端丰富，等等。

5http://bitbucket.org/

6http://tortoisehg.bitbucket.org/

不仅如此，Mercurial 的命令体系与广为人知的 Subversion 很类似，对于长期使用 Subversion 的用户而言，要比 Git 更容易上手。

1.2.2　安装 Mercurial

单看前面的说明很难对分布式版本控制系统的优势有一个明确理解，所以现在我们通过实际的安装和操作来体会一下。另外，高级的 Mercurial 运用技巧我们将在第 6 章中进行学习，熟悉 Mercurial 的读者可以直接跳过本部分。

Mercurial 与其他程序包一样具有多种安装方法，用户可以通过 apt-get、pip、源文件构建等不同途径进行安装。这里我们为保证 Mercurial 尽量是最新版，决定选用 pip 进行安装（LIST 1.17）。

LIST 1.17　安装 Mercurial

$ sudo pip install mercurial

Mercurial 安装完成后我们就可以使用 hg 命令了。现在输入 hg --version 来查看版本（LIST 1.18）。

LIST 1.18　查看 Mercurial 的安装情况

$ hg --version
Mercurial Distributed SCM (version 3.1.2)
(see http://mercurial.selenic.com for more information)

Copyright (C) 2005-2014 Matt Mackall and others
This is free software; see the source for copying conditions. There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

1.2.3　创建版本库

在创建版本库之前，我们先准备好 Mercurial 的环境设置文件。在主目录下创建名为 .hgrc 的文件，然后进行如下描述。其中括号内的部分称为节（Section），比如 [ui] 节就是用来设置 username 属性的。

username 中的账户信息会在用户向版本库进行提交时记录在日志中。各位请将自己的账户名和邮箱地址写在这里（LIST 1.19）。

LIST 1.19　设置 .hgrc 的用户名与邮箱地址

[ui]
username=bpbook <bpbook@beproud.jp>

[extensions]
color=
pager=

[pager]
pager=LESS='FSRX' less

[extensions] 节用来激活 Mercurial 附属的扩展工具。只要将扩展工具名写入 [extensions] 节的项目中，我们就可以使用该工具了。此外，某些扩展工具不仅要在 [extensions] 中设置激活，还需要在 [pager] 节中设置 pager 属性。

完成环境设置文件之后就该创建版本库了。如 LIST 1.20 所示，我们为版本库创建一个目录并移动至该目录下，执行 hg init 命令创建版本库。

LIST 1.20　hg init（初始化版本库）

$ mkdir ~/hgtest
$ cd ~/hgtest
$ hg init

1.2.4　文件操作

创建好版本库之后让我们给它实际添加文件。现在创建一个测试用的文件，查看当前版本库的状态。

查看版本库状态用 hg status 命令（LIST 1.21）。如果觉得每次输入 status 太麻烦，可以将这个命令缩写为 hg st。hg 命令为用户准备了部分缩写形式，感兴趣的读者可以参考帮助或其他资料。

LIST 1.21　hg status（查看状态）

$ touch test.txt
$ hg status

? test.txt

文件名左侧显示了该文件在版本库内的状态。各位可以看到 text.txt 文件的左侧显示了状态“?”，这代表该文件现在并不在版本管理的范围内。因此我们执行 hg add 命令将其添加为版本管理的对象，如 LIST 1.22 所示。

LIST 1.22　hg add（添加文件）

$ hg add test.txt
$ hg status

A test.txt

执行 hg add 之后我们再进行一次查看，会发现之前的“?”已经变成了“A”。这代表该文件是新添加到版本管理里的。

要想让添加文件反映到版本库，我们需要进行提交。提交时请按照 LIST 1.23 所示，输入 hg commit 命令。如果不执行 hg commit，那么我们创建的文件就只能停留在当前工作的机器里，不会反映到版本库中。

LIST 1.23　hg commit（提交）

$ hg commit

test commit

HG: Enter commit message.  Lines beginning with 'HG:' are removed.
HG: Leave message empty to abort commit.
HG: --
HG: user: bpbook <bpbook@beproud.jp>
HG: branch 'default'
HG: added test.txt

执行 hg commit 后编辑器就会启动，用来记录提交时的相关信息与注释。我们在这里输入 test commit。

注释输入栏下方显示着 branch 'default'，这表示 default 分支（版本库创建时就存在的分支）为我们当前进行修正 / 添加操作的对象。

NOTE

提交时打开的编辑器是可以更改的。更改时需要如下例所示，在 .hgrc 的 [ui] 节里添加 editor 属性。

[ui]
editor = vim

这表示我们要使用vim 编辑器。默认启动的编辑器取决于环境变量EDITOR，环境变量可以用下述命令查看。

$ echo $EDITOR
vim

提交文件之后用 hg status 查看，结果应该如 LIST 1.24 所示，什么都没有。

LIST 1.24　提交后的查看

$ hg status

接下来我们看看如何在 Mercurial 中操作编辑过的文件。

先用编辑器打开 test.txt 进行修改，然后保存文件。在当前状态下执行 hg status 后，会发现该文件的状态变成了 M。状态 M 表示该文件在最后一次提交之后又进行了“变更”（Modify）。使用 hg diff 命令可以查看变更前的状态（已提交的文件）与当前状态的差别（LIST 1.25）。

LIST 1.25　hg diff（查看差别）

$ hg status
M test.txt

$ hg diff

diff -r 74471564b074 test.txt
--- a/test.txt      Mon Oct 31 18:07:15 2014 +0900
+++ b/test.txt      Mon Oct 31 18:10:45 2014 +0900
@@ -0,0 +1,1 @@
+modify this file

在尚未提交的状态下，我们可以使用 hg revert 命令撤销编辑（LIST 1.26）。

LIST 1.26　hg revert（撤销编辑）

$ hg revert test.txt

NOTE

hg revent 可以撤销尚未提交的内容，但对已提交的内容无效。这种情况需要使用 hg commit --amend 命令对已提交的变更集进行修改或撤销。

Mercurial 的基本操作方法就解说到这里，但下列几项我们并未提及。

分支的操作

远程版本库的使用

以团队开发为前提的 Mercurial 的使用方法

上述几点我们将在第 4 章和第 6 章中详细了解。

1.3　编辑器与辅助开发工具

本节将介绍编写 Python 代码时可用的编辑器，以及一些有助于开发的小贴士和 Python 模块。

1.3.1　编辑器

编写 Python 代码自然少不了编辑器。当今世上的编辑器种类繁多，但编写 Python 代码的编辑器必须具备下列几项功能。

语法高亮

将语法设置高亮是一项十分重要的功能。用特殊字色或字体表示关键词可以让编程人员快速发现拼写类错误。这样一来，我们便能够在编写代码的过程中注意并修正此类错误。

智能缩进

自动根据语法添加缩进的功能。特别是对于Python这类缩进具有重要意义的语言，编辑器适时地自动缩进要比手动插入空格时的编程效率高得多。

执行 DEBUG

在编辑器上执行DEBUG的功能。比如在编程过程中遇到某些问题时，如果能直接使用编辑器进行DEBUG，那么将能更快地找出原因，提高解决问题的效率。

可以使用静态解析类插件

能在编辑器上执行静态解析类插件的功能。如果能直接在编辑器内执行语法高亮无法查出的键入错误、语法错误、编码格式检查等，将能省去每次开关编辑器的麻烦，大幅削减修改时消耗的时间。

这里将按照上述 4 点，给各位介绍 3 个编辑器（Vim、Emacs、PyCharm）的使用方法和功能。

各位可以以此为参考来寻找自己用着顺手的编辑器。

◉ Vim

Vim 是一款功能强大的文本编辑器，默认搭载在许多 OS 上，普及度很高。

Vim 可以通过内置的 Vim script 脚本语言进行功能扩展，因此存在许多插件。

语法高亮

默认捆绑Python的语法高亮设置文件（python.vim）。

智能缩进

有自动缩进的设置选项。缩进时插入的空格数也可以调节。

执行 DEBUG

Vim可以在编辑器上调出外部命令，因此可以执行pdb（后述）。比如执行:!/path/to/virtualenv/python -m pdb %之后，编辑器便会对编辑中的脚本启动调试工具。

可以使用静态解析类插件

vim-flake8插件可以检查当前打开的文件并发出问题警告。

Vim 的设置在主目录“.vimrc”文件中进行，我们写在该文件中的设置将反映在 Vim 上。

现在我们来设置语法高亮和自动缩进（LIST 1.27）。

LIST 1.27　用于 Python 的设置

" 语法高亮的设置
syntax on
" 自动缩进的设置
filetype plugin indent on

另外，设置文件本身也可以根据对象文件类型进行分割。下面，我们专门为 Python 脚本准备一个设置文件（LIST 1.28）。

LIST 1.28　准备 Python 专用的设置文件

$ mkdir ~/.vim
$ mkdir ~/.vim/ftplugin
$ touch ~/.vim/ftplugin/python.vim

我们在这个 python.vim 中进行针对 Python 的设置，以方便编写 Python 代码。

LIST 1.29　将 Tab 改为 4 个空格

" 将“Tab”替换为“空格”
setl expandtab
" 将“Tab”的“缩进幅度”改为4
setl tabstop=4
" 自动缩进时的“缩进幅度”改为4
setl shiftwidth=4
" 按下键盘“Tab”键时插入的空格数
" 这里设置为0 就可以插入“tabstop”中设置的空格数了
setl softtabstop=0
" 保存时删除行尾的空格
autocmd BufWritePre * :%s/\s\+$//ge
"80 个字符换行
setlocal textwidth=80

像 LIST 1.29 这样设置是为了迎合 Python 社区推荐的 PEP 87 编码格式（后述）。

7https://www.python.org/dev/peps/pep-0008

vim-flake8 是 Vim 的插件，需要进行安装。neobundle.vim8 是一个专门用来管理 Vim 插件的插件，用它可以轻松安装其他 Vim 插件。

8https://github.com/Shougo/neobundle.vim

安装 neobundle.vim 后，我们在“.vimrc”里这样设置 vim-flake8。

NeoBundle 'nvie/vim-flake8'

现在只要重启 Vim 就会开始安装插件。安装结束后，用 Vim 打开 Python 脚本，按下 Ctrl + F7 就可以看到 flake8 的警告信息了。

NOTE

python.org 官方 Wiki 上也记载着 Vim 的一些设置资料，各位不妨去看一看。

· Vi Improved9

此外，在Vim 官方网站和许多民间社区中都能找到大量关于Vim 的信息。

· Vim online10

· vim-jp11

9https://wiki.python.org/moin/Vim

10http://www.vim.org/

11http://vim-jp.org/

◉ Emacs

Emacs 是 Unix 系列 OS 长期沿用的编辑器。该编辑器可以用 Emacs Lisp 语言进行扩展，因此拥有大量插件。如今的 Emacs 已经捆绑了 python.el，安装 Emacs 之后可以直接编写 Python 代码，不必特意去设置。

人们常说 Emacs 是一个环境，不过我们在这里主要是看它作为 Python 编辑器的特征。

语法高亮

内置python.el。当global-font-lock-mode不为nil时（默认）进行语法高亮。

智能缩进

python.el会按照PEP 8的要求缩进到指定位置。

执行 DEBUG

Emacs上可以执行pdb。

先键入M-x pdb，然后直接在minibuffer里运行类似/path/to/virtualenv/ python -m pdb /path/to/app.py的pdb命令行。此时会启动pdb专用的缓冲，我们可以在里面查看源码或进行调试。

可以使用静态解析类插件

flymake-python-pyflakes可以在编码过程中高亮警告。

安装完成之后我们在设置文件$HOME/.emacs.d/init.el 中设置 Tab 和空格的执行动作。另外，Emacs 可以让空格和 Tab 文字可视化。在 Python 中，缩进是语法的一部分，所以建议各位先将这些设置好（LIST 1.30）。

LIST 1.30　空格和 Tab 的设置

(require 'whitespace)
(setq whitespace-style '(face tabs tab-mark spaces lines-tail empty))
(global-whitespace-mode 1)
(setq-default tab-width 4 indent-tabs-mode nil)
(setq indent-tabs-mode nil)

flymake-python-pyflakes 等插件并没有捆绑在 Emacs 中，所以我们需要通过 Emacs 的版本控制系统 elpa 进行安装。首先在 $HOME/.emacs.d/init.el 文件中进行如 LIST 1.31 所示的设置。

LIST 1.31　elpa 的程序包版本库设置

(require 'package)
(add-to-list 'package-archives
          '("melpa" . "http://melpa.milkbox.net/packages/"))
(add-to-list 'package-archives
          '("marmalade" . "http://marmalade-repo.org/packages/"))
(package-initialize)

通过 elpa 安装好 flymake 和 flymake-python-pyflakes 之后，我们再进行 LIST 1.32 所示的设置。

LIST 1.32　flymake 的设置

(require 'flymake)
(require 'flymake-python-pyflakes)
(add-hook 'python-mode-hook 'flymake-python-pyflakes-load)
(setq flymake-python-pyflakes-executable "flake8")

设置好 flymake，我们编写源码时就能在 Emacs 上看到 flake8 的警告了。

NOTE

python.org 上有设置Emacs 的相关资料。

· Emacs Editor12

另外，我们在其官方网站和许多Wiki 上也能找到大量Emacs 的信息。

· GNU Emacs13 · EmacsWiki14 / · Python Programming In Emacs15

12https://wiki.python.org/moin/EmacsEditor

13http://www.gnu.org/software/emacs/emacs.html

14http://www.emacswiki.org/

15http://www.emacswiki.org/emacs/PythonProgrammingInEmacs

◉ PyCharm

对长期以来使用 Eclipse 等 IDE（Integrated Development Environment，集成开发环境）进行开发的人来说，IDE 用起来要比编辑器顺手得多。在当今众多的 IDE 中，普及率较高的当属 PyCharm16 了。

16http://www.jetbrains.com/pycharm/

PyCharm 是一款 2010 年左右问世的 IDE，为 Windows、OS X、Linux 等 OS 提供了相应的安装程序，同时还支持 Python 的 Web 框架 Django。PyCharm 有多种许可证形态，比如付费的 Pro 版和免费的 Community 版等。其中付费的 Pro 版还提供 30 天的免费使用期。

与 Vim 和 Emacs 相比，PyCharm 自带了许多 Python 开发的辅助功能，在安装完之后可以立刻使用。相对地，自行开发插件则需要应用 Java 的知识，所以并不算容易。从这种角度来讲，这款工具可能并不适合极客们使用。

如果各位实在无法适应 Vim 或 Emacs，那么建议先尝试 PyCharm 的 Community 版。它作为编写 Python 时使用的 IDE，具有以下特征。

语法高亮

标准功能。PyCharm会根据当前项目使用的Python版本切换语法及内置函数等文字的高亮效果。

PyCharm标准支持Python、HTML、CSS、JavaScript、XML、SQL以及CoffeeScript、Angular JS、LESS、SASS、SCSS等格式的高亮显示。Pro版还支持Django、Mako、Jinja2、Web2py、Chameleon模板的记法。

智能缩进

标准功能。PyCharm会按照PEP 8的要求缩进到指定位置。

与语法高亮相同，PyCharm也支持Python以外的其他格式。另外，用户还可以对各个项目的各个语言进行详细设置，比如规定插入Hard Tab还是空格等。

执行 DEBUG

标准功能。IDE中可以设置断点、逐句执行、显示执行中的变量值。Pro版可以用手边终端的IDE调试远程服务器上的进程。

静态解析类功能

标准功能。在编写代码的过程中，PyCharm会高亮显示对未使用的变量、未定义的名称、已过期函数的警告等。另外，PyCharm在执行解析功能时会从多角度分析Python代码是否存在风险。

导入 PyCharm 之后，我们就可以不再为搭建环境劳神费心了。它为用户标配了大量功能，大部分人都可以在默认设置的状态下轻松使用。不过相对地，一旦习惯了这些懒办法，当极客的乐趣自然也会大打折扣。

PyCharm 也并不适用于所有情况，比如编辑单个文件就很麻烦。这种情况需要先创建一个空项目，把待编辑的文件加入项目之后才能打开编辑。另外，PyCharm 的使用环境建议内存为 2 GB 以上，显示器为 SXGA 以上。

专栏 　PyCharm 适合用来干什么

PyCharm3 及之前的版本以提供面向 Web 开发者的辅助功能为主。从 2014 年 11 月发布的 PyCharm4 开始，该系列 IDE 开始内置 IPython notebook 等功能，预计今后会逐步增添科学计算相关的辅助功能。

1.3.2　开发辅助工具

本部分将向各位介绍一些 Python 开发过程中应当了解的模式及 Python 包。

◉ 交互模式

单独执行 python 命令时，该命令会以交互模式执行。交互模式指通过对话方式输入并执行 Python 代码的模式。现在请直接输入 python 命令（LIST 1.33）。

LIST 1.33　启动交互模式

$ python
Python 2.7.6 (default, Mar 22 2014, 22:59:56)
[GCC 4.8.2] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>>

随后系统会进入等待输入的状态。我们可以在这个状态下直接编写 Python 代码。代码执行后的内容会直接显示在屏幕上（LIST 1.34）。

LIST 1.34　Python 代码执行示例

>>> import sys
>>> sys.path
['', '/usr/lib/python2.7', '/usr/lib/python2.7/plat-x86_64-linux-gnu',
'/usr/lib/python2.7/lib-tk', '/usr/lib/python2.7/lib-old',
'/usr/lib/python2.7/lib-dynload', '/usr/local/lib/python2.7/dist-packages',
'/usr/lib/python2.7/dist-packages']
>>>

我们导入了标准模块 sys 并显示了 Python 包的搜索路径列表。

最后一次执行的结果存储在变量“_”中，执行它就可以返回相同结果。交互模式让我们能随手轻松地查看代码，方便进行 DEBUG 等操作。如果想结束交互模式，可以按 Ctrl + d 或输入 exit()。

NOTE

交互模式在启动时会读取环境变量 PYTHONSTARTUP 中设置的文件。如果各位在启动交互模式时想顺便导入些什么，可以在这里进行设置。

另外，IPython 模块可以将 Python 的交互模式用作调试器。该模块可通过 pip 安装。

$ sudo pip install ipython

导入IPython 后，交互模式会实现下述功能。

· TAB 键补全代码

· 使用通常的 Shell 命令

· 与pdb（调试器）联动

◉ flake8（编码格式 / 语法检测）

为更好地规范和优化 Python，人们以社区为中心给 Python 制定了 PEP（Python Enhancement Proposals，Python 增强建议书）17 指导规范。

17https://www.python.org/dev/peps

其中 PEP 8 主要规范 Python 的编码格式。这里即将介绍的 flake8 模块就可以检查我们的代码是否符合该编码格式。如 LIST 1.35 所示，flake8 可通过 pip 安装。

LIST 1.35　安装 flake8

$ sudo pip install flake8

flake8 不但能检查编码格式，还可以帮助我们找出语法错误以及一些已导入但未被使用的模块并发出警告。

使用方法很简单，只要像 LIST 1.36 一样指定文件并执行 flake8 命令即可。

LIST 1.36　flake8 执行示例

$ flake8 sample.py
sample.py:1:12: E401 multiple imports on one line
sample.py:1:17: E703 statement ends with a semicolon
sample.py:3:1: E302 expected 2 blank lines, found 1
sample.py:4:5: E265 block comment should start with '# '
sample.py:4:80: E501 line too long (83 > 79 characters)
sample.py:5:15: E225 missing whitespace around operator

NOTE

由于我们平时使用的是 flake8，所以这里只对它进行了介绍。该类常用的程序包还有 PyLint 等，各位可以根据自己的开发风格选用合适的模块。

◉ pdb（调试器）

最后我们来了解一下 Python 的调试器。如果各位曾经用过 C 语言的 gdb 等调试器，或者对 IDE 等上面附属的调试器有所接触，那么一定不会对插入断点、逐句执行等功能感到陌生。 Python 的调试器也具备这些功能。

pdb 是 Python 的标准模块，不必另外安装。其最简单的用法就是在我们希望程序停止的位置插入如 LIST 1.37 所示的代码。

LIST 1.37　pdb 的插入代码

def add(x, y):
     return x + y

x = 0
import pdb; pdb.set_trace()
x = add(1, 2)

执行这个插入了 LIST 1.37 中的代码的 Python 脚本时，脚本会停在上述插入代码的位置，然后启动对话型界面。

LIST 1.38　pdb 执行示例

$ python pdbtest.py
> pdbtest.py(7)<module>()
-> x = add(1, 2)
(Pdb)

本节我们对一些辅助开发的工具、Python 程序包等作了了解。由于涉及范围较广，我们只简单学习了部分使用方法。有关这些工具、程序库的详细信息请各位自行查阅相关文档。

1.4　小结

本章对下列话题进行了介绍，意在指导各位完成 Python 语言独立开发的事前准备。

安装 Python

安装 Mercurial

编辑器和 DEBUG 工具

为保证开发前的准备工作，本章介绍了许多工具的安装方法。这里介绍的很多内容在 Ubuntu 以外的 OS 上同样适用。此外，各位还可以编写自动完成安装的 Shell 脚本，或者省略并简化本书介绍的步骤等，总之能做的事情还有很多。各位请根据自己的需要搭建适合自己的环境。

NOTE

进入第 2 章之前，我们先来看一下 The Zen of Python。The Zen of Python 是 Python 开发者之一 Tim Peters 撰写的文章，通过 19 个分项简明扼要地表达了 Python 的特点。The Zen of Python 现已作为 PEP 2018 公开发表，在交互模式下输入 import this 命令也可以看到这篇文章。我们强烈建议各位去读一读。

>>> import this

另外，@atsuoishimoto 在博客上对The Zen of Python 进行了详细讲解，各位不妨参考一下。

· The Zen of Python 解题 - 前篇19（日语）

· The Zen of Python 解题 - 后篇20（日语）

18https://www.python.org/dev/peps/pep-0020

19http://www.gembook.org/2010-09-20.html

20http://www.gembook.org/2010-09-26.html

第 2 章　开发 Web 应用

本章中，我们先来了解 Web 应用的概念，然后一起实际开发一个简单的留言板应用，借此来了解 Web 应用开发的基本流程。至于留言板应用，它类似于观光景点的留言板，可以让访问网站的人在上面添加留言，说白了就是一个简单的网络留言板。正文中会涉及 HTML/CSS 和 Python 代码，但本书将省去对这些语法的说明。另外，本章的开发环境为 VirtualBox 虚拟机上的 Ubuntu 和 Python 2.7，同时还会用到 virtualenv。

2.1　了解Web 应用

要想开发 Web 应用，首先要知道 Web 应用是什么，它是怎样工作的。所以我们先来了解一下什么是 Web 应用。

2.1.1　Web 应用是什么

顾名思义，Web 应用就是可以通过网络使用的应用程序。比如 Google 的搜索服务、Gmail、Wikipedia、各种博客服务、Twitter 等迷你博客、GREE 和手机游戏、Facebook 等社交网站以及上面的社交游戏等，这些都是 Web 应用。人们使用 Web 应用时需要通过 Web 浏览器访问（连接）相应服务。

那么，同样需要通过 Web 浏览器阅览的 Web 站点又如何呢？ Web 站点只是单纯地显示页面，它们能称作 Web 应用吗？

答案是不一定。某些 Web 站点或许可以称为 Web 应用。因为虽然有些东西在阅览者眼中像 Web 站点，但它实际上却是由 CMS（Content Management System，内容管理系统）等 Web 应用构成的。Web 浏览器会从访问对象的计算机（服务器）中下载 HTML、CSS、图片等各种内容，然后再把这些内容显示在我们的屏幕上。如果负责发送内容的服务器只是返回一些早已准备好的静态内容，那么这个 Web 站点就不能称作 Web 应用。只有能够动态生成并返回内容的系统（比如通过 Web 浏览器接收用户输入的数据，再根据这些数据生成内容）才能称作 Web 应用。

NOTE

CMS 是负责管理和发送文章、图片等内容的系统。Wiki 和博客系统也都属于 CMS。

2.1.2　Web 应用与桌面应用的区别

要通过 Web 浏览器连接服务器使用的应用叫 Web 应用；相对地，要将软件安装在计算机上才能使用的应用叫桌面应用。二者的区别如下。

比较项目

Web 应用

桌面应用

服务器

需要

可有可无

网络连接

需要

部分应用需要

使用 OS 具备的功能

不可能

可能

安装

不需要

需要

升级

不需要使用者专门注意

需要使用者亲自动手

跨平台使用

有 Web 浏览器就能用

需要各平台分别作处理

移动端支持

与 PC 端基本相同

需要各平台分别作处理

运行速度

慢

快

从这张表上看，二者各有所长。但如果要开发一个让使用者通过网络互相交流信息的应用，那么由于桌面应用也同样需要用到服务器，所以使用 Web 应用开发起来会简单很多。不过，这个表中的内容也不是一成不变的。

比如 Web 应用本来离不开网络，但如今已有一部分应用可以离线运行。另外，某些桌面应用也开始具备自动升级新版本的功能（比如 Google Chrome），这让使用者不必再费心手动升级。可以说，Web 应用和桌面应用之间的距离正在逐渐缩小。

2.1.3　Web 应用的机制

接下来我们来了解一下 Web 应用的机制。

从我们在 Web 浏览器中输入 URL 到显示出页面，其间要进行下述处理。

① 用户在 Web 浏览器中输入 URL

② 向 DNS 服务器询问该 URL 中的域名，获取 IP 地址

③ Web 浏览器连接该 IP 地址的 Web 服务器，开始 HTTP 通信

④ Web 服务器根据 HTTP 发送来的信息运行 Web 应用，获取相应内容

⑤ Web 服务器响应，返回执行应用后得到的 HTML、CSS、JavaScript、图片文件等内容

⑥ Web 浏览器将收到的内容显示在页面中

下表是对上述处理流程中的术语进行的说明。

Web 浏览器 | 通过 HTTP 等协议与 URL 所示的计算机通信，将 HTML、CSS、图片等内容显示在页面中的软件
URL | Uniform Resource Locator（统一资源定位符）的简称，其中包含域名。这个字符串用来表示计算机需要访问网络上的哪些内容
域名 | 与 IP 地址挂钩的字符串（比如 URL 中包含的 www.beproud.jp 等字符串）
DNS 服务器 | 可以通过域名查询 IP 地址的服务器
IP 地址 | 这个数值用来在网络上识别我们想访问的计算机（以 IPv4 地址为例，IP 地址由 0 ～ 255 的数字和点组成，比如 192.168.0.1）
HTTP | Hypertext Transfer Protocol（超文本传输协议）的简称，是与被访问计算机之间的通信协议
HTML | 用来描述文档（包括文字和图片等）结构的语言
CSS | 描述 HTML 等语言描述的文档该如何显示的语言、机制
JavaScript | 在 Web 浏览器上运行的程序
Web 服务器 | 通过 HTTP 进行通信的服务器程序 / 计算机
Web 应用 | 在 Web 服务器上运行的程序
CGI | Common Gateway Interface（通用网关接口）的简称，Web 应用的机制之一

上述流程可以用图 2.1 表示。

图 2.1　Web 应用的处理流程

◉ Web 应用与 CGI

CGI 是 Web 服务器运行 Web 应用的一种机制。Web 服务器执行 CGI 程序（CGI 脚本），然后将该程序的标准输出结果作为 HTTP 通信的响应返回给对方。最简单的 CGI 程序就是在控制台界面上显示字符串。

CGIHTTPServer 是 Python 标准模块中的 Web 服务器，它可以运行 CGI 程序。现在我们试着运行下面这个 CGI 程序（LIST 2.1）。

LIST 2.1　hello.py

#!/usr/bin/env python
print "200 OK"
print "Content-Type: text/plain"
print ""
print "Hello CGI!"

用 python 命令运行这个脚本，然后控制台界面中将显示如 LIST 2.2 所示的 4 行字符串。

LIST 2.2　用 python 命令运行 hello.py 的结果

$ python hello.py
200 OK
Content-Type: text/plain

Hello CGI!

用 CGIHTTPServer 运行 CGI 程序时，待运行文件必须位于 cgi-bin 目录下，所以我们要创建这个目录并将 hello.py 放进去。另外，必须具有运行权限才可以运行这些文件，因此还要用 chmod 命令赋予运行权限。配置好 CGI 程序之后，我们就可以按照下面的例子，用 python 命令的 -m 选项运行 CGIHTTPServer 了。

LIST 2.3　CGI 程序的配置与 CGIHTTPServer 的运行

$ mkdir cgi-bin
$ mv hello.py cgi-bin/
$ chmod u+x cgi-bin/hello.py
$ python -m CGIHTTPServer
Serving HTTP on 0.0.0.0 port 8000 ...

执行 LIST 2.3 中的命令后，在该环境（这里是 VirtualBox 上的 Ubuntu）的 8000 端口等待请求的 Web 服务器将会启动。在终端按下 Ctrl + C 键可以关闭服务器。

要想通过本地环境（主 OS）的 Web 浏览器查看效果，我们需要先设置端口转发。设置方法与附录 B 中介绍的 SSH 端口的设置方法一样，这里我们给 VirtualBox 的端口转发添加如下设置：主、客端口均为 8000，协议为 TCP。

完成端口转发的设置后，我们只要通过 Web 浏览器访问 http://127.0.0.1:8000/cgi-bin/hello.py，即可看到 Hello CGI! 字样。

NOTE

127.0.0.1 代表的是自己的计算机的 IP 地址。设置过 VirtualBox 的端口转发之后，我们对自己的计算机（主OS）的 8000 端口的访问会被转发到客 OS 的 8000 端口。

本例中的 CGI 程序由 Python 代码编写而成。实际上，只要 CGI 程序能够进行标准输出，用任何语言都没有问题。

◉ Web 应用与应用服务器

应用服务器指能运行 Web 应用的功能且能与 Web 服务器通信的服务器。处于启动状态的应用服务器会一直等待 Web 服务器发来请求，一旦接到请求便会运行 Web 应用并返回结果。由于待运行的程序一直放在内存里，所以它的速度通常要比 CGI 程序的速度更快。Web 服务器与应用服务器之间的通信协议通常为 HTTP 或 FCGI 等。如果一个应用服务器可以通过 HTTP 通信，那么我们就可以用 Web 浏览器访问它。

本章使用的 Flask 内置了用于开发的应用服务器（Web 服务器），因此不需要再另外准备 Web 服务器。

2.2　前置准备

接下来我们需要为开发 Web 应用做一些前置准备。这里需要使用 Python 和 virtualenv，二者的安装请参考第 1 章。

2.2.1　关于 Flask

开发留言板应用时，我们会用到 Flask1，因此需要事先安装。Flask 是一个用 Python 编写的 Web 应用框架，它整合了 Werkzeug（WSGI 实用工具）和 Jinja2（模板引擎）两个库。用它可以轻松完成小规模的应用程序开发。

1http://flask.pocoo.org/

2.2.2　安装Flask

安装 Flask 之前，我们先用 virtualenv 命令搭建开发应用所需的虚拟环境。用 LIST 2.4 中的命令可以搭建出名为 venv 的虚拟环境。

LIST 2.4　搭建名为 venv 的虚拟环境

$ virtualenv venv

通过 source 命令执行 venv/bin 目录下的 activate 脚本，启动我们刚刚搭建好的虚拟环境（LIST 2.5）。执行完成后，命令提示符上会显示虚拟环境名 (venv)。

LIST 2.5　启动虚拟环境 venv

$ source venv/bin/activate

NOTE

Windows 需要使用 venv\Scripts\activate 命令启动虚拟环境。

接下来使用 pip 命令在虚拟环境上安装最新版本的 Flask。我们需要在 venv 虚拟环境处于激活状态时执行 LIST 2.6 中的命令。

LIST 2.6　用 pip 命令安装 Flask

$ pip install -U Flask

指定 -U 选项之后，可以用新版本替换已经安装的旧版本。2014 年 12 月时，最新的 Flask 版本为 0.10.1。至此 Flask 安装完毕，前置准备结束。

2.3　Web 应用的开发流程

那么，接下来我们应该按什么顺序开发 Web 应用呢？

现在唯一确定下来的事情就是要开发一个留言板应用，除此之外毫无计划。首先我们要确定这个应用的需求，再以需求为出发点考虑如何实现页面和功能，然后开始敲代码。等编码完成后，还要测试该应用是否能够正常运行。

整个流程总结起来是下面这样的。

① 确认需求（确认要开发什么应用）

② 根据需求明确成品必备的功能

③ 根据功能明确成品必备的页面

④ 页面设计

⑤ 实现功能

⑥ 将功能植入到页面中

⑦ 确认是否能正常运行

⑧ 完成

关于上述各流程，我们将在实际开发过程中详细了解。

2.4　明确要开发什么应用

这里，我们来了解一下这个应用的需求以及必备功能和页面。

2.4.1　留言板应用的需求

首先，我们来确定留言板应用是个什么样的应用，需要实现些什么（即需求）。

如果不事先明确需求或规格，很容易在编码过程中遇到“原本想做什么来着”“这个功能有必要做吗”之类的问题，导致项目一团乱。因此我们需要先确定这个留言板应用的需求，其具体内容如下。

① 在 Web 浏览器上显示一个包含“提交留言”表单的页面

② 可以在提交留言表单中输入名字和留言正文

③ 通过提交留言表单发送的名字和留言内容会被保存

④ 已保存的名字、留言、提交日期会显示在页面中

⑤ 整个应用由一个页面构成，页面上部为提交留言表单，下部显示已提交的内容

⑥ 提交的内容按新旧顺序由上到下排列

⑦ 可经由网络（互联网）使用本系统

⑧ 可同时在多台计算机上显示已提交的内容

2.4.2　明确必备的功能

确定好该应用的需求后，我们来思考一下该用哪些功能来满足这些需求。

这次我们要开发的是一个 Web 应用，并且导入了专门开发 Web 应用的框架，因此能够轻松实现需求①在 Web 浏览器上显示、⑦经由网络使用以及⑧同时在多台计算机上显示。

从该应用的需求来看，我们需要的功能如下表所示。

功能

说明

需求编号

提交留言功能

显示可输入名字和留言的表单，保存该表单发送的数据

①、②、③

留言显示功能

取出提交留言功能保存的数据（优先提交日期较新的数据）并显示在页面上

④、⑥

Web 应用

在 Web 浏览器上显示，并且可让多台计算机同时经由网络使用该应用

①、⑦、⑧

需求⑤是与页面相关的问题，我们在下一小节学习页面的时候再处理。这里，我们再确认一遍已明确的功能，看看是否能满足需求。

2.4.3　明确必备的页面

接下来我们根据应用的功能分析所需的页面，结果如下。

功能

必备的页面

说明

提交留言功能

可以输入名字和留言的表单

可供输入名字与留言的栏、提交按钮

留言显示功能

显示有名字和留言的列表

在页面上列表显示已提交的名字和留言

需求中提到整个应用由一个页面构成（需求⑤），所以我们要将这两个页面元素糅合到同一个页面中。于是，必备的页面就成了下面这样。

必备的页面

页面元素

提交和显示留言的页面

可以输入名字和留言的表单、显示有名字和留言的列表

至此，我们明确了必备的页面。接下来可以开始设计页面了。

2.5　页面设计

需要的功能和页面都已经敲定，接下来，我们就动手设计页面吧。

至于为什么先实现页面而非功能，是因为当页面完成后，我们可以更好地把握成品应用的整体印象。在把功能植入系统之前，即页面设计阶段，我们需要先写好 HTML 文件和 CSS 文件。

2.5.1　确定成品页面的形式

在开始编写 HTML 代码之前，还需先按捺住自己跃跃欲试的心情，把我们希望呈现的页面明确下来。所以，现在要做的就是把必备页面转化成图。这一步称为页面设计。

建议各位先在纸上画出页面的草图。如果希望后期修改起来方便，或者希望页面草图干净漂亮，还可以考虑用绘图软件或页面设计方面的专业软件来画。

这次我们没有使用工具，而是直接画了一张页面草图（图 2.2）。

图 2.2　留言板应用的页面草图

按照需求，上面是表单，下面显示提交的评论内容。另外，这张草图还为各个显示元素（表单和文章）添加了注释。这样一张草图不但能在植入功能时为我们提供参考，还能随时提醒我们仍缺少哪些功能，这对开发来说意义重大。

2.5.2　编写 HTML 和 CSS

下面我们开始编写 HTML 文件和 CSS 文件。为了查看其在 Web 浏览器中的效果，我们将在本地环境（主 OS）中进行编写。编写完成的文件可以通过 scp 命令发送到服务器，以便后续使用。

首先我们来参考草图编写 HTML 文件。现阶段不必太在意页面布局，这些外观上的东西都可以留在编写 CSS 文件的时候进行调整。

LIST 2.7 是我们编写的 HTML 文件的源码。

LIST 2.7　index.html

<!DOCTYPE html>
<html lang="zh">
  <head>
    <meta charset="utf-8">
    <title> 留言板</title>
  </head>
  <body>
    <h1> 留言板</h1>
    <form action="/post" method="post">
      <p> 请留言</p>
      <table>
        <tr>
          <th> 名字</th>
          <td>
            <input type="text" size="20" name="name">
          </td>
        </tr>
        <tr>
          <th> 留言</th>
          <td>
            <textarea rows="5" cols="40" name="comment"></textarea>
          </td>
        </tr>
      </table>
      <p><button type="submit"> 提交</button></p>
    </form>
    <div>
      <h2> 留言记录</h2>
      <h3> 游客　的留言(2014/10/31 10:00:00):</h3>
      <p>
        留言内容<br>
        留言内容
      </p>
      <h3> 游客　的留言(2014/10/31 09:00:00):</h3>
      <p>
        留言内容<br>
        留言内容
      </p>
    </div>
  </body>
</html>

用 Web 浏览器打开这个 HTML 文件，结果如图 2.3 所示。

图 2.3　只有 HTML 文件的页面

这样，HTML 文件就写好了，接下来开始写 CSS 文件。此时需要调整字体大小、颜色、显示位置等外观（风格）。

LIST 2.8 是写好的 CSS 文件。

LIST 2.8　main.css

body {
  margin: 0;
  padding: 0;
  color: #000E41;
  background-color: #004080;
}
h1 {
  padding: 0 1em;
  color: #FFFFFF;
}
form {
  padding: 0.5em 2em;
  background-color: #78B8F8;
}
.main {
  padding: 0;
}
.entries-area {
  padding: 0.5em 2em;
  background-color: #FFFFFF;
}
.entries-area p {
  padding: 0.5em 1em;
  background-color: #DBDBFF;
}

为了让这个样式表生效，我们需要在 HTML 文件中添加 link 标签读取 CSS，并在相应位置指定 class。修改后的 HTML 文件如 LIST 2.9 所示。

LIST 2.9　index.html

<!DOCTYPE html>
<html lang="zh">
  <head>
    <meta charset="utf-8">
    <title> 留言板</title>
    <link rel="stylesheet" href="main.css" type="text/css"> <!-- 添加link 标签 -->
  </head>
  <body>
    <h1> 留言板</h1>
    <form action="/post" method="post">
      <p> 请留言</p>
      <table>
        <tr>
          <th> 名字</th>
          <td>
            <input type="text" size="20" name="name">
          </td>
        </tr>
        <tr>
          <th> 留言</th>
          <td>
            <textarea rows="5" cols="40" name="comment"></textarea>
          </td>
        </tr>
      </table>
      <p><button type="submit"> 提交</button></p>
    </form>
    <div class="entries-area">  <!-- 在div 标签中添加class 属性 -->
      <h2> 留言记录</h2>
      <h3> 游客 的留言(2014/10/31 10:00:00):</h3>
      <p>
        留言内容<br>
        留言内容
      </p>
      <h3> 游客 的留言(2014/10/31 09:00:00):</h3>
      <p>
        留言内容<br>
        留言内容
      </p>
    </div>
  </body>
</html>

我们用 Web 浏览器打开套用 CSS 后的 HTML 文件，如图 2.4 所示。

图 2.4　套用 CSS 后的页面

这样我们就写好了显示页面用的 HTML 文件和 CSS 文件。现在用 scp 命令将已写好的文件发送到 VirtualBox 上的 Ubuntu 环境下。发送文件的命令如 LIST 2.10 所示。

LIST 2.10　用 scp 命令发送文件

$ scp -P 2222 index.html main.css bpbook@127.0.0.1:

NOTE

SSH 连接的设置等问题请参考附录 B。

下一步我们要实现功能，并将其植入页面中。

2.6　实现功能

终于到了编写 Python 程序的阶段。我们准备让服务器端的程序来实现这个应用的必备功能。这里将优先实现比较重要的功能，或者能提高其他部分实现速度的功能。

保存和读取用户提交的数据是这个应用的核心部分，所以我们先从它下手。

2.6.1　保存留言数据

提交功能不但要能保存表单传来的名字和留言，还得能将提交日期及时间存储下来，供显示时使用。

这里我们用 Python 的标准模块 shelve 来存储数据。shelve 能够像 Python 字典对象一样操作数据，将对象持久化。也就是说，shelve 会将提交的数据转换成字典对象，以列表形式保存多个字典，然后将这些字典保存在 shelve 中。

下面来编写留言板的脚本文件。guestbook.py 实现了负责保存数据的 save_data 函数，具体代码如 LIST 2.11 所示。

LIST 2.11　guestbook.py

# coding: utf-8
import shelve

DATA_FILE = 'guestbook.dat'

def save_data(name, comment, create_at):
    """ 保存提交的数据
    """
    # 通过shelve 模块打开数据库文件
    database = shelve.open(DATA_FILE)
    # 如果数据库中没有greeting_list，就新建一个表
    if 'greeting_list' not in database:
        greeting_list = []
    else:
        # 从数据库获取数据
        greeting_list = database['greeting_list']
    # 将提交的数据添加到表头
    greeting_list.insert(0, {
        'name': name,
        'comment': comment,
        'create_at': create_at,
    })
    # 更新数据库
    database['greeting_list'] = greeting_list
    # 关闭数据库文件
    database.close()

然后再来看看 save_data 函数的运行情况。我们通过终端在 guestbook.py 文件所在的目录下启动 Python shell，然后像 LIST 2.12 这样通过 Python shell 加载并执行 guestbook 模块的 save_data 函数。

LIST 2.12　save_data 函数的运行测试

$ ls  # 确认guestbook.py 位于当前目录下
guestbook.py
$ python  # 启动Python shell
>>> import datetime
>>> from guestbook import save_data
>>> save_data('test', 'test comment',
...           datetime.datetime(2014, 10, 31, 10, 0, 0))
>>>

这里我们将 datetime 模块的日期时间对象传递给传值参数 create_at，以此来保存提交的日期和时间。在这个阶段，我们只能看出它的运行是否报错，至于数据是不是真的被保存下来了，还要等取出数据的功能实现之后才能知道。

2.6.2　获取已保存的留言列表

实际上，我们在保存数据的时候就从 shelve 模块中取出过数据，现在只把这部分代码单独拿出来做成函数即可。

在 guestbook.py 中添加 load_data 函数（LIST 2.13）。

LIST 2.13　guestbook.py

def load_data():
    """ 返回已提交的数据
    """
    # 通过shelve 模块打开数据库文件
    database = shelve.open(DATA_FILE)
    # 返回greeting_list。如果没有数据则返回空表
    greeting_list = database.get('greeting_list', [])
    database.close()
    return greeting_list

这里同样通过 Python shell 查看其运行情况。启动 Python shell，加载函数并运行（LIST 2.14）。

LIST 2.14　load_data 函数的运行测试

>>> from guestbook import load_data
>>> load_data()
[{'comment': 'test comment', 'name': 'test', 'create_at': datetime.datetime (2014, 10, 31, 10, 0)}]

如果运行正常，那就表示我们能获取 save_data 函数保存下来的数据。

2.6.3　用模板引擎显示页面

从文件中取出数据之后，为了将其显示到页面上，我们要使用模板引擎。模板引擎可以将模板（程序的雏形）与要植入模板内的数据合并输出。Flask 标准支持 Jinja2 模板引擎。

接下来创建 templates 目录，然后将前面已经写好的 HTML 文件放到该目录下（LIST 2.15）。这样一来，我们就可以以它为模板生成 HTML 了。

LIST 2.15　放置模板

$ mkdir templates
$ mv index.html templates/

下面从程序端入手，用这个 HTML 文件（模板）来完成页面的显示。先添加代码，让 guestbook.py 调用 Flask，然后再添加用来显示首页的函数以及用来启动 Web 服务器的代码（LIST 2.16）。

LIST 2.16　guestbook.py

# coding: utf-8
import shelve

from flask import Flask, request, render_template, redirect, escape, Markup

application = Flask(__name__)

DATA_FILE = 'guestbook.dat'

def save_data(name, comment, create_at):
    """ 保存提交的数据
    """
    # 省略

def load_data():
    """ 返回已提交的数据
    """
    # 省略

@application.route('/')
def index():
    """ 首页
    使用模板显示页面
    """
    return render_template('index.html')

if __name__ == '__main__':
    # 在IP 地址127.0.0.1 的8000 端口运行应用程序
    application.run('127.0.0.1', 8000, debug=True)

我们将 Flask 类的实例赋给变量 application，传值参数指定为 __name__ 变量的模块名。方法 route 是一个装饰器，负责注册针对特定 URL 执行的函数。这里我们让主页的 URL 对应执行 index 函数。render_template 函数负责将指定文件用作模板，再通过模板引擎进行输出。

Flask 类的 run 方法用于启动 Web 服务器并执行应用程序，传值参数用来指定要绑定的 IP 地址及端口。另外，将 debug 选项指定为 True 时，一旦应用程序出错，Web 浏览器端就会启动可用的调试程序。

接下来用 python 命令运行 guestbook.py（LIST 2.17）。

LIST 2.17　运行已开发完毕的 Web 应用

$ python guestbook.py
* Running on http://127.0.0.1:8000/
* Restarting with reloader

运行 guestbook.py 之后，在该环境的（这里是 VirtualBox 上的 Ubuntu）127.0.0.1 地址的 8000 端口等待请求的应用服务器（Web 服务器）就会启动。我们可以在终端同时按下 Ctrl 键和 C 键，关闭这个服务器。

现在我们需要设置好 8000 端口的端口转发，然后在 Web 浏览器中打开 http://127.0.0.1:8000/。如何，看到页面没有？

我们会发现 CSS 文件并没有生效，所以需要重新调整一下 CSS 文件的位置，让它能够被读取。Flask 会公开 static 目录下存放的静态文件。接下来我们创建一个 static 目录，把 main.css 文件放进去（LIST 2.18）。

LIST 2.18　放置静态文件

$ mkdir static
$ mv main.css static/

另外，还要将 templates/index.html 中的 CSS 文件引用位置改为 /static/main.css（LIST 2.19、LIST 2.20）。

LIST 2.19　templates/index.html（更改前）

<link rel="stylesheet" href="main.css" type="text/css">

LIST 2.20　templates/index.html（更改后）

<link rel="stylesheet" href="/static/main.css" type="text/css">

现在再用 Web 浏览器打开该页面，就会发现 CSS 文件这时已经生效了。

接下来，我们把从数据库中取出的内容显示在页面上。修改 guestbook.py 文件，让 index 函数调用 load_data 函数，同时使模板能够使用 load_data 函数取出来的数据（LIST 2.21）。

LIST 2.21　guestbook.py

@application.route('/')
def index():
    """ 首页
    使用模板显示页面
    """
    # 读取已提交的数据
    greeting_list = load_data()
    return render_template('index.html', greeting_list=greeting_list)

render_template 函数可以将关键字传值参数所指定的值用作模板变量。比如本例就使用了名为 greeting_list 的模板变量。

此外，我们还要修改模板 templates/index.html，使其能够使用模板变量进行显示。现在对 HTML 中的显示留言部分作如下修改（LIST 2.22）。

LIST 2.22　templates/index.html

<div class="entries-area">
  <h2> 留言记录</h2>
  {% for greeting in greeting_list %}
    <h3>{{ greeting.name }}
        的留言({{ greeting.create_at }}):</h3>
    <p>{{ greeting.comment }}</p>
  {% endfor %}
</div>

模板内可以使用一种特殊的描述方式，即模板语言。Jinja2 的模板可以通过 {%…%} 的形式使用 if 或 for 等控制语句。植入有模板变量的部分用 {{…}} 的形式描述。在上述模板中，程序会从 greeting_list 中逐一取值并赋给模板变量 greeting，然后使用从 for 到 endfor 之间的模板变量进行循环输出。

这样一来，应用就能将 save_data 函数保存的数据显示在页面上了。

2.6.4　准备评论接收方的 URL

下一步我们用 save_data 函数保存表单提交来的数据。由于模板文件中表单的 action 值为 /post，所以我们就做出这个 URL。

这里，我们将 post 函数添加到 guestbook.py 的 if __main__ ... 前面（LIST 2.23）。

LIST 2.23　guestbook.py

@application.route('/post', methods=['POST'])
def post():
    """ 用于提交评论的URL
    """
    # 获取已提交的数据
    name = request.form.get('name')  # 名字
    comment = request.form.get('comment')  # 留言
    create_at = datetime.now()  # 投稿时间（当前时间）
    # 保存数据
    save_data(name, comment, create_at)
    # 保存后重定向到首页
    return redirect('/')

在 Flask 中，可以用 request.form 引用表单发来的数据。此外，由于保存数据后需要重新显示首页，所以我们要返回 redirect 函数的结果并进行重定向。

post 函数使用了 datetime 模块，所以需要在文件开头添加如 LIST 2.24 所示代码，以导入该模块。

LIST 2.24　导入 datetime 模块（guestbook.py）

# coding: utf-8
import shelve
from datetime import datetime  # 添加此行

至此，保存数据的功能也实现了。应用的运行部分基本完工。

2.6.5　调整模板的输出

程序运行所需的功能现在已经基本上都实现了，但这里至少还有两点需要注意。

表单提交多行留言时，无法正常显示留言

显示的时间精确到了毫秒

要想解决这两个问题需创建一个模板过滤器。模板过滤器会对模板变量的值加以转换并输出。接下来，我们在 guestbook.py 的 if __main__ ... 前添加如 LIST 2.25 所示代码，将模板过滤器导入模板。

LIST 2.25　guestbook.py

@application.template_filter('nl2br')
def nl2br_filter(s):
    """ 将换行符置换为br 标签的模板过滤器
    """
    return escape(s).replace('\n', Markup('<br>'))

@application.template_filter('datetime_fmt')
def datetime_fmt_filter(dt):
    """ 使datetime 对象更容易分辨的模板过滤器
    """
    return dt.strftime('%Y/%m/%d %H:%M:%S')

Flask 类的 template_filter 方法是一个装饰器，它负责将函数注册为指定名称的模板过滤器。程序运行时，指定了模板过滤器的模板变量会被作为传值参数传递给相应函数，而函数的返回值则为最后输出的值。在上述源码中，我们注册了 nl2br 和 datetime_fmt 两个模板过滤器。

接下来，我们修改一下 templates/index.html 文件，让模板能够使用这两个模板过滤器（LIST 2.26）。

LIST 2.26　templates/index.html

<div class="entries-area">
  <h2> 留言记录</h2>
  {% for greeting in greeting_list %}
    <h3>{{ greeting.name }}
        的留言({{ greeting.create_at|datetime_fmt }}):</h3>
    <p>{{ greeting.comment|nl2br }}</p>
  {% endfor %}
</div>

在模板内指定模板过滤器的方法很简单，只需要在模板变量的名称后面加管道符“|”再加模板过滤器名即可。在本次的模板中，我们给 greeting.create_at 指定了 datetime_fmt 过滤器，给 greeting.comment 指定了 nl2br 过滤器。

至此，服务器端的功能、功能与页面的对接已经全部完工。从 Web 浏览器看到的效果如图 2.5 所示。显示留言部分 2 显示了我们已提交的内容。

2留言记录部分的 tokibito 为本章撰写者冈野真也先生的 Twitter 用户名。——编者注

图 2.5　植入功能后的页面

整个应用的开发过程到这里就结束了，接下来就是确认该应用的运行是否正常，以及看一看成品是否能满足我们当初定下的需求。

2.7　查看运行情况

虽然程序的编写已经结束，但我们还需要确认应用程序能不能按预想的样子运行。下面就来逐条确认刚刚开发完成的应用是否满足需求，以及运行上是否存在问题。到了这一步，可能会有人觉得“开发的时候就已经逐条确认过了，肯定不会有问题的”。这种心情可以理解。但要知道，我们在编码后期阶段所作的修改可能会导致之前的功能出现 Bug，而且也难保开发过程中不会遗漏某些需求。因此要想制作一个品质上乘的应用程序，这项确认工作必不可少。

好了，现在让我们回顾一下这个留言板应用的需求。

① 在 Web 浏览器上显示一个包含“提交留言”表单的页面

② 可以在提交留言表单中输入名字和留言正文

③ 通过提交留言表单发送的名字和留言内容会被保存

④ 已保存的名字、留言、提交日期会显示在页面中

⑤ 整个应用由一个页面构成，页面上部为提交留言表单，下部显示已提交的内容

⑥ 提交的内容按新旧顺序由上到下排列

⑦ 可经由网络（互联网）使用本系统

⑧ 可同时在多台计算机上显示已提交的内容

现在运行开发服务器，查看成品是否能满足这 8 项需求。

只要通过 Web 浏览器访问 http://127.0.0.1:8000/ 即可查看需求①是否得到了满足。与此同时，由于虚拟机和本地计算机之间经由网络连接，所以⑦和⑧也没有问题。至于②，只要我们能在页面中的表单里填写名字和留言内容，那就是过关了。接下来输入内容并点击提交按钮，随后页面被刷新，刚刚提交的内容显示在了页面上。于是⑤也搞定了。然后重启开发服务器，再次通过 Web 浏览器读取页面，如果之前提交的内容能够正常显示，就表示③和④也 OK。最后我们再进行一次输入和提交，只要后来提交的内容显示在最上方，那么需求⑥也就满足了。

另外，这次我们开发的应用没有对使用者输入的字符串做任何限制，而且输入的内容会直接以植入 HTML 的形式显示出来。因此，接下来需要确认是否存在跨站脚本攻击漏洞。

NOTE

跨站脚本攻击（Cross Site Scripting，XSS3）是一种常见的漏洞，用户能在某些以植入 HTML 的形式显示输入内容的应用中，故意植入一些攻击型的脚本（比如 HTML 标签或 JavaScript 等）。

XSS 漏洞可被用在会话劫持、钓鱼等恶意行为上。

3Cross Site Scripting 的缩写为 XSS，这是为了和层叠样式表（Cascading Style Sheet，CSS）有所区分。——编者注

要验证应用中是否含有 XSS 漏洞，只需要像 LIST 2.27 这样，在留言输入栏中键入带有 JavaScript 代码的 HTML 标签，然后点击提交即可。

LIST 2.27　验证跨站脚本攻击时需要输入的内容

<script>alert('NG')</script>

显示提交内容的区域内是否显示出了我们输入的字符串呢？如果该应用含有 XSS 漏洞，那么 NG 字样将显示在浏览器的警告中。

我们在本次开发中使用的是 Jinja2 模板引擎，它在翻译字符串时会自动忽略 HTML 标签的“< 和 >”符号。因此，我们的应用能够成功避免 XSS 漏洞。

建议各位在最后查看运行情况时也检测一下其他可能造成安全问题的漏洞。

NOTE

与 XSS 同样恶名昭彰的漏洞还有跨站请求伪造（Cross Site Request Forgery，CSRF）。用户通过与目标应用程序无关的外部输入表单（这些输入表单通常用于攻击）发送数据，一旦目标应用程序处理了这些数据，就会引发使用者意料之外的操作。

CSRF 漏洞可被用来触发使用者意料之外的操作（比如在线购买商品、泄露个人信息等）。

本章中开发的应用并没有对 CSRF 进行防范。由于 XSS 和 CSRF 这两种攻击手法可以相互组合出新的攻击手法，所以建议各位务必做好防范工作。

如果上述过程全部顺利通过，我们的应用开发就可以宣告完工了。各位辛苦了。

最终的文件结构如下表所示。

文件路径

说明

guestbook.py

服务器程序

guestbook.dat

提交数据文件

static/main.css

CSS 文件

templates/index.html

输出 HTML 的模板，用于显示提交 / 留言列表页面

Web 应用开发的第一步是确定自己要开发什么东西。要是对自己要做的东西没有概念，开发就不可能进行下去。另外，如果必备的页面和功能不够明确，那么编写代码的过程将是相当痛苦的。要想让开发如行云流水般流畅，那就必须将这些不确定的因素统统明确下来，确定一个开发流程。

在本章中，我们强调了 Web 应用是一个发送动态内容的程序，并且用户可以经由 Web 浏览器使用 Web 应用，同时还学习了 Web 浏览器 / 服务器 / 应用之间的基本通信机制。另外，我们还以留言板应用为例，从确定需求到最终完成，边学边练地一起了解了整个应用开发流程。本章学习的开发流程适用于多种应用的开发。

第 3 章　Python 项目的结构与包的创建

在 Python 圈子里，有许多开发者会无偿公开自己开发的程序库。为了让使用者能够通过 pip 命令安装这些库，我们在发布时需要将其创建成一种特殊的文件，这种文件就是程序包。在使用 Python 语言进行开发的过程中，Python 自带的库往往不能满足我们，因此我们还需要用到这些程序包。

本章将介绍程序包的制作流程。首先，我们要了解一下 Python 项目开发环境的相关工具。然后，我们来了解一下该环境下的相对易于掌握的 Python 项目目录结构以及文件结构，同时对第 2 章中开发的留言板应用进行整理，封装成包。最后还将学习如何将我们开发的项目发布在 PyPI 上，与全世界人分享。

3.1　Python 项目

用 Python 开发的应用程序达到一定规模后，必然会出现多个模块（.py）或程序包目录。同时除源码以外，说明性质的文本文件、管理相关程序库的元信息等都会越来越多。这些为同一个目的服务的文件、目录以及元信息，就是我们所说的项目。

实际上，Python 项目的内部结构是因项目而异的。这里，一个完整的结构需要满足以下条件。

拥有一个在版本管理之下的源码目录

程序信息在 setup.py 中定义

在一个 virtualenv 环境中运行

对于 Python 开发上的一些约定俗成的工具来说，满足上述条件的结构更便于处理。我们在第 1 章中介绍的 pip 和 virtualenv 都属于这类工具。

这些 Python 中的约定俗成的工具也随着时代逐渐变化着。近年来 PEP 标准正被逐渐推行。为了规范这些约定俗成的东西，2013 年成立了 PyPA（Python Packaging Authority）工作组。PyPA 负责 Python 封装方面相关工具的维护，以及 PEP 标准化等工作。许多老牌的封装相关工具都被移交给 PyPA 管理，包括本书中使用的 pip、virtualenv、wheel 现在也都由 PyPA 提供。

如今，许多 Python 项目的结构都以 PyPA 提供的工具为参照，选用了适合这些工具的文件、目录结构。

如果项目的结构符合标准，那么它与工具之间就会有很强的亲和力，而且便于今后自己或其他开发者进一步开发。另外，本章中介绍的结构与流程不但适用于个人的开发环境，同样也适用于团队的开发环境。

NOTE

在 PyPA 的封装文档中，将以发布为目的的一个整体单位称为一个项目（Project）。

https://packaging.python.org/en/latest/glossary.html#term-project

3.2　环境与工具

本节，我们将对 Python 项目开发的必备工具进行了解。此外，还将学习项目的目录结构、必备程序包的管理方法以及安装方法。

3.2.1　用 virtualenv 搭建独立环境

如果大量项目全都混杂在一个环境下，程序很可能会在预想不到的地方停止运行，而且不利于我们把握当前环境的具体状态。所以为了防止出现这些恼人的情况，我们建议各位搭建简单的独立环境。

使用 virtualenv 可以给每个项目搭建一个独立的 Python 环境。

独立环境有以下优点。

添加程序包以及变更版本时，能将影响控制在当前环境内

便于判断已安装的程序包是否可以删除

不再需要该环境时，可以直接删除整个环境

一旦出了问题，那么问题必然出在该环境的项目上，这就有助于我们找出问题所在

◉ virtualenv

NOTE

当前介绍的版本为 virtualenv 1.11.6。

用 pip 安装外部程序库时，该库会被安装到 Python 的安装目录下。比如 Python 是安装在了 /usr/local/ 目录下，那么该外部程序库就会被安装在 /usr/local/lib/python2.7/site-packages 目录下，这就是库的默认安装路径。但这样一来，不同目的的程序库就全都安装到了同一目录下，不但容易导致版本冲突，而且很难分辨出哪些程序库已经没用了。

另外，在 /usr/local/ 下安装东西时，需要我们提供该目录的写入权限。我们对自己的计算机中的 OS 目录具有写入权限，但是不一定对其他计算机的 OS 目录也拥有写入权限。就算有，乱用权限也很容易导致意外事故，因此应尽量避免乱用权限。

其实，这些问题都可以用 virtualenv 解决。

virtualenv 的主要特征体现在下列功能上。

在 virtualenv 环境中可自由安装 Python，不需要提供 OS 管理员权限

在 virtualenv 环境下，可根据目的不同来安装程序库，这样一来包的安装目的和依存关系就会更加明确

仍然使用 Python 主体，且虚拟环境仅由一小部分备份文件构成，因此环境搭建速度快，占用硬盘空间小

无视 Python 主体的 site-packages，而且能分离主体上已安装完毕的程序包

可以用 activate/deactivate 命令随时启动 / 关闭 virtualenv 环境

virtualenv 命令可以将任意目录设置为“virtualenv 环境（Python 虚拟环境）”。激活 virtualenv 环境之后，Python 解释器会将该目录识别为默认安装目录。所以，如果我们此时用 pip 命令安装程序库，那么这个程序库将被安装到 virtualenv 环境中。

下面我们来了解一下 virtualenv 的一般使用方法以及一些常用选项。其他详细知识请参考以下网站。

Reference Guide - virtualenv 1.11.6 documentation

https://virtualenv.pypa.io/en/latest/

专栏　Python 标配的用户站点目录功能与 virtualenv 的差异

用户站点目录功能由PEP 3701 提出，随后被 Python 采纳并从 Python 2.6 版本开始提供。这个功能让 Python 解释器能够识别安装在用户目录 $HOME/.local 下的程序库。与此同时，pip 也纳入了这一机制，允许用户使用 --user 选项将程序库安装到 $HOME/.local 目录下。

有了这个功能之后，用户不必使用 virtualenv 就能自由地安装程序库。不过，用户站点目录功能无法像 virtualenv 一样搭建多个环境，自然也就不能在多个环境中切换。

1https://www.python.org/dev/peps/pep-0370

◉ virtualenv 的使用方法

接下来我们看一看 virtualenv 的安装、virtualenv 环境的搭建以及启动（LIST 3.1、LIST3.2）。

LIST 3.1　安装 virtualenv

$ sudo pip install virtualenv

LIST 3.2　virtualenv 环境的搭建及启动

$ cd /home/bpbook/work
$ virtualenv venv
$ ls -F
venv/
$ source venv/bin/activate
(venv)$

如上所示，使用某一 virtualenv 环境的 activate 命令后，该 virtualenv 环境就会被设置为默认的 Python 执行环境。这里我们搭建了名为 venv 的 virtualenv 环境，因此命令提示符前会出现环境名 (venv)。

执行 activate 后，PATH、PROMPT 等数个环境变量会被改写。这样一来，对象 virtualenv 环境的 bin 目录将在 PATH 搜索中被优先处理。此时，只有环境变量会被修改，文件并不会有任何变动。

在这个状态下，计算机会优先使用 venv/bin 目录下的执行文件来执行各个命令。给 virtualenv 环境安装额外的程序库时，我们需要如 LIST 3.3 所示，在命令提示符前有“(venv)”的状态下执行 pip 命令。

LIST 3.3　给 virtualenv 环境安装程序库

(venv)$ pip install requests bottle
(venv)$ pip freeze
bottle==0.12.7
requests==2.4.3

如果要使用这个 virtualenv 环境的 Python，则要执行该 virtualenv 环境的 python 命令（LIST 3.4）。

LIST 3.4　运行 virtualenv 环境的 Python

(venv)$ python
Python 2.7.6 (default, Mar 22 2014, 22:59:56) [GCC 4.8.2] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> import sys
>>> sys.executable
'/home/bpbook/work/venv/bin/python'
>>> import requests
>>> import bottle

解除 activate 时，需要还原之前被 activate 更改的环境变量。因此我们要结束 shell，或者执行 deactivate 命令（LIST 3.5）。

LIST 3.5　通过 deactivate 命令关闭 virtualenv 环境

(venv)$ deactivate
$ python -c "import sys; print sys.executable"
/usr/local/bin/python

virtualenv 环境的数量没有上限。这里我们再来搭建一个名为 another-venv 的 virtualenv 环境（LIST 3.6）。

LIST 3.6　搭建另一个 virtualenv 环境

$ virtualenv another-venv
$ ls -F
another-venv/   venv/

使用 another-venv 环境的 Python 时，我们无法 import 其他 virtualenv 环境安装的库。

LIST 3.7　运行另一个 virtualenv 环境的 Python

$ source another-venv/bin/activate
(another-venv)$ python
Python 2.7.6 (default, Mar 22 2014, 22:59:56) [GCC 4.8.2] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> import sys
>>> sys.executable
'/home/bpbook/work/another-venv/bin/python'
>>> import requests
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ImportError: No module named 'requests'

可见，每一个 virtualenv 环境都是相互独立运行的 Python 环境（LIST 3.7）。而且每个环境都仅由 Python 的一部分文件构成，所以能够很快搭建完成或者删掉，并不会占用太多硬盘空间。

专栏　virtualenv 环境的硬盘使用量

在 Ubuntu 14.04 下，Python 主体的硬盘使用量大约为 100 MB，每个 virtualenv 环境的硬盘使用量约为 6 MB。不过，如果使用了我们后面即将了解的 --always-copy 选项，那么每个 virtualenv 环境将占用约 50 MB。

不再使用的 virtualenv 环境可以连同其所在目录一起删除（LIST 3.8）。

LIST 3.8　删除 virtualenv 环境

(another-venv)$ deactivate
$ rm -R another-venv

专栏　在不 activate 的状态下执行 virtualenv 环境的命令

我们执行 activate 命令启动 virtualenv 环境时，环境变量会被更新，此后执行命令时会优先使用 venv/bin/ 目录下的文件。实际上，即便是在没有执行 activate 命令的状态下，只要我们用完整路径执行了 venv/bin/ 下的命令，就可以运行 virtualenv 环境中的程序。

$ python -c "import sys; print sys.executable"
/usr/local/bin/python

$ venv/bin/python -c "import sys; print sys.executable"
/home/bpbook/work/venv/bin/python

$ venv/bin/python

>>> import requests            # 可以从venv环境中导入

$ venv/bin/pip install flask   # 安装到venv环境中
...

因此，当我们想有计划地执行 venv 环境的程序，或者想启动服务器时，只要用完整路径指定 venv 环境的程序，就可以在不执行 activate 命令的状态下完成运行了。

◉ virtualenv 的选项

virtualenv 为用户提供了许多可用选项。我们可以通过 virtualenv --help 来查看选项列表。现在我们选其中一些比较好用的选项来了解一下。

-p、--python

指定virtualenv环境下使用的Python，格式如--python=/usr/local/bin/python2.7。

如果省略该选项，则默认选择执行virtualenv命令的Python。

--system-site-packages

使用当前Python主体上已经安装的程序库。如果省略该选项，则默认忽略Python主体上已经安装的程序库。

--always-copy

无论符号链接是否可用，一概不使用符号链接，而是直接复制文件。即便是允许使用符号链接的OS，在某些文件系统下仍会出现符号链接不可用的情况。此时就可以用到这个选项。

--clear

删除指定的virtualenv环境中安装的依赖库等，初始化环境。

-q、-quiet

执行virtualenv命令时，控制向工作台输出的信息量。

-v、-verbose

执行virtualenv命令时，增加向工作台输出的信息量。

LIST 3.9　virtualenv 的选项示例

$ virtualenv -q --system-site-packages -p /usr/local/bin/python2.7 venv

如果我们经常用到某些选项，可以事先将其写入设置文件。这样一来，我们就不用每次执行命令时都再写一遍了。

在 Linux 系统下，设置文件默认使用 $HOME/.virtualenv/virtualenv.ini。举个例子，如果要在 virtualenv.ini 中指定 LIST 3.9 里的选项，我们可以按照 LIST 3.10 进行描述。

LIST 3.10　virtualenv.ini

[virtualenv]
python = /usr/local/bin/python2.7
quiet = true
system-site-packages = true

另外，还可以在环境变量中指定选项。如果我们同时用环境变量和设置文件指定了某个选项，那么将以环境变量的指定为准。当然，命令行传值参数的指定优先于一切。

环境变量名是根据选项名自动生成的。将选项名的“--”后的字母全改为大写，短线改为下划线，然后在开头加上 VIRTUALENV_，就是该选项所对应的环境变量。请各位在设置环境变量时记住这个规律。

● 示例

--quiet                 -> VIRTUALENV_QUIET=true
--python                -> VIRTUALENV_PYTHON=/usr/local/bin/python2.7
--system-site-packages  -> VIRTUALENV_SYSTEM_SITE_PACKAGES=true

专栏　--system-site-packages 选项

virtualenv 环境不会引用 Python 主体上安装的程序包，也就是 /usr/local/lib/python2.7/sitepackages 目录下的程序包。因此，当我们新建了一个 virtualenv 环境时，这个环境处于干净的初始状态，没有安装任何多余的包。

相反地，如果我们想在 virtualenv 环境中使用 Python 主体上安装的程序包，那么在新建环境时可以加上 --system-site-packages 选项。

不过，一旦加上这个选项，我们就相当于同时使用两个环境，因此很难分辨出程序包在哪里，以及正在使用的是哪个程序包。这会导致 virtualenv 环境的优势大打折扣。所以，除非迫不得已，建议各位尽量不要使用 --system-site-packages 选项。

3.2.2　用 pip 安装程序包

NOTE

我们使用的是 pip 1.5.6。

给 virtualenv 环境额外安装程序包时，需要用该环境的 pip 命令。

pip 是用来安装程序包的命令。既可以经由网络进行安装，也可以直接从本地的程序包文件进行安装。第三方的程序库发布在 PyPI 上。pip 命令会默认从 PyPI 上搜索并安装程序包。

pip 提供了多个子命令。下面便是其子命令列表。

install | 安装程序包（指定包名、包文件名、URL 等）
uninstall | 卸载程序包
freeze | 以 requirements 格式列表输出当前已安装的程序包及其版本
list | 列表显示当前已安装的程序包
show | 列表显示当前已安装程序包的版本信息
search | 按指定关键字在 PyPI 上搜索程序包并显示结果列表
wheel | 根据指定 requirement 构建 wheel 文件
help | 显示帮助

接下来，我们将对 pip 的选项以及安装、卸载方法进行学习。其他详细内容请参考以下网站。

Reference Guide - pip 1.5.6 documentation

https://pip.pypa.io/en/latest/reference/index.html

User Guide - pip 1.5.6 documentation

https://pip.pypa.io/en/latest/user_guide.html#configuration

◉ pip 的选项

pip 的选项有两种，一种是不依赖于子命令的共通选项，另一种是指定给子命令的选项。比如 --quiet 和 --proxy 就是所有命令共通的选项，而 install 子命令则拥有 --upgrade 等自己独有的选项（LIST 3.11）。

这两种选项都需要在执行时通过命令行指定。

LIST 3.11　pip 的选项示例

$ pip --quiet --proxy=server:9999 install --upgrade requests

常用的选项可以事先写在设置文件中。这样可以省去每次都写的麻烦。

Linux 的默认设置文件为 $HOME/.pip/pip.conf。比如我们要在 pip.conf 中指定 LIST 3.11 pip 的选项示例中的选项，则可以按照 LIST 3.12 进行描述。

LIST 3.12　pip.conf

[global]
quiet = true
proxy = server:9999

[install]
upgrade = true

NOTE

上述内容只是个例子，并非推荐设置。各位请严格按照自己的需要设置各个选项。

另外，还可以在环境变量中指定选项。如果我们同时用环境变量和设置文件指定了某个选项，那么将以环境变量的指定为准。当然，命令行传值参数的指定优先于一切。

环境变量名是根据选项名自动生成的。将选项名的“--”后的字母全改为大写，短线改为下划线，然后在开头加上 PIP_，就是该选项所对应的环境变量。请各位在设置环境变量时记住这个规律。

● 示例

--quiet    -> PIP_QUIET=true
--proxy    -> PIP_PROXY=server:9999
--upgrade  -> PIP_UPGRADE=true

专栏　关于指定HTTP 代理

pip 需要使用 HTTP 协议从外部网站获取程序包。在某些企业或环境下，我们需要经由代理才能访问到外部网站。而且有些时候，我们必须输入 ID 和密码才可以使用代理访问外部网站（认证代理）。

在这类环境下使用 pip 时，我们要用 --proxy 选项指定代理，格式为 [user:passwd@]proxy.server:port（LIST 3.13、LIST 3.14）。

LIST 3.13　指定 pip 的代理

$ pip --proxy=proxy.example.com:1234 install requests

LIST 3.14　指定 pip 的认证代理

$ pip --proxy=beproud:passwd@proxy.example.com:1234 install requests

我们也可以像 LIST 3.15 这样，在设置文件或环境变量中指定 --proxy 选项。

LIST 3.15　在 PIP_PROXY 环境变量中指定

$ export PIP_PROXY=proxy.example.com:1234
$ pip install requests

除 PIP_PROXY 环境变量外，我们还可以在 HTTP_PROXY 环境变量中指定 Proxy（LIST 3.16）。

LIST 3.16　在 HTTP_PROXY 环境变量中指定

$ export HTTP_PROXY=proxy.example.com:1234
$ pip install requests

◉ 安装程序包

pip install 是安装程序包的命令。install 子命令可以详细指定“安装什么”“从哪里安装”“如何安装”。因此，我们在看帮助文档时会发现它有很多选项和对象指定方法（LIST 3.17）。

LIST 3.17　pip install 的帮助

$ pip install -h

Usage:
  pip install [options] <requirement specifier> ...
  pip install [options] -r <requirements file> ...
  pip install [options] [-e] <vcs project url> ...
  pip install [options] [-e] <local project path> ...
  pip install [options] <archive url/path> ...

...

从 PyPI 安装程序包的方法如 LIST 3.18 所示。这里，我们可以指定一个或多个程序包。

LIST 3.18　从 PyPI 安装

$ pip install requests
$ pip install flask bottle

如果手边有源码，还可以像 LIST 3.19 这样进行安装。

LIST 3.19　从源码包安装

$ pip install ./logfilter-0.9.2

像上例这样从本地目录安装时，我们需要写明本地路径，如 ./logfilter-0.9.2 或 file://logfilter-0.9.2 等。如果只写 pip install logfilter-0.9.2，计算机会跑到 PyPI 上去找名为 logfilter-0.9.2 的程序包。

从源码安装时，我们可以使用 -e（--editable）选项。这样一来，安装时不会复制源码，而是直接在该目录下原地进行安装。对于一些尚在开发中的源码，用 editable 进行安装可以省去每次修改代码后重新安装的麻烦（LIST 3.20）。由于程序可以在运行时直接使用我们编辑过的代码，所以 editable 也被称为可编辑安装。

LIST 3.20　指定 editable 安装源码目录

$ pip install -e ./logfilter-0.9.2

将版本库 clone 下来并进行安装时，需要在版本库的 URL 前添加例如 hg+ 等版本库类别，然后再执行 pip install（LIST 3.21）。在执行本例的过程中，需要在内部使用 hg 命令，因此必须有一个可以使用 hg 命令的环境。对象为 git 版本库时请将上述 hg 替换为 git。

现在让我们来执行安装。

LIST 3.21　从 hg 上 clone 并安装

$ pip install hg+https://bitbucket.org/shimizukawa/logfilter

如果想在 clone 来的源码上进行开发，我们可以同时加上 -e 选项。此时需要如 LIST 3.22 所示，在 URL 末尾添加 #egg=<< 程序包名 >>。

LIST 3.22　从 hg 上 clone 并 editable 安装

$ pip install -e hg+https://bitbucket.org/shimizukawa/logfilter#egg=logfilter

如果要一次安装多个程序包，比较简便的方法就是使用对象程序包以及描述这些程序包的 requirements 格式的文件。文件名一般使用 requirements.txt。我们可以自己动手准备这个文件，不过一般我们都会用 pip freeze 来输出。requirements.txt 文件用 -r（--requirement）选项指定（LIST 3.23）。

LIST 3.23　通过 requirements.txt 安装

$ pip install -r requirements.txt

使用 pip 时，如果之前已经安装过指定的程序包，计算机并不会自动将其更新为新版本。指定更新到某一版本需要用 -U（--upgrade）选项（LIST 3.24）。

LIST 3.24　用 -U 选项更新版本

$ pip install -U requests

如果不想每次都从 PyPI 下载程序包，我们可以用 --download-cache 选项指定缓存目录（LIST 3.25）。此时，为了检查版本，计算机仍会进行网络通信。如果缓存目录中有我们要用的版本，计算机则会直接用缓存文件进行安装。

LIST 3.25　指定下载缓存

$ pip install --download-cache=~/.pip-cache requests

如果想一直使用缓存目录，建议各位在环境变量中设置该选项（LIST 3.26）。只要维持环境变量中的这一设置，我们就能有效运用缓存。

LIST 3.26　在环境变量中设置下载缓存

$ export PIP_DOWNLOAD_CACHE=~/.pip-cache
$ pip install requests

NOTE

从 pip-6.0 起，下载缓存改为默认有效。指定 --download-cache 选项时会出现无效警告。

◉ 记录程序包一览表

pip freeze 命令用来将已安装程序包及其版本的一览表以 requirements 格式输出。其执行如 LIST 3.27 所示。

LIST 3.27　pip freeze

$ pip freeze
Flask==0.10.1
Jinja2==2.7.3
MarkupSafe==0.23
Werkzeug==0.9.6
argparse==1.2.1
itsdangerous==0.24
wsgiref==0.1.2

NOTE

从 pip-6.0 起，不再显示 Python 标准库 argparse 和wsgiref。

pip freeze 以 requirements 格式输出程序包及其版本的列表。为了方便 pip install 使用这些内容，它们会被保存在 requirements.txt 文件中（LIST 3.28）。这个文件名并不是硬性要求，但大多数情况下我们习惯使用 requirements.txt。

LIST 3.28　将 pip freeze 的输出保存在 requirements.txt 中

$ pip freeze > requirements.txt

这样一来，我们就可以用类似 pip install -r requirements.txt 的方式在其他环境中安装相同版本的程序包了。

如果环境中安装的程序包发生变动，我们可以再次执行 pip freeze > requirements.txt，以更新文件内容。此时，用 -r（--requirement）选项指定原来的 requirements.txt 可以确认更新前后的差别（LIST 3.29）。用这种方法能帮助我们确认是否存在意外更改。

LIST 3.29　pip freeze -r 的结果

$ pip install bottle
$ pip freeze -r requirements.txt
Flask==0.10.1
Jinja2==2.7.3
MarkupSafe==0.23
Werkzeug==0.9.6
argparse==1.2.1
itsdangerous==0.24
wsgiref==0.1.2
## The following requirements were added by pip --freeze:
bottle==0.12.7

专栏　在 requirements.txt 内指定版本库

如果我们在当前环境下用 -e（--editable）安装了版本库的代码，那么执行 pip freeze 后，输出结果如下。

$ pip install -e hg+https://bitbucket.org/shimizukawa/logfilter#egg=logfilter
$ pip freeze
-e hg+https://bitbucket.org/shimizukawa/logfilter@96fd26dae42053c015d3285c
002d45aa5fe6e324#egg=logfilter-dev
Flask==0.10.1
Jinja2==2.7.3
MarkupSafe==0.23
Werkzeug==0.9.6
itsdangerous==0.24
wsgiref==0.1.2

该版本库特定版本的代码会被安装到使用 requirements.txt 进行 pip install 时的环境中。所以各位请注意，安装时会执行版本库 clone 操作，而且无论版本库是否接到了新的提交，安装时都会使用我们指定的版本。

◉ 卸载程序包

pip uninstall 命令用于卸载、删除当前已安装的程序包。其执行方式如 LIST 3.30 所示。

LIST 3.30　pip uninstall

$ pip uninstall flask
Uninstalling flask:
  /home/bpbook/work/venv/lib/python2.7/site-packages/...
  ...
Proceed (y/n)? y
  Successfully uninstalled flask

屏幕上会显示待删除文件的列表，并向用户询问是否真的要删除。此时输入 y 并按下 Enter 键即可完成删除操作。

如果不需要确认，可以加上 -y（--yes）选项，其执行如 LIST 3.31 所示。

LIST 3.31　pip uninstall -y

$ pip uninstall -y flask

请注意，pip uninstall 命令只会卸载我们指定的程序包。比如安装 Flask 时会捆绑安装 Jinja2 等 4 个关联程序包，这些程序包在 Flask 被卸载之后仍会残留在环境中。如果我们不再需要这些程序包，就必须通过 pip uninstall 手动删除它们，或者直接重建 virtualenv 环境，选我们需要的程序包重新安装。

3.2.3　小结

开发 Python 项目时，我们可以用 virtualenv 搭建该项目专用的虚拟环境。有了这个环境，我们使用程序库时就不会影响到其他项目了。项目所需的程序库用 pip 进行安装。用 pip freeze 命令搭建某项目环境的 requirements.txt 文件之后，我们能够轻松重建该项目所需的环境。除此之外，部署等方面也有 pip 和 virtualenv 的用武之地。这两个工具是 Python 开发环境的基础，建议各位牢记它们的使用方法。

3.3　文件结构与发布程序包

编写完程序之后，我们还要面对从封装成包到发布的复杂流程。在本部分中，我们会尝试把第 2 章中开发的留言板应用放到 PyPI 上进行公开，并在此过程中学习一下 setup.py 的写法以及如何向 PyPI 上传程序包等。

3.3.1　编写 setup.py

首先我们来了解一下 setup.py 的功能。Python 的封装离不开 setup.py。将开发完毕的程序封装成包，可以方便其他用户或其他项目拿去用。而封装的绝大部分时间都要消耗在编写 setup.py 上。

我们用 setup.py 来设置 Python 程序包的信息（元数据）、定义程序包。setup.py 这个文件名是 Python 中定好了的，不可以更改。我们要在这个文件中定义程序包名称、包及依赖包的信息等元数据。

有了 setup.py 之后，我们便能在命令行中执行诸如 python setup.py sdist 和 python setup.py install 等与包相关的操作了。另外，将程序包注册到 PyPI 的操作也需要通过 setup.py 进行。

◉ setup.py 的命令

首先，我们来做出一个能运行的 setup.py 空壳（LIST 3.32）。

LIST 3.32　setup.py

from setuptools import setup
setup(name='guestbook')

现在计算机已经可以执行 setup.py 的命令了。各位可以通过 --help-commands 选项查看 setup.py 提供的命令（LIST 3.33）。我们在下面列举了一些有代表性的命令。命令后面的英文注释已经译为了中文。

LIST 3.33　setup.py 的指令一览

$ python setup.py --help-commands
标准命令
  build            构建安装所需的全部内容
  clean            删除 'build' 命令创建的所有临时文件
  install          安装 build 目录下的全部内容
  sdist            创建源码包（以 tar、zip 等格式）
  register         将程序包注册到 Python Package Index
  bdist            创建二进制包
  bdist_dumb       创建 'dumb' 格式的二进制包
  bdist_rpm        创建 RPM 格式的二进制包
  bdist_wininst    创建面向 MS Windows 的安装包
  upload           将二进制包上传至PyPI
  check            检查程序包的设置值是否正确

扩展命令
  develop          以开发模式安装程序包
  setopt           在 setup.cfg 等文件中记录一个选项
  saveopts         将给定的多个选项记录在 setup.cfg 等文件中
  upload_docs      向 PyPI 上传文档
  alias            定义快捷命令
  bdist_egg        创建 'egg' 格式的程序包
  test             原地构建后运行 Unit Test

下面我们来创建最基本的源码包。源码包需要通过 python setyp.py sdist 命令创建（LIST 3.34）。

LIST 3.34　python setup.py

$ python setup.py sdist
running sdist
running egg_info
-( 中间省略)-
warning: sdist: standard file not found: should have one of README, README.rst, README.txt

running check
warning: check: missing required meta-data: url

warning: check: missing meta-data: either (author and author_email) or (maintainer and maintainer_email) must be supplied

creating guestbook-0.0.0
-( 中间省略)-
creating dist
Creating tar archive
removing 'guestbook-0.0.0' (and everything under it)

$ ls dist/
guestbook-0.0.0.tar.gz

现在，dist 目录下已经生成了 guestbook-0.0.0.tar.gz 文件。这个 tar.gz 文件目前只包含 setup.py。

另外，我们在执行过程中看到了几个 warning。这些 warning 指出的项目最好都设置一下。后面我们会学习如何进行设置。

3.3.2　留言板的项目结构

首先，我们来了解一下 Python 项目一般的目录结构。当封装对象只有一个“.py”文件时，其结构如 LIST 3.35 所示。

LIST 3.35　项目内只有一个文件时的结构示例

/home/bpbook/projectname
   +-- MANIFEST.in
   +-- README.rst
   +-- packagename.py
   +-- setup.py

如果封装对象目录下包含多个“.py”或模板等文件，则结构如 LIST 3.36 所示。

LIST 3.36　项目内含多个文件时的结构示例

/home/bpbook/projectname
   +-- MANIFEST.in
   +-- README.rst
   +-- packagename/
   |    +-- __init__.py
   |    +-- module.py
   |    +-- templates/
   |         +-- index.html
   +-- setup.py

关于这方面，我们的留言板应用由下述文件组成。

文件路径

说明

guestbook.py

服务器程序

guestbook.dat

提交数据文件

static/main.css

CSS 文件

templates/index.html

输出 HTML 的模板，用于显示“提交 / 留言列表”的页面

虽然“.py”文件只有一个，但 static 和 templates 目录下都包含文件。由于我们介绍的前一种项目结构无法安装模板等文件，因此这里需要使用后一种项目结构。

文件最终的安排如 LIST 3.37 所示。

LIST 3.37　留言板项目的目录结构

/home/bpbook/guestbook/
   +-- LICENSE.txt
   +-- MANIFEST.in
   +-- README.rst
   +-- guestbook
   |    +-- __init__.py
   |    +-- static/main.css
   |    +-- templates/index.html
   +-- setup.py

现在我们来创建 guestbook 目录，将 guestbook.py 文件移动到该目录下并重命名为“__ init__.py”（init 前后各两个半角下划线）。另外，templates 和 static 目录也要移动到 guestbook 目录下。guestbook.dat 不是我们要发布的东西，所以这里不需要它。

接下来，我们来实际应用这个封装用的结构。

3.3.3　setup.py 与 MANIFEST.in——设置程序包信息与捆绑的文件

接下来我们将在 setup.py 中设置程序包的信息，然后在 MANIFEST.in 中指定捆绑的文件。那么，我们先来按照顺序了解一下。

◉ setup.py

首先，我们像 LIST 3.38 这样描述 guestbook 项目的 setup.py。

LIST 3.38　最低限度内容的 setup.py

from setuptools import setup, find_packages

setup(
    name='guestbook',
    version='1.0.0',
    packages=find_packages(),
    include_package_data=True,
    install_requires=[
        'Flask',
    ],
)

如果一个环境能使用 pip，那么该环境中一定安装了 setuptools 库。虽然用 from distutils.core import setup 这种 Python 标准写法也没有问题，但一般情况下我们习惯使用 setuptools 提供的含有扩展功能的 setup 函数。

下面来了解一下各个参数的意义。

name

程序包的名称。这里我们定为'guestbook'。一般情况下，包名都与项目名称一致。但是，用于发布的程序包需要有一个独特的名称，以防止与其他程序包名撞车。实际上，guestbook这个名称实在不够独特。因此，如果一定要使用这个名称，最好在前面加上组织名等，例如beproud.guestbook。

version

代表版本号的字符串。这里我们定为'1.0.0'。

packages

指定所有捆绑的Python程序包（可以用python命令import的目录名）。举个例子，如果一个项目包含多级目录，那么我们需要用下例所示的方法，列表指定所有程序包。

packages=[
    'guestbook', 'guestbook.server', 'guestbook.server.dir',
    'guestbook.storage', ...
],

find_packages()函数可以自动搜索当前目录下的所有Python程序包并返回程序包名。有了它，我们便可以省去一个个列举的麻烦。

NOTE

如果项目仅由一个“.py”文件构成，那么要用 py_modules 代替 packages 传值参数，在 py_modules 中指定对象模块名。

include_package_data

在packages指定的Python包（目录）中，除“.py”之外的文件都称为程序包资源。这个设置用来指定是否安装Python包中所含的程序包资源。

这里我们要安装templates和static这两个程序包资源，所以将它们指定为True。

不过，这一设置并不能将程序包资源与我们要发布的程序包捆绑在一起。捆绑的方法将在MANIFEST.in中学习。

install_requires

列表指定依赖包。留言板应用要依赖Flask，所以我们在这里指定Flask。与requirements.txt不同，这里一般不指定版本。

◉ MANIFEST.in

为将 HTML 文件、CSS 文件等程序包资源与程序包捆绑在一起，我们需要用 MANIFEST.in 来指定封装对象文件。

这里我们在 setup.py 所在的目录下创建 MANIFEST.in 文件，指定封装对象文件的范围（LIST 3.39）。

LIST 3.39　MANIFEST.in

recursive-include guestbook *.html *.css

recursive-include 表示捆绑指定目录下所有与指定类型一致的文件。以 LIST 3.39 为例，我们捆绑了 guestbook 目录下所有与“*.html”和“*.css”一致的文件。

现在我们希望使用这个程序包的环境能安装这些捆绑好的程序包资源。为此，我们需要将前面提到的 install_package_data 指定为 True，这一点千万不能忘。

MANIFEST.in 还可以指定捆绑 guestbook 应用不使用的非程序包资源文件，比如 LICENSE.txt。在发布程序包时最好把许可文件也捆绑进去。

假设我们使用了 BSD 许可，并在 LICENSE.txt 文件中描述了许可条款。接下来，我们需要在 MANIFEST.in 里添加对它的捆绑指定（LIST 3.40）。

LIST 3.40　MANIFEST.in

recursive-include guestbook *.html *.css
include LICENSE.txt

include 会捆绑所有与指定类型一致的文件。所以添加了 LIST 3.40 中所示的指定语句后，LICENSE.txt 文件就和程序包捆绑在了一起。另外，我们要安装的是 guestbook 目录，而 LICENSE.txt 文件并不在该目录下，所以 LICENSE.txt 文件并不会被安装到使用该程序包的环境中。

MANIFEST.in 有许多种描述方式，不但可以将某个扩展名的文件全部捆绑起来，还可以剔除特定扩展名的全部文件。MANIFEST.in 的详细描述方法请查阅 Python 的参考手册。

Creating a Source Distribution - Python 2.7.12 documentation

https://docs.python.org/2.7/distutils/sourcedist.html

◉ 确认运行情况

为查看前面的设置是否正确，我们需要搭建一个用来开发程序包的 virtualenv 环境并安装该程序包。这里我们用“.venv”作为 virtualenv 环境的目录名（LIST 3.41）。

LIST 3.41　搭建 virtualenv 环境及安装

$ cd ..
$ virtualenv .venv

此时的目录结构如 LIST 3.42 所示。

LIST 3.42　目录结构

/home/bpbook/guestbook/
   +-- .venv/
   +-- LICENSE.txt
   +-- MANIFEST.in
   +-- guestbook
   |    +-- __init__.py
   |    +-- static/main.css
   |    +-- templates/index.html
   +-- setup.py

然后我们启动 virtualenv 环境并执行安装。在安装时请加上 -e（--editable）选项进行原地安装（在原目录下直接转为安装状态）（LIST 3.43）。这样一来，我们在开发过程中就不用每改一次都重新安装一遍了。

LIST 3.43　搭建 virtualenv 环境及 editable 安装

$ source .venv/bin/activate
(.venv)$ pip install -e .
Obtaining file:///home/bpbook/guestbook
  Running setup.py (path:/home/bpbook/guestbook/setup.py) egg_info for package from file:///home/bpbook/guestbook

Installing collected packages: guestbook
-（中间省略：安装依赖包）-
  Running setup.py develop for guestbook

    Creating /home/bpbook/.venv/lib/python2.7/site-packages/guestbook.egg-link(link to .)
    Adding guestbook 1.0.0 to easy-install.pth file

    Installed /home/bpbook/guestbook

-（中间省略）-
Successfully installed guestbook
Cleaning up...

(.venv)$ pip freeze
Flask==0.10.1
Jinja2==2.7.3
MarkupSafe==0.23
Werkzeug==0.9.6
guestbook==1.0.0
itsdangerous==0.24

现在，guestbook-1.0.0 已经安装到 virtualenv 环境中了。我们可以看到，记录程序包元数据位置的 guestbook.egg-link 文件被安装到 virtualenv 环境中了。easy-install.pth 文件中添加了 guestbook 的源码位置。另外，Flask 及其相关程序包也都安装好了。

这样一来，我们在其他 PC 或服务器上构建环境时，就不必再去一个个地安装依赖包了。如果今后需要添加或更改依赖库，各位只要按照前面讲的流程更新 setup.py，然后再执行一次 pip install 即可。

3.3.4　setup.py——创建执行命令

我们在第 2 章开发的留言板是一个直接从 Python 启动的脚本。要想让下载它的人用起来更方便，那最好生成一些用户命令。这里我们通过设置 setup.py，让其自动生成 guestbook 命令（LIST 3.44）。

LIST 3.44　让 setup.py 生成命令

from setuptools import setup

setup(
    name='guestbook',
    version='1.0.0',
    packages=find_packages(),
    include_package_data=True,
    install_requires=[
        'Flask',
    ],
    entry_points="""
        [console_scripts]
        guestbook = guestbook:main
    """,
)

我们在 setup.py 中添加了 entry_points。这样一来，在安装程序包时就会自动生成 guestbook 命令。用户执行 guestbook 命令时将会调用 guestbook 模块的 main 函数。

但是 guestbook/__init__.py 中还没有 main 函数，所以我们需要添加这个函数，具体代码如 LIST 3.45 所示。

LIST 3.45　guestbook/__init__.py

   ⋮
   ⋮

def main():
    application.run('127.0.0.1', 8000)
if __name__ == '__main__':
    # 在IP 地址127.0.0.1 的8000 端口运行应用程序
    application.run('127.0.0.1', 8000, debug=True)

然后我们再次执行安装命令，看看是否能生成 guestbook 命令。

即便是在 editable 安装的状态下，如果想反映出对元信息进行的修改（比如添加命令、更改依赖库等），也需要重新执行一次安装命令（LIST 3.46）。

LIST 3.46　重新安装

(.venv)$ pip install -e ./guestbook
-（中间省略）-
Successfully installed guestbook
Cleaning up...

(.venv)$ ls .venv/bin/guestbook
guestbook

(.venv)$ guestbook
 * Running on http://127.0.0.1:8000/
 * Restarting with reloader

可以看到，guestbook 命令已经成功生成，而且可以正常运行了。

3.3.5　python setup.py sdist——创建源码发布程序包

创建用于发布的程序包时，需要如 LIST 3.47 所示，执行 python setup.py sdist 命令。

LIST 3.47　python setup.py sdist

$ python setup.py sdist
running sdist
running egg_info
writing requirements to guestbook.egg-info/requires.txt
writing guestbook.egg-info/PKG-INFO
writing top-level names to guestbook.egg-info/top_level.txt
writing dependency_links to guestbook.egg-info/dependency_links.txt
reading manifest file 'guestbook.egg-info/SOURCES.txt'
reading manifest template 'MANIFEST.in'
writing manifest file 'guestbook.egg-info/SOURCES.txt'
running check
creating guestbook-1.0.0
-( 中间省略)-
making hard links in guestbook-1.0.0...
hard linking LICENSE.txt -> guestbook-1.0.0
-( 中间省略)-
Creating tar archive
removing 'guestbook-1.0.0' (and everything under it)

$ ls dist/
guestbook-1.0.0.tar.gz

这样，我们就在 dist 目录下生成了 guestbook-1.0.0.tar.gz。这个 tar.gz 文件中包含 guestbook/__init__.py、setup.py、LICENSE.txt、HTML、CSS 等文件。

现在只要将这个文件放到我们想安装应用的环境中，就可以运行 pip install guestbook-1.0.0.tar.gz，直接从文件进行安装了。

3.3.6　提交至版本库

我们先将前面的内容提交到版本库。关于 hg 命令的操作，第 1 章和第 6 章中有详细介绍。

目前的目录结构如 LIST 3.48 所示。

LIST 3.48　留言板项目的目录结构

/home/bpbook/guestbook/
   +-- .venv/
   +-- LICENSE.txt
   +-- MANIFEST.in
   +-- guestbook
   |    +-- __init__.py
   |    +-- static/main.css
   |    +-- templates/index.html
   +-- guestbook.dat
   +-- guestbook.egg-info/
   +-- setup.py

开发 Python 项目时，我们习惯将 setup.py 放在版本库最初级目录（根目录）下。这样我们就能用 pip 直接从版本库进行安装了。

另外，有些文件和目录是不用保存到版本库中的。guestbook.dat 文件的作用是记录留言板接收到的数据，这些数据没必要记录到版本库里。

guestbook.egg-info 目录的作用是记录程序包的元数据。元数据将在执行 pip install -e . 时自动生成。如果缺乏元数据，editable 安装可能无法正常进行。不过，由于它是在安装时自动生成的，所以也不用保存到版本库。

“.venv”也可以重新生成，因此不必保存到版本库。

接下来，我们需要将除上述三者以外的文件提交给版本库（LIST 3.49）。

LIST 3.49　注册到版本库

$ cd ~/guestbook
$ hg init
$ hg add LICENSE.txt MANIFEST.in guestbook setup.py
$ hg ci -m "initial"

另外，如果在目前的状态下执行 hg status 命令，刚才那些不需要上传的文件和目录仍会显示为非管理对象文件。我们需要在“.hgignore”文件中添加设置，将这些不需要管理的文件剔除出显示对象（LIST 3.50）。

LIST 3.50　.hgignore

.*.egg-info
^guestbook.dat$
^.venv$

“.hgignore”文件也要提交上去（LIST 3.51）。这样一来，在其他环境中使用 clone 的版本库时也就不会显示这些文件了。

LIST 3.51　提交“.hgignore”

$ hg add .hgignore
$ hg ci -m "add ignore list"

我们先暂且将其 push 到版本库服务器上。各位请在 Bitbucket 上创建一个空的 guestbook 项目，然后执行 LIST 3.52 所示的命令。

LIST 3.52　hg push

$ hg push https://bitbucket.org/< 你的Bitbucket 账户>/guestbook

建议各位今后适时地将添加、修改过的源码提交到版本库中。

专栏　Bitbucket

Bitbucket 是 Mercurial 的版本库服务器。Bitbucket 为用户提供了许多免费功能，可以管理 Mercurial 和Git 版本库。

各位请先注册账户，创建空的 Mercurial 版本库，然后执行 LIST 3.53 所示的命令，完成 push 操作（本例中的用户名为 beproud，版本库名为 guestbook）。

LIST 3.53　hg push 示例

$ hg push https://bitbucket.org/beproud/guestbook

3.3.7　README.rst——开发环境设置流程

下面我们来描述设置流程说明书，总结该留言板应用开发环境的搭建流程。我们前面讲到的流程如下。

① clone 项目的版本库

② 搭建项目专用的 virtualenv 环境

③ 在 virtualenv 环境内执行 pip install <directory>（如果用于开发，则执行 pip install -e <directory>）

LIST 3.54　设置流程

$ hg clone https://bitbucket.org/beproud/guestbook
$ cd guestbook
$ virtualenv .venv
$ source .venv/bin/activate
(.venv)$ pip install .
(.venv)$ guestbook
 * Running on http://127.0.0.1:5000/

我们来把 LIST 3.54 中的流程原封不动地写入 README.rst。扩展名为 .rst 的文件是用 reStructuredText（reST）语法描述的文本文件。一般说来，Python 项目都会选用 reST 语法来写 README.rst。关于 reST 语法，我们将在第 7 章中详细了解。

通常，README.rst 包含 LIST 3.55 所示内容即可。

LIST 3.55　README.rst

===================
留言板应用
===================

目的
=====

练习开发通过 Web 浏览器提交留言的 Web 应用程序

工具版本
====================

:Python:     2.7.8
:pip:        1.5.6
:virtualenv: 1.11.6

安装与启动方法
=======================

从版本库获取代码，然后在该目录下搭建 virtualenv 环境::

    $ hg clone https://bitbucket.org/beproud/guestbook
    $ cd guestbook
    $ virtualenv .venv
    $ source .venv/bin/activate
    (.venv)$ pip install .
    (.venv)$ guestbook
     * Running on http://127.0.0.1:5000/

开发流程
=========

用于开发的安装
------------------

1. 检测
2. 按以下流程安装

      (.venv)$ pip install -e .

写完之后要记得将 README.rst 文件提交到版本库。

各位请注意，我们在安装流程中写的是直接安装，但在开发流程中写的却是用 pip install -e 进行安装。而关于这二者的区别，我们并没有在 README.rst 文件中提及。这是因为我们认为阅读这篇文档的人应该懂得如何使用 pip 的 -e 选项，知道有它和没它的不同。使用普及率较高的工具或选项的优势就在于此。即便我们阅读文档时不知道它是什么，也能立刻查到相关资料，或者根据类似知识进行摸索。

专栏　缩短、定型化环境的搭建流程

时间一久，就算是自己开发的项目，我们也会忘记如何搭建运行环境。所以为了将来不忘记，我们最好在文档的开头就记下运行程序之前所需的全部流程。另外，尽量能让自己在看文档时立刻回想起当时用了什么流程。因此，流程要尽量短，而且要用开发者们普遍采用的结构。

以常用命令定型化的简洁流程具有以下优势。

不容易出现键入错误、流程颠倒等人为失误

减少整个项目中需要记忆的东西

需要向其他开发者或使用者传递的信息更少，减少文档量

测试和部署更容易自动化

3.3.8　变更依赖包

留言板的依赖包是 Flask。但是，我们很难在开发初期就确定好一款应用程序内的所有依赖包，有些时候还会放弃当前的包而改用其他的。特别是周期短、发布频繁的项目，往往每发布一次都会变更一次依赖包。

举个例子，假设我们放弃 Flask 改用 Bottle。这时如果直接用 pip 命令安装了 Flask 或 Bottle，那就必须将这一步骤告知其他开发者甚至是未来的自己（LIST 3.56）。

LIST 3.56　用 pip 替换了程序包，这一步该如何告知其他人

(.venv)$ pip uninstall flask
(.venv)$ pip install bottle

留言板的 setup.py 里记录着依赖包的信息，因此我们只需更改 setup.py 的设置即可。如果改写了 setup.py 的 install_requires 行，需要再次执行 pip install -e .。

这一步骤的命令和安装时的命令一样，因此不需要修改流程说明书。只要其他新建项目环境的开发者执行了 pip install -e . 命令，就能安装好该项目所需的全部程序包。

不过，还有一点需要注意。那就是，即使我们从 setup.py 中删除了 flask，之前安装到环境中的 Flask 及其关联程序包也不会被卸载。要想删除已经无用的程序包，需要用 virtualenv --clear . 等方法重建环境（LIST 3.57）。

LIST 3.57　重建环境

(.venv)$ virtualenv --clear .venv  # 删除.venv 环境内的全部依赖库
(.venv)$ pip install -e .          # 根据./setup.py 安装依赖库

这一处理会重新安装依赖库，所以运行时将占用较长时间。

NOTE

建议各位设置 pip 的 --download-cache 选项，缩短下载时间（pip-6.0 之前的版本）。使用第 9 章中介绍的 wheelhouse 能进一步加快速度。

NOTE

关于如何固定开发环境中安装的程序包的版本，各位请参考第 9 章。另外，第 9 章还会讲解强制指定依赖包范围的相关知识。

另外，最好在 README.rst 中添加 LIST 3.58 所示的流程。

LIST 3.58　README.rst

开发流程
=========

变更依赖库时
---------------------

1. 更新 ``setup.py`` 的 ``install_requires``
2. 按以下流程更新环境::

      (.venv)$ virtualenv --clear .venv
      (.venv)$ pip install -e ./guestbook

3. 将 setup.py 提交到版本库

3.3.9　通过 requirements.txt 固定开发版本

前面我们介绍了用 setup.py 管理依赖包的方法。实际上，我们还可以用 requirements.txt 管理依赖库。

setup.py 是在 PyPI 上发布程序包时必不可少的组成部分，而且安装时的依赖库也需要用 setup.py 的 install_requires 来指定。这种情况下，由于使用该包的各个环境大不相同，所以我们不能严格指定依赖库的版本，只能指定最低需求。当然，我们还可以手动编辑 requirements.txt，免除指定版本的工作。但要知道，pip install guestbook 是不会引用 requirements.txt 的，就算我们将 requirements.txt 与发布的程序包捆绑在了一起，计算机仍然不会自动安装依赖库。

相反，如果我们的项目不需要封装，只是被拿来当作一个 Web 应用在服务器上发布，就没必要使用 setup.py 了。在项目从开发到正式上线的过程中，有许多程序库和应用程序要一个版本用到底，因此我们要严格地指定版本。而对于不需要发布也不需要封装的项目，setup.py 就失去了用处。对这些项目而言，用 requirements.txt 效率更高。

创建 requirements.txt 的命令如 LIST 3.59 所示。

LIST 3.59　创建 requirements.txt

(.venv)$ pip freeze > requirements.txt

requirements.txt 中记载着当前环境内已安装的所有程序包及明确的版本号（LIST 3.60）。

LIST 3.60　requirements.txt

Flask==0.10.1
Jinja2==2.7.3
MarkupSafe==0.23
Werkzeug==0.9.6
guestbook==1.0.0
itsdangerous==0.24

用 setup.py 管理依赖包时，我们只写了 Flask 但没有指定版本。这是 setup.py 管理和 requirements.txt 管理的一大区别。

要想在其他环境安装同样的程序包们，我们需要将这个 requirements.txt 文件放到该环境下，然后用如 LIST 3.61 所示的方法安装。

LIST 3.61　用 requirements.txt 进行安装

(.venv)$ pip install -r requirements.txt

这样一来，环境中就安装了同样版本的程序包。

现在将创建好的 requirements.txt 文件也提交到版本库。另外，当我们变更依赖包时要记得更新这个文件。请各位打开 README.rst 文件，将变更依赖库时的流程更新成如 LIST 3.62 所示的内容。

LIST 3.62　README.rst

开发流程
=========

变更依赖库时
---------------------

1. 更新 ``setup.py`` 的 ``install_requires``
2. 按以下流程更新环境::

      (.venv)$ virtualenv --clear .venv
      (.venv)$ pip install -e ./guestbook
      (.venv)$ pip freeze > requirements.txt

3. 将 setup.py 和 requirements.txt 提交到版本库

在这个流程中，依赖包同时被 setup.py 和 requirements.txt 两个文件管理着。至于该用 setup.py 管理还是 requirements.txt 管理，要视项目的公开方式或使用方式而定。

3.3.10　python setup.py bdist_wheel——制作用于 wheel 发布的程序包

接下来，我们制作 wheel 程序包。wheel 程序包的使用方法在 9.1 节有详细讲解。

制作 wheel 程序包之前，我们先安装 wheel（LIST 3.63）。

LIST 3.63　安装 wheel

$ pip install wheel
Downloading/unpacking wheel
  Downloading wheel-0.24.0-py2.py3-none-any.whl (63kB): 63kB downloaded
  Installing collected packages: wheel
  Successfully installed wheel
  Cleaning up...

安装完成之后，我们就可以用 bdist_wheel 命令了。接下来，我们执行 python setup.py bdist_wheel 来生成 wheel 程序包（LIST 3.64）。

LIST 3.64　生成 wheel 程序包

$ python setup.py bdist_wheel
running bdist_wheel
-( 中间省略)-
creating _build/bdist.linux-x86_64/wheel/guestbook-1.0.0.dist-info/WHEEL

$ ls dist/
guestbook-1.0.0-py2-none-any.whl   guestbook-1.0.0.tar.gz

执行完之后，dist 目录下就会生成 guestbook-1.0.0-p2-none-any.whl。这个扩展名为“.whl”的文件就是 wheel 程序包。在 wheel 程序包内，按照安装后的目录结构捆绑了源码和各种文件。与源码程序包不同，它里面没有 setup.py。

现在只要将这个文件复制到等待安装的环境中，我们就可以执行 pip install guestbook-1.0.0-p2-none-any.whl，直接从文件进行安装了。由于这时不需要运行 setup.py，所以会比源码程序包的安装速度快出一大截。

专栏　Universal Wheel：同时支持 Python2 和 Python3 的 wheel 程序包

我们将Python2 系列和 Python3 系列都可以用的 wheel 程序包称为 Universal Wheel。

刚才我们生成的程序包是 guestbook-1.0.0-p2-none-any.whl，从名字上就可以看出，这个程序包是对应 Python2 的。由于 guestbook 同时支持 Python3，所以我们在 Python3 下执行上述流程时，会生成名为 guestbook-1.0.0-p3-none-any.whl 的 wheel 文件。

如果想生成 Universal Wheel 程序包，就需要在 bdist_wheel 后面加上 --universal 选项，代码如下。

$ python setup.py bdist_wheel --universal
--(中间省略)--
$ ls dist
guestbook-1.0.0-py2.py3-none-any.whl

当程序包仅由纯 Python 代码实现时，会生成 Universal Wheel。而在诸如需要二进制构建、Python 实现方面受限等情况下，则无法生成 Universal Wheel。

3.3.11　上传到 PyPI 并公开

我们之所以能用 pip 命令安装指定的程序包，是因为这些包都被注册到了 PyPI 上。PyPI 是 Python 的官方网站，所有人都能随意上传及下载 Python 程序包。如果各位不介意公开自己开发的程序包，不妨将它注册到 PyPI 上。

举个例子，如果我们要安装一个已经在 PyPI 上注册的程序包 bpmappers，那么只需执行 pip install bpmappers 即可。

PyPI 的作用相当于一台负责分发程序包的中央服务器。不过，注册到 PyPI 的程序包无法只对特定用户公开，所以各位要千万注意，别把对外保密的程序库注册上来。

NOTE

在这种情况下，我们可以在公司内部准备一台 PyPI 交换服务器，或者找其他等价的方法。这类方法我们将在第 9 章中详细介绍。

下面我们就把已做好的程序包文件注册到 PyPI。如果想在实际注册之前先注册到测试服务器，可以参考本节的专栏“PyPI 的测试服务器”。

执行 register 命令，注册 guestbook 程序包（LIST 3.65）。

LIST 3.65　注册程序包

$ python setup.py register

如果发生下述情况，执行 register 命令时会被询问是否拥有 PyPI 账户。

该环境第一次执行 register 命令

保存账户信息的 .pypirc 文件无效

发生上述情况时，我们会接到如 LIST 3.66 所示的账户询问信息。

LIST 3.66　注册程序包时的账户询问

$ python setup.py register
running register
...
We need to know who you are, so please choose either:
1. use your existing login,
2. register as a new user,
3. have the server generate a new password for you (and email it to you), or
4. quit
Your selection [default 1]:

如果各位已经有 PyPI 账户，请选 1 并输入用户名和密码。如果没有，则需要先去 PyPI 网站创建账户之后再选 1，要么就是直接选择 2 或 3。这步操作会认证我们的 PyPI 账户，只要认证成功，我们就可以使用 register 和 upload 命令操作 PyPI 了。

专栏　在.pypirc 上保存密码时的注意事项

在输入完用户信息、即将完成注册时，系统会询问是否将登录信息保存在主目录的“.pypirc”文件中。如果输入Y，计算机会自动生成“.pypirc”文件，以纯文本形式保存用户名和密码。因此，我们最好采取一些对策（比如设置权限等），防止“.pypirc”文件的内容被第三者窃取。

另外，从 Python 2.7 开始，我们可以用编辑器将保存后的“.pypirc”文件的 password 栏设置为空栏。此后再进行上传时，只要用 register upload 命令代替单独的 upload 命令，就可以做到仅执行时验证密码。

完成注册之后，就可以向 PyPI 上传程序包了。执行下述命令之后，源码程序包就会被上传至 PyPI。

$ python setup.py sdist bdist_wheel upload

刚才我们单独执行了 sdist 命令和 bdist_wheel 命令。其实如上例所示，只要在命令末尾指定 upload 命令，就可以在封装 sdist 和 bdist_wheel 程序包之后直接将它们设为上传对象。

绝大部分情况下，一个项目每次发布的程序包类型都基本一致。因此，我们可以把注册新版本、构建程序包、上传这一系列流程整合成一个命令。整合多条命令时，需要用到 alias 功能，代码如下。

$ python setup.py alias release register sdist bdist_wheel upload
$ python setup.py release

alias 命令会把设置保存在 setup.cfg 中，所以我们要把这个文件也提交到版本库里。共享 alias 可以一定程度上避免项目其他成员在发布时出现失误。

专栏　PyPI 的测试服务器

如果严格执行本书中的流程，各位的 guestbook 程序包就会被实际注册到 PyPI 上。但这毕竟是一个练习，我们不建议各位向 PyPI 服务器注册这些练习性质的东西（请在 PyPI 网站搜索 printer）。

所以，请各位在练习时使用 PyPI 的测试服务器 TestPyPI2。TestPyPI 是一个对所有人开放的服务器，专门用来供人们做实验。我们可以用它来练习向 PyPI 上传程序包以及从 PyPI 下载程序包。

具体使用方法请参考 TestPyPI 的说明页面3。

2https://testpypi.python.org/pypi

3https://wiki.python.org/moin/TestPyPI

◉ 描述程序包的详细信息

上传完成后，PyPI 会给该程序包分配一个 URL。例如 guestbook 程序包的 URL 是 https://pypi.python.org/pypi/guestbook。另外，setup.py 的 long_description 传值参数所指定的内容将会显示在 PyPI 页面上。

以刚才编写完成的 setup.py 为例来看，我们会发现 PyPI 页面上只显示了下载文件的列表，并没有任何详细说明。这是因为我们并没有指定 long_description。要知道，除了下载列表之外，还有很多对使用者有帮助的信息，所以我们应该将这些信息描述在 setup.py 中，让人们能在 PyPI 上看到它们。下面是一个描述示例。

import os
from setuptools import setup, find_packages

def read_file(filename):
    basepath = os.path.dirname(os.path.dirname(__file__))
    filepath = os.path.join(basepath, filename)
    if os.path.exists(filepath):
        return open(filepath).read()
    else:
        return ''

setup(
    name='guestbook',
    version='1.0.0',
    description='A guestbook web application.',
    long_description=read_file('README.rst'),
    author='< 你的名字>',
    author_email='< 你的邮箱地址>',
    url='https://bitbucket.org/< 你的Bitbucket 账户>/guestbook/',
    classifiers=[
        'Development Status :: 4 - Beta',
        'Framework :: Flask',
        'License :: OSI Approved :: BSD License',
        'Programming Language :: Python',
        'Programming Language :: Python :: 2.7',
    ],
    packages=find_packages(),
    include_package_data=True,
    keywords=['web', 'guestbook'],
    license='BSD License',
    install_requires=[
        'Flask',
    ],
    entry_points="""
        [console_scripts]
        guestbook = guestbook:main
    """,
)

这里添加的项目有以下 4 个。

long_description

可描述多行说明。说明内容按照reStructuredText(reST)语法进行描述，PyPI会将其自动转换为HTML并显示在网站上。long_description的内容大多和README.rst相同。即便不同，我们也建议各位将长达几行的说明文章存放在其他文件中。在上面的例子里，我们设置了让long_description读取README.rst文件。

classifiers

从trove classifiers定义的项目中选取适当项目，以列表的形式列举在这里。这个列表包含的项目就是程序包在PyPI上的分类。用户可以在PyPI网站上通过分类筛选来寻找自己想要的程序包。

在上面的例子里，我们描述了许可证信息和Python版本等。如果我们想指定的分类并不在trove classifiers之中，那么指不指定它都无所谓。

keywords

以列表形式列举出易于搜索的单词，或者让使用者一眼就明白意思的词汇。

license

可随意描述许可证信息。前面我们只能用trove classifiers定义过的值来指定classifiers，但 license处却可以指定任意字符串。在上例中，我们将这部分描述为BSD License。

这里只对一部分会显示在 PyPI 上的 setup 函数的传值参数进行了介绍，此外还有许多这里并未提及的传值参数。

Python 官方文档

https://docs.python.org/2.7/distutils/setupscript.html#additional-meta-data

◉ 检查 setup 函数内指定的参数

将程序包实际上传到 PyPI 之后，我们需要打开 PyPI 页面查看一下效果。如果发现页面并没有将 long_description 的内容转换为 HTML，而是直接显示了 reST 文本，那么很可能是我们的 reST 描述出现了错误。为回避这一问题，最好在 upload 之前查一遍错。查错时可以用 docutils 附属的 rst2html.py 命令。docutils 是一个文字处理工具，它能将 reST 文本转换成其他多种格式。其安装方法如下。

$ pip install docutils

安装好 docutils 后，用下述方法执行 rst2html.py 命令，查看文档内是否有错误描述。

$ python setup.py --long-description | rst2html.py > /dev/null

另外，如果各位使用的是 Python 2.7 以上的版本，并且环境中安装了 docutils，那么可以用下述简短的命令来查错。文档没有问题的情况下只会显示 running check，有问题则会显示类似下例的内容。

$ python setup.py check -r -s
running check
warning: check: No directive entry for "spam" in module "docutils.parsers.rst.languages.en".
Trying "spam" as canonical directive name. (line 19)

warning: check: Could not finish the parsing.

error: Please correct your package.

check 命令用于检查 setup 函数各参数的设置是否正确。如果参数遗漏或指定有误，系统会报错或者发出警告。我们只要给 check 命令指定几个选项，就可以检查 long_description 中指定的文本是否符合 reStructuredText 语法了。

这个检测命令也可以添加到我们前面提过的 alias 设置当中，具体如下例所示。

$ python setup.py alias release check -r -s register sdist bdist_egg upload

这样一来，在我们执行 python setup.py release 命令时，系统就会先进行 check。一旦 check 发现问题，后续的 register 命令将不再执行，有问题的程序包也就不会被发布出去了。

专栏　在 PyPI 上公开

不知各位有没有这样一种感觉：在 PyPI 上公开了程序包的 Python 工程师都是大牛！

“写好程序之后，先整理成 Python 的标准发布形式，然后公开到 PyPI 上，让全世界人拿去用！”这话说起来容易，但真正做起来时，许多人会不禁感到犹豫。原因主要有两个，一来是不知道怎么生成标准的发布包，二来是认为自己写的程序别人拿去也没什么用。当然可能还有心理上的抗拒，觉得 PyPI 上面满满的都是世界顶尖好用的程序，自己写的程序难登大雅之堂，没资格发布在 PyPI 上。

那么，我们回过头来想一下，在 PyPI 上公开程序包的最大动力又是什么呢？这个问题显然因人而异，但绝大多数人的出发点无外乎“希望得到程序的反馈信息”“希望能帮到别人”“向 Python 工程师同行们炫耀一下”“想听到别人的夸赞”之类。另外，我们平时也会隔三差五地向 PyPI 上传几个程序包，而一直支持我们的动机则是“想为组织或企业做宣传”“想让大家知道，让大家拿去用”“很多自己平时用的 OSS 都来自这里，所以想把自己做出的成果也放在这上面，为 OSS 界做一份贡献”。

在公开程序包时，“保证品质”“整理文档”“进行测试”这三项工作必不可少。这里，“不想让全世界的工程师看自己出丑”的心理因素只是一小方面，更大的原因是这个阶段能完善我们的程序库，使其回到一个干净的状态，避免在日常开发的过程中混入一些多余功能。当然，公开后获得的反馈也是其魅力之一。

不但个人编写的程序如此，工作中开发出来的程序同样是这个道理。拿我们 BePROUD 来说，bpmappers 和 bpssl 就是例子。因为它们向一般公众公开，所以要维持适当的功能和文档。我们同时还收到了公司内外两方面的反馈，这让我们能不断作出改进。

所以，让我们都来做在 PyPI 上公开了程序包的 Python 工程师大牛吧。这对技术上的要求并没有各位想象中那么高。

3.3.12　小结

发布采用 Python 编写的程序库或应用程序时，最标准的方法就是用 setup.py 进行封装。经 setup.py 封装后的发布包要注册到 PyPI 上，然后其他用户就可以通过 pip 轻松地安装了。另外，对于一些不打算公开的程序，我们也建议将其封装成可发布的状态，这样既能方便地放到其他环境下试运行，又能便于其他项目拿去重复利用。此外还要养成一个习惯，即在 README.rst 文件中写明项目概要、运行方法、设置等信息，以便重复利用程序包。

如果各位还想进一步了解 setup.py 的写法或封装的相关知识，可以参考 Python Packaging User Guide。

Python Packaging User Guide

https://packaging.python.org/en/latest/

3.4　小结

本章我们学习了 Python 项目的结构，以及如何对使用 PyPA 工具的开发环境进行设置。另外，我们对第 2 章编写的程序进行了结构调整，同时学习了如何把程序包上传至 PyPI。

对于要经常重建环境的情况（例如自动测试或向服务器部署）而言，本章介绍的上传流程更能发挥其效果。另外，从下一章开始，我们的介绍重点将从个人开发转移到团队开发，而这里学习的流程对团队开发同样有效。

第 2 部分　团队开发的周期

在第 2 部分中，我们将学习团队开发中必备的思路，以及一些支持团队开发的技术和工具。本部分以多人同时作业为前提，内容涉及多人协同开发所必须的堆栈及源码共享、文档的编写、单元测试、封装、持续集成等方面。

第 4 章　面向团队开发的工具

第 5 章　项目管理与审查

第 6 章　用 Mercurial 管理源码

第 7 章　完备文档的基础

第 8 章　模块分割设计与单元测试

第 9 章　Python 封装及其运用

第 10 章　用 Jenkins 持续集成

第 4 章　面向团队开发的工具

前面我们用 3 章的篇幅学习了个人如何搭建开发环境以及开发应用。独立开发的时候，保存源码等工作完全可以按照自己喜欢的方法来，而且各种信息和点子直接写到自己的笔记本上就好。

那么，一个由多人组成的团队应该如何进行开发呢？首先，开发出来的成品需要在所有成员间共享。然后，还要有一个用来测试开发成品的环境。另外，需求和技术等信息也必须共享给所有成员，而且成员间交流的场所也必不可少。

本章将以提高团队开发效率为目的，为各位介绍团队开发环境的搭建过程以及一些有用的工具。

4.1　问题跟踪系统

要想让团队开发顺风顺水，首先就要把握每个开发任务的负责人是谁、开发进展到了什么程度等信息，并把这些信息整理起来。在这类任务管理工作上，问题跟踪系统（Issue Tracking System，ITS）是一把好手。

我们可以将开发中的任务添加到问题跟踪系统，然后用该系统来追踪、管理这些任务的状态。多数问题跟踪系统会以问题（Ticket）为单位进行项目管理。一般情况下，我们会把工作任务以问题的形式添加到问题跟踪系统，而不是单纯拿它来管理项目。

问题跟踪系统可以给问题添加状态（新建、进行中、解决、结束、驳回等），设置优先级（紧急、高、普通、低）、负责人、日期等，并且允许用户通过搜索查看信息一览，让我们能随时把握项目的开发情况。

将作业分割成一个个任务，再将任务分配给问题进行管理，这种开发手法称为问题驱动开发（Ticket Driven Development，TiDD）。它与敏捷开发有着很好的相容性，因此在近年来的开发中人们逐渐开始实践这种手法。关于问题跟踪系统的实际运用以及问题驱动开发的相关知识，我们将在第 5 章中进行学习。

4.1.1　Redmine

接下来，我们来了解一款问题跟踪系统——Redmine1。Redmine 是一款开源的问题跟踪系统，它以管理项目内的任务和 Bug 的问题功能为中心，兼具服务于团队开发的功能，比如与 Wiki 和版本控制系统的联动等。

1http://www.redmine.org/

4.1.2　安装Redmine

现在我们来安装 Redmine。首先要安装的是 Web 服务器（Apache）和 Passenger（运行 Rails 应用所需的 Apache 模块）（LIST 4.1）。

LIST 4.1　安装 Apache

$ sudo apt-get install -y apache2 libapache2-mod-passenger

然后安装数据库 MySQL（LIST 4.2）。

LIST 4.2　安装 MySQL

$ sudo apt-get install -y mysql-server mysql-client

在安装 MySQL 的过程中，需要设置 MySQL 的 root 用户密码（图 4.1、图 4.2）。这个密码在随后安装 Redmine 时会用到。

图 4.1　MySQL 的密码设置

图 4.2　MySQL 的密码设置（确认）

接下来安装 Redmine（LIST 4.3）。

LIST 4.3　安装 Redmine

$ sudo apt-get install -y redmine redmine-mysql

安装过程中会显示数据库设置的相关界面，请选择“是”（图 4.3）。

图 4.3　数据库设置

下一步选择我们要使用的数据库。这里选刚才安装的 MySQL（图 4.4）。

图 4.4　选择数据库

输入 MySQL 数据库管理员的密码。这里输入刚刚安装 MySQL 时设置的密码（图 4.5）。

图 4.5　管理员密码输入界面

最后设置 Redmine 专用的 MySQL 应用密码。设置加上确认，密码总共要输入两遍（图 4.6、图 4.7）。

图 4.6　设置 MySQL 应用密码

图 4.7　确认 MySQL 应用密码

至此，Redmine 安装完毕。

4.1.3　Redmine 的设置

要想通过 Web 浏览器访问 Redmine，需要更改 Apache 的设置，所以接下来我们修改 Apache 的设置文件。LIST 4.4 中的设置将这个 Web 服务器设置成了 Redmine 专用。

LIST 4.4　修改 Apache 的设置文件

$ sudo vi /etc/apache2/sites-available/000-default.conf

替换 000-default.conf 的如下部分（LIST 4.5）。

LIST 4.5　000-default.conf 的修改

DocumentRoot /var/www/html
↓
DocumentRoot /usr/share/redmine/public

Redmine 需要一个名为 bundler 的工具，以管理 Gem 包，所以我们还需要安装 bundler（LIST 4.6）。

LIST 4.6　安装 bundler

$ sudo gem install bundler --no-rdoc --no-ri

为保证我们能通过 Apache 对 redmine 目录下执行写入操作，需要修改文件的所有权（LIST 4.7）。

LIST 4.7　变更文件的所有权

$ sudo chown -R www-data:www-data /usr/share/redmine

至此，所有设置结束，我们重新启动 Apache（LIST 4.8）。

LIST 4.8　重启 Apache

$ sudo service apache2 restart

如果要把 Redmine 安装在客 OS 上，然后通过本地环境（主 OS）的 Web 浏览器浏览界面，则必须设置端口转发。方法与附录 B.2 一样，需要在 VirtualBox 的端口转发设置中添加一行设置：主机端口（TCP）8000、子系统端口 80（图 4.8）。

图 4.8　VirtualBox 端口转发设置

现在各位可以打开浏览器访问 http://127.0.0.1:8000/，看看是否能显示出 Redmine 的界面。如果全部设置正确，各位应该会看到如图 4.9 所示界面。

图 4.9　Redmine 的初始界面

怎么样，看到 Redmine 的界面了吗？

在初始状态下，管理员只要在用户名和密码处都填入 admin 即可登录。请各位登录管理员用户，修改 Redmine 的各项设置。

4.1.4　插件

Redmine 有多种用来扩展功能的插件。下面我们挑几个比较好用的来了解一下。

◉ Redmine reStructuredText Formatter

Redmine reStructuredText Formatter2 用来将问题和 Wiki 的语法从标准 Textile 转换为 reStructuredText（reST）。

2https://github.com/ebrahim/redmine_restructuredtext_formatter

具体使用哪种语法，我们可以按照团队的喜好来定。用 reST 描述的好处在于方便用 Sphinx 整理文章。我们将在第 7 章中学习 Sphinx 的相关内容。

◉ SCM Creator plugin

使用 SCM Creator plugin3 之后，我们就可以通过 Redmine 给项目创建版本库了。

3http://www.redmine.org/plugins/redmine_scm

这个插件的安装和设置请参考 4.2 节。

◉ Slack chat plugin

导入 Slack chat plugin4 之后，Slack 聊天系统将能够接收 Redmine 中的问题更新通知。关于 Slack，我们将在 4.3 节进行了解。

4https://github.com/sciyoshi/redmine-slack

◉ Issue Template plugin

使用 Issue Template plugin5 可以为每种问题分别设置模板，以便开发团队成员明白哪种问题该描述些什么。比如，我们给 Bug 问题设置了模板后，就可以有效防止漏记项目。

5http://www.r-labs.org/projects/issue-template/

Issue Template plugin 的安装和使用方法请参考 5.2 节。

4.2　版本控制系统

在进行团队开发时，我们需要一个地方来集中管理和共享各成员的开发成果。另外，在团队开发的过程中，程序内难免混入 Bug，这就需要对开发成果进行历史管理，以便追踪并掌握 Bug 混入的时间点。

在这一点上，版本控制系统（Version Control System，VCS）能提供很大帮助。顾名思义，版本控制系统就是用来管理源码等内容的开发历史的。当今的版本控制系统主要有集中式的 Subversion 和分布式的 Mercurial、Git。本书选用的是 Mercurial。

Redmine 和 Mercurial 都是面向团队开发的工具，这里将学习如何把它们结合在一起使用。Mercurial 的安装与简单的用法请参考第 1 章，实用性用法请参考第 6 章。

4.2.1　Mercurial 与 Redmine 的联动

如果让版本控制系统和问题跟踪系统两个系统联动起来，可以明确源码的变更和问题之间的对应关系，让二者相得益彰。现在我们就来了解一下 Redmine 和 Mercurial 的联动方法。

如果各位的服务器上还没有安装 Mercurial，那么需要先安装 Mercurial，然后重启 Redmine（LIST 4.9）。

LIST 4.9　安装 Mercurial

$ sudo pip install mercurial
$ sudo service apache2 restart

以管理员身份登录 Redmine，设置版本控制系统。

在图 4.10 所示的配置界面选择“版本库”标签页，勾选“启用 SCM”中的相应系统然后保存。这样我们就可以使用选中的版本控制系统了。

图 4.10　Redmine 的版本库配置界面

4.2.2　用于生成版本库的插件

虽然实现版本控制系统的联动就可以了，但是我们还希望能直接从 Redmine 创建版本库，这样会更加方便。为此，我们还要安装 SCM Creator plugin6 并进行设置。

6http://projects.andriylesyuk.com/projects/scm-creator

首先创建一个用于生成版本库的目录（LIST 4.10）。这里我们用的目录为 /var/lib/hg，然后更改它的所有权，让我们能从 Redmine 对它进行写入操作。

LIST 4.10　创建用于版本库的目录

$ sudo mkdir /var/lib/hg
$ sudo chown www-data:www-data /var/lib/hg

然后创建设置文件 /usr/share/redmine/config/scm.yml（LIST 4.11）。下例是最低限度的设置。关于设置项目的详细资料，请参考该插件的 Web 页面。

LIST 4.11　scm.yml

production:
  deny_delete: false
  auto_create: true
  force_repository: false
  max_repos: 0
  only_creator: true
  allow_add_local: false
  allow_pickup: false
  mercurial:
    path: /var/lib/hg
    hg: /usr/local/bin/hg

插件的安装流程如 LIST 4.12。需要先将下载好的插件解压到 Redmine 的 plugins 目录下，然后执行 rake 命令构建。

LIST 4.12　安装 SCM Creator plugin

$ sudo mkdir /usr/share/redmine/plugins
$ wget http://projects.andriylesyuk.com/attachments/download/563/redmine_scm-0.5.0b.tar.bz2
$ sudo tar xfj redmine_scm-0.5.0b.tar.bz2 -C /usr/share/redmine/plugins/
$ cd /usr/share/redmine/
$ sudo chown -R www-data:www-data plugins/redmine_scm/
$ sudo rake redmine:plugins:migrate RAILS_ENV=production
$ sudo service apache2 restart

重启 apache2 后，插件将被自动加载。我们可以在图 4.11 所示的配置界面里看到插件正常加载的信息。

图 4.11　Redmine 的插件配置界面

接下来新建一个项目。选择界面左上方的“项目”，然后点击“新建项目”进入新建项目界面（图 4.12）。要记得先指定 SCM（这里指定为 Mercurial）再创建项目。

图 4.12　新建项目界面

查看刚刚创建好的项目的配置界面，可以看到“版本库”标签页中已经生成了 Mercurial 的版本库（图 4.13）。

图 4.13　版本库配置界面

至此，Mercurial 与 Redmine 的联动就实现了。

4.3　聊天系统

在团队开发中，必须保证报告、联络、探讨这些交流能顺利进行。说到交流，一般不是面对面开会就是邮件往来，然而面对面开会时，其内容很难与不在场的成员共享，而邮件又难以保证效率。聊天系统则能扬长避短，轻松实现远程实时对话以及团队内信息共享。

聊天系统种类繁多，比如 Skype、Slack、LINE、IRC 等。这里我们来了解一下 Slack7。

7https://slack.com/

4.3.1　Slack

Slack 是一款面向企业和团体的交流工具。除了可以进行一对一的对话之外，还能创建多个频道、内部群组等聊天室，让多名团队成员同时参与对话（图 4.14）。

图 4.14　Slack

这项服务在 2013 年才刚刚起步，但由于其导入简单、使用方便而广受人们青睐，如今已被许多企业和团体使用。

4.3.2　Slack 的特点

下面我们来了解一下 Slack 的主要特点。

◉ 能轻松导入的 Web 服务

虽然 Slack 有专用的客户端，但其很大程度上是基于 Web 的，所以能脱离客户端直接通过浏览器使用。它不像 IRC 那样需要准备服务器，再加上采用了免费增值模式，因此可以免费使用。导入和使用几乎零成本是它的巨大优势。

◉ 能与许多外部服务联动

Slack 可以实现与许多外部服务的联动（Integration）。它标配的 Integration 超过 60 种，其中包括了 Dropbox、GitHub、Google Drive、Twitter 等，我们可以从设置界面轻松地开启某项联动功能。联动的内容因服务而异，以 Google Drive 为例，如果我们在聊天室粘贴了 Google Drive 上的文档的 URL，聊天室内就会自动显示出文档的标题。

Redmine 与 Slack 的联动可由 Slack chat plugin for Redmine8 实现。这是一个要用到 Slack API 的插件，将它导入 Redmine 之后，Redmine 就能向 Slack 的聊天室发送通知了。

8https://github.com/sciyoshi/redmine-slack

除 Redmine 之外，Jenkins、Sentry 也可以向 Slack 发送通知（图 4.15）。Jenkins 直接用标配的 Integration 即可。如果使用 Sentry，则要给 Sentry 安装 sentry-slack 扩展 9。

9https://github.com/getsentry/sentry-slack

图 4.15　Slack 与 Redmine 和 Jenkins 的联动

通过实时显示问题的更新、向版本库的提交、Jenkins 的构建信息等，既可以方便我们掌握团队成员的工作情况，又可以保留作业日志，对团队开发而言是一种极大的帮助。

◉ 可创建 bot

Slackbot 是 Slack 标配的 bot。这个功能会对我们设置的关键字作出反应，然后返回特定的消息。比如，如果希望在有人说“我要回家了”的时候，自动回答“您辛苦了”这句话或者别的话，只要在 Slackbot 中进行相应设置就行了。

如果想进行更加复杂的处理，可以用 Slack 附带的 WebHook 功能来创建自己的 bot。

既然是自己的 bot，那它的用处就全看我们的创造力了。

举个例子，假设我们在 Google Drive 的电子表格里存了测试项目与测试结果的数据一览。现在每次查看测试进展情况都要打开一次电子表格，实在太麻烦，所以我们希望这一流程能实现自动化。这时就可以创建一个 bot，让它在电子表格更新时自动获取测试结果一览，并提交给 Slack。有了这个 bot 之后，团队所有成员都能共享测试的情况，这对团队开发大有助益。

◉ 强大的 @ 功能加速团队内的信息传递

Slack 有着强大的 @ 功能，这个功能可以让我们在发消息时指定呼叫某个用户。如果在我们使用 Slack 时，有人发消息并 @ 了我们，那么聊天室列表中会显示通知图标，告诉我们“有人 @ 你”。如果我们当时不在线，该消息内容会以邮件形式进行通知。另外，使用 iOS 或 Android 的 Slack 应用时，通知会显示在终端的通知区域里。由于它会借助各种通知手段来通知，因此我们很难漏掉那些 @ 自己的信息。此外，Slack 还有“@ 一览”功能，供用户查看以往所有 @ 自己的信息。

在 @ 别人时，用户名输入到一半会自动弹出用户名候选列表，我们只要从列表中选择用户名即可。这样一来既可以节省时间，又能防止输错用户名。如果要 @ 聊天室中的所有人，需要用 @channel、@group 等特殊名称。

正因为 Slack 的 UI 发 @ 方便、被 @ 醒目，所以很少会出现“想告诉某人一件事，结果某人没注意到”这种沟通失误。对使用 Slack 的团队开发而言，这绝对是一大优势。

◉ URL 和文件的预览功能

在聊天室贴 URL 或向聊天室上传文件后，成员们能预览其内容。Web 页面的 URL 会显示该页面内的文本，图片的 URL 则会显示图片。上传的文件也是同样道理，文本文件会显示文本，jpg 或 png 等图片文件则会显示图片。

借助于这一功能，我们能很轻松地与团队成员共享图片形式的信息（比如尚在开发中的设计方案）。

◉ 其他特点

除上述这些之外，Slack 作为一款聊天系统还具备许多方便的功能和特征。

简洁的 UI

Slack的UI设计简洁明了，让人看一眼就能上手使用。虽然目前（2014年11月）只提供了英文UI，但不懂英语的人用起来也不会有什么障碍。

可读性高的 text snippet

用户可以用text snippet功能在聊天室内粘贴源码。这些源码会以语法高亮的形式显示。

可重复利用的消息记录

Slack会给每一条消息设置永久链接，在引用消息内容时会经常用到这些链接。如果我们想引用某条消息，只要在Slack中粘贴它的永久链接，该消息就会显示在聊天框中。这一功能让我们的消息不再转瞬即逝，而是可以随时拿来重复利用。

类似书签的星标功能

如果有想关注的聊天室或者怕忘记的消息，那么可以给它们标上星星。我们可以在星标一览中查看标记过星星的信息。有了这个功能，再久远的消息也能很快找到。

好用的搜索功能

搜索聊天记录的功能拥有跨频道、跨群搜索以及指定日期等多种选择，能很轻松地找到过去的信息或附件。

可添加自定义表情

Slack允许用户添加自定义的表情用于聊天。我们可以添加上限为128像素×128像素的自定义表情，还可以添加公司或产品的Logo，这给我们带来了意想不到的便利。

◉ 收费版

前面我们说过，Slack 采用了免费增值模式，所以用户可以免费使用，但它同时还提供了收费版 Slack。收费版的特征如下。

付费

收费版会根据活动用户数量收取费用。

可设置单频道来宾

收费版可创建只能访问一个频道的免费来宾账户，也就是单频道来宾。在项目需要临时与团队外人员交流时，有单频道来宾会方便许多。

不限制记录保存数

免费版只可以保存1万条聊天记录，但收费版没有这个限制。在交流频繁的时候，往往一天会出现几百条消息，1万条记录最多也就能撑半年。无法保存记录就意味着我们无法参考过去的消息，而收费版就不用担心这个问题了。

不限制 Integration

免费版只能添加5个Integration，但收费版没有限制。

◉ Slack 应用

Slack 为 OS X、iOS、Android 准备了专用的客户端。只要在各个系统的应用商城中搜索 Slack 就能找到。

网页版的 Slack 不支持智能手机，所以 iOS 和 Android 要想使用 Slack 必须安装应用。iOS 和 Android 的 Slack 应用运行起来更快，用户能轻松查看消息通知和发送消息，而且新的消息通知会发送到 OS 的通知区域内，不用担心遗漏重要的事情。

截止到现在（2014 年 11 月），Slack 还没有 Windows 专用的客户端，但 Google Chrome 的“创建应用程序快捷方式”功能可以创建 Slack 的快捷方式，我们可以把它当成专用客户端来用。

4.3.3　Slack 做不到的事

但是，Slack 只能让我们与团队内部成员交流。当我们需要与团队外（公司外）的人进行交流时，必须将对方邀请进 Slack 才行。另外，有些时候让团队外的人使用 Slack 本身就是一件难事，因此可以考虑把 Slack 和普及率较广的 Skype 等软件结合起来使用，以便与团队外的人进行交流。

Slack 还无法语音聊天。有些商讨内容打字聊天说不清楚，而语音会议的效果会好很多，这种时候就可以拿 Skype 等带有语音聊天功能的交流工具来配合使用。

4.3.4　Slack 的注册

Slack 的注册需要在 https://slack.com/ 上进行。虽然注册全程为英语，但中间要输入的东西也就是邮箱地址、用户名、团队名、专用域名、邮箱域名、密码而已，并没有什么难的。创建团队后，Slack 会自动分配一个专用域名给我们（专用域名“.slack.com”）。此后，我们只要访问这个地址，就可以进行登录聊天室等操作了。

给团队设置邮箱域名后，所有拥有该域名邮箱的人都可以创建自己团队的账户。比如要在公司里用 Slack，只要我们在这里设置了公司的邮箱域名，公司成员就都可以创建 Slack 账户了。

4.4　对团队开发有帮助的工具

在前面几节中，我们了解了团队开发必不可少的问题跟踪系统、版本控制系统以及聊天系统。除此之外，还有很多能帮助开发团队共享信息和数据的工具。这里我们来了解一下其中的 Dropbox 和 Google Drive。

4.4.1　Dropbox

Dropbox10 是一项能轻松共享开发资料等文件的在线存储服务。用户不必准备文件服务器，只要通过 Dropbox 即可与团队成员共享文件。由于其前期工作只有注册用户这一步，所以可以省去搭建及使用文件服务器的成本。各位可以把它看作一个网络上的文件服务器。

10http://www.dropboxchina.com/

使用 Dropbox 共享文件时不像邮箱或 Skype 那样烦琐，用户只需操作一下本地目录，文件就会自动完成同步，这可以为我们节省大量时间。另外，Dropbox 允许付费增加可用空间，而且为用户准备了个人与团体两个不同使用方案。

4.4.2　Google Drive

Google Drive11 是 Google 提供的一项云服务，用来创建和共享文档。它通过浏览器为用户提供 Microsoft 的 Word、Excel、PowerPoint 等软件。其独特之处在于允许多人同时编辑一个文件，因此可以在文档中实现交流。此外，用户可以详细地为每个文件设置共享用户及权限。

11https://www.google.com/drive/

4.5　小结

本章重点在于了解面向团队开发的工具，内容涉及问题跟踪系统、版本控制系统、聊天系统等。

如果我们能够灵活运用 Mercurial 和 Redmine，把握好开发成果及信息的共享环境，就可以有效提高开发效率。另外，借助 Slack、Google Drive 等工具，我们可以通过网络进行交流、共享信息和数据，开发工作不再受场所限制，减少了交通和交流方面的成本。

如果我们花些心思学会使用工具，还能有效地提高作业效率。动脑筋提高效率同样是一种乐趣。虽然团队开发中的交流更加复杂，但我们可以通过灵活运动工具提高沟通效率，从而做出更大的成果。

第 5 章　项目管理与审查

以团队形式进行开发时，应该遵循什么样的流程呢？首先，要分配作业任务，让每个成员负责一部分开发工作。每天或每周报告进度，如果工作过程中发现问题则必须马上共享。要想更好地管理这一系列流程，使开发工作更加顺利，我们要用到 Redmine、Trac 这类问题跟踪系统。

本章首先在 5.1 节就问题跟踪系统中的项目管理与问题的区分使用进行说明。另外，会在 5.2 节通过实际的示例讲解如何统一项目内容。

然后再进一步，将问题跟踪系统中的问题与版本管理系统中的分支相结合，为各位介绍问题驱动开发的相关知识（5.3 节）。

团队开发还有另外一项优势，那就是在分配任务和实现任务之余，还可以验证当前任务或实现是否正确地完成了，也就是所谓的审查。5.4 节将不惜大量篇幅，为各位讲解审查方和被审查方各自的注意事项，以及一些行之有效的审查方法。

5.1　项目管理与问题的区分使用

进行项目管理时，应该以什么为单位创建问题，每个问题中应该写什么，这都是整个团队必须统一的事。本节将以 Redmine 为例，学习进行团队项目管理时应注意的一些点。

5.1.1　项目管理的前置准备工作

在 Redmine 上创建项目、开始项目管理之前，有些设置需要先做好。我们可以在项目的“配置”标签页进行各类设置（图 5.1）。

图 5.1　项目的配置界面

◉ 添加成员

首先添加参与项目的成员。成员按职责不同分为 3 种，具体区分如下。

管理人员：可更改项目的设置。一般情况下所有项目成员都可以设置为管理人员

开发人员：无法对配置界面进行操作，其余操作则不受限制。通常是公司外项目成员等

报告人员：测试负责人，用来对那些负责添加问题的成员进行设置

◉ 添加版本

一个项目必然存在确定需求、实现完毕、测试完毕等阶段点（里程碑）。我们要把各个里程碑的信息添加到版本标签页，将其视为一个个版本。如果版本发布日期已确定，则可以给相应版本设置日期。

◉ 添加问题类别

如果项目具备一定规模，那么最好以功能为单位添加问题类别。添加类别时，请记得设置该类别的负责人。在没有相应负责人的情况下，可以直接将项目组长设为负责人。这样一来可以避免出现没人负责的问题。

5.1.2　创建问题

现在我们已经做好了给项目创建问题的准备，可以实际动手创建问题了。点击“新建问题”标签会出现如图 5.2 所示的界面。

图 5.2　新建问题界面

创建问题时，各项目按如下内容输入、设置。

项目

内容

跟踪

指定问题的种类。区分如下所示1。
• 功能：用于实现新功能的问题
• 错误：用于报告 Bug
• 任务：实际工作、操作等
• 需求讨论：用于讨论需求的问题。需求确定后新建功能问题并与之关联

主题

简洁明了地描述问题的内容

描述

描述这个问题想要实现的内容。用问题模板（Issue Template）统一描述内容后可有效防止遗漏

状态

设置问题的状态。比如开始工作后选择“进行中”，工作完成需要别人确认时选择“反馈”等

优先级

如果描述的内容比较重要，可以将优先级提升为“高”或其他

指派给

选择负责这个问题的人。如果不清楚该由谁负责，可以指定项目组长，再让项目组长更改为适当人选

类别

指定问题的类别，标明问题属于哪个部分、与什么功能相关

目标版本

如果项目中存在里程碑，则选择“目标版本”这一项

跟踪者

如果除负责人之外还有其他人员与此问题有关，则选择此项

文件

在此添加相关图片或文件

父任务

要与父任务相关联时，在此输入父任务的编号

开始日期

添加着手处理该问题的日期。可直接使用默认日期（创建日期）

计划完成日期

希望在哪一天之前完成此问题里的内容

预期时间

如果预估了工作时间，可写入此栏

% 完成

直接用 0% 即可

1新建项目默认跟踪标签只有“功能”“错误”“支持”3 项，不过用户可以通过管理界面里的“跟踪标签”页面增加、删除和修改选项。这里的“任务”和“需求讨论”应该是作者自己创建的。——编者注

5.1.3　整理问题

在问题中描述工作内容，根据内容进行操作和实现，将完工的问题的状态改为“已关闭”。当这一系列周期顺风顺水时，我们会发现问题一个个减少，有一种项目向着成功迈进的感觉。

然而，因需求变更导致问题的内容与最新需求产生偏差，或是负责人设置不当等问题经常会导致开发无法进行下去，结果就是无人问津的问题越积越多。一旦问题堆积起来，我们将不知道自己还剩多少工作要做，自然也就看不到项目的终点。

为防止无人问津的问题越来越多，我们需要定期整理问题。问题的整理方法如下。

① 问题分类

　按更新顺序重新排列未结束的问题。更新日期较早的问题大多已经停滞，所以可以根据日期从旧到新进行查看。

② 查看问题

　 查看问题里写的工作内容。确认工作内容与实施目标是否一致，以及负责人是否合适。

③ 结束、驳回问题

　 如果发现内容与其他问题重复，则注明对应问题编号并结束当前问题。如果工作内容与当前需求出现很大偏差，则写明理由并驳回该问题。

④ 修正问题

　 如果工作内容不明确或有错误，则要将内容修正至能正确进行为止。如果工作内容涉及面太广，同时出现多名负责人，则可以按照 5.1.4 节说明的方法进行分割。

⑤ 分配问题

　 将问题指派给合适的负责人。有些工作内容会涉及多名负责人，此时要根据工作优先级挑选出一名主要负责人。

要是等问题堆积起来再去整理，那我们很可能要在这上面耗上一整天，结果耽误本来该干的工作。建议各位平时就抽出一丁点时间来整理它们。

5.1.4　分割问题

当既有问题久久无法完成时，就需要我们重新审视该问题的目标了。因为我们在创建问题的时候，很可能没注意到其中暗藏的大量工作。一旦遇到这种情况，就应该把这些暗藏的任务分割到其他问题中。

这样一来，我们就可以把暂时不需要处理的内容向后放，或者将分割出来的问题交给其他负责人。另外，把每个项目都细分成问题还有助于管理。Redmine 能够给问题添加关联，比如设置父子关系，或者将几个问题设置为“相关问题”等，这对管理的帮助很大。

当然，我们也可以在最开始就把工作细分成较小的问题。这个分配工作既可以在着手该项目时进行，也可以等到必要时再做。重要的是，要根据工作量以及工作内容的复杂度合理分配任务，然后整理成问题。

5.2　问题模板

我们在 5.1 节了解到，新建问题时要在“描述”一栏描述这个问题想要实现的内容。不过真到新建问题时，我们往往不知道该写些什么。另外，一旦问题的描述不够充分，我们就要花费很多时间在留言交流上。

因此，为了防止问题的描述出现遗漏，团队应该事先统一好需要描述的项目。Redmine 有一个插件叫问题模板，它能生成统一的格式（模板）并把该格式反映到问题中。

这里我们将学习问题模板插件的使用方法，然后给各位看一个实际的例子。

5.2.1　安装插件

要想给 Redmine 的问题设置模板，需要安装 Issue Template Plugin2。

2http://www.r-labs.org/projects/issue-template/

安装插件的流程如 LIST 5.1 所示。

LIST 5.1　安装Issue Template Plugin

$ wget https://bitbucket.org/akiko_pusu/redmine_issue_templates/downloads/redmine_issue_templates-0.0.9.zip
$ sudo mkdir /usr/share/redmine/plugins
$ unzip redmine_issue_templates-0.0.9.zip -d /usr/share/redmine/plugins/
$ cd /usr/share/redmine
$ sudo chown -R www-data:www-data plugins
$ sudo cp -pr plugins/redmine_issue_templates/assets public/plugin_assets/redmine_issue_templates
$ sudo rake redmine:plugins:migrate RAILS_ENV=production
$ sudo bundle install
$ sudo service apache2 reload

如果插件安装正确，插件管理界面会显示 Redmine Issue Templates plugin，管理菜单中将多一条 Global Issue Templates（全局问题模板）（图 5.3）。

图 5.3　Redmine 的插件管理界面

5.2.2　问题模板的使用方法

下面我们来学习问题的设置和使用方法。

◉ 启用问题模板功能

打开项目的“配置”→“模块”界面，勾选“问题模板”，保存后即可对该项目开启问题模板功能。开启后，配置界面上将多出“问题模板”标签页。

另外，我们在这里更改全部设置，以便今后创建的所有项目都可以直接使用问题模板功能。打开管理界面的“配置”→“项目”界面，在“新建项目默认启用的模块”中勾选“问题模板”并保存（图 5.4）。

图 5.4　管理界面的项目配置

◉ 创建模板

在配置界面选中“问题模板”选项卡，点击“新建”进入模板添加界面（图 5.5）。

图 5.5　添加问题模板的界面

请按照下表设置各输入项目。

项目

说明

模板名称

在这里指定问题模板的名称。我们规范为“用于〇〇的模板”

跟踪标签

在这里指定应用模板的跟踪标签的种类（功能、错误、支持等）

问题标题

在这里指定标题的模板

模板内容

在这里输入问题内容模板。推荐使用标题或逐条列记，这种模板更直观易用

批注

不需要填写

默认值

勾选之后，选择该类问题的跟踪标签时会自动套用当前模板

启用

勾选之后，该模板才会生效

Enabled sharing with project tree.

勾选之后，子项目也可以使用该模板

◉ 套用模板

问题模板的使用方法很简单，只要在新建问题界面中选择模板就行了。

选择跟踪标签（问题类别）后，界面上会列表显示该跟踪标签下有效的问题模板。此处只要选择了任意一个模板，“主题”“描述”就会被自动填充。随后我们只需要按照通常创建问题的步骤，对内容作一些修改即可（图 5.6）。

图 5.6　选择问题模板

5.2.3　Global Issue Templates

问题模板很方便，但当我们有一个所有项目通用的模板时，如果要一个项目一个项目地去设置这个模板，那可不是一般的麻烦。这种时候，我们可以使用 Global Issue Templates 功能，创建一个所有项目通用的模板。

创建 Global Issue Templates 时，先选择 Redmine 管理界面中的 Global Issue Templates 菜单，然后点击“新建模板”打开如图 5.7 所示的界面。这里的基本输入项目与问题模板相同，不同点则是下面多出了“项目”区域。这里会显示 Redmine 上的所有项目，我们在哪个项目前面打上勾并保存，该模板就会对哪个项目生效。

图 5.7　添加 Global Issue Templates 的界面

另外，如果我们新建了项目，Global Issue Templates 并不会自动对该项目生效。所以当我们创建了新项目时，需要打开 Global Issue Templates 的编辑界面，勾选新项目并重新保存。

5.2.4　问题模板示例

这里，我们为每个跟踪标签各准备了一个问题模板的范例。

我们用的格式是 reStructuredText，如果各位使用的是 Redmine 默认的 Textile，请自行作相应替换。

◉ 功能

与添加功能相关的问题（LIST 5.2）。描述要实现何种功能。

LIST 5.2　功能问题的模板

目的
====
- 实现该功能后成品能完成什么工作，将大致预想写在这里

输入输出
======
- 记录输入的值和输出的结果

相关功能、影响范围
==================
- 需要进行回归测试的地方
- 生成该功能所需数据的功能（可添加与对象问题的关联）
- 使用该功能所生成数据的功能（可添加与对象问题的关联）

安全
============
- 如有安全相关问题（权限等），则将相应内容写在这里

演示方法
========
- 记录 URL 或其他能轻松查看该功能的操作流程

◉ 错误

用于报告 Bug 的问题（LIST 5.3）。将试验等过程中发现的 Bug 制作成问题，分配给相应负责人。

LIST 5.3　错误问题的模板

现象
====
- 描述该Bug 的现象

流程
====
- 描述出现该Bug 的操作流程

预期结果
============
- 描述原本应该出现的结果

环境
====
- 描述试验环境

示例
 - OS ：Mac OS X 10.10
 - 浏览器：IE11
 - 执行用户：admin
 - 出现Bug 的URL ：https://staging.example.com/very-critical-feature

相关信息
========
- 描述错误日志、消息Sentry 的URL 等

◉ 任务

关于各种作业的问题（LIST 5.4）。

LIST 5.4　任务问题的模板

目的
====
- 执行该任务的目的

内容
====
- 具体要实施的内容

◉ 需求讨论

用来讨论需求的问题。确定需求后关闭这个问题，然后新建功能问题，并与之相关联。

LIST 5.5　需求讨论问题的模板（LIST 5.5）

目的
====- 当前讨论的需求要达成何种预期。

需求方案
======
- 记录已有的需求方案，有多种时可记录多个方案
- 同时记录优点和缺点

总结
======
- 推荐的方案以及推荐理由

演示方法
========
- 记录URL 或其他能轻松查看该方案的操作流程

5.3　问题驱动开发

接下来我们来学习问题驱动的团队开发。

5.3.1　别急着敲代码，先建问题

下面我们开始学习团队开发的一系列流程。所谓开发，既可以是开发新应用，也可以是修改已有系统。重要的是，我们在打开编辑器敲代码之前，要先把即将着手处理的项目添加到问题跟踪系统之中。

在 Redmine 界面中填入标题和任务概述并完成添加后，我们将得到一个带有编号的问题（这里是“#2”）（图 5.8）。

图 5.8　Redmine 的问题界面

5.3.2　创建与问题编号同名的分支

获得问题跟踪系统分配的问题编号之后，我们就可以在 Mercurial 上创建一个与之同名的分支了。举个例子，如果问题的编号为“#100”，那么我们的分支名就对应为“t100”。

这样一来，我们负责的项目和源码的修改内容就一一对应起来了。等到我们完成了这部分工作，只要将 t100 分支合并到 default 分支，就能轻松完成发布工作，十分方便。另外，将“一个问题对应创建一个分支”用作项目方针来规范团队，可以有效减少恼人的多头现象（Multiple Head，即最新端变更集分成多个版本的现象）。而且，由于问题的编号与分支名一一对应，所以当我们想查看项目 #100 所修改的内容时，只需查看分支 t100 即可，非常方便。

◉ 主题分支与问题驱动开发

这种为单一问题（主题）而创建的分支称为主题分支。

一个项目拥有的主题分支数往往很惊人。我们没必要给每个主题分支都起一个有意义的名字。花大量时间在起名字上，有时反而会起出一些让人摸不清头脑的名字，倒不如机械式地起名来得方便，而且不会出现歧义。问题跟踪系统的问题 ID 一般都很独特，再加上它具备我们前面提过的那些优势，所以用它肯定万无一失。

像这样基于问题跟踪系统的问题来进行开发的模式就称为问题驱动开发。

专栏　明确区分分支名与版本修订号

主题分支的英文是 Topic Branch，所以名字前面都加了t。在 Mercurial 中，这还起到了区分分支名与版本修订号的重要作用。仅由数值组成的分支名很容易与版本修订号搞混，弄不好就会出现意外情况，所以应当尽量避免使用仅由数值组成的分支名。

5.3.3　让发布与分支相对应

从项目管理的观点讲，我们提倡设置里程碑并制定发布计划。而采用敏捷开发的项目还会基于迭代来制定发布计划。

在系统开发中，发布计划的重要性不言而喻。前面我们讲了问题与分支一一对应的好处，同样道理，将发布与版本管理系统的分支一一对应也能方便管理。

使用 Mercurial 时，default 分支是一个既定的分支，必然存在。因此我们规定“default ＝面向正式运行的分支”。然后假设现在有一个发布，我们从 default 创建一个新分支与它相对应（LIST 5.6）。

LIST 5.6　从 default 创建发布分支

$ hg update default
$ hg branch m1231

然后，基于新创建的发布分支派生出各个主题分支（LIST 5.7）。

LIST 5.7　创建主题分支

$ hg branch
m1231
$ hg branch t100

用作里程碑的分支并没有严格的命名法则，但为了表示其“里程碑”之意，我们用字母 m 打头。

m 后面可以是发布日期（上例中的 1231 表示 12 月 31 日），也可以是明确表示发布的特殊词汇（比如智能手机版的发布命名为 m_smartphone 等）。由于问题跟踪系统不会专门给里程碑分配特殊 ID，而且创建里程碑并不像创建主题分支那样频繁，所以我们完全可以给每个里程碑设置个性化的分支名。

5.3.4　分支的合并

刚刚我们通过创建分支将开发成果分割开了。各个项目都有对应的分支，分支之间的开发和提交相互独立。因此，当项目完成后，还需要将它合并回来。合并的规则很简单，保证只合并有父子关系的分支就行了（图 5.9、图 5.10）。

① 只在父分支和子分支之间进行合并。

② 子分支之间不合并。

③ 从子分支派生出来的孙分支不能直接合并到父分支。

上述规则必须严格遵守。

图 5.9　父分支与子分支之间的合并

图 5.10　一些应当避免的合并

如果合并了没有父子关系的分支，我们好不容易分割清楚的项目会再度乱成一团。这可能会导致问题里没提到的修改内容在不知不觉间混入，或者导致源码产生冲突，等等。想在其他分支中反映某分支的成果时也要遵守这一规则，先与它们共同的父分支合并，再反映到对象子分支里。

虽然这样做很麻烦，但在父分支中分享一些本不该出现的半成品会导致很多问题，所以请各位务必遵守。

只要按照我们这里学习的方法创建分支，一般不会遇到子分支之间合并或者孙分支向父分支合并的情况。如果无论如何都得进行这类操作，那就可以认定是分割项目与设置父子关系的阶段出现了问题，需要我们修改问题的内容或选择其他方法应对。

另外，在将子分支合并到父分支之前，一定要先在子分支中反映父分支的修改内容。因为在我们修改子分支的过程中很可能有其他人对父分支作了修改，所以要先将这部分修改吸收到我们的子分支中，保证“父分支和子分支之间的差别 = 我们对子分支作的修改”。

这样一来，即便子分支与父分支之间出现了矛盾，我们只要回到自己的任务分支中就能解决该问题。

5.4　审查

5.4.1　为什么需要审查

审查的意义大致有两个。

一个是修正错误。通过审查人员的指正，我们能及早发现单人作业时忽略的问题以及一些错误的认识。

另一个是知识共享。审查能帮助团队成员分享在工作过程中学到的新知识，同时也是不同特长的成员相互交流窍门和经验的好机会。

这里我们了解一下“受审查者在接受审查时”和“审查者在进行审查时”的注意事项。另外，本节将审查分为分“源码的审查”和“发布作业的审查”，分别称为代码审查和作业审查。

5.4.2　审查委托：代码审查篇

◉ 准备资料

接受审查前首先要准备资料。将自己写代码时参考的规格文档、需求定义文档、界面图等资料提供给审查者。

同时要让审查者清楚自己修改了哪部分源码。我们可以列出源码修改前后的差别，让审查者对修改内容一目了然。

◉ 统一编码风格

我们在第 1 章中了解到，PEP8 这种编码风格如今被人们广泛采用。所以我们可以先借助 flake8 统一编码风格，然后再请人来审查。flake8 的使用方法以及在编辑器中的设置方法请参考 1.3 节。

如果某个项目想采用与 PEP8 稍有不同的编码风格，我们可以准备出一个设置文件（setup.cfg）来自定义 flake8 的检测内容。

比方说我们想将一行的最大限制从 80 个字符提高到 120 个字符，那么要在项目根目录下的 setup.cfg 中作如下描述（LIST 5.8）。

LIST 5.8　setup.cfg 示例

[flake8]
 max-line-length = 120

flake8 Configuration

https://flake8.readthedocs.org/en/2.2.3/config.html

这样一来，flake8 就不会对 120 个字符以内的行发出警告了。变更项目的编码风格时，记得要先取得项目成员们的一致同意。

把希望审查员确认的事项整理出来

接下来思考一下审查时的步骤。漫无目的地检查全部代码既耗时又没效果，所以要圈出重点。

那么应该如何圈重点呢？请参考以下 2 个原则。

① 自己不放心的地方

　 处理比较复杂或者涉及陌生模块的地方等。总之要将自己不放心的地方交给审查者确认。

② 费心设计的地方

　 回顾一下实现过程中思虑再三的地方以及设计上比较费心的地方，将它们总结并整理出来。这些需要花时间思考的部分很可能是需求或设计上的重要部分，应该积极地交给审查者检查。另外，将编码或测试中的难点与其他团队成员共享也是一件有利无弊的事。

◉ 反映审查结果

接受审查时要虚心听取指正并记录下来。另外，根据指正进行修改后还要请审查者再确认一遍。

5.4.3　审查委托：作业审查篇

◉ 准备资料

接受作业审查时也需要准备相关资料。尽量把需要负责人严格把关的事故易发点共享出来，比如服务器的环境设置、网络构架等。

◉ 制作作业流程文档

将作业总结成“作业流程文档”。制作该文档时需要注意以下几点。

① 目的和概述

　 写出该作业的目的或概述。比如像下面这样的原因或问题经过、非正常作业流程等就要写出来，让审查者一目了然。

由于编号为 #123 的问题中的问题导致接单表数据不一致，为修正此问题进行数据维护

发布面向营业的接单数据报告功能。为进行发布作业而更改数据库定义以及修改 Apache 的设置

② 作业场所

　 写明是在哪里进行的作业。在正式环境由多台服务器共同组成时（比如主从式数据库）要格外注意这一点。

③ 所需时间与时间规划

　 各项作业的预计所需时间以及作业整体的时间规划能帮助我们制定当天的计划。另外，当外部相关人员加入时，我们还可以根据它来修改日程。

④ 负责人

　 如果事先知道当天的作业负责人，最好把负责人信息记下来。同时要写明进行该作业的是团队内部人员还是承包方负责人。

⑤ 执行用户与权限

　 写明该作业由 OS 的哪个用户执行。这样就可以避免到了实际作业时才发现没有 root 权限而慌了手脚。这对写流程文档的人来说或许意义不大，但当天保不准会由其他人来代替我们进行该项作业，如果对方搞不清执行用户，将很容易引起麻烦。所以这一条还是记上为妙。

⑥ 执行命令

　 在命令行执行某条命令时，需要将该命令记录下来。只要保证作业内容固定，那么任何人执行该作业都能得到相同结果，造成问题的风险也就相对较小。另外，执行命令之后要检查命令是否正常执行完毕，这个检查的方法也最好写在这里。

⑦ 作业失败时的对策以及补救方法

　 对于一些有着复杂流程的作业，或者一旦失败就难以还原的作业，应该事先写明失败时的还原方法。事先准备好应对方法能避免正式作业中慌手慌脚。

⑧ 试运行的记录

　 如果在本地的 VM 上进行了试运行，那么应该把结果记录添加在这里。试运行的结果不但能让审查者对正式作业有一个更清晰的印象，还能帮助审查者发觉正式作业时需要注意的点。

审查者要根据这些资料判断受审查者的开发成果是否妥当。

5.4.4　实施审查：代码审查篇

◉ 形式上的检查没有意义

如果把有限的时间花费在检查“一行有多少个字符”“类或函数的定义之间空了几行”等问题上，那完全是在浪费审查时间。这些事项用 PEP8 或 pyflakes 等 lint 工具就能轻松搞定。

审查时要把时间花在以下几个项目的检查上。

① 程序是否满足需求

② 是否有遗漏需求

③ 设计或实现的效率是否够高

◉ 发现疑问就询问理由或缘由

在实际写代码的过程中，设计与实现上的很多判断都要基于各种背景或缘由。

受审查者在接受审查时不一定能把这些情况全都说清楚，而审查者又不知道对方漏说了哪些知识，所以审查者在看代码时常常会觉得有些不对劲。作为一名审查者，我们必须考虑到这种情况。对于有疑问的部分，要向受审查者了解背景或缘由，然后努力去理解。如果我们有更好的解决方法，要当场提出。

◉ 整理注释

关于源码上是否应该留有注释，众说纷纭。不过，我们建议将修改程序时的背景或缘由简单地注释下来。日后再有人接触这部分源码时，很可能会对该项处理抱有疑问，而注释能帮助他们理解实现者的意图。

但这并不是说所有地方都要留注释。只要把实现过程中需要注意的难点顾及到了就行。另外，如果注释文的篇幅实在太长，最好改用文档或其他形式保存。

反过来，我们还需要检查该删除的注释是否都删除了。因需求变更而修改源码后，有一部分注释会与事实不符，这些注释就需要删去。

◉ 判断是否需要修正时，要考虑重要性和日程之间的平衡

即便我们断定受审查者写的代码需要修改，也要考虑修改内容的重要性以及日程。很多时候，代码修改后确实能提高可读性，但当涉及的代码规模很小时，我们完全可以不对本次代码作修改，只敦促对方下次注意即可。相对地，如果可读性差或效率低的代码已经被大量复制和粘贴，那就很可能对今后的修改造成影响，这时就必须督促对方修改。

不过，是否立刻修改仍有商量余地。有时考虑到日程所限，可以将修改程序的作业创建成问题并延后处理。

◉ 将编码能力不足和问题意识分开评价

有些时候，虽然受审查者的编码能力还欠火候，但需求和设计方面的问题意识可圈可点，一看就是认真考虑过的。受审查者自然希望获得肯定，所以这种时候我们要充分肯定对方的问题意识，只针对编码方面作出指正。

专栏　 rietveld 的功能介绍

平日里，我们用 Mercurial 的 diff 进行简单审查，同时会还配合使用 Google 开发的代码审查工具 rietveld。这个专栏将对 rietveld 的功能作简单介绍。rietveld 是在 Google App Engine 上运行的 Web 应用式代码审查工具。

委托别人进行审查时，要将审查对象 diff 提交给 rietveld。提交内容将汇总为 patch set 形式，生成审查专用页面。根据 diff 的内容，该工具会生成审查对象文件一览表以及各文件的审查页面（图5.11）。

图 5.11　审查对象文件一览表

各文件的审查页面有两种阅览形式（unified 形式和 side-by-side 形式）（图 5.12、图 5.13）。不管是哪种形式，在任意一行上双击都可以打开留言框，提交审查留言（图 5.14）。

图 5.12　unified 形式的审查页面

图 5.13　side-by-side 形式的审查页面

图 5.14　可以直接在代码中添加留言

rietveld 这类工具虽然会增加审查的前置准备工作，但它可以高效审查涉及多个文件的大幅度更改，而且不必审查者和被审查者同时在场。根据审查对象的不同，我们可以选择用工具进行审查，或者用 diff 甚至是打印出来的源码进行审查，从而提高审查效率。

5.4.5　实施审查：作业审查篇

◉ 能否跟踪作业内容和实施历史

不论事前事后，我们都希望能够客观掌握负责人的作业情况，所以要督促对方将作业内容以流程文档等形式保留下来。在服务器或数据库上作业时则要记录已执行的命令或 SQL 语句。另外，命令的执行结果也要记录下来。这样一来，即便事后才发现作业失误或者作业内容不周全，我们也可以根据记录查找原因，制定对策进行弥补。

◉ 能否还原，是否有备份

作业失败时能进行还原是十分重要的。

比如这个作业要进行数据库的数据修正，如果我们发现事务忘记了 BEGIN，直接发出了 UPDATE 语句，那么必须指正出来。同样地，数据库备份的重要性也是不言而喻的。

◉ 是否受外部因素的影响

即便受审查者在写流程文档时制定了妥当的作业流程，实际作业仍可能受到一些与作业内容没有直接关系的外部因素影响而失败。

比如以下这些情况。

发布对象程序需要调用外部 API 时，API 方有访问限制，但程序没有申请解除限制

发布服务时，没有将维护消息通知给用户。或者维护时间过紧，每次都是勉强完成维护

忘记停止正式服务器的 cron 中设置的定时任务，在作业中误执行了批处理，从而引起问题

审查者要注意这类外部因素的有无。

5.5　小结

本章先讲解了使用问题跟踪系统时的注意点。

接下来对把项目管理与版本管理结合起来的问题驱动开发进行了介绍。问题与分支相互配合管理有助于开发顺利进行。另外，这样做还能有效减少源码的矛盾，让我们在团队开发时不必为此费心。

至于审查，我们从被审查者和审查者两个角度出发，分别了解了其需要注意的地方。我们前面也说过，能够进行审查是团队开发的一大优势。但是，如果审查时抓不住重点，那就审查很可能沦为形式上的检查。希望各位能熟记本章讲述的这些注意点，去实践一些更有意义的审查。

第 6 章　用 Mercurial 管理源码

Mercurial 是我们公司标配的版本控制系统，公司的日常业务大多离不开它。

除前面几章提到的提交、push、pull、创建分支等基本操作之外，Mercurial 还具备许多功能。灵活运用这些功能能帮助我们在各种情况下顺利完成开发。

本章将重点讲解 Mercurial 的使用窍门，包括一些好用的功能、版本库的管理方法、各种工具等，帮助各位进一步熟练使用 Mercurial。

6.1　Mercurial 版本库的管理与设置

Mercurial 是分布式版本管理系统，所以原则上是不需要 Subversion 那种中央版本库的。但是，开发团队成员之间共享成果（变更集）时，如果仅凭对等（Peer To Peer）方式进行交流，效果是十分有限的。为解决这一问题，需要在所有开发成员都能随时访问的服务器上设置一个中央版本库，用它来共享成果。接下来将要介绍的，就是在服务器上设置中央版本库的步骤。

6.1.1　服务器上的 Uinx 用户群设置

要想对文件系统上的 Mercurial 版本库进行操作，必须能在对象版本库内的“.hg”目录下进行读写，这就需要相应的访问权限。因此，用版本库进行开发的 Unix 用户需要设置为同一用户群来集中管理。

我们创建名为 dev 的群作为本例的开发者群（LIST 6.1）。这需要以管理员权限执行 groupadd 命令。

LIST 6.1　创建 dev 群

$ sudo groupadd dev

然后用 useradd 命令给 dev 群新建用户，用户名指定方法如 LIST 6.2 所示。

LIST 6.2　新建 dev 群的用户

$ sudo useradd -g dev <username>

即通过 <username> 指定用户名。

NOTE

指定 -m 选项可以创建主目录。

指定 -s 选项可以设置登录时使用的shell。

另外，向群中添加已有用户时要用 usermod 命令（LIST 6.3）。

LIST 6.3　向 dev 群添加已有用户

$ sudo usermod -a -G dev <username>

NOTE

用户群的修改内容不会对已登录服务器的用户立刻生效。直到下次登录时才会应用新的群。

6.1.2　创建版本库

由于服务器上要放好几个版本库，所以我们新建一个统一存放版本库的目录。本例的路径为 /var/hg/（LIST 6.4）。各版本库要在这个目录下创建。

LIST 6.4　创建 /var/hg/ 目录

$ sudo mkdir /var/hg/

接下来我们在这个目录下新建一个版本库。执行 hg init 命令，新建名为 testrepo 的版本库（LIST 6.5）。

LIST 6.5　创建 testrepo 版本库

$ sudo hg init /var/hg/testrepo

然后把版本库内“.hg”目录的所有权交给 dev 群，让 dev 群的用户能够对该版本库进行写入操作（LIST 6.6）。

LIST 6.6　将版本库的群改为 dev

$ sudo chgrp -R dev /var/hg/testrepo/.hg

另外还需要设置 SGID（Set Group ID），保证向版本库写入时，文件的所有者是 dev 群（LIST 6.7）。

LIST 6.7　设置 SGID

$ sudo chmod g+sw -R /var/hg/testrepo/.hg

这样我们就有了一个可多用户共同使用的版本库。

6.1.3　hgrc 的设置

要想在服务器端执行钩子（Hook）脚本，必须使用可信群或可信用户（关于钩子脚本的相关知识将在 6.2 节学习）。我们在 hgrc 的 trusted 节的 groups 中添加 dev 群。如果该文件不存在，则直接新建。至于 hgrc 的路径，假如版本库路径为 /var/hg/testrepo/，则 hgrc 位于 /var/hg/testrepo/.hg/hgrc。clone 来的版本库会自动生成这个文件，而用 init 创建的版本库并不会自动生成，需要手动创建（LIST 6.8）。

LIST 6.8　在 hgrc 的 trusted 节的 groups 中添加 dev 群

[trusted]
groups = dev

6.1.4　使用设置好的版本库

如果想通过 ssh 来 clone 我们刚创建的这个版本库，需要以“ssh:// 主机名 / 目录”的形式指定 clone 位置的路径。举个例子，假设服务器可通过 example.com 域名进行访问，现在我们想 clone 路径 /var/hg/testrepo 下的版本库，则命令如 LIST 6.9 所示。

LIST 6.9　对 example.com 上的 /var/hg/testrepo 版本库执行 clone 操作

$ hg clone ssh://example.com//var/hg/testrepo

指定路径时请注意斜杠数。协议部分 ssh://、域名 example.com 与目录 /var/hg/testrepo 的结合部分都是双斜杠。

NOTE

通过 ssh 连接时如果需要密钥，就需要在 ssh 客户端的设置文件 $HOME/.ssh/config 中预先设置好对连接对象使用的密钥文件。

6.1.5　使用 hgweb 建立简易中央版本库

Mercurial 具有 hgweb 功能，我们只需一个 hg serve 命令就能公开版本库浏览器的 Web 站点。

$ hg serve

启用 hgweb 功能的版本库默认允许 clone/pull，在 hgrc 中添加设置之后还可以进行 push 操作，所以对于一些仅需要在 LAN 内共享的版本库来说，启用 hgweb 功能后就可以当作中央版本库来用，能省去不少功夫。

我们在“.hg/hgrc”中添加以下设置。allow_push 是允许经由 hgweb 进行 push 操作的 user 列表，加上 * 表示不需认证即可 push。push_ssl 用来设置 push 时是否需要 SSL。

[web]
allow_push = *
push_ssl = False

这里不妨加上 -d 选项，让 hgweb 的 Web 服务器常驻。启动之后会显示 hgweb 的 URL。

$ hg serve -d
listening at http://127.0.0.1:8000/ (bound to *:8000)

如果直接执行 hg clone 加 URL，本地版本库名会变成 domain:port，所以我们在 clone 时添加版本库名作为第二传值参数。

$ hg clone http://127.0.0.1:8000/ monjudoh

如下所示，我们在 clone 来的版本库中进行修改，然后提交并 push。

$ cd monjudoh
$ touch monjudoh.txt
$ hg ci -A -m "add monjudoh.txt"
adding monjudoh.txt
$ hg push
pushing to http://127.0.0.1:8000/
searching for changes
remote: adding changesets
remote: adding manifests
remote: adding file changes
remote: added 1 changesets with 1 changes to 1 files

6.2　灵活使用“钩子”

Mercurial 有钩子功能。所谓钩子功能，就是指 Mercurial 在执行特定处理时还能够额外执行其他处理的功能，而且这个额外处理是任意的。比如借助这个功能，我们可以在 push 时发送邮件通知，或者在提交之前自动检测提交是否有疏漏等。

本节将通过几个实际的例子，教各位灵活运用 Mercurial 的钩子功能。

6.2.1　钩子功能的设置方法

钩子功能的设置可通过在 hgrc 的 [hooks] 节下指定 shell 命令或 Python 函数来进行。想让哪个版本库执行钩子功能就在哪个版本库的 hgrc 中进行指定。比如在 6.2.2 节，我们想利用钩子功能在提交时检查编码风格，那么就在本地版本库的 hgrc 中进行指定；又比如在 6.2.5 节，我们想通过钩子功能禁止中央版本库出现多头现象，那么就要在中央版本库的 hgrc 中进行指定。

[hooks]
commit = echo commit;
update = python:myhook.sample

如果在一个钩子事件中指定了多个钩子脚本，那么将执行最后一个。

[hooks]
update = python:myhook.sample1
update = python:myhook.sample2 # 这个钩子事件会被执行

指定为空时不会执行任何操作。我们可以利用空指定覆盖已有操作，从而避免该操作被执行。

[hooks]
update = python:myhook.sample1
update =  # update 钩子事件不会被执行

在钩子事件名后面加上后缀，就能让同一个钩子事件执行多个钩子脚本了。

[hooks]
update.sample1 = python:myhook.sample1
update.sample2 = python:myhook.sample2

6.2.2　尝试钩子脚本

如今网络上有许多已公开的钩子脚本，这里我们以“提交时检验 PEP8 编码风格”的脚本为例进行导入。该脚本包含在名为 hghooks1 的钩子脚本集之中。

1https://pypi.python.org/pypi/hghooks/

这个 hghooks 可通过 pip 安装。

$ sudo pip install hghooks

安装完成后，只要在 hooks 中添加以下设置即可开始使用。

[hooks]
pretxncommit.pep8 = python:hghooks.code.pep8hook

6.2.3　钩子事件

钩子事件种类繁多，下面我们对应命令的执行类型，来了解一下其中的一部分。给版本库设置钩子功能时，要看对应命令是否会给版本库带来变更，这两种情况下所用的钩子事件是不一样的。

不会给当前操作版本库带来变更的命令有 update、从当前版本库 push、被其他版本库 pull 等。

会给当前操作版本库带来变更的命令有 commit、tag、从当前版本库 pull、被其他版本库 push 等。

接下来，我们对应着命令来了解一些已定义的钩子事件。其中，changegroup 表示 changeset 群被拿到版本库时的事件。另外，对 changeset 群中各个 changeset 执行的是 incoming。

◉ update 时

当前操作版本库的变更集不会被变更

① preupdate

② update

◉ 提交时

当前操作版本库的变更集会被变更

① precommit

② pretxncommit

③ commit

◉ 从当前版本库 push/ 被其他版本库 pull 时

向其他版本库发送变更集群的操作。当前操作版本库的变更集不会被变更

① preoutgoing

② outgoing

◉ 向当前版本库 pull/ 被其他版本库 push 时

接受其他版本库的变更集群的操作。当前操作版本库的变更集会被变更

① prechangegroup

② pretxnchangegroup

③ changegroup

④ incoming

我们将对版本库 A 执行各操作后的动作汇总成了下表。

执行的命令

版本库 A 发生的事件

版本库 B 发生的事件

hg update

preupdate, update

无

hg commit

precommit, pretxncommit, commit

无

hg push B

preoutgoing, outgoing

prechangegroup, pretxnchangegroup, incoming

hg pull B

prechangegroup, pretxnchangegroup, incoming

preoutgoing, outgoing

可以看到，对于部分会给当前操作版本库变更集带来变更的命令，其执行时发生的事件名带有 pretxn 前缀。这是因为这些变更是事务性的，钩子事件必须发生在变更处理已结束且事务尚未确定的时间点。

6.2.4　钩子功能的执行时机

接下来详细了解一下钩子功能。

我们看到，版本库发生变更时，有些钩子事件带有 pre 前缀，有些却没有。有 pre 前缀的钩子事件位于执行命令指定的处理之前，没有前缀的则位于执行命令指定的处理之后。pre 可以通过 exit 1 在命令指定的处理被执行之前直接中止命令。

比如我们把 /usr/bin/false 加入 preupdate。

[hooks]
preupdate = echo preupdate;hg parents;/usr/bin/false;
update = echo update;hg parents;

如下所示，如果工作目录的 parent 处于 tip（最新提交）的前一个状态，那么 hg update tip 命令在执行时将被 preupdate 打断。结果就是 update 并没有执行，工作目录的 parent 仍保持原样。

$ hg log -l2
changeset:   12:45648f583d32
tag:         tip
user:        monjudoh <monjudoh@gmail.com>
date:        Fri Dec 02 14:03:11 2011 +0900

changeset:   11:2b1a3a2e6e29
user:        monjudoh <monjudoh@gmail.com>
date:        Fri Dec 02 14:02:55 2011 +0900

$ hg parents
changeset:   11:2b1a3a2e6e29
user:        onjudoh <monjudoh@gmail.com>
date:        ri Dec 02 14:02:55 2011 +0900

$ hg update tip
preupdate
changeset:   11:2b1a3a2e6e29
user:        monjudoh <monjudoh@gmail.com>
date:        Fri Dec 02 14:02:55 2011 +0900

abort: preupdate hook exited with status 1

$ hg parents
changeset:   11:2b1a3a2e6e29
user:        monjudoh <monjudoh@gmail.com>
date:        Fri Dec 02 14:02:55 2011 +0900

版本库发生变更时，钩子事件有 3 种：有 pre 前缀的、有 pretxn 前缀的、没有前缀的。pre 位于命令指定的处理被执行之前，pretxn 位于处理实际执行之后且事务完成之前，无后缀的位于事务完成之后。

比如，我们这里设置 precommit、pretxncommit、commit 这 3 个钩子事件来执行 hg tip。然后执行 hg commit 会发现，从 pretxncommit 起 tip 就被更改了。

[hooks]
precommit = echo precommit;hg tip;
pretxncommit = echo pretxncommit $HG_NODE;hg tip;
commit = echo commit $HG_NODE;hg tip;

$ hg ci -A -m "commit successful"
precommit
changeset:   28:8a68c4364175
tag:         tip
user:        monjudoh <monjudoh@gmail.com>
date:        Fri Dec 02 15:26:47 2011 +0900

pretxncommit d3000a3cea812e687f2f6bac7aaa2716be003cab
changeset:   29:d3000a3cea81
tag:         tip
user:        monjudoh <monjudoh@gmail.com>
date:        Fri Dec 02 15:26:58 2011 +0900
summary:     commit successful

commit d3000a3cea812e687f2f6bac7aaa2716be003cab
changeset:   29:d3000a3cea81
tag:         tip
user:        monjudoh <monjudoh@gmail.com>
date:        Fri Dec 02 15:26:58 2011 +0900
summary:     commit successful

不过，由于 pretxn 位于事务完成之前，所以在 pretxn 中用 exit 1 可以实现回滚。现在我们把 /usr/bin/false 加入 pretxncommit 中试试看。

[hooks]
precommit = echo precommit;hg tip;
pretxncommit = echo pretxncommit $HG_NODE;hg tip;/usr/bin/false;
commit = echo commit $HG_NODE;hg tip;

$ hg parents
changeset:   29:d3000a3cea81
tag:         tip
user:        monjudoh <monjudoh@gmail.com>
date:        Fri Dec 02 15:26:58 2011 +0900
summary:     commit successful

$ hg ci -m "commit failed"
precommit
changeset:   29:d3000a3cea81
tag:         tip
user:        monjudoh <monjudoh@gmail.com>
date:        Fri Dec 02 15:26:58 2011 +0900
summary:     commit successful

pretxncommit c42df85f2e7632b6e7ec124011b11ad8a9a3d61f
changeset:   30:c42df85f2e76
tag:         tip
user:        monjudoh <monjudoh@gmail.com>
date:        Fri Dec 02 15:36:52 2011 +0900
summary:     commit failed

transaction abort!
rollback completed
abort: pretxncommit hook exited with status 1

$ hg parents
changeset:   29:d3000a3cea81
tag:         tip
user:        monjudoh <monjudoh@gmail.com>
date:        Fri Dec 02 15:26:58 2011 +0900
summary:     commit successful

可以看到，pretxncommit 时反映出的提交已经被撤销。灵活使用 pretxncommit、pretxnchangegroup 能帮助我们验证提交的结果或 push 的结果是否有问题，并且在有问题时实现回滚。

6.2.5　编写钩子脚本

◉ 用shell 脚本实现钩子脚本

我们以禁止中央版本库出现多头现象的钩子脚本为例，来了解一下用 shell 脚本实现的钩子脚本。首先如下所示，将脚本放到任意位置（“.hg”之下就不错）。

#!/bin/bash
# force-one-head
# add the following to <repository>/.hg/hgrc :
# [hooks]
# pretxnchangegroup.forceonehead = /path/to/force-one-head

if [ $(hg heads --template "{branch}\n"|sort|uniq|wc -l) != $(hg heads --template "{branch}\n"|sort|wc -l) ]; then
    echo "There are multiple heads."
    echo "Please 'hg pull' and get your repository up to date first."
    echo "Also, don't 'hg push --force' because that won't work either."
    exit 1
fi

接下来，在 hgrc 中将其指定为 pretxnchangegroup 的钩子脚本。具体方法如下。

[hooks]
pretxnchangegroup.forceonehead = .hg/force-one-head

这个脚本被指定给了 pretxnchangegroup，所以它的执行时机位于 push 处理执行之后且事务完成之前。因此我们可以在 push 完毕的状态下执行各种 Mercurial 命令。这里我们利用 heads 命令检查是否出现多头现象，如果出现则通过 exit 1 回滚。这就是带 pretxn 前缀的钩子事件的用法之一。

在 Mercurial 上禁止中央版本库出现多头现象

http://labs.timedia.co.jp/2011/09/reject-multiple-head.html

◉ 用 Python 脚本实现钩子脚本

这里我们讲一个用 Python 脚本实现钩子脚本的例子，即在每次提交时都对第 2 章中完成的留言板发送一次 diff（LIST 6.10）。

LIST 6.10　myhook.py

# coding: utf-8
import logging
import urllib

# 接收方URL
POST_URL = 'http://127.0.0.1:8000/post'

# 发送信息的格式
MESSAGE_FORMAT = """%(description)s
%(diff)s"""

def http_post(url, data):
    """ 用POST 方法向url 发送data
    """
    fp = urllib.urlopen(url, urllib.urlencode(data))
    return fp.read()

def postdiff(ui, repo, hooktype, node=None, source=None, **kwargs):
    """ 将差别用HTTP 进行POST 的钩子函数
    """
    # 获取提交的上下文对象
    context = repo['tip']
    # 从上下文中获取差别列表（由于是迭代器对象，所以要列表化）
    diff_list = list(context.diff())
    # 将差别结合成文本
    text_diff = ''.join(diff_list)
    # 获取用户
    user = context.user()
    # 获取概要
    description = context.description()
    # 生成要发送的信息
    message = MESSAGE_FORMAT % {'description': description, 'diff': text_diff}
    # 生成发送数据的字典
    data = {
        'name': user,
        'comment': message,
    }
    # 发送
    http_post(POST_URL, data)

将 myhook.py 放到 PYTHONPATH 的影响范围之下，然后在 hgrc 中作如下描述。

[hooks]
commit = python:myhook.postdiff

提交后，会显示如图 6.1 所示的结果。

图 6.1　向留言板发送的提交信息

用 Python 写出的钩子脚本能直接使用 context 对象。当输出结果需要用版本库内数据进行条件判断等加工时，shell 脚本不但要处理输出格式，还必须对字符串进行操作。相对地， Python 脚本只需要从管理各修订版元数据的 context 对象中获取适当信息即可。

另外从管理方面上讲，Python 脚本可以利用 Python 的包管理，用 pip 就可以安装，十分方便。

6.3　分支的操作

下面我们来看看 Mercurial 对分支的操作。所谓分支，是指版本库中独立存在的开发线。分布式版本控制系统的一大优势就在于各个本地环境中的版本库互相独立。相对于 Subversion 等集中式版本控制系统，分布式版本控制系统能更放心地处理分支。

LIST 6.11　hg branch

$ hg branch
default

如 LIST 6.11 所示，默认情况下，版本库中只存在 default 分支。现在我们来创建一个新分支（LIST 6.12）。default 分支是版本库中原本就存在的分支。

LIST 6.12　hg branch（创建分支）

$ hg branch test-branch
$ hg branch
test-branch

分支创建出来之后，我们可以看到当前分支变成了 test-branch。不过对 Mercurial 而言，创建分支的流程到这里还没有结束。只有我们向新分支做过提交之后，这个分支才会具有实体。所以我们先向分支中添加文件，然后再看看效果（LIST 6.13）。

NOTE

Mercurial 允许用户在创建分支后直接进行提交，不必添加或删除文件。

LIST 6.13　向 test-branch 添加文件以及提交

$ touch test2.txt
$ hg add test2.txt
$ hg commit

至此分支创建完毕，我们来查看所有分支（LIST 6.14）。

LIST 6.14　hg branches（查看所有分支）

$ hg branches

test-branch  1:bcbc567db3dd
default      0:74471564b074 (inactive)

不出意外应该能看到 test-branch 和 default 两个分支。接下来我们回到 default 分支。

LIST 6.15　hg update（分支间的切换）

$ hg update default

如 LIST 6.15 所示，用 hg update 能在分支间进行切换。现在我们已经回到了 default 分支。下面来看看分支间合并的相关内容。

6.4　关于合并

利用 Mercurial 等版本控制系统进行多项工作时，必然离不开成果的合并。Mercurial 的 hg merge 命令能自动通过三路合并为我们完成合并工作，但并不是说所有情况下都能正常合并。本节，我们将学习基本的合并流程以及发生冲突时的应对方法。熟练掌握合并的相关技巧，能让各位对版本控制系统更加得心应手。

6.4.1　未发生冲突的合并

多名成员并行开发时会发生多头现象。下面例子中就存在 rev1 和 rev2 两个头。要解决这一问题就必须进行合并。

$ hg log -G --style compact
o  2[tip]:0   c0d244a079ce   2011-11-18 11:40 +0900   tokibito
|    tokibito
|
| @  1   ead8ad7a4d9f   2011-11-16 11:07 +0900   monjudoh
|/     monjudoh
|
o  0   fe75405e6383   2011-11-16 11:05 +0900   monjudoh
     first commit

假设现在我们位于 rev1，进行合并要先运行 merge 命令再进行提交。在同一分支内合并多头时，merge 命令不需要加任何传值参数。

$ hg merge
1 files updated, 0 files merged, 0 files removed, 0 files unresolved
(branch merge, don't forget to commit)
$ hg ci -m "merge"

这样一来多头就被合并成了一个头。

$ hg log -G --style compact
@    3[tip]:1,2   b5f62f57038b   2011-11-18 14:07 +0900   monjudoh
|\     merge
| |
| o  2:0   c0d244a079ce   2011-11-18 11:40 +0900   tokibito
| |    tokibito
| |
o |  1   ead8ad7a4d9f   2011-11-16 11:07 +0900   monjudoh
|/     monjudoh
|
o  0   fe75405e6383   2011-11-16 11:05 +0900   monjudoh
     first commit

工作中我们也经常会遇到合并两个分支的情况。下面的例子是包含 default 和 tokibito2 两个分支的状态。

2okibito 为本章撰写者冈野真也先生的 Twitter 用户名。——编者注

$ hg log -G
o  changeset:   2:a663e1bf8f71
|  branch:      tokibito
|  tag:         tip
|  parent:      0:fe75405e6383
|  user:        tokibito
|  date:        Fri Nov 18 11:40:13 2011 +0900
|  summary:     tokibito
|
| @  changeset:   1:ead8ad7a4d9f
|/   user:        monjudoh <monjudoh@gmail.com>
|    date:        Wed Nov 16 11:07:59 2011 +0900
|    summary:     monjudoh
|
o  changeset:   0:fe75405e6383
   user:        monjudoh <monjudoh@gmail.com>
   date:        Wed Nov 16 11:05:34 2011 +0900
   summary:     first commit

此时就不能用无传值参数的 merge 命令了。因为分支内不存在多头，无法自动选择合并对象。

$ hg merge
abort: branch 'default' has one head - please merge with an explicit rev
(run 'hg heads' to see all heads)

假设我们位于 default 分支的 rev1，现在只要将合并对象的分支名交给 merge 命令就能完成合并。

$ hg merge tokibito
1 files updated, 0 files merged, 0 files removed, 0 files unresolved
(branch merge, don't forget to commit)
$ hg ci -m "merge"

执行后的合并情况如下。我们是在 default 分支下指定 tokibito 分支执行了 merge 命令，所以被合并的那个修订版现在属于 default 分支。这里千万注意别搞错合并方向。

$ hg log -G
@    changeset:   3:7dffb01df37b
|\   tag: tip
| |  parent:      1:ead8ad7a4d9f
| |  parent:      2:a663e1bf8f71
| |  user:        monjudoh <monjudoh@gmail.com>
| |  date:        Fri Nov 18 14:17:13 2011 +0900
| |  summary:     merge
| |
| o  changeset:   2:a663e1bf8f71
| |  branch:      tokibito
| |  parent:      0:fe75405e6383
| |  user:        tokibito
| |  date:        Fri Nov 18 11:40:13 2011 +0900
| |  summary:     tokibito
| |
o |  changeset:   1:ead8ad7a4d9f
|/   user:        monjudoh <monjudoh@gmail.com>
|    date:        Wed Nov 16 11:07:59 2011 +0900
|    ummary:      monjudoh
|
o    changeset:   0:fe75405e6383
     user:        monjudoh <monjudoh@gmail.com>
     date:        Wed Nov 16 11:05:34 2011 +0900
     summary:     first commit

6.4.2　合并时发生冲突以及用文本编辑器解决冲突的方法

虽然大部分情况下合并都能自动完成，然而一旦发生冲突，就需要我们来手动解决问题了。

假设有如下多头情况。

$ hg log -G --style compact
o  2[tip]:0   0dffbb8f7780   2011-11-18 15:09 +0900   monjudoh
|    other
|
| @  1   a239fe812ab0   2011-11-18 15:08 +0900   monjudoh
|/     this
|
o  0   0648c3b5afbd   2011-11-18 15:07 +0900   monjudoh
     base

rev0、1、2（base、this、other）中 conflict.txt 的文件内容分别如下所示。

rev0（base）

A
A
A
A
A

rev1（this）第 3 行变更为 B

A
A
B
A
A

rev2（other）第 3 行变更为 C

A
A
C
A
A

然后我们在 rev1（this）执行 merge 命令。

LIST 6.16　执行合并查看冲突

$ hg merge
merging conflict.txt
merge: warning: conflicts during merge
merging conflict.txt failed!
0 files updated, 0 files merged, 0 files removed, 1 files unresolved
use 'hg resolve' to retry unresolved file merges or 'hg update -C .' to abandon

不出意外应该会看到如 LIST 6.16 所示的消息。这就是发生了冲突的状态，我们来解决它。

首先执行一个查看冲突状态的命令。

我们用 -l 选项执行 hg resolve 命令，发现 conflict.txt 仍处于未解决状态（LIST 6.17）。

LIST 6.17　hg resolve -l（查看冲突）

$ hg resolve -l
U conflict.txt

U 表示“未解决”（Unresolved）。打开文件可以查看冲突的位置，具体请参考“查看冲突位置”部分的内容。这里与其他版本控制系统一样，可以手动进行修正。 **

◉ 查看冲突位置

我们打开合并时发生冲突的 conflict.txt 文件，会发现内容变成了下面的样子。其实简单说来，所谓发生冲突，就是两个头对同一个文件的同一行进行了变更，计算机无法从机器逻辑层面上决定采用哪一方。要解决这个问题，就必须由人类明确表示出采用哪个变更。

A
A
<<<<<<< local
B
=======
C
>>>>>>> other
A
A

于是我们用文本编辑器打开 conflict.txt，手动编辑出合并后的 conflict.txt 文件。这里我们采用 rev1（this）的变更。

A
A
B
A
A

Mercurial 当然不知道人类是否解决了冲突，所以现在执行 hg resolve -l 仍然会出现之前的结果。此时我们要用 hg resolve -m 将当前状态从“冲突未解决”设置为“冲突解决完毕”（LIST 6.18）。然后只要提交即可。

LIST 6.18　hg resolve -m（解决冲突）

$ hg resolve -m conflict.txt
$ hg resolve -l
R conflict.txt
$ hg ci -m "merge"
$ hg log -G --style compact
@    3[tip]:1,2   c72367726805   2011-11-18 16:08 +0900   monjudoh
|\     merge
| |
| o  2:0   0dffbb8f7780   2011-11-18 15:09 +0900   monjudoh
| |    other
| |
o |  1   a239fe812ab0   2011-11-18 15:08 +0900   monjudoh
|/     this
|
o  0   0648c3b5afbd   2011-11-18 15:07 +0900   monjudoh
     base

6.4.3　合并的类型与冲突

上面我们提到冲突是“计算机无法从机器逻辑层面上决定采用哪一方”的状态，这里将对此作进一步的说明。

这里依然和前面一样，假设出现了如下多头现象。

$ hg log -G --style compact
o  2[tip]:0   0dffbb8f7780   2011-11-18 15:09 +0900   monjudoh
|    other
|
| @  1   a239fe812ab0   2011-11-18 15:08 +0900   monjudoh
|/     this
|
o  0   0648c3b5afbd   2011-11-18 15:07 +0900   monjudoh
     base

rev0（base）的 conflict.txt 文件的内容如下。

A
A
A
A
A

我们将 rev1、2（this、other）对第 3 行的修改情况以及合并后的结果总结成了下表（合并类型）。

合并类型

类型

this

other

结果

①

A

A

A（无变更）

②

B

A

B（采用 this 的变更）

③

A

C

C（采用 other 的变更）

④

B

B

B（碰巧合并成功）

⑤

B

C

冲突

①～④都是合并成功。①的双方都没有作变更，所以合并结果中也没有出现变更。

②和③都只有一方作了变更，所以只采用变更的一方。

④比较特殊，虽然双方都对同一行作了变更，但变更内容是相同的，所以可以直接采用。具体④的情况下能不能成功合并还要看版本控制系统的种类，虽然 Mercurial 认为是成功，但有一部分版本控制系统会判定为冲突。

⑤是双方对同一行作了不同变更，于是出现了冲突。

6.4.4　用 GUI 的合并工具进行合并

除命令行之外，Mercurial 还支持用 GUI 的合并工具进行合并。

◉ KDiff3

这里以 KDiff33 为例介绍 GUI 的合并工具。KDiff3 支持 OS X、Windows、Linux，可以进行三路合并，是 GPLv2 的 OSS。

3http://kdiff3.sourceforge.net/

◉ 安装 KDiff3

任何环境下都能轻松安装 KDiff3。

○ 在 OS X 上安装

下载 dmg 并解压，将解压出来的 kdiff3.app 放到应用程序文件夹中。OS X10.9 无法直接使用放在文件夹中的 kdiff3.app。初次双击启动 kdiff3.app 时，系统会弹出对话框通知无法启动（图 6.2）。

图 6.2　通知 kdiff3.app 无法启动的对话框

于是我们需要以下流程。

初次启动时不要双击，要点右键通过上下文菜单打开（图 6.3）。

图 6.3　通过上下文菜单打开

此时会弹出确认启动的对话框，点击“打开”便能启动 kdiff3.app（图 6.4）。只要第一次成功启动，以后我们就可以通过双击打开它了。

图 6.4　确认启动的对话框

○ 在 Windows 上安装

下载安装包并运行。

○ 在 Linux（基于 Debian）上安装

$ sudo apt-get install kdiff3

○ 在 hgrc 中设置 merge-tools

要想关联 KDiff3，在合并发生冲突时通过它来解决问题，需要在 hgrc 或 Mercurial.ini（Windows 的情况下）中作以下设置。

[merge-patterns]
**.* = kdiff3
[merge-tools]
# Override stock tool location
# MacOSX
kdiff3.executable = /Applications/kdiff3.app/Contents/MacOS/kdiff3
# Windows
# kdiff3.regkey=Software\KDiff3
# kdiff3.regappend=\kdiff3.exe
# kdiff3.fixeol=True
# kdiff3.gui=True
# Linux
# kdiff3.executable = ~/bin/kdiff3

# Specify command line
kdiff3.args = $base $local $other -o $output
# Windows
# kdiff3.args=--auto --L1 base --L2 local --L3 other $base $local $other -o $output
# Give higher priority
kdiff3.priority = 1

在 merge-tools 节的 kdiff3 中设置 KDiff3 的命令以及命令行对象，然后在 merge-patterns 节中设置所有文件冲突都使用 kdiff3 解决。

○ 用 KDiff3 解决冲突

我们还以 6.4.2 节描述的状态为例，给版本库执行 hg merge 命令。随后 conflict.txt 发生冲突，KDiff3 自动启动（图 6.5）。

图 6.5　因发生冲突而启动 KDiff3 之后的状态

左、中、右的 A、B、C 中分别是多头共同的祖先版本、执行 merge 命令时的 parent 修订版、执行 merge 命令时的另一个最新修订版中的 conflict.txt 内容，下方的视图显示了合并完成后的结果。我们可以看到发生冲突的地方显示为红色。前面也说了，解决冲突就是要在发生冲突的位置明确选择采用（或者不采用）某一方的变更。

在 KDiff3 中，右键点击发生冲突的位置，就会显示菜单供我们选择变更（图 6.6）。

图 6.6　KDiff3 解决冲突时选择变更的菜单

这里我们选 B（图 6.7）。

图 6.7　KDiff3 选择采用 B 时的状态

现在的状态是冲突已解决，界面中也明确显示出了合并对象的行采用了哪一个结果。随后保存文件并退出即可。

$ hg merge
merging conflict.txt
0 files updated, 1 files merged, 0 files removed, 0 files unresolved
(branch merge, don't forget to commit)

关闭 KDiff3 的同时 merge 命令也就执行完了。与未设置合并工具的合并不同，这里没有出现 Unresolved 的文件。最后只要提交一下，这次合并工作就完工了。

6.5　GUI 客户端

在前面的说明中，我们一直在使用 Mercurial 的命令工具（CUI 客户端），其实 Mercurial 也有 GUI 客户端工具的。本节我们将了解一下 GUI 客户端的优缺点，以及一些工具。

6.5.1　GUI 客户端的介绍

这里先介绍一些主要的 GUI 客户端及其导入方法。

◉ TortoiseHg

TortoiseHg4 最早是 Windows 专用的 GUI 客户端，如今已经可以在 Windows/OS X/Linux 上跨平台使用了（图 6.8）。

4http://tortoisehg.bitbucket.org/

图 6.8　TortoiseHg 的示例界面

○ 安装

在 OS X 环境下，要通过 http://tortoisehg.bitbucket.org/download/index.html 的相应链接下载 zip 格式的 app 压缩包，解压后将 TortoiseHg.app 放到应用程序文件夹中并安装。之后的流程与安装 KDiff3 时一样。在 Windows 环境下，只要从 http://tortoisehg.bitbucket.org/ 下载安装包直接运行安装即可。如果是 Linux，http://tortoisehg.bitbucket.org/download/index.html 也写了哪个发布版有程序包可用。如果各位的环境有相应程序包，可以通过 apt 等包管理系统进行安装。

◉ SourceTree

SourceTree5 是 Atlassian 提供的商用 GUI 客户端，同时支持 Mercurial 和 Git。最早是 OS X 专用的 GUI 客户端，现在则可以同时支持 OS X 和 Windows（图 6.9）。

5http://www.sourcetreeapp.com/

图 6.9　SourceTree 的示例界面

○ 安装

如果是 OS X 环境，需要从 http://www.sourcetreeapp.com/ 下载 dmg 文件，然后将解压出来的 SourceTree.app 放入应用程序文件夹并安装。Mac App Store 上只提供了旧版本，所以请不要用那个。如果是 Windows 环境，则要从 http://www.sourcetreeapp.com/ 下载安装包直接安装。

至于合并工具的设置，在“环境设置”→“Diff”的“外部代码差异对比 / 合并”部分进行（图 6.10）。

图 6.10　设置 SourceTree 的合并工具

6.5.2　GUI 客户端的优点

下面我们以 TortoiseHg 为例来看看 GUI 客户端的优势。

◉ 显示历史图

GUI 客户端最大的特点就是时常显示历史图，并且能以它为起点进行多种操作。

如图 6.11 所示，打开工作台，上部窗格显示历史图，左下窗格显示历史图中已选定的修订版与其 parent 之间的 status，右下的窗格则显示提交日志以及 status 窗格中已选定文件的 diff。在历史图上右键点击各修订版可以直接执行 update 等操作，十分方便。

图 6.11　TortoiseHg 的工作台

◉ 工作目录的状态与差别的显示

在历史图中，工作目录与修订版享受同等待遇。选择工作目录后，左下窗格显示 status，右下窗格显示提交日志的草稿以及 diff（图 6.12）。

图 6.12　TortoiseHg 的工作台（选择了工作目录的状态）

提交也在这个界面中进行。由于提交时能看到文件复制的状态，所以能有效地减少提交方面的失误，比如出现了不该有的变更，或者在没有进行 add/remove 的情况下就进行提交等。

◉ 合并

○ TortoiseHg

TortoiseHg 会在合并发生冲突时显示 Unresolved 文件的列表。

图 6.13　TortoiseHg 解决冲突的界面

如图 6.13 所示，我们可以选择 Unresolved 的文件并点击右键，并从下述解决方案中进行选择。

通过 Mercurial 消除冲突

通常我们在合并时都选择了“尽量自动消除合并冲突”，所以不选用这个

启动 GUI 客户端消除冲突

采用当前所处位置的最新版本，并将状态改为 Resolved

采用合并目标一方的最新版本，并将状态改为 Resolved

编辑 Unresolved 文件后将状态改为 Resolved

6.5.3　GUI 客户端的缺点

GUI 客户端的缺点如下。

操作对象必须是 PC 本地版本库。通过 ssh 在服务器上工作时无法使用它们

hg 命令只要导入 extension 就能轻松地添加功能，但 GUI 客户端只能使用其固定支持的 extension

虽说有着这些缺点，但几乎所有 GUI 客户端都提供了打开 terminal 的功能，在用户觉得用 CUI 更好的时候能随时切换到 CUI。至于在服务器上工作的问题，我们平时的提交可以通过 CUI 进行，遇到合并等用 GUI 更方便的操作时，可以先把版本库 pull 到本地再用 GUI 客户端处理。

6.6　考虑实际运用的 BePROUD Mercurial Workflow

多人合作进行开发时，难免会遇到几个本地环境之间的提交出现矛盾，以及为解决矛盾需要进行合并的情况。另外，在多种功能同时进行开发的过程中，很可能后实现的功能需要先发布。为防止这类情况给团队开发带来混乱，保证开发和发布的顺利进行，我们必须事先确定好版本库创建分支与合并分支的时机。

另外，我们的理想情况是所有工作人员都可以通过命令行对版本库进行操作，但有些时候受条件所限并不能满足这一要求。为在这种情况下也能保证工作进度，我们需要用到一些工具，这些工具也会在本部分进行介绍。

6.6.1　概述

这里将介绍的是我们自己使用 Mercurial 进行源码管理的工作流程和管理结构。我们将在讲述项目相关人员以及开发方法等背景的基础上，为各位说明工作流程的相关内容。

NOTE

本节内容以 bpmercurial-workflow 文档为基础。文档与源码的许可证为 CC6 BY 2.1。本节讲述的流程与 Web 版相同，只是为了方便阅读对结构和文章作了少量修改。

bpmercurial-workflow 文档

http://beproud.bitbucket.org/bpmercurial-workflow/ja/

CC BY 2.1

https://creativecommons.org/licenses/by/2.1/au/

6Creative Commons，简称 CC，中国大陆正式名称为知识共享，是一个非营利组织，也是一种创作的授权方式。此组织的主要宗旨在于增加创意作品的流通可及性，作为其他人据以创作及共享的基础，并寻找适当的法律以确保上述理念。——编者注

6.6.2　背景

既然要说明工作流程，那自然少不了项目的背景。所以我们先来了解一下背景。

◉ 目标项目

在这个工作流程中我们的目标项目是开发应用于 B to C 的 Web 站点（包括系统在内）。

多个小开发任务（包括设计变更等）并行进行，支持发布顺序不定的情况。

源码等成品的交付工作，均通过 Mercurial 向客户管理的版本库（后述的发布版本库）进行 push。

◉ 这个工作流程的相关人员

○ 程序员

提交系统的源码，主要负责管理版本库的人员。分支的合并与切换也由程序员负责。

服务器上已经为成员准备了各自的用户账户。为方便说明，我们这里假设程序员的用户名为 programmer。

○ 设计师

提交 HTML 模板、各种媒体文件的人员。也可能是网页制作工程师。

设计师需要向服务器上传文件，所以也备有用户账户。

○ 客户

接受开发完成的源码等成品的人员。通过后述的发布版本库取走源码。

◉ 工作环境

程序员和设计师对源码进行修改、测试等工作的环境，即服务器或本地机器上的环境。

本书中，我们将在给开发专设的服务器（Linux）上对版本库进行操作。至于设计师工作的服务器环境，我们也已经保证该环境能启动应用程序服务器来查看模板文件等是否正常工作。以 Python/Django 为例来说，就是用 runserver 等来负责启动。

6.6.3　版本库的结构

我们使用的是分布式版本控制系统，所以必须掌握版本库的结构以及各版本库的作用（图 6.14）。

图 6.14　版本库的结构

◉ 主版本库（master）

包含所有成果的版本库。用来积累以及共享整个团队每天开发出来的成果（比如开发中的分支等）。

版本库路径示例：/var/hg/example-prj

◉ 发布版本库（release）

装有已完成开发且等待发布到生产环境中的源码。放入这个版本库中的代码就是我们交给客户的成品。客户会使用这个版本库中的代码进行部署等工作。一般只有 default 分支处于活动状态。

进行发布（交付）工作时，我们要把 default 分支从主版本库 push 到发布版本库。

版本库路径示例：/var/hg/example-prj-release

◉ 工作版本库（working）

进行源码添加及修改等工作的版本库。开发成果要先提交到这个版本库，然后再 push 到主版本库。获取其他人的开发成果时，要从主版本库 pull 到这个版本库。

工作版本库既可以在服务器上也可以在本地环境上。

由于本书所介绍的开发将在服务器上进行，所以我们将主版本库 clone 至工作人员在服务器上的主目录下。

版本库路径示例：/home/programmer/example-prj。

6.6.4　提交源码

源码写好后要提交到工作版本库。

default 分支要时常用于发布，所以在提交成果时，除了为发布而进行合并以外都要使用其他分支。

◉ 问题与分支

我们在第 5 章中也提到了，修改源码时要先发起问题，然后根据问题编号创建相应分支。

○ 示例

为了修改源码，我们在问题跟踪系统中创建了编号为 #5 的问题（图 6.15）。

图 6.15　在问题跟踪系统中创建的问题

这种情况下，修改后的源码要提交到 t5 分支。于是我们新建 t5 分支。

$ cd ~
$ cd example-prj
$ hg branch  # 查看当前分支
default
$ # 问题编号为5，所以从default 分支创建名为t5 的分支
$ hg branch t5

接下来，将源码添加成为管理对象，然后提交。

$ hg add
$ hg commit

这样就可以将我们的修改提交上去了。

在这个状态下，default 分支并没有被修改，所以就算其中的代码传到其他版本库（比如发布版本库等），我们所作的修改也不会被发布（图 6.16）。

图 6.16　向分支提交之后的历史图

一般说来，修改过的源码不会很快被发布。

如果我们将暂不发布的源码提交到了 default 分支，那么一旦遇到需要插入临时发布的情况，就必需面临很多容易出问题的操作，比如多头现象、删除不合适的变更等。

只有到了发布那一刻才能将源码提交至 default 分支，也就是在开发过程中保证源码只出现在开发分支里，这样才能保证安全。

我们将工作版本库的更改传到主版本库，具体代码如下。

$ hg push --new-branch  # 指定new-branch 远程添加分支

专栏　hg push --new-branch

不加任何选项的 hg push 命令不能在远程版本库创建新的头。不但已有的分支受此限制，而且在远程版本库新建分支的操作也包含在内。指定 --new-branch 选项可以在远程版本库添加新分支。另外，即便指定了 --new-branch 选项，我们依然无法给已有分支创建新的头，所以可以放心大胆地创建新分支并 push 变更。还有一点要注意，就算我们已经将本地创建的工作分支与 default 等合并，在 push 时仍然需要指定 --new-branch 选项。

6.6.5　提交设计

如何对待设计模板以及媒体文件是个非常难的点，而且根据设计师的技术以及工作人数的不同，这项工作的难度会有极大变化。一般说来，我们会使用 GUI 客户端通过 FTP、SCP 等将文件上传至服务器，确认其正常工作后再直接在服务器上提交文件。

◉ 在分支中进行设计

设计模板和媒体文件也要和源码一样提交到分支中。这是为了保证在与系统合并失败时能立刻还原到正常工作的状态。至于分支名，我们建议也和源码一样，起一个与问题编号相对应的名字。

如果设计需要频繁更改但系统没有变更，那么上面的方法会显得很繁琐，工作量很大。这种情况可以创建一个设计专用的分支，把所有和设计相关的变更都提交到这里（图 6.17）。

$ cd example-prj
$ hg branch
default
$ hg branch design  # 从default 创建设计专用的分支
$ hg commit  # 提交

图 6.17　向设计专用分支提交后的历史图

◉ 伴随系统变更而产生的设计提交

在添加、修改系统源码之后，如果需要对设计进行添加或修正，那么设计师必须先将查看设计的分支切换至已提交系统源码变更的分支，然后再进行自己的提交。

6.6.6　分支的合并

◉ 发布时的合并

为发布（交付）在工作分支中开发的功能或设计，我们需要先将工作分支合并到 default 分支（图 6.18）。

$ hg update default
$ hg branch
default
$ # 为发布t5 分支中的变更，要将该分支合并到default 分支
$ hg merge t5
$ hg commit

图 6.18　t5 分支合并到 default 分支后的历史图

◉ 用来追踪最新变更的合并

发布之后，未发布的分支也必须吸收这些变更，也就是要将 default 分支合并到对象分支（图 6.19）。把 default 分支的成果吸收到所有对象分支中能带来很多好处，比如对象分支向 default 分支合并时不会出现冲突。另外，对象分支的头与合并后的修订版，即 default 分支的新头之间不会有任何差别。这意味着只要合并前的分支通过了测试，合并后也必然能通过测试。

$ hg update t3
$ # 将default 分支合并到t3 分支，使t3 分支吸收最新的代码
$ hg merge default
$ hg commit

图 6.19　default 分支合并到 t3 分支后的历史图

专栏　如何处理已经无用的分支

当我们完成某部分开发，将与问题相对应的分支合并到 default 之后，这个分支就没有用了。但是，它仍然会以（inactive）的形式残留在 Mercurial 的分支一览中。

$ hg branches
default            7:a587bc0eb68e
design             6:e3f875bb6623
t3                 3:0440d428d18a
t2                 2:c4b2cd02f0c5
t1                 1:c65aded0fe16
t5                 4:9647e05b7580 (inactive)  # 已经合并到default的分支仍会留在一览表里

inactive（非活动）状态表示该分支已经合并到其他分支（这里是 default）了。因此，有些尚未被发布的分支也可能进入 inactive 状态。如果不分清已经发布完毕的无用分支和仍要使用的分支，就很可能酿成遗漏发布等事故。Mercurial 有一个专门表示无用分支的状态closed（已关闭），我们可以将没有用的分支设置成这个状态。关闭某个分支（转入 closed 状态）时，需要先切换到该分支下，然后在提交时指定 --close-branch。

$ hg update t5
$ hg branch
t5
$ hg commit --close-branch  # 将t5分支转为closed状态
$ hg branches  # 已被close的t5不再出现在一览表中
default                         7:a587bc0eb68e
design                          6:e3f875bb6623
t3                              3:0440d428d18a
t2                              2:c4b2cd02f0c5
t1                              1:c65aded0fe16

closed 状态的分支虽然不会显示在 hg branches 命令的一览表中，但这并不代表分支被删除了。虽然不关闭分支不会对开发造成影响，但考虑到事故风险，还是建议各位将无用的分支关闭。

6.6.7　集成分支

在统合多个分支的变更内容时，我们要创建一个集成分支来合并它们。

◉ 创建问题

先创建问题来确定分支编号（图 6.20）。

图 6.20　集成分支的问题

◉ 创建集成分支

创建与问题编号相对应的集成分支 t6。

$ hg update default
$ hg branch t6
$ hg commit  # 提交分支

将要集成的对象分支合并到 t6（图 6.21）。

$ hg update t6  # 更新集成分支
$ hg merge t1  # 合并t1 分支
$ hg commit
$ hg merge t3  # 合并t3 分支
$ hg commit

图 6.21　将对象分支合并到用于查看的分支后的历史图

发布集成分支中的全部内容时，要将集成分支 t6 合并到 default 分支。

$ hg update default
$ hg merge t6  # 集成分支
$ hg commit

专栏　集成分支的用武之地

在变更内容出现冲突，或者需要在特定时间整合分支并统一发布等情况下，集成分支会显得非常好用。

另外，我们还可以将开发完成的各功能分支先合并到集成分支，经过综合测试之后再向 default 合并。

6.7　小结

本章就 Mercurial 的使用方法进行了说明，内容如下。

钩子功能的概述与活用方法

分支、合并以及消除冲突的方法

GUI 客户端的介绍

另外，还对多人开发时的工作流程进行了说明，它对于防范冲突及运用上的不统一有着重要的意义。

如今，无论人多人少，Mercurial 等版本控制系统都是开发中必备的工具，能否用好版本控制系统已成为左右开发效率的一个重要因素。

专栏　Git 与 Mercurial 的区别

Git 是一种广受欢迎的分布式版本控制系统，与 Mercurial 齐名。因此，我们能看到很多 Git 与 Mercurial 的命令对照表，这就是为 Git 用户准备的 Mercurial 入门，或者是为 Mercurial 用户准备的 Git 入门。不过，如果让 Git 用户单纯根据命令对照表来使用 Mercurial，或者反过来让 Mercurial 用户根据对照表使用 Git，恐怕会是一种很危险的行为。要知道，虽然 Git 和 Mercurial 同为分布式版本控制系统，但二者的模型并不相同。乍看上去，人们只要记住 Git 中的命令和 Mercurial 中的命令的差别，就能随便在二者间换着用了，然而即使有对照表，也无法改变它们不同的本质，所以对很多操作都是都会产生误解，这早晚会让我们栽跟头。举个例子更能说明 Git 和 Mercurial 模型间的差异，Mercurial 的版本库由提交对象的图表构成，而 Git 的版本库由提交对象的图表和引用构成。

这个差别最明显地体现在对分支的看法以及同步（push/pull）上。

Mercurial 的分支本质上是给提交对象的分支的根部命了名。严格说来，分支只不过是被命了名的各个提交对象的参数。但是，在我们给子分支新命名之前，子分支会继承父分支的名字，所以说它在给分支的根部命名。

相对地，Git 的分支是给提交对象的图表的最新端命了名。这是一个每次提交都会跟着最新端切换的引用。Git 的 fast-foward 合并其实就是这个表示分支的引用的切换，分支的删除其实就是引用的删除。因此，Mercurial 中既没有 fast-foward 合并也没有分支的删除（fast-foward曾被导入过，但与 Git 的 fast-foward 合并仍是貌相似而质不同）。

如果把 push/pull 看作版本库的同步，那么版本库模型不同的理由将更加显而易见。Mercurial 的版本库是提交对象的图表，因此只需将提交对象图表的差别同步即可。push 操作中只需发送对方没有的那部分图表，pull 则只需获取自己没有的那部分图表。比如“删除历史”就无法同步。就算我们加上 -f 选项，最多也就是让历史受到污染（共享了多余的子图表）。

Git 的版本库是提交对象的图表和引用，因此双方必须保持同步。切换引用的必要性致使 pull 需要伴随合并操作。如果不进行合并，该分支就将出现 remote 的和 local 的两个最新端。但是 Git 的分支是给最新端命的名，这就要求一个分支只能有一个最新端。Git 的 pull 之所以必须以分支为单位，恐怕就是因为存在这样一个需主观意识判断的合并过程。push 无法进行主观意识判断，所以只有满足 fast-foward 合并（不需主观意识判断的合并）的情况下才允许 push。

在更改历史上也有显著差异。Git 在日常使用中充满了更改历史，所以习惯 Git 的人在用 Mercurial 时总会去找类似的操作。但是，此时直接按对照表操作会出现很大隐患。虽然 Git 和 Mercurial 中都有“更改历史”，但二者实际要做的事却完全不同。Git 是新建图表并将引用切换到最新端，所以在 GC 启动之前仍然保留着更改前的图表。这个图表我们只是看不到罢了，但仍可以通过 reflog 引用它。相对地，Mercurial 是实际更改原有图表，即向图表添加新的子图表并删除旧的子图表。一个由图表的引用构成，一个由图表直接构成，这里，二者的差别非常显著。正因为如此，Git 在更改失败时只需引用 reflog 寻找更改前的修订版（分支的最新端），将这个修订版设置为该分支的头即可完成恢复，但 Mercurial 就必须通过 bundle backup 恢复旧的子图表，然后用 strip 命令删除新的子图表。可见，由于 Git 与 Mercurial 在模型方面存在差异，导致更改历史有着实质上的不同，更改历史失败后的恢复难度及安全性也都大相径庭。所以各位在用M ercurial 更改历史时，建议先让保存原历史的选项有效，等确认更改无误后再通过 strip 命令删除旧的子图表。

第 7 章　完备文档的基础

这世上有着数不尽的项目，其中既有工作项目，也有爱好者们开发的开源项目。但是，这些项目中有相当大一部分都没有写文档，或者事后才补写文档。为什么开发者们不喜欢写文档呢？这其中包含着怎样的问题呢？

本章中，我们将考察何种环境能便于开发者写文档。同时作为例子，还将介绍文档编辑工具 Sphinx。

7.1　要记得给项目写文档

项目文档的内容分为许多种。虽然我们不必在一开始就把所有文档都写出来，然而一旦缺少关键文档，就很可能造成项目止步不前。

《敏捷建模：极限编程和统一过程的有效实践》1（Scott Ambler 著）一书是这样回答“应该何时写文档”这个问题的。

1原书名为 Agile Modeling: Effective Practices for eXtreme Programming and the Unified Process，Scott W. Ambler / Ron Jeffries 著，张嘉路译，机械工业出版社，2003 年 1 月出版。——译者注

据我的经验，当实际需要时再去做模型或写文档比较有效

只在遇到麻烦时才更新文档

所有文档都应该尽量晚写，应该留到马上要用时再写

所以我们需要的是写文档的时间计划，以及想写随时就能写的环境。那么，什么样的环境能让人随时可以写文档，什么样的环境又能让人有动力写文档呢？或者反过来说，妨碍我们写文档的因素都有哪些呢？下面我们就从 Python 程序员的视角出发，考察妨碍以及促使我们写文档的因素。

7.1.1　写文档时不想做的事

回顾我们以往的经历，那些没有写文档的情况都源于某些共通的理由，下面就介绍其中几个理由。

◉ 不想用写文档的专用工具

写文档时常用的工具有下面这几种。

文字处理软件

电子制表软件

Wiki

其他综合型工具

这些工具在使用上有许多地方都和我们平时编程用的编辑器不同。为了写文档，必须用自己用不惯的编辑器或工具，这成了写文档时的一大制约。我们更希望能用平常编程用的编辑器写文档。

◉ 不想编辑流水账似的单一文件

一般的文字处理软件都是一个文件管理一篇文档。如果一个文件纵向延伸得较长，我们在编辑或显示内容时就需要将其滚动到特定位置。写文章过程中一旦需要对其他地方进行修改，就必须先滚动到待修改的位置再滚动回来。这种操作往往每写一篇文档就要经历许多次，而且每次寻找原先的位置都很麻烦。

然而这并不是说给工具加个界面分割或是记忆位置的功能就能行的。问题的根源在于一个文档以一个大文件的形式被管理着。这就像一个程序员肯定会觉得可分割的源码写在一个文件里十分影响效率一样。

要解决这个问题，应该像源码一样将文档分割成多个文件管理。

◉ 不能用 Mercurial 等管理差别，让人生厌

用文字处理软件写出的文档是单一的二进制文档，不管我们在里面修改了几个字、几段文章或者几幅图片，Mercurial 等版本控制系统都无法掌握该文件中的被修改之处。另外，如果有好几个人编辑同一个文件，很容易出现变更冲突，而且无法事后自动整合双方的变更。

由于存在着这些问题，我们很难放心大胆地去添加变更。

◉ WYSIWYG 工具在装饰和显示上很费时间

WYSIWYG 是 What You See is What You Get 的简写，意思是“所见即所得”。顾名思义，用 WYSIWYG 类工具写文档时能直接看到文档最终的显示或装饰效果。

不过，这有时也会让我们在写文章时分心去考虑显示效果，在装饰和显示上花费多余时间。

◉ 不想把参考资料和程序分开写

极限编程等敏捷过程问世以来，人们对程序的概念发生了变化，那就是从“程序是按设计书写的一成不变的东西”变成了“程序是阶段性变化的东西”。

在程序阶段性变化的过程中，文档必须跟着程序的脚步进行更新。如果文档没能跟上程序的变化，看文档的人就会按照陈旧的错误信息去写程序，导致开发无法顺利进行。

其实，人们很早以前就开始借助专门工具来解决这一问题了。这些工具可以自动将函数定义附近的 API 文档反映到参考文档中。但可惜的是，有些编程语言无法使用这类工具。

所以我们需要一个轻松的联动机制，保证 API 和函数的实现与参考文档的内容不出现错位。

◉ 不想写没人看的文档

没人看的文档根本没必要写。那么，我们应该如何分辨一个文档有没有人看呢？

写出来的文档没人看，主要原因是这个文档的目的不够明确，让人不知道是写给谁的，为什么写的。所以在项目的早期阶段就该给文档做好计划，规定好什么时候写、为了什么目的而写，以及需要写什么内容。

如果写文档的目的不明确，那写出来的内容也不可能明确。有了一个明确的目的，不但能让我们有机会讨论是不是有必要写这个文档，而且写起来脑中也能有明确的内容。

有些时候，我们会遇到一些必须写的文档，但这些文档又不大可能有人来读。我们也要搞清楚这类文档是为谁写的，为什么要写。如果发现真的没必要写，可以跟委托人商量之后再定夺。

7.1.2　什么样的状态让人想写文档

我们前面考察了妨碍写文档的因素，那么什么样的情况能让我们更愿意写并且更轻松地写文档呢？

消除了妨碍因素，掌握写文档时的关键点后，以下几个“让人愿意写文档并能轻松写文档的条件”就自然而然地浮现了出来。

能在平时用的编辑器上写文档

能把文档分成几个文件来写

能用 Mercurial 等轻松实现版本管理

能集中精神编辑内容，不用顾虑装饰等外观问题

API 参考手册与程序的管理一体化

平时的引用可通过 Web 浏览器共享

在提交文档时可转换成漂亮整齐的单一文件

写有用的文档

那么，满足这些条件的文档编辑环境有哪些呢？

如今市面上有很多文档编辑工具，它们各有各的特点，也各有各的长项与短项。这里我们从文档结构的观点出发，来了解一下这些特点的差异。

Wiki 是用来构筑半格（Semilattice）结构文档的工具。半格结构没有起点和终点，是一种各元素之间依靠引用链相连的网状结构。这种结构适合在某一主题下以不断添加关键字的形式编写文档片段。相反地，它不适合编写那种要从头到尾按顺序阅读并提交的文档。

与此相对的还有构筑树结构的工具。树结构存在起点，起点元素之下的各元素有着固定的从属关系。以图书为例，目录页下挂着各章的标题，各章下又挂着各节的标题。树结构的文档适合从头到尾按顺序阅读。不过，单纯的树结构在很多情况下都不大好用，所以我们会添加一个到任意元素的引用，将树结构改造成网状结构。虽然树结构看上去优于半格结构，但它必须有一个主干，所以不适合用来当没有固定结构的字典。

接下来我们将学习 Sphinx，它是构筑树结构文档的文档编辑工具。

Sphinx 是用 Python 编写的工具，用来构建多个以 reStructuredText 语法编写的文本文件，将它们转换为 HTML 或 PDF 等格式。Sphinx 可以将树的各个元素分割成多个文件进行管理。另外，这款工具的功能和特征满足刚才我们说过的“让人愿意写文档并能轻松写文档的条件”中的许多条，甚至能让人更加主动地想要写文档。

NOTE

当然，只要讲究方法、肯下功夫，不管什么工具都有可能让我们达到目的，但其过程是否给人带来压力就另当别论了。我们即将学习的 Sphinx 也完全不是那种在 GUI 中随便一操作就能写文章的工具，但它适合像写程序一样结构化地写文章。

接下来，我们将先来了解一下 Sphinx 的基本使用方法。然后根据本节列出的这些条件，分条学习如何用 Sphinx 实现它们。

7.2　Sphinx 的基础与安装

Sphinx 是一款文档编辑工具，具有十分全面的文档资料。

Sphinx 官方网站

http://www.sphinx-doc.org/en/stable/

Sphinx 使用手册

http://www.sphinx-doc.org/en/stable/contents.html

本节将对以下各项进行简要说明。

Sphinx 的安装

reStructuredText 入门

用 Sphinx 编写结构化文档的流程

Sphinx 扩展

7.2.1　Sphinx 的安装

Sphinx 的安装流程并不复杂，但各位要尽量使用最新版本。这里建议各位使用最新的 Sphinx-1.3 系列（截至 2014 年 12 月时的最新版本）。

安装用以下命令执行。

$ pip install sphinx

这样，我们就装好了 Sphinx 关联的程序包，现在可以用 sphinx-quickstart 命令了。sphinx-quickstart 命令能自动生成启动 Sphinx 所需的数个文件。具体执行方法如 LIST 7.1 所示。

LIST 7.1　sphinx-quickstart

$ sphinx-quickstart -q -p SW-Project -a BeProud -v 1.0 sw-project
Creating file sw-project/conf.py.
Creating file sw-project/index.rst.
Creating file sw-project/Makefile.
Creating file sw-project/make.bat.

Finished: An initial directory structure has been created.

You should now populate your master file sw-project/index.rst and create other documentation
source files. Use the Makefile to build the docs, like so:
   make builder
where "builder" is one of the supported builders, e.g. html, latex or linkcheck.

$ ls sw-project/
_build  conf.py  index.rst  make.bat  Makefile  _static  _templates

执行完毕后，sw-project 目录下就生成了 Sphinx 项目，然后就可以通过 make html 构建 Sphinx 了。Sphinx 写文档的标准扩展名为“.rst”，所以这里会生成 index.rst 文件。

如果不加任何选项直接执行 sphinx-quickstart，则会以对话形式生成 Sphinx 项目。这种情况下，计算机将以对话形式提出几项询问，其中 Project Name、Author、Version 这 3 个问题需要由我们来输入，其他问题则只需根据实际情况选择 Y 或 N 即可。在 LIST 7.2 的例子中，我们让文件生成在 sw-project 目录下。

LIST 7.2　sphinx-quickstart 对话模式

$ sphinx-quickstart
Welcome to the Sphinx 1.3 quickstart utility.

-（中间省略）-

> Root path for the documentation [.]: sw-project

-（中间省略）-

> Project name: SW-Project
> Author name(s): BeProud

-（中间省略）-

> Project version: 1.0

-（中间省略）-

$ ls sw-project
_build  conf.py  index.rst  make.bat  Makefile  _static  _templates

通过 sphinx-quickstart 设置的内容全都记录在 conf.py 文件中，所以日后想更改设置时需要编辑 conf.py 文件。另外，如果想用中文版的 Sphinx，可以在 conf.py 文件中作如下设置（LIST 7.3）。

LIST 7.3　conf.py

language = 'zh_CN'

详细安装流程以及 Sphinx 的初始设置请参考以下网站。

Sphinx 入门

http://www.sphinx-doc.org/en/stable/install.html

7.2.2　reStructuredText 入门

Sphinx 要用 reStructuredText（reST）语法写文档。这里我们学习几个具有代表性的 reST 写法（LIST 7.4）。

LIST 7.4　sample.rst

==============
章标题
==============

: 项目1：字段列表内容1
: 项目2：字段列表内容2
: 项目3：字段列表内容3

节标题1
=====================

节内第一个段落的文章。
换行会被忽略。

第二个段落的文章。
用空行划分段落。
空行至少为一行，输入多少行效果都一样。

1. 规定编号的有序列表
2. 规定编号的有序列表

    #. 自动编号的有序列表
    #. 自动编号的有序列表

没有编号的无序列表按以下格式书写。

* 无序列表
* 无序列表

  + 低一级的无序列表
  + 低一级的无序列表

可以给词汇添加多种特殊意义。

- ** 强调**
- * 斜体*
- `` 显示为字符串``
- ` 链接字符串A`_
- ` 链接字符串B<http://docs.sphinx-users.jp>`__
- :doc:`index` 到对象文件的链接。会自动替换为章标题。

.. _ 链接字符串A: http://sphinx-users.jp

为了从首页链接到这个 sample.rst 文件，我们在 index.rst 中作如下描述（LIST 7.5）。

LIST 7.5　index.rst

.. toctree::
   :maxdepth: 2

   sample

这样就可以构建 sample.rst 了。接下来我们执行 make html（LIST 7.6）。

LIST 7.6　make html

$ make html

构建结果将输出到“_build/html”目录下。用浏览器打开“_build/html/sample.html”可以看到如图 7.1 所示的输出结果。

图 7.1　Sphinx 输出示例

reStructuredText 入门

http://www.sphinx-doc.org/en/stable/rest.html

7.2.3　用 Sphinx 写结构化文档的流程

Sphinx 在很多地方都引入了结构化的概念，因此它为写文档的人提供了一个方便写文档且方便整理的环境。下面就是利用 Sphinx 提供的结构化来编写文档的流程。

◉ 标题与元素

我们从零开始写文档时，往往会不知从何写起。虽然想到什么写什么不失为一种办法，但最好还是先列出标题大纲，整理一下文档内容的结构（LIST 7.7）。

LIST 7.7　先逐条列出标题

* 标题1

  * 副标题1
  * 副标题2

* 标题2
* 标题3

然后再将想到的东西填进适当位置，使文章逐渐丰满起来（LIST 7.8）。

LIST 7.8　给标题添加内容

* 标题1

  * 副标题1
  * 副标题2
  * 副标题3

    在这里逐渐补充副标题3 的内容。
    如果想到了与副标题3 无关的内容，
    就补充到其他合适的地方。

* 标题2

  * 副标题1
  * 副标题2

* 标题3

  * 副标题1
  * 副标题2

这种具有明确父子关系或兄弟关系的文本称为结构化文本。本例中既用了标题和内容这种具有明显父子关系的逐条列记，又用了空格缩进的文本。在写文档时，如果我们脑中没有一个明确的框架，就需要不断重复这种堆砌“标题”与“表达内容的元素”（本例中是副标题）的工作。

◉ 单一文件内的结构化

用 reStructuredText 写的文章会被结构化，看上去条理分明。比如用 reStructuredText 描述会议记录并添加到邮件中，收件人就可以直接流畅地阅读。

结构化让各条信息之间的关系更加明确，并列、父子结构一目了然，而且信息的换位与重排也能很轻松地进行。

按照这种结构化规则写出的文本具有树结构（图 7.2）。

图 7.2　文本的树结构

◉ 文件与目录的结构化

在写文档的过程中，会遇到标题下的内容过于详细的情况，这会破坏文章的平衡性。另外，如果我们只往一个文件里写，文章的主干就会越来越模糊，事情也会越来越讲不清楚。当文章达到一定规模，其内容需要分类分割时，我们可以根据文件和目录将其分割并结构化。分割后，主干与分支之间必须保持一个父子关联。这样一来，文件就能在分割之后仍保持树结构了（图 7.3）。

图 7.3　在维持树结构的前提下分割文件

等到文件数量多起来，可以将文件分组，按目录进行结构化。总而言之，就是灵活运用文件与目录，不断将结构化向前推进。

先将文档的章或节按目录或文件进行分割，这有助于我们今后对章节进行增减与重排。另外，由于文件被分成了多个，所以可以 1 人或多人同时编辑多个地方。举个例子，如果我们同时想到好几个点，那么可以同时打开多个文件做记录，这要比编辑单一文件更容易找到信息的位置，而且能很轻松地定位到之前中断工作的地方。

Sphinx 能将所有文档文件组合到一个树结构中。这样，所有文件都被排成了一个序列，并以让人能从上至下阅读的格式进行输出。该定义需在文档中用 .. toctree:: 指令描述。

只要有了 toctree 这样一个主干，文档就能在被分割成多个文件之后仍保持其结构。

◉ 网状结构

只要按照一定的规则给文档加入关键字或到其他章节的跳转，就能实现 Wiki 那种灵活的网状结构。脚注、交叉引用、术语集、索引等就是此类网状结构（图 7.4）。

图 7.4　结构化文档的网状结构

链接可以让我们更容易地在文档中找到想找的信息。Sphinx 会为我们提供清晰的术语集、API 参考手册等信息，我们可以利用这个功能来统一术语。

比如在写文章的过程中发现有术语需要统一，我们可以先用 :term:` 术语 ` 的形式将该术语写下来。由于 Sphinx 在 make 时会提示该术语没有对应的术语说明，所以我们完全可以把写术语说明的工作放到最后再做，这样既不会打断写文章的进度，也不必担心出现遗漏。

此外，我们还可以用 :doc:`../sub/index` 这样的形式指定引用页面的相对路径，Sphinx 在 make 时会自动将该页面的标题和链接填充到这里。

◉ 结构化的 3 个阶段

正如我们前面所介绍的，结构化分为如下 3 个阶段（图 7.5）。

① 单一文件内的结构化

② 分割成多个文件 / 目录并结构化

③ 连接成无直接父子关系结构的网络

图 7.5　结构化的 3 个阶段

通过这样从内到外地让文章结构化、分组化，文档将自然而然地得到整理，写起来当然也会轻松许多。另外，Sphinx 以树结构为基础，与 Wiki 那种仅由网络形成的半格结构不同，阅读起来主次分明，易于理解。以树结构为主让文档整体有了一根脊梁，然后在各结构之间添加神经网络，这样可以加强文档阅读时的灵活性。

7.2.4　Sphinx 扩展

Sphinx 捆绑了 TeX 排版系统等扩展，另外也支持单独安装第三方开发的扩展。这些扩展包括流程图、序列图等图表的植入扩展、UML 画图、乐谱绘图、HTML 模板变更等。当然我们还可以自己开发扩展。

扩展功能在 Sphinx 的 conf.py 文件中设置。比如我们可以像 LIST 7.9 这样描述。

LIST 7.9　conf.py

extensions = ['sphinx.ext.pngmath', 'sphinx.ext.todo', 'sphinx.ext.autodoc',]

这样就激活了 Sphinx 主体程序自带的 3 个扩展功能。这里将介绍的是 sphinx.ext.pngmath。

pngmath 扩展用来在文档中以图片形式显示数学公式。我们只要在文档中描述了代表数学公式的字符串，该扩展就会在构建时将它们转换为 PNG 图像文件植入文档。不过，用这个扩展的前提是具备 LaTeX 环境 2。

2Sphinx-users.jp 网站上有介绍 LaTeX 环境的安装流程。——编者注

我们在文档中作如下描述（LIST 7.10）。

LIST 7.10　math.rst

=======
数学公式
=======

.. math:: (a + b)^2 = a^2 + 2ab + b^2

构建之后会显示如图 7.6 所示的数学公式。

图 7.6　sphinx.ext.pngmath 的数学公式绘图

Sphinx 对数学公式的支持

http://www.sphinx-doc.org/en/stable/ext/math.html

经由LaTeX 输出PDF 文档3（日语）

http://sphinx-users.jp/cookbook/pdf/latex.html

3该部分内容是 Sphinx-users.jp 网站针对日语文档的生成这种情况而编写的。英语文档的生成则参照 Sphinx 手册即可。——编者注

7.3　导入 Sphinx 可解决的问题与新出现的问题

本章最开始就列举了下列“让人愿意写文档并能轻松写文档的条件”。

能在平时用的编辑器上写文档

能把文档分成几个文件来写

能用 Mercurial 等轻松实现版本管理

能集中精神编辑内容，不用顾虑装饰等外观问题

API 参考手册与程序的管理一体化

平时的引用可通过 Web 浏览器共享

在提交文档时可转换成漂亮整齐的单一文件格式

写有用的文档

现在我们分条学习如何用 Sphinx 实现这些条件。

7.3.1　由于是纯文本，所以能在平时用的编辑器上写文档

想必程序员都有过读纯文本文档的经历。特别是开源程序的附属文档，其中有许多图表都是由纯文本表达，它们和程序一起被视为源码进行管理。为什么很多人选择用这种方法写文档呢？理由有以下几点。

不必借助特殊工具，仅用纯文本就能表达结构或图表

编写、阅览文档不依赖特殊工具，无论该工具有偿无偿

可以用源码管理工具来管理文本文件的变更

写代码和写文档都能在惯用的编辑器上进行

可见，用纯文本写文档并不是心血来潮，它是有明确优势的。

Sphinx 就是用纯文本写文档。用户按照特定的规则描述纯文本，然后 Sphinx 来解释该规则，将文本以 HTML、PDF 等格式输出，这就是 Sphinx 的作用。

图片要以独立文件的形式统一保存，跟文档的文本文件放在同一目录或其子目录中（LIST 7.11）。这样，一旦日后遇到容量等问题需要改变大小或格式时，能方便地统一转换。

LIST 7.11　用于植入图片的 figure 指令

方便多人同时编辑，也便于用版本管理工具进行管理

.. figure:: images/7-sphinx-vcs-manage.png

   用 Mercurial 管理 Sphinx 文档

7.3.2　 信息与视图相分离，所以能集中精神编辑内容，不用顾虑装饰等外观问题

Sphinx 的以下特征实现了信息（ Data）与视图（ View）的分离，所以我们能集中精神写文档。

用 reStructuredText 写文档看不到最终的显示效果

可以集中精力描述逻辑结构化的正文

最终的设计由 Sphinx（或者由制定主题的设计师）调整

相当于其他文档编辑工具的大纲功能

前面我们学习了如何用 reStructuredText 写文档以及如何用 Sphinx 构建并输出。总而言之，我们可以把装饰和外观交给 Sphinx 处理，自己集中精神写文章内容。

比如，我们要用 Sphinx 编写如下会议记录（LIST 7.12）。

LIST 7.12　会议记录示例

============================
2015/2/17 会议记录
============================
: 参加者: SW 商事平野、BP 清水川、BP 小田切、BP 冈野
: 日期及时间: 2015/2/17（二）10:00 ～ 11:30
: 场地: SW 商事的会议室

今日议程
==================
1. 介绍新成员
2. 面向开发者的程序库的开发情况
3. 11 月起的工作方针

介绍新成员
=================
冈野:
   Python 经验 10 年。平时用 Django 框架和 GoogleAppEngine 进行开发。
   参与了许多面向 Python/Django 的开源程序库的开发，
   其中以面向移动电话的开发支持库 django-bpmobile 为代表。

这篇会议记录是用 reStructuredText 写的。如果用 Sphinx 构建，可以获取如图 7.7 ～图 7.9 所示的经过整理的 HTML 或 PDF 文件。

图 7.7　Sphinx 的 HTML 输出（default 主题）

图 7.8　Sphinx 的 HTML 输出（bizstyle 主题）

图 7.9　Sphinx 的 PDF 输出（latexpdfja）

此外，Sphinx 还搭载了强大的代码高亮功能，它可以给程序代码等自动搭配颜色，提高可读性。

代码高亮功能由 Sphinx 捆绑的 Pygments4 提供。支持的格式方面，编程语言、设置文件、HTML 模板等加起来有 200 种左右，而且仍在增加（图 7.10 ～图 7.12）。

4http://pygments.org/docs/lexers/

图 7.10　Windows ini 格式的高亮

图 7.11　Python 代码的高亮

图 7.12　Apache conf 格式的高亮

7.3.3　可根据一个源码输出 PDF 等多种格式

前面我们了解了纯文本的好处以及文件分割的好处。不过，我们仍然希望在印刷和交付时，文档能被整合成一个文件。

另外，虽然文本文件形式的文档与 PDF 文档有着同样的信息价值，但文本文件形式的文档总会给人一种廉价感。为避免因此而受到影响，我们在上交文档时最好准备一份经过简单排版和装饰的 PDF 文件。

Sphinx 可以将同一份源码文件转换成多种不同的格式。输出格式支持 HTML、PDF、EPUB、man、LaTeX、HTML Help（chm）等（图 7.13）。另外，导入自制的扩展后，还可以支持一些原本不支持的格式。

图 7.13　Sphinx 输出的 PDF

7.3.4　通过结构化，文档可分成几个文件来写

程序员在写程序时不会将所有代码都写在同一个文件里。因为历史和长期以来的经验告诉我们，这样做是非常不明智的。源码要以概念或目的为单位分割成多个文件，在目录中进行分类管理。

这个做法对于文档同样有效。分割、分类的标准可由写文档的人随意制定，但其中还是有一些技巧可言的。比如 LIST 7.13 这个例子就能让人一眼看出文档的结构，非常清晰。

LIST 7.13　文档目录结构示例

文档/
   +-- index.txt
   +-- 项目管理/
   |    +-- index.txt
   |    +-- project-goal.txt
   |    +-- member-and-structure.txt
   |    +-- phase1-schedule.txt
   +-- 设计/
   |    +-- index.txt
   |    +-- middleware-versions.txt
   |    +-- framework-comparision.txt
   |    +-- server-structures.txt
   |    +-- components-and-interfaces.txt
   +-- 开发/
   |    +-- index.txt
   |    +-- repository.txt
   |    +-- coding-rule.txt
   |    +-- develop-environment.txt
   |    +-- release-packaging.txt
   +-- 参考手册/
   |    +-- index.txt
   |    +-- install-and-setup.txt
   |    +-- class-and-functions.txt
   |    +-- api-references.txt
   +-- 会议记录/
        +-- index.txt
        +-- 20150201-meeting.txt
        +-- 20150208-meeting.txt
        +-- 20150215-meeting.txt

这样分类之后，我们写文档时的注意力就会转移到以下几项上。相较于在单一文件中编写文档，这样能更加轻松地整理文档内容。

根据分类或文件名调整该文件涉及内容的范围

文件内容或文章量达到一定规模后，重新整理内容、分割文件

文件达到一定数量后，将同一分类的文件归入一个子目录

如上面例子所示，Sphinx 可以借助文件分割以及目录分类来构筑文档。这些分割开的文件会在使用 Sphinx 构建时通过链接等方式添加关联，并采用适合 HTML、PDF 等格式的形式输出。

至于输出之后的效果，完全可以交给 HTML 或 PDF 等输出算法来处理。写文档的人只需关心文档结构是否符合逻辑，链接数量是否恰到好处等。也就是说，我们能完全以逻辑思考为中心来编辑文档，不必去想多余的事。

7.3.5　能用 Mercurial 等轻松实现版本管理

Sphinx 的文档由多个目录下的多个纯文本文件共同构成。图片文件也和文本文件一样保存在目录中。在这种结构下，我们能以极细的粒度进行版本管理，而且即便出现多人同时编辑文档的情况，各个变更之间也不会发生冲突（图 7.14）。

图 7.14　用 Mercurial 管理 Sphinx 文档

7.3.6　API 参考手册与程序的管理一体化

作为开发者，我们有时候会自己写 API，有时候又会拿现成的 API 来用。理想的情况是写好 API 的同时就能做好 API 参考手册，但如果是没有人读的东西，做出来也只是白费功夫。所以什么时候该做什么东西，要仔细结合重要性进行考量。

进行 Python 开发时，只要 docstring 用得好，完全可以解决这一问题。docstring 指的是写在 Python 模块、类、函数最开头的字符串对象。比如，我们会用 docstring 在函数的开头像 LIST 7.14 这样描述文档。

LIST 7.14　path.py

# -*- coding: utf-8 -*-

def commonprefix(path_list):
    """
    返回路径的“`path_list`”中最长的公共前缀
    （依次判断路径名的每一个字符）

        >>> commonprefix(['/usr/bin/python', '/usr/local/bin/python'])
        '/usr/'
        >>> commonprefix(['/usr/bin/python'])
        '/usr/bin/python'

    如果“`path_list`”为空，则返回空字符串“''”

        >>> commonprefix([])
        ''

    请注意，由于每次只判断一个字符，
    所以可能返回非法路径

        >>> commonprefix(['/usr/local/bin/python', '/usr/local/bin/pylint'])
        '/usr/local/bin/py'
    """
    if not path_list: return ''
    s1 = min(path_list)
    s2 = max(path_list)
    for i, c in enumerate(s1):
        if c != s2[i]:
            return s1[:i]
    return s1

如 LIST 7.15 所示，以这种方式写进去的函数文档可以在交互模式中引用。

LIST 7.15　函数文档的引用

>>> help(path.commonprefix)
Help on function commonprefix in module path:

commonprefix(path_list):
    返回路径的“`path_list`”中最长的公共前缀
    （依次判断路径名的每一个字符）

        >>> commonprefix(['/usr/bin/python', '/usr/local/bin/python'])
        '/usr/'
        >>> commonprefix(['/usr/bin/python'])
        '/usr/bin/python'

-（中间省略）-

Python 还拥有 doctest 功能，它能够识别出在 docstring 中描述的，也就是和交互模式显示信息相同的部分并进行测试。进行 doctest 的最简单的方法就是执行 LIST 7.16 中的命令。

LIST 7.16　doctest

$ python -m doctest -v path.py
...
4 tests in 2 items.
4 passed and 0 failed.
Test passed.

这样一来，函数文档里写的内容和函数的功能就不会再有偏差了。另外，我们可以放心地将 docstring 植入 Sphinx 文档。植入时要用到 sphinx.ext.autodoc 扩展，具体描述如下（LIST 7.17、LIST 7.18）。

LIST 7.17　conf.py

sys.path.insert(0, '【path.py 所在目录的路径】')
extensions = ['sphinx.ext.autodoc',]

LIST 7.18　path.rst

================
AutoDoc 范例
================

.. autofunction:: path.commonprefix

然后我们就能够得到图 7.15 中的输出结果了。

图 7.15　用 sphinx.ext.autodoc 将 docstring 植入 Sphinx 文档

可见，只要 API 的实现及其相关文档保持在可测试状态，并保证其能自动植入 Sphinx 文档之中，许多问题就迎刃而解了。另外，如测试驱动开发一样，将介绍 API 使用方法的文档兼测试写在实现 API 之前，这既能让人们对该 API 的使用有一个更明确的认识，也能给开发带来帮助。

7.3.7　通过 Web 浏览器共享

文档内常常包含开发规则或 API 参考手册等内容。让成员能在 Web 上浏览到这些信息会带来许多好处。

当我们想和其他开发者共享某个文档，进而展开讨论或交流时，如果能通过 URL 指定该文档，那将让共享变得非常方便。而且这样做不需要在邮件上挂附件，能避免出现多余的文档副本。

Sphinx 用起来最方便的输出格式就是 HTML。我们可以设置一个机制，在 Mercurial 的版本管理之下提交 Sphinx 源码时，用 Jenkins 自动将其构建成 HTML，这样一来就能随时在 Web 上看到最新的文档了。与 Jenkins 的关联我们将在第 10 章中详细了解。

7.3.8　导入 Sphinx 后仍存在的问题

前面我们了解了 Sphinx 作为文档编辑工具的一面，但有些问题并不是导入 Sphinx 就能解决的。另外，对于非程序员或非技术人员来说，Sphinx 的某些优点反而会变成缺点。

所以，我们这里要探讨导入 Sphinx 无法解决的问题以及导入后新增的问题。

◉ 写有用的文档

我们在“让人愿意写文档并能轻松写文档的条件”中提到了“写有用的文档”这一条。可惜的是，仅导入 Sphinx 并不能解决这一问题。在 7.4 节，我们将学习如何写有用的文档。

◉ 审查需要多花心思

许多文字处理软件都配备了审查或审校的功能，但 Sphinx 中并没有这类功能。所以在进行审查校对、反馈校对内容等工作时，需要额外花些心思才行。

Sphinx 中有用来记录 todo 的扩展表示法。使用这个功能前，先要在 conf.py 中作如下设置（LIST 7.19）。

LIST 7.19　conf.py

extensions = ['sphinx.ext.todo',]
todo_include_todos = True

然后像 LIST 7.20 这样在文档中描述审查校对的内容。

LIST 7.20　在 todo 指令中描述校对内容

今日议程
==================
1. 介绍新成员
2. 面向开发者的程序库的开发情况
3. 11 月起的工作方针

.. todo:: （sato） 缺少** 事务联络**，请添加。

如果想获取 todo 的一览表，需要在文档的任意位置加一行 .. todolist:: 然后 make html。

sphinx.ext.todo - Support for todo items

http://www.sphinx-doc.org/en/stable/ext/todo.html

◉ 部分人只会在 WYSIWYG 编辑器上写文档

前面也说了，Sphinx 的信息与视图是分离的，所以我们能集中精力去编写文章，不必为装饰等外观方面的问题分心。但是对于一部分人而言，不能把握输出效果就写不下去文章。这一类人会觉得没有 WYSIWYG 编辑器的 Sphinx 很难用。这个问题暂时还没有很好的解决方法。

◉ 输出 PDF 需要搭建相应的环境

虽然 Sphinx 支持多种输出格式，但仅有它本身时，是不能输出 PDF 的。输出 PDF 有两种方法（经由 LaTeX 和使用 reportlab 扩展）。这两种方法各有特点，其中经由 LaTeX 的输出更加直观。

关于用 Sphinx 输出 PDF 的方法，http://www.sphinx-doc.org/en/stable/index.html 网站上介绍了相关的导入流程以及使用方法。此外，该网站上还介绍了将 Sphinx 文档经由 LaTeX 输出成 PDF 的方法。

用 Sphinx 生成 PDF 文件

http://www.sphinx-doc.org/en/stable/tutorial.html?highlight=pdf

7.4　文档集的创建与使用

各位在写文档时都注意了哪些点呢？另外，要想写出一篇有用的文档，有哪些点需要注意呢？

文档是有读者的。如果在写文档时没有考虑读者，那我们的意识就会偏到“应该加入哪些信息”“应该写到哪种程度”上，结果就是相关信息越添越多，最后却忘了原本要表达的信息，导致文档不能达到原定的目的。能让读者觉得是好文档的文档，必然考虑了“读者是谁”以及“怎样写能让读者看得更明白”等问题，确保为读者提供了充足且有用的内容。

文档的目标读者、内容、深度等问题，可以拿过去项目的文档来参考。我们知道，“文档的写法”“版本库的用法”“项目的推进放法”这类东西都是可以重复利用的。同样道理，一个项目文档的许多元素完全可以拿到另一个文档中重复利用。

7.4.1　什么是文档集

如今有一种思路，就是为了重复利用文档而将文档模板化，然后构建成文档集（Documentation Portfolio）。《Python 高级编程》5 一书将文档集称为文档工件集，并作出如下定义。

5原书名为 Expert Python Programming，Tarek Ziadé 著，姚军等译，人民邮电出版社，2010 年 1 月。——译者注

从作者的角度，这可以通过拥有一组可复用的模板和描述如何、何时在项目中使用这些模板的指南来完成，它被称为文档工件集。

也就是说，一个文档集由以下内容构成。

文档模板集（规则、流程、会议记录、设计等）

使用文档模板的导航（何时用、怎样用等说明）

7.4.2　项目所需文档的一览表

Scoot Ambler 著的《敏捷建模：极限编程和统一过程的有效实践》一书中有文档集的相关信息，可供各位参考。他在书中提到了项目所需的文档一览表，同时举了例子，现整理如下。

文档一览表（精选）

协议模型：关于系统的技术性界面

设计上的已确定事项：设计和架构方面的重要决定的记录

对上级的概要说明：系统构想、需求、当前的预估、风险、人员计划、日程安排

使用文档：使用时所需信息、流程、环境的概述

项目概要：开发时所需信息、流程、环境的概述

需求文书：定义系统需要完成的目标

支持文档：给技术支持者的培训资料、问题排查、维护团队的联络方式一览表等

系统文档：系统概要、构架、需求事项等的概要

用户文档：使用说明书、培训资料等

敏捷建模的文档列表基本网罗了所有必备信息。但这仍存在一些问题，比如从完全没有文档的状态起步时，我们很难一次性将这些文档全写出来。所以第一步，可以先处理自身团队或组织所需的部分，或者是以过去的文档为基础，将文档的目的抽选出来进行类似上述分类，进而模板化。最终我们将构筑出适用于自己或团队的文档集，供我们在今后的项目中加以利用。

接下来，我们将根据目标读者的类型分别了解一下文档集。

7.4.3　面向项目组长、经理

普通程序员很难想象项目组长、经理们想要的信息，所以更谈不上将它们写入文档。这里最好的办法就是向项目组长、经理询问具体想要的信息。各位不妨以本节所讲的内容为基础，跟项目组长、经理探讨一下访问客户之前应该掌握哪些信息，应该将哪些东西落实在文档中。

◉ 项目的目标（终点）

在项目目标中，我们要写出“用户导入我们即将开发的系统后能获取何种收益”“与以往相比有哪些改善”之类的信息，而技术和系统方面的目标则不必在此太过重视。当然，有些项目本身就是为了开发 Python 程序库，这时技术和系统方面的目标就成了必须写入目标的事项。不过，这种情况也必须写清楚为什么要开发这个东西。

项目的目标（终点）自然应该跟所有参与项目的成员共享，但意外的是，这一点在现实中贯彻得并不好。即便团队中某些成员只负责敲代码，我们也应该把目的共享给整个团队。更何况，在文档中写明目标对项目组长本身也有帮助。或许尚不熟悉项目运营的组长会觉得“目标会经常跟着客户的要求变”，但实际看来，目标真正改变的项目并不多。我们不光要询问客户的要求，还要将目标落实到字面上，然后跟客户以及项目组成员共享出来，这样才能给项目创造出一个主干，使开发有条不紊。如果这一点上搞不清楚，那这个项目从头至尾都不会稳定，难免发生差错，甚至会导致返工。

◉ 体制

在项目的体制中，要写明各个参与者（团队）的职务，使成员们清楚自己在项目中的定位。职务不能只写名称，还要写明各职务需要做的工作，让人明白每个职务该负起哪些责任。如果只写一个“主程序员”，那么任谁都搞不清楚这个职务应该负责什么。这部分虽然不必写得很严密，但必须让每个职务对应的人员清楚自己的职责范围，不然项目开发的过程中必定出现大问题。另外，某些职务或团队的人数需要根据参与时期进行调整，这类信息最好也写进来。

◉ 需求

需求是很重要的。在项目开始之前，需求就已经朦胧地存在于客户脑中了。我们的设计与开发全都要以需求为准。在开发过程中，需求是考察系统完成度的指标，如果需求不明确，我们完全无法判断系统的发展方向是否正确，也无法考察开发到了哪里。到了运营阶段，我们也需要参考需求来了解系统的概念或衡量业务变更的影响。

在定义需求时，一般要将需要哪些功能、需要怎样运营、需要运行性能达到何种程度等信息逐条列出或者汇总成表。

◉ 日程、咨询项目表

对实现需求的日程进行描述。能左右日程的因素有很多，比如客户的要求、当前可处理的范围、能影响到日程的外部因素，等等。在初期阶段，日程常会受各种因素影响而变化，所以要定期重新审视并做好记录。

除此之外还有咨询项目表，用来与客户协商尚不明确的事宜。

7.4.4　面向设计者

面向设计者的文档需要对构架和概念多加描述，但随着时间推移，选择该设计、基础结构、开发语言的理由会显得越来越重要，所以这些也要写在文档中。另外，在 OS 和开发语言方面，最好也写上选择该版本的理由。以 Python 为例，假设我们选用了较旧的 2.5 版，理由可能是只有这个版本提供了我们用作基础结构的服务，也可能是当时没有发布新版本，或者是 OS 的程序包管理中没有新版本，又或者是公司的方针规定不采用最初的主版本，总之可能的情况非常之多。如果文档中没有对这些情况做记录，一旦将来要探讨版本变更问题，那将会是一件非常麻烦的事。

涉及下列各项时，请各位务必记录选择的理由。

OS、语言、版本的选择

构架设计

基础设施设计

7.4.5　面向开发者

在搭建开发环境方面，我们提倡导入自动化机制来尽量缩短流程，但还是难免会遇到无法自动化的部分，或者是自动化成本过高的情况。因此，我们应该将流程尽量详细地记录下来，以备不时之需。有些时候，流程文档的记录会不够全面，比如只写了搭建步骤却没写构建选项，等等。要知道，流程文档是写给不懂这些的人看的，所以应该将命令行要输入的内容全部网罗进去。

NOTE

能自动化的地方不能太吝啬成本，应当尽量自动化，从而降低搭建环境和编辑文档的成本。

在一些环境搭建流程尚未优化的项目中，单是给一个开发者搭建环境就可能花费超过一周的时间。这种情况下，每当遇到实现或测试阶段都会消耗大量的人力资源。软件开发项目中的许多机制都可以自动化，其中与搭建环境相关的自动化最好在项目起步时就准备好。

本书将在第 9 章和第 11 章中介绍自动化搭建环境的相关内容。

7.4.6　面向客户

面向客户的文档包括指导如何使用成品程序的学习手册，以及总结了日常操作流程的使用指南等。

当然，前面讲到的设计、开发、搭建环境等的文档一般也会交付给客户，但除了客户想自己修改程序之外，这些文档的内容都太过晦涩，而且客户无法对内容加以干涉。与此相对，使用手册是客户日常会用到的东西，而且客户在给这类文档的内容提要求时更清楚自己想要什么。是否需要面向客户的文档，或者文档中该有哪些内容，这都需要与客户认真探讨系统的使用情境之后再决定。如果定不下来，那么很可能是客户自己对系统没有一个明确的设想。这种情况下，客户可能在收到成品后或开始使用后才发现“这跟我想要的不一样”。所以为了防止出

现这类问题，我们必须与客户认真探讨，共享成品在开始运营后的情境，编写出所需的文档。

7.5　小结

本章对“什么样的环境便于开发者写文档”进行了整理，并以 Sphinx 为例介绍了实现此类环境的方法。

我们无法保证大程序从一开始就能被正确实现，同样道理，我们也无法保证大文档从一开始就能有一个正确的结构。Sphinx 这类结构便于文档阶段性成长，它能防止我们在写文档上浪费劳动力，同时也能作为指向标，引导我们最终完成一份恰到好处的文档。

另外，对于“写有用的文档”这一无法用工具解决的问题，各位可以运用文档集来弥补。将已有文档的结构和构造升华为文档集来重复利用，不但能逐渐提高文档的品质，还能有效降低文档编写的成本。

第 8 章　模块分割设计与单元测试

Python 上手容易，编程者可以很快投入到应用的开发之中。但这里不能操之过急。在动手之前，各位要先问一问自己：理解接下来要做的东西了吗？知道该怎么做了吗？

没错，敲代码前还有一个必经的步骤，那就是设计。

另外，敲完代码之后还少不了测试。如今，在极限编程等敏捷开发方法的影响下，人们对测试的认识正在从“麻烦”逐渐向着“主导开发的步骤”转变。

从某种意义上讲，设计是对测试的一种输入，因为我们只能在测试的过程中检验自己明确

设计出来的东西。另外，易于测试的设计还能让源码维护省力不少。为了让测试能够立竿见影，我们选择将功能分割到模块之中进行开发。

本章会向各位介绍将功能分割到各个模块的设计方法、测试手法以及如何根据测试结果改善设计。

8.1　模块分割设计

应用是由功能集合而成的。功能则要通过函数、对象等的相互作用来实现。在 Python 中，函数、类等（以下统称为组件）整合在模块里，模块又整合在程序包里。设计的第一步是设计功能，然后才轮到实现功能的各个组件。

8.1.1　功能设计

在功能设计阶段，我们要敲定即将开发的应用包含哪些功能，明确各功能的输入输出。

从应用的角度看，输入输出不仅包括用户能看到的这部分，还涵盖了与外部相关系统的接口、向数据库保存的数据等。

首先我们要给功能写出一个方案。所谓方案，就是描述用户在使用功能时会与系统进行何种交互的文本。我们要通过这一步明确用户向系统输入的内容，以及系统该向用户显示的内容。

将方案所示的整个流程画成图，即为页面迁移图。这里要写下各个页面的功能。作为一款 Web 应用，应当包含下面几项。

URL

HTTP 方法

安全（登录、权限等）

输入

用户输入

从数据库或文件读取

外部系统发来的内容

输出

页面输出

向数据库或文件写入

外部系统的调用

另外，要明确输入和输出的对应关系。连接输入与输出是功能的职责。在功能测试中，我们要查看的也是这些输入和输出。

通过功能设计，我们确定了即将开发的应用应当怎样运作。做完了这一步，接下来就是设计“如何实现这些功能”。简单的功能通常可以一步到位，但在实际开发中，绝大部分功能要么复杂，要么必须与多个功能协同工作。对付复杂的功能时切忌强求一步到位，最好将其视为多个部分（组件）的组合。将复杂功能分割成组件的好处在于可以重复使用同一组件来完成相同的工作，从而有效避免应用内的矛盾。

下面，我们将对构成 Web 应用的组件进行了解。随后再来学习如何将功能分解成组件。

8.1.2　构成 Web 应用的组件

将功能分解成组件需要用到一个指标，我们将这个指标称为软件架构。

Web 应用架构中最有名的当属 MVC（Model View Controller，模型－视图－控制器）了。 =MVC 根据职责不同对 Web 应用的互动（应用与用户关联的）部分进行了分割。M 是 Model，它负责功能的主要逻辑与数据；V 是 View，负责将 Model 以用户能理解的形式显示出来；C 是 Controller，它的工作是根据用户的输入将 Model 和 View 联系起来。

近来的 Web 应用框架已经基本上集成了 Controller 的功能。与此同时，View 则更多地被分割成 HTML 模板和显示逻辑两部分。比如，Django、Pyramid 等框架就很少需要明确写出 Controller，绝大部分情况都是 Model、View、Template 结构。

Model 部分在 MVC 中很少被提及。不过，当应用不是单纯的 Web+DB 形式时，其必然会与外部系统存在某种协作。另外，虽然绝大部分 Model 会被永久保存，但不可否认有些 Model 只是为了构成功能而存在的。鉴于这些因素，我们把 Model 分成 ApplicationModel 和 DomainModel 两类来考虑。DomainModel 是拥有持久（保存在文件或数据库中）状态的模型，而 ApplicationModel 是用于构成功能的模型，虽然它们也能具有状态，但这些状态通常不会被永久化。

现在来总结一下构成 Web 应用的组件。由于 Controller 可以完全交给框架来处理，所以构成功能的组件应如图 8.1 所示。

图 8.1　Web 应用构架

View

它的工作就是接受请求然后作出响应。输入主要为请求的传值参数，输出则是响应体。它会检查用户输入的数据，另外还会加载模型以及调用ApplicationModel。最后，它会将结果对象转换成可以显示给用户的HTML，生成响应体。

DomainModel

拥有持久状态的对象。大部分情况下，对象的状态保存在RDBMS中。Python上能使用的O/R映射工具（Object-Relational Mapper）以SQLAlchemy最为有名。另外，它还具有能改变自身状态的业务逻辑（Business Logic）。

ApplicationModel

没有持久性，其状态是暂时的。状态大多只能维持Web应用的一次请求，或者一定程度上完整的多个页面迁移期间。它们通过一般的类、模块中的函数来实现。经View调用后，执行其对应的各个DomainModel内定义的方法。它们大多不进行具体的处理，只负责传递处理的流程。也正是因为这个，它们依存的模块通常较多。

ServiceGateway

表示外部服务。其作用是为了让Web API的简单封装、邮件发送、启动其他处理等直接依存于外部系统的内容要能够不包含在Model和View之中。

Utility

它们大多都很小，不具备状态，输入只有函数传值参数，输出只有返回值。当然，它也可以拥有多种形式的输入，以对象状态维持自身直到最终调用方法，但输入输出要符合前面所说的要求，在该对象内完成所有处理。

一个功能对应一个 View。

ApplicationMode 和 DomainModel 被 View 调用。如果 DomainModel 只有一种，那么程序将通过 View 直接调用 DomainModel。当同时关联着多个 DomainModel 时，程序则要通过 View 调用 ApplicationModel。

ServiceGateway 的作用就是一个简单的封装，它里面不能包含应用固有的内容。ApplicationModel 则要使用 ServiceGateway 进行应用固有的处理。

NOTE

SQLAlchemy

它是连结对象与 RDBMS 的 O/R 映射工具库。虽然 SQLAlchemy 并不是 Python 唯一的映射工具（其他还有 SQLObject、Storm 等），但相比之下它的功能最为丰富，文档也相对齐全。像 Pyramid、Flask 等非全栈式（不具备 O/R 映射工具）框架的说明大多以使用 SQLAlchemy 为前提，这在如今的 Web+DB 应用开发中已经逐渐成为了一条不成文的规定。

8.1.3　组件设计

接下来我们把功能分割到各个组件当中。

◉ 根据输入设计 View

首先对付输入。作为一款 Web 应用，输入的主要来源应该是用户输入的数据。除此之外，已存在的数据和从外部系统获取的数据都属于输入。

用户输入的数据由 View 接收。另外，View 还负责向用户传递数据。交给模板的内容要由 View 进行输出。在 View 的内部会调用 ApplicationModel 或 DomainModel，这些 Model 也是输入之一。还有，由于 View 没有状态可言，所以大多数情况以函数（以下称 View 函数）形式实现。

◉ 初步设计与 View 关联的 ApplicationModel

对 View 函数而言，函数传值参数是输入，返回值是输出。另外，它只关联一种 Model。

这一阶段我们先不考虑 DomainModel 的相关问题，先给每个 View 函数定义一个 ApplicationModel。View 函数和 ApplicationModel 建议根据功能名来命名，比如“{ 功能名 }_view、{ 功能名 }Model”。除此之外，还要给 ApplicationModel 预先定义一个方法，名称同样根据功能名来起。

◉ DomainModel 的设计

接下来定义 DomainModel（View 和 DomainModel 的定义可以并行进行。实际上，在设计 View 之前就可以一定程度上对 DomainModel 进行定义了）。由于 DomainModel 有状态，所以要用类来定义。

定义好 DomainModel 的类之后就要考虑方法了。要明确各个方法是如何改变 DomainModel 的状态的。这些状态要定义成属性。另外，如果要通过调用方法来改变其他相关 DomainModel 的状态，需要在目标 DomainModel 里定义方法，然后调用目标 DomainModel 里的方法。切记不可以直接改变其他 DomainModel 的状态。

◉ 关联 ApplicationModel 和 DomainModel

接下来给 ApplicationModel 和 DomainModel 添加关联。在 ApplicationModel 的方法中定义需要调用的 DomainModel。然后检查功能的输入输出是否满足要求。

◉ 整理 ApplicationModel

当 ApplicationModel 只关联着一个 DomainModel 时，应当删除该 ApplicationModel，让 View 直接调用 DomainModel。在 ApplicationModel 中，对象 Domain 模型一致地要整合成一个类。整合好之后需要立刻给类命名。如果无法确定类名，就尽量不要整合。

◉ 从组件设计转入实现

到这里，我们已经将功能分割到了各个组件之中。接下来就是把它们向 Python 的模块、程序包一步步整合了。

8.1.4　模块与程序包

现在我们根据目的把组件整合成模块或程序包。开发 Web 应用时，要每一个功能制作一个程序包，功能内的各个组件（View、Model 等）分别制成模块。在 Python 中，各个组件由类和函数来实现。

NOTE

我们来整理一下 Python 的模块与程序包。

模块

Python 的源码文件就是一个个模块。里面整合着多个类、函数、变量等内容。虽然我们可以把处理写到模块内，但当我们开发的应用由多个模块构成时，最好只让它作为一种函数和类的集合体出现。

程序包

整合在一起的多个模块。它就是把程序包内包含的模块文件与 __init__.py 整合到了一个单独的目录下。

命名空间程序包

程序包的一种比较特殊的形式，用来向多个位置安装相同名称的程序包。因为只有在分配库文件的时候才会用到它，所以一般应用都与它无缘。

◉ 项目的文件结构

以 MVC 架构为基准时，项目的实际文件结构如下所示。

project/
 +-- __init__.py
 +-- views.py
 +-- models.py
 +-- services/
 |    +-- __init__.py
 |    +-- twitter.py
 |    +-- twitpic.py
 +-- utils.py
 +-- templates/
 +-- tests/
      +-- __init__.py
      +-- test_models.py
      +-- test_views.py
      +-- test_services.py
      +-- test_utils.py

○ project/__int__.py

启动应用所需的处理都写在这里，比如 WSGI 应用的入口点等。另外，models 的导入、数据库连接的初始化、设置文件的读取等都在这里进行。

○ project/views.py 以及 project/utils.py

分别实现 View 和 Utility。

○ project/models.py

实现 DomainModel 和 ApplicationModel。另外，连接 DB 所需的模块等也要事先导入到这里。

○ project/services/

在存在多个 services 时，由程序包进行整合。

○ project/templates/

存放 HTML 模板的位置。

○ project/tests/

存放单元测试（Unit Test）的位置。

另外，当项目规模非常大的时候，会由多个 project 合在一起形成一个网站。比如用户种类不同（终端用户与服务提供方的用户）或 DomainModel 完全分离时。

8.2　测试

完成设计之后，应该在动手实现前先把测试考虑好。因为如果没有一个能验证成品是否符合设计初衷的方法，我们根本无法着手去实现。反过来，只要把现成的测试摆在那里，那么我们只需以通过测试为目的去编程即可。

实现阶段的测试包括单元测试和功能单元测试。我们应该让其自动化，并且时常查看其结果。由于测试要重复执行多次，所以必须保证其足够迅速，并且可以随时随地执行。要满足“随时随地执行”这一点，就意味着这些测试不能依赖于环境。分离组件可以让不依赖于环境的测试成为可能。

本节将介绍借助 Python 中的测试工具进行测试的手法，以及从测试中提出环境依赖的技巧。

8.2.1　测试的种类

测试其实只是一个泛泛的概念，对于不同的对象或观点，其实施内容都不一样。人们常说集成测试、单元测试，但究竟是集成了什么，又是以什么为单元呢？这里我们以此思路为基础，对其进一步细分。

○ 单元测试

测试函数、方法等最小单元的测试。这个等级的测试能明确看到输入和输出，所以测试内容往往就是函数或方法的设计方案本身。该部分要利用 mock 或 dummy，把测试对象的处理单独拿出来执行，看结果是否达到预期。

○ 组件集成测试

这是集成多个函数或方法的输入输出的测试，测试时需要将多个测试对象组合在一起。由单个测试对象构成的流程已在单元测试中测试完毕，所以不参与这一步测试。对象的前后处理与单元测试一样要使用 mock 或 dummy。

○ 功能单元测试

测试用户能看得到的功能。此时用户的输入项目以及数据库等外部系统为输入的来源。输出则是向用户显示的结果、向数据库保存的内容以及对外部系统的调用。系统内部不使用 mock 和 dummy，而是全部使用正式的代码。不过，在对付某些异步调用之类的难以自动测试的部分时，需要进行一定程度的置换。外部系统方面，要准备好虚拟的 SMTP 服务器或 Web API 服务器，用以执行应用内的通信。

○ 功能集成测试

集成各功能之间输入输出的测试。这里要尽可能不去直接查看数据库内的数据，比如可以用引用类功能来显示更新类功能生成的数据。另外在与外部系统的联动方面，要借助开发专用的 API 等模拟出正式运行时的结构，然后再进行测试。这部分测试要依赖于数据库以及外部服务等诸多环境，难以自动执行，所以属于偏手动的测试。

○ 系统测试

对需求的测试。测试成品是否最终满足了所有需求。在客户验收项目时进行。

○ 非功能测试

对性能、安全等非功能方面进行的测试。借助压力测试软件进行正常 / 高峰 / 极限情况的测试，通过 XSS、CSRF 以及注入式攻击等模拟攻击来验证系统的安全性及可靠性。

本节，我们将就需开发者主要负责的单元测试、集成测试以及功能测试进行学习。

8.2.2　编写单元测试

现在我们来编写单元测试。Python 的标准库里有为编写单元测试而准备的 unittest 模块。另外，执行测试时建议使用 pytest。pytest 是一款能够自动搜索并执行测试的测试执行工具，并且会输出详细的错误报告。

本节将为各位讲解如何用 unittest 和 pytest 生成并执行单元测试。

◉ unittest 模块

它是基于 xunit（派生自 unit、sunit 等）的测试工具及程序库。其中主要使用的是 unittest.TestCase 类。我们定义一个继承自它的类，然后在其各个方法中描述测试用例（Test Case）。测试用例要写在名称以 test 开头的方法中，具体如下。

测试用例写在名称包含 test 的方法中。

import unittest

class TestIt(unittest.TestCase):

    def test_it(self):
        """ 本方法是一个测试用例 """

    def test_one(self):
        """ 这是另一个测试用例 """

另外，TestCase 类里有用于设置环境的配置器（Fixture）和用于查看测试结果的断言方法（Assert Method）。

配置器指执行测试所需的必要前提条件的搭建以及执行完毕后的善后工作。unittest 在执行测试用例前后会分别调用各个类的 setUp 和 tearDown 方法，因此我们可以在这两个方法内描述执行测试所需的环境设置（LIST 8.1）。Python 2.7 之后的 unittest 允许使用以模块为单位的配置器。我们可以把整个模块共通的、不必每次测试都重置的环境设置流程写在模块配置器里，这能够有效削减测试的执行时间（LIST 8.2）。

LIST 8.1　各个类的配置器

class TestIt(unittest.TestCase):

    def setUp(self):
        """ 搭建测试环境 """

    def tearDown(self):
        """ 测试后的环境清理 """

setUp 在测试前被调用，tearDown 在测试后被调用。

LIST 8.2　各个模块的配置器

def setUpModule():
    """ 搭建测试环境 """

def tearDownModule():
    """ 测试后的环境清理 """

setUpModule 会在模块测试开始前被调用一次。tearDownModule 会在整个模块的测试全部结束后被调用一次。

Python 在查看测试结果时要使用 assert 语句等断言。

a = 1
b = 2
c = a + b
assert c == 3, "%d != %d" % (c, 3)

assert 语言失败时会同时显示错误信息和测试结果（失败显示 F，出错显示 E）。不过，由于 assert 语句本身只能进行 True、False 的判断，而且信息很多为定式，所以 unittest 的 TestCase 类大多拥有自己的断言方法。

最常用的断言方法当属 assertEqual。刚才那个例子改用 assertEqual 会变成下面这样。

def test_it(self):
    a = 1
    b = 2
    c = a + b
    self.assertEqual(c, 3)

◉ testfixtures 库

testfixtures 库（测试配置器）整合了多种典型配置器。里面包含生成 / 删除临时目录、更改系统日期、添加 mock/dummy 等模块，这些模块能帮助我们将单元测试与环境分离开来。

使用 testfixtures 库之前需要进行安装，安装步骤与通常的库相同（LIST 8.3）。

LIST 8.3　安装

$ pip install testfixtures

testfixtures 的 compare 函数显示出的错误信息比 unittest 的 assertEqual 还要详细。

from testfixtures import compare

def test_add():
    result = add(2, 3)
    compare(result, 5)

使用 compare 函数时，只需将比较对象用作实参直接执行即可。

另外，该比较会递归地执行到 dict 及 list 内部，并生成结果报告。

在比较下面这种复杂数据的时候，compare 函数能详细显示出 list 中的 dict 元素如何不同。

>>> compare([{'one': 1}, {'two': 2, 'text':'foo\nbar\nbaz'}],
...         [{'one': 1}, {'two': 2, 'text':'foo\nbob\nbaz'}])
Traceback (most recent call last):
 ...
AssertionError: sequence not as expected:

same:
[{'one': 1}]

first:
[{'text': 'foo\nbar\nbaz', 'two': 2}]

second:
[{'text': 'foo\nbob\nbaz', 'two': 2}]

While comparing [1]: dict not as expected:

same:
['two']

values differ:
'text': 'foo\nbar\nbaz' != 'foo\nbob\nbaz'

While comparing [1]['text']:
@@ -1,3 +1,3 @@
 foo
-bar
+bob
 baz

多行的字符串会以 unified diff 格式显示不同。可见，它会递归地根据数据类型不同选用最直观的显示方法。

另外，比较方法和结果输出可通过 testfixture.comparison.register 函数进行添加。各位想多次重复使用 assert 语句时不妨一试。

使用 Comparison 类可以一次性查看对象的属性。

from testfixtures import compare, Comparison as C

def test_create_object():
    result = create_object(name="dummy-object", value=4)
    compare(result, C(SomeObject, name="dummy-object", value=4)

ShouldRaise 可以查看发生的例外。特别是它连交给例外的传值参数都能查看到，这是 unittest 的 assertRaises 做不到的。

from testfixtures import ShouldRaises

def test_critical_error():
    with ShouldRaise(CriticalError('very-critical')):
        important_process(None)

testfixtures 提供的能应用于单元测试的实用程序还远不止这些，其他还有控制台输出、日志输出等等。加之它可以像普通的模块一样对待，所以能够在 unittest、nose、pytest 等 testrunner 上使用。

◉ 通过 pytest 执行测试

pytest 是第三方出品的测试工具。它描述测试比 unittest 要简单，而且能输出详细的错误报告。pytest 能够自动发现并执行测试，其中包括用 unittest 写的测试，所以 unittest 与 pytest 完全可以并用。

pytest 可以用 pip 安装，具体如 LIST 8.4 所示。

LIST 8.4　安装

$ pip install pytest

pytest 执行时会在指定的目录（未指定状态下则默认当前目录）下寻找测试。这个过程称为 Test Discovery。Python 程序包下的 tests 模块以及“test_**_test”等形式的名称都会被识别为测试模块。pytest 发现测试模块后会执行该模块内的测试并显示结果。

◉ 实际编写一个测试并执行

建议不要把测试放得离测试对象太远。我们将测试与测试对象放在同一个程序包内，以“test_{ 测试对象的模块名 }.py”命名该文件。接下来用 unittest 写一个测试用例（LIST 8.5）。

LIST 8.5　测试对象：bankaccount.py

class NotEnoughFundsException(Exception):
    """ Not Enough Funds """

class BankAccount(object):

    def __init__(self):
        self._balance = 0

    def deposit(self, amount):
        self.balance += amount

    def withdraw(self, amount):
        self.balance -= amount

    def get_balance(self):
        return self._balance

    def set_balance(self, value):
        if value < 0:
            raise NotEnoughFundsException

        self._balance = value

    balance = property(get_balance, set_balance)

这是一个传统的 BankAccount 教程。状态只有 _balance，balance 属性为包装。其实际的逻辑是 withdraw 和 deposit。现在我们把这些测试写出来。

LIST 8.6　测试类

import unittest

class TestBankAccount(unittest.TestCase):

这里创建一个继承了 unittest.TestCase 的测试类（LIST 8.6）。类名没有特殊要求，但最好让人能明确分辨出测试对象。比如“Test+ 测试对象类名”这种命名规则就很不错。

LIST 8.7　加载测试对象

class TestBankAccount(unittest.TestCase):

    def _getTarget(self):
        from bankaccount import BankAccount
        return BankAccount

    def _makeOne(self, *args, **kwargs):
        return self._getTarget()(*args, **kwargs)

    #...( 略)...

这是用来准备测试对象的实用方法。为防止模块的副作用对其他测试产生影响，这里需要单独导入（LIST 8.8）。

LIST 8.8　测试方法

class TestBankAccount(unittest.TestCase):

    #...( 中间省略)...

    def test_construct(self):
        target = self._makeOne()
        self.assertEqual(target._balance, 0)

    def test_deposit(self):
        target = self._makeOne()
        target.deposit(100)
        self.assertEqual(target._balance, 100)

    def test_withdraw(self):
        target = self._makeOne()
        target._balance = 100
        target.withdraw(20)
        self.assertEqual(target._balance, 80)

    def test_get_balance(self):
        target = self._makeOne()
        target._balance = 500
        self.assertEqual(target.get_balance(), 500)

    def test_set_balance(self):
        target = self._makeOne()
        target.set_balance(500)
        self.assertEqual(target._balance, 500)

    def test_set_balance_not_enough_funds(self):
        target = self._makeOne()
        from bankaccount import NotEnoughFundsException
        try:
            target.set_balance(-1)
            self.fail()
        except NotEnoughFundsException:
            pass

测试方法要每个方法分开描述。在 _makeOne 方法内生成测试对象的实例，备齐测试的前提条件，然后执行测试。测试结果在 assert* 方法内查看。会出现例外的测试要使用 fail 方法，或者用 assertRaises 也行。

执行 pytest 很简单，只要在描述测试的文件（test_bankaccount.py）所在的目录下执行 py.test

即可。pytest 会自动找出并执行测试（LIST 8.9）。

LIST 8.9　执行测试

$ py.test

◉ 巧用 pytest 插件

pytest 的插件多种多样，比如收集执行数据并进行解析的插件、改变测试结果显示模式的插件，等等。除 pytest 自身包含的插件外，我们还可以选择使用第三方开发的插件，甚至自己编写插件。

○ pytest-cov

coverage 会在执行命令时收集该命令的信息。使用 pytest-cov 可以查看测试中都执行了哪些代码。虽然一味盲从这个数值是很危险的，但我们可以利用它来推断哪些部分未被测试。pytest-cov 可以通过 pip install pytest-cov 进行安装。安装完后用 --cov 选项指定要获取覆盖率的程序包。

○ xunit

它和 JUnit 一样会将测试结果保存在特定格式的文件中。在与 Jenkins 等 CI 工具联动时会用到它。它是 pytest 标配的插件，可以通过 --junit-xml 选项添加使用。

○ pdb

它会在测试发生错误时自动执行 pdb（Python 的调试器）。可以通过 --pdb 选项添加使用。

专栏　什么是覆盖率

覆盖率由百分比表示。比如测试代码执行过了程序的每一行，那么覆盖率就是100%。这种时候，几乎不会出现新程序上线后突然无法运行的尴尬情况。不过，这一过程只是让程序跑了一遍而已，因此并不能检测出逻辑错误引起的Bug。换句话说，覆盖率不关心代码内容究竟是什么。覆盖率是用来检查“测试代码不足、测试存在疏漏”的一个指标，“测试内容是否妥当”并不归它管。覆盖率按评测标准分为 3 个阶段（①最宽松，③最严格）。

① 指令覆盖率（简称C0）：只要执行了所有命令即可。比如存在 if 语句，只要测试代码从 if 语句中通过即可达到100%。

② 分支覆盖率（C1）：通过所有分支即可。如果代码中存在 if..elif..else 这样的分支，需要执行所有分支以及“不进入分支的情况”才可以达到100%。

③ 条件覆盖率（C2）：当存在多个分支条件时，测试代码需要执行过所有情况才能达到 100%。

指令覆盖率、分支覆盖率、条件覆盖率三者之间，后者达到 100% 的难度都要高于前者。指令覆盖率比较容易达到 100%，所以我们通常希望能保证这个 100%。但是能分给编写测试代码的时间毕竟有限，如果为提升覆盖率编写大量测试，那么维护测试代码的成本将大大提升（测试代码负债化）。所以我们应该现实一点，根据眼前情况判断覆盖率应当达到多高。

另外，引入覆盖率后，大家会发现无意义的行以及意图不明确的处理都很难被覆盖，这就顺便督促了我们在编程时应尽量避免出现上述情况。覆盖率能让我们注意到一些平时注意不到的东西，所以还没接触过它的朋友请务必一试。

◉ 使用 mock

mock 是将测试对象所依存的对象替换为虚构对象的库。该虚构对象的调用允许事后查看。另外，还允许指定其在被调用时的返回值以及是否发生例外等。

mock 可以通过 pip 安装，具体如 LIST 8.10 所示。

LIST 8.10　安装

$ pip install mock

○ 虚构对象

用 mock.Mock 生成虚构对象。虚构对象的添加方法也很简单。虚构对象生成后，用任何方法都可以调用它。

>>> import mock
>>> m = mock.Mock()
>>> m.something("this-is-dummy-arg")

用 return_value 可以指定返回值。

>>> m.something.return_value = 10
>>> m.something("this-is-dummy-arg")
10

执行后可以查看到 mock 的方法的调用。

>>> m.something.called
True
>>> m.something.call_args
(('this-is-dummy-arg',), {})

此外，还能指定让其发生例外。

>>> m.something.side_effect = Exception('oops')
>>> m.something('this-is-dummy-arg')
Traceback (most recent call last):
 ...
Exception: oops

可以在测试内使用断言。

assert_called_with

assert_called_once_with

这些方法的作用是在测试对象的处理执行完毕后，查看其被调用的情况以及被调用时的传值参数。

>>> m = mock.Mock()
>>> m.something('spam') # 第一次调用
<mock.Mock object at 0x00000000025AF7F0>
>>> m.something.assert_called_with('egg')
Traceback (most recent call last):
 ...
    'Expected: %s\nCalled with: %s' % ((args, kwargs), self.call_args)
AssertionError: Expected: (('egg',), {})
Called with: (('spam',), {})
>>> m.something.assert_called_with('spam')
>>> m.something('spam') # 第二次调用
<mock.Mock object at 0x00000000025AF7F0>
>>> m.something.assert_called_once_with('spam')
Traceback (most recent call last):
  ...
   raise AssertionError(msg)
AssertionError: Expected to be called once. Called 2 times.

○ patch

生成虚构对象后，需要将其混入测试对象的代码之中。虚构对象用作传值参数的时候最好对付，只要将其作为传值参数交出去即可，但处理过程中要用到的类或函数就不能通过传值参数来传递了。这种时候，mock 可以在 patch 的作用范围内将模块内的类或函数临时替换为虚构对象（LIST 8.11）。

LIST 8.11　patch 装饰器的使用示例

@patch('myviews.SomeService')
def test_it(MockSomeService):
    mock_obj = MockSomeService.return_value
    mock_obj.something.return_value = 10
    from myviews import MyView
    result = MyView()
    assert result = 10

通过 patch 的传值参数指定要替换的对象。这样一来，被替换进去的 Mock 对象就会作为测试的传值参数被传进去。这个替换仅在执行被 patch 装饰器装饰的函数的过程中有效，所以不会对其他测试造成影响。

◉ 如何编写高效率的 Python 测试用例

光拿 unittest 模块来写测试用例并不能让测试变得高效。写完测试用例后要多运行几遍。另外，在修改源码时如果已经有了内容明确的相关测试用例，那么要迅速且准确地找出受影响的部分。另外，各个测试用例要分离得足够开。如果修改一个测试用例导致其他测试用例不能运行，这将会成为一种负债。

要想最大限度地巧用测试用例，需要注意以下几点。

尽可能简单

要让人从测试内容中一眼看出输入和输出。

尽可能快，多执行几次

单元测试多执行几遍。为实现这一点，要用pytest简化执行操作，并且保证测试本身的执行不消耗过多时间。如果执行一次就要花去10分钟，那么没人会愿意多执行几遍的。

分离各个测试

保证测试数据不被多个测试用例共享。对一个测试有用的数据对其他测试不一定有用，如果测试中包含了这种没用的数据，会使输入输出变得不明确。另外，如果遇到不得不变更测试数据的情况，那么必须事先查清其影响范围。

不直接向模块内 import 测试对象

如果直接向测试模块内import测试对象，那么一旦这个测试对象的import本身会带来问题，就会导致测试模块内的所有测试用例全部无法评测。此外，某些测试用例会将 import失败判断为错误。还有，如果模块包含会在import时执行的代码，那么还没等测试开始，这些代码就先执行完毕了。

简化配置器

在“单元测试”部分我们已经提过了，不要试图用setUp方法做完一切准备工作。setUp 不是用来存放相同的处理的地方。尤其不能用setUp来生成依存于测试的数据配置器。

不搞通用的数据配置器

通用的数据配置器（Data Fixture）会在测试之间建立依存性。在单元测试阶段，要保证只在测试用例中创建必要的输入数据。

不过分信任虚构对象

mock库可以轻松分离测试对象。但是不能过分依赖于mock。正因为mock简单，我们才容易被它骗。另外，检查一下是不是写了“在mock返回了mock之后返回一个mock”这种mock。这么复杂的mock真的是模仿其他组件做出来的？这种mock会让输入输出变得模糊，同时导致关联度上升。在制作mock上费脑筋是一个危险信号，此时应该重新审视设计方案。

8.2.3　从单元测试中剔除环境依赖

要保证单元测试不依赖于环境，只执行测试对象本身。否则，每当其所依赖的模块或外部系统的运作稍有变化就要对测试进行一次修改，这样单元测试眨眼间就会成为一种负债。

◉ 请求 / 响应

View 的形式有许多种。接下来我们选 Python 框架中几种常见的模式各写一个测试。

○ View 类

View 类是用类定义的 View（LIST 8.12）。它不是单纯的函数，所以可以使用属性和方法，使得结构更加多变。View 的处理在类的方法中进行定义，测试时用 dummy 替换这些方法，我们就能做到只运行测试对象的方法了。

使用 View 类时，大部分框架会用构造函数来接收请求，通过方法来进行 View 的处理。此时能很轻松地用 dummy 替换请求。另外，虽然 View 会调用服务以及读取模型，不过我们完全可以用 mock 来替换它们。在 View 的输出中，HTTP 响应是方法的返回值，它能被视为响应对象。对于这种状态，View 绝大多数时候都已经做完了 HTML 渲染。鉴于 HTML 内容的易变性，测试时最好确认一下交给模板的传值参数。

LIST 8.12　典型的 View 类

class MyView(object):
    def __init__(self, request):
        self.request = request

    def index(self):
        s = SomeService()
        result = s.some_method(**self.request.params)
        return render('index.html', dict(result=result))

为达到分离效果需要解决下面两点内容。

① 把 SomeService 替换为 mock

② 获取传给 render 的 dict 的内容

LIST 8.13　更便于测试的 View 类

class MyView(object):
    someservice_cls = SomeService
    def __init__(self, request):
        self.request = request

    def index(self):
        s = self.someservice_cls()
        result = s.some_method(**self.request.params)
        self.render_context = dict(result=result)
        return render('index.html', self.render_context)

把 SomeService 替换为 mock 的最简单方法就是把它放在类里。另外，只要把 dict 的内容放在对象里，我们就能在测试中查看它了（LIST 8.14）。

LIST 8.14　test

class DummyRequest(object):
    def __init__(self, params):
        self.params = params

class DummySomeService(object):
    def somemethod(self, **kwargs):
        return kwargs

class TestIt(unittest.TestCase):
    def test_it(self):
        request = DummyRequest(params={'a': 1})
        target = MyView(request)
        target.someservice_cls = DummySomeService
        result = target.index()
        self.assertEqual(target.render_context['result'], {'a': 1})

◉ 全局请求对象

某些框架会把请求对象当作一个线程本地化的全局对象提供给我们。更改它们需要对框架进行调整，所以必须加以注意。另外，有些时候框架根本不允许我们对请求对象进行修改。对于这类情况，框架一般都会提供一个从框架传送虚拟请求的机制，我们要利用这一机制来控制输入（LIST 8.15）。Flask 就属于这类框架。

LIST 8.15　test

with app.test_request_context:
    result = myview()

然而麻烦来了，Flask 的 view 的返回值是响应体。而且 view 是个函数，没地方存储传给模板的上下文。其实有一个地方可以用，那就是请求对象（LIST 8.16）。

LIST 8.16　myviews.py

def index():
    s = SomeService()
    result = s.some_method(**self.request.params)
    request.environ['render_context'] =  dict(result=result)
    return render('index.html', self.render_context)

把上下文添加到 request.environ 中，事后可以通过测试用例查看。此外，在框架内部使用的 ApplicationModel 等也会变得难以替换。到这里，我们自然希望 mock 等专用库伸出援手。不过别急，我们先不用库，直接混入些 dummy 试试。

def test_it():
    import myviews
    SomeService_orig = myview.SomeService
    try:
        myviews.SomeService = DummySomeService
        app = flask.Flask(__name__)
        with app.test_request_context:
            result = myview()

            assert flask.request.environ['render_context'] == {'a': 1}
    finally:
        myviews.SomeService = SomeService_orig

显然，这作为测试而言太取巧了，我们甚至需要测试一下这个测试是否能够正常运行。这种时候，使用 mock 库就简单得多。

@patch('myviews.SomeService')
def test_it(MockSomeService):
    myviews.SomeService = DummySomeService
    app = flask.Flask(__name__)
    with app.test_request_context:
        result = myview()

        assert flask.request.environ['render_context'] == {'a': 1}

由于问题的根本在于测试对象会直接返回响应体，所以我们用装饰器来避开它。

def render_view(name)
    def dec(view_func):
        def wraps()
            data = view_func()
            return render_template(name, data)
        return wraps
    return dec

但是，如果仅有上面这个处理，我们只能在测试对象加了装饰器的状态下进行访问，所以需要再作一些调整，以保证能在加了装饰器的状态下直接获取原来的函数。

def render_view(name)
    def dec(view_func):
        def wraps()
            data = view_func()
            return render_template(name, data)
        wraps.inner = view_func
        return wraps
    return dec

这样一来，我们就能在测试中直接查看返回值了。

@patch('myviews.SomeService')
def test_it(MockSomeService):
    myviews.SomeService = DummySomeService
    app = flask.Flask(__name__)
    with app.test_request_context:
        result = myview.inner()

        assert result == {'a': 1}

◉ 数据库

SQLAlchemy 支持 sqlite 的内存数据库。使用内存数据库时，可以在不具备实际数据库的情况下测试伴随数据库连接的处理。另外，SQLAlchemy 用 DBSession 对象进行访问数据库以及取出、更新 DomainModel 的操作。

我们继续按照本章中的设计，将 View 能直接访问的 Model 限制在一个。涉及多个 DomainModel 的处理交给 ApplicationModel 来应付。这样一来，View 就只会从 DBSession 加载一个模型了。它对 DBSession 的调用是输出，从 DBSession 获取的内容是输入。

输入大致分为两种情况，即存在 DomainModel 的情况和不存在 DomainModel 的情况。在测试对象执行前将 DomainModel 放到（或不放到）会话（session）内，这样，我们就能轻松控制这两种情况了。DomainModel 的调用方法要限制在一个。需要调用多个时交给 ApplicationModel 来处理。

由于存在实际访问数据库的行为，所以测试前后需要用配置器来设置数据库。另外，我们不必每次测试都设置一遍数据库，因此要选用模块配置器。

首先编写一个实用程序用作设置数据库的配置器（LIST 8.17）。

LIST 8.17　数据库配置器

def _setup_db():
    from .models import DBSession, Base
    from sqlalchemy import create_engine
    engine = create_engine("sqlite:///")
    DBSession.remove()
    DBSession.configure(bind=engine)
    Base.metadata.create_all(bind=engine)
    return DBSession

def _teardown_db():
    from .models import DBSession, Base

    DBSession.rollback()
    Base.metadata.drop_all(bind=DBSession.bind)
    DBSession.remove()

在实际的测试套件中，通过模块配置器调用上述实用程序（LIST 8.18）。

LIST 8.18　测试

def setUpModule():
    _setup_db()

def tearDownModule():
    _teardown_db()

class TestMyView(unittest.TestCase):
    def setUp(self):
        from .models import DBSession
        self.session = DBSession

    def _setup_entry(self, **kwargs):
        from .models import Entry
        entry = Entry(**kwargs)
        self.session.add(entry)
        return entry

    def test_it(self):
        e = self._setup_entry(name=u"this-is-test")

        result = self._callFUT(entry_id=e.id)

        self.assertEqual(result['name'], e.name)

测试数据的生成不要在 setUp 中进行。保证在测试方法内只生成该测试所需的数据。

一旦用 setUp 生成测试数据，这些测试数据就会被多个测试共享。被共享的测试数据会给各个测试之间带来依存性。

多余数据会使输入输出变得不明确。而输入输出一旦不明确，会让人很难搞清到底在测试什么。另外，当前提改变后，如果我们对测试数据进行更改，其影响的测试可能导致有问题的数据残留在程序中。所以，我们需要让单元测试只能给各个测试准备该测试所需的数据，并要在保证没有多余数据的情况下进行测试。

◉ 系统时间

我们以判断系统时间是否为月末的实用程序为例来思考一下。系统时间每次调用都会返回不同的值，所以做自动测试时要花些心思才行。这里我们暂且把系统时间放在一边，先编写一个通过传值参数接收时间并进行判断的实用程序，然后再写一个函数来给这个传值参数传递系统时间（LIST 8.19、LIST 8.20）。

LIST 8.19　实现通过传值参数接收时间并进行判断的实用程序

def is_last_of_month(d):
    return (d + timedelta(1)).day == 1

接下来进行测试。

def test_is_last_of_month():
    d = date(2011, 11, 30)
    assert is_last_of_month(d), "%s" % d

def test_is_last_of_month_not():
    d = date(2011, 11, 29)
    assert not is_last_of_month(d), "%s" % d

LIST 8.20　实现用系统时间进行判断的实用程序

def is_last_of_month_now():
    return is_last_of_month(datetime.now())

把这个也测试一遍。获取时间部分使用 testfixtures 的 Replacer 和 test_date。

from datetime import datetime,date

def test_is_last_of_month_now():
    with Replacer() as r:
        r.repace('util.datetime', test_date(2011, 11, 30))
        assert is_last_of_month_now()

这里不能替换 datetime.datetime，而是要替换掉测试对象 import 的东西（本例中测试对象使用的是 util.datetime）。在 ApplicationModel 或 DomainModel 中使用的时候，要用 mock 替换掉 is_last_of_month_now。

实用程序的功能一定要压缩到上述这个程度。

8.2.4　用 WebTest 做功能测试

确认所有组件运转正常后，就该把它们结合起来，查看功能的运作情况了。

对于 Web 应用而言，功能测试要查看从接受请求到作出响应的整个过程是否正常运作。系统内部的所有组件都直接使用正式代码，与外部系统联动的部分用 mock。mock 部分要尽可能小。另外，如果这一阶段能拿到与相当于正式运营时的示例数据，那就更应该积极测试了。

本阶段通过内存数据库、模拟请求等来控制输入输出部分。内存数据库用 SQLite 的内存数据库即可。由于 O/R 映射工具会帮我们吸收掉 RDBMS 的差异，所以用作功能测试绰绰有余。

至于模拟请求，用 WebTest 比较容易实现。

◉ WebTest

WebTest 是用于 Web 应用功能测试的库。它会对 WSGI 应用执行模拟请求并获取结果。基本上所有 WSGI 应用的测试都可以用它。

WebTest 可以用 pip 进行安装（LIST 8.21）。

LIST 8.21　安装

$ pip install webtest

LIST 8.22　使用方法

def test_it():
    from webtest import TestApp
    import myproject.app
    app = TestApp(myproject.app)
    res = app.get('/')
    assert "Hello" in res

如 LIST 8.22 所示，WebTest 中的 webtest.TestApp 可以通过构造函数的传值参数接收 WSGI 应用类型的测试对象（比如 myproject.app）。TestApp 拥有 get、post 等方法。它可以利用这些方法来执行 WSGI 应用，接收响应对象。在测试过程中，要测试响应对象的内容以及执行测试之后的数据库的状态等。

NOTE

WSGI（Web Server Gateway Interface，Web 服务器网关接口）是 PEP 33331 所倡导的机制。

WSGI 将 Web 服务器与 Web 应用之间的处理定义成了简洁统一的接口，符合 WSGI 标准的服务器以及应用之间可以相互替换，对应 WSGI 的应用可以在任意对应 WSGI 的服务器上运行。

比如在 gunicorn 上运行的 Web 应用可以直接放到 Apache 的 mod.wsgi 上运行。

关于在 gunicorn 上运行 WSGI 应用的方法，我们将在第 12 章中了解。

1https://www.python.org/dev/peps/pep-3333

◉ 配置器

让 WSGI 应用能通过 WebTest 调用模拟请求。另外，还要在数据库配置器和数据库中生成必要的数据以及为外部服务准备 mock。

◉ 测试用例

一个请求对应一个测试用例。设置好配置器之后，通过 WebTest 发送模拟请求。

◉ 断言

当断言的对象为 HTML 时，响应输出的内容很容易变化，所以要检查由 HTML 输出的内容是否以字符串形式包含在其中。功能的输出多为更新 DB，所以直接检查 DB 的数据即可。另外，外部系统的调用也属于输出，这部分要通过 mock 的功能来查看。

◉ 与外部服务有关的测试

这里假设要使用外部的搜索服务。此时只将调用服务的部分替换为 mock。另外，负责生成数据的配置器要生成测试内所需的内容。

from mock import patch
from webtest import TestApp

def setUpModule():
    _setup_db()

def tearDownModule():
    _teardown_db()

def _init_data():
    # 在这里生成数据

def _init_search_results():
    # 在这里创建 mock 的外部服务结果

class TestWithMock(unittest.TestCase):

    def _getTarget(self):
        from app import myapp
        app = TestApp(myapp)
        return app

    @patch('othersite.search')
    def test_it(mock_search):
        """ 测试 """

        # 前提条件
        mock_search.return_value = _init_search_results()
        _init_data()

        # 准备测试对象
        app = self._getTarget()

        # 执行测试对象
        res = app.get('/?search_word=abcd')

        # 确认结果
        assert "20" in res
        mock_account.deposit.assert_called_with(q="abcd")

这里我们假设测试对象 myapp 在内部通过 othersite.search 函数调用了外部服务。测试时 othersite.search 被替换为了 mock。

◉ 与认证和会话相关的测试

大部分 WSGI 应用会将认证信息放在 environ['REMOTE_USER'] 里（非此类框架的原理也是相同的，只需将存储位置替换为该框架的存储位置即可）。WebTest 支持 Cookie，所以能正常执行基于 Cookie 的会话及认证的相关测试。

LIST 8.23　Cookie 的相关测试

from webob.dec import wsgify

@wsgify
def myapp(request):
    c = int(request.cookies.get('count', "0"))
    request.response.set_cookie('count', str(c + 1))
    return "response %d" % c

def test_it():
    from webtest import TestApp
    app = TestApp(myapp)
    res = app.get('/')
    assert res.body == "response 0"

    res = app.get('/')
    assert res.body == "response 1"

如果不是与认证本身直接关联，那么可以在 extra_environ 的 REMOTE_USER 里直接进行设置。下面是直接将 REMOTE_USER 传递给 extra_environ 的例子。

LIST 8.24　认证相关的测试

@wsgify
def myapp(request):
    if not request.remote_user:
        return HTTPFound(location="/login")

    return "OK"

def _makeOne():
    from webtest import TestApp
    return TestApp(myapp)

def _callAUT(url, params={}, method="GET", remote_user=None):
    extra_environ = {'REMOTE_USER': remote_user}
    if method == "GET":
        return _makeOne().get(
           url, params=params, extra_environ=extra_environ)
    elif method == "POST":
        return _makeOne().post(
           url, params=params, extra_environ=extra_environ)

def test_with_login():
    result = _callAUT('/', remote_user='dummy')
    assert result.body == 'OK'

def test_without_login():
    result = _callAUT('/')
    assert result.location == "/login"

_callAUT 方法对测试对象的调用进行了包装。传递给这个包装后的方法的 remote_user 最后将通过 WebTest 的 extra_environ 被传递给测试对象（WSGI 应用）的 environ。

8.3　通过测试改良设计

前面我们谈了一系列仅执行测试对象的测试。不知各位在写测试时是否觉得举步维艰，是否把 mock 写得非常复杂？为什么测试会变得复杂呢？要知道，组件之间的结合度越高，给测试做准备就越花时间，测试也就越复杂。光是替换 mock 都已经要绞尽脑汁，怎么可能轻松添加新功能呢？因此我们要根据测试结果来改良设计。

便于测试的设计

什么样的设计便于测试呢？首先结合度要低。模块间的关联越少，测试起来就越简单。另外就是内聚度要高。模块做的事越少测试越简单。

◉ 面向对象原则

面向对象程序设计有几个原则。遵守这些原则能让我们的设计更加便于测试。当然，有时也要勇于打破它们。不过，如果没有特殊原因，应尽量遵循面向对象原则进行设计。

○ 单一职责原则

保证一个对象只具有一项职责，一项职责只由一个对象来负责。如果只顾眼前方便而盲目追求对象的通用性，就很容易生成大得吓人的类。所以我们应根据单一职责原则，将正确的方法放到正确的类中。名如 HogeManager 的类往往都是未经过整理的类。给类起这种名字自然无妨，但具体的处理最好别写在 HogeManager 中。具体的处理应该在 Model 中实现，然后让 HogeManager 只负责调用即可。这样做有助于提高内聚度。

○ 接口隔离原则

即不依赖于没有必要的接口。Python 的语法中没有接口的概念，但仍然会遇到类似问题。比如一旦我们进行了类检查，那么传值参数将只能接受该类或其子类。然而 Python 支持鸭子类型（Duck Typing），非子类也可以替换使用，所以最好不要做类型检验，而是根据类内是否存在某方法来判定传值参数，从而使模型结构更加灵活。

○ 开放封闭原则

对扩展开放，对修改封闭。一个模块的修改不带来额外的派生，同时模块具有可扩展性。将某个类的对象替换为其子类的对象时，不需在扩展的基础上再修改其他模块。

这类替换必须遵循特定条件，并不是说用类和继承随时都能实现。

○ 里氏替换原则

子类替换父类的过程对调用方透明。这要求我们在定义子类时，宽松对待其能接受的内容，严格把关其返回的内容。

在下述情况下，父类不可以替换为子类。

子类会发生父类不允许发生的例外

子类不接受父类已有的参数

子类返回父类不返回的值

父类中实现的内容在子类中被重载，但并未实现相关处理

如果只是想加强代码的通用性，那么应该尽量重视复用，少用继承。

○ 复用优先于继承

继承是一种紧密的结合，因为我们无法将子类与父类彻底分离。复用则不同，它也是对已有代码的一种再利用，但不会产生继承关系。另外，复用允许我们在测试时用虚拟对象替换原对象。从对象角度看，父类与子类属于同一个东西，从模块角度看则不然。

NOTE

面向对象程序设计的原则并不止这 5 个。本章仅是从活用测试结果的角度挑选了几个加以说明。

8.4　推进测试自动化

测试要做到让任何人都能随时执行。因为这种任何人都能随时执行的测试可以自动化。后面的章节中我们将介绍 CI 工具（Continuous Integration Tool，持续集成工具），它能够在我们向版本库提交代码时自动执行测试，并返回一个规整的测试结果报告。

执行测试和提交测试代码通常都是开发者的工作。此时各位不妨回想一下昨天的情况。测试用例增加了吗？测试用例中通过测试的比例变了吗？覆盖率怎么样？

自动化的 CI 工具会为我们持续保存这些测试结果。它能帮助我们更有效地利用测试结果，并且可以使用已提交至版本库的正式代码进行测试（毕竟谁都难免有忘记提交的时候），从而及早发现源码乃至开发效率上的种种问题。

CI 工具的用法我们放到后面的章节再学习。本章剩余的部分，我们用来给 CI 工具铺路，学习一下如何搭建让任何人都能随时执行测试的环境。

8.4.1　用 tox 自动生成执行测试的环境

tox 能便捷地为我们准备好执行测试所需的环境。tox 会在多个 virtualenv 环境中搭建测试环境，然后在这些环境中执行测试并显示结果。它能够把测试工具的选项及环境变量等内容统一起来，所以我们只需执行 tox 命令即能轻松完成所需的测试。

如下所示，可以用 pip 安装 tox。

$ pip install tox

安装完成后，tox 命令就能用了。执行 tox 时需要用到 tox.ini 文件，我们要在这个文件中描述测试环境的设置。各个环境的设置由所用 Python 的版本、环境变量、测试所需的库、执行测试的代码等组成。

下面是 tox.ini 文件的一个例子。

[tox]
envlist = py26,py27,flake8
skipsdist = true

[testenv]
deps = pytest
       webtest
       testfixtures
       mock
       -rrequirements.txt

commands = py.test

[testenv:flake8]
basepython = python2.7
deps = flake8
commands = flake8

[tox] 节的 envlist 用于设置测试环境列表。这里我们用到了 py27、py26 和 flake8 这 3 个环境。py27 是个特殊的名字，它会找出当前已安装的 python2.7 命令并生成 virtualenv。

另外，我们在 skipsdist 选项中进行了设置，保证即使没有 setup.py 也能执行测试。在没有 setup.py 的情况下，依赖库由 requirements.txt 等进行管理。关于依赖库的管理，我们将在 9.2 节再次进行学习。

[testenv] 节用来进行测试环境的设置。如果存在 [testenv:flake8] 这种指定了环境名的节，则优先采用该节的设置。环境未被特别指定时，使用 [testenv] 节的通用设置。

在这个 tox.ini 文件所在的目录下执行 tox 之后，以下测试将会被执行。

在 python2.6 生成的 virtualenv 内执行 py.test

在 python2.7 生成的 virtualenv 内执行 py.test

在 python2.7 生成的 virtualenv 内执行 flake8

另外，使用 -e 选项可以对指定环境进行测试。

$ tox -e py27
$ tox -e flake8

可见，tox 能将多种不同的测试执行方法统一成一个。另外，由于每个测试都被分离在各自的 virtualenv 内，所以更改一个测试的设置不会对其他测试造成影响。用 Python 2.6 和 2.7 两个版本进行测试就是很好的例子。不仅如此，即便是 Web 应用框架等大程序库的不同版本测试，tox 同样能发挥作用。

8.4.2　可重复使用的测试环境

要想让测试能够重复进行，必须能够随时准备出完全相同的环境。如果上一次测试生成的文件或数据残留在环境中，很可能会对新一次测试造成影响。所以测试结束后，千万记得要用 tearDown 方法清理测试环境。另外，考虑到前一次测试可能由于出错等原因以中断的形式残留在里面，最好在测试开始时和结束时都调用一遍清理方法。清理的对象包括测试用数据库、目录、外部系统的过程等。

8.5　小结

本章讲解了便于测试的设计方法以及高效的测试手法。高效的测试具备以下特点。

测试对象明确

测试要检查的内容明确

可任意次重复执行

进行这类测试必须剔除环境依赖。本章也一直是这样做的，让测试对象不依赖于环境，仅执行测试对象。

测试同样能验证应用的正确性，有助于品质的提升。不仅如此，便于测试的设计拥有低结合高内聚的特点，通过将各自独立的组件组合在一起来实现功能。充分独立的组件所组成的结构有着很好的可维护性和可扩展性。

兼顾到测试的设计可以为我们带来如此多的好处，何乐而不为呢？

第 9 章　Python 封装及其运用

本章将对程序包的使用方法和运用技巧进行介绍。

在第 3 章中，我们已经介绍了 Python 项目的结构。本章内容可视为其后续，为各位讲解包的使用方法，包括可用于试运行环境和正式环境的部署、为执行测试的部署等方面。只要包的使用方法和封装方法得当，测试及部署的自动化也会简单很多。另外，根据实际情况，我们往往需要满足一些常规处理之外的条件。加深对封装的理解，有助于各位应对这些特殊情况，免除为其单独花费工夫的麻烦。

本章还将进一步深入地讲解 pip 的用法。此外，我们还将学习一些其在用法上的组合技巧，帮助我们活用 pip。

9.1　使用程序包

本部分将介绍一些能应用于部署及自动测试的 pip 功能。

9.1.1　程序包的版本指定

有些时候，我们需要安装一些特定版本的程序包，比如想查看某版本下的运行情况，或者需要用到某个版本之前的最后一版。

指定程序包版本的方法有几个。以 pip install colander 为例，colander 是用于模式定义和校验的 Python 程序包。我们用几种不同方式来安装指定版本（LIST 9.1）。

LIST 9.1　指定版本的方法

$ pip install -U colander                # 1.0 : 最新的稳定版
$ pip install -U colander==1.0"          # 1.0 : 指定版本
$ pip install -U "colander<1.0"          # 0.9.9 : 1.0 版以前的最后一个稳定版
$ pip install -U "colander<1.0" --pre    # 1.0b1 : 1.0 版以前的最后一个版本
$ pip install -U colander==1.0b1         # 1.0b1 : 指定版本
$ pip install -U "colander<=1.0"         # 1.0 : 1.0 版以前的最后一个稳定版（包括 1.0 版）
$ pip install -U "colander>=0.9,<0.9.9"  # 0.9.8 : 0.9 版（含）以后 0.9.9 版以前的最后一个稳定版

可见，指定版本的方法并不唯一。某些指定方法需要用双引号“"”括起来。这是防止不等号“<”“>”被 shell 解释为重定向。

为了便于理解，我们这里指定了 -U（--upgrade）选项，因此即便指定程序包已存在于计算机中，系统仍会再安装一遍。

另外，pip 不会安装 alpha 版、beta 版等预发布版本的程序包。至于某版本是否为预发布版本，可以看版本号是否像 1.3a1、1.3b1 这样后面跟着 a1、b1（在 PEP 4401 中有相关定义）。要安装上述预发布版本时，需要明确指定版本号，或者附加 --pre 选项。

1https://www.python.org/dev/peps/pep-0440

专栏　版本命名规范与大小关系

版本的命名规范（结构）与大小关系在 PEP 440 中有相关规定。

版本号要遵循 [N!]N(.N)*[{a|b|c}N][.postN][.devN] 的结构。N 可以为正整数或0。(.N)* 可以重复出现任意次。被 [] 括起来的部分可以省略。a、b、c 分别代表 alpha、beta、rc。

各版本号的先后顺序自然不能根据字符串排序来定，其编号方式及排序大致如 LIST 9.2 所示。

LIST 9.2　版本的编号方式及排序

0.9.1
0.10
1.0a1.dev1
1.0a1
1.0b1
1.0b2
1.0c1
1.0
1.0-post1
1.0.1

实际上，版本号这东西我们很少会在命令行指定。大多是在 setup.py 中指定版本时才会用到它。

举个例子，假设我们为 Python 2.7 开发的 myapp 程序依赖于名叫 securelib 的外部库。然而 securelib 从 1.0 版之后才开始提供我们所需的功能，所以必须指定安装 1.0 以后的版本（LIST 9.3）。现阶段的情况是，myapp 的 setup.py 中并没有给 install_requires 指定版本号。这种情况下，如果计算机中已经装有 securelib，那么其版本将不会被更新。一旦使用者环境中安装的是不提供所需功能的旧版本，程序将无法正常运行。

LIST 9.3　在 setup.py 中指定依赖库为 1.0 以后的版本

setup(
    name='myapp',
    ...
    install_requires=[
        'securelib>=1.0',
    ],
)

这里如果指定 securelib==1.0 又会带来别的问题。因为这样一来，只要我们使用 myapp，securelib 的版本就会被限制在 1.0。就算 securelib 因为安全问题发布了 1.0.1，myapp 的使用者也只能继续用 securelib-1.0。为避免这类问题，要尽量避免在 setup.py 中指定固定版本。

接下来了解一下另一种情况，就是指定某版本之前的版本。比如 securelib-3.0 发布，其使用的 API 发生了重大变更。此时如果安装 myapp 时安装了 3.0，那么 myapp 将无法正常工作。为防止这个情况发生，可以给 myapp 追加一条指定，指定其使用 securelib-3.0 之前的版本（LIST 9.4）。

LIST 9.4　在 setup.py 中指定依赖库为 1.0 以后、3.0 以前的版本

setup(
    name='myapp',
    ...
    install_requires=[
        'securelib>=1.0,<3.0',
    ],
)

做过上述修改后，就能让使用者安装正确版本的程序包，保证 myapp 正常运行了。

NOTE

本例中，myapp 选择使用旧版本的securelib。然而，如果程序一直依赖于旧版本的 securelib，将无法使用今后推出的安全更新。所以根据 API 的变更重新修改、测试 myapp，让其支持 securelib-3.0 才是比较好的选择。

9.1.2　从非 PyPI 服务器安装程序包

pip 搜索程序包时默认引用 PyPI 的 URL2。需要引用 PyPI 以外的服务器时，有两个选项可供使用。

2https://pypi.python.org/simple

-i（--index-url）选项可以指定其他兼容服务器来代替 PyPI。这些服务器被称为 index 服务器，PEP 3013、PEP 4384 中对它们的规格有着严格规定。举个例子，当我们想从 PyPI 的测试服务器 5 安装程序包时，可以像下面这样执行。

3https://www.python.org/dev/peps/pep-0301

4https://www.python.org/dev/peps/pep-0438

5https://testpypi.python.org/

$ pip install bpbook -i https://testpypi.python.org/simple

同样地，我们可以在公司内搭建替代 PyPI 的 index 服务器，用 pip 从该服务器上进行安装。PyPI 上公开了多种搭建 PyPI 替代服务器的方法，其中 devpi6 可以给每个用户设置多个索引，同时具备 PyPI 镜像功能，非常好用。

6https://pypi.python.org/pypi/devpi

另一个用起来更方便的选项是 find-links。-f（--find-links）选项用来指定包含目标程序包链接的页面。该选项指定的页面所链接的程序包会优先于 index 服务器。如果在指定页面没有找到目标程序包，计算机则会引用 index 服务器内的资源进行安装。

$ pip install -f https://bitbucket.org/shimizukawa/logfilter/downloads logfilter
Downloading/unpacking logfilter
  Downloading logfilter-0.9.2.zip
  Running setup.py egg_info for package logfilter

像上面例子中这样，指定 -f 选项后，计算机便会检查该页面内的链接，识别程序包名称，自动获取并安装最新版本。不过，pip install 只能识别符合规定的正确包名。规定大致如下。

{ 包名}-{ 版本号}（-{ 平台}）?.{ 扩展名}

指定平台的部分可以省略。如果一个程序包指定了平台（如 win32），那证明它在其他平台上是无法运行的。因此，pip 只会寻找并安装适合当前平台的程序包。

专栏　安装未存放于PyPI 的程序包

有些程序包虽然被添加到了PyPI，但实际的程序包文件却存放在其他地方。这种程序包无法用 pip install 程序包名的方式进行安装，所以必须指定 --allow-external 选项。

$ pip install --allow-external 程序包名

另外，如果程序包被存放在 http 环境而非 https 环境下，pip 会认为程序包有可能在通讯中被篡改，因此会中断安装。在迫不得已一定要使用这类程序包的情况下，请指定 --allowunverified 选项。

$ pip install --allow-unverified 程序包名

专栏　已删除的 PyPI 镜像规格

PEP 3817 对 PyPI 镜像服务器规格作了规定。但随着 CDN 等技术的应用，以及 PyPI 服务器逐渐稳定，镜像服务器规格的删除在 PEP 4648 中被提出，并最终于 2014 年春完成删除。因此，如今已不需要用 --index-url 选项引用镜像服务器。另外，曾经的 --use-mirror 选项也于pip-1.5起被删除。

曾提供服务的 a.pypi.python.org、b.……之类的镜像服务器的域名早已废止。现在使用旧版本 pip，通过环境变量或设置将镜像引用设为有效后，pip 有可能不正常工作。因此请各位避免使用那些会引用镜像服务器的选项。

7https://www.python.org/dev/peps/pep-0381

8https://www.python.org/dev/peps/pep-0464

9.1.3　程序包的发布格式

如今上传到 PyPI 的程序包大多采用 sdist 格式。sdist 是包含程序包元数据及构建方法的存档格式。它会在每次安装时读取各环境的设置，存在 C 扩展时进行构建，然后检查所需的 Python 程序包并复制到 site-packages。另外，sdist 中其实有许多文件并不会被安装。这些工作在每个平台上实施一次就足够了。相对地，二进制包的特点是仅包含已构建完毕的 C 扩展和 Python 程序包，仅解压程序包的存档即可完成安装。

Python 的二进制包长期以来使用着 setuptools 实现的 egg 格式。但是 pip 并不支持 egg 格式的程序包，这导致该格式在普及的道路上一直停滞不前。随着 Python 封装的规范化，PEP 4279 提出了一个能克服 egg 缺点的 wheel 格式。加之 pip 也开始支持 wheel 格式，所以其很快在部署等方面得到应用。

9https://www.python.org/dev/peps/pep-0427

已上传到 PyPI 的 wheel 格式程序包可以通过 pip 直接安装。接下来，我们以安装 Django 为例来学习一下。

$ pip install django
Downloading/unpacking django
  Downloading Django-1.7.1-py2.py3-none-any.whl (7.4MB): 7.4MB downloaded
  Storing download in cache at /var/pip-chache/https%3A%2F%2Fpypi.python.org%2Fpackages%2Fpy2.py3%2FD%2FDjango%2FDjango-1.7.1-py2.py3-none-any.whl
Installing collected packages: django
Successfully installed django
Cleaning up...

可以看到计算机从 PyPI 下载了 wheel 格式的程序包。pip 是从 1.5 版以后开始正式支持 wheel 格式的。这里我们将 pip 的版本降回 1.4，然后再安装 Django 试试。

$ pip install django
Downloading/unpacking django
  Downloading Django-1.7.1.tar.gz (7.5MB): 7.5MB downloaded
  Storing download in cache at /home/aodag/.pip/eggs/https%3A%2F%2Fpypi.python.org%2Fpackages%2Fsource%2FD%2FDjango%2FDjango-1.7.1.tar.gz
  Running setup.py egg_info for package django

    warning: no previously-included files matching '__pycache__' found under directory '*'
    warning: no previously-included files matching '*.py[co]' found under directory '*'
Installing collected packages: django
  Running setup.py install for django

    warning: no previously-included files matching '__pycache__' found under directory '*'
    warning: no previously-included files matching '*.py[co]' found under directory '*'
    changing mode of build/scripts-2.7/django-admin.py from 644 to 755
    changing mode of /home/aodag/works/bpbook2015/djangoenv/bin/django-admin.py to 755
    Installing django-admin script to /home/aodag/works/bpbook2015/djangoenv/bin
Successfully installed django
Cleaning up...

可以看到，这种情况下的安装是通过 tar.gz 存档的 sdist 进行的。另外，相较于通过 wheel 进行安装，通过 sdist 进行安装时，下载后的处理要多出很多。

对于 Django 这种不包含 C 扩展的程序包而言，上面的差距还算可以接受。可是一旦换成 Pillow 这种包含 C 扩展，且 C 扩展需要大量依赖库的程序包时，差距就无法忽视了。现在 PyPI 上提供了支持各种 Python 版本及 CPU 的 wheel 格式 Windows 版 Pillow 程序包，即便是在难以准备编译器的 Windows 环境下，也只需要通过 pip 进行安装即可开始使用 Pillow 了。

专栏　wheel 程序包的文件名

PEP 42710 中规定了 wheel 程序包的命名规则。自此，wheel 程序包依赖于哪个版本的 Python 以及哪个OS，仅凭包名即可一目了然。

其命名与解释规则如下。

{distribution}-{version}(-{build tag})?-{python tag}-{abi tag}-{platform tag}.whl

LIST 9.5 是一些已上传到PyPI 的程序包的文件名。

LIST 9.5　wheel 程序包文件名示例

bpmappers-0.7-py2-none-any.whl
Django-1.7.1-py2.py3-none-any.whl
MarkupSafe-0.23-cp27-none-linux_x86_64.whl
Pillow-2.6.1-cp32-cp32m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.whl

10https://www.python.org/dev/peps/pep-0427

9.1.4　生成wheelhouse 的方法

wheel 格式的程序包方便好用，但我们前面也说了，PyPI 上大部分程序包都是 sdist 格式。为方便今后在 CI 工具上和部署时能有 wheel 可用，我们来学习一下如何根据 sdist 生成 wheel。pip 可以通过 wheel 命令将 PyPI 上只提供了 sdist 的程序包转换成 wheel 格式并保存在本地计算机中。本地的 wheel 格式程序包默认保存在 wheelhouse 目录下，也正因为如此，我们习惯将所有保存 wheel 格式程序包的目录都称为 wheelhouse（与实际目录名称无关）。使用 wheel 命令前，需要先安装 wheel 包。

$ pip install wheel

接下来，我们以生成 Pillow 的 wheel 为例实际生成一个 wheelhouse。

$ pip wheel pillow
Downloading/unpacking pillow
  Downloading Pillow-2.6.1.tar.gz (7.3MB): 7.3MB downloaded
  Running setup.py egg_info for package pillow

 ...

Building wheels for collected packages: pillow
  Running setup.py bdist_wheel for pillow
  Destination directory: /home/aodag/works/bpbook2015/wheelhouse
Successfully built pillow
Cleaning up...

$ ls wheelhouse/ -l
-rw-r--r-- 1 bp bp 714085 11 月 10 19:31 Pillow-2.6.1-cp27-none-linux_x86_64.whl

可见，pip 和 wheel 组合使用之后，可以将只提供了 sdist 的程序包转换为 wheel 格式保存。

NOTE

生成并保存在 wheelhouse 里的 wheel 依赖于执行 wheel 命令的平台。库中包含 C 扩展的 wheel 不具备跨平台通用性。比如正式服务器使用 Linux 但开发者使用 OS X，就需要准备一个与正式服务器同平台的 CI 服务器，在上面执行生成 wheelhouse 的任务。

9.1.5　从 wheelhouse 安装

现在我们来学习如何安装 wheelhouse 内的程序包。最简单的方法就是用 pip install 直接指定 wheelhouse 目录下的文件。

$ pip install wheelhouse/Pillow-2.6.1-cp27-none-linux_x86_64.whl
Unpacking ./wheelhouse/Pillow-2.6.1-cp27-none-linux_x86_64.whl
Installing collected packages: Pillow
Successfully installed Pillow
Cleaning up...

由于 C 扩展的编译等处理在这个阶段早已完成，所以安装会十分迅速。不过，wheel 格式程序包的文件名通常比较复杂，每次安装都手动输入实在麻烦。

其实，只要用 -f（--find-links）指定 wheelhouse 目录，程序包名部分就可以只写 Pillow 了。

$ pip install -f wheelhouse pillow
Downloading/unpacking pillow
Installing collected packages: pillow
Successfully installed pillow
Cleaning up...

保险起见，我们添上 --no-index 选项以防 wheelhouse 以外的目录被引用。

$ pip install --no-index -f wheelhouse pillow
Ignoring indexes: https://pypi.python.org/simple/
Downloading/unpacking pillow
Installing collected packages: pillow
Successfully installed pillow
Cleaning up...

事先在 wheelhouse 目录下生成的 wheel 格式程序包都可以通过上述步骤进行安装。只要将所有依赖库全都放到 wheelhouse 下，我们就可以脱机完成环境搭建。另外，由于安装时所需的处理极少，所以非常适合需要多次重复搭建相同环境的情况。为部署和 CI 工具搭建环境时，请务必记得运用 wheelhouse 目录。

9.2　巧用程序包

在需要多次重复安装的时候，wheel 格式的程序包显得十分便捷。接下来我们学习一下如何将其巧用到 CI 和部署中去。

9.2.1　私密发布

有时候我们需要用到未在 PyPI 上公开的程序包，比如不能在 PyPI 上公开的公司内部库，或者一些经过修改但尚未正式发布的库等。

这种时候，可以借助版本库服务器提供的功能做私密发布。私密发布一般用来做程序包公开到 PyPI 前的测试，以及给一些不想添加到 PyPI 但仍需安排版本号的程序包做内部公开。前面我们学习了直接从版本库进行安装的方法，使用者可以通过这个方法获取目标版本的程序包，其过程与程序包作者本人的意图基本无关。

Github 和 Bitbucket 会以标签名或分支名为单位提供 zip 或其他格式的源码文件。同时，通过使用 pip 指定包含程序包名称的完整 URL，可以绕过索引服务器直接安装程序包。将这两个组合在一起，我们就能安装已私密发布但尚未在 PyPI 发布的程序包了。

举个例子，有个名为 logfilter 的工具，其源码在 Bitbucket 中公开管理。这个工具并未在 PyPI 上公开程序包，因此不能通过 pip install logfilter 进行安装。但是，如果我们像 LIST 9.6 这样直接指定 URL，就能够完成安装了。

LIST 9.6　安装已私密发布的程序包

$ pip install https://bitbucket.org/shimizukawa/logfilter/get/logfilter-0.9.2.zip

9.2.2　巧用 requirements.txt

用 pip freeze 命令可以查看通过 pip 安装的依赖库的内容。如果将这些内容保存在文件中，再用 pip install 的 -r 选项指定这个文件，在该环境中安装的库就可以在其他环境中被还原。

$ pip freeze > requirements.txt
$ pip install -r requirements.txt

多数项目都将依赖库的列表保存在了 requirements.txt 文件中。不过，requirements.txt 能做的事还远不止于此。

requirements.txt 内部可以引用其他 requirements.txt。比方说我们要把只在开发时才需要的库和执行时需要的库分开管理。此时，我们需要把测试运行器和配置器工具写在 tests-require.txt 文件里，把执行时需要的库写在 requirements.txt 文件里。这种情况下，只要准备一个如 LIST 9.7 所示的 dev-requires.txt，就能通过一条 pip install -r dev-requires.txt 完成开发者环境的准备工作。

LIST 9.7　dev-requires.txt

-r requirements.txt
-r tests-require.txt

另外，建议将 --allow-external 和 --allow-unverified 等常用选项也一并写在 dev-requires.txt 中。这样可以将选项与 requirements.txt 分离，免得在使用 pip freeze > requirements.txt 自动生成文件时丢失选项。

9.2.3　requirements.txt 层级化

接下来，我们考虑对 requirements.txt 进行分割。首先，requirements.txt 是许多工具默认识别的文件名，所以这个文件名的重要性最高，要用来保存执行时所需程序库的列表。接下来考虑开发者使用的工具。这部分可以按用途分为测试工具、文档生成、模式迁移等多个类别。我们为每个用途分别准备一个文件。

LIST 9.8 ～ LIST 9.12 表示的就是一个文件分割的例子。

LIST 9.8　requirements.txt

pyramid==1.5.2
sqlalchemy==0.9.8
psycopg2==2.5.4

LIST 9.9　tests-require.txt

pytest==2.6.4
pytest-cov==1.8.1
coverage==3.7.1
webtest==2.0.6

LIST 9.10　docs-require.txt

Sphinx==1.3b2

LIST 9.11　db-requires.txt

alembic==0.6.7
psycopg2==2.5.4

LIST 9.12　dev-requires.txt

--no-index
-f http://devpi/+myproject/simple
-r requirements.txt
-r tests-require.txt
-r docs-require.txt
-r db-requires.txt

这样设置下来之后，就不会在执行时安装一些没用的库了。同时，tests-require.txt 等还可以拿到其他项目之中重复利用。

9.2.4　为部署和 CI+tox 准备的 requiremests

前面我们学习过，用 pip 能够将依赖库以 wheel 格式保存在 wheelhouse 里。为方便将它们运用到 CI 和部署当中，我们要简化这些依赖库的安装流程。

用 pip 管理程序包的情况下，依赖库会记录在 requirements.txt 中。这里我们跟前面一样，在 requirements.txt 里只保存依赖库的列表。

然后，为了在搭建环境时只从 wheelhouse 获取这些库，我们像 LIST 9.13 这样编写 dev- requires.txt，将库的获取位置限定为 wheelhouse。

LIST 9.13　dev-requires.txt

-f wheelhoouse
--no-index
-r requirements.txt

有了这些文件之后，就可以用下述方法从 wheelhouse 进行安装了。

$ pip install -r dev-requires.txt

接下来，我们把它应用到我们在 8.4.1 节学习过的测试环境工具 tox 上。tox 可以在 virtualenv 中为每个测试环境搭建一个虚拟环境，如果能通过 wheel 格式给各个环境安装依赖库，那么必将提升搭建环境的效率。

tox 可以在 tox.ini 文件中指定安装到环境的库（LIST 9.14）。由于这部分是直接交给 pip 处理的，所以指定的东西不一定非要是库，还可以是 requirements.txt 等文件。

LIST 9.14　tox.ini

[testenv]
deps = -rdev-requires.txt

这样设置下来之后，计算机就会根据 dev-requires.txt 从本地的 wheelhouse 安装依赖库。这里有一点需要注意，tox 不会查看 dev-requires.txt、requirements.txt 这类文件的内容，所以如果我们对这些文件进行了编辑，需要手动执行带 --recreate 选项的 tox 命令。

$ tox --recreate

虽然难以避免上述这类麻烦，但对于某些需要频繁重置环境的 CI 工具而言，它仍是一个非常省时省力的方法。特别是使用 travis.ci 等对执行时间有限制的工具时，缩短搭建环境的时间显得十分重要。

9.2.5　通过 requirements.txt 指定库的版本

我们在用 pip freeze 生成的 requirements.txt 文件中指定了库的版本。通过 requirements.txt 指定版本的方法有很多种，这里我们学习直接指定版本的方法。对于程序库而言，即便只是修复 Bug，也可能导致其运行出现变化。因此，开发、测试、正式环境要保证使用同版本的库。CI 工具可以用 tox 的 --recreate 选项来严格按照 requirements.txt 中描述的库重新搭建环境。另外，想知道环境中是否安装了未包含在 requirements.txt 中的库时，可以通过 pip freeze 指定 requirements.txt 进行查看（LIST 9.15）。

LIST 9.15　查看是否安装了未包含在 requirements.txt 中的库

$ pip freeze -r requirements.txt

随着程序库版本不断更新，wheelhouse 内的文件会越积越多。我们可以通过下述方法将未包含在 requirements.txt 中的库转移到其他目录。

$ mv wheelhouse wheelhouse.old
$ pip wheel -r requirements.txt -f wheelhouse.old

对正式服务器而言，直接从当前活动环境中撤走程序库会带来很高的风险。这时就需要花些心思来规避风险了，比如在 virtualenv 上重新搭建一个环境，然后切换 nginx 等反向代理的连接。

9.3　小结

本章介绍了程序包的使用方法和使用技巧。正如本章所说的，巧用 pip 可以为测试和部署的自动化减轻很多负担。希望各位能以本章介绍的方法为参考，研究出适合各自项目的结构。

第 10 章　用 Jenkins 持续集成

不知各位有没有遇到过这种事——在自己的环境里运行一切正常的程序放到开发环境和正式环境中却突然无法运行了。如果有，那么在导致无法运行的原因中，有没有遗漏提交或者忘记安装关键程序呢？

这些恼人的低级错误往往会导致我们被经理叫去面谈，出错经过被刨根问底，最后被迫“写个应对方案出来”，心情沉重好几天。麻烦的是，就算我们在发现问题的头几天能提前注意，暂时避免问题的重复发生，但是随着时间推移早晚还会再犯，结果还是得对着经理铁青的脸咽苦水。相信不少人都有过类似经历吧？

如果每次修改程序都重新构建、测试、检查测试结果，我们或许能避免上述失误。但是，人类是一种不擅长单调重复劳作的生物。所以，我们需要借助工具让这一系列工作的执行自动化、定期化。

这种自动化、定期化执行的解决方案称为持续集成（Continuous Integration，CI）。这个解决方案极大降低了各种人为失误（粗心大意、重复劳作的惰性等）带来的风险。本章将以 Django 框架的 Web 应用为例，讲解如何用 CI 工具 Jenkins 实现持续集成。Django 的相关知识请参考第 14 章。

10.1　什么是持续集成

10.1.1　持续集成的简介

◉ 开发流程中存在的风险

持续集成是一种让计算机自动地任意次重复整个开发流程（编译、测试、汇报等）的开发手法，一般简称为 CI。由于其频繁重复整个开发流程，所以能帮助开发者提早发现问题。

为方便理解持续集成，现在我们把从写代码到向执行环境发布的整个开发流程大致分为以下 3 个阶段。

① 编写源码。修改已有代码

② 提交、push

③ 进行发布

在②和③的过程中容易出现下述问题，必须严加注意。

遗漏提交。忘记提交要发布的程序

遗漏合并。push 到 default 的时候忘记合并，出现多头现象

遗漏安装。编写的程序需要特定 Python 模块才能运行，但是忘记安装该模块了

对于这类问题，只要事先准备好测试代码，在程序发布到目标环境之前执行测试，基本都能被发现。

◉ 长期开发中容易出现的问题

我们都希望测试代码在发布之后仍能够长期继续使用。毕竟开发是一个持续的过程，一次发布之后还会有下一次发布，并不是发布一次就结束了。

在开发长期持续的过程中，我们会遇到下面这些问题。

○ 忘记以前发布的代码

肩负程序修改任务的开发者对源码的修改十分敏感，会频繁地执行测试代码。可是，一旦修改任务结束，问题关闭，就很少有人再愿意回过头去手动执行那些已经通过的测试了。

○ 只关心自己负责的部分

大部分时候，我们会去执行与自己负责部分相关的测试代码，但不会关心责任范围以外的模型的测试。然而，我们对程序的修改有时会“枪毙”掉某些看似毫无关系的模块的测试结果。估计各位之中有不少人有过类似经历。

可见，我们很难通过人为的注意和操作来确认整个开发流程，而且这样做也是一种时间和劳动力的浪费。如果项目代码量很大，那么执行所有测试代码必然消耗大量时间，如果是在自己的环境中执行，这根本不现实。所以这类工作最好交给计算机去做。

◉ 一天内多构建几次能很快发现问题

一天内多次定期 pull 源码并运行测试代码可以帮助我们及早发现问题。与其等到后期一次性找出一大堆问题，不如在产生问题时及早处理来得方便和放心。这一做法被我们称为持续集成。

NOTE

持续集成是XP（Extreme Programming，极限编程）的最佳实践之一。原文出自 Martin Fowler1 的论文，各位可通过下述 URL 查看2。

1国际著名的面向对象分析设计、UML、模式等方面的专家，敏捷开发方法的创始人之一，现为 ThoughtWorks 公司的首席科学家。——编者注

2中文版论文请参考如下网址：http://www.cnblogs.com/cloudteng/archive/2012/02/25/2367565.html。——编者注

Continuous Integration

http://www.martinfowler.com/articles/continuousIntegration.html

10.1.2　Jenkins 简介

Jenkins 是基于 Java 的开源 CI 工具，其安装和操作都很简单。另外，Jenkins 不仅能在面板上轻松看出 Job 成功或失败，还可以借助通知功能将结果以邮件或 RSS 订阅的形式发给用户。

除此之外，Jenkins 还允许通过插件进行功能扩展，所需功能可以随用随添加，而且还支持主从式集群，能够轻松地横向扩展。

下述流程是用 Jenkins 实现持续集成的一个例子。本章我们将根据下述流程进行学习。

为 Job 的执行制定日程表

签出源码

通过 shell 脚本执行测试（包括检测覆盖率）并输出结果

通过 shell 脚本构建文档

统计测试结果和覆盖率

统计 TODO

发送邮件通知 Job 的结果

将 Job 的结果保存至 Slack

10.2　Jenkins 的安装

我们先从 Jenkins 的安装开始学习。Jenkins 发布了面向 Windows 和 OS X 的安装包。此外，还可以通过包管理器安装 Ubuntu/Debian、Red Hat/Fedora/CentOS、FreeBSD 等。我们可以根据构建对象的环境选择相应的版本。本书将以 Ubuntu 14.04 上的安装与运行为基准向各位讲解 Jenkins。

10.2.1　安装 Jenkins 主体程序

现在来安装 Jenkins 的主体程序。首先执行下述命令添加用于 apt 的公共密钥。

$ wget -q -O - http://pkg.jenkins-ci.org/debian/jenkins-ci.org.key | sudo apt-key add -

然后打开 apt 的设置文件 /etc/apt/sources.list，将下述语句添加到最后一行。

deb http://pkg.jenkins-ci.org/debian binary/

最后执行下述命令，Jenkins 就会被安装到计算机中。

$ sudo apt-get update
$ sudo apt-get install jenkins

安装完成之后系统会自动生成 jenkins 用户，Jenkins 也会启动。比如启动 Jenkins 的服务器 IP 地址为 10.0.0.1，那么只要访问 http://10.0.0.1:8080 即可看到 Jenkins 的首页。/var/lib/jenkins 是 Jenkins 用户的主目录，该目录下保存着设置文件和工作目录。

NOTE

Gunicorn、Tomcat 等运行着 Web 容器的环境可能已经占用了 8080 端口，这种时候， Jenkins 无法在默认设置状态下提供服务。要想使用 Jenkins，必须更改 Jenkins 启动时的 HTTP 端口号，比如用文本编辑器打开 /etc/default/jenkins，将 HTTP_PORT 的值从默认的 8080 改成其他值。

10.2.2　本章将用到的 Jenkins 插件

Jenkins 可以通过插件进行功能扩展，因此广大用户为适应各种用途开发了大量插件。本章我们将用到下述 4 个插件。

① Mercurial Plugin：使用源码管理系统（SCM）Mercurial 时所需的插件

② Cobertura Plugin：生成源码覆盖率报告时所需的插件

③ Task Scanner Plugin：统计源码中的 TODO 时所需的插件

④ Slack Plugin：用来向 Slack 发送通知的插件

插件的安装可以通过“系统管理”界面的“管理插件”进行。这里为方便说明，我们来了解一下通过 CLI（Command Line Interface，命令行接口）进行安装的方法。

首先下载 Jenkins 的 CLI 工具。

$ wget http://localhost:8080/jnlpJars/jenkins-cli.jar

通过 CLI 工具安装插件。

$ java -jar jenkins-cli.jar -s http://localhost:8080 install-plugin tasks mercurial slack cobertura

重启 Jenkins，使刚安装的插件生效。

$ sudo service jenkins restart

方便好用的 Jenkins 插件很多，除了这里介绍的几种以外，还有 JobConfigHistory Plugin、Email-ext Plugin、Timestamper Plugin，等等。

下面是这些插件的官方维基百科。

JobConfigHistory

https://wiki.jenkins-ci.org/display/JENKINS/JobConfigHistory+Plugin

Email-ext Plugin

https://wiki.jenkins-ci.org/display/JENKINS/Email-ext+plugin

Timestamper Plugin

https://wiki.jenkins-ci.org/display/JENKINS/Timestamper

10.3　执行测试代码

现在我们拿一个简单的 Python 项目的测试代码作为例子执行一下。Python 项目由版本控制系统保存，Jenkins 将从版本控制系统获取 Python 项目并执行测试。

10.3.1　让 Jenkins 运行简单的测试代码

首先，我们写一个包含 unittest 模块的简单的测试代码，让 Jenkins 来运行它（LIST 10.1、 LIST 10.2）。

LIST 10.1　foo.py

# -*- coding:utf-8 -*-

def divide(num1, num2):
    """ 对传值参数做除法的简单函数
    """
    return num1 / num2

LIST 10.2　test_foo.py

# -*- coding:utf-8 -*-
import unittest

import foo

class SimpleTest(unittest.TestCase):
    """ 测试做除法的函数
    """
    def test1(self):
        self.assertEqual(foo.divide(2, 2), 1)

    def test2(self):
        self.assertEqual(foo.divide(0, 1), 1)

if __name__ == "__main__":
    unittest.main()

将这两个文件保存在 Mercurial 的版本库里，放在 Jenkins 服务器上的 /var/hg/simple_test 目录下。

10.3.2　添加 Job

接下来给 Jenkins 添加 Job。Jenkins 的 Job 可以指定多种类型的任务，比如构建项目、签出代码等。现在请打开 Jenkins 面板侧边菜单的“新建”界面，如图 10.1。

图 10.1　Jenkins 的面板

在“新建”界面的“Item 名称”处输入 Job 名，选择 Job 种类。如图 10.2，这里我们输入 simple_test 作为 Job 名，种类则选择“构建一个自由风格的软件项目”，然后点击界面下部的“OK”按钮。

图 10.2　新建界面

◉ 指定源码管理系统

在 Job 设置界面的“源码管理”一栏中选择 Mercurial，随后会显示 3 个输入框，我们作如下输入。

① Repository URL：输入版本库的 URL

　 输入 /var/hg/simple_test

② Branch：输入 Mercurial 的分支名。留空则使用 default

　 这次我们要用的是 default，因此留空

③ 源码库浏览器：指定用于查看版本库更改的工具

　 现阶段先用默认的“自动”

NOTE

关于版本库 pull 出错

Jenkins 的 Job 从 Mercurial 上签出源码时，经常由于忘记设置版本库权限而导致 pull 出错。

安装 Jenkins 时会自动创建 jenkins 用户，执行 Job 的都是这个用户，所以一定要记得给这个用户权限。

另外，有时即便已经有权限，在执行 hg pull 时仍会遇到“不可信的用户”“不可信的群组”这类错误。这种情况一般出现在版本库的“.hg/hgrc”所有者与执行 hg 命令的用户不一致的时候。只要在 jenkins 用户的 $HOME/.hgrc 的 trusted 节中进行设置即可解决该问题。比如“不可信的用户”为 foo、“不可信的群组”为 bar 时，则需在 hgrc 作以下设置。

[trusted]
user = foo
group = bar

此外，trusted 节的 user、group 元素可以一次指定多个值，相邻两值之间用逗号隔开。

◉ 制定日程表

然后我们来设置另一个重要项目——构建触发器。这个项目能像 cron 一样制定日程表，比如让 Job 定期执行，或者在其他 Job 结束后执行等。这里我们选择“Build periodically”。勾选之后会出现“日程表”输入框，我们在这里设置 Job 的执行间隔，具体方法与设置 crontab 时一样。比如我们想每个小时的 0 分时执行 Job，则写成下面这样。

0 * * * *

此外，在 Jenkins 的 Job 设置界面里，每一项后面都有为用户准备的帮助。各位如果遇到看不懂的项目，可以点击相应的帮助图标作参考。

◉ 设置执行测试代码的命令

最后，在“构建”选项卡处设置执行测试代码的命令。点击“增加构建步骤”之后即可选择“Execute shell”。然后在文本框中描述下述命令，如图 10.3 所示。

python test_foo.py

图 10.3　构建步骤“Execute shell”

只要把我们写 unittest 的测试用例时手动执行的命令写在这里即可。完成上述所有步骤之后点击“保存”按钮进行保存，接下来 Jenkins 将在我们指定的时刻执行 Job。

10.3.3　Job 的成功与失败

Jenkins 的 Job 可以从管理界面手动启动。现在我们试着点击面板上的“立刻构建”，启动刚才创建的 Job。

随后各位看到的应该是一个红色图标，表示“失败”。我们可以点击左侧栏“控制台输出”链接查看执行结果（图 10.4）。Jenkins 会以控制台日志的形式记录每次执行 Job 的过程，便于用户调查失败原因。

图 10.4　通过“控制台输出”查看 Job 执行失败的内容

从日志中一眼就能看出测试结果为 FAILURE。失败的原因是测试代码有问题，所以接下来，我们要将测试用例修改成 LIST 10.3 这样，然后重新启动 Job。

LIST 10.3　test_foo.py

# -*- coding:utf-8 -*-
import unittest

import foo

class SimpleTest(unittest.TestCase):
    """ 测试做除法的函数
    """
    def test1(self):
        self.assertEqual(foo.divide(2, 2), 1)

    def test2(self):
        self.assertEqual(foo.divide(0, 1), 0)

if __name__ == "__main__":
    unittest.main()

如图 10.5，这次看到的应该是蓝色图标，测试结果为 SUCCESS。

图 10.5　执行成功时的“控制台输出”

10.4　测试结果输出到报告

经过前面的安装与设置，我们已经可以用 Jenkins 创建 Job 并执行测试代码了。不过，实际开发中的测试用例要比这个多得多，而且单纯从面板上查看测试成功 / 失败并不能满足开发的需求。

Jenkins 可以读取指定格式（xUnit 格式）的 xml 文件并将测试结果以报告形式显示在屏幕上。使用 Python 的 pytest 不仅能够一次性执行多个测试用例，还能将结果以 xUnit 格式输出。所以这里我们借助 pytest 来输出测试结果的报告。

10.4.1　安装 pytest

可以用 pip 安装 Pytest。

$ pip install pytest

10.4.2　调用 pytest 命令

安装完 pytest 后就可以调用 py.test 命令了。我们先移动到放有 test_foo.py 的目录下，然后执行如下命令。

$ py.test --junit-xml=test_result.xml

该命令会输出一个 xUnit 格式的 test_result.xml 文件。现在只要让 Jenkins 读取它，我们就能从屏幕中看到测试结果的报告了。

10.4.3　根据 pytest 更改 Jenkins 的设置

首先给 Jenkins 设置 virtualenv，安装 pytest。

$ sudo su - jenkins
$ virtualenv .virtualenv/simple_test
$ source .virtualenv/simple_test/bin/activate
$ pip install pytest

然后来更改 Jenkins 的设置。需要修改的只有“Execute shell”中指定的命令行操作内容。原本我们是用 python 命令直接执行 unittest 的测试用例的，这里替换成调用上面提到的 py.test 命令，如图 10.6。

图 10.6　根据 pytest 修改命令

接下来设置“构建后操作”选项卡，让 Jenkins 读取 py.test 命令输出的 xml 文件，如图 10.7。

图 10.7　设置读取测试报告

点击“增加构建后操作步骤”按钮，选择“Publish JUnit test result report”。随后该选项卡内会自动生成一个“Publish JUnit test result report”表格项，我们在“测试报告（XML）”里指定 test_result.xml。

现在再点击“立刻构建”，则将显示如图 10.8 的报告。

图 10.8　测试结果报告

10.5　显示覆盖率报告

现在我们已经能通过 Jenkins 界面查看测试用例执行结果的报告了。接下来我们看看如何用 pytest-cov 获取代码的覆盖率。

10.5.1　安装 pytest-cov

pytest-cov 同样可以用 pip 安装，具体如下。

$ pip install pytest-cov

在本地开发环境中安装完毕后，别忘了在 Jenkins 的 virtualenv 环境中也安装一遍。

10.5.2　从 pytest 获取覆盖率

执行下述命令，输出 xml 格式的覆盖率报告。

$ py.test --cov=foo --cov-report=xml

报告会以固定名称 coverage.xml 输出到当前目录下。如果想同时输出 xUnit 格式的文件，则要用下述命令。

$ py.test --junit-xml=test_result.xml --cov=foo --cov-report=xml

然后将 Jenkins 对应 Job 的“Execute shell”设置值替换成上述命令。

10.5.3　读取覆盖率报告

最后，让 Jenkins 读取上述命令输出的 xml 文件并显示在屏幕上即可。

点击“增加构建后操作步骤”按钮，选择“Publish CoberturaCoverage Report”。随后该选项卡内会自动生成一个“Publish Cobertura Coverage Report”表格项。现在点击“高级”按钮，按照下述方法在图 10.9 中进行设置。

Cobertura xml report pattern ：coverage.xml

Source Encoding ：UTF-8

图 10.9　设置读取覆盖率报告

设定完成之后执行 Job，接下来各位应该能在屏幕上看到如图 10.10 所示的覆盖率报告了。

图 10.10　覆盖率报告

测试覆盖到的源码行显示为绿色，反之显示为粉红色。

图 10.11　查看各个文件的覆盖率

如图 10.11，这样一来我们就能掌握执行测试代码之后的覆盖率了。接下来我们将学习 Django 测试代码的执行方法。

10.6　执行 Django 的测试

前面我们学习了涉及到 unittest 和 pytest 的测试代码的执行方法，这里我们学习一下如何执行 Django 的测试。Django 有着专用的测试机制，各位在采用 Django 时可以拿本节内容作为参考。在学习如何执行测试的同时，我们还会学习如何向邮箱、Slack 等发送通知。

10.6.1　安装 Python 模块

这里要安装 Django 主体程序以及 Django 与 Jenkins 进行联动时所需的几个工具。

◉ Django 主体程序

首先安装 Django 主体程序。

$ pip install django

◉ unittest-xml-reporting

我们知道，要想让 Jenkins 显示测试结果报告，需要将测试执行结果以 Junit 格式的 xml 文件形式输出。

使用 Django 的 manage.py test 命令执行测试后，生成的结果只是纯文本，并不满足上述格式要求。用 unittest-xml-reporting 可以解决这一问题，其安装如下。

$ pip install unittest-xml-reporting

◉ coverage

还要安装 coverage 模块。

$ pip install coverage

10.6.2　Django 的调整

◉ 创建项目目录

创建项目目录。这里我们用 cafe/apps 作为其名称。

$ mkdir cafe
$ cd cafe
$ django-admin.py startproject apps

这个项目由 Mercurial 的版本库管理，保存在 /var/hg/cafe 目录下。

◉ 创建 Django 应用

接下来创建 Django 应用。运行上面那条命令之后，我们会得到 apps 目录。现在移动到该目录下，创建一个名为 menu 的应用。

$ cd apps
$ python manage.py startapp menu

执行完毕后将生成 apps/menu 目录。

10.6.3　示例代码

◉ 编写模型

编写模型。我们这里所用的概念是一间咖啡厅的茶品清单，代码如 LIST 10.4 所示。

LIST 10.4　cafe/apps/menu/models.py

# -*- coding:utf-8 -*-
from django.db import models

TEA_KINDS = (
    ("english", u" 英式红茶"),
    ("chinese", u" 中国茶"),
    ("japanese", u" 日本茶"),
)

class TeaManager(models.Manager):
    def recommended(self):
        """ 仅显示推荐商品 """
        return self.filter(is_flavor=True)

    def count_each_kind(self):
        """ 以字典形式返回各类茶的件数 """
        result = self.values_list("kind").annotate(
                    count=models.Count("kind"))
        return dict(result)

class Tea(models.Model):
    objects = TeaManager()

    name = models.CharField(u" 名称", max_length=255)
    kind = models.CharField(u" 种类", max_length=255, choices=TEA_KINDS)
    price = models.IntegerField(u" 价格")
    is_recommended = models.BooleanField(
                        u" 推荐商品", default=False)

◉ 编写表单

表单代码如 LIST 10.5 所示。

LIST 10.5　cafe/apps/menu/forms.py

# -*- coding:utf-8 -*-
from django import forms

from menu.models import Tea, TEA_KINDS

class TeaSearchForm(forms.Form):
    name = forms.CharField(label=u"", max_length=255, required=False)
    kind = forms.MultipleChoiceField(
                label=u" 种类", choices=TEA_KINDS, required=False)
    extra_report = forms.BooleanField(
                label=u" 输出追加报告", required=False)False)

    def clean(self):
        clnd = self.cleaned_data
        if not self.is_valid():
            return clnd

        if not clnd["name"] and not clnd["kind"]:
            raise forms.ValidationError(
                      u" 名称和种类请至少输入一项")

        return clnd

◉ 向 settings.INSTALLED_APPS 添加 menu

创建完 menu 应用后记得在 settings.py 的 INSTALLED_APPS 里指定它（LIST 10.6）。缺少这一步是不能用 manage.py 执行测试的。

LIST 10.6　cafe/apps/settings.py

INSTALLED_APPS = (
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'menu',
)

◉ 为 Jenkins 写 settings

我们希望 Jenkins 利用 unittest-xml-reporting 将测试结果以 xml 文件的形式输出，所以这里要专门为 Jenkins 写一个 settings。

代码如 LIST 10.7 所示。

LIST 10.7　cafe/apps/settings_jenkins.py

# -*- coding:utf-8 -*-
from settings import *

######################
# Xml test runner
######################
TEST_RUNNER = 'xmlrunner.extra.djangotestrunner.XMLTestRunner'
TEST_OUTPUT_DIR = 'test_reports'

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': 'jenkins.db',
        'USER': '',
        'PASSWORD': '',
        'HOST': '',
        'PORT': '',
    }
}

◉ 生成迁移文件

前面我们用示例代码创建了模型，所以这里还需要生成迁移文件。移动到 manage.py 文件所在的目录下执行下述命令，即可完成迁移文件的生成。

$ python manage.py makemigrations

◉ 编写测试代码

模型和表单的代码都已经写好，接下来该编写测试代码了。测试代码如 LIST 10.8 所示。

LIST 10.8　cafe/apps/menu/tests.py

# -*- coding:utf-8 -*-
import unittest
from django.test import TestCase as DjangoTest

from menu.models import Tea
from menu.forms import TeaSearchForm

row = lambda L: (dict(zip(L[0], x)) for x in L[1:])

class TeaManagerTest(DjangoTest):
    def setUp(self):
        datas = (
            ("name", "kind"),
            (u" 大吉岭", "english"),
            (u" 锡兰红茶", "english"),
            (u" 乌龙茶", "chinese"),
            (u" 铁观音", "chinese"),
            (u" 普洱茶", "chinese"),
            (u" 静冈茶", "japanese"),
        )
        for data in row(datas):
            Tea.objects.create(price=100, **data)

    def test_count_each_kind(self):
        result = Tea.objects.count_each_kind()
        self.assertEqual(result, dict(english=2, chinese=3, japanese=1))

class TeaSearchFormTest(unittest.TestCase):
    def test_valid(self):
        """ 检查输入正常时是否会报错 """
        params = dict(name="foo", kind=["english"])
        form = TeaSearchForm(params)
        self.assertEqual(form.is_valid(), True, form.errors.as_text())

    def test_either1(self):
        """ 检查名称和种类都无输入时是否会报错 """
        params = dict()
        form = TeaSearchForm(params)
        self.assertEqual(form.is_valid(), False, form.errors.as_text())

    def test_either2(self):
        """ 检查输入名称后是否会报错 """
        params = dict(name="foo")
        form = TeaSearchForm(params)
        self.assertEqual(form.is_valid(), True, form.errors.as_text())

    def test_either3(self):
        """ 检查输入种类后是否会报错 """
        params = dict(kind=["english", "chinese"])
        form = TeaSearchForm(params)
        self.assertEqual(form.is_valid(), True, form.errors.as_text())

10.6.4　Jenkins 的调整

◉ 新建 Job

点击 Jenkins 面板侧边栏的“新建”，创建 Job。

① 选择“构建一个自由风格的软件项目”

② 在“Item 名称”处输入 cafe（Job 名并没有硬性要求，这里只是特意选择了与项目名称相同的 cafe）

◉ “源码管理系统”的设置

这里使用 Mercurial。我们事先已经将 cafe 的版本库放在了 /var/hg/cafe 下，所以直接在 Jenkins 的设置界面作如下输入即可。

源码管理系统：Mercurial

Repository URL ：/var/hg/cafe

Branch ：default

源码库浏览器：Auto

◉ 在 Execute shell 中填写测试命令

把 Django 测试结束后输出覆盖率报告的命令写在“Execute shell”中。如果只是单纯地进行 Django 测试，可以用下述命令执行。

$ PYTHONPATH=apps python -m manage test menu --settings=settings_jenkins

通过 coverage 命令执行上述代码即可获取覆盖率。接下来，我们将上述代码改成下面这样。

$ PYTHONPATH=apps coverage run --source=apps -m manage test menu --settings=s
ettings_jenkins && coverage xml -o coverage_reports/coverage.xml

然后，run 成功之后，执行 coverage xml 命令即可输出 xml 格式的报告。

这里我们指定了 --source=apps 选项。该选项可以将覆盖率统计对象限制在指定的目录下。在遇到这种 Django 项目目录与版本库路径不一致的情况时，指定该选项能有效防止读取无关代码。

专栏　不要直接在Django 的项目目录下执行测试

获取覆盖率时有一点需要注意，那就是不能直接在 Django 的项目目录下执行测试。使用 Django 时有很多工作是在 Django 的项目目录下进行（比如上面例子中移动到 apps 目录下并执行 manage.py test）。如果用上述方法统计覆盖率，那么一旦我们移动到 apps 并执行 manage.py test，会连同 Django 本身一同测试并统计覆盖率，最终的报告会变成图 10.12 这个样子。因此，为了防止非测试对象混入覆盖率报告，必须对这一点强加注意。

图 10.12　混入多余代码之后的覆盖率

10.6.5　“构建后操作”选项卡的设置

“构建后操作”选项卡里设置的任务将在 Job 的构建处理执行完毕后执行。

Jenkins 读取测试结果报告与覆盖率报告的操作需要在这里进行设置。另外，执行 Job 失败的邮件通知也是在这里设置的。

◉ 读取测试结果报告

“ Publish JUnit test result report”中有“测试报告（ XML）”一项，用于指定输出的 xml 文件。

这里允许使用通配符。unittest-xml-reporting 会针对每一个测试类输出一个 xml 文件，所以按照下述方法设置即可。另外，测试结果的输出目录要与 Jenkins 的 settings 中指定的目录一致。

测试报告（XML）

cafe/test_reports/*.xml

◉ 读取覆盖率报告

“Publish CoberturaCoverage Report”里有“Cobertura xml report pattern”，我们要在这里指定 coverage xml 命令输出的文件。

Cobertura xml report pattern

cafe/coverage_reports/*.xml

◉ 查看结果

上述设置完成之后，我们就可以看到测试结果报告和覆盖率报告了，如图 10.13 和图 10.14。

图 10.13　测试结果报告

图 10.14　覆盖率报告

NOTE

输出代码覆盖率报告时可能会出现乱码，这是 Java 字体设置导致的。解决中文乱码的方法如下。

Linux

配置环境变量 export JAVA_TOOL_OPTIONS="-Dfile.encoding=UTF-8"

Windows

新建变量 JAVA_TOOL_OPTIONS ，value 值为-Dfile.encoding=UTF-8

重启 Jenkins 之后再执行一遍刚刚出现乱码的Job，可以看到乱码问题已经被解决。

◉ 邮件通知

“E-mail Notification”中有“Recipients”，我们要在这里指定通知接收方的邮箱地址，如图 10.15 所示。

图 10.15　设置收件人

除此之外，还要在侧边栏“系统管理”→“系统设置”里找出“Jenkins Location”，在“系统管理员邮件地址”中填写 Jenkins 用来发送邮件的邮箱地址，然后在“E-mail Notification”下的“SMTP 服务器”中设置 SMPT 服务器（图 10.16、图 10.17）。另外，如果要使用 Gmail 等需要认证的邮箱账户，需要点击“E-mail Notification”中的“高级”按钮，指定“用户名”“密码”等信息。

图 10.16　设置发件人的邮箱地址

图 10.17　设置发件人的邮箱服务器

在上述设置完成之后，Jenkins 会在 Job 执行失败时发送邮件，以通知开发者。

NOTE

安装插件后还可以向即将讲到的 Slack、IRC、Jabber、Growl 等发送通知。另外，安装 Email-ext plugin 之后还可以对邮件的主题、正文、通知设置等进行自定义。

◉ 向 Slack 发送通知

Slack 是一款可以跨平台使用的团队交流工具，还可以与许多第三方服务集成。Jenkins 可以借助 Slack Plugin 向 Slack 发送通知。Slack 的相关说明请参考 4.3 节。

首先进行 Slack 的设置。找出需要通知的频道或组，选择“Add a service integration”，添加“Jenkins CI”。接下来会跳转到“Post to Channel”界面，确认要通知的频道或组无误后，点击“Add Jenkins CI Integration”按钮，跳转至自定义界面。这里要先将“Step 3”中写的“TeamDomain”和“Integration Token”复制下来，因为后面设置 Jenkins 时会用到。Slack 发送通知时所用的名字和头像可以在这个界面修改。最后点击“Save Setting”完成 Slack 的设置。

接下来设置 Jenkins。在 Jenkins 的“系统管理”→“系统设置”中找到“Global Slack Notifier Settings”，然后作如下更改（图 10.18）。

① TeamDomain：设置 Slack 时复制的 TeamDomain

② Integration Token：设置 Slack 时复制的 Integration Token

③ Channel：Slack 的频道名或组名

④ Build Server URL：Jenkins 服务器的 URL

图 10.18　Global Slack Notifier Settings 示例

接着在 Job 的设置中找到“Slack Notifications”，勾选要通知的项目，最后在“构建后操作”处选择添加 Slack Notifications”即可（图 10.19）。

图 10.19　通知项目示例

现在再执行 Job 就会向 Slack 发送通知了（图 10.20）。

图 10.20　向 Slack 发送通知

10.7　通过 Jenkins 构建文档

我们在第 7 章中介绍了如何用 Sphinx 写文档，现在来看看如何用 Jenkins 来辅助 Sphinx 编写文档。

用 Sphinx 编写文档时经常会遇到下述问题。

reST 语法错误

忘记提交文档中引用的图片或源码

如果存在上述问题，Sphinx 会在执行构建命令时发出警告。因此，只要我们利用 Jenkins 定期监视文档的构建，就能尽早发现这些问题。

本书就是借助 Sphinx 编写的。接下来我们将以本书的编写过程为例，学习一下我们应该怎样用 Jenkins 管理多人共同执笔的书稿。该方法不仅适用于多人共同管理文档的情况，对单独管理文档的情况也有一定的帮助。

10.7.1　安装 Sphinx

首先将 Sphinx 安装到本地环境中。

$ pip install sphinx

10.7.2　在 Jenkins 添加 Job

该 Job 的设置如下（/var/hg/bpbook 为源码版本库）。

① 基本设置

Job 类型：自由风格

Job 名称：bpbook

② 源码管理系统

SCM：Mercurial

版本库：/var/hg/bpbook

分支：default

③ 构建触发器

种类：定期执行

日程表：H/15****（每小时的 15 分）

10.7.3　Sphinx 构建发出警告时令 Job 失败

Sphinx 的 -w 选项可以将警告内容以文件形式输出。我们希望出现警告时让 Jenkins 的 Job 失败，所以要在“Execute shell”选项卡处进行如下设置：当输出警告内容的文件不为空时，用返回码 9 结束执行。

sphinx-build -a -E -w build_warnings.txt source docs

if [ -s build_warnings.txt ] ; then
    exit 9
fi

这里的返回码可以用 0 之外的任意数字。Jenkins 将所有构建完毕时返回码不为 0 的 Job 均视为失败。另外，为保证每次都重新构建所有文档，要加上 -a -E 选项。

10.7.4　查看成果

Jenkins 可以在管理界面查看工作区内的文件，因此我们可以在这里查看 Job 构建的文档（图 10.21）。

图 10.21　查看工作区

Jenkins 中的各 Job 首页的“描述”中允许添加 html 标签，我们可以将已构建的文档的链接插入“描述”中，这样一来就能直接从首页点击链接查看构建后的文档了（图 10.22）。

图 10.22　编辑 Job 首页的“描述”

10.7.5　通过 Task Scanner Plugin 管理 TODO

编写代码和稿件时，我们常需要将一些临时想到的事或暂时不需要做的事以 TODO 注释的形式记录下来。用 Jenkins 的 Task Scanner Plugin 插件可以让我们在报告中看到 TODO 注释（图 10.23）。

图 10.23　Task Scanner Plugin 报告

如图 10.24 所示，报告中还对对应行做了高亮处理。

图 10.24　高亮显示

另外，Sphinx 扩展中有一个 todo 扩展，激活之后即可允许用户使用 todo 指令（图 10.25）。todo 指令的布局与 note 和 warning 相同。

图 10.25　使用 todo 指令的示例

与其他人合著一本书时，我们常会怀疑自己写的内容与其他章节是否存在矛盾，或是自己写的稿子是否满足与合著者之间定下的规则。如果每出现一次这种情况就停下手去检查一遍，势必拖累稿子的进度，所以我们选择先留下 TODO 注释，等待最后统一解决。

另外，TODO 注释经常写了不删，让人搞不清到底问题有没有解决。这种情况不仅存在于写稿的过程中，在敲代码时也同样会发生。虽然每次处理完源码或稿件都手动 grep 搜索 TODO 注释能避免这种情况发生，但实在太麻烦了。

因此，在用 Jenkins 执行测试或构建文档时，可以顺便生成一个 TODO 的列表。另外，Task Scanner Plugin 能够用图表显示 TODO 注释的数量。随着 TODO 被一个个解决，我们能够直观地掌握其减少的过程。看到 TODO 减少能够激发开发者解决 TODO 的积极性，在某种意义上起到提高效率的作用。

10.7.6　Task Scanner Plugin 的设置示例

首先从“管理插件”安装 Task Scanner Plugin。完成后，Job 设置界面的“构建后操作”选项卡处会增加“Scan workspace for open tasks”一项。勾选该项，输入下述几项。

Files to scan：TODO 的搜索对象

Files to exclude：这里设置的对象将不包含在搜索范围内

Tasks tags：这里输入的字符串将被视为 TODO，计入统计结果

管理本书稿的 Job 就是按照图 10.26 设置的。

图 10.26　Task Scanner Plugin 设置示例

10.8　Jenkins 进阶技巧

10.8.1　好用的功能

这里简单了解一下前面没有讲到的 Jenkins 的好用功能。

◉ Job 触发器

Job 触发器是指通过 Jenkins 的某个 Job 来启动其他 Job 的功能。它没有专门的设置界面，必须到“构建触发器”的“Build after other projects are built”或“构建后操作”的“Build other projects”中进行设置。

这里可以设置前一个 Job 失败时不执行后续 Job，因此能够构成“测试不通过则不进行部署”的工作流程。

◉ 用户管理

用户管理可以设置 Jenkins 的认证与认可。设置可以满足很多细节，比如只有公司内部人员可通过外部网络访问 Jenkins，或者只有管理员能够执行 Job，等等。

另外，用户管理后端可以使用 LDAP，因此可以将用户管理交给 OpenLDAP 或 Active Directory。使用 Active Directory 时建议安装 Active Directory plugin，它能够让我们轻轻松松地进行设置。

NOTE

我们可以在下列 URL 上找到关于 Jenkins 用户管理的详细介绍，有兴趣的读者不妨参考一下。

Standard Security Setup

https://wiki.jenkins-ci.org/display/JENKINS/Standard+Security+Setup

Active Directory plugin

https://wiki.jenkins-ci.org/display/JENKINS/Active+Directory+plugin

◉ 集群

Jenkins 允许以主从方式组建集群。我们在使用 Jenkins 时，往往一建就是十几二十个定期执行的 Job。另外，Job 的执行时间可能会被测试拖长（测试过慢），导致 1 台计算机无法处理所有 Job。这种时候组件集群就显得行之有效了。此外，对于需要在多个不同环境中进行的测试而言，集群也是一种很好的解决方法。

简单来说，Jenkins 集群的机制如下所述。

主计算机向从计算机发送指令

从计算机执行指令

主计算机统计从计算机的结果

NOTE

我们可以在下列URL 上找到Jenkins 集群的详细介绍，有兴趣的读者不妨参考一下。

Distributed builds

https://wiki.jenkins-ci.org/display/JA/Distributed+builds

◉ CLI

我们在 10.2.2 节了解过，除了 Web UI 以外，Jenkins 还有其他 CLI。Jenkins 的 CLI 很适合用来执行插件定期升级等标准化处理。另外，Jenkins 的 CLI 允许远程执行，便于用户在不能使用 Web 浏览器的终端上操作 Jenkins。

NOTE

我们可以在下列 URL 上找到 Jenkins CLI 的详细介绍，有兴趣的读者不妨参考一下。

Jenkins CLI

https://wiki.jenkins-ci.org/display/JA/Jenkins+CLI

10.8.2　进一步改善

到 10.7 节为止，我们学习了如何用 Jenkins 签出源码、执行测试（测量覆盖率）、统计 TODO 任务、发送结果通知和编写文档等。但是，这些内容只是整个开发流程的一部分。相信各位还希望能用 Jenkins 做更多的事，比如下面这些。

Pylint 等的静态解析

向正式环境或演示环境部署

测试已部署的产品

向 PyPI 服务器上传

所交付产品的 Zip 文件存档

另外，只要 Jenkins 的运用步入正轨，我们将会在改善业务流程、根据业务流程自定义 Jenkins 等方面产生需求。

遇到此类需求时可以参考下述信息。

NOTE

官方维基百科的信息

Jenkins Home: https://wiki.jenkins-ci.org/display/JENKINS/Home

邮件列表

http://jenkins-ci.org/content/mailing-lists

书籍

《Jenkins 入门与实践》3（技术评论社，2011 年）

Jenkins: The Definitive Guide 4（O'Reilly Media，2011 年）

《持续集成：软件质量改进和风险降低之道》（机械工业出版社，2008 年）

《Jenkins 入门》5（Shuwa System，2012 年）

3原书名为『Jenkins 実践入門』，暂无中文版。——译者注

4本书作者为 John Ferguson Smart，暂无中文版。——译者注

5原书名为『入門 Jenkins』，暂无中文版。——译者注

虽然用 Jenkins 能完成许多精细的工作，但这不代表自动化优于一切。使用 Jenkins 实现自动化时，务必要保证作业成本和便捷性的平衡。

10.9　小结

本章对持续集成进行了简单的说明，同时讲解了 Jenkins 的用法。读完本章之后，相信各位已经发现持续集成和 Jenkins 并没有想象中那么难。虽然本章主要是以 Django 为例进行讲解的，但 Jenkins 的用武之地远不止于此。各位可以从小规模项目入手，逐步感受导入 Jenkins 的方便之处。

第 3 部分　服务公开

第 3 部分主要讨论与正式环境相关的一些较现实的问题。这部分将介绍向正式环境公开项目时的思路和流程，研究在正式环境中改善性能的相关问题。

第 11 章　环境搭建与部署的自动化

第 12 章　应用的性能改善

第 11 章　环境搭建与部署的自动化

应用的运行离不开执行环境。

搭建环境要做的事非常多，比如安装依赖包、设置中间件、设置应用本身等。这些工作稍有差错就会导致应用无法运行，而我们很难分辨问题究竟出在应用身上还是环境身上，因此解决问题常常要费一番力气。

另外，许多项目的环境不止一个，除正式环境外还需要开发环境、演示环境等。现今还要考虑到向外扩展，有时一个环境甚至是由几十台服务器共同构成的。要手动搭建这么多环境还要保证不出错，实在有些强人所难。

为保证良好的服务运营，需要有能让应用稳定运行且便于维护的环境。本章将就搭建稳定环境的方法理论以及通过 Ansible 实现环境搭建、部署自动化的方法进行说明。

本章的目的是搭建环境的自动化，但是如果过于随意地搭建内容和流程，则很难形成一个稳定的环境。另外，在这种状态下也不可能顺利实现自动化。因此本章内容将分为两部分，第一部分探讨搭建环境的流程和内容，第二部分讲解如何将第一部分的内容自动化。

11.1　确定所需环境的内容

首先要从所需环境的内容入手。环境一旦开始使用就很难再进行大的改动，所以动手搭建前搞清所需环境的内容是十分重要的。

在熟悉搭建环境的流程之前，建议先摸索着尝试手动搭建。等到熟悉之后，再考虑结构选择和搭建过程自动化的问题。

利用 VirtualBox 和快照功能可以帮助我们快速确定所需环境的内容。感兴趣的读者请同时参考附录 A。

11.1.1　网络结构

许多服务是由多台服务器组合实现的，所以在探讨服务器内部的环境搭建之前，先要确定服务器的组成结构。

可用服务器数、预算、应用性能不同，所需的服务器结构也是千差万别。这里我们以图 11.1 所示的结构为例进行学习。

图 11.1　服务器结构

LB：负载平衡器（nginx）

AP1、2：应用服务器（django/gunicorn）

DB：数据库（mysql）

gateway：跳板服务器

◉ 服务器编组

确定服务器结构时，要给职责相同的服务器起一个统一的名字。

这里我们将分担各个职责的服务器群称为组。在图 11.1 所示的结构中，有 LB、AP、DB、gateway 这 4 个组。

即便某个职责只由一台服务器完成，我们也认为其是一个组。这样一来能更灵活地应对多服务器结构。

NOTE

按职责划分的服务器群也被称为“角色”（Role）。但这会与我们即将讲到的 Ansible 的 Role 重复，所以这里改用“组”（Group）。

◉ 跳板服务器

允许从外部网络直接 ssh 登录各个服务器会带来很多安全隐患，所以一般我们会阻止外部通过 ssh 访问各服务器。

可是，一旦阻止了对所有服务器的 ssh，我们便无法通过外网登录，这会造成很多不便。所以这里要准备一台用作登录跳板的服务器，形成通过跳板登录各服务器的结构。

11.1.2　服务器搭建内容的结构化

将服务器搭建内容按照图 11.2 所示的结构进行结构化。

图 11.2　服务器搭建内容的结构化

这个结构很好理解。在中间件里定义安装和设置等流程，然后将各个中间件组合起来搭建服务器组。最后把搭建好的多个服务器组整合起来，就形成了一个环境。

这里需要特别注意的是，将搭建流程整合到各个中间件里时，如果只简单地把要做的处理按顺序一件一件写下来，很容易让人搞不清究竟哪个步骤属于哪个中间件的设置。加强对这个结构的理解和注意，有助于提高自动化的效率。关于自动化的知识我们将在 11.2 节进行学习。

根据图 11.2 所示的结构，本章中的服务器可划分为如下图 11.3 所示的结构。

图 11.3　本章中的服务器的搭建内容

这里出现了“环境设置”，它不是中间件，而是我们为了便于理解，将 OS 的设置、用户的创建等不针对特定中间件进行的操作汇总在了一起，并统一称为“环境设置”。

下面我们开始学习具体的搭建流程。

11.1.3　用户的设置

我们已经确定好了服务器的结构，接下来要探讨搭建流程。首先是用户的设置。

初始状态下的 Ubuntu 以 ubuntu 用户为登录用户。但是，如果让默认的管理员用户来做维护或启动应用，会带来一些安全隐患。所以，我们新建 mainte 用户用于搭建和维护，新建一个不能直接登录的普通用户 www 用于执行应用。

# useradd -m mainte
# useradd -m www

搭建工作会经常用到 sudo，因此要给 mainte 用户设置 sudo 权限。另外，考虑到自动化的问题，最好让 mainte 不需要密码就可以执行 sudo。

可以通过编辑 /etc/sudoers 或者在 /etc/sudoers.d/ 中添加设置文件修改 sudo 权限。这次我们用后一种方法，创建 /etc/sudoers.d/mainte。

%mainte ALL=(ALL) NOPASSWD:ALL

%mainte 表示给 mainte 组的所有用户设置 sudo 权限。今后如果有其他用户需要用到 sudo，只要将其加入 mainte 组即可。

至于 www 用户，要让它只保有启动应用所需的最小权限，因此不设置 sudo 权限。

最后是登录设置。mainte 用户今后要用来做维护工作，所以需要能从外部登录。

首先用 ssh-keygen 生成密钥文件。

$ sudo su - mainte
$ ssh-keygen -b 2048

如果在初始设置状态下执行 ssh-keygen，会在 /home/mainte/.ssh/ 下生成 id_rsa 和 id_rsa.pub 两个文件。我们将这两个文件下载到本地 PC 保管。

将 id_rsa.pub 的内容设置到 authorized_keys 中，登录设置就完成了。

$ cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys
$ chmod 600 ~/.ssh/authorized_keys

经过上面的设置，我们在 mainte 用户的“.ssh”目录下生成了密钥文件，同时完成了对应的 authorized_key 的设置。

这样设置下来之后，就能很轻松地在多个服务器之间切换登录了。

最后查看设置是否正确。

mainte 用户能用下载到本地 PC 的密钥文件登录服务器

mainte 用户能通过 sudo 切换为 www 用户

可以对 localhost 执行 ssh

$ ssh localhost

11.1.4　选定程序包

接下来安装应用运行所需的程序包。

在 Ubuntu 上运行 Python 应用时，主要通过 apt-get 和 pip 安装程序包。

选择要安装的程序包时请注意以下几点。

① 安装的程序包要尽量少

　 尽量不要导入与运行应用无关的程序包。

　 盲目导入程序包会使我们无法准确掌握应用的运行条件，出问题时很难分辨问题出在环境上还是应用本身上。

　 要导入与运行应用没有直接关系的程序包时，必须先仔细考虑其用途和安装范围，再确定是否要将其加入搭建流程。

　 下面是一些做判断的例子。

应用的目录结构很复杂，排查 Bug 时需要 tree 命令的辅助

任何环境都难免需要排查 Bug，因此需要将 tree 命令加入构建流程。

想用自己常用的编辑器 emacs

基本只会在开发环境中用到，所以仅在开发环境中导入。

② 掌握并管理程序包的版本

　 对开发力度较大的应用 / 库而言，常有从某个版本起发生大幅度规格变更，或是不兼容旧版本设置文件的情况发生。

　 这种时候如果盲目升级了版本，很容易导致应用无法运行。

　 要防止这类因版本导致的事故，重点在于把握安装的程序包的版本以及确定更新原则。

　 原则主要有下面几种，各位可以为每个程序包分别选择合适的原则。

完全固定

不考虑版本升级，仅使用指定版本的程序包。

固定至次版本号

固定主版本号和次版本号，允许加入安全更新和 Bug 修复。

◉ 通过 apt-get 安装程序包

用 apt-get 或 yum 从各个 Linux 发行版的程序包版本库安装程序包时，首先要确认各个发行版的更新原则。

本书所用的 Ubuntu 原则上只加入安全更新和 Bug 修复，不进行其他版本升级。

也就是说，环境本身就处于上述“固定至次版本号”原则的状态，我们不必多作修改。程序包升级时只会加入重大的 Bug 修复，不会更新主版本号和次版本号。

上述原则只要能满足我们的需求即可，没有什么特别需要注意的地方。现在我们可以通过简单的命令安装程序包，具体如下。

$ sudo apt-get install packagename

如果需要固定版本，则需要在程序包名称后面添加等号以及版本号。具体如下所示。

$ sudo apt-get install packagename=1.2.3-4ubuntu3

包名和版本号都可以用正则表达式。比如，如果只想固定到次版本号，则上述命令可以写成“1.2\..*”的形式。

◉ 通过 pip 安装程序包

用 pip 安装程序包的流程在第 9 章中有详细介绍，不清楚的读者请参考该部分。

根据 11.1.4 节得出的结果编写 requirements.txt，修正版本指定，依照自身情况选择是否分离一部分到 dev-requires.txt 和 tests-require.txt。

完成 requirements.txt 之后，只需用 pip install -r requirements.txt 进行安装即可。

◉ 封闭环境中的安装

为没有网络连接的服务器搭建环境时，apt-get 和 pip 这种通过外部连接安装程序包的方法就不好用了。另外，程序包提供方（apt 版本库、PyPI、GitHub 等）故障或维护时也是同样道理。

为应对这种情况，我们可以将所需的程序包事先保存在版本库中，然后进行离线安装。我们把这些打包在一起的程序包叫作 bundle。

bundle 不但可以让我们离线安装程序包，还能有效固定版本以及削减搭建时间。

○ 通过 apt-get 安装 bundle

apt-get 安装的程序包都是以“.deb”格式发布的。用 apt-get download 命令可以获取我们需要的 deb 程序包。

$ mkdir aptcache && cd aptcache
$ sudo apt-get download python3.4
$ ls
python3.4_3.4.0-2ubuntu1_amd64.deb

deb 程序包用 dpkg -i 命令安装。

$ sudo dpkg -i python3.4_3.4.0-2ubuntu1_amd64.deb

但是有一个问题，apt-get download 命令只能获取目标程序包，无法同时获取该程序包的依赖包。因此要用 apt-cache depends 命令查看依赖包，然后在通过上述方法获取它们的 deb 程序包。

$ apt-cache depends python3.4
python3.4
  Depends: python3.4-minimal
  Depends: libpython3.4-stdlib
  Depends: mime-support
  Suggests: python3.4-doc
  Suggests: binutils

有时这些依赖包本身又依赖于其他程序包，如此循环下去不知何时是个头，所以我们需要一个方法来一次性获取所有相关的程序包。

实际上，用 apt-get install 命令安装程序包时，目标程序包及其所有依赖包的 deb 文件全都会被下载到缓存目录下。只要通过 -o 选项临时变更缓存目录，就能将目标程序包和其所有依赖包保存到任意目录下了。

$ sudo apt-get install python3.4 -o Dir::Cache::Archives=/home/mainte/aptcache

然而有一点需要注意，那就是这个方法无法获取已经安装的程序包，因此需要在初始状态的环境下（比如刚用 VM 搭建完毕的环境）进行操作。

○ 通过 pip 安装 bundle

旧版本的 pip 可以用 pip bundle 命令进行安装，但这个命令在 pip 1.5 被删除。1.5 之后开始使用 wheel。9.1.4 节详细介绍了 wheel 的相关内容，有需要的读者请参考该部分。

将所需程序包全部转换为 wheel。此时也通过 requirements.txt 指定程序包。

$ pip wheel -r requirments.txt

这个操作会在当前目录的 wheelhouse 目录下生成 wheel。我们将整个 wheelhouse 目录都添加到应用的版本库中。

在各环境下都可以通过下述命令从 wheelhouse 安装程序包。

$ pip install --no-index -f /path/to/my/repository/wheelhouse -r requirements.txt

○ bundle 的维护

更新或添加新的程序包时，需要将程序包重新打包成 bundle。一旦漏掉这个步骤，应用可能出现在某些环境下能运行，在某些环境下却不能运行的情况，问题很难排查。

重新打包 bundle 是一件非常繁琐的工作，对于 deb 程序包来说尤其如此。开发时会频繁出现程序包的添加和更新，因此过早地 bundle 化会带来许多麻烦。毕竟开发环境很少遇到无法连接外部网络的情况，所以开发中建议使用 apt-get 和 pip install 来安装程序包。等到正式环境就绪再考虑 bundle 化也不迟。

程序包管理进入 bundle 阶段后，程序包的 bundle 化也可以交给构建服务器实现自动化。

11.1.5　中间件的设置

mysql、nginx 等中间件要根据使用环境进行设置。

我们往往需要在大量服务器上实施或更新中间件的设置，如果这些全都手动去完成，很容易出现疏漏，导致发生问题。因此，实现中间件设置的自动化才是上上之选。首先搞清需要自动化的内容，选出对象文件以及要修改的项目。本章用作例子的服务器结构中包含了 mysql、nginx 和 gunicorn，这里我们就来探讨一下这 3 个中间件的设置。

◉ 让中间件设置生效的方法

自动化更新中间件的设置文件需要以下步骤。

① 在版本库中管理对象文件

② 用模板语言重新描述可变部分

③ 将模板化的文件翻译过来直接覆盖原设置文件

步骤②和③提到的模板功能是 Ansible 标配的功能，关于 Ansible 的知识会在后面讲到。探讨设置内容的过程中需要我们手动改写设置文件，但最终所有设置都要通过上述步骤反映出来。 Ansible 采用 Jinja2 为模板编辑器，因此本节的模板文件都是以 Jinja2 格式描述的。

下面我们来看看用 Jinja2 格式描述设置文件可变部分的例子。

◉ mysql

MySQL 的设置描述在“/etc/mysql/conf.d/*”中。本例的设置文件为 /etc/mysql/conf.d/myproject.cnf。

讨论所需项目并编写文件。

[mysqld]

bind_address = {{ MY_LOCAL_IP }}
innodb_file_per_table = yes
innodb_buffer_pool_size = {{ MYSQL_INNODB_BUFFER_POOL_SIZE }}

在 mysql 的设置中，有些值会根据环境变化，比如根据服务器 IP 变化的 bind_address，根据服务器内存容量变更最优值的 innodb_buffer_pool_size 等。如果事先将这些值设置为变量，可以在中间件变更所在服务器时轻松完成设置更新。

◉ nginx

nginx 的设置描述在 /etc/nginx/conf.d/ap.conf 中。

nginx 的 conf.d 目录下设置有 default.conf 和 example_ssl.conf 两个文件，它们是用来显示 nginx 默认页的，与我们要搭建环境的服务器无关。因此要在探讨设置之前把这两个文件删除。

LIST 11.1　/etc/nginx/conf.d/ap.conf

upstream app {
    {% for server in NGINX_UPSTREAMS %}
    server {{ server }}:8000;
    {% endfor %}
}

server {
    listen {% if SSL %}443{% else %}80{% endif %};
    server_name {{ DOMAIN }};

    {% if SSL %}
    ssl on;
    ssl_certificate {{ SSL.CERTIFICATE }};
    ssl_certificate_key {{ SSL.KEY }};
    {% endif %}

    ...
}

需要在 upstream 指令中指定多个反向代理对象的服务器地址。这里我们是用变量 NGINX_UPSTREAMS 来存储服务器地址清单，并通过 for 语句来指定服务器地址的。

另外，我们还对 server 指令内是否存在 SSL 的设置进行了判断，并根据情况进行了 listen 端口的切换以及 ssl_certificate 的设置。出于预算原因，我们的项目并不是每个环境都使用 SSL，所以在这里保证了两种设定之间的简单切换。

可见，利用 Jinja2 模板的 if 和 for 能做出复杂的情况分类（LIST 11.1）。但有一点需要注意，过度使用控制语句会降低程序的可读性，增加修改的难度。

◉ gunicorn

让 gunicorn 通过 upstart 启动。创建 /ect/init/myapp.conf 文件用作启动脚本，脚本内容如下。

upstart 的文档

http://upstart.ubuntu.com/

description "myapp"

start on (filesystem)
stop on runlevel [016]

respawn
console log
setuid www
setgid www
chdir {{ SOURCE_DIR }}/src

exec DJANGO_SETTINGS_MODULE={{ DJANGO_SETTINGS }} {{ SOURCE_DIR }}.venv/bin/gunicorn myapp.wsgi:application --bind={{ MY_LOCAL_IP }}:8000 --workers={{GUNICORN_WORKERS }}

上述例子将许多值替换为了变量。等到自动化的时候，这些地方就会发挥出其效果了。

SOURCE_DIR

放置源码目录。虽然这个值很少因为环境而变，但换成变量能防止键入错误。

DJANGO_SETTINGS

Django的settings文件需要根据环境而变。将它设置为变量之后，我们就可以在所有环境中应用同一个模板了。

MY_LOCAL_IP

自身服务器的本地IP。直接拿mysql设置文件中定义的变量来用。

GUNICORN_WORKERS

gunicorn的worker进程数。

将中间件的设置文件模板化时，需要考虑以下几个问题。

哪个设置值需要用变量替换

因环境而变的值，以及容易输入错的值（比如较长的文件路径）。

有多个中间件共用的变量吗

所有环境共享的值要设计成共用一个变量。

11.1.6　部署

部署就是将应用安置到环境中，使其进入可运行状态。

应用源码的安置、中间件设置的反映、中间件的重启等都属于部署工作。

本节将对上述 3 个工作进行说明。

◉ 源码的安置与更新

源码的安置用 Mercurial 的 clone 和 pull 即可轻松完成。利用标签和分支的功能，还能灵活应对回滚等操作。

如果有从各环境均可连接的公共密钥认证的中央版本库，那么最好从该版本库进行 clone 操作。这里我们假设中央版本库放在 myrepository.com。

clone 时用 11.1.3 节创建的 www 用户。访问版本库时用到的公共密钥和私有密钥要事先设置好。

本例中，我们把版本库设置在 /var/www/myproject。

$ cd /var/www/
$ hg clone ssh://myrepository.com/myproject

执行 clone 操作之后就完成了应用源码的安置工作。更新工作只需要在各个服务器上对版本库进行 pull/update 即可。

$ cd /var/www/myproject
$ hg pull; hg update default

根据用途和环境不同可以将 update 位置替换为其他分支名或标签名，以灵活应对各种需求。实现自动化时只需将其改为变量，让其能够被替换即可。

专栏　没有可通过公共密钥认证的中央版本库时该怎么办

源码在 Mercurial 上管理时，只要我们能使用 clone 操作，就能将源码部署到环境中。但是如果中央版本库要求密码认证，那么每次 clone 都要输入密码，不利于自动化。

这种时候可以先将主版本库 clone 到 gateway 服务器上，然后各环境服务器再从 gateway服务器上的版本库进行clone，这样就能将输入密码的次数降到最少（图11.4）。

图 11.4　密码认证环境中的源码部署

◉ 中间件设置的反映

如 11.1.5 节所述，现阶段需要手动改写模板部分并上传至各环境，从而反映设置。

改写过程中要时刻注意设置文件有没有放错地方，有没有不小心改写了未加入管理的文件。

◉ 重启中间件

守护进程的启动和停止要使用 service 命令。

$ sudo service myapp restart

11.2　用 Ansible 实现自动化作业

上面我们大致总结了搭建环境的流程，现在来看看如何用 Ansible 实现自动化搭建环境。

11.2.1　Ansible 简介

Ansible 是基于 Python 研发的结构管理工具。不过它除了能进行结构管理之外，还能将许多针对服务器的操作自动化。

Ansible 本身由 Python 实现，但运行所需的设置文件均以 INI 格式或 YAML 格式描述，因此没有 Python 知识也能使用它。

另外，Ansible 不像 Chef 和 Puppet，它不需要在被操作的服务器上安装代理程序。只要服务器允许 ssh 登录，Ansible 就能执行相关操作。

Ansible 的主要概念有以下 5 个，我们随后将依次进行了解。

inventory

module

role

playbook

vars

NOTE

本书只介绍了 Ansible 的一部分功能，想了解其基本使用方法以及其他功能的读者可以参考 Ansible 的官方文档。

http://docs.ansible.com/

◉ inventory

inventory 是保存有执行对象（即主机）清单的 INI 格式的设置文件。主机通过 IP 地址或主机名指定。

192.168.0.1
192.168.0.2
192.168.0.3
192.168.0.4
192.168.0.5

Ansible 只能访问这里指定的主机，因此我们需要为不同的执行环境准备不同的 inventory 文件。

另外，主机可以用段进行分组。后面讲到的 playbook 和 vars 会用到这里定义的组。

[load-balancers]
192.168.0.2

[app-servers]
192.168.0.3
192.168.0.4

[db-servers]
192.168.0.5

同一个主机可以同时出现在多个组里。比如在所有功能全由一台主机实现的开发环境中，inventory 就是下面这个样子。

[load-balancers]
192.168.0.6

[app-servers]
192.168.0.6

[db-servers]
192.168.0.6

Inventory - Ansible Documentation

http://docs.ansible.com/intro_inventory.html

专栏　Dynamic Inventory

inventory 文件除了可以指定 INI 格式的文件外，还可以指定可执行的脚本文件。我们将这类文件称为 Dynamic Inventory，适用于 Amazon EC2、Google Compute Engine、Docker 等需要频繁变更对象主机信息的情况。

Ansible 的版本库中有以上述 EC2 等为对象的示例 Dynamic Inventory，各位不妨加以参考。

Ansible plugins/inventory

https://github.com/ansible/ansible/tree/devel/contrib/inventory

Dynamic Inventory

http://docs.ansible.com/intro_dynamic_inventory.html

◉ module

Ansible 以 module 为单位定义对服务器的操作。

Ansible 本身自带了许多 module，可以将它们组合起来，实现多种操作。

Module Index - Ansible Documentation

http://docs.ansible.com/modules_by_category.html

只要遵循一定的规则，module 可用任何语言来实现。当标准模块无法满足需求时，可以直接将现有脚本封装成 module 来使用。

Developing Modules - Ansible Documentation

http://docs.ansible.com/developing_modules.html

执行 module 时以 task 形式描述自身的传值参数及其他参数。

- name: install nginx
  sudo: yes
  apt: name=nginx
- name: install mysql
  sudo: yes
  apt:
    name: mysql-server
    state: latest
    update_cache: yes

module 的传值参数以 key=value 的形式描述，各传值参数之间用空格区分。或者也可以用字典形式描述。传值参数较多时建议使用字典形式。

◉ role

role 可以批量重复利用 task。

role 的结构如 LIST 11.2 所示。

LIST 11.2　role 的目录结构

roles/
  +-- nginx/
       +-- tasks/
            +-- main.yml
       +-- handlers/
            +-- main.yml
       +-- vars/
            +-- main.yml
       +-- defaults/
            +-- main.yml
       +-- files/
            +-- nginx.repo
       +-- templates/
            +-- conf.d/
                 +-- ap.conf
       +-- meta/
            +-- main.yml

tasks

task定义。定义描述在该目录的main.yml中。

handlers

handler的定义。由task定义的notify指定并调用。同上，在main.yml中描述。

vars

该role使用的变量。同上，在main.yml中描述。

defaults

上述变量的默认值。同上，在main.yml中描述。

files

该role的task中，文件关联模块用到的文件。

templates

该role的task中，template模块用到的Jinja2模板文件。

meta

元信息。可定义role之间的依赖关系等。

专栏　role 的共享

role 也能像 PyPI 一样在 Web 上公开及共享。Ansible Galaxy1 是 Ansible 公司运营的网站，任何人都可以在这里免费上传和下载 role。

从 Ansible Galaxy 上下载 role 时，需要用 ansible-galaxy install 命令。这个命令只需安装 Ansible 就可以使用了。下载的 role 默认安装到 /etc/ansible/roles 目录下，我们可以通过 -p 选项指定安装位置。

$ ansible-galaxy install username.rolename -p ROLES_PATH

使用 ansible-galaxy init 命令可以生成包含 meta/main.yml 和tasks 目录等内容的样板文件。这个命令原本是为方便用户向 Ansible Galaxy 上传role 而设计的，但对于不想公开的 role 同样好用。因此各位在制作 role 的时候不妨试试这个命令。

$ ansible-galaxy init rolename

1https://galaxy.ansible.com/

◉ playbook

playbook 是 YAML 格式的文件，用来定义要执行的处理。

- hosts: load-balancers
  sudo_user: mainte
  roles:
    - django

在 hosts 处指定对象服务器。这里指定 inventory 中定义的群名，可以对该群中的主机执行 task。指定为 all 则以所有主机为对象。

定义 sudo: yes 之后，该 playbook 将全部由 sudo 用户执行。sudo 用户默认为 root。想使用 root 以外的用户时需要在 sudo_user 处指定。

roles 处以 YAML 的列表形式指定要执行的 role。虽然可以在 tasks 处直接描述 task，但除了没有现成脚本的情况以外，还是建议使用 role。

Playbooks - Ansible Documentation

http://docs.ansible.com/playbooks.html

◉ vars

playbook、task、role 的模板中都可以使用变量。vars 的设置方法有很多种，这里只介绍比较有代表性的。

○ role 的 defaults

定义该 role 使用的变量的默认值。这里设置的值一般都是无法直接运行的临时值或空值，实际的值在 group_vars 中描述，等到运行时再进行覆盖。这样一来只需看 defaults 就能掌握 role 所需的全部变量，使 role 的重复利用成为可能。

○ group_vars

针对 inventory 中定义的组进行设置。Ansible 会读取 group_vars 目录下的 YAML 格式文件。文件名对应组名。另外，文件名为 all，会对所有组套用变量。

○ host_vars

针对 inventory 中定义的主机进行设置。Ansible 会读取 host_vars 目录下的 YAML 格式文件。文件名对应组名。

11.2.2　文件结构

与 Ansible 关联的文件全都要在一个目录下统一管理。本章示例的目录结构如 LIST 11.3 所示。

LIST 11.3　ansible 脚本群的文件结构

+-- deployment/
    +-- group_vars/
        +-- all
        ...
    +-- host_vars/
    +-- roles/
        +-- environ
        +-- nginx
        +-- mysql
        ...
    +-- inventory/
        +-- production
        +-- dev
        ...
    +-- site.yml
    +-- ap.yml
    +-- lb.yml
    ...

这些文件也都要放在版本库中进行管理。管理方法可以有以下 2 种。

创建专用的版本库进行管理

与应用的源码放在同一个版本库中进行管理

◉ 在专用的版本库中管理

如果没必要或者不希望应用的开发与环境搭建同步，可以把源码与 Ansible 的文件群放在不同版本库中进行管理。

Ansible 关联文件的数量通常很大，如果不想让源码的版本库太复杂，同样可以用这个方法。

◉ 与应用的源码放在同一个版本库中进行管理

在应用的版本库中专门创建一个用于管理这些文件的目录。这样做的好处是能在开发过程中让应用开发与环境搭建内容的变更对应起来。

我们大部分项目都采用了这种方法。没有特殊要求的情况下文件路径以 myproject/deployment/ 为基准。

11.2.3　执行 Ansible

创建好的脚本用 ansible-playbook 命令执行。对象环境的选择是通过切换 inventory 来实现的，因此要用 -i 选项明确指定 inventory。

$ ansible-playbook -i inventory/production.ini site.yml

在本章的结构中，会频繁用到指定了 tag 的执行操作。-t 选项可以在执行时指定任意标签。

$ ansible-playbook -i inventory/production -t deploy site.yml

11.2.4　与最初确定的结构相对应

11.1 节确定的搭建内容可以与 Ansible 的各概念对应起来。图 11.5 与图 11.2 相对应。

图 11.5　Ansible 的概念与服务器搭建内容的对应

环境 = inventory + vars

针对各个环境（正式环境、开发环境、过渡环境等）编写inventory文件。另外，Ansible 会自动读取与inventory的描述内容相对应的vars。

服务器组 = inventory 的段

前面定义的服务器组在inventory中是以段形式定义的，各段中列举了主机。

各服务器组的搭建流程 = playbook

给各个组分别创建lb.yml、ap.yml、db.yml、gateway.yml文件，将组设置成hosts。另外，创建一个include所有playbook的playbook，方便一次性搭建所有环境。这个playbook一般命名为site.yml。

针对各个中间件的搭建流程 = role

以中间件为单位创建role，各服务器组用对应其所需role的playbook统一管理。

其他环境设置 = role

不依赖于特定中间件的环境设置（创建用户等）也以role形式描述。

11.2.5　将各步骤 Ansible 化

◉ playbook/role 的设计

着手细节步骤之前，要先探讨搭建流程的各个步骤应该整合到怎样的 role/playbook 之中。

我们对前面学习的搭建操作流程进行如下分类，然后分割到各个 role 中。

① 应该套用到所有服务器中的操作

　 除了本章讲到的创建用户之外，比较常见的还有 ntp 和时区设置。

　 我们将这些处理整合到名为 environ 的 role 中，对所有服务器组进行套用。

② 特定服务器组使用的中间件的设置

　 为每个中间件编写一个 role，以达到分割的目的。

③ 部署

　 包含到对象中间件的 role 里。设置 tag 以保证能单独执行部署操作。然后将分割好的 role 组合成 playbook。以下是正式环境的例子。

- hosts: app-servers
  roles:
    - environ
    - python
    - repository
    - django
    - appserver

- hosts: db-servers
  roles:
    - environ
    - mysql

- hosts: load-balancers
  roles:
    - environ
    - nginx

大致的设计完成后就可以开始实际编写所需操作，边测试边调整了。

◉ 用户设置

用户的创建和设置可以用 user 模块完成。

tasks:
  - user: name=mainte
  - user: name=www

借助 file 模块向 sudoers.d 目录下放置文件。本例的文件内容比较简单，因此可以通过 content 传值参数直接描述脚本内容来进行设置。

tasks:
  - file:
      dest: /etc/sudoers.d/mainte
      content: "%mainte ALL=(ALL) NOPASSWD:ALL"

公开密钥的设置用 authorized_keys 模块。传值参数处需要些公开密钥的字符串，由于字符串太长，我们用 group_vars/all 文件的变量代替。

mainte_pubkey: AAAA1234512345...

tasks:
  - authorized_keys:
      user: mainte
      key: "{{mainte_pubkey}}"

密钥文件在版本库中管理，通过 file 模块放置。这里不要忘记修改权限。

tasks:
  - file:
      src: mainte_seckey
      dest: /home/mainte/.ssh/id_rsa
      mode: 0600

本例的 src 只指定了文件名，它在这里其实是相对路径，该相对路径是基于下面两个中的一个。

playbook 的目录

包含该 task 的 role 的 files 目录

◉ deb 程序包的安装

安装 deb 程序包时使用 apt 模块。指定版本的方式与 apt-get 相同。

- apt:
    name: mysql-server-5.5=5.5.40-0ubuntu0.14.04.1

用 with_items 可以将多个程序包的安装流程描述在一个 task 中。

- apt:
    name: "{{ item }}"
  with_items:
    - mysql-server-5.5=5.5.40-0ubuntu0.14.04.1
    - nginx

◉ 通过 pip 安装程序包

Ansible 有兼容 pip 的 pip 模块，使用前需要先安装 pip。

- name: install pip
  shell: curl -L https://bootstrap.pypa.io/get-pip.py | python creates=/usr/local/bin/pip

使用 shell 模块时，如果传值参数 creates 处指定的文件已经存在，该步骤将被强制跳过。本例是通过检查是否存在 /user/local/bin/pip 来判断是否需要执行该步骤的。

由于这些模块都可以自由执行命令，因此幂等性需要我们自己来验证。只要给传值参数 creates 指定了文件，当被指定的文件存在时，当前步骤就会被强制跳过。本例是通过检查 /user/local/bin/pip 是否存在来判断是否需要执行该步骤。

安装完成后 pip 模块就能用了。

- name install packages
  pip: name={{item}}
  with_items:
    - virtualenv

我们在第 1 章中也了解到，应用所需的程序包要安装在 virtualenv 环境中。因此我们把搭建 virtualenv 环境的步骤也写了进来。

- name: create virtualenv
  sudo_user: www
  command: virtualenv venv chdir=/var/www/ creates=venv/bin/activate

- name: install
  sudo_user: www
  pip: requirements=/var/www/myproject/requirements.txt virtualenv=/var/www/venv

pip 模块会向 virtualenv 参数处指定的环境安装程序包，所以我们在这里指定虚拟环境的路径。另外，只把 name 参数替换为 requirements 参数并指定 requirements.txt 的路径，就可以使用 requirements.txt 了。

◉ 中间件设置的反映

放置、更新设置文件要用 template 模块。

之前我们在设置文件中使用了一些尚未定义的变量，现在在 group_vars 和 role 的 defaults/main.yml 中定义它们。比如对 nginx.conf 中使用的变量要作如下定义（LIST 11.4）。

LIST 11.4　group_vars/all

DOMAIN: myproject.example.com
NGINX_UPSTREAMS:
  - 192.168.0.3
  - 192.168.0.4

SSL:
  CERTIFICATE: myproject.crt
  KEY: myproject.key

变量准备完毕后开始写放置配置文件的 task。涉及多个文件时推荐使用 with_items。

tasks:
  - template:
      src: "{{ item }}"
      dest: /etc/nginx/"{{ item }}"
    with_items:
      - conf.d/gunicorn.conf

template 模块的文件路径引用的是包含该 task 的 role 的 templates 目录。保持 templates 目录的相对路径与文件放置目标目录的相对路径一致能够简化描述。

◉ 部署

部署的 task 也写在各个 role 的 tasks 中。描述时要给该 task 设置 tag。设置 tag 能让我们单独拿出与部署相关的 task 来执行。

按照本章的结构，我们定义下述 tag。

configure

设置文件的更新

update

源码的更新

reload

重新加载中间件设置

deploy

执行update、configure、reload

◉ 设置的更新

给在“中间件设置的反映”部分编写的 template 模块的 task 添加 configure 和 deploy 标签。

- template:
    src: "{{ item }}"
    dest: /etc/nginx/"{{ item }}"
  with_items:
    - conf.d/gunicorn.conf
  tags:
    - configure
    - deploy

◉ 中间件的重启

经由 sysvinit 或 upstart 启动的中间件可以用 service 模块重启或重新加载。

- service:
    name: nginx
    state: reloaded
  tags:
    - reload
    - deploy

如果使其与 Ansible 的 Notify 机制相结合，则可以规定仅在设置文件被修改时自动重新加载中间件。

通过 Notify 调用的 task 在 role 的 handlers/main.yml 中描述。name 会被视为标识符，所以要指定一个在整个项目中独一无二的名字。

LIST 11.5　roles/nginx/handlers/main.yml

- name: reload nginx
  service:
    name: nginx
    state: reloaded

接下来在调用方的 notify 参数处指定通过 LIST 11.5 设置的 name。这个 task 的返回值 changed 为 True 时（对 template 模块而言是文件内容被更新时）将执行 notify 指定的 task（LIST 11.6）。

LIST 11.6　roles/nginx/tasks/main.yml

...
- name: configure nginx
  template:
    src: "{{ item }}"
    dest: /etc/nginx/"{{ item }}"
  with_items:
    - conf.d/gunicorn.conf
  tags:
    - configure
    - deploy
  notify:
    - reload nginx

使用 Notify 可以让我们不必分神去注意重新加载设置的问题，但会使单独执行重新加载或重启，以及只修改设置不重启等类似操作变得难以实现。因此需要根据项目的实际情况选择合适的方法。

◉ 源码的更新

通过 Mercurial 放置源码的操作可以用 hg 模块来完成，但需要事前完成安装 Mercurial、创建目录、设置权限等准备工作。

把这一系列工作整合到名为 repository 的 role 中，让需要放置源码的服务器组能够使用该 role。

- pip:
    name: mercurial

- file:
    state: directory
    dest: /var/www/
    owner: www
    mode: 755

- hg:
    repo: ssh://user@myrepository.com/myproject
    dest: /var/www/myproject
    revision: "{{ revision }}"
  tags:
    - update
    - deploy

为了能够更新成任意版本而不仅限于 default，这里将 hg 模块的传值参数 revision 设为变量。

根据需要还可以将放置源码的目标项目名、版本库 URL 也设为变量，方便替换和引用，从而灵活应对多种需求。

11.2.6　整理Ansible 的执行环境

本例中的环境如 11.1.1 节所述，除 gateway 服务器以外全都无法从外部进行 ssh，因此我们无法从本地环境操作所有服务器。另外，如果给所有相关人员的本地环境中都搭建 Ansible 的执行环境，那么每次调换或新增负责人时都要搭建一次环境。

这种时候，最好的解决办法就是在一个项目全体成员共享的、可连接环境中所有服务器并且与应用运行没有直接关系的 gateway 服务器上搭建 Ansible 的执行环境。

虽然我们也希望通过 Ansible 完成 gateway 服务器的整理，但这又涉及到用 Ansible 整理 Ansible 执行环境的自举问题。

这里我们从手头环境执行 Ansilbe，以解决这一问题。但有一点要注意，执行这部分操作时 gateway 服务器上还不存在 mainte 用户。虽然我们设想用 mainte 进行维护工作，但构建环境时无法使用该用户。所以要为 root 或 ubuntu 用户创建一份临时的 inventory 文件（LIST 11.7 ～ LIST 11.9）。

LIST 11.7　gateway 搭建时所需的 ini

[gateway]
gateway.example.com ansible_ssh_user=ubuntu

LIST 11.8　gateway.yml

- hosts: gateway
  sudo: yes
  roles:
    - environ
    - python
    - ansible

LIST 11.9　roles/ansible/tasks/main.yml

- name: install Ansible
  pip: name=ansible

11.3　小结

本章讲解了探讨环境搭建流程的思路以及自动化搭建环境的方法。

我们往往觉得环境的运行没有什么技术含量，而且很难看到付诸其中的努力。但是，应用能持续平稳地运行，毫无疑问是高效环境搭建以及维护的功劳。

从长远角度看，搭建环境的自动化对提高搭建环境及维护的效率有十分明显的效果。另外，搭建流程自动化使得项目成员能够自由地搭建个人开发环境，能大幅提高整个项目的生产能力。

因此推荐各位搭建环境时多注意稳定和高效两个方面。另外，为了让项目稳定高效运转，建议导入环境搭建的自动化。

专栏　自动化的“度”在哪里

当我们为实现自动化而写搭建流程的详细列表时，会发现搭建所需的步骤比我们想象中要多得多。其中有些内容自动化起来很麻烦，有些内容又很难自动化。于是这些步骤要自动化到一个什么“程度”便成了重要的研究课题。

刚开始导入自动化时，看到服务器自己搭建环境，人们往往会得意忘形，希望把所有步骤全都自动化。然而我们认为这样做是错的。Ansible 的 Playbook 虽然能极简洁地描述搭建步骤，但量堆积到一定数量同样会使可读性变差。shell 模块确实可以强行自动化一些繁杂的步骤，不过日后读和改的时候肯定会遇到麻烦。

我们认为，简化流程是流程自动化工作的一部分。大部分自动化工具都把构建时经常遇到的操作进行了简化，使得我们能轻松执行这些操作，Ansible 更是将这些机制以标准模块的形式提供给了我们。难以用这些标准模块实现的操作可以认为是冗余的或者是错的，应该考虑删除或者改良。有些时候，如果能简化流程或结构，考虑改变架构也是值得的。

通过自动化实现繁琐的搭建流程远不如找一个能简单完成环境搭建的方法来得有价值。因此不要想着用自动化去掩盖复杂的操作，而是要以自动化为契机着手改善流程。

专栏　巧用备份

搭建环境实现自动化后，每次向组中添加服务器都要整体从零搭建环境。但是，一遍遍重新搭建相同结构的服务器是一件非常浪费时间的事。如果我们采用的基础设备可以使用备份或服务器镜像，那么直接复制现有的服务器备份要远比重新搭建环境轻松且安全。

另外，添加新的服务器组时，由于全新服务器中不存在维护用户，所以每次都要面对自举问题。如果能从已有维护用户的状态开始搭建，那么整个过程将轻松不少。

出于以上原因，我们在搭建时会按照下述方针进行备份，缩短搭建时间。

在执行了environ role 的状态下做一次备份

基于上述备份给每组里的每一台服务器搭建环境并备份，有多台服务器组成的服务器组基于该备份搭建环境。

虽然看服务器自动搭建环境是一种享受，但多余操作还是应该尽量减少。

第 12 章　应用的性能改善

使用 Web 应用时，随着访问量的增加，我们会遇到响应延迟、请求失败无法正常提供服务的情况。

本章我们先学习上述问题的应对方法以及 Web 应用 / 服务器的性能评估，然后阶段性地导入 gunicorn、nginx，并在各阶段进行性能评估，观察性能改善的情况。

本章最终将形成如图 12.1 所示的服务器结构。gunicorn 和 nginx 的相关内容会在导入时详细说明，这里不作深入了解。

图 12.1　nginx 与 gunicorn 的协作

NOTE

所谓反向代理（Reverse Proxy），是指负责接收随机多个客户端发来的请求的服务器，其目的在于降低特定服务器处理请求的负担或向指定服务器群分配访问请求。它的运作方式与通常的代理服务器正好相反，因此称为反向代理。

12.1　Web 应用的性能

Web 应用负荷过重时会产生哪些问题？面对负荷过重应采取什么对策？选择对策时又应该进行哪些考量呢？接下来我们将了解一下这些问题。

12.1.1　Web 应用面对大量集中请求时会产生哪些问题

当应用服务器接收到的请求增多时，如果一个请求尚未处理完又接到了下一个请求（即两个请求几乎同时到达），那么后一个请求将被加入队列，等待前一个请求处理完毕。即便是可以并行处理多个请求的服务器，一旦到了并行处理数的极限，后到的请求也会被加入队列，等待处理的线程将越来越多。

这种状态会使应用服务器进入高负荷状态，出现 CPU 负担加重、内存空间不足等问题。

高负荷状态下应用可能无法正常运行，或者性能出现明显下降。以第 2 章中编写的 Web 应用为例，这个应用在 Python 的 SimpleHTTPServer 模块的 Web 应用服务器上运行，然而这个服务器是单进程单线程的，无法同时处理多个请求。因此请求集中到达时等待处理的请求会增多，应用性能会下降。

另外，等待队列也是有上限的，具体上限与硬件本身的性能有关。一旦等待数达到上限，新的请求将被视为无法处理的请求，表现为切断连接，请求失败。另外，队列达到上限有时会造成服务器的程序异常停止，导致无法连接服务器。

这些问题在客户端会表现为无法连接、反应慢、频繁报错等。该状态会影响用户对应用的正常使用。

12.1.2　针对高负荷的对策

解决高负荷状态需要哪些对策呢？

高负荷其实可以细分为很多种，不同种的负荷解决方法也不同。比如应用服务器的 CPU 处理能力不足了，结果我们换了一块性能更好的硬盘，这显然无法解决问题。

因此要明确问题所在，选择合适的对策。下面是几种典型问题及其对策。

问题-

解决方法

CPU 占用率高

增加处理的进程和线程数。更换成性能更好的 CPU

硬盘 I/O 负荷高

优化读取、写入数据的方法。更换成性能更好的硬盘。

内存不足

释放被无用程序占用、分配的内存。增加物理内存。

除此之外还有许多解决方法，不过本章将以上述 3 点为中心进行说明。另外，为提高改善性能的效率，需要遵循以下原则。

从预期效果最大的方法开始尝试

从所需资金、步骤、时间最少的方法开始尝试

实施对策前后各评估一次性能

第一条“从预期效果最大的方法开始尝试”是因为如果先采用了效果较小的方法，日后再采用效果较大的方法时，前一个方法的效果会被覆盖掉，这就使前一次的工作成了无用功。

第二条“从所需资金、步骤、时间最少的方法开始尝试”指优先选择性价比高的方法，把成本高成效低的方法摆到次要位置。

第三条“实施对策前后各评估一次性能”是为了掌握该对策的实际效果。评估结果能明确告诉我们成本换来了多少效果。

12.2　评估留言板应用的性能

本节，我们将对第 2 章中开发的应用进行性能评估，然后学习如何让该应用在 nginx 和 gunicorn 上运行。

12.2.1　什么是应用的性能

我们总是会说“应用的性能好 / 不好”，但好与不好究竟指的是什么呢？下面是几个性能好的例子。

应用的处理能力强

内存占用量小

页面显示速度快

通信响应速度快

这些评价对象都不相同，评价中使用的词语也是强、小、快，各不一样。可见，应用的性能有多个指标。所以在谈论性能时，必须首先确定是哪方面性能，不然理解上就会出现偏差。

在 Web 应用的各项性能指标中，本章将重点研究从发送 HTTP 请求到返回响应的这段时间（即响应时间）。响应时间越短，浏览器切换页面的速度就越快，用户就会觉得应用越流畅。另外，我们在评估性能时将使用 ApacheBench（ab 命令）。这是一款基准测试工具，能简单地检测应用的响应时间和请求成功率。

12.2.2　安装 ApacheBench

ApacheBench 是 Web 服务器 Apache 附带的工具之一。通过 apt 进行安装时，要安装 apache-utils（LIST 12.1）。

LIST 12.1　通过 apt 进行安装

$ sudo apt-get install apache2-utils

安装完后就能使用 ab 命令了。本章使用的是 ApacheBench 的 2.3 版本。

12.2.3　用 ApachBench 评估性能

这里运行第 2 章中开发的应用，用 ab 命令检测响应时间。这里以留言板首页的响应速度为检测对象。我们希望在已提交数据的状态下进行检测，因此事先输入了 50 条数据。另外，应用的运行环境如下。

OS | Ubuntu 14.04（64bit 版）
CPU | Intel Core 2 Duo 2.2GHz（双核）
内存 | 512MB

检测前先启动应用服务器（LIST 12.2）。

LIST 12.2　启动应用

$ python guestbook.py
 * Running on http://127.0.0.1:8000/
 * Restarting with reloader

使用 screen 命令的情况下会弹出新窗口，在新窗口中执行 ab 命令。不使用 screen 命令时则需要按 Ctrl+Z 键，将运行中的应用移至后台，然后再执行 ab 命令。

应用可以通过 IP 地址 127.0.0.1、端口号 8000 访问，所以 ab 命令要指定这个 URL（LIST 12.3）。-n 选项可以指定请求次数，-c 选项可以指定连接数。这里我们把总计 1000 次请求分 100 个连接并行发送。

LIST 12.3　执行 ab 命令

$ ab -n 1000 -c 100 http://127.0.0.1:8000/

检测结束后会输出如 LIST 12.4 所示的报告。

Server Software:        Werkzeug/0.9.6
Server Hostname:        127.0.0.1
Server Port:            8000

Document Path:          /
Document Length:        7398 bytes

Concurrency Level:      100
Time taken for tests:   4.911 seconds
Complete requests:      1000
Failed requests:        0
Write errors:           0
Total transferred:      7554000 bytes
HTML transferred:       7398000 bytes
Requests per second:    203.61 [#/sec] (mean)
Time per request:       491.146 [ms] (mean)
Time per request:       4.911 [ms] (mean, across all concurrent requests)
Transfer rate:          1501.99 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   1.1      0       5
Processing:    19  466  82.5    489     497
Waiting:       19  466  82.5    489     497
Total:         24  467  81.5    489     497

Percentage of the requests served within a certain time (ms)
  50%    489
  66%    490
  75%    491
  80%    491
  90%    492
  95%    496
  98%    497
  99%    497
 100%    497 (longest request)

接下来了解一下检测结果中需要注意的地方。

◉ Complete requests

Complete requests 部分显示了请求成功与失败的情况。通过上面的例子，我们可以看到，1000 个请求全部成功。如果请求没有全部成功，表示可能超出了 Web 服务器的处理能力。

Complete requests:      1000
Failed requests:        0

◉ Requests per second

1 秒处理的请求数。下述结果表示每秒大约处理 203 个请求。

Requests per second:    203.61 [#/sec] (mean)

◉ Connection Times

Connection Times 部分可以查看处理 1 个请求所需的连接时间（Connect）、处理时间（Processing）、等待时间（Waiting），单位精确到毫秒。各列元素分别代表最小值（min）、平均值（mean）、标准差（[+/-sd]）、中间值（median）、最大值（max）。如果某个时间存在明显的不平均现象，证明应用的某个部分有可能存在瓶颈。

以下述执行结果为例，我们是在本地环境中连接本地的 Web 应用，所以连接时间非常短。另外，处理时间和等待时间都没有明显的不平均现象，证明应用中不存在瓶颈。

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   1.1      0       5
Processing:    19  466  82.5    489     497
Waiting:       19  466  82.5    489     497
Total:         24  467  81.5    489     497

我们的这个留言板应用直接使用了 Flask 内置的 HTTP 服务器。该内置 HTTP 服务器由 Python 标准模块的 HTTPServer 类扩展而来。这个服务器并不算特别慢，但如果导入更高速的 Python 专用应用服务器，将能明显改善响应性能。

接下来我们将导入 gunicorn，看看这个 Python 的面向 Web 应用的 HTTP 服务器是如何改善响应性能的。

NOTE

本章用 ApacheBench 评估了应用的性能，但要注意，这与 Web 应用实际运作时的情况不完全相同。

ApacheBench 的评估对象是单一URL，但一般的 Web 应用除HTML 以外还需要对图片、CSS、JavaScript 文件发送请求。要获取某 URL 页面中的所有元素，有时需要几十个请求。因此各位要记住，ab 命令检测出的响应性能与用户体感的响应时间之间是存在差异的。

12.3　gunicorn 简介

gunicorn 是面向 Python WSGI 应用的 HTTP 服务器。它在 Linux/Unix 上运行，优点是轻量级且速度快。gunicorn 通过一个控制进程和多个工作进程来执行应用程序。由于它是一个 Python 模块，所以可以变更工作进程的类以及扩展服务器本身。

12.3.1　安装 gunicorn

gunicorn 可以用 pip 命令安装（LIST 12.5）。

LIST 12.5　用 pip 命令安装

$ pip install gunicorn

通过上述方法安装 gunicorn 后，就可以使用 gunicorn 命令了。

12.3.2　在 gunicorn 上运行应用

首先设置一个工作进程，运行应用（LIST 12.6）。

LIST 12.6　用 gunicorn 命令运行留言板应用

$ gunicorn -w 1 -b 127.0.0.1:8000 guestbook

我们在这个状态下再执行一次 ab 命令，检测结果如下。

Server Software:        gunicorn/19.1.1
Server Hostname:        127.0.0.1
Server Port:            8000

Document Path:          /
Document Length:        7398 bytes

Concurrency Level:      100
Time taken for tests:   4.469 seconds
Complete requests:      1000
Failed requests:        0
Write errors:           0
Total transferred:      7560000 bytes
HTML transferred:       7398000 bytes
Requests per second:    223.75 [#/sec] (mean)
Time per request:       446.937 [ms] (mean)
Time per request:       4.469 [ms] (mean, across all concurrent requests)
Transfer rate:          1651.87 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    1   2.2      0       8
Processing:    31  424  71.7    444     462
Waiting:       14  424  71.8    443     462
Total:         37  425  69.8    444     469

Percentage of the requests served within a certain time (ms)
  50%    444
  66%    444
  75%    444
  80%    444
  90%    445
  95%    445
  98%    446
  99%    446
 100%    469 (longest request)

可以看到与之前用 Flask 内置服务器时的结果差别不大。

由于当前运行应用的虚拟机的 CPU 数设置为 2，因此单独一个工作进程无法充分利用 CPU 资源。所以进程数应至少设置为 2。

于是我们将 gunicorn 的工作进程设置为 2，再次执行 ab 命令进行检测。工作进程数用 -w 选项指定（LIST 12.7）。

LIST 12.7　用 gunicorn 命令运行应用（工作进程数为 2）

$ gunicorn -w 2 -b 127.0.0.1:8000 guestbook

检测结果如下。

Server Software:        gunicorn/19.1.1
Server Hostname:        127.0.0.1
Server Port:            8000

Document Path:          /
Document Length:        7398 bytes

Concurrency Level:      100
Time taken for tests:   2.376 seconds
Complete requests:      1000
Failed requests:        0
Write errors:           0
Total transferred:      7560000 bytes
HTML transferred:       7398000 bytes
Requests per second:    420.91 [#/sec] (mean)
Time per request:       237.582 [ms] (mean)
Time per request:       2.376 [ms] (mean, across all concurrent requests)
Transfer rate:          3107.48 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    1   1.6      0       7
Processing:    18  225  37.3    235     248
Waiting:       17  225  37.3    235     247
Total:         25  226  35.8    235     252

Percentage of the requests served within a certain time (ms)
  50%    235
  66%    236
  75%    236
  80%    236
  90%    236
  95%    237
  98%    238
  99%    238
 100%    252 (longest request)

平均响应时间缩短了 40%，可见速度快了许多。接下来我们再看看用作 Web 服务器和代理服务器的 nginx。

12.4　nginx 简介

nginx（Engine X）1 是 Nginx Inc. 开发的 Web 服务器，拥有 HTTP 服务器、代理服务器等功能，轻量级且速度快。

1http://nginx.org/

近年来 nginx 成长迅速，在 Web 服务器界占有的份额仅次于 Apache、IIS（Internet Information Services），居世界第三（约 14%）。一些较大规模的 Web 服务也采用了 nginx，比如 WordPress.com2、GitHub3、Instagram4 等。

2https://wordpress.com/

3https://github.com/

4http://instagram.com

12.4.1　安装 nginx

nginx 的安装可以直接从源码构建，不过由于 Ubuntu 的版本库中有相关程序包，所以我们用 apt-get 命令进行安装（LIST 12.8）。

LIST 12.8　安装 nginx

$ sudo apt-get install nginx

本书使用了 nginx 的 1.4.6 版本（LIST 12.9）。

LIST 12.9　查看版本

$ nginx -v
nginx version: nginx/1.4.6 (Ubuntu)

启动、停止等操作通过 service 命令实现（LIST 12.10 ～ LIST 12.13）。

LIST 12.10　启动 nginx

$ sudo service nginx start

LIST 12.11　停止 nginx

$ sudo service nginx stop

LIST 12.12　重启 nginx

$ sudo service nginx restart

LIST 12.13　重载 nginx

$ sudo service nginx reload

测试设置文件是否有错时，执行配置测试（ConfigTest）（LIST 12.14）。

LIST 12.14　nginx 的配置测试

$ sudo nginx -t

12.4.2　检测nginx 的性能

前面我们检测了留言板应用的性能，现在我们看看 Web 服务器 nginx 本身的性能如何。

由于要运行默认站点，所以这里我们先用管理员权限编辑设置文件 /etc/nginx/sites-available/default（LIST 12.15）。如果 Apache 等已安装的软件已经占用了端口 80，则需要将 listen 后的端口号修改成其他数值。

LIST 12.15　/etc/nginx/sites-available/default

server {
    listen   80;  # 使用端口80
    server_name  localhost;  # 主机名为localhost
    # 访问日志的文件路径
    access_log  /var/log/nginx/localhost.access.log;

    location / {  # 域名后的路径为/ 时
        root   /var/www;  # 根目录
        index  index.html index.htm;  # 索引文件的文件名
    }
}

这里不存在已设置好的根目录 /var/www 时，需要手动创建（LIST 12.16）。

LIST 12.16　创建 /var/www 目录

$ sudo mkdir /var/www
$ sudo chown www-data:www-data /var/www

用管理员权限生成用于默认站点的目录（LIST 12.17）。

LIST 12.17　/var/www/index.html

<html>
  <head>
    <meta http-equiv="Content-type" content="text/html; charset=utf-8">
    <title> 测试页面</title>
  </head>
  <body>
    <h1> 测试页面</h1>
  </body>
</html>

另外，有时候我们会遇到工作进程数默认为 1 的情况，所以要根据 CPU 数（核心数）修改 /etc/nginx/nginx.conf 的 worker_processes。这里我们设置为 2（LIST 12.18）。

LIST 12.18　/etc/nginx/nginx.conf

user www-data;
worker_processes 2;

# 下略

编辑完成后执行配置测试，确认没有问题后重载或重启 nginx（LIST 12.19）。

LIST 12.19　nginx 的配置测试与重载

$ sudo nginx -t
the configuration file /etc/nginx/nginx.conf syntax is ok
configuration file /etc/nginx/nginx.conf test is successful
$ sudo service nginx reload
* Reloading nginx configuration nginx                             [ OK ]

接下来用 ApacheBench 评估性能（LIST 12.20）。

LIST 12.20　执行 ab 命令

$ ab -n 1000 -c 100 http://127.0.0.1/

评估结果如下。

Server Software:        nginx/1.4.6
Server Hostname:        127.0.0.1
Server Port:            80

Document Path:          /
Document Length:        194 bytes

Concurrency Level:      100
Time taken for tests:   0.214 seconds
Complete requests:      1000
Failed requests:        0
Write errors:           0
Total transferred:      404000 bytes
HTML transferred:       194000 bytes
Requests per second:    4662.92 [#/sec] (mean)
Time per request:       21.446 [ms] (mean)
Time per request:       0.214 [ms] (mean, across all concurrent requests)
Transfer rate:          1839.67 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    9   2.7     10      11
Processing:     1   12   3.8     11      26
Waiting:        1   10   4.1      9      25
Total:         11   21   2.9     21      29

Percentage of the requests served within a certain time (ms)
  50%     21
  66%     21
  75%     21
  80%     21
  90%     23
  95%     26
  98%     28
  99%     29
 100%     29 (longest request)

与 gunicorn 上运行的留言板相比，响应速度快出了一个层级。

12.5　在 nginx 和 gunicorn 上运行应用

现在在 gunicorn 上运行留言板应用，然后通过 nginx 进行反向代理，返回响应。通过反向代理响应可以将留言板应用的服务器横向扩展成多个。这里我们不进行横向扩展，只评估单一服务器的应用性能。

12.5.1　gunicorn 的设置

gunicorn 可以指定 -D 选项将进程转为守护进程，即守护进程化（Daemonize）（LIST 12.21）。

LIST 12.21　通过 gunicorn 命令将留言板应用转为守护进程并执行

$ gunicorn -w 2 -b 127.0.0.1:8000 -D guestbook

NOTE

守护进程化是指以 Unix 守护进程的形式运行程序。Unix 守护进程运行在后台，并且启动之后不需要用户直接控制。

以 gunicorn 为例，给 gunicorn 命令指定 -D 选项后，gunicorn 便脱离了用户的控制，会转为持续等待请求的后台进程。

12.5.2　nginx 的设置

编辑 nginx 默认站点的设置文件，以反向代理的形式连接在 gunicorn 上运行的应用（LIST 12.22）。

LIST 12.22　/etc/nginx/sites-available/default

# 名为guestbook 的upstream（上游服务器）的设置
upstream guestbook {
    server 127.0.0.1:8000;
}

server {
    listen   80;  # 使用端口80
    server_name  localhost;  # 主机名为localhost
    # 访问日志的文件路径
    access_log  /var/log/nginx/localhost.access.log;

    location / {  # 域名后的路径为/ 时
        # 反向代理到名为guestbook 的upstream
        proxy_pass http://guestbook;
    }
}

upstream 指令里可以设置多个服务器。在一台应用服务器无法满足处理需求时，可以在 upstream 里设置多台应用服务器来分担负荷。

设置文件编辑完成后要记得进行配置测试及重载。

12.5.3　评估 nginx+gunicorn 的性能

接下来用 ApacheBench 评估性能（LIST 12.23）。

LIST 12.23　执行 ab 命令

$ ab -n 1000 -c 100 http://127.0.0.1/

评估结果如下。

Server Software:        nginx/1.4.6
Server Hostname:        127.0.0.1
Server Port:            80

Document Path:          /
Document Length:        7398 bytes

Concurrency Level:      100
Time taken for tests:   2.655 seconds
Complete requests:      1000
Failed requests:        0
Write errors:           0
Total transferred:      7556000 bytes
HTML transferred:       7398000 bytes
Requests per second:    376.61 [#/sec] (mean)
Time per request:       265.529 [ms] (mean)
Time per request:       2.655 [ms] (mean, across all concurrent requests)
Transfer rate:          2778.95 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    1   3.6      0      14
Processing:    22  252  42.2    264     273
Waiting:       22  252  42.2    264     273
Total:         27  253  39.8    264     286

Percentage of the requests served within a certain time (ms)
  50%    264
  66%    265
  75%    265
  80%    265
  90%    266
  95%    266
  98%    267
  99%    268
 100%    286 (longest request)

与单独由 gunicorn 构成的服务器相比，响应时间略有延长，但考虑到可用多台服务器构成服务器组，这点损失还是可以接受的。

12.5.4　性能比较

最后我们来比较一下改善前和改善后的性能评估结果。评估结果如 LIST 12.24 和 LIST 12.25 所示。

LIST 12.24　改善前的评估结果（精选）

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   1.1      0       5
Processing:    19  466  82.5    489     497
Waiting:       19  466  82.5    489     497
Total:         24  467  81.5    489     497

LIST 12.25　改善后的评估结果（精选）

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    1   3.6      0      14
Processing:    22  252  42.2    264     273
Waiting:       22  252  42.2    264     273
Total:         27  253  39.8    264     286

从 Flask 的开发服务器换到 nginx 和 gunicorn 组成的服务器之后，Total 的时间比改善前缩短了近一半。响应时间缩短了，因此我们可以认为有改善效果。至此改善工作结束。

NOTE

若想在上述基础上进一步缩短响应时间，可以考虑下述对策。出于篇幅原因，这里就不进行详细说明了。

数据库方面将 shelve 换成 MySQL 等REBMS（加快数据库访问速度）

在别的机器上运行应用，增加机器数（增加可同时处理的请求数）

使用 memcached 等缓存服务器（减少访问数据库的次数）

12.6　小结

本章首先讲了对 Web 应用集中访问时会产生的服务器负担加重、应用运行不稳定等问题。

接下来介绍了该类问题的解决方法以及性能的概念，并用 ApacheBench 对第 2 章中开发的 Web 应用进行了性能评估。另外，还讲解了如何在 gunicorn 这一应用服务器上运行 Web 应用，以及如何设置 nginx 用作反向代理。

改善性能的目的是让已有系统的性能最优化。然而，在我们花大块时间一次次寻求改善时，会发现新解决方案的效果总是越来越小。因此，改善性能之前要先定一个性能的目标值，确定要花多少时间来做这件事。着手改善前切记要先对现有系统进行评估，通过比较确定目标。选一个合适的目标，用最少的时间和资源完成最优化工作，这才是我们追求的高效的性能改善。

第 4 部分　加速开发的技巧

第 4 部分的内容与团队开发、正式环境等话题相对独立。这里将介绍完成集成测试之后的测试思路、在 Django 上进行开发时需要注意的信息、方便好用的 Python 库等内容。

第 13 章　让测试为我们服务

第 14 章　轻松使用 Django

第 15 章　方便好用的 Python 模块

第 13 章　让测试为我们服务

不知各位在开发过程中有没有遇到过下述情况。

没有明确的目标，只能摸索着进行开发，最后成品与原定的需求不符，导致整个推翻重做

程序严格按照设计书实现，但在集成测试阶段发现其与周边功能结合不到一起，导致故障频出

本章将把测试的观点引入开发的各个过程之中，为解决或规避开发中出现的上述问题提供解决方案。我们希望本章内容能让各位从开发初级阶段开始就知道需要兼顾测试，明白自己需要考虑什么，需要亲眼确认什么，为各位的整个开发过程带来帮助。

NOTE

本书的中心内容是编程技法，因此不对各个测试所用的手法进行具体讲解。测试手法的相关内容有许多优秀的专业书籍可供参考，有兴趣的读者不妨一试。

《软件测试的艺术》（机械工业出版社，2012 年）

《软件测试 PRESS 合集》1（技术评论社，2011 年）

1原书名为『ソフトウェア·テスト PRESS 総集編』，暂无中文版。——编者注

13.1　认识现状：测试的客观环境

在各位所处的环境中，测试处于怎样一个地位呢？

在从确认运行状况到进行系统测试的过程中，各个阶段都花大把时间去测试吗？还是在编码结束后只简单确认一下运行状况就发布了呢？又或者是仔细编写测试代码，但却尽量控制手动测试呢？

细化到各个测试阶段来说，由于项目的性质、交付对象 / 搭档 / 经理的方针等因素的影响，很多时候我们不能选择最合适的工作流程，所以时间安排往往不尽如人意。另外，就算日程表已经制定好，某些时间安排也会因各种情况而缩短甚至删除。

所以很多时候我们心里明白测试的重要性，却实在挤不出时间给测试。对于需要赶工的项目而言更是如此。面对火烧眉毛的日程表，我们往往会失去方向，不知道该从哪里下手。

13.2　将测试导入开发各个阶段

项目开始之后，为了能尽可能高效地完成开发的各阶段，即便时间再紧，也要尽早将测试的观点引入项目之中。因为随着开发流程的推进，修改、变更都会越来越难。

这就像身处一个陌生的环境，只要在行动前先确认一下地图，了解当前地点和目的地的位置，之后就基本不会走偏。即便方向略有偏差，只要能在转第一个弯时及时发现，我们依然能很快返回出发点。也就是说，如果途中能每到一个路口都确认一下，就算其间走错过一条路，我们也能很快折返或者在下一个路口修正方向。但是，如果行动前不作任何确认，等瞎蒙乱撞地跑到了其他城市才意识到走错了路，此时再回过头来看地图就为时已晚了。到了这个地步，不但向前走找不到目的地，就连折返也很难返回出发点，进退两难。这在系统开发上也是同样道理，我们开始项目之前必须正确理解项目的前提条件和目的，尽量在初期阶段及早发现错误，修正方向。

总之，所谓测试的观点，就是给项目定义一个正确的状态，然后在开发过程中及时发现偏离该状态的元素并对其加以改善。将测试的观点从程序测试阶段扩展至需求定义和设计阶段，这能有效地帮助我们在开发初期修正错误。

接下来我们按照开发流程一步步进行了解。

13.2.1　文档的测试（审查）

文档类的测试一般称为审查（Review），它也是测试的一种。认真进行审查能防止因理解偏差而导致的返工或者不小心实现了多余内容等问题。因此即便没有充足时间去单独审查每个文档，也要在开始实现或测试设计之前简单地将文档重新审视一遍。

文档可分为很多种，比如下面这些。

【主要文档类型】

需求说明

需求定义

基本设计

详细设计

ER 图

数据表定义

测试说明或测试设计等

文档大致可分为三大类，一是确定开发方向的文档（需求说明、需求定义等），二是指导具体实现的文档（基本设计、详细设计等），三是规定数据使用方式的文档（ER 图、数据表定义等）。

下面是审查文档时经常发生的问题。

【问题】

文档没有更新，内容陈旧

未指明未确定的事项

描述含糊不清

存在多个相同标题的文档文件，搞不清哪个才是最新的

对于内容陈旧的文档，只能随发现随更新，然后定期重审，没有其他办法。而其他问题都是有改善余地的，下面我们来一个个解决。

◉ 未确定的事项标明“未确定”

文档是担保程序以及测试正确性的重要指标，因此其内容务必准确详实，不能有遗漏，也不能有多余内容。

拿到文档时，不要直接动手开工，先从头到尾通读一遍文档，把其中有疑问的地方和不清楚的地方记录下来。特别是在项目刚刚开始的阶段，某些需求和规定还很模糊。此时要把不明确的部分找出来，在对象文档里注明“未确定”。明确未确定的项目是对残留的待确定项目的一种可视化，让我们能直观地看到哪里还有待确定的项目。想消除文档的遗漏、缺失等问题，这种可视化必不可少。“不清楚还有哪些不清楚的事”的情况必须尽早解决。记录未确定项目时可以同时将确定该项目所需的条件写下来，便于重审文档时确认是否需要处理该项目。

另外，查找文档漏记了哪些内容其实是一个难度很高的工作，这里介绍两种行之有效的方法。

一种是对比上一阶段的文档，查看内容是否有遗漏。由于当前阶段文档全部源于前一阶段文档，所以二者内容出现不吻合时，可以认为文档某处存在遗漏。

另一种是将功能、页面、数据等方面有关联的文档放在一起，看它们之间是否存在矛盾。如果对比文档时出现内容不契合的情况，那么文档某处很可能存在问题。

【消除不明确的点】

存在未确定的事项时，在对应文档内标明“未确定”

标注“未确定”的同时记录确定所需的条件

查看前一阶段的文档，确认项目是否存在遗漏

检查功能、页面、数据等方面的关联，确认各部分关联正常

◉ 描述时尽量避免歧义

如果阅读文档时发现内容存在模糊不清的地方，证明我们对正确的内容的定义还不够明确。

在表达同一个事物时用了不同说法（用语不一致）

“○○等”“○○左右”等需要清单化的部分表述含糊不清

数值增减的幅度、上下限不明（比如写 1 ～ 10，看的人不清楚是按 1、2、3…还是按 1.0、 1.1、1.2…的规律变化）

能数值化、符号化的内容却用文字描述（大于～、～以上、不足～等）

到了实现阶段，这类描述难免被开发者加入个人的理解，产生歧义，进而导致最后成品与我们想要的东西不一致。

若想改善上述情况，首先要将表达同一个事物的用语统一，坚决不用其他说法来表述该事物。用语的统一能带来认知的统一。需要清单化的东西一定要清单化，明确描述出所有项目。清单最好集中写在一处，其中在需要参考某些资料的部分写明相应资料的位置，以便将来出现变更时能轻松修改。

数值增减的幅度、上下限也要尽量写明。因为它会给是否限制输入内容、项目的显示宽度等设计方面带来影响。

能数值化、符号化的东西尽量用数值、符号来表达，不要用文字描述。因为文字描述更容易产生误解和歧义。

为避免开发者加入个人的理解，写文档时应避免加入多余内容，同时要将必备内容表述得尽量清楚。在发现模棱两可的表述时，如果只是单一的局部问题，我们只需即刻通知其他相关的开发人员，对该部位进行修正即可。但是当发现该问题散布在文档各处时，就要提起注意了。首先查看手头的其他文档，如果这些文档也有同样问题，说明编写文档的前期讨论做得不够充分。此时需要警示项目中的所有人员，因为该问题搞不好会影响整个项目的进程。

【消除模糊不清的描述】

表述同一事物时统一用词，使表述有整体感

“○○等”“○○左右”等描述要列出所有可能性 → 例如 A. ○○、B. □□、C. △△

能数值化、符号化的就不用文字描述 → 用“>”“<”“≥”“≤”等符号以及具体数值替换文字描述

注明数值增减的幅度和上下限

◉ 明确区分最新版本和非最新版本

存在多个同名（近似名称）文档会带来混乱。相信很多人见过下面这种文件名。

○○○○修正版

○○○○最终版

新○○○○

这些文档的描述往往大同小异，却都各自存在一些需要修正或需要更新的地方。要解决这个现象，最好是对所有同类文件的描述进行一个汇总，做成一个最新的文档，其他的则都视为旧文档全部处理掉。

首先把可以确定的旧版本文档删除。接下来把重复的文档汇总，选出其中更新日期最近、名字最相似的两个进行比对，详细记录二者的区别，在其中一个文件中进行勘误及汇总。汇总完成后找出下一个最相近的文档重复上述工作。所有重复文档的对比、汇总完成之后，将用作比较对象的文档全部归档到一起，按照陈旧文档处理。一定要明确区分最新版本和旧版本的保存位置，防止再发生相同情况。像管理源码一样用版本控制系统来管理文档也是个不错的选择。

从防止同样情况再次发生的角度讲，如果陈旧文档已经明确失去了使用价值，可以考虑直接删除。

【保持文档处于最新状态】

删除确实陈旧的文档

根据更新日期和标题选择相近的文档逐一汇总

做出最新版本的文档之后，其余文档全部视为陈旧文档处理

确认没有问题的情况下删除陈旧文档

如果各个文档的审查工作正常进行，则能给后续阶段奠定可靠的基石，保证在遇到问题时有据可依。希望各位在开始编写代码之前先腾出一部分时间处理文档，它能让后续的时间安排更加灵活高效。

13.2.2　测试设计的编写方法（输入与输出）

测试设计的编写完成时间最晚不能晚于功能的实现。

进行测试设计时，需要根据测试内容和目的不同编写不同的输入文档。这是为了对程序进行多角度测试，从而尽量减少程序存在的缺陷。

文档

测试

测试内容 / 目的

功能需求

系统测试（又称综合测试）

最终成品严格按照需求运行

非功能需求-

性能测试、压力测试（又称负荷测试）

使用者使用时感觉不到压力

基本设计

集成测试（内部集成 / 外部集成）

各功能之间是否正常协作

详细设计

UT 或单元测试

当前功能是否严格符合要求

（实现）

（试运行）

功能是否如开发者预期正常实现

这个时候，要注意测试设计的编写是否顺利。因为测试的实施项目要以各文档内记录的内容以及正常状态为依据来编写，所以这一过程中能明显看出文档的好坏。测试设计的编写轻松且顺利，证明文档质量高，反过来如果编写测试设计时频频遇到难题，则说明文档不合格。要是文档中多处出现 13.2.1 节提及的问题，最好重新审查一遍文档。

编写测试设计时常出现的问题有以下几个。

【问题】

不知该写什么，写多少

测试设计的时间不够用

文档记录的内容有问题

没有可用作依据的输入文档

如果问题出在文档自身，那么只需要像上面所说的那样，在开始编写测试设计之前重新查看并审查文档即可解决问题。就算没有充足时间，也尽量不要在没更新文档的情况下直接根据最新状态编写测试设计。因为这样一来我们就失去了客观说明测试设计的依据。更新文档可以让所有相关人员及时共享最新信息，因此千万怠慢不得。

接下来我们逐个解决剩下的问题。

◉ 明确测试的目的

如果测试的项目只求详细，那想写多少项目都能写出来（当然也因文档而异）。同样，项目想删怎么都能删掉。所以在设计测试时要注意“必须保证的点（= 测试的目的）”，区分重要的项目和不重要的项目。

测试设计既要完成其在所属流程内应当完成的所有任务，又不能重复记录属于其前后流程的项目。一定要清楚自己编写的测试设计所负责的阶段以及需要完成的任务。

另外，对于用文字难以表述的测试设计，用另附表格的方式往往能轻松地表述清楚。需要记录多种情况时，可以在测试设计文本中写操作流程，然后另附表格来说明各个情况，这样既可以让文档看起来干净利索，又能有效防止遗漏。总而言之，就是不要拘泥于定式，选择最符合该项目测试的形式来编写测试设计。

还有，一个项目内要确认的内容必须限制在一个。比如在“点击按钮后信息写入数据库”中，“生成报告”和“各项目的保存内容正确无误”就要分开来写。这样，在实施时能更轻松地判断是 OK 还是 NG。

【写什么，写多少】

注意该测试内必须保证的点（= 测试的目的），区分主干和枝叶

不写属于其他测试中要保证的项目（防止重复）

难以用文字表述的另附表格

一个项目内需确认的内容限制在一个

◉ 从重要的部分开始写

测试设计是基于文档的，那么一旦文档编写的进度落后，势必会压缩测试设计的时间。很多时候，留给测试设计的日程安排都是非常紧的。

这里重点需要注意的地方和上面有些类似，也就是“写什么”“写多少”“能不能不写”的问题。越是时间紧的时候，越需要仔细阅读文档，然后从重要的部分开始写测试设计。如果动手之前不先读一遍，写到一半肯定会遇到瓶颈。因为我们先写了重要的部分，所以最后就算时间不够用了，我们也能保证重要的部分不出问题。如果真的完全没有时间可用，至少也要把需要检查的观点和方针逐条记录下来，这必然会对实施阶段有所帮助。

【时间不够用的时候】

仔细阅读文档

从重要的部分开始写

就算没时间做测试设计，也要将观点和方针逐条记录下来

◉ 利用所有可以利用的资源

在规模较小或人数较少的开发现场，不编写文档、文档写出来不更新导致形同虚设的情况时有发生。

在这种情况下，我们需要一边向相关负责人咨询所需信息一边推进测试设计。如果此时已经有了能够勉强运行的程序，最好一并拿来作参考。咨询来的信息一定要记录下来，哪怕只是草草作个笔记也好，这些可以共享的资料在后续阶段必然会用得到。每次咨询之后都要做个总结，这能避免我们为了同一件事反复去询问负责人。

在没有文档可用的情况下，如果能认真地编写测试设计，测试设计将成为最后进行规格核对时的重要资料。

【没有文档的时候】

观察已能运行的程序，掌握大致情况

必要信息直接咨询相关负责人

把咨询到的信息记录下来，汇总保存

要比有文档时更用心地编写测试设计

总而言之，编写测试设计时要注意我们上面提到的所有问题，同时综合考量程序的健壮性、发布前的日程安排等因素，在此基础上选择与测试各阶段方针相符的粒度，根据所选粒度最终完成测试设计的编写。

还有，由于需求变更时必须同时修改测试设计，所以测试设计要尽量选用方便添加、删除操作的格式，免得修改时遇到麻烦。

13.2.3　测试的实施与测试阶段的轮换（做什么，做多少）

本部分所讲的问题通常出现在正式开始测试的时候。实施测试时需要同时准备相关文档以及测试设计。

开始实际测试程序时常出现的问题有下面几个。

【问题】

没时间实施所有测试

Bug 太多，测试难以进行

不清楚在发现 Bug 时如何报告

没有测试设计下面我们来对这些问题进行逐条改善。

◉ 实施测试时也要有优先顺序

有些时候，我们不缺文档和测试设计，但交付期前紧迫的时间让我们无法完成数量庞大的全部测试项目。从整个项目的优先顺序来讲，人们往往会先照顾交付期，把执行全部测试项目放到其次。遇到这种情况我们应该怎么办呢？

首先，要确认交付期是否真的无法延后。缩短测试期意味着可以保证的项目相应减少，这很有可能导致发布后出现 Bug 和安全问题，直接影响信誉。

我们需要从整个项目的角度出发，重新认真衡量一下得失，确认交付期是否真的优先于上述几点。

如果考虑到项目整体平衡而需要删除某些测试项目，那么在选择项目前最好先按照以下条件对测试项目进行梳理及分类。

已经完成的 / 尚未完成的

不完成就无法发布的 / 就算没完成也可以删除而不影响发布的

必须详细进行测试的 / 大概确认一下就可以的

以上述分类标准为参考，给各项目设置优先顺序如下。

等级 A：已经实现，且不完成就无法发布的功能

等级 B：尚未实现，但不完成就无法发布的功能

等级 C：大概确认一下就可以的 / 实在不行删掉也可以的功能

A 和 B 是发布所需的最低限度的功能，因此如果它们的测试不够充分，我们连最低限度的功能都无法保证。这两类项目的测试时间一旦无法保证，必须同交付方进行交涉。

如果无论如何都无法延期，则需要将测试能细分的细分，不能细分的调整粒度（= 缩小保证范围，添加限制。此时需要保证整个团队达成共识）。

C 部分就是与时间赛跑，先把这些测试按优先级排成一条线，然后逐个实施。这里要做好心理准备，因为排在后半部分的项目多数时候没时间实施。

确定优先顺序时要避免模糊不清的等级制，一定要严格排序。这是为了保证那些必不可少的测试能够先执行完。这里一旦用了模糊不清的等级制，等到火烧眉毛的时候往往会出现“全都是 A”的情况，或者冒出来“AA 级”“S 级”之类原先根本不存在的等级。这就使当初的分级失去了意义。确定优先顺序是为了给测试项目划分界限（这里界限的标准不是“哪些需要保证”，而是“哪些可以不用保证”），以备万不得已之时可以将没必要的东西砍掉，因此决不能有半点模糊。

制定方针、共享方针之后，剩下的就是跟时间赛跑，在剩余时间内做到最好了。

关于优先顺序再补充一点，性能、压力测试等非功能测试要在环境具备一定条件时尽早实施。因为如果拖到一部分功能完成后再做，出现问题时需要排查的范围将会很大，修正 / 重新测试的周期也会很长。

另外，这些测试要在测试实施的各阶段定期进行。这听起来虽然与前面的言论自相矛盾，但要知道，非功能需求和功能需求一样，有些问题只有在集成之后才会显现出来。

【时间不够用的时候】

确认是否真的无法延期

确定优先顺序，先测试必不可少的项目

优先级较低的项目排成一条直线逐个执行（万不得已时直接放弃）

非功能需求的测试要尽早地、持续地实施

◉ 问题过多时先着眼大局

有时我们会遇到这种情况：开始测试之后，程序到处都是毛病根本无法运行，或者按日程表来说理应完成的功能却不能用，结果导致安排好的测试项目无法推进。这种时候就要暂时放下测试，先把已经完成的功能和未完成的功能分个类，做成清单共享给团队。

实现阶段的进度一旦与认知出现偏差，很可能发展成影响整个项目进度的问题，因此需要尽早做到信息共享。如果功能已经实现，但是品质方面存在问题，则需要确定问题的优先顺序并逐个检查。出现问题时眼光不能局限在一个功能内，要尽量站在全局角度。

① 安全方面的问题

② 功能是否正常运行

③ 数据的显示、读写是否正常

④ 设计方面的问题

测试时，先像上面这样大致列出几个重点，把当前功能中优先级高的项目测试完，然后暂时跳过优先级低的项目，直接开始下一个功能的测试。等到所有优先级高的项目测试完后，再回过头来逐个解决优先级低的项目。

【缺陷过多的时候】

区分有缺陷的功能和未实现的功能，将未实现的部分做成清单共享出来

给要实施的测试项目制定优先顺序

目光不局限在一个功能内，测试尽量照顾到整体

◉ 缺陷报告要简洁、详细

如果在测试过程中发现缺陷，应该如何报告呢？如果只说“无法运行”“运行不正常”，负责修改的人也无从改起。所以要尽量加入一些详细信息。

实施时间

实施环境

实施者（负责实施的人，或者当时登录系统的用户等）

实施意图

出现的问题（与预想结果的偏差）

这些都是在重现问题或者检查问题现状时必不可少的信息。将实施时间加入报告是为了让负责人能通过程序日志进行调查。因为有些问题只有在特定时间才会发生。

报告里还要尽量留下证据。另外，执行测试时的输入值、执行前后数据库的状态最好也保存下来。这些能在发生问题时大幅减轻调查的压力。能否还原发生问题时的情况，直接影响着解决问题的速度。

遇到那些觉得不对劲，但是又无法根据文档判断是否为缺陷的问题时，也要作出报告。借助整个团队的力量解决问题要远比一个人伤脑筋来得快。另外需要注意，出现这种难以判断的情况时，意味着设计阶段很可能潜藏了大问题。

【发现缺陷的时候】

尽量详细记录情况并报告

尽可能地留下证据

无法判断是否为缺陷时，不要急着独自下判断，先报告

◉ 即便没有测试设计，也要保证方针共享

根据项目规模和发布前的日程安排，我们有时候会大胆放弃测试设计，或者由于时间太紧没能完成测试设计。

学习测试设计的时候我们学习过，当剩余时间真的不够用的时候，至少要保证观点或方针明确。在此基础上，再根据实施时的优先顺序，从一旦发生缺陷可能引发严重问题的地方开始测试。如果开始测试后才发现问题比想象中复杂，难以解决，至少要把需确认的项目逐条记录下来。

【没有测试设计的时候】

确认、共享观点和方针

优先处理最可能引发问题的地方

对于太过复杂难以测试的地方，逐条列出需确认的项目

由于实施测试的阶段位于整个项目的后半部分，所以往往需要与时间赛跑。但即便时间不够用，我们也要有计划地推进测试，力求提高效率，用最少的劳力换来最大的效果。

13.3　小结：测试并不可怕

本章我们讲了在项目早期阶段导入测试观点能带来的几点改善。

说得笼统一点，就是确定一个正确的状态，检查当前状态与正确状态之间有多少偏差，然后考虑怎样做能修正这些偏差。至于思路和需确认的事项等，很多都可以直接拿到编程的过程中重复利用。测试能够担保的东西其实并不多，充其量不过是在有限的范围内做个保证，比如“能防止这类错误”“这种情况在预料之中”等，不可能保证完全的正确性。测试能够证明“这个缺陷已被修复”，却不能证明“这里已经没有缺陷了”。

另外，设计者、实现者都是活生生的人，难免犯错和疏忽。所以，无论我们在开发过程中多么谨慎，也不可能将出现问题的可能性降为零。

测试要有计划性。无计划的测试不但浪费时间，获得的成效也低。相反地，有计划的测试能帮助我们提高程序的品质。由于往往到了项目后半期，测试才受到重视，所以人们的注意力常会被实现代码的过程吸引，忘记去关注测试。但是我们希望各位能在项目的初期就去有意识地兼顾测试，放开胆子与测试携手同行，从而提高开发的品质。

第 14 章　轻松使用 Django

基于 Python 开发的 Web 应用框架有很多，例如 Pyramid、Flask、Bottle、Tornado 等。本章我们将目光放在 Django 上，首先对其进行简单学习，然后了解一些在实际开发过程中对 Django 有辅助作用的库。

14.1　Django 简介

Django 是一款基于 Python 开发的全栈式一体化 Web 应用框架。2003 年问世之初，它只是美国一家报社的内部工具，2005 年 7 月使用 BSD 许可证完成了开源。其目的是削减代码量，简单且迅速地搭建以数据库为主体的复杂 Web 站点。它是全栈式框架，因此安装起来很简单，而且使用者众多。这使得 Django 除具有完备的官方文档之外，还有大量的关联文档、丰富的第三方库可供使用。与其他框架相比，Django 用起来要轻松得多。

14.1.1　Django 的安装

Django 的安装与其他 Python 程序包一样，需要通过 pip 进行。

$ pip install django

本书使用了 Django 的 1.7.1 版。

14.1.2　Django 的架构

Django 继承并简化了 MVC 架构。MVC 中的 Controller 部分基本全由 Django 完成。View 部分则被分割为两部分，即负责 HTML 渲染的模板和负责显示逻辑的视图。所以 Django 又被称为 MTV（Model-Template-View）框架。这个 Django 框架除了 MTV 框架的核心部分，即 O/R 映射工具、URL 分配器（Dispatcher）、视图、模板系统之外，还有管理界面、缓存系统、国际化支持、表单处理等机制和功能。

使用 Django 开发 Web 应用站点时，需准备一个承载着 Django 实例及数据库设置等内容的工程，然后通过在该工程中新建几个应用或者调用外部应用，或者将二者结合起来进行开发。由于每个应用的本质都是 Python 程序包，所以只要按功能（模型、视图、模板等）对这些包进行分离，完全可以拿到其他工程中重复利用。

图 14.1 是 Django 架构处理请求的流程。

图 14.1　Django 的架构

① 客户端发来的 HTTP 请求被视为 Django 的请求对象

② URL 分配器负责搜索并调用被请求的 URL 所对应的视图

③ 被调用的视图视情况使用模型或模板生成响应对象

④ 响应对象作为 HTTP 响应发回给用户

接下来我们按顺序了解一下 Django 的组成元素。

◉ 工程

工程是承载了 Django 实例的所有设置的 Python 程序包。大部分情况下，一个 Web 站点就是一个工程。工程内可以新建及存放该工程固有的应用，或者保存 Web 站点的设置（数据库设置、Django 的选项设置、各应用的设置等）。

如果完整安装了 Django，可以使用 Django 管理脚本 django-admin 的 startproject 命令创建工程目录 myprj 和初始文件。

$ django-admin startproject myprj

myprj 的布局如下所示。

myprj/
 ├── manage.py
 └── myprj
        ├── __init__.py
        ├── settings.py
        ├── urls.py
        └── wsgi.py

◉ 应用

对于 Django 而言，应用指的是表示单一功能的 Web 应用的 Python 程序包。由于其实质就是 Python 程序包，因此放在 PYTHONPATH 有效的任何位置都没有问题。这里最好尽量减少应用与工程、应用与其他应用之间的依赖关系，做到功能独立，以便在其他工程中重复利用。

比如我们要创建名为 polls 的应用，就可以使用刚刚在工程目录下生成的工程管理脚本 manage.py，执行 startapp 命令。

$ cd myprj
$ python manage.py startapp polls

随后会按照下列布局生成 polls 目录及初始文件。

polls/
 ├── __init__.py
 ├── admin.py
 ├── migrations
 │     └── __init__.py
 ├── models.py
 ├── tests.py
 └── views.py

◉ 模型

Django 提供了 O/R 映射工具，因此可以用 Python 代码来描述数据库布局。

每个模型都是继承了 django.db.models.Model 类的 Python 的类，分别对应数据库中的一个表格。通过将数据库的字段、关系、行为定义为模型类的属性或方法，我们可以使用丰富且灵活的数据库访问 API。

◉ URL 分配器

URL 分配器机制使得 URL 信息不再受框架及扩展名的制约，从而让 Web 应用的 URL 设计保持简洁。

URL 在 URLconf 模块中进行描述，URLconf 模块中包括使用正则表达式书写的 URL 和 Python 函数的映象。URLconf 能够以应用为单位进行分割，因此提高了应用的可重复利用性。另外，我们可以利用给 URL 设置名称并定义的方式让代码和目标直接通过该名称调用 URL，从而将 URL 设计与代码分离。

◉ 视图

Django 的视图是一类函数，它能够生成指定页面的 HttpResponse 对象或像 Http 404 这样的异常情况，返回 HTTP 请求。典型的视图函数的处理流程通常是先从请求参数中获取数据，读取模板，然后根据获取到的数据渲染模板。

◉ 模板系统

在 Django 的概念中，模板系统只负责显示，并不是编写逻辑代码的环境。因此 Django 的模板系统将设计与内容、代码分离开来了，是一种功能强、扩展性高、对设计者很友好的模板语言。

模板基于文本而不是 XML，因此它不但能生成 XML 和 HTML，还能生成 E-mail、JavaScript、CSV 等任意文本格式。

另外，如果使用模板继承功能，子模板只需要将父模板中预留的空位填满即可。我们在编写模板时只需要描述各个模板独有的部分，因此可以省去重复冗余的编码过程。

◉ 管理界面

大多 Web 应用在运行过程中，都需要一个专供拥有管理员权限的用户添加、编辑、删除数据的界面，但是实际制作这个界面并不容易。

Django 只需将已完工的模型添加到管理站点，就能根据模型定义动态地生成面，为我们提供一个功能齐全的管理界面（图 14.2、图 14.3）。

图 14.2　登录管理界面后的首页

图 14.3　管理界面的模型编辑界面

◉ 缓存系统

Django 可以使用 memcached 等缓存后端（Cache Backend）轻松地缓存数据。比如可以将动态页面的渲染结果（部分或全部）缓存下来，等到下次需要时直接读取缓存，从而不必每次都对动态页面进行处理。

缓存的后端可以从 memcached、数据库、文件系统、本地内存等位置中进行选择。缓存对象也支持整个网站、特定的整个视图、部分模板、特定数据等。

14.1.3　Django 的文档

在下述网站上可以阅览 Django 1.7 ～ 1.10 以及 dev 版的英文文档。

Django documentation

https://docs.djangoproject.com/

从下一节开始，本章的内容均面向有 Django 使用经验的读者，因此，在进入下一节的学习之前，请先阅读文档内的教程部分。

Django 教程

https://docs.djangoproject.com/en/1.7/intro/tutorial01/

14.2　数据库的迁移

14.2.1　什么是数据库的迁移

数据库的迁移（Migration）是对数据库中的模式定义以及数据转移进行管理的功能。它的主要作用是版本管理以及版本升级、回滚等，类似于 Ruby 的 Ruby on Rails 框架。

那么，在什么场合下才能体会到它的便捷之处呢？我们经过亲身实践，认为它在以下场合能发挥极大作用。

◉ 多人持续开发时

多人共同开发的时候，应用程序经常要反映别人的修改，所以模式升级和回滚的机会也相对较多。

◉ 需要在运行过程中修改数据表时

如果需要在运行过程中修改数据表，就必须修改模式以防止现有数据受损。另外，一旦修改后出现问题，还需要立刻进行恢复。这种时候就必须做好版本管理，保证随时可以回滚。

14.2.2　Django 的迁移功能

◉ Django 数据库迁移

Django 的迁移功能支持给模型添加新的字段、向数据库的列添加 null=Ture 等。在版本管理方面，Django 以模型为基准，通过生成并执行迁移文件来完成数据库版本的更新，从而实现版本管理。

迁移文件同时也是从零新建数据库的方法之一。第一个迁移文件的执行结果是从空模式迁移为初始状态的数据表。等到所有迁移文件执行完毕之后，我们就会得到应用了最新版本的数据库模式。另外，如果有上一个版本的数据库，那么只需执行最后生成的迁移文件即可得到最新的数据库模式。

◉ 示例（对新建的应用套用迁移时）

接下来我们以新建应用的情况为例来学习一下迁移功能。首先新建一个应用（LIST 14.1）。

LIST 14.1　新建 polls 应用

$ python manage.py startapp polls

新建 polls 应用之后，首先在 settings.py 的 INSTALLED_APPS 中添加 polls，然后在 polls/models.py 中创建模型（LIST 14.2）。

LIST 14.2　myprj/settings.py

INSTALLED_APPS = (
    ...

    'polls',
)

polls 应用需要投票项目（poll）和选项（choice）两个模型，这里我们只创建 poll。poll 中要包含题目（question_test）的信息（LIST 14.3）。

LIST 14.3　polls/models.py

# -*- coding: utf-8 -*-
from django.db import models

class Poll(models.Model):
    question_text = models.CharField(max_length=200)

接下来要做的是生成迁移文件。迁移文件通过 makemigrations 命令生成（LIST 14.4）。

LIST 14.4　生成第一个迁移文件

$ python manage.py makemigrations polls
Migrations for 'polls':
  0001_initial.py:
    - Create model Poll

执行上述命令后，会生成 polls/migrations/0001_initial.py 迁移文件。此时数据库中还没有 polls，所以这里我们需要通过 migrate 命令执行迁移文件，从而生成 polls（LIST 14.5）。

LIST 14.5　执行 migrate

$ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, contenttypes, polls, auth, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  Applying admin.0001_initial... OK
  Applying polls.0001_initial... OK
  Applying sessions.0001_initial... OK

此时会发现 poll 还缺少公布日期（Publication Date），于是我们还需要为它添加公布日期（LIST 14.6）。

LIST 14.6　polls/models.py

# -*- coding: utf-8 -*-
from django.db import models

class Poll(models.Model):
    question_text = models.CharField(max_length=200)
    # 添加公布日期
    pub_date = models.DateTimeField('date published')

此类细微修改也能通过迁移功能完成。做修改时也要用 makemigrations 命令生成迁移文件（LIST 14.7）。

LIST 14.7　生成用于修改的迁移文件

$ python manage.py makemigrations polls
You are trying to add a non-nullable field 'pub_date' to poll without a default; we can't do that (the database needs something to populate existing rows).
Please select a fix:
 1) Provide a one-off default now (will be set on all existing rows)
 2) Quit, and let me add a default in models.py

此时，计算机表示“pub_date 中没有设置 default”，询问如何处理已有数据。选择 1 可以向已有数据输入要设置的值，选择 2 则可以中断迁移文件的生成。这里我们选择 1。计算机告诉我们可以使用 datetime module 和 Django 的 timezone mudule，这里我们选择 timezone.now()（有时区概念的当前时间。向数据库中添加的值为 UTC）（LIST 14.8）。

LIST 14.8　设置 default 数据

Select an option: 1
Please enter the default value now, as valid Python
The datetime and django.utils.timezone modules are available, so you can do e.g. timezone.now()
>>> timezone.now()
Migrations for 'polls':
  0002_poll_pub_date.py:
    - Add field pub_date to poll

到此，用于修改的迁移文件生成完毕。只生成迁移文件并不能将修改反映到数据库中，因此别忘了执行 migrate（LIST 14.9）。

LIST 14.9　执行 migrate

$ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, contenttypes, polls, auth, sessions
Running migrations:
  Applying polls.0002_poll_pub_date... OK

这样一来，修改就反映到数据库中了。

以上就是迁移的基本使用方法。只要记住 makemigrations 和 migrate 这两个命令，就能够应付绝大部分情况。不过例外总还是有的，为此我们来简单了解一下各个命令。

◉ 命令简介

○ migrate

用于执行迁移文件的命令（LIST 14.10、LIST 14.11）。

LIST 14.10　指定应用执行

$ python manage.py migrate application

LIST 14.11　指定文件名或编号执行

$ python manage.py migrate application file_name_or_number

该命令可以指定应用或文件名来执行。如果不指定，则会执行所有应用尚未执行过的迁移文件。

--fake

该选项可以将未执行的迁移文件标记为已执行，但不实际执行该文件。比如存在编号很旧的未执行的迁移文件，但当前的数据库状态下执行该文件会出现错误时，就可以使用这个选项。

○ makemigrations

用于生成迁移文件的命令。

--empty

用于生成空迁移文件的选项。在我们想手动编写迁移文件的内容，或者需要生成用于数据迁移的文件时，可以使用该选项。关于数据迁移的详细内容，我们将在“数据迁移”部分学习。

○ sqlmigrate

该命令可以查看套用迁移文件时执行的 SQL（LIST 14.12）。

LIST 14.12　查看执行的 SQL

$ python manage.py sqlmigrate application file_name_or_number

◉ 数据迁移

学习 makemigrations 的 --empty 选项时我们了解到，Django 可以借助迁移功能做数据迁移。数据迁移是伴随着值的变化的数据库模式变更，为与一般的模式迁移作区分，才被称为数据迁移。下面我们试着做一个让 poll 的公布日期延后一天的数据迁移（LIST 14.13、LIST 14.14）。

LIST 14.13　生成用于数据迁移的文件

$ python manage.py makemigrations --empty polls
Migrations for 'polls':
0003_auto_20141104_0236.py:

LIST 14.14　0003_auto_20141104_0236.py

# -*- coding: utf-8 -*-
from __future__ import unicode_literals

from django.db import models, migrations

class Migration(migrations.Migration):

    dependencies = [
        ('polls', '0002_poll_pub_date'),
    ]

    operations = [
    ]

生成的迁移文件中只有类的定义和空的方法，并没有描述实际的处理。我们只需添加数据迁移所需的处理，即可用它来做数据迁移（LIST 14.15）。

LIST 14.15　添加让公布日期延后一天的数据迁移

# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import datetime

from django.db import models, migrations

def forward_pub_date_by_one_day(apps, schema_editor):
    Poll = apps.get_model('polls', 'Poll')
    for poll in Poll.objects.all():
        poll.pub_date += datetime.timedelta(days=1)
        poll.save()

def backward_pub_date_by_one_day(apps, schema_editor):
    Poll = apps.get_model('polls', 'Poll')
    for poll in Poll.objects.all():
        poll.pub_date -= datetime.timedelta(days=1)
        poll.save()

class Migration(migrations.Migration):

    dependencies = [
        ('polls', '0002_poll_pub_date'),
    ]

    operations = [
        migrations.RunPython(forward_pub_date_by_one_day,
                             backward_pub_date_by_one_day)
    ]

添加完处理之后，执行migrate，将其反映到数据库中（LIST 14.16）。

LIST 14.16　执行 migrate

$ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, contenttypes, polls, auth, sessions
Running migrations:
  Applying polls.0003_auto_20141104_0236... OK

想了解数据迁移的更多内容可以参考 Django 的迁移文档 1。

1https://docs.djangoproject.com/en/1.7/topics/migrations/#data-migrations

◉ 迁移文件的 squash

随着迁移的频繁使用，迁移文件会越积越多，执行时间自然越来越长。如果想缩短时间，就需要将多个迁移文件合并成一个，这时可以使用 squashmigrations 命令（LIST 14.17）。比如在发布 Web 应用时将所有迁移文件合并成一个，这样一来我们在搭建环境时就只需要执行一个迁移。

LIST 14.17　迁移文件的 squash

$ python manage.py squashmigrations polls 0003
Will squash the following migrations:
 - 0001_initial
 - 0002_poll_pub_date
 - 0003_auto_20141104_0236
Do you wish to proceed? [yN]

计算机询问是否执行，这里键入 y 并执行（LIST 14.18）。

LIST 14.18　迁移文件的 squash

Do you wish to proceed? [yN] y
Optimizing...
  Optimized from 3 operations to 2 operations.
Created new squashed migration /path/to/myprj/polls/migrations/0001_squashed_0003_auto_20141104_0236.py
  You should commit this migration but leave the old ones in place;
  the new migration will be used for new installs. Once you are sure
  all instances of the codebase have applied the migrations you squashed,
  you can delete them.
Manual porting required
  Your migrations contained functions that must be manually copied over,
  as we could not safely copy their implementation.
  See the comment at the top of the squashed migration for details

执行后生成了名为 0001_squashed_0003_auto_20141104_0236.py 的新文件。这个迁移文件相当于之前生成的 0001、0002、0003 的总和。往后在新建的数据库中执行 migrate 时会使用这个新的迁移文件，而在已有数据库中则使用原来的迁移文件。

不过，从执行 squashmigrations 时输出的信息来看，我们需要手动修改新文件。这是因为之前做数据迁移时，0003_auto_201141104_0236 是手动添加的。新建数据库进行迁移时不需要进行数据迁移，所以我们可以将数据迁移的相关处理从 0001_squashed_0003_auto_20141104_0236 中删除。打开文件，删除 operations 内的 migrations.RunPython(…)。另外，用于添加 pub_date 的迁移文件中设置的当前时间也要一并删除（LIST 14.19）。

LIST 14.19　0001_squashed_0003_auto_20141104_0236.py

# -*- coding: utf-8 -*-
from __future__ import unicode_literals

from django.db import models, migrations
import datetime
from django.utils.timezone import utc

# Functions from the following migrations need manual copying.
# Move them and any dependencies into this file, then update the
# RunPython operations to refer to the local versions:
# polls.migrations.0003_auto_20141104_0236

class Migration(migrations.Migration):

    replaces = [(b'polls', '0001_initial'), (b'polls', '0002_poll_pub_date'), (b'polls', '0003_auto_20141104_0236')]

    dependencies = [
    ]

    operations = [
        migrations.CreateModel(
            name='Poll',
            fields=[
            ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
            ('question_text', models.CharField(max_length=200)),
            # 删除下面第一行，添加下面第二行
            # ('pub_date', models.DateTimeField(default=datetime.datetime (2014, 11, 4, 1, 22, 22, 729029, tzinfo=utc), verbose_name=b'date published')),
            ('pub_date', models.DateTimeField(verbose_name=b'date published')),
        ],
        options={
        },
        bases=(models.Model,),
    ),
    # 删除以下被注释的部分
    # migrations.RunPython(
    #     code=polls.migrations.0003_auto_20141104_0236.forward_pub_date_by_one_day,
    #     reverse_code=polls.migrations.0003_auto_20141104_0236.backward_pub_date_by_one_day,
    #     atomic=True,
    # ),
]

新建数据库进行迁移时，只需要执行 polls 的一个迁移文件。

$ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, contenttypes, polls, auth, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  Applying admin.0001_initial... OK
  Applying polls.0001_squashed_0003_auto_20141104_0236... OK
  Applying sessions.0001_initial... OK

无论是多人持续开发时还是需要在运行过程中修改数据表时，迁移都能大幅减少劳动力的付出以及出现操作失误的风险。各位请务必试一试，亲自体验其效果。

14.3　fixture replacement

14.3.1　什么是测试配置器

在第 8 章中，我们学习了测试的相关知识，但在实际编写测试代码的过程中，有时必须依赖于模块和数据，这种时候就要事先准备测试数据。

Django 默认能够将测试数据事先读入数据库。Django 的测试配置器是一种专门用来让 Django 读入数据库的特定格式数据文件。除测试之外，测试配置器还可以用来导入开发初期阶段需要用到的数据。

◉ 用 loaddata 命令读取配置器

下面我们手动创建一个用于配置器的数据文件，然后读取它。这里需要利用前面生成的 Poll 模型。我们首先在工程目录下创建名为 polls.json 的文件，文件内容如 LIST 14.20 所示。

LIST 14.20　polls.json

[
  {
    "model": "polls.poll",
    "pk": 1,
    "fields": {
      "question_text": "Why not use the fixture",
      "pub_date": "2011-11-01 00:00:00Z"
    }
  },
  {
    "model": "polls.poll",
    "pk": 2,
    "fields": {
      "question_text": "Why not use the fixture2",
      "pub_date": "2011-11-02 00:00:00Z"
    }
  }
]

pk 指模型的 primary key 的 ID。接下来将这个数据文件加载到数据库中（LIST 14.21）。

LIST 14.21　执行 loaddata

$ python manage.py loaddata polls.json

执行完毕后，可以在数据库的 polls_poll 表中看到上述配置器提供的数据。各位可以使用 SQL 等进行查看。

◉ 配置器文件的存放位置

在工程开始阶段，将配置器文件放在工程根目录下并无大碍，但是随着应用规模越来越大，文件自然也越来越多，放在工程根目录下将很难维护。这种时候就需要修改配置器文件的存放位置并进行整理。整理有以下 2 个方法。

存放在对应应用的 fixtures 目录下

存放在 settings.py 的 FIXTURE_DIRS 指定的目录下

比如我们在应用目录下创建了 fixtures 目录（例如 polls/fixtures/polls.json），我们就可以将该应用需要读取的配置器文件放在这里。这样一来，只需要执行 python manage.py loaddata polls.json 命令即可读取配置器文件。另一种就是设置 settings.py 中的 FIXTURE_DIRS，该变量所指目录下存放的配置器文件将被视为读取对象。如果想把任意目录作为读取对象，建议使用这种方法。LIST 14.22 是添加与 manage.py 同级的 fixtures 目录的方法。

LIST 14.22　FIXTURE_DIRS 的设置

FIXTURE_DIRS = [
    os.path.join(BASE_DIR, 'fixtures'),
]

◉ 在 TestCase 中使用配置器

Django 的 TestCase 能够自动读取配置器的信息。只要在 TestCase 的属性里设置了 fixtures，所指定的配置器文件就会被自动读取。在 LIST 14.23 中，我们读取了名为 polls.json 的配置器，使得测试用例可以使用配置器内记录的数据。

LIST 14.23　使用配置器的 TestCase

from django.test import TestCase
from polls.models import Poll

class PollsTestCase(TestCase):
    fixtures = ['polls.json']

    def setUp(self):
        # 一般的测试定义

    def testPoll(self):
        # 使用配置器的测试

◉ 用dumpdata 生成配置器文件

很多时候，纯手动编写配置器文件并不现实（比如需要大量档案或者多种不同情况的数据时）。Django 有生成 fixutre 文件的辅助功能，那就是 dumpdata 命令。dumpdata 命令可以用数据库里的数据生成配置器文件（LIST 14.24）。

LIST 14.24　执行 dumpdata

$ python manage.py dumpdata polls > polls.json

执行完毕后生成了名为 polls.json 的配置器文件。内容与之前用 loaddata 加载的内容相同。

14.3.2　几种不便使用默认配置器的情况

配置器用起来很方便，但编写测试代码时我们也会遇到配置器碍事的情况，下面就举几个例子来说明一下。

◉ 日期字段会变成特定日期

我们再来看看上面提到的 polls.json，会发现 pub_data 列的日期是固定的（LIST 14.25）。

LIST 14.25　polls.json

[
  {
    "model": "polls.poll",
    "pk": 1,
    "fields": {
      "question_text": "Why not use the fixture",
      "pub_date": "2011-11-01 00:00:00Z"
    }
  },
  {
    "model": "polls.poll",
    "pk": 2,
    "fields": {
      "question_text": "Why not use the fixture2",
      "pub_date": "2011-11-02 00:00:00Z"
    }
  }
]

比如我们的测试代码希望用到包含“今天的日期”的 Poll 模型时，就需要将配置器文件中的 pub_data 修改成“今天的日期”才行。在这种需要用到“今天的日期”或类似数据的情况下，就不适合用配置器来准备测试数据。

◉ 配置器不容易维护

举个例子，我们给 Poll 模型添加新的属性（列）时，需要对 polls.json 的所有数据添加属性。这将是一个非常费劲的工作。特别是在开发新的应用时，模型的修改往往很频繁，要想维护配置器文件使之能跟上模型的变化，成本通常不低。

可见，配置器在事先准备数据方面显得十分便捷，但它准备的数据缺乏灵活性和可维护性。

接下来我们将学习一个能解决上述这些问题的工具——factory_boy。

14.3.3　如何使用 factory_boy

factory_boy2 可以帮助我们生成更加灵活、更易维护的配置器。从名字可以看出来，它是 Ruby 经典工具 factory_girl3 的 Python 版。

2https://github.com/rbarrois/factory_boy

3https://github.com/thoughtbot/factory_girl

Django 的配置器是基于文件来准备数据，而 factory_boy 是生成对应于数据模型的 Factory 类，因此它能够动态且灵活地为我们准备测试数据。这种不以文件形式提供配置器，而是提供能带来同样效果的数据模型的工具称为 fixture replacement。除支持 Django 外，factory_boy 还支持其他 O/R 映射工具。

这里我们用 pip 安装 factory_boy（LIST 14.26）。

LIST 14.26　安装 factory_boy

$ pip install factory-boy

本书使用了 factory_boy 的 2.4.1 版本。

◉ Factory

Factory 对于将目标对象实例化时所需的一系列属性进行了定义。在命名 Factory 类时，需要让使用者能够通过我们赋予的名称推测出其目标对象的类（LIST 14.27）。

LIST 14.27　PollFactory 类

import factory

from django.utils import timezone

from polls.models import Poll

class PollFactory(factory.django.DjangoModelFactory):
    question_text = 'factory question'
    pub_date = timezone.now()

    class Meta:
        model = Poll

◉ 构建策略

factory_boy 支持多种不同的构建策略。构建策略是确定数据模型生成状态的方法。在测试用例中，如果需要使用不同状态的目标对象，就会用到它们（LIST 14.28）。

LIST 14.28　支持的构建策略

# 返回没有save 的Poll 实例
poll = PollFactory.build()

# 返回已save 的Poll 实例
poll = PollFactory.create()

# 以字典形式返回生成Poll 实例时所用的属性
attributes = PollFactory.attributes()

# 返回所有属性桩代码化后的对象
stub = PollFactory.stub()

直接实例化 Factory 类时，其运行效果与 create() 相同（LIST 14.29）。

LIST 14.29　Factory 类的实例化

poll = PollFactory() #=> 与PollFactory.create() 效果相同

生成实例时可以通过传递关键字传值参数的方式覆盖原有属性值，而无需关心使用的构建策略（LIST 14.30）。

LIST 14.30　覆盖属性

poll = PollFactory.create(question_text='changed question')

◉ 延迟计算属性

虽然我们在定义 Factory 的时候已经添加了静态的属性，但有些属性（关联的数据模型、需要动态生成的属性等）需要在每次生成实例时进行设置。准备这类属性就需要用到 LazyAttribute 了（LIST 14.31）。

LIST 14.31　LazyAttribute 的示例

class UserFactory(factory.django.DjangoModelFactory):
    first_name = 'Beproud'
    last_name = 'Taro'
    email = factory.LazyAttribute(lambda a:
                                  '{0}.{1}@example.com'.format(a.first_name,
                                                               a.last_name).lower())
    class Meta:
        model = User

UserFactory().email #=> beproud.taro@example.com

◉ 序列

根据特定格式需要用到连续的属性值时，可以使用序列（Sequence）（LIST 14.32）。

LIST 14.32　Sequence

class UserFactory(factory.django.DjangoModelFactory):
    email = factory.Sequence(lambda n: 'person{0}@example.com'.format(n))

    class Meta:
        model = User

UserFactory().email   # => 'person0@example.com'
UserFactory().email   # => 'person1@example.com'

基本的内容到这里就了解清楚了。接下来我们看看如何用 factory_boy 解决 Django 中的配置器不好用的问题。

14.3.4　消除“不便使用默认配置器的情况”

◉ 解决“日期字段会变成特定日期”的问题

按照 LIST 14.33 所示定义 PollFactory 类，然后为测试用例的属性配上合适的日期，这样我们就能得到想要的数据模型了。

LIST 14.33　设置灵活的日期字段

class PollFactory(factory.django.DjangoModelFactory):
    question_text = 'factory question'
    # 配上与测试匹配的日期
    pub_date = timezone.now()

    class Meta:
        madel = Poll

# 还可以在生成实例时设置
poll = PollFactory.create(pub_date=timezone.now())

◉ 解决“配置器不容易维护”的问题

配置器不易维护的问题怎么解决呢？ Django 的配置器在给模型添加列时，需要对配置器的所有数据进行修改，但如果使用 factory_boy，则只需要给 Factory 类添加列即可。

只需进行下述修改，所有通过 PollFactory 生成的 Poll 模型中就都添加了 user_name 列（LIST 14.34）。

LIST 14.34　添加列

class PollFactory(factory.django.DjangoModelFactory):
    question_text = 'factory question'
    # 添加的列
    user_name = 'factory san'
    pub_date = timezone.now()

    class Meta:
        model = Poll

通过上面的内容可以看出，Django 的配置器对于简单的测试、事先准备数据等用途而言十分便捷，但要想在实际开发中持续地实施测试，还是建议使用更加灵活且易于维护的 factory_boy。

14.4　Django Debug Toolbar

接下来了解一下在用 Django 开发应用的过程中辅助调试的 Django Debug Toolbar。

Django Debug Toolbar 的简介

用 Django 进行开发时，各位是否想过“显示某个页面的过程中总共发送了哪些 SQL”呢？其实只要使用 Django Debug Toolbar4，我们就可以在开发 Web 页面的同时查看“发送了哪些 SQL”“处理花费了多少时间”等信息了。

4https://github.com/django-debug-toolbar/django-debug-toolbar

除了 SQL 之外，使用 Django Debug Toolbar 还可以查看许多在开发过程中想要查看的信息。下面是除 SQL 之外可以查看的项目。

Versions | Django 的版本
Time | 显示视图所用的时间
Settings | settings.py 中描述的设置值
Headers | HTTP 请求 / 响应的头信息
Request | GET/POST/Cookie/Session 的变量信息
StaticFiles | 静态文件的相关信息
Templates | 模板的相关信息
Cache | 缓存框架的访问信息
Signals | Django 的内置 signal 信息
Logging | 被记录的日志信息

这里我们来了解一下几个可查看的信息。另外，本书使用了 Django Debug Toolbar 的 1.2.2 版本。现在来看看它的具体使用方法，先从安装开始。

◉ 安装

$ pip install django-debug-toolbar

安装完成之后在 settings.py 中添加如 LIST 14.35 所示的内容。

LIST 14.35　INSTALLED_APPS 内添加的内容

INSTALLED_APPS = (
    .
    .
    'django.contrib.staticfiles'   # 在'debug_toolbar' 之前设置
    .
    .
    'debug_toolbar' # <- 添加
)

这里的 'django.contrib.staticfiles' 要设置在 'debug_toolbar' 之前。另外，Django 1.7 默认设置了 'django.contrib.staticfiles'。

◉ 查看 Debug Toolbar

设置完 settings.py 之后重启 Django，随后浏览器上将显示图 14.4 所示的调试用工具栏。

图 14.4　Debug Toolbar

不需要显示 Debug Toolbar 时，点击右上角的 Hide 即可隐藏工具栏。

◉ SQL

如图 14.5 所示，我们能够查看当前视图内发送的 SQL 语句。除此之外还有 SQL 的 Explain 的执行结果以及发送 SQL 前的 Stacktrace 等信息。

图 14.5　SQL

◉ Versions

Versions 会显示当前视图使用的模块的版本信息（图 14.6）。

图 14.6　Versions

◉ 时间

如图 14.7 所示，“时间”能查看显示当前视图所消耗的时间。我们来简单看一下其中的主要项目。

User CPU time：从接到请求到渲染完页面的时间

System CPU time：从渲染完毕到服务器生成 HTTP 响应并返回给客户端所用的时间

Total CPU time：User CPU time + System CPU time 的总和，即从接到请求到返回响应所用的时间

图 14.7　时间

◉ Settings

Settings 可以列表查看 Django 的 settings.py 内设置的值（常量）。这里能查看的只有常量，因此不会显示 settings.py 中定义的函数等内容（图 14.8）。

图 14.8　Settings

◉ Headers

Headers 里可以查看 HTTP 请求 / 响应的头信息（图 14.9）。

图 14.9　Headers

◉ Request

Request 可以查看 HTTP 请求发送的 GET/POST/Cookie 的内容、Session 的内容，以及被调用的视图的信息（图 14.10）。

图 14.10　Request

◉ Static files

Static files 可以查看通过 django.contrib.staticfiles 获取的静态文件列表，以及 staticfiles 获取文件时的对象目录列表（图 14.11）。

图 14.11　StaticFiles

◉ Templates

Templates 里可以查看当前视图使用的模板（包含继承关系）以及传给模板的 Context、Context processor 的列表（图 14.12）。

图 14.12　Templates

◉ Cache

Cache 可以查看当前视图使用的缓存后端的列表（图 14.13）。

图 14.13　Cache

◉ Logging

假设 polls 应用的视图中输出了如 LIST 14.36 所示的调试日志，那么浏览器上就会显示如图 14.14 所示的日志。

LIST 14.36　Logging 的设置示例

import logging
logger = logging.getLogger(__name__)   # __name__ 处代入模块路径'polls.views'

def detail(request, poll_id):
    poll = get_object_or_404(Poll, id=poll_id)
    logger.info('Poll: %s', poll.id)
    return TemplateResponse(request, 'polls/detail.html',
                            {'poll': poll})

图 14.14　Logging

◉ Intercept redirects

勾选工具栏下方的“Intercept redirects”选项，网页的重定向将被拦截。这项功能可以帮助我们确认重定向的时间点、查看返回重定向响应的视图内都执行了哪些处理。

图 14.15　Intercept redirects

◉ 自定义显示

工具栏的显示可以根据开发应用的情况与阶段进行自定义。自定义的方法很简单，只要在 settings.py 中添加 DEBUG_TOOLBAR_PANELS 项，按照自己的需要对其中内容进行重排或改为注释，即可修改工具栏的显示项目（LIST 14.37）。

LIST 14.37　DEBUG_TOOLBAR_PANELS 的设置

DEBUG_TOOLBAR_PANELS = [
    'debug_toolbar.panels.versions.VersionsPanel',
    'debug_toolbar.panels.timer.TimerPanel',
    'debug_toolbar.panels.settings.SettingsPanel',
    'debug_toolbar.panels.headers.HeadersPanel',
    'debug_toolbar.panels.request.RequestPanel',
    'debug_toolbar.panels.sql.SQLPanel',
    'debug_toolbar.panels.staticfiles.StaticFilesPanel',
    'debug_toolbar.panels.templates.TemplatesPanel',
    'debug_toolbar.panels.cache.CachePanel',
    'debug_toolbar.panels.signals.SignalsPanel',
    'debug_toolbar.panels.logging.LoggingPanel',
    'debug_toolbar.panels.redirects.RedirectsPanel',
]

14.5　小结

本章我们聚焦基于 Python 开发的 Web 应用框架 Django，对其架构进行了学习，并了解了一些有助于开发的库。

如果要开发的 Web 应用很简单，那么默认设置的 Django 也就够我们用了。不过，一旦我们希望让开发或运营的 Web 应用更加健壮和安全（业务方面尤为多见），“这里做的还不够”“那里再改善一点就好了”之类的需求也就多了起来。正如本章所讲的那样，很多时候，可重复利用的 Django 应用和 Python 库可以帮助我们解决或者一定程度上改善这些问题。

当然，实际开发中方便好用的库远不止本章介绍的这些。下面我们再简单了解几个。

django-extensions5

管理命令、数据库字段等的结合体，能便捷地为Django框架做功能扩展。

sorl-thumbnail6

Django应用，通过专用模板标签和管理命令简化缩略图的生成与显示。

bpmappers7

简化模块映射的Python库。第15章将对其作详细介绍。

5https://github.com/django-extensions/django-extensions

6https://github.com/mariocesar/sorl-thumbnail

7https://bitbucket.org/tokibito/python-bpmappers

第 15 章　方便好用的 Python 模块

各位在开发应用的过程中有没有想过“这个处理是不是有人实现过”“这个东西该怎么实现”之类的问题呢？这种时候最好先去 Google 上看看是否有现成的模块可用。如果只需要 Python 模块，可以到 PyPI 上去找。发现有能拿来就用的模块绝对是一大好事。确认一下许可证，只要没问题就放心去用吧。

如果能顺利缩短开发时间自然是好事一桩，但现实中往往不那么顺利，比如找不到好用的模块，或是找到模块却不知道怎么用，查询用法又花掉许多时间等。最理想的情况是事先知道这些好用模块的适用场合及使用方法，以便有需要时直接拿来用。因此我们要从平时就注意积累，多通过书籍、网络博客、RSS 订阅等途径收集相关信息。

本章的目的就是充当各位的信息源，为各位介绍一些方便好用的 Python 模块。

15.1　轻松计算日期

日期是大部分系统都要用到的，但是它的计算比较复杂，因此很容易出现 Bug。开发高品质软件时要尽量避免复杂的操作。使用 dateutil 模块可以让我们用简单的描述来完成复杂的日期计算。

dateutil

http://labix.org/python-dateutil

15.1.1　日期计算的复杂性

首先我们了解一下日期计算中容易出 Bug 的地方。

◉ “1 个月后”是哪一天

遇到“1 月 1 日的 1 个月后是哪一天”的问题时，大部分人都会回答“2 月 1 日”。那么，换成“1 月 31 日的 1 个月后是哪一天”，答案又是怎样呢？恐怕我们会得到“2 月 28 日或 29 日”“3 月 3 日”“2 月 31 日”等多种回答。在开发系统的过程中，如果用了“1 个月后”这种模糊不清的表述，开发者之间很可能产生认识上的分歧，最终开发结果就会出现 Bug。在这个例子中，“30 天之后”“下个月的最后一天”等表述都比“1 个月后”清楚得多，它们能准确指定一个日期，极大地避免歧义。

◉ 求下个月的最后一天

首先我们用 Python 标准模块 datetime 的 timedelta 来编写一个计算“下个月最后一天”的程序（LIST 15.1）。

LIST 15.1　datetime_dateutil_1.py

# coding: utf-8
from datetime import datetime, timedelta

def main():
    # 下个月最后一天= 下下个月的前一天
    now_time = datetime.now()
    # 获取下下个月第一天的日期对象
    first_day_of_after_two_month = datetime(now_time.year,
                                            now_time.month + 2,
                                            1)
    # 获取下个月最后一天的日期对象
    last_day_of_next_month = \
        first_day_of_after_two_month - timedelta(days=1)
    # 输出结果
    print last_day_of_next_month.date()

if __name__ == '__main__':
    main()

执行上述代码会得到如 LIST 15.2 所示的结果。

LIST 15.2　执行结果

$ python datetime_dateutil_1.py
2012-02-29

这里我们通过“下下个月第一天的前一天”算出了“下个月的最后一天”。虽然这乍看上去没有什么问题，但实际上，在求“下下个月的第一天”时，我们忘记考虑年份的更迭了。now_time.month 可以取 1 ～ 12 的值，而这个值加上 2 有可能达到 13、14。因此，这个程序在遇到 11 月和 12 月时会发生例外。我们想要的运行情况是 now_time.month 为 11 和 12 时增算一年。问题修正后的代码如 LIST 15.3 所示。

LIST 15.3　datetime_dateutil_2.py

# coding: utf-8
from datetime import datetime, timedelta

def main():
    # 下个月最后一天= 下下个月的前一天
    now_time = datetime.now()
    # 获取下下个月第一天的日期对象（考虑跨年问题）
    if now_time.month in [11, 12]:
        first_day_of_after_two_month = datetime(now_time.year + 1,
                                                now_time.month + 2 - 12,
                                                1)
    else:
        first_day_of_after_two_month = datetime(now_time.year,
                                                now_time.month + 2,
                                                1)
    # 输出下个月最后一天
    last_day_of_next_month = \
        first_day_of_after_two_month - timedelta(days=1)
    # 输出结果
        print last_day_of_next_month.date()

if __name__ == '__main__':
    main()

可见，日期计算常常会遇到复杂的边界问题，很容易出现 Bug。下面我们用 dateutil 模块来简化这段代码。

15.1.2　导入 dateutil

用 pip 命令安装 dateutil 模块，代码如 LIST 15.4 所示。2014 年 12 月初的 dateutil 最新版本为 2.3。

LIST 15.4　用 pip 命令安装 dateutil 模块

$ pip install python-dateutil==2.3

使用 dateutil 模块的 relativedelta 函数时，我们可以使用如 LIST 15.5 所示的代码获取前面提到的“下个月最后一天”。

LIST 15.5　datetime_dateutil_3.py

# coding: utf-8
from dateutil.relativedelta import relativedelta
from datetime import datetime, timedelta

def main():
    # 下个月最后一天= 下下个月的前一天
    now_time = datetime.now()
    # 输出下个月最后一天
    last_day_of_next_month = \
        now_time + relativedelta(months=2, day=1, days=-1)
    # 输出结果
      print last_day_of_next_month.date()

if __name__ == '__main__':
    main()

relativedelta 与 timedelta 一样，可以对 datetime 对象进行加减运算。关键字传值参数方面有 day、second、hour 等单数型和 days、seconds、hours 等复数型可供选择。单数型的传值参数为指定数值，复数型的传值参数为指定增减幅度。

◉ rrule

接下来要介绍的是 rrule，它可以获取符合指定规则的日期对象。比如 LIST 15.6，这段代码的作用是获取 2012 年 1 月 1 日到 2012 年 2 月 1 日的周一和周三的日期对象并输出。

LIST 15.6　dateutil_rrule.py

# coding: utf-8
from datetime import datetime
from dateutil.rrule import rrule, DAILY, MO, WE

def main():
    # 生成rrule 对象
    rrule_obj = rrule(DAILY,  # 每天
                      byweekday=(MO, WE),  # 周一、周三
                      dtstart=datetime(2012, 1, 1),  # 2012 年1 月1 日起
                      until=datetime(2012, 2, 1))  # 2012 年2 月1 日止
    # 逐个取出符合条件的日期对象并显示在屏幕上
    for dt in rrule_obj:
        print dt

if __name__ == '__main__':
    main()

rrule 函数的第一个传值参数为获取间隔，可以指定 YEARLY（每年）、MONTHLY（每月）、WEEKLY（每周）、DAILY（每天）、HOURLY（每小时）、MINUTELY（每分钟）、SECONDLY（每秒）。其他条件通过选项指定。以 LIST 15.6 中的代码为例，byweekday 指定了星期几，dtstart 指定了开始日期，until 指定了终止日期。实际执行结果如 LIST 15.7 所示。

LIST 15.7　执行结果

$ python dateutil_rrule.py
2012-01-02 00:00:00
2012-01-04 00:00:00
2012-01-09 00:00:00
2012-01-11 00:00:00
2012-01-16 00:00:00
2012-01-18 00:00:00
2012-01-23 00:00:00
2012-01-25 00:00:00
2012-01-30 00:00:00
2012-02-01 00:00:00

15.2　简化模型的映射

近年来，Web 系统为保证服务器与客户端、服务器与服务器之间的协作，越来越多地开始提供 JSON、XML 等格式的 API。在这类 API 的内部处理中，O/R 映射工具生成的对象要序列化成 JSON 或 XML 格式。

开发 API 时，API 提供的 JSON 数据的结构必须与 O/R 映射工具生成的模型对象的结构一致，否则就会出现问题。这种问题称为阻抗失配（Impedance Missmatch）。这种时候，如果模型层级结构比较复杂，那么模型的重复利用、代码的可读性、维护成本等方面都会遇到困难。

这里我们学习一个能有效解决阻抗失配的模块——bpmappers。

bpmappers

http://bpmappers.readthedocs.io/en/latest/（日文）

https://pypi.python.org/pypi/bpmappers

15.2.1　模型映射的必要性

在实际开发系统的过程中，API 规定的键名与值的对应关系很少能与数据模型的结构一致。接下来，我们以使用 JSON 格式返回响应的 API 为例进行学习。现在假设系统中使用了如 LIST 15.8 所示的 User 类的数据模型。

LIST 15.8　User 类

class User(object):
    def __init__(self, id, password, nickname, age):
        self.id = id  # 用户ID
        self.password = password  # 密码
        self.nickname = nickname  # 昵称
        self.age = age  # 年龄

这个数据模型拥有“用户 ID”“密码”“昵称”“年龄”这 4 个值。而在我们生成的 API 中，只将“用户 ID”和“昵称”两个值包含到响应之中。该 API 通过如下 JSON 格式的响应公开了 User 类的数据。

{"user_id": " 用户ID", "user_nickname": " 昵称"}

接下来写一个函数，使用该函数可以获取一个 User 类的对象，并将其转换为 JSON 格式（LIST 15.9）。

LIST 15.9　将 User 类对象转换为 JSON 格式的函数

import json

def convert_user_to_json(user):
    """ 获取一个User 对象并返回JSON
    """
    # 生成用于转换格式的字典对象
    user_dict = {
        'user_id': user.id,  # 使用名为user_id 的键
        'user_nickname': user.nickname,  # 使用名为user_nickname 的键
    }
    return json.dumps(user_dict)  # 转换为JSON

这个函数通过 user_dict 变量生成字典对象，它实质上是给模型类的值与字典对象做了映射。像上面这样，我们用 API 提供数据模型的值时，必须给键和值做好映射。

数据模型与 API 响应数据的结构一致时，可以通过给数据模型添加元信息的方式简化映射的描述。使用 O/R 映射工具的数据模型大多含有元信息，因此映射更加简单一些。但正如例子所示，我们很少能遇到数据结构一致的模型，所以描述映射操作是必不可少的一步。

15.2.2　映射规则的结构化与重复利用

在需要返回多种响应的 API 时，意义相同部分的映射代码要保持一致，以便重复利用。

LIST 15.10 是一个返回简单的用户数据以及留言数据（包含用户和文本的数据）的 API。为便于理解，这里不采用 Web API 的形式，而是直接在控制台调用并显示结果。另外，本例中没有使用数据库。

LIST 15.10　mapping_model.py

# coding: utf-8
import json

class User(object):
    def __init__(self, id, password, nickname, age):
        self.id = id  # 用户ID
        self.password = password  # 密码
        self.nickname = nickname  # 昵称
        self.age = age  # 年龄

class Comment(object):
    def __init__(self, id, user, text):
        self.id = id  # 留言ID
        self.user = user  # 用户ID
        self.text = text  # 留言内容

def get_user(user_id):
    """ 返回用户对象的函数
    """
    # 实际开发时应该访问数据库
    user = User(id=user_id,
                password='hoge',
                nickname='tokibito',
                age=26)
    return user

def get_comment(comment_id):
    """ 返回留言对象的函数
    """
    # 实际开发时应该访问数据库
    comment = Comment(id=comment_id,
                      user=get_user('bp12345'),
                      text=u'Hello, world!')
    return comment

def mapping_user(user):
    """User 模型与API 的映射
    """
    return {'user_id': user.id, 'user_nickname': user.nickname}

def mapping_user_2(user):
    """User 模型与API 的映射2
    """
    return {'user_id': user.id,
            'user_nickname': user.nickname,
            'user_age': user.age}

def mapping_comment(comment):
    """Comment 模型与API 的映射
    """
    return {'user': mapping_user(comment.user), 'text': comment.text}

def api_user_json(user_id):
    """ 以JSON 格式返回用户数据的API
    """
    user = get_user(user_id)  # 获取User 对象
    user_dict = mapping_user(user)  # 映射到字典
    return json.dumps(user_dict, indent=2)  # 以JSON 格式返回

def api_user_detail_json(user_id):
    """ 以JSON 格式返回用户详细数据的API
    """
    user = get_user(user_id)  # 获取User 对象
    user_dict = mapping_user_2(user)  # 映射到字典
    return json.dumps(user_dict, indent=2)  # 以JSON 格式返回

def api_comment_json(comment_id):
    """ 以JSON 格式返回留言数据的API
    """
    comment = get_comment(comment_id)  # 获取Comment 对象
    comment_dict = mapping_comment(comment)  # 映射到字典
    return json.dumps(comment_dict, indent=2)  # 以JSON 格式返回

def main():
    # 获取用户数据的JSON 并显示
    print "--- api_user_json ---"
    print api_user_json('bp12345')
    # 获取用户数据（详细）的JSON 并显示
    print "--- api_user_detail_json ---"
    print api_user_detail_json('bp12345')
    # 获取留言数据的JSON 并显示
    print "--- api_comment_json ---"
    print api_comment_json('cm54321')

if __name__ == '__main__':
    main()

在这段代码中，实现 API 功能的函数有 api_user_json、api_user_detail_json、api_comment_json。其执行结果如 LIST 15.11 所示。

LIST 15.11　执行结果

$ python mapping_model.py
--- api_user_json ---
{
  "user_id": "bp12345",
  "user_nickname": "tokibito"
}
--- api_user_detail_json ---
{
  "user_id": "bp12345",
  "user_nickname": "tokibito",
  "user_age": 26
}
--- api_comment_json ---
{
  "text": "Hello, world!",
  "user": {
    "user_id": "bp12345",
    "user_nickname": "tokibito"
  }
}

在 api_comment_json 的响应中，user 部分的数据结构要与 api_user_json 保持一致，因此使用了相同的映射函数。相对地，虽然 api_user_detail_json 与 api_user_json 的结构大致相同，但它们具有差异的部分使得它们用了不同的映射函数。

像上面这样，由于每个 API 之间都只存在细微的差异，使得映射函数成了一个俄罗斯套娃般的结构。随着这种函数增多，代码的可读性会越来越差。另外，因 API 的需求变更而导致函数传值参数增加时，需要一次性修正多个地方。

这些问题可以通过导入 bpmappers 来解决。

15.2.3　导入bpmappers

bpmappers 能帮助我们将对象或字典的数据映射到其他字典上。bpmappers 通过 pip 命令进行安装，代码如 LIST 15.12 所示。本书使用的 bpmappers 版本是 0.8。

LIST 15.12　用 pip 命令安装 bpmappers

$ pip install bpmappers

bpmappers 主要由 Mapper 类和 Field 类构成。Mapper 类相当于映射函数，Field 类相当于映射字典的键值对。我们通过 Python shell 执行 bpmappers，做一个简单的映射（LIST 15.13）。

LIST 15.13　用 bpmappers 做映射

>>> from bpmappers import Mapper, RawField
>>> class SpamMapper(Mapper):
...     spam = RawField('foo')
...     egg = RawField('bar')
...
>>>
>>> SpamMapper(dict(foo=123, bar='abc')).as_dict()
{'egg': 'abc', 'spam': 123}

例子中定义了继承 Mapper 类的 SpamMapper 类，其属性包含 spam 和 egg 两个 RawField 对象。生成 SpamMapper 类的实例时，传值参数中指定了用做映射对象的字典。映射后的字典可以通过执行 Mapper 类的 as_dict 方法来获取。SpamMapper 类将 foo 键（或属性）的值映射到了 spam 键，将 bar 键（或属性）的值映射到了 egg 键。

接下来我们对前面那个返回用户数据和留言数据的 API（mapping_model.py）的源码作一下修改，对其导入 bpmappers。类和函数的重复部分在此省略。

LIST 15.14　bpmappers_mapping_model.py

# coding: utf-8
import json
from bpmappers import Mapper, RawField, DelegateField

class User(object):
    " 省略"

class Comment(object):
    " 省略"

def get_user(user_id):
    " 省略"

def get_comment(comment_id):
    " 省略"

class UserMapper(Mapper):
    """User 模型与API 的映射
    """
    user_id = RawField('id')
    user_nickname = RawField('nickname')

class UserMapper2(UserMapper):
    """User 模型与API 的映射2
    """
    user_age = RawField('age')

class CommentMapper(Mapper):
    """Comment 模型与API 的映射
    """
    user = DelegateField(UserMapper)
    text = RawField()

def api_user_json(user_id):
    """ 以JSON 格式返回用户数据的API
    """
    user = get_user(user_id)  # 获取User 对象
    user_dict = UserMapper(user).as_dict()  # 映射到字典
    return json.dumps(user_dict, indent=2)  # 以JSON 格式返回

def api_user_detail_json(user_id):
    """ 以JSON 格式返回用户详细数据的API
    """
    user = get_user(user_id)  # 获取User 对象
    user_dict = UserMapper2(user).as_dict()  # 映射到字典
    return json.dumps(user_dict, indent=2)  # 以JSON 格式返回

def api_comment_json(comment_id):
    """ 以JSON 格式返回留言数据的API
    """
    comment = get_comment(comment_id)  # 获取Comment 对象
    comment_dict = CommentMapper(comment).as_dict()  # 映射到字典
    return json.dumps(comment_dict, indent=2)  # 以JSON 格式返回

def main():
    " 省略"

if __name__ == '__main__':
    main()

LIST 15.14 的执行结果没有变化。api_user_json 使用了 UserMapper。api_user_detail_json 使用的是继承 UserMapper 且添加了 age 映射的 UserMapper2 类。可以看到，bpmappers 的 Mapper 类能够利用继承的结构来添加不同的映射。另外，api_comment_json 的 user 部分与 UserMapper 的数据结构相同，所以我们直接通过 DelegateField 指定了 UserMapper。这种俄罗斯套娃式的映射结构同样可以用其他类来实现。

另外，列表内元素的套娃式映射可以用 ListDelegateField 来完成。LIST 15.15 中，我们通过 Python shell 执行了一个用 ListDelegateField 实现的映射。

LIST 15.15　用 ListDelegateField 实现的映射

>>> from bpmappers import Mapper, RawField, ListDelegateField
>>> class SpamMapper(Mapper):
...     spam = RawField('foo')
...
>>> class ListSpamMapper(Mapper):
...     spam_list = ListDelegateField(SpamMapper)
...
>>> ListSpamMapper({'spam_list': [{'foo': 123}, {'foo': 456}]}).as_dict()
{'spam_list': [{'spam': 123}, {'spam': 456}]}

ListDelegateField 中指定了继承 Mapper 类的 SpamMapper 类。ListDelegateField 可以以指定的类映射列表中的各个元素。通过上述例子我们可以看到，用 bpmappers 能够简化映射定义，同时方便映射的重复利用。

15.2.4　与 Django 联动

bpmappers 的一些功能可以为 Django 框架的模型对象映射提供辅助。使用 bpmappers.djangomodel.ModelMapper 可以轻松地根据 Django 的模型类生成用于映射的类。

下面我们用 ModelMapper 类来给简单的 Django 模型类作一个映射。请注意，这里我们不创建 Django 工程，所以需要在源码内初始化 Django（LIST 15.16）。

LIST 15.16　django_and_bpmappers.py

# coding: utf-8
# 初始化Django
from django.conf import settings
settings.configure()

from django.db import models
from bpmappers.djangomodel import ModelMapper

class Person(models.Model):
    """ 表示人的数据模型
    """
    name = models.CharField(u' 名字', max_length=20)
    age = models.IntegerField(u' 年龄')

    class Meta:
        # 指定app_label，防止应用名解析时出错
        app_label = ''

class PersonMapper(ModelMapper):
    """ 让Person 模型映射到字典时需要用到的类
    """
    class Meta:
        model = Person

def main():
    # 生成Person 对象
    person = Person(id=123, name=u'okano', age=26)
    # 映射到字典
    person_dict = PersonMapper(person).as_dict()
    # 输出到屏幕上
    print person_dict

if __name__ == '__main__':
    main()

为了让 Person 模型映射到字典，我们定义了一个继承 ModelMapper 类的 PersonMapper 类。ModelMapper 类内部定义了内部类 Meta，model 指定了 Person 模型。这样描述之后，ModelMapper 就会自动地根据 Person 模型拥有的字段生成映射。

在安装了 bpmappers 和 Django 的计算机上运行上述代码将得到如 LIST 15.17 所示的结果。

LIST 15.17　执行结果

$ python django_and_bpmappers.py
{'id': 123, 'name': u'okano', 'age': 26}

15.2.5　编写JSON API

接下来我们在导入 bpmappers 的前提下实际编写一个返回 JSON 格式响应的 API。首先，我们以第 2 章中开发的留言板应用为例编写代码，实现在用户提交信息时以 JSON 格式返回响应。具体代码如下。

from flask import jsonify
from bpmappers import Mapper, RawField, ListDelegateField

class GreetingMapper(Mapper):
    name = RawField()
    comment = RawField()

class GreetingListMapper(Mapper):
    greeting_list = ListDelegateField(GreetingMapper)

@application.route('/api/')
def api_index():
    """ 留言
    """
    # 读取提交的数据
    greeting_list = load_data()
    result_dict = GreetingListMapper(
        {'greeting_list': greeting_list}).as_dict()
    # 以JSON 格式返回响应
    return jsonify(**result_dict)

将这段代码添加到 guestbook.py 的 if __name__ …之前。JSON 的响应会以 greeting_list 为键，通过数组的形式返回各次提交的姓名以及留言内容。要返回的数据通过已有的 load_data 函数获取，然后以 GreetingListMapper 类进行映射。GreetingListMapper 类使用了 ListDelegateField 类，从而实现以 GreetingMapper 类对列表内的值进行映射。

接下来保存修改，执行源码并启动服务器。在添加几条数据之后访问 http://127.0.0.1:5000/api/，我们会得到 JSON 格式的响应。下面是用 urllib 访问时的例子。

$ python -m urllib http://127.0.0.1:5000/api/
{
  "greeting_list": [
    {
      "comment": "\u65e5\u672c\u8a9e\u306e\u6587\u5b57\u5217",
      "name": "tokibito"
    },
    {
      "comment": "Hello, world!",
      "name": "tokibito"
    }
  ]
}

导入 bpmappers 能提高映射的重复利用率，还能让我们在需求变更时更灵活地加以应对，因此即便是很简单的 API，也建议用 bpmappers 来实现。

15.3　图像处理

Python 的图像处理通常用 Pillow（Python Imaging Library（Fork））来进行。Pillow 由 PIL（Python Imaging Library）的分支工程开发而来。由于 PIL 已经停止开发及维护，所以如今 Pillow 成为了主流。它支持 JPEG、PNG、GIF、BMP 等多种图像格式。本书使用的是 Pillow 的 2.6.1 版本。

Pillow

http://pillow.readthedocs.org/

15.3.1　安装Pillow

Pillow 与多种处理图像数据的程序库存在依赖关系，因此安装时需要多加注意。目前 Pillow 在 PyPI 上提供了面向 Windows 和 OS X 的 wheel 包。在 Windows、OS X 上安装（包括用 pip 命令安装）时不需要进行编译。如果使用的是其他平台，那么由于需要从 sdist 进行 C 扩展的编译，所以必须准备编译器和各种图像处理库。

◉ 有 wheel 可用的平台

如果是 OS X 和 Windows，只需像 LIST 15.18 这样使用 pip install 安装 wheel 包即可。

LIST 15.18　在OS X 上安装Pillow

$ pip install pillow==2.6.1
Downloading/unpacking pillow==2.6.1
  Downloading Pillow-2.6.1-cp27-none-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.whl (2.8MB): 2.8MB downloaded
Installing collected packages: pillow
Successfully installed pillow
Cleaning up...

◉ 从源码构建

接下来准备进行 Pillow 编译时所需的库。下面以 Ubuntu 14.04 为例进行学习。

首先，因为需要编译 C 扩展，所以需要一些基本的开发工具。我们先来确认一下 1.1 节中的安装（LIST 15.19）。

LIST 15.19　检查设置以便进行 python 的 C 扩展编译

$ pkg-config python-2.7 --libs --cflags
-I/usr/include/python2.7 -I/usr/include/x86_64-linux-gnu/python2.7  -lpython2.7

另外，图像格式和字体等的支持需要用到下述程序库。

支持对象

库

JPEG

libjpeg-dev

OpenJPEG

libopenjpeg-dev

PNG

zlib1g-dev

TIFF

libtiff5-dev

webp

libwebp-dev

字体

libfreetype6-dev

色彩管理

liblcms2-dev

执行 LIST 15.20 中的命令，统一安装 Pillow 需要的程序包。

LIST 15.20　安装 Pillow 需要的程序包

$ sudo apt-get install libjpeg-dev libopenjpeg-dev zlib1g-dev libtiff5-dev libfreetype6-dev libwebp-dev liblcms2-dev

现在所需工具和库已经齐全，可以用 pip 进行安装了（LIST 15.21）。

LIST 15.21　用 pip 命令安装 Pillow

$ pip install pillow==2.6.1

安装时会显示支持的图像格式等，我们可以借此查看想要的功能是否已经生效。LIST 15.22 是除 TKINTER 以外的所有功能均生效的例子。

LIST 15.22　查看支持的功能

running build_ext
--------------------------------------------------------------------
PIL SETUP SUMMARY
--------------------------------------------------------------------
version      Pillow 2.6.1
platform     linux2 2.7.6 (default, Mar 22 2014, 22:59:56)
             [GCC 4.8.2]
--------------------------------------------------------------------
*** TKINTER support not available
--- JPEG support available
--- OPENJPEG (JPEG2000) support available (2.1.3)
--- ZLIB (PNG/ZIP) support available
--- LIBTIFF support available
--- FREETYPE2 support available
--- LITTLECMS2 support available
--- WEBP support available
--- WEBPMUX support available
--------------------------------------------------------------------
To check the build, run the selftest.py script.

NOTE

Pillow 2.6.1 无法识别 Ubuntu 14.04 上安装的 libopenjpeg-dev。今后的版本中应该会修复这个问题。

如果应用不涉及 Tkinter 模块的图像，可以不用管 TKINTER 的支持问题。另外，安装 Python 时，如果 Tkinter 模块并未生效，同样无法支持 TKINTER。

15.3.2　图像格式转换

图像文件的格式转换通过在 Image 类的 save 方法的传值参数中指定格式并保存来完成。下面，我们打开当前目录下名为 python.gif 的图像文件，将其转换为 JPEG 格式，并保存在 python_convert.jpg 文件中。具体代码如下。

# coding: utf-8
from PIL import Image

def main():
    # 打开文件获取Image 对象
    image = Image.open('python.gif')
    # 模式转换为RGB
    image_rgb = image.convert('RGB')
    # 图像保存至文件
    image_rgb.save('python_convert.jpg', 'jpeg')

if __name__ == '__main__':
    main()

可以看到，程序在读取完文件之后将图像模式转为了 RGB。

在 GIF 以及不足 256 色的 PNG、BMP 等格式中，颜色信息都保存在调色板数据块里。这类文件用 Pillow 打开时分为 P 模式（调色板模式）和 1 模式（单色模式）。另外，JPEG 文件有时还会是 CMYK 模式。当模式不支持 save 方法指定的格式时，程序会报错，所以要先用 convert 方法进行模式转换。

15.3.3　改变图像尺寸

如果想改变图像尺寸，可以使用 Image 类的 thumbnail 方法或 resize 方法。下面，我们打开当前目录下名为 python.jpg 的图像文件，将其长宽缩小一半后保存为 python_thumbnail.jpg，代码如 LIST 15.23 所示。

LIST 15.23　pil_thumbnail.py

# coding: utf-8
from PIL import Image

def main():
    # 打开文件获取Image 对象
    image = Image.open('python.jpg')
    # 计算图像长宽的一半
    half_size = (image.size[0] / 2, image.size[1] / 2)
    # 图像大小降为一半
    image.thumbnail(half_size, Image.ANTIALIAS)
    # 图像保存至文件
    image.save('python_thumbnail.jpg')

if __name__ == '__main__':
    main()

Image 类的对象能够通过 size 属性以元组的形式获取图像的长和宽。

thumbanil 方法的第一个传值参数指定了图像长和宽的元组，第二个传值参数指定了滤镜 Image.ANTIALIAS。滤镜有 NEAREST、BILINER、BICUBIC、ANTIALIAS4 种可供选择，其中使用 ANTIALIAS 修改尺寸后的图像品质最高（损失最小）。

在执行 thumbnail 方法之后，会直接修改对象自身的图像大小。但是，这个方法只能用于长宽比例不变的修改。变更长宽比例时需要使用 resize 方法。下面，我们打开当前目录下名为 python.jpg 的图像文件，将其长度放大为 2 倍后保存为 python_resize.jpg，具体代码如 LIST 15.24 所示。

LIST 15.24　pil_resize.py

# coding: utf-8
from PIL import Image

def main():
    # 打开文件获取Image 对象
    image = Image.open('python.jpg')
    # 计算图像长度的2 倍
    double_size = (image.size[0], image.size[1] * 2)
    # 图像大小增加至2 倍
    image_resized = image.resize(double_size, Image.ANTIALIAS)
    # 图像保存至文件
    image_resized.save('python_resize.jpg')

if __name__ == '__main__':
    main()

与 thumbnail 方法不同，resize 方法的返回值是修改尺寸后的 Image 类的对象。它同 thumbnail 一样，可以指定滤镜。图 15.1 和图 15.2 分别是修改尺寸之前的图像与执行完 LIST 15.24 所示的代码之后的图像。

图 15.1　修改尺寸之前的图像（python.jpg）

图 15.2　修改尺寸之后的图像（python_resize.jpg）

15.3.4　剪裁图像

Image 类的 crop 方法能够以长方形剪裁图像。下面，我们打开当前目录下名为 python.jpg 的图像文件，按照图像的宽度从正中间剪裁一个正方形并保存为 python_crop.jpg。

LIST 15.25　pil_crop.py

# coding: utf-8
from PIL import Image

def main():
    # 打开文件获取Image 对象
    image = Image.open('python.jpg')
    # 根据短边长度求中央正方形的坐标
    if image.size[0] < image.size[1]:
        # 横边较短时（瘦高的图像）
        crop_rect = (
            0,
            (image.size[1] - image.size[0]) / 2,
            image.size[0],
            (image.size[1] - image.size[0]) / 2 + image.size[0])
    else:
        # 竖边较短时（矮胖的图像）
        crop_rect = (
            (image.size[0] - image.size[1]) / 2,
            0,
            (image.size[0] - image.size[1]) / 2 + image.size[1],
            image.size[1])
    # 剪裁
    image_croped = image.crop(crop_rect)
    # 图像保存至文件
    image_croped.save('python_crop.jpg')

if __name__ == '__main__':
    main()

crop 方法的传值参数是包含 4 个值的元组（Tuple），这 4 个值代表长方形剪裁区域的左上角坐标和右下角坐标。crop 的返回值为存有剪裁后图像的 Image 类对象。执行 LIST 15.25 中的代码后会得到如图 15.3 所示的结果。

图 15.3　剪裁后的图像（python_crop.jpg）

15.3.5　对图像进行滤镜处理

进行滤镜处理必须获取像素值。像素值可以用 Image 类的 getdata 方法或 getpixel 方法来获取。获取的像素值为包含 R（红）、G（绿）、B（蓝）3 个值的元组，3 个值的范围均为 0 ～ 255。下面，我们打开当前目录下名为 python.jpg 的图像文件，将所有像素反色并保存为 python_filter.jpg。

LIST 15.26　pil_filter.py

# coding: utf-8
from PIL import Image

def main():
    # 打开文件获取Image 对象
    image = Image.open('python.jpg')
    buffer = []
    # 循环逐一获取图像的像素
    for pixel in image.getdata():
        # 将像素反色并存入缓冲区
        buffer.append((
            255 - pixel[0],
            255 - pixel[1],
            255 - pixel[2]))
    # 用缓冲区内的像素覆盖原有数据
    image.putdata(buffer)
    # 图像保存至文件
    image.save('python_filter.jpg')

if __name__ == '__main__':
    main()

getdata 方法能够返回一个迭代器，用于逐一访问图像的每一组像素值。在上例中，我们逐一取出了每个像素的像素值并进行反色（255 减去色值）。等所有像素值处理完毕之后，用 putdata 方法替换了 Image 类的对象的像素。LIST 15.26 的执行结果如图 15.4。

图 15.4　反色后的图像（python_filter.jpg）

如果要获取指定坐标的像素值，可以用 Image 类的 getpixel 方法。下面，我们打开当前目录下名为 python.jpg 的图像文件，将右上角的像素反色并保存为 python_pixel.jpg，具体代码如 LIST 15.27 所示。

LIST 15.27　pil_pixel.py

# coding: utf-8
from PIL import Image

def main():
    # 打开文件获取Image 对象
    image = Image.open('python.jpg')
    # 右上角的位置
    point = (image.size[0] - 1, 0)
    # 获取像素值
    pixel = image.getpixel(point)
    # 改写为反色的像素值
    image.putpixel(point, (
        255 - pixel[0],
        255 - pixel[1],
        255 - pixel[2]))
    # 图像保存至文件
    image.save('python_pixel.jpg')

if __name__ == '__main__':
    main()

getpixel 方法的传值参数为含有横纵坐标（起点为 0）两个值的元组。改写指定位置像素值时使用了 putpixel 方法。这些方法的方便之处在于能够指定坐标，但是速度太慢，因此一旦需要大量使用，它们的效率并不见得比 getdata、putdata 等方法更高。

15.4　数据加密

在数据传输过程中，为防止数据被第三方获取或篡改，需要对数据进行加密。这里我们学习一下如何通过 PyCrypto 进行通用加密系统和公钥加密系统的加密及解密。

PyCrypto

https://pypi.python.org/pypi/pycrypto

15.4.1　安装 PyCrypto

如 LIST 15.28 所示，PyCrypto 的安装可以通过 pip 命令进行。由于它包含 C 语言编写的模块，所以安装时与 Pillow 一样需要 gcc 等编译器。各位可以事先用 apt 安装 python-dev 包和 build-essential 包。本书使用的是 PyCrypto 的 2.6.1 版本。

LIST 15.28　用 pip 命令安装 PyCrypto

$ pip install pycrypto==2.6.1

15.4.2　通用加密系统的加密及解密

通用加密系统在加密和解密时使用同一套密钥。AES、DES 等都属于通用加密算法。AES 的密码长度和块长都高于 DES，因此安全性较高。本书使用的就是 AES。

NOTE

DES（Data Encryption Standard）是1977 年被美国标准化的加密系统。

AES（Advanced Encryption Standard）是2011 年被美国标准化的加密系统。

以 AES 加密、解密时需要用到 PyCrypto 的 Crypto.Cipher.AES 类。下面我们用 PyCrypto 实现 AES 加密及解密，并将结果输出到屏幕上（LIST 15.29）。

LIST 15.29　aes_encrypt.py

# coding: utf-8
from Crypto.Cipher import AES

KEY = 'testtesttesttest'  # 加密和解密时使用的通用密钥
DATA = '0123456789123456'  # 数据长度为16 的倍数

def main():
    aes = AES.new(KEY)  # 生成AES 类的实例
    encrypt_data = aes.encrypt(DATA)  # 加密
    print repr(encrypt_data)  # 输出至屏幕
    decrypt_data = aes.decrypt(encrypt_data)  # 解密
    print repr(decrypt_data)  # 输出至屏幕

if __name__ == '__main__':
    main()

给 AES.new 函数指定用作密钥的字符串，生成 AES 对象。密钥可以是长度为 16、24、32 字符的任意字符串。数据通过 AES 对象的 encrypt 方法加密，通过 decrypt 方法解密。上述代码段的执行结果如 LIST 15.30 所示。

LIST 15.30　执行结果

$ python aes.py
'\xf7\xf1\x0ccS\xef\x02\xe10\xf2\xe1\xd2\x80{\xcf\xfa'
'0123456789123456'

执行结果的第一条输出是加密状态的数据，第二条是将加密的二进制串解密后还原的数据。

15.4.3　公钥加密系统（RSA）的加密与解密

公钥加密系统在加密和解密时分别使用不同的密钥。RSA 等就是公钥加密算法。

NOTE

RSA 是 Ron Rivest、Adi Shamir、Len Adleman 于 1977 年发明的加密算法。

◉ RSA 私钥和公钥的生成

在公钥加密系统中，加密使用公钥（Public key），解密使用私钥（Private key）。这两种密钥都需要通过算法生成。公钥和私钥的密钥对可以通过 ssh-keygen 命令或 openssl 命令来创建，不过我们这里要学习的是用 PyCrypto 生成密钥的方法。下面，我们用 PyCrypto 生成 RSA 的密钥对，以 PEM 格式（RFC1421）输出到屏幕上，具体代码如下。

LIST 15.31　rsa_generate_keypair.py

# coding: utf-8
from Crypto.PublicKey import RSA
from Crypto import Random

INPUT_SIZE = 1024

def main():
    random_func = Random.new().read  # 产生随机数的函数
    key_pair = RSA.generate(INPUT_SIZE, random_func)  # 生成密钥对
    private_pem = key_pair.exportKey()  # 获取PEM 格式的私钥
    public_pem = key_pair.publickey().exportKey()  # 获取PEM 格式的公钥
    print private_pem  # 输出至屏幕
    print public_pem  # 输出至屏幕

if __name__ == '__main__':
    main()

LIST 15.31 用 RSA.generate 函数生成了 RSA 密钥对的对象，用 exportKey 方法获取了用作私钥的 PEM 格式的字符串。公钥则是先通过 publickey 方法获取对象，然后再用 exportKey 方法获取的。上述代码的执行结果如 LIST 15.32 所示。

LIST 15.32　执行结果

$ python rsa_generate_keypair.py
-----BEGIN RSA PRIVATE KEY-----
MIICXgIBAAKBgQDMYS234o1C1Z2fbeZazcUnEfspBcs06hSmvDji+Jm5Gk6tvIHl
IFFu1aCD8kBbjf2ivzmG8Dgtcn6jnLjXe3EB0H1vh70TUsvi0ZjxZsmbv6fJmJrQ
zJvW1Wi3wnoBeVYQk6ha8rbfY35wErxxdTWeWm1nSBwaFfnRFYnrkqVGlQIDAQAB
AoGBAKJZ39Ne6A/bWOa4inA/XQl4QyeHLrDN8bGxew7xpEtiFnX0dMrqLUX59RRb
b7xKwtxxQuVqFXYkqWyWpk6mBFGcRH1yH888Cgu+mSbsKvMAGOW/oTl7XLV8hc4T
m0iT/gEUsCHFcE6mstkUIEMlZCWmnuoijprDbehh1OSEZPQBAkEA1IFgXqMGIC/x
CYwrizFgJVAa/o4IF183CocfqPaYlotKCeNovnPXeSCmAX1d0GhCHKBIQmkmL7YU
TZ1DxiWL1QJBAPY2CWyA26GKGu1WzURJa7guizaqGJpghF30U5VdvdKmetYU2gXA
rhHQ9LxdjG09L9BWSxg5Y1Zl02b8f2Qf78ECQQCNr3VBpBCBhXWAmCSwOcuRFUfq
UWizrJhWPKGvVjuGpHhI/4bm9PXFnS8R7zSNr/XkgDmtjc4YIZ6H4UM+6enBAkBi
yC9jvxdfan9/NdJJUYPMc7AbEIeqeIri/0IBrYiZWX3zIo6OvE2ajFGEuau7sE7c
saKTZ4L5iQUWTrv1ufKBAkEAis4KsI4Inxz01ZPRcmPlUVKULvVqyquqsfKP+NFG
PTurYiXOc2kXPbBNxyhTDQ6Dw3OB0GhARHSGiuhQQicA2w==
-----END RSA PRIVATE KEY-----
-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDMYS234o1C1Z2fbeZazcUnEfsp
Bcs06hSmvDji+Jm5Gk6tvIHlIFFu1aCD8kBbjf2ivzmG8Dgtcn6jnLjXe3EB0H1v
h70TUsvi0ZjxZsmbv6fJmJrQzJvW1Wi3wnoBeVYQk6ha8rbfY35wErxxdTWeWm1n
SBwaFfnRFYnrkqVGlQIDAQAB
-----END PUBLIC KEY-----

在输出的字符串中，从 -----BEGIN RSA PRIVATE KEY----- 到 -----END RSA PRIVATE KEY----- 的部分为私钥，从 -----BEGIN PUBLIC KEY----- 到 -----END PUBLIC KEY----- 的部分为公钥。加密解密时就是使用这一对密钥。

◉ 用公钥加密

加密需要使用公钥。PyCrypto 可以使用我们输入的 PEM 格式的公钥字符串。下面，我们将字符串 Hello, world! 加密并输出到屏幕上（LIST 15.33）。

LIST 15.33　rsa_encrypt.py

# coding: utf-8
from Crypto.PublicKey import RSA
from Crypto import Random

DATA = 'Hello, world!'
PUBLIC_KEY_PEM = """-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDMYS234o1C1Z2fbeZazcUnEfsp
Bcs06hSmvDji+Jm5Gk6tvIHlIFFu1aCD8kBbjf2ivzmG8Dgtcn6jnLjXe3EB0H1v
h70TUsvi0ZjxZsmbv6fJmJrQzJvW1Wi3wnoBeVYQk6ha8rbfY35wErxxdTWeWm1n
SBwaFfnRFYnrkqVGlQIDAQAB
-----END PUBLIC KEY-----"""

def main():
    random_func = Random.new().read  # 产生随机数的函数
    public_key = RSA.importKey(PUBLIC_KEY_PEM)  # 输入PEM 格式的公钥
    encrypted = public_key.encrypt(DATA, random_func)  # 加密数据
    print encrypted  # 输出至屏幕

if __name__ == '__main__':
    main()

这段代码用 RSA.importKey 函数输入公钥并获取了 RSA 对象，然后用 encrypt 方法进行加密。encrypt 方法的传值参数处指定了需要加密的数据以及产生随机数的函数。上述代码的执行结果如 LIST 15.34 所示。

LIST 15.34　执行结果

$ python rsa_encrypt.py
('\x05 a\\\xb9U\x88/El\x1a\x02\xe6\xb4\xede\xf2\xe6\xe3\xa6&~\x9e\x180[K%i\x02k\xdd\xd5%\xfd\x1a\xc6\xd7\xc4\xa8\xcf\x86\x07\xdck\x7f\xb4\xb5_,I\x80\xe9\x83\x00*q\xce\xacA\x9a\xe3$]\xe5*\x9e\x91F\xd2\xe3P\xb8+\xa6\xc1R\xde\xf2G\xf1\x185\xcd\x8f\x82\x1a\xa4c\xf5\x9c\xd8\xe0\xd1g \xfdw\xa0\xe6\xca\xf7\x9f\xde\xbf(\xa2\xd5\xdb\xd5}\xe5\xaf\x99\xf9\x90\x1cx\n\xe8\xda\x14\x9cJ\xd7\xe4\x96S',)

◉ 用私钥解密

解密需要使用私钥。与加密时一样，这里也要输入 PEM 格式的字符串。下面，我们把前面加密的数据解密并显示在屏幕上，代码如 LIST 15.35 所示。

LIST 15.35　rsa_decrypt.py

# coding: utf-8
from Crypto.PublicKey import RSA

DATA = ('\x05 a\\\xb9U\x88/El\x1a\x02\xe6\xb4\xede\xf2\xe6\xe3\xa6&~\x9e\x180[K%i\ x02k\xdd\xd5%\xfd\x1a\xc6\xd7\xc4\xa8\xcf\x86\x07\xdck\x7f\xb4\xb5_,I\x80\xe9\x83\x00*q\xce\xacA\x9a\xe3$]\xe5*\x9e\x91F\xd2\xe3P\xb8+\xa6\xc1R\xde\xf2G\xf1\x185\xcd\x8f\x82\x1a\xa4c\xf5\x9c\xd8\xe0\xd1g \xfdw\xa0\xe6\xca\xf7\x9f\xdexbf(\xa2\xd5\xdb\xd5}\xe5\xaf\x99\xf9\x90\x1cx\n\xe8\xda\x14\x9cJ\xd7\xe4\x96S',)
PRIVATE_KEY_PEM = """-----BEGIN RSA PRIVATE KEY-----
MIICXgIBAAKBgQDMYS234o1C1Z2fbeZazcUnEfspBcs06hSmvDji+Jm5Gk6tvIHl
IFFu1aCD8kBbjf2ivzmG8Dgtcn6jnLjXe3EB0H1vh70TUsvi0ZjxZsmbv6fJmJrQ
zJvW1Wi3wnoBeVYQk6ha8rbfY35wErxxdTWeWm1nSBwaFfnRFYnrkqVGlQIDAQAB
AoGBAKJZ39Ne6A/bWOa4inA/XQl4QyeHLrDN8bGxew7xpEtiFnX0dMrqLUX59RRb
b7xKwtxxQuVqFXYkqWyWpk6mBFGcRH1yH888Cgu+mSbsKvMAGOW/oTl7XLV8hc4T
m0iT/gEUsCHFcE6mstkUIEMlZCWmnuoijprDbehh1OSEZPQBAkEA1IFgXqMGIC/x
CYwrizFgJVAa/o4IF183CocfqPaYlotKCeNovnPXeSCmAX1d0GhCHKBIQmkmL7YU
TZ1DxiWL1QJBAPY2CWyA26GKGu1WzURJa7guizaqGJpghF30U5VdvdKmetYU2gXA
rhHQ9LxdjG09L9BWSxg5Y1Zl02b8f2Qf78ECQQCNr3VBpBCBhXWAmCSwOcuRFUfq
UWizrJhWPKGvVjuGpHhI/4bm9PXFnS8R7zSNr/XkgDmtjc4YIZ6H4UM+6enBAkBi
yC9jvxdfan9/NdJJUYPMc7AbEIeqeIri/0IBrYiZWX3zIo6OvE2ajFGEuau7sE7c
saKTZ4L5iQUWTrv1ufKBAkEAis4KsI4Inxz01ZPRcmPlUVKULvVqyquqsfKP+NFG
PTurYiXOc2kXPbBNxyhTDQ6Dw3OB0GhARHSGiuhQQicA2w==
-----END RSA PRIVATE KEY-----"""

def main():
    # 输入PEM 格式的私钥
    private_key = RSA.importKey(PRIVATE_KEY_PEM)
    # 解密数据
    decrypted = private_key.decrypt(DATA)
    # 输出至屏幕
    print decrypted

if __name__ == '__main__':
    main()

与加密时一样，解密也是通过 RSA.importKey 函数输入私钥并获取 RSA 对象。接下来，我们在 decrypt 方法的传值参数中指定已加密的数据进行解密。上述代码的执行结果如 LIST 15.36 所示。

LIST 15.36　执行结果

$ python rsa_decrypt.py
Hello, world!

NOTE

为了便于理解，上述例子都是直接在代码中描述公钥和私钥。实际使用时可以从密钥文件中读取密钥数据，从而提高效率。

15.5　使用 Twitter 的 API

随着 Twitter 在全世界推广，系统与 Twitter 联动的需求越来越常见。现在有不少封装了 Twitter API 的 Python 模块，这里我们以 tweepy 为例学习如何使用 tweepy 模块。tweepy 几乎涵盖了所有 Twitter API，而且能相对灵活地应对 Twitter API 自身的规格变更。下面我们用 Flask 和 tweepy 来简单做一个基于 Web 的时间轴视图。

tweepy

http://www.tweepy.org/

15.5.1　导入 tweepy

如 LIST 15.37 所示，通过 pip 命令安装 tweepy。本书使用了 tweepy 的 3.1.0 版本。

LIST 15.37　用 pip 命令安装 tweepy

$ pip install tweepy

15.5.2　添加应用与获取用户密钥

开发使用 Tiwtter 的 API 的应用时，需要将应用添加到 Twitter Application Management。下面我们来实际操作一下。如图 15.5 所示，Twitter Application Management 可以用 Twitter 账户登录。没有账户时需要新注册一个。登录后会进入 Twitter Apps 页面，该页上显示了所有已添加的应用。

Twitter Application Management

http://apps.twitter.com/

图 15.5　已添加应用的一览页面

添加新应用时，需要点击 Create New App 按钮进入添加页面，然后在该页面输入必填事项，如图 15.6 所示。

图 15.6　用于添加应用的页面

本书所用例子的输入如下。

Name（应用名） | bpbook-example
Description（应用的说明） | bpbook example
WebSite（应用的 Web 站点） | http://www.beproud.jp/
Callback URL（OAuth 认证后的回调 URL） | http://127.0.0.1:5000/callback
Yes, I agree（同意使用条款） | 勾选后同意使用条款

系统上比较重要的只有 Callback URL 的值。这里指定的是通过 Twitter 站点认证后重定向访问的应用的 URL。我们需要在之后开发的应用中实现这个 URL 的处理。输入所有项目后点击 Create your Twitter application，如果没有问题，系统会提示添加完成，并显示已添加应用的详细信息，如图 15.7 所示。

图 15.7　已添加应用的详细信息（添加完成时）

详细信息页面的 Keys and Access Tokens 标签页的 Application Settings 部分显示了 Consumer Key（用户密钥）和 Consumer Secret（用户机密）字符串。使用 Twitter 的 API 时需要用到这两个字符串以及下面即将学习的访问令牌和访问令牌机密。

Access Level 表示应用可以对用户数据做哪些操作，有 Read-only（只读）、Read and Write（可读写）、Read,Write and Access direct messages（可读写及访问私信）3 种操作可供选择。我们这里开发的时间轴视图只有读取操作，因此用 Read-only 就足够了。

15.5.3　获取访问令牌

OAuth 的访问令牌是为每一个使用应用的用户分别配发的值。可以通过已添加应用的详细信息页面为已添加应用的用户手动配发访问令牌。点击 Keys and Access Tokens 标签页下部的 Create my Access token 即可完成访问令牌的配发。点击 Regenerate My Access Token and Token Secret 可以作废已有令牌并重新配发新的。访问令牌以图 15.8 所示的字符串形式给出。我们将在应用中用到 Access Token（访问令牌）和 Access Token Secret（访问令牌机密）。

图 15.8　获取自己的访问令牌

如果应用只使用自己的 Twitter 账户，那么我们开发应用时只考虑上面获取的访问令牌即可。如果应用需要任意多个用户使用不同的 Twitter 账户，则需要使用基于 Web 的 Authorize URL 方式认证，从应用端获取访问令牌。我们即将开发的时间轴视图属于后者。

15.5.4　调用 Twitter API

用 tweepy 调用 Twitter API 时，需要用到 OAuthHandler 类和 API 类。下面，我们使用前面获取的用户密钥和访问令牌，获取 Twitter 主页上显示的时间轴，代码如 LIST 15.38 所示。

LIST 15.38　tweepy_hello.py

# coding: utf-8
from tweepy import OAuthHandler, API

# 用户密钥（本应用的）
CONSUMER_KEY = 'Consumer key'
CONSUMER_SECRET = 'Consumer secret'
# 访问令牌（bpbook 用户的）
ACCESS_TOKEN = 'Access token'
ACCESS_TOKEN_SECRET = 'Access token secret'

def main():
    # 生成OAuth 的handler
    handler = OAuthHandler(CONSUMER_KEY, CONSUMER_SECRET)
    # 给handler 设置访问令牌
    handler.set_access_token(ACCESS_TOKEN, ACCESS_TOKEN_SECRET)
    # 生成API 实例
    api = API(handler)
    # 用API 获取时间轴
    statuses = api.home_timeline()
    for status in statuses:
        # 将获取的状态的前15 个字符输出到页面上
        print "%s: %s" % (status.user.screen_name, status.text[:15])

if __name__ == '__main__':
    main()

请各位自行将上述代码中的 CONSUMER_KEY、CONSUMER_SECRET、ACCESS_TOKEN、ACCESS_TOKEN_SECRET 部分替换为前面获取的值。先在 OAuthHandler 类的构造函数中指定用户密钥和用户机密，然后用 OAuthHandler 的 set_access_token 方法给 handler 设置对应的访问令牌。在 API 类的构造函数中指定 handler 对象，让我们能够使用 API 调用。没有设置访问令牌的 handler 无法使用绝大多数的 API 调用。home_timeline 方法最多可以返回 20 条首页的时间轴信息。

上述代码的执行结果如 LIST 15.39 所示。

LIST 15.39　执行结果

$ python tweepy_hello.py
bpbook: 测试
liblar_jp: 【有关添加功能的通知】书籍相关
connpass_jp: 【有关功能修改的通知】多天以来
liblar_jp:【有关维护工作已全面结束的通知】
beproud_jp: BePROUD 公司现在就Web
（以下省略）

发生 TweepError: HTTP Error 401: Unauthorized 错误时，需要查看用户密钥和访问令牌是否有误。另外，执行环境的系统时间与实际时间不一致也会导致这个错误。

除 home_timeline 之外，还有许多方法封装了 API。详细内容可以查看 tweepy 文档的 API Reference 来了解。

tweepy API Reference

http://tweepy.readthedocs.org/en/v3.0.0/api.html

在这个例子中，我们是直接在代码中描述访问令牌（ACCESS_TOKEN、ACCESS_TOKEN_SECRET）的。对于只通过单一用户获取时间轴信息或进行发言的应用（比如 TwitterBot 等）而言，由于不需要用于认证的 UI，所以使用这种方法不会有问题。

15.5.5　编写用 Twitter 认证的系统

下面我们编写一个用 Twitter 账户登录（认证）并根据各 Twitter 账户的权限使用 API 的系统。编写这个系统中需要用到 Twitter 的认证页面，还要获取各个账户的访问令牌。从登录的起始处理到使用 API 的处理流程如下所示。

① 用户通过 Web 浏览器访问应用的登录 URL

② 应用从 Twitter 获取请求令牌

③ 应用将获取到的请求令牌保存在会话中

④ 应用向 Twitter 的认证 URL 返回带有令牌属性的重定向响应

⑤ 用户通过 Web 浏览器在 Twitter 的认证页面对应用授权

⑥ 用户根据 Twitter 端的重定向响应，通过 Web 浏览器访问应用的回调 URL（带有令牌属性）

⑦ 应用从会话中还原请求令牌

⑧ 应用从 Twitter 获取用户的访问令牌

⑨ 应用依据访问令牌使用 Twitter 的 API

使用 tweepy 可以轻松完成①的生成带属性的 URL、②的获取令牌、⑨的依据访问令牌使用 Twitter API。下面我们看一下 web 应用，它使用 Flask 和 tweepy 对 Twitter 账户进行认证，然后获取时间轴信息并使之显示在页面上，代码如 LIST 15.40 所示。

LIST 15.40　tweepy_auth.py

# coding: utf-8
from flask import Flask, render_template, session, request, redirect
from tweepy import OAuthHandler, API

application = Flask(__name__)
# 设置用于会话的密钥
application.secret_key = 'my secret key'

# 用户密钥
CONSUMER_KEY = 'Consumer key'
CONSUMER_SECRET = 'Consumer secret'

def get_access_token():
    """ 从会话获取访问令牌的函数
    """
    return session.get('access_token')

def set_access_token(access_token):
    """ 在会话中保存访问令牌的函数
    """
    session['access_token'] = access_token

def get_request_token(key):
    """ 从会话获取请求令牌的函数
    """
    return session.get(key)

def set_request_token(key, token):
    """ 在会话中保存请求令牌的函数
    """
    session[key] = token

def get_oauth_handler():
    """ 返回Tweepy 的OAuth handler 的函数
    """
    return OAuthHandler(CONSUMER_KEY, CONSUMER_SECRET)

def get_api(access_token):
    """ 指定访问令牌返回API 实例的函数
    """
    handler = get_oauth_handler()
    handler.set_access_token(access_token[0], access_token[1])
    api = API(handler)
    return api

def set_user(user):
    """ 在会话中保存用户信息的函数
    """
    # 以可JSON 序列化的字典对象格式保存在会话中
    session['user'] = {'screen_name': user.screen_name}

def get_user():
    """ 从会话获取用户信息的函数
    """
    return session.get('user')

def is_login():
    """ 返回是否为登录状态
    """
    return not not get_access_token()

def clear_session():
    """ 删除会话的值
    """
    session.clear()

@application.route('/')
    def index():
    """ 首页
    使用模板显示页面
    """
    # 根据模板显示页面
    return render_template('index.html', is_login=is_login(), user=get_user())

@application.route('/login')
def login():
    """ 登录URL
    开始Twitter 认证
    """
    handler = get_oauth_handler()
    # 获取认证URL
    auth_url = handler.get_authorization_url()
    # 请求令牌保存在会话中
    set_request_token(
        handler.request_token['oauth_token'],
        handler.request_token['oauth_token_secret'])
    # 重定向到认证URL
    return redirect(auth_url)

@application.route('/callback')
    def callback():
    """ 回调URL
    Twitter 认证后的回调URL
    """
    # 用GET 方法获取OAuth 的回调属性
    oauth_token = request.args.get('oauth_token')
    oauth_verifier = request.args.get('oauth_verifier')
    # 取消时重定向到首页
    if not oauth_token and not oauth_verifier:
        return redirect('/')
    # 获取OAuth 的handler
    handler = get_oauth_handler()
    # 从会话获取请求令牌并设置给handler
    request_token = get_request_token(oauth_token)
    handler.request_token = {
        'oauth_token': oauth_token,
        'oauth_token_secret': request_token,
    }
    # 获取访问令牌
    access_token = handler.get_access_token(oauth_verifier)
    # 将访问令牌保存在会话中
    set_access_token(access_token)
    # 获取API 实例
    api = get_api(access_token)
    # 获取登录用户的信息并保存至会话
    set_user(api.me())
    # 重定向到首页
    return redirect('/')

@application.route('/logout')
def logout():
    """ 登出URL
    从会话中删除登录状态的信息
    """
    # 删除会话中的登录信息
    clear_session()
    # 重定向到首页
    return redirect('/')

@application.route('/timeline')
def timeline():
    """ 显示时间轴信息的页面
    需要认证
    """
    # 获取登录状态
    if not is_login():
        # 没有登录则重定向到首页
        return redirect('/')
    # 从会话获取访问令牌
    access_token = get_access_token()
    if not access_token:
        # 无法获取访问令牌时
        # 清除会话并重新开始
        clear_session()
        return redirect('/')
    # 获取API 实例
    api = get_api(access_token)
    # 调用Twitter API 获取时间轴信息
    statuses = api.home_timeline()
    # 通过模板显示页面
    return render_template('timeline.html', statuses=statuses)

if __name__ == '__main__':
    # 通过IP 地址127.0.0.1 的5000 端口执行应用
    application.run('127.0.0.1', 5000, debug=True)

其中，CONSUMER_KEY、CONSUMER_SECRET 的值需要替换成之前为应用获取的值。在上面的例子中，我们用 Flask 的会话保存了请求令牌。该段代码使用了 index.html 和 timeline.html 两个模板文件，这两个文件保存在 templates 目录下，其内容如 LIST 15.41 所示。

LIST 15.41　templates/index.html

<html>
  <head>
    <meta http-equiv="Content-type" content="text/html; charset=utf-8" />
    <title>Twitter 认证</title>
  </head>
  <body>
    {% if is_login %}
      <p> 登录名: {{ user.screen_name }}</p>
      <ul>
        <li><a href="/timeline"> 获取时间轴信息</a></li>
        <li><a href="/logout"> 登出</a></li>
      </ul>
    {% else %}
      <ul>
        <li><a href="/login"> 登录</a></li>
      </ul>
    {% endif %}
  </body>
</html>

index.html 在未认证状态下显示开始认证处理的 URL 链接，在已认证状态下则显示 Twitter 的昵称（登录名）、获取时间轴信息的处理以及登出处理的链接（LIST 15.42）。

LIST 15.42　templates/timeline.html

<html>
  <head>
    <meta http-equiv="Content-type" content="text/html; charset=utf-8" />
    <title> 时间轴信息的显示</title>
  </head>
  <body>
    <dl>
      {% for status in statuses %}
        <dt>{{ status.user.screen_name }}</dt>
        <dd>{{ status.text }}</dd>
      {% endfor %}
    </dl>
  </body>
</html>

timeline.html 用来显示获取到的时间轴信息（状态）。准备好源码及模板后，用 python 命令执行 tweepy_auth.py 启动服务器，代码如 LIST 15.43 所示。

LIST 15.43　用 python 命令执行 tweepy_auth.py

$ python tweepy_auth.py
 * Running on http://127.0.0.1:5000/
 * Restarting with reloader

NOTE

本例是用 5000 端口启动开发服务器的，因此请保证 SSH 隧道的主 OS 与客 OS 的 5000 端口相连接。

启动服务器后，打开 Web 浏览器试着访问 http://127.0.0.1:5000/。刚开始时我们处于未登录状态，所以会显示有登录链接的页面。点击登录连接，随后将跳转至 Twitter 的认证页面，我们会看到如图 15.9 所示的应用授权请求信息以及按钮。

图 15.9　Twitter 的应用授权页面

点击页面中的授权按钮之后，页面将重定向到我们添加应用时填写的 Callback URL，处理也将返回到应用端。获取访问令牌和个人信息后，应用显示带有用户名、获取时间轴信息链接以及登出链接的登录状态界面。我们点击获取时间轴信息的链接之后，应用会使用用户的访问令牌获取时间轴信息并显示在页面上。至此，时间轴视图的应用开发完毕。

NOTE

这里介绍的时间轴视图是将访问令牌保存了在会话中，会话一旦过期就需要重新获取令牌。我们可以通过将访问令牌存放在数据库中来避免这一问题。

15.6　使用REST API

系统间协作及使用外部服务时，经常会用 REST API 作接口。REST API 可以用 Python 标准模块 urllib 和 json 来控制，不过本书要讲的是 Requests 的使用方法。

Requests: HTTP for Humans

http://docs.python-requests.org/en/latest/

15.6.1　REST 简介

REST（Representational state transfer，具象状态传输 1）是一种软件架构。它于 2000 年由 Roy Fielding 提出，是几种软件设计原则的集合。

1有时也被译为表述性状态转移或表述性状态传输等。——译者注

不过，现在人们广泛使用的 REST 和 REST API 通常指“在 HTTP 上运行的非 SOAP 非 RPC 的 API”。

REST API 的特征如下。

在 HTTP 上运行

数据被称为“资源”

REST API 可使用的资源具有唯一的 URL

GET/POST/PUT/DELETE 等 HTTP 方法分别对应资源的获取 / 保存 / 覆盖 / 删除等操作

通过 JSON、XML 等格式收发数据

请求成功、请求失败等处理结果体现在状态代码中

如果要使用 REST API，则需要用到 HTTP 客户端。在 shell 命令或 shell 脚本中使用时要用 curl，在程序中使用时则要用 HTTP 客户端程序库。

REST - Wikipedia

http://zh.wikipedia.org/wiki/REST

Fielding Dissertation CHAPTER 5 Representational State Transfer (REST)

http://www.ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm

15.6.2　导入 Requests

如 LIST 15.44 所示，用 pip 命令安装 Requests。本书使用的是 Requests 的 2.5.0 版本。

LIST 15.44　用 pip 命令安装 Requests

$ pip install requests

15.6.3　导入测试服务器

要检查 Requests 模块的运行状况就必须使用 Web 服务器。httpbin 模块是基于 Python 开发的 HTTP 测试服务器。本书将用 httpbin 模块作为测试服务器来讲解 Requests 的使用方法。

如 LIST 15.45 所示，用 pip 命令安装 httpbin。本书使用的是 httpbin 的 0.2.0 版本。

LIST 15.45　用 pip 命令安装 httpbin

$ pip install httpbin

httpbin 的测试服务器用 LIST 15.46 中的命令启动。

LIST 15.46　启动 httpbin 的测试服务器

$ python -m httpbin.core
 * Running on http://127.0.0.1:5000/

在导入 Requests 模块之前，先用 curl 命令检查测试服务器的运行状况。

在测试服务器已启动的状态下运行 LIST 15.47。如果运行正常，屏幕上将显示如下所示的 JSON 格式报告。

LIST 15.47　用 curl 命令检查测试服务器的运行状况

$ curl "http://127.0.0.1:5000/get?foo=bar"  # 向测试服务器发送GET 请求
{
  "args": {  # args 为GET 参数
    "foo": "bar"
  },
  "headers": {
    "Accept": "*/*",
    "Content-Length": "",
    "Content-Type": "",
    "Host": "127.0.0.1:5000",
    "User-Agent": "curl/7.35.0"
  },
  "origin": "127.0.0.1",
  "url": "http://127.0.0.1:5000/get?foo=bar"
}
$ curl -X post -d foo=bar http://127.0.0.1:5000/post  # 向测试服务器发送POST 请求
{
  "args": {},
  "data": "",  # data 为发送数据的正文（未经过编码的表单数据除外）
  "files": {},  # files 为被上传的文件
  "form": {  # form 为经过编码的表单数据
    "foo": "bar"
  },
  "headers": {
    "Accept": "*/*",
    "Content-Length": "7",
    "Content-Type": "application/x-www-form-urlencoded",
    "Host": "127.0.0.1:5000",
    "User-Agent": "curl/7.35.0"
  },
  "json": null,
  "origin": "127.0.0.1",
  "url": "http://127.0.0.1:5000/post"
}

可以看出，测试服务器具有将请求的内容以 JSON 格式返回的功能。

NOTE

在 Windows 上使用curl 命令时需要安装命令工具。

cURL - Download

http://curl.haxx.se/download.html

15.6.4　发送GET 请求

用 Requests 发送 GET 请求，具体代码如 LIST 15.48 所示。

LIST 15.48　requests_get.py

# coding: utf-8
import pprint
import requests

def main():
    # GET 参数以字典形式通过params 传值参数指定
    response = requests.get(
        'http://127.0.0.1:5000/get',
        params={'foo': 'bar'})
    # 使用响应对象的json 方法可以获取转换为Python 字典对象的JSON 数据
    pprint.pprint(response.json())

if __name__ == '__main__':
    main()

在 requests.get 函数中指定对象 URL，用 params 关键字传值参数指定 GET 参数。服务器返回的响应为 JSON 格式，因此要用 json 方法将其转换为字典形式再显示到页面上。上述代码的执行结果如 LIST 15.49 所示。

LIST 15.49　执行结果

$ python requests_get.py
{u'args': {u'foo': u'bar'},
 u'headers': {u'Accept': u'*/*',
              u'Accept-Encoding': u'gzip, deflate',
              u'Connection': u'keep-alive',
              u'Content-Length': u'',
              u'Content-Type': u'',
              u'Host': u'127.0.0.1:5000',
              u'User-Agent': u'python-requests/2.5.0 CPython/2.7.8 Linux/3.13.0-35-generic'},
 u'origin': u'127.0.0.1',
 u'url': u'http://127.0.0.1:5000/get?foo=bar'}

15.6.5　发送 POST 请求

用 Requests 发送 POST 请求，具体代码如 LIST 15.50 所示。

LIST 15.50　requests_post.py

# coding: utf-8
import pprint
import requests

def main():
    # POST 参数以字典形式通过第二个传值参数指定
    response = requests.post(
        'http://127.0.0.1:5000/post',
        {'foo': 'bar'})
    # 使用响应对象的json 方法可以获取转换为Python 字典对象的JSON 数据
    pprint.pprint(response.json())

if __name__ == '__main__':
    main()

request.post 函数中指定了对象 URL 和 POST 参数。给 POST 参数指定的字典会被编码为 URL 发送出去。这段代码的执行结果如 LIST 15.51 所示。

LIST 15.51　执行结果

$ python requests_post.py
{u'args': {},
 u'data': u'',
 u'files': {},
 u'form': {u'foo': u'bar'},
 u'headers': {u'Accept': u'*/*',
              u'Accept-Encoding': u'gzip, deflate',
              u'Connection': u'keep-alive',
              u'Content-Length': u'7',
              u'Content-Type': u'application/x-www-form-urlencoded',
              u'Host': u'127.0.0.1:5000',
              u'User-Agent': u'python-requests/2.5.0 CPython/2.7.8 Linux/3.13.0-35-generic'},
 u'json': None,
 u'origin': u'127.0.0.1',
 u'url': u'http://127.0.0.1:5000/post'}

15.6.6　发送 JSON 格式的 POST 请求

有些 API 接口要求直接发送 JSON 格式的字符串，不能将数据编码为 URL。用 Requests 以 JSON 格式字符串的形式发送 POST 请求，具体代码如 LIST 15.52 所示。

LIST 15.52　requests_post_json.py

# coding: utf-8
import pprint
import json
import requests

def main():
    # 指定json.dumps 生成的字符串之后，可以直接发送数据而不进行URL 编码
    # 需要明确指定Content-Tpye
    response = requests.post(
        'http://127.0.0.1:5000/post',
        json.dumps({'foo': 'bar'}),
        headers={'Content-Type': 'application/json'})
    pprint.pprint(response.json())

if __name__ == '__main__':
    main()

post 函数的传值参数中指定的发送数据为字符串。传值参数指定字符串时会直接发送数据，不进行 URL 编码。这段代码的执行结果如 LIST 15.53 所示。

LIST 15.53　执行结果

$ python requests_post_json.py
{u'args': {},
 u'data': u'{"foo": "bar"}',
 u'files': {},
 u'form': {},
 u'headers': {u'Accept': u'*/*',
              u'Accept-Encoding': u'gzip, deflate',
              u'Connection': u'keep-alive',
              u'Content-Length': u'14',
              u'Content-Type': u'application/json',
              u'Host': u'127.0.0.1:5000',
              u'User-Agent': u'python-requests/2.5.0 CPython/2.7.8 Linux/3.13.0-35-generic'},
 u'json': {u'foo': u'bar'},
 u'origin': u'127.0.0.1',
 u'url': u'http://127.0.0.1:5000/post'}

15.6.7　使用 GET/POST 之外的 HTTP 方法

某些 API 还会用到 GET、POST 之外的 HTTP 方法（PUT、DELETE、HEAD、OPTIONS）。Requests 同样为这些方法准备了对应的函数，代码如 LIST 15.54 所示。

LIST 15.54　requests_other_methods.py

# coding: utf-8
import requests

def main():
    requests.put('http://127.0.0.1:5000/put', {'foo': 'bar'})  # PUT
    requests.delete('http://127.0.0.1:5000/delete')  # DELETE
    requests.head('http://127.0.0.1:5000/get')  # HEAD
    requests.options('http://127.0.0.1:5000/options')  # OPTIONS

if __name__ == '__main__':
    main()

执行了这段代码之后，程序会向服务器发送 HTTP 的 PUT、DELETE、HEAD、OPTIONS 方法的请求，获取响应后执行结束。

可见，用 Requests 可以轻松使用以 HTTP 为接口的 REST API。

15.7　小结

在开发应用的过程中，我们多少都会遇到些难题，而方便好用的模块往往是解决难题的方法之一。本章介绍了几个开发中的难题以及解决相应难题的 Python 模块。希望各位多多利用本书以及其他各种信息来源，收集难题的解决方案以及好用模块的信息。多了解一些可以拿来就用的模块是大幅缩短开发时间的有效途径。

附录

附录 A　VirtualBox 的设置

附录 B　OS（Ubuntu）的设置

附录 A　VirtualBox 的设置

近年来，PC 的虚拟化技术不断发展，个人已经能够轻松地搭建并使用虚拟机了。随着虚拟机个人使用的简化，越来越多的开发者也开始利用虚拟化软件搭建本地开发环境。这里我们学习一下将 Oracle 的 VirtualBox 设置为虚拟机的流程。VirtualBox 是一款支持 Windows、OS X、 Linux 的免费虚拟化软件。

这里我们将本地计算机称为主 OS，虚拟机称为客 OS。主 OS 端的系统以 OS X 为例进行说明。

A.1　安装VirtualBox

首先从 VirtualBox 的下载页面 1 下载适合主 OS 的 VirtualBox。本书使用了编写时（2014 年 11 月）的最新版本 VirtualBox 4.3.18（图 A.1）。

1https://www.virtualbox.org/wiki/Downloads

图 A.1　下载 VirtualBox

下载完成之后按照各 OS 的安装向导进行安装。出现图 A.2 所示的启动界面时，表示 VirtualBox 安装完毕。

图 A.2　VirtualBox 的启动界面

A.2　新建虚拟机

接下来在 VirtualBox 上新建虚拟机。点击 VirtualBox 的“新建”按钮（图 A.3）。

图 A.3　创建虚拟机

本例中的 OS 类型的配置如下。

操作系统：Linux

版本：Ubuntu（64 bit）

图 A.3 中设置了 64 bit 版的 Ubuntu，各位可按照自己的客 OS 进行更改。VirtualBox 的版本有 Ubuntu 和 Ubuntu（64 bit）可选。点击下一步之后系统会询问下述内容，各位请按照自己的环境进行设置。

内存

虚拟硬盘

虚拟盘的文件类型

虚拟盘的容量设置完成后，一个空的虚拟机镜像就创建好了（图 A.4）。

图 A.4　虚拟机新建完毕

A.3　备份虚拟机

在阅读本书的过程中，各位会遇到许多服务器设置的变更。在修改设置时，难免出现设置错误甚至设置损坏，导致虚拟机无法使用。为防备这种情况发生，我们要为当前已设置完毕的虚拟机做好备份，以便出了问题能从头再来。

接下来学习一下如何备份虚拟机以及如何从备份还原。使用 VirtualBox 备份虚拟机的方法有许多，这里我们选其中比较简单的“虚拟电脑的导出与导入”进行学习。

这里提到的虚拟电脑是对包含设置在内的所有文件进行备份的机制。该机制将备份数据以 OVA（Open Virtualization Format Archive）文件的形式保存，当我们的虚拟机出现问题时，只要有这个文件，就可以在 VirtualBox 上将虚拟机恢复到导出时的状态。如果只是想临时保存虚拟机的状态，还可以使用 VirtualBox 的“快照”功能。

备份前先关闭客 OS。关闭后依次选择“VirtualBox > 管理 > 导出虚拟电脑”。点击导出虚拟电脑后会出现图 A.5 所示的界面，我们可以在该界面中选择虚拟机。选择完毕后点击下一步。

图 A.5　导出虚拟电脑（1）

接下来选择导出到的位置，再点击下一步开始导出。

图 A.6　导出虚拟电脑（2）

这样，我们就完成了虚拟电脑的导出（图 A.6）。

导入也很简单，只要依次选择“VirtualBox > 管理 > 导入虚拟电脑”，选择之前导出的 ova 文件进行导入，我们就又能使用该设置下的虚拟机了。VirtulBox 的设置就了解到这里。接下来，在附录 B 中，我们将学习如何在新建的虚拟机镜像中安装 OS（Ubuntu）。

附录 B　OS（Ubuntu）的设置

我们在附录 A 中已经准备好了环境，现在来了解一下安装客 OS（Ubuntu）的流程。

B.1　安装Ubuntu

首先从下述 URL 下载 Ubuntu 的 CD 镜像（图 B.1）。Ubuntu 为多种执行环境准备了相应的镜像，本书使用的是 Server 版。

http://www.ubuntu.com/download/server

图 B.1　下载 Ubuntu

这里我们使用的版本是 ubuntu-14.04.1-server-amd64.iso。下载完成之后启动之前创建好的虚拟机。初次启动时会显示如图 B.2 所示的安装媒体选择界面。

图 B.2　VirtualBox 初次启动窗口

这里选择刚刚下载的 CD 镜像并继续。之后界面上会显示启动按钮，点击按钮实际启动 OS 即可。

安装包启动后，进行 Ubuntu 的安装设置。

图 B.3　Ubuntu 的安装界面

首先是语言设置，最开始请选择 English 并按 Enter 键（图 B.3）。一开始选择中文会导致在 VirtualBox 上运行的控制台显示乱码，所以语言问题等到后面再作调整。

图 B.4　Ubuntu 的安装准备

设置完语言之后选择 Install Ubuntu Server 进入下一步（图 B.4）。

图 B.5　Ubuntu Select a language

如图 B.6 ～图 B.8 所示，安装开始后，系统将询问“选择当前所在地”和“键盘设置”，各位请根据自己的情况进行设置。

图 B.6　Ubuntu Select your location

图 B.7　Ubuntu Configure locales

图 B.8　Ubuntu Configure the keyboard

设置完成后，安装就会正式开始。接下来会显示网络的设置。如图 B.9 所示输入 Hostname 并进入下一步。

图 B.9　Ubuntu Configure the network

根据安装进度，接下来会进入创建账户的界面，这里要设置“用户名”和“密码”。这里我们设置一个名为 bpbook 的用户（图 B.10）。

图 B.10　Ubuntu Set up users and passwords

接下来是时区的设置，系统会自动为我们推测当前时区。确认无误后点击 Yes（图 B.11）。

图 B.11　Ubuntu Configure the clock

然后是硬盘分区的设置。选择 Guided - use entire disk and set up LVM 并继续（图 B.12）。

图 B.12　Ubuntu Partition disks（1）

随后的分区设置会询问 Write the changes to disk and configure LVM，这里选择 Yes（图 B.13）。随后还会出现几次询问，基本上都选 Yes 就可以了。

图 B.13　Ubuntu Partition disks（2）

如果是网络连接受限的环境，建议设置 HTTP 代理以保证程序包管理器可以访问网络。不受限制的情况下则可以直接选择 Continue 继续安装（图 B.14）。

图 B.14　Ubuntu Configure the package manager

与安全更新相关的问题要选择 Install security updates automatically（图 B.15）。OS 开发每天都在进行，其中包括很多重要的安全更新，因此我们需要选择这个选项，以保证 OS 能持续安全运行。

图 B.15　Ubuntu Configuring tasksel

由于 Ubuntu Server 版是精简安装，所以这个阶段并没有安装任何软件。这里我们只勾选 OpenSSH server 并继续（图 B.16）。选择由空格键完成。

图 B.16　Ubuntu Software Selection

最后会询问是否安装 GRUB 引导加载程序，这里选择 Yes 并继续（图 B.17）。

图 B.17　Ubuntu Install the GRUB boot loader on a hard disk

之后只要等安装进度条走完，我们就完成客 OS 的安装了。安装完毕之后会要求重启，所以我们这里立刻重新启动（图 B.18）。

图 B.18　Ubuntu Finish the installation

客 OS 重启后将显示如图 B.19 所示的登录界面。用前面创建的用户登录即可。

图 B.19　Ubuntu 的登录界面

B.2　SSH 的设置

接下来要让主 OS 通过 SSH 连接虚拟机上的客 OS。我们在图 B.16 所示的步骤中已经安装了 OpenSSH server，因此不必再另行安装了。现在在客 OS 的控制台执行下述命令，检查 ssh 模块是否已经启动（LIST 1）。

LIST 1　查看进程

$ ps aux | grep sshd

如果进程列表中有 /usr/sbin/sshd -D，就表示 SSH 守护进程已经启动。

NOTE

如果前面忘记安装 OpenSSH server，则需要通过 apt-get 等命令手动安装。

$ sudo apt-get install ssh

现在 SSH 已经准备就绪，可以从主 OS 通过 SSH 登录客 OS 了。VirtualBox 提供了多种连接方法，这里我们用 NAT 连接的端口转发机制，将主 OS 的 2222 端口与客 OS 的 SSH 端口（22）连接起来。

从 VirtualBox 管理界面选择“设置 > 网络 > 网卡 1 > 端口转发”，然后如图 B.20 进行设置。

图 B.20　VirtualBox 端口转发的设置

如图 B.20 设置完毕后，在主 OS 的控制台上输入 LIST 2 所示的命令。

LIST 2　SSH 连接

$ ssh -p 2222 bpbook@127.0.0.1
The authenticity of host '[127.0.0.1]:2222 ([127.0.0.1]:2222)' can't be established.
RSA key fingerprint is d0:e8:9f:96:8e:24:0d:96:dc:0b:51:fb:7f:b7:1b:f0.
Are you sure you want to continue connecting (yes/no)? yes
Warning: Permanently added '[127.0.0.1]:2222' (RSA) to the list of known hosts.
bpbook@127.0.0.1's password:

NOTE

这里假定主 OS 为 OS X，因此使用的是 ssh 命令。而 Windows 没有 ssh 命令，因此需要使用其他软件（例如PuTTY1）进行 SSH 连接。

1http://www.chiark.greenend.org.uk/~sgtatham/putty/

@ 前的 bpbook 是安装 Ubuntu 时创建的用户名，各位请替换成自己的用户名。初次连接时系统会询问 Are you sure you want to continue connecting (yes/no)?，这里输入 yes。随后系统还会要求输入密码，此时输入创建用户时设置的密码即可。顺利登录客 OS 后将显示图 B.21 所示的内容。

图 B.21　主 OS 通过 SSH 连接客 OS

NOTE

一般说来，通过 SSH 连接服务器时只校验密码并不安全。本节内容面向的是个人开发环境的搭建，因此只讲了用密码登陆服务器的方法。要想搭建更加安全的环境，建议使用“公钥加密系统”进行认证。

公钥加密https://zh.wikipedia.org/wiki/%E5%85%AC%E5%BC%80%E5%AF%86%E9%92%A5%E5%8A%A0%E5%AF%86

B.3　中文的设置

LIST 3　安装中文语言包

sudo apt-get install language-pack-zh-hans
sudo update-locale LANG=zh_CN.UTF-8

执行 LIST 3 中的命令后即可安装中文的 locale 文件并完成设置。安装完成之后在 login-shell 的设置文件（.bashrc）中设置下述环境变量（LIST 4）。

LIST 4　在 .bashrc 中设置用于设定语言的环境变量

export LANG="zh_CN.UTF-8"
export LANGUAGE="zh_CN:zh"

设置完成之后，用 source 命令反映设置（LIST 5）。

LIST 5　反映 .bashrc 的设置

$ source ~/.bashrc

到这里，中文就设置完毕了。以后再启动支持中文的 Vim 等程序时，可以看到信息都是以中文显示的。

B.4　添加用户

用于开发的服务器上常要运行许多应用，但从安全角度讲，最好不要用 root 用户来运行它们。

所以，我们需要根据应用的开发进度，设置能够通过 sudo 命令修改服务器和中间件设置的组以及用户。这里我们以添加 dev 组和 bpuser 用户为例进行学习。

首先用 bpbook 用户登录并创建组。如 LIST 6 所示，以 root 权限执行 groupadd 命令来创建 dev 组。

LIST 6　添加组

$ sudo groupadd dev

接下来创建用户。用 adduser 命令创建 bpuser 用户。此时用 --ingroup 选项指定 dev 可以让新用户加入 dev 组（LIST 7）。

LIST 7　添加用户

$ sudo adduser bpuser --ingroup dev

这样一来就创建了 bpuser 用户。需要删除用户时使用 userdel 命令进行删除（LIST 8）。

LIST 8　删除用户

$ sudo userdel -r bpuser

Ubuntu 14.04 默认有 sudo 组，被添加到这个组的用户均可以使用 sudo 命令。

LIST 9　向组添加用户

$ sudo usermod -aG sudo bpuser

不过在目前的设置下，dev 组的用户每次使用 sudo 命令都要输一遍密码，这必然影响开发环境的便捷性。因此我们要让 dev 组的用户在执行 sudo 命令时免去输入密码的麻烦。

我们在 /etc/sudoers.d/ 目录下创建一个文件，并给 dev 组设置权限。先执行下述 visudo 命令（LIST 10）。

LIST 10　执行 visudo

$ sudo visudo -f /etc/sudoers.d/dev

NOTE

对 sudo 进行个别设置时，尽量不要编辑原设置文件 /etc/sudoers，最好是如上所述在 /etc/sudoers.d/ 下放置设置文件。因为在升级 OS 等时容易无意中覆盖掉 /etc/sudoers 文件，导致所有账户都无法使用 sudo 命令。这样做可以免除上述顾虑。

执行 visudo 命令后会生成 /etc/sudoers.d/dev 文件。对该文件作如下描述。

LIST 11　sudo 设置示例

%dev ALL=(ALL) NOPASSWD:ALL

按照 LIST 11 所示设置之后，dev 组的所有用户就都可以不输入密码直接执行 sudo 命令了。

NOTE

本节，我们站在本地开发环境的角度，学习了如何设置无需密码执行 sudo 命令，以维持开发环境的便捷性。

但是，sudo 命令可以使用 root 权限对服务器进行操作，一旦用户被窃取，带来的损害将会非常大。这个风险我们一定要清楚。

因此，如果我们使用的是可通过互联网访问的共享服务器、正式运营的服务器，那么当我们考虑设置“无需密码执行 sudo 命令”时，一定要慎之又慎。

看完了

如果您对本书内容有疑问，可发邮件至contact@turingbook.com，会有编辑或作译者协助答疑。也可访问图灵社区，参与本书讨论。

如果是有关电子书的建议或问题，请联系专用客服邮箱：ebook@turingbook.com。

在这里可以找到我们：

微博 @图灵教育 : 好书、活动每日播报

微博 @图灵社区 : 电子书和好文章的消息

微博 @图灵新知 : 图灵教育的科普小组

微信 图灵访谈 : ituring_interview，讲述码农精彩人生

微信 图灵教育 : turingbooks 　　

091507240605ToBeReplacedWithUserId

版权信息

书名：Python语言及其应用

作者：[美] Bill Lubanovic

译者：丁嘉瑞　梁杰　禹常隆

ISBN：978-7-115-40709-2

本书由北京图灵文化发展有限公司发行数字版。版权所有，侵权必究。

您购买的图灵电子书仅供您个人使用，未经授权，不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟，与我们共同保护知识产权。

如果购买者有侵权行为，我们可能对该用户实施包括但不限于关闭该帐号等维权措施，并可能追究法律责任。

091507240605ToBeReplacedWithUserId

版权声明

O'Reilly Media, Inc. 介绍

业界评论

前言

目标读者

本书结构

Python版本

排版约定

使用代码示例

Safari® Books Online

联系我们

致谢

第 1 章　Python 初探

1.1　真实世界中的Python

1.2　Python与其他语言

1.3　为什么选择Python

1.4　何时不应该使用Python

1.5　Python 2与Python 3

1.6　安装Python

1.7　运行Python

1.7.1　使用交互式解释器

1.7.2　使用Python文件

1.7.3　下一步

1.8　禅定一刻

1.9　练习

第 2 章　Python 基本元素：数字、字符串和变量

2.1　变量、名字和对象

2.2　数字

2.2.1　整数

2.2.2　优先级

2.2.3　基数

2.2.4　类型转换

2.2.5　一个int型有多大

2.2.6　浮点数

2.2.7　数学函数

2.3　字符串

2.3.1　使用引号创建

2.3.2　使用str()进行类型转换

2.3.3　使用\转义

2.3.4　使用+拼接

2.3.5　使用*复制

2.3.6　使用[]提取字符

2.3.7　使用[start:end:step]分片

2.3.8　使用len()获得长度

2.3.9　使用split()分割

2.3.10　使用join()合并

2.3.11　熟悉字符串

2.3.12　大小写与对齐方式

2.3.13　使用replace()替换

2.3.14　更多关于字符串的内容

2.4　练习

第 3 章　Python 容器：列表、元组、字典与集合

3.1　列表和元组

3.2　列表

3.2.1　使用[]或list()创建列表

3.2.2　使用list()将其他数据类型转换成列表

3.2.3　使用[offset]获取元素

3.2.4　包含列表的列表

3.2.5　使用[offset]修改元素

3.2.6　指定范围并使用切片提取元素

3.2.7　使用append()添加元素至尾部

3.2.8　使用extend()或+=合并列表

3.2.9　使用insert()在指定位置插入元素

3.2.10　使用del删除指定位置的元素

3.2.11　使用remove()删除具有指定值的元素

3.2.12　使用pop()获取并删除指定位置的元素

3.2.13　使用index()查询具有特定值的元素位置

3.2.14　使用in判断值是否存在

3.2.15　使用count()记录特定值出现的次数

3.2.16　使用join()转换为字符串

3.2.17　使用sort()重新排列元素

3.2.18　使用len()获取长度

3.2.19　使用=赋值，使用copy()复制

3.3　元组

3.3.1　使用()创建元组

3.3.2　元组与列表

3.4　字典

3.4.1　使用{}创建字典

3.4.2　使用dict()转换为字典

3.4.3　使用[key]添加或修改元素

3.4.4　使用update()合并字典

3.4.5　使用del删除具有指定键的元素

3.4.6　使用clear()删除所有元素

3.4.7　使用in判断是否存在

3.4.8　使用[key]获取元素

3.4.9　使用keys()获取所有键

3.4.10　使用values()获取所有值

3.4.11　使用items()获取所有键值对

3.4.12　使用=赋值，使用copy()复制

3.5　集合

3.5.1　使用set()创建集合

3.5.2　使用set()将其他类型转换为集合

3.5.3　使用in测试值是否存在

3.5.4　合并及运算符

3.6　比较几种数据结构

3.7　建立大型数据结构

3.8　练习

第 4 章　Python 外壳：代码结构

4.1　使用#注释

4.2　使用\连接

4.3　使用if、elif和else进行比较

什么是真值（True）

4.4　使用while进行循环

4.4.1　使用break跳出循环

4.4.2　使用continue跳到循环开始

4.4.3　循环外使用else

4.5　使用for迭代

4.5.1　使用break跳出循环

4.5.2　使用continue跳到循环开始

4.5.3　循环外使用else

4.5.4　使用zip()并行迭代

4.5.5　使用range()生成自然数序列

4.5.6　其他迭代方式

4.6　推导式

4.6.1　列表推导式

4.6.2　字典推导式

4.6.3　集合推导式

4.6.4　生成器推导式

4.7　函数

4.7.1　位置参数

4.7.2　关键字参数

4.7.3　指定默认参数值

4.7.4　使用*收集位置参数

4.7.5　使用**收集关键字参数

4.7.6　文档字符串

4.7.7　一等公民：函数

4.7.8　内部函数

4.7.9　闭包

4.7.10　匿名函数：lambda()函数

4.8　生成器

4.9　装饰器

4.10　命名空间和作用域

名称中_和 __ 的用法

4.11　使用try和except处理错误

4.12　编写自己的异常

4.13　练习

第 5 章　Python 盒子：模块、包和程序

5.1　独立的程序

5.2　命令行参数

5.3　模块和import语句

5.3.1　导入模块

5.3.2　使用别名导入模块

5.3.3　导入模块的一部分

5.3.4　模块搜索路径

5.4　包

5.5　Python标准库

5.5.1　使用setdefault()和defaultdict()处理缺失的键

5.5.2　使用Counter()计数

5.5.3　使用有序字典OrderedDict()按键排序

5.5.4　双端队列：栈+队列

5.5.5　使用itertools迭代代码结构

5.5.6　使用pprint()友好输出

5.6　获取更多Python代码

5.7　练习

第 6 章　对象和类

6.1　什么是对象

6.2　使用class定义类

6.3　继承

6.4　覆盖方法

6.5　添加新方法

6.6　使用super从父类得到帮助

6.7　self的自辩

6.8　使用属性对特性进行访问和设置

6.9　使用名称重整保护私有特性

6.10　方法的类型

6.11　鸭子类型

6.12　特殊方法

6.13　组合

6.14　何时使用类和对象而不是模块

命名元组

6.15　练习

第 7 章　像高手一样玩转数据

7.1　文本字符串

7.1.1　Unicode

7.1.2　格式化

7.1.3　使用正则表达式匹配

7.2　二进制数据

7.2.1　字节和字节数组

7.2.2　使用struct转换二进制数据

7.2.3　其他二进制数据工具

7.2.4　使用binascii()转换字节/字符串

7.2.5　位运算符

7.3　练习

第 8 章　数据的归宿

8.1　文件输入/输出

8.1.1　使用write()写文本文件

8.1.2　使用read()、readline()或者readlines()读文本文件

8.1.3　使用write()写二进制文件

8.1.4　使用read()读二进制文件

8.1.5　使用with自动关闭文件

8.1.6　使用seek()改变位置

8.2　结构化的文本文件

8.2.1　CSV

8.2.2　XML

8.2.3　HTML

8.2.4　JSON

8.2.5　YAML

8.2.6　安全提示

8.2.7　配置文件

8.2.8　其他交换格式

8.2.9　使用pickle序列化

8.3　结构化二进制文件

8.3.1　电子数据表

8.3.2　层次数据格式

8.4　关系型数据库

8.4.1　SQL

8.4.2　DB-API

8.4.3　SQLite

8.4.4　MySQL

8.4.5　PostgreSQL

8.4.6　SQLAlchemy

8.5　NoSQL数据存储

8.5.1　dbm family

8.5.2　memcached

8.5.3　Redis

8.5.4　其他的NoSQL

8.6　全文数据库

8.7　练习

第 9 章　剖析 Web

9.1　Web客户端

9.1.1　使用telnet进行测试

9.1.2　Python的标准Web库

9.1.3　抛开标准库：requests

9.2　Web服务端

9.2.1　最简单的Python Web服务器

9.2.2　Web服务器网关接口

9.2.3　框架

9.2.4　Bottle

9.2.5　Flask

9.2.6　非Python的Web服务器

9.2.7　其他框架

9.3　Web服务和自动化

9.3.1　webbrowser模块

9.3.2　Web API和表述性状态传递

9.3.3　JSON

9.3.4　抓取数据

9.3.5　用BeautifulSoup来抓取HTML

9.4　练习

第 10 章　系统

10.1　文件

10.1.1　用open()创建文件

10.1.2　用exists()检查文件是否存在

10.1.3　用isfile()检查是否为文件

10.1.4　用copy()复制文件

10.1.5　用rename()重命名文件

10.1.6　用link()或者symlink()创建链接

10.1.7　用chmod()修改权限

10.1.8　用chown()修改所有者

10.1.9　用abspath()获取路径名

10.1.10　用realpath()获取符号的路径名

10.1.11　用remove()删除文件

10.2　目录

10.2.1　使用mkdir()创建目录

10.2.2　使用rmdir()删除目录

10.2.3　使用listdir()列出目录内容

10.2.4　使用chdir()修改当前目录

10.2.5　使用glob()列出匹配文件

10.3　程序和进程

10.3.1　使用subprocess创建进程

10.3.2　使用multiprocessing创建进程

10.3.3　使用terminate()终止进程

10.4　日期和时间

10.4.1　datetime模块

10.4.2　使用time模块

10.4.3　读写日期和时间

10.4.4　其他模块

10.5　练习

第 11 章　并发和网络

11.1　并发

11.1.1　队列

11.1.2　进程

11.1.3　线程

11.1.4　绿色线程和gevent

11.1.5　twisted

11.1.6　asyncio

11.1.7　Redis

11.1.8　队列之上

11.2　网络

11.2.1　模式

11.2.2　发布-订阅模型

11.2.3　TCP/IP

11.2.4　套接字

11.2.5　ZeroMQ

11.2.6　scapy

11.2.7　网络服务

11.2.8　Web服务和API

11.2.9　远程处理

11.2.10　大数据和MapReduce

11.2.11　在云上工作

11.3　练习

第 12 章　成为真正的 Python 开发者

12.1　关于编程

12.2　寻找Python代码

12.3　安装包

12.3.1　使用pip

12.3.2　使用包管理工具

12.3.3　从源代码安装

12.4　集成开发环境

12.4.1　IDLE

12.4.2　PyCharm

12.4.3　IPython

12.5　命名和文档

12.6　测试代码

12.6.1　使用pylint、pyflakes和pep8检查代码

12.6.2　使用unittest进行测试

12.6.3　使用doctest进行测试

12.6.4　使用nose进行测试

12.6.5　其他测试框架

12.6.6　持续集成

12.7　调试Python代码

12.8　使用pdb进行调试

12.9　记录错误日志

12.10　优化代码

12.10.1　测量时间

12.10.2　算法和数据结构

12.10.3　Cython、NumPy和C扩展

12.10.4　PyPy

12.11　源码控制

12.11.1　Mercurial

12.11.2　Git

12.12　复制本书代码

12.13　更多内容

12.13.1　书

12.13.2　网站

12.13.3　社区

12.13.4　大会

12.14　后续内容

附录 A　Python 的艺术

A.1　2D图形

A.1.1　标准库

A.1.1　PIL和Pillow

A.1.3　ImageMagick

A.2　图形用户界面

A.3　3D图形和动画

A.4　平面图、曲线图和可视化

A.4.1　matplotlib

A.4.2　bokeh

A.5　游戏

A.6　音频和音乐

附录 B　工作中的 Python

B.1　Microsoft Office套件

B.2　执行商业任务

B.3　处理商业数据

B.3.1　提取、转换、加载

B.3.2　额外信息源

B.4　金融中的Python

B.5　商业数据安全性

B.6　地图

B.6.1　格式

B.6.2　绘制地图

B.6.3　应用和数据

附录 C　Python 的科学

C.1　标准库中的数学和统计

C.1.1　数学函数

C.1.2　使用复数

C.1.3　使用小数对浮点数进行精确计算

C.1.4　使用分数进行有理数运算

C.1.5　使用array创建压缩序列

C.1.6　使用statistics进行简单数据统计

C.1.7　矩阵乘法

C.2　科学Python

C.3　Numpy

C.3.1　使用array()创建数组

C.3.2　使用arange()创建数组

C.3.3　使用zeros()、ones()和random()创建数组

C.3.4　使用reshape()改变矩阵的形状

C.3.5　使用[]访问元素

C.3.6　数组运算

C.3.7　线性代数

C.4　SciPy库

C.5　SciKit库

C.6　IPython库

C.6.1　更好的解释器

C.6.2　IPython笔记本

C.7　Pandas

C.8　Python和科学领域

附录 D　安装 Python 3

D.1　安装标准Python

D.1.1　Mac OS X

D.1.2　Windows

D.1.3　Linux或Unix

D.2　安装Anaconda

D.3　安装并使用pip和virtualenv

D.4　安装并使用conda

附录 E　习题解答

E.1　第1章“Python初探”

E.2　第2章“Python基本元素：数字、字符串和变量”

E.3　第3章“Python容器：列表、元组、字典与集合”

E.4　第4章“Python外壳：代码结构”

E.5　第5章“Python盒子：模块、包和程序”

E.6　第6章“对象和类”

E.7　第7章“像高手一样玩转数据”

E.8　第8章“数据的归宿”

E.9　第9章“剖析Web”

E.10　第10章“系统”

E.11　第11章“并发和网络”

附录 F　速查表

F.1　操作符优先级

F.2　字符串方法

F.2.1　改变大小写

F.2.2　搜索

F.2.3　修改

F.2.4　格式化

F.2.5　字符串类型

F.3　字符串模块属性

作者介绍

封面介绍

版权声明

© 2015 by Bill Lubanovic.

Simplified Chinese Edition, jointly published by O'Reilly Media, Inc. and Posts & Telecom Press, 2015. Authorized translation of the English edition, 2014 O'Reilly Media, Inc., the owner of all rights to publish and sell the same.

All rights reserved including the rights of reproduction in whole or in part in any form.

英文原版由 O'Reilly Media, Inc. 出版，2014。

简体中文版由人民邮电出版社出版，2016。英文原版的翻译得到 O'Reilly Media, Inc. 的授权。此简体中文版的出版和销售得到出版权和销售权的所有者—— O'Reilly Media, Inc. 的许可。

版权所有，未经书面许可，本书的任何部分和全部不得以任何形式重制。

O'Reilly Media, Inc. 介绍

O'Reilly Media 通过图书、杂志、在线服务、调查研究和会议等方式传播创新知识。自 1978 年开始，O'Reilly 一直都是前沿发展的见证者和推动者。超级极客们正在开创着未来，而我们关注真正重要的技术趋势——通过放大那些“细微的信号”来刺激社会对新科技的应用。作为技术社区中活跃的参与者，O'Reilly 的发展充满了对创新的倡导、创造和发扬光大。

O'Reilly 为软件开发人员带来革命性的“动物书”；创建第一个商业网站（GNN）；组织了影响深远的开放源代码峰会，以至于开源软件运动以此命名；创立了 Make 杂志，从而成为 DIY 革命的主要先锋；公司一如既往地通过多种形式缔结信息与人的纽带。O'Reilly 的会议和峰会集聚了众多超级极客和高瞻远瞩的商业领袖，共同描绘出开创新产业的革命性思想。作为技术人士获取信息的选择，O'Reilly 现在还将先锋专家的知识传递给普通的计算机用户。无论是通过书籍出版、在线服务或者面授课程，每一项 O'Reilly 的产品都反映了公司不可动摇的理念——信息是激发创新的力量。

业界评论

“O'Reilly Radar 博客有口皆碑。”

——Wired

“O'Reilly 凭借一系列（真希望当初我也想到了）非凡想法建立了数百万美元的业务。”

——Business 2.0

“O'Reilly Conference 是聚集关键思想领袖的绝对典范。”

——CRN

“一本 O'Reilly 的书就代表一个有用、有前途、需要学习的主题。”

——Irish Times

“Tim 是位特立独行的商人，他不光放眼于最长远、最广阔的视野，并且切实地按照 Yogi Berra 的建议去做了：‘如果你在路上遇到岔路口，走小路（岔路）。’回顾过去，Tim 似乎每一次都选择了小路，而且有几次都是一闪即逝的机会，尽管大路也不错。”

——Linux Journal

献给Mary、Karin、Tom和Roxie。

前言

本书介绍 Python 编程语言，主要面向编程初学者。不过，如果你是一位有经验的程序员，想再学门 Python 编程语言，本书也很适合作为入门读物。

本书节奏适中，从基础开始逐步深入其他话题。我会结合食谱和教程的风格来解释新术语和新概念，但不会一次介绍很多。你会尽早并且常常接触到真实的 Python 代码。

虽然本书是入门读物，但我还是介绍了一些看起来比较高阶的话题，比如 NoSQL 数据库和消息传递库。之所以介绍它们，是因为在解决某类问题时它们比标准库更加合适。你需要下载并安装这些第三方 Python 包，从而更好地理解 Python“内置电池”适用于什么场景。此外，尝试新事物本身也充满乐趣。

我还会展示一些反面的例子，提醒你不要那么去做。如果你之前使用过其他语言并且想把风格照搬到 Python 的话，要格外注意。还有，我不认为 Python 是完美的，我会告诉你哪些东西应该避免。

　书中有时会出现类似本条的提示内容，主要用于解释一些容易混淆的概念或者用更合适的 Python 风格的方法来解决同一个问题。

目标读者

本书的目标读者是那些对世界上最流行的计算语言感兴趣的人，无论你之前是否学过编程。

本书结构

本书前 7 章介绍 Python 基础知识，建议按顺序阅读。后面 5 章介绍如何在不同的应用场景中使用 Python，比如 Web、数据库、网络，等等，可以按任意顺序阅读。附录 A、B、C 介绍 Python 在艺术、商业和科学方面的应用，附录 D 是 Python 3 的安装教程，附录 E 和附录 F 是每章练习题的答案和速查表。

第 1 章

程序和织袜子或者烤土豆很像。通过一些真实的 Python 程序可以了解这门语言的概貌、能力以及在真实世界中的用途。Python 和其他语言相比有很多优势，不过也有一些不完美的地方。旧版本的 Python（Python 2）正在被新版本（Python 3）替代。如果你在使用 Python 2，请安装 Python 3。你可以使用交互式解释器自行尝试本书中的代码示例。

第 2 章

该章会介绍 Python 中最简单的数据类型：布尔值、整数、浮点数和文本字符串。你也会学习基础的数学和文本操作。

第 3 章

该章会学习 Python 的高级内置数据结构：列表、元组、字典和集合。你可以像玩乐高积木一样用它们来构建更复杂的结构，并学到如何使用迭代器和推导式来遍历它们。

第 4 章

该章会学习如何在之前学习的数据结构上用代码实现比较、选择和重复操作。你会学习如何用函数来组织代码，并用异常来处理错误。

第 5 章

该章会介绍如何使用模块、包和程序组织大型代码结构。你会学习如何划分代码和数据、数据的输入输出、处理选项、使用 Python 标准库并了解标准库的内部实现。

第 6 章

如果你已经在其他语言中学过面向对象编程，就可以轻松掌握 Python 的写法。该章会介绍对象和类的适用场景，有时候使用模块甚至列表和字典会更加合适。

第 7 章

该章会介绍如何像专家一样处理数据。你会学到如何处理文本和二进制数据以及 Unicode 字符和 I/O。

第 8 章

数据需要地方来存放。在该章中，你首先会学习使用普通文件、目录和文件系统，接着会学习如何处理常用文件格式，比如 CSV、JSON 和 XML。此外，你还会了解如何从关系型数据库甚至是最新的 NoSQL 数据库中存取数据。

第 9 章

该章单独介绍 Web，包括客户端、服务器、数据抓取、API 和框架。你会编写一个带请求参数处理和模板的真实网站。

第 10 章

该章会介绍系统相关内容，难度较高。你会学习如何管理程序、进程和线程，处理日期和时间，实现系统管理任务自动化。

第 11 章

该章会介绍网络相关内容：服务、协议和 API。该章示例覆盖了底层 TCP 套接字、消息库以及队列系统、云端部署。

第 12 章

该章会介绍 Python 相关的小技巧，比如安装、使用 IDE、测试、调试、日志、版本控制和文档，还会介绍如何寻找并安装有用的第三方包、打包自己的代码以供重用，以及如何寻找更多有用的信息。祝你好运。

附录 A

附录 A 会介绍 Python 在艺术领域的应用：图像、音乐、动画和游戏。

附录 B

Python 在商业领域也有应用：数据可视化（图表、图形和地图）、安全和管理。

附录 C

Python 在科学领域应用得尤其广泛：数学和统计学、物理科学、生物科学以及医学。附录 C 会介绍 NumPy、SciPy 和 Pandas。

附录 D

如果你还没有安装 Python 3，附录 D 会介绍 Windows、Mac OS/X、Linux 和 Unix 下的安装方法。

附录 E

附录 E 包含每章结尾的练习答案。请在亲自尝试解答之后再查看答案。

附录 F

附录 F 包含一些有用的速查内容。

Python版本

开发者会不断向计算机语言中加入新特性、修复问题，因此计算机语言一直在变化。本书中的代码示例在 Python 3.3 中编写和测试。在本书编辑期间 Python 3.4 发布了，我会介绍一些新版本的内容。如果你想了解相关信息和特性的发布时间，可以阅读 What's New in Python 页面（https://docs.python.org/3/whatsnew/）。这个页面技术性比较强，对于 Python 初学者来说难度较大，不过如果你之后想研究 Python 的兼容性，可以阅读它。

排版约定

本书使用了下列排版约定。

楷体

表示新术语。

等宽字体（constant width）

表示程序片段，以及正文中出现的变量、函数名、数据库、数据类型、环境变量、语句和关键字等。

加粗等宽字体（constant width bold）

表示应该由用户输入的命令或其他文本。

　该图标表示一般注记。

　该图标表示警告或警示。

使用代码示例

补充材料（代码示例、练习等）可以从 https://github.com/madscheme/introducing-python 下载。

本书是要帮你完成工作的。一般来说，如果本书提供了示例代码，你可以把它用在你的程序或文档中。除非你使用了很大一部分代码，否则无需联系我们获得许可。比如，用本书的几个代码片段写一个程序就无需获得许可，销售或分发 O'Reilly 图书的示例光盘则需要获得许可；引用本书中的示例代码回答问题无需获得许可，将书中大量的代码放到你的产品文档中则需要获得许可。

我们很希望但并不强制要求你在引用本书内容时加上引用说明。引用说明一般包括书名、作者、出版社和 ISBN。比如：“Introducing Python by Bill Lubanovic(O'Reilly). Copyright 2015 Bill Lubanovic, 978-1-449-35936-2.”

如果你觉得自己对示例代码的用法超出了上述许可的范围，欢迎你通过 permissions@oreilly.com 与我们联系。

Safari® Books Online

Safari Books Online（http://www.safaribooksonline.com）是应运而生的数字图书馆。它同时以图书和视频的形式出版世界顶级技术和商务作家的专业作品。技术专家、软件开发人员、Web 设计师、商务人士和创意专家等，在开展调研、解决问题、学习和认证培训时，都将 Safari Books Online 视作获取资料的首选渠道。

对于组织团体、政府机构和个人，Safari Books Online 提供各种产品组合和灵活的定价策略。用户可通过一个功能完备的数据库检索系统访问 O'Reilly Media、Prentice Hall Professional、Addison-Wesley Professional、Microsoft Press、Sams、Que、Peachpit Press、Focal Press、Cisco Press、John Wiley & Sons、Syngress、Morgan Kaufmann、IBM Redbooks、Packt、Adobe Press、FT Press、Apress、Manning、New Riders、McGraw-Hill、Jones & Bartlett、Course Technology 以及其他几十家出版社的上千种图书、培训视频和正式出版之前的书稿。要了解 Safari Books Online 的更多信息，我们网上见。

联系我们

请把对本书的评价和问题发给出版社。

美国：

　　O'Reilly Media, Inc.

　　1005 Gravenstein Highway North

　　Sebastopol, CA 95472

中国：

　　北京市西城区西直门南大街 2 号成铭大厦 C 座 807 室（100035）

　　奥莱利技术咨询（北京）有限公司

O'Reilly 的每一本书都有专属网页，你可以在那儿找到本书的相关信息，包括勘误表、示例代码以及其他信息。本书的网站地址是：

　　http://shop.oreilly.com/product/0636920028659.do

对于本书的评论和技术性问题，请发送电子邮件到：

　　bookquestions@oreilly.com

要了解更多 O'Reilly 图书、培训课程、会议和新闻的信息，请访问以下网站：

　　http://www.oreilly.com

我们在 Facebook 的地址如下：

　　http://facebook.com/oreilly

请关注我们的 Twitter 动态：

　　http://twitter.com/oreillymedia

我们的 YouTube 视频地址如下：

　　http://www.youtube.com/oreillymedia

致谢

非常感谢那些阅读本书初稿并给予反馈的人。我尤其要感谢仔细审阅本书的 Eli Bessert、Henry Canival、Jeremy Elliott、Monte Milanuk、Loïc Pefferkorn 和 Steven Wayne。

第 1 章　Python 初探

我们从一个小谜题以及它的答案开始。你认为下面这两行的含义是什么？

(Row 1): (RS) K18,ssk,k1,turn work.
(Row 2): (WS) Sl 1 pwise,p5,p2tog,p1,turn.

它们看起来像是某种计算机程序。实际上，这是一个针织图案。更准确地说，这两行描述的是如何编织袜子的足跟部分。对我来说，看懂它们就像让猫看懂《纽约时报》上的填字游戏一样难，但是对我妻子来说却轻而易举。如果你也懂编织，一样可以轻松看懂。

来看另一个例子。虽然你不知道最终会做出什么，但是马上就能明白下面的内容是什么。

  1/2杯黄油或者人造黄油
  1/2杯奶油
  2.5杯面粉
  1茶匙盐
  1汤匙糖
  4杯糊状土豆（冷藏）

确保在加入面粉之前冷藏所有材料。
混合所有材料。
用力揉。
揉成20个球并冷藏。
对于每一个球：
  在布上洒上面粉。
  用擀面杖把球擀成圆饼。
  入锅，炸至棕色。
  翻面继续炸。

即使你不会做饭，应该也能看懂这是一个菜谱：一系列食物原料以及准备工作。这道菜是什么呢？是 lefse，一道和玉米饼很像的挪威美食。做好之后抹上黄油、果酱或者其他你喜欢吃的东西，最后卷起来吃。

编织图案和菜谱有一些共同的特征。

专有名词、缩写以及符号。有些很常见，有些很难懂。

规定专有名词、缩写以及符号的使用方法，也就是它们的语法。

一个操作序列，按照顺序进行。

有时需要重复一些操作（循环），比如炸 lefse 的每一面。

有时需要引用其他操作序列（用计算机术语来说就是一个函数）。在菜谱中，你可能需要引用另一个将土豆捣成糊状的菜谱。

假定已经有相关知识。菜谱假定你知道水是什么以及如何烧水。编织图案假定你学过编织并且不会经常扎到手。

一个期望的结果。在我们的例子中分别是袜子和食物，千万不要把它们混在一起哦。

以上这些概念都会出现在计算机程序中。这两个例子的目的是让你知道编程并不像看起来那么高深莫测，其实只是学习一些正确的单词和规则而已。

下面来看看真正的程序。你知道它在做什么吗？

for countdown in 5, 4, 3, 2, 1, "hey!":
    print(countdown)

这其实是一段 Python 程序，会打印出下面的内容：

5
4
3
2
1
hey!

看到了吗？学习 Python 就像看懂菜谱或者编织图案一样简单。此外，你可以在桌子上舒服并且安全地练习编写 Python 程序，完全不用担心被热水烫到或者被针扎到。

Python 程序有一些特殊的单词和符号——for、in、print、逗号、冒号、括号以及其他符号。这些单词和符号是语法的重要组成部分。好消息是，Python 的语法非常优秀，相比其他大多数编程语言，学习 Python 需要记住的语法内容很少。Python 语法非常自然，就像一份菜谱一样。

下面的 Python 程序会从一个 Python 列表（list）中选出一条电视新闻的常用语并打印出来：

cliches = [
    "At the end of the day",
    "Having said that",
    "The fact of the matter is",
    "Be that as it may",
    "The bottom line is",
    "If you will",
    ]
print(cliches[3])

程序会打印出第四条常用语：

Be that as it may

一个 Python 列表，比如 cliches，就是一个值序列，可以通过它们相对于列表起始位置的偏移量来访问。第一个值的偏移量是 0，第四个值的偏移量是 3。

　人们通常从 1 开始数数，所以从 0 开始数似乎很奇怪。用偏移量来代替位置会更好理解一些。

列表在 Python 中很常用，第 3 章会讲解列表的用法。

下面这段程序同样会打印出一条引用内容，但是这次是用说话者的人名而不是列表中的位置来进行访问：

quotes = {
    "Moe": "A wise guy, huh?",
    "Larry": "Ow!",
    "Curly": "Nyuk nyuk!",
    }
stooge = "Curly"
print(stooge, "says:", quotes[stooge])

运行这个小程序会打印出：

Curly says: Nyuk nyuk!

quotes 是一个 Python 字典。字典是一个集合，包含唯一键（本例中是跟屁虫“Stooge”的名字）及其关联的值（本例中是跟屁虫说的话）。使用字典可以通过名字来存储和查找东西，和列表一样非常有用。第 3 章会详细讲解字典。

常用语的例子中使用方括号（[ 和 ]）来创建 Python 列表，跟屁虫的例子中使用大括号（{ 和 }，大括号的英文是 curly bracket，但是大括号和 Curly 没有任何关系 1）来创建 Python 字典。这些都是 Python 的语法，在之后的内容中你会看到更多语法。

1Curly 是美国乌鸦童子军（Crow Scouts）的一员，乌鸦童子军是美国和印第安人打仗时由印第安人战俘组成的军队。Curly 是小巨角河战役中为数不多的幸存者之一。小巨角河战役是美军和北美势力最庞大的苏族印地安人之间的战争，在这场战争中印第安人歼灭了美国历史上最有名的第七骑兵团，Curly 当时没有参战，他是第一个报告第七骑兵团战败的人，也因此出名。——译者注

现在我们来看另一个完全不同的例子：示例 1-1 中的 Python 程序会执行一系列复杂的任务。你可能还看不懂这段程序，没关系，学完本书之后就可以看懂了。这个例子的目的是让你了解典型的 Python 程序长什么样。如果你了解其他计算机语言，可以对比一下。

示例 1-1 会连接 YouTube 网站并获取当前评价最高的视频的信息。如果 YouTube 返回的是常见的 HTML 文本，那就很难从中挖掘出我们想要的信息（9.3.4 节会介绍网页抓取）。幸运的是，它返回的是 JSON 格式的数据，这种格式可以直接用计算机处理。JSON（JavaScript Object Notation，JavaScript 对象符号）是一种人类可以阅读的文本格式，它描述了类型、值以及值的顺序。JSON 就像一个小型编程语言，使用 JSON 在不同计算机语言和系统之间交换数据已经成为了一种非常流行的方式。更多关于 JSON 的内容请参考 8.2.4 节。

Python 程序可以把 JSON 文本翻译成 Python 的数据结构——你会在之后的两章中学习它们——和你自己创建出来的一样。这个 YouTube 响应包含很多数据，作为演示我只打印出了前 6 个视频的标题。再说一次，这是一个完整的 Python 程序，你自己也可以运行一下。

示例 1-1：intro/youtube.py

import json
from urllib.request import urlopen
url = "https://gdata.youtube.com/feeds/api.standardfeeds/top_rated?alt=json"
response = urlopen(url)
contents = response.read()
text = contents.decode('utf8')
data = json.loads(text)
for video in data['feed']['entry'][0:6]:
    print(video['title']['$t'])

最后一次运行这个程序得到的输出是：

Evolution of Dance - By Judson Laipply
Linkin Park - Numb
Potter Puppet Pals: The Mysterious Ticking Noise
"Chocolate Rain" Original Song by Tay Zonday
Charlie bit my finger - again !
The Mean Kitty Song

这个 Python 小程序仅仅用了 9 行代码就很好地完成了任务，并且具备很高的可读性。如果你看不懂下面的术语，没关系，接下来的几章会让你明白它们的意思。

第 1 行：从 Python 标准库中导入名为 json 的所有代码。

第 2 行：从 Python 标准 urllib 库中导入 urlopen 函数。

第 3 行：给变量 url 赋值一个 YouTube 地址。

第 4 行：连接指定地址处的 Web 服务器并请求指定的 Web 服务。

第 5 行：获取响应数据并赋值给变量 contents。

第 6 行：把 contents 解码成一个 JSON 格式的文本字符串并赋值给变量 text。

第 7 行：把 text 转换为 data——一个存储视频信息的 Python 数据结构。

第 8 行：每次获取一个视频的信息并赋值给变量 video。

第 8 行：使用两层 Python 字典（data['feed']['entry']）和切片操作（[0:6]）。

第 9 行：使用 print 函数打印出视频标题。

视频信息中包含多种你之前见过的 Python 数据结构，第 3 章会详细介绍。

在这个例子中，我们使用了一些 Python 标准库模块（它们是安装 Python 时就已经包含的程序），但是它们并不是最好的。下面的代码使用第三方 Python 软件包 requests 重写了这个例子：

import requests
url = "https://gdata.youtube.com/feeds/api.standardfeeds/top_rated?alt=json"
response = requests.get(url)
data = response.json()
for video in data['feed']['entry'][0:6]:
    print(video['title']['$t'])

新版代码只有 6 行，并且我认为可读性更高。第 5 章会详细介绍 requests 以及其他第三方 Python 软件。

1.1　真实世界中的Python

那么，是否真的值得付出时间和努力来学习 Python 呢？它真的有用吗？实际上，Python 诞生于 1991 年（比 Java 还早），并且一直是最流行的十门计算机语言之一。公司需要雇用程序员来写 Python 程序，包括你每天都会用到的 Google、YouTube、Dropbox、Netflix 和 Hulu 等。我用 Python 开发过许多产品级应用，从邮件搜索应用到商业网站都有。对于发展迅速的组织来说，Python 能极大地提高生产力。

Python 可以应用在许多计算环境下，如下所示：

命令行窗口

图形用户界面，包括 Web

客户端和服务端 Web

大型网站后端

云（第三方负责管理的服务器）

移动设备

嵌入式设备

Python 程序从一次性脚本——就像你在本章中看到的一样——到几十万行的系统都有。我们会介绍 Python 在网站、系统管理和数据处理方面的应用，还会介绍 Python 在艺术、科学和商业方面的应用。

1.2　Python与其他语言

Python 和其他语言相比如何呢？什么时候该选择什么语言呢？本节会展示一些其他语言的代码片段，这样更直观一些。如果有些语言你从未使用过，也不必担心，你并不需要看懂所有代码（当你看到最后的 Python 示例时，会发现没学过其他语言也不是什么坏事）。如果你只对 Python 感兴趣，完全可以跳过这一节。

下面的每段程序都会打印出一个数字和一条描述语言的信息。

如果你使用的是命令行或者终端窗口，那你使用的就是 shell 程序，它会读入你的命令、运行并显示结果。Windows 的 shell 叫作 cmd，它会运行后缀为 .bat 的 batch 文件。Linux 和其他类 Unix 系统（包括 Mac OS X）有许多 shell 程序，最流行的称为 bash 或者 sh。shell 有许多简单的功能，比如执行简单的逻辑操作以及把类似 * 的通配符扩展成文件名。你可以把命令保存到名为“shell 脚本”的文件中稍后运行。shell 可能是程序员接触到的第一个程序。它的问题在于程序超过百行之后扩展性很差，并且比其他语言的运行速度慢很多。下面就是一段 shell 程序：

#!/bin/sh
language=0
echo "Language $language: I am the shell. So there."

如果你把这段代码保存为 meh.sh 并通过 sh meh.sh 命令来运行它，就会看到下面的输出：

Language 0: I am the shell. So there.

老牌语言 C 和 C++ 是底层语言，只有极其重视性能时才会使用。它们很难学习，并且有许多细节需要你自己处理，处理不当就可能导致程序崩溃和其他很难解决的问题。下面是一段 C 程序：

#include <stdio.h>
int main(int argc, char *argv[]) {
    int language = 1;
    printf("Language %d: I am C! Behold me and tremble!\n", language);
    return 0;
}

C++ 和 C 看起来很相似，但是特性完全不同：

#include <iostream>
using namespace std;
int main(){
    int language = 2;
    cout << "Language " << language << \
        ": I am C++! Pay no attention to that C behind the curtain!" << \
        endl;
    return(0);
}

Java 和 C# 是 C 和 C++ 的接班人，解决了后者的许多缺点，但是相比之下代码更加冗长，写起来也有许多限制。下面是 Java 代码：

public class Overlord {
    public static void main (String[] args) {
        int language = 3;
        System.out.format("Language %d: I am Java! Scarier than C!\n", language);
    }
}

如果你没写过这些语言的程序，可能会觉得很奇怪：这都是什么东西？有些语言有很大的语法包袱。它们有时被称为静态语言，因为你必须告诉计算机许多底层细节，下面我来解释一下。

语言有变量——你想在程序中使用的值的名字。静态语言要求你必须声明每个变量的类型：它会使用多少内存以及允许的使用方法。计算机利用这些信息把程序编译成非常底层的机器语言（专门给计算机硬件使用的语言，硬件很容易理解，但是人类很难理解）。计算机语言的设计者通常必须进行权衡，到底是让语言更容易被人使用还是更容易被计算机使用。声明变量类型可以帮助计算机发现更多潜在的错误并提高运行速度，但是却需要使用者进行更多的思考和编程。C、C++ 和 Java 代码中经常需要声明类型。举例来说，在上面的例子中必须使用 int 将 language 变量声明为一个整数。（其他类型的存储方式和整数不同，比如浮点数 3.14159、字符以及文本数据。）

那么为什么它们被称为静态语言呢？因为这些语言中的变量不能改变类型。它们是静态的。整数就是整数，永远无法改变。

相比之下，动态语言（也被称为脚本语言）并不需要在使用变量前进行声明。假设你输入 x = 5，动态语言知道 5 是一个整数，因此变量 x 也是整数。这些语言允许你用更少的代码做更多的事情。动态语言的代码不会被编译，而是由解释器程序来解释执行。动态语言通常比编译后的静态语言更慢，但是随着解释器的不断优化，动态语言的速度也在不断提升。长期以来，动态语言的主要应用场景都是很短的程序（脚本），比如给静态语言编写的程序进行数据预处理。这样的程序通常称为胶水代码。虽然动态语言很擅长做这些事，但是如今它们也已经具备了处理大型任务的能力。

许多年来，Perl（http://www.perl.org/）一直是一门万能的动态语言。Perl 非常强大并且有许多扩展库。然而，它的语法非常难用，并且似乎无法阻挡 Python 和 Ruby 的崛起。下面是一段 Perl 代码：

my $language = 4;
print "Language $language: I am Perl, the camel of languages.\n";

Ruby（http://www.ruby-lang.org/）是一门新语言。它借鉴了一些 Perl 的特点，并且因为 Web 开发框架 Ruby on Rails 红遍大江南北。Ruby 和 Python 的许多应用场景相同，选择哪一个通常看个人喜好或者是否有你需要的库。下面是一段 Ruby 代码：

language = 5
puts "Language #{language}: I am Ruby, ready and aglow."

PHP（http://www.php.net/）在 Web 开发领域非常流行，因为它可以轻松结合 HTML 和代码，就像例子中展示的那样。然而，PHP 语言本身有许多缺陷，并且很少被应用在 Web 以外的领域。

<?PHP
$language = 6;
echo "Language $language: I am PHP. The web is <i>mine<i>, I say.\n";
?>

最后是我们的主角，Python：

language = 7
print("Language %s: I am Python. What's for supper?" % language)

1.3　为什么选择Python

Python 是一门非常通用的高级语言。它的设计极大地增强了代码可读性，可读性远比听上去重要得多。每个计算机程序只被编写一次，但是会被许多人阅读和修改许多次。提高可读性也可以让学习和记忆更加容易，因此也更容易修改。和其他流行的语言相比，Python 的学习曲线更加平缓，可以让你很快具备生产力，当然，想成为专家还需要深入学习才行。

Python 简洁的语法可以让你写出比静态语言更短的程序。研究证明，程序员每天可以编写的代码行数是有限的——无论什么语言，因此，如果完成同样的功能只需要编写一半长度的代码，生产力就可以提高一倍。对于重视这一点的公司来说，Python 是一个不算秘密的秘密武器。

在顶尖的美国大学中（http://cacm.acm.org/blogs/blog-cacm/176450-python-is-now-the-most-popular-introductory-teaching-language-at-top-us-universities/fulltext），Python 是计算机入门课程中最流行的语言。此外，它也被两千多名雇主（http://blog.codeeval.com/codeevalblog/2014#.U73vaPldUpw=）用来评估编程技能。

当然，它是免费的，就像啤酒和演讲一样。你可以免费用 Python 来编写任何东西并用在任何地方。没人可以一边阅读你的 Python 程序一边说：“这是一个非常棒的小程序，希望不会发生什么意外。”

Python 几乎可以运行在任何地方并且其标准库中有很多有用的软件。

不过，选择 Python 最关键的理由可能出乎你的意料：大家都喜欢它。实际上，大家不只是把 Python 当作一个完成工作的工具，而是非常享受用它编程。在工作中不得不用其他语言时，人们通常会非常想念 Python 的某些特性。这就是 Python 能够胜出的原因。

1.4　何时不应该使用Python

Python 并非在所有场合都是最好用的语言。

它并不是默认安装在所有环境中。如果你的电脑上没有 Python，附录 D 会告诉你如何安装。

对于大多数应用来说，Python 已经足够快了，但是有些场合下，它的性能仍然是个问题。如果你的程序会花费大量时间用于计算（专业术语是中央处理器受限），那么可以使用 C、C++ 或者 Java 来编写程序从而提高性能。但是这并不是唯一的选择！

有时候用 Python 实现一个更好的算法（一系列解决问题的步骤）可以打败 C 中的低效算法。Python 对于开发效率的提升可以让你有更多的时间来尝试各种选择。

在许多应用中，程序会因为等待其他服务器的响应而浪费时间。这段时间里 CPU（中央处理单元，计算机中负责所有计算的芯片）几乎什么都不做，因此，静态和动态程序的端到端时间几乎是一样的。

Python 的标准解释器用 C 实现，所以可以通过 C 代码进行扩展。12.10 节会介绍一些。

Python 解释器变得越来越快。Java 最初也很慢，经过大量的研究和资金投入之后，它变得非常快。Python 并不属于某个公司，因此它的发展会更缓慢一些。12.10.4 节会介绍 PyPy 项目及其意义。

可能你的项目要求非常严格，无论如何努力 Python 都无法达到要求。那么，借用伊恩 · 荷姆在电影《异形》中说过的一句话，我很同情你。通常来说可以选择 C、C++ 和 Java，不过新语言 Go（http://golang.org，写起来像 Python，性能像 C）也是一个不错的选择。

1.5　Python 2与Python 3

你即将面临的最大问题是，Python 有两个版本。Python 2 已经存在了很长时间并且预装在 Linux 和 Apple 电脑中。Python 是一门很出色的语言，但是世界上不存在完美的东西。和其他领域一样，在计算机语言中许多问题很容易解决，但是也有一些问题很难解决。后者的难点在于不兼容：使用修复后的新版本编写的程序无法运行在旧的 Python 系统中，旧的程序也无法运行在新的系统中。

Python 的发明者（吉多 · 范 · 罗苏姆，https://www.python.org/~guido）和其他开发者决定把这些困难问题放在一起解决，并把解决后的版本称作 Python 3。Python 2 已经成为过去，Python 3 才是未来。Python 2 的最后一个版本是 2.7，它会被支持很长一段时间，但也就仅此而已，再也没有 Python 2.8 了。新的开发全部会在 Python 3 上进行。

本书使用的是 Python 3。如果你使用的是 Python 2 也不用担心，两者差别不大。最明显的区别在于调用 print 的方式，最重要的区别则是处理 Unicode 字符的方式，详情参见第 2 章和第 7 章。流行的 Python 软件需要逐步升级，和常见的“先有鸡还是先有蛋”问题一样。不过，看起来我们现在终于到达了发生转变的临界点。

1.6　安装Python

为了让这章更加简洁，安装 Python 3 的细节参见附录 D。如果你还没安装 Python 3 或者不确定是否安装过 Python，请阅读附录 D。

1.7　运行Python

安装好 Python 3 之后，可以用它来运行本书中的 Python 程序和你自己的 Python 代码。那么如何运行 Python 程序呢？通常来说有两种方法。

Python 自带的交互式解释器可以很方便地执行小程序。你可以一行一行输入命令然后立刻查看运行结果。这种方式可以很好地结合输入和查看结果，从而快速进行一些实验。我会用交互式解释器来说明一些语言特性，你可以在自己的 Python 环境中输入同样的命令。

除此之外，可以把 Python 程序存储到文本文件中，通常要加上 .py 扩展名，然后输入 python 加文件名来执行。

我们来分别尝试一下这两种方式。

1.7.1　使用交互式解释器

本书中大多数代码示例都用到了交互式解释器。当你输入示例中的命令并且看到同样的输出时，就可以确定你没有跑偏。

可以在电脑上输入 Python 主程序的名称来启动解释器：应该是 python、python3 或者类似的东西。在本书接下来的内容中，我们会假设它叫 python；如果你的 Python 主程序名字不同，请把代码示例中的 python 全部替换成你电脑上的名称。

交互式解释器的工作原理基本上和 Python 对文件的处理方式一样，除了一点：当你输入一些包含值的东西时，交互式解释器会自动打印出这个值。举例来说，如果你启动 Python 并输入数字 61，它会立刻出现在终端中。

　在下面的例子中，$表示系统提示符，用来输入终端中的命令，比如 python。本书的代码示例都会使用它，尽管在你的电脑中提示符可能不是 $。

$ python
Python 3.3.0 (v3.3.0:bd8afb90ebf2, Sep 29 2012, 01:25:11)
[GCC 4.2.1 (Apple Inc. build 5666) (dot 3)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> 61
61
>>>

这种自动打印值的省时特性只有交互式解释器中有，在 Python 语言中没有。

顺便说一句，也可以使用 print() 在解释器中打印内容：

>>> print(61)
61
>>>

如果你亲自动手在交互式解释器中执行了上面这些例子并看到了相同的结果，恭喜你，你成功运行了真正的 Python 代码（虽然有点短）。接下来的几章中会接触到更长的 Python 程序。

1.7.2　使用Python文件

如果你把 61 放在文件中并使用 Python 来执行它，确实可以，但是程序什么都不会输出。在非交互式的 Python 程序中，你必须调用 print 函数来打印内容，如下所示：

print(61)

我们来生成一个 Python 程序文件并运行它。

(1) 打开你的文本编辑器。

(2) 输入代码 print(61)，如上所示。

(3) 保存文件，命名为 61.py。一定要确保存储为纯文本而不是“富文本”格式，比如 RTF 或者 Word。Python 程序文件并不是一定要以 .py 结尾，但是加上它可以让你清楚这个文件的作用。

(4) 如果你使用的是图形用户界面——基本上每个人都会用——请打开一个终端窗口 2。

2如果你不明白什么是终端窗口，请阅读附录 D。

(5) 输入下面的命令来运行程序：

$ python 61.py

应该可以看到一行输出：

61

成功了吗？如果成功了，恭喜你运行了你的第一个 Python 程序！

1.7.3　下一步

你可以向真正的 Python 系统中输入命令，不过它们必须符合 Python 的语法。我们不会一次性向你展示所有的语法规则，而是在接下来的几章中逐一进行讲解。

开发 Python 程序最基础的方法是使用一个纯文本编辑器和一个终端窗口。在本书中会直接展示纯文本，有时候在交互式终端中，有时候在 Python 文件中。不过除此之外，还有很多优秀的 Python 集成开发环境（IDE）。它们的图形用户界面可能会有许多高端文本编辑功能和辅助开发功能。在第 12 章中你会看到一些相关介绍。

1.8　禅定一刻

每种计算机语言都有自己的风格。在前言中我提到过，你可以用 Python 的方式来表达自己。Python 中内置了一些自由体诗歌，它们简单明了地说明了 Python 的哲学（就我所知，Python 是唯一一个包含这种复活节彩蛋的语言）。只要在交互式解释器中输入 import this，然后按下回车就能看到它们：

>>> import this
《Python之禅》 Tim Peters

优美胜于丑陋
明了胜于隐晦
简洁胜于复杂
复杂胜于混乱
扁平胜于嵌套
宽松胜于紧凑
可读性很重要
即便是特例，也不可违背这些规则
虽然现实往往不那么完美
但是不应该放过任何异常
除非你确定需要如此
如果存在多种可能，不要猜测
肯定有一种——通常也是唯一一种——最佳的解决方案
虽然这并不容易，因为你不是Python之父
动手比不动手要好
但不假思索就动手还不如不做
如果你的方案很难懂，那肯定不是一个好方案
如果你的方案很好懂，那可能是一个好方案
命名空间非常有用，我们应当多加利用

这些哲学观点会贯穿全书。

1.9　练习

本章介绍了 Python 语言——它是干什么的、它是什么样的以及它在计算机世界中的作用。在每章的结尾我都会列出一些小练习来帮助你巩固刚学到的知识并为学习新知识做好准备。

(1) 如果你还没有安装 Python 3，现在就立刻动手。具体方法请阅读附录 D。

(2) 启动 Python 3 交互式解释器。再说一次，具体方法请阅读附录 D。它会打印出几行信息和一行 >>>，这是你输入 Python 命令的提示符。

(3) 随便玩玩解释器。可以用它来计算 8 * 9，按下回车来查看结果，Python 应该会打印出 72。

(4) 输入数字 47 并按下回车，解释器有没有在下一行打印出 47 ？

(5) 现在，输入 print(47) 并按下回车，解释器有没有在下一行打印出 47 ？

第 2 章　Python 基本元素：数字、字符串和变量

本章会从 Python 最基本的内置数据类型开始学习，这些类型包括：

布尔型（表示真假的类型，仅包含 True 和 False 两种取值）

整型（整数，例如 42、100000000）

浮点型（小数，例如 3.14159，或用科学计数法表示的数字，例如 1.0e8，它表示 1 乘以 10 的 8 次方，也可写作 100000000.0）

字符串型（字符组成的序列）

这些基本类型就像组成 Python 的原子一样。本章将学习如何单独使用这些基本“原子”，而第 3 章则会介绍如何将这些“原子”组合成更大的“分子”。

每一种类型都有自己的使用规则，计算机对它们的处理方式也不尽相同。此外我们还会学到变量的概念（与实际数据相关联的名字，后面有更详细的介绍）。

本章中的代码虽然都只是小的片段，但它们都是有效的 Python 程序。为了方便快速测试，我们将使用 Python 的交互式解释器，这样在输入代码的同时就可以获得执行结果。尝试在你自己搭建的 Python 环境中运行书中的代码片段，它们会由提示符 >>> 标出。第 4 章将开始编写能独立执行的 Python 程序。

2.1　变量、名字和对象

Python 里所有数据——布尔值、整数、浮点数、字符串，甚至大型数据结构、函数以及程序——都是以对象（object）的形式存在的。这使得 Python 语言具有很强的统一性（还有许多其他有用的特性），而这恰恰是许多其他语言所缺少的。

对象就像一个塑料盒子，里面装的是数据（图 2-1）。对象有不同类型，例如布尔型和整型，类型决定了可以对它进行的操作。现实生活中的“陶器”会暗含一些信息（例如它可能很重，注意不要掉到地上，等等）。类似地，Python 中一个类型为 int 的对象会告诉你：可以把它与另一个 int 对象相加。

图 2-1：对象就像一个盒子

对象的类型还决定了它装着的数据是允许被修改的变量（可变的）还是不可被修改的常量（不可变的）。你可以把不可变对象想象成一个透明但封闭的盒子：你可以看到里面装的数据，但是无法改变它。类似地，可变对象就像一个开着口的盒子，你不仅可以看到里面的数据，还可以拿出来修改它，但你无法改变这个盒子本身，即你无法改变对象的类型。

Python 是强类型的（strongly typed），你永远无法修改一个已有对象的类型，即使它包含的值是可变的（图 2-2）。

图 2-2：Strong Typing 不是指用力敲打键盘

编程语言允许你定义变量（variable）。所谓变量就是在程序中为了方便地引用内存中的值而为它取的名称。在 Python 中，我们用 = 来给一个变量赋值。

　我们都在数学课上学过 = 代表“等于”，那么为什么计算机语言（包括 Python）要用 = 代表赋值操作呢？一种解释是标准键盘没有像左箭头一样的逻辑上能代表赋值操作的键，与其他键相比，= 显得相对不那么令人困惑；而在程序中，赋值出现的频率又要远远超过等于，因此把 = 分给了赋值操作来使用。

下面这段仅两行的 Python 程序首先将整数 7 赋值给了变量 a，之后又将 a 的值打印了出来：

>>> a = 7
>>> print(a)
7

注意，Python 中的变量有一个非常重要的性质：它仅仅是一个名字。赋值操作并不会实际复制值，它只是为数据对象取个相关的名字。名字是对对象的引用而不是对象本身。你可以把名字想象成贴在盒子上的标签（见图 2-3）。

图 2-3：贴在对象上的名字

试着在交互式解释器中执行下面的操作：

(1) 和之前一样，将 7 赋值给名称 a，这样就成功创建了一个包含整数 7 的对象；

(2) 打印 a 的值；

(3) 将 a 赋值给 b，这相当于给刚刚创建的对象又贴上了标签 b；

(4) 打印 b 的值。

>>> a = 7
>>> print(a)
7
>>> b = a
>>> print(b)
7

在 Python 中，如果想知道一个对象（例如一个变量或者一个字面值）的类型，可以使用语句：type( thing )。试试对不同的字面值（58、99.9、abc）以及不同的变量（a、b）执行 type 操作：

>>> type(a)
<class 'int'>
>>> type(b)
<class 'int'>
>>> type(58)
<class 'int'>
>>> type(99.9)
<class 'float'>
>>> type('abc')
<class 'str'>

类（class）是对象的定义，第 6 章会详细介绍。在 Python 中，“类”和“类型”一般不加区分。

变量名只能包含以下字符：

小写字母（a~z）

大写字母（A~Z）

数字（0~9）

下划线（_）

名字不允许以数字开头。此外，Python 中以下划线开头的名字有特殊的含义（第 4 章会解释）。下面是一些合法的名字：

a

a1

a_b_c___95

_abc

_1a

下面这些名字则是非法的：

1

1a

1_

最后要注意的是，不要使用下面这些词作为变量名，它们是 Python 保留的关键字：

False      class      finally    is         return
None       continue   for        lambda     try
True       def        from       nonlocal   while
and        del        global     not        with
as         elif       if         or         yield
assert     else       import     pass
break      except     in         raise

这些关键字以及其他的一些标点符号是用于描述 Python 语法的。在本书中，你会慢慢学到它们各自的作用。

2.2　数字

Python 本身支持整数（比如 5 和 1000000000）以及浮点数（比如 3.1416、14.99 和 1.87e4）。你可以对这些数字进行下表中的计算。

运算符

描述

示例

运算结果

+

加法

5 + 8

13

-

减法

90 - 10

80

*

乘法

4 * 7

28

/

浮点数除法

7 / 2

3.5

//

整数除法

7 // 2

3

%

模（求余）

7 % 3

1

**

幂

3 ** 4

81

接下来会给你展示一些示例，这些示例体现了 Python 作为一个计算机器的非凡特性。

2.2.1　整数

任何仅含数字的序列在 Python 中都被认为是整数：

>>> 5
5

你可以单独使用数字零（0）：

>>> 0
0

但不能把它作为前缀放在其他数字前面：

>>> 05
  File "<stdin>", line 1
    05
     ^
SyntaxError: invalid token

　这是你第一次看见 Python 异常——程序错误。在上面的例子中，解释器抛出了一个警告，提示 05 是一个“非法标识”（invalid token）。2.2.3 节会解释这个警告的意义。你会在本书中见到许多种异常，这是 Python 主要的错误处理机制。

一个数字序列定义了一个正整数。你也可以显式地在前面加上正号 +，这不会使数字发生任何改变：

>>> 123
123
>>> +123
123

在数字前添加负号 - 可以定义一个负数：

>>> -123
-123

你可以像使用计算器一样使用 Python 来进行常规运算。Python 支持的运算参见之前的表格。试试进行加法和减法运算，运算结果和你预期的一样：

>>> 5 + 9
14
>>> 100 - 7
93
>>> 4 - 10
-6

可以连续运算任意个数：

>>> 5 + 9 + 3
17
>>> 4 + 3 - 2 - 1 + 6
10

格式提示：数字和运算符之间的空格不是强制的，你也可以写成下面这种格式：

>>> 5+9   +      3
17

只不过添加空格会使代码看起来更规整更便于阅读。

乘法运算的实现也很直接：

>>> 6 * 7
42
>>> 7 * 6
42
>>> 6 * 7 * 2 * 3
252

除法运算比较有意思，可能与你预期的有些出入，因为 Python 里有两种除法：

/ 用来执行浮点除法（十进制小数）

// 用来执行整数除法（整除）

与其他语言不同，在 Python 中即使运算对象是两个整数，使用 / 仍会得到浮点型的结果：

>>> 9 / 5
1.8

使用整除运算得到的是一个整数，余数会被截去：

>>> 9 // 5
1

如果除数为 0，任何一种除法运算都会产生 Python 异常：

>>> 5 / 0
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ZeroDivisionError: division by zero
>>> 7 // 0
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ZeroDivisionError: integer division or modulo by z

之前的例子中我们都在使用立即数进行运算，你也可以在运算中将立即数和已赋值过的变量混合使用：

>>> a = 95
>>> a
95
>>> a - 3
92

上面代码中出现了 a - 3，但我们并没有将结果赋值给 a，因此 a 的值并未发生改变：

>>> a
95

如果你想要改变 a 的值，可以这样写：

>>> a = a - 3
>>> a
92

对于初学者来说，上面这行式子可能很费解，因为小学的数学知识让我们根深蒂固地认为 = 代表等于，因此上面的式子显然是不成立的。但在 Python 里并非如此，Python 解释器会首先计算 = 右侧的表达式，然后将其结果赋值给左侧的变量。

试着这样理解看看是否有帮助。

计算 a-3

将运算结果保存在一个临时变量中

将这个临时变量的值赋值给 a：

>>> a = 95
>>> temp = a - 3
>>> a = temp

因此，当输入：

>>> a = a - 3

Python 实际上先计算了右侧的减法，暂时记住运算结果，然后将这个结果赋值给了 = 左侧的 a。这种写法比使用临时变量要更加迅速、简洁。

你还可以进一步将运算过程与赋值过程进行合并，只需将运算符放到 = 前面。例如，a -= 3 等价于 a = a - 3：

>>> a = 95
>>> a -= 3
>>> a
92

下面的代码等价于执行 a = a + 8：

>>> a += 8
>>> a
100

以此类推，下面的代码等价于 a = a * 2：

>>> a *= 2
>>> a
200

再试试浮点型除法，例如 a = a / 3：

>>> a /= 3
>>> a
66.66666666666667

接着将 13 赋值给 a，然后试试执行 a = a // 4（整数除法）的简化版：

>>> a = 13
>>> a //= 4
>>> a
3

百分号（%）在 Python 里有多种用途，当它位于两个数字之间时代表求模运算，得到的结果是第一个数除以第二个数的余数：

>>> 9 % 5
4

使用下面的方法可以同时得到商和余数：

>>> divmod(9,5)
(1, 4)

或者你也可以分别计算：

>>> 9 // 5
1
>>> 9 % 5
4

上面的代码出现了一些你没见过的新东西：一个叫作 divmod 的函数。这个函数接受了两个整数：9 和 5，并返回了一个包含两个元素的结果，我们称这种结构为元组（tuple）。第 3 章会学习元组的使用，而关于函数的内容将在第 4 章进行讲解。

2.2.2　优先级

想一想下面的表达式会产生什么结果？

>>> 2 + 3 * 4

如果你先进行加法运算 2 + 3 = 5，然后计算 5 * 4，最终得到 20。但如果你先进行乘法运算，3 * 4 = 12，接着 2 + 12，结果等于 14。与其他编程语言一样，在 Python 里，乘法的优先级要高于加法，因此第二种运算结果是正确的：

>>> 2 + 3 * 4
14

如何了解优先级规则？我在附录 F 中为你准备了一张优先级表，但在实际编程中我几乎从来没有查看过它，因为我们总可以使用括号来保证运算顺序与我们期望的一致：

>>> 2 + (3 * 4)
14

这样书写的代码也可让阅读者无需猜测代码的意图，免去了检查优先级表的麻烦。

2.2.3　基数

在 Python 中，整数默认使用十进制数（以 10 为底），除非你在数字前添加前缀，显式地指定使用其他基数（base）。也许你永远都不会在自己的代码中用到其他基数，但你很有可能在其他人编写的 Python 代码里见到它们。

我们大多数人都有 10 根手指 10 根脚趾（我家里倒是有只猫多了几根指头，但我从来没见过它用自己的指头数数）。因此，我们习惯这样计数：0，1，2，3，4，5，6，7，8，9。到了 9 之后，我们用光了所有的数字，于是将数字 1 放到“十位”，并把 0 放到“个位”。因此，10 代表“1 个十加 0 个一”。我们无法用一个字符代表数字“十”。接着是 11，12，一直到 19，然后仿照之前的做法，我们将新多出来的 1 加到十位来组成 20（2 个十加 0 个一），以此类推。

基数指的是在必须进位前可以使用的数字的最大数量。以 2 为底（二进制）时，可以使用的数字只有 0 和 1。这里的 0 和十进制的 0 代表的意义相同，1 和十进制的 1 所代表的意义也相同。然而以 2 为底时，1 与 1 相加得到的将是 10（1 个二加 0 个一）。

在 Python 中，除十进制外你还可以使用其他三种进制的数字：

0b 或 0B 代表二进制（以 2 为底）

0o 或 0O 代表八进制（以 8 为底）

0x 或 0X 代表十六进制（以 16 为底）

Python 解释器会打印出它们对应的十进制整数。我们来试试这些不同进制的数。首先是单纯的十进制数字 10，代表“1 个十加 0 个一”。

>>> 10
10

接着，试试二进制（以 2 为底），代表“1（十进制）个二加上 0 个一”：

>>> 0b10
2

八进制（以 8 为底），代表“1（十进制）个八加上 0 个一”：

>>> 0o10
8

十六进制（以 16 为底），代表“1（十进制）个 16 加上 0 个一”：

>>> 0x10
16

可能你会好奇，十六进制用的是哪 16 个“数字”，它们是：0，1，2，3，4，5，6，7，8，9，a，b，c，d，e 以及 f。因此，0xa 代表十进制的 10，0xf 代表十进制的 15，0xf 加 1 等于 0x10（十进制 16）。

为什么要使用 10 以外的基数？因为它们在进行位运算时非常有用。有关位运算以及不同进制之间的转换将在第 7 章进行介绍。

2.2.4　类型转换

我们可以方便地使用 int() 函数将其他的 Python 数据类型转换为整型。它会保留传入数据的整数部分并舍去小数部分。

Python 里最简单的数据类型是布尔型，它只有两个可选值：True 和 False。当转换为整数时，它们分别代表 1 和 0：

>>> int(True)
1
>>> int(False)
0

当将浮点数转换为整数时，所有小数点后面的部分会被舍去：

>>> int(98.6)
98
>>> int(1.0e4)
10000

也可以将仅包含数字和正负号的字符串（如果你不知道字符串是什么，不用着急，先往后读，很快就会了解）转换为整数，下面有几个例子：

>>> int('99')
99
>>> int('-23')
-23
>>> int('+12')
12

将一个整数转换为整数没有太多意义，这既不会产生任何改变也不会造成任何损失：

>>> int(12345)
12345

如果你试图将一个与数字无关的类型转化为整数，会得到一个异常：

>>> int('99 bottles of beer on the wall')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: invalid literal for int() with base 10: '99 bottles of beer on the wall'
>>> int('')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: invalid literal for int() with base 10: ''

尽管上面例子中的字符串的确是以有效数字（99）开头的，但它没有就此截止，后面的内容不是纯数字，无法被 int() 函数识别，因此抛出异常。

　第 4 章会详细介绍异常。现在，你只需知道异常是 Python 处理程序错误的方式（不像有些语言不做处理直接造成程序崩溃）。在本书里，我会经常展示各种出现异常的情况，而不是假定程序总是正确运行的，这样你可以更加了解 Python 是如何处理程序错误的。

int() 可以接受浮点数或由数字组成的字符串，但无法接受包含小数点或指数的字符串：

>>> int('98.6')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: invalid literal for int() with base 10: '98.6'
>>> int('1.0e4')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: invalid literal for int() with base 10: '1.0e4'

如果混合使用多种不同的数字类型进行计算，Python 会自动地进行类型转换：

>>> 4 + 7.0
11.0

与整数或浮点数混合使用时，布尔型的 False 会被当作 0 或 0.0，Ture 会被当作 1 或 1.0：

>>> True + 2
3
>>> False + 5.0
5.0

2.2.5　一个int型有多大

在 Python 2 里，一个 int 型包含 32 位，可以存储从 -2 147 483 648 到 2 147 483 647 的整数。

一个 long 型会占用更多的空间：64 位，可以存储从 -9 223 372 036 854 775 808 到 9 223 372 036 854 775 807 的整数。

到了 Python 3，long 类型已不复存在，而 int 类型变为可以存储任意大小的整数，甚至超过 64 位。因此，你可以进行像下面一样计算（10**100 被赋值给名为 googol 的变量，这是 Google 最初的名字，但由于其拼写困难而被现在的名字所取代）：

>>>
>>> googol = 10**100
>>> googol
100000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000
>>> googol * googol
100000000000000000000000000000000000000000000000000000000000000000000000000000
000000000000000000000000000000000000000000000000000000000000000000000000000000
000000000000000000000000000000000000000000000

在许多其他编程语言中，进行类似上面的计算会造成整数溢出，这是因为计算中的数字或结果需要的存储空间超过了计算机所提供的（例如 32 位或 64 位）。在程序编写中，溢出会产生许多负面影响。而 Python 在处理超大数计算方面不会产生任何错误，这也是它的一个加分点。

2.2.6　浮点数

整数全部由数字组成，而浮点数（在 Python 里称为 float）包含非数字的小数点。浮点数与整数很像：你可以使用运算符（+、-、*、/、//、** 和 %）以及 divmod() 函数进行计算。

使用 float() 函数可以将其他数字类型转换为浮点型。与之前一样，布尔型在计算中等价于 1.0 和 0.0：

>>> float(True)
1.0
>>> float(False)
0.0

将整数转换为浮点数仅仅需要添加一个小数点：

>>> float(98)
98.0
>>> float('99')
99.0

此外，也可以将包含有效浮点数（数字、正负号、小数点、指数及指数的前缀 e）的字符串转换为真正的浮点型数字：

>>> float('98.6')
98.6
>>> float('-1.5')
-1.5
>>> float('1.0e4')
10000.0

2.2.7　数学函数

Python 包含许多常用的数学函数，例如平方根、余弦函数，等等。这些内容被放到了附录 C，在那里我还会讨论 Python 的科学应用。

2.3　字符串

不是程序员的人经常会认为程序员一定都非常擅长数学，因为他们整天和数字打交道。但事实上，大多数程序员在处理字符串上花费的时间要远远超过处理数字的时间。逻辑思维（以及创造力）的重要性要远远超过数学能力。

对 Unicode 的支持使得 Python 3 可以包含世界上任何书面语言以及许多特殊符号。对于 Unicode 的支持是 Python 3 从 Python 2 分离出来的重要原因之一，也正是这一重要特性促使人们转向使用 Python 3。处理 Unicode 编码有时会非常困难，因此我将相关内容分散在了本书的不同地方。而本节只会使用 ASCII 编码的例子。

字符串型是我们学习的第一个 Python 序列类型，它的本质是字符序列。

与其他语言不同的是，Python 字符串是不可变的。你无法对原字符串进行修改，但可以将字符串的一部分复制到新字符串，来达到相同的修改效果。很快你就会学到如何实现。

2.3.1　使用引号创建

将一系列字符包裹在一对单引号或一对双引号中即可创建字符串，就像下面这样：

>>> 'Snap'
'Snap'
>>> "Crackle"
'Crackle'

交互式解释器输出的字符串永远是用单引号包裹的，但无论使用哪种引号，Python 对字符串的处理方式都是一样的，没有任何区别。

既然如此，为什么要使用两种引号？这么做的好处是可以创建本身就包含引号的字符串，而不用使用转义符。可以在双引号包裹的字符串中使用单引号，或者在单引号包裹的字符串中使用双引号：

>>> "'Nay,' said the naysayer."
"'Nay,' said the naysayer."
>>> 'The rare double quote in captivity: ".'
'The rare double quote in captivity: ".'
>>> 'A "two by four" is actually 1 1/2" × 3 1/2".'
'A "two by four is" actually 1 1/2" × 3 1/2".'
>>> "'There's the man that shot my paw!' cried the limping hound."
"'There's the man that shot my paw!' cried the limping hound."

你还可以使用连续三个单引号 '''，或者三个双引号 """ 创建字符串：

>>> '''Boom!'''
'Boom'
>>> """Eek!"""
'Eek!'

三元引号在创建短字符串时没有什么特殊用处。它多用于创建多行字符串。下面的例子 中，创建的字符串引用了 Edward Lear 的经典诗歌：

>>> poem =  '''There was a Young Lady of Norway,
... Who casually sat in a doorway;
... When the door squeezed her flat,
... She exclaimed, "What of that?"
... This courageous Young Lady of Norway.'''
>>>

（上面这段代码是在交互式解释器里输入的，第一行的提示符为 >>>，后面行的提示符为 ...，直到再次输入三元引号暗示赋值语句的完结，此时光标跳转到下一行并再次以 >>> 提示输入。）

如果你尝试通过单独的单双引号创建多行字符串，在你完成第一行并按下回车时，Python 会弹出错误提示：

>>> poem = 'There was a young lady of Norway,
  File "<stdin>", line 1
    poem = 'There was a young lady of Norway,
                                            ^
SyntaxError: EOL while scanning string literal
>>>

在三元引号包裹的字符串中，每行的换行符以及行首或行末的空格都会被保留：

>>> poem2 = '''I do not like thee, Doctor Fell.
...     The reason why, I cannot tell.
...     But this I know, and know full well:
...     I do not like thee, Doctor Fell.
... '''
>>> print(poem2)
I do not like thee, Doctor Fell.
    The reason why, I cannot tell.
    But this I know, and know full well:
    I do not like thee, Doctor Fell.

>>>

值得注意的是，print() 函数的输出与交互式解释器的自动响应输出存在一些差异：

>>> poem2
'I do not like thee, Doctor Fell.\n    The reason why, I cannot tell.\n    But
this I know, and know full well:\n    I do not like thee, Doctor Fell.\n'

print() 会把包裹字符串的引号截去，仅输出其实际内容，易于阅读。它还会自动地在各 个输出部分之间添加空格，并在所有输出的最后添加换行符：

>>> print(99, 'bottles', 'would be enough.')
99 bottles would be enough.

如果你不希望 print() 自动添加空格或换行，随后将学会如何避免它们。

解释器可以打印字符串以及像 \n 的转义符，关于转义符的内容你会在 2.3.3 节看到。

最后要指出的是 Python 允许空串的存在，它不包含任何字符且完全合法。你可以使用前面提到的任意一种方法创建一个空串：

>>> ''
''
>>> ""
''
>>> ''''''
''
>>> """"""
''
>>>

为什么会用到空字符串？有些时候你想要创建的字符串可能源自另一字符串的内容，这时需要先创建一个空白的模板，也就是一个空字符串。

>>> bottles = 99
>>> base = ''
>>> base += 'current inventory: '
>>> base += str(bottles)
>>> base
'current inventory: 99'

2.3.2　使用str()进行类型转换

使用 str() 可以将其他 Python 数据类型转换为字符串：

>>> str(98.6)
'98.6'
>>> str(1.0e4)
'10000.0'
>>> str(True)
'True'

当你调用 print() 函数或者进行字符串插值（string interpolation）时，Python 内部会自动使用 str() 将非字符串对象转换为字符串。第 7 章会了解到相关内容。

2.3.3　使用\转义

Python 允许你对某些字符进行转义操作，以此来实现一些难以单纯用字符描述的效果。在字符的前面添加反斜线符号 \ 会使该字符的意义发生改变。最常见的转义符是 \n，它代表换行符，便于你在一行内创建多行字符串。

>>> palindrome = 'A man,\nA plan,\nA canal:\nPanama.'
>>> print(palindrome)
A man,
A plan,
A canal:
Panama.

转义符 \t（tab 制表符）常用于对齐文本，之后会经常见到：

>>> print('\tabc')
    abc
>>> print('a\tbc')
a    bc
>>> print('ab\tc')
ab      c
>>> print('abc\t')
abc

（上面例子中，最后一个字符串的末尾包含了一个制表符，当然你无法在打印的结果中看到它。）

有时你可能还会用到 \' 和 \" 来表示单、双引号，尤其当该字符串由相同类型的引号包裹时：

>>> testimony = "\"I did nothing!\" he said. \"Not that either! Or the other
 thing.\""
>>> print(testimony)
"I did nothing!" he said. "Not that either! Or the other thing."
>>> fact = "The world's largest rubber duck was 54'2\" by 65'7\" by 105'"
>>> print(fact)
The world's largest rubber duck was 54'2" by 65'7" by 105'

如果你需要输出一个反斜线字符，连续输入两个反斜线即可：

>>> speech = 'Today we honor our friend, the backslash: \\.'
>>> print(speech)
Today we honor our friend, the backslash: \.

2.3.4　使用+拼接

在 Python 中，你可以使用 + 将多个字符串或字符串变量拼接起来，就像下面这样：

>>> 'Release the kraken! ' + 'At once!'
'Release the kraken! At once!'

也可以直接将一个字面字符串（非字符串变量）放到另一个的后面直接实现拼接：

>>> "My word! " "A gentleman caller!"
'My word! A gentleman caller!'

进行字符串拼接时，Python 并不会自动为你添加空格，需要显式定义。但当我们调用 print() 进行打印时，Python 会在各个参数之间自动添加空格并在结尾添加换行符：

>>> a = 'Duck.'
>>> b = a
>>> c = 'Grey Duck!'
>>> a + b + c
'Duck.Duck.Grey Duck!'
>>> print(a, b, c)
Duck. Duck. Grey Duck!

2.3.5　使用*复制

使用 * 可以进行字符串复制。试着把下面这几行输入到交互式解释器里，看看结果是什么：

>>> start = 'Na ' * 4 + '\n'
>>> middle = 'Hey ' * 3 + '\n'
>>> end = 'Goodbye.'
>>> print(start + start + middle + end)

2.3.6　使用[]提取字符

在字符串名后面添加 []，并在括号里指定偏移量可以提取该位置的单个字符。第一个字符（最左侧）的偏移量为 0，下一个是 1，以此类推。最后一个字符（最右侧）的偏移量也可以用 -1 表示，这样就不必从头数到尾。偏移量从右到左紧接着为 -2、-3，以此类推。

>>> letters = 'abcdefghijklmnopqrstuvwxyz'
>>> letters[0]
'a'
>>> letters[1]
'b'
>>> letters[-1]
'z'
>>> letters[-2]
'y'
>>> letters[25]
'z'
>>> letters[5]
'f'

如果指定的偏移量超过了字符串的长度（记住，偏移量从 0 开始增加到字符串长度 -1），会得到一个异常提醒：

>>> letters[100]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
IndexError: string index out of range

位置索引在其他序列类型（列表和元组）中的使用也是如此，你将在第 3 章见到。

由于字符串是不可变的，因此你无法直接插入字符或改变指定位置的字符。看看当我们试图将 'Henny' 改变为 'Penny' 时会发生什么：

>>> name = 'Henny'
>>> name[0] = 'P'
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'str' object does not support item assignment

为了改变字符串，我们需要组合使用一些字符串函数，例如 replace()，以及分片操作（很快就会学到）：

>>> name = 'Henny'
>>> name.replace('H', 'P')
'Penny'
>>> 'P' + name[1:]
'Penny'

2.3.7　使用[start:end:step]分片

分片操作（slice）可以从一个字符串中抽取子字符串（字符串的一部分）。我们使用一对方括号、起始偏移量 start、终止偏移量 end 以及可选的步长 step 来定义一个分片。其中一些可以省略。分片得到的子串包含从 start 开始到 end 之前的全部字符。

[:] 提取从开头到结尾的整个字符串

[start:] 从 start 提取到结尾

[:end] 从开头提取到 end - 1

[start:end] 从 start 提取到 end - 1

[start:end:step] 从 start 提取到 end - 1，每 step 个字符提取一个

与之前一样，偏移量从左至右从 0、1 开始，依次增加；从右至左从 -1、-2 开始，依次减小。如果省略 start，分片会默认使用偏移量 0（开头）；如果省略 end，分片会默认使用偏移量 -1（结尾）。

我们来创建一个由小写字母组成的字符串：

>>> letters = 'abcdefghijklmnopqrstuvwxyz'

仅仅使用 : 分片等价于使用 0 :（也就是提取整个字符串）：

>>> letters[:]
'abcdefghijklmnopqrstuvwxyz'

下面是一个从偏移量 20 提取到字符串结尾的例子：

>>> letters[20:]
'uvwxyz'

现在，从偏移量 10 提取到结尾：

>>> letters[10:]
'klmnopqrstuvwxyz'

下一个例子提取了偏移量从 12 到 14 的字符（Python 的提取操作不包含最后一个偏移量对应的字符）：

>>> letters[12:15]
'mno'

提取最后三个字符：

>>> letters[-3:]
'xyz'

下面一个例子提取了从偏移量为 18 的字符到倒数第 4 个字符。注意与上一个例子的区别：当偏移量 -3 作为开始位置时，将获得字符 x；而当它作为终止位置时，分片实际上会在偏移量 -4 处停止，也就是提取到字符 w：

>>> letters[18:-3]
'stuvw'

接下来，试着提取从倒数第 6 个字符到倒数第 3 个字符：

>>> letters[-6:-2]
'uvwx'

如果你需要的步长不是默认的 1，可以在第二个冒号后面进行指定，就像下面几个例子所示。

从开头提取到结尾，步长设为 7：

>>> letters[::7]
'ahov'

从偏移量 4 提取到偏移量 19，步长设为 3：

>>> letters[4:20:3]
'ehknqt'

从偏移量 19 提取到结尾，步长设为 4：

>>> letters[19::4]
'tx'

从开头提取到偏移量 20，步长设为 5：

>>> letters[:21:5]
'afkpu'

（记住，分片中 end 的偏移量需要比实际提取的最后一个字符的偏移量多 1。）

是不是非常方便？但这还没有完。如果指定的步长为负数，机智的 Python 还会从右到左反向进行提取操作。下面这个例子便从右到左以步长为 1 进行提取：

>>> letters[-1::-1]
'zyxwvutsrqponmlkjihgfedcba'

事实上，你可以将上面的例子简化为下面这种形式，结果完全一致：

>>> letters[::-1]
'zyxwvutsrqponmlkjihgfedcba'

分片操作对于无效偏移量的容忍程度要远大于单字符提取操作。在分片中，小于起始位置的偏移量会被当作 0，大于终止位置的偏移量会被当作 -1，就像接下来几个例子展示的一样。

提取倒数 50 个字符：

>>> letters[-50:]
'abcdefghijklmnopqrstuvwxyz'

提取从倒数第 51 到倒数第 50 个字符：

>>> letters[-51:-50]
''

从开头提取到偏移量为 69 的字符：

>>> letters[:70]
'abcdefghijklmnopqrstuvwxyz'

从偏移量为 70 的字符提取到偏移量为 71 的字符：

>>> letters[70:71]
''

2.3.8　使用len()获得长度

到目前为止，我们已经学会了使用许多特殊的标点符号（例如 +）对字符串进行相应操作。但标点符号只有有限的几种。从现在开始，我们将学习使用 Python 的内置函数。所谓函数指的是可以执行某些特定操作的有名字的代码。

len() 函数可用于计算字符串包含的字符数：

>>> len(letters)
26
>>> empty = ""
>>> len(empty)
0

也可以对其他的序列类型使用 len()，这些内容都被放在第 3 章里讲解。

2.3.9　使用split()分割

与广义函数 len() 不同，有些函数只适用于字符串类型。为了调用字符串函数，你需要输入字符串的名称、一个点号，接着是需要调用的函数名，以及需要传入的参数：string.function(arguments)。4.7 节会学习更多关于函数的内容。

使用内置的字符串函数 split() 可以基于分隔符将字符串分割成由若干子串组成的列表。所谓列表（list）是由一系列值组成的序列，值与值之间由逗号隔开，整个列表被方括号所包裹。

>>> todos = 'get gloves,get mask,give cat vitamins,call ambulance'
>>> todos.split(',')
['get gloves', 'get mask', 'give cat vitamins', 'call ambulance']

上面例子中，字符串名为 todos，函数名为 split()，传入的参数为单一的分隔符 ','。如果不指定分隔符，那么 split() 将默认使用空白字符——换行符、空格、制表符。

>>> todos.split()
['get', 'gloves,get', 'mask,give', 'cat', 'vitamins,call', 'ambulance']

即使不传入参数，调用 split() 函数时仍需要带着括号，这样 Python 才能知道你想要进行函数调用。

2.3.10　使用join()合并

可能你已经猜到了，join() 函数与 split() 函数正好相反：它将包含若干子串的列表分解，并将这些子串合成一个完整的大的字符串。join() 的调用顺序看起来有点别扭，与 split() 相反，你需要首先指定粘合用的字符串，然后再指定需要合并的列表：string.join(list)。因此，为了将列表 lines 中的多个子串合并成完整的字符串，我们应该使用语句：'\n'.join(lines)。下面的例子将列表中的名字通过逗号及空格粘合在一起：

>>> crypto_list = ['Yeti', 'Bigfoot', 'Loch Ness Monster']
>>> crypto_string = ', '.join(crypto_list)
>>> print('Found and signing book deals:', crypto_string)
Found and signing book deals: Yeti, Bigfoot, Loch Ness Monster

2.3.11　熟悉字符串

Python 拥有非常多的字符串函数。这一节将探索其中最常用的一些。我们的测试对象是下面的字符串，它源自纽卡斯尔伯爵 Margaret Cavendish 的不朽名篇 What Is Liquid?：

>>> poem = '''All that doth flow we cannot liquid name
Or else would fire and water be the same;
But that is liquid which is moist and wet
Fire that property can never get.
Then 'tis not cold that doth the fire put out
But 'tis the wet that makes it die, no doubt.'''

先做个小热身，试着提取开头的 13 个字符（偏移量为 0 到 12）：

>>> poem[:13]
'All that doth'

这首诗有多少个字符呢？（计入空格和换行符。）

>>> len(poem)
250

这首诗是不是以 All 开头呢？

>>> poem.startswith('All')
True

它是否以 That's all, folks! 结尾？

>>> poem.endswith('That\'s all, folks!')
False

接下来，查一查诗中第一次出现单词 the 的位置（偏移量）：

>>> word = 'the'
>>> poem.find(word)
73

以及最后一次出现 the 的偏移量 ：

>>> poem.rfind(word)
214

the 在这首诗中出现了多少次？

>>> poem.count(word)
3

诗中出现的所有字符都是字母或数字吗？

>>> poem.isalnum()
False

并非如此，诗中还包括标点符号。

2.3.12　大小写与对齐方式

在这一节，我们将介绍一些不那么常用的字符串函数。我们的测试字符串如下所示：

>>> setup = 'a duck goes into a bar...'

将字符串收尾的 . 都删除掉：

>>> setup.strip('.')
'a duck goes into a bar'

　由于字符串是不可变的，上面这些例子实际上没有一个对 setup 真正做了修改。它们都仅仅是获取了 setup 的值，进行某些操作后将操作结果赋值给了另一个新的字符串而已。

让字符串首字母变成大写：

>>> setup.capitalize()
'A duck goes into a bar...'

让所有单词的开头字母变成大写：

>>> setup.title()
'A Duck Goes Into A Bar...'

让所有字母都变成大写：

>>> setup.upper()
'A DUCK GOES INTO A BAR...'

将所有字母转换成小写：

>>> setup.lower()
'a duck goes into a bar...'

将所有字母的大小写转换：

>>> setup.swapcase()
'A DUCK GOES INTO A BAR...'

再来看看与格式排版相关的函数。这里，我们假设例子中的字符串被排版在指定长度（这里是 30 个字符）的空间里。

在 30 个字符位居中：

>>> setup.center(30)
'  a duck goes into a bar...   '

左对齐：

>>> setup.ljust(30)
'a duck goes into a bar...     '

右对齐：

>>> setup.rjust(30)
'     a duck goes into a bar...'

第 7 章更加详细地介绍字符串格式以及转换，包括如何使用 % 以及 format()。

2.3.13　使用replace()替换

使用 replace() 函数可以进行简单的子串替换。你需要传入的参数包括：需要被替换的子串，用于替换的新子串，以及需要替换多少处。如果你省略了最后的参数 count，replace() 将替换所有实例（匹配到的字符串）。在这个例子中，只匹配到并且替换了一个字符串：

>>> setup.replace('duck', 'marmoset')
'a marmoset goes into a bar...'

修改最多 100 处：

>>> setup.replace('a ', 'a famous ', 100)
'a famous duck goes into a famous bar...'

当你准确地知道想要替换的子串是什么样子时，replace() 是个非常不错的选择。但使用时一定要小心！在上面第二个例子中，如果我们粗心地把需要替换的子串写成了单个字符的 'a' 而不是两个字符的 'a '（a 后面跟着一个空格）的话，会错误地将所有单词中出现的 a 也一并替换了：

>>> setup.replace('a', 'a famous', 100)
'a famous duck goes into a famous ba famousr...'

有时，你可能想确保被替换的子串是一个完整的词，或者某一个词的开头，等等。在这种情况下，你需要借助正则表达式，相关内容将在第 7 章进行介绍。

2.3.14　更多关于字符串的内容

Python 还内置了许多在此没有涉及的字符串函数，其中有一些你可以在之后的几章见到。除此之外，你可以在标准文档链接（https://docs.python.org/3/library/stdtypes.html#string-methods）获取所有字符串函数的细节。

2.4　练习

本章介绍了 Python 最基本的元素：数字、字符串以及变量。我们试着在交互式解释器里完成一些相关的练习。

(1) 一个小时有多少秒？这里，请把交互式解释器当作计算器使用，将每分钟的秒数（60）乘以每小时的分钟数（60）得到结果。

(2) 将上一个练习得到的结果（每小时的秒数）赋值给名为 seconds_per_hour 的变量。

(3) 一天有多少秒？用你的 seconds_per_hour 变量进行计算。

(4) 再次计算每天的秒数，但这一次将结果存储在名为 seconds_per_day 的变量中。

(5) 用 seconds_per_day 除以 seconds_per_hour，使用浮点除法（/）。

(6) 用 seconds_per_day 除以 seconds_per_hour，使用整数除法（//）。除了末尾的 .0，本练习所得结果是否与前一个练习用浮点数除法得到的结果一致？

第 3 章　Python 容器：列表、元组、字典与集合

第 2 章介绍了 Python 最底层的基本数据类型：布尔型、整型、浮点型以及字符串型。如果把这些数据类型看作组成 Python 的原子，那么本章将要提到的数据结构就像分子一样。在这一章中，我们会把之前所学的基本 Python 类型以更为复杂的方式组织起来。这些数据结构以后会经常用到。在编程中，最常见的工作就是将数据进行拆分或合并，将其加工为特定的形式，而数据结构就是用以切分数据的钢锯以及合并数据的粘合枪。

3.1　列表和元组

大多数编程语言都有特定的数据结构来存储由一系列元素组成的序列，这些元素以它们所处的位置为索引：从第一个到最后一个依次编号。前一章已经见过 Python 字符串了，它本质上是字符组成的序列。你也在上一章初识了列表结构——由任意类型元素组成的序列。本章将更深入地了解列表。

除字符串外，Python 还有另外两种序列结构：元组和列表。它们都可以包含零个或多个元素。与字符串不同的是，元组和列表并不要求所含元素的种类相同，每个元素都可以是任何 Python 类型的对象。得益于此，你可以根据自己的需求和喜好创建具有任意深度及复杂度的数据结构。

为什么 Python 需要同时设定列表和元组这两种序列呢？这是因为元组是不可变的，当你给元组赋值时，这些值便被固定在了元组里，再也无法修改。然而，列表却是可变的，这意味着可以随意地插入或删除其中的元素。在后面的内容中，我会举许多关于这两种结构的例子，但重点会放在列表上。

　说一点题外话，你可能听到过元组（tuple）的两种不同发音，究竟哪一种是正确的？是不是会担心，如果猜错了就会被别人认为是个装腔作势假装懂 Python 的人？其实你一点也不用担心。Python 之父吉多 ·范罗苏姆在 Twitter（https://twitter.com/gvanrossum/status/86144775731941376）上说：“每周的一三五我会把 tuple 念作 too-pull，而二四六我喜欢念作 tub-pull。至于礼拜天嘛，我从不会讨论这些。 :)”

3.2　列表

列表非常适合利用顺序和位置定位某一元素，尤其是当元素的顺序或内容经常发生改变时。与字符串不同，列表是可变的。你可以直接对原始列表进行修改：添加新元素、删除或覆盖已有元素。在列表中，具有相同值的元素允许出现多次。

3.2.1　使用[]或list()创建列表

列表可以由零个或多个元素组成，元素之间用逗号分开，整个列表被方括号所包裹：

>>> empty_list = [ ]
>>> weekdays = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday']
>>> big_birds = ['emu', 'ostrich', 'cassowary']
>>> first_names = ['Graham', 'John', 'Terry', 'Terry', 'Michael']

也可以使用 list() 函数来创建一个空列表：

>>> another_empty_list = list()
>>> another_empty_list
[]

　4.6 节会再介绍一种创建列表的方式，称为列表推导。

上面的例子中，只有 weekdays 列表充分利用了列表的顺序性。first_names 则展示了列表中的值允许重复这一性质。

　如果你仅仅想要记录一些互不相同的值，而不在乎它们之间的顺序关系，集合（set）会是一个更好的选择。在上面的例子中，big_birds 就更适合存储在一个集合中。本章的后半部分会了解到一些关于集合的内容。

3.2.2　使用list()将其他数据类型转换成列表

Python 的 list() 函数可以将其他数据类型转换成列表类型。下面的例子将一个字符串转换成了由单个字母组成的列表：

>>> list('cat')
['c', 'a', 't']

接下来的例子将一个元组（在列表之后介绍）转换成了列表：

>>> a_tuple = ('ready', 'fire', 'aim')
>>> list(a_tuple)
['ready', 'fire', 'aim']

正如 2.3.9 节所述，使用 split() 可以依据分隔符将字符串切割成由若干子串组成的列表：

>>> birthday = '1/6/1952'
>>> birthday.split('/')
['1', '6', '1952']

如果待分割的字符串中包含连续的分隔符，那么在返回的列表中会出现空串元素：

>>> splitme = 'a/b//c/d///e'
>>> splitme.split('/')
['a', 'b', '', 'c', 'd', '', '', 'e']

如果把上面例子中的分隔符改成 // 则会得到如下结果：

>>> splitme = 'a/b//c/d///e'
>>> splitme.split('//')
>>>
['a/b', 'c/d', '/e']

3.2.3　使用[offset]获取元素

和字符串一样，通过偏移量可以从列表中提取对应位置的元素：

>>> marxes = ['Groucho', 'Chico', 'Harpo']
>>> marxes[0]
'Groucho'
>>> marxes[1]
'Chico'
>>> marxes[2]
'Harpo'

同样，负偏移量代表从尾部开始计数：

>>> marxes[-1]
'Harpo'
>>> marxes[-2]
'Chico'
>>> marxes[-3]
'Groucho'
>>>

　指定的偏移量对于待访问列表必须有效——该位置的元素在访问前已正确赋值。当指定的偏移量小于起始位置或者大于末尾位置时，会产生异常（错误）。下面的例子展示了访问第 6 个（偏移量为 5，从 0 开始计数）或者倒数第 5 个 marxes 元素时产生的异常：

>>> marxes = ['Groucho', 'Chico', 'Harpo']
>>> marxes[5]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
IndexError: list index out of range

>>> marxes[-5]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
IndexError: list index out of range

3.2.4　包含列表的列表

列表可以包含各种类型的元素，包括其他列表，如下所示：

>>> small_birds = ['hummingbird', 'finch']
>>> extinct_birds = ['dodo', 'passenger pigeon', 'Norwegian Blue']
>>> carol_birds = [3, 'French hens', 2, 'turtledoves']
>>> all_birds = [small_birds, extinct_birds, 'macaw', carol_birds]

all_birds 这个列表的结构是什么样子的？

>>> all_birds
[['hummingbird', 'finch'], ['dodo', 'passenger pigeon', 'Norwegian Blue'], 'macaw',
[3, 'French hens', 2, 'turtledoves']]

来访问第一个元素看看：

>>> all_birds[0]
['hummingbird', 'finch']

第一个元素还是一个列表：事实上，它就是 small_birds，也就是创建 all_birds 列表时设定的第一个元素。以此类推，不难猜测第二个元素是什么：

>>> all_birds[1]
['dodo', 'passenger pigeon', 'Norwegian Blue']

和预想的一样，这是之前指定的第二个元素 extinct_birds。如果想要访问 extinct_birds 的第一个元素，可以指定双重索引从 all_birds 中提取：

>>> all_birds[1][0]
'dodo'

上面例子中的 [1] 指向外层列表 all_birds 的第二个元素，而 [0] 则指向内层列表的第一个元素。

3.2.5　使用[offset]修改元素

就像可以通过偏移量访问某元素一样，你也可以通过赋值对它进行修改：

>>> marxes = ['Groucho', 'Chico', 'Harpo']
>>> marxes[2] = 'Wanda'
>>> marxes
['Groucho', 'Chico', 'Wanda']

与之前一样，列表的偏移量必须是合法有效的。

通过这种方式无法修改字符串中的指定字符，因为字符串是不可变的。列表是可变的，因此你可以改变列表中的元素个数，以及元素的值。

3.2.6　指定范围并使用切片提取元素

你可以使用切片提取列表的一个子序列：

>>> marxes = ['Groucho', 'Chico', 'Harpo']
>>> marxes[0:2]
['Groucho', 'Chico']

列表的切片仍然是一个列表。

与字符串一样，列表的切片也可以设定除 1 以外的步长。下面的例子从列表的开头开始每 2 个提取一个元素：

>>> marxes[::2]
['Groucho', 'Harpo']

再试试从尾部开始提取，步长仍为 2：

>>> marxes[::-2]
['Harpo', 'Groucho']

利用切片还可以巧妙地实现列表逆序：

>>> marxes[::-1]
['Harpo', 'Chico', 'Groucho']

3.2.7　使用append()添加元素至尾部

传统的向列表中添加元素的方法是利用 append() 函数将元素一个个添加到尾部。假设前面的例子中我们忘记了添加 Zeppo，没关系，由于列表是可变的，可以方便地把它添加到尾部：

>>> marxes.append('Zeppo')
>>> marxes
['Groucho', 'Chico', 'Harpo', 'Zeppo']

3.2.8　使用extend()或+=合并列表

使用 extend() 可以将一个列表合并到另一个列表中。一个好心人又给了我们一份 Marx 兄弟的名字列表 others，我们希望能把它加到已有的 marxes 列表中：

>>> marxes = ['Groucho', 'Chico', 'Harpo', 'Zeppo']
>>> others = ['Gummo', 'Karl']
>>> marxes.extend(others)
>>> marxes
['Groucho', 'Chico', 'Harpo', 'Zeppo', 'Gummo', 'Karl']

也可以使用 +=：

>>> marxes = ['Groucho', 'Chico', 'Harpo', 'Zeppo']
>>> others = ['Gummo', 'Karl']
>>> marxes += others
>>> marxes
['Groucho', 'Chico', 'Harpo', 'Zeppo', 'Gummo', 'Karl']

如果错误地使用了 append()，那么 others 会被当成一个单独的元素进行添加，而不是将其中的内容进行合并：

>>> marxes = ['Groucho', 'Chico', 'Harpo', 'Zeppo']
>>> others = ['Gummo', 'Karl']
>>> marxes.append(others)
>>> marxes
['Groucho', 'Chico', 'Harpo', 'Zeppo', ['Gummo', 'Karl']]

这个例子再次体现了列表可以包含不同类型的元素。上面的列表包含了四个字符串元素以及一个含有两个字符串的列表元素。

3.2.9　使用insert()在指定位置插入元素

append() 函数只能将新元素插入到列表尾部，而使用 insert() 可以将元素插入到列表的任意位置。指定偏移量为 0 可以插入列表头部。如果指定的偏移量超过了尾部，则会插入到列表最后，就如同 append() 一样，这一操作不会产生 Python 异常。

>>> marxes.insert(3, 'Gummo')
>>> marxes
['Groucho', 'Chico', 'Harpo', 'Gummo', 'Zeppo']
>>> marxes.insert(10, 'Karl')
>>> marxes
['Groucho', 'Chico', 'Harpo', 'Gummo', 'Zeppo', 'Karl']

3.2.10　使用del删除指定位置的元素

校对员刚刚通知说 Gummo 确实是 Marx 兄弟的一员，但 Karl 并不是，因此我们需要撤销刚才最后插入的元素：

>>> del marxes[-1]
>>> marxes
['Groucho', 'Chico', 'Harpo', 'Gummo', 'Zeppo']

当列表中一个元素被删除后，位于它后面的元素会自动往前移动填补空出的位置，且列表长度减 1。再试试从更新后的 marxes 列表中删除 'Harpo'：

>>> marxes = ['Groucho', 'Chico', 'Harpo', 'Gummo', 'Zeppo']
>>> marxes[2]
'Harpo'
>>> del marxes[2]
>>> marxes
['Groucho', 'Chico', 'Gummo', 'Zeppo']
>>> marxes[2]
'Gummo'

　del 是 Python 语句，而不是列表方法——无法通过 marxes[-2].del() 进行调用。del 就像是赋值语句（=）的逆过程：它将一个 Python 对象与它的名字分离。如果这个对象无其他名称引用，则其占用空间也被会清除。

3.2.11　使用remove()删除具有指定值的元素

如果不确定或不关心元素在列表中的位置，可以使用 remove() 根据指定的值删除元素。再见了，Gummo：

>>> marxes = ['Groucho', 'Chico', 'Harpo', 'Gummo', 'Zeppo']
>>> marxes.remove('Gummo')
>>> marxes
['Groucho', 'Chico', 'Harpo', 'Zeppo']

3.2.12　使用pop()获取并删除指定位置的元素

使用 pop() 同样可以获取列表中指定位置的元素，但在获取完成后，该元素会被自动删除。 如果你为 pop() 指定了偏移量，它会返回偏移量对应位置的元素；如果不指定，则默认使 用 -1。因此，pop(0) 将返回列表的头元素，而 pop() 或 pop(-1) 则会返回列表的尾元素：

>>> marxes = ['Groucho', 'Chico', 'Harpo', 'Zeppo']
>>> marxes.pop()
'Zeppo'
>>> marxes
['Groucho', 'Chico', 'Harpo']
>>> marxes.pop(1)
'Chico'
>>> marxes
['Groucho', 'Harpo']

　又到了计算机术语时间了！别担心，这些不在期末考试范围内。如果使用 append() 来添加元素到尾部，并通过 pop() 从尾部删除元素，实际上，实现了一个被称为 LIFO（后进先出）队列的数据结构。我们更习惯称之为栈（stack）。如果使用 pop(0) 来删除元素则创建了一个 FIFO（先进先出）队列。这两种数据结构非常有用，你可以不断接收数据，并根据需求对最先到达的数据（FIFO）或最后到达的数据（LIFO）进行处理。

3.2.13　使用index()查询具有特定值的元素位置

如果想知道等于某一个值的元素位于列表的什么位置，可以使用 index() 函数进行查询：

>>> marxes = ['Groucho', 'Chico', 'Harpo', 'Zeppo']
>>> marxes.index('Chico')
1

3.2.14　使用in判断值是否存在

判断一个值是否存在于给定的列表中有许多方式，其中最具有 Python 风格的是使用 in：

>>> marxes = ['Groucho', 'Chico', 'Harpo', 'Zeppo']
>>> 'Groucho' in marxes
True
>>> 'Bob' in marxes
False

同一个值可能出现在列表的多个位置，但只要至少出现一次，in 就会返回 True：

>>> words = ['a', 'deer', 'a', 'female', 'deer']
>>> 'deer' in words
True

　如果经常需要判断一个值是否存在于一个列表中，但并不关心列表中元素之间的顺序，那么使用 Python 集合进行存储和查找会是更好的选择。本章的稍后部分会介绍一点关于集合的内容。

3.2.15　使用count()记录特定值出现的次数

使用 count() 可以记录某一个特定值在列表中出现的次数：

>>> marxes = ['Groucho', 'Chico', 'Harpo']
>>> marxes.count('Harpo')
1
>>> marxes.count('Bob')
0

>>> snl_skit = ['cheeseburger', 'cheeseburger', 'cheeseburger']
>>> snl_skit.count('cheeseburger')
3

3.2.16　使用join()转换为字符串

2.3.10 节详细地讨论了 join() 的使用方法，这里又提供了一种新的使用方式：

>>> marxes = ['Groucho', 'Chico', 'Harpo']
>>> ', '.join(marxes)
'Groucho, Chico, Harpo'

等等，你可能会觉得 join() 的使用顺序看起来有点别扭。这是因为 join() 实际上是一个字符串方法，而不是列表方法。不能通过 marxes.join(',') 进行调用，尽管这可能看起来更直观。join() 函数的参数是字符串或者其他可迭代的包含字符串的序列（例如上面例子中的字符串列表），它的输出是一个字符串。如果 join() 是列表方法，将无法对其他可迭代的对象（例如元组、字符串）使用。如果坚持想让它能接受任何迭代类型，你必须亲自为每一种类型的序列编写合并代码，这太过费力。试着这样来记忆 join() 的调用顺序：join() 是 split() 的逆过程，如下所示：

>>> friends = ['Harry', 'Hermione', 'Ron']
>>> separator = ' * '
>>> joined = separator.join(friends)
>>> joined
'Harry * Hermione * Ron'
>>> separated = joined.split(separator)
>>> separated
['Harry', 'Hermione', 'Ron']
>>> separated == friends
True

3.2.17　使用sort()重新排列元素

在实际应用中，经常需要将列表中的元素按值排序，而不是按照偏移量排序。Python 为此提供了两个函数：

列表方法 sort() 会对原列表进行排序，改变原列表内容；

通用函数 sorted() 则会返回排好序的列表副本，原列表内容不变。

如果列表中的元素都是数字，它们会默认地被排列成从小到大的升序。如果元素都是字符串，则会按照字母表顺序排列：

>>> marxes = ['Groucho', 'Chico', 'Harpo']
>>> sorted_marxes = sorted(marxes)
>>> sorted_marxes
['Chico', 'Groucho', 'Harpo']

sorted_marxes 是一个副本，它的创建并不会改变原始列表的内容：

>>> marxes
['Groucho', 'Chico', 'Harpo']

但对 marxes 列表调用列表函数 sort() 则会改变它的内容：

>>> marxes.sort()
>>> marxes
['Chico', 'Groucho', 'Harpo']

当列表中的所有元素都是同一种类型时（例如 marxes 中都是字符串），sort() 会正常工作。有些时候甚至多种类型也可——例如整型和浮点型——只要它们之间能够自动地互相转换：

>>> numbers = [2, 1, 4.0, 3]
>>> numbers.sort()
>>> numbers
[1, 2, 3, 4.0]

默认的排序是升序的，通过添加参数 reverse=True 可以改变为降序排列：

>>> numbers = [2, 1, 4.0, 3]
>>> numbers.sort(reverse=True)
>>> numbers
[4.0, 3, 2, 1]

3.2.18　使用len()获取长度

len() 可以返回列表长度：

>>> marxes = ['Groucho', 'Chico', 'Harpo']
>>> len(marxes)
3

3.2.19　使用=赋值，使用copy()复制

如果将一个列表赋值给了多个变量，改变其中的任何一处会造成其他变量对应的值也被修改，如下所示：

>>> a = [1, 2, 3]
>>> a
[1, 2, 3]
>>> b = a
>>> b
[1, 2, 3]
>>> a[0] = 'surprise'
>>> a
['surprise', 2, 3]

现在，b 的值是什么？它会保持 [1, 2, 3]，还是改变为 ['surprise', 2, 3] ？试一试：

>>> b
['surprise', 2, 3]

还记得第 2 章中贴标签的比喻吗？ b 与 a 实际上指向的是同一个对象，因此，无论我们是通过 a 还是通过 b 来修改列表的内容，其结果都会作用于双方：

>>> b
['surprise', 2, 3]
>>> b[0] = 'I hate surprises'
>>> b
['I hate surprises', 2, 3]
>>> a
['I hate surprises', 2, 3]

通过下面任意一种方法，都可以将一个列表的值复制到另一个新的列表中：

列表 copy() 函数

list() 转换函数

列表分片 [:]

测试初始时我们的列表叫作 a，然后利用 copy() 函数创建 b，利用 list() 函数创建 c，并使用列表分片创建 d：

>>> a = [1, 2, 3]
>>> b = a.copy()
>>> c = list(a)
>>> d = a[:]

再次注意，在这个例子中，b、c、d 都是 a 的复制：它们是自身带有值的新对象，与原始的 a 所指向的列表对象 [1, 2, 3] 没有任何关联。改变 a 不影响 b、c 和 d 的复制：

>>> a[0] = 'integer lists are boring'
>>> a
['integer lists are boring', 2, 3]
>>> b
[1, 2, 3]
>>> c
[1, 2, 3]
>>> d
[1, 2, 3]

3.3　元组

与列表类似，元组也是由任意类型元素组成的序列。与列表不同的是，元组是不可变的，这意味着一旦元组被定义，将无法再进行增加、删除或修改元素等操作。因此，元组就像是一个常量列表。

3.3.1　使用()创建元组

下面的例子展示了创建元组的过程，它的语法与我们直观上预想的有一些差别。

可以用 () 创建一个空元组：

>>> empty_tuple = ()
>>> empty_tuple
()

创建包含一个或多个元素的元组时，每一个元素后面都需要跟着一个逗号，即使只包含一个元素也不能省略：

>>> one_marx = 'Groucho',
>>> one_marx
('Groucho',)

如果创建的元组所包含的元素数量超过 1，最后一个元素后面的逗号可以省略：

>>> marx_tuple = 'Groucho', 'Chico', 'Harpo'
>>> marx_tuple
('Groucho', 'Chico', 'Harpo')

Python 的交互式解释器输出元组时会自动添加一对圆括号。你并不需要这么做——定义元组真正靠的是每个元素的后缀逗号——但如果你习惯添加一对括号也无可厚非。可以用括号将所有元素包裹起来，这会使得程序更加清晰：

>>> marx_tuple = ('Groucho', 'Chico', 'Harpo')
>>> marx_tuple
('Groucho', 'Chico', 'Harpo')

可以一口气将元组赋值给多个变量：

>>> marx_tuple = ('Groucho', 'Chico', 'Harpo')
>>> a, b, c = marx_tuple
>>> a
'Groucho'
>>> b
'Chico'
>>> c
'Harpo'

有时这个过程被称为元组解包。

可以利用元组在一条语句中对多个变量的值进行交换，而不需要借助临时变量：

>>> password = 'swordfish'
>>> icecream = 'tuttifrutti'
>>> password, icecream = icecream, password
>>> password
'tuttifrutti'
>>> icecream
'swordfish'
>>>

tuple() 函数可以用其他类型的数据来创建元组：

>>> marx_list = ['Groucho', 'Chico', 'Harpo']
>>> tuple(marx_list)
('Groucho', 'Chico', 'Harpo')

3.3.2　元组与列表

在许多地方都可以用元组代替列表，但元组的方法函数与列表相比要少一些——元组没有 append()、insert()，等等——因为一旦创建元组便无法修改。既然列表更加灵活，那为什么不在所有地方都使用列表呢？原因如下所示：

元组占用的空间较小

你不会意外修改元组的值

可以将元组用作字典的键（详见 3.4 节）

命名元组（详见第 6 章“命名元组”小节）可以作为对象的替代

函数的参数是以元组形式传递的（详见 4.7 节）

这一节不会再介绍更多关于元组的细节了。实际编程中，更多场合用到的是列表和字典，而接下来要介绍的就是字典结构。

3.4　字典

字典（dictionary）与列表类似，但其中元素的顺序无关紧要，因为它们不是通过像 0 或 1 的偏移量访问的。取而代之，每个元素拥有与之对应的互不相同的键（key），需要通过键来访问元素。键通常是字符串，但它还可以是 Python 中其他任意的不可变类型：布尔型、整型、浮点型、元组、字符串，以及其他一些在后面的内容中会见到的类型。字典是可变的，因此你可以增加、删除或修改其中的键值对。

如果使用过只支持数组或列表的语言，那么你很快就会爱上 Python 里的字典类型。

　在其他语言中，字典可能会被称作关系型数组、哈希表或哈希图。在 Python 中，字典（dictionary）还经常会被简写成 dict。

3.4.1　使用{}创建字典

用大括号（{}）将一系列以逗号隔开的键值对（key:value）包裹起来即可进行字典的创建。最简单的字典是空字典，它不包含任何键值对：

>>> empty_dict = {}
>>> empty_dict
{}

引用 Ambrose Bierce 的《魔鬼辞典》（The Devil's Dictionary）来创建一个字典：

>>> bierce = {
...     "day": "A period of twenty-four hours, mostly misspent",
...     "positive": "Mistaken at the top of one's voice",
...     "misfortune": "The kind of fortune that never misses",
...     }
>>>

在交互式解释器中输入字典名会打印出它所包含的所有键值对：

>>> bierce
{'misfortune': 'The kind of fortune that never misses',
'positive': "Mistaken at the top of one's voice",
'day': 'A period of twenty-four hours, mostly misspent'}

　Python 允许在列表、元组或字典的最后一个元素后面添加逗号，这不会产生任何问题。此外，在括号之间输入键值对来创建字典时并不强制缩进，我这么做只是为了增加代码的可读性。

3.4.2　使用dict()转换为字典

可以用 dict() 将包含双值子序列的序列转换成字典。（你可能会经常遇到这种子序列，例如“Strontium，90，Carbon，14”或者“Vikings，20，Packers，7”，等等。）每个子序列的第一个元素作为键，第二个元素作为值。

首先，这里有一个使用 lol（a list of two-item list）创建字典的小例子：

>>> lol = [ ['a', 'b'], ['c', 'd'], ['e', 'f'] ]
>>> dict(lol)
{'c': 'd', 'a': 'b', 'e': 'f'}

　记住，字典中元素的顺序是无关紧要的，实际存储顺序可能取决于你添加元素的顺序。

可以对任何包含双值子序列的序列使用 dict()，下面是其他例子。

包含双值元组的列表：

>>> lot = [ ('a', 'b'), ('c', 'd'), ('e', 'f') ]
>>> dict(lot)
{'c': 'd', 'a': 'b', 'e': 'f'}

包含双值列表的元组：

>>> tol = ( ['a', 'b'], ['c', 'd'], ['e', 'f'] )
>>> dict(tol)
{'c': 'd', 'a': 'b', 'e': 'f'}

双字符的字符串组成的列表：

>>> los = [ 'ab', 'cd', 'ef' ]
>>> dict(los)
{'c': 'd', 'a': 'b', 'e': 'f'}

双字符的字符串组成的元组：

>>> tos = ( 'ab', 'cd', 'ef' )
>>> dict(tos)
{'c': 'd', 'a': 'b', 'e': 'f'}

4.5.4 节会教你如何使用 zip() 函数，利用它能非常简单地创建上面这种双元素序列。

3.4.3　使用[key]添加或修改元素

向字典中添加元素非常简单，只需指定该元素的键并赋予相应的值即可。如果该元素的键已经存在于字典中，那么该键对应的旧值会被新值取代。如果该元素的键并未在字典中出现，则会被加入字典。与列表不同，你不需要担心赋值过程中 Python 会抛出越界异常。

我们来建立一个包含大多数 Monty Python 成员名字的字典，用他们的姓当作键，名当作值：

>>> pythons = {
...     'Chapman': 'Graham',
...     'Cleese': 'John',
...     'Idle': 'Eric',
...     'Jones': 'Terry',
...     'Palin': 'Michael',
...     }
>>> pythons
{'Cleese': 'John', 'Jones': 'Terry', 'Palin': 'Michael',
'Chapman': 'Graham', 'Idle': 'Eric'}

等等，我们好像落下了一个人：生于美国的 Terry Gilliam。下面是一个糟糕的程序员为了将 Gilliam 添加进字典而编写的代码，他粗心地将 Gilliam 的名字打错了：

>>> pythons['Gilliam'] = 'Gerry'
>>> pythons
{'Cleese': 'John', 'Gilliam': 'Gerry', 'Palin': 'Michael',
'Chapman': 'Graham', 'Idle': 'Eric', 'Jones': 'Terry'}

下面是另一位颇具 Python 风格的程序员的修补代码：

>>> pythons['Gilliam'] = 'Terry'
>>> pythons
{'Cleese': 'John', 'Gilliam': 'Terry', 'Palin': 'Michael',
'Chapman': 'Graham', 'Idle': 'Eric', 'Jones': 'Terry'}

通过使用相同的键（'Gilliam'）将原本的对应值 'Gerry' 修改为了 'Terry'。

记住，字典的键必须保证互不相同。这就是为什么在这里使用姓作为键，而不是使用名——Monty Python 的成员中有两个都叫 Terry ！如果创建字典时同一个键出现了两次，那么后面出现的值会取代之前的值：

>>> some_pythons = {
...     'Graham': 'Chapman',
...     'John': 'Cleese',
...     'Eric': 'Idle',
...     'Terry': 'Gilliam',
...     'Michael': 'Palin',
...     'Terry': 'Jones',
...     }
>>> some_pythons
{'Terry': 'Jones', 'Eric': 'Idle', 'Graham': 'Chapman',
'John': 'Cleese', 'Michael': 'Palin'}

上面例子中，我们首先将 'Gilliam' 赋值给了键 'Terry'，然后又用 'Jones' 把它取代掉了。

3.4.4　使用update()合并字典

使用 update() 可以将一个字典的键值对复制到另一个字典中去。

首先定义一个包含所有成员的字典 pythons：

>>> pythons = {
...     'Chapman': 'Graham',
...     'Cleese': 'John',
...     'Gilliam': 'Terry',
...     'Idle': 'Eric',
...     'Jones': 'Terry',
...     'Palin': 'Michael',
...     }
>>> pythons
{'Cleese': 'John', 'Gilliam': 'Terry', 'Palin': 'Michael',
'Chapman': 'Graham', 'Idle': 'Eric', 'Jones': 'Terry'}

接着定义一个包含其他喜剧演员的字典，命名为 others：

>>> others = { 'Marx': 'Groucho', 'Howard': 'Moe' }

现在，出现了另一个糟糕的程序员，它认为 others 应该被归入 Monty Python 成员中：

>>> pythons.update(others)
>>> pythons
{'Cleese': 'John', 'Howard': 'Moe', 'Gilliam': 'Terry',
'Palin': 'Michael', 'Marx': 'Groucho', 'Chapman': 'Graham',
'Idle': 'Eric', 'Jones': 'Terry'}

如果待添加的字典与待扩充的字典包含同样的键会怎样？是的，新归入字典的值会取代原有的值：

>>> first = {'a': 1, 'b': 2}
>>> second = {'b': 'platypus'}
>>> first.update(second)
>>> first
{'b': 'platypus', 'a': 1}

3.4.5　使用del删除具有指定键的元素

技术上来说，上面那个糟糕的程序员写的代码倒是正确的。但是他不应该这么做！ others 里的成员虽然也很搞笑很出名，但他们终归不是 Monty Python 的成员。把最后添加的两个成员清除出去：

>>> del pythons['Marx']
>>> pythons
{'Cleese': 'John', 'Howard': 'Moe', 'Gilliam': 'Terry',
'Palin': 'Michael', 'Chapman': 'Graham', 'Idle': 'Eric',
'Jones': 'Terry'}
>>> del pythons['Howard']
>>> pythons
{'Cleese': 'John', 'Gilliam': 'Terry', 'Palin': 'Michael',
'Chapman': 'Graham', 'Idle': 'Eric', 'Jones': 'Terry'}

3.4.6　使用clear()删除所有元素

使用 clear()，或者给字典变量重新赋值一个空字典（{}）可以将字典中所有元素删除：

>>> pythons.clear()
>>> pythons
{}
>>> pythons = {}
>>> pythons
{}

3.4.7　使用in判断是否存在

如果你希望判断某一个键是否存在于一个字典中，可以使用 in。我们来重新定义一下 pythons 字典，这一次可以省略一两个人名：

>>> pythons = {'Chapman': 'Graham', 'Cleese': 'John',
'Jones': 'Terry', 'Palin': 'Michael'}

测试一下看看谁在里面：

>>> 'Chapman' in pythons
True
>>> 'Palin' in pythons
True

这一次记得把 Terry Gilliam 添加到成员字典里了吗？

>>> 'Gilliam' in pythons
False

糟糕，好像又忘记了。

3.4.8　使用[key]获取元素

这是对字典最常进行的操作，只需指定字典名和键即可获得对应的值：

>>> pythons['Cleese']
'John'

如果字典中不包含指定的键，会产生一个异常：

>>> pythons['Marx']
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
KeyError: 'Marx'

有两种方法可以避免这种情况的发生。第一种是在访问前通过 in 测试键是否存在，就像在上一小节看到的一样：

>>> 'Marx' in pythons
False

另一种方法是使用字典函数 get()。你需要指定字典名，键以及一个可选值。如果键存在，会得到与之对应的值：

>>> pythons.get('Cleese')
'John'

反之，若键不存在，如果你指定了可选值，那么 get() 函数将返回这个可选值：

>>> pythons.get('Marx', 'Not a Python')
'Not a Python'

否则，会得到 None（在交互式解释器中什么也不会显示）：

>>> pythons.get('Marx')
>>>

3.4.9　使用keys()获取所有键

使用 keys() 可以获得字典中的所有键。在接下来的几个例子中，我们将换一个示例：

>>> signals = {'green': 'go', 'yellow': 'go faster', 'red': 'smile for the camera'}
>>> signals.keys()
dict_keys(['green', 'red', 'yellow'])

　在 Python 2 里，keys() 会返回一个列表，而在 Python 3 中则会返回 dict_keys()，它是键的迭代形式。这种返回形式对于大型的字典非常有用，因为它不需要时间和空间来创建返回的列表。有时你需要的可能就是一个完整的列表，但在 Python 3 中，你只能自己调用 list() 将 dict_keys 转换为列表类型。

>>> list( signals.keys() )
['green', 'red', 'yellow']

在 Python 3 里，你同样需要手动使用 list() 将 values() 和 items() 的返回值转换为普通的 Python 列表。之后的例子中会用到这些。

3.4.10　使用values()获取所有值

使用 values() 可以获取字典中的所有值：

>>> list( signals.values() )
['go', 'smile for the camera', 'go faster']

3.4.11　使用items()获取所有键值对

使用 items() 函数可以获取字典中所有的键值对：

>>> list( signals.items() )
[('green', 'go'), ('red', 'smile for the camera'), ('yellow', 'go faster')]

每一个键值对以元组的形式返回，例如 ('green','go')。

3.4.12　使用=赋值，使用copy()复制

与列表一样，对字典内容进行的修改会反映到所有与之相关联的变量名上：

>>> signals = {'green': 'go', 'yellow': 'go faster', 'red': 'smile for the camera'}
>>> save_signals = signals
>>> signals['blue'] = 'confuse everyone'
>>> save_signals
{'blue': 'confuse everyone', 'green': 'go',
'red': 'smile for the camera', 'yellow': 'go faster'}

若想避免这种情况，可以使用 copy() 将字典复制到一个新的字典中：

>>> signals = {'green': 'go', 'yellow': 'go faster', 'red': 'smile for the camera'}
>>> original_signals = signals.copy()
>>> signals['blue'] = 'confuse everyone'
>>> signals
{'blue': 'confuse everyone', 'green': 'go',
'red': 'smile for the camera', 'yellow': 'go faster'}
>>> original_signals
{'green': 'go', 'red': 'smile for the camera', 'yellow': 'go faster'}

3.5　集合

集合就像舍弃了值，仅剩下键的字典一样。键与键之间也不允许重复。如果你仅仅想知道某一个元素是否存在而不关心其他的，使用集合是个非常好的选择。如果需要为键附加其他信息的话，建议使用字典。

很久以前，当你还在小学时，可能就学到过一些关于集合论的知识。当然，如果你的学校恰好跳过了这一部分内容（或者实际上教了，但你当时正好盯着窗外发呆，我小时候就总是这样开小差），可以仔细看看图 3-1，它展示了我们对于集合进行的最基本的操作——交和并。

图 3-1：集合的常见操作

假如你将两个包含相同键的集合进行并操作，由于集合中的元素只能出现一次，因此得到的并集将包含两个集合所有的键，但每个键仅出现一次。空或空集指的是包含零个元素的集合。在图 3-1 中，名字以 X 开头的女性组成的集合就是一个空集。

3.5.1　使用set()创建集合

你可以使用 set() 函数创建一个集合，或者用大括号将一系列以逗号隔开的值包裹起来，如下所示：

>>> empty_set = set()
>>> empty_set
set()
>>> even_numbers = {0, 2, 4, 6, 8}
>>> even_numbers
{0, 8, 2, 4, 6}
>>> odd_numbers = {1, 3, 5, 7, 9}
>>> odd_numbers
{9, 3, 1, 5, 7}

与字典的键一样，集合是无序的。

　由于 [] 能创建一个空列表，你可能期望 {} 也能创建空集。但事实上，{} 会创建一个空字典，这也是为什么交互式解释器把空集输出为 set() 而不是 {}。为何如此？没有什么特殊原因，仅仅是因为字典出现的比较早并抢先占据了花括号。

3.5.2　使用set()将其他类型转换为集合

你可以利用已有列表、字符串、元组或字典的内容来创建集合，其中重复的值会被丢弃。

首先来试着转换一个包含重复字母的字符串：

>>> set( 'letters' )
{'l', 'e', 't', 'r', 's'}

注意，上面得到的集合中仅含有一个 'e' 和一个 't'，尽管字符串 'letters' 里各自包含两个。

再试试用列表建立集合：

>>> set( ['Dasher', 'Dancer', 'Prancer', 'Mason-Dixon'] )
{'Dancer', 'Dasher', 'Prancer', 'Mason-Dixon'}

再试试元组：

>>> set( ('Ummagumma', 'Echoes', 'Atom Heart Mother') )
{'Ummagumma', 'Atom Heart Mother', 'Echoes'}

当字典作为参数传入 set() 函数时，只有键会被使用：

>>> set( {'apple': 'red', 'orange': 'orange', 'cherry': 'red'} )
{'apple', 'cherry', 'orange'}

3.5.3　使用in测试值是否存在

这是集合里最常用的功能。我们来建立一个叫 drinks 的字典。每个键都是一种混合饮料的名字，与之对应的值是配料组成的集合：

>>> drinks = {
...     'martini': {'vodka', 'vermouth'},
...     'black russian': {'vodka', 'kahlua'},
...     'white russian': {'cream', 'kahlua', 'vodka'},
...     'manhattan': {'rye', 'vermouth', 'bitters'},
...     'screwdriver': {'orange juice', 'vodka'}
...     }

尽管都由花括号（{ 和 }）包裹，集合仅仅是一系列值组成的序列，而字典是一个或多个键值对组成的序列。

哪种饮料含有伏特加？（注意，后面例子中我会提前使用一点下一章出现的 for、if、and 以及 or 语句。）

>>> for name, contents in drinks.items():
...     if 'vodka' in contents:
...         print(name)
...
screwdriver
martini
black russian
white russian

我想挑的饮料需要有伏特加，但不含乳糖。此外，我很讨厌苦艾酒，觉得它尝起来就像煤油一样：

>>> for name, contents in drinks.items():
...     if 'vodka' in contents and not ('vermouth' in contents or
...         'cream' in contents):
...         print(name)
...
screwdriver
black russian

后面的内容会将上面的代码改写得更加简洁。

3.5.4　合并及运算符

如果想要查看多个集合之间组合的结果应该怎么办？例如，你想要找到一种饮料，它含有果汁或含有苦艾酒。我们可以使用交集运算符，记作 &：

>>> for name, contents in drinks.items():
...     if contents & {'vermouth', 'orange juice'}:
...         print(name)
...
screwdriver
martini
manhattan

& 运算符的结果是一个集合，它包含所有同时出现在你比较的两个清单中的元素。在上面代码中，如果 contents 里面不包含任何一种指定成分，则 & 会返回一个空集，相当于 False。

现在来改写一下上一小节的例子，就是那个我们想要伏特加但不需要乳脂也不需要苦艾酒的例子：

>>> for name, contents in drinks.items():
...     if 'vodka' in contents and not contents & {'vermouth', 'cream'}:
...         print(name)
...
screwdriver
black russian

将这两种饮料的原料都存储到变量中，以便于后面的例子不用再重复输入：

>>> bruss = drinks['black russian']
>>> wruss = drinks['white russian']

之后的例子会涵盖所有的集合运算符。有些运算使用特殊定义过的标点，另一些则使用函数，还有一些运算两者都可使用。这里使用测试集合 a（包含 1 和 2），以及 b（包含 2 和 3）：

>>> a = {1, 2}
>>> b = {2, 3}

可以通过使用特殊标点符号 & 或者集合函数 intersection() 获取集合的交集（两集合共有元素），如下所示：

>>> a & b
{2}
>>> a.intersection(b)
{2}

下面的代码片段使用了我们之前存储的饮料变量：

>>> bruss & wruss
{'kahlua', 'vodka'}

下面的例子中，使用 | 或者 union() 函数来获取集合的并集（至少出现在一个集合中的元素）：

>>> a | b
{1, 2, 3}
>>> a.union(b)
{1, 2, 3}

还有酒精饮料的例子：

>>> bruss | wruss
{'cream', 'kahlua', 'vodka'}

使用字符 - 或者 difference() 可以获得两个集合的差集（出现在第一个集合但不出现在第二个集合）：

>>> a - b
{1}
>>> a.difference(b)
{1}

>>> bruss - wruss
set()
>>> wruss - bruss
{'cream'}

到目前为止，出现的都是最常见的集合运算，包括交、并、差。出于完整性的考虑，我会在接下来的例子中列出其他的运算符，这些运算符并不常用。

使用 ^ 或者 symmetric_difference() 可以获得两个集合的异或集（仅在两个集合中出现一次）：

>>> a ^ b
{1, 3}
>>> a.symmetric_difference(b)
{1, 3}

下面的代码帮我们找到了两种俄罗斯饮料的不同成分：

>>> bruss ^ wruss
{'cream'}

使用 <= 或者 issubset() 可以判断一个集合是否是另一个集合的子集（第一个集合的所有元素都出现在第二个集合中）：

>>> a <= b
False
>>> a.issubset(b)
False

向“黑俄罗斯酒”中加入一些乳脂就变成了“白俄罗斯酒”，因此 wruss 是 bruss 的超集：

>>> bruss <= wruss
True

一个集合是它本身的子集吗？答案为：是的。

>>> a <= a
True
>>> a.issubset(a)
True

当第二个集合包含所有第一个集合的元素，且仍包含其他元素时，我们称第一个集合为第二个集合的真子集。使用 < 可以进行判断：

>>> a < b
False
>>> a < a
False

>>> bruss < wruss
True

超集与子集正好相反（第二个集合的所有元素都出现在第一个集合中），使用 >= 或者 issuperset() 可以进行判断：

>>> a >= b
False
>>> a.issuperset(b)
False

>>> wruss >= bruss
True

一个集合是它本身的超集：

>>> a >= a
True
>>> a.issuperset(a)
True

最后，使用 > 可以找到一个集合的真超集（第一个集合包含第二个集合的所有元素且还包含其他元素）：

>>> a > b
False

>>> wruss > bruss
True

一个集合并不是它本身的真超集：

>>> a > a
False

3.6　比较几种数据结构

回顾一下，我们学会了使用方括号（[]）创建列表，使用逗号创建元组，使用花括号（{}）创建字典。在每一种类型中，都可以通过方括号对单个元素进行访问：

>>> marx_list = ['Groucho', 'Chico', 'Harpo']
>>> marx_tuple = 'Groucho', 'Chico', 'Harpo'
>>> marx_dict = {'Groucho': 'banjo', 'Chico': 'piano', 'Harpo': 'harp'}
>>> marx_list[2]
'Harpo'
>>> marx_tuple[2]
'Harpo'
>>> marx_dict['Harpo']
'harp'

对于列表和元组来说，方括号里的内容是整型的偏移量；而对于字典来说，方括号里的是键。它们返回的都是元素的值。

3.7　建立大型数据结构

我们从最简单的布尔型、数字、字符串型开始学习，到目前为止，学习到了列表、元组、集合以及字典等数据结构。你可以将这些内置的数据结构自由地组合成更大、更复杂的结构。试着从建立三个不同的列表开始：

>>> marxes = ['Groucho', 'Chico', 'Harpo']
>>> pythons = ['Chapman', 'Cleese', 'Gilliam', 'Jones', 'Palin']
>>> stooges = ['Moe', 'Curly', 'Larry']

可以把上面每一个列表当作一个元素，并建立一个元组：

>>> tuple_of_lists = marxes, pythons, stooges
>>> tuple_of_lists
(['Groucho', 'Chico', 'Harpo'],
['Chapman', 'Cleese', 'Gilliam', 'Jones', 'Palin'],
['Moe', 'Curly', 'Larry'])

同样，可以创建一个包含上面三个列表的列表：

>>> list_of_lists = [marxes, pythons, stooges]
>>> list_of_lists
[['Groucho', 'Chico', 'Harpo'],
['Chapman', 'Cleese', 'Gilliam', 'Jones', 'Palin'],
['Moe', 'Curly', 'Larry']]

还可以创建以这三个列表为值的字典。本例中，使用这些喜剧演员组合的名字作为键，成员列表作为值：

>>> dict_of_lists = {'Marxes': marxes, 'Pythons': pythons, 'Stooges': stooges}
>> dict_of_lists
{'Stooges': ['Moe', 'Curly', 'Larry'],
'Marxes': ['Groucho', 'Chico', 'Harpo'],
'Pythons': ['Chapman', 'Cleese', 'Gilliam', 'Jones', 'Palin']}

在创建自定义数据结构的过程中，唯一的限制来自于这些内置数据类型本身。比如，字典的键必须为不可变对象，因此列表、字典以及集合都不能作为字典的键，但元组可以作为字典的键。举个例子，我们可以通过 GPS 坐标（纬度，经度，海拔；B.6 节可以看到更多与地图有关的例子）定位感兴趣的位置：

>>> houses = {
        (44.79, -93.14, 285): 'My House',
        (38.89, -77.03, 13): 'The White House'
        }

3.8　练习

在这一章，你见到了一些更为复杂的数据结构：列表、元组、字典和集合。你可以使用这些数据结构以及第 2 章提到的数据类型（数字和字符串）来表示现实生活中各种各样的元素。

(1) 创建一个叫作 years_list 的列表，存储从你出生的那一年到五岁那一年的年份。例如，如果你是 1980 年出生的，那么你的列表应该是 years_list = [1980, 1981, 1982, 1983, 1984, 1985]。

如果你现在还没到五岁却在阅读本书，那我真的没有什么可教你的了。

(2) 在 years_list 中，哪一年是你三岁生日那年？别忘了，你出生的第一年算 0 岁。

(3) 在 years_list 中，哪一年你的年纪最大？

(4) 创 建 一 个 名 为 things 的 列 表， 包 含 以 下 三 个 元 素："mozzarella"、"cinderella" 和 "salmonella"。

(5) 将 things 中代表人名的字符串变成首字母大写形式，并打印整个列表。看看列表中的元素改变了吗？

(6) 将 things 中代表奶酪的元素全部改成大写，并打印整个列表。

(7) 将代表疾病的元素从 things 中删除，收好你得到的诺贝尔奖，并打印列表。

(8) 创建一个名为 surprise 的列表，包含以下三个元素："Groucho"、"Chico" 和 "Harpo"。

(9) 将 surprise 列表的最后一个元素变成小写，翻转过来，再将首字母变成大写。

(10) 创建一个名为 e2f 的英法字典并打印出来。这里提供一些单词对：dog 是 chien，cat 是 chat，walrus 是 morse。

(11) 使用你的仅包含三个词的字典 e2f 查询并打印出 walrus 对应的的法语词。

(12) 利用 e2f 创建一个名为 f2e 的法英字典。注意要使用 items 方法。

(13) 使用 f2e，查询并打印法语词 chien 对应的英文词。

(14) 创建并打印由 e2f 的键组成的英语单词集合。

(15) 建立一个名为 life 的多级字典。将下面这些字符串作为顶级键：'animals'、'plants' 以及 'others'。令 'animals' 键指向另一个字典，这个字典包含键 'cats'、'octopi' 以及 'emus'。令 'cat' 键指向一个字符串列表，这个列表包括 'Henri'、'Grumpy' 和 'Lucy'。让其余的键都指向空字典。

(16) 打印 life 的顶级键。

(17) 打印 life['animals'] 的全部键。

(18) 打印 life['animals']['cats'] 的值。

第 4 章　Python 外壳：代码结构

在前三章中，我们用到很多关于数据的例子，但是没有对它们进行复杂的操作。大多数的示例代码都很短，并且使用交互式解释器进行解释执行。本章将介绍如何组织代码和数据。

许多计算机编程语言使用字符（例如花括号 { 和 }）或者关键字（例如 begin 和 end）来划分代码段。在这些语言中，使用一致的代码缩进可以增加代码的可读性，并且有很多便利的工具整理缩进代码。

在吉多 · 范 · 罗苏姆开始考虑设计 Python 语言时，他决定通过代码缩进来区分代码块结构，避免输入太多的花括号和关键字。Python 使用空白来区分代码结构，这是初学者需要注意的不同寻常的第一点，而且有其他语言开发经验的人会觉得奇怪。但使用 Python 一段时间后会觉得很自然，而且会习惯于编写简洁的代码来进行大量的编程工作。

4.1　使用#注释

注释是程序中会被 Python 解释器忽略的一段文本。通过使用注释，可以解释和明确 Python 代码的功能，记录将来要修改的地方，甚至写下你想写的任何东西。在 Python 中使用 # 字符标记注释，从 # 开始到当前行结束的部分都是注释。你可以把注释作为单独的一行，如下所示：

>>> # 60 sec/min * 60 min/hr * 24 hr/day
>>> seconds_per_day = 86400

也可以把注释和代码放在同一行：

>>> seconds_per_day = 86400 # 60 sec/min * 60 min/hr * 24 hr/day

# 有很多含义和叫法：hash、sharp、pound 或者 octothorpe1。无论你如何称呼它 2，在 Python 代码中，注释的作用只对它出现的当前行有效。

1样子像在你身边的凶恶的八脚怪物。

2嘘，别叫它，小心它回来找你。

Python 没有多行注释的符号。你需要明确地在注释部分的每一行开始处加上一个 #。

>>> # 尽管Python不会喜欢，但是我可以在这里讲任何东西
... # 因为我被“保护”
... # 令人敬畏的#号
...
>>>

然而，如果它出现在文本串中，将回归普通字符 # 的角色：

>>> print("No comment:  quotes make the # harmless.")
No comment: quotes make the # harmless.

4.2　使用\连接

程序在合理的长度下是易读的。一行程序的（非强制性）最大长度建议为 80 个字符。如果你在该长度下写不完你的代码，可以使用连接符 \（反斜线）。把它放在一行的结束位置，Python 仍然将其解释为同一行。

例如，假设想把一些短字符串拼接为一个长字符串，可以按照下面的步骤：

>>> alphabet = ''
>>> alphabet += 'abcdefg'
>>> alphabet += 'hijklmnop'
>>> alphabet += 'qrstuv'
>>> alphabet += 'wxyz'

或者，使用连接符一步就可以完成：

>>> alphabet = 'abcdefg' + \
...      'hijklmnop' + \
...      'qrstuv' + \
...      'wxyz'

在 Python 表达式占很多行的情况下，行连接符也是必需的：

>>> 1 + 2 +
  File "<stdin>", line 1
    1 + 2 +
          ^
SyntaxError: invalid syntax
>>> 1 + 2 + \
... 3
6
>>>

4.3　使用if、elif和else进行比较

到目前为止，我们几乎一直在讨论数据结构。现在，我们将迈出探讨代码结构的第一步，将数据编排成代码（上一章讲到集合时已经见到一些例子，希望那些不会造成理解困难）。下面第一个例子是一个 Python 小程序，判断一个布尔变量 disaster 的值，然后打印输出合适的取值：

>>> disaster = True
>>> if disaster:
...     print("Woe!")
... else:
...     print("Whee!")
...
Woe!
>>>

程序中，if 和 else 两行是 Python 用来声明判断条件（本例中是 disaster 的值）是否满足的语句。print() 是将字符打印到屏幕的 Python 内建函数。

　如果你之前使用过其他编程语言，不需要在 if 判断语句中加上圆括号，例如 if (disaster == True) 是没必要的，但在判断的末尾要加上冒号（:）。如果你像我一样有时会忘记输入冒号，这会导致 Python 解释器报错。

每一个 print() 在判断语句之后要缩进。我一般缩进四个空格。尽管你可以使用你喜欢的任何方式缩进，但是在同一代码段内最好使用一致的缩进——从每一行的左边开始使用相同数量的缩进字符。推荐的代码缩进风格 PEP-8（http://legacy.python.org/dev/peps/pep-0008/）同样使用四个空格。避免使用 Tab 字符或者 Tab 与 Space 混合的缩进风格，这样会使缩进字符的数量变得混乱。

上面的示例代码做了以下事情，在随后的内容中我还会更详细地介绍。

将布尔值 True 赋值给变量 disaster。

使用 if 和 else 进行条件比较判断，根据 disaster 的值执行不同的代码。

调用 print() 函数，在屏幕打印文本。

同时你可以根据需要进行多层判断语句的嵌套：

>>> furry = True
>>> small = True
>>> if furry:
...     if small:
...         print("It's a cat.")
...     else:
...         print("It's a bear!")
... else:
...     if small:
...         print("It's a skink!")
...     else:
...         print("It's a human. Or a hairless bear.")
...
It's a cat.

在 Python 中，代码缩进决定了 if 和 else 是如何配对的。在第一个判断 furry 中，因为 furry 的值是 True，所以程序跳转到执行判断 if small。我们之前将 small 赋值为 True，所以 if small 的值被估计为 True。因此程序会执行它的下一行，输出 It's a cat。

如果要检查超过两个条件，可以使用 if、elif（即 else if）和 else：

>>> color = "puce"
>>> if color == "red":
...     print("It's a tomato")
... elif color == "green":
...     print("It's a green pepper")
... elif color == "bee purple":
...     print("I don't know what it is, but only bees can see it")
... else:
...     print("I've never heard of the color", color)
...
I've never heard of the color puce

上面的例子中，我们使用了 == 作为判断相等的操作符，Python 中的比较操作符见下表。

相等 | ==
不等于 | !=
小于 | <
不大于 | <=
大于 | >
不小于 | >=
属于 | in...

这些操作符都返回布尔值 True 或者 False。让我们看看这些是如何执行的，首先对变量 x

赋值：

>>> x = 7

现在，测试一些例子：

>>> x == 5
False
>>> x == 7
True
>>> 5 < x
True
>>> x < 10
True

注意，两个等号（==）是用来判断相等的，而一个等号（=）是把某个值赋给一个变量。

如果你想同时进行多重比较判断，可以使用布尔操作符 and、or 或者 not 连接来决定最终表达式的布尔取值。

布尔操作符的优先级没有比较表达式的代码段高，也就是说，表达式要先计算然后再比较。在这个例子中，x 赋值为 7，5 < x 返回 True，x < 10 也同样返回 True， 最终的结果就是 True and True：

>>> 5 < x and x < 10
True

2.2.2 节中提到，避免混淆的最简单的办法是加圆括号：

>>> ( 5 < x ) and ( x < 10)
True

下面是其他的一些例子：

>>> 5 < x or x < 10
True
>>> 5 < x and x > 10
False
>>> 5 < x and not x > 10
True

如果对同一个变量做多个 and 比较操作，Python 允许下面的用法：

>>> 5 < x < 10
True

这个表达式和 5 < x and x < 10 是一样的，你也可以使用更长的比较：

>>> 5 < x < 10 < 999
True

什么是真值（True）

如果表达式的返回类型不是布尔会发生什么？什么情况下 Python 会认为是 True 和 False?

一个成假赋值不一定明确表示为 False，下面的情况也会被认为是 False。

布尔 | False
null 类型 | None
整型 | 0
浮点型 | 0.0
空字符串 | ''
空列表 | []
空元组 | ()
空字典 | {}
空集合 | set()

剩下的都会被认为是 True。Python 程序中使用定义“真值”（在本例中是“假值”）的方式来判断数据结构是否为空以及成假条件：

>>> some_list = []
>>> if some_list:
...     print("There's something in here")
... else:
...     print("Hey, it's empty!")
...
Hey, it's empty!

如果你在判断一个表达式而不是一个简单的变量，Python 会先计算表达式的值，然后返回布尔型结果。所以，如果你输入以下的式子：

if color == "red":

Python 会判断 color == "red"。示例中，已把字符串 "puce" 赋值给 color，所以 color == "red" 为 False，Python 转而执行下面的判断：

elif color == "green":

4.4　使用while进行循环

使用 if、elif 和 else 条件判断的例子是自顶向下执行的，但是有时候我们需要重复一些操作——循环。Python 中最简单的循环机制是 while。打开交互式解释器，执行下面的从 1 打印到 5 的简单循环：

>>> count = 1
>>> while count <= 5:
...     print(count)
...     count += 1
...
1
2
3
4
5
>>>

首先将变量 count 的值赋为 1，while 循环比较 count 的值和 5 的大小关系，如果 count 小于等于 5 的话继续执行。在循环内部，打印 count 变量的值，然后使用语句 count += 1 对 count 进行自增操作，返回到循环的开始位置，继续比较 count 和 5 的大小关系。现在，count 变量的值为 2，因此 while 循环内部的代码会被再次执行，count 值变为 3 。

在 count 从 5 自增到 6 之前循环一直进行。然后下次判断时，count <= 5 的条件不满足，while 循环结束。Python 跳到循环下面的代码。

4.4.1　使用break跳出循环

如果你想让循环在某一条件下停止，但是不确定在哪次循环跳出，可以在无限循环中声明 break 语句。这次，我们通过 Python 的 input() 函数从键盘输入一行字符串，然后将字符串首字母转化成大写输出。当输入的一行仅含有字符 q 时，跳出循环 ：

>>> while True:
...     stuff = input("String to capitalize [type q to quit]: ")
...     if stuff == "q":
...         break
...     print(stuff.capitalize())
...
String to capitalize [type q to quit]: test
Test
String to capitalize [type q to quit]: hey, it works
Hey, it works
String to capitalize [type q to quit]: q
>>>

4.4.2　使用continue跳到循环开始

有时我们并不想结束整个循环，仅仅想跳到下一轮循环的开始。下面是一个编造的例子：读入一个整数，如果它是奇数则输出它的平方数；如果是偶数则跳过。同样使用 q 来结束循环，代码中加上了适当的注释：

>>> while True:
...     value = input("Integer, please [q to quit]: ")
...     if value == 'q':      # 停止循环
...         break
...     number = int(value)
...     if number % 2 == 0:   # 判断偶数
...        continue
...     print(number, "squared is", number*number)
...
Integer, please [q to quit]: 1
1 squared is 1
Integer, please [q to quit]: 2
Integer, please [q to quit]: 3
3 squared is 9
Integer, please [q to quit]: 4
Integer, please [q to quit]: 5
5 squared is 25
Integer, please [q to quit]: q
>>>

4.4.3　循环外使用else

如果 while 循环正常结束（没有使用 break 跳出），程序将进入到可选的 else 段。当你使用循环来遍历检查某一数据结构时，找到满足条件的解使用 break 跳出；循环结束，即没有找到可行解时，将执行 else 部分代码段：

>>> numbers = [1, 3, 5]
>>> position = 0
>>> while position < len(numbers):
...     number = numbers[position]
...     if number % 2 == 0:
...         print('Found even number', number)
...         break
...     position += 1
... else: #没有执行break
...     print('No even number found')
...
No even number found

　else 在此处的用法不是很直观，可以认为是循环中没有调用 break 后执行的检查。

4.5　使用for迭代

Python 频繁地使用迭代器。它允许在数据结构长度未知和具体实现未知的情况下遍历整个数据结构，并且支持迭代快速读写中的数据，以及允许不能一次读入计算机内存的数据流的处理。

下面这一遍历序列的方法是可行的：

>>> rabbits = ['Flopsy', 'Mopsy', 'Cottontail', 'Peter']
>>> current = 0
>>> while current < len(rabbits):
...     print(rabbits[current])
...     current += 1
...
Flopsy
Mopsy
Cottontail
Peter

但是，有一种更优雅的、Python 风格的遍历方式：

>>> for rabbit in rabbits:
...     print(rabbit)
...
Flopsy
Mopsy
Cottontail
Peter

列表（例如 rabbits）、字符串、元组、字典、集合等都是 Python 中可迭代的对象。元组或者列表在一次迭代过程产生一项，而字符串迭代会产生一个字符，如下所示：

>>> word = 'cat'
>>> for letter in word:
...     print(letter)
...
c
a
t

对一个字典（或者字典的 keys() 函数）进行迭代将返回字典中的键。在下面的例子中，字典的键为图板游戏 Clue（《妙探寻凶》）中牌的类型：

>>> accusation = {'room': 'ballroom', 'weapon': 'lead pipe',
                  'person': 'Col. Mustard'}
>>> for card in accusation:  # 或者是for card in accusation.keys():
...     print(card)
...
room
weapon
person

如果想对字典的值进行迭代，可以使用字典的 values() 函数：

>>> for value in accusation.values():
...     print(value)
...
ballroom
lead pipe
Col. Mustard

为了以元组的形式返回键值对，可以使用字典的 items() 函数：

>>> for item in accusation.items():
...     print(item)
...
('room', 'ballroom')
('weapon', 'lead pipe')
('person', 'Col. Mustard')

记住，元组只能被初始化一次，它的值是不能改变的。对于调用函数 items() 返回的每一个元组，将第一个返回值（键）赋给 card，第二个返回值（值）赋给 contents：

>>> for card, contents in accusation.items():
...     print('Card', card, 'has the contents', contents)
...
Card weapon has the contents lead pipe
Card person has the contents Col. Mustard
Card room has the contents ballroom

4.5.1　使用break跳出循环

在 for 循环中跳出的用法和在 while 循环中是一样的。

4.5.2　使用continue跳到循环开始

在一个循环中使用 continue 会跳到下一次的迭代开始，这一点和 while 循环也是类似的。

4.5.3　循环外使用else

类似于 while，for 循环也可以使用可选的 else 代码段，用来判断 for 循环是否正常结束（没有调用 break 跳出）。若正常结束，则会执行 else 段。

当你想确认之前的 for 循环是否正常跑完，增加 else 判断是有用的。下面的例子中，for 循环打印输出奶酪的名称，并且如果任一奶酪在商店中找到则跳出循环：

>>> cheeses = []
>>> for cheese in cheeses:
...     print('This shop has some lovely', cheese)
...     break
... else: # 没有break表示没有找到奶酪
...     print('This is not much of a cheese shop, is it?')
...
This is not much of a cheese shop, is it?

　在 for 循环外使用 else 可能和 while 循环一样，不是很直观和容易理解。下面的理解方式会更清楚：for 循环用来遍历查找，如果没有找到则调用执行 else。同样在没有 else 的情况下，为了达到相同的作用，可以声明某个变量指出在 for 循环中是否找到，看下面的例子：

>>> cheeses = []
>>> found_one = False
>>> for cheese in cheeses:
...     found_one = True
...     print('This shop has some lovely', cheese)
...     break
...
>>> if not found_one:
...     print('This is not much of a cheese shop, is it?')
...
This is not much of a cheese shop, is it?

4.5.4　使用zip()并行迭代

在使用迭代时，有一个非常方便的技巧：通过 zip() 函数对多个序列进行并行迭代：

>>> days = ['Monday', 'Tuesday', 'Wednesday']
>>> fruits = ['banana', 'orange', 'peach']
>>> drinks = ['coffee', 'tea', 'beer']
>>> desserts = ['tiramisu', 'ice cream', 'pie', 'pudding']
>>> for day, fruit, drink, dessert in zip(days, fruits, drinks, desserts):
...     print(day, ": drink", drink, "- eat", fruit, "- enjoy", dessert)
...
Monday : drink coffee - eat banana - enjoy tiramisu
Tuesday : drink tea - eat orange - enjoy ice cream
Wednesday : drink beer - eat peach - enjoy pie

zip() 函数在最短序列“用完”时就会停止。上面例子中的列表（desserts）是最长的，所以我们无法填充列表，除非人工扩展其他列表。

3.4 节中的 dict() 函数会将两项序列，比如元组、列表、字符串，创建成一个字典，同时使用 zip() 函数可以遍历多个序列，在具有相同位移的项之间创建元组。下面创建英语单词和法语单词之间的对应关系的两个元组：

>>> english = 'Monday', 'Tuesday', 'Wednesday'
>>> french = 'Lundi', 'Mardi', 'Mercredi'

现在使用 zip() 函数配对两个元组。函数的返回值既不是元组也不是列表，而是一个整合在一起的可迭代变量：

>>> list( zip(english, french) )
[('Monday', 'Lundi'), ('Tuesday', 'Mardi'), ('Wednesday', 'Mercredi')]

配合 dict() 函数和 zip() 函数的返回值就可以得到一本微型的英法词典：

>>> dict( zip(english, french) )
{'Monday': 'Lundi', 'Tuesday': 'Mardi', 'Wednesday': 'Mercredi'}

4.5.5　使用range()生成自然数序列

range() 函数返回在特定区间的自然数序列，不需要创建和存储复杂的数据结构，例如列表或者元组。这允许在不使用计算机全部内存的情况下创建较大的区间，也不会使你的程序崩溃。

range() 函数的用法类似于使用切片：range(start，stop，step)。而 start 的默认值为 0。唯一要求的参数值是 stop，产生的最后一个数的值是 stop 的前一个，并且 step 的默认值是 1。当然，也可以反向创建自然数序列，这时 step 的值为 -1。

像 zip()、range() 这些函数返回的是一个可迭代的对象，所以可以使用 for ... in 的结构遍历，或者把这个对象转化为一个序列（例如列表）。我们来产生序列 0, 1, 2：

>>> for x in range(0,3):
...     print(x)
...
0
1
2
>>> list( range(0, 3) )
[0, 1, 2]

下面是如何从 2 到 0 反向创建序列：

>>> for x in range(2, -1, -1):
...     print(x)
...
2
1
0
>>> list( range(2, -1, -1) )
[2, 1, 0]

下面的代码片段将 step 赋值为 2，得到从 0 到 10 的偶数：

>>> list( range(0, 11, 2) )
[0, 2, 4, 6, 8, 10]

4.5.6　其他迭代方式

第 8 章将介绍文件之间的迭代。在第 6 章中，你会看到如何在自己创建的对象之间迭代。

4.6　推导式

推导式是从一个或者多个迭代器快速简洁地创建数据结构的一种方法。它可以将循环和条件判断结合，从而避免语法冗长的代码。会使用推导式有时可以说明你已经超过 Python 初学者的水平。也就是说，使用推导式更像 Python 风格。

4.6.1　列表推导式

你可以从 1 到 5 创建一个整数列表，每次增加一项，如下所示：

>>> number_list = []
>>> number_list.append(1)
>>> number_list.append(2)
>>> number_list.append(3)
>>> number_list.append(4)
>>> number_list.append(5)
>>> number_list
[1, 2, 3, 4, 5]

或者，可以结合 range() 函数使用一个迭代器：

>>> number_list = []
>>> for number in range(1, 6):
...     number_list.append(number)
...
>>> number_list
[1, 2, 3, 4, 5]

或者，直接把 range() 的返回结果放到一个列表中：

>>> number_list = list(range(1, 6))
>>> number_list
[1, 2, 3, 4, 5]

上面这些方法都是可行的 Python 代码，会得到相同的结果。然而，更像 Python 风格的创建列表方式是使用列表推导。最简单的形式如下所示：

[ expression for item in iterable ]

下面的例子将通过列表推导创建一个整数列表：

>>> number_list = [number for number in range(1,6)]
>>> number_list
[1, 2, 3, 4, 5]

在第一行中，第一个 number 变量为列表生成值，也就是说，把循环的结果放在列表 number_list 中。第二个 number 为循环变量。其中第一个 number 可以为表达式，试试下面改编的例子：

>>> number_list = [number-1 for number in range(1,6)]
>>> number_list
[0, 1, 2, 3, 4]

列表推导把循环放在方括号内部。这种例子和之前碰到的不大一样，但却是更为常见的方式。同样，列表推导也可以像下面的例子加上条件表达式：

[expression for item in iterable if condition]

现在，通过推导创建一个在 1 到 5 之间的奇数列表（当 number % 2 为真时，代表奇数；为假时，代表偶数）：

>>> a_list = [number for number in range(1,6) if number % 2 == 1]
>>> a_list
[1, 3, 5]

于是，上面的推导要比之前传统的方法更简洁：

>>> a_list = []
>>> for number in range(1,6):
...     if number % 2 == 1:
...         a_list.append(number)
...
>>>  a_list
[1, 3, 5]

最后，正如存在很多嵌套循环一样，在对应的推导中会有多个 for ... 语句。我们先来看一个简单的嵌套循环的例子，并在屏幕上打印出结果：

>>> rows = range(1,4)
>>> cols = range(1,3)
>>> for row in rows:
...     for col in cols:
...         print(row, col)
...
1 1
1 2
2 1
2 2
3 1
3 2

下面使用一次推导，将结果赋值给变量 cells，使它成为元组 (row,col):

>>> rows = range(1,4)
>>> cols = range(1,3)
>>> cells = [(row, col) for row in rows for col in cols]
>>> for cell in cells:
...     print(cell)
...
(1, 1)
(1, 2)
(2, 1)
(2, 2)
(3, 1)
(3, 2)

另外，在对 cells 列表进行迭代时可以通过元组拆封将变量 row 和 col 的值分别取出：

>>> for row, col in cells:
...     print(row, col)
...
1 1
1 2
2 1
2 2
3 1
3 2

其中，在列表推导中 for row ... 和 for col ... 都可以有自己单独的 if 条件判断。

4.6.2　字典推导式

除了列表，字典也有自己的推导式。最简单的例子就像：

{ key_expression : value_expression for expression in iterable }

类似于列表推导，字典推导也有 if 条件判断以及多个 for 循环迭代语句：

>>> word = 'letters'
>>> letter_counts = {letter: word.count(letter) for letter in word}
>>> letter_counts
{'l': 1, 'e': 2, 't': 2, 'r': 1, 's': 1}

程序中，对字符串 'letters' 中出现的字母进行循环，计算出每个字母出现的次数。对于程序执行来说，两次调用 word.count(letter) 浪费时间，因为字符串中 t 和 e 都出现了两次，第一次调用 word.count() 时已经计算得到相应的值。下面的例子会解决这个小问题，更符合 Python 风格：

>>> word = 'letters'
>>> letter_counts = {letter: word.count(letter) for letter in set(word)}
>>> letter_counts
{'t': 2, 'l': 1, 'e': 2, 'r': 1, 's': 1}

字典键的顺序和之前的例子是不同的，因为是对 set(word) 集合进行迭代的，而前面的例子是对 word 字符串迭代。

4.6.3　集合推导式

集合也不例外，同样有推导式。最简单的版本和之前的列表、字典推导类似：

{expression for expression in iterable }

最长的版本（if tests, multiple for clauses）对于集合而言也是可行的：

>>> a_set = {number for number in range(1,6) if number % 3 == 1}
>>> a_set
{1, 4}

4.6.4　生成器推导式

元组是没有推导式的。你可能认为将列表推导式中的方括号变成圆括号就可以定义元组推导式，就像下面的表达式一样：

>>> number_thing = (number for number in range(1, 6))

其实，圆括号之间的是生成器推导式，它返回的是一个生成器对象：

>>> type(number_thing)
<class 'generotor'>

4.8 节会详细介绍。它是将数据传给迭代器的一种方式。

你可以直接对生成器对象进行迭代，如下所示：

>>> for number in number_thing:
...     print(number)
...
1
2
3
4
5

或者，通过对一个生成器的推导式调用 list() 函数，使它类似于列表推导式：

>>> number_list = list(number_thing)
>>> number_list
[1, 2, 3, 4, 5]

　一个生成器只能运行一次。列表、集合、字符串和字典都存储在内存中，但是生成器仅在运行中产生值，不会被存下来，所以不能重新使用或者备份一个生成器。

如果想再一次迭代此生成器，会发现它被擦除了：

>>> try_again = list(number_thing)
>>> try_again
[]

你既可以通过生成器推导式创建生成器，也可以使用生成器的函数。后面会介绍这些函数，并且探讨这些生成器函数的特殊用法。

4.7　函数

到目前为止，我们的 Python 代码已经实现了小的分块。它们都适合处理微小任务，但是我们想复用这些代码，所以需要把大型代码组织成可管理的代码段。

代码复用的第一步是使用函数，它是命名的用于区分的代码段。函数可以接受任何数字或者其他类型的输入作为参数，并且返回数字或者其他类型的结果。

你可以使用函数做以下两件事情：

定义函数

调用函数

为了定义 Python 函数，你可以依次输入 def、函数名、带有函数参数的圆括号，最后紧跟一个冒号（:）。函数命名规范和变量命名一样（必须使用字母或者下划线 _ 开头，仅能含有字母、数字和下划线）。

我们先定义和调用一个没有参数的函数。下面的例子是最简单的 Python 函数：

>>> def do_nothing():
...     pass

即使对于一个没有参数的函数，仍然需要在定义时加上圆括号和冒号。下面的一行需要像声明 if 语句一样缩进。Python 函数中的 pass 表明函数没有做任何事情。和这一页故意留白有同样的作用（即使它不再是）。

通过输入函数名和参数调用此函数，像前面说的一样，它没有做任何事情：

>>> do_nothing()
>>>

现在，定义一个无参数，但打印输出一个单词的函数：

>>> def make_a_sound():
...     print('quack')
...
>>> make_a_sound()
quack

当调用 make_a_sound() 函数时，Python 会执行函数内部的代码。在这个例子中，函数打印输出单个词，并且返回到主程序。

下面尝试一个没有参数但返回值的函数：

>>> def agree():
...    return True
...

或者，调用这个函数，使用 if 语句检查它的返回值：

>>> if agree():
...     print('Splendid!')
... else:
...     print('That was unexpected.')
...
Splendid!

学到现在已经迈出了很大的一步。在函数中，使用 if 判断和 for/while 循环组合能实现之前无法实现的功能。

这个时候该在函数中引入参数。定义带有一个 anything 参数的函数 echo()。它使用 return 语句将 anything 返回给它的调用者两次，并在两次中间加入一个空格：

>>> def echo(anything):
...    return anything + ' ' + anything
...
>>>

然后用字符串 'Rumplestiltskin' 调用函数 echo()：

>>> echo('Rumplestiltskin')
'Rumplestiltskin Rumplestiltskin'

传入到函数的值称为参数。当调用含参数的函数时，这些参数的值会被复制给函数中的对应参数。在之前的例子中，被调用的函数 echo() 的传入参数字符串是 'Rumplestiltskin'，这个值被复制给参数 anything , 然后返回到调用方（在这个例子中，输出两次字符串，中间有一个空格）。

上面的这些函数例子都很基础。现在我们写一个含有输入参数的函数，它能真正处理一些事情。在这里依旧沿用评论颜色的代码段。调用 commentary 函数，把 color 作为输入的参数，使它返回对颜色的评论字符串：

>>> def commentary(color):
...     if color == 'red':
...         return "It's a tomato."
...     elif color == "green":
...         return "It's a green pepper."
...     elif color == 'bee purple':
...         return "I don't know what it is, but only bees can see it."
...     else:
...         return "I've never heard of the color "  + color +  "."
...
>>>

传入字符串参数 'blue'，调用函数 commentary()：

>>> comment = commentary('blue')

这个函数做了以下事情：

把 'blue' 赋值给函数的内部参数 color

运行 if-elif-else 的逻辑链

返回一个字符串

将该字符串赋值给变量 comment

我们如何得到返回值呢？

>>> print(comment)
I've never heard of the color blue.

一个函数可以接受任何数量（包括 0）的任何类型的值作为输入变量，并且返回任何数量（包括 0）的任何类型的结果。如果函数不显式调用 return 函数，那么会默认返回 None。

>>> print(do_nothing())
None

有用的 None

None 是 Python 中一个特殊的值，虽然它不表示任何数据，但仍然具有重要的作用。虽然 None 作为布尔值和 False 是一样的，但是它和 False 有很多差别。下面是一个例子：

>>> thing = None
>>> if thing:
...     print("It's some thing")
... else:
...     print("It's no thing")
...
It's no thing

为了区分 None 和布尔值 False , 使用 Python 的 is 操作符：

>>> if thing is None:
...     print("It's nothing")
... else:
...     print("It's something")
...
It's nothing

这虽然是一个微妙的区别，但是对于 Python 来说是很重要的。你需要把 None 和不含任何值的空数据结构区分开来。0 值的整型 / 浮点型、空字符串（''）、空列表（[]）、空元组（()）、空字典（{}）、空集合（set()）都等价于 False，但是不等于 None。

现在，快速写一个函数，输出它的参数是否是 None：

>>> def is_none(thing):
...     if thing is None:
...         print("It's None")
...     elif thing:
...         print("It's True")
...     else:
...         print("It's False")
...

现在，运行一些测试函数：

>>> is_none(None)
It's None
>>> is_none(True)
It's True
>>> is_none(False)
It's False
>>> is_none(0)
It's False
>>> is_none(0.0)
It's False
>>> is_none(())
It's False
>>> is_none([])
It's False
>>> is_none({})
It's False
>>> is_none(set())
It's False

4.7.1　位置参数

Python 处理参数的方式要比其他语言更加灵活。其中，最熟悉的参数类型是位置参数，传入参数的值是按照顺序依次复制过去的。

下面创建一个带有位置参数的函数，并且返回一个字典：

>>> def menu(wine, entree, dessert):
...     return {'wine': wine, 'entree': entree, 'dessert': dessert}
...
>>> menu('chardonnay', 'chicken', 'cake')
{'dessert': 'cake', 'wine': 'chardonnay', 'entree': 'chicken'}

尽管这种方式很常见，但是位置参数的一个弊端是必须熟记每个位置的参数的含义。在调用函数 menu() 时误把最后一个参数当作第一个参数，会得到完全不同的结果：

>>> menu('beef', 'bagel', 'bordeaux')
{'dessert': 'bordeaux', 'wine': 'beef', 'entree': 'bagel'}

4.7.2　关键字参数

为了避免位置参数带来的混乱，调用函数时可以指定对应参数的名字，甚至可以采用与函数定义不同的顺序调用：

>>> menu(entree='beef', dessert='bagel', wine='bordeaux')
{'dessert': 'bagel', 'wine': 'bordeaux', 'entree': 'beef'}

你也可以把位置参数和关键字参数混合起来。首先，实例化参数 wine，然后对参数 entree 和 dessert 使用关键字参数的方式：

>>> menu('frontenac', dessert='flan', entree='fish')
{'entree': 'fish', 'dessert': 'flan', 'wine': 'frontenac'}

如果同时使用位置参数和关键字参数两种方式调用函数，位置参数必须放置于关键字参数之前。

4.7.3　指定默认参数值

当调用方没有提供对应的参数值时，你可以指定默认参数值。这个听起来很普通的特性实际上特别有用，以之前的例子为例：

>>> def menu(wine, entree, dessert='pudding'):
...     return {'wine': wine, 'entree': entree, 'dessert': dessert}

这一次调用不带 dessert 参数的函数 menu()：

>>> menu('chardonnay', 'chicken')
{'dessert': 'pudding', 'wine': 'chardonnay', 'entree': 'chicken'}

如果你提供参数值，在调用时会代替默认值：

>>> menu('dunkelfelder', 'duck', 'doughnut')
{'dessert': 'doughnut', 'wine': 'dunkelfelder', 'entree': 'duck'}

　默认参数值在函数被定义时已经计算出来，而不是在程序运行时。Python 程序员经常犯的一个错误是把可变的数据类型（例如列表或者字典）当作默认参数值。

在下面的例子中，函数 buggy() 在每次调用时，添加参数 arg 到一个空的列表 result，然后打印输出一个单值列表。但是存在一个问题：只有在第一次调用时列表是空的，第二次调用时就会存在之前调用的返回值：

>>> def buggy(arg, result=[]):
...     result.append(arg)
...     print(result)
...
>>> buggy('a')
['a']
>>> buggy('b')   # expect ['b']
['a', 'b']

如果写成下面的样子就会解决刚才的问题：

>>> def works(arg):
...     result = []
...     result.append(arg)
...     return result
...
>>> works('a')
['a']
>>> works('b')
['b']

这样的修改也为了表明是第一次调用跳过一些操作：

>>> def nonbuggy(arg, result=None):
...     if result is None:
...         result = []
...     result.append(arg)
...     print(result)
...
>>> nonbuggy('a')
['a']
>>> nonbuggy('b')
['b']

4.7.4　使用*收集位置参数

如果你之前使用 C/C++ 编程，可能会认为 Python 中的星号（*）和指针相关。然而，Python 是没有指针概念的。

当参数被用在函数内部时，星号将一组可变数量的位置参数集合成参数值的元组。在下面的例子中 args 是传入到函数 print_args() 的参数值的元组：

>>> def print_args(*args):
...     print('Positional argument tuple:', args)
...

无参数调用函数，则什么也不会返回：

>>> print_args()
Positional argument tuple: ()

给函数传入的所有参数都会以元组的形式返回输出：

>>> print_args(3, 2, 1, 'wait!', 'uh...')
Positional argument tuple: (3, 2, 1, 'wait!', 'uh...')

这样的技巧对于编写像 print() 一样接受可变数量的参数的函数是非常有用的。如果你的函数同时有限定的位置参数，那么 *args 会收集剩下的参数：

>>> def print_more(required1, required2, *args):
...     print('Need this one:', required1)
...     print('Need this one too:', required2)
...     print('All the rest:', args)
...
>>> print_more('cap', 'gloves', 'scarf', 'monocle', 'mustache wax')
Need this one: cap
Need this one too: gloves
All the rest: ('scarf', 'monocle', 'mustache wax')

当使用 * 时，不一定要把元组参数命名为 args，但这是 Python 中的一个常见做法。

4.7.5　使用**收集关键字参数

使用两个星号可以将参数收集到一个字典中，参数的名字是字典的键，对应参数的值是字典的值。下面的例子定义了函数 print_kwargs()，然后打印输出它的关键字参数：

>>> def print_kwargs(**kwargs):
...     print('Keyword arguments:', kwargs)
...

现在，使用一些关键字参数调用函数：

>>> print_kwargs(wine='merlot', entree='mutton', dessert='macaroon')
Keyword arguments: {'dessert': 'macaroon', 'wine': 'merlot', 'entree': 'mutton'}

在函数内部，kwargs 是一个字典。

如果你把带有 *args 和 **kwargs 的位置参数混合起来，它们必须按照顺序解析。和 args 一样，不一定要把这个关键字参数命名为 kwargs，但这是通常做法。

4.7.6　文档字符串

正如《Python 之禅》（the Zen of Python）中提到的，程序的可读性很重要。建议在函数体开始的部分附上函数定义说明的文档，这就是函数的文档字符串：

>>> def echo(anything):
...     'echo returns its input argument'
...     return anything

可以定义非常长的文档字符串，加上详细的规范说明，如下所示：

def print_if_true(thing, check):
    '''
    Prints the first argument if a second argument is true.
    The operation is:
        1. Check whether the *second* argument is true.
        2. If it is, print the *first* argument.
    '''
    if check:
        print(thing)

调用 Python 函数 help() 可以打印输出一个函数的文档字符串。把函数名传入函数 help() 就会得到参数列表和规范的文档：

>>> help(echo)
Help on function echo in module __main__:

echo(anything)
    echo returns its input argument

如果仅仅想得到文档字符串：

>>> print(echo.__doc__)
echo returns its input argument

看上去很奇怪的 __doc__ 是作为函数中变量的文档字符串的名字。4.10 节的“名称中 _ 和 __ 的用法”会解释所有下划线背后的原理。

4.7.7　一等公民：函数

之前提过 Python 中一切都是对象，包括数字、字符串、元组、列表、字典和函数。函数是 Python 中的一等公民，可以把它们（返回值）赋给变量，可以作为参数被其他函数调用，也可以从其他函数中返回值。它可以帮助你在 Python 中实现其他语言难以实现的功能。

为了测试，现在定义一个简单的函数 answer()，它没有任何参数，仅仅打印输出数字 42：

>>> def answer():
...     print(42)

运行该函数，会得到下面的结果：

>>> answer()
42

再定义一个函数 run_something。它有一个参数 func，这个参数是一个可以运行的函数的名字：

>>> def run_something(func):
...     func()

将参数 answer 传到该函数，在这里像之前碰到的一样，把函数名当作数据使用：

>>> run_something(answer)
42

注意，你传给函数的是 answer , 而不是 answer()。在 Python 中圆括号意味着调用函数。在没有圆括号的情况下，Python 会把函数当作普通对象。这是因为在其他情况下，它也仅仅代表一个对象：

>>> type(run_something)
<class 'function'>

我们来运行一个带参数的例子。定义函数 add_args()，它会打印输出两个数值参数（arg1 和 arg2）的和：

>>> def add_args(arg1, arg2):
...     print(arg1 + arg2)

那么，add_args() 的类型是什么？

>>> type(add_args)
<class 'function'>

此刻定义一个函数 run_something_with_args()，它带有三个参数：

func——可以运行的函数

arg1——func 函数的第一个参数

arg2——func 函数的第二个参数

>>> def run_something_with_args(func, arg1, arg2):
...     func(arg1, arg2)

当调用 run_something_with_args() 时，调用方传来的函数赋值给 func 参数，而 arg1 和 arg2 从参数列表中获得值。然后运行带参数的 func(arg1, arg2)。

将函数名 add_args 和参数 5、9 传给函数 run_something_with_args()：

>>> run_something_with_args(add_args, 5, 9)
14

在函数 run_something_with_args() 内部，函数名 add_args 被赋值给参数 func，5 和 9 分别赋值给 arg1 和 arg2。程序最后执行：

add_args(5, 9)

同样可以在此用上 *args（位置参数收集）和 **kwargs（关键字参数收集）的技巧。

我们定义一个测试函数，它可以接受任意数量的位置参数，使用 sum() 函数计算它们的和，并返回这个和：

>>> def sum_args(*args):
...     return sum(args)

之前没有提到 sum() 函数，它是 Python 的一个内建函数，用来计算可迭代的数值（整型或者浮点型）参数的和。

下面再定义一个新的函数 run_with_positional_args()，接收一个函数名以及任意数量的位置参数：

>>> def run_with_positional_args(func, *args):
...    return func(*args)

现在直接调用它：

>>> run_with_positional_args(sum_args, 1, 2, 3, 4)
10

同样可以把函数作为列表、元组、集合和字典的元素。函数名是不可变的，因此可以把函数用作字典的键。

4.7.8　内部函数

在 Python 中，可以在函数中定义另外一个函数：

>>> def outer(a, b):
...     def inner(c, d):
...         return c + d
...     return inner(a, b)
...
>>>
>>> outer(4, 7)
11

当需要在函数内部多次执行复杂的任务时，内部函数是非常有用的，从而避免了循环和代码的堆叠重复。对于这样一个字符串的例子，内部函数的作用是给外部的函数增加字符串参数：

>>> def knights(saying):
...     def inner(quote):
...         return "We are the knights who say: '%s'" % quote
...     return inner(saying)
...
>>> knights('Ni!')
"We are the knights who say: 'Ni!'"

4.7.9　闭包

内部函数可以看作一个闭包。闭包是一个可以由另一个函数动态生成的函数，并且可以改变和存储函数外创建的变量的值。

下面的例子以之前的 knights() 为基础。现在，调用新的函数 knight2()，把 inner() 函数变成一个叫 inner2() 的闭包。可以看出有以下不同点。

inner2() 直接使用外部的 saying 参数，而不是通过另外一个参数获取。

knights2() 返回值为 inner2 函数，而不是调用它。

>>> def knights2(saying):
...     def inner2():
...         return "We are the knights who say: '%s'" % saying
...     return inner2
...

inner2() 函数可以得到 saying 参数的值并且记录下来。return inner2 这一行返回的是 inner2 函数的复制（没有直接调用）。所以它就是一个闭包：一个被动态创建的可以记录外部变量的函数。

用不同的参数调用 knights2() 两次：

>>> a = knights2('Duck')
>>> b = knights2('Hasenpfeffer')

那么 a 和 b 会是什么类型？

>>> type(a)
<class 'function'>
>>> type(b)
<class 'function'>

它们是函数，同时也是闭包：

>>> a
<function knights2.<locals>.inner2 at 0x10193e158>
>>> b
<function knights2.<locals>.inner2 at 0x10193e1e0>

如果调用它们，它们会记录被 knights2 函数创建时的外部变量 saying：

>>> a()
"We are the knights who say: 'Duck'"
>>> b()
"We are the knights who say: 'Hasenpfeffer'"

4.7.10　匿名函数：lambda()函数

Python 中，lambda 函数是用一条语句表达的匿名函数。可以用它来代替小的函数。

首先，举一个使用普通函数的例子。定义函数 edit_story()，参数列表如下所示：

words——单词列表

func——遍历列表中单词的函数

>>> def edit_story(words, func):
...     for word in words:
...         print(func(word))

现在，需要一个单词列表和一个遍历单词的函数。对于单词，可以选择我的猫从某一台阶上掉下时发出的声音：

>>> stairs = ['thud', 'meow', 'thud', 'hiss']

对于函数，它要将每个单词的首字母变为大写，然后在末尾加上感叹号，用作猫画报的标题非常完美：

>>> def enliven(word):   # 让这些单词更有情感
...     return word.capitalize() + '!'

混合这些“配料”：

>>> edit_story(stairs, enliven)
Thud!
Meow!
Thud!
Hiss!

最后，到了 lambda。enliven() 函数可以简洁地用下面的一个 lambda 代替：

>>>
>>> edit_story(stairs, lambda word: word.capitalize() + '!')
Thud!
Meow!
Thud!
Hiss!
>>>

lambda 函数接收一个参数 word。在冒号和末尾圆括号之间的部分为函数的定义。

通常，使用实际的函数（例如 enliven()）会比使用 lambda 更清晰明了。但是，当需要定义很多小的函数以及记住它们的名字时，lambda 会非常有用。尤其是在图形用户界面中，可以使用 lambda 来定义回调函数。请参见附录 A 中的例子。

4.8　生成器

生成器是用来创建 Python 序列的一个对象。使用它可以迭代庞大的序列，且不需要在内存中创建和存储整个序列。通常，生成器是为迭代器产生数据的。回想起来，我们已经在之前的例子中使用过其中一个，即 range()，来产生一系列整数。range() 在 Python 2 中返回一个列表，这也限制了它要进入内存空间。Python 2 中同样存在的生成器 xrange() 在 Python 3 中成为标准的 range() 生成器。这个例子累加从 1 到 100 的整数：

>>> sum(range(1, 101))
5050

每次迭代生成器时，它会记录上一次调用的位置，并且返回下一个值。这一点和普通的函数是不一样的，一般函数都不记录前一次调用，而且都会在函数的第一行开始执行。

如果你想创建一个比较大的序列，使用生成器推导的代码会很长，这时可以尝试写一个生成器函数。生成器函数和普通函数类似，但是它的返回值使用 yield 语句声明而不是 return。下面编写我们自己的 range() 函数版本：

>>> def my_range(first=0, last=10, step=1):
...     number = first
...     while number < last:
...         yield number
...         number += step
...

这是一个普通的函数：

>>> my_range
<function my_range at 0x10193e268>

并且它返回的是一个生成器对象：

>>> ranger = my_range(1, 5)
>>> ranger
<generator object my_range at 0x101a0a168>

可以对这个生成器对象进行迭代：

>>> for x in ranger:
...        print(x)
...
1
2
3
4

4.9　装饰器

有时你需要在不改变源代码的情况下修改已经存在的函数。常见的例子是增加一句调试声明，以查看传入的参数。

装饰器实质上是一个函数。它把一个函数作为输入并且返回另外一个函数。在装饰器中，通常使用下面这些 Python 技巧：

*args 和 **kwargs

闭包

作为参数的函数

函数 document_it() 定义了一个装饰器，会实现如下功能：

打印输出函数的名字和参数的值

执行含有参数的函数

打印输出结果

返回修改后的函数

看下面的代码：

>>> def document_it(func):
...     def new_function(*args, **kwargs):
...         print('Running function:', func.__name__)
...         print('Positional arguments:', args)
...         print('Keyword arguments:', kwargs)
...         result = func(*args, **kwargs)
...         print('Result:', result)
...         return result
...     return new_function

无论传入 document_it() 的函数 func 是什么，装饰器都会返回一个新的函数，其中包含函数 document_it() 增加的额外语句。实际上，装饰器并不需要执行函数 func 中的代码，只是在结束前函数 document_it() 调用函数 func 以便得到 func 的返回结果和附加代码的结果。

那么，如何使用装饰器？当然，可以通过人工赋值：

>>> def add_ints(a, b):
...     return a + b
...
>>> add_ints(3, 5)
8
>>> cooler_add_ints = document_it(add_ints)  # 人工对装饰器赋值
>>> cooler_add_ints(3, 5)
Running function: add_ints
Postitional arguments: （3， 5）
Keyword arguments: {}
Result: 8
8

作为对前面人工装饰器赋值的替代，可以直接在要装饰的函数前添加装饰器名字 @decorator_name：

>>> @document_it
... def add_ints(a, b):
...     return a + b
...
>>> add_ints(3, 5)
Running function: add_ints
Positional arguments: (3, 5)
Keyword arguments: {}
Result: 8
8

同样一个函数可以有多个装饰器。下面，我们写一个对结果求平方的装饰器 square_it()：

>>> def square_it(func):
...     def new_function(*args, **kwargs):
...         result = func(*args, **kwargs)
...         return result * result
...     return new_function
...

靠近函数定义（def 上面）的装饰器最先执行，然后依次执行上面的。任何顺序都会得到相同的最终结果。下面的例子中会看到中间步骤的变化：

>>> @document_it
... @square_it
... def add_ints(a, b):
...     return a + b
...
>>> add_ints(3, 5)
Running function: new_function
Positional arguments: (3, 5)
Keyword arguments: {}
Result: 64
64

交换两个装饰器的顺序：

>>> @square_it
... @document_it
... def add_ints(a, b):
...     return a + b
...
>>> add_ints(3, 5)
Running function: add_ints
Positional arguments: (3, 5)
Keyword arguments: {}
Result: 8
64

4.10　命名空间和作用域

一个名称在不同的使用情况下可能指代不同的事物。Python 程序有各种各样的命名空间，它指的是在该程序段内一个特定的名称是独一无二的，它和其他同名的命名空间是无关的。

每一个函数定义自己的命名空间。如果在主程序（main）中定义一个变量 x，在另外一个函数中也定义 x 变量，两者指代的是不同的变量。但是，天下也没有完全绝对的事情，需要的话，可以通过多种方式获取其他命名空间的名称。

每个程序的主要部分定义了全局命名空间。因此，在这个命名空间的变量是全局变量。

你可以在一个函数内得到某个全局变量的值：

>>> animal = 'fruitbat'
>>> def print_global():
...     print('inside print_global:', animal)
...
>>> print('at the top level:', animal)
at the top level: fruitbat
>>> print_global()
inside print_global: fruitbat

但是，如果想在函数中得到一个全局变量的值并且改变它，会报错：

>>> def change_and_print_global():
...     print('inside change_and_print_global:', animal)
...     animal = 'wombat'
...     print('after the change:', animal)
...
>>> change_and_print_global()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 2, in change_and_report_it
UnboundLocalError: local variable 'animal' referenced before assignment

实际上，你改变的另外一个同样被命名为 animal 的变量，只不过这个变量在函数内部：

>>> def change_local():
...     animal = 'wombat'
...     print('inside change_local:', animal, id(animal))
...
>>> change_local()
inside change_local: wombat 4330406160
>>> animal
'fruitbat'
>>> id(animal)
4330390832

这里发生了什么？在函数第一行将字符串 fruitbat 赋值给全局变量 animal。函数 change_local() 也有一个叫作 animal 的变量。不同的是，它在自己的局部命名空间。

我们使用 Python 内嵌函数 id() 打印输出每个对象的唯一的 ID 值，证明在函数 change_local() 中的变量 animal 和主程序中的 animal 不是同一个。

为了读取全局变量而不是函数中的局部变量，需要在变量前面显式地加关键字 global（也正是《Python 之禅》中的一句话：明了胜于隐晦）：

>>> animal = 'fruitbat'
>>> def change_and_print_global():
...     global animal
...     animal = 'wombat'
...     print('inside change_and_print_global:', animal)
...
>>> animal
'fruitbat'
>>> change_and_print_global()
inside change_and_print_global: wombat
>>> animal
'wombat'

如果在函数中不声明关键字 global，Python 会使用局部命名空间，同时变量也是局部的。函数执行后回到原来的命名空间。

Python 提供了两个获取命名空间内容的函数：

locals() 返回一个局部命名空间内容的字典；

globals() 返回一个全局命名空间内容的字典。

下面是它们的实例：

>>> animal = 'fruitbat'
>>> def change_local():
...     animal = 'wombat' #局部变量
...     print('locals:',locals())
...
>>> animal
'fruitbat'
>>> change_local()
locals: {'animal':'wombat'}
>>> print('globals:', globals())  #表示时格式稍微发生变化
globals:{'animal': 'fruitbat',
'__doc__': None,
'change_local': <function change_it at 0x1006c0170>,
'__package__': None,
'__name__': '__main__',
'__loader__': <class '_frozen_importlib.BuiltinImporter'>,
'__builtins__': <module 'builtins'>}
>>> animal
'fruitbat'

函数 change_local() 的局部命名空间只含有局部变量 animal。全局命名空间含有全局变量 animal 以及其他一些东西。

名称中_和 __ 的用法

以两个下划线 __ 开头和结束的名称都是 Python 的保留用法。因此，在自定义的变量中不能使用它们。选择这种命名模式是考虑到开发者一般是不会选择它们作为自己的变量的。

例如，一个函数的名称是系统变量 function.__name__，它的文档字符串是 function.__doc__：

>>> def amazing():
...     '''This is the amazing function.
...     Want to see it again?'''
...     print('This function is named:', amazing.__name__)
...     print('And its docstring is:', amazing.__doc__)
...
>>> amazing()
This function is named: amazing
And its docstring is: This is the amazing function.
    Want to see it again?

如同之前 globals 的输出结果所示，主程序被赋值特殊的名字 __main__。

4.11　使用try和except处理错误

要么做，要么不做，没有尝试这回事。3

——尤达（Yoda）

3“Do, or do not. There is no try.”科幻电影《星球大战》中绝地大师尤达 Yoda 的台词。——译者注

在一些编程语言中，错误是通过特殊的函数返回值指出的，而 Python 使用异常，它是一段只有错误发生时执行的代码。

之前已经接触到一些有关错误的例子，例如读取列表或者元组的越界位置或者字典中不存在的键。所以，当你执行可能出错的代码时，需要适当的异常处理程序用于阻止潜在的错误发生。

在异常可能发生的地方添加异常处理程序，对于用户明确错误是一种好方法。即使不会及时解决问题，至少会记录运行环境并且停止程序执行。如果发生在某些函数中的异常不能被立刻捕捉，它会持续，直到被某个调用函数的异常处理程序所捕捉。在你不能提供自己的异常捕获代码时，Python 会输出错误消息和关于错误发生处的信息，然后终止程序，例如下面的代码段：

>>> short_list = [1, 2, 3]
>>> position = 5
>>> short_list[position]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
IndexError: list index out of range

与其让错误随机产生，不如使用 try 和 except 提供错误处理程序：

>>> short_list = [1, 2, 3]
>>> position = 5
>>> try:
...     short_list[position]
... except:
...     print('Need a position between 0 and', len(short_list)-1, ' but got',
...            position)
...
Need a position between 0 and 2 but got 5

在 try 中的代码块会被执行。如果存在错误，就会抛出异常，然后执行 except 中的代码；否则，跳过 except 块代码。

像前面那样指定一个无参数的 except 适用于任何异常类型。如果可能发生多种类型的异常，最好是分开进行异常处理。当然，没人强迫你这么做，你可以使一个 except 去捕捉所有的异常，但是这样的处理方式会比较泛化（类似于直接输出发生了一个错误）。当然也可以使用任意数量的异常处理程序。

有时需要除了异常类型以外其他的异常细节，可以使用下面的格式获取整个异常对象：

except exceptiontype as name

下面的例子首先会寻找是否有 IndexError，因为它是由索引一个序列的非法位置抛出的异常类型。将一个 IndexError 异常赋给变量 err，把其他的异常赋给变量 other。示例中会输出所有存储在 other 中的该对象的异常。

>>> short_list = [1, 2, 3]
>>> while True:
...     value = input('Position [q to quit]? ')
...     if value == 'q':
...         break
...     try:
...         position = int(value)
...         print(short_list[position])
...     except IndexError as err:
...         print('Bad index:', position)
...     except Exception as other:
...         print('Something else broke:', other)
...
Position [q to quit]? 1
2
Position [q to quit]? 0
1
Position [q to quit]? 2
3
Position [q to quit]? 3
Bad index: 3
Position [q to quit]? 2
3
Position [q to quit]? two
Something else broke: invalid literal for int() with base 10: 'two'
Position [q to quit]? q

输入 3 会抛出异常 IndexError；输入 two 会使函数 int() 抛出异常，被第二个 except 所捕获。

4.12　编写自己的异常

前面一节讨论了异常处理，但是其中讲到的所有异常（例如 IndexError）都是在 Python 或者它的标准库中提前定义好的。根据自己的目的可以使用任意的异常类型，同时也可以自己定义异常类型，用来处理程序中可能会出现的特殊情况。

　这里需要定义一个类的新对象，这会在第 6 章深入说明。如果你对类不是很熟悉，可以学完后面的部分再返回这一节。

一个异常是一个类，即类 Exception 的一个子类。现在编写异常 UppercaseException，在一个字符串中碰到大写字母会被抛出。

>>> class UppercaseException(Exception):
...     pass
...
>>> words = ['eeenie', 'meenie', 'miny', 'MO']
>>> for word in words:
...     if word.isupper():
...         raise UppercaseException(word)
...
Traceback (most recent call last):
  File "<stdin>", line 3, in <module>
__main__.UppercaseException: MO

即使没有定义 UppercaseException 的行为（注意到只使用 pass），也可以通过继承其父类 Exception 在抛出异常时输出错误提示。

你当然能够访问异常对象本身，并且输出它：

>>> try:
...     raise OopsException('panic')
... except OopsException as exc:
...     print(exc)
...
panic

4.13　练习

(1) 将 7 赋值给变量 guess_me，然后写一段条件判断（if、else 和 elif）的代码：如果 guess_me 小于 7 输出 'too low'；大于 7 则输出 'too high'；等于 7 则输出 'just right'。

(2) 将 7 赋值给变量 guess_me，再将 1 赋值给变量 start。写一段 while 循环代码，比较 start 和 guess_me：如果 start 小于 guess_me，输出 too low；如果等于则输出 'found it!' 并终止循环；如果大于则输出 'oops'，然后终止循环。在每次循环结束时自增 start。

(3) 使用 for 循环输出列表 [3, 2, 1, 0] 的值。

(4) 使用列表推导生成 0~9（range(10)）的偶数列表。

(5) 使用字典推导创建字典 squares，把 0~9（range(10)）的整数作为键，每个键的平方作为对应的值。

(6) 使用集合推导创建集合 odd，包含 0~9（range(10)）的奇数。

(7) 使用生成器推导返回字符串 'Got ' 和 0~9 内的一个整数，使用 for 循环进行迭代。

(8) 定义函数 good：返回列表 ['Harry','Ron','Hermione']。

(9) 定义一个生成器函数 get_odds：返回 0~9 内的奇数。使用 for 循环查找并输出返回的第三个值。

(10) 定义一个装饰器 test：当一个函数被调用时输出 'start'，当函数结束时输出 'end'。

(11) 定义一个异常 OopsException：编写代码捕捉该异常，并输出 'Caught an oops'。

(12) 使用函数 zip() 创建字典 movies：匹配两个列表 titles = ['Creature of Habit', 'Crewel Fate'] 和 plots = ['A nun turns into a monster', 'A haunted yarn shop']。

第 5 章　Python 盒子：模块、包和程序

在自底向上的学习过程中，我们已经学完内置的数据类型，以及构建较大的数据和代码结构。本章落到实质问题，学习如何写出实用的大型 Python 程序。

5.1　独立的程序

到目前为止，我们已经学会在 Python 的交互式解释器中编写和运行类似下面的代码：

>>> print("This interactive snippet works.")
This interactive snippet works.

现在编写你的第一个独立程序。在你的计算机中，创建一个文件 test1.py，包含下面的单行 Python 代码：

print("This standalone program works!")

注意，代码中没有 >>> 提示符，只有一行 Python 代码，而且要保证在 print 之前没有缩进。

如果要在文本终端或者终端窗口运行 Python，需要键入 Python 程序名，后面跟上程序的文件名：

$ python test1.py
This standalone program works!

　你可以把在本书中已经看到的终端可交互的代码片段保存到文件中，然后直接运行。如果剪切或者粘贴，不要忘记删除 >>> 提示符和 ...（包括最后的空格）。

5.2　命令行参数

在你的计算机中，创建文件 test2.py，包含下面两行：

import sys
print('Program arguments:',sys.argv)

现在，使用 Python 运行这段程序。下面是在 Linux 或者 Mac OS X 系统的标准 shell 程序下的运行结果：

$ python test2.py
Program arguments: ['test2.py']
$ python test2.py tra la la
Program arguments: ['test2.py', 'tra', 'la', 'la']

5.3　模块和import语句

继续进入下一个阶段：在多个文件之间创建和使用 Python 代码。一个模块仅仅是 Python 代码的一个文件。

本书的内容按照这样的层次组织：单词、句子、段落以及章。否则，超过一两页后就没有很好的可读性了。代码也有类似的自底向上的组织层次：数据类型类似于单词，语句类似于句子，函数类似于段落，模块类似于章。以此类推，当我说某个内容会在第 8 章中说明时，就像是在其他模块中引用代码。

引用其他模块的代码时使用 import 语句，被引用模块中的代码和变量对该程序可见。

5.3.1　导入模块

import 语句最简单的用法是 import 模块，模块是不带 .py 扩展的另外一个 Python 文件的文件名。现在来模拟一个气象站，并输出天气预报。其中一个主程序输出报告，一个单独的具有单个函数的模块返回天气的描述。

下面是主程序（命名为 weatherman.py）：

import report

description = report.get_description()
print("Today's weather:", description)

以下是天气模块的代码（report.py）：

def get_description():  #看到下面的文档字符串了吗？
    """Return random weather, just like the pros"""
    from random import choice
    possibilities = ['rain', 'snow', 'sleet', 'fog', 'sun', 'who knows']
    return choice(possibilities)

如果上述两个文件在同一个目录下，通过 Python 运行主程序 weatherman.py，会引用 report 模块，执行函数 get_description()。函数 get_description() 从字符串列表中返回一个随机结果。下面就是主程序可能返回和输出的结果：

$ python weatherman.py
Today's weather: who knows
$ python weatherman.py
Today's weather: sun
$ python weatherman.py
Today's weather: sleet

我们在两个不同的地方使用了 import：

主程序 weatherman.py 导入模块 report；

在模块文件 report.py 中，函数 get_description() 从 Python 标准模块 random 导入函数 choice。

同样，我们以两种不同的方式使用了 import：

主程序调用 import report，然后运行 report.get_description()；

report.py 中的 get_description() 函数调用 from random import choice，然后运行 choice(possibilities)。

第一种情况下，我们导入了整个 report 模块，但是需要把 report. 作为 get_description() 的前缀。在这个 import 语句之后，只要在名称前加 report.，report.py 的所有内容（代码和变量）就会对主程序可见。通过模块名称限定模块的内容，可以避免命名冲突。其他模块可能也有函数 get_description()，这样做不会被错误地调用。

第二种情况下，所有代码都在同一个函数下，并且没有其他名为 choice 的函数，所以我们直接从 random 模块导入函数 choice()。我们也可以编写类似于下面的函数，返回随机结果：

def get_description():
    import random
    possibilities = ['rain', 'snow', 'sleet', fog', 'sun', 'who knows']
    return random.choice(possibilities)

同编程的其他方面一样，选择你所能理解的最清晰的风格。符合模块规范的命名（random.choice）更安全，但输入量略大。

这些 get_description() 的例子介绍了各种各样的导入内容，但没有涉及在什么地方进行导入——它们都在函数内部调用 import。我们也可以在函数外部导入 random：

>>> import random
>>> def get_description():
...    possibilities = ['rain', 'snow', 'sleet', 'fog', 'sun', 'who knows']
...    return random.choice(possibilities)
...
>>> get_description()
'who knows'
>>> get_description()
'rain'

如果被导入的代码被多次使用，就应该考虑在函数外部导入；如果被导入的代码使用有限，就在函数内部导入。一些人更喜欢把所有的 import 都放在文件的开头，从而使代码之间的依赖关系清晰。两种方法都是可行的。

5.3.2　使用别名导入模块

在主程序 weatherman.py 中，我们调用了 import report。但是，如果存在同名的另一个模块或者你想使用更短更好记的名字，该如何做呢？在这种情况下，可以使用别名 wr 进行导入：

import report as wr
description = wr.get_description()
print("Today's weather:", description)

5.3.3　导入模块的一部分

在 Python 中，可以导入一个模块的若干部分。每一部分都有自己的原始名字或者你起的别名。首先，从 report 模块中用原始名字导入函数 get_description()：

from report import get_description
description = get_description()
print("Today's weather:", description)

用它的别名 do_it 导入：

from report import get_description as do_it
description = do_it()
print("Today's weather:", description)

5.3.4　模块搜索路径

Python 会在什么地方寻找文件来导入模块？它使用存储在标准 sys 模块下的目录名和ZIP压缩文件列表作为变量 path。你可以读取和修改这个列表。下面是在我的 Mac 上 Python 3.3 的 sys.path 的内容：

>>> import sys
>>> for place in sys.path:
...        print(place)
...
/Library/Frameworks/Python.framework/Versions/3.3/lib/python33.zip
/Library/Frameworks/Python.framework/Versions/3.3/lib/python3.3
/Library/Frameworks/Python.framework/Versions/3.3/lib/python3.3/plat-darwin
/Library/Frameworks/Python.framework/Versions/3.3/lib/python3.3/lib-dynload
/Library/Frameworks/Python.framework/Versions/3.3/lib/python3.3/site-packages

最开始的空白输出行是空字符串 ''，代表当前目录。如果空字符串是在 sys.path 的开始位置，Python 会先搜索当前目录：import report 会寻找文件 report.py。

第一个匹配到的模块会先被使用，这也就意味着如果你在标准库之前的搜索路径上定义一个模块 random，就不会导入标准库中的 random 模块。

5.4　包

我们已使用过单行代码、多行函数、独立程序以及同一目录下的多个模块。为了使 Python 应用更具可扩展性，你可以把多个模块组织成文件层次，称之为包。

也许我们需要两种类型的天气预报：一种是次日的，一种是下周的。一种可行的方式是新建目录 sources，在该目录中新建两个模块 daily.py 和 weekly.py。每一个模块都有一个函数 forecast。每天的版本返回一个字符串，每周的版本返回包含 7 个字符串的列表。

下面是主程序和两个模块（函数 enumerate() 拆分一个列表，并对列表中的每一项通过 for 循环增加数字下标）。

主程序是 boxes/weather.py：

from sources import daily, weekly

print("Daily forecast:", daily.forecast())
print("Weekly forecast:")
for number, outlook in enumerate(weekly.forecast(), 1):
    print(number, outlook)

模块 1 是 boxes/sources/daily.py：

def forecast():
    'fake daily forecast'
    return 'like yesterday'

模块 2 是 boxes/sources/weekly.py：

def forecast():
    """Fake weekly forecast"""
    return ['snow', 'more snow', 'sleet',
        'freezing rain', 'rain', 'fog', 'hail']

还需要在 sources 目录下添加一个文件：init.py。这个文件可以是空的，但是 Python 需要它，以便把该目录作为一个包。

运行主程序 weather.py：

$ python weather.py
Daily forecast: like yesterday
Weekly forecast:
1 snow
2 more snow
3 sleet
4 freezing rain
5 rain
6 fog
7 hail

5.5　Python标准库

Python 的一个显著特点是具有庞大的模块标准库，这些模块可以执行很多有用的任务，并且和核心 Python 语言分开以避免臃肿。当我们开始写代码时，首先要检查是否存在想要的标准模块。在标准库中你会经常碰到一些“珍宝”！ Python 同时提供了模块的官网文档（http://docs.python.org/3/library）以及使用指南（https://docs.python.org/3.3/tutorial/stdlib.html）。Doug Hellmann 的网站 Python Module of the Week（http://pymotw.com/2/contents.html）和他的书 The Python Standard Library by Example 都是非常有帮助的指南。

接下来的几章会着重介绍关于网络、系统、数据库等的标准模块。本节讨论一些常用的标准模块。

5.5.1　使用setdefault()和defaultdict()处理缺失的键

读取字典中不存在的键的值会抛出异常。使用字典函数 get() 返回一个默认值会避免异常发生。函数 setdefault() 类似于 get()，但当键不存在时它会在字典中添加一项：

>>> periodic_table = {'Hydrogen': 1, 'Helium': 2}
>>> print(periodic_table)
{'Helium': 2, 'Hydrogen': 1}

如果键不在字典中，新的默认值会被添加进去：

>>> carbon = periodic_table.setdefault('Carbon', 12)
>>> carbon
12
>>> periodic_table
{'Helium': 2, 'Carbon': 12, 'Hydrogen': 1}

如果试图把一个不同的默认值赋给已经存在的键，不会改变原来的值，仍将返回初始值：

>>> helium = periodic_table.setdefault('Helium', 947)
>>> helium
2
>>> periodic_table
{'Helium': 2, 'Carbon': 12, 'Hydrogen': 1}

defaultdict() 也有同样的用法，但是在创建字典时，对每个新的键都会指定默认值。它的参数是一个函数。在本例中，把函数 int 作为参数传入，会按照 int() 调用，返回整数 0：

>>> from collections import defaultdict
>>> periodic_table = defaultdict(int)

现在，任何缺失的值将被赋为整数 0：

>>> periodic_table['Hydrogen'] = 1
>>> periodic_table['Lead']
0
>>> periodic_table
defaultdict(<class 'int'>, {'Lead': 0, 'Hydrogen': 1})

函数 defaultdict() 的参数是一个函数，它返回赋给缺失键的值。在下面的例子中，no_idea() 在需要时会被执行，返回一个值：

>>> from collections import defaultdict
>>>
>>> def no_idea():
...     return 'Huh?'
...
>>> bestiary = defaultdict(no_idea)
>>> bestiary['A'] = 'Abominable Snowman'
>>> bestiary['B'] = 'Basilisk'
>>> bestiary['A']
'Abominable Snowman'
>>> bestiary['B']
'Basilisk'
>>> bestiary['C']
'Huh?'

同样，可以使用函数 int()、list() 或者 dict() 返回默认空的值：int() 返回 0，list() 返回空列表（[]），dict() 返回空字典（{}）。如果你删掉该函数参数，新键的初始值会被设置为 None。

顺便提一下，也可以使用 lambda 来定义你的默认值函数：

>>> bestiary = defaultdict(lambda: 'Huh?')
>>> bestiary['E']
'Huh?'

使用 int 是一种定义计数器的方式：

>>> from collections import defaultdict
>>> food_counter = defaultdict(int)
>>> for food in ['spam', 'spam', 'eggs', 'spam']:
...     food_counter[food] += 1
...
>>> for food, count in food_counter.items():
...     print(food, count)
...
eggs 1
spam 3

上面的例子中，如果 food_counter 已经是一个普通的字典而不是 defaultdict 默认字典，那每次试图自增字典元素 food_counter[food] 值时，Python 会抛出一个异常，因为我们没有对它进行初始化。在普通字典中，需要做额外的工作，如下所示：

>>> dict_counter = {}
>>> for food in ['spam', 'spam', 'eggs', 'spam']:
...     if not food in dict_counter:
...         dict_counter[food] = 0
...     dict_counter[food] += 1
...
>>> for food, count in dict_counter.items():
...     print(food, count)
...
spam 3
eggs 1

5.5.2　使用Counter()计数

说起计数器，标准库有一个计数器，它可以胜任之前或者更多示例所做的工作：

>>> from collections import Counter
>>> breakfast = ['spam', 'spam', 'eggs', 'spam']
>>> breakfast_counter = Counter(breakfast)
>>> breakfast_counter
Counter({'spam': 3, 'eggs': 1})

函数 most_common() 以降序返回所有元素，或者如果给定一个数字，会返回该数字前的元素：

>>> breakfast_counter.most_common()
[('spam', 3), ('eggs', 1)]
>>> breakfast_counter.most_common(1)
[('spam', 3)]

也可以组合计数器。首先来看一下 breakfast_counter：

>>> breakfast_counter
>>> Counter({'spam': 3, 'eggs': 1})

这一次，新建一个列表 lunch 和一个计数器 lunch_counter：

>>> lunch = ['eggs', 'eggs', 'bacon']
>>> lunch_counter = Counter(lunch)
>>> lunch_counter
Counter({'eggs': 2, 'bacon': 1})

第一种组合计数器的方式是使用 +：

>>> breakfast_counter + lunch_counter
Counter({'spam': 3, 'eggs': 3, 'bacon': 1})

你也可能想到，从一个计数器去掉另一个，可以使用 -。什么是早餐有的而午餐没有的呢？

>>> breakfast_counter - lunch_counter
Counter({'spam': 3})

那么什么又是午餐有的而早餐没有的呢 ?

>>> lunch_counter - breakfast_counter
Counter({'bacon': 1, 'eggs': 1})

和第 4 章中的集合类似，可以使用交集运算符 & 得到二者共有的项：

>>> breakfast_counter & lunch_counter
Counter({'eggs': 1})

两者的交集通过取两者中的较小计数，得到共同元素 'eggs'。这合情合理：早餐仅提供一个鸡蛋，因此也是共有的计数。

最后，使用并集运算符 | 得到所有元素：

>>> breakfast_counter | lunch_counter
Counter({'spam': 3, 'eggs': 2, 'bacon': 1})

'eggs' 又是两者共有的项。不同于合并，并集没有把计数加起来，而是取其中较大的值。

5.5.3　使用有序字典OrderedDict()按键排序

在前面几章的代码示例中可以看出，一个字典中键的顺序是不可预知的：你可以按照顺序添加键 a、b 和 c，但函数 keys() 可能返回 c、a 和 b。下面是第 1 章用过的一个例子：

>>> quotes = {
...     'Moe': 'A wise guy, huh?',
...     'Larry': 'Ow!',
...     'Curly': 'Nyuk nyuk!',
...     }
>>> for stooge in quotes:
...  print(stooge)
...
Larry
Curly
Moe

有序字典 OrderedDict() 记忆字典键添加的顺序，然后从一个迭代器按照相同的顺序返回。试着用元组（键，值）创建一个有序字典：

>>> from collections import OrderedDict
>>> quotes = OrderedDict([
...     ('Moe', 'A wise guy, huh?'),
...     ('Larry', 'Ow!'),
...     ('Curly', 'Nyuk nyuk!'),
...     ])
>>>
>>> for stooge in quotes:
...     print(stooge)
...
Moe
Larry
Curly

5.5.4　双端队列：栈+队列

deque 是一种双端队列，同时具有栈和队列的特征。它可以从序列的任何一端添加和删除项。现在，我们从一个词的两端扫向中间，判断是否为回文。函数 popleft() 去掉最左边的项并返回该项，pop() 去掉最右边的项并返回该项。从两边一直向中间扫描，只要两端的字符匹配，一直弹出直到到达中间：

>>> def palindrome(word):
...     from collections import deque
...     dq = deque(word)
...     while len(dq) > 1:
...        if dq.popleft() != dq.pop():
...            return False
...     return True
...
...
>>> palindrome('a')
True
>>> palindrome('racecar')
True
>>> palindrome('')
True
>>> palindrome('radar')
True
>>> palindrome('halibut')
False

这里把判断回文作为双端队列的一个简单说明。如果想要写一个快速的判断回文的程序，只需要把字符串反转和原字符串进行比较。Python 没有对字符串进行反转的函数 reverse()，但还是可以利用反向切片的方式进行反转，如下所示：

>>> def another_palindrome(word):
...     return word == word[::-1]
...
>>> another_palindrome('radar')
True
>>> another_palindrome('halibut')
False

5.5.5　使用itertools迭代代码结构

itertools（https://docs.python.org/3/library/itertools.html）包含特殊用途的迭代器函数。在for ... in 循环中调用迭代函数，每次会返回一项，并记住当前调用的状态。

即使 chain() 的参数只是单个迭代对象，它也会使用参数进行迭代：

>>> import itertools
>>> for item in itertools.chain([1, 2], ['a', 'b']):
...     print(item)
...
1
2
a
b

cycle() 是一个在它的参数之间循环的无限迭代器：

>>> import itertools
>>> for item in itertools.cycle([1, 2]):
...     print(item)
...
1
2
1
2
.
.
.

accumulate() 计算累积的值。默认的话，它计算的是累加和：

>>> import itertools
>>> for item in itertools.accumulate([1, 2, 3, 4]):
...     print(item)
...
1
3
6
10

你可以把一个函数作为 accumulate() 的第二个参数，代替默认的加法函数。这个参数函数应该接受两个参数，返回单个结果。下面的例子计算的是乘积：

>>> import itertools
>>> def multiply(a, b):
...     return a * b
...
>>> for item in itertools.accumulate([1, 2, 3, 4], multiply):
...     print(item)
...
1
2
6
24

itertools 模块有很多其他的函数，有一些可以用在需要节省时间的组合和排列问题上。

5.5.6　使用pprint()友好输出

我们见到的所有示例都用 print()（或者在交互式解释器中用变量名）打印输出。有时输出结果的可读性较差。我们需要一个友好输出函数，比如 pprint()：

>>> from pprint import pprint
>>> quotes = OrderedDict([
...     ('Moe', 'A wise guy, huh?'),
...     ('Larry', 'Ow!'),
...     ('Curly', 'Nyuk nyuk!'),
...     ])
>>>

普通的 print() 直接列出所有结果：

>>> print(quotes)
OrderedDict([('Moe', 'A wise guy, huh?'), ('Larry', 'Ow!'), ('Curly', 'Nyuk nyuk!')])

但是，pprint() 尽量排列输出元素从而增加可读性：

>>> pprint(quotes)
{'Moe': 'A wise guy, huh?',
 'Larry': 'Ow!',
 'Curly': 'Nyuk nyuk!'}

5.6　获取更多Python代码

有时标准库没有你需要的代码，或者不能很好地满足需求。有很多开源第三方 Python 软件供参考，如下所示：

PyPi（http://pypi.python.org，也称为 Cheese Shop，名称源自 Monty Python1 的滑稽短剧）

github（http://github.com/Python）

readthedocs（https://readthedocs.org/）

1Monty Python 是英国六人喜剧团体。——译者注

你可以在 activestate（http://code.activestate.com/recipes/langs/python/）找到很多小代码示例。

本书的绝大部分代码使用的是安装在你电脑中的标准 Python 程序，包括所有内置函数和标准库。外部的包在以下地方提及：第 1 章中谈到的 requests，具体细节在 9.1.3 节；附录 D 里面提及第三方 Python 软件的安装和详细的开发细节。

5.7　练习

(1) 创建文件 zoo.py。在该文件中定义函数 hours()，输出字符串 'Open 9-5 daily'。然后使用交互式解释器导入模块 zoo 并调用函数 hours()。

(2) 在交互式解释器中，把模块 zoo 作为 menagerie 导入，然后调用函数 hours()。

(3) 依旧在解释器中，直接从模块 zoo 导入函数 hours() 并调用。

(4) 把函数 hours() 作为 info 导入，然后调用它。

(5) 创建字典 plain，包含键值对 'a':1、'b':2 和 'c':3，然后输出它。

(6) 创建有序字典 fancy：键值对和练习 (5) 相同，然后输出它。输出顺序和 plain 相同吗？

(7) 创建默认字典 dict_of_lists，传入参数 list。给 dict_of_lists['a'] 赋值 'something for a'，输出 dict_of_lists['a'] 的值。

第 6 章　对象和类

“对象并不神秘，神秘的是你的眼睛。”1

—— Elizabeth Bowen

“选取一个对象，对它进行修改，然后再进行一些其他的修改。”2

——Jasper Johns

1“No object is mysterious. The mystery is your eye.”原话应译为：没有什么是神秘的，神秘的是你的眼睛。本书作者使用 object 一语双关。——译者注

2“Take an object. Do something to it. Do somthing else to it.”原话应译为：选取一件事物，对它进行创作，再对它进行其他创作。本书作者使用 object 一语双关。——译者注

到目前为止，你已经学习了字符串、字典之类的数据结构，还有函数、模块之类的代码结构。本章将学习如何使用自定义的数据结构：对象。

6.1　什么是对象

就像在第 2 章提到的一样，Python 里的所有数据都是以对象形式存在的，无论是简单的数字类型还是复杂的代码模块。然而，Python 特殊的语法形式巧妙地将实现对象机制的大量细节隐藏了起来。输入 num = 7 就可以创建一个值为 7 的整数对象，并且将这个对象赋值给变量 num。事实上，在 Python 中，只有当你想要创建属于自己的对象或者需要修改已有对象的行为时，才需要关注对象的内部实现细节。在本章，这两种情况都会涉及。

对象既包含数据（变量，更习惯称之为特性，attribute），也包含代码（函数，也称为方法）。它是某一类具体事物的特殊实例。例如，整数 7 就是一个包含了加法、乘法之类方法的对象，这些方法在 2.2 节曾经介绍过。整数 8 则是另一个对象。这意味着在 Python 里，7 和 8 都属于一个公共的类，我们称之为整数类。字符串 'cat' 和 'duck' 也是 Python 对象，它们都包含着 capitalize() 和 replace() 之类的字符串方法，这些方法之前也都见到过。

当你想要创建一个别人从来没有创建过的新对象时，首先必须定义一个类，用以指明该类型的对象所包含的内容（特性和方法）。

可以把对象想象成名词，那么方法就是动词。对象代表着一个独立的事物，它的方法则定义了它是如何与其他事物相互作用的。

与模块不同，你可以同时创建许多同类的对象，它们的特性值可能各不相同。对象就像是包含了代码的超级数据结构。

6.2　使用class定义类

在第 1 章，我把对象比作塑料盒子。类（class）则像是制作盒子用的模具。例如，Python 的内置类 String 可以创建像 'cat' 和 'duck' 这样的字符串对象。Python 中还有许多用来创建其他标准数据类型的类，包括列表、字典等。如果想要在 Python 中创建属于自己的对象，首先你必须用关键词 class 来定义一个类。先来看一个简单的例子。

假设你想要定义一些对象用于记录联系人，每个对象对应一个人。首先需要定义 Person 类作为生产对象的模具。在接下来的几个例子中，我们会不停更新这个类的内容，从最简单的开始，直到它成为一个可以实际使用的类。

首先创建的是最简单的类，即一个没有任何内容的空类：

>>> class Person():
...     pass

同函数一样，用 pass 表示这个类是一个空类。上面这种定义类的方法已经是最简形式，无法再省略。你可以通过类名来创建对象，同调用函数一样：

>>> someone = Person()

在这个例子中，Person() 创建了一个 Person 类的对象，并给它赋值 someone 这个名字。但是，由于我们的 Person 类是空的，所以由它创建的对象 someone 实际上什么也做不了。实际编程中，你永远也不会创建这样一个没用的类，我在这里只是为了从零开始引出后面每一步的内容。

我们来试着重新定义一下 Person 类。这一次，将 Python 中特殊的对象初始化方法 __init__ 放入其中：

>>> class Person():
...     def __init__(self):
...         pass

我承认 __init__() 和 self 看起来很奇怪，但这就是实际的 Python 类的定义形式。__init__() 是 Python 中一个特殊的函数名，用于根据类的定义创建实例对象。3self 参数指向了这个正在被创建的对象本身。

3你会在 Python 中见到许多双下划线（double underscore）的名字。一些懒人喜欢将其简称为 dunder，这样比较节省音节。

当你在类声明里定义 __init__() 方法时，第一个参数必须为 self。尽管 self 并不是一个 Python 保留字，但它很常用。没有人（包括你自己）在阅读你的代码时需要猜测使用 self 的意图。

尽管我们添加了初始化方法，但用这个 Person 类创建的对象仍然什么也做不了。即将进行的第三次尝试要更吸引人了，你将学习如何创建一个简单可用的 Python 对象。这一次，会在初始化方法中添加 name 参数：

>>> class Person():
...     def __init__(self, name):
...         self.name = name
...
>>>

现在，用 Person 类创建一个对象，为 name 特性传递一个字符串参数：

>>> hunter = Person('Elmer Fudd')

上面这短短的一行代码实际做了以下工作：

查看 Person 类的定义；

在内存中实例化（创建）一个新的对象；

调用对象的 __init__ 方法，将这个新创建的对象作为 self 传入，并将另一个参数（'Elmer-Fudd'）作为 name 传入；

将 name 的值存入对象；

返回这个新的对象；

将名字 hunter 与这个对象关联。

这个新对象与任何其他的 Python 对象一样。你可以把它当作列表、元组、字典或集合中的元素，也可以把它当作参数传递给函数，或者把它做为函数的返回结果。

我们刚刚传入的 name 参数此时又在哪儿呢？它作为对象的特性存储在了对象里。可以直接对它进行读写操作：

>>> print('The mighty hunter: ', hunter.name)
The mighty hunter: Elmer Fudd

记住，在 Person 类定义的内部，你可以直接通过 self.name 访问 name 特性。而当创建了一个实际的对象后，例如这里的 hunter，需要通过 hunter.name 来访问它。

在类的定义中，__init__ 并不是必需的。只有当需要区分由该类创建的不同对象时，才需要指定 __init__ 方法。

6.3　继承

在你编写代码解决实际问题时，经常能找到一些已有的类，它们能够实现你所需的大部分功能，但不是全部。这时该怎么办？当然，你可以对这个已有的类进行修改，但这么做很容易让代码变得更加复杂，一不留神就可能会破坏原来可以正常工作的功能。

当然，也可以另起炉灶重新编写一个类：复制粘贴原来的代码再融入自己的新代码。但这意味着你需要维护更多的代码。同时，新类和旧类中实现同样功能的代码被分隔在了不同的地方（日后修改时需要改动多处）。

更好的解决方法是利用类的继承：从已有类中衍生出新的类，添加或修改部分功能。这是代码复用的一个绝佳的例子。使用继承得到的新类会自动获得旧类中的所有方法，而不需要进行任何复制。

你只需要在新类里面定义自己额外需要的方法，或者按照需求对继承的方法进行修改即可。修改得到的新方法会覆盖原有的方法。我们习惯将原始的类称为父类、超类或基类，将新的类称作孩子类、子类或衍生类。这些术语在面向对象的编程中不加以区分。

现在，我们来试试继承。首先，定义一个空类 Car。然后，定义一个 Car 的子类 Yugo。定义子类使用的也是 class 关键词，不过需要把父类的名字放在子类名字后面的括号里（class Yugo(Car)）：

>>> class Car():
...     pass
...
>>> class Yugo(Car):
...     pass
...

接着，为每个类创建一个实例对象：

>>> give_me_a_car = Car()
>>> give_me_a_yugo = Yugo()

子类是父类的一种特殊情况，它属于父类。在面向对象的术语里，我们经常称 Yugo 是一个（is-a）Car。对象 give_me_a_yugo 是 Yugo 类的一个实例，但它同时继承了 Car 能做到的所有事情。当然，上面的例子中 Car 和 Yugo 就像潜艇上的甲板水手一样起不到任何实际作用。我们来更新一下类的定义，让它们发挥点儿作用：

>>> class Car():
...     def exclaim(self):
...         print("I'm a Car!")
...
>>> class Yugo(Car):
...     pass
...

最后，为每一个类各创建一个对象，并调用刚刚声明的 exclaim 方法：

>>> give_me_a_car = Car()
>>> give_me_a_yugo = Yugo()
>>> give_me_a_car.exclaim()
I'm a Car!
>>> give_me_a_yugo.exclaim()
I'm a Car!

我们不需要进行任何特殊的操作，Yugo 就自动从 Car 那里继承了 exclaim() 方法。但事实上，我们并不希望 Yugo 在 exlaim() 方法里宣称它是一个 Car，这可能会造成身份危机（无法区分 Car 和 Yugo）。让我们来看看怎么解决这个问题。

6.4　覆盖方法

就像上面的例子展示的一样，新创建的子类会自动继承父类的所有信息。接下来将看到子类如何替代——更习惯说覆盖（override）——父类的方法。Yugo 和 Car 一定存在着某些区别，不然的话，创建它又有什么意义？试着改写一下 Yugo 中 exclaim() 方法的功能：

>>> class Car():
...     def exclaim(self):
...         print("I'm a Car!")
...
>>> class Yugo(Car):
...     def exclaim(self):
...         print("I'm a Yugo! Much like a Car, but more Yugo-ish.")
...

现在，为每个类创建一个对象：

>>> give_me_a_car = Car()
>>> give_me_a_yugo = Yugo()

看看它们各自会宣称什么？

>>> give_me_a_car.exclaim()
I'm a Car!
>>> give_me_a_yugo.exclaim()
I'm a Yugo! Much like a Car, but more Yugo-ish.

在上面的例子中，我们覆盖了父类的 exclaim() 方法。在子类中，可以覆盖任何父类的方法，包括 __init__()。下面的例子使用了之前创建过的 Person 类。我们来创建两个子类，分别代表医生（MDPerson）和律师（JDPerson）：

>>> class Person():
...     def __init__(self, name):
...         self.name = name
...
>>> class MDPerson(Person):
...     def __init__(self, name):
...         self.name = "Doctor " + name
...
>>> class JDPerson(Person):
...     def __init__(self, name):
...         self.name = name + ", Esquire"
...

在上面的例子中，子类的初始化方法 __init__() 接收的参数和父类 Person 一样，但存储到对象内部 name 特性的值却不尽相同：

>>> person = Person('Fudd')
>>> doctor = MDPerson('Fudd')
>>> lawyer = JDPerson('Fudd')
>>> print(person.name)
Fudd
>>> print(doctor.name)
Doctor Fudd
>>> print(lawyer.name)
Fudd, Esquire

6.5　添加新方法

子类还可以添加父类中没有的方法。回到 Car 类和 Yugo 类，我们给 Yugo 类添加一个新的方法 need_a_push()：

>>> class Car():
...     def exclaim(self):
...         print("I'm a Car!")
...
>>> class Yugo(Car):
...     def exclaim(self):
...         print("I'm a Yugo! Much like a Car, but more Yugo-ish.")
...     def need_a_push(self):
...         print("A little help here?")
...

接着，创建一个 Car 和一个 Yugo 对象：

>>> give_me_a_car = Car()
>>> give_me_a_yugo = Yugo()

Yugo 类的对象可以响应 need_a_push() 方法：

>>> give_me_a_yugo.need_a_push()
A little help here?

但比它广义的 Car 无法响应该方法：

>>> give_me_a_car.need_a_push()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'Car' object has no attribute 'need_a_push'

至此，Yugo 终于可以做一些 Car 做不到的事情了。它的与众不同的特征开始体现了出来。

6.6　使用super从父类得到帮助

我们已经知道如何在子类中覆盖父类的方法，但如果想要调用父类的方法怎么办？“哈哈！终于等到你问这个了。”super() 站出来说道。下面的例子将定义一个新的类 EmailPerson，用于表示有电子邮箱的 Person。首先，来定义熟悉的 Person 类：

>>> class Person():
...     def __init__(self, name):
...         self.name = name
...

下面是子类的定义。注意，子类的初始化方法 __init__() 中添加了一个额外的 email 参数：

>>> class EmailPerson(Person):
...     def __init__(self, name, email):
...         super().__init__(name)
...         self.email = email

在子类中定义 __init__() 方法时，父类的 __init__() 方法会被覆盖。因此，在子类中，父类的初始化方法并不会被自动调用，我们必须显式调用它。以上代码实际上做了这样几件事情。

通过 super() 方法获取了父类 Person 的定义。

子类的 __init__() 调用了 Person.__init__() 方法。它会自动将 self 参数传递给父类。因此，你只需传入其余参数即可。在上面的例子中，Person() 能接受的其余参数指的是 name。

self.email = email 这行新的代码才真正起到了将 EmailPerson 与 Person 区分开的作用。

接下来，创建一个 EmailPerson 类的对象：

>>> bob = EmailPerson('Bob Frapples', 'bob@frapples.com')

我们既可以访问 name 特性，也可以访问 email 特性：

>>> bob.name
'Bob Frapples'
>>> bob.email
'bob@frapples.com'

为什么不像下面这样定义 EmailPerson 类呢？

>>> class EmailPerson(Person):
...     def __init__(self, name, email):
...         self.name = name
...         self.email = email

确实可以这么做，但这有悖我们使用继承的初衷。我们应该使用 super() 来让 Person 完成它应该做的事情，就像任何一个单纯的 Person 对象一样。除此之外，不这么写还有另一个好处：如果 Person 类的定义在未来发生改变，使用 super() 可以保证这些改变会自动反映到 EmailPerson 类上，而不需要手动修改。

子类可以按照自己的方式处理问题，但如果仍需要借助父类的帮助，使用 super() 是最佳的选择（就像现实生活中孩子与父母的关系一样）。

6.7　self的自辩

Python 中经常被争议的一点（除了使用空格 4 外）就是必须把 self 设置为实例方法（前面例子中你见到的所有方法都是实例方法）的第一个参数。Python 使用 self 参数来找到正确的对象所包含的特性和方法。通过下面的例子，我会告诉你调用对象方法背后 Python 实际做的工作。

4Python 使用空格而不使用 tab 进行缩进，并且用缩进标志代码块，这使得不同开发者合作时必须事先统一缩进空格的数量，不然会造成代码混乱。这是 Python 被争议最多的毛病之一。——译者注

还记得前面例子中的 Car 类吗？再次调用 exclaim() 方法：

>>> car = Car()
>>> car.exclaim()
I'm a Car!

Python 在背后做了以下两件事情：

查找 car 对象所属的类（Car）；

把 car 对象作为 self 参数传给 Car 类所包含的 exclaim() 方法。

了解调用机制后，为了好玩，我们甚至可以像下面这样进行调用，这与普通的调用语法（car.exclaim()）效果完全一致：

>>> Car.exclaim(car)
I'm a Car!

当然，我们没有理由使用这种臃肿的语法。

6.8　使用属性对特性进行访问和设置

有一些面向对象的语言支持私有特性。这些特性无法从对象外部直接访问，我们需要编写 getter 和 setter 方法对这些私有特性进行读写操作。

Python 不需要 getter 和 setter 方法，因为 Python 里所有特性都是公开的，使用时全凭自觉。如果你不放心直接访问对象的特性，可以为对象编写 setter 和 getter 方法。但更具 Python 风格的解决方案是使用属性（property）5。

5本书将 property 译作属性，而将 attribute 译作特性，请读者注意区分。——译者注

下面的例子中，首先定义一个 Duck 类，它仅包含一个 hidden_name 特性。（下一节会告诉你命名私有特性的一种更好的方式。）我们不希望别人能够直接访问这个特性，因此需要定义两个方法：getter 方法（get_name()）和 setter 方法（set_name()）。我们在每个方法中都添加一个 print() 函数，这样就能方便地知道它们何时被调用。最后，把这些方法设置为 name 属性：

>>> class Duck():
...     def __init__(self, input_name):
...         self.hidden_name = input_name
...     def get_name(self):
...         print('inside the getter')
...         return self.hidden_name
...     def set_name(self, input_name):
...         print('inside the setter')
...         self.hidden_name = input_name
...     name = property(get_name, set_name)

这两个新方法在最后一行之前都与普通的 getter 和 setter 方法没有任何区别，而最后一行则把这两个方法定义为了 name 属性。property() 的第一个参数是 getter 方法，第二个参数是 setter 方法。现在，当你尝试访问 Duck 类对象的 name 特性时，get_name() 会被自动调用：

>>> fowl = Duck('Howard')
>>> fowl.name
inside the getter
'Howard'

当然，也可以显式调用 get_name() 方法，它就像普通的 getter 方法一样：

>>> fowl.get_name()
inside the getter
'Howard'

当对 name 特性执行赋值操作时，set_name() 方法会被调用：

>>> fowl.name = 'Daffy'
inside the setter
>>> fowl.name
inside the getter
'Daffy'

也可以显式调用 set_name() 方法：

>>> fowl.set_name('Daffy')
inside the setter
>>> fowl.name
inside the getter
'Daffy'

另一种定义属性的方式是使用装饰器（decorator）。下一个例子会定义两个不同的方法，它们都叫 name()，但包含不同的装饰器：

@property，用于指示 getter 方法；

@name.setter，用于指示 setter 方法。 实际代码如下所示：

>>> class Duck():
...     def __init__(self, input_name):
...         self.hidden_name = input_name
...     @property
...     def name(self):
...         print('inside the getter')
...         return self.hidden_name
...     @name.setter
...     def name(self, input_name):
...         print('inside the setter')
...         self.hidden_name = input_name

你仍然可以像之前访问特性一样访问 name，但这里没有了显式的 get_name() 和 set_name() 方法：

>>> fowl = Duck('Howard')
>>> fowl.name
inside the getter
'Howard'
>>> fowl.name = 'Donald'
inside the setter
>>> fowl.name
inside the getter
'Donald'

　实际上，如果有人能猜到我们在类的内部用的特性名是 hidden_name，他仍然可以直接通过 fowl.hidden_name 进行读写操作。下一节将看到 Python 中特有的命名私有特性的方式。

在前面几个例子中，我们都使用 name 属性指向类中存储的某一特性（在我们的例子中是 hidden_name）。除此之外，属性还可以指向一个计算结果值。我们来定义一个 Circle 类，它包含 radius 特性以及一个计算属性 diameter：

>>> class Circle():
...     def __init__(self, radius):
...         self.radius = radius
...     @property
...     def diameter(self):
...         return 2 * self.radius
...

创建一个 Circle 对象，并给 radius 赋予一个初值：

>>> c = Circle(5)
>>> c.radius
5

可以像访问特性（例如 radius）一样访问属性 diameter：

>>> c.diameter
10

真正有趣的还在后面。我们可以随时改变 radius 特性的值，计算属性 diameter 会自动根据新的值更新自己：

>>> c.radius = 7
>>> c.diameter
14

如果你没有指定某一特性的 setter 属性（@diameter.setter），那么将无法从类的外部对它的值进行设置。这对于那些只读的特性非常有用：

>>> c.diameter = 20
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: can't set attribute

与直接访问特性相比，使用 property 还有一个巨大的优势：如果你改变了某个特性的定义，只需要在类定义里修改相关代码即可，不需要在每一处调用修改。6

6如前面例子中，假如我们需要把特性 hidden_name 的名字改成 in_class_name。不设置属性（property）的话，我们需要在每一处访问 hidden_name 的地方将它替换成 in_class_name；而设置了属性的话，仅需在类的内部修改，其余部分的访问仍直接通过属性 name 即可。——译者注

6.9　使用名称重整保护私有特性

前面的 Duck 例子中，为了隐藏内部特性，我们曾将其命名为 hidden_name。其实，Python 对那些需要刻意隐藏在类内部的特性有自己的命名规范：由连续的两个下划线开头（__）。

我们来把 hidden_name 改名为 __name，如下所示：

>>> class Duck():
...     def __init__(self, input_name):
...         self.__name = input_name
...     @property
...     def name(self):
...         print('inside the getter')
...         return self.__name
...     @name.setter
...     def name(self, input_name):
...         print('inside the setter')
...         self.__name = input_name
...

看看代码是否还能正常工作：

>>> fowl = Duck('Howard')
>>> fowl.name
inside the getter
'Howard'
>>> fowl.name = 'Donald'
inside the setter
>>> fowl.name
inside the getter
'Donald'

看起来不错！现在，你无法在外部访问 __name 特性了：

>>> fowl.__name
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'Duck' object has no attribute '__name'

这种命名规范本质上并没有把特性变成私有，但 Python 确实将它的名字重整了，让外部的代码无法使用。如果你实在好奇名称重整是怎么实现的，我可以偷偷地告诉你其中的奥秘，但不要告诉别人哦：

>>> fowl._Duck__name
'Donald'

发现了吗？我们并没有得到 inside the getter，成功绕过了 getter 方法。尽管如我们所见，这种保护特性的方式并不完美，但它确实能在一定程度上避免我们无意或有意地对特性进行直接访问。

6.10　方法的类型

有些数据（特性）和函数（方法）是类本身的一部分，还有一些是由类创建的实例的一部分。

在类的定义中，以 self 作为第一个参数的方法都是实例方法（instance method）。它们在创建自定义类时最常用。实例方法的首个参数是 self，当它被调用时，Python 会把调用该方法的对象作为 self 参数传入。

与之相对，类方法（class method）会作用于整个类，对类作出的任何改变会对它的所有实例对象产生影响。在类定义内部，用前缀修饰符 @classmethod 指定的方法都是类方法。与实例方法类似，类方法的第一个参数是类本身。在 Python 中，这个参数常被写作 cls，因为全称 class 是保留字，在这里我们无法使用。下面的例子中，我们为类 A 定义一个类方法来记录一共有多少个类 A 的对象被创建：

>>> class A():
...     count = 0
...     def __init__(self):
...         A.count += 1
...     def exclaim(self):
...         print("I'm an A!")
...     @classmethod
...     def kids(cls):
...         print("A has", cls.count, "little objects.")
...
>>>
>>> easy_a = A()
>>> breezy_a = A()
>>> wheezy_a = A()
>>> A.kids()
A has 3 little objects.

注意，上面的代码中，我们使用的是 A.count（类特性），而不是 self.count（可能是对象的特性）。在 kids() 方法中，我们使用的是 cls.count，它与 A.count 的作用一样。

类定义中的方法还存在着第三种类型，它既不会影响类也不会影响类的对象。它们出现在类的定义中仅仅是为了方便，否则它们只能孤零零地出现在代码的其他地方，这会影响代 码的逻辑性。这种类型的方法被称作静态方法（static method），用 @staticmethod 修饰，它既不需要 self 参数也不需要 class 参数。下面例子中的静态方法是一则 CoyoteWeapon 的广告：

>>> class CoyoteWeapon():
...     @staticmethod
...     def commercial():
...         print('This CoyoteWeapon has been brought to you by Acme')
...
>>>
>>> CoyoteWeapon.commercial()
This CoyoteWeapon has been brought to you by Acme

注意，在这个例子中，我们甚至都不用创建任何 CoyoteWeapon 类的对象就可以调用这个方法，句法优雅不失风格！

6.11　鸭子类型

Python 对实现多态（polymorphism）要求得十分宽松，这意味着我们可以对不同对象调用同名的操作，甚至不用管这些对象的类型是什么。

我们来为三个 Quote 类设定同样的初始化方法 __init__()，然后再添加两个新函数：

who() 返回保存的 person 字符串的值；

says() 返回保存的 words 字符串的内容，并添上指定的标点符号。

它们的具体实现如下所示：

>>> class Quote():
...     def __init__(self, person, words):
...         self.person = person
...         self.words = words
...     def who(self):
...         return self.person
...     def says(self):
...         return self.words + '.'
...
>>> class QuestionQuote(Quote):
...     def says(self):
...         return self.words + '?'
...
>>> class ExclamationQuote(Quote):
...     def says(self):
...         return self.words + '!'
...
>>>

我们不需要改变 QuestionQuote 或者 ExclamationQuote 的初始化方式，因此没有覆盖它们的 __init__() 方法。Python 会自动调用父类 Quote 的初始化函数 __init__() 来存储实例变量 person 和 words，这就是我们可以在子类 QuestionQuote 和 ExclamationQuote 的对象里访问 self.words 的原因。

接下来创建一些对象：

>>> hunter = Quote('Elmer Fudd', "I'm hunting wabbits")
>>> print(hunter.who(), 'says:', hunter.says())
Elmer Fudd says: I'm hunting wabbits.

>>> hunted1 = QuestionQuote('Bugs Bunny', "What's up, doc")
>>> print(hunted1.who(), 'says:', hunted1.says())
Bugs Bunny says: What's up, doc?

>>> hunted2 = ExclamationQuote('Daffy Duck', "It's rabbit season")
>>> print(hunted2.who(), 'says:', hunted2.says())
Daffy Duck says: It's rabbit season!

三个不同版本的 says() 为上面三种类提供了不同的响应方式，这是面向对象的语言中多态的传统形式。Python 在这方面走得更远一些，无论对象的种类是什么，只要包含 who() 和 says()，你便可以调用它。我们再来定义一个 BabblingBrook 类，它与我们之前的猎人猎物（Quote 类的后代）什么的没有任何关系：

>>> class BabblingBrook():
...     def who(self):
...         return 'Brook'
...     def says(self):
...         return 'Babble'
...
>>> brook = BabblingBrook()

现在，对不同对象执行 who() 和 says() 方法，其中有一个（brook）与其他类型的对象毫无关联：

>>> def who_says(obj):
...     print(obj.who(), 'says', obj.says())
...
>>> who_says(hunter)
Elmer Fudd says I'm hunting wabbits.
>>> who_says(hunted1)
Bugs Bunny says What's up, doc?
>>> who_says(hunted2)
Daffy Duck says It's rabbit season!
>>> who_says(brook)
Brook says Babble

这种方式有时被称作鸭子类型（duck typing），这个命名源自一句名言：

如果它像鸭子一样走路，像鸭子一样叫，那么它就是一只鸭子。7

—— 一位智者

7源于“鸭子测试”。在鸭子类型中，并不关注对象本身的具体类型，只关注它能实现的功能。——译者注

6.12　特殊方法

到目前为止，你已经能创建并使用基本对象了。现在再往深钻研一些。

当我们输入像 a = 3 + 8 这样的式子时，整数 3 和 8 是怎么知道如何实现 + 的？同样，a 又是怎么知道如何使用=来获取计算结果的？你可以使用 Python 的特殊方法（special method），有时也被称作魔术方法（magic method），来实现这些操作符的功能。别担心，不需要甘道夫 8 的帮助，它们一点也不复杂。

8英国作家 J. R. R. 托尔金小说《指环王》《霍比特人》中的巫师。——译者注

这些特殊方法的名称以双下划线（__）开头和结束。没错，你已经见过其中一个： __init__，它根据类的定义以及传入的参数对新创建的对象进行初始化。

假设你有一个简单的 Word 类，现在想要添加一个 equals() 方法来比较两个词是否一致，忽略大小写。也就是说，一个包含值 'ha' 的 Word 对象与包含 'HA' 的是相同的。

下面的代码是第一次尝试，创建一个普通方法 equals()。self.text 是当前 Word 对象所包含的字符串文本，equals() 方法将该字符串与 word2（另一个 Word 对象）所包含的字符串做比较：

>>> class Word():
...     def __init__(self, text):
...         self.text = text
...
...     def equals(self, word2):
...         return self.text.lower() == word2.text.lower()
...

接着创建三个包含不同字符串的 Word 对象：

>>> first = Word('ha')
>>> second = Word('HA')
>>> third = Word('eh')

当字符串 'ha' 和 'HA' 被转换为小写形式再进行比较时（我们就是这么做的），它们应该是相等的：

>>> first.equals(second)
True

但字符串 'eh' 无论如何与 'ha' 也不会相等：

>>> first.equals(third)
False

我们成功定义了 equals() 方法来进行小写转换并比较。但试想一下，如果能通过 if first == second 进行比较的话岂不更妙？这样类会更自然，表现得更像一个 Python 内置的类。好的，来试试吧，把前面例子中的 equals() 方法的名称改为 __eq__()（请先暂时接受，后面我会解释为什么这么命名）：

>>> class Word():
...     def __init__(self, text):
...         self.text = text
...     def __eq__(self, word2):
...         return self.text.lower() == word2.text.lower()
...

修改就此结束，来看看新的版本能否正常工作：

>>> first = Word('ha')
>>> second = Word('HA')
>>> third = Word('eh')
>>> first == second
True
>>> first == third
False

太神奇了！是不是如同魔术一般？仅需将方法名改为 Python 里进行相等比较的特殊方法名 __eq__() 即可。表 6-1 和表 6-2 列出了最常用的一些魔术方法。

表6-1：和比较相关的魔术方法

方法名

使用

__eq__(self, other)

self == other

__ne__(self, other)

self != other

__lt__(self, other)

self < other

__gt__(self, other)

self > other

__le__(self, other)

self <= other

__ge__(self, other)

self >= other

表6-2：和数学相关的魔术方法

方法名

使用

__add__(self, other)

self + other

__sub__(self, other)

self - other

__mul__(self, other)

self * other

__floordiv__(self, other)

self // other

__truediv__(self, other)

self / other

__mod__(self, other)

self % other

__pow__(self, other)

self ** other

不仅数字类型可以使用像 +（魔术方法 __add__()）和 -（魔术方法 __sub__()）的数学运算符，一些其他的类型也可以使用。例如，Python 的字符串类型使用 + 进行拼接，使用 * 进行复制。关于字符串的魔术方法还有很多，你可以在 Python 3 在线文档的 Special method names（https://docs.python.org/3/reference/datamodel.html#special-method-names）里找到，其中最常用的一些参见下面的表 6-3。

表6-3：其他种类的魔术方法

方法名

使用

__str__(self)

str(self)

__repr__(self)

repr(self)

__len__(self)

len(self)

除了 ___init__() 外，你会发现在编写类方法时最常用到的是 __str__()，它用于定义如何打印对象信息。print() 方法，str() 方法以及你将在第 7 章读到的关于字符串格式化的相关方法都会用到 __str__()。交互式解释器则用 __repr__() 方法输出变量。如果在你的类既没有定义 __str__() 也没有定义 __repr__()，Python 会输出类似下面这样的默认字符串：

 >>> first = Word('ha')
>>> first
<__main__.Word object at 0x1006ba3d0>
>>> print(first)
<__main__.Word object at 0x1006ba3d0>

我们将 __str__() 和 __repr__() 方法都添加到 Word 类里，让输出的对象信息变得更好看些：

>>> class Word():
...     def __init__(self, text):
...         self.text = text
...     def __eq__(self, word2):
...         return self.text.lower() == word2.text.lower()
...     def __str__(self):
...         return self.text
...     def __repr__(self):
...         return 'Word("'  + self.text +  '")'
...
>>> first = Word('ha')
>>> first          # uses __repr__
Word("ha")
>>> print(first)   # uses __str__
ha

更多关于魔术方法的内容请查看 Python 在线文档（https://docs.python.org/3/reference/datamodel.html#special-method-names）。

6.13　组合

如果你想要创建的子类在大多数情况下的行为都和父类相似的话（子类是父类的一种特殊情况，它们之间是 is-a 的关系），使用继承是非常不错的选择。建立复杂的继承关系确实很吸引人，但有些时候使用组合（composition）或聚合（aggregation）更加符合现实的逻辑（x 含有 y，它们之间是 has-a 的关系）。一只鸭子是鸟的一种（is-a），它有一条尾巴（has-a）。尾巴并不是鸭子的一种，它是鸭子的组成部分。下个例子中，我们会建立 bill 和 tail 对象，并将它们都提供给 duck 使用：

>>> class Bill():
...     def __init__(self, description):
...         self.description = description
...
>>> class Tail():
...     def __init__(self, length):
...         self.length = length
...
>>> class Duck():
...     def __init__(self, bill, tail):
...         self.bill = bill
...         self.tail = tail
...     def about(self):
...         print('This duck has a', self.bill.description, 'bill and a', self.tail.length, 'tail')
...
>>> tail = Tail('long')
>>> bill = Bill('wide orange')
>>> duck = Duck(bill, tail)
>>> duck.about()
This duck has a wide orange bill and a long tail

6.14　何时使用类和对象而不是模块

有一些方法可以帮助你决定是把你的代码封装到类里还是模块里。

当你需要许多具有相似行为（方法）但不同状态（特性）的实例时，使用对象是最好的选择。

类支持继承，但模块不支持。

如果你想要保证实例的唯一性，使用模块是最好的选择。不管模块在程序中被引用多少次，始终只有一个实例被加载。（对 Java 和 C++ 程序员来说，如果读过 Erich Gamma 的《设计模式：可复用面向对象软件的基础》，可以把 Python 模块理解为单例。）

如果你有一系列包含多个值的变量，并且它们能作为参数传入不同的函数，那么最好将它们封装到类里面。举个例子，你可能会使用以 size 和 color 为键的字典代表一张彩色图片。你可以在程序中为每张图片创建不同的字典，并把它们作为参数传递给像 scale() 或者 transform() 之类的函数。但这么做的话，一旦你想要添加其他的键或者函数会变得非常麻烦。为了保证统一性，应该定义一个 Image 类，把 size 和 color 作为特性，把 scale() 和 transform() 定义为方法。这么一来，关于一张图片的所有数据和可执行的操作都存储在了统一的位置。

用最简单的方式解决问题。使用字典、列表和元组往往要比使用模块更加简单、简洁且快速。而使用类则更为复杂。

创始人 Guido 的建议：

不要过度构建数据结构。尽量使用元组（以及命名元组）而不是对象。尽量使用简单的属性域而不是 getter/setter 函数……内置数据类型是你最好的朋友。尽可能多地使用数字、字符串、元组、列表、集合以及字典。多看看容器库提供的类型，尤其是双端队列。

—— Guido van Rossum

命名元组

由于 Guido 刚刚提到了命名元组（named tuple），那么我们就在这里谈一谈关于它的事情。命名元组是元组的子类，你既可以通过名称（使用 .name）来访问其中的值，也可以通过位置进行访问（使用 [offset]）。

我们来把前面例子中的 Duck 类改写成命名元组，简洁起见，把 bill 和 tail 当作简单的字符串特性而不当作类。我们可以通过将下面两个参数传入 namedtuple 函数来创建命名元组：

名称；

由多个域名组成的字符串，各个域名之间由空格隔开。

命名元组并不是 Python 自动支持的类型，使用之前需要加载与其相关的模块，下面例子中的第一行就是在进行模块加载工作：

>>> from collections import namedtuple
>>> Duck = namedtuple('Duck', 'bill tail')
>>> duck = Duck('wide orange', 'long')
>>> duck
Duck(bill='wide orange', tail='long')
>>> duck.bill
'wide orange'
>>> duck.tail
'long'

也可以用字典来构造一个命名元组：

>>> parts = {'bill': 'wide orange', 'tail': 'long'}
>>> duck2 = Duck(**parts)
>>> duck2
Duck(bill='wide orange', tail='long')

注意，上面例子中的 **parts，它是个关键词变量（keyword argument）。它的作用是将 parts 字典中的键和值抽取出来作为参数提供给 Duck() 使用。它与下面这行代码的功能一样：

>>> duck2 = Duck(bill = 'wide orange', tail = 'long')

命名元组是不可变的，但你可以替换其中某些域的值并返回一个新的命名元组：

>>> duck3 = duck2._replace(tail='magnificent', bill='crushing')
>>> duck3
Duck(bill='crushing', tail='magnificent')

假设我们把 duck 定义为字典：

>>> duck_dict = {'bill': 'wide orange', 'tail': 'long'}
>>> duck_dict
{'tail': 'long', 'bill': 'wide orange'}

可以向字典里添加新的域（键值对）：

>>> duck_dict['color'] = 'green'
>>> duck_dict
{'color': 'green', 'tail': 'long', 'bill': 'wide orange'}

但无法对命名元组这么做：

>>> duck.color = 'green'
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'dict' object has no attribute 'color'

作为总结，我列出了一些使用命名元组的好处：

它无论看起来还是使用起来都和不可变对象非常相似；

与使用对象相比，使用命名元组在时间和空间上效率更高；

可以使用点号（.）对特性进行访问，而不需要使用字典风格的方括号；

可以把它作为字典的键。

6.15　练习

(1) 创建一个名为 Thing 的空类并将它打印出来。接着，创建一个属于该类的对象 example，同样将它打印出来。看看这两次打印的结果是一样的还是不同的？

(2) 创建一个新的类 Thing2，将 'abc' 赋值给类特性 letters，打印 letters。

(3) 再创建一个新的类，叫作 Thing3。这次将 'xyz' 赋值给实例（对象）特性 letters，并试着打印 letters。看看你是不是必须先创建一个对象才可以进行打印操作？

(4) 创建一个名为 Element 的类，它包含实例特性 name、symbol 和 number。使用 'Hydrogen'、'H' 和 1 实例化一个对象。

(5) 创建一个字典，包含这些键值对：'name': 'Hydrogen'、'symbol': 'H' 和 'number': 1。然后用这个字典实例化 Element 类的对象 hydrogen。

(6) 为 Element 类定义一个 dump() 方法，用于打印对象的特性（name、symbol 和 number）。使用这个新定义的类创建一个对象 hydrogen 并用 dump() 打印。

(7) 调用 print(hydrogen)，然后修改 Element 的定义，将 dump 方法的名字改为 __str__。再次创建一个 hydrogen 对象并调用 print(hydrogen)，观察输出结果。

(8) 修改 Element 使得 name、symbol 和 number 特性都变成私有的。为它们各定义一个 getter 属性来返回各自的值。

(9) 定义三个类 Bear、Rabbit 和 Octothorpe。对每个类都只定义一个方法 eats()，分别返回 'berries'（Bear）、'clover'（Rabbit） 和 'campers'（Octothorpe）。为每个类创建一个对象并输出它们各自吃的食物（调用 eats()）。

(10) 定义三个类 Laser、Claw 以及 SmartPhone。每个类都仅有一个方法 does()，分别返回 'disintegrate'（Laser）、'crush'（Claw）以及 'ring'（SmartPhone）。接着，定义 Robot 类，包含上述三个类的实例（对象）各一个。给 Robot 定义 does() 方法用于输出它各部分的功能。

第 7 章　像高手一样玩转数据

本章将学到许多操作数据的方法，它们大多与下面这两种内置的 Python 数据类型有关。

字符串

Unicode 字符组成的序列，用于存储文本数据。

字节和字节数组

8 比特整数组成的序列，用于存储二进制数据。

7.1　文本字符串

对大多数读者来说，文本应该是最熟悉的数据类型了，因此我们从文本入手，首先介绍一些 Python 中有关字符串操作的强大特性。

7.1.1　Unicode

到目前为止，书中例子使用的都是用原始 ASCII 编码的字符串。ASCII 诞生于 20 世纪 60 年代，那时的计算机还和冰箱差不多大，运算速度也仅仅比人力稍快一些。众所周知，计算机的基本存储单元是字节（byte），它包含 8 位 / 比特（bit），可以存储 256 种不同的值。出于一些设计目的，ASCII 只使用了 7 位（128 种取值）：26 个大写字母、26 个小写字母、10 个阿拉伯数字、一些标点符号、空白符以及一些不可打印的控制符。

不幸的是，世界上现存的字符远远超过了 ASCII 所能支持的 128 个。设想在一个只有 ASCII 字符的世界中，你可以在小餐馆点个热狗，但永远不能在咖啡厅点 Gewürztraminer 酒 1。为了支持更多的字母及符号，人们已经做出了许多努力，其中有些成果你可能见到过。例如下面这两个：

1这个词在德语中原本含有曲音符，但传到法国时就丢失了。

Latin-1 或 ISO 8859-1

Windows code page 1252

上面这些编码规则使用全 8 比特（ASCII 只使用了 7 比特）进行编码，但这明显不够用，尤其是当你需要表示非印欧语系的语言符号时。Unicode 编码是一种正在发展中的国际化规范，它可以包含世界上所有语言以及来自数学领域和其他领域的各种符号。

Unicode 为每个字符赋予了一个特殊的数字编码，这些编码与具体平台、程序、语言均无关。

—— Unicode 协会

Unicode Code Charts 页面（http://www.unicode.org/charts/）包含了通往目前已定义的所有字符集的链接，且包含字符图示。最新的版本（6.2）定义了超过 110 000 种字符，每一种都有自己独特的名字和标识数。这些字符被分成了若干个 8 比特的集合，我们称之为平面（plane）。前 256 个平面为基本多语言平面（basic multilingual plane）。你可以在维基百科中查看更多关于 Unicode 平面的信息（http://en.wikipedia.org/wiki/Plane_(Unicode)）。

1. Python 3中的Unicode字符串

Python 3 中的字符串是 Unicode 字符串而不是字节数组。这是与 Python 2 相比最大的差别。在 Python 2 中，我们需要区分普通的以字节为单位的字符串以及 Unicode 字符串。

如果你知道某个字符的 Unicode ID，可以直接在 Python 字符串中引用这个 ID 获得对应字符。下面是几个例子。

用 \u 及 4 个十六进制的数字 2 可以从 Unicode 256 个基本多语言平面中指定某一特定字符。其中，前两个十六进制数字用于指定平面号（00 到 FF），后面两个数字用于指定该字符位于平面中的位置索引。00 号平面即为原始的 ASCII 字符集，字符在该平面的位置索引与它的 ASCII 编码一致。

我们需要使用更多的比特位来存储那些位于更高平面的字符。Python 为此而设计的转义序列以 \U 开头，后面紧跟着 8 个十六进制的数字，其中最左一位需为 0。

你也可以通过 \N{name} 来引用某一字符，其中 name 为该字符的标准名称，这对所有平面的字符均适用。在 Unicode 字符名称索引页（http://www.unicode.org/charts/charindex.html）可以查到字符对应的标准名称。

20~9、A~F，共 16 个字符。

Python 中的 unicodedata 模块提供了下面两个方向的转换函数：

lookup()——接受不区分大小写的标准名称，返回一个 Unicode 字符；

name()——接受一个 Unicode 字符，返回大写形式的名称。

下面的例子中，我们将编写一个测试函数，它接受一个 Python Unicode 字符，查找它对应的名称，再用这个名称查找对应的 Unicode 字符（它应该与原始字符相同）：

>>> def unicode_test(value):
...     import unicodedata
...     name = unicodedata.name(value)
...     value2 = unicodedata.lookup(name)
...     print('value="%s", name="%s", value2="%s"' % (value, name, value2))
...

用一些字符来测试一下吧。首先试一下纯 ASCII 字符：

>>> unicode_test('A')
value="A", name="LATIN CAPITAL LETTER A", value2="A"

ASCII 标点符号：

>>> unicode_test('$')
value="$", name="DOLLAR SIGN", value2="$"

Unicode 货币字符：

另一个 Unicode 货币字符：

>>> unicode_test('\u20ac')
value="€", name="EURO SIGN", value2="€"

这些例子唯一可能遇到的问题来源于使用的字体自身的限制。没有任何一种字体涵盖了所有 Unicode 字符，当缺失对应字符的图片时，会以占位符的形式显示。例如下面是尝试打印 SNOWMAN 字符得到的结果，这里使用的是 dingbat 字体：

假设想在 Python 字符串中存储 café 这个词。一种方式是从其他文件或者网站中复制粘贴出来，但这并不一定成功，只能祈祷一切正常：

>>> place = 'café'
>>> place
'café'

示例中之所以成功是因为我是从以 UTF-8 编码（马上你就会了解）的文本源复制粘贴过来的。

有没有什么办法能够直接指定末尾的 é 字符呢？如果你查看了 E 索引（http://www.unicode.org/charts/charindex.html#E）下的字符会发现，我们所需字符 E WITH ACUTE, LATIN SMALL LETTER 对应的 Unicode 值为 00E9。我们用刚刚的 name() 函数和 lookup() 函数来检测一下，首先用编码值查询字符名称：

>>> unicodedata.name('\u00e9')
'LATIN SMALL LETTER E WITH ACUTE'

接着，通过名称查询对应的编码值：

>>> unicodedata.lookup('E WITH ACUTE, LATIN SMALL LETTER')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
KeyError: "undefined character name 'E WITH ACUTE, LATIN SMALL LETTER'"

　为了方便查阅，Unicode 字符名称索引页列出的字符名称是经过修改的，因此与由 name() 函数得到的名称有所不同。如果需要将它们转化为真实的 Unicode 名称（Python 使用的），只需将逗号舍去，并将逗号后面的内容移到最前面即可。据此，我们应将 E WITH ACUTE, LATIN SMALL LETTER 改为 LATIN SMALL LETTER E WITH ACUTE：

>>> unicodedata.lookup('LATIN SMALL LETTER E WITH ACUTE')
'é'

现在，可以通过字符名称或者编码值来指定 café 这个词了：

>>> place = 'caf\u00e9'
>>> place
'café'
>>> place = 'caf\N{LATIN SMALL LETTER E WITH ACUTE}'
>>> place
'café'

上面的代码中，我们将 é 直接插入了字符串中。也可以使用拼接来构造字符串：

>>> u_umlaut = '\N{LATIN SMALL LETTER U WITH DIAERESIS}'
>>> u_umlaut
'ü'
>>> drink = 'Gew' + u_umlaut + 'rztraminer'
>>> print('Now I can finally have my', drink, 'in a', place)
Now I can finally have my Gewürztraminer in a café

字符串函数 len 可以计算字符串中 Unicode 字符的个数，而不是字节数：

>>> len('$')
1
>>> len('\U0001f47b')
1

2. 使用UTF-8编码和解码

对字符串进行处理时，并不需要在意 Python 中 Unicode 字符的存储细节。

但当需要与外界进行数据交互时则需要完成两件事情：

将字符串编码为字节；

将字节解码为字符串。

如果 Unicode 包含的字符种类不超过 64 000 种，我们就可以将字符 ID 统一存储在 2 字节中。遗憾的是，Unicode 所包含的字符种类远不止于此。诚然，我们可以将字符 ID 统一编码在 3 或 4 字节中，但这会使空间开销（内存和硬盘）增加 3 到 4 倍。

两位为 Unix 开发者所熟知的大神 Ken Thompson 和 Rob Pike 在新泽西共用晚餐时解决了这个问题，他们在餐桌垫上设计出了 UTF-8 动态编码方案。这种方案会动态地为每一个 Unicode 字符分配 1 到 4 字节不等：

为 ASCII 字符分配 1 字节；

为拉丁语系（除西里尔语）的语言分配 2 字节；

为其他的位于基本多语言平面的字符分配 3 字节；

为剩下的字符集分配 4 字节，这包括一些亚洲语言及符号。

UTF-8 是 Python、Linux 以及 HTML 的标准文本编码格式。这种编码方式简单快速、字符覆盖面广、出错率低。在代码中全都使用 UTF-8 编码会是一种非常棒的体验，你再也不需要不停地转化各种编码格式。

　如果你创建 Python 字符串时使用了从别的文本源（例如网页）复制粘贴过来的字符串，一定要确保文本源使用的是 UTF-8 编码。将 Latin-1 或者 Windows 1252 复制粘贴为 Python 字符串的错误极其常见，这样得到的字节序列是无效的，会产生许多后续隐患。

3. 编码

编码是将字符串转化为一系列字节的过程。字符串的 encode() 函数所接收的第一个参数是编码方式名。可选的编码方式列在了表 7-1 中。

表7-1：编码方式

编码

说明

'ascii'

经典的 7 比特 ASCII 编码

'utf-8'

最常用的以 8 比特为单位的变长编码

'latin-1'

也被称为 ISO 8859-1 编码

'cp-1252'

Windows 常用编码

'unicode-escape'

Python 中 Unicode 的转义文本格式，\uxxxx 或者 \Uxxxxxxxx

你可以将任何 Unicode 数据以 UTF-8 的方式进行编码。我们试着将 Unicode 字符串 '\u2603' 赋值给 snowman：

>>> snowman = '\u2603'

snowman 是一个仅包含一个字符的 Unicode 字符串，这与它存储所需的字节数没有任何关系：

>>> len(snowman)
1

下一步将这个 Unicode 字符编码为字节序列：

>>> ds = snowman.encode('utf-8')

就像我之前提到的，UTF-8 是一种变长编码方式。在这个例子中，单个 Unicode 字符 snowman 占用了 3 字节的空间：

>>> len(ds)
3
>>> ds
b'\xe2\x98\x83'

现在，len() 返回了字节数（3），因为 ds 是一个 bytes 类型的变量。

当然，你也可以使用 UTF-8 以外的编码方式，但该 Unicode 字符串有可能无法被指定的编码方式处理，此时 Python 会抛出异常。例如，如果你想要使用 ascii 方式进行编码，必须保证待编码的字符串仅包含 ASCII 字符集里的字符，不含有任何其他的 Unicode 字符，否则会出现错误：

>>> ds = snowman.encode('ascii')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeEncodeError: 'ascii' codec can't encode character '\u2603'
in position 0: ordinal not in range(128)

encode() 函数可以接受额外的第二个参数来帮助你避免编码异常。它的默认值是 'strict'，如上例所示，当函数检测到需要处理的字符串包含非 ASCII 字符时，会抛出 UnicodeEncodeError 异常。当然，该参数还有别的可选值，例如 'ignore' 会抛弃任何无法 进行编码的字符：

>>> snowman.encode('ascii', 'ignore')
b''

'replace' 会将所有无法进行编码的字符替换为 ?：

>>> snowman.encode('ascii', 'replace')
b'?'

'backslashreplace' 则会创建一个和 unicode-escape 类似的 Unicode 字符串：

>>> snowman.encode('ascii', 'backslashreplace')
b'\\u2603'

如果你需要一份 Unicode 转义符序列的可打印版本，可以考虑使用上面这种方式。

下面的代码可以用于创建网页中使用的字符实体串：

>>> snowman.encode('ascii', 'xmlcharrefreplace')
b'&#9731;'

4. 解码

解码是将字节序列转化为 Unicode 字符串的过程。我们从外界文本源（文件、数据库、网站、网络 API 等）获得的所有文本都是经过编码的字节串。重要的是需要知道它是以何种方式编码的，这样才能逆转编码过程以获得 Unicode 字符串。

问题是字节串本身不带有任何指明编码方式的信息。之前我也提到过从网站随意复制粘贴文本的风险，你也可能遇到过网页乱码的情况，本应是 ASCII 字符的位置却被奇怪的字符占据了，这些都是编码和解码的方式不一致导致的。

创建一个 place 字符串，赋值为 'café'：

>>> place = 'caf\u00e9'
>>> place
'café'
>>> type(place)
<class 'str'>

将它以 UTF-8 格式编码为 bytes 型变量，命名为 place_bytes：

>>> place_bytes = place.encode('utf-8')
>>> place_bytes
b'caf\xc3\xa9'
>>> type(place_bytes)
<class 'bytes'>

注意，place_bytes 包含 5 个字节。前 3 个字节的内容与 ASCII 一样（UTF-8 的强大之处），最后两个字节用于编码 'é'。现在，将字节串转换回 Unicode 字符串：

>>> place2 = place_bytes.decode('utf-8')
>>> place2
'café'

一切正常，这是因为编码和解码使用的都是 UTF-8 格式。如果使用其他格式进行解码会发生什么？

>>> place3 = place_bytes.decode('ascii')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'ascii' codec can't decode byte 0xc3 in position 3:
ordinal not in range(128)

ASCII 解码器会抛出异常，因为字节值 0xc3 在 ASCII 编码中是非法值。对于另一些使用 8 比特编码的方式而言，位于 128（十六进制 80）到 255（十六进制 FF）之间的 8 比特的字符集可能是合法的，但解码得到的结果显然与 UTF-8 不同：

>>> place4 = place_bytes.decode('latin-1')
>>> place4
'cafÃ©'
>>> place5 = place_bytes.decode('windows-1252')
>>> place5
'cafÃ©'

这个故事告诉我们：尽可能统一使用 UTF-8 编码。况且它出错率低，兼容性好，可以表达所有的 Unicode 字符，编码和解码的速度又快，这么多优点，何乐而不为？

5. 更多内容

如果想要了解更多关于 Unicode 的细节，下面这些链接对你可能会有所帮助：

Unicode HOWTO（https://docs.python.org/3/howto/unicode.html）

Pragmatic Unicode（http://nedbatchelder.com/text/unipain.html）

The Absolute Minimum Every Software Developer Absolutely, Positively Must Know About Unicode and Character Sets (No Excuses!)（http://www.joelonsoftware.com/articles/Unicode.html）

7.1.2　格式化

之前几乎都没有提到过文本格式化的问题，现在有必要关注一下这方面的内容了。第 2 章曾经用过一些字符串排版函数，那些示例代码要么简单地使用 print() 语句，要么直接在交互式解释器显示。现在是时候看看如何使用不同的格式化方法将变量插值（interpolate）到字符串中了，即将变量的值嵌入字符串中。你可以用这种方法来生成那些格式框架看起来一样的报告或者其他固定格式的输出。

Python 有两种格式化字符串的方式，我们习惯简单地称之为旧式（old style）和新式（new style）。这两种方式在 Python 2 和 Python 3 中都适用（新式格式化方法适用于 Python 2.6 及以上）。旧式格式化相对简单些，因此我们从它开始。

1. 使用%的旧式格式化

旧式格式化的形式为 string % data。其中 string 包含的是待插值的序列。表 7-2 展示了最简单的插值序列，它仅由 % 以及一个用于指定数据类型的字母组成。

表7-2：转换类型

%s | 字符串
%d | 十进制整数
%x | 十六进制整数
%o | 八进制整数
%f | 十进制浮点数
%e | 以科学计数法表示的浮点数
%g | 十进制或科学计数法表示的浮点数
%% | 文本值 % 本身

下面是一些简单的例子。首先格式化一个整数：

>>> '%s' % 42
'42'
>>> '%d' % 42
'42'
>>> '%x' % 42
'2a'
>>> '%o' % 42
'52'

接着是浮点数：

>>> '%s' % 7.03
'7.03'
>>> '%f' % 7.03
'7.030000'
>>> '%e' % 7.03
'7.030000e+00'
>>> '%g' % 7.03
'7.03'

整数和字面值 %：

>>> '%d%%' % 100
'100%'

下面是一些关于字符串和整数的插值操作：

>>> actor = 'Richard Gere'
>>> cat = 'Chester'
>>> weight = 28

>>> "My wife's favorite actor is %s" % actor
"My wife's favorite actor is Richard Gere"

>>> "Our cat %s weighs %s pounds" % (cat, weight)
'Our cat Chester weighs 28 pounds'

字符串内的 %s 意味着需要插入一个字符串。字符串中出现 % 的次数需要与 % 之后所提供的数据项个数相同。如果只需插入一个数据，例如前面的 actor，直接将需要插入的数据置于 % 后即可。如果需要插入多个数据，则需要将它们封装进一个元组（以圆括号为界，逗号分开），例如上例中的 (cat, weight)。

尽管 weight 是一个整数，格式化串中的 %s 也会将它转化为字符串型。

你可以在 % 和指定类型的字母之间设定最大和最小宽度、排版以及填充字符，等等。

我们来定义一个整数 n、一个浮点数 f 以及一个字符串 s：

>>> n = 42
>>> f = 7.03
>>> s = 'string cheese'

使用默认宽度格式化它们：

>>> '%d %f %s' % (n, f, s)
'42 7.030000 string cheese'

为每个变量设定最小域宽为 10 个字符，右对齐，左侧不够用空格填充：

>>> '%10d %10f %10s' % (n, f, s)
'        42   7.030000 string cheese'

和上面的例子使用同样的域宽，但改成左对齐：

>>> '%-10d %-10f %-10s' % (n, f, s)
'42         7.030000   string cheese'

这次仍然使用之前的域宽，但是设定最大字符宽度为 4，右对齐。这样的设置会截断超过长度限制的字符串，并且将浮点数的精度限制在小数点后 4 位：

>>> '%10.4d %10.4f %10.4s' % (n, f, s)
'      0042     7.0300       stri'

去掉最小域宽为 10 的限制：

>>> '%.4d %.4f %.4s' % (n, f, s)
'0042 7.0300 stri'

最后，改变一下上面例子的硬编码方式，将域宽、字符宽度等设定作为参数：

>>> '%*.*d %*.*f %*.*s' % (10, 4, n, 10, 4, f, 10, 4, s)
'      0042     7.0300       stri'

2. 使用{}和format的新式格式化

旧式格式化方式现在仍然兼容。Python 2（将永远停止在 2.7 版本）会永远提供对旧式格式化的支持。然而，如果你在使用 Python 3，新式格式化更值得推荐。

新式格式化最简单的用法如下所示：

>>> '{} {} {}'.format(n, f, s)
'42 7.03 string cheese'

旧式格式化中传入参数的顺序需要与 % 占位符出现的顺序完全一致，但在新式格式化里，可以自己指定插入的顺序：

>>> '{2} {0} {1}'.format(f, s, n)
'42 7.03 string cheese'

0 代表第一个参数 f；1 代表字符串 s；2 代表最后一个参数，整数 n。

参数可以是字典或者命名变量，格式串中的标识符可以引用这些名称：

>>> '{n} {f} {s}'.format(n=42, f=7.03, s='string cheese')
'42 7.03 string cheese'

下面的例子中，我们试着将之前作为参数的 3 个值存到一个字典中，如下所示：

>>> d = {'n': 42, 'f': 7.03, 's': 'string cheese'}

下面的例子中，{0} 代表整个字典，{1} 则代表字典后面的字符串 'other'：

>>> '{0[n]} {0[f]} {0[s]} {1}'.format(d, 'other')
'42 7.03 string cheese other'

上面这些例子都是以默认格式打印结果的。旧式格式化允许在 % 后指定参数格式，但在新式格式化里，将这些格式标识符放在 : 后。首先使用位置参数的例子：

>>> '{0:d} {1:f} {2:s}'.format(n, f, s)
'42 7.030000 string cheese'

接着使用相同的值，但这次它们作为命名参数：

>>> '{n:d} {f:f} {s:s}'.format(n=42, f=7.03, s='string cheese')
'42 7.030000 string cheese'

新式格式化也支持其他各类设置（最小域宽、最大字符宽、排版，等等）。

下面是一个最小域宽设为 10、右对齐（默认）的例子：

>>> '{0:10d} {1:10f} {2:10s}'.format(n, f, s)
'        42   7.030000 string cheese'

与上面例子一样，但使用 > 字符设定右对齐显然要更为直观：

>>> '{0:>10d} {1:>10f} {2:>10s}'.format(n, f, s)
'        42   7.030000 string cheese'

最小域宽为 10，左对齐：

>>> '{0:<10d} {1:<10f} {2:<10s}'.format(n, f, s)
'42         7.030000   string cheese'

最小域宽为 10，居中：

>>> '{0:^10d} {1:^10f} {2:^10s}'.format(n, f, s)
'    42      7.030000  string cheese'

新式格式化与旧式格式化相比有一处明显的不同：精度（precision，小数点后面的数字）对于浮点数而言仍然代表着小数点后的数字个数，对于字符串而言则代表着最大字符个数，但在新式格式化中你无法对整数设定精度：

>>> '{0:>10.4d} {1:>10.4f} {2:10.4s}'.format(n, f, s)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: Precision not allowed in integer format specifier
>>> '{0:>10d} {1:>10.4f} {2:>10.4s}'.format(n, f, s)
'        42     7.0300       stri'

最后一个可设定的值是填充字符。如果想要使用空格以外的字符进行填充，只需把它放在 : 之后，其余任何排版符（<、>、^）和宽度标识符之前即可：

>>> '{0:!^20s}'.format('BIG SALE')
'!!!!!!BIG SALE!!!!!!'

7.1.3　使用正则表达式匹配

第 2 章接触到一些简单的字符串操作。有了这些知识，你可能已经会在命令行里使用一些简单的“通配符”模式了，例如 ls*.py，这条命令的意思是“列出当前目录下所有以 .py 结尾的文件名”。

是时候使用正则表达式（regular expression）探索一些复杂模式匹配的方法了。与之相关的功能都位于标准库模块 re 中，因此首先需要引用它。你需要定义一个用于匹配的模式（pattern）字符串以及一个匹配的对象：源（source）字符串。简单的匹配，如下所示：

result = re.match('You', 'Young Frankenstein')

这里，'You' 是模式，'Young Frankenstein' 是源——你想要检查的字符串。match() 函数用于查看源是否以模式开头。

对于更加复杂的匹配，可以先对模式进行编译以加快匹配速度：

youpattern = re.compile('You')

然后就可以直接使用编译好的模式进行匹配了：

result = youpattern.match('Young Frankenstein')

match() 并不是比较 source 和 pattern 的唯一方法。下面列出了另外一些可用的方法：

search() 会返回第一次成功匹配，如果存在的话；

findall() 会返回所有不重叠的匹配，如果存在的话；

split() 会根据 pattern 将 source 切分成若干段，返回由这些片段组成的列表；

sub() 还需一个额外的参数 replacement，它会把 source 中所有匹配的 pattern 改成 replacement。

1. 使用match()进行准确匹配

字符串 'Young Frankenstein' 是以单词 'You' 开头的吗？以下是一些带注释的代码：

>>> import re
>>> source = 'Young Frankenstein'
>>> m = re.match('You', source)  # 从源字符串的开头开始匹配
>>> if m:  # 匹配成功返回了对象，将它输出看看匹配得到的是什么
...     print(m.group())
...
You
>>> m = re.match('^You', source) # 起始锚点也能起到同样作用
>>> if m:
...     print(m.group())
...
You

尝试匹配 'Frank' 又会如何？

>>> m = re.match('Frank', source)
>>> if m:
...     print(m.group())
...

这一次，match() 什么也没有返回，if 也没有执行内部的 print 语句。如前所述，match() 只能检测以模式串作为开头的源字符串。但是 search() 可以检测任何位置的匹配：

>>> m = re.search('Frank', source)
>>> if m:
...      print(m.group())
...
Frank

改变一下匹配的模式：

>>> m = re.match('.*Frank', source)
>>> if m:  # match返回对象
...     print(m.group())
...
Young Frank

以下是对新模式能够匹配成功的简单解释：

. 代表任何单一字符；

* 代表任意一个它之前的字符，.* 代表任意多个字符（包括 0 个）；

Frank 是我们想要在源字符串中某处进行匹配的短语。

match() 返回了匹配 .*Frank 的字符串：'Young Frank'。

2. 使用 search() 寻找首次匹配

你可以使用 search() 在源字符串 'Young Frankenstein' 的任意位置寻找模式 'Frank'，无需通配符 .*：

>>> m = re.search('Frank', source)
>>> if m:  # search返回对象
...     print(m.group())
...
Frank

3. 使用 findall() 寻找所有匹配

之前的例子都是查找到一个匹配即停止。但如果想要知道一个字符串中出现了多少次字母 'n' 应该怎么办？

>>> m = re.findall('n', source)
>>> m   # findall返回了一个列表
['n', 'n', 'n', 'n']
>>> print('Found', len(m), 'matches')
Found 4 matches

将模式改成 'n'，紧跟着任意一个字符，结果又如何？

>>> m = re.findall('n.', source)
>>> m
['ng', 'nk', 'ns']

注意，上面例子中最后一个 'n' 并没有匹配成功，需要通过 ? 说明 'n' 后面的字符是可选的：

>>> m = re.findall('n.?', source)
>>> m
['ng', 'nk', 'ns', 'n']

4. 使用 split() 按匹配切分

下面的示例展示了如何依据模式而不是简单的字符串（就像普通的 split() 方法做的）将一个字符串切分成由一系列子串组成的列表：

>>> m = re.split('n', source)
>>> m    # split返回的列表
['You', 'g Fra', 'ke', 'stei', '']

5. 使用 sub() 替换匹配

这和字符串 replace() 方法有些类似，只不过使用的是模式而不是文本串：

>>> m = re.sub('n', '?', source)
>>> m   # sub返回的字符串
'You?g Fra?ke?stei?'

6. 模式：特殊的字符

许多书中关于正则表达式的描述都是从如何定义它开始的，我觉得这不太符合学习的逻辑。正则表达式不是一两句就能说清楚的小语言，它拥有大量的语言细节，会完全占据你的大脑让你无所适从。它使用的符号实在是太多了，看起来简直就像是幽灵画符一样！

有了上面介绍的方法（match()、search()、findall() 和 sub()）做铺垫，现在可以从应用讲起并研究如何构造正则表达式了，即上述方法中的模式。

已经见过的一些基本模式：

普通的文本值代表自身，用于匹配非特殊字符；

使用 . 代表任意除 \n 外的字符；

使用 * 表示任意多个字符（包括 0 个）；

使用 ? 表示可选字符（0 个或 1 个）。

接下来要介绍一些特殊字符，参见表 7-3。

表7-3：特殊字符

模式

匹配

\d

一个数字字符

\D

一个非数字字符

\w

一个字母或数字字符

\W

一个非字母非数字字符

\s

空白符

\S

非空白符

\b

单词边界（一个 \w 与\W 之间的范围，顺序可逆）

\B

非单词边界

Python 的 string 模块中预先定义了一些可供我们测试用的字符串常量。我们将使用其中的 printable 字符串，它包含 100 个可打印的 ASCII 字符，包括大小写字母、数字、空格符以及标点符号：

>>> import string
>>> printable = string.printable
>>> len(printable)
100
>>> printable[0:50]
'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN'
>>> printable[50:]
'OPQRSTUVWXYZ!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c'

printable 中哪些字符是数字？

>>> re.findall('\d', printable)
['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']

哪些字符是数字、字符或下划线？

>>> re.findall('\w', printable)
['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b',
'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n',
'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L',
'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X',
'Y', 'Z', '_']

哪些属于空格符？

>>> re.findall('\s', printable)
[' ', '\t', '\n', '\r', '\x0b', '\x0c']

正则表达式不仅仅适用于 ASCII 字符，例如 \d 还可以匹配 Unicode 的数字字符，并不局限于 ASCII 中的 '0' 到 '9'。我们从 FileFormat.info（http://www.fileformat.info/info/unicode/category/Ll/list.htm）中引入两个新的非 ASCII 编码的小写字母。

这个测试例子中，在模式中添加以下内容：

三个 ASCII 字母

三个不会被 \w 所匹配的标点符号

Unicode 中的 LATIN SMALL LETTER E WITH CIRCUMFLEX（\u00ea）

Unicode 中的 LATIN SMALL LETTER E WITH BREVE（\u0115）

>>> x = 'abc' + '-/*' + '\u00ea' + '\u0115'

与预期的一样，应用这个模式可以匹配出下面这些字母：

>>> re.findall('\w', x)
['a', 'b', 'c', 'ê', 'ě']

7. 模式：使用标识符

现在试着用表 7-4 中所包含的一些常用的模式标识符来烹饪一道“符号比萨”大餐。

表中，expr 和其他斜体的单词表示合法的正则表达式。

表7-4：模式标识符

模式

匹配

abc

文本值abc

（expr）

expr

expr 1 | expr 2

expr 1 或 expr 2

.

除 \n 外的任何字符

^

源字符串的开头

$

源字符串的结尾

prev?

0 个或 1 个prev

prev*

0 个或多个prev，尽可能多地匹配

prev*?

0 个或多个prev，尽可能少地匹配

prev+

1 个或多个prev，尽可能多地匹配

prev+?

1 个或多个prev，尽可能少地匹配

prev{m}

m 个连续的prev

prev{m, n}

m 到 n 个连续的prev，尽可能多地匹配

prev{m, n}?

m 到 n 个连续的prev，尽可能少地匹配

[abc]

a 或 b 或 c（和 a|b|c 一样）

[^abc]

非（a 或 b 或c）

prev (?=next)

如果后面为next，返回prev

prev (?!next)

如果后面非next，返回prev

(?<=prev) next

如果前面为prev，返回next

(?<!prev) next

如果前面非prev，返回next

在看下面的例子时，你可能需要时不时地查阅上面的表格。先来定义我们使用的源字符串：

>>> source = '''I wish I may, I wish I might
... Have a dish of fish tonight.'''

首先，在源字符串中检索 wish：

>>> re.findall('wish', source)
['wish', 'wish']

接着，对源字符串任意位置查询 wish 或者 fish：

>>> re.findall('wish|fish', source)
['wish', 'wish', 'fish']

从字符串开头开始匹配 wish：

>>> re.findall('^wish', source)
[]

从字符串开头开始匹配 I wish：

>>> re.findall('^I wish', source)
['I wish']

从字符串结尾开始匹配 fish：

>>> re.findall('fish$', source)
[]

最后，从字符串结尾开始匹配 fish tonight.：

>>> re.findall('fish tonight.$', source)
['fish tonight.']

^ 和 $ 叫作锚点（anchor）：^ 将搜索域定位到源字符串的开头，$ 则定位到末尾。上面例子中的 .$ 可以匹配末尾的任意字符，包括句号，因此能成功匹配。但更准确地说，上面的例子应该使用转义符将 . 转义为句号，这才是我们真正想示意的纯文本值匹配：

>>> re.findall('fish tonight\.$', source)
['fish tonight.']

接下来查询以 w 或 f 开头，后面紧接着 ish 的匹配：

>>> re.findall('[wf]ish', source)
['wish', 'wish', 'fish']

查询以若干个 w、s 或 h 组合的匹配：

>>> re.findall('[wsh]+', source)
['w', 'sh', 'w', 'sh', 'h', 'sh', 'sh', 'h']

查询以 ght 开头，后面紧跟一个非数字非字母字符的匹配：

>>> re.findall('ght\W', source)
['ght\n', 'ght.']

查询以 I 开头，后面跟着 wish 的匹配（wish 出现次数尽量少）：

>>> re.findall('I (?=wish)', source)
['I ', 'I ']

最后查询以 wish 结尾，前面为 I 的匹配（I 出现的次数尽量少）：

>>> re.findall('(?<=I) wish', source)
[' wish', ' wish']

有时，正则表达式的语法可能会与 Python 本身的语法冲突。例如，我们期望下面例子中的模式能匹配任何以 fish 开头的词：

>>> re.findall('\bfish', source)
[]

为什么没有匹配成功？第 2 章曾提到，Python 字符串会使用一些特殊的转义符。例如上面的 \b，它在字符串中代表退格，但在正则表达式中，它代表一个单词的开头位置。因此，把 Python 的普通字符串用作正则表达式的模式串时需要特别注意，不要像上面一样与转义符产生冲突。或者在任何使用正则表达式的地方都记着在模式串的前面添加字符 r，这样可以告诉 Python 这是一个正则表达式，从而禁用字符串转义符，如下所示：

>>> re.findall(r'\bfish', source)
['fish']

8. 模式：定义匹配的输出

当使用 match() 或 search() 时，所有的匹配会以 m.group() 的形式返回到对象 m 中。如果你用括号将某一模式包裹起来，括号中模式匹配得到的结果归入自己的 group（无名称）中，而调用 m.groups() 可以得到包含这些匹配的元组，如下所示：

>>> m = re.search(r'(. dish\b).*(\bfish)', source)
>>> m.group()
'a dish of fish'
>>> m.groups()
('a dish', 'fish')

(?P< name >expr) 这样的模式会匹配 expr，并将匹配结果存储到名为 name 的组中：

>>> m = re.search(r'(?P<DISH>. dish\b).*(?P<FISH>\bfish)', source)
>>> m.group()
'a dish of fish'
>>> m.groups()
('a dish', 'fish')
>>> m.group('DISH')
'a dish'
>>> m.group('FISH')
'fish'

7.2　二进制数据

处理文本数据比较晦涩难懂（新旧格式化、正则表达式等），而处理二进制数据就有趣多了。你需要了解像字节序（endianness，电脑处理器是如何将数据组织存储为字节的）以及整数的符号位（sign bit）之类的概念。你可能需要研究二进制文件格式、网络包等内容，从而对其中的数据进行提取甚至修改。本节将了解到 Python 中有关二进制数据的一些基本操作。

7.2.1　字节和字节数组

Python 3 引入了下面两种使用 8 比特序列存储小整数的方式，每 8 比特可以存储从 0~255 的值：

字节是不可变的，像字节数据组成的元组；

字节数组是可变的，像字节数据组成的列表。

我们的示例从创建列表 blist 开始。接着需使用这个列表创建一个 bytes 类型的变量 the_bytes 以及一个 bytearray 类型的变量 the_byte_array：

>> blist = [1, 2, 3, 255]
>>> the_bytes = bytes(blist)
>>> the_bytes
b'\x01\x02\x03\xff'
>>> the_byte_array = bytearray(blist)
>>> the_byte_array
bytearray(b'\x01\x02\x03\xff')

　bytes 类型值的表示形式比较特殊：以 b 开头，接着是一个单引号，后面跟着由十六进制数（例如 \x02）或 ASCII 码组成的序列，最后以配对的单引号结束。Python 会将这些十六进制数或者 ASCII 码转换为整数，如果该字节的值为有效 ASCII 编码则会显示 ASCII 字符。

>>> b'\x61'
 b'a'

>>> b'\x01abc\xff'
 b'\x01abc\xff'

下面的例子说明了 bytes 类型的不可变性：

>>> the_bytes[1] = 127
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'bytes' object does not support item assignment

但 bytearray 类型的变量是可变的：

>>> the_byte_array = bytearray(blist)
>>> the_byte_array
bytearray(b'\x01\x02\x03\xff')
>>> the_byte_array[1] = 127
>>> the_byte_array
bytearray(b'\x01\x7f\x03\xff')

下面两行代码都会创建一个包含 256 个元素的结果，包含 0~255 的所有值：

>>> the_bytes = bytes(range(0, 256))
>>> the_byte_array = bytearray(range(0, 256))

打印 bytes 或 bytearray 数据时，Python 会以 \xxx 的形式表示不可打印的字符，以 ASCII 字符的形式表示可打印的字符（以及一些转义字符，例如 \n 而不是 \x0a）。下面是 the_bytes 的打印结果（手动设置为一行显示 16 个字节）：

>>> the_bytes
b'\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0c\r\x0e\x0f
\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f
!"#$%&\'()*+,-./
0123456789:;<=>?
@ABCDEFGHIJKLMNO
PQRSTUVWXYZ[\\]^_
`abcdefghijklmno
pqrstuvwxyz{|}~\x7f
\x80\x81\x82\x83\x84\x85\x86\x87\x88\x89\x8a\x8b\x8c\x8d\x8e\x8f
\x90\x91\x92\x93\x94\x95\x96\x97\x98\x99\x9a\x9b\x9c\x9d\x9e\x9f
\xa0\xa1\xa2\xa3\xa4\xa5\xa6\xa7\xa8\xa9\xaa\xab\xac\xad\xae\xaf
\xb0\xb1\xb2\xb3\xb4\xb5\xb6\xb7\xb8\xb9\xba\xbb\xbc\xbd\xbe\xbf
\xc0\xc1\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca\xcb\xcc\xcd\xce\xcf
\xd0\xd1\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9\xda\xdb\xdc\xdd\xde\xdf
\xe0\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xeb\xec\xed\xee\xef
\xf0\xf1\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa\xfb\xfc\xfd\xfe\xff'

看起来可能有些困惑，毕竟上面输出的数据是字节（小整数）而不是字符。

7.2.2　使用struct转换二进制数据

如你所见，Python 中有许多文本处理工具（模块、函数等），然而处理二进制数据的工具则要少得多。标准库里有一个 struct 模块，专门用于处理类似 C 和 C++ 中结构体的数据。你可以使用 struct 模块的功能将二进制数据转换为 Python 中的数据结构。

以一个 PNG 文件（一种常见的图片格式，其他图片格式还有 GIF、JPEG 等）为例看看 struct 是如何工作的。我们来编写一个小程序，从 PNG 文件中获得图片的宽度和高度信息。使用 O'Reilly 的经典标志：一只睁大了眼睛的眼镜猴，见图 7-1。

图 7-1：O'Reilly 的标志眼镜猴

你可以在Wikipedia（http://upload.wikimedia.org/wikipedia/en/9/95/O'Reilly_logo.png）上获取这张图片的 PNG 文件。第 8 章之前都不会讨论读取文件的方法，因此这里我仅仅是将这个文件下载下来，并编写了一个简单的小程序将它的数据以字节形式打印出来，然后将起始的 30 字节数据存入 Python bytes 型变量 data 中，如下所示。方便起见，你只需复制这部分数据即可。（PNG 格式规定了图片的宽度和高度信息存储在初始 24 字节中，因此不需要其他的额外数据。）

>>> import struct
>>> valid_png_header = b'\x89PNG\r\n\x1a\n'
>>> data = b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR' + \
...     b'\x00\x00\x00\x9a\x00\x00\x00\x8d\x08\x02\x00\x00\x00\xc0'
>>> if data[:8] == valid_png_header:
...     width, height = struct.unpack('>LL', data[16:24])
...     print('Valid PNG, width', width, 'height', height)
... else:
...     print('Not a valid PNG')
...
Valid PNG, width 154 height 141

以上代码说明：

data 包含了 PNG 文件的前 30 字节内容，为了书的排版，我将这 30 字节数据放到了两行字节串中，并用 + 和续行符（\）将它们连接起来；

valid_png_header 包含 8 字节序列，它标志着这是一个有效的 PNG 格式文件；

width 值位于第 16~20 字节，height 值则位于第 21~24 字节。

上面代码中的 >LL 是一个格式串，它用于指导 unpack() 正确解读字节序列并将它们组装成 Python 中的数据类型。可以将它分解成下面几个基本格式标志：

> 用于指明整数是以大端（big-endian）方案存储的；

每个 L 代表一个 4 字节的无符号长（unsigned long）整数。

你可以直接获取 4 字节数据：

>>> data[16:20]
b'\x00\x00\x00\x9a'
>>> data[20:24]0x9a
b'\x00\x00\x00\x8d'

大端方案将高字节放在左侧。由于宽度和高度都小于 255，因此它们存储在每一个 4 字节序列的最后一字节中。不难验证，上面的十六进制数转换为十进制后与我们预期的数值（图片的宽和高）一致：

>>> 0x9a
154
>>> 0x8d
141

如果想要执行上述过程的逆过程，将 Python 数据转换为字节，可以使用 struct pack() 函数：

>>> import struct
>>> struct.pack('>L', 154)
b'\x00\x00\x00\x9a'
>>> struct.pack('>L', 141)
b'\x00\x00\x00\x8d'

表 7-5 和表 7-6 列出了 pack() 和 unpack() 使用的一些格式标识符。

首先是字节序标识符。

表7-5：字节序标识符

标识符

字节序

<

小端方案

>

大端方案

表7-6：格式标识符

标识符

描述

字节

x

跳过一个字节

1

b

有符号字节

1

B

无符号字节

1

h

有符号短整数

2

H

无符号短整数

2

i

有符号整数

4

I

无符号整数

4

l

有符号长整数

4

L

无符号长整数

4

Q

无符号 long long 型整数

8

f

单精度浮点数

4

d

双精度浮点数

8

p

数量和字符

1 + 数量

s

字符

数量

类型标识符紧跟在字节序标识符的后面。任何标识符的前面都可以添加数字用于指定需要匹配的数量，例如 5B 代表 BBBBB。

可以使用数量前缀改写 >LL：

>>> struct.unpack('>2L', data[16:24])
(154, 141)

之前的例子中使用了切片 data[16:24] 直接获取所需的特定字节，也可以使用 x 标识符来跳过不需要的字节：

>>> struct.unpack('>16x2L6x', data)
(154, 141)

上面格式串的含义如下：

使用大端方案（>）

跳过 16 个字节（16x）

读取 8 字节内容——两个无符号长整数（2L）

跳过最后 6 个字节（6x）

7.2.3　其他二进制数据工具

一些第三方开源包提供了下面这些更加直观地定义和提取二进制数据的方法：

bitstring（https://code.google.com/p/python-bitstring/）

construct（http://construct.readthedocs.org/en/latest/）

hachoir（https://bitbucket.org/haypo/hachoir/wiki/Home）

binio（http://spika.net/py/binio/）

你可以在附录 D 查看下载和安装外部包的详细过程，这里不再赘述。接下来的几个例子需要提前安装 construct 包，只需执行下面这行代码即可：

$ pip install construct

下面的例子展示了如何使用 construct 从之前的 data 中提取 PNG 图片的尺寸：

>>> from construct import Struct, Magic, UBInt32, Const, String
>>> # 基于https://github.com/construct上的代码修改而来
>>> fmt = Struct('png',
...     Magic(b'\x89PNG\r\n\x1a\n'),
...     UBInt32('length'),
...     Const(String('type', 4), b'IHDR'),
...     UBInt32('width'),
...     UBInt32('height')
...     )
>>> data = b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR' + \
...     b'\x00\x00\x00\x9a\x00\x00\x00\x8d\x08\x02\x00\x00\x00\xc0'
>>> result = fmt.parse(data)
>>> print(result)
Container:
    length = 13
    type = b'IHDR'
    width = 154
    height = 141
>>> print(result.width, result.height)
154, 141

7.2.4　使用binascii()转换字节/字符串

标准 binascii 模块提供了在二进制数据和多种字符串表示（十六进制、六十四进制、uuencoded，等等）之间转换的函数。例如，下面的小例子将 8- 字节的 PNG 头打印为十六进制值的形式，而不是 Python 默认的打印 bytes 型变量的方式：混合使用 ASCII 和转义的 \x xx。

>>> import binascii
>>> valid_png_header = b'\x89PNG\r\n\x1a\n'
>>> print(binascii.hexlify(valid_png_header))
b'89504e470d0a1a0a'

反过来转换也可以：

>>> print(binascii.unhexlify(b'89504e470d0a1a0a'))
b'\x89PNG\r\n\x1a\n'

7.2.5　位运算符

Python 提供了和 C 语言中类似的比特级运算符。表 7-7 列出了这些位运算符并附上了整数 a（十进制 5，二进制 0b0101）和 b（十进制 1，二进制 0b0001）的运算示例。

表7-7：比特级整数运算符

运算符

描述

示例

十进制结果

二进制结果

&

与

a & b

1

0b0001

|

或

a | b

5

0b0101

^

异或

a ^ b

4

0b0100

~

翻转

~a

-6

取决于 int 类型的大小

<<

左位移

a << 1

10

0b1010

>>

右位移

a >> 1

2

0b0010

这些运算和第 3 章的集合运算有些类似。& 返回两个运算数中相同的比特。| 返回两个运算数中任意一者有效的比特。^ 返回仅在一个运算数中有效的比特。~ 将所有比特翻转。现代计算机都使用二进制补码（two's complement）进行运算，其中整数的最高位定义为符号位（0 为正，1 为负），因此翻转操作会改变运算数的符号。<< 和 >> 仅仅将比特向左或向右移动，左位移操作相当于将数字乘以 2，右位移操作相当于将数字除以 2。

7.3　练习

(1) 创建一个 Unicode 字符串 mystery 并将它的值设为 '\U0001f4a9'。打印 mystery，并查看 mystery 的 Unicode 名称。

(2) 使用 UTF-8 对 mystery 进行编码，存入字节型变量 pop_bytes，并将它打印出来。

(3) 使用 UTF-8 对 pop_bytes 进行解码，存入字符串型变量 pop_string，并将它打印出来，看看它与 mystery 是否一致？

(4) 使用旧式格式化方法生成下面的诗句，把 'roast beef'、'ham'、'head' 和 'clam' 依次插入字符串：

My kitty cat likes %s,
My kitty cat likes %s,
My kitty cat fell on his %s
And now thinks he's a %s.

(5) 使用新式格式化方法生成下面的套用信函，将下面的字符串存储为 letter（后面的练习中会用到）：

Dear {salutation} {name},

Thank you for your letter. We are sorry that our {product} {verbed} in your
{room}. Please note that it should never be used in a {room}, especially
near any {animals}.

Send us your receipt and {amount} for shipping and handling. We will send
you another {product} that, in our tests, is {percent}% less likely to
have {verbed}.

Thank you for your support.

Sincerely,
{spokesman}
{job_title}

(6) 创建一个字典 response 包含以下键：'salutaion'、'name'、'product'、'verbed'（动词过去式）、'room'、'animals'、'amount'、'percent'、'spokesman' 以及 'job_title'。设定这些键对应的值，并打印由 response 的值填充的 letter。

(7) 正则表达式在处理文本上非常方便，在这个练习中我们会对示例文本尝试做各种各样的操作。示例文本是一首诗，名为 Ode on the Mammoth Cheese，作者是 James McIntyre，写于 1866 年。出于对当时安大略湖手工制造的 7000 磅的巨型奶酪的敬意，它当时甚至在全球巡回展出。如果你不愿意自己一词一句敲出来，直接用搜索引擎搜一下粘贴到你的 Python 代码里即可。你也可以从 Project Gutenberg（http://www.gutenberg.org/ebooks/36068?msg=welcome_stranger）找到。我们将这个字符串命名为 mammoth。

We have seen thee, queen of cheese,
Lying quietly at your ease,
Gently fanned by evening breeze,
Thy fair form no flies dare seize.

All gaily dressed soon you'll go
To the great Provincial show,
To be admired by many a beau
In the city of Toronto.

Cows numerous as a swarm of bees,
Or as the leaves upon the trees,
It did require to make thee please,
And stand unrivalled, queen of cheese.

May you not receive a scar as
We have heard that Mr. Harris
Intends to send you off as far as
The great world's show at Paris.

Of the youth beware of these,
For some of them might rudely squeeze
And bite your cheek, then songs or glees
We could not sing, oh! queen of cheese.

We'rt thou suspended from balloon,
You'd cast a shade even at noon,
Folks would think it was the moon
About to fall and crush them soon.

(8) 引入 re 模块以便使用正则表达式相关函数。使用 re.findall() 打印出所有以 c 开头的单词。

(9) 找到所有以 c 开头的 4 个字母的单词。

(10) 找到所有以 r 结尾的单词。

(11) 找到所有包含且仅包含连续 3 个元音的单词。

(12) 使用 unhexlify() 将下面的十六进制串（出于排版原因将它们拆成两行字符串）转换为 bytes 型变量，命名为 gif：

'47494638396100010001800000000000ffffff21f9' +
'0401000000002c000000000100010000020144003b'

(13) gif 定义了一个 1 像素的透明 GIF 文件（最常见的图片格式之一）。合法的 GIF 文件开头由 GIF89a 组成，检测一下上面的 gif 是否为合法的 GIF 文件？

(14) GIF 文件的像素宽度是一个 16 比特的以小端方案存储的整数，偏移量为 6 字节，高度数据的大小与之相同，偏移量为 8。从 gif 中抽取这些信息并打印出来，看看它们是否与预期的一样都为 1 ？

第 8 章　数据的归宿

“在没有事实（数据）作为参考的情况下妄下结论是个可怕的错误。”

——阿瑟 · 柯南 · 道尔

一个运行中的程序会存取放在随机存取存储器（RAM）上的数据。RAM 读取速度快，但价格昂贵，需要持续供电，断电后保存在上面的数据会自动消失。磁盘速度比 RAM 慢，但容量大、费用低廉并且多次插拔电源线仍可保持数据。因此，计算机系统在数据存储设计中做出很大的努力来权衡磁盘和 RAM。程序员需要在非易失性介质（例如磁盘）上做持久化存储和检索数据。

本章会涉及不同类型的数据存储，它们基于不同的目的进行优化：普通文件、结构化文件和数据库。除了输入和输出，文件操作都会在 10.1 节讲到。

　本章也是第一次讲到非标准的 Python 模块，也就是除了标准库之外的 Python 代码。你可以通过 pip 命令轻松地安装这些第三方库，更多的使用细节详见附录 D。

8.1　文件输入/输出

数据持久化最简单的类型是普通文件，有时也叫平面文件（flat file）。它仅仅是在一个文件名下的字节流，把数据从一个文件读入内存，然后从内存写入文件。Python 很容易实现这些文件操作，它模仿熟悉的和流行的 Unix 系统的操作。

读写一个文件之前需要打开它：

fileobj = open(filename, mode)

下面是对该 open() 调用的简单解释：

fileobj 是 open() 返回的文件对象；

filename 是该文件的字符串名；

mode 是指明文件类型和操作的字符串。

mode 的第一个字母表明对其的操作。

r 表示读模式。

w 表示写模式。如果文件不存在则新创建，如果存在则重写新内容。

x 表示在文件不存在的情况下新创建并写文件。

a 表示如果文件存在，在文件末尾追加写内容。

mode 的第二个字母是文件类型：

t（或者省略）代表文本类型；

b 代表二进制文件。

打开文件之后就可以调用函数来读写数据，之后的例子会涉及。

最后需要关闭文件。

接下来在一个程序中用 Python 字符串创建一个文件，然后返回。

8.1.1　使用write()写文本文件

出于一些原因，我们没有太多的关于狭义相对论的五行打油诗（limerick1）。下面这首作为源数据：

1一种通俗幽默的短诗。——译者注

>>> poem = '''There was a young lady named Bright,
... Whose speed was far faster than light;
... She started one day
... In a relative way,
... And returned on the previous night.'''
>>> len(poem)
150

以下代码将整首诗写到文件 'relativity' 中：

>>> fout = open('relativity', 'wt')
>>> fout.write(poem)
150
>>> fout.close()

函数 write() 返回写入文件的字节数。和 print() 不一样，它没有增加空格或者换行符。同 样，你也可以在一个文本文件中使用 print()：

>>> fout = open('relativity', 'wt')
>>> print(poem, file=fout)
>>> fout.close()

这就产生了一个问题：到底是使用 write() 还是 print() ？ print() 默认会在每个参数后面添加空格，在每行结束处添加换行。 在之前的例子中，它在文件 relativity 中默认添加了一个换行。为了使 print() 与 write() 有同样的输出，传入下面两个参数。

sep 分隔符：默认是一个空格 ' '

end 结束字符：默认是一个换行符 '\n'

除非自定义参数，否则 print() 会使用默认参数。在这里，我们通过空字符串替换 print() 添加的所有多余输出：

>>> fout = open('relativity', 'wt')
>>> print(poem, file=fout, sep='', end='')
>>> fout.close()

如果源字符串非常大，可以将数据分块，直到所有字符被写入：

>>> fout = open('relativity', 'wt')
>>> size = len(poem)
>>> offset = 0
>>> chunk = 100
>>> while True:
...     if offset > size:
...          break
...     fout.write(poem[offset:offset+chunk])
...     offset += chunk
...
100
50
>>> fout.close()

第一次写入 100 个字符，然后写入剩下的 50 个字符。

如果 'relativity' 文件已经存在，使用模式 x 可以避免重写文件：

>>> fout = open('relativity', 'xt')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
FileExistsError: [Errno 17] File exists: 'relativity'

可以加入一个异常处理：

>>> try:
...     fout = open('relativity', 'xt')
...     fout.write('stomp stomp stomp')
... except FileExistsError:
...     print('relativity already exists!. That was a close one.')
...
relativity already exists!. That was a close one.

8.1.2　使用read()、readline()或者readlines()读文本文件

你可以按照下面的示例那样，使用不带参数的 read() 函数一次读入文件的所有内容。但在读入文件时要格外注意，1 GB 的文件会用到相同大小的内存。

>>> fin = open('relativity', 'rt' )
>>> poem = fin.read()
>>> fin.close()
>>> len(poem)
150

同样也可以设置最大的读入字符数限制 read() 函数一次返回的大小。下面一次读入 100 个字符，然后把每一块拼接成原来的字符串 poem：

>>> poem = ''
>>> fin = open('relativity', 'rt' )
>>> chunk = 100
>>> while True:
...     fragment = fin.read(chunk)
...     if not fragment:
...         break
...     poem += fragment
...
>>> fin.close()
>>> len(poem)
150

读到文件结尾之后，再次调用 read() 会返回空字符串（''），if not fragment 条件被判为 False。此时会跳出 while True 的循环。 当然，你也能使用 readline() 每次读入文件的一行。在下一个例子中，通过追加每一行拼接成原来的字符串 poem：

>>> poem = ''
>>> fin = open('relativity', 'rt' )
>>> while True:
...     line = fin.readline()
...     if not line:
...         break
...     poem += line
...
>>> fin.close()
>>> len(poem)
150

对于一个文本文件，即使空行也有 1 字符长度（换行字符 '\n'），自然就会返回 True。当文件读取结束后，readline()（类似 read()）同样会返回空字符串，也被 if not line: 判 为 False。

读取文本文件最简单的方式是使用一个迭代器（iterator），它会每次返回一行。这和之前的例子类似，但代码会更短：

>>> poem = ''
>>> fin = open('relativity', 'rt' )
>>> for line in fin:
...     poem += line
...
>>> fin.close()
>>> len(poem)
150

前面所有的示例最终都返回单个字符串 poem。函数 readlines() 调用时读入所有行，并返回单行字符串的列表：

>>> fin = open('relativity', 'rt' )
>>> lines = fin.readlines()
>>> fin.close()
>>> print(len(lines), 'lines read')
5 lines read
>>> for line in lines:
...     print(line, end='')
...
There was a young lady named Bright,
Whose speed was far faster than light;
She started one day
In a relative way,
And returned on the previous night.>>>

之前我们让 print() 去掉每行结束的自动换行，因为前面的四行都有换行标志，而最后一行没有，所以导致解释器的提示符 >>> 出现在最后一行的最右边。

8.1.3　使用write()写二进制文件

如果文件模式字符串中包含 'b'，那么文件会以二进制模式打开。这种情况下，读写的是字节而不是字符串。

我们手边没有二进制格式的诗，所以直接在 0~255 产生 256 字节的值：

>>> bdata = bytes(range(0, 256))
>>> len(bdata)
256

以二进制模式打开文件，并且一次写入所有的数据：

>>> fout = open('bfile', 'wb')
>>> fout.write(bdata)
256
>>> fout.close()

再次，write() 返回到写入的字节数。

就像文本一样，二进制数据也可以分块写入：

>>> fout = open('bfile', 'wb')
>>> size = len(bdata)
>>> offset = 0
>>> chunk = 100
>>> while True:
...     if offset > size:
...          break
...     fout.write(bdata[offset:offset+chunk])
...     offset += chunk
...
100
100
56
>>> fout.close()

8.1.4　使用read()读二进制文件

下面简单的例子只需要用 'rb' 打开文件即可：

>>> fin = open('bfile', 'rb')
>>> bdata = fin.read()
>>> len(bdata)
256
>>> fin.close()

8.1.5　使用with自动关闭文件

如果你忘记关闭已经打开的一个文件，在该文件对象不再被引用之后 Python 会关掉此文件。这也就意味着，如果你在一个函数中打开一个文件，之后没有关闭它，那么在函数结束时该文件会被自动关掉。然而你可能已在一直运行中的函数或者程序的主要部分打开了一个文件。应该关闭文件，以完成剩下的所有写操作。

Python 的上下文管理器（context manager）会清理一些资源，例如打开的文件。它的形式为 with expression as variable：

>>> with open('relativity', 'wt') as fout:
...     fout.write(poem)
...

完成上下文管理器的代码后，文件会被自动关闭。

8.1.6　使用seek()改变位置

无论是读或者写文件，Python 都会跟踪文件中的位置。函数 tell() 返回距离文件开始处的字节偏移量。函数 seek() 允许跳转到文件其他字节偏移量的位置。这意味着可以不用从头读取文件的每一个字节，直接跳到最后位置并只读一个字节也是可行的。

对于这个例子，使用之前写过的 256 字节的二进制文件 'bfile'：

>>> fin = open('bfile', 'rb')
>>> fin.tell()
0

使用 seek() 读取文件结束前最后一个字节：

>>> fin.seek(255)
255

一直读到文件结束：

>>> bdata = fin.read()
>>> len(bdata)
1
>>> bdata[0]
255

seek() 同样返回当前的偏移量。

用第二个参数调用函数 seek()：seek(offset,origin)。

如果 origin 等于 0（默认为 0），从开头偏移 offset 个字节；

如果 origin 等于 1，从当前位置处偏移 offset 个字节；

如果 origin 等于 2，距离最后结尾处偏移 offset 个字节。

这些值也在标准 os 模块中被定义：

>>> import os
>>> os.SEEK_SET
0
>>> os.SEEK_CUR
1
>>> os.SEEK_END
2

所以，我们可以用不同的方法读取最后一个字节：

>>> fin = open('bfile', 'rb')

文件结尾前的一个字节：

>>> fin.seek(-1, 2)
255
>>> fin.tell()
255

一直读到文件结尾：

>>> bdata = fin.read()
>>> len(bdata)
1
>>> bdata[0]
255

　在调用 seek() 函数时不需要额外调用 tell()。前面的例子只是想说明两个函数都可以返回同样的偏移量。

下面是从文件的当前位置寻找的例子：

>>> fin = open('bfile', 'rb')

接下来的例子返回最后两个字节：

>>> fin.seek(254, 0)
254
>>> fin.tell()
254

在此基础上前进一个字节：

>>> fin.seek(1, 1)
255
>>> fin.tell()
255

最后一直读到文件结尾：

>>> bdata = fin.read()
>>> len(bdata)
1
>>> bdata[0]
255

这些函数对于二进制文件都是极其重要的。当文件是 ASCII 编码（每个字符一个字节）时，也可以使用它们，但是计算偏移量会是一件麻烦事。其实，这些都取决于文本的编码格式，最流行的编码格式（例如 UTF-8）每个字符的字节数都不尽相同。

8.2　结构化的文本文件

对于简单的文本文件，唯一的结构层次是间隔的行。然而有时候需要更加结构化的文本，用于后续使用的程序保存数据或者向另外一个程序传送数据。

结构化的文本有很多格式，区别它们的方法如下所示。

分隔符，比如 tab（'\t'）、逗号（','）或者竖线（'|'）。逗号分隔值（CSV）就是这样的例子。

'<' 和 '>' 标签，例如 XML 和 HTML。

标点符号，例如 JavaScript Object Notation（JSON2）。

缩进，例如 YAML（即 YAML Ain't Markup Language 的缩写），要了解更多可以去搜索。

混合的，例如各种配置文件。

2JSON 是一种轻量级的数据交换格式，它是基于 JavaScript 的一个子集。——译者注

每一种结构化文件格式都能够被至少一种 Python 模块读写。

8.2.1　CSV

带分隔符的文件一般用作数据交换格式或者数据库。你可以人工读入 CSV 文件，每一次读取一行，在逗号分隔符处将每行分开，并添加结果到某些数据结构中，例如列表或者字典。但是，最好使用标准的 csv 模块，因为这样切分会得到更加复杂的信息。

除了逗号，还有其他可代替的分隔符：'|' 和 '\t' 很常见。

有些数据会有转义字符序列，如果分隔符出现在一块区域内，则整块都要加上引号或者在它之前加上转义字符。

文件可能有不同的换行符，Unix 系统的文件使用 '\n'，Microsoft 使用 '\r\n'，Apple 之前使用 '\r' 而现在使用 '\n'。

在第一行可以加上列名。

首先读和写一个列表的行，每一行包含很多列：

>>> import csv
>>> villains = [
...     ['Doctor', 'No'],
...     ['Rosa', 'Klebb'],
...     ['Mister', 'Big'],
...     ['Auric', 'Goldfinger'],
...     ['Ernst', 'Blofeld'],
...     ]
>>> with open('villains', 'wt') as fout:  # 一个上下文管理器
...     csvout = csv.writer(fout)
...     csvout.writerows(villains)

于是创建了包含以下几行的文件 villains：

Doctor,No
Rosa,Klebb
Mister,Big
Auric,Goldfinger
Ernst,Blofeld

现在，我们来重新读这个文件：

>>> import csv
>>> with open('villains', 'rt') as fin:  # 一个上下文管理器
...     cin = csv.reader(fin)
...     villains = [row for row in cin]  # 使用列表推导式
...
>>> print(villains)
[['Doctor', 'No'], ['Rosa', 'Klebb'], ['Mister', 'Big'],
['Auric', 'Goldfinger'], ['Ernst', 'Blofeld']]

停下来想想列表推导式（随时打开 4.6 节，温习一下它的语法）。我们利用函数 reader() 创建的结构，它在通过 for 循环提取到的 cin 对象中构建每一行。

使用 reader() 和 writer() 的默认操作。每一列用逗号分开；每一行用换行符分开。

数据可以是以字典为元素的列表（a list of dictionary），不仅仅是以列表为元素的列表（a list of list）。这次使用新函数 DictReader() 读取文件 villains，并且指定每一列的名字：

>>> import csv
>>> with open('villains', 'rt') as fin:
...     cin = csv.DictReader(fin, fieldnames=['first', 'last'])
...     villains = [row for row in cin]
...
>>> print(villains)
[{'last': 'No', 'first': 'Doctor'},
{'last': 'Klebb', 'first': 'Rosa'},
{'last': 'Big', 'first': 'Mister'},
{'last': 'Goldfinger', 'first': 'Auric'},
{'last': 'Blofeld', 'first': 'Ernst'}]

下面使用新函数 DictWriter() 重写 CSV 文件，同时调用 writeheader() 向 CSV 文件中第一行写入每一列的名字：

import csv
villains = [
    {'first': 'Doctor', 'last': 'No'},
    {'first': 'Rosa', 'last': 'Klebb'},
    {'first': 'Mister', 'last': 'Big'},
    {'first': 'Auric', 'last': 'Goldfinger'},
    {'first': 'Ernst', 'last': 'Blofeld'},
    ]
with open('villains', 'wt') as fout:
    cout = csv.DictWriter(fout, ['first', 'last'])
    cout.writeheader()
    cout.writerows(villains)

于是创建了具有标题行的新文件 villains：

first,last
Doctor,No
Rosa,Klebb
Mister,Big
Auric,Goldfinger
Ernst,Blofeld

回过来再读取写入的文件，忽略函数 DictReader() 调用的参数 fieldnames，把第一行的值（first,last）作为列标签，和字典的键做匹配：

>>> import csv
>>> with open('villains', 'rt') as fin:
...     cin = csv.DictReader(fin)
...     villains = [row for row in cin]
...
>>> print(villains)
[{'last': 'No', 'first': 'Doctor'},
{'last': 'Klebb', 'first': 'Rosa'},
{'last': 'Big', 'first': 'Mister'},
{'last': 'Goldfinger', 'first': 'Auric'},
{'last': 'Blofeld', 'first': 'Ernst'}]

8.2.2　XML

带分隔符的文件仅有两维的数据：行和列。如果你想在程序之间交换数据结构，需要一种方法把层次结构、序列、集合和其他的结构编码成文本。

XML 是最突出的处理这种转换的标记（markup）格式，它使用标签（tag）分隔数据，如下面的示例文件 menu.xml 所示：

<?xml version="1.0"?>
<menu>
  <breakfast hours="7-11">
    <item price="$6.00">breakfast burritos</item>
    <item price="$4.00">pancakes</item>
  </breakfast>
  <lunch hours="11-3">
    <item price="$5.00">hamburger</item>
  </lunch>
  <dinner hours="3-10">
    <item price="8.00">spaghetti</item>
  </dinner>
</menu>

以下是 XML 的一些重要特性：

标签以一个 < 字符开头，例如示例中的标签 menu、breakfast、lunch、dinner 和 item；

忽略空格；

通常一个开始标签（例如 <menu>）跟一段其他的内容，然后是最后相匹配的结束标签，例如 </menu>；

标签之间是可以存在多级嵌套的，在本例中，标签 item 是标签 breakfast、lunch 和 dinner 的子标签，反过来，它们也是标签 menu 的子标签；

可选属性（attribute）可以出现在开始标签里，例如 price 是 item 的一个属性；

标签中可以包含值（value），本例中每个 item 都会有一个值，比如第二个 breakfast item 的 pancakes；

如果一个命名为 thing 的标签没有内容或者子标签，它可以用一个在右尖括号的前面添加斜杠的简单标签所表示，例如 <thing/> 代替开始和结束都存在的标签 <thing> 和 </thing>；

存放数据的位置可以是任意的——属性、值或者子标签。例如也可以把最后一个 item 标签写作 <item price ="$8.00" food ="spaghetti"/>。

XML 通常用于数据传送和消息，它存在一些子格式，如 RSS 和 Atom。工业界有许多定制化的 XML 格式，例如金融领域（http://www.service-architecture.com/articles/xml/finance_xml.html）。

XML 的灵活性导致出现了很多方法和性能各异的 Python 库。

在 Python 中解析 XML 最简单的方法是使用 ElementTree，下面的代码用来解析 menu.xml 文件以及输出一些标签和属性：

>>> import xml.etree.ElementTree as et
>>> tree = et.ElementTree(file='menu.xml')
>>> root = tree.getroot()
>>> root.tag
'menu'
>>> for child in root:
...     print('tag:', child.tag, 'attributes:', child.attrib)
...     for grandchild in child:
...         print('\ttag:', grandchild.tag, 'attributes:', grandchild.attrib)
...
tag: breakfast attributes: {'hours': '7-11'}
    tag: item attributes: {'price': '$6.00'}
    tag: item attributes: {'price': '$4.00'}
tag: lunch attributes: {'hours': '11-3'}
    tag: item attributes: {'price': '$5.00'}
tag: dinner attributes: {'hours': '3-10'}
    tag: item attributes: {'price': '8.00'}
>>> len(root)     # 菜单选择的数目
3
>>> len(root[0])  # 早餐项的数目
2

对于嵌套列表中的每一个元素，tag 是标签字符串，attrib 是它属性的一个字典。ElementTree 有许多查找 XML 导出数据、修改数据乃至写入 XML 文件的方法，它的文档（https://docs.python.org/3.3/library/xml.etree.elementtree.html）中有详细的介绍。

其他标准的 Python XML 库如下。

xml.dom

JavaScript 开发者比较熟悉的文档对象模型（DOM）将 Web 文档表示成层次结构，它会把整个 XML 文件载入到内存中，同样允许你获取所有的内容。

xml.sax

简单的 XML API 或者 SAX 都是通过在线解析 XML，不需要一次载入所有内容到内存中，因此对于处理巨大的 XML 文件流是一个很好的选择。

8.2.3　HTML

在 Web 网络中，海量的数据以超文本标记语言（HTML）这一基本的文档格式存储。然而许多文档不遵循 HTML 的规则，导致很难进行解析。况且更多的 HTML 是用来格式化输出显示结果而不是用于交换数据。本章的主要内容是描述定义明确的数据格式，关于 HTML 的更多讨论放在第 9 章。

8.2.4　JSON

JavaScript Object Notation（JSON，http://www.json.org）是源于 JavaScript 的当今很流行的数据交换格式，它是 JavaScript 语言的一个子集，也是 Python 合法可支持的语法。对于 Python 的兼容性使得它成为程序间数据交换的较好选择。同样，在第 9 章的 Web 开发中会看到很多 JSON 的示例。

不同于众多的 XML 模块，Python 只有一个主要的 JSON 模块 json（名字容易记忆）。下面的程序将数据编码成 JSON 字符串，然后再把 JSON 字符串解码成数据。用之前 XML 例子的数据构建 JSON 的数据结构：

>>> menu = \
... {
... "breakfast": {
...         "hours": "7-11",
...         "items": {
...                 "breakfast burritos": "$6.00",
...                 "pancakes": "$4.00"
...                 }
...         },
... "lunch" : {
...         "hours": "11-3",
...         "items": {
...                 "hamburger": "$5.00"
...                 }
...         },
... "dinner": {
...         "hours": "3-10",
...         "items": {
...                 "spaghetti": "$8.00"
...                 }
...         }
... }
.

接下来使用 dumps() 将 menu 编码成 JSON 字符串（menu_json）：

>>> import json
>>> menu_json = json.dumps(menu)
>>> menu_json
'{"dinner": {"items": {"spaghetti": "$8.00"}, "hours": "3-10"},
"lunch": {"items": {"hamburger": "$5.00"}, "hours": "11-3"},
"breakfast": {"items": {"breakfast burritos": "$6.00", "pancakes":
"$4.00"}, "hours": "7-11"}}'

现在反过来使用 loads() 把 JSON 字符串 menu_json 解析成 Python 的数据结构（menu2）：

>>> menu2 = json.loads(menu_json)
>>> menu2
{'breakfast': {'items': {'breakfast burritos': '$6.00', 'pancakes':
'$4.00'}, 'hours': '7-11'}, 'lunch': {'items': {'hamburger': '$5.00'},
'hours': '11-3'}, 'dinner': {'items': {'spaghetti': '$8.00'}, 'hours': '3-10'}}

menu 和 menu2 是具有相同键值的字典，和标准的字典用法一样，得到的键的顺序是不尽相同的。

你可能会在编码或者解析 JSON 对象时得到异常，包括时间对象 datetime（在 10.4 节详细介绍）：

>>> import datetime
>>> now = datetime.datetime.utcnow()
>>> now
datetime.datetime(2013, 2, 22, 3, 49, 27, 483336)
>>> json.dumps(now)
Traceback (most recent call last):
# …… (删除栈跟踪以保存树)
TypeError: datetime.datetime(2013, 2, 22, 3, 49, 27, 483336) is not JSON serializable
>>>

上述错误发生是因为标准 JSON 没有定义日期或者时间类型，需要自定义处理方式。你可以把 datetime 转换成 JSON 能够理解的类型，比如字符串或者 epoch 值（第 10 章讲解）：

>>> now_str = str(now)
>>> json.dumps(now_str)
'"2013-02-22 03:49:27.483336"'
>>> from time import mktime
>>> now_epoch = int(mktime(now.timetuple()))
>>> json.dumps(now_epoch)
'1361526567'

如果 datetime 值出现在正常转换后的数据中间，那么做上面的特殊转化是困难的。你可以通过继承修改 JSON 的编码方式（6.3 节中讲解），Python 中的 JSON 文档（https://docs.python.org/3.3/library/json.html）给出了一个复杂数字的例子，同样使 JSON 出现问题。下面为 datetime 修改编码方式：

>>> class DTEncoder(json.JSONEncoder):
...     def default(self, obj):
...         # isinstance()检查obj的类型
...         if isinstance(obj, datetime.datetime):
...             return int(mktime(obj.timetuple()))
...         # 否则是普通解码器知道的东西：
...         return json.JSONEncoder.default(self, obj)
...
>>> json.dumps(now, cls=DTEncoder)
'1361526567'

新类 DTEncoder 是 JSONEncoder 的一个子类。我们需要重载它的 default() 方法来增加处理 datetime 的代码。继承确保了剩下的功能与父类的一致性。

函数 isinstance() 检查 obj 是否是类 datetime.datetime 的对象，因为在 Python 中一切都是对象，isinstance() 总是适用的：

>>> type(now)
<class 'datetime.datetime'>
>>> isinstance(now, datetime.datetime)
True
>>> type(234)
<class 'int'>
>>> isinstance(234, int)
True
>>> type('hey')
<class 'str'>
>>> isinstance('hey', str)
True

　对于 JSON 和其他结构化的文本格式，在不需要提前知道任何东西的情况下可以从一个文件中解析数据结构。然后使用 isinstance() 和相关类型的方法遍历数据。例如，如果其中的一项是字典结构，可以通过 keys()、values() 和 items() 抽取内容。

8.2.5　YAML

和 JSON 类似，YAML（http://www.yaml.org）同样有键和值，但主要用来处理日期和时间这样的数据类型。标准的 Python 库没有处理 YAML 的模块，因此需要安装第三方库 yaml（http://pyyaml.org/wiki/PyYAML）操作数据。load() 将 YAML 字符串转换为 Python 数据结构，而 dump() 正好相反。

下面的 YAML 文件 mcintyre.yaml 包含加拿大诗人 James McIntyre 的两首诗的信息：

name:
  first: James
  last: McIntyre
dates:
  birth: 1828-05-25
  death: 1906-03-31
details:
  bearded: true
  themes: [cheese, Canada]
books:
  url: http://www.gutenberg.org/files/36068/36068-h/36068-h.htm
poems:
  - title: 'Motto'
    text: |
      Politeness, perseverance and pluck,
      To their possessor will bring good luck.
  - title: 'Canadian Charms'
    text: |
      Here industry is not in vain,
      For we have bounteous crops of grain,
      And you behold on every field
      Of grass and roots abundant yield,
      But after all the greatest charm
      Is the snug home upon the farm,
      And stone walls now keep cattle warm.

类似于 true、false、on 和 off 的值可以转换为 Python 的布尔值。整数和字符串转换为 Python 等价的。其他语法创建列表和字典：

>>> import yaml
>>> with open('mcintyre.yaml', 'rt') as fin:
>>>     text = fin.read()
>>> data = yaml.load(text)
>>> data['details']
{'themes': ['cheese', 'Canada'], 'bearded': True}
>>> len(data['poems'])
2

创建的匹配这个 YAML 文件的数据结构超过了一层嵌套。如果你想得到第二首诗歌的题目，使用 dict/list/dict 的引用：

>>> data['poems'][1]['title']
'Canadian Charms'

　PyYAML 可以从字符串中载入 Python 对象，但这样做是不安全的。如果导入你不信任的 YAML，使用 safe_load() 代替 load()。最好还是使用 safe_load()，通过阅读 war is peace（http://nedbatchelder.com/blog/201302/war_is_peace.html）进一步了解载入 YAML 在 Ruby on Rails 平台上如何折中。

8.2.6　安全提示

你可以使用本章中介绍的所有格式保存数据对象到文件中，或者在文件中读取它们。在这个过程中也可能会产生安全性问题。

例如，下面引自 10 亿维基百科页面的 XML 片段定义了 10 个嵌套实体，每一项扩展 10 倍的子项，总共有 10 亿的扩展项：

<?xml version="1.0"?>
<!DOCTYPE lolz [
 <!ENTITY lol "lol">
 <!ENTITY lol1 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
 <!ENTITY lol2 "&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;">
 <!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">
 <!ENTITY lol4 "&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;">
 <!ENTITY lol5 "&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;">
 <!ENTITY lol6 "&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;">
 <!ENTITY lol7 "&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;">
 <!ENTITY lol8 "&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;">
 <!ENTITY lol9 "&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;">
]>
<lolz>&lol9;</lolz>

糟糕的是，前面提到的 XML 库无法容纳 10 亿多的项。Defused XML（https://bitbucket.org/tiran/defusedxml）列出了这种攻击和 Python 库的其他缺点，并且指出了如何修改设置避免这些问题。或者使用 defusedxml 库作为安全的保护：

>>> # 不安全：
>>> from xml.etree.ElementTree import parse
>>> et = parse(xmlfile)
>>> # 受保护：
>>> from defusedxml.ElementTree import parse
>>> et = parse(xmlfile)

8.2.7　配置文件

许多程序提供多种选项和设置。动态的设置可以通过传入程序参数，但是持久的参数需要保存下来。因此，尽管你急着想快速定义自己的配置文件格式，但是最好不要这样做。你定义的格式可能既粗糙也没有节省时间。你需要同时维护写入配置文件的程序以及读取配置文件的程序（有时被称为解析程序）。其实，在程序中配置文件可以有许多好的选择，包括之前几节中提到的格式。

我们使用标准 configparser 模块处理 Windows 风格的初始化 .ini 文件。这些文件都包含 key = value 的定义，以下是一个简单的配置文件 settings.cfg 例子：

[english]
greeting = Hello

[french]
greeting = Bonjour

[files]
home = /usr/local
# 简单的插入：
bin = %(home)s/bin

下面的代码是把配置文件读入到 Python 数据结构：

>>> import configparser
>>> cfg = configparser.ConfigParser()
>>> cfg.read('settings.cfg')
['settings.cfg']
>>> cfg
<configparser.ConfigParser object at 0x1006be4d0>
>>> cfg['french']
<Section: french>
>>> cfg['french']['greeting']
'Bonjour'
>>> cfg['files']['bin']
'/usr/local/bin'

其他操作包括自定义修改也是可以实现的，请参阅文档 configparser（https://docs.python.org/3.3/library/configparser.html）。如果你需要两层以上的嵌套结构，使用 YAML 或者 JSON。

8.2.8　其他交换格式

这些二进制数据交换格式通常比 XML 或者 JSON 更加快速和复杂：

MsgPack（http://msgpack.org）

Protocol Buffers（https://code.google.com/p/protobuf/）

Avro（http://avro.apache.org/docs/current/）

Thrift（http://thrift.apache.org/）

因为它们都是二进制文件，所以人类是无法使用文本编辑器轻易编辑的。

8.2.9　使用pickle序列化

存储数据结构到一个文件中也称为序列化（serializing）。像 JSON 这样的格式需要定制的序列化数据的转换器。Python 提供了 pickle 模块以特殊的二进制格式保存和恢复数据对象。

还记得 JSON 解析 datetime 对象时出现问题吗？但对于 pickle 就不存在问题：

>>> import pickle
>>> import datetime
>>> now1 = datetime.datetime.utcnow()
>>> pickled = pickle.dumps(now1)
>>> now2 = pickle.loads(pickled)
>>> now1
datetime.datetime(2014, 6, 22, 23, 24, 19, 195722)
>>> now2
datetime.datetime(2014, 6, 22, 23, 24, 19, 195722)

pickle 同样也适用于自己定义的类和对象。现在，我们定义一个简单的类 Tiny，当其对象强制转换为字符串时会返回 'tiny'：

>>> import pickle
>>> class Tiny():
...     def __str__(self):
...        return 'tiny'
...
>>> obj1 = Tiny()
>>> obj1
<__main__.Tiny object at 0x10076ed10>
>>> str(obj1)
'tiny'
>>> pickled = pickle.dumps(obj1)
>>> pickled
b'\x80\x03c__main__\nTiny\nq\x00)\x81q\x01.'
>>> obj2 = pickle.loads(pickled)
>>> obj2
<__main__.Tiny object at 0x10076e550>
>>> str(obj2)
'tiny'

pickled 是从对象 obj1 转换来的序列化二进制字符串。然后再把字符串还原成对象 obj1 的副本 obj2。使用函数 dump() 序列化数据到文件，而函数 load() 用作反序列化。

　因为 pickle 会创建 Python 对象，前面提到的安全问题也同样会发生，不要对你不信任的文件做反序列化。

8.3　结构化二进制文件

有些文件格式是为了存储特殊的数据结构，它们既不是关系型数据库也不是 NoSQL 数据库。下面会介绍其中的几种。

8.3.1　电子数据表

电子数据表，尤其是 Microsoft Excel，是广泛使用的二进制数据格式。如果你把电子数据表保存到一个 CSV 文件中，就可以利用之前提到的标准 csv 模块读取它。如果你有一个 xls 文件，也可以使用第三方库 xlrd（http://pypi.python.org/pypi/xlrd）读写文件。

8.3.2　层次数据格式

层次数据格式（HDF5）是一种用于多维数据或者层次数值数据的二进制数据格式。它主要用在科学计算领域，快速读取海量数据集（GB 或者 TB）是常见的需求。即使某些情况下 HDF5 能很好地代替数据库，但它在商业应用上也是默默无闻的。它能适用于 WORM（Write Once/Read Many；一次写入，多次读取）应用，不用担心写操作冲突的数据保护。下面是两个可能有用的模块：

h5py 是功能全面的较低级的接口，参考文档（http://www.h5py.org/）和代码（https://github.com/h5py/h5py）；

PyTables 是具有数据库特征的较为高级的接口，参考文档（http://www.pytables.org/）和代码（http://pytables.github.com/）。

这两个模块都会在附录 C 中的 Python 科学应用中讨论，在这里提到它以防你有存储和检索海量大数据的需求以及愿意考虑不同于普通数据库的解决方案。一个很好的例子是 Million Song dataset（http://labrosa.ee.columbia.edu/millionsong/），就是通过 HDF5 格式下载歌曲数据。

8.4　关系型数据库

关系型数据库虽然只有 40 多年的历史，却无处不在。你一定曾经和它打过交道，使用时你会体会到它提供的如下功能：

多用户同时访问数据；

用户使用数据的保护；

高效地存储和检索数据；

数据被模式定义以及被约束限制；

Joins 通过连接发现不同数据之间的关系；

声明式（非命令式）查询语言，SQL（Structured Query Language）。

之所以被称为关系型（relational）是因为数据库展现了表单（table）形式的不同类型数据之间的关系。例如之前菜单的例子中，每一项和它的价格是有对应关系的。

表单是一个具有行和列的二元组，和电子数据表类似。要创建一个表单，需要给它命名，明确次序、每一项的名称以及每一列的类型。每一行都会存在相同的列，即使允许缺失项（也称为 null）。在菜单例子中，你创建的表单中应该把每一个待售的食物作为一行；每一行都存在相同的列，包括它的价格。

某一列或者某几列通常作为表单的主键，在表单中主键的值是独一无二的，防止重复增添数据项。这些键在查询时被快速索引，类似于图书的索引，方便快速地找到指定行。

每一个表单都附属于某数据库，类似于一个文件都存在于某目录下。两层的层次结构便于更好地组织和管理。

　数据库一词有多种用法，用于指代服务器、表单容器以及存储的数据。如果你同时指代它们，可以称其为数据库服务器（database server）、数据库（database）和数据（data）。

如果你想通过非主键的列的值查找数据，可以定义一个二级索引，否则数据库服务器需要扫描整个表单，暴力搜索每一行找到匹配列的值。

表单之间可以通过外键建立关系，列的值受这些键的约束。

8.4.1　SQL

SQL 既不是一个 API 也不是一种协议，而是一种声明式语言，只需要告诉它做什么即可。它是关系型数据库的通用语言。SQL 查询是客户端发送给数据库服务器的文本字符串，指明需要执行的具体操作。

SQL 语言存在很多标准定义格式，但是所有的数据库制造商都会增加它们自己的扩展，导致产生许多 SQL 方言。如果你把数据存储在关系型数据库中，SQL 会带来一定的可移植性，但是方言和操作差异仍然会导致难以将数据移植到另一种类型的数据库中。

SQL 语句有两种主要的类型：

DDL（数据定义语言）

处理用户、数据库以及表单的创建、删除、约束和权限等。

DML（数据操作语言）

处理数据插入、选择、更新和删除。

表 8-1 列出了基本的 SQL DDL 命令。

表8-1：基本的SQL DDL命令

操作

SQL模式

SQL示例

创建数据库

CREATE DATABASE dbname

CREATE DATABASE d

选择当前数据库

USE dbname

USE d

删除数据库以及表单

DROP DATABASE dbname

DROP DATABASE d

创建表单

CREATE TABLE tbname (coldefs)

CREATE TABLE t(id INT, count INT)

删除表单

DROP TABLE tbname

DROP TABLE t

删除表单中所有的行

TRUNCATE TABLE tbname

TRUNCATE TABLE t

　为什么语句中命令都是大写字母？ SQL 是不区分大小写的，但是一般为了区分命令和名称，在代码示例中还是用大写字母。

SQL 关系型数据库的主要 DML 操作可以缩略为 CRUD。

Create：使用 INSERT 语句创建

Read：使用 SELECT 语句选择

Update：使用 UPDATE 语句更新

Delete：使用 DELETE 语句删除

表 8-2 列出了一些 SQL DML 命令。

表8-2：基本的SQL DML命令

操作

SQL模式

SQL示例

增加行

INSERT INTO tbname VALUES(...)

INSERT INTO t VALUES(7,40)

选择全部行和全部列

SELECT * FROM tbname

SELECT * FROM t

选择全部行和部分列

SELECT cols FROM tbname

SELECT id,count from t

选择部分行部分列

SELECT cols FROM tbname WHERE condition

SELECT id,count from t WHERE count > 5 AND id = 9

修改一列的部分行

UPDATE tbname SET col = value WHERE condition

UPDATE t SET count=3 WHERE id=5

删除部分行

DELETE FROM tbname WHERE condition

DELETE FROM t WHERE count <= 10 OR id = 16

8.4.2　DB-API

应用程序编程接口（API）是访问某些服务的函数集合。DB-API（http://legacy.python.org/dev/peps/pep-0249/）是 Python 中访问关系型数据库的标准 API。使用它可以编写简单的程序来处理多种类型的关系型数据库，不需要为每种数据库编写独立的程序，类似于 Java 的 JDBC 或者 Perl 的 dbi。

它的主要函数如下所示。

connect()

连接数据库，包含参数用户名、密码、服务器地址，等等。

cursor()

创建一个 cursor 对象来管理查询。

execute() 和 executemany()

对数据库执行一个或多个 SQL 命令。

fetchone()、fetchmany() 和 fetchall()

得到 execute 之后的结果。

下一节的 Python 数据库模块遵循 DB-API，但会有扩展和细节上的差别。

8.4.3　SQLite

SQLite（http://www.sqlite.org）是一种轻量级的、优秀的开源关系型数据库。它是用 Python 的标准库实现，并且存储数据库在普通文件中。这些文件在不同机器和操作系统之间是可移植的，使得 SQLite 成为简易关系型数据库应用的可移植的解决方案。它不像功能全面的 MySQL 或者 PostgreSQL，SQLite 仅仅支持原生 SQL 以及多用户并发操作。浏览器、智能手机和其他应用会把 SQLite 作为嵌入数据库。

首先使用 connect() 函数连接本地的 SQLite 数据库文件，这个文件和目录型数据库（管理其他的表单）是等价的。字符串 ':memory:' 仅用于在内存中创建数据库，有助于方便快速地测试，但是程序结束或者计算机关闭时所有数据都会丢失。

下一个例子会创建一个数据库 enterprise.db 和表单 zoo 用以管理路边繁华的宠物动物园业务。表单的列如下所示。

critter

可变长度的字符串，作为主键。

count

某动物的总数的整数值。

damages

人和动物的互动中损失的美元数目。

>>> import sqlite3
>>> conn = sqlite3.connect('enterprise.db')
>>> curs = conn.cursor()
>>> curs.execute('''CREATE TABLE zoo
    (critter VARCHAR(20) PRIMARY KEY,
     count INT,
     damages FLOAT)''')
<sqlite3.Cursor object at 0x1006a22d0>

Python 只有在创建长字符串时才会用到三引号（'''），例如 SQL 查询。

现在往动物园中新增一些动物：

>>> curs.execute('INSERT INTO zoo VALUES("duck", 5, 0.0)')
<sqlite3.Cursor object at 0x1006a22d0>
>>> curs.execute('INSERT INTO zoo VALUES("bear", 2, 1000.0)')
<sqlite3.Cursor object at 0x1006a22d0>

使用 placeholder 是一种更安全的、插入数据的方法：

>>> ins = 'INSERT INTO zoo (critter, count, damages) VALUES(?, ?, ?)'
>>> curs.execute(ins, ('weasel', 1, 2000.0))
<sqlite3.Cursor object at 0x1006a22d0>

在 SQL 中使用三个问号表示要插入三个值，并把它们作为一个列表传入函数 execute()。这些占位符用来处理一些冗余的细节，例如引用（quoting）。它们会防止 SQL 注入：一种常见的 Web 外部攻击方式，向系统插入恶意的 SQL 命令。

现在使用 SQL 获取所有动物：

>>> curs.execute('SELECT * FROM zoo')
<sqlite3.Cursor object at 0x1006a22d0>
>>> rows = curs.fetchall()
>>> print(rows)
[('duck', 5, 0.0), ('bear', 2, 1000.0), ('weasel', 1, 2000.0)]

按照数目（count）排序，重新获得它们：

>>> curs.execute('SELECT * from zoo ORDER BY count')
<sqlite3.Cursor object at 0x1006a22d0>
>>> curs.fetchall()
[('weasel', 1, 2000.0), ('bear', 2, 1000.0), ('duck', 5, 0.0)]

又需要按照降序得到它们：

>>> curs.execute('SELECT * from zoo ORDER BY count DESC')
<sqlite3.Cursor object at 0x1006a22d0>
>>> curs.fetchall()
[('duck', 5, 0.0), ('bear', 2, 1000.0), ('weasel', 1, 2000.0)]

哪种类型的动物花费最多呢？

>>> curs.execute('''SELECT * FROM zoo WHERE
...     damages = (SELECT MAX(damages) FROM zoo)''')
<sqlite3.Cursor object at 0x1006a22d0>
>>> curs.fetchall()
[('weasel', 1, 2000.0)]

你可能会认为是 bears（花费最多）。实际上，最好还是查看一下实际数据。

在结束本节之前，有一点需要明确，如果我们已经打开了一个连接（connection）或者游标（cursor），不需要时应该关掉它们：

>>> curs.close()
>>> conn.close()

8.4.4　MySQL

MySQL（http://www.mysql.com）是一款非常流行的开源关系型数据库。不同于 SQLite，它是真正的数据库服务器，因此客户端可以通过网络从不同的设备连接它。

MysqlDB（http://sourceforge.net/projects/mysql-python）是最常用的 MySQL 驱动程序，但至今没有支持 Python 3。表 8-3 列出了 Python 连接 MySQL 的几个驱动程序。

表8-3：MySQL的驱动程序

名称

链接

Pypi包

导入

注意

MySQL Connector

http://dev.mysql.com/doc/connector-python/en/index.html

mysql-connector-python

mysql.connector

PYMySQL

https://github.com/petehunt/PyMySQL/

pymysql

pymysql

oursql

http://pythonhosted.org/oursql/

oursql

oursql

需要 SQL 客户端的 C 依赖库

8.4.5　PostgreSQL

PostgreSQL（http://www.postgresql.org/）是一款功能全面的开源关系型数据库，在很多方面超过 MySQL。表 8-4 列出了 Python 连接 PostgreSQL 的几个驱动程序。

表8-4：PostgreSQL的驱动程序

名称

链接

Pypi包

导入

注意

psycopg2

http://initd.org/psycopg/

psycopg2

psycopg2

需要来自 PostgreSQL 客户端工具的 pg_config

py-postgresql

http://python.projects.pgfoundry.org/

py-postgresql

postgresql

最流行的驱动程序是 psycopg2，但是它的安装依赖 PostgreSQL 客户端的相关库。

8.4.6　SQLAlchemy

对于所有的关系型数据库而言，SQL 是不完全相同的，并且 DB-API 仅仅实现共有的部分。每一种数据库实现的是包含自己特征和哲学的方言。许多库函数用于消除它们之间的差异，最著名的跨数据库的 Python 库是 SQLAlchemy（http://www.sqlalchemy.org）。

它不在 Python 的标准库，但被广泛认可，使用者众多。在你的系统（Linux）中使用下面这条命令安装它：

$ pip install sqlalchemy

你可以在以下层级上使用 SQLAlchemy：

底层负责处理数据库连接池、执行 SQL 命令以及返回结果，这和 DB-API 相似；

再往上是 SQL 表达式语言，更像 Python 的 SQL 生成器；

较高级的是对象关系模型（ORM），使用 SQL 表达式语言，将应用程序代码和关系型数据结构结合起来。

随着内容的深入，上面提到的术语会变得熟悉。SQLAlchemy 实现在前面几节提到的数据库驱动程序的基础上。因此不需要导入驱动程序，初始化的连接字符串会作出分配，例如：

dialect + driver :// user : password @ host : port / dbname

字符串中的值代表如下含义。

dialect

数据库类型。

driver

使用该数据库的特定驱动程序。

user 和 password

数据库认证字符串。

host 和 port

数据库服务器的位置（只有特定情况下会使用端口号 :port）。

dbname

初始连接到服务器中的数据库。

表 8-5 列出了常见方言和对应的驱动程序。

表8-5：SQLAlchemy连接

方言

驱动程序

sqlite

pysqlite（可以忽略）

mysql

mysqlconnector

mysql

pymysql

mysql

oursql

postgresql

psycopg2

postgresql

pypostgresql

1. 引擎层

首先，我们试用一下 SQLAlchemy 的底层，它可以实现多于基本 DB-API 的功能。

以内置于 Python 的 SQLite 为例，连接字符串忽略 host、port、user 和 password。dbname 表示存储 SQLite 数据库的文件，如果省去 dbname，SQLite 会在内存创建数据库。如果 dbname 以斜线（/）开头，那么它是文件所在的绝对路径（Linux 和 OS X 是斜线，而在 Windows 是例如 C:\\ 的路径名）。否则它是当前目录下的相对路径。

以下是一个程序的所有部分，为了解释把它们隔开。

开始导入库函数，例子中使用了 import 的别名，用字符串 sa 指代 SQLAlchemy。我通常会这样做是因为 sa 要比 sqlalchemy 简洁得多：

>>> import sqlalchemy as sa

连接到数据库，并在内存中存储它（参数字符串 'sqlite:///:memory:' 也是可行的）：

>>> conn = sa.create_engine('sqlite://')

创建包含三列的数据库表单 zoo：

>>> conn.execute('''CREATE TABLE zoo
...     (critter VARCHAR(20) PRIMARY KEY,
...      count INT,
...      damages FLOAT)''')
<sqlalchemy.engine.result.ResultProxy object at 0x1017efb10>

运行函数 conn.execute() 返回到一个 SQLAlchemy 的对象 ResultProxy。马上你会看到它的用处。

顺便提一句，如果你之前从未创建过数据库表单，祝贺你，可以把它从你的人生清单（bucket list）去掉了。

现在向空表单里插入三组数据：

>>> ins = 'INSERT INTO zoo (critter, count, damages) VALUES (?, ?, ?)'
>>> conn.execute(ins, 'duck', 10, 0.0)
<sqlalchemy.engine.result.ResultProxy object at 0x1017efb50>
>>> conn.execute(ins, 'bear', 2, 1000.0)
<sqlalchemy.engine.result.ResultProxy object at 0x1017ef090>
>>> conn.execute(ins, 'weasel', 1, 2000.0)
<sqlalchemy.engine.result.ResultProxy object at 0x1017ef450>

接下来在数据库中查询放入的所有数据：

>>> rows = conn.execute('SELECT * FROM zoo')

在 SQLAlchemy 中，rows 不是一个列表，不能直接输出：

>>> print(rows)
<sqlalchemy.engine.result.ResultProxy object at 0x1017ef9d0>

但它可以像列表一样迭代，每次可以得到其中的一行：

>>> for row in rows:
...     print(row)
...
('duck', 10, 0.0)
('bear', 2, 1000.0)
('weasel', 1, 2000.0)

这个例子几乎和 SQLite DB-API 提到的示例是一样的。一个优势是在程序开始时不需要导入数据库驱动程序，SQLAlchemy 从连接字符串（connection string）已经指定了。改变连接字符串就可以使得代码可移植到另一种数据库。另外一个优势是 SQLAlchemy 的连接池，如果想了解更多可以阅读它的文档（http://docs.sqlalchemy.org/en/latest/core/pooling.html）。

2. SQL表达式语言

再往上一层是 SQLAlchemy 的 SQL 表达式语言。它介绍了创建多种 SQL 操作的函数。相比引擎层，它能处理更多 SQL 方言的差异，对于关系型数据库应用是一种方便的中间层解决方案。

下面介绍如何创建和管理数据表 zoo。同样也是一个程序的连续片段。

导入和连接同之前的完全一样：

>>> import sqlalchemy as sa
>>> conn = sa.create_engine('sqlite://')

在定义表单 zoo 时，开始使用一些表达式语言代替 SQL：

>>> meta = sa.MetaData()
>>> zoo = sa.Table('zoo', meta,
...     sa.Column('critter', sa.String, primary_key=True),
...     sa.Column('count', sa.Integer),
...     sa.Column('damages', sa.Float)
...    )
>>> meta.create_all(conn)

注意多行调用时的圆括号。Table() 方法的调用结构和表单的结构相一致，此表单中包含三列，在 Table() 方法调用时括号内部也调用三次 Column()。

同时，zoo 是连接 SQL 数据库和 Python 数据结构的一个对象。

使用表达式语言的更多函数插入数据：

... conn.execute(zoo.insert(('bear', 2, 1000.0)))
<sqlalchemy.engine.result.ResultProxy object at 0x1017ea910>
>>> conn.execute(zoo.insert(('weasel', 1, 2000.0)))
<sqlalchemy.engine.result.ResultProxy object at 0x1017eab10>
>>> conn.execute(zoo.insert(('duck', 10, 0)))
<sqlalchemy.engine.result.ResultProxy object at 0x1017eac50>

接下来创建 SELECT 语句（zoo.select() 会选择出 zoo 对象表单的所有项，和 SELECT * FROM zoo 在普通 SQL 做的相同）：

>>> result = conn.execute(zoo.select())

最后得到结果：

>>> rows = result.fetchall()
>>> print(rows)
[('bear', 2, 1000.0), ('weasel', 1, 2000.0), ('duck', 10, 0.0)]

3. 对象关系映射

在上一节中，对象 zoo 是 SQL 和 Python 之间的中间层连接。在 SQLAlchemy 的顶层，对象关系映射（ORM）使用 SQL 表达式语言，但尽量隐藏实际数据库的机制。你自己定义类，ORM 负责处理如何读写数据库的数据。在 ORM 这个复杂短语背后，最基本的观点是：同样使用一个关系型数据库，但操作数据的方式仍然和 Python 保持接近。

我们定义一个类 Zoo，把它挂接到 ORM。这一次，我们使用 SQLite 的 zoo.db 文件以便于验证 ORM 是否有效。

和前两节一样，代码片段实际上是一个被解释所隔开的程序。如果不明白其中的部分代码，不要着急，SQLAlchemy 文档有全部的细节，这些资料可能会变得更加复杂。这里我仅仅想让你了解使用该方法的工作量，从而方便决定本章的哪种方法更适合你。

初始的 import 还是一样，这一次需要导入新的东西：

>>> import sqlalchemy as sa
>>> from sqlalchemy.ext.declarative import declarative_base

连接数据库：

>>> conn = sa.create_engine('sqlite:///zoo.db')

现在进入 SQLAlchemy 的 ORM，定义类 Zoo，并关联它的属性和表单中的列：

>>> Base = declarative_base()
>>> class Zoo(Base):
...     __tablename__ = 'zoo'
...     critter = sa.Column('critter', sa.String, primary_key=True)
...     count = sa.Column('count', sa.Integer)
...     damages = sa.Column('damages', sa.Float)
...     def __init__(self, critter, count, damages):
...         self.critter = critter
...         self.count = count
...         self.damages = damages
...     def __repr__(self):
...         return "<Zoo({}, {}, {})>".format(self.critter, self.count, self.damages)

下面这行代码可以很神奇地创建数据库和表单：

>>> Base.metadata.create_all(conn)

然后通过创建 Python 对象插入数据，ORM 内部会管理这些：

>>> first = Zoo('duck', 10, 0.0)
>>> second = Zoo('bear', 2, 1000.0)
>>> third = Zoo('weasel', 1, 2000.0)
>>> first
<Zoo(duck, 10, 0.0)>

接下来，利用 ORM 接触 SQL，创建连接到数据库的会话（session）：

>>> from sqlalchemy.orm import sessionmaker
>>> Session = sessionmaker(bind=conn)
>>> session = Session()

借助会话，把创建的三个对象写入数据库。add() 函数增加一个对象，而 add_all() 增加一个列表：

>>> session.add(first)
>>> session.add_all([second, third])

最后使整个过程完整：

>>> session.commit()

成功了吗？好的，在当前目录下创建了文件 zoo.db，可以使用命令行的 SQLite3 程序验证一下：

$ sqlite3 zoo.db
SQLite version 3.6.12
Enter ".help" for instructions
Enter SQL statements terminated with a ";"
sqlite> .tables
zoo
sqlite> select * from zoo;
duck|10|0.0
bear|2|1000.0
weasel|1|2000.0

本节的目的是介绍 ORM 和它在顶层的实现过程。SQLAlchemy 的作者撰写了完整的教程（http://docs.sqlalchemy.org/en/rel_0_8/orm/tutorial.html）。阅读后决定哪一级最适合你的需求：

普通 DB-API

SQLAlchemy 引擎层

SQLAlchemy 表达式语言

SQLAlchemy ORM

使用 ORM 避开复杂的 SQL 看似是个很自然的选择。到底应该使用哪一个？有些人认为应该避免使用 ORM（http://blog.codinghorror.com/object-relational-mapping-is-the-vietnam-of-computer-science/），但其他人觉得批判太重（http://java.dzone.com/articles/martin-fowler-orm-hate）。不管谁正确，ORM 终究是一种抽象，所有的抽象在某种情况下都会出现问题，毕竟它们是有纰漏的（http://www.joelonsoftware.com/articles/LeakyAbstractions.html）。当 ORM 不能满足需求时，必须要弄明白在 SQL 如何实现修正。借用互联网的一句话：一些人在遇到问题时理所当然地认为“我明白了，要使用 ORM”。但现在他们会有两个困扰。谨慎使用 ORM 以及多用于简单应用，但是应用足够简单的话，或许至少可以直接使用 SQL（或者是 SQL 表达式语言）。

或者尝试一些更为简单的，例如 dataset（https://dataset.readthedocs.org/en/latest/）。它建立在 SQLAlchemy 之上，提供对于 SQL、JSON 以及 CSV 存储的简单 ORM。

8.5　NoSQL数据存储

有些数据库并不是关系型的，不支持 SQL。它们用来处理庞大的数据集、支持更加灵活的数据定义以及定制的数据操作。这些被统称为 NoSQL（以前的意思是 no SQL，现在理解为 not only SQL）。

8.5.1　dbm family

dbm 格式在 NoSQL 出现之前已存在很久了，它们是按照键值对的形式储存，封装在应用程序（例如网页浏览器）中，来维护各种各样的配置。从以下角度看，dbm 数据库和 Python 字典是类似的：

给一个键赋值，自动保存到磁盘中的数据库；

通过键得到对应的值。

下面简单的例子中，open() 方法的第二个参数 'r' 代表读；'w' 代表写；'c' 表示读和写，如果文件不存在则创建之：

>>> import dbm
>>> db = dbm.open('definitions', 'c')

同字典一样创建键值对，给一个键赋值：

>>> db['mustard'] = 'yellow'
>>> db['ketchup'] = 'red'
>>> db['pesto'] = 'green'

停下来看看数据库中存放了什么：

>>> len(db)
3
>>> db['pesto']
b'green'

现在关掉数据库，然后重新打开验证它是否被完整保存：

>>> db.close()
>>> db = dbm.open('definitions', 'r')
>>> db['mustard']
b'yellow'

键和值都以字节保存，因此不能对数据库对象 db 进行迭代，但是可以使用函数 len() 得到键的数目。注意 get() 和 setdefault() 函数只能用于字典的方法。

8.5.2　memcached

memcached（http://memcached.org/）是一种快速的、内存键值对象的缓存服务器。它一般置于数据库之前，用于存储网页服务器会话数据。Linux 和 OS X 点此链接（https://code.google.com/p/memcached/wiki/NewInstallFromPackage）下载，而 Windows 系统在此（http://zurmo.org/wiki/installing-memcache-on-windows）下载。如果你想要尝试使用，需要一个 memcached 服务器和 Python 的驱动程序。

当然存在很多这样的驱动程序，其中能在 Python 3 使用的是 python3-memcached（https://github.com/eguven/python3-memcached），可以通过下面这条命令安装：

$ pip install python-memcached

连接到一个 memcached 服务器之后，可以做以下事项：

赋值和取值

其中一个值的自增或者自减

删除其中一个键

数据在 memcached 并不是持久化保存的，后面的可能会覆盖早些写入的数据，这本来就是它的固有特性，因为它作为一个缓存服务器，通过舍弃旧数据避免程序运行时内存不足的问题。

你也可以同时连接到多个 memcached 服务器。不过下面的例子只连到一个：

>>> import memcache
>>> db = memcache.Client(['127.0.0.1:11211'])
>>> db.set('marco', 'polo')
True
>>> db.get('marco')
'polo'
>>> db.set('ducks', 0)
True
>>> db.get('ducks')
0
>>> db.incr('ducks', 2)
2
>>> db.get('ducks')
2

8.5.3　Redis

Redis（http://redis.io/）是一种数据结构服务器（data structure server）。和 memcached 类似，Redis 服务器的所有数据都是基于内存的（现在也可以选择把数据存放在磁盘）。不同于 memcached，Redis 可以实现：

存储数据到磁盘，方便断电重启和提升可靠性；

保存旧数据；

提供多种数据结构，不限于简单字符串。

Redis 的数据类型和 Python 很相近，Redis 服务器会是一个或多个 Python 应用程序之间共享数据的非常有帮助的中间件。据我的经验，值得用一定的篇幅介绍它。

Python 的 Redis 驱动程序 redis-py 在 GitHub（https://github.com/andymccurdy/redis-py）托管代码和测试用例，也可在此参考在线文档（http://redis-py.readthedocs.org/en/latest/）。可以使用这条命令安装它：

$ pip install redis

Redis 服务器自身就有好用的文档。如果在本地计算机（网络名为 localhost）安装和启动了 Redis 服务器，就可以开始尝试下面的程序。

1. 字符串

具有单一值的一个键被称作 Redis 的字符串。简单的 Python 数据类型可以自动转换成 Redis 字符串。现在连接到一些主机（默认 localhost）以及端口（默认 6379）上的 Redis 服务器：

>>> import redis
>>> conn = redis.Redis()

redis.Redis('localhost') 或者 redis.Redis('localhost', 6379) 会得到同样的结果。

列出所有的键（目前为空）：

>>> conn.keys('*')
[]

给键 'secret' 赋值一个字符串；给键 'carats' 赋一个整数；给键 'fever' 赋一个浮点数：

>>> conn.set('secret', 'ni!')
True
>>> conn.set('carats', 24)
True
>>> conn.set('fever', '101.5')
True

通过键反过来得到对应的值：

>>> conn.get('secret')
b'ni!'
>>> conn.get('carats')
b'24'
>>> conn.get('fever')
b'101.5'

这里的 setnx() 方法只有当键不存在时才设定值：

>>> conn.setnx('secret', 'icky-icky-icky-ptang-zoop-boing!')
False

方法运行失败，因为之前已经定义了 'secret'：

>>> conn.get('secret')
b'ni!'

方法 getset() 会返回旧的值，同时赋新的值：

>>> conn.getset('secret', 'icky-icky-icky-ptang-zoop-boing!')
b'ni!'

先不急着继续下面的内容，看之前的操作是否可以运行？

>>> conn.get('secret')
b'icky-icky-icky-ptang-zoop-boing!'

使用函数 getrange() 得到子串（偏移量 offset：0 代表开始，-1 代表结束）：

>>> conn.getrange('secret', -6, -1)
b'boing!'

使用函数 setrange() 替换子串（从开始位置偏移）：

>>> conn.setrange('secret', 0, 'ICKY')
32
>>> conn.get('secret')
b'ICKY-icky-icky-ptang-zoop-boing!'

接下来使用函数 mset() 一次设置多个键值：

>>> conn.mset({'pie': 'cherry', 'cordial': 'sherry'})
True

使用函数 mget() 一次取到多个键的值：

>>> conn.mget(['fever', 'carats'])
[b'101.5', b'24']

使用函数 delete() 删掉一个键：

>>> conn.delete('fever')
True

使用函数 incr() 或者 incrbyfloat() 增加值，函数 decr() 减少值：

>>> conn.incr('carats')
25
>>> conn.incr('carats', 10)
35
>>> conn.decr('carats')
34
>>> conn.decr('carats', 15)
19
>>> conn.set('fever', '101.5')
True
>>> conn.incrbyfloat('fever')
102.5
>>> conn.incrbyfloat('fever', 0.5)
103.0

不存在函数 decrbyfloat()，可以用增加负数代替：

>>> conn.incrbyfloat('fever', -2.0)
101.0

2. 列表

Redis 的列表仅能包含字符串。当第一次插入数据时列表被创建。使用函数 lpush() 在开始处插入：

>>> conn.lpush('zoo', 'bear')
1

在开始处插入超过一项：

>>> conn.lpush('zoo', 'alligator', 'duck')
3

使用 linsert() 函数在一个值的前或者后插入：

>>> conn.linsert('zoo', 'before', 'bear', 'beaver')
4
>>> conn.linsert('zoo', 'after', 'bear', 'cassowary')
5

使用 lset() 函数在偏移量处插入（列表必须已经存在）：

>>> conn.lset('zoo', 2, 'marmoset')
True

使用 rpush() 函数在结尾处插入：

>>> conn.rpush('zoo', 'yak')
6

使用 lindex() 函数取到给定偏移量处的值：

>>> conn.lindex('zoo', 3)
b'bear'

使用 lrange() 函数取到给定偏移量范围（0~-1 代表全部）的所有值：

>>> conn.lrange('zoo', 0, 2)
[b'duck', b'alligator', b'marmoset']

使用 ltrim() 函数仅保留列表中给定范围的值：

>>> conn.ltrim('zoo', 1, 4)
True

使用函数 lrange() 得到一定范围的的值（0~-1 代表全部）：

>>> conn.lrange('zoo', 0, -1)
[b'alligator', b'marmoset', b'bear', b'cassowary']

第 10 章会介绍如何使用 Redis 列表以及发布 - 订阅（publish-subscribe）用于实现任务队列。

3. 哈希表

Redis 的哈希表类似于 Python 中的字典，但它仅包含字符串，因此只能有一层结构，不能进行嵌套。下面的例子创建了一个 Redis 的哈希表 song，并对它进行操作。

使用函数 hmset() 在哈希表 song 设置字段 do 和字段 re 的值：

>>> conn.hmset('song', {'do': 'a deer', 're': 'about a deer'})
True

使用函数 hset() 设置一个单一字段值：

>>> conn.hset('song', 'mi', 'a note to follow re')
1

使用函数 hget() 取到一个字段的值：

>>> conn.hget('song', 'mi')
b'a note to follow re'

使用函数 hmget() 取到多个字段的值：

>>> conn.hmget('song', 're', 'do')
[b'about a deer', b'a deer']

使用函数 hkeys() 取到所有字段的键：

>>> conn.hkeys('song')
[b'do', b're', b'mi']

使用函数 hvals() 取到所有字段的值：

>>> conn.hvals('song')
[b'a deer', b'about a deer', b'a note to follow re']

使用函数 hlen() 返回字段的总数：

>>> conn.hlen('song')
3

使用函数 hgetall() 取到所有字段的键和值：

>>> conn.hgetall('song')
{b'do': b'a deer', b're': b'about a deer', b'mi': b'a note to follow re'}

使用函数 hsetnx() 对字段中不存在的键赋值：

>>> conn.hsetnx('song', 'fa', 'a note that rhymes with la')
1

4. 集合

正如你会在下面看到的例子所示，Redis 的集合和 Python 的集合是完全类似的。

在集合中添加一个或多个值：

>>> conn.sadd('zoo', 'duck', 'goat', 'turkey')
3

取得集合中所有值的数目：

>>> conn.scard('zoo')
3

返回集合中的所有值：

>>> conn.smembers('zoo')
{b'duck', b'goat', b'turkey'}

从集合中删掉一个值：

>>> conn.srem('zoo', 'turkey')
True

新建一个集合以展示一些集合间的操作：

>>> conn.sadd('better_zoo', 'tiger', 'wolf', 'duck')
0

返回集合 zoo 和集合 better_zoo 的交集：

>>> conn.sinter('zoo', 'better_zoo')
{b'duck'}

获得集合 zoo 和集合 better_zoo 的交集，并存储到新集合 fowl_zoo：

>>> conn.sinterstore('fowl_zoo', 'zoo', 'better_zoo')
1

哪一个会在集合 fowl_zoo 中？

>>> conn.smembers('fowl_zoo')
{b'duck'}

返回集合 zoo 和集合 better_zoo 的并集：

>>> conn.sunion('zoo', 'better_zoo')
{b'duck', b'goat', b'wolf', b'tiger'}

存储并集结果到新的集合 fabulous_zoo：

>>> conn.sunionstore('fabulous_zoo', 'zoo', 'better_zoo')
4
>>> conn.smembers('fabulous_zoo')
{b'duck', b'goat', b'wolf', b'tiger'}

什么是集合 zoo 包含而集合 better_zoo 不包含的项？使用函数 sdiff() 得到它们的差集，sdiffstore() 将存储到新集合 zoo_sale：

>>> conn.sdiff('zoo', 'better_zoo')
{b'goat'}
>>> conn.sdiffstore('zoo_sale', 'zoo', 'better_zoo')
1
>>> conn.smembers('zoo_sale')
{b'goat'}

5. 有序集合

Redis 中功能最强大的数据类型之一是有序表（sorted set 或者 zset）。它里面的值都是独一无二的，但是每一个值都关联对应浮点值分数（score）。可以通过值或者分数取得每一项。有序集合有很多用途：

排行榜

二级索引

时间序列（把时间戳作为分数）

我们把最后一个（时间序列）作为例子，通过时间戳跟踪用户的登录。在这里，时间表达使用 Unix 的 epoch 值（更多介绍见第 10 章），它由 Python 的 time() 函数返回：

>>> import time
>>> now = time.time()
>>> now
1361857057.576483

首先增加第一个访客：

>>> conn.zadd('logins', 'smeagol', now)
1

5 分钟后，又一名访客：

>>> conn.zadd('logins', 'sauron', now+(5*60))
1

两小时后：

>>> conn.zadd('logins', 'bilbo', now+(2*60*60))
1

一天后，负载并不是很多：

>>> conn.zadd('logins', 'treebeard', now+(24*60*60))
1

那么 bilbo 登录的次序是什么？

>>> conn.zrank('logins', 'bilbo')
2

登录时间呢？

>>> conn.zscore('logins', 'bilbo')
1361864257.576483

按照登录的顺序查看每一位访客：

>>> conn.zrange('logins', 0, -1)
[b'smeagol', b'sauron', b'bilbo', b'treebeard']

附带上他们的登录时间：

>>> conn.zrange('logins', 0, -1, withscores=True)
[(b'smeagol', 1361857057.576483), (b'sauron', 1361857357.576483),
(b'bilbo', 1361864257.576483), (b'treebeard', 1361943457.576483)]

6. 位图

位图（bit）是一种非常省空间且快速的处理超大集合数字的方式。假设你有一个很多用户注册的网站，想要跟踪用户的登录频率、在某一天用户的访问量以及同一用户在固定时间内的访问频率，等等。当然，你可以使用 Redis 集合，但如果使用递增的用户 ID，位图的方法更加简洁和快速。

首先为每一天创建一个位集合（bitset）。为了测试，我们仅使用 3 天和部分用户 ID：

>>> days = ['2013-02-25', '2013-02-26', '2013-02-27']
>>> big_spender = 1089
>>> tire_kicker = 40459
>>> late_joiner = 550212

每一天是一个单独的键，对应的用户 ID 设置位，例如第一天（2013-02-25）有来自 big_spender(ID 1089) 和 tire_kicker(ID 40459) 的访问记录：

>>> conn.setbit(days[0], big_spender, 1)
0
>>> conn.setbit(days[0], tire_kicker, 1)
0

第二天用户 big_spender 又有访问：

>>> conn.setbit(days[1], big_spender, 1)
0

接下来的一天，朋友 big_spender 再次访问，并又有新人 late_joiner 访问：

>>> conn.setbit(days[2], big_spender, 1)
0
>>> conn.setbit(days[2], late_joiner, 1)
0

现在统计得到这三天的日访客数：

>>> for day in days:
...     conn.bitcount(day)
...
2
1
2

查看某一天某个用户是否有访问记录？

>>> conn.getbit(days[1], tire_kicker)
0

显然 tire_kicker 在第二天没有访问。

有多少访客每天都会访问？

>>> conn.bitop('and', 'everyday', *days)
68777
>>> conn.bitcount('everyday')
1

让你猜三次他是谁：

>>> conn.getbit('everyday', big_spender)
1

最后，这三天中独立的访客数量有多少？

>>> conn.bitop('or', 'alldays', *days)
68777
>>> conn.bitcount('alldays')
3

7. 缓存和过期

所有的 Redis 键都有一个生存期或者过期时间（expiration date），默认情况下，生存期是永久的。也可以使用 expire() 函数构造 Redis 键的生存期，下面看到的设置值是以秒为单位的数：

>>> import time
>>> key = 'now you see it'
>>> conn.set(key, 'but not for long')
True
>>> conn.expire(key, 5)
True
>>> conn.ttl(key)
5
>>> conn.get(key)
b'but not for long'
>>> time.sleep(6)
>>> conn.get(key)
>>>

expireat() 命令给一个键设定过期时间，对于更新缓存是有帮助的，并且可以限制登录会话。

8.5.4　其他的NoSQL

NoSQL 服务器都要处理远超过内存的数据，并且很多服务器要使用多台计算机。表 8-6 列出了值得注意的服务器和它们的 Python 库。

表8-6：NoSQL数据库

Site

Python API

Cassandra（http://cassandra.apache.org/）

pycassa（https://github.com/pycassa/pycassa）

CouchDB（http://couchdb.apache.org/）

couchdb-python（https://github.com/djc/couchdb-python）

HBase（http://hbase.apache.org/）

happybase（https://github.com/wbolster/happybase）

Kyoto Cabinet（http://fallabs.com/kyotocabinet/）

kyotocabinet（http://fallabs.com/kyotocabinet/pythondoc/）

MongoDB（http://www.mongodb.org/）

mongodb（http://api.mongodb.org/python/current/）

Riak（http://basho.com/riak/）

riak-python-client（https://github.com/basho/riak-pythonclient）

8.6　全文数据库

最后，有一类特殊的数据库用于做全文检索。它们对所有内容都建索引，所以你可以检索到吟诵风车和满车奶酪的诗歌。表 8-7 是一些流行的开源软件以及它们的 Python API。

表8-7：全文数据库

Site

Python API

Lucene（http://lucene.apache.org/）

pylucene（http://lucene.apache.org/pylucene/）

Solr（http://lucene.apache.org/solr/）

SolPython（http://wiki.apache.org/solr/SolPython）

ElasticSearch（http://www.elasticsearch.org/）

pyes（https://github.com/aparo/pyes/）

Sphinx（http://sphinxsearch.com/）

sphinxapi（https://code.google.com/p/sphinxsearch/source/browse/trunk/api/sphinxapi.py）

Xapian（http://xapian.org/）

xappy（https://code.google.com/p/xappy/）

Whoosh（https://bitbucket.org/mchaput/whoosh/wiki/Home）

由 Python 编写，包含API

8.7　练习

(1) 将字符串 'This is a test of the emergency text system' 赋给变量 test1，然后把它写到文件 test.txt。

(2) 打开文件 test.txt，读文件内容到字符串 test2。test1 和 test2 是一样的吗？

(3) 保存这些文本到 books.csv 文件。注意，字段间是通过逗号隔开的，如果字段中含有逗号需要在整个字段加引号。

author,book
J R R Tolkien,The Hobbit
Lynne Truss,"Eats, Shoots & Leaves"

(4) 使用 csv 模块和它的 DictReader 方法读取文件 books.csv 到变量 books。输出变量 books 的值。DictReader 可以处理第二本书题目中的引号和逗号吗？

(5) 创建包含下面这些行的 CSV 文件 books.csv：

title,author,year
The Weirdstone of Brisingamen,Alan Garner,1960
Perdido Street Station,China Miéville,2000
Thud!,Terry Pratchett,2005
The Spellman Files,Lisa Lutz,2007
Small Gods,Terry Pratchett,1992

(6) 使用 sqlite3 模块创建一个 SQLite 数据库 books.db 以及包含字段 title（text）、author（text）以及 year（integer）的表单 book。

(7) 读取文件 books.csv，把数据插入到表单 book。

(8) 选择表单 book 中的 title 列，并按照字母表顺序输出。

(9) 选择表单 book 中所有的列，并按照出版顺序输出。

(10) 使用 sqlalchemy 模块连接到 sqlite3 数据库 books.db，按照 (8) 一样，选择表单 book 中的 title 列，并按照字母表顺序输出。

(11) 在你的计算机安装 Redis 服务器和 Python 的 redis 库（pip install redis）。创建一个 Redis 的哈希表 test，包含字段 count(1) 和 name('Fester Bestertester')，输出 test 的所有字段。

(12) 自增 test 的 count 字段并输出它。

第 9 章　剖析 Web

CERN 是一个很适合当作 007 老巢的粒子物理研究所，横跨了法国和瑞士的边界。幸运的是，CERN 的目标并不是统治世界，而是研究宇宙的本质。这项工作给 CERN 带来了海量的数据，对物理学家和计算机科学家来说极具挑战。

1989 年，英国科学家蒂姆 · 伯纳斯 - 李首次提出可以在 CERN 和研究机构之间传递信息。他称之为万维网，并将其架构提炼成三个非常简单的概念。

HTTP（超文本传输协议）

规定了网络客户端和服务器 1 之间如何交换请求和响应。

HTML（超文本标记语言）

结果的展示格式。

URL（统一资源定位符）

唯一表示服务器和服务器上资源的方法。

1下文会出现三个名词：服务器、Web 服务器和服务端。服务器指的是物理意义上的服务器，也就是主机或者云服务器；Web 服务器指的是服务器上负责 Web 服务的软件，比如 Nginx、Apache，等等。服务端包含服务器端的所有服务，比如 Web 服务器、API，等等。——译者注

在最简单的场景中，一个 Web 客户端（我觉得伯纳斯 - 李应该是第一个使用术语浏览器的人）通过 HTTP 连接到一个 Web 服务器，请求一个 URL，收到 HTML。

他编写了第一个 Web 浏览器和第一个 Web 服务器程序，后者部署在一台 NeXT 计算机上。NeXT 计算机是由一家短命的公司研发出来的，这家公司是乔布斯在离开苹果公司期间创办的。Web 真正开始发展壮大是在 1993 年，这一年伊利诺伊大学的一群学生发布了 Mosaic Web 浏览器（支持 Windows、Macintosh 和 Unix）和 NCSA httpd 服务器程序。当我下载它们并用它们来搭建网站时，根本没有想到 Web 和互联网会迅速演变成我们日常生活的一部分。当时互联网仍然是官方并且非商业性的，全世界大概有 500 个公开的 Web 服务器（http://home.web.cern.ch/topics/birth-web）。到 1994 年底，Web 服务器的数量已经增长到了 10 000。互联网开始开放商业使用，Mosaic 的作者创立了 Netscape 来编写商用 Web 软件。从 Netscape 上市就可窥见当时互联网热潮的一斑，从那之后，Web 的爆发式增长就再也没有停止过。

几乎每种计算机语言都被用来编写过 Web 客户端和 Web 服务器程序。动态语言 Perl、PHP 和 Ruby 更是独领风骚。本章会说明为什么 Python 在 Web 相关的每个层面都是一种非常优秀的语言。Web 大致有三层。

客户端：访问远程网站。

服务端：为网站和 Web API 提供数据。

Web API 和服务：用另一种不同于可视化网页的方式来交换数据。

在本章结尾的练习中，我们还会搭建一个真正的交互式网站。

9.1　Web客户端

互联网最底层的网络传输使用的是传输控制协议 / 因特网协议，更常用的叫法是 TCP/IP（详情参见 11.2.3 节）。这些协议会在计算机之间传输字节，但是并不关心这些字节的含义，后者由更高层的协议——用于特定目的的语法定义——来处理。HTTP 是 Web 数据交换的标准协议。

Web 是一个客户端 - 服务器系统。客户端向服务器发起请求：它会创建一个 TCP/IP 连接，通过 HTTP 发送 URL 和其他信息并接收一个响应。

响应的格式也由 HTTP 定义。其中包括请求的状态以及（如果请求成功）响应的数据和格式。

最著名的 Web 客户端是 Web 浏览器。它可以用很多种方式来发起 HTTP 请求。你可以在地址栏中输入 URL 或者点击网页上的链接来手动发起请求。通常来说，请求所返回的数据会被当作网页展示——HTML 文档、JavaScript 文件、CSS 文件和图片——但也可以是其他类型的数据，它们并不会被用于展示。

HTTP 的一个重要概念是无状态。你发起的每个 HTTP 请求都和其他请求互相独立。这可以简化基本的 Web 操作，但是会让其他的操作变得更复杂。下面列举其中一些。

缓存

没有改变的远程内容应该保存在 Web 客户端中，并避免重新从服务器下载。

Session

购物网站必须记住你购物车中的商品。

认证

使用用户名和密码登录之后，网站应该记住你的登录状态。

可以使用 cookie 来解决无状态带来的问题。服务器可以在 cookie 中加入一些特殊的信息，这样当客户端发回 cookie 时就可以根据 cookie 内容来进行判断。

9.1.1　使用telnet进行测试

HTTP 是基于文本的协议，所以你实际上可以自己编写协议内容来进行 Web 测试。古老的 telnet 程序可以让你连接到目标服务器和端口并输入命令。

下面以大家最常用的测试站点 Google 为例，来获取它的一些首页信息。输入：

$ telnet www.google.com 80

如果 google.com 的 80 端口有一个 Web 服务器程序（我觉得这是肯定的），telnet 会打印出一些确认信息并显示一个空白行供你输入信息：

Trying 74.125.225.177...
Connected to www.google.com.
Escape character is '^]'.

现在，向 telnet 中输入一条真实的 HTTP 命令并发送给 Google 的 Web 服务器。最常用的 HTTP 命令（当你在浏览器地址栏中输入 URL 时，实际上发送的就是这条命令）是 GET。这会获取指定资源的内容，比如一个 HTML 文件，并返回给客户端。在我们的第一次测试中，使用的是 HTTP 命令 HEAD，这会获取一些和资源相关的基本信息：

HEAD / HTTP/1.1

HEAD / 会发送 HTTP HEAD 动词（命令）来获取和首页（/）相关的信息。再次按下回车来发送一个空行，这样远程服务器就知道你已经输入完毕，正在等待响应。你会收到一个下面这样的响应（我们用 ... 省略了一些内容，这样页面会比较整洁）：

HTTP/1.1 200 OK
Date: Sat, 26 Oct 2013 17:05:17 GMT
Expires: -1
Cache-Control: private, max-age=0
Content-Type: text/html; charset=ISO-8859-1
Set-Cookie: PREF=ID=962a70e9eb3db9d9:FF=0:TM=1382807117:LM=1382807117:S=y...
  expires=Mon, 26-Oct-2015 17:05:17 GMT;
  path=/;
  domain=.google.com
Set-Cookie: NID=67=hTvtVC7dZJmZzGktimbwVbNZxPQnaDijCz716B1L56GM9qvsqqeIGb...
  expires=Sun, 27-Apr-2014 17:05:17 GMT
  path=/;
  domain=.google.com;
  HttpOnly
P3P: CP="This is not a P3P policy! See http://www.google.com/support/accounts...
Server: gws
X-XSS-Protection: 1; mode=block
X-Frame-Options: SAMEORIGIN
Alternate-Protocol: 80:quic
Transfer-Encoding: chunked

这些是 HTTP 响应头和对应的值。其中一些，比如 Date 和 Content-Type，是必要的。其他的，比如 Set-Cookie，是用来在多次访问（稍后会讨论状态管理）之间追踪你的活动的。当你发起 HTTP HEAD 请求时，只会得到头部。如果你使用 HTTP GET 或者 POST 命令，就会收到头部和首页的数据（混合了 HTML、CSS、JavaScript 以及其他 Google 放在首页的东西）。

我不会把你扔在 telnet 里不管，输入下面的命令来关闭 telnet：

q

9.1.2　Python的标准Web库

在 Python 2 中，Web 客户端和服务器模块结构都比较散乱。Python 3 的目标之一就是把这些模块打包成两个包（第 5 章提到过，包就是一个包含模块文件的目录）。

http 会处理所有客户端 - 服务器 HTTP 请求的具体细节：

client 会处理客户端的部分

server 会协助你编写 Python Web 服务器程序

cookies 和 cookiejar 会处理 cookie，cookie 可以在请求中存储数据

urllib 是基于 http 的高层库：

request 处理客户端请求

response 处理服务端的响应

parse 会解析 URL

下面，我们使用标准库来获取网站的内容。例子中的 URL 会返回一段随机文本，有点像幸运饼干 2：

2幸运饼干，又称签语饼、幸运签语饼、幸福饼干、占卜饼等，是一种美式亚洲风味脆饼，通常由面粉、糖、香草及奶油做成，并且里面包有类似箴言或者模棱两可预言的字条。——译者注

>>> import urllib.request as ur
>>> url = 'http://www.iheartquotes.com/api/v1/random'
>>> conn = ur.urlopen(url)
>>> print(conn)
<http.client.HTTPResponse object at 0x1006fad50>

在官方文档（https://docs.python.org/3/library/http.client.html）中，我们可以看到 conn 是一个包含许多方法的 HTTPResponse 对象，其中的 read() 方法会获取网页的数据：

>>> data = conn.read()
>>> print(data)
b'You will be surprised by a loud noise.\r\n\n[codehappy]
http://iheartquotes.com/fortune/show/20447\n'

这段 Python 代码会创建一个 TCP/IP 连接并连接到远程服务器，发起一个 HTTP 请求并接收 HTTP 响应。响应中除了网页的数据（那段随机文本）还包含很多其他信息，其中最重要的一条就是 HTTP 状态码：

>>> print(conn.status)
200

200 意味着一切正常。HTTP 状态码有许多种，可以根据第一个（百位）数字来分成五类。

1xx（信息）

服务器收到了请求，但是需要客户端发送一些额外的信息。

2xx（成功）

请求成功。除了 200 以外，其他的状态码还会包含一些特殊含义。

3xx（重定向）

资源位置发生改变，所以响应会返回一个新的 URL 给客户端。

4xx（客户端错误）

客户端发生错误，比如最出名的 404（页面不存在）。418（我是一个茶壶）是一个愚人节笑话。

5xx（服务端错误）

500 是最常见的错误。你可能也见到过 502（网关错误），这表示 Web 服务器程序和后端的应用服务器之间无法连接。

Web 服务器程序可以返回各种格式的数据。通常是 HTML（以及一些 CSS 和 JavaScript），但是在幸运饼干例子中返回的是纯文本。数据格式由 HTTP 响应头部中的 Content-Type 指定，它也出现在了 google.com 例子中：

>>> print(conn.getheader('Content-Type'))
text/plain

text/plain 字符串表示的是一个 MIME 类型，它的意思是纯文本。google.com 例子中返回的 MIME 类型是 text/html。稍后你还会看到更多 MIME 类型。

出于好奇，我们来看看响应中还有什么 HTTP 头？

>>> for key, value in conn.getheaders():
...     print(key, value)
...
Server nginx
Date Sat, 24 Aug 2013 22:48:39 GMT
Content-Type text/plain
Transfer-Encoding chunked
Connection close
Etag "8477e32e6d053fcfdd6750f0c9c306d6"
X-Ua-Compatible IE=Edge,chrome=1
X-Runtime 0.076496
Cache-Control max-age=0, private, must-revalidate

还记得前面的那个 telnet 例子吗？现在，Python 标准库解析了 HTTP 响应的整个头部并存储成一个字典。Date 和 Server 一眼就能看懂，其他的会难懂一些。HTTP 有许多类似 Content-Type 的标准头部，也有许多可选头部。

9.1.3　抛开标准库：requests

第 1 章最开始，我们使用标准库 urllib.request 和 json 来访问 YouTube 的 API。接着使用第三方模块 requests 重写了一个新版本。requests 的版本更短并且更易读。

在大多数情况下，使用 requests 可以让 Web 客户端开发变得更加简单。你可以阅读文档（http://docs.python-requests.org/）来获取更多信息。本节会介绍 requests 的基本用法。本书之后的内容将用它来实现 Web 客户端。

首先需要把 requests 库安装到你的 Python 环境中。打开一个终端窗口（Windows 用户可以输入 cmd 来打开），输入下面的命令让 Python 的包管理器 pip 下载最新版的 requests 包并安装：

$ pip install requests

如果遇到问题，请阅读附录 D 来学习如何安装和使用 pip。

用 requests 来重写一遍上面的例子：

>>> import requests
>>> url = 'http://www.iheartquotes.com/api/v1/random'
>>> resp = requests.get(url)
>>> resp
<Response [200]>
>>> print(resp.text)
I know that there are people who do not love their fellow man, and I hate
people like that!

    -- Tom Lehrer, Satirist and Professor

[codehappy] http://iheartquotes.com/fortune/show/21465

看起来和 urllib.request.urlopen 差不多，不过我觉得看起来更简洁一些。

9.2　Web服务端

Web 开发者已经认识到，在编写 Web 服务器和服务端程序方面 Python 是一门非常优秀的语言。因此，出现了一系列基于 Python 的 Web 框架，这导致开发者很难作出选择——对 Python 教程的作者来说更是如此。

Web 框架提供了一系列用户搭建网站的特性，已经不再是一个简单的 Web（HTTP）服务器了。你会看到许多特性：路由（URL 映射到服务端函数）、模板（HTM 加上动态内容）、调试等。

我不会介绍所有的框架，只会介绍那些相对比较简单易用并且适合开发产品级网站的框架，也会介绍如何用 Python 来处理网站的动态部分并用传统的 Web 服务器来处理静态部分。

9.2.1　最简单的Python Web服务器

可以用一行 Python 代码启动一个简单的 Web 服务器：

$ python -m http.server

这是一个非常简单的 Python HTTP 服务器。如果成功启动，会打印出下面的初始化状态信息：

Serving HTTP on 0.0.0.0 port 8000 ...

0.0.0.0 表示任意 TCP 地址。这样无论服务器的地址是什么，Web 客户端都可以访问。第 11 章会有更多 TCP 的底层细节和其他的网络协议。

现在你可以通过相对路径来请求文件，它们会被 Web 服务器返回。如果你在 Web 浏览器中输入 http://localhost:8000，会看到一个目录列表，Web 服务器会打印出下面这样的访问日志：

127.0.0.1 - - [20/Feb/2013 22:02:37] "GET / HTTP/1.1" 200 -

localhost 和 127.0.0.1 在 TCP 中是同义词，都表示你的本地计算机，因此即使你的计算机没有连网，也可以执行这个例子。这行输出的具体解释如下所示。

127.0.0.1 是客户端的 IP 地址

第一个 "-" 是远程用户名，本例中为空

第二个 "-" 是登录用户名，本例中是可选的，为空

[20/Feb/2013 22:02:37] 是访问日期和时间

“GET / HTTP/1.1”是发送给 Web 服务器的命令：

HTTP 方法（GET）

请求的资源（/，最上层目录）

HTTP 版本（HTTP/1.1）

最后的 200 表示 Web 服务器返回的 HTTP 状态码

随便点击一个文件。如果你的浏览器可以识别它的格式（HTML、PNG、GIF、JPEG 等），就会显示出这个文件，Web 服务器也会记录这次请求。举例来说，如果当前目录下有 oreilly.png 文件，请求 http://localhost:8000/oreilly.png 会返回图 7-1 中这个令人不安的家伙，Web 服务器中会显示类似下面这样的日志：

127.0.0.1 - - [20/Feb/2013 22:03:48] "GET /oreilly.png HTTP/1.1" 200 -

如果同一个目录下还有其他文件，它们也会出现在列表中，你可以点击它们来下载。如果你的浏览器可以显示点击文件的格式，那你会直接在屏幕上看到文件内容；否则你的浏览器会询问你是否要下载并保存文件。

默认的端口数是 8000，你也可以指定其他的数字：

$ python -m http.server 9999

输出如下所示：

Serving HTTP on 0.0.0.0 port 9999 ...

这个 Python 特有的 Web 服务器很适合用作快速测试。在大多数终端中，你可以按下 Ctrl+C 来结束这个进程。

一定不要把这个简单的 Web 服务器用在真正的产品级网站中。Nginx 和 Apache 等传统 Web 服务器可以更快地处理静态文件。此外，这个简单的 Web 服务器不能处理动态内容，其他更高端的 Web 服务器可以接收参数并返回动态内容。

9.2.2　Web服务器网关接口

人总是会变的，现在只提供简单的文件服务已经不能满足我们了，我们想要能够动态运行程序的 Web 服务器。在 Web 发展的早期，出现了通用网关接口（CGI），客户端可以通过它来让 Web 服务器运行外部程序并返回结果。CGI 也会从 Web 服务器获取用户输入的参数并传给外部程序。然而，对于每个用户请求都需要运行一次程序，这样很难扩大用户规模，因为即使程序很小，启动时还是会有明显的等待时间。

为了避免启动延迟，人们开始把语言解释器合并到 Web 服务器中。Apache 可以通过 mod_php 模块来运行 PHP，通过 mod_perl 模块来运行 Perl，通过 mod_python 来运行 Python。这样，动态语言的代码就可以直接在持续运行的 Apache 进程中执行，不用再调用外部程序。

另一种方式是在一个独立的持续运行的程序中运行动态语言，并让它和 Web 服务器进行通信，例如 FastCGI 和 SCGI。

Web 服务器网关接口（WSGI）的定义极大地促进了 Python 在 Web 方面的发展。WSGI 是一个通用的 API，连接 Python Web 应用和 Web 服务器。本章接下来介绍的所有 Python Web 框架和 Web 服务器都使用了 WSGI。你并不需要知道 WSGI 的原理（其实也没有多少内容），但是理解其中一些概念是非常有帮助的。

9.2.3　框架

Web 服务器会处理 HTTP 和 WSGI 的具体细节，但是真正的网站是你使用框架写出的 Python 代码。因此，接下来会介绍一下什么是框架，然后再来讲解如何使用它们来创建网站。

如果你想用 Python 编写网站，有许多 Python Web 框架供你选择（或许有些过多了）。对于一个 Web 框架来说，至少要具备处理客户端请求和服务端响应的能力。框架可能会具备下面这些特性中的一种或多种。

路由

解析 URL 并找到对应的服务端文件或者 Python 服务器代码。

模板

把服务端数据合并成 HTML 页面。

认证和授权

处理用户名、密码和权限。

Session

处理用户在多次请求之间需要存储的数据。

接下来会用两个框架（bottle 和 flask）来编写一些示例代码。之后会介绍其他框架，用它们编写带数据库的网站非常方便。无论你想编写什么网站，都能找到合适的框架。

9.2.4　Bottle

Bottle（瓶子）只包含一个简单的 Python 文件，所以非常易于使用并且易于部署。Bottle 并不是 Python 标准库的一部分，所以需要使用下面的命令来安装它：

$ pip install bottle

下面的代码会运行一个测试用于 Web 服务器，如果你在浏览器中访问 http://localhost : 9999/，这个服务器会返回一行文本。把下面的代码保存为 bottle1.py：

from bottle import route, run

@route('/')
def home():
  return "It isn't fancy, but it's my home page"

run(host='localhost', port=9999)

Bottle 使用 route 装饰器来关联 URL 和函数。在本例中，/（首页）会被 home() 函数处理。输入下面的命令来用 Python 运行这个服务器脚本：

$ python bottle1.py

如果你在浏览器中访问 http://localhost:9999，会看到下面的内容：

It isn't fancy, but it's my home page

run() 函数会执行 bottle 内置的 Python 测试用 Web 服务器。你也可以使用其他 Web 服务器，但是在开发和测试时它非常有用。

把 HTML 硬编码到代码中是很不合适的，我们创建一个单独的 HTML 文件 index.html 并写入下面的内容：

My <b>new</b> and <i>improved</i> home page!!!

接着让 bottle 在首页被请求时返回这个 HTML 文件的内容。把下面的代码保存为 bottle2.py：

from bottle import route, run, static_file

@route('/')
def main():
    return static_file('index.html', root='.')

run(host='localhost', port=9999)

调用 static_file() 时，我们指定的是 root 目录（在本例中是 '.'，也就是当前目录）下的 index.html 文件。如果上一个例子的脚本还在执行，请终止它并运行这个新服务器：

$ python bottle2.py

在浏览器中访问 http://localhost:9999 时，会看到：

My new and improved home page!!!

再来看最后一个例子。它展示了如何指定 URL 中的参数并使用它们。按照惯例，这次的文件名是 bottle3.py：

from bottle import route, run, static_file

@route('/')
def home():
    return static_file('index.html', root='.')

@route('/echo/<thing>')
def echo(thing):
    return "Say hello to my little friend: %s!" % thing

run(host='localhost', port=9999)

我们定义了一个新函数 echo()，并且在 URL 中指定了一个字符串参数。这就是例子中 @route('/echo/<thing>') 做的事情。路由中的 <thing> 表示 URL 中 /echo/ 之后的内容都会被赋值给字符串参数 thing，然后被传入 echo 函数。下面来看看效果，如果旧服务器还在运行就终止它，然后启动新服务器：

$ python bottle3.py

接着在浏览器中访问 http://localhost:9999/echo/Mothra，会看到：

Say hello to my little friend: Mothra!

好的，现在请继续让 bottle3.py 运行，我们来做一个实验。刚才你在浏览器中验证了这个例子确实能够正常工作，并且看到了展示出来的页面。其实还可以让客户端库（比如 requests）来做同样的事。把下面的代码保存为 bottle_test.py：

import requests

resp = requests.get('http://localhost:9999/echo/Mothra')
if resp.status_code == 200 and \
  resp.text == 'Say hello to my little friend: Mothra!':
    print('It worked! That almost never happens!')
else:
    print('Argh, got this:', resp.text)

然后运行：

$ python bottle_test.py

你会在终端中看到：

It worked! That almost never happens!

这是一个简单的单元测试。第 12 章中详细介绍了为什么要测试以及如何用 Python 编写测试。

bottle 还有许多其他的特性，例如你可以试着在调用 run() 时加上这些参数：

debug=True，如果出现 HTTP 错误，会创建一个调试页面；

reloader=True，如果你修改了任何 Python 代码，浏览器中的页面会重新载入。

详细的文档可以在开发者网站（http://bottlepy.org/docs/dev/）上找到。

9.2.5　Flask

Bottle 是一个非常优秀的入门框架。但如果你需要更多的功能，那就试试 Flask 吧。Flask 最初只是 2010 年的一个愚人节玩笑，但是由于大家的反响非常热烈，作者 Armin Ronacher 把它变成了一个真正的框架。有趣的是，Flask 这个名字也是一个文字游戏 3。

3Flask 和 bottle 都有瓶子的意思。——译者注

Flask 和 Bottle 一样易用，同时还支持很多专业 Web 开发需要的扩展功能，比如 Facebook 认证和数据库集成。Flask 是我最喜欢的 Python Web 框架，因为它成功地做到了既好用又强大。

Flask 包中自带了 werkzeug WSGI 库和 jinja2 模板库。你可以从终端中安装：

$ pip install flask

我们用 flask 来重写一下最后那个 bottle 例子。首先需要进行一些修改。

Flask 默认的静态文件目录是 static，默认的静态文件 URL 由 /static 开始。我们把文件夹改成 '.'（当前目录），把 URL 前缀改成 ''（空），这样 URL/ 可以被映射到文件 index.html。

在 run() 函数中，设置 debug=True 可以启用代码自动重载；bottle 把这个参数拆成了两个，debug 和 reload。

把下面的代码保存为 flask1.py：

from flask import Flask

app = Flask(__name__, static_folder='.', static_url_path='')

@app.route('/')
def home():
    return app.send_static_file('index.html')

@app.route('/echo/<thing>')
def echo(thing):
    return "Say hello to my little friend: %s" % thing

app.run(port=9999, debug=True)

然后在终端或者命令行中运行 Web 服务器：

$ python flask1.py

在浏览器中输入下面的 URL 来测试首页是否可以正常访问：

http://localhost:9999/

你能看到下面的内容（和 bottle 例子一样）：

My new and improved home page!!!

试试 /echo 功能：

http://localhost:9999/echo/Godzilla

会看到：

Say hello to my little friend: Godzilla

把 debug 设置为 True 还有一个好处。在调用 run 时，如果代码中出现异常，Flask 会返回一个特殊的网页，其中会包含一些有用的信息，比如错误类型和错误位置。此外，你还可以使用一些命令来查看服务器程序中变量的值。

　在生产环境中不要把 debug 设置为 True，否则可能会暴露出太多信息，有安全隐患。

到目前为止，Flask 的例子只是重复了我们之前用 bottle 做的事。那 Flask 能做什么 bottle 做不了的事呢？ Flask 内置了 jinja2，一个极具扩展性的模板系统。下面这个例子展示了如何在 flask 中使用 jinja2。

创建一个名为 templates 的目录，把下面的代码存为 flask2.html：

<html>
<head>
<title>Flask2 Example</title>
</head>
<body>
Say hello to my little friend: {{ thing }}
</body>
</html>

接着在服务器程序中获取这个模板，写入我们传入的值 thing，然后渲染成 HTML（为了节约空间，我去掉了 home() 函数）。把下面的代码存储为 flask2.py：

from flask import Flask, render_template

app = Flask(__name__)

@app.route('/echo/<thing>')
def echo(thing):
    return render_template('flask2.html', thing=thing)

app.run(port=9999, debug=True)

thing = thing 这个参数的意思是把名为 thing 的变量传入模板，它的值是变量 thing 中的字符串。

关闭 flask1.py，运行 flask2.py：

$ python flask2.py

现在输入 URL：

http://localhost:9999/echo/Gamera

你会看到这些内容：

Say hello to my little friend: Gamera

修改一下模板内容并把它存为 flask3.html，放在 templates 目录下：

<html>
<head>
<title>Flask3 Example</title>
</head>
<body>
Say hello to my little friend: {{ thing }}.
Alas, it just destroyed {{ place }}!
</body>
</html>

你可以用很多方法把第二个参数传入 echo 的 URL。

通过URL路径传入参数

你可以把参数当作 URL 的一部分，使用这种方法可以直接扩展 URL 本身（把下面的代码存储为 flask3a.py）：

from flask import Flask, render_template

app = Flask(__name__)

@app.route('/echo/<thing>/<place>')
def echo(thing, place):
    return render_template('flask3.html', thing=thing, place=place)

app.run(port=9999, debug=True)

按照惯例，停止之前的服务器脚本并运行这个新脚本：

$ python flask3a.py

URL 看起来是这样：

http://localhost:9999/echo/Rodan/McKeesport

你会看到：

Say hello to my little friend: Rodan. Alas, it just destroyed McKeesport!

此外，还可以用 GET 参数来传递参数（把下面的代码存储为 flask3b.py）：

from flask import Flask, render_template, request

app = Flask(__name__)

@app.route('/echo/')
def echo():
    thing = request.args.get('thing')
    place = request.args.get('place')
    return render_template('flask3.html', thing=thing, place=place)

app.run(port=9999, debug=True)

运行新的服务器脚本：

$ python flask3b.py

这次使用这个 URL：

http://localhost:9999/echo?thing=Gorgo&place=Wilmerding

你会看到：

Say hello to my little friend: Gorgo. Alas, it just destroyed Wilmerding!

在 URL 中使用 GET 命令时，传入的参数形式为 ?key1=val1&key2=val2&...。

你可以使用字典的 ** 操作符来向模板中一次性传入字典的多个值（把下面的代码存储为 flask3c.py）：

from flask import Flask, render_template,request

app = Flask(__name__)

@app.route('/echo/')
def echo():
    kwargs = {}
    kwargs['thing'] = request.args.get('thing')
    kwargs['place'] = request.args.get('place')
    return render_template('flask3.html', **kwargs)

app.run(port=9999, debug=True)

**kwargs 的行为与 thing=thing 和 place=place 一样，但是在参数很多时可以少输入很多内容。

jinja2 模板语言还有很多功能，如果你用过 PHP，应该会看到许多熟悉的东西。

9.2.6　非Python的Web服务器

到目前为止，我们使用的 Web 服务器都很简单：不是标准库的 http.server 就是 Bottle 和 Flask 自带的调试用服务器。在生产环境中，你需要用更快的 Web 服务器来运行 Python。下面是常用的选择：

apache 加上 mod_wsgi 模块

nginx 加上 uWSGI 应用服务器

两者都很不错。apache 可能是最流行的，nginx 更稳定并且占用内存更少。

1. Apache

Apache（http://httpd.apache.org/）Web 服务器中最好用的 WSGI 模块是 mod_wsgi（https://code.google.com/p/modwsgi/）。这个模块可以在 Apache 进程中运行 Python 代码，也可以在独立进程中运行 Python 代码并和 Apache 进行通信。

如果你的系统是 Linux 或者 OS X，那你已经有 apache 了。如果是 Windows，你需要安装 apache（http://httpd.apache.org/docs/current/platform/windows.html）。

最后，安装好你喜欢的基于 WSGI 的 Python Web 框架，这里我们使用 bottle。之后的工作基本上都是配置 Apache，这里有很多黑魔法。

把下面的代码存储为 /var/www/test/home.wsgi：

import bottle

application = bottle.default_app()

@bottle.route('/')
def home():
    return "apache and wsgi, sitting in a tree"

这次不要调用 run()，因为它会启动内置的 Python Web 服务器。我们需要给变量 application 赋值，因为 mod_wsgi 会使用这个变量来结合 Web 服务器和 Python 代码。

如果 apache 和 mod_wsgi 模块工作正常，只需要把它们连接到 Python 脚本就行。要做到这件事，需要向 apache 服务器的默认网站配置文件中加入一行，但是找到那个文件并不容易。它可能是 /etc/apache2/httpd.conf，也可能是 /etc/apache2/sites-available/default，还可能是某个人的宠物蝾螈的拉丁文名字。

假设现在你能找到那个文件，把下面这行加入控制默认网站的 <VirtualHost> 中：

WSGIScriptAlias / /var/www/test/home.wsgi

添加之后可能是这样的：

<VirtualHost *:80>
    DocumentRoot /var/www

    WSGIScriptAlias / /var/www/test/home.wsgi

    <Directory /var/www/test>
    Order allow,deny
    Allow from all
    </Directory>
</VirtualHost>

启动 apache，如果你已经启动就重启，这样才能应用新的配置文件。之后，如果访问 http://localhost/，你会看到：

apache and wsgi, sitting in a tree

这样就在嵌入模式中运行了 mod_wsgi，在这个模式下它是 apache 的一部分（在同一进程内）。

也可以用守护模式来运行，这样会产生一个或多个独立于 apache 的进程。要使用守护模式，可以向 apache 配置文件中加入两行内容：

$ WSGIDaemonProcess domain-name user=user-name group=group-name threads=25
WSGIProcessGroup domain-name

在上面的代码中，user-name 和 group-name 是操作系统的用户和用户组名称，domain-name 是你的互联网域名。最简单的 apache 配置如下所示：

<VirtualHost *:80>
    DocumentRoot /var/www

    WSGIScriptAlias / /var/www/test/home.wsgi

    WSGIDaemonProcess mydomain.com user=myuser group=mygroup threads=25
    WSGIProcessGroup mydomain.com

    <Directory /var/www/test>
    Order allow,deny
    Allow from all
    </Directory>
</VirtualHost>

2. Nginx Web服务器

Nginx（http://nginx.org/）Web 服务器没有内嵌的 Python 模块。它通过一个独立的 WSGI 服务器（比如 uWSGI）来和 Python 程序通信。把它们结合在一起可以实现高性能并且可配置的 Python Web 开发平台。

你可以从官网（http://wiki.nginx.org/Install）安装 nginx。此外，还需要安装 uWSGI（http://uwsgidocs.readthedocs.org/en/latest/Install.html）。uWSGI 是一个大系统，有许多需要调节的内容。可以在 http://flask.pocoo.org/docs/0.10/deploying/uwsgi/ 看到如何结合 Flask、nginx 和 uWSGI。

9.2.7　其他框架

网站和数据库就像花生酱和果冻，它们经常一起出现。小型框架，比如 bottle 和 flask，不能直接支持数据库，尽管有一些插件可以实现。

如果你需要开发基于数据库的网站并且数据库的结构不会经常变化，那最好试试大型的 Python Web 框架。现在主流的框架有以下这些。

django（https://www.djangoproject.com/）

是最流行的，尤其是大型网站很喜欢用它。有很多学习 django 的理由，其中最重要的就是 Python 的招聘要求中经常需要 django 的开发经验。它有 ORM 功能（8.4.6 节的“对象关系映射”部分讨论过），可以在网页中自动应用典型的数据库 CRUD 功能（创建、替换、更新和删除），就像之前在 8.4.1 节中说的一样。你也可以不用 django 自带的 ORM，可以选择 SQLAlchemy 或者直接使用 SQL 查询语句。

web2py（http://www.web2py.com/）

和 django 功能类似，只是风格不同。

pyramid（http://www.pylonsproject.org/）

诞生于最早的 pylons 项目，和 django 很像。

turbogears（http://turbogears.org/）

这个框架支持 ORM、多种数据库以及多种模板语言。

wheezy.web（http://pythonhosted.org/wheezy.web/）

这是一个比较新的框架，专为性能而生。在最近的测试中，它比其他框架都快（http://mindref.blogspot.com/2012/10/python-web-routing-benchmark.html）。

你可以使用这个在线表格（https://wiki.python.org/moin/WebFrameworks）来对比这些框架。

如果你的网站使用的是关系数据库，就可以不使用大型框架，直接用 bottle、flask 这类框架结合关系数据库模块就行。也可以使用 SQLAlchemy 来屏蔽数据库的差异，直接写通用 SQL 代码就行。相比特定的 ORM 语法，大多数程序员更熟悉 SQL。

当然，你完全可以不使用关系数据库。如果你的数据结构差异很大——不同行的同一列差别很大——那你或许应该试试无模式数据库，比如 8.5 节讨论过的 NoSQL 数据库。我之前开发的一个网站一开始使用 NoSQL 数据库来存储数据，后来切换到一个关系数据库，然后又切换到另一个关系数据库，接着又切换到一个 NoSQL 数据库，最后又切换回了一个关系数据库。

其他Python Web服务器

下面是一些类似 apache 和 nginx 的基于 Python 的 WSGI 服务器，使用多进程和 / 或线程（参见 11.1 节）来处理并发请求：

uwsgi（http://projects.unbit.it/uwsgi/）

cherrypy（http://www.cherrypy.org/）

pylons（http://www.pylonsproject.org/）

下面是一些基于事件的服务器，只使用单线程但不会阻塞：

tornado（http://www.tornadoweb.org）

gevent（http://gevent.org/）

gunicorn（http://gunicorn.org/）

第 11 章关于“并发”的讨论会详细介绍事件。

9.3　Web服务和自动化

我们已经看过了传统的 Web 客户端和服务器应用，它们会生成并使用 HTML 页面。然而，Web 逐渐演变出了许多非 HTML 的数据传输格式。

9.3.1　webbrowser模块

首先来看点好玩的。在终端里启动 Python 会话并输入下面的代码：

>>> import antigravity

这会调用标准库的 webbrowser 模块并让你的浏览器显示一个 Python 入门网页 4。

4如果你因为某些原因看不到该网页，访问 xkcd（http://xkcd.com/353/）。

你也可以直接使用这个模块。下面的程序会在浏览器中打开 Python 官网的首页：

>>> import webbrowser
>>> url = 'http://www.python.org/'
>>> webbrowser.open(url)
True

下面的代码会在新窗口中打开它：

>>> webbrowser.open_new(url)
True

如果你的浏览器支持标签，下面的代码会在新标签中打开它：

>>> webbrowser.open_new_tab('http://www.python.org/')
True

webbrowser 可以完全控制你的浏览器。

9.3.2　Web API和表述性状态传递

通常来说，数据只存在于网页内。如果你想获取数据，需要在 Web 浏览器中访问网页并阅读数据。如果网站作者在你最后一次访问之后做了什么改动，数据的位置和格式就可能发生变化。

除了发布网页，你还可以通过应用编程接口（API）来提供数据。客户端通过 URL 来访问你的服务并从响应中获取状态和数据。数据并不是 HTML 网页，而是更容易被程序处理的格式，比如 JSON 和 XML（第 8 章中对这些格式有详细的介绍）。

表述性状态传递（REST）是 Roy Fielding 在他的博士论文中提出的概念。许多产品都宣称它们具备 REST 接口或者是 RESTful 接口。在具体实现上，其实就是一个 Web 接口，即定义一组可以访问 Web 服务的 URL。

RESTful 服务会用特定的方式来使用 HTTP 动词，如下所示。

HEAD

获取资源的信息，但是不包括数据。

GET

顾名思义，GET 会从服务器取回资源的数据。这是浏览器使用的标准方法。如果你在 URL 中看到一个问号（?）之后跟着一堆参数，那就是一个 GET 请求。GET 不应该被用来创建、修改或者删除数据。

POST

这个动词会更新服务器上的数据。通常它会被用在 HTML 的表单和 Web API 中。

PUT

这个动词会创建一个新资源。

DELETE

顾名思义，这个动词会删除一些东西，就像广告中的真话一样 5 ！

5意思是非常少见。——译者注

RESTful 客户端也可以使用 HTTP 请求头来请求一种或多种类型的内容，例如一个具备 REST 接口的复杂服务可能更希望输入和输出是 JSON 字符串。

9.3.3　JSON

第 1 章展示的两个 Python 例子获取了最热门的 YouTube 视频信息。第 8 章介绍了 JSON。JSON 非常适合用在 Web 客户端和服务器的数据交换中。它在基于 Web 的 API 中非常流行，比如 OpenStack。

9.3.4　抓取数据

有时候你可能想要更多信息——电影排名、股票价格或者商品供应信息——但是这些信息只存在于 HTML 页面中，包裹在广告和其他无关的信息中。

你可以使用下面的方法来手动提取信息：

(1) 在浏览器中输入 URL；

(2) 等待页面加载；

(3) 浏览页面并找到你想要的信息；

(4) 把信息记录下来；

(5) 可能要把这个过程重复应用在所有相关 URL 上。

然而，我们更希望能自动执行其中的一步或者多步。自动抓取 Web 信息的程序叫作爬行者或者爬虫（对于蜘蛛恐惧者来说毫无吸引力）。从远端服务器获取到信息之后爬虫会进行解析并寻找有用的信息。

如果你需要一个企业级的爬虫，那强烈推荐 Scrapy（http://scrapy.org/）：

$ pip install scrapy

Scrapy 是一个框架，并不是类似 BeautifulSoup 的模块。它会做更多事，不过也更难设置。更多关于 Scrapy 的信息请阅读文档（http://scrapy.org）或者在线教程（http://amaral-lab.org/blog/quick-introduction-web-crawling-using-scrapy-part-）。

9.3.5　用BeautifulSoup来抓取HTML

如果你已经拿到了一个网站的 HTML 数据并且想从中提取数据，BeautifulSoup 是一个不错的选择。解析 HTML 比想象中要难得多，因为互联网上的 HTML 在技术角度通常是不合法的：没有闭合的标签、不正确的嵌套以及其他复杂的东西。如果你自己尝试过用正则表达式（第 7 章介绍过）来解析 HTML，你一定遇到过这些麻烦事。

输入下面的命令来安装 BeautifulSoup（千万别忘了结尾的 4，否则 pip 会试着安装旧版并且可能会安装失败）：

$ pip install beautifulsoup4

现在可以用它来尝试一下获取一个网页上的所有链接。HTML 的 a 元素表示一个链接，它的 href 属性表示链接的目标地址。下面的例子会定义函数 get_links() 并用它来完成任务。程序可以从命令行参数接收一个或多个 URL：

def get_links(url):
    import requests
    from bs4 import BeautifulSoup as soup
    result = requests.get(url)
    page = result.text
    doc = soup(page)
    links = [element.get('href') for element in doc.find_all('a')]
    return links

if __name__ == '__main__':
    import sys
    for url in sys.argv[1:]:
        print('Links in', url)
        for num, link in enumerate(get_links(url), start=1):
            print(num, link)
        print()

我把这个程序存储为 links.py 并运行下面的命令：

$ python links.py http://boingboing.net

下面是一部分输出：

Links in http://boingboing.net/
1 http://boingboing.net/suggest.html
2 http://boingboing.net/category/feature/
3 http://boingboing.net/category/review/
4 http://boingboing.net/category/podcasts
5 http://boingboing.net/category/video/
6 http://bbs.boingboing.net/
7 javascript:void(0)
8 http://shop.boingboing.net/
9 http://boingboing.net/about
10 http://boingboing.net/contact

9.4　练习

(1) 如果你还没有安装 flask，现在安装它。这样会自动安装 werkzeug、jinja2 和其他包。

(2) 搭建一个网站框架，使用 Flask 的调试 / 代码重载来开发 Web 服务器。使用主机名 localhost 和默认端口 5000 来启动服务器。如果你电脑的 5000 端口已经被占用，使用其他端口。

(3) 添加一个 home() 函数来处理对于主页的请求，让它返回字符串 It's alive!。

(4) 创建一个名为 home.html 的 Jinja2 模板文件，内容如下所示：

<html>
<head>
<title>It's alive!</title>
<body>
I'm of course referring to {{thing}}, which is {{height}} feet tall and {{color}}.
</body>
</html>

(5) 修改 home() 函数，让它使用 home.html 模板。给模板传入三个 GET 参数：thing、height 和 color。

第 10 章　系统

“被密封在一个仓库的纸箱中是一件计算机能做但是大多数人无法做到的事。”

——Jack Handey

在你日常使用计算机时，经常需要列出一个文件夹或者目录的内容，创建和删除文件，以及做其他一些比较无聊但是不得不做的“家务活”。在 Python 程序中可以做到同样的事，甚至能做更多的事。这些功能是否能减少你的工作量呢？我们拭目以待。

Python 在模块 os（操作系统，operating system）中提供了许多系统函数，本章的所有程序都需要导入这个模块。

10.1　文件

和其他语言一样，Python 的文件操作很像 Unix。有些函数的名字相同，比如 chown() 和 chmod()，不过也有很多新函数。

10.1.1　用open()创建文件

8.1 节介绍了如何使用 open() 函数来打开文件或者创建文件。下面来创建一个名为 oops.txt 的文本文件：

>>> fout = open('oops.txt', 'wt')
>>> print('Oops, I created a file.', file=fout)
>>> fout.close()

我们用这个文件来进行一些测试。

10.1.2　用exists()检查文件是否存在

要判断文件或者目录是否存在，可以使用 exists()，传入相对或者绝对路径名，如下所示：

>>> import os
>>> os.path.exists('oops.txt')
True
>>> os.path.exists('./oops.txt')
True
>>> os.path.exists('waffles')
False
>>> os.path.exists('.')
True
>>> os.path.exists('..')
True

10.1.3　用isfile()检查是否为文件

本节中的函数可以检查一个名称是文件、目录还是符号链接（详情见下方的例子）。

我们要学习的第一个函数是 isfile，它只回答一个问题：这个是不是文件？

>>> name = 'oops.txt'
>>> os.path.isfile(name)
True

下面是判断目录的方法：

>>> os.path.isdir(name)
False

一个点号（.）表示当前目录，两个点号（..）表示上层目录。它们一直存在，所以下面的语句总会返回 True：

>>> os.path.isdir('.')
True

os 模块中有许多处理路径名（完整的文件名，由 / 开始并包含所有上级目录）的函数。其中之一是 isabs()，可以判断参数是否是一个绝对路径名。参数不需要是一个真正的文件：

>>> os.path.isabs(name)
False
>>> os.path.isabs('/big/fake/name')
True
>>> os.path.isabs('big/fake/name/without/a/leading/slash')
False

10.1.4　用copy()复制文件

copy() 函数来自于另一个模块 shutil。下面的例子会把文件 oops.txt 复制到文件 ohno.txt：

>>> import shutil
>>> shutil.copy('oops.txt', 'ohno.txt')

shutil.move() 函数会复制一个文件并删除原始文件。

10.1.5　用rename()重命名文件

这个函数的作用看名字就知道了。下面的例子会把 ohno.txt 文件重命名为 ohwell.txt 文件：

>>> import os
>>> os.rename('ohno.txt', 'ohwell.txt')

10.1.6　用link()或者symlink()创建链接

在 Unix 中，文件只存在于一个位置，但是可以有多个名称，这种机制叫作链接。在更底层的硬链接中，找到一个文件的所有名称并不容易。可以考虑使用符号链接，它会把新名称当作文件存储，这样一次就可以获取到原始名称和新名称。link() 会创建一个硬链接。symlink() 会创建一个符号链接。islink() 函数会检查参数是文件还是符号链接。

下面这个例子展示了如何把已有文件 oops.txt 硬链接到一个新文件 yikes.txt：

>>> os.link('oops.txt', 'yikes.txt')
>>> os.path.isfile('yikes.txt')
True

下面的例子展示了如何把已有文件 oops.txt 符号链接到一个新文件 jeepers.txt：

>>> os.path.islink('yikes.txt')
False
>>> os.symlink('oops.txt', 'jeepers.txt')
>>> os.path.islink('jeepers.txt')
True

10.1.7　用chmod()修改权限

在 Unix 系统中，chmod() 可以修改文件权限。可以设置用户（如果是你创建了文件，那用户通常就是你）、当前用户所在用户组以及其他所有用户组的读、写和执行权限。这个命令接收一个压缩过的八进制（基数为 8）值，这个值包含用户、用户组和权限。举例来说，下面的命令可以让 oops.txt 只能被拥有者读：

>>> os.chmod('oops.txt', 0o400)

如果你不想用压缩过的八进制值并且更喜欢那些（有点）难懂的符号，可以从 stat 模块中导入一些常量并用在语句中：

>>> import stat
>>> os.chmod('oops.txt', stat.S_IRUSR

10.1.8　用chown()修改所有者

这个函数也是 Unix/Linux/Mac 特有的。你可以指定用户的 ID（uid）和用户组 ID（gid）来修改文件的所有者和 / 或所有用户组：

>>> uid = 5
>>> gid = 22
>>> os.chown('oops', uid, gid)

10.1.9　用abspath()获取路径名

这个函数会把一个相对路径名扩展成绝对路径名。如果你的当前目录是 /usr/gabenlunzie，其中有文件 oops.txt，那你可以用这个函数得到下面的输出：

>>> os.path.abspath('oops.txt')
'/usr/gaberlunzie/oops.txt'

10.1.10　用realpath()获取符号的路径名

在之前的几节中，我们创建了一个 oops.txt 到 jeepers.txt 的符号链接。在这种情况下，你可以使用 realpath() 函数从 jeepers.txt 获取名称 oops.txt，如下所示：

>>> os.path.realpath('jeepers.txt')
'/usr/gaberlunzie/oops.txt'

10.1.11　用remove()删除文件

下面的例子使用 remove() 函数来删除 oops.txt 文件：

>>> os.remove('oops.txt')
>>> os.path.exists('oops.txt')
False

10.2　目录

在大多数操作系统中，文件被存储在多级目录（现在经常被称为文件夹）中。包含所有这些文件和目录的容器是文件系统（有时候被称为卷）。标准模块 os 可以处理这些东西，下面是一些可以使用的函数。

10.2.1　使用mkdir()创建目录

下面的例子展示了如何创建目录 poems：

>>> os.mkdir('poems')
>>> os.path.exists('poems')
True

10.2.2　使用rmdir()删除目录

假如你现在发现不需要这个目录，可以用这个函数来删除目录：

>>> os.rmdir('poems')
>>> os.path.exists('poems')
False

10.2.3　使用listdir()列出目录内容

你又后悔了，来重新创建 poems 并加入一些内容：

>>> os.mkdir('poems')

现在列出它的内容（目前还是空）：

>>> os.listdir('poems')
[]

接着创建一个子目录：

>>> os.mkdir('poems/mcintyre')
>>> os.listdir('poems')
['mcintyre']

在这个子目录中，创建一个文件（不要手动输入这些文字，除非你真的很喜欢这首诗；一定要确保文中的单引号和三引号可以正确匹配）：

>>> fout = open('poems/mcintyre/the_good_man', 'wt')
>>> fout.write('''Cheerful and happy was his mood,
... He to the poor was kind and good,
... And he oft' times did find them food,
... Also supplies of coal and wood,
... He never spake a word was rude,
... And cheer'd those did o'er sorrows brood,
... He passed away not understood,
... Because no poet in his lays
... Had penned a sonnet in his praise,
... 'Tis sad, but such is world's ways.
... ''')
344
>>> fout.close()

最后来看看目录中有什么：

>>> os.listdir('poems/mcintyre')
['the_good_man']

10.2.4　使用chdir()修改当前目录

你可以使用这个函数从一个目录跳转到另一个目录。我们从当前目录跳转到 poems 目录中：

>>> import os
>>> os.chdir('poems')
>>> os.listdir('.')
['mcintyre']

10.2.5　使用glob()列出匹配文件

glob() 函数会使用 Unix shell 的规则来匹配文件或者目录，而不是更复杂的正则表达式。具体规则如下所示：

* 会匹配任意名称（re 中是 .*）

? 会匹配一个字符

[abc] 会匹配字符 a、b 和 c

[!abc] 会匹配除了 a、b 和 c 之外的所有字符

试着获取所有以 m 开头的文件和目录：

>>> import glob
>>> glob.glob('m*')
['mcintyre']

获取所有名称为两个字符的文件和目录：

>>> glob.glob('??')
[]

获取名称为 8 个字符并且以 m 开头和以 e 结尾的文件和目录：

>>> glob.glob('m??????e')
['mcintyre']

获取所有以 k、l 或者 m 开头并且以 e 结尾的文件和目录：

>>> glob.glob('[klm]*e')
['mcintyre']

10.3　程序和进程

当运行一个程序时，操作系统会创建一个进程。它会使用系统资源（CPU、内存和磁盘空间）和操作系统内核中的数据结构（文件、网络连接、用量统计等）。进程之间是互相隔离的，即一个进程既无法访问其他进程的内容，也无法操作其他进程。

操作系统会跟踪所有正在运行的进程，给每个进程一小段运行时间，然后切换到其他进程，这样既可以做到公平又可以响应用户操作。你可以在图形界面中查看进程状态，在 Mac OS X 上可以使用活动监视器，在 Windows 上可以使用任务管理器。

你也可以自己编写程序来获取进程信息。标准库模块 os 提供了一些常用的获取系统信息的函数。举例来说，下面的函数会获取正在运行的 Python 解释器的进程号和当前工作目录：

>>> import os
>>> os.getpid()
76051
>>> os.getcwd()
'/Users/williamlubanovic'

下面的函数会获取我的用户 ID 和用户组 ID：

>>> os.getuid()
501
>>> os.getgid()
20

10.3.1　使用subprocess创建进程

到目前为止，你看到的所有程序都是单进程程序。你可以使用 Python 标准库中的 subprocess 模块来启动和终止其他程序。如果只是想在 shell 中运行其他程序并获取它的输出（标准输出和标准错误输出），可以使用 getoutput() 函数。这里获取了 Unix date 程序的输出：

>>> import subprocess
>>> ret = subprocess.getoutput('date')
>>> ret
'Sun Mar 30 22:54:37 CDT 2014'

在进程执行完毕之前，你获取不到任何内容。如果需要调用一些比较耗时的程序，可以使用 11.1 节提到的并发。因为 getoutput() 的参数是一个字符串，可以表示一个完整的 shell 命令，所以你可以在里面使用参数、管道、I/O 重定向 < 和 >，等等：

>>> ret = subprocess.getoutput('date -u')
>>> ret
'Mon Mar 31 03:55:01 UTC 2014'

把这个输出用管道传给 wc 命令，可以计算出一共有 1 行、6 个单词和 29 个字符：

>>> ret = subprocess.getoutput('date -u | wc')
>>> ret
'       1       6      29'

另一个类似的方法是 check_output()，可以接受一个命令和参数列表。默认情况下，它返回的不是字符串，而是字节类型的标准输出。此外，这个函数并没有使用 shell：

>>> ret = subprocess.check_output(['date', '-u'])
>>> ret
b'Mon Mar 31 04:01:50 UTC 2014\n'

如果要获取其他程序的退出状态，可以使用 getstatusoutput() 函数，它会返回一个包含状态码和输出的元组：

>>> ret = subprocess.getstatusoutput('date')
>>> ret
(0, 'Sat Jan 18 21:36:23 CST 2014')

如果只想要退出状态，可以使用 call()：

>>> ret = subprocess.call('date')
Sat Jan 18 21:33:11 CST 2014
>>> ret
0

（在 Unix 类操作系统中，退出状态 0 通常表示运行成功。）

本例中，日期和时间被打印到输出中，但是并没有被我们的程序获取，所以 ret 中只有状态码。

你可以用两种方式来运行带参数的程序。第一种是在字符串中写明参数。我们的示例中使用的是 date -u，这会打印出 UTC 格式的当前日期和时间（稍后会有 UTC 的解释）：

>>> ret = subprocess.call('date -u', shell=True)
Tue Jan 21 04:40:04 UTC 2014

需要加上参数 shell=True，这样函数就会用 shell 来执行命令，shell 会把 date -u 分割成单独的字符串并对通配符（比如 *，我们的例子中没有使用它）进行扩展。

第二种方式是传入一个参数列表，这样函数就不需要调用 shell：

>>> ret = subprocess.call(['date', '-u'])
Tue Jan 21 04:41:59 UTC 2014

10.3.2　使用multiprocessing创建进程

你可以在一个单独的进程中运行一个 Python 函数，也可以使用 multiprocessing 模块在一个程序中运行多个进程。下面的例子做了一些很无聊的事，把它存储为 mp.py，然后用 python mp.py 运行它：

import multiprocessing
import os

def do_this(what):
    whoami(what)

def whoami(what):
    print("Process %s says: %s" % (os.getpid(), what))

if __name__ == "__main__":
    whoami("I'm the main program")
    for n in range(4):
        p = multiprocessing.Process(target=do_this,
          args=("I'm function %s" % n,))
        p.start()

运行时得到如下输出：

Process 6224 says: I'm the main program
Process 6225 says: I'm function 0
Process 6226 says: I'm function 1
Process 6227 says: I'm function 2
Process 6228 says: I'm function 3

Process() 函数会创建一个新进程来运行 do_this() 函数。由于我们在一个循环中执行它，所以生成了 4 个执行 do_this() 的进程并在执行完毕后退出。

multiprocessing 模块真正的功能比这个例子中所展示的要强大得多。当你需要用多进程来减少运行时间时，它非常有用，比如下载需要抓取的网页、调整图片尺寸等。它支持任务队列和进程间通信，而且可以等待所有进程执行完毕。11.1 节会详细讲解。

10.3.3　使用terminate()终止进程

如果创建了一个或者多个进程并且想终止它们（可能是因为进入了死循环，也可能是因为你觉得很无聊，还有可能是因为你只是想干坏事），可以使用 terminate()。下面的例子中，我们的进程会一直计数到百万，每次计数之后都会等待 1 秒并打印出相关信息。然而，主程序只会保持 5 秒耐心，之后就会终止进程：

import multiprocessing
import time
import os

def whoami(name):
    print("I'm %s, in process %s" % (name, os.getpid()))

def loopy(name):
    whoami(name)
    start = 1
    stop = 1000000
    for num in range(start, stop):
        print("\tNumber %s of %s. Honk!" % (num, stop))
        time.sleep(1)

if __name__ == "__main__":
    whoami("main")
    p = multiprocessing.Process(target=loopy, args=("loopy",))
    p.start()
    time.sleep(5)
    p.terminate()

运行程序时得到如下输出：

I'm main, in process 97080
I'm loopy, in process 97081
    Number 1 of 1000000. Honk!
    Number 2 of 1000000. Honk!
    Number 3 of 1000000. Honk!
    Number 4 of 1000000. Honk!
    Number 5 of 1000000. Honk!

10.4　日期和时间

程序员们需要花费大量时间来处理日期和时间。我们会讨论一些常见的问题，之后会介绍一些对应的最佳实践和能够帮助缓解问题的小技巧。

可以用多种方式来表示日期，甚至多到让人厌烦。即使是使用罗马历的英语国家也有很多表示日期的方法：

July 29 1984

29 Jul 1984

29/7/1984

7/29/1984

表示日期的第一个问题就是二义性。从上面的例子可以很容易看出，7 表示月份，29 表示天数，因为月份不可能是 29。但是 1/6/2012 呢？它到底是 1 月 6 日还是 6 月 1 日呢？

罗马历中，月份的名字在不同语言中差别很大。在其他文化中，年份和月份本身的定义都有可能不一样。

闰年是另一个问题。你可能知道，每隔 4 年会出现一个闰年（夏季奥运会和美国总统选举也是隔四年一次）。不过你知道吗，年份是整百数时，必须是 400 的倍数才是闰年。下面的代码可以检测是否是闰年：

>>> import calendar
>>> calendar.isleap(1900)
False
>>> calendar.isleap(1996)
True
>>> calendar.isleap(1999)
False
>>> calendar.isleap(2000)
True
>>> calendar.isleap(2002)
False
>>> calendar.isleap(2004)
True

时间则有另外的问题，主要是因为时区以及夏时制。观察时区图会发现，时区是按照政治和历史因素分割的，并不是按照经度 15 度（360 度 /24）分割。不同国家每年夏时制的开始和结束时间也不一样。实际上，南半球和北半球的夏时制时间段刚好相反（细想就知道原因了）。

Python 的标准库中有很多日期和时间模块：datetime、time、calendar、dateutil，等等。有些在功能上有重复，还有点不好理解。

10.4.1　datetime模块

首先介绍一下标准 datetime 模块。它定义了 4 个主要的对象，每个对象都有很多方法：

date 处理年、月、日

time 处理时、分、秒和微秒

datetime 处理日期和时间同时出现的情况

timedelta 处理日期和 / 或时间间隔

你可以指定年、月、日，来创建一个 date 对象。这些值之后会变成对象的属性：

>>> from datetime import date
>>> halloween = date(2014, 10, 31)
>>> halloween
datetime.date(2014, 10, 31)
>>> halloween.day
31
>>> halloween.month
10
>>> halloween.year
2014

可以使用 isoformat() 方法打印一个 date 对象：

>>> halloween.isoformat()
'2014-10-31'

iso 是指 ISO 8601，一种表示日期和时间的国际标准。这个标准的显示顺序是从一般（年）到特殊（日）。它也可以对日期进行正确的排序：先按照年，然后是月，最后是日。我经常在程序中使用这种格式表示日期，还会在存储和日期相关的数据时当作文件名。下一节会介绍更复杂的 strptime() 和 strftime() 方法，它们分别用来解析和格式化日期。

下面的例子使用 today() 方法生成今天的日期：

>>> from datetime import date
>>> now = date.today()
>>> now
datetime.date(2014, 2, 2)

下面的例子使用 timedelta 对象来实现 date 的加法：

>>> from datetime import timedelta
>>> one_day = timedelta(days=1)
>>> tomorrow = now + one_day
>>> tomorrow
datetime.date(2014, 2, 3)
>>> now + 17*one_day
datetime.date(2014, 2, 19)
>>> yesterday = now - one_day
>>> yesterday
datetime.date(2014, 2, 1)

date 的范围 是 date.min（ 年 = 1， 月 = 1， 日 = 1） 到 date.max（ 年 = 9999， 月 = 12， 日 = 31）。因此，不能使用它来进行和历史或者天文相关的计算。

datetime 模块中的 time 对象用来表示一天中的时间：

>>> from datetime import time
>>> noon = time(12, 0, 0)
>>> noon
datetime.time(12, 0)
>>> noon.hour
12
>>> noon.minute
0
>>> noon.second
0
>>> noon.microsecond
0

参数的顺序是按照时间单位从大（时）到小（微秒）排列。如果没有参数，time 会默认全部使用 0。顺便说一句，能够存取微秒并不意味着你能从计算机中得到准确的微秒。每秒的准确度取决于硬件和操作系统中的很多因素。

datetime 对象既包含日期也包含时间。你可以直接创建一个 datetime 对象，如下所示，表示 2014 年 1 月 2 日上午 3:04，5 秒 6 微秒：

>>> from datetime import datetime
>>> some_day = datetime(2014, 1, 2, 3, 4, 5, 6)
>>> some_day
datetime.datetime(2014, 1, 2, 3, 4, 5, 6)

datetime 对象也有一个 isoformat() 方法：

>>> some_day.isoformat()
'2014-01-02T03:04:05.000006'

中间的 T 把日期和时间分割开。

datetime 有一个 now() 方法，可以用它获取当前日期和时间：

>>> from datetime import datetime
>>> now = datetime.now()
>>> now
datetime.datetime(2014, 2, 2, 23, 15, 34, 694988)
14
>>> now.month
2
>>> now.day
2
>>> now.hour
23
>>> now.minute
15
>>> now.second
34
>>> now.microsecond
694988

可以使用 combine() 方法把一个 date 对象和一个 time 对象合并成一个 datetime 对象：

>>> from datetime import datetime, time, date
>>> noon = time(12)
>>> this_day = date.today()
>>> noon_today = datetime.combine(this_day, noon)
>>> noon_today
datetime.datetime(2014, 2, 2, 12, 0)

也可以使用 date() 和 time() 方法从 datetime 中取出 date 和 time 部分：

>>> noon_today.date()
datetime.date(2014, 2, 2)
>>> noon_today.time()
datetime.time(12, 0)

10.4.2　使用time模块

Python 的 datetime 模块中有一个 time 对象，Python 还有一个单独的 time 模块，这有点令人困扰。此外，time 模块还有一个函数叫作 time()。

一种表示绝对时间的方法是计算从某个起始点开始的秒数。Unix 时间使用的是从 1970 年 1 月 1 日 0 点开始的秒数 1。这个值通常被称为纪元，它是不同系统之间最简单的交换日期时间的方法。

1开始时间大约与 Unix 出现的时间吻合。

time 模块的 time() 函数会返回当前时间的纪元值：

>>> import time
>>> now = time.time()
>>> now
1391488263.664645

数一下位数，从 1970 年新年 2 到现在已经过去了超过十亿秒。时间都去哪儿了？

2美国新年是中国的元旦。——译者注

可以用 ctime() 把一个纪元值转换成一个字符串：

>>> time.ctime(now)
'Mon Feb  3 22:31:03 2014'

下一节会介绍如何生成其他格式的日期和时间。

纪元值是不同系统（比如 JavaScript）之间交换日期和时间的一种非常有用的方法。但是，有时候想要真正的日期而不是一串数字，这时可以使用 struct_time 对象。localtime() 会返回当前系统时区下的时间，gmtime() 会返回 UTC 时间：

>>> time.localtime(now)
time.struct_time(tm_year=2014, tm_mon=2, tm_mday=3, tm_hour=22, tm_min=31,
tm_sec=3, tm_wday=0, tm_yday=34, tm_isdst=0)
>>> time.gmtime(now)
time.struct_time(tm_year=2014, tm_mon=2, tm_mday=4, tm_hour=4, tm_min=31,
tm_sec=3, tm_wday=1, tm_yday=35, tm_isdst=0)

在我所处的时区（中央时区），22:31 是 UTC（正式称呼是格林威治时间或者祖鲁时间）第二天的 04:31。如果省略 localtime() 或者 gmtime() 的参数，默认会返回当前时间。

对应上面两个函数的是 mktime()，它会把 struct_time 对象转换回纪元值：

>>> tm = time.localtime(now)
>>> time.mktime(tm)
1391488263.0

这个值和之前 now() 返回的纪元值并不完全相同，因为 struct_time 对象只能精确到秒。

一些建议：尽量多使用 UTC 来代替时区。UTC 是绝对时间，和时区无关。如果你有服务器，把它的时间设置为 UTC，不要使用本地时间。

还有一些建议（都是免费的）：如果有可能，绝对不使用夏时制时间。如果使用了夏时制时间，那每年的一段时间（春季）会少一个小时，另一段时间（秋季）会多一个小时。出于某些原因，许多组织在它们的计算机系统中使用夏时制，但是每年都需要处理数据重复和丢失。说起来都是泪。

　别忘了，UTC 指时间，UTF-8 指字符串（更多关于 UTF-8 的内容参见第 7 章）。

10.4.3　读写日期和时间

isoformat() 并不是唯一一种可以打印日期和时间的方法。前面已经见过，time 模块中的 ctime() 函数可以把纪元转换成字符串：

>>> import time
>>> now = time.time()
>>> time.ctime(now)
'Mon Feb  3 21:14:36 2014'

也可以使用 strftime() 把日期和时间转换成字符串。这个方法在 datetime、date 和 time 对象中都有，在 time 模块中也有。strftime() 使用格式化字符串来指定输出，详见表 10-1。

表10-1：strftime()的格式化字符串

格式化字符串

日期/时间单元

范围

%Y

年

1900-...

%m

月

01-12

%B

月名

January,...

%b

月名缩写

Jan,...

%d

日

01-31

%A

星期

Sunday,...

%a

星期缩写

Sun,...

%H

时（24 小时制）

00-23

%I

时（12 小时制）

01-12

%p

上午 / 下午

AM, PM

%M

分

00-59

%S

秒

00-59

数字都是左侧补零。

下面是 time 模块中的 strftime() 函数。它会把 struct_time 对象转换成字符串。我们首先定义格式化字符串 fmt，然后使用它：

>>> import time
>>> fmt = "It's %A, %B %d, %Y, local time %I:%M:%S%p"
>>> t = time.localtime()
>>> t
time.struct_time(tm_year=2014, tm_mon=2, tm_mday=4, tm_hour=19,
tm_min=28, tm_sec=38, tm_wday=1, tm_yday=35, tm_isdst=0)
>>> time.strftime(fmt, t)
"It's Tuesday, February 04, 2014, local time 07:28:38PM"

如果使用 date 对象的 strftime() 函数，只能获取日期部分，时间默认是午夜：

>>> from datetime import date
>>> some_day = date(2014, 7, 4)
>>> fmt = "It's %B %d, %Y, local time %I:%M:%S%p"
>>> some_day.strftime(fmt)
"It's Friday, July 04, 2014, local time 12:00:00AM"

对于 time 对象，只会转换时间部分：

>>> from datetime import time
>>> some_time = time(10, 35)
>>> some_time.strftime(fmt)
"It's Monday, January 01, 1900, local time 10:35:00AM"

显然，你肯定不想使用 time 对象的日期部分，因为它们毫无意义。

如果要把字符串转换成日期或者时间，可以对同样的格式化字符串使用 strptime() 函数。

这里不能使用正则表达式，字符串的非格式化部分（没有 % 的部分）必须完全匹配。假设可以匹配格式“年 - 月 - 日”，比如 2012-01-29，如果目标字符串中用空格代替破折号，解析时会发生什么呢？

>>> import time
>>> fmt = "%Y-%m-%d"
>>> time.strptime("2012 01 29", fmt)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Library/Frameworks/Python.framework/Versions/3.3/lib/
  python3.3/_strptime.py", line 494, in _strptime_time
    tt = _strptime(data_string, format)[0]
  File "/Library/Frameworks/Python.framework/Versions/3.3/lib/
  python3.3/_strptime.py", line 337, in _strptime
    (data_string, format))
ValueError: time data '2012 01 29' does not match format '%Y-%m-%d'

如果传入 strptime() 的字符串中有破折号会怎么样呢？

>>> time.strptime("2012-01-29", fmt)
time.struct_time(tm_year=2012, tm_mon=1, tm_mday=29, tm_hour=0, tm_min=0,
tm_sec=0, tm_wday=6, tm_yday=29, tm_isdst=-1)

现在可以正常解析了。

即使字符串看起来可以匹配格式，但如果超出取值范围也会报错：

>>> time.strptime("2012-13-29", fmt)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Library/Frameworks/Python.framework/Versions/3.3/lib/
  python3.3/_strptime.py", line 494, in _strptime_time
    tt = _strptime(data_string, format)[0]
  File "/Library/Frameworks/Python.framework/Versions/3.3/lib/
  python3.3/_strptime.py", line 337, in _strptime
    (data_string, format))
ValueError: time data '2012-13-29' does not match format '%Y-%m-%d'

名称通过你的 locale 设置，这是操作系统中的国际化设置。如果要打印出不同的月和日名称，可以通过 setlocale() 来修改。这个函数的第一个参数是 locale.LC_TIME，表示设置的是日期和时间，第二个参数是一个结合了语言和国家名称的缩写字符串。假设我们邀请了一些国际友人来参加万圣节派对，需要分别打印出美国英语、法语、德语、西班牙语和冰岛语中的月、日和具体的星期。（什么？你觉得冰岛人并不喜欢这样的派对？错！他们甚至有真正的精灵。）

>>> import locale
>>> from datetime import date
>>> halloween = date(2014, 10, 31)
>>> for lang_country in ['en_us', 'fr_fr', 'de_de', 'es_es', 'is_is',]:
...     locale.setlocale(locale.LC_TIME, lang_country)
...     halloween.strftime('%A, %B %d')
...
'en_us'
'Friday, October 31'
'fr_fr'
'Vendredi, octobre 31'
'de_de'
'Freitag, Oktober 31'
'es_es'
'viernes, octubre 31'
'is_is'
'föstudagur, október 31'
>>>

去哪儿找这些神奇的 lang_country 值呢？虽然不是特别靠谱，不过可以用下面的方式来获取所有值（有好几百种）：

>>> import locale
>>> names = locale.locale_alias.keys()

我们从 names 中过滤出可以用在 setlocale() 中的名称，它们的格式和之前例子中的类似，两个字符构成的语言代码（http://en.wikipedia.org/wiki/List_of_ISO_639-1_codes）加一个下划线和两个字符构成的国家代码（http://en.wikipedia.org/wiki/ISO_3166-1_alpha-2）：

>>> good_names = [name for name in names if \
len(name) == 5 and name[2] == '_']

看看前 5 个是什么？

>>> good_names[:5]
['sr_cs', 'de_at', 'nl_nl', 'es_ni', 'sp_yu']

如果想获取所有的德国语言，试试这个：

>>> de = [name for name in good_names if name.startswith('de')]
>>> de
['de_at', 'de_de', 'de_ch', 'de_lu', 'de_be']

10.4.4　其他模块

如果你觉得标准库模块不好用或者没有你想要的功能，可以试试下面这些第三方模块。

arrow（http://crsmithdev.com/arrow/）

这个模块有许多日期和时间函数，并提供了简单易用的 API。

dateutil（http://labix.org/python-dateutil）

这个模块可以解析绝大多数日期格式，并且可以很好地处理相对日期和时间。

iso8601（https://pypi.python.org/pypi/iso8601）

这个模块会完善标准库中对于 ISO8601 格式的处理。

fleming（https://github.com/ambitioninc/fleming）

这个模块提供了许多时区函数。

10.5　练习

(1) 把当前日期以字符串形式写入文本文件 today.txt。

(2) 从 today.txt 中读取字符串到 today_string 中。

(3) 从 today_string 中解析日期。

(4) 列出当前目录下的文件。

(5) 列出父目录下的文件。

(6) 使用 multiprocessing 创建三个进程，让它们等待随机的秒数（范围 1~5），打印出当前时间并退出。

(7) 用你的生日创建一个 date 对象。

(8) 你的生日是星期几？

(9) 你出生 10 000 天的日期是什么？

第 11 章　并发和网络

“时间是大自然防止所有事情同时发生的一种方法。空间是防止所有事情都发生在我身上的方法。”

——关于时间的名言（http://en.wikiquote.org/wiki/Talk:Time）

到目前为止，你写过的大多数程序都是在一个地方（一台机器）一次运行一行（顺序执行）。但是，我们可以在多个地方（分布式计算或者网络）同时做很多事（并发）。这样做有很多好处。

性能

你的目标是让高速部件不间断运行，不用等待慢速部件。

鲁棒性

数量可以提高安全性，可以同时执行多个相同任务，这样就不用担心出现硬件和软件错误。

简化

最佳实践是把复杂任务分解成许多小任务，这样更容易创建、理解和修复。

通信

发送数据并接收新数据本身就是一件非常有趣的事。

我们从并发开始，先用第 10 章中提到的非网络技术——进程和线程——构建一个程序。之后会介绍其他方法，比如回调、绿色线程和协程。最后会使用网络技术，仍然是从并发技术开始，逐步扩展到其他方面。

　在编写本书时，本章介绍的一些 Python 包还没有兼容 Python 3。大多数情况下的示例代码只能在 Python 2 的解释器中运行，这个解释器我们称之为 python2。

11.1　并发

Python 官网基于标准库（https://docs.python.org/3/library/concurrency.html）介绍了常见的并发技术。页面中提到了很多包和技术，本章会介绍其中最有用的部分。

在计算机中，如果你的程序在等待，通常是因为以下两个原因。

I/O 限制

这个限制很常见。计算机的 CPU 速度非常快——比计算机内存快几百倍，比硬盘或者网络快几千倍。

CPU 限制

在处理数字运算任务时，比如科学计算或者图形计算，很容易遇到这个限制。

以下是和并发相关的两个术语。

同步

一件事接着一件事发生，就像送葬队伍一样。

异步

任务是互相独立的，就像派对参与者从不同的车上下来一样。

当你要用简单的系统和任务来处理现实中的问题时，迟早需要处理并发。假设你有一个网站，必须给用户很快地返回静态和动态网页。一秒是可以接受的，但是如果展示或者交互需要很长时间，用户就会失去耐心。谷歌和亚马逊的测试显示，页面加载速度降低一点就会导致流量大幅下降。

但是，如何处理需要长时间执行的任务呢，比如上传文件、改变图片大小或者查询数据库？显然无法用同步的 Web 客户端代码解决这个问题，因为同步就必然会产生等待。

在一台电脑中，如果你想尽快处理多个任务，就需要让它们互相独立。慢任务不应该阻塞其他任务。

10.3 节介绍了如何用多进程实现单机的并发工作。如果你需要改变图片的大小，Web 服务器代码可以调用一个独立、专用的图片处理进程，可以异步地并发执行。这样可以通过增加处理进程来横向扩展你的应用。

问题的关键是如何让这些进程并发地执行。任何共享控制或者状态管理都会导致瓶颈。另一个更大的问题是如何处理错误，因为并发计算比常规计算更难，更容易出现错误，因此成功的概率更低。

那么到底应该如何处理这些复杂的问题呢？我们来看一种优秀的多任务管理方法：队列。

11.1.1　队列

队列有点像列表：从一头添加事物，从另一头取出事物。这种队列被称为 FIFO（先进先出）。

假设你正在洗盘子，如果需要完成全部工作，需要洗每一个盘子、烘干并放好。你有很多种方法来完成这个任务。或许你会先洗第一个盘子，烘干并把它放好，之后用同样的方法来处理第二个盘子，以此类推。此外，你也可以执行批量操作，先洗完所有的盘子，再烘干所有的盘子，最后把它们都放好。这样做需要你有足够大的水池和烘干机来放置每一步积累的所有盘子。这些都是同步方法——一个工人，一次做一件事。

还有一种方法是再找一个或者两个帮手。如果你是洗盘子的人，可以把洗好的盘子递给烘干盘子的人，他再把烘干的盘子递给放置盘子的人。所有人都在自己的位置工作，这样会比你一个人要快很多。

然而，如果你洗盘子的速度比下一个人烘干的速度快怎么办？要么把湿盘子扔在地上，要么把它们堆在你和下一个人之间，或者一直闲着直到下一个人处理完之前的盘子。如果最后一个人比第二个人还慢，那第二个人要么把盘子扔在地上，要么把它们堆在两个人之间，要么就闲着。你有很多个工人，但总体来说，任务仍然是同步完成的，处理速度和最慢的工人速度是一样的。

俗话说：人多好办事（我总觉得这句话来自阿们宗派，因为它总会让我想到粮仓建筑）。增加工人可以更快地搭建粮仓或者洗盘子，前提是使用队列。

通常来说，队列用来传递消息，消息可以是任意类型的信息。在本例中，我们用队列来管理分布式任务，这种队列也称为工作队列或者任务队列。水池中的每个盘子都会发给一个闲置的洗盘子的人，他会洗盘子并把盘子传给第一个闲置的烘干盘子的人，他会烘干盘子并把盘子传给第一个闲置的放盘子的人。这个过程可以是同步的（工人等着处理盘子，处理完等着把盘子给下一个人），也可以是异步的（盘子堆在两个工人中间）。只要你有足够多的工人并且他们都能认真工作，完成速度会很快。

11.1.2　进程

可以用很多方法来实现队列。对单机来说，标准库中的 multiprocessing 模块（参见 10.3 节）有一个 Queue 函数。接下来模拟一个洗盘子的人和多个烘干进程（不用担心，之后会有人把这些盘子放好），我们使用一个中间队列 dish_queue。把下面的代码保存为 dishes.py：

import multiprocessing as mp

def washer(dishes, output):
    for dish in dishes:
        print('Washing', dish, 'dish')
        output.put(dish)

def dryer(input):
    while True:
        dish = input.get()
        print('Drying', dish, 'dish')
        input.task_done()

dish_queue = mp.JoinableQueue()
dryer_proc = mp.Process(target=dryer, args=(dish_queue,))
dryer_proc.daemon = True
dryer_proc.start()

dishes = ['salad', 'bread', 'entree', 'dessert']
washer(dishes, dish_queue)
dish_queue.join()

运行这个新程序：

$ python dishes.py
Washing salad dish
Washing bread dish
Washing entree dish
Washing dessert dish
Drying salad dish
Drying bread dish
Drying entree dish
Drying dessert dish

这个队列看起来很像一个简单的 Python 迭代器，会生成一系列盘子。这段代码实际上会启动几个独立的进程，洗盘子的人和烘干盘子的人会用它们来进行通信。我使用 JoinableQueue 和最后的 join() 方法让洗盘子的人知道，所有的盘子都已经烘干。multiprocessing 模块还有其他类型的队列，更多实例请参考文档（https://docs.python.org/3/library/multiprocessing.html）。

11.1.3　线程

线程运行在进程内部，可以访问进程的所有内容，有点像多重人格。multiprocessing 模块有一个兄弟模块 threading，后者用线程来代替进程（实际上，multiprocessing 是在 threading 之后设计出来的，基于进程来完成各种任务）。我们使用线程来重写一遍上面的进程示例：

import threading

def do_this(what):
    whoami(what)

def whoami(what):
    print("Thread %s says: %s" % (threading.current_thread(), what))

if __name__ == "__main__":
    whoami("I'm the main program")
    for n in range(4):
        p = threading.Thread(target=do_this,
          args=("I'm function %s" % n,))
        p.start()

运行后得到以下输出：

Thread <_MainThread(MainThread, started 140735207346960)> says: I'm the main program
Thread <Thread(Thread-1, started 4326629376)> says: I'm function 0
Thread <Thread(Thread-2, started 4342157312)> says: I'm function 1
Thread <Thread(Thread-3, started 4347412480)> says: I'm function 2
Thread <Thread(Thread-4, started 4342157312)> says: I'm function 3

可以使用线程来重写上面的盘子示例：

import threading, queue
import time

def washer(dishes, dish_queue):
    for dish in dishes:
        print ("Washing", dish)
        time.sleep(5)
        dish_queue.put(dish)

def dryer(dish_queue):
    while True:
        dish = dish_queue.get()
        print ("Drying", dish)
        time.sleep(10)
        dish_queue.task_done()

dish_queue = queue.Queue()
for n in range(2):
    dryer_thread = threading.Thread(target=dryer, args=(dish_queue,))
    dryer_thread.start()

dishes = ['salad', 'bread', 'entree', 'desert']
washer(dishes, dish_queue)
dish_queue.join()

multiprocessing 和 threading 的区别之一就是 threading 没有 terminate() 函数。很难终止一个正在运行的线程，因为这可能会引起代码和时空连续性上的各种问题。

线程可能会很危险。就像 C 和 C++ 这类语言中的手动内存管理一样，线程可能会引起很难寻找和处理的 bug。要使用线程，程序中的所有代码——以及程序使用的所有外部库中的代码——必须是线程安全的。在之前的示例代码中，线程之间没有共享任何全局变量，因此可以在没有副作用的情况下独立运行。

假设你是一个幽灵屋中的超自然现象调查员，幽灵在大厅中漫游，但是它们互相之间并不能感知到对方。此外，幽灵可以在任意时间浏览、添加、删除或者移动房间中的任意物品。

你一边看着令人惊讶的仪表读数，一边穿过整个房间。突然，你发现几秒钟之前刚看过的烛台不见了。

房间中的物品就像程序中的变量，幽灵是进程（房间）中的线程。如果幽灵只会浏览房间中的物品，就没有任何问题。就像线程只会读取常量或者变量中的值，但是不会修改它们。

然而，有些看不见的东西会抓住你的手电筒，往你的脖子上吹冷风，在大理石楼梯上一步一步地走，或者点燃壁炉。真正精明的幽灵甚至会在你看不到的房间中捣乱。

尽管你有很高端的设备，要找出是谁在什么时候做了什么改动仍然非常困难。

如果使用进程来代替线程，那就像有很多个房子但是每个房子里只有一个（活）人。如果你把白兰地放在壁炉前，一个小时后它还会在那儿。或许会蒸发一些，但是位置不变。

没有全局变量时，线程是非常有用并且安全的。通常来说，如果需要等待 I/O 操作完成，那么使用线程可以节省很多时间。在这种情况下，线程不会因为数据打架，因为每个线程使用的是完全独立的变量。

但是线程有时候确实需要修改全局变量。实际上，使用多线程的一个常见目的就是把需要处理的数据进行划分，这就不可避免地需要修改数据。

常见的安全共享数据的方法是让线程在修改变量之前加软件锁，这样在进行修改时其他线程都会等待。这就像在有幽灵的房子中有一个抓幽灵敢死队帮你看门。需要注意的是，千万别忘了解锁。此外，锁可以嵌套，就像你还有另一个抓幽灵敢死队来看同一个房间或者同一个房子。锁的用法非常传统但是要想用对非常困难。

　在 Python 中，线程不能加速受 CPU 限制的任务，原因是标准 Python 系统中使用了全局解释器锁（GIL）。GIL 的作用是避免 Python 解释器中的线程问题，但是实际上会让多线程程序运行速度比对应的单线程版本甚至是多进程版本更慢。

总而言之，对于 Python，建议如下：

使用线程来解决 I/O 限制问题；

使用进程、网络或者事件（下一节会介绍）来处理 CPU 限制问题。

11.1.4　绿色线程和gevent

如你所见，开发者通常会把程序中运行速度慢的部分划分为多个线程或者进程从而加快速度。Apache Web 服务器就是一个典型的例子。

另一种方法是基于事件编程。一个基于事件的程序会运行一个核心事件循环，分配所有任务，然后重复这个循环。nginx Web 服务器就是基于事件的设计，通常来说比 Apache 快。

gevent 就是一个基于事件的很棒的库：你只需要编写普通的代码，gevent 会神奇地把它们转换成协程。协程就像可以互相通信的生成器，它们会记录自己的位置。gevent 可以修改许多 Python 的标准对象，比如 socket，从而使用它自己的机制来代替阻塞。协程无法处理 C 写成的 Python 扩展代码，比如一些数据库驱动程序。

　在编写本书时，gevent 还不能完全兼容 Python 3，因此下面的示例代码使用的是 Python 2 的工具 pip2 和 python2。

可以使用 Python 2 版的 pip 来安装 gevent：

$ pip2 install gevent

下面是 gevent 官网示例代码（http://www.gevent.org/）的一个变体。11.2.7 节会介绍 socket 模块的 gethostbyname() 函数。这个函数是同步的，所以当它在全世界的名称服务器中寻找地址时，你必须等待（可能要好几秒）。但是，你可以使用 gevent 版本的代码来同时查询多个网站的地址。把下面的代码保存为 gevent_test.py：

import gevent
from gevent import socket
hosts = ['www.crappytaxidermy.com', 'www.walterpottertaxidermy.com',
    'www.antique-taxidermy.com']
jobs = [gevent.spawn(gevent.socket.gethostbyname, host) for host in hosts]
gevent.joinall(jobs, timeout=5)
for job in jobs:
    print(job.value)

这段代码中有一个只有一行的 for 循环。每个主机名都会被提交到一个 gethostbyname() 调用中，但是这些调用可以异步执行，因为使用的是 gevent 版本的 gethostbyname()。

使用下面的命令来用 Python 2 运行 gevent_test.py：

$ python2 gevent_test.py
66.6.44.4
74.125.142.121
78.136.12.50

gevent.spawn() 会为每个 gevent.socket.gethostbynome(url) 创建一个绿色线程（也叫微线程）。

绿色线程和普通线程的区别是前者不会阻塞。如果遇到会阻塞普通线程的情况，gevent 会把控制权切换到另一个绿色线程。

gevent.joinall() 方法会等待所有的任务完成。最后，我们会输出获得的所有 IP 地址。

除了使用 gevent 版本的 socket 之外，你也可以使用猴子补丁（monkey-patching）函数。这个函数会修改标准模块，比如 socket，直接让它们使用绿色线程而不是调用 gevent 版本。如果想在整个程序中应用 gevent，这种方法非常有用，即使那些你无法直接接触到的代码也会被改变。

在程序的开头，添加下面的代码：

from gevent import monkey
monkey.patch_socket()

这会把程序中所有的普通 socket 都修改成 gevent 版本，即使是标准库也不例外。再提醒一次，这个改动只对 Python 代码有效，对 C 写成的库无效。

另一个函数会给更多的标准库模块打上补丁：

from gevent import monkey
monkey.patch_all()

在程序开头加上这段代码可以让你的程序充分利用 gevent 带来的速度提升。

把下面的程序保存为 gevent_monkey.py：

import gevent
from gevent import monkey; monkey.patch_all()
import socket
hosts = ['www.crappytaxidermy.com', 'www.walterpottertaxidermy.com',
    'www.antique-taxidermy.com']
jobs = [gevent.spawn(socket.gethostbyname, host) for host in hosts]
gevent.joinall(jobs, timeout=5)
for job in jobs:
    print(job.value)

再使用 Python 2 运行程序：

$ python2 gevent_monkey.py
66.6.44.4
74.125.192.121
78.136.12.50

使用 gevent 还有一个潜在的危险。对于基于事件的系统来说，执行的每段代码都应该尽可能快。尽管不会阻塞，执行复杂任务的代码还是会很慢。

猴子补丁的理念对于很多人来说并不容易接受。但是，很多大型网站（比如 Pinterest）都在使用 gevent，对网站来说有明显的加速作用。就像一瓶外表精美的药丸一样，要用正确的方式使用 gevent。

　另外两个流行的事件驱动框架是 tornado（http://www.tornadoweb.org/en/stable/）和 gunicorn（http://gunicorn.org/）。它们都使用了底层事件处理和高速 Web 服务器。如果你想使用传统的 Web 服务器（比如 Apache）来构建高速网站，这两个框架非常值得一看。

11.1.5　twisted

twisted（http://twistedmatrix.com/trac/）是一个异步事件驱动的网络框架。你可以把函数关联到事件（比如数据接收或者连接关闭）上，当事件发生时这些函数会被调用。这种设计被称为回调（callback），如果你以前用过 JavaScript，那一定不会陌生。如果是第一次见到回调，可能会觉得它有点过时。对于有些开发者来说，基于回调的代码在应用规模变大之后会很难维护。

和 gevent 一样，twisted 还没有兼容 Python 3。本节会使用 Python 2 的安装器和解释器。使用下面的命令来安装 twisted：

$ pip2 install twisted

twisted 很大，支持很多基于 TCP 和 UDP 的互联网协议。出于教学目的，我们会展示一个简单的敲门服务器和客户端，由 twisted 示例（http://twistedmatrix.com/documents/current/core/examples/）修改而来。首先来看服务器，把代码保存到 knock_server.py（注意 Python 2 中 print() 的语法）：

from twisted.internet import protocol, reactor

class Knock(protocol.Protocol):
    def dataReceived(self, data):
        print 'Client:', data
        if data.startswith("Knock knock"):
            response = "Who's there?"
        else:
            response = data + " who?"
        print 'Server:', response
        self.transport.write(response)

class KnockFactory(protocol.Factory):
    def buildProtocol(self, addr):
        return Knock()

reactor.listenTCP(8000, KnockFactory())
reactor.run()

现在看看服务器的忠实伙伴，knock_client.py：

from twisted.internet import reactor, protocol

class KnockClient(protocol.Protocol):
    def connectionMade(self):
        self.transport.write("Knock knock")

    def dataReceived(self, data):
        if data.startswith("Who's there?"):
            response = "Disappearing client"
            self.transport.write(response)
        else:
            self.transport.loseConnection()
            reactor.stop()

class KnockFactory(protocol.ClientFactory):
    protocol = KnockClient

def main():
    f = KnockFactory()
    reactor.connectTCP("localhost", 8000, f)
    reactor.run()

if __name__ == '__main__':
    main()

先启动服务器：

$ python2 knock_server.py

然后启动客户端：

$ python2 knock_client.py

服务器和客户端会交换信息，服务器打印以下对话内容：

Client: Knock knock
Server: Who's there?
Client: Disappearing client
Server: Disappearing client who?

之后，我们的魔术师客户端结束了，只留服务器还在不断等待。

如果想了解更多关于 twisted 的内容，可以试试官方文档中的其他示例。

11.1.6　asyncio

最近，吉多 · 范 · 罗苏姆（还记得他是谁吗？）参与处理 Python 的并发问题。许多包有自己的事件循环，每种事件循环都想成为标准。他该如何调停回调、绿色线程以及其他并发方法呢？经过许多讨论和交流，他发布了异步 IO 支持重新启动：“asyncio”模块（http://legacy.python.org/dev/peps/pep-3156/），代号 Tulip。asyncio 模块在 Python 3.4 中首次出现。目前，它提供了一种通用的事件循环，可以兼容 twisted、gevent 和其他异步方法。目标是提供一种标准、简洁、高性能的异步 API。期待它在未来的 Python 发布版中不断发展。

11.1.7　Redis

我们之前的洗盘子示例代码，无论使用的是进程还是线程，都运行在一台机器上。下面我们使用另一种方法来实现队列，让它可以既支持单机又支持网络。有时候用了进程和线程，单机仍然无法满足需求。本章的目的就是帮助你从一个盒子（单机）过渡到多个并发的盒子。

要运行本章的示例，需要安装 Redis 服务器和它的 Python 模块。安装方法参见 8.5.3 节。第 8 章中，Redis 的角色是数据库，而这里指的是它的并发特性。

可以使用 Redis 列表来快速创建一个队列。Redis 服务器部署在一台机器上；客户端可以部署在同一台机器上也可以部署在不同机器上，通过网络通信。无论是哪种情况，客户端都是通过使用 TCP 和服务器通信，因此它们是网络化的。一个或多个生产者客户端向列表的一端压入消息，一个或多个工人客户端通过阻塞弹出操作从列表中获得需要洗的盘子。如果列表为空，它们就会闲置。如果有一条消息，第一个空闲工人就会去处理。

和之前的基于进程和线程的示例一样，redis_washer.py 会生成一个盘子序列：

import redis
conn = redis.Redis()
print('Washer is starting')
dishes = ['salad', 'bread', 'entree', 'dessert']
for dish in dishes:
    msg = dish.encode('utf-8')
    conn.rpush('dishes', msg)
    print('Washed', num)
conn.rpush('dishes', 'quit')
print('Washer is done')

循环会生成四个包含盘子名称的消息，最后一条消息是“退出”（quit）。程序会把所有消息都添加到 Redis 服务器上的 dishes 列表中，就像添加到 Python 列表一样。

当第一个盘子就绪之后，redis_dryer.py 就开始工作了：

import redis
conn = redis.Redis()
print('Dryer is starting')
while True:
    msg = conn.blpop('dishes')
    if not msg:
        break
    val = msg[1].decode('utf-8')
    if val == 'quit':
        break
    print('Dried', val)
print('Dishes are dried')

这段代码会等待第一个令牌是 dishes 的消息，并打印出烘干的盘子。如果遇到“退出”消息就终止循环。

启动烘干工人，然后启动清洗工人。在命令结尾加上 &，让第一个程序在后台运行；它会一直运行下去，但是不会监听键盘。尽管第二行的输出稍有不同，但是这个技巧在 Linux、OS X 和 Windows 上都有效。在本例中（OS X），第二行输出的是和后台烘干进程相关的信息。接着，我们正常（在前台）启动清洗进程。你可以看到两个进程混在一起的输出：

$ python redis_dryer.py &
[2] 81691
Dryer is starting
$ python redis_washer.py
Washer is starting
Washed salad
Dried salad
Washed bread
Dried bread
Washed entree
Dried entree
Washed dessert
Washer is done
Dried dessert
Dishes are dried
[2]+  Done                    python redis_dryer.py

只要盘子 ID 从清洗进程到达 Redis，我们勤劳的烘干进程就会取出它们。每个盘子 ID 都是一个数字，除了最后的哨兵值，它是字符串 'quit'。当烘干进程读取到盘子 ID quit 就会退出，后台进程的信息会打印到终端（具体内容在不同系统中同样有差别）。你可以使用一个哨兵（或者说一个非法值）在数据流中表示一些特殊的意义——本例的意义是完毕。如果不这样做，需要添加一些编程逻辑，如下所示：

提前设定好盘子的最大值，这也是一种哨兵；

进行特殊的带外（不在数据流中）进程间通信；

一定时间没有新数据就退出。

再进行一些最终的修改：

创建多个 dryer 进程；

除了等待哨兵，给每个烘干进程添加一个超时时间。

新的 redis_dryer2.py：

def dryer():
    import redis
    import os
    import time
    conn = redis.Redis()
    pid = os.getpid()
    timeout = 20
    print('Dryer process %s is starting' % pid)
    while True:
        msg = conn.blpop('dishes', timeout)
        if not msg:
            break
        val = msg[1].decode('utf-8')
        if val == 'quit':
            break
        print('%s: dried %s' % (pid, val))
        time.sleep(0.1)
    print('Dryer process %s is done' % pid)

import multiprocessing
DRYERS=3
for num in range(DRYERS):
    p = multiprocessing.Process(target=dryer)
    p.start()

在后台启动烘干进程，接着在前台启动清洗进程：

$ python redis_dryer2.py &
Dryer process 44447 is starting
Dryer process 44448 is starting
Dryer process 44446 is starting
$ python redis_washer.py
Washer is starting
Washed salad
44447: dried salad
Washed bread
44448: dried bread
Washed entree
44446: dried entree
Washed dessert
Washer is done
44447: dried dessert

一个烘干进程读取 quit ID 并退出：

Dryer process 44448 is done

20 秒后，其他烘干进程的 blpop 调用返回 None，表示超时，所以它们打印出最后一句话并退出：

Dryer process 44447 is done
Dryer process 44446 is done

最后一个烘干子进程退出后，主烘干程序退出：

[1]+  Done                    python redis_dryer2.py

11.1.8　队列之上

加入的功能越多，流水线就越有可能出问题。如果需要给一个宴会洗盘子，工人数量是否足够呢？如果烘干工人喝多了怎么办？如果水槽堵了怎么办？好担心啊！

如何应对这一切呢？幸运的是，有三种技术可供你使用。

触发并忘记

只传递内容，并不关心结果，即使没人处理。这就是“把盘子扔地上”方法。

请求 - 响应

对于每一个盘子，流水线上的清洗工人需要收到烘干工人的确认，烘干工人需要收到放置工人的确认。

背压或者节流

适用于上游工人速度比下游工人快的情况。

在真实系统中，你必须保证工人的速度能够满足需求，否则就会听到盘子摔碎的声音。你可以把新任务添加到一个等待列表中，一些工人进程会从中弹出最后一个消息并把它添加到工作列表中。消息处理完成后会从工作列表中移除并被添加到完成列表。这样就可以知道哪些任务失败或者占用了太长的时间。你可以自己使用 Redis 来完成这些功能，或者使用其他人已经写好并通过测试的系统。以下有一些基于 Python 的队列包添加了这种额外的控制层（有些使用的是 Redis）。

celery（http://www.celeryproject.org/）

这个包非常值得一看。它可以同步或者异步执行分布式任务，使用了我们之前介绍的方法：multiprocessing、gevent 等。

thoonk（https://github.com/andyet/thoonk.py）

这个包基于 Redis 构建，可以创建任务队列并实现发布 - 订阅（下一节会介绍）。

rq（http://python-rq.org/）

这是一个处理任务队列的 Python 库，同样基于 Redis。

Queues（http://queues.io/）

这个网站介绍了队列化软件，其中有些是基于 Python 开发的。

11.2　网络

在讨论并发时，主要讨论的是时间：单机解决方案（进程、线程和绿色线程）。还简单介绍了网络化的解决方案（Redis、ZeroMQ）。现在，我们来单独介绍一下网络化，也就是跨空间的分布式计算。

11.2.1　模式

你可以使用一些基础的模式来搭建网络化应用。

最常见的模式是请求 - 响应，也被称为客户端 - 服务器。这个模式是同步的：用户会一直等待服务器的响应。在本书中，你已经看过了很多“请求 - 响应”的示例。你的 Web 浏览器也是一个客户端，向 Web 服务器发起一个 HTTP 请求并等待响应。

另一种常见的模式是推送或者扇出：你把数据发送到一个进程池中，空闲的工作进程会进行处理。一个典型的例子是有负载均衡的 Web 服务器。

和推送相反的是拉取或者扇入：你从一个或多个源接收数据。一个典型的例子是记录器，它会从多个进程接收文本信息并把它们写入一个日志文件。

还有一个和收音机或者电视广播很像的模式：发布 - 订阅。这个模式中，会有发送数据的发布者。在简单的发布 - 订阅系统中，所有的订阅者都会收到一份副本。更常见的情况是，订阅者只关心特定类型的数据（通常被称为话题），发布者只会发送这些数据。因此，和推送模式不同，可能会有超过一个订阅者收到数据。如果一个话题没有订阅者，相关的数据会被忽略。

11.2.2　发布-订阅模型

发布 - 订阅并不是队列，而是广播。一个或多个进程发布信息，每个订阅进程声明自己感兴趣的消息类型，然后每个消息都会被复制一份发给感兴趣的订阅进程。因此，一个消息可能只被处理一次，也可能多于一次，还可能完全不被处理。每个发布者只负责广播，并不知道谁（如果有人的话）在监听。

1. Redis

你可以使用 Redis 来快速搭建一个发布 - 订阅系统。发布者会发出包含话题和值的消息，订阅者会声明它们对什么话题感兴趣。

下面是发布者，redis_pub.py：

import redis
import random

conn = redis.Redis()
cats = ['siamese', 'persian', 'maine coon', 'norwegian forest']
hats = ['stovepipe', 'bowler', 'tam-o-shanter', 'fedora']
for msg in range(10):
    cat = random.choice(cats)
    hat = random.choice(hats)
    print('Publish: %s wears a %s' % (cat, hat))
    conn.publish(cat, hat)

每个话题是猫的一个品种，每个消息的值是帽子的一种类型。

下面是一个订阅者，redis_sub.py：

import redis
conn = redis.Redis()
topics = ['maine coon', 'persian']
sub = conn.pubsub()
sub.subscribe(topics)
for msg in sub.listen():
    if msg['type'] == 'message':
        cat = msg['channel']
        hat = msg['data']
        print('Subscribe: %s wears a %s' % (cat, hat))

订阅者只会展示猫的品种为 'maine coon' 或者 'persian' 的消息。listen() 方法会返回一个字典，如果它的类型是 'message'，那就是由发布者发出的消息。'channel' 键是话题（猫），'data' 键包含消息的值（帽子）。

如果你先启动发布者，这时没有订阅者，就像把一个哑剧演员扔到树林里一样（他会发出声音吗？），因此要先启动订阅者：

$ python redis_sub.py

接着启动发布者，它会发送 10 个消息，然后退出：

$ python redis_pub.py
Publish: maine coon wears a stovepipe
Publish: norwegian forest wears a stovepipe
Publish: norwegian forest wears a tam-o-shanter
Publish: maine coon wears a bowler
Publish: siamese wears a stovepipe
Publish: norwegian forest wears a tam-o-shanter
Publish: maine coon wears a bowler
Publish: persian wears a bowler
Publish: norwegian forest wears a bowler
Publish: maine coon wears a stovepipe

订阅者只关心两类猫：

$ python redis_sub.py
Subscribe: maine coon wears a stovepipe
Subscribe: maine coon wears a bowler
Subscribe: maine coon wears a bowler
Subscribe: persian wears a bowler
Subscribe: maine coon wears a stovepipe

我们并没有让订阅者退出，因此它会一直等待消息。如果重新启动一个发布者，那订阅者会继续抓取消息并输出。

可以使用任意数量的订阅者（和发布者）。如果一个消息没有订阅者，那它会从 Redis 服务器中消失。然而，如果有订阅者，消息会停留在服务器中，直到所有的订阅者都获取完毕。

2. ZeroMQ

还记得之前介绍过 ZeroMQ 的 PUB 和 SUB 套接字吗？终于轮到它们大显身手了。ZeroMQ 没有核心服务器，因此每个发布者都会发送给所有订阅者。我们来使用 ZeroMQ 重写一下上面的猫 - 帽子示例。发布者为 zmq_pub.py，内容如下所示：

import zmq
import random
import time
host = '*'
port = 6789
ctx = zmq.Context()
pub = ctx.socket(zmq.PUB)
pub.bind('tcp://%s:%s' % (host, port))
cats = ['siamese', 'persian', 'maine coon', 'norwegian forest']
hats = ['stovepipe', 'bowler', 'tam-o-shanter', 'fedora']
time.sleep(1)
for msg in range(10):
    cat = random.choice(cats)
    cat_bytes = cat.encode('utf-8')
    hat = random.choice(hats)
    hat_bytes = hat.encode('utf-8')
    print('Publish: %s wears a %s' % (cat, hat))
    pub.send_multipart([cat_bytes, hat_bytes])

注意代码是如何用 UTF-8 来编码话题和值字符串的。

下面是订阅者 zmq_sub.py：

import zmq
host = '127.0.0.1'
port = 6789
ctx = zmq.Context()
sub = ctx.socket(zmq.SUB)
sub.connect('tcp://%s:%s' % (host, port))
topics = ['maine coon', 'persian']
for topic in topics:
    sub.setsockopt(zmq.SUBSCRIBE, topic.encode('utf-8'))
while True:
    cat_bytes, hat_bytes = sub.recv_multipart()
    cat = cat_bytes.decode('utf-8')
    hat = hat_bytes.decode('utf-8')
    print('Subscribe: %s wears a %s' % (cat, hat))

在这段代码中，我们订阅了两个不同的比特值：用 UTF-8 编码的 topics 中的两个字符串。

　这看起来有点过时，但是如果你想订阅所有话题，需要订阅空比特字符串 b''，否则什么消息都得不到。

注意，我们在发布者中调用了 send_multipart()，在订阅者中调用了 recv_multipart()。这样就可以收到消息的多个部分并使用第一部分来判断话题是否匹配。也可以选择使用一个字符串或者比特字符串来发送话题和消息值，但是把猫和帽子分开发送会更加清晰。

启动订阅者：

$ python zmq_sub.py

启动发布者，它会立刻发送 10 条消息并退出：

$ python zmq_pub.py
Publish: norwegian forest wears a stovepipe
Publish: siamese wears a bowler
Publish: persian wears a stovepipe
Publish: norwegian forest wears a fedora
Publish: maine coon wears a tam-o-shanter
Publish: maine coon wears a stovepipe
Publish: persian wears a stovepipe
Publish: norwegian forest wears a fedora
Publish: norwegian forest wears a bowler
Publish: maine coon wears a bowler

订阅者打印出它想要的内容：

Subscribe: persian wears a stovepipe
Subscribe: maine coon wears a tam-o-shanter
Subscribe: maine coon wears a stovepipe
Subscribe: persian wears a stovepipe
Subscribe: maine coon wears a bowler

3. 其他发布-订阅工具

你可能会对 Python 的其他发布 - 订阅工具感兴趣。

RabbitMQ

这是一个非常著名的消息发送器。pika 是它的 Python API。详情参见 pika 文档（http://pika.readthedocs.org/）和发布 - 订阅教程（http://www.rabbitmq.com/tutorials/tutorial-three-python.html）。

pypi.python.org

在右上角的搜索框内输入 pubsub 来寻找类似 pypubsub（http://pubsub.sourceforge.net/）这样的 Python 包。

pubsubhubbub

这个读起来非常顺口的协议（https://code.google.com/p/pubsubhubbub/）允许订阅者注册对应发布者的回调函数。

11.2.3　TCP/IP

我们一直处在网络的世界中，理所当然地认为底层的一切都可以正常工作。现在，我们来真正地深入底层，看看那些维持系统运转的东西到底是什么样。

因特网是基于规则的，这些规则定义了如何创建连接、交换数据、终止连接、处理超时等。这些规则被称为协议，它们分布在不同的层中。分层的目的是兼容多种实现方法。你可以在某一层中做任何想做的事，只要遵循上一层和下一层的约定就行。

最底层处理的是电信号，其余层都基于下面的层构建而成。在大约中间的位置是 IP（因特网协议）层，这层规定了网络位置和地址的映射方法以及数据包（块）的传输方式。IP 层的上一层有两个协议描述了如何在两个位置之间移动比特。

UDP（用户数据报协议）

这个协议被用来进行少量数据交换。一个数据报是一次发送的很少信息，就像明信片上的一个音符一样。

TCP（传输控制协议）

这个协议被用来进行长时间的连接。它会发送比特流并确保它们都能按序到达并且不会重复。

UDP 信息并不需要确认，因此你永远无法确认它是否到达目的地。如果你想讲一个 UDP 笑话：

Here's a UDP joke. Get it?（这是一个UDP笑话，你笑了吗？）

TCP 会在发送者和接收者之间通过秘密握手建立有保障的连接。下面是一个 TCP 笑话：

Do you want to hear a TCP joke?（你想听一个TCP笑话吗？）
Yes, I want to hear a TCP joke.（是的，我想听一个TCP笑话。）
Okay, I'll tell you a TCP joke.（好的，我会告诉你一个TCP笑话。）
Okay, I'll hear a TCP joke.（好的，我会听到一个TCP笑话。）
Okay, I'll send you a TCP joke now.（好的，我现在要发给你一个TCP笑话。）
Okay, I'll receive the TCP joke now.（好的，我现在会收到一个TCP笑话。）
... (and so on)（下面省略）

你的本地机器 IP 地址一直是 127.0.0.1，名称一直是 localhost。你可能听过它的另一个名字环回接口。如果连接到因特网，那你的机器还会有一个公共 IP。如果使用的是家用计算机，那它一般会接到调制解调器或者路由器上。你甚至可以在同一台机器的两个进程之间使用因特网协议。

在因特网上，我们接触到的大多数事物——Web、数据库服务器，等等——都是基于 IP 协议上的 TCP 协议运行的。简单起见，写为 TCP/IP。下面先来看一些基本的因特网服务，然后会了解一些常用的网络化模式。

11.2.4　套接字

我们一直把这个话题留到现在才讲，是因为即使你不知道所有的底层细节也可以使用高层的因特网。但是，如果你想知道底层的工作原理，那就读读这节吧。

最底层的网络编程使用的是套接字，源于 C 语言和 Unix 操作系统。套接字层的编程是非常繁琐的。使用类似 ZeroMQ 的库会简单很多，但是了解一下底层的工作原理还是非常有用的。举例来说，网络发生错误时出现的错误信息通常是和套接字相关的。

我们来编写一个非常简单的客户端 - 服务器通信示例。客户端发送一个包含字符串的 UDP 数据报给服务器，服务器会返回一个包含字符串的数据包。服务器需要监听特定的地址和端口——就像邮局和邮筒一样。客户端需要知道这两个值才能发送、接收和响应消息。

在下面的客户端和服务器代码中，address 是一个（地址，端口）元组。address 是一个字符串，可以是名称或者 IP 地址。当你的程序和同一台机器上的另一个程序通信时，可以使用名称 'localhost' 或者等价的地址 '127.0.0.1'。

首先从一个进程给另一个进程发送一些数据，让后者返回一些数据。第一个程序是客户端，第二个程序是服务器。在这两个程序中，我们都会打印出时间并打开一个套接字。服务器会监听它套接字上的连接，客户端会向它的套接字写入数据，套接字会发送一个消息给服务器。

下面是第一个程序，udp_server.py：

from datetime import datetime
import socket

server_address = ('localhost', 6789)
max_size = 4096

print('Starting the server at', datetime.now())
print('Waiting for a client to call.')
server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server.bind(server_address)

data, client = server.recvfrom(max_size)

print('At', datetime.now(), client, 'said', data)
server.sendto(b'Are you talking to me?', client)
server.close()

服务器必须用 socket 包中的两个方法来建立网络连接。第一个方法是 socket.socket，它会创建一个套接字。第二个方法 bind 会绑定（监听到达这个 IP 地址和端口的所有数据）到这个套接字上。AF_INET 表示要创建一个因特网（IP）套接字。（还有其他类型的 Unix 域套接字，不过那些只能在本地运行。）SOCK_DGRAM 表示我们要发送和接收数据报，换句话说，我们要使用 UDP。

之后，服务器会等待数据报到达（recvfrom）。收到数据报后，服务器会被唤醒并获取数据和客户端的信息。client 变量包含客户端的地址和端口，用于给客户端发送数据。接着，服务器发送一个响应并关闭连接。

下面，我们来看看 udp_client.py：

import socket
from datetime import datetime

server_address = ('localhost', 6789)
max_size = 4096

print('Starting the client at', datetime.now())
client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
client.sendto(b'Hey!', server_address)
data, server = client.recvfrom(max_size)
print('At', datetime.now(), server, 'said', data)
client.close()

客户端的许多方法和服务器一样（除了 bind()）。客户端先发送数据，然后接收数据，而服务器恰好相反。

先在一个窗口中启动服务器。它会打印出欢迎信息，然后一直沉默，直到客户端发送数据：

$ python udp_server.py
Starting the server at 2014-02-05 21:17:41.945649
Waiting for a client to call.

接着在另一个窗口中启动客户端。它会打印出欢迎信息并向服务器发送数据，打印出响应并退出：

$ python udp_client.py
Starting the client at 2014-02-05 21:24:56.509682
At 2014-02-05 21:24:56.518670 ('127.0.0.1', 6789) said b'Are you talking to me?'

最后，服务器会打印类似下面的内容并退出：

At 2014-02-05 21:24:56.518473 ('127.0.0.1', 56267) said b'Hey!'

客户端需要知道服务器的地址和端口号，但是并不需要指定自己的端口号。它的端口号由系统自动分配——在本例中是 56267。

　UDP 使用一个块来发送数据，并且不能保证一定可以送达。如果你使用 UDP 发送多个消息，那它们可能以任何顺序到达，也有可能全部都无法到达。UDP 很快、很轻，不需要建立连接，但是并不可靠。

由于 UDP 不可靠，我们准备使用 TCP（传输控制协议）。TCP 用来进行长时间连接，比如 Web。TCP 按照发送的顺序传输数据。如果出现任何问题，它会尝试重新传输。我们尝试一下使用 TCP 在客户端和服务器之间传输一些包。

tcp_client.py 和之前的 UDP 客户端有点像，只向服务器发送一个字符串，但是在调用套接字时有一些区别，如下所示：

import socket
from datetime import datetime

address = ('localhost', 6789)
max_size = 1000

print('Starting the client at', datetime.now())
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(address)
client.sendall(b'Hey!')
data = client.recv(max_size)
print('At', datetime.now(), 'someone replied', data)
client.close()

我们把 SOCK_DGRAM 换成了 SOCK_STREAM，指定使用流协议 TCP。还使用 connect() 来建立流。使用 UDP 时不需要这么做，因为每个数据报都是直接暴露在互联网中。

tcp_server.py 和 UDP 版本也有一些区别：

from datetime import datetime
import socket

address = ('localhost', 6789)
max_size = 1000

print('Starting the server at', datetime.now())
print('Waiting for a client to call.')
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(address)
server.listen(5)

client, addr = server.accept()
data = client.recv(max_size)

print('At', datetime.now(), client, 'said', data)
client.sendall(b'Are you talking to me?')
client.close()
server.close()

server.listen(5) 的意思是最多可以和 5 个客户端连接，超过 5 个就会拒绝。server.accept() 接收第一个到达的消息，client.recv(1000) 指定最大的可接收消息长度为 1000 字节。

和之前一样，先启动服务器再启动客户端，然后看看会发生什么。首先是服务器：

$ python tcp_server.py
Starting the server at 2014-02-06 22:45:13.306971
Waiting for a client to call.
At 2014-02-06 22:45:16.048865 <socket.socket object, fd=6, family=2, type=1,
    proto=0> said b'Hey!'

接着是客户端。它会给服务器发送消息、接收响应并退出：

$ python tcp_client.py
Starting the client at 2014-02-06 22:45:16.038642
At 2014-02-06 22:45:16.049078 someone replied b'Are you talking to me?'

服务器会收集消息、打印出来、发送响应然后退出：

At 2014-02-06 22:45:16.048865 <socket.socket object, fd=6, family=2, type=1,
    proto=0> said b'Hey!'

可以看到，TCP 服务器使用 client.sendall() 发送响应，之前的 UDP 服务器使用的是 client.sendto()。TCP 会维持多个客户端 - 服务器套接字并保存客户端的 IP 地址。

这看起来并不坏，但是如果你试着编写更复杂的代码，那就会体会到套接字有多难写。下面是一些需要处理的问题。

UDP 可以发送消息，但是消息的大小有限制，而且不能保证消息到达目的地。

TCP 发送字节流，不是消息。你不知道每次调用时系统会发送或者接收多少字节。

如果要用 TCP 传输完整的消息，需要一些额外的信息来把片段拼凑成整个消息：固定的消息大小（字节）、整个消息的大小或者一些特殊的哨兵字符。

由于消息是字节，不是 Unicode 文本字符串，你需要使用 Python 的 bytes 类型。更多内容参见第 7 章。

看完这些之后，如果你还对套接字编程感兴趣，可以看看 Python 套接字编程教程（https://docs.python.org/3/howto/sockets.html）。

11.2.5　ZeroMQ

我们已经看过如何用 ZeroMQ 套接字创建发布 - 订阅模型。ZeroMQ 是一个库，有时候也被称为打了激素的套接字（sockets on steroids），ZeroMQ 套接字实现了很多你需要但是普通套接字没有的功能：

传输完整的消息

重连

当发送方和接收方的时间不同步时缓存数据

这个在线教程（http://zguide.zeromq.org/）写得很好，是我见过的最好的讲解网络化模型的教程。印刷版（ZeroMQ: Messaging for Many Applications，Pieter Hintjens 著，O'Reilly 出版社）中的代码风格很好，封面上还有一条大鱼。印刷版中的示例都是用 C 语言写成的，但是在线版可以选择很多种语言，比如 Python 版示例（https://github.com/imatix/zguide/tree/master/examples/Python）。本章会介绍一些 Python 写成的简单的 ZeroMQ 示例。

ZeroMQ 就像乐高积木，我们都知道用很少的乐高积木就能搭建出很多东西。在本例中，你可以用很少几个套接字类型和模式来构建网络。下面这些“乐高积木块”是 ZeroMQ 的套接字类型，看起来很像之前说过的网络模型：

REQ（同步请求）

REP（同步响应）

DEALER（异步请求）

ROUTER（异步响应）

PUB（发布）

SUB（订阅）

PUSH（扇出）

PULL（扇入）

在动手尝试之前，需要先安装 Python 的 ZeroMQ 库：

$ pip install pyzmq

最简单的模式是一个请求 - 响应对。这是同步的：一个套接字发送请求，另一个发送响应。首先是发送响应的代码（服务器），zmq_server.py：

import zmq

host = '127.0.0.1'
port = 6789
context = zmq.Context()
server = context.socket(zmq.REP)
server.bind("tcp://%s:%s" % (host, port))
while True:
    #  等待客户端的下一个请求
    request_bytes = server.recv()
    request_str = request_bytes.decode('utf-8')
    print("That voice in my head says: %s" % request_str)
    reply_str = "Stop saying: %s" % request_str
    reply_bytes = bytes(reply_str, 'utf-8')
    server.send(reply_bytes)

创建一个 Context 对象：这是一个能够保存状态的 ZeroMQ 对象。接着创建一个 REP（用于响应）类型的 ZeroMQ 套接字。调用 bind()，让它监听特定的 IP 地址和端口。注意，地址和端口用字符串 'tcp://localhost:6789' 来指定，并不是普通套接字中的元组。

这个示例代码会从一个发送者接收请求并发送响应。消息可以非常长——ZeroMQ 会处理这些细节。

下面是对应的请求代码（客户端），zmq_client.py。它的类型是 REQ（用于请求），而且调用的是 connect()，不是 bind()：

import zmq

host = '127.0.0.1'
port = 6789
context = zmq.Context()
client = context.socket(zmq.REQ)
client.connect("tcp://%s:%s" % (host, port))
for num in range(1, 6):
    request_str = "message #%s" % num
    request_bytes = request_str.encode('utf-8')
    client.send(request_bytes)
    reply_bytes = client.recv()
    reply_str = reply_bytes.decode('utf-8')
    print("Sent %s, received %s" % (request_str, reply_str))

现在是时候启动它们了。和普通套接字不同的一点是，你可以用任何顺序启动服务器和客户端。在后台的一个窗口中启动服务器：

$ python zmq_server.py &

然后在同一个窗口中启动客户端：

$ python zmq_client.py

你会看到客户端和服务器交替输出如下所示的内容：

That voice in my head says 'message #1'
Sent 'message #1', received 'Stop saying message #1'
That voice in my head says 'message #2'
Sent 'message #2', received 'Stop saying message #2'
That voice in my head says 'message #3'
Sent 'message #3', received 'Stop saying message #3'
That voice in my head says 'message #4'
Sent 'message #4', received 'Stop saying message #4'
That voice in my head says 'message #5'
Sent 'message #5', received 'Stop saying message #5'

客户端发送完第五条消息之后就退出了，但是我们并没有让服务器退出，所以它一直在等待消息。如果再次运行客户端，它会打印出相同的五行，服务器也会打印出这五行。如果不终止 zmq_server.py 进程并且再次运行它，那 Python 会抱怨说地址已经被使用：

$ python zmq_server.py &

[2] 356
Traceback (most recent call last):
  File "zmq_server.py", line 7, in <module>
    server.bind("tcp://%s:%s" % (host, port))
  File "socket.pyx", line 444, in zmq.backend.cython.socket.Socket.bind
      (zmq/backend/cython/socket.c:4076)
  File "checkrc.pxd", line 21, in zmq.backend.cython.checkrc._check_rc
      (zmq/backend/cython/socket.c:6032)
zmq.error.ZMQError: Address already in use

消息需要用字节字符串形式发送，所以需要把示例中的字符串用 UTF-8 格式编码。你可以发送任意类型的消息，只要把它转换成 bytes 就行。我们的消息是简单的文本字符串，所以 encode() 和 decode() 可以实现文本字符串和字节字符串的转换。如果你的消息包含其他数据类型，可以使用类似 MessagePack（http://msgpack.org/）的库来处理。

由于任何数量的 REQ 客户端都可以 connect() 到一个 REP 服务器，即使是基础的请求 - 响应模式也可以实现一些有趣的通信模式。服务器是同步的，一次只能处理一个请求，但是并不会丢弃这段时间到达的其他请求。ZeroMQ 会在触发某些限制之前一直缓存这些消息，直到它们被处理；这就是 ZeroMQ 中 Q 的意思。Q 表示队列（Queue），M 表示消息（Message），Zero 表示不需要任何消息分发者。

虽然 ZeroMQ 不需要任何核心分发者（中间人），但是如果需要，你可以搭建一个。举例来说，可以使用 DEALER 和 ROUTER 套接字异步连接到多个源和 / 或目标。

多个 REQ 套接字可以连接到一个 ROUTER 上，后者会把请求传递给 DEALER，DEALER 又会传递给和它连接的所有 REP 套接字（图 11-1）。就像很多浏览器连接到一个代理服务器，后者连接到一个 Web 服务器群。你可以根据需要添加任意数量的客户端和服务器。

图 11-1：使用一个分发者连接多个客户端和服务器

REQ 套接字只能和 ROUTER 套接字连接；DEALER 可以和后面的多个 REP 套接字连接。ZeroMQ 会处理具体的细节部分，确保请求的负载均衡并把响应发送给正确的目标。

另一种网络化模式被称为通风口，使用 PUSH 套接字来发送异步任务，使用 PULL 套接字来收集结果。

最后一个需要介绍的 ZeroMQ 特性是它可以实现扩展和收缩，只要改变创建的套接字连接类型即可：

tcp 适用于单机或者分布式的进程间通信

ipc 适用于单机的进程间通信

inproc 适用于单个进程内线程间通信

最后的 inproc 是一种线程间无锁的数据传输方式，可以替代 11.1.3 节中的 threading 示例。

使用 ZeroMQ 之后，你应该再也不会想写原始的套接字代码了。

　ZeroMQ 并不是 Python 支持的唯一一个消息传递库。消息传递是网络化的一个重要内容，Python 当然也不能落后。9.2.6 节中“Apache”部分提到的 Web 服务器是 Apache 项目的一部分，这个项目也在维护 ActiveMQ（https://activemq.apache.org/）项目，其中包含了几个使用简单文本 STOMP（http://stomp.github.io/implementations.html）协议的 Python 接口。RabbitMQ（http://www.rabbitmq.com/）也很出名，并且有优秀的 Python 教程（http://www.rabbitmq.com/tutorials/tutorial-one-python.html）。

11.2.6　scapy

有时候你需要深入网络流中处理字节。你可能想要调试 Web API 或者追踪一些安全问题。scapy 库是一个优秀的 Python 数据包分析工具，比编写和调试 C 程序简单很多。实际上，它是一门简单的用来构建和分析数据包的语言。

我本来计划在这里展示一些示例代码，但是由于以下两点原因改变了想法。

scapy 还不兼容 Python 3。这个问题之前我们也遇到过，当时是使用 pip2 和 python2 来解决，但是……

在我看来，scapy 的安装教程（http://www.secdev.org/projects/scapy/portability.html）对 于一本入门级的书来说太复杂了。

如果你愿意，可以看看文档（http://www.secdev.org/projects/scapy/doc/）中的示例代码。这些代码可能会让你有勇气在机器上安装 scapy。

最后，不要把 scapy 和 scrapy 搞混，后者在 9.3.4 节有介绍。

11.2.7　网络服务

Python 有许多网络工具。下面的内容会介绍如何用自动化的方式实现那些最流行的网络服务。官方的完整文档（https://docs.python.org/3/library/internet.html）可以在线查看。

1. 域名系统

计算机有类似 85.2.101.94 的数字 IP 地址，但是相比数字，我们更容易记住名称。域名系统（DNS）是一个非常重要的网络服务，通过一个分布式的数据库实现 IP 地址和名称的转换。当你使用 Web 浏览器并且看到类似“查找主机”的消息时，那可能就是网络连接中断了，第一种可能就是 DNS 错误。

在底层 socket 模块中有一些 DNS 函数。gethostbyname() 会返回一个域名的 IP 地址、扩展版本 gethostbyname_ex() 会返回名称、一个可选名称列表和一个地址列表：

>>> import socket
>>> socket.gethostbyname('www.crappytaxidermy.com')
'66.6.44.4'
>>> socket.gethostbyname_ex('www.crappytaxidermy.com')
('crappytaxidermy.com', ['www.crappytaxidermy.com'], ['66.6.44.4'])

getaddrinfo() 方法会查找 IP 地址，不过它返回的信息很全，可以用于创建套接字连接：

>>> socket.getaddrinfo('www.crappytaxidermy.com', 80)
[(2, 2, 17, '', ('66.6.44.4', 80)), (2, 1, 6, '', ('66.6.44.4', 80))]

上面的调用会返回两个元组，第一个用于 UDP，第二个用于 TCP（2, 1, 6 中的 6 表示的就是 TCP）。

你可以只获取 TCP 或者 UDP 信息：

>>> socket.getaddrinfo('www.crappytaxidermy.com', 80, socket.AF_INET,
socket.SOCK_STREAM)
[(2, 1, 6, '', ('66.6.44.4', 80))]

有些 TCP 和 UDP 端口号（http://en.wikipedia.org/wiki/List_of_TCP_and_UDP_port_numbers）是 IANA 为特定服务保留的，每个端口号关联一个服务名。举例来说，HTTP 的名称是 http，关联到 TCP 端口 80。

下面的函数可以实现服务名和端口号的转换：

>>> import socket
>>> socket.getservbyname('http')
80
>>> socket.getservbyport(80)
'http'

2. Python的Email模块

标准库中有以下这些 Email 模块：

smtplib 使用简单邮件传输协议（SMTP）发送邮件；

email 用来创建和解析邮件；

poplib 可以使用邮递协议（POP3）来读取邮件；

imaplib 可以使用因特网消息访问协议（IMAP）来读取邮件。 官方文档包含这些库对应的示例代码（https://docs.python.org/3/library/email-examples.html）。

如果你想编写自己的 Python SMTP 服务器，可以试试 smtpd（https://docs.python.org/3/library/smtpd.html）。

Lamson（http://Lamsonproject.org/）是一个纯 Python 的 SMTP 服务器，可以在数据库中存储邮件，甚至可以过滤垃圾邮件。

3. 其他协议

标准的 ftplib 模块（https://docs.python.org/3/library/ftplib.html）可以使用文件传输协议（FTP）来发送字节。虽然这是一个很古老的协议，但它的表现仍然非常优秀。

本书已经介绍了很多标准库中的模块，不过还是推荐你阅读一下标准库文档中的网络协议（https://docs.python.org/3/library/internet.html）部分。

11.2.8　Web服务和API

信息提供商都有网站，但是这些网站的目标是普通用户，并不是自动化。如果数据只展示在网页上，那想要获取并结构化这些数据的人就必须编写爬虫（参见 9.3.4 节），在页面格式改变时还必须更新爬虫。这是一件很麻烦的事。但是如果一个网站提供数据 API，那对于客户端程序来说，数据的获取就会变得非常直观。相比网页布局，API 很少改变，因此客户端也不需要经常重写。一个快速、整洁的数据通道可以大大简化混搭程序的编写难度——这些程序虽然不具备前瞻性，但是可能非常有用并且能带来利润。

通常来说，最简单的 API 是一个 Web 接口，可以提供类似 JSON 或者 XML 的结构化数据，而不是纯文本或者 HTML。API 既可以做得非常简单也可以是一套成熟的 RESTful API（9.3.2 节有具体定义），后者可以更好地处理那些不安分的字节 1。

1英语“不安分”是 restless，正好和 RESTful 对应。——译者注

在本书的最开始，你就看过一个 Web API：它从 YouTube 上获取最流行的视频。在看过 Web 请求、JSON、字典、元组和切片之后，这个例子应该已经很容易理解了：

import requests
url = "https://gdata.youtube.com/feeds/api/standardfeeds/top_rated?alt=json"
response = requests.get(url)
data = response.json()
for video in data['feed']['entry'][0:6]:
    print(video['title']['$t'])

在挖掘知名社交媒体网站，比如 Twitter、Facebook 和 LinkedIn 时，API 非常有用。这些网站都提供可以免费使用的 API，但是它们要求你注册来获得一个 key（一个很长的文本字符串，有时也被称为 token），使用这个 key 来访问 API。网站可以通过 key 来判断是谁在获取数据，也可以用它来限制请求频率。在 YouTube 这个例子中，进行搜索不需要 API key，但是如果要更新 YouTube 上的数据，那就必须使用 key。

下面是一些有趣的服务 API：

纽约时报（http://developer.nytimes.com/）

YouTube（http://gdata.youtube.com/demo/index.html）

Twitter（https://dev.twitter.com/docs/twitter-libraries）

Facebook（https://developers.facebook.com/tools/）

Weather Underground（http://www.wunderground.com/weather/api/）

漫威漫画（http://developer.marvel.com/）

你可以在附录 B 中看到地图 API 示例，附录 C 中还有其他示例。

11.2.9　远程处理

本书中的很多示例都是介绍如何在同一台机器上调用 Python 代码，通常还是在同一个进程中。但是 Python 的能力远不止这些，你可以调用其他机器上的代码，就像它们在本地一样。在高级设置中，如果你用完了单机的空间，可以扩展到其他机器。通过网络连接的一组计算机可以让你操作更多进程和 / 或线程。

1. 远程过程调用

远程过程调用（RPC）看起来和普通的函数一样，但其实运行在通过网络连接的远程机器上。RESTful API 需要通过 URL 编码参数或者请求体来调用，但是 RPC 函数是在你自己的机器上调用。下面是 RPC 客户端的工作原理：

(1) 把你的函数参数转换成比特（有时候被称为编组、序列化或者编码）；

(2) 把编码后的字节发送给远程机器。

下面是远程机器的工作原理：

(1) 接收编码后的请求字节；

(2) 接收完毕后，RPC 客户端会把字节解码成原始的数据结构（或者等价的东西，如果两台机器的硬件和软件有差别）；

(3) 客户端找到本地目标函数并用解码后的数据调用它；

(4) 客户端编码函数执行结果；

(5) 客户端把编码后的字节发送给调用者。

最后，发起请求的机器把字节解码成返回值。

RPC 是一种非常流行的技术，有很多种实现方式。在服务端，你可以启动一个服务器程序，把它和一些字节传输和编码 / 解码方法连接起来，定义一些访问函数并宣布你的 RPC 开始正常运转。客户端可以连接到服务器并通过 RPC 调用服务器的函数。

标准库中包含一种 RPC 实现，xmlrpc，使用 XML 作为传输格式。你在服务器上定义并注册函数，客户端使用类似导入的方式来调用它们。首先来看文件 xmlrpc_server.py：

from xmlrpc.server import SimpleXMLRPCServer

def double(num):
    return num * 2

server = SimpleXMLRPCServer(("localhost", 6789))
server.register_function(double, "double")
server.serve_forever()

我们在服务器上提供了 double() 函数，它接收一个数字参数并返回这个数字乘以 2 的结果。服务器在一个地址和端口上启动。我们需要注册函数，这样它才能让客户端通过 RPC 调用。最后，启动服务器并等待。

接着，和你想的一样，xmlrpc_client.py：

import xmlrpc.client

proxy = xmlrpc.client.ServerProxy("http://localhost:6789/")
num = 7
result = proxy.double(num)
print("Double %s is %s" % (num, result))

客户端通过 ServerProxy() 和服务器连接。接着它会调用 proxy.double()。这个函数是哪儿来的？实际上，它是由服务器动态生成的。RPC 机制会截获这个函数名并在远程服务器上调用它。

下面来看一下效果，先启动服务器：

$ python xmlrpc_server.py

接着启动客户端：

$ python xmlrpc_client.py
Double 7 is 14

服务器会打印出如下内容：

127.0.0.1 - - [13/Feb/2014 20:16:23] "POST / HTTP/1.1" 200 -

常用的传输方式是 HTTP 和 ZeroMQ。除了 XML 外，JSON、Protocol Buffers 和 MessagePack 也是常用的编码方式。有许多基于 JSON 的 Python RPC 包，但是它们要么不支持 Python 3，要么太难用。这里我们使用 MessagePack 自己的 Python RPC 实现（https://github.com/msgpack-rpc/msgpack-rpc-python）。下面是安装方法：

$ pip install msgpack-rpc-python

这条命令还会安装 tornado，这是一个基于事件的 Python Web 服务器，会被这个库用于传输数据。按照惯例，先是服务器的代码（msgpack_server.py）：

from msgpackrpc import Server, Address

class Services():
    def double(self, num):
        return num * 2

server = Server(Services())
server.listen(Address("localhost", 6789))
server.start()

Services 类把它的方法暴露为 RPC 服务。下面是客户端 msgpack_client.py：

from msgpackrpc import Client, Address
client = Client(Address("localhost", 6789))
num = 8
result = client.call('double', num)
print("Double %s is %s" % (num, result))

依照惯例，先启动服务器再启动客户端：

$ python msgpack_server.py

$ python msgpack_client.py
Double 8 is 16

2. fabric

fabric 包可以运行远程或者本地命令、上传或者下载文件、用 sudo 权限运行命令。这个包使用安全 Shell（SSH：加密文本协议，基本上已经代替了 telnet）来运行远程程序。你需要把（Python）函数写入一个 fabric 文件并声明它们应该在远程还是本地执行。之后，使用时需要用 fabric 程序（名字是 fab，但是并不是向披头士或者洗涤灵致敬）来运行，需要指定目标远程机器和目标函数。它比 RPC 简单很多。

　在编写本书时，fabric 的作者正在合并一些和 Python 3 相关的改动。如果完成修改，那下面的例子可以正常运行。不过，在那之前还是需要使用 Python 2 来运行。

首先，用下面的命令安装 fabric：

$ pip2 install fabric

可以不使用 SSH，直接用 fabric 运行本地 Python 代码。把下面的代码保存为 fab1.py：

def iso():
    from datetime import date
    print(date.today().isoformat())

接着，输入下面的命令来运行：

$ fab -f fab1.py -H localhost iso

[localhost] Executing task 'iso'
2014-02-22

Done.

-f fab1.py 选项指定使用 fabric 文件 fab1.py，而不是默认的 fabfile.py。-H localhost 选项指定运行本地的命令。最后，iso 是 fab 文件中要运行的函数名。它的工作原理和之前的 RPC 有点像。具体的选项参见官方文档（http://docs.fabfile.org/）。

要在本地或者远程运行外部程序，机器必须运行 SSH 服务器。在 Unix 类系统中，服务器是 sshd；service sshd status 可以检查服务器是否启动，如果需要，可以使用 service sshd start 来启动它。在 Mac 中，打开“系统偏好设置”，点击“共享”，然后勾选“远程登录”。Windows 没有内置的 SSH 支持，建议安装 putty（http://www.chiark.greenend.org.uk/~sgtatham/putty/）。

我们还是使用了函数名 iso，但这次使用 local() 来运行命令。下面是代码和输出：

from fabric.api import local

def iso():
    local('date -u')

$ fab -f fab2.py -H localhost iso

[localhost] Executing task 'iso'
[localhost] local: date -u
Sun Feb 23 05:22:33 UTC 2014

Done.
Disconnecting from localhost... done.

local() 对应的远程方法是 run()。下面是 fab3.py：

from fabric.api import run

def iso():
    run('date -u')

fabric 在遇到 run() 时会使用 SSH 连接命令行中用 -H 指定的主机。如果你有本地网络并且可以使用 SSH 连接一个主机，那可以在 -H 之后加上那个主机名（就像下面的示例一样）。如果没有这样的主机，那就使用 localhost，fabric 会像访问远程机器一样访问它，这在测试时很有用。本例还是使用 localhost：

$ fab -f fab3.py -H localhost iso

[localhost] Executing task 'iso'
[localhost] run: date -u
[localhost] Login password for 'yourname':
[localhost] out: Sun Feb 23 05:26:05 UTC 2014
[localhost] out:

Done.
Disconnecting from localhost... done.

注意，我需要输入密码来登录。如果想省略这一步，可以在 fabric 文件中写入密码：

from fabric.api import run
from fabric.context_managers import env

env.password = "your password goes here"

def iso():
    run('date -u')

运行它：

$ fab -f fab4.py -H localhost iso

[localhost] Executing task 'iso'
[localhost] run: date -u
[localhost] out: Sun Feb 23 05:31:00 UTC 2014
[localhost] out:

Done.
Disconnecting from localhost... done.

　把密码放在代码中非常不安全。更好的方法是使用公钥和密钥配置 SSH，可以使用 ssh-keygen（https://help.github.com/articles/generating-ssh-keys/）。

3. Salt

Salt（http://saltstack.com/）最初的目的是实现远程运行，但是后来变成了一个完整的系统管理平台。它是基于 ZeroMQ 开发的，不是基于 SSH，因此可以扩展到上千台服务器。

Salt 还没有兼容 Python 3，这里我不会提供 Python 2 的示例代码。如果你对它感兴趣，可以阅读文档并等待它兼容 Python 3。

　类似的产品有 puppet（http://puppetlabs.com/）和 chef（http://www.getchef.com/chef/），它们和 Ruby 关系密切。ansible（http://www.ansible.com/home）包是 Python 写成的另一个类似 Salt 的系统，也值得一试。它可以免费下载和使用，但是支持和一些插件包需要商业许可。它默认使用 SSH，并且并不需要在机器上安装其他特殊软件。

salt 和 ansible 都包含了 fabric 的功能，可以进行初始化配置、部署和远程执行。

11.2.10　大数据和MapReduce

当 Google 和其他互联网公司成长起来之后，它们发现传统的计算机解决方案不能扩展。可以运行在单机或者少量机器上的软件无法支持上千台机器。

存储数据的数据库和文件需要多次寻道，这会产生多次磁头移动。（想想黑胶唱片和它移动唱针的时间，再想想唱针放下时造成的噪音和人们说话的声音。）但是，连续读取磁盘上的区块时速度很快。

开发者发现把数据分布在网络的不同机器上并进行分析会比只用一台机器快很多。它们会使用那些听起来很简单但是效率很高的算法来快速处理分布式数据。其中之一就是 MapReduce，它可以在许多机器上执行计算并收集结果，很像队列。

Google 在论文中发表这个成果之后，Yahoo 发布了一个基于 Java 的开源包，名为 Hadoop（这个名字来源于项目领导者儿子的一个玩具大象）。

这里要说一下大数据这个词。通常来说，它的意思是“数据对于我的机器来说太大了”：数据超出了已有的磁盘、内存、CPU 时间或者所有这些。对于某些组织来说，一旦遇到大数据问题，那解决方案总是 Hadoop。Hadoop 会把数据复制到其他机器上，通过 map 和 reduce 程序来处理它们并把每一步的结果存储到磁盘上。

这个过程可能很慢。更快的方法是 Hadoop 流，就像 Unix 的管道一样，把每一步产生的数据流直接传输给下一步，这样就可以避免存储到磁盘。你可以用任何语言来编写 Hadoop 流程序，包括 Python。

已经有很多关于 Hadoop 的 Python 模块，“Python Hadoop 框架教程”（http://blog.cloudera.com/blog/2013/01/a-guide-to-python-frameworks-for-hadoop/）这篇博文介绍了很多。Spotify 公司的流媒体音乐很出名，它开源了自己处理 Hadoop 流的 Python 部件 Luigi（https://github.com/spotify/luigi）。不过现在还不兼容 Python 3。

Hadoop 有一个竞争对手 Spark（http://spark.apache.org/docs/latest/index.html），它的目标是大大加快运行速度。它可以读取和处理所有 Hadoop 的数据结构和格式。Spark 包含 Python 和其他语言的 API，可以参见在线安装文档（http://spark.apache.org/downloads.html）。

另一个类似的产品是 Disco（http://discoproject.org/），它使用 Python 来完成 MapReduce 过程，使用 Erlang 完成通信部分。不过，只可惜无法使用 pip 来安装，具体方法参见文档（http://disco.readthedocs.org/en/latest/start/download.html）。

附录 C 有并行编程相关的示例，可以在分布式集群中执行大规模结构化计算。

11.2.11　在云上工作

不久之前，你还需要买自己的服务器，把它们放在数据中心的机柜上，安装各种软件：操作系统、设备驱动、文件系统、数据库、Web 服务器、邮件服务器、域名服务器、负载均衡、监控程序，等等。当你做过很多遍之后，就会失去新鲜感，并且需要一直担心安全问题。

许多托管服务都提供有偿维护，但是你仍然需要租用物理设备并且按照峰值负载来付费。

机器数量多了之后，就很容易出现问题。你需要横向扩展服务并对数据做冗余存储。网络操作和单机完全不同，Peter Deutsch 说过，分布式计算的八大误解是：

网络是可靠的；

延迟为零；

带宽无限；

网络是安全的；

拓扑结构不会改变；

传输成本为零；

网络是同构的。

你可以试着搭建复杂的分布式系统，但这非常困难，并且需要另一组工具集。借用一个比喻，如果你只有少数几个服务器，你会像对待宠物一样对待它们——给它们命名，了解它们的特点，在需要时尽量治疗它们。但是规模变大之后，你像对待牲口一样对待它们：它们看起来都一样，每个都有编号，如果遇到问题可以被替换掉。

除了自己搭建，你还可以租用云上的服务器。使用这种模式时，维护是其他人的问题，你可以专注在你的服务、博客或者任何你想展示给世界的东西上。使用 Web 仪表盘和 API 可以快速和轻松地创建任何你需要的服务器——它们是有弹性的。你可以监控它们的状态，如果某些参数超过阈值会收到提醒。目前，云是一个非常火的话题，企业在云组件上的支出在不断飙升。

下面我们来看看如何在 Python 中使用现在流行的云平台。

1. Google

Google 内部大量使用 Python，它还招聘了很多高级 Python 开发者（连吉多 · 范 · 罗苏姆都工作过一段时间）。

打开 App Engine 网站（https://developers.google.com/appengine/），在“选择语言”下面点击 Python。你可以在云编辑器中输入 Python 代码，可以直接在下方看到运行结果。在结果后面是链接，可以下载 Python SDK，这样你就可以在自己的硬件上使用 Google 的云 API 进行开发。下面是把应用部署到 AppEngine 的一些细节。

在 Google 云的主页（https://cloud.google.com/）上可以找到服务的详细介绍。

App Engine

一个高层平台，包含一些 Python 工具，比如 flask 和 django。

Compute Engine

创建一个虚拟机集群来进行大规模分布式计算。

Cloud Storage

对象存储（对象是文件，但是没有目录结构）。

Cloud Datastore

大型 NoSQL 数据库。

Cloud SQL

大型 SQL 数据库。

Cloud Endpoints

用 Restful 来访问应用。

BigQuery

类似 Hadoop 的大数据处理。

如果硬要说，Google 的服务在和 Amazon、OpenStack 竞争。

2. Amazon

当 Amazon 的服务器数量剧增之后，开发者遇到了许多分布式系统带来的问题。大约是 2002 年的某一天，CEO Jeff Bezos 向所有员工宣布，从今往后，Amazon 的所有数据和功能都要通过网络服务接口来使用——再也没有文件、数据库或者本地调用。他们必须把这些接口设计成可以公开使用。最后 Jeff 说：“做不到的人会被解雇。”

不出所料，开发者们开发出一个非常大的面向服务的架构。他们借鉴了很多解决方案，最终完成了 Amazon Web Services（AWS，http://aws.amazon.com/cn/）。这个庞然大物现在已经统治了市场。目前，AWS 包含很多服务。和我们关系最密切的有以下这些服务。

Elastic Beanstalk

高层应用平台

EC2（Elastic Compute）

分布式计算

S3（Simple Storage Service）

对象存储

RDS

关系数据库（MySQL、PostgreSQL、Oracle、MSSQL）

DynamoDB

NoSQL 数据库

Redshift

数据仓库

EMR

Hadoop

更多关于 AWS 服务的细节，请下载 Amazon Python SDK（http://aws.amazon.com/developers/getting-started/python/）并阅读帮助部分。

官方的 Python AWS 库 boto（http://docs.pythonboto.org/en/latest/）是另一个类似的工具，还没有完全兼容 Python 3。你需要使用 Python 2 或者使用其他类似的工具，可以在 Python 包索引（https://pypi.python.org/pypi）中搜索“aws”或者“amazon”。

3. OpenStack

第二个非常流行的云服务是由 Rackspace 提供的。2010 年，Rackspace 和 NASA 达成了不寻常的合作关系，把它们的一些云设施合并成了 OpenStack（http://www.openstack.org/）。这是一个免费的开源平台，可以搭建公有云、私有云和混合云。每 6 个月发布一个新版本，最近的版本有超过 125 万 Python 代码，有很多贡献者。越来越多的组织在产品中使用 OpenStack，包括 CERN 和 PayPal。

OpenStack 的主要 API 是 RESTful 的，它的 Python 模块还提供了程序级别的接口和用于 shell 自动化的命令行 Python 程序。下面是当前发行版中的一些标准访问。

Keystone

认证服务，提供认证（比如用户名 / 密码）、授权（功能）和服务发现。

Nova

计算服务，通过网络上的服务器进行分布式工作。

Swift

对象存储，类似 Amazon 的 S3。它被用于 Rackspace 的 Cloud Files 服务。

Glance

中层的镜像存储服务。

Cinder

低层次的块存储服务。

Horizon

基于 Web 的所有服务的仪表盘。

Neutron

网络管理服务。

Heat

配置管理（多个云）服务。

Ceilometer

遥测（度量、监控）服务。

经常有新服务被提出，有些经过孵化过程后，可能会成为标准 OpenStack 平台的一部分。

OpenStack 运行在 Linux 或者 Linux 虚拟机中。核心服务的安装还是有些复杂。在 Linux 上安装 OpenStack 最快捷的方法就是使用 Devstack（http://docs.openstack.org/developer/devstack/）来一键安装。完成后，你可以使用一个 Web 仪表盘来查看和配置其他服务。

如果想手动安装 OpenStack，可以使用 Linux 的包管理工具。所有的主要 Linux 发行版都支持 OpenStack 并且在下载服务器上提供了官方安装包。可以在 OpenStack 官网上查看安装文档、新闻和相关信息。

OpenStack 的开发和企业支持正在逐步推进，很像当年 Linux 和 Unix 竞争的情景。

11.3　练习

(1) 使用原始的 socket 来实现一个获取当前时间的服务。当客户端向服务器发送字符串 time 时，服务器会返回当前日期和时间的 ISO 格式字符串。

(2) 使用 ZeroMQ 的 REQ 和 REP 套接字实现同样的功能。

(3) 使用 XMLRPC 实现同样的功能。

(4) 你可能看过那部很老的《我爱露西》（I Love Lucy）电视节目。露西和埃塞尔在一个巧克力工厂里工作（这是传统）。他们落在了运输甜点的传送带后面，所以必须用更快的速度进行处理。写一个程序来模拟这个过程，程序会把不同类型的巧克力添加到一个 Redis 列表中，露西是一个客户端，对列表执行阻塞的弹出操作。她需要 0.5 秒来处理一块巧克力。打印出时间和露西处理的每块巧克力类型以及剩余巧克力的数量。

(5) 使用 ZeroMQ 发布第 7 章练习 (7) 中的诗（参见 7.3 节），每次发布一个单词。写一个 ZeroMQ 客户端来打印出每个以元音开头的单词，再写另一个客户端来打印出所有长度为 5 的单词。忽略标点符号。

第 12 章　成为真正的 Python 开发者

“想回到过去揍年轻的自己吗？选择软件开发作为你的事业吧！”

—— Elliot Loh（https://twitter.com/loh/status/411282297816498176）

本章会介绍 Python 开发中的艺术和科学，还有一些“最佳实践”。掌握它们之后，你就可以成为一个正牌的 Python 开发者。

12.1　关于编程

首先是一些基于我个人经历的和编程相关的建议。

最初我准备走科学这条路，我自学了编程来分析和展示实验数据。我以为计算机编程和会计一样——准确但是无聊。当我发现自己真正享受这个过程时非常惊讶。部分乐趣来自逻辑方面，就像解开谜题一样，但是还有一部分是创造力。你必须正确地编写程序才能得到正确的结果，但是可以用任何喜欢的方式来完成它。这是一种不寻常的左右脑平衡思考训练。

在掉进编程这个大坑后，我发现这个领域有很多方向，每个方向都有很多不同的任务和不同的人。你可以选择计算机图形学、操作系统、商业应用，甚至科学。

如果你是一个程序员，可能也有类似的经历。如果你不是，或许应该尝试一下编程，看看是否符合你的个性，至少也可以帮你完成一些工作。就像我在本书前面提到的一样，数学能力并不是那么重要。看起来逻辑思考的能力最重要，语言能力也很有用。最后，耐心很重要，尤其是寻找代码中的 bug 时。

12.2　寻找Python代码

当你需要开发功能时，最快的解决方法就是“偷”。好吧……是从一个经过允许的来源“偷”代码。

Python 标准库（http://docs.python.org/3/library/）既宽又深，而且特别整洁。深入进去能学到很多东西。

就像各种体育运动的名人堂一样，一个模块需要经过时间的检验才能加入标准库。新的包一直在出现，本书已经介绍过一些，它们有的能做一些新的东西，有些可以更好地完成旧的工作。Python 的广告语是内置电池（batteries included），但你可能需要一种新电池。

所以，除了标准库之外，还能去哪儿寻找优秀的 Python 代码呢？

首先要推荐的就是 Python 包索引（PyPi，https://pypi.python.org/pypi）。之前被命名为巨蟒剧团中的奶酪店（Cheese Shop）。这个网站上的 Python 包一直在更新，我编写本书时已经超过了 39 000 个。当你使用 pip（参见下一节）时它就会搜索 PyPi。PyPi 的主页显示的是最近添加的包。你也可以直接搜索。举例来说，表 12-1 列出了 genealogy 的搜索结果。

表12-1：PyPi中搜索genealogy的结果

包

权重

描述

Gramps 3.4.2

5

分析、组织并分享你的家谱

python-fs-stack 0.2

2

Python 封装过的所有 FamilySearch API

human-names 0.1.1

1

人名

nameparser 0.2.8

1

一个简单的 Python 模块，用于将人名解析成具体的组成部件

最匹配的包权重最高，因此 Gramps 看起来最符合要求。可以去 Python 网站（https://pypi.python.org/pypi/Gramps/3.4.2）来查看文档和下载链接。

另一个很流行的仓库是 GitHub。可以在“流行”（https://github.com/trending?l=python）中查看当前流行的 Python 包。

流行 Python 菜谱（http://code.activestate.com/recipes/langs/python/）有 4000 多个短 Python 程序，涉及多个方面。

12.3　安装包

有 3 种安装 Python 包的方法：

推荐使用 pip，你可以使用 pip 来安装绝大多数 Python 包；

有时可以使用操作系统自带的包管理工具；

从源代码安装。

如果你对同一个领域的多个包感兴趣，或许可以找到一个包含这些包的 Python 发行版。举例来说，在附录 C 中，可以使用一系列数学和科学程序，如果手动安装很麻烦，可以使用类似 Anaconda 这样的发行版。

12.3.1　使用pip

Python 的包有一些限制。之前有一个安装工具叫 easy_install，现在已经被 pip 替代了，但是它们都没有成为标准的 Python 安装工具。如果想使用 pip，如何安装它呢？从 Python 3.4 开始，pip 终于成为了 Python 的一部分，避免了不必要的步骤。如果使用的是 Python 3 之前的版本并且没有 pip，那你可以从 http://www.pip-installer.org 下载。

pip 最简单的使用方法就是通过下面的命令安装一个包的最新版：

$ pip install flask

你会看到详细的安装过程，这样就可以确保安装正常进行：下载，运行 setup.py，在硬盘上安装文件，等等。

也可以要求 pip 安装指定的版本：

$ pip install flask==0.9.0

或者指定最小版本（当你必须使用的一些特性在某个版本之后开始出现时，这个功能特别有用）：

$ pip install 'flask>=0.9.0'f

在这条命令中，单引号可以防止 shell 把 > 解析成输出重定向，那样会把输出写入一个名为 =0.9.0 的文件中。

如果你想安装多个 Python 包，可以使用 requirements 文件（https://pip.pypa.io/en/latest/refe-rence/pip_install.html#requirements-file-format）。虽然它有很多选项，但是最简单的使用方法是列出所有包，一个包一行，加上可选的目标版本或者相对版本：

$ pip -r requirements.txt

你的示例 requirements.txt 文件可能是这样：

flask==0.9.0
django
psycopg2

12.3.2　使用包管理工具

苹果的 OS X 中有第三方包管理工具 homebrew（brew，http://brew.sh/）和 ports（http://www.macports.org/）。它们的原理和 pip 类似，但并不是只能安装 Python 包。

Linux 的不同发行版有不同的包管理工具，最流行的是 apt-get、yum、dpkg 和 zypper。

Windows 有 Windows 安装工具，需要后缀为 .msi 的包文件。如果想在 Windows 上安装 Python，那可能就是 MSI 格式的。

12.3.3　从源代码安装

有时候，一个 Python 包是新出的，或者作者还没有把它发布到 pip 上。如果要安装这样的包，通常需要这样做：

(1) 下载代码；

(2) 如果是压缩文件，使用 zip、tar 或者其他合适的工具来解压缩；

(3) 在包含 setup.py 文件的目录中运行 python install setup.py。

　下载和安装时一定要小心。虽然在 Python 程序中加入病毒难度不小（因为这些程序是可读的文本），有时还是会遇到有毒的程序。

12.4　集成开发环境

我编写本书中的软件时使用的是纯文本编辑器，但是你不一定非要在命令行窗口或者纯文本编辑器中写代码。有许多免费和收费的集成开发环境（IDE），它们有图形窗口（GUI）并且支持文本编辑器、调试器、库搜索，等等。

12.4.1　IDLE

IDLE（https://docs.python.org/3/library/idle.html）是唯一一个标准发行版中包含的 Python IDE。它是基于 tkinter 开发的，图形界面比较简单。

12.4.2　PyCharm

PyCharm（http://www.jetbrains.com/pycharm/）是一个新出的 IDE，有许多特性。社区版是免费的，你也可以使用学生身份或者开源项目来获取免费的专业版许可。图 12-1 是初始界面。

图 12-1：PyCharm 的初始界面

12.4.3　IPython

IPython（http://ipython.org/）在附录 C 中有介绍，既是一个发布平台也是一个扩展 IDE。

12.5　命名和文档

你绝对记不住自己写过什么。很多次我看着自己最近写的代码，心里想的是：它们到底是从哪儿来的。这就是文档的重要性。文档可以包括注释和文档字符串，也可以把信息记录在变量名、函数名、模块名和类名中。不要像下面这样啰嗦：

>>> # 这里我要给变量"num"赋值10：
... num = 10
>>> # 我希望它确实赋值成功了
... print(num)
10
>>> # 好的

相反，要说清楚为什么要赋值 10，为什么变量名是 num。如果在编写华氏到摄氏度的转换，那应该让变量名自己表达它们的意义，而不是写一些魔法代码。加一些测试更好：

def ftoc(f_temp):
    "把华氏温度<f_temp>转换为摄氏温度并返回"
    f_boil_temp = 212.0
    f_freeze_temp = 32.0
    c_boil_temp = 100.0
    c_freeze_temp = 0.0
    f_range = f_boil_temp - f_freeze_temp
    c_range = c_boil_temp - c_freeze_temp
    f_c_ratio = c_range / f_range
    c_temp = (f_temp - f_freeze_temp) * f_c_ratio + c_freeze_temp
    return c_temp

if __name__ == '__main__':
    for f_temp in [-40.0, 0.0, 32.0, 100.0, 212.0]:
        c_temp = ftoc(f_temp)
        print('%f F => %f C' % (f_temp, c_temp))

运行测试：

$ python ftoc1.py

-40.000000 F => -40.000000 C
0.000000 F => -17.777778 C
32.000000 F => 0.000000 C
100.000000 F => 37.777778 C
212.000000 F => 100.000000 C

这段代码（至少）有两处可以改进的地方。

Python 没有常量，但是 PEP8 格式规范建议（http://legacy.python.org/dev/peps/pep-0008/#constants）使用大写字母和下划线（比如 ALL_CAPS）来表示常量名。我们据此修改示例中的常量。

我们基于常量值提前进行了一些计算，应该把它们移动到模块顶层，这样它们就只会计算一次，否则每次调用 ftoc() 时都需要计算。

修改后的版本如下所示：

F_BOIL_TEMP = 212.0
F_FREEZE_TEMP = 32.0
C_BOIL_TEMP = 100.0
C_FREEZE_TEMP = 0.0
F_RANGE = F_BOIL_TEMP - F_FREEZE_TEMP
C_RANGE = C_BOIL_TEMP - C_FREEZE_TEMP
F_C_RATIO = C_RANGE / F_RANGE

def ftoc(f_temp):
    "把华氏温度<f_temp>转换为摄氏温度并返回"
    c_temp = (f_temp - F_FREEZE_TEMP) * F_C_RATIO + C_FREEZE_TEMP
    return c_temp

if __name__ == '__main__':
    for f_temp in [-40.0, 0.0, 32.0, 100.0, 212.0]:
        c_temp = ftoc(f_temp)
        print('%f F => %f C' % (f_temp, c_temp))

12.6　测试代码

有时候，我会修改一些代码，然后对自己说：“看起来没问题，发布吧。”接着程序就出问题了。唉，每次遇到这种情况时（还好这种情况已经越来越少了），我都觉得自己是个笨蛋并发誓下次要写更多的测试。

测试 Python 程序最简单的办法就是添加一些 print() 语句。Python 交互式解释器的读取 - 求值 - 打印循环（REPL）允许快速添加和测试修改。然而，你或许不会想要在产品级代码中添加 print() 语句，因此需要记住自己添加的所有 print() 语句并在最后删除它们。但是，这样做很容易出现剪切 - 粘贴错误。

12.6.1　使用pylint、pyflakes和pep8检查代码

在创建真实的测试程序之前，需要运行 Python 代码检查器。最流行的是 pylint（http://www.pylint.org/）和 pyflakes（https://pypi.python.org/pypi/pyflakes/）。你可以使用 pip 来安装它们：

$ pip install pylint
$ pip install pyflakes

它们可以检查代码错误（比如在赋值之前引用变量）和代码风格问题（就像穿着格子衣服和条纹衣服一样）。下面是一段毫无意义的程序，有一个 bug 和一个风格问题：

a = 1
b = 2
print(a)
print(b)
print(c)

下面是 pylint 输出内容的一部分：

$ pylint style1.py

No config file found, using default configuration
************* Module style1
C:  1,0: Missing docstring
C:  1,0: Invalid name "a" for type constant
  (should match (([A-Z_][A-Z0-9_]*)|(__.*__))$)
C:  2,0: Invalid name "b" for type constant
  (should match (([A-Z_][A-Z0-9_]*)|(__.*__))$)
E:  5,6: Undefined variable 'c'

往下翻，Global evaluation 下面是我们的分数（最高为 10.0）：

Your code has been rated at -3.33/10

好吧。我们首先来修复 bug。pylint 输出中以 E 开头的表示这是一个 Error（错误），原因是我们在给 c 赋值之前打印了它。修复一下：

a = 1
b = 2
c = 3
print(a)
print(b)
print(c)

$ pylint style2.py

No config file found, using default configuration
************* Module style2
C:  1,0: Missing docstring
C:  1,0: Invalid name "a" for type constant
  (should match (([A-Z_][A-Z0-9_]*)|(__.*__))$)
C:  2,0: Invalid name "b" for type constant
  (should match (([A-Z_][A-Z0-9_]*)|(__.*__))$)
C:  3,0: Invalid name "c" for type constant
  (should match (([A-Z_][A-Z0-9_]*)|(__.*__))$)

好的，没有 E 了，分数也从 -3.33 变成了 4.29：

Your code has been rated at 4.29/10

pylint 需要一个文档字符串（出现在模块或者函数内部第一行的一段短文本，用来描述代码），而且它认为短变量名 a、b 和 c 太土了。我们来让 pylint 高兴点儿，把 style2.py 修改为 style3.py：

"这里是模块文档字符串"

def func():
    "函数的文档字符串在这里。妈妈我在这儿！"
    first = 1
    second = 2
    third = 3
    print(first)
    print(second)
    print(third)

func()

$ pylint style3.py

No config file found, using default configuration

嘿，没有任何抱怨了。我们的分数呢？

Your code has been rated at 10.00/10

还可以，是吧？

另一个格式检查工具是 pep8（https://pypi.python.org/pypi/pep8），可以使用熟悉的方式安装：

$ pip install pep8

它对我们的代码有何意见呢？

$ pep8 style3.py

style3.py:3:1: E302 expected 2 blank lines, found 1

为了满足格式要求，它建议我在文档字符串后面添加一个空行。

12.6.2　使用unittest进行测试

我们已经通过了代码风格的考验，下面该真正地测试程序逻辑了。

最好先编写独立的测试程序，在提交代码到源码控制系统之前确保通过所有测试。写测试看起来是一件很麻烦的事，但是它们真的能帮助你更快地发现问题，尤其是回归测试（破坏之前还能正常工作的代码）。工程师们已经从惨痛的经历中领悟到一个真理：即使是很小的看起来没有任何问题的改动，也可能出问题。如果看那些优秀的 Python 包就会发现，它们大多都有测试集。

标准库中有两个测试包。首先介绍 unittest。假设我们编写了一个单词首字母转大写的模块，第一版直接使用标准字符串函数 capitalize()，之后会看到许多意料之外的结果。把下面的代码保存为 cap.py：

def just_do_it(text):
    return text.capitalize()

测试就是先确定输入对应的期望输出（本例期望的输出是输入文本的首字母大写版本），然后把输入传入需要测试的函数，并检查返回值和期望输出是否相同。期望输出被称为断言，因此在 unittest 中，可以使用 assert（断言）开头的方法来检查返回的结果，比如下面代码中的 assertEqual 方法。

把下面的测试脚本保存为 test_cap.py：

import unittest
import cap

class TestCap(unittest.TestCase):

    def setUp(self):
        pass

    def tearDown(self):
        pass

    def test_one_word(self):
        text = 'duck'
        result = cap.just_do_it(text)
        self.assertEqual(result, 'Duck')

    def test_multiple_words(self):
        text = 'a veritable flock of ducks'
        result = cap.just_do_it(text)
        self.assertEqual(result, 'A Veritable Flock Of Ducks')

if __name__ == '__main__':
    unittest.main()

setUp() 方法会在每个测试方法执行之前执行，tearDown() 方法是在每个测试方法执行之后执行。它们通常用来分配和回收测试需要的外部资源，比如数据库连接或者一些测试数据。在本例中，我们的测试方法已经足够进行测试，因此不需要再定义 setUp() 和 tearDown()，但是放一个空方法也没关系。我们测试的核心是函数 test_one_word() 和 test_multiple_words()。它们会运行我们定义的 just_do_it() 函数，传入不同的输出并检查返回值是否和期望输出一样。

运行一下这个脚本，它会调用那两个测试方法：

$ python test_cap.py

 F.
 ======================================================================
 FAIL: test_multiple_words (__main__.TestCap)
 ----------------------------------------------------------------------
 Traceback (most recent call last):
   File "test_cap.py", line 20, in test_multiple_words
  self.assertEqual(result, 'A Veritable Flock Of Ducks')
 AssertionError: 'A veritable flock of ducks' != 'A Veritable Flock Of Ducks'
 - A veritable flock of ducks
 ?   ^         ^     ^  ^
 + A Veritable Flock Of Ducks
 ?   ^         ^     ^  ^

 ----------------------------------------------------------------------
 Ran 2 tests in 0.001s

 FAILED (failures=1)

看起来第一个测试（test_one_word）通过了，但是第二个（test_multiple_words）失败了。上箭头（^）指出了字符串不相同的地方。

为什么多个单词会失败？可以阅读 string 的 capitalize（https://docs.python.org/3/library/stdtypes.html#str.capitalize）函数文档来寻找线索：它只会把第一个单词的第一个字母转成大写。或许我们应该先阅读文档。

为了修复错误，我们需要另一个函数。往下翻一翻网页，可以看到 title() 函数（https://docs.python.org/3/library/stdtypes.html#str.title）。我们把 cap.py 中的 capitalize() 替换成 title()：

def just_do_it(text):
    return text.title()

再次运行测试，看看结果如何：

$ python test_cap.py

 ..
 ----------------------------------------------------------------------
 Ran 2 tests in 0.000s

 OK

看起来没问题了。不过，其实还是有问题的。我们还需要在 test_cap.py 中添加另一个方法：

def test_words_with_apostrophes(self):
    text = "I'm fresh out of ideas"
    result = cap.just_do_it(text)
    self.assertEqual(result, "I'm Fresh Out Of Ideas")

再试一次：

$ python test_cap.py

 ..F
 ======================================================================
 FAIL: test_words_with_apostrophes (__main__.TestCap)
 ----------------------------------------------------------------------
 Traceback (most recent call last):
   File "test_cap.py", line 25, in test_words_with_apostrophes
     self.assertEqual(result, "I'm Fresh Out Of Ideas")
 AssertionError: "I'M Fresh Out Of Ideas" != "I'm Fresh Out Of Ideas"
 - I'M Fresh Out Of Ideas
 ?   ^
 + I'm Fresh Out Of Ideas
 ?   ^

----------------------------------------------------------------------
Ran 3 tests in 0.001s

FAILED (failures=1)

函数把 I'm 中的 m 大写了。浏览一下 title() 的文档会发现，它不能处理撇号。我们真得应该先完整地阅读一遍文档。

在标准库 string 文档的底部有另一个函数：一个名为 capwords() 的辅助函数。试试这个：

def just_do_it(text):
    from string import capwords
    return capwords(text)

$ python test_cap.py

 ...
 ----------------------------------------------------------------------
 Ran 3 tests in 0.004s

 OK

终于完成了！呃，还有问题。向 test_cap.py 中再加一个测试：

def test_words_with_quotes(self):
    text = "\"You're despicable,\" said Daffy Duck"
    result = cap.just_do_it(text)
    self.assertEqual(result, "\"You're Despicable,\" Said Daffy Duck")

能通过吗？

$ python test_cap.py

 ...F
 ======================================================================
 FAIL: test_words_with_quotes (__main__.TestCap)
 ----------------------------------------------------------------------
 Traceback (most recent call last):
   File "test_cap.py", line 30, in test_words_with_quotes
     self.assertEqual(result, "\"You're
     Despicable,\" Said Daffy Duck")
 AssertionError: '"you\'re Despicable," Said Daffy Duck'
 != '"You\'re Despicable," Said Daffy Duck'
 - "you're Despicable," Said Daffy Duck
 ?  ^
 + "You're Despicable," Said Daffy Duck
 ?  ^

 ----------------------------------------------------------------------
 Ran 4 tests in 0.004s

 FAILED (failures=1)

似乎第一个双引号并没有被目前为止最好用的函数 capwords 正确处理。它试着把 " 转成大写，并把其他内容转成小写（You're）。此外，字符串的其余部分应该保持不变。

做测试的人可以发现这些边界条件，但是开发者在面对自己的代码时通常有盲区。

unittest 提供了数量不多但非常有用的断言，你可以用它们检查值、确保类能够匹配、判断是否触发错误，等等。

12.6.3　使用doctest进行测试

标准库中的第二个测试包是 doctest（https://docs.python.org/3/library/doctest.html）。使用这个包可以把测试写到文档字符串中，也可以起到文档的作用。它看起来有点像交互式解释器：字符 >>> 后面是一个函数调用，下一行是执行结果。你可以在交互式解释器中运行测试并把结果粘贴到测试文件中。我们修改一下 cap.py（暂时不考虑那个双引号的问题）：

def just_do_it(text):
    """
    >>> just_do_it('duck')
    'Duck'
    >>> just_do_it('a veritable flock of ducks')
    'A Veritable Flock Of Ducks'
    >>> just_do_it("I'm fresh out of ideas")
    "I'm Fresh Out Of Ideas"
    """
    from string import capwords
    return capwords(text)

if __name__ == '__main__':
    import doctest
    doctest.testmod()

运行时如果测试全部通过不会产生任何输出：

$ python cap.py

加上冗杂选项（-v），看看会出现什么：

$ python cap.py -v

 Trying:
     just_do_it('duck')
 Expecting:
     'Duck'
 ok
 Trying:
     just_do_it('a veritable flock of ducks')
 Expecting:
     'A Veritable Flock Of Ducks'
 ok
 Trying:
     just_do_it("I'm fresh out of ideas")
 Expecting:
     "I'm Fresh Out Of Ideas"
 ok
 1 items had no tests:
     __main__
 1 items passed all tests:
    3 tests in __main__.just_do_it
 3 tests in 2 items.
 3 passed and 0 failed.
 Test passed.

12.6.4　使用nose进行测试

第三方包 nose（https://nose.readthedocs.org/en/latest/）和 unittest 类似。下面是安装命令：

$ pip install nose

不需要像使用 unittest 一样创建一个包含测试方法的类。任何名称中带 test 的函数都会被执行。我们修改一下之前的 unittest 示例并保存为 test_cap_nose.py：

import cap
from nose.tools import eq_

def test_one_word():
    text = 'duck'
    result = cap.just_do_it(text)
    eq_(result, 'Duck')

def test_multiple_words():
    text = 'a veritable flock of ducks'
    result = cap.just_do_it(text)
    eq_(result, 'A Veritable Flock Of Ducks')

def test_words_with_apostrophes():
    text = "I'm fresh out of ideas"
    result = cap.just_do_it(text)
    eq_(result, "I'm Fresh Out Of Ideas")

def test_words_with_quotes():
    text = "\"You're despicable,\" said Daffy Duck"
    result = cap.just_do_it(text)
    eq_(result, "\"You're Despicable,\" Said Daffy Duck")

运行测试：

$ nosetests test_cap_nose.py

 ...F
 ======================================================================
 FAIL: test_cap_nose.test_words_with_quotes
 ----------------------------------------------------------------------
 Traceback (most recent call last):
   File "/Users/.../site-packages/nose/case.py", line 198, in runTest
     self.test(*self.arg)
   File "/Users/.../book/test_cap_nose.py", line 23, in test_words_with_quotes
     eq_(result, "\"You're Despicable,\" Said Daffy Duck")
 AssertionError: '"you\'re Despicable," Said Daffy Duck'
     != '"You\'re Despicable," Said Daffy Duck'

 ----------------------------------------------------------------------
 Ran 4 tests in 0.005s

 FAILED (failures=1)

这和我们使用 unittest 进行测试得到的错误一样。幸运的是，在本章结尾的练习中，我们会修复它。

12.6.5　其他测试框架

出于某些原因，开发者会编写 Python 测试框架。如果你很感兴趣，可以试试 tox（http://tox.readthedocs.org/en/latest/）和 py.test（http://pytest.org/latest/）。

12.6.6　持续集成

当你的团队每天会产生很多代码时，就需要在出现改动时进行自动测试。你可以让源码控制系统，在提交代码时进行自动化测试。这样每个人都知道是谁破坏了构建并消失在吃午饭的人群中。

这些系统很庞大，我不会在这里介绍安装和使用方法。如果某一天，你需要用到它们，至少知道可以去哪儿找。

buildbot（http://buildbot.net/）

用 Python 写成的源码控制系统，可以自动构建、测试和发布。

jenkins（http://jenkins-ci.org/）

用 Java 写成，应该是目前最受欢迎的 CI（持续集成）系统。

travis-ci（http://travis-ci.com/）

这个自动化项目托管在 GitHub 上，对开源项目是免费的。

12.7　调试Python代码

“调试的难度是写代码的两倍。以此类推，如果你绞尽脑汁编写巧妙的代码，那你一定无法调试它。”

——Brian Kernighan

测试先行。测试越完善，之后需要修复的 bug 越少。不过，bug 总是无法避免的，发现 bug 就要去修复它。之前就说过，Python 中最简单的调试方法就是打印字符串。vars() 是非常有用的一个函数，可以提取本地变量的值，包括函数参数：

>>> def func(*args, **kwargs):
...     print(vars())
...
>>> func(1, 2, 3)
{'args': (1, 2, 3), 'kwargs': {}}
>>> func(['a', 'b', 'argh'])
{'args': (['a', 'b', 'argh'],), 'kwargs': {}}

就像 4.9 节介绍的，装饰器可以在不修改函数代码的前提下，在函数运行之前或者之后执行其他代码。这意味着你可以使用装饰器在任何 Python 函数（不仅是你自己写的函数）之前或者之后做一些事情。我们定义一个装饰器 dump，它可以打印出输入的参数和函数的返回值（对设计师来说，垃圾堆通常需要装饰一下 1）：

1dump 的意思是倾倒垃圾，在计算机中是一个常用的术语，用来打印一些信息。——译者注

def dump(func):
    "打印输入参数和输出值"
    def wrapped(*args, **kwargs):
        print("Function name: %s" % func.__name__)
        print("Input arguments: %s" % ' '.join(map(str, args)))
        print("Input keyword arguments: %s" % kwargs.items())
        output = func(*args, **kwargs)
        print("Output:", output)
        return output
    return wrapped

下面是被装饰函数。这个函数名为 double()，接收带名称和不带名称的数值参数，把它们的值乘 2 并放到一个列表中返回：

from dump1 import dump

@dump
def double(*args, **kwargs):
    "每个参数乘2"
    output_list = [ 2 * arg for arg in args ]
    output_dict =  { k:2*v for k,v in kwargs.items() }
    return output_list, output_dict

if __name__ == '__main__':
    output = double(3, 5, first=100, next=98.6, last=-40)

运行结果：

$ python test_dump.py

Function name: double
Input arguments: 3 5
Input keyword arguments: dict_items([('last', -40), ('first', 100),
 ('next', 98.6)])
Output: ([6, 10], {'last': -80, 'first': 200, 'next': 197.2})

12.8　使用pdb进行调试

上面提到的技能很有用，但仍然无法代替真正的调试器。大多数 IDE 都自带了调试器，有各种各样的特性和用户界面。这里，我会介绍标准的 Python 调试器 pdb（https://docs.python.org/3/library/pdb.html）。

　如果使用 -i 标志运行程序，出现错误时 Python 会自动进入交互式解释器。

下面来看一个在处理某些数据时存在 bug 的程序，这种 bug 很难发现。这是计算机早期出现的一个真实的 bug，困扰了程序员们很长时间。

我们需要从一个文件中读出国家和它们的首都，它们由逗号分割：首都，国家。它们的大小写可能不正确，在输出时需要修复这个问题。哦，还有可能出现多余的空白，需要正确处理。最后，虽然程序知道是否到达文件结尾，出于某些原因，我们的领导希望在遇到单词 quit 时终止程序（大小写可能不正确）。下面是数据文件示例：

France, Paris
venuzuela,caracas
  LithuniA,vilnius
     quit

我们来设计一下算法（解决问题的方法）。下面是伪代码，它看起来像一个程序，其实只是用普通的语言来描述逻辑，还需要转换成真实的程序。程序员喜欢 Python 的一个原因就是它看起来很像伪代码，因此把伪代码转换成真正的程序比较简单：

for each line in the text file:
    read the line
    strip leading and trailing spaces
    if 'quit' occurs in the lower-case copy of the line:
        stop
    else:
        split the country and capital by the comma character
        trim any leading and trailing spaces
        convert the country and capital to titlecase
        print the capital, a comma, and the country

为了满足要求，需要去掉名字首尾的空格，还需要使用小写形式来和 quit 进行比较，并把城市和国家名转换成首字母大写。据此，我们可以快速写出 capitals.py，看起来应该可以正常工作：

def process_cities(filename):
    with open(filename, 'rt') as file:
        for line in file:
            line = line.strip()
            if 'quit' in line.lower():
                return
            country, city = line.split(',')
            city = city.strip()
            country = country.strip()
            print(city.title(), country.title(), sep=',')

if __name__ == '__main__':
    import sys
    process_cities(sys.argv[1])

使用之前准备好的示例数据文件来测试一下。预备、瞄准、发射：

$ python capitals.py cities1.csv
Paris,France
Caracas,Venuzuela
Vilnius,Lithunia

很好！通过测试，我们把它放到生产环境中测试一下，使用下面的数据文件来处理首都和国家名称，直到出错：

argentina,buenos aires
bolivia,la paz
brazil,brasilia
chile,santiago
colombia,Bogotá
ecuador,quito
falkland islands,stanley
french guiana,cayenne
guyana,georgetown
paraguay,Asunción
peru,lima
suriname,paramaribo
uruguay,montevideo
venezuela,caracas
quit

运行程序，只打印出 5 行内容，但是数据文件中有 15 行：

$ python capitals.py cities2.csv
Buenos Aires,Argentina
La Paz,Bolivia
Brazilia,Brazil
Santiago,Chile
Bogotá,Colombia

发生了什么？我们可以修改 capitals.py，在一些可能出错的地方添加 print() 语句，不过这里尝试一下调试器。

如果要使用调试器，需要在命令行中使用 -m pdb 来导入 pdb 模块，如下所示：

$ python -m pdb capitals.py cities2.csv

> /Users/williamlubanovic/book/capitals.py(1)<module>()
-> def process_cities(filename):
(Pdb)

这条命令会启动程序并停在第一行。如果你输入 c（继续），程序会一直运行下去，直到正常结束或者出现错误：

(Pdb) c

Buenos Aires,Argentina
La Paz,Bolivia
Brazilia,Brazil
Santiago,Chile
Bogotá,Colombia
The program finished and will be restarted
> /Users/williamlubanovic/book/capitals.py(1)<module>()
-> def process_cities(filename):

程序正常结束，和没使用调试器之前一样。我们再试一次，使用一些命令来缩小问题出现的范围。看起来这是一个逻辑错误，而不是语法错误或者异常（否则会打印出错误信息）。

输入 s（单步）来一行一行执行 Python 代码。这会单步执行所有 Python 代码：你自己的、标准库的、你用到的其他模块的。使用 s 时也会进入函数内部并继续单步执行。输入 n（下一个）也可以单步执行，但是不会进入函数；如果遇到一个函数，n 会执行整个函数并前进到下一行代码。因此，不确定问题在哪时使用 s，确定问题不在函数中时使用 n，函数很长时尤其有用。通常来说，你需要单步执行自己的代码，跳过库代码，因为后者往往已经通过测试。我们在程序开头使用 s 来进入函数 process_cities()：

(Pdb) s

 > /Users/williamlubanovic/book/capitals.py(12)<module>()
 -> if __name__ == '__main__':

(Pdb) s

 > /Users/williamlubanovic/book/capitals.py(13)<module>()
 -> import sys

(Pdb) s

 > /Users/williamlubanovic/book/capitals.py(14)<module>()
 -> process_cities(sys.argv[1])

(Pdb) s

 --Call--
 > /Users/williamlubanovic/book/capitals.py(1)process_cities()
 -> def process_cities(filename):

(Pdb) s

 > /Users/williamlubanovic/book/capitals.py(2)process_cities()
 -> with open(filename, 'rt') as file:

输入 l（列表）来查看之后的几行：

 (Pdb) l

  1      def process_cities(filename):
  2  ->     with open(filename, 'rt') as file:
  3             for line in file:
  4                 line = line.strip()
  5                 if 'quit' in line.lower():
  6                     return
  7                 country, city = line.split(',')
  8                 city = city.strip()
  9                 country = country.strip()
 10                 print(city.title(), country.title(), sep=',')
 11
(Pdb)

箭头（->）指示当前行。

我们可以继续使用 s 或者 n，看看是否能发现问题，不过这里使用调试器的另一个重要特性：断点。断点会把程序暂停在你指定的位置。在本例中，我们想知道 process_cities() 为什么在读完所有输入之前退出。第 3 行（for line in file:）会读入输入文件的每一行，看起来没什么问题。函数中能在读入所有数据之前的地方只有第 6 行（return）。我们在第 6 行设置一个断点：

(Pdb) b 6

Breakpoint 1 at /Users/williamlubanovic/book/capitals.py:6

接着继续运行程序，直到停在断点或者读入所有内容之后正常退出：

(Pdb) c

 Buenos Aires,Argentina
 La Paz,Bolivia
 Brasilia,Brazil
 Santiago,Chile
 Bogotá,Colombia
 > /Users/williamlubanovic/book/capitals.py(6)process_cities()
 -> return

它在第 6 行的断点停下了，也就是说，程序在处理完哥伦比亚之后就准备退出了。打印 line 的值，看看读入的是什么：

 (Pdb) p line

'ecuador,quito'

它有什么特别的吗？没有啊。

真的吗？ *quit*o ？我们的领导一定不希望正常数据中的字符串 quit 终止程序运行，看起来用它当哨兵值（表示终止运行）似乎是一个愚蠢的主意。你应该马上告诉他，我在这儿等你。

如果现在你还没丢掉工作，可以使用 b 命令查看所有的断点：

(Pdb) b

Num Type         Disp Enb   Where
1   breakpoint   keep yes   at /Users/williamlubanovic/book/capitals.py:6
    breakpoint already hit 1 time

l 命令会显示代码行，当前行（->）和断点（B）。l 命令默认会显示从上次使用 l 之后直到现在的所有代码，可以包含一个可选的起始行（这里从第 1 行开始）：

(Pdb) l 1

  1      def process_cities(filename):
  2         with open(filename, 'rt') as file:
  3             for line in file:
  4                 line = line.strip()
  5                 if 'quit' in line.lower():
  6 B->                 return
  7                 country, city = line.split(',')
  8                 city = city.strip()
  9                 country = country.strip()
 10                 print(city.title(), country.title(), sep=',')
 11

好了，修复一下 quit 问题，只让它在匹配整行时退出：

def process_cities(filename):
    with open(filename, 'rt') as file:
        for line in file:
            line = line.strip()
            if 'quit' == line.lower():
                return
            country, city = line.split(',')
            city = city.strip()
            country = country.strip()
            print(city.title(), country.title(), sep=',')

if __name__ == '__main__':
    import sys
    process_cities(sys.argv[1])

再试一次：

$ python capitals2.py cities2.csv

Buenos Aires,Argentina
La Paz,Bolivia
Brasilia,Brazil
Santiago,Chile
Bogotá,Colombia
Quito,Ecuador
Stanley,Falkland Islands
Cayenne,French Guiana
Georgetown,Guyana
Asunción,Paraguay
Lima,Peru
Paramaribo,Suriname
Montevideo,Uruguay
Caracas,Venezuela

本章简单介绍了一下调试器，只是告诉你调试器可以做什么以及最常用的命令。

记住：测试越多，调试越少。

12.9　记录错误日志

有时候，你需要使用比 print() 更高端的工具来记录日志。日志通常是系统中的一个文件，用于持续记录信息。信息中通常会包含很多有用的内容，比如时间戳或者运行程序的用户的名字。通常来说，日志每天会被旋转（重命名）并压缩，这样它们就不会占用太多磁盘空间。如果程序出错，你可以查看对应的日志文件来了解发送了什么。异常信息非常重要，因为它们会告诉你出错的行数和原因。

Python 标准库模块中有一个 logging（https://docs.python.org/3/library/logging.html）。我发现关于它的许多描述都很难懂。使用一段时间之后可以更好地理解，但是对于新手来说，有点太复杂了。logging 模块包含以下的内容：

你想保存到日志中的消息；

不同的优先级以及对应的函数：debug()、info()、warn()、error() 和 critical()；

一个或多个 logger 对象，主要通过它们使用模块；

把消息写入终端、文件、数据库或者其他地方的 handler；

创建输出的 formatter；

基于输入进行筛选的过滤器。

下面是最简单的日志示例，导入模块并使用它的函数：

>>> import logging
>>> logging.debug("Looks like rain")
>>> logging.info("And hail")
>>> logging.warn("Did I hear thunder?")
WARNING:root:Did I hear thunder?
>>> logging.error("Was that lightning?")
ERROR:root:Was that lightning?
>>> logging.critical("Stop fencing and get inside!")
CRITICAL:root:Stop fencing and get inside!

看到了吗，debug() 和 info() 什么都没做，另外两个函数在每条消息之前打印出来级别 :root:。到目前为止，它们很像有个性的 print() 语句，有些还对我们抱有敌意 2。

2因为有些消息的语气很严厉。——译者注

不过它们很有用。你可以在一个日志文件中搜索指定级别的消息，通过比较时间戳来看，在服务器崩溃之前发生了什么。

查看文档之后我们找到了第一个问题的答案（第二个稍后介绍）：默认的优先级是 WARNING，所以前两个函数没有输出。当调用第一个函数（logging.debug()）时它就被锁定了。我们可以使用 basicConfig() 来设置默认的级别。DEBUG 是最低一级，因此下面的例子会打印出所有级别的消息：

>>> import logging
>>> logging.basicConfig(level=logging.DEBUG)
>>> logging.debug("It's raining again")
DEBUG:root:It's raining again
>>> logging.info("With hail the size of hailstones")
INFO:root:With hail the size of hailstones

上面的例子都是直接使用默认的 logging 函数，没有创建 logger 对象。每个 logger 都有一个名称。创建一个名为 bunyan 的 logger：

>>> import logging
>>> logging.basicConfig(level='DEBUG')
>>> logger = logging.getLogger('bunyan')
>>> logger.debug('Timber!')
DEBUG:bunyan:Timber!

如果 logger 名称中包含点号，会生成不同层级的 logger，每层可以有不同的属性。也就是说，名为 quark 的 logger 比名为 quark.charmed 的层级更高。特殊的 root logger 在最顶层，它的名字是 ''。

到目前为止，我们只是打印出消息，和 print() 的差别并不大。可以使用 handler 把消息输出到不同的地方。最常见的是写入日志文件，如下所示：

>>> import logging
>>> logging.basicConfig(level='DEBUG', filename='blue_ox.log')
>>> logger = logging.getLogger('bunyan')
>>> logger.debug("Where's my axe?")
>>> logger.warn("I need my axe")
>>>

啊哈，这些内容并没有出现在屏幕上，而是写入了文件 blue_ox.log：

DEBUG:bunyan:Where's my axe?
WARNING:bunyan:I need my axe

调用 basicConfig() 时使用 filename 参数会创建一个 FileHandler 并对 logger 进行设置。logging 模块至少包含 15 种 handler，比如电子邮件、Web 服务器、屏幕和文件。

最后，你可以控制消息的格式。在我们的第一个例子中，默认的格式是这样：

WARNING:root:消息...

如果给 basicConfig() 传入 format 字符串，可以改变格式：

>>> import logging
>>> fmt = '%(asctime)s %(levelname)s %(lineno)s %(message)s'
>>> logging.basicConfig(level='DEBUG', format=fmt)
>>> logger = logging.getLogger('bunyan')
>>> logger.error("Where's my other plaid shirt?")
2014-04-08 23:13:59,899 ERROR 1 Where's my other plaid shirt?

我们修改了消息的格式并让 logger 把消息输出到屏幕。logging 模块可以识别出 fmt 格式化字符串中的变量名。我们使用了 asctime（一个包含日期和时间的 ISO 8601 字符串）、levelname、lineno（行号）和 message。它们都是内置的变量，你也可以创建自定义变量。

logging 还有许多这里没有提到的功能，比如说，你可以同时把消息输出到多个位置，每个位置都有不同的优先级和格式。这个包的扩展性很强，不过有时候会牺牲一些简洁性。

12.10　优化代码

一般来说，Python 已经足够快了，直到它不够快的那一刻之前。大多数情况下，你都可以使用更好的算法或者数据结构来加速，关键是知道把这些技巧用在哪里。即使是经验丰富的程序员也常常会犯错误。你需要像裁缝一样耐心，在裁剪之前认真测量。下面来介绍一下计时器。

12.10.1　测量时间

之前已经看到过，time 模块中的 time 函数会返回一个浮点数，表示当前的纪元时间。测量时间最简单的方法就是先获取当前时间、做一些事情、获取新的时间，然后用新时间减去初始时间。具体代码是 time1.py：

from time import time

t1 = time()
num = 5
num *= 2
print(time() - t1)

在本例中，我们测量了把 5 赋值给变量 num 并给它乘以 2 所需要的时间。这并不是真正的基准测试，只是告诉你如何测量 Python 代码的执行时间。把它运行几次，看看变化幅度：

$ python time1.py
2.1457672119140625e-06
$ python time1.py
2.1457672119140625e-06
$ python time1.py
2.1457672119140625e-06
$ python time1.py
1.9073486328125e-06
$ python time1.py
3.0994415283203125e-06

大概是两百万或者三百万分之一秒。试试更慢的代码，比如 sleep。如果睡眠一秒，计时器应该比一秒稍大一些。把下面的代码保存为 time2.py：

from time import time, sleep

t1 = time()
sleep(1.0)
print(time() - t1)

为了检验我们的猜测，多次运行这段代码：

$ python time2.py
1.000797986984253
$ python time2.py
1.0010130405426025
$ python time2.py
1.0010390281677246

意料之中，它需要运行一秒多一点。如果不是这个结果，那计时器和 sleep() 肯定有一个出了问题。

有一种更简单的方法来测量代码片段的执行时间：标准模块 timeit（https://docs.python.org/3/library/timeit.html）。它有一个函数叫作（你应该能猜到）timeit()，这个函数会运行你的测试代码 count 次并打印结果。调用形式：timeit.timeit(code，number，count)。

在本节的例子中，code 需要放在引号中，这样它们会在 timeit() 内部运行，而不是直接运行。（下一节会看到如何用 timeit() 来测量函数的运行时间。）运行一次之前的示例代码并计时。把下面的代码存为 timeit1.py：

from timeit import timeit
print(timeit('num = 5; num *= 2', number=1))

运行几次：

$ python timeit1.py
2.5600020308047533e-06
$ python timeit1.py
1.9020008039660752e-06
$ python timeit1.py
1.7380007193423808e-06

和上面差不多，这两行代码需要大约两百万分之一秒来运行。我们可以使用 timeit 模块的 repeat() 函数的 repeat 参数来运行多次。把下面的代码保存为 timeit2.py：

from timeit import repeat
print(repeat('num = 5; num *= 2', number=1, repeat=3))

运行一下：

$ python timeit2.py
[1.691998477326706e-06, 4.070025170221925e-07, 2.4700057110749185e-07]

第一次运行用了大约两百万分之一秒，第二和第三次运行快了很多。为什么？原因可能有很多，比如说，由于我们测试的代码片段太小，它的运行速度取决于计算机同时在做的其他事情、Python 系统对计算的优化方式以及其他许多事。

也有可能只是碰巧。下面来尝试一些相比变量赋值和 sleep 更加真实的代码。我们会测量一些代码并比较不同算法（程序逻辑）和数据结构（存储方式）的效率。

12.10.2　算法和数据结构

Python 之禅（http://legacy.python.org/dev/peps/pep-0020/）提到，应该有一种，最好只有一种，明显的解决方法。不幸的是，有时候并没有那么明显，你需要比较各种方案。举例来说，如果要构建一个列表，使用 for 循环好还是列表解析好？我们如何定义更好？是更快、更容易理解、占用内存更少还是更具 Python 风格？

在接下来的练习中，我们会用不同的方式构建列表，比较速度、可读性和 Python 风格。下面是 time_lists.py：

from timeit import timeit

def make_list_1():
    result = []
    for value in range(1000):
        result.append(value)
    return result

def make_list_2():
    result = [value for value in range(1000)]
    return result

print('make_list_1 takes', timeit(make_list_1, number=1000), 'seconds')
print('make_list_2 takes', timeit(make_list_2, number=1000), 'seconds')

在每个函数中，我们都向列表添加了 1000 个元素，还分别调用两个函数 1000 次。注意，这里调用 timeit() 时，传入的第一个参数是函数名不是字符串形式的代码。运行一下：

$ python time_lists.py
make_list_1 takes 0.14117428699682932 seconds
make_list_2 takes 0.06174145900149597 seconds

列表解析至少比用 append() 添加元素快两倍。通常来说，列表解析要比手动添加快。

可以使用这个方法来加速你的代码。

12.10.3　Cython、NumPy和C扩展

如果你已经尽了最大努力仍然无法达到想要的速度，还有一些方法可以选择。

Cython（http://cython.org/）混合了 Python 和 C，它的设计目的是把带有性能注释的 Python 代码翻译成 C 代码。这些注释非常简单，比如声明一些变量、函数参数或者函数返回值的类型。对于科学上的数字运算循环来说，添加这些注释之后会让程序快很多，速度能达到之前的一千倍。可以在 Cython wiki（https://github.com/cython/cython/wiki）查看文档和示例。

NumPy 是 Python 的一个数学库，它是用 C 语言编写的，运行速度很快。你可以在附录 C 中阅读 NumPy 的相关信息。

为了提高性能并且方便使用，Python 的很多代码和标准库都是用 C 写成并用 Python 进行封装。这些钩子可以直接在你的程序中使用。如果你熟悉 C 和 Python 并且真的想提高程序性能，可以写一个 C 扩展，这样做难度很大但是效果很好。

12.10.4　PyPy

大约 20 年前，Java 刚出现时，速度就像一只得了关节炎的雪纳瑞一样慢。当 Sun 和其他公司看到它的价值之后，它们投入了几百万美元来优化 Java 的解释器和底层的 Java 虚拟机（JVM），借鉴了其他语言（比如 Smalltalk 和 LISP）的许多技术。微软也投入了很大精力来优化它的 C# 语言和 .NET 虚拟机。

Python 不属于任何人，因此没人投入这么多努力来提高它的速度。你使用的很可能是标准的 Python 实现。它是由 C 写成，通常被称为 CPython（和 Cython 不一样）。

和 PHP、Perl 甚至 Java 一样，Python 并不会被编译成机器语言，而是被翻译成中间语言（通常被称为字节码或者 p- 代码），然后被虚拟机解释执行。

PyPy（http://pypy.org/）是一个新出现的 Python 解释器，实现了许多 Java 中的加速技术。它的基准测试（http://speed.pypy.org/）显示 PyPy 几乎完全超越了 CPython——平均快 6 倍，最高快 20 倍。它支持 Python 2 和 Python 3，你可以下载并用它代替 CPython。PyPy 在不断改进，某一天可能会取代 CPython。可以阅读官网的最新发布内容，看看是否可以用在你的项目上。

12.11　源码控制

当你在一个小团队或者小项目中工作时，通常很容易跟踪自己的改动，直到你犯了很愚蠢的错误并花费很多时间来修复它。源码控制系统会保护你的代码不被破坏（比如被你自己的愚蠢错误）。如果你和其他开发者在一个团队工作，源码控制就变得极其重要。这个领域有很多收费和开源的包可以用。在 Python 所处的开源领域，最流行的是 Mercurial 和 Git，它们都是分布式版本控制系统，会生成代码仓库的多个副本。早期的系统，比如 Subversion，是工作在单个服务器上的。

12.11.1　Mercurial

Mercurial（http://mercurial.selenic.com/）是用 Python 写成的。它很容易学习，有很多有用的子命令，比如从 Mercurial 仓库下载代码、添加文件、提交改动、从不同的源合并改动。bitbucket（https://bitbucket.org/）和其他网站（https://mercurial.selenic.com/wiki/MercurialHosting）提供了免费或者收费的托管服务。

12.11.2　Git

Git（http://git-scm.com/）最初是用于 Linux 内核开发，但是现在基本上已经统治了开源领域。它和 Mercurial 很像，不过有些人发现它更难精通。GitHub（https://github.com/）是最大的 Git 托管平台，拥有超过一百万个仓库。除了 GitHub，还有一些其他的 Git 托管平台（https://git.wiki.kernel.org/index.php/GitHosting）。

本书中的程序示例都放在 GitHub（https://github.com/madscheme/introducing-python）的一个公共 Git 仓库中。如果你的计算机上有 git 程序，可以使用下面的命令来下载这些程序：

$ git clone https://github.com/madscheme/introducing-python

也可以点击 GitHub 页面上的按钮来下载代码：

点击“Clone in Desktop”来打开桌面版的 git，如果之前已经安装过；

点击“Download ZIP”来下载压缩归档的程序。

如果你没有 git，但是想尝试一下，可以阅读安装教程（http://git-scm.com/book/en/v2/Getting-Started-Installing-Git）。我会介绍命令行版本的 git，不过你也可以试试 GitHub 这样的网站，它们会提供额外的服务并且有时候会更好用。git 有许多特性，但有时候不太直观。

下面来尝试一下 git。我们不会太深入，不过会展示一些命令和它们的输出。

创建一个新目录并进入：

$ mkdir newdir
$ cd newdir

在当前目录 newdir 中创建一个本地 Git 仓库：

$ git init

Initialized empty Git repository in /Users/williamlubanovic/newdir/.git/

在 newdir 中创建一个 Python 文件 test.py，内容如下所示：

print('Oops')

把这个文件添加到 Git 仓库：

$ git add test.py

Git 先生，感觉如何？

$ git status

On branch master

Initial commit

Changes to be committed:
  (use "git rm --cached <file>..." to unstage)

    new file:   test.py

这表示 test.py 已经是本地仓库的一部分，但是它的改动还没有被提交。我们来提交一下：

$ git commit -m "simple print program"

 [master (root-commit) 52d60d7] my first commit
  1 file changed, 1 insertion(+)
  create mode 100644 test.py

-m "my first commit" 是你的提交说明。如果忽略说明， git 会打开一个编辑器并要求你输入说明。它会被记录到这个文件在 git 中的提交历史里。

看看现在的状态：

$ git status

 On branch master
 nothing to commit, working directory clean

好，所有改动都已经提交。这表示我们可以进行任何改动并且不用担心丢失原始版本。修改一下 test.py，把 Oops 改成 Ops! 并保存文件：

print('Ops!')

再来看看 git 的感觉：

$ git status

On branch master
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

    modified: test.py

no changes added to commit (use "git add" and/or "git commit -a")

使用 git diff 来查看上次提交之后的改动：

$ git diff

 diff --git a/test.py b/test.py
 index 76b8c39..62782b2 100644
 --- a/test.py
 +++ b/test.py
 @@ -1 +1 @@
 -print('Oops')
 +print('Ops!')

如果尝试提交改动，git 会抱怨：

$ git commit -m "change the print string"

On branch master
Changes not staged for commit:
    modified:   test.py

no changes added to commit

staged for commit 的意思是需要先 add 文件，可以把这个动作翻译成：嘿，Git，往这儿看：

$ git add test.py

也可以输入 git add . 来添加当前目录下的所有改动文件。如果你修改了很多文件并且想要提交所有改动，这个命令会非常方便。现在提交改动：

$ git commit -m "my first change"

 [master e1e11ec] my first change
  1 file changed, 1 insertion(+), 1 deletion(-)

如果想查看你对 test.py 做的所有事情，按照时间倒序排列，可以使用 git log：

$ git log test.py

 commit e1e11ecf802ae1a78debe6193c552dcd15ca160a
 Author: William Lubanovic <bill@madscheme.com>
 Date:   Tue May 13 23:34:59 2014 -0500

     change the print string

 commit 52d60d76594a62299f6fd561b2446c8b1227cfe1
 Author: William Lubanovic <bill@madscheme.com>
 Date:   Tue May 13 23:26:14 2014 -0500

     simple print program

12.12　复制本书代码

你可以下载本书中所有程序的代码。查看 Git 仓库（https://github.com/madscheme/introducing-python）并按照教程把仓库复制到你的本地电脑上。如果你有 git，可以运行命令 git clone https://github.com/madscheme/introducing-python 来复制仓库。你也可以下载 zip 格式的文件。

12.13　更多内容

这本书只是入门教程，大部分内容是基础知识，实践方面内容不多。下面，我会推荐一些有用的 Python 教程。

12.13.1　书

下面是我觉得非常有用的图书，从入门到精通都有，涵盖了 Python 2 和 Python 3：

Barry, Paul. Head First Python. O'Reilly, 2010.

Beazley, David M. Python Essential Reference (4th Edition). Addison-Wesley, 2009.

Beazley, David M. and Brian K. Jones. Python Cookbook (3rd Edition). O'Reilly, 2013.

Chun, Wesley. Core Python Applications Programming (3rd Edition). Prentice Hall, 2012.

McKinney, Wes. Python for Data Analysis: Data Wrangling with Pandas, NumPy, and IPython. O'Reilly, 2012.

Summerfield, Mark. Python in Practice: Create Better Programs Using Concurrency, Libraries, and Patterns. Addison-Wesley, 2013.

当然，除此之外还有很多（https://wiki.python.org/moin/PythonBooks）。

12.13.2　网站

可以在下面这些网站上找到很多有用的教程：

Zed Shaw 的 Learn Python the Hard Way（http://learnpythonthehardway.org/book/）

Mark Pilgrim 的 Dive Into Python 3（http://www.diveintopython3.net/）

Michael Driscoll 的 Mouse Vs. Python（http://www.blog.pythonlibrary.org/）

如果你对 Python 世界的新东西很感兴趣，可以看看下面这些网站：

comp.lang.python（https://groups.google.com/forum/#!forum/comp.lang.python）

comp.lang.python.announce（https://groups.google.com/forum/#!forum/comp.lang.python.announce）

python subreddit（http://www.reddit.com/r/python）

Planet Python（http://planet.python.org/）

最后是一些下载 Python 代码的好地方：

The Python Package Index（https://pypi.python.org/pypi）

stackoverflow Python questions（http://stackoverflow.com/questions/tagged/python）

ActiveState Python recipes（http://code.activestate.com/recipes/langs/python/）

Python packages trending on GitHub（https://github.com/trending?l=python）

12.13.3　社区

有很多计算机社区：热心的、好辩的、沉闷的、时髦的、无聊的，等等。Python 社区非常友好和民主。你可以基于位置发现 Python 社区——meetups（http://python.meetup.com/）和遍布全世界（https://wiki.python.org/moin/LocalUserGroups）的本地用户社区。还有一些分布式的基于共同兴趣的社区。举例来说，PyLadies（http://www.pyladies.com/）是由那些对 Python 和开源感兴趣的女性组成。

12.13.4　大会

Python 有遍布全世界（https://www.python.org/community/workshops/）的大会（http://www.pycon.org/）和实践课程，每年在南美（https://us.pycon.org/2015/）和欧洲（https://europython.eu/en/）举办的大会规模最大。

12.14　后续内容

等等，还没结束！附录 A、附录 B 和附录 C 分别介绍了 Python 在艺术、商业和科学方面的应用。你至少能找到一个感兴趣的包。

互联网上有很多闪闪发亮的东西，只有你自己知道，对你来说，哪些是装饰品哪些是银弹 3。即使你现在还没有遇到狼人，也应该在口袋中备一些银弹，以防万一。

3传说中，银弹是狼人的克星。在《人月神话》中，作者用银弹比喻能快速解决难题的方法。这里作者的意思也是如此，学会 Python 以备不时之需。——译者注

书的最后是每章结尾练习的答案、安装 Python 和其相关内容的详细教程，以及我觉得很有用的速查表。虽然你对这些东西已经很熟悉了，不过万一需要可以去书后查阅。

附录 A　Python 的艺术

“艺术就是艺术，对吧？同样，水就是水！东就是东，西就是西。如果你把越橘炖得像苹果酱一样，那么它尝起来就比大黄更像西梅。”

——Groucho Marx

你可能是个艺术家、抑或是音乐家，或者你只是想尝试一些不同的能激发无限创造力的事情。

前三个附录收录了人们在不同场合、不同领域借助 Python 进行的探索。如果你对其中的某些领域感兴趣，可能会从中获取些许灵感，或产生动手尝试的冲动。

A.1　2D图形

几乎所有的计算机编程语言或多或少都会被应用到图形处理中。尽管本章用到的许多功能强大的平台，出于效率考虑，都是由 C 或 C++ 编写的，但它们大都喜欢用 Python 编写的库来扩展其功能。我们先来看一些 2D 成像库。

A.1.1　标准库

标准库中与图形相关的库并不多，这里介绍其中的两个。

imghdr

这个模块用于检测图片文件的文件格式。

colorsys

这个模块用于在不同的颜色系统（RGB、YIQ、HSV 以及 HLS）间进行转换。

如果你已经把 O'Reilly 的标志下载到本地并存储为 oreilly.png，可以直接运行下面这段代码：

>>> import imghdr
>>> imghdr.what('oreilly.png')
'png'

为了在 Python 中对图片进行更高级的处理，我们需要安装一些第三方包。先来看看有哪些包可用。

A.1.1　PIL和Pillow

多年以来，尽管 Python 图片库（Python Image Library，PIL，http://effbot.org/imagingbook/pil-index.htm）并不属于 Python 的标准库，但它一直是 Python 中最著名的 2D 图片处理库。由于它在包安装器 pip 之前就已出现，因此我们无法用便捷的 pip 命令进行安装。对此，有人创建了一个友好的项目分支 Pillow（http://pillow.readthedocs.org/）。Pillow 向后兼容 PIL，并且文档更为规范，因此这里我们选择使用 Pillow 作为替代。

安装过程非常简单，在终端中执行下面的命令即可：

$ pip install Pillow

如果你曾经安装过 libjpeg、libfreetype 和 zlib 这几个系统包，它们会被 Pillow 检测出来并且直接使用，不再重复安装。你可以在安装页（http://pillow.readthedocs.org/en/latest/installation.html）了解更多安装细节。

打开一个图片文件：

>>> from PIL import Image
>>> img = Image.open('oreilly.png')
>>> img.format
'PNG'
>>> img.size
(154, 141)
>>> img.mode
'RGB'

尽管包的名字是 Pillow，但为了让它与旧的 PIL 兼容，我们需要将它引用为 PIL。

使用 Image 对象的 show() 方法可以在屏幕上显示图片，但为此你需要先安装 ImageMagick 包。下一小节会介绍这个包，到时候试着运行下面的代码：

>>> img.show()

执行上述代码后，屏幕上会跳出一个新窗口，如图 A-1 所示。（截图是在 Mac OS X 系统下截取的，show() 实际上打开了 Mac 中预览程序来显示图片。不同系统下的图片浏览窗口可能有所不同。）

图 A-1：使用 Python 库打开的图片

将放入内存中的图片裁剪一下，把结果存储为一个新的对象 img2 并重新显示它。图片总是以水平坐标值（x）和垂直坐标值（y）计量，它们以图片的一角作为参考。这个角被称作原点，它的 x 坐标和 y 坐标都是 0。我们使用的库中，原点（0, 0）定义为图片的左上角，x 向右增加，y 向下增加。想要给 crop() 方法传入 4 个参数：左侧距 x（55）、顶距 y（70）、右侧距 x（85）以及底距 y（100），需要先将它们按顺序封装在元组中。

>>> crop = (55, 70, 85, 100)
>>> img2 = img.crop(crop)
>>> img2.show()

结果如图 A-2 所示。

图 A-2：裁剪后的图片

使用 save 方法存储图片。它接收一个文件名参数以及一个可选参数——图片类型。如果文件名中有扩展名，库函数会直接使用这个扩展名作为图片的格式，当然你也可以显式指定使用的格式。执行下面的代码可以将裁剪后的图片存储为 GIF 文件：

>>> img2.save('cropped.gif', 'GIF')
>>> img3 = Image.open('cropped.gif')
>>> img3.format
'GIF'
>>> img3.size
(30, 30)

我们来美化一下我们的小吉祥物吧。首先将胡须图片（http://www.pinkmoustache.net/wp-content/uploads/2009/12/moustaches.png）下载到本地，存储为 moustaches.png。接着将它加载到内存中，进行适当的裁剪，然后把它置于之前图片的上层：

>>> mustache = Image.open('moustaches.png')
>>> handlebar = mustache.crop( (316, 282, 394, 310) )
>>> handlebar.size
(78, 28)
>>> img.paste(handlebar, (45, 90) )
>>> img.show()

图 A-3 展示了最佳效果。

图 A-3：全新帅气的吉祥物

如果胡须图片的背景是透明的话效果会更好。不如把这个当作练习吧！如果你想要把上面的例子做到最好，查看一下 Pillow 教程（http://pillow.readthedocs.org/en/latest/handbook/tutorial.html）中关于透明度和 alpha 值的相关内容，然后自己动手试试吧！

A.1.3　ImageMagick

ImageMagick（http://www.imagemagick.org/script/index.php）是一套用于转换、修改、显示 2D 位图的程序。它已经有 20 余年的历史了。许多第三方 Python 库都会应用到 ImageMagick C 库，其中最近的一个支持 Python 3 的库是 wand（http://docs.wand-py.org/en/0.4.0/）。执行下面的语句来安装它：

$ pip install Wand

你可以使用 wand 做许多 Pillow 能做到的事情：

>>> from wand.image import Image
>>> from wand.display import display
>>>
>>> img = Image(filename='oreilly.png')
>>> img.size
(154, 141)
>>> img.format
'PNG'

和 Pillow 类似，下面的语句用于显示图片：

>>> display(img)

wand 包含旋转、调整大小、添加文本、画线、格式转化等强大的功能，这些功能你也可以在 Pillow 中找到。Wand 和 Pillow 的 API 和文档都非常规范好用。

A.2　图形用户界面

GUI 这个名词虽然包括图形这个词，但它的重点更在于用户界面（展示数据用的小控件、输入的方法、菜单、按钮以及包含所有这些的窗口等）上。

GUI 编程（https://wiki.python.org/moin/GuiProgramming）的 wiki 页以及 FAQ（https://docs.python.org/3.3/faq/gui.html）列出了许可用 Python 编写的 GUI。我们从唯一一个内置于标准库的 Tkinter（https://wiki.python.org/moin/TkInter）开始介绍。它功能不多，但可以在所有平台上创建符合接近原生界面的窗口以及控件。

下面是一个极简的 Tkinter 程序，它可以在新建的窗口中显示我们最爱的小吉祥物，它有着曲棍球似的眼睛：

>>> import tkinter
>>> from PIL import Image, ImageTk
>>>
>>> main = tkinter.Tk()
>>> img = Image.open('oreilly.png')
>>> tkimg = ImageTk.PhotoImage(img)
>>> tkinter.Label(main, image=tkimg).pack()
>>> main.mainloop()

注意，上面的代码用到了 PIL/Pillow 中的一些模块。你应该又一次见到了 O'Reilly 的标志，如图 A-4 所示。

图 A-4：使用 Tkinter 库显示的图片

想要关闭窗口的话只需点击关闭按钮或者关闭 Python 解释器即可。

你可以在 tkinter wiki（http://tkinter.unpythonic.net/wiki/）和 Python wiki（https://wiki.python.org/moin/TkInter）上看到更多关于 Tkinter 的信息。现在来看一些不包含在标准库中的 GUI。

Qt（http://qt-project.org/）

Qt 是一款由挪威 Trolltech 公司开发的专业级 GUI 及应用工具包，已有 20 多年的历史。像著名的 Google Earth、Maya 以及 Skype 都是由 Qt 协助完成的。它还被用于一款桌面版 Linux——KDE 的开发。Qt 有两个主要的 Python 库版本：其中 PySide（http://wiki.qt.io/PySide）是免费的（LGPL 许可），PyQt（http://www.riverbankcomputing.com/software/pyqt/intro）则是基于 GPL 许可付费使用的。你可以在这里看到它们之间的差别（http://wiki.qt.io/Differences_Between_PySide_and_PyQt），可以从 PyPi（https://pypi.python.org/pypi/PySide）或 Qt（http://qt-project.org/wiki/Get-PySide）网站下载 Qt 并阅读相关教程（http://qt-project.org/wiki/PySide_Tutorials），可以在线（http://www.riverbankcomputing.com/software/pyqt/download5）免费下载 Qt 主体。

GTK+（http://www.gtk.org/）

GTK+ 是 Qt 的一个强劲对手，它也被广泛应用于包括 GIMP 和 Gnome（一款桌面版 Linux）在内的许多应用（http://gtk-apps.org/）的开发。它与 Python 通过 PyGTK（http://www.pygtk.org/）进行绑定。你可以访问 PyGTK 的官网（http://www.pygtk.org/downloads.html）下载代码、阅读相关文档（http://python-gtk-3-tutorial.readthedocs.org/en/latest/）。

WxPython（http://www.wxpython.org/）

用于将 Python 与 WxWidgets（http://www.wxwidgets.org/）进行绑定。WxWidgets 也是一款功能强大的 UI 工具包，你可以从网上（http://wxpython.org/download.php）免费下载。

Kivy（http://kivy.org/）

Kivy 是一个用于搭建跨平台——PC 端（Windows、OS X、Linux）及移动端（Android、 iOS）——多媒体UI的现代库。它包含对于多点触控的支持。你可以从 Kivy 官网（http://kivy.org/#download）下载所有平台对应的库文件。它还提供了不同平台的应用开发教程（http://kivy.org/docs/gettingstarted/intro.html）。

网络

像 Qt 这样的框架依赖的都是本地控件，然而也有一些其他的框架使用的是网络。毕竟网络是一种通用的 GUI，它包含图形（SVG）、文本（HTML）甚至现在还可以包含多媒体（HTML5）。使用 Python 编写的基于网络的 GUI 工具包括 RCTK（Remote Control Toolkit，https://code.google.com/p/rctk/）以及 Muntjac（http://muntiacus.org/）。你可以创建支持任意前端（基于浏览器）和后端（网络服务器）组合的网络程序。在瘦客户端模式下，后端会完成大部分工作。而如果前端起支配作用的话，我们称之为厚客户端、胖客户端或富客户端（最后一个词显然有拍马屁的嫌疑）。端与端之间的通信常使用 RESTful API、AJAX 以及 JSON。

A.3　3D图形和动画

现在几乎每一部电影的片尾字幕里都包含了许多制作特效和动画的人名。许多大型工作室——华特迪士尼动画、ILM、维塔数码、梦工厂、皮克斯——都会雇不少具有 Python 经验的人。你可以上网搜索“python 动画职位”看看，或者到 vfxjobs 上搜索关键词“python”看看有多少职位空缺。

如果你喜欢探索 Python，喜欢 3D、动画、多媒体以及游戏的话，应该试试 Panda3D（http://www.panda3d.org/）。它开源、完全免费，你甚至可以用它搭建商业应用。你可以从 Panda3D 官网（http://www.panda3d.org/download.php?sdk）上下载与你电脑系统对应的版本。如果你想要测试一下它自带的示例程序，需要先将终端的路径切换到 /Developer/Examples/Panda3D。这个目录中的每一个子目录都包含了一个或多个 .py 文件。使用 Panda3D 的 ppython 命令来运行这些示例程序。例如：

$ cd /Developer/Examples/Panda3D
$ cd Ball-in-Maze/
$ ppython Tut-Ball-in-Maze.py
DirectStart: Starting the game.
Known pipe types:
  osxGraphicsPipe
(all display modules loaded.)

一个类似图 A-5 的窗口会打开。

图 A-5：使用 Panda3D 库显示的图片

你可以使用鼠标来倾斜盒子让球在迷宫中前进。

如果一切运行正常，Panda3D 的环境搭建也一切顺利，那么恭喜你，现在就可以开始使用这个库了。

下面是一个来自于 Panda3D 文档的示例程序，将它保存为 panda1.py：

from direct.showbase.ShowBase import ShowBase

class MyApp(ShowBase):

    def __init__(self):
        ShowBase.__init__(self)

        # 加载环境模型
        self.environ = self.loader.loadModel("models/environment")
        # 为模型重定父级以渲染
        self.environ.reparentTo(self.render)
        # 对模型应用尺度和位置变换
        self.environ.setScale(0.25, 0.25, 0.25)
        self.environ.setPos(-8, 42, 0)

app = MyApp()
app.run()

使用下面的命令运行它：

$ ppython panda1.py
Known pipe types:
  osxGraphicsPipe
(all display modules loaded.)

运行后会弹出一个窗口，如图 A-6 所示。当前的石头和树是悬浮在半空中的。点击 Next 按钮，按照教程继续，它会引导你解决这个问题。

图 A-6：使用 Panda3D 库显示的适应窗口大小的图片

下面是其他一些可以在 Python 中使用的 3D 包。

Blender（http://www.blender.org/）

Blender 是一个免费的 3D 动画和游戏的生成器。当你从 http://www.blender.org/download/ 下载并安装了它后，它会绑定一个自带的 Python 3。

Maya（http://www.autodesk.com/products/maya/overview）

Maya 是一个商用的 3-D 动画和图形处理系统。它也会绑定一个自带的 Python，目前绑定的是 Python 2.6。Chad Vernon 编写了 Python Scripting for Maya Artists（http://www.chadvernon.com/blog/resources/python-scripting-for-maya-artists/），你可以在网上免费下载。如果你在网上搜索 Python 和 Maya，可以找到许多其他资源，包括一些视频资料，它们有些是免费的，有些可能是付费的。

Houdini（https://www.sidefx.com/）

Houdini 是一个商用系统，但你可以下载它的一个免费版本 Apprentice。和其他动画包一样，它也有自己绑定的 Python（http://www.sidefx.com/docs/houdini13.0/hom/）。

A.4　平面图、曲线图和可视化

Python 是目前绘制平面图、曲线图以及进行数据可视化的最佳解决方法。它在科学研究中尤其受到欢迎，你可以在附录 C 了解相关应用。Python 的官方网站上关于可视化有一些概论性内容（https://wiki.python.org/moin/NumericAndScientific/Plotting）。在这里我想花一点时间做一些额外介绍。

A.4.1　matplotlib

使用下面的命令可以免费安装 matplotlib 2D 绘图库：

$ pip install matplotlib

官网图片库（http://matplotlib.org/gallery.html）中的例子展示了 matplotlib 的应用范围之广。我们使用和之前一样的程序来展示图片（如图 A-7 所示），这里只是为了看看需要编写的代码以及显示结果如何：

import matplotlib.pyplot as plot
import matplotlib.image as image

img = image.imread('oreilly.png')
plot.imshow(img)
plot.show()

图 A-7：使用 matplotlib 库显示的图片

你可以在附录 C 中看到更多关于 matplotlib 的内容，它与 NumPy 以及其他一些研究性应用关系十分密切。

A.4.2　bokeh

在网络发展初期，为了在线显示图形，开发者们需要在服务器端将其生成好并给浏览器返回对应的 URL。但如今，JavaScript 的表现能力越来越强，一些像 D3 之类的在客户端生成图形的工具也越发流行。前几页中，我曾提到过可以使用 Python 搭建前 - 后端框架用于展示图形和 GUI。一款名为 bokeh（http://bokeh.pydata.org/en/latest/）的新工具将 Python 的能力（大数据集、易于使用）与 JavaScript（交互性、图形绘制延迟小）结合了起来。它致力于大数据集的快速可视化。

如果你已经安装过 bokeh 所依赖的库（NumPy、Pandas 和 Redis），那么现在可以通过下面一行简单的命令来安装 bokeh：

$ pip install bokeh

（你可以在附录 C 看到 NumPy 和 Pandas 的作用。）

或者，你也可以从 Bokeh 官网（http://bokeh.pydata.org/en/latest/docs/quickstart.html）下载并一口气安装所有所需内容。尽管 matplotlib 是运行在服务器上的，bokeh 主要在浏览器上运行，它可以充分利用最近客户端能力的提升。点击官网图片库（http://bokeh.pydata.org/en/latest/docs/gallery.html）中的图片可以进行交互式查看，并查阅实现该效果所用的 Python 代码。

A.5　游戏

Python 非常适合数据处理，在这一附录中，你又发现了 Python 在处理多媒体上的强大功能。那么关于游戏呢？

没错，碰巧 Python 还是一个绝佳的游戏开发平台。使用 Python 开发游戏的书籍不计其数，例如下面两本：

Al Sweigart 编写的 Invent Your Own Computer Games with Python（http://inventwithpython.com）

Horst Jens 编写的 The Python Game Book（一本 wiki 文档式的书，http://thepythongamebook.com/en:start）

Python 的 wiki 页（https://wiki.python.org/moin/PythonGames）上包含了更多关于使用 Python 开发游戏的讨论。

最著名的 Python 游戏开发平台要数 pygame（http://pygame.org/news.html）了。你可以从 Pygame 官网（http://pygame.org/download.shtml）上下载对应平台的可执行安装包进行安装，并阅读 pummel the chimp 游戏的逐行实例（http://pygame.org/docs/tut/chimp/ChimpLineByLine.html）。

A.6　音频和音乐

Python 还能做什么？音频、音乐？或者让我的猫学会唱 Jingle Bell ？

好吧，其实只有前两个能做到。

标准库的多媒体服务（http://docs.python.org/3/library/mm.html）下有一些基本的音频模块，当然还有其他许多第三方模块（https://wiki.python.org/moin/Audio）可以使用。

下面这些库可以帮你生成音乐：

pyknon（https://github.com/kroger/pyknon）用在 Pedro Kroger（CreateSpace） 的 Music for Geeks and Neds（https://github.com/kroger/pyknon）一书中；

mingus（https://code.google.com/p/mingus/）是一个音序器，用于读取 / 生成 MIDI 文件；

remix（http://echonest.github.io/remix/python.html），就像名字本身暗示的一样，是一组混音用的 API。它的应用之一是 morecowbell.dj（http://morecowbell.dj）这个网站，它能往你上传的歌曲中添加牛铃声；

sebastian（https://github.com/jtauber/sebastian/）是一个用于音乐理论分析的库；

Piano（http://jugad2.blogspot.com/2013/04/play-piano-on-your-computer-with-python.html）可以让你用键盘来弹奏不同调性（例如 C、D、E、F、A、B）的音乐。

最后，下面列出的库可以帮助你整理你的音乐集或访问音乐数据：

Beets（http://beets.radbox.org/）用于管理音乐集；

Echonest（http://developer.echonest.com/）是对音乐元数据进行访问用的 API；

Monstermash（http://karlgrz.com/monstermash-flask-zeromq-and-echonest-remix/）用于对音乐片段进行拼接合成，它建立在 Echonest、Flask、ZeroMQ 以及 Amazon EC2 上；

Shiva（https://hacks.mozilla.org/2013/03/shiva-more-than-a-restful-api-to-your-music-collection/）是一个用来访问音乐集的 RESTful API 和服务器（https://github.com/tooxie/shiva-server）；

下载一个 album art（http://jameh.github.io/mpd-album-art/）来查找匹配你的音乐。

附录 B　工作中的 Python

“生意！”鬼魂攥紧手凄凉地说道，“我为什么不把人看成是我的生意的一部分……”

—— 查尔斯·狄更斯的《圣诞颂歌》

男性商务人士总是习惯一身西装戴着领带。但不知为何，每当他们要真正开始干活时总是磨磨蹭蹭的，他们会先把夹克扔到椅背上，然后解开领带，卷起袖口，再冲些咖啡。与此同时，女员工往往已经默默地把活都干完了。也许会有一杯拿铁相伴。

在实际商业活动中，我们会使用到前面曾介绍过的所有技术——数据库、互联网、系统和网络。Python 凭借着它极高的生产力被广泛应用于企业（http://opensource.com/life/13/11/python-enterprise-jessica-mckellar）和创业公司（http://opensource.com/business/13/12/why-python-perfect-startups）。

在商业界，人们曾在很长的一段时间内寻求能够一击解决遗产问题（legacy problem）的银色子弹。这些遗产问题包括：互不兼容的文件格式、晦涩难懂的网络协议、开发语言独占以及文档准确性普遍不高。然而今天，我们已经可以看到一些科学技术开始可以协同工作、成规模发展。通过采用下面这些建议，公司可以创建更快捷、更便宜且更易于扩展的应用：

像 Python 一样的动态语言

将互联网作为通用 GUI

使用 RESTful API 作为独立于语言的服务接口

关系型数据库和 NoSQL 数据库

大数据及大数据分析

通过“云”进行部署，节省开支

B.1　Microsoft Office套件

当今商业的正常运行对 Microsoft Office 应用及其文件格式有着很强的依赖性。Python 中有一些可以对 Office 文件进行处理的库，但它们大都没什么名气，甚至有些连像样的文档都没有。下面是一些可以用来处理 Office 文件的 Python 库的例子。

docx（https://pypi.python.org/pypi/docx）

这个库可以用于创建、读取、写入 Microsoft Office Word 2007 及以上使用的 .docx 文件。

python-excel（http://www.python-excel.org/）

这个库有一份 PDF 教程（http://www.simplistix.co.uk/presentations/python-excel.pdf），介绍了如何使用 xlrd、xlwt 和 xlutils 模块。Excel 还可以对逗号分隔值（Comma-Saparated Value，CSV）格式的文件进行读写。你应该已经知道如何使用 csv 模块处理 CSV 文件了。

oletools（http://www.decalage.info/python/oletools）

这个库可以从 Office 格式的文件中抽取数据。

下面这些模块可自动化处理 Windows 应用：

pywin32（http://sourceforge.net/projects/pywin32/）

这个模块可以自动化处理许多 Windows 应用 1。但是它仅限在 Python 2 环境中使用，而且文档不全。你可以参考博客：http://www.galalaly.me/index.php/2011/09/use-python-to-parse-microsoft-word-documents-using-pywin32-library/ 和 http://www.blog.pythonlibrary.org/2010/07/16/python-and-microsoft-office-using-pywin32/。

pywinauto（https://code.google.com/p/pywinauto/）

这个模块也是用于自动化处理 Windows 应用的，同样限制于 Python 2。使用方法可以参考这篇博客：https://techsaju.wordpress.com/2013/03/06/using-python-pywinauto-for-automating-windows-gui-applications/。

swapy（https://code.google.com/p/swapy/）

swapy 可以直接根据用户的原始操作生成对应的 pywinauto 的代码。

1类似于 Windows 中宏的作用，它可以记录你执行的可重复性操作便于以后复用。——译者注

OpenOffice（http://www.openoffice.org/）是一款开源的 Office 应用的替代品。它可以在 Linux、Unix、Windows 以及 OS X 上运行，可以对 Office 格式的文件进行读写操作。OpenOffice 自身也绑定了供自己使用的 Python 3。你可以通过 PyUNO 库（http://www.openoffice.org/udk/python/python-bridge.html）对 OpenOffice 进行 Python 编程（https://wiki.openoffice.org/wiki/Python）。

OpenOffice 是由 Sun Microsystems 开发的，当甲骨文将它收购之后，许多用户害怕未来会无法免费使用这款产品。LibreOffice（https://www.libreoffice.org/）因此横空出世。DocumentHacker（https://documenthacker.files.wordpress.com/2013/07/writing_documents-_for_software_engineers_v0002.pdf）描述了如何使用 Python 的 UNO 库对 LibreOffice 进行编程。

为了对 Office 文件进行读写操作，OpenOffice 和 LibreOffice 不得不对 Microsoft 的文件格式进行逆向解析，这个过程难度可不小。通用 Office 转换器（Universal Office Converter，http://dag.wiee.rs/home-made/unoconv/）就依赖于 OpenOffice 和 LibreOffice 中的 UNO 库。 这个转换器可以转换多种文件格式：文档、电子表格、图表和演示文稿。

你可以使用 python-magic（https://github.com/ahupp/python-magic）对格式未知文件中的某些字节序进行分析从而猜测出它的具体格式。

python open document 库（http://appyframework.org/pod.html）允许你根据模板格式提供对应的 Python 代码来创建动态文档。

Adobe 的 PDF 格式在商业中也十分常见，尽管它并不属于 Micorsoft 格式家族。ReportLab（http://www.reportlab.com/opensource/）是一个基于 Python 的 PDF 文件生成器，它包含一个开源版本和一个商用版本。如果你需要用 Python 编辑一个 PDF 文件的话，也许可以从 StackOverflow（http://stackoverflow.com/questions/1180115/add-text-to-existing-pdf-using-python）获取些帮助。

B.2　执行商业任务

无论需要做什么事情，你几乎都可以找到对应的 Python 模块来帮助你。访问 PyPI（https://pypi.python.org/pypi）并将你想要搜索的内容敲进搜索框就可以了。你可能会对其中一些与商业任务相关的模块有兴趣：

使用 Fedex（https://github.com/gtaylor/python-fedex）或 UPS（https://github.com/openlabs/PyUPS）运送货物。

使用 stamps.com（https://github.com/jzempel/stamps）API 邮递。

阅读关于 Python for business intelligence（http://www.slideshare.net/Stiivi/python-business-intelligence-pydata-2012-talk）的讨论。

爱乐压咖啡机在 Anoka 的畅销是正常的消费行为还是有什么内情？ Cubes（http://cubes.databrewery.org/）是一个可以进行联机分析处理（Online Analytical Processing，OLAP）的网络服务器，它同时还是一个数据浏览器，可以帮你对上述问题进行分析。

OpenERP（https://www.openerp.com/）是一个大型的商用企业资源计划（Enterprise Resource Planning，ERP）系统。它是由 Python 和 JavaScript 编写的，拥有数千个扩展模块。

B.3　处理商业数据

商业界对数据情有独钟。但不幸的是，它们总是莫名其妙地制造麻烦让数据变得难以使用。

电子表格是一项伟大的发明，随着时间的推移商业界对它愈发痴迷。许多非程序员被诱骗去编程，仅仅因为他们编码的被称作宏（macro）而不是程序。但就如同宇宙在不断扩张一样，数据也在飞速膨胀。旧版本的 Excel 最多支持 65 536 行数据，即使是新版本也只不过能支持百万行。当一个企业所拥有的数据量超过了单一计算机所能承载的最大值，就像企业员工数过百一样——瞬间你会发现旧框架变得不再适用，你亟需新的层级结构、新的媒介以及新的通信方式。

海量数据处理程序的诞生并不是由单机上庞大的数据量导致的，而是由于不同来源的数据倾注到单一业务中聚合导致的。关系型数据库可以同时处理百万行数据而不崩溃，但这种性能也只有在集中写入或更新数据时才能达到。旧的纯文本文件或二进制文件可以增长到 GB 级别的大小，但如果你需要一口气对它进行全面处理的话，就必须有足够大的内存（否则只能分块读取到内存中进行处理）。传统的桌面程序并不是为解决这些问题而设计的。但像 Google 和亚马逊这样的公司则不得不去寻找大规模处理数据的解决方案。Netflix（http://techblog.netflix.com/2013/03/python-at-netflix.html）就是一个例子，它建立在亚马逊的 AWS 云系统上，使用 Python 将 RESTful API、信息安全、部署调度、数据库有机地结合了起来，提供了一套完整的解决方案。

B.3.1　提取、转换、加载

数据就像一座冰山，复杂的操作都埋藏在水下。其中占最大部分的、同时也是最重要的是关于获取数据的操作。企业中对于处理数据常用的术语是提取、转换、加载（Extracting，Transforming，Loading），也就是所谓的 ETL。类似的同义词还有 data munging 或 data wrangling，给人一种驯服凶猛野兽的感觉 2，比喻得还算恰当。现在看来，这似乎只是一个解决了的工程问题，但其实如何优雅巧妙地处理海量数据还是需要一定艺术灵感的。附录 C 会涉及更多关于数据科学的内容，这是许多开发者需要花费大部分时间挣扎的地方。

2munging 和 wrangling 有争吵、斗争的意思。——译者注

如果你看过《绿野仙踪》，可能还记得（除了飞天猴外）快结尾的部分：善良的巫师告诉多萝西，其实她只要敲敲她的红鞋子就可以回到堪萨斯的家了。尽管当时我还小，但还是有一种“拖了这么久，她终于告诉多萝西了！”的感觉。事后回顾，我意识到，如果女巫早点就把这个诀窍告诉多萝西的话，那这电影就没法拍了。

但在这里我们讨论的不是电影，而是商业。在商业，能把任务尽快完成是一件好事。所以，我想要在这里告诉你们一些窍门。你每天工作中用来处理数据的大多数工具都在这本书的前面有所涉及，包括高度抽象的数据结构，例如字典、对象，成千上万个标准库和第三方库以及一个满是高手的社区，离你只有 Google 一下的距离。

如果你是一个从事商务的程序员，那你每天的工作流程几乎都逃不开下面这些步骤：

(1) 从奇怪格式的文件和数据库中抽取出目标数据；

(2) 清洗数据，包括各种苦差事，你需要和各种各样的对象打交道；

(3) 进行转换工作，例如日期的转换、时间的转换以及字符集的转换；

(4) 真正开始处理数据；

(5) 将结果数据存储在文件或者数据库中；

(6) 回到第一步重新继续；给数据抹上肥皂，清洗，一遍又一遍。

举个例子：设想你需要将电子表格中的一些数据挪到数据库里。一种可行的方式是将电子表格存储为 CSV 格式，然后用第 8 章介绍过的 Python 库进行处理。或者也可以搜索看看有没有可以直接读取二进制电子表格文件的 Python 模块。你的手会自然而然地在 Google 里敲入 python excel，找到像 Working with Excel files in Python（http://www.python-excel.org/）这样的网站。接着你可以使用 pip 安装你感兴趣的包，然后定位 Python 的数据库驱动为后续工作做好准备。第 8 章也提到过 SQLAlchemy 以及直接与底层数据库打交道的驱动。准备工作就绪，现在我们可以在这两者之间编写点代码了，这里才是 Python 的数据结构以及库真正开始帮你节省时间的地方。

首先来看一个例子，之后我们会尝试使用一些库来简化其中某些步骤。我们需要读取一个 CSV 文件，然后以某一列的值作为键，将具有相同键的数据进行聚合（这里使用加法聚合）。如果在 SQL 中进行这些操作的话，会用到 SELECT、JOIN、GROUP BY 等命令。

首先关于待处理文件，zoo.csv，包含以下几列内容：动物种类、咬伤游客的次数、处理咬伤需要缝合的针数以及为了防止游客告知当地媒体所支付的赔偿金。

animal,bites,stitches,hush
bear,1,35,300
marmoset,1,2,250
bear,2,42,500
elk,1,30,100
weasel,4,7,50
duck,2,0,10

我们想要了解到底哪种动物给我们带来的损失最多，因此需要计算在每一种动物身上花费的总的封口费数目（至于如何处理咬伤和如何缝针就交给实习医生了，不是我们程序员能力所及）。我们需要使用 8.2.1 节介绍过的 csv 模块以及 5.5.2 节介绍过的 Counter。将下面的代码存储为 zoo_counts.py：

import csv
from collections import Counter

counts = Counter()
with open('zoo.csv', 'rt') as fin:
    cin = csv.reader(fin)
    for num, row in enumerate(cin):
        if num > 0:
            counts[row[0]] += int(row[-1])
for animal, hush in counts.items():
    print("%10s %10s" % (animal, hush))

我们直接跳过第一行，因为第一行仅仅提供了每列的名称，没有任何有实际价值的数据。counts 是一个 Counter 对象，它首先会将每一种动物对应的值（封口费）初始化为 0。此外还对输出结果进行了右对齐调整。看一下输出结果：

$ python zoo_counts.py
      duck         10
       elk        100
      bear        800
    weasel         50
  marmoset        250

最让我们操心的果然是熊！它一直就是我们主要怀疑的对象，这下有数据为证了。

现在试着使用一个数据处理工具 Bubbles（http://bubbles.databrewery.org/）来重复上面的工作。你可以通过下面这条命令来安装它：

$ pip install bubbles

它依赖 SQLAlchemy，如果你没有安装过的话可以通过 pip install sqlalchemy 安装。下面是我们根据文档（http://pythonhosted.org/bubbles/install.html#quick-start）改写的测试程序（命名为 bubbles1.py）：

import bubbles

p = bubbles.Pipeline()
p.source(bubbles.data_object('csv_source', 'zoo.csv', infer_fields=True))
p.aggregate('animal', 'hush')
p.pretty_print()

真相只有一个！接下来就是见证真相的时刻了：

$ python bubbles1.py

 2014-03-11 19:46:36,806 DEBUG calling aggregate(rows)
 2014-03-11 19:46:36,807 INFO called aggregate(rows)
 2014-03-11 19:46:36,807 DEBUG calling pretty_print(records)
 +--------+--------+------------+
 |animal  |hush_sum|record_count|
 +--------+--------+------------+
 |duck    |      10|           1|
 |weasel  |      50|           1|
 |bear    |     800|           2|
 |elk     |     100|           1|
 |marmoset|     250|           1|
 +--------+--------+------------+
 2014-03-11 19:46:36,807 INFO called pretty_print(records)

Bubbles 的文档中提供了将调试信息隐藏掉的方法，甚至还包括改变输出表格格式的方法。

对比这两个例子，我们发现 bubbles 例子中我们仅仅使用了一个函数调用（aggregate）就完成了之前手动读取、计数 CSV 文件的全部工作。根据需求的不同，数据处理工具有时能为你节省出大量时间。

更现实的例子中，zoo 文件可能包含几千行记录（这个动物园还真是危险），甚至包含拼写错误（例如把 bear 写成了 bare），或者数字中无意添加了分隔符，等等。如果你想要更多关于如何使用 Python 和 Java 进行实际数据处理的例子，我推荐 Greg Wilson 的 Data Crunching: Solve Everyday Problems Using Java, Python, and More（Pragmatic Bookshelf，http://shop.oreilly.com/product/9780974514079.do）。

数据清理工具可以节省你许多时间，而 Python 中恰好就有许多这样的工具。再举个例子，PETL（http://petl.readthedocs.org/en/latest/）可以对表格中的数据进行行列提取以及重命名。它的相关产品（http://petl.readthedocs.org/en/latest/related_work.html）网页列出了许多有用的模块和产品。附录 C 里有一些关于常用的数据处理工具的讨论，例如 Pandas、NumPy 以及 IPython。尽管这些工具现在仍主要被科学研究者所熟知，但它们在金融工作者以及数据处理员之间也开始愈加流行。2012 年 Pydata 会议上，AppData（http://www.computerworld.com/article/2492915/big-data/python--big-data-s-secret-power-tool.html）介绍了上述三款工具以及其他的一些 Python 工具是如何帮助我们每日处理 15TB 的数据的。毫无疑问，Python 有足够的能力处理海量的实际数据。

B.3.2　额外信息源

有时你可能需要使用一些来源于企业之外的数据。下面是一些可以使用的商业和政府提供的数据源。

data.gov（https://www.data.gov/）

数千个数据集和数据处理工具的门户。它的 API（https://www.data.gov/developers/apis）是搭建在一套 Python 数据管理系统——CKAN（http://ckan.org/）上的。

Opening government with Python（http://sunlightfoundation.com/）

从这里获取相关视频（https://www.youtube.com/watch?v=FTwjUL6Gq4A）及幻灯片（http://goo.gl/8Yh3s）。

python-sunlight（http://python-sunlight.readthedocs.org/en/latest/）

用于访问 Sunlight API（http://sunlightfoundation.com/api/）的库。

froide（http://stefanw.github.io/froide/）

一个基于 Django 的管理信息自由请求（Freedom of Information Request）的工具。

30 个寻找公开数据的网址（http://blog.visual.ly/data-sources/）

一些有用的链接。

B.4　金融中的Python

最近，金融界对于 Python 的兴趣愈发浓烈。数据分析专家们（quant）正在搭建新一代的金融工具，他们或在附录 C 中提到的 Python 工具的基础上进行改写，或另起炉灶从头编写。

Quantitative economics（http://quant-econ.net/）

这是一个用于建立经济模型的工具，包含许多数学公式和 Python 代码。

Python for finance（http://www.python-for-finance.com）

以 Yves Hilpisch 的书 Derivatives Analytics with Python: Data Analytics, Models, Simulation, Calibration, and Hedging（Wiley）为代表，介绍 Python 在金融中的应用。

Quantopian（https://www.quantopian.com/）

Quantopian 是一个交互式网站，你可以在上面编写自己的 Python 代码来整理股票的历史数据，进行后验测试。

PyAlgoTrade（http://gbeced.github.io/pyalgotrade/）

这也是一个对股票进行后验测试的工具，只不过 PyAlgoTrade 运行在你自己的计算机上，无需联网。

Quandl（http://www.quandl.com/）

你可以使用 Quandl 在数百万金融数据集中进行搜索。

Ultra-finance（https://code.google.com/p/ultra-finance/）

实时的股票信息库。

Python for Finance（O'Reilly，http://shop.oreilly.com/product/0636920032441.do）

Yves Hilpisch 的一本关于如何使用 Python 进行金融建模的书。

B.5　商业数据安全性

在商业中，安全性永远是一个重要的问题。关于这方面的内容可以写整整一本书，事实上也确实有许多关于此的书，因此在这里我们不多涉及，仅仅提几个与 Python 有关的小贴士。

11.2.6 节中介绍了 scapy，一款用 Python 编写的网络包内容查看器。它经常用于网络攻击分析中。

Python Security（http://www.pythonsecurity.org/）网站上有许多关于安全性的话题，包括 Python 中一些与安全加密相关的模块以及使用技巧。

TJ O'Connor 的书 Violent Python: A Cookbook for Hackers, Forensic Analysts, Penetration Testers and Security Engineers（http://shop.oreilly.com/product/9781597499576.do，Syngress）中广泛介绍了 Python 及计算机安全方面的内容。

B.6　地图

地图具有很高的商业价值，而 Python 又非常善于绘制地图，所以我们会在这方面多花点篇幅介绍。高层们都喜欢图表，如果你能迅速为你们企业的官网绘制出一张漂亮的展示地图，他们一定会对你有所好感。

在互联网发展初期，我就曾访问过 Xerox 旗下的一个地图制作网站，当时还处于试验阶段。而随后像 Google Maps 这样的大网站的上线带来了一场新的革命（同经典的那句“为什么我就没有想到这个点子挣上几百万呢？”）。再到现在，处处可见地图和基于地理位置 服务（location-based service），它们在移动设备中尤为有用。

关于地图的许多术语的意思都有所重叠，例如地图绘制（mapping）、制图学（cartography）、GIS（地理信息系统，Geographic Information System）、GPS（全球定位系统，Global Positioning System）、地理空间分析（geospatial analysis），等等。Geospatial Python（http://geospatialpython.com/2013_11_01_archive.html）上的博客中有一张描述“无所不能（800-pound gorilla）”的系统——GDAL/OGR、GEOS 以及 PROJ.4（projections）——以及它周边系统的图片（图中用猩猩来表示这个系统 3）。它们大多提供了 Python 接口。我们会对其中的一些进行讲解，就从最简单的地图格式讲起。

3前文 800-pound gorilla 在英文中有无所不能、强大的意思，图片使了双关。——译者注

B.6.1　格式

地图的世界中有许多不同的表示格式：向量（线）、栅格（图片）、元数据（词）以及它们之间的不同组合。

Esri 是开发地理系统的先驱，在 20 年前发明了 shapefile 格式。一个 shapefile 格式的文件通常包含了多个子文件，其中至少需要包含下面这些：

.shp

“形状”（向量）信息

.shx

形状索引

.dbf

属性数据库

下面列出了一些 Python 中用于处理 shapefile 文件的有用的模块：

pyshp（https://code.google.com/p/pyshp/）是一个纯 Python 编写的 shapefile 库；

shapely（http://toblerity.org/shapely/）可以处理与地理位置相关的问题，例如：“这座小镇里哪些房子位于 50 年洪水线上？”

fiona（https://github.com/Toblerity/Fiona）包含了用于处理 shapefile 以及其他向量格式文件的 OGR 库；

kartograph（http://kartograph.org/）可以将 shapefile 格式的文件转化为便于在服务器端或客户端上使用的 SVG 格式；

basemap（http://matplotlib.org/basemap/）使用 matplotlib 在地图上绘制 2-D 数据；

cartopy（http://scitools.org.uk/cartopy/docs/latest/）使用 matplotlib 和 shapely 绘制地图。

为了便于之后讲解示例，我们需要先获取一个 shapefile 格式的文件。访问 Natural Earth 上的 1:110m Cultural Vectors page（http://www.naturalearthdata.com/downloads/110m-cultural-vectors/），在“Admin 1 - States and Provinces”一栏下，点击绿色的 download states and provinces（http://www.naturalearthdata.com/http/www.naturalearthdata.com/download/110m/cultural/ne_110m_admin_1_states_provinces.zip）框可以下载得到一个压缩文件。下载完成后将它解压，你应该可以看到下面列出的这些文件：

ne_110m_admin_1_states_provinces_shp.README.html
ne_110m_admin_1_states_provinces_shp.sbn
ne_110m_admin_1_states_provinces_shp.VERSION.txt
ne_110m_admin_1_states_provinces_shp.sbx
ne_110m_admin_1_states_provinces_shp.dbf
ne_110m_admin_1_states_provinces_shp.shp
ne_110m_admin_1_states_provinces_shp.prj
ne_110m_admin_1_states_provinces_shp.shx

后面的例子会使用到这些文件。

B.6.2　绘制地图

你需要安装下面这个库来读取 shapefile 文件：

$ pip install pyshp

现在该看看程序了。下面是我根据 Geospatial Python 博客（http://geospatialpython.com/2010/12/rasterizing-shapefiles.html）修改得到的代码，map1.py：

def display_shapefile(name, iwidth=500, iheight=500):
    import shapefile
    from PIL import Image, ImageDraw
    r = shapefile.Reader(name)
    mleft, mbottom, mright, mtop = r.bbox
    # map units
    mwidth = mright - mleft
    mheight = mtop - mbottom
    # scale map units to image units
    hscale = iwidth/mwidth
    vscale = iheight/mheight
    img = Image.new("RGB", (iwidth, iheight), "white")
    draw = ImageDraw.Draw(img)
    for shape in r.shapes():
        pixels = [
            (int(iwidth - ((mright - x) * hscale)), int((mtop - y) * vscale))
            for x, y in shape.points]
        if shape.shapeType == shapefile.POLYGON:
            draw.polygon(pixels, outline='black')
        elif shape.shapeType == shapefile.POLYLINE:
            draw.line(pixels, fill='black')
    img.show()

if __name__ == '__main__':
    import sys
    display_shapefile(sys.argv[1], 700, 700)

上面的代码读取了 shapefile 文件，并遍历了其中的图形库。我们只关注其中两种图形类型：首尾相连的多边形和首尾不相连的折线。上面的代码仅仅是我简单看了一眼 pyshp 的文档一遍编写出来的，所以我并不百分百保证它能正常运行。但往往万事开头难，不要怕出错，出错了慢慢修改即可。

好了，运行一下上面的代码。需要传入的参数是 shapefile 文件的原始文件名，不添加任何扩展名：

$ python map1.py ne_110m_admin_1_states_provinces_shp

你应该会看到类似下面图 B-1 所示的结果。

图 B-1：原始地图

恩，它确实成功画出了一幅类似美国地图的图，但似乎存在些问题：

阿拉斯加和夏威夷的地图像是被顽皮的猫拽了一条线缠绕了起来，这显然是个 bug；

地图被压扁了，我们需要对原始图做映射（projection）处理；

图片不太美观，我们需要做些样式（style）修改。

关于上述第一点：显然是因为我草草编写的代码逻辑出现了问题，应该怎么改呢？第 12 章曾介绍了一些开发建议，包括如何调试 bug，但这里我们可以考虑一些其他途径。比如可以不断添加一些测试代码直到定位并解决问题，或者可以干脆使用别的绘图库试试。也许有些抽象能力更高的库可以一口气解决上面所有问题（零散杂乱的线、压缩的外观以及原始丑陋的样式）。

下面是其他一些可选的 Python 绘图软件。

basemap（http://matplotlib.org/basemap/）

基于 matplotlib，可以一层一层地绘图以及添加数据。

mapnik（http://mapnik.org/）

以 C++ 编写的库，提供了 Python 接口，便于快速开发。可以绘制矢量地图（线）和栅格地图（图片）。

tilemill（https://www.mapbox.com/tilemill/）

一个基于 mapnik 的地图设计平台。

Vincent（http://vincent.readthedocs.org/en/latest/）

一个从 Python 到 Vega（一款 JavaScript 可视化工具）的翻译器。你可以阅读 Mapping data in Python with pandas and vicent（http://wrobstory.github.io/2013/10/mapping-data-python.html）这篇博客学习如何使用。

Python for ArcGIS（http://resources.arcgis.com/en/communities/python/）

链接到 Esri 的商业产品 ArcGIS 中包含的 Python 资源。

使用 Python 进行空间分析（https://complex.luxbulb.org/howto/spatial-analysis-python）

链接到相关的教程、包以及视频。

使用 Python 处理地理空间数据（https://conference.scipy.org/scipy2013/tutorial_detail.php?id=110）

链接到相关视频展示。

使用 Python 绘制地图（http://sensitivecities.com/so-youd-like-to-make-a-map-using-python-EN.html）

使用 pandas、matplotlib、shapely 以及其他 Python 模块绘制历史性地标的地图。

Python Geospatial Development（http://shop.oreilly.com/product/9781782161523.do）

Eric Westra 的一本书，介绍了如何使用 mapnik 以及其他工具。

Learning Geospatial Analysis with Python（http://shop.oreilly.com/product/9781783281138.do）

Joel Lawhead 的一本书，回顾了与地理空间相关的算法、格式以及库。

上面提到的模块都能绘制出精美的地图，但它们的安装、学习难度都比较大。有些模块甚至依赖于你没有见过的库，例如 numpy 和 pandas。使用它们带来的收益能否大于付出？作为开发者，我们总是不得不在掌握的信息不完整的情况下被迫做出取舍。如果你对于绘制地图感兴趣，不妨下载其中的一些库看看能做出点什么。或者你可以试着连接到某些远程网络服务的 API 以免去安装软件的烦恼；第 9 章介绍了如何连接到网络服务器并解析服务器做出的 JSON 响应。

B.6.3　应用和数据

目前为止，我们已经讨论了许多关于绘制地图的内容，但其实我们可以使用地图数据做更多事情而不是仅仅停留在绘图。地理编码（Geocoding）指在地址信息和地理坐标之间进行转化。你可以找到许多与地理编码相关的 API（http://www.programmableweb.com/apitag/geocoding），详见 ProgrammableWeb's comparison（http://www.programmableweb.com/news/7-free-geocoding-apis-google-bing-yahoo-and-mapquest/2012/06/21）及 Python 库，例如 geopy（https://code.google.com/p/geopy/）、pygeocoder（https://pypi.python.org/pypi/pygeocoder）以及 googlemaps（http://py-googlemaps.sourceforge.net/）。如果你注册了 Google 或其他平台并获得了相应的 API 密钥，你还可以获取这些平台提供的一些其他服务，例如两位置间详细步骤的旅行路线、某一位置附近搜索，等等。

下面是一些可用的地图数据源：

http://www.census.gov/geo/maps-data/

美国人口统计局发布的地图文件总览

http://www.census.gov/geo/maps-data/data/tiger.html

大量的地理地图数据以及人口地图数据

http://wiki.openstreetmap.org/wiki/Potential_Datasources

全球地图资源

http://www.naturalearthdata.com/

三种比例尺下的矢量图以及栅格地图数据

我们还应该在这里讨论一下相关的数据科学工具（Data Science Toolkit，http://www.datasciencetoolkit.org/），包括免费的双向地理编码工具、政治版图边界的坐标信息以及统计信息，等等。你可以将所有的数据和软件统一下载到虚拟机（VM）中，从而让它们独立运行在你的电脑里不受其他软件和数据的影响。

附录 C　Python 的科学

在她统治期间，

蒸汽无所不能的力量被用于海陆之间，

这些都强烈地依赖于

科学上的突破。

——1887 年维多利亚女王执政 50 周年纪念颂歌，麦金泰尔 1

1詹姆斯 · 麦金泰尔，加拿大诗人。

最近几年，由于大量实用软件包的出现（本章中会见到这些软件包），Python 在科学研究者中愈加流行。如果你是科学工作者或者学生，可能用过像 MATLAB 或者 R 这样的工具，也可能使用过像 Java、C、C++ 这样的传统编程语言。而本章，你将会见识 Python 为了科学研究和出版而搭建的完美平台。

C.1　标准库中的数学和统计

首先重温一下标准库，看看那些我们之前不曾提到过的模块和功能。

C.1.1　数学函数

Python 的标准 math（https://docs.python.org/3/library/math.html）库中汇集了大量的数学函数。通过 import math 即可将它们引入到你的程序中进行使用。

math 库会引入一些常量，例如 pi 和 e：

>>> import math
>>> math.pi
>>> 3.141592653589793
>>> math.e
2.718281828459045

组成 math 库的大部分内容都是函数，我们不妨先来看一些最常用的。

fabs() 用于获得绝对值：

>>> math.fabs(98.6)
98.6
>>> math.fabs(-271.1)
271.1

你还可以获得向下取整的整数（floor()）和向上取整的整数（ceil()）：

>>> math.floor(98.6)
98
>>> math.floor(-271.1)
-272
>>> math.ceil(98.6)
99
>>> math.ceil(-271.1)
-271

使用 factorial() 计算阶乘（在数学中以 n! 表示）：

>>> math.factorial(0)
1
>>> math.factorial(1)
1
>>> math.factorial(2)
2
>>> math.factorial(3)
6
>>> math.factorial(10)
3628800

使用 log() 计算自然对数（以 e 为底）：

>>> math.log(1.0)
0.0
>>> math.log(math.e)
1.0

如果你想使用别的数字作为 log 的底，可以把它当作第二个参数传入函数：

>>> math.log(8, 2)
3.0

pow() 做的工作与上面正好相反，它用于计算一个数的指数：

>>> math.pow(2, 3)
8.0

Python 内置的指数运算符 ** 也可以进行同样的计算，只不过当底数和指数都是整数时，用 ** 计算得到的结果也是整数，不会被自动转化为浮点数：

>>> 2**3
8
>>> 2.0**3
8.0

使用 sqrt() 得到平方根：

>>> math.sqrt(100.0)
10.0

别想着调戏 sqrt() 函数，你的小把戏它都见过：

>>> math.sqrt(-100.0)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: math domain error

常见的三角函数都可以使用，这里我只列出它们的名字：sin()、cos()、tan()、asin()、acos()、atan()、atan2()。如果你还记得勾股定理，math 库还包含 hypot()2 函数帮你计算给定两直角边对应的斜边长度：

2函数名 hypot 是 hypotenuse 的缩写，意思是斜边。——译者注

>>> x = 3.0
>>> y = 4.0
>>> math.hypot(x, y)
5.0

如果你不放心这些神奇的函数，可以自己验证一下，看看它们是否能正常工作：

>>> math.sqrt(x*x + y*y)
5.0
>>> math.sqrt(x**2 + y**2)
5.0

最后一组函数用于进行角坐标转换：

>>> math.radians(180.0)
3.141592653589793
>>> math.degrees(math.pi)
180.0

C.1.2　使用复数

基本 Python 语言（即不引入任何第三方库的 Python）就支持复数运算。所谓复数，就是由实部（real）和虚部（imaginary）两部分组成的数：

>>> # 一个实数
... 5
5
>>> # 一个虚数
... 8j
8j
>>> # 一个虚数
... 3 + 2j
(3+2j)

由于虚数 i（在 Python 中用 1j 表示）被定义为 -1 的平方根，因此我们可以执行下列 运算：

>>> 1j * 1j
(-1+0j)
>>> (7 + 1j) * 1j
(-1+7j)

一些与复数计算相关的函数都被纳入了 cmath（https://docs.python.org/3/library/cmath.html）模块。

C.1.3　使用小数对浮点数进行精确计算

计算机中的浮点数和我们在学校学的实数有些差别。由于计算机的 CPU 是针对二进制运算设计的，因此它无法准确表示那些不是 2 的幂次方的数：

>>> x = 10.0 / 3.0
>>> x
3.3333333333333335

天呐，最后的那个 5 是怎么回事？应该一直是 3 才对啊。使用 Python 的 decimal（https://docs.python.org/3/library/decimal.html）模块，你可以把数字按任意你所需的精度表示。这种对于涉及钱的计算非常重要。美元的最小精度是 1 美分（1 美元的百分之一），因此当我们需要用美元和美分计算钱的数目时，我们期望最终的结果精确到美分。如果我们使用像 19.99 和 0.06 这样的浮点数来表示美元和美分的话，在开始实际计算前，我们的数据精度就会有所损失（浮点数的最后几位数字会像上面的例子一样产生误差）。那么如何解决这个问题呢？很简单，使用 decimal 模块代替即可：

>>> from decimal import Decimal
>>> price = Decimal('19.99')
>>> tax = Decimal('0.06')
>>> total = price + (price * tax)
>>> total
Decimal('21.1894')

我们使用字符串值初始化了价格（price）和税收（tax）变量，以此隐性指明数据的有效数字。total 的计算过程保留了所有有效数字，但是我们只需要精确到最近的美分：

>>> penny = Decimal('0.01')
>>> total.quantize(penny)
Decimal('21.19')

上例中，也许你使用浮点数和取整运算能获得一样的结果，但这并不总能成功。你也可以把所有数都乘以 100，然后使用整数进行运算，但小心，这早晚也会产生问题。在 www.itmabyahack.com（http://www.itmaybeahack.com/homepage/books/nonprog/html/p13_modules/p13_c03_decimal.html）上，有许多关于上述计算精度损失问题的讨论。

C.1.4　使用分数进行有理数运算

你可以使用标准 Python 中的 fractions（https://docs.python.org/3/library/fractions.html）模块将有理数表示为分子除以分母的形式。下面是一个简单的例子，计算三分之一乘以三分之二：

>>> from fractions import Fraction
>>> Fraction(1,  3) * Fraction(2, 3)
Fraction(2, 9)

使用浮点数作为参数会造成结果不准确，你可以在 Fraction 中使用 Decimal 作为替代传入：

>>> Fraction(1.0/3.0)
Fraction(6004799503160661, 18014398509481984)
>>> Fraction(Decimal('1.0')/Decimal('3.0'))
Fraction(3333333333333333333333333333, 10000000000000000000000000000)

使用 gcd 函数可以获得两个数的最大公约数：

>>> import fractions
>>> fractions.gcd(24, 16)
8

C.1.5　使用array创建压缩序列

Python 中的列表类型更像是一个链表而不是数组。如果你需要一个元素类型全都相同的一维序列，可以使用数组（array，https://docs.python.org/3/library/array.html）类型。与列表相比，数组占用空间更小，却支持许多列表方法。使用 array(typecode, initializer)可以创建一个新的数组。其中 typecode 定义了数据类型（例如 int 或 float），另一个可选的参数 initializer 包含了初始化的值，它可以是一个列表、字符串或者其他可迭代类型。

在实际工作中，我从未使用过这个包。数组是一个相对低层次的数据结构，在表示图像数据时比较有用。如果你使用数组——尤其是多维数组——仅仅是为了进行数值计算的话（例如矩阵运算），最好还是使用 NumPy 代替，随后就会讲到这个包。

C.1.6　使用statistics进行简单数据统计

从 Python 3.4 开始，statistics（https://docs.python.org/3.4/library/statistics.html）被纳入了标准模块。它包含许多实用函数：平均值、中数、求模、标准差、方差，等等。输入的数据可以是序列型的（列表或元组）也可以是不同类型的数值（整数、浮点数、小数、分数）组成的迭代型。其中，mode 函数还可以接受字符串类型的输入。SciPy 和 Pandas 包含更多的统计函数，随后会介绍。

C.1.7　矩阵乘法

据说从 Python 3.5 开始，@ 字符将不再仅限于处理字符时使用了。它仍然还会具有修饰符的作用，但同时还将可以用于矩阵乘法（http://legacy.python.org/dev/peps/pep-0465/）。但在它真正到来之前，NumPy 仍然是你进行矩阵运算的最佳选择。

C.2　科学Python

本附录剩下的内容会涵盖一些与科学、数学相关的第三方 Python 包。你可以选择分开安装它们，但我建议通过安装某个科学计算版本的 Python 从而一口气把它们全都安装了。下面是几种主流的科学版 Python。

Anaconda（https://store.continuum.io/cshop/anaconda/）

这个包是免费的、可扩展的、不断更新的科学计算版 Python。它同时支持 Python 2 和 Python 3，而且不会对你系统中已经安装的 Python 产生任何副作用。

Enthought Canopy（https://www.enthought.com/products/canopy/）

这个包既有免费版本也有商业版本。

Python(x,y)（https://code.google.com/p/pythonxy/）

这个包仅可在 Windows 下使用。

Pyzo（http://www.pyzo.org/）

这个包是建立在 Anaconda 的一些工具上的，同时添加了一些其他的实用工具。

ALGORETE Loopy（http://algorete.org/）

这也是一个建立在 Anaconda 上的包，同样添加了一些其他内容。

我建议安装 Anaconda。虽然它的体积比较庞大，但包含了本附录中会使用到的所有工具，一劳永逸。你可以参考附录 D，学习如何安装 Python 3 版本的 Anaconda。本附录的后半部分内容会假定你已经安装好了所有所需的包，不管你是分开安装还是通过 Anaconda 安装。

C.3　Numpy

NumPy（http://www.numpy.org/）是 Python 在科学工作者中流行起来的主要原因之一。你可能听到过类似这样的说法：像 Python 这样的动态语言要比像 C 语言一样的编译型语言效率低，甚至比像 Java 这样的解释型语言的效率也要低。NumPy 是为了进行快速多维矩阵运算而设计的，和 FORTRAN 这样的科学性语言有些类似。你可以同时获得接近 C 语言的运算速度和 Python 的友好（便于编写使用）接口。

如果你已经下载安装了 Python 的某一个科学计算版本，应该已经拥有 NumPy 了。如果没有的话可以前往 NumPy 的下载页（http://www.scipy.org/scipylib/download.html）按教程下载安装。

在开始使用 NumPy 之前，你需要理解它的核心数据结构：多维数组，用 ndarray（N-dimensional array）或仅仅 array 表示。和 Python 的列表、元组不同，多维数组中的元素必须是同一类型的。NumPy 把数组的维数称为它的秩（rank）。一维数组就像是一行数据，二维数组就像是一张包含行列的表格，三维数组就像是一个魔方。每一维的长度不要求相同。

　NumPy 中的 array 和标准 Python 中的 array 并不是一回事。本附录的后面部分，当我说数组时，我指的都是 NumPy 里面的数组。

为什么需要数组类型呢？

科学数据总是包含大量序列数据；

对这种数据进行的科学计算经常包括矩阵数学、回归运算、模拟以及其他需要同时对许多数据点进行运算的操作；

NumPy 对数组的处理速度要远远超过它处理标准 Python 里列表或元组的速度。

NumPy 数组有多种创建方式。

C.3.1　使用array()创建数组

你可以利用普通的列表或元组生成数组：

>>> b = np.array( [2, 4, 6, 8] )
>>> b
array([2, 4, 6, 8])

ndim 属性会返回数组的秩：

>>> b.ndim
1

数组中所有元素的个数由 size 记录：

>>> b.size
4

每一秩（维）的元素数量由 shape 返回：

>>> b.shape
(4,)

C.3.2　使用arange()创建数组

NumPy 中的 arange() 与 Python 中的标准 range() 类似。如果你在调用 arange() 时只传入了一个整形参数 num，它会返回一个包含了从 0 到 num-1 的整数的 ndarray：

>>> import numpy as np
>>> a = np.arange(10)
>>> a
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
>>> a.ndim
1
>>> a.shape
(10,)
>>> a.size
10

如果传入两个参数，则 arange() 方法会返回从第一个参数到最后一个参数减一的全部整数的数组：

>>> a = np.arange(7, 11)
>>> a
array([ 7,  8,  9, 10])

除此之外，你还可以把自己指定的步长（默认是 1）作为第三个参数：

>>> a = np.arange(7, 11, 2)
>>> a
array([7, 9])

目前为止，我们的例子中出现的都是整数，但事实上，使用浮点数也能完美运行：

>>> f = np.arange(2.0, 9.8, 0.3)
>>> f
array([ 2. ,  2.3,  2.6,  2.9,  3.2,  3.5,  3.8,  4.1,  4.4,  4.7,  5. ,
        5.3,  5.6,  5.9,  6.2,  6.5,  6.8,  7.1,  7.4,  7.7,  8. ,  8.3,
        8.6,  8.9,  9.2,  9.5,  9.8])

最后一个小技巧，dtype 参数可以指定 arange 产生的值的类型：

>>> g = np.arange(10, 4, -1.5, dtype=np.float)
>>> g
array([ 10. ,   8.5,   7. ,   5.5])

C.3.3　使用zeros()、ones()和random()创建数组

zeros() 会返回一个全都是 0 的矩阵。zeros 接收的参数是元组，用于指定你想要创建的矩阵的形状。下面是一个一维数组的例子：

>>> a = np.zeros((3,))
>>> a
array([ 0.,  0.,  0.])
>>> a.ndim
1
>>> a.shape
(3,)
>>> a.size
3

下面创建的矩阵的秩为二：

>>> b = np.zeros((2, 4))
>>> b
array([[ 0.,  0.,  0.,  0.],
       [ 0.,  0.,  0.,  0.]])
>>> b.ndim
2
>>> b.shape
(2, 4)
>>> b.size
8

另一个特殊的函的矩阵中所有值都相等：数 ones()，它创建

>>> import numpy as np
>>> k = np.ones((3, 5))
>>> k
array([[ 1.,  1.,  1.,  1.,  1.],
       [ 1.,  1.,  1.,  1.,  1.],
       [ 1.,  1.,  1.,  1.,  1.]])

最后一个初始化函数可以创建由 0.0 到 1.0 之间的随机数组成的矩阵：

>>> m = np.random.random((3, 5))
>>> m
array([[ 1.92415699e-01,   4.43131404e-01,   7.99226773e-01,
         1.14301942e-01,   2.85383430e-04],
       [ 6.53705749e-01,   7.48034559e-01,   4.49463241e-01,
         4.87906915e-01,   9.34341118e-01],
       [ 9.47575562e-01,   2.21152583e-01,   2.49031209e-01,
         3.46190961e-01,   8.94842676e-01]])

C.3.4　使用reshape()改变矩阵的形状

目前为止，你可能还是觉得数组能做的事情和列表、元组没有什么区别。其实，数组能做许多列表和元组做不到的事情，例如你可以使用 reshape() 函数对数组的形状进行修改：

>>> a = np.arange(10)
>>> a
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
>>> a = a.reshape(2, 5)
>>> a
array([[0, 1, 2, 3, 4],
       [5, 6, 7, 8, 9]])
>>> a.ndim
2
>>> a.shape
(2, 5)
>>> a.size
10

你可以将刚才的数组转化为其他形状：

>>> a = a.reshape(5, 2)
>>> a
array([[0, 1],
       [2, 3],
       [4, 5],
       [6, 7],
       [8, 9]])
>>> a.ndim
2
>>> a.shape
(5, 2)
>>> a.size
10

将表示形状的元组赋值给 shape 属性也可以达到相同的效果：

>>> a.shape = (2, 5)
>>> a
array([[0, 1, 2, 3, 4],
       [5, 6, 7, 8, 9]])

形状的唯一限制是秩的乘积需要等于数组中元素的个数（上例中是 10）：

>>> a = a.reshape(3, 4)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: total size of new array must be unchanged

C.3.5　使用[]访问元素

一维数组的使用和列表类似：

>>> a = np.arange(10)
>>> a[7]
7
>>> a[-1]
9

然而，如果数组的形状不是一维的，就需要用逗号分开指定每一维的索引了：

>>> a.shape = (2, 5)
>>> a
array([[0, 1, 2, 3, 4],
       [5, 6, 7, 8, 9]])
>>> a[1,2]
7

这和二维的 Python 列表的访问方法有所不同：

>>> l = [ [0, 1, 2, 3, 4], [5, 6, 7, 8, 9] ]
>>> l
[[0, 1, 2, 3, 4], [5, 6, 7, 8, 9]]
>>> l[1,2]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: list indices must be integers, not tuple
>>> l[1][2]
7

还有一件事情。分片操作仍然可用，只不过你需要将它们全都放到一组方括号中。再次创 建我们熟悉的测试数组：

>>> a = np.arange(10)
>>> a = a.reshape(2, 5)
>>> a
array([[0, 1, 2, 3, 4],
       [5, 6, 7, 8, 9]])

使用分片操作访问第一行中从偏移量为 2 的元素开始的所有元素：

>>> a[0, 2:]
array([2, 3, 4])

接下来试着获取最后一行的倒数三个元素：

>>> a[-1, :3]
array([5, 6, 7])

你还可以使用分片同时给多个元素赋值。例如下面的语句将每一行的第 2 列和第 3 列（偏移位置）元素赋值为 1000：

>>> a[:, 2:4] = 1000
>>> a
array([[   0,    1, 1000, 1000,    4],
       [   5,    6, 1000, 1000,    9]])

C.3.6　数组运算

创建数组和数组变形太有趣了，以至于我们差点忘了介绍如何对数组中的数据进行处理。下面要介绍的第一个技巧是使用 NumPy 中重定义的乘法运算符（*）对 NumPy 数组中的

所有元素进行批量乘法：

>>> from numpy import *
>>> a = arange(4)
>>> a
array([0, 1, 2, 3])
>>> a *= 3
>>> a
array([0, 3, 6, 9])

如果你想要对一个普通 Python 列表中所有元素进行乘法操作，需要使用循环或者列表迭代：

>>> plain_list = list(range(4))
>>> plain_list
[0, 1, 2, 3]
>>> plain_list = [num * 3 for num in plain_list]
>>> plain_list
[0, 3, 6, 9]

这种批量操作同样适用于加法、减法、除法以及其他一些 NumPy 库中的函数。例如，通过组合使用 zeros() 和 +，你可以将新创建的数组中所有元素初始化为某一个相同的值：

>>> from numpy import *
>>> a = zeros((2, 5)) + 17.0
>>> a
array([[ 17.,  17.,  17.,  17.,  17.],
       [ 17.,  17.,  17.,  17.,  17.]])

C.3.7　线性代数

NumPy 包含许多与线性代数有关的函数，比如我们定义下面这组线性方程：

4x + 5y = 20
 x + 2y = 13

如何对 x 和 y 进行求解？首先需要创建两个矩阵：

系数矩阵（coefficient，x 和 y 的系数组成的矩阵）

因变量矩阵（等式右侧）

>>> import numpy as np
>>> coefficients = np.array([ [4, 5], [1, 2] ])
>>> dependents = np.array( [20, 13] )

现在使用 linalg 模块中的 solve() 函数：

>>> answers = np.linalg.solve(coefficients, dependents)
>>> answers
array([ -8.33333333,  10.66666667])

运行结果显示 x 的值大约是 -8.3，y 的值大约是 10.6。这个解正确吗？

>>> 4 * answers[0] + 5 * answers[1]
20.0
>>> 1 * answers[0] + 2 * answers[1]
13.0

觉得有些繁琐？那看看下面的做法如何。直接让 NumPy 为我们计算两个矩阵的点积（dot product），以省去一些敲打键盘的时间：

>>> product = np.dot(coefficients, answers)
>>> product
array([ 20.,  13.])

如果得到的解是正确的，那么 product 数组中的值应该与 dependents 中的值相近。你可以使用 allclose() 函数来检查两个数组是否近似相等（点积得到的结果可能与因变量矩阵不完全相同，因为存在浮点小数的取整问题）：

>>> np.allclose(product, dependents)
True

NumPy 还提供了与多项式、傅立叶变换、统计以及一些概率分布相关的模块。

C.4　SciPy库

SciPy（http://www.scipy.org/）是一个建立在 NumPy 的基础之上的库，它包含了更多的数学函数和统计函数。SciPy 发布包（http://www.scipy.org/scipylib/download.html）包括 NumPy、SciPy、Pandas（本附录后面会讲到）以及一些其他的库。

SciPy 包含许多模块，可以完成下面这些计算任务：

优化

统计

插值

线性回归

积分

图像处理

信号处理

如果你曾经使用过其他的科学计算工具，就会发现 Python、NumPy、SciPy 可以涵盖商业的 MATLAB（http://cn.mathworks.com/products/matlab/）以及开源的 R（http://www.r-project.org/）中的大部分功能。

C.5　SciKit库

同样遵循根据已有的软件包进行扩展的模式，SciKit（https://scikits.appspot.com/scikits）是一组建立在 SciPy 基础上的科学计算软件包。SciKit 的特长在于机器学习（machine learning）。它支持建模、分类、聚类以及多种机器学习算法。

C.6　IPython库

IPython（http://ipython.org/）的诸多优点让它非常值得我们花时间学习，下面列出了其中几点：

改进的交互式解释器（简单来说，就是本书中一直使用的 >>> 的扩展）；

在基于网页的笔记本（notebook）中发布代码、图形、文本以及其他形式的文件；

支持并行化计算（parallel computing，http://ipython.org/ipython-doc/stable/parallel/parallel_intro.html）。

我们主要来看看解释器和笔记本部分。

C.6.1　更好的解释器

IPython 有分别针对 Python 2 和 Python 3 的两个不同的版本，在安装 Anaconda 或其他科学计算版 Python 时会自动安装。使用 ipython3 运行与 Python 3 对应的版本。

$ ipython3

 Python 3.3.3 (v3.3.3:c3896275c0f6, Nov 16 2013, 23:39:35)
 Type "copyright", "credits" or "license" for more information.

 IPython 0.13.1 -- An enhanced Interactive Python.
 ?         -> Introduction and overview of IPython's features.
 %quickref -> Quick reference.
 help      -> Python's own help system.
 object?   -> Details about 'object', use 'object??' for extra details.

 In [1]:

标准 Python 解释器使用 >>> 和 ... 作为输入提示符，其中，... 表示你仍需要输入一些代码以保证正常运行。IPython 会将你输入的所有东西保存在名为 In 的列表中，同时将所有输入的内容保存到 Out 列表中。每个输入可以包含多行数据，通过按下 Shift 加回车可以结束输入并提交你输入的内容。下面是一个一行的例子：

In [1]: print("Hello? World?")
Hello? World?

In [2]:

In 和 Out 是自动编号的列表，这让你可以方便地访问任何你输入的内容和得到的输出内容。

如果你在一个变量后边输入 ?，IPython 会显示出它的类型、值、创建该类变量的方式以及其他一些相关信息：

In [4]: answer = 42

In [5]: answer?

Type:       int
String Form:42
Docstring:
int(x=0) -> integer
int(x, base=10) -> integer

Convert a number or string to an integer, or return 0 if no arguments
are given.  If x is a number, return x.__int__().  For floating point
numbers, this truncates towards zero.

If x is not a number or if base is given, then x must be a string,
bytes, or bytearray instance representing an integer literal in the
given base.  The literal can be preceded by '+' or '-' and be surrounded
by whitespace.  The base defaults to 10.  Valid bases are 0 and 2-36.
Base 0 means to interpret the base from the string as an integer literal.
>>> int('0b100', base=0)
4

名称查找是像 IPython 这样的 IDE 中常见的功能。如果你在输入了一串字符后按下 Tab 键，IPython 会显示出所有以当前字符串为开头的变量、关键词、函数，等等。我们首先来定义一些变量，然后尝试查找所有以 f 开头的内容：

In [6]: fee = 1

In [7]: fie = 2

In [8]: fo = 3

In [9]: fum = 4

In [10]: ftab
%%file    fie       finally   fo        format    frozenset
fee       filter    float     for       from      fum

如果你在输入 fe 后按下 Tab 键，它会自动扩展为变量 fee，因为它是当前程序中唯一一个以 fe 开头的内容：

In [11]: fee
Out[11]: 1

C.6.2　IPython笔记本

如果你倾向于图形界面，可能会喜欢 IPython 的基于网页的实现。首先打开 Anaconda 的开始窗口（图 C-1）。

图 C-1：Anaconda 开始界面

点击 ipython-notebook 右侧的 launch 图标即可在浏览器中运行笔记本。图 C-2 显示了初始启动页。

图 C-2：iPython 主界面

现在点击创建新笔记本会弹出一个类似图 C-3 的窗口。

图 C-3：iPython 笔记本页面

在图形界面下重新输入我们之前例子中的代码，如图 C-4 所示。

图 C-4：在 iPython 中输入代码

点击黑色的小三角运行它。运行结果如图 C-5 所示。

图 C-5：在 iPython 中运行代码

IPython 的笔记本不仅仅是图形化版的 IPython 解释器。除了代码，它还可以包含文本、图片、格式化的数学表达式，等等。

在笔记本界面上方的一排按钮中有一个是下拉菜单（图 C-6），它可以指定你输入内容的方式。下面是几种可选的方式。

Code

默认的输入方式，用于输入 Python 代码。

Markdown

HTML 的一种替代，可以将输入文本按照预先指定的格式处理为可读文本。

纯文本

无格式文本，从 Heading 1 到 Heading 6，即对应 HTML 中的 <H1> 到 <H6> 标签。

图 C-6：可选内容格式菜单

试着在我们的代码中穿插加入些文本，让它看起来像是某种 wiki 页。从下拉菜单中选择 Heading 1 然后输入“Humble Brag Example”，接着按下 Shift 加回车键（Enter）。你应该能看到粗体显示的上面三个单词。然后从下拉菜单中选择 Code 并随便输入一些代码，比如下面这些：

print("Some people say this code is ingenious")

再次按下 Shift+Enter 来完成输入。现在你应该能看到格式化的标题和代码，如图 C-7 所示。

图 C-7：格式化文本和代码

通过将输入代码、输出结果、文本甚至图片穿插起来，你可以创建交互式的笔记本。又由于它是基于网页的，因此你可以从任何浏览器上访问到，十分便捷。

你可以看到一些转化成静态 HTML（http://nbviewer.ipython.org/）的笔记，或者放置在收藏（https://github.com/ipython/ipython/wiki/A-gallery-of-interesting-IPython-Notebooks）中的笔记。举个具体的例子，你可以看看关于泰坦尼克号上乘客的笔记（http://nbviewer.ipython.org/github/agconti/kaggle-titanic/blob/master/Titanic.ipynb）。它包括一些图表，展示了性别、穷富、位于船上的位置等因素是如何影响生存率的。作为奖励，你还可以读一读如何使用不同的机器学习方法。

科学家们也开始使用 IPython 笔记来发表他们的研究成果，包括所有代码和支持结论的数据。

C.7　Pandas

最近几年，数据科学（data science）这个词变得越来越流行。关于这个词，我看到过这样一些解释：“在 Mac 上完成的统计”或“在旧金山完成的统计”。然而，不管你如何定义数据科学的范畴，本章我们讨论的工具——NumPy、SciPy 以及这一节的主角 Pandas——都是日益流行的数据科学工具中的重要成员。（当然 Mac 和旧金山都是可有可无的。）

Pandas（http://pandas.pydata.org/）是一款全新的用于进行交互式数据分析的工具包。Pandas 融合了 NumPy 进行矩阵运算的能力以及处理电子表格和关系型数据库的能力，因此它在处理真实世界的数据上非常有效。Wes McKinney 编写的 Python for Data Analysis: Data Wrangling with Pandas, NumPy, and IPython（O'Reilly，http://shop.oreilly.com/product/0636920023784.do）讲解了如何使用 NumPy、IPython 以及 Pandas 进行有效的数据处理。

NumPy 是针对传统科学计算而设计的，它主要用于处理单一类型的多维数据集，通常为浮点型数据。Pandas 更像是一个数据库编辑器，可以处理包含多种数据类型的组（group）。在某些语言中，这种组也被称为记录（record）或者结构（structure）。Pandas 定义一种基本数据结构，叫作 DataFrame（数据帧）。它是一个有序的列的集合，每一列都有自己的名称和类型。数据帧有点像数据库中的表格、Python 中的命名元组和嵌套字典。Pandas 的设计目的在于简化不同类型数据的处理过程，使它不仅仅适用于科学计算中遇到的类型（主要是浮点型）还适用于商业数据。事实上，Pandas 最初就是为了处理金融数据而设计的，它最常见的替代品就是电子表格。

Pandas 是一款用于处理真实世界中各种类型的混杂数据（值的缺失、古怪的格式、稀疏的测量值，等等）的 ETL 工具。你可以用它对数据文件进行划分（split）、合并（join）、扩展（extend）、添加（fill in）、转换（convert）、变形（reshape）、切分（slice）、加载（load）和保存（save）等操作。它整合了我们之前讨论过的那些工具——NumPy、SciPy、IPython——用于进行统计计算、根据模型拟合数据、绘制图表、发表，等等。

大多数科学家们都希望能尽快把他们的工作做完，而不需要花费几个月的时间先成为某种晦涩的计算机语言或应用程序的专家。有了 Python，他们很快就能变得非常有效率。

C.8　Python和科学领域

之前我们讨论的都是可以应用到任何科学领域的 Python 工具。那么，有没有专门针对于某一特定领域的 Python 工具和相关文档呢？下面列出了 Python 在某些特定问题方向的应用，以及一些适用于特定问题的 Python 库。

通用

在科学和工程中使用 Python 进行计算（http://kitchingroup.cheme.cmu.edu/pycse/pycse.html）

科学家的 Python 速成课（http://nbviewer.ipython.org/gist/rpmuller/5920182）

物理

计算物理学（http://www-personal.umich.edu/~mejn/computational-physics/）

生物和医药

生物学家应学的 Python（http://pythonforbiologists.com/）

使用 Python 处理神经影像（http://nipy.org/）

下面列出一些与 Python 和科学数据处理相关的国际会议：

PyData（http://pydata.org/）

SciPy（http://conference.scipy.org/）

EuroSciPy（https://www.euroscipy.org/）

附录 D　安装 Python 3

等到 Python 3 被预装到每一台计算机上时，估计烤面包机都已经被可以打印酥粒甜甜圈的 3D 打印机取代了。Windows 压根没有 Python，OS X、Linux、Unix 也都只是预装了旧版本的 Python 而已。在它们赶上 Python 最新版本前，我们只能自己手动安装 Python 3。

下面几节详细描述了如何做到以下几件事情：

查询你电脑中安装的 Python 版本（如果安装了）

安装标准发布版的 Python 3（如果没有安装）

安装 Anaconda 发布的 Python 科学计算模块

如果无法直接修改你的系统（权限问题等），不妨安装 pip 和 virtualenv 进行第三方包的管理

安装 conda 作为 pip 的替代品

本书中大部分例子都是在 Python 3.3 的环境下编写并测试的，这是写本书时最新的稳定版本。有些是在 3.4 下编写并测试的，这个版本是在校对本书时发布的。新版本 Python 的变化网页（https://docs.python.org/3/whatsnew/）里描述了每一个新版本更新的内容。Python 安装包有多种来源，安装新版本的方式也不止一种。本附录会介绍其中两种安装方式。

如果你只会用标准解释器和库，建议从官方语言网站（https://www.python.org/）下载安装。

如果你除了使用 Python 的标准库外还需要使用一些附录 C 中描述的强大的科学计算库，就下载安装 Anaconda 吧。

D.1　安装标准Python

打开浏览器前往 Python 下载页（https://www.python.org/downloads/）。该网页会尝试获取你的操作系统类型并显示对应的 Python 版本。如果它显示的不对，你可以手动从下面几个链接中选择对应的版本下载：

Python Releases for Windows（https://www.python.org/downloads/windows/）

Python Releases for Mac OS X（https://www.python.org/downloads/mac-osx/）

Python Source Releases（Linux and Unix，https://www.python.org/downloads/source/）

你会看到类似图 D-1 的网页。

图 D-1：下载页实例

点击最新版本的下载链接。在编写本书时是 3.4.1。点击后会跳转到详细信息页，如图 D-2 所示。

图 D-2：下载详情页

你需要向下滚动才能找到实际下载链接（如图 D-3 所示）。

图 D-3：下载链接页的底部

点击下载链接可以跳转到实际发布版本页（如图 D-4 所示）。

图 D-4：可以下载的文件

现在点击对应版本的链接即可将 Python 下载到你的电脑了。

D.1.1　Mac OS X

点击 Mac OS X 64-bit/32-bit installer（https://www.python.org/ftp/python/3.4.1/python-3.4.1-macosx10.6.dmg）链接下载 Mac 中使用的 .dmg 文件。下载完成后双击它，桌面上会弹出一个包含 4 个图标的窗口。右键单击 Python.mpkg，在弹出的对话框中点击“打开”。点击大约三次“继续”按钮，期间会显示一些法律声明，最后的对话框出现后点击“安装”。Python 3 的所有内容都会被安装到 /usr/local/bin/python3，不会对电脑上已安装的 Python 2 产生任何影响。

D.1.2　Windows

对于 Windows，根据自己计算机的具体环境选择下面两个安装包之一：

Windows x86 MSI installer（32 位，https://www.python.org/ftp/python/3.4.1/python-3.4.1.msi）

Windows x86-64 MSI installer（64 位，https://www.python.org/ftp/python/3.4.1/python-3.4.1.amd64.msi）

如果你不清楚自己的 Windows 到底是 32 位还是 64 位，可以执行下面几步来查看：

(1) 点击“开始”按钮；

(2) 右键点击“计算机”；

(3) 点击“属性”，就可以找到相关信息了。

点击适当的安装包链接（.msi 文件）。下载完成后双击打开，并根据安装指导进行安装。

D.1.3　Linux或Unix

Linux 和 Unix 用户可以选择下载 Python 源代码的压缩文件：

XZ compressed source tarball（https://www.python.org/ftp/python/3.4.1/Python-3.4.1.tar.xz）

Gzipped source tarball（https://www.python.org/ftp/python/3.4.1/Python-3.4.1.tgz）

下载上面两个链接中的一个。使用 tar xJ（.xz 文件）或 tar xz（.tgz 文件）命令解压，然后运行解压得到的 shell 脚本即可。

D.2　安装Anaconda

Anaconda 是一个针对科学计算设计的多合一安装包：包括 Python 本体、标准库以及许多实用的第三方库。目前为止，Anaconda 自带的仍是 Python 2 以及对应的解释器，但我们可以使用一些变通的方法强制它安装 Python 3。

最新的升级版本，Anaconda 2.0 会安装最近版本的 Python 及其标准库（编写此书时是 3.4）。它还会安装一些其他的实用工具，包括本书中讨论过的一些库：beautifulsoup4、flask、ipython、matplotlib、nose、numpy、pandas、pillow、pip、scipy、tables、zmq，等等。Anaconda 还包括一个跨平台的安装程序 conda，它是 pip 的增强版，之后会简单介绍它。

前往与 Python 3 版本对应的下载页（http://repo.continuum.io/archive/）下载安装 Anaconda 2。点击与你使用的平台对应的链接（具体的版本信息可能与下面列出的不一致，但我相信你能识别出应该下载哪一个）。

如果需要在 Mac 上使用，点击 Anaconda3-2.0.0-MacOSX-x86_64.pkg（http://repo.continuum.io/anaconda3/Anaconda3-2.0.0-MacOSX-x86_64.pkg）。“下载完成后双击打开文件，然后按提示步骤安装 Anaconda 即可。安装器会将所有内容安装到 home 目录 1 下面的 anaconda 子目录中。

对于 Windows 用户，点击 32-bit version（http://bit.ly/a/warning?url=http%3a%2f%2frepo%2econtinuum%2eio%2fanaconda3%2fAnaconda3%2d2%2e0%2e0%2dWindows%2dx86%2eexe&hash=win-32b）或者 64-bit version（http://bit.ly/a/warning?url=http%3a%2f%2frepo%2econtinuum%2eio%2fanaconda3%2fAnaconda3%2d2%2e0%2e0%2dWindows%2dx86%5f64%2eexe&hash=win-64b）进行下载。下载完成后双击 .exe 进行安装。

对于 Linux 用户，点击 32-bit version（http://repo.continuum.io/anaconda3/Anaconda3-2.0.0-Linux-x86.sh）或者 64-bit version（http://bit.ly/a/warning?url=http%3a%2f%2frepo%2econtinuum%2eio%2fanaconda3%2fAnaconda3%2d2%2e0%2e0%2dWindows%2dx86%5f64%2eexe&hash=win-64b）进行下载。下载结束后，执行它（一个大型 shell 脚本）即可。

1一般是 /Users/Your_Name。——译者注

　确保你下载的文件是以 Anaconda3 开头的。如果文件名仅仅是以 Anaconda 或 Anaconda2 开头，那么你下载的应该是 Python 2 对应的版本，它不是我们想要的。

Anaconda 将所有文件都安装到属于它自己的目录中（home 路径下 anaconda 目录中）。这意味着它的安装不会对你电脑上的任何 Python 版本产生影响，同时你也不需要特殊的权限（像 admin 和 root 之类的）来执行安装。

如果你想要知道 Anaconda 包含哪些 Python 包，可以访问 Anaconda 文档页（http://docs.continuum.io/anaconda/pkg-docs.html），点击文档顶部选择框中的“Python version：3.4”即可。我上次查看时总共列出了 141 个包。

安装完毕 Anaconda 3 后，你可以看看这位“圣诞老人”都在你的电脑里放了些什么小礼物。执行下面的命令：

$ ./conda list

# packages in environment at /Users/williamlubanovic/anaconda:
#
anaconda                  2.0.0                np18py34_0
argcomplete               0.6.7                    py34_0
astropy                   0.3.2                np18py34_0
backports.ssl-match-hostname 3.4.0.2                   <pip>
beautiful-soup            4.3.1                    py34_0
beautifulsoup4            4.3.1                     <pip>
binstar                   0.5.3                    py34_0
bitarray                  0.8.1                    py34_0
blaze                     0.5.0                np18py34_0
blz                       0.6.2                np18py34_0
bokeh                     0.4.4                np18py34_1
cdecimal                  2.3                      py34_0
colorama                  0.2.7                    py34_0
conda                     3.5.2                    py34_0
conda-build               1.3.3                    py34_0
configobj                 5.0.5                    py34_0
curl                      7.30.0                        2
cython                    0.20.1                   py34_0
datashape                 0.2.0                np18py34_1
dateutil                  2.1                      py34_2
docutils                  0.11                     py34_0
dynd-python               0.6.2                np18py34_0
flask                     0.10.1                   py34_1
freetype                  2.4.10                        1
future                    0.12.1                   py34_0
greenlet                  0.4.2                    py34_0
h5py                      2.3.0                np18py34_0
hdf5                      1.8.9                         2
ipython                   2.1.0                    py34_0
ipython-notebook          2.1.0                    py34_0
ipython-qtconsole         2.1.0                    py34_0
itsdangerous              0.24                     py34_0
jdcal                     1.0                      py34_0
jinja2                    2.7.2                    py34_0
jpeg                      8d                            1
libdynd                   0.6.2                         0
libpng                    1.5.13                        1
libsodium                 0.4.5                         0
libtiff                   4.0.2                         0
libxml2                   2.9.0                         1
libxslt                   1.1.28                        2
llvm                      3.3                           0
llvmpy                    0.12.4                   py34_0
lxml                      3.3.5                    py34_0
markupsafe                0.18                     py34_0
matplotlib                1.3.1                np18py34_1
mock                      1.0.1                    py34_0
multipledispatch          0.4.3                    py34_0
networkx                  1.8.1                    py34_0
nose                      1.3.3                    py34_0
numba                     0.13.1               np18py34_0
numexpr                   2.3.1                np18py34_0
numpy                     1.8.1                    py34_0
openpyxl                  2.0.2                    py34_0
openssl                   1.0.1g                        0
pandas                    0.13.1               np18py34_0
patsy                     0.2.1                np18py34_0
pillow                    2.4.0                    py34_0
pip                       1.5.6                    py34_0
ply                       3.4                      py34_0
psutil                    2.1.1                    py34_0
py                        1.4.20                   py34_0
pycosat                   0.6.1                    py34_0
pycparser                 2.10                     py34_0
pycrypto                  2.6.1                    py34_0
pyflakes                  0.8.1                    py34_0
pygments                  1.6                      py34_0
pyparsing                 2.0.1                    py34_0
pyqt                      4.10.4                   py34_0
pytables                  3.1.1                np18py34_0
pytest                    2.5.2                    py34_0
python                    3.4.1                         0
python-dateutil           2.1                       <pip>
python.app                1.2                      py34_2
pytz                      2014.3                   py34_0
pyyaml                    3.11                     py34_0
pyzmq                     14.3.0                   py34_0
qt                        4.8.5                         3
readline                  6.2                           2
redis                     2.6.9                         0
redis-py                  2.9.1                    py34_0
requests                  2.3.0                    py34_0
rope                      0.9.4                    py34_1
rope-py3k                 0.9.4                     <pip>
runipy                    0.1.0                    py34_0
scikit-image              0.9.3                np18py34_0
scipy                     0.14.0               np18py34_0
setuptools                3.6                      py34_0
sip                       4.15.5                   py34_0
six                       1.6.1                    py34_0
sphinx                    1.2.2                    py34_0
spyder                    2.3.0rc1                 py34_0
spyder-app                2.3.0rc1                 py34_0
sqlalchemy                0.9.4                    py34_0
sqlite                    3.8.4.1                       0
ssl_match_hostname        3.4.0.2                  py34_0
sympy                     0.7.5                    py34_0
tables                    3.1.1                     <pip>
tk                        8.5.15                        0
tornado                   3.2.1                    py34_0
ujson                     1.33                     py34_0
werkzeug                  0.9.4                    py34_0
xlrd                      0.9.3                    py34_0
xlsxwriter                0.5.5                    py34_0
yaml                      0.1.4                         1
zeromq                    4.0.4                         0
zlib                      1.2.7                         1

D.3　安装并使用pip和virtualenv

pip 是最受欢迎的管理第三方（非标准库）Python 包的包。这么实用的一款工具竟然不是标准 Python 的一部分实在是让人觉得很烦恼，因为我们不得不自己手动下载安装。我的一个朋友曾经说过：这简直就是残忍恼人的仪式。好消息是从 Python 3.4 开始，pip 已经被包含到标准 Python 中了！

virtualenv 经常与 pip 一起使用，它允许我们将 Python 包安装到指定的路径（文件夹）中以避免它与一些已经安装了的 Python 包互相影响。它的精髓在于，即使你没有权限修改已安装的 Python 包，你仍然可以随心所欲地使用任何你喜欢的 Python 工具 2。

2既然不会互相影响，重新安装一份即可。——译者注

如果你安装了 Python 3，但发现使用的仍然是 Python 2 版本的 pip，下面的命令可以帮助你在 Linux 或 OS X 下获取 Python 3 对应的版本：

$ curl -O http://python-distribute.org/distribute_setup.py
$ sudo python3 distribute_setup.py
$ curl -O https://raw.github.com/pypa/pip/master/contrib/get-pip.py
$ sudo python3 get-pip.py

上面的命令会将 pip-3.3 安装到你的 Python 3 安装路径下的 bin 目录中。之后你就可以使用 pip-3.3 而不是 Python 2 的 pip 来安装第三方包了。

下面列出一些有关 pip 和 virtualenv 教程的链接：

A non-magical introduction to Pip and Virtualenv for Python beginners（http://www.dabapps.com/blog/introduction-to-pip-and-virtualenv-python/）

The hitchhiker's guide to packaging: pip（http://the-hitchhikers-guide-to-packaging.readthedocs.org/en/latest/pip.html）

D.4　安装并使用conda

目前为止，pip 下载得到的大多是源文件而不是编译完成的二进制文件，但有些 Python 模块并不是用 Python 编写的而是用 C 语言库编写的，这意味着我们不得不进行额外的编译工作，非常麻烦。最近，Anaconda 的开发者们编写了 conda（http://www.continuum.io/blog/conda），旨在解决使用 pip 和其他工具时存在的各种问题。pip 是一个 Python 专属的包管理器，但 conda 可以运行在任何软件和语言环境中。conda 还同时解决了使用 pip 时不得不同时使用 virtualenv 这样的工具来保证安装的包之间互不影响的问题。

如果你安装了 Anaconda，conda 就已经附带安装完毕。如果你没有安装 Anaconda，可以从 miniconda 网页（http://conda.pydata.org/miniconda.html）下载 conda 和 与它绑定的 Python 3。和 Anaconda 一样，请确保你下载的文件以 Miniconda3 开头，仅以 Miniconda 开头的是 Python 2 对应的版本。

conda 依赖 pip。尽管 conda 有自己的包仓库（https://binstar.org/），但像 conda search 这样的命令在执行时除了搜索 conda 包仓库中的内容外，还会搜索 PyPi 库（http://pypi.python.org/）。如果你在使用 pip 时遇到了问题，不妨试试 conda。

附录 E　习题解答

E.1　第1章“Python初探”

(1) 如果你还没有安装 Python 3，现在就立刻动手。具体方法请阅读附录 D。

(2) 启动 Python 3 交互式解释器。再说一次，具体方法请阅读附录 D。它会打印出几行信息和一行 >>>，这是你输入 Python 命令的提示符。

下面是在我的 MacBook Pro 上显示的内容：

$ python
Python 3.3.0 (v3.3.0:bd8afb90ebf2, Sep 29 2012, 01:25:11)
[GCC 4.2.1 (Apple Inc. build 5666) (dot 3)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>

(3) 随便玩玩解释器。可以用它来计算 8 * 9。按下回车来查看结果，Python 应该会打印出 72。

>>> 8 * 9
72

(4) 输入数字 47 并按下回车，解释器有没有在下一行打印出 47 ？

>>> 47
47

(5) 现在输入 print(47) 并按下回车，解释器有没有在下一行打印出 47 ？

>>> print(47)
47

E.2　第2章“Python基本元素：数字、字符串和变量”

(1) 一个小时有多少秒？这里，请把交互式解释器当作计算器使用，将每分钟的秒数（60）乘以每小时的分钟数（60）得到结果。

>>> 60 * 60
3600

(2) 将上一个练习得到的结果（每小时的秒数）赋值给名为 seconds_per_hour 的变量。

>>> seconds_per_hour = 60 * 60
>>> seconds_per_hour
3600

(3) 一天有多少秒？用你的 seconds_per_hour 变量进行计算。

>>> seconds_per_hour * 24
86400

(4) 再次计算每天的秒数，但这一次将结果存储在名为 seconds_per_day 的变量中。

>>> seconds_per_day = seconds_per_hour * 24
>>> seconds_per_day
86400

(5) 用 seconds_per_day 除以 seconds_per_hour，使用浮点除法（/）。

>>> seconds_per_day / seconds_per_hour
24.0

(6) 用 seconds_per_day 除以 seconds_per_hour，使用整数除法（//）。除了末尾的 .0，本练习所得结果是否与前一个练习用浮点数除法得到的结果一致？

>>> seconds_per_day // seconds_per_hour
24

E.3　第3章“Python容器：列表、元组、字典与集合”

(1) 创建一个叫作 years_list 的列表，存储从你出生的那一年到你五岁那一年的年份。例如，如果你是 1980 年出生的，那么你的列表应该是 years_list = [1980, 1981, 1982, 1983, 1984, 1985]。

假设你出生在 1980 年，输入如下所示：

>>> years_list = [1980, 1981, 1982, 1983, 1984, 1985]

(2) 在 years_list 中，哪一年是你三岁生日那年？别忘了，你出生的第一年算 0 岁。

你需要的偏移量为 3，如果你出生在 1980 年，那么：

>>> years_list[3]
1983

(3) 在 years_list 中，哪一年你的年纪最大？

你需要得到列表中的最后一项，因此使用偏移量 -1，或者你也可以使用偏移量 5，因为你已经提前知道列表中有 6 项，但是 -1 返回任何大小列表的最后一项。对于一个 1980 年代的人：

>>> years_list[-1]
1985

(4) 创建一个名为 things 的列表，包含以下三个元素："mozzarella"、"cinderella" 和 "salmonella"。

>>> things = ["mozzarella", "cinderella", "salmonella"]
>>> things
['mozzarella', 'cinderella', 'salmonella']

(5) 将 things 中代表人名的字符串变成首字母大写形式，并打印整个列表。看看列表中的元素改变了么？

下面的方法实现了单词首字母大写，但是没有在列表中改变它：

>>> things[1].capitalize()
'Cinderella'
>>> things
['mozzarella', 'cinderella', 'salmonella']

如果你想在列表中改变它，应该将它重新赋值回列表：

>>> things[1] = things[1].capitalize()
>>> things
['mozzarella', 'Cinderella', 'salmonella']

(6) 将 things 中代表奶酪的元素全部改成大写，并打印整个列表。

>>> things[0] = things[0].upper()
>>> things
['MOZZARELLA', 'Cinderella', 'salmonella']

(7) 将代表疾病的元素从 things 中删除，收好你因此得到的诺贝尔奖，并打印列表。

按照值删掉它：

>>> things.remove("salmonella")
>>> things
['MOZZARELLA', 'Cinderella']

因为它是列表中的最后一项，所以下面的方法也是可行的：

>>> del things[-1]

或者从列表开始处按照偏移量删掉它：

>>> del things[2]

(8) 创建一个名为 surprise 的列表，包含以下三个元素："Groucho"、"Chico" 和 "Harpo"。

>>> surprise = ['Groucho', 'Chico', 'Harpo']
>>> surprise
['Groucho', 'Chico', 'Harpo']

(9) 将 surprise 列表的最后一个元素变成小写，翻转过来，再将首字母变成大写。

>>> surprise[-1] = surprise[-1].lower()
>>> surprise[-1] = surprise[-1][::-1]
>>> surprise[-1].capitalize()
'Oprah'

(10) 创建一个名为 e2f 的英法字典并打印出来。这里提供一些单词对：dog 是 chien、cat 是 chat 以及 walrus 是 morse。

>>> e2f = {'dog': 'chien', 'cat': 'chat', 'walrus': 'morse'}
>>> e2f
{'cat': 'chat', 'walrus': 'morse', 'dog': 'chien'}

(11) 使用你的仅包含三个词的字典 e2f 查询并打印出 walrus 对应的法语词。

>>> e2f['walrus']
'morse'

(12) 利用 e2f 创建一个名为 f2e 的法英字典。注意要使用 items 方法。

>>> f2e = {}
>>> for english, french in e2f.items():
    f2e[french] = english
>>> f2e
{'morse': 'walrus', 'chien': 'dog', 'chat': 'cat'}

(13) 使用 f2e，查询并打印法语词 chien 对应的英文词。

>>> f2e['chien']
'dog'

(14) 创建并打印由 e2f 的键组成的英语单词集合。

>>> set(e2f.keys())
{'cat', 'walrus', 'dog'}

(15) 建立一个名为 life 的多级字典，将下面这些字符串作为顶级键：'animals'、'plants' 以及 'others'。令 'animals' 键指向另一个字典，这个字典包含键 'cats'、'octopi' 以及 'emus'。令 'cats' 键指向一个字符串列表，这个列表包括 'Henri'、'Grumpy' 和 'Lucy'。让其余的键都指向空字典。

这是一道比较难的题，如果第一眼看到，不要觉得不舒服：

>>> life = {
...     'animals': {
...         'cats': [
...             'Henri', 'Grumpy', 'Lucy'
...             ],
...         'octopi': {},
...         'emus': {}
...         },
...     'plants': {},
...     'other': {}
...     }
>>>

(16) 打印 life 的顶级键。

>> print(life.keys())
dict_keys(['animals', 'other', 'plants'])

Python 3 包含 dict_keys 的项，把它作为普通的列表打印输出：

>>> print(list(life.keys()))
['animals', 'other', 'plants']

顺便提一句，在代码中多加空格可以提高可读性：

>>> print ( list ( life.keys() ) )
['animals', 'other', 'plants']

(17)打印 life['animals'] 的全部键。

>>> print(life['animals'].keys())
dict_keys(['cats', 'octopi', 'emus'])

(18)打印 life['animals']['cats'] 的值。

>>> print(life['animals']['cats'])
['Henri', 'Grumpy', 'Lucy']

E.4　第4章“Python外壳：代码结构”

(1) 将 7 赋值给变量 guess_me，然后写一段条件判断（if、else 和 elif）的代码：如果 guess_me 小于 7 输出 'too low'，大于 7 则输出 'too high'，等于 7 则输出 'just right'。

guess_me = 7
if guess_me < 7:
    print('too low')
elif guess_me > 7:
    print('too high')
else:
    print('just right')

执行这段代码得到如下结果：

just right

(2) 将 7 赋值给变量 guess_me，再将 1 赋值给变量 start。写一段 while 循环代码比较 start 和 guess_me：如果 start 小于 guess_me 则输出 'too low'，如果等于则输出 'found it'，如果大于则输出 'oops'，然后终止循环。在每次循环结束时自增 start。

guess_me = 7
start = 1
while True:
    if start < guess_me:
        print('too low')
    elif start == guess_me:
        print('found it!')
        break
    elif start > guess_me:
        print('oops')
        break
    start += 1

如果代码正确执行，结果如下所示：

too low
too low
too low
too low
too low
too low
found it!

注意 elif start > guess_me：这一行可以只用简单的 else:，因为 start 不小于等于 guess_me 即大于，至少在这是对的。

(3) 使用 for 循环输出列表 [3, 2, 1, 0] 的值。

>>> for value in [3, 2, 1, 0]:
...     print(value)
...
3
2
1
0

(4) 使用列表推导生成 10 以内（range(10)）偶数的列表。

>>> even = [number for number in range(10) if number % 2 == 0]
>>> even
[0, 2, 4, 6, 8]

(5) 使用字典推导创建字典 squares。把 0~9 内的整数作为键，每个键的平方作为对应的值。

>>> squares = {key: key*key for key in range(10)}
>>> squares
{0: 0, 1: 1, 2: 4, 3: 9, 4: 16, 5: 25, 6: 36, 7: 49, 8: 64, 9: 81}

(6) 使用集合推导创建集合 odd，包含 0~9 内（range(10)）的奇数。

>>> odd = {number for number in range(10) if number % 2 == 1}
>>> odd
{1, 3, 9, 5, 7}

(7) 使用生成器推导返回字符串 'Got ' 和 0~9 内的一个整数，使用 for 循环进行迭代。

>> for thing in ('Got %s' % number for number in range(10)):
...     print(thing)
...
Got 0
Got 1
Got 2
Got 3
Got 4
Got 5
Got 6
Got 7
Got 8
Got 9

(8) 定义函数 good()：返回列表 ['Harry', 'Ron', 'Hermione']。

>>> def good():
...     return ['Harry', 'Ron', 'Hermione']
...
>>> good()
['Harry', 'Ron', 'Hermione']

(9) 定义一个生成器函数 get_odds()：返回 0~9 内的奇数。使用 for 循环查找并输出返回的第三个值。

>>> def get_odds():
...     for number in range(1, 10, 2):
...         yield number
...
>>> for count, number in enumerate(get_odds(), 1):
...     if count == 3:
...         print("The third odd number is", number)
...         break
...
The third odd number is 5

(10) 定义一个装饰器 test：当一个函数被调用时输出 'start'，当函数结束时输出 'end'。

>>> def test(func):
...     def new_func(*args, **kwargs):
...         print('start')
...         result = func(*args, **kwargs)
...         print('end')
...         return result
...     return new_func
...
>>>
>>> @test
... def greeting():
...     print("Greetings, Earthling")
...
>>> greeting()
start
Greetings, Earthling
end

(11) 定义一个异常 OopsException：编写代码捕捉该异常，并输出 'Caught an oops'。

>>> class OopsException(Exception):
...     pass
...
>>> raise OopsException()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
__main__.OopsException
>>>
>>> try:
...     raise OopsException
... except OopsException:
...     print('Caught an oops')
...
Caught an oops

(12) 使用函数 zip() 创建字典 movies：匹配两个列表 titles = ['Creature of Habit', 'Crewel Fate'] 和 plots = ['A nun turns into a monster', 'A haunted yarn shop']。

>>> titles = ['Creature of Habit', 'Crewel Fate']
>>> plots = ['A nun turns into a monster', 'A haunted yarn shop']
>>> movies = dict(zip(titles, plots))
>>> movies
{'Crewel Fate': 'A haunted yarn shop', 'Creature of Habit': 'A nun turns
into a monster'}

E.5　第5章“Python盒子：模块、包和程序”

(1) 创建文件 zoo.py。在文件中定义函数 hours：输出字符串 'Open 9-5 daily'。然后使用交互式解释器导入模块 zoo，调用函数 hours。下面是文件 zoo.py：

def hours():
    print('Open 9-5 daily')

现在，在解释器中导入它：

>>> import zoo
>>> zoo.hours()
Open 9-5 daily

(2) 在交互式解释器，把模块 zoo 作为 menagerie 导入，然后调用函数 hours()。

>>> import zoo as menagerie
>>> menagerie.hours()
Open 9-5 daily

(3) 继续在解释器中，直接从模块 zoo 导入函数 hours()，然后调用。

>>> from zoo import hours
>>> hours()
Open 9-5 daily

(4) 把函数 hours() 作为 info 导入，然后调用它。

>>> from zoo import hours as info
>>> info()
Open 9-5 daily

(5) 创建字典 plain：包含键值对 'a':1、'b':2 和 'c':3，然后输出它。

>>> plain = {'a': 1, 'b': 2, 'c': 3}
>>> plain
{'a': 1, 'c': 3, 'b': 2}

(6) 创建有序字典 fancy：键值对和 (5) 相同，然后输出它。输出顺序和 plain 相同吗？

>>> from collections import OrderedDict
>>> fancy = OrderedDict([('a', 1), ('b', 2), ('c', 3)])
>>> fancy
OrderedDict([('a', 1), ('b', 2), ('c', 3)])

(7) 创建默认字典 dict_of_lists，传入参数 list：给 dict_of_lists['a'] 赋值 'something for a'，输出 dict_of_lists['a'] 的值。

>>> from collections import defaultdict
>>> dict_of_lists = defaultdict(list)
>>> dict_of_lists['a'].append('something for a')
>>> dict_of_lists['a']
['something for a']

E.6　第6章“对象和类”

(1) 创建一个名为 Thing 的空类并将它打印出来。接着创建一个属于该类的对象 example，同样将它打印出来。看看这两次打印结果是一样的还是不同的？

>>> class Thing:
...     pass
...
>>> print(Thing)
<class '__main__.Thing'>
>>> example = Thing()
>>> print(example)
<__main__.Thing object at 0x1006f3fd0>

(2) 创建一个新类 Thing2，将 'abc' 赋值给类特性 letters，打印 letters。

>>> class Thing2:
...     letters = 'abc'
...
>>> print(Thing2.letters)
abc

(3) 再创建一个新类 Thing3。这次将 'xyz' 赋值给实例（对象）特性 letters，并打印 letters。看看你是不是必须先创建一个对象才可以进行打印操作？

>>> class Thing3:
...     def __init__(self):
...         self.letters = 'xyz'
...

变量 letters 属于类 Thing3 的任何对象，而不是 Thing3 类本身：

>>> print(Thing3.letters)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: type object 'Thing3' has no attribute 'letters'
>>> something = Thing3()
>>> print(something.letters)
xyz

(4) 创建一个名为 Element 的类，它包含实例属性 name、symbol 和 number。使用 'Hydrogen'、'H' 和 1 实例化一个对象 hydrogen。

>>> class Element:
...     def __init__(self, name, symbol, number):
...         self.name = name
...         self.symbol = symbol
...         self.number = number
...
>>> hydrogen = Element('Hydrogen', 'H', 1)

(5) 创建一个字典，包含这些键值对：'name': 'Hydrogen'、'symbol': 'H' 和 'number': 1。然后用这个字典实例化 Element 类的对象 hydrogen。

首先创建该字典：

>>> el_dict = {'name': 'Hydrogen', 'symbol': 'H', 'number': 1}

虽然会编写较多代码，但这是可行的：

>>> hydrogen = Element(el_dict['name'], el_dict['symbol'], el_dict['number'])

检查一下实例化的结果：

>>> hydrogen.name
'Hydrogen'

然而，你可以直接从字典初始化对象，因为它的键名称是和 __init__ 参数相匹配的（参考第 4 章关于关键字参数的讨论）：

>>> hydrogen = Element(**el_dict)
>>> hydrogen.name
'Hydrogen'

(6) 为 Element 类定义个 dump() 方法，用于打印对象的属性（name、symbol 和 number）。使用这个新类创建一个对象 hydrogen 并用 dump() 打印。

>>> class Element:
...     def __init__(self, name, symbol, number):
...         self.name = name
...         self.symbol = symbol
...         self.number = number
...     def dump(self):
...         print('name=%s, symbol=%s, number=%s' %
...             (self.name, self.symbol, self.number))
...
>>> hydrogen = Element(**el_dict)
>>> hydrogen.dump()
name=Hydrogen, symbol=H, number=1

(7) 调用 print(hydrogen)，然后修改 Element 的定义，将 dump 方法的名字改为 __str__。再次创建一个 hydrogen 对象并调用 print(hydrogen)，观察输出结果。

>>> print(hydrogen)
<__main__.Element object at 0x1006f5310>
>>> class Element:
...     def __init__(self, name, symbol, number):
...         self.name = name
...         self.symbol = symbol
...         self.number = number
...     def __str__(self):
...         return ('name=%s, symbol=%s, number=%s' %
...             (self.name, self.symbol, self.number))
...
>>> hydrogen = Element(**el_dict)
>>> print(hydrogen)
name=Hydrogen, symbol=H, number=1

__str__() 是 Python 的一个魔术方法，print 函数调用一个对象的 __str__() 方法获取它的字符串表示。如果类中没有定义 __str__() 方法，它会采用父类的默认方法，返回类似于 <__main__.Element object at 0x1006f5310> 的一个字符串。

(8) 修改 Element 使得 name、symbol 和 number 特性都变成私有的。为它们各定义一个 getter 属性（property）来返回各自的值。

>>> class Element:
...     def __init__(self, name, symbol, number):
...         self.__name = name
...         self.__symbol = symbol
...         self.__number = number
...     @property
...     def name(self):
...         return self.__name
...     @property
...     def symbol(self):
...         return self.__symbol
...     @property
...     def number(self):
...         return self.__number
...
>>> hydrogen = Element('Hydrogen', 'H', 1)
>>> hydrogen.name
'Hydrogen'
>>> hydrogen.symbol
'H'
>>> hydrogen.number
1

(9) 定义三个类：Bear、Rabbit 和 Octothorpe。对每个类都只定义一个方法 eats()，分别返回 'berries'(Bear)、'clover'(Rabbit) 和 'campers'(Octothorpo)。为每个类创建一个对象并输出它们各自吃的食物（调用 eats()）。

>> class Bear:
...     def eats(self):
...         return 'berries'
...
>>> class Rabbit:
...     def eats(self):
...         return 'clover'
...
>>> class Octothorpe:
...     def eats(self):
...         return 'campers'
...
>>> b = Bear()
>>> r = Rabbit()
>>> o = Octothorpe()
>>> print(b.eats())
berries
>>> print(r.eats())
clover
>>> print(o.eats())
campers

(10) 定义三个类：Laser、Claw 以及 SmartPhone。每个类都仅有一个方法 does()，分别返回 'disintegrate'(Laser)、'cursh'(Claw) 以及 'ring'(SmartPhone)。接着定义 Robot 类，包含上述三个类的实例（对象）各一个。给 Robot 定义 does() 方法，用于输出它各部分的功能。

>>> class Laser:
...     def does(self):
...         return 'disintegrate'
...
>>> class Claw:
...     def does(self):
...         return 'crush'
...
>>> class SmartPhone:
...     def does(self):
...         return 'ring'
...
>>> class Robot:
...     def __init__(self):
...         self.laser = Laser()
...         self.claw = Claw()
...         self.smartphone = SmartPhone()
...     def does(self):
...         return '''I have many attachments:
... My laser, to %s.
... My claw, to %s.
... My smartphone, to %s.''' % (
...     self.laser.does(),
...     self.claw.does(),
...     self.smartphone.does() )
...
>>> robbie = Robot()
>>> print( robbie.does() )
I have many attachments:
My laser, to disintegrate.
My claw, to crush.
My smartphone, to ring.

E.7　第7章“像高手一样玩转数据”

(1) 创建一个 Unicode 字符串 mystery 并将它的值设为 '\U0001f4a9'。打印 mystery，并查看 mystery 的 Unicode 名称。

>>> import unicodedata
>>> mystery = '\U0001f4a9'
>>> mystery
' '
>>> unicodedata.name(mystery)
'PILE OF POO'

它们还会变成什么呢？

(2) 使用 UTF-8 对 mystery 进行编码，存入字节型变量 pop_bytes，并将它打印出来。

>>> pop_bytes = mystery.encode('utf-8')
>>> pop_bytes
b'\xf0\x9f\x92\xa9'

(3) 使用 UTF-8 对 pop_bytes 进行解码，存入字符串型变量 pop_string，并将它打印出来，看看它与 mystery 是否一致？

>>> pop_string = pop_bytes.decode('utf-8')
>>> pop_string
' '
>>> pop_string == mystery
True

(4) 使用旧式格式化方法生成下面的诗句，把 'roast beef'、'ham'、'head' 以及 'clam' 依次插入字符串：

My kitty cat likes %s,
My kitty cat likes %s,
My kitty cat fell on his %s
And now thinks he's a %s.

>>> poem = '''
... My kitty cat likes %s,
... My kitty cat likes %s,
... My kitty cat fell on his %s
... And now thinks he's a %s.
... '''
>>> args = ('roast beef', 'ham', 'head', 'clam')
>>> print(poem % args)

My kitty cat likes roast beef,
My kitty cat likes ham,
My kitty cat fell on his head
And now thinks he's a clam.

(5) 使用新式格式化方法生成下面的套用信函，将下面的字符串存储为 letter（后面的练习中会用到）：

Dear {salutation} {name},

Thank you for your letter. We are sorry that our {product} {verbed} in your {room}. Please note that it should never be used in a {room}, especially near any {animals}.

Send us your receipt and {amount} for shipping and handling. We will send you another {product} that, in our tests, is {percent}% less likely to have {verbed}.

Thank you for your support.

Sincerely,
{spokesman}
{job_title}

>>> letter = '''
... Dear {salutation} {name},
...
... Thank you for your letter. We are sorry that our {product} {verb} in your
... {room}. Please note that it should never be used in a {room}, especially
... near any {animals}.
...
... Send us your receipt and {amount} for shipping and handling. We will send
... you another {product} that, in our tests, is {percent}% less likely to
... have {verbed}.
...
... Thank you for your support.
...
... Sincerely,
... {spokesman}
... {job_title}
... '''

(6) 创建一个字典 response，包含以下键：'salutaion'、'name'、'product'、verved（动词过去式）、'room'、'animals'、'percent'、'spokesman' 以及 'job_title'。设定这些键对应的值，并打印由 response 的值填充的 letter。

>>> response = {
...     'salutation': 'Colonel',
...     'name': 'Hackenbush',
...     'product': 'duck blind',
...     'verbed': 'imploded',
...     'room': 'conservatory',
...     'animals': 'emus',
...     'amount': '$1.38',
...     'percent': '1',
...     'spokesman': 'Edgar Schmeltz',
...     'job_title': 'Licensed Podiatrist'
...     }
...
>>> print( letter.format(**response) )

Dear Colonel Hackenbush,

Thank you for your letter. We are sorry that our duck blind imploded in your conservatory. Please note that it should never be used in a conservatory, especially near any emus.

Send us your receipt and $1.38 for shipping and handling. We will send you another duck blind that, in our tests, is 1% less likely to have imploded.

Thank you for your support.

Sincerely,
Edgar Schmeltz
Licensed Podiatrist

(7) 正则表达式在处理文本上非常方便。在这个练习中，我们会对示例文本尝试做各种各样的操作。我们示例文本是一首名为 Ode on the Mammoth Cheese 的诗，它的作者是 James McIntyre，写于 1866 年，出于对当时安大略湖手工制造的 7000 磅的巨型奶酪的敬意，它当时甚至在全球巡回展出。如果你不愿意自己一字一句敲出来，直接百度一下粘贴到你的 Python 代码里即可。你也可以从 Project Gutenberg（http://www.gutenberg.org/ebooks/36068?msg=welcome_stranger）找到。我们将这个字符串命名为 mammoth。

>>> mammoth = '''
... We have seen thee, queen of cheese,
... Lying quietly at your ease,
... Gently fanned by evening breeze,
... Thy fair form no flies dare seize.
...
... All gaily dressed soon you'll go
... To the great Provincial show,
... To be admired by many a beau
... In the city of Toronto.
...
... Cows numerous as a swarm of bees,
... Or as the leaves upon the trees,
... It did require to make thee please,
... And stand unrivalled, queen of cheese.
...
... May you not receive a scar as
... We have heard that Mr. Harris
... Intends to send you off as far as
... The great world's show at Paris.
...
... Of the youth beware of these,
... For some of them might rudely squeeze
... And bite your cheek, then songs or glees
... We could not sing, oh! queen of cheese.
...
... We'rt thou suspended from balloon,
... You'd cast a shade even at noon,
... Folks would think it was the moon
... About to fall and crush them soon.
... '''

(8) 引入 re 模块以便使用正则表达式相关函数。使用 re.findall() 打印出所有以 'c' 开头的单词。

首先对所要匹配的模式定义变量 pat，然后在 mammoth 中查找：

>>> import re
>>> re = r'\bc\w*'
>>> re.findall(pat, mammoth)
['cheese', 'city', 'cheese', 'cheek', 'could', 'cheese', 'cast', 'crush']

\b 代表以单词之间的分隔符作为开始，使用它一般用于指定单词的开始或者结束，字母 c 是我们要查找单词的首字母。\w 代表任意单词字符（包括字母、数字和下划线）。* 表示一个或者多个字符。综合起来，它用来查找以字母 c 开头的单词，包括 'c' 本身。如果你不使用原始字符串（在第一个引号前加 r），Python 会把 \b 解释为退格字符，查找会神奇地挂掉：

>>> pat = '\bc\w*'
>>> re.findall(pat, mammoth)
[]

(9) 找到所有以 c 开头的 4- 字母单词。

>>> pat = r'\bc\w{3}\b'
>>> re.findall(pat, mammoth)
['city', 'cast']

你需要最后的 \b 来指明单词的结束。否则，你会得到所有以 c 开头并且至少有四个字母的单词的前四个字母：

>>> pat = r'\bc\w{3}'
>>> re.findall(pat, mammoth)
['chee', 'city', 'chee', 'chee', 'coul', 'chee', 'cast', 'crus']

(10) 找到所有以 r 结尾的单词。

下面代码使用要小心，对于以 r 结尾的单词会得到完美的结果：

>>> pat = r'\b\w*r\b'
>>> re.findall(pat,mammoth)
['your', 'fair', 'Or', 'scar', 'Mr', 'far', 'For', 'your', 'or']

然而，用在以 l 结尾的单词结果就不好：

>>> pat = r'\b\w*l\b'
>>> re.findall(pat,mammoth)
['All', 'll', 'Provincial', 'fall']

但是，ll 为什么出现在那儿？ \w 仅仅匹配到字母、数字和下划线，不会匹配到 ASCII 中的撇号 (')。所以，它会从 you'll 抽取到最后的 ll。解决该问题可以把撇号加到要匹配的字符集合。第一次这样做失败了：

>>> >>> pat = r'\b[\w']*l\b'
  File "<stdin>", line 1
    pat = r'\b[\w']*l\b'

Python 指到了错误附近的位置，但仍然需要花费一段时间发现模式串被两个引号（撇号）同时包括，一个解决方法是加转移字符：

>>> pat = r'\b[\w\']*l\b'
>>> re.findall(pat, mammoth)
['All', "you'll", 'Provincial', 'fall']

另一种方法是给模式串加双引号：

>>> pat = r"\b[\w']*l\b"
>>> re.findall(pat, mammoth)
['All', "you'll", 'Provincial', 'fall']

(11) 找到所有包含且仅包含连续 3 个元音的单词。

开始匹配时是一个单词边界符，然后任意数目的字母、三个连续的元音，接下来是任意数目的非元音字符直到单词结束：

>>> pat = r'\b\w*[aeiou]{3}[^aeiou]\w*\b'
>>> re.findall(pat, mammoth)
['queen', 'quietly', 'beau\nIn', 'queen', 'squeeze', 'queen']

上面的匹配看起来是对的，除了字符串 'beau\nIn'。把 mammoth 作为多行的字符串进行搜索，[^aeiou] 匹配任何非元音字符包括换行符。所以要把一些间隔字符加入到忽略集合，例如 \n（\s 匹配到间隔字符）：

>>> pat = r'\b\w*[aeiou]{3}[^aeiou\s]\w*\b'
>>> re.findall(pat, mammoth)
['queen', 'quietly', 'queen', 'squeeze', 'queen']

但这一次没有搜索到 beau，所以还要对模式串进行变动，匹配到三个连续元音之后还要匹配任意数目的非元音。之前的模式串只匹配了一个非元音：

>>> pat = r'\b\w*[aeiou]{3}[^aeiou\s]*\w*\b'
>>> re.findall(pat, mammoth)
['queen', 'quietly', 'beau', 'queen', 'squeeze', 'queen']

上面所有的内容表明了什么？其中一点是：正则表达式可以完成很多事情，但正确使用它还要多加小心。

(12) 使用 unhexlify() 将下面的十六进制串（出于排版原因将它们拆成两行字符串）转换为 bytes 型变量，命名为 gif：

'47494638396101000100800000000000ffffff21f9' +
'0401000000002c000000000100010000020144003b'

>>> import binascii
>>> hex_str = '47494638396101000100800000000000ffffff21f9' + \
...     '0401000000002c000000000100010000020144003b'
>>> gif = binascii.unhexlify(hex_str)
>>> len(gif)
42

(13) gif 定义了一个 1 像素的透明 GIF 文件（最常见的图片格式之一）。合法的 GIF 文件开头由 GIF89a 组成，检测一下上面的 gif 是否为合法的 GIF 文件？

>>> gif[:6] == b'GIF89a'
True

注意，我们使用 b 来定义一个字节串而不是 Unicode 字符串，你可以在字节之间做比较，但是不能用字符串和字节比较：

>>> gif[:6] == 'GIF89a'
False
>>> type(gif)
<class 'bytes'>
>>> type('GIF89a')
<class 'str'>
>>> type(b'GIF89a')
<class 'bytes'>

(14) GIF 文件的像素宽度是一个 16 比特的以大端方案存储的整数，偏移量为 6 字节，高度数据的大小与之相同，偏移量为 8。从 gif 中抽取这些信息并打印出来，看看它们是否与预期的一样都为 1 ？

>>> import struct
>>> width, height = struct.unpack('<HH', gif[6:10])
>>> width, height
(1, 1)

E.8　第8章“数据的归宿”

(1) 将字符串 'This is a test of the emergency text system' 赋给变量 test1，然后把它写到文件 test.txt。

>>> test1 = 'This is a test of the emergency text system'
>>> len(test1)
43

下面是如何使用 open、write 和 close 函数实现题目要求：

>>> outfile = open('test.txt', 'wt')
>>> outfile.write(test1)
43
>>> outfile.close()

或者直接使用 with，避免调用 close（Python 帮你实现）：

>>> with open('test.txt', 'wt') as outfile:
...     outfile.write(test1)
...
43

(2) 打开文件 test.txt，读文件内容到字符串 test2。test1 和 test2 是一样的吗？

>>> with open('test.txt', 'rt') as infile:
...     test2 = infile.read()
...
>>> len(test2)
43
>>> test1 == test2
True

(3) 保存这些文本到 test.csv 文件。注意，字段间是通过逗号隔开的，如果字段中含有逗号需要在整个字段加引号。

author,book
J R R Tolkien,The Hobbit
Lynne Truss,"Eats, Shoots & Leaves"

>>> text = '''author,book
... J R R Tolkien,The Hobbit
... Lynne Truss,"Eats, Shoots & Leaves"
... '''
>>> with open('test.csv', 'wt') as outfile:
...     outfile.write(text)
...
73

(4) 使用 csv 模块和它的 DictReader() 方法读取文件 test.csv 到变量 books。输出变量 books 的值。DictReader() 可以处理第二本书题目中的引号和逗号吗？

>>> with open('test.csv', 'rt') as infile:
...     books = csv.DictReader(infile)
...     for book in books:
...         print(book)
...
{'book': 'The Hobbit', 'author': 'J R R Tolkien'}
{'book': 'Eats, Shoots & Leaves', 'author': 'Lynne Truss'}

(5) 创建包含下面这些行的 CSV 文件 books.csv：

title,author,year
The Weirdstone of Brisingamen,Alan Garner,1960
Perdido Street Station,China Miéville,2000
Thud!,Terry Pratchett,2005
The Spellman Files,Lisa Lutz,2007
Small Gods,Terry Pratchett,1992

>>> text = '''title,author,year
... The Weirdstone of Brisingamen,Alan Garner,1960
... Perdido Street Station,China Miéville,2000
... Thud!,Terry Pratchett,2005
... The Spellman Files,Lisa Lutz,2007
... Small Gods,Terry Pratchett,1992
... '''
>>> with open('books.csv', 'wt') as outfile:
...     outfile.write(text)
...
201

(6) 使用 sqlite3 模块创建一个 SQLite 数据库 books.db 以及包含字段 title（text）、author（text）以及 year（integer）的表单 book。

>>> import sqlite3
>>> db = sqlite3.connect('books.db')
>>> curs = db.cursor()
>>> curs.execute('''create table book (title text, author text, year int)''')
<sqlite3.Cursor object at 0x1006e3b90>
>>> db.commit()

(7) 读取文件 books.csv，把数据插入到表单 book。

>>> import csv
>>> import sqlite3
>>> ins_str = 'insert into book values(?, ?, ?)'
>>> with open('books.csv', 'rt') as infile:
...     books = csv.DictReader(infile)
...     for book in books:
...         curs.execute(ins_str, (book['title'], book['author'], book['year']))
...
<sqlite3.Cursor object at 0x1007b21f0>
<sqlite3.Cursor object at 0x1007b21f0>
<sqlite3.Cursor object at 0x1007b21f0>
<sqlite3.Cursor object at 0x1007b21f0>
<sqlite3.Cursor object at 0x1007b21f0>
>>> db.commit()

(8) 选择表单 book 中的 title 列，并按照字母表顺序输出。

>>> sql = 'select title from book order by title asc'
>>> for row in db.execute(sql):
...     print(row)
...
('Perdido Street Station',)
('Small Gods',)
('The Spellman Files',)
('The Weirdstone of Brisingamen',)
('Thud!',)

如果你只想输出 title 的值，不包含引号和逗号，试下这个方法：

>>> for row in db.execute(sql):
...     print(row[0])
...
Perdido Street Station
Small Gods
The Spellman Files
The Weirdstone of Brisingamen
Thud!

如果你想排序时忽略掉题目开头的 'The'，还需要加额外的 SQL 魔法（语句）：

>>> sql = '''select title from book order by
... case when (title like "The %") then substr(title, 5) else title end'''
>>> for row in db.execute(sql):
...     print(row[0])
...
Perdido Street Station
Small Gods
The Spellman Files
Thud!
The Weirdstone of Brisingamen

(9) 选择表单 book 中所有的列，并按照出版顺序输出。

>>> for row in db.execute('select * from book order by year'):
...     print(row)
...
('The Weirdstone of Brisingamen', 'Alan Garner', 1960)
('Small Gods', 'Terry Pratchett', 1992)
('Perdido Street Station', 'China Miéville', 2000)
('Thud!', 'Terry Pratchett', 2005)
('The Spellman Files', 'Lisa Lutz', 2007)

为了打印输出表单 book 的每一行所有的字段，用逗号和空格把它们隔开：

>>> for row in db.execute('select * from book order by year'):
...     print(*row, sep=', ')
...
The Weirdstone of Brisingamen, Alan Garner, 1960
Small Gods, Terry Pratchett, 1992
Perdido Street Station, China Miéville, 2000
Thud!, Terry Pratchett, 2005
The Spellman Files, Lisa Lutz, 2007

(10) 使用 sqlalchemy 模块连接到 sqlite3 数据库 books.db，按照 (8) 一样，选择表单 book 中的 title 列，并按照字母表顺序输出。

>>> import sqlalchemy
>>> conn = sqlalchemy.create_engine('sqlite:///books.db')
>>> sql = 'select title from book order by title asc'
>>> rows = conn.execute(sql)
>>> for row in rows:
...     print(row)
...
('Perdido Street Station',)
('Small Gods',)
('The Spellman Files',)
('The Weirdstone of Brisingamen',)
('Thud!',)

(11) 在你的计算机安装 Redis 服务器（参见附录 D）和 Python 的 redis 库（pip install redis）。创建一个 Redis 的哈希表 test，包含字段 count(1) 和 name('Fester Bestertester')，输出 test 的所有字段。

>>> import redis
>>> conn = redis.Redis()
>>> conn.delete('test')
1
>>> conn.hmset('test', {'count': 1, 'name': 'Fester Bestertester'})
True
>>> conn.hgetall('test')
{b'name': b'Fester Bestertester', b'count': b'1'}

(12) 自增 test 的 count 字段并输出它。

>>> conn.hincrby('test', 'count', 3)
4
>>> conn.hget('test', 'count')
b'4'

E.9　第9章“剖析Web”

(1) 如果你还没有安装 flask，现在安装它。这样会自动安装 werkzeug、jinja2 和其他包。

(2) 搭建一个网站框架，使用 Flask 的调试 / 代码重载来开发 Web 服务器。使用主机名 localhost 和默认端口 5000 来启动服务器。如果你电脑的 5000 端口已经被占用，使用其他端口。

下面是文件 flask1.py：

from flask import Flask

app = Flask(__name__)

app.run(port=5000, debug=True)

开启 Web 服务器引擎：

$ python flask1.py
 * Running on http://127.0.0.1:5000/
 * Restarting with reloader

(3) 添加一个 home() 函数来处理对于主页的请求，让它返回字符串 It's alive!。

我们该如何调用 flask2.py 呢？

from flask import Flask

app = Flask(__name__)
@app.route('/')
def home():
    return "It's alive!"

app.run(debug=True)

开启服务器：

$ python flask2.py
 * Running on http://127.0.0.1:5000/
 * Restarting with reloader

最后通过浏览器或者命令行 HTTP 程序（例如 curl、wget 甚至 telnet）进入主页 :

$ curl http://localhost:5000/
It's alive!

(4) 创建一个名为 home.html 的 Jinja2 模板文件，内容如下所示：

I'm of course referring to {{thing}}, which is {{height}} feet tall and {{color}}.

创建名为 templates 的目录，在该目录下创建包含以上内容的文件 home.html。如果你的 Flask 服务器仍在运行中，它会检测到新的内容并自动重启。

(5) 修改 home() 函数，让它使用 home.html 模板。给模板传入三个 GET 参数：thing、height 和 color。

下面是文件 flask3.py：

from flask import Flask, request, render_template

app = Flask(__name__)

@app.route('/')
def home():
    thing = request.values.get('thing')
    height = request.values.get('height')
    color = request.values.get('color')
    return render_template('home.html',
        thing=thing, height=height, color=color)

app.run(debug=True)

在 Web 客户端前往地址 http://localhost:5000/?thing=Octothorpe&height=7&color=green，你应该可以看到如下内容：

I'm of course referring to Octothorpe, which is 7 feet tall and green.

E.10　第10章“系统”

(1) 把当前日期以字符串形式写入文本文件 today.txt。

>>> from datetime import date
>>> now = date.today()
>>> now_str = now.isoformat()
>>> with open('today', 'wt') as output:
...     print(now_str, file=output)
>>>

除了 print，你可以使用 output.write(now_str) 作为输出。使用 print 会在文件末尾增加一行空行。

(2) 从 today.txt 中读取字符串到 today_string 中。

>>> with open('today', 'rt') as input:
...     today_string = input.read()
...
>>> today_string
'2014-02-04\n'

(3) 从 today_string 中解析日期。

>>> fmt = '%Y-%m-%d\n'
>>> datetime.strptime(today_string, fmt)
datetime.datetime(2014, 2, 4, 0, 0)

如果你在文件末尾写入空行，需要在格式字符串（format string）中匹配它。

(4) 列出当前目录下的文件。

如果你的当前目录为 ohmy，包含三个以动物命名的文件，结果可能是这样的：

>>> import os
>>> os.listdir('.')
['bears', 'lions', 'tigers']

(5) 列出父目录下的文件。

如果父目录包含两个文件和当前的 ohmy 目录，结果可能是这样的：

>>> import os
>>> os.listdir('..')
['ohmy', 'paws', 'whiskers']

(6) 使用 multiprocessing 创建三个独立的进程，每一个进程在 0 和 1 之间等待随机的时间，输出当前时间，然后终止进程。

保存下面代码到文件 multi_times.py：

import multiprocessing

def now(seconds):
    from datetime import datetime
    from time import sleep
    sleep(seconds)
    print('wait', seconds, 'seconds, time is', datetime.utcnow())

if __name__ == '__main__':
    import random
    for n in range(3):
        seconds = random.random()
        proc = multiprocessing.Process(target=now, args=(seconds,))
        proc.start()

$ python multi_times.py
wait 0.4670532005508353 seconds, time is 2014-06-03 05:14:22.930541
wait 0.5908421960431798 seconds, time is 2014-06-03 05:14:23.054925
wait 0.8127669040699719 seconds, time is 2014-06-03 05:14:23.275767

(7) 创建一个你出生日的日期对象。

假设你出生在 1982 年 8 月 14 日：

>>> my_day = date(1982, 8, 14)
>>> my_day
datetime.date(1982, 8, 14)

(8) 你的生日是星期几？

>>> my_day.weekday()
5
>>> my_day.isoweekday()
6

使用函数 weekday()，周一返回 0，周日返回 6。而使用函数 isoweekday()，周一返回 1，周日返回 7。因此，这一天是周六。

(9) 你出生 10 000 天的日期是什么时候？

>>> from datetime import timedelta
>>> party_day = my_day + timedelta(days=10000)
>>> party_day
datetime.date(2009, 12, 30)

如果你的生日真的是那天，你可能失去了一个参加聚会的理由（Party 已经过时了）。

E.11　第11章“并发和网络”

(1) 使用原始的 socket 来实现一个获取当前时间的服务。当客户端向服务器发送字符串 time 时，服务器会返回当前日期和时间的 ISO 格式字符串。

下面是实现服务器端的一种方式，udp_time_server.py：

from datetime import datetime
import socket

address = ('localhost', 6789)
max_size = 4096

print('Starting the server at', datetime.now())
print('Waiting for a client to call.')
server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server.bind(address)
while True:
    data, client_addr = server.recvfrom(max_size)
    if data == b'time':
        now = str(datetime.utcnow())
        data = now.encode('utf-8')
        server.sendto(data, client_addr)
        print('Server sent', data)
server.close()

下面是客户端 udp_time_client.py：

import socket
from datetime import datetime
from time import sleep

address     = ('localhost', 6789)
max_size    = 4096

print('Starting the client at', datetime.now())
client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
while True:
    sleep(5)
    client.sendto(b'time', address)
    data, server_addr = client.recvfrom(max_size)
    print('Client read', data)
client.close()

在客户端的循环中加入 sleep(5) 以避免数据交换过于迅速。在一个窗口开启服务器进程：

$ python udp_time_server.py
Starting the server at 2014-06-02 20:28:47.415176
Waiting for a client to call.

在另一个窗口执行客户端：

$ python udp_time_client.py
Starting the client at 2014-06-02 20:28:51.454805

5 秒钟后，你开始得到两者的输出。下面是来自服务器的前三行：

Server sent b'2014-06-03 01:28:56.462565'
Server sent b'2014-06-03 01:29:01.463906'
Server sent b'2014-06-03 01:29:06.465802'

以下是来自客户端的前三行输出 :

Client read b'2014-06-03 01:28:56.462565'
Client read b'2014-06-03 01:29:01.463906'
Client read b'2014-06-03 01:29:06.465802'

这两个程序都会一直运行，需要人工进行终止。

(2) 使用 ZeroMQ 的 REQ 和 REP 套接字实现同样的功能。

这是服务器端程序 zmq_time_server.py：

import zmq
from datetime import datetime

host = '127.0.0.1'
port = 6789
context = zmq.Context()
server = context.socket(zmq.REP)
server.bind("tcp://%s:%s" % (host, port))
print('Server started at', datetime.utcnow())
while True:
    #  等待客户端的下一个请求
    message = server.recv()
    if message == b'time':
        now = datetime.utcnow()
        reply = str(now)
        server.send(bytes(reply, 'utf-8'))
        print('Server sent', reply)

以下是客户端程序 zmq_time_client.py：

import zmq
from datetime import datetime
from time import sleep

host = '127.0.0.1'
port = 6789
context = zmq.Context()
client = context.socket(zmq.REQ)
client.connect("tcp://%s:%s" % (host, port))
print('Client started at', datetime.utcnow())
while True:
    sleep(5)
    request = b'time'
    client.send(request)
    reply = client.recv()
    print("Client received %s" % reply)

对于原始的 socket，你需要首先启动服务器；而使用 ZeroMQ，先启动服务器或者客户端都是可行的。

$ python zmq_time_server.py
Server started at 2014-06-03 01:39:36.933532

$ python zmq_time_client.py
Client started at 2014-06-03 01:39:42.538245

大约 15 秒后，服务器会返回一些行 :

Server sent 2014-06-03 01:39:47.539878
Server sent 2014-06-03 01:39:52.540659
Server sent 2014-06-03 01:39:57.541403

可以在客户端看到：

Client received b'2014-06-03 01:39:47.539878'
Client received b'2014-06-03 01:39:52.540659'
Client received b'2014-06-03 01:39:57.541403'

(3) 使用 XMLRPC 实现同样的功能。

服务器端，xmlrpc_time_server.py：

from xmlrpc.server import SimpleXMLRPCServer

def now():
    from datetime import datetime
    data = str(datetime.utcnow())
    print('Server sent', data)
    return data

server = SimpleXMLRPCServer(("localhost", 6789))
server.register_function(now, "now")
server.serve_forever()

客户端，xmlrpc_time_client.py：

import xmlrpc.client
from time import sleep

proxy = xmlrpc.client.ServerProxy("http://localhost:6789/")
while True:
    sleep(5)
    data = proxy.now()
    print('Client received', data)

启动服务器进程：

$ python xmlrpc_time_server.py

启动客户端进程：

$ python xmlrpc_time_client.py

大约 15 秒后，这是服务器端输出的前三行：

Server sent 2014-06-03 02:14:52.299122
127.0.0.1 - - [02/Jun/2014 21:14:52] "POST / HTTP/1.1" 200 -
Server sent 2014-06-03 02:14:57.304741
127.0.0.1 - - [02/Jun/2014 21:14:57] "POST / HTTP/1.1" 200 -
Server sent 2014-06-03 02:15:02.310377
127.0.0.1 - - [02/Jun/2014 21:15:02] "POST / HTTP/1.1" 200 -

下面是客户端输出的前三行：

Client received 2014-06-03 02:14:52.299122
Client received 2014-06-03 02:14:57.304741
Client received 2014-06-03 02:15:02.310377

(4) 你可能看过那部很老的《我爱露西》（I Love Lucy）电视节目。露西和埃塞尔在一个巧克力工厂里工作（这是传统）。他们落在了运输甜点的传送带后面，所以必须用更快的速度进行处理。写一个程序来模拟这个过程，程序会把不同类型的巧克力添加到一个 Redis 列表中，露西是一个客户端，对列表执行阻塞的弹出操作。她需要 0.5 秒来处理一块巧克力。打印出时间和露西处理的每块巧克力类型以及剩余巧克力的数量。

redis_choc_supply.py 提供初始的工作：

import redis
import random
from time import sleep

conn = redis.Redis()
varieties = ['truffle', 'cherry', 'caramel', 'nougat']
conveyor = 'chocolates'
while True:
    seconds = random.random()
    sleep(seconds)
    piece = random.choice(varieties)
    conn.rpush(conveyor, piece)

露西的过程更像是 redis_lucy.py：

import redis
from datetime import datetime
from time import sleep

conn = redis.Redis()
timeout = 10
conveyor = 'chocolates'
while True:
    sleep(0.5)
    msg = conn.blpop(conveyor, timeout)
    remaining = conn.llen(conveyor)
    if msg:
        piece = msg[1]
        print('Lucy got a', piece, 'at', datetime.utcnow(),
        ', only', remaining, 'left')

任意的顺序打开服务器或者客户端进程，因为露西需要半秒钟处理每一个巧克力，而且平均每隔半秒钟会生产一块巧克力，这是一场追赶着的比赛。开始放入传送带上的巧克力越多，露西的工作难度也越大。

$ python redis_choc_supply.py&

$ python redis_lucy.py
Lucy got a b'nougat' at 2014-06-03 03:15:08.721169 , only 4 left
Lucy got a b'cherry' at 2014-06-03 03:15:09.222816 , only 3 left
Lucy got a b'truffle' at 2014-06-03 03:15:09.723691 , only 5 left
Lucy got a b'truffle' at 2014-06-03 03:15:10.225008 , only 4 left
Lucy got a b'cherry' at 2014-06-03 03:15:10.727107 , only 4 left
Lucy got a b'cherry' at 2014-06-03 03:15:11.228226 , only 5 left
Lucy got a b'cherry' at 2014-06-03 03:15:11.729735 , only 4 left
Lucy got a b'truffle' at 2014-06-03 03:15:12.230894 , only 6 left
Lucy got a b'caramel' at 2014-06-03 03:15:12.732777 , only 7 left
Lucy got a b'cherry' at 2014-06-03 03:15:13.234785 , only 6 left
Lucy got a b'cherry' at 2014-06-03 03:15:13.736103 , only 7 left
Lucy got a b'caramel' at 2014-06-03 03:15:14.238152 , only 9 left
Lucy got a b'cherry' at 2014-06-03 03:15:14.739561 , only 8 left

可怜的露西！！

(5) 使用 ZeroMQ 发布第 7 章练习 (7) 中的诗，每次发布一个单词。写一个 ZeroMQ 客户端来打印出每个以元音开头的单词，再写另一个客户端来打印出所有长度为 5 的单词。忽略标点符号。

下面是服务器 poem_pub.py，把每个单词从诗中拆分出来。如果单词首字母为元音，就发布到主题 vowels 上；如果单词有五个字母，就发布到主题 five 上。一些词可能同时包括在两个主题内，也有一些都没有。

import string
import zmq

host = '127.0.0.1'
port = 6789
ctx = zmq.Context()
pub = ctx.socket(zmq.PUB)
pub.bind('tcp://%s:%s' % (host, port))

with open('mammoth.txt', 'rt') as poem:
    words = poem.read()
for word in words.split():
    word = word.strip(string.punctuation)
    data = word.encode('utf-8')
    if word.startswith(('a','e','i','o','u','A','e','i','o','u')):
        pub.send_multipart([b'vowels', data])
    if len(word) == 5:
        pub.send_multipart([b'five', data])

客户端程序 poem_sub.py，订阅主题 vowels 和 five，并打印输出主题和单词：

import string
import zmq

host = '127.0.0.1'
port = 6789
ctx = zmq.Context()
sub = ctx.socket(zmq.SUB)
sub.connect('tcp://%s:%s' % (host, port))
sub.setsockopt(zmq.SUBSCRIBE, b'vowels')
sub.setsockopt(zmq.SUBSCRIBE, b'five')
while True:
    topic, word = sub.recv_multipart()
    print(topic, word)

如果你开启这些服务并执行代码，它们几乎是不工作的。代码看起来是对的，但是没有做任何事情。首先需要阅读 ZeroMQ 文档（http://zguide.zeromq.org/page:all）了解慢连接（slow joiner）问题：即使是在服务器端之前开启客户端，服务器会立刻发布数据，客户端只有片刻时间连接到服务器。如果你发布持续的数据流，当订阅的客户错过一些是没有关系的。但在本例中，数据流很小导致订阅客户端“眨眼”间流过，就像快球迅速掠过击球手。

最简单的解决办法是在发布者（服务器端）调用 bind() 函数之后和开始发送消息之前休眠一秒。使用这个版本的程序 poem_pub_sleep.py：

import string
import zmq
from time import sleep

host = '127.0.0.1'
port = 6789
ctx = zmq.Context()
pub = ctx.socket(zmq.PUB)
pub.bind('tcp://%s:%s' % (host, port))

sleep(1)

with open('mammoth.txt', 'rt') as poem:
    words = poem.read()
for word in words.split():
    word = word.strip(string.punctuation)
    data = word.encode('utf-8')
    if word.startswith(('a','e','i','o','u','A','e','i','o','u')):
        print('vowels', data)
        pub.send_multipart([b'vowels', data])
    if len(word) == 5:
        print('five', data)
        pub.send_multipart([b'five', data])

开启订阅者进程，然后打开休眠版的发布者进程：

$ python poem_sub.py

$ python poem_pub_sleep.py

现在，订阅者有时间来捕捉这两个主题的消息。以下是它输出的前几行：

b'five' b'queen'
b'vowels' b'of'
b'five' b'Lying'
b'vowels' b'at'
b'vowels' b'ease'
b'vowels' b'evening'
b'five' b'flies'
b'five' b'seize'
b'vowels' b'All'
b'five' b'gaily'
b'five' b'great'
b'vowels' b'admired'

如果你不能在发布者程序中加入 sleep() 函数，可以使用 REQ 和 REPsockets 同步进行发布者和订阅者。在 GitHub（https://github.com/zeromq/pyzmq/tree/master/examples/pubsub）查看实例代码 publisher.py 和 subscriber.py。

附录 F　速查表

我发现我会频繁地查找某些东西。下面列出的表格希望对你有所帮助。

F.1　操作符优先级

下面这张表是官方文档中关于优先级的混合，高优先级的运算符在上面。

操作符

描述和示例

[v1, ...]、{v1, ...}、{k1: v1, ...}、(...)

列表 / 集合 / 字典 / 生成器的创建和推导，括号内表达式

seq[n]、seq[n:m]、func(args...)、obj .attr

索引、切片、函数调用和属性引用

**

幂运算

`+`x、 `-`x、`~`x

正号、负号和位求反

*、/、//、%

乘法、浮点除法、整数除法和取余

+、-

加法、减法

<<、>>

按位左移、按位右移

&

按位与\

|

按位或

in、not in、is、is not、<、<=、>、>=、!=、==

属于关系和相等性测试

not x

布尔取非

and

布尔取与

or

布尔取或

if...else

条件表达式

lambda

lambda 表达式

F.2　字符串方法

Python 不仅提供了字符串方法（不借助 str 对象），而且包含了定义丰富的 string 模块。使用下面的测试变量：

>>> s = "OH, my paws and whiskers!"
>>> t = "I'm late!"

F.2.1　改变大小写

>>> s.capitalize()
'Oh, my paws and whiskers!'
>>> s.lower()
'oh, my paws and whiskers!'
>>> s.swapcase()
'oh, MY PAWS AND WHISKERS!'
>>> s.title()
'Oh, My Paws And Whiskers!'
>>> s.upper()
'OH, MY PAWS AND WHISKERS!'

F.2.2　搜索

>>> s.count('w')
2
>>> s.find('w')
9
>>> s.index('w')
9
>>> s.rfind('w')
16
>>> s.rindex('w')
16
>>> s.startswith('OH')
True

F.2.3　修改

>>> ''.join(s)
'OH, my paws and whiskers!'
>>> ' '.join(s)
'O H ,   m y   p a w s   a n d   w h i s k e r s !'
>>> ' '.join((s, t))
"OH, my paws and whiskers! I'm late!"
>>> s.lstrip('HO')
', my paws and whiskers!'
>>> s.replace('H', 'MG')
'OMG, my paws and whiskers!'
>>> s.rsplit()
['OH,', 'my', 'paws', 'and', 'whiskers!']
>>> s.rsplit(' ', 1)
['OH, my paws and', 'whiskers!']
>>> s.split()
['OH,', 'my', 'paws', 'and', 'whiskers!']
>>> s.split(' ')
['OH,', 'my', 'paws', 'and', 'whiskers!']
>>> s.splitlines()
['OH, my paws and whiskers!']
>>> s.strip()
'OH, my paws and whiskers!'
>>> s.strip('s!')
'OH, my paws and whisker'

F.2.4　格式化

>>> s.center(30)
'  OH, my paws and whiskers!   '
>>> s.expandtabs()
'OH, my paws and whiskers!'
>>> s.ljust(30)
'OH, my paws and whiskers!     '
>>> s.rjust(30)
'     OH, my paws and whiskers!'

F.2.5　字符串类型

>>> s.isalnum()
False
>>> s.isalpha()
False
>>> s.isprintable()
True
>>> s.istitle()
False
>>> s.isupper()
False
>>> s.isdecimal()
False
>>> s.isnumeric()
False

F.3　字符串模块属性

这些是用于常量定义的类属性：

属性

示例

ascii_letters

'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'

ascii_lowercase

'abcdefghijklmnopqrstuvwxyz'

ascii_uppercase

'ABCDEFGHIJKLMNOPQRSTUVWXYZ'

digits

'0123456789'

hexdigits

'0123456789abcdefABCDEF'

octdigits

'01234567'

punctuation

"!"#$%&\'()*+,-./:;<=>?@[\]^_{|}~'

printable

"0123456789abcdefghijklmnopqrstuvwxyz' + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + '!"#$%&\'()*+,-./:;<=>?@[]^_`{|}~' + ' \t\n\r\x0b\x0c'

whitespace

' \t\n\r\x0b\x0c'

作者介绍

Bill Lubanovic 1977 年开始开发 Unix 软件，1981 年开始开发 GUI 软件，1990 年开始开发数据库软件，1993 年开始开发 Web 软件。

1982 年，Bill 在一家名为 Intran 的创业公司为最早的图形工作站之一开发了 MetaForm，这是（在 Mac 和 Windows 之前）最早的商业 GUI 软件之一。20 世纪 90 年代初期，Bill 在 Northwest Airlines 编写了一个图形化的收益管理系统，它带来了数百万美元的收入；Bill 还帮助公司在互联网上打响了名声，并编写了公司的第一个网络营销测试。之后，他在 1994 年和别人共同成立了 ISP 公司（Tela），在 1999 年成立了一家 Web 开发公司（Mad Scheme）。

近几年，他还和一个远程团队一起为曼哈顿的一家创业公司编写核心服务。目前，他正在为一家超级计算机公司集成 OpenStack 服务。

Bill 很享受在明尼苏达州的生活，陪伴他的还有他优秀的妻子 Mary、孩子 Tom 和 Karin 以及猫咪 Inga、Chester 和 Lucy。

封面介绍

本书封面上的动物是一只亚洲巨蟒（网纹蟒）。这种蛇并不像看起来那样吓人：它没有毒性，也很少攻击人类。这种蛇的长度可以达到七米（有时甚至能超过九米），是世界上最长的蛇和爬行动物，不过大多数个体只有三四米长。在拉丁语中，这种蛇的名字形容的是它像网一样的图案和颜色。不同地区网纹蟒的尺寸和颜色差别很大，但是背部都有钻石形状。特殊的外表可以让网纹蟒轻松地融入周围的环境中。

亚洲巨蟒主要分布在东南亚。按照地区可以划分为三个亚种，但是科学界并不认可这种说法。蟒蛇通常生活在雨林、树林、草原和水中，它们的游泳技能十分出众，甚至能游到很远的岛上。蟒蛇主要的食物是哺乳动物和鸟类。

网纹蟒在人工饲养中越来越受欢迎，因为它们的外表很有特点，习性良好。不过在饲养中也会遇到一些问题。有记录表明出现过亚洲巨蟒吃人或者杀人的案例。对于活体蟒蛇的长度测量也非常困难，只能准确测量已经死亡或者麻醉的蟒蛇。人工饲养的蟒蛇大多比野生蛇更胖一些，而且它们通常来说不具有危险性，顶多算是不太稳定。

O'Reilly 封面上出现的许多动物都濒临灭绝，但是它们对地球来说都非常重要。如果想伸出援手，请访问 http://animals.oreilly.com。

封面图片来自 Johnson 的《自然历史》。

看完了

如果您对本书内容有疑问，可发邮件至contact@turingbook.com，会有编辑或作译者协助答疑。也可访问图灵社区，参与本书讨论。

如果是有关电子书的建议或问题，请联系专用客服邮箱：ebook@turingbook.com。

在这里可以找到我们：

微博 @图灵教育 : 好书、活动每日播报

微博 @图灵社区 : 电子书和好文章的消息

微博 @图灵新知 : 图灵教育的科普小组

微信 图灵访谈 : ituring_interview，讲述码农精彩人生

微信 图灵教育 : turingbooks 　　

091507240605ToBeReplacedWithUserId

来源｜网络

版权归原作者所有

未经允许切勿传播或商用

谢谢配合