版本修订记录

这个版本修订记录从版本 64 开始记录是因为这本小书之前的版本都是以 PDF 的形式编写和发布的。从版本 64 开始,这本小书将开始以线上书籍的方式编写和发布,以提高更新速度。

作者首次发布最新发布版本适用Python版本
徐涛2018 年 6 月2022 年 11 月643.10

版本 64

  • 改版以 MDBook 形式编写和发布。
  • 增加 Python 3.10 中新增的match语句介绍。

简介

Python 是一种解释型高级语言,诞生于 20 世纪 90 年代。与 Java、C++等语言不同,Python 在运行时不需要编译,而是采用解释执行的方式运行。Python 在设计上坚持了清晰划一的风格,这让 Python 成为了一门易读、易维护的语言,Python 的设计者秉着对于一个特定的问题,只有一种最好的方法来解决的指导思想,使得 Python 的编程习惯和处理问题的思维方式在团队内部可以达到高度统一,这也是 Python 相比其他语言的一个优势之一。

Python 有两个版本:Python 2 与 Python 3,这两个版本之间不能相互兼容。截止到成稿时 Python 2 的最新版本是 2.7.5,Python 3 的最新版本是 3.11.0。造成这一不兼容现象的原因是 Python 3 在设计之初,就是为了不从 Python 2 带入过多的累赘。

在 Python 3 已经发展到 3.11.0 版的现在,大部分常用的 Python 库都已经完成了对 Python 3 的兼容,所以在系统中可以摒弃 Python 2 版本,专注使用 Python 3 版本完成系统开发了。

Python 作为一门解释型语言,虽然执行速度较 Java、C++等编译型语言要慢,但是其书写简单且功能强大,是编译型语言无法比拟的。自从大数据以及人工智能的兴起,Python 更是因为一些著名的项目框架越发频繁的出现在了人们面前。与 PHP、Javascript 等解释型语言不同,Python 更适合计算密集型的任务,而作为著名的“胶水语言”,Python 可以十分简便的使用 C++、Java 等语言的库,这也更加加强了 Python 本就十分强大的功能。

相对于 PHP 等网页开发语言,Python 不仅能够适用于网站开发,还可以胜任桌面应用开发、自动化脚本、复杂计算系统、科学计算、生命支持管理系统、物联网、游戏、机器人、自然语言处理等多种方面。可以说是一个功能十分全面的多面手。并且自 2018 年 3 月开始,全国计算机等级考试(NCRE)将 Python 列入了 2018 版考试大纲,Python 正式成为程序设计科目可选语言之一;并且在多地的信息技术教材中,都将 Python 列入了进来。这些国家层面的政策都大大增强了 Python 的普及程度,也为这一门古老而常青的语言注入了更多的活力。

如果你所计划开发的项目属于请求密集型的项目,那么可能 Python 并不适合你的需求,对于请求密集型的项目,Javascript 会处理的更加的心应手。但是如果你的项目中充满了统计、分析、数据检索、数据计算这类计算密集型的任务,那么 Python 就再合适不过了。当然,如果你硬要将 Python 应用于请求密集型的任务中,你可能也会发现,在 Nginx 等 HTTP 服务的辅助下,Python 的性能也不会差到哪里去。

本指南面向的读者需要一定的编程基础,并且使用了一些与其他常用语言的对比,所以如果读者有其他语言的使用经验,Python 会更加容易上手。

环境搭建

Python 是一种跨平台的语言,其解释器在 Windows、macOS、Linux、OS/2 甚至 Java 和.Net 虚拟机上都有相应的实现。

这里阐述两种 Python 的安装方式:安装包安装和软件管理器安装。

安装包安装

安装包是最简单的 Python 安装方式,Python 对于大部分常用系统都提供了可以直接下载的安装包供下载,尤其是对于 Linux 系统,也不必从源码自行编译安装了。

Python 安装包可以从Python Download上根据自己的系统选择相应的安装包下载。安装包一般都是可执行的,下载之后只需要运行即可。

对于 Windows 系统,一般 Python 会默认安装到C:\Python3目录下,这个可以不做特别的修改,如果更换到了其他的目录中,需要检查系统环境变量PATH中是否包含了 Python3 可执行文件python.exepythonw.exe所在的目录。

使用软件管理器安装

使用安装包安装 Python 虽然简单,但是在升级 Python 版本时就会显得繁琐,由于不确定是否可以覆盖安装,但又担心完全卸载后安装新版本会导致之前项目中所以来的库丢失,就会出现既纠结又尴尬的局面。

软件管理器的出现,大大方便了 Python 这类软件的安装和更新的操作。而主流操作系统中都有相应的软件管理器可供使用。

Chocolately

Chocolately 是 Windows 系统中的软件管理器,但是它并不是微软开发的,所以并不附在 Windows 发行版中。要使用 Chocolately,需要有一点 PowerShell 的基础,而 Chocolately 的安装也是通过 PowerShell 完成的。

本教程中不再详细叙述 Chocolately 的安装,对于 Chocolately 的安装和使用将简单的一带而过。如果对 Chocolately 不熟悉,可以先按照本教程给出的命令完成 Python 3 的安装。

Chocolately 只能按照官网给定的安装方法安装,这种安装方法实际上是运行官网上的一个 PowerShell 安装脚本。在 PowerShell 下可以通过以下命令来完成 Chocolately 的安装。

# 设定允许执行远程脚本
Set-ExecutionPolicyBypass-ScopeProcess-Force
# 下载并执行安装脚本
iex((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1'))

在完成 Chocolately 的安装之后,可以使用以下命令来完成 Python 的安装:choco install python3。如果需要对通过 Chocolately 安装的 Python 进行升级,则可以执行以下命令:choco upgrade python3

Homebrew

Homebrew 是 macOS 中的软件管理器,同样不附在 macOS 发行版中。要使用 Homebrew,也同样需要在终端中进行安装。在 macOS 中安装 Homebrew 并不是一件复杂的事情,因为 Homebrew 与 Chocolately 一样,也是通过远程安装脚本完成安装的。

Info

实际上,Chocolately是在Homebrew之后出现的。

打开终端后,可以运行以下命令完成 Homebrew 的安装。

/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"

使用 Homebrew 安装 Python3 就简单多了,只需要在终端中执行命令:brew install python3。同样的,升级系统中已经通过 Homebrew 安装的 Python3 也只需要换个命令:brew upgrade python3

apt-get

Linux 系统中的软件管理器都是附在 Linux 发行版中的,不同的发行版所使用的软件管理器都不相同,这需要根据所使用的 Linux 发行版来确定软件管理器的命令。这里以 Debian 和 Ubuntu 中所使用的apt-get命令为例。

安装 Python3 的命令为:sudo apt-get install python3,升级 Python3 的命令为:sudo apt-get update python3

使用 Anaconda 发行版

Anaconda 是一个开源的 Python 发行版本,其中集成了用于科学计算用的大部分库,可以让用户以最快的速度投入 Python 的开发和部署中,是一个十分值得选择的发行版。Anaconda 可以在Anaconda Download下载。Anaconda 基本上都是使用可执行安装文件进行安装,安装完成后会集成 Anaconda 的专有辅助管理工具。

此外,Anaconda 还提供了一个用于 Visual Studio Code 编辑器的插件,可以使 Visual Studio Code 能够对 Python 支持的更好。

Anaconda 的安装和使用十分简单,这里不再赘述。对于 Anaconda 所使用的 Conda 环境管理器,后文将做简要的说明。

基础的基础

在开始 Python 的学习之前,还有一些比语言基础知识更加基础的 Python 常识需要了解。了解这些内容可以使 Python 的编程过程更加顺畅,阅读他人的代码也会更加省时省力。

本教程将主要从项目组织形式、功能库的安装、变量命名习惯等方面来介绍这些基础常识。

变量命名

在编程界对于变量的命名主要分为两大阵营:下划线阵营与驼峰法阵营。Python 诞生于 C/C++称雄的年代,自然继承了众多下划线阵营的习惯。这在阅读 Python 标准库以及普遍使用的库时,经常会看到采用下划线分割的变量及函数名称(snake_case),例如:calculate_the_max_prime_number。但是随着时代的发展,驼峰法命名变量显然更加便捷,于是有相当数量的库开始采用驼峰命名法(camelCase),例如:calculateTheMaxPrimeNumber

这就让 Python 有些尴尬,我们书写的代码中会出现两种命名方法混合使用的情况。但是这并不影响 Python 的使用,我们在自己的代码中只要按照约定规则进行命名即可。

在本教程中,推荐采用 PEP8 约定的命名方法,关于 PEP8 的概要内容,后文将会介绍。

此外,下划线在 Python 中还有特殊的用途:标记特殊变量。后文中会介绍到,Python 中没有私有字段,私有字段是靠约定形成的访问规则,而这个规则则是使用下划线标记字段。而由双下划线包围的变量,例如:__main__,则是 Python 提供的内部变量,用于提供代码访问脚本属性的能力。这些内容都将在后文中一一提到。

PEP

在使用 Python 的时候,经常会看到 PEP 的字样,例如后文提到的 PEP8。PEP 的全称是 Python Enhancement Proposals,即 Python 增强建议书。每一个 PEP 是一份为 Python 社区提供各种增强功能的技术规范,也是提交新特性,精确化技术文档的提案。

PEP 即是新特性提案,也是 Python 中标准化的内容,其中规定了 Python 代码与特性应该遵循的规范和协议。当然,PEP 中也包含了相应的技术文档,如果 Python 中某个库不知道怎样使用,可以参考这个库相应的 PEP 文档。

解释器

Python 既然是一门解释型语言,自然与编译型语言不同。编译型语言需要经过编译器编译成为二进制文件或者字节码文件后才能由计算机执行。而解释型语言一般是由解释器实时将代码转译交由计算机执行的。从这个流程上也可以看出,解释型的语言在运行效率上的确是要比编译型语言慢。但是解释型语言一般都是比编译型语言高一个层级的存在,所以往往拥有更强的扩展能力,并且更加自由,这也是为什么解释型语言更多是以脚本语言的形式出现。

既然是解释型语言,那么 Python 必然具备运行代码所必须的解释器。Python 的解释器是两个可执行文件,Windows 中为 python.exe 和 pythonw.exe,Linux 和 macOS 中都为 python 和 pythonw(可能部分可执行文件带有版本标记)。其中名称为 python 的可执行文件为命令行解释器,也就是通常所说的运行时会出现黑界面的解释器;而 pythonw 则是没有黑界面的解释器。

一般情况下,只有运行 GUI 应用时,才使用 pythonw 解释器;大部分脚本都是使用 python 解释器运行的。

交互式运行

直接在命令行中运行不带有任何参数的 python 命令,可以进入交互式运行模式。在这个模式下,python 可以立刻执行输入的任何语句。

在交互式运行模式下,执行语句exit()可以退出。

命令行脚本

将代码保存在后缀为.py的文件中,使用python file.py的命令运行保存的代码文件,就是最常用的脚本执行方法。

如果运行的是一个完整的项目,那么python命令后指示运行的代码文件,需要是项目的入口文件。

bash 脚本

对于 Linux 以及 macOS 系统来说,bash 脚本是一种特殊的脚本,其可以使用任何解释型语言编写,Python 也不例外。bash 脚本的执行不需要使用python <filename>的格式,而且 bash 脚本的文件名后缀一般为.sh,并且使用chmod +x赋予可执行属性。bash 脚本在终端中可以直接使用./bash.sh的方式执行。

bash 脚本与 Python 源代码文件的区别,就是在文件的最开头,声明了用于解释执行 bash 脚本的解释器。使用 Python 书写 bash 脚本,脚本的首行声明如下:

#!/usr/local/bin/python3

或者还可以使用下面这种标注,下面这种标注会通过环境变量寻找 Python3 解释器的位置。

#!/usr/bin/env python3

其他 Linux 发行版中安装 Python 之后,在书写 bash 脚本之前,需要自行寻找一下解释器所在的位置以及确定一下解释器的版本,以防运行出错。

项目文件组织形式

Python 项目主要通过包和模块来组织其代码,一般可以按照功能对代码进行分组形成模块和包。

既然是一个完整的项目,那么仅有包和模块提供功能是不够的,还需要在项目根目录下提供一个项目入口文件,供解释器加载。

模块

模块是一个包含所有你定义的函数和变量的文件,其后缀名是`.py}。模块可以被别的程序引入以使用该模块中函数等功能。这也是 Python 功能库的组成方法。

模块中除了可以包含方法定义以外,还可以包含可执行的代码,用于在第一次导入时初始化模块使用。每个模块都有独立的符号表,在模块内部为所有的函数当作全局符号来使用,所以在模块内部可以放心大胆的使用这些全局变量。

在阅读其他作者书写的模块时,常常会在模块的结尾看到以下格式的代码段。

if __name__ == '__main__':
    pass

这是利用__name__属性来判断模块是独立运行还是被另一个程序引入执行。具体可以参考以下示例。

if __name__ == '__main__':
    print('程序独立运行')
else:
    print('来自另一个模块')

利用这项功能,可以为模块的不同运行模式进行不同的初始化工作。

包是管理 Python 模块命名空间的形式,采用package.module(点模块名称)的格式。比如一个模块的名称是A.B,则表示包 A 中的子模块 B。

使用点模块名称的好处是不用担心不同库之间的模块重名的情况。

包在文件系统中,就是一个普通的目录,其中需要放置一个__init__.py文件作为包的标记,并且其中可以放置包的初始化代码,在一般情况下,只需要放置一个空白的__init__.py即可。并且包中可以有子包存在,例如下面的包结构。

father_package/
    __init__.py
    father_module.py
    father_module_another.py
    child_package/
        __init__.py
        child_module.py
        child_module_another.py
这里的示例在后文中还会使用。

在导入一个包的时候,实际上是导入了它的__init__.py文件,所以可以在__init__.py中批量导入需要的模块,而不用再次一一导入。此外,__init__.py中还有一个重要的变量:__all__,它接受一个字符串列表作为它的值,凡是出现在其中的内容,都会在使用from ... import *导入时被全部导入,所有没有列入__all__的不会被导入。但是__all__不会影响明确列明要导入内容的模块,例如上例中的import father_package.father_module

文件编码

Python2 是不支持 UTF-8 格式的源代码文件的,所有出现和使用 UTF-8 内容的位置都需要采用转义的方式表示。在 Python3 中这一情况有了极大的改观,因为 Python3 默认采用 UTF-8 作为源代码的编码格式。

如果需要使用其他的编码格式保存源代码文件,需要在源代码文件首行使用以下格式的标记来声明要使用的编码格式。

# -*- coding: gbk -*-

Warning

注意,Python 很计较空格,以上声明标记中的空格一个都不要少。

代码格式

Python 有一套详细而具体的编码规范可供参考和遵循,具体见后文对于 PEP8 的阐述。这里只针对一般常用的基本代码格式做一个介绍。

标识符

第一个字符必须是字母或者下划线,其他字符可以由字母、数字和下划线组成。Python 的标识符是大小写敏感的。

注释

有单行注释和多行注释两种类型。其中单行注释以#开头,而多行注释既可以以#开头,也可以直接使用"""三引号字符串。

缩进

这是 Python 的一大特色。Python 不使用大括号来表示代码块,而是使用缩进来表示代码块。相同的缩进数量表示一个代码块。如果缩进的空格数不一致,并且没有新代码块的引导语句,那么 Python 将会报错。这一点需要在日常的练习和编码中多加体会。

语句 Python 的语句不使用分号等任何符号结尾,每行中建议仅书写一条语句。如果需要书写多条语句,那么语句之间需要使用分号隔开。如果语句过长需要分行书写,可以在每行的末尾使用反斜线进行分行标记。

功能库

Python 的生命力大部分来自其众多的第三方功能库。这些功能库为 Python 提供了丰富而全面的功能。这些功能库可以使用 Python 自带的包管理工具 PIP 来进行管理。已经安装到 Python 的功能库可以直接导入到脚本中使用。

Python 并不提倡「重复制造轮子」的行为,如果有可以使用的第三方功能库,建议在项目中优先使用,自行编写功能库和「造轮子」是最后的选择。

PIP

PIP 是一个现代的通用的 Python 包管理工具,现在会随着 Python 的发布版本自动安装,提供了对 Python 包的查找、下载、安装和卸载等功能。PIP 在命令行中运行,常用的指令有以下这些。

  • pip install SomePackage,安装指定的包。
  • pip show SomePackage,查看相关包的信息。
  • pip list}`,查看已经安装的包。
  • pip list --outdated,列出目前存在更新的包。
  • pip install --upgrade SomePackage,更新指定的包。
  • pip search keyword,搜索包。
  • pip uninstall SomePackage,卸载指定的包。

在 Python2 和 Python3 共存的机器上,可以通过python3 -m pip <参数>的命令格式来执行 Python2 或者 Python3 中携带的 PIP。

如果 PIP 安装功能库缓慢,可以使用以下指令来设置 PIP 使用清华大学的安装源。

pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple

或者参考后文中的 pipenv 中提到的其他 Pypi 的源。

Conda

Conda 是一个随着 Anaconda 发行版发布的环境管理工具,与 PIP 一样提供了 Python 包的查找、下载、安装等功能,但是不同的是,Conda 的功能主要是偏向于环境管理功能,所以其可以直接在系统中创建和管理虚拟 Python 环境。不同的虚拟 Python 环境可以共存,并通过 Conda 进行激活和切换。Conda 常用的命令有以下这些。

  • conda create --name 环境名称 [库名称=版本,...],创建一个带有指定 Python 版本和库的环境。例如conda create --name py35 python=3.5}`将会创建一个使用 Python 3.5 版本解释器的虚拟环境。
  • conda env remove --name 环境名称,删除指定虚拟环境。
  • activate 环境名称,激活指定虚拟环境,macOS 和 Linux 使用source activate 环境名称
  • deactivate,关闭当前虚拟环境,macOS 和 Linux 使用source deactivate
  • conda install 库名称[=版本],向当前环境中安装指定库,并可以指定库版本。
  • conda remove 库名称,从当前环境中移除指定库,注意与删除环境的区别
  • conda update 库名称,升级当前环境中的指定库,可以用于升级 Conda 自身。
  • conda search 库名称,搜索指定库。
  • conda list,列出当前环境中安装的全部库。
  • conda env list,列出当前已经创建的环境。
  • conda env export > 文件名,将当前环境的配置导出到 YAML 文件中以方便共享。
  • conda env create -f 文件名,从导出的 YAML 文件中创建虚拟环境。

Conda 的命令和功能远不止以上这些,具体其他的命令和功能可以在使用时查询文档。

Warning

在日常使用中,需要注意 Conda 和 PIP 的功能区别,尤其是后文提到的使用virtualenvvenv库建立的虚拟环境与 Conda 建立的虚拟环境的区别。Conda 建立的虚拟环境是系统级的,用于支撑整个系统的,其级别层次要比 virtualenv 等库建立的虚拟环境层次要更加低一些。在开发中可以灵活搭配这两种虚拟环境来使用,而不是仅拘泥于使用其中哪一种。

在 macOS 和 Linux 中,Conda 可以直接在终端环境中使用。在 Windows 系统中需要在 Anaconda 安装的「Anaconda Prompt」程序中使用,当然如果你喜欢在命令行中使用也是可以的。

Anaconda 默认的软件包源中,可能包含的内容不足,并且访问速度较慢,这时我们可以使用以下命令来让 Conda 使用清华大学的源。

conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/
conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main/
conda config --set show_channel_urls yes

# 以下是清华大学维护的第三方源

conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/
conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/msys2/
conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/bioconda/
conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/menpo/
conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/pytorch/
conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/peterjc123/

加载到源文件

要使用功能库与模块,需要在另一个 Python 源文件中执行import语句,当解释器遇到 import 语句时,就会在当前的全部搜索路径中进行搜索并导入。解释器会优先搜索当前项目的路径,之后是所有库目录的列表。Python 的搜索路径保存在 sys 模块的 path 变量中,可以在交互式解释器中查看。

一个模块只会被导入一次,这可以防止模块被一遍又一遍的执行以及递归导入的出现。

import module

这是最普通的导入语句了。它会将目标模块内容导入到当前脚本中,但是如果需要访问其中的内容,需要使用\lstinline|module.function|的全名模式,因为其不会对当前的命名空间作出任何修改,只是加载了一个新的命名空间供使用而已。例如:

import sys
print(sys.path)

Warning

注意,这里所提到的所有 module,都是指点模块名称,即package.module的全名格式。

from module import ...

import module不同,这条导入语句可以将指定模块的一个指定部分导入到当前的命名空间中。这里用一个示例来说明。

假如有这样一个模块:

# fibo.py

def fib(n):
    a, b = 0, 1
    while b < n:
        print(b, end=' ')
        a, b = b, a+b
        print()

使用import module的格式来使用这个函数的方法如下:

import fibo

fibo.fib(1000)

但是如果使用from module import ...的格式来使用这个函数的方法则是:

from fibo import fib

fib(1000)

仔细对比函数的调用,就会理解from module import ...是如何工作的。

from module import *

这个引入操作更加容易理解,它将把指定模块中的全部内容都导入到当前的命名空间中。这个导入语句并不建议使用,因为这种导入非常容易造成命名空间中成员重名的情况。

前面提到了,模块可以使用包来组织,那么对于之前的示例使用from father_package import *会出现什么情况?如果在__init__.py中没有定义任何内容,这条引入语句是不会引入任何内容的。这是因为在包是依赖文件系统来组织模块的,Python 不能保证每个平台(尤其是 Windows)上模块的名称都是唯一的,比如 Windows 是不区分大小写的,DOS 对大于 8 个字符的文件名称有特殊处理,Linux 是区分大小写的。

这就需要在__init__.py中使用__all__来定义一个精确的列表,用来指示哪些模块是可以被导入的。当然,如果不使用from package import *的方式引入库,也是不必要去定义__init__.py的内容的。

Pythonic

只要使用 Python,总会接触到 Pythonic 这个词语。但是 Pythonic 又是一个非常难于定义的东西。一般说来,Pythonic 是指那些极具 Python 特色的 Python 代码,这些代码会明显区别于其他语言的写法。

这里先引用《Python 之禅》中的经典阐释:

  • 优美胜于丑陋;
  • 明了胜于晦涩;
  • 简洁胜于复杂;
  • 复杂胜于凌乱;
  • 扁平胜于嵌套;
  • 间隔胜于紧凑;
  • 可读性很重要。

Pythonic 追求的是对 Python 语法的充分发挥,而不是与其他语言相似。如何让代码书写的更加 Pythonic 需要在日常不断的进行积累,并且要对 Python 的语法深入了解,还要有一定的想象力。

后文会在可以 Pythonic 的位置进行抛砖引玉的示例说明。

PEP8:Python 编码规范

代码编排

  1. 缩进采用 4 个空格,不使用 Tab 进行缩进,不能混合使用 Tab 和空格,可以利用 IDE 将 Tab 转换为 4 个空格。
  2. 每行最大长度为 79 字符,包含缩进空格。需要换行可以使用\,最好使用圆括号,圆括号内的内容换行不需要反斜线。换行回车要在操作符后面出现,即操作符不能在一行中打头。
  3. 类和 top-level 函数定义、全局变量定义之间空两行书写;类中方法定义之间空一行书写;函数内逻辑无关段落之间空一行;其他位置尽量不要空行。

文档编排

  1. 模块中内容的顺序为:
    1. 模块说明和 docstring;
    2. import,库的引用按照标准库、第三方库和自编库的顺序依次排放,三者之间空一行;
    3. 全局变量(globals)与常量(constants);
    4. 其他定义。
  2. 不要在一条 import 中引用多个库。
  3. 如果采用from package.module import xx方式引用库,可以省略 module;如果产生命名冲突需要明确采用import module

例如:

"""This is the example.

The docstring stuff
"""
from __future__ import barry_as_FLUFL

__all__ = ['a', 'b', 'c']
__version__ = '0.1'
__author__ = 'FarDawn'

import os
import sys

空格的使用

  1. 所有右括号前不要加空格。
  2. 逗号、冒号、分号前不要加空格。
  3. 函数的左括号前不要加空格。
  4. 序列的左括号前不要加空格。
  5. 操作符的左右各加一个空格,不要为了对齐添加额外的空格。
  6. 函数默认参数使用赋值符左右不添加空格。
  7. 不要在一行内写入多条语句。
  8. if/for/while 语句中,即便语句块中只有一条语句,也必须另起一行书写。

注释

  1. 在代码块前增加的注释,在#后增加一个空格。
  2. 尽量避免采用在语句后增加注释的行注释方式。
  3. 避免无谓的注释。

文档描述

  1. 所有的共有模块、函数、类、方法都应该附加 docstring;非共有的内容可不加。
  2. 如果 docstring 需要换行,可以使用三引号括起的方式。

命名规范

  1. 尽量单独使用小写字母i、大写字母O等易混淆字母。
  2. 模块命名尽量短小,使用全部小写的方式,可以使用下划线。
  3. 包命名尽量短小,使用全部小写的方式,不可以使用下划线。
  4. 类的命名使用CapitalizeWord格式,模块的内部类采用_CapitalizeWord格式。
  5. 异常类命名采用CapitalizeWordError格式。
  6. 全局变量尽量只在模块内有效,类似 C 语言中的 static;可以采用__all__或者前缀一条下划线的方式实现。
  7. 函数命名使用全部小写的方式,可以使用下划线。
  8. 常量命名使用全部大写的方式,可以使用下划线。
  9. 类的属性(包括方法和变量)命名使用全部小写的方式,可以使用下划线。
  10. 类的属性有 3 种作用域:public、non-public 和 subclass API,可以理解为 C++ 中的 publicprivateprotected,在 non-public 属性前,需要前缀一条下划线。
  11. 类的属性如果与关键字名称冲突,可后缀一条下划线,不要使用缩略等其他方式。
  12. 为避免与子类属性命名冲突,在类的一些属性前,前缀两条下划线。
  13. 类的方法第一个参数必须是 self(相当于 C++中的 this),静态方法第一个参数必须是 cls(代表类)。

编码建议

  1. 代码应该兼顾 Python 的不同实现,如 CPython 中,+操作符拼合字符串的性能很好,但 Jython 中不是这样,所以应该采用''.join()来兼顾不同的 Python 实现。
  2. 与单例内容进行比较时,应该采用is或者is not,例如:if x is not None
  3. is not|操作顺序要比not … is`更加直观。
  4. 在进行排序和比较时,优先选择实现用于比较的六个操作:__eq____ne____lt____le____gt____ge__
  5. 不要用 Lambda 表达式来定义函数或者方法,始终使用def
  6. 使用基于类的异常,每个模块和包都要有自己的继承自 Exception 的异常类。
  7. 异常中不要使用裸露的 exceptexcept 后要跟具体的 Exception。
  8. 异常中 try 的代码要尽可能的少。
  9. 字符串不要使用空格结尾。
  10. 方法中的所有 return 语句都要有返回值,如果确实不返回内容,要返回 None
  11. 尽量使用语义明确的方法替代切片操作。
  12. 不要使用==对布尔值进行比较操作。

语言基础

前文说的都是一些基础中的基础,这里要开始阐述的是 Python 语言的基本元素:运算符、数据类型以及基础语句。

运算符

运算符是组成表达式的基本要素,是将标识符连接起来进行运算的主要方式。Python 中的运算符主要有算术运算符、比较运算符、赋值运算符、逻辑运算符、位运算符、成员运算符和身份运算符几种。

操作符一般有两个操作数,分别称为左操作数和右操作数,个别的操作符仅能接受一个操作数。

下面将依次进行介绍。

算术运算符

算术运算符是最基本的运算符,用于各种数学计算。

操作符功能
+两个操作数相加,或者是拼合两个字符串
-两个操作数相减
*两个操作数相乘,或者是返回一个被重复若干次的字符串
/两个操作数相除,始终返回浮点值
%取余,返回除法的余数
**幂,如 x ** y 相当于\( x^y \)
//整除,仅返回商的整数部分

比较运算符

比较运算符用于两个操作数之间的比较,比较运算符会返回关键字True或者False。实际上所有比较运算符只会返回整型数值,其中 0 对应关键字False,即假值;1 对应关键字True,即真值。

操作符功能
==比较两个操作数是否相等
!=比较两个操作数是否不相等
>大于
<小于
>=大于等于
<=小于等于

赋值运算符

Python 中的变量不需要声明,但是每个变量在使用前都必须赋值,变量赋值后该变量才会被创建。Python 中的变量没有类型,一般所说的类型是指变量所指向的内存中对象的类型。赋值运算符还可以与算术运算符组合形成快速赋值运算符。

Python 中的变量有一个非常重要的特性,就是它仅仅是一个名称,是对对象的引用,而不是存储对象本身。

运算符功能
=基础赋值
+=加法赋值,c += a相当于c = c + a
-=减法赋值,c -= a相当于c = c - a
*=乘法赋值,c _= a相当于c = c * a
/=除法赋值,c /= a相当于c = c / a
%=取余赋值,c %= a相当于c = c % a
**=幂赋值,c **= a相当于c = c ** a
//=整除赋值,c //= a相当于c = c // a

Python 允许同时为多个变量赋值,例如:a = b = c = 1,这样三个变量都将指向同一个内存位置。还允许同时为多个对象指定多个变量,例如:a, b, c = 1, 2, "Hello",这样变量 ab 将分别被分配给 12,而字符串对象"Hello"将分配给 c

对于赋值,Python 还提供了一个快速交换两个变量内容的方式,即a, b = b, a,这可以让两个变量直接交换其指向的位置,从而达到交换变量值的目的。

位运算符

位运算符是将数字作为二进制来进行计算的。位运算在一些协议处理和低级别信息处理上十分有用。

操作符功能
&按位与,参与运算的两个值,相应的位为 1,则结果为 1,否则为 0
|按位或,参与运算的两个值,相应的位有一个为 1,则结果为 1, 否则为 0
^按位异或,参与运算的两个值,相应的位不一致时,结果为 1,否则为 0
~按位反,对操作数的每个二进制位取反,1 变为 0,0 变为 1
<<左移,将左操作数的各二进制位向左移动若干位,移动位数由右操作数的值决定,移动后,高位丢弃,低位补 0
>>右移,将左操作数的各二进制位向右移动若干位,移动位数由右操作数的值决定,移动后,低位丢弃,高位补 0

逻辑运算符

逻辑运算符与比较运算符类似,也是返回True或者False的值,但是这里需要注意的是,部分操作符还可能返回右操作数的值。

操作符功能
and布尔与,如果左操作数为False,则返回False,否则返回右操作数的计算值
or布尔或,如果左操作数为True,则返回左操作数的值,否则返回右操作数的计算值
not布尔非,如果 🈶️ 操作数为True,则返回False,反之亦然

对于or操作符,可以用来获取默认值,参考以下示例:

a = 40
print(a or 30) # 输出 40
a = 0
print(a or 30) # 输出 30
a = None
print(a or 30) # 输出 30

成员操作符

成员操作符用来测试实例中是否包含指定的成员,成员操作符可以应用于字符串、列表和元组中。

运算符功能
in如果在右操作数中找到了左操作数,则返回True
not in如果在右操作数中找到了左操作数,则返回False

身份操作符

身份操作符用于比较两个对象的存储单元。对于一个对象,可以使用id()函数来获取其内存地址,身份操作符主要是用于判断两个操作数是否引用了同一个地址的对象。

操作符功能
is判断两个操作数是否引用了同一个对象,是同一个对象则返回True
is not判断两个操作数是否引用了不同的对象,引用的对象不同则返回True

is==的区别在于,==是用于判断引用的对象的值是否相等,而is则是判断引用的对象的内存地址是否相等。

Warning

始终要记得,Python 的变量没有类型,其完全是指向或者称为引用自内存的指定地址。

运算符优先级

当不同的运算符组合在一起时,不同的运算符会有一个优先级的区别,相同优先级的运算符遵循自左至右的计算,而拥有高优先级的运算符则会优先计算结果。这与日常先乘除后加减的数学计算规则相似。

下表按照由高至低的顺序列出了全部运算符的优先级。

操作符功能
**指数
~+-按位反,正负号
*/%//乘、除、取余、整除
+-加、减
>><<右移、左移
&按位与
^|按位异或、按位或
<=<>>=比较运算符
==!=等于运算符
=%=/=//=-=+=*=**=赋值运算符
isis not身份运算符
innot in成员运算符
andornot逻辑运算符

基础数据类型

Python3 中有七个标准数据类型,有如前面说过的这个数据类型并不是变量的数据类型,而是内存中存储的数据的类型,即变量指向的内存空间的类型。

这七个数据类型分别为:

  • Bool,布尔
  • Number,数字
  • String,字符串
  • List,列表
  • Tuple,元组
  • Sets,集合
  • Dictionary,字典

这七个数据类型中,数字、字符串、元组和集合是不可变数据,列表和字典是可变数据。不可变数据在每次内容发生更改时,Python 都会将其复制一份后再进行修改。

Python 所有的数据,不仅这七种基础数据类型,甚至包括其他复杂数据结构、函数及程序,都是以对象的形式存在的。这使得 Python 从根基上就是一种面向对象的语言。并且 Python 是强类型语言,已有对象的类型是无法修改的。要获取一个对象或者一个字面值的类型,可以使用type()函数。

布尔型

布尔型值其实非常简单,只表示真与假,由两个关键字代表它的值,分别是TrueFalse。布尔型值也是条件表达式的运算结果。

由于历史原因,布尔型值可以作为数值类型进行运算,其中True的值为 1,False的值为 0。如果将数值类型直接用于条件判断,则零值为False,非零数值为True

Warning

注意,一般并不鼓励直接使用数值类型用于条件判断,因为计算机存储浮点值是不精确的,所以在对0.0进行判断时,很有可能会因为计算机存储的值为0.0000001而出现零值对应True的情况。

数字

Python 支持三种数值类型:

  • 整型(int),可以是正负整数,不带有小数点。Python3 的整数没有大小限制,可以当作 Long 类型使用。
  • 浮点型(float),由整数和小数部分组成,浮点型也常用科学计数法表示。
  • 复数(complex),由实数和虚数部分组成可以使用a+bj或者complex(a, b)表示。

整型数值还可以使用十六进制、八进制和二进制来表示,其中前缀0x表示十六进制数值,0o表示八进制,0b表示二进制。反过来,可以使用函数hex()oct()bin()来分别获得整型数值的十六进制、八进制和二进制表示。

在很多时候我们经常需要进行数字类型转换,数据类型转换只需要将数据类型作为函数名即可,例如:int(x)。复数类型转换可以接受两个参数,分别作为复数的实数部分和虚数部分。

之前运算符中的整除运算符://,在进行计算时需要注意,如果左右操作数都是整型,那么其将会返回整型结果,如果有一个操作数是浮点型,那么就会返回浮点型结果。

浮点型数值是不精确数值,其在进行计算的时候也是不精确的,所以在使用时需要严加注意。Python 提供了多种可供精确计算带小数点的数值的库或者类,后文将拣选部分予以介绍。

分数计算

模块fractions提供了一个名为Fraction的类,用以支持分数类型及计算。Fraction 类有以下两个构造函数:

class fractions.Fraction(numerator=0, denominator=1) # numerator 为分子,denominator 为分母
class fractions.Fraction(int|float|str|Decimal|Fraction)

使用分子和分母实例化分数类时,两者需要均为 int 类型或者 numbers.Rational 类型,分母为 0 会抛出ZeroDivisionError的异常。

分数类型可以直接使用运算操作符进行计算,返回结果也为 Fraction 类型实例,但是 print()的数值则是约分化简后的字符串分数形式。

from fractions import Fraction

x = Fraction(2, 10)
y = Fraction(3, 5)
z = x + y # z 的值为 Fraction(4, 5)

print(z) # 会输出"4/5"

精确小数计算

模块decimal提供了一个Decimal数据类型用于精确小数计算,并且可以指定其计算精度。其具体使用可参考以下示例:

import decimal

decimal.getcontext().prec = 4 # 设定全局计算精度为 4 位小数

print(decimal.Decimal(1) / decimal.Decimal(7)) # 会输出"0.1429"

with decimal.loacalcontext() as ctx:
ctx.prec = 2 # 设定当前上下文计算精度为 2 位小数
print(decimal.Decimal('1.00') / decimal.Decimal('3.00')) # 输出"0.33"

数学计算

Python3 中的数学计算主要是由math模块提供的,该模块中提供了以下函数供使用。

函数功能返回值类型
acos(x)返回 x 的反余弦弧度值浮点型
asin(x)返回 x 的反正弦弧度值浮点型
atan(x)返回 x 的反正切弧度值浮点型
atan2(y, x)返回给定的 x 及 y 坐标值的反正切值浮点型
ceil(x)返回数字的进位整数整型
cos(x)返回 x 弧度的余弦值浮点型
degrees(x)将弧度转换为角度浮点型
exp(x)返回\( e^x \)浮点型
fabs(x)返回 x 的绝对值浮点型
floor(x)返回 x 的舍位整数整型
gcd(x, y)返回 x 与 y 的最大公约数整型
hypot(x, y)返回欧几里得范数,\( \sqrt{x^2+y^2} \)浮点型
log(x)返回\( ln\ x \)浮点型
log(x, y)返回\( log_{y}\ x \)浮点型
log10(x)返回\( log_{10}\ x \)浮点型
modf(x)返回 x 的整数与小数部分元组
pow(x, y)返回\( x^y \)浮点型
radians(x)将角度 x 转换为弧度浮点型
sin(x)返回 x 弧度的正弦值浮点型
sqrt(x)返回\( \sqrt{x} \)浮点型
tan(x)返回 x 弧度的余切值浮点型

随机数生成

随机数生成功能是由random模块提供的,该模块提供了以下常用函数供使用。

函数功能返回值类型
choice(seq)从序列中随机挑选一个元素由序列内容决定
randrange([start], stop, [step])从指定范围内,按照指定基数递增的集合中获取一个随机数,基数默认为 1由序列内容决定
random()随机生成一个\( [0, 1) \)范围内的实数浮点型
seed([x])改变随机数发生器种子
shuffle(lst)将序列中的元素随机排序
uniform(x, y)随机生成一个\( [x, y] \)范围内的实数

浮点数取整

round()函数是 Python 标准库提供的功能,不需要加载任何模块即可使用。该函数使用的进舍准则与日常的“四舍五入”有所不同,其进舍的处理方法采用统计学标准,使得进舍后数值变化对系统误差影响降低到最低。

round()接受两个参数,一个为待处理的数字,一个为小数保留位数,其中小数保留位数默认为 0。在进行取整时,round()会遵循以下规则。

  1. 要求保留位数的后一位如果是 4 或者 4 以下的数字,则舍去,例如 4.124 保留两位小数为 4.12。
  2. 要求保留位数的后一位如果是 6 或者 6 以上的数字,则进位,例如 4.126 保留两位小数为 4.13。
  3. 如果保留位数的后一位是 5,并且该位数后有数字,哪怕是 0.00000000001 之类的,则进位,例如 4.1250001,保留两位小数为 4.13。
  4. 如果保留位数的后一位是 5,且该位后没有数字,则根据保留位采用“奇进偶舍”的规则进行进舍,例如 3.35 保留一位小数为 3.4。

Python 的进舍规则是遵照《GB/T8170-2008 数值修约规则与极限数值的表示和判定》来进行处理的,其中对于数值修约规则,可根据以下口诀来记忆。

四舍六入五考虑,
五后非零都进一,
五后皆零视奇偶,
五前为偶应舍去,
五前为奇则进一,
不论数字多少位,
都要一次修约成。

字符串

Python 中的字符串可以使用单引号或者双引号来创建,使用单引号和双引号创建的字符串没有区别。Python 中没有单字符类型,即便是一个字符,也是作为一个字符串使用。字符串支持使用切片来截取其子字符串,切片的使用方法可见“切片”一节。

Python 中的字符串是不可变数据,每次在进行运算时,都会产生一个新的字符串对象,如果使用id()函数去获取他们的内存地址就会发现变量的指向地址始终在变化。

字符串支持下表中的运算符进行运算。

操作符功能
+连接字符串
\*重复输出字符串
[]通过索引获取字符串中的字符
[:]通过切片获取字符串中的一部分
in成员运算符,测试字符串中是否包含指定字符
not in成员运算符,测试字符串中是否不包含指定字符
r原始字符串标记,例如r"Hello\n"将原封不动的输出,不转换任何转义字符
b字节标记,将str变为以字节为单位的bytes
uUTF-8 字符串标记,用于标记字符串内容为 UTF-8 编码,例如u"你好",Python3 中所有的字符串都是 UTF-8 的,这个标记已经不再需要了。
f格式化字符串标记,允许将变量及表达式等直接插入字符串形成字符串常量。
%格式化输出字符串

由于 Python3 的字符串类型为str,在内存中以 Unicode 表示,一个字符对应若干个字节,所以要在网络上传输或者保存到磁盘,就需要将其转换为以字节为单位的bytes,这可以使用b在字符串前进行标记,例如:b'ABC'。中文内容不能用 ASCII 编码表示,但是可以将含有中文的str编码为bytes

Python 中还允许使用三引号"""来包裹字符串,三引号定义的字符串可以跨多行,并且可以包含换行、制表等特殊字符。三引号字符串可以自行保持字符串的原有格式,每行的换行符以及行首、行尾的空格都会保留,使日常编程不再需要字符串组合与转义等。

使用str()可以将其他数据类型转换为字符串。在使用print()或者字符串差值是,Python 都会自动使用str()对非字符串对象进行转换。

在字符串名后面添加[],并在括号中指定索引,可以提取指定索引位置的单个字符,字符串的索引值从 0 开始。索引值可以是负数,这代表从字符串尾端开始。如果对指定索引位置进行赋值,则会得到TypeError错误,因为字符串是不可变的。如果需要改变字符串,需要使用字符串函数或者切片来完成,这些将在后面具体提到。

字符串格式化

Python 中的字符串格式化使用与 C 语言中printf()一样的语法,其基本思路是将一个值或者数个值插入到一个有字符串格式描述符的字符串中。

例如:

print("这是来自%d 光年外%s 的信息。" % (3000, "St. Alpha"))

# 会输出“这是来自 3000 光年外 St. Alpha 的信息。”

字符串格式化通过一系列的格式化符号对要替换的内容进行标记,常用的格式化符号见下表。

符号功能
%c输出字符及其 ASCII 码
%s输出字符串
%d输出整数
%u输出无符号整数
%o输出无符号八进制整数
%x输出无符号小写十六进制整数
%X输出无符号大写十六进制整数
%f输出浮点数,可搭配辅助指令指定小数精度
%e%E使用科学计数法输出浮点数
%g%G根据浮点数长度自动选择自然计数法或者科学计数法输出浮点数
%p用十六进制输出变量的地址

格式化符号可以接受一些辅助指令的来对输出内容的格式进行详细规定。常用的辅助指令见下表。

符号功能示例
*接受一个整型值,定义显示宽度,可以直接书写整型值'%*s' % (10, 'Willie')
-左对齐'%-*s' % (10, 'Willie')
+强制显示正负号'%+d' % 10
空格在正数前显示空格'% d' % 10
#显示八进制、十六进制前的引导标记(0o}或者0x})'%#x' % 90
0在数字前填充 0 而不是空格'%0*d' % (8, 10)
%输出一个单一的%
(var)从字典参数中获取相应键值的值'%(a)d' % {'a':90}
m.n.m 为显示的最小总宽度(可省略),n 是小数点后的位数'%10.5f' % 19.093

自从 Python 2.6 版本开始,字符串提供了str.format()函数来进行增强的字符串格式化功能,调用格式为"模版字符串".format(逗号分割的参数)。调用format()方法后会返回一个全新的字符串。模版字符串中使用{}来代表参数,称为格式槽,参数从 0 开始索引,例如:"进程{}的 CPU 占用率为{}%。".format("Python", 10)会输出"进程 Python 的 CPU 占用率为 10%。"。如果需要引用指定索引的参数,可以在{}中书写指定参数的索引,例如例如:"进程{1}的 CPU 占用率为{0}%。".format(10, "Python")会输出"进程 Python 的 CPU 占用率为 10%。"。若要输出大括号,可以使用{{表示{,用}}表示}

format()方法中同样可以控制参数的格式,其中格式槽的格式为{<参数序号>:<填充><对齐><宽度><,><.精度><类别>}。各个格式位的使用方法可参考下表。

:<填充><对齐><宽度><,><.精度><类别>
用于填充的单个字符<,左对齐槽的设定输出宽度数字千分位分隔符浮点数小数点部分的精度b,二进制整型
>,右对齐字符串的最大输出长度c,Unicode 字符
^,居中d,十进制整数
o,八进制整数
X,大写十六进制整数
E,大写指数形式浮点数
%,百分比形式浮点数

例如"{0:e},{0:0.4f}".format(3.14),读者可自行在交互式解释器中实验以下这条语句的运行结果。

自 Python 3.6 版本开始,Python 基金会通过 PEP 498 – Literal String Interpolation 引入了格式化字符常量来产生一个格式化后的字符串,因为这种用法是采用f""的字符串标记形式,所以又称为“f-string”。f-string 的功能与之前介绍的使用“%”进行格式化和使用str.format()进行格式化的功能基本相同,但具有更好的性能,所以如果要使用 Python 3.6 以后的版本,推荐使用 f-string 来进行字符串格式化。

f-string 在字符串中使用{}来直接填入替换内容,并且其中支持表达式和函数调用。最简单的使用方法可见一下示例。

number = 7
print(f"Total number is {number}")

print(f"Calculate result is {(2 + 4j) / (2 - 3j)}")

name = "ERIC"
print(f"The lowercase version is {name.lower()}")

f-string 在使用时也是有一些限制要求的,主要集中在符号的使用上,限制条件主要有以下几个。

  1. 大括号内所使用的引号不能与大括号外的字符串定界符冲突,例如|f“{“ERIC”}“|是错误的。
  2. 如果单引号和双引号不能满足要求,可以使用三引号来定义字符串,例如|f““”{“Eric”}“”“|。
  3. 大括号外的引号可以使用||进行转义,但大括号内不可以。
  4. 如果在大括号外要显示大括号,需要使用|{{|和|}}|的形式。

f-string 也支持格式描述,其使用格式为{内容:格式},例如f"{number:8.2f}"。f-string 的格式描述符格式与str.format()基本相同,但是其更扩展支持了 datetime 库中用于格式化时间的描述符,具体内容可在使用时参考 datetime 库文档。

Warning

在 Python 3.8 中为 f-string 增加了一个=说明符。f"{expr=}"将被输出为赋值表达式的文本。

内建函数

对于字符串操作,Python 提供了以下常用函数。

  • capitalize(),将字符串第一个字符转换位大写。
  • center(width, fillchar),返回一个指定宽度为 width 并居中的字符串,字符串两端使用 fillchar 填充,默认使用空格。
  • count(str, begin=0, end=len(string)),返回 str 在字符串中出现的次数,其中 begin 和 end 用于指定进行统计的字符串范围。
  • bytes.decode(encoding='utf-8', errors='strict'),Python3 中的字符串没有 decode 方法,所以需要使用bytes模块中的decode()方法来解码 bytes 对象。
  • encode(encoding='UF-8', errors='strict'),以指定编码格式编码字符串,如果出错则报ValueError的异常,除非errors的值为'ignore'或者'replace'
  • endswith(suffix, begin=0, end=len(string)),检查字符串是否以suffix结束,如果指定beginend的值,则检查指定范围。返回布尔型值。
  • expandtabs(tabsize=8),将字符串中的 tab 符号转换为空格,默认一个 tab 转换为 8 个空格。
  • find(str, begin=0, end=len(string)),检查str是否包含在字符串中,如果指定beginend的值,则检查指定范围。返回布尔型值。
  • index(str, begin=0, end=len(string)),获取str在字符串中的索引位置,如果字符串中不存在str则报异常。
  • isalnum(),如果字符串至少有一个字符且全部都是字符或者数字,返回True
  • isaloha(),如果字符串至少有一个字符且全部都是字符,返回True
  • isdigit(),如果字符串至少有一个字符且全部都是数字,返回True
  • islower(),如果字符串中包含至少一个区分大小写的字符,且全部都是小写,返回True
  • isnumeric(),如果字符串只包含数字字符,返回True
  • isspace(),如果字符串只包含空白,返回True
  • istitle(),如果字符串是标题化的,返回True
  • isupper(),如果字符串中包含至少一个区分大小写的字符,且全部都是大写,返回True
  • join(seq),以指定字符串为分隔符,将seq中的所有元素合并为一个新的字符串。
  • len(string),返回字符串长度。
  • ljust(width[, fillchar]),返回一个宽度为width的字符串,原字符串在新字符串中左对齐,右侧用fillchar填充。
  • lower(),将所有字符转为小写。
  • lstrip(),截掉字符串左边的空格或者指定字符。
  • maketrans(),创建字符映射的转换表。
  • max(str),返回字符串str中最大的字母。
  • min(str),返回字符串str中最小的字母。
  • replace(old, new[, max]),将字符串中的old替换成new,替换不超过max次。
  • rfind(str, begin=0, end=len(string)),从右侧查找。
  • rindex(str, begin=0, end=len(string)),从右侧获取索引。
  • rjust(width[, fillchar]),返回一个宽度为width的字符串,原字符串在新字符串中右对齐,左侧用fillchar填充。
  • rstrip(),删除字符串末尾的空格。
  • split(str='', num=string.count(str)),以str为分隔符截取字符串,截取num个子字符串。
  • splitlines([keepends]),按照'\r''\r\n''\n'为分割,返回包含各行作为元素的列表。如果keependsFalse则不包含换行符。
  • startswith(prefix, begin=0, end=len(string)),检查字符串是否以prefix结束,如果指定beginend的值,则检查指定范围。返回布尔型值。
  • strip([chars]),在字符串上执行lstrip()rstrip()
  • swapcase(),将字符串中的大小写反转。
  • title(),返回标题化字符串,即每个单词都是大写开始,其余字母均为小写。
  • translate(table, deletechars=''),根据table给出的表,转换字符串,要过滤掉deletechars中的字符。
  • upper(),转换字符串中的字母为大写。
  • zfill(width),返回长度为width的字符串,原字符串右对齐,左侧填充 0。
  • isdecimal(),检查字符串中是否只包含十进制字符。

列表

序列是 Python 中最基本的数据结构,序列中每一个元素都会被分配一个用于表示其位置的数字,即索引。序列的索引从 0 开始。Python 中有 6 个序列类型,常用的是列表和元组。所有序列类型都可以进行索引、切片、加、乘和成员检查的操作。

列表是最常用的数据类型,通常使用在方括号中使用逗号分割值的形式定义,列表的数据项不需要有相同的数据类型。空列表可以使用[],或者list()函数来创建。

list1 = ['a', 'b', 'c']
list2 = [1, 2, 3, 4, 5]
list3 = ['a', 'b', 'c', 1, 2, 3]
list4 = list()
list5 = []

列表使用索引来访问相应的元素,并且可以对元素进行修改。要删除列表元素需要使用del语句。例如:

list1 = [1, 2, 3, 4, 5]
print(list1[2]) # 输出 3
list1[2] = 9
print(list1) # 输出[1, 2, 9, 4, 5]
del list1[3]
print(list1) # 输出[1, 2, 9, 5]

在使用索引访问列表元素时需注意超范围访问,即下标越界,此时 Python 会抛出IndexError。列表可以嵌套来定义多维列表,例如:[[1, 2, 3], ['a', 'b', 'c']]

列表操作

使用+可以拼接组合两个列表,*可以将列表的内容重复指定次数,in可以用来判断元素是否存在于列表中,搭配for可以用来进行列表内容的迭代。

使用for来进行列表迭代的时候,默认是直接迭代列表中的值,如果需要获取元素的索引,则需要使用enumerate()函数来辅助,该函数是一个生成器,每次会返回一个元组,其中第一个元素为索引值,第二个元素为索引对应的值,使用格式一般为for index, element in enumerate(list)

对于列表,Python 提供了以下函数和方法。

  • len(list),返回列表元素个数。
  • max(list),返回列表中元素的最大值。
  • min(list),返回列表中元素最小值。
  • list(tuple),将元组转换为列表。
  • list.append(obj),将obj添加到列表末尾。
  • list.count(obj),统计列表中obj出现的次数。
  • list.extend(seq),在列表末尾追加另一个列表中的元素。
  • list.index(obj),返回obj在列表中的索引。
  • list.insert(index, obj),将obj插入到列表的指定索引位置。
  • list.pop(index=-1),移除列表中指定索引的元素,默认最后一个元素,并返回其值。
  • list.remove(obj),移除列表中obj的第一个匹配项。
  • list.reverse(),返回反转后的列表。
  • list.sort(cmp=None, key=None, reverse=False),对原列表进行排序。
  • list.clear(),清空列表。
  • list.copy(),复制列表。

切片

切片是 Python 中强大功能之一,常用于操作序列和字符串。切片与序列和字符串的索引访问类似,都是使用方括号,但格式不同。切片可以包含三个数字,完整格式为:[起始索引:结束索引:步进]。这三个数字可正可负,分别表示不同的含义。切片中的这三个数字有些时候可以省略,但起始索引和结束索引之间的冒号不可以省略。

列表与字符串中每个元素都对应一正一负两个索引值,其中正值索引是从左向右的索引,负值索引是从右向左的索引。所以切片中步进值为正表示从左向右截取,负值则表示从右向左截取。

切片应用于列表,其返回结果也还是列表;应用于字符串,其返回结果依旧是字符串。

以下通过一些示例,来说明不同切片值的含义。假设有一个列表a = [1, 2, 3, 4, 5, 6, 7],就可以有以下操作。

  • a[:],返回完整列表内容。
  • a[4:],返回索引从 4 开始以后的全部数据,即[5, 6, 7]
  • a[:3],返回索引$[0,3)$的元素组成的集合,即[1, 2, 3]
  • a[:-5],返回 0 到从右数第五个元素,即[1, 2]
  • a[:5:2],以步进 2 返回 0 到 5 的元素,即返回索引 0、2、4,即[1, 3, 5]
  • a[:4:-1],步进为负值,从右侧开始取到索引 4 的前一个元素位置,即[7, 6]
  • a[:10],返回索引$[0, 10)$的的全部数据,超过实际索引后也不会报错。
  • a[::-1],返回逆序列表,即[7, 6, 5, 4, 3, 2, 1]

任何情况下都需要记住,结束索引始终不会包含在切片中。对切片进行操作,会改变列表的结构和值。下面通过几个示例来演示对切片赋值的操作结果。

list = ['a', 'b', 'c', 'd', 'e', 'f']
list[len(list):] = ['g', 'h'] # 将指定列表添加到原列表的末尾,操作完后的 list 是['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
list[1:4] = [1, 2] # 用指定列表替换原列表中切片部分,操作完成后的 list 是['a', 1, 2, 'e', 'f', 'g', 'h']
list[:] = '' # 替换掉原列表的内容,而不是创建一个新的对象

列表推导式

列表推导式提供了创建列表的简单途径,通常用于将一些操作应用于序列的每个元素,用其获得的结果作为生成新列表的元素,或者根据确定的条件创建子序列。用简单的话说,列表推导式相当于 Java 语言中 stream 操作的mapfilter的组合。列表推导式都在for关键字后跟一个表达式,之后还会跟零到多个for或者if子句;返回结果是一个根据表达式从其后的 forif 上下文环境中生成出来的列表。如果希望返回元组,则需要用括号包裹推导式。

Info

列表推导式是一项十分经典的 Pythonic 操作。

下面看一些示例。

vec = [2, 4, 6]
list = [3*x for x in vec]
print(list) # 会输出[6, 12, 18]
list = [[x, x**2] for x in vec]
print(list) # 会输出[[2, 4], [4, 16], [6, 36]]
list = [3*x for x in vec if x > 3]
print(list) # 会输出[12, 18]

列表推导式中可以使用复杂表达式和嵌套函数。下面给出一些技巧性的演示,读者可以自行在解析器中尝试运行。

vec1 = [2, 4, 6]
vec2 = [4, 3, -9]
print([x*y for x in vec1 for y in vec2])
print([vec1[i]\*vec2[i] for i in range(len(vec1))])
print([str(round(355/113, i) for i in range(1, 6))])

例如有以下矩阵:

matrix = [
[1, 2, 3, 4],
[5, 6, 7, 8],
[9, 10, 11, 12]
]

可以使用以下推导式完成矩阵的旋转:

new_matrix = [[row[i] for row in matrix] for i in range(4)]

元组

元组与列表类似,也是由任意类型元素组成的序列。但是与列表不同的是,元组是不可变的,可以认为元组是一个常量列表。

元组使用()包裹使用逗号分割的元素进行创建。例如:

tup1 = (1, 2, 3, 4)
tup2 = ('a', 'b', 1, 2)
tup3 = ()
tup4 = tuple()
tup5 = (40,)

注意上例中,当元组中有且仅有一个元素时,需要在元素后添加一个逗号防止括号被当作运算符使用。此外,在定义元组时也可以省略括号,但逗号不能省略,因为 Python 解释器是依靠逗号来定义元组的,这种语法并不推荐使用。

元组也可以向列表一样使用索引来访问其中的元素,但元组的元素是不允许修改的,仅能够对元组进行连接操作。能够使用索引,就意味着元组可以使用切片来进行操作;对元组进行切片操作,依旧会返回元组类型。

另外一种快速取得元组内元素的方式,称为元组解包。这种方式是通过将元组一次性赋予多个变量来完成的,多个变量之间使用逗号分割。例如:a, b, c = (1, 2, 3)

元组在进行数据库操作时会常常被用到,从数据库返回的记录结果集中的记录行,一般就是元组类型。此外,函数的参数也是以元组形式传递的,这在之后的函数中会有更加详细的介绍。

元组中也可以使用列表推导式一样的推导式语法,但是其形成的并不是一个元组,而是一个生成器。具体生成器的概念,可参考后文“函数”一节中生成器的内容。

字典

字典也是序列的一种,但不是使用索引进行访问,其中元素的顺序也无关紧要。字典中的元素都拥有唯一的键值(Key),键值通常为字符串,也可以是其他任意不可变类型,如布尔型、整型、浮点、元组等。字典是可变的,所以可以进行增加、删除、修改等操作。

Info

字典相当于 Java 中的 Map 类型。

字典使用大括号包裹的逗号分割的键值对进行定义,键值对中键与值之间使用冒号分开。例如:

dict1 = { 'abc': 123, 'bcd': 456, 98.7: 'ui' }
dict2 = {}
dict3 = dict()

对于字典中值的访问需要使用键值,键值需要放在方括号中,例如:dict1['abc']。对于值的修改,也是通过对于键值的访问完成的;删除操作也一样。

使用dict()可以将固定格式的二维序列转换为字典,这个格式为((键, 值), (键, 值))。具体内容类型可以是列表和元组或者是两者混合的类型,甚至还可以是双字符字符串列表。字典中的键是唯一的,如果同一个键被定义两次,则只有最后一次定义生效。

字典操作

字典可以使用in来测试其中是否包含指定的键,注意不是值。

对于字典,Python 提供了以下函数和方法来操作。

  • len(dict),计算元素个数。
  • str(dict),输出字典到字符串。
  • type(dict),返回字典的类型。
  • dict.update(dict2),将 dict2 中的内容合并到 dict 中。
  • dict.clear(),删除字典内的全部元素。
  • dict.copy(),复制字典内容。
  • dict.keys(),以列表返回字典中全部的键。
  • dict.get(key, default=None),尝试获取指定键对应的值,如果键不存在则返回default指定的值。
  • dict.items(),以列表返回可遍历的由键和值组成的元组序列。
  • dict.setdefault(key, default=None),尝试设置指定键的值,如果键不存在则添加键并设置默认值。
  • dict.values(),以列表返回字典中的所有值。
  • dict.pop(key, default=None),删除字典中的键以及对应值,返回被删除的值,如果键不存在,返回default值。
  • dict.popitem(),随机返回并删除字典中的一对键和值。

集合

集合也是常用的序列的一种,其特性与字典相似,但是没有键的存在,集合中的值不允许重复。如果只需要关心元素是否存在,那么集合是最佳选择。

集合使用大括号{}或者set()创建,其中的值使用逗号进行分隔。例如:

set1 = {1, 2, 3, 4}
set2 = set('a', 'b', 'c')
set3 = set()

Warning

注意,{}不会创建一个空集合,而是创建一个空字典。

字典与集合都可以使用推导式语法,并且都是使用{}包裹推导式,但区别是定义字典需要使用冒号:来分隔键与值。

集合操作

集合也可以使用in来判断其中是否包含指定值。除此之外还支持其他的一些操作符进行集合专有的操作。

  • &或者set.intersection(set2),交集运算,返回两个集合的交集。
  • |或者set.union(set2),并集运算,返回两个集合的和。
  • -或者set.difference(set2),差集运算,返回仅存在于第一个集合但不出现在第二个集合中的元素组成的集合。
  • ^或者set.symmetric_differnece(set2),异或集运算,返回仅在两个集合中出现一次的元素组成的集合。
  • <=或者set.issubset(set2),判断 set 是否是 set2 的子集。
  • <,用于真子集的判断。
  • >=或者set.issuperset(set2),判断 set 是否是 set2 的超集。
  • >,用于真超集的判断。

命名元组

命名元组是元组的子类,在一定程度上可以替代类来使用。命名元组适合用来存储数据,所以适合在仅保存数据而不内置其他方法的情况下使用,类似于 Java 中的 POJO(Plain Ordinary Java Object,简单 Java 对象即普通的 Java Bean)。命名元组既可以通过namedTuple.name来访问元组,还可以通过namedTuple[offset]来访问元素。

命名元组不是 Python 内置自动支持的类型,使用之前需要使用from collections import namedtuple来加载命名元组类型。命名元组的使用与元组不同,它不是直接定义的元组类型数据,而是需要先定义一个带有元素命名内容的数据结构,之后再进行数据的初始化。这个数据结构的定义需要两个参数:命名元组的名称和由多个域名组成的字符串,各个域名之间使用空格隔开。

具体参考以下示例,读者可在交互式解释器中观察示例的输出结果。

from collections import namedtuple

Person = namedtuple('Person', 'firstname lastname age') # 定义命名元组
# 通过指定详细参数来初始化命名元组的内容
student = Person('Bill', 'Lynn', 17)
# 或者通过命名参数来初始化命名元组的内容
student = Person(firstname='Bill', lastname='Lynn', age=17)

print(student)
print(student.firstname)
print(student.age)
print(student[1])

# 命名元组还可以使用字典来初始化
parts = {'firstname': 'Kate', 'lastname': 'Lynn', 'age': 18}
student2 = Person(**parts) # **可以将字典中的键和值取出来提供给函数使用,作为后文中会提到的命名参数

命名元素是不可变的,可以通过._replace()来替换其中的某些域的值来创建一个带有新值的命名元组,例如:student3 = student2._replace(firstname='Lucy')。命名元组的域一旦确定就不能再进行添加或者删除。

条件控制

Python 中的条件控制只有if一种,其语句格式为:

if 条件1:
	语句块1
elif 条件2:
	语句块2
else:
	语句块3

一个if语句中,可以有多个elif条件语句块,但是仅有一个else语句块。if语句可以嵌套在其他语句块结构中,但是要注意语句块的缩进。

这里需要注意以下两个使用要点:

  1. 每个条件后面都要使用冒号:,表示接下来是满足条件后要执行的语句块。冒号也是定义新语句块的符号。
  2. 一个语句块中的语句要使用相同的缩进。

Info

用于定义新语句块的冒号:,以后会在各个需要定义新语句块的语句末尾出现,需要牢记并熟悉这个语言特性。

在 Python 中,if语句的使用方法十分灵活。由于 Python 没有其他语言中的三元操作符,即?:操作,但是 Python 利用if语句实现了更加强大的选择赋值操作。这种操作与前面提到过的列表推导式十分相似,以下用一个示例来说明。

c = []
b = max(c) if len(c) > 0 else -1

在上例中,if被放在了一个表达式后面,这表示这个表达式的执行将由if条件来决定,如果条件不满足,则返回else语句的值。所以上例中b的值即为-1。如果列表c中有内容,不是空列表,则b的内容则是列表中的最大值。

关于 switch

从前面的基本控制语句中可以看出,Python 中没有提供 switch/case 语句。这是因为 Python 认为 switch/case 语法结构需要对参与判断的内容进行 Hash 和唯一性识别,与 Python 的优雅背道而驰。但实际上,我们依旧有多种方法来实现 switch/case 语法结构的替代。

最常用的替代方法是使用字典。

def case(value):
    return {
        'value1': some_value,
        'value2': lambda x: x + 1
    }.get(value, None)

case(some_value)

使用字典来实现 switch/case 结构依靠字典的键来进行判断,返回的是字典中键对应的值。另外一种替代方法则用到了后文中才会提到的一些技术,例如嵌套函数和生成器。

def switch(value):
    fall = False
    def match(\*args):
        nonlocal value, fall
        if fall or not args:
            return True
        elif value in args:
            fall = True
            return True
        else:
            return False
    yield match

for case in switch('v'):
    if case('v'):
        pass
    if case('b'):
        pass

这种替代方法使用生成器,返回了用于判断的函数,实现了 switch/case 的连续判断分支结构。在这种方法中,对于match()中的逻辑可以根据需要进行更加详细的定义。

match结构

在Python 3.10版本中,一个功能更加强大的match语句结构被引入了进来。自此,Python中也就具备了类似与switch语句的结构,但是功能要更加强大。

match语句格式非常简单,其使用格式如下所示。

match 表达式:
    case 条件1:
        语句1
    case 条件2:
        语句2
    case 条件3 | 条件4 | 条件5:
        语句3
    case default:
        默认语句

match语句最简单的使用方式就是跟其他语言中的switch语句一样,在case语句中列举需要匹配的值即可。此时的match语句相当于多条if exp == value:的组合。如果想要达到在匹配多个值时都执行相同分支语句的效果,那么可以使用上面格式说明中的组合条件:case 条件 | 条件 | 条件:,这种形式的分支允许match语句匹配列举的任意条件。

Tip

match语句中的case分支不会向下贯通式的执行,每次仅会执行一个分支。

传统使用if/else结构分支语句的时候,在语句结构的最后往往会使用一个else语句块来执行没有任何匹配内容时的处理。在match语句中,这种默认匹配是采用case default:或者使用case _:的分支来声明的,这个默认分支通常都被放置在整个match语句结构的末尾。

match语句还支持对给定的表达式进行解构,例如以下示例中就解构了一个元组。

point = (2, 1)

match point:
    case (1, y):
        print(f"点在行1,列{y}上")
    case (x, 0):
        print(f"点在纵座标轴上,行{x}")
    case (x, y):
        print(f"点在行{x},列{y}上")
    case _:
        raise ValueError("给定值不是一个点坐标。")

除了可以解构元组以外,match语句还可以解构数据类,其解构的使用方法与解构元组基本一致,只是case分支要改用数据类的类名来进行解构操作指定,如果只需要解构其中一部分内容,还可以使用命名参数来获取指定字段的内容。

另外,对于上面这个示例来说,还可以使用更加Pythonic的方式,那就是使用if来执行额外的判断。改写一下上面这个示例变成了以下的样子。

point = (2, 1)

match point:
    case (x, y) if x == 1:
        print(f"点在行1,列{y}上")
    case (x, y) if y == 0:
        print(f"点在纵座标轴上,行{x}")
    case (x, y):
        print(f"点在行{x},列{y}上")
    case _:
        raise ValueError("给定值不是一个点坐标。")

在使用if进行额外判断的时候,要记住,match语句会首先捕获值,然后再执行if判断。

对于匹配枚举类中的成员,匹配的值必须书写连带枚举类名称的枚举成员全称,例如以下示例。

from enum import Enum

class Color(Enum):
    RED = 1
    GREEN = 2
    BLUE = 3

match color:
    case Color.RED:
        pass
    case Color.GREEN:
        pass
    case Color.BLUE:
        pass

在对枚举类成员进行匹配的时候,match语句中的分支应该尽量列举枚举中所有可能的值,避免出现空缺匹配的问题。

循环

Python 中的循环语句有forwhile两种。需要注意的是 python 中没有do...while循环。

while循环

while 循环是前置真值条件循环,也就是说会先对判断条件进行求值,如果返回真值,则开始执行循环体内容;如果返回假值,则退出循环。while 的语句格式为:

while 判断条件:
    语句块

例如以下示例可以用于求和:

sum = 0
count = 1
while count <= 100:
    sum = sum + count
    count += 1

如果 while 的条件表达式值永远为True,则 while 就变成了一个无限循环,这在运行常驻服务以及 GUI 程序时十分有用。

while 语句可以附加一个 else 语句块,用以在判断条件为False时执行,其语句格式为:

while 判断条件:
    语句块
else:
    语句块

如果 while 中只有一条语句,则可以将其与 while 写在同一行中,但是 while 语句末尾的冒号不可省略。

for 语句

与其他语言中的 for 语句不同,Python 中的 for 语句用于遍历任意序列,如列表或者字符串。其语句格式为:

for 循环变量 in 序列:
    语句块
else:
    语句块

for 语句与 while 语句一样,也可以附加一个 else 语句块,这个 else 语句块是在序列为空,没有执行循环时执行的。

如果需要像其他语言一样循环遍历一个数字序列,可以使用range()定义一个数字序列,例如:

for i in range(5, 10):
    print(i)

range(n, m)会生成一个\( [n,m) \)的区间,如果加入第三个参数,即range(n, m, s)则可以定义序列增长的步进。

对于序列,可以使用函数enumerate(seq)来同时获取序列的索引和值,例如:

for index, value in enumerate(seq):
    pass

循环退出

breakcontinue语句提供了从循环中退出的功能,其中break会完全退出当前循环体,并且循环体附加的 else 语句块也不会执行。continue语句则是用于跳过循环块中剩余的语句并开始下一次循环的执行。

空语句

空语句十分简单,它不执行任何操作,只是为了保持程序结构的完整性。空语句使用关键字pass完成占位操作。例如:

while True:
	pass

海象表达式

海象表达式(:=)是在 Python 3.8 版本中引入的,在使用时需要注意目前项目所使用的 Python 版本。海象表达式可以用于给变量赋值或者创建局部变量,并将局部变量带入其作用域。海象表达式的值为完成赋值后的变量的值。其具体使用可以参考以下示例。

# 取得列表长度,避免做两次len()运算
if (n := len(arr)) > 10:
	print(f"List is longer than {n}")

# 从文件中循环读取指定长度的内容,并进行处理
# 在没有获取到内容时结束循环读取
while (block := f.read(1024)) != "":
	process_data(block)

# 在列表推导中使用临时变量
new_arr = [a for a in some-arr if (cond := process(a)) in other_list]

迭代

迭代是 Python 中最强大的功能之一,是快速科学访问集合元素的方式之一。迭代器是一个可以记录遍历位置的对象,在迭代器进行迭代时,会从集合的第一个元素开始,直到所有元素都访问结束,并且只能前进不能后退。

迭代器使用iter(seq)进行创建,使用next(it)进行迭代操作。可以用于创建迭代器的数据类型有列表、元组、集合和字符串。如前面所述的推导式语法,创建一个迭代器最快的方法就是使用元组推导式。

使用next(it)可以对生成的迭代器进行取值,并将其向下步进一位。此外,迭代器还可以使用 for 语句进行遍历。以下两段代码的执行结果是一样的。

list = [1, 2, 3, 4]
it = iter(list)
for i in it:
	print(i, end=" ")

it2 = iter(list)
while True:
	try:
		print(next(it))
	except StopIteration:
		sys.exit()

Warning

注意print中的end关键字,它可以用于将结果输出到同一行,或者在输出的结尾添加不同的字符。

next()在默认情况下,步进到迭代器结尾的时候会抛出StopIteration异常以示迭代结束。但是next()除了可以接受迭代器作为第一个参数外,还可以接受一个默认值作为第二个参数。当给定默认值时,next(iter, default)在步进到迭代器末尾时便不会抛出StopIteration异常,而是会返回给定的默认值。

在 Python 中没有提供对于列表等可迭代类型中元素的检索函数,但是比较 Pythonic 的方法就是使用next()来完成,可参考以下示例语句。

item = next((element for element in arr if element["property"] == someValue), None)

以上语句可以完成对可迭代对象中第一个符合条件的元素进行搜索,并且当可迭代对象中没有满足指定条件的对象时,会直接返回None值。

异常处理

这里所指的异常并不是语法错误,语法错误在 Python 中被称为解析错误。解析错误一般会由 Python 解析器直接指出。异常则是在语法都正确的情况下,解析器在运行期检测到的错误。在没有进行异常处理时,Python 会终止脚本的执行并指出错误所在。

使用try...except可以进行异常处理,这与其他语言中的try...catch功能基本一致。其使用格式为:

try:
	可能出错的语句块
except 错误类型:
	处理语句块

如果没有异常发生,except语句块的内容会被略过;如果发生了异常,则会执行except语句块内容,发生错误之后的 try 语句块内容将被略过。一个 try 语句可以包含多个except语句块,但同一时刻仅有一个except语句块会被执行。如果需要在except中处理多种错误,可以将其组合成元组放置于except关键字后,例如:

try:
	pass
except (RuntimeError, TypeError, NameError):
	pass

通常习惯中,try 语句的最后一个 except 语句块不附加任何错误,直接接一个冒号,这表示这一条 except 语句块将捕获前面所有 except 语句块未捕获的异常。此外,try 语句还可以附加一个 else 语句块,这个 else 语句块必须放置在所有 except 语句块之后,它将会在 try 语句块未发生任何异常的时候执行。具体使用可见以下示例。

try:
	pass
except IOError:
	pass
except:
	pass
else:
	pass

Python 可以使用raise语句抛出一个新的异常实例,例如:raise NameError('Hi')。在 except 语句块中使用不带任何参数的raise,可以将捕获到的异常再次抛出。对于异常的自定义,将在后面类的定义中介绍。

try 语句还可以附加 finally 语句块,用于在任何情况下语句块的执行,通常用来进行异常现场的清理。finally 语句块的用法与 else 语句块类似。

函数

函数是可重复使用的实现单一或者相关功能的代码段。函数能够提高应用的模块性和代码的重复利用率。除了内建函数以外,Python 允许用户自定义函数。要定义一个函数需要遵守以下基本的规则。

  1. 函数代码段由def开头,后接函数标识符和圆括号。
  2. 任何传入的参数和自变量都必须放在圆括号中间。
  3. 函数的第一行语句可以选择性的使用三引号字符串,即 docstring,来对函数进行说明。
  4. 函数内容以冒号起始,并且保持缩进一致。
  5. 使用return结束函数并返回值。不带表达式的return相当于返回None

所以定义函数的一般格式为:

def 函数名(函数参数表):
	函数体

定义好的函数通过函数标识符进行调用,调用格式为函数标识符(参数列表)

def area(width, height):
	return width * height

print(area(15, 10))

参数传递

Python 中的数据类型分为可变类型和不可变类型两种,其中字符串、元组、数字是不可变对象,列表、集合、字典等则是可变对象。在进行参数传递时,不同类型的对象在传递时也不相同。

  • 不可变类型:类似于 C++的值传递,传递的只是实参的复制,对形参的修改不影响实参本身。
  • 可变类型:类似于 C++的引用传递,传递的是实参的内存地址,对形参的修改将使实参受到影响。

由于 Python 中一切都是对象,所以不能使用值传递或者引用传递的概念,而是应该使用传递可变对象或者传递不可变对象。简而言之,被传递的对象是可变的,那么在函数中对形参的修改,也将直接反映到被传递的对象上,反之亦然。

参数定义

Python 中函数的参数有四种类型:

  • 必需参数,
  • 关键字参数,
  • 默认参数,
  • 不定长参数。

必需参数是必需以定义时的顺序传入函数的参数,调用时参数的数量和顺序必须和声明时完全一致。大部分的函数都是采用此种方式声明和调用。这也是函数定义的默认状态。

关键字参数是使用关键字来确定传入的参数值,这时解释器可以根据参数名来确定和匹配参数值,也就允许函数调用时,参数的顺序和声明时的顺序不一致。

def area(width, height):
	return width * height

# 使用必需参数形式调用
area(10, 5)
# 使用关键字参数形式调用
area(height=5, width=10)

参数可以在函数声明时设定默认值,这样在函数调用时,如果没有传递这个参数,解释器就会使用设定的默认值填充参数。默认参数在定义时必须放置在参数列表最后的位置。

def area(width, height=5):
	return width * height

# 使用默认参数
area(10)
# 不使用默认参数
area(10, 8)

不定长参数可以允许函数能够处理比声明时更多的参数,与之前的参数不同,不定长参数不会命名,也没有默认值。不定长参数的使用格式如下:

def 函数标识符([常规形参表], *不定长形参名):
	"docstring"
	函数体
	return 返回表达式

不定长参数使用*在其前方进行标记,所有未命名的变量参数都会形成一个元组传入不定长形参中。用于标记不定长参数的*可以单独出现,例如:func(a, b, *, c),这种情况下,星号后的参数必须以命名参数的形式出现。

Info

在 Python 3.8 版本中,新增了一个标记/,在/前方的参数必须是位置参数,不被作为关键字参数,但是/不要放置在*之后。例如以下示例。

def f(a,b, /, **kwargs):
	print(a, b, kwargs)

# 以下调用将输出:10 20 {'a': 1, 'b': 2}
f(10, 20, a=1, b=2)

如果使用**在不定长参数前进行标记,则会将所有未显式定义的命名参数转化为字典。例如:

def foo(country, **kwargs):
	print(kwargs)

foo('China', province='Hebei', city='Shijiazhuang')

return 语句

return 表达式语句用于退出函数,选择性的向调用方返回一个表达式。不带参数的return语句会返回None

Python 允许在一个函数中返回多个元素,同时被返回的元素将组合成一个元组,可以使用解包语句来获取其内容。例如:

def twins()
	return 0, 1

zero, one = twins()

# 如果只需要从其中获得部分元素,则可以使用_来屏蔽掉不需要的元素,例如
zero, _ = twins()

此外,一个函数中可以有多个return语句,但只有最后一个return语句的结果被返回,这尤其是在使用try...finally...语句时需要注意。

生成器

不使用return返回值,而使用yield返回值的函数称为生成器。与return不同的是,yield用于不断的返回值,也就是说是用于迭代操作。生成器实际上就是一个迭代器。

生成器在运行过程中,遇到yield时就会暂停运行并保存当前的运行信息,并返回yield的值;当执行next()时,生成器会从保存的位置继续运行,并抛出下一个值。所以调用生成器返回的是一个迭代器对象。如果生成器在运行过程中遇到了return则会直接抛出StopIteration终止迭代。

如果函数需要返回一个容量很大的列表,并且其中的内容是动态计算得到的,那么使用生成器会更加经济、快速。

以下示例构建了一个用于生成斐波那契数列的生成器。读者可以自行在交互式解释器中实验这个函数的运行。

def fibonancci(n):
	a, b, counter = 0, 1, 0
	while True:
		if (counter > n):
			return
		yield a
		a, b = b, a + b
		counter += 1

Info

这个示例中存在若干个 Pythonic 用法,请注意观察。

生成器不仅可以用在for循环中,还可以用于函数的参数。如果这个函数接受一个iterable(可迭代)参数,就可以将生成器传递给这个函数。

除了可以使用next()来获取生成器中的下一个值以外,还可以使用send(arg)来获取生成器中的下一个值。这两个函数的区别是send()函数会将参数传递给yield表达式作为yield表达式的值。这里需要仔细理解生成器的整个操作过程,因为这个操作过程是 Python 进行异步处理的核心概念。以下给出一个生成器接受send()传递的值的例子。

def echo():
	m = 0
	while m < 100:
		m = yield m


f = echo()
value = f.next() # 或者可以调用 f.send(None)
print(value)
value = f.send(98)
print(value)

yield关键字会返回其右侧的值,并从send()函数中取得新值作为其表达式值,在下一次迭代时,将会从yield的下一行开始执行。函数next()send()的返回值就是本次yield关键字右侧的值。

生成器在初始化后的第一次调用必须使用next()或者send(None),在第一次调用生成器时,这两条语句是等同的。之所以给send()传递参数None,是因为在调用send()时,还没有出现第一个yield,所以send()没有对应的yield表达式可以修改,必须传递None值以保证生成器的正常运行。

在日常使用时,还会发现yield from关键字形式。yield from关键字允许在一个生成器中调用另一个生成器,并抛出另一个生成器生成的值。yield from常用来将嵌套循环或序列扁平化。以下给出一个扁平化的例子。

# 没有进行扁平化的生成器
def chain(*iterables):
	for it in iterables:
		for value in it:
			yield value

# 扁平化后的生成器
def chain(*iterables):
	for it in iterables:
		yield from it

以下示例使用yield from实现了一个类似于递归的扁平化循环嵌套型序列的方法,请仔细体会。

from collections import Iterable


def flatten(items):
	for it in items:
		if isinstance(it, Iterable):
			yield from flatten(it)
		else:
			yield it

匿名函数

使用lambda关键字可以创建一个只包含一条语句的匿名函数。其定义格式为:lambda 参数列表:表达式。匿名函数默认返回表达式的结果。

area = lambda width, height: width * height
print(area(10, 5))

匿名函数在其他语言中也称为闭包、Lambda 表达式,其功能主旨都是基本相同的。这里要注意的是,匿名函数中变量作用域与普通函数是有不同的,所以不能将其作为普通函数的完全替代品来使用。

变量作用域

Python 中变量的作用域也是由变量的定义位置决定的,并且范围由小到大有以下四种作用域。

  • 局部作用域(Local);
  • 闭包外作用域(Enclosing);
  • 全局作用域(Global);
  • 内建作用域(Built-in)。

在 Python 中只有模块、类和函数才会引入新的作用域,除此之外其他的代码块是不会引入新的作用域的。

定义在函数内部的变量拥有一个局部作用域,局部变量只能在其被声明的函数内部访问,如果在函数内部要修改外部作用域的变量时,需要使用global关键字来修饰声明要引用的变量。例如:

num = 1
def foo():
	global num
	num = 2
	print(num)

foo()

如果需要修改嵌套作用域,而外层作用域又非全局作用域,则需要使用nonlocal关键字。例如:

def outer():
	num = 10
	def inner():
		nonlocal num
		num = 20
		print(num)
	inner()

outer()

修饰器

修饰器可以在不修改目标函数代码的情况下,在目标函数执行前后增加一些额外功能,例如函数计时、鉴权等。修饰器是一个函数,它需要返回一个新的函数,修饰器在模块加载时就会执行生成一个个被修饰的新函数。

以下示例定义了一个用于计时的修饰器。

import time

def timing(fn):
	def new_fn(*args):
		start = time.time() # 在被修饰的函数执行前进行操作
		result = fn(*args) # 调用被修饰的函数,被修饰的函数调用结果收集起来
		end = time.time() # 在被修饰的函数执行后进行操作
		duration = end - start
		print("%s secnods cunsomed in executing %s" % (duration, fn.__name__))
		return result # 返回被修饰的函数执行结果
	return new_fn # 返回局部定义的函数

# 使用@来调用修饰器函数对目标函数进行修饰
@timing
def acc(start, end):
	a = 0
	for i in xrange(start, end):
		a += i
	return a

acc(10, 1000000)

修饰器函数使用@进行调用来标记目标函数,并且不允许与函数定义书写在同一行。修饰器函数可以接收参数,定义时只需要再在外面套一层函数即可,带参数的修饰器在调用时直接按照函数调用格式书写参数即可。

以下根据上例修改了一个带参数的修饰器,可以设定是否保留函数元信息。

import time
import functools

def timing(keep_meta=False):
	def real_dec(fn):
		if keep_meta:
			@functools.wraps(fn)
			def new_fn(*args):
				pass # 这里照搬上例中的计时和调用代码即可
		else:
			def new_fn(*args):
				pass # 这里照搬上例中的计时和调用代码即可
		return new_fn
	return real_dec

@timing(keep_meta=True)
def acc(start, end):
	pass # 这里也照搬上例中的处理代码

Info

Python 支持在函数中定义函数,这样定义出来的函数要注意其作用域,如果未将其作为返回值返回,则只能在定义它的函数中使用。需要注意@functools.wraps()修饰器的作用,在后面的框架中,会常常用到这个修饰器,它表示修饰器不会对被修饰的函数造成影响,这会在一些情况下避免潜在问题的出现,例如 Flask 的路由处理函数。

面向对象

Python从设计之初就是一门面向对象的语言,所以在Python中创建一个类和对象是十分简便的。与其他语言相比,Python在尽可能不增加新语法和语义的情况下加入了类机制。

在面向对象部分中,主要会用到下面这些概念。

  • :用来描述具有相同功能的属性和方法的集合。对象是类的实例。
  • 实例化:创建一个类的实例的过程。
  • 对象:通过类定义的数据结构实例,一般包括两种数据成员和方法。
  • 方法:类中定义的函数。
  • 类变量:类变量在整个实例中是公用的,又称为“字段”或者“特性”。类变量定义在类中但在函数体外。类变量通常不作为实例变量使用。
  • 实例变量:定义在方法中的变量,只作用于当前实例的类。
  • 数据成员:类变量或者实例变量用于处理类及其实例对象的相关数据。
  • 继承:派生类继承基类的字段和方法的过程。继承也允许把一个派生类的对象作为一个基类对象对待。Python支持一个派生类继承多个基类。
  • 方法重载:如果从基类继承的方法不能满足派生类的要求,可以对其进行改写,这时子类的方法会覆盖掉父类的方法,这个过程称为方法重载。Python中的派生类可以重载基类中的任何方法,并且可以调用基类中的同名方法。

类定义

Python中的类使用关键字class进行定义,格式如下:

class 类名:
	类变量定义
	
	def 方法名(self, 参数列表):
		方法体

在上面方法定义中有一个特殊的self参数,这个参数有特殊意义并且是强制规范,这将在后面方法定义中具体阐述。

类定义后,即可使用其实例化对象。实际上,可以通过类名访问其属性。

类对象

类的实例(即对象)支持两种操作:属性引用和实例化。

属性引用使用和Python中所有属性引用一样的标准语法:obj.name。类的实例化有点儿类似于函数调用,使用类名与圆括号,例如:x = MyClass()

Info

Python中没有new这个关键字,类的实例化也不借助其他的关键字,这点儿与其他语言不同。

下面给出一个简单的示例,读者可以自行在交互式解释器中实验。

class MyClass:
	"""一个简单的类实例"""
	i = "Hello my class"
	
	def f(self):
		return 'foo'

x = MyClass()

print("MyClass类的属性为{0}".format(x.i))
print("MyClass类的方法输出内容为{0}".format(x.f()))

方法定义

类的方法与普通函数基本上没有什么区别,唯一的区别是它们必须有一个额外的第一个参数名称,在惯例上,这个参数命名为self,也可以根据其他语言的习惯使用this,但是这样并不Pythonic。

这里的参数self代表的是类的实例,而不是类。读者可以在交互式解析器中运行以下示例来查看self的代表。

class Test:
	def prt(self):
		print(self)
		print(self.__class__)

t = Test()
t.prt()

如果使用@classmethod修饰符对一个方法进行修饰,并将第一个参数由self更换为cls(即class,但class为关键字不能够作为参数名称使用),这个方法则会成为类方法。类方法通过类来调用,而不是类实例。类方法的第一个参数cls代表类本身,其不能访问任何实例变量和实例方法,但可以访问类变量。

第三种特殊的方法则是静态方法,这种方法使用修饰器@staticmethod修饰,其定义不包含self或者cls等任何参数。静态方法即不会影响类,也不会影响类的对象,它们的存在仅是因为代码组织的规范,其调用方法与类方法相同。

以下示例演示了类方法与静态方法的定义和使用,读者可自行在交互式解释器中试验。

class A:
	count = 0 # 注意这个变量的级别,这是一个类变量

	def __init__(self):
		A.count += 1 # 这里使用类特性,而不是对象的特性

	@classmethod
	def children(cls):
		print("Has {0} children".format(cls.count))
	
	@staticmethod
	def standalone():
		print("A static method")

ch1 = A()
ch2 = A()
ch3 = A()

A.children()
A.standalone()

构造函数

Python中没有构造函数的概念,只有初始化方法的概念,即__init__()方法。这里借用其他面向对象语言中的构造函数的概念来描述这个初始化方法的功能。初始化方法与普通方法的定义方法一样,但是其名称是Python内部固定不可改变的。所以其定义格式为:

class MyClass:
	def __init__(self):
		pass

初始化方法除了self参数以外,还可以接受其他的参数,这一点与其他方法无异。但是如果添加了其他的参数,那么在类实例的实例化时,也需要传入相应的参数。例如:

class MyCalss:
	def __init__(self, mark):
		self.mark = mark

x = MyClass('check')

上例中MyClass的初始化方法中声明的self.mark是一个实例变量,可以在类中的其他方法中使用。这个实例变量从类内部需要使用self.name的形式来访问,在类外部则需要使用实例名.name的形式来访问。

初始化方法__init__()也可以被重载。可以在类中多次定义初始化方法,但它们所能够接收的参数列表不能相同。在实例化时,传入不同数量的参数,Python就会调用相应的初始化方法。

读者可自行在交互式解释器中试验以下代码。

class MyClass:
	w = 0
	h = 0

	def __init__(self, w):
		self.w = w
	
	def __init__(self, w, h):
		self.w = w
		self.h = h
	
	def export(self):
		print("Width: {0}, height: {1}".format(self.w, self.h))

x1 = MyClass(9)
x1.export()

x2 = MyClass(8, 10)
x2.export()

属性的使用

面向对象的语言一般都支持私有字段,这部分字段不能从对象外部直接访问。但是Python中所有字段都是公开的,即public的。所以Python中有一条惯例:使用双下划线标记的字段为私有字段,不能从对象外部及其子类中直接访问,即__name形式的字段,私有字段在类内部也要使用self.__name的形式访问。同理,私有方法则是在方法名前增加两条下划线,即def __private_method();如果需要允许在子类中访问指定属性或方法,则需要使用单下划线语法,即_namedef _protected_method()

这种私有内容的表示方法虽然是约定惯例,但目前Python的解释器已经将其实装并在出现非法访问时会提示错误。

要对私有字段进行访问,Python提供了属性这个特性。可以允许为对象编写getter和setter方法来组成对类内部字段的操作。下面通过一个示例来说明属性的定义方法。

def Person:
	__name = ''
	
	def __init__(self, name):
		self.__name = name
	
	def get_name(self):
		return self.__name
	
	def set_name(self, name):
		self.__name = name
	
	name = property(get_name, set_name)

h = Person('Howard')
print(h.name)
h.name = 'Lynn'
print(h.name)

示例中的get_name()set_name()都是可以直接调用的,最后一个property()的定义将两者结合了起来形成了一个属性。

另一种定义属性的方式是使用修饰符。使用修饰符定义属性设计两个修饰符的使用:@property@propertyName.setter。这里需要使用定义的属性名称替换propertyName

下面用修饰符重新定义上面的示例。

def Person:
	__name = ''
	
	def __init__(self, name):
		self.__name = name
	
	@property
	def name(self):
		return self.__name
	
	@name.setter
	def name(self, name):
		self.__name = name

这种定义已经不像前面那一种一样需要两个显式的getter和setter方法了。这里的属性使用同一个名称但有两个不同重载的方法组成,并且包含不同的修饰符。属性的名称默认是@property修饰符修饰的方法名称。

除了可以对类内部的私有字段进行操作以外,属性还可以用来获取动态产生的计算结果值,即计算属性。如果没有定义setter属性,那么使用@property定义的属性即成为只读属性。

魔术方法

Python中提供了一些特殊的方法,对它们进行重载,就可以让你的类实现实例的加减乘除等操作,而且还包括一些特殊的操作,这些方法也被称为魔术方法。魔术方法以双下划线(__)开头和结束,就像之前说过的构造函数__init__()。可以通过重载这些魔术函数来实现一些Python内置的操作。下表列出了常用的Python支持的方法以及如何使用它们,具体可重载的方法可参考标准库operator

方法名使用方法
__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
__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
__str__(self)str(self)或者print(self)
__repr__(self)repr(self)或者print(self)
__len__(self)len(self)
__iter__(self)返回一个迭代对象,与__next__()配合使用
__next__(self)返回迭代的下一个值
__getitem__(self, key)定义按照下标访问数据,需要实现索引、切片等全部操作
__setitem__(self, key, value)定义按照下标设定数据的行为
__delitem__(self, key)定义按照下标删除数据的行为
__reversed__(self)定义调用reversed()时的行为
__contains__(self, item)定义使用成员测试in或者not in时的行为
__enter__(self)定义使用with是的初始化行为,其返回值被with的目标或者as后的变量绑定
__exit__(self, exc_type, exc_value, traceback)定义当一个with被执行或者终止后需要执行的内容,一般用来处理异常、清除等工作

其中方法__add__()__sub__()__mul__()__floordiv__()__truediv__()等都有添加i前缀的对应方法,例如__iadd__(),可以用来实现自增、自减等操作,例如a += b。其他可以使用i前缀的方法可参考标准库文档。

继承与组合

继承是面向对象编程的基础之一,Python也不例外。Python支持从多继承,也就是一个派生类可以继承多个基类的内容。派生类的定义格式为:

class 派生类(基类1, 基类2, ...):
	pass

派生类可以重载基类的方法,重载只需要保持函数签名一致即可。派生类可以重载任何一级基类的方法。派生类可以使用super()调用基类的方法。具体使用方法可参考以下示例:

def Base:
	def __init__(self):
		pass
		
	def hello(self):
		pass

def Child(Base):
	def __init__(self):
		super().__init__()
		
	def greeting():
		super().hello()

对于类的继承关系来说,可以使用isinstance()函数来判断一个实例的类型,如果对子类的实例去判断其是否是基类的实例,会得到True的结果。

继承就像是把一个原材料加工成成品或者零件,而组合则是将零件组装成更加强大的产品的过程。换句话说,继承是is的识别关系,而组合则是has的包含关系。

使用组合十分容易,只需要将其他的类实例作为组合类的字段或者属性即可。

鸭子类型

这里的“鸭子类型”并不是真的去指代一个鸭子,而是借用鸭子来说明动态语言与静态语言的区别。对于静态类型,如果需要传入一个Animal类型,则传入的对象必须是Animal类型或者它的子类,否则将会无法调用其中的walk()方法。但是对于Python这种动态语言来说,则不一定要传入Animal类型,只需要保证传入的对象有一个walk()方法就可以了。

这就是动态语言的“鸭子类型”,它不要求严格的继承体系,一个对象如果“看起来像鸭子,走起路来像鸭子,那它就可以被看做是鸭子”。这就是“鸭子类型”的精髓。

对于数据结构的选择

对于数据结构的选择实际上是一件十分Pythonic的事情,虽然条条大路通罗马,但是还是有一些可以参考采纳的Pythonic方法的。

当需要许多相似行为但状态不同的实例时,使用类对象是最好的选择。因为类支持继承而模块不支持继承。但是如果需要保持实例的唯一性,使用模块则是最好的选择,因为不管模块在程序中引用多少次,始终只有一个实例被加载,所以可以把Python的模块理解为单例。

用最简单的方式解决问题。字典、列表和元组往往比模块和类更加简单、简洁和高效。

引用Python创始人Guido的建议:

Quote

不要过度使用数据结构,尽量使用元组而不是对象。尽量使用简单的属性域而不是getter/setter函数。内置数据类型永远是你最好的朋友。尽可能多的使用数字、字符串、元组、列表、集合以及字典。多看看容器库提供的类型,尤其是双端队列。

自定义异常

前面介绍了异常的处理,在很多时候Python内置和标准库提供的异常就已经足够满足使用,但是在一些项目中,应用自定义异常也是十分普遍的事情,而且能够达到针对项目进行特殊设计。

异常就是一个继承了Exception类的类。Python中的所有异常都直接或者间接继承自Exception类。异常一般以Error字样结尾。当创建的一个模块有可能抛出多种不同的异常时,通常的做法是为这个包建立一个基础异常类,然后基于这个基类为不同的错误情况建立不同的子类。例如:

class Error(Exception):
	def __init__(self, value):
		self.value = value
	def __str__(self):
		return repr(self.value)

class InputError(Error):
	pass

class TransitionError(Error):
	pass

使用__slots__

正常情况下,在定义了一个类并且创建其实例后,可以向这个实例绑定任何属性和方法,这是动态语言的灵活性。例如:

class Student:
	pass

s = Student()
s.name = 'Kate'
print(s.name)

以上示例只是绑定了一个属性,实际上还可以为其绑定方法。但是对一个实例绑定的方法,对另一个实例是不起作用的。如果需要给所有实例都绑定方法,可以直接将方法绑定给类。例如:

# 以下绑定了一个方法到实例
def set_age(self, age):
	self.age = age

from types import MethodType
s.set_age = MethodType(set_age, s)
s.set_age(25)
print(s.age)

# 以下绑定了一个方法到类
def set_score(self, score):
	self.score = score

Student.set_score = set_score
s.set_score(100)
print(s.score)

但是在很多时候需要限制实例的属性,不能允许随意添加属性,这时就需要__slots__来完成限制工作。Python允许在定义类的时候使用一个特殊的__slots__变量来限制类能够添加的属性。例如:

class Student:
	__slots__ = ('name', 'age')

s = Student()
s.name = 'Kate'
s.age = 20
s.score = 100 # 这一句将会报错

如果子类中也定义了__slots__,那么子类中允许定义的属性就是其自身定义的__slots__加上父类的__slots__

使用__dict__存取任意数据

类的静态函数、类函数、普通函数、全局变量以及内置属性都是放在类的__dict__属性中,直接操作__dict__属性可以直接向类的实例中动态的添加成员。在发生继承时,子类有自己的__dict__,父类也有自己的__dict__,属于子类的全局变量和函数将放置在子类的__dict__中,父类的放置在父类的__dict__中,父类的__dict__不会影响子类的__dict__

__dict__是一个字典类型,可以直接使用字典的常规操作来处理。配合魔术方法__getattr____setattr__可以做到在类中存取任意数量的数据。

以下给出一个可自由保存数据的类的示例。

class Storage:
	def __init__(self, **kwargs):
		self.__dict__.update(kwargs)
	
	def __getattr__(self, name):
		if name in self.__dict__.keys():
			return self.__dict__.get(name)
		else:
			return None
	
	def __setattr__(self, key, value):
		self.__dict__.update({key: value})


sample = Storage({"id": 0, "name": "sample"})
sample.create_time = "1970-1-1"
sample.create_time = "1970-1-2"
print(sample)

枚举类

常量一般用大写来定义,但是有相关关系的一组常量一般都是使用类来定义,每个常量都是类的唯一实例。Python在这里提供了枚举类来辅助完成这项工作。

以下给出一个示例。

from enum import Enum

Weekday = Enum('Weekday', ('Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'))

print(Weekday.Mon) # 访问成员
# 迭代成员
for name, member in Weekday.__members__.items():
	print("{} => {}".format(name, member))

也可以使用以下方法来精确控制枚举类的定义。

from enum import Enum, unique

# @unique可以用来保证没有重复值出现
@unique
class Weekday(Enum):
	Sun = 0
	Mon = 1
	Tue = 2
	Wed = 3
	Thu = 4
	Fri = 5
	Sat = 6

数据类

自3.7版本开始,Python引入了一个新的功能:数据类(dataclass)。根据PEP-557的定义,数据类是一个带有默认值的可变命名元组。可以认为数据类就是一个类,其中的属性都是公有的,属性都带有默认值并且可以修改,这个类中还可以包含与这些属性相关的方法。

声明一个数据类需要引入dataclasses模块中的dataclass修饰器。以下给出一个简单的数据类示例。

from dataclasses import dataclass, field


@dataclass
def Book:
	name = ""
	isbn = ""
	price = 0.0
	authors = field(default_factory=list)

数据类在定义后会自动产生一个为所有属性赋值的构造函数,并且会自动建立__repr__()__eq__()两个方法。数据类中的属性可以不带有默认值,但是需要注意的是不带默认值的属性要写在带有默认值的属性前面,这与函数参数的书写顺序要求是一致的。

修饰器dataclass可以接受一些参数来自定义数据类的建立,主要的参数有以下这些,全都都只接受布尔类型值。

  • init,指示是否生成构造函数,如果需要自己定义构造函数则应设置为False
  • repr,指示是否生成__repr__()方法。
  • eq,指示是否生成__eq__()方法。
  • order,指示是否生成__lt__()__gt__()__le__()__ge__()等用于比较的方法。
  • unsafe_hash,根据eqfrozen的值来决定是否生成__hash__
  • frozen,设置为True的时候实例对象将是不可变的。

数据类通过dataclass修饰器自动生成的构造函数默认会调用名为__post_init__()的方法,如果使用了默认生成的构造函数,那么一些追加的构造操作可以在__post_init__()方法中书写。

数据类支持继承,dataclass修饰器会检查数据类的所有基类,会将其中出现的其他数据类按顺序添加进当前的数据类中,如果子类的属性与基类的属性重名,则子类的属性会覆盖掉基类的同名属性。

数据类中的每个属性都会在dataclass修饰器的作用下自动转化为dataclasses.Field对象。通常这个自动转换的过程是无需干预的,但是有的时候则需要定制这个转换过程。我们可以通过dataclasses.field()函数来定义一个属性,并声明它的特性。

正如前面示例中的authors属性,这是一个列表,如果直接使用list()或者[]来定义,则会在数据类实例创建的时候仅复制列表的引用,这会造成不必要的混乱。所以使用field()函数来定义这个属性的具体特性。dataclasses.field()函数可以接受以下参数来定制dataclasses.Field对象。

  • default,设置属性的默认值。
  • default_factory,控制如何产生默认值,接受一个callable类型值。
  • repr,是否包含到默认的__repr__()方法输出中。
  • hash,是否参与到生成__hash__中。
  • init,是否参与到构造函数中。
  • compare,是否参与实例比较方法。
  • metadata,使用字典类型值设定属性的元数据,用于第三方扩展使用。

除此之外,dataclasses中还提供了一些常用的工具函数,其功能如下。

  • asdict(),将数据类实例转换为一个字典类型实例。
  • astuple(),将数据类实例转换为一个元组。

迭代器与生成器

在前面的魔术方法的表格中,出现了两个魔术方法:__iter__()__next__()。通过这两个魔术方法的组合,可以将一个类创建成一个迭代器。其中__iter__()方法需要返回一个可迭代对象,__next__()需要返回被迭代的对象中的一个元素。

如果一个类只重写了__iter__(),那么这个类可以被称为可迭代对象。如果一个类同时重写了__iter__()__next__()方法,那么这个类可以被称为迭代器。由于生成器也是一个可迭代对象,所以如果要让一个类成为生成器,只需要重写__iter__()方法即可。以下通过三个示例来分别展示将类书写为不同的形式的方法。

可迭代对象

仅仅作为一个可迭代对象,类中的__iter__()方法需要返回另一个可迭代的对象。

class Collector:
	_collect = []
	
	def __iter__(self):
		return self._collect


for i in Collector():
	pass

示例中通过返回另一个可迭代对象,把这个类定义为了一个可迭代对象,可以直接用于iter()函数或者for循环。

如果在__iter__()方法中使用yield抛出一个值,那么这个类的实例就会变成一个生成器。

class Fib:
	def __init__(self):
		self.a, self.b = 0, 1
	
	def __iter__(self):
		while True:
			yield self.a
			self.a, self.b = self.b, self.a + self.b


for i in Fib():
	pass

迭代器

如果要将可迭代对象扩展为迭代器,需要额外重写__next__()方法,并且__iter__()方法中需要返回类实例本身。

class Fib:
	def __init__(self):
		self.a, self.b = 0, 1
	
	def __iter__(self):
		return self
	
	def __next__(self):
		return_value = self.a
		self.a, self.b = self.b, self.a + self.b
		return return_value

这里需要注意的是,在__next__()方法中不能使用yield关键字抛出值,那样会导致next()函数取得一个生成器对象,而不是一个值。

用类写修饰器

在前面函数一章最后介绍的修饰器,一般都是使用函数来书写的。类也可以被用来书写修饰器,这主要依靠魔术方法来实现。

以下给出一个使用类来书写日志记录器的示例。

from functools import wraps

class Log(object):
	def __init__(self, logfile="some_path"):
		self.log_file = logfile
	
	def __call__(self, func):
		# __call__魔术方法负责进行被修饰函数的调用
		@wraps(func)
		def wrapped_func(*args, **kwargs):
			self.write_log(*args, **kwargs)
			return func(*args, **kwargs)
		return wrapped_func
	
	def write_log(self, *args, **kwargs):
		pass


# 使用类的实例作为修饰器
@Log()
def some_operate():
	pass

使用类书写的修饰器来做日志记录只是其最常见的功能实现,更多的使用场景,读者可以发挥想象力来应用。

类型标注

Python是一个静态类型的语言,但是在编码过程中并不需要严格指定变量、参数的类型,也不需要声明函数返回值的类型。这种特性在Python日常编程中虽然带来了很大的自由度,但是也带来了许多潜在的问题,例如在不知情的情况下错将一个整型值赋予了字符串类型变量。

此外,对于变量及参数类型的不显式限定,也给代码中确定参数类型带来了额外的工作。

于是在Python 3.5版本开始,按照PEP 484的规定,Python增加了类型标注功能(Type Hints)。这项协议旨在为Python加入显式的类型声明,其中就包括函数参数与函数返回值的类型声明。但是需要注意的是,Python 3.5开始加入的类型标注功能仅仅是一个类型标注协议,但未提供类型检查工具,所以在编码时需要其他的工具来配合使用。

类型标注只是给Python提供了一个额外的属性,并不影响任何运行时功能,解释器也不会根据类型标注进行任何类型检查。

Warning

注意:PEP 484目前还依旧是临时协议版本,并非正式版本,所有在Python已经实现的功能,都有可能会再次发生变化,所以这里仅介绍日常常用的一些语法。

类型定义

类型标注所使用的类型可以是Python内置类型,例如strint等,可以是抽象基类,可以是types模块中定义的类型,亦或者是用户自定义的类。

为了能够对Python所使用的数据类型进行描述,Python在typings模块中声明了一系列的特殊类型。其中包括NoneAnyTupleUnionCallable。其中None与常量None所表示的意义相同并且可以由常量None来作为类型标注。

将类型赋予一个变量可以便捷的创建类型别名,例如:Url = str。类型别名还可以使用更加复杂的类型,例如T = TypeVar('T', int, float, complex),表示类型T可以代表整型、浮点以及复数类型,这就使得T表现的像是一个泛型中的类型变量一样,而事实也的确如此。

函数类型较为复杂,是使用Callable来定义的。Callable使用Callable[[参数1类型, 参数2类型], 返回值类型]的格式来定义一个函数,其中列表中第一个元素是由函数所接收的全部参数的类型组成的列表,第二个元素是函数返回值的类型。例如:Callable[[int, str], str]

以下给出一些常用的类型描述:

类型格式说明
NoneNone类型
Any任意类型
UnionUnion[X, Y]联合类型,可能是X类型或者是Y类型
TupleTuple[X, Y, Z]多元素元组类型,一般的元组类型为Tuple[Any, ...]
CallableCallable[[X, Y], Z]函数类型,第一个序列为参数类型,第二个元素为返回值类型
IterableIterable[X]序列类型,元素类型为X
SetSet[T]集合类型,元素类型为T
MappingMapping[K, V]字典类型,键类型为K,值类型为V
TypeVarTypeVar('Name', Types)定义类型变量
NoReturn永远不会返回的函数返回值类型
OptionalOptional[X]可空类型,相当于Union[X, None]
AnyStr任意字符串类型,相当于TypeVar('AnyStr', str, bytes)
GeneratorGenerator[Y, S, R]生成器类型,其中Y为抛出类型,S为发送类型,R为返回值类型

泛型

前面提到可以使用TypeVar()来定义类型变量,而在泛型中,类型参数就是一种类型变量,所以使用TypeVar()定义的变量可以作为泛型的类型参数来使用。TypeVar()接受至少一个参数,其第一个参数用于声明类型变量的名称,之后的参数用于限定类型变量可以代表的类型。

类型变量是存在作用域的,例如如果使用了类型变量的两个方法都在同一个class中,那么类型变量的值是统一的、不能改变的。这与其他面向对象语言中的泛型特性相同。

类型标注的使用

类型定义好之后必须要能够使用才能发挥其标注的作用。在函数中进行类型标注是一个最典型的应用案例。

def foo(a: str, b: int) -> str:
	pass

在以上代码示例中,类型标注使用:来标注参数的类型,使用->来标注函数返回值的类型。

前向引用

类型标注有时需要引用一些尚未定义的类型,例如引用自身类型的类。这种情况下直接使用尚未定义的类型进行标注是不正确的,作为替代的方法,Python提供了使用字面量表达式引用尚未定义的类型。例如:

class Tree:
	def __init__(self, left: 'Tree', right: 'Tree'):
		self.left = left
		self.right = right

这种使用字面量表达式引用尚未定义的的类型的方法就叫做前向引用。进行前向引用的字面量表达式中应该包含合法的Python表达式。

工厂方法

工厂方法通常用来创建一个类的实例,这是一种应用很普遍的设计模式。在面向对象的语言中,工厂方法通常是隶属于类的静态方法。Python中因为没有相应定义静态方法及类方法的关键字,所以引入了修饰器来标记方法的不同特征。

工厂方法属于类方法,可以直接使用类来调用,这里给出一个工厂方法的示例。

T = TypeVar('T', bound='C') # 声明类型变量T绑定至类型C,即C及C的子类。

class C:

	@classmethod
	def factory(cls: Type[T]) -> T:
		pass

class D(C):
	pass

d = D.factory() # 此处将生成D类型的实例。

类型转换

有很多时候,编码者自身已经明确变量内容的类型,但是需要返回相应的类型,这就要用到类型转换。类型转换使用typing模块中的cast()函数完成。

cast()函数的使用非常简单,cast(目标类型, 值)即可将指定值转换为目标类型。

自由参数和默认值

阅读到这里,相信读者对自有参数已经不陌生了,其经典示例就是def foo(*args, **kwargs)。这种自由参数也可以标注其参数类型,但是一旦标注类型,函数就会将参数解析为指定类型的列表和字典。例如def foo(*args: str, **kwargs: int)

此外,进行标注了的参数也依旧可以使用原有的默认参数语法指定参数的默认值,例如def foo(y: int = 0)

元编程

软件开发中的一条重要原则就是“不要重复自己的工作”,当我们需要复制粘贴的时候,通常都会存在一个更加优雅的解决方案。在Python中,这类问题通常是使用元编程来处理的。元编程的主要目的是创建函数和类,并对代码进行修改、生成或者包装。Python中用于实现这个目的的主要特性有修饰器、类修饰器和元类。

其中使用修饰器来修饰一个函数从而产生一个新的函数在前文中已经介绍过了,这里不再赘述。本章将主要以类修饰器和元类为主进行介绍。

类修饰器

类修饰器是一个普通的函数或者类,可以接受一个类作为参数,并返回一个新的类。类修饰器可以修改类的函数或者修改类的成员。

以下示例是使用类修饰器给类增加成员。

def addAttrs(*attrs):
	def rebuild(cls):
		class NewClass(cls):
			def __init__(self, *args, **kwargs):
				for attr in attrs:
					setattr(self, attr, None)
				self.__id = id
				super(NewClass, self).__init__(*args, **kwargs)
		return NewClass
	return rebuild


@addAttrs('uuid', 'created')
class DBModel(object):
	def __init__(self, *args, **kwargs):
		pass


m = DBModel()
print(m.created)

任何对类的修改,其原理都是动态创建被修改类的一个子类。修改类的函数也不例外。以下示例也是通过动态创建被修改的类的子类来修改类的函数。

def class_decor(*method_names):
	def class_rebuilder(cls):
		class NewClass(cls):
			def __getattribute__(self, attr_name):
				attr_val = super(NewClass, self).__getattribute__(attr_name)
				# 这里可以做一些其他的处理
				return attr_val
		return NewClass
	return class_rebuilder


@class_decor('first_method', 'second_method')
class DecoratedClass(object):
	def first_method(self, *args, **kwargs):
		pass
	
	def second_method(self, *args, **kwargs):
		pass

使用类修饰器来对类进行改变是具有一定局限性的,如果需要更强大的动态修改功能,可以使用下面的元类。

元类

元类是Python中的一个重要特性,要理解其工作方式,必须要明确用于建立对象实例的类也是对象,那么就一定有用于建立类的相关类。在Python中,定义所有类的基类都是内置的type类。

例如,运行以下示例代码,可以看到类的类型。

class MyClass:
	pass


print(type(MyClass))

使用type()创建类

type()作为class的等效功能,给定类名、基类名和属性映射即可创建一个新类。具体创建方法可以参考以下示例。

def foo(self):
	print("From foo")

def boo(self):
	print("From boo")
	
DynamicClass = type("DynamicClass", (object, ), {"func1": foo, "func2": boo})
obj = DynamicClass()
print(type(obj))
obj.func1()

元类模板

使用class创建的类都隐式默认使用type()作为元类,在定义类的时候,可以使用metaclass来指定要使用的元类来改变Python的默认行为。

前面面向对象一章曾经介绍过Python中的魔术方法,在定义元类时也常常使用魔术方法来定义元类模板。元类模板中所使用的魔术方法与一般类中的魔术方法有所不同,以下表格中列出了元类模板定义常用的魔术方法,这些魔术方法也可以被使用到一般类的定义中,并不局限于元类模板中使用。

魔术方法含义与使用方法
__prepare__(metacls, name, bases, **kwargs)用于创建一个空的命名空间,传递给__new__,返回一个空的字典。先于__new__调用。
__new__(cls, [])__new__是对象实例化时调用的第一个方法,其第一个参数为要实例化的这个类本身,其他的参数是要直接传递给__init__方法的。__new__可以决定是否要调用__init__方法,或者调用其他类的构造方法,亦或是返回其他类的实例来作为本类的实例。__new__没有返回实例对象,则__init__就不会被调用。
__init__(self, [])一个实例被创建后调用的初始化方法。
__del__(self)析构方法,一个实例被销毁时调用的方法。
__call__(self[, args])允许一个类的实例像函数一样被调用,例如x(a, b)可以是x.__call__(a, b)的形式。
__getattr__(self, name)定义当用户试图获取一个不存在的属性时要执行的行为。
__getattribute__(self, name)定义当该类属性被访问时的行为。
__setattr__(self, name, value)定义当一个属性被设置时的行为。
__delattr__(self, name)定义当一个属性被删除时的行为。
__dir__(self)定义当dir()被调用时的行为。
__get__(self, instance, owner)定义当描述符被获取时的行为。
__set__(self, instance, value)定义当描述符的值被改变时的行为。
__delete__(self, instance)定义当描述符的值被删除时的行为。

有了以上这些魔术方法,就可以仿照以下示例来定义和使用元类模板。

class MetaClassTemplate(type):
	""" 注意元类模板中的魔术方法接受的第一个参数大多都为一个类,而不是一个实例 """
	
	@classmethod
	def __prepare__(metacls, name, bases, **kwargs):
		""" __prepare__方法的第一个参数要接受一个元类,所以需要使用@classmethod修饰 """
		print("Meta Class Template __prepare__")
		return super().__prepare__(name, bases, **kwargs)
	
	def __new__(metacls, name, bases, namespace):
		print("Meta Class Template __new__")
		return super().__new__(metacls, name, bases, namespace)
		
	def __init__(cls, name, bases, namespace, **kwargs):
		print("Meta Class Template __init__")
		super().__init__(name, bases, namespace)
	
	def __call__(cls, *args, **kwargs):
		print("Meta Class Template __call__")
		return super().__call__(*args, **kwargs)


class ANewClass(metaclass=MetaClassTemplate):
	def __new__(cls):
		print("New Class __new__")
		return super().__new__(cls)
	
	def __init__(self):
		print("New Class __init__")
		super().__init__()


# 可以观察一下以上元类模板和使用元类定义的类的执行顺序
obj = ANewClass()

常见设计模式的实现

设计模式是经过长时间总结、优化过的针对常见编程问题的解决方案。设计模式提供了在特定情形下编制方法的模板,其并不会绑定具体的编程语言。在常见的23种设计模式中,Python也提供了具有自身特色的实现方法。这里从23种设计模式中拣选一些比较常用的设计模式进行简要的介绍。

工厂方法

当一个类或者函数不知道它所需要创建的对象时,可以责成它的子类来创建目标对象,通过联合不同的子类,类或者函数可以根据需要使用相应的子类来创建所需要的对象。就好像是一个工厂根据消费者的需要进行定制化生产一样。

以下提供一个工厂方法的实现示例,在实际编程中,可以参考这个示例来编写实际的代码。

class Cake:
	def __init__(self):
		self.name = "cake"
		
	def whoami(self):
		return self.name

	
class Waffle:
	def __init__(self):
		self.name = "waffle"
	
	def whoami(self):
		return self.name
	

def food_factory(need="cake"):
	bakers = {"cake": Cake, "waffle": Waffle}
	return bakers[need]()


product = food_factory("waffle")
print(product.whoami())

构造者

一个复杂系统中的对象,往往都十分复杂。这种复杂对象在进行创建时,总是十分麻烦的。尤其是使用构造函数进行初始化,需要传递很长的参数,这常常会使人忘记每一个参数的用途和目的。将一个复杂对象的构建和表示分离,是构造者模式最主要的目标。

以下提供一个构造者模式的示例。

class Cake:

	__name = ""
	__taste = "sweet"
	
	def __init__(self):
		pass
	
	@property
	def taste(self):
		return self.__taste
	
	@taste.setter
	def taste(self, value):
		self.__taste = value
	
	@property
	def name(self):
		return self.__name
	
	@name.setter
	def name(self, value):
	self.__name = value
	
	def __str__(self):
		return "{0} taste {1}".format(self.__name, self.__taste)


class CakeBuilder:
	
	def __init__(self):
		self.__cake = Cake()
	
	def name(self, value):
		self.__cake.name = value
		return self
	
	def taste(self, value):
		self.__cake.taste = value
		return self
	
	def build(self):
		return self.__cake


waffle = CakeBuilder().name("Waffle").taste("sweet").build()
print(waffle)

适配器

适配器用于将一个类暴露出的接口转换成所希望的接口,主要用于使原本因为接口不兼容的类能够协同工作。

假如有以下一个类。

class Car:
	def __init__(self):
		self.name = "car"
	
	def horn(self):
		return "WoWo"

但是我们希望像下面这样进行调用。

def main():
	print("{0} can {1}".format(something.name, somgthing.make_noise()))

但是可以看到,类Car中是没有make_noise()方法的,所以需要定义下面这样一个适配器类来完成所期望的使用方式。

class Adapter(object):
	def __init(self, obj, adapted_method):
		self.obj = obj
		self.__dict__.update(adapted_methods)
	
	def __getattr__(self, attr):
		return getattr(self.obj, attr)


def main():
	vehicle = Car()
	something = Adapter(vehicle, dict(make_noise=horn))
	print("{0} can {1}".format(something.name, somgthing.make_noise()))

代理

代理模式可以为其他对象提供一个代理对象来实现对目标对象的访问。代理模式常用于是用比较通用和复杂的对象替代简单的指针时使用。

代理模式与适配器模式十分的相似,都可以将目标对象转换为另一种访问模式。但是代理类相较适配器类,拥有更高的权限,它甚至可以决定是否要调用目标对象的方法,或者组合目标对象的方法来解决一个功能调用。而适配器类则只是完成接口的转换功能。

读者可以在适配器类的基础上增加更高权限的处理代码来将一个适配器类改变为代理类以体会其中的区别。

观察者

观察者模式号称是其他设计模式之母,所以可以看出其地位的重要。观察者模式主要用于定义对象间的一对多关系,当一个对象的状态发生变化时,其他所有对其依赖的对象都将获得通知并自动更新。观察者模式可以演变出许多常见的设计模式,例如生产-消费模式等。

以下给出一个观察者模式的示例。

class Subject:
	def __init__(self):
		self._observers = []
	
	def attach(self, observer):
		if not observer in self._observers:
			self._observers.append(observer)
	
	def detach(self, observer):
		try:
			self._observers.remove(observer)
		except ValueError:
			pass
	
	def notify(self, modifier=None):
		for observer in self._observers:
			if modifier != observer:
				observer.update(self)


class Data(Subject):
	def __init__(self, name=""):
		Subject.__init__(self)
		self.name = name
		self._data = 0
		
	@property
	def data(self):
		return self._data
	
	@data.setter
	def data(self, value):
		self._data = value
		self.notify()


class Viewer1:
	def update(self, subject):
		print("In view 1: {0}".format(subject.data))
	

class Viewer2:
	def update(self, subject):
		print("In view 2: {0}".format(subject.data))


def main():
	data1 = Data('Data 1')
	view1 = Viewer1()
	view2 = Viewer2()
	data1.attach(view1)
	data1.attach(view2)
	
	data1.data = 10
	data1.data = 15

组合

组合模式主要用于表示对象的部分-整体层次的结构。组合后的对象与单个对象并无什么不同,组合结构中的所有对象将被统一的使用。

以下举一个简单的示例。

class Component:
	def __init__(self, name):
		self.str_name = name
	
	def add(self, com):
		pass
	
	def display(self, depth):
		pass


class Leaf(Component):
	def add(self, com):
		print("Leaf cannot add anything")
	
	def display(self, depth):
		strtemp = "-" * depth
		strtemp = strtemp + self.str_name
		print(strtemp)


class Branch(Component):
	def __init__(self, name):
		self.str_name = name
		self.c = []
	
	def add(self, com):
		self.c.append(com)
	
	def display(self, depth)
		strtemp = "-" * depth
		strtemp = strtemp + self. str_name
		print(strtemp)
		for com in self.c:
			com.display(depth+2)


root = Branch("root")
root.add(Leaf("leaf-1"))
root.add(Leaf("leaf-2"))
branch = Branch("branch-1")
branch.add(Leaf("dead leaf"))
root.add(branch)
root.display()

单例

当一个类只能有一个实例,并且需要提供一个全局访问点时,就可以使用单例模式来定义类。

在Python中单例有多种定义方式,这里选取最简单的一种定义和使用修饰器的线程安全的定义方式进行举例。

首先看最简单的定义方式。

class Singleton(object):
	def __new__(cls, *args, **kwargs):
		if not hasattr(cls, '_instance'):
			org = super(Singleton, cls)
			cls._instance = org.__new__(cls, *args, **kwargs)
		return cls._instance


class Factory(Singleton):
	def __init__(self, name):
		self.name = name
	
	def __str__(self):
		return self.name


obj1 = Factory("Fac1")
print(id(obj1))
obj2 = Factory("Fac2")
print(id(obj2))

最简单的定义方式定义出的单例类面临最大的问题就是线程安全,当有两个或以上的线程同时对单例类实例进行操作时,就可能会出现不可预知的潜在问题。所以使用线程安全的单例定义会更加稳妥。这里会先使用后文才会介绍到的线程,具体多线程处理的内容可参考后面的章节。

import functools
import threading


def singleton(cls):
	""" 用于标记单例类的修饰器 """

	cls.__new_original__ = cls.__new__

	@functools.wraps(cls.__new__)
	def singleton_new(cls, *args, **kw):
		it = cls.__dict__.get('__it__')
		if it is not None:
			return it

		cls.__it__ = it = cls.__new_original__(cls, *args, **kw)
		it.__init_original__(*args, **kw)
		return it
	
	cls.__new__ = singleton_new
	cls.__init_original__ = cls.__init__
	cls.__init__ = object.__init__
	
	return cls


def synchronized(func):
	""" 为普通函数增加线程同步功能的修饰器 """

	func.__lock__ = threading.Lock()
	
	def synced_func(*args, **kwargs):
		with func.__lock__:
			return func(*args, **kwargs)
	
	return synced_func


@singleton
class ProgramState:
	
	__activated = False
	
	def __init__(self):
		self.__activated = False
	
	@property
	def activated(self):
		return self.__activated
		
	@synchronized
	@activated.setter
	def activated(self, value):
		self.__activated = value

原型

当程序中需要实例化的类是在运行时才能确定时,就可以使用原型来创建新的对象。原型实例可以持有多种种类的对象,并可以通过拷贝这些对象来创建新的对象。在Python中实现原型模式十分简单,可参考以下示例。

import copy


class Prototype:
	def __init__(self):
		self._objects = {}
	
	def register_object(self, name, obj):
		self._objects[name] = obj
	
	def unregister_object(self, name):
		del self._objects[name]
	
	def clone(self, name, **attr):
		obj = copy.deepcopy(self._objects.get(name))
		object.__dict__.update(attr)
		return obj


class Cake:
	def __str__(self):
		return "This is a cake."


cake = Cake()
prototype = Prototype()
prototype.register_object("cake", cake)
another_cake = prototype.clone("cake", taste="sweet")

print(cake)
print(cake.taste)

状态

状态模式允许一个对象在其内部状态改变时,改变其行为,看起来其内部实现像是被修改了一样。状态模式适用于一个操作中含有庞大的多分支条件语句,并且这些分支依赖于对象的状态。这些状态通常都使用一个或者多个枚举常量表示。状态模式将每一个条件分支放入一个独立的类中,这就使得你可以根据对象自身的情况将对象的状态作为一个对象,使对象可以不依赖于其他对象而独立变化。

以下给出一个状态模式的示例。

class State(object):
	"""这是一个基本状态,用于在状态间共享功能"""
	
	def scan(self):
		self.pos += 1
		if self.pos == len(self.stations):
			self.pos = 0
		print("Scanning...")


class AMState(State):
	def __init__(self, radio):
		self.radio = radio
		self.stations = ["15", "38", "96"]
		self.pos = 0
		self.name = "AM"
	
	def toggle_am_fm(self):
		print("Swtich AM -> FM")
		self.radio.state = self.radio.fmstate


class FMState(State):
	def __init__(self, radio):
		self.radio = radio
		self.stations = ["81", "99", "106"]
		self.pos = 0
		self.name = "AM"
	
	def toggle_am_fm(self):
		print("Swtich AM -> FM")
		self.radio.state = self.radio.amstate


class Radio(object):
	def __init__(self):
		self.amstate = AMState(self)
		self.fmstate = FMState(self)
		self.state = self.amstate
	
	def toggle_am_fm(self):
		self.state.toggle_am_fm()
	
	def scan(self):
		self.state.scan()


radio = Radio()
actions = [radio.scan] * 2 + [radio.toggle_am_fm] + [radio.scan] * 2
actions = actions * 2

for action in actions:
	action()

中介

中介模式用于解除各个对象间的显式相互引用,松开紧耦合,从而使它们之间的交互可以变得更加独立。中介模式尤其适用于一组对象以定义好的复杂方式通信,或者一个对象引用其他很多对象并直接与它们进行通信的情况。

中介类作为统领其他各个对象的核心,对其他所有相关的对象进行引用和依赖,其他各个对象仅需要处理自己的任务。组合这些对象来完成复杂问题的任务由中介类组织和执行,被统领的类不必关心其他相邻类的情况。

该模式比较容易理解和编写,读者可自行设想一下其实际的应用场景。

责任链

责任链模式用于解除请求事件发送者与处理者之间的关系,能够处理请求的对象会连成一条链,并且沿着这条链来传递请求,直到有一个对象获取并处理了请求为止。可以想象一下击鼓传花游戏的场景。

以下给出一个简单的示例。

class Handler:
	def handle(self, request):
		pass
	
	def next(self, next):
		self.next = next
		return self.next
	
	def forward(self, request):
		self.next.handle(request)


class HandlerOne(Handler):
	def handle(self, request):
		if request.type == 'type1':
			print("Handler 1 process")
		else:
			self.forward(request)


class HandlerTwo(Handler):
	def handle(self, request):
		if request.type == 'type2':
			print("Handler 1 process")
		else:
			print("end of chain")


requests = ["type1", "type2", "type3"]
h1 = HandlerOne()
h1.next(HandlerTwo())
for request in requests:
	h1.handle(request)

装饰

装饰模式可以动态的给一个对象添加职责,尤其是在不能大量添加子类的情况下。相对于使用子类来扩展功能,装饰模式更加灵活。并且装饰模式可以在不影响其他对象的前提下,动态、透明的给单个对象添加功能,并且可以处理可撤销的功能。

下面给出一个装饰模式的示例。

class foo(object):
	def f1(self):
		print("fucntion 1")
	
	def f2(self):
		print("function 2")


class foo_decorator(object):
	def __init__(self, decoratee):
		self.__decoratee = decoratee
	
	def f1(self):
		print("decorated funciton 1")
		self._decoratee.f1()
	
	def __getattr__(self, name):
		return getattr(self._decoratee, name)


u = foo()
ud = foo_decorator(u)
ud.f1()
ud.f2()

享元

享元模式用于对大量细粒度对象的支持。一个应用中如果使用了大量的对象,会造成很大的存储开销。由于享元对象可以被共享,所以可以使用相对较少的享元对象来替代组对象。

以下给出一个享元模式的示例,读者可以在交互式解释器中实验来体会享元模式的用途。

import weakref


class Card(object):
	_CardPool = weakref.WeakValueDictionary()
	
	def __new__(cls, value, suit):
		obj = Card._CardPool.get(value + suit, None)
		if not obj:
			obj = object.__new__(cls)
			Card._CardPool[value + suit] = obj
			obj.value, obj.suit = value, suit
		return obj
	
	def __str__(self):
		return "Card: {0} {1}".format(self.value, self.suit)


c1 = Card('1', 'h')
c2 = Card('2', 'h')
print(c1 == c2)
print(id(c1), id(c2))

库的使用

一门语言自身所能提供的不可能是覆盖方方面面的功能,Python也是如此。但是Python能够完成的功能却是方方面面的,这全部都是Python的各种功能库的功劳。通过挂载不同的功能库,Python能够完成不同的功能。

Python自身也提供了一个标准库,来提供一些功能的默认实现,大部分情况下这个标准库已经够用。但是其他的功能库则提供了更加专业和精确的功能。

功能库的安装

功能库的安装方法曾经有多种方式,目前最为常用的方式是通过pip进行安装。pip会自动将功能库安装到Python安装目录的指定目录下,一般为安装目录的site-packages目录中。其中pip的常用命令已经在前面介绍过,有需要可以到前面去参考。

库功能的查询

Python提供了dir()函数,可以用来列出模块内定义的全部名称,它会以字符串列表形式返回结果。dir()在使用前,需要先使用import引入。例如:

import collections

dir(collections)

对于详细文档内容的查询,可以使用help()来完成。在help()查询界面,可以按Q键退出。help()函数可以列出目标包、模块的docstring。

所以在编写代码时,可以在交互式解释器中使用以上函数来做文档的查询。

此外访问Python Docs也可以查看详细的Python标准库功能以及语言功能。

标准库功能概表

Python的标准库覆盖的功能十分广泛,这里没有具体的空间进行详述。所以这里仅给出一个常用列表,用以说明标准库中各个模块的功能,以方便读者在使用时查询使用。标准库各个功能的详细文档可在Python标准库索引查询。

  1. 文字处理服务
    1. string,普通字符串操作
    2. re,正则表达式操作
    3. difflib,增量运算辅助操作
    4. textwrap,文本包装与填充
    5. unicodedata,Unicode数据处理
    6. stringgrep,互联网字符串预处理
    7. readline,GNU readline接口
    8. rlcompleter,GNU readline接口的完整实现
  2. 二进制数据服务
    1. struct,将字节数组解释为打包的二进制数据
    2. codecs,编码器注册和基础类
  3. 数据类型
    1. datetime,日期时间
    2. calendar,日历相关操作
    3. collections,集合数据类型
    4. collections.abc,抽象集合类型
    5. heapq,堆队列算法
    6. bisect,数组分割算法
    7. array,高效数值数组
    8. weakref,弱引用
    9. types,动态类型创建与内部类型名称
    10. copy,浅拷贝与深拷贝
    11. pprint,美化后的数据输出
    12. reprlib,附加的repr实现
    13. enum,枚举类型支持
  4. 数字与数学
    1. numbers,抽象数字类
    2. math,数学计算功能
    3. cmath,复数计算功能
    4. decimal,精确浮点计算功能
    5. fractions,分数计算功能
    6. random,随机数功能
    7. statistics,统计数学计算功能
  5. 函数式编程
    1. itertools,迭代器功能
    2. functools,高级函数功能
    3. operator,标准运算符号
  6. 文件存取
    1. pathlib,面向对象的文件系统路径
    2. os.path,通用文件路径处理
    3. fileinput,打开多个文件的处理
    4. stat,解析stat()的结果
    5. filecmp,文件与目录的比较
    6. tempfile,临时文件与目录的处理
    7. glob,Unix式路径通配符扩展
    8. fnmatch,Unix式文件通配符扩展
    9. linecache,文件行的随机存取
    10. shutil,高级文件操作
    11. macpath,macOS 9路径处理功能
  7. 数据持久化
    1. pickle,对象序列化
    2. copyreg,注册pickle支持的功能
    3. shelve,对象持久化
    4. marshal,Python内部对象序列化
    5. dbm,Unix数据库接口
    6. sqlite3,SQLite数据库的DB-API 2.0接口
  8. 数据压缩与打包
    1. zlib,gzip兼容压缩
    2. gzip,gzip文件支持
    3. bz2,bzip2压缩支持
    4. lzma,LZMA压缩算法
    5. zipfile,zip压缩包操作
    6. tarfile,tar压缩包操作
  9. 文件格式
    1. csv,csv文件操作
    2. configparser,配置文件解析
    3. netrc,netrc文件处理
    4. xdrlib,xdr文件编解码
    5. plistlin,生成与解析macOS的.plist文件
  10. 加密
    1. hashlib,散列支持
    2. hmac,键控散列算法
    3. secrets,密钥生成
  11. 操作系统服务
    1. os,通用操作系统接口
    2. io,流操作核心工具
    3. time,时间转换
    4. argparse,命令行解析
    5. getopt,C样式命令行处理
    6. logging,日志功能
    7. logging.config,日志配置
    8. logging.handlers,日志处理
    9. getpass,密码输入支持
    10. curses,终端输出控制
    11. curses.textpad,curses输入组件
    12. curses.ascii,curses的ASCII工具
    13. curses.panel,curses面板扩展
    14. platform,操作系统识别及专属功能
    15. errno,标准系统错误码
    16. ctypes,外接功能库支持
  12. 并行操作
    1. threading,线程基础的并行处理
    2. multiprocessing,进程基础的并行处理
    3. concurrent,并行任务库
    4. concurrent.futures,并行任务启动
    5. subprocess,子进程管理
    6. sched,事件控制器
    7. queue,并行队列
    8. dummy\_threading,线程的替换类
    9. \_thread,低级别线程API
    10. \_dummy\_thread,低级别线程API替换类
  13. 网络交互
    1. socket,套接字接口
    2. ssl,TLS/SSL接口
    3. select,IO等待处理
    4. selectors,高级IO等待处理
    5. asyncio,异步IO、事件循环、协程、任务
    6. asyncore,异步套接字处理
    7. asynchat,异步套接字命令/回复处理模型
    8. signal,异步事件处理
    9. mmap,内存映射文件支持
  14. 互联网数据处理
    1. email,Email与MIME处理
    2. json,JSON编解码器
    3. mailcap,Mailcap文件处理
    4. mailbox,mailboxes处理
    5. mimetypes,文件对MIME类型映射处理
    6. base64,Base16、Base32、Base64、Base85编码器
    7. binhex,binhex4文件编解码器
    8. binacsii,ASCII与二进制转换
    9. quopri,MIME可打印数据的编解码
    10. uu,uuencode文件编解码器
  15. 标记文本处理
    1. html,HTML文件支持
    2. html.parser,HTML文件解析
    3. html.entities,HTML内容实体
    4. xml,XML文件支持
    5. xml.dom,XML DOM支持
    6. xml.sax,XML SAX解析器
  16. 互联网协议支持
    1. webbrowser,浏览器控制器
    2. cgi,通用网关接口支持
    3. wsgiref,WSGI工具
    4. urllib,URL处理工具
    5. urllib.request,URL请求处理
    6. urllib.response,URL回应处理
    7. urllib.parser,URL解析
    8. urllib.error,URL请求错误
    9. urllib.robotparser,robot.txt解析器
    10. http,HTTP模块
    11. http.client,HTTP客户端
    12. ftplib,FTP客户端
    13. imaplib,IMAP客户端
    14. nntplib,NNTP客户端
    15. smtplib,SMTP客户端
    16. smtpd,SMTP服务器
    17. telnetlib,Telnet客户端
    18. uuid,RFC 4122 UUID对象支持
    19. socketserver,网络服务器框架
    20. http.server,HTTP服务器
    21. http.cookies,HTTP状态管理
    22. http.cookiejar,HTTP Cookies处理
    23. xmlrpc,XML-RPC处理模块
    24. xmlrpc.client,XML-RPC客户端
    25. xmlrpc.server,XML-RPC服务器
    26. ipaddress,IPv4/IPv6处理库
  17. 多媒体服务
    1. audioop,RAW音频处理
    2. wave,WAV文件处理
    3. colorsys,颜色系统转换
    4. imghdr,图像类型判断
    5. sndhdr,音频文件类型判断
    6. ossauiodev,OSS兼容音频设备存取
  18. 国际化
    1. gettext,多语言国际化服务支持
    2. locale,国际化服务
  19. 编程框架
    1. turtle,Turtle图形支持
    2. cmd,命令行解析
    3. shlex,基本词法支持
  20. GUI框架
    1. tkinter,Tcl/Tk接口
    2. tkinter.ttk,主题化Tk组件
    3. tkinter.tix,Tk扩展组件
    4. tkinter.scrolledtext,滚动文本组件
  21. 开发者服务
    1. pydoc,文档生成系统
    2. unittest,单元测试框架
    3. unittest.mock,mock对象库
    4. 2to3,Python2到Python3的自动代码转换器
  22. 运行时服务
    1. sys,系统功能访问
    2. sysconfig,Python配置信息访问
    3. builtins,内置对象访问
    4. __main__,顶级脚本环境
    5. warnings,警告管理
    6. contextlibwith语句工具
    7. abc,抽象基础类
    8. atexit,退出控制器
    9. gc,垃圾回收器接口

常用库列表

这里收集了一些网络上可以获取到的常用库名称,可以在网上搜索查看这些库的使用方法。后文将拣选一些开发中比较常用的库做详细介绍。

  1. algotithms,常用算法组合模块
  2. virtualenv,虚拟运行环境
  3. flask-oauthlib,用于Flask的OAuth 1.0,OAuth 2.0的实现
  4. pandas,高性能数据分析器
  5. matplotlib,2D绘图库
  6. psycopg2,PostgreSQL驱动
  7. pymysql,MySQL驱动
  8. pymssql,SQL Server驱动
  9. redis-py,Redis驱动
  10. moment,类似于Moment.js的日期处理库
  11. pendulum,日期时间处理库
  12. TensorFlow,Google的深度学习库
  13. PyTorch,深度学习框架
  14. Theano,快速数字计算库
  15. Keras,基于TensorFlow和Theano的深度学习库
  16. PyInstaller,跨平台可执行程序转换器
  17. curses,ncurses封装,用于创建终端图形界面
  18. PyQT,QT框架的Python绑定
  19. kivy,多平台NUI应用支持
  20. PyGObject,GTK+ 3绑定
  21. wxPython,wxWidget的Python绑定
  22. pyglet,游戏开发框架
  23. PySDL2,SDL2库的Python绑定
  24. pillow,PIL图像处理库实现
  25. python-qrcode,QR码实现
  26. nude.py,裸体检测
  27. SQLAlchemy,ORM库
  28. Django,HTTP Web框架
  29. Tornado,HTTP Web框架
  30. Flask,轻型HTTP Web框架
  31. NumPy,科学计算库
  32. SciPy,科学、工程计算库
  33. pyexcel,csv、ods、xls、xlsx文件处理库
  34. openpyxl,用于读写Excel xlsx文件的的库
  35. python-docx,用于读写Word docx文件的库
  36. PyYAML,YAML解析器
  37. NLTK,自然语言处理平台
  38. jieba,中文分词工具
  39. requests,人性化HTTP请求处理库
  40. pickleDB,轻量级键值存储数据库
  41. BeautifulSoup,Python风格HTML与XML文件处理库
  42. gevent,基于协程的Python网络库

并行计算

现代操作系统都是支持多任务的操作系统,也就是说操作系统可以同时运行多个任务。而且目前多核CPU已经非常普及,即便是以前的单核CPU也可以执行多任务。

操作系统执行多任务的原理就是轮流让各个任务交替执行,术语称为时间片轮转。这样在CPU高速的执行下,我们就感觉所有任务都在同时执行一样。真正的并行执行只能在多核CPU上实现,但是任务数量远远多于CPU核心数量,所以在每个CPU核心上依旧是采用轮转的运行方式。

对于操作系统来说,一个任务就是一个进程。但是一个进程不见得同时只做一件事,进程内的子任务就称为线程。所以一个进程至少有一个线程。我们之前编写的Python程序都是单任务的,也就是只有一个线程。如果需要执行多个任务就有三种解决方案:

  • 启动多个进程,由多个进程同时执行并行任务。
  • 启动多个线程,由多个线程同时执行并行任务。
  • 启动多个进程,每个进程再启动多个线程。

第三种解决方案一般过于复杂,很少采用。Python既支持多进程,也支持多线程,如何选择取决于我们的程序设计。但是始终要记得,如何调度进程和线程,完全由操作系统决定,程序自己不能决定什么时候执行,以及执行多长时间。

多进程的最大优点是稳定性高,一个子进程的崩溃不会影响主进程和其他子进程。但多进程的缺点就是进程创建代价较高,操作系统中进程总数还是有上限的。多线程的模式通常会比多进程快一些,但也只是有限的一点儿,而且任何一个线程出现问题,都可能造成整个进程的崩溃。

所以采用并行处理的形式是受任务类型影响的,计算密集型和IO密集型是不一样的。计算密集型任务主要消耗CPU的资源,所以需要高效的利用CPU,通常会选择C语言编写并且使并行计算任务数量等于CPU的核心数量。IO密集型则相反,消耗CPU资源少,但需要等待网络和磁盘等操作完成,所以此时Python是首选,并且可以选择多线程来完成。

多进程

Unix/Linux 系统提供了一个fork()的系统调用。这个调用比较特殊,普通的函数调用一次,返回一次。但是fork()调用一次,返回两次,因为操作系统自动把当前进程(父进程)复制了一份(子进程),然后分别在父进程和子进程中返回。

其中子进程中始终返回 0,而父进程中返回子进程的进程 ID。这是因为父进程可以通过返回的子进程 ID 来对子进程进行控制。Python 的os模块封装了这个系统调用,可以在 Python 中轻松的创建子进程。

但是需要注意的是,Windows 系统没有fork()调用,所以在 Windows 上是不能使用这个调用的。

Python 的multiprocess模块提供了跨平台的多进程功能,其中也包括了 Windows 的多进程支持。multiprocess模块中提供的 Process 类代表一个进程对象。以下示例演示了multiprocess模块的使用。

from multiprocess import Process
import os

def run_proc(name):
	print('Run child process %s (%s)' % (name, os.getpid()))

if __name__ == '__main__':
	print('Parent process %s' % os.getpid())
	p = Process(target=run_proc, args=('test',))
	p.start()
	p.join()
	print('Child process end')

.start()方法可以启动 Process 实例,.join()可以等待子进程结束后继续执行。这里要注意的是由于 Windows 下没有fork(),所以 Python 需要模拟一个fork(),这就需要先使用pickle序列化所有对象再传递到子进程中,所以在 Windows 上出现子进程创建失败,要优先考虑pickle失败的情况。

要启动大量的子进程,可以使用进程池的方式批量创建子进程,这需要用到multiprocess模块提供的 Pool 类。以下示例演示了进程池的使用,读者可以自行试验其效果。

from multiprocess import Pool
import os, time, random

def long_time_task(name):
	print('Run task %s (%s)' % (name, os.getpid()))
	start = time.time()
	time.sleep(random.random() * 10)
	end = time.time()
	print('Task %s runs %0.2f seconds' % (name, (end - start)))

if __name__ == '__main__':
	print('Parent process %s' % os.getpid())
	p = Pool(4)
	for i in range(5):
		p.apply_async(long_time_task, args=(i,))
	print('Waiting for all subprocess done')
	p.close()
	p.join()
	print('All done')

对 Pool 对象调用.close()方法会等待所有子进程运行完毕,调用.join()之前需要先调用.close()结束向 Pool 中添加新的 Process。

前面提到过,父进程需要对子进程进行控制,这不仅包括启动和结束的控制,还包括输入和输出的控制。subprocess模块提供了启动一个子进程,之后控制其输入输出的能力。例如:

import subprocess

print('> nslookup www.baidu.com')
r = subprocess.call(['nslookup', 'www.baidu.com'])
print('Exit code %d' % r)

如果子进程需要输入,可以通过.communicate()方法输入。

进程间的通信是通过multiprocess模块提供的 Queue、Pipes 等方式来交换数据的。

多线程

由于线程是操作系统直接支持的最小执行单元,所以一般的高级语言都内置线程的支持。Python标准库提供了_threadthreading两个模块,其中_thread是低级模块,threading是高级模块,大部分情况下只需要使用threading模块即可。

启动一个线程就是将一个函数传入并创建Thread实例,然后调用.start()开始执行。具体可参考以下示例。

import time, threading

def loop():
	print('thread %s is running' % threading.current_thread().name)
	n = 0
	while n < 5:
		n += 1
		print('thread %s >> %s' % (threading.current_thread().name, n))
		time.sleep(10)
	print('thread %s ended' % threading.current_thread().name)

print('thread %s is running' % threading.current_thread().name)
t = threading.Thread(target=loop, name='LoopThread')
t.start()
t.join()
print('thread %s ended' % threading.current_thread().name)

数据同步

多线程和多进程最大的不同在于多进程中,同一个变量各自有一份拷贝在每个进程中,互不影响;而多线程中,所有变量都由所有线程共享。所以这就导致了一个问题,任何一个变量都可以被任何一个线程修改,因此线程使用中最大的危险在于多个线程同时操作一个变量造成的内容混乱。

解决这个问题的一种方案是使用锁。threading模块中就提供了Lock类来完成这个操作。具体使用可参考以下示例。

import time, threading

balance = 0
lock = threading.Lock()

def change(n):
	global balance
	balance += n
	balance -= n

def run_thread(n):
	for i in range(100000):
		lock.acquire()
		try:
			change(n)
		finally:
			lock.release()

t1 = threading.Thread(target=run_thread, args=(5,))
t2 = threading.Thread(target=run_thread, args=(8,))
t1.start()
t2.start()
t1.join()
t2.join()
print(balance)

当多个线程同时执行lock.acquire()时,只有一个线程能够得到锁,然后继续执行代码。获得锁的线程在结束的时候必须释放锁,即调用lock.release(),否则其他未获得锁的线程将永远等待,造成死锁。锁的使用实际上降低了执行效率,在实际使用中需要仔细考虑数据同步的问题。

在一个线程中,使用自己的局部变量肯定比使用全局变量要好,因为局部变量只有自己可见,而全局变量还需要加锁,但是局部变量也存在调用时参数传递繁琐的问题。threading模块中提供的ThreadLocal类就提供了在全局为每个线程保存数据的功能。具体使用可参考以下示例。

import threading

local_school = threading.local()

def process_student():
	std = local_school.student
	print('Hello, {} (in {})'.format(std, threading.current_thread().name))

def process_thread(name):
	local_school.student = name
	process_student()

t1 = threading.Thread(target=process_thread, args=('Kate',), name='Thread-t1')
t2 = threading.Thread(target=process_thread, args=('Michael',), name='Thread-t2')
t1.start()
t2.start()
t1.join()
t2.join()

分布式进程

在Thread和Process中,应该首选Process,其中不止因为Process更加稳定,而且Process可以分布到多台机器上,而Thread只能局限在一台机器上。Python的multiprocess模块中managers子模块支持把多进程分布到多台机器上,一个服务进程可以作为调度器将任务依靠网络通信分布到其他多个进程中。

首先看服务进程的示例。

import random, time, queue
from multiprocess.managers import BaseManager
# 设置发送和接受的队列
task_queue = queue.Queue()
result_queue = queue.Queue()

class QueueManager(BaseManager):
	pass

# 注册两个队列
QueueManager.register('get_task_queue', callable=lambda: task_queue)
QueueManager.refister('get_result_queue', callable=lambda: result_queue)
# 绑定端口并设置验证
manager = QueueManager(address=('', 5000), authkey=b'abc')
# 启动队列
manager.start()
# 获取网络访问的对象
task = manager.get_task_queue()
result = manager.get_result_queuq()
# 部署任务
for i in range(10):
	n = random.randint(0, 10000)
	print('Put task {}'.format(n))
	task.put(n)
# 从接受队列读结果
print('Try get result')
for i in range(10):
	r = result.get(timeout=10)
	print('Result {}'.format(r))
manager.shutdown()
print('master exit')

之后可以在另一台机器或者本机运行以下子进程。

import time, sys, queue
from multiprocess.managers import BaseManager

class QueueManager(BaseManager):
	pass

QueueManager.register('get_task_queue')
QueueManager.register('get_result_queue')

server_addr = '127.0.0.1'
print('Connect to {}'.format(server_addr))

m = QueueManager(address=(server_addr, 5000), authkey=b'abc')
m.connect()

task = get_task_queue()
result = get_result_queue()

for i in range(10):
	try:
		n = task.get(timeout=1)
		print('run task {0} * {0}'.format(n))
		r = '{0} * {0} = {1}'.format(n, n * n)
		time.sleep(n)
		result.put(r)
	except Queue.Empty:
		print('task queue is empty')

print('worker exit')

此时就可以在两个进程上观察任务的分配和运行结果了。

协程

协程是比线程更加微小的结构,称为Coroutine。一般的函数调用是通过栈实现的,一个线程就是执行一个函数,函数的调用总是一个入口,一个返回,调用顺序是明确的。而协程则不同,协程看上去也是函数,但在执行过程中在函数内部可以中断,转而执行其他的函数,在适当的时候在返回来继续执行。

协程的特点是在一个线程执行,其优势就是有极高的执行效率。因为函数切换不是线程切换,因此没有线程切换的开销,和多线程相比,线程数越多,协程的性能优势就越明显。此外,协程不需要线程锁的机制,因为只有一个线程,所以也不存在写变量冲突。

在利用多核CPU方面,可以采用多进程+协程的方式,充分利用多核并且发挥协程的高效率。Python对协程的支持是通过生成器实现的。

Python的yield不仅可以返回一个值,还可以接收调用者发出的参数。

以下示例使用生产者-消费者模式演示了协程的使用。

def consumer():
	r = ''
	while True:
		n = yield r
		if not n:
			return
		print('[CONSUMER] Consuming {}'.format(n))
		r = '200 ok'

def produce(c):
	c.send(None)
	n = 0
	while n < 5:
		n += 1
		print('[PORODUCER] Producing {}'.format(n))
		r = c.send(n)
		print('[PRODUCER] Consumer return {}'.format(r))
	c.close()

c = consumer()
produce(c)

这里需要注意以下几点:

  1. consumer是一个生成器,将其传入producer后,调用c.send(None)启动生成器;
  2. 一旦产生了内容,通过c.send(n)切换到consumer的执行;
  3. consumer通过yield拿到消息,处理后又通过yield传回结果;
  4. producer拿到consumer处理的结果,继续产生下一条消息;
  5. producer结束生产时,调用c.close()关闭consumer结束整个过程。

这整个运行过程中是不需要锁的,而且在同一个线程中执行。

IO编程

文件读写是最常见的IO操作,Python标准库中内置了读写文件的函数,用法与C语言是相兼容的。除此之外还提供了字符串、字节数组等非文件内容的IO操作,可以允许用户像读写文件一样操作这些资源。

下面对IO相关的内容进行简单的介绍。

开域资源管理

在开始介绍IO操作之前,先介绍一个Python提供的语句:with(开域语句)。在之前的异常处理一节,我们提到了可以使用try...finally来进行出现异常之后的资源清理操作。但是每次这样书写都十分繁琐,例如:

try:
	f = open('/path/file', 'r')
	print(f.read())
finally:
	if f:
		f.close()

所以Python就提供了with语句,它与try...finally语句的效果是一致的,会自动调用其打开的资源的关闭方法。例如:

with open('/path/file', 'r') as f:
	print(f.read())

当然并不是只有open()函数返回的对象才可以使用with语句,任何对象只要正确的实现了上下文管理,就可以使用于with语句。上下文管理是通过__enter____exit__两个方法实现的。例如:

class AutoCloseable:
	def __init__(self):
		pass
	
	def __enter__(self):
		return self
	
	def __exit__(self, exc_type, exc_value, traceback):
		self.close()
	
	def query(self):
		pass
	
	def close(self):
		pass

或者还可以使用contextlib中的contextmanager来实现。例如:

from contextlib import contextmanager

class AutoCloseable:
	def __init__(self):
		pass
		
	def query(self):
		pass
	
@contextmanager
def create_autocloseable():
	a = Autocloseable()
	yield a

with create_autocloseable() as a:
	a.query()

如果希望在某段代码前后自动执行特定代码,也可以使用@contextmanager实现。例如:

@contextmanager
def tag(name):
	print("<%s>" % name)
	yield
	print("</%s>" % name)

with tag("h1"):
	print("hello")

上述代码的执行顺序为:

  1. with首先执行yield之前的语句。
  2. yield执行with内部的所有语句。
  3. 执行yield之后的所有语句。

因此,@contextmanager可以通过编写生成器来简化上下文管理

文件读写

前面的示例已经提到了open()函数,这个函数可以以指定的模式打开一个文件对象,要使用这个函数,只需要传入文件名和标识符。如果要打开的文件不存在,open()函数会抛出一个IOError并给出详细的错误码和错误信息。open()的使用格式是open(文件路径, 标识符)

打开的文件可以使用.read()方法一次性读取其中的数据,或者使用read(n)来读取指定文件长度,还可以使用.write()来向文件中写入数据,最后需要使用.close()方法关闭文件对象。为了操作的简便,可以使用with语句来完成文件的操作。

标识符是一个可以组合的字符串,其中使用几个字母来表示打开的模式。常见的组合有以下若干:

标识符模式
r只读模式,指针在头部
rb二进制只读模式,指针在头部
r+读写模式,指针在头部
rb+二进制读写模式,指针在头部
w只写模式,如果文件存在则覆盖,如果不存在则新建
wb二进制只写模式,如果文件存在则覆盖,如果不存在则新建
w+读写模式,如果文件存在则覆盖,如果不存在则新建
wb+二进制读写模式,如果文件存在则覆盖,如果不存在则新建
a追加模式,如果文件已存在则指针在末尾,如果不存在则新建
ab二进制追加模式,如果文件已存在则指针在末尾,如果不存在则新建
a+读写模式,如果文件已存在则指针在末尾,如果不存在则新建
ab+二进制读写模式,如果文件已存在则指针在末尾,如果不存在则新建

open()函数还可以接受一个encoding参数,用于指示打开的文件的编码,以及一个errors参数,用于指示出现错误后的处理方式,例如直接忽略(errors='ignore')。

StringIO

数据的读写不一定全都是文件,也可能是字符串或者字节数组。这跟Java等语言中的流的概念很相似。Python的io模块提供了StringIO类来提供在内存中读写字符串的功能。

StringIO提供使用StringIO()来创建一个可操作的字符串对象,并且可以像读写文件那样对其进行操作。最后可以使用.getvalue()方法来获取最后的字符串。

具体操作可以参考以下示例:

from io import StringIO
f = StringIO()
f.write('hello\n')
f.write(' \n')
f.write('world\n')
print(f.getvalue())
g = StringIO(f.getvalue())
while True:
	s = g.readline()
	if s == '':
		break
	print(s.strip())

BytesIO

StringIO提供的是对字符串的操作,要操作二进制数据,就需要使用BytesIO类。其具体使用与StringIO类似。

from io import BytesIO
f = BytesIO()
f.write("你好".encode('utf-8'))
print(f.getvalue())

文件和目录

文件和目录的操作都是由操作系统完成的,Python中的os模块可以直接调用操作系统提供的接口函数来完成目录和文件的操作。这里需要注意的是,os模块提供的功能是操作系统相关的,有些操作系统例如Windows会不支持Linux提供的一些功能,所以在使用之前,要注意操作系统的区别。

调用os.name()可以获得操作系统类型,返回值为'posix'表示系统可能为Linux或者macOS,如果返回值为'nt',则表示系统为Windows。

操作文件和目录的函数一部分放在os模块中,一部分放在os.path模块中。常用的函数有一下这些。

函数功能
os.path.abspath()返回指定目录的绝对路径
os.path.join()拼合目录,会自动根据系统选择路径分隔符
os.path.splittext()拆解目录
os.mkdir()创建目录
os.rmdir()删除目录
os.rename()文件重命名
os.remove()删除文件
os.listdir()列出目录中的内容

例如列出当前目录中的所有子目录,可以使用一行代码完成:[x for x in os.listdir('.') if os.path.isdir(x)]

Python没有直接提供复制文件的函数,如果需要对文件进行复制,只能分别打开源文件和目标文件两个句柄,然后对其中内容进行复制。操作可参考以下示例。

with open(sourceFile, "rb") as source:
	with open(destinationFile, "wb") as destination:
		destination.write(source.read())

序列化

把变量的内容从内存中变成可存储或者可传输的过程称为序列化,反之,将序列化的内容读入内存称为反序列化。

二进制序列化

Python通过pickle模块来实现二进制的序列化。其使用十分简单:

import pickle
d = dict(name='Bob', age=20)
f = pickle.dump(d) # 导出内容
rd = pickle.load(f) # 导入内容

JSON

要在不同的语言之间传递对象,需要把对象序列化为标准格式,例如XML。目前更为流行的格式是JSON。Python内置的json模块提供了完善的Python对象到JSON格式的转换。其使用与pickle相似。

import json
d = dict(name='Bob', age=20)
f = json.dump(d)
rd = json.loads(f)

Python的dict对象可以直接序列化为JSON的{}。如果需要将其反序列化为类实例,需要定义转换方法。例如有以下类:

class Student:
	def __init__(self, name, age, score):
		self.name = name
		self.age = age
		self.score = score

json.dump()接受一个default参数,接收一个转换函数,可以用来将对象转换为dict。例如:

def student2dict(std):
	return {'name': std.name, 'age': std.age, 'score': std.score}

或者还可以有个偷懒的办法:json.dump(object, default=lambda obj: obj.__dict__)。通常类的实例都有一个__dict__属性,用来存储实例变量,但是使用了__slots__的类就不能使用这个办法。

在反序列化的时候,loads()方法首先将其转换出一个dict对象,然后再传入一个函数将其转换为我们需要的实例即可。

异步IO

asyncio是Python 3.4引入的标准库,内置了异步IO的支持。asyncio的编程模型就是一个消息循环,可以从asyncio模块中直接获取一个Eventloop的引用,然后把需要执行的协程放入Eventloop中执行就实现了异步IO。

以下是一个asyncio的简单示例:

import asyncio

@asyncio.coroutine
def hello():
	print('hello world.')
	r = yield from asyncio.sleep(1)
	print('hello again')

loop = asyncio.get_event_loop()
loop.run_until_complete(hello())
loop.close()

@asyncio.coroutine把一个生成器标记为coroutine类型,然后就把这个coroutine放入Eventloop中执行。yield from可以让我们方便的调用另一个生成器,由于示例中asyncio.sleep()也是一个协程,所以线程不会等待,而直接去执行下一个消息循环了。在此期间,主线程并未做任何等待操作,而是直接去执行Eventloop中其他可以执行的协程,因此就实现了并发执行。

在实际应用中,只需要把yield from这里抛出真正消耗时间的IO操作就可以实现多个协程由一个线程执行。

async/await模型

上一节使用asyncio提供的@asyncio.coroutine把一个生成器标记成了coroutine类型,并在coroutine内部使用yield from调用另一个coroutine实现异步操作。这个代码并不容易阅读,所以在Python 3.5引入了async/await语法。

接触过C#和Javascript ES7、Typescript的读者可能会对这个语法比较熟悉。在这些语言中,async/await是典型的异步操作语法。在Python中使用这个语法也很简单,下面用这个新语法来重新写一遍上面的示例。

async def hello():
	print('hello world.')
	r = await asyncio.sleep(1)
	print('hello again')

示例中其余的代码可以保持不变。读者可以对比一下两段代码,是不是简洁了许多。

基本数据库访问

Python对于数据库的访问提供了一套通用的API,称为DB-API。任何数据库只要提供符合这套API的驱动,就能够让Python连接到数据库并完成操作。所以各个数据库之间仅有引入的模块与数据库连接语句不同,其他操作都是共通的。

使用DB-API访问数据库,一般会使用以下几个步骤。

  1. 连接到输出,获得数据库连接;
  2. 打开游标(Cursor);
  3. 通过游标执行SQL语句,获得执行结果;
  4. 关闭游标;
  5. 关闭连接。

由于SQLite的驱动直接内置在Python的标准库中,所以这里以SQLite 3的操作为例来说明DB-API的使用。

import sqlite3

conn = sqlite3.connect('test.db')
cursor = conn.cursor()
cursor.execute('select * from user where name=%s', ('Kate',))
print(cursor.rowcount)
values = cursor.fetchall()
cursor.close()
conn.close()

示例中演示了向SQL语句中传递参数,Cursor是按照参数出现位置来绑定参数的,有几个占位符就需要多少个参数。占位符可以参考使用字符串格式化中的占位符。额外的规则是,%s表示顺序占位,顺序占位需要传入一个元组;%(name)s表示名称占位,可以传入一个字典来归置参数。

DB-API返回的数据结构是一个列表,其中每行数据以元组类型出现。如果需要以类来表示每行数据,则需要后文介绍的ORM框架来实现。

虚拟环境的使用

在同时开发多个应用的时候,可能每个应用都需要各自拥有一套独立的Python运行环境。virtualenv就是用来为一个应用创建一套隔离的Python运行环境的工具,

首先在使用pip install virtualenv后,在项目根目录中使用以下命令创建一个独立的Python运行环境,名为venv:virtualenv --no-site-packages venv

新建的Python环境被放置到当前目录的venv目录下,之后可以使用命令来激活这个环境:source venv/bin/activate,Windows系统的环境启动命令是:venv/Scripts/activate

此时可以看到命令提示符已经发生变化,表示Python环境已经切换。从这里开始就可以正常安装各种第三方包以及运行Python命令了。

如果需要退出venv环境,可以使用deactivate命令。

virtualenv可以解决不同应用间多版本冲突问题。

对于Python 3.5以上版本已经内置了venv功能库,不需要再安装virtualenv功能库了,可以直接在命令行执行命令python3 -m venv .env来将虚拟环境创建在.env目录中。这时要启动虚拟环境,针对不同的命令行环境,可以使用以下不同的启动方式(以虚拟环境安装至venv目录为例)。

命令行环境激活命令退出命令
bash/zshsource venv/bin/activatedeactivate
fish. venv/bin/activate.fishdeactivate
csh/tcshsource venv/bin/activate.cshdeactivate
cmd.exevenv\textbackslash Scripts\textbackslash activate.batvenv\textbackslash Scripts\textbackslash deactivate.bat
PowerShellvenv\textbackslash Scripts\textbackslash Activate.ps1venv\textbackslash Scripts\textbackslash Deactivate.ps1

对于一个项目来说,需要一种能够记录其安装过的依赖库的方法来方便项目的迁移。pip使用requirements.txt文件提供了这个功能。requirements.txt文件中记录了项目中所安装的全部依赖库及其版本,能够在项目迁移时通过一条命令直接恢复项目的运行环境。在建立requirements.txt文件时,尽量在虚拟环境中完成,这样可以防止不被依赖的库被误加入requirements.txt中。

在项目环境已经建立好后,可以在虚拟环境下通过pip freeze > requirements.txt命令来将目前环境中安装的全部依赖库列表导出至requirements.txt中。之后可以使用pip install -r requirements.txt来完成全部依赖库的安装。

Warning

注意,requirements.txt在项目开发过程中要及时更新。

使用pipenv进行集成化虚拟环境管理

前面介绍了使用pip进行Python项目的包管理,而使用virtualenv来给项目运行提供一个虚拟环境。在多数情况下,综合使用这两种工具就已经能够满足项目基础环境管理的需求。但是,使用多种工具毕竟是复杂的,pipenv的出现就将pipvirtualenv两个工具的功能集合在了一起。

如果你了解NodeJS的npm包管理器,那么恭喜你,你会很容易的上手pipenv

安装

Pipenv也是Python的一个库,可以直接使用pip完成安装,其命令是pip install pipenv

安装结束后,就可以直接在命令行下使用pipenv命令来进行项目的环境创建、依赖管理等功能了。

基本概念

Pipenv是Pipfile的主要倡导者、requests库的作者Kenneth Reitz开发的一个命令行工具,主要包含了Pipfilepipclickrequestsvirtualenv几个库。Pipfile是社区拟定的依赖管理文件,用来替代过于简陋的requirements.txt文件。

Pipfile是一个TOML格式文件,而不是与requirements.txt一样的纯文本文件。每个项目对应一个Pipfile,并且支持区分开发环境和生产环境。对其管理的依赖提供了版本锁功能,并保存为Pipfile.lock文件。

使用方法

Pipenv通过pipenv命令搭配子命令以及参数来完成项目创建和依赖管理功能,其通用的命令格式为pipenv [选项] 子命令 [参数]。以下通过完整的创建一个项目来说明pipenv的常见命令使用。

建立项目文件目录

在命令行中新建一个项目目录(例如project),并进入到这个目录中(即cd project)。

创建项目虚拟环境

执行命令pipenv --three可以使用当前系统的Python 3创建环境,或者还可以使用pipenv --python 版本号来创建指定版本Python的环境。

激活虚拟环境

保持位于项目目录下,执行命令pipenv shell可以激活项目虚拟环境,在虚拟环境下执行命令exit可以退出虚拟环境。

依赖库管理

安装依赖库并不需要激活虚拟环境,只需要保持在项目目录下,执行命令pipenv install 库名称,即可安装第三方库。pipenv会自动按照Pipfile区分所在虚拟环境。使用install命令安装第三方库,会自动将第三方库及其版本号加入到Pipfile中。

依赖库默认会安装到生产环境中,但是通常有些库在生产环境中并不需要,这时可以使用带参数的install --dev命令,来将第三方库安装到开发环境中,例如pipenv install --dev django--dev参数可以帮你在一个虚拟环境中区分开发环境和生产环境。

如果当前目录中没有建立虚拟环境,那么pipenv install命令(注意,该命令不带任何第三方库名称)将会自动建立一个新的虚拟环境。如果当前目录中没有虚拟环境,但是存在Pipfile,则将会使用已有Pipfile文件的内容创建一个虚拟环境。

如果需要卸载第三方库,也是十分容易的,只需要执行命令pipenv uninstall 库名称即可。

运行脚本

运行指定脚本同样也不需要激活虚拟环境,但是需要用虚拟环境中的Python解释器。调用虚拟环境中的Python解释器的命令格式为pipenv run python [参数]

所以如果需要运行项目脚本,可以使用命令pipenv run python foo.py

常用子命令

下表中列出了常用的子命令的功能。

子命令功能
check检查项目中的安全漏洞。
graph显示当前虚拟环境中的依赖关系图。
install安装虚拟环境或者第三方库。
lock锁定并生成Pipfile.lock文件。
open在编辑器中查看一个库。
run在虚拟环境中执行命令。
shell激活虚拟环境。
uninstall卸载一个第三方库。
update卸载全部库,并安装它们的最新版本。

常用选项

一般来说pipenv命令的选项并不常用,但是如果需要一些额外的功能,就需要在命令中加入选项,选项一般书写在子命令前面。下表中列出了常用的选项及其功能。

选项功能
--update更新pipenv和pip。
--where显示项目文件所在路径。
--venv显示虚拟环境实际文件所在路径。
--py显示虚拟环境Python解释器的所在路径。
--envs显示虚拟环境的选项变量。
--rm删除虚拟环境。
--three使用Python 3创建虚拟环境。
--two使用Python 2创建虚拟环境。
--site-packages附带安装原Python解释器中的全部第三方库。

更换pipenv的源

在使用pipenv的时候,有时第三方库的安装会变得很慢,这是因为pipenv的源的问题。这时可以打开项目的Pipfile文件,将其中[source]部分中url一项内容更改为以下地址,可以更换项目所使用的源。

  • https://pypi.tuna.tsinghua.edu.cn/simple,清华源;
  • http://mirrors.aliyun.com/pypi/simple/,阿里源;
  • https://pypi.mirrors.ustc.edu.cn/simple/,中国科学技术大学源。

熟悉TOML的人应该知道,[source]这种标记是代表列表的,所以可以在Pipfile里列举多个源

常用内建模块使用

日期处理

datetime是Python处理日期和时间的标准库。以下通过示例来说明常用的功能的用法。

from datetime import datetime, timedelta

now = datetime.now() # 获取当前时间
dt = datetime(2018, 6, 4, 23, 59) # 用指定时间创建datetime
timpstamp = dt.timestamp() # 获取指定时间的Unix时间戳,Python中的时间戳为浮点数
dt2 = datetime.fromtimestamp(timestamp) # 从时间戳转换回datetime,默认使用本地时区
cday = datetime.strptime('2018-6-4 23:08:59', '%Y-%m-%d %H:%M:%S') # 用指定格式解析文本时间串
print(now.strftime(%a, %b %d %H:%M)) # 用指定格式输出时间
print(now + timedelta(hour=6)) # 时间加减运算,timedelta代表时间段

集合

collections是Python内建的集合模块,提供了许多有用的集合类。

namedtuple

命名元组之前已经介绍过了,这里不再赘述。

deque

使用list存储数据的时候,按照索引访问元素很快,但是数据量大的时候,插入和删除的效率很低。deque是为了高效实现插入和删除操作的双向列表,适合用于队列和栈。

defaultdict

当使用dict时,如果引用的键不存在,就会抛出KeyErrordefaultdict可以在键不存在的时候返回一个默认值。

OrderedDict

使用dict时,键是无序的,在对dict做迭代时,键的顺序无法确定。OrderedDict可以保持键的顺序。

Counter

Counter是一个简单的计数器,可以用来进行简单的统计。

Base64编码

Base64是一种用64个字符来表示任意二进制数据的方法。Base64的原理很简单,首先准备一个包含64个字符的数组,然后对二进制数据进行处理,每3个字节一组,划分为4组,每组6个bit。这样可以得到4个数字作为索引,然后查表获得相应的4个字符,这就是编码后的字符串。所以Base64编码会将3字节的二进制数据编码为4字节的文本数据,长度增加33%,但好处是编码后的文本可以在邮件正文、网页等位置直接显示。如果要编码的二进制数据不是3的倍数,Base64会使用\x00字节在末尾补足后,再在编码的末尾加上1个或2个=号,表示补了多少字节,解码时会自动去掉。

Python内置的base64可以直接进行Base64的编解码。

import base64
base64.b64encode(b'binary\x00string')
base64.b64decode(b'YmluYXJ5AHN0cmluZw==')

字节数据打包

Python中没有专门处理字节的数据类型,但由于字节数组可以由二进制字符串表示。而在C语言中,可以很方便的使用structunion来处理字节,以及字节和int,float的转换。

Python提供了一个struct模块来解决bytes和其他二进制数据类型的转换。structpack函数把任意数据类型变成bytes。pack()方法接收两种参数,第一个参数是处理指令,后面是要打包的数据,数据的个数和顺序要与处理指令一致。例如:

import struct
struct.pack('>I', 1024000)

pack()功能相反的方法是unpack(),它接收两个参数,第一个参数依旧是处理指令,第二个参数是要处理的bytes。

处理指令由两部分组成,第一部分是字节序、长度以及对齐标记。默认情况下,C类型会使用机器的原生格式和字节序表示,但是在不同设备之间传递可能就需要变更这些配置。第一部分的控制标记如下:

字符字节序长度对齐
@原生原生原生
=原生标准
>小端序标准
<大端序标准
!网络序标准

剩下的控制字符可以根据编解码内容顺序,选择以下内容。

字符C类型Python类型长度备注
x填充字节无值
ccharbytes1
bsigned charinteger1
Bunsigned charinteger1
?_Boolbool1
hshortinteger2
Hunsigned shortinteger2
iintinteger4
Iunsigned intinteger4
llonginteger4
Lunsigned longinteger4
qlong longinteger8
Qunsigned long longinteger8
nssize_tinteger只用于原生长度
Nsize_tinteger只用于原生长度
efloatfloat2用于半精度浮点
ffloatfloat4
ddoublefloat8
schar[]bytes
pchar[]bytes表示Pascal字符串
Pvoid*integer

sp以外的字符,前面添加数字表示重复次数,例如4h表示hhhhsp前的数字表示其内容长度。

散列算法

Python的hashlib提供了常见的散列算法,如MD5、SHA1等。以下示例以MD5算法演示了hashlib的使用。

import hashlib

md5 = hashlib.md5()
md5.update('hello'.encode('utf-8'))
md5.update('world'.encode('utf-8'))
print(md5.hexdigest())

对于较短的文本,使用一次update()即可,对于较长的内容,则可以分块进行计算。

由于基本的散列算法可以根据彩虹表反推原始内容,所以一般在使用散列算法时都会加盐(salt)。例如md5.update(message + salt)。这样就必须要提供盐值才能完成验证,这就形成了Hmac算法。

Hmac通过标准算法将盐值混入计算过程,可以使得散列后的内容更加安全。Python提供了hmac模块来支持Hmac算法,具体使用可参考以下示例。

import hmac
message = b'Hello world'
key = b'secret'
h = hmac.new(key, message, digestmod='MD5')
print(h.hexdigest())

辅助迭代操作

对于迭代操作,Python提供了itertools模块来提供用于辅助迭代操作的功能。

首先itertools模块提供了几个用于创建无限迭代器的函数。

  • count(),生成自然数序列;
  • cycle(),将传入的序列循环重复下去;
  • repeat(),将一个元素无限循环发送,可以使用第二个参数指定循环次数。

此外,itertools还提供了一些额外的函数来组合迭代对象的内容。

  • takewhile(条件函数, 迭代器),从迭代中根据条件获取一个序列;
  • chain(),将多个迭代对象串联形成更加强大的迭代器;
  • groupby(),将迭代器中的重复元素放在一起,形成字典,其中值形成序列,重复值形成键。

其他itertools中的功能可参考Python文档。

URL

urllib模块提供了一系列的用于操作URL的功能。其中常用的是Get操作和Post操作,这在爬虫应用中是十分常用的。对于HTML的解析,建议使用Beautiful Soup库,因为这个功能库有中文文档,所以这里不再赘述。

Get操作

urllib中的request模块可以非常方便的抓取URL内容,也就是通过GET请求获取相应的HTTP响应。具体使用可参考以下示例。

from urllib import request

with request.urlopen('http://www.baidu.com') as f:
	data = f.read()
	print('Status: {} {}'.format(f.status, f.reason))
	for k, v in f.getheaders():
		print('{}: {}'.format(k, v))
	print('Data: {}'.format(data.decode('utf-8')))

如果需要模拟浏览器发送的GET请求,就需要使用Request对象。Request对象可以添加HTTP头,这样就可以伪装成浏览器了。

将上例改写一下,改成模拟浏览器请求的方式。

from urllib import request

req = reqeust.Request('http://www.baidu.com')
req.add_header('User-Agent', 'xxxxxxxx')

with request.urlopen(req) as f:
	data = f.read()
	print('Status: {} {}'.format(f.status, f.reason))
	for k, v in f.getheaders():
		print('{}: {}'.format(k, v))
	print('Data: {}'.format(data.decode('utf-8')))

Post操作

要发送POST请求,同样是使用request模块,但是需要将要提交的参数以bytes形式传入。例如:

from urllib import request, parse

email = input('Email: ')
passwd = input('Password: ')
login_data = parse.urlencode([
	('username', email),
	('password', passwd),
	('entry', 'mweibo'),
	('client_id', ''),
	('savestate', '1'),
	('ec', '')
])

req = request.Request('https://passport.weibo.cn/sso/login')
req.add_header('Origin', 'https://passport.weibo.cn')
req.add_header('User-Agent', 'Mozilla/6.0 (iPhone; CPU iPhone OS 8_0 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/8.0 Mobile/10A5376e Safari/8536.25')

with request.urlopen(req, data=login_data.encode('utf-8')) as f:
	print('Status:', f.status, f.reason)
	for k, v in f.getheaders():
		print('%s: %s' % (k, v))
	print('Data:', f.read().decode('utf-8'))

调用动态链接库

Python被称为胶水语言,就是因为其强大的调用功能,在功能库的支持下,Python可以调用其他语言的功能。但在实际应用中,Python更多的还是调用C语言编写的动态链接库,因为这会给Python提供强大的计算性能和低级别操作的补充。

对于动态链接库的调用是通过标准库ctypes模块实现的。ctypes可以调用以下几种动态链接库:

  • CDLL,一般共享库,常用于Linux,例如libc.so
  • WinDLL,Windows动态链接库,只用于Windows系统;
  • OleDLL,Windows系统中的OLE动态练级库,只用于Windows系统;
  • PyDLL,与CDLL类似,但是会抛出Python的异常。

假设现在有一个stdcall格式定义的动态链接库test.dll,其定义为:

extern "C"
{
	int _stdcall test(void* p, int len)
	{
		return len;
	}
}

那么在Windows系统中可以按照以下方式调用:

import ctypes
dll = ctypes.windll.LoadLibrary('test.dll')
buf = 'abcdefg'
pStr = ctypes.c_char_p()
pStr.value = buf
ret = dll.test(ctypes.cast(pStr, ctypes.c_void_p).value, pStr.value)
print(ret)

如果动态链接库test.dll(Linux中为test.so)的定义是使用cdecl格式定义的,如:

extern "C"
{
	int _cdecl test(void* p, int len)
	{
		return len;
	}
}

则需要使用相应的类型去调用:

import ctypes
dll = ctypes.cdll.LoadLibrary('test.so')
buf = 'abcdefg'
pStr = ctypes.c_char_p()
pStr.value = buf
ret = dll.test(ctypes.cast(pStr, ctypes.c_void_p).value, pStr.value)
print(ret)

老牌ORM:SQLAlchemy

在Python的世界里,最著名的ORM框架就是SQLAlchemy。

假设现在有数据表user和role,这里将主要以这两个表为例来说明SQLAlchemy的使用。

CREATE TABLE user (
	id varchar(200) not null primary key,
	name varchar(200) not null,
	pass varchar(50) not null,
	age int default 0
)

CREATE TABLE role (
	id int not null primary key,
	user_id varchar(200) not null,
	role varchar(20) not null
)

使用SQLAlchemy主要有以下几步。

  • 导入SQLAlchemy,并初始化DBSession
  • 完成具体表的结构定义;
  • 使用具体表来进行具体数据的操作。

下面来详细介绍每一步的具体实现。

连接数据库

SQLAlchemy的数据库连接是先定义engine,再由engine定义sessionengine的定义可以使用以下语句。

from sqlalchemy import create_engine
engine = create_engine('mysql+mysqlconnector://root:password@localhost:3306/test')

create_engine()接受一个连接串作为参数,连接串的格式为:数据库类型+数据库驱动名称://用户名:口令@服务地址:端口号/数据库名。如果需要连接池支持,可以在create_engine()中使用pool_size来定义连接池大小;此外还可以使用max_overflowpool_recyclepool_timeout等来定义相关的连接池特性。如果需要更换连接池实现需要用poolclass来指定使用何种特性的连接池,连接池的实现可以从sqlalchemy.pool中寻找。

定义engine之后,可以使用以下语句建立适用的Session类。

from sqlalchemy.orm import sessionmaker
Session = sessionmaker(bind=engine)

之后就可以使用session = Session()来获得用于数据库操作的连接会话了。连接会话是用来缓存所有数据库操作的缓存区,在其中对数据表的修改,会在会话提交时采用事务完成数据的更新。

表映射定义

表的映射定义有两种模式:声明式定义(SQLAlchemy ORM)和经典式定义(SQLAlchemy Core)。

声明式定义

声明式定义的表映射需要继承一个基类,这个基类是固定的,可以使用以下语句获得。

from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()

之后就可以用来定义映射类了。

from sqlalchemy import Column, String, Integer

class User(Base):
	__tablename__ = 'user'
	
	id = Column(String(200), primary_key=True, nullable=False)
	name = Column(String(200), nullable=False)
	password = Column('pass', String(50), nullable=False)
	age = Column(Integer)

Column()的第一个参数是指定要映射的数据表列名,这里可以允许映射类的属性与数据表列名不一致。如果不指定列名,则会使用映射类属性名作为数据表列名。

映射类中还可以存在一个__mapper_args__属性,这个属性的类型是一个字典,用以对映射进行配置。这个属性常用的配置项有:

  • column_prefix,指定列名前缀。
  • include_properties,列表类型,用来指定要映射的属性。
  • exclude)properties,列表类型,用来指定不进行映射的属性。
  • primary_key,列表类型,用来指定表的主键列。
  • polymorphic_identity,字符串类型,用来指定区分值,用于单表存储按照类型区分的多个映射类,每个子类都需要指定。
  • polymorphic_on,Column类型,用来指定用于区分多个映射的列,只需要在基类中指定。

经典式定义

经典式定义的表映射则是需要将表描述与映射类手工绑定,上一节的映射示例使用经典式定义则是下面这样的景象。

from sqlalchemy import Table, MetaData, Column, Integer, String
from sqlalchemy.orm import mapper

metadata = MetaData()

user = Table('user', metadata, 
	Column('id', String(200), primary_key=True, nullable=False),
	Column('name', String(200), nullable=False),
	Column('pass', String(50), nullable=False),
	Column('age', Integer)
)

class User(object):
	def __init__(self, id, name, pass, age):
		self.id = id
		self.name = name
		self.password = pass
		self.age = age

mapper(User, user)

在表映射定义上,声明式定义已经简洁了很多,这里就不再对经典式定义过多介绍了,后文也主要以声明式定义为主。

Warning

注意,如果仅使用Table()来定义模型,不将其与一个类建立映射关系,则是SQLAlchemy Core的定义方法,这时需要采用SQLAlchemy Core的查询方法,不能采用SQLAlchemy ORM的查询方法。两种查询方法不能通用。

计算列

计算列有两种定义方式,一种是使用修饰器,一种是使用column_property

以下使用修饰器定义了一个用户表示字段。

from sqlalchemy.ext.hybrid import hybrid_property

class User(Base):
	__tablename__ = 'user'
	
	id = Column(String(200), primary_key=True, nullable=False)
	name = Column(String(200), nullable=False)
	password = Column('pass', String(50), nullable=False)
	age = Column(Integer)
	
	@hybrid_property
	def standardname(self):
		return "[{}]{}".format(self.id, self.name)

而使用column_property则更加简单一些。

from sqlalchemy.orm import column_property

class User(Base):
	__tablename__ = 'user'
	
	id = Column(String(200), primary_key=True, nullable=False)
	name = Column(String(200), nullable=False)
	password = Column('pass', String(50), nullable=False)
	age = Column(Integer)
	standardname = column_property("[{}]{}".format(id, name))

一对多映射

SQLAlchemy中的关联映射并不复杂,只需要逐项定义关联项即可。前面给出的两个表,即是一个一对多的经典关联,下面就利用这两个表来演示一对多映射的定义方法。

from sqlalchemy import Column, Integer, String, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.ext.declarative import declarative_base

Base = declarative_base()

class User(Base):
	__tablename__ = 'user'
	
	id = Column(String(200), primary_key=True, nullable=False)
	name = Column(String(200), nullable=False)
	password = Column('pass', String(50), nullable=False)
	age = Column(Integer)
	roles = relationship("Role")

class Role(Base):
	__tablename__ = 'role'
	
	id = Column(Integer, primary_key=True, nullable=False),
	user_id = Column(String(200), nullable=False, ForeignKey('user.id'))
	role = Column(String(20), nullable=False)

使用relationship()可以定义指定属性为关联属性,建立单向的关联关系,示例中使用back_populates参数将关联关系指向了对向的属性,从而就建立了双向关联关系。这个参数可以根据需要选择使用。当relationship()只关联单一的字段时,可以使用backref参数来指定逆向关联,该关联是在内存中实现的,使用backref可以不必在被映射的对方定义关联字段。

ForeignKey()函数给映射列指出了其参考的外键列,外键列使用表名.列名的方式表示。外键列不需要在数据库中声明为外键,这里只需要虚拟的设定外键即可。

在一对多映射中,外键一般都放置在子表中,来与主表建立关联。relationship()放置在主表来代表子表的实例集合。

对于关联常用的级联操作,可以使用cascade参数在relationship()函数中定义,需要级联的功能使用英文逗号隔开连接成字符串即可,例如cascade=""save-update, merge, delete"。对于使用backref定义的逆向关联,可以使用cascade_backrefs来定义是否使用逆向级联操作。

常用的级联操作有:

  • save-update
  • delete
  • delete-orphan
  • merge
  • refresh-expire
  • expunge

多对一映射

其实在形成一对多映射时,子表对于主表的关联就可以同时形成多对一的映射关系。多对一的映射通常将外键设置在主表中,relationship()也在主表声明,来定义对子表的关联。

同样使用back_populates可以建立双向的关联,这与一对多的关系是相同的。

一对一映射

realtionship()默认会创建使用列表的“多”映射,如果是一对一映射,就需要在不需要出现列表的一方的relationship()中使用uselist参数来将其转换成单一属性。

也就是说,谁那一方的属性应该是单数,就在谁的relationship()中添加uselist=False参数。

多对多映射

多对多映射通常会用到中间表来定义关联关系,这在映射定义时,就需要在relationship()中定义secondary参数。例如以下关联映射的定义:

association_table = Table('association', Base.metadata,
	Column('left', Integer, ForeignKey('left.id')),
	Column('right', Integer, ForeignKey('right.id'))
)

class Left(Base):
	__tablename__ = 'left'
	
	id = Column(Integer, primary_key=True)
	right = relationship('Right', secondary=association_table, backref="left")

class Right(Base):
	__tablename__ = 'right'
	
	id = Column(Integer, primary_key=True)

其中Left中的secondary参数指定了多对多映射使用的中间表,并且使用了backrefRight中定义了映射字段left来完成双向映射。

当中间表中还有其他要使用的字段时,可以将中间表也定义为一个类,此时上例就变成了下面这样。

class Association(Base):
	__tablename__  = 'association_table'
	
	left_id = Column('left', Integer, ForeignKey('left.id'), primary_key=True)
	right_id = Column('right', Integer, ForeignKey('right.id'), primary_key=True)
	extra_data = Column(String(200))
	right = realationship('Right')

class Left(Base):
	__tablename__ = 'left'
	
	id = Column(Integer, primary_key=True)
	right = realtionship('Association')

class Right(Base):
	__tablename__ = 'right'
	
	id = Column(Integer, primary_key=True)

自关联映射

在数据表设计的时候,经常会使用数据表自己与自己关联来产生级联数据,这种情况下就不能使用前面的关联方法。如果使用前面的一对多的映射方法,就会得到一个异常。这时就需要relationship()函数的另一个参数remote_side来定义循环映射主键。

class Node(Base):
	__tablename__ = 'node'
	
	id = Column(Integer, primary_key=True),
	parent_id = Column(Integer, ForeignKey('node.id'))
	data = Column(String(100))
	children = relationship('Node', remote_side=[id])

示例中定义了一个单向的映射,如果需要改为双向映射,只需要将关联映射一句改为children = relationship('Node', backref=backref('parent', remote_side=[id]))

如果存在多个键的复杂自关联映射,则需要将关联键都列举在remote_side中。

自定义关联条件

在默认情况下,关联条件都是采用相等的处理方式。但是个别情况下可能会使用其他的关联方式,比如like或者是数组包含(PostgreSQL支持的操作)等。在这种情况下可以使用relationship()primaryjoin参数来指定关联条件。

primaryjoin接受一个布尔表达式。

from sqlalchemy.orm import foreign, remote

class Host(Base):
	__tablename__ = 'host'
	
	id = Column(Integer, primary_key=True)
	ip_address = COlumn(INET)
	content = Column(String(50))
	
	parent_host = relationship('Host', primaryjoin=remote(ip_address) == cast(foreign(content), INET))

如果是使用PostgreSQL的特殊比较操作符,则需要使用op方法,格式为属性名.op('操作符', is_comparision=True)(外表字段)。例如:

class IPA(Base):
	__tablename__ = 'ip_address'
	
	id = Column(Integer, primary_key=True)
	v4address = Column(INET)
	
	network = relationship('Network', 
		primaryjoin="IPA.v4address.op('<<', is_comparision=True)(foreign(Network.v4representation))",
		viewonly=True
	)

class Network(Base):
	__tablename__ = 'network'
	
	id = Column(Integer, primary_key=True)
	v4representation = Column(CIDR)

数据查询

进行数据查询之前,需要先按照前文的方法获取一个数据库连接会话,在Web服务等需要并行数据库操作的情况下建议使用连接池进行操作。

Query对象代表着一个查询,这是由Session的query()方法创建的。query()方法中可以列举要查询的类名称,或者类属性名称,例如session.query(User)或者session.query(User.id)。如果是采用经典式模型定义,则Query对象是由模型的查询方法创建的。Query对象返回的查询结果都是命名元组类型的,命名元组的键都是各个类中定义的字段。如果排列了多个查询内容,则会使用类名称或者属性名称。具体可参考以下示例:

for row in session.query(User, User.name).all():
	print(row.User, row.name)

使用sqlalchemy.orm模块中的aliased()函数可以为查询目标定义别名,定义之后需要使用别名在查询中进行访问。

查询筛选

筛选条件是查询中的主要内容,也是最常用的内容。Query对象使用filter()方法来对查询目标进行过滤,filter()方法接受一个布尔表达式。多个filter()方法连续使用表示各个筛选条件之间以and连接。此外还可以从sqlalchemy模块中引入and_()or_()方法来组装andor条件。

大部分条件都定义在Column对象上,常用的有:

  • query.filter(User.id == id),判断相等;
  • query.filter(User.id != id),判断不相等;
  • query.filter(User.name.like('%th%')),区分大小写的LIKE判断;
  • query.filter(User.name.ilike('%th%')),不区分大小写的LIKE判断;
  • query.filter(User.name.in_(['jack', 'kate'])),是否在列表或者子查询中的判断;
  • query.filter(~User.name.in_(['jack', 'kate'])),是否不在列表或者子查询中的判断;
  • query.filter(User.name.is_(None)),判断是否为空;
  • query.filter(User.name.isnot(None)),判断是否为非空;
  • query.filter(and_(User.name == 'a', User.age < 20)),AND条件联合;
  • query.filter(User.name == 'a', User.age < 20),AND条件联合;
  • query.filter(User.name == 'a').filter(User.age < 20),AND条件联合;
  • query.filter(or_(User.name == 'a', User.age < 20)),OR条件联合。

还可以直接使用原生SQL文字来书写比较繁琐的查询条件,如果其中需要参数,可以使用params()方法传递相应的参数进去。例如:

session.query(User).filter(text('age>:minage and age<:maxage')).params(minage=10, maxage=25).all()

获取查询内容

查询需要使用以下方法来获取查询的结果。

  • .all(),获取全部结果,返回一个列表;
  • .first(),获取第一条数据,返回一个表示行的命名元组;
  • .one(),获取一条数据,如果结果不唯一或者无结果会报错;
  • .one_or_none(),获取一条数据,如果结果为空不会报错;
  • .scalar(),与.one()类似,但返回第一列的内容;
  • .count(),返回结果集长度。

对于排序和分组,可以使用Query对象的.order_by().group_by()方法,使用方式很简单,只需要传入要操作的列即可。.order_by()默认采用升序排列,如果需要降序,可以使用Column类中的.desc()方法来声明,当然升序也可以使用.asc()方法来显式声明。例如:query.order_by(User.age.desc())

关联查询

关联查询是数据库查询的一项重要内容。SQLAlchemy提供了多种方法来完成关联查询。

如果不打算使用显式的.join()方法,可以直接将要关联查询的映射类都放在.query()方法中。SQLAlchemy将返回条件合适的元组供使用。例如:

for u, r in session.query(User, Role).filter(User.id==Role.user_id).all();
	print(u)
	print(r)

如果使用.join()方法,则会将被关联的子表结果放在定义的关联映射里。

for u in session.query(User).join(Role).all():
	print(u)

.join()方法还有若干使用方式,可以自动推断如何建立关联。

  • query.join(Role, User.id==Role.user_id),显式条件关联;
  • query.join(User.roles),从左至右的关联定义;
  • query.join(Role, User.roles),同上,显式指定关联目标;
  • query.join('roles'),同上,以字符串指定关联目标。

数据修改

仅能完成数据查询是不够的,数据库连接会话提供了一系列的功能来完成数据修改功能。对于数据的修改,基本上都是针对保存有数据的映射类的实例来操作的。

Session类使用add()来添加一个映射类实例,使用add_all()来添加一个映射类实例列表以完成批量添加功能。

当查询到一个结果后,可以对这个结果进行修改,来完成数据更新的功能。

对一条记录的删除,则是使用.delete()方法提供的。此外还可以使用Query对象的.delete()方法来删除数据。例如:

session.query(User).filter(User.pending_delete == 1).delete()

对Session类实例监管下的映射类实例的任何添加和修改操作,都会使Session类实例进入dirty状态,这意味着Session缓存了全部的修改内容,需要进一步操作来将其同步到数据库。

调用Session类的.commit()方法可以将全部修改内容提交到数据库,而使用.rollback()方法可以撤销全部修改。这与事务操作基本一致。

执行原生SQL语句

原生SQL语句可以通过EngineConnectionSession类实例中的.execute()方法来执行。由于一般会需要将要执行的方法排入事务,所以建议在Session类实例中执行SQL语句。

SQLAlchemy支持建立动态SQL语句,并在执行时动态绑定参数。要完成这个功能,首先要文本SQL模板,这是由sqlalchemy.sql模块中的text()函数提供的。文本SQL模板可以直接在.execute()方法中执行,并使用一个字典来向其中传递参数。执行原生SQL语句时,要尽量使用文本SQL模板和绑定参数的方式,这种方式可以在一定程度上避免SQL注入攻击。

以下示例给了一个执行原生SQL语句的参考。

from sqlalchemy.sql import text

sql_cmd = text("SELECT user_id, user_name FROM users WHERE user_id=:id")
rows = db.session.execute(sql_cmd, {'id': 1})

文本SQL模板中使用“:param_name”的格式来声明参数,注意在书写时不要丢掉冒号。在.execute()方法传递参数时,参数名需要作为键名,但不要加入冒号。SQLAlchemy会在执行时自动判定传递参数的类型。

.execute()方法会返回一个ResultProxy类的实例,其中可以使用.fetchone().fetchall().fetchmany().first()等方法返回RowProxy实例或者RowProxy实例序列。RowProxy实例可以直接通过使用数据表列名作为键名来访问其中的数据,也可以直接使用dict()将其转换为字典实例,或者使用list()tuple()来转换成值列表或者值元组。

例如继续上例的内容对返回的结果集进行处理。

# 将整个结果集转换为字典序列
results = [dict(row) for row in rows.fetchall()]

# 只将指定列的内容取出作为序列
usernames = [row["user_name"] for row in rows.fetchall()]


# 取出第一行结果,之后关闭结果集
# .scalar()在结果集为空的时候,会返回None
user = dict(rows.scalar())

ResultProxy实例中是否包含数据库返回的记录,可以使用.returns_rows属性判断,这个属性是只读的。ResultProxy实例在使用完毕后,要记得使用.close()方法关闭,直接关闭数据库连接或者会话并不能使ResultProxy关闭,它还会继续返回空白结果集。

调用存储过程

虽然存储过程已经不再是推荐选项,但是很多时刻依旧会使用到,在SQLAlchemy中调用存储过程不是一件十分容易的事情,因为SQLAlchemy主要的目标是ORM,但存储过程返回的内容并不像数据表那样固定。所以在SQLAlchemy中调用存储过程更多的是使用原生数据库连接去完成。以下给出一个使用原生连接执行存储过程的示例。

connection = engine.raw_connection()
try:
	cursor = connection.cursor()
	cursor.callproc("my_procedure", ['x', 'y', 'z'])
	results = list(cursor.fetchall())
	cursor.close()
	connection.commit()
finally:
	connection.close()

注意,.callproc()方法调用存储过程时,不能使用OUTPUT返回数据。如果需要返回数据,需要使用原生SQL来调用存储过程,即使用db.session.execute()完成调用。

对于原生数据库功能DBAPI的使用,可以参考相关的Python文档。

操作Redis

Redis是一个key-value(键值)存储系统,与Memcache类似,常用于对数据进行缓存以提高数据存取命中率,降低数据的存取时间。相比Memcache,Redis支持更多的数据类型,主要包括:string(字符串)、list(列表)、set(集合)、zset(有序集合)和hash(键值表)。Redis在很多场合中可以对关系型数据库起到很好的补充作用,并在常用的主流语言中都提供客户端,使用十分方便。

在Python中操作Redis可以直接使用redis库,只需要运行pip install redis即可完成redis库的安装并在程序中使用。

连接数据库

redis库提供了StrictRedisRedis两个类来建立Redis数据库的连接,但是请注意,在新版的redis库中,StrictRedis类已经被废弃并成为了Redis类的别名,所以这两个类现在是执行相同的功能。

建立数据库连接可以使用以下两种方式:

import redis


# 以默认方式连接到数据库
connection = redis.Redis(host='localhost', port=6379, db=0)
# 以URL方式连接到的数据库
connection = redis.Redis.from_url('redis://@localhost:6379/0')

Redis类提供的数据库连接本身是线程安全的,可以将连接放到程序自身的一个模块中来引用使用。除此之外redis库还提供了连接池,同样是线程安全的,可以按照以下方式来使用。

import redis


# 以默认方式建立连接池
pool = redis.ConnectionPool(host='localhost', port=6379, db=0)
# 以URL方式建立连接池
pool = redis.ConnectionPool.from_url('redis://@localhost:6379/0')

# 使用连接池建立数据库连接
connection = redis.Redis(connection_pool=pool)

redis库在每次执行请求时都会创建和断开一次连接操作,这在进行多次请求时效率不高,所以redis库提供了管道(pipline)功能来一次性指定全部请求命令。使用格式示例如下:

import redis


pool = redis.ConnectionPool(host='localhost', port=6379, db=0)
connection = redis.Redis(connection_pool=pool)
pipe = connection.pipline()

# 进行请求操作
pipe.set('name', 'jack')
# 管道可以执行链式操作
pipe.set('role', 'admin').sadd('foo', 'boo')

# 提交全部操作
pipe.execute()

操作数据的键

对于Redis的操作可以与在Python中操作字典相比较,同样都是以操作数据的键和值为主。在redis库中的Redis类中,对于各种数据的操作都是对标准Redis函数的实现,在使用时只需要直接调用相应的函数即可完成操作,所以这里针对不同的操作,都以表格方式列举函数来说明。

以下函数可以用来操作数据的键。

方法功能返回值
.exists(name)判断一个键是否存在布尔值
.delete(name)删除一个键删除键的数量
.type(name)判断键的类型字符串
.keys(pattern)获取所有符合规则的键键列表
.randomkey()随机获取一个键字符串
.rename(src, dst)重命名一个键布尔值
.dbsize()获取键总数整型值
.expire(name, time)设置键的过期时间,单位秒布尔值
.ttl(name)获取键的过期时间整型值,-1表示不过期
.move(name, db)将键移动到其他数据库布尔值
.flushdb()清除当前数据库中的全部键布尔值
.flushall()清除所有数据库中的键布尔值

字符串及整型数据操作

字符串与整型是Redis中比较基本的数据类型。以下方法可以用来操作这些类型的值。

方法功能返回值
.set(name, value)将键赋值布尔值
.get(name)获取键对应的值
.getset(name, value)将键赋予指定值,并返回旧值
.mget(names, *args)返回多个值(列表)对应的值值列表
.setnx(name, value)如果键不存在则赋予值布尔值
.setex(name, time, value)将值赋予键,并设置有效期布尔值
.setrange(name, offset, value)设置键中的子字符串修改后字符串的长度
.mset(mapping)使用字典批量赋值布尔值
.msetnx(mapping)使用字典批量赋值,只有键不存在时才赋值布尔值
.incr(name, amount=1)为指定键的值进行增量操作,不存在时设置为amount修改后的值
.decr(name, amount=1)为指定键的值进行减量操作,不存在时设置为-amount修改后的值
.append(name, value)为指定键的值进行追加操作修改后的字符串长度
.substr(name, start, end=1)返回指定键的值的子字符串子字符串内容
.getrange(name, start, end)返回指定键的值的子字符串子字符串内容

列表类型数据操作

一个键中除了可以存放基本数据类型以外,还可以存放列表、集合等符合数据类型,以下方法可以用来操作列表类型的值。其基本上都有一个共同的特征,方法名中都包含l字母或者r字母。

方法功能返回值
.rpush(name, *values)向指定键列表尾部添加元素新列表大小
.lpush(name, *values)向指定键列表头部添加元素新列表大小
.llen(name)获取指定键列表大小整型值
.lrange(name, start, end)获取指定键列表指定范围内的元素子元素列表
.ltrim(name, start, end)截取指定范围内的列表,并保留指定范围内的内容布尔值
.lindex(name, index)返回指定索引位置的元素
.lset(name, index, value)设置指定索引位置的值布尔值
.lrem(name, count, value)删除count个键中值为value的元素删除的元素个数
.lpop(name)返回并删除指定键列表中的首元素
.rpop(name)返回并删除指定键列表中的尾元素
.blpop(names, timeout=0)返回并删除指定键列表中的首元素,如果列表为空则阻塞等待
.brpop(names, timeout=0)返回并删除指定键列表中的尾元素,如果列表为空则阻塞等待
.rpoplpush(src, dst)返回并删除指定键列表src中的尾元素,并添加到指定键列表dst中的头部

集合类型数据操作

集合类型中不能存在重复的值,并可以进行集合运算。集合通常有无序集合和有序集合两种,无序集合的方法通常以字母s开头,而有序集合的方法通常以字母z开头。以下方法常用来操作集合类型数据。

方法功能返回值
.sadd(name, *values)向指定键集合中添加元素插入的元素数量
.srem(name, *values)从指定键集合中删除元素被删除的元素数量
.spop(name)随机返回并删除指定键集合中的一个元素
.smove(src, dst, value)src中对应的元素移动到dst集合布尔值
.scard(name)返回键为name的集合个数整型值
.sismember(name, value)测试指定值是否为指定键集合的成员布尔值
.sinter(names, *args)返回指定键(列表)的集合的交集集合
.sinterstore(dst, names, *args)将指定键(列表)的集合的交集保存到dst保存集合数量
.sunion(names, *args)返回指定键(列表)的集合的并集集合
.sunionstore(dst, names, *args)将指定键(列表)的集合的并集保存到dst保存集合的数量
.sdiff(names, *args)返回指定键(列表)的集合的差集集合
.sdiffstore(dst, names, *args)将指定键(列表)的集合的差集保存到dst保存集合的数量
.smembers(name)返回指定键集合的所有元素集合
.srandmember(name)随机返回指定键集合中的一个元素,但不删除
.zadd(name, scores, values)向指定键集合中添加元素,score用于决定其后值的排序添加的元素个数
.zrem(name, *values)从指定键集合中删除元素被删除的元素个数
.zincrby(name, value, amount=1)如果指定键集合中存在指定元素则其score增加amount,否则添加元素,scoreamount修改后的值
.zrank(name, value)返回指定键集合中指定元素的排名排名值
.zrevrank(name, value)返回指定键集合中指定元素的倒数排名排名值
.zrevrange(name, start, end, withscores=False)返回指定键集合的子集合列表
.zrangebyscore(name, min, max, start=None, num=None, withscores=False)返回指定键集合中score在指定区间的子集合列表
.zcount(name, min, max)返回指定键集合中score在给定区间的元素数量整型值
.zcard(name)返回指定键集合中元素的个数整型值
.zremrangebyrank(name, min, max)删除指定键集合中排名在指定区间的元素被删除的元素数量
.zremrangebyscore(name, min, max)删除指定键集合中score在指定区间的元素被删除的元素数量

键值表数据操作

键值表类型在Redis中称为hash类型,可以理解为Python中的字典类型。操作键值表的方法通常以字母h开头。以下方法常用来操作键值表类型数据。

方法功能返回值
.hset(name, key, value)向指定键的键值表中添加键值对添加键值对的个数
.hsetnx(name, key, value)向指定键的键值表中添加键值对,如果指定映射键不存在时添加键值对的个数
.hget(name, key)返回指定键的键值表中指定映射键的值
.hmget(name, keys, *args)返回指定键的键值表中指定键名列表的值集合值列表
.hmset(name, mapping)批量向指定键的键值表中添加键值对布尔值
.hincrby(name, key, amount=1)将指定键的键值表中指定映射键的值做做增量操作修改后的值
.hexists(name, key)测试指定键的键值表中是否存在指定映射键布尔值
.hdel(name, *keys)删除指定键的键值表中指定键值对布尔值
.hlen(name)返回指定键的键值表中键值对的数量整型值
.hkeys(name)从指定键的键值表中获取所有映射键名列表
.kvals(name)从指定键的键值表中获取所有的映射键值列表
.hgetall(name)返回指定键的键值表中全部的键值对字典

科学计算:NumPy和SciPy

NumPy是Python的开源数值计算扩展,可以用来存储和处理大型矩阵,并且比Python内置数据结构要高效的多。它主要提供了许多高级数值编程工具,例如:矩阵计算、矢量处理、精密运算等,专门为进行严格的数字处理而生,在很多大型金融公司以及科学计算组织(如NASA)中大规模使用。

SciPy建立在NumPy基础上,提供了大量科学算法,例如:特殊函数、积分、最优化、傅里叶变换、信号处理、线性代数等。

Info

由于SciPy中涉及众多的数学计算知识,所以读者可以在实际应用时自行查阅文档。对于NumPy,本指南只介绍基本高精度计算的使用,其他涉及数学计算的内容,也同样根据实际应用需要查阅文档使用。

数据类型

NumPy支持比Python更多的数据类型,详细可见下表。

数据类型长度描述
bool_1布尔值
int_32/64默认整型,相当于C的long
intc32/64整型,相当于C的int
intp32/64索引的整型,相当于C的size_t
int888位整型
int161616位整型
int323232位整型
int646464位整型
uint888位无符号整型
uint161616位无符号整型
uint323232位无符号整型
uint646464位无符号整型
float6464位浮点型
float1616半精度浮点型,1位符号位,5位指数,10位尾数
float3232单精度浮点型,1位符号位,8位指数,23位尾数
float6464双精度浮点型,1位符号位,11位指数,52位尾数
complex_128128位复数型
complex6464由两个32位浮点数表示
complex128128由两个64位浮点数表示

NumPy的数据类型一般采用np.float32的格式使用。

数据类型对象描述了应用于数组的固定内存块的解释,主要包括以下几个方面的内容:

  • 数据类型;
  • 数据大小;
  • 字节序;
  • 结构化类型的字段名称,每个字段的数据类型以及每个字段占用的内存块部分;
  • 子序列的形状和数据类型。

数据类型对象,可以由numpy.dtype(object, align, copy)方法构造,其中,align参数表示是否像C的结构体一样在字段间添加间隔,copy参数表示是否要生成object的新副本,否则将使用引用。

Ndarray

NumPy中定义的最重要的类就是Ndarray,这是一个N维数组类型,用以描述相同类型的元素集合,其索引起始于0,可以认为Ndarray表示的是一个矩阵。Ndarray中的每个元素在内存中都使用相同大小的块。从Ndarray中提取的任何元素都是由一个数组标量类型的Python对象表示。Ndarray的实例可以通过不同的数组创建函数来构造,最基本的是使用numpy.array()方法。

numpy.array()可以从任何暴露数组接口的对象以及从返回数组的任何方法创建Ndarray。其完整格式为:

numpy.array(object, dtype=None, copy=True, order=None, subok=False, ndmin=0)

其各项参数的含义是:

  • object,用于构造Ndarray的原始序列;
  • dtype,数组内存储的数据的数据类型,可选;
  • copy,是否复制对象,可选,默认为True;
  • order,元素在内存中的排列方式,可以指定C(按行排列,C语言风格)、F(按列排列,Fortran和MATLAB的风格)、A(任意,默认风格);
  • subok,返回数组被强制为基类数组,否则返回子类;
  • ndmin,返回数组的最小维数。

除了numpy.array()之外,还有以下函数可以快速的创建常用的或者经典的数组。

  • numpy.empty(shape, dtype, order),创建未初始化数组;
  • numpy.zeros(shape, dtype, order),创建零值数组;
  • numpy.ones(shape, dtype, order),创建1值数组;
  • numpy.asarray(object, dtype, order),将Python序列转换为Ndarray;
  • numpy.frombuffer(buffer, dtype, count, offset),将缓冲区内容转化为一维数组;
  • numpy.fromiter(iterable, dtype, count),从迭代对象构建一个Ndarray;
  • numpy.arange(start, stop, step, dtype),构建一个等间隔值的一维数组;
  • numpy.linspace(start, stop, endpoint, retstep, dtype),构建一个均匀间隔的指定间隔数量的一维数组;
  • numpy.logspace(start, stop, num, endpoint, base, dtype),构建一个对数刻度。

Ndarray也支持Python中的切片和索引操作,但稍有不同的是,NumPy提供一个slice()函数,允许在Python传统方法之外创建一个切片对象,并以此来进行切片操作。slice()函数可以接受三个参数,分别对应切片中的起始、终止和步长三个值。以下示例中的几种切片用法的效果都是等同的。

import numpy as np
a = np.arange(10)
s = slice(2, 9, 2)
print(a[s])
print(a[2:9:2])

Ndarray对象中的元素遵循基于零的索引,并且其切片功能是Python中切片概念延伸到多维的扩展。对于多维数组的切片,Numpy提供了更多的功能。在多维数组中,Ndarray可以在[]中使用逗号分隔的索引来选择获取多维数组中的指定位置的元素,并且也支持在指定位置使用切片语法。

import numpy as np
a = np.array([1, 2, 3], [3, 4, 5], [4, 5, 6])

print(a[1:]) # 输出[[3, 4, 5], [4, 5, 6]],普通切片操作
print(a[..., 1]) # 输出[2, 4, 5],取的是第二列的元素
print(a[1, ...]) # 输出[3, 4, 5],取的是第二行的元素
print(a[..., 1:]) # 输出[[2, 3], [4, 5], [5, 6]],取的是第二列及其剩余元素

使用省略号(...)进行切片的操作,可以选择出与数组维数相同长度的元组。如果在行位置使用省略号,将会选出包含行中元素的Ndarray。

如果在索引位置使用布尔表达式,则会使用比较运算来对元素进行筛选,例如a[a > 5]将选出全部大于5的元素并组成新的Ndarray对象。

广播

广播是指NumPy在算术运算期间处理不同形状矩阵的能力。对矩阵的算术运算通常在相应的元素上进行,如果两个矩阵具有完全相同的形状,则算术操作会被直接执行。但是如果两个数组的维数不相同,则元素到元素的操作是不可能的。此时较小的数组会广播到较大数组的大小,以便形状可以兼容。

广播需要满足以下规则:

  • 维度较小的数组会在前面追加一个长度为1的维度。
  • 输出数组的每个维度的大小是输入数组该维度大小的最大值。
  • 如果输入在每个维度中的大小与输出大小匹配,或其值为1,则在计算中可广播。
  • 如果输入的某个维度大小为1,则该维度中的第一个数据将用于该维度的所有计算。

简而言之,如果两个数组从末尾开始算起的维度的轴长度相等,或者其中一方的长度为1,则它们是广播兼容的,广播会在缺失或者长度为1的轴上进行。

最简单的广播是一维数组与常量数字之间的运算,读者可在交互式解释器中试验以下示例来观察广播运算结果。

import numpy as np
arr = np.array([1, 2, 3])
arr += 4
print(arr)

在这个示例中,虽然执行的是运算: $$ \begin{bmatrix} 1 & 2 & 3 \end{bmatrix} + 4 = \begin{bmatrix} 5 & 6 & 7 \end{bmatrix} $$

NumPy实际上进行了以下运算: $$ \begin{bmatrix} 1 & 2 & 3 \end{bmatrix} + \begin{bmatrix} 4 & 4 & 4 \end{bmatrix} = \begin{bmatrix} 5 & 6 & 7 \end{bmatrix} $$

常量数字4被自动扩展成了一个长度为3的一维数组。

对于二维数组(矩阵)与一位数组相加的操作,则会出现以下效果: $$ \begin{bmatrix} 0 & 0 & 0 \\ 1 & 1 & 1 \\ 2 & 2 & 2 \\ 3 & 3 & 3 \end{bmatrix} + \begin{bmatrix} 1 & 2 & 3 \end{bmatrix} = \begin{bmatrix} 1 & 2 & 3 \\ 2 & 3 & 4 \\ 3 & 4 & 5 \\ 4 & 5 & 6 \end{bmatrix} $$

根据广播的原则,第一个数组的形状为\((4,3)\),第二个数组的形状为\((3,)\),所以会在较小的轴向上发生广播,一维数组\(\bigl[\begin{smallmatrix} 1 & 2 & 3\end{smallmatrix}\bigr]\)就被扩展为了\(\bigl[\begin{smallmatrix} 1 & 2 & 3 \\ 1 & 2 & 3 \\ 1 & 2 & 3 \\ 1 & 2 & 3 \end{smallmatrix}\bigr]\)。这样就可以完成形状不同的矩阵之间的运算了,对于更高维度的数组的计算,也同样适用于这个原则。

对于在多个轴向上进行广播的情形,例如: $$ \begin{bmatrix} 0 \\ 1 \\ 2 \\ 3 \end{bmatrix} + \begin{bmatrix} 1 & 2 & 3 \end{bmatrix} = \begin{bmatrix} 1 & 2 & 3 \\ 2 & 3 & 4 \\ 3 & 4 & 5 \\ 4 & 5 & 6 \end{bmatrix} $$

读者可自行在交互式解释器中进行试验来观察广播的操作规律。

数组操作

对于Ndarray,常用的操作是迭代。除此之外还有位操作、字符串操作、算数计算、统计、排序等等。下面将选择几个比较常用的操作进行简单介绍。对于其他操作可以参考NumPy的文档,根据需要进行选择。

元素操作

操作数组时最基础的操作当属针对数组内的元素进行增删的操作,Ndarray在增删元素时没有采用Python列表的常用操作,而是提供了一系列的方法来完成相关操作。

  • numpy.resize(arr, shape),返回指定大小的新数组,如果新数组较原数组大,则原数组的一部分元素将会被重复以填充空位。
  • numpy.append(arr, values, axis),向指定数组的指定轴向上添加元素。
  • numpy.insert(arr, obj, values, axis),向指定数组的指定轴向上的指定值之前插入值。如果未提供axis参数,则输入数组会被展开。
  • numpy.delete(arr, obj, axis),返回从输入数组中删除指定子数组之后的新数组。

迭代

NumPy提供了numpy.nditer()函数用来创建一个多维迭代对象,可以使用Python的迭代操作访问。

如果两个数组都是可广播的,那么nditer组合对象就可以同时迭代它们。

变换操作

NumPy提供了以下针对Ndarray的常用变换操作。

  • numpy.reshape(arr, newshape, order),不改变数据的条件下修改形状。
    • arr参数表示要修改形状的数组;
    • newshape参数表示用于描述新形状的整数或者整数数组;
    • order参数表示元素在内存中的出现顺序,可取'C''F''A'
  • ndarray.flat,数组上的一维迭代器。
  • ndarray.flatten(order),返回折叠为一维的数组副本。
  • numpy.ravel(arr, order),返回连续的展开数组。
  • numpy.transpose(arr, axes),翻转数组维度。
    • axes接受一个整数的列表,用于对应转置的维度。
  • numpy.rollaxis(arr, axis, start),向后滚动指定轴。
    • axis参数表示要向后滚动的轴,其他轴的相对位置不会改变;
    • start参数表示滚动到的特定位置。
  • numpy.swapaxes(arr, axis1, axis2),互换数组的两个轴。
    • axis1参数表示第一个轴;
    • axis2参数表示第二个轴。
  • numpy.broadcast(arr1, arr2),产生模仿广播的对象。
    • arr1参数表示广播来源;
    • arr2参数表示广播接收目标。
  • numpy.broadcast_to(arr, shape, subok),将数组广播到新形状。
  • numpy.expand_dims(arr, axis),扩展数组的形状。
    • axis表示新轴插入的位置。
  • numpy.squeeze(arr, axis),从数组的形状中删除一个维度。
    • axis表示要删除的维度,接收一个整数或者一个整数列表。

连接与分隔

对于数组的连接,NumPy也提供了常用的几种连接方法来将多个数组连接成为一个新的数组。这里需要注意的是Ndarray是一个多维数组,在进行数据连接时需要关注连接的轴向。

  • numpy.concatenate((arr1, arr2, ...), axis),沿着指定轴向或者现有轴向连接数组。
  • numpy.stack([arr1, arr2, ...], axis),沿着指定轴向堆叠相同形状的数组序列。
  • numpy.hstack(arrays),沿水平轴堆叠相同形状的数组序列。
  • numpy.vstack(arrays),沿竖直轴堆叠相同形状的数组序列。

数组之间可以进行连接,那么也可以对一个数组进行拆分。对于数组的拆分,Numpy只提供了一个函数来实现拆分功能,另有两个衍生的快捷拆分函数,只是节省了拆分轴向的指定。

  • numpy.split(arr, indices, axis),沿指定轴向将数组分隔为子数组,返回数组列表。
    • indices可以是整数,表示要拆分成的等大小子数组数量;也可以是一维整数数组,表示进行切割的索引位置。
  • numpy.hsplit(arr, indices),水平切割数组。
  • numpy.vsplit(arr, indices),竖直切割数组。

算数运算

NumPy的算数运算功能与Python标准库中math库的一部分功能是存在重复的,可以根据喜好来使用。但是需要注意的是NumPy中提供的运算功能都是可以直接应用于Ndarray并且直接返回逐元素处理后的Ndarray的,这是Python标准库所不能企及的。

  • 全部标准三角函数。
  • numpy.around(arr, decimal),逐元素进行四舍五入,decimal参数表示要保留的小数位数。
  • numpy.floor(arr),逐元素返回不大于输入参数的最大整数。
  • numpy.ceil(arr),逐元素返回输入值的上限整数。
  • numpy.add(arr1, arr2)numpy.subtract(arr1, arr2)numpy.multiply(arr1, arr2)numpy.divide(arr1, arr2),加减乘除运算,要求输入的数组必须具有相同的形状或者符合数组广播规则。
  • numpy.reciprocal(arr),返回逐元素的倒数。
  • numpy.power(arr, pow),返回以逐元素为底数,pow为指数的幂。
  • numpy.mod(arr1, arr2),返回两个数组逐元素相除的余数。

统计

作为数据计算的扩展库,NumPy必然也提供了全面的统计计算功能。这里对常用的一些统计功能进行列举,具体这些统计功能的实际用途与意义还需要参考专门的统计类书籍。

  • numpy.amin(arr, axis),沿指定轴向返回最小值。
  • numpy.amax(arr, axis),沿指定轴向返回最大值。
  • numpy.ptp(arr, axis),沿指定轴向返回值的范围。
  • numpy.percentile(arr, q, axis),百分位数是统计中使用的度量,表示将数组从小到大排序,并将样本值区间划分为100等分,某一百分位所对应的数据就成为这一百分位的百分位数。q值表示要计算的百分位数。
  • numpy.median(arr, axis),获取将数组的上半部分与下半部分分开的中值。
  • numpy.mean(arr, axis),获取数组的算术平均值。
  • numpy.average(arr, weights, returned, axis),计算数组的加权平均值,其中weights是与数组相同长度用于表示数组元素权重的权重数组。returned参数用于指示是否返回权重的和。
  • numpy.std(arr, axis),计算标准差,即\(\sigma=\sqrt{\frac{\sum_{i=1}^{N}(x_{i}-\overline{x})^{2}}{N}}\)。
  • numpy.var(arr, axis),计算方差,即\(\sigma^{2}=\frac{\sum_{i=1}^{N}(x_{i}-\overline{x})^{2}}{N}\)。

排序

排序也是对数组中的元素经常要做的一种操作,NumPy提供了三种排序算法可供使用,这三种算法都具有不同的性能和稳定性。

算法速度复杂度空间占用稳定性
quicksort1\( O(n^{2}) \)0
mergesort2\(O(n\log{n})\)\(\frac{n}{2}\)
heapsort3\(O(n\log{n})\)0

NumPy提供了以下排序函数来对数组进行排序。

  • numpy.sort(arr, axis, kind, order),其中kind用于指定要使用的排序算法,默认会使用quicksort
  • numpy.argsort(arr, axis, kind),沿给定轴向进行间接排序,并使用指定排序算法返回数据的索引数组。
  • numpy.lexsort(arr, axis),使用键序列执行间接排序,返回一个索引数组。

搜索

搜索是NumPy中较为神奇的一个功能。Ndarray可以使用numpy.where()函数返回的索引序列来获取满足条件的元素,并形成一个新的Ndarray。

numpy.where()接受一个布尔表达式来形成索引序列。具体可见以下示例。

import numpy as np
arr = np.arange(9.)
condi = np.where(arr > 3)
print(arr[condi])
# 以下使用索引的语法也可以达到同样的效果
print(arr[arr > 3])

线性代数

线性代数的计算也是NumPy中的一个重要功能,这里仅针对常用的矩阵计算列举常用的重要功能。线性代数计算功能一般都包含在numpy.linalg模块中,如果有实际使用需要可以参考相关模块文档。

  • numpy.dot(arr1, arr2),计算两个数组的点积,对于矩阵等效于矩阵乘法,对于一维数组是向量的内积。
  • numpy.vdot(arr1, arr2),计算两个向量的点积。如果第一个参数是复数,则会使用其共轭复数进行计算;多维数组会被展开。
  • numpy.inner(arr1, arr2),计算一维数组的向量内积。对于多维数组,只返回最后一个轴上的向量内积。
  • numpy.matmul(arr1, arr2),计算两个数组的矩阵乘积。对于大于二维的数组,会将其视为存在于最后两个索引的矩阵的栈并进行广播。对于一维数组会自动进行升维,并在计算后降维。
  • numpy.linalg.det(arr),计算矩阵的行列式,对于矩阵\(\bigl[\begin{smallmatrix} a & b \\ c & d \end{smallmatrix}\bigr]\),其行列式计算为\(ad-bc\)。较大的矩阵会被认为是\(2\times 2\)矩阵的组合。
  • numpy.linalg.inv(arr),计算矩阵的逆。
  • numpy.linalg.solve(a, b),计算矩阵形式线性方程的解。例如有以下线性方程组:

$$ \begin{cases} x+y+z=6 \\ 2y+5z=-4 \\ 2x+5y-z=27 \end{cases} $$

可以转化为矩阵形式:

$$ \begin{bmatrix} 1 & 1 & 1 \\ 0 & 2 & 5 \\ 2 & 5 & -1 \end{bmatrix}\begin{bmatrix} x \\ y \\ z \end{bmatrix}=\begin{bmatrix} 6 \\ -4 \\ 27 \end{bmatrix} $$

也即是\(AX=B\)的形式,求解即是根据\(A\)和\(B\)来求出\(X\)。

SciPy各子包功能

SciPy中面对不同科学计算领域的功能被组织成了不同的子包。

子包名称集成功能
scipy.cluster矢量量化处理
scipy.constants物理与数学常数
scipy.fftpack傅里叶变换
scipy.integrate集成例程
scipy.interpolate插值计算
scipy.io输入输出处理
scipy.linalg线性代数
scipy.ndimage多维图像处理
scipy.odr正交距离回归
scipy.optimize优化算法
scipy.signal信号分析
scipy.sparse稀疏矩阵
scipy.spatial空间数据结构
scipy.special特殊数学函数
scipy.stats数理统计

SciPy在使用时,其基础数据结构依然为NumPy提供的Ndarray。与NumPy相比,SciPy中有一些基本代数功能是有重复的,但是实际功能并不相同,这点需要注意。

积分

SciPy提供了许多用于执行数值积分(定积分)的函数,其中大部分都位于scipy.integrate子包中。以下介绍几个比较常用的积分函数。

  • scipy.integrate.quad(f, a, b),单定积分函数,其公式形式为\(\int_{a}^{b}f(x)dx\)。

其中f是要积分的函数名称,可以是Lambda表达式;ab分别是定积分的下限和上限,传递给单积分函数的上限与下限可以是正无穷和负无穷。例如计算以下定积分

$$\int_{0}^{1}e^{-x^{2}}dx$$

可以参考以下示例。

import scipy.integrate
from numpy import exp
f = lambda x: exp(-x ** 2)
i = scipy.integrate.quad(f, 0, 1)
print(i)

quad()函数返回两个值,其中第一个值是积分值,第二个值为积分值绝对误差的估计值。

  • scipy.integrate.dblquad(f, a, b, g, h),双重定积分函数,其公式形式为\(\int_{a}^{b}dy\int_{g(y)}^{h(y)}f(x,y)dx\)。

其中f是要积分的函数名称,ab是变量$x$的下限和上限,而gh则是定义变量$y$的下限和上限的函数名称。

Warning

需要注意的是,即便是gh是常数,也必须要定义为函数形式。

例如计算双重定积分\(\int_{0}^{\frac{1}{2}}dy\int_{0}^{\sqrt{1-4y^2}}16xydx\)可以使用以下例程。

import scipy.integrate
from numpy import exp
from math import sqrt
f = lambda x, y: 16 * x * y
g = lambda x: 0
h = lambda y: sqrt(1 - 4 * y ** 2)
i = scipy.integrate.dblquad(f, 0, 0.5, g, h)
print(i)
  • scipy.integrate.tplquad(),三重定积分函数。
  • scipy.integrate.nquad(),多重定积分函数。

三重定积分和多重定积分两个函数的使用方法可以参考SciPy的文档,其基本使用方法与单定积分和双重定积分相似,只是参数更多。

数据分析:Pandas

Pandas是基于Numpy的一种用于解决数据分析任务的数据分析包,最初是作为金融数据分析工具而存在的。Pandas纳入了大量库以及标准数据模型,并提供了高效操作大型数据集所需的工具,还有大量能使我们快速处理数据的函数和方法,这使得Pandas为Python添加了强大而高效的数据分析能力。

核心数据结构

Pandas的核心数据结构有两个,SeriesDataFrame

其中Series是一个带标签的一维同构类型数组。而DataFrame是一个二维表格结构可以包含异构数据列的类型。可以认为DataFrameSeries的容器。

Series

Series是能够保存任何类型数据的一维数组,其轴标签统称为索引。Series可以通过pandas.Series(data, index, dtype, copy)构造函数来创建。构造函数中的各个参数的含义及使用方法如下:

  • dataSeries中存储的数据,可以采用各种格式如Numpy的ndarraylist、常量值等。
  • index,数据的索引列表。索引值必须是唯一和散列的,索引列表的长度与数据的长度要相同。
  • dtype,数据的类型,如果未显式声明类型,则自动推断数据的类型。
  • copy,是否复制数据,默认为False

以下示例从Numpy的ndarray中创建了一个Series

import pandas as pd
import numpy as np
data = np.array(['a', 'b', 'c', 'd'])
s = pd.Series(data, index=[101, 102, 103, 104])
print(s)

字典也可以被用来创建Series,如果没有指定索引,则按照字典中内容的排列顺序取得字典的键来构造索引,如果传递了索引,则按照索引的顺序拉取字典中对应键的值来组建数据。可在交互式解释器中执行以下两段代码以观察区别。

# 不传递索引,使用字典默认顺序
import pandas as pd
data = {'a': 0., 'b': 1., 'c': 2.}
s = pd.Series(data)
print(s)

# 传递索引,使用索引顺序构建数据
import pandas as pd
data = {'a': 0., 'b': 1., 'c': 2.}
s = pd.Series(data, index=['b', 'c', 'd', 'a'])
print(s)

使用常量值来创建Series时,必须提供索引,Series将按照索引的长度,重复常量值来填充数据。例如s = pandas.Series(4, index=[0, 1, 2, 3])

Series中的数据可以使用与列表相似的下标来访问,并且也支持切片。在指定索引之后,还支持使用索引来访问指定位置的数据,但并不局限于获取单一的一个数据。例如:

from pandas import Series
s = Series([1, 2, 3, 4, 5], index=['a', 'b', 'c', 'd', 'e'])
# 获取单一元素,返回元素本身
print(s['a'])
# 获取多个元素,返回一个新的Series
print(s[['a', 'c', 'd']])

如果试图获取不存在的索引,Pandas将会抛出KeyError异常。

DataFrame

DataFrame是一个数据以行和列方式排列的二维表格数据结构。在DataFrame中,各列可以是不同的数据类型,并且行列大小都是可变的。DataFrame可以对其中的行和列进行算术运算。DataFrame可以使用构造函数pandas.DataFrame(data, index, columns, dtype, copy)来创建,该函数中大部分参数都与Series相同,其参数的主要含义如下:

  • dataDataFrame中的储存的数据,可以采用ndarray、Series、maplistsdictDataFrame或者是常量值来定义。
  • index,行标签定义,用于定义行索引。其长度要与数据中行总数相同。默认会使用数字序列索引。
  • columns,列标签定义,用于定义列索引。其长度要与数据行中的元素数量相同。默认会使用数字序列索引。
  • dtype,定义每列的数据类型。
  • copy,是否复制数据,默认为False

DataFrame的创建要较Series复杂许多,一般常用列表、字典、Seriesndarrays和DataFrame来创建。

使用列表来创建DataFrame时,列表的每一个元素都对应DataFrame中的一个数据行。例如以下示例。

import pandas as pd
data = [1, 2, 3, 4, 5]
df = pd.DataFrame(data)
print(df)

其输出的结果是下面这个样子。

	0
0	1
1	2
2	3
3	4
4	5

使用比较复杂的二维列表可以创建比较复杂的DataFrame,例如下面这样的表格。

import pandas as pd
data = [['Kate', 95], ['Lily', 90], ['May', 89]]
df = pd.DataFrame(data, columns=['Name', 'Score'])
print(df)

这个示例则会输出以下表格的样子。

	Name	Score
0	Kate	95
1	Lily	90
2	May		89

示例中使用columns参数给数据行中的每个列都建立了标签,并且形成了熟悉的表格样式数据结构。

除此之外,列标签还可以使用字典来设定,下面使用以上数据来看一下使用字典如何创建DataFrame。

import pandas as pd
data = {'Name': ['Kate', 'Lily', 'May'], 'Score': [95, 90, 89]}
df = pd.DataFrame(data)
print(df)

这个示例会得到与前一个示例同样的结果。读者可以自行比较其异同点。使用字典来创建DataFrame还有一种更为复杂的方法,但是在一些情况下可能更为适用,那就是使用字典列表来作为数据源。同样使用以上数据,来看以下使用自点列表作为数据源的示例。

import pandas as pd
data = [{'Name': 'Kate', 'Score': 95}, {'Name': 'Lily', 'Score': 90}, {'Name': 'May', 'Score': 89}]
df = pd.DataFrame(data)
# 或者可以指定更加详细的参数
df = pd.DataFrame(data, index=['1st', '2nd', '3rd'], columns=['Name', 'Score'])
print(df)

前面的所有示例中都没有给定index参数,所以所有数据列的行索引都是采用自增长整型数值的默认索引的,赋予index参数一个与数据行长度相同的列表,可以为每个数据行建立行标签,也就是自定义行索引。这个语法与columns参数的使用是相同的,可参考上例中指定详细参数的用法。

如果Columns参数指定了字典中不存在的键值,那么DataFrame将会将此列的数据记为NaN,表示这里没有任何数字内容。如果数据行之间键值也不相同,那么DataFrame将会采用所有数据行中出现的列的并集作为DataFrame的列标签,并且在每个数据行缺失的列上使用NaN补齐。这里需要注意的是,DataFrame并不是使用None来补齐数据,而是使用NaN

除了可以使用列表的列表、字典列表、字典以外,还可以使用字典的Series、字典的ndarray等相似的数据结构来创建DataFrame,读者可以自行尝试不同的创建DataFrame的方法。

Series类似,DataFrame可以通过dataFrame[column]的格式来访问某个数据列,并且可以对一个不存在的列标签进行赋值来创建一个新列。而删除一个数据列则需要使用.pop(column)函数或者直接使用del dataFrame[column]

对于数据行的操作,DataFrame则提供了.loc(index).iloc(index)两个方法来获取数据列,其中.loc()函数使用DataFrame中实际的行标签来选择行,而.iloc()则是使用数字索引来选择行。对DataFrame使用切片操作是操作数据行,而不是数据列。要向DataFrame中增加一行数据,需要使用.append()函数,删除使用.drop(index)函数。如果DataFrame中有重名的行标签,则会全部被删除掉。

除了使用构造函数来创建DataFrame以外,Pandas还提供了以下用于读取固定格式数据源来直接创建DataFrame的函数(仅包含常用函数,非全部函数)。

函数用于数据源类型
pandas.read_csv().csv格式文件读取数据
pandas.read_clipboard()从剪贴板读取数据
pandas.read_excel()从Excel文件读取数据
pandas.read_gbq()从Google BigQuery读取数据
pandas.read_html()从HTML中的表格中读取数据
pandas.read_json()从JSON字符串中读取数据
pandas.read_pickle()从Python保存的Pickle文件中读取数据
pandas.read_sql()从SQL查询或者数据表中读取数据,支持SQLAlchemy
pandas.read_sql_query()从SQL查询中读取数据,支持SQLAlchemy
pandas.read_sql_table()从数据表中读取数据,支持SQLAlchemy

以上函数的具体使用方法可参考Pandas的文档,这里不再赘述。

基本数据结构方法

SeriesDataFrame中有一些共同的基本方法,主要是用来获取其存储的数据的一些内容的,这里通过一个表格来说明其功能。

属性或方法SeriesDataFrame功能
.T转置行与列,旋转表格。
.axes返回行标签列表。
.dtype返回此对象中的数据类型。
.dtypes返回此对象中的数据类型。
.empty内容是否为空。
.ndim轴或者数组的维度大小。
.shape返回用于表示维度的元组。
.size元素数量。
.values使用Numpy数据结构表示对象中的数据。
.head(n)返回前n行内容。
.tail(n)返回最后n行内容。

统计方法

Pandas是一个专门用于数据分析的库,所以其最强大的功能在于对数据统计的支持。由于一般进行数据分析时,通常采用表格方式,所以这里主要以DataFrame为主列举常用的统计方法。

大部分统计方法都有一个参数axis,其默认值为0。在默认情况下,在DataFrame上调用统计方法,会对全部内容进行计算操作,但是可以通过指定参数axis的值来指定只需操作的列,axis参数既可以接受数字索引,也可以接受列标签名称索引。

下表中列出了常用的重要统计函数,其具体含义和使用方法可参考统计类书籍。

函数功能
.count()非空观测数量
.sum()所有值之和
.mean()平均值
.median()中位数
.mode()模值
.std()标准差
.min()最小值
.max()最大值
.abs()绝对值
.prod()所有元素的乘积
.cumsum()累计总和
.cumprod()累计乘积
.describe()计算有关列的统计信息摘要
.pct_change()环比,默认对列进行操作
.cov()协方差,计算所有列之间的协方差,支持Series
.corr()计算相关性,支持Series
.rank()数据排名

数据控制方法

多数情况下,对于采样的原始数据,需要进行变化、分组、排序等多种操作,来获得符合要求的采样数据。针对对原始采样数据的整理需求,Pandas也提供了一系列方法来实现。以下同样以DataFrame为主列举常用的方法。

变换函数

变换函数有管道函数.pipe()、行列变换函数.apply()和元素变换函数.applymap()三个。

.pipe()可以通过给定一个变换函数和一定数量的参数作为变换函数的参数来执行自定义变换操作,这个变换操作是针对整个DataFrame进行的。自定义的变换函数将被应用于DataFrame的每一个元素上。.pipe()函数所接收的第一个参数是自定义的变换函数,其后的参数是自定义变换函数所需的除第一个参数以外的剩余参数。.pipe()会将DataFrame中的元素作为自定义变换函数的第一个参数传递给变换函数,并使用变换函数的返回值替换相应位置的元素,并最终更新DataFrame中的全部元素。

.apply()可以沿DataFrame的任意轴上执行自定义变换函数,默认操作是按列执行,可以通过指定axis参数为1来指定按行执行。.apply()的使用与.pipe()类似,可以仿照使用。

不是所有的自定义变换函数都能够使用.pipe()应用于DataFrame的每一个元素,除了.pipe()之外,Pandas还提供了.applymap()来对DataFrame中的每一个元素应用普通变换函数,针对Series中的元素是使用.map().applymap()接受任何Python函数,并返回单个值。

迭代

DataFrameSeries都是支持迭代的。对于Series来说,迭代将直接输出其索引标签,而迭代DataFrame则较为复杂。

直接使用for...in...DataFrame进行迭代,将会从DataFrame中取得其全部列名。如果需要遍历DataFrame中的行,则需要使用以下函数。

  • .iteritems(),迭代键值对,格式为(key, value)
  • .iterrows(),将行迭代为键值对,格式为(key, Series)
  • .itertuples(),使用命名元组的形式迭代行。

排序

Pandas有两种排序方式,一种是按标签排序,一种是按照实际值排序。

使用.sort_index()可以按照标签进行排序,默认情况下将按照升序对行标签进行排序操作。可以通过指定axis参数来变更排序轴,ascending参数(可取True或者False)来变更排序方向。

而使用.sort_values()可以按照实际值来进行排序。.sort_values()可以接受by参数来指定参加排序的列,kind参数来指定排序算法。由于Mergesort是Pandas中比较稳定的排序算法,所以一般情况下不必特殊指定算法。

窗口

窗口(Window)用于将原始采样数据进行动态分组,使其能够组合成一定数量的样本来应用统计函数。Pandas提供了滚动、展开、指数移动等函数来建立窗口。窗口函数主要用于通过平滑曲线来以图形方式查找数据内的趋势。

滚动窗口使用.rolling()函数来建立,需要指定window参数来指定窗口中统计样本的大小。

展开窗口使用.expanding()函数来建立,需要指定min_periods参数来指定窗口中最小统计样本的大小。

.ewm()函数可以建立指数权重窗口,需要通过comspanhalflife等参数来定义权重。

聚合

聚合较为简单,主要是每列的数据进行合并操作,形成一个有代表性的统计数据的过程。聚合可以应用于DataFrame或者列,主要使用.aggregate()来完成,也可以使用其简短形式.agg()

.aggregate()可以接受一个聚合函数,或者一个聚合函数列表。.aggregate()会使用每一个聚合函数建立一列聚合数据。例如df['A'].aggregate([np.sum, np.mean])

.aggregate()还可以对多个列执行聚合操作,例如df[['A', 'B']].aggregate([np.sum, np.mean])。亦或者针对不同的列执行不同的聚合操作,例如df.aggregate({'A': np.sum, 'B': np.mean}),但是需要注意的是,.aggregate()中使用字典进行聚合的列,必须是DataFrame中存在的列。如果使用列表,则可以通过lambda表达式创建新的列,但是此时创建的列名称为<lambda>,需要使用.rename()进行改名。

分组

在许多情况下,需要对采样数据分成多个集合,并在每一个子集上应用统计函数。{\color{red} 需要注意分组与窗口的不同。}通常在执行分组后,还会继续执行的操作是聚合、变换以及过滤。

对采样数据进行分组拆分主要通过.groupby()函数来进行,.groupby()函数返回的结果是可以迭代为(key, DataFrame)结构的数据。.groupby()常用的拆分方式有以下几种。

  • .groupby(key),按照指定列标签来拆分。
  • .groupby([key1, key2]),按照指定列标签的组合来拆分。
  • .groupby(key, axis=1),按照指定行标签来拆分。

.groupby()可以接受一个as_index参数,这个参数的默认值为True。当as_indexTrue时,所有参与分组的列将变为索引。如果需要继续保留参与分组的列,并且按照原先的列名进行访问,则需要将此参数设置为False

完成分组的数据,可以通过.get_group(key)来获取一个分组的数据。而进行聚合操作,则是使用.agg()函数,而不是.aggregate()函数。

对于分组数据的转换则比较复杂,转换应该返回与组块大小相同的结果。转换使用.transform()函数完成。

.filter()函数可以接受一个函数来对已经分组的数据进行过滤,并返回符合要求的数据。

连接

Pandas具有功能全面的内存连接操作,这与关系型数据库中的连接十分相似,由于是在内存中操作,所以性能较高。对于连接操作,Pandas只提供了一个函数.merge()来实现。

.merge()的完整格式为.merge(left, right, how='inner', on=None, left_on=None, right_on=None, left_index=False, right_index=False, sort=True)。以下针对出现的参数及其功能进行一个简要的说明,读者可根据SQL语言中的JOIN子句的语法特性来体会.merge()函数的功能。

  • left,位于左侧的DataFrame
  • right,位于右侧的DataFrame
  • on,列的连接,指定列名必须在左右两侧的DataFrame中都存在。
  • left_on,指定左侧DataFrame中的列用于键。
  • right_on,参考left_on
  • left_index,如果为True,则使用左侧DataFrame中的行标签作为连接键。
  • right_index,参考left_index
  • sort,按照字典顺序通过连接键对结果进行排序,默认为True
  • how,确定合并时哪些键将被包含在结果表中,如果组合键没有出现在左侧或者右侧表中,则连接表中的值为NaN。可以取以下值:
    • left,左侧外连接,使用左侧对象的键。
    • right,右侧外连接,使用右侧对象的键。
    • outer,全外连接,使用键的联合。
    • inner,内连接,使用键的交集,此项为默认值。

与SQL语法的对比

从上面几节的内容可以看出,Pandas在DataFrame上的操作与SQL十分相似。在实际应用中,DataFrame的一些特性也的确能够完成与SQL类似的查询。这里仅针对一些相似度较高的操作进行简要的介绍。

假设有一个DataFrame,其中有如下几列数据:idclient_codetypebilltime。下面的对比都针对这几列数据来进行查询操作。

选择列查询

SQL: SELECT id, client_code, bill FROM df;

DataFrame: df[['id', 'client_code', 'bill']]

选择全部列,并只取前5行记录

SQL: SELECT * FROM df LIMIT 5;

DataFrame: df.head(5)

Where条件子句

SQL: SELECT * FROM df WHERE type='0' LIMIT 5;

DataFrame: df[df['type'] == '0'].head(5),该语句将一系列True/False对象传递给DataFrame,并将符合条件的所有行返回True。参与筛选的条件可以使用&|.isin()等来表示条件联合、条件或,以及列表包含等。

GroupBy分组

SQL: SELECT type, count(*) FROM df GROUP BY type;

DataFrame: df.groupby('type').size()

带聚合的GroupBy分组

SQL: SELECT client_code, max(time) FROM df GROUP BY grade;

DataFrame: df.groupby('client_code').agg({'time': np.max})

Join

SQL: SELECT * FROM df1 LEFT JOIN df2 ON df2.id=df1.id

DataFrame: pd.merge(df1, df2, how='left', left_on='id', right_on='id')

Distinct

SQL: SELECT DISTINCT client_code, time FROM df

DataFrame: df.drop_duplicates(subset=['client_code'], keep='first', inplace=True),其中keep表示保留哪一个值,可取firstlastFalse,分别表示第一个、最后一个和全部不保留。inplace参数默认为False,表示返回一个新的DataFrame,否则会在原来的DataFrame上进行修改。subset表示对哪些列进行排重。

可视化图表:Matplotlib

Matplotlib是Python中常用的数据可视化工具库,这个工具库能够创建多种数据类型的图表,比如:条形图、散点图、饼图等等。

Matplotlib可以直接使用pip完成安装,如果在导入模块时报错,需要考虑是否缺少了名为six的模块,如果是这样,就需要单独安装six模块。

以下示例应该弹出一个简单的带有坐标图形的窗口。

# macOS需要添加以下两句来配置Matplotlib使用Tk框架
import matplotlib
matplotlib.use('TkAgg')
# 以上为macOS系统使用的语句
import matplotlib.pyplot as plt
plt.plot([1,2,3], [5,7,4])
plt.show()

Matplotlib默认的视图窗口中是带有图形浏览功能的,读者可以自行探索一番。

图例和标题

在之前的例子中,通过给.plot()函数传入两个序列来给定线条的位置。但是当图形中有多条线条时,往往需要使用图例来对线条进行标记。此时可以使用.plot()的命名参数label来为线条指定名称。最后在使用.show()函数显示图形之前,要使用.legend()函数生成图例。

大部分的图形都会使用标题,Matplotlib中的图形标题使用.title()函数进行设定。标题同样要在显示图形之前设定。

条形图

前面示例中所使用的.plot()是创建折线图的函数,Matplotlib通过提供不同的图形函数来产生不同的图形。对于生成条形图,Matplotlib提供了.bar()函数。

.bar()函数常用的参数有:

  • x,x轴坐标数据,序列类型。
  • height,y轴数据,序列类型。
  • width,形状宽度。
  • color,填充颜色。

Tip

这里只列出能够完成图形绘制的基本参数和常用参数,要获得更加丰富的图形绘制选项,可参考Matplotlib的文档。

Note

颜色设置在Matplotlib中可以直接使用十六进制RGB颜色值,例如#908E4F

直方图

直方图与条形图类似,但是主要用来显示一组数据在指定区间中的分布情况。直方图使用.hist()函数来生成。

.hist()函数常用的参数有:

  • x,输入数据。
  • bins,数据分隔区间,可认为是x轴。
  • histtype,条形图形状。
  • rwidth,条形图的相对宽度。
  • color,条形图填充颜色。
  • label,图例名称。

散点图

散点图通常用于比较两个变量来寻找相关性或者分组。散点图使用.scatter()函数来生成。

.scatter()函数常用的参数有:

  • x,数据x轴位置。
  • y,数据y轴位置。
  • s,标记的大小。
  • c,标记的颜色。
  • marker,标记的形状。

堆叠图

堆叠图通常用于表示部分与整体之间随时间变化的关系。堆叠图使用.stackplot()函数来生成。

.stackplot()函数常用的参数有:

  • x,x轴数据。
  • y,y轴数据,可包含多个序列。
  • labels,每条y轴的图例名称,序列类型,要对应y轴数据数量。
  • colors,每条y轴的填充颜色,序列类型,要对应y轴数据数量。

饼图

饼图通常用于显示部分对于整体的情况,通常以百分比为单位。Matplotlib在处理饼图时,会自动计算百分比,所以只需要传入实际数据即可。饼图使用.pie()函数生成。

.pie()函数常用的参数有:

  • x,输入数据,序列类型。
  • labels,每个数据的标签,序列类型。
  • colors,每个数据对应的颜色,序列类型。
  • explode,指示哪个数据从饼图中分离,并且指示分离距离,元组类型,例如(0.1,0,0,0)将分离第一个数据。
  • shadow,是否绘制饼图下的阴影,布尔类型。
  • autopct,饼图上绘制百分比的格式。
  • startangle,绘制起始角度。

样式

样式使得自定义图形时不必书写大量的代码,只需要载入指定样式就可以直接改变图形的外观。

要使用样式,需要先导入样式模块:from matplotlib import style,之后就可以使用style.use()来指定要使用的样式了。Matplotlib已经内置了一些样式,可以通过print(plt.style.available)来查看。

样式是可以自定义的,自定义样式的方法较为繁琐,可直接参考Matplotlib的文档,这里不再赘述。

子图

子图用来在图表上创建多个图表。子图的创建有两种方式。第一种是通过add_subplot()方法,第二种是通过subplot2grid()方法。但在首先,需要先获得整个图形的实例,来对其进行操作。

要获得整个图形的实例,可以使用fig = plt.figure()来获取。

.add_subplot()方法接受一个整型数字作为参数,其工作原理是使用3个数字来指定要创建的子图位置,这3个数字分别代表行数、列数和绘图编号,例如fig.add_subplot(221)表示两行两列的第一个位置。如果行列数大于10,这三个数字需要使用逗号分开传入。

.subplot2grid()方法则更加强大,它可以使用网格来排列图表。它首先接受一个元组参数,作为网格形状,例如(6, 1)表示6行1列的网格。之后传递一个图表左上角的起始点,例如(0, 0)表示最左上角的网格。之后可以使用rowspancolspan来定义图表所占的行数和列数。最后,subplot2grid()还可以接受一个名为sharex的参数用来指示图表共享指定图表的x轴。

实时图表

实时图表是使用了Matplotlib的动画功能,这需要引入Matplotlib的动画模块,即:import matplotlib.animation as animation

之后与创建子图一样,需要获得图表实例并且创建子图。然后创建一个绘图函数,但是这个绘图函数中需要做子图清除以及子图绘制的动作。最后将这个绘图函数放置到动画中并启动显示。例如:

import matplotlib.pyplot as plt
import matplotlib.animation as animation

fig = plt.figure()
ax1 = fig.add_subplot(1, 1, 1)

def animate(i):
	ax1.clear() # 清除子图
	ax1.plot(x, y) # 重绘子图

ani = animation.FuncAnimation(fig, animate, interval=1000) # 设置动画
plt.show()

添加文本

向图表中添加文本主要有两种方式,第一是将文本放置在图表的某个位置上,另一个是注解图表上的绘图。

添加文本同样要使用到子图功能,文本通过.text()方法绘制到图上。首先要使用传入图标的数据来给文本指定坐标,之后可以用fontdict参数来定义文本的字体、颜色和大小。

注解文本是通过.annotate()方法来创建。注解创建函数接受的参数首先是要注解的文本,其次是注解指向的坐标。接下来要使用xytext参数来指定文本所在的坐标位置,这个坐标可以是轴域或者小数比例(代表百分比)。最后可以使用arrorprops参数来绘制箭头。注解在图表上的位置是固定的,不会随着图表的拖移而移动,但是箭头会随着变换。

Tip

更多的图表绘制方法可以在使用时参考网上的示例或者Matplotlib文档。

内置GUI模块:Tkinter框架

Tkinter模块时Python的标准Tk GUI工具包的接口,Tkinter可以在大多数Linux系统、Windows系统和macOS系统中使用。并且可以根据所运行系统的不同,使用系统原生界面风格,兼容性十分优越。

Tkinter内置在Python的安装包中,对于简单界面和略复杂界面Tkinter可以应付自如。

最小应用

Tkinter可以直接使用import tkinter引入。以下代码显示了一个空白的窗口,是一个Tkinter的最小应用。

import tkinter
master = tkinter.Tk()
master.mainloop()

示例中,tkinter.Tk()建立了个窗口,而.mainloop()使应用进入了Tkinter的主事件循环。

另外一个略微复杂一些的最小应用是采用了类来统领窗口内容。

import tkinter as tk

class Application(tk.Frame):
	def __init__(self, master=None):
		tk.Frame.__init__(self, master)
		self.grid()
		self.create_widgets()
	
	def create_widgets(self):
		self.button_quit = tk.Button(self, text='Quit', command=self.quit)
		self.button_quit.grid()

app = Application()
app.master.title('Sample application')
app.mainloop()

示例中使用了网格布局,并且在其中添加了一个能够执行退出功能的退出按钮。在这个示例中,Application实例并不是顶级窗口,要在其中获取顶级窗口,需要调用.winfo_toplevel()

基础概念

在开始使用Tkinter开发GUI程序之前,需要先了解以下几个基础概念。

顶级窗口

由系统桌面管理器管理的,在桌面上独立存在的窗口。

组件

GUI应用中的功能零件,例如按钮、标签,框架等。

框架

用于组织复杂布局的基础功能单元,可以用来盛放其他组件。

计量单位

如果在程序中使用一个整型值作为长宽等维度值,那么它的单位将是“点”(Pixel)。除此之外还可以在字符串中使用以下单位。

  • c,centimeter,厘米。
  • i,inch,英寸。
  • m,millimeter,毫米。
  • p,打印机的点,约\(\frac{1}{72}\ inch\)。

坐标系

Tkinter采用原点位于左上角的直角坐标系,坐标向右向下为正值。

锚点

根据组件的上下左右以及斜向的八个方位,加上中心点,组件共有9个锚点,除中心点外,分别以东南西北的缩写命名,即:NNEESESSWWNW

网格布局

Tkinter中有多种组件布局方式,其中网格布局是目前最为推荐的组件布局方式。要将组件加入到网格布局,需要在组件上调用.grid()方法。.grid()方法接受以下参数来定义网格布局。

参数名功能
column所在网格列数,从0开始计数
columnspan同时占据列数
in_指定组件的父组件
ipadx内部左侧和右侧的留白
ipady内部上部和下部的留白
padx外部左侧和右侧的留白
pady外部上部和下部的留白
row所在网格行数,从0开始计数
rowspan同时占据行数
sticky指示组件在网格中的对齐位置

参数sticky可以影响组件的大小,其取值可参考以下方式。

  • 不给定sticky的值,组件以原始大小在所在网格中居中放置。
  • 赋值sticky=tk.NE等单一固定值,组件将放置在所在网格的相应锚点位置。
  • 赋值sticky=tk.N+tk.S将会使组件垂直方向拉伸贴近锚点并居中放置。
  • 赋值sticky=tk.E+tk.W将会使组件水平方向拉伸贴近锚点并居中放置。
  • 赋值sticky=tk.N+tk.E+tk.S+tk.W将会使组件在四个方向上拉伸以填满网格。
  • 赋值sticky=tk.N+tk.W将会使组件在相应方向上拉伸。

Tip

注意,网络布局的使用是在组件上调用.grid()方法,而不是在容器上调用。

在容器组件上调用.columnconfigure().rowconfigure()可以对指定行列进行配置。这两个方法接受的第一个参数是行列的索引,用来指定其后的配置应用在哪里。行列的配置主要由以下三项内容构成。

  • minsize,最小大小,以像素值描述。
  • pad,周围的留白大小。
  • weight,定义行列在缩放时的缩放比例权重。

组件内容的承载

Tkinter通常使用一个控制变量来承载组件的内容以及内容的变化。Tkinter提供了三种变量的类型:DoubleVarIntVarStringVar,分别用于承载浮点型、整型和字符型的内容。

控制变量类提供了两个方法来获取和设置组件的内容:

  • .get(),用来获取当前控制变量的值,也就是组件的值。
  • .set(s),用来设置当前控制变量的值,相关组件的内容也会同时发生改变。

常用组件

Tkinter有两种组件来源,一种是传统的来自于Tkinter模块的组件,另一种是来自ttk模块的带主题的组件。其中ttk模块中的组件兼容Tkinter模块的原生组件,但又扩展了几个新的组件。在应用设计中,可以尽量使用ttk模块的组件以获得更好的外观和功能。

通用属性

组件都具有一些通用的属性这些属性可以直接在所有组件中直接配置使用。

  • text,组件上需要显示的文本内容。
  • height,组件的高度,单位通常使用文字行高或者像素,ttk模块中的部分组件不支持此属性。
  • width,组件的宽度,单位通常使用字母宽度或者像素。
  • background或者bg,设定背景色,ttk模块中的组件不支持此属性。
  • foreground或者fg,设定前景色,ttk模块中的组件不支持此属性。
  • font,设定字体名称,ttk模块中的组件不支持此属性。
  • cursor,鼠标指向组件时的指针样式。
  • padx,额外的内部水平留白,ttk模块中的组件不支持此属性。
  • pady,额外的内部垂直留白,ttk模块中的组件不支持此属性。
  • padding,ttk模块中组件用于设定留白的属性。
  • takefocus,控制聚焦行为。
  • relief,组件的显示样式,默认是tk.FLAT,ttk模块中的组件不支持此属性。
  • style,ttk模块组件中指示组件样式的属性。
  • class_,ttk模块组件中组件的样式类名称。

按钮

按钮是最常用的组件,其创建格式为w = tk.Button(parent, option=value, ...)。其常用的配置属性有:

  • bitmap,按钮上的标准位图,用以替代文字。
  • image,按钮上的图片,用以替代文字。
  • command,按钮点击后执行的函数或者方法。
  • state,用以设定按钮状态,可取tk.DISABLED来禁止点击。
  • textvariable,设定用于控制按钮文本的StringVar类型控制变量。

画布

画布是一个用来显示图片或者展示其他复杂布局的方形区域,在其上可以放置图片、文字、组件甚至是框架。画布的创建格式为w = tk.Canvas(parent, option=value, ...)。常用配置属性有:

  • scrollregion,创建一个比可视范围更大的滚动区,使用一个元组来定义,格式为(w, n, e, s)
  • confine,设为True将不能滚动到滚动区之外。

画布能够容纳多种元素,并且支持在其上绘画,所以画布的使用较为复杂,读者可在需要使用画布时参考相关的文档。

输入框

输入框是典型的信息收集组件,允许使用者在其中输入内容。输入框的创建格式为w = tk.Entry(parent, option=value, ...)。常用的配置属性有:

  • show,在输入时,输入框内显示的文字,使用show='*'可以创建密码框。
  • validate,指定何时指定输入框内容的验证,可以取focusfocusinfocusoutkeyallnone
  • validatecommand,指定用于验证输入框内容的方法。
  • textvariable,指定用于控制输入框内容的StringVar类型控制变量。

输入框常用的方法有:

  • .get(),取回输入框当前的内容。
  • .delete(first, last=None),删除输入框中指定位置或范围内的内容。

复选框

复选框用于让用户进行多项选择。其创建格式为w = tk.Checkbutton(parent, option=value, ...) 。常用的配置属性有:

  • command,当用户改变复选框时执行的动作。
  • indicatoron,当设为0时,整个复选框变为可切换状态的按钮。
  • offvalue,当不被选中时的值。
  • onvalue,当被选中时的值。
  • variable,指定关联控制变量,可以是StringVar或者IntVar类型。

在使用复选框时建议使用一个类型为IntVar的控制变量来控制和获取复选框的状态。

框架

框架是最基础的组件容器,每个框架都拥有独立的网格布局管理器。并且应用可以通过继承框架来组织功能相关的组件,以实现应用的模块化。框架的创建格式为w = tk.Frame(parent, option=value, ...)

标签

标签是用于在窗口上显示一行或者多行文字、图片的组件。标签的创建格式为w = tk.Label(parent, option=value, ...)。常用的配置属性有:

  • anchor,内容的对齐位置。
  • bitmap,加载标准位图,用以替代文字。
  • image,加载图片,用以替代文字。
  • textvariable,指定用于控制内容的控制StringVar类型控制变量。

命名框架

命名框架是一个特殊框架,其边框上可以附带一个标签。命名框架的创建格式为w = tk.LabelFrame(parent, option=value, ...)。常用的配置属性有:

  • labelanchor,标签放置位置。
  • labelwidget,使用非内部定义的标签组件。

列表

列表是用来按行列出数据的组件,通常用来提供用户进行单项选择或者多项选择使用。列表的创建格式为w = tk.Listbox(parent, option=value, ...)。常用的配置属性有:

  • activestyle,激活行的显示样式,可选值有underlinedotboxnone
  • listvariable,指定一个用于关联全部列表内容的StringVar类型变量。
  • selectmode,设定用户可选择内容的数量,可选值有tk.BROWSEtk.SINGLEtk.MULTIPLEtk.EXTENDED

常用的方法有:

  • .activate(index),激活指定行。
  • .curselection(),返回当前选择的行,没有已选择的行返回空白元组。
  • .delete(first, last=None),删除指定行。
  • .get(first, last=None),返回指定行。
  • .index(i),滚动指定索引内容到列表顶端。
  • .insert(i, *elements),插入内容到指定索引位置。
  • .itemconfig(index, option=value, ...),改变指定索引内容的配置,如前景、背景等。

列表组件不包含滚动条,所以在需要滚动条的时候需要将其与滚动条连接起来。以下给出一个可以参考的示例。

self.yScroll = tk.Scrollbar(self, orient=tk.VERTICAL)
self.yScroll.grid(row=0, column=1, sticky=tk.N+tk.S)

self.listbox = tk.Listbox(self, yscrollcommand=self.yScroll.set)
self.yScroll['command'] = self.listbox.yview

Tip

其他需要滚动条的组件也可以这样进行连接,包括标签、输入框、文本框等。

消息

消息是一个类似于标签的多行文本组件,其中的内容会保持一致的字体样式。消息的创建格式为w = tk.Message(parent, option=value, ...)。常用的配置属性有。

  • aspect,宽高比,取值100时为正方形,取值200时,宽为高的2倍。
  • textvariable,指定用于控制消息文本的控制变量。

单选框

单选框用于提供用户在一组选项中做出选择。将多个单选框组成一个功能组的方法是将其绑定到一个控制变量上,控制变量可以是IntVar或者StringVar类型。单选框的创建格式为w = tk.Radiobutton(parent, option=value, ...)。常用的配置属性有。

  • command,指定当用户改变单选框状态时执行的函数。
  • variable,指定单选框的控制变量。

刻度条

刻度条允许用户在一定的值区间内选择一个值。刻度条的创建格式为w = tk.Scale(parent, option=value, ...)。其常用的配置属性有。

  • digits,指定数字位数。
  • from_,浮点型,指定刻度条的区间起始值。
  • orient,指定刻度条的绘制方向,可选tk.HORIZONTALtk.VERTICAL
  • resolution,指定用户可以移动的最小步进值。
  • to,浮点型,指定刻度条的区间终止值。

滚动条

Tkinter中的组件都是不带滚动条的,在需要滚动条的位置需要手工建立并绑定。滚动条的创建格式为w = tk.Scrollbar(parent, option=value, ...)。常用的配置选项有。

  • command,当滚动条滚动时调用的函数。
  • orient,指示滚动条的放置方向。

大部分支持滚动条的组件都带有.xview()或者.yview()方法,将此方法绑定到相应滚动条的command属性上就可以实现组件对滚动动作的支持。但滚动条一般还需要跟随组件自身的滚动产生状态变化,这就需要将组件的xscrollcommand或者yscrollcommand绑定为滚动条的set方法,使两者之间保持联系。

转轮数字框

转轮数字框常用于让用户在一个指定区间或者值群内选择一个数字。其创建格式为w = tk.Spinbox(parent, option=value, ...)。常用配置属性有。

  • format,数字显示格式。
  • from_,区间的起始值。
  • increment,增长步进值。
  • to,区间的终止值。
  • values,列表类型,可以是字符串列表,提供固定的可选值。

文本框

文本框是用来编辑多行文本的组件,常用来做编辑器使用。其创建格式为w = tk.Text(parent, option=value, ...)。常用配置属性有。

  • autoseparators,当设置了Undo选项,该选项会自动控制编辑栈。
  • maxundo,最大Undo次数。
  • tabs,指定Tab键代表的空格数量。
  • undo,指示是否启用Undo功能。

文本框支持使用索引来对其中的内容进行访问。具体索引的格式及使用方法可以参考Python的官方文档。利用这些基于索引功能的方法可以做出一套功能比较全面的编辑器。

组合框

组合框是由输入框和下拉菜单组成的选择框。组合框由ttk模块提供,创建格式为w = ttk.Combobox(parent, option=value, ...)。常用的配置属性有。

  • values,下拉菜单中的可选项列表。
  • validate,输入框的验证时机。
  • validatecommmand,输入框的验证函数。
  • textvariable,指定控制输入框内容的控制变量

进度条

进度条是用来提示用户工作进度或者程序状态的组件,其创建格式为w = ttk.Progressbar(parent, option=value, ...)。常用的配置属性有。

  • length,组件的长度。
  • maximum,指示器的最大值,默认为100。
  • mode,指示器的工作模式,'indeterminate'模式为循环状态工作模式,'determinate'模式为进度提示模式。
  • orient,进度条的放置方向。
  • variable,指定用于设置和获取当前值的控制变量。

常用的方法有。

  • .start([interval]),启动进度条自动前进,定时移动进度条。
  • .step([delta]),使进度条的值增加delta
  • .stop(),停止自动进度条。

分割线

分割线就是在界面上划出的一道线,用来将组件进行视觉隔开。其创建格式为w = ttk.Separator(parent, option=value, ...)。常用的配置属性有。

  • orient,分割线的放置方向。

标签页

标签页用于在同一块位置盛放多页内容,并像卡片一样切换。创建格式为w = ttk.Notebook(parent, option=value, ...)。常用方法有。

  • .add(child, **kw),用于添加一个Frame,kw为新Frame页面的属性。
  • .forget(child),永久性移除一个页面。
  • .hide(tabId),临时隐藏一个页面。
  • .insert(where, child, **kw),在指定位置插入一个页面。
  • .select(tabId),获取当前页面,或者指定显示某个页面。
  • .tab(tabId, option=None, **kw),设置指定页面的属性。

标签页中每个页面都有以下属性可供访问和设置。

  • compound,图片或者文字的放置位置。
  • padding,四周的留白。
  • sticky,设定页面四边贴近的位置。
  • image,设置在标签上显示图片。
  • text,设置在标签上显示的文字。

树形列表

树形列表是一个强大的组件,不仅可以创建树形的内容,还可以作为表格组件使用。要建立一个树形列表,一般可以使用以下步骤。

  • 使用构造器建立组件,并且使用columns属性指定要显示的列的数量,并指定每一列的名称。
  • 使用.column().heading()方法建立列表头,并且配置各列的属性和宽度。
  • 从顶级内容开始添加,用.insert()方法建立整个树形结构。用.open()方法决定每个分支的展开或者收缩状态。

树形列表的创建格式为w = ttk.Treeview(parent, option=value, ...)。常用的配置属性有。

  • columns,由字符串组成的列识别标识序列,定义需要显示哪些列。
  • displaycolumns,定义要显示的列的顺序。
  • height,每行的高度。
  • padding,树形列表内部留白。
  • selectmode,内容选择方式,可取值有'browse''extended''none'

常用的方法有。

  • .column(cid, option=None, **kw),用于配置逻辑列的外观,逻辑列的cid可以使用列索引或者列标识。图标列需要使用cid为'#0'。每个逻辑列有以下配置项。
    • anchor,列中内容的对齐位置,使用锚点名设置。
    • id,列名。
    • minwidth,列最小宽度。
    • stretch,缩放时是否拉伸列,默认值为1,可以设为True
    • width,指定初始宽度,默认值为200。
  • .delete(*item),删除并销毁符合指定iid的行。
  • .detach(*item),删除符合指定iid的行。
  • .exists(iid),检索指定iid是否于列表中存在。
  • .heading(cid, option=None, **kw),配置指定列的标头,图标列的cid使用'#0'。可以用的配置项有。
    • anchor,内部文字的对齐位置,使用锚点位置。
    • command,设定当用户点击操作时的处理函数。
    • image,设定显示图片。
    • text,设定显示文本。
  • .set_children(item, *newChildren),为指定元素设定新的字内容。
  • .insert(parent, index, iid=None, **kw),插入一个进的元素,如果插入顶级元素,parent需要保持为空白字符串。
  • .item(iid[, option[, **kw]]),获取元素或者设置指定元素的属性。
  • .move(iid, parent, index),移动指定元素。
  • .selection_add(items),向选中内容中添加指定元素。
  • .selection_remove(items),从选中内容中去除指定元素。
  • .selection_set(items),将指定元素设为选中状态。
  • .selection_toggle(items),切换指定元素的选中状态。

使用菜单

菜单是由两个元素一个是菜单按钮,另一个是菜单列表。菜单列表是用户点击菜单按钮之后出现的,而菜单按钮是应用中始终显示的内容。

菜单按钮的创建格式为w = tk.Menubutton(parent, option=value, ...)。常用的配置属性有。

  • direction,指示菜单列表出现在菜单按钮的那个方向上。
  • menu,指定菜单按钮点击后对应的菜单列表。
  • textvariable,指定菜单按钮的控制变量,StringVar类型。

菜单列表的创建格式为w = tk.Menu(menubutton, option=value, ...)。菜单列表中的内容可以有文字或图片指令、复选框、单选框以及子菜单选项。菜单列表常用的方法有。

  • .add(kind, coption, ...),向菜单列表中添加指定类型的新元素,元素类型可以是cascade(子菜单列表,相当于调用.add_cascade())、checkbutton(复选框,相当于调用.add_checkbutton())、command(普通菜单项,相当于调用add_command())、radiobutton(单选框,相当于调用.add_radiobutton())、separator(分隔线,相当于调用add_separator())。

创建菜单时的coption配置可以使用以下常用属性。

  • accelerator,快捷键设置。
  • command,点击是要执行的函数。
  • label,菜单文字内容。
  • image,菜单图片内容。
  • menu,子菜单内容。
  • variable,复选框和单选框使用的控制变量设置。

对于macOS来说,菜单一般是显示在顶级窗口上的,也就是桌面顶部的共享菜单栏。这种需求下,可以将菜单的parent设置为顶级窗口,即调用.winfo_toplevel()的返回结果。Windows和Linux中使用这样设置菜单列表,可以获得应用全局菜单栏。

对话框

对话框功能是由tkinter.messagebox模块提供的。其中提供了以下函数用来显示一个对话框,并可以返回相应类型的值。tkinter.messagebox模块中的函数其功能都非常容易识别。

  • .askyesno(title, message),返回布尔类型,显示问号图标,按钮为“是”、“否”。
  • .askquestion(title, message),返回字符串,显示问号图标,按钮为“是”、“否”。
  • .askokcancel(title, message),返回布尔类型,显示问号图标,按钮为“确定”、“取消”。
  • .askyesnocancel(title, message),返回布尔类型,显示问号图标,按钮为“是”、“否”、“取消”,其中取消返回None。
  • .askretrycancel(title, message),返回布尔类型,显示三角图标,按钮为“重试”、“取消”。
  • .showinfo(title, message),返回字符串,显示蓝色圆形图标,按钮为“确定”。
  • .showwarning(title, message),返回字符串,显示三角图标,按钮为“确定”。
  • .showerror(title, message),返回字符串,显示红色圆形图标,按钮为“确定”。

文件选择对话框

文件选择对话框是由tkinter.filedialog模块提供的。这个模块主要提供了打开文件和保存文件的对话框。

  • .askopenfilename(),打开文件选择对话框,并返回一个文件的完整路径,选择取消则返回空字符串。
  • .askopenfilenames(),打开文件选择对话框,并返回一个元组,其中包括所有已选择的文件完整路径。
  • .asksaveasfilename(),打开文件选择对话框,可以创建文件,返回要创建的文件的完整路径。

文件选择对话框可以使用以下共用的参数配置。

  • defaultextension,指定文件后缀。
  • filetypes,指定筛选文件类型的下拉框选项。
  • initialdir,指定默认路径。
  • parent,指定显示在哪个窗口之上。

SDL游戏库:pygame

Pygame是一个利用SDL库编写的游戏库。SDL全名是Simple Direct Media Layer,是使用C语言编写的,也可以使用C++进行开发,Pygame就是在SDL库基础之上建立的使用Python来开发SDL应用的库。相比于其他的游戏库,pygame虽然可以利用OpenGL进行3D游戏的开发,但是其更加专长于2D游戏的开发。就游戏开发而言,底层的内容永远是相通的。

Pygame可以直接使用pip install pygame进行安装,但是在使用过程中,需要注意pygame运行所需要的支持库是否完备,如果pygame报错,那么就需要去安装相应的支持库。

模块列表

Pygame中提供了众多的模块来完成不同的功能,以下给出一张概览表,方便在开发时查阅相关文档。

模块名称功能
pygamepygame核心模块
pygame.cdrom光驱访问模块
pygame.cursors鼠标光标控制模块
pygame.display显示设备控制模块
pygame.draw绘图模块
pygame.event事件管理模块
pygame.font字体控制模块
pygame.image图片加载、存储模块
pygame.joystick游戏手柄及控制杆控制模块
pygame.key键盘控制模块
pygame.mixer声音管理模块
pygame.mouse鼠标控制模块
pygame.music音频播放模块
pygame.overlay高级视频叠加处理模块
pygame.rect矩形区域管理模块
pygame.sndarray声音数据处理模块
pygame.sprite精灵管理模块
pygame.surface图像及屏幕管理模块
pygame.surfarray点阵图像管理模块
pygame.time时间与帧信息管理模块
pygame.transform图像变换模块

某些模块在不同的系统平台上可能是不可用的,所以在使用前可以使用if pygame.font is None:来测试一下。

最小应用

一下通过一个最小应用来看一下pygame的运行效果,在执行以下代码之前,请先准备一张图片。

import sys
import math
import pygame
import pygame.locals


# 初始化pygame
pygame.init()

# 创建一个800×600的窗口
screen = pygame.display.set_mode((800, 600), 0, 32)
# 设置窗口标题
pygame.display.set_caption('Hello World')

# 加载一张图片
background = pygame.image.load('sample.jpg').convert()
# 对图片进行缩放处理
(b_width, b_height) = background.get_size()
ratio = b_width / 800 if b_width >= b_height else b_height / 600
scaled_background = pygame.transform.scale(
	background, 
	(math.floor(b_width / ratio), math.floor(b_height / ratio))
)

while True:
	for event in pygame.event.get():
		# 对应用中出现的事件进行处理,如果出现退出事件则结束程序
		if event.type == pygame.locals.QUIT:
			sys.exit()
	
	# 将图片绘制到窗口中,居中绘制
	(sb_width, sb_height) = scaled_background.get_size()
	screen.blit(
		scaled_background, 
		(math.floor((800 - sb_width) / 2), math.floor((600 - sb_height) / 2))
	)
	
	# 刷新整个画面
	pygame.display.update()

一些基础概念

精灵

精灵是游戏开发中的一个重要概念,它代表游戏画面中的一个最小的图像元素单元。比如一个人物,或者一串文本。

渲染

绘制游戏画面并将其显示在屏幕上的整个过程,一般称为“渲染”。

碰撞检测

游戏在处理精灵交互时,常常需要判断两个或多个精灵是否产生了接触。使用游戏开发的术语,这种接触就称为碰撞。碰撞检测一般有多种方法,其检测的精度也不尽相同。在进行碰撞检测的方法选择时,需要平衡考虑碰撞检测的精度和计算效率,如果计算过于复杂,可能会拖累画面的渲染。

双缓冲

按照一般的处理循环,画面的绘制是采用“绘制-更新”的流程来完成的,当画面的绘制没有完成时,不会向屏幕上更新画面。这在一些绘制任务繁重的画面处理过程中,可能会导致帧速严重下降,也就是常说的“卡顿”。双缓冲是在“绘制-更新”的基础上,将绘制任务提前进行,在每次更新屏幕画面时,下一帧的内容就已经绘制完成并缓存在显存中了。这在一定程度上减少了“卡顿”。双缓冲一般都是与硬件加速搭配使用。

处理循环

游戏的主循环一般是一个无限循环,其处理流程一般是按照以下顺序进行的:

  1. 帧速控制;
  2. 用户操作事件处理;
  3. 游戏规则逻辑处理;
  4. 精灵运动及碰撞处理;
  5. 绘制画面缓存;
  6. 更新画面。

创建基本窗口

Pygame中的基本图像结构是pygame.Surface,它代表着游戏中所需要处理的全部图像对象。前面示例中创建的基本窗口也是一个Surface对象。

在对pygame进行初始化之后,就可以创建基本窗口了。这个窗口是通过pygame.display.set_mode(resolution, flags, depth)来创建的。其中resolution是所创建的窗口的分辨率,是一个由两个整型值组成的元组,分别代表窗口的宽度和高度,计算单位是“像素”。

flags则是表示窗口中渲染所使用的方法,如果不需要特殊的渲染方法和窗口状态,可以传递0值。flags支持使用以下值来指示窗口的渲染和状态。

  • pygame.FULLSCREEN,创建一个全屏显示。
  • pygame.DOUBLEBUF,创建一个双缓冲显示,一般与硬件加速和OpenGL搭配使用。
  • pygame.HWSURFACE,创建一个硬件加速显示,只可以在全屏显示中使用。
  • pygame.OPENGL,创建一个使用OpenGL渲染的显示。
  • pygame.RESIZABLE,创建一个可变大小的窗口。
  • pygame.NOFRAME,创建一个没有边框的窗口。

如果需要使用多个值,可以使用|来将多种状态连接起来,例如:pygame.FULLSCREEN | pygame.HWSURFACE

depth表示画面的颜色深度,这项一般可以省略不给出,pygame会根据当前系统自动选择一个最有效率的颜色深度来使用。

如果需要在程序运行过程中切换窗口和全屏,可以直接调用.set_mode()来设置新的窗口状态。如果将窗口设置为了可变大小模式,那么就需要在事件处理中响应pygame.locals.VIDEORESIZE事件来重新设置窗口状态,否则窗口虽然大小变化了,但是窗口所对应的Surface对象却没有进行响应的变化,必须让两者同步才能达到缩放窗口的目的。

加载图片

在pygame中,最核心的类就是Surface,加载图片就是将一个图片文件加载成一个Surface对象。pygame支持JPEG、PNG、GIF、BMP、PCX、TGA、TIF等常用的图片文件类型。尽管输入的图像文件格式不同,但是在pygame中都是使用Surface对象来进行操作,整个pygame世界就是由各种Surface对象组成的。

Tip

从前面可以注意到,窗口中可供操作的内容也是一个Surface对象。

加载图片到Surface可以像前面最小应用中一样,直接使用pygame.image.load('文件名')即可。通常加载完图片后并不需要理会其中的内容,但是将Surface对象转换一下可以获得更好的性能。在前面的最小应用中也进行了这样的转换。

Surface的转换有两个函数,.convert().convertAlpha(),它们的区别就是是否携带Alpha通道。如果需要透明内容,那么就需要使用带Alpha通道的转换。

使用字体

Pygame可以直接调用系统字体,也可以使用字体文件。pygame的字体功能是由pygame.font模块提供的。要使用字体,必须要先创建一个Font对象。

pygame.font.SysFont('字体名', 字体大小)可以用来建立一个使用系统字体的Font对象,其中第一个参数是系统中的字体名称,第二个参数是字体的字号大小。字体这个东西,每个人的系统中可能都是不一样的,所以更多的时候还是使用TTF字体文件来建立Font对象。使用TTF字体文件来建立Font对象可以直接使用Font类的构造函数pygame.font.Font('字体文件名', 字体大小)

创建完Font对象之后,就可以使用Font对象中的.render()函数来生成文字内容对应的Surface对象了。.render()函数可以接受四个参数,其完整调用格式为.render('文字内容', 反锯齿优化, 文字颜色, 背景颜色)。其中反锯齿优化参数为布尔值,开启时字体显示会比较平滑;文字颜色和背景颜色都是一个由三个整型数值组成的元组,分别对应红、绿、蓝三原色的值,如果不需要背景色,那么可以省略第四个参数。

以下示例会将文字Surface保存成一个图片文件。

import pygame
pygame.init()
custom_font = pygame.font.SysFont("arial", 64)
hello_surface = custom_font.render("Hello World", True, (0, 0, 0), (255, 255, 255))
pygame.image.save(hello_surface, "hello.png")

渲染画面

从图片加载到使用字体生成,我们现在手头应该已经有了不少的Surface对象。要将其绘制到屏幕上,就需要对其进行渲染。

从前面的最小应用中可以看到,语句screen.blit()将指定的图片Surface对象绘制在了自身的指定位置,并在最后使用pygame.display.update()刷新了整个画面。在实际的应用中,都是通过.blit()函数来逐一绘制相应的Surface。

在渲染画面时,还是有一定技巧的,通过应用这些技巧,可以对绘制工作进行提速。

分区绘制

在很多游戏中,通常并不需要进行全屏绘制,而是先将屏幕分为不同的显示区,分别显示不同的信息。pygame也支持对屏幕进行分区绘制,这样在绘制的时候只需要绘制出现变化的区域即可。分区绘制会减少很多不必要的绘制工作,从而使绘制过程提速。

对屏幕进行分区是使用.set_clip()函数实现的,这个函数接受四个整型数值参数来定义一个矩形区域。在绘制时,可以通过.get_clip()函数来获得绘制区域。在设定分区之后,其后的绘制操作将只会影响这个区域内的像素,如果需要绘制其他的区域,则需要再次调用.set_clip()来激活相应的绘制区域。

Subsurface

把所有的图像素材都保存成单独的文件是一种十分不合理的行为,连续不断的加载图片会大量消耗内存,最后可能会中会出现的结果就是内存不足。通常处理图片的方式是将相同类型或者内容的素材放置在一个图片里,在加载之后动态进行切割,这样会节省不少的内存。

Pygame中的Subsurface可以通过Surface对象的.subsurface()函数来获取,这个函数接受两个元组作为参数,分别代表左上角和右下角的坐标值,以此来对Surface进行切割并以Surface对象返回指定区域内的内容。

Surface锁定

Pygame在绘制Surface时,会自动对Surface进行锁定,并在绘制结束后自动解锁,以防止多线程操作时其他线程的干扰。但是连续不断的锁定和解锁操作会消耗大量的时间,使得锁定操作十分没有效率。为了解决这个问题,我们可以手动对Surface进行锁定,

Warning

但是千万要记得解锁,否则pygame会被卡住而失去响应。

锁定可以直接调用Surface对象的.lock()函数,而解锁则是直接调用.unlock()函数。

读取事件

事件是驱动游戏进行和分支的重要部分,没有事件的存在,游戏只会在原地踏步并且不能响应任何玩家的操作,甚至连退出游戏都十分困难。事件不仅会来自于用户,也可能会来自系统,事件随时可能发生,并且量级巨大。pygame支持用户的各种操作,包括键盘、鼠标还有游戏手柄等,并且使用队列来处理这些巨大量级的事件。

在实际开发中,常常用pygame.event.get()这个生成器来读取队列中的事件。pygame.event.get()通常不会阻塞游戏的主循环,而pygame.event.wait()就不一样了,它会让整个程序停下来等待下一个事件的发生。

事件通常可以使用event.type属性来判断这个事件的类型,通过区分不同的事件类型,我们可以在程序中做出响应的处理。pygame中常用的事件类型有以下这些。

事件类型产生途径可用成员
QUIT用户按下窗口关闭按钮或者执行退出
ACTIVEEVENTpygame窗口获得焦点或者失去焦点gainstate
KEYDOWN键盘被按下unicodekeymod
KEYUP键盘被放开keymod
MOUSEMOTION鼠标移动posrelbuttons
MOUSEBUTTONDOWN鼠标按键按下posbutton
MOUSEBUTTONUP鼠标按键抬起posbutton
JOYAXISMOTION手柄移动joyaxisvalue
JOYBALLMOTION手柄控制球移动joyaxisvalue
JOYHATMOTION手柄数位杆移动joyaxisvalue
JOYBUTTONDOWN手柄按键按下joybutton
JOYBUTTONUP手柄按键抬起joybutton
VIDEORESIZE窗口缩放sizewh
VIDEOEXPOSE窗口显示
USEREVENT用户自定义事件

键盘事件

键盘事件比较简单,只是对按下的按键进行记录,不同的成员用来记录不同类型的按键。key用来记录按下或者释放的键值,由于键值是一个整型数字,并不方便使用,所以pygame在pygame.locals中提供了一系列的常量来供使用,其中按键采用K_xxx来表示,例如K_a表示按下了字母A键,空格和回车分别是K_SPACEK_RETURN

mod用来记录组合键信息,组合键通常使用位运算来判断,例如event.mod & pygame.locals.KMOD_CTRL如果为真,则表示用户按下了Ctrl键。常用的其他按键还有KMOD_SHIFTKMOD_ALT

pygame.key模块中,还提供了一个.get_pressed()函数,可以获得当前所有已经按下的键盘的值。这个函数会返回一个元组,元素的索引是键值,值为是否按下。所以对已经按下的键的判断还可以使用以下方式来完成。

pressed_keys = pygame.key.get_pressed()
if pressed_keys[K_SPACE]:
	# 处理空格键按下的逻辑
	pass

同样,获取组合键信息的函数是.get_mods()

处理鼠标事件

MOUSEMOTION事件在鼠标动作时产生,会在产生的事件中提供三个成员,其中buttons成员是一个元组,其中有三个整型值,分别代表左、中、右三个键,哪个值为1,就代表对应的哪个键被按下了。pos成员表示目前鼠标的位置。rel成员表示当前事件与上次事件发生时的鼠标位置距离。

MOUSEBUTTONDOWNMOUSEBUTTONUP两个事件只在鼠标按键点击和释放的时候产生,其中button成员代表哪个按键被操作,pos依旧代表发生事件时的鼠标位置。

阻止事件和自定义事件

通常不是所有的事件都是程序感兴趣的,但是pygame将其排入了队列,即便是不予理睬也会稍微的影响性能。这时可以使用pygame.event.set_blocked([事件类型名称])来对事件进行过滤,所有列在列表中的事件类型,pygame都会不再捕获。相反,使用pygame.event.set_allowed()可以用来设置允许的事件。

Pygame也支持自定义事件,为了使用自定义事件,需要先创建一个事件,再将其发送到pygame的事件队列中。这里的自定义事件并不仅是前表中的USEREVENT,也可以是自定义事件类型,或者是按键、鼠标等事件类型。例如可以使用以下示例来发送键盘事件和自定义事件。

# 创建键盘事件
key_event = pygame.event.Event(KEYDOWN, key=K_SPACE, mod=0, unicode=u' ')
pygame.event.post(key_event)

# 使用字典创建键盘事件
key_event = pygame.event.Event(KEYDOWN, {'key': K_SPACE, 'mod': 0, 'unicode': u' '})

# 创建自定义事件
ANEWUSEREVENT = USEREVENT + 1
custom_event = pygame.event.Event(ANEWUSEREVENT, message='Hello from custom event')
pygame.event.post(custom_event)

控制帧速

帧速永远是游戏和硬件之间的较量,同样的游戏在不同配置的机器上运行帧速肯定是不一样的。在没有对帧速进行控制时,pygame永远是使用最大火力运行。但是我们往往并不需要过高的帧速。对于一般游戏来说,30FPS的帧速已经可以提供流畅的体验了,而目前的LCD显示器一般可以支持60FPS的帧速。帧速高于60FPS已经没有很大的意义了。

Pygame的pygame.time模块中提供了一个Clock对象,可以用来对帧速进行控制。以下例程将最大帧速控制在了30FPS。

clock = pygame.time.Clock()
# 返回一个距离上次调用的时间差,单位是毫秒
time_passed = clock.tick()

while True:
	time_passed = clock.tick(30)

示例中的clock.tick(30)将帧速限制在了30FPS,这是游戏运行的最大帧速。但是需要注意的是,游戏的实际帧速很有可能达不到这个值。

精灵的运动

前面提到游戏中出现的物体一般都成为精灵,精灵在画面上不可能是完全静止不动的。结合前面提到的帧速控制,就会出现一个不得不处理的问题:无论在哪个帧速下,精灵的运动都是与时间相匹配的,不可能在配置较好的机器上精灵的运动是在1秒钟完成,而配置较差的机器上则会大于1秒。

这就要求在帧速较低的机器上,精灵需要有更高的运动速度,反之,在帧速较高的机器上,精灵的运动速度可以较低。针对这种要求,我们可以给精灵定义一个基础运动速度,结合前面clock.tick()函数返回的时间差,来计算精灵的运动距离,之后再进行相应的绘制。这样在不同的机器上看起来,精灵的运动效果就一致了。

Tip

但是在配置较低的机器上,会明显感觉到精灵的运动是一跳一跳的,这是非常正常的。

所以,在游戏中产生动画无非就是在每一帧中,将前一帧中精灵的位置进行一些变化。这种变化可以通过与时间相关的量来进行计算得出,如果使用向量来进行计算,那么这种运动的计算将会十分简单。游戏的开发会使用到大量的数学知识,所以在不知所措的时候,请尽情的翻数学书吧。

OpenGL游戏库:pyglet

pyglet主要应用于OpenGL开发,其应该算是一个游戏功能库,而不是一个GUI功能库。要在项目中使用pyglet,可以直接使用pip install pyglet进行安装。

这一章节将对pyglet的基本使用进行说明,pyglet的深入使用还需要参考其文档。

最小应用

一个最小应用是了解一个功能库的最简单的方式,这次,示例将通过在窗口中间输出“Hello World”来完成最小应用的构建。

在编辑器内录入一下代码并执行,就可以看到最小应用的运行结果。

import pyglet

window = pyglet.window.Window(800, 600)
label = pyglet.text.Label('Hello World',
			font_name='Time New Roman',
			font_size=36,
			x=window.width//2, y=window.height//2,
			anchor_x='center', anchor_y='center')

@window.event
def on_draw():
	window.clear()
	label.draw()

# 启动pyglet应用并进入主循环
pyglet.app.run()

这个示例会建立一个\(800 \times 600\)的窗口,并在窗口的中间输出“Hello World”字样,当按下ESC键时,程序会退出。

OpenGL上下文

对于一般的应用来说,这一部分内容比较Low-Level,但是对于需要优化的应用来说十分有用。

当使用window = pyglet.window.Window()创建一个Window对象时,就已经创建了一个OpenGL上下文,这个上下文可以通过window.context来访问。对于上下文的相关配置都存储在上下文的.config属性中。

对于上下文的配置可以在创建Window对象之前通过config = pyglet.gl.Config()建立需要的配置组合,并使用window = pyglet.window.Window(config=config)来使用自定义配置创建Window对象以及OpenGL上下文。

常用的配置项有以下这些:

  • buffer_size,采样缓冲区位数大小,通常可以取24或者32,表示每个颜色使用8位。
  • sample_buffer,结合samples配置多重采样的配置,取值0或者1。
  • samples,多重采样的数量,取值从0到4,数字越大,显示质量越好,但设备负载越大。
  • stereo,对于将立体影像的左右通道分离。
  • double_buffer,设置是否打开双重缓冲,关闭双重缓冲会使屏幕重绘立刻进行,容易导致画面撕裂,默认打开。
  • depth_size,设置3D渲染时的深度缓冲大小,默认为24。
  • major_version,使用OpenGL的主要版本号,可以选择OpenGL 3.x或者OpenGL 4.x。
  • forward_compatible,设置是否向前兼容。

Display和Screen

在pyglet中,Display表示一组Screen的组合,而Screen就是表示一块物理屏幕。pyglet可以设置Window对象所在的Display或者Screen位置。但是在设置Window对象之前,必须能够先获得系统中存在的Display和Screen列表。

系统中存在的Display列表可以通过pyglet中唯一的Platform类实例中获取。当获取了Display之后,就可以通过其get_screens()来获得物理屏幕列表。

Window

Window是pyglet显示内容的主要位置,Window可以使用pyglet.window.Window()创建,在默认情况下,将自动创建一个\(640 \times 480\)的窗口,并且建立一个全部采用默认值的OpenGL上下文。可以通过给定两个整型值来改变窗口的大小。

Window在创建的时候可以使用screendisplay参数指定其要显示到的屏幕和显示组。并且可以通过fullscreen参数来设定是否要全屏显示。

在应用运行过程中可以通过window.set_fullscreen()方法来改变窗口的全屏状态。

窗口在应用运行期间的大小是可变的,改变窗口大小的方法有以下几种。一是通过在创建窗口时设定resizeable=True来使窗口成为可变大小窗口来允许用户自行调整窗口的大小。二是通过window.set_size(width, height)方法来设定窗口的大小。

除了窗口的大小可以重设以外,还可以通过window.set_location(x, y)来该笔安窗口在屏幕上的显示位置。

除了大小与位置之外,窗口还可以在创建实例时通过stylecaption和来icon来设置窗口的显示样式、标题和图标。pyglet支持的窗口样式主要有三种:

  • WINDOW_STYLE_DEFAULT,默认窗口样式。
  • WINDOW_STYLE_DIALOG,对话框样式,只能关闭,不能最大化。
  • WINDOW_STYLE_TOOL,工具窗口样式,没有图标,只能关闭,并且标题栏较窄。
  • WINDOW_STYLE_BORDERLESS,无框窗口。

要在窗口上设置图标有一定的要求,首先要求是一张正方形的图片,其次在不同的系统中,图片的尺寸大小也不相同。

  • Windows系统中,使用宽高为16和32像素的图片。
  • macOS系统中,使用宽高为16、24、32、48、72和128的图片。
  • Linux系统没有特殊要求,一般与Windows系统采用相同配置。

前面最小应用示例中的@window.event修饰器表示其修饰的函数是一个窗口事件处理函数。事件处理函数的函数名都是固定的,表示响应指定的事件。比如on_draw()就表示响应绘制画面事件,其他可以使用的事件还有on_resize()on_key_press()on_mouse_press()等,可以在使用时参考pyglet的文档。

用户输入事件

作为一个游戏框架,能够与用户进行交互是必须具备的,pyglet提供了比较基础的键盘响应事件和鼠标响应事件来捕获用户的输入。这两种事件都需要在相应的Window对象上声明,即使用@window.event修饰。

游戏一般的处理流程,都是“捕获用户输入\(\rightarrow\)修改数据状态\(\rightarrow\)重新绘制画面”,所以捕获用户输入是一个游戏的开始步骤。

键盘事件

键盘事件是通过on_key_press()on_key_release()两个函数来响应的,这两个函数都接受相同的参数,一个是symbol,表示按下的键,另一个是modifiers,表示按下的修饰按键,例如Shift、Ctrl等。

所有的按键的键值都定义在pyglet.window.key模块中,可以通过key.Wkey.DOWN等来使用,具体按键的列表可以在pyglet文档中寻找。

鼠标事件

鼠标的事件就比键盘要多一些了,虽然鼠标的按键少,但是鼠标可以做出的动作较多。鼠标事件不一定只能绑定在Window对象上,也可以绑定在后面章节中提到的文字、图片等对象上,但是事件响应函数名称都是一致的。

on_mouse_motion(x, y, dx, dy)用于捕获鼠标的每次移动,其中,x和y表示鼠标本次移动的起点坐标,dx和dy分别表示鼠标在x轴和y轴上的移动距离。

on_mouse_press(x, y, buttons, modifiers)on_mouse_release(x, y, buttons, modifiers)用于捕获鼠标按键的按下和释放动作,鼠标按钮的值与键盘键值一样都定义在pyglet.window.mouse模块中。

on_mouse_drag(x, y, dx, dy, buttons, modifiers)是用来捕获鼠标按钮按住时的拖拽动作的,其参数含义与之前的函数中同名参数相同。

对于窗口来说,还有鼠标移入和移出两个事件,分别是使用on_key_enter(x, y)on_key_leave(x, y),要注意的是,on_key_leave(x, y)返回的坐标是在窗口之外的。

鼠标的另一个操作就是滚轮操作,滚轮事件是通过on_mouse_scroll(x, y, scroll_x, scroll_y)来响应的。其中,scroll_xscroll_y给出的是是滚轮滚动计数,而不是像素距离。

时间控制

在最小应用示例的最后,使用了pyglet.app.run()来启动了整个应用,并使应用进入了主循环来处理相应的事件。但是对于游戏来说,应用并不总是立刻对用户的输入产生响应的,而是采用了基于时间的处理机制,换句话说可以称为FPS控制。

如果不采用FPS控制,任由应用对用户的输入进行实时响应,那么整个画面就会失去流畅的感觉,造成忽快忽慢的现象。所以采用基于时间的处理机制就显得十分重要。pyglet提供了pyglet.clock.schedule_interval(func, seconds)函数来定期的执行某个处理。例如:

def update(dt):
	pass

pyglet.clock.schedule_interval(update, 0.1)

传递给pyglet.clock.schedule_interval(func, seconds)的函数需要接收一个参数,即示例中的dt,用以控制函数的运行间隔。也就是函数自身可以控制其启动的绝对时间间隔,但是pyglet会按照计划定期的调用排入的函数,而并不管指定函数是否真正的完成了工作。一般情况下为了控制画面的帧速不会大于60 FPS,传递给schedule_interval()的时间间隔通常都为\(\frac{1}{60}\)。

如果要定期执行的任务不必控制运行频率,可以使用pyglet.clock.schedule(func)。这个函数会尽可能的反复调用指定函数,可能会造成较高的CPU占用。

另外一种定期执行任务的方式是pyglet.clock.schedule_once(func),这个函数会仅执行一次指定函数。

要使指定函数从定时任务中取消,可以使用pyglet.clock.unschedule(func)函数。这在用户进行一项输入后取消指定函数的执行,然后在进行其他输入后又恢复执行的动作中十分有用。

精灵控制技巧

对于第一次接触游戏开发的读者这里会接触一个新词汇:精灵。这是游戏中所有可以活动的物体的统称,一般用来指代图像物体。精灵是画面中始终随时间进行运动变化的物体,所以如何控制其动画状态和运动状态,是一个游戏中非常重要的控制内容。

前面示例中update(dt)函数接受的参数dt是一个时间分量。这个时间分量是pyglet根据实际调用生成的,是代表真实函数调用间隔时间的。所以可以在定时函数中采用\(d = v \times dt\)来计算精灵的移动距离,并且可以同理计算精灵的旋转、动画播放等参数。

基础绘画

pyglet提供了pyglet.graphics.draw()函数来向绘图区绘制原始图形物体。pyglet支持的OpenGL图形模式主要有以下这些:

  • pyglet.gl.GL_POINTS
  • pyglet.gl.GL_LINES
  • pyglet.gl.GL_LINE_LOOP
  • pyglet.gl.GL_LINE_STRIP
  • pyglet.gl.GL_TRIANGLES
  • pyglet.gl.GL_TRIANGLES_STRIP
  • pyglet.gl.GL_TRIABGLES_FAN
  • pyglet.gl.GL_QUADS
  • pyglet.gl.GL_QUADS_STRIP
  • pyglet.gl.GL_POLYGON

pyglet.graphics.draw(size, mode, *data)函数接受三个参数,第一个参数是一个整型值,用来描述每个形状描述中给定点的数量,第二个参数是要绘制的图形模式,最后一个参数较为复杂,是用于描述组成图形的点的属性的,这将在接下来进行详细描述。另一个可以按照指定索引顺序绘图的函数是pyglet.graphics.draw_indexed(size, mode, indices, *data),它所接受的第三个参数是一个序列,用于指定按照何种顺序使用给定的点的数据。

这里给出一个示例,在后面会对其进行详细解释。

pyglet.graphics.draw(2, pyglet.gl.GL_POINTS,
	('v2i', (10, 15, 30, 35),
	('c3B', (0, 0, 255, 0, 255, 0))
)

点的描述

点是组成二维图形和三维图形的基本元素,通过描述点的属性,可以定义出二维物体和三维物体。pyglet中对于点的描述是采用一个双元素元组来完成的,其中第一个元素为属性格式字符串,第二个元素为参数元组。

其中常用的属性格式字符串可以按照下表给定的内容进行声明。

声明属性可选格式推荐格式
点坐标(Vertex Position)v[234][sifd]v[234]f
点颜色(Color)c[34][bBsSiIfd]c[34]B
边界标志(Edge flag)e1[bB]
法线(Normal)n3[bsifd]n3f
第二颜色(Secondary Color)s[34][bBsSiIfd]s[34]B
材质坐标(Texture coordinate)[0-31]?t[234][sifd][0-31]?t[234]f

表中的可选格式和推荐格式都是以正则表达式方式说明其格式的,比如v[234][sifd]可以有v2iv3fv4d等表达格式。格式中的字符是用来声明数据类型的,一般情况下常用f(浮点)和B(无符号字节整型)。

现在来回顾一下前面的示例。('v2i', (10, 15, 30, 35)表示其后的参数元组定义的是使用整型数值坐标描述的二维点,即点\((10, 15)\)和点\((30, 35)\)。而('c3B', (0, 0, 255, 0, 255, 0))表示前面定义的这两个点的颜色,第一个点的颜色是rgb(0, 0, 255)(蓝色),第二个点的颜色是rgb(0, 255, 0)(绿色)。

读者可以以此类推来试验其他的图形定义。

点列表

每次绘制都列举点的定义是一件很麻烦的事情,而且一般图形发生变化时,并不需要对图形中的点完全重新定义。这种需求可以使用点列表来满足。

点列表可以使用pyglet.graphics.vertex_list(size, *data)来定义,其接受的参数与pyglet.graphics.draw(size, mode, *data)类似,只是不需要声明图形模式。点列表可以直接使用.draw(mode)方法绘制输出。例如之前的示例可以改写为以下形式。

vertex_list = pyglet.graphics.vertex_list(2, 
	('v2i', (10, 15, 30, 35),
	('c3B', (0, 0, 255, 0, 255, 0))
)

vertex_list.draw(pyglet.gl.GL_POINTS)

点列表在创建之后并不熟一成不变的,而是可以通过以下属性(均为列表类型)进行修改的。

  • .vertices,点坐标位置。
  • .colors,点颜色。
  • .edge_flags,边界标识。
  • .normals,法线。
  • .secondary_colors,第二眼色。
  • .tex_coords,材质坐标。

点列表的上述属性都是可以使用列表进行重新赋值的,并且还可以使用vertex_list.vertices[:3]=[45, 60]的切片语法进行局部更新。

批量渲染

从优化OpenGL性能的角度来说,在一次draw()调用中应该绘制尽可能多的点列表。为了能够便捷的将点列表收集在一起进行控制,pyglet提供了Batch类。Batch类实例的创建不需要任何参数,直接使用pyglet.graphics.Batch()即可。

Batch类实例通过add()add_indexed()函数来创建点列表,使用这两个函数来创建点列表时,需要指定点列表的绘制模式,就像是调用draw()函数一样。Batch类实例也提供了.draw()方法来将批量创建的点列表一次性绘制出来。

显示文字

在前面最小应用中已经展示了如何向画面中绘制文本,pyglet通过pyglet.text模块提供了一系列文本绘制的类,Label只是其中之一。Label类是用来渲染输出纯文本内容的,而HTMLLabel类则是用来输出HTML格式的内容,其可以用来输出混合样式的内容。

使用LabelHTMLLabel在程序中输出大量的内容是不明智的,也是不经济的。所以pyglet提供了“文档/布局”(Document/Layout)架构,这个类似于“模型/视图”(Model/View)的处理架构,大大简化了文本内容的输出。

Document类的实例是整个架构的模型部分,用来定义内容和文字显示的样式。其中UnformattedDocument类用于创建单一样式的文本,而FormattedDocument类则用于创建样式可变的文本。大部分时间,FormattedDocument会使用的更多。

FormattedDocument类实例可以由三个函数来返回:decode_text()decoed_attributed()decode_html()Document类的内容可以通过.text属性来进行更改,例如:document.text = 'Hello, world';也可以使用.delete_text().insert_text()方法来进行修改。

真正将文字内容输出到画面的是TextLayout类,不同的TextLayout类及其子类的实例可以将同一个Document类同时渲染在不同的位置。位于同一渲染批次的多个TextLayout类实例会共享图形组,这使得TextLayout适合于渲染短小且不进行变化的Document实例。TextLayout类的子类ScrollableTextLayout适合于渲染指定位置与大小的文字块,并且其中的内容会发生滚动;而IncrementalTextlayout则适合于渲染常常发生变化的大块文本内容,以及用户编辑的呈现。

TextLayout实例的创建格式为layout = pyglet.text.layout.TextLayout(document, width, height)。除此之外,还可以接收两个命名参数,一个是multiline,用来指示是否支持分段;一个是wrap_lines用来指示是否支持自动换行。

格式化文本

前面提到过,FormattedDocuemnt可以支持文本样式,并且可以为文本中的单一字符指定样式。Textlayout会将样式应用的范围直接应用在字符串上,而不需要预先定义样式名称。例如文本样式可以像以下方式使用:

# 获取字符索引为0的位置的样式中的字体名称
font_name = document.get_style('font_name', 0)

# 为字符串中的前五个字符设置字体和大小
document.set_style(0, 5, dict(font_name='Arial', font_size=12))

样式一般是通过一个字典类型来定义的,常用的字典键值有:

  • font_name: 字体名称,由pyglet.font.load()提供。
  • font_size: 字体大小。
  • bold: 粗体,布尔型。
  • italic: 斜体,布尔型。
  • underline: 添加下划线,布尔型。
  • kerning: 字符间距。
  • baseline: 字符偏离基线的距离,正值向上偏离,负值向下偏离。
  • color: 字体颜色,由四元素元组声明。
  • background_color: 文字背景颜色,由四元素元组声明。

如果在Layout中启用了multiline选项,则可以使用.set_paragraph_style()来设置段落样式,段落样式可以使用以下字典键值来声明相应的样式。

  • align: 段落对齐,取值为'left''center''right'
  • indent: 首行缩进的距离。
  • leading: 每行缩进距离。
  • line_spacing: 行距。
  • margin_left: 左侧留白距离。
  • margin_right: 右侧留白距离。
  • margin_top: 头部留白距离。
  • margin_bottom: 尾部留白距离。
  • tab_stops: 水平制表符的位置。
  • wrap: 自动换行。

属性化文本

格式化文本虽然好用,但是在进行复杂内容排版时,总是需要确定要设定样式的文字位置。这种时候属性化文本就会变得更加方便易用。属性化文本类似于HTML,使用类似于标签的语法来声明文本中的样式。例如:

This is a {bold True}bold{bold False} text. {font_name 'Times New Roman'}{font_size 20}Hello{font_size 14}, {color (255, 0, 0, 255)}world{color (0, 0, 0, 255)}!

{.margin_left '24px'}This is a left margin paragraph!

加载字体

系统中自带字体可以通过pyglet.font.load()来加载,并且可以将加载的字体直接定义为一个字体样式,例如:times16 = pyglet.font.load('Times New Roman', 16)。此外关于字体设置的命名参数也都可以用来设定字体样式。

要判断一个字体是否在系统中存在,可以使用pyglet.font.have_font()来进行检测,或者直接使用sans_serif = pyglet.font.load(('Verdana', 'Helvetica', 'Arial'), 16)d的语法设置一个自动适配的字体设置。

对于自定义的字体,则需要使用pyglet.font.add_file()将其加载进来,之后再使用pyglet.font.load()完成加载和设置。自定义的字体需要跟随应用发布,可以设置为应用资源。

显示图片

图片是组成游戏的重要元素,pyglet中提供了多种对于图片的处理,包括前面提到的精灵。

图片的加载十分简单,只需要使用image = pyglet.image.load(filename)即可完成加载,或者还可以使用文件流的方式进行加载,例如以下示例。

house_stream = open('house.png', 'rb')
house = pyglet.image.load('house.png', file=house_stream)

pyglet在不同的操作系统中支持不同的图片格式,这是与操作系统本身相关的,所以在图片资源格式选择上,推荐使用.bmp.dds.gif.jpg.png.tif这些比较通用的文件格式。

pyglet.image.load()返回的是AbstractImage类的实例,实例的实际类型取决于pyglet使用哪个解码器对图片文件进行了解码。但是所有被加载的图片都会有以下属性:

  • width: 图片宽度。
  • height: 图片高度。
  • anchor_x: 锚点x轴位置。
  • anchor_y: 锚点y轴位置。

在默认情况下,锚点都是位于\((0,0)\)的位置。可以使用.get_region()方法从目标图像上截取一个方形区域并重新设定锚点。

图片类型转换

通过pyglet.image.load()加载的图片默认为AbstractImage类型,但是在实际使用中常常使用到Texture和ImageData、BufferImage等类型的实例,所以pyglet提供了.get_texture().get_image_data()来转换不同的类型。

其中ImageData、ImageDataRegion、CompressedImageData、BufferImage可以通过调用.get_texture()来转换为Texture和TextureRegion类型的图像;反之则可以使用.get_image_data()来获得。

分区图像

接触过CSS Sprites的读者可能会对此节内容比较熟悉。对于序列动画的存储,一般不是将每一帧图片都保存为一个图片文件的,而是将其放置在同一个图片文件中,排布形成一个较大的图片文件,之后在读取时只需要按照一定格式进行拆解即可。

pyglet提供了一个可以用来直接读取这种分区图像的类,ImageGrid。其创建格式为image_seq = pyglet.image.ImageGrid(AbstractImage, rows, columns)。之后可以通过序列索引来访问每个分区图像的内容。

精灵

这里再一次提到了精灵(Sprites),前面所介绍的全部都是图片的加载和转换,但是真正能够将图片渲染到画面上,需要借助于Sprites类。Sprites类除了用于将图片渲染到画面中,还可以对图片进行变换、移动等操作。

要通过一个加载后的图片来创建精灵,可以使用以下初始化格式sprite = pyglet.sprite.Sprite(img=AbstractImage)。调用sprite.draw()方法可以将精灵渲染到画面上。

精灵也支持批量渲染,并且批量渲染是推荐的渲染方法。要将精灵加入批量渲染的队列中,可以在建立精灵实例时,使用batch参数指定队列。

在实际的渲染中,精灵还会分为前景物体和背景物体,这种分层渲染可以通过对精灵进行分组来完成。pyglet通过OrderedGroup类来定义不同层次的组,其创建格式为group = pyglet.graphics.OrderedGroup(n),其中n的值越小,该组内容越在最底层。要将精灵分配至不同的组,需要在创建精灵实例时,使用group参数指定其所属的组。

播放声音

除了能够渲染图片和文字以外,pyglet还支持音频和视频的播放。对于播放音频,首先需要指定pyglet可以使用的音频驱动。与图片解码器一样,音频驱动在不同的系统中也是不同的,但是Windows、macOS以及Linux均支持的音频驱动是OpenAL,所以指定驱动时,OpenAL应该是首选。但好在指定音频驱动的pyglet.options['audio']接受一个元组值,可以列举计划使用的全部驱动,pyglet会根据系统自动选择一个使用。例如:pyglet.options['audio'] = ('openal', 'pulse', 'directsound', 'silent')

pyglet通过pyglet.media.load()来加载媒体文件,这个函数会返回Source类实例,其中包含了媒体文件的信息。加载后的媒体文件只需要调用.play()方法即可播放。其他支持的控制方法还有.pause()(暂停)、.next_source()(下一轨)和.seek()(转到指定时间)。

应用资源

前面所提到的文件使用方式,一般都需要指定文件的绝对路径,或者pyglet会从当前工作路径中去寻找指定文件。这些情况下都不是应用自带资源的使用方式,因为应用自带资源一般都是与可执行文件放在一起,其绝对路径是变化的,并且当前工作路径也是不确定的。所以加载应用的自带资源,就只能使用以下方式:

import os

script_dir = os.path.dirnamr(__file__)
path = os.path.join(script_dir, 'logo.png')
logo_image = pyglet.image.load(path)

每次都使用这样的语句来加载资源是相当繁琐的,所以pyglet提供了资源模块resource。以上语句如果改用资源模块来加载,就会变成logo_image = pyglet.resource.image('logo.png')

资源的所在路径,可以使用pyglet.resource.path = []来设定,其可以接受一个字符串列表,用来指定资源的搜索路径。在设定资源搜索路径之后,需要使用pyglet.resource.reindex()来将资源内容重新索引。

一个比较特殊的资源是用户配置目录,这个目录在不同的系统中对应的位置也不相同,但是在pyglet中的对待是相同的,都是使用pyglet.resource.get_setting_path('appName')来获取。需要注意的是,pyglet并不保证这个目录一定存在,所以在使用前需要确定并创建这个目录。

一个应用中可以使用多个资源加载器(Loader),资源加载器之间是相互独立的,不会共享资源缓存内容。资源加载器可以使用pyglet.resource中的全部资源加载方法。资源加载器可以使用loader = pyglet.resource.Loader([标示字符串])的格式来完成创建。

pyglet资源常用的加载方法主要有以下这些。

资源加载对应文件加载返回类型功能
.file()open()文件对象加载指定的文件
.image()image.load()Texture,TextureRegion加载图像
.texture()image.load()Texture加载材质
.animation()image.load_animation()Animation加载动画
.media()media.load()Source加载媒体资源
.text()text.load()UnformattedDocument加载无格式文本
.html()text.load()FormattedDocument加载格式文本
.attributed()text.load()FormattedDocument加载属性文本
.add_font()font.add_font()None加载字体

终端GUI:Curses

Curses是Linux下广泛使用的图形函数库,可以在终端界面中绘制用户界面以及图形。Python中的curses库封装了C语言中的curses库,提供了终端图形化的简便功能。curses库内置于Python标准库中,在使用时不需要再安装其他的库来支持。

在使用Curses时,需要先了解以下几个基本概念。

屏幕

在Curses初始化后,屏幕即进入Curses模式,并且在内存中初始化一系列的数据结构。屏幕也是一切操作的根基。

窗口

窗口(Window)是Curses库中的一个重要组件,其实质上只是屏幕上的一块方形区域。在这个区域上,可以进行输入及输出的各种操作。窗口可以位于标准屏幕的任何位置,它们之间可以相互重叠包括与标准屏幕。窗口同时可以包含与它们相关联的子窗口。任何在父窗口和子窗口重叠区域的变化都会同时影响到它们中的任何一个。

基板

基板(Pad)是Curses中的另一种窗口。基板的各个方面与窗口并无二异,只是它的大小和位置不再局限于终端屏幕的可视部分。它可以比标准屏幕大,位置也可以位于标准屏幕之外而我们看不到。

支持Windows系统

Curses库是用于Linux/Unix平台终端环境的,在默认情况下是不支持Windows系统的命令行环境的,包括cmd.exe和PowerShell。但是可以通过安装Python的功能包来使Windows系统的命令行环境支持Curses库。

首先要明确自己机器上Python的安装版本,例如Python 3.6或者Python 3.7,其次要明确自己机器上Windows的架构类型,即32位系统还是64位系统。

在明确Python运行环境之后,需要到Curses预编译包链接下载相应的Python功能包,注意,在下载时需要对照机器上的Python版本以及Windows架构类型来下载相应的功能包,例如在64位Windows上安装的Python 3.7,就需要下载curses-2.2-cp37-cp37m-win_amd64.whl文件。

下载后将下载文件放置到一个临时目录中,之后打开命令行或者PowerShell,将工作路径转移到这个临时目录中(使用cd命令),执行命令pip install curses-2.2-cp37-cp37m-win_amd64.whl,提示安装成功之后,即可以在命令行环境中使用Curses库的功能。

Curses环境的启动与退出

在任何应用使用Curses环境之前,都必须初始化Curses。初始化Curses可以使用.initscr()函数,之后可以进行一系列的初始化,初始化的示例如下。

import curses

stdscr = curses.initscr()
curses.start_color()
curses.init_pair(1, curses.COLOR_GREEN, curses.COLOR_BLACK)
curses.init_pair(2, curses.COLOR_RED, curses.COLOR_BLACK)
curses.noecho()
curses.cbreak()

在示例中,curses.initscr()负责初始化Curses环境,并返回一个标准窗口,即示例中的stdscr。这个标准窗口直接对应目前的屏幕。

curses.start_color()用于初始化颜色环境,如果应用中需要用到颜色,就必须使用这一函数初始化颜色环境。.init_pair()函数用于定义颜色组,这将在后面的内容中介绍。

.noecho().cbreak()比较常见,并且在应用中发挥用处较大。其中.noecho()用于关闭键盘输入显示,即关闭输入回显,此时敲击键盘输入的内容将不在屏幕上显示;这在组织屏幕内容和实现快捷键输入时十分有用。

.cbreak()用于进入CBreak模式,在该模式下,应用可以立刻响应键盘的输入,而不是在键盘输入完毕按下回车键后才开始响应。与此模式相反的是缓冲模式,可参考一般应用从终端读取数据的形式。

在退出Curses时,必须要关闭Curses模式,才能让终端回到正常可用的状态下,根据前面的示例,以下示例是其对应的关闭示例。

curses.nocbreak()
curses.echo()
curses.endwin()

示例中的状态设置顺序与初始化时相反,并且最后使用.endwin()函数关闭Curses环境。关闭Curses环境非常重要,这关系着终端在应用运行完毕后能不能回到正常的工作状态。

操作窗口与基板

通常在使用curses.initscr()函数之后,将获取一个代表全屏幕的窗口对象。但是一般在使用Curses时,经常会在屏幕上划分出若干小区域,即小窗口,来供使用。

窗口可以使用.newwin()方法来创建,其格式为curses.newwin(高, 宽, 左上角y坐标, 左上角x坐标)。这里需要注意的是,由于Curses库的习惯,在描述宽高及坐标时,都是纵轴在前,横轴在后,也就是说,先描述竖向属性,再描述横向属性。

为了减少重绘,在输出文字时,Curses会先进行操作的积累,并在适当的时刻去绘制内容。如果需要立刻重绘窗口,需要调用窗口的.refresh()函数。

基板的创建与窗口相似,因为基板是一种特殊的窗口。基板的可以显示的面积比窗口大得多,所以其创建时只需要指定宽高即可,并不需要提供位置。但是在其使用.refresh()函数进行强制重绘时,则必须指定显示位置与显示区域,即.refresh(基板起始行, 基板起始列, 屏幕起始行, 屏幕起始列, 屏幕结束行, 屏幕结束列)。基板的重绘将把基板上指定位置开始的内容直接绘制在屏幕的指定位置。

当屏幕上有多个窗口或者基板,刷新其中一个窗口或者基板时会造成屏幕的闪烁,这可以在每个窗口上调用.noutrefresh()函数。该函数可以将窗口标记为待重绘,要完成重绘,需要调用curses.doupdate()函数完成重绘。

窗口和基板可以放置在其他窗口和基板之上,要实现这样的效果可以使用.overlay(目标窗口)函数,该函数最简单的使用方法就是直接指定要被覆盖的窗口。两个窗口的大小并不一定要完全一样。更高级的使用方式是部分覆盖,这需要指定额外的六个参数来确定覆盖范围。

在计算窗口内容输出位置时,常常会需要取得窗口左上角的坐标以及窗口的宽高。Curses库在窗口类中提供了两个方法来完成这两项任务,首先.getbegyx()是用来获取窗口左上角的坐标的,其返回值为一个元组,其次.getmaxyx()是用来获取窗口的最大可用宽高的,其返回值同样是一个元组。这里要注意的是,返回的元组的第一个值同样是纵向属性,第二个值为横向属性。

文字内容操作

输出文字内容是Curses库最大的目的之一,窗口与基板的创建也都是为文字内容的输出服务的。Curses提供了基于窗口和基板的内容控制函数。

.addscr()用于向窗口中输出文字内容,使用其参数可以定义输出位置和内容样式。其完整调用格式为.addscr([y, x], str[, attr])。其中用于描述输出位置的y、x两个参数与窗口位置相同,不再赘述。attr为要输出的文字内容的样式,常用来设定特殊格式和内容前景、背景颜色。

前面曾经使用curses.init_pair(序号, 前景颜色, 背景颜色)定义了若干颜色组,在输出内容时,可以使用curses.color_pair(序号)来调用前面定义过的颜色组。在定义颜色组时需要注意,颜色组0是Curses内置的黑白配色颜色组,是固定搭配,不能更改,所以颜色组的序号要从1开始定义。

Warning

需要注意的是,如果在窗口边界外输出内容,则会引发curses.error异常。

清除文字内容也是常用的操作之一,Curses库提供了以下几个函数来提供不同的清除功能。

函数功能
.erase()清除窗口内容
.clear()清除窗口内容,并触发重绘
.clearok(flag)如果给定参数为True,则在重绘时清空窗口内容
.clrtobot()清除从当前光标位置到窗口最后一行的全部内容
.clrtoeol()清除从当前光标位置到当前行末尾的全部内容

获取用户输入

对于一个应用来说,仅有输出是不完整的,能够获取输入并产生相应的输出才是一个完整的应用。在Curses库中,常用.getch()函数来获得用户输入的字符,这个函数会返回用户按下的键相对应的ASCII码值,其中包括PAGE UPPAGE DOWNShift等键都有对应的整数值。

根据窗口是否调用了.nodelay(flag)方法,.getch()方法会有阻塞和非阻塞两种工作方式。在阻塞模式下,应用会停下来等待用户输入;而在非阻塞模式下,如果获取不到用户的输入,.getch()会返回-1

具体使用可见以下示例:

import curses

stdscr = curses.init_scr()
curses.noecho()
stdscr.nodelay(False) # 使用阻塞模式

while True:
	key = stdscr.getch()
	if key == ord('a'):
		pass
	if key == ord('q'):
		break

curses.echo()
curses.endwin()

Curses库还提供了.getstr()函数,但是该函数的使用限制较多,一般并不推荐使用。

模版引擎:Jinja2

Jinja2是Python的模板引擎之一,其完全支持Unicode,并且具备沙箱执行环境,应用比较广泛,在Flask和aiohttp中都有比较好的支持。

Info

Jinja2在Flask中是作为默认模板引擎使用的。

使用Jinja2的一般方式是创建一个包括{{ }}或者{% %}的字符串或者文件。以下示例演示了一个最基本的Jinja2模板的使用。

from jinja2 import Template
template = Template('Hello {{name}}!')
template.render(name='Kate')

示例中通过创建一个Template实例获得一个新的模板对象,该对象提供一个.render()方法,使用字典或者命名参数调用渲染模板。字典或者命名参数称为模板的上下文(context)。

在Jinja2中,用{{ }}输出变量,用{% %}表示指令。

加载模板文件

Jinjia2使用一个Environment对象来存储全局配置,并用来从文件系统或者网络位置加载模板。大多数应用在开始的时候都会创建一个Environment对象,并使用它加载模板。

以下是创建Environment对象和加载模板的示例。

from jinjia2 import Environment, PackageLoader
env = Environment(loader=PackageLoader('application', 'templates'))
template = env.get_template('template.html')
print(template.render(variables='Hello'))

使用变量

要在模板中动态的添加变量,可以使用指令{% set name='' %}。这样创建出来的变量在全局都可以使用。

变量在模板中使用{{ }}输出。

控制指令

模板除了要输出上下文内容以外,还需要使用控制指令来以上下文内容对模板内容进行动态的控制。常用的控制指令有iffor

if指令主要用来对内容进行条件输出。以下示例给出了if指令的使用格式,注意最后的endif不能缺少,这是if指令结束标记。

{% if condition==1 %}
Condition Text 1
{% elif condition==2 %}
Condition Text 2
{% else %}
Condition Text 3
{% endif %}

for指令则是用来循环输出上下文内容。以下示例给出了for指令的使用格式,最后的endfor作为for指令结束标记同样不能缺少。

{% for user in users %}
{{ user.name|e }}
{% else %}
Nothing found.
{% endfor %}

Warning

注意,循环中是不能够使用continue或者是break的。

运算符

在变量输出标记中,除了可以输出变量,还可以输出表达式。表达式中可以使用的运算符有以下这些。

  • +,数字相加,列表连接
  • -,数字相减
  • /,数字相除
  • %,数字取余
  • *,数字相乘,字符相乘
  • **,数字幂运算
  • in,集合、字符串包含运算
  • ~,字符串连接

过滤器

过滤器是通过|符号进行使用的。例如{{ user.name|length }}将返回指定变量内容的长度。过滤器相当于一个函数,将其前方的值传入函数中,返回处理后的值。Jinja2中常用的过滤器有以下这些:

  • abs,返回数字的绝对值;
  • default(value, boolean=False),如果当前变量没有值,则使用参数中的值代替,如果boolean参数值为true则以Python的方式判断是否为false,否则只有变量为undefined时才会输出默认值;
  • escape或者e,将字符串转义后输出;
  • first,返回序列的第一个元素;
  • format(*args, **kwargs),格式化输出字符串,例如{{ "\%s" - "\%s"|format('Hello', 'Foo') }}
  • last,返回序列的最后一个元素;
  • length,返回序列、字符串或者字典的长度;
  • join(value),用value作为分隔符拼接序列为字符串;
  • safe,如果开启了全局转义,则临时关闭转义;
  • int,将值转换为整型;
  • float,将值转换为浮点型;
  • lower,将字符串转换为小写;
  • upper,将字符串转换为大写;
  • replace(old, new),将字符串中的old替换为new
  • truncate(length=255, killwords=False),截取长度为length的字符串;
  • striptags,删除字符串中的HTML标签;
  • string,将值转换为字符串;
  • workcount,计算字符串中单词的个数。

宏是一个页面中可以重复使用的部分,相当于一个函数。宏使用{% marco %}指令定义,定义和调用格式可参考下例。

{% marco list(users) %}
{% for user in user %}
{{ user.name }}
{% endfor %}
{% endmarco %}

{{ list(loginUnsers) }}

在日常开发中,常常将宏定义在独立的文件中,这种宏可以像Python引用模块一样导入。语句格式为import...as...from...import...或者from...import...as...。导入模板不会将当前模板的上下文添加到被导入的模板中,如果需要传入上下文,可以使用语句from...import...with context

宏文件中可以引用其他的宏,这是使用语句include来完成的。这条语句相当于将另一个模板复制到当前模板的指定位置。

模板继承

模板是可以继承的,通过继承可以将一些重复出现的元素提取出来,放在父模板中;再将可变部分定义为block,由子模板去覆盖。

父模板可以通过{% block name %}{% endblock %}来定义可重写的block。子模板通过{% extends "父模板文件名" %}来引用父模板,并通过{% block name %}{% endblock %}来重写父模板中同名的block。

测试操作

Jinja2提供了一系列的测试器来判断一个值是否满足某种类型,需要搭配if指令使用,使用格式为{% if 变量 is 测试器名称 %}。常用的测试器有以下这些:

  • callable,是否可调用;
  • defined,是否已经被定义;
  • escaped,是否已经被转义;
  • upper,是否全是大写;
  • lower,是否全是小写;
  • string,是否是一个字符串;
  • sequence,是否是一个序列;
  • number,是否是一个数字;
  • odd,是否是奇数;
  • even,是否是偶数。

静态文件引入

HTML中需要加载的静态文件主要有CSS文件、Javascript文件、图片文件、字体文件等,但是这些静态文件存储的目录可能是被映射过的,所以在加载时并不能完全按照项目目录结构进行引用。Jinja2提供了一个url_for()函数来生成引用URL。例如引用static目录下的样式文件,可以写为<link href="{{ url_for('static', filename='main.css') }}">

序列化工具:marshmallow

marshmallow是一个序列化库,专门用来将ORM或者ODM等框架中使用的复杂数据类型实例与简单的Python数据类型之间进行转化。marshmallow的使用很简单,首先使用pip install marshmallow即可完成库的安装。本文使用marshmallow 3.0版本,由于该版本在本文成稿时依旧是Beta版所以在安装时要使用--pre参数。

建立Schema

marshmallow对于对象的实例化是通过Schema来完成的,建立Schema是对对象进行序列化和反序列化的第一步。marshmallow提供了一个Schema类,用来作为自定义Schema类的基类。

自定义Schema类中,需要定义要输出的成员属性,成员属性名称尽量与被序列化类中的成员属性名称相同。Schema类中的成员属性使用marshmallow模块中提供的fields模块中的类进行声明。

以下给出一个Schema示例。

import datatime as dt
from marshmallow import Schema, fields

# 假设有这样一个需要序列化的类
class User(object):
	def __inti__(self, name, age, email):
		self.name = name
		self.email = email
		self.age = age
		self.create_at = dt.datetime.now()

# 可以创建以下这个对应的Schema
class UserSchema(Schema):
	name = fields.Str()
	email = fields.Email()
	age = fields.Int()
	create_at = fields.DateTime()

fields模块中的类一般都采用以下共同的初始化参数,可以在声明成员属性时进行更加精细的调整。

参数名类型功能
default由内容确定当相应的输入值不存在时,使用指定默认值替代。
attributestr在序列化时指定对应的被序列化实例中的属性名。
data_keystr在反序列化时对应序列化内容中的键名。
validatecallable指定反序列化时该属性的验证器,如果验证器返回False则抛出ValidationError
requiredbool是否必须存在。
allow_nonebool是否允许空值。
load_onlybool序列化时跳过本属性。
dump_onlybool反序列化时跳过本属性。
missing由内容确定指定当反序列化时,该属性不存在时的默认值。

常用的属性声明类及其特殊的初始化参数有以下这些,可以在实际项目中选择使用。

  • Raw(),不使用任何格式化及验证方式的属性。
  • Nested(),使用其他的Schema来处理本属性,第一个参数需要指定要使用的Schema类。
    • nested,要使用的Schema类。
    • exlcude,元组类型,用于从排除不需要处理的属性。
    • only,元组类型,用于指定仅要包含的属性。
    • many,布尔类型,用于指示该属性的值是一个序列。
  • Dict(),字典类型属性。
  • List(),列表类型属性,第一个参数用于指定组成序列的其他Field类型,注意要与Nested()区分开。
  • String(),字符串类型属性,可简写为Str()
  • UUID(),UUID类型属性。
  • Number(),数值类型属性。
    • as_string,布尔类型,用于指示是否将数值按照字符串形式输出。
  • Integer(),整型属性,可简写为Int()
  • Decimal()decimal.Decimal类型属性,用于高精度小数计算。
    • places,数值位数。
    • rounding,取整进位模式。
    • allow_nan,是否允许非数字类型。
  • Boolean(),布尔类型属性,可简写为Bool()
  • FormattedString(),格式化字符串属性。接受一个格式化字符串作为属性,可以输出其他Field的内容。
  • Float(),浮点类型属性。
  • DataTime(),时间类型属性,接受一个格式化字符串来进行时间格式化。
  • LocalDateTime(),本地化时间类型属性。
  • Time(),时间类型属性。
  • Date(),日期类型属性。
  • TimeDelta(),时间域类型属性。
    • prescision,字符串类型,用于指定时间域的单位。
  • URL(),URL字符串属性。
  • Email(),带有Email验证器的字符串属性。
  • Method(),使用Schema中定义的方法来进行转换,需要分别指定序列化方法和反序列化方法。
  • Function(),使用其他位置定义的函数来进行转换,需要分别指定序列化函数和反序列化函数。
  • Constant(),固定值属性。

此外,Schema中还可以使用修饰器来修饰一些方法来在序列化和反序列化前后进行额外的处理工作。常用的有:

  • @post_dump,序列化后运行。
  • @post_load,反序列化后运行。
  • @pre_dump,序列化前运行。
  • @pre_load,反序列化前运行。
  • @validates(field_name),注册指定属性的验证器。

序列化和反序列化

当定义完Schema后,序列化和反序列化就只需要通过Schema实例的两个方法来完成:.dump().load()

其中.dump()接受一个Schema对应的对象实例,返回一个序列化后的字典实例,如果指定了many参数,则会返回一个序列实例,其中的元素都为序列化后的字典实例。

.load()则正好相反,它会从一个字典实例建立相应的对象实例。

任务调度:Celery

Celery是一个纯Python开发的分布式任务调度模块,专注于任务队列的实时处理,并且支持任务调度,能够处理大量消息。Celery本身不包含任何消息服务,可以使用第三方消息服务来传递任务,它支持的消息服务包括RabbitMQ、Redis和数据库,这其中Redis是最佳选择。

Celery可以通过pip install celery来完成安装。如果使用Redis作为消息服务(消息中间人),则可以使用pip install -U celery[redis]来捆绑安装依赖。在生产环境下推荐采用RabbtMQ作为消息中间人,因为Redis在没有开启数据持久化之前,容易受到断电等情况带来的数据丢失影响。

基础知识

任务队列是一种在线程或者机器间分发任务的机制。任务队列的输入是一个工作单元,独立的工作进程(Worker)持续监视队列中是否有需要处理的新任务。

Celery使用消息进行通信,Broker(消息中间人)作为客户端与工作进程之间的桥梁,将消息从客户端派送给工作进程。Celery系统中可以包含多个工作进程和消息中间人。消息中间人通常会选择RabbitMQ消息队列或者Redis数据库来实现,这两种实现支持Celery的全部特性。

Celery系统的构建需要一个发送和接收消息的传输者,即消息中间人。Celery既可以单机运行也可以以集群方式运行,甚至可以跨数据中心运行。

最小工作进程示例

这里给出一个最小的工作进程作为示例。

# 文件名:tasks.py
from celery import Celery


app = Celery('tasks', broker='amqp://guest@localhost//')

@app.task
def add(x, y):
	return x + y

这个最小示例中从celery模块中引入了Celery类,并使用它建立了任务。这个最小示例可以使用以下命令启动:celery -A tasks worker --loglevel=info

之后可以在其他脚本中使用以下代码调用:

from tasks import add


add.deley(1, 1)

选择中间人

中间人是在创建Celery实例时必须给定的。中间人通过Celery类实例化时的参数broker来定义,一般为中间人的连接地址,以下给出常用的中间人的连接地址书写方法。

  • RabbitMQamqp://用户名:密码@主机:端口//
  • Redisredis://:密码@主机:端口/数据库号

在Redis上的广播信息默认所有虚拟主机可见,如果只想让活动主机接收,则需要给消息添加前缀:BROKER_TRANSPORT_OPTIONS = {'fanout_prefix': True}

如果任务没有在规定时间内确认接收,则会被重新委派给另一个工作进程,这个超时时间可以通过BROKER_TRANSPORT_OPTIONS = {'visibility_timeout': 3600}来配置,默认为1小时。

任务调用

从最小工作进程示例中可以看到,任务采用add.delay()的方式调用,如果是直接调用add(),那么指定的任务将在调用进程上执行,而不是在任务队列中执行。

要在任务队列上调用任务,需要使用.delay()方法,或者采用.apply_async()方法。其中.delay().apply_async()的别名。.apply_async()常用有以下几种调用方式。

  • .apply_async(arg, kwarg=value),直接在任务队列上调用指定方法,并传递相应参数。
  • .apply_async(countdown=10),10秒后在任务队列上调用指定方法。
  • .apply_async(eta=now + timedelta(seconds=10)),使用eta参数定义10秒后在任务队列上调用指定方法。
  • countdown=60, expires=120,1分钟后调用指定方法,但2分钟后尚未执行就取消任务。

除了以上几种调用方式以外,.apply_async()还可以接收以下这些参数来完成其他功能。

  • link,可以接收一个函数或者函数列表,用于连续的执行任务,通常采用task.s()来定义子任务传递给这个参数。
  • link_error,定义当任务出错时需要调用的错误处理函数。
  • retry,布尔值,用于指定在任务失败时是否要重试。
  • retry_policy,设定任务重试策略,需要传递一个字典类型值。
    • max_reties,设定任务最大重试次数。
    • interval_start,设定两次重试之间间隔的秒数。
    • interval_step,设定多次重试之间时间间隔的递增倍率。
    • interval_max,设定两次重试之间时间间隔的最大值。
  • queue,设定任务使用的不同路由队列。

在很多情况下,我们经常需要连续的调用一些方法,这就需要Celery提供的一项功能:「任务签名」。跟编程语言中的函数签名概念一致,任务签名也是生成一个能够代表函数的内容供Celery调用。任务签名一般使用signature()函数定义,格式为signature('任务函数名', args=(), **kwargs)。其中args需要传递传给任务函数的参数。**kwargs中则与.apply_async()方法能够接收的参数相同。例如创建前面add()任务延迟10秒执行的签名就是这样:signature('tasks.add', args=(1, 1), countdown=10)。任务签名可以直接传递给.apply_async()方法的link参数使用。

在一般情况下,子任务比任务签名更加常用。子任务可以使用.subtask((参数), **kwargs)来创建。子任务还可以使用.subtask()的快捷方式.s(params, **kwargs)创建。

借助于子任务和link参数,可以实现任务的链式调用,例如:add.apply_async((2, 2), link=add.s(6)),前一个任务的调用结果会作为子任务的第一个参数传入子任务执行。

任务结果的获取

任务调用之后会返回一个AsyncResult类型的值。AsyncResult提供了.get()方法可以获得任务的调用结果。对于链式任务调用,直接使用.get()仅能返回链式调用中第一步的结果,如果需要获得最终结果,可以使用.collect()方法来获取链式任务调用中每一步的结果,或者可以使用.children属性来访问链式任务调用中的每一步的执行结果。

任务组

任务组提供了工作进程并行执行多个任务的能力。默认情况下,任务是一个接一个的完成调用的。Celery中的group()函数可以将多个任务签名或者子任务组合在一起形成一个任务组。任务组会以列表的形式返回其中所有任务的执行结果。

工作进程部署

Celery提供了一个celery命令脚本来启动工作进程,就像前面章节提到的命令:celery -A tasks worker --loglevel=infocelery命令通过worker子命令来启动工作进程。

每调用一次celery worker都会创建一个工作进程,同一台机器可以创建多个工作进程。多个工作进程可以通过-n参数来命名。每个工作进程都可以使用--concurrency参数来指定工作线程数量。

工作进程的终止需要操作系统向其发送TERM指令,这在Linux中可以使用kill命令,Windows中可以在任务管理器中寻找相应的进程并结束之。

Celery默认没有提供以Daemon方式启动后台工作进程的选项,如果需要启动Daemon模式的工作进程,需要其他工具配合,如supervisord、celeryd等。

定时任务:APScheduler

APScheduler是一个轻量级的定时任务调度框架,实现了Quartz的全部功能,提供了基于日期、固定时间间隔以及crontab类型任务的三种调度模式。同时还支持异步执行、后台执行和任务持久化。

APScheduler的安装十分简单,只需要执行命令pip install apscheduler即可。

运行一个由APScheduler调度的任务只需要以下四个步骤。

  1. 创建一个调度器(Scheduler)。
  2. 创建一个任务存储(Job Store)。
  3. 添加一个调度任务。
  4. 执行调度器。

基本概念

APScheduler由四种基本组件组成:

调度器(Scheduler): 用于调度和控制任务,需要在其中配置任务存储器和执行器。

触发器(Triggers): 描述被调度任务的执行条件,触发器是完全无状态的。

任务存储器(Job Stores): 任务持久化仓库,默认情况下任务都被保存在内存中,选择保存在数据库中可以在整个服务重新启动后继续保持定时任务的调度状态。

执行器(Executors): 负责处理任务的执行,通常会在线程池或者进程池中执行任务,在任务结束后会通知调度器。

调度器

APScheduler提供了以下7种调度器供使用,你可以根据项目的特点和需要从中选择一个使用,所有的调度器的使用方法都是一致的。

  • BlockingScheduler,调度器在主线程执行,执行时会阻塞主线程。
  • BackgroundScheduler,调度器在后台线程执行,不会阻塞主线程。
  • AsyncIOScheduler,基于asyncio模块的异步调度器。
  • GeventScheduler,基于Gevent并发框架的异步调度器,使用时需要GeventExecutor配合。
  • TornadoScheduler,基于Tornado Web框架的异步调度器。
  • TwistedScheduler,基于Twisted异步网络框架的异步调度器,使用时需要TwistedExecutor配合。
  • QtScheduler,用于Qt应用,使用QTimer完成任务唤醒。

BlockingScheduler为例,在建立调度器的时候,需要为其指定任务存储器和执行器,还有任务默认配置。这三种内容可以在创建调度器实例时指定,也可以使用调度器的.configure()方法指定。调度器完成配置并添加任务后,即可调用.start()方法启动。如果需要停止任务调度,可以调用方法.shutdown()

以下是一个最简单的示例,其中不带有任何需要调度的任务。

from apscheduler.schedulers.background import BackgroundScheduler


scheduler = BackgroundScheduler()
scheduler.start()

这个最简单的示例中将会使用内存任务存储器,并且所有的配置都会使用默认值。

向调度器中添加任务有两种方式:通过.add_job()方法和@scheduled_job()修饰器。其中使用.add_job()方法添加的任务,可以通过返回的apscheduler.job.Job实例对任务进行修改和移除。向调度器中添加和修改、删除任务可参考以下示例。

job = scheduler.add_job(some_function, 'interval', minutes=2)
job.modify(max_instance=6, name='Some another job') # 修改任务
job.remove() # 移除任务

触发器

触发器不是单独定义的,而是在向调度器中添加任务时定义的,其表现为.add_job()方法和@scheduled_job()修饰器的参数。以.add_job()方法为例,添加任务的格式一般为.add_job(任务函数, 触发器类型, 触发器参数, args=任务函数参数)

这其中触发器类型为一个字符串型值,根据APScheduler内建的三种触发器,分别可以取dateintervalcron。以下是三种触发器可以接受的触发器参数及意义。

  • date触发器,被调度任务只会在指定时间执行一次。
    • run_date,datetime类型或者字符串类型,表示任务的运行日期或者时间。
    • timezone,tzinfo类型或者字符串类型,指定时区。
  • interval触发器,被调度任务会按照指定的时间间隔执行。以下参数除start_dateend_date外只需指定一项即可。
    • weeks,间隔周数。
    • days,间隔天数。
    • hours,间隔小时数。
    • minutes,间隔分钟数。
    • seconds,间隔秒数。
    • start_date,开始日期。
    • end_date,结束日期。
    • timezone,指定时区。
  • cron触发器,在特定的时间周期性触发,兼容Linux crontab格式。以下参数除start_dateend_date外可以使用多项,并且可以使用后面介绍的算术表达式来定义触发周期。
    • year,年。
    • month,月。
    • day,日。
    • week,周。
    • day_of_week,周内第几天。
    • hour,时。
    • minute,分。
    • second,秒。
    • start_date,开始日期。
    • end_date,结束日期。
    • timezone,指定时区。

当使用cron触发器时,其每一项参数都用来指定周期的触发条件。之所以称其为最强大的触发器,可以参考以下算术表达式表来体会。

表达式可用于参数功能
*所有每一个单位的值都触发
*/a所有每a个单位的值都触发
a-b所有在a到b之间的值都触发
a-b/c所有在a到b之间每c个单位的值都触发
nth y在每个月中第n次出现的一周中第y天触发
last y在每个月中最后一次出现的一周中第y天触发
last在每个月的最后一天触发
x,y,z所有联合条件,符合任何一项条件即触发,可以使用以上所有条件定义

以下给出一个使用cron定义任务触发条件的示例。

# 指定任务func会在每年1到3月、7到9月的每个周一、周二每隔三个小时执行一次
scheduler.add_job(func, 'cron', month='1-3,7-9', day='0, true', hour='*/3')

这个示例中已经包含了大部分条件的使用方法,可以在实际项目中参照书写。

任务存储器

在APScheduler中,任务存储器的作用是用来持久化任务执行和调度状态。其中主要记录了任务上一次执行的时间,和下一次执行时间,有哪些任务需要执行。建立任务存储器一般只需要指定持久化位置即可,例如数据库连接串等。APScheduler提供了以下任务存储器供选择使用。

  • MemoryJobStore,内存任务存储器。
  • MongoDBJobStore,MongoDB任务存储器。
  • RedisJobStore,Redis任务存储器。
  • RethinkDBJobStore,RethinkDB任务存储器。
  • SQLAlchemyJobStore,借助SQLAlchemy向数据库保存任务的任务存储器,需要指定SQLAlchemy的engine。
  • ZooKeeperJobStore,将任务保存至ZooKeeper树的任务存储器。

任务存储器一般定义为一个字典,传递给调度器的jobstores参数来设定。具体可见以下示例。

from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
from apscheduler.jobstores.redis import RedisJobStore


jobstores = {
	'sqlalchemy': SQLAlchemyJobStore(url='sqlite:///jobs.sqlite'),
	'default': RedisJobStore(db=0, host='localhost')
}

scheduler = BackgroundScheduler(jobstores=jobstores)

当任务存储器有多个时,默认情况下任务会被添加到键为default的任务存储器中,如果需要将任务添加到不同的任务储存器中,可以使用.add_job()jobstore参数指定用于持久化任务的任务存储器键名。

执行器

APScheduler提供的执行器根据调度器和工作方式不同,主要有以下这些。

  • ThreadPoolExecutor,线程池执行器。
  • ProcessPoolExecutor,进程池执行器。
  • AsyncIOExecutor,基于asyncio的执行器,搭配AsyncIO调度器使用。
  • GeventExecutor,基于Gevent的执行器,搭配Gevent调度器使用。
  • TwistedExecutor,基于Twisted的执行器,搭配Twisted调度器使用。
  • DebugExecutor,调试执行器,不使用任何线程和进程。

与任务存储器一样,执行器一般也是初始化成一个字典,之后传递给调度器的executors参数来设定。具体可见以下示例。

from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.executors.pool import ThreadPoolExecutor, ProcessPoolExecutor


executors = {
	'default': ThreadPoolExecutor(20),
	'process': ProcessPoolExecutor(5)
}

scheduler = BackgroundScheduler(executors=executors)

同样的,任务可以通过.add_job()executor参数来设定所要使用的执行器。

任务默认行为

调度器的构造方法中一般还会设定一项参数:job_defaults,这个参数会赋予之后添加的任务一些默认的行为。job_defaults参数一般是一个字典类型的值,其中的键均为.add_job()方法的参数。.add_job()方法可以通过重设这些参数来覆盖任务的默认行为。常用的行为主要有以下这些。

  • misfire_grace_time,如果任务没有按时执行,最大允许延误秒数,在指定的秒数中任务依旧可以被触发执行。
  • coalesce,任务是否只需要运行一次来决定是否成功执行。
  • max_instance,允许同时运行任务的实例数量。
  • replace_existing,是否替换拥有同名ID的任务。

调度事件

根据调度和任务的执行,APScheduler会产生一系列的事件,这些事件可以通过调度器的.add_listener()方法来进行监听。.add_listener()接受两个参数,一个是事件处理函数,一个是要监听的事件。如果需要使用一个事件处理函数监听多个事件,可以使用|来连接多个事件名称。

APScheduler提供的事件主要有以下这些。

  • EVENT_SCHEDULER_STARTED,调度器启动事件。
  • EVENT_SCHEDULER_SHUTDOWN,调度器关闭事件。
  • EVENT_SCHEDULER_PAUSED,有任务在调度器中暂停。
  • EVENT_SCHEDULER_RESUMED,已经暂停的任务在调度器中恢复执行。
  • EVENT_EXECUTOR_ADDED,执行器添加到调度器的事件。
  • EVENT_EXECUTOR_REMOVED,执行器从调度器移除的事件。
  • EVENT_JOBSTORE_ADDED,任务储存器添加到调度器的事件。
  • EVENT_JOBSTORE_REMOVED,任务存储器从调度器移除的事件。
  • EVENT_ALL_JOBS_REMOVED,任务存储器中所有任务都被移除的事件。
  • EVENT_JOB_ADDED,有任务添加到任务存储器的事件。
  • EVENT_JOB_REMOVED,有任务从任务存储器移除的事件。
  • EVENT_JOB_MODIFIED,有任务被修改的事件。
  • EVENT_JOB_SUBMITTED,有任务被提交到执行器执行的事件。
  • EVENT_JOB_MAX_INSTANCES,任务被提交到执行器但因为达到最大实例而被取消执行的事件。
  • EVENT_JOB_EXECUTED,任务成功完成执行的事件。
  • EVENT_JOB_ERROR,任务执行出现异常的事件。
  • EVENT_JOB_MISSED,任务未能按时触发执行的事件。
  • EVENT_ALL,所有事件。

一个完整示例

这里给出一个简单的完整示例,仅供参考。

from apscheduler.schedulers.blocking import BlockingScheduler
from apscheduler.jobstore.memory import MemoryJobStore
from apscheduler.executor.pool import ThreadPoolExecutor
from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_EXECUTED


jobstores = {
	'default': MemoryJobStore()
}
executors = {
	'default': ThreadPoolExecutor(20)
}
job_defaults = {
	'coalesce': False,
	'max_instance': 1
}
scheduler = BlockingScheduler(
    jobstores=jobstores, 
    executors=executors, 
    job_defaults=job_defaults
)
scheduler.add_job(my_job, 'cron', hour='*/3', id='my_job')
scheduler.add_job(short_one, 'interval', seconds=5, id='short_job')

def on_job_event(event):
	if event.exception:
		print(f'Job {event.job_id} crashed')
	else:
		print(f'Job {event.job_id} succeded.')

scheduler.add_listener(on_job_event, EVENT_JOB_EXECUTED | EVENT_JOB_ERROR)
scheduler.start()

AMQP实现:Pika

Pika是纯Python编写的AMQP 0-9-1协议的实现,一般用来操作RabbitMQ。AMQP是一个双向RPC(远程进程调用)协议,并且Pika可以在支持AMQP的消息队列上方便的实现RPC。

Pika根据不同的IO循环提供了不同的连接方式。

  • AsyncioConnection,位于pika.adapters.asyncio_connection包中,用于支持Python 3的Asyncio IO循环的异步连接。
  • BlockingConnection,同步连接。
  • SelectConnection,没有第三方依赖支持的异步连接。
  • TornadoConnection,位于pika.adapters.tornado_connection包中,用于支持Tornado IO循环的异步连接。
  • TwistedProtocolConnection,位于pika.adpaters.twisted_connection包中,用于支持Twisted IO循环的异步连接。

使用Pika操作消息队列,需要构建一个Channel,而Channel是由Connection建立的。Pika构建Channel需要以下几个核心类的辅助。

  • PlainCredentials,RabbitMQ默认密码认证策略。
  • ConnectionParameters,连接参数对象。
  • URLParameters,URL连接串对象。

最小应用

Pika的最小应用包括消息生产应用和消息消费应用。这里借用Pika官方最简示例进行说明,先看消息生产应用。

import pika


connection = pika.BlockingConnection()
channel = connection.channel()
channel.basic_publish(
    exchange='test', 
    routing_key='test',
    body=b'Test message'
)
connection.close()

消息生产应用中通过消息队列连接创建Channel,然后再通过Channel发送消息。

下面看一下消息消费应用,消息的消费要比生产复杂很多。

import pika


connection = pika.BlockingConnection()
channel = connection.channel()

for method_frame, properties, body in channel.consume('test'):
	# 打印输出各项内容并确认消息
	print(method_frame, properties, body)
	channel.basic_ack(method_frame.delivery_tag)
	
	# 接收10次消息后退出消费
	if method_frame.delivery_tag == 10:
		break


# 取消消费者并退回所有未处理的消息
requeued_messages = channel.cancel()
# 关闭连接
connection.close()

基础名词

  • Exchange: 即消息交换网关,消息生产者将消息发送到Exchange,Exchange负责将消息转发到相应的目标位置。这个目标位置可以是与Exchange绑定的另一个Exchange,也可能是一个Queue。Exchange有四种类型:directfanouttopicheader
    • direct: 生产者生产消息时指定的Routing Key与Queue绑定到Exchange上的Routing Key完全一致。多个Queue可以通过相同的Routing Key绑定到同一个Exchange来达到订阅相同消息的功能。
    • fanout: 这种类型的Exchange会将消息转发到所有与之绑定的Queue上。
    • topic: 这种类型的Exchange会视消息的Routing Key与Queue绑定的Routing Key之间的匹配情况来进行消息的转发。例如消息的Routing Key为X.Y.Z,就会转发给绑定Routing Key为*.Y.*的Queue,但不会转发给绑定Routing Key为*.A.*的Queue。
    • header: 这种类型的Exchange不再按照Routing Key进行路由,而是基于多个属性进行路由,这些属性表示为消息的消息头。
  • Queue: 即消费队列,用于存储消费者待消费的消息。Queue在使用之前必须先声明。
  • binding: Queue获取来自Exchange的消息的前提是Queue必须与Exchange进行绑定。绑定之后,Exchange才可以按照规则将消息路由到指定的Queue中去。如果生产者生产的某条消息,没有与之匹配的Queue可供路由,那么这条消息将被丢弃或者返回生产者。
  • 消息确认: 消息确认代表消费者已经收到或者完成了消息的处理,AMQP已经可以将消息从Queue中移除了。消息确认有两种方式,一种是自动确认,这一般在消费者收到消息时确认消息,另一种是消费者手工确认消息,手工确认消息比较灵活,并且可以防止因为消息处理失败导致的消息丢失。

连接队列

队列的连接有两种方法,一种是通过ConnectionParameters定义连接参数,一种是通过URLParameters定义连接URL串。

使用URL串来连接消息队列是最简单的创建连接的方法,类URLParameters在构建的时候接收一个URL字符串作为参数。AMQP的连接串格式为amqp://用户名:密码@主机:端口/<虚拟主机>[?查询串],默认的虚拟主机/在连接时应使用%2F代替。

而使用ConnectionParameters定义连接参数就明确许多。类ConnectionParameters可以接收以下常用参数。

  • blocked_connection_timeout,阻塞连接超时时间。
  • channel_max,最大允许Channel数量。
  • connection_attempts,尝试连接的套接字号。
  • credentials,连接密码。
  • frame_max,AMQP的最大帧大小。
  • heartbeat,心跳超时时间。
  • host,消息队列主机。
  • locale,传递给中间件的本地化信息。
  • retry_delay,套接字连接重试间隔秒数。
  • socket_timeout,套接字超时时间。
  • ssl_options,SSL连接配置,使用纯文本或者pika.SSLOptions实例。
  • port,消息队列端口。
  • virtual_port,虚拟端口。
  • tcp_options,TCP配置。

ConnectionParameters类构建函数中的credentials参数需要使用PlainCredentials类实例来定义。PlainCredentials类在创建的时候接收两个参数,一个是用户名,另一个是明文密码。

有了连接参数实例,就可以来连接消息队列了。Pika提供的所有消息队列连接类都继承自pika.connection.Connection类,所以构造方法接收的参数基本类似,常用参数有以下这些。

  • parameters,连接参数。
  • on_open_callback,连接建立时调用的回调函数。
  • on_open_error_callback,建立连接失败时调用的回调函数。
  • on_close_callback,连接关闭时调用的回调函数。

当消息队列连接建立之后就可以创建Channel来进行消息的发送和消费了。如果不再需要消息队列的连接,需要使用.close()方法关闭连接。.close()方法可以接收两个参数,reply_code用于指定关闭连接时的代码,默认为200;replay_text用于指定连接关闭的原因,默认是"Normal shutdown"

通道

通道(Channel)是Pika收发消息的根本途径。Channel是通过消息队列连接(Connection)的.channel()方法来获取的,格式为.channel(channel_number=None, on_open_callback=None)。其中channel_number表示申请使用的通道编号,如果不指定通道编号,则由消息队列自动分配。on_open_calback则是当通道建立时调用的回调函数。

通道中常用的操作消息的方法主要有以下这些。

  • .basic_ack(delivery_tag=0, multiple=False),应答收到一条或者多条消息。
    • delivery_tag,消息服务对消息设定的编号。
    • multiple,如果设为True,则表示确认收到包含delivery_tag及以前的全部消息,否则仅确认delivery_tag代表的这一条消息。
  • .basic_cancel(consumer_tag='', callback=None),取消一个消息消费者。这个操作不会影响已经发送的消息,但并不意味着消息队列不会再向消费者发送更多的消息。
  • .basic_consume(queue, on_message_callback=None, auto_ack=False, exclusive=False, consumer_tag=None, arguments=None, callback=None),向消息队列发送AMQP 0-9-1 Basic.Consume命令建立对指定队列的消费响应。
    • queue,指定响应的消息队列。
    • on_message_callback,收到消息的时候调用的回调函数,函数签名为on_message_callback(channel, method, properties, body)
    • auto_ack,设定是否自动确认收到消息。
    • exclusive,设定是否在同一队列上允许存在其他消费者。
    • consumer_tag,设定消费者编号,如果不设定将由消息队列自动分配。
    • arguments,用于配置消费者的参数。
    • callback,用于响应Basic.ConsumeOk指令的回调函数。
  • .basic_get(queue, callback, auto_ack=False),从指定消息队列中获取一条消息。
  • .basic_nack(delivery_tag=None, multiple=False, requeue=True),拒收一条或者多条消息。
    • requeue,设定被拒收的消息是否重新排回消息队列。
  • .basic_publish(exchange, routing_key, body, properties=None, mandatory=False),向指定Channel发送一条消息。
    • exchange,消息要经由的交换网关。
    • routing_key,消息的路由键值。
    • body,字节数组类型,消息主体。
    • properties,消息的属性,pika.spec.BasicProperties类型。
    • mandatory,强制标志。
  • .basic_reject(delivery_tag, requeue=True),拒收一条收到的消息。
  • .basic_recover(requeue=False, callback=None),通知消息队列重新发送所有未确认的消息。
  • .close(reply_code=0, reply_text='Normal shutdown'),关闭Channel。
  • .confirm_delivery(ack_nack_callback, callback=None),转到Channel的确认模式,用来通知消息队列消息的确认状态。
  • .exchange_bind(destination, source, routing_key='', arguments=None, callback=None),绑定一个消息交换网关到另一个网关。
    • destination,要绑定到的目标网关。
    • source,消息来源网关。
    • routing_key,要进行路由的消息路由键值。
    • arguments,设定绑定的属性。
  • .exchange_declare(exchange, exchange_type='direct', passive=False, durable=False, auto_delete=True, internal=False, arguments=None, callback=None),声明一个新的消息交换网关。
    • exchange,消息交换网关的名称。
    • exchange_type,消息交换网关的类型,默认为'direct'直接发送型网关,还可以选择'topic'主题转发型网关,'fanout'群发型网关。
    • passive,设定只在网关不存在时新建。
    • durable,设定当RabbitMQ重启后是否重建。
    • auto_delete,设定当没有队列绑定到网关时,自动删除网关。
    • internal,设定只能接收从其他网关发来的消息。
  • .exchange_delete(exchange=None, if_unused=False, callback=None),删除消息交换网关。
    • exchange,指定要删除的网关。
    • if_unused,设定只删除未使用的网关。
  • .exchange_unbind(destination=None, source=None, routing_key='', arguments=None, callback=None),解除两个消息交换网关间的绑定。
  • .queue_bind(queue, exchange, routing_key=None, arguments=None, callback=None),将指定队列绑定到交换网关。
  • .queue_declare(queue, passive=False, exclusive=False, auto_delete=False, arguments=None, callback=None),声明一个用于消费消息的队列。
  • .queue_delete(queue, if_unused=False, if_empty=False, callback=None),删除一个队列。
    • if_empty,只在队列是空的时候删除队列。
  • .queue_purge(queue, callback=None),清空队列中的消息。
  • .queue_unbind(queue, exchange=None, routing_key=None, arguments=None, callback=None),将消费队列与交换网关解绑。

Channel同时也提供了一些专用的回调函数附加方法用于将回调函数附加到一些需要关注和操作的状态转换点上。回调函数附加方法主要有以下这些。

  • .add_callback(callback, replies, one_shot=True),附加用于处理消息队列返回的回复信息的回调函数。
    • replies,设定要响应的回复。
    • one_shot,只处理第一个类型的回调。
  • .add_on_cancel_callback(callback),附加当服务器调用.basic_cancel()时的回调。
  • .add_on_close_callback(callback),附加当Channel关闭后的回调。
  • .add_on_return_callback(callback),附加当调用.basic_publish()后消息被拒收并从服务器返回时的回调。

使用BlockingConnection消费消息的示例

前面的最小应用中已经使用BlockingConnection发送了消息,并且采用了直接获取消息的方法来消费消息,这里给出一个使用回调函数来消费消息的示例。

import pika


def on_message(channel, method_frame, header_frame, body):
	print(method_frame.deliery_tag)
	print(body)
	channel.basic_ack(delivery_tag=method_frame.delivery_tag)


connection = pika.BlockingConnection()
channel = connection.channel()
channel.basic_consume('test', on_message)
try:
	# 启动Channel的消费监视
	channel.start_consuming()
except KeyboardInterrupt:
	# 结束Channel的消费监视
	channel.stop_consuming()
connection.close()

异步生产和消费的设计概念

前面的示例都是采用BlockingConenction的同步阻塞式生产和消费。同步阻塞式的模式效率很低,一般只用于规模很小的应用中。在大部分生产环境中还是使用异步模型来产生较高的处理性能。

Pika中提供了多种异步Connection以供使用,具体可见前面的列举。但是不管使用哪种异步Connection实现,其基本原理都是一致的:通过Connection提供的IO Loop来进行异步响应。

针对异步消费模型,一般都使用以下步骤来进行消息的处理。

  1. 【主动调用】 建立Connection,并绑定响应Connection建立事件、关闭事件的回调。
  2. 【主动调用】 启动Connection的IO循环(ioloop)。
  3. 【Connection建立事件回调】 打开Channel,并绑定响应Channel打开事件的回调。
  4. 【Channel打开事件回调】 声明Exchange,并绑定响应Channel关闭事件的回调与Exchange声明事件的回调。
  5. 【Exchange声明事件回调】 声明Queue,并绑定Queue声明事件的回调。
  6. 【Queue声明事件回调】 绑定Queue到Exchange,并绑定绑定事件的回调。
  7. 【绑定事件回调】 启动Channel的消费监听,并绑定消息接收处理回调。
  8. 【消息接收处理回调】 处理消息,并确认消息。
  9. 【主动调用】 关闭Channel的消费监听。
  10. 【主动调用】 关闭Connection。
  11. 【Connection关闭事件回调】 关闭Connection的IO循环。

异步生产模型与异步消费模型相似,但是如果不需要处理消息接收事件,则不必再声明Queue,并且需要将消息接收处理回调去除改为调用消息发送即可。IO循环的开启与关闭位置都是相同的,需要在相应的回调函数中启动和关闭。

利用Pika实现RPC

前面所叙述的消息大多都是单向传输的,也就是始终是从生产者传递向消费者。但是在RPC应用中,消息的传输是双向的,RPC客户端在调用RPC服务端的过程后,RPC服务端需要将执行结果反馈给RPC客户端,这个过程通常是异步的。

RPC的具体实现可参考前一节针对异步生产和消费的模式进行设计,这里仅给出用于实现RPC的关键代码。

首先是RPC服务器的设计。

class RPCServer:
	def __init__(self):
		# 这里省略Connection及Channel的创建
		self.consume = self.channel.basic_consume(self.on_request, queue='rpc_queue')
	
	def on_request(self, channel, method, props, body):
		# 接收并处理请求消息,形成返回消息
		channel.basic_publish(
            exchange='',
			routing_key=props.reply_to,
			properties=pika.BasicProperties(correlation_id=props.correlation_id),
			body=str(callback_msg)
        )
		channel.basic_ack(delivery_tag=method.delivery_tag)

然后是RPC客户端的设计。

class RPCClient:
	def __init__(self):
		# 这里省略Connection及Channel的创建
		result = self.channel.queue_declare(exclusive=True) # 不指定Queue的名字,消费者断开后自动删除Queue
		self.callback_queue = result.method.queue # 给Channel自动分配一个队列名
		
		# 注意这里不要设定自动确认消息,因为不能判定消息是否与请求匹配
		self.channel.basic_consume(
            self.on_response,
			no_ack=True,
			queue=self.callback_queue
        )
	
	def on_response(self, channel, method, props, body):
		if self.corr_id == props.correlation_id:
			# 如果RPCServer返回的结果的关联ID与请求ID相同,则缓存结果
			self.response = body.decode()
	
	def call(self, args):
		self.response = None
		# 生成一个请求ID
		self.corr_id = str(uuid.uuid1())
		
		# 使用默认Exchange向RPCServer中定义的rpc_queue发送信息,
		# 在properties中指定reply_to用于告知RPCServer响应消息的返回队列,
		# correlation_id用于匹配请求和响应
		self.channel.basic_publish(
            exchange='',
			routing_key='rpc_queue',
			properties=pika.BasicProperties(
				reply_to=self.callback_queue,
				correlation_id=self.corr_id
            ),
			body=args
        )
		
		while self.response is None:
			# 等待结果返回
			self.connection.process_data_events()

在设计RPC时,一般不会每一个RPC调用占用一个Queue,而是多个或者所有RPC调用共用一个Queue,消息通过BasicProperties中的correlation_id来区分RPC请求。

\subsection{消息属性}

AMQP中的消息属性是消息在生产时确认的,不会被消息队列改变。Pika中使用BasicProperties来配置消息的属性,并在channel.basic_publish()properties参数中赋予消息。

pika.spec.BasicProperties的构造函数可以接受以下参数,这些参数的意义均为AMQP标准定义的。

  • content_type,消息的内容类型,参考MIME TYPE,例如纯文本为plain/text,JSON为application/json
  • content_encoding,消息内容的编码格式,主要用于设置压缩编码格式,如base64或者gzip
  • headers,消息的头信息,键值结构,健最大为255个字符,值可以是任何有效的AMQP值类型。
  • delivery_mode,是否持久化,整型,1表示不持久化,2表示持久化。
  • priority,消息的优先级,取值0到9,一般不建议使用。
  • correlation_id,指定消息的关联ID,常用于回复消息。
  • reply_to,用于指定回复的队列名称。
  • expiration,消息失效时间,255字符内的短字符串,常放置整数时间戳。
  • message_id,消息ID。
  • timestamp,消息的时间戳。
  • type,消息类型。
  • user_id,用户ID。
  • app_id,应用程序ID。
  • cluster_id,集群ID,一般不建议使用。

高级AMQP应用:Kombu

Kombu是一个消息处理库,主要用来处理AMQP协议。相比Pika,Kombu提供了更高层级的接口,使得消息处理更加简单。Kombu不仅支持AMQP协议传递消息,还可以使用Redis、ZooKeeper、MongoDB、Pyro等作为消息传递媒介来进行消息传递。

Kombu可以直接使用pip安装,命令为pip install kombu。当使用AMQP协议消息时,Kombu可以使用py-amqplibrabbitmqqpid-python等消息队列驱动库,但如果追求高性能可以选择使用C语言编写的librabbitmq库。

以下以连接AMQP消息队列为例,简要说明Kombu的使用。

建立连接

消息队列的连接是通过kombu.Connection类控制的。Connection类一般采用一个消息队列连接串作为参数完成实例化。例如:connection = kombu.Connection("amqp://guest:guest@localhost")

Kombu采用的消息队列连接串中声明了Kombu要使用的消息队列传递媒介和对应的驱动,以及消息队列的位置和登录信息等。消息队列连接串的格式为驱动库名称://用户名:密码@主机:端口/虚拟主机

除了使用连接串实例化Connection类以外,Connection类还接收以下命名参数来完成实例化。

  • hostname,消息队列主机。
  • userid,登录用户名。
  • password,登录密码。
  • virtual_host,虚拟主机。
  • port,端口。
  • transport,消息队列传输媒介及驱动名称,字符串类型值。
  • ssl,是否使用SSL进行连接,布尔类型值。
  • connect_timeout,连接超时的秒数。
  • transport_options,其他配置项,字典类型值。

仅仅创建Connection类实例并不能真正的建立连接,还需要使用Connection类提供的方法和属性来控制。

  • .connect(),显式建立连接。
  • .connected,查看连接状态。
  • .close(),显式关闭连接。
  • .release(),根据是否使用连接池来进行连接的关闭和资源释放。

Connection类还实现了__enter____exit__方法,来使用with ... as语句。例如:with Connection() as connection:

Kombu针对消息队列连接复用提供了一个全局的连接池。连接池位于kombu.pools包中。连接池的使用方法可参考以下示例。

from kombu import Connection
from kombu.pools import connections


connection = Connection("amqp://guest:guest@localhost/")
pool = connections[connection]

with pool.acquire(block=True) as conn:
	# 进行消息队列的操作

连接池采用.acquire()方法来获取可用的连接,指定block=True表示在连接池中连接不足的情况下等待可用连接。如果不指定block=True,则在连接池中连接不足的时候,就会抛出kombu.exceptions.ConnectionLimitExceeded异常。如果需要设置连接池的大小,可在建立连接池前使用kombu.pools.set_limit(n)来指定连接池的大小。

除了使用kombu.pools.connections来建立连接池以外,还可以使用kombu.connection.ConnectionPool类和Connection类中的.Pool(limit)方法来创建。其中ConnectionPool类接收Connection类实例和表示连接池大小的limit两个参数来完成实例化。使用这两种方法创建的连接池,在使用是同样需要使用.acquire(block, timeout)方法来获取连接,并且在完成使用后需要使用Connection类实例的.release()方法将连接交回连接池。

Exchange与Queue

对于AMQP协议来说,Exchange(网关)和Queue(队列)是其进行消息传递的基本元件。

Exchange在Kombu中通过kombu.Exchange类来创建。创建时常用以下参数来定制Exchange。

  • name,Exchange的名称。
  • type,Exchange的类型,可选directtopicfanoutheaders
  • channel,Exchange绑定到的通道,可以通过Connection类的.channel()方法获取。
  • durable,指示当消息队列重启后这个Exchange是否还继续存在,布尔型值。
  • auto_delete,指示当所有依赖这个Exchange的Queue都删除后是否自动删除Exchange,布尔型值,默认为Flase
  • delivery_mode,消息传递模式,字符串型,可取transient(即时)或者persistent(持久化)。

Exchange类实例在创建后,需要调用.declare()方法来在消息队列中创建Exchange。Exchange在不再需要时可以使用.delete()方法删除。Exchange类实例还可以通过使用.bind_to(exchange='', routing_key='')来绑定到另一个Exchange来完成消息中转。

Queue则是需要绑定到Exchange才能够接收到消息的。Queue在Kombu中可以通过kombu.Queue类来创建。创建时常用以下参数来定制Queue。

  • name,Queue的名称。
  • exchange,需要绑定到的Exchange名称或者Exchange类实例。
  • routing_key,绑定键,可参考前面Pika一章中的叙述。
  • channel,绑定到的Channel。
  • durable,指示在消息队列重启之后Queue是否还继续存在。
  • exclusive,指示Queue中的消息是否只能被当前连接消费。
  • auto_delete,指示当没有消费者连接到这条Queue时是否自动删除Queue。
  • expires,指示Queue从空闲到自动删除的等待时间。
  • message_ttl,消息的存活时间。
  • max_length,Queue持有消息的最大数量。
  • max_priority,Queue的最大优先级。
  • on_declared,当Queue完成创建后的回调。

如果Queue在创建的时候没有指定Exchange,那么可以使用.bind_to(exchange='', routing_key='')来绑定到Exchange。

生产者

当完成消息队列的连接之后,就可以建立消息生产者和消息消费者了。在Kombu中建立消息生产者可以使用消息队列连接实例建立或者直接使用kombu.Producer类实例化。

通过消息队列连接建立生产者比较容易,直接通过Connection类的.Producer()方法就可以获得可用的生产者实例。直接建立生产者需要先获取一个Channel实例,Channel实例可以通过Connection类的.channel()方法直接获取,例如。

with Connection("amqp://") as conn:
	with conn.channel() as channel:
		producer = Producer(channel)

Producer类在实例化时,可以使用以下常用参数来进行定制。

  • channel,要绑定到的Connection实例或者Channel实例。
  • exchange,指定默认要使用的Exchange。
  • routing_key,指定默认绑定键。
  • serializer,指定默认的串行化方法,默认采用JSON。
  • compression,指定默认的压缩方法,默认不压缩。
  • auto_declare,指定是否创建默认的Exchange,默认是True
  • on_return,当消息不能被发送出去而返回生产者时的回调。

对于生产者来说,最重要的就是使用.publish()方法发送消息。.publish()方法可以将一条消息发送到指定的Exchange。.publish()方法常用的参数主要有以下这些。

  • body,消息内容,可以是字符串、字典、列表等,无需指定参数名称。
  • routing_key,绑定键,用于决定消息转发到哪个Queue中。
  • delivery_mode,发送模式,与Exchange中的同名参数意义相同。
  • priority,消息优先级。
  • content_type,内容类型,字符串型,默认是自动检测。
  • content_encoding,内容编码方式,字符串型,默认是自动检测。
  • serializer,串行化消息要使用的串行化方法,字符串型,默认是自动检测。
  • compression,消息压缩方法,字符串型,默认是自动检测。
  • headers,消息头,字典类型。
  • declare,在发送消息之前需要完成的消息传递元件创建,列表型,可以传入Exchange实例等。
  • retry,指示是否在连接丢失时重试发送消息。
  • retry_policy,消息重发的策略,字典类型。
    • interval_start,重试开始延迟秒数。
    • interval_step,每次重试间隔时间增加的秒数。
    • interval_max,两次重试之间最大间隔时间。
    • max_retries,最大重试次数。
  • expiration,消息过期时间,浮点型,单位是秒。

Kombu也提供了生产者池,允许在程序中共用一组生产者以提高消息发送效率。与连接池类似,生产者池可以使用kombu.pools.producers[connection]来创建。从生产者池中获取生产者的方式与从连接池里获取连接相同,都是使用.acquire(block)方法。除此之外,生产者池还可以通过kombu.pools.ProducerPool()来创建。

消费者

消费者是消息队列中相对于生产者的另一端。Kombu中的消费者可以从ConnectionChannel或者一系列Queue中获取消息。消费者的创建要比生产者复杂一些,并且Kombu还提供了一系列Mixins来辅助消费者的快速创建。Kombu是一个异步事件型的消息处理库,主要通过回调函数来处理传入的消息。

最简单的消费者创建方法是使用kombu.Consumer类,在创建Consumer实例的时候,最少要指定Consumer实例所使用的ConnectionQueue,以及可接受的消息类型(Content-Type)。Kombu自3.0之后,默认只能接受JSON/Binary和纯文本消息,如果需要其他类型的消息,必须显式声明,例如Pickle或者YAML类型。Consumer常用的构造参数主要有以下这些。

  • channel,指定Consumer使用的Connection或者Channel
  • queues,指定Consumer使用的Queue,列表类型。
  • no_ack,是否由库自动确认消息。
  • auto_declare,自动创建相关消息队列元件。
  • callbacks,指定当消息收到时需要按次序调用的回调函数,列表类型。
  • on_message,当收到消息时调用的回调函数,回调函数接受Message实例作为唯一参数,设定后callbacks将会失效。
  • accept,指定可接受的Content Type。
  • on_decode_error,当消息解码失败后的回调函数。

在消费者建立后,可以调用Connection类实例的.drain_events(timeout)方法来等待消息的传入,timeout参数用来设定等待超时时间。或者还可以使用Consumer实例中的.consume()来启动消费者的消息接收。Consumer类实例中常用的方法主要有以下这些。

  • .consume(no_ack),开始Consumer的消息接收。
  • .cancel(),结束Consumer的消息接收。
  • .purge(),删除所有Queue中的消息。
  • .recover(requeue),重新发送所有未确认的消息。

然而在实际项目中,使用Mixins来建立Consumer实例是一个更加方便的选择。Kombu在kombu.mixins包中提供了两个常用的Mixins类供使用:ConsumerMixinConsumerProducerMixin。其中ConsumerMixin主要用于单向接收和处理消息,ConsumerProducerMixin可以用于双向处理消息。先从ConsumerMixin开始,以下是一个最简单的应用ConsumerMixin的消费者。

from kombu import Queue, Exchange
from kombu.mixins import ConsumerMixin


class Worker(ConsumerMixin):
	task_queue = Queue('tasks', Exchange('tasks'), 'tasks')
	
	def __init__(self, connection):
		self.connection = connection
	
	def get_consumers(self, Consumer, channel):
		return [Consumer(
            queues=self.task_queue,
			on_message=self.on_request
        )]
	
	def on_request(self, message):
		# 处理消息
		message.ack()


Worker(connection).run()

ConsumerMixin中提供了许多预置的方法和属性,用于方便处理消息,在继承ConsumerMixin时,可以根据需要进行重载。常用的方法和属性主要有以下这些。

  • .on_connection_error(exception, interval),当连接失败或丢失时的回调。
  • .on_connection_revived(),当连接恢复时的回调。
  • .on_consume_ready(connection, channel, consumers),当消费者准备好处理消息时的回调。
  • .on_consume_end(connection, channel),当消费者调用了.cancel()方法之后的回调。
  • .on_iteration(),当取得事件时每次调用处理函数时的回调。
  • .on_decode_error(message, exception),当消息无法解码时的回调。
  • .get_consumers(Consumer, channel),设定消息来源消费者的列表,是必须重载的方法。
  • .run(),启动消费者。

ConsumerMixin中,处理消息的方法是通过.get_consumers()返回的消费者列表中不同的消费者的回调方法来设定的,具体可参考示例中的书写。

ConsumerProducerMixin则提供了双向通信的功能,允许在一个类中,既可以接收和处理消息,又可以发出消息。与ConsumerMixin提供的预置方法和属性基本一致,但额外提供了.producer属性来提供生产者功能。利用ConsumerProducerMixin可以方便的实现RPC功能。以下给出一个近似RPC功能的示例。

class Worker(ConsumerProducerMixin):
	
	def __init__(self, connection):
		self.connection = connection
	
	def get_consumers(self, Consumer, channel):
		return [Consumer(
            queues=[Queue('foo')],
			on_message=self.handle_message,
			accept='application/json',
		    prefetch_count=10
        )]
	
	def handle_message(self, message):
		self.producer.publish(
			{'message': 'something need returned'},
			exchange='rpc_exchange',
			routing_key=message.properties['reply_to'],
			correlation_id=messsage.properties['correlation_id'],
			retry=True
        )

微服务框架:Nameko

Nameko是一个使用Python书写的微服务框架,主要提供了RPC功能。Nameko的RPC功能建立在Kombu之上,支持通过AMQP完成RPC调用。虽然Nameko支持HTTP访问,但Nameko不是一个Web框架,在需要的时候可以将其与Flask结合来使用。

Nameko除提供了一整套关于RPC调用的功能以外,还提供了运行RPC服务的命令行,可以用来负载RPC服务。相对应的,还提供了一个交互式客户端来对正在运行的RPC服务进行测试。

Nameko可以直接通过pip进行安装,命令为:pip install nameko

最小RPC服务

一个Nameko的最小RPC服务,主要包括暴露远程调用的方法和取得其他RPC服务。所以一般一个RPC服务会至少类似下面这样。

from nameko.rpc import rpc, RpxProxy


class Service:
	name = 'service' # 这里指定的名称是供其他服务调用时使用的。
	
	other_rpc = RpcProxy('another_service') # 取得另一个RPC服务
	
	# 使用rpc修饰器来暴露指定RPC方法
	@rpc
	def method(self):
		return something # 函数返回值即是RPC调用获取到的内容

这个最小服务可以使用命令行nameko run [模块名:[服务类名]]来启动。

一些基本概念

为了使RPC服务正常运作,Nameko引入了以下这些概念来组建RPC服务。

Entrypoints(入口点)

Entrypoints是一个RPC服务中方法的托管器,也可以称为网关。Entrypoints通常会监控一些RPC服务以外的内容,例如消息队列。当收到运行RPC服务所暴露的方法的事件时,也称为Entrypoints激活,Entrypoints将会创建一个Worker并在其中执行指定方法。Entrypoints通常体现为一个修饰器。

Dependencies(依赖)

一个RPC服务通常只会完成其所关注的一类业务,如果项目涉及到多个业务类别,就会需要多个RPC服务联合工作,这样在RPC服务之间就会产生依赖关系。Nameko中对于其他RPC服务的依赖一般是通过代理模式使用的。

除此之外,一个RPC服务的依赖还包括业务逻辑之外的基础内容,例如数据库连接、额外事件处理等。这些依赖都可以通过Nameko的依赖注入功能在RPC服务实例化时注入到Worker中。依赖注入是通过DependencyProvider属性实现的,而在Nameko中,能够被注入的依赖也是由Dependency Provider Extension提供的。

Worker(工作线程)

Worker会在Entrypoints启动一个RPC调用响应时由Entrypoints创建,Worker一般只是一个RPC服务类的实例。一个RPC服务同时可以有多个Worker在运行,但每一个Worker只处理一次请求并且Worker是无状态的。Worker是有自己的生命周期的,周期顺序如下。

  1. Entrypoints激活。
  2. 实例化RPC服务类创建Worker。
  3. 将所有的依赖项注入到Worker中。
  4. 执行指定方法。
  5. 销毁Worker。

Extensions(扩展)

Nameko将所有的Entrypoints和Dependency Provider都定义为了Extension。这些Extensions通常都不是RPC服务中业务逻辑的组成部分,而是各个RPC服务可以根据自己的需要自行选择的。Nameko提供了一系列的内置Extensions,并且通过开源社区提供了更多常见的Extensions。

定义和访问RPC方法

Nameko的RPC是通过AMQP实现的。就像之前最小RPC服务示例中所展示的,一个RPC服务就是一个普通的类,其中需要使用name属性来定义这个RPC服务的名称,并且使用@rpc修饰器来修饰要暴露出去的方法。定义好的RPC服务类就可以通过Nameko的命令行托管运行了。例如有以下RPC服务的定义。

from nameko.rpc import rpc, RpcProxy


class ServiceB:
	name = 'service_b'
	
	@rpc
	def remote_method(self, value):
		return f'From ServiceB give you {value}'

class ServiceA:
	name = 'service_a'
	
	b = RpcProxy('service_b')
	
	@rpc
	def calling_another(self, value):
		greeting = f'Greeting from ServiceA, {value}'
		return self.b.remote_method(greeting)

对于RPC方法的访问一般有两种方法。第一种是使用RpcProxy类创建一个目标RPC服务的代理,有这个代理来执行RPC方法。这种使用RpcProxy类代理的方法通常在RPC服务类中使用,可以建立一个RPC服务对于其他RPC服务的依赖。另一种功能方法则是使用独立的代理类来调用RPC方法,这通常用在非RPC服务类中。这种独立的代理类还支持向一个RPC服务集群调用指定RPC方法。

在前面的示例中,已经在ServiceA中使用了RpcProxy类创建了ServiceB的代理,并通过这个代理调用了ServiceB暴露出来的方法。

对于独立代理类,Nameko在nameko.standalone.rpc模块中提供了ClusterRpcProxyServiceRpcProxy两个类来使用RPC服务。ClusterRpcProxy可以对存在与目前暴露在AMQP消息队列上的所有RPC服务进行代理,在建立代理时不需要指定RPC服务的名称。而ServiceRpcProxy则是针对指定的一个RPC服务进行代理。这两个类在创建时都需要一个配置对象,其中至少要指定AMQP消息队列的连接。RPC服务代理进行RPC方法调用时,有同步和异步两种调用方式,具体使用方法请参考以下两种代理的使用示例。

from nameko.standalone.rpc import ClusterRpcProxy, ServiceRpcProxy


config = {
	'AMQP_URI': 'amqp://guest:guest@localhost'
}

with ClusterRpcProxy(config) as cluster_rpc:
	# 同步调用
	result = cluster_rpc.service_a.calling_another('hello')
	
	# 异步调用
	calling = cluster_rpc.service_a.calling_another.call_async('hello')
	result = calling.result() # .result()方法会一直阻塞到取得结果。

with ServiceRpcProxy('service_a', config) as service_rpc:
	# 同步调用
	result = service_rpc.calling_another('hello')
	
	# 异步调用
	calling = service_rpc.calling_another('hello')
	result = calling.result()

需要注意的是,Nameko在进行RPC调用时,默认不会设置超时时间,如果目标RPC服务没有运行,那么整个RPC调用就会一直阻塞等待下去。如果使用timeout参数设置了超时秒数,那么如果按时没有从目标RPC服务获得调用结果,就会抛出RpcTimeout异常。如果调用了不存在的RPC服务,则会直接抛出UnknownService异常。

运行RPC服务

Nameko提供了一个命令行脚本来负载RPC服务,但是也提供了通过程序启动RPC服务的方式。如之前最小RPC服务一节所示,直接运行nameko run [模块名[:RPC服务类名]]就可以启动一个或者一组RPC服务,如果不指定RPC服务类名,仅指定模块名,Nameko会自动寻找模块中的RPC服务类。

通过程序来启动RPC服务是通过Nameko提供的ServiceContainerServiceRunner类来实现的。ServiceContainer类是Nameko为RPC服务提供包装和依赖注入的工具类,每一个RPC服务都需要在ServiceContainer类实例的支持下运行。在启动RPC服务时,每一个RPC服务都需要一个ServiceContainer类实例来负载。构造一个ServiceContainer至少需要提供RPC服务类和一个配置对象。以下是一个使用ServiceContainer控制RPC服务的示例。

from nameko.containers import ServiceContainer


class Service:
	name = 'rpc_service'


container = ServiceContainer(Service, config={})

# 启动RPC服务
container.start()

# 停止RPC服务
container.stop()

# 强行终止RPC服务
container.kill()

ServiceContainer可以接受以下配置项,这些配置项也可以用于其他需要配置RPC服务的位置。

  • AMQP_URI,AMQP消息队列连接串。
  • WEB_SERVER_ADDRESS,Web服务地址。
  • rpc_exchange,RPC服务消息所使用的消息队列Exchange。
  • serializer,串行化RPC调用传递信息的串行化器。
  • SERIALIZERS,可用的串行化器。
  • ACCEPT,允许使用的内容类型。
  • AMQP_SSL,配置AMQP的SSL连接。
  • max_workers,配置最大允许的Worker数量。

在Nameko的命令行中也可以使用这些配置项,可以将其写入到一个YAML文件中,并使用nameko run --config config.yaml [模块名[:RPC服务类名]]来加载配置并启动RPC服务。

在配置文件中还可以使用自定义的配置项,这些自定义的配置项可以通过nameko.dependency_providers模块中提供的Config类来只读访问。以下是一个使用Config类实例来读取配置项的示例。

from nameko.dependency_providers import Config


class Service:
	name = 'config_test'
	
	config = Config()
	
	@property
	def feature_enabled(self):
		return self.config.get('SOME_FEATURE_ENABLED', False)
	
	@rpc
	def some_method(self):
		pass

ServiceRunner是一个允许托管多个RPC服务的工具类,它会自动对RPC服务使用ServiceContainer进行包装,对加入其中的RPC服务进行统一的管理。Nameko的命令行nameko run也是采用这个工具类来完成RPC服务的运行。以下是ServiceRunner类的使用示例。

from nameko.runners import ServiceRunner


class ServiceA:
	name = 'service_a'


class ServiceB:
	name = 'service_b'


runner = ServiceRunner(config={})
runner.add_service(ServiceA)
runner.add_service(ServiceB)

# 启动所有RPC服务
runner.start()

# 停止所有RPC服务
runner.stop()

# 强行终止所有RPC服务
runner.kill()

除此之外,Nameko还提供了一个run_services()方法来运行RPC服务,这个方法在执行后会自动返回一个ServiceRunner对象用于RPC服务的控制。以下是run_services()方法的使用示例。

from nameko.runners import run_services


with run_services(config, ServiceA, ServiceB) as runner:
	# 执行其他交互操作
# 当离开with的作用域,所有服务自动停止

run_service()方法可以接受一个参数名为kill_on_exit,当给定值为True时,将会使用.kill()来代替.stop()来停止RPC服务。

事件系统

Nameko的事件系统是一个异步消息系统,可以在服务之间使用消息来触发一些处理功能。一个Nameko事件可以被一个或多个服务消费,也可以不被任何服务响应。以下是一个简单的特定事件的触发和响应的示例。

from nameko.events import EventDispatcher, event_handler
from nameko.rpc import rpc


class ServiceA:
	name = 'service_a'
	
	dispatch = EventDispatcher()
	
	@rpc
	def dispatch_message(self, payload):
		self.dispatch('event_name', payload)


class ServiceB:
	name = 'service_b'
	
	@event_handler('service_a', 'event_name')
	def handle_event(self, payload):
		# 处理payload中携带的信息
		pass

EventDispatcher类是Nameko的事件分发来源,EventDispatcher类实例提供了一个.dispatch()方法,其接受两个参数:event_type,作为消息转发的Routing_key;event_data,作为消息主体。

用于响应事件的事件处理方法需要使用@event_handler修饰器进行修饰。@event_handler修饰器可以接受以下几个参数。

  • source_service,指定事件的来源服务名称,可以用来指定处理方法特定响应指定服务发来的事件。
  • event_type,对应EventDispatcher发送事件时使用的event_type参数。
  • handler_type,处理方法类型,主要有以下三类。
    • events.SERVICE_POOL,自动匹配型,所有的处理方法都放在一个池中并根据其关注的服务来源和event_type进行分组,每个事件在一个处理方法组中只会有一个方法响应。这是handler_type的默认值。
    • events.SINGLETON,单一型,每个事件仅有一个注册的处理方法来响应,只有这个处理方法抛出错误并且设定requeue_on_error时,才会由其他的处理方法来响应。
    • events.BROADCAST,群发型,每个事件都会被所有的处理方法响应。
  • requeue_on_error,设定当处理方法抛出错误时,是否将事件重新排回队列。
  • reliable_delivery,设定为True时,事件会一直保存在队列中,直到有事件处理器来处理。

以下给出一个群发型事件处理器的示例。

from nameko.events import BROADCAST, event_handler


class ListenerService:
	name = 'listener'
	
	@event_handler(
		'monitor', 'ping', handler_type=BROADCAST, reliable_delvery=True
	)
	def ping(self, payload):
		# 所有正在运行的服务都会进行响应
		print(f'reponse from {payload}')

定时任务

定时任务是Nameko中提供的一个很简单的Entrypoint,可以根据一个固定的秒数间隔运行被修饰的方法。以下是一个定时任务的示例。

from nameko.events import EventDispatcher
from nameko.timer import timer


class TimedService:
	name = 'timed_service'
	
	dispatcher = EventDispatcher()
	
	@timer(interval=30)
	def ping(self):
		self.dispatcher.dispatch('heart_beat', self.name)

自定义Extensions

大多数项目都需要自定义一些个性化的功能,而不会是仅靠Nameko提供的内置Extensions就可以完成设计。利用Nameko提供的基础类,可以根据Nameko的设计思路,很方便的将个性化的功能利用Nameko的基础功能类融入到项目中。

在Nameko中能够自定义的内容都是Extensions,包括Dependency Provider和Entrypoint,这两个都是Extension的子类。要自定义一个Extension,一般需要继承nameko.extensions.Extension并且至少定义以下三个方法。

  • .setup(),在Service Container启动之前进行绑定的阶段调用,通常用来进行初始化工作。
  • .start(),在Service Container成功启动之后调用,会在所有的Extension的.setup()完成初始化后调用。
  • .stop(),在Service Container开始关闭之前调用。

Extension的.__init__()方法将在Service Container的.bind()方法执行过程中调用,但Nameko为了避免一些歧义,将Extension的初始化配置改在了.setup()方法中进行。绑定到Service Container中的Extension可以根据Extension类型,通过.entrypoints.dependencies.subextensions三个集合类型属性来进行访问,或者可以直接通过.extensions属性来统一进行访问。

Dependency Provider用于向RPC服务提供一些依赖环境,例如数据库连接、额外的控制功能等。要自定义个一个Dependency Provider一般需要继承nameko.extensions.DependencyProvider并至少实现以下方法。

  • .worker_setup(worker_ctx),在一个Worker执行任务之前调用,这里需要做一些开始任务处理之前的准备工作。在此抛出异常将导致Worker产生失败事件。
  • .worker_result(worker_ctx, result=None, exc_info=None),在一个Worker完成任务获得返回结果后调用,这里需要做一些针对Worker执行结果的处理,或者例如关闭数据库事务会话。
  • .worker_teardown(work_ctx),在一个Worker完成执行一个任务之后调用,这里需要做一些针对工作现场的清理工作,例如提交数据库事务会话。
  • .get_dependency(work_ctx),必须实现的一个方法,这个方法需要返回一个对象,可以是对象或者函数,这个被返回的对象将被注入到Worker中。

自定义Entrypoint一般用来实现新的传输机制或者新的服务初始化机制。要自定义一个Entrypoint,必须完成以下三项内容。

  1. 继承nameko.extensions.Entrypoint类。
  2. 实现.start()方法来启动Entrypoint。如果需要一个后台线程,可以使用Service Container提供的.spawn_managed_thread(function, identifier)来启动。
  3. 调用Service Container的.spawn_worker()方法来启动一个响应。

Entrypoint通常以一个修饰器的形式出现,这可以通过调用.decorator()方法来获取。获取到的修饰器其所接收的所有参数都会用来传递给Entrypoint的.__init__()方法。

在实现Entrypoint必须完成的三项内容中提到了需要使用Service Container的.spawn_worker()方法来启动一个响应,这个方法主要接受以下参数。

  • entrypoint,通常是Entrypoint实例自身。
  • argskwargs,传递给服务方法的参数。
  • context_data=None,一般会初始化为Worker上下文。
  • handle_result=None,一般会指定为Entrypoint中用于处理服务方法返回内容的处理函数。如果指定处理函数,则这个函数必须能够接受以下四个参数,并且必须返回一个元组包括服务方法返回结果和抛出的异常。
    • message,传入的消息。
    • worker_ctx,执行服务方法的Worker上下文。
    • result,服务方法的返回结果。
    • exc_info,服务方法抛出的异常。

实现微服务所需要注意的事项

Nameko是一个基于RPC的简单微服务框架,其中还有很多用于微服务的功能没有能够实现。在使用Nameko实现微服务时,需要着重考虑以下问题。

  1. 组件拆分与系统集成和部署运维之间的平衡。组件拆分的越多,对于业务的耦合就会越低并且可扩展性就会越高,但是系统的集成和部署运维就会更加复杂。
  2. 事务一致性。在微服务集群中需要进行分布式事务管理,在一项业务需要跨多个RPC服务进行处理时,必须有有效的事务控制手段,例如二阶段提交算法、三阶段提交算法,亦或者Paxos算法等。
  3. 集群健康度监测。集群中的RPC服务都依赖于AMQP消息队列,如果某个或者某些RPC服务停止响应或者离线,就可能会导致消息队列中的消息积压,从而拖累整个微服务框架的运行。要解决此问题需要对整个集群中的RPC服务有良好的监测和控制措施。
  4. 是否需要一个统一的服务管理平台。
  5. Nameko没有实现的服务注册、服务发布、服务路由、安全访问与授权、服务调用消息与日志等如何来实现。
  6. API网关是微服务架构的中心点和瓶颈,需要仔细选择、考量和实现。

网络通信:Twisted

Twisted是一个用Python编写的事件驱动网络框架,支持多种协议,包括UDP、TCP、TLS和其他应用层协议,例如HTTP、SMTP、NNTM、IRC、XMPP/Jabber等。开发人员可以直接使用Twisted实现的协议来编写自己的应用,而遇到Twisted没有提供协议实现的时候,也只需要自己编写协议类,并不需要处理更加底层的内容。

一个Twisted程序一般由Reactor发起的主循环和一些回调函数组成。当事件发生时,程序中相应的回调函数就会执行。Twisted是一个异步模型,与多线程模型不同的是,对于线程的控制,决定权在操作系统手中,编程者只能假设和建议线程的控制。而在异步模型中,一个任务想要运行,必须放弃对当前任务的控制权,这就使得异步模型比多线程模型要更加简洁。但对于编程过程来说,异步模型要更加复杂,必须将大块的任务拆成小步骤来做。但是为了提高应用的性能,这些付出是值得的。

最小应用

Twisted的最小应用真的十分简单。

from twisted.internet import reactor
reactor.run()

这个最小应用其实没有实现任何功能,只是启动了Twisted的主循环。并且在开始运行之后,必须使用Ctrl-C来结束程序运行。Twisted实现了Reactor模式,也就是说必然会有一个对象来代表这个Reactor也就是事件主循环,Twisted的核心即在于此。

我们后面会一步一步的在这个最小应用中添加功能。

安装

Twisted在Linux、macOS上都可以直接通过pip来完成安装。这里所说的安装专指Windows系统。

Twisted库依赖一部分C语言编写的基础层,所以在Linux和macOS上可以直接通过安装时编译来解决,但是在Windows系统上则会对系统中安装的C/C++编译器产生要求,在很多情况下直接使用pip进行安装就会失败。

所以在Windows上安装Twisted,可以先到Python libs上下载相应的Wheel包(.whl文件)。之后使用pip直接安装这个Wheel包即可完成安装。在下载Wheel包的时候,需要注意Wheel包对应的Python版本(cp35cp36cp37等)和系统架构版本(32位或64位)。

Tip

其实在很多Windows不能完成功能库安装的情况下,都可以采用这种使用预编译Wheel包的方式安装,只有注明仅支持某个系统的功能库除外。

Reactor

Reactor是整个Twisted事件循环的核心,提供了一些服务的基本接口,用于网络通信、线程和事件分发等。在Twisted中,Reactor是事件管理器,掌管着事件注册、注销,事件循环的运行,事件触发时回调函数的调用等。在Twisted中,Reactor是单例的,在每个程序中只能存在一个Reactor实例,Reactor实例在引用的时候会自动创建。

Reactor主要有以下特点。

  1. Reactor只能通过reactor.run()来启动。
  2. Reactor的停止需要调用reactor.stop()
  3. Reactor的循环会在调用reactor.run()的线程中运行,一般为程序的主线程。
  4. Reactor一旦启动就会一直运行下去,直到使用Ctrl-C或者kill等方法结束程序运行。
  5. Reactor的事件循环不会消耗任何CPU资源。
  6. 不需要显式创建Reactor实例,Reactor实例会在引入的时候自动创建。

Twisted在默认情况下会按照操作系统自动安装相应类型的默认Reactor。如果需要使用其他类型的Reactor需要在引入Reactor之前手动安装。以下提供一个手动安装使用pollreactor的示例。

from twisted.internet import pollreactor
pollreactor.install()
from twisted.internet import reactor
reactor.run()

选择不同类型的Reactor主要是根据所使用的操作系统和程序要实现的功能。Reactor基本上都是由twisted.internet包提供的。常用的Reactor主要有以下这些。

selectreactor

适用于Unix和Windows系统,采用select()实现,是在没有更好性能方案下的默认选择。

pollreactor

适用于Unix系统,采用select.poll实现。

kqreactor

适用于FreeBSD系统,采用FreeBSD的kqueue技术实现。

win32eventreactor

适用于Windows系统,采用WaitForMultiObjects实现。

iocpreactor

适用于Windows系统,采用IO Completion Ports实现。

epollreactor

适用于Linux 2.6及以上版本核心的Linux发行版,采用epoll实现。

gtk2reactor

适用于GTK 2.0图形程序。

gtk3reactor

适用于GTK 3.0图形程序。

cfreactor

适用于macOS系统,使用Cocoa框架实现。

wxreactor

适用于wxPython框架。对于wxPython框架,Twisted有两种实现方案,但是每一种都不是完美解决方案,并且可能会限制程序的跨平台特性。wxreactor目前是兼容性比较好的一种选择。以下是wxreactor的应用启动示例。

from twisted.internet import wxreactor; wxreactor.install()
from twisted.internet import reactor
import wx


app = wx.App(0)
reactor.registerWxApp(app)
reactor.run()

接口与协议

Twisted内部有许多被称作接口的子模块,每个子模块都定义了一组接口类。使用接口的目的主要是使应用文档化,并且充分利用了Python「鸭子类型」的特征。Reactor根据其所支持的操作系统和特性的不同,部分或全部实现了以下接口来实现相应的功能。

  • IReactorCore,核心功能,提供Reactor启动、终止、异常处理等功能。
  • IReactorFDSet,提供Reactor使用文件描述符的功能,允许向Reactor添加实现了IReadDescriptor接口的Reader类实例来从连接读取数据或者添加实现了IWriteDescriptor接口的Writer类实例来向连接写入数据。
  • IReactorProcess,提供进程管理功能。
  • IReactorSSL,提供支持网络SSL功能。
  • IReactorTCP,提供支持网络TCP通讯的功能,允许Reactor监听TCP连接或者连接到TCP服务器。Reactor通过协议和协议工厂来处理数据。
  • IReactorThreads,提供线程控制功能。
  • IReactorTime,提供定时任务功能,允许部署一些定期任务。
  • IReactorUDP,提供支持网络UDP功能,允许Reactor监听UDP连接。
  • IReactorUNIX,提供支持UNIX套接字功能。
  • IReactorSocket,提供第三方套接字支持。

不止Reactor是由众多的接口组合起来的,整个Twisted框架都是由众多抽象层松散的组合起来的。因此要了解和使用Twisted框架必须了解每个抽象层都提供了哪些API,有哪些接口和实例可以使用。Reactor所实现的接口都属于比较低层级的接口,在实际应用中除非应用的需求必要,不会选择直接使用低层级接口来实现应用逻辑,而是通过高层级抽象层来实现。几乎所有的高层级抽象都是在低层级抽象的基础上建立的,并且高层级抽象不会重复实现低层级抽象已经实现的功能,而是通过继承和组合来获取已经实现的功能。

在常见的Twisted应用中,很少会直接和Reactor的API进行交互,但是请记住Reactor的存在。

在Twisted框架中,还有三个概念需要了解:传输、协议和协议工厂。

传输

twisted.interfaces中的ITransport接口定义,表示一个可以收发字节的单条连接。ITransport几乎总是在低层级抽象中完成异步数据读写的操作,然后通过回调将数据发送给我们的应用。通常在编写Twisted应用时不会自行实现ITransport接口,而是使用Twisted提供的实现。

协议

twisted.interfaces中的IProtocol接口定义,表示一个具体网络协议的实现,例如FTP、IMAP等。每一个Twisted的协议类都为一个具体的连接提供协议解析,所以应用每建立一条连接都需要一个协议实例,也就是说协议是存储协议状态、间断性接收数据并积累数据的地方。每个协议都会有一个makeConnection(ITransport)函数用来确定协议实例所要使用的连接。Twisted已经实现了许多的通用协议,大多都存放在twisted.protocols中。

协议工厂

由于每个连接都需要使用自己的协议实例,所以协议实例是需要根据连接动态创建的。Twisted需要通过一种方式来为连接创建合适的协议,这种方式就是协议工厂的工厂模式。协议工厂由twisted.interfaces中的IProtolcolFactory接口定义。Twisted利用协议工厂中的buildProtocol()方法来返回一个新的协议实例。在日常定义协议工厂时,通常会采用继承twisted.internet.protocol.Factory基类的方式来实现工厂类。Factory基类中已经实现了buildProtocol()这一方法,所以我们需要将要实现的协议类赋予类变量protocol以通知基类创建何种协议实例,buildProtocol()会根据这个类变量来创建相应的协议实例。

在创立协议实例后,协议工厂即可通过makeConnection()方法将协议与传输实例进行绑定。在绑定传输实例后,协议即可通过dataReceived()等方法来翻译数据。

当传输实例关闭时,就会激活connectionLost()方法,这个方法可以接收一个twisted.python.failure.Failure实例来说明连接关闭的原因。Failure实例对象可以用来传递给回调函数,用来捕获异常与跟踪栈。异常在Twisted的回调用可以使用一个独立的回调函数来处理。

Deferred

Deferred是Twisted中处理回调的一种抽象机制。一个Deferred有一对回调链,一个处理正确的结果,另一个处理异常结果。我们可以通过向其中添加callbackerrback回调来使Deferred响应正确结果或者异常结果。

以下是一个只有一层回调的Deferred示例。

from twisted.internet.defer import Deferred
from twisted.python.failure import Failure


def success(res):
	print('A success chain')
	return res


def err_process(err):
	print('A Error occurred')


d = Deferred()
d.addCallbacks(success, err_process)
d.callback('success') # 调用处理正确结果的回调
d.errback(Failure(Exception('Some error'))) # 调用处理错误结果的回调,这里手工建立Failure对象
d.errback(Esception('Some error')) # 调用处理错误结果的回调,Deferred会将Exception对象自动转换为Failure对象

一个Deferred对象只允许被激活一次,也就是只允许调用一次.callback()方法。这是因为多次激活会导致多个回调同时出现,

下面再向Deferred中添加一层回调,并且跟Reactor结合起来。

from twisted.internet.defer import Deferred


def success(res):
	print('A success chain')
	return res


def err_process(err):
	print('A Error occurred')


def done(_):
	from twisted.internet import reactor
	reactor.stop()


d = Deferred()
d.addCallbacks(success, err_process)
d.addBoth(done) # 将回调同时添加到两条回调链上

from twisted.internet import reactor
reactor.callWhenRunning(d.callback, 'Success calling') # 当Reactor启动时调用指定函数,并传递给定的参数
reator.run()

在不使用Deferred时,重构异步函数相当困难,但是借助Deferred,可以通过修改回调链来重构应用。

Deferred提供了以下四个方法来将回调函数添加到回调链中。

\begin{itemize}

  • .addCallbacks(callback, errback),同时添加一对callbackerrback
  • .addCallback(callback),添加一个callback和一个直接返回第一个参数的直通函数。
  • .addErrback(errback),添加一个errback和一个直通函数。
  • .addBoth(function),将同一个回调函数同时添加到两条回调链中。 \end{itemize}

这里要注意的一个问题就是:在一个Deferred中,callbackerrback总是成对出现的,即便是仅添加一个callback或者errback,Deferred也会自动向另一条回调链中添加一个虚设的函数。

Deferred可以嵌套,可以在一个回调函数中返回一个Deferred,这样外层的Deferred会等待内部的Deferred激活并返回后继续执行。

Warning

注意:如果一个对象创建了一个Deferred,那么应该在其中对被创建的Deferred进行激活。

內联回调

前面的章节已经提到了Python中的一个重要功能「生成器」的概念,而且生成器也是Python实现异步操作的基础。生成器不仅可以根据步进向外抛出值,还可以接受外部传来的值。例如以下示例:

def generator():
	val = 0
	while val < 30:
		val = yield val
	print('done')


gen = generator()
print(gen.next())
print(gen.send(20))
print(gen.send(29))
print(gen.send(31))

yield语句是一个计算值的表达式,v = yield n可以在迭代生成器时向外抛出n的值,并将外部调用.send()方法的参数赋予v。并且在生成器内部还可以使用raise抛出异常。

回顾至此,生成器是不是与Deferred十分相似。如果将生成器假设为一个Deferred对象,那么其中每个回调都会被yield分隔,每一个yield表达式的值就是下一个回调的结果,就像是以下示例中那样。

def a_deferred(arg1, arg2):
	# 第一个callback
	b = b * arg1
	b2 = some_function(b)
	result = yield b
	
	# 第二个callback
	f = result + b
	result = yield some_function2()
	
	# 第三个callback
	try:
		some_function3(result)
	except SomeError:
		handle_error(arg2)

Twisted中提供了一个名为inlineCallbacks的修饰器,用来修饰一个生成器,将生成器转化为一系列异步回调,该修饰器位于twisted.internet.defer包中。当调用一个由inlineCallbacks修饰的生成器时,不需要手动调用send或者raise,修饰器会完成全部操作并保证生成器运行结束。如果生成器使用yield抛出一个非Deferred值,那么生成器会立刻继续向下迭代。但如果生成器抛出一个Deferred,那么生成器就会等待Deferred对象被激活并返回,如果Deferred被激活后产生异常,那么yield就会抛出异常,但这个异常只是普通的Exception,而不是Failure。

inlineCallbacks修饰的生成器在调用时,会得到一个Deferred对象,这个Deferred对象并不是生成器中yield生成的Deferred。

Deferred在被激活后可以通过.cancel()方法取消掉。调用.cancel()方法会忽略后续的任何callbackerrback

Twisted应用

Twisted中的Application是一个代表整个Twisted应用的最顶层服务,其中实现了IServiceIServiceCollection等接口。如果使用Twisted提供的twistd脚本来实现守护进程,则必须要用到这个类。Twisted已经提供了一个常用的Application实现,大部分情况下我们不必自行完成Applicaition实现。

IService接口定义了一个可以启动和停止的命名服务,其中有两个方法比较关键:startService()stopService(),分别用于启动和停止服务。IService中有两个必需的属性:name用来对服务进行命名,running用来表示服务当前的运行状态。IService中还提供了一个setServiceParent()方法,用来将服务添加到一个IServiceCollection对象中,使得服务可以被更加容易的管理。

twistd脚本最关心的就是Application,所以针对以下示例:

top_service = service.MultiService()

content_service = ContentService(content_source) # 自定义内容服务
content_service.setServiceParent(top_service)

factory = ContentServiceFactory(content_service) # ContentServiceFactory是自定义的用于建立服务的工厂类
tcp_service = internet.TCPServer(port, factory, iterface=iface) # 建立TCP服务
tcp_service.setServiceParent(top_service)

application = service.Application('Demo')
top_service.setServiceParent(application)

可以使用命令:twistd --nodaemon --python app.py来启动。

Warning

注意:--nodaemon表示要求twistd以常规进程方式启动而不是守护进程。

IService接口允许我们将不同的任务分开定义,形成不同的服务。而在实际项目中,服务之间往往是紧密联系的。Twisted提供了一个MultiService类来将多个服务组织在一起作为一个服务存在。由于MultiService实现了IServiceCollection,所以只需要调用.setServiceParent()MultiService实例设为服务的父服务即可将服务添加到MultiService中。具体用法可参考上面的示例。

异步HTTP:aiohttp

aiohttp是一个基于asyncio模块的HTTP客户端与服务端的集成功能库。它不仅同时支持HTTP客户端和HTTP服务端,并且还支持WebSocket,以及中间件支持等。

aiohttp直接使用pip安装后就可以使用,官网上建议安装aiodns功能库来加速DNS解析。

作为客户端使用

aiohttp是一个基于asyncio模块的异步HTTP客户端,所以在使用时,要打开思路,以异步的方式思考问题。

进行基本访问

使用aiohttp模块访问指定URL十分简单,只需要调用相应的HTTP谓词方法即可。基本使用方式如下:

import aiohttp

async with aiohttp.ClientSession() as session:
	async with session.get('http://www.baidu.com/') as resp:
		print(resp.status)
		print(await resp.text())

示例代码中使用ClientSession()创建了一个用于访问的会话,之后使用这个会话的不同谓词方法来进行HTTP访问,并返回ClientResponse类型的结果。

会话的调用函数一般会将其排入asyncio.get_event_loop()中,参考之前并行计算中介绍的使用.run_until_complete()方法排入异步任务的示例。

在实际使用中,可以在整个应用中使用一个会话,不要对于每次HTTP访问都创建一个会话,因为会话本身会在服务端留下一些信息。

会话可以使用的HTTP访问方法支持大部分的HTTP谓词,其使用格式如下:

  • sesison.get('url')
  • session.post('url', data=b'data')
  • session.put('url', data=b'data')
  • sesison.delete('url')
  • sesison.head('url')
  • sesison.options('url')
  • session.patch('url', data=b'data')

解析响应内容

会话访问指定URL返回的ClientResponse类型的结果中携带了全部服务器的响应内容。其中使用gzip和deflate压缩传输的内容将会被自动解压缩。

通常可以使用response.text()这个异步方法来获取响应的主体内容。response.status属性可以用来获取服务器响应的HTTP状态码。如果要获取二进制响应体内容,可以使用response.read()这个异步方法。此外还可以直接使用response.json()异步方法来获取JSON格式的响应体并直接进行解析。

如果响应体内容很长,可以直接使用response.content属性来进行响应流的操作。

传递Query参数

HTTP谓词一般都可以携带Query参数。aiohttp在进行HTTP访问时,不需要将Query参数手工拼入URL中,只需要将Query参数内容传入谓词方法的params参数中。

params参数接受字典({'key': 'value'})、元组列表[('key', 'value')]等类型的内容。

具体使用可参考以下示例:

params = {'key1': 'value1', 'key2': 'value2'}

async with session.get('http://www.baidu.com/', params=params) as r:
	assert str(r.url) == 'http://www.baidu.com/?key1=value1&key2=value2'

传递JSON参数

在使用POST等方法时,可以使用data参数来上传字典类型的表单数据。但是在目前的项目开发中,使用JSON格式的表单数据会变得更加常见。

aiohttp也支持使用JSON格式的表单数据,只需要使用字典类型的数据传递给json参数而不是data参数即可,例如:session.post(url, json={'key': 'value'})

上传文件

上传文件也是HTTP访问中经常要做的操作之一,aiohttp中上传文件非常简单。以下是一个上传文件的示例。

files = {'file': open(path, 'rb')}
await session.post(url, data=files)

或者还可以使用FormData()方法来对表单数据进行精细化配置。除此之外,可以使用流来上传文件,以下给出一个使用流上传文件的最简单示例。

with open(path, 'rb') as f:
	await session.post(url, data=f)

或者还可以使用异步生成器来传输更大的文件。

async def file_sender(file_name=None):
	async with asiofiles.open(file_name, 'rb') as f:
		chunk = await f.read(64*1024)
		while chunk:
			yield chunk
			chunk = await f.read(64*1024)

async with session.post(url, data=file_sender(path)) as resp:
	print(await resp.text())

Websocket访问

aiohttp功能库内置了WebSocket的支持,使用会话连接WebSocket会得到一个ClientWebSocketResponse类型实例,用来进行实时通讯。可以仿照下例来书写WebSocket访问。

async with session.ws_connect(ws_url) as ws:
	async for msg in ws:
		if msg.type == aiohttp.WSMsgType.TEXT:
			if msg.data == 'close cmd':
				await ws.close
				break;
			else:
				await ws.send_str(msg.data + '/answer')
		elif msg.type == aiohttp.WSMsgType.ERROR:
			break

WebSocket在同一时刻只能有一个读取任务,但是可以有多个写入任务。

头信息

头信息是HTTP请求中重要的信息储存位置,所有HTTP谓词访问方法都支持使用headers参数接受一个字典来对HTTP请求的头信息进行设置。

此外,跟随头信息设定的还有Cookies信息,这是使用coookies参数来设定的,Cookies参数同样接受一个字典类型的对象。

对于服务器响应信息中的头信息和Cookies信息的访问,可以分别通过headerscookies属性来访问,这两个属性中保存的信息都是字典类型,可以直接使用键值来访问。

连接池

默认情况下,aiohttp会同时并发进行全部的HTTP请求(默认上限为100个请求)。但是很多情况下需要对同时连接数进行限制,这时就可以使用连接池技术。aiohttp功能库提供了TCPConnector()方法,允许用户对HTTP连接进行细致的调控,连接池就是使用TCPConnector类来完成的配置。

conn = aiohttp.TCPConnector(limit=10) # 限制全局并发连接数为10个
conn = aiohttp.TCPConnector(limit_per_host=20) # 限制每个host的并发连接数为20个
session = aiohttp.ClientSession(connector=conn) # 使用TCPConnector初始化会话。

建立HTTP服务

aiohttp功能库的另一个重要功能就是建立HTTP服务。使用aiohttp功能库建立HTTP服务一般要经历三个步骤:

  1. 定义相关的请求处理方法。
  2. 定义路由来将URL与处理方法绑定。
  3. 启动HTTP应用。

以下示例演示了一个最简答的HTTP服务的搭建。

from aiohttp import web

async def hello(request):
	return web.Response(text='Hello world')

app = web.Application()
app.add_routes([web.get('/', hello)])
web.run_app(app)

或者还可以使用修饰器来定义路由:

from aiohttp impoer web

routes = web.RouteTableDef()

@routes.get('/')
async def hello(request):
	return web.Response(text='Hello world')

app = web.Application()
app.add_routes(routes)
web.run_app(app)

请求处理

在Web应用中,最核心的内容是请求处理函数的编写。请求处理包含两部分内容,一是编写处理函数,二是将处理函数与要响应的URL以及请求方法绑定。

编写处理函数比较简单,需要接收一个Request类实例,并且返回一个StreamResponse或其子类的实例即可,但是由于aiohttp是异步的,所以需要使用async/await关键字来标注。

async def handler(request):
	return await web.Response()

在编写处理函数之后,就是将处理函数与要响应的URL以及请求方法绑定。URL以及请求方法可以形成一个组合,相同的URL但不同的请求方法可以绑定不同的处理函数。这种绑定有两种实现方法,第一种是使用web模块提供的HTTP谓词方法来形成路由实例,其次则是前面示例中出现的路由修饰器。路由修饰器也是使用HTTP谓词来进行路由的绑定。

默认情况下绑定到GET的处理函数将可以处理HEAD请求,如果不允许这种请求,可以使用web.get(url, hanler, allow_head=False)来关闭这项特性。

请求处理的函数不仅可以独立放置在包里,还可以使用类来组织。aiohttp不关心任何处理函数的实现细节。如果使用类来组织请求处理,可以参考以下示例。

class Handler:
	def __init__(self):
		pass
	
	async def handle_greeting(self, request):
		name = request.match_info.get('name', 'Anonymous')
		test = "Hello, {}".format(name)
		return web.Response(text=test)

handler = Handler()
app.add_routes([web.get('/greet/{name}', handler.handle_greeting)])

使用类来组织处理函数,还有一种方法,就是继承web.View。继承web.View的类可以直接定义HTTP谓词方法,并将类整个绑定在路由上,来响应指定路由的不同谓词访问。例如:

@routes.view('path/to')
class MyView(web.View):
	async def get(self):
		return await get_resp(self.request)
	
	async def post(self):
		return await post_resp(self.request)
		
# 如果不使用修饰器,则可以使用以下语句进行绑定
web.view('/path/to', MyView)

路由参数传递

在Restful API的设计理念中,追求URL的语义化表达方式,这就使要传递给API的参数混入了URL中,而不是像传统API使用Query参数来传递。这样往往会出现/a/123/b/c格式的URL,其中的123就是要传递给API的参数。

解析Restful URL中的参数是一个支持Restful设计理念的框架的基本能力。aiohttp可以在路由映射中定义参数位置,并在传入处理函数的Request对象中提供了.match_info()方法来访问这些参数。例如:

@routes.get('/a/{name}')
async def hello_handler(request):
	return web.Response(text="Hello {}".format(request.match_info['name']))

此外,路由参数可以使用正则表达式来限定接收什么格式的参数,格式为{标识符:正则表达式}

命名路由

路由在定义的时候可以附加一个name属性,用来为其命名。命名的路由可以基由request.app.router对象访问。例如:

@routes.get('/root', name='root')
async def handler(request)
	pass

async def other_handler(request)
	url = request.app.router['root']

返回JSON响应

以上几节的示例中,返回的都是文本类型的响应。但是在实际项目中,常常会返回JSON类型、XML类型、HTML类型等多种类型的响应。

其中最常用的是返回JSON类型的数据,要返回JSON类型的数据,可以使用web.json_response(data)。这个函数接受一个字典类型的参数,可以将其编码成一个JSON字符串返回给客户端。

Session控制

aiohttp功能库并不带任何Session功能支持,其Session功能支持是由aiohttp_session这个第三方中间件支持的。具体使用可参考以下示例。

import asyncio
import time
import base64
from cryptography import fernet
from aiohttp import web
from aiohttp_session import setup, get_session, session_middleware
from aiohttp_session.cookie_storage import EncryptedCookiesStorage

async def handler(request):
	session = await get_session(request)
	last_visit = session['last_visit'] if 'last_visit' in session else None
	text = 'last visited: {}'.format(last_visit)
	return web.Response(text=text)

async def make_app():
	app = web.Application()
	fernet_key = fernet.Fernet.generate_key()
	secret_key = base64.urlsafe_b64decode(fernet_key)
	setup(app, EncryptedCookieStorage(secret_key))
	app.add_routes([web.get('/', handler)])
	return app

web.run_app(make_app())

Warning

这里的last_visit = session['last_visit'] if 'last_visit' in session else None,也是一个Pythonic用法,可以用来在目标值不存在或者不满足条件时返回一个默认值,也可以当做其他语言中的三元操作符来使用。

表单及文件上传

使用GET的Query参数接收数据是存在限制的,大量数据传输一般都是由表单完成的。从POST请求中获取表单数据并不是一件十分困难的事情。Request类型实例可以使用.post()方法解析表单数据,它可以接受并解析application/x-www-form-urlencodedmultipart/form-data类型的表单数据。表单数据可以像以下示例中所示解析表单数据。

async def do_login(request):
	data = await request.post()
	login = data['login']
	password = data['password']

Request.post()也可以完成文件上传功能,但是不建议处理较大的文件,因为.post()方法是将表单内的所有内容都读入内存,容易产生Out of memory错误。所以以下给出两种文件上传的处理。

# 直接使用.post()进行解析的示例
async def store_file(request):
	data = await request.post()
	file = data['upfile']
	filename = file.filename
	file_handle = data['upfile'].file
	content = file_handle.read()
	return web.Response(body=content, header=MultiDict({'CONTENT-DISPOSITION': file_handle))
# 推荐用于解析文件的示例
async def store_file(request):
	reader = await request.multipart()
	field = await reader.next()
	name = await field.read(decode=True)
	
	field = await reader.next
	filename = field.filename
	size = 0
	with open(os.path.join(path, filename), 'wb') as f
		while True:
			chunk = await field.read_chunk()
			if not chunk:
				break
			size += len(chunk)
			f.write(chunk)
	return web.Response(text="{} size of {} ".format(filename, size))

URL重定向以及HTTP响应状态

重定向是采用aiohttp.web.HTTPFound(url)来完成的。对于要返回不同的HTTP响应码,aiohttp针对不同的状态码提供了不同的异常类。以下给出一个常用的简表。

状态码对应方法
200HTTPOk
201HTTPCreated
202HTTPAccepted
204HTTPNoContent
205HTTPResetContent
301HTTPMovedPermanently
302HTTPFound
304HTTPNotModified
305HTTPUseProxy
307HTTPTemporaryRedirect
308HTTPPermanentRedirect
400HTTPBadRequest
401HTTPUnauthorized
403HTTPForbidden
404HTTPNotFound
405HTTPMethodNotAllowed
406HTTPNotAcceptable
408HTTPRequestTimeout
500HTTPInternelServerError
501HTTPNotImplemented
502HTTPBadGateway
503HTTPServiceUnavailable
504HTTPGatewayTimeout

所有的响应异常都有相同的初始化格式,以HTTPNotFound为例,aiohttp.web.HTTPNotFound(*, headers=None, reason=None, body=None, text=None, content_type=None)。其中301、302、305、307采用以下格式aiohttp.web.HTTPFound(location, *, headers=None, reason=None, body=None, text=None, content_type=None),而405则使用aiohttp.web.HTTPMethodNotAllowed(method, allowed_methods, *, headers=None, reason=None, body=None, text=None, content_type=None)的格式。

WebSocket

aiohttp的HTTP服务同样支持WebSocket。要支持WebSocket服务,只需要建立WebSocketResponse类型实例并在请求处理函数中返回即可。以下给出一个简单的示例。

async def websocket_handler(request):
	ws = web.WebSocketResponse()
	await ws.prepare(request)
	
	async for msg in ws:
		if msg.type = aiohttp.WSMsgType.TEXT:
			if msg.data == 'close':
				await ws.close()
			else:
				await ws.send_str(msg.data + '/answer')
		elif msg.type == aiohttp.WSMsgType.ERROR:
			print(ws.excwption())
	print('websocket connection closed')
	return ws

# WebSocket的处理函数需要绑定到GET请求上。
app.add_routes([web.get('/ws', websocket_handler)])

静态文件映射

对于静态文件的访问,一般HTTP框架的做法都是将一个URL目录映射至自身的静态文件目录中。aiohttp中使用aiohttp.web.static(url, path_to_static_folder)来完成静态文件目录映射。

aiohttp.web.static()除了接受url和被映射路径以外,还接受以下几个参数用于对映射方式进行配置:

  • show_index,对静态目录进行列表。
  • follow_symlinks,允许访问符号链接。
  • append_version,自动追加版本号,用于使客户端的JS和CSS缓存失效。

模板输出

aiohttp支持模板渲染输出,常用的渲染引擎是前面提到的Jinja2,这是由第三方中间件aiohttp_jinja2提供支持的。

在使用模板引擎之前需要先配置Jinja2的环境。

app = web.Application()
aiohttp_jinja2.setup(app, loader=jinja2.FileSystemLoader(templateFolder))

之后便可以在处理器函数上使用修饰器@aiohttp_jinja2.template(templateFilename)并在处理器函数中返回字典类型实例作为模板的上下文来渲染输出模板内容。

Warning

这里要注意的是,在使用修饰器定义处理器函数的映射路径时,@aiohttp_jinja2_templete()修饰器需要放在所有修饰器的最末尾,也就是以最后一个修饰器身份出现。因为模板渲染需要在所有修饰器之前执行来输出内容。

共享数据

aiohttp并不建议使用全局变量(如:单例变量)来保存和传递数据,所以aiohttp中的Application和Request类都加入了collections.abc.MutableMapping接口的支持,允许在其中存储一些数据。

要向其中保存数据,可以直接使用对键赋值的格式:app['key']=data。要调用存储的值,可以直接使用request.app['key']来访问。

中间件定义

使用中间件是aiohttp最强大的功能之一,也给aiohttp带来了强大的扩展性。中间件是通过aiohttp.web.middleware修饰器修饰定义的,可以对传入处理函数的Request和从处理函数传出的Response进行修改。以下是一个简单中间件的定义:

from aiohttp.web import middleware

@middleware
async def middleware(request, handler):
	resp = await handler(request)
	resp.text += ' hello'
	return resp

如同示例中所示,中间件函数需要两个参数,第一个是Request实例,第二是处理函数本身。中间件函数中需要调用处理函数来进行下一步的操作。

中间件在定义后,需要在web.Application()中声明使用,格式为app = web.Application(middlewares=[middleware_1, middleware_2]),这里声明中间件的顺序,一般就是中间件的应用顺序。中间件会应用到应用的所有路由处理函数中。

子应用

子应用是为了解决巨型应用中单一问题而存在的,一个巨型应用可以由多个子应用组成。子应用的建立与正常应用相同,但是启动是由父应用负责,需要使用app.add_subapp(url, subapp)方法将子应用绑定在父应用的一个路径下,当父应用启动时,对于指定路径的访问将转由子应用处理。

异步启动

之前的示例中所采用的web.run_app()方法启动应用,是采用阻塞式启动方式。如果需要以异步方式或者指定主机或端口的方式启动,则需要借助AppRunner的辅助。以下示例将应用启动在了8080端口上。

runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, 'localhost', 8080)
await site.start()

如果需要关闭服务,则需要调用await runner.cleanup()

后台任务

很多时候在项目中需要在应用启动后进行一些异步的后台操作,例如对AMQP队列消息的监听等。这些任务可以通过绑定到应用的启动事件和结束事件来执行。

aiohttp提供了app.on_startup_append()来将要执行的任务函数添加到启动事件中,还提供了app.on_clieanup_append()来将任务函数添加到结束事件中以做处理和资源清理工作。

流行Web框架:Flask

Flask是一个用于Web开发的微框架。Flask的微小,在于其可以使用一个文件完成服务的建立。微小的目的是在保证其内核足够简单的情况下能够获得最大的扩展能力。Flask不会为你决定需要使用哪些技术或者哪些框架,一切都可以随你所选;它也不会附带任何数据库抽象层、表单验证器以及其他任何额外的功能,Flask只是支持你将自己熟悉的功能加入到应用里来。

与前面所介绍的aiohttp不同,aiohttp并不是一个完整的Web框架,而更加像是一个HTTP服务的核心。相比之下,Flask拥有更加强大的兼容性以及调试能力。

相比本文提到但未介绍的Django和Tornado,Flask更加轻型。Django是一个集大成者,其中包含面面俱到的功能,这就使得Django变得较为庞大而缓慢;而Tornado更加偏重于异步通讯能力,Web框架方面较弱。所以在进行快速开发的时候,Flask就成了一个更加适合的选择。

以下示例启动了一个最小的Flask应用,读者可以试一下Flask的简单。

from flask import Flask
app = Flask(__name__)

@app.route("/")
def hello():
	return "Hello world"

if __name__ == "__main__":
	app.run()

\subsection{约定项目结构}

Flask对于项目结构没有过多的硬性要求,甚至一个单独的文件也可以构成一个网站服务。但实际上对于项目结构还是约定了一个推荐设置。项目根目录中需要包含以下内容:

  • 应用包目录,包含网站应用全部代码和文件。
    • __init__.py,应用包初始化代码及核心启动文件。
    • templates/,模板文件目录。
    • static/,静态资源文件目录。
  • tests/,包含全部单元测试及其他测试代码。
  • venv/,虚拟环境目录。
  • setup.py,项目安装文件。
  • wsgi.py,WSGI服务启动文件,可选。
  • app.py,应用启动文件,可选。
  • uwsgi.ini,uWSGI配置文件,可选。
  • uwsgi.yml,uWSGI配置文件,可选。

其形成的目录结构示意如下:

  • project-root/
    • project-package/
      • init.py
      • db.py
      • routes.py
      • templates/
      • statics/
        • css/
        • js/
        • images/
    • tests/
    • venv/
    • .git/
    • setup.py
    • wsgi.py
    • uwsgi.ini
    • uwsgi.yml
    • .gitignore

Flask及其依赖功能的安装

Flask的运行依赖于以下内容,这些功能库在Flask安装时会自动安装。

  • Werkzeug,WSGI实现。
  • Jinja,模板引擎。
  • MarkupSafe,防止注入攻击的自动编码工具。
  • ItsDangerous,用于保护Session cookie的数据签名库。
  • Click,命令行应用框架,用于支持flask命令及扩展管理命令。

以下依赖功能库是选装内容,可以根据需要选择安装。

  • Blinker,用于支持Signals。
  • SimpleJSON,高速JSON操作库。
  • python-dotenv,运行flask命令时用于支持环境变量。
  • Watchdog,开发环境中热部署支持。

Flask的安装十分简单,只需要在项目虚拟环境中运行pip install flask即可。其余可选装内容可以手动使用pip进行安装。

应用配置与启动

一个Flask应用就是Flask类的实例,应用中的所有配置、URL映射等等内容都必须在这个实例中注册。建立Flask类实例最简单直接的办法就是建立一个全局实例,就像是前面的最小Flask应用一般。但是这种创建方式并不推荐使用。

推荐的应用建立的方式是在一个函数中创建Flask的实例,这个函数被称为应用工厂(application factory)。所有的配置、注册工作都在这个函数中完成,函数最后返回Flask实例完成工作。

以下示例给出了一个示范性的应用工厂。

import os
from flask import Flask


def create_app(test_config=None):
	# 建立Flask实例
	app = Flask(__name__, instance_relative_config=True)
	# 设定应用将要使用的默认配置
	app.config.from_mapping(
		# SECRET_KEY用于对Flask及其扩展对数据进行加密保护
		SECRET_KEY='dev',
		DATABASE=os.path.join(app.instance_path, 'flaskr.sqlite'),
	)

	if test_config is None:
		# 从config.py中读取配置覆盖默认设置
		app.config.from_pyfile('config.py', silent=True)
	else:
		# load the test config if passed in
		app.config.from_mapping(test_config)

	# 此处用于保证前面声明的数据库所在路径存在
	try:
		os.makedirs(app.instance_path)
	except OSError:
		pass

	# 最简单的路由声明
	@app.route('/hello')
	def hello():
		return 'Hello, World!'

	return app

Flask提供了flask命令来运行应用,但是请注意,这个命令应该只用于调试。flask命令依赖于两个环境变量来运行,一是FLASK_APP,用于指定项目主程序所在包,二是FLASK_ENV指定运行环境。在指定两个环境变量之后,执行flask run来启动应用。

对于Linux系统,可以执行以下指令:

export FLASK_APP=project-package
export FLASK_ENV=development
flask run

对于Windows系统,在命令行cmd.exe中,可以使用set替代export

set FLASK_APP=project-package
set FLASK_ENV=development
flask run

在PowerShell中,则需要使用$env:来代替export

$env:FLASK_APP=project-package
$env:FLASK_ENV=development
flask run

flask run默认监听127.0.0.1的地址,可以使用--host参数来改变监听。Flask的监听服务默认运行在5000端口上。指定FLASK_ENV为development可以启动调试模式,此时Flask会使用友好方式列出程序错误所在。

全局配置项

前面的示例中,使用了app.config.from_mapping()来加载Flask应用的配置。Flask应用的全局配置是保存在app.config中的,这个属性是一个字典类型对象,并提供了许多种方法来加载配置。Flask内置了一些配置项来对整体应用特性进行配置。需要注意的是,Flask扩展的配置项也会保存在app.config中,可以将其全部定义在一个配置文件中统一加载使用。

Flask常用的配置项有:

  • ENV,启动环境设置,默认为'production'
  • SECRET_KEY,用来对Cookies等安全信息进行签名的密钥。
  • SESSION_COOKIE_NAME,用于保存Session ID的Cookies名称。
  • SESSION_COOKIE_DOMAIN,用于保存Session ID的Cookies相关配置。
  • SESSION_COOKIE_PATH,用于保存Session ID的Cookies相关配置。
  • SESSION_COOKIE_HTTPONLY,用于保存Session ID的Cookies相关配置。
  • SESSION_COOKIE_SECURE,用于保存Session ID的Cookies相关配置。
  • SESSION_REFRESH_EACH_REQUEST,指示是否在每次请求时都刷新Session。
  • SEND_FILE_MAX_AGE_DEFAULT,文件缓存的最长有效时间。
  • SERVER_NAME,指定应用监听的主机和端口。
  • APPLICATION_ROOT,指定应用根URL路径。
  • JSON_AS_ASCII,指示是否将对象序列化为ASCII编码的JSON串。
  • JSONIFY_MIMETYPE,JSON格式输出内容所使用的MimeType。

这些配置项可以定义在独立的配置文件中,像前面示例中一样通过from_mapping()或者通过from_object()来加载。如果配置被定义为了一个文件,其内容类似于以下示例:

# 文件名为config.py
class Config(object):
	DEBUG = False
	TESTING = False
	DATABASE_URI = 'sqlite:///:memory:'

class ProductionConfig(Config):
	DATABASE_URI = 'mysql://user@localhost/foo'

class DevelopmentConfig(Config):
	DEBUG = True

class TestingConfig(Config):
	TESTING = True

则可以使用app.config.from_object('config.ProductionConfig')来完成配置内容的加载。

此外,Flask还提供了from_envvar()from_pyfile()from_json()来分别从系统环境变量、Configuration配置文件和JSON文件中读取配置。

一些特殊的全局变量

Flask中提供了一些特殊的全局变量,用于在一次请求或者应用全局中储存和传递数据。这些特殊变量可以直接从flask模块中导入。

  • g,对每次请求都是唯一存在的特殊全局对象,用于保存在全局生效并共享的数据,每次请求都会重设这个变量。
  • current_app,指向站点Flask实例的特殊对象。当使用Application factory来建立Flask实例时,就可以使用current_app来访问当前的Flask实例。
  • request,用于访问当前请求中携带的HTTP数据及其他内容。
  • session,用于访问和保存当前会话中需要的数据,常用于存储请求之间需要记录的值。

路由定义

现代Web框架都是将处理函数(视图函数)绑定在指定URL上的,Flask也不例外。Flask的路由定义通过Flask实例的route()修饰器来定义。格式为@app.route(url, methods=[]),其中methods中可以使用字符串'GET''POST'等来设置处理函数能够响应的HTTP谓词。

路径参数

在前面aiohttp中曾经提到过,路由可以接受路径参数,Flask的路由同样也可以接受路径参数。路径参数的定义格式为<转换器:参数名>,设定的参数可以在处理函数的参数表中使用同名参数接收。例如:

@app.route('/post/<int:post_id>')
def show_post(post_id):
	return 'Post {0:d}".format(post_id)

Flask对于路径参数可以使用的转换器有以下几个:

  • string,默认转换器,用于接收字符串但不包含任何斜线。
  • int,接收正值整型。
  • float,接收正值浮点型。
  • path,接收字符串,但包含斜线。
  • uuid,接收UUID字符串。

URL定义

Flask对于路由路径定义中的斜线是有特殊处理的,不同的定义格式产生的效果不同。假如有以下两个路由定义。

@app.route('/projects/')
def projects():
	return 'The project page'

@app.route('/about')
def about():
	return 'The about page'

其中/projects/是带有尾部的斜线的,这会让它像一个目录一样运作,当你访问不带斜线的/projects时,Flask会将你转向至带斜线的URL上。但是尾部不带斜线的/about则像是一个文件一样,如果带上斜线,例如/about/,就会报404错误。

URL组装

很多情况下,项目需要将跳转链接输出到模板上,或者直接进行跳转。Flask提供了一个快速组装URL的途径:url_for()函数。它的第一个参数为使用@app.route()绑定路由路径的函数名称(字符串类型),其后需要使用命名参数来组成路由路径中的路径参数,或者是Query参数。

借用官方示例:

from flask import Flask, url_for

app = Flask(__name__)

@app.route('/')
def index():
	return 'index'

@app.route('/login')
def login():
	return 'login'

@app.route('/user/<username>')
def profile(username):
	return '{}\'s profile'.format(username)

with app.test_request_context():
	print(url_for('index')) # 输出/
	print(url_for('login')) # 输出/login
	print(url_for('login', next='/')) # 输出/login?next=/
	print(url_for('profile', username='John Doe')) # 输出/user/John%20Doe

对于静态文件URL的组装,可以使用'static'来作为第一个参数,即url_for('static', filename='style.css')

渲染模板

之前的项目结构中提到了模板目录templates,这个目录用于盛放用于渲染输出的模板文件。在Flask中渲染一个模板十分容易。

from flask import render_template

@app.route('/hello/')
@app.route('/hello/<name>')
def hello(name=None):
	return render_template('hello.html', name=name)

Flask提供了一个render_template()函数来进行模板的渲染,其第一个参数接受一个字符串用于指定模板文件名,之后使用命名参数的方式为模板建立上下文。

指定的模板文件的搜索与项目文件的组织形式有关。如果应用是以模块的形式出现,即使用application.py来定义Flask实例,则会从模块文件同级的templates目录里寻找相应名称的模板文件。如果应用是以包的形式出现,参考前面Application factory的例子,则Flask会从包中的templates目录下开始寻找。

如果不需要输出模板内容,而是输出JSON数据,可以从flask模块中引入jsonify()函数,来将要输出的内容转换为JSON字符串。

请求钩子

请求钩子使用修饰器实现,用于在处理请求之前或者之后执行一些代码,例如建立数据库连接或者对用户进行认证。Flask提供了4种请求钩子供使用。

  • @before_first_request,在处理第一个请求之前运行被注册的函数。
  • @before_request,在每次请求之前都运行被注册的函数。
  • @after_request,在每次没有未处理的异常的请求之后都运行被注册的函数。
  • @teardown_request,在每次请求之后运行被注册的函数,即便是请求抛出了未处理的异常。

请求钩子函数和视图函数之间共享数据一般使用上下文全局变量g。钩子函数可以直接取消请求或者直接返回响应内容,例如认证失败。

获取请求数据

一次HTTP请求中包含最重要的内容就是数据,数据会以各种形式传递给URL处理函数。Flask对于请求数据的接收是通过全局的request变量来提供访问支持的。

Info

Flask通过Context Locals来使得全局的request变量变成线程安全并仅保持目前处理函数关心的内容的。关于Context Locals的内容可以查阅Flask的文档。

request变量只需要使用from flask import request即可引入使用。对于POST和PUT请求,可以使用request.form属性来访问,该属性类型为字典。

对于URL中的Query参数,可以使用request.args属性来访问,该属性也是字典类型。

通过POST上传的文件,一般是multipart/form-data类型的内容,可以通过request.files属性来访问,其类型依旧是字典,通过键值可以访问得到临时文件的句柄。文件名则可以通过句柄的filename属性获得。

控制Cookies

Cookies是用于在客户端保存信息的一种方式,对于Cookies的访问是通过request.cookies属性访问的,该属性也同样是字典类型。但是需要注意的是,该属性仅提供Cookies的读取,并不能设置Cookies。

要设置Cookies,需要使用make_response()获取Response实例,并通过该实例来设置Cookies。具体可参考以下示例。

from flask import make_response

@app.route("/")
def index():
	resp = make_response(render_template('index.html'))
	resp.set_cookies('session_id', 'session x')
	return resp

重定向与抛出错误

Flask提供了redirect()函数用于重定向,只需要在处理函数中返回redirect()的结果即可使访问重定向。

而抛出错误则是使用abort(status_code)函数。处理函数执行遇到abort()即终止处理函数的执行,直接抛出相应状态码的错误。在默认情况下,Flask在出现错误时会展现一个空白页。

要自定义错误页内容,Flask提供了@app.errorhandler()修饰器。这个修饰器接收一个HTTP状态码作为参数(整型值),用于指定被修饰函数所响应的错误类型。并且在函数返回时需要像以下示例中一样返回错误码。

from flask import render_template

@app.errorhandler(404)
def page_not_found(error):
	return render_template('404.html'), 404

注意返回语句最后的404,这在无错误处理函数中默认是200,由于那些处理函数默认返回200,所以不必书写,但错误处理函数中需要明确书写HTTP状态码。这是因为处理函数实际上返回的是一个元组类型值,这个元组类型是按照以下格式排布的(响应内容, HTTP状态码, 响应头信息)。响应头信息是一个字典类型的值,如果元组中第二个值是字典类型,那么HTTP状态码默认就为200,元祖中的第二个值就变为了响应头信息。

要手工建立Response实例可以用之前提到的make_response()函数,并且可以使用获得的Response实例来进行详细的设置。

使用Session

Flask提供的基于Context Locals的全局变量除了request以外,还有session变量。session变量用于存储当前会话的信息,Flask中的Session也是基于Cookies的。

要使用Session信息,必须先为Flask实例,即app,设定secret_key属性来指定如何对Session信息进行加密处理。

session变量是一个字典类型,可以直接使用session['key'] = value来向其中保存信息。

多模块应用

在前面aiohttp的章节提到过,aiohttp支持将一个巨型站点应用拆分成多个模块聚合的应用。Flask也支持这种功能,称之为“蓝图”(Blueprint)。蓝图的创建十分简单,flask模块提供了Blueprint类。Blueprint类的初始化需要三个参数,第一个参数是蓝图的名称,第二个参数为蓝图的定义位置,一般会传递__name__作为位置说明,第三个参数一般使用命名参数url_prefix来指定蓝图对应的URl前缀。例如admin = Blueprint('admin', __name__, url_prefix='/admin')

蓝图可以像一个Flask实例那样使用,延续上面的示例,可以使用@admin.route()来定义蓝图中的路由处理函数。蓝图在Flask实例中使用.register_blueprint()方法绑入应用中。具体可参考以下示例。

假设有以下蓝图:

# 与__init__.py同级的admin.py文件,即admin模块
from flask import Blueprint, request, current_app, jsonify

bp = Blueprint('admin', __name__, url_prefix='/auth')

@bp.route('/login', methods=('POST'))
def admin_login():
	username = request.form['username']
	current_app.logger.debug("User attempt to login: %s", username)
	return jsonify()

__init__.py中可以这样来引入注册。

from flask import Flask

def create_app():
	app = Flask(__name__)
	
	# 引入语句不一定必须在脚本的头部,这里引入前面定义的admin模块
	from . import admin
	app.register_blueprint(admin.bp)
	
	return app

此时再访问/auth/login就会由admin模块的蓝图提供响应了。

信号机制

Flask中的信号机制(Signal)与操作系统中的信号机制类似,都是通过信号来通知已经注册的回调函数,并使回调函数开始运行的机制。在Flask中的信号机制由blinker库(官方推荐)提供支持,用于将函数注册到信号,并在触发信号时运行指定回调函数。信号机制主要用于将观察者模式中的各个模块之间解耦。

Flask定义了多个内置的信号,供应用监听特定事件,其中有一些是与请求钩子的触发位置相同,但是又有所不同。Flask内置的信号有以下这些:

  • template_rendered: 模板成功渲染后触发。
  • before_render_template: 模板渲染之前触发。
  • request_started: 请求被处理之前触发。
  • request_finished: 请求的响应被发送给客户端之后触发。
  • got_request_exception: 请求处理过程中发生异常的第一时间触发。
  • request_tearing_down: 请求被销毁时触发,无论是否存在异常都会触发。
  • appcontext_tearing_down: 应用上下文被销毁时触发。
  • appcontext_pushed: 应用上下文被Push时触发。
  • appcontext_popped: 应用上下文被Pop时触发。
  • message_flashed: 发送Flash信息时触发。

信号与请求钩子不同,信号不能中止请求,并且其调用是无序的,并且信号是可以携带参数的,而请求钩子通常不携带参数。信号与RabbitMQ之类的消息队列功能类似,但是更加轻型。RabbitMQ之类的消息队列能够实现的功能更多,并且可以在不同系统之间传递消息,而信号只是限于Flask应用中进行简单的消息分发。

订阅一个信号,可以使用以下基于修饰器的简单方式。

from flask import template_rendered

@template_rendered.connect_via(app)
def when_template_rendered(sender, template, context, **kwargs):
	pass

信号同样支持自定义,其需要用到blinker库中的signal类。信号的建立和订阅方式可参考下例。

from blinker import signal

# 实例化信号
sign = signal('test')

# 订阅信号
@sign.connect
def each(arg):
	pass

# 发送信号
sign.send(args)

日志

Flask中的日志功能是由Flask实例提供的,即app变量。日志功能由app.logger属性提供,其中常用的方法有:

  • .debug(),输出DEBUG级别日志。
  • .warning(),输出WARNING级别日志。
  • .error(),输出ERROR级别日志。
  • .info(),输出INFO级别的日志。

日志输出方法的第一个参数都是接收一个格式化字符串,其中为后续参数预留了占位符,其功能类似于C语言中的printf()

使用扩展增强Flask功能

Flask自身仅包含非常核心的功能,其更加强大的功能是由扩展来提供的。以下选择几个常用的扩展进行介绍,更加丰富的扩展列表可以在Flask Extensions找到。

扩展一般都命名为Flask-ExtName或者ExtName-Flask的形式。要使用一个扩展,可以直接在主启动文件(Flask实例初始化的文件)中通过传入Flask实例来完成扩展的载入和实例化。例如:

from flask-foo import Foo

foo = Foo()
app = Flask(__name__)

foo.init_app(app)

常用的Flask扩展有以下这些,其具体用法可以参考其文档。

  • Flask-SQLAlchemy:SQLAlchemy的Flask扩展,允许在Flask中便捷的使用SQLAlchemy。
  • Flask-WTF:提供了WTForms的接入,允许以简便的方法处理表单。
  • Flask-Peewee:提供Peewee ORM的接入。
  • Flask-OAtuh:提供了OAuth的支持。
  • Flask-Mail:提供了比较简单的发送邮件的支持。
  • Flask-Mako:允许使用Mako模板引擎来替代Jinja2。
  • Flask-Login:提供Flask对于用户Session的管理,支持用户的登入、登出以及会话记录等功能。
  • Flask-HTTPAuth:提供了Flask对于HTTP Header认证的支持。
  • Flask-Security:提供简单易用的安全与验证功能。
  • Flask-SSE:提供了Server Send Events的支持。
  • Flask-XML-RPC:提供了XML-RPC的支持。
  • Flask-Celery:提供了Celery的接入。
  • Flask-Bcrypt:提供了Bcrypt加密、散列功能支持。
  • Flask-SocketIO:提供了WebSocket功能的支持。
  • Flask-PageDown:提供了PageDown的包装,用于将Markdown转换为HTML。
  • Flask-KVsession:使用服务器端存储实现的用户Session。
  • Flask-Assets:用于合并、压缩、编译CSS和Javascript等静态资源文件。
  • Falsk-ApScheduler:定时任务框架。
  • Flask_nameko:用于在Flask中使用Nameko微服务。

Flask-SQLAlchemy

SQLAlchemy是Python中常用的ORM框架,Flask提供了一个扩展来允许用户更加便捷的在Flask应用中使用SQLAlchemy。Flask-SQLAlchemy主要提供了随着Flask实例启动数据库连接和关闭数据库连接的功能,其中数据表映射定义及查询等功能都没有任何变化,唯一的变化可能是不再需要引入sqlalchemy.orm

以下示例引入了Flask-SQLAlchemy,并将其载入了Flask实例。

from flask import Flask
from flask_sqlalchemy import SQLAlchemy

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////temp/test.db'
db = SQLAlchemy(app)

从示例中可以看出,Flask-SQLAlchemy是通过app.config['SQLALCHEMY_DATABASE_URI']来指定要连接的数据库内容的,所以可以在配置文件中将此配置项写入以便不在代码中进行硬编码。之后整个应用中的数据库操作均可以通过db来完成,其中数据表映射定义要使用的Column()等方法,均已包含在db中,可以直接使用。

对于使用类来进行数据表映射,之前独立使用SQLAlchemy时需要先建立一个Base基类,这项工作在Flask-SQLAlchemy中已经完成,可以直接继承db.Model类即可。

Flask-SQLAlchemy常用的配置项有:

  • SQLALCHEMY_DATABASE_URI,指定SQLAlchemy要连接的数据库URI,格式与SQLAlchemy中连接串格式相同。
  • SQLALCHEMY_ECHO,在SQL语句出现错误时,是否输出全部SQL语句。
  • SQLALCHEMY_POOL_SIZE,指定数据库连接池的大小。
  • SQLALCHEMY_POOL_TIMEOUT,指定数据库连接池中连接的超时时间,单位是秒。
  • SQLALCHEMY_POOL_RECYCLE,指定数据库连接池中连接的空闲回收时间,当空闲超过本时间后,连接即被关闭回收,单位是秒。
  • SQLALCHEMY_MAX_OVERFLOW,指定当数据库连接池连接数达到指定大小时,还可以创建的最大连接数。

对于数据表的查询可以直接使用实体来完成,增删改可以借助db.session来完成,这部分操作与SQLAlchemy中的操作完全一致。

在Restful Web应用中,常常会以JSON格式输出数据内容,但是如果直接使用jsonify()对SQLAlchemy查询结果进行序列化时,往往会得到XXX is not JSON serializable的错误,这是因为SQLAlchemy返回的实体是一个复杂结构,并不能简单的转换为字典等简单数据类型。要完成SQLAlchemy实体向字典类型的转换,推荐采用marshmallow库,具体使用可参考相应章节的介绍。

Flask-SocketIO

Flask本身不带WebSocket的实现,如果要在Flask框架上使用WebSocket功能,需要使用Flask-SocketIO扩展来完成。在客户端可以使用socket.io库来完成功能对接。

Flask-SocketIO的初始化十分简单,而且与Flask-SQLAlchemy基本相似。

from flask import Flask
from flask_socketio import SokcetIO

app = Flask(__name__)
app.config['SECRET_KEY'] = b'key'
socketio = SocketIO(app)

Flask-SocketIO通过修饰器来设定相应WebSocket事件的处理函数。@socketio.on(param)可以用来接收命名事件与未命名事件的信息。如果param的值为'message'则被修饰的处理函数可以接受未命名事件,并将其发送的内容作为参数收集起来;如果param的值为'json',则可以使用JSON解析客户端发送的未命名事件。

如果在@socketio.on(param)中传入一个自定义的事件名称,则处理函数就会响应带有自定义事件的请求。所有类型的处理函数都可以接受多个参数。此外,@socketio.on(param)还可以接受一个额外的命名参数namespace,指定不同的namespace可以允许客户端与WebSocket之间建立多条独立连接。

WebSocket是双向通讯的,所以Flask-SocketIO也同样支持消息的发送。消息发送功能是通过Flask-SocketIO提供的send()emit()两个函数完成的。其中send()函数用来发送未命名事件,emit()用来发送命名事件。两个函数的使用格式基本一致,其中emit()的第一个参数为事件名称,第二个参数为消息内容,并且支持发送多个消息内容;而send()函数由于是发送未命名事件,所以其参数直接为消息内容。两个参数都可以接一个名称为namespace的参数,这可以允许两个函数将消息发送至指定的命名空间(连接)中。

在默认情况下,send()emit()都是与客户端一对一进行消息收发操作的,如果服务器需要群发通知,则需要给这两个函数添加一个命名参数broadcast=True,这就表示本次消息发送为广播发送,所有已连接的客户端都会收到消息。

下面给出Flask-SocketIO的一些常用修饰器及响应事件的标识。

  • @socketio.on(event, namespace='/'),用于对客户端发送数据进行响应。
    • 'message',未命名事件,直接获取数据。
    • 'json',未命名事件,以JSON格式解析数据。
    • 'custom event',命名事件,可以接受任意类型数据。
    • 'connect',客户端连接事件。
    • 'disconnect',客户端断开事件。
    • 'join',客户端加入群事件。
    • 'leave',客户端离开群事件。
  • @socketio.on_error(),用于响应默认命名空间内的错误,指定命名空间后响应指定命名空间的错误。
  • @socketio.on_error_default,用于响应所有命名空间内的错误。

其他更多的使用方式可参考Flask-SocketIO的文档。

Flask-Script

Flask-Script扩展向Flask提供了插入外部脚本的功能,常用功能主要有以下几个。

  • 运行一个开发用的服务器。
  • 运行一个定制的Python Shell。
  • 设置数据库。
  • 定时任务。
  • 运行Web应用之外的命令。

在很多线上教程的示例中,都会出现一个专门用于管理Flask站点的manager.py,并且一般通过这个文件来完成Flask运行实例的管理。这个文件可以通过Flask-Script来实现功能。

Warning

注意,在0.11版之后的Flask中内置的CLI命令行工具已经能够完全替代Flask-Script了,所以在大部分情况下还请使用Flask内置的CLI工具来实现应用管理脚本。

Manager类

Flask-Script提供了一个Manager类,可以使用它来管理一个Flask实例。其最简单的使用示例如下。

from flask_script import Manager
from app import app


manager = Manager(app)


if __name__ == '__main__':
	manager.run()

在这个最简单的示例中,只需要执行python manager.py即可启动Manager实例并运行Flask应用实例。Manager实例还可以接受命令行命令来完成额外的功能。在Flask-Script中,命令的定义有三种方式。

  • 创建Command子类,实现其中的run()方法,并使用Manageradd_command()方法将其添加到Manager实例中。
  • 使用@manager.command修饰器修饰一个函数,函数名即可成为一个命令。
  • 使用@manager.option()修饰器修饰一个函数,函数名即可成为一个命令,并且可以接受参数。一个函数可以被多个@manager.option()修饰器修饰。

以下给出一个详细的使用示例。

from flask_script import Manager
from app import create_app


manager = Manager(create_app)


@manager.option('-n', '--name', dest='name', help='App name', default='APP')
@manager.option('-p', '--port', dest='port', default=8080)
def start(name, port):
	print(name)
	print(port)


if __name__ == '__main__':
	manager.run()

在这个示例中,dest='name'指定了这个Option修饰器定义的参数对应的函数参数名称,并且可以在执行命令python manager.py start -n MyApp时将参数传入函数进行处理。

Manager实例可以通过add_command()方法嵌套使用,以形成一套复杂的命令行控制。

Server类

Server类是Flask-Script中提供的开发服务器。Server类一般会提供一个默认的命令和配置。以下给出一个常见的使用方法。

from flask_script import Server, Manager
from app import create_app


manager = Manager(create_app)
server = Server(host='0.0.0.0', port=9000)
manager.add_command('runserver', server)


if __name__ == '__main__':
	manager.run()

当在命令行中执行python manager.py runserver时,便会按照配置启动Flask应用。

Flask_Nameko

Flask_Nameko用于在Flask中对Nameko微服务进行包装。Flask_Nameko可以通过命令pip install flask_nameko完成安装。

要使用Flask_Nameko,需要在Flask app中对Nameko进行初始化,Flask_Nameko提供了一个FlaskPooledClusterRpcProxy类来与Nameko的RPC服务集群进行通信。初始化过程一般可以参考以下示例。

from flask import Flask
from flask_nameko import FlaskPooledClusterRpcProxy


rpc = FlaskPooledClusterRpcProxy()

def create_app():
	app = Flask(__name__)
	
	rpc.init_app(app)

app = create_app()

之后在需要调用RPC服务的位置就可以如以下示例中一般使用。

from . import app, rpc


@app.route('/')
def index():
	result = rpc.service.some_method('some_value')
	return result

FlaskPooledClusterRpcProxy可以接受所有Nameko中的配置项,但前面需要添加NAMEKO_前缀。除Nameko定义的配置项以外,Flask_Nameko还定义了以下配置项供使用。

  • NAMEKO_INITIAL_CONNECTIONS,初始创建的连接数量,默认为2。
  • NAMEKO_MAX_CONNECTIONS,最大创建的连接数量,默认为8。
  • NAMEKO_CONNECT_ON_METHOD_CALL,决定何时加载连接到服务的连接,False表示在连接到服务时,True表示调用RPC方法时。
  • NAMEKO_RPC_TIMEOUT,调用RPC方法的默认超时时间。
  • NAMEKO_POOL_RECYCLE,连接池连接回收的秒数,连接大于指定秒数的将被自动回收。

CLI工具

Flask在安装的时候会默认安装一套CLI命令行工具,这套工具就是之前章节中见到过的flask命令。Flask CLI一般通过系统环境变量来对其进行运行时配置。例如通过FLASK_APP环境变量可以配置当执行flask run时需要的应用实例。

FLASK_APP环境变量有三个组成部分,分别是设定当前工作目录、应用实例或工厂方法所在文件、应用实例的名称或者工厂方法的名称。一般有以下几种设定方法。

  • 不设置任何值,自动在wsgi.py或者app.py中寻找应用实例或者工厂方法来启动开发服务器。
  • src/hello,设定当前工作目录为src,并且从文件hello.py中寻找应用实例或者工厂方法。
  • src.web,导入src/web.py,并从中寻找应用实例或者工厂方法。
  • hello:app2,导入hello.py,并使用其中的app2实例来启动开发服务器。
  • hello:create_app('dev'),导入hello.py,并使用其中的create_app()工厂方法来启动开发服务器,工厂方法给定一个参数'dev'

Flask CLI也支持自定义命令,这与Flask-Script一样,也有多种实现方式。

通过修饰器来定义命令

应用实例中的.cli.command()修饰器可以用于修饰一个函数,将这个函数作为CLI的一个命令来使用,并且可以使用Click库的@click.argument()来接收参数。具体使用可参考以下示例。

import click
from flask import Flask


app = Flask(__name__)

@app.cli.command()
@click.argument('name')
def create_user(name):
	pass

在以上示例中,执行命令flask create_user admin可以将admin作为参数传入方法create_user中。

在使用@app.cli.command()进行修饰的时候,Flask应用的上下文会自动的应用到命令处理函数中。但是如果直接使用@click.command来修饰命令处理函数,就需要使用@with_appcontext来注入上下文。例如:

import click
from flask.cli import with_appcontext


app = Flask(__name__)

@click.command
@with_appcontext
def do_something():
	pass


app.cli.add_command(do_something)

如果使用@app.cli.command()来修饰命令处理函数,但不需要注入上下文,则可以给修饰器传入参数with_appcontext=False来取消上下文注入。

使用应用组来添加命令

应用组可以提供嵌套命令的支持,但是这种情况下就不能单纯的使用修饰器来将命令添加到Flask CLI了,需要借助于add_command()方法。这里使用应用组来重写上一节的示例。

import click
from flask import Flask
from flask.cli import AppGroup


app = Flask(__name__)
user_cli = AppGroup('user') # 应用组的命令

@user_cli.command('create') # 嵌套的子命令
@click.argument('name')
def create_user(name):
	pass

此时执行命令flask user cerate admin就可以达到与上一节示例相同的功能。

部署站点

后面“使用Nginx部署Web应用”一节将会介绍使用uWSGI部署Flask站点的方法,这里介绍另外两种便捷的自托管方式。

功能库Gevent提供了一个基于协程的高性能WSGI服务,可以使用pip install gevent完成Gevent的安装,并建立以下文件作为站点的启动文件。

from gevent.wsgi import WSGIServer
from application import app

http_server = WSGIServer(('', 5000), app)
http_server.serve_forever()

功能库Tornado提供了一个基于异步的高性能HTTP服务,其中也提供了WSGI容器,可以用来负载使用Flask编写的Web服务。由于Tornado库功能丰富,这里只提供一个将Flask编写的Web服务托管进WSGI容器的示例。

from tornado.httpserver import HTTPServer
from tornado.ioloop import IOLoop
from tornado.wsgi import WSGIContainer
from application import app

http_server = HTTPServer(WSGIContainer(app))
http_server.listen(80)

# 启动Tornado的事件循环,配合HTTPServer工作
IOLoop.instance().start()

异步Web框架:Starlette

前面介绍过的Flask是一个基于WSGI的阻塞式Web微型框架。阻塞式架构在使用时,常常会遇到性能问题,例如某一个访问请求需要消耗较长的时间来进行处理和组织响应,那么就会导致使用相同服务线程的其他访问请求进入排队,一旦一台服务器的全部服务线程被占满,那么就会使服务的性能出现瓶颈。在这情况就会显得Web服务性能低下,但这是WSGI阻塞式架构的固有缺陷。

为了解决这些缺陷,Python社区顺应时代发展潮流推出了WSGI的继任者——ASGI,异步Web服务框架。ASGI采用异步访问,占据资源较多或者耗时较长的访问请求不会影响其他的访问请求,利用现代多核心多线程CPU的特点以及异步函数的特性大大提升了Web服务的性能。

ASGI框架还不是很多,目前其中的佼佼者可算是Starlette,其综合性能较Flask提升了约250%。Starlette的使用与Flask十分相似,在具体项目中从Flask切换到Starlette并不困难。

Starlette主要提供了以下现代Web开发的常用功能支持:

  • WebSocket;
  • GraphQL;
  • 进程内后台任务;
  • 启动与中止事件任务;
  • CORS、GZip与静态文件、流的支持;
  • Session与Cookie支持。

最小应用

Starlette可以直接安装使用,在部署时只需要搭配一个ASGI服务使用即可。Starlette没有指定或者内置ASGI服务,所以可以自行选择一个ASGI服务实现,这里推荐性能比较好的uvicorn。

在项目目录中执行以下两条命令即可完成项目基本运行环境的安装。

pip install starlette
pip install uvicorn

以下给出一个Starlette的最小应用,其中包含了全部Starlette应用的基本结构。

from starlette.applicatons import Starlette
from starlette.response import JSONResponse
import uvicorn


app = Starlette()

@app.route('/')
async def homepage(request):
	return JSONResponse({'hello': 'world'})

if __name__ == '__main__':
	uvicorn.run(app, host='0.0.0.0', port=8080)

要运行这个最小应用可以使用python app.py命令,或者使用uvicorn app:app命令来启动。

Starlette没有必需依赖,只在需要其中某些功能时才要安装相应的依赖,所需要的依赖主要有以下这些。

  • requestsTestClient功能依赖。
  • aiofilesFileResponse或者StaticFiles功能依赖。
  • jinja2,使用默认模板时需要。
  • python-multipart,使用request.form()进行表单解析时需要。
  • itsdangerousSessionMiddlwware功能依赖。
  • sqlalchemyDatabaseMiddleware功能依赖。
  • pyyamlSchemaGenerator功能依赖。
  • grapheneGraphQLApp功能依赖。
  • ujsonUJSONResponse功能依赖。

对于以上依赖内容,可以通过命令pip install starlette[full]一次性完全安装。

主应用类

前面的最小应用中使用Starlette类初始化了核心的app应用变量。这个Starlette类就是Starlette中的主应用类,通过创建一个Starlette类的实例可以创建一个新的Starlette应用。

Starlette类主要提供了以下功能:

  • 定义应用的路由处理:
    • app.add_route(path, func, methods=["GET"]),添加一个HTTP路由,其中path为路由路径,func为处理函数,处理函数可以是异步函数或者普通函数,但其签名格式需要是格式func(request, **kwargs) -> response
    • app.add_websocket_route(path, func),添加一个WebSocket路由,其中func为处理函数,必须是异步函数,签名格式为func(session, **kwargs)
    • @app.route(path, methods=['GET'])app.add_route()的修饰器版本,可以直接用来修饰处理函数。
    • @app.websocket_route(path)app.add_websocker_route()的修饰器版本。
  • 定义应用事件处理器:
    • app.add_event_handler(event_type, func),添加一个时间处理器,其中event_type只能取值'startup'或者'shutdown',分别表示应用启动事件和应用关闭事件。
    • @app.on_event(event_type)app.add_event_handler()的修饰器版本。
  • 加载其他的子应用:
    • app.mount(prefix, sub_app),将子应用(Starlette类实例)加载到路由前缀为prefix的路由下。这常用来组织复杂应用。
  • 处理异常:
    • app.add_exception_handler(exc_class_or_status_code, handler),添加一个异常处理器,用于处理指定的异常类型或者HTTP状态码;处理函数可以是异步函数或者普通函数,其签名格式为func(request, exc) -> response
    • @app.exception_handler(exc_class_or_status_code)app.add_exception_handler()的修饰器版本。
    • app.debug,启用或者禁用浏览器中的错误追踪。

Starlette类提供的功能可以看出,Web应用中核心的路由功能和事件处理功能都是由Starlette来完成的,多个Starlette类可以将整个Web应用进行模块化分隔,并重新组织起来。

路由定义

除了使用Starlette类中提供的add_route()函数来定义路由,Starlette还提供了Router类来定义路由。使用Router类定义的路由可以替代Starlette类实例来启动Starlette应用,并直接提供路由访问功能。

Router类定义的路由需要使用Route类和Mount类配合。以下给出一个示例。

from starlette.routing import Mount, Route, Router
# 从项目的其他模块中引入Home类和SubApp实例等。

app = Router([
	Route('/', endpoint=Home, methods=['GET']),
	Route('/users/{username}', endpoint=User, methods=['GET']),
	Route('/users/{user_id:int}', endpoint=User, methods=['GET']),
	Mount('/mount', app=SubApp),
	Mount('/sub', app=Router([
		Route('/', endpoint=SubHome, methods=['GET', 'POST'])
	]))
])

上面的示例已经展示了Router类的使用。其中包括如何在路由路径中使用参数,以及定义参数的类型。具体如何在Endpoint中捕获这些定义的参数,将在后面Endpoint一节中介绍。在使用路由参数时,需要注意路由路径的定义顺序,带有参数的路由应当在同样前缀的路由组中尽量靠后放置,例如有两个路由:\user\self和路由\user\{username},其定义顺序就十分重要,如果带有参数的路由路径先定义,那么\user\self就永远不会有任何响应,因为它不能捕获到任何访问。

这里需要牢记的是Route类用于定义一个路由处理器,Mount类用于加载一套子路由。

Endpoints

前面路由一节提到路由处理器是由endpoint参数定义的,需要接受一个Endpoint作为参数值。Starlette中提供了两种Endpoint类,一种是HTTPEndpoint类,用于处理HTTP请求,一种是WebSocketEndpoint,用于处理WebSocket请求。

在定义路由处理器时需要继承相应的Endpoint类,并实现其中相应的方法。以下给出一个实现HTTPEndpoint类和WebSocketEndpoint类的示例。

from starlette.application import Starlette
from starlette.response import PlainTextResponse
from starlette.endpoints import HTTPEndpoint, WebSocketEndpoint


app = Starlette()

@app.route('/')
class HomePage(HTTPEndpoint):
	async def get(self, request):
		return PlainTextResponse(f"Nice")


@app.route('/{username}')
class User(HTTPEndpoint):
	async def get(self, request):
		username = request.path_params['username']
		return PlainTextResponse(f"Hello, {username}")


@app.websocket_route('/ws')
class Echo(WebSocketEndpoint):
	encoding = "text"
	
	async def on_receive(self, websocket, data):
		await websocket.send_text(f"Message received: {data}")

如果要将HTTPEndpoint类的实现绑定给路由的话,只需要将实现类本身绑定到app.add_route()方法中的func参数的位置即可,或者是Route类的endpoint参数上,无需将实现类的实例绑定过去。

HTTPEndpoint类中提供了对应与HTTP请求方法的getpostputdelete等,可以根据需要完成实现。如果请求了Endpoint实现类中没有实现的访问方法,Starlette将会抛出405错误。

WebSocketEndpointHTTPEndpoint要复杂一些。其中要先定义类成员encoding的值,用于指定在WebSocket传输中所使用的数据格式,可取的值有'json''bytes''text'三种。之后需要实现以下三个方法或者其中的某几个以实现WebSocket通信功能。

  • async def on_connect(self, websocket, **kwargs),用于处理WebSocket连接建立的事件,可以使用await websocket.accept()来完成连接建立。
  • async def on_receive(self, websocket, data),用于从WebSocket接收数据。
  • async def on_disconnect(self, websocket, close_code),用于处理WebSocket连接终止事件。

请求解析

在Endpoint中的request参数是Starlette中的Request类的实例,其中包含了对HTTP请求的全部解析内容。对于请求的内容可以通过以下方式访问。

  • request.method,访问方法。
  • request.url,请求URL,可以通过pathport等属性获取URL的不同部分。
  • request.headers,字典类型,获取请求头中的内容。
  • request.query_params,字典类型,获取Query String中的查询参数。
  • request.path_params,字典类型,获取URL路径中的参数。
  • request.client,获取客户端的信息,例如request.client.host
  • request.cookies,获取Cookie的值,使用.get(name)方法访问。
  • request.body(),以字节方式异步获取请求体内容。
  • request.form(),异步方式解析表单或者Multipart表单数据。
  • request.json(),异步方式以JSON格式解析请求体。

组建响应

Starlette中的响应是通过Response基类来提供的,但针对不同的响应还是提供了快捷的组建类。实例化一个Response类实例一般需要通过参数提供以下四种内容。

  • content,响应体内容。
  • status_code,HTTP响应码。
  • headers,响应头。
  • media_type,响应的媒体类型。
  • background,响应输出后要执行的后台任务。

Response实例可以设置客户端的Cookie,这主要通过set_cookie()delete_cookie()两个方法实现。

常用的响应类主要有以下这些。

  • HTMLResponse,HTML文本响应类。
  • PlainTextResponse,纯文本响应类。
  • TemplateResponse,通过模板渲染引擎的动态模板响应类。
  • JSONResponse,返回application/json的数据响应类。
  • UJSONResponse,使用ujson进行数据JSON串行化的响应类。
  • RedirectResponse,产生302转向的响应类。
  • StreamingResponse,产生流式数据的响应类。
  • FileResponse,产生文件下载的响应类。

WebSocket

WebSocket类提供了对于WebSocket通讯的操作支持,该类的实例无需手工创建,在路由处理器中会自动获得相应的实例。WebSocket类实例提供了与Request类实例相似的内容,可以用于访问WebSocket通讯的请求内容。

但是WebSocket与普通HTTP请求和响应不同的是,WebSocket是双向实时通讯的,所以WebSocket类提供了一系列的异步方法来支持通讯功能。

  • websocket.accept(),建立WebSocket连接。
  • websocket.send_text(data),发送纯文本数据。
  • websocket.send_bytes(data),发送字节数组数据。
  • websocket.send_json(data),发送JSON格式数据。
  • websocket.receive_text(),接受纯文本数据。所有接受数据的方法可能会抛出starlette.websockets.Disconnect()异常。
  • websocket.receive_bytes(),接受字节数组数据。
  • websocket.receive_json(),接受JSON格式数据。
  • websocket.close(),关闭WebSocket连接。

静态文件

静态文件的挂载可以直接在路由定义处通过StaticFiles类完成,可以将存放静态文件的目录直接挂载在某一个路由上。常用的使用方法可仿照以下示例。

from starlette.routing import Router, Mount
from starlette.staticfiles import StaticFiles


app = Router(routes=[
	Mount("/static", app=StaticFiles(directory="static"), name="static")
])

StaticFiles类的构造方法可以接受以下三个命名参数。

  • directory,静态文件所在的文件目录,默认相对于应用的根目录。
  • packages,一个列表,用于列举存放着静态文件的Python包。
  • check_dir,布尔值,用于确保存放指定静态文件的目录存在。

模板渲染

Starlette没有固定指定一种模板引擎来渲染输出HTML内容,但是在日常使用中,Jinja2模板引擎是比较好的一个匹配选择。Starlette针对Jinja2提供了简便的配置方法。以下是一个使用模板渲染输出的示例。

from starlette.applications import Starlette
from starlette.staticfiles import StaticFiles
from starlette.templating import Jinja2Templates


templates = Jinja2Templates(directory="templates")

app = Starlette()
app.mount("/static", StaticFiles(directory="statics"), name="static")

@app.route("/")
async def homepage(request):
	return templates.TemplateResponse("index.html", { "request": request })

首先需要对Jinja2Templates类进行实例化,获取模板引擎。实例化时需要指定模板文件所在目录。Jinja2Templates类的实例可以用来渲染模板目录中的指定模板。渲染模板需要使用TemplateResponse()方法,这个方法可以接受以下参数,并直接产生响应输出。

  • name,模板文件名。
  • context,要传递给模板的上下文实例,一般采用字典类型。
  • status_code,输出响应的Status Code。
  • headers,设置输出响应的头信息。
  • media_type,设置输出的媒体类型信息。
  • background,完成响应输出后要执行的后台任务。

中间件

Starlette使用中间件来扩展功能和在请求处理流程上进行请求接收和请求处理之间的额外处理过程。其中Starlette也提供了不少内置的中间件来完成相应的功能。中间件可以使用Starlette类中提供的add_middleware()方法来添加到处理流程中。add_middleware()方法针对不同的中间件,接受不同的命名参数。

以下就几个常用的中间件进行简单的介绍。

CORSMiddleware

CORSMiddleware位于starlette.middleware.cors包中,用于提供跨域配置。其在add_middleware()方法中接受的参数有以下这些。

  • allow_origins,字符串列表类型,用于配置允许访问的请求来源。['*']表示全部来源。
  • allow_origin_regex,字符串类型,使用正则表达式配置允许访问的请求来源。
  • allow_methods,字符串列表类型,用于配置允许访问的请求方法。['*']表示全部。
  • allow_headers,字符串列表类型,用于配置允许HTTP请求头。
  • allow_credentials,布尔类型,用于配置是否允许跨域Cookie。
  • expose_headers,配置响应头中哪些内容可以被浏览器访问到。
  • max_age,配置浏览器缓存CORS响应的最大时长。

SessionMiddleware

SessionMiddleware用于提供基于Cookie的会话支持。加载SessionMiddleware后,可以使用request.session来访问保存在会话中的内容。SessionMiddleware在add_middleware()方法中接受的参数有以下这些。

  • secret_key,用于加密Session的密钥。
  • session_cookie,用于保存Session ID的Cookie名称。
  • max_age,Session最长的存活时间,默认为2周。
  • same_site,用于配置是否允许跨站发送Session Cookie。
  • https_only,配置是否仅在HTTPS站上使用Session。

HTTPSRedirectMiddleware

HTTPSRedirectMiddleware十分简单,也不需要任何配置,主要是强制将HTTP和WS等非安全连接转向至HTTPS和WSS安全连接。

TrustedHostMiddleware

TrustedHostMiddleware主要用于配置可信网站,用于防止HTTP头攻击。在配置时只接受一个参数:allowed_hosts,字符串列表类型,用于列举可信主机。

GZipMiddleware

GZipMiddleware用于对响应进行压缩,降低响应在网络上的传输时间。在配置时只接受一个参数:minimum_size,整型值,用于表示低于此大小(字节数)的响应将不会被压缩,该值默认为500。

BaseHTTPMiddleware

BaseHTTPMiddleware并不是一个可以直接使用的中间件,而是一个中间件的抽象基类。通过继承BaseHTTPMiddleware类并实现其中的async def dispatch(self, request, call_next)方法可以自定义一个中间件。

BaseHTTPMiddleware的__init__()构造函数通常需要为以下格式:def __init__(self, app, **kwargs),其中app为当前Starlette应用的实例,而后面的**kwargs命名参数则是可以从add_middleware()方法处获取的命名参数。

集成数据库访问支持

DatabaseMiddleware用于在应用中提供数据库访问功能,在使用时需要依赖databases库以及连接相应数据库的异步驱动,例如PostgreSQL需要使用驱动asyncpg,MySQL需要使用驱动aiomysql

DatabaseMiddleware在配置时接受一个参数:database_url,这是一个字符串类型的值,用于提供DatabaseMiddleware数据库的连接串。数据库连接可以在之后通过request.database来获取。

Warning

这里需要注意一下,根据Starlette的开发计划,DatabaseMiddleware将在后续的版本中废除,所以建议尽量使用SQLAlchemy库或者databases库。这两个库需要在应用事件中独立完成数据库连接建立和连接关闭。

在数据库中查询可以使用以下四种方法来获取不同数量和类型的内容:

  • request.database.fetchall(query),获取全部行;
  • request.database.fetchone(query),获取一行内容;
  • request.database.fetchval(query),获取一个指定值;
  • request.database.execute(query),执行一条插入、更新、删除的查询。

对于数据库的查询可以使用SQLAlchemy Core查询或者使用原生SQL。

Warning

请勿与SQLAlchemy ORM的查询混淆,且仅能使用SQLAlchemy的经典模型定义方式。

要使用数据库事务支持,可以在Endpoint处理方法上使用@transaction来进行修饰,被修饰的方法中将自动开启和提交事务。当方法中有异常抛出的时候,事务会自动回滚。手动开启事务可以通过request.database.transaction()来获得事务实例,并通过事务实例的start()方法来启动事务,rollback()方法来回滚,commit()方法来提交事务。

除此之外,Starlette还可以与其他ORM框架一起使用,但需要在startupshutdown事件中自行控制数据库连接或连接池的开启和关闭,并且不能自动集成到Starlette的Request对象中。

身份验证

Starlette中的身份验证功能是通过AuthenticationMiddleware来提供的。AuthenticationMiddleware在配置是需要通过参数backend提供一个继承了Authenticationbackend基类的验证类。验证的结果会被保存在request.userrequest.auth中。

继承AuthenticationMiddleware类主要需要实现其中的async def authenticate(self, request) -> AuthCredentials, User方法。该方法返回一个元组,第一个元素是验证结果标记,第二个元素是用户信息。

AuthCredentials类通常用于存放验证结果标记,包括是否通过验证以及权限组合等。这些标记可以在Endpoint处使用@require()修饰器进行过滤。@require()修饰器除了可以接受一个字符串作为参数,还可以接受一个字符串数组作为参数,来进行标记的组合。默认情况下,验证不通过将会返回403错误,但是@require()可以通过status_code参数重新指定验证不通过时的错误号。

验证过程中抛出的异常,可以通过add_middleware()中的参数on_error来捕获处理。

配置文件

Starlette支持配置文件的读取,并且建议将应用的配置放进配置文件中,而不是应用代码中。Starlette支持12-Factor应用标准。按照12-Factor标准,应用的配置需要保存在环境变量中,以和应用代码本身分离,并且配置本身还需要根据不同的运行环境进行分组,例如开发环境测试环境生产环境等。

Starlette通过在starlette.config中的Config类来支持配置文件的加载。Config类可以加载环境变量、.env文件以及默认值的内容,如果不满足以上内容,则会抛出异常。其中最常用的是.env文件,其中内容格式与.ini文件类似,例如以下文件示例。

DEBUG=True
DATABASE_URL=postgresql://localhost/mydatabase
SECRET_KEY=098305345093845
ALLOWED_HOSTS="127.0.0.1", "localhost"

以上示例文件可以按照以下示例来使用。

from starlette.applications import Starlette
from starlette.config import Config
from starlette.datastructures import CommaSeparatedStrings, DatabaseURL, Secret


config = Config(".env")

# 获取DEBUG项配置,转换为bool类型值,并设置默认值
DEBUG = config("DEBUG", cast=bool, default=False)
# 获取数据库连接串配置
DATABASE_URL = config("DATABASE_URL", cast=DatabaseUrl)
# 获取密文
SECRET_KEY = config("SECRET_KEY", cast=Secret)
# 获取逗号分隔的字符串
ALLOWED_HOSTS = config("ALLOWED_HOSTS", cast=CommaSeparatedStrings)

app = Starlette()
app.debug = DEBUG

其中Secret密文类可以隐藏任何不想被追踪的信息,在使用时必须使用str()进行转换才能得到密文中信息。DatabaseUrl类也具备此功能。

逗号分隔的字符串会将设定好的值按照逗号拆分成一个字符串列表,其中的内容可以通过下标来访问。

对于字符串类型的配置项,也可以不使用cast参数进行转换,这样会直接得到原始字符串的值。

后台任务

Starlette支持后台任务的创建,后台任务通过BackgroundTask类创建,这个类位于starlette.background包中,主要用来在发送出响应之后完成一些后续的任务。BackgroundTask类的使用格式为BackgroundTask(func, *args, **kwargs),其所接受的参数均是要传递给后台任务func的。

传递给BackgroundTask类的func参数的函数需要是异步的。后台任务并不能返回任何信息。

要向响应中附加多个后台任务,可以使用BackgroundTasks类,其中提供了add_task()方法允许添加一个BackgroundTask类实例,从而将多个后台任务组合在一起。

部署Starlette应用

Uvicorn是一个实现了ASGI的轻型负载服务,在配合Starlette使用时,有两种启动方法。

  1. 命令行启动,格式为uvicorn app,其中app表示可以启动应用的变量或者类,其书写格式为文件名:变量名或者文件名:类名
  2. 程序式启动,使用uvicorn.run(app, host, port, log_level),其中app依旧为可以启动应用的变量或者类。

在实际项目部署中,Uvicorn经常会搭配Gunicorn来使用。Gunicorn是一个进程管理器,可以在uvicorn出现异常时或者组成集群簇时进行管理。搭配Gunicron时经常使用以下命令。

gunicorn app:App -w 4 -k uvicorn.workers.UvicornWorker

Gunicorn也可以使用pip安装。常用的命令行选项有以下这些。

  • -c CONFIG或者--config=CONFIG,指定配置文件,可以使用以下格式:
    • ${PATH}
    • file:${PATH}
    • python:${MODULE_NAME}
  • -b BIND或者--bind=BIND,指定监听的套接字,可以使用以下格式:
    • ${HOST}
    • ${HOST}:${PORT}
    • unix:${PATH}
  • -w WORKERS或者--workers=WORKERS,设定工作进程数量,每CPU核心可支持2-4个工作进程。
  • -k WORKER_CLASS或者--worker-class=WORKER_CLASS,设定工作进程类型以及实现。
  • -n APP_NAME或者--name=APP_NAME,设定进程名称。

Gunicorn可以搭配Nginx使用,这里给出一个示范性的Nginx配置,其中省略了大部分配置内容,仅保留了与Gunicorn有关的部分,具体其中各项配置的含义可参考下一章。

http {
	upstream app_server {
		# 当使用UNIX域套接字时,即绑定到unix:${PATH}时
		server unix:/tmp/gunicorn.sock fall_timeout=0;
		
		# 当使用TCP配置时
		server 127.0.0.1:8000 fall_timeout=0;
	}
	
	server {
		location / {
			try_files $uri @proxy_to_app;
		}
		
		location @proxy_to_app {
			proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
			proxy_set_header X-Forwarded-Proto $scheme;
			proxy_set_header Host $http_host;
			proxy_redirect off;
			# 如果需要使用流或者WebSocket,需要将缓冲关闭
			proxy_buffering off;
			proxy_pass http://app_server;	
		}
	}
}

异步版Flask:Sanic

Flask以其微框架的特点获得了相当高的关注,简单、实用、功能完备是架构师们选择Flask的原因之一。目前阻碍Flask继续发展的阻力之一就是基于WSGI的阻塞式架构。在前面一节虽然已经介绍过了基于异步的Starlette,但对于众多的Flask爱好者来说,用熟悉的Flask用法搭配异步处理将会更加舒爽。

Sanic就是这样一款能够满足Flask爱好者需要的框架,采用与Flask十分近似的语法和用法,但是采用异步设计。Sanic的理论性能与Starlette相比要更加强一些。然而在实际应用时,根据所搭配的ASGI服务和HTTP服务的不同,Web框架的整体性能也会有所变化。

Sanic需要运行在3.6版本以上的Python环境中,通过async/await关键字来实现异步。由于Sanic的使用方法与Flask十分相似,所以本节将主要针对Sanic在使用和部署中与Flask的不同来进行简单介绍。

Sanic的依赖不多,要应用在项目中只需要通过命令pip install sanic即可完成安装。如果不需要使用uvloop和ujson库,可以使用以下较为复杂的命令来安装。

SANIC_NO_UVLOOP=true SANIC_NO_UJSON=true pip install sanic

Sanic安装好以后,就可以新建一个文件来运行以下的最小应用了。

最小应用

与其他的框架一样,这里也从一个最小应用来展示Sanic的基本功能。

from sanic import Sanic
from sanic.response import json

app = Sanic()

@app.route("/")
async def homepage(requset):
	return json({"hello": "world"})

if __name__ == "__main__":
	app.run(host="0.0.0.0", port=80)

读者可以将这个最小应用的示例与Flask的最小示例比较一下,看看有多少异同点。

项目配置

Sanic中加载应用配置的方法与Flask类似,不同点主要是配置项来源格式和配置项内容。Sanic的配置项可以通过Sanic实例中的.config属性来访问,该属性提供了.update()方法来从一个字典中更新配置项。Sanic中的配置项都是使用大写字母书写,单词之间使用连词符分隔开。

在默认情况下,Sanic会自动加载前缀为SANIC_环境变量中的配置内容,如果需要更换一个前缀,可以在实例化Sanic类时指定,例如:app = Sanic(load_env='WEBAPP_');如果不需要从环境变量中加载配置项,则可以将参数load_env设置为False来关闭自动加载功能。

sanic.config.Config类中提供了以下几个方法来手动加载配置项,可以通过Sanic实例中的.config属性来调用。

  • .from_envvar(环境变量名),从环境变量指定的配置文件中加载项目配置,这里只需要提供指定了配置文件的环境变量名。
  • .from_object(object),从指定对象中加载配置项,只会加载属性名全部为大写的属性。
  • .from_pyfile(filename),从指定Python文件中加载配置项,只会加载其中名称全部为大写字母的变量及内容。

Sanic的内建配置项要比Flask少很多,所有配置项可以见下表。

配置项默认值配置功能
REQUEST_MAX_SIZE100,000,000一个请求的最大字节数
REQUEST_BUFFER_QUEUE_SIZE100请求缓存队列长度
REQUEST_TIMEOUT60请求抵达超时秒数
RESPONSE_TIMEOUT60响应处理超时秒数
KEEP_ALIVETrue是否启用Keep-Alive功能
KEEP_ALIVE_TIMEOUT5TCP保持Keep-Alive的最长秒数
GRACEFUL_SHUTDOWN_TIMEOUT15.0强制关闭非空闲连接的秒数
ACCESS_LOGTrue是否允许记录访问日志
PROXIES_COUNT-1设置在Web应用之前放置的反向代理数量

路由定义

前面Flask和Starlette已经比较完整描述过路由的概念了,Sanic中的路由采用了与Flask比较相像的使用方法。Sanic类提供了.add_route()方法和.route()修饰器来定义路由。其中.route()修饰器会更加常用一些。

.route()修饰器的调用格式为:.route(uri, methods, host, strict_slashes, stream, version, name),其常用参数含义如下:

  • uri,要绑定到的URL。
  • methods,列表类型,同于指示路由可以响应的请求方法。
  • strict_slashes,确定是否精确匹配尾部的斜线。
  • host,设定处理函数只响应来自指定Host的请求。
  • stream,设定以流的方式处理请求和响应。
  • version,设定当前处理过程的版本。
  • name,设定路由名称,用于.url_for()方法拼合URL。

.route()可以响应GET、POST、PUT、DELETE等所有请求方法,如果只需要响应特定的请求方法,Sanic还提供了.get().post().put().delete().head()等修饰器来修饰特定请求方法的响应函数,这些修饰器的使用与.route()基本一致。如果不使用特定的修饰器来定义请求方法,则需要使用.route()修饰器中的methods参数来设定。

.add_route()方法可以以普通语句方式向应用中添加路由,这对于应用与路由处理函数不在一个文件中定义的项目来说十分有用。.add_route()方法的调用格式为:.add_route(handler, uri, methods, host, strict_slashes, version, name, stream),除参数handler以外,都与修饰器版本可接受的参数一样。.add_route()方法的参数hander可以接受一个函数或者一个类实例用于相应URL的处理。

由于Sanic是基于异步的,所以所有的处理函数都需要是使用async关键字修饰的,这一点与Flask不同,要尤其注意。处理函数一般都接受一个sanic.request.Request类型的参数,其中保存了请求的全部数据,并返回一个来自sanic.response包中的响应类型实例。

路径参数

Sanic的路由也是可以接受参数的,其参数定义与Flask稍微不同,格式为<参数名:参数类型>。处理函数中需要在sanic.request.Request类型参数之后继续书写与路径参数名同名的参数来收取通过URL路径传入的值。

例如:

@app.route("/tag/<tag>", methods=["POST"])
async def add_tag(request, tag)
	return json({"tag": tag})

Sanic的参数类型支持stringintnumberalphapathuuid和正则表达式几种,选择不同的参数类型可以使路由匹配到不同的URL内容。

定义子路由

Sanic中的子路由也是通过蓝图(Blueprint)来设定的。蓝图在定义后需要注册到Web应用中即可。以下给出一个简单示例来说明。

from sanic.response import json
from sanic import Blueprint


bp = Blueprint('a_subroute')

@bp.route("/")
async def bp_root(request):
	return json({"hello": "world"})

在Web应用文件中需要用以下示例中的方式来将蓝图绑定进应用。

from sanic import Sanic
from sub_route import bp


app = Sanic(__name__)
app.blueprint(bp)

app.run(host="0.0.0.0", port=8000)

蓝图中的路由与Sanic应用实例中的路由定义完全相同。蓝图可以被挂载到应用的一个子路径下,自路径可以通过Blueprint类构造函数中的url_prefix参数来指定,在未指定时,蓝图会默认挂载到/下。

挂载到同一路径下的蓝图可以成组来设置,并且蓝图组可以嵌套。具体可参考以下示例。

from sanic import Sanic
from sanic import Blueprint

bp1 = Blueprint("the_first", url_prefix="/first")
bp2 = Blueprint("the_second", url_prefix="/second")

bp3_1 = Blueprint("the_third_sub_1", url_prefix="/t1")
bp3_2 = Blueprint("the_third_sub_2", url_prefix="/t2")
bp3 = Blueprint.group(bp3_1, bp3_2, url_prefix="/third")

api = Blueprint.group(bp1, bp2, bp3, url_prefix="/api")

# 在实际应用时可拆为多个文件
app = Sanic(__name__)
app.blueprint(api)

输出静态文件

对于静态文件的输出是依靠Sanic类和Blueprint类中提供的.static()方法实现的。.static()方法主要用于实现URL与磁盘文件之间的映射。例如:app.static("/static", "./resources")

.static()方法的常用的各个参数用途如下:

  • uri,静态资源要挂载到的URL。
  • path,要向外提供的静态资源所在路径或文件名。
  • pattern,用于过滤所提供的静态文件名的正则表达式。
  • use_modified_since,是否发送文件的修改时间。
  • use_content_range,是否支持发送指定的文件段。
  • stream_large_files,自动采用流发送大文件,设定True将自动采用1KB作为大文件判定依据,可以设置为具体字节数来自定义判定依据。
  • name,用于.url_for()的路由名称。
  • host,指定路由要匹配的Host。
  • strict_slashes,确定是否精确匹配尾部的斜线。
  • content_type,自定义Content-Type。

版本路由

版本路由是Sanic提供的一个比较有特色的路由功能。通过给Sanic类实例和Blueprint类实例提供的.route()修饰器或者.add_route()方法中使用version参数赋予整形值,Sanic可以自动向指定路由前添加v{version}来区分相同路由的不同版本。

具体可参考以下示例。

from sanic import response

# 通过 http://localhost/v1/greeting 访问
@app.route("/greeting", version=1)
def handle_request(request):
	return response.text("The first version")

# 通过 http://localhost/v2/greeting 访问
@app.route("/greeting", version=2)
def handle_request(request):
	return response.text("The second version")

如果在创建蓝图时设定了蓝图的版本,那么版本号将添加到蓝图内的所有URL前。

获取请求数据

Sanic路由处理函数的第一个参数都是一个sanic.request.Request类的实例,其中保存了一次HTTP请求中所携带的全部信息。对于请求中所携带的信息,一般是通过Request类提供的属性和一些方法来获取的。其中常用的属性主要有以下这些。

  • .json,对象类型,请求中用JSON格式提交的表单数据。
  • .args,字典类型,请求中携带的Query参数。
  • .query_args,列表类型,使用(key, value)形式的元组保存Query参数。
  • .raw_args,字典类型,所有Query参数,对于Query参数中同名参数只保留第一个值。
  • .form,字典类型,保存着使用Form Data格式提交的保单数据。
  • .files,字典类型,保存的值为文件对象,文件对象中有以下字段可供访问请求中上传的文件。
    • .name,上传的文件名。
    • .type,上传文件的文件类型。
    • .body,上传文件的文件体。
  • .body,字节数组,请求中提交的原始请求体数据。
  • .headers,字典类型,请求头内容,其中请求头名称区分大小写。
  • .methods,字符串类型,HTTP请求方法。
  • .ip,字符串类型,请求者的IP地址。
  • .port,字符串类型,请求者发送请求时的端口。
  • .socket,元组类型,其中包含请求者的IP和端口。
  • .app,响应请求的Sanic实例。
  • .url,请求的具体URL。
  • .scheme,请求使用的协议,如httphttpswswss
  • .host,请求的主机名称。
  • .server_name,请求的主机名称,不包含端口号。
  • .server_port,请求的主机端口号。
  • .path,请求URL的路径。
  • .query_string,请求的URL中包含的原始Query参数字符串。
  • .uri_template,匹配到请求URL的路由路径。
  • .token,请求头中的Authentication值。

输出响应

与请求相对应的响应方法都在sanic.response包中定义,用于处理函数的返回值。Sanic将不同的响应内容都定义成了函数,根据调用函数的不同,将会返回HTTPResponse类实例或者StreamingHTTPResponse类实例,分别代表普通HTTP响应和流式HTTP响应。

基本上所有的响应函数都可以接受以下两个参数:

  • status,HTTP响应码,如果没有特殊说明则默认值为200。
  • headers,额外的响应头内容,默认值为None,可以使用字典类型值来设置。

常用的响应函数主要有以下这些:

响应函数响应内容可用参数(按参数表顺序)
.file()输出一个文件
  • location,响应文件的路径。
  • status,HTTP响应码。
  • mime_type,内容类型。
  • headers,额外响应头。
  • filename,输出文件名。
  • _range,输出文件内容的范围。
.file_stream()流式输出一个文件
  • location,响应文件的路径。
  • status,HTTP响应码。
  • chunk_size,分块大小,默认为4096。
  • mime_type,内容类型。
  • headers,额外响应头。
  • filename,输出文件名。
  • trunked,是否分块输出,默认为True
  • _range,输出文件内容的范围。
    .html()以HTML格式输出
    • body,要输出的内容。
    • status,HTTP响应码。
    • headers,额外响应头。
    .json()以JSON格式输出
    • body,要输出的内容,字典或对象格式。
    • status,HTTP响应码。
    • content_type,内容类型,默认为application/json
    • headers,额外响应头。
    • dumps,JSON转换器设置。
    • **kwargs,提供给JSON转换器的额外参数。
    .raw()不进行任何转换的输出
    • body,要输出的内容。
    • status,HTTP响应码。
    • headers,额外响应头。
    • content_type,内容类型,默认为application/octet-stream
    .redirect()输出一个重定向
    • to,重定向到的位置。
    • headers,额外响应头。
    • status,HTTP响应码,默认为302。
    • content_type,内容类型,默认为text/html
    • charset,编码格式,默认为utf-8
    .stream()使用流式输出指定内容
    • stream_fn,一个接受Response实例并且向其中流式写入的函数。
    • status,HTTP响应码。
    • headers,额外响应头。
    • content_type,内容类型,默认为text/plain
    • charset,编码格式,默认为utf-8
    • chunked,是否分块输出,默认为True
    .text()以纯文本格式输出内容
    • body,要输出的内容。
    • status,HTTP响应码。
    • headers,额外响应头。
    • content_type,内容类型,默认为text/plain
    • charset,编码格式,默认为utf-8

    异常控制

    任何HTTP请求都不可能保证完全正确没有错误,服务端可以推送错误到客户端,但有时又需要隐藏掉服务端的错误。Sanic提供的异常控制功能主要包括从处理函数中抛出一个异常和服务出现异常时的捕获。Sanic中的异常功能都在sanic.exceptions包中提供。

    要从服务端抛出一个异常,只需要在处理函数中发起一个sanic.exceptions包中的异常即可。sanic.exceptions包中可用的异常主要有以下这些。

    • ContentRangeError(message, content_rage),HTTP 416错误。
    • FileNotFound(message, path, relative_url),HTTP 404错误,但指示找不到指定文件。
    • Forbidden(message),HTTP 403错误。
    • HeaderExpectationFailed(message),HTTP 417错误。
    • HeaderNotFound(message),HTTP头未找到错误。
    • InvalidRangeType(message, content_range),无效范围错误。
    • InvalidUsage(message),HTTP 400错误。
    • MethodNotSupported(message, method, allowed_methods),HTTP 405错误,请求方法不被支持。
    • NotFound(message),HTTP 404错误。
    • PayloadTooLarge(message),HTTP 413错误。
    • RequestTimeout(message),HTTP 408错误。
    • ServerError(message),HTTP 500错误。
    • ServiceUnavailable(message),HTTP 503错误。
    • URLBuildError(message),URL构造错误。
    • Unauthorized(message, status_code, scheme, **kwargs),HTTP 401错误,kwargs参数用于填充WWW-Authentication头。

    以上列表中大部分异常类还可以接受一个status_code参数用来改变其HTTP错误码,但这种行为可能会造成HTTP语义被破坏,故不提倡使用。

    此外还可以用sanic.exceptions包中提供的函数abort(status_code, message)快速抛出一个自定义的HTTP错误。

    以下是两种抛出异常的示例。

    @app.route("/some-error")
    async def ready_to_error(request):
    	raise ServerError("Not good")
    
    @app.route("/not-pass")
    async def not_pass(request):
    	abort(401)
    

    当服务出现异常时,如果不需要将其推向客户端,则可以使用异常捕获来进行处理。Sanic类提供了修饰器.exception()来捕获指定类型的异常,或者使用.error_handler属性来添加异常处理函数。

    以下给出两种异常捕获的方法的示例。

    # 修饰器接受一个 sanic.exceptions 包中的类作为参数
    @app.exception(NotFound)
    async def process_404(request, exception):
    	return text("Page not found")
    
    
    async def handle_server_exception(request, exception):
    	return text("Something bad happens")
    
    app.error_handler.add(ServerError, handle_server_exception)
    

    控制Cookies

    Cookies是服务端在客户端临时保存信息的主要手段,虽然客户端可以随时进行清除,但是在其中保存一些需要连续处理的业务鉴别信息也是非常常见的选择。Cookies可以通过处理函数中Request类型参数中的.cookies属性来访问,.cookies属性中以字典类型保存了从客户端传来的全部Cookies。

    要向客户端写入Cookies需要提前创建HTTPResponse类型对象,并通过其中的.cookies属性来设置。HTTPResponse实例中的.cookies属性是一个字典类型,要新建一个Cookies项,只需要对一个新的键赋值即可。如果要删除一个已有的Cookies项,只需要用del关键字删除即可,例如:del response.cookies["sessionid"]

    HTTPResponse实例中的每一个Cookies项都是一个字典类型的值,可以通过以下预定义的键值来控制Cookies项的属性。

    • expires,日期类型,用于设置Cookies的过期时间。
    • path,设置Cookies生效的URL。
    • comment,Cookies的备注。
    • domain,设置Cookies生效的域名。
    • max-age,设置Cookies有效的最长秒数。
    • secure,当设置为True时,Cookies将只能在HTTPS下生效。
    • httponly,设置Javascript是否能读取这个Cookies。

    在一个处理函数中设置客户端的Cookies可以参考以下示例。

    @app.route("/")
    async def home(request):
    	session_id = request.cookies["session_id"]
    	
    	response = response.text("Hello!")
    	response.cookies["test"] = "Hello there"
    	response.cookies["test"]["max-age"] = 60
    	
    	return response
    

    拦截修饰器

    由于所有的处理函数都是普通Python函数,所以要实现面向切片(AOP)风格的处理可以直接使用自定义修饰器来实现。虽然Sanic文档中未将这类修饰器命名,这里根据其常用操作方式,称其为拦截修饰器。以下借用鉴权验证功能来展示以下拦截修饰器的定义和使用。

    from functools import wraps
    from sanic.response import json
    
    
    def authorized():
    	def decorator(f):
    		@wraps(f)
    		asnyc def process_authorizing(request, *args, **kwargs):
    			# 这里进行鉴权操作
    			is_authorized = check_authorize()
    			
    			if is_authorized:
    				response = f(request, *args, **kwargs)
    				return response
    			else:
    				return json({"authorized": false}, status=403)
    		return process_authorizing
    	return decorator
    
    
    @app.route("/")
    @authorized()
    async def get_names(request):
    	return json({"names": []})
    

    使用类来处理请求

    在构建Resuful API时,针对相同URL的不同HTTP请求方法都是相互关联的,这时采用基于类进行路由处理,要比使用独立的处理函数要更容易控制和抽象。Sanic在sanic.views包中提供了基类HTTPMethodView来支持将路由绑定到类实例并自动匹配不同的HTTP请求方法的行为。

    类实例不能使用.route()修饰器直接进行路由绑定,而是需要使用.add_route()方法完成路由绑定。以下给出一个可供参考的示例。

    from sanic import Sanic
    from sanic.views import HTTPMehodView
    from sanic.response import text
    
    
    app = Sanic(__name__)
    
    class ExampleView(HTTPMethodView):
    	def get(self, request):
    		return text("The get method")
    		
    	def post(self, request):
    		return text("The post method")
    		
    	def put(self, request):
    		return text("The put method")
    		
    	def patch(self, request):
    		return text("The patch method")
    		
    	def delete(self, request):
    		return text("The delete method")
    
    
    app.add_route(ExampleView.as_view(), "/")
    

    示例中展示的是同步处理函数组成的类,如果需要异步处理函数,则只需要在需要异步的处理函数前使用async关键字修饰即可。处理函数从URL中收取参数的方式也与独立处理函数是相同的,只是由于现在路由路径是绑定到类的,所以这个类中的所有处理函数都应该能够处理相同的URL路径参数,这与Restful API的风格要求是一致的。

    前面章节中对独立处理函数使用的拦截修饰器也可以被用在类上,但是使用形式要发生一些变化。具体可参考以下示例中的两个类。

    class AllAuthorizedView(HTTPMethodView):
    	# 使用 decorators 属性来将拦截修饰器应用到所有处理方法上
    	decorators = [authorized]
    	
    	def get(self, request):
    		pass
    	
    	def post(self, request):
    		pass
    
    
    class PartAuthorizedView(HTTPMethodView):
    	
    	def get(self, request):
    		pass
    	
    	# 直接在需要的处理方法上使用拦截修饰器,但这个处理方法应该是静态方法
    	@staticmethod
    	@authorized()
    	def post(self, request):
    		pass
    

    日志

    Sanic的日志系统是直接对Python 3的日志系统进行的包装,所以Sanic的日志系统的使用方法与Python 3自身日志系统的使用方法是一致的。要打开Sanic的请求日志输出,需要在调用Sanic类实例的.run()方法时设定access_log参数的值为True,要打开调试日志则需要设定debug参数。

    以下是一个使用Sanic日志的示例。

    from sanic import Sanic
    from sanic.log import logger
    from sanic.response import text
    
    app = sanic(__name__)
    
    @app.route("/")
    async def home(request):
    	logger.info("log something.")
    	return text("Hello world")
    
    if __name__ == "__main__":
    	app.run(access_log=True)
    

    中间件

    Sanic的中间件可以被应用在Sanic实例上,与拦截修饰器不同,中间件会在所有请求与响应中运行。多个中间件将会按照定义顺序执行。中间件也是一个普通的函数,但是是使用.middleware()修饰器修饰的。.middleware()修饰器可以接受一个字符串参数,可以取"request""response"两个值,分别代表中间件函数应用在请求流程和响应流程中。应用在请求流程中的中间件只能接受一个Request类型的参数,而应用在响应流程中的中间件可以接受一个Request类型和一个HTTPResponse类型的参数。

    用在请求流程中的中间件通常都会产生一些操作结果供后续处理使用,这些处理结果可以保存在request.ctx中传递给下一个中间件或者请求处理函数。而用在响应流程中的中间件可以直接对HTTPResponse参数做修改。但是要注意,如果在请求流程中的中间件中返回HTTPResponse实例,将会直接中断请求处理流程,跳过剩余的全部中间件和请求处理函数。

    以下是两个中间件的示例。

    # 请求流程的中间件
    @app.middleware("request")
    async def parse_token(request):
    	request.ctx.parsed_token = parse(request.token)
    
    # 响应流程的中间件
    @app.middleware("response")
    async def prevent_xss(request, response):
    	response.headers["x-xss-protection"] = "1; mode=block"
    

    生命周期事件

    生命周期事件可以允许在Web服务启动和停止前后执行一些初始化和清理任务,生命周期事件响应函数都是通过.listener()修饰器修饰的普通Python函数,其中响应函数接受两个参数,分别是Sanic类实例和事件循环实例,而修饰器可以接受一个字符串函数,用于指定响应函数所响应的事件。

    Sanic中的生命周期事件有以下四个,可以直接用在修饰器上。

    • before_server_start,在Web服务启动之前触发,通常用来初始化数据库连接等。
    • after_server_start,在Web服务启动之后触发,通常用来启动一些依附于Web服务的其他服务。
    • before_server_stop,在Web服务即将停止之前触发,通常用来关闭一些需要提前关闭的服务。
    • after_server_stop,在Web服务停止之后触发,通常用来执行一些后期的清理工作。

    这里给出一个启动和关闭数据库连接的示例。

    @app.listener("before_server_start)
    async def setup_db(app, loop):
    	app.db = await db_setup()
    
    @app.listener("after_server_stop")
    async def close_db(app, loop):
    	await app.db.close()
    

    定时任务

    Sanic类中提供了.add_task()方法来添加一个在异步事件循环启动之后运行的任务,这个任务将会在协程中运行,并不会影响Web服务。借助Sanic提供的这个功能,可以将其设计为一个定时任务来使用。.add_task()方法接受一个异步函数作为参数。具体使用可参考以下示例。

    async def scheduled_task(app):
    	# 通过 while True 建立一个长期循环
    	while True:
    		await asyncio.sleep(3600)	# 设定任务休眠时间
    		await db.query()		# 执行定时功能
    
    # 启动定时任务
    app.add_task(scheduled_task(app))
    

    WebSocket

    对于WebSocket的支持是内建到Sanic的路由系统中的,与URL路由一样都采用修饰器和绑定方法来完成绑定。修饰器.websocket()和绑定方法.add_websocket_route()的用法与URL路由是一样的,但处理函数除接受Request类型参数以外,还需要接受一个WebSocketConnection类型的参数来控制WebSocket通讯。

    以下是一个简单的WebSocket通讯的示例,可以实现回声传送。

    @app.websocket("/echo")
    async def echo_feed(request, ws):
    	data = "greeting"
    	while True:
    		print(f"Sending: {data}")
    		await ws.send(data)
    		data = await ws.recv()
    		print(f"Received: {data}")
    

    这里需要注意的是,WebSocketConnection类型参数不能保证始终存在并且是已连接状态,所以不要将其放置到处理函数以外的变量中以方便服务器推送的实现,此外提升局部变量的作用域范围还会对服务本身造成较大的GC压力。对于服务器推送的实现,可以在处理函数内部定义局域函数,并使用观察者模式来将其注册到推送广播源来实现对服务器推送信息的响应和处理。

    以下给出一个简单的示意示例。

    # 首先定义一个广播源
    class EmitSource:
    	handlers = dict()
    	
    	def register(self, key, handler):
    		self.handlers[key] = handler
    	
    	def unregister(self, key):
    		self.handlers.pop(key)
    	
    	async def publish(self, data):
    		for h in range self.handlers.values():
    			await h(data)
    
    source = EmitSource()
    
    # 在WebSocket处理函数中可以这样来用
    @app.websocket("/message")
    async def process_message(request, ws):
    	async def server_pushing(data):
    		await ws.send(data)
    	
    	# 注册服务广播处理
    	source.register(request.ctx.user, server_pushing)
    	
    	while True:
    		# 这里处理从客户端发来的信息
    	
    	# 连接即将结束,注销服务广播处理
    	source.unregister(request.ctx.user)
    

    部署站点

    运行Sanic Web应用的方法已经在前面示例中见过多次,都是使用Sanic类的.run()方法来启动的。.run()方法支持以下命名参数来对启动过程进行配置。

    • host,Sanic服务监听的IP地址,默认为127.0.0.1。
    • port,Sanic服务监听的端口,默认为8000
    • debug,是否启动调试日志输出,如果开启会降低服务性能。
    • ssl,设定用于加密SSL的SSLContext实例。
    • sock,Sanic使用的用于建立连接的Unix Socket。
    • workers,Sanic的工作进程数量,默认是1。
    • loop,设定Sanic使用的异步事件循环,如果不指定则Sanic会自行建立一个。
    • protocol,设定Sanic的通讯协议,默认是HttpProtocol
    • access_log,是否启用请求日志输出,如果开启会大幅降低服务性能。

    Sanic是一个ASGI兼容的框架,所以可以使用ASGI容器来承载,与Starlette相同,常用的ASGI容器有Daphne、uvicorn和hypercorn。例如使用uvicorn可以使用命令uvicorn app_package:app来启动。当使用ASGI方式启动Sanic时需要注意以下几点。

    1. WebSocket功能在使用Sanic自身启动时由websockets功能库来运行;但在ASGI模式下将由ASGI容器接管。
    2. 在ASGI模式下,Sanic的生命周期事件将被缩减为仅支持ASGI的启动和关闭事件,并受ASGI容器控制,推荐使用after_server_startbefore_server_stop两个事件来保证在所有环境下的兼容。
    3. Sanic的ASGI模式目前依旧是Beta版,将其用于生产模式要格外留意未来的版本变化。

    当然Sanic还可以借助成熟的WSGI容器来启动,并在其中使用异步技术支持。但需要注意的是,在WSGI容器中异步只能是在一个工作进程中使用,性能相比同步框架有提高但不及在ASGI容器中使用异步的性能。Sanic在sanic.worker包中提供了WSGI容器用的工作进程类,在与Gunicorn搭配时可以使用以下命令启动。

    gunicorn app_package:app --bind 0.0.0.0:8000 --worker-class sanic.worker.GunicornWorker
    

    Sanic框架自己已经提供了异步事件循环,但是在与其他异步库同时使用时,常常需要共享事件循环,这就要用到Sanic的共享事件循环功能。共享事件循环可以参考以下示例来编写。

    # 返回一个使用asyncio事件循环的服务
    sanic_server = app.create_server(host="0.0.0.0", port=8000, return_asyncio_server=True)
    # 获取事件循环,这个事件循环可以共享使用
    loop = asyncio.get_event_loop()
    # 将服务加入到事件循环
    task = asyncio.encure_future(server)
    # 启动事件循环
    loop.run_forever()
    

    关于异步Web框架的注意事项

    由于Python的异步是在最近几个版本中才开始收到支持,所以目前大部分异步Web框架都会面临一个问题:如何保证服务的彻底异步。不像是NodeJS那样从语言环境的根基就是异步的,Python中的大部分库都依旧是同步设计,尤其是大部分数据库驱动都是同步的。这对于异步服务的影响是比较大的。

    在没有异步数据库驱动和异步ORM支持的情况下,仅仅将数据处理过程异步化能够提升的性能是有限的。所以如果要采用异步Web框架,尤其要重视数据库系统的选择,尽可能将整个应用做到彻底异步化。

    Nginx部署Web应用

    Nginx是Igor Sysoev为俄罗斯访问量第二的rambler.ru设计开发的。Nginx功能丰富,既可以作为HTTP服务器,也可以作为反向代理服务器,邮件服务器,支持FastCGI、SSL、虚拟主机、URL重写、GZip等多种功能,并且支持众多的扩展模块。

    Nginx常用的功能是HTTP代理、反向代理、负载均衡等功能。这里主要介绍将Nginx用于负载Python开发的网站以及前后端分离的网站所需要的配置。

    配置文件结构

    Nginx的配置文件为nginx.conf,随着系统的不同,其所在位置也不同,Windows系统一般在conf目录下,而Linux下的配置文件则可能存在于多个位置,可以使用nginx -t命令来寻找。

    配置文件主要分为以下配置块:

    1. 全局块:配置影响Nginx全局的指令,一般是运行Nginx服务的用户组配置、日志存放路径、允许生成的worker进程数等。
    2. events块:配置影响Nginx服务器与用户的网络连接,主要有每进程的最大连接数、事件驱动模型选择、是否允许多链路连接等。
    3. http块:可以嵌套配置多个服务,用来配置代理、缓存、日志定义等绝大多数功能。
    4. server块:配置虚拟主机的相关参数,一个http中可以有多个server。
    5. location块:配置请求的路由以及各种页面的处理策略。

    在Nginx配置文件中,每个配置指令都必须使用分号结束指令。

    全局配置项解析

    以下提供一个示范性的Nginx配置文件来配合说明。

    # 指定运行用户,Windows下不需要此项设置
    user nobody;
    # 启动进程,通常设置成和CPU的数量相等
    worker_processes 1;
    
    # 全局错误日志及PID文件
    #error_log logs/error.log;
    #error_log logs/error.log notice;
    #error_log logs/error.log info;
    
    #pid logs/nginx.pid;
    
    # 工作模式及连接数上限
    events {
    	# epoll是多路复用IO的一种方式,用于Linux系统,Windows系统不做配置
    	use epoll;
    	
    	# 单个后台worker进程的最大并发连接数
    	worker_connections 1024;
    }
    
    # 定义功能一致的服务器群,用于反向代理设置和负载均衡设置,server_id用于为服务器群命名
    upstream server_id {
    	# 使用server来定义服务所在位置
    	server 127.0.0.1:8080;
    }
    
    http {
     ...
    }
    

    Nginx的并发总数是worker_processesworker_connections的乘积,即

    $$max\_clients=worker\_processes \times worker\_connections$$

    但是在设置了反向代理的情况下,并发总数为

    $$max\_clients=\frac{worker\_process \times worker\_connections}{4}$$

    worker_connenction的设置跟服务器的物理内存有关,因为并发受IO约束,max_clients的值必须小于系统可以打开的最大文数。系统可以打开的最大文件数与内存成正比,一般1GB内存的机器上可以打开的文件数约为10万,但需要注意的是,系统不会将所有内存都提供出来用于打开文件,系统的其他工作进程也需要消耗资源。

    HTTP块设置

    这里继续接上一节的示范性Nginx配置文件来说明。

    http {
    	# 设定mime类型,mime类型有mime.types文件定义
    	include mime.types;
    	# 设定默认mime类型
    	default_type application/octect-stream;
    	# 设定日志格式
    	log_format main '';
    	access_log logs/access.log main;
    	
    	# send_file指令指定nginx是否调用sendfile函数来输出文件,对于普通应用可以设置为on,但是对于下载等重磁盘IO负载应用,应该设置为off来平衡磁盘与网络的IO处理速度
    	sendfile on;
    	
    	# 连接超时时间
    	keepalive_timeout 60;
    	tcp_nodelay on;
    	
    	# GZip压缩设置
    	gzip on;
    	# 设定禁用GZip压缩的条件
    	gzip_disbale "MSIE [1-6].";
    	
    	# 设定请求缓冲
    	client_header_buffer_size 128k;
    	large_client_header_buffers 4 128k;
    	
    	# 虚拟主机配置
    	server {
    		...
    	}
    }
    

    虚拟主机设置

    虚拟主机是HTTP服务根据来访域名的不同转向提供不同的内容的服务形式,所有虚拟主机一般都监听同一个端口,但使用不同的域名。以下依旧继续上一节的示范性Nginx配置文件来说明。

    server {
    	# 监听端口设置
    	listen 80;
    	# 定义响应的域名
    	server_name www.nginx.cn;
    	
    	# 定义服务器默认网站根目录位置
    	root html;
    	# 设定本虚拟主机的访问日志
    	access_log logs/nginx.access.log main;
    	
    	# 默认请求配置
    	location / {
    		# 定义首页文件的名称
    		index index.html;
    	}
    	
    	# 定义错误页面
    	error_page 500 502 503 504 /50x.html
    	location = /50x.html {
    	}
    	
    	# 静态文件,由nginx自己处理
    	location ~ ^/(images|javascript|js|css|flash|media|static)/ {
    		# 设定过期时间,如果静态文件更新频繁则可以设置小一些
    		expires 30d;
    	}
    	
    	# 禁止访问指定文件
    	location ~ /.ht {
    		deny all;
    	}
    }
    

    虚拟主机中使用location来配置网站中各个内容的访问,其中就包括了反向代理、脚本执行等。具体网站的location设置要根据各个网站的不同来设计,后面将针对反向代理和前后端分离对location的设置进行说明。

    反向代理设置

    反向代理是将一个域名定向的访问,转移移交至其他服务提供位置的代理过程。反向代理不需要新增额外的模块,只需要使用proxy_pass指令即可完成。以下给出一个示例的反向代理配置。

    upstream wildfly_server {
    	server 127.0.0.1:8080;
    }
    
    server {
    	listen 80;
    	server_name www.nginx.cn;
    	
    	location / {
    		proxy_pass http://wildfly_server;
    	}
    }
    

    反向代理的location设置中还可以有更多的针对代理的设置,这里不再赘述,读者可以自行参考相关文档来根据项目需要进行设计。

    负载均衡设置

    负载均衡的配置位置实际上前面已经提到过了,就是对于upstream的配置。每个upstream块配置一套功能一致的服务器群,而负载均衡就由upstream块完成配置。对于负载均衡,主要是配置各个主机如何承担作业的策略,以下用一个示例来介绍不同的策略的设置。

    # 默认轮询负载
    upstream poll_server {
    	server 127.0.0.1:8080;
    	server 127.0.0.1:8081;
    	# 标记down的服务器为下线主机,不参与负载
    	server 127.0.0.1:8082 down;
    	# 标记backup的服务器仅在全部非备份服务器不能访问或者繁忙的时候参与负载
    	server 127.0.0.1:8083 backup;
    }
    
    # 权重负载
    upstream weight_server {
    	# weight值越大的服务器,负载越大
    	server 127.0.0.1:8080 weight=5;
    	server 127.0.0.1:8081 weight=10;
    }
    
    # 根据IP固定
    upstream ip_server {
    	# 每个访客固定访问一台服务器,可以解决session问题
    	ip_hash;
    	server 127.0.0.1:8080;
    	server 127.0.0.1:8081;
    }
    
    # 按服务器响应时间分配,优先分配时间短的服务器
    upstream fair_server {
    	server 127.0.0.1:8080;
    	server 127.0.0.1:8081;
    	fair;
    }
    

    通过uWSGI来运行Python应用

    uWSGI是一个Web服务器,它实现了WSGI协议,在Nginx中使用HttpUwsgiModule提供服务。WSGI是一种网关接口,是Web服务器与Web应用之间通讯的一种规范。uWSGI是由Python实现的Web容器,对Django、Flask等有较好的兼容性;因为本质上来说uwsgi是python的一个模块,所以uwsgi可以使用pip install uwsgi来安装。

    uWSGI一般用来负载Flask项目,因为虽然可以使用Python直接来负载Flask项目,但是始终还存在并发、异步等众多问题。所以开发好的项目使用Web容器来发布是比较正确且通用的做法。

    配置uWSGI需要在项目根目录中放置一个.ini或者是.yml格式的配置文件。例如有以下项目根文件。

    from flask import Flask
    app = Flask(__name__)
    
    @app.route("/")
    def hello():
    	return "Hello world."
    
    if __name__ == "__main__":
    	app.run(host="0.0.0.0", port=8080)
    

    这里要注意,app.run()只应该在测试时使用,而不是在发布时使用,所以需要将其写入判断中,防止uWSGI运行app.run()方法。下面以.ini配置文件为例来配置uWSGI。

    # 配置文件名称为uwsgi.ini
    [uwsgi]
    # 设定项目所在的目录
    chdir=/Home/user/webservice/site
    # 设定virtualenv的环境目录,也就是python可执行文件所在目录
    home=/Home/user/webservice/site/venv
    # 指定主模块名称
    module=hello
    # 指定代表主应用的变量名称
    callable=app
    # 是否允许主线程存在
    master=true
    # 开启进程数量
    processes=2
    # 指定项目执行的端口号,配合Nginx时只用socket,自己执行时使用http配置
    # IP地址可以省略
    http-socket=127.0.0.1:3230
    # 指定服务器退出时清理环境
    vacuum=true
    # 指定服务器后台运行时的日志记录
    daemonize=/Home/user/log/uwsgi_log.log
    pidfile=/Home/user/log/uwsgi.pid.log
    # 动态监控指定目录中的文件变化,有变化则自动重启
    touch-reload=/Home/user/webservice/site/
    

    配置文件书写好后,就可以使用uwsgi --ini /path/to/uwsgi.ini来启动uWSGI服务了。如果需要连续监控项目的变化,可以使用uwsgi --emperor /path/to/project/来启动应用。如果不使用连续监控,则可以使用以下命令来对uWSGI进行控制。

    • uwsgi uwsgi.ini --daemonize,后台启动
    • uwsgi --stop uwsgi.pid,停止服务
    • uwsgi --reload uwsgi.pid,无缝重启服务

    对于Nginx来说,需要配置反向代理,将请求转移至uWSGI来执行。

    server {
    	listen 80;
    	server_name localhost;
    	location / {
    		include uwsgi_params;
    		# uwsgi.ini中http-socket的配置
    		uwsgi_pass 127.0.0.1:3230;
    		# 项目中venv的位置
    		uwsgi_param UWSGI_PYHOME /Home/user/webservice/site/venv;
    		# 项目根目录
    		uwsgi_param UWSGI_CHDIR /Home/user/webservice/site;
    		# 启动项目的主程序
    		uwsgi_param USWSGI_SCRIPT hello:app
    	}
    }
    

    深度学习基础

    深度学习是机器学习的一个分支领域,是Python得到广泛推广的重要推手,并且也是从人思考如何解决问题向机器自行探索并解决问题的重要方法。经典的程序设计是人们输入规则(程序)和需要按照这些规则处理的数据,系统则会根据规则给出答案,而利用机器学习,人们输入的则是需要处理的数据和从这些数据中预期得到的答案,系统给出的是规则,这些规则可以用来处理新的数据。

    机器学习系统是学习出来的,而不是通过编程书写出来的。在进行机器学习时,需要以下三个元素:

    1. 输入的数据点。
    2. 预期的输出示例。
    3. 衡量算法好坏的方法。

    所以深度学习乃至机器学习的核心问题在于如何有意义的变换数据。深度学习强调从连接的层中进行学习,这些层对应于越来越有意义的信息表示。在深度学习中,这些分层基本上都是通过神经网络模型来学习得到的,神经网络通过逐层堆叠来形成分析结构。深度学习中的层就像是过滤器,信息通过每级过滤器都会进行蒸馏,连续穿过过滤器,信息的纯度会越来越高。虽然可能深度学习的机制简单,但是如果给予的训练规模足够大,就会产生神奇的效果。

    本章将对深度学习的一些基础知识进行阐述,在后面几章涉及深度学习库的介绍用都会用到。

    基本概念

    机器学习是将输入映射到目标的过程,这一过程是通过观察许多输入和目标的示例来完成的。深度学习使用的深度神经网络通过一系列简单的数据变换来实现这种输入到目标的映射,而这些数据变换都是通过观察示例学习到的。

    神经网络中每层对于输入数据所做的操作都保存在该层的权重中,也就是说每层实现的变换都由其权重来参数化。在这种意义下,学习就是为神经网络的所有层找到一组权重值,使得该网络能够将每个示例输入与其目标正确的一一对应。想要控制神经网络的输出,就必须能够衡量输出于预测值之间的距离,这是神经网络损失函数的任务。损失函数的输入是网络预测值于真实目标值,输出值是一个距离值,用于衡量神经网络在这个示例上的好坏。

    深度学习的技巧就是利用损失函数计算得到的距离值作为反馈信号来对权重值进行微调,从而降低当前示例对应的损失值。这种调节由实现了反向传播的优化器完成。

    一开始对神经网络的权重随机赋值,只是让网络实现了随机变换,输出结果与理想结果的距离也很大。但是随着网络处理的示例越来越多,权重值也就能够不断的向正确的方向逐步微调,损失值也逐渐降低,这就是训练循环。将这种循环重复足够多的次数,得到的权重值就可以使损失函数最小,这样的神经网络其输出值和目标值会尽可能的接近,也就完成了神经网络的训练。

    在之前的机器学习技术(浅层学习)中,通常会使用简单的变换,但这些变换不能得到复杂问题所需要的精确表示,所以人们必须尽全力让输入数据更适合使用一些常见的统计方法和分析方法进行处理,也必须手动为数据设计好的表示层,也称为特征工程。

    常见名词

    • 卷积核,在给定输入矩阵后,输出矩阵中的每一个元素都是输入矩阵中的一个小区域中元素的加权平均,这个权重值由一个函数定义,这个函数就称为卷积核。
    • 滤波器,即卷积核,英文为filter,滤波器针对具备特定特征的矩阵有很高的输出,对其他矩阵的输出很低,这就模仿了神经元的激活。滤波器的权重值就相当于人脑的记忆。
    • 训练,构建滤波器的组合,并设定其中各个滤波器矩阵的权重值使之能够对特定特征激活的过程。对于一个空白滤波器,通过修改其权重值可以使它能够检测特定的特征或模式,这个过程就如同工程中的反馈。
    • 步长,卷积核在输入矩阵上行进的步幅,默认是(1, 1),表示在横纵两个方向上都是逐元素前进。设定为(2, 2)则表示间隔一个元素前进。
    • 特征图,即feature map。在卷积层的输入和输出中,输入矩阵的每一个深度单元都是一个特征图,例如灰度图片有一个特征图,RGB图片有三个特征图。每一个特征图跟卷积核做卷积后就会产生下一个特征图。
    • 损失函数,也称为目标函数或者优化评分函数,用于衡量神经网络在这个示例上的好坏,是编译模型时必需的两个参数之一。
    • 权重,神经网络中每层对于输入数据的变换操作的参数,合适的权重值能够使网络将每个示例输入与其目标正确的一一对应。找到合适的权重值是神经网络学习的目的。

    神经网络的数据

    神经网络中使用的数据一般保存在Numpy数组中,也称为张量(tensor)。张量在机器学习领域非常重要,它是一个数据容器,其中所包含的数据基本上全部都是数值数据。矩阵是一个二维张量,张量是矩阵向任意维度的推广。张量的维度一般也称为

    仅包含一个数字的张量称为标量,也称为零维张量或者0D张量。由数字组成的数组称为向量或者一维张量(1D张量),一维张量只有一个轴。这里有一个概念比较容易混淆,当一个向量中拥有多个元素时,可以称其为nD向量,例如包含9个元素的向量可以称为9D向量,但是不是9D张量。nD向量只能有一个轴,沿着轴可以有n个维度;但是nD张量有n个轴,每个轴都可以有任意个维度。维度既可以表示沿着某一个轴上元素的个数,也可以表示张量中轴的个数。在表示张量中轴的个数时,最为准确的称呼为n阶张量,但是nD张量更加常用。同理,矩阵是一个2D张量,多个矩阵会构成一个3D张量,也就是一个数据组成的立方体;多个矩阵组成一个数组,就形成了4D张量;以此类推就可以理解更高阶的张量组成。

    张量一般由三个关键属性来定义:

    • :也就是轴的个数,在Python的Numpy中,也称为张量的ndim
    • 形状:一个整数元组,用于表示张量沿每个轴的维度大小。例如一个\(3\times5\)(三行五列)的矩阵,其形状为(3, 5),不同维度的张量的形状包含的元组元素个数不同。
    • 数据类型:张量中所包含的数据类型,在Python中称为dtype

    通常来说,深度学习中所有数据张量的第一个轴(0轴)都是样本轴,或称为样本维度。并且深度学习模型不会同时处理整个数据集,而是将数据拆成小份,也称为批量,对于批量张量,第一个轴(0轴)称为批量轴或者批量维度

    在现实世界中常见的数据张量主要有以下几种:

    • 向量数据:2D张量,形状为(samples, features)
    • 序列数据或者时间序列:3D张量,形状为(samples, timestamps, features)
    • 图像:4D张量,形状为(samples, height, width, channels)或者(samples, channels, height, width)
    • 视频:5D张量,形状为(samples, frames, height, width, channels)或者(samples, frames, channels, height, width)

    张量运算

    所有的计算机程序最终都可以简化为二进制运算,而深度神经网络对于数据的的所有变换也都可以简化为数值数据张量上的张量运算。常见的张量运算有以下这些。

    • 逐元素运算:逐元素运算独立的应用于张量中的每一个元素,这些运算非常适合大规模并行实现。在处理Numpy数组时,逐元素运算都是优化好的Numpy内置函数。
    • 广播:广播曾经在前面章节介绍过,逐元素运算仅支持形状相同的张量计算,如果将不同形状的张量进行计算,就会产生广播。广播会包含以下两个步骤。
      1. 形状比较小的张量会被添加广播轴,使其ndim与较大的张量相同。
      2. 形状比较小的张量会按照新轴重复,使其形状与较大的张量相同。
    • 点积:又称为张量积运算。与逐元素运算不同,它可以将输入张量的元素合并在一起。两个张量在作点积时,如果其中有一个的ndim大于1,那么点积运算将不会是对称的,即\(dot(x, y) \neq dot(y, x)\)。
    • 张量变形:张量变形是指改变张量的行和列,以得到所需要的形状,变形后的张量的元素个数与初始张量相同。例如矩阵的转置。

    对于张量来说,其元素可以被解释为在几何空间内点的坐标,因此所有的张量运算都有几何解释。神经网络完全由一系列张量运算完成,而张量运算都只是输入数据的几何变换,所以神经网络也可以被解释为高维空间中复杂的几何变换。

    梯度优化

    在神经网络的一个训练循环中,一般会发生以下步骤:

    1. 抽取训练样本\(x\)和对应的目标\(y\)组成的数据批量。
    2. 在\(x\)上运行网络,得到预测值\(y’\)。
    3. 计算网络在这批数据上的损失,用于衡量\(y’\)和\(y\)之间的距离。
    4. 更新网络的所有权重,是网络在这批数据上的损失略微下降。

    当神经网络的\(y’\)和预期目标\(y\)之间的距离很小时,神经网络就会“学会”将输入映射到正确的目标。在这四个步骤中,最大的难点在于第四步,如何确定系数的变化以及调整量。

    由于神经网络中所有的操作都是可微的,所以计算损失相对于网络系数的梯度,然后向梯度相反的方向改变系数来降低损失是目前比较简便易行的方法。

    梯度是张量运算的导数,是导数概念向多元函数导数的推广。在给定一个可微的函数时,理论上可以找到它的最小值,也就是函数的导数为0的点。但是在实际神经网络运行时,参与计算的参数已经多到了无法进行求解的地步,所以不可能能够按照理论取得导数为0的点。所以在神经网络进行学习时,通常会采用的方法是每次抽取一个小样本进行训练,将这一批数据的损失减小一些,然后再进行下一次迭代。这种方法被称为小批量随机梯度下降(SGD)。两次SGD之间损失减小的步长称为学习率。

    当学习率过小时,SGD可能会陷入局部极小点而无法找到全局极小点,这种情况下可以借助物理学中的动量的概念来将损失下降的速率计算进来,使SGD冲出局部极小点。

    由于神经网络中所有的函数都是可微的,所以可以明确计算每一层处张量计算的导数,所以可以将链式法则(\((f(g(x)))’=f’(g(x)) \times g’(x)\))应用于梯度值的计算,这种算法被称为反向传播

    数据组织

    当机器学习的模型建立之后,就需要为机器学习准备数据。在一般习惯上,会将所有数据划分为训练数据、验证数据和测试数据。如果在训练集上进行模型评估,所观察到的学习结果参数是不正确的,因为随着学习的进行,模型在训练集上的性能始终在提高,而在未知数据上的性能则会达到一个瓶颈甚至出现衰退。

    机器学习的目的是得到一个泛化的模型,这个模型需要在未知数据上也能有很好的性能。这就需要对模型的泛化能力进行衡量,也就是对机器学习模型进行评估。

    将所有数据划分为三个集合的用途分别是,在训练数据上训练模型,在验证数据上评估模型,找到最佳参数后在测试数据上测试模型。如果仅划分为两个数据集合,会将验证数据上的信息泄露于模型中,使验证数据也变成了训练数据。这是因为模型的调节过程需要模型在验证数据上的性能作为反馈信号,这个过程也是学习的一部分。当多次重复验证过程时,验证数据上的信息就会被模型学习进去。所以当训练数据和验证数据都能够得到较好的性能时,模型在未知数据上的性能未必是理想的。

    在选择训练数据时,通常要注意以下三个问题。

    1. 数据代表性。训练集和测试集的数据都应该能代表当前数据,在进行数据划分之前,通常应该先随机打乱数据。
    2. 时间。如果需要根据过去预测未来,那么在划分数据前就不应该打乱数据,否则会因为打乱数据而造成时间泄露。在这种情况下应该始终保证测试集的数据始终晚于训练集的数据。
    3. 数据冗余。如果数据中的某些数据重复出现了,那么打乱数据再划分训练集和验证集就会导致两个数据集合之间存在数据冗余,这将对模型的评估造成影响。所以一定要确保训练集和验证集之间没有交集。

    根据我们能够拥有的数据量的多少,可以有多种方法来完成数据的组织工作。即便是可用数据非常少,也还有几种高级方法可以对数据进行扩增。以下介绍几种常用的数据组织方式。

    留出验证

    留出验证是最简单的数据划分方式,仅仅是预留处一定比例的数据作为测试集,然后在剩余的数据上训练模型,用预留出的数据评估模型。但是为了避免前文提到的信息泄露问题,一般还需要一个额外验证集。

    这里给出一个简单的留出验证的示例。

    num_validations = 10000
    
    # 打乱全部数据
    np.random.shuffle(data)
    
    # 定义验证集
    validation_data = data[:num_validations]
    
    # 定义训练集
    data = data[num_validations:]
    training_data = data[:]
    
    # 进行第一轮模型训练
    model = get_model()
    model.train(training_data)
    # 训练评估
    validation_score = model.evaluate(validation_data)
    
    # 测试模型
    model = get_model()
    model.train(np.concatenate([training_data, validation_data]))
    test_score = model.evaluate(test_data)
    

    留出验证的最大问题就是,当可用数据较少时,验证集和测试集包含的样本就会更少,无法在统计学上代表数据。

    K折验证

    K折验证弥补了留出验证中的一些不足。K折验证将数据划分为大小相同的K个分区,对于每个分区\(\alpha\),在剩余的K-1个分区上训练模型,然后在分区\(\alpha\)上评估模型。模型最终的评估分数等于K个分数的平均值。对于针对不同训练集和测试集敏感的模型,K折验证很适合使用。与留出验证一样,K折验证也需要一个额外验证集。

    这里不再给出K折验证的示例程序,读者可参考上面的留出验证尝试编写K折验证的数据分区方法。

    重复K折验证

    当数据更加少,而又需要尽可能精确的评估模型的时候,可以选择带有打乱数据的重复K折验证。这种验证的具体做法是多次使用K折验证,但是每次在将数据划分为K个分区前都先将数据打乱。带有打乱数据的重复K折验证的模型最终分数是每次K折验证分数的平均值。

    需要注意的是,重复K折验证需要训练和评估\(N \times K\)个模型,其中\(N\)代表重复次数,计算成本很高。

    数据预处理

    数据预处理的目的是使原始数据更加适合于使用神经网络处理,数据预处理的方法包括向量化、标准化、处理缺失值和特征提取。

    • 向量化:神经网络的所有输入和目标都必须是浮点张量。任何数据,包括声音、图像、文字都必须首先转换为张量,这个步骤叫做数据向量化。
    • 值标准化:在完成向量化后的数据,一般需要将取值相对较大的数据或者异质数据进行标准化。向神经网络输入的数据一般应该具有取值较小(大部分值都在\(0 \sim 1\)范围之内)和同质性(所有特征的取值应该在大致相同的范围)两个特征。
    • 处理缺失值:用于神经网络的数据中可能会存在缺失值,但一般对于神经网络来说,将缺失值设置为0是安全的。只要0不是一个有意义的值,神经网络就可以忽略这个值。但是需要注意的是,如果神经网络没有学会处理缺失值,那么在应用到带有缺失值的实际数据中时,就会出现潜在的问题。这种情况下应该人为生成带缺失值的训练样本进行补充训练。
    • 特征提取:特征工程是指在数据输入模型之前,先利用算法对数据进行硬编码转换,以改善模型的效果。多数情况下,呈现给模型的数据应该便于模型进行学习。虽然针对现代深度学习,大部分的特征工程都是不需要的,但是良好的特征仍然可以让你用更少的资源和数据解决问题。

    问题分类

    根据面对不同的问题,需要让神经网络得到不同的答案。这个需求决定着激活函数、优化器、损失函数的选择。我们最常碰到的问题分类是二分类、多分类和回归问题。

    二分类问题

    二分类问题是应用最广泛的机器学习问题,二分类主要用于对数据进行二互斥值分类,对于指定问题的回答一般只是“是”或者“否”。但是在机器学习反馈的结果中,对于结果的表示,并非是绝对的两个值,而是针对可能是某一答案的概率。

    二分类问题一般在网络的最后一层使用sigmoid激活函数来输出一个\([0, 1]\)区间内的概率值,用于表示样本目标值等于1的可能性。

    二分类问题一般选择二元交叉熵作为损失函数,或者使用均方误差(MSE),但交叉熵往往是更好的选择。

    多分类问题

    二分类可以将输入的数据划分为两个互斥的类别。但是很多情况下会需要到多于两个的分类,那么就要用到多分类。多分类问题有两种情况,如果一个数据点只能被划分到一个类别,那么这就是一个单标签多分类问题;如果每个数据点可以划分到多个类别,那么这就是一个多标签多分类问题。

    多分类问题与二分类问题相比,其输出类别的数量从2个上升为了n个,输出空间的维度要大许多。所以在进行中间处理层的选择时,需要使用更大的维数,以防止出现信息瓶颈。

    多分类问题的最后一层一般会选择使用大小为N的Dense层,指定softmax激活函数,这个激活函数输出不同类别上的概率分布。对于每一个输入样本,网络都会输出一个n维向量,其中第i个索引的内容是样本属于第i个类别的概率。

    多分类问题一般选择分类交叉熵作为损失函数。在处理多分类问题标签一般也有两种方法:通过分类编码(one-hot编码)对标签进行编码,使用分类交叉熵作为损失函数;或者将标签编码为整数,使用稀疏分类交叉熵作为损失函数。

    回归问题

    除了进行分类以外,神经网络还可以对连续值进行预测,比如根据气象数据预测气温等,这种问题被称为回归问题。

    处理回归问题的神经网络一般在最后一层不指定激活函数,只使用一个线性层,这是标量回归的典型设置,因为添加激活函数会限制输出的范围。

    回归问题一般选择均方误差(MSE)作为损失函数,回归指标使用平均绝对误差(MAE),并且使用较少维度的小型网络。

    小结

    常见问题类型的最后一层激活函数和损失函数的选择可以直接参考下表。

    问题类型最后一层激活损失函数
    二分类问题sigmoidbinary_crossentropy
    多分类单标签问题softmaxcategorical_crossentropy
    多分类多标签问题signoidbinary_crosswntropy
    回归到任意值mse
    回归到\([0,1]\)区间的值signoidmsebinary_crossentropy

    过拟合和欠拟合

    机器学习的根本问题是优化和泛化之间的对立。优化是指调节模型获得最佳性能的过程,泛化则是训练好的模型在未知数据上的性能好坏。机器学习的目的在于获得更好的泛化,但是泛化又无法控制,只能通过优化来实现。

    模型在训练数据上的损失越小,在测试数据上的损失也越小,则模型是欠拟合的,依旧存在改进空间。这时的模型还没有对训练数据中的所有模式完成建模。当迭代一定次数之后,泛化不会再提高,验证指标由不变到变差,就说明模型开始进入过拟合。在过拟合的情况下,模型仅会学习和训练数据有关的模式,对验证数据和未知数据是没有意义的。

    最好的解决过拟合的方法是使用更多的训练数据。训练数据越多,泛化能力越好。如果不能获取足够多的数据,那么就需要调节模型允许存储的信息量,迫使模型集中学习最重要的模式。通过限制模型存储的信息量的方式进行降过拟合的方法,称为正则化。下面就针对常用的正则化方法进行一些介绍。

    减小网络大小

    防止过拟合的最简单方法是减小模型中可学习参数的个数(层数和每层的单元个数)。模型中可学习参数的个数通常称为模型的容量,参数多的模型拥有更大的记忆容量。

    大规模的记忆容量可以在训练样本和目标之间轻松完成字典式映射,但对于泛化能力没有任何贡献。因为深度学习模型都擅长拟合训练数据而不是泛化。

    当网络的记忆容量有限时,网络就必须开始学会对目标进行预测,这才是我们所需要的神经网络的能力。所以在设计模型时,必须在容量过大(过拟合)和容量不足(欠拟合)之间找到折中方案。寻找折中方案最佳的工作流程是在开始选择较少的层和参数,然后逐渐增加层和参数,直到层和参数的增加对验证的损失影响变得很小未知。

    添加权重正则化

    神经网络的模型也复合奥卡姆剃刀原理:简单模型比复杂模型更不容易过拟合。简单模型的参数值分布的熵更小,所以强制让模型权重只能取更小的值来限制模型复杂度,使权重值的分布更加规则也是降低过拟合的有效方法。

    这个方法一般称为权重正则化,一般通过向网络损失函数中添加与较大权重值有关的成本来实现。这个成本有以下两种方式。

    • L1正则化,添加的成本与权重系数的绝对值成正比。
    • L2正则化,添加的成本与权重系数的平方成正比。L2正则化也称为权重衰减。

    添加dropout正则化

    对神经网络的某一层使用dropout,可以在训练过程中随机将这一层的一些输出特征丢弃。dropout比率是被丢弃的特征所占的比例,通常在\(0.2 \sim 0.5\)的范围内。

    dropout的核心思想是在层的输出中引入噪声,打破不显著的偶然模式,如果没有噪声,网络会记住这些偶然。

    通用工作流程

    之前的章节介绍的都是理论性的关于机器学习的基础知识和技术要点。从本节开始将根据机器学习的通用工作流程,逐步介绍Keras在每个工作流程中的使用以及一些配置细节。

    机器学习的通用工作流程和要解决的问题已经总结在以下列表中。

    1. 定义问题,收集数据集。
      1. 首先需要确定输入哪些数据,需要预测什么内容。
      2. 其次需要确定面对什么样的问题,是二分类、多分类、标量回归、向量回归还是多分类多标签等。这有助于选择模型架构和损失函数。
    2. 选择衡量成功的目标。衡量成功的指标将指引损失函数的选择,应该直接与业务保持一致。
    3. 确定评估方法。评估方法用于确定如何衡量当前的进展。可以使用之前介绍的三种方法。
      • 留出验证集。适用于数据量较大的情况。
      • K折交叉验证。适用于留出验证的样本量较少,无法保证可靠性时使用。
      • 重复的K折验证。使用与可用数据量很少,但又需要准确评估模型时使用。
    4. 准备数据。用于输入神经网络的数据需要提前完成格式化。
      • 数据应该格式化为张量。
      • 张量的取值应该缩放为较小的值,例如\([-1, 1]\)或者\([0, 1]\)。
      • 如果不同的特征有不同的取值范围,应该先做数据标准化。
      • 对于小数据可能需要做特征工程。
    5. 开发比基准更好的模型。使用小型模型来获取统计功效。
    6. 扩大模型规模,开发过拟合的模型。
    7. 模型正则化,调整超参数。

    卷积核的确定

    卷积核尺寸及卷积层数的确定原则是小卷积核多层卷积,即加大卷积深度、缩小视野范围。卷积核就相当于神经元的眼睛,卷积核捕获到的内容就是神经元能够看到的内容,也就是神经元的视野或者叫感受野,多层卷积的结果就是神经元通过多个小视野组合成一个大视野。

    大尺寸的卷积核可以带来比较大的视野,但是由于其能够观察到的内容多,所以需要的参数数量也相对巨大。小尺寸的卷积核需要的参数较少而且计算量也很小,即便是加大深度,由于神经元进行了多层的抽象,参数也比大尺寸卷积核要少很多。所以目前卷积核尺寸的流行趋势是选择较小的卷积核并且加大卷积深度。就卷积效果来说,三层\(3 \times 3\)的卷积核的效果与一层的\(7 \times7\)卷积核是一致的,所以目前常见的卷积神经网络都是采用\(3 \times 3\)的卷积核。

    卷积核一般都是奇数尺寸,这主要是因为偶数尺寸的卷积核即便是对称的添加padding,也不能保证输入矩阵和输出矩阵的尺寸不变。所以卷积核一般都选择奇数尺寸,并且使用3作为卷积核的大小。

    卷积核的数量一般需要结合CPU和GPU的配置,按照16的倍数递增。常见的卷积神经网络会从32个卷积核开始。每一层有多少个卷积核,经过卷积后就会形成多少个特征图。一般网络越深,这个值就会越大,随着网络的加深,特征图的尺寸在缩小,每个特征图所提取的特征就越具有代表性,所以后一层卷积需要增加特征图的数量才能充分提取出前一层的特征。

    \(1 \times 1\)卷积核的意义

    一般来说,卷积核的大小必须大于1才有提升感受野的作用。但是\(1 \times 1\)的卷积核在许多神经网络,例如NIN、Googlenet中都有广泛的应用。

    \(1 \times 1\)卷积核一般认为可以用于以下目的。

    1. 实现跨通道的交互和信息整合。
    2. 进行卷积核通道数的升维和降维。
    3. 减少卷积核参数。
    4. 对于单通道特征图可以实现多个特征图的线性组合。
    5. 实现与全连接层等价的效果。

    通道数调节

    \(1 \times 1\)的卷积核由于不用考虑像素与周边像素的关系,所以可以仅用来调整通道数。对于深度为3的输入矩阵,选择两个\(1 \times 1\)的卷积核,特征图的深度就会从3变成2。如果使用四个\(1 \times 1\)的卷积核,特征图的深度就会从3变成4。

    减少参数

    将输入矩阵降维,其实也是减少参数,相当于在特征图的通道数上进行卷积,压缩特征图,二次提取特征,使新特征图的表达更优良。

    跨通道信息组合

    对于通道数的升维和降维的变化,实际上也是通道间信息的线性组合变化。在\(3 \times 3 \times 64\)的卷积核前增加一个\(1 \times 1 \times 28\)的卷积核,就变成了\(3 \times3 \times28\)的卷积核。之前的64个通道就可以跨通道线性组合成28个通道,增加了通道间的信息交互,并且不损失特征图的分辨率。

    padding参数选择

    对于padding参数的经验处理可按以下列表中给出的值来确定。

    1. 选择\(3 \times 3\)的卷积核时,padding选择1。
    2. 选择\(5 \times 5\)的卷积核时,padding选择2。
    3. 选择\(7 \times 7\)的卷积核时,padding选择3。

    Keras中对于padding参数的设置,只允许两个值:validsame。其中valid表示只进行有效的卷积,对边界数据不处理。same表示保留边界处的卷积结果,通常会使输出矩阵形状与输入矩阵形状相同。对于valid模式,输出矩阵的尺寸计算公式为\(height_{out}=width_{out}=\left[\frac{size_{in}-size_{kernel}+1}{stride}\right]\),而same模式中输出矩阵的尺寸则是\(height_{out}=width_{out}=\left[\frac{size_{in}}{stride}\right]\)。

    如果需要自定义padding,Keras提供了ZeroPadding1DZeroPadding2D的层,直接使用padding参数定义填充大小即可。

    常用池化层选择

    池化通常有两种:平均池化和最大池化。池化主要用于压缩特征图,平均池化会使用池中数据的平均值来代表池数据,而最大池化则是使用池中数据的最大值。池化相当于对特征图进行分区,然后提取每一个区的特征值来形成新的压缩过的特征图。池化也常称为下采样(downsampling)

    池化层实例化时的参数pool_size是特征图的缩小比例因数。例如MaxPooling2D((2, 2))会在两个方向上将输入缩小一半,而MaxPooling2D((3, 3))则会在横竖两个方向上将输入缩小至原来的\(\frac{1}{3}\)。

    对于一般卷积神经网络来说,常在卷积层后使用MaxPooling2D((2, 2))进行池化以压缩特征图,降低运算压力。

    乐高积木:Keras

    Keras是一个轻量级、高度模块化的高级深度学习库的包装,它支持多种后端作为其实现,包括Theano、TensorFlow、CNTK等。Keras允许快速建立深度学习网络原型,搭建和训练网络都十分的容易。通常十几行代码就可以完成一个网络的搭建和训练。所以Keras非常适用于上手学习神经网络。

    但是Keras并非没有缺点。由于Keras是一种高度封装的库,所以对于神经网络内部知识的观察、学习和高自由度的调整都会受到较大的限制,而且Keras的训练速度也是比较慢的。这些缺点并不妨碍Keras成为上手学习神经网络的优秀功能库,而且许多夺得Kaggle大赛头筹的团队都是选择Keras作为其神经网络搭建工具。

    Keras中也集成了许多预训练模型,包括VGG16、VGG19、InceptionV3、ResNet50等。Keras基于MIT许可证发布,可以在商业项目中免费使用。Keras具有以下重要特性:

    1. 支持相同的代码在CPU和GPU上无缝切换运行。
    2. 具有用户友好的API,可以快速开发深度学习的原型。
    3. 内置支持卷积神经网络和循环网络,分别用于计算机视觉和序列处理,并且支持两者的随意组合。
    4. 支持任意网络架构,能够构建任意深度学习模型。

    Keras不处理任何张量操作、求微分等低层级的运算,在使用Keras时,需要搭配选择一个后端引擎作为Keras的张量库。目前Keras有三个后端实现:Tensorflow、Theano和CNTK。一般情况下推荐使用Tensorflow作为大部分深度学习的后端平台。Tensorflow自身封装了分别针对CPU和GPU高度优化的运算库,应用十分广泛并且可以应用于生产环境。

    一个典型的Keras工作流程一般分为以下四步:

    1. 定义训练模型,输入张量和目标张量。
    2. 定义层组成的网络或者模型,将输入映射到目标。
    3. 配置学习过程,选择损失函数、优化器和需要监控的目标。
    4. 调用模型的fit方法在训练数据上进行迭代。

    模型构建

    Keras定义模型一般有两种方法,一种是使用Sequential类,一般用于层的线性堆叠;另一种是函数式API,一般用于层组成的有向无环图,来组建任意形式的架构。在定义好模型架构之后,接下来的处理步骤就是配置学习过程。学习过程的配置需要指定模型要使用的优化器和损失函数,并且指定学习过程中计划要监控的指标。学习过程配置好后,就可以将数据传入模型开始学习迭代了。Keras中的模型构建一般是通过定义模型类型和数据处理层来完成的。

    使用Sequential类构建神经网络模型是最常用的选择,它位于keras.models包中。要使用Sequential模型,只需要使用以下语句即可完成模型的实例化。

    from keras import models
    from keras import layers
    
    
    model = models.Sequential()
    # 以下是处理层的定义
    model.add(layers.Dense(32, activation="relu", input_shape=(5,))) # 这里定义了输入数据的预期形状
    model.add(layers.Dense(10, activation="softmax")) # 这里定义了输出张量处理
    

    实例化后的模型即可开始向其中加入处理层。如果使用函数式API定义模型,则是需要先定义模型要操作的层,再将层传入模型的构造函数中。以下模型的定义与上例中的定义相同。

    from keras import models
    from keras import layers
    
    
    # 定义输入张量
    input_tensor = layers.Input(shape=(5,))
    # 定义第一层处理
    x = layers.Dense(32, activation="relu")(input_tensor)
    # 定义输出张量处理
    output_tensor = layers.Dense(10, activation="softmax")(x)
    
    # 定义模型
    model = models.Model(inputs=input_tensor, outputs=output_tensor)
    

    使用函数式API定义模型,可以突破Sequential顺序模型的限制,允许建立更多种类型的模型,例如多输入多输出模型、有向无环图、共享层、Inception模型、残差网络等。并且函数式API允许将一个预训练模型作为一个层来使用,也就是说,Keras中任何层和模型都可以当做一个函数来使用,或者说任何一个Keras层都可以写为函数形式,通过连续调用函数,不断的接受一个张量作为参数,返回另一个张量,就构成了一个复杂的神经网络。

    神经网络的基本数据结构是层。层是一个数据处理模块,主要用于将一个张量转换为另一个或另几个张量。层可以理解为搭建神经网络的积木,组建深度学习模型就是将相互兼容的多个层拼接在一起,建立有用的数据变换流程。大部分层是有状态的,这个状态即是层的权重,权重是利用随机梯度下降学到的一个或者多个张量,其中包含的是神经网络学习到的知识。

    不同的张量格式和不同的数据处理类型需要用到不同的层。一般层的选择如下:

    • 简单的向量数据保存在2D张量中,处理时可采用{\bfseries 密集连接层},对应Keras中的Dense类。
    • 序列数据保存在3D张量中,处理时可采用{\bfseries 循环层},对应Keras中的LSTM类。
    • 图像数据保存在4D张量中,处理时可采用{\bfseries 二维卷积层},对应Keras中的Conv2D类。

    在选择拼接层的时候要注意层之间的兼容性,也就是输入张量和输出张量的形状,相邻的层要匹配。Keras中的层位于keras.layers包中,关于其他常用的层将在后文进行详细的介绍。

    完成模型架构的定义后,接下来的步骤就与模型的架构没有关系了。

    一套模型最好只解决一个问题,要同时解决不同的问题,可以通过构建多套模型来联合实现。不要妄想构建一个超级智能的模型来同时获取多种不同问题的答案。

    Keras模型常用方法

    Keras有两类模型:顺序模型(Sequential)和泛型模型(Model)。这两类模型都有一些共通的常用方法。

    • .summary(),打印输出模型概况。
    • .get_config(),返回包含模型配置的字典。
    • .get_weights(),返回模型的权重张量列表,类型为Numpy array。
    • .set_weights(),从Numpy array中载入权重给模型。
    • .to_json(),将模型的网络结构输出为JSON字符串,可以用keras.models.model_from_json()方法重构模型。
    • .to_yaml(),将模型的网络结构输出为YAML字符串,可以用keras.models.model_from_yaml()方法重构模型。
    • .save_weights(filepath),将模型权重保存到指定路径,文件格式为HDF5。
    • .load_weights(filepath, by_name=False),从HDF5文件中载入权重,不改变模型的结构,如果权重与模型的结构不同,可以设置by_name=True按照层名称匹配载入权重。
    • .add(),向模型中添加一个层。
    • .compile(optimizer, loss, metrics=[], sample_weight_mode=None),配置模型的学习过程。该函数只为训练服务,如果只是加载模型用于预测值,则不必进行编译。
      • optimizer,预定义的优化器名称或优化器对象。
      • loss,预定义的损失函数名或目标函数。
      • metrics,指定评估模型在训练和测试时的评估指标。
      • sample_weight_mode,指定按何种方式为样本赋权。
    • .fit(x, y, batch_size=32, nb_epoch=10, verbose=1, callbacks=[], validation_split=0.0, validation_data=None, shuffle=True, class_weight=None, sample_weight=None),将模型训练nb_epoch轮。
      • x,输入数据,一个输入的类型为Numpy array,多个输入的类型为List[Numpy array]。
      • y,标签,类型为Numpy array。
      • batch_size,进行梯度下降时每个batch包含的样本数。
      • nb_epoch,训练的轮数。
      • verbose,日志显示。
      • callbacks,在训练过程中的不同时机调用的回调函数。
      • validation_split,用来指定一定比例的数据作为验证集,取值区间为$[0,1]$。
      • validation_data,指定验证集,形式为(x, y)的元组。
      • shuffle,训练过程中随机打乱样本。
      • class_weight,将不同的类别映射成不同的权重值,只在训练中用于调整损失函数。
      • sample_weight,给样本进行加权,只在训练中用于调整损失函数。
    • .evaluate(x, y, batch_size=32, verbose=1, sample_weight=None),计算在指定输入数据上的模型误差。参数含义与.fit()方法相同。本方法返回一个测试误差的标量值或者标量列表。
    • .predict(x, batch_size=32, verbose=0),在未知输入数据上应用模型以获取预测结果。本方法的返回值为Numpy array。

    常用层

    所有的Keras层都有以下方法。

    • .get_weights(),返回层的权重,类型为Numpy array。
    • .set_weights(weights),从Numpy array中加载权重。
    • .get_config(),返回当前层的配置。

    并且可以通过以下属性和方法来访问其中的指定内容。

    • .input,输入张量。
    • .output,输出张量。
    • .input_shape,输入数据形状。
    • .output_shape,输出数据形状。
    • .get_input_at(index),获取指定节点的输入张量。
    • .get_output_at(index),获取指定节点的输出张量。
    • .get_input_shape_at(index),获取指定节点的输入数据形状。
    • .get_output_shape_at(index),获取指定节点的输出数据形状。

    模型的第一层可以使用input_shape来设定输入数据的形状,类型为nD向量,最常见的形状为(sample_size,),对于图片数据,则是(height, width, channels)

    Keras的层均位于keras.layers包中,这里拣选一些常用的层进行简要说明。由于Keras发展很快,具体层的使用方法还需要参考Keras文档。

    全连接层

    Dense是最常用的全连接层,也称为密集连接层,位于keras.layersDense采用以下构造方法建立。

    keras.layers.Dense(
    	units, 
    	activation=None, 
    	use_bias=True, 
    	kernel_initializer='global_uniform', 
    	bias_initializer='zeros', 
    	kernel_regularizer=None, 
    	bias_regularizer=None, 
    	activity_regularizer=None, 
    	kernel_constraint=None, 
    	bias_constraint=None)
    

    其中各个参数的含义如下。

    • units,输出空间维度,可以直观的理解为网络进行学习时内部所拥有的自由度,维度越多,网络越能够学到更加复杂的表示,但计算代价也变得更大。
    • activation,激活函数,若不指定,则使用线性激活。
    • use_bias,设定是否使用偏移量。
    • kernel_initializer,核心权值矩阵初始化器。
    • bias_initializer,偏置向量初始化器。
    • kernel_regularizer,核心权值矩阵正则化函数。
    • bias_regularizer,偏置向量正则化函数。
    • activity_regularizer,层输出正则化函数。
    • kernel_constraint,核心权值矩阵的约束函数。
    • bias_constraint,偏置向量的约束函数。

    Dense层主要实现\(output=activation(dot(input, kernel) + bias)\)的操作,其中\(activation\)是按逐个元素计算的激活函数。Dense层的输入形状为(batch_size, ..., input_dim),输出形状为(batch_size, ..., units)

    Dense层的“全连接”表示上一层的每一个神经元都与下一层的每一个神经元是相互连接的。加入全连接层是学习特征之间非线性组合的有效办法,例如将卷积层和池化层提取出来的特征进行特征之间的组合。

    激活层

    Activation层主要将指定激活函数用于输出,相当于在其前一个层上不指定激活函数,而在Activation层中独立指定激活函数。构造方法为:keras.layers.Activation(activation)

    高级激活层

    高级激活层是将几种常用的激活函数连同其常用调整参数一起组合形成了专用的激活层。高级激活层主要有以下这些。

    ELU

    指数线性单元,格式为keras.layers.ELU(alpha=1.0),其中参数alpha为负因子的尺度。ELU的函数定义为:

    $$ f(x)=\left\lbrace \begin{array}{lcl} \alpha \times (e^x - 1) & & x < 0 \\ x & & x \ge 0 \end{array} \right. $$

    ReLU

    对应激活函数relu,格式为keras.layers.ReLU(max_value=None, negative_slope=0.0, threshold=0.0),其中max_value为最大输出值(\(v_{max}\)),nagetive_slope为负斜率系数(\(\alpha\)),threshold为激活阈值(\(\theta\))。ReLU的函数定义为:

    $$ f(x)=\left\lbrace \begin{array}{lcl} v_{max} & & x \ge v_{max} \\ x & & \theta \le x < v_{max} \\ \alpha \times (x - \theta) & & x < \theta \end{array} \right. $$

    LeakyReLU

    带泄露的ReLU激活,当神经元未激活时,仍允许赋予一个很小的梯度值。格式为keras.layers.LeakyReLU(alpha=0.3),其中参数alpha为负斜率系数,必须取\(\geq 0\)的值。LeakyReLU的函数定义为:

    $$ f(x)=\left\lbrace \begin{array}{lcl} \alpha \times x & & x < 0 \\ x & & x \ge 0 \end{array} \right. $$

    Flatten层

    Flatten层用于将输入展平,把多维的输入一维化。经常用在从卷积层到全连接层的过渡。构造方法为:keras.layers.Flatten()

    Dropout层

    将dropout应用于输入,在训练中每次更新时,将输入单元按比率随机设置为0,防止过拟合。构造方法为:keras.layers.Dropout(rate, noise_shape=None, seed=None)。其中各个参数的含义如下。

    • rate,在\([0, 1]\)区间内设定需要丢弃的输入比例。
    • noise_shape,1D整数张量,用于控制与输入相乘的dropout掩层形状。
    • seed,随机种子。

    变形层

    将输入重新调整为特定形状。构造方法为:keras.layers.Reshape(target_shape)

    RepeatVector

    将输入重复n次。构造方法为:keras.layers.RepeatVector(n)

    Lambda层

    将任意表达式封装为层对象。构造方法为:keras.layers.Lambda(function, output_shape=None, mask=None, arguements=None)。其中各个参数的含义如下。

    • function,需要封装的函数,其第一个参数为输入张量。
    • arguements,其他可选的需要传递的参数。

    正则化层

    对基于代价函数的输入应用一个更新。构造方法为:keras.layers.ActivityRegularization(l1=0.0, l2=0.0)。其中各个参数的含义如下。

    • l1,L1正则化因子。
    • l2,L2正则化因子。

    空间Dropout层

    SpatialDropout1DSpatialDropout2DSpatialDropout3D这三种层功能都与Dropout相同,但SpatialDropout1D会丢弃整个1D的特征,SpatialDropout2D会丢弃整个2D的特征,SpatialDropout3D会丢弃整个3D的特征,均不是单个像素。如果特征图中相邻的帧为强相关,常规的Dropout无法激活正则化导致学习速率降低,就可以使用SpatialDropout系列层来代替。

    SpatialDropout1D的输入输出形状为3D张量,形状为(samples, timestamps, channels)SpatialDropout2D的输入输出形状为4D张量,形状为(samples, channels, rows, cols)SpatialDropout3D的输入输出形状为5D张量,形状为(samples, channels, dim1, dim2, dim3)

    卷积层

    Conv1D为1D卷积层,该层创建一个卷积核,以单个空间或时间维度上的层输入进行卷积,生成输出张量。构造方法为:

    keras.layers.Conv1D(
    	filters, 
    	kernel_size, 
    	strides=1, 
    	padding='valid', 
    	data_format='channels_last', 
    	dilation_rate=1, 
    	activation=None, 
    	use_bias=True, 
    	kernel_initializer='glorot_uniform', 
    	bias_initializer='zeros', 
    	kernel_regularizer=None, 
    	bias_regularizer=None, 
    	activity_regularizer=None, 
    	kernel_constraint=None, 
    	bias_constraint=None)
    

    其中各个参数的意义如下。

    • filters,输出空间的维度,卷积中滤波器的输出数量。
    • kernel_size,1D卷积窗口的长度。
    • strides,卷积的步长。
    • padding,如何填充输入,可取same(输入与输出有相同的长度)、valid(不填充)、causal(因果卷积)。
    • dilation_rate,膨胀卷积的膨胀率。

    Conv1D的输入形状为3D张量,具体为(batch_size, steps, input_dim)。输出形状也为3D张量,具体为(batch_size, new_steps, filters)

    相似的还有Conv2D,常用于对图像的空间卷积,卷积步长接受一个由两个整数组成的元组;Conv3D,常用于对立体空间的卷积,卷积步长接受一个由三个整数组成的元组。从这三个卷积层,还有几个相应的功能扩展层,如SeparableConv1DSeparableConv2D,用于优先执行深度方向空间卷积;DepthwiseConv2D用于执行深度可分离2D卷积;Conv2DTransposeConv3DTranspose用于执行反卷积。

    卷积的根本目的在于从输入中提取特征。卷积在特征抽取的时候,会使用卷积核大小的矩阵在输入上移动,形成输入形状除以步长的大小的矩阵,例如输入大小为(128, 128, 3),步长为2,输出空间维度为32,那么输出的特征矩阵大小就为(64, 64, 32)。卷积层层叠之后,可以将每一层提取的特征继续细化,形成更加详细的特征。在整个网络中,特征矩阵的深度会逐步增大,而特征矩阵每一层的尺寸则会逐步减小,这是所有卷积神经网络的模式。

    卷积层输入张量和输出张量的尺寸可以按照以下公式计算。

    $$height_{out}=\frac{(height_{in}-height_{kernel}+2 \times padding)}{stride}+1$$

    $$width_{out}=\frac{(width_{in}-width_{kernel}+2 \times padding)}{stride}+1$$

    综合起来就是:

    $$Q=\left[\frac{n+2p-f}{s}+1\right]$$

    其中:

    • \(Q\)为卷积运算后张量的大小。
    • \(n\)为输入张量的尺寸。
    • \(f\)为卷积核的大小。
    • \(p\)为所要填充的像素值。
    • \(s\)为卷积步长。
    • 计算结束后要向下取整。

    裁剪层

    Cropping1D为1D裁剪层,会沿着时间维度(第一轴)进行裁剪。接受一个长度为2的整数元组,用于指明裁剪的开始位置和结束位置。

    相应的还有Cropping2DCropping3D,分别沿着空间维度和时空间维度裁剪。

    池化层

    MaxPooling1D用于对输入时序数据的最大池化。构造方法为:

    keras.layers.MaxPooling1D(
    	pool_size=2, 
    	strides=None, 
    	padding='valid', 
    	data_format='channels_last')
    

    其中pool_size参数为最大池化的窗口大小。

    相似的还有MaxPooling2DMaxPooling3D,分别用于对空间和时空间数据的最大池化。

    除最大池化以外,还有平均池化、全局最大池化层,分别是AveragePoolingGlobalMaxPooling

    池化的主要功能是逐步减少输入表征的空间尺寸,可以使输入表征更小,更容易操作,并且减少网络中的参数和计算数量抑制过拟合,可帮助神经网络获得不因尺寸而改变的等效图片表征(可以探测图片中的物体,而不论物体在什么位置)。卷积层层与池化层一起能够实现特征提取,之后可以交由全连接层进行分类。

    局部连接层

    LocallyConnected1DConv1D层的工作方式相同,但权值不共享,会在输入的每个不同部分应用不同的一组过滤器。构建方法参数可以参考Conv1D层。

    相似的局部连接层还有LocallyConnected2D

    循环层

    当数据中因果关系和顺序都十分重要时,就可以使用循环层来处理。循环层的使用会增加计算负荷。

    循环层的内容较多也比较复杂,这里拣选几个常用有代表性的层来说明。

    SimpleRNN

    全连接的RNN(循环神经网络),所有输出都会被反馈到输入。构造方法为:

    keras.layers.SimpleRNN(
    	units, 
    	activation='tanh', 
    	use_bias=True, 
    	kernel_initializer='glorot_uniform', 
    	recurrent_initializer='orthogonal', 
    	bias_initializer='zeros', 
    	kernel_regularizer=None, 
    	recurrent_regularizer=None, 
    	bias_regularizer=None, 
    	activity_regularizer=None, 
    	kernel_constraint=None, 
    	bias_constraint=None, 
    	dropout=0.0, 
    	recurrent_dropout=0.0, 
    	return_sequence=False, 
    	return_state=False, 
    	go_backwards=False, 
    	stateful=False, 
    	unroll=False)
    

    其中部分参数的含义如下。

    • units,输出空间的维度。
    • activation,要使用的激活函数,默认双曲正切(\(tan\ h\))。
    • recurrent_constraint,循环核心权值矩阵的约束函数。
    • dropout,单元的丢弃比例,用于输入的线性转换,取值范围为\([0, 1]\)。
    • recurrent_dropout,单元的丢弃比例,用于循环层的线性转换,取值范围为\([0, 1]\)。
    • return_sequence,指定返回序列中的最后一个输出还是全部序列。
    • return_state,指定除了输出以外时候还需要返回最后一个状态。
    • go_backgwards,设定为True,则向后处理输入序列并返回相反的序列。
    • stateful,设定为True,则批次中索引i处的每个样本的最后一个状态将用作下一批次中索引i样本的初始状态。
    • unroll,设定为True,则网络将展开,否则使用符号循环。展开可以加速RNN,但占用更多内存。

    GRU

    门限循环单元网络,构造参数基本与SimpleRNN相同。

    LSTM

    长短期记忆网络层,构造参数与SimpleRNN相同。

    ConvLSTM2D

    卷积LSTM,用途类似于LSTM,但输入变换和循环变换都是卷积的。

    损失函数

    损失函数,也称目标函数或者优化评分函数,是编译模型时所必需的两个参数之一。在编译时,可以给loss参数传递一个字符串形式的损失函数名或者TensorFlow/Theano符号函数。

    常用的损失函数有以下这些:

    • mean_squared_errormse,均方误差,\(L=\frac{1}{n}\sum_{i=1}^{n}(y_{true}^{(i)}-y_{pred}^{(i)})^2\)。
    • mean_absolute_error,绝对值误差,\(L=\frac{1}{n}\sum_{i=1}^{n}|y_{true}^{(i)}-y_{pred}^{(i)}|\)。
    • mean_absolute_percentage_error,\(L=\frac{1}{n}\sum_{i=1}^{n}|\frac{y_{true}^{(i)}-y_{pred}^{(i)}}{y_{true}^{(i)}}| \cdot 100\)。
    • mean_squared_logarithmic_error,\(L=\frac{1}{n}\sum_{i=1}^{n}(log(y_{true}^{(i)}+1)-log(y_{pred}^{(i)}+1))^2\)。
    • squared_hinge,\(L=\frac{1}{n}\sum_{i=1}^{n}(max(0, 1-y_{true}^{(i)} \cdot y_{pred}^{(i)}))^2\)。
    • hinge,\(L=\frac{1}{n}\sum_{i=1}^{n}max(0, 1-y_{true}^{(i)} \cdot y_{pred}^{(i)})\)。
    • categorical_hinge
    • logcosh
    • categorical_crossentropy,分类交叉熵,目标值需要使用分类格式,如果有n个类,那么每个样本的目标值应该是一个n维的向量,所属类的目标值应该是1,不属于该类的目标值为0。从整型目标值转换为分类目标值可以使用Keras的工具函数to_categorical
    • binary_crossentropy,对数损失(二元交叉熵),主要用于二选一的分类。
    • kullback_leibler_divergence,从预测值概率分布到真值概率分布的信息增益,用来度量两个分布的差异。
    • poisson,\(L=\frac{1}{n}\sum_{i=1}^{n}(y_{pred}^{(i)}-y_{true}^{(i)} \cdot log(y_{pred}^{(i)}))\)。
    • cosine_proximity,\(L=-\frac{\sum_{i=1}^{n}y_{true}^{(i)} \cdot y_{pred}^{(i)}}{\sqrt{\sum_{i=1}^{n}(y_{true}^{(i)})^2} \cdot \sqrt{\sum_{i=1}^{n}(y_{pred}^{(i)})^2}}\)。

    激活函数

    Keras中预定义的激活函数主要有以下这些。在使用时可以直接以字符串的形式赋予激活函数参数来使用。

    • softmax,在多分类中常用,也可用于二分类问题,基于逻辑回归。
    • elu,指数线性单元。
    • selu,可伸缩的指数线性单元,等同于\(scale \cdot elu(x, \alpha)\)。
    • softplus,\(softplus(x)=log(1+e^x)\),近似生物神经激活函数。
    • softsign
    • relu,整流线性单元,近似生物神经激活函数,默认返回逐元素的\(max(x, 0)\)。
    • tanh,双曲正切,常用激活函数。
    • sigmoid,S型曲线激活函数,常用于二分类问题,常用激活函数。
    • hard_signoid,S型曲线激活函数,速度比Sigmoid快。
    • exponential,自然指数激活函数。
    • linear,线性激活函数。

    优化器

    优化器是Keras模型编译时的另外一个必需参数。Keras模型编译时可以接受一个优化器对象,也可以接受一个字符串表示的使用默认参数的优化器。优化器都位于keras.optimizers包中。

    常用的优化器及其构造方法有以下这些。

    • SGD(lr=0.01, momentum=0.0, decay=0.0, nesterov=False),随机梯度下降优化器。
      • lr,学习率。
      • momentum,动量,用于加速啊SGD在相关方向上的前进,并抑制震荡。
      • decay,学习率衰减值。
      • nesterov,指定是否使用Nesterov动量。
    • RMSprop(lr=0.001, rho=0.9, epsilon=None, decay=0.0),RMSprop优化器,一般建议使用默认参数,常用于训练循环神经网络(RNN)。RMSprop与Adadelta的公式表达是一致的,但RMSprop计算历史所有梯度的衰减平方和,没有时间窗口的概念。
      • rho,梯度平方的移动均值衰减率。
      • epsilon,模糊因子。
    • Adagrad(lr=0.01, epsilon=None, decay=0.0),一种具有特殊参数学习率的优化器,可根据参数在训练期间的更新频率自适应调整,一般建议使用默认参数。Adagrad可以对低频的参数做较大更新,对高频的参数做较小的更新,对于稀疏数据表现更好。
    • Adadelta(lr=1.0, rho=0.95, epsilon=None, decay=0.0),基于Adagrad的扩展优化器,不对历史梯度进行累积,而是根据渐变更新的移动窗口自动调整学习速率,一般建议使用默认参数。可以解决Adagrad学习率急速下降的问题。
    • Adam(lr=0.1, beta_1=0.9, beta_2=0.999, epsilon=None, decay=0.0, asmgrad=False),自适应学习方法,将存储类似于Adadelta和RMSprop的历史梯度平方的衰减平均速度以外,还存储历史梯度的衰减平均速度。

    在针对稀疏输入数据时,建议采用自适应学习率的优化方法。自适应学习算法中,Adadelta、RMSprop和Adam的表现结果类似,但Adam的效果略优。

    评价函数

    评价函数主要用于在模型编译时指定给编译方法的metrics参数用于评估当前训练模型的性能。一般只需要将评价函数的名称以字符串形式传入即可。

    常用的评价函数主要有以下这些。

    • binary_accuracy,对于二分类问题,计算在所有预测值上的平均正确率。
    • categorical_accuracy,对于多分类问题,计算在所有预测值上的平均正确率。
    • sparse_categorical_accuracy,于categorical_accuracy相同,但对于稀疏目标预测值时有用。
    • top_k_categorical_accuracy,计算top-k正确率,当预测值的前k个值中存在目标类别即认为预测正确。
    • sparse_top_k_categorical_accuracy,与top_k_categorical_accuracy作用相同,但适用于稀疏输入情况。

    预处理与工具

    Keras提供了众多的预处理方法,用于生成和转换数据。这里针对几个常用的方法进行简要的介绍。

    TimeseriesGenerator

    用于生成批量时序数据。TimeseriesGenerator可以以一系列由相等间隔以及一些时间序列参数汇集的数据点作为输入,以生成用于训练和验证的批次数据。常用于序列数据的预处理。

    构造方法为:

    keras.preprocessing.sequence.TimeseriesGenerator(
    	data,
    	target,
    	length,
    	sampling_rate=1, 
    	stride=1,
    	start_index=0,
    	end_index=None,
    	shuffle=False, 
    	reverse=False,
    	batch_size=128)
    

    其中相关参数的含义如下。

    • data,可索引的生成器,如列表或者Numpy array,需要包含连续的数据点,且应该为2D数据,0轴为时间维度。
    • targets,对应data的数据点的目标值,长度应与data相同。
    • length,输出序列的长度。
    • sampling_rate,序列内连续各个时间步的周期。
    • stride,连续输出序列之间的周期。
    • start_index,拆分数据的起始点。
    • end_index,拆分数据的结束点。
    • shuffle,指定是否打乱输出样本。
    • reverse,设定是否倒序输出样本。
    • batch_size,设定每个批次中的时间序列样本数。

    Tokenizer

    用于向量化文本语料库。能够使用两种方法对语料库进行向量化:将每个文本转化为一个整数序列或者将其转化为一个向量。常用于文本数据的预处理。

    构造方法为:

    keras.preprocessing.text.Tokenizer(
    	num_words=None,
    	filters='!"#$%&()*+,-./:;<=>?@[\]^_`{|}` ',
    	lower=True,
    	split=' ',
    	char_level=False,
    	oov_token=None,
    	document_count=0)
    

    其中相关参数的含义如下:

    • num_words,需要保留的最大词数,基于词频。
    • filters,用于过滤的字符串,其中的内容将从文本中去除。
    • lower,设定是否将文本转换为小写。
    • split,设定如何切割文本。
    • char_level,设定是否将字符视为标记。
    • oov_token,用于在text_to_sequence过程中替换词汇表以外的单词。

    one-hot

    将文本编码为大小为n的单词索引表。

    构造方法为:

    keras.preprocessing.text.one_hot(
    	text,
    	n,
    	filters='!"#$%&()*+,-./:;<=>?@[\]^_`{|}` ',
    	lower=True,
    	split=' ')
    

    其中相关参数的含义如下:

    • text,输入的文本。
    • n,词汇表的尺寸。

    ImageDataGenerator

    通过实时增强生成张量图像的数据批次。常用来利用可信图像来进行随机变换增加可用样本数量。

    构造方法为:

    keras.preprocessing.image.ImageDataGenerator(
    	featurewise_center=False,
    	samplewise_center=False,
    	featurewise_std_normalization=False,
    	samplewise_std_normalization=False,
    	zca_whitening=False,
    	zca_epsilon=1e-06,
    	rotation_range=0,
    	width_shift_range=0.0,
    	height_shift_range=0.0,
    	brightness_range=None,
    	shear_range=0.0,
    	zoom_range=0.0,
    	channel_shift_range=0.0,
    	fill_mode='nearest',
    	cval=0.0,
    	horizontal_flip=False,
    	vertical_flip=False,
    	rescale=None,
    	preprocessing_function=None,
    	data_format=None,
    	validation_split=0.0,
    	dtype=None)
    

    其中相关参数的含义如下:

    • featurewise_center,将输入数据的均值设置为0,逐特征进行。
    • samplewise_center,将每个样本的均值设置为0。
    • featurewise_std_normalization,将输入除以数据标准差,逐特征进行。
    • samplewise_std_normalization,将每个输入除以标准差。
    • zca_epsilon,ZCA白化的epsilon值。
    • zca_whitening,设定是否启用ZCA白化。
    • rotation_range,随机旋转的度数范围。
    • width_shift_range,宽度改变范围。
      • 值为浮点型,如果\(<1.0\),则是除以总宽度的值,如果\(\ge 1.0\),则为像素值。
      • 值为1D数组,从数组中随机选择元素。
      • 值为整型,来自\([-n, +n]\)之间的整数。
    • height_shift_range,高度改变范围,取值规则与width_shift_range相同。
    • shear_range,剪切强度。
    • zoom_range,随机缩放范围。
    • channel_shift_range,随机通道转换范围。
    • fill_mode,边界以外点的填充模式。
    • cval,用于边界之外的点的值。
    • horizontal_flip,是否随机水平翻转。
    • vertical_flip,是否随机垂直翻转。
    • rescale,重缩放因子。
    • preprocessing_function,预处理方法。
    • data_format,图像数据格式,取值channels_first或者channels_last
    • validation_split,保留用于验证的图像的比例。
    • dtype,生成数组使用的数据类型。

    to_categorical

    将类向量转换为二进制类矩阵。

    normalize

    标准化一个Numpy array。

    get_file

    从一个URL下载文件,保存在缓存中。

    打印输出模型概况。

    plot_model

    将Keras模型转换为dot格式并保存到文件中。

    绘制训练统计图

    对于神经网络的训练效果,一般是通过训练的四项指标来观察的。Keras模型的fit()方法会返回一个训练历史,其中的history属性保存了训练全过程中的指标记录,其中包含四个键值val_acc(验证精度)、acc(训练精度)、val_loss(验证损失)和loss(训练损失)。

    对于这几项指标的观察,习惯上通过Matplotlib来进行图表绘制来进行观察和调整。以下给出一套指标的图表绘制示例,其他指标的图表绘制可根据这个示例进行修改。

    import matplotlib.pyplot as plt
    
    
    history_dict = training_history.history
    loss_values = history_dict["loss"]
    validation_loss_values = history_dict["val_loss"]
    
    epochs = range(1, len(loss_value) + 1)
    
    # 使用蓝色圆点表示训练损失
    plt.plot(epochs, loss_values, 'bo', label="训练损失")
    # 使用蓝色线条表示验证损失
    plt.plot(epochs, validation_loss_values, 'b', label="验证损失")
    plt.title("训练和验证损失")
    plt.xlabel("Epochs")
    plt.ylabel("Loss")
    plt.legend()
    
    plt.show()
    

    通过对训练损失和训练精度、验证损失和验证精度的观察,可以推断出模型在何时出现过拟合状态,并可以观察加入抑制过拟合后模型的训练情况。

    燎原的火种:PyTorch

    Torch是一个用于机器学习和科学计算的模块化库,最初是NYU的研究人员为了学术研究而开发的。Torch利用LuaJIT编译器提高性能,并且可以通过Nivida CUDA扩展使用GPU加速。PyTorch有两种变体,一种是使用基于Torch框架的Python包装,一种是利用Python全新实现的Torch。这里所说的PyTorch就是第二种实现,PyTorch不仅保留了基于GPU的硬件加速,还提供了向后兼容的特性。

    大部分使用计算图表的深度学习框架都会在运行之前生成和分析图表,但PyTorch在运行期间使用反向模式自动微分来构建图表。所以对模型的随意更改不会增加运行时间延迟和重建模型的开销。并且这种动态图表功能使PyTorch能够处理可变长度输入和输出。

    PyTorch没有使用单一的后端,根据CPU和GPU的不同功能特性使用了单独的后端,这种模式使将PyTorch部署在资源受限的系统上变得更加容易。

    PyTorch在使用时一般需要安装torchtorchvision两个包,一般可以使用pip直接进行安装。PyTorch网站根据不同的系统和系统特性会自动给出安装命令,针对自己系统的安装命令,可以直接到PyTorch查看。其中torchvision包中主要包含了流行数据集、模型结构和图片转换工具等。

    PyTorch相比TensorFlow,训练速度要更加快一些,相比Keras则要快许多,虽然Keras是采用TensorFlow为后端。PyTorch比Keras更加低级,但又提供了更加方便的模型调整能力,相比TensorFlow要更加容易学习。

    张量

    PyTorch中的张量(Tensor)与NumPy中的数组(ndarray)十分相似。但是PyTorch中的张量可以利用GPU来加速计算,所以在一定程度上,可以使用PyTorch来替代NumPy进行科学计算。在进行所有的计算之前,我们必须能够创建相应的张量,PyTorch中提供了以下常用方法来创建张量,这些方法都位于torch包下。

    • .tensor(data),直接从现有数据中创建一个张量,其中数据可以是列表、元组、NumPy数组等,PyTorch会始终复制data中的数据而不是直接引用。
    • .rand(*size, out=None),采用随机数初始化建立一个指定大小的张量,其中size参数可以是若干整型数或者列表、元组,用来定义建立的张量的形状。
    • .rand_link(input),用随机数创建一个形状与input一致的新张量。
    • .randn(*size, out=None),随机使用\( (0, 1) \)区间的值,即标准正态分布,来初始化指定形状的张量。
    • .randn_like(input),随机使用\( (0, 1) \)区间的值初始化一个形状与input相同的张量。
    • .randint(low=0, high, size, out=None),随机使用\( [low, high) \)区间的整型值初始化一个形状为size的张量。
    • .randint_like(input, low=0, high),随机使用\( [low, high) \)区间的整型值初始化一个形状与input相同的张量。
    • .randperm(n),返回一个\( [0, n-1] \)的随机整型序列。
    • .zeros(*size),返回一个形状为size的全零张量。
    • .zeros_like(input),返回一个形状与input相同的全零张量。
    • .ones(*size),返回一个形状为size的全1张量。
    • .ones_like(input),返回一个形状与input相同的全1张量。
    • .empty(*size),返回一个未初始化的形状为size的张量。
    • .empty_like(input),返回一个未初始化的形状与input相同的张量。
    • .full(size, value),返回一个形状为size、使用value填充的张量。
    • .full_like(input, value),返回一个形状与input相同、使用value填充的张量。

    其中常见的共用参数有以下这些:

    • dtype,张量内个元素的类型。
    • device,定义采用CPU张量还是GPU张量。
    • layout,定义输出张量期望的布局,一般默认与输入张量相同。
    • requires_grad,是否在返回的张量上进行自动微分操作。
    • pin_memory,返回的张量是否定位在固定的内存区间中,只在CPU张量中起效。

    除此之外,PyTorch还支持使用torch.from_numpy()来从NumPy数组中导入形成一个张量。或者可以使用tensor.numpy()将一个张量转换为NumPy数组。张量还可以像NumPy数组一样使用索引获取其中的元素,并且支持切片操作,例如t[:, 1]

    张量计算

    张量计算是神经网络训练的基础,PyTorch中提供了非常全面的用于张量计算的函数。PyTorch的张量计算函数一般都可以接受一个out参数,当指定out参数时,可以将计算结果输出到指定的张量中,如果没有指定out参数,则会返回张量计算结果。

    PyTorch中提供的张量计算函数常用的主要有以下这些。

    • 变换操作
      • torch.cat(tensors, dim=0),在指定维度上连接多个张量。
      • torch.split(tensor, chunk, dim=0),在指定维度上将张量分隔为多个张量。
      • torch.index_select(input, dim, index),在指定维度上以指定索引建立新的张量。
      • torch.masked_select(input, mask),使用指定蒙版建立新的张量。
      • torch.nonzero(input),返回非零元素的索引张量。
      • torch.reshape(input, shape),按照指定形状元组变换张量形状。
      • torch.squeeze(input, dim=None),返回丢弃全部尺寸为1的维度后的张量。
      • Tensor.view(*shape),返回相同数据但具备指定形状的新张量。
    • 逐点计算
      • torch.abs(input),\( out_i=|input_i| \)。
      • torch.acos(input),\( out_i = cos^{-1}(input_i) \)。
      • torch.add(input, value=1, other),\( out=input+value \times other \)。
      • torch.addcdiv(tensor, value=1, tensor1, tensor2),\( out_i = tensor_i + value \times \frac{tensor1_i}{tensor2_i} \)。
      • torch.addcmul(tensor, value=1, tensor1, tensor2),\( out_i = tensor_i + value \times tensor1_i \times tensor2_i \)。
      • torch.asin(input),\( out_i = sin^{-1}(input_i) \)。
      • torch.atan(input),\( out_i = tan^{-1}(input_i) \)。
      • torch.ceil(input),\( out_i = \lceil input_i \rceil = \lfloor input_i \rfloor + 1 \)。
      • torch.clamp(input, min, max),\( out_i = \left\lbrace \begin{array}{lcl} min & & input_i < min \\ input_i & & min \le input_i \le max \\ max & & max < input_i \end{array}\right. \)。
      • torch.cos(input),\( out_i = cos(input_i) \)。
      • torch.cosh(input),\( out_i = cosh(input_i) \)。
      • torch.div(input, tensor),\( out_i = \frac{input_i}{tensor_i} \)。
      • torch.digamma(input),\( \psi(x) = \frac{d}{dx}ln(\Gamma(x)) = \frac{\Gamma’(x)}{\Gamma(x)} \)。
      • torch.erf(input),\( erf(x) = \frac{2}{\sqrt{\pi}}\int_{0}^{x}e^{-t^2}dt \)。
      • torch.erfc(input),\( erfc(x) = 1 - \frac{2}{\sqrt{\pi}}\int_{0}^{x}e^{-t^2}dt \)。
      • torch.erfinv(input),\( erfinv(erf(x)) = x \)。
      • torch.exp(input),\( out_i = e^{input_i} \)。
      • torch.expm1(input),\( out_i = e^{input_i} - 1 \)。
      • torch.floor(input),\( out_i = \lfloor input_i \rfloor \)。
      • torch.fmod(input, divisor),取余。
      • torch.frac(input),\( out_i = input_i - \lfloor input_i \rfloor \)。
      • torch.lerp(start, end, weight),\( out_i = start_i + weight_i \times (end_i - start_i) \)。
      • torch.log(input),\( out_i = log_e(input_i) \)。
      • torch.log10(input),\( out_i = log_{10}(input_i) \)。
      • torch.lop1p(input),\( out_i = log_e(input_i + 1) \)。
      • torch.log2(input),\( out_i = log_2(input_i) \)。
      • torch.mul(input, tensor),\( out_i = input_i \times tensor_i \)。
      • torch.mvlgamma(input, p),\( log(\Gamma_p(a)) = C + \sum_{i=1}^{p}log(\Gamma(a - \frac{i - 1}{2})) \)。
      • torch.neg(input),\( out = -1 \times input \)。
      • torch.pow(input, exponent),\( out_i = input_i^{exponent_i} \)。
      • torch.reciprocal(input),\( out_i = \frac{1}{input_i} \)。
      • torch.round(input),四舍五入取整。
      • torch.rsqrt(input),\( out_i = \frac{1}{\sqrt{input_i}} \)。
      • torch.sigmoid(input),\( out_i = \frac{1}{1 + e^{-input_i}} \)。
      • torch.sign(input),取符号。
      • torch.sin(input),\( out_i = sin(input_i) \)。
      • torch.sinh(input),\( out_i = sinh(input_i) \)。
      • torch.sqrt(input),\( out_i = \sqrt{input_i} \)。
      • torch.tan(input),\( out_i = tan(input_i) \)。
      • torch.tanh(input),\( out_i = tanh(input_i) \)。
      • torch.trunc(input),取整数部分。
    • 归约操作
      • torch.argmax(x),返回最大值的索引。
      • torch.argmin(x),返回最小值的索引。
      • torch.cumprod(input, dim),在指定维度上计算所有元素的累乘积。
      • torch.cumsum(input, dim),在指定维度上计算所有元素的累加和。
      • torch.dist(input, other),计算范数,\( |x|_p = \left(\sum_{i=1}^{p}|x_i|^p \right)^{\frac{1}{p}} \)。
      • torch.logsumexp(input, dim),在指定维度上计算\( logsumexp(x)_i = log\sum_{j} e^{x_{ij}} \)。
      • torch.mean(input, dim),取所有元素的平均值,如指定维度则在指定维度上计算平均值。
      • torch.median(input, dim),去所有元素的中值,如。
      • torch.mode(input, dim=-1),在指定维度上计算众数值。
      • torch.norm(input, p='fro', dim=None),计算矩阵范数或者矢量范数。
      • torch.prod(input, dim=None),在指定维度上计算元素的积。
      • torch.std(input, dim=None),在指定维度上计算标准差。
      • torch.sum(input, dim=None),在指定维度上计算元素的和。
      • torch.unique(input, dim=None),返回指定维度上的不重复元素。
      • torch.var(input, dim=None),在指定维度上计算方差。
    • 比较操作
      • torch.eq(tensor1, tensor2)torch.ge(tensor1, tensor2)torch.gt(tensor1, tensor2)torch.le(tensor1, tensor2)torch.lt(tensor1, tensor2)torch.ne(tensor1, tensor2),比较两个张量,返回一个新的相同形状张量,其中1表示True,0表示False
      • torch.equal(tensor1, tensor2),比较两个张量是否相等,返回布尔值。
      • torch.isfinite(tensor1)torch.isinf(tensor1)torch.isnan(tensor1),判断张量中逐元素的特征。
      • torch.max(tensor)torch.min(tensor),从整个张量或者指定维度中选出最大值或者最小值。
    • 其他操作
      • torch.flatten(input),展平张量。
      • torch.flip(input, dims),翻转张量。
      • torch.rot90(input, k ,dims),90度旋转张量。
      • torch.roll(input, shifts, dims),滚动张量。
      • torch.tensordot(a, b, dims=2),对两个张量进行点积。

    张量的操作除了可以调用torch包下定义的函数以外,Tensor类中也定义了相应的方法,其使用方法是相同的,只是需要注意的是,部分方法以_后缀结尾,这种方法是会改变当前张量值的,在使用时需要注意。

    自动微分

    自动微分是由autograd包提供支持的。PyTorch的自动微分功能可以根据张量的变换记录自动进行微分的求解。在创建张量时可以通过设置参数requires_gradTrue来启用自动微分的支持。

    对张量进行变换后,就可以调用.backward()进行自动微分操作,导数\( \frac{d(out)}{dx} \)(梯度)就可以使用.grad属性进行获取。

    如果在操作过程中需要关闭自动微分功能,可以使用with torch.no_grad():关闭。

    神经网络构建

    PyTorch中神经网络的构建是使用torch.nn包的。torch.nn包基于自动微分进行模型的定义和差别计算。PyTorch中的网络一般是一个继承了nn.Module类的实例,其中包含了定义的层以及方法.forward(input)来根据输入获得输出。

    以下是一个神经网络的定义示例。

    import torch
    import torch.nn as nn
    import torch.nn.functional as F
    
    
    class Net(nn.Module):
    
    	def __init__(self):
    		super().__init__()
    		self.conv1 = nn.Conv2d(1, 6, 3)
    		self.conv2 = nn.Conv2d(6, 16, 3)
    		self.fc1 = nn.Linear(16 * 6 * 6, 120)
    		self.fc2 = nn.Linear(120, 84)
    		self.fc3 = nn.Linear(84, 10)
    
    	def forward(self, x):
    		x = F.max_pool2d(F.relu(self.conv1(x)), (2, 2))
    		x = F.max_pool2d(F.relu(self.conv2(x)), 2)
    		x = x.view(-1, self.num_flat_features(x))
    		x = F.relu(self.fc1(x))
    		x = F.relu(self.fc2(x))
    		x = self.fc3(x)
    		return x
    
    	def num_flat_features(self, x):
    		size = x.size()[1:]
    		num_features = 1
    		for s in size:
    			num_features *= s
    		return num_features
    
    
    net = Net()
    

    当定义了方法.forward(input)后,用于计算梯度的.backward()方法就会在使用自动微分时自动建立。

    Module类中可以包含其他的Module类,这样可以形成一个树状的神经网络结构。Module可以表示网络的一层,也可以表示某一个网络结构。在定义Module时,一般会将模型中所需的各层都保存在类变量中,之后在.forward(input)方法中使用nn.functional中的函数定义层与层之间的处理过程。PyTorch中层的实例与Keras中的层实例一样,都可以将其当做函数来调用。例如nn.Conv2d(1, 6, 3)(x)和示例中self.conv1(x)的调用方法是等价的。每一个层都需要至少一个输入,并且在进行函数式调用后都会产生一个输出。将每一层的处理实例保存为类变量的好处是可以在后期进行更加方便的深入调整,而不是只依靠构造函数中的参数。

    与Keras相似,神经网络每一层的处理顺序一般都是先进行特征提取,再调用激活函数,从激活函数获得的张量就可以传递给下一层使用了。

    损失计算与权重更新

    损失函数用于评估每次训练中特征提取计算与目标值之间的差距。在torch.nn包中提供了众多可用的损失函数选择。损失函数实例在调用时可以接受两个张量,并且会返回两个张量之间的误差张量,其中还会给出反向传播用的微分方法.backward()

    要将损失反向传播到网络中,只需要调用损失函数返回的误差张量中的.backward()即可。调用.backward()之后就需要更新网络中各层的权重。

    最简单的更新权重的方法是使用以下语句:

    learning_rate = 0.01
    for f in net.parameters():
    	f.data.sub_(f.grad.data * learning_rate)
    

    以上语句使用了SGD优化,优化公式为\( weight = weight - learning\_rate \times gradient \)。网络中所有的可学习参数都可以通过.parameters()进行访问,其中每一个可学习参数都是一个张量,可以使用优化器函数进行调整。如果直接使用torch.optim包中提供的优化器,则可以更加方便的调整网络中的全部参数。

    例如使用预定义的优化器进行权重更新,则可以如下例一般进行。

    import torch.nn as nn
    import torch.optim as optim
    
    
    optimizer = optim.SGD(net.parameters(), lr=0.01)
    
    # 在训练循环中
    # 重置优化器
    optimizer.zero_grad()
    output = net(input)
    loss = nn.MAELoss()(output, target)
    loss.backward()
    optimizer.step()
    

    模型训练与应用

    不像Keras中已经将训练完全模块化了,PyTorch中的模型训练需要手动控制,但代码并不复杂。主要步骤如下:

    1. 划分训练循环阶段。
    2. 获取本阶段数据。
    3. 重置优化器。
    4. 使用输入计算目标值。
    5. 使用损失函数进行评估。
    6. 使用损失进行反向传播。
    7. 使用优化器更新权重。

    以下给出一个示例训练代码段。

    optimizer = optim.SGD(net.parameters(), lr=0.01)
    criterion = nn.MAELoss()
    
    for epoch in range(2):
    	for data in train_loader:
    		inputs, labels = data
    		
    		optimizer.zero_grad()
    		
    		outputs = net(inputs)
    		loss = criterion(outputs, labels)
    		loss.backward()
    		optimizer.step()
    

    PyTorch中模型的使用与训练基本相似,只是需要在运行应用前使用.eval()关闭DropoutBatch Normalization等正则化层,使模型转换为评估模式。评估模式的模型调用结果即为模型的预测结果。

    模型保存与加载

    在PyTorch中state_dict可以理解为Python中的字典对象,其中每一层映射到一个参数张量,只有包含待学习参数的网络层会在模型的state_dict中存在元素值。当保存模型时,只有模型的可学习参数是有必要进行保存的。

    可学习参数可以使用torch.save(model.state_dict, PATH)进行保存,文件一般以.pt或者.pth为后缀。在加载时,需要使用语句model.load_state_dict(torch.load(PATH))进行加载,在参数加载之后,需要调用model.eval()来将模型中的dropoutbatch normalization设置为评估模式。

    这里需要注意的是model.load_state_dict()可接受的参数是Python字典类型,而不是路径字符串,所以需要先使用tourch.load()进行反序列化。

    将优化器对象中的state_dict与模型的state_dict一起保存到文件,可以建立模型断点,用于推断或者恢复训练。在恢复模型训练时,需要调用model.train()将模型中的各层都置于训练模式。

    由于torch.load()torch.save()采用Python的Pickle进行序列化和反序列化,所以可以利用字典将多个不同的模型或者优化器的数据保存到一个文件中。

    在跨CPU和GPU设备进行参数保存和加载时,只需要将torch.device()获取的设备类型传递给model.load()的参数map_location即可。当模型需要加载到GPU时,需要调用model.to()来确保将模型转换为针对目标设备优化的模型。

    与Keras的不同

    PyTorch相比Keras要更加低级一些,许多Keras中已经集成的操作,在PyTorch中是分散开需要自行组装的。而PyTorch中的层相比Keras要更多,尤其需要注意的是,在Keras中的密集连接层Dense,在PyTorch中为线性层Linear。以密集连接层为例,在Keras中只需要几个参数就可以完成的配置,在PyTorch中需要自行定义处理顺序。

    Keras中的密集连接层示例。

    x = Dense(64, activation='relu', padding='valid')(input)
    

    在PyTorch中就要较为复杂一些。

    pad = nn.ConstantPad1d(2, 0)
    lin = nn.Linear(64, 10)
    relu = nn.ReLU()
    
    x = relu(lin(pad(input)))
    

    使用Torchvision准备数据

    与Keras一样,PyTorch通过torchvision提供了许多内置数据集和预训练模型。其中内置数据集保存在torchvision.datasets包中,预训练模型保存在torchvision.models中,读者可以在需要的时候自行查阅文档集成到代码中使用。

    这里主要要介绍的是torchvision.transformstorchvision.utils两个包。先从utils包说起,这个包里只有两个函数make_grid()save_image(),分别用于将图片以表格形式排布和将图片保存到文件。

    make_grid()主要接受以下参数:

    • tensor,接受一个4D张量或者有相同尺寸的图片列表。
    • nrow,设定每行显示多少张图片。
    • padding,图片间隔。
    • normalize,正则化,将图片像素的值转换到\( (0, 1) \)区间中。
    • range,整型元组,设定正则化的区间。

    make_grid会返回一个张量,可以通过使用.numpy()将其转换为NumPy数组,交给Matpoltlib显示。相比make_grid()save_image()要多接受一个filename参数,用于将给定的张量保存到文件中。

    transforms包主要提供的是图像变换功能。与Keras的ImageDataGenerator不同,PyTorch需要给定一系列的变换定义和组合,并自行进行应用。多个变换类实例可以通过transforms.Compose()组合在一起。transforms包中提供的变换操作主要有两种,一种是应用于PIL的Image实例,一种则是应用于张量。

    以下变换类都是用于PIL Image实例的。

    • CenterCrop(size),从图片中央开始剪裁指定大小。
    • ColorJitter(brightness, contrast, satuation, hue),随机调整亮度、对比度、饱和度等。
    • FiveCrop(size),将图片四角和中央的五部分剪裁出来。
    • Grayscale(num_output_channels),将图片转换为灰度。
    • Pad(padding, fill, padding_mode),使用给定值在图片四周补白。
    • RandomAffine(degrees, translate, scale, shear, resample, fillcolor),对图片进行随机仿射变换。
    • RandomApply(transforms, p),对图片随机进行列表中的变换操作。
    • RandomChoice(transforms),对图片随机进行列表中定义的一种变换操作。
    • RandomCrop(size, padding, pad_if_need, fill, padding_mode),随机剪裁。
    • RandomGrayscale(p),按照几率随机进行灰度转换。
    • RandomHorizontalFlip(p),按照几率随机进行水平翻转。
    • RandomPerspective(distortion_scale, p, interpolation),随机进行透视变换。
    • RandomResizedCrop(size, scale, ratio, interpolation),随机进行缩放剪裁。
    • RandomRotation(degreeds, resample, expand, center),随机进行旋转。
    • RandomVerticalFlip(p),按照几率随机进行垂直翻转。
    • Resize(size, interpolation),缩放到指定尺寸。
    • Lambda(lambd),应用用户自定义变换。

    transforms中用于张量的变换类只有两个,用于线性变换和正则化,读者可参考文档来使用。除此之外更加常用的是转换类,主要可以进行以下转换。

    • ToPILImage(mode),将张量或者NumPy数组转换为PIL Image实例,其中mode用于指定颜色空间。
    • ToTensor(),将PIL Image实例或者NumPy数组转换为张量。

    以下给出一个使用PIL加载图片,进行随机仿射变换后转换为张量的示例。

    image = PIL.Image.open(image_path)
    affine_transform = transform.RandomAffine(60, 0, 1.5, 0)
    t = transform.ToTensor()(affine_transform(image))
    

    使用scikit-image来进行图片的预处理

    scikit-image是一个基于NumPy数组来进行图像处理的工具包,可以直接使用pip安装,使用import skimage导入。在包skimage下定义了众多的子包,其中常用的是iofiltersdrawtransform等。

    对图片进行读写操作主要是使用io包中的imread()imsave()等功能,这两个函数可以将图片文件读入NumPy数组,或者将NumPy数组保存为图片文件。draw主要是进行一些绘制操作。filterstransform中的函数可以对NumPy数组进行一些数学变换,对图片进行数学变换需要一些数学和图像学知识,读者在使用这些变换函数时可以直接参考scikit-image的文档。

    使用scikit-image导入的图片直接就是NumPy数组的形式,如果需要应用torchvision的变换,需要进行一次转换。所以如果打算使用torchvision的变换,尽量使用Pillow库来进行图像的加载和预处理。如果使用scikit-image库预处理图片,那么图片的变换操作应该尽量全部由scikit-image库完成,并直接将处理后的NumPy数组转换为张量交由PyTorch使用。

    机器学习工具箱:scikit-learn

    scikit-learn是一个开源机器学习库,建立在NumPy、SciPy和Matplotlib上。scikit-learn为用户提供了葛总机器学习算法的接口,可以让用户简单高效的进行数据挖掘和数据分析。

    安装scikit-learn直接使用pip就可以完成,但是最新版的scikit-learn要求运行在Python 3.5及以上版本上。

    scikit-learn主要能够完成以下六大功能:

    • 分类,识别物体所属类别。
    • 回归,根据物体的连续值特性进行预测。
    • 聚类,自动归类相似的物体。
    • 降维,减少需求的随机变量。
    • 模型选型,针对机器学习模型设计进行对比、验证和参数选择。
    • 预处理,提取特征和正则化数据。

    分类算法、回归算法、聚类算法、降维算法在scikit-learn中都是已经完全定义好的现成模型,所以在使用起来并不困难。在日常使用中一定要牢记,scikit-learn是一个内置了众多算法模型的机器学习库,相比Keras、PyTorch、TensorFlow等更加实用,实现也更加低级,使用CPU运算的效率也更高。在不需要进行复杂模型构建的数据分析用途下,采用scikit-learn将会更加有效率。

    本章将主要以使用scikit-learn进行模型超参数调整和模型评估为主进行介绍。

    方案选择

    scikit-learn中所包含的内容纷繁复杂,在进行实际问题处理时,很有可能就会在其中迷失方向。这里借鉴scikit-learn官网上的算法选择流程图来指导算法的选择。

    在每种算法中,也都有不同的详细算法实现可供选择。首先看分类算法中算法的选择指引。

    分类算法都是监督式学习,而聚类算法则是无监督式学习。聚类算法中算法选择指引如下。

    回归算法一般用于序列数据的预测,也都是监督是学习,回归算法中算法选择指引如下。

    降维算法一般用于减少随机变量,加强特征。降维算法中算法选择指引如下。

    分类、回归与聚类算法的使用

    在使用scikit-learn之前,必须要牢记,scikit-learn中已经定义好了各个算法要使用的模型,在实际使用中,只需要直接按照scikit-learn的文档实例化相应的模型即可开始使用。如果模型不需要自己训练,可以使用scikit-learn中提供的预训练模型数据来直接进行预测,但是一般还是建议使用算法模型自行训练。

    scikit-learn中的模型一般都具有两个方法:.fit().predict()或者.fit_predict(),分别用于训练和预测。这就使得scikit-learn的模型使用非常简单。以支持向量机(SVM)下的SVC算法模型为例。

    from sklearn import svm
    from sklearn import datasets
    
    
    clf = svm.SVC()
    iris = datasets.load_iris()
    x, y = iris.data, iris.target
    clf.fit(x, y)
    
    predict_y = clf.predict(unknown_x)
    

    模型训练和预测所使用的数据可以是NumPy数组或者普通的数组,但训练数据和标签数据应该具有一个相同大小的维度。

    在scikit-learn中有一个流水线(Pipeline)的概念,Pipeline可以用来整合数据处理流程,将数据从预处理到模型训练流水化,Pipeline定义在sklearn.pipeline包中,构建时接受一个元组列表,其中包括全部处理步骤,元组的格式为(名称, 处理器实例),其中处理器(transform)需要实现.fit().transform()方法。Pipeline也拥有.fit().predict()等方法,在调用时会按照顺序链式调用列表中的全部处理器。Pipeline中列表的最后一项可以是一个评估器,用于支持Pipeline调用.score()方法进行评估。

    以下给出一个建立Pipeline的示例。

    from sklearn.pipeline import Pipeline
    
    text_clf = Pipeline([
    	('vect', CountVectorizer()),
    	('tfidf', TfidfTransformer()),
    	('clf', MultinomialNB())
    ])
    text_clf.fit(train.data, train.target)
    

    交叉验证

    在前面深度学习一章中,我们曾经提到过留出验证、K折验证和重复K折验证。不论在Keras还是PyTorch中,这些验证方法一般都需要自行实现。而这些验证方法往往关系着模型参数的调优。所以在进行参数设置时需要利用大量数据进行大量的试验。在scikit-learn中,除了使用交叉验证来调整参数外,更加有效的方法是后一节要介绍的利用Grid Search的超参数调整。

    使用交叉验证往往是最简单有效的解决过拟合问题的方法。scikit-learn提供了非常便利的将样本数据进行划分的方法。

    方法train_test_split(data, target, test_size, random_state)可以将一组样本按照test_size设置的比例划分为训练组和验证组,调用后会返回由四个元素组成的元组,其中元素分别是(训练数据, 验证数据, 训练标签, 验证标签)。以下是一个划分出30%验证数据的示例。

    import numpy as np
    from sklearn.mode_selection import train_test_split
    from sklearn import datasets
    from sklearn import svm
    
    
    iris = datasets.load_iris()
    x_train, x_test, y_train, y_test = train_test_split(iris.data, iris.target, test_size=0.3, random_state=0)
    clf = svm.SVC(kernel='linear', C=1).fit(x_train, y_train)
    clf.score(x_test, y_test)
    

    在这个示例中参数C就是超参数,它用来配置模型并且需要手动设置。对于模型的调参实际上主要就是对各个超参数的调整。但是无论如何调整,在模型训练过程中,验证集数据在用过一次后就没有用了,反复使用同一组验证集数据会加重模型的过拟合。这就需要前面介绍的K折验证来解决验证集重复使用的问题。

    scikit-learn中在sklearn.model_selection包中提供了KFoldRepeatedKFoldLeaveOneOut等几个工具类,其中的.split()方法可以返回一个生成器,给出按照相应的分隔方法所分离出的样本索引分组,后续可以按照这些索引来编排样本的训练和验证组合。以下给出一个使用K-Fold进行分组的示例,其他的分组方法也都大同小异,只是参数不同而已。

    import numpy as np
    from sklearn.model_selection import KFold
    
    
    x = ['a', 'b', 'c', 'd']
    kf = KFold(n_splits=2)
    for train, test in kf.split(x):
    	print(f'{train}, {test}')
    

    超参数调整

    scikit-learn中定义了一种名为estimator(评估器)的对象,estimator主要用于对模型进行评估和解码。所有的estimator对象都必须拥有.fit()方法,并且提供.set_params().get_params()方法。基本上scikit-learn中分类、回归、聚类等算法模型都是继承自base.BaseEstimator类,所以基本上都是具有这些必备方法的。

    超参数调整的一个主要途径就是通过不同超参数的搭配来获得最高的交叉验证评分。在estimator中可以通过.get_params()方法来获取模型中的全部参数。scikit-learn根据选择参数搭配的方法提供了两种参数值搭配寻找方法:GridSearchCV类和RandomizeSearchCV类。

    GridSearchCV类采用穷举法,从给定的参数值中进行穷举搭配测试,最后从中选择出一组评分最好的搭配组合。GridSearchCV类接受一个字典列表作为参数值来源,其中字典键为参数名,值为可使用的参数值,每一个字典表示一种需要探索的超参数组合空间。以下给出一个使用GridSearchCV类进行参数搜索的示例。

    from sklearn import datasets
    from sklearn.model_selection import train_test_split, GridSearchCV
    from sklearn.metrics import classification_report
    from sklearn.svm import SVC
    
    digits = datasets.load_digits()
    n_samples = len(digits.images)
    x = digits.images.reshape((n_samples, -1))
    y = digits.target
    
    x_train, x_test, y_train, y_test = train_test_split(x, y, test_size=0.5, random_state=0)
    tuned_parameters = [
    	{'kernel': ['rbf'], 'gamma': [1e-3, 1e-4], 'C': '[1, 10, 100, 1000]},
    	{'kernel': ['linear'], 'C': [1, 10, 100, 1000]}
    ]
    scores = ['precision', 'recall']
    
    for score in scores:
    	clf = GridSearchCV(SVC(), tuned_parameters, cv=5, scoring=f'{score}_marco')
    	clf.fit(x_train, y_train)
    	print(clf.best_params_)
    	y_true, y_pred = y_test, clf.predict(x_test)
    	print(classification_report(y_true, y_pred))
    

    构造函数中的参数cv可以接受多种值,但总起来是用来定义交叉验证的分类。当给定整型值时,表示使用K折验证的折数,不传值采用默认值,K折为3。当给定一个可以抛出(train, test)结构的生成器时,将会按照生成器抛出的元素索引数组拆分样本。或者还可以给定一个拆分器(CV Splitter)实例,拆分器类可在sklearn.model_selection包中找到。

    GridSearchCV类不同的是,RandomizeSearchCV类不需要预先对可能的参数值分配空间,只需要将所有可能的参数与值列成一个字典即可,注意,不是字典列表。

    寻找得到的最佳参数值可以通过模型的.best_params_属性获得。

    模型评估

    要对模型的预测准确度进行评估,需要利用estimator中的.score()方法、交叉验证函数中的scoring参数指定的评估算法和metrics包中提供的计量方法。一般estimator中的.score()方法都是内置定义好的,一般都紧密贴合模型不需要进行修改,所以对模型的评估主要是选择交叉验证函数的scoring参数和计量算法。

    像前一节提到的GridSearchCV和交叉验证评估函数model_selection.cross_val_score()都可以接受一个名为scoring的参数用来指定模型的评估和计量方法。scoring参数可以接受一个字符串值,scikit-learn中的评估函数都有对应的字符串表示。常用的评估计量方法有以下这些。

    标识字符串对应函数
    用于分类算法
    accuracymetrics.accuracy_score
    balanced_accuracymetrics.balanced_accuracy_score
    average_precisionmetrics.average_precision_score
    brier_score_lossmetrics.brier_score_loss
    f1metrics.f1_score
    neg_log_lossmetrics.log_loss
    precisionmetrics.precision_score
    recallmetrics.recall_score
    jaccardmetrics.jaccard_score
    roc_aucmetrics.roc_auc_score
    用于聚类算法
    adjusted_mutual_info_scoremetrics.adjusted_mutual_info_score
    adjusted_rand_scoremetrics.adjusted_rand_score
    completeness_scoremetrics.completeness_score
    fowlkes_mallow_scoremetrics.fowlkes_mallow_score
    homogeneity_scoremetrics.homogeneity_score
    mutual_info_scoremetrics.mutual_info_score
    normalized_mutual_info_scoremetrics.normalized_mutual_info_score
    v_measure_scoremetrics.v_measure_score
    用于回归算法
    explained_variancemetrics.explained_variance_score
    max_errormetrics.max_error
    neg_mean_absolute_errormetrics.mean_absolute_error
    neg_mean_squared_errormetrics.mean_squared_error
    neg_mean_squared_log_errormetrics.mean_squared_log_error
    neg_median_absolute_errormetrics.median_absolute_error
    r2metrics.r2_score
    neg_mean_poisson_deviancemetrics.mean_poisson_deviance
    neg_mean_gamma_deviancemetrics.mean_gamma_deviance

    需要注意的是f1precisionrecalljaccard四个标识支持添加后缀,可用的后缀及意义如下:

    • 无后缀,应用于二分类。
    • _micro,应用于微平均,多用于多标签,会逐项计算平均。
    • _macro,应用于宏平均,直接计算各项平均,各项权重相同。
    • _weighted,应用于带权重平均。
    • _samples,应用于多标签样本。

    以下给出一个简单的模型评估示例。

    from sklearn import svm, datasets
    from sklearn.model_selection import cross_val_score
    
    iris = datasets.load_iris()
    x, y = iris.data, iris.target
    clf = svm.SVC(random_state=0)
    cross_val_score(clf, x, y, cv=5, scoring='recall_macro')
    

    scoring参数可以接受一个列表或者字典作为参数,其中可以同时指定多种评估方法。

    sklearn.metrics包中提供的评估计量方法除了可以用在scoring参数上,还可以手动调用,一般评估计量方法会接受至少两个参数,一个是对比组y_true,一个是从模型获得的结果组y_pred。例如可以像以下示例中一样使用。

    import numpy as np
    from sklearn.metrics import accuracy_score
    
    y_pred = [0, 2, 1, 3]
    y_true = [0, 1, 2, 3]
    accuracy_score(y_true, y_pred)
    

    如果将scikit-learn与其他深度学习框架结合使用,就必须寻找并使用能够将模型包装成scikit-learn中estimator的工具。

    如果要使用Keras建立模型并使用scikit-learn进行评估,就必须使用Keras中提供的keras.wrappers.scikit_learn.KerasClassifier类来包裹分类模型,使用keras.wrappers.scikit_learn.KerasRegressor类来包裹回归模型。这两个类在构建的时候使用build_fn参数接受一个能够返回模型的函数或者类。

    如果需要使用PyTorch建立模型并使用scikit-learn进行评估,需要借助skorch库。skorch库中提供了NeuralNetClassifier类来包裹分类模型,NeuralNetRegressor类来包裹回归模型,也可以直接使用这两个类的基类NeuralNet来包裹模型,但部分参数需要自行设定。skorch中的NeuralNet类在创建时需要使用参数criterion来指定损失函数,参数optimizer来指定优化器。NeuralNet类接受一个module参数,可以将模型类传入。

    标签变换

    对于分类算法和聚类算法来说,整理分类标签是一项必不可少的操作。scikit-learn中在sklearn.preprocessing包中提供了几个可以进行标签变换的类。

    LabelBinarizer可以用来对二分类和多分类单标签问题的标签进行转换,类MultiLabelBinarizer可以用来对多分类多标签问题的标签进行转换,具体使用可以参考以下示例。

    from sklearn.preprocessing import LabelBinarizer
    lb = LabelBinarizer()
    lb.fit([1, 2, 4, 6, 2, 4])
    print(lb.classes_) # 会输出 array([1, 2, 4, 6])
    labels = lb.transform([1, 6])
    print(labels) # 会输出array([1, 0, 0, 0], [0, 0, 0, 1])
    

    调用变换器实例的.fit()方法可以根据标签生成所有分类标签的集合,分类标签集合可以通过.classes_属性访问。.transform()方法可以根据集合转换标签。方法.fit_transform()可以在生成分类标签集合的同时对给定的全部标签进行转换。

    LabelEncoder可以将标签正则化,将全部标签转化为\( \left[0, n\_classes - 1\right) \)(即标签列表的索引形成的新列表)之间的数值。并且可以通过.inverse_transform()将正则化后的值转换为原标签的值。除LabelEncoder外,多分类的标签还可以使用OrdinalEncoder类和OneHotEncoder类进行转换。

    使用Visual Studio Code编辑Python项目

    Visual Studio Code(简称VSCode)是一个十分轻量级的多功能IDE,在使用Python开发套件加持后,VSCode也能非常轻松的完成项目开发工作。但是由于VSCode的自由度很大,让很多人觉得无从下手。所以这里给出配置VSCode来进行Python项目开发的详细流程。

    使用VSCode开发Python项目,强烈建议使用virtualenv功能为项目建立虚拟环境,而不是直接配置使用系统Python环境。

    开发套件安装与基本配置

    VSCode在安装以后,并不能进行Python项目的开发,需要先进行配置。

    首先要去扩展商店安装Python扩展。打开VSCode之后,进入扩展页面,在顶部的搜索栏中输入“Python”,即可搜索到由微软开发的Python扩展,安装之后即可为Visual Studio Code启用Python编辑功能。

    接下来需要对Python进行一些配置。进入到配置编辑界面。VSCode的配置是由几个JSON文件组成的,其应用顺序是:默认配置\(\rightarrow\)用户配置\(\rightarrow\)工作区配置,后加载的配置内容会覆盖先加载的内容。VSCode的配置项非常多,不必要每项都十分了解,也不需要死记硬背。VSCode提供了自动将默认配置复制到用户配置和工作区配置的功能,并且在配置时大部分内容会给出可选项。在配置过程中要尽可能利用这一功能。

    首先从默认配置复制files.exclude一项,在其中添加以下内容:**/*.pyc**/__pycache__。这是因为Python在执行时会将.py文件编译为.pyc的字节码文件,以加速脚本的执行,这些文件没有必要在编辑器的文件列表中保留。如果你还有想屏蔽的其他文件,也可以书写在这里。其中**表示任意深度的目录,*表示通配符。如果同时还会使用Jetbrains的PyCharm编辑器,那么还建议屏蔽掉**/.idea

    之后找到python.pythonPath一项,将其复制到用户配置中,把Python3的可执行文件及其完整绝对路径填进去。但是注意,这里只是提供了一个用于运行virtualenv等非项目环境下功能的配置,在项目环境中,是不依靠这项配置的。如果使用的是Python的Anaconda或者Miniconda发行版的话,这里的设置会有一些区别,首先需要确定项目所使用的环境,其次要根据项目的特点选择合适的解释器,例如命令行程序可以使用python.exe解释器,GUI程序需要使用pythonw.exe解释器。

    使用IDE的另一个需求是代码自动补全,这项功能是由Python库Jedi提供的,也可以安装辅助应用Kite来进行智能检索和提示。在一般情况下,Jedi已经足够使用。Jedi可以使用命令行pip install jedi完成安装,在VSCode的配置中配置python.jediPath一项,该项的配置内容是Jedi库所在的site-packages目录的绝对路径。Python发行版可以在安装目录的lib目录下找到,Anaconda发行版则需要到安装目录的lib/python3.x下寻找,具体目录位置是由Anaconda发行版所携带的Python版本决定的。

    Tip

    目前,微软已经为Visual Studio Code开发了新的Python支持插件:Pylance,并且已经默认启用,建议优先采用这个插件。

    其他的内容诸如编辑器文字、主题、配色等配置,读者可以参考网上的其他VSCode配置教程,这里不再赘述。

    项目环境建立与工作区配置

    工作区的配置是保存在项目目录中的,也就是说,工作区配置实际上就是VSCode针对一个项目或者一套相关项目组合的配置。VSCode中的一个项目实际上就是一个目录。所以要建立项目,我们首先建立一个目录,并使用VSCode的打开目录功能将目录加载到IDE中。

    这时先不要着急建立项目文件,我们还需要先使用virtualenv建立项目运行的虚拟环境。按下Ctrl+`快捷键,打开VSCode内置的集成命令行面板。执行以下命令来创建一个新的虚拟环境:virtualenv --no-site-packages venv,其中venv是虚拟环境的名称,可以自定义,而且这个名称在后面的配置中还要用到。

    虚拟环境创建完成后,可以在集成命令行面板中继续输入source venv/bin/activate启动虚拟环境。此时可以进行项目要使用的功能库的安装。Windows的命令行中激活虚拟环境需要输入venv/Scripts/activate

    Warning

    注意,每次打开集成命令面板的时候,VSCode会自动将目录置为当前目录,但是虚拟环境不会启动,如果需要在命令行中安装内容,还是需要手动启动虚拟环境。

    接下来就需要配置工作区了,工作区配置是保存在项目目录的.vscode目录下,名为setting.json。这个文件可以通过多种途径创建。

    首先要加入工作区配置的,就是前面提到过的python.pythonPath,由于虚拟环境提供了Python可执行文件,所以这里的配置内容要书写为:${workspaceFolder}/venv/bin/python,Windows下的配置是${workspaceFolder}/venv/Scripts/python.exe

    此外还需要配置的一项内容是python.venvFolders。这项配置是指示Python扩展以及PyLint功能对于虚拟环境的支持,这个配置内容是一个字符串数组,只需要将前面生成的虚拟环境的名称添加进去即可。

    配置至此,VSCode就可以顺利的开始Python项目的开发了,并且PyLint也会对项目中所使用的功能库进行智能检索。但是IDE所需要具备的一项重要功能还没有配置:调试。

    调试配置文件

    调试配置文件也是位于项目的.vscode目录下,名为launch.json。在项目刚建立的时候这个文件是不存在的,可以在调试界面中,先点击齿轮按钮选择项目所使用的语言,这里要选择“Python”;再使用下拉列表中的“添加配置”功能添加这个文件。对于Python,这个文件在建立的时候会自动生成常用的项目类型用的调试配置,可以直接按照项目对其中的内容进行删减。

    configurations一项中的内容列出了项目中要使用的调试配置,其中每个由一对大括号括起的内容是一个配置单元。这个配置单元里主要由配置名称、配置类型、启动指令文件、项目入口文件或者模块、启动参数等内容组成。这里给出一个示例。

    {
    	"name": "Python: Main",
    	"type": "python",
    	"request": "launch",
    	"module": "main",
    	"pythonPath": "${config:python.pythonPath}",
    	"envFile": "${workspaceFolder}/.env",
    	"console": "integratedTerminal",
    	"internalConsoleOptions": "openOnSessionStart",
    	"debugOptions": [
    		"RedirectOutput"
    	]
    }
    

    示例中使用${config:python.pythonPath}引用了之前工作区配置中的Python解释器位置,module指定了项目启动模块,也可以使用program来指定启动文件。

    此外还可以用${workspaceFolder}来指代当前工作区目录,用${config:}来引用配置文件中的值。

    调试配置文件中常用的内容项主要有以下这些,可以选择用来配置需要的调试选项。

    1. name,当前调试功能的名称;
    2. type,调试类型,根据项目语言选择,Python项目为python
    3. request,调试请求类型,可选attachlaunch,Python一般使用launch
    4. preLaunchTask,在开始执行前要执行的任务,任务在.vscode目录中的task.json中定义;
    5. postDebugTask,在调试结束之后要执行的任务;
    6. internelConsoleOptions,集成命令行选项,可以用来控制如何显示集成命令行界面;
    7. program,要执行调试的可执行文件或者代码文件;
    8. args,要传递给调试代码的命令行参数;
    9. env,调试环境变量文件;
    10. cwd,调试工作目录,用来定位相关依赖文件;
    11. port,使用attach模式时附加到进程的端口;
    12. stopOnEntry,当调试开始是是否立刻中断运行;
    13. console,决定使用哪个命令行界面,可选integratedTerminalexternelTerminalNone

    Setuptools

    Setuptools是目前跟随Python安装包自带的包管理工具,可以支持从PyPI上自动下载安装包。Setuptools是一组适用于Python 2.3.5以上的发布工具,可以让程序员更方便的创建和发布Python包。

    相比于Python内置的distutils,setuptools的优势在于其在包管理功能方面的增强。Setuptools可以使用一种更加透明的方法来寻找、下载和安装依赖包,并且可以在一个包的多个版本中自由切换。用户在使用setuptools创建的包时,不需要安装setuptools,只需要一个启动模块即可。

    Setuptools的功能主要有以下这些:

    1. 使用EasyInstall自动查找、下载、安装和升级依赖包。
    2. 创建Python Eggs。
    3. 自动包含包目录中的所有包,而不必在setup.py中列举。
    4. 自动包含包内所有和发布有关的文件,而不需要创建一个MANIFEST.in文件。
    5. 自动生成经过包装的脚本和Windows可执行文件。
    6. 支持Pyrex,可以在setup.py中列出.pyx文件,而最终用户无需安装Pyrex。
    7. 支持上传到PyPI。
    8. 可以部署开发模式,使项目出现在sys.path中。
    9. setup()扩展distutils。
    10. setup()中简单声明脚本入口,可以创建自动发现、扩展的应用和框架。

    创建一个简单的包

    Setuptools一般会随着Python的安装或者Virtualenv虚拟环境的初始化自动安装到环境中,并可以直接使用。setuptools创建一个包基本上只需要一个位于包根目录中的setup.py文件。以下是一个最简短的setup.py的示例,可以将项目打包为一个egg包供发布使用。

    from setuptools import setup, find_packages
    
    
    setup(
    	name="demo",
    	version="0.1",
    	packages=find_packages()
    )
    

    在项目根目录中执行命令python setup.py demo_egg就可以将当前项目编译为egg文件。egg包文件是一个zip压缩文件包,里面包含了项目的文件以及包的描述文件。使用命令python setup.py install可以将其安装到Python的dist-packages下供其他项目import使用,或者还可以使用pip install .来进行安装。

    setup函数的常用参数

    从上面的简短示例可以看出,setup()函数是整个setuptools的核心,所有配置的实现都是通过其参数的值来设定的。setup()函数的常用参数有以下这些。

    • name:包或者应用的名称。
    • version:包或者应用的版本号,这用来指示EasyInstall或者pip如何来安装。
    • description:包或者应用的描述。
    • py_module:列表类型,需要列出要包含的所有模块,适用于没有使用package组织的代码。
    • packages:列表类型,需要列出所有需要包含的package,不含第三方库。
    • include_package_data:是否自动将各个包目录中的非代码数据文件包含进来。
    • exclude_package_data:设置需要排除的费代码数据文件。
    • package_data:手动设定需要包含进来的费代码数据文件。
    • install_requires:用于指定项目所依赖的包的列表。
    • extra_require:设定额外的依赖,用于指定功能性可选特性的依赖。
    • entry_point:用于设定动态发现服务的入口,也可以用于设定直接在命令行执行时的入口。

    使用find_packages()来收集包

    我们自己撰写的项目一般不会只由一个或者几个文件组成,而是在习惯上会使用大量目录来对代码进行包管理。find_packages()提供了对于项目中包的收集功能。

    find_packages()在使用的时候需要提供一个存放包的目录名称,find_packages()会在这个指定的目录下寻找包。例如:find_packages('src')。但是需要注意的是,如果仅仅使用find_packages()来收集包的话,最终生成的发布包中将只会存在__init__.py和Python代码文件,而不包含其他类型的支持文件。所以如果需要包含其他文件,需要使用package_dir属性来定义包的目录对应的别名;之后再使用package_data属性来定义每个包中需要包含的额外文件,这两个属性都接受一个字典类型的值。具体使用可见以下示例。

    setup(
    	...
    	packages = find_packages('src'),
    	package_dir = {'': 'src'},
    	package_data = {
    		'': ['*.txt],
    		'data': ['data/*.dat']
    	}
    )	
    

    服务的动态发现

    Setuptools的一项功能是可以将脚本安装到系统中作为可执行命令,这就需要通过entry_points属性来进行配置。entry_points属性接受一个字典类型的值,其中使用键console_scriptsgui_scripts来定义命令及其对应的入口函数。例如:

    setup(
    	...
    	entry_points = {
    		'console_scripts': [
    			'foo = demo:test_foo',
    			'bar = demo:test_bar'
    		],
    		'gui_scripts': [
    			'gui = demo:test_gui'
    		]
    	}
    )
    

    当这个包在使用EasyInstall或者pip进行安装的时候,就会在系统中生成foobargui三条可执行命令,分别对应相应包下的指定函数。这些命令一般会存在于系统的PATH环境变量中,可以直接在命令行环境中使用。其中console_scripts用来定义命令行应用的入口命令,gui_scripts用来定义执行图形界面应用的入口命令。

    使用PyInstaller创建可执行应用

    PyInstaller是一个十分有用的第三方库。它会通过对源文件的打包,让Python程序在没有安装Python的环境中运行,也可以作为一个独立文件传递和管理。

    在命令行中使用pip install pyinstaller即可完成PyInstaller的安装。

    PyInstaller的使用也很简单,最简单的使用方法就是将程序的主文件传递给它,例如pyinstaller ./main.py。这样PyInstaller会生成两个目录,一个是build,用来放置生成过程中产生的文件,一个是dist用于放置最后生成的可执行文件和动态链接库,并且还会生成一个用于描述编译配置的.spec文件。

    PyInstaller在使用时需要注意一下两点:

    1. 文件路径中不能出现空格和英文句号;
    2. 源文件必须是UTF-8编码。

    一般一个应用在打包后应该与从源码执行拥有相同的行为特性,但是打包后的应用,其工作目录会发生变化。首先操作应用包外的目录和操作应用包内的目录会有区别。PyInstaller的Bootloader在sys模块中添加了一个属性来方便程序判断自身的运行状态。以下是一个常见的判断示例。

    import sys
    
    
    if getattr(sys, 'frozen', False):
    	# 当前在打包状态下运行
    else:
    	# 当前在源码状态下运行
    

    程序在不同的状态下运行,寻找相关文件和信息的方式也会有所不同,比如打包状态下可能会将配置信息保存在系统提供的应用数据目录中,而从代码运行则常常放在源码目录中。当程序在源码状态下运行时,我们一般会使用__file__来获取源码文件的绝对路径,但是在打包状态下,这个路径就会变为打包应用的路径,而不是代码文件的路径,所以PyInstaller提供了sys._MEIPASS来获取打包目录的绝对路径。

    常用命令行参数

    在编译过程中,PyInstaller也支持个性化的调整,这就需要使用命令行参数或者使用Spec文件来进行调整。当当前目录中不存在Spec文件时,PyInstaller会使用命令行参数来初始化一个Spec文件。PyInstaller中常用的命令行参数有以下这些。

    参数简短参数功能
    --help-h查看帮助文本
    --version-v查看PyInstaller版本
    --dispatch DIR指定最终打包文件输出目录,默认为.\textbackslash dist
    --workpath DIR设置放置中间编译文件的临时目录,默认为.\textbackslash build
    --noconfirm-y覆盖全部输出目录和文件时不再逐条提示确认
    --upx-dir UPX_DIR设置UPX工具的目录
    --ascii-a不使用Unicode编码支持
    --clean编译前清除全部临时目录和输出目录内容
    --onedir-D将最终输出的可执行文件和支持库放在一个目录中
    --onefile-F将最终的可执行文件和支持库打包为一个文件
    --specpath DIR指定放置Spec文件的目录位置
    --name NAME-n NAME指定Spec文件和打包App文件的名称,默认使用传入的脚本文件名
    --add-data <SRC:DEST或者SRC;DEST>添加额外的非二进制文件或者目录到打包文件或目录中,其中SRC为来源文件或者目录,DEST为打包文件中的目录,在不同的系统中需要使用不同的分隔符,其中Windows系统使用;,Unix系列系统使用:
    --add-binary <SRC:DEST或者SRC;DEST>添加额外的二进制文件到打包文件或目录中
    --paths DIR-p DIR设置用于寻找导入模块的目录,需要列举多个目录时,目录之间需要使用:隔开
    --hidden-import MODULE_NAME设置在代码中使用的隐式导入模块的名称
    --additional-hooks-dir HOOKS_DIR设置附加的连接文件所在目录
    --runtime-hook RUNTIME_HOOKS设置自定义的运行时连接文件
    --exclude-module EXCLUDES设置需要排除的可选模块或者包
    --key KEY设置加密Python字节码的密钥
    --console-c打开一个命令行窗口进行标准IO输入输出(默认方式)
    --windowed-w不显示用于标准IO的命令行窗口,在macOS中会自动打包为.app打包文件。Unix系列系统会忽略这个选项
    --icon ICON_FILE-i ICON_FILE指定Windows中可执行文件的图标(ico格式),以及macOS中app包的图标(icn格式)
    --osx-bundle-identifier BUNDLE_IDENTIFIER设置macOS中app打包文件的识别ID

    使用Spec文件配置编译

    即便是使用了命令行参数,PyInstaller最终还是要生成一个Spec文件来描述编译配置,并最终按照Spec文件的描述来生成可执行的文件包。在大部分情况下,你并不需要对Spec文件进行额外的编辑,就可以完成可执行文件包的编译和生成。但是如果需要更加详细的设置或者自定义某些设置,就需要对Spec文件进行进一步编辑后再进行编译和生成操作。

    使用Spec文件进行编译的命令与直接编译源代码文件不同,再次执行编译源代码文件的命令将会让PyInstaller重写Spec文件。让PyInstaller使用Spec文件进行编译只需要使用pyinstaller SPEC_FILE即可。

    一般在以下四种情况下使用Spec文件进行编译打包是非常便利的。

    1. 打包应用中需要附加一些额外的非二进制文件。
    2. 打包应用中需要附加一些运行时链接库(DLL或者SO文件),但PyInstaller并不能自行确定这些库的来源位置。
    3. 需要在可执行文件中添加Python解释器的运行时选项。
    4. 需要制作一个包含多个程序的打包应用。

    通用配置

    Spec文件是一个标准的Python脚本文件,其中分为几个部分,Analysis是对源代码的分析,并定义要包含的文件和库等。PYZ是打包配置。EXE是Windows系统可执行文件的编译配置。BUNDLE是用于配置macOS中的APP程序包的配置信息。COLLECT用于在使用--onedir方式输出应用时,对所有需要的内容进行收集;在使用--onefile时不会出现。

    Analysis的常见格式如下:

    a = Analysis(['main.py'],
    			pathex=['/project-folder'],
    			binaries=[],
    			datas=[],
    			hiddenimports=[],
    			hookspath=[],
    			runtime_hooks=[],
    			excludes=[],
    			cipher=block_cipher)
    

    Analysis中的内容与前面命令行参数中的配置内容十分相似,命令行参数中的大部分内容也的确都可以在这里进行配置。其中Analysis的第一个参数用于指定应用的入口代码文件;pathex用于指定PyInstaller寻找相关模块、包以及链接库的位置;binaries用于向应用中增加二进制文件,相当于--addbinary参数的作用;datas用于向应用中增加非二进制文件,相当于--add-data参数的作用。

    使用datas添加非二进制文件的格式为datas=[('filename', 'path')],其中path是最终的程序包中用于存放该文件的目录名称。打包进程序包中的非二进制文件可以通过包pkgutil中的功能进行访问,例如有配置datas=[('help.txt', 'help')]可以通过pkgutil.get_data('help', 'help.txt')来获取。

    与非二进制文件相似,二进制链接库的添加和使用的方法也是一样的。

    PYZ用于描述源码编译后的可执行文件包的组成,其中包括了Analysis中添加的全部内容。一般情况下这一部分内容无需进行调整修改。

    编译Windows可执行文件

    EXE用于输出可执行文件,这部分输出的并不一定是Windows的可执行文件,而是会根据当前系统,生成相应的可执行文件。

    这一部分主要是对可执行文件的特征进行配置,例如UPX配置、输出文件名、是否使用命令行处理标准IO等等。其中关于编译好的程序都是从PYZ和Analysis中继承来的,如果需要修改这些内容,需要到相应的部分去修改。此外,为Python解释器添加参数也是在这一部分完成的,可以在a.scripts后的列表参数中添加。

    编译macOS可执行App

    当使用--onefile--windowed参数在macOS上进行编译时,会默认生成APP应用包。macOS的APP应用包是在EXE部分的基础之上进行配置,并添加了针对macOS的独有的配置项。

    用于配置macOS应用包打包的配置项由BUNDLE定义,其中常用的配置项有以下这些。

    • name,应用包名称。
    • icon,应用包的图标。
    • bundle_identifier,应用包的唯一化标识。
    • info_plist,应用包中的info.plist配置,可以根据实际info.plist文件的内容进行书写。

    常见问题

    打包后的应用在macOS上字体发虚。

    这种情况在使用Retina显示屏的macOS设备上常见,需要配置应用包的info_plist来支持Retina分辨率,可在info_plist配置项中添加'NSHighResolutionCapable': 'True'来启用Retina分辨率的支持。

    在Windows中进行编译时,提示DLL文件找不到

    这通常表现为提示WARNING: lib not found,可以记下缺少的这些文件,到Windows系统的System32目录下寻找,并将其复制到项目的虚拟环境的bin或者Scripts目录(Python可执行文件所在目录)中。

    另一种方法是配置Analysis中的pathex的配置,将DLL链接库的位置放入列表。

    py2app和py2exe

    上一章讲述了使用PyInstaller对Python应用进行打包生成可执行文件的方法。但是PyInstaller存在一个比较大的缺点,就是打包文件体积较大。py2app和py2exe是两个独立的工具,分别用于macOS系统和Windows系统的可执行文件打包。这两个工具都依赖setup.py文件进行配置,并且可以在同一个setup.py文件中同时进行两种配置。

    安装

    py2app只需要通过pip install py2app命令即可完成安装。相应的,py2exe需要使用命令pip install py2exe完成安装。

    最小setup.py文件

    如果项目的入口文件是main.py,那么最简单的setup.py文件就如下所示。

    from setuptools import setup
    
    
    setup(
    	app=["main.py"],
    	setup_requires=["py2app"]
    )
    

    只需要在项目目录及环境中执行命令python setup.py py2app即可在dist目录中生成相应的macOS应用文件,要生成Windows可执行文件,需要将setup_requires中的值改为["py2exe"],之后使用命令python setup.py py2exe来完成生成。

    py2app还提供了py2applet脚本来自动生成setup.py文件,命令格式为py2applet --make-setup main.py

    跨平台setup.py文件配置

    跨平台的setup.py文件与py2app和py2exe无关,只是提供了按照不同的系统加载不同配置的功能,以下提供一个示例。

    import ez_setup
    ez_setup.use_setuptools()
    
    import sys
    form setuptools import setup
    
    
    enterence = 'main.py'
    
    if sys.platform == 'darwin':
    	extra_options = dict(
            setup_requires=['py2app'],
            app=[enterence],
            options=dict(py2app=dict(argv_emulation=True),
            plist=dict(CFBundleIdentifier='org.holynite.app')))
    elif sys.platform == 'win32':
    extra_options = dict(
        setup_requires=['py2exe'],
        console=[enterence])
    else:
    	extra_options = dict(scripts=[enterence])
    
    setup(name="Application",
    	  **extra_options)
    

    plist配置

    macOS上的应用是通过plist文件来完成信息定义的,每个应用中都会包含一个info.plist文件。对于plist配置的调整是通过setup函数中传递给options参数的字典中的plist键完成的,例如上一节中options参数中与py2app一项同级的plist项。

    plist参数中常用的配置项主要有以下这些。

    • CFBundleDocumentTypes,用于指定可以由应用打开的文件类型,列表类型。
    • CFBundleGetInfoString,用于在Finder中显示的信息。
    • CFBundleIdentifier,应用的识别串。
    • CFBundleURLTypes,用于指定应用支持的URL Scheme。
    • LSBackgroundOnly,如果为True,则该应用为后台应用。
    • LSUIElement,如果为True,应用为代理应用,不会出现在Dock中,但是会显示界面。
    • NSServices,用于指定由应用提供的服务,列表类型。

    py2app的其他配置项

    除了plist配置项以外,常用的配置项还有以下这些。

    • includes,设定需要包含的模块。
    • excludes,设定需要排除的模块。
    • packages,设定需要包含的包。
    • iconfile,指定图表文件。
    • resources,设定需要一同打包的数据文件。
    • alias,使用别名模式,相当于在命令行中使用-A参数,仅应该在开发阶段使用,使用别名模式打包的应用仅能在本电脑上使用。
    • argv_emulation,允许模拟argv传入参数。
    • optimize,优化级别。

    py2exe的配置

    py2exe在定义应用入口时,不是使用app参数,而是使用consolewindows参数,分别指示使用终端窗口或者图形窗口。

    此外还可以在options参数中使用data_files来定义需要一同包含到发布文件夹中的数据文件。如果项目依赖了Microsoft C Runtime,则可以在这里定义。data_files接受一个元组列表,格式为[("目标文件夹", ["文件名"])],其中文件名可以使用通配符来包含多个文件。

    py2exe通过参数bundle_files来配置有哪些文件需要被打包到发布文件夹中。bundle_files取值为整数,值为3时,会将所有必需文件打包;值为2时会将Python文件和其他依赖分别打包进ZIP压缩包;值为1时会将所有依赖打包进ZIP压缩包;值为0时会将应用打包为一个文件。