Python-系统管理高级教程-全-

Python 系统管理高级教程(全)

原文:Pro Python System Administration

协议:CC BY-NC-SA 4.0

零、简介

这些年来,系统管理员的角色已经大大增加了。单个工程师支持的系统数量也增加了。因此,手工制作每个安装是不切实际的,需要尽可能多地自动化任务。系统的结构因组织而异,因此系统管理员必须能够创建自己的管理工具。历史上,用于这些任务的最流行的编程语言是 UNIX shell 和 Perl。它们很好地实现了自己的目的,我怀疑它们是否会不复存在。然而,当前系统的复杂性需要新的工具,Python 编程语言就是其中之一。

Python 是一种面向对象的编程语言,适合开发大规模应用。它的语法和结构使它非常容易阅读——以至于这种语言有时被称为“可执行伪代码”Python 解释器允许交互式执行,因此在某些情况下,管理员可以使用它来代替标准的 UNIX shell。尽管 Python 主要是一种面向对象的语言,但它很容易被用于过程式和函数式编程。综上所述,Python 非常适合作为实现系统管理应用的新语言。已经有大量的 Linux 系统工具是用 Python 编写的,比如 Yum 包管理器和 Anaconda,Linux 安装程序。

使用这本书的先决条件

这本书是关于使用 Python 编程语言来解决特定的系统管理任务。我们着眼于四个不同的系统管理领域:网络管理、web 服务器和 web 应用管理、数据库系统管理和系统监控。虽然我详细解释了本书中使用的大部分技术,但请记住,这里的主要目标是展示 Python 库的实际应用,以便解决相当具体的问题。因此,我假设您是一位经验丰富的系统管理员。你应该能够自己找到额外的信息;这本书给了你一个粗略的指导,告诉你如何达到你的目标,但是你必须能够想出如何使它适应你的特定系统和环境。

在我们讨论示例时,会要求您安装额外的包和库。在大多数情况下,我提供了在 Fedora 系统上执行这些任务的命令和指令,但是您应该准备好在您将要使用的 Linux 发行版中采用这些指令。大多数例子也可以在最近的 OS X 版本(10.10.X)上运行,不需要太多修改。

我还假设您有 Python 编程语言的背景。我介绍了系统管理任务中使用的特定库,以及一些鲜为人知或很少讨论的语言功能,如生成器函数或类内部方法,但这里没有解释基本的语言语法。如果你想刷新你的 Python 技能,我推荐以下几本书:Marty al chin 和 J. Burton Browning 的 Pro Python(a press,2012;但请关注将于 2015 年初发布的新版本);Mike Dawson 为绝对初学者编写的 Python 编程(课程技术 PTR,2010);以及 Wesley Chun 的核心 Python 应用编程

本书中的所有例子都假设 Python 版本为 2.7。这主要是由示例中使用的库决定的。部分库已经移植到 Python 3;然而,有些人没有。因此,如果您需要运行 Python 3,请确保检查所需的库是否支持 Python 3。

这本书的结构

这本书包含 14 章,每章解决一个独特的问题。有些例子跨越了多个章节,但即使这样,每章都处理特定问题的一个特定方面。

除了这些章节,这本书还有其他几个组织层次。首先,我根据问题类型对章节进行了分组。第一章到第四章处理网络管理问题;第五章到第七章讲的是 Apache web 服务器和 web 应用管理;第八章至 11 章专门用于监控和统计计算;而第十二章和第十三章关注的是数据库管理问题。

第二,我在所有章节中保持一个共同的模式。我从问题陈述开始,然后继续收集需求,在进入实现部分之前继续设计阶段。

第三,每章关注一种或多种技术以及为特定技术提供语言接口的 Python 库。这种技术的例子可以是 SOAP 协议、应用插件架构或云计算概念。

更具体地说,以下是各章节的分类:

第一章:使用 SNMP 读取和收集性能数据

大多数网络连接设备通过简单网络管理协议(SNMP)公开内部计数器。本章解释了 SNMP 的基本原理和数据结构。然后我们看一下 Python 库,这些库为支持 SNMP 的设备提供了接口。我们还研究了循环数据库,它是存储统计数据的事实上的标准。最后,我们看看 Jinja2 模板框架,它允许我们生成简单的 web 页面。

第二章:使用 SOAP API 管理设备

复杂的任务,如管理设备配置,不能通过使用 SNMP 轻易完成,因为该协议过于简单。因此,高级设备(如 Citrix Netscaler 负载平衡器)为设备管理系统提供 SOAP API 接口。在这一章中,我们研究了 SOAP API 结构和支持 Python 编程语言中基于 SOAP 的通信的库。我们还将使用内置的库来查看基本的日志功能。这本书的第二版包含了如何使用新的 REST API 来管理负载平衡器设备的例子。

第三章:创建一个用于 IP 地址统计的 Web 应用

在这一章中,我们将构建一个 web 应用来维护分配的 IP 地址和地址范围的列表。我们学习如何使用 Django 框架创建 web 应用。我将向您展示 Django 应用的构造方式,告诉您如何创建和配置应用设置,并解释 URL 结构。我们还研究了如何使用 Apache web 服务器部署 Django 应用。

第四章:集成 IP 地址应用和 DHCP

本章是对前一章的扩展,我们实现了 DHCP 地址范围支持。我们还研究了一些高级 Django 编程技术,比如定制响应 MIME 类型和服务 AJAX 调用。第二版增加了使用 OMAPI 协议管理动态 DHCP 租约的新功能。

第五章:在 Apache 配置文件中维护虚拟主机列表

这是我们在本书中开发的另一个 Django 应用,但是这次我们的重点是 Django 管理接口。在构建 Apache 配置管理应用时,您学习了如何用自己的视图和函数定制默认的 Django 管理界面。

第六章:从 Apache 日志文件中收集和显示统计数据

在本章中,我们的目标是构建一个解析和分析 Apache web 服务器日志文件的应用。我们没有采用构建单一应用这种简单但不灵活的方法,而是着眼于构建插件应用所涉及的设计原则。您将学习如何使用对象和类类型发现功能,以及如何执行动态模块加载。本书的第二版向您展示了如何基于收集的数据执行数据可视化。

第七章:执行复杂的搜索并报告应用日志文件

本章还涉及日志文件解析,但这次我将向您展示如何解析复杂的多行日志文件条目。我们研究了名为 Exctractor 的开源日志文件解析工具的功能,您可以从exctractor.sourceforge.net/下载。

第八章:Nagios 的网站可用性检查脚本

Nagios 是最流行的开源监控系统之一,因为它的模块化结构允许用户实现自己的检查脚本,从而定制工具来满足他们的需求。在这一章中,我们创建了两个检查网站功能的脚本。我们研究如何使用美汤 HTML 解析库从 HTML 网页中提取信息。

第九章:管理监控子系统

本章是三章系列的开始,在这一系列中,我们将构建一个完整的监控系统。本章的目标不是取代成熟的监控系统,如 Nagios 或 Zenoss,而是展示分布式应用编程的基本原则。我们着眼于数据库设计原则,如数据规范化。我们还研究了如何使用 RPC 调用实现网络服务之间的通信机制。

第十章:远程监控代理

这是监视系列的第二章,我们将实现远程监视代理组件。在本章中,我还描述了如何使用 ConfigParser 模块将应用从其配置中分离出来。

第十一章:统计数据收集和报告

这是监控系列的最后一部分,我将向您展示如何对收集的性能数据执行基本的统计分析。我们使用科学库:NumPy 执行计算,matplotlib 创建图形。您将学习如何发现哪些性能读数属于舒适区,以及如何计算该区域的边界。我们还进行基本的趋势检测,这为容量规划提供了很好的洞察力。

第十二章:分布式消息处理系统

这是本书第二版的新章节。在这一章中,我将向您展示如何使用远程任务执行框架 Celery 来转换分布式管理系统。

第十三章【MySQL 数据库性能自动调优

在本章中,我将向您展示如何获取 MySQL 数据库的配置变量和内部状态指示器。我们构建了一个应用,该应用根据获得的数据就如何提高数据库引擎性能提出建议。

第十四章:亚马逊 EC2/S3 作为数据仓库解决方案

本章向您展示了如何利用 Amazon 弹性计算云(EC2)并将不常见的计算任务卸载给它。我们构建了一个自动创建数据库服务器的应用,您可以在其中传输数据以供进一步分析。您可以使用这个示例作为构建随需应变数据仓库解决方案的基础。

示例源代码

本书中所有示例的源代码,以及任何适用的样本数据,都可以按照www.apress.com/source-code/的说明从 Apress 网站下载。存储在该位置的源代码包含书中描述的相同代码。

本书中描述的大多数原型也可以作为开源项目获得。你可以在作者的网站找到这些项目,www.sysadminpy.com/

一、使用 SNMP 读取和收集性能数据

大多数连接到网络的设备使用 SNMP(简单网络管理协议)报告它们的状态。该协议主要是为管理和监控网络连接的硬件设备而设计的,但是一些应用也使用该协议来公开它们的统计数据。在本章中,我们将探讨如何从 Python 应用中访问这些信息。我们将使用 RRDTool 将获得的数据存储在 RRD(循环数据库)中,RRDTool 是一个广为人知的流行应用和库,用于存储和绘制性能数据。最后,我们将研究 Jinja2 模板系统,我们将使用它为我们的应用生成简单的 web 页面。

应用需求和设计

系统监控的主题非常广泛,通常包含许多不同的领域。一个完整的监控系统相当复杂,通常由多个部件协同工作组成。我们不打算在这里开发一个完整的、自给自足的系统,但是我们将研究典型监控系统的两个重要方面:信息收集和表示。在本章中,我们将实现一个系统,该系统使用 SNMP 协议查询设备,然后使用 RRDTool 库存储数据,该库也用于生成可视化数据表示的图形。所有这些都使用 Jinja2 模板库绑定到简单的 web 页面中。在本章中,我们将更详细地了解这些组件。

详细说明要求

在开始设计我们的应用之前,我们需要对我们的系统提出一些要求。首先,我们需要理解我们期望我们的系统提供的功能。这将帮助我们创建一个有效的(我们希望易于实现的)系统设计。在本章中,我们将创建一个使用 SNMP 协议监控网络连接设备(如网络交换机和路由器)的系统。因此,第一个要求是系统能够使用 SNMP 查询任何设备。

需要存储从设备收集的信息,以供将来参考和分析。让我们对这些信息的使用做一些假设。首先,我们不需要无限期地存储它。(我将在第 9-11 章中详细讨论永久信息存储。)这意味着信息只存储预定义的一段时间,一旦过时就会被擦除。这提出了我们的第二个需求:信息在“过期”后需要被删除

第二,需要存储信息,以便生成图表。我们不会将它用于任何其他用途,因此数据存储应该针对数据表示任务进行优化。

最后,我们需要生成图表,并在易于访问的网页上显示这些信息。该信息只需要由设备名称构成。例如,如果我们正在监控几个设备的 CPU 和网络接口利用率,那么这些信息需要显示在一个页面上。我们不需要在多个时间尺度上呈现这些信息;默认情况下,图表应该显示过去 24 小时的性能指标。

高级设计规范

现在我们已经对系统的功能有了一些想法,让我们创建一个简单的设计,我们将使用它作为开发阶段的指南。基本的方法是,我们之前指定的每个需求都应该被一个或多个设计决策所覆盖。

第一个要求是我们需要监控网络连接设备,我们需要使用 SNMP 来实现。这意味着我们必须使用适当的 Python 库来处理 SNMP 对象。SNMP 模块不包含在默认的 Python 安装中,所以我们必须使用一个外部模块。我推荐使用 PySNMP 库(可以在pysnmp.sourceforge.net/获得),它可以在大多数流行的 Linux 发行版上获得。

数据存储引擎的完美候选是 RRDTool (在oss.oetiker.ch/rrdtool/有售)。循环数据库意味着数据库的结构是这样的,每个“表”都有一个有限的长度,一旦达到限制,最旧的条目就会被丢弃。事实上,它们没有被丢弃;新的只是被写入它们的位置。

RRDTool 库提供了两种不同的功能:数据库服务和图形生成工具包。Python 中没有对 RRD 数据库的本地支持,但是有一个外部库提供了到 RRDTool 库的接口。

最后,为了生成网页,我们将使用 Jinja2 模板库(可在jinja.pocoo.org或 GitHub:github.com/mitsuhiko/jinja2获得),它让我们可以创建复杂的模板,并分离设计和开发任务。

我们将使用一个简单的 Windows INI 风格的配置文件来存储我们将监控的设备的信息。此信息将包括设备地址、SNMP 对象引用和访问控制详细信息等详细信息。

该应用将分为两部分:第一部分是信息收集工具,它查询所有已配置的设备并将数据存储在 RRDTool 数据库中;第二部分是报告生成器,它生成网站结构以及所有需要的图像。这两个组件都将从标准的 UNIX 调度程序应用 cron 实例化。这两个脚本将分别被命名为 snmp-manager.py 和 snmp-pages.py。

SNMP 简介

SNMP(简单网络管理协议)是一种基于 UDP 的协议,主要用于管理网络连接设备,如路由器、交换机、计算机、打印机、摄像机等。一些应用还允许通过 SNMP 协议访问内部计数器。

SNMP 不仅允许您从设备中读取性能统计数据,它还可以发送控制消息来指示设备执行某些操作,例如,您可以使用 SNMP 命令远程重启路由器。

由简单网络管理协议(SNMP)管理的系统中有三个主要组件:

  • 负责管理所有设备的管理系统
  • 被管理设备,即由管理系统管理的所有设备
  • SNMP 代理是运行在每个被管理设备上并与管理系统交互的应用

这种关系在图 1-1 中说明。

9781484202180_Fig01-01.jpg

图 1-1 。SNMP 网络组件

这种方法相当普通。该协议定义了七个基本命令,其中我们最感兴趣的是 get、get bulk 和 response。你可能已经猜到了,前两个是管理系统向代理发出的命令,后一个是代理软件的响应。

管理系统如何知道要寻找什么?该协议没有定义交换该信息的方式,因此管理系统无法询问代理以获得可用变量的列表。

该问题通过使用管理信息库(或 MIB)来解决。每个设备通常都有一个相关的 MIB,它描述了该系统上管理数据的结构。这种 MIB 将按层次顺序列出在被管理设备上可用的所有对象标识符(oid)。OID 有效地表示了对象树中的一个节点。它包含从树顶部的节点开始通向当前 OID 的所有节点的数字标识符。节点 id 由 IANA(互联网号码分配机构)分配和管理。一个组织可以申请一个 OID 节点,当它被分配后,它负责管理分配节点下的 OID 结构。

图 1-2 展示了 OID 树的一部分。

9781484202180_Fig01-02.jpg

图 1-2 。SNMP OID 树

让我们看一些 oid 的例子。分配给思科组织的 OID 树节点的值为 1.3.6.1.4.1.9,这意味着所有与思科制造的设备相关联的专有 oid 将以这些数字开头。同样,Novell 设备的 oid 将从 1.3.6.1.4.1.23 开始。

我特意强调了专有 oid,因为有些属性预计会出现在所有设备上(如果有的话)。这些在 RFC1213 定义的 1.3.6.1.2.1.1(系统 SNMP 变量)节点下。欲了解更多关于 OID 树及其元素的细节,请访问 http://www.alvestrand.no/objectid/top.html。这个网站允许你浏览 OID 树,它包含了相当大的各种 oid 的集合。

系统 SNMP 变量节点

在大多数情况下,关于设备的基本信息可以在系统 SNMP 变量 OID 节点下的子树中找到。因此,让我们仔细看看你能在那里找到什么。

这个 OID 节点包含几个额外的 OID 节点。表 1-1 提供了大多数子节点的描述。

表 1-1 。系统 SNMP OIDs

|

OID 字符串

|

目录名称

|

描述

1.3.6.1.2.1.1.1 系统描述 包含系统或设备简短描述的字符串。通常包含硬件类型和操作系统的详细信息。
1.3.6.1.2.1.1.2 sysObjectID 包含特定于供应商的设备 OID 节点的字符串。例如,如果为组织分配了 OID 节点 1.3.6.1.4.1.8888,并且在组织的空间下为该特定设备分配了. 1.1 OID 空间,则该字段将包含值 1.3.6.1.4.1.8888.1.1。
1.3.6.1.2.1.1.3 工作时间 一个数字,表示从系统初始化开始以百秒为单位的时间。
1.3.6.1.2.1.1.4 联系方式 包含负责此系统的联系人信息的任意字符串。
1.3.6.1.2.1.1.5 系统 分配给系统的名称。通常这个字段包含一个完全合格的域名。
1.3.6.1.2.1.1.6 系统地址 描述系统物理位置的字符串。
1.3.6.1.2.1.1.7 系统服务 表示该系统提供哪些服务的数字。该编号是所有 OSI 协议的位图表示,最低位代表第一个 OSI 层。例如,一个交换设备(运行在第 2 层)会将这个数字设置为 2 2 = 4。这个字段现在已经很少使用了。
1.3.6.1.2.1.1.8 sysLastChange 一个数字,包含任何系统 SNMP 对象发生更改时的 sysUpTime 值。
1.3.6.1.2.1.1.9 包含多个 sysEntry 元素的节点。每个元素代表一个独特的功能和相应的 OID 节点值。

接口 SNMP 变量节点

类似地,基本的接口统计数据可以从接口 SNMP 变量 OID 节点子树中获得。接口变量的 OID 是 1.3.6.1.2.1.2,包含两个子节点:

  • 包含网络接口总数的 OID。此条目的 OID 值为 1 . 3 . 6 . 1 . 2 . 1 . 2 . 1;它通常被称为 ifNumber。此 OID 下没有可用的子节点。
  • 包含所有接口条目的 OID 节点。它的 OID 是 1.3.6.1.2.1.2.2,通常被称为 ifTable。此节点包含一个或多个入口节点。入口节点(1.3.6.1.2.1.2.2.1,也称为 ifEntry)包含关于该特定接口的详细信息。列表中条目的数量由 ifNumber 节点值定义。

您可以在表 1-2 中找到所有 ifEntry 子节点的详细信息。

表 1-2 。接口条目 SNMP OIDs

|

OID 字符串

|

目录名称

|

描述

1.3.6.1.2.1.2.2.1.1 如果索引 分配给接口的唯一序列号。
1.3.6.1.2.1.2.2.1.2 ifDescr 包含接口名称和其他可用信息的字符串,如硬件制造商的名称。
1.3.6.1.2.1.2.2.1.3 ifType 代表接口类型的数字,取决于接口的物理链路和协议。
1.3.6.1.2.1.2.2.1.4 ifMtu 这个接口可以传输的最大网络数据报。
1.3.6.1.2.1.2.2.1.5 芯倍速 接口的估计当前带宽。如果无法计算当前带宽,该数字应包含接口的最大可能带宽。
1.3.6.1.2.1.2.2.1.6 ifPhysAddress 接口的物理地址,通常是以太网接口上的 MAC 地址。
1.3.6.1.2.1.2.2.1.7 ifAdminStatus 该 OID 允许设置接口的新状态。通常限于以下值:1(向上),2(向下),3(测试)。
1.3.6.1.2.1.2.2.1.8 异态 接口的当前状态。通常限于以下值:1(向上),2(向下),3(测试)。
1.3.6.1.2.1.2.2.1.9 iflastschange 当接口进入当前状态时,包含系统运行时间(sysUpTime)读数的值。如果接口在最后一次系统重新初始化之前进入此状态,可以设置为零。
1.3.6.1.2.1.2.2.1.10 菲诺特人 接口上接收的字节(八位字节)总数。
1.3.6.1.2.1.2.2.1.11 ifInUcastPkts 转发到设备网络堆栈的单播数据包数量。
1.3.6.1.2.1.2.2.1.12 ifInNUcastPkts 传送到设备网络堆栈的非单播数据包的数量。非单播数据包通常是广播或组播数据包。
1.3.6.1.2.1.2.2.1.13 ifInDiscards 丢弃的数据包数量。这并不表示有数据包错误,但可能表示接收缓冲区太小,无法接受数据包。
1.3.6.1.2.1.2.2.1.14 ifInErrors 收到的无效数据包的数量。
1.3.6.1.2.1.2.2.1.15 ifinunnknownprotos 由于设备接口不支持该协议而丢弃的数据包数量。
1.3.6.1.2.1.2.2.1.16 ifOutOctets 从接口传输出去的字节数(八位字节)。
1.3.6.1.2.1.2.2.1.17 ifOutUcastPkts 从设备网络堆栈接收的单播数据包的数量。这个数字还包括被丢弃或未发送的数据包。
1.3.6.1.2.1.2.2.1.18 ifNUcastPkts 从设备网络堆栈接收的非单播数据包的数量。这个数字还包括被丢弃或未发送的数据包。
1.3.6.1.2.1.2.2.1.19 如果丢弃 被丢弃的有效数据包的数量。这不是一个错误,但它可能表明发送缓冲区太小,无法接受所有数据包。
1.3.6.1.2.1.2.2.1.20 ifOutErrors 由于错误而无法传输的传出数据包数。
1.3.6.1.2.1.2.2.1.21 ifOutQLen 出站数据包队列的长度。
1.3.6.1.2.1.2.2.1.22 如果具体 通常包含对描述该接口的特定于供应商的 OID 的引用。如果这样的信息不可用,则该值被设置为 OID 0.0,这在语法上是有效的,但不指向任何内容。

SNMP 中的身份验证

早期 SNMP 实现中的认证有些原始,容易受到攻击。SNMP 代理定义了两个社区字符串:一个用于只读访问,另一个用于读/写访问。当管理系统连接到代理时,它必须使用这两个字符串之一进行身份验证。代理仅接受来自已使用有效社区字符串进行身份验证的管理系统的命令。

从命令行查询 SNMP

在开始编写我们的应用之前,让我们快速看一下如何从命令行查询 SNMP。如果您想检查 SNMP 代理返回的信息是否被您的应用正确接受,这是非常有用的。

命令行工具由 Net-SNMP-Utils 包提供,该包可用于大多数 Linux 发行版。这个包包括查询和设置 SNMP 对象的工具。关于安装这个软件包的详细信息,请参考您的 Linux 发行版文档。例如,在基于 RedHat 的系统上,您可以使用以下命令安装这些工具:

$ sudo yum install net-snmp-utils

在基于 Debian 的系统上,软件包可以这样安装:

$ sudo apt-get install snmp

这个包中最有用的命令是 snmpwalk ,它将一个 OID 节点作为参数,并试图发现所有子节点 oid。这个命令使用 SNMP 操作 getnext ,它返回树中的下一个节点,并有效地允许您从指定的节点开始遍历整个子树。如果没有指定 OID,snmpwalk 将使用默认的 SNMP 系统 OID (1.3.6.1.2.1)作为起点。清单 1-1 演示了针对运行 Fedora Linux 的笔记本电脑发出的 snmpwalk 命令。

清单 1-1 。snmpwalk 命令的示例

$ snmpwalk –v2c -c public -On 192.168.1.68
.1.3.6.1.2.1.1.1.0 = STRING: Linux fedolin.example.com 2.6.32.11-99.fc12.i686 #1 SMP Mon Apr 5 16:32:08 EDT 2010 i686
.1.3.6.1.2.1.1.2.0 = OID: .1.3.6.1.4.1.8072.3.2.10
.1.3.6.1.2.1.1.3.0 = Timeticks: (110723) 0:18:27.23
.1.3.6.1.2.1.1.4.0 = STRING: Administrator (admin@example.com)
.1.3.6.1.2.1.1.5.0 = STRING: fedolin.example.com
.1.3.6.1.2.1.1.6.0 = STRING: MyLocation, MyOrganization, MyStreet, MyCity, MyCountry
.1.3.6.1.2.1.1.8.0 = Timeticks: (3) 0:00:00.03
.1.3.6.1.2.1.1.9.1.2.1 = OID: .1.3.6.1.6.3.10.3.1.1
.1.3.6.1.2.1.1.9.1.2.2 = OID: .1.3.6.1.6.3.11.3.1.1
.1.3.6.1.2.1.1.9.1.2.3 = OID: .1.3.6.1.6.3.15.2.1.1
.1.3.6.1.2.1.1.9.1.2.4 = OID: .1.3.6.1.6.3.1
.1.3.6.1.2.1.1.9.1.2.5 = OID: .1.3.6.1.2.1.49
.1.3.6.1.2.1.1.9.1.2.6 = OID: .1.3.6.1.2.1.4
.1.3.6.1.2.1.1.9.1.2.7 = OID: .1.3.6.1.2.1.50
.1.3.6.1.2.1.1.9.1.2.8 = OID: .1.3.6.1.6.3.16.2.2.1
.1.3.6.1.2.1.1.9.1.3.1 = STRING: The SNMP Management Architecture MIB.
.1.3.6.1.2.1.1.9.1.3.2 = STRING: The MIB for Message Processing and Dispatching.
.1.3.6.1.2.1.1.9.1.3.3 = STRING: The management information definitions for the SNMP User-based Security Model.
.1.3.6.1.2.1.1.9.1.3.4 = STRING: The MIB module for SNMPv2 entities
.1.3.6.1.2.1.1.9.1.3.5 = STRING: The MIB module for managing TCP implementations
.1.3.6.1.2.1.1.9.1.3.6 = STRING: The MIB module for managing IP and ICMP implementations
.1.3.6.1.2.1.1.9.1.3.7 = STRING: The MIB module for managing UDP implementations
.1.3.6.1.2.1.1.9.1.3.8 = STRING: View-based Access Control Model for SNMP.
.1.3.6.1.2.1.1.9.1.4.1 = Timeticks: (3) 0:00:00.03
.1.3.6.1.2.1.1.9.1.4.2 = Timeticks: (3) 0:00:00.03
.1.3.6.1.2.1.1.9.1.4.3 = Timeticks: (3) 0:00:00.03
.1.3.6.1.2.1.1.9.1.4.4 = Timeticks: (3) 0:00:00.03
.1.3.6.1.2.1.1.9.1.4.5 = Timeticks: (3) 0:00:00.03
.1.3.6.1.2.1.1.9.1.4.6 = Timeticks: (3) 0:00:00.03
.1.3.6.1.2.1.1.9.1.4.7 = Timeticks: (3) 0:00:00.03
.1.3.6.1.2.1.1.9.1.4.8 = Timeticks: (3) 0:00:00.03
.1.3.6.1.2.1.2.1.0 = INTEGER: 5
.1.3.6.1.2.1.2.2.1.1.1 = INTEGER: 1
.1.3.6.1.2.1.2.2.1.1.2 = INTEGER: 2
.1.3.6.1.2.1.2.2.1.1.3 = INTEGER: 3
.1.3.6.1.2.1.2.2.1.1.4 = INTEGER: 4
.1.3.6.1.2.1.2.2.1.1.5 = INTEGER: 5
.1.3.6.1.2.1.2.2.1.2.1 = STRING: lo
.1.3.6.1.2.1.2.2.1.2.2 = STRING: eth0
.1.3.6.1.2.1.2.2.1.2.3 = STRING: wlan1
.1.3.6.1.2.1.2.2.1.2.4 = STRING: pan0
.1.3.6.1.2.1.2.2.1.2.5 = STRING: virbr0
.1.3.6.1.2.1.2.2.1.3.1 = INTEGER: softwareLoopback(24)
.1.3.6.1.2.1.2.2.1.3.2 = INTEGER: ethernetCsmacd(6)
.1.3.6.1.2.1.2.2.1.3.3 = INTEGER: ethernetCsmacd(6)
.1.3.6.1.2.1.2.2.1.3.4 = INTEGER: ethernetCsmacd(6)
.1.3.6.1.2.1.2.2.1.3.5 = INTEGER: ethernetCsmacd(6)
.1.3.6.1.2.1.2.2.1.4.1 = INTEGER: 16436
.1.3.6.1.2.1.2.2.1.4.2 = INTEGER: 1500
.1.3.6.1.2.1.2.2.1.4.3 = INTEGER: 1500
.1.3.6.1.2.1.2.2.1.4.4 = INTEGER: 1500
.1.3.6.1.2.1.2.2.1.4.5 = INTEGER: 1500
.1.3.6.1.2.1.2.2.1.5.1 = Gauge32: 10000000
.1.3.6.1.2.1.2.2.1.5.2 = Gauge32: 0
.1.3.6.1.2.1.2.2.1.5.3 = Gauge32: 10000000
.1.3.6.1.2.1.2.2.1.5.4 = Gauge32: 10000000
.1.3.6.1.2.1.2.2.1.5.5 = Gauge32: 10000000
.1.3.6.1.2.1.2.2.1.6.1 = STRING:
.1.3.6.1.2.1.2.2.1.6.2 = STRING: 0:d:56:7d:68:b0
.1.3.6.1.2.1.2.2.1.6.3 = STRING: 0:90:4b:64:7b:4d
.1.3.6.1.2.1.2.2.1.6.4 = STRING: 4e:e:b8:9:81:3b
.1.3.6.1.2.1.2.2.1.6.5 = STRING: d6:f9:7c:2c:17:28
.1.3.6.1.2.1.2.2.1.7.1 = INTEGER: up(1)
.1.3.6.1.2.1.2.2.1.7.2 = INTEGER: up(1)
.1.3.6.1.2.1.2.2.1.7.3 = INTEGER: up(1)
.1.3.6.1.2.1.2.2.1.7.4 = INTEGER: down(2)
.1.3.6.1.2.1.2.2.1.7.5 = INTEGER: up(1)
.1.3.6.1.2.1.2.2.1.8.1 = INTEGER: up(1)
.1.3.6.1.2.1.2.2.1.8.2 = INTEGER: down(2)
.1.3.6.1.2.1.2.2.1.8.3 = INTEGER: up(1)
.1.3.6.1.2.1.2.2.1.8.4 = INTEGER: down(2)
.1.3.6.1.2.1.2.2.1.8.5 = INTEGER: up(1)
.1.3.6.1.2.1.2.2.1.9.1 = Timeticks: (0) 0:00:00.00
.1.3.6.1.2.1.2.2.1.9.2 = Timeticks: (0) 0:00:00.00
.1.3.6.1.2.1.2.2.1.9.3 = Timeticks: (0) 0:00:00.00
.1.3.6.1.2.1.2.2.1.9.4 = Timeticks: (0) 0:00:00.00
.1.3.6.1.2.1.2.2.1.9.5 = Timeticks: (0) 0:00:00.00
.1.3.6.1.2.1.2.2.1.10.1 = Counter32: 89275
.1.3.6.1.2.1.2.2.1.10.2 = Counter32: 0
.1.3.6.1.2.1.2.2.1.10.3 = Counter32: 11649462
.1.3.6.1.2.1.2.2.1.10.4 = Counter32: 0
.1.3.6.1.2.1.2.2.1.10.5 = Counter32: 0
.1.3.6.1.2.1.2.2.1.11.1 = Counter32: 1092
.1.3.6.1.2.1.2.2.1.11.2 = Counter32: 0
.1.3.6.1.2.1.2.2.1.11.3 = Counter32: 49636
.1.3.6.1.2.1.2.2.1.11.4 = Counter32: 0
.1.3.6.1.2.1.2.2.1.11.5 = Counter32: 0
.1.3.6.1.2.1.2.2.1.12.1 = Counter32: 0
.1.3.6.1.2.1.2.2.1.12.2 = Counter32: 0
.1.3.6.1.2.1.2.2.1.12.3 = Counter32: 0
.1.3.6.1.2.1.2.2.1.12.4 = Counter32: 0
.1.3.6.1.2.1.2.2.1.12.5 = Counter32: 0
.1.3.6.1.2.1.2.2.1.13.1 = Counter32: 0
.1.3.6.1.2.1.2.2.1.13.2 = Counter32: 0
.1.3.6.1.2.1.2.2.1.13.3 = Counter32: 0
.1.3.6.1.2.1.2.2.1.13.4 = Counter32: 0
.1.3.6.1.2.1.2.2.1.13.5 = Counter32: 0
.1.3.6.1.2.1.2.2.1.14.1 = Counter32: 0
.1.3.6.1.2.1.2.2.1.14.2 = Counter32: 0
.1.3.6.1.2.1.2.2.1.14.3 = Counter32: 0
.1.3.6.1.2.1.2.2.1.14.4 = Counter32: 0
.1.3.6.1.2.1.2.2.1.14.5 = Counter32: 0
.1.3.6.1.2.1.2.2.1.15.1 = Counter32: 0
.1.3.6.1.2.1.2.2.1.15.2 = Counter32: 0
.1.3.6.1.2.1.2.2.1.15.3 = Counter32: 0
.1.3.6.1.2.1.2.2.1.15.4 = Counter32: 0
.1.3.6.1.2.1.2.2.1.15.5 = Counter32: 0
.1.3.6.1.2.1.2.2.1.16.1 = Counter32: 89275
.1.3.6.1.2.1.2.2.1.16.2 = Counter32: 0
.1.3.6.1.2.1.2.2.1.16.3 = Counter32: 922277
.1.3.6.1.2.1.2.2.1.16.4 = Counter32: 0
.1.3.6.1.2.1.2.2.1.16.5 = Counter32: 3648
.1.3.6.1.2.1.2.2.1.17.1 = Counter32: 1092
.1.3.6.1.2.1.2.2.1.17.2 = Counter32: 0
.1.3.6.1.2.1.2.2.1.17.3 = Counter32: 7540
.1.3.6.1.2.1.2.2.1.17.4 = Counter32: 0
.1.3.6.1.2.1.2.2.1.17.5 = Counter32: 17
.1.3.6.1.2.1.2.2.1.18.1 = Counter32: 0
.1.3.6.1.2.1.2.2.1.18.2 = Counter32: 0
.1.3.6.1.2.1.2.2.1.18.3 = Counter32: 0
.1.3.6.1.2.1.2.2.1.18.4 = Counter32: 0
.1.3.6.1.2.1.2.2.1.18.5 = Counter32: 0
.1.3.6.1.2.1.2.2.1.19.1 = Counter32: 0
.1.3.6.1.2.1.2.2.1.19.2 = Counter32: 0
.1.3.6.1.2.1.2.2.1.19.3 = Counter32: 0
.1.3.6.1.2.1.2.2.1.19.4 = Counter32: 0
.1.3.6.1.2.1.2.2.1.19.5 = Counter32: 0
.1.3.6.1.2.1.2.2.1.20.1 = Counter32: 0
.1.3.6.1.2.1.2.2.1.20.2 = Counter32: 0
.1.3.6.1.2.1.2.2.1.20.3 = Counter32: 0
.1.3.6.1.2.1.2.2.1.20.4 = Counter32: 0
.1.3.6.1.2.1.2.2.1.20.5 = Counter32: 0
.1.3.6.1.2.1.2.2.1.21.1 = Gauge32: 0
.1.3.6.1.2.1.2.2.1.21.2 = Gauge32: 0
.1.3.6.1.2.1.2.2.1.21.3 = Gauge32: 0
.1.3.6.1.2.1.2.2.1.21.4 = Gauge32: 0
.1.3.6.1.2.1.2.2.1.21.5 = Gauge32: 0
.1.3.6.1.2.1.2.2.1.22.1 = OID: .0.0
.1.3.6.1.2.1.2.2.1.22.2 = OID: .0.0
.1.3.6.1.2.1.2.2.1.22.3 = OID: .0.0
.1.3.6.1.2.1.2.2.1.22.4 = OID: .0.0
.1.3.6.1.2.1.2.2.1.22.5 = OID: .0.0
.1.3.6.1.2.1.25.1.1.0 = Timeticks: (8232423) 22:52:04.23
.1.3.6.1.2.1.25.1.1.0 = No more variables left in this MIB View (It is past the end of the MIB tree)

作为练习,尝试使用表 1-1 和 1-2 识别一些列出的 oid,并找出它们的含义。

从 Python 查询 SNMP 设备

现在,我们已经对 SNMP 有了足够的了解,可以开始在我们自己的管理系统上工作了,该系统将定期查询已配置的系统。首先,让我们指定我们将在应用中使用的配置。

配置应用

正如我们已经知道的,我们需要每次检查可用的以下信息:

  • 运行 SNMP 代理软件的系统的 IP 地址或可解析域名
  • 将用于通过代理软件进行身份验证的只读社区字符串
  • OID 节点的数值表示

因为简单,我们将使用 Windows INI 风格的配置文件。Python 默认包含一个配置解析模块,所以使用起来也很方便。(第九章非常详细的讨论了 ConfigParser 模块;有关模块的更多信息,请参考该章。)

让我们回到应用的配置文件。没有必要为我们将要查询的每个 SNMP 对象重复系统信息,所以我们可以在单独的部分定义每个系统参数一次,然后在每个检查部分引用系统 ID。check 部分定义了 OID 节点标识符字符串和简短描述,如清单 1-2 所示。使用下面列表中的内容创建一个名为 snmp-manage.cfg 的配置文件;不要忘记相应地修改 IP 和安全细节。

清单 1-2 。M 管理系统配置文件

[system_1]
description=My Laptop
address=192.168.1.68
port=161
communityro=public

[check_1]
description=WLAN incoming traffic
oid=1.3.6.1.2.1.2.2.1.10.3
system=system_1

[check_2]
description=WLAN incoming traffic
oid=1.3.6.1.2.1.2.2.1.16.3
system=system_1

确保系统和检查部分 id 是唯一的,否则可能会得到不可预知的结果。

我们将用两个方法创建一个 SnmpManager 类,一个用于添加系统,另一个用于添加检查。由于支票包含系统 ID 字符串,它将自动分配给该特定系统。在清单 1-3 中,你可以看到类定义和初始化部分,它读入配置,遍历各个部分,并相应地更新类对象。创建一个名为 snmp-manage.py 的文件,其内容如下所示。我们将继续在脚本中添加新的特性。

清单 1-3 。读取和存储配置

import sys
from ConfigParser import SafeConfigParser

class SnmpManager:
    def __init__(self):
        self.systems = {}

    def add_system(self, id, descr, addr, port, comm_ro):
        self.systems[id] = {'description' : descr,
                            'address'     : addr,
                            'port'        : int(port),
                            'communityro' : comm_ro,
                            'checks'      : {}
                           }

    def add_check(self, id, oid, descr, system):
        oid_tuple = tuple([int(i) for i in oid.split('.')])
        self.systems[system]['checks'][id] = {'description': descr,
                                              'oid'        : oid_tuple,
                                             }

def main(conf_file=""):
    if not conf_file:
        sys.exit(-1)
    config = SafeConfigParser()
    config.read(conf_file)
    snmp_manager = SnmpManager()
    for system in [s for s in config.sections() if s.startswith('system')]:
        snmp_manager.add_system(system,
                                config.get(system, 'description'),
                                config.get(system, 'address'),
                                config.get(system, 'port'),
                                config.get(system, 'communityro'))
    for check in [c for c in config.sections() if c.startswith('check')]:
        snmp_manager.add_check(check,
                               config.get(check, 'oid'),
                               config.get(check, 'description'),
                               config.get(check, 'system'))

if __name__ == '__main__':
    main(conf_file='snmp-manager.cfg')

正如您在示例中看到的,在继续检查部分之前,我们首先必须遍历系统部分并更新对象。

Image 注意这个顺序很重要,因为如果我们试图为一个还没有插入的系统添加检查,我们会得到一个字典索引错误。

还要注意,我们将 OID 字符串转换为整数元组。在本节的后面,您将会看到为什么我们必须这样做。配置文件已加载,我们准备好对已配置的设备运行 SNMP 查询。

使用 PySNMP 库

在这个项目中,我们将使用 PySNMP 库,它是用纯 Python 实现的,不依赖于任何预编译的库。pysnmp 包适用于大多数 Linux 发行版,可以使用标准发行版包管理器安装。除了 pysnmp,您还需要 ASN.1 库,它由 pysnmp 使用,也可以作为 Linux 发行包的一部分获得。例如,在 Fedora 系统上,您可以使用以下命令安装 pysnmp 模块:

$ sudo yum install pysnmp
$ sudo yum install python-pyasn1

或者,您可以使用 Python 包管理器(PiP) 来安装这个库:

$ sudo pip install pysnmp
$ sudo pip install pyasn1

如果你没有可用的 pip 命令,你可以从pypi.python.org/pypi/pip下载并安装这个工具。我们也会在后面的章节中用到它。

PySNMP 库将 SNMP 处理的所有复杂性隐藏在具有简单 API 的单个类后面。您所要做的就是创建一个 CommandGenerator 类的实例。该类可从 py snmp . entity . RFC 3413 . oneliner . cmdgen 模块中获得,并实现大多数标准 SNMP 协议命令:getCmd()、setCmd()和 nextCmd()。让我们更详细地看一下每一项。

SNMP GET 命令

我们将要讨论的所有命令都遵循相同的调用模式:导入模块,创建 CommandGenerator 类的实例,创建三个必需的参数(身份验证对象、传输目标对象和参数列表),最后调用适当的方法。该方法返回一个包含错误指示符(如果有错误)和结果对象的元组。

在清单 1-4 中,我们使用标准 SNMP OID(1 . 3 . 6 . 1 . 2 . 1 . 1 . 0)查询一台远程 Linux 机器。

清单 1-4 。SNMP GET 命令的一个例子

>>> from pysnmp.entity.rfc3413.oneliner import cmdgen
>>> cg = cmdgen.CommandGenerator()
>>> comm_data = cmdgen.CommunityData('my-manager', 'public')
>>> transport = cmdgen.UdpTransportTarget(('192.168.1.68', 161))
>>> variables = (1, 3, 6, 1, 2, 1, 1, 1, 0)
>>> errIndication, errStatus, errIndex, result = cg.getCmd(comm_data, transport, variables)
>>> print errIndication
None
>>> print errStatus
0
>>> print errIndex
0
>>> print result
[(ObjectName('1.3.6.1.2.1.1.1.0'), OctetString('Linux fedolin.example.com
 2.6.32.11-99.fc12.i686 #1 SMP Mon Apr 5 16:32:08 EDT 2010 i686'))]
>>>

让我们更仔细地看一些步骤。当我们启动社区数据对象时,我们提供了两个字符串——社区字符串(第二个参数)和代理或管理器安全名称字符串;在大多数情况下,这可以是任何字符串。可选参数指定要使用的 SNMP 版本(默认为 SNMP v2c)。如果您必须查询版本 1 设备,请使用以下命令:

>>> comm_data = cmdgen.CommunityData('my-manager', 'public', mpModel=0)

传输对象由包含完全限定的域名或 IP 地址字符串和整数端口号的元组初始化。

最后一个参数是 OID,表示为所有节点 ID 的元组,这些节点 ID 组成了我们正在查询的 OID。因此,当我们读取配置项时,我们必须将点分隔的字符串转换为元组。

最后,我们调用 API 命令 getCmd(),它实现了 SNMP GET 命令,并传递这三个对象作为它的参数。该命令返回一个元组,元组的每个元素在表 1-3 中描述。

表 1-3 。CommandGenerator 返回对象

|

元组元素

|

描述

根除 如果该字符串不为空,则表明 SNMP 引擎出错。
错误状态 如果此元素评估为真,则表明 SNMP 通信中有错误;产生错误的对象由 errIndex 元素指示。
ehrindex 如果 errStatus 指示发生了错误,则该字段可用于查找导致错误的 SNMP 对象。结果数组中的对象位置是 errIndex-1。
结果 此元素包含所有返回的 SNMP 对象元素的列表。每个元素都是一个包含对象名和对象值的元组。

SNMP SET 命令

SNMP SET 命令在 PySNMP 中被映射到 setCmd()方法调用。所有参数都相同;唯一的区别是 variables 部分现在包含一个元组:OID 和新值。让我们试着用这个命令来改变一个只读对象;清单 1-5 显示了命令行序列。

清单 1-5 。SNMP SET 命令的示例

>>> from pysnmp.entity.rfc3413.oneliner import cmdgen
>>> from pysnmp.proto import rfc1902
>>> cg = cmdgen.CommandGenerator()
>>> comm_data = cmdgen.CommunityData('my-manager', 'public')
>>> transport = cmdgen.UdpTransportTarget(('192.168.1.68', 161))
>>> variables = ((1, 3, 6, 1, 2, 1, 1, 1, 0), rfc1902.OctetString('new system description'))
>>> errIndication, errStatus, errIndex, result = cg.setCmd(comm_data, transport,
variables)
>>> print errIndication
None
>>> print errStatus
6
>>> print errIndex
1
>>> print errStatus.prettyPrint()
noAccess(6)
>>> print result
[(ObjectName('1.3.6.1.2.1.1.1.0'), OctetString('new system description'))]
>>>

这里发生的情况是,我们试图写入一个只读对象,这导致了一个错误。这个例子中有趣的是我们如何格式化参数。你必须把字符串转换成 SNMP 对象类型;否则;它们不能作为有效的论点。因此,字符串必须封装在 OctetString 类的实例中。如果需要转换为其他 SNMP 类型,可以使用 rfc1902 模块的其他方法;这些方法包括 Bits()、Counter32()、Counter64()、Gauge32()、Integer()、Integer32()、IpAddress()、OctetString()、Opaque()、TimeTicks()和 Unsigned32()。如果需要将字符串转换为特定类型的对象,可以使用这些类名。

SNMP GETNEXT 命令

SNMP GETNEXT 命令 实现为 nextCmd()方法。语法和用法与 getCmd()相同;唯一的区别是结果是一个对象列表,这些对象是指定 OID 节点的直接子节点。

让我们使用这个命令来查询 SNMP 系统 OID (1.3.6.1.2.1.1)的所有直接子节点对象;清单 1-6 展示了运行中的 nextCmd()方法。

清单 1-6 。SNMP GETNEXT 命令的示例

>>> from pysnmp.entity.rfc3413.oneliner import cmdgen
>>> cg = cmdgen.CommandGenerator()
>>> comm_data = cmdgen.CommunityData('my-manager', 'public')
>>> transport = cmdgen.UdpTransportTarget(('192.168.1.68', 161))
>>> variables = (1, 3, 6, 1, 2, 1, 1)
>>> errIndication, errStatus, errIndex, result = cg.nextCmd(comm_data, transport, variables)
>>> print errIndication
requestTimedOut
>>> errIndication, errStatus, errIndex, result = cg.nextCmd(comm_data, transport, variables)
>>> print errIndication
None
>>> print errStatus
0
>>> print errIndex
0
>>> for object in result:
...  print object
...
[(ObjectName('1.3.6.1.2.1.1.1.0'), OctetString('Linux fedolin.example.com
 2.6.32.11-99.fc12.i686 #1 SMP Mon Apr 5 16:32:08 EDT 2010 i686'))]
[(ObjectName('1.3.6.1.2.1.1.2.0'), ObjectIdentifier('1.3.6.1.4.1.8072.3.2.10'))]
[(ObjectName('1.3.6.1.2.1.1.3.0'), TimeTicks('340496'))]
[(ObjectName('1.3.6.1.2.1.1.4.0'), OctetString('Administrator (admin@example.com)'))]
[(ObjectName('1.3.6.1.2.1.1.5.0'), OctetString('fedolin.example.com'))]
[(ObjectName('1.3.6.1.2.1.1.6.0'), OctetString('MyLocation, MyOrganization, MyStreet, MyCity, MyCountry'))]
[(ObjectName('1.3.6.1.2.1.1.8.0'), TimeTicks('3'))]
[(ObjectName('1.3.6.1.2.1.1.9.1.2.1'), ObjectIdentifier('1.3.6.1.6.3.10.3.1.1'))]
[(ObjectName('1.3.6.1.2.1.1.9.1.2.2'), ObjectIdentifier('1.3.6.1.6.3.11.3.1.1'))]
[(ObjectName('1.3.6.1.2.1.1.9.1.2.3'), ObjectIdentifier('1.3.6.1.6.3.15.2.1.1'))]
[(ObjectName('1.3.6.1.2.1.1.9.1.2.4'), ObjectIdentifier('1.3.6.1.6.3.1'))]
[(ObjectName('1.3.6.1.2.1.1.9.1.2.5'), ObjectIdentifier('1.3.6.1.2.1.49'))]
[(ObjectName('1.3.6.1.2.1.1.9.1.2.6'), ObjectIdentifier('1.3.6.1.2.1.4'))]
[(ObjectName('1.3.6.1.2.1.1.9.1.2.7'), ObjectIdentifier('1.3.6.1.2.1.50'))]
[(ObjectName('1.3.6.1.2.1.1.9.1.2.8'), ObjectIdentifier('1.3.6.1.6.3.16.2.2.1'))]
[(ObjectName('1.3.6.1.2.1.1.9.1.3.1'), OctetString('The SNMP Management Architecture MIB.'))]
[(ObjectName('1.3.6.1.2.1.1.9.1.3.2'), OctetString('The MIB for Message Processing and Dispatching.'))]
[(ObjectName('1.3.6.1.2.1.1.9.1.3.3'), OctetString('The management information
 definitions for the SNMP User-based Security Model.'))]
[(ObjectName('1.3.6.1.2.1.1.9.1.3.4'), OctetString('The MIB module for SNMPv2 entities'))]
[(ObjectName('1.3.6.1.2.1.1.9.1.3.5'), OctetString('The MIB module for managing TCP
 implementations'))]
[(ObjectName('1.3.6.1.2.1.1.9.1.3.6'), OctetString('The MIB module for managing IP
 and ICMP implementations'))]
[(ObjectName('1.3.6.1.2.1.1.9.1.3.7'), OctetString('The MIB module for managing UDP
 implementations'))]
[(ObjectName('1.3.6.1.2.1.1.9.1.3.8'), OctetString('View-based Access Control Model for SNMP.'))]
[(ObjectName('1.3.6.1.2.1.1.9.1.4.1'), TimeTicks('3'))]
[(ObjectName('1.3.6.1.2.1.1.9.1.4.2'), TimeTicks('3'))]
[(ObjectName('1.3.6.1.2.1.1.9.1.4.3'), TimeTicks('3'))]
[(ObjectName('1.3.6.1.2.1.1.9.1.4.4'), TimeTicks('3'))]
[(ObjectName('1.3.6.1.2.1.1.9.1.4.5'), TimeTicks('3'))]
[(ObjectName('1.3.6.1.2.1.1.9.1.4.6'), TimeTicks('3'))]
[(ObjectName('1.3.6.1.2.1.1.9.1.4.7'), TimeTicks('3'))]
[(ObjectName('1.3.6.1.2.1.1.9.1.4.8'), TimeTicks('3'))]
>>>

如您所见,结果与命令行工具 snmpwalk 产生的结果相同,它使用相同的技术来检索 SNMP OID 子树。

实现 SNMP 读取功能

让我们在应用中实现读取功能。工作流程如下:我们需要遍历列表中的所有系统,对于每个系统,我们遍历所有已定义的检查。对于每个检查,我们将执行 SNMP GET 命令,并将结果存储在相同的数据结构中。

出于调试和测试的目的,我们将添加一些打印语句来验证应用是否按预期工作。稍后,我们将用 RRDTool 数据库存储命令替换这些打印语句。我准备把这个方法叫做 query_all_systems() 。清单 1-7 显示了您想要添加到之前创建的 snmp-manager.py 文件中的代码。

清单 1-7 。查询所有已定义的 SNMP 对象

def query_all_systems(self):
    cg = cmdgen.CommandGenerator()
    for system in self.systems.values():
        comm_data = cmdgen.CommunityData('my-manager', system['communityro'])
        transport = cmdgen.UdpTransportTarget((system['address'], system['port']))
        for check in system['checks'].values():
            oid = check['oid']
            errInd, errStatus, errIdx, result = cg.getCmd(comm_data, transport, oid)
            if not errInd and not errStatus:
                print "%s/%s -> %s" % (system['description'],
                                       check['description'],
                                       str(result[0][1]))

如果您运行该工具,您将得到类似于以下的结果(假设您正确地将您的配置指向响应 SNMP 查询的工作设备):

$ ./snmp-manager.py
My Laptop/WLAN outgoing traffic -> 1060698
My Laptop/WLAN incoming traffic -> 14305766

现在,我们准备将所有这些数据写入 RRDTool 数据库。

使用 RRDTool 存储数据

RRDTool 是由 Tobias Oetiker 开发的应用,它已经成为图形化监控数据的事实上的标准。RRDTool 生成的图形用于许多不同的监控工具,比如 Nagios、Cacti 等等。在这一节中,我们将看看 RRTool 数据库的结构和应用本身。我们将讨论循环数据库的细节,如何向其添加新数据,以及稍后如何检索它。我们还将了解数据绘制命令和技术。最后,我们将把 RRDTool 数据库与我们的应用集成起来。

RRDTool 简介

正如我所提到的,RRDTool 提供了三个不同的功能。首先,它作为一个数据库管理系统,允许您以自己的数据库格式存储和检索数据。它还执行复杂的数据操作任务,如数据重采样和速率计算。最后,它允许您创建包含来自各种源数据库的数据的复杂图表。

让我们先来看看循环数据库结构。很抱歉,在本节中您会遇到大量的缩写词,但是在这里提到它们是很重要的,因为它们都在 RRDTool 的配置中使用,所以熟悉它们是至关重要的。

RRD 不同于传统数据库的第一个特性是数据库的大小有限。这意味着数据库大小在初始化时是已知的,并且大小永远不会改变。新记录会覆盖旧数据,这个过程会一遍又一遍地重复。图 1-3 显示了 RRD 的简化版本,以帮助您可视化其结构。

9781484202180_Fig01-03.jpg

图 1-3 。RRD 的结构

假设我们已经初始化了一个能够容纳 12 条记录的数据库,每条记录都在自己的单元格中。当数据库为空时,我们从向 1 号单元格写入数据开始。我们还用上次写入数据的单元格的 ID 来更新指针。图 1-3 显示 6 条记录已经写入数据库(如阴影框所示)。指针在单元 6 上,因此当接收到下一个写指令时,数据库将把它写到下一个单元(单元 7)并相应地更新指针。到达最后一个单元格(单元格 12)后,该过程将从 1 号单元格重新开始。

RRD 数据存储的唯一目的是存储性能数据,因此它不需要维护不同数据表之间的复杂关系。事实上,RRD 中没有表,只有单独的数据源(DSs) 。

RRD 的最后一个重要属性是数据库引擎被设计用来存储时间序列数据,因此每条记录都需要标记时间戳。此外,当您创建一个新的数据库时,您需要指定采样率,即条目被写入数据库的速率。默认值为 300 秒或 5 分钟,但如果需要,可以覆盖该值。

存储在 RDD 中的数据称为循环档案(RRA) 。RRA 是 RRD 如此有用的原因。它允许您通过应用可用的合并功能(CF) 来合并从 DS 收集的数据。您可以指定四个 CFs(平均值、最小值、最大值和最后值)中的一个,应用于许多实际数据记录。结果存储在循环“表”中您可以在数据库中以不同的粒度存储多个 rra。例如,一个 RRA 存储最后 10 条记录的平均值,另一个存储最后 100 条记录的平均值。

当我们在接下来的部分中查看使用场景时,这些都会一起出现。

在 Python 程序中使用 RRDTool

在我们开始创建 RRDTool 数据库之前,让我们看看为 RRDTool 提供 API 的 Python 模块。我们在这一章将要使用的模块叫做 Python RRDTool,可以在sourceforge.net/projects/py-rrdtool/下载。

然而,大多数 Linux 发行版都预先打包了这个包,并且可以使用标准的包管理工具进行安装。例如,在 Fedora 系统上,您可以运行以下命令来安装 Python RRDTool 模块:

$ sudo yum install rrdtool-python

在基于 Debian 的系统上,安装命令是:

$ sudo apt-get install python-rrd

安装软件包后,您可以验证安装是否成功:

$ python
Python 2.6.2 (r262:71600, Jan 25 2010, 18:46:45)
[GCC 4.4.2 20091222 (Red Hat 4.4.2-20)] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> import rrdtool
>>> rrdtool.__version__
'$Revision: 1.14 $'
>>>

创建循环数据库

让我们从创建一个简单的数据库开始。我们将要创建的数据库将有一个数据源,这是一个简单的递增计数器:计数器值随着时间的推移而增加。这种计数器的典型例子是通过接口传输的字节。读数每 5 分钟进行一次。

我们还将定义两个 rra。一种是对单个读数求平均值,这有效地指示 RRDTool 存储实际值,另一种是对六次测量求平均值。以下是创建此数据库的命令行工具语法示例:

$ rrdtool create interface.rrd \
> DS:packets:COUNTER:600:U:U \
> RRA:AVERAGE:0.5:1:288 \
> RRA:AVERAGE:0.5:6:336

类似地,您可以使用 Python 模块来创建相同的数据库:

>>> import rrdtool
>>> rrdtool.create('interface.rrd',
...                'DS:packets:COUNTER:600:U:U',
...                'RRA:AVERAGE:0.5:1:288',
...                'RRA:AVERAGE:0.5:6:336')
>>>

DS(数据源)的结构定义行是:

DS:*<name>*:*<DS type>*:*<heartbeat>*:*<lower limit>*:*<upper limit>*

名称字段是您命名这个特定数据源的名称。由于 RRD 允许您存储来自多个数据源的数据,因此您必须为每个数据源提供一个唯一的名称,以便以后访问。如果您需要定义多个数据源,只需添加另一个 DS 行。

DS type(或 data source type)字段指示将向该数据源提供什么类型的数据。有四种类型可用:计数器、仪表、衍生和绝对:

  • 计数器类型表示测量值随时间增加。为了计算速率,RRDTool 从当前测量值中减去最后一个值,然后除以测量步长(或采样速率)以获得速率数值。如果结果是负数,它需要补偿计数器翻转。典型用途是监控不断增加的计数器,例如通过接口传输的总字节数。
  • 派生类型类似于 COUNTER,但是它也允许负速率。您可以使用此类型来检查对您的站点的传入 HTTP 请求的速率。如果图表在零线以上,这意味着你收到越来越多的请求。如果它低于零线,这意味着你的网站变得不那么受欢迎。
  • 绝对类型表示每次读取测量值时计数器都会复位。而对于计数器和派生类型,RRDTool 在除以时间段之前从当前测量值中减去上一次测量值,ABSOLUTE 告诉它不要执行减法运算。您可以在计数器上使用它,计数器的重置速率与您进行测量的速率相同。例如,您可以每 15 分钟测量一次系统平均负载(过去 15 分钟内)读数。这将代表平均系统负载的变化率。
  • 仪表类型表示测量的是速率值,不需要进行计算。例如,当前 CPU 使用率和温度传感器读数是仪表类型的良好候选。

心跳值表示在将其重置为未知状态之前,允许读数进入的时间。RRDTool 允许数据丢失,但它不做任何假设,如果没有收到数据,它使用特殊值 unknown。在我们的示例中,我们将心跳设置为 600,这意味着数据库在声明下一个测量未知之前,要等待两次读取(记住,步长是 300)。

最后两个字段指示可以从数据源接收的最小值和最大值。如果您指定了这些,超出该范围的任何内容都将被自动标记为未知。

RRA 定义结构是:

RRA:*<consolidation function>*:*<XFiles factor>*:*<dataset>*:*<samples>*

合并函数定义了将对数据集的值应用什么数学函数。数据集参数是从数据源接收的最后一个数据集测量值。在我们的示例中,我们有两个 rra,一个在数据集中只有一个读数,另一个在数据集中有六个测量值。可用的合并函数有平均值、最小值、最大值和最后值:

  • AVERAGE 指示 RRDTool 计算数据集的平均值并存储它。
  • 最小值和最大值从数据集中选择最小值或最大值并存储。
  • 最后一个指示使用数据集中的最后一个条目。

XFiles 因子值 s 如何确定数据集的多少百分比可以有未知值,合并函数计算仍将执行。例如,如果设置为 0.5 (50%),则六次测量中有三次可能是未知的,仍将计算数据集的平均值。如果错过四个读数,则不执行计算,未知值存储在 RRA 中。将此值设置为 0 (0%的遗漏余量),只有当数据集中的所有数据点都可用时,才会执行计算。将该设置保持在 0.5 似乎是一种常见的做法。

正如已经讨论过的,数据集参数指示有多少记录将参与合并函数计算。

最后, samples 告诉 RRDTool 应该保留多少 CF 结果。所以,回到我们的例子,数字 288 告诉 RRDTool 保存 288 条记录。因为我们每 5 分钟测量一次,所以这是 24 小时的数据(288/(60/5))。同样,数字 336 意味着我们以 30 分钟的采样率存储 7 天的数据(336/(60/30)/24)。如您所见,第二个 RRA 中的数据被重新采样;我们通过整合每六个(5 分钟)样本的数据,将采样率从 5 分钟更改为 30 分钟。

从循环数据库写入和读取数据

将数据写入 RRD 数据文件非常简单。您只需调用 update 命令,并假设您已经定义了多个数据源,按照您在创建数据库文件时指定的顺序为它提供一个数据源读数列表。每个条目之前必须有当前(或期望的)时间戳,从纪元(1970-01-01)开始以秒表示。或者,您可以使用字符 N 来表示当前时间,而不是使用实际的数字来表示时间戳。可以在一个命令中提供多个读数:

$ date +"%s"
1273008486
$ rrdtool update interface.rrd 1273008486:10
$ rrdtool update interface.rrd 1273008786:15
$ rrdtool update interface.rrd 1273009086:25
$ rrdtool update interface.rrd 1273009386:40 1273009686:60 1273009986:66
$ rrdtool update interface.rrd 1273010286:100 1273010586:160 1273010886:166

Python 替代看起来非常相似。在下面的代码中,我们将插入另外 20 条记录,指定定期间隔(300 秒)并提供生成的测量值:

>>> import rrdtool
>>> for i in range(20):
...  rrdtool.update('interface.rrd',
...                 '%d:%d' % (1273010886 + (1+i)*300, i*10+200))
...
>>>

现在让我们从 RRDTool 数据库取回数据:

$ rrdtool fetch interface.rrd AVERAGE
                        packets

1272983100: -nan
[...]
1273008600: -nan
1273008900: 2.3000000000e-02
1273009200: 3.9666666667e-02
1273009500: 5.6333333333e-02
1273009800: 4.8933333333e-02
1273010100: 5.5466666667e-02
1273010400: 1.4626666667e-01
1273010700: 1.3160000000e-01
1273011000: 5.5466666667e-02
1273011300: 8.2933333333e-02
1273011600: 3.3333333333e-02
1273011900: 3.3333333333e-02
1273012200: 3.3333333333e-02
1273012500: 3.3333333333e-02
1273012800: 3.3333333333e-02
1273013100: 3.3333333333e-02
1273013400: 3.3333333333e-02
1273013700: 3.3333333333e-02
1273014000: 3.3333333333e-02
1273014300: 3.3333333333e-02
1273014600: 3.3333333333e-02
1273014900: 3.3333333333e-02
1273015200: 3.3333333333e-02
1273015500: 3.3333333333e-02
1273015800: 3.3333333333e-02
1273016100: 3.3333333333e-02
1273016400: 3.3333333333e-02
1273016700: 3.3333333333e-02
1273017000: -nan
[...]
1273069500: -nan

如果您计算条目的数量,您将看到它与我们在数据库上执行的更新数量相匹配。这意味着我们看到的是最高分辨率的结果——在我们的例子中,是每条记录一个样本。以最大分辨率显示结果是默认行为,但是您可以通过指定分辨率标志来选择另一种分辨率(前提是它具有匹配的 RRA)。请记住,在 RRA 定义中,分辨率必须用秒数来表示,而不是用样本数来表示。因此,在我们的示例中,下一个可用分辨率是 6(样本)* 300(秒/样本)= 1800(秒):

$ rrdtool fetch interface.rrd AVERAGE -r 1800
                        packets

[...]
1273010400: 6.1611111111e-02
1273012200: 6.1666666667e-02
1273014000: 3.3333333333e-02
1273015800: 3.3333333333e-02
1273017600: 3.3333333333e-02
[...]

现在,您可能已经注意到,我们的 Python 应用插入的记录会产生存储在数据库中的相同数字。为什么会这样?计数器肯定在增加吗?记住,RRDTool 总是存储速率而不是实际值。因此,您在结果数据集中看到的数字显示了值变化的速度。因为 Python 应用以稳定的速率生成新的测量值(值之间的差异总是相同的),所以速率数字总是相同的。

这个数字到底是什么意思?我们知道,每插入一条新记录,生成的值就增加 10,但是 fetch 命令输出的值是 3.333333333 e-02。(对于许多人来说,这可能看起来有点混乱,但这只是值 0.0333(3)的另一种表示法。)那是哪里来的?在讨论不同的数据源类型时,我提到过 RRDTool 获取两个数据点值之间的差值,并将其除以采样间隔的秒数。默认的采样间隔是 300 秒,因此计算出的速率是 10/300 = 0.0333(3),这是写入 RRDTool 数据库的值。换句话说,这意味着我们的计数器平均每秒增加 0.0333(3)。请记住,所有速率测量值都存储为每秒的变化。在本节的后面,我们将把这个值转换成可读性更强的形式。

下面是使用 Python 模块方法调用检索数据的方法:

>>> for i in rrdtool.fetch('interface.rrd', 'AVERAGE'): print i
...
(1272984300, 1273071000, 300)
('packets',)
[(None,), [...], (None,), (0.023,), (0.03966666666666667,), (0.056333333333333339,),
 (0.048933333333333336,), (0.055466666666666671,), (0.14626666666666666,),
 (0.13160000000000002,),  (0.055466666666666671,), (0.082933333333333331,),
 (0.033333333333333333,), (0.033333333333333333,), (0.033333333333333333,),
 (0.033333333333333333,), (0.033333333333333333,), (0.033333333333333333,),
 (0.033333333333333333,), (0.033333333333333333,), (0.033333333333333333,),
 (0.033333333333333333,), (0.033333333333333333,), (0.033333333333333333,),
 (0.033333333333333333,), (0.033333333333333333,), (0.033333333333333333,),
 (0.033333333333333333,), (0.033333333333333333,), (0.033333333333333333,),
 (None,), [...] (None,)]
>>>

结果是一个三元组:数据集信息数据源列表结果数组:

  • 数据集信息是另一个元组,它有三个值:开始和结束时间戳以及采样率。
  • 数据源列表简单地列出了存储在 RRDTool 数据库中并由您的查询返回的所有变量。
  • 结果数组包含存储在 RRD 中的实际值。每个条目都是一个元组,包含被查询的每个变量的值。在我们的示例数据库中,我们只有一个变量;因此,元组只包含一个元素。如果无法计算该值(未知),则返回 Python 的 None 对象。

如果需要,您还可以更改采样率:

>>> rrdtool.fetch('interface.rrd', 'AVERAGE', '-r', '1800')
((1272983400, 1273071600, 1800), ('packets',), [(None,), [...] (None,),
 (0.06161111111111111,), (0.061666666666666668,), (0.033333333333333333,),
 (0.033333333333333333,), (0.033333333333333333,), (None,), [...] (None,)])

Image 注意到目前为止,您应该知道命令行工具语法是如何映射到 Python 模块调用的,您总是调用模块方法,它总是以 RRDTool 函数名命名,例如 fetch、update 等等。该函数的参数是一个任意的值列表。在这种情况下,值是命令行上用空格分隔的任何字符串。基本上,你可以把命令行作为参数列表复制到函数中。显然,您需要用引号将每个单独的字符串括起来,并用逗号分隔它们。为了节省空间和避免混乱,在接下来的例子中,我将只提供命令行语法,您应该能够很容易地将它映射到 Python 语法。

用 RRDTool 绘制图形

用 RRDTool 绘制图表真的很容易,而且绘图是这个工具如此受欢迎的原因之一。最简单的形式是,图形生成命令非常类似于数据获取命令:

$ rrdtool graph packets.png --start 1273008600 --end 1273016400 --step 300\
> DEF:packetrate=interface.rrd:packets:AVERAGE \
> LINE2:packetrate#c0c0c0

即使没有任何额外的修改,结果也是一个非常专业的性能图,正如你在图 1-4 中看到的。

9781484202180_Fig01-04.jpg

图 1-4 。RRDTool 生成的简单图形

首先,我们来看看命令参数。所有绘图命令都以结果图像的文件名和可选的时间比例值开始。您还可以提供分辨率设置,如果没有指定,将默认为最详细的分辨率。这类似于 fetch 命令中的-r 选项。分辨率用秒表示。

下一行(尽管您可以在一行中键入整个 graph 命令)是选择器行,它从 RRDTool 数据库中选择数据集。选择器语句的格式为:

DEF:<*selector name*>=<*rrd file*>:<*data source*>:<*consolidation function*>

选择器名称参数是一个任意字符串,用于命名结果数据集。可以把它看作一个数组变量,它存储来自 RRDTool 数据库的结果。您可以根据需要使用任意多的选择器语句,但是您至少需要一个来产生输出。

rrd 文件数据源合并函数变量的组合准确定义了需要选择的数据。如您所见,这种语法完全解耦了数据存储和数据表示功能。您可以将来自不同 RRDTool 数据库的结果包含在同一个图表中,并以您喜欢的任何方式组合它们。图形数据可以在不同的监控服务器上收集,也可以在单个图像上组合和呈现。

这个选择器语句可以用可选参数扩展,这些参数指定每个数据源的开始、停止和解析值。格式如下,该字符串应该附加在选择器语句的末尾。每个元素都是可选的,您可以使用它们的任意组合。

:step=<*step value*>:start=<*start time value*>:end=<*end time value*>

因此,我们可以将前面的绘图命令重写为:

$ rrdtool graph packets.png \
> DEF:packetrate=interface.rrd:packets:AVERAGE:step=300:
start=1273008600:end=1273016400 \
> LINE2:packetrate#c0c0c0

命令行的最后一个元素是告诉 RRDTool 如何绘制数据的语句。数据绘图命令的基本语法是:

*<PLOT TYPE>*:*<selector name><#color>*:*<legend>*

最广泛使用的绘图类型是线和面积。LINE 关键字后面可以跟一个浮点数来表示线条的宽度。AREA 关键字指示 RRDTool 绘制直线,并填充 x 轴和图形线之间的区域。

两个命令后面都跟有选择器名称,为绘图功能提供数据。颜色值被写成 HTML 颜色格式字符串。您还可以指定一个可选参数图例,它告诉 RRDTool 需要在图形的底部显示一个匹配颜色的小矩形,后跟图例字符串。

就像使用数据选择器语句一样,您可以根据需要使用任意多的绘图语句,但是您至少需要定义一个来生成图形。

让我们再看一下我们制作的图表。RRDTool 很方便地将时间戳打印在 x 轴上,但是 y 轴上显示的是什么呢?这看起来像是以米为单位的测量值,但事实上代表“毫”,或者是数值的千分之一。因此,打印出来的值与存储在 RRDTool 数据库中的值完全相同。然而,这并不直观。我们看不到数据包的大小,数据传输速率可能很低,也可能很高,具体取决于传输的数据包大小。让我们假设我们正在处理 4KB 的数据包。在这种情况下,合理的解决方案是将信息表示为每秒位数。要将每秒的数据包转换为每秒的位数,我们需要做什么?因为速率间隔不变(在这两种情况下,我们测量的是每秒的数量),所以只需要乘以数据包值,首先乘以 4096(数据包中的字节数),然后乘以 8(一个字节中的位数)。

RRDTool graph 命令允许定义将应用于任何数据选择器变量的数据转换函数。在我们的示例中,我们将使用以下语句将每秒的数据包转换为每秒的字节数:

$ rrdtool graph kbps.png --step 300 --start 1273105800 --end 1273114200 \
DEF:packetrate=interface.rrd:packets:AVERAGE \
CDEF:kbps=packetrate,4096,\*,8,\* \
LINE2:kbps#c0c0c0

如果你看这个命令产生的图像,你会看到它的形状与图 1-4 中的相同,但是 y 轴标签已经改变。它们不再显示“毫”值——所有的数字都标记为 k 。这更有意义,因为大多数人更愿意看到 3kbps,而不是每秒 100 毫包。

Image 注意你可能想知道为什么计算字符串看起来很奇怪。首先,我必须对*字符进行转义,以便将它们传递给 rrdtool 应用,而不被 shell 处理。公式本身必须用逆波兰符号来写,在这里你指定第一个参数,然后第二个参数,然后是你想要执行的函数。然后可以将结果用作第一个参数。在我的例子中,我有效地告诉应用“取 packetrate 和 4096 并相乘,取结果和 8 并相乘。”这需要一些时间来调整,但是一旦你掌握了它,用 RPN 表达公式就真的很容易了。

最后,我们需要为 y 轴添加一个标签,为我们正在绘制的值添加一个图例,并为图表本身添加标题,从而使图表更加直观。此示例还演示了如何更改生成图像的大小:

$ rrdtool graph packets.png --step 300 --start 1273105800 --end 1273114200 \
--width 500 --height 200 \
--title "Primary Interface" --vertical-label "Kbp/s" \
DEF:packetrate=interface.rrd:packets:AVERAGE \
CDEF:kbps=packetrate,4096,\*,8,\* \
AREA:kbps#c0c0c0:"Data transfer rate"

结果如图 1-5 所示。

9781484202180_Fig01-05.jpg

图 1-5 。格式化 RRDTool 生成的图形

对 RRDTool 的介绍只涵盖了它的基本用途。然而,该应用附带了一个非常广泛的 API,允许您更改图形的几乎每个方面。我推荐阅读 RRDTool 文档,可以在oss.oetiker.ch/rrdtool/doc/获得。

将 RRDTool 与监控解决方案集成

我们现在准备将 RRDTool 调用集成到我们的监控应用中,这样我们从支持 SNMP 的设备中收集的信息就会被记录下来,并可随时用于报告。虽然可以在一个 RRDTool 数据库中维护多个数据源,但建议只对密切相关的测量进行维护。例如,如果您正在监视一个多处理器系统,并且想要存储每个 CPU 的中断计数,那么将它们全部存储在一个数据文件中是非常合理的。相比之下,将内存利用率和温度传感器读数混合在一起可能不是一个好主意,因为您可能决定需要一个更高的采样速率来进行一次测量,并且您不能在不影响其他数据源的情况下轻易改变这一点。

在我们的系统中,SNMP OIDs 是在配置文件中提供的,应用完全不知道它们是否相关。因此,我们将每个读数存储在一个单独的数据文件中。每个数据文件将获得与检查部分名称相同的名称(例如,check_1.rrd),因此要确保它们是唯一的。

我们还必须扩展配置文件,以便每次检查都定义所需的采样率。最后,每次调用应用时,它都会检查数据存储文件是否存在,并创建任何缺失的文件。这减轻了应用用户为每个新支票手动创建文件的负担。您可以在清单 1-8 中看到更新后的脚本。

清单 1-8 。用 SNMP 数据更新 RRD

#!/usr/bin/env python

import sys, os.path, time
from ConfigParser import SafeConfigParser
from pysnmp.entity.rfc3413.oneliner import cmdgen
import rrdtool

class SnmpManager:
    def __init__(self):
        self.systems = {}
        self.databases_initialised = False

    def add_system(self, id, descr, addr, port, comm_ro):
        self.systems[id] = {'description' : descr,
                            'address'     : addr,
                            'port'        : int(port),
                            'communityro' : comm_ro,
                            'checks'      : {}
                           }

    def add_check(self, id, oid, descr, system, sampling_rate):
        oid_tuple = tuple([int(i) for i in oid.split('.')])
        self.systems[system]['checks'][id] = {'description': descr,
                                              'oid'        : oid_tuple,
                                              'result'     : None,
                                              'sampling_rate' : sampling_rate
                                             }

    def query_all_systems(self):
        if not self.databases_initialised:
            self.initialise_databases()
            self.databases_initialised = True
        cg = cmdgen.CommandGenerator()
        for system in self.systems.values():
            comm_data = cmdgen.CommunityData('my-manager', system['communityro'])
            transport = cmdgen.UdpTransportTarget((system['address'],
            system['port']))
            for key, check in system['checks'].iteritems():
                oid = check['oid']
                errInd, errStatus, errIdx, result = cg.getCmd(comm_data, transport,
                oid)

                if not errInd and not errStatus:
                    file_name = "%s.rrd" % key
                    rrdtool.update(file_name,
                                   "%d:%d" % (int(time.time(),),
                                              float(result[0][1]),)
                                  )

    def initialise_databases(self):
        for system in self.systems.values():
            for check in system['checks']:
                data_file = "%s.rrd" % check
                if not os.path.isfile(data_file):
                    print data_file, 'does not exist'
                    rrdtool.create(data_file,
                                   "DS:%s:COUNTER:%s:U:U" % (check,
                                    system['checks'][check]['sampling_rate']),
                                   "RRA:AVERAGE:0.5:1:288",)

def main(conf_file=""):
    if not conf_file:
        sys.exit(-1)
    config = SafeConfigParser()
    config.read(conf_file)
    snmp_manager = SnmpManager()
    for system in [s for s in config.sections() if s.startswith('system')]:
        snmp_manager.add_system(system,
                                config.get(system, 'description'),
                                config.get(system, 'address'),
                                config.get(system, 'port'),
                                config.get(system, 'communityro'))
    for check in [c for c in config.sections() if c.startswith('check')]:
        snmp_manager.add_check(check,
                               config.get(check, 'oid'),
                               config.get(check, 'description'),
                               config.get(check, 'system'),
                               config.get(check, 'sampling_rate'))
    snmp_manager.query_all_systems()

if __name__ == '__main__':
    main(conf_file='snmp-manager.cfg')

脚本现在可以进行监控了。您可以将它添加到 Linux cron 调度程序中,并让它每 5 分钟执行一次。如果你配置了一些采样率大于 5 分钟的检查,也不用担心;RRDTool 足够聪明,能够以创建数据库时指定的采样率存储测量值。下面是我用来生成示例结果的一个示例 cronjob 条目,我们将在下一节中使用它:

$ crontab -l
*/5 * * * * (cd /home/rytis/snmp-monitor/; ./snmp-manager.py > log.txt)

用 Jinja2 模板系统创建网页

在本章的最后一节,我们将创建另一个脚本,这个脚本生成一个包含图表的简单网页结构。主入口页面列出了按系统分组的所有可用检查,并链接到“检查详细信息”页面。当用户导航到该页面时,她将看到由 RRDTool 生成的图形以及支票本身的一些细节(比如支票描述和 OID)。现在,这看起来相对容易实现,大多数人会简单地开始编写 Python 脚本,使用 print 语句来生成 HTML 页面。尽管这种方法看似可行,但在大多数情况下,它很快就会变得难以控制。功能代码通常与内容生成代码混合在一起,添加新功能通常会破坏一切,这反过来又会导致花费数小时来调试应用。

这个问题的解决方案是使用一个模板框架,它允许将应用逻辑从表示中分离出来。模板系统的基本原理很简单:您编写代码来执行计算和其他与内容无关的任务,例如从数据库或其他来源检索数据。然后将这些信息以及使用这些信息的模板的名称传递给模板框架。在模板代码中,您将所有 HTML 格式文本与动态数据(之前生成的)放在一起。然后,框架解析模板中的简单处理语句(比如迭代循环和逻辑测试语句)并生成结果。你可以在图 1-6 中看到该加工的基本流程。

9781484202180_Fig01-06.jpg

图 1-6 。模板框架中的数据流

这样,您的应用代码就没有任何内容生成语句,并且更易于维护。该模板可以访问提供给它的所有变量,但它看起来更像一个 HTML 页面,将其加载到 web 浏览器中通常会产生可接受的结果。因此,你甚至可以请一个专门的 web 开发人员为你创建模板,因为没有必要知道任何 Python 来修改它们。

我将使用一个名为 Jinja 的模板框架,它的语法与 Django web 框架使用的语法非常相似。我们还将在本书中讨论 Django 框架,因此使用类似的模板语言是有意义的。Jinja 框架也被广泛使用,大多数 Linux 发行版都包含 Jinja 包。在 Fedora 系统上,您可以使用以下命令安装它:

$ sudo yum install python-jinja2

或者,您可以使用 PiP 应用来安装它:

$ sudo pip install Jinja2

你也可以从官网获得 Jinja2 框架的最新开发版本:jinja.pocoo.org/

Image 提示确保安装的是 Jinja2 而不是更早的版本——Jinja。Jinja2 提供了一种扩展的模板语言,并被积极开发和支持。

用 Jinja2 加载模板文件

Jinja2 被设计用于 web 框架,因此具有非常广泛的 API。它的大部分功能在只生成几页的简单应用中没有使用,所以我将跳过这些功能,因为它们可以成为自己的一本书的主题。在这一节中,我将向您展示如何加载一个模板,向它传递一些变量,并保存结果。这三个函数是您在应用中大多数时间会用到的。有关 Jinja2 API 的更多文档,请参考jinja.pocoo.org/docs/api/

Jinja2 框架使用所谓的加载器类来加载模板文件。这些可以从各种来源加载,但最有可能的是它们存储在一个文件系统中。负责加载存储在文件系统上的模板的 loader 类称为 jinja2。FileSystemLoader 。它接受一个字符串或字符串列表,这些字符串是可以找到模板文件的文件系统上的路径名:

from jinja2 import FileSystemLoader

loader1 = FileSystemLoader('/path/to/your/templates')
loader2 = FileSystemLoader(['/templates1/', '/teamplates2/']

一旦初始化了 loader 类,就可以创建 jinja2 的实例了。环境等级。这个类是框架的核心部分,用于存储配置变量,访问模板(通过加载器实例),并将变量传递给模板对象。初始化环境时,如果要访问外部存储的模板,必须传递 loader 对象:

from jinja2 import Environment, FileSystemLoader

loader = FileSystemLoader('/path/to/your/templates')
env = Environment(loader=loader)

创建环境后,您可以加载模板并呈现输出。首先调用 get_template 方法,它返回一个与模板文件相关联的模板对象。接下来调用模板对象的方法 render,该方法处理模板内容(由之前初始化的 loader 类加载)。结果是经过处理的模板代码,可以写入文件。您必须将所有变量作为字典传递给模板。字典键是模板中可用变量的名称。字典值可以是您想要传递给模板的任何 Python 对象。

from jinja2 import Environment, FileSystemLoader

loader = FileSystemLoader('/path/to/your/templates')
env = Environment(loader=loader)
template = env.get_template('template.tpl')
r_file = open('index.html', 'w')
name = 'John'
age = 30
result = template.render({'name': name, 'age': age})
r_file.write(result)
r_file.close()

Jinja2 模板语言

Jinja2 模板语言非常广泛且功能丰富。然而,基本概念非常简单,语言非常类似于 Python。要获得完整的语言描述,请在jinja.pocoo.org/2/documentation/templates查看官方的 Jinja2 模板语言定义。

模板语句必须转义;任何未被转义的内容都不会被处理,并将在呈现过程后被逐字返回。

有两种类型的语言分隔符:

  • 变量访问分隔符,表示对变量的引用:{{...}}
  • 语句执行分隔符,它告诉框架分隔符内的语句是函数指令:{%...%}

访问变量

正如您已经知道的,模板通过作为字典键给出的名称来识别变量。假设传递给呈现函数的字典是这样的:

{'name': name, 'age': age}

模板中的以下语句可以访问这些变量,如下所示:

{{ name }} / {{ age }}

传递给模板的对象可以是任何 Python 对象,模板可以使用相同的 Python 语法访问它。例如,您可以访问字典或数组元素。假设以下呈现调用:

person = {'name': 'John', 'age': 30}
r = t.render({'person': person})

然后,您可以使用以下语法来访问模板中的字典元素:

{{ person.name }} / {{ person.age }}

流量控制语句

流控制语句允许您对变量进行检查,并选择模板的不同部分进行相应的渲染。在生成表格或列表等结构时,也可以使用这些语句来重复模板的一部分。

森林...in loop 语句可以遍历这些 iterable Python 对象,一次返回一个元素:

Available products</h1>
<ul>
{% for item in products %}
  <li>{{ item }}</li>
{% endfor %}
</ul>

一旦进入循环,将定义以下特殊变量。您可以使用它们来检查您在循环中的确切位置。

。循环属性变量

|

可变的

|

描述

循环索引 循环的当前迭代。索引从 1 开始;使用 loop.index0 进行从 0 开始的计数。
walk.revindex 类似于 loop.index,但从循环结束时开始计算迭代次数。
循环优先 如果第一次迭代。
loop.last 如果最后一次迭代。
循环长度 序列中元素的总数。

逻辑测试函数 if 用作布尔检查,类似于 Python if 语句的使用:

{% if items %}
  <ul>
  {% for item in items %}
    {% if item.for_sale %}
      <li>{{ item.description }}</li>
    {% endif %}
  {% endfor %}
  </ul>
{% else %}
  There are no items
{% endif %}

Jinja2 框架还允许模板继承。也就是说,您可以定义一个基本模板并从它继承。然后,每个子模板用适当的内容重新定义主模板文件中的块。例如,父模板(parent.tpl)可能如下所示:

<head>
  <title> MyCompany – {% block title %}Default title{% endblock %}</title>
</head>
<html>
{% block content %}
There is no content
{% endblock %}
</html>

然后,子模板从基础模板继承,并使用自己的内容扩展块:

{% extends 'parent.tpl' %}
{% block title %}My Title{%endblock %}
{% block content %}
My content %}
{% endblock %}

生成网站页面

生成页面和图像的脚本使用与检查脚本相同的配置文件。它遍历所有系统和检查部分,并构建一个字典树。整个树被传递给索引生成函数,该函数又将其传递给索引模板。

每项检查的详细信息由一个单独的函数生成。同一个函数还调用 rrdtool 方法来绘制图形。所有文件都保存在网站的根目录中,根目录在全局变量中定义,但可以在函数调用中被否决。你可以在清单 1-9 中看到完整的脚本。

清单 1-9 。生成网站页面

#!/usr/bin/env python

from jinja2 import Environment, FileSystemLoader
from ConfigParser import SafeConfigParser
import rrdtool
import sys

WEBSITE_ROOT = '/home/rytis/public_html/snmp-monitor/'

def generate_index(systems, env, website_root):
    template = env.get_template('index.tpl')
    f = open("%s/index.html" % website_root, 'w')
    f.write(template.render({'systems': systems}))
    f.close()

def generate_details(system, env, website_root):
    template = env.get_template('details.tpl')
    for check_name, check_obj in system['checks'].iteritems():
        rrdtool.graph ("%s/%s.png" % (website_root, check_name),
                      '--title', "%s" % check_obj['description'],
                      "DEF:data=%(name)s.rrd:%(name)s:AVERAGE" % {'name':
                                                                   check_name},
                      'AREA:data#0c0c0c')
        f = open("%s/%s.html" % (website_root, str(check_name)), 'w')
        f.write(template.render({'check': check_obj, 'name': check_name}))
        f.close()

def generate_website(conf_file="", website_root=WEBSITE_ROOT):
    if not conf_file:
        sys.exit(-1)
    config = SafeConfigParser()
    config.read(conf_file)
    loader = FileSystemLoader('.')
    env = Environment(loader=loader)
    systems = {}
    for system in [s for s in config.sections() if s.startswith('system')]:
        systems[system] = {'description': config.get(system, 'description'),
                           'address'    : config.get(system, 'address'),
                           'port'       : config.get(system, 'port'),
                           'checks'     : {}
                          }
    for check in [c for c in config.sections() if c.startswith('check')]:
        systems[config.get(check, 'system')]['checks'][check] = {
                                        'oid'        : config.get(check, 'oid'),
                                        'description': config.get(check,
                                                                 'description'),
                                                                }

    generate_index(systems, env, website_root)
    for system in systems.values():
        generate_details(system, env, website_root)

if __name__ == '__main__':
    generate_website(conf_file='snmp-manager.cfg')

大部分表示逻辑都在模板中实现,比如检查变量是否被定义以及遍历列表项。在清单 1-10 中,我们首先定义了索引模板,它负责生成 index.html 页面的内容。如您所知,在这一页中,我们将列出所有已定义的系统,以及每个系统可用的检查的完整列表。

清单 1-10 。索引模板

System checks</h1>
{% if systems %}
  {% for system in systems %}
    <h2>{{ systems[system].description }}</h2>
    <p>{{ systems[system].address }}:{{ systems[system].port }}</p>
    {% if systems[system].checks %}
      The following checks are available:
      <ul>
        {% for check in systems[system].checks %}
          <li><a href="{{ check }}.html">
              {{ systems[system].checks[check].description }}</a></li>
        {% endfor %}
      </ul>
    {% else %}
      There are no checks defined for this system
    {% endif %}
  {% endfor %}
{% else %}
  No system configuration available
{% endif %}

该模板生成的网页呈现如图图 1-7 所示。

9781484202180_Fig01-07.jpg

图 1-7 。浏览器窗口中的索引网页

每个列表项的链接指向一个单独的支票详细信息网页。每个这样的网页都有一个检查部分名称,如 check_1.html。这些页面是从 details.tpl 模板生成的:

{{ check.description }}</h1>
<p>OID: {{ check.oid }}</p>
<img src="{{ name }}.png" />

该模板链接到由 RRDTool graph 方法生成的图形图像。图 1-8 显示了结果页面。

9781484202180_Fig01-08.jpg

图 1-8 。带有图形的 SNMP 详细信息

摘要

在这一章中,我们建立了一个简单的设备监控系统。在此过程中,您了解了 SNMP,以及用于 Python 的数据收集和绘图库——RRD tool 和 Jinja2 模板系统。需要记住的要点:

  • 大多数网络连接设备使用 SNMP 公开其内部计数器。
  • 每个这样的计数器都有一个分配给它的专用对象 ID。
  • 对象 id 被组织成树状结构,其中树状分支被分配给不同的组织。
  • RRDTool 是一个库,允许您存储、检索和绘制网络统计数据。
  • RRD 数据库是一个循环数据库,这意味着它有一个固定的大小,新记录在插入时会将旧记录推出。
  • 如果您生成 web 页面,请使用 Jinja2 模板系统,它允许您将功能代码从表示中分离出来。

二、使用 SOAP API 管理设备

在本章中,我们将构建一个命令行工具来查询和管理 Citrix Netscaler 负载平衡器设备。这些设备通过 SOAP API 公开管理服务,这是 web 服务之间通信的标准方式之一。

什么是 SOAP API?

SOAP 代表简单对象访问协议。开发和创建该协议是为了用作在各种 web 服务之间交换结构化信息的机制。许多知名公司通过 SOAP API 接口公开他们的服务;例如,Amazon 允许使用 SOAP API 调用来控制他们的弹性计算云(EC2)和简单存储系统(S3)服务。

使用 SOAP 查询,用户可以创建虚拟机、启动和停止服务、操作远程分布式文件系统上的数据,以及执行产品搜索。支持 SOAP 的应用通过发送 SOAP“消息”来交换信息每条消息都是 XML 格式的文档。SOAP 协议位于其他传输协议之上,如 HTTP、HTTPS、SMTP 等。理论上,您可以发送封装在电子邮件消息(SMTP)中的 SOAP 请求,但是最广泛使用的 SOAP 传输机制不是普通 HTTP 就是 HTTPS (SSL 加密 HTTP)。

由于 XML 的冗长,SOAP 不是最有效的通信方式,因为即使是最小和最简单的消息也会变得非常大和神秘。SOAP 定义了一组用于构造应用层协议消息的规则。最常用的协议之一是 RPC(远程过程调用)。因此,通常所说的 SOAP API 实际上是一个 SOAP 编码的 RPC API。RPC 定义了 web 服务如何相互通信和交互。当与 RPC 一起使用时,SOAP 可以执行请求-响应对话。

SOAP 的最大优势在于它不是特定于语言或平台的,因此用不同语言编写并在不同平台上运行的应用可以很容易地相互通信。它还是一个开放标准协议,这意味着有许多库为开发支持 SOAP 的应用和服务提供支持。

SOAP 消息的结构

每个 SOAP 消息包含以下元素:

  • *信封。*该元素将 XML 文档标识为 SOAP 消息。它还定义了在 SOAP 消息中使用的名称空间。
  • 消息头。该元素位于 Envelope 元素中,包含特定于应用的信息。例如,认证细节通常存储在 Header 元素中。该元素还可能包含不是发送给消息接收方的数据,而是发送给重新传输 SOAP 通信的中间设备的数据。
  • 消息体。该元素位于 SOAP Envelope 元素中,包含请求和响应信息。邮件正文元素是必填字段,不能省略。该元素包含打算发送给消息接收者的实际数据。
  • 故障元素。这个可选元素位于消息体中。如果存在,它包含错误代码、可读的错误描述、错误发生的原因以及任何特定于应用的详细信息。

清单 2-1 是一个框架 SOAP 消息的例子。

清单 2-1 。一条简单的 SOAP 消息

<?xml version="1.0"?>
<soap:Envelope
    xmlns:soap="http://www.w3.org/2001/12/soap-envelope"
    soap:encodingStyle="http://www.w3.org/2001/12/soap-encoding">

    <soap:Header>
        [...]
    </soap:Header>

    <soap:Body>
        [...]
        <soap:Fault>
            [...]
        </soap:Fault>
    </soap:Body>

</soap:Envelope>

用 SOAP 请求服务

假设我们有两个 web 服务:web 服务 A 和 Web 服务 b。每个 Web 服务都是运行在专用服务器上的应用。我们还假设服务 B 实现了一个简单的客户查找服务,它接受一个表示客户标识符的整数,并在一个数组中返回两个字段:客户的姓名和联系电话号码。服务 A 是一个应用,它充当客户端并向服务 b 请求详细信息。

当服务 A(发送者)想要找出关于客户的细节时,它构造清单 2-2 中所示的 SOAP 消息,并将其作为 HTTP POST 请求发送给服务 B。

清单 2-2 。SOAP 请求消息

<?xml version="1.0" encoding="UTF-8" ?>
  <SOAP-ENV:Envelope
   SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"
   xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"
   xmlns:SOAP-ENC="http://schemas.xmlsoap.org/soap/encoding/"
   xmlns:xsi="http://www.w3.org/1999/XMLSchema-instance"
   xmlns:xsd="http://www.w3.org/1999/XMLSchema">
   <SOAP-ENV:Body>
      <ns1:getCustomerDetails
       xmlns:ns1="urn:CustomerSoapServices">
         <param1 xsi:type="xsd:int">213307</param1>
      </ns1:getCustomerDetails>
   </SOAP-ENV:Body>
  </SOAP-ENV:Envelope>

接下来,服务 B(服务器)执行查找,将结果封装在 SOAP 消息中,并将其发送回来。响应消息(清单 2-3 )作为对原始 POST 请求的 HTTP 响应。

清单 2-3 。SOAP 响应消息

<?xml version="1.0" encoding="UTF-8" ?>
  <SOAP-ENV:Envelope
   xmlns:xsi="http://www.w3.org/1999/XMLSchema-instance"
   xmlns:xsd="http://www.w3.org/1999/XMLSchema"
   xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/">
   <SOAP-ENV:Body>
      <ns1: getCustomerDetailsResponse
      xmlns:ns1="urn:CustomerSoapServices"
      SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
         <return
         xmlns:ns2="http://schemas.xmlsoap.org/soap/encoding/"
         xsi:type="ns2:Array"
         ns2:arrayType="xsd:string[2]">
            <item xsi:type="xsd:string">John Palmer</item>
            <item xsi:type="xsd:string">+44-(0)306-999-0033</item>
         </return>
      </ns1:getCustomerDetailsResponse>
   </SOAP-ENV:Body>
  </SOAP-ENV:Envelope>

从例子中可以看出,SOAP 对话非常健谈。所有这些额外的信息(包括名称空间定义和字段数据类型)都是必需的,这样客户机和服务器都知道如何解析和验证数据。

查找有关 WSDL 可用服务的信息

如果您仔细查看前面的示例,您会注意到客户端请求了以下方法:getCustomerDetails。我们如何知道哪些方法或服务是可用的?此外,我们如何找出方法需要什么参数,以及什么方法将返回它的响应消息?

找到这些信息最简单的方法是从 web 服务的 WSDL (Web 服务描述语言)文档中找到。这个 XML 格式的文档描述了与 web 服务相关的各种细节,例如:

  • 使用的通信协议(部分)
  • 接受和发送的消息(部分)
  • web 服务公开的方法(部分)
  • 使用的数据类型(部分)

这些部分中的每一个都可能包含多个条目,这取决于 web 服务正在做什么。例如,清单 2-4 是翻译服务的简化 WSDL 定义。在这个例子中,我们假想的自动翻译器接受一个文本字符串作为输入参数,并返回一个翻译后的字符串作为结果。我们有两个远程方法,分别叫做 translateEnglishToFrench 和 translateFrenchToEnglish。它们都使用相同的请求和响应数据类型。

清单 2-4 。WSDL 定义的一个例子

<message name="translateRequest">
   <part name="term" type="xs:string"/>
</message>

<message name="translateResponse">
   <part name="value" type="xs:string"/>
</message>

<portType name="languageTranslations">
   <operation name="translateEnglishToFrench">
      <input message="translateRequest"/>
      <output message="translateRequest"/>
   </operation>
   <operation name="translateFrenchToEnglish">
      <input message="translateRequest"/>
      <output message="translateRequest"/>
   </operation>
</portType>

<binding type="languageTranslations" name="bn">
   <soap:binding style="document" transport="http://schemas.xmlsoap.org/soap/http"/>
   <operation>
      <soap:operation soapAction="http://example.com/translateEnglishToFrench"
                     name="trEn2Fr"/>
      <input><soap:body use="literal"/></input>
      <output><soap:body use="literal"/></output>
   </operation>
   <operation>

      <soap:operation soapAction="http://example.com/translateFrenchToEnglish"
      name="trFr2En"/>
      <input><soap:body use="literal"/></input>
      <output><soap:body use="literal"/></output>
   </operation>
</binding>

绑定部分定义了用于访问每个公开的方法的访问 URL。每个操作都有一个用于引用它的名称。

Python 中的 SOAP 支持

在支持 SOAP 协议方面,Python 不如其他语言幸运。过去有一些计划和项目试图在 Python 中实现 SOAP 库,但是大多数都被放弃了。目前最活跃和最成熟的项目是 Zolera SOAP 基础设施(ZSI)。

在大多数 Linux 发行版中,这个包被命名为 python-ZSI,可以从发行版的默认包管理器中安装。如果你选择从源代码安装 ZSI 软件包,它可以在pywebsvcs.sourceforge.net/找到。

使用 ZSI 从 Python 访问 SOAP 服务有两种方式:

  • 服务方法可以通过 ServiceProxy 类访问,它是 ZSI 库的一部分。创建该类的对象时,所有远程函数都可以作为该对象实例的方法使用。这是访问所有服务的一种便捷方式,但是它要求您手动生成类型代码和定义名称空间,这是一项繁重的工作。
  • 可以使用 wsdl2py 工具访问 SOAP 接口。该工具读取服务的 WSDL 定义并生成两个模块:一个包含类型代码信息,另一个包含服务方法。

我更喜欢使用第二种方法,因为它让我不必定义类型代码和记忆名称空间。使用 ServiceProxy 类时,用户必须显式定义过程的命名空间。此外,请求对象的类型代码必须与 WSDL 中定义的类型兼容,并且这种类型代码必须手工编写,对于使用复杂数据结构的服务来说,这可能是一个真正的难题。

将 WSDL 模式转换为 Python 助手模块

到目前为止,您已经了解了 SOAP 协议(一种基于 XML 的协议,它定义了如何封装消息)、RPC 的通信方式(客户端发送一条消息,告诉服务器它希望服务器执行什么功能,服务器用一条包含远程功能生成的数据的消息来响应),以及 WSDL (这种语言定义了哪些方法可用以及在请求/响应中使用哪些数据类型)。

我们还决定生成两个助手模块:一个包含远程方法,另一个定义数据结构,我们将使用 ZSI 图书馆提供的 wsdl2py 工具。

我将编写一个工具来管理 Citrix Netscaler 负载平衡器设备。这些设备提供了两个 web 服务接口:

  • 统计 Web 服务 。该服务提供查询负载平衡器所有功能方面的统计信息的方法,如虚拟服务器、服务、VLAN 配置等。在 Netscaler 操作系统的 8.1 版中,有 44 个对象可用于收集性能信息。
    ** 配置 Web 服务 。此服务允许您更改设备配置和执行维护任务,例如启用/禁用服务器和服务。在同一个 8.1 NS 操作系统中,有 2,364 个可通过 SOAP 接口访问的可配置参数。*

**可以通过访问192.168.1.1/ws/download . pl找到 WSDL 位置和其他有用信息的链接,例如 API 文档和 SNMP 对象定义,其中 192 . 168 . 1 . 1 需要替换为您正在使用的 Netscaler 负载平衡器的 IP 地址。在本章中,我将使用 192.168.1.1 作为我的 Netscaler 设备的 IP。也可以从主管理屏幕获得下载页面的链接。

我提供了以下 WSDL 网址,因为它们不太可能会改变:

使用 wsdl2py 脚本非常简单;如果不需要特殊的配置,我们只需要提供 WSDL 文档的位置,它就会自动生成方法和数据类型模块。不需要额外的用户输入。wsdl2py 工具可以从 web 位置获取 wsdl 文档,或者我们可以提供一个文件名,它将解析该文件。

在下面的示例中,我们将把 wsdl2py 脚本直接指向 Netscaler 负载平衡器上的 WSDL URL。

$ wsdl2py --url http://192.168.1.1/api/NSStat.wsdl

如果脚本可以联系目标服务器,并且它接收的 XML 文档不包含错误,那么它将不会产生任何消息,并且会自动创建两个 Python 包。

Image 注意如果您检索了一个 WSDL 文件并将其存储在本地,您可以使用- file 标志并提供 WSDL 文件的文件名。这将指示 wsdl2py 解析本地存储的文件。

此时,我们已经运行了脚本,wsdl2py 已经生成了以下两个模块:

  • NSStat_services.py:这个模块包含 Locator 类,用于连接到每个远程可用方法的服务和类。
  • NSStat_services_types.py:这个文件很少被直接使用。它是从前面的模块导入的,包含我们的 web 服务使用的每种数据类型的类定义。它确实包含了一些有用的信息,我们稍后在创建请求和检查来自 web 服务的响应时会用到这些信息。

wsdl2py 工具还有其他选项可用于生成服务器助手模块。有了这些模块,我们就可以实现我们自己版本的 web 服务,该服务公开相同的接口,并理解由我们的 WSDL 文件定义的相同协议,但这超出了我们这里项目的范围。

为我们的负载平衡器工具定义需求

到目前为止,我们只研究了 SOAP 协议和提供 SOAP 支持的 Python 库,它们创建了我们将用来访问 Netscaler web 服务的助手模块。我们还没有编写实际的代码来执行 SOAP 调用,并利用它接收到的信息做一些有用的事情,但是在我们深入有趣的事情(即编写代码)之前,让我们后退一步,决定几件重要的事情:

  • 我们希望我们的工具做什么?
  • 我们将如何构建我们的代码?

因为这些问题听起来简单,看起来显而易见,所以经常被忽视。这通常会导致糟糕的编写和难以管理的代码。

如果我们不确切地知道我们想要我们的代码做什么,我们就有可能使我们的代码过于简单或者过于复杂。换句话说,我们可能会写几行简单的代码,但实际上我们希望它更通用,可以为其他人或其他项目重用。因此,我们不断增加新的功能,创造各种变通方法,代码变成了一个不可维护的庞然大物。这种过度复杂也是危险的,因为我们可能会发现自己花了几天、几周(如果我们真的有创造力,甚至几个月)来编写复杂的数据结构,而几行抛弃式的原型代码会更有效。所以,在开始之前仔细考虑你想做什么,但是也不要在上面花太多时间;在大多数情况下,系统管理员不需要开发全面的应用,因此对他们来说事情就容易多了。

在开始之前,我发现考虑以下几点,并为每一点写几个简单的段落,就足以作为一个粗略的指南和需求规范文档:

  • 定义基本要求
  • 定义代码结构
  • 决定可配置和可更改的项目
  • 定义错误处理和日志记录

基本要求

我们列出了我们希望这个工具做什么的列表;简单的陈述,如“我想要...去做……”非常有效,因为我们不追求正式的需求规格。下面的例子说明了这种思维方式:

  • 我们希望我们的应用收集以下统计信息:
    • CPU 和内存利用率
    • 系统概述:请求速率、数据速率、已建立的连接
    • 所有虚拟服务器概述:启动/关闭以及每个虚拟服务器中有哪些服务关闭
  • 我们希望我们的应用能够:
    • 禁用/启用任何可用虚拟服务器的所有服务
    • 禁用/启用任何单独的服务
    • 禁用/启用任何一组服务(可能跨越多个虚拟服务器)
  • 我们希望在其他脚本中重用已定义的函数。
  • 代码应该易于修改和添加新功能。

代码结构

现在我们已经定义了对工具的需求,我们可以清楚地看到如何组织我们的脚本:

  • 所有进行 SOAP 调用的函数都需要在单独的模块中定义。这个模块可以由不同的脚本导入,这些脚本可以使用相同的功能。
  • 最好定义一个包含访问 web 服务的方法的类,这样任何人都可以简单地继承这个类并扩展附加功能。
  • 该工具将由两个不同的部分组成,一部分用于读取统计数据,另一部分用于控制服务。

将其映射到源代码,我们将拥有以下文件和模块:

  1. 我们自己的库 NSLib.py,它将包含以下内容的定义:
    • NSLibError 异常类。每当我们遇到任何不可恢复的问题,我们将提出这个例外。
    • NSSoapApi 类。这是根类,实现所有 Netscaler SOAP API 对象共有的方法:初始化和登录。
    • NSStatApi 类。这继承了 NSSoapApi 类。该类实现所有处理统计信息收集和监视的方法。它只执行 WSDL 统计局定义的呼叫。
    • NSConfigApi 类。这继承了 NSSoapApi 类。该类实现处理负载平衡器配置的所有方法,并调用由配置 WSDL 定义的方法。
  2. 这个文件使用 NSLib 中的 NSStatApi,并且是实现我们的统计信息收集任务的实际脚本。这是我们将从命令行调用的脚本。
  3. 该文件使用 NSLib 中的 NSConfigApi,并且是实现我们的负载平衡器配置任务的实际脚本。这是我们将从命令行调用的脚本。
  4. 这是我们的配置脚本,包含了与负载平衡器建立通信所需的所有定义。参见下面的详细描述。

配置

我们可能需要管理和监控不止一个负载平衡器。因此,我们将创建一个简单的配置文件来标识它们中的每一个,并且还包含登录详细信息和服务组。

因为它将被对脚本相当熟悉的人使用,而不是针对简单用户的脚本,所以我们可以创建一个带有静态定义变量的 Python 文件并导入它。清单 2-5 是我将在本章中使用的例子。

清单 2-5 。包含负载平衡器详细信息的配置文件

#!/usr/bin/env python

netscalers = {
                'default': 'primary',

                'primary':    {
                                 'USERNAME': 'nstest',
                                 'PASSWORD': 'nstest',
                                 'NS_ADDR' : '192.168.1.1',
                                 'groups': {},
                              },

                 'secondary': {
                                 'USERNAME': 'nstest',
                                 'PASSWORD': 'nstest',
                                 'NS_ADDR' : '192.168.1.2',
                                 'groups': {},
                              },
             }

如您所见,我们这里有两个 netscalers,主要和辅助,具有不同的 IP 地址(您也可以有不同的用户和密码)。尚未定义服务组;我们可以在以后需要时添加它们。

在我们的工具中,如果我们需要访问这些配置数据,我们将如清单 2-6 所示检索它。

清单 2-6 。访问配置数据

import ns_config as config

# to access configuration of the 'primary' loadbalancer
username_pri = config.netscalers['primary']['USERNAME']

# to access configuration of the default loadbalancer
default_lb = config.netscalers['default']
username_def = config.netscalers[default_lb]['USERNAME']

使用 SOAP API 访问 Citrix Netscaler 负载平衡器

我们需要找到服务地点。对于 web 服务,它几乎总是一个 URL。然而,我们并不真的需要知道 URL,因为我们有特殊的 Locator 类,它一旦被初始化就创建一个绑定对象,我们用它来访问 SOAP 服务。

但是,在继续之前,我们需要解决 Netscaler 的 WSDL 的一个小问题。

修复 Citrix Netscaler WSDL 的问题

我们生成的服务访问助手模块(NSStat_services.py)中的定位器类如清单 2-7 中的所示定义。

清单 2-7 。定位器类定义

# Locator
class NSStatServiceLocator:
    NSStatPort_address = "http://netscaler_ip/soap/"
    def getNSStatPortAddress(self):
        return NSStatServiceLocator.NSStatPort_address
    def getNSStatPort(self, url=None, **kw):
        return NSStatBindingSOAP(url or NSStatServiceLocator.NSStatPort_address, **kw)

这显然是错误的,因为服务主机名 netscaler_ip 不是有效的 ip 地址(应该是 192.168.1.1),也不是有效的域名。Citrix Netscaler 一直以这种方式公开其端点,因此我们只能假设这是设计使然。

以这种方式发生的一个可能的原因是,某人可能想要使用相同的 WSDL 信息连同他的软件来管理多个负载平衡设备,因此从他将要管理的每一个设备中检索和编译 WSDL 是不切实际的。因此,由 API 用户/开发者用正确的地址替换这个地址。Netscaler SOAP API 手册中的所有示例都以相同的方式运行,它们忽略此变量,而不是传递自己的设置。

因此,我们必须修改 NSStatPort_address 变量,将 netscaler_ip 替换为我们设备的 ip 地址。幸运的是,这只能做一次;WSDL 不会经常改变(通常只在主要的操作系统升级期间)。清单 2-8 显示了修改。

清单 2-8 。手动修改定位器类

 # Locator
class NSStatServiceLocator:
    NSStatPort_address = "http://192.168.1.1/soap/"
    def getNSStatPortAddress(self):
        return NSStatServiceLocator.NSStatPort_address
    def getNSStatPort(self, url=None, **kw):
        return NSStatBindingSOAP(url or NSStatServiceLocator.NSStatPort_address, **kw)

Image 注意如果您不希望修改模块,您将在本章后面看到解决这个问题的另一种方法,通过这种方法,您可以在定位器对象的初始化过程中指定服务端点。

创建连接对象

我们已经准备好并修复了助手模块,所以最后我们将通过 SOAP API 与我们的负载平衡器进行通信。在我们继续之前,我们需要导入我们用 wsdl2py 生成的所有方法:

import NSStat_services

初始化定位器和服务访问对象非常简单,只需两行代码就可以完成。首先,我们创建实际的定位器对象,它包含关于 web 服务位置的信息:

locator = NSStat_services.NSStatServiceLocator()

然后,我们调用将返回绑定对象的方法,该对象已经用服务 URL 初始化:

soap = locator.getNSStatPort()

定位器对象只有两个方法:一个从 WSDL 读取 URL,另一个初始化并返回绑定对象。

绑定对象(在我们的示例中,初始化为变量 soap)包含我们的 web 服务上可用的所有方法(在这个实例中,是 Citrix Netscaler Statistics API)。它就像一个代理,将对象方法映射到 API 函数。

在我们继续之前,让我们看看如何修复 Netscaler 无效 URL 问题。正如您已经知道的,您可以询问定位器对象并请求一个端点 URL。还可以强制 getNSStatPort 使用自定义 URL,而不是生成的 URL。因此,我们要做的是获取 URL,用我们的负载平衡器的 IP 替换假字符串,然后用正确的 URL 生成一个绑定对象。清单 2-9 显示了代码。

清单 2-9 。替换负载平衡器地址

MY_NS_IP = '192.168.1.1'
locator = NSStat_services.NSStatServiceLocator()
bad_url = locator.getNSStatPortAddress()
good_url = re.sub('netscaler_ip', MY_NS_IP, bad_url)
soap = locator.getNSStatPort(url=good_url)

正如您所看到的,这里我使用了 getNSStatPortAddress 定位器方法来检索 URL 字符串,然后使用正则表达式对其进行修改,并用负载平衡器的 ip 替换 netscaler_ip 字符串。最后,我请求定位器用我新的(正确的)URL 创建我的 SOAP 绑定对象。

这种方法比更改自动生成的模块更加灵活。无论出于什么原因(例如升级 NS OS),如果您决定生成一个新的模块,您将会丢失所做的更改。另一种方法也要求你记住你必须修改代码。在发出请求的代码中覆盖 IP 更加明显,并且它不会干扰可能重用相同助手模块的其他工具。

因此,这是创建连接对象的一种快速方法,但是我们如何将它放入我们之前定义的所需结构中呢?请记住,我们决定创建一个具有初始化和日志记录功能的泛型类,然后从中派生出两个不同的类:一个用于统计和监控模块,另一个用于管理和配置模块。你可以看到 图 2-1 中的类继承。

9781484202180_Fig02-01.jpg

图 2-1 。类继承图

这造成了一个直接的问题,因为我们需要为每个服务使用不同的定位器对象;我们不能在 NSSoapApi 类中初始化它们,因为我们不知道需要使用哪种类型的定位器对象,Stat 还是 Config。

泛型类需要能够识别它应该使用哪个模块作为服务定位器,因此我将把 NSStatApi 或 NSConfigApi 中的模块对象作为参数传递给 NSSoapApi,NSSoapApi 将使用该参数初始化适当的定位器,并使用特定的模块调用执行登录调用。这听起来可能很复杂,但实际上并不复杂。清单 2-10 显示了实现这个的代码。

清单 2-10 。定义泛型类

class NSSoapApi(object):

    def __init__(self, module=None,
                       hostname=None,
                       username=None,
                       password=None):
        [...]
        self.username = username
        self.password = password
        self.hostname = hostname
        self.module   = module
        if self.module.__name__ == 'NSStat_services':
            [...]
            self.locator = self.module.NSStatServiceLocator()
            bad_url = self.locator.getNSStatPortAddress()
            good_url = re.sub('netscaler_ip', self.hostname, bad_url)
            self.soap = self.locator.getNSStatPort(url=good_url)
        elif self.module.__name__ == 'NSConfig_services':
            [...]
            self.locator = self.module.NSConfigServiceLocator()
            bad_url = self.locator.getNSConfigPortAddress()
            good_url = re.sub('netscaler_ip', self.hostname, bad_url)
            self.soap = self.locator.getNSConfigPort(url=good_url)
        else:
            [...]
        self.login()

    def login(self):
        [...]
        req = self.module.login()
        req._username = self.username
        req._password = self.password
        [...]
        res = self.soap.login(req)._return
        [...]

这个泛型类需要一个模块对象传递给它,因此它可以执行以下操作:

  • 从传递的模块中直接调用通用方法,如 login。
  • 根据模块的不同,调用特定的方法或引用特定于模块的类,如 NSStatServiceLocator vs NSConfigServiceLocator

我们的子类将把模块对象传递给超类,如清单 2-11 所示。

清单 2-11 。将模块对象传递给泛型类

class NSStatApi(NSSoapApi):

    def __init__(self, hostname=None, username=None, password=None):
        super(NSStatApi, self).__init__(hostname=hostname,
                                        username=username,
                                        password=password,
                                        module=NSStat_services)

class NSConfigApi(NSSoapApi):

    def __init__(self, hostname=None, username=None, password=None):
        super(NSConfigApi, self).__init__(hostname=hostname,
                                          username=username,
                                          password=password,
                                          module=NSConfig_services)

登录:我们的第一个 SOAP 调用

此时,还没有进行任何实际的 API 调用;我们所做的只是准备和初始化工作。在开始请求性能数据或进行配置更改之前,我们需要做的第一件事是向负载平衡器进行验证。因此,我们的第一个 API 调用将是登录方法。

使用生成的助手库执行 SOAP 请求总是遵循相同的模式:

  1. 创建一个请求对象。
  2. 用参数初始化请求对象;这是 SOAP 函数的参数列表。
  3. 调用表示适当 SOAP 方法的 binder 方法,并将请求对象传递给它。
  4. binder 方法返回一个 API 响应(或者在联系 web 服务失败时引发一个异常)。

正如我们已经看到的,定位器返回的绑定对象属于 NSStatBindingSOAP 类。该类的方法表示 web 服务上所有可用的函数。其中之一是登录功能,如清单 2-12 所示,我们将使用它向负载均衡器表明自己的身份。

清单 2-12 。登录方法的定义

# op: login
def login(self, request):
    if isinstance(request, login) is False:
        raise TypeError, "%s incorrect request type" % (request.__class__)
    kw = {}
    # no input wsaction
    self.binding.Send(None, None, request, soapaction="urn:NSConfigAction",
                      encodingStyle="http://schemas.xmlsoap.org/soap/encoding/",
                      **kw)
    # no output wsaction
    typecode = Struct(pname=None, ofwhat=loginResponse.typecode.ofwhat,
                      pyclass=loginResponse.typecode.pyclass)
    response = self.binding.Receive(typecode)
    return response

与 NSStatBindingSOAP 类的其他方法一样,login 方法只接受一个参数,即请求对象。

请求对象必须从 login 类构造,该类可从同一个 helper 模块获得。找出请求对象必须包含什么的最简单方法是查看它的定义;清单 2-13 显示了我们实例中的内容。

清单 2-13 。登录请求类

class login:
    def __init__(self):
        self._username = None
        self._password = None
        return

因此,当我们初始化新的请求对象时,我们必须在将它传递给绑定对象之前设置 _username 和 _password。

现在,让我们创建这些对象并进行登录 SOAP 调用。清单 2-14 显示了代码。

清单 2-14 。默认登录方法的包装

class NSSoapApi(object):
    [...]
    def login(self):
        # create request object and assign default values
        req = self.module.login()
        req._username = self.username
        req._password = self.password
        [...]
        res = self.soap.login(req)._return
        if res._rc != 0:
            # an error has occurred

与所有其他请求一样,发出 SOAP 登录调用是一个两步过程:

  1. 我们创建并初始化请求对象;这个对象包含我们将要发送给 web 服务的数据。在下面的例子中,req 是我们的登录请求对象,我们通过为登录调用设置用户名和密码来初始化它:

    req = self.module.login()
    req._username = self.username
    req._password = self.password
    
  2. 我们从绑定对象中调用适当的代理函数,并将请求对象传递给它。以下步骤被压缩成一行代码:

    1. 调用我们的绑定对象的登录方法。
    2. 传递在上一步中构造的请求对象。
    3. 阅读回复。

所有步骤完成后,res 将包含返回对象,变量在 NSStat_services_types.py 模块(或 WSDL 数据类型部分)中定义:

res = self.soap.login(req)._return

查找响应中返回的内容

我们已经知道,要想知道我们应该在对 web 服务的请求中发送什么,我们需要查看服务的 helper 模块,它包含所有请求对象的类。但是我们如何知道我们收到的回应是什么呢?

如果我们再次查看绑定类中的 login 方法,我们会发现它返回一个 loginResponse 类型的对象,如清单 2-15 所示。

清单 2-15 。绑定类的返回值

def login(self, request):
    [...]
    typecode = Struct(pname=None, ofwhat=loginResponse.typecode.ofwhat,
                      pyclass=loginResponse.typecode.pyclass)
    response = self.binding.Receive(typecode)
    return response

从 loginResponse 类(清单 2-16 )中,我们发现它只包含一个变量,_return。

清单 2-16 。LoginResponse 类的内容

class loginResponse:
    def __init__(self):
        self._return = None
        returnloginResponse.typecode =
Struct(pname=("urn:NSConfig","loginResponse"),
 ofwhat=[ns0.simpleResult_Def(pname="return", aname="_return", typed=False, encoded=None,
 minOccurs=1, maxOccurs=1, nillable=True)], pyclass=loginResponse, encoded="urn:NSConfig")

然而这还不够,因为 _return 是包含我们需要的信息的对象,我们需要找到如何引用它。由于 loginResponse 非常简单(只返回两个字段),所以它使用了一个通用的响应对象;我们通过查看 loginResponse 类的 typecode 定义中的 ofwhat 设置,从该类的 typecode 设置中发现了这一点。在以下示例中,它是突出显示的字符串:

class loginResponse:
    def __init__(self):
        self._return = None
        return
loginResponse.typecode = Struct(pname=("urn:NSConfig","loginResponse"),
  ofwhat=[ns0.simpleResult_Def(pname="return",
                               aname="_return",
                               typed=False,
                               encoded=None,
                               minOccurs=1,
                               maxOccurs=1,
                               nillable=True)],
  pyclass=loginResponse, encoded="urn:NSConfig")

更复杂的结构有以它们命名的结果对象,所以更容易找到它们,但是对于 login,我们需要在类型定义模块(NSStat_services_types.py)中寻找 simpleResult 类。这个类的定义,如清单 2-17 所示,可能看起来有点神秘,但是我们并不真的需要知道它运行的细节;只需查找 Holder 类定义。

清单 2-17 。simpleResult 的类定义

class simpleResult_Def(ZSI.TCcompound.ComplexType, TypeDefinition):
    [...]
        class Holder:
            typecode = self
            def __init__(self):
                # pyclass
                self._rc = None
                self._message = None
                return
    [...]

我将在本章后面更详细地解释如何找到复杂数据类型的对象的引用和定义;请参阅“读取系统健康数据”

我们登录后,如何维护会话?

您可能想知道在我们成功登录到我们的 web 服务后,接下来会发生什么。当其他调用不需要用户名和密码以及其他参数时,负载平衡器如何知道我们被授权进行其他调用?

一些 web 服务发回一个特殊的令牌,这个令牌是在服务器上生成的,并且与使用 API 的帐户相关联。如果是这样的话,我们就必须将这个令牌与我们发送给 web 服务的每个请求结合起来。

不过,使用 Netscaler 负载平衡器,事情就简单多了。在我们发送登录请求之后,如果我们的身份验证信息是正确的,负载均衡器将会用一个简单的“OK”消息来响应。它还会在 HTTP 头中用一个特殊的 cookie 来响应,这个 cookie 充当我们的令牌。我们只需要在向 web 服务发送后续请求时,确保在 HTTP 头中设置了这个 cookie,而不是将令牌细节合并到每个 SOAP 请求中。清单 2-18 显示了 tcpdump 命令的输出,它清楚地展示了这一点。(我省略了其他 TCP 包,去掉了无关的二进制数据,所以只显示 HTTP 和 SOAP 协议。)

清单 2-18 。HTTP 封装的 SOAP 登录请求和登录响应消息

11:11:35.283170 IP 192.168.1.10.40494 > 192.168.1.1.http: P 1:166(165) ack 1 win 5488
[...]
POST /soap/ HTTP/1.1
Host: 192.168.1.1
Accept-Encoding: identity
Content-Length: 540
Content-Type: text/xml; charset=utf-8
SOAPAction: "urn:NSConfigAction"
[...]
<SOAP-ENV:Envelope xmlns:SOAP-ENC="http://schemas.xmlsoap.org/soap/encoding/" xmlns:
SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"
 xmlns:ZSI="http://www.zolera.com/schemas/ZSI/" xmlns:xsd=
"http://www.w3.org/2001/XMLSchema" xmlns:xsi=
"http://www.w3.org/2001/XMLSchema-instance" SOAP-ENV:encodingStyle=
"http://schemas.xmlsoap.org/soap/encoding/"><SOAP-ENV:Header></SOAP-ENV:
Header><SOAP-ENV:Body xmlns:ns1="urn:NSConfig"><ns1:login><username>nstest</username>
<password>nstest</password></ns1:login></SOAP-ENV:Body></SOAP-ENV:Envelope>

11:11:35.567226 IP 192.168.1.1.http > 192.168.1.10.40494: P 1:949(948) ack 706 win 57620
[...]
HTTP/1.1 200 OK
Date: Mon, 29 Jun 2009 11:13:08 GMT
Server: Apache
Last-Modified: Mon, 29 Jun 2009 11:13:08 GMT
Status: 200 OK
Content-Length: 622
Connection: close
Set-Cookie:
NSAPI=##F0F402A6574084DB4956184C6443FEE54DD5FC1E1953E3730A5A307BBEC3;Domain=
192.168.1.1; Path=/soap
Content-Type: text/xml; charset=utf-8
<?xml version="1.0" encoding="UTF-8"?><SOAP-ENV:Envelope xmlns:SOAP-ENV=
"http://schemas.xmlsoap.org/soap/envelope/" xmlns:SOAP-ENC=
"http://schemas.xmlsoap.org/soap/encoding/" xmlns:xsi=
"http://www.w3.org/2001/XMLSchema-instanc" xmlns:xsd=
"http://www.w3.org/2001/XMLSchema" xmlns:ns=
"urn:NSConfig"><SOAP-ENV:Header></SOAP-ENV:Header><SOAP-ENV:Body SOAP-ENV:
encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" id=
"_0"><ns:loginResponse><return xsi:type=
"ns:simpleResult"><rc xsi:type="xsd:unsignedInt">0</rc><message xsi:type=
"xsd:string">Done</message></return></ns:loginResponse></SOAP-ENV:Body></SOAP-ENV:Envelope>

我们可以看到,对于登录操作的初始请求,我们发送了一个 SOAP 消息,其中我们的凭据被封装为一个 HTTP POST 请求。

响应也是 SOAP 消息,封装在 HTTP 响应中。SOAP 响应没有携带太多有用的信息;它只包含两段数据:一个数字返回代码(rc)和一个字母数字字符串(message)。当一切正常时,rc 设置为 0,message 设置为 Done。

HTTP 头包含更重要的信息:它设置了一个 cookie,我们需要将它用于其他请求:

Set-Cookie: NSAPI=##F0F402A6574084DB4956184C6443FEE54DD5FC1E1953E3730A5A307BBEC3;↲
Domain=192.168.1.1;Path=/soap

这个 cookie 值与我们在 NS 上的帐户相关联,因此 web 服务知道发送这个 cookie 的人已经通过了身份验证。

收集性能统计数据

我们已经为统计数据收集和监控工具建立了以下要求:

  • 我们希望我们的工具能够收集以下统计信息:
    • CPU 和内存利用率
    • 系统概述:请求速率、数据速率、已建立的连接
    • 所有虚拟服务器概述:启动/关闭以及每个虚拟服务器中有哪些服务关闭
  • 这些可以分为两组:
    • 系统状态(CPU、内存和请求率读数)
    • 虚拟服务器状态(虚拟服务器状态)

我们现在可以将我们的实现分成两部分,这更容易编码和测试。

读取统计数据及其返回值的 SOAP 方法

表 2-1 列出了我们的统计收集工具中使用的方法,以及每个方法的返回对象的名称和简要描述。我们将在代码中使用其中一些。(您应该能够非常容易地修改代码,并为工具添加更多的查询项。如果您发现自己需要更多关于更具体项目的详细信息,如 AAA、GSLB 或压缩,请参考 Netscaler API 文档,可从 Netscaler 管理网页下载。)

表 2-1 。统计我们示例中使用的 Web 服务方法及其返回值

|

方法

|

返回变量

|

描述

统计系统 _internaltemp 内部系统温度,单位为摄氏度。
_ rescpuusage 以百分比表示的组合 CPU 使用率。
_memusagepcnt 以百分比表示的内存使用量。
statprotocolhttp _httprequestsrate 总 HTTP(S)请求速率(每秒)。
statlbvserver _ 主 IP 地址 虚拟服务器的 IP 地址。
_primaryport 虚拟服务器的端口号
_state 虚拟服务器的状态:启动:虚拟服务器正在运行。DOWN:虚拟服务器中的所有服务都失败。停止服务:虚拟服务器被禁用。
_vslbhealth 虚拟服务器的运行状况,表示为处于运行状态的服务的百分比。
_requestsrate 虚拟服务器每秒接收请求的速率。
启动服务 _ 主 IP 地址 虚拟服务器的 IP 地址。
_primaryport 虚拟服务器的端口号。
_state 虚拟服务器的状态:启动:服务正在运行。关闭:服务没有在物理服务器上运行。停止服务:服务被禁用。
_requestsrate 该服务每秒接收请求的速率。

读取系统健康数据

读取系统状态数据非常简单;我们需要做的就是调用两个方法:一个检索关于硬件和内存状态的读数,另一个检查由负载均衡器服务的 HTTP 和 HTTPS 请求总数。

表 2-1 中我们可以看到,我们将调用 statsystem 和 statprotocolhttp 方法。这两种方法都不需要任何输入参数。清单 2-19 显示了我们的 NSStatApi 类中统计信息收集方法的简化版本。

清单 2-19 。获取系统健康数据

def system_health_check(self):
    results = {}
    [...]
    req = self.module.statsystem()
    res = self.soap.statsystem(req)._return
    results['temp'] = res._List[0]._internaltemp
    results['cpu'] = res._List[0]._rescpuusage
    results['mem'] = res._List[0]._memusagepcnt
    [...]
    req = self.module.statprotocolhttp()
    res = self.soap.statprotocolhttp(req)._return
    results['http_req_rate'] = res._List[0]._httprequestsrate
    [...]
    return results

这看起来类似于我们之前执行的登录请求;然而,有一个重要的区别需要注意。这一次我们需要使用 _List 变量来访问我们感兴趣的细节。这是因为所有 response _return 对象都包含两个必需变量和一个可选变量:_rc、_message 或 _List。我们已经知道 _rc 和 _message 包含一个请求返回代码和一个提供关于请求状态更多细节的消息。

但是,_List 是可选的,它是一个数组,可以包含返回对象的一个或多个实例。即使该方法总是返回单个实例,它仍然包含在数组中。这是提供标准通信方式的方法之一:每个请求总是返回相同的变量集,所以如果我们需要,我们可以编写一个标准的 SOAP 请求调度程序/响应处理程序。

我们如何找出列表中返回了哪些结构对象?这很简单。首先,您需要在 NSStat_services_types.py 模块中查找包含 SOAP 通信中使用的所有数据类型的 methodname 响应类。在我们的例子中,我们正在搜索 statsystemResult_Def 类。

一旦我们找到了它,我们需要寻找类型定义,如下所示:

TClist = [ZSI.TCnumbers.IunsignedInt(pname="rc", aname="_rc", minOccurs=1,
maxOccurs=1, nillable=False, typed=False, encoded=kw.get("encoded")),
ZSI.TC.String(pname="message", aname="_message", minOccurs=1, maxOccurs=1,
nillable=False, typed=False, encoded=kw.get("encoded")),
GTD("urn:NSConfig","systemstatsList",lazy=False)(pname="List",
aname="_List", minOccurs=0, maxOccurs=1, nillable=False, typed=False,
encoded=kw.get("encoded"))]

现在,我们寻找 systemstatsList 类定义,如清单 2-20 所示。

清单 2-20 。systemstatsList 类定义

class systemstatsList_Def(ZSI.TC.Array, TypeDefinition):
    #complexType/complexContent base="SOAP-ENC:Array"
    schema = "urn:NSConfig"
    type = (schema, "systemstatsList")
    def __init__(self, pname, ofwhat=(), extend=False, restrict=False,
                 attributes=None, **kw):
        ofwhat = ns0.systemstats_Def(None, typed=False)
        atype = (u'urn:NSConfig', u'systemstats[]')
        ZSI.TCcompound.Array.__init__(self, atype, ofwhat, pname=pname,
                                          childnames='item', **kw)

在这个类定义中,我们找到了对实际类的引用,它将包含我们将在 SOAP 响应中收到的所有变量。

最后,在清单 2-21 中,我们搜索 systemstats_Def 类,其中子类 Holder 包含所有可用的变量。

清单 2-21 。systemstats 返回类型的定义

class systemstats_Def(ZSI.TCcompound.ComplexType, TypeDefinition):
    [...]
    class Holder:
        typecode = self
        def __init__(self):
            # pyclass
            self._rescpuusage = None
            self._memusagepcnt = None
            self._internaltemp = None
            [...]

这可能看起来很复杂,但是对于自动化系统来说,访问信息总是相同的模式,这有助于简化过程。

正在读取服务状态数据

检索关于服务的信息非常类似;只是涉及到更多的步骤:

  1. 我们需要检索 Netscaler 上所有虚拟服务器的列表。这可以通过 statlbvserver 方法实现,该方法接受一个可选的 name 参数。如果指定了该选项,将只返回有关该虚拟服务器的信息。如果没有指定名称或者设置为空,将返回所有虚拟服务器的信息。
  2. 对于列表中的每个虚拟服务器,我们会创建一个附加到它的服务列表。这实际上需要使用不同的 SOAP 服务 Netscaler 配置 SOAP。Statistics API 不提供查询配置实体之间的依赖关系的功能,因此我们将使用配置 API 中的 getlbvserver 方法。
  3. 我们检查虚拟服务器健康分数是否为 100 %而不是。如果服务器不在忽略列表中,我们会列出附加到它的不健康服务。我们使用 statservice 方法来检索每个服务的统计信息,如果服务没有处于运行状态,我们会指出这一点。

Image 注意在 Citrix 负载均衡器中,虚拟服务器附带了许多服务来满足用户请求。虚拟服务器的运行状况分数是以虚拟服务器池中活动服务的百分比来计算的。如果虚拟服务器池中包含十项服务,其中两项没有响应运行状况检查,则该虚拟服务器的得分为 80%。

在下面的代码清单中,我展示了实现健康和服务统计信息收集的类和方法。为了保持代码简单,这些例子没有任何错误处理。完整的源代码可以从该书在 www.apress.com 的页面下载,包含了额外的错误处理和报告功能。

首先,在清单 2-22 中,我们定义了一个新的统计 API 包装类,它实现了两个方法:get_vservers_list 和 get_service_details。该类继承了我们前面定义的 NSSoapApi 基类的所有函数。

清单 2-22 。统计 API 包装类

class NSStatApi(NSSoapApi):
    [...]
    def get_vservers_list(self, name=''):
        result = {}
        req = self.module.statlbvserver()
        req._name = name
        res = self.soap.statlbvserver(req)._return
        for e in res._List:
            result[e._name.strip('"')] = { 'ip':           e._primaryipaddress,
                                           'port':         e._primaryport,
                                           'status':       e._state,
                                           'health':       e._vslbhealth,
                                           'requestsrate': e._requestsrate, }
        return result

    def get_service_details(self, service):
        result = {}
        req = self.module.statservice()
        req._name = service
        res = self.soap.statservice(req)._return
        result = { 'ip': res._List[0]._primaryipaddress,
                   'port': res._List[0]._primaryport,
                   'status': res._List[0]._state,
                   'requestsrate': res._List[0]._requestsrate, }
        return result

get_vservers_list 方法调用 statlbvserver SOAP 方法,并传递一个可选的 name 参数。如果名称字符串为空,将返回所有虚拟服务器的列表。当列表返回时,我们用完整列表中的几个条目创建自己的字典。

get_service_details 方法调用 statservice SOAP 方法,并将服务名作为参数传递。SOAP 响应由关于服务的详细信息组成。我们将只提取我们感兴趣的信息,并将其作为 Python 字典返回。

在清单 2-23 中,我们定义的第二个类是一个配置 API 包装类。这个类应该主要用于处理负载平衡器配置的函数,但是我们需要从这个服务中调用一个函数:getlbvserver。该函数返回(除了关于虚拟服务器的其他细节之外)绑定到特定虚拟服务器的所有服务的列表。我们的方法叫做 get_services_list,它只是以 Python 列表的形式返回结果,以服务名作为元素。

清单 2-23 。配置 API 包装类

class NSConfigApi(NSSoapApi):
    def get_services_list(self, vserver):
        req = self.module.getlbvserver()
        req._name = vserver
        res = self.soap.getlbvserver(req)._return
        result = [e.strip('"') for e in res._List[0]._servicename]
        return result

最后,在清单 2-24 中,我们实现了我们的查询函数,它执行以下步骤:

  1. 初始化两个类的实例。
  2. 检索所有虚拟服务器的列表。
  3. 如果虚拟服务器运行状况不是 100%,则获取绑定到它的服务列表。
  4. 打印出所有不健康的服务。

清单 2-24 。正在检索服务状态数据

ns = NSStatApi([...])
ns_c = NSConfigApi([...])

for (vs, data) in ns.get_vservers_list(name=OPTS.vserver_query).iteritems():
        if (data['status'] != 'UP' or data['health'] != 100) and
           vs not in config.netscalers['primary']['vserver_ignore_list'] or
           OPTS.verbose:
            print " SERVICE: %s (%s:%s)" % (vs, data['ip'], data['port'])
            print "    LOAD: %s req/s" % data['requestsrate']
            print "  HEALTH: %s%%" % data['health']
            for srv in sorted(ns_c.get_services_list(vs)):
                service = ns.get_service_details(srv)
                if service['status'] != 'UP' or OPTS.vserver_query or OPTS.verbose:
                    print ' * %s (%s:%s) - %s (%s req/sec)' % (srv, service['ip'],
                                                               service['port'],
                                                               service['status'],
                                                           service['requestsrate'])

以下是该工具的示例输出。根据您的负载平衡器配置以及虚拟服务器和服务的运行状态,您显然会得到不同的结果。

在本例中,第一部分显示了负载平衡器的基本健康信息:内存使用、CPU 使用、温度和 HTTP 请求总数。第二部分显示关于不完全健康的服务的信息。该服务应该有 30 个服务在运行,但其中两个被标记为关闭:

$ ./ns_stat.py
**************************************************
Health check for loadbalancer: 192.168.1.1
 Memory usage: 6.434952%
    CPU usage: 15%
  Temperature: 47C
     Requests: 4926/sec
------------------------------
SERVICE: main_web_server (192.168.0.5:80)
    LOAD: 1140 req/s
  HEALTH: 92%
 * web_farm_service-13 (192.168.2.13:80) - DOWN (0 req/sec)
 * web_farm_service-14 (192.168.2.14:80) - DOWN (0 req/sec)
------------------------------
$

自动化一些管理任务

我们练习的第二部分是为我们的负载平衡器创建一个管理工具。回到我们最初的需求,我们知道我们希望配置工具执行以下任务:

  1. 禁用/启用任何可用虚拟服务器的所有服务。
  2. 禁用/启用任何单独的服务。
  3. 禁用/启用任何一组服务(可能跨多个虚拟服务器)。

设备配置 SOAP 方法

配置 API 提供了 2500 多种不同的方法来改变负载平衡器的配置。配置负载平衡器通常是一项复杂的任务,远远超出了本书的范围。不过,在本节中,我将展示如何获取服务列表以及如何启用和禁用它们。其他函数的行为方式类似,所以如果您需要创建一个新的虚拟服务器,您只需调用适当的函数。

2-2 列出了我们将在配置工具中使用的方法,以及每个方法的返回变量和描述。

表 2-2 。用于启用和禁用服务器的方法

|

方法

|

返回变量

|

描述

禁用服务 _rc 操作的返回代码(simpleResult 类型);如果成功,则为 0。
_ 消息 结果的详细说明(simpleResult 类型)。如果成功,则“完成”;否则会提供有意义的解释。
启用服务 _rc 操作的返回代码(simpleResult 类型);如果成功,则为 0。
_ 消息 结果的详细说明(simpleResult 类型)。如果成功,则“完成”;否则会提供有意义的解释。
getlbvserver _ 服务名称 绑定到特定虚拟服务器的所有服务的列表。

如您所见,启用和禁用服务的前两种方法的响应非常简单:要么成功,要么失败。就像 login 方法一样,它们返回一个数据结构 simpleResponse,这个数据结构只包含一个返回代码和一个详细的错误描述,以防失败。

最后一个方法是 getlbvserver,我们在上一节中使用它来检索绑定到虚拟服务器的所有服务的列表。这里将使用相同的方法包装器。

设置服务状态

设置服务的状态非常简单,只需调用 enableservice 或 disableservice,并将服务名作为方法调用的参数。Citrix Netscaler 负载平衡器服务和虚拟服务器名称不区分大小写,因此在调用任一方法时,您都不需要考虑为 name 参数设置正确的大小写。

我们在 NSConfigApi 类中定义了另一个函数,它将实现状态之间的切换,并将两个 SOAP 函数包装到一个方便、易用的类方法中。我们将这个方法称为 set_service_state,它将接受两个必需的参数:一个新的状态和一个 Python 数组,该数组包含我们想要更改其状态的所有服务的名称。清单 2-25 显示了代码。

清单 2-25 。SOAP enableservice 和 disableservice 函数的包装器

def set_service_state(self, state, service_list, verbose=False):
    [...]
        for service in service_list:
            if verbose:
                print 'Changing state of %s to %sd... ' % (service, state)
            req = getattr(self.module, '%sservice' % state)()
            req._name = service
            res = getattr(self.soap, '%sservice' % state)(req)._return
    [...]
    return

如你所见,这是一个简单的函数;然而,它包含了一件值得多加注意的事情:我们没有明确地指定我们所调用的方法的名称;它是在运行时根据我们在状态变量中收到的参数值自动构造的。

为了实现这一点,我们使用 Python getattr 函数,该函数允许我们在运行时获取对对象属性的引用,而无需事先知道属性名。当我们调用 getattr 时,我们提供两个参数:一个对象的引用和我们正在处理的属性的名称。因此,我们对方法的显式调用如下所示:

result = some_object.some_function()

这相当于:

result = getattr(some_object, "some_function")()

请务必注意 getattr 调用后的()。getattr 返回值是对对象的引用,因此不执行函数。如果我们正在访问一个对象变量,它将返回该变量的值,但是如果我们正在访问一个函数,我们将只获得对它的引用:

>>> class C():
...  var = 'test'
...  def func(self):
...   print 'hello'
...
>>> o = C()
>>> getattr(o, 'var')
'test'
>>> getattr(o, 'func')
<bound method C.func of <__main__.C instance at 0xb7fe038c>>
>>> getattr(o, 'func')()
hello
>>>

此方法通常用于实现 dispatcher 功能,我们也在代码中使用它,而不是显式测试 state 参数,如下所示:

if state == 'enable':
    req = self.module.enableservice()
    req._name = service
    res = self.soap.enableservice(req)._return
elif state == 'disable':
    req = self.module.disableservice()
    req._name = service
    res = self.soap.disableservice(req)._return

此时,我们构造函数的名称,并自动调用它:

    req = getattr(self.module, '%sservice' % state)()
    req._name = service
    res = getattr(self.soap, '%sservice' % state)(req)._return

这是一项强大的技术,它使您的代码可读性更强,更易于维护。在前面的例子中,我们将行数从八行减少到只有三行。但是,有一个警告:我们可能会引用一个不存在的属性。在我们的示例中,我们必须确保状态设置为“启用”或“禁用”;否则,getattr 将返回 None 作为结果。

关于日志记录和错误处理

虽然它们不影响我们的工具或 API 访问库的功能,但是实现基本的日志记录、错误报告和错误处理是很重要的。在编写代码的每个阶段,我们需要预测所有可能的结果,特别是如果我们使用外部库和/或外部服务,比如 SOAP API。

使用 Python 日志记录模块

不管我们的项目有多大,尽可能多地报告代码中发生的事情的细节是一个好的做法。Python 附带了一个内置的日志模块,该模块灵活且可配置,并且易于使用。

日志记录级别和范围

Python 日志模块提供了五个级别的详细信息。表 2-3 提供了何时使用每个等级的详细信息。

表 2-3 。日志记录级别以及何时应该使用每个

|

水平

|

何时使用

调试 顾名思义,这个日志记录级别是为了调试目的。使用 DEBUG 记录尽可能多的信息;这个级别的消息应该包含足够的细节,以便您识别代码可能存在的问题。
信息 这是一个不太详细的级别,通常用于记录系统生命周期中的关键事件,比如联系外部服务或调用相当复杂的子系统。
警告 报告此日志记录级别的所有意外事件。凡是没有危害,但是出格的,都要在这里举报。例如,如果没有找到配置文件,但是我们有默认设置,我们应该发出警告。
错误 此级别用于记录任何阻止我们完成给定任务但仍允许我们继续完成剩余任务的事件。例如,如果我们需要检查五台虚拟服务器的状态,但其中一台找不到,我们就报告这是一个错误,并继续检查其他服务器。
批评的 如果我们不能继续下去,我们用这个日志级别记录错误并退出。此时不需要提供详细信息;说到故障排除,我们就切换到较低级别的,比如 DEBUG。

考虑我们日志记录的范围和目的是很重要的。我们必须区分工具的常规输出和日志记录。常规输出和报告是该工具的主要功能,因此不能与来自应用的日志消息混淆。我们也可以选择使用日志模块来编写应用输出消息,但是它们需要进入不同的流。应用日志纯粹是为了报告应用的状态。

例如,如果我们无法连接到负载平衡器,我们必须将其记录为严重事件并退出。换句话说,我们的工具发生了一些问题,导致它无法完成操作。但是,如果我们获得温度读数,并确定它高于正常值,我们不能在日志流中将其记录为临界,因为高系统温度与我们的应用无关。不管负载平衡器的健康状况如何,我们的工具都可以正常运行。继续这个例子,我们可能决定要么简单地打印警告消息,要么将它记录在其他流中,可能称为 loadbalancers_health.log。

配置和使用记录器

根据我们想要实现的目标,日志配置可以简单也可以复杂。我倾向于不使它过于复杂,尽可能保持简单。在一天结束的时候,在我们的日志配置中,我们只需要几样东西:

  • 测井级别。我们希望我们的记录器产生多少输出?如果工具是成熟的、经过良好测试的、稳定的,实际上我们会将日志级别设置为错误,但是如果我们正在开发,我们可能会坚持调试。
  • 日志目的地。我们希望在屏幕上还是在文件中记录消息?最好将它写到一个文件中,特别是当我们使用多个记录器时,一个用于应用状态消息,另一个用于我们正在管理或监控的系统。
  • 日志消息格式。默认的日志记录程序消息格式信息不多,所以我们可能希望向它添加额外的字段,这很容易实现。

幸运的是,日志模块提供了一个 basicConfig 方法,允许我们通过一个函数调用来设置所有这些:

import logging
logging.basicConfig(level=logging.DEBUG, filename='NSLib.log',
format="%(asctime)s [%(levelname)s] (%(funcName)s() (%(filename)s:%(lineno)d)) %(message)s")

正如您可能已经猜到的,设置日志记录级别是微不足道的;我们只需要使用一个已定义的内部变量,其名称与我们之前使用的日志级别名称相匹配:DEBUG、INFO、WARNING、ERROR 或 CRITICAL。日志输出目标只是一个文件名。如果我们不指定任何文件名,日志模块将使用标准输出(stdout)来写入所有消息。

日志格式有点复杂。必须按照 Python 字符串格式规则定义格式,假设正确的参数是字典。使用散列数组中的参数格式化 Python 中的字符串的标准约定如下:

>>> string = "%(var1)s %(var2)d %(var3)s" % {'var1': 'I bought', 'var2': 3, 'var3':
 'sausages'}
>>> print string
I bought 3 sausages
>>>

正如在我们的例子中,日志模块期望在%操作符的左边有一个格式化的字符串,并提供一个标准的预填充字典作为右边的参数。表 2-4 列出了在测井格式字符串中使用的最有用的参数。

表 2-4 。可在日志格式字符串中使用的预定义字典字段

|

水平

|

描述

%(asctime)s 以可读形式显示日志消息的时间,例如 2009-07-07 14:04:39,462。逗号后面的数字是以毫秒为单位的时间部分。
%(levelname)s 表示日志级别的字符串。可能的默认值:调试、信息、警告、错误或严重。
%(funcName)s 生成日志消息的函数的名称。
%(文件名)s 进行日志记录调用的文件的名称。这不包含文件的完整路径,只包含文件名部分。
%(模块)s 生成日志调用的模块的名称。这与去掉扩展名的文件名相同。
%(lineno)d 发出日志记录调用的文件中的行号。并不总是可用。
%(消息)s 以 msg % args 格式处理的实际日志记录消息:logging.debug(msg,args)

一旦我们配置了日志模块,使用它就非常简单了—我们所要做的就是初始化日志记录器的一个新实例,并调用它的方法来编写适当的日志消息(所有方法都由适当的日志级别名称调用):

清单 2-26 。初始化新的记录器实例

logging.basicConfig(level=logging.DEBUG, filename='NSLib.log',
format="%(asctime)s [%(levelname)s] (%(funcName)s() (%(filename)s:%(lineno)d)) %(message)s")

logger = logging.getLogger()
logger.critical('Simple message...')
logger.error('Message with one argument: %s', str1)
logger.warning('Message with two arguments. String %s and digit: %d', (msg, val))
try:
    not_possible = 1 / 0
except:
    logger.critical('An exception has occurred! Stack trace below:', exc_info=True)

如您所见,日志模块非常灵活,而且易于配置。尽可能多地使用它,并尽量避免使用打印语句的旧式日志记录。

处理异常

异常是阻止我们的代码(或者我们的代码正在调用的模块的代码)正确执行并导致执行终止的错误。在我们之前的例子中,在清单 2-26 中,代码失败了,因为我们包含了一个指示 Python 执行被零除的语句,这是不可能的。这引发了一个 ZeroDivisionError 异常,代码的执行在此终止。除非我们使用了 try:...除了:...语句,我们的程序将在这一点上终止。Python 允许我们对异常采取行动,因此我们可以决定如何适当地处理它们。例如,如果我们试图与远程 web 服务建立通信,但是服务没有响应,我们将得到一个“连接超时”异常。如果我们有不止一个服务要查询,我们可能只是报告这是一个错误,并继续其他服务。

捕捉异常很容易:

try:
    call_to_some_function()
except:
    do_something_about_it()

正如我们在上一节中看到的,我们可以只通过指示我们想要记录日志记录器函数调用的异常细节来记录完整的异常堆栈跟踪。在我的代码示例中,我使用下面的结构来检测异常,记录它,并传递它。如果您正在编写一个模块,并且您不能真正决定如何处理发生的异常,这是处理它们的方法之一:

try:
    module.function()
except:
    logger.error('An exception has occurred while executing module.function()',
                 exc_info=True)
    raise

还可以捕捉特定的异常,并对每个异常执行不同的操作:

try:
    result = divide_two_numbers(arg1, arg2)
except ZeroDivisionError:
    # if this happens, we will return 0
    logger.error('We attempted to divide by zero, setting result to 0')
    result = 0
except:
    # something else has happened, so we reraise it
    logger.critical('An exception has occurred while executing module.function()',
                 exc_info=True)
    raise

如果您正在编写自己的模块,您可能会决定引入特定于该模块的异常,这样就可以相应地捕捉和处理它们。我在 NSLib.py 模块中使用了这种技术。自定义异常必须从泛型异常类派生。如果您不需要任何特定的功能,您可以将新的异常定义为以下类:

class NSLibError(Exception):
    def __init__(self, error_message):
        self.error_message = error_message

    def __str__(self):
        return repr(self.error_message)

一旦定义了异常类,就可以通过调用 raise 运算符并传递该异常类的对象实例来引发它:

class NSSoapApi(object):
    def __init__(self, module=None, hostname=None, username=None, password=None):
        [...]
        if not (hostname and username and password):
            self.logger.critical('One or more from the following: hostname, username and password, are undefined')
            raise NSLibError('hostname, username and password must be defined')

尽管这不是必需的,但遵循异常类约定是一种好的做法,该约定规定所有异常类名都应以 Error 结尾。除非模块很大,并且实现明显不同的功能,否则您可能只需为每个模块或一组模块定义一个异常。

NetScaler NITRO API

现在我们知道了如何使用 SOAP API 来管理 NetScaler 设备,让我们快速了解一下管理这些负载平衡器的替代方法。从 9.x 版本开始,Citrix 引入了基于 REST 的 API,称为 Nitro API。从 NetScaler 操作系统的 10.5 版本开始,Citrix 还提供了一个用于访问和使用 Nitro API 的 Python 模块。本章的其余部分展示了如何使用这个模块。

[计] 下载

首先,我们需要获得模块和文档文件。我们可以从正在运行的 NetScaler 设备中检索它们:

  1. 登录 netscaler web 控制台。
  2. 点击“下载”
  3. 下载“NITRO API SDK for Python”(NITRO-Python . tgz)。

下载完模块和文档归档文件后,我们需要对它们进行解压缩:

$ tar zxf nitro-python.tgz
$ ls -l
total 27724
-rw-r--r-- 1 rytis rytis  7700769 Aug 21 17:35 nitro-python.tgz
-rwxr-xr-- 1 rytis rytis 20684800 Jul  3 20:52 ns_nitro-python_tagma_50_10.tar
$ rm nitro-python.tgz
$ tar xf ns_nitro-python_tagma_50_10.tar
$ ls -l
total 20204
drwxr-xr-x 7 rytis rytis     4096 Jul  3 20:26 nitro-python-1.0
-rwxr-xr-- 1 rytis rytis 20684800 Jul  3 20:52 ns_nitro-python_tagma_50_10.tar
$ ls -l nitro-python-1.0/
total 52
drwxr-xr-x 2 rytis rytis  4096 Jul  3 20:26 doc
drwxr-xr-x 2 rytis rytis  4096 Jul  3 20:26 lib
-r-x------ 1 rytis rytis 10351 Jul  3 19:44 License.txt
-r-x------ 1 rytis rytis   109 Jul  3 19:44 MANIFEST.in
drwxr-xr-x 2 rytis rytis  4096 Jul  3 20:26 nitro_python.egg-info
drwxr-xr-x 3 rytis rytis  4096 Jul  3 20:26 nssrc
-rw-r--r-- 1 rytis rytis   353 Jul  3 20:26 PKG-INFO
-r-x------ 1 rytis rytis  1054 Jul  3 19:44 readme_start.txt
drwxr-xr-x 2 rytis rytis  4096 Jul  3 20:26 sample
-rw-r--r-- 1 rytis rytis    59 Jul  3 20:26 setup.cfg
-r-x------ 1 rytis rytis  1573 Jul  3 19:44 setup.py

为了安装和检查依赖项,nitro-python 包依赖于 requests 模块,但是所需的文件在同一个包中提供,所以我们不需要担心单独安装它们:

$ cd nitro-python-1.0
$ python setup.py install
$ pip freeze
argparse==1.2.1
distribute==0.6.24
nitro-python==1.0
requests==2.3.0
wsgiref==0.1.2
$

使用 Nitro-Python 模块

模块布局

Citrix Netscaler Python API 包的布局略有不同,它遵循典型的 Java 打包模式,将类和方法分成许多子包。我们不打算讨论这在布局 Python 项目中是否是一个好的实践;不过,我发现这有点不寻常,它导致了大量的、有时不方便的导入语句,我们将在后面看到。

如果我们检查包的目录结构,我们会看到子包结构是多么的细粒度,有许多目录(每个目录都是一个独立的子包)和很少的模块。每个模块通常只包含一个或两个在每个模块中定义的类。我们可以通过在 nitro-python-1.0 目录下运行以下命令来确认这一点:

$ nitro-python-1.0/
$ find nssrc/ -name \*.py -exec grep -c ^class {} \; -print

主包中有许多模块文件,这表明代码很可能是自动生成的:

$ cd nitro-python-1.0/
$ find nssrc/ -name \*.py -not -name __init__.py | wc -l
1132

那么,我们怎样才能找到我们需要的类或方法呢?

API 函数分为两个主要部分:

  • 配置。此类别中的功能用于主动管理 Netscaler 设备。
  • 统计。此类别中的功能用于从 Netscaler 设备收集统计数据。

每个类别中的项目列表几乎相同,因为可以配置的项目通常也可以被监控。表 2-5 显示每个类别中项目的逻辑组。

表 2-5 。功能的逻辑分组

|

配置

|

统计数字

|

描述

事件 - 用于订阅和发布 Netscaler 事件的事件框架
美国汽车协会 美国汽车协会 认证、授权和记账服务
应用 - 应用资源配置
应用流加速 应用流加速 AppFlow 资源
应用防火墙 应用防火墙 应用防火墙
云应用平台 云应用平台 应用级别的体验质量
审计 审计 审计资源
证明 证明 认证资源
批准 批准 授权服务
自动缩放 自动缩放 自动缩放
基础 基础 基本系统配置资源
内容加速器服务
集成缓存 集成缓存 集成缓存服务
Netscaler 集群管理
压缩 压缩 HTTP 压缩服务
缓存重定向 缓存重定向 HTTP 缓存管理服务
内容切换 内容切换 内容感知流量管理服务
分贝 - 数据库用户配置
域名服务 域名服务 DNS 管理
HTTP DoS 保护 HTTP DoS 保护 HTTP 拒绝服务保护服务
前端优化 前端优化 Web 内容优化服务
过滤器 - 请求内容过滤配置
全局服务器负载平衡 全局服务器负载平衡 全球服务器负载平衡服务
高可用性 高可用性 Netscaler 高可用性配置资源
安全协议 安全协议 IPsec 管理
负载平衡 负载平衡 负载平衡管理资源
线性低密度聚乙烯 线性低密度聚乙烯 链路层发现协议资源
网络 网络 网络配置管理
纳秒 纳秒 全局系统配置资源
标准温度和压力 - 系统 NTP 配置
政策 - 系统策略配置
优先排队 优先排队 优先排队服务
草案 草案 协议管理
- 服务质量 服务质量统计数据
回答者 回答者 响应服务
重写 重写 HTTP 重写服务
升高 - 远程集成服务引擎配置
路由器 - 路由器配置
当然连接 当然连接 SureConnect 服务
简单网络管理协议(Simple Network Management Protocol) 简单网络管理协议(Simple Network Management Protocol) 简单网络管理协议服务
溢出 溢出 溢出管理资源
加密套接字协议层 加密套接字协议层 安全套接字层配置资源
溪流 溪流 连接流管理资源
系统 系统 系统配置管理资源
交通管理 交通管理 交通服务/政策管理资源
改变 改变 URL 转换资源
隧道 - SSL VPN 隧道管理
效用 - 系统技术支持工具
虚拟专用网 虚拟专用网 虚拟专用网管理资源
网络界面 - Netscaler Web 界面配置

每个组包含处理系统的相同方面的资源;例如,负载平衡组包含处理负载平衡器资源配置、虚拟服务器资源配置等的所有资源。要了解所有详细信息,您必须从正在运行的设备下载 NetScaler NITRO API 文档档案,并使用 web 浏览器阅读文档。它设计得很好,信息也很容易找到。

所有 NetScaler 资源都在 ns src . com . Citrix . NetScaler . nitro . resource 包的子包中定义。配置资源可以在 ns src . com . Citrix . netscaler . nitro . resource . config 包中的包中找到(例如,LBserver 资源定义在 ns src . com . Citrix . netscaler . nitro . resource . config . lb . lbv server 模块中),统计资源可以在 ns src . com . Citrix . netscaler . nitro . resource . stats 包中的包中找到(例如,LBserver 统计资源可以在 ns src . com . Citrix . netscaler . nitro . resource . stat 中找到

不幸的是,很难找到正确的方法,在大多数情况下,我们必须遵循以下清单:

  • 确定我们是想要获取统计数据还是进行配置更改。
  • 如果我们正在读取统计数据,那么我们会对 ns src/com/Citrix/netscaler/nitro/resource/stats 目录中包含的模块感兴趣。
  • 如果我们正在配置资源,那么我们应该查看位于 ns src/com/Citrix/netscaler/nitro/resource/config 目录中的模块。
  • 服务管理资源位于 ns src/com/Citrix/netscaler/nitro/service 目录中。
  • 工具类位于 ns src/com/Citrix/netscaler/nitro/util 目录中。这些主要供图书馆内部使用,但我们可能会发现它们也很有用。
  • 异常定义位于 ns src/com/Citrix/netscaler/nitro/中。
  • 一旦我们确定了包含与我们要做的事情相关的功能的包,我们必须手动定位相关的模块,并找出我们需要使用的资源的名称。

继续 LBservice 配置示例,假设我们想要找到与 LBservice 配置相关的方法。我们知道它们位于/ns src/com/Citrix/netscaler/nitro/resource/config 中,因此我们转到该目录并列出所有子目录:

$ cd nssrc/com/citrix/netscaler/nitro/resource/config
$ ls -l
total 200
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 aaa
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 app
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 appflow
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 appfw
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 appqoe
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 audit
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 authentication
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 authorization
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 autoscale
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 basic
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 ca
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 cache
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 cluster
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 cmp
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 cr
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 cs
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 db
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 dns
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 dos
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 Event
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 feo
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 filter
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 gslb
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 ha
-rw-r--r-- 1 rytis rytis  449 Jul  3 20:04 __init__.py
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 ipsec
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 lb
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 lldp
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 network
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 ns
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 ntp
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 policy
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 pq
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 protocol
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 responder
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 rewrite
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 rise
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 router
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 sc
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 snmp
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 spillover
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 ssl
drwxr-xr-x 2 rytis rytis 4096 Aug 28 11:55 stream
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 system
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 tm
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 transform
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 tunnel
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 utility
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 vpn
drwxr-xr-x 2 rytis rytis 4096 Jul  3 20:26 wi

我们可以看到子目录大致与 表 2-5 中的列表相匹配,虽然有些名字是缩写的。在我们的示例中,负载平衡组缩写为“lb”

现在,如果我们转到“lb”目录,我们会发现许多处理负载平衡器资源配置的模块:

$ cd lb
$ ls -l
total 744
-rw-r--r-- 1 rytis rytis   1369 Jul  3 20:04 __init__.py
-rw-r--r-- 1 rytis rytis   3446 Jul  3 20:04 lbgroup_binding.py
-rw-r--r-- 1 rytis rytis   6986 Jul  3 20:04 lbgroup_lbvserver_binding.py
-rw-r--r-- 1 rytis rytis  16062 Jul  3 20:04 lbgroup.py
-rw-r--r-- 1 rytis rytis   3664 Jul  3 20:04 lbmetrictable_binding.py
-rw-r--r-- 1 rytis rytis   7232 Jul  3 20:04 lbmetrictable_metric_binding.py
-rw-r--r-- 1 rytis rytis   9059 Jul  3 20:04 lbmetrictable.py
-rw-r--r-- 1 rytis rytis   3885 Jul  3 20:04 lbmonbindings_binding.py
-rw-r--r-- 1 rytis rytis   6741 Jul  3 20:04 lbmonbindings.py
-rw-r--r-- 1 rytis rytis   7171 Jul  3 20:04 lbmonbindings_service_binding.py
-rw-r--r-- 1 rytis rytis   6665 Jul  3 20:04
lbmonbindings_servicegroup_binding.py
-rw-r--r-- 1 rytis rytis   3055 Jul  3 20:04 lbmonitor_args.py
-rw-r--r-- 1 rytis rytis   3548 Jul  3 20:04 lbmonitor_binding.py
-rw-r--r-- 1 rytis rytis   8153 Jul  3 20:04 lbmonitor_metric_binding.py
-rw-r--r-- 1 rytis rytis 103635 Jul  3 20:04 lbmonitor.py
-rw-r--r-- 1 rytis rytis   7894 Jul  3 20:04 lbmonitor_service_binding.py
-rw-r--r-- 1 rytis rytis   7954 Jul  3 20:04 lbmonitor_servicegroup_binding.py
-rw-r--r-- 1 rytis rytis  16051 Jul  3 20:04 lbparameter.py
-rw-r--r-- 1 rytis rytis   1102 Jul  3 20:04 lbpersistentsessions_args.py
-rw-r--r-- 1 rytis rytis   8354 Jul  3 20:04 lbpersistentsessions.py
-rw-r--r-- 1 rytis rytis   8144 Jul  3 20:04 lbroute6.py
-rw-r--r-- 1 rytis rytis   8731 Jul  3 20:04 lbroute.py
-rw-r--r-- 1 rytis rytis   7512 Jul  3 20:04 lbsipparameters.py
-rw-r--r-- 1 rytis rytis  10514 Jul  3 20:04 lbvserver_appflowpolicy_binding.py
-rw-r--r-- 1 rytis rytis  10721 Jul  3 20:04 lbvserver_appfwpolicy_binding.py
-rw-r--r-- 1 rytis rytis  11369 Jul  3 20:04 lbvserver_appqoepolicy_binding.py
-rw-r--r-- 1 rytis rytis  14857 Jul  3 20:04
lbvserver_auditnslogpolicy_binding.py
-rw-r--r-- 1 rytis rytis  14877 Jul  3 20:04
lbvserver_auditsyslogpolicy_binding.py
-rw-r--r-- 1 rytis rytis  10881 Jul  3 20:04
lbvserver_authorizationpolicy_binding.py
-rw-r--r-- 1 rytis rytis   9367 Jul  3 20:04 lbvserver_binding.py
-rw-r--r-- 1 rytis rytis  10474 Jul  3 20:04 lbvserver_cachepolicy_binding.py
-rw-r--r-- 1 rytis rytis  11289 Jul  3 20:04 lbvserver_capolicy_binding.py
-rw-r--r-- 1 rytis rytis  10681 Jul  3 20:04 lbvserver_cmppolicy_binding.py
-rw-r--r-- 1 rytis rytis   7236 Jul  3 20:04 lbvserver_csvserver_binding.py
-rw-r--r-- 1 rytis rytis  11386 Jul  3 20:04 lbvserver_dnspolicy64_binding.py
-rw-r--r-- 1 rytis rytis   6143 Jul  3 20:04 lbvserver_dospolicy_binding.py
-rw-r--r-- 1 rytis rytis  11309 Jul  3 20:04 lbvserver_feopolicy_binding.py
-rw-r--r-- 1 rytis rytis  14777 Jul  3 20:04 lbvserver_filterpolicy_binding.py
-rw-r--r-- 1 rytis rytis  14644 Jul  3 20:04 lbvserver_pqpolicy_binding.py
-rw-r--r-- 1 rytis rytis 124769 Jul  3 20:04 lbvserver.py
-rw-r--r-- 1 rytis rytis  10591 Jul  3 20:04
lbvserver_responderpolicy_binding.py
-rw-r--r-- 1 rytis rytis  10514 Jul  3 20:04 lbvserver_rewritepolicy_binding.py
-rw-r--r-- 1 rytis rytis  14450 Jul  3 20:04 lbvserver_scpolicy_binding.py
-rw-r--r-- 1 rytis rytis  11101 Jul  3 20:04 lbvserver_service_binding.py
-rw-r--r-- 1 rytis rytis   8876 Jul  3 20:04 lbvserver_servicegroup_binding.py
-rw-r--r-- 1 rytis rytis   9325 Jul  3 20:04
lbvserver_servicegroupmember_binding.py
-rw-r--r-- 1 rytis rytis  11429 Jul  3 20:04
lbvserver_spilloverpolicy_binding.py
-rw-r--r-- 1 rytis rytis  14590 Jul  3 20:04
lbvserver_tmtrafficpolicy_binding.py
-rw-r--r-- 1 rytis rytis  10554 Jul  3 20:04
lbvserver_transformpolicy_binding.py
-rw-r--r-- 1 rytis rytis   3448 Jul  3 20:04 lbwlm_binding.py
-rw-r--r-- 1 rytis rytis   6260 Jul  3 20:04 lbwlm_lbvserver_binding.py
-rw-r--r-- 1 rytis rytis  10356 Jul  3 20:04 lbwlm.py

模块名称通常与 NetScaler 命令行界面名称非常匹配,因此如果您熟悉 NetScaler 命令行配置,您应该能够识别包含资源定义的正确模块。例如,lbvserver.py 有一个表示 lbvserver 资源的类定义。

Image 注意如果你只使用了命令行或者 web 界面,你可能不会意识到一些中间资源,比如绑定资源。如果您熟悉 NetScaler 的基本概念,您应该知道可以将服务(服务资源表示在专用主机上运行的任何服务)绑定到 LBvserver (LBvserver 表示虚拟负载平衡服务)。当您将多个服务绑定到虚拟负载平衡服务器时,您实际上是指示 NetScaler 开始将到达 LBvserver 的所有流量转发到绑定到 LBvserver 的服务。绑定服务的 CLI 命令是“bind lb vserverservice name>CLI 不公开的是中间对象,称为“绑定”此绑定对象是 LBvserver 和服务之间的一对一映射。可以把它想象成关系数据库中的多对多表关系。当两个表之间存在多对多关系时,您将创建第三个表,用于分离其他两个表。

注册

我们要做的第一件事就是用 NetScaler 验证我们自己。在幕后,我们发送身份验证细节,负载平衡器用身份验证令牌进行回复,我们将在后续请求中使用该令牌。

为了建立连接,我们需要创建 nitro_service 类的一个实例,用正确的凭证细节初始化它,并调用 login()方法:

>>> from nssrc.com.citrix.netscaler.nitro.service.nitro_service import nitro_service
>>> client = nitro_service('192.168.0.100', 'http')
>>> client.set_credential('nsroot', 'nsroot')
>>> client.timeout = 10
>>> client.isLogin()
False
>>> client.login()
<nssrc.com.citrix.netscaler.nitro.resource.base.base_response.base_response
instance at 0x7ffe352ad098>
>>> client.isLogin()
True
>>>

重要的是,我们要为连接设置超时,如示例所示。如果我们不这样做,默认超时将保持设置为零秒,如果我们尝试建立连接,将会得到以下异常:

>>> client.login()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/rytis/.virtualenvs/nitro-test/local/lib/python2.7/site-packages/nitro_python-1.0-py2.7.egg/nssrc/com/citrix/netscaler/nitro/service/nitro_service.py",
line 220, in login
    raise e
requests.exceptions.ConnectionError:
HTTPConnectionPool(host='192.168.0.100', port=80): Max retries
exceeded with url: /nitro/v1/config/login (Caused by <class
'socket.error'>: [Errno 115] Operation now in progress)

一旦我们建立了连接,我们就可以在与 NetScaler 负载平衡器的通信中使用客户端对象。

收集统计数据

如前所述,所有处理从设备收集的统计信息的类都可以在 ns src/com/Citrix/netscaler/nitro/resource/stats/子目录中找到。在本节中,我们将了解如何收集特定于系统和特定于资源(虚拟服务器)的统计数据。

处理特定于系统的数据的类位于 ns src/com/Citrix/netscaler/nitro/resource/stat/system/中。在这个包中,我们可以找到以下模块:

  • systembw_stats.py
  • systemcpu_stats.py
  • systemmemory_stats.py
  • system_stats.py

为了获得 CPU 使用情况的详细信息,我们必须使用 systemcpu_stats.py 模块中定义的类。首先,我们需要初始化会话对象。不需要显式调用 login()方法,因为库会自动为我们完成这项工作:

>>> from nssrc.com.citrix.netscaler.nitro.service.nitro_service import nitro_service
>>> from nssrc.com.citrix.netscaler.nitro.resource.stat.system.systemcpu_stats import systemcpu_stats
>>> client = nitro_service('192.168.0.100', 'http')
>>> client.set_credential('nsroot', 'nsroot')
>>> client.timeout = 500

然后,我们创建一个 systemcpu_stats 类的实例。我们必须传递客户端对象,以便 systemcpu_stats 对象知道如何连接到负载平衡器:

>>> cpu_stats = systemcpu_stats.get(client)

在我的实例中,我有一个带有六个 CPU 的设备,因此响应包含六个元素:

>>> len(cpu_stats)
6

最后,我们来看看实际的统计数据:

>>> for c in cpu_stats:
...   print c.percpuuse
...
0
2
1
0
2
0

可以看到,设备并不是特别忙。

类似地,我们可以检索关于内存使用的数据:

>>> from nssrc.com.citrix.netscaler.nitro.service.nitro_service import nitro_service
>>> from nssrc.com.citrix.netscaler.nitro.resource.stat.system.systemmemory_stats import systemmemory_stats
>>> client = nitro_service('192.168.0.100', 'http')
>>> client.set_credential('nsroot', 'nsroot')
>>> client.timeout = 500
>>> mem_stats = systemmemory_stats.get(client)
>>> mem_stats[0].memtotallocmb
u'1964'

其他可供读取的有趣属性见 表 2-6

表 2-6 。Netscaler 设备特定属性

|

属性名称

|

描述

Shmemallocpcnt 共享内存百分比。
Shmemallocinmb 共享内存,以兆字节为单位。
Shmemtotinmb 允许分配的总共享内存,以兆字节为单位。
Memtotfree 系统中总的空闲 PE 内存。
Memusagepcnt NetScaler 上的内存利用率百分比。
Memtotuseinmb 使用的 NetScaler 内存总量,以兆字节为单位。
Memtotallocpcnt 当前分配的内存百分比。
Memtotallocmb 当前分配的内存,以兆字节为单位。
memtotinmb 可供数据包引擎(PE)使用的总可用(抓取)内存,以兆字节为单位。
Memtotavail 可供 PE 从系统中获取的总系统内存。

您可以在 REST API 文档中找到有关可用属性的详细信息,可以从 NetScaler 管理 Web UI 下载该文档。

如果我们想要检索在我们的负载平衡器设备上运行的虚拟服务器的信息,我们需要使用 ns src . com . Citrix . netscaler . nitro . resource . stat . lb . lbv server _ stats 模块。

首先,让我们检查虚拟服务器的状态。在以下示例中,我们检索所有虚拟服务器的统计数据,然后查看每台服务器的名称和状态:

 >>> from nssrc.com.citrix.netscaler.nitro.service.nitro_service
import nitro_service
>>> client = nitro_service('192.168.0.100', 'http')
>>> client.set_credential('nsroot', 'nsroot')
>>> client.timeout = 500
>>> from nssrc.com.citrix.netscaler.nitro.resource.stat.lb.lbvserver_stats import lbvserver_stats
>>> lbvs_stats = lbvserver_stats.get(client)
>>> len(lbvs_stats)
4
>>> for lbvs in lbvs_stats:
...   print "%s: %s" % (lbvs.name, lbvs.state)
...
test_1: UP
test_2: UP
test_3: UP
test_4: DOWN

如果我们需要检索单个虚拟服务器的详细信息,我们指定服务器的名称。在这种情况下,结果不是一个列表,而只是一个对象:

>>> lbvs_stats = lbvserver_stats.get(client, name="test_4")
>>> len(lbvs_stats)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: object of type 'lbvserver_stats' has no len()
>>> lbvs_stats.state
u'DOWN'

有关属性的完整列表,请参考 NetScaler REST API 文档。最有用的属性在表 2-7 中列出。

表 2-7 。虚拟服务器的特定属性

|

属性名称

|

描述

Vsvrsurgecount 在此 vserver 上等待的请求数。
已建立连接 处于已建立状态的客户端连接数。
Inactsvcs 绑定到 vserver 的非活动服务的数量。
Vslbhealth vserver 的运行状况。这给出了绑定到此 vserver 的启动服务的百分比。
小学女教师 SVM 的 IP 地址。
主端口 运行服务的端口。
类型 与 vserver 关联的协议。
状态 服务器的当前状态。可能的值有启动、关闭、未知、OFS(停止服务)、TROFS(停止服务的转换)、TROFS_DOWN(停止服务时关闭)。
actsvcs 绑定到 vserver 的活动服务的数量。
托菲特 vserver 命中总数。
hitstate tothits 的速率(/s)计数器。
请求总数 此服务或虚拟服务器上接收的请求总数。(这适用于 HTTP/SSL 服务和服务器。)
请求率 totalrequests 的速率(/s)计数器。
总计响应 在此服务或虚拟服务器上接收的响应数。(这适用于 HTTP/SSL 服务和服务器。)
响应状态 totalresponses 的速率(/s)计数器。
totalrequestbytes 此服务或虚拟服务器上接收的请求字节总数。
requestbytesrate totalrequestbytes 的速率(/s)计数器。
totalresponsebytes 此服务或虚拟服务器接收的响应字节数。
responsebytestate totalresponsebytes 的速率(/s)计数器。
totalpktsrecvd 此服务或虚拟服务器接收的数据包总数。
pktsrecvdrate totalpktsrecvd 的速率(/s)计数器。
totalpktssent 发送的数据包总数。
pktssentrate totalpktssent 的速率(/s)计数器。
电路连接 当前客户端连接数。
光标连接 虚拟服务器后面的实际服务器的当前连接数。

我希望这些信息为您指明了正确的道路,并使您能够找到 NetScaler 设备上可用的大量统计数据。

执行管理任务

寻找管理任务方法同样容易。所有方法都在 ns src/com/Citrix/netscaler/nitro/resource/config/目录中的可用模块中定义。以下示例显示了如何禁用和启用任何特定的服务器:

>>> from nssrc.com.citrix.netscaler.nitro.service.nitro_service import nitro_service
>>> from nssrc.com.citrix.netscaler.nitro.resource.config.basic.server import server
>>> client = nitro_service('192.168.0.100', 'http')
>>> client.set_credential('nsroot', 'nsroot')
>>> srv_obj = server.get(client, name="test_srv_1")
>>> srv_obj.state
u'ENABLED'
>>> srv_obj.state = 'DISABLED'
>>> server.update(client, srv_obj)
>>> >>> srv_obj = server.get(client, name="test_srv_1")
>>> srv_obj.state
u'DISABLED'

摘要

本章演示了如何使用 Python 访问 SOAP API 来监控和管理 Citrix Netscaler 负载平衡器。它还介绍了如何组织您自己的项目,如何组织您的代码,以及如何处理错误和报告模块的功能状态。提出了以下几点:

  • SOAP API 是一种调用远程服务器上的过程的方法,也称为 web 服务。
  • SOAP 协议为服务提供者和消费者之间的信息交换定义了一个消息结构。
  • SOAP 消息使用 XML 语言来结构化数据。
  • 底层或载体协议是 HTTP。
  • WSDL 用于描述 web 服务上可用的所有服务以及调用/响应消息中使用的数据结构。
  • 可以使用 wsdl2py 工具将 WSDL 定义转换为 Python 助手模块。
  • 在开始编码之前定义需求是很重要的。
  • 错误和异常必须得到适当的处理。
  • 日志模块用于记录消息,并按严重性对其进行分组。
  • 从版本 10.5 开始,nitro-python 包可用于访问 NetScaler 上的 REST API。**

三、为 IP 地址统计创建一个 Web 应用

在这一章中,我们将构建一个简单的应用来跟踪内部网络上分配的所有 IP 地址。本章涵盖了开发该应用的所有阶段,从收集和设置需求开始,通过实现阶段的各个方面来设计应用。

设计应用

理想情况下,应用设计不应该基于将要用来实现它的技术。话虽如此,这种独立性很难实现,而且在大多数情况下是不实际的,因为每种技术都暗示着它自己的实现模式和最佳实践。

在本章中,我们将在解释将要使用的技术之前定义需求和应用设计。这样,即使在您自己的工作中,您将使用不同的技术,您也会更容易理解如何重用设计阶段。

列出要求

在开发任何应用时,最重要的考虑是准确理解您想从中获得什么。远离你在别处看到的用户界面图像,或者你过去可能使用过的其他(可能类似的)应用的功能。相反,拿一张纸,用简短的句子写下你希望你的应用做什么。

我们想象的组织是一个相当大的企业,具有相当复杂的网络基础设施,因此有效地分配和使用 IP 地址空间非常重要。过去,地址记录在简单的电子表格中,不同的团队使用不同的结构来表示相同的信息。在这里,没有分配 IP 地址范围的权限,因此团队之间有效和清晰的沟通非常重要。新系统正在引入,而旧系统正在退役。组策略阻止服务器使用动态 IP 分配;只有用户机器可以从 DHCP 获得地址信息。根据这一简要描述,我们来列出以下要求:

  • 该系统必须是集中的,但是可以被许多不同的用户访问。
  • 应用必须能够存储 IP 范围和个人 IP 地址。
  • 应用必须提供一种方法来创建范围和单个 IP 地址的分层组织。
  • 用户必须能够添加、删除和修改条目。
  • 用户必须能够搜索信息。
  • 系统必须能够检查使用 IP 地址的机器是否有响应。
  • 对于所有 IP 地址,系统应该尝试获取名称记录。
  • 必须要求用户输入他们所做的任何 IP 保留的描述。
  • 扩展系统使用 DHCP 应该很容易。

既然我们已经定义了所有的需求,我们就可以在开发阶段的任何时候回顾这些需求,并验证我们的应用是否完全符合预期。我们不会实现不必要的功能;通过将实际的实现与需求集进行比较,我们将总是知道我们已经取得了多少进展,还有多少工作要做。展望未来,如果有必要,我们甚至可以将个人任务委派给其他人。如果在某个时候我们发现我们遗漏了一些重要的功能,我们总是可以回到我们的列表并相应地修改它,但是这将是一个有意识的决定,这将阻止我们在开发过程中实现任何新的功能。

做出设计决策

一旦我们建立了需求,我们就可以进行一些关于如何实现它们的设计决策。每个设计决策必须试图解决需求列表中陈述的一些目标。

因为这不是一个大规模的项目,所以不需要创建正式的设计文档;同样的非正式发言清单在这里就足够了。因此,根据刚才陈述的需求,我们可以对应用开发和结构做出以下决定:

  • 该应用将是基于网络的。
  • 它将运行在专用的网络服务器上,组织中的任何人都可以通过自己的网络浏览器进行访问。
  • 该应用将使用 Python 编写,并将使用 Django 框架。
  • 实施分为两个阶段:基本的 IP 分配和预留功能,以及与 DHCP 的集成。(我们将在本章中处理第一个阶段,然后在第四章中继续讨论 DHCP 集成。)

就是这样;尽管这个列表很短,但它确保了我们不会偏离我们最初陈述的目标,如果我们真的需要做一些改变,那将被记录下来。这里的列表主要代表设计的非功能方面;我们将在接下来的章节中讨论更具体的细节。形式上,这应该构成一个详细的设计文档,但是我将只描述两件事:我们的应用将操作什么数据,以及应用将如何处理这些数据。

定义数据库模式

从刚才陈述的需求中,我们知道我们需要记录以下数据:

  • IP 范围和/或单个 IP 地址
  • 当前范围所属的父范围
  • 对于每条记录,是否允许为空

IP 地址如何工作

在继续之前,让我们检查一下 IP 寻址是如何工作的,这样您将更好地理解我们将要做出的一些具体的数据库布局和结构决策。这里提供的描述有些简化;如果你想了解更多关于 IP 网络的知识,特别是关于 IP 地址的知识,我推荐维基百科上关于 http://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing CIDR 的词条:

简而言之,每个 IP 地址都有两个部分:网络地址部分,用于标识特定地址所属的网络,以及该网络中的主机地址。IPV4 中的完整 IP 地址总是 32 位长。在无类域间路由(CIDR) 推出之前,只有三种可用的网络块或网络类:A 类(8 位定义网络地址,允许超过 1600 万个唯一主机地址)、B 类(16 位网络地址,超过 65,000 个唯一主机地址)和 C 类(24 位网络地址,256 个唯一主机地址)。这是非常低效的,因为它不允许细粒度的地址和范围分配,所以 CIDR 方案被引入,它允许我们使用任何长度的网络地址。在 CIDR 符号中,每个 IP 地址后面都有一个数字,用来定义网络部分包含多少位。因此,地址 192.168.1.1/24 告诉我们,这是一个来自 C 类网络的 IP,其前 24 位是网络地址。

这张图片展示了 IP 地址的各种配置,我稍后会解释。该示例使用的网络地址范围比默认的 C 类小得多,因此您可以看到它是如何工作的。

9781484202180_unFig03-01.jpg

  • (A)显示了 IP 地址 192.168.1.52,以及它是如何分成两部分的:网络地址和主机地址。
  • 在(B)中,主机地址设置为 0,从而有效地定义了网络。因此,如果您想引用 192.168.1.52 地址所属的网络范围,您可以将其写成 192.168.1.32/27。
  • 如果我们将主机地址设置为全 1,我们将获得该范围内最后一个可能的 IP 地址,也称为广播 IP 。在中的示例中,它是地址 192.168.1.63。
  • 最后,在(D)中,您可以看到 192.168.1.93/27 是如何超出范围的,因此与 192.168.1.52/27 处于不同的 IP 网络范围;它的网络部分是不同的。其实是在一个相邻的网络范围内,192.168.1.64/27。

这应该对 IP 编号方案有所启发,您可以看到理解这一点如何帮助我们更有效地定义我们的数据库模式。

当您查看 IP 地址是如何构造的时,您可能会注意到较大的网络范围包含较小的网络范围,因此一个 24 位网络可能包含两个 25 位网络,或四个 26 位网络,依此类推;这完全取决于网络基础设施。这种结构让我们很容易检查网络之间的父子关系。

现在,我们需要决定如何存储这些信息。将它存储为四个独立的十进制数(四个八位字节)和一些位是一个显而易见的选择,但是正如您可能已经猜到的,这对任何数据库系统都没有帮助。像“给我所有属于这个范围的 IP”这样的搜索在客户端计算量会很大。因此,我们将把所有 IP 号码转换成 32 位整数,并照此存储。我们还将以位为单位分别存储网络大小,因此计算范围内的第一个和最后一个地址将非常简单。

我来举例说明一下。如果我们取之前使用的 IP 地址 192.168.1.52/27,用按位记数法表示,就会得到下面这个二进制数:11000000000000100110100。这个数字可以表示为 32 位整数(十进制表示法):3232235828。现在我们可以找到它的网络地址。我们知道网络范围是由前 27 位定义的,所以我们需要做的就是对这个数和一个由 27 个 1 和 5 个 0 组成的数进行二进制 AND 运算(1111111111111111111100000 b = 4294967264d):

3232235828D AND 4294967264D = 3232235808D

或者,用二进制表示:

11000000101010000000000100100000B

将这个结果与“IP 地址如何工作”边栏中的例子进行比较,您会发现结果是一致的。

找到上边界同样容易;我们需要将可用地址的最大数量添加到之前计算的结果中。因为 27 位网络空间留下 5 位来定义主机地址,所以最大(或广播)地址是 2⁵ = 32。因此,我们给定地址的网络表示为 3232235808D,其中最后一个地址是 3232235808D + 32D = 3232235840D。从这里,我们可以很容易地找到所有在同一网络范围内的地址。

基于这些信息,我们准备定义我们的数据库模式,它非常简单,只包含一个表。表 3-1 描述了模式中的每一列。

表 3-1 。网络定义方案中的字段

|

圆柱

|

数据类型

|

评论

记录 ID 整数 主键,它是唯一的,并且随着每个新记录自动递增。
地址 整数 一个键,它必须被定义,并且是一个表示 32 位网络地址的整数。
网络规模 整数 一个密钥,它必须被定义并决定地址的网络部分的位数。
描述 文本 必须定义,描述这个 IP 是做什么的。

创建应用工作流

由于这个应用相对简单,我们不需要使用正式的规范语言,如统一建模语言(UML),来定义应用的行为和工作流。这一阶段的主要目标是写下想法和布局结构,这样我们就可以在实现时随时参考文档,并且我们可以确认实现与最初设计的没有什么不同。

我发现只写几个语句是有用的,这些语句简要地描述了对于我们需求列表中的每个功能需求,将会发生什么,以及信息将如何呈现给最终用户。功能需求是我们的应用需要执行的功能。不要将它们与非功能需求相混淆,例如性能或可用性需求,它们不会影响应用的工作流程。

搜索和显示功能

一个常见的需求是搜索功能。即使我们不打算搜索,而只想查看列出的所有地址和网络范围,这也是一个广泛的搜索请求,要求系统显示所有可用信息。

因为我们已经决定为信息创建一个层次结构,所以 search 函数将通过在描述中查找 IP 地址或子字符串,并返回匹配条目的列表。显示功能将显示关于当前所选地址的信息(地址、网络位数以及范围的起始和结束地址),并列出所有子条目——即所选条目中的所有地址或网络。单击它们中的任何一个都会导致一个搜索和显示调用,它会沿着树向下。

显示功能还应该提供到父条目的链接,这样用户可以双向移动。如果搜索查询是空的或者匹配树中最顶端的节点,应该没有向上移动一级的选项。网络树(或超级网络)中最顶端的节点总是 0.0.0.0/0。对于每个子条目,view 函数应该调用一个健康检查函数来查看地址是否有响应。另外,一个名称解析过程被调用来获得一个 DNS 名称。这些信息应该相应地显示出来。

如果当前选择的树节点是一个网络地址,应该向用户显示一个到 Add New Entry 表单的链接。

添加功能

添加功能允许用户添加新的子条目。该表单要求输入新条目的详细信息,比如 IP 地址和描述,并创建一个相应的数据库条目。如果成功完成,表单将返回到之前的视图。

当我们添加一个新条目时,这个函数必须确认该条目是有效的,并且提供的 IP 地址存在。我们还需要检查该地址是否是任何当前父网络的子集。

删除功能

删除选项应该出现在每个条目旁边的地址列表中。单击它应该会产生一个简单的 JavaScript 确认对话框,如果确认删除,则必须从数据库中删除相应的条目。

如果条目是一个网络地址,所有子条目都应该递归删除。例如,如果我有一个包含网络 B 的网络 A,而网络 B 又包含地址 C,当我删除网络 A 时,网络 B 和地址 C 条目也应该被删除。

修改功能

当前地址列表中的所有条目都应该有一个修改选项。单击 Modify 应该会显示一个类似于添加新条目的表单,其中所有字段都填充了当前信息。

如果条目是一个网络地址,只有描述应该是可更改的。如果条目是主机 IP 地址,则在使用新设置更新数据库行之前,应该执行完整性检查(例如地址是否重复或者是否在有效的网络范围内)。

系统健康检查功能

当列出所有子条目时,视图函数应该为每个不是网络地址的地址调用一个系统健康检查。运行状况检查函数执行简单的 ICMP 检查(ping ),如果收到响应,则返回 True,否则返回 False。

名称解析功能

正如我们对健康检查函数所做的那样,我们将创建另一个过程,该过程将为网络外部的所有地址调用名称解析。名称解析将执行反向 DNS 查找,并返回 DNS 名称(如果可用)。如果没有 DNS 记录,将返回一个空字符串。

Django 框架的基本概念

正如我前面提到的,我们将使用 Django web 框架来开发应用。我选择 Django 是因为它是一个多功能的工具,可以极大地简化 web 应用开发。

姜戈是什么?

简而言之,Django 是一个高级 web 开发框架。Django 为快速 web 应用开发提供了工具。它的设计方式鼓励开发人员编写更干净、更一致的代码;同时也让他们少写代码。为开发人员提供了 web 开发中常用的各种高级功能,因此他们不需要重写别人已经开发的东西。Django 还加强了模块化,使开发人员能够编写一个无需修改或修改很少就可以在许多不同项目中使用的模块。

以下是 Django 框架的一些要点。

对象到关系数据库映射器?

我们使用 Python 类来定义我们的数据模型,Django 自动将它们转换成数据库表和关系。除此之外,Django 提供了一个直接来自 Python 的数据库访问 API,所以我们很少需要自己编写任何 SQL 代码。此外,我们可以在各种数据库系统(MySQL、SQLite 和其他)之间切换,而无需对代码进行任何更改。

管理界面

当我们定义我们的数据方案时,Django 不仅自动创建数据库和所有需要的表,它还生成一个全功能的管理界面来管理我们的数据。

灵活的模板系统

所有可显示的组件或视图都被分离到模板中,所以我们永远不会发现自己在程序中生成 HTML 代码。相反,代码和 HTML 设计是分开的。模板语言学习起来非常简单,但是灵活且对设计者友好,所以我们可以把设计工作交给其他人。

开源社区支持

最后但同样重要的是,Django 是开源的,并得到了活跃的开发者社区的支持。Django 发展非常迅速,每年都有几次重大升级,并且已经出现了一段时间,证明了自己是一个成熟可靠的产品。

模型/视图/控制器模式

在深入其实现细节之前,让我们先来探索 Django 所基于的最重要的设计模式:模型-视图-控制器(MVC)。任何遵循这种模式的 web 应用都被分成三个不同的部分:数据模型、视图和控制器。

数据模型组件

数据模型(或只是模型)部分定义了应用正在使用或操作的数据。这通常是数据库数据结构,但也可以是数据访问方法和函数。在 Django 中,所有的数据结构都被定义为 Python 类,框架自动在数据库上创建相应的数据模式。

视图组件

大多数 web 框架中的视图部分负责向最终用户显示数据。它是一组生成 HTML 代码的函数,这些代码被发送回 web 浏览器。Django 更进了一步,将传统上所谓的视图组件分成两个不同的实体:视图和模板。用 Django 的术语来说,视图是决定将显示哪些数据的代码,模板是定义如何显示数据的组件。

控制器组件

按照惯例,控制器组件负责从数据库中检索数据(或访问模型),对数据进行操作,并将其传递给视图组件。在 Django 中,控制器组件并不明显,也没有与其他组件分开——整个框架充当一个控制器组件。因为数据模型被定义为一组 Python 类,所以更加智能,知道如何对数据进行基本操作。视图(但不是模板!)还包含一些应用逻辑,所有这些都由框架控制。

安装 Django 框架

我建议你下载并使用 www.djangoproject.com 发布的最新 Django 代码。在撰写本文时,最新的版本是 1.6,这里所有的例子和代码都是基于这个版本的 Django。如果您要使用 1.6 以外的版本,请阅读发行说明,了解可能影响功能的任何更改。通常会有清晰的说明,告诉你如何让你的代码适应 Django 的新版本。根据我的经验,这项任务通常非常简单,不需要开发人员做大量的工作。

我假设您的系统上已经安装了 Python 2.6+。本章示例将使用的数据库引擎是 SQLite,因此相应的包和 Python 绑定也必须安装。在大多数现代 Linux 发行版中,这是标准设置,很可能会出现在您的系统中。如果您有疑问,可以使用以下命令进行检查:

$ python
Python 2.7.5 (default, Feb 19 2014, 13:47:28)
[GCC 4.8.2 20131212 (Red Hat 4.8.2-7)] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> import sqlite3
>>> sqlite3.version
'2.6.0'
>>>

如果您正在使用一个非主流的 Linux 发行版,或者如果在初始安装过程中没有安装软件包,请参考您的 Linux 发行版的文档,以获取有关安装最新 Python 2.7.x 发行版和 SQLite 软件包的信息。

Image 截至本文撰写时,Django 1.6 需要 Python 2.6.5 及以上版本。下一个 Django 版本 1.7 将完全放弃 Python 2.6,支持的最低 Python 版本将是 2.7。Django 版及以上正式支持 Python 3,所以也可以使用。

大多数主流 Linux 发行版都有一个相当最新的 Django 版本,作为一个包提供。例如,在 Fedora 系统上安装 Django 的方式如下:

$ sudo yum install python-django

您还可以使用 Python 包管理器(PIP)来安装所需的包:

$ sudo pip install django

使用 PIP 的优点是软件包通常是最新的。在撰写本文时,Fedora 存储库中的包是 1.6.4,PyPI (Python 包索引,PIP 查找包的地方)中的包是 1.6.5,这是最新的可用版本。主要的缺点是,您最终会在系统上部署一个应用,而这个应用对于系统的包管理器来说是未知的。

您可以通过从 Python 命令行界面导入其模块来测试 Django 安装:

# python
Python 2.7.5 (default, Feb 19 2014, 13:47:28)
[GCC 4.8.2 20131212 (Red Hat 4.8.2-7)] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> import django
>>> django.get_version()
'1.6.4'
>>>

Django 应用的结构

Django 将任何网站都视为一个项目。在 Django 的术语中,项目是一组 web 应用和项目(或站点)特定的配置。您可以在不同的站点重用相同的应用,只需将它们部署到新的项目中,它们就会自动使用新的设置,比如数据库凭证。一个项目可以包含任意数量的应用。术语项目可能听起来有点混乱;我觉得网站或者网站更合适。

创建新项目很简单。假设您已经正确安装了 Django,那么您只需要在您想要创建新项目目录的目录中运行 django-admin.py 命令。Django 的管理工具将用基本的配置文件创建一个简单的项目框架。

我们将使用/var/app/vhosts/www _ example _ com/作为项目的基本目录,该目录将包含所有 Django 应用:

$ mkdir -p /var/app/virtual/
$ cd /var/app/virtual
$ django-admin.py startproject www_example_com
$ ls -lR www_example_com/
total 8
-rw-r--r--  1 rytis  staff  258 10 Jun 21:08 manage.py
drwxr-xr-x  6 rytis  staff  204 10 Jun 21:08 www_example_com

www_example_com//www_example_com:
total 24
-rw-r--r--  1 rytis  staff     0 10 Jun 21:08 __init__.py
-rw-r--r--  1 rytis  staff  1999 10 Jun 21:08 settings.py
-rw-r--r--  1 rytis  staff   306 10 Jun 21:08 urls.py
-rw-r--r--  1 rytis  staff   405 10 Jun 21:08 wsgi.py

在项目目录中,您会找到以下文件:

  • manage.py:一个自动生成的脚本,您将使用它来管理您的项目。使用该工具可以创建新的数据库表、验证模式或转储 SQL 脚本。该工具还允许您调用命令提示界面来访问数据模型。
  • www_example_com/settings.py:保存数据库信息和应用特定设置的配置文件。
  • www_example_com/urls.py:充当 URL 调度程序的配置文件。在这里,您可以定义哪些视图应该响应哪些 URL。
  • www_example_com/wsgi.py:一个 wsgi 配置文件,如果 Django 应用在一个兼容 WSGI 的 web 服务器(比如 Apache)上运行,并且启用了 mod_wsgi,那么可以使用这个文件。

Image 注意配置文件的位置特定于你的项目。在本章中,我们的项目是在/var/app/virtual/www _ example _ com/中创建的,所以当您看到对 manage.py、settings.py 和 urls.py 文件的引用时,请假定该位置。

一旦创建了新项目,就需要指定 Django 应该使用的数据库引擎。如前所述,在我们的例子中,我们将使用 SQLite。要实现这一点,我们需要在 settings.py 配置文件(在本章后面称为设置文件)中做两处更改:指定数据库引擎和数据库文件的绝对文件名:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
    }
}

当项目和数据库配置完成后,我们可以通过在项目目录中发出以下命令来创建应用:

$  python manage.py startapp ip_addresses
$ ls –l ip_addresses/
total 12
-rw-r--r-- 1 root root   0 2014-05-24 14:55 __init__.py
-rw-r--r-- 1 root root  57 2014-05-24 14:55 models.py
-rw-r--r-- 1 root root 514 2014-05-24 14:55 tests.py
-rw-r--r-- 1 root root  26 2014-05-24 14:55 views.py

就像 Django 管理工具一样,项目管理脚本为我们的新应用创建了一个框架。现在我们已经建立了项目(或网站)并配置了一个应用,我们需要做的是定义数据模型,编写视图方法,创建 URL 结构,最后设计模板。所有这些我将在下面的章节中详细描述,但是首先我仍然需要展示如何让其他人看到我们的新网站。

该应用不能立即使用;我们需要在设置文件中将它添加到 INSTALLED_APPS 列表中:

INSTALLED_APPS = (
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.sites',
    'ip_addresses',
)

在 Apache Web 服务器上使用 Django

Django 自带轻量级 web 服务器,是用 Python 编写的。这是快速测试或开发过程中的一个很好的工具,但是我强烈建议不要在生产环境中使用它。我在使用它时从未遇到任何问题,但是正如 Django 背后的开发人员所说,他们从事 web 框架业务,并不是来开发健壮的 web 服务器的。

web 服务器最明显的选择之一是 Apache web 服务。它被广泛应用于互联网上的绝大多数网站。许多 Linux 发行版默认包含 Apache 安装包。设置 Apache 很容易,它既服务于静态 CSS 样式表和图像又服务于动态生成的页面(就像在 Django 应用中一样)。

我们的示例将假设以下信息:

  • 网站名称:www.example.com
  • 服务器的 IP 地址:192.168.0.1
  • Django 代码存储的目录:/var/app/vhosts/www.example.com/
  • 存储静态内容的目录:/var/www/vhosts/www _ example _ com/

Image 注意你可能想知道为什么代码和内容目录是分开的。分开的原因是这是一种额外的安全措施。正如你将在本章后面看到的,我们将指示 web 服务器为所有对虚拟服务器的请求调用 mod_python 模块。例外是以/static/开头的所有 URIs,这将是我们的静态内容。现在,如果由于某种原因我们在配置文件中犯了一个错误,导致 mod_python 没有被调用,并且代码目录是 DocumentRoot 指令的一部分,那么我们所有的 python 文件都将变成可下载的。因此,请始终将您的代码文件单独保存在 DocumentRoot 之外!

清单 3-1 显示了 Apache web 服务器配置文件中的 VirtualServer 定义。根据您的 Linux 发行版,这一部分可能直接包含在 httpd.conf 中,或者作为一个单独的配置文件与其他 VirtualServer 定义放在一起。

清单 3-1 。Django Web 应用的虚拟服务器定义

<VirtualHost 192.168.0.1:80>
    ServerName www.example.com
    DocumentRoot /var/www/virtual/www.example.com
    ErrorLog /var/log/apache2/www.example.com-error.log
    CustomLog /var/log/apache2/www.example.com-access.log combined
    SetHandler mod_python
    PythonHandler django.core.handlers.modpython
    PythonPath sys.path+['/var/app/virtual/']
    SetEnv DJANGO_SETTINGS_MODULE www_example_com.settings
    SetEnv PYTHON_EGG_CACHE /tmp
    <Location "/static/">
        SetHandler None
    </Location>
</VirtualHost>

配置的第一部分处理基本配置,例如设置服务器名称、所有静态内容的基本目录以及日志文件位置。接下来是 mod_python 配置,其中第一行告诉 Apache 将每个 web 服务器阶段的执行传递给 mod_python 模块:

SetHandler mod_python

该指令后面是模块配置设置。

什么是 APACHE 处理程序

所有的要求。由 Apache web 服务器分阶段处理。例如,对一个简单的 index.html 文件的请求可能涉及三个阶段:将 URI 翻译成文件的绝对位置;读取文件并在 HTTP 响应中发送它;并记录该事件。每个请求涉及的阶段取决于服务器配置。每个阶段都由一个处理程序处理。Apache 服务器只有基本的处理程序;更复杂的函数由作为可加载模块一部分的处理程序实现,mod_python 就是其中之一。Python 模块有所有可能的 Apache 阶段的处理程序,但是默认情况下没有调用处理程序。每个阶段都需要与配置文件中适当的处理程序相关联。

Django 只需要一个处理程序,即通用的 PythonHandler,它在向请求者提供实际内容的阶段被调用。Django 框架自带处理程序,不需要默认的 mod_python.publisher 处理程序。

下面的语句告诉 Apache 调用 Django 的处理程序:

PythonHandler django.core.handlers.modpython

正如你已经知道的,Django 中的每个网站实际上都是一个 Python 模块,有它的配置文件。Django 处理程序需要这些信息,这样它就可以加载配置并找到合适的函数。下面两行提供了这些信息。第一个指令将我们的基本目录添加到默认的 Python 路径中,第二个指令设置一个环境变量,该变量标识哪个框架将用于获取要加载的模块的名称。

PythonPath sys.path+['/var/app/virtual/']
SetEnv DJANGO_SETTINGS_MODULE ip_accounting.settings

我们还需要确定临时 Python 文件的存储位置。我们确保用户可以写这个目录,我们用它来运行 Apache web 服务器:

SetEnv PYTHON_EGG_CACHE /tmp

最后,让我们定义异常,这样静态内容(所有以/static/开头的内容)就不会交给 mod_python 进行处理。相反,将调用默认的 Apache 处理程序;它将简单地服务于任何请求的文件:

<Location "/static/">
    SetHandler None
</Location>

如果您按照这些说明来配置 Django,并且已经创建了您的第一个应用,并指示 Apache 相应地为其提供服务,那么您现在应该能够启动 web 浏览器并导航到 Django web 应用。此时,数据模型还没有创建,甚至 URL dispatcher 也没有配置,所以 Django 将只提供通用的“它工作了!”页面,如图 3-1 中的所示。

9781484202180_Fig03-01.jpg

图 3-1 。标准 Django 应用问候页面

Image 提示如果您看到的是“服务器错误”消息而不是标准页面,请检查包含 Python 异常或 Apache 错误消息的 Apache 错误日志文件,这可以帮助您确定错误的原因。

实现基本功能

一旦准备工作完成?包括 Django 安装和 Apache web 服务器的设置已经完成,我们可以继续开发 web 应用了。这个过程可以分为以下几个部分:

  • 创建模型
  • 定义 URL 模式
  • 创建视图

以我的经验,这个过程是非常迭代的;随着开发的进行,我继续修改我的模型,添加新的 URL,并创建新的视图。这种方法允许我非常快速地让一些东西工作起来,并且测试一些功能,即使整个应用还没有完成。不要认为这种方法是混乱的。恰恰相反;我只处理我在设计阶段确定并写下的元素。因此,这个过程仅仅是将一个巨大的工作分解成更小的、更易管理的块,这些块可以单独地、分阶段地进行开发和测试。

定义数据库模型

在继续之前,回头看一下表 3-1 并回顾我们将在数据模型中使用的字段。因为 Django 将对象映射到关系数据库,并且是自动进行的,所以我们需要为应用中使用的每个概念创建一个类定义,它将被映射到数据库中的表。

表 3-2 。常用的 Django 字段类型

|

字段类名

|

描述

布尔菲尔德 该字段只接受 True 或 False 值,除非它用于 MySQL 数据库,在这种情况下,该字段相应地存储值 1 或 0。在测试字段值时,请记住这一点。
加菲 使用该字段存储字符串。它需要一个 max_length 参数来设置它可以存储的字符串的最大长度。不要使用此字段存储大量文本;请改用 TextField。
戴达菲尔德 将日期存储为 Python datetime.date 类的实例。该字段类接受两个可选参数:auto_now,如果设置为 True,则在每次保存对象时将字段值设置为当前日期;auto_now_add,如果设置为 True,则仅在首次创建时将字段值设置为当前日期。这两个参数都强制 Django 使用当前日期,并且不能被覆盖。
日期 将日期和时间存储为 Python datetime.datetime 实例。使用与 DateField 相同的可选参数。
德西马菲尔德 用于存储固定精度的十进制数。需要两个参数:max_digits,用于设置数字的最大位数 decimal _ places,用于设置小数位数。
邮箱 类似于 CharField,但也检查有效的电子邮件地址。
文件字段 用于存储上传的文件。请注意,文件不是存储在数据库中,而是存储在本地文件系统中。该字段需要一个参数 path_to,它指向相对于 MEDIA_ROOT 目录的。您可以使用 strftime 变量根据当前日期和时间构造路径名和文件名。必须在当前项目的设置文件中设置 MEDIA_ROOT。
浮田 存储浮点数。
ImageField(图像字段) 非常类似于 FileField,但是额外执行一个文件是有效图像的检查。还有两个可选参数:height_field 和 width_field,它们存储模型类变量的名称,并将根据上传的图像尺寸自动填充。使用此字段类型需要 Python 影像库(PIL)。
-你是什么意思 存储整数值。
正积分域 存储整数值,但只允许正整数。
NullBooleanField 像 BooleanField 一样存储 True 和 False,但也不接受任何值。在需要是/否/未定义选项的组合时很有用。
斯拉格菲尔德 像 CharField 一样存储文本,但只允许字母数字字符、下划线和连字符。用于存储 URL(没有域部分!).max_length 参数默认为 50,但可以被覆盖。
TextField 对象 用于存储大块文本。
时间字段 将时间存储为 Python datetime.time 实例。接受与 DateField 相同的可选参数。
URLField 先生 用于存储包括域名在内的 URL。有一个可选的参数 verify_exists,它检查 URL 是否有效,是否实际加载,并且不返回 404 或任何其他错误。
XMLField 一个文本字段,它还检查文本是否是有效的 XML,是否对应于 RELAX NG(www.relaxng.org)定义的 XML 模式。需要参数 schema_path,该参数必须指向有效的架构定义文件。

我们只有一个表,所以让我们为它定义类,如清单 3-2 所示。将这段代码添加到 models.py 文件中,就在默认内容的下面。

清单 3-2 。定义应用网络地址模型的数据类

class NetworkAddress(models.Model):
    address = models.IPAddressField()
    network_size = models.PositiveIntegerField()
    description = models.CharField(max_length=400)
    parent = models.ForeignKey('self')

这段代码非常简单明了。我们首先定义一个新的类 NetworkAddress,它继承自 Django 的模型。模型类,在 django.db 模块中定义。所以这个类变成了一个定制模型,Django 将用它来创建数据库表。这个模型类还将用于动态创建数据库 API。稍后我将展示如何使用这个 API。

在这个类中,我们通过用 models 类中的适当对象初始化类变量来定义三个字段。Django 提供了许多不同类型的字段,表 3-2 列出了最常用的类型。

要创建数据库表,我们只需使用带有 syncdb 选项的 manage.py 工具。当我们第一次运行它时,它还会为设置文件中列出的其他应用创建表(身份验证、Django 内容类型以及会话和站点管理)。内置身份验证应用需要一个管理员帐户,因此它会多问几个问题:

$ python manage.py syncdb
Creating tables ...
Creating table django_admin_log
Creating table auth_permission
Creating table auth_group_permissions
Creating table auth_group
Creating table auth_user_groups
Creating table auth_user_user_permissions
Creating table auth_user
Creating table django_content_type
Creating table django_session
Creating table ip_addresses_networkaddress

You just installed Django's auth system, which means you don't have any superusers defined.
Would you like to create one now? (yes/no): yes
Username (leave blank to use 'rytis'):
Email address: rytis@example.com
Password:
Password (again):
Superuser created successfully.
Installing custom SQL ...
Installing indexes ...
Installed 0 object(s) from 0 fixture(s)
$

这个命令行对话框已经成功地在数据库中创建了所有必需的表。为了确切地了解我们的表在数据库中的结构,我们使用以下命令:

$ python manage.py sql ip_addresses
BEGIN;
CREATE TABLE "ip_addresses_networkaddress" (
    "id" integer NOT NULL PRIMARY KEY,
    "address" char(15) NOT NULL,
    "network_size" integer unsigned NOT NULL,
    "description" varchar(400) NOT NULL,
    "parent_id" integer REFERENCES "ip_addresses_networkaddress" ("id")
)
;
COMMIT;
$

如您所见,Django 使用变量名作为表中字段的名称,表名由应用和模型类名构成。这很方便,因为它确实提供了一定程度的名称间距,所以您不必担心您的类名与另一个应用的类名冲突。

URL 配置

在 Django 开发过程中,您会发现自己经常更改 URL 配置,因为您将添加新的视图和功能。为了不使这个过程失控,你需要为如何定义新的 URL 制定一些基本规则。虽然 Django 让您可以完全控制整个过程,但是通过选择合理的 URL 结构和命名约定,对他人(尤其是对自己)好一点。

对于如何创建 URL,没有明确的规则或指导方针。作为一名系统管理员,您可能不会开发面向大量用户的 web 系统,因此您可以更轻松地组织它们。然而,我想提出一些我认为非常有用的指导方针

  • 总是以应用的名称开始。在 IP 地址示例中,所有 URL(包括域名)都将是 http://www . example . com/IP _ address/[...].如果你想在你的网站中使用另一个应用,你不必担心 URL 名称重叠。例如,视图功能就很常见。在我们的例子中,如果我们没有将应用名称放在前面,并且我们有两个应用 A 和 B,如果它们都想使用 URL /view/的话,我们就会遇到问题。
  • 将模型名称放在应用名称之后。如果需要同一类型的更具体的对象子集,请在模型名称后添加选择标准。尽可能避免使用对象 id!因此,继续我们的示例,我们将拥有 ip_addresses/networkaddress/,它列出了所有顶级网络。如果我们导航到/IP _ addresses/network address/109 . 168 . 0 . 0/
  • 如果需要对任何对象进行操作,请在特定对象名称后添加操作谓词。因此,在我们的示例中,如果我们想要一个到网络地址的删除函数的链接,我们将使用/IP _ addresses/network address/192 . 168 . 0 . 1/delete。

这些指导原则可以通过以下示例 URL 进行总结:

http://www.example.com/<application>/<model>/<object>/<action>/

URL 映射在 urls.py 模块中定义,其默认设置如清单 3-3 所示。

清单 3-3 。站点范围的 urls.py 文件的默认内容

from django.conf.urls import patterns, include, url

from django.contrib import admin
admin.autodiscover()

urlpatterns = patterns('',
    # Examples:
    # url(r'^$', 'www_example_com.views.home', name='home'),
    # url(r'^blog/', include('blog.urls')),

    url(r'^admin/', include(admin.site.urls)),
)

这个文件的结构非常简单明了。最重要的部分是 urlpatterns 变量,它是 URL 元组的列表。每个条目(元组)有三个部分:

url(<regular expression>, <callback function>, <dictionary (optional)>)

当用户从 Django web 应用请求一个页面时,会发生这样的情况:请求被发送到 Apache web 服务器,该服务器又会调用它的 Django 处理程序。Django 框架将检查 urlpatterns 中的所有条目,并尝试将每个正则表达式与所请求的 URL 进行匹配。当找到匹配时,Django 将调用一个与正则表达式相关联的回调函数。它将传递一个 HttpRequest 对象(我将在视图部分讨论这一点)和一个可选的从 URL 获取的参数列表。

我强烈建议您不要在主 urls.py 文件中定义任何特定于应用的 URL 规则;使用您正在开发的应用的本地配置。通过这种方式,您可以将应用 URL 从网站中分离出来,从而允许您在不同的项目中重用相同的应用。

让我解释一下这是如何工作的。解耦相当简单;您只需在应用模块中定义特定于应用的 URL,并在所有以应用名称开头的请求中引用该文件。因此,在我们的示例中,我们将在主项目 urls.py 中包含以下条目:

urlpatterns = ('',
    [...]
    url(r'^ip_addresses/', include('www_example_com.ip_addresses.urls')),
    [...]
)

而特定于应用的配置文件 ip_addresses/urls.py 包含:

urlpatterns = patterns('',
    [...]
)

如您所见,main urls.py 将捕获所有以 ip_addresses/开头的 URL,URL 的其余部分被发送到 ip_addresses/urls.py 进行进一步处理。

使用管理界面

我们现在可以继续创建一些视图和表单来显示记录,并添加和删除它们,但是在我们这样做之前,下面是如何启用 Django 管理界面。这是一个非常方便的工具,它提供了对数据的即时访问,具有完整而丰富的功能,允许您添加、删除、修改、搜索和过滤存储在数据库中的记录。它在开发阶段也非常有用,允许您在创建表单添加新记录之前添加新记录并创建显示视图。

启用管理界面

Image 注意在 Django 的早期版本中,管理界面默认是禁用的。在 Django 的最新版本中,管理界面会自动为您启用,因此您不需要做任何事情。浏览这里的说明并熟悉配置文件结构仍然是一个好主意。

启用管理界面只需做很少的工作:将其添加到站点配置中的应用列表,启用 URL 规则,并配置 Apache 为界面提供静态内容(主要是 CSS 和 JS 脚本)。您可以修改 settings.py 模块中的 INSTALLED_APPS 列表,使其包含管理包:

INSTALLED_APPS = (
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.sites',
    'django.contrib.admin',
    'ip_addresses',
)

完成之后,您需要重新运行 syncdb 命令,以便在数据库中为管理应用创建新表:

$ python manage.py syncdb
Creating table django_admin_log
Installing index for admin.LogEntry model
$

取消对 urls.py 模块中与管理插件相关的所有行的注释。你要确保你的 urls.py 看起来像清单 3-4 中的。

清单 3-4 。在 urls.py 模块中启用管理界面

from django.conf.urls import patterns, include, url

from django.contrib import admin
admin.autodiscover()

urlpatterns = patterns('',
    # Examples:
    # url(r'^$', 'www_example_com.views.home', name='home'),
    # url(r'^blog/', include('blog.urls')),

    url(r'^admin/', include(admin.site.urls)),

    # ip_addresses application
    url(r'^ip_addresses/', include('ip_addresses.urls')),
)

您在 DocumentRoot 目录中创建一个链接,这样/opt/local/django-trunk/django/contrib/admin/media 的内容就可以由 Apache 从 URL www.example.com/static/admin:提供服务

$ ln –s /usr/share/django/django/contrib/admin/media \
/var/www/virtual/www.example.com/static/admin

一旦您完成了所有这些准备工作,您应该能够导航到 www.example.com/admin 并看到管理界面登录页面,如图 3-2 所示。

9781484202180_Fig03-02.jpg

图 3-2 。Django 管理登录页面

您可以使用先前在第一次运行 syncdb 时创建的管理员帐户登录。登录后,您将看到基本的用户和站点管理选项,如图 3-3 所示。

9781484202180_Fig03-03.jpg

图 3-3 。Django 管理界面的默认视图

允许管理插件管理新模型

您可能已经注意到,Django 管理界面还没有提供任何管理 NetworkAddress 模型的选项。这是因为它没有找到任何这样做的指令。向管理界面添加任何数据模型类都非常容易;您需要做的就是在您的应用目录 admin.py 中创建一个新的 Python 模块,包含清单 3-5 中的代码。

清单 3-5 。将网络地址类添加到管理界面

from www_example_com.ip_addresses.models import NetworkAddress
from django.contrib import admin

class NetworkAddressAdmin(admin.ModelAdmin):
    pass

admin.site.register(NetworkAddress, NetworkAddressAdmin)

在我们的示例中,我们首先从标准 Django 包中导入 NetworkAddress 类和管理模块。然后,我们为我们想要置于管理模块控制之下的每个模型定义一个管理类。管理类的命名约定是 Admin。该类必须从 admin 继承。ModelAdmin 类,它定义了模型管理界面的默认行为。

对于我们的简单模型,没有必要调整默认行为。它允许查看/添加/删除/修改等基本功能,并且因为我们将创建自己的具有附加功能的界面(例如以层次顺序显示信息),所以我们不需要 Django 管理模块的任何额外功能。

你可以用自动生成的界面玩一玩;尝试添加新条目并修改现有条目。此外,尝试输入无效信息,比如格式错误的 IP 地址,并检查 Django 管理界面对错误的反应。您会注意到无效的 IP 地址不被接受;但是,没有逻辑检查网络大小是否在适用的范围内:1–32。(我们将不得不在表单级别使用验证,我将在后面描述。)

查看记录

让我们从最简单的视图开始,它的目的是表示数据库中定义的所有网络的信息。现在,您必须使用前面创建的管理界面来添加新网络和定义关系。

首先,我们需要定义 URL 映射规则,以便将请求重定向到适当的视图函数:

urlpatterns = patterns(ip_addresses.views',
    url(r'^networkaddress/$', 'display'),
    url(r'^networkaddress/(?P<address>\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2})/$',
     'display'),
)

第一个规则匹配 URL /ip_address/networkaddress/第二个规则搜索类似/IP _ address/network address/a . b . c . d/x/的 URL。它还调用 display 函数,但这一次它传递关键字参数 address,该参数用字符串 a.b.c.d/x 初始化。

让我们通过定义一个视图的简化版本来快速测试一下这是否可行。现阶段我们想知道的是,我们的两条规则是否如预期的那样起作用。清单 3-6 是一个简单的 views.py 文件的例子,它将测试我们的 URL。

清单 3-6 。测试 URL 调度程序规则的简单视图

from ip_addresses.models import *
from django.http import HttpResponse

def display(request, address=None):
    if not address:
        msg = "Top of the address tree"
    else:
        msg = "Top level address: %s" % address
    return HttpResponse(msg)

这里发生的事情非常简单。我们导入模型类和 HttpResponse 类。Django 框架期望一个 HttpResponse 的实例或者一个由它调用的任何视图函数产生的异常。显然,view 函数在这一点上并没有多大作用;它将只显示来自 URL 的 IP 地址,或者告诉你它在地址树的顶端,如果在 URL 中没有找到 IP。

在开始创建更复杂的视图之前,这是整理 URL 映射正则表达式的好方法。在调试视图功能时,您需要知道您的映射是否按预期运行。

Image 注意同时包含 IP 地址和网络大小的原因是,只有这两个地址对才能创建唯一的对象。如果您只使用 IP 地址,在大多数情况下,它可能是不明确的。例如,192.168.0.0 (/24)和 192.168.0.0 (/25)不是同一个网络,尽管它们的 IP 地址相同。

现在,在继续之前,让我们在数据库中创建一些条目。您必须使用 Django 管理界面,因为没有用于输入数据的定制表单。表 3-3 包含示例数据,你可以用它来创建类似的条目,并将本书中的结果与你在实施过程中得到的结果进行比较。

表 3-3 。一个样本 IP 网络和地址数据集

Table3-3.jpg

手动添加的内容似乎很多。如果您想手动创建所有记录,那很好,但是 Django 有另一个特性:您可以将初始数据作为 fixture 文件提供。Django 版本支持三种格式:XML、YAML 和 JSON。这在开发和测试阶段非常有用。您只需创建一次初始数据,然后在需要时使用确切的数据集重新创建数据库。

清单 3-7 显示了我们将用来初始化数据库的样本夹具文件的一部分。我在这里选择使用 JSON,主要是因为它的简单性、可读性和可支持性。

清单 3-7 。用于加载初始数据的 sample_data.json 文件摘录

[
...
    {
        "model":  "ip_addresses.networkaddress",
        "pk": 1,
        "fields": {
            "address":  "192.168.0.0",
            "network_size": 24,
            "description": "First top level network"
        }
    },
...
    {
        "model":  "ip_addresses.networkaddress",
        "pk": 3,
        "fields": {
            "address":  "192.168.0.0",
            "network_size": 25,
            "description": "Subnet 1-1",
            "parent": 1

    },
...
]

文件的结构非常简单明了。每条记录都以定义模型类开始,后跟主键,主键是一个整数,除非您已经显式地重新定义了它。最后,所有类字段都在“字段”部分的“键:”值”对中列出。

如果记录之间有任何关系,它们通过使用主键值来定义,就像在这个例子中一样;子网 1-1 有一个父记录,通过将“parent”设置为值 1(父记录的主键)来引用它。

如果该字段是可选的,您可以跳过它。创建文件后,使用以下命令加载数据:

$ python manage.py loaddata sample_data.json
Installed 20 object(s) from 1 fixture(s)
$

使用模板

模板在 Django 框架模型中扮演着重要的角色。正是模板允许开发人员将应用逻辑从表示中分离出来。同样,模型定义数据结构,视图函数负责数据查询和过滤,模板定义数据如何呈现给最终用户。

Django 附带了一种灵活而复杂的模板语言。让我们看看如何将模板与视图函数获得的数据一起使用。首先,我们需要定义一个视图,该视图将查询数据库并获取我们将呈现给用户的信息。清单 3-8 显示了新的显示功能。

清单 3-8 。使用模板显示数据的视图功能

def display(request, address=None):
    if not address:
        parent = None
    else:
        ip, net_size = address.split('/')
        parent = NetworkAddress.objects.get(address=ip, network_size=int(net_size))
    addr_list = NetworkAddress.objects.filter(parent=parent)
    return render_to_response('display.html',
                              {'parent': parent, 'addresses_list': addr_list})

正如您已经知道的,Django 的 URL dispatcher 调用 display 函数时要么没有初始 IP 地址(当用户请求树的顶端列表时),要么没有初始 IP 地址(请求显示子网的内容)。如果地址字段为空,我们将显示所有没有父节点的树节点。如果地址字段不为空,我们需要获取父节点设置为给定地址的树节点列表。结果存储在 addr_list 中,并传递给模板。

需要显示两个实体:关于当前树节点的信息和其子节点的列表。所以我们必须将两者作为变量传递给模板渲染过程。在我们的示例中,我们使用一个名为 render_to_response 的快捷函数,它接受两个参数:模板文件的名称和模板将用来呈现 HTML 输出的变量字典。

可以使用以下 import 语句导入 render_to_response 快捷方式:

from django.shortcuts import render_to_response

正如您所看到的,我们指定了模板名,而没有任何前面的目录路径,那么 Django 如何知道在哪里寻找模板呢?默认情况下,settings.py 配置文件中启用了以下模板加载器:

TEMPLATE_LOADERS = (
                    'django.template.loaders.filesystem.load_template_source',
                    'django.template.loaders.app_directories.load_template_source',
)

我们使用 app _ directories 加载程序提供的功能。这个加载器查找存储在 templates/子目录下的应用目录中的模板。将模板存储在应用中非常有用,因为这允许开发人员为每个应用分发一组默认模板。因此,回到我们的示例,我们需要在应用目录 ip_addresses 中创建一个名为“templates”的子目录。然后我们创建清单 3-9 所示的模板,它负责显示由显示视图函数传递给它的信息。

清单 3-9 。显示视图的模板

{% if parent %}
Current address: {{ parent.address }}/{{ parent.network_size }}</h1>
<a href="../../{% if parent.parent %}{{ parent.parent.address }}/{{
 parent.parent.network_size }}/{% endif %}">Go back</a></h2>
{% else %}
At the top of the networks tree</h1>
{% endif %}

{% if addresses_list %}
    <ul>
    {% for address in addresses_list %}
        <li><a href="{% if parent %}../../{% endif %}{{ address.address }}/{{
 address.network_size }}{% ifequal address.network_size 32 %}/modify/{% endifequal %}">{{
 address.address }}/{{ address.network_size }}
           </a>
            {% ifequal address.network_size 32 %}(host){% else %}(network){% endifequal %}
            {{ address.description }}
            (<a href="{% if parent %}../../{% endif %}
                      {{ address.address }}/{{ address.network_size }}/delete/">delete</a> |
            <a href="{% if parent %}../../{% endif %}
                     {{ address.address }}/{{ address.network_size }}/modify/">modify</a>)
        </li>
    {% endfor %}
    </ul>
{% else %}
{% ifequal parent.network_size 32 %}
This is a node IP
{% else %}
No addresses or subnets in this range
{% endifequal %}
{% endif %}
<a href="add/">Add new subnet or node IP</a></h2>

您可能已经猜到模板语言标记是用{%括起来的...%}或{{...}}.这两种形式的区别在于,前者用于包围命令和过程控制语句,例如比较和验证操作符,而后者用于指示需要在指定位置显示变量的内容。

引用对象属性时,所有变量都遵循相同的 Python 约定。例如,在模板中,parent 是 NetworkAddress 模型类的一个实例,因此 parent 具有属性 Address。为了在模板中显示该变量,我们需要将其作为 parent.address 引用。

表 3-4 列出了你会经常用到的基本命令结构。

表 3-4 。Django 模板语言最常见的元素

|

结构

|

描述

{% if %}{% else %}{% endif %} 最常用于测试变量是否已定义且内容不为空。根据结果,您可以显示变量的值,也可以提供一条信息性消息,告知找不到该值。
{% for in %}{ % endfor % } 遍历中的所有项,并将单个列表项分配给,您可以在 for 构造中使用它。
{ % ifqual% }{% else %}{% endifequal %} 比较两个变量,并根据结果处理两个模板块中的一个。
{% comment %}{% endcomment %} 这两个运算符之间的所有内容都将被忽略。

正如您在模板中看到的,我已经添加了 URL 链接来删除、修改和添加记录。即使在这个阶段,所有这些都是可能的,因为我们最初设定了需求,并且在开发过程的任何阶段,我们都精确地知道需要做什么。

在这种情况下,应用还没有准备好执行这些操作,但是我们需要进行布局设计并在模板中实现它。如果您需要将模板交给其他人,这尤其有用,因为那个人不必猜测您可能需要什么操作和什么链接,即使功能尚未实现,他也可以创建它们。

图 3-4 显示了当我们导航到一个预先创建的网络地址时,应用网页的外观。

9781484202180_Fig03-04.jpg

图 3-4 。网络地址列表

删除记录

我们已经有了一个到列出了每个 IP 地址的 delete 函数的链接,如您所知,它的基本 URL 与列表函数的相同,但是它还附加了/delete/例如,以下是 192.168.0.0/25 网络的删除 URL:

http://www.example.com/ip_addresses/networkaddress/192.168.0.0/25/delete/

首先,我们需要“教”Django 识别这个 URL 并调用删除函数(或视图)。让我们通过将以下 URL 规则添加到 urls.py 文件来实现这一点:

url(r'^networkaddress/(?P<address>\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2})/delete/$',
      'delete'),

清单 3-10 显示了 Django 框架在遇到匹配该规则的 URL 时将调用的删除函数。

清单 3-10 。删除视图

def delete(request, address=None):
    ip, net_size = address.split('/')
    parent = NetworkAddress.objects.get(address=ip,
                                        network_size=int(net_size)).parent
    NetworkAddress.objects.get(address=ip, network_size=int(net_size)).delete()
    redirect_to = '../../../'
    if parent:
        redirect_to += '%s/%s/' % (parent.address, int(parent.network_size))
    return HttpResponseRedirect(redirect_to)

在我们的示例中,地址变量总是包含 x.x.x.x/y 格式的 IP 地址,其中 x.x.x.x 是 IP 地址,y 是网络位数。我们不以这种格式存储地址信息,所以我们必须将其拆分为两部分,然后才能使用它来查找所需的记录。

在我们删除对象之前,让我们通过运行下面的 object get 方法来找出它的父对象:

parent = NetworkAddress.objects.get(address=ip, network_size=int(net_size)).parent

一旦我们检索到了对象,让我们简单地调用 delete()方法,该方法可用于任何 Django 模型对象。

您可能想知道作为我们刚刚删除的树节点的子节点的对象会发生什么情况。Django 框架足够智能,可以运行一个递归 SQL 查询,该查询将跟踪外键并删除树中所有相关的对象。

对象删除完成后,我们通过返回 HttpResponseRedirect 对象并以 path 作为其初始化参数来重定向到当前视图。

Image 提示你注意到我们如何在重定向 URL 中使用相对路径了吗?我们这样做是因为我们不知道如果有人重用代码,项目甚至应用将被调用。我们知道的是 URL 结构,所以我们可以计算出我们需要重定向到哪里,并使用相对路径。尽量避免使用绝对路径和在生成的 URL 中嵌入应用名称。

添加新记录

这是需要用户输入的功能。因为我们的模型相当简单,所以只需要填写几个字段,特别是 IP 地址、网络大小和描述。根据用户单击添加链接时所处的位置,将自动分配一个父树节点。例如,如果用户导航到www . example . com/ip _ addresses/network address/192 . 168 . 1 . 0/24/并点击添加新 IP 链接,新记录将自动获得 192.168.0.1/24 作为父记录。

Django 中有两种处理数据输入的方式:硬方式和 Django 方式。如果我们选择硬方法,我们需要在模板中定义表单,手动处理请求 HTTP POST 变量,执行验证,并进行数据类型转换。或者,我们可以选择使用 Django 表单对象和小部件,它们会自动为我们完成所有这些工作。

因此,首先我们需要定义一个表单模型类,用于生成 HTML 表单小部件。我们通过在 models.py 中定义清单 3-11 中所示的类来实现这一点。

清单 3-11 。地址添加表单类

from django.forms import ModelForm

[...]

class NetworkAddressAddForm(ModelForm):
    class Meta:
        model = NetworkAddress
        exclude = ('parent', )

这里发生的事情是,我们定义了一个表单类,它使用一个数据模型类作为原型。换句话说,这告诉 Django 生成一个表单来接受数据模型中定义的数据。我们可以选择定义任意的表单类,使用任意的字段集,但是在这个例子中,我们只需要数据模型中的三个字段。

坚持住。我们有四个字段,其中一个是父对象。但是我们不希望用户能够选择父对象,仅仅因为它在创建时就已经知道了。另一个原因是,对于大型数据库,父列表可能会变得太大而无法处理。因此,我们必须使用排除列表来指明哪些字段不需要显示在表单中。

第二步是定义处理视图的表单。这个视图与普通的只显示数据的视图函数略有不同,因为它可以通过两种不同的方式调用:作为 HTTP GET ,这意味着用户刚刚导航到表单页面;或者作为 HTTP POST ,这意味着用户提交了表单数据。

在 HTTP GET 的情况下,我们只显示空表单。如果我们收到一个 HTTP POST,我们将必须检查表单是否有效。如果表单数据有效,我们必须调用表单的 save()函数,这将在数据库中创建一个新对象。如果表单无效,它将再次显示,请求中的字段条目已经填充,错误消息解释了错误。

我们如何验证表单?非常简单——通过调用另一个 form 方法:is_valid(),如列表 3-12 所示。我们甚至不需要考虑错误信息;这些也是根据模型的数据类型自动创建的。

清单 3-12 。Add 函数的视图方法

from django.template import RequestContext

[...]

def add(request, address=None):
    if request.method == 'POST':
        parent = None
        if address:
            ip, net_size = address.split('/')
            parent = NetworkAddress.objects.get(address=ip,
                                                network_size=int(net_size))
        new_address = NetworkAddress(parent=parent)
        form = NetworkAddressAddForm(request.POST, instance=new_address)
        if form.is_valid():
            form.save()
            return HttpResponseRedirect("..")
    else:
        form = NetworkAddressAddForm()
    return render_to_response('add.html', {'form': form,},
                              context_instance=RequestContext(request))

在这个视图中,我们还执行了创建新对象的附加步骤。通常从 POST 数据创建新表单如下所示:

form = NetworkAddressAddForm(request.POST)

但是要记住;在我们的表单中没有父字段,我们需要从 URL 中的地址部分派生它。因此,我们需要自己找到父对象,并将其分配给新对象:

new_address = NetworkAddress(parent=parent)
form = NetworkAddressAddForm(request.POST, instance=new_address)

用实例参数调用表单初始化函数会强制 Django 使用分配给它的对象,而不是创建一个新的对象。

你可以看到我们使用了模板 add.html,并将表单对象传递给它。清单 3-13 显示了模板的样子。

清单 3-13 。添加表单模板

<form action="." method="POST">
{% csrf_token %}
{{ form.as_p }}
<input type="submit" value="Add" />
</form>

是的,它是那么短,但它做了很多。首先,它将呈现一个 HTML 表单,带有适当的字段和一个提交按钮。如果提交的表单数据无效,它也会显示错误消息。

演示文稿(如图 3-5 所示)是完全可定制的,但为了简单起见,我们只使用。as_p 标签,因此字段将显示在< p >标签内,以便更好地对齐。

9781484202180_Fig03-05.jpg

图 3-5 。HTML 页面上的表单小部件

Image 注意Django 的新版本(从 1.5 开始)强制执行跨站点请求伪造(CSRF) 默认检查。简而言之,CSRF 是指恶意网站试图使用输入到该网站的数据在您的网站上执行某些操作。这很糟糕,因为这意味着有人可以伪装成合法网站,并可以收集敏感的用户数据。为了确保这种情况不会发生,Django 在每次构建表单时都会生成一个惟一的令牌,这个令牌会和表单数据一起发送回去。Django 然后检查提供的令牌是否与本地存储的令牌匹配。如果匹配,则请求是真实的;否则,其他人正在试图发送数据,这样的请求需要被忽略。

最后,我们确保向 urls.py 文件添加两条新规则,一条用于向子网范围添加地址,另一条用于添加顶级地址:

url(r'^networkaddress/(?P<address>\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2})/add/$',
      'add'),
  url(r'^networkaddress/add/$', 'add'),

修改现有记录

对象修改的表单和视图与添加表单和视图非常相似。唯一的区别是允许用户编辑的字段将会更少。实际上,如果用户决定更改 IP,他或她需要删除记录并在另一个网络中重新创建。因此,我们将只允许用户更改记录的描述。

因此,我们的表单对象中唯一的字段是描述,如清单 3-14 所示。

清单 3-14 。修改表单类

class NetworkAddressModifyForm(ModelForm):
    class Meta:
        model = NetworkAddress
        fields = ('description',)

正如您所看到的,我们没有排除字段,而是使用了字段列表,它告诉 Django 应该包括哪些字段;所有其他字段都将被忽略。

视图方法与用于添加新记录的方法非常相似。事实上,除了一点之外,一切都是一样的:在第一次查看表单时,表单已经预先填充了数据库中的数据,因为用户正在更改现有的数据,而不是创建新的记录。

保存更改是一样的,因为 Django 发现记录已经存在并更新它,而不是添加一个新的。从清单 3-15 中可以看到,甚至连模板都是不做任何改动就重用的。

清单 3-15 。修改视图方法

def modify(request, address=None):
    if request.method == 'POST':
        # submitting changes
        ip, net_size = address.split('/')
        address_obj = NetworkAddress.objects.get(address=ip,
                                                 network_size=int(net_size))
        form = NetworkAddressModifyForm(request.POST, instance=address_obj)
        if form.is_valid():
            form.save()
            return HttpResponseRedirect("..")
    else:
        # first time display
        ip, net_size = address.split('/')
        address_obj = NetworkAddress.objects.get(address=ip,
                                                 network_size=int(net_size))
        form = NetworkAddressModifyForm(initial={ 'description':
                                                   address_obj.description, })
    return render_to_response('add.html', {'form': form,},
                              context_instance=RequestContext(request))

我们还向 url dispatcher 配置文件 urls.py 添加了两条新规则:

url(r'^networkaddress/(?P<address>\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2})/modify/$',
    'modify'),
url(r'^networkaddress/modify/$', 'modify'),

摘要

在这一章中,我们介绍了如何设计一个应用,以及如何从需求收集和规范到设计再到实际的实现。还解释了如何使用 Django 框架进行快速 web 应用开发。

  • 总是从需求规格开始。这将作为一个参考点,并简化测试。它还有助于管理用户期望。
  • 首先设计数据模型,确保设计符合需求。
  • 将应用从项目(或网站)中分离出来,以便可以多次重用。

四、将 IP 地址应用与 DHCP 集成

在前一章中,我们实现了一个简单的 IP 记账应用,允许用户跟踪他们的 IP 地址资产。我描述了应用的整个生命周期,从需求收集到设计阶段,最后到实现。重点是需求和设计阶段的重要性,因为这允许开发者验证其实现。

您可能已经注意到,尽管我们实现了大部分的初始需求,但是我们并没有得到所有的需求!我故意漏掉了几个,比如搜索功能,DNS 解析,主动检查。我这样做主要是为了演示验证您的实现并显示其中缺少的东西是多么容易,但也只是为了将章节保持在可管理的大小,而不是让信息淹没您。

因此,在本章中,我们将实现缺失的组件,并通过添加对 DHCP 服务的支持来扩展原始设计的新功能。

扩展设计和要求

我在前一章提到了“支持 DHCP”作为一个需求,但是我们真正想要的是什么呢?让我们看看 DHCP 在一个典型的组织中是如何使用的。我将假设 ISC DHCP 服务,它在大多数 Linux 发行版中广泛可用。

在子网中分配地址时,我们有以下选项:

  • 静态分配 IP 地址,在这种情况下,我们为每台设备配置自己的 IP 地址。
  • 根据使用 DHCP 服务的一组规则,动态分配 IP 地址。

Image 提示在继续本章之前,您可能需要安装 ISC DHCP 服务器软件包。您可以通过使用 Linux 发行版中可用的软件包管理器来实现这一点(在 RedHat Linux 上,可以使用命令 yum install dhcp 来实现,或者在基于 Debian 的系统上,可以使用 apt-get install isc-dhcp-server)。或者,你可以从 http://www.isc.org/software/dhcp 的 ISC DHCP 官方网站下载。

让我们快速回顾一下 DHCP 能做什么以及它是如何配置的。ISC DHCP 允许您定义非常复杂的规则集。最简单的集合根本不包含任何规则,在这种情况下,假设地址池中有可用的空闲 IP,那么对 IP 的任何请求都将被准许,并且从可用池中分配一个唯一的地址。

一个常用的规则是根据硬件 MAC 地址分配 IP 地址。这种方法允许您始终为同一台机器分配相同的 IP 地址,但不要求在服务器上进行本地配置。我们可以使用 DHCP group 指令来配置这样的主机:

group {
    ...
    host host001 {
        hardware ethernet 00:11:22:33:44:55;
        fixed-address 192.168.0.1;
    }
    ...
}

客户端分组的一种更高级的用法是 DHCP 客户端类别分离,通过这种方法,客户端被分组到满足某些标准的类别中。ISC DHCP 服务器为这种分离提供了许多选项。例如,您可以使用各种 DHCP 请求字段(甚至是它们的一部分)来对节点进行分组,例如使用 DHCP 主机名的一部分来标识发送请求的内容。您还可以通过阅读 dhcp-options 的 UNIX 手册页来了解哪些 DHCP 选项可用。以下示例使用 DHCP 选项 vendor-class-identifier 将所有 Sun Ultra 5 计算机归为一类:

class "sun-ultra-5" {
    match if option vendor-class-identifier "SUNW.Ultra-5_10";
}

对于第二个示例,此代码匹配 DHCP 主机名的开头,如果它以“server”开头,则将其放入单独的类中:

class "server" {
    match if substring (option hostname, 0, 6) = "server";
}

为了简单起见,让我们假设所有子网都在 DHCP 服务器上的同一个物理网络上,这意味着我们在定义新子网时将使用 shared-network 指令。

正如您所看到的,简单的调查过程正逐渐演变为做出某些设计决策。这种任务模糊的情况应该尽可能避免,我在这里展示它只是为了说明为了适应任何特定产品的限制(或特性)而改变设计是多么容易。

首先,让我们忽略我们所知道的关于特定 DHCP 服务器产品的一切,列出我们希望它做的所有事情。我们想象中的组织有多个网络,这些网络被细分为更小的子网,而这些子网又可以包含更小的子网。通常,如果子网被细分为更小的网络,它很少包含物理(或虚拟)主机的 IP 地址。这种网络中存在的唯一 IP 是网络设备的 IP 地址,如路由器、交换机和负载平衡器,它们都不是从 DHCP 获得 IP 的。因此,我们将只创建 DHCP 管理的子网,这些子网位于子网树的底部,不会细分为更小的网络。

在 DHCP 管理的网络中,我们希望有以下内容:

  • 完全不受 DHCP 服务器控制的静态分配的地址。换句话说,DHCP 服务器应该不知道该范围,并且不应该配置为提供该范围内的任何地址。每个 IP 地址都是在使用它的设备上手动配置的。
  • DHCP 服务器分配的静态地址。例如,我们希望根据请求者的 MAC 地址提供 IP 地址。
  • 根据客户端的属性(如硬件供应商、DHCP 参数值等)分配 IP 地址。因为我们确实知道我们需要多少 IP 地址,所以我们必须能够分配一个预定义的地址范围。我们也不希望仅仅局限于一组 DHCP 选项;我们应该完全控制所有可能的选择。
  • 分配给所有其他客户端的 IP 地址。和前面的需求一样,我们需要能够指定在这里使用的 IP 范围。

正如您所看到的,列出的需求与前面列出的非常相似,但是它们不包含任何对特定实现的引用。这种方法有两个主要优点:您可以自由选择任何您认为最适合该目的的产品,并且您可以将实现外包给其他团队。只要结果满足要求,我们并不真正关心技术实现细节。此外,拥有这份清单有助于你快速识别和选择合适的产品。

除了网络管理和 IP 分配要求,我们还有一些运营需求:

  • 我们需要生成配置,但不是立即应用,这样就可以手动检查和应用更改。
  • 我们不需要将对配置文件的手动更改传播回应用数据库。例如,如果我们手动将一些主机添加到 DHCP 配置中,我们不需要应用相应地更新数据库条目。
  • 在这个阶段,我们不希望应用控制 DHCP 服务;这将使用标准操作系统命令手动完成。

现在我们已经确定了需要什么,我们可以开始制定基本的设计决策:

  • 我们将使用 ISC DHCP,因为它允许我们实现所有列出的要求。
  • 我们将使用相同的 web 应用框架和语言,因为这个项目是另一个项目的扩展。
  • 配置文件将由同一个 web 应用生成(也就是说,没有从数据库读取并生成配置文件的外部工具)。

正如前面的例子一样,我们现在需要做两件事:定义扩展数据模型和创建应用工作流。

扩展数据库模式

这一次,数据模型比我们只需要收集网络和 IP 地址信息时要复杂得多。现在我们需要存储 DHCP 服务器的网络拓扑视图,包括每个 DHCP 子网内的所有分类规则和地址范围。因此,我们将把它分解成定义 DB 模型类、编写视图函数和测试的几个迭代。这种渐进的方法更容易处理,我们也更容易发现错误。

目前,我们只确定了我们将拥有以下数据模型类:

  • DHCP 网络,指向它的“赞助商”网络类别。这只能为没有任何子网的网络创建。
  • 地址池模型,它定义了 DHCP 网络中的地址范围,并且必须有相关的规则。
  • 规则模型,定义了对 DHCP 请求进行分类的规则。每个规则可以分配给一个或多个地址池。
  • “静态”DHCP 地址规则模型,允许根据请求者的硬件 MAC 地址分配 IP。

向工作流程添加内容

工作流程中还有一些额外的东西。首先,我们需要添加一个链接,为每个没有子网的网络创建(或删除)DHCP 网络。我们还需要允许用户添加和删除关于 DHCP 网络池、规则和静态 IP 地址的信息。这些选项中的每一个都可以在 DHCP 网络列表中找到。

添加 DHCP 网络数据

在第一次迭代中,我们将添加对 DHCP 网络定义的支持。我们将使用一种类似于大型项目的方法:定义数据模型,定义工作流,然后进入实现阶段。

数据模型

让我们从添加一个新数据类开始,它将存储关于 DHCP 网络的信息。该类将指向其“赞助商”物理网络类,并包含客户端所需的几个 DHCP 选项,如路由器地址、DNS 服务器和域名。清单 4-1 展示了我们将要添加到 models.py 文件中的内容。

清单 4-1 。DHCP 子网的数据模型类别

class DHCPNetwork(models.Model):
    physical_net = models.OneToOneField(NetworkAddress)
    router = models.IPAddressField()
    dns_server = models.ForeignKey(DNSServer)
    domain_name = models.ForeignKey(DomainName)

    def __unicode__(self):
        return "DHCP subnet for %s" % physical_net

在本例中,我们还引用了两个新实体: DNSServer 和 DomainName。它们的类也在 models.py 中定义,它们只包含关于 DNS 服务器的 IP 和域名的信息以及简短的注释。将它们与 DHCPNetwork 类分开的原因是,如果我们想要更改 DNS 服务器的 IP 地址,我们不需要遍历每个 DHCP 网络条目并进行更改。您可以在 Apress 网站上的源代码中找到其他类的定义。

其他工作流程

通用 DHCP 网络支持需要哪些额外的工作流?显然,我们希望在子网中添加或删除一个 DHCP 网络,假设该子网可以有一个相应的 DHCP 网络。定义了 DHCP 网络后,我们还想修改它的设置。与前一章一样,每个工作流操作都有自己的视图功能,添加和修改也有自己的数据输入表单。正如您已经知道的,除非在 URL 配置文件中定义视图,否则视图是不起作用的,因此 Django 框架知道当它收到用户的请求时要调用什么视图函数。

添加功能

首先,我们需要知道我们是否可以为子网提供“添加 DHCP 网络”功能。最简单、最合理的方法是查看网络显示视图,查看是否有任何地址条目的子网大小未设置为 32 位。如果存在子网大小不是 32 位的条目,则该子网不能启用 DHCP 否则,我们可以提供一个到 DHCP add 函数的链接。因此,网络视图将执行检查并传递一个布尔变量,我们将在模板中查询该变量,它将显示一条消息或提供一个链接。下面是视图代码中的快速检查:

for address in addr_list:
    if address.network_size != 32:
        has_subnets = True

以及对模板的补充:

<h3><a href="add/">Add new subnet or node IP</a></h3>
<h3>{% if has_subnets %}
DHCP support cannot be enabled for networks with subnets
{% else %}
<a href="dhcp/add/">Enable DHCP support</a>
{% endif %}
</h3>

你能看到产生的 URL 是什么吗?该结构遵循我们之前定义的 URL 的相同约定:

http://www.example.com/<application>/<model>/<object>/<action>/

到目前为止,对象是一对 IP 地址和它们的网络大小,它们唯一地标识了数据库中的每个对象。现在,对象是物理网络中的 DHCP 网络。DHCP 网络本身没有唯一标识它的东西。因此,让我们将/dhcp/添加到 IP/网络大小对中,这告诉我们这是这个特定网络的 dhcp 对象。假设用于添加 DHCP 网络的新视图名为 add_dhcp,这是需要添加到 URL 映射文件中的内容:

(r'^networkaddress/(?P<address>\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2})/
dhcp/add/$', 'add_dhcp'),

这个视图将遵循相同的表单处理模式,它看起来非常类似于我们用来添加新网络的视图。它还要求 form 类为模板自动生成一个表单模型:

class DHCPNetworkAddForm(ModelForm):
    class Meta:
        model = DHCPNetwork
        exclude = ('physical_net',)

我们排除物理网络字段,因为它在创建时已经是已知的;它是作为 URL 参数提供的。清单 4-2 展示了我们的 dhcp_add 函数,其中我们甚至使用了之前使用过的相同模板。

清单 4-2 。处理 DHCP 网络添加功能的视图

def add_dhcp(request, address=None):
    if request.method == 'POST':
        network_addr = None
        if address:
            ip, net_size = address.split('/')
            network_addr = NetworkAddress.objects.get(address=ip,
                                                      network_size=int(net_size))
        dhcp_net = DHCPNetwork(physical_net=network_addr)
        form = DHCPNetworkAddForm(request.POST, instance=dhcp_net)
        if form.is_valid():
            form.save()
            return HttpResponseRedirect("../..")
    else:
        form = DHCPNetworkAddForm()
    return render_to_response('add.html',
                              {'form': form,},
                              context_instance=RequestContext(request))

您可能想知道 DNS 和域名域发生了什么变化;它们是模型定义中的外键,那么用户应该在这里输入什么呢?在图 4-1 中,你可以看到 Django 将要显示的内容。

9781484202180_Fig04-01.jpg

图 4-1 。呈现的 DHCP 添加表单

Django 引擎足够聪明,能够判断出您希望为用户提供相关表中的对象选择,因此它生成了所有对象的下拉列表!这真的很聪明,它节省了你大量的编码。因此,您需要做的就是输入路由器详细信息,从列表中选择 DNS 服务器和域名,然后单击 Add 按钮。您可以通过转到管理界面并选择 DHCP Networks 视图来验证记录是否已成功创建。

尝试浏览并为其他网络启用 DHCP 支持。请注意,当您向上浏览地址树时,将不会向您提供启用 DHCP 的选项。

此时,我们还需要修改网络的显示模板,以便显示网络的 DHCP 设置的详细信息,并提供修改和删除设置的链接。

修改功能

修改视图功能与添加功能非常相似,除了不是为初始视图创建一个空表单,而是检索现有数据并显示在表单中。所以在清单 4-3 中,我们首先搜索一个现有的 DHCP 网络对象,然后将它传递给 form 类。

清单 4-3 。处理 DHCP 网络修改功能的视图

def modify_dhcp(request, address=None):
    ip, net_size = address.split('/')
    network_addr = NetworkAddress.objects.get(address=ip, network_size=int(net_size))
    dhcp_net = DHCPNetwork.objects.get(physical_net=network_addr)
    if request.method == 'POST':
        # submiting changes
        form = DHCPNetworkAddForm(request.POST, instance=dhcp_net)
        if form.is_valid():
            form.save()
            return HttpResponseRedirect("../..")
    else:
        # first time display
        form = DHCPNetworkAddForm(instance=dhcp_net)
    return render_to_response('add.html',
                              {'form': form,},
                              context_instance=RequestContext(request))

删除功能

这是一个简单的函数,搜索 DHCP 网络对象并删除它。目前,我们还没有定义任何相关的数据结构,比如 DHCP 池或规则,但是值得一提的是,所有相关的对象也会被自动删除。

使用地址池扩展 DHCP 配置

到目前为止,我们已经有了不错的代码来处理通用的 DHCP 子网信息,比如路由器、DNS 和域服务器地址。如果您需要任何额外的字段,您可以通过修改 DHCP 网络数据模型类,添加新的字段实例来轻松添加它们。您可能已经注意到,没有一个视图函数直接引用模型字段。Django 框架会自动处理新条目的添加。模板解析器将选择它们并相应地生成输入字段。

现在我们将进行第二次迭代,我们将添加对地址池数据的支持。如您所知,地址池是子网内的一个地址范围,可以根据客户端的类别分配给一组特定的客户端。例如,C 类子网有 254 个可分配给节点的可用地址。然后,我们可以指示 DHCP 服务器将前 10 个地址分配给主机名以 server 开头的主机;另外 10 个将用于请求太阳微系统公司的机器;等等。

地址池数据模型

典型的地址池允许定义特定于地址池的附加 DHCP 选项。例如,您可能希望增加某些池上的 DHCP 租用时间。并非所有服务器都需要短期 DHCP 地址,因此您需要增加这些服务器在池中的租用时间。或者您可能希望所有工作站使用不同的 DNS 服务器。在这个例子中,我们不允许任何额外的选项。因此,模型类看起来相对简单,只包含三个字段:指向其父 DHCP 网络对象的指针和两个边界地址。清单 4-4 显示了代码。

清单 4-4 。DHCP 池数据模型类

class DHCPAddressPool(models.Model):
    DHCPNetwork = models.ForeignKey(DHCPNetwork)
    range_start  = models.IPAddressField()
    range_finish = models.IPAddressField()

一旦将它添加到 models.py 中,其中还需要定义表单模型类,在 admins.py 文件中创建适当的记录,并运行 syncdb 命令,Django 将在数据库中创建一个表。请查看第三章中的“定义数据库模型”部分,了解详细说明。

DHCP 网络详细信息

作为第一个工作流,也是查看功能,我们将定义 DHCP 网络查看功能。我们已经在物理网络列表页面上显示了一些通用信息,但现在我们将会有更多与 DHCP 配置相关的项目,所以最好将它们显示在单独的页面上。本页将包含有关地址池和静态 IP 分配规则以及分类规则的信息。到目前为止,您应该对添加新视图相当熟悉了,并且您应该知道这涉及三个步骤:向 urls.py 文件添加 URL-to-view 映射函数规则;定义视图函数,并为视图创建模板。

下面是我们将用来调用 DHCP 显示视图的 URL 映射规则:

(r'^networkaddress/(?P<address>\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2})/dhcp/$',
 'display_dhcp'),

对于 DHCP 显示视图,我们引入了两个新函数:一个从 URL 编码的 IP/network_size 对中获取地址对象,另一个从相同的数据中获取 DHCP 网络对象。由于大多数函数需要执行这些操作,现在是时候将它们分开了,如清单 4-5 所示。

清单 4-5 。DHCP 池显示视图和助手功能

def display_dhcp(request, address=None):
    dhcp_net = get_dhcp_object_from_address(address)
    dhcp_pools = DHCPAddressPool.objects.filter(dhcp_network=dhcp_net)
    return render_to_response('display_dhcp.html', {'dhcp_net': dhcp_net,
                                                    'dhcp_pools': dhcp_pools,})

def get_network_object_from_address(address):
    ip, net_size = address.split('/')
    return NetworkAddress.objects.get(address=ip, network_size=int(net_size))

def get_dhcp_object_from_address(address):
    return DHCPNetwork.objects.get(physical_net=get_network_object_from_address(address))

DHCP 详细信息页面(清单 4-6 )显示了 DHCP 网络的基本信息,还列出了所有可用的池(如果它们已定义)。

清单 4-6 。DHCP 详细信息显示页面

<h1> DHCP details for {{ dhcp_net.physical_net.address }}/{{ dhcp_net.physical_net.network_size }} network</h1>
<h2><a href="../">Go back to network details</a></h2>
<ul>
<li>Router: {{ dhcp_net.router }}
<li>DNS: {{ dhcp_net.dns_server }}
<li>Domain: {{ dhcp_net.domain_name }}
</ul>
<p>( <a href="modify/">modify</a> | <a href="delete/">delete</a> )</p>
{% if dhcp_pools %}
<p>
<h3>Following DHCP pools are available:</h3>
<ul>
{% for pool in dhcp_pools %}
<li>{{ pool.range_start }} - {{ pool.range_finish }}
( <a href="../dhcp_pool/{{ pool.range_start }}/{{ pool.range_finish }}/delete/">delete</a> )
</li>
{% endfor %}
</ul>
</p>
{% else %}
<h3>There are no DHCP pools defined</h3>
{% endif %}
<p>
( <a href="../dhcp_pool/add/">add new pool</a> )
</p>

同样,这是另一个非常标准的视图模板;然而,有几件事值得一提。首先,模板解析器非常智能,它允许您从作为参数传递给模板的对象中引用相关对象。如您所见,我们不直接传递物理网络对象—只传递 DHCP 网络对象;但是因为 DHCP 网络有一个引用其“父”对象的外键,所以我们可以简单地说 dhcp_net.physical_net.address,Django 模板引擎将找到要显示的正确信息。

您可能已经注意到的另一件事是到 Delete 函数的链接。URL 的对象部分变得相当长,被定义为

<network_address>/<network_size>/dhcp_pool/<range_start>/<range_finish>

严格地从数据建模的角度来看,这个键包含冗余信息,因为具有给定范围地址的 DHCP 地址池只能属于一个物理网络;因此,不需要在 URL 中指定网络地址。然而,因为我们在所有的模板中都使用了相对 URL,所以在这里包含它也容易得多。这是一个很好的例子,说明严格的规则有时需要妥协,以在代码的其他领域实现更高的效率和简单性。

添加和删除功能

添加和删除在结构和功能上与物理网络和 DHCP 网络视图中的等效功能几乎相同。Add 函数重用相同的 add.html 模板,而 Delete 函数则引用 DHCPAddressPool。

修改 URL 结构

我喜欢从错误中学习,因为我认为这是最有效的学习方法。显然,从别人的错误中学习更好。所以我故意引入了一些不是真正的错误,但可以被称为设计中的缺陷的东西,我把它留到了这一章的这一点上。

如果你已经仔细阅读了所有的代码示例,你一定已经注意到了一件事:尽管我们的代码在功能上是完美的,但感觉就是不对。猜到了吗?继续,再看一下所有模板和视图函数的例子。你在那里注意到了什么普遍现象?

没错。我们已经在模板和视图函数中使用了相对 URL。这是一个非常简单的技巧,而且在大多数时候都非常有效,尤其是在小项目中。它甚至适用于分离的应用,因为当您使用相对路径时,地址解析从另一端有效地工作,与应用 URL 的起始深度无关。

问题是,有这么多的类模型和函数,记住每个模型的 URL 的结构变得相当困难。我们已经设置了关于格式化 URL 的严格规则(记住,总是<模型> / <对象> / <方法>),并且使用有限数量的方法(到目前为止只有添加、删除、修改和隐式显示),我们很容易应付。然而,随着模型和 URL 数量的增加,管理和维护所有的 URL 变得更加困难。为什么你需要改变 URL 结构?原因有很多:重组站点、在层次结构中添加新的应用,或者仅仅是修复开发过程中的一个错误。

当我们谈论改变 URL 结构时,我现在需要提到我让另一个“bug”悄悄进来了。还记得我们说过的 /

现在我们有了一个真正有效的理由来返工或修复代码,我们应该如何处理它呢?如果有一种工具或功能允许您获得任何想要链接的对象的 URL,那将是理想的。

模型类中 URL 的生成

Django 框架允许您为每个模型定义一个额外的方法来返回对象的绝对 URL。例如,我们可以这样为网络地址类定义这个方法:

def get_absolute_url(self):
    return '/networkaddress/%s/%s/' % (self.address, self.network_size)

有了这个定义,我们就可以在所有模板中使用这个函数来获取对象的 URL:

<a href="{{ address.get_absolute_url }}">{{ address }}</a>

这允许我们引用对象 URL,而不用考虑 URL 结构。我们需要的只是一个对 URL 的引用,我们通过引用对象的 get_absolute_url 属性来获取该值。如果出于某种原因,我们决定改变 URL 结构,我们将不需要修改任何模板代码,因为引用是在它之外生成的。

URL 的反向解析

这种方法仍然存在一个问题;如果您还记得,URL 现在将在两个位置定义:URL 配置文件和模型定义。因此,即使我们不需要重新访问整套模板和视图函数,我们仍然需要确保 get_absolute_url 返回的任何内容也在 URLConfig 文件中定义。

Django 对这个问题也有一个解决方案:您可以使用 permalink decorator(decorator 是一个修改它所修饰的函数的行为的类)进一步将您的模型从 URLConfig 文件中分离出来。您需要将视图方法名和视图方法参数(作为列表或字典)传递给装饰器,然后它会为您计算出匹配的 URL。让我们看看这个例子:

@models.permalink
def get_absolute_url(self):
    return ('views.networkaddress_display', (),
            {'address': '%s/%s' % (self.address, self.network_size)})

这里我们没有使用参数列表,但是因为它是必需的,所以我们只传递一个空列表。我的偏好是使用字典来传递 URL 中使用的所有参数,因此我们不需要记住每个变量的数量和位置。

让我提醒您这个视图的 URL 配置是什么样子的:

(r'^networkaddress/(?P<address>\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2})/$', views.networkaddress_display),

给定这个组合(视图函数和参数),permalink 将找到匹配的 URL 并返回它。

不过,这里有一个陷阱。有些情况下,装饰者不能唯一地识别匹配的 URL:

(r'^networkaddress/$', views.networkaddress_display),
(r'^networkaddress/(?P<address>\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2})/$',
                                                 views.networkaddress_display),

将名称分配给 URL 模式

在这种情况下,有两个 URL 调用同一个视图函数,所以反向 URL 匹配器(试图从视图名中找到匹配的 URL)会被弄糊涂,因为不止一个 URL 指向同一个视图。

如果是这种情况,您可以为您的 URL 模式指定名称,以便它们都可以被唯一地识别:

url(r'^networkaddress/$', views.networkaddress_display,
name='networkaddress-displaytop'),
url(r'^networkaddress/(?P<address>\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2})/$',
                      views.networkaddress_display, name='networkaddress-display'),

现在,即使两种 URL 模式调用相同的函数,也可以使用它们唯一的名称单独引用它们。

最后,下面是模型类将如何解析其对象的 URL:

@models.permalink
def get_absolute_url(self):
    return ('networkaddress-display', (),
            {'address': '%s/%s' % (self.address, self.network_size)})

模板中 URL 引用的使用

显然,模型代码只能为每个对象返回一个 URL。这样的模型类对应用的功能没有可见性;它仅设计用于表示应用操作的数据。所以通常模型实例会返回用于显示对象的 URL——换句话说,一个表示 URL

在我们的应用中,我们有多个与数据实体相关联的函数,比如添加、删除和修改。因为我们有一个定义良好的 url 结构,并且所有动作“关键字”都附加在末尾,所以我们可以在对象上使用 get_absolute_url 来获取它的基本 URL,然后在模板中附加动作词。但是这种方法不合适,因为 URL 信息将包含在 URLConfig 和使用它的每个模板中。

在前面的示例中,我们在模板中使用了{{ object.get_absolute_url }}结构来引用 url。Django 还有一个 URL 解析器模板标签,它能够通过名称引用 URL。然后,您需要向它传递一个参数,以便它能够匹配并生成所需的 URL:

{% url "networkaddress-display" address %}

清单 4-7 显示了如何使用 URL 标签的一个更详细的例子。

清单 4-7 。URL 解析器模板标记的示例

{% if addresses_list %}
    <ul>
    {% for address in addresses_list %}
        <li><a href="{% url 'networkaddress-display' address %}">
                    {{ address.address }}/{{ address.network_size }}</a>
            {% ifequal address.network_size 32 %}(host){% else %}(network){% endifequal %}
            {{ address.description }}
            (<a href="{% url 'networkaddress-delete' address %}">delete</a> |
            <a href="{% url 'networkaddress-modify' address %}">modify</a>)
        </li>
    {% endfor %}
    </ul>
{% else %}
    {% ifequal parent.network_size 32 %}
        This is a node IP
        <ul>
        <li>Description: {{ parent.description }}
            ( <a href="{% url 'networkaddress-modify' parent %}">modify</a> )</li>
        </ul>
    {% else %}
        No addresses or subnets in this range
    {% endifequal %}
{% endif %}

所有的 URL 模式名称都在 URLConfig 文件中定义,如清单 4-8 所示。

清单 4-8 。网络地址 URL 模式

urlpatterns = patterns('',
   url(r'^networkaddress/$', views.networkaddress_display,
        name='networkaddress-displaytop'),
   url(r'^networkaddress/add/$', views.networkaddress_add,
        name='networkaddress-addtop'),
   url(r'^networkaddress/(?P<address>\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2})/$',
        views.networkaddress_display, name='networkaddress-display'),
url(r'^networkaddress/(?P<address>\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2})/
       delete/$', views.networkaddress_delete, name='networkaddress-delete'),

url(r'^networkaddress/(?P<address>\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2})/
       add/$', views.networkaddress_add, name='networkaddress-add'),

url(r'^networkaddress/(?P<address>\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2})/
       modify/$', views.networkaddress_modify, name='networkaddress-modify'),

最后,所有的 URL 都被分离并定义在一个位置 URLConfig 文件。每当您选择更改它们时,您只需要在一个地方这样做,无论是模型、视图还是模板都不需要修改。

添加客户分类

为了充分利用地址池,我们需要具备客户端分类功能。换句话说,我们必须定义一些规则来识别是什么在发送请求,然后从适当的地址池中分配 IP 地址。由于我们没有实现“向导”类型的应用,所有规则都需要是 ISC DHCP 能够理解的格式的纯文本。这对于对配置文件语法知之甚少的人没有帮助,但是对于那些必须管理相当大的 DHCP 配置的人来说,这确实很方便。

对数据模型的补充

新数据模型的类定义相当简单,只包含两个字段:规则文本和描述。我们还需要扩展 Pool 类,以便它引用适当的 Class Rule 对象,如清单 4-9 所示。

清单 4-9 。扩展 DHCP 池模型并引入规则模型

class DHCPAddressPool(models.Model):
    dhcp_network = models.ForeignKey(DHCPNetwork)
    class_rule = models.ForeignKey(ClassRule)
    range_start  = models.IPAddressField()
    range_finish = models.IPAddressField()

    def __unicode__(self):
        return "(%s - %s)" % (self.range_start, self.range_finish)

class ClassRule(models.Model):
    rule = models.TextField()
    description = models.CharField(max_length=400)

    def __unicode__(self):
        return self.id

模板继承

由于规则管理将是通用的,我们希望在我们的所有页面中提供到显示和管理页面的链接,这样用户就可以直接跳转到它。到目前为止,我们有两个显示页面来显示物理网络和 DHCP 网络,但将来我们可能会有更多。那么我们如何给所有页面添加一个链接呢?显然,编辑每个页面并不是一个理想的解决方案。

Django 模板管理系统允许模板继承,因此您可以定义一个容器模板,然后在此基础上创建其他模板。基本模板包含占位符,从它继承的每个模板将提供放置在这些占位符中的元素。

这里有一个例子。首先,让我们定义所有其他人将继承的基础模板;清单 4-10 显示了代码。

清单 4-10 。基本模板

{% block menu %}
<ul>
    <li><a href="{% url 'networkaddress-displaytop' %}">Network address management</a></li>
    <li><a href="{% url 'classrule-displaytop' %}">Class rule management</a></li>
</ul>
{% endblock %}
<hr/>
{% block contents %}
{% endblock %}

接下来,我们定义两个块:一个菜单块和一个内容块。因为我们的主要目标是分离菜单模板代码并重用它,所以我们不需要将它放在一个单独的块中——但是这样做是一个很好的实践,因为这允许其他模板在需要的时候用其他的东西替换这个菜单。{% block %}标记之外的任何内容都不能从其他模板中访问,因此是不可更改的。标记中包含的任何内容都是默认值,如果继承模板不重写该块,将会显示这些内容。

第二个块用于保存其他显示页面的内容,因此它被留空。继承模板将替换它的内容;可选地,他们也可以覆盖菜单部分。清单 4-11 显示了新的 display.html 模板,它现在继承自 base.html。

清单 4-11 。让 display.html 继承 base.html

{% extends "base.html" %}
{% block contents %}
<contents of the original display.html>
{% endblock %}

同样,我们也需要更改 display_dhcp.html。完成后,两个页面都将包含一个通用菜单,允许应用用户在网络配置和类规则配置之间切换。

班规管理

我们将使相同的规则集适用于我们系统中的所有 DHCP 地址池。因此,在我们为任何 DHCP 地址池分配特定规则之前,我们首先需要定义这个规则。我们这样做是为了让用户能够重用现有的规则。如果在许多不同的子网中重复使用相同的规则,这种方法很好。但是,如果您的规则是特定的,并且不太可能被重用,那么这不是最好的方法,因为您最终会得到大量的一次性条目,并且列表很快会变得难以管理。

如果是后者,您可能需要考虑为规则和子网定义类别,以便它们可以自动分组。然后,当您创建一个新的 DHCP 网络时,您可以选择您想要查看的类别。

之前,我们已经为系统中的所有模型创建了添加、修改、删除和显示视图。这似乎是一个重复的过程,你不觉得吗?如果有一种方法可以自动执行像对象的基本创建、修改和删除这样的任务,那就太好了。你猜怎么着?Django 框架提供了这种功能;它被称为通用视图

通用视图

通用视图是对传递给它们的任何对象执行基本和常见任务的视图。Django 附带了四种类型的通用视图:

  • 重定向到其他页面或呈现任何给定模板(通常是静态内容)的视图。
  • 生成对象列表或显示任何特定对象的详细信息的视图。
  • 根据对象的创建日期列出对象的视图。如果你正在创建一个博客或新闻网站,这些会更有用。
  • 用于添加、删除和修改对象的视图。

通用视图可以从 django.views.generic 库中导入。通常在 URLConfig 文件中需要它们,因为这是将 URL 映射到视图的地方。你可以在 Django 官方文档中找到每个泛型和基类视图的完整列表和详细描述:docs . Django project . com/en/dev/ref/class-based-views/。你会发现有两种类型的类视图:基类和泛型。如果您正在定义自己的视图类,基本视图将用作基础视图,而通用视图可以立即使用。

由于类规则模型的简单性,我们将使用通用视图来管理模型的对象。我们不使用通用视图来管理其他模型的原因是,我们希望在视图所做的事情上留下更多的灵活性。在以后的某个阶段,我们可能想要扩展视图函数来执行额外的检查和任务,这对于一般视图来说是不容易做到的。

对象列表的显示

重要的事情先来。让我们调用一个通用视图来显示所有可用的类规则对象的列表。使用泛型类视图的最佳方式是创建一个从泛型类继承的自定义类,定义一些指定其行为的参数(如模板名、模型类名),然后在 urls.py 中使用该类。

清单 4-12 中的代码说明了如何定义一个定制的通用视图。这段代码通常放在 views.py 中,与其他视图函数和类放在一起。

清单 4-12 。分类规则查询集

class ClassRuleDisplay(ListView):
    model = ClassRule
    template_name = 'display_classrule.html'

这里的模型条目包含我们想要显示的模型类名,template_name 是模板文件的名称。如果我们选择不定义模板的名称,Django 会尝试自动为它生成文件名,并尝试加载一个名为 <application_name>/ <model_name>_list.html 的文件。我们选择指定模板名称,这样就少了一件担心事情不正常的事情。</model_name></application_name>

我们还需要添加 URL 到视图的映射,就像您在前面几节中对所有其他视图所做的那样。但是,这一次我们将使用一个通用视图 list_detail.object_list,并向它传递包含通用视图所需的所有信息的 queryset 对象:

url(r'^classrule/$', views.ClassRuleDisplay.as_view(), name='classrule_displaytop'),

最后,我们需要创建一个模板来很好地显示所有的对象。我们已经添加了到细节、修改和删除函数的链接,我们将在下一节中定义这些函数,所以代码看起来像清单 4-13 中的。

清单 4-13 。类别规则列表模板

{% extends "base.html" %}
{% block contents %}
<h1>List of all Class Rules</h1>
{% if object_list %}
    <ul>
    {% for rule in object_list %}
        <li>{{ rule.description }}
                ( <a href="{% url 'classrule_display' rule.id %}">details</a> |
                  <a href="{% url 'classrule_modify' rule.id %}">modify</a> |
                  <a href="{% url 'classrule_delete' rule.id %}">delete</a> )</li>
    {% endfor %}
    </ul>
{% else %}
No class rules defined yet.
{% endif %}
<h3><a href="{% url classrule_add %}">Add new rule</a></h3>
{% endblock %}

这是快速显示任何一组对象列表的一种简单得多的方式,并且不需要您编写一行视图代码。

对象的详细视图

类似地,我们将使用通用视图来显示任何。特定的类规则对象。这里唯一的区别是,我们需要向通用视图传递一个特定的对象 ID,以便视图代码可以从列表中选择适当的对象。

我们必须定义一个新的定制视图类,它继承自 Django 的一个内置泛型类:

class ClassRuleDetailDisplay(DetailView):

    queryset = ClassRule.objects.all()
    template_name = 'display_classrule.html'

    def get_object(self):
        object = super(ClassRuleDetailDisplay, self).get_object()
        return object

在 list 类中,我们为 Django 提供了模型名称。这里,我们必须创建一个 queryset 对象,这在大多数情况下就足够了。我还覆盖了 get_object 方法,调用该方法来检索单个对象。在我们的例子中,我们不需要这样做,因为默认的实现已经足够了。事实上,如你所见,我们在这里所做的只是调用父方法的实现。但是您可以在这里添加额外功能,比如从不同的对象获取额外的数据,或者在访问这个对象时更新另一个模型中的字段。

新的 URL 规则将如下所示;它包含对 pk(或“主键”)的引用,告诉通用视图需要将哪个对象传递给模板:

url(r'^classrule/(?P<pk>\d+)/$', views.ClassRuleDetailDisplay.as_view(), name='classrule_display'),

最后,让我们更新模板的版本,如清单 4-14 所示。它现在检查对象是否包含任何内容,在这种情况下,它显示关于它的详细信息;否则,它将显示一个类规则列表。

清单 4-14 。显示列表和对象详细信息的更新视图

{% extends "base.html" %}
{% block contents %}
{% if object %}
    <h1>Class Rules details</h1>
    <ul>
        <li>ID: {{ object.id }}</li>
        <li>Description: {{ object.description }}</li>
        <li>Rule text:
            <pre>
                {{ object.rule }}
            </pre>
        </li>
    </ul>
    ( <a href="{% url 'classrule-modify' object.id %}">modify</a> |
      <a href="{% url 'classrule-delete' object.id %}">delete</a> )
{% else %}
    <h1>List of all Class Rules</h1>
    {% if object_list %}
        <ul>
        {% for rule in object_list %}
            <li>{{ rule.description }}
                   ( <a href="{% url 'classrule-display' rule.id %}">details</a> |
                     <a href="{% url 'classrule-modify' rule.id %}">modify</a> |
                     <a href="{% url 'classrule-delete' rule.id %}">delete</a> )</li>
        {% endfor %}
        </ul>
    {% else %}
        No class rules defined yet.
    {% endif %}
    <h3><a href="{% url 'classrule-add' %}">Add new rule</a></h3>
{% endif %}
{% endblock %}

Image 注意默认情况下,模板对象名称是 object。通用列表视图将 _list 附加到该名称上。因此,在详细视图中,您将收到作为单个对象实例的 object 或作为对象列表的 object_list。通过在 queryset 字典中将 context_object_name 设置为您喜欢的任何名称,您可以随时更改模板名称。

添加或修改的新对象

使用通用视图添加对象也同样简单。您需要通过从通用视图类继承来为其提供基本信息,并为这些动作定义 URL 模式。通用视图需要以下信息:

  • 模型类名,因此视图知道它正在处理哪种类型的对象。
  • 模型表单类,所以表单生成框架知道如何生成表单表示。
  • 动作后重定向 URL,它告诉视图在数据提交后将用户重定向到哪里。这应该是一个表示 URL 的字符串。如果没有指定,Django 会尝试对对象应用 get_absolute_url,所以要确保定义了对象的 get_absolute_url 方法。然而,使用 get_absolute_url 的优点是,如果修改 url,不需要在两个地方更改它。

在清单 4-15 中,我们定义了两个类;一个是模型类,一个是模型表单类。严格地说,这里不需要 Model Form 类,因为我们有一个只有两个字段的非常简单的模型,但是我更喜欢显式地定义它们;如果我想在以后扩展和修改模型,这就更容易了。注意 get_absolute_url 返回反向解析的 url。这些修改应该在 models.py 文件中完成:

清单 4-15 。类规则模型和表单类

class ClassRule(models.Model):
    rule = models.TextField()
    description = models.CharField(max_length=400)

    def __unicode__(self):
        return self.description[:20]

    @models.permalink
    def get_absolute_url(self):
        return ('classrule_display', (), {'object_id': self.id})

class ClassRuleForm(ModelForm):
    class Meta:
        model = ClassRule

在 views.py 中,我们定义了两个继承自通用视图的类:一个用于添加新条目,另一个用于更新现有记录。注意,更新类需要知道模型类。它使用这个类来查找数据库中的相关对象,所以当您单击修改链接时,表单将预加载现有数据。

class ClassRuleCreate(CreateView):
    form_class = ClassRuleForm
    template_name = 'add.html'

class ClassRuleUpdate(UpdateView):
    model = ClassRule
    form_class = ClassRuleForm
    template_name = 'add.html'

我们甚至可以重用我们用来添加或修改其他对象的表单。因为我们保持了表单的通用性,并让模板处理程序生成所有必需的字段集,所以它不需要任何更改。

最后,让我们为 add 和 Modify 函数添加两个 URL 模式,并确保使用与模板中引用的相同的 URL 模式名称:

url(r'^classrule/(?P<pk>\d+)/modify/$', views.ClassRuleUpdate.as_view(), name='classrule_modify'),
url(r'^classrule/add/$', views.ClassRuleCreate.as_view(), name='classrule_add'),

对象的删除

删除对象包括一个中间步骤:要求用户确认操作。这是在一般的 delete 视图中通过使用简单的逻辑实现的——如果 HTTP 请求是 GET,则意味着用户单击了删除链接,因此需要显示确认页面(该页面指向同一个 URL)。如果 HTTP 请求是 POST,这意味着用户单击了 Confirm 按钮,并且表单已经通过 HTTP POST 调用提交,在这种情况下,视图将继续删除对象。

通用删除视图有一个警告。它需要一个删除后的 URL 换句话说,它需要知道在对象被删除后将用户带到哪里。显而易见的解决方案是反向查找 URL 并使用它。

清单 4-16 。基于通用类视图的自定义删除视图

class ClassRuleDelete(DeleteView):
    model = ClassRule
    success_url = reverse_lazy('classrule_displaytop')
    template_name = 'delete_confirm_classrule.html'

确认模板只是要求确认并将数据重新提交到同一个 URL,但是现在使用 HTTP POST 方法:

<form method="post" action=".">
<p>Are you sure?</p>
<input type="submit" />
</form>

最后,我们向 URL 模式列表添加了另一项内容:

url(r'^classrule/(?P<pk>\d+)/delete/$', views.ClassRuleDelete.as_view(), name='classrule_delete'),

Image 注意正如您可能已经猜到的,修改和删除视图不仅需要了解它们所操作的对象的类型,还必须唯一地标识它们所修改或删除的对象。对象 ID 作为 pk 变量从 URL 模式传递给它们。

生成 DHCP 配置文件

我们已经得到了我们需要的所有信息,但就目前的形式来看,这些信息没有多大用处。所有数据都在数据库表中,尽管它详细说明了 DHCP 服务器应该如何配置,但它不能以这种形式使用。我们需要编写一个视图来生成 DHCP 服务器能够理解的配置文件。

让我们回过头来重新看看 DHCP 配置文件应该是什么样子。因为我们使用的是 ISC DHCP 服务器,所以配置文件(仅包括我们感兴趣的元素)具有以下结构:

<dhcpd configuration items or generic DHCP options>

<class definitions>

<network definition>
    <subnet definition>
        <subnet options>
        <pool definitions>

让我们将这个配置文件作为 web 资源提供。因此,我们需要以与生成用户界面页面类似的方式来处理它:我们需要定义一个视图,该视图提供数据以及在页面上展示这些数据的模板——在本例中,是一个纯文本文档。

我们从视图开始,如清单 4-17 所示。

清单 4-17 。为 DHCP 配置文件收集数据的视图

def dhcpd_conf_generate(request):
    class_rules = ClassRule.objects.all()
    networks = []
    for net in DHCPNetwork.objects.all():
        networks.append( { 'dhcp_net': net,
                           'pools': DHCPAddressPool.objects.filter(dhcp_network=net),
                         } )

    return render_to_response('dhcpd.conf.txt',
                              {'class_rules': class_rules,
                               'networks': networks,
                              },
                              mimetype='text/plain')

我们不在数据库中保存 DHCP 服务器配置项;因此,我们将把它们直接放入模板中。类规则简单地列在任何其他结构之外,所以我们生成系统上所有类规则的列表,并将其作为列表传递。

每个 DHCP 子网可能在其范围内定义了几个不同的 DHCP 地址池,因此这些地址池只需要出现在特定的 DHCP 地址池定义中。因此,我们遍历所有可用的 DHCP 网络,并生成一个包含以下内容的列表:

  • DHCP 地址对象
  • 与给定 DHCP 网络相关的所有 DHCP 池的列表

最后,我们告诉 Django 将文档的 MIME 类型改为“text/plain”。如果我们只是要下载它,这没多大关系。但是,如果您尝试在 web 浏览器中打开这个文件,您会看到整个文档显示在一行上,因为 web 浏览器会认为它是一个有效的 HTML 文档。因此,为了在浏览器中查看时保留格式,我们需要对响应进行格式化,以表明文档是一个平面文本文件。

最后,在清单 4-18 中,我们有一个模板,它把所有的数据放在一个可以被 DHCP 服务器使用的结构中。

清单 4-18 。DHCP 配置文件的模板

 1 {% autoescape off %}
 2 ignore client-updates;
 3 ddns-update-style interim;
 4
 5 {% if class_rules %}
 6     {% for cr in class_rules %}
 7         # {{cr.description }}
 8         class "class_rule_{{ cr.id }}" {
 9             {{ cr.rule }};
10         }
11     {% endfor %}
12 {% endif %}
13
14 {% if networks %}
15     {% for net in networks %}
16         shared-network network_{{ net.dhcp_net.id }} {
17             subnet {{ net.dhcp_net.physical_net.address }} netmask {{                net.dhcp_net.physical_net.get_netmask }} {
18                 option routers {{ net.dhcp_net.router }};
19                 option domain-name-servers {{ net.dhcp_net.dns_server.address }};
20                 option domain-name {{ net.dhcp_net.domain_name.name }};
21
22                 {% if net.pools %}
23                     {% for pool in net.pools %}
24                         pool {
25                             allow members of "class_rule_{{ pool.class_rule.id }}";
26                             range {{ pool.range_start }} {{ pool.range_finish }};
27                         }
28                     {% endfor %}
29                 {% endif %}
30             }
31         }
32     {% endfor %}
33 {% endif %}
34
35 {% endautoescape %}

现在让我们更详细地看一些行。

  • Line 1:Django 模板引擎有一个内置的文本转义功能,可以将所有不符合 HTML 的字符转换成 HTML 代码表示。例如,字符(")将被替换为&quot;字符串。因为我们提供的是平面文本文档,所以我们需要用原始符号表示所有字符,而不是 HTML 编码。所以我们关闭了 autoescape 功能,默认情况下它是打开的。
  • 第 2–3 行:这些只是标准的 DHCP 服务器配置项目,您可能希望将其替换为适合您环境的项目。
  • 第 5–12 行:查看 class_rules 列表是否为空的简单检查,随后是遍历所有元素并显示它们的循环。
  • 第 14–15 行:再次检查网络列表是否为空,然后是循环语句。
  • 第 17 行:这里你可以看到我们是如何引用相关对象的。我们没有将任何关于物理网络的信息直接传递给模板,但是我们仍然可以通过 DHCP 网络对象访问它,该对象有一个指向相关物理网络对象的外键。只要这种关系是明确的(一个 DHCP 网络只能属于一个物理网络),就可以使用这种语法来访问相关信息。
  • 类似地,我们正在访问相关的路由器和 DNS 对象。
  • 第 22–23 行:检查 DHCP 网络是否有可用的地址池,如果有,就遍历这些地址池。
  • 第 25–26 行:注意,我们是根据类名和网络名的对象 id 来生成它们的。这是确保名称唯一的最简单方法,也可用于在配置文件中进行交叉引用。

您可能已经注意到,我们正在使用物理网络对象的 get_netmask 属性。这个字段不存在,那么它是什么?DHCP 服务器希望子网被定义为由基本网络地址和网络掩码组成的对。我们在模型中没有网络掩码字段,但从网络大小中推导出来非常简单,网络大小用位数表示;清单 4-19 显示了代码。

清单 4-19 。根据网络大小计算网络掩码

def get_netmask(self):
    bit_netmask = 0;
    bit_netmask = pow(2, self.network_size) - 1
    bit_netmask = bit_netmask << (32 - self.network_size)
    nmask_array = []
    for c in range(4):
        dec = bit_netmask & 255
        bit_netmask = bit_netmask >> 8
        nmask_array.insert(0, str(dec))
    return ".".join(nmask_array)

这个函数的逻辑非常简单:

  • 将整数变量中的位数设置为 1(即设置为 1)。这可以表示为 2^ -1。
  • 将结果左移,用 0 填充剩余的位数。网络掩码中的总位数总是 32。
  • 每 8 位(共 4 组),转换成十进制数串。
  • 连接所有数字,使用点符号分隔各个数字。

最后,我们需要添加一个额外的 URL 模式来调用这个视图:

url(r'^dhcpd.conf/$', views.dhcpd_conf_generate, name='dhcp-conf-generate')

下面是一个 DHCP 配置文件的例子,它是由我输入到数据库中的一些样本数据生成的:

ignore client-updates;
ddns-update-style interim;
        # class rule 1
        class "class_rule_1" {
            match if substring (option host-name, 0, 6) = "server";;
        }
        # test rule (gen form)
        class "class_rule_2" {
            test rule - gen form;
        }

        shared-network network_4 {
            subnet 192.168.0.128 netmask 255.255.255.128 {
                option routers 192.168.0.130;
                option domain-name-servers 208.67.222.222;
                option domain-name domain1.example.com;
            }
        }
        shared-network network_5 {
            subnet 192.168.0.0 netmask 255.255.255.128 {
                option routers 192.168.0.113;
                option domain-name-servers 208.67.220.220;
                option domain-name domain2.example.com;
                        pool {
                            allow members of "class_rule_1";
                            range 192.168.0.1 192.168.0.20;
                        }
                }
        }

其他修改

大部分工作已经完成,但是我们仍然需要添加一些东西来满足最初的需求:节点 IP 的主机名解析和状态检查。

将 IP 解析为主机名

为了获得关于 IP 地址的更多信息,让我们做一个反向的域名解析,在每个地址条目旁边打印一个完全限定的域名。我们可以在两个地方实现这种查找:我们可以修改显示视图并在那里进行主机查找,然后将信息传递给模板;或者,我们可以用一个额外的函数来扩展模型类,该函数返回 IP 地址的主机名,如果主机名无法解析,则返回一个空字符串。

让我们选择第二个选项,因为它更优雅,并且不需要改变视图和模板之间的接口。这里有一个模型类的附加方法,它使用 Python 的套接字库中的 gethostbyaddr 函数来执行反向查找。结果是一个元组:(

),我们使用第一个条目(主机名)作为结果。
import socket

...
class NetworkAddress(models.Model):
...
    def get_hostname(self):
        try:
            fqdn = socket.gethostbyaddr(str(self.address))[0]
        except:
            fqdn = ''
        return fqdn

以及模板中的一个小变化,以显示附加属性(如果可用):

{% for address in addresses_list %}
  <li><a href="{% url networkaddress-display address %}">{{ address.address }}/
                                                         {{ address.network_size }}</a>
    {% ifequal address.network_size 32 %}(host){% else %}(network){% endifequal %}
      {{ address.description }}
      {% if address.get_hostname %} ({{ address.get_hostname }}) {% endif %}
    (<a href="{% url networkaddress-delete address %}">delete</a> |
     <a href="{% url networkaddress-modify address %}">modify</a>)
  </li>
{% endfor %}

检查地址是否在使用中

让我们实现一个简单的函数来检查 IP 地址是否在使用中。为此,我们需要向 IP 地址发送 ICMP 回应消息,并等待响应。严格地说,这不是检查地址是否在使用中的有效测试,因为可能有一些情况是 IP 地址被使用了,但是没有响应 ping 请求。例如,防火墙可能阻止 ICMP 通信,或者该通信可能在服务器级别被阻止。然而,在大多数情况下,这个简单的测试是非常有效的;请记住,此测试指示的故障不一定意味着服务器的实际故障或地址未被使用。

实现遵循定义一个视图并将一个新的 URL 模式添加到 URLConfig 文件的常见模式。由于使用 Python 套接字库实现 ICMP 相对复杂(它要求在原始模式下使用套接字,这又要求应用以 root 用户身份运行),我们将调用系统 ping 工具并根据返回代码做出决定,如清单 4-20 所示。

清单 4-20 。对 IP 地址进行 ICMP 检查的视图

def networkaddress_ping(request, address=None):
    if responding_to_ping(address):
        msg = "Ping OK"
    else:
        msg = "No response"
    return HttpResponse(msg)

def responding_to_ping(address, timeout=1):
    import subprocess
    rc = subprocess.call("ping -c 1 -W %d %s" % (timeout, address),
                         shell=True, stdout=open('/dev/null', 'w'),
                         stderr=subprocess.STDOUT)
    if rc == 0:
        return True
    else:
        return False

这里我们强制 ping 只发送一个数据包,超时设置为 1 秒。虽然这可能会降低准确性,但响应会快得多。大多数本地网络应该在这些限制内运行,但是如果您需要更高的准确性,您可以增加默认超时并指示 ping 发送多个探测数据包。

您还需要添加两个额外的 URL 模式:

url(r'^networkaddress/(?P<address>\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/ping/$',
    views.networkaddress_ping, name='networkaddress-ping'),
url(r'^networkaddress/$', views.networkaddress_ping,
name='networkaddress-ping-url'),

第一个模式捕获一个 IP 地址以及它需要在给定地址上执行的方法(/ping/)。第二行只是用于日常管理——稍后您会发现为什么需要它。

为什么我们将这种检查实现为对 web 服务器的单独调用?生成要显示的 IP 地址列表,逐个 ping 每个地址,然后将 ping 结果和 IP 地址一起传递给模板,不是更容易吗?是的,我们可以这样做,但是这种方法有一个主要问题:应用响应时间。在现实生活中,您可能有非常大的网络,可能需要在数百台服务器上执行 ping 检查。即使您以多线程的方式实现这种检查——换句话说,尝试同时调用 ping 函数——您仍然会花费 1 秒、2 秒甚至更多的时间来完成请求。从可用性的角度来看,这是不可接受的;如果系统反应慢,用户不会喜欢它。

因此,我们在这里要做的是显示子网中所有地址的列表,然后使用 JavaScript 异步调用 ping URL。用户不会立即获得状态报告,但至少会立即显示包含其他信息和操作链接的页面。

这种方法的另一个好处是,您根本不需要对显示视图进行任何更改——只需对显示模板进行一些小的修改(添加一个占位符来保存状态信息)。JavaScript 将被放在基础模板中,因此所有页面都自动获得该功能。

因为这本书不是关于 JavaScript 的,所以我将只限于一个简单的解释和一个如何使用它的例子。清单 4-21 使用 jQuery 库执行异步 AJAX 调用来获得结果并相应地更新网页。

清单 4-21 。修改的地址列表循环代码

{% for address in addresses_list %}
        <li><a href="{% url networkaddress-display address %}">{{ address.address }}/
            {{ address.network_size }}</a>
            {% ifequal address.network_size 32 %}(host){% else %}(network){% endifequal %}
            {{ address.description }}
            {% if address.get_hostname %} ({{ address.get_hostname }}) {% endif %}
            (<a href="{% url networkaddress-delete address %}">delete</a> |
            <a href="{% url networkaddress-modify address %}">modify</a>)
            {% ifequal address.network_size 32 %}
            [Status: <span class="address"
                 id="ip_{{ address.get_formated_address }}">Unknown</span> ]
            {% endifequal %}
        </li>
    {% endfor %}

附加行检查地址是否可能是节点 IP,然后插入一个 HTML 标记,它将用于更新文档中该位置的信息。这个标签有两个属性:Class 和 ID。我们将使用 Class 属性来标识哪些标签包含 IP 地址并需要检查,并使用 ID 属性来保存 IP 地址的值。

你可能想知道这个 get_formated_address 方法是什么,为什么我们不直接使用地址。原因是 jQuery 期望 HTML 标签 ID 名称中不能有点,ID 名称也需要以字母开头;因此,我们必须为它添加 ip_ 前缀。此方法只是替换所有出现的(。)和地址字段中的(_)。

最后,我们添加一些 JavaScript 来遍历属于同一个地址类的所有标签,并对 web 服务器执行 AJAX 异步调用。结果将被用作标签的 HTML 内容。清单 4-22 中的代码已经被添加到基础模板中,所有其他模板都继承自基础模板。

清单 4-22 。执行异步调用并更新状态页面的 JavaScript

<html>
<head>
<script type="text/javascript" src "http://ajax.aspnetcdn.com/ajax/jQuery/jquery-1.3.2.min.js">
</script>
<script type="text/javascript">
    $(document).ready(function(){
        $(".address").each(function () {
            var curId = $(this).attr('id');
            updateStatus(curId);
        });
    });

    function updateStatus(attrId) {
        address = attrId.replace('ip_', '');
        address = address.replace(/_/g, '.');
        $.ajax({
            url: '{% url networkaddress-ping-url %}' + address + '/ping/',
            success: function(response) {
                $('#' + attrId).html(response);
            }
        });
    }

</script>
</head>

现在你明白为什么我们需要占位符 URL 模式了。JavaScript 也部分由 Django 生成——我们使用反向 URL 查找插入网络地址 URL。因为我们不能生成一个完整的 URL(其中包含地址部分),所以这是一个通用的 URL,它将被 JavaScript 修改。我们只使用它的第一部分;因此,我们需要在 URLConfig 中定义它。

因此,这段 JavaScript 代码的逻辑如下:

  • 删除 ip_ 前缀。
  • 用点替换下划线。
  • 执行 AJAX 异步调用。
  • 结果出来后更新网页。

现在,当用户导航到列表页面时,它将立即显示,然后随着结果的可用,逐渐更新每个 IP 地址的状态报告。

动态 DHCP 租约管理

到目前为止,我们的项目是基于我们需要生成一个静态 DHCP 配置文件的假设。到目前为止,我们构建的应用允许我们输入所需的数据,输出是一个配置文件,可以由 ISC DHCP 服务器使用。

这种方法通常已经足够好了,尤其是在 DHCP 配置相当静态的环境中。当您需要频繁地添加和删除静态分配时,问题就出现了。每次修改后,您都必须部署新的配置文件并重启 DHCP 服务器进程。当您重新启动 DHCP 进程时,会有一小段时间 DHCP 服务器不可用。当服务关闭时,所有对 DHCP 地址的请求都将失败,您可能最终会遇到缺少 IP 地址的客户端。

这个问题的解决方案是使用 OMAPI(对象管理应用编程接口)的动态租赁管理。OMAPI 是 ISC DHCP 服务器的 API 接口,它允许您操作服务运行实例的内部数据结构。

在这一节中,我将展示如何使用 OMAPI 操作 DHCP 分配。我们不打算改变到目前为止编写的应用;这只是给出一个如何动态管理 DHCP 租约的想法。

对 OMAPI 采用 Python 接口

我们将使用 Torge Szczepanek 博士的 pypureomapi 库来访问 ISC DHCP OMAPI 接口。该项目可在以下网址获得:github.com/CygnusNetworks/pypureomapi如果你想安装它的源代码。

这个包也可以从 PyPI 包库中获得,并且可以用 PIP 工具安装:

# pip install pypureomapi
Downloading/unpacking pypureomapi
  Downloading pypureomapi-0.3.tar.gz
  Running setup.py egg_info for package pypureomapi

Installing collected packages: pypureomapi
  Running setup.py install for pypureomapi

Successfully installed pypureomapi
Cleaning up...

设置 ISC DHCP 服务器

ISC DHCP 的默认配置不允许通过 OMAPI 进行管理。如果您想要动态管理服务,您必须创建一个定义身份验证和连接详细信息的附加配置。

让我们从最基本的 ISC DHCP 服务器配置文件开始(在基于 RedHat 的系统上,这个文件是/etc/dhcp/dhcpd.conf),它包含以下配置:

subnet 192.168.0.0 netmask 255.255.255.0 {

}

Image 注意请记住,在本节中,我们将使用最小的 DHCP 服务器配置,这足以说明 OMAPI 的功能。在现实生活中,您可能希望扩展这种配置,使其符合您的环境要求。

首先,我们需要为将要连接到 ISC DHCP 服务器的用户生成一个 HMAC-MD5 密钥:

# dnssec-keygen -r /dev/urandom -a HMAC-MD5 -b 256 -n USER omapikey
Komapikey.+157+08556
#

这将创建两个文件,一个包含密钥,另一个包含元数据信息:

# ls -l Komapikey*
-rw------- 1 root root  70 Jul  6 14:49 Komapikey.+157+08556.key
-rw------- 1 root root 185 Jul  6 14:49 Komapikey.+157+08556.private
#

两个文件都包含密钥,在我的例子中是“qkhrf 1 lax E4 cnuaa 2t/go A0 vbfeub 5 ROS+53 ge w2 bzq = ":

# awk '/Key/ {print $2}' Komapikey.+157+08556.private
QKHRF1laxE4cNUAa2t/GOa0VBFeUb5ROS+53gEw2BzQ=
#

让我们使用密钥和我们使用的用户名(omapikey ),并更新 ISC DHCP 配置文件:

key omapikey {
  algorithm hmac-md5;
  secret QKHRF1laxE4cNUAa2t/GOa0VBFeUb5ROS+53gEw2BzQ=;
}

omapi-key omapikey;
omapi-port 7911;

subnet 192.168.0.0 netmask 255.255.255.0 {

}

当您重新启动 DHCP 服务时,您会看到它现在正在侦听 OMAPI 命令的定义端口:

# netstat -ntlp
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name
tcp        0      0 0.0.0.0:22              0.0.0.0:*               LISTEN      1330/sshd
tcp        0      0 0.0.0.0:7911            0.0.0.0:*               LISTEN      8994/dhcpd
#

添加新的主机租赁记录

在我们开始修改 DHCP 租约记录之前,让我们确保租约文件中没有现有的租约记录。基于 RedHat 的系统上的文件是/var/lib/dhcpd/dhcpd.leases,在这种情况下,其内容如下所示。如您所见,那里没有主机记录:

# cat /var/lib/dhcpd/dhcpd.leases
# The format of this file is documented in the dhcpd.leases(5) manual page.
# This lease file was written by isc-dhcp-4.2.6

server-duid "\000\001\000\001\033L\014\234\010\000'\037\337\302";
#

让我们使用 OMAPI 连接到 ISC DHCP,并创建一个新的租用记录:

>>> import pypureomapi
>>> USER='omapikey'
>>> KEY='QKHRF1laxE4cNUAa2t/GOa0VBFeUb5ROS+53gEw2BzQ='
>>> omapi = pypureomapi.Omapi('127.0.0.1', 7911, USER, KEY)
>>> omapi.add_host('192.168.0.100', '00:11:22:33:44:55')
>>>

现在,如果您看一下租约文件,您会发现插入了一条新记录:

# cat /var/lib/dhcpd/dhcpd.leases
# The format of this file is documented in the dhcpd.leases(5) manual page.
# This lease file was written by isc-dhcp-4.2.6

server-duid "\000\001\000\001\033L\014\234\010\000'\037\337\302";

host nh53b963617fbd63378fe0 {
  dynamic;
  hardware ethernet 00:11:22:33:44:55;
  fixed-address 192.168.0.100;
}

删除主机租赁记录

类似地,您可以从租约数据库中删除主机记录:

>>> import pypureomapi
>>> USER='omapikey'
>>> KEY='QKHRF1laxE4cNUAa2t/GOa0VBFeUb5ROS+53gEw2BzQ='
>>> omapi = pypureomapi.Omapi('127.0.0.1', 7911, USER, KEY)
>>> omapi.del_host('00:11:22:33:44:55')
>>>

您将看到租赁记录没有从数据库中删除;相反,它被标记为已删除:

# cat dhcpd.leases
# The format of this file is documented in the dhcpd.leases(5) manual page.
# This lease file was written by isc-dhcp-4.2.6

host nh53b963617fbd63378fe0 {
  dynamic;
  hardware ethernet 00:11:22:33:44:55;
  fixed-address 192.168.0.100;
}
server-duid "\000\001\000\001\033L\014\234\010\000'\037\337\302";

host nh53b963617fbd63378fe0 {
  dynamic;
  deleted;
}

摘要

在本章中,我们扩展了网络地址管理应用的功能,增加了对 DHCP 的支持,还执行了一些检查,如 DNS 查找和 ICMP pings,以确保地址在使用中。

  • 通用视图有助于减少您需要编写的代码量;使用它们来执行一般任务,如显示对象信息和基本操作,如删除、修改和添加。
  • 您可以修改响应 MIME 类型,允许 Django 生成各种各样的内容——HTML、XML、文本,甚至二进制文档。
  • 考虑用户体验,以及当数据量增长时,您的应用是否会同样快速地执行各种任务。如果需要,可以使用 JavaScript 推迟内容加载。
  • 您不需要有可用的库或编写自己的功能来执行某些任务。如果需要,您可以使用系统工具(如 ping)来执行这些任务。
  • 您可以使用 OMAPI 接口来动态更新 ISC DHCP 运行配置。

五、在 Apache 配置文件中维护虚拟主机列表

我们在第三章和第四章中详细研究了 Django web 框架。在本章中,我们将继续探索 Django 框架,尤其是管理应用。我们将使用内置的对象管理应用,而不是自己编写视图和表单,但是我们将对它进行定制,以满足我们的需要和要求。我们将在本章中创建的应用是一个基于 web 的应用,用于为 Apache web 服务器生成虚拟主机配置。

指定应用的设计和要求

为什么您想要一个为您生成 Apache 配置文件的应用呢?这种方法有利也有弊。让我从自动生成配置文件的优点开始。

首先,虽然你不能完全消除它,但你大大减少了误差因素。当您自动生成配置文件时,这些设置或者可以作为一个选项,因此您不能犯任何错误,或者可以被验证。这样你就有了一个做基本错误检查的系统,像“ServreName”这样愚蠢的错误就被消除了。其次,这种方法在某种程度上加强了备份策略。如果您不小心破坏了应用配置,您总是可以重新创建它。第三,在我看来,这是最重要的方面,你可以有一个中心位置来配置多个客户端。例如,让我们假设您有一个由十个相同的 web 服务器组成的 web 场,所有这些服务器都位于一个 web 负载平衡器后面。所有的服务器都运行 Apache web 服务器,并且都应该进行相同的配置。通过使用自动化的配置系统,您只需生成一次配置文件(或者更好的是,您可以按需创建配置),然后上传到所有服务器。

也有一些缺点。任何配置工具,除非是为您正在配置的系统编写的,否则都会在您和应用之间增加一层。对配置结构的任何更改都会立即对配置工具产生影响。需要在配置系统中提供新的配置项目。即使是语法上最轻微的变化也需要考虑。如果您想充分利用您的配置工具,您必须针对每个新的软件版本重新验证它,以确保您的工具仍然生成有效的配置文件。

选择权显然在你。对于标准配置,我建议尽可能自动化,并且如果您正在创建自己的工具,您可以始终考虑特定于您的环境的额外配置。

功能需求

让我们回到 Apache web 服务器配置工具。首先,这个工具应该只生成基于名称的虚拟主机配置。我们不期望这个工具生成特定于服务器的配置,只期望生成负责定义虚拟主机的模块。

在虚拟主机定义部分,您可以使用来自各种已安装模块的配置指令。通常 Apache 核心模块总是可用的;因此,该工具应该为您提供来自核心模块的所有配置指令的列表。应该可以添加新的配置指令。

一些配置指令可能相互嵌套,如下例所示,SetHandler 指令封装在 Location 指令部分。该工具应该允许您定义配置指令之间的关系,其中一个指令被另一个指令封装:

<Location /status>
    SetHandler server-status
</Location>

可能会出现多个虚拟主机定义部分具有非常相似的配置的情况。我们将要构建的应用应该允许您克隆任何现有的虚拟主机定义及其所有配置指令。除了克隆操作之外,应用应该允许您将任何虚拟主机部分标记为模板。模板虚拟主机块不应该成为配置文件的功能部分,尽管它可以以注释块的形式包含在内。

任何虚拟主机定义中最重要的部分是服务器域名及其别名。虚拟主机响应的所有域名的列表应该很容易获得,并且应该提供到适当 web 位置的链接。

配置文件应该作为 web 资源可用,而服务器应该作为纯文本文件文档可用。

高层设计

如前所述,我们将使用 Django web 框架来构建我们的应用。然而,我们将重用 Django 提供的数据管理应用,而不是手动编写所有表单,我们将根据自己的需要对其进行配置。

应用不太可能维护大量虚拟主机的配置,因此我们将使用 SQLite3 数据库作为我们配置的数据存储。

我们将在数据库中存储两种类型的数据:虚拟主机对象和配置指令。这允许扩展和进一步修改应用——例如,我们可以扩展配置指令模型并添加一个“允许值”字段。

设置环境

我们已经在第三章和第四章中详细讨论了 Django 应用的结构,所以你应该可以轻松地为新应用创建环境设置。我将在这里简要地提到关键的配置项,这样你就可以更容易地理解本章后面的例子和代码片段。

Apache 配置

首先,我们需要指示 Apache web 服务器如何处理发送给我们的应用的请求。这是一个相当标准的配置,假设我们的工作目录在/srv/app/中,Django 项目名是 www_example_com。文档根目录设置为/srv/www/www.example.com,它仅用于包含到管理网站静态文件的链接。稍后我们将开始创建链接。清单 5-1 显示了代码。

清单 5-1 。Apache Web 服务器配置

<VirtualHost *:80>
    ServerName www.example.com
    DocumentRoot /srv/www/www.example.com
    ErrorLog /var/log/httpd/www.example.com-error.log
    CustomLog /var/log/httpd/www.example.com-access.log combined
    SetHandler mod_python
    PythonHandler django.core.handlers.modpython
    PythonPath sys.path+['/srv/app/']
    SetEnv DJANGO_SETTINGS_MODULE www_example_com.settings
    SetEnv PYTHON_EGG_CACHE /tmp
    <Location "/static/">
        SetHandler None
    </Location>
</VirtualHost>

创建配置后,我们确保配置文件(/srv/www/www.example.com/和/srv/app/)中提到的所有目录都存在。此外,我们确保这些目录归运行 Apache 守护进程的用户所有。通常是名为 apache 或 httpd 的用户。完成后,我们重新启动 Apache web 服务器,这样它就可以读入新的配置。

创建 Django 项目和应用

我们将从创建一个名为 www_example_com 的新 Django 项目开始。正如你从第三章第一章和第四章第三章已经知道的,这个项目实际上变成了一个 Python 模块,包括它的初始化方法 ?? 和可能的子模块(项目中的应用)。因此,项目名称必须遵循 Python 变量命名约定,不能包含点或以数字开头。我们先开始一个新项目:

$ cd /srv/app/
$ django-admin.py startproject www_example_com

此时,您应该能够导航到您之前定义的网站 URL(在我们的例子中,它是 http://www.example.com ),并且您应该看到标准的 Django 欢迎页面。

下一步是在项目中创建新的应用。为应用选择名称时,必须遵循与项目名称相同的命名规则。我将简单地称之为 httpconfig:

$ django-admin.py startapp httpconfig

配置应用

现在,我们需要指定项目的一些细节,比如数据库引擎类型,还要告诉项目新的应用。即使我们已经创建了它的框架文件,应用也不会自动包含在项目配置中。

首先,在项目目录的 settings.py 文件中更改数据库配置。不要担心数据库文件,因为它是自动创建的:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
    }
}

其次,更改默认的管理媒体位置;您将在现有的媒体目录中链接到它。在同一个 settings.py 文件中,确保具有以下设置:

ADMIN_MEDIA_PREFIX = '/static/admin/'

第三,将两个新应用添加到启用的应用列表中。您将启用管理应用,它是标准 Django 安装的一部分,您还将把您的应用添加到列表中:

INSTALLED_APPS = (
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'httpconfig',
)

第四,您必须运行一个数据库同步脚本,它将为我们创建数据库文件,并创建应用模型文件中定义的所有必需的数据库表。当然,在 httpconfig 应用中还没有,但是您需要完成这一步,以便管理和其他应用创建它们的数据库表。运行以下命令创建数据库:

$ python manage.py syncdb
Creating tables ...
Creating table django_admin_log
Creating table auth_permission
Creating table auth_group_permissions
Creating table auth_group
Creating table auth_user_groups
Creating table auth_user_user_permissions
Creating table auth_user
Creating table django_content_type
Creating table django_session

You just installed Django's auth system, which means you don't have any superusers defined.
Would you like to create one now? (yes/no): yes
Username (leave blank to use 'rytis'): 
Email address: rytis@example.com
Password: 
Password (again): 
Superuser created successfully.
Installing custom SQL ...
Installing indexes ...
Installed 0 object(s) from 0 fixture(s)

定义 URL 结构

您已经设置了应用和数据库,但是仍然无法导航到任何页面,甚至是管理界面。这是因为项目不知道如何响应请求 URL,以及如何将它们映射到适当的应用视图。

您需要在 urls.py 配置文件中做两件事:启用到管理接口对象的 URL 路由,并指向特定于应用的 urls.py 配置。特定于项目的 urls.py 文件位于/SRV/app/www _ example _ com/www _ example _ com/的项目目录中。启用这两种设置后,其内容将是清单 5-2 中的代码。

清单 5-2 。特定于项目(或站点)的 URL 映射

from django.conf.urls import patterns, include, url

# this is required by the administration appplication
from django.contrib import admin
admin.autodiscover()

urlpatterns = patterns('',
    # route requests to the administration application
    url(r'^admin/', include(admin.site.urls)),
    # delegate all other requests to the application specific
    # URL dispatcher
    url(r'', include('httpconfig.urls')),
)

您还没有在这个应用中创建任何视图,但是您已经可以在特定于应用的 urls.py 中定义 URL 映射,这需要在应用目录 httpconfig 中创建。大部分工作将在管理界面中完成,因此应用与外界的交互相当有限。它将只响应两个请求:如果 URL 路径上没有指定任何内容,视图应该以纯文本格式返回所有虚拟主机。如果指定了整数,它将只返回特定虚拟主机的配置文件部分。这将在管理界面中使用。在 httpadmin 目录中,创建清单 5-3 中所示的 urls.py 文件。

清单 5-3 。特定于应用的 URL 映射

from django.conf.urls import patterns, include, url

urlpatterns = patterns('httpconfig.views',
    url(r'^$', 'full_config'),
    url(r'^(?P<object_id>\d+)/$', 'full_config'),
)

这种配置意味着 URL 中没有特定于应用的部分——对根位置的所有请求都将被转发到我们的应用。如果您需要将该应用“隐藏”在 URL 中的某个路径后面,请参考第三章和第四章中的来了解如何操作的详细信息。

除了这个配置之外,您还必须定义视图方法;否则,Django URL 解析器可能会抱怨未定义的视图。在应用目录的 views.py 文件中创建以下方法:

from django.http import HttpResponse

def full_config(request):
    return HttpResponse('<h1>Hello!</h1>')

Image 提示如果您在导航到新创建的网站时出现任何错误,请确保项目目录中的所有文件和目录以及项目目录本身都属于 Apachehttpd 用户。还要注意,如果您对项目目录中的 Python 文件进行了任何更改,您将需要重新启动 Apache 守护进程,以便新的代码而不是旧的代码来处理请求,旧的代码可能仍然缓存在内存中。

数据模型

正如我们在需求和设计部分所讨论的,我们的应用的数据库模型相当简单,只包含两个实体: 虚拟主机定义和配置指令定义。然而,对于实现,我们还需要向模式中添加第三个元素,将虚拟主机和配置指令元素联系起来。添加另一个表的原因是每个配置指令可以是一个或多个虚拟主机的一部分。此外,每个虚拟主机中可能有一个或多个指令。因此,我们在对象之间有一个多对多的关系,为了解决这个问题,我们需要插入一个与其他表有一对多关系的中间表。

我们可以在图 5-1 所示的实体关系(ER)图中表示这个关系模型,在图中可以看到每个实体的属性以及它们之间的关系。ER 图 在编码时真的很有帮助,如果你知道不同表之间的关系,它们有时会让你不用编写复杂的代码来查找可以通过简单的 SQL 语句轻松获得的信息。我们将在后面的章节中再次使用这种技术。

9781484202180_Fig05-01.jpg

图 5-1 。实体关系图

Image 图 5-1 中的图表是使用 MySQL 工作台工具生成的。它遵循用来表示数据表的约定和结构,以及它们之间的关系(一对多链接,等等)。那些细节的描述已经超出了本书的范围,但是如果你想进一步了解这个主题,我推荐开始数据库设计:从新手到专业人员, 2 nd ed 。,作者 Clare Churcher(纽约:Apress,2012),这是一篇很好的数据库设计入门。在维基百科页面en.wikipedia.org/wiki/Entity-relationship_model上可以找到图表中使用的一些符号的更简短的描述。

您可以看到 ConfigDirective 和 VirtualHost 表与 VHostDirective 表具有一对多的关系。该表还包含配置指令的值,该值特定于特定的虚拟主机。您可能还注意到,VHostDirective 自身有一个环回关系。这是为了实现指令封装,其中一些指令可以是其他指令的“父”指令。

基本模型结构

在创建数据模型时,我们将经历几次迭代。我们将从只包含对象属性的基本模型开始,然后随着管理界面的改进逐步添加功能。清单 5-4 显示了初始代码。

清单 5-4 。基本模型结构

from django.db import models

class ConfigDirective(models.Model):
    name = models.CharField(max_length=200)
    is_container = models.BooleanField(default=False)
    documentation = models.URLField(
                       default='http://httpd.apache.org/docs/2.0/mod/core.html')

    def __unicode__(self):
        return self.name

class VirtualHost(models.Model):
    is_default = models.BooleanField(default=False)
    is_template = models.BooleanField(default=False, 
                                      help_text="""Template virtual hosts are 
                                                  commented out in the configuration 
                                                  and can be reused as templates""")
    description = models.CharField(max_length=200)
    bind_address = models.CharField(max_length=200)
    directives = models.ManyToManyField(ConfigDirective, through='VHostDirective')

    def __unicode__(self):
        default_mark = ' (*)' if self.is_default else ''
        return self.description + default_mark

class VHostDirective(models.Model):
    directive = models.ForeignKey(ConfigDirective)
    vhost = models.ForeignKey(VirtualHost)
    parent = models.ForeignKey('self', blank=True, null=True, 
                               limit_choices_to={'directive__is_container': True})
    value = models.CharField(max_length=200)

    def __unicode__(self):
        fmt_str = "<%s %s>" if self.directive.is_container else "%s %s"
        directive_name = self.directive.name.strip('<>')
        return fmt_str % (directive_name, self.value)

如果你遵循了第三章和第四章中的例子和解释,你应该对这个模型相当熟悉。定义每个元素的基本属性,以及定义类之间关系的 ForeignKey 对象。

不过,有一件事您可能不太熟悉 VirtualHost 类中的多对多关系声明:

directives = models.ManyToManyField(ConfigDirective, through='VHostDirective')

如果已经定义了连接两个实体的 VHostDirective 类,为什么还要显式定义这种关系呢?原因在于,这允许您直接从 VirtualHost 中找到相应的 ConfigDirectives,而不必先找到 VHostDirective 对象。

我们可以从这个模型中创建数据库结构,但是此时它将是空的,因此如果没有核心 Apache 模块指令的列表,它就没有多大用处。我已经创建了一个包含所有核心模块指令条目的初始数据 JSON 文件。下面是几个条目的例子:你可以从这本书的网页上的apress.com获得全套资料。

[
  <...>
    {
        "model":    "httpconfig.configdirective",
        "pk":       1,
        "fields":   {
                        "name":   "AcceptPathInfo",
                        "documentation":
                    "http://httpd.apache.org/docs/2.0/mod/core.html#AcceptPathInfo",
                        "is_container":     "False"
                    }
    },

    {
        "model":    "httpconfig.configdirective",
        "pk":       2,
        "fields":   {
                        "name":   "AccessFileName",
                        "documentation":
                    "http://httpd.apache.org/docs/2.0/mod/core.html#AccessFileName",
                        "is_container":     "False"
                    }
    },
  <...>
]

如果您将该文件复制到项目目录(在我们的示例中,这将是 www _ example _ com/httpconfig/fixtures/)并将其命名为 initial_data.json,则每次运行 syncdb 命令时都会加载该文件中的数据。现在,删除所有与应用相关的表(如果您已经在数据库中创建了表的话),并使用新模型和初始数据集重新创建数据库表:

$ sqlite3 database.db 
SQLite version 3.7.13 2012-07-17 17:46:21
Enter ".help" for instructions
Enter SQL statements terminated with a ";"
sqlite> .tables
auth_group                  django_admin_log
auth_group_permissions      django_content_type
auth_message                django_session
auth_permission             django_site
auth_user                   httpconfig_configdirective
auth_user_groups            httpconfig_vhostdirective
auth_user_user_permissions  httpconfig_virtualhost
sqlite> drop table httpconfig_configdirective;
sqlite> drop table httpconfig_vhostdirective;
sqlite> drop table httpconfig_virtualhost;
sqlite> .exit 
$ ./manage.py syncdb
Creating table httpconfig_configdirective
Creating table httpconfig_virtualhost
Creating table httpconfig_vhostdirective
Installing index for httpconfig.VHostDirective model
Installing json fixture 'initial_data' from absolute path.
Installed 62 object(s) from 1 fixture(s)

您几乎可以开始在管理应用中管理对象了;只需在管理界面中注册所有的模型类,然后重启 Apache web 服务器。正如您已经知道的,您必须在应用目录中创建 admin.py 文件,其内容类似于清单 5-5 。

清单 5-5 。基本管理挂钩

from django.contrib import admin
from www_example_com.httpconfig.models import *

class VirtualHostAdmin(admin.ModelAdmin):
    pass

class VHostDirectiveAdmin(admin.ModelAdmin):
    pass

class ConfigDirectiveAdmin(admin.ModelAdmin):
    pass

admin.site.register(VirtualHost, VirtualHostAdmin)
admin.site.register(ConfigDirective, ConfigDirectiveAdmin)
admin.site.register(VHostDirective, VHostDirectiveAdmin)

如果您导航到管理控制台,您可以在www.example.com/admin/找到,您将看到登录屏幕。您可以使用在第一次调用 syncdb 时创建的用户帐户登录。登录后,您将看到标准的管理界面,其中列出了所有的模型类,并允许您创建单独的条目。现在,您必须意识到这已经为您节省了多少工作——您不需要处理用户管理、模型对象发现或任何其他日常工作。然而,管理接口 是通用的,完全不知道数据模型背后的目的以及哪些字段对您来说是重要的。

让我们以我们的模型为例。您的主要实体是虚拟主机。但是,如果您在管理界面中导航到它,您将只能在列表视图中看到一列。如果您添加了任何条目,您将看到显示的是描述字段。单击添加按钮添加新的虚拟主机。显示了所有属性字段,但是配置指令呢?这些需要在不同的屏幕上单独创建,然后您必须将每个指令链接到适当的虚拟主机。那不是很有用,是吗?

幸运的是,Django 管理模块非常灵活,可以定制以适应您能想到的大多数需求。在接下来的部分中,我们将改进管理界面的外观,并为其添加更多的功能。

修改管理界面

大多数管理界面调整都是在 models.py 和 admin.py 文件中完成的。Python 社区正试图将所有的模型定义文件从管理定制文件中分离出来,并且已经做了大量的工作来实现这种分离。但是,在撰写本文时,仍然可以在 models.py 文件中找到一些影响管理界面的项目。在这两种情况下,我将始终指出您需要在哪个文件中进行更改,但是除非得到指示,否则始终假定应用目录为:/SRV/app/www _ example _ com/http config/。

改进类和对象列表

管理应用只能猜测您的数据模型、它的属性以及您希望得到的信息。因此,如果您不做任何修改或调整,您将得到标准的对象表示字符串显示,就像该类的 unicode()方法返回的字符串一样。在接下来的几节中,我将向您展示如何更改默认布局。

自定义类名

默认情况下,Django 试图猜测类名。通常,管理框架会得到相当接近的结果,但是有时您可能会以奇怪的名称结束。例如,我们的三个类将被列为:

  • 配置指令
  • v 主机指令
  • 虚拟主机

在这种情况下,“V host 指令”的名称可能看起来有点神秘。另一个问题是类名的复数形式。我们已经很好地解决了这些例子,但是如果我们有一个名为“Host Entry”的类,例如,我们会以自动生成的复数形式“Host Entrys”结束,这显然不是正确的拼写。

在这种情况下,您可能希望自己设置类名和名称的复数形式。您不需要设置两者,只需设置您想要修改的一个即可。这个设置是在模型定义文件 models.py 中完成的。

清单 5-6 。更改类名

class ConfigDirective(models.Model):
    class Meta:
        verbose_name = 'Configuration Directive'
        verbose_name_plural = 'Configuration Directives'
    [...]

class VirtualHost(models.Model):
    class Meta:
        verbose_name = 'Virtual Host'
        verbose_name_plural = 'Virtual Hosts'
    [...]

class VHostDirective(models.Model):
    class Meta:
        verbose_name = 'Virtual Host Directive'
        verbose_name_plural = 'Virtual Host Directives'
    [...]

您进行修改并重新加载 Apache web 服务器。现在,您将看到更多可读的选项:

  • 配置指令
  • 虚拟主机指令
  • 虚拟主机

向对象列表添加新字段

让我们从修改虚拟主机列表开始。如果您还没有创建任何虚拟主机,现在就可以这样做。在配置中使用什么属性并不重要;在这个阶段,我们只对正确的布局感兴趣。此外,为您创建的虚拟主机分配一些配置指令。

任何虚拟主机最重要的属性之一是 ServerName,它定义了这个特定虚拟主机响应的主机名。如您所知,Apache web 服务器通过主机 HTTP 头值来标识虚拟主机。它从 HTTP 请求中获取该值,并尝试将其与配置文件中的所有 ServerName 或 ServerAlias 字段进行匹配。当它找到一个匹配时,它就知道哪个虚拟主机应该服务于这个特定的请求。因此,这两条指令可能是您希望在虚拟主机列表中看到的指令。

在只显示对象的字符串表示的列表中,如何包括这些虚拟主机?您可以使用 ModelAdmin 类属性 list_display 来指定您想要显示的属性,但是在 VirtualHost 类中没有服务器名称列表这样的属性。因此,您必须编写自己的方法来返回每个关联的 ServerName 和 ServerAlias。你用清单 5-7 所示的方法扩展你的虚拟主机类。

清单 5-7 。列出相关的服务器名和服务器别名

def domain_names(self):
  result = ''
  primary_domains = self.vhostdirective_set.filter(directive__name='ServerName')
  if primary_domains:
      result = "<a href='http://%(d)s' target='_blank'>%(d)s</a>" % 
                                   {'d': primary_domains[0].value}
  else:
      result = 'No primary domain defined!'
  secondary_domains = self.vhostdirective_set.filter(directive__name='ServerAlias')
  if secondary_domains:
      result += ' ('
        for domain in secondary_domains:
        result += "<a href='http://%(d)s' target='_blank'>%(d)s</a>, " % 
                                  {'d': domain.value}
      result = result[:-2] + ')'
  return result
domain_names.allow_tags = True

此代码获取所有指向 ConfigDirective 对象的 VHostDirective 对象,该对象的名称为“ServerName”或“ServerAlias”。然后,将这种 VHostDirective 对象的值追加到结果字符串中。事实上,该值用于构造一个 HTML 链接,单击该链接将在新的浏览器窗口中打开。这里的意图是虚拟主机的所有链接都显示在列表中,并且是可点击的,因此您可以立即测试它们。

让我们仔细看看检索 VHostDirective 对象的指令(清单 5-7 中突出显示的行)。正如您从模型定义中所知道的,您现在正在修改的 VirtualHost 类没有链接到 VHostDirective 类。链接反了;VHostDirective 类有一个指向 VirtualHost 类的外键。Django 允许您通过使用特殊的属性名<lower case _ class _ name>_ set 来创建反向查找。在我们的例子中,名称是 virtualhostdirective_set。此属性实现标准的对象选择方法,如 filter()和 all()。现在,使用这个 virtualhostdirective_set 属性,我们实际上正在访问 VHostDirective 类的实例,因此我们可以指定一个转发过滤器,该过滤器根据我们的搜索字符串匹配相应的指令对象名称:directive__name='ServerName '。

让我们添加另一个方法,返回到对象表示 URL 的链接。我们还将在清单中显示它,以便用户可以单击它,这个虚拟主机的代码片段将出现在新的浏览器窗口中。这个虚拟主机类方法是在 models.py 文件中定义的:

def code_snippet(self):
    return "<a href='/%i/' target='_blank'>View code snippet</a>" % self.id
code_snippet.allow_tags = True

您是否注意到,在这两种情况下,我们都通过将方法的 allow_tags 属性设置为 True 来修改它?这阻止了 Django 解析 HTML 代码并用“安全”字符替换它们。启用标记后,您可以在对象列表中放置任何 HTML 代码;例如,您可以包含指向外部 URL 的链接或包含图像。

最后,让我们列出我们希望在对象列表中看到的所有属性。这包括我们刚刚创建的两个函数的类属性和名称。将以下属性添加到 admin.py 文件中的 ModelAdmin 类定义中:

class VirtualHostAdmin(admin.ModelAdmin):
    list_display = ('description', 'is_default', 'is_template', 
                    'bind_address', 'domain_names', 'code_snippet')

现在当你导航到虚拟主机对象列表时,你应该看到类似于图 5-2 的东西。这可能不明显,但是列出的域名和代码片段文本是可点击的,并且应该在新的浏览器窗口中打开 URL。

9781484202180_Fig05-02.jpg

图 5-2 。修改对象列表视图

重组表单字段

如果您尝试使用当前的管理界面添加虚拟主机实例,您可能会注意到这个过程是多么不友好和混乱。首先,您必须创建一个新的 VirtualHost 对象;然后,您必须离开它,通过选择新创建的 VirtualHost 对象来创建一个或多个 VHostDirective 对象。如果可以从一个表单中创建所有这些内容,岂不是更好?幸运的是,这很容易做到。用 Django 的术语来说,这叫做内联表单集,它允许你在与父模型相同的页面上编辑模型。

在我们的例子中,父模型是 VirtualHost,我们想要内联编辑 VHostDirective 的实例。这只需两步就能完成。首先,创建一个新的 administration 类,它继承自 admin。TabularInline 类。将以下代码添加到 admin.py 文件中。这个类的属性表明您想要包含哪个子模型,以及您想要在表单集中有多少额外的空行:

class VHostDirectiveInLine(admin.TabularInline):
    model = VHostDirective
    extra = 1

第二步是指示管理类,您希望将这个内嵌表单包含在主模型编辑表单中:

class VirtualHostAdmin(admin.ModelAdmin):
    inlines = (VHostDirectiveInLine,)
    [...]

这个简单的操作产生了一个非常漂亮的表单集,包括父模型和子模型的输入字段,如图 5-3 所示。

9781484202180_Fig05-03.jpg

图 5-3 。包括子模型编辑表单

如果您不喜欢表单中字段的组织方式,您可以更改它们的顺序,也可以将它们分组到逻辑组中。通过定义字段集对字段进行分组。每个字段集是由两个元素组成的元组:一个字段集名称和一个字段集属性字典。需要一个字典键,即字段列表。另外两个键 classes 和 description 是可选的。以下是 ConfigDirective 模型管理表单的示例,其中定义了两个字段集组:

class ConfigDirectiveAdmin(admin.ModelAdmin):
    fieldsets = [
                    (None,      {'fields': ['name']}),
                    ('Details', {'fields': ['is_container', 'documentation'],
                             'classes': ['collapse'],
                             'description': 'Specify the config directive details'})
                ]

第一组只包含一个字段,没有名称。第二组标记为详细信息..它在标签下有一个简短的描述,包含两个字段,并具有显示/隐藏功能。

Classes 属性定义 CSS 类名并依赖于类定义。标准的 Django administration CSS 定义了两个有用的类:折叠类允许你显示/隐藏整个组,宽类为表单字段增加了一些额外的空间。

添加自定义对象操作

我们几乎准备好了应用,但是还有两个函数需要实现。在虚拟主机模型中,我们有一个布尔标志来指示该主机是否是默认主机。该信息也方便地显示在列表中。然而,如果我们想改变它,我们必须导航到对象的编辑页面并在那里改变设置。

如果可以在对象列表屏幕上完成,只需选择适当的对象并使用列表左上角下拉菜单中的操作,那就太好了。但是,当前唯一可用的操作是“删除选定的虚拟主机”Django 允许我们定义自己的动作功能,并将它们添加到管理屏幕菜单中。在动作列表中获得一个新函数有两个步骤。首先,我们在管理类中定义一个方法;接下来,我们确定应该将该方法作为一个操作列在其操作列表中的管理类。

调用自定义操作方法时,会传递三个参数。第一个是调用方法的模型管理类的实例。我们可以在模型管理类之外定义自定义方法,在这种情况下,多个模型管理类可以重用它们。如果在特定的模型管理类中定义方法,第一个参数将始终是该类的实例;换句话说,这是一个典型的类方法自我属性。

第二个参数是 HTTP 请求对象。一旦操作完成,它可以用来将消息传递给用户。

第三个参数是包含用户选择的所有对象的查询集。这是您将要操作的对象列表。因为只能有一个默认虚拟主机,所以您必须检查是否选择了多个对象,如果是,则返回一个错误,指出。清单 5-8 显示了对模型管理类的修改,它创建了一个新的自定义对象动作。

清单 5-8 。设置默认虚拟主机标志的自定义操作

class VirtualHostAdmin(admin.ModelAdmin):
    [...]
    actions = ('make_default',)

    def make_default(self, request, queryset):
        if len(queryset) == 1:
            VirtualHost.objects.all().update(is_default=False)
            queryset.update(is_default=True)
            self.message_user(request, 
                 "Virtual host '%s' has been made the default virtual host" % queryset[0])
        else:
            self.message_user(request, 'ERROR: Only one host can be set as the default!')
    make_default.short_description = 'Make selected Virtual Host default'

我们要定义的下一个自定义操作是对象复制。此操作会提取选定的对象并“克隆”它们。克隆将具有相同的设置和具有相同值的相同配置指令集,但以下例外情况适用:

  • 虚拟主机描述将在其描述后附加“(Copy)”字符串。
  • 新的虚拟主机不会是默认的。
  • 新的虚拟主机不会是模板。

这里的挑战是正确解析 VHostDirective 对象的所有父子依赖关系。在 Apache 虚拟主机定义中,我们只能有一个封装级别,所以我们不需要对相关对象进行任何递归发现。复制方法可分为以下逻辑步骤:

  1. 创建 VirtualHost 类的新实例并克隆所有属性。
  2. 克隆没有任何父级的所有指令。
  3. 克隆所有容器指令,因此可能包含子指令。
  4. 对于每个容器指令,找到它的所有子指令并克隆它们。

清单 5-9 显示了复制功能代码。

清单 5-9 。复制虚拟主机对象的操作

def duplicate(self, request, queryset):
    msg = ''
    for vhost in queryset:
        new_vhost = VirtualHost()
        new_vhost.description = "%s (Copy)" % vhost.description
        new_vhost.bind_address = vhost.bind_address
        new_vhost.is_template = False
        new_vhost.is_default = False
        new_vhost.save()
        # recreate all 'orphan' directives that aren't parents
        o=vhost.vhostdirective_set.filter(parent=None).filter(directive__is_container=False)
        for vhd in o:
            new_vhd = VHostDirective()
            new_vhd.directive = vhd.directive
            new_vhd.value = vhd.value
            new_vhd.vhost = new_vhost
            new_vhd.save()
        # recreate all parent directives
        for vhd in vhost.vhostdirective_set.filter(directive__is_container=True):
            new_vhd = VHostDirective()
            new_vhd.directive = vhd.directive
            new_vhd.value = vhd.value
            new_vhd.vhost = new_vhost
            new_vhd.save()
            # and all their children
            for child_vhd in vhost.vhostdirective_set.filter(parent=vhd):
                msg += str(child_vhd)
                new_child_vhd = VHostDirective()
                new_child_vhd.directive = child_vhd.directive
                new_child_vhd.value = child_vhd.value
                new_child_vhd.vhost = new_vhost
                new_child_vhd.parent = new_vhd
                new_child_vhd.save()
    self.message_user(request, msg)
duplicate.short_description = 'Duplicate selected Virtual Hosts'

生成配置文件

我们已经完成了管理界面的调整,因此现在可以添加新的虚拟主机和管理现有的数据库条目了。我们需要完成显示信息的视图方法的编写。不过,有一个问题:“parent”指令模仿了 XML 语法。也就是说,它们有开始和结束元素。我们为 VHostDirective 模型类编写的默认字符串表示负责开始元素,但是我们还需要编写一个函数来生成类似 XML 的结束标记。这两个标签将用于包含“子”配置指令。

我们将下面的方法添加到 models.py 文件中的 VHostDirective 类。如果指令被标记为容器指令:,此函数将转换为

def close_tag(self):
    return "</%s>" % self.directive.name.strip('<>') if self.directive.is_container else ""

一旦我们完成了这些,我们用清单 5-10 中的代码扩展之前创建的空视图方法。如果没有提供参数,这段代码将遍历所有可用的对象。如果整数作为参数提供,它将只选择具有匹配 ID 的对象。对于列表中的所有对象,都会创建一个字典结构。该结构包含 VirtualHost 对象和相应的指令对象。孤儿和容器是分开存储的,所以在模板中更容易区分它们。return 对象将响应的 MIME 类型设置为“text/plain”,这允许我们将 URL 直接下载到配置文件中。

清单 5-10 。查看方法

from httpconfig.models import *
from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import render_to_response, get_object_or_404

# Create your views here.

def full_config(request, object_id=None):
    if not object_id:
        vhosts = VirtualHost.objects.all()
    else:
        vhosts = VirtualHost.objects.filter(id=object_id)
    vhosts_list = []
    for vhost in vhosts:
        vhost_struct = {}
        vhost_struct['vhost_data'] = vhost
        vhost_struct['orphan_directives'] = \ 
          vhost.vhostdirective_set.filter(directive__is_container=False).filter(parent=None)
        vhost_struct['containers'] = []
        for container_directive in \ 
          vhost.vhostdirective_set.filter(directive__is_container=True):
            vhost_struct['containers'].append({'parent': container_directive,
                                               'children': \ 
          vhost.vhostdirective_set.filter(parent=container_directive),
                                              })
        vhosts_list.append(vhost_struct)
    return render_to_response('full_config.txt', 
                              {'vhosts': vhosts_list},
                              mimetype='text/plain')

Image 注意例子中的反斜杠字符是用来换行的。这是一种有效的 Python 语言语法,允许您格式化代码以获得更好的可读性。如果您重新键入这些示例,请保持相同的代码结构和布局。不要将反斜杠字符与换行符号(shift-enter.jpg)混淆,换行符号表示该行太长,不适合一页,已经换行。重用示例时,必须连接由该符号拆分的线条。

从第三章和第四章可知,模板存储在应用文件夹的 templates 子目录中。清单 5-11 显示了 full_config.txt 模板。

清单 5-11 。虚拟主机视图模板

# Virtual host configuration section
# automatically generated - do not edit

{% for vhost in vhosts %}

##
## {{ vhost.vhost_data.description }}
##
{% if vhost.vhost_data.is_template %}#{% endif %} <VirtualHost {{
vhost.vhost_data.bind_address }}>
{% if vhost.vhost_data.is_template %}#{% endif %}    {% for orphan_directive in 
vhost.orphan_directives %}
{% if vhost.vhost_data.is_template %}#{% endif %}      {{ orphan_directive }}
{% if vhost.vhost_data.is_template %}#{% endif %}    {% endfor %}
{% if vhost.vhost_data.is_template %}#{% endif %}    {% for container in vhost.containers %}
{% if vhost.vhost_data.is_template %}#{% endif %}      {{ container.parent|safe }}
{% if vhost.vhost_data.is_template %}#{% endif %}        {% for child_dir in 
container.children %}
{% if vhost.vhost_data.is_template %}#{% endif %}          {{ child_dir }}
{% if vhost.vhost_data.is_template %}#{% endif %}        {% endfor %}
{% if vhost.vhost_data.is_template %}#{% endif %}      {{ container.parent.close_tag|safe }}
{% if vhost.vhost_data.is_template %}#{% endif %}    {% endfor %}
{% if vhost.vhost_data.is_template %}#{% endif %}  </VirtualHost>

完成所有修改后,您应该能够导航到网站 URL(在我们的示例中,这将是www.example.com/),结果应该是自动生成的 Apache 配置文件的一部分,其中包含虚拟主机定义,如清单 5-12 所示。请注意,模板也包括在内,但被注释掉了,因此将被 web 服务器忽略。

清单 5-12 。示例配置文件

# Virtual host configuration section
# automatically generated - do not edit
##
## My test server 1
##
  <VirtualHost *>
      ServerName www.apress.com
      <Directory />
          AcceptPathInfo Off
          AddDefaultCharset Off
      </Directory>
  </VirtualHost>
##
## Another test server
##
#  <VirtualHost *:8080>
#
#      ServerName www.google.com
#
#      ServerAlias www.1e100.net
#
#
#  </VirtualHost>

摘要

在本章中,我们讨论了如何修改默认的 Django 管理应用,使其更加用户友好并适合您的对象模型。需要记住的要点:

  • 对象列表可以包括任何模型属性以及自定义函数。
  • 对象列表中的自定义函数也可以生成 HTML 输出。
  • 您可以向对象列表管理页面添加自定义操作。
  • 如果您的模型有许多字段,它们可以重新排列成逻辑组。
  • 您可以将子模型作为内嵌字段集包含在父编辑页面中。

六、从 Apache 日志文件中收集和显示统计数据

本章涵盖了基于插件的应用的架构和实现。例如,我们将构建一个分析 Apache 日志文件的框架。我们将使用模块化的方法,而不是创建一个单一的应用。一旦我们有了一个基础框架,我们将为它创建一个插件,该插件基于请求者的地理位置执行分析。

应用结构和功能

在数据挖掘和统计信息收集领域,很难找到一个能够满足多个用户需求的应用。让我们以 Apache web 服务器日志的分析为例。web 服务器收到的每个请求都被写入日志文件。每个日志行中写有几个不同的数据字段,以及请求到达时的时间戳。

假设您被要求编写一个分析日志文件并生成报告的应用。这是来自对统计信息感兴趣的用户的典型请求的范围。显然,您对这个请求无能为力,所以您要求更多的信息,比如用户到底想在他们的报告中看到什么。现在,假设的用户越来越多地参与到设计阶段,他们告诉你他们想要看到特定文件的下载总数。嗯,这很容易做到。但是随后您会收到另一个请求,要求提供每小时的网站点击率统计数据。你把它写进去。然后,请求将一天中的时间与浏览器类型相关联。这样的例子不胜枚举。即使您正在为一个特定的组织编写工具,需求也是多种多样的,不可能在需求收集阶段捕获。那么在这种情况下你应该怎么做呢?

拥有一个可以用专门提取和处理信息的模块来扩展的通用应用不是很好吗?每个模块将负责执行特定的计算并生成报告。这些模块可以根据需要添加和删除,而不会影响其他模块的功能,更重要的是,不需要对主应用进行任何更改。这种类型的模块化结构通常被称为插件架构。

插件是扩展主应用功能的一小块软件。这种技术非常流行,被用于许多不同的应用中。网络浏览器就是一个很好的例子。市场上大多数流行的网络浏览器都支持插件。网页可能包含嵌入的 Adobe Flash 电影,但浏览器本身不知道(也不需要知道)如何处理这种类型的文件。所以它寻找一个具有能力的插件来处理和显示 Adobe Flash 文件。如果找到这样的插件,它会将文件对象传递给它进行处理。如果找不到插件,对象就不会显示给最终用户。缺少适当的插件并不妨碍网页的显示。

我们将使用这种方法来构建分析 Apache 日志的应用。让我们从应用的特定统计分析任务的需求开始。

应用要求

我们需要在应用中实现两个主要需求:

  • 主应用将负责解析 Apache 日志文件,并从每个日志行中提取字段。日志行格式在不同的 web 服务器安装之间可能会有所不同,因此应用应该是可配置的,以匹配日志文件格式。
  • 应用应该能够发现已安装的插件模块,并将提取的字段传递给适当的插件模块进行进一步处理。添加新的插件模块不应该对现有模块的功能和主应用的功能有任何影响。

应用设计

这些要求意味着应用应该分为两部分:

  • 主应用: 应用将从作为命令行参数提供给它的目录列表中解析日志文件。每个日志文件将一次处理一行。该应用不保证按时间顺序处理文件。每一个日志行都在单词边界上分开,字段分隔符是空格字符。一些字段的内容中可能会有空格字符;此类字段必须用双引号括起来。为了便于使用,这些字段将由相应的日志格式字段代码来标识,如 Apache 文档中所述。
  • 插件管理器组件: 插件管理器负责发现和注册可用的插件模块。只有特殊的 Python 类将被视为插件模块。每个插件都公开它感兴趣的日志字段。当主应用解析日志文件时,它将检查订阅的插件表,并将所需的信息传递给相关的插件。

接下来,让我们看看如何用 Python 实现插件框架。

Python 中的插件框架实现

谈到 Python 中的插件框架实现,有好消息也有坏消息。坏消息是在实现插件架构时没有标准的方法。有几种不同的技术,以及商业和开源产品可供使用,但每种技术处理问题的方式都不同。有些人在某一方面做得更好,但在其他方面可能做得不够。您选择实现这种架构的方式很大程度上取决于您想要实现什么。

好消息是实现插件框架没有事实上的标准,所以我们可以自己编写!在我们编写实现的过程中,您将学到一些关于 Python 语言和编程技术的新东西,比如类类型检查、duck 类型化和动态模块加载。

但是,在我们深入研究技术细节之前,让我们确切地了解一下什么是插件,以及它与主应用或主机应用的关系。

插件框架的机制

主机应用处理它接收到的数据—无论是日志解析引擎的日志文件、web 浏览器的 HTML 文件还是其他类型的文件。它的工作完全不受插件及其功能的影响。然而,主应用向插件模块提供服务。

在日志处理应用的情况下,它唯一的职责是从文件中读取数据,识别日志格式,并将数据转换成适当的数据结构。这是它提供给插件模块的服务。主应用不关心它产生的数据是否被任何模块使用,也不关心它是如何被使用的。

插件模块很大程度上依赖于主机应用。举个例子,让我们以计算请求数量的插件为例。这个插件不能执行任何计数,除非它接收到数据。因此,如果没有主应用,插件很少有用。

你可能想知道你为什么要为这种分离而烦恼。为什么插件模块不能读取数据文件并对数据做任何需要做的事情?正如我们所讨论的,可能有许多不同的应用使用相同的数据执行不同的计算。从开发的角度来看,让每个模块实现相同的数据读取和解析功能是低效的——一次又一次地重新开发相同的流程需要时间。

显然,这是一个相当简单的例子。通常,最终用户不会注意到主应用和插件模块之间的这种分离。用户将应用体验为应用和插件的组合结果。

让我们再次考虑 web 浏览器的例子。HTML 页面由浏览器引擎渲染并呈现给用户。插件模块呈现页面中的各种组件。例如,Adobe Flash 电影由 Flash 插件呈现,而 Windows Media 文件由 Windows Media 插件模块呈现。用户只能看到最终结果:呈现的网页。向系统添加新的插件只是扩展了应用的功能。部署新插件后,用户可以开始访问在插件安装前无法正确显示(或根本无法显示)的网站。

另一个基于插件的应用的例子是 Eclipse 项目(eclipse.org/)。它最初是一个 Java 开发环境,但现在已经发展成为一个支持多种语言、集成各种版本控制系统并提供建模和报告的平台——这一切都归功于它的插件架构。基本的应用不会做很多事情,但是您可以通过安装适当的插件来扩展它,并根据您的需要进行定制。因此,同一个“应用”可能会做完全不同的事情。对我来说,它是一个 Python 开发平台;对其他人来说,它是一个 UML 建模工具。

界面模型

正如您可能已经猜到的,主机应用和插件模块通常是非常松散耦合的实体。因此,必须为这两个实体之间的交互定义一个协议。通常,宿主应用会公开记录良好的服务接口,比如函数名。每当插件方法需要主机应用的任何东西时,就会调用它们。

类似地,插件公开它们的接口,以便宿主应用可以向它们发送数据或通知它们一些正在发生的事件。这是事情变得稍微复杂的地方。插件模块通常实现主机应用可能不知道的功能。因此,插件可以宣布它们的能力,例如显示 Flash 电影文件的能力。能力类型通常与模块函数名相关联,因此主应用知道哪个方法实现了该能力。

作为一个例子,让我们考虑一个简单的浏览器模型。我们有一个基本的主机应用,它接收 HTML 页面并下载所有链接的资源。每个资源都有一个与之关联的 MIME 类型。Flash 对象具有 application/x-shockwave-flash 类型。当浏览器遇到这样的对象时,它会查看其插件注册表,并搜索声称具有处理此类文件能力的插件。一旦找到插件和方法名,宿主应用就调用该方法并将文件对象传递给它。

插件注册和发现

那么宿主应用检查的这个插件注册表到底是什么呢?简而言之,它是已经找到并与主应用一起加载的所有插件模块的列表。该列表通常包含对象实例、它们的功能以及实现这些功能的函数。注册表是存储所有插件实例的中心位置,这样主机应用就可以在运行时找到它们。

插件注册中心是在插件发现过程中创建的。在不同的实现中,发现过程会有所不同,但通常包括找到合适的应用文件并将它们加载到内存中。通常,主机应用中有一个单独的进程来处理插件管理任务,如发现、注册和控制。图 6-1 显示了所有组件及其关系的概述。

9781484202180_Fig06-01.jpg

图 6-1 。典型的插件架构

创建插件框架

正如我提到的,有几种方法可以在 Python 中实现基于插件的架构。在这里,我将讨论一种最简单的方法,它足够灵活,可以满足大多数小型应用的需要。

Image andréRoberge 博士在 PyCon 2009 上做了一个描述性的介绍,比较了几种不同的插件机制。你可以在 http://blip.tv/file/1949302/找到他的演讲,题目是“插件和恶作剧:增加灵活性,应对不灵活”。如果你决定需要一个更复杂的实现,看看 Zope(zope.org/)、Grok(grok.zope.org/)和 envision(code.enthought.com/projects/envisage/)框架提供的实现。这些产品是企业级插件框架,允许您构建可扩展的应用。使用它们的缺点是对于简单的应用来说,它们通常太大太复杂。

发现和注册

发现过程基于这样一个事实,即基类可以找到它的所有子类。这里有一个简单的例子:

>>> class Plugin(object):
...  pass
... 
>>> class MyPlugin1(Plugin):
...  def __init__(self):
...   print 'plugin 1'
... 
>>> class MyPlugin2(Plugin):
...  def __init__(self):
...   print 'plugin 2'
... 
>>> Plugin.__subclasses__()
[<class '__main__.MyPlugin1'>, <class '__main__.MyPlugin2'>]
>>>

这段代码创建了一个基类,然后定义了另外两个从基类继承的类。我们现在可以通过调用基类内置方法 _ _ subclass _ _(),找到所有从主类继承的类。这是一个非常强大的机制,可以在不知道类的名字,甚至不知道它们被加载的模块的名字的情况下找到类。

一旦发现了类,我们就可以创建每个类的实例,并将它们添加到一个列表中。这是注册过程。注册完所有对象后,主程序可以开始调用它们的方法:

>>> plugins = []
>>> for cls in Plugin.__subclasses__():
...  obj = cls()
...  plugins.append(obj)
... 
plugin 1
plugin 2
>>> plugins
[<__main__.MyPlugin1 object at 0x10048c8d0>, <__main__.MyPlugin2 object at 0x10048c910>]
>>>

因此发现和注册过程流程如下:

  1. 所有的插件类都继承自一个基类,这个基类对于插件管理器来说是已知的。
  2. 插件管理器导入一个或多个包含插件类定义的模块。
  3. 插件管理器调用基类方法 _ _ subclass _ _()并发现所有加载的插件类。
  4. 插件管理器创建实例。

我们现在有几个问题要解决。首先,插件类需要存储在单独的位置,最好存储在单独的文件中。这允许部署新的插件和删除过时的插件,而不用担心应用文件可能会被意外覆盖。因此,我们需要一种机制来导入包含插件类定义的任意 Python 模块。您可以使用 Python 内置方法 import 在运行时按名称加载任何模块,但模块文件需要在系统搜索路径中。

对于示例应用,我们将使用以下目录和文件结构:

http_log_parser.py                      <-- host application 
manager.py                              <-- plug-in manager module
plugins/                                <-- directory containing all plug-in modules
plugin_<name>.py                        <-- module containing one or more plug-in classes
logs/                                   <-- directory containing the log files
<any name>                              <-- any file is assumed to be a log file

这个目录结构被认为是默认的,但是我们允许修改路径,所以我们可以修改它们以更好地满足我们的需求。插件模块遵循这种特殊的命名约定,因此更容易将它们与普通的 Python 脚本区分开来。每个模块必须从 manager.py 模块导入插件类。

让我们从 manager 类初始化方法开始。我们将允许宿主应用向插件对象传递任何可选的初始化参数,这样它们就可以执行它们需要的任何运行时初始化。然而,有一个问题。我们不知道这些参数是什么,或者是否有任何参数。因此,我们将只传递关键字参数,而不是定义精确的参数列表结构。管理器的 init()方法将一个字典作为参数,并将它传递给插件方法初始化函数。

我们还需要发现插件文件的位置。它可以作为参数传递给管理器的构造函数,在这种情况下,它应该是一个绝对路径;否则,我们将假设一个名为/plugins/的子目录相对于脚本的位置:

class PluginManager():
    def __init__(self, path=None, plugin_init_args={}):
        if path:
            self.plugin_dir = path
        else:
            self.plugin_dir = os.path.dirname(__file__) + '/plugins/'
        self.plugins = []
        self._load_plugins()
        self._register_plugins(**plugin_init_args)

下一步是将所有插件文件作为模块加载。每个 Python 应用都可以作为一个模块加载,因此它的所有函数和类都可以用于主应用。我们不能使用传统的 import 语句来导入文件,因为只有在运行时我们才知道它们的名称。所以我们将使用内置方法 import,它允许我们使用包含模块名称的变量。否则,该方法与 import 方法相同,这意味着它试图加载的模块应该位于搜索路径中。显然,事实并非如此。因此,我们需要将包含插件模块的目录添加到系统路径中。我们可以通过将目录附加到 sys.path 数组:来实现这一点

def _load_plugins(self):
    sys.path.append(self.plugin_dir)
    plugin_files = [fn for fn in os.listdir(self.plugin_dir) if  fn.startswith('plugin_') and fn.endswith('.py')]
    plugin_modules = [m.split('.')[0] for m in plugin_files]
    for module in plugin_modules:
        m = __import__(module)

最后,我们使用 _ _ subclass _ _ 方法发现从基类继承的类,并将初始化的对象添加到插件列表中。注意我们是如何将关键字参数传递给插件的:

def _register_plugins(self, **kwargs):
    for plugin in Plugin.__subclasses__():
        obj = plugin(**kwargs)
        self.plugins.append(obj)

我们在这里使用关键字参数列表,因为我们还不知道插件类需要或使用什么参数。此外,模块可以使用或识别不同的自变量。通过使用关键字参数,我们允许模块只响应他们感兴趣的参数。清单 6-1 显示了插件管理器的完整列表。

清单 6-1 。插件发现和注册

#!/usr/bin/env python

import sys
import os

class Plugin(object):
    pass

class PluginManager():
    def __init__(self, path=None, plugin_init_args={}):
        if path:
            self.plugin_dir = path
        else:
            self.plugin_dir = os.path.dirname(__file__) + '/plugins/'
        self.plugins = []
        self._load_plugins()
        self._register_plugins(**plugin_init_args)

    def _load_plugins(self):
        sys.path.append(self.plugin_dir)
        plugin_files = [fn for fn in os.listdir(self.plugin_dir) if \
                                    fn.startswith('plugin_') and fn.endswith('.py')]
        plugin_modules = [m.split('.')[0] for m in plugin_files]
        for module in plugin_modules:
            m = __import__(module)

    def _register_plugins(self, **kwargs):
        for plugin in Plugin.__subclasses__():
            obj = plugin(**kwargs)
            self.plugins.append(obj)

这就是我们初始化所有插件模块所需要做的一切。一旦我们创建了 PluginManager 类的实例,它将自动发现可用的模块,加载它们,初始化所有插件类,并将初始化的对象放入列表:

plugin_manager = PluginManager()

定义插件模块

到目前为止,我们只有两个插件类必须满足的要求:每个类必须从基本插件类继承,每个的 init 方法必须接受关键字参数。该类可以选择完全忽略初始化过程中传递给它的内容,但它仍然必须接受参数;否则,当主应用传递我们不希望收到的参数时,我们会得到无效参数列表异常。

插件模块骨架是这样的(这里假设我们调用了插件管理器脚本 manager.py 否则相应地更新导入语句):

#!/usr/bin/env python

from manager import Plugin

class CountHTTP200(Plugin):
    def __init__(self, **kwargs):
        pass

这个插件显然还做不了多少。我们现在需要定义主应用和插件之间的接口。在我们的日志解析应用示例中,通信将是单向的:应用将消息(日志信息)发送给插件进行进一步处理。此外,应用可以发送其他命令或信号,通知插件对象关于应用的当前状态。所以现在我们需要创建宿主应用。

日志解析应用

正如我们已经讨论过的,主机应用不依赖也不应该依赖附带插件的功能和存在。它提供了一组可以被插件使用的服务。在我们的例子中,主应用负责处理 Apache 访问日志文件。为了理解处理日志信息的最佳方式,让我们首先看看 Apache 记录请求数据的方式。

Apache 日志文件的格式

日志文件的格式由 Apache 配置文件中的 LogFormat 指令定义,通常是/etc/apache2/apache2.conf 或/etc/httpd/conf/httpd.conf,具体取决于 Linux 发行版。这里有一个例子:

LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" combined

该配置行分为三部分。第一部分是指令名。第二部分是定义日志行结构的格式字符串。我们将很快回到格式字符串定义。最后一部分是日志格式的名称。

我们可以定义尽可能多的不同日志行格式,然后根据需要将它们分配给日志文件定义。例如,我们可以向虚拟主机定义部分添加以下指令,该指令指示 Apache web 服务器将日志行以组合日志格式指令所描述的格式写入名为 logs/access.log 的日志文件中:

CustomLog logs/access.log combined

我们可以有多个 CustomLog 指令,每个指令都有不同的文件名和格式指令。

Image 注意关于日志文件的更多信息,请参考 Apache 官方文档。你可以在 http://httpd.apache.org/docs/2.2/logs.html找到。

与 LogFormat 配置语句一起使用的格式字符串包含一个或多个以%字符开头的指令。当日志行被写入日志文件时,这些指令将被相应的值替换。表 6-1 列出了一些最常用的指令。

表 6-1 。常用的日志格式指令

|

管理的

|

描述

|
| --- | --- |
| %a | 远程主机的 IP 地址。 |
| %A | 本地主机的 IP 地址。 |
| %B | 以字节表示的响应大小。不包括 HTTP 头大小。 |
| %b | 与%B 相同,但如果响应为空,则使用-符号代替 0。 |
| %{ cookie_name }C | cookie_name cookie 的值。 |
| %D | 请求处理时间(以微秒计)。 |
| %h | 远程主机。 |
| %H | 请求协议(HTTP 1.0、1.1 等。). |
| %{ header_field }i | HTTP 请求字段的内容。这些是常用的 HTTP 请求头:Referer:如果存在,标识引用 URL
User-Agent:标识用户客户端软件
Via 的字符串:通过其发送请求的代理列表
Accept-Language:客户端接受的语言代码列表
Content-Type:Request MIME Content Type |
| %l | 远程 identd 进程的远程日志名,如果正在运行。这通常是-,除非安装了 mod_ident 模块。一个 |
| %m | 请求方法(POST、GET 等。). |
| %{ header_field }o | 响应中 HTTP 头变量的内容。有关更多详细信息,请参见% I 定义。 |
| %P | 为请求提供服务的 Apache web 服务器子进程的进程 ID。 |
| %q | 查询字符串(仅用于 GET 请求),如果存在的话。该字符串前面带有?性格。 |
| %r | 请求的第一行。这通常包括请求方法、请求 URL 和协议定义。 |
| %s | 响应的状态,如 404 或 200。这就是原来的状态(!)请求。如果配置了任何内部重定向,这将不同于发送回请求者的最终状态。 |
| %>s | 请求的最后状态。换句话说,这就是客户端收到的内容。 |
| %t | 收到请求时的时间戳。这是标准的英文格式,看起来像[20/May/2010:07:26:23 +0100]。我们可以修改格式。详见%{ 格式 }t 指令定义。 |
| %{ 格式 }t | 由格式字符串定义的时间戳。该格式是使用 strftime 指令定义的。 |
| %T | 请求服务时间,以秒计。 |
| %u | 远程用户是否使用身份验证模块进行身份验证。 |
| %U | 请求的 URL 部分。不包括查询字符串。 |

即使远程进程和 Apache 模块都存在,我也不建议依赖这些信息,因为 identd 协议被认为是不安全的

日志文件阅读器

正如我们所看到的,日志格式可以根据 Apache 配置中的日志格式定义而变化。我们需要适应格式上的差异。为了更容易与插件模块通信,我们将把从日志行中提取的值映射到一个可以传递给插件代码的数据结构中。

首先,我们需要将 Apache 日志格式指令映射到更具描述性的字符串,这些字符串可以用作字典键。下面是我们将使用的映射表:

DIRECTIVE_MAP = {
                  '%h':  'remote_host',
                  '%l':  'remote_logname',
                  '%u':  'remote_user',
                  '%t':  'time_stamp',
                  '%r':  'request_line',
                  '%>s': 'status',
                  '%b':  'response_size',
                  '%{Referer}i':    'referer_url',
                  '%{User-Agent}i': 'user_agent',
                }

当我们初始化日志阅读器对象时,我们给它两个可选的参数。第一个参数按照 Apache 配置中的定义设置日志格式行。如果没有提供参数字符串,将采用默认值。另一个参数指示日志文件的位置。一旦我们确定了日志行格式,我们将创建一个在映射表中定义的替代指令名列表。列表中的关键字将与日志格式字符串中出现的指令顺序完全相同。

以下初始化函数执行所描述的所有步骤:

class LogLineGenerator:
    def __init__(self, log_format=None, log_dir='logs'):
        # LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" combined
        if not log_format:
            self.format_string = '%h %l %u %t %r %>s %b %{Referer}i %{User-Agent}i'
        else:
            self.format_string = log_format
        self.log_dir = log_dir
        self.re_tsquote = re.compile(r'(\[|\])')
        self.field_list = []
        for directive in self.format_string.split(' '):
            self.field_list.append(DIRECTIVE_MAP[directive])

日志字符串通常遵循简单的模式,用空格字符分隔字段。如果字段值包含空格字符,它将被引号字符括起来。例如%r 和%t 字段,如以下示例日志行所示:

220.181.7.76 - - [20/May/2010:07:26:23 +0100] "GET / HTTP/1.1" 200 29460 "-"
"Baiduspider+(+http://www.baidu.com/search/spider.htm)"
220.181.7.116 - - [20/May/2010:07:26:43 +0100] "GET / HTTP/1.1" 200 29460 "-"
"Baiduspider+(+http://www.baidu.com/search/spider.htm)"
209.85.228.85 - - [20/May/2010:07:26:49 +0100] "GET /feeds/latest/ HTTP/1.1" 200 45088 "-"\
"FeedBurner/1.0 (http://www.FeedBurner.com)"
209.85.228.84 - - [20/May/2010:07:26:57 +0100] "GET /feeds/latest/ HTTP/1.1" 200 45088 "-"\
"FeedBurner/1.0 (http://www.FeedBurner.com)"

Image 注意记住\符号表示该行内容已经换行。在真实的日志文件中,内容在一行中。

我们将使用内置的 Python 模块来解析逗号分隔值(CSV)格式文件。虽然文件格式意味着值由逗号分隔,但是库足够灵活,允许我们指定任何字符作为分隔符。除了分隔符,我们还可以指定引号字符。在我们的例子中,分隔符是空格字符,引号字符(用于包装请求和用户代理字符串)是双引号字符。

我猜你已经注意到了这里的一个问题。时间字段包含一个空格,但没有用双引号括起来。而是用方括号括起来。不幸的是,CSV 库不允许为多个引号字符指定选择,所以我们需要使用正则表达式将所有出现的方括号替换为双引号。匹配方括号的正则表达式已在类构造函数方法中定义。我们将在后面的代码中使用预编译的正则表达式:

self.re_tsquote = re.compile(r'(\[|\])')

现在让我们编写一个简单的文件阅读器,它可以进行动态字符翻译,用双引号代替方括号。这是一个我们可以迭代的生成器函数。我们将在下一章更详细地讨论生成器函数。

def _quote_translator(self, file_name):
    for line in open(file_name):
        yield self.re_tsquote.sub('"', line)

我们还需要一个函数来列出它在指定的日志目录中找到的所有文件。以下函数列出所有文件,并返回找到的每个文件名以及目录名。这个函数只列出文件对象,忽略任何目录。

def _file_list(self):
    for file in os.listdir(self.log_dir):
        file_name = "%s/%s" % (self.log_dir, file)
        if os.path.isfile(file_name):
            yield file_name

最后,我们需要从我们读入的日志行中提取所有字段,并创建一个 dictionary 对象。字典键是我们前面创建的映射表中的指令名,值是从日志行中提取的字段。这听起来可能是一项复杂的任务,但实际上并不复杂,因为 CSV 库为我们提供了这一功能。初始化的 csv。DictReader 类返回一个迭代器对象,该对象遍历第一个参数对象返回的所有行。在我们的例子中,这个对象是我们之前写的文件读取器方法(_quote_translator)。

DictReader 类的下一个参数是字典键的列表。提取的字段将被映射到这些名称。另外两个参数指定分隔符和引号。

reader = csv.DictReader(self._quote_translator(file), 
                        fieldnames=self.field_list, 
                        delimiter=' ',
                        quotechar='"')

现在我们可以遍历结果对象,这将返回映射值的新字典。清单 6-2 显示了日志阅读器类的完整列表,以及所需的模块。

清单 6-2 。日志文件读取器类

class LogLineGenerator:
    def __init__(self, log_format=None, log_dir='logs'):
        # LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" combined
        if not log_format:
            self.format_string = '%h %l %u %t %r %>s %b %{Referer}i %{User-Agent}i'
        else:
            self.format_string = log_format
        self.log_dir = log_dir
        self.re_tsquote = re.compile(r'(\[|\])')
        self.field_list = []
        for directive in self.format_string.split(' '):
            self.field_list.append(DIRECTIVE_MAP[directive])

    def _quote_translator(self, file_name):
        for line in open(file_name):
            yield self.re_tsquote.sub('"', line)

    def _file_list(self):
        for file in os.listdir(self.log_dir):
            file_name = "%s/%s" % (self.log_dir, file)
            if os.path.isfile(file_name):
                yield file_name

    def get_loglines(self):
        for file in self._file_list():
            reader = csv.DictReader(self._quote_translator(file), 
                                    fieldnames=self.field_list, 
                                    delimiter=' ', quotechar='"')
            for line in reader:
                yield line

我们现在可以创建一个 generator 类的实例,并遍历指定目录中所有文件的所有日志行:

log_generator = LogLineGenerator()
for log_line in log_generator.get_loglines():
    print "-" * 20
    for k, v in log_line.iteritems():
        print "%20s: %s" % (k, v)

这将产生类似如下的结果:

--------------------
            status: 200
       remote_user: -
      request_line: GET /posts/7802/ HTTP/1.1
    remote_logname: -
       referer_url: -
        user_agent: Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)
     response_size: 26507
        time_stamp: 20/May/2010:11:57:55 +0100
       remote_host: 66.249.65.40
--------------------
            status: 200
       remote_user: -
      request_line: GET / HTTP/1.1
    remote_logname: -
       referer_url: -
        user_agent: Sogou web spider/4.0(+http://www.sogou.com/docs/help/webmasters.htm#07)
     response_size: 26130
        time_stamp: 20/May/2010:11:58:47 +0100
       remote_host: 220.181.94.216
--------------------
            status: 200
       remote_user: -
      request_line: GET /posts/7803/ HTTP/1.1
    remote_logname: -
       referer_url: -
        user_agent: Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)
     response_size: 29040
        time_stamp: 20/May/2010:11:59:00 +0100
       remote_host: 66.249.65.40

调用插件方法

我们现在需要定义一种方法来将这些信息传递给插件模块。我们有两个问题需要解决

  • 我们需要知道可以调用插件对象的哪些方法。
  • 我们需要知道什么时候给他们打电话。例如,一些插件可能没有实现这些方法。

我们需要能够识别插件的类型,因为类型定义了插件能够做什么。了解了插件的功能,我们就知道何时调用合适的插件方法。回到 web 浏览器的例子,我们看到一些插件能够处理图像文件;其他人可以处理视频内容。将视频内容发送给图像处理插件是没有意义的,因为他们不知道如何处理它。换句话说,他们没有能力处理这个请求。

我们将从解决第二个问题开始。在日志处理应用中,我们将允许插件向插件管理器公开关键字列表。这些关键字标识了插件希望接收哪种类型的请求。这并不意味着它可以处理这些请求,但至少插件表达了对它们的兴趣。主机应用发出的每个请求也标有关键字列表。如果关键字集重叠,那么请求被转发到插件对象。否则,我们不需要调用插件,因为它显然对接收任何这种类型的请求不感兴趣。

标记插件类

插件类上的标记很简单。我们只需在类定义中添加一个属性,这是一个标签列表。我们可以让这个列表为空,在这种情况下,插件将只接收未标记的调用:

class CountHTTP200(Plugin):
    def __init__(self, **kwargs):
        self.keywords = ['counter']

我们还需要修改 manager 类,以便关键字与每个插件对象一起注册。因此,我们将用 dictionary 对象替换插件注册表列表,其中键是插件对象,值是它们的标记列表。如果插件没有定义关键字列表,我们将假设列表为空:

class PluginManager():
    def __init__(self, path=None, plugin_init_args={}):
        [...]
        self.plugins = {}

    [...]

    def _register_plugins(self, **kwargs):
        for plugin in Plugin.__subclasses__():
            obj = plugin(**kwargs)
            self.plugins[obj] = obj.keywords if hasattr(obj, 'keywords') else []

插件方法和调用机制

我们现在已经标记了所有的插件,,理论上,我们应该知道哪些方法在哪些插件对象上可用。但是,这种方法不太灵活。我们已经添加了标签,因此函数得到了优化,插件不会被不必要的调用。仍然可能存在这样的情况,当插件宣布它对某种类型的调用感兴趣,但是不实现宿主应用与该组关键字相关联的功能。

因为主机应用和插件软件是非常松散耦合的,并且经常由完全不同的组织开发,所以实际上不可能使两者的开发进度同步。例如,假设一个主机应用被设计为调用所有插件上的 function_A()方法,这些插件声明它们对关键字 foobar 感兴趣。然后修改宿主应用,以便它在所有标记有相同关键字的插件上调用两个方法 function_A 和 function_B。然而,一些插件可能没有被维护,或者他们可能对实现新功能不感兴趣——对于他们的目的来说,只实现单个功能就足够了。

这似乎是个问题,但实际上不是。宿主应用将调用该方法,而不检查它是否可用。如果插件实现了那个方法,它就会执行它。如果这个方法没有被实现和定义,那也没关系——我们可以忽略这个异常。这个技术叫做鸭式打字

我们将为 manager 类提供以下新方法,它将负责调用插件方法。主应用将使用它希望插件运行的函数的名称来调用这个方法。或者,它也可以传递参数和关键字列表。如果定义了关键字,则调用将仅被分派给用该列表中的一个或多个关键字标记的插件:

def call_method(self, method, args={}, keywords=[]):
    for plugin in self.plugins:
        if not keywords or (set(keywords) & set(self.plugins[plugin])):
            try:
                getattr(plugin, method)(**args)
            except:
                pass

现在我们可以完成主机应用的编写了。让我们将打印日志行结构的 print 语句替换为对插件管理器调用调度程序方法的实际调用。我们将在主循环中调用 process()方法,并将日志行结构作为参数传入。所有实现该方法的插件都将收到函数调用以及关键字参数。在循环的最后,我们将调用 report()函数。需要报告任何内容的插件现在有机会这样做了。如果插件没有被设计成产生任何报告,它将简单地忽略这个调用。

def main():
    plugin_manager = PluginManager()
    log_generator = LogLineGenerator()
    for log_line in log_generator.get_loglines():
    plugin_manager.call_method('process', args=log_line)
    plugin_manager.call_method('report')

什么是鸭子打字?

术语鸭子打字来自詹姆斯·b·凯里的一句话,“当某人走路像鸭子,游泳像鸭子,嘎嘎叫像鸭子,他就是一只鸭子。”

在面向对象的编程语言中,duck typing 意味着对象的行为由其可用方法和属性的集合决定,而不是由其继承决定。换句话说,只要我们感兴趣的方法和属性存在并且可用,我们就不担心对象类的类型。因此,duck typing 不依赖于对象类型测试。

当你需要某样东西的时候,你只需要简单地提出要求。如果对象不知道你想从它那里得到什么,就会引发一个异常。这意味着这个物体不知道如何“嘎嘎”叫,因此它不是“鸭子”这种“试探一下,看看会发生什么”的方法有时被称为“请求原谅比请求允许容易”(EAFP)的原则。下面的示例代码很好地说明了这一点:

班牛():
...def moo(self):
...打印“哞哞”
...
类鸭子():
...def 庸医(自我):
...打印“嘎嘎!”
...
animal 1 = Cow()
animal 2 = Duck()

【animal 1,animal 2】:
...if hasattr(动物,‘嘎嘎’):
...animal . quak()
...否则:
...印刷动物,‘不能嘎嘎叫’
...
<__ 主 _ _。0x100491a28 >处的奶牛实例不能呱呱
呱呱!

【animal 1,animal2】中的动物:
...试试:
...animal . quak()
...除属性错误:
...印刷动物,‘不能呱呱’
...
<__ 主 _ _。0x100491a28 >处的奶牛实例不能呱呱
呱呱!

在第一次迭代中,我们在调用方法之前明确检查方法的可用性(我们请求许可)。在第二次迭代中,我们调用方法而不检查它是否可用。然后,我们捕捉可能的异常(我们请求原谅),并相应地处理方法的缺失(如果有的话)。

插件模块

我们现在可以开始编写插件模块,并使用脚本来分析 Apache web 服务器日志文件。在这一节中,我们将创建一个脚本,该脚本对所有请求进行计数,并根据它们来自的国家对它们进行排序。我们将使用 GeoIP Python 库来执行 IP 到国家名称的映射。

Image geo IP 数据由 MaxMind 公司制作,该公司提供数据库供个人(免费)和商业(付费)使用。你可以在 http://maxmind.com/app/ip-location 找到更多关于 MaxMind 产品和服务的信息。

GeoIP 数据库试图提供 IP 地址所在位置的地理信息(如国家、城市和坐标)。这对于各种目的都是有用的。例如,它允许企业提供本地化的广告服务,根据用户的位置向他们显示广告。

安装所需的库

GeoIP 数据库库是用 C 编写的,但是也有 Python 绑定可用。大多数 Linux 平台上都有这些包。例如,在 Fedora 系统上,运行以下命令来安装这些库:

$ sudo yum install GeoIP GeoIP-python

这将安装 C 库以及助手工具和 Python 绑定。该包可能包括包含 IP 到国家映射数据的初始数据库,但该数据很可能会过时,因为该数据库通常每三到四周更新一次。有两个数据库可供个人免费使用:国家数据库和城市数据库。如果你想得到最新的信息,我建议定期更新这两个数据库。基础包中提供了可以获取数据库最新版本的工具。下面是安装软件包后获取数据库的方法:

$ sudo touch /usr/share/GeoIP/GeoIP.dat
$ sudo touch /usr/share/GeoIP/GeoLiteCity.dat
$ sudo perl /usr/share/doc/GeoIP-1.4.7/fetch-geoipdata.pl
Fetching GeoIP.dat from http://geolite.maxmind.com/download/geoip/database/GeoLiteCountry/GeoIP.dat.gz
GeoIP database updated. Old copy is at GeoIP.dat.20100521
$ sudo perl /usr/share/doc/GeoIP-1.4.7/fetch-geoipdata-city.pl
Fetching GeoLiteCity.dat from http://geolite.maxmind.com/download/geoip/database/GeoLiteCity.dat.gz
GeoIP database updated. Old copy is at GeoLiteCity.dat.20100521

开始时使用 touch 命令的原因是,如果。dat 文件不存在,工具将无法下载新版本,因此必须先创建这些文件。

使用 GeoIP Python 绑定

安装库时,它们会在标准位置(通常在/usr/share/GeoIP/)查找数据文件,因此我们不需要指定位置。我们只需要指定访问方法:

import GeoIP

# the data is read from the disk every time it’s accessed
# this is the slowest access method
gi = GeoIP.new(GeoIP.GEOIP_STANDARD)
# the data is cached in memory 
gi = GeoIP.new(GeoIP.GEOIP_MEMORY_CACHE)

一旦我们初始化了数据访问对象,我们就可以开始查找信息:

>>> import GeoIP
>>> gi = GeoIP.new(GeoIP.GEOIP_MEMORY_CACHE)
>>> gi.country_name_by_name('www.apress.com')
'United States'
>>> gi.country_code_by_name('www.apress.com')
'US'
>>> gi.country_name_by_addr('4.4.4.4')
'United States'
>>> gi.country_code_by_addr('4.4.4.4')
'US'
>>>

如果我们想检索城市信息,我们需要打开特定的数据文件。然后,我们还可以执行城市数据查找:

>>> import GeoIP
>>> gi = GeoIP.open('/usr/share/GeoIP/GeoLiteCity.dat', GeoIP.GEOIP_MEMORY_CACHE)
>>> gir = gi.record_by_name('www.apress.com')
>>> for k, v in gir.iteritems():
...  print "%20s: %s" % (k, v)
... 
                city: Emeryville
         region_name: California
              region: CA
           area_code: 510
           time_zone: America/Los_Angeles
           longitude: -122.289703369
          metro_code: 807
       country_code3: USA
            latitude: 37.8342018127
         postal_code: 94608
            dma_code: 807
        country_code: US
        country_name: United States
>>>

编写插件代码

我们需要决定要实现哪些方法。我们需要接收关于正在处理的每个日志行的信息。因此,插件必须实现 process()方法,该方法将执行国家查找并增加适当的计数器。在循环的最后,我们需要打印一个简单的报告,列出所有的国家,并根据请求的数量对列表进行排序。

如清单 6-3 所示,我们只使用了数据结构中的一个字段,并忽略了其余的数据。

清单 6-3 。GeoIP 查找插件

#!/usr/bin/env python

from manager import Plugin
from operator import itemgetter
import GeoIP

class GeoIPStats(Plugin):

    def __init__(self, **kwargs):
        self.gi = GeoIP.new(GeoIP.GEOIP_MEMORY_CACHE)
        self.countries = {}

    def process(self, **kwargs):
        if 'remote_host' in kwargs:
            country = self.gi.country_name_by_addr(kwargs['remote_host'])
            if country in self.countries:
                self.countries[country] += 1
            else:
                self.countries[country] = 1

    def report(self, **kwargs):
        print "== Requests by country =="
        for (country, count) in sorted(self.countries.iteritems(), 
                                       key=itemgetter(1), reverse=True):
            print " %10d: %s" % (count, country)

我们将该文件保存为 plugins/目录中的 plugin_geoiplookup.py。(实际上,任何带有 plugin_ 前缀和的名称。py 后缀将被识别为有效的插件模块。)现在,如果我们运行主应用,我们将得到类似于以下示例的结果,前提是我们在 logs/目录中有一个示例日志文件。

$ ./http_log_parser.py 
== Requests by country ==
        382: United States
        258: Sweden
        103: France
         42: China
         31: Russian Federation
          9: India
          8: Italy
          7: United Kingdom
          7: Anonymous Proxy
          6: Philippines
          6: Switzerland
          2: Tunisia
          2: Japan
          1: Croatia

可视化数据

这个简单的报告功能对于数据分析目的来说已经足够了,但是有时您可能希望快速直观地了解结果。在前面示例的基础上,我们将创建一个热图图像,作为报告生成过程的一部分。热图将代表所有国家,颜色的强度将与我们在日志文件中找到的点击次数成比例。

我们将使用 matplotlib 库和 matplotlib 库的底图扩展来绘制世界地图。Matplotlib 自带基本的世界地图形状定义;然而,我们将需要每个国家更详细的形状。这些可以从互联网上的各种资源中免费获得。

您可以在第十一章的中找到更多关于 numpy 和 matplotlib 的信息和详细的安装说明,因此我将在本章中仅讨论 matplotlib 和底图的制图功能。

安装所需的库和数据文件

以下安装说明假设您运行的是 Fedora 系统。您可能需要修改它们以适应您的特定操作系统,但是包名通常是相同的。

我们将使用 numpy 包中的几个助手函数,所以我们需要首先安装它:

$ yum install numpy

绘图功能由 matplotlib 库提供,可通过运行以下命令安装:

$ yum install matplotlib

地图操作功能可从名为底图的 matplotlib 扩展中获得。底图本身不会进行任何绘图;它使用 matplotlib 来绘制实际的图形。底图提供了将坐标转换为一种可用地图投影的功能。它依赖于 geos(几何引擎)库,所以我们也需要安装它:

$ yum install geos
$ yum install basemap

最后,我们将需要解析自定义 ESRI 形状文件,所以我们将使用 shapefile 库来完成这项任务。不幸的是,在撰写本文时,它还不能作为 RPM 包使用,因此我们将使用 pip 命令来安装它:

$ pip install pyshp

ESRI 文件格式是一种流行的地理信息系统(GIS)矢量数据格式,许多地理信息都是以这种格式提供的。我们将从thematicmapping.org/downloads/world_borders.php下载所有国家的外形数据。在我们的插件代码所在的目录下,我们下载下面的文件thematicmapping . org/downloads/TM _ WORLD _ BORDERS-0.3 . zip并解压到一个单独的目录:

$ mkdir world_borders && cd world_borders
$ curl –O http://thematicmapping.org/downloads/TM_WORLD_BORDERS-0.3.zip
$ unzip TM_WORLD_BORDERS-0.3.zip

这将创建多个 ESRI 形状文件:

$ ls -1 world_borders/
Readme.txt
TM_WORLD_BORDERS-0.3.dbf
TM_WORLD_BORDERS-0.3.prj
TM_WORLD_BORDERS-0.3.shp
TM_WORLD_BORDERS-0.3.shx

Image ESRI 代表环境系统研究所。该公司专门生产地理信息系统(GIS)软件和地理数据库管理应用。shapefile 是一种地理空间矢量数据格式,用于在 GIS 软件之间存储和交换数据。存储在 shapefile 中的数据是一组几何数据图元,如点、线和多边形,以及描述这些图元所代表的内容的相关属性。例如,我们将要使用的 shapefile 包含表示世界各国(相关属性)的多边形(几何数据基元)。换句话说,每个多边形都有一个相关的名称。术语 shapefile 可能意味着它是单个文件,但实际上 shapefile 是多个文件的集合。SHP 文件包含几何形状数据,SHX 是几何数据索引文件,用于定位相关数据,DBF 是属性数据库,PRJ 定义坐标系。

使用 Shapefile

PyShp 库使得从 shapefile 中读取和提取地理空间信息变得容易。你可以在这里找到完整的 PyShp 文档:code.google.com/p/pyshp/wiki/PyShpDocs

首先,我们需要创建并初始化 reader 对象,因为所有的数据访问都将使用这个对象来完成。当我们初始化一个新的 reader 对象时,我们需要告诉它在哪里可以找到包含 shape 对象的文件。它会自动打开属性和其他文件:

>>> import shapefile
>>> r = shapefile.Reader('world_borders/TM_WORLD_BORDERS-0.3.shp')

一旦创建了文件读取器对象,我们就可以开始处理 shapefile 中包含的数据了。让我们先读取所有存储的形状。形状由一个或多个点组成,它们代表地图上的一个物理位置。

>>> countries = r.shapes()
>>> len(countries)
246

如您所见,我们的 shapefile 中存储了 246 个形状,每个形状代表世界上的一个国家。每个形状都是一组点,包含一个或多个部分。例如,如果一个大陆国家有一个属于它的岛屿,代表这样一个国家的形状将包含两个部分:一个用于定义大陆国家的边界,另一个用于岛屿。

让我们仔细看看列表中的一个国家。我们将选择第一个:

>>> country = countries[0]

这个国家的边界由 48 个点组成:

>>> len(country.points)
48

它也不是连续的边界;相反,它有两个不同的部分:

>>> len(country.parts)
2
>>> country.parts
[0, 23]

零件列表中的编号是引用零件的第一个点的索引。因此,在我们的示例中,第一部分从索引 0 处的点开始,第二部分从索引 23 处的点开始。因此,第一部分有 23 个点(点 0 到 22),第二部分有 25 个点(点 23 到 48)。

每个点只是世界地图上的一个坐标:

>>> country.points[0]
[-61.686668, 17.024441000000138]

现在,我们知道如何阅读几何数据,但如果我们不知道这些数据代表什么,它们就毫无意义。正如我们已经知道的,每个 shapefile 还包含每个形状的属性。这些属性可以通过调用 reader 对象的 records()方法来读取,就像我们读取形状信息一样:

>>> records = r.records()
>>> len(records)
246

你可以看到我们有匹配数量的属性——每个形状一个属性。让我们看看列表中第一个国家的属性是什么:

>>> country_rec = records[0]
>>> country_rec
['AC', 'AG', 'ATG', 28, 'Antigua and Barbuda', 44, 83039, 19, 29, -61.783, 17.078]

这解释了为什么我们看到国家形状的两个部分:安提瓜和巴布达是一个位于加勒比海东部边缘的双岛国。

虽然我们可能能够猜出一些字段是什么(如国家名称和代码),但其他字段不是不言自明的。要了解每个字段的含义,我们需要检查 reader 对象的 fields 属性:

>>> r.fields
[('DeletionFlag', 'C', 1, 0), 
 ['FIPS', 'C', 2, 0], 
 ['ISO2', 'C', 2, 0], 
 ['ISO3', 'C', 3, 0], 
 ['UN', 'N', 3, 0], 
 ['NAME', 'C', 50, 0], 
 ['AREA', 'N', 7, 0], 
 ['POP2005', 'N', 10, 0], 
 ['REGION', 'N', 3, 0], 
 ['SUBREGION', 'N', 3, 0], 
 ['LON', 'N', 8, 3], 
 ['LAT', 'N', 7, 3]]

列表中的每个字段都是另一个列表,包含表 6-2 中所示的信息:

表 6-2 。字段描述列表中的属性

|

索引

|

描述

Zero 字段名称,描述此列索引中的数据。
one 此列索引中包含的字段类型。可能的类型有:[C]character、[N]number、[L]long、[D]date 和[M]emo。
Two 字段长度定义在该列索引中找到的数据的长度。
three 小数长度描述了“数字”字段中的小数位数。

在世界地图上显示请求数据

我们现在准备好生成一个包含这些国家的世界地图。生成任意数量请求的每个国家都将被着色,颜色强度与生成的请求数量成比例。

我们将以下代码添加到 plugin_geoip_stats.py 插件中。清单 6-4 中的注释解释了代码的每一部分是做什么的:

清单 6-4 。向 GeoIP 查找插件添加地图生成器

#!/usr/bin/env python

[...]

import shapefile
import numpy as np
import matplotlib.pyplot as plt
import matplotlib as mpl
from mpl_toolkits.basemap import Basemap
from matplotlib.collections import LineCollection
from matplotlib import cm

[...]

def report(self, **kwargs):
    print "== Requests by country =="
    for (country, count) in sorted(self.countries.iteritems(), key=itemgetter(1), reverse=True):
        print " %10d: %s" % (count, country)
    generate_map(self.countries)

def generate_map(countries):

    # Initialize plotting area, set the boundaries and add a sub-plot on which
    # we are going to plot the map
    fig = plt.figure(figsize=(11.7, 8.3))
    plt.subplots_adjust(left=0.1,right=0.9,top=0.9,bottom=0.1,wspace=0.15,hspace=0.05)
    ax = plt.subplot(111)

    # Initialize the basemap, set the resolution, projection type and the viewport
    bm = Basemap(resolution='i', projection='robin', lon_0=0)

    # Tell basemap how to draw the countries (built-in shapes), draw parallels and meridians
    # and color in the water
    bm.drawcountries(linewidth=0.5)
    bm.drawparallels(np.arange(-90., 120., 30.))
    bm.drawmeridians(np.arange(0., 360., 60.))
    bm.drawmapboundary(fill_color='aqua')

    # Open the countries shapefile and read the shape and attribute information
    r = shapefile.Reader('world_borders/TM_WORLD_BORDERS-0.3.shp')
    shapes = r.shapes()
    records = r.records()

    # Iterate through all records (attributes) and shapes (countries)
    for record, shape in zip(records, shapes):

        # Extract longitude and latitude values into two separate arrays then
        # project the coordinates onto the map projection and transpose the array, so that
        # the data variable contains (lon, lat) pairs in the list.
        # Basically, the following two lines convert the initial data
        #  [ [lon_original_1, lat_original_1], [lon_original_2, lat_original_2], ... ]
        # into projected data
        #  [ [lon_projected_1, lat_projected_1, [lon_projected_2, lat_projected_2], ... ]
        #
        # Note: Calling baseshape object with the coordinates as an argument returns the
        #       projection of those coordinates
        lon_array, lat_array = zip(*shape.points)
        data = np.array(bm(lon_array, lat_array)).T

        # Next we will create groups of points by splitting the shape.points according to
        # the indices provided in shape.parts

        if len(shape.parts) == 1:
            # If the shape has only one part, then we have only one group. Easy.
            groups = [data,]
        else:
            # If we have more than one part ...
            groups = []
            for i in range(1, len(shape.parts)):
                # We iterate through all parts, and find their start and end positions
                index_start = shape.parts[i-1]
                index_end = shape.parts[i]
                # Then we copy all point between two indices into their own group and append
                # that group to the list
                groups.append(data[index_start:index_end])
            # Last group starts at the last index and finishes at the end of the points list
            groups.append(data[index_end:])

        # Create a collection of lines provided the group of points. Each group represents a line.
        lines = LineCollection(groups, antialiaseds=(1,))
        # We then select a color from a color map (in this instance all Reds)
        # The intensity of the selected color is proportional to the number of requests.
        # Color map accepts values from 0 to 1, therefore we need to normalize our request count
        # figures, so that the max number of requests is 1, and the rest is proportionally spread
        # in the range from 0 to 1.
        max_value = float(max(countries.values()))
        country_name = record[4]

        requests = countries.get(country_name, 0)
        requests_norm = requests / max_value

        lines.set_facecolors(cm.Reds(requests_norm))

        # Finally we set the border color to be black and add the shape to the sub-plot
        lines.set_edgecolors('k')
        lines.set_linewidth(0.1)
        ax.add_collection(lines)

    # Once we are ready, we save the resulting picture
    plt.savefig('requests_per_country.png', dpi=300)

当调用插件报告方法时,它将以文本形式显示结果,并生成类似于图 6-2 所示的地图。

9781484202180_Fig06-02.jpg

图 6-2 。Geo-IP 查找插件生成的地图示例

摘要

在这一章中,我们用 Python 编写了一个简单但可扩展的强大插件框架。我们还实现了一个简单的 Apache web 服务器日志解析器,并编写了一个插件来计算接收到的请求数量,然后根据它们来自的国家对它们进行分组。

需要记住的要点:

  • 插件允许主应用与其扩展——插件模块——分离。
  • 插件架构通常由三个组件组成:宿主应用、插件框架和插件模块。
  • 插件框架负责查找和注册插件模块。
  • 任何 Python 类都可以找到从它继承的其他类,这种机制可以用来查找和分组这些类。类的这个属性可用于查找所有插件类。
  • 您可以使用 MaxMind GeoIP 数据库来查找 IP 地址的物理位置。
  • Matplotlib 与 PyShp (shapefile)和底图结合使用,可用于在地图上绘制数据。

七、对应用日志文件执行复杂的搜索和报告

系统管理职责通常包括安装和支持各种应用。这些可能是由开源社区产生的,也可能是内部开发的。在开发这些应用时,也使用了各种各样的语言;如今常见的语言是 Java、PHP、Python、Ruby 和 Perl(是的,有些人仍在使用)。在这一章中,我将讨论用 Java 开发的应用,因为这似乎是大型企业为其 web 应用选择的最常见的语言。Java 应用通常运行在应用服务器容器中,比如 Tomcat、Jetty、Websphere 或 JBoss。

作为系统管理员,您需要知道应用是否正常运行。每个组织良好和结构化的应用都应该将其状态写入一个或多个日志文件;在 Java 世界中,这通常是通过 log4j 适配器完成的。通过观察日志文件,系统管理员可以检测应用中的任何错误和失败,这些错误和失败通常被记录为异常堆栈跟踪。记录完整的异常堆栈跟踪通常表示不可恢复的错误,即应用无法自行处理的错误。如果您没有很多请求,并且应用只是在做一些事情,那么可以手工捕捉这些异常并分析它们。但是,如果您需要管理数百台服务器,并且产生了数十 GB 的信息,那么您肯定需要一些自动化工具来为您收集和分析数据。在这一章中,我将解释我是如何开发名为 Exctractor 的开源工具的(不,这不是一个错别字——这个名字是由两个词组成的,异常提取器)以及它是如何工作的。

定义问题

在继续之前,让我们回顾一下这个应用将试图解决的问题。每个程序都将其运行状态写入日志文件。具体记录什么取决于创建应用的开发人员。没有关于记录什么的强制标准,甚至记录的格式也有些不明确。虽然这不是必需的,但是大多数日志条目都有时间戳,并包括指示消息重要性的严重性级别,以及状态消息的实际文本。这不是强制性的,您可能会发现您正在处理的日志文件有更多的属性,甚至可能更少。例如,我遇到的一些应用甚至懒得记录时间戳。

通常,开发良好的 Java 应用在记录状态消息时或多或少遵循相同的标准。通常,消息是由应用编写的状态报告,指示应用当前正在做什么。在应用运行到未定义状态的情况下,它会生成一个异常,该异常通常会记录完整的执行状态信息:调用堆栈。

我已经创建了一个简单的 web 应用,我将在本章中使用它来说明异常引发的各个方面,并分析不同类型的异常。清单 7-1 是这个应用的源代码。您可以用 javac 工具编译它,并从 Tomcat 应用容器中运行它。请注意,此应用仅用作示例,因为它可能允许任何用户访问您系统上的任何文件;唯一的限制是您的文件系统的访问权限机制。

清单 7-1 。说明应用行为的 Java 程序

import java.io.*;
import java.util.*;
import java.text.*;
import javax.servlet.*;
import javax.servlet.http.*;

public class FileServer extends HttpServlet {

    public void doGet(HttpServletRequest request, HttpServletResponse response)
    throws IOException, ServletException
    {
        response.setContentType("text/html");
        PrintWriter out = response.getWriter();

        String fileName = request.getParameter("fn");
        if (fileName != null) {
            out.println(readFile(fileName));
        } else {
            out.println("No file specified");
        }

    }

    private String readFile(String file) throws IOException {
        StringBuilder stringBuilder = new StringBuilder();
        Scanner scanner = new Scanner(new BufferedReader(new FileReader(file)));

        try {
            while(scanner.hasNextLine()) {
                stringBuilder.append(scanner.nextLine() + "\n");
            }
        } finally {
            scanner.close();
        }
        return stringBuilder.toString();
    }
}

清单 7-2 是一个 Java 堆栈跟踪的例子,它是由运行在 Tomcat 应用容器中的 web 应用生成的。

清单 7-2 。一个 Java 堆栈跟踪的例子

Jan 18, 2010 8:08:49 AM org.apache.catalina.core.StandardWrapperValve invoke
SEVERE: Servlet.service() for servlet FileServer threw exception
java.io.FileNotFoundException: /etc/this_does_not_exist_1061 (No such file or directory)
    at java.io.FileInputStream.open(Native Method)
    at java.io.FileInputStream.<init>(FileInputStream.java:137)
    at java.io.FileInputStream.<init>(FileInputStream.java:96)
    at java.io.FileReader.<init>(FileReader.java:58)
    at FileServer.readFile(FileServer.java:30)
    at FileServer.doGet(FileServer.java:21)
    at javax.servlet.http.HttpServlet.service(HttpServlet.java:690)
    at javax.servlet.http.HttpServlet.service(HttpServlet.java:803)
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter
    (ApplicationFilterChain.java:269)
    at org.apache.catalina.core.ApplicationFilterChain.doFilter
    (ApplicationFilterChain.java:188)
    at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:210)
    at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:172)
    at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:127)
    at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:117)
    at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:108)
    at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:151)
    at org.apache.coyote.http11.Http11Processor.process(Http11Processor.java:870)
    at org.apache.coyote.http11.Http11BaseProtocol$Http11ConnectionHandler.
    processConnection(Http11BaseProtocol.java:665)
    at org.apache.tomcat.util.net.PoolTcpEndpoint.processSocket(PoolTcpEndpoint.java:528)
    at org.apache.tomcat.util.net.LeaderFollowerWorkerThread.runIt
    (LeaderFollowerWorkerThread.java:81)
    at org.apache.tomcat.util.threads.ThreadPool$ControlRunnable.run(ThreadPool.java:685)
    at java.lang.Thread.run(Thread.java:636)

如果您仔细观察这个异常,您可能会注意到应用代码试图打开一个文件,但该文件并不存在。显然,一个编写良好的应用应该比抛出异常更优雅地处理像丢失文件这样的简单情况,但有时在应用逻辑中构建对所有可能场景的检查是不可行的。对于更复杂的应用,这可能根本不可能。

为什么我们使用异常

诸如事件和信号之类的语言结构是正常程序流的一部分。相比之下,异常表示在执行程序时出现了错误,比如用错误的参数调用了一个函数,结果无法计算。例如,假设我们有一个将两个数相除并接受它们作为参数的函数。自然地,被零除是不可能的,并且如果这样的函数接收到被零除的指令,它将不知道该做什么。于是一个看似简单的函数变得相当复杂;它必须检查它是否能够将给出的两个数相除,并返回两个值而不是一个值:一个值表示操作是否成功完成,另一个值保存实际结果。或者,如果操作成功,它可以返回一个数字,否则返回一个空对象。无论是哪种情况,调用这个函数的代码现在都必须能够处理数字和空对象,从而将简单的算术结构变成更复杂的“如果”...else”逻辑流程。

这就是例外出现的地方。无法完成正常操作的函数只会引发一个异常,而不是返回一个指示错误的特殊代码。当出现异常时,程序停止执行,Java 环境继续执行异常处理过程。这种异常可以被应用“捕获”。回到除法的例子,整个计算代码可以包装在 Java 的“try”中...catch”结构。然后,不管代码在哪个点失败,也不管具体的函数(比如除法),代码都会捕捉任何算术异常,并知道计算不可能完成。

例外总是不好的征兆吗?

简短的回答是“没有”,稍微长一点的回答是“看情况”引发异常的原因是发生了意想不到的事情。假设我们有一个 web 应用从我们的服务器提供文件。所有文件都是从外部页面链接的,通常的假设是,创建列表的人只会列出确实存在的文件。但是,作为人,我们都会犯错误,清单的操作者可能会打错字,所以结果链接会指向一个不存在的文件。现在,如果用户点击链接,应用会尝试完全按照要求去做——检索文件。但是文件不存在,所以负责读取文件的代码会失败并抛出一个异常,指出文件不存在。

应用是否应该检查丢失的文件并做出适当的反应?在这个例子中,大概是的;但是在更复杂的情况下,并不总是能够预测每一种可能的结果并编写代码。即使是像我的文件检索服务这样简单的应用,也不可能总是考虑到所有可能出错的地方。

例如,让我们以 Tomcat 用户的身份运行应用,并假设写入文件系统的所有文件都设置了权限,Tomcat 用户可以读取这些文件。这种情况已经持续了很长时间,应用运行得非常完美。一天,一个新的系统管理员加入了团队,在不知情的情况下,部署了一个具有不同用户权限的文件。突然有一个文件访问错误。文件没有丢失,但是使用 Tomcat 用户权限运行的进程无法读取它。开发人员没有想到这种情况,所以没有代码来处理它。这就是异常处理真正有用的地方;应用可能会遇到不同于正常程序流的情况,并且无法处理它,因此代码会引发一个异常,系统管理员或开发人员可以检查出问题的原因。

为什么我们应该分析异常

现在我们知道日志中的异常并不总是一个不好的迹象,这是否意味着我们应该让它们不被处理?我的一般观点是日志文件应该包含尽可能少的异常。偶然的异常意味着发生了异常的事情,我们应该进行调查;但是,如果在一段时间内有类似的例外,这意味着该事件不再是例外,而是司空见惯的事情。因此,需要改变应用,使处理此类事件成为正常程序流程的一部分,而不是一个例外事件。

回到我的文件阅读器示例,我们看到开发人员最初认为可能有一个他需要检查的错误,这是一个丢失的参数,所以他将检查构建到应用逻辑中:

if (fileName != null) {
    out.println(readFile(fileName));
} else {
    out.println("No file specified");
}

这是一个好策略,因为有时可能会发生外部引用没有指定任何文件名的情况,但是应用很乐意处理这种情况。

现在,让我们假设这已经运行了很长时间,没有人报告任何问题。但是有一天,您决定查看应用日志文件,并注意到一些以前从未记录过的异常堆栈跟踪:

Jan 18, 2010 8:08:35 AM org.apache.catalina.core.StandardWrapperValve invoke
SEVERE: Servlet.service() for servlet FileServer threw exception
java.io.FileNotFoundException: /etc/this_does_not_exist_2 (No such file or directory)
    at java.io.FileInputStream.open(Native Method)
...

这表示用户试图访问一个不存在的文件。您知道到您的 web 服务的唯一链接来自另一个页面,所以您去修复它。但是如何防止这种情况再次发生呢?您的应用没有问题,但是您可能希望检查并改进向外部页面添加新链接的过程,以便它只指向确实存在的文件。是否构建一个用于处理不存在的文件的案例完全取决于您,因为在应用逻辑中,对于什么时候应该处理什么内容并没有严格的规则。我的观点是,如果异常不太可能发生,最好尽可能保持应用逻辑简单。

现在,过了一段时间,您会遇到另一个异常:

Jan 18, 2010 8:07:59 AM org.apache.catalina.core.StandardWrapperValve invoke
SEVERE: Servlet.service() for servlet FileServer threw exception
java.io.FileNotFoundException: /etc/shadow (Permission denied)
    at java.io.FileInputStream.open(Native Method)
...

这一次,它表明文件存在,但权限错误。同样,是时候调查为什么会发生这种情况并解决问题的根本原因了——这并不总是应用的问题,但很可能是应用外部的问题。在这种情况下,新的系统管理员更改了文件权限,这破坏了应用。

从这个简单的真实场景中可以看出,应用日志文件中的异常并不一定意味着生成它们的应用有问题。要找到异常日志直接或间接指出的问题的根本原因,作为系统管理员,您需要尽可能多地了解各种指标。异常堆栈跟踪非常有用,但是您也想知道异常何时开始出现在日志文件中。问题的严重程度如何?你有多少例外?如果您正在接收大量的消息,这可能并不是一种例外情况,需要修改应用以将其作为应用逻辑的一部分来处理。

解析复杂的日志文件

解析日志文件(或任何其他非结构化数据集)是一项相当具有挑战性的任务。与 XML 或 JSON 等结构化数据文件不同,普通日志文本文件不遵循任何严格的规则,可能会在没有任何警告的情况下更改。完全由开发应用的人来决定记录什么以及以什么格式记录。在软件的不同版本之间,日志条目的格式甚至可能会发生变化。作为系统管理员,您可能需要协商某种批准程序,这样,如果您自动解析日志,您就不会在文件格式改变时感到惊讶。最好也让开发人员参与进来,这样他们就可以使用和你一样的工具。如果他们使用相同的工具,他们就不太可能弄坏它们。

为了举例说明,我使用 Tomcat 应用服务器生成的 catalina.out 文件。正如您所看到的,应用本身根本不写任何日志消息,所以您将在那里找到的唯一日志条目来自 JVM 和 Tomcat。显然,如果您使用不同的应用容器,比如 Jetty 或 Jboss,您的日志条目可能会看起来不同。即使您使用的是 Tomcat,您也可以覆盖默认行为和消息格式化的方式,所以请查看您正在处理的日志文件,并相应地调整本章中的示例,以便它们与您的环境相匹配。

我们可以在典型的日志文件中找到什么?

在继续编写分析器代码或为其更改任何配置之前,请查看并确定日志文件中的消息类型,并确定如何明确地识别它们。寻找使它们可区分的共同属性。通常,您会看到由应用本身或应用容器生成的标准消息。

这些消息旨在通知您应用的状态。因为这些消息是由应用生成的,它们很可能指示预期的行为,并且它们通知您的每个状态都是正常应用流的一部分。因为我要调查异常,所以我对那种类型的消息不太感兴趣。清单 7-3 是 Tomcat 日志文件的一个片段,显示了“正常”日志消息的样子。

清单 7-3 。catalina.out 中的标准日志消息

Jan 17, 2010 8:18:24 AM org.apache.catalina.core.AprLifecycleListener lifecycleEvent
INFO: The Apache Tomcat Native library which allows optimal performance in production
 environments was not found on the java.l
ibrary.path: /usr/lib/jvm/java-1.6.0-openjdk-1.6.0.0/jre/lib/i386/client:/usr/lib/jvm/
java-1.6.0-openjdk-1.6.0.0/jre/lib/i386:
/usr/lib/jvm/java-1.6.0-openjdk-1.6.0.0/jre/../lib/i386:/usr/java/packages/lib/i386:/lib:/
usr/libJan 17, 2010 8:18:24 AM org.apache.coyote.http11.Http11BaseProtocol 
initINFO:Initializing Coyote HTTP/1.1 on http-8081Jan 17, 2010 8:18:24 AM
org.apache.catalina.startup.Catalina load
INFO: Initialization processed in 673 ms
Jan 17, 2010 8:18:24 AM org.apache.catalina.core.StandardService start
INFO: Starting service Catalina
Jan 17, 2010 8:18:24 AM org.apache.catalina.core.StandardEngine start
INFO: Starting Servlet Engine: Apache Tomcat/5.5.23
Jan 17, 2010 8:18:24 AM org.apache.catalina.core.StandardHost start
INFO: XML validation disabled
Jan 17, 2010 8:18:25 AM org.apache.catalina.core.ApplicationContext log
INFO: ContextListener: contextInitialized()
Jan 17, 2010 8:18:25 AM org.apache.catalina.core.ApplicationContext log
INFO: SessionListener: contextInitialized()
Jan 17, 2010 8:18:25 AM org.apache.catalina.core.ApplicationContext log
INFO: ContextListener: contextInitialized()
Jan 17, 2010 8:18:25 AM org.apache.catalina.core.ApplicationContext log
INFO: SessionListener: contextInitialized()
Jan 17, 2010 8:18:25 AM org.apache.catalina.core.ApplicationContext log
INFO: org.apache.webapp.balancer.BalancerFilter: init(): ruleChain:
 [org.apache.webapp.balancer.RuleChain: [org.apache.webapp.
balancer.rules.URLStringMatchRule: Target string: News / Redirect URL:
 http://www.cnn.com], [org.apache.webapp.balancer.rules.
RequestParameterRule: Target param name: paramName / Target param value:
 paramValue / Redirect URL: http://www.yahoo.com], [or
g.apache.webapp.balancer.rules.AcceptEverythingRule: Redirect URL:
 http://jakarta.apache.org]]

您可以看到所有日志条目都以时间戳开始。这是我将用来检测日志条目的属性之一。另请注意,日志条目可能会跨越多行。因此,每个长条目都以一行开始,该行以时间戳开始,并在检测到另一个带有时间戳的行时结束。请记下这一点,因为这将成为您的应用的设计决策之一。

异常堆栈跟踪日志的结构

清单 7-4 是一个由 JVM 生成的堆栈跟踪的例子。这个堆栈跟踪来自 Tomcat 应用,该应用由于 web.xml 格式错误而无法加载我的 web 应用。因此,它们是正常操作的例外。

清单 7-4 。异常堆栈跟踪的示例

Jan 17, 2010 10:07:04 AM org.apache.catalina.startup.ContextConfig applicationWebConfig
SEVERE: Parse error in application web.xml file at jndi:/localhost/test/WEB-INF/web.xml
org.xml.sax.SAXParseException: The element type "servlet-class" must be terminated
 by the matching end-tag "</servlet-class>".
    at org.apache.xerces.parsers.AbstractSAXParser.parse(Unknown Source)
    at org.apache.xerces.jaxp.SAXParserImpl$JAXPSAXParser.parse(Unknown Source)
    at org.apache.tomcat.util.digester.Digester.parse(Digester.java:1562)
    at org.apache.catalina.startup.ContextConfig.applicationWebConfig
    (ContextConfig.java:348)
    at org.apache.catalina.startup.ContextConfig.start(ContextConfig.java:1043)
    at org.apache.catalina.startup.ContextConfig.lifecycleEvent(ContextConfig.java:261)
    at org.apache.catalina.util.LifecycleSupport.fireLifecycleEvent
    (LifecycleSupport.java:120)
    at org.apache.catalina.core.StandardContext.start(StandardContext.java:4144)
    at org.apache.catalina.startup.HostConfig.checkResources(HostConfig.java:1105)
    at org.apache.catalina.startup.HostConfig.check(HostConfig.java:1203)
    at org.apache.catalina.startup.HostConfig.lifecycleEvent(HostConfig.java:293)
    at org.apache.catalina.util.LifecycleSupport.fireLifecycleEvent
    (LifecycleSupport.java:120)
    at org.apache.catalina.core.ContainerBase.backgroundProcess(ContainerBase.java:1306)
    at org.apache.catalina.core.ContainerBase$ContainerBackgroundProcessor.
    processChildren(ContainerBase.java:1570)
    at org.apache.catalina.core.ContainerBase$ContainerBackgroundProcessor.
    processChildren(ContainerBase.java:1579)
    at org.apache.catalina.core.ContainerBase$ContainerBackgroundProcessor.run
    (ContainerBase.java:1559)
    at java.lang.Thread.run(Thread.java:636)

与“普通”日志条目一样,这从显示条目创建时间的时间戳开始。也跨越了几行;事实上,大多数堆栈跟踪都相当长,可能包含一百多行,这取决于应用的结构。堆栈跟踪实际上是一个调用堆栈,它打印出整个函数层次结构,一直到遇到异常情况的函数。

Java 异常堆栈跟踪日志的结构无论如何都不是正式的;我只是为了方便起见才把它分开,因为这将有助于我稍后在解析器代码中组织这些日志条目。你应该可以毫不费力地应用同样的结构。

日志条目的第一行我称之为“logline”这一行包含日志条目创建时的时间戳,以及发生异常的模块名称和函数:

Jan 17, 2010 10:07:04 AM org.apache.catalina.startup.ContextConfig applicationWebConfig

下面这条线我称之为“头线”这一行实际上不是实际堆栈跟踪的一部分,但它是由“捕获”异常的应用代码打印出来的:

SEVERE: Parse error in application web.xml file at jndi:/localhost/test/WEB-INF/web.xml

最后,第三部分包含异常的“主体”。这包括以下所有行,是日志条目的最后一部分。通常最后一行是 Java 线程运行方法。

org.xml.sax.SAXParseException: The element type "servlet-class" must be terminated by the matching end-tag "</servlet-class>".
    at org.apache.xerces.parsers.AbstractSAXParser.parse(Unknown Source)
    at org.apache.xerces.jaxp.SAXParserImpl$JAXPSAXParser.parse(Unknown Source)
    at org.apache.tomcat.util.digester.Digester.parse(Digester.java:1562)
...
at java.lang.Thread.run(Thread.java:636)

我已经定义了异常日志条目的结构,但是我如何知道这是一个异常而不是一个正常的日志条目呢?到目前为止,它们看起来都是一样的:它们都有时间戳,并且都跨越了一行或多行。对于一个人来说,这是一个相当明显的区别,您会立即发现异常,但是在异常堆栈跟踪中是否有任何其他的指纹可以用来识别它是一个真正的异常,而不是冗长的日志条目?

如果您查看并比较不同的异常堆栈跟踪,您会注意到一个共性:每个异常堆栈跟踪都提到异常类名。一些例子包括 org.xml.sax.SAXParseException 和 java.io.FileNotFoundException。同样,类名可以是任何东西,但是将单词 Exception 附加到类名是一种公认的做法。所以我要用这个作为我的分类器之一。另一个分类器是单词 java 。因为我正在处理 Java 程序,所以在大多数情况下,我会有一个或多个来自原生 Java 库的方法。所以我将假设如果我的异常候选包含这两个单词,它很可能是一个实际的异常。但是我不想限制自己,所以我必须确保我的应用结构允许我更改或插入另一个验证方法。

现在我有东西可以操作了:我知道我的日志条目应该是什么样子。我还知道这个异常是什么样子的,以及它与普通日志条目的不同之处。这应该足以实现日志解析器。

处理多个文件

在开始实际解析之前,我需要先读入数据。这听起来可能微不足道,但是如果你想有效地做到这一点,有一些技巧你可能想知道。

首先,您需要决定从哪里获取数据。虽然这似乎是显而易见的,但是请记住日志文件有不同的形状和大小。我希望这个工具足够灵活,这样它就可以应用于不同的情况。为了使事情变得简单,并在实现阶段消除猜测,我将首先列出我将要做出的一些假设和我将要依赖的一些需求:

  • 日志文件可以是纯文本,也可以用 bzip2 压缩。
  • 日志文件具有扩展名。对于纯文本文件为 log,对于 bzip2 文件为. log.bz2。
  • 我需要能够根据日志文件的名称来处理它们的子集。例如,我需要能够使用文件模式 web 服务器;将处理所有与此匹配的文件,但不处理其他文件。
  • 所有文件处理的结果应合并到一个报告中。
  • 该工具应该对在指定目录或不同目录列表中找到的所有文件进行操作。还应该包括所有子目录中的日志文件。

处理多个文件

给定刚才陈述的需求,我定义两个变量来表示文件搜索调用的模式:

LOG_PATTERN = ".log"
BZLOG_PATTERN = ".log.bz2"

文件名模式存储在全局变量 OPTIONS.file_pattern 中。默认情况下,这被设置为一个空字符串,因此它将匹配所有文件名。这个变量是由命令行解析类控制的,我将在这一章的后面讲到。目前,只需注意它可以通过使用-p 或- pattern 选项设置为任何值。

我需要递归地创建一个目录和所有子目录的列表,这样我就可以在其中搜索日志文件。用户将为我提供一个顶级目录列表,我需要将它展开成一个包含所有子目录和子目录的完整树。

参数列表将由 OptionParser 类存储在 ARGS 变量中。Python 的 OS 库中有一个非常方便的函数,叫做 walk。它递归地在每个目录和所有子目录中构建一个文件列表。

让我们建立一个简单的目录结构,看看 os.walk 函数是如何工作的:

$ mkdir -p top_dir_{1,2}/sub_dir_{1,2}/sub_sub_dir

这将产生一个三级目录结构:

$ ls -1R
top_dir_1
top_dir_2

./top_dir_1:
sub_dir_1
sub_dir_2

./top_dir_1/sub_dir_1:
sub_sub_dir

./top_dir_1/sub_dir_1/sub_sub_dir:

./top_dir_1/sub_dir_2:
sub_sub_dir

./top_dir_1/sub_dir_2/sub_sub_dir:

./top_dir_2:
sub_dir_1
sub_dir_2

./top_dir_2/sub_dir_1:
sub_sub_dir

./top_dir_2/sub_dir_1/sub_sub_dir:

./top_dir_2/sub_dir_2:
sub_sub_dir

./top_dir_2/sub_dir_2/sub_sub_dir:

现在我们可以使用 os.walk 生成相同的输出,如清单 7-5 所示。

清单 7-5 。用 os.walk 递归检索目录列表

$ python
Python 2.6.1 (r261:67515, Jul  7 2009, 23:51:51)
[GCC 4.2.1 (Apple Inc. build 5646)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import os
>>> for d in os.walk('.'):
...  print d
...
('.', ['top_dir_1', 'top_dir_2'], [])
('./top_dir_1', ['sub_dir_1', 'sub_dir_2'], [])
('./top_dir_1/sub_dir_1', ['sub_sub_dir'], [])
('./top_dir_1/sub_dir_1/sub_sub_dir', [], [])
('./top_dir_1/sub_dir_2', ['sub_sub_dir'], [])
('./top_dir_1/sub_dir_2/sub_sub_dir', [], [])
('./top_dir_2', ['sub_dir_1', 'sub_dir_2'], [])
('./top_dir_2/sub_dir_1', ['sub_sub_dir'], [])
('./top_dir_2/sub_dir_1/sub_sub_dir', [], [])
('./top_dir_2/sub_dir_2', ['sub_sub_dir'], [])
('./top_dir_2/sub_dir_2/sub_sub_dir', [], [])
>>> os.walk('.')
<generator object walk at 0x1004920a0>
>>>

如您所见,对 os.walk 的调用返回一个生成器对象。我将在本章后面更详细地讨论生成器,但是现在,请注意它们是可以迭代的对象,就像你可以迭代任何普通的 Python 列表或 tuple 对象一样。

返回结果是一个三元组,包含以下条目:

  • 目录路径:当前目录,其内容在接下来的两个变量中公开。
  • 目录名:目录路径中的目录名列表。此列表不包括“.”和' .. '目录。
  • 文件名:目录路径下的文件名列表。

默认情况下,os.walk 不会跟随指向目录的符号链接。要跟踪符号链接,可以将 followlinks 参数设置为 True,这将指示 os.walk 跟踪它在扫描目录树时遇到的所有符号链接。

我只对目录列表感兴趣,因为我将使用一个不同的函数来过滤出将要处理和分析的文件。只收集三元组结果的第一个元素,我就可以构建目录列表。因此,为了从作为参数列表提供的顶级目录列表中构建所有目录的递归列表,我将编写以下代码:

DIRS = []
for dir in ARGS:
    for root, dirs, files in os.walk(dir):
        DIRS.append(root)

现在 DIRS 列表包含了我需要搜索日志文件的所有目录。我需要遍历这个列表,搜索名称满足三个搜索模式的所有文件:LOG_PATTERN 或 BZLOG_PATTERN 和 OPTIONS.file_pattern。

我将使用一种最简单的方法来获取列表,即遍历目录列表,创建一个简单的内容列表,然后将结果与搜索模式进行匹配,并且只使用满足这两个条件的文件。下面的代码就是这样做的,并打开匹配的文件进行读取:

for DIR in DIRS:

    for file in (DIR + "/" + f for f in os.listdir(DIR) if
                         f.find(LOG_PATTERN) != -1 and f.find(OPTIONS.file_pattern) != -1 ):
        if file.find(BZLOG_PATTERN) != -1:
            fd = bz2.BZ2File(file, 'r')
        else:
            fd = open(file, 'r')

仔细看看列表结构,它被称为“列表理解”这是一个强大的机制,可以创建你想要遍历的对象列表。通过 list comprehension,您可以快速、优雅地对现有列表进行验证或转换,并立即获得新列表。例如,下面是快速生成 1 到 10 的所有偶数平方列表的方法:

>>> [x**2 for x in range(10) if x % 2 == 0]
[0, 4, 16, 36, 64]

列表理解的基本结构是:

[ <operand> /operation/ for <operand> in <list> /if <check  condition>/ ]

其中是用于生成列表的变量,/operation/是您可能需要对结果列表的每个元素执行的可选操作,是您正在迭代的项目列表,/ /是从结果列表中过滤掉不需要的元素的验证过滤器。

考虑到这一点,如果我仔细分析我的文件列表结构,我得到的是:

  • 结果数组的每个元素都将被构造为 DIR + "/" + f,其中 DIR 是目录名,f 是从 os.listdir()中收集的。
  • 变量 f 按顺序分配给调用 os.listdir()返回的列表中的所有元素。
  • 只接受满足条件(f.find(LOG_PATTERN)!= -1 和 f.find(OPTIONS.file_pattern)!= -1),这要求它们同时匹配 LOG_PATTERN 和 OPTIONS.file_pattern。

另外,请注意,您可以使用列表理解来生成列表对象或生成器。如果创建一个生成器,下一个元素值将仅在被请求时导出,例如,在 for 循环中。根据使用情况,这可能比生成整个列表并保存在内存中要快得多,也更节省内存。

使用内置的 Bzip2 库

您可能已经注意到有两条语句创建了文件描述符对象。一个是平面文本日志文件,另一个是用 bzip2 压缩的文件。区别在于日志文件扩展名,在 bzip2 压缩的情况下是. bz2.

Python 包含一个 bzip2 处理模块,作为一组标准包的一部分。模块中最有用的类是 BZ2File,它实现了处理压缩文件的完整接口。您可以像使用标准的 Open 函数一样使用它。返回的对象是实现标准文件处理操作的文件描述符对象:读、readline、写、writeline、查找和关闭。

因为唯一的区别是文件描述符对象是如何创建的,即使我使用了不同的函数来获取对象,结果还是被赋给了相同的 fd 变量,这个变量将在后面的代码中使用。

遍历大型数据文件

如果我必须读取和处理大量数据,我不能使用简单的方法将所有内容加载到内存中,然后再进行处理。我肯定会在这里处理大量的数据。根据您的具体情况,这可能会有所不同,但是繁忙的系统可能每小时都会生成千兆字节的日志数据。显然,所有这些数据不能一次装入内存。

解决这个问题的方法是使用发电机。generator 函数允许您生成输出(从文件中读取行),而无需将整个文件加载到内存中。如果您只需要逐行读取文件,您实际上不需要封装 readline()函数,因为您可以简单地编写:

f = open('file.txt', 'r')
for line in f:
    print line

但是,如果您需要操作文件数据并使用结果,编写自己的生成器函数来执行所需的计算并生成结果可能是个好主意。例如,您可能希望编写一个生成器,在文件中搜索特定的字符串,并打印该字符串以及该字符串之前和之后的几行内容。这就是发电机派上用场的地方。

什么是发电机,它们是如何使用的?

简而言之,Python 生成器是一个潜在可以返回许多值的函数,并且它还能够在返回之间保持自己的状态。这意味着你可以多次调用这个函数,每次它都会返回一个新的结果。每次你随后调用它,它知道它的最后位置,并从该点继续。

以下示例函数生成斐波那契数列:

def f():
    x, y = 0, 1
    while 1:
        yield y
        x, y = y, y+x

第一次调用这个函数时,会初始化 x 和 y,进入无限循环。循环中的第一条语句是返回 y 的值(注意,在生成器中,必须使用 yield 语句)。下次调用这个函数时,它将从停止执行并返回值的地方开始,即 yield 语句。下一条语句是用新值重新分配 x 和 y,其中 x 变成旧的 y,新的 y 是旧的 y 和 x 的和。需要注意的是,调用 generator 函数并不返回该函数要计算的值,而是返回实际的 generator 对象。然后,您可以像通常处理列表一样遍历它,或者调用 next()方法,这将得到下一个值:

>>> g = f()
>>> for i in range(10):
...  g.next()
...
1
1
2
3
5
8
13
21
34
55
>>>

如你所见,生成器实际上是函数而不是列表,但是它们可以用作列表。有时,就像 Fibonacci 的例子一样,虚拟链表可以是无限的。当生成器有一组有限的结果时,例如文件中的行或数据库查询中的行,它必须引发 StopIteration 异常,这将通知调用者没有更多可用的结果。

您可以使用生成器遍历文件中的所有行。每当您调用 next()函数时,这将有效地返回下一行,而不实际将整个文件加载到内存中。一旦它被定义为一个生成器,你就可以遍历它。

在我的代码中,我有一个 get_suspect()函数,它实际上是一个生成器,从日志文件中返回可能是异常堆栈跟踪的文本摘录。这个函数接受一个生成器作为它的参数,并遍历它,从而检索所有的行。这是怎么做的。

首先,我创建一个生成器,返回文件中的所有行:

g = (line for line in fd)

然后,我使用这个生成器来检索函数中的行:

def get_suspect(g):
    line = g.next()
    next_line = g.next()
    while 1:
        <do something with line and next_line>
        yield result
        try:
            line, next_line = next_line, g.next()
        except:
            raise StopIteration

我将对 next()的调用放在“try:...except:"子句,因为当到达文件的最后一行时,生成器将引发一个异常。因此,当文件无法读取时,我只需抛出 StopIteration 异常,它向迭代器发出信号,表明生成器已经用尽了所有值。

检测异常

大多数日志条目只包含一行。所以我检测异常日志条目的方法是这样的:

  • 忽略所有单行条目。这些很可能来自应用,并且没有堆栈跟踪,因为不可能将完整的堆栈跟踪放在一行中。
  • 具有多行的所有日志条目都被视为包含异常堆栈跟踪。
  • 异常堆栈跟踪日志条目必须在日志正文中包含单词 java异常

进行这种两阶段检测的原因是一个简单的检查,如“它是否有不止一行?”非常便宜,并且可以消除大量的日志条目。

检测潜在候选人

在抽象语言中,该函数的算法如下所示:

  • 从文件中读入两行。
  • 如果第二行与时间戳模式不匹配,则将其添加到结果字符串中。
  • 继续读入和追加行,直到时间戳模式匹配。
  • 返回结果。
  • 重复此操作,直到文件中不再有数据。

正如你在清单 7-6 中看到的,在这里使用生成器函数是一个明显的选择,因为我需要在函数返回包含潜在异常堆栈跟踪的结果字符串后保留内部函数状态。该函数本身接受另一个生成器函数,用于检索文本行。使用这种方法,可以用任何其他能够生成日志行的生成器来替换文件读取生成器。例如,这可能是一个数据库读取函数,甚至是一个监听和接受 syslog 服务消息的函数。

清单 7-6 。检测潜在异常的生成器函数

def get_suspect(g):
    line = g.next()
    next_line = g.next()
    while 1:
        if not (TS_RE_1.search(next_line) or TS_RE_2.search(next_line)):
            suspect_body = line
            while not (TS_RE_1.search(next_line) or TS_RE_2.search(next_line)):
                suspect_body += next_line
                next_line = g.next()
            yield suspect_body
        else:
            try:
                line, next_line = next_line, g.next()
            except:
                raise StopIteration

显然,这可以用一个具有更高级的逻辑和更好的命中率的函数来代替,但它同样有效和轻量级。

这里有一些你可能想尝试的想法:

  • 不要使用两个预定义的模式进行时间戳检测,而是尝试用预编译模式定义一个列表,它将匹配大多数流行的格式。然后,当函数运行时,它会计算成功的匹配,并动态地重新排列列表,使最受欢迎的匹配首先出现。
  • 如果有大量多行日志条目,这种简单的方法将会失败。尝试在日志正文中生成第一行的散列,并将它们存储在一个单独的数据结构中。真正的异常验证函数将根据猜测是否正确,用真/假值更新这个表。然后,这个函数可以根据这个表检查哈希值,因此它将知道哪些重复的日志条目不是真正的异常,尽管它们看起来像异常。

过滤合法的异常跟踪

到目前为止,所有的代码都是标准函数。这主要是因为代码处理选择文件和做一些初始验证。这些任务都与实际的异常处理代码无关。现在,对于异常解析和分析任务,我将使用适当的方法定义一个类。这样我可以把它作为一个完全独立的模块来分发和使用。

例如,假设我想实现一个基于 web 的应用,我的用户可以在其中提交他们的异常日志并获得一些统计数据,我希望能够重用这些代码。打开文件和处理文件模式的功能已经过时,因为根本没有要处理的文件——所有数据都来自 web 服务器。类似地,您可能想要分析存储在数据库中的数据,在这种情况下,您必须编写一个接口来检索这些数据;但是,您仍然可以重用处理异常堆栈跟踪文本的代码。因此,请始终保持代码在逻辑上的分离。

正如我所提到的,我的异常检测机制(清单 7-7 )有些幼稚——我在堆栈跟踪体中检查单词异常java

清单 7-7 。验证异常

def is_exception(self, strace):
        if strace.lower().find('exception') != -1 and \
           strace.lower().find('java')      != -1:
            return True
        else:
            return False

这很容易改变;如果你需要比这个简单的测试更复杂的东西,你可以重写这个函数来使用一个更适合你的情况的算法。

清单 7-8 显示了这种检测机制是如何与类的其他部分结合在一起的。

清单 7-8 。异常容器类的基本结构

class ExceptionContainer:
    def __init__(self):
        <initialise the object>

    def insert(self, suspect_body, f_name=""):
        lines = suspect_body.strip().split("\n", 1)
        log_l = lines[0]
        if self.is_exception(lines[1]):
            <update exceptions statistics and couners>

对于检测到的每个可疑日志行,将调用 insert 方法。然后,该方法将调用验证函数,该函数检查所提供的文本是否实际上是堆栈跟踪,是否应该被计数。

在数据结构中存储数据

我的应用的主要目标是收集日志文件中发生的异常的统计信息;因此,我需要考虑如何以及在哪里存储这些数据。有两种选择:我可以将这些数据保存在内存中,或者将它们转储到数据库中。当在这两者之间做出选择时,我需要问我是否必须做下面的任何一个:

  • 在程序终止后,在相同的结构中维护这些数据?
  • 长时间保存大量记录,并从任何其他工具访问它们?

如果两个问题的答案都是肯定的,我可能需要使用一个外部数据库来保存统计数据。但是,我不认为日志文件会有大量不同类型的异常。可能有成千上万的异常,但最有可能的是只有几百种异常。很难想象一个应用会产生所有的异常。此外,存储统计数据不是该应用生命周期的一部分。收集和分析这些数据是由一个外部进程来完成的,因此对于这个应用来说,这些数据只需要在计算阶段是“实时”的。

因此。我将使用 Python 的列表数据结构来保存数据,并在以后将其用于报告,但是当应用完成执行时,这些数据将全部丢失。

异常堆栈跟踪数据的结构

没有必要抓住我遇到的每一个例外;我只需要保存每种特定类型的异常的所有事件的计数器,以及该类型的详细信息。如前所述,异常堆栈跟踪可以分解成这些部分:

  1. 日志行(带有时间戳的行)
  2. 异常标题(异常堆栈跟踪的第一行)
  3. 异常体(堆栈跟踪)

除了这些信息,我还需要以下信息:

  • 对每种特定类型出现的次数进行计数的计数器。
  • 用于快速参考的描述。
  • 可用于组织不同类型异常的组。例如,您可能希望有一个组来统计与丢失文件相关的所有异常;但是因为它们可能是由应用的不同部分生成的,甚至是由不同的库生成的,所以您可能需要使用不同的规则来匹配它们。在这里分组是为所有这些异常维护相同计数器的唯一方便的方法。
  • 文件名,以便用户知道在哪个文件中发现了异常。如果您正在分析存储在单个目录中的大量文件,这将非常有用。

因此,每当我插入一个新的异常时,下面的字典将被追加到一个列表中:

{ 'count'    : # counter
  'log_line' : # logline
  'header'   : # header line
  'body'     : # body text with stack trace
  'f_name'   : # file name
  'desc'     : # description
  'group'    : # group
}

为未知异常生成异常指纹

假设我还没有提供任何分类规则,应用需要能够识别类似的异常,并相应地对它们进行分组。一种可能是存储一个异常正文文本,并与其他文本进行比较。如果下一个异常与存储的匹配,我就增加计数器;否则,我也会把它存储起来,用于将来的比较。图 7-1 是该过程的流程图。

9781484202180_Fig07-01.jpg

图 7-1 。统计例外情况

这是可行的,但会非常慢,因为字符串比较操作在计算能力方面非常慢且昂贵。所以如果可能的话,尽量避免使用它们,尤其是当你需要比较长的字符串时,比如长的文本片段。

执行快速文本-斑点比较更有效的方法是为每个文本片段生成一些唯一的属性,然后比较这些属性。(我说的“唯一”是指在那段特定的文本中是唯一的。)

这种属性可以是数据流的 MD5 散列函数。您可能已经知道,加密哈希函数(MD5 是一个广泛使用的例子)是一个接受任何数据块并返回预定义大小的位串的过程。该字符串的生成方式是,如果原始数据被修改,它也会改变。根据定义,输出字符串可能比输入字符串小得多,所以显然信息丢失了,无法恢复;但是该算法保证,如果两个字符串的哈希值相同,那么原始字符串也很有可能相同。

Python 有一个内置的 MD5 库,可以用来为任何输入数据生成 MD5 和。因此,我将使用这个函数为我遇到的所有异常生成 MD5 散列,然后比较这些字符串,而不是比较全栈跟踪。清单 7-9 摘自插入方法。以下变量在函数的开头定义:

  • log_l:异常日志行
  • hd_l:异常标题行
  • 异常体文本
  • f_name:发现异常的文件名
  • self.exception:字典,其中键是异常体文本的总和,值是保存异常堆栈跟踪详细信息的另一个字典

清单 7-9 。生成 MD5 并将其与存储的值进行比较

01:    m = md5.new()
02:    m.update(log_l.split(" ", 3)[2])
03:    m.update(hd_l)
04:    for ml in bd_l.strip().split("\n"):
05:        if ml:
06:            ml = re.sub("\(.*\)", "", ml)
07:            ml = re.sub("\$Proxy", "", ml)
08:            m.update(ml)
09:        if m.hexdigest() in self.exceptions:
10:            self.exceptions[m.hexdigest()]['count'] += 1
11:        else:
12:            self.exceptions[m.hexdigest()] = { 'count'  : 1,
13:                                               'log_line': log_l,
14:                                               'header' : hd_l,
15:                                               'body'  : bd_l,
16:                                               'f_name': f_name,
17:                                               'desc'  : 'NOT IDENTIFIED',
18:                                       'group' : 'unrecognised_'+m.hexdigest(), }

下面是这个函数中实际发生的事情的详细解释:

  • 第 1–3 行:初始化 md5 对象,并将它分配给异常日志行的第三个字段和整个异常标题行。我只选择异常日志行的最后一个字段的原因是,前两个字段将包含不断变化的日期和时间字符串,所以我不希望它们改变我将要生成的 MD5 散列。
  • 第 4–5 行:遍历异常体的所有行,一次一行。
  • 第 6–8 行:去掉括号中的所有文本,并删除所有对自动生成的 Java 代理对象的引用。如果行号不同,但异常堆栈跟踪看起来相同,则很有可能实际上它们是相同的。代理对象被分配了序列号,所以它们永远不会有相同的名字;因此,我也需要删除它们,这样 MD5 散列就不会改变。
  • 第 9 行:调用 hexdigest 方法,该方法将为使用 update 函数存储的文本生成 MD5 散列,并将结果与所有存储的密钥进行比较。
  • 第 10 行:如果有匹配,增加它的计数器。
  • 第 11–18 行:否则,插入一条新记录。

检测已知异常

到目前为止,我的应用可以检测唯一的异常,并对它们进行适当的分类。这很有用,但是有一些问题:

  • 与任何启发式算法一样,当前的实现在检测和比较异常的方式上非常幼稚。它做得很好,但即使是在非常简单的情况下,如文件未找到异常,也可能会有困难。如果异常是在 Java 应用的不同部分引发的,它将产生完全不同的输出,并且基本上相同类型的异常将被记录多次。有人可能会认为这是预期的行为,您确实需要知道异常是在哪里出现的,这将是一个有效的注释。在其他情况下,您并不真正关心这些细节,而是希望将所有文件未找到错误消息合并到一个组中。目前这是不可能的。
  • 命名约定真的很混乱;所有的异常组都将有不可读的名称,比如无法识别的 _ 6 C2 DC 65d 7 c 0 bfb 0768 ddff 8 caba CCF 68。
  • 如果异常细节包含特定于时间或特定于请求的信息,则该算法会将这些异常视为不同的,因为无法知道“文件未找到:文件 1.txt”和“文件未找到:文件 2.txt”实际上是同一个异常。为了验证这种行为,我生成了一千多个异常,在这些异常中,请求的文件名是相同的,并且生成了类似数量的具有唯一文件名的错误消息。针对这个示例日志文件运行应用的结果是生成一个包含一千多个实例的组和一千多个包含一个或两个实例的不同组。事实是所有的异常都是同一类型的。
  • 虽然我不是在比较大段的文本,但是计算一个 MD5 散列然后比较 has 字符串还是比较慢的。

根据这些结果,我将修改应用,以便它允许我定义如何检测和分类我的异常。

正如您已经知道的,每个异常都分为三个部分:日志行、头和堆栈跟踪体。我将允许用户为任何这些字段定义一个正则表达式,然后使用该正则表达式来检测异常。如果任何一个定义的正则表达式是匹配的,那么异常将被相应地分类;否则,它会被我之前实现的启发式算法进一步处理。我还将允许用户定义他们喜欢的任何分组名称,因此它将比无法识别的 _ 6 C2 DC 65d 7 c 0 bfb 0768 ddff 8 caba CCF 68 字符串更有意义。

配置文件

有许多方法可以存储应用的配置数据。我更喜欢使用 XML 文档,原因如下:

  1. Python 有用于解析 XML 的内置库,因此访问配置数据很简单。
  2. 当配置文件被提供给 XML 解析器时,语法验证会自动发生,所以我不需要担心检查配置文件的语法。
  3. XML 文档有一个定义清晰、明确的结构,允许我在需要时实现层次结构。

使用 XML 还有一个实际的缺点——它并不真正对人友好。然而,通过使用适当的可以突出语法的编辑器,我们可以减轻这种情况。现在,大多数编辑器都支持这个功能。几乎所有 Linux 发行版都提供的 ViM 编辑器也能够突出显示 XML 语法。

清单 7-10 是一个简单的配置文件,用于捕捉大多数文件未找到异常。

清单 7-10 。包含两条规则的配置文件

<?xml version="1.0"?>
<config>
    <exception_types>
        <exception logline=""
                   headline=""
                   body="java\.io\.FileNotFoundException: .+ \(No such file or directory\)"
                   group="File not found exception"
                   desc="File not found exception"
        />
        <exception logline=""
                   headline=""
                   body="java\.io\.FileNotFoundException: .+ \(Permission denied\)"
                   group="Permission denied exception"
                   desc="Permission denied exception"
        />
    </exception_types>
</config>

配置文件以一个文档标识字符串开始,它告诉解析器这是一个 XML 1.0 版本的文档。对于基本处理,这些信息不是严格要求的,可以省略,但是为了完整性,最好遵循规范。

XML 配置文件的根元素是标记,它包含了所有其他的配置项。现在我可以选择将异常声明直接放在标签中,因为我没有计划在我的配置文件中放入任何其他东西,这样就可以了。但是,如果我后来添加了任何新类型的配置项—例如,影响报告的内容—它在逻辑上就不合适了。因此,创建一个 branch 标记并将给定类型的所有元素放入其中总是一个好主意。因此,我定义了一个新的域元素,我将其命名为<exception_types>。每个单独异常类型的所有声明都将在这里定义。</exception_types>

如您所见,实际的异常声明非常简单。我有三个正则表达式占位符,后面是描述和组名字段。

用 Python 解析 XML 文件

有两种解析 XML 文档的方法。一种方法叫做 SAX,或者 XML 的简单 API。但是,在用 SAX 处理 XML 之前,需要为每个感兴趣的标记定义一个回调函数。然后调用 SAX 方法来解析 XML。解析器将一次读取 XML 文件的一行,并为每个识别的元素调用一个注册的方法。

我将在示例中使用的另一种方法叫做文档对象模型(DOM)。与 SAX 不同,DOM 解析器将整个 XML 文档读入内存,解析它,并构建该文档的内部表示。本质上,XML 文档表示一种类似树的结构,节点元素包含子元素或分支元素,等等。所以 DOM 解析器构建了一个类似树的链接数据结构,并为您提供了遍历该树结构的方法。

在 XML 文档中查找信息有三个基本步骤:解析 XML 文档,找到包含您感兴趣的元素的树节点,并读取它们的值或内容。

第一步,解析 XML 文档,非常简单,只需要一行代码(如果算上 include 语句,是两行)。下面的代码读入整个配置文件,并创建一个 XML 解析器对象,以后可以用它来查找信息。

from xml.dom import minidom
config = minidom.parse(CONFIG_FILE)

下一步是找到所有的元素。我知道它们的“父”节点是<exception_types>元素,所以我需要先获得它们的列表。这可以通过 getElementsByTagName 方法来完成,该方法可用于任何 XML 对象。该方法接受一个参数——您要查找的元素的名称。结果是具有您搜索的名称的元素对象的列表。method 执行的搜索是递归的,所以如果我从顶层开始(在我的实例中是 document 对象),它将返回具有这个特定名称的所有元素。在这种情况下,我不妨搜索一下标签。有了这个简单的配置文件,这个方法也可以工作,但是单词 exception 太普通了,因此可能用在 exception_types 部分之外。另一个需要注意的重要事情是,每个元素对象也是可搜索的,并且有相同的方法可供使用。因此,我可以遍历列表中的< exception_types >元素并进一步深入,在每个元素中搜索< exception >标记:</exception_types>

for et in config.getElementsByTagName('exception_types'):
    for e in et.getElementsByTagName('exception'):

Image 注意下面的文字可能看起来有点混乱,因为在术语上有重叠。XML 元素可以有属性,如下例:element value。类似地,python 对象或类可以具有如下访问的属性:python_object.attribute。当解析 XML 并为您的文档构建表示 Python 对象时,您可以使用 Python 类属性来访问 XML 文档属性

现在,我已经找到了我感兴趣的元素,第三步是提取它们的值。从配置文件示例中可以看出,我选择将数据存储为元素属性。每个元素对象中的属性都可以使用名为“属性”的属性来访问这个属性是一个充当字典的对象。字典的每个元素都有两个值: name 包含 XML 元素属性的名称, value 保存属性的实际文本值。

这听起来可能令人困惑,但如果你看看清单 7-11 中的例子,就会明白了。

清单 7-11 。访问 XML 文档中的配置数据

for et in config.getElementsByTagName('exception_types'):
    for e in et.getElementsByTagName('exception'):
        print e.attributes['logline'].value
        print e.attributes['headline'].value
        print e.attributes['body'].value
        print e.attributes['group'].value
        print e.attributes['desc'].value

从这个例子中可以看出,搜索和访问 XML 文档元素的属性确实是一项简单的任务。

存储和应用过滤器

所有异常检测和分类规则都将存储在一个数组中。每个数组元素都是一个字典,包含预编译的正则表达式、组和描述字段,以及一个 ID 字符串,它只是正则表达式字符串的 MD5 散列。这个 ID 可以在以后引用特定的异常组时使用,只要规则没有改变,它就将保持唯一。

使用预编译的正则表达式可以显著提高搜索速度,因为它们已经被验证并转换为准备执行的字节代码。配置解析和导入是在类初始化过程中完成的,正如你在清单 7-12 中看到的例子。

清单 7-12 。类初始化和配置导入

class ExceptionContainer:
    def __init__(self):
        self.filters = []
        config = minidom.parse(CONFIG_FILE)
        for et in config.getElementsByTagName('exception_types'):
            for e in et.getElementsByTagName('exception'):
                m = md5.new()
                m.update(e.attributes['logline'].value)
                m.update(e.attributes['headline'].value)
                m.update(e.attributes['body'].value)
                self.filters.append({ 'id'   : m.hexdigest(),
                                      'll_re':
                                          re.compile(e.attributes['logline'].value),
                                     'hl_re':
                                         re.compile(e.attributes['headline'].value),
                                     'bl_re':
                                         re.compile(e.attributes['body'].value),
                                      'group': e.attributes['group'].value,
                                      'desc' : e.attributes['desc'].value, })

当调用 insert 方法(前面有详细描述)时,它将遍历过滤器列表并尝试搜索匹配的字符串。当找到这样的字符串时,将存储异常详细信息,或者增加组的运行计数器,这取决于日志文件中是否已经遇到了该异常。如果没有找到匹配,将执行启发式分类方法,如清单 7-13 所示。

清单 7-13 。匹配自定义分类规则的代码

def insert(self, suspect_body, f_name=""):
    ...

    if self.is_exception(lines[1]):
        self.count += 1
        ...

        logged = False

        for f in self.filters:
            if f['ll_re'].search(log_l) and
                   f['hl_re'].search(hd_l) and
                   f['bl_re'].search(bd_l):
                logged = True
                if f['id'] in self.exceptions:
                    self.exceptions[f['id']]['count'] += 1
                else:
                    self.exceptions[f['id']] = { 'count'    : 1,
                                                 'log_line' : log_l,
                                                 'header'   : hd_l,
                                                 'body'     : bd_l,
                                                 'f_name'   : f_name,
                                                 'desc'     : f['desc'],
                                                 'group'    : f['group'], }
                break

        if not logged:
            # ... unknown exception, try to automatically categorise

预编译搜索优于纯文本搜索的优势

我提到过 MD5 散列计算和字符串比较比预编译正则表达式搜索要慢,但是真的是这样吗?让我做些实验来检验这个理论。

首先,我将针对有 4000 多个不同异常的日志文件运行应用,并测量执行时间。文件中有四种类型的异常:几个由 Tomcat 引擎生成的异常,几百个权限被拒绝的异常,一千多个文件名相同的文件找不到,一千多个文件名不同的文件找不到。结果中的第一个数字表示异常的总数,第二个数字表示已识别组的总数:

$ time ./exctractor.py .
4098, 1070

real    0m1.759s
user    0m1.699s
sys     0m0.047s

如您所见,浏览文件并统计所有异常花费了将近两秒钟的时间。现在,让我们尝试使用两个简单的规则来检测两种类型的文件未找到和权限被拒绝异常:

$ time ./exctractor.py .
4098, 6

real    0m0.789s
user    0m0.746s
sys     0m0.037s

因此,执行时间得到了显著改善,应用只需一半的时间就能完成工作。如果数据集相对较小,并且一些执行时间花费在加载库和读取配置文件上,那么当应用于较大的日志文件时,实际节省的时间甚至会更多。

此外,请注意,超过 1000 个异常组变成了 6 个。这更易于管理,信息量也更大。

生成报告

我现在有了一个全功能的应用,它读入日志文件,解析它们,搜索异常,并根据自动分组或用户定义的类别对类似的异常进行计数。所有这些都很好,但是除非有人能够阅读和分析这些数据,否则这些数据仍然是毫无用处的。

让我们编写一个简单的报告函数,以便将要使用这个应用的人可以从中受益。

将例外分组

如果您密切关注前面讨论异常分组的部分,您可能已经注意到异常不是基于字段进行分组的。而且,如果异常没有在配置文件中分类,它将根据其 MD5 哈希值进行分组;然而,在这种情况下,组名和异常 ID 将有一对一的映射,因为组名是从哈希值生成的:

if m.hexdigest() in self.exceptions:
    self.exceptions[m.hexdigest()]['count'] += 1
else:
    self.exceptions[m.hexdigest()] = { 'count'    : 1,
                                       'log_line' : log_l,
                                       'header'   : hd_l,
                                       'body'     : bd_l,
                                       'f_name'   : f_name,
                                       'desc'     : 'NOT IDENTIFIED',
                                       'group'    : 'unrecognized_'+m.hexdigest(), }

但是,如果使用配置文件中的一个过滤器“捕获”了该异常,则会根据过滤器 MD5 哈希值而不是“组”字符串对其进行分类:

if f['ll_re'].search(log_l) and f['hl_re'].search(hd_l) and f['bl_re'].search(bd_l):
    if f['id'] in self.exceptions:
        self.exceptions[f['id']]['count'] += 1
    else:
        self.exceptions[f['id']] = { 'count'    : 1,
                                     'log_line' : log_l,
                                     'header'   : hd_l,
                                     'body'     : bd_l,
                                     'f_name'   : f_name,
                                     'desc'     : f['desc'],
                                     'group'    : f['group'], }

这种方法允许您找出每个过滤器被点击的次数,还可以根据“组”字段对计数器进行分组。

所以首先,我需要检查所有记录的异常列表,并创建不同的类别。categories 字典只存储组名和该组中的异常总数。我还使用选项键–v(表示详细)来决定是否打印异常细节。清单 7-14 显示了代码。

清单 7-14 。将异常 id 分组到类别中

def print_status(self):
    categories = {}
    for e in self.exceptions:
        if self.exceptions[e]['group'] in categories:
            categories[self.exceptions[e]['group']] += self.exceptions[e]['count']
        else:
            categories[self.exceptions[e]['group']] = self.exceptions[e]['count']
        if OPTIONS.verbose:
            print '-' * 80
            print "Filter ID                 :", e
            print "Exception description     :", self.exceptions[e]['desc']
            print "Exception group           :", self.exceptions[e]['group']
            print "Exception count           :", self.exceptions[e]['count']
            print "First file                :", self.exceptions[e]['f_name']
            print "First occurrence logline :", self.exceptions[e]['log_line']
            print "Stack trace headline      :", self.exceptions[e]['header']
            print "Stack trace               :"
            print self.exceptions[e]['body']

为同一数据集生成不同格式的输出

如果没有为详细报告提供选项,应用将只打印两个数字,这两个数字表示发现的异常总数和不同组的总数。您可以使用这些信息快速检查当前状态,也可以累积一段时间的记录,并将数据导入 Excel 或其他工具以绘制漂亮的图表。

如果您计划将报告数据导入到其他应用,它需要符合该应用接受的格式。如果您使用 Excel 创建图表,最方便的导入文件类型是逗号分隔值(CSV ),但是如果您只想在屏幕上显示此信息,您很可能希望它比逗号分隔的一对数字更能提供信息。

因此,我引入了一个选项,允许用户设置他们想要的结果格式:CSV 或纯文本。然后,我创建了两个引用相同变量但提供不同格式的模板字符串:

TPL_SUMMARY['csv']  = "%(total)s, %(groups)s"
TPL_SUMMARY['text'] = "="*80 + "\nTotal exceptions: %(total)s\nDifferent groups: %(groups)s"

然后,根据用户提供的格式键,print 语句将选择适当的格式字符串并将变量传递给它:

print TPL_SUMMARY[OPTIONS.format.lower()] % {'total': self.count, 'groups': len(categories)}

请注意如何将变量传递给格式化的字符串,并通过名称引用它们。当您需要使用同一组变量产生不同格式的输出时,这种技术非常有用。

计算组统计信息

最后,我想生成一个更详细的报告,显示找到了多少个不同的组,以及每个组中的异常数量,包括相对的(百分比)和绝对的(出现的总数)。

我已经有了字典中的所有细节,包括组名和每个组中的异常总数。但是字典是没有排序的,如果有一个按降序排列的列表就更好了,最糟糕的“冒犯者”在最上面。

Python 有一个非常有用的内置函数,可以对任何可迭代对象进行排序:sorted()。这个函数接受任何可迭代的对象,比如一个列表或字典,并返回一个新的排序列表。棘手的部分是,当遍历一个字典时,你只是遍历它的键,所以当调用 sorted()并把字典作为它的参数时,你只能得到一个排序的键的列表!

>>> d = {'a': 10, 'b': 5, 'c': 20, 'd': 15}
>>> for i in d:
...  print i
...
a
c
b
d
>>> sorted(d)
['a', 'b', 'c', 'd']
>>>

显然这不是你真正想要的;您的结果中需要这两个值。字典有一个内置的方法,它将键/值对作为可迭代对象返回—iteritems()。如果您使用这个,您会得到稍微好一点的结果,显示每一对的键和值,但是它们仍然是根据键值排序的,这也不是您想要的:

>>> for i in d.iteritems():
...  print i
...
('a', 10)
('c', 20)
('b', 5)
('d', 15)
>>> sorted(d.iteritems())
[('a', 10), ('b', 5), ('c', 20), ('d', 15)]
>>>

sorted()函数接受一个参数,该参数允许您指定一个函数,用于在列表元素是复合元素(如值对)时从列表元素中提取比较键。换句话说,这个函数应该从每一对中返回第二个值。您需要操作符库中的一个特殊函数:itemgetter()。我将使用该函数从每对中提取第二个值,sorted()函数将使用该值对列表进行排序:

>>> from operator import itemgetter
>>> t = ('a', 20)
>>> itemgetter(1)(t)
20
>>> sorted(d.iteritems(), key=itemgetter(1))
[('b', 5), ('a', 10), ('d', 15), ('c', 20)]
>>>

最后一步是告诉 sorted()对列表进行逆序排序,这样列表就从值最大的项开始:

>>> sorted(d.iteritems(), key=itemgetter(1), reverse=True)
[('c', 20), ('d', 15), ('a', 10), ('b', 5)]
>>>

类似地,我生成并打印例外组的列表。我添加一个统计计算,只是为了显示每个组的相对大小:

for i in sorted(categories.iteritems(), key=operator.itemgetter(1), reverse=True):
    print "%8s (%6.2f%%) : %s" % (i[1], 100 * float(i[1]) / float(self.count), i[0] )

摘要

在这一章中,我详细解释了开源工具 Exctractor 是如何编写的,以及每个功能部分是做什么的。本章展示了如何应用 Python 知识构建一个相对复杂的命令行工具来分析大型文本文件。尽管 Python 本身不是一种文本处理语言,但它可以成功地用于这个目的。需要记住的要点:

  • 从定义一个问题和你希望你的应用实现什么开始。
  • 分析您将使用的数据结构,并根据这些信息做出设计决策。
  • 如果您正在处理大型数据集,请尝试通过使用生成器(动态生成值的 Python 函数)来最小化所需的内存量。
  • 如果您需要读取和搜索大型数据文件中的信息,请使用生成器构造一次读取一行。
  • Python 内置了对读取和写入压缩文件(如 bzip2 档案)的支持。
  • 保持配置的结构化格式,比如 XML,特别是当它包含很多条目的时候。

八、Nagios 的网站可用性检查脚本

在本章中,我们将为目前可用的标准网络监控系统(NMS)之一 Nagios 构建一个定制的检查脚本。我们将通过使用 HTML 解析库来监控一个简单的网站,它允许我们检查网站的操作方面。检查脚本试图通过模拟用户登录操作,浏览不安全的页面,然后到达一些受保护的页面。所有的动作都将被记录并反馈到 Nagios 系统,该系统可以被配置为进行报告和报警。如果需要。

对检查系统的要求

我们将要实现的系统的主要需求是监控远程网站的能力。然而,检查应该超越简单的 HTTP GET 或 POST 请求,它必须允许用户指定导航路径。例如,它应该能够执行一些模拟标准用户行为的操作:进入主网站页面,然后浏览产品列表或导航到新闻网站并选择头条新闻。

作为该场景的变体,系统还需要能够模拟一个登录过程,通过该过程,支票将用户详细信息提交给远程网站。然后,系统会验证这些详细信息,并返回安全令牌(通常以浏览器 cookie 值的形式)。

与简单的 HTTP 检查不同,默认的 Nagios 发行版提供了简单的 HTTP 检查,这种机制实际上触发了 web 应用逻辑,并充当更复杂的检查。当它与计时参数结合使用时,可以实现复杂的检查,监视用户登录时间,并在登录过程成功但耗时过长时发出警报。

我们将使用 Python 的标准 urllib 和 urllib2 库来访问网站。作为一个网页解析器,我们要用漂亮的 Soup HTML 解析库。

每个网站都是独一无二的,或者至少是试图脱颖而出。因此,制作通用支票系统可能是一项复杂的任务;为了简单起见,我将对我们将在本章中构建的系统设置一些约束。例如:

  • 导航(或用户旅程)路径将在脚本中编码,不作为配置提供。
  • 登录检查仅适用于使用基于 cookie 的身份验证机制的站点。

Nagios 监控系统

Nagios 是最流行的网络监控系统之一。它用于监控使用不同访问协议(如 HTTP、SNMP、FTP 和 SSH)的各种网络连接组件。这些功能是无穷无尽的,因为 Nagios 有一个基于插件的架构,允许您扩展基本功能以满足您的监控需求。您也可以使用 Nagios 远程插件执行器(NRPE)工具远程运行检查。

除了监视任务之外, Nagios 还能够将收集到的数据图形化,比如系统响应时间或 CPU 利用率。当出现问题时,Nagios 能够通过电子邮件或短信通知发出警报。

Nagios 包(基础应用和插件)在大多数 Linux 平台上都是可用的,所以请查看您的 Linux 发行版文档以了解安装细节。或者,您可以从 Nagios 网站的www.nagios.org/download下载源代码。在典型的 Fedora 系统上。您可以使用以下命令安装 Nagios 基本系统以及一组基本的插件(或检查):

$ sudo yum install nagios nagios-plugins-all

在 Debian 系统上,您可以运行以下命令:

$ sudo apt-get install nagios3

要继续学习本章,您应该有一些管理 Nagios 的经验。如果您需要更多信息,请参考您可以在www.nagios.org/documentation/网站在线找到的官方文档。

Nagios 插件架构

Nagios NMS 的强大之处在于它的插件架构。所有的检查命令都是外部工具,可以用任何语言编写——C、Python、Ruby、Perl 等等。插件通过操作系统返回代码和标准输入/输出机制与 Nagios 系统通信。换句话说,Nagios 有一组预定义的返回代码,检查脚本必须返回这些代码。返回代码指示新的服务状态应该设置为什么。所有返回代码和相应的服务状态在表 8-1 中列出。

表 8-1 。Nagios 插件返回代码

|

返回代码

|

服务状态

Zero 好的。这项服务状况良好。
one 警告。服务是可用的,但是危险地接近危急状况。
Two 危急关头。该服务不可用。
three 未知。无法确定服务的状态。

除了返回代码之外,插件应该在标准输出中至少打印一行。这个打印的字符串应该包含一个强制的状态文本,后跟可选的性能数据字符串。因此,一个简单的单行报告示例可以是:

WebSite OK

该文本将被附加到 Nagios GUI 中的状态报告消息中。类似地,附加了性能数据后,它看起来像这样:

WebSite OK | response_time=1.2

性能数据部分可以通过内置的 Nagios 宏获得,并可以用于绘制图表。有关使用性能数据参数的更多信息,请访问nagios.sourceforge.net/docs/3_0/perfdata.html

当您编写一个新的插件时,您必须首先在配置文件中提供它,以便 Nagios 知道在哪里可以找到它。按照惯例,所有插件都存储在/usr/lib/nagios/plug-ins 中。

一旦编写了检查脚本,就必须在 command.cfg 配置文件中定义它,该文件可以在/etc/nagios/objects/中找到。实际位置可能不同,这取决于您如何安装 Nagios。以下是支票定义的示例:

define command {
        command_name    check_local_disk
        command_line    $USER1$/check_disk -w $ARG1$ -c $ARG2$ -p $ARG3$
}

当您定义服务或主机时,现在可以使用 check_local_disk 名称来引用此检查。实际的可执行文件是$USER1$/check_disk,接受三个参数。下面是一个服务定义的示例,该服务定义使用此检查并将所有三个参数传递给它:

define service {
        use                             local-service
        host_name                       localhost
        service_description             Root Partition
        check_command                   check_local_disk!10%!5%!/
}

您之前在命令行定义中看到的$user 1$宏只是指插件目录,在/etc/Nagios/private/resource . CFG 中定义为$user 1 $=/usr/lib/Nagios/plugins。

如果需要,您可以定义一个新宏,并将其用于您的检查脚本。通过这种方式,您可以将打包的脚本与您自己的脚本分开,这样维护起来就更容易了。我建议对具有复杂结构的检查脚本这样做,这些脚本带有外部配置文件或其他依赖项。

网站导航检查

如您所知,每个网站都是独一无二的,尽管通常应用相同的导航和实现原则,但您仍然需要做大量的手工工作来对其进行逆向工程,以便您可以成功地模拟用户操作。如果你知道网站是如何建立的,事情就变得简单多了,不需要猜测。在这个示例检查中,我们将构建一个脚本,该脚本导航到位于news.bbc.co.uk/的 BBC 英国网站,选择头条新闻,并跟随该链接。

这个检查是一个很好的例子,它模拟了一个用户行为模式,还测试了至少两个功能的内部网站逻辑:生成首页的能力和生成头条内容的能力。我们还将监控执行时间,如果它超过了预先配置的阈值,我们也会发出警报。

安装漂亮的 Soup HTML 解析库

在继续之前,我们需要安装漂亮的汤库。Beautiful Soup 是一个 Python 模块,用于解析 HTML 和 XML 文档,并从中提取信息。这个库非常适合处理现实世界中的 HTML 页面,因为它忽略了格式错误的 HTML 语法,缺少结束标记以及网页可能包含的其他错误。

因为 Beautiful Soup 是一个非常受欢迎的库,它的包适用于大多数 Linux 发行版。例如,在 Fedora 系统上,我们可以使用以下命令安装这个库:

$ sudo yum install python-BeautifulSoup

我们也可以从 Python 包索引(PyPI)安装它:

$ sudo pip install BeautifulSoup

或者,可以从位于www.crummy.com/software/BeautifulSoup/的应用网站下载源代码。

检索网页

在其最简单的形式中,页面检索功能只需要两次函数调用就可以实现,在大多数情况下,我们不提交任何信息,只进行检索,这就足够了。以下示例使用 urlopen()函数,如果没有提供额外的表单数据,该函数将执行 HTTP GET 请求。在本章的后面,我们将会看到向 web 应用提交数据的不同方法。

>>> import urllib2
>>> r = urllib2.urlopen('http://news.bbc.co.uk')
>>> html = r.read()
>>> len(html)
143484

read()调用的结果是一个字符串,其中包含服务器提供的网页。但是,该字符串不是完整的响应,并且不包括额外的信息,如 HTTP 协议头。urlopen()调用返回的结果对象有 info()方法,我们可以用它来检索服务器返回的 HTTP 头。我们需要记住 info()调用返回的对象是 httplib 的一个实例。HTTPMessage 类,它实现了与 dictionary 类相同的协议,但实际上它本身并不是一个字典:

>>>r.info()
<httplib.HTTPMessage instance at 0x1005c7ef0>
>>>print r.info()
Server: Apache
X-Cache-Action: HIT
X-Cache-Hits: 133
Vary: X-CDN
X-Cache-Age: 8
Cache-Control: private, max-age=0, must-revalidate
Content-Type: text/html
Date: Tue, 13 May 2014 18:02:06 GMT
Expires: Tue, 13 May 2014 18:01:58 GMT
Content-Language: en-GB
X-LB-NoCache: true
Connection: close
Set-Cookie: BBC-UID=758377d205ee016ea1140d3d4136ec148f4d29ac7484e1befa21640e52188e380Python-urllib/2.7; 
expires=Sat, 12-May-18 18:02:06 GMT; path=/; domain=.bbc.co.uk
Content-Length: 143484

>>>r.info()['Server']
'Apache'
>>>

Image 提示你可以在www.cs.tut.fi/~jkorpela/http.html找到更多关于 HTTP 头的信息,包括简短的描述和到适当 RFC 规范文档的链接。

响应对象的另一个有用的方法是 geturl()。这个方法返回被检索文档的实际 URL。初始 URL 可能会响应 HTTP 重定向,实际上我们最终会从一个完全不同的 URL 检索到一个页面。在这种情况下,我们可能需要检查页面的来源。重定向的一个可能原因是,我们试图在没有事先验证的情况下访问受限制的内容。在这种情况下,我们很可能会被重定向到网站的登录页面。

>>> r.geturl()
'http://www.bbc.co.uk/news/'

产生的内容可以传递给 Beautiful Soup 进行 HTML 解释和解析。结果是一个 HTML 文档对象,它实现了从文档中搜索和提取数据的各种方法。提供给漂亮的 Soup 构造函数的参数只是一个字符串,这意味着我们可以使用任何字符串作为参数,而不仅仅是我们刚刚从网站上获取的那个。

>>> from BeautifulSoup import BeautifulSoup
>>> soup = BeautifulSoup(html)
>>> type(soup)
<class 'BeautifulSoup.BeautifulSoup'>
>>>

用漂亮的汤解析 HTML 页面

一旦内容被加载到 BeautifulSoup 对象中,我们就可以开始剖析 BBC 首页了。为了让你知道我们需要在页面上找到什么,图 8-1 展示了一个首页截图的例子,我们可以清楚地看到头条的位置。当我捕捉到这个画面时,BBC 英国新闻的头条新闻是“法庭调查英国虐待伊拉克的指控”每篇文章的标题都有明显的变化,但网站的布局很少变化,头条新闻总是显示在网页的同一位置。

9781484202180_Fig08-01.jpg

图 8-1 。英国广播公司英国新闻头版

我们现在需要在网页中找到相应的 HTML 代码。让我们来看看网页源代码,如清单 8-1 所示。(我做了一些格式化,所以如果您从 web 浏览器查看代码,您可能会看到稍微不同的代码布局。)

清单 8-1 。BBC 新闻英国版首页的 HTML 源代码

[...]

<div id="now" class="container-now">
  <div id="container-top-stories-with-splash" class="container-top-stories">
    <div id="top-story" class="large-image">
      <h2 class="top-story-header ">
        <a class="story" rel="published-1399992906414" href="/news/uk-27397695">
          Court to probe UK Iraq abuse claims<img src="http://news.bbcimg.co.uk/media/
          img/jpg/_74828076_74828067.jpg" alt="British soldiers" /></a>
      </h2>
      <p>An initial investigation into claims that UK forces abused Iraqi detainees is to be opened by the International Criminal Court.
<span id="dna-comment-count___CPS__27397695" class="gvl3-icon gvl3-icon-comment comment-count"></span>
      </p>
      <ul class="see-also">
        <li class=" first-child column-1">
          <a class="story" rel="published-1389534113695" href="/news/uk-25703723">Hague rejects Iraq 'abuse' complaint</a>
        </li>
        <li class=" column-1">
          <a class="story" rel="published-1380724487138" href="/news/uk-24361521">Military 'must aid Iraq inquiries'</a>
        </li>
        <li class=" column-2">
          <a class="story" rel="published-1363716369370" href="/news/uk-21740286">Q&amp;A: Al-Sweady inquiry</a>
        </li>
      </ul>
      <hr />
    </div>
    <div id="second-story" class="secondary-top-story">
      <div class="large-image">
        <h2 class=" secondary-story-header">
          <a class="story" rel="published-1399993960900" href="/news/world-europe-27396448"><img           src="http://news.bbcimg.co.uk/medimg/jpg/_74826634_74826454.jpg" alt="Pro-
          Russian militant in Donetsk - 13 May" />'Troops killed' in Ukraine ambush</a>
        </h2>
        <p>Seven Ukrainian soldiers and one pro-Russian insurgent have reportedly been killed in an 
         ambush in the eastern Donetsk region, reports say.
<span id="dna-comment-count___CPS__27396448" class="gvl3-icon gvl3-icon-comment comment-count"></span>
        </p>
        <ul class="see-also">
          <li class=" first-child column-1">
            <a class="story" rel="published-1399980282897" href="/news/world-europe-27392074">Ukrainian speakers leave Donetsk</a>
          </li>
          <li class=" column-1">
            <a class="story" rel="published-1399911372202" href="/news/business-27374070">Russia deadline on Ukraine gas debt</a>
          </li>
          <li class=" column-2">
            <a class="story" rel="published-1399904895703" href="/news/world-europe-27379219">Rosenberg: What will Putin do next?</a>
          </li>
          <li class=" column-2">
            <a class="story" rel="published-1399896081600" href="/news/world-europe-27376297">Media 
            mull Ukraine vote significance</a>
          </li>
        </ul>
      </div>
    </div>

[...]

我们可以立即发现三个不同的标记,它们可能会将我们引向顶层 URL 链接。第一个是 ID 设置为 top-story 的

标记。第二个标记是属于 top-story-header 类的

标记。最后一个标记可能是属于 tshsplash 类的

有几种方法可以访问漂亮的 Soup 文档中的标签。如果我们确切地知道我们正在寻找什么和网站的确切结构,我们可以简单地使用标签名称作为 soup 对象的属性:

>>> import urllib2
>>> from BeautifulSoup import BeautifulSoup
>>> WEBSITE = 'http://www.bbc.co.uk/news/'
>>> result = urllib2.urlopen(WEBSITE)
>>> html = result.read()
>>> soup = BeautifulSoup(html)
>>> print soup.html.head.title

这段代码将打印标题 HTML 字符串:

<title>BBC News - Home</title>

这是一种方便快捷的访问单个标签的方法,但是如果标签封装结构很复杂,这种方法就不能很好地工作。例如,我们试图到达的第一个

标签已经比封装它的标签深了九层(见图 8-2 ,我们甚至不知道那个特定的< div >标签相对于文档的根元素在哪里。

9781484202180_Fig08-02.jpg

图 8-2 。英国广播公司新闻英国头版 DOM 树(部分)

对于这种情况,Beautiful Soup 提供了 find 方法,允许我们搜索元素,而不管它们在文档树中的什么位置。换句话说,搜索是递归的。有两个 find 方法 : findAll,返回所有匹配搜索字符串的标签列表;和 find,它返回匹配标记的第一个匹配项。

另外,请记住,每个文档元素都实现了与主 soup 对象相同的搜索方法。因此,如果我们想要获得包含在另一个

中的每个,我们将首先通过其 ID 或其类搜索第一个标签,然后仅从该对象开始运行另一个搜索查询,如下所示:

>>> top_div = soup.find('div', {'id': 'now'})
>>> divs = top_div.findAll('div')
>>> divs[-1]
<div class="languages-footer">
<a href="/worldservice/languages/">More languages</a>
</div>
>>>

对我们来说幸运的是,BBC 新闻网站结构良好,我们只需少量搜索就能获得头条新闻。首先,我们将使用 ID top-story 获取

。这让我们非常接近顶层链接。然后,我们将获得属于 top-story-header 类的

元素。产生的子树将包含所需的

>>> top_story_div = soup.find('div', {'id': 'top-story'})
>>> h2_tag = top_story_div.find('h2', {'class': 'top-story-header '})
>>> a_tag = h2_tag.find('a', {'class': 'story'})
>>> a_tag
<a class="story" rel="published-1399992906414" href="/news/uk-27397695">
      Court to probe UK Iraq abuse claims<img src="http://news.bbcimg.co.uk/medimg/jpg/_74828076_74828067.jpg" alt="British soldiers" /></a>
>>>

检查字典键是否实际存在总是一个好主意;否则,您将会得到 KeyError 异常。但是,因为我们没有访问“真正的”Python 字典对象,所以我们不能使用 IS IN 构造,因为它会给我们一个不正确的结果:

>>> 'href' in a_tag
False
>>> a_tag.has_key('href')
True
>>> a_tag['href']
u'/news/uk-27397695'
>>>

下一步是加载这个页面。只需成功加载此页面就足以确认网站正在运行,因此我们不会对该页面进行任何 HTML 解析。检查脚本还需要测量检索两个网页所花费的时间。如果时间超过了定义的阈值,脚本将返回一个错误代码。清单 8-2 显示了完整的校验码。

清单 8-2 。站点导航脚本

#!/usr/bin/env python

import sys
import urllib2
import time
from BeautifulSoup import BeautifulSoup
from optparse import OptionParser

NAGIOS_OK = 0
NAGIOS_WARNING = 1
NAGIOS_CRITICAL = 2
WEBSITE_ROOT = 'http://news.bbc.co.uk/'

def fetch_top_story():
    status = []
    try:
        result = urllib2.urlopen(WEBSITE_ROOT)
        html = result.read()
        soup = BeautifulSoup(html)
        a_tag = soup.find('a', 'tshsplash')
        story_heading = a_tag.string
        topstory_url = ''
        if a_tag.has_key('href'):
            topstory_url = "%s/%s" % (WEBSITE_ROOT, a_tag['href'])
        else:
            status = [NAGIOS_CRITICAL, 'ERROR: Top story anchor tag has no link']
        result = urllib2.urlopen(topstory_url)
        html = result.read()
        status = [NAGIOS_OK, story_heading]
    except:
        status = [NAGIOS_CRITICAL, 'ERROR: Failed to retrieve the top story']
    return status

def main():
    parser = OptionParser()
    parser.add_option('-w', dest='time_warn', default=1.8,
                      help="Warning threshold in seconds, default: %default")
    parser.add_option('-c', dest='time_crit', default=3.8,
                      help="Critical threshold in seconds, default: %default")
    (options, args) = parser.parse_args()
    if options.time_crit < options.time_warn:
        options.time_warn = options.time_crit

    start = time.time()
    code, message = fetch_top_story()
    elapsed = time.time() - start
    if code != 0:
        print message
        sys.exit(code)
    else:
        if elapsed < float(options.time_warn):
            print "OK: Top story '%s' retrieved in %f seconds" % (message, elapsed)
            sys.exit(NAGIOS_OK)
        elif elapsed < float(options.time_crit):
            print "WARNING: Top story '%s' retrieved in %f seconds" % (message, elapsed)
            sys.exit(NAGIOS_WARNING)
        else:
            print "CRITICAL: Top story '%s' retrieved in %f seconds" % (message, elapsed)
            sys.exit(NAGIOS_CRITICAL)

if __name__ == '__main__':
    main()

如您所见,该脚本接受两个可选参数,允许我们设置警告和临界条件的阈值。在将其部署为 Nagios 检查之前,让我们用各种设置测试检查脚本,只是为了触发所有可能的条件:

$ ./check_website_navigation.py -w 1.2
OK: Top story 'Court to probe UK Iraq abuse claims' retrieved in 0.540721 seconds
$ echo $?
0
$ ./check_website_navigation.py -w 0.4
WARNING: Top story 'Court to probe UK Iraq abuse claims' retrieved in 0.556052 seconds
$ echo $?
1
$ ./check_website_navigation.py -c 0.4
CRITICAL: Top story 'Court to probe UK Iraq abuse claims' retrieved in 0.535464 seconds
$ echo $?
2
$

向 Nagios 系统添加新的检查

现在是时候在 Nagios 中提供这项检查并开始监控 BBC 新闻网站了。首先,我们将在命令列表文件中添加新的部分,它是/etc/nagios/objects/目录中的 commands.cfg。以下代码使检查可以在名称 check_website_navigation 下使用,并指示命令需要提供两个参数:

define command {
    command_name    check_website_navigation
    command_line    $USER2$/check_website_navigation -w $ARG1$ -c $ARG2$
}

然后,我们需要创建一个至少包含主机和服务定义的配置文件。清单 8-3 显示了如何创建一个简单的配置文件,该文件定义了一个主机模板,然后主机从该模板继承基本设置。然后,该主机被放入一个单独的主机组。类似地,具有新的检查命令的服务被定义并分组到单独的服务组中。当我们在本章后面添加另一个检查时,我们将扩展这个配置。一旦我们创建了配置文件,我们就必须在 nagios.cfg 文件中添加一个指向这个配置文件的 cfg_file 语句。

清单 8-3 。Nagios 主机和服务定义

define host {
    name            template-website-host
    use            generic-host
    register        0
    max_check_attempts    5
    contacts        nagiosadmin
    parents            localhost
    check_command        check-host-alive
}

define host {
    use            template-website-host
    host_name        news.bbc.co.uk
    address            news.bbc.co.uk
    notes            BBC News UK
}

define hostgroup {
    hostgroup_name        InternetWebsites
    alias            Internet Websites
    members            news.bbc.co.uk
}

define service {
    use            generic-service
    hostgroup_name        InternetWebsites
    service_description    SiteNavigation
    check_command        check_website_navigation!1.5!2.5
}

define servicegroup {
    servicegroup_name    InternetWeb
    alias            Internet Websites
    members            news.bbc.co.uk,SiteNavigation
}

如果我们给 Nagios 一些时间来重新检查所有已定义的服务,然后导航到服务检查屏幕,我们应该会看到类似于图 8-3 所示的结果。

9781484202180_Fig08-03.jpg

图 8-3 。检查 Nagios 中的脚本状态

模拟用户登录过程

我们要实现的下一个检查是用户登录操作。对于一个示例网站,我将使用www.telegraph.co.uk/。该网站允许用户参与不同的促销活动,订阅邮件列表和电子邮件通知。显然,这些选项需要允许用户向网站表明自己的身份。

当用户单击位于网页右上角的登录链接时,他或她将被重定向到登录登录页面。该页面包含一个带有两个字段的 web 页面表单:一个用于用户电子邮件地址,另一个用于密码。清单 8-4 显示了网页源代码中的表单定义。

清单 8-4 。telegraph.co.uk 登录表单

<form id="user" class="basicForm" action="./login.htm" method="post">
  <label for="email" >Email address</label>

  <input id="email" name="email" type="text" value=""/>

  <label for="password" >Password</label>

  <input id="password" name="password" type="password" value="" autocomplete="off"/>

  <div class="cl"></div>
  <a href="forgotpassword.htm" class="noLabel">Forgotten password?</a><br/>
  <a href="http://www.telegraph.co.uk/topics/about-us/3489870/Contact-us.html"
class="noLabel">Need help?</a>
  <div class="bottomButtons">

    <input type="submit" value="Log in" />

  </div>
  <div class="cl"></div>
  <p class="noLabel">Or<a href="registration.htm">register now</a>
    if you do not have a Telegraph.co.uk profile
  </p>
</form>

当我们填写值并点击 Submit 按钮时,web 浏览器通过将所有字段(包括字段名及其新值)组合成一个字符串来对值进行编码,并将该信息作为 HTTP POST 请求发送。HTTP 方法通常在表单定义中指定,从我们的例子中可以看出,它当前被设置为 POST。

如果我们想要达到同样的结果,我们首先需要封装我们将要提交的数据。不幸的是,urllib2 不提供这种功能,我们必须使用 urllib 方法对表单数据进行编码。包含表单数据的格式化字符串应该作为可选参数提供给 urlopen()方法。如果提供了附加数据,该方法将自动发送 POST 请求,而不是默认的 GET 请求。

Image 注意POST 和 GET 请求有什么区别?主要区别在于这两个请求向 web 服务提交额外数据的方式。如果发送 GET 请求,数据包含在 URL 字符串中。URL 的语法应该与此类似:example.com/some_page?key =值&key 2 =值 2。相反,如果发送 POST 请求,URL 将是example.com/some_page,数据将封装在 HTTP 请求头中。

网站通常使用 HTTP cookies 来管理用户会话。HTTP cookie 是一个协议消息字段,包含在从网络浏览器应用发送到网络服务器的通信消息中。HTTP 协议本质上是无状态的。HTTP 请求不携带任何有助于识别请求发送者的信息。然而,跟踪用户活动对于网络购物服务或任何其他需要提供个性化结果的服务来说是必不可少的。这项活动被称为“维护 web 会话”维护这个会话的方法之一是使用 HTTP cookies。下面是一个 HTTP cookie 的例子:

Set-Cookie: BBC-UID=758377d205ee016ea1140d3d4136ec148f4d29ac7484e1befa21640e52188e380Python-urllib/2.7; expires=Sat, 12-May-18 18:02:06 GMT; path=/; domain=.bbc.co.uk

在 HTTP 头消息中可以设置多个 cookies。每个 cookie 都有一个名称、一个值和一些额外的属性,比如应该接收它的域、过期时间和 URL 部分。那么 cookies 是如何帮助维护会话的呢?当 web 服务器收到请求时,它会将初始响应发送回 web 浏览器。连同其他 HTTP 头字段一起,它插入了 cookie 字段。web 客户端依次将 cookie 保存在其内部数据库中。当它发出另一个请求时,它会扫描数据库,查找属于当前向其发送请求的同一个域并且具有匹配的 path 属性的 cookies。然后,web 客户端在其后续请求中包含所有匹配的 cookies。现在,web 服务器接收到用 cookies“标记”的请求,因此知道这些请求是同一个“会话”的一部分,或者换句话说,属于同一个 web 会话。

我已经描述了自动处理 cookie 存储和管理活动的典型 web 浏览器的行为。默认的 URL 处理器(或 urllib2 术语中的开启器)不处理 cookies。幸运的是,所有处理 cookies 的类都包含在 urllib2 模块中,您只需要用自定义的 opener 对象替换默认的 opener。我们将在构造新的 opener 对象时使用的 HTTPCookieProcessor 类负责存储从服务器接收到的 HTTP cookies,然后将它们注入到发往同一网站的所有 HTTP 请求中:

>>> import urllib, urllib2
>>> url = 'https://auth.telegraph.co.uk/sam-ui/login.htm'
>>> data = urllib.urlencode({'email': 'user@example.com', 'password': 'secret'})
>>> opener = urllib2.build_opener(urllib2.HTTPCookieProcessor())
>>> urllib2.install_opener(opener)
>>> result = opener.open(url, data)
>>> html = result.read()
>>> print html

[...]

  <head>

    <title>My Account</title>

  </head>

[...]

检索到的 HTML 页面显示我们已成功登录系统,返回给我们的网页是用户资料/帐户管理页面。旧版本的电报账户页面用你的用户名迎接我们;然而,自从本书的第一次修订以来,这种情况已经发生了变化,我们不得不假设“我的帐户”标题是成功登录的充分指标。

现在让我们尝试访问注销页面,它应该将我们从站点中注销,从而有效地使我们之前检索到的会话 cookie 失效。我们还会检查检索到的实际 URL,因为我们可能会被重定向到与请求页面不同的页面:

>>> url_logon = 'https://auth.telegraph.co.uk/sam-ui/login.htm'
>>> url_logoff = 'https://auth.telegraph.co.uk/sam-ui/logoff.htm'
>>> import urllib, urllib2
>>> data = urllib.urlencode({'email': 'user@example.com', 'password': 'secret'})
>>> opener = urllib2.build_opener(urllib2.HTTPCookieProcessor())
>>> urllib2.install_opener(opener)
>>> res = opener.open(url_logon, data)
>>> html_logon = res.read()
>>> print res.geturl()
'https://auth.telegraph.co.uk/customer-portal/myaccount/index.html'
>>> res.close()
>>> res = opener.open(url_logoff)
>>> html_logoff = res.read()
>>> res.geturl()
'http://www.telegraph.co.uk/'
>>> res.close()

现在让我们看看标签是否是一个可以用来区分注册页面(意味着我们已经成功登录)和主登录页面(意味着我们已经成功注销)的标记:

>>> from BeautifulSoup import BeautifulSoup
>>> soup_logon = BeautifulSoup(html_logon)
>>> soup_logoff = BeautifulSoup(html_logoff)
>>> soup_logon.head.title
<title>My Account</title>
>>> soup_logoff.head.title
<title>The Telegraph - Telegraph online, Daily Telegraph, Sunday Telegraph - Telegraph</title>
>>>

事实上,这被证明是一个合理有效的测试。在我们提交身份验证表单后,我们将检查该 URL 是否与帐户管理 URL(auth . telegraph . co . uk/customer-portal/My Account/index . HTML)匹配,以及 HTML < title >标签是否包含关键字短语“我的帐户”类似地,当我们注销时,我们将检查我们是否被重定向到主页面,并且<标题>标签也被更改。

因此,我们有一种方法通过在 POST 数据请求中提交所需的信息来向网站验证我们自己。我们也可以使用相同的方法来提交大型表单。例如,我们可能想要建立一个自动检查来测试我们网站或评论系统的注册功能。

Nagios 系统的检查脚本与我们为导航测试编写的脚本非常相似。清单 8-5 显示了完整的脚本。

清单 8-5 。站点登录/注销检查脚本

#!/usr/bin/env python

import sys
import urllib2, urllib
import time
from BeautifulSoup import BeautifulSoup
from optparse import OptionParser

NAGIOS_OK = 0
NAGIOS_WARNING = 1
NAGIOS_CRITICAL = 2
WEBSITE_LOGON  = 'https://auth.telegraph.co.uk/sam-ui/login.htm'
WEBSITE_LOGOFF = 'https://auth.telegraph.co.uk/sam-ui/logoff.htm'
WEBSITE_USER = 'user@example.com'
WEBSITE_PASS = 'secret'

def test_logon_logoff():
    opener = urllib2.build_opener(urllib2.HTTPCookieProcessor())
    urllib2.install_opener(opener)
    data = urllib.urlencode({'email': WEBSITE_USER, 'password': WEBSITE_PASS})
    status = []
    try:
        # test logon
        result = opener.open(WEBSITE_LOGON, data)
        html_logon = result.read()
        soup_logon = BeautifulSoup(html_logon)
        logon_ok = validate_logon(soup_logon.head.title.text, result.geturl())
        result.close()
        # test logoff
        result = opener.open(WEBSITE_LOGOFF)
        html_logoff = result.read()
        soup_logoff = BeautifulSoup(html_logoff)
        logoff_ok = validate_logoff(soup_logoff.head.title.text, result.geturl())
        result.close()

        if logon_ok and logoff_ok:
            status = [NAGIOS_OK, 'Logon/logoff operation']
        else:
            status = [NAGIOS_CRITICAL, 'ERROR: Failed to logon and then logoff to the web site']
    except:
        status = [NAGIOS_CRITICAL, 'ERROR: Failure in the logon/logoff test']
    return status

def validate_logon(title, redirect_url):
    result = True
    if title.find('My Account') == -1:
        result = False
    if redirect_url != 'https://auth.telegraph.co.uk/customer-portal/myaccount/index.html':
        result = False
    return result

def validate_logoff(title, redirect_url):
    result = True
    if title.find('My Account') != -1:
        result = False
    if redirect_url != 'http://www.telegraph.co.uk/':
        result = False
    return result

def main():
    parser = OptionParser()
    parser.add_option('-w', dest='time_warn', default=3.8, help="Warning threshold in seconds, 
    defaul: %default")
    parser.add_option('-c', dest='time_crit', default=5.8, help="Critical threshold in seconds, 
    default: %default")
    (options, args) = parser.parse_args()
    if float(options.time_crit) < float(options.time_warn):
        options.time_warn = options.time_crit
    start = time.time()
    code, message = test_logon_logoff()
    elapsed = time.time() - start
    if code != 0:
        print message
        sys.exit(code)
    else:
        if elapsed < float(options.time_warn):
            print "OK: Performed %s sucessfully in %f seconds" % (message, elapsed)
            sys.exit(NAGIOS_OK)
        elif elapsed < float(options.time_crit):
            print "WARNING: Performed %s sucessfully in %f seconds" % (message, elapsed)
            sys.exit(NAGIOS_WARNING)
        else:
            print "CRITICAL: Performed %s sucessfully in %f seconds" % (message, elapsed)
            sys.exit(NAGIOS_CRITICAL)

if __name__ == '__main__':
    main()

我们需要将这个脚本添加到 commands.cfg 文件中,并在 Nagios 配置文件中创建适当的主机、主机组、服务和服务组定义,就像我们处理站点导航脚本一样。一旦我们添加了这个配置,我们重新启动 Nagios 进程,过一会儿我们应该会在 Nagios 控制台中看到 check 状态。

用请求模块简化 HTTP 客户端

在上一节中,我们使用标准 Python 库 urllib 和 urllib2 为我们发出所有 web 请求。不幸的是,这些库有时很难使用,API 可能会变得有点神秘,特别是在处理更复杂的任务时,比如维护会话 cookies。

对我们来说幸运的是,Kenneth Reitz 还发现这两个库使用起来相当痛苦,他非常沮丧,于是决定编写一个更好、更简单的 HTTP 客户端库版本。因此,在 2011 年初,他编写了 requests 库的初始版本,该版本非常受欢迎,因为它提供了一种更加优雅和高效的方式来处理 web 请求。

正在安装请求库

大多数 Linux 发行版的默认软件包中都有一个 requests 模块。例如,在 Fedora 系统上,您可以运行以下命令来安装它:

$ sudo yum install python-requests

或者,您可以使用 PyPI 包:

$ sudo pip install requests

requests 库依赖于另一个非标准库 urllib3,但它包含在软件包中,因此您不需要单独安装它。因为这些包是捆绑在一起的,所以您也不需要担心包的版本。

基本用法

请求模块如此受欢迎的原因之一是它不需要编写任何样板代码。我们需要做的就是导入模块并调用一个与 HTTP 方法名相匹配的帮助函数。下面是我们检索 BBC 新闻主网页并检查 HTML 文档大小的方法:

>>> import requests
>>> result = requests.get('http://www.bbc.co.uk/news')
>>> len(result.text)
145858
>>>

同样容易的是,我们可以发出 POST、DELETE 和 HEAD 请求,因为它们都作为助手函数公开。例如,HEAD 请求可用于获取有关网页的元数据,而无需下载页面的全部内容:

>>> result = requests.head('http://www.bbc.co.uk/news/')
>>> len(result.text)
0
>>> result.status_code
200
>>> for k, v in result.headers.iteritems():
...   print "%s: %s" % (k, v)
...
x-lb-nocache: true
content-length: 145858
content-type: text/html
content-language: en-GB
set-cookie: BBC-UID=f5933797120e92467907670df14f1beb2510f8d4b4f4d11e3a3144ee92a8ee880python-requests/2.3.0%20CPython/2.7.5%20Darwin/13.2.0; expires=Wed, 16-May-18 09:38:46 GMT; path=/; 
domain=.bbc.co.uk
expires: Sat, 17 May 2014 09:38:37 GMT
vary: X-CDN
server: Apache
connection: keep-alive
x-cache-hits: 9
x-cache-action: HIT
cache-control: private, max-age=0, must-revalidate
date: Sat, 17 May 2014 09:38:46 GMT
x-cache-age: 9
>>>

正如您所看到的,headers 属性允许我们访问 HTTP 响应头,非常类似于 urllib2 实现的 info()方法。但不是返回 httplib。HTTPMessage 类实例,headers 属性是一个简单的 Python 字典,我们可以直接迭代和操作它。

现在,如果你仔细看看上面的两个例子,你会注意到一些有趣的事情。一个是 content-length 头包含表示网页大小的数字。因此,您可以使用这些信息来检测网页内容的变化。您还应该注意到,当我使用 GET 方法检索网页时,我没有在 URL 后面附加“/”符号,而是使用 HEAD 方法。这只是因为 requests.get()函数跟踪所有重定向。您可以看到实际检索到的 URL 与我们请求的不同。响应对象还包含完整的重定向历史,因此您可以看到所有中间 URL:

>>> result = requests.get('http://www.bbc.co.uk/news')
>>> result.url
u'http://www.bbc.co.uk/news/'
>>> result.history
[<Response [301]>]
>>> for r in result.history:
...   print r.url
...
http://www.bbc.co.uk/news
>>>

相反,requests.head()函数不遵循重定向:

>>> result = requests.head('http://www.bbc.co.uk/news')
>>> result.url
u'http://www.bbc.co.uk/news'
>>> result.status_code
301
>>>

要登录到 Telegraph 网站,我们需要使用 HTTP POST 请求将表单数据(电子邮件和密码)发送到网站:

>>> form_data = {'email': 'user@example.com', 'password': 'secret'}
>>> result = requests.post('https://auth.telegraph.co.uk/sam-ui/login.htm', data=form_data)
>>> result.url
u'https://auth.telegraph.co.uk/customer-portal/myaccount/index.html'
>>>

不过,我们还有一个小问题。在两次请求之间,网站不会记住我们,随后尝试检索帐户页面会将我们重定向回主登录页面:

>>> form_data = {'email': 'user@example.com', 'password': 'secret'}
>>> result = requests.post('https://auth.telegraph.co.uk/sam-ui/login.htm', data=form_data)
>>> result.url
u'https://auth.telegraph.co.uk/customer-portal/myaccount/index.html'
>>> result = requests.get('https://auth.telegraph.co.uk/customer-portal/myaccount/index.html')
>>> result.url
u'https://auth.telegraph.co.uk/sam-ui/login.htm?redirectTo=http%3A%2F%2Fauth.telegraph.co.uk%2Fcustomer-portal%2Fmyaccount%2Findex.html&logintype=tmg'
>>>

众所周知,网站使用存储在 cookies 中的信息来识别我们的身份。对于 urllib 和 urllib2,我们必须编写大量样板代码来实现 cookie 持久性。requests 模块简化了这个过程——我们需要做的就是创建一个 Session 类的实例,并使用它来代替主 requests 模块:

>>> session = requests.Session()
>>> form_data = {'email': 'user@example.com', 'password': 'secret'}
>>> result = session.post('https://auth.telegraph.co.uk/sam-ui/login.htm', data=form_data)
>>> result.url
u'https://auth.telegraph.co.uk/customer-portal/myaccount/index.html'
>>> result = session.get('https://auth.telegraph.co.uk/customer-portal/myaccount/index.html')
>>> result.url
u'https://auth.telegraph.co.uk/customer-portal/myaccount/index.html'
>>>

可以看到,requests 模块提供了一种更简洁的方式来处理高级 HTTP 协议操作。有关请求模块高级使用的更多信息,请访问:requests.readthedocs.org/en/latest/

重写站点登录检查脚本

现在让我们使用请求模块重写登录/注销检查脚本,如清单 8-6 : 所示

清单 8-6 。使用请求模块的站点登录/注销检查脚本

#!/usr/bin/env python

import sys
import urllib2, urllib
import time
import requests
from BeautifulSoup import BeautifulSoup
from optparse import OptionParser

NAGIOS_OK = 0
NAGIOS_WARNING = 1
NAGIOS_CRITICAL = 2
WEBSITE_LOGON  = 'https://auth.telegraph.co.uk/sam-ui/login.htm'
WEBSITE_LOGOFF = 'https://auth.telegraph.co.uk/sam-ui/logoff.htm'
WEBSITE_USER = 'user@example.com'
WEBSITE_PASS = 'secret'

def test_logon_logoff():
    session = requests.Session()
    form_data = {'email': WEBSITE_USER, 'password': WEBSITE_PASS}
    status = []
    try:
        # test logon
        result = session.post(WEBSITE_LOGON, data=form_data)
        html_logon = result.text
        soup_logon = BeautifulSoup(html_logon)
        logon_ok = validate_logon(soup_logon.head.title.text, result.url)
        # test logoff
        result = session.get(WEBSITE_LOGOFF)
        html_logoff = result.text
        soup_logoff = BeautifulSoup(html_logoff)
        logoff_ok = validate_logoff(soup_logoff.head.title.text, result.url)

        if logon_ok and logoff_ok:
            status = [NAGIOS_OK, 'Logon/logoff operation']
        else:
            status = [NAGIOS_CRITICAL,
                      'ERROR: Failed to logon and then logoff to the web site']
    except:
        status = [NAGIOS_CRITICAL, 'ERROR: Failure in the logon/logoff test']
        import traceback
        traceback.print_exc()
    return status

def validate_logon(title, redirect_url):
    result = True
    if title.find('My Account') == -1:
        result = False
    if redirect_url != 'https://auth.telegraph.co.uk/customer-portal/myaccount/index.html':
        result = False
    return result

def validate_logoff(title, redirect_url):
    result = True
    if title.find('My Account') != -1:
        result = False
    if redirect_url != 'http://www.telegraph.co.uk':
        result = False
    return result

def main():
    parser = OptionParser()
    parser.add_option('-w', dest='time_warn', default=3.8,
                      help="Warning threshold in seconds, defaul: %default")
    parser.add_option('-c', dest='time_crit', default=5.8,
                      help="Critical threshold in seconds, default: %default")
    (options, args) = parser.parse_args()
    if float(options.time_crit) < float(options.time_warn):
        options.time_warn = options.time_crit
    start = time.time()
    code, message = test_logon_logoff()
    elapsed = time.time() - start
    if code != 0:
        print message
        sys.exit(code)
    else:
        if elapsed < float(options.time_warn):
            print "OK: Performed %s sucessfully in %f seconds" % (message, elapsed)
            sys.exit(NAGIOS_OK)
        elif elapsed < float(options.time_crit):
            print "WARNING: Performed %s sucessfully in %f seconds" % (message, elapsed)
            sys.exit(NAGIOS_WARNING)
        else:
            print "CRITICAL: Performed %s sucessfully in %f seconds" % (message, elapsed)
            sys.exit(NAGIOS_CRITICAL)

if __name__ == '__main__':
    main()

摘要

在这一章中,我们已经了解了超越简单 HTTP 进程检查的网站监控脚本。这些测试模拟标准用户行为,并实际测试 web 应用逻辑。需要记住的要点:

  • 您可以使用标准的 Python urllib2 模块来访问 web 内容。
  • urllib2 库提供了无缝管理 cookies 的附加处理程序。
  • 如果可用,您可以使用请求模块来执行高级 HTTP 请求操作。
  • 你可以用漂亮的 Soup 库解析 HTML 文档。
  • 通过基于标准 UNIX 进程通信机制的 API,很容易将应用与 Nagios 监控系统集成在一起。
  • 你可以在官方文档中找到关于 Nagios API 的详细信息,可以在www.nagios.org/documentation/找到。

九、管理和监控子系统

这是四章中的第一章,我将向您展示如何构建一个简单的分布式监控系统。在第一部分中,我演示了如何构建一个监控服务器组件。该组件负责向所有监控代理发送查询,调度请求,并将收集的数据存储在本地数据库中。本章将讨论三个主题:数据建模、进程间通信和多线程编程。在数据建模部分,我们将研究一些数据库设计和建模方法。稍后我们将研究 XML-RPC 协议和支持它的 Python 库。最后,我们将看看用 Python 进行多线程编程。

设计

在开始实现之前提出某种设计是很重要的,尤其是在编写分布式系统的代码时。我需要建立两个主要领域:监视系统将由哪些组件组成,以及它将操作哪些数据对象。

组件

从需求收集练习中,我知道系统将是集中式的——也就是说,将有多个代理向主监控服务器报告。因此,至少需要两个不同的组件:监控服务器和监控代理。服务器进程将与客户机通信,并从它们那里检索性能和状态数据。

现在的问题是代理人需要有多聪明。它需要知道如何自己执行所有检查吗?或者它应该有一个可插拔的架构,代理本身只作为一个控制器组件?我将选择代理依赖插件来执行所有检查的架构。代理进程本身只会将服务器请求代理给插件代码,并将结果传回。我将这些插件称为“传感器”,因为这就是它们实际上在做的事情——测量系统的参数。

图 9-1 表示高层组件交互图。以下部分提供了每个组件的更详细的设计说明。

9781484202180_Fig09-01.jpg

图 9-1 。系统组件

监控服务器

监控服务器负责向客户端系统发出请求,并从所有客户端接收传感器读数。从客户机获取数据有两种选择:第一种是让服务器发起连接,第二种是让客户机发起连接。每种方法都有其优点。当客户端启动连接时,服务器端的开销会减少,因为它不需要做任何调度工作。它也更安全,因为不可能请求数据,因此数据将仅由系统接收,该系统在客户端(监控服务器)上注册。

然而,客户端发起的连接的最大缺点是服务器完全无法控制传入的信息流,这会导致服务器过载。理想情况下,应该由服务器决定在什么时间点需要什么信息。例如,一个真正智能的系统会禁用某些没有意义的检查。一个很好的例子是在收到硬盘故障警报后停止卷使用检查;很明显,磁盘故障将导致所有卷检查失败,因此报告潜在问题的症状没有意义。

在我的简单监控系统中,我将使用一种有效的服务器启动的控制机制,但不会牺牲安全模型。服务器进程将向客户端发送通知以提交传感器数据。当客户端收到这样的通知时,它将执行检查并将读数提交回它注册的服务器。所以没有办法从客户端获取数据;它只是一个单向的通信通道,只有“可信的”服务器才能接收结果。

客户端配置以类似的方式完成:客户端接收外部信号以更新其配置(“受信任的”服务器地址或传感器代码),然后启动与服务器的连接以获取所需的详细信息。

监控代理

监控代理进程是完全被动的,仅当它从服务器接收指令时才动作。如前所述,这样的指令可以是提交传感器读数,更新服务器地址,以及从服务器检索新的传感器代码。

当代理被通知提交读数时,它将调用外部工具来执行实际的读数。然后,它将从流程中读取输出,并将其与流程返回代码一起发送回监控服务器。

服务器地址更新命令指示监控代理连接到当前注册的服务器并请求新地址。然后,代理将尝试连接到新地址。如果操作成功,当前服务器地址将被新地址替换;否则地址不会改变。

最后,当代理收到更新其中一个传感器代码的命令时,它将连接回服务器并请求传感器代码存档。服务器将把存档的副本发送回请求的客户机。当归档文件被接收并存储在临时位置时,它被解压缩并执行基本的健全性检查。如果检查成功,旧代码被存档,新代码被部署到适当的位置。

传感器

与其他监视系统不同,在其他监视系统中,传感器或检查包含一些逻辑(例如,正如我们在前一章中看到的,Nagios 检查返回 OK、警告或关键状态消息),我不打算在我的传感器中嵌入任何验证逻辑。毕竟,传感器是用来报告状态的,不能也不应该知道它所报告的情况是否会造成任何危险。由主监控服务器决定读数是否表明系统有任何问题。

这种方法允许进一步扩展检查逻辑,以执行更高级和适应性更强的报告。例如,代替简单的阈值检查,系统可以扩展为趋势检查。即使被观察系统上的负载超过了设定的阈值,它也可能是正常的,因为这就是负载模式。类似地,如果系统报告给定时间段内的负载远低于正常值,则可能表明存在问题,而简单的阈值检查无法检测到这些问题。

数据对象

自然,所有涉及的过程都将消耗或产生(或两者兼有)一些数据。最明显的数据是传感器读数,但也会有配置、已定义的调度设置等等。因此,在编写任何代码之前,我需要对监控系统将要处理的数据进行合理的定义和设计。

将有四种不同类型的数据:

  • 配置数据,描述所有监视代理、传感器及其参数
  • 站点配置数据,它定义了需要在每台服务器上执行哪些检查,以及在哪里可以找到客户端服务器
  • 计划数据,定义检查的时间间隔
  • 性能读取数据,即从客户端服务器上的传感器接收的数据

配置

配置数据包含关于传感器、传感器参数和监控代理的数据。所有可用的代理及其名称和地址都是监控系统配置的一部分。除了所有主机和传感器的简单列表之外,配置还包含关于哪些传感器在哪些监控服务器上可用的信息。

代理服务器的可用硬件资源和配置可能不同,因此必须能够为每个监控代理定义单独的阈值。

性能读数

显然,这是监控系统中的关键数据组件。每个性能读数需要保持它被记录的时间,以便它可以在时间线上被正确地表示。还需要记录数值和传感器应用返回代码,以及节点和传感器标识信息。

站点配置

目前,站点信息将只保存监控服务器地址,但是需要放置占位符,以便将来功能扩展。值得注意的是,站点信息在监控服务器上集中维护,代理服务器将检索这些信息并相应地更新本地配置。

集中存储这些信息的原因是,如果将这些信息存储在一个位置,控制配置会容易得多。当代理需要更新时,单独的进程将发出更新命令,以便自动更新配置。

行程安排

调度配置定义了哪些传感器命令需要在哪些监控代理上以及以什么时间间隔执行。将为每个具有适当间隔设置的代理检查组合保存信息。

该数据类似于 UNIX cron 文件中定义的信息,但是在定义执行时间模式方面没有那么灵活。所有的时间间隔都将是等长的。

数据结构

在上一节中,我简要描述了我将在监控系统中使用的数据结构的高级设计。在本节中,我将创建数据库布局和不同数据库表之间的关系。最后,这些信息将被映射到用于初始化数据库的 SQL 语句。

我用来创建图表和数据模型的建模工具是 MySQL Workbench,这是一个开源应用,你可以从wb.mysql.com下载。MySQL Workbench 是一个强大的数据库和实体关系(ER)可视化设计工具。您可以创建新的可视化设计,并根据设计生成 SQL 脚本。

数据规范化简介

数据规范化 是一种确保数据以防止丢失数据完整性的方式维护的方法。如果数据库结构没有规范化,错误的代码操作、用户数据输入错误或者更新操作期间的系统或应用故障都可能导致数据损坏。我这里指的数据损坏是逻辑损坏,即数据库文件是正确的,但存储的信息可能在逻辑上是不正确的。为了继续数据布局部分,我需要解释一些数据规范化的概念,这样您就可以理解为什么我要以一种特殊的方式组织数据。

假设我想要实现配置部分的一个需求,即存储以下信息项:

  • 关于传感器的信息
  • 传感器选项
  • 监控代理信息

因此,我创建了一个包含以下字段的表:主机名、地址、传感器名称和传感器选项。然后,我输入一些我想在两个监控代理上执行的检查:

hostname         address          sensor name      sensor options 
---------------  ---------------  ---------------  ---------------
my laptop        127.0.0.1        disk_check       free_space 
my laptop        127.0.0.1        memory_check     total 
remote server    192.168.0.1      disk_check       free_space 
remote server    192.168.0.1      memory_check     total

现在,假设我想更新远程服务器的地址字段。因为信息存储在两个不同的行中,所以我需要确保所有的行将被更新。如果由于某种原因,尝试更新数据库的应用未能识别所有行并相应地更新,我可能会在表中得到以下数据:

hostname         address          sensor name      sensor options 
---------------  ---------------  ---------------  ---------------
my laptop        127.0.0.1        disk_check       free_space 
my laptop        127.0.0.1        memory_check     total 
remote server    192.168.0.1      disk_check       free_space 
remote server    192.168.0.2      memory_check     total

从数据库的角度来看,这个数据是正确的,但是不一致:远程服务器现在有两个地址,不清楚哪个是正确的。

这就是数据规范化派上用场的地方。遵循一些简单的规则,您可以将信息拆分到不同的表中,从而消除数据损坏或不规则的可能性。有三种基本的数据规范化形式,每种形式都定义了结构化数据的规则。除了这些形式之外,还开发了许多更高级的规范化形式,但在大多数情况下,它们只引起学术兴趣。

我将从第一个范式开始,它定义了创建表结构的两个重要规则:行必须是惟一的,列中不能有重复组。

第一条规则非常明显,意味着必须有一种唯一标识每一行的方法。唯一键可以是一列或多列的组合。

第二条规则意味着我不能定义携带逻辑上相同信息的多个列。例如,如果我想对每台服务器进行多次检查,并将这些检查添加为附加列来存储该信息,这将违反第二条规则,如下例所示:

hostname    address     sensor1      options1     sensor2        options2 
----------  ----------  -----------  -----------  -------------  -----------
my laptop   127.0.0.1   disk_check   free_space   memory_check   total

单列中也不允许有分组数据;例如,以字符串形式列出选项,如 free_space、swap_space,也会违反这条规则。

当数据满足第一范式的所有规则,并且还满足不是主键的一部分的所有字段依赖于该键的所有字段的要求时,该数据被认为处于第二范式。考虑以下示例:

address          sensor           default option 
---------------  ---------------  ---------------
127.0.0.1        disk_check       free_space 
127.0.0.1        memory_check     total 
192.168.0.1      disk_check       free_space 
192.168.0.2      memory_check     total

单独的地址或传感器字段都不能成为每一行的唯一键,但是地址-传感器的组合可以被认为是每一行的唯一键。所以表符合第一范式。然而,默认选项字段仅取决于传感器,与地址列无关,因此该表不是第二范式。

我需要将数据分成两个表,这样每个表都满足第二范式规则。第一个表列出了每个传感器的默认选项,唯一的关键字是传感器字段。

sensor           default option 
---------------  ---------------
disk_check       free_space 
memory_check     total

第二个表列出了每个节点上的所有检查,其中唯一键是两列的组合。

address          sensor 
---------------  ---------------
127.0.0.1        disk_check 
127.0.0.1        memory_check 
192.168.0.1      disk_check 
192.168.0.2      memory_check

Image 注意值得注意的是,如果第一范式表没有组合键,则自动在第二范式中。

最后,第三种范式要求所有非键字段只依赖于主键。在下面的示例中,我记录了在每个代理上执行了哪些检查。出于本例的目的,假设每分钟只能执行一次检查,因此地址“-”检查时间是唯一的键。

address     check time    sensor         sensor location
----------  ------------  -------------  -----------------
127.0.0.1   10:20         disk_check     /checks/disk
127.0.0.1   10:21         disk_check     /checks/disk 
127.0.0.1   10:22         memory_check   /checks/memory
127.0.0.1   10:23         memory_check   /checks/memory

此表符合第二范式的规则,但仍不在第三范式中。这是因为尽管所有字段都依赖于一个完整的主键,但其中一些字段也依赖于非键字段。特别是,传感器位置取决于传感器场。在有故障的应用中,可能出现同一传感器的传感器位置不同的情况。因此,我需要将 sensor“-”传感器位置拆分到一个单独的表中,以符合第三范式。

一般来说,数据规范化可以防止数据丢失完整性,因此通常被认为是一种好的做法。然而,有时规范化数据会对应用的性能和代码复杂性造成严重影响。如果您完全确定数据变得不规则时不会发生严重的问题——更重要的是,如果您有办法从这种情况中恢复——您可以牺牲数据规范化的完整性来换取速度和代码的简单性。因此,在设计数据结构时,一定要运用自己的判断。

在下面的例子中,我将展示一种情况,在这种情况下,严格遵循规范化规则是不可行的,你可以做出一些妥协。

配置数据

让我们从配置数据开始,它包含关于所有监控代理和分配给它们的传感器检查的信息。有多种方法来组织和设计数据库表。一种正式的方法是将所有列记为一条记录,并从那里开始,应用第一个范式的所有规则。当您完成并拥有第一范式的一个或多个表时,您可以继续应用第二范式规则,直到您获得想要的结果—理想情况下,数据库表为第三范式。

虽然这种方法每次都能完美地工作,但我觉得它有点乏味,因为经过一些练习,你已经知道如何组织表格,并且经历所有正式的步骤只会产生不必要的工作。我发现下面的方法更有效。

想想你的模型中的对象是静态的和独立的。回到配置数据,我看到有两个对象:主机和传感器。现在,我为每个这样的对象创建表。我将从主机条目表开始,表 9-1 。

表 9-1 。主持人参赛作品

|

|

数据类型

|

描述

身份 整数 唯一标识符。
名字 文本 主机的名称。
地址 文本 主机的 IP 地址或完整域名。
港口 文本 运行客户端进程的端口号。

正如您所看到的,这个表已经是第三范式了,完全没有必要对它做任何改进。每个条目都是唯一的,列中没有重复组,只有一个主键字段(名称),其他字段仅依赖于该字段。

Image 注意我需要指出的是,这个表和其他表中的 ID 不被视为唯一字段。当您规范化数据时,字段必须携带合理的信息,而隐藏 id 或时间戳等任意字段不能被视为信息字段,因为它们不构成数据集。它们主要用于参考目的,因为处理整数值比处理文本或其他数据类型的键更快更有效。

现在让我们继续传感器的定义。这将稍微复杂一些,因为从传感器设计中,我已经知道每个传感器可以执行几项检查。例如,磁盘卷传感器可以执行多项检查,如总空间、已用空间、已用信息节点等。您可能希望将所有字段添加到一个表中,以便传感器检查组合成为唯一的关键字段。这对于小型数据集来说可能没问题,但是如果您想要扩展和添加更多字段,这种结构就变得不灵活,您需要重新设计表。根据经验,我建议拆分任何包含多个“”的数据..."属性。回到我的例子,我可以声明每个传感器包含多个检查。因此,如果您将这些信息拆分到两个表中,在大多数情况下,您会发现这一点。表 9-2 是传感器条目表。

表 9-2 。传感器条目

|

|

数据类型

|

描述

身份 整数 唯一标识符
名字 文本 传感器的名称

检查表(或称探头,本书中我称之为)如表 9-3 所示;它包含更多信息,还引用了传感器条目。

表 9-3 。探针条目

|

|

数据类型

|

描述

身份 整数 唯一标识符。
名字 文本 探测器的名称。
参数 文本 要传递给传感器检查命令的字符串。
警告 漂浮物 该特定探测器的默认阈值,处于警告级别。
错误 漂浮物 此特定探测器在错误级别的默认阈值。
传感器 id 整数 传感器记录的 ID。正如我提到的,在正式符号中,这应该是传感器的名称,但是为了简单和灵活,我们使用唯一的行 id。

现在仔细查看表 9-2 和表 9-3 ,看看是否能发现任何违反规范化表单规则的地方。传感器表这么简单,毫无疑问是第三范式,但是探头表呢?乍一看,它看起来很好,但是仔细观察你会发现我有重复的组,所以这个表甚至不是第一范式!有两个字段可以有效地定义相似类型的信息:阈值字段、警告和错误。我必须创建一个新表来保存阈值定义,包括警告、错误,如果我想添加的话,还可能包括其他信息性和严重性。然而,这带来了另一个复杂性:我不能在那个表中放入任何值,因为阈值是特定于每个探针的。因此,我需要定义另一个表,将探测和阈值记录联系在一起,并添加值列。我现在有两个选择:重组表,引入两个新的表,并且还要面对复杂得多的代码来处理这个问题;或者接受两个阈值水平的限制。因为我在这里构建的监控系统非常简单,并且我不需要太多的阈值粒度,所以我选择了第二个选项。

定义完静态组件后,继续处理关系。我的监控系统将在所有被监控的主机上执行探测读取,因此我需要定义这个关系。显然,并不是所有的探测器都适用于所有的主机,所以我必须创建另一个表来定义探测器到主机的映射。我还将允许基于每个主机的阈值覆盖。它只是表中的一个占位符,阈值优先的逻辑必须在代码级别实现。参见表 9-4 。

表 9-4 。探测器到主机的映射

|

|

数据类型

|

描述

编号 整数 唯一标识符。
探测器 id 整数 探测记录的 ID。
主机标识 整数 必须在其上执行探测的主机记录的 ID。
警告 浮动 警告阈值覆盖条目的占位符。不得为必填字段,因为如果留空,将采用默认值。
错误 浮动 错误阈值覆盖条目的占位符。不得为必填字段,因为如果留空,将采用默认值。

工作特性

性能数据表中唯一的附加信息是监控代理返回的读数和执行测量时的时间戳。其余信息可从主机-探测器映射表中找到(表 9-4 )。探头读数表(表 9-5 )包含进行测量的主机的详细信息、传感器的类型以及准确的检查参数。

表 9-5 。探针读数

|

|

数据类型

|

描述

编号 整数 唯一标识符。
hostprobe_id 整数 主机到探测器映射表中记录的 ID。
时间戳 文本 时间戳表示进行测量的时间。
探测值 浮动 探测代码返回的值。
S7-1200 可编程控制器 整数 传感器代码的返回代码。

行程安排

调度数据由两个互不相关的不同部分组成:定义在特定时间间隔需要执行哪些探测的调度数据;以及票据队列,用于保存票据调度程序进程的指令。让我们仔细看看它们。

探测调度表(表 9-6 )保存静态数据,该数据引用了探测器到主机映射表中的各个记录。这是查找有关传感器名称和需要执行的特定探头参数的信息所必需的。它还指示需要联系哪个监控代理(或主机)。除了这些信息之外,调度表还包含 interval 参数,该参数指示探测器读数之间的时间段。

表 9-6 。探测器调度

|

|

数据类型

|

描述

编号 整数 唯一标识符。
hostprobe_id 整数 主机到探测器映射表中记录的 ID。
探测间隔 整数 探测检查的时间间隔,以分钟表示。

标签队列表(表 9-7 )包含关于要执行的探针的动态数据。该表由调度程序进程或任何其他需要从代理获取性能数据的进程填充。dispatcher 进程从表中读取所有条目,并向监控代理发送请求。一旦发送了请求,记录就被更新为已分派。这样做是为了防止重复请求。最后,当监控服务器进程收到传感器数据和票据编号时,它会从表中删除记录。

表 9-7 。探针购票队列

|

|

数据类型

|

描述

编号 整数 唯一标识符。
hostprobe_id 整数 主机到探测器映射表中记录的 ID。该记录包含执行传感器查询呼叫所需的所有信息。
时间戳 文本 时间戳记录指示票据何时被放入队列。当请求已经被分派,但是结果从未返回时,用于检测情况。
派遣 整数 一个标志,指示票证是否已被分派给相应的监控代理。

站点配置

站点配置信息分为两个表:系统范围的参数和主机特定的参数。在开发应用时,我希望能够灵活地定义新的自定义参数,因此不是将设置固定到表列,而是将每个设置定义为表中的一个键值对。列唯一地标识了参数名,该值是默认值,可以在将参数映射到特定主机的第二个表中被覆盖。这种方法允许我拥有一个两级继承系统,就像我拥有传感器阈值条目一样。

所以第一个表包含键值记录,如表 9-8 所示。

表 9-8 。系统参数

|

|

数据类型

|

描述

编号 整数 唯一标识符。
名字 文本 系统参数设置的唯一名称。
价值 文本 该项的默认值,可以用作系统范围的设置,也可以根据每个特定主机条目的需要进行覆盖。所有值都存储为文本,并且必须在运行时将类型转换为适当的类型。

第二个表(表 9-9 )参考系统参数表并允许覆盖设置。

表 9-9 。特定于主机的参数

|

|

数据类型

|

描述

编号 整数 唯一标识符。
金字塔吗 整数 参数表中记录的 ID。
主机标识 整数 主机表记录的 ID。这允许对每个特定的主机应用特定的设置。
派遣 文本 主机特定的参数值。

在 ER 图中表示信息

现在,我已经定义了将在监控服务器上使用的所有表。定义了每个字段以及表之间的关系。尽管我只有几个表,但有时仍然很难找到并可视化不同表之间的关系。为了使事情变得简单,尤其是在编写 SQL 查询时,画一个实体关系(er)图是一个好主意,这是在第五章中引入的概念。

图 9-2 是我为之前定义的表格画的 ER 图。

9781484202180_Fig09-02.jpg

图 9-2 。服务器数据结构的 ER 图

沟通流程

我在这里建立的监控实际上是一个分布式计算系统。它具有大多数分布式系统属性—控制器进程(调度程序组件)负责向处理节点(监控代理)发送作业请求,最后将信息提供回数据处理组件(监控服务器)。

与任何分布式系统一样,定义通信流和交换信息的方法至关重要。实现进程通信的方法有很多——SOAP(在第二章中详细讨论)、REST(表述性状态转移)、XML-RPC 等等。我将使用交换信息和调用远程方法的 XML-RPC 方法,因为它使用起来相对简单,而且 Python 自带内置的 XML-RPC 客户端和服务器库。

用于信息交换的 XML-RPC

XML-RPC 是一种执行远程过程调用的方法,通过这种方法,一个进程向远程系统发送消息,并使其执行特定的功能。XML-RPC 协议类似于 SOAP 协议,但是结构更简单。事实上,最初的 XML-RPC 是 SOAP 协议的前身。XML-RPC 消息使用 XML 编码,并使用 HTTP 作为传输机制。

结构

XML-RPC 调用消息具有相对简单的结构,并且它们只允许一种数据序列化方法。下面是一个 XML-RPC 过程调用消息的例子:

<methodCall>
<methodName>cmd_get_sensor_code</methodName>
<params>
<param>
<value><string>disk</string></value>
</param>
</params>
</methodCall>

尽管在这个例子中只有一个参数被传递给远程过程,但是 XML-RPC 允许多个参数嵌套在数组或列表中,这意味着它允许将复杂的对象和结构作为请求或响应参数来传输。XML-RPC 协议支持主要的数据类型,比如数组、base64 编码的数据流、布尔值、日期时间对象、双精度浮点数、整数、字符串、结构和空对象。

以下是对传感器更新请求的响应示例,响应对象是以 base64 编码的二进制数据对象,因此可以封装在 XML 消息中:

<methodResponse>
<params>
<param>
<value><base64>
QlpoOTFBWSZTWbXv/NUAAad/hP6YQIB+9v/vOw5fCv/v3+4AAQAIQAIdVWSrWEoknonqmYpo0wmT
TEANBoPUGI0NB6nqMmnqCVNBMSaGp6Jp6I9IAAGgAZNABoADhppghkNNMjJhANNAGE0aZMACBoJJ
Knp6JPJNlD0T1PU0bUADQAaGQABoDT1Mfhn03axWSSsQghGnU545FVU08YoQcAwgFBiiK7+M3lmm
9b2lcEqqqb5TUIVrK2vGUFTK6AEqDJIMQwCK7At2EVF6xHAj3e5I33xZm8d8+FQEApNQvgxJEflD
nwilZzqaPMelGNtGl27o7Ss51Fl0ebZuhJZOQ5aVjg6gZIyrzq6MNttwJpbNuJHGMzNiJQ4RMSkQ
23GVRwYVCyti8yqZ1ppjGGBr6lG4QY328gCTLALIZNlYNq01p8U48MsCHPFLznOVKisKYsE7nubL
K1tdUnEQ4XKbibYRsVQSsDnwYtshI+I1gkr2DWoihkgeB4fejEhqPRLzISHihEn0F5Ge4sqCpMgt
8IAyfCEqEyEetRVc/QnBQOrV6dA18m9GHtJOGkikwdjGTpgGdAMTw5FqKHHMHT1ucTvZcRWOurze
q2ndOEjXSliyjqWyXlD5/aWSwKy5UhjUKjbGhyRbVUHIEZQSekThXKgZNUq1Mi7eXZddjBdKRigi
F+RgMBo1LwT5iqJoUSZtCokLR/T5dLx2ySEQZA+ZaARBHaPwlDRNtiF25NTtoLgTsWpDJQRoKwSI
UKYILTRv2giFmqLzY1K0awTkMRrztnSqDbUNsKeNQ8UpddfLkXFdEA/xdyRThQkLXv/NUA==
</base64></value>
</param>
</params>
</methodResponse>

Python 支持

Python 内置了对 XML-RPC 协议的支持,允许您编写简单的客户端和服务器应用,而无需安装任何额外的包。

客户端库称为 xmlrpclib,它提供了访问服务和创建 XML-RPC 调用消息的基本功能。为了访问服务器,首先需要创建一个代理对象,然后用它来调用远程过程。在大多数情况下,您必须知道您将使用的过程的名称,因为 XML-RPC 不使用正式的服务定义语言,如 WSDL。如果您调用保留方法 systems.listMethods(),一些服务器可能会提供可用过程的列表,因此值得一试,但不要依赖此方法。在初始化远程连接时,您还需要指定一个端点 URL,例如本例中使用的/xmlrpc/

>>> import xmlrpclib
>>> proxy = xmlrpclib.ServerProxy('http://192.168.1.65:8081/xmlrpc/')
>>> url = proxy.cmd_get_new_monitor_url('myhost')
>>> print url
http://localhost:8081/xmlrpc/
>>>

Python 也有一个基本的 XML-RPC 服务器,它允许您编写函数并使它们对远程客户端可用。创建 XML-RPC 服务器的工作流程非常简单——您需要导入服务器类,创建服务器对象,用您希望它监听的服务器地址和端口号初始化它,向服务器注册您的函数,最后运行服务器:

>>> from SimpleXMLRPCServer import SimpleXMLRPCServer as s
>>> def hello(name):
... return "Hello, %s!" % name
... 
>>> server = s(('localhost', 8080))
>>> server.register_function(hello, 'hello')
>>> server.serve_forever()

然后,您可以使用客户端库连接和使用公开的函数:

>>> import xmlrpclib
>>> proxy = xmlrpclib.ServerProxy('http://localhost:8080/')
>>> print proxy.hello('John')
Hello, John!
>>>

另一个有用的方法是 dumps,您可以使用它将基本的 Python 数据类型“封装”到 XML 结构中。这用于在将消息发送到远程服务器之前构造消息:

>>> import xmlrpclib
>>> print xmlrpclib.dumps(('temperature', 20))
<params>
<param>
<value><string>temperature</string></value>
</param>
<param>
<value><int>20</int></value>
</param>
</params>
>>>

樱桃派

尽管内置的 XML-RPC 服务器使用起来非常简单,但我需要一个更具可伸缩性的解决方案,以防我必须支持具有数百个提交结果的监控代理的广泛分布的系统。默认情况下,SimpleXMLRPCServer 库只启动一个进程,因此不是多线程的,这意味着只能与它建立一个连接,所有其他客户端都必须等待。这就是奇瑞皮的用武之地。

简而言之,CherryPy 是一个 web 应用框架,允许快速开发和部署 web 应用。它是用 Python 编写的,毫不奇怪,它支持的 web 开发语言也是 Python。

除了作为 web 应用框架,CherryPy 还是一个符合 RFC2616 的 web 服务器,RFC 2616 定义了 HTTP 1.1 协议。CherryPy 可以单独用作高度可配置和可定制的 web 服务器,也可以与任何支持 WSGI 接口的 web 服务器结合使用。

我选择 CherryPy 作为 HTTP 服务器和公开 XML-RPC 函数的简单框架的原因是,它支持开箱即用的多套接字连接和多线程,因此我不必编写任何额外的代码。此外,该框架提供了一种简单的配置方法。

使用 CherryPy 非常简单。下面是一个简单的 web 应用示例,它只打印出一条静态消息:

import cherrypy
from datetime import datetime

class CurrentTime(object):
def index(self):
return str(datetime.now())
index.exposed = True

cherrypy.quickstart(CurrentTime())

这就是显示当前时间的 web 服务所需的全部内容。如果您想服务于 XML-RPC 过程,事情并没有很大的不同。你只需要从 _cptools 继承你的主类。XMLRPCController 类并使用@cherrypy.expose decorator 函数,该函数有效地将每个函数注册到框架中,并使其作为远程过程可用。

让我们使用 CherryPy 重写 hello RPC 服务:

import cherrypy
from cherrypy import _cptools

class Root(_cptools.XMLRPCController):
@cherrypy.expose
def hello(self, name):
return "Hello, %s" % name

cherrypy.quickstart(Root(), '/')

正如您所看到的,该框架给默认的内置实现增加了很少的开销,但是作为交换,它提供了一个多线程的、完全可配置的 web 服务器,以及在 Apache 等企业级 web 服务器后面使用它的能力。

您可以使用 Python 安装程序 pip 通过以下命令安装最新的 CherryPy 包:

pip install cherrypy

服务器进程

服务器进程不发起任何连接;它只接受传入的请求。实际上,通信是由调度程序或其他工具发起的,这些工具指示客户端需要执行一些操作,然后向服务器报告或向服务器请求其他详细信息。因为服务器进程正在处理大型数据集—它存储探测器读数并维护客户端配置数据—它将使用一个轻量级数据库引擎:SQLite3。

将数据存储在 SQLite3 数据库中

SQLite3 是一个轻量级数据库管理系统。它是完全独立的,事实上只是一组库,它们允许应用使用 SQL 语法来存储和操作数据,这意味着您不需要设置和配置任何数据库服务器。不需要配置 SQLite3 您可以直接“连接”到数据库文件。Python 内置了对 SQLite3 的支持;您只需要导入库并开始使用它。

初始化数据库文件

您可以从 Python 应用创建数据库,也可以使用 SQL 指令编写文件并从命令行初始化数据库。或者,SQLite3 提供了一个命令行工具来与数据库交互。

清单 9-1 显示了初始化 SQL 语句的完整序列;这将使进一步的阅读更加容易,因为服务器代码将包含大量 SQL 语句,并且您确实需要手头有表模式和初始数据。

清单 9-1 。服务器数据库的初始化 SQL 命令

-- ******************************************
-- Table: SENSOR
-- Description: List of all available sensors

DROP TABLE IF EXISTS sensor;

CREATE TABLE sensor (
    id INTEGER PRIMARY KEY,
    name TEXT
);

INSERT INTO sensor VALUES (1, 'cpu_load');
INSERT INTO sensor VALUES (2, 'memory');
INSERT INTO sensor VALUES (3, 'processes');

-- ******************************************
--       Table: PROBE
-- Description: Adds parameter list to sensor command
--              and defines default thresholds

DROP TABLE IF EXISTS probe;

CREATE TABLE probe (
    id INTEGER PRIMARY KEY,
    sensor_id INTEGER,
    name TEXT,
    parameter TEXT,
    warning FLOAT,
    error FLOAT,
    FOREIGN KEY (sensor_id) REFERENCES sensor(id)
);

INSERT INTO probe VALUES ( 1, 1, 'Idle CPU %', 'idle', NULL, NULL);
INSERT INTO probe VALUES ( 2, 1, 'Used CPU %', 'used', NULL, NULL);
INSERT INTO probe VALUES ( 3, 1, 'User CPU %', 'user', NULL, NULL);
INSERT INTO probe VALUES ( 4, 1, 'System CPU %', 'system', NULL, NULL);
INSERT INTO probe VALUES ( 5, 1, 'IO Wait CPU %', 'iowait', NULL, NULL);
INSERT INTO probe VALUES ( 6, 2, 'Free memory, %', 'free_pct', NULL, NULL);
INSERT INTO probe VALUES ( 7, 2, 'Free memory, in bytes', 'free', NULL, NULL);
INSERT INTO probe VALUES ( 8, 2, 'Used memory, %', 'used_pct', NULL, NULL);
INSERT INTO probe VALUES ( 9, 2, 'Used memory, in bytes', 'used', NULL, NULL);
INSERT INTO probe VALUES (10, 2, 'Used swap, %', 'swap_used_pct', NULL, NULL);
INSERT INTO probe VALUES (11, 3, '1 min load average', 'load1', NULL, NULL);
INSERT INTO probe VALUES (12, 3, '5 min load average', 'load5', NULL, NULL);
INSERT INTO probe VALUES (13, 3, '15 min load average', 'load15', NULL, NULL);
INSERT INTO probe VALUES (14, 3, 'Running processes', 'running', NULL, NULL);
INSERT INTO probe VALUES (15, 3, 'Total processes', 'total', NULL, NULL);

-- ******************************************
--       Table: HOST
-- Description: List of all monitoring agents

DROP TABLE IF EXISTS host;

CREATE TABLE host (
    id INTEGER PRIMARY KEY,
    name TEXT,
    address TEXT,
    port TEXT
);

INSERT INTO host VALUES (1, 'My laptop', 'localhost', '8080');

-- ******************************************
--       Table: HOSTPROBE
-- Description: Maps available probes to the hosts
--              overrides thresholds if required

DROP TABLE IF EXISTS hostprobe;

CREATE TABLE hostprobe (
    id INTEGER PRIMARY KEY,
    probe_id INTEGER,
    host_id INTEGER,
    warning FLOAT,
    error FLOAT,
    FOREIGN KEY (probe_id) REFERENCES probe(id),
    FOREIGN KEY (host_id) REFERENCES host(id)
);

INSERT INTO hostprobe VALUES ( 1, 1, 1, NULL, NULL);
INSERT INTO hostprobe VALUES ( 2, 2, 1, NULL, NULL);
INSERT INTO hostprobe VALUES ( 3, 3, 1, NULL, NULL);
INSERT INTO hostprobe VALUES ( 4, 4, 1, NULL, NULL);
INSERT INTO hostprobe VALUES ( 5, 5, 1, NULL, NULL);
INSERT INTO hostprobe VALUES ( 6, 6, 1, NULL, NULL);
INSERT INTO hostprobe VALUES ( 7, 7, 1, NULL, NULL);
INSERT INTO hostprobe VALUES ( 8, 8, 1, NULL, NULL);
INSERT INTO hostprobe VALUES ( 9, 9, 1, NULL, NULL);
INSERT INTO hostprobe VALUES (10, 10, 1, NULL, NULL);
INSERT INTO hostprobe VALUES (11, 11, 1, NULL, NULL);
INSERT INTO hostprobe VALUES (12, 12, 1, NULL, NULL);
INSERT INTO hostprobe VALUES (13, 13, 1, NULL, NULL);
INSERT INTO hostprobe VALUES (14, 14, 1, NULL, NULL);
INSERT INTO hostprobe VALUES (15, 15, 1, NULL, NULL);

-- ******************************************
--       Table: TICKETQUEUE
-- Description: Holds all pendiing and sent tickets
--              tickets are removed when the sensor reading arrive

DROP TABLE IF EXISTS ticketqueue;

CREATE TABLE ticketqueue (
    id INTEGER PRIMARY KEY,
    hostprobe_id INTEGER,
    timestamp TEXT,
    dispatched INTEGER,
    FOREIGN KEY (hostprobe_id) REFERENCES hostprobe(id)
);

-- ******************************************
--       Table: PROBEREADING
-- Description: Stores all readings obtained from the monitoring agents

DROP TABLE IF EXISTS probereading;

CREATE TABLE probereading (
    id INTEGER PRIMARY KEY,
    hostprobe_id INTEGER,
    timestamp TEXT,
    probe_value FLOAT,
    ret_code INTEGER,
    FOREIGN KEY (hostprobe_id) REFERENCES hostprobe(id)
);

-- ******************************************
--       Table: PROBINGSCHEDULE
-- Description: Defines execution intervals for the probes

DROP TABLE IF EXISTS probingschedule;

CREATE TABLE probingschedule (
    id INTEGER PRIMARY KEY,
    hostprobe_id INTEGER,
    probeinterval INTEGER,
    FOREIGN KEY (hostprobe_id) REFERENCES hostprobe(id)
);

INSERT INTO probingschedule VALUES (1, 11, 1);
INSERT INTO probingschedule VALUES (2, 15, 1);
INSERT INTO probingschedule VALUES (3, 8, 5);
INSERT INTO probingschedule VALUES (4, 10, 5);

-- ******************************************
--       Table: SYSTEMPARAMS
-- Description: Defines system configuration parameters

DROP TABLE IF EXISTS systemparams;

CREATE TABLE systemparams (
    id INTEGER PRIMARY KEY,
    name TEXT,
    value TEXT
);

INSERT INTO systemparams VALUES (1, 'monitor_url', 'http://localhost:8081/xmlrpc/');

-- ******************************************
--       Table: HOSTPARAMS
-- Description: Assigns system parameters to the hosts
--              allows to override the default values

DROP TABLE IF EXISTS hostparams;

CREATE TABLE hostparams (
    id INTEGER PRIMARY KEY,
    host_id INTEGER,
    param_id INTEGER,
    value TEXT,
    FOREIGN KEY (host_id) REFERENCES host(id),
    FOREIGN KEY (param_id) REFERENCES systemparams(id)
);

INSERT INTO hostparams VALUES (1, 1, 1, 'http://localhost:8081/xmlrpc/');

将这些命令保存到一个文本文件中,或者从本书的源代码库中下载代码,并运行以下命令来创建初始数据库文件:

sqlite3 –init monitor_db_init.sql monitor.db

这将创建一个新的数据库文件,或者打开现有文件(如果有的话),并从该文件运行 SQL 命令。

Image 注意我正在使用 DROP TABLE 命令,所以有效地运行这个命令会清除您可能已经在数据库文件中收集的任何数据。慎用。

从 Python 应用访问 SQLite3 数据库中的数据非常简单:

>>> import sqlite3
>>> con = sqlite3.connect('monitor.db')
>>> for e in con.execute('select * from hostprobe'):
... print e
... 
(1, 1, 1, None, None)
(2, 2, 1, None, None)
(3, 3, 1, None, None)
(4, 4, 1, None, None)
(5, 5, 1, None, None)
(6, 6, 1, None, None)
(7, 7, 1, None, None)
(8, 8, 1, None, None)
(9, 9, 1, None, None)
(10, 10, 1, None, None)
(11, 11, 1, None, None)
(12, 12, 1, None, None)
(13, 13, 1, None, None)
(14, 14, 1, None, None)
(15, 15, 1, None, None)
>>>

但是,您需要注意,如果您运行 update 或 insert 语句,您必须在运行 execute()语句之后调用 commit()函数来完成事务;否则,事务将回滚,所有更改都将丢失。

Image 提示你也可以使用 sqlite3 命令行工具来操作 sqlite3 文件。一些有用的命令包括:。读取<文件名>(打开一个文件)。表(列出数据库中的所有表)。模式<表>(描述表结构)。

行动

服务器进程的主要目的是接受监控代理提交的数据。然而,除此之外,它还提供自动配置和传感器代码升级服务。服务器还实现了一个虚拟服务,它总是返回一个包含文本“OK”的字符串。此服务的主要目的是让客户端可以在更改其配置之前测试服务器的健康状况。

接受传感器读数

实现传感器数据存储的函数需要提供三个参数:标签号、探测器读数以及传感器应用返回代码,以及读取时的时间戳。

当收到呼叫时,根据票证队列验证票证是非常重要的。如果票号不在队列中,这意味着提供的读数是无效的,可能表明某个恶意应用试图伪造数据。还有一种可能是,客户端花了很长时间才做出响应,而票证在队列中已经过期,因此我们对这些数据不再感兴趣。

因为我们需要准确记录传感器读取时间,所以最好在客户端记录下来,并与读取数据一起提交,而不是在服务器端对数据进行时间戳标记。

还要注意,代码从票据队列中删除了票据,这有效地完成了探测读取请求周期。清单 9-2 显示了代码。

清单 9-2 。传感器数据存储功能

@cherrypy.expose
def cmd_store_probe_data(self, ticket, probe, tstamp):
    # probe - [ret_code, data_string]
    self.store_reading(ticket, probe, tstamp)
    return 'OK'

def store_reading(ticket, probe, tstamp):
    con = sqlite3.connect('monitor.db')
    res = [r[0] for r in con.execute('SELECT hostprobe_id FROM ticketqueue WHERE id=?',
                                      (ticket,) )][0]
    if res:
        con.execute('DELETE FROM ticketqueue WHERE id=?', (ticket,) )
        con.execute('INSERT INTO probereading VALUES (NULL, ?, ?, ?, ?)', 
                    (res, str(tstamp), float(probe[1].strip()), int(probe[0])))
        con.commit()
    else:
        print 'Ticket does not exist: %s' % str(ticket)

提供新的配置

正如您在数据库部分所知道的,服务器数据库有两个包含系统配置属性的表。虽然我已经创建了一个数据结构,它允许将来进行扩展,并且能够保存几乎无限数量的配置参数,但此时它只用于一个目的:定义监控服务器地址。负责此参数的条目具有 monitor_url 的键值。可以为每个单独的节点覆盖此设置,这基本上是在多个监控服务器之间分配负载的一种方式。

当客户机收到检索新数据的指令时,它将连接回服务器并提供其主机名。服务器代码(清单 9-3 )首先尝试从 CherryPy 配置类中查找自己的地址和端口号。要读取 CherryPy 配置,您需要调用以下函数并提供配置项键作为参数:

cherrypy.config.get('server.socket_port')

清单 9-3 。提供新的服务器地址

@cherrypy.expose
def cmd_get_new_monitor_url(self, host):
    port = cherrypy.config.get('server.socket_port') if 
                           cherrypy.config.get('server.socket_port') else 8080
    host = cherrypy.config.get('server.socket_host') if 
                           cherrypy.config.get('server.socket_host') else '127.0.0.1'
    server_url = "http://%s:%s/xmlrpc/" % (host, str(port))
    con = sqlite3.connect('monitor.db')
    res = con.execute("""SELECT hostparams.value 
                                      FROM hostparams, host, systemparams
                                     WHERE host.id = hostparams.host_id
                                       AND systemparams.name = 'monitor_url'
                                       AND hostparams.param_id = systemparams.id
                                       AND host.address = ?""", (host,) ).fetchone()
    if not res:
       res = con.execute("""SELECT value FROM systemparams WHERE name = 
                                                             'monitor_url'""").fetchone()
    if res:
        server_url = res[0]
    return server_url

请记住,只有在配置中定义了结果,您才能得到结果;因此,我有采用默认值的回退语句。

下一步是找到特定于主机的设置,如果找不到,则使用系统范围的值或默认值。也有可能它们没有被定义;如果是这样,我们将发送 CherryPy 配置,或者,如果没有,发送假定的默认值。

提供新的传感器代码

当被指示这样做时,客户端可以请求传感器检查应用的更新。第十章详细讲传感器应用的结构和逻辑;现在,只需注意代码是作为压缩的 TAR 归档文件存储在预先配置的目录中。您会注意到传感器代码目录的配置没有存储在数据库中。这样做是为了方便用户将其更改到任何其他位置。第十章还讨论了如何访问存储在纯文本文件中的配置数据。

当通过 XML-RPC 链接发送二进制数据时,必须使用 Python xmlrpclib 库的一个特殊函数:binary(),它封装二进制数据并将其转换成符合 HTTP 和 XML 要求的格式。二进制数据被转换为 base64 字符集,因此它可以被客户端接受,而客户端只希望接收一定范围的可用字符。清单 9-4 显示了代码。

清单 9-4 。通过 XML-RPC 链接发送二进制数据

@cherrypy.expose
def cmd_get_sensor_code(self, sensor):
    with open("%s/%s.tar.bz2" % (self.cm.sensor.source_dir, sensor), 'rb') as f:
        return xmlrpclib.Binary(f.read())

服务器运行状况检查

服务器进程中的最后一个动作是系统健康检查调用,此时它只是返回一个预定义的字符串。您可以扩展它来执行更精细的自我健康检查——例如,测试数据库是否存在以及是否可以读写。

@cherrypy.expose
def healthcheck(self):
    return 'OK'

调度程序

由于监控客户端完全是被动的,除非被告知,否则不会执行任何操作,所以我需要某种调度机制 来发送指令给客户端执行监控检查。

有几种方法可以实现这种调度机制。最简单的方法是编写一个脚本,定期向所有节点发送传感器检查请求,并将该脚本作为 UNIX cron 作业运行。这很容易实现,但是缺乏灵活性——我可能最终需要为每个轮询间隔添加一个新的 cron 条目,因此更改轮询时间表意味着更改所有的 cron 条目。

另一个解决方案是编写一个独立的后台守护进程,它将在后台运行,并在规定的时间发送检查请求。因为轮询时间表是在数据库中定义的,所以可以很容易地适应它,而不需要更改 cron 配置。另一个好处是它可以在 cron 守护进程不可用的地方运行。

行动

请求调度器应该实现几个动作,我将在本节中描述。

调度程序守护程序的主要功能是发送传感器读取请求。该过程将查看数据库中的待处理票据队列,并发送对尚未发出的票据的请求。我将把这个过程称为票调度员??。

显然,票必须以某种方式生成并注入到待处理票队列中。所以我需要另一个过程来完成这个任务。该过程将查看调度表,以了解需要运行哪些检查以及检查的时间间隔。当它找到应该在当前时间执行的标签时,它将把相应的标签插入到未决标签表中。我将这个进程命名为票调度器 。您可能会注意到,我已经暗示了调度逻辑——在给定的时间间隔生成票据。然而,这种模块化结构允许我使用任何调度算法;例如,如果系统负载增加,我可以增加不太重要的检查的时间段。此外,因为所有票据都在数据库队列中,所以它们也可以由外部进程注入,比如命令行工具。

运行多个流程

很明显,我需要为我的调度器实现运行两个甚至更多单独的进程。我可以编写单独的脚本并并行运行它们,也可以编写一个产生多个进程的多线程应用。第一种方法更容易实现,因为我不必在脚本中处理流程管理,但是它缺乏可维护性——我可能很容易运行和维护大量脚本。

另一种方法是从我的应用中产生多个线程或进程。这有点复杂,因为我必须负责从我的应用中启动和停止进程,但它也提供了更多的灵活性并产生了更好的代码,因为所有的函数都在同一个脚本中维护,并且可以共享公共的对象和类定义。

多线程、多重处理和 GIL

Python 支持多线程已经很久了。事实上,有两个库实现了多线程。首先是线程库,提供底层原语;我建议避免这个模块,除非你真的有一个特定的需求来控制低层次的线程活动。另一个是线程库,它提供了处理多线程的高级类和助手类,比如锁、队列、信号量、事件等等。

线程实现因系统而异,但一般来说,它们可以被视为轻量级进程。通常线程是从一个进程中启动的,它们共享同一个内存地址空间。因为它们共享内存,所以它们很容易交流——它们可以很容易地访问相同的变量。因此,开发人员在使用多线程时必须格外小心;共享变量在更新前必须被锁定,这样其他线程就不会得到不一致的结果。这不一定是一件坏事,但是在使用线程的时候你需要记住这一点。

使用线程时一个更大的问题是 Python 解释器的实现。因为 Python 内存管理不是线程安全的,所以不可能(安全地)运行解释 Python 字节码的多个本机线程。停止多个线程同时执行的机制称为全局解释器锁 (GIL) ,确保在任何给定的时间点只有一个 Python 解释器线程在运行。所以虽然每个 Python 线程都映射到一个专用的原生系统线程,但是一次只有一个线程在运行;因此,实际上您的多线程应用变成了单线程,GIL、线程调度和上下文切换机制带来了额外的开销。

您可能想知道,如果一次只有一个线程在运行,为什么线程库会提供各种锁定原语。GIL 的主要目标是防止多个线程访问同一个 Python 对象结构。所以它保护了解释器的内部内存结构,但没有保护你的应用数据,这些数据你必须自己处理。

锁定线程的这种情况是原始 Python 实现特有的,不太可能改变。当前的 Python 解释器——CPython——经过了大量优化,在没有 GIL 的情况下重写它会影响那些单线程 Python 应用的性能。还有其他 Python 实现,比如 IronPython,它们没有 GIL,因此在使用多个 CPU 内核时效率更高。

线程的替代方法是在应用中使用进程。线程和进程的主要区别在于,进程有自己完全隔离的内存段和堆栈。因此,多个进程不能共享相同的对象,这消除了由多个线程同时更新对象数据的所有问题。但是,这是有代价的——创建新进程时会有更多的额外开销,因为需要复制主进程并分配新的内存段。另一个问题是开发人员不能从两个不同的进程中引用同一个对象。所以进程需要不同的通信方法,比如队列和管道。

从版本 2.6 开始,Python 中已经实现了对多处理的支持。Python 有一个名为多重处理的库,其 API 与线程库调用非常匹配,因此移植现有的多线程应用是一项相对简单的任务。该模块可以帮助用户使用相对简单的代码编写并行化代码。通过利用系统进程而不是线程,多重处理可以帮助您避免类似 GIL 的问题。

如你所见,Python 中“真正的”多重处理可以通过在进程中而不是线程中运行代码来实现。在某些情况下,这种方法更有优势,因为这些进程不共享任何东西,并且彼此完全独立,这允许进一步解耦这些进程,并在不同的服务器上运行它们。进程使用队列和管道原语共享数据,这些原语可以使用 TCP/IP 将数据从一个进程发送到另一个进程。

基本使用模式和示例

如前所述,多处理库 API 与线程库非常相似。本节中的清单和代码片段提供了几个如何创建多个流程并在它们之间交换数据的示例。

您可以将希望在单独的进程中运行的代码定义为函数或从多处理继承的类。进程类。对于使用哪种方法以及何时使用,没有严格的规则;这很大程度上取决于任务的大小和代码的复杂性。我更喜欢使用类而不是函数,因为这让我更容易扩展代码库;此外,新类可以扩展,因此应用代码可以用作扩展功能的新应用的基础库。

清单 9-5 展示了用多重处理库创建进程。

清单 9-5 。使用多重处理库创建进程

import multiprocessing
import time

def sleeper(timeout):
    print "function: I am a sleeper function and going to sleep for %s seconds" % timeout
    time.sleep(timeout)
    print "function: I'm done!"

class SleeperClass(multiprocessing.Process):
    def __init__(self, timeout):
        self.timeout = timeout
        print "Class: I am a class and can do initialisation tasks before starting"
        super(SleeperClass, self).__init__()

    def run(self):
        print "Class: I have been told to run now"
        print "Class: So I'm going to sleep for %s seconds" % self.timeout
        time.sleep(self.timeout)
        print "Class: I'm done."

p1 = multiprocessing.Process(target=sleeper, args=(5,))
p2 = SleeperClass(10)
p1.start()
p2.start()
p1.join()
p2.join()

正如你所看到的,如果你使用类,你有在进程开始前运行一些初始化任务的优势。运行示例代码将产生以下结果:

Class: I am a class and can do initialisation tasks before starting
function: I am a sleeper function and going to sleep for 5 seconds
Class: I have been told to run now
Class: So I'm going to sleep for 10 seconds
function: I'm done!
Class: I'm done.

当您开发产生多个进程的应用时,尤其是如果它们将是长时间运行的进程,比如服务,您必须处理中断,以便所有进程都被优雅地终止。现在让我们做一个快速实验,看看如果在程序运行时按 Ctrl-C 会发生什么:

Class: I am a class and can do initialisation tasks before starting
function: I am a sleeper function and going to sleep for 5 seconds
Class: I have been told to run now
Class: So I'm going to sleep for 10 seconds
^CTraceback (most recent call last):
  File "./example_processes.py", line 26, in <module>
    p1.join()
  File "/System/Library/Frameworks/Python.framework/Versions/2.6/lib/python2.6/
multiprocessing/process.py", line 119, in join
Process Process-1:
Traceback (most recent call last):
Process SleeperClass-2:
  File "/System/Library/Frameworks/Python.framework/Versions/2.6/lib/python2.6/
multiprocessing/process.py", line 231, in _bootstrap
Traceback (most recent call last):
  File "/System/Library/Frameworks/Python.framework/Versions/2.6/lib/python2.6/
multiprocessing/process.py", line 231, in _bootstrap
    res = self._popen.wait(timeout)
  File "/System/Library/Frameworks/Python.framework/Versions/2.6/lib/python2.6/
multiprocessing/forking.py", line 117, in wait
    self.run()
  File "./example_processes.py", line 19, in run
    self.run()
  File "/System/Library/Frameworks/Python.framework/Versions/2.6/lib/python2.6/
multiprocessing/process.py", line 88, in run
    self._target(*self._args, **self._kwargs)
  File "./example_processes.py", line 7, in sleeper
    time.sleep(timeout)
    return self.poll(0)
    time.sleep(self.timeout)
  File "/System/Library/Frameworks/Python.framework/Versions/2.6/lib/python2.6/
multiprocessing/forking.py", line 106, in poll
    pid, sts = os.waitpid(self.pid, flag)
KeyboardInterrupt
KeyboardInterrupt
KeyboardInterrupt

如您所见,这是非常糟糕的行为——两个进程都收到了 KeyboardInterrupt 异常并异常终止。同样,如果你多次尝试这个实验,你可能每次都会得到不同的结果。实际结果取决于进程在收到键盘中断信号时在 CPU 执行队列中的位置。

为了解决这个问题,我需要捕获并处理每个进程中的中断,以便当中断到达时,进程完成它正在做的事情并优雅地退出。我将把这两个函数都包装到 try 中:...除了键盘中断:...子句,它允许我捕获进程收到的所有中断。重要的是要知道主进程也接收中断信号,因此也需要处理它。但是当子进程运行时,主进程在做什么呢?它只是在等待它们完成,所以基本上它“停留”在 p1.join()语句上。如果主进程没有其他事情要做,最好让它检查正在运行的子进程的数量,并在所有子进程都完成工作后将它们加入进来。你可以在清单 9-6 中看到这一点。

清单 9-6 。处理中断的多个进程

import multiprocessing
import time

def sleeper(timeout):
    try:
        print "function: I am a sleeper function and going to sleep for %s 
seconds" %
 timeout
        time.sleep(timeout)
        print "function: I'm done!"
    except KeyboardInterrupt:
        print "function: I have received a signal to stop, exiting..."

class SleeperClass(multiprocessing.Process):
    def __init__(self, timeout):
        self.timeout = timeout
        print "Class: I am a class and can do initialisation tasks before starting"
        super(SleeperClass, self).__init__()

    def run(self):
        try:
            print "Class: I have been told to run now"
            print "Class: So I'm going to sleep for %s seconds" % self.timeout
            time.sleep(self.timeout)
            print "Class: I'm done."
        except KeyboardInterrupt:
            print "Class: I must stop now, exiting..."

p1 = multiprocessing.Process(target=sleeper, args=(5,))
p2 = SleeperClass(10)
p1.start()
p2.start()
try:
    while len(multiprocessing.active_children()) != 0:
        time.sleep(1)
except KeyboardInterrupt:
    p1.terminate()
    p2.terminate()
p1.join()
p2.join()

在这个例子中,我调用了 multithreading.active_children()函数 ,它返回正在运行的活动进程的列表。如果列表不为空,主进程会在再次检查列表之前休眠一秒钟。当接收到键盘中断时,主进程将试图终止子进程。当您按下 Ctrl-C 时,所有进程都将接收到这个中断,因此将停止它们的执行。但是,如果你给主进程发送一个 SIGINT 信号,它就会终止,因为 SIGINT 实际上会引发 KeyboardInterrupt,但与 Ctrl-C 组合不同,该信号不会级联到子进程。因此,您必须向子进程发送一个信号,或者简单地终止它们。

以相等的时间间隔运行方法

如您所知,我的应用中的一个进程是票证调度程序进程。此过程将查看调度配置,并将请求票证注入票证队列;然后,它们将由票据调度程序进行调度。这里我想做的基本上是实现一个行为类似 UNIX cron 守护进程的进程,以预定义的时间间隔处理票证。

例如,我可能需要每 5 分钟探测一次传感器检查。然后,我需要这个过程每隔 5 分钟将适当的票注入队列。我要实现的算法有以下步骤:

  • 在预定的时间间隔醒来。在我们的例子中,最短的间隔是 1 分钟。
  • 找到所有应该在该时刻触发的规则。
  • 将适当的记录插入票证队列。

因此,我需要一种在给定的时间间隔“醒来”的机制。我可以使用 time.sleep()函数,它允许我暂停执行任意秒数,但是这会牺牲准确性,因为其他代码(查找规则和插入票据)也需要一些时间;如果我将我的线程设置为睡眠 60 秒,执行时间是 1 秒,那么总时间将是 61 秒。我可能会测量执行时间,然后只调用睡眠函数 60 秒减去执行时间间隔,但是测量和减法调用也会消耗时间,所以也不会那么准确。

我需要的是一个向我的进程发送信号的机制,进程等待信号。当接收到信号时,进程执行任何需要的操作,然后再次等待信号。

一个简单的时钟实现

首先,我需要一个振荡器进程,它的主要目的是以预定义的时间间隔生成事件。任何其他需要定时执行的进程都可以监听事件并做出相应的反应。一般来说,振荡器是通过某种固定或半固定的模式自然地来回传递的东西。

振荡器进程使用 time.sleep()函数来测量事件之间的间隔。因为除了设置和重置事件之外没有太多其他事情要做,所以计时器非常准确。清单 9-7 显示了实现振荡器类的代码。

清单 9-7 。振荡器类以定义的时间间隔生成事件

class Oscillator(multiprocessing.Process):

    def __init__(self, event, period):
        self.period = period
        self.event = event
        super(Oscillator, self).__init__()

    def run(self):
        try:
            while True:
                self.event.clear()
                time.sleep(self.period)
                self.event.set()
        except KeyboardInterrupt:
            pass

oscillator 类接受一个代理对象,该对象在示例中作为事件变量被引用。这个代理对象是多重处理返回的一个实例。经理类。Manager 类是一种在不同进程之间共享状态和数据的机制,它还支持其他数据类型,例如:list、dict、NameSpace、Lock、RLock、Semaphore、Condition、Event、Queue、Value 和 Array。除了 list、dict 和 NameSpace,所有其他类型都是线程库中相应原语的克隆。

让我们定义一个简单的类,它将监听事件并在收到事件时执行一些操作。清单 9-8 中的代码只是打印当前时间。

清单 9-8 。调度程序类监听周期性事件

class Scheduler(multiprocessing.Process):

    def __init__(self, event):
        self.event = event
        super(Scheduler, self).__init__()

    def run(self):
        try:
            while True:
                self.event.wait()
                print datetime.now()
        except KeyboardInterrupt:
            pass

现在让我们看看这一切是如何组合在一起的。在清单 9-9 的主进程代码中,我创建了一个管理类的实例。然后,我将使用它返回一个事件实例的代理。相同的对象将被传递给振荡器和调度器进程。振荡器将设置并清除事件状态,调度程序将等待事件清除,然后打印时间并再次返回等待状态。

清单 9-9 。将共享事件对象传递给两个进程

mgr = multiprocessing.Manager()
e = mgr.Event()
o = Oscillator(e, 60)
s = Scheduler(e)
o.start()
s.start()
try:
    while len(multiprocessing.active_children()) != 0:
        time.sleep(1)
except KeyboardInterrupt:
    o.terminate()
    s.terminate()
o.join()
s.join()

如果您运行这段代码,您将得到每分钟生成的输出。在这里,您可以根据需要使用任意数量的“订阅者”对象,所有对象都在等待振荡器实例生成的事件。

2010-02-28 18:35:09.243200
2010-02-28 18:36:09.244793
2010-02-28 18:37:09.246509
2010-02-28 18:38:09.248229
2010-02-28 18:39:09.249935
2010-02-28 18:40:09.251436
2010-02-28 18:41:09.253154

值得注意的是,这种实现虽然相当精确,但并不理想,并且时间间隔实际上比预定义的 60 秒稍长。这是因为重置事件对象花费了一些时间。然而,给定间隔大小(60 秒),该误差实际上可以忽略(大约 2000 毫秒),并且仅是总振荡周期的大约 0.003%。对于简单的调度系统,这是可以接受的。

类似 Cron 的调度程序

让我们回到票证调度器实现。如您所知,调度信息存储在 probingschedule 表中,该表有以下字段:

。探测调度表字段

|

|

数据类型

|

描述

编号 整数 唯一标识符。
hostprobe_id 整数 指向相应的主机探测条目。该字段包含主机探测行的 ID。
探测间隔 整数 以分钟为单位的探测间隔。

我的调度器实现与 cron 应用使用的逻辑略有不同。cron 配置允许您精确地指定某件事情应该在什么时候发生,比如“每小时过去 5 分钟,每周二”或者“每 10 分钟,每天 9 点到 17 点之间”,而我的调度程序只理解时间段,比如“每 X 分钟”

我要用来计算某件事是否需要发生的算法是这样的:

  • 取自纪元以来的秒数,或任意起点(1970-01-01)。
  • 除以 60,所以用分钟表示。
  • 如果以分钟表示的当前时间可以被探测间隔值整除,则计划在此时进行录制;换句话说,当前时间模数探测间隔应该等于零。

这听起来可能相当复杂,但是 SQLite3 SQL 语言允许我在一条 SQL 语句中执行所有这些检查。我使用 strftime("%s "," now ")内置函数来获取从 epoch 开始的秒数,该秒数被转换为分钟数,并且在同一语句中检查探测间隔的模数。清单 9-10 显示了 Ticket Scheduler 类的完整代码。

清单 9-10 。TicketScheduler 类将探测票证插入票证队列

class TicketScheduler(multiprocessing.Process):

    def __init__(self, event):
        self.event = event
        self.con = sqlite3.connect('monitor.db')
        super(TicketScheduler, self).__init__()

def run(self):
        try:
            from datetime import datetime
            while True:
                self.event.wait()
                res = [r[0] for r in self.con.execute("""SELECT hostprobe_id 
                                                   FROM probingschedule 
                                                  WHERE (strftime('%s', 'now')/60) % 
                                             probingschedule.probeinterval = 0;""")]
                for probe_id in res:
                    self.con.execute("INSERT INTO ticketqueue VALUES 
                                     (NULL, ?, datetime('now'), 0)", (probe_id,))
                    self.con.commit()
        except KeyboardInterrupt:
            pass

因此,存储在 res 数组中的结果将包含该分钟需要执行的所有主机探测器的 ID 号。下一个 for 循环将相应的记录插入票据队列。每个记录包含探测 ID 和时间戳,dispatched 标志被设置为零,这意味着票还没有被发送到目标主机。

票务调度员

一旦票证被放入待处理票证队列中,就会有另一个称为票证调度程序的进程来搜索待处理票证并向客户端主机发送请求。每个客户端实现 cmd_submit_reading XMLRPC 调用,期望在请求中找到以下信息:

  • 票号
  • 传感器名称
  • 传感器参数

此外,我需要知道 XML-RPC 服务器的主机名和端口号。

所有这些信息都分散在多个表中,需要整合在一起。图 9-3 是包含这些信息的表格的 ER 图,以及它们之间的关系。这将有助于定义 SQL 查询。

9781484202180_Fig09-03.jpg

图 9-3 。票务调度组件的表和关系的 ER 图

正如您已经知道的,票证队列包含票证 id 和 hostprobe 行 id,所以我必须从请求所有尚未被分派的条目开始:

pending_tickets = [r for r in self.con.execute("""SELECT id, hostprobe_id 
                                                    FROM ticketqueue 
                                                   WHERE dispatched = 0""")]

一旦我有了 hostprobe 行 id 的列表,我需要从传感器表中找出传感器名称。该条目从探针表中引用,探针表包含特定于该特定传感器的参数。该探测器直接从主机探测器条目引用。XML-RPC 服务器信息在主机表中,也可以从主机探测表中直接引用。我现在需要将所有数据合并到一个 SQL 语句中。最简单的方法是使用隐式连接符号,它列出了所有需要匹配的字段。因为我在引用行时使用主键字段,所以需要在 select 语句中对它们进行比较。

在清单 9-11 中,您可以看到票据调度程序代码的一部分,它使用了之前生成的未决票据列表。for 循环将遍历所有 ticket ID-host probe ID 对,并获取进行传感器检查调用所需的信息。一旦进行了调用,相应的票据将被标记为已分派,因此它将不会显示在后续的查询中。

清单 9-11 。从多个表中检索信息

for (ticket_id, hostprobe_id) in pending_tickets:
    res = [r for r in self.con.execute("""SELECT host.address, 
                                                 host.port, 
                                                 sensor.name, 
                                                 probe.parameter
                                            FROM hostprobe, host, probe, sensor
                                           WHERE hostprobe.id=?
                                                 AND hostprobe.host_id = host.id
                                                 AND hostprobe.probe_id = probe.id
                                                 AND probe.sensor_id = sensor.id""", 
                                                               (hostprobe_id,) )][0]
    self._send_request(ticket_id, res[0], res[1], res[2], res[3])
    self.con.execute("UPDATE ticketqueue SET dispatched=1 WHERE id=?", (ticket_id,))
    self.con.commit()

WHERE 子句中的比较操作有效地对表数据执行连接,因此结果只返回匹配的记录。显然,起点是主机探测器行 ID。你可能还注意到我调用了另一个函数,self。_send_request ,它执行对远程系统的 XML-RPC 调用。代码(清单 9-12 )非常简单明了;只有一点需要注意:当存储在数据库中时,参数字符串是一个逗号分隔的列表,在发送到远程客户端之前,需要将其转换为一个数组。

清单 9-12 。向客户机节点发送 XML-RPC 调用的函数

def _send_request(self, ticket, address, port, sensor, parameter_string=None):
        url = "http://%s:%s/xmlrpc/" % (address, port)
        proxy = xmlrpclib.ServerProxy(url, allow_none=True)
        if parameter_string:
            parameter = parameter_string.split(',')
        else:
            parameter = None
        print ticket
        print sensor
        print parameter
        res = proxy.cmd_submit_reading(ticket, sensor, parameter)
        return

摘要

这是我展示如何实现一个简单的分布式监控系统的三章中的第一章。本章专门介绍数据结构和监控服务器组件;后续章节将详细分析监控代理和统计分析器的结构。需要记住的要点:

  • 总是从一个合理的设计开始你的项目。
  • 定义组成系统的所有组件以及它们将要执行的操作。
  • 定义组件将要使用的数据结构。
  • 当设计数据库表时,尽量接近第三范式,但不要忘记简单性和实用性对你的决策也有一定的影响。
  • 尽管 XML-RPC 是一个相当老的协议,但是由于它的简单性,它对于中小型项目仍然非常有用。
  • Python 内置了对 XML-RPC 的支持:xmlrpclib 用于客户端实现,SimpleXMLRPCServer 用于服务器实现。
  • CherryPy 在自动化 web 框架任务方面很有用,它还支持 XML-RPC 函数包装。
  • Python 中的多线程不是“真正的”多线程——即使您运行单独的线程,由于全局解释器锁(GIL)的实现,只有一个线程是活动的。
  • 如果您需要充分利用多个处理器并避免复杂的对象锁定情况,请将组件解耦到没有共享数据的独立进程中,并使用多处理库,该库使用进程而不是线程。

十、远程监控代理

这是讨论一个简单的分布式监控系统的实现细节的四章系列中的第二章。在前一章中,我展示了高级系统设计,并详细描述了服务器实现。本章专门介绍监控代理的实现、与传感器应用的交互以及安全模型。

设计

我将进一步阐述我在前一章中简要提到的客户机或监控代理的设计。如您所知,监控代理负责接受传感器读取命令并发回结果。它依赖外部工具来执行测量。

无源组件

监控代理组件将是一个被动组件,这意味着它只对传入的命令做出反应来执行动作。这种架构允许我们对整个系统操作和通信流进行细粒度的控制。监控服务器可以决定何时查询以及查询什么,并且这种行为可能会根据代理先前的响应而改变。

体系结构

监控代理的架构分为两个不同的部分:代理代码,作为守护进程运行,接受来自监控服务器的命令;以及负责检查系统状态的传感器。

传感器代码可以是任何应用,由代理在收到执行检查的命令时调用。由于传感器可以用任何编程或脚本语言编写,这为用于监控系统的工具提供了更大的灵活性。

行动

同样,监控代理的主要目的是调用传感器代码,读取结果,并将它们提交给监控服务器。除此之外,它还执行自我配置和自我更新任务。

接受新配置

安全模型意味着每个监控代理必须知道它的监控服务器地址,并使用它进行通信。换句话说,当被查询时,代理不回答请求者,而是启动到已知服务器的连接并提交所请求的数据。

这种方法要求服务器 URL(用于 XML-RPC 通信)存储在每个代理的本地。尽管服务器地址不太可能改变,但我们仍然需要处理它改变时的情况。更改配置的一种方式是使用某种配置管理系统,比如 Puppet、Chef 或 CFEngine,来维护配置;但是我们将实现一种机制,客户端通过这种机制接受更新其配置的请求。在前一章中,我们在服务器数据库上创建了站点和节点配置参数。现在,我们将使用它们来更新客户端配置。

当客户端收到更新配置的命令时,它将启动与当前注册的服务器的连接,并请求一个新的 URL。一旦检索到新的 URL,它将尝试连接到新的服务器。如果连接失败,配置将不会更新;否则,新的数据将覆盖现有的设置,今后,新的 URL 将用于通信。

升级传感器

当引入新功能时,传感器代码可能会改变,例如添加新参数或改进现有检查。因此,代理必须能够用新代码更新其传感器库。

此功能类似于配置更新—代理在收到更新其传感器应用的命令后,会启动与请求新代码的服务器的连接。服务器从其存储库中发送包含新代码的档案。

代码传输完成后,代理会将代码解压缩到一个临时位置,运行一个简单的检查命令来确保可执行文件没有损坏,如果该操作成功,就会用新的应用替换现有的代码。

同样的机制也可以用于部署全新的传感器;不需要替换现有的代码,所以只需要部署新的代码。

提交传感器读数

这是监控代理的主要功能:将读数提交给主服务器。每个传感器产生两个值——应用返回代码和代表该特定读数的单个浮点值。如果有多个值要返回,则必须将它们分成两个单独的检查,并且必须单独调用每个检查。

代理收到运行检查的指令,每个指令包含两个参数:传感器名称和选项字符串。传感器名称用于查找传感器代码;包含传感器应用的目录必须与传感器同名。除此之外,传感器应用名称必须与客户端配置文件中定义的名称相匹配。当代理收到指令时,它会启动传感器应用,并将选项字符串作为附加参数传递给它。

安全模型

这种方法可能会带来一些安全问题,因为理论上任何人都可以向代理进程发送查询并获取读数。这个问题有几种可能的解决方法。一种方法是使用某种认证机制,请求者通过这种机制来标识自己,代理只对授权方做出响应。另一种实现起来简单得多的方法是将请求-响应对话分离成两个不同的部分:请求或命令阶段和响应,后者实际上是由代理组件发起的操作。

因此,我们不会对谁可以连接代理并向代理发送操作请求实施任何限制。这将增加另一层安全性,但也会带来一些复杂性。如果您对改进安全模型感兴趣,可以考虑添加一个双向 SSL 证书,这样只有拥有 SSL 密钥并将其密钥部署在代理上的应用才能连接。

传输命令时,代理将使用默认的确认消息进行响应,表示命令已被接受并终止会话。然后,它将执行与接收到的命令相关的所有操作。

如果操作意味着必须连接到中央服务器,代理将使用存储在本地配置文件中的服务器详细信息。这确保了只有注册和信任的一方将接收数据。

为了跟踪所有命令,服务器会在每个命令上标记一个标签号,并将其与命令请求一起发送。当代理处理完命令并发回结果时,它将在响应中包含相同的票据编号。这种机制有两个目的。首先,服务器知道请求了什么以及来自谁,所以它将需要传输的数据减到最少。第二,它作为一种额外的安全机制,只接受具有有效票据的响应,因此没有人能够在不知道票据编号的情况下将错误的数据注入主服务器。

配置

在前一章中,我简要地提到了使用 Python 库来管理和解析配置文件——config parser 模块。在这一章中,我将向你更详细地展示如何使用这个模块来读写配置文件。作为本练习的一部分,我们还将构建一个简单的包装器类来隐藏所有的读写方法——您将访问所有的配置文件属性,就像访问常规 Python 对象的属性一样。

这种方法简化了编码过程,也使您有机会用其他读写配置的方式来替换 ConfigParser 模块;例如,您可能希望将其存储在 XML 或 JSON 格式的文件中。

ConfigParser 库

ConfigParser 库定义了几个可以用来解析 Windows INI 风格配置文件的类。我将在本节后面更详细地描述这种格式。库中的基本配置类称为 RawConfigParser,它实现了一个基本的配置文件语法解析器,并提供了读写配置文件的方法。可以直接使用这个类,但是使用另外两个类更方便,这两个类扩展了它的功能并提供了一些访问数据的方便方法。

这些子类被称为 ConfigParser 和 SafeConfigParser 前者扩展了。去拿。方法,后者扩展了。set()方法。

文件格式

在我们继续描述如何使用类方法访问配置数据之前,让我们看看 ConfigParser 库支持的文件格式。您可能遇到过 Windows INI 风格的配置文件。尽管它们被称为“Windows 配置文件”,但由于其简单性,许多 Linux 应用也使用类似的(或相同的)格式。

配置文件分为几个部分,每个部分包含任意数量的键和值对。可以使用两种可用的赋值格式之一为每个键赋值:键:值键=值。注释也是允许的,并且必须以。或#符号。当找到一个或另一个时,行尾的所有内容都被忽略。考虑下面的例子:

[user]
# define a user
name=John
location=London
[computer]
name=Jons-PC  ; network name
operatingsystem=OS X

ConfigParser 库还允许指定对其他配置项的引用。例如,可以将一个变量设置为某个值,然后在设置另一个变量时,可以重用第一个变量的值。这允许我们在一个地方定义公共条目。在清单 10-1 中,我们定义了数据库表名,并有一个最终用户可控制的自定义表名前缀。

清单 10-1 。一个示例配置文件

[database]
ip=192.168.1.1
name=my_database
user=myuser
password=mypassword

[tables]
use_prefix=no
prefix=mytables
user_table=%(prefix)s_users
mailbox_table=%(prefix)s_mailboxes

您可能已经注意到,引用语法是使用字典名称的标准 Python 字符串格式:%( dictionary_key )s。虽然我们在其他使用它的项之前定义了我们引用的项,但是位置没有意义,该项可以出现在节中的任何位置。

使用 ConfigParser 类方法

现在我们知道了配置文件的样子,让我们看看如何访问其中的信息。在下面的例子中,我们将使用来自清单 10-1 的配置文件;文件名是 example.cfg。

让我们从打开和读取配置文件开始:

>>> import ConfigParser
>>> c = ConfigParser.RawConfigParser()
>>> c.read('example.cfg')

Image 提示如果你需要使用文件指针而不是文件名,你也可以使用 feadfp()方法。这在您刚刚写入一个文件对象,现在需要将其解析为一个配置文件的情况下非常有用。

一旦文件被读取和解析,您就可以使用 get()方法直接访问这些值,这需要您将节和键的名称指定为必需的参数。下面的代码还演示了一个方便的方法 getboolean() ,它将指定键的值转换为布尔表示。代表真值的可接受值有 1、yes、on 和 True;而假的表示可以是 0、否、关和假。另外两个方便的函数是 getint() 和 getfloat() ,它们相应地将值转换为整数和浮点表示。get()方法总是返回一个字符串值:

>>> c.get('database', 'name')
'my_database'
>>> c.get('tables', 'use_prefix')
'no'
>>> c.getboolean('tables', 'use_prefix')
False
>>>

如果您事先知道节和键的名称,这些方法是很好的,但是如果节是动态的,并且您不知道它们的确切名称和数量,该怎么办呢?在这种情况下,您可以使用 sections()方法,该方法以列表形式返回配置文件中所有节的名称。类似地,您可以通过使用 options()方法找出每个部分中的所有键:

>>> for s in c.sections():
...  print "Section: %s" % s
...  for o in c.options(s):
...   print " Option: %s" % o
... 
Section: tables
 Option: mailbox_table
 Option: use_prefix
 Option: prefix
 Option: user_table
Section: database
 Option: ip
 Option: password
 Option: user
 Option: name
>>>

前面的示例还说明了 ConfigParser 类的一个重要属性—结果返回的顺序与它们在配置文件中出现的顺序不同。请记住这一点,尤其是如果保持这个顺序对您的脚本很重要的话。一个简单的现实情况可能是,一个部分中的键代表应用需要执行的步骤,并且它们需要以特定的顺序发生。

让我们假设以下配置文件的例子,其中用户可以添加任意数量的算术运算,这些运算应用于应用中的内部变量(如果您想使用下面的示例代码,请将内容保存到 example2.cfg):

[tasks]
step_1="+10"
step_2="*5"
step_3="-12"
step_4="/3"
step_5="+45"

所有这些操作都将被计算并应用于名为 x 的变量。实际上,此配置的目的是计算以下表达式的值:

((x + 10) * 5 – 12) / 3 + 45

如果 x 的初始值是 11,那么期望的结果应该是 76。让我们解析配置文件,评估所有操作,看看我们得到了什么:

>>> import ConfigParser
>>> c.read('example2.cfg')
['example2.cfg']
>>> x = 11.0
>>> for o in c.options('tasks'):
...  print "Operation: %s" % c.get('tasks', o)
...  x = eval("x %s" % c.get('tasks', o).strip('"'))
... 
Operation: "+10"
Operation: "-12"
Operation: "*5"
Operation: "+45"
Operation: "/3"
>>> x
30.0
>>>

这显然是错误的,原因在于操作的顺序是错误的。这可能会导致意想不到的结果,并且可能很难确定问题出在哪里。只是通过以错误的顺序应用运算,我们最终评估了下面的公式:

((x + 10 - 12) * 5 + 45) / 3

因此,如果您要求部分和/或键以特定的顺序出现,请确保对它们进行命名,以便允许简单的字符串排序,然后在使用之前对列表进行排序:

>>> x = 11.0
>>> for o in sorted(c.options('tasks')):
...  print "Operation: %s" % c.get('tasks', o)
...   x = eval("x %s" % c.get('tasks', o).strip('"'))
... 
Operation: "+10"
Operation: "*5"
Operation: "-12"
Operation: "/3"
Operation: "+45"
>>> x
76.0
>>>

Image 注意在我的例子中,我使用了一个带有附加整数的字符串。这是最简单的方法;如果超过 9,不要忘记用零来扩展索引号。所以请确保您使用类似于: step_01,step_02,...,第 83 步,等等。对三位数或更多位数的索引应用类似的策略。这种方法的原因是,要排序的是字符串,而不是附加数字的整数值,在这种情况下,“step_9”实际上大于“step_11”

ConfigParser 类还提供了两个方便的方法,允许您快速检查一个节或一个节中的一个键是否存在:分别是 has_section() 和 has_option() 。这些方法非常有用,因为它们允许您拥有可选的参数,如果没有定义,这些参数将采用一些默认设置(如果需要的话),或者可以在配置文件中被覆盖。

>>> import ConfigParser
>>> c = ConfigParser.RawConfigParser()
>>> c.read('example.cfg')
['example.cfg']
>>> c.has_section('tables')
True
>>> c.has_section('doesnotexist')
False
>>> c.has_option('tables', 'prefix')
True
>>> c.has_option('tables', 'optional')
False
>>>

到目前为止,我们所做的只是对配置数据的只读操作。我们已经检查了可用的节及其内容,我们还知道如何检查节或键是否存在。ConfigParser 模块还提供了更改配置文件内容的方法。这可以通过一种可用的方法来实现,这种方法允许您添加或删除一个节,还可以更新任何给定键的值。要添加一个部分,您应该使用 add_section()方法。使用 set()方法更改键值,如果键值不存在,该方法还会创建一个新的键值:

>>> c.add_section('server')
>>> c.set('server', 'address', '192.168.1.2')
>>> c.set('server', 'description', 'test server')
>>> c.sections()
['tables', 'server', 'database']
>>> c.options('server')
['description', 'address']
>>>

您还可以分别使用 remove_option() 和 remove_section() 方法从节中删除一个键,或者将节作为一个整体删除(在这种情况下,该节中包含的所有键也将被删除):

>>> c.options('server')
['description', 'address']
>>> c.remove_option('server', 'description')
True
>>> c.options('server')
['address']
>>> c.remove_section('server')
True
>>> c.sections()
['tables', 'database']
>>>

最后,一旦您对配置文件做了所有的修改,您可以通过使用 write()函数将它保存到一个文件对象中。保存后,可以使用您已经熟悉的 read()方法再次读入文件:

>>> import ConfigParser
>>> c = ConfigParser.RawConfigParser()
>>> c.add_section('section')
>>> c.set('section', 'key1', '1')
>>> c.set('section', 'key2', 'hello')
>>> c.write(open('example3.cfg', 'w'))
>>>  ^D
$ cat example3.cfg 
[section]
key2 = hello
key1 = 1
$

配置类包装

我们现在已经对 ConfigParser 库有了足够的了解,可以开始使用它了,但是在继续之前,我想向您展示如何隐藏所有的库方法并将它们表示为类方法。如果您查看配置文件,它只是一组参数。那么,为什么不隐藏 get 和 set 方法的复杂性,将配置文件中包含的所有数据表示为类变量呢?这样做有几个原因。首先,它简化了对变量的访问;例如,代替编写 var = c.get('section ',' key '),我们可以简单地使用 var = c.section.key 构造(类似于 set()操作)。第二个原因是,因为实现对其余代码是隐藏的,所以我们可以很容易地用存储和检索配置数据的其他方法来替换 ConfigParser 库。

所以在继续之前,让我们了解一下我们需要从包装类中得到什么。基本要求是:

  • 当启动类时,必须读取配置文件,并且必须将所有项目映射到类实例的相应属性中。
  • 当属性设置为某个值但尚不存在时,必须动态创建该属性,并为其分配新值。
  • 如果配置被修改,类实例必须提供将配置保存回文件的方法。

我们将使用内置方法 getattr()和 setattr()来创建和访问实例的属性。这些方法允许通过存储在变量中的属性名来访问属性。清单 10-2 展示了完整的包装器类,我将在这一节更详细地讨论它的各个部分。

清单 10-2 。配置包装类

01 class ConfigManager(object):
02
03     class Section:
04         def __init__(self, name, parser):
05             self.__dict__['name'] = name
06             self.__dict__['parser'] = parser
07
08         def __setattr__(self, option, value):
09             self.__dict__[option] = str(value)
10             self.parser.set(self.name, option, str(value))
11
12     def __init__(self, file_name):
13         self.parser = SafeConfigParser()
14         self.parser.read(file_name)
15         self.file_name = file_name
16         for section in self.parser.sections():
17             setattr(self, section, self.Section(section, self.parser))
18             for option in self.parser.options(section):
19                 setattr(getattr(self, section), 
                            option, self.parser.get(section, option))
20
21     def __getattr__(self, section):
22         self.parser.add_section(section)
23         setattr(self, section, Section(section, self.parser))
24         return getattr(self, section)
25
26     def save_config(self):
27         f = open(self.file_name, 'w')
28         self.parser.write(f)
29         f.close()

让我们从第 12–19 行定义的构造函数方法开始。在前三行代码(13–15)中,我们创建了 ConfigParser 类的一个新实例,并读入配置文件,其文件名在构造函数参数中传递给我们。

在第 16 行,我们遍历所有可用的节名;每个名称都存储在变量名部分中。直到我们读取配置文件时才知道属性名,因此不能在类定义中定义。要使用名称在任何对象中创建属性,我们使用内置函数 setattr() 。该方法接受三个参数:对对象的引用、我们正在访问或创建的属性的名称,以及我们希望赋予该属性的值。如果转换为代码表示,语句 object.attribute = value 与 setattr(object,' attribute ',value)具有相同的含义。如果属性不存在,将创建该属性并为其赋值:

>>> class C:
...  pass
... 
>>> o = C()
>>> dir(o)
['__doc__', '__module__']
>>> setattr(o, 'newattr', 10)
>>> dir(o)
['__doc__', '__module__', 'newattr']
>>> o.newattr
10
>>>

因此,我们用配置文件中的节名创建了一个新属性。我们分配的值是另一个类的新实例—第 3–10 行中定义的 Section 类。我们稍后会回到这个课堂。现在,请注意,您可以将该类中的任何属性名赋值为实例。

一旦创建了具有部分名称的属性,我们将遍历该部分中的所有选项(或者键,因为我已经提到过它们),并创建与键同名的属性。我们还将配置文件中的值分配给这些属性。所有这些都发生在相当长的第 19 行,这里我们使用了 setattr()函数。正如我们已经知道的,函数的第一个参数是对一个对象的引用,但是如果在我们编写应用的时候变量名是未知的,我们如何获得那个引用呢?我们刚刚使用一个名称创建了属性,名称仍然以字符串的形式存储在另一个变量中,因此类似地,我们可以使用该字符串名称来访问它。通过名称访问对象属性的函数称为 getattr() ,它接受两个参数——对象的引用和我们正在访问的属性的名称。因此,语句 val = object.attribute 在功能上等同于 val = getattr(object,' attribute '),正如我们从下面的示例中看到的:

>>> dir(o)
['__doc__', '__module__', 'newattr']
>>> o.newattr
10
>>> getattr(o, 'newattr')
10
>>>

我们现在有了满足第一个需求的功能—当创建配置管理器类的实例时,构造函数方法打开配置文件,读取所有数据,并创建相应的对象属性。这允许我们读取配置文件中所有属性的值,并修改它们。本练习的第二部分是让模型接受新的属性并给它们赋值。我们已经知道如何在初始化过程中创建对象属性,但这是一个受控的过程,当类被初始化时,构造函数方法 init() 被调用。如果我们试图访问一个不存在的属性会发生什么?嗯,通常我们会得到一个由 Python 解释器引发的 AttributeError 异常,如果我们这样做的话:

>>> class C:
...  attribute = 'value'
... 
>>> o = C()
>>> o.attribute
'value'
>>> o.does_not_exist
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: C instance has no attribute 'does_not_exist'
>>>

但是我们可以覆盖这种行为,或者更准确地说,在属性不存在的情况下拦截处理并做一些事情。例如,我们总是可以返回一些默认值,而不是引发异常:

>>> class C:
...  attribute = 'value'
...  def __getattr__(self, attr):
...   return 'default value for %s' % attr
... 
>>> o = C()
>>> o.attribute
'value'
>>> o.does_not_exist
'default value for does_not_exist'
>>>

我们通过覆盖内置对象方法 getattr() 来实现这一点。当你请求一个对象的属性时,解释器检查它是否存在,如果存在,返回它的值。如果属性不存在,解释器检查 getattr()方法是否已定义,如果已定义,则调用它。此方法应返回属性值或引发 AttributeError 异常。

那么我们的 getattr()方法什么时候会被调用呢?当我们试图访问尚未定义的 section 对象时,将会调用它,例如,当我们试图为一个不存在的 section 赋值时,如下所示:

config_manager.new_section.option = value

在这种情况下,将调用 getattr()方法,并将属性参数设置为字符串 new_section。然后,我们需要在解析器实例中创建新的部分,在对象实例中创建新的属性,就像初始化对象时一样。所有这些都发生在第 22–23 行。最后,在第 24 行,我们返回一个对 section 对象的引用。但是等等;我们已经创建了一个截面对象,但不是它的属性!换句话说,我们已经创建了 config_manager.new_section 属性,但是还没有创建 config _ manager . new _ section . option。

最后,我们到达了部分类。首先,让我们看看需要为每个 section 对象定义什么。首先我们定义节名,然后我们需要有一个对解析器对象的引用,所以每当我们写入节对象属性(实际上是配置文件的节键)时,我们需要调用解析器 set()方法来设置键值。剩下的属性只是配置文件中的键。

我还需要提一下,每个 Python 对象都有一个内置的字典,里面包含了属于那个对象的所有属性,这个字典叫做 dict 。您可以使用此字典来访问和修改对象属性:

>>> class C:
...  def __init__(self):
...   self.a = 1
... 
>>> o.a
1
>>> o.__dict__['a']
1
>>> o.b
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: C instance has no attribute 'b'
>>> o.__dict__['b'] = 2
>>> o.b
2
>>>

正如每个类实例都有内置的类方法 getattr(),它也有 setattr()函数。如果定义了这个函数,Python 解释器会在尝试直接修改对象属性之前调用它。这允许您覆盖默认行为并拦截所有赋值调用,甚至是初始化调用:

>>> class C:
...  def __init__(self):
...   self.attr = 'default'
...  def __setattr__(self, attr, value):
...   self.__dict__[attr] = 'you cannot change it'
... 
>>> o = C()
>>> o.attr
'you cannot change it'
>>>

所以我们定义了(第 8-10 行)我们的自定义 setattr()方法,它做了两件事:它在 Section 类实例中创建新属性,并调用解析器方法来创建配置条目。但是为什么我们必须在初始化方法中使用字典呢?我们可以不像下面的例子那样初始化类实例吗?

def __init__(self, name, parser):
    self.name = name
    self.parser = parser

如果我们这样做,新的 setattr()方法将被调用。它将引用 name 属性(第 10 行,self.name ),这是我们正在尝试创建的!因此,为了绕过对 setattr()方法的调用,我们需要在构造函数方法中直接修改 dict dictionary。

Image 注意记住 getattr()和 setattr()调用是不对称的。getattr()函数是在对属性执行了查找(并且失败)之后调用的。所以如果属性存在,这个方法就永远不会被调用。在执行内部字典中的查找之前,调用 setattr()函数*。*

最后,在第 26–29 行,我们定义了一个助手函数,它将我们所有的更改保存到同一个配置文件中。没有自动的变化检测,所以我们需要确保在配置对象发生变化时调用这个函数。

传感器设计

我们必须就传感器应用中的某些 结构达成一致,以便代理知道如何控制它。因此,我们需要确保每个传感器应用都符合以下标准:所有已安装的传感器的传感器名称必须相同。默认的应用名称是 check,可以在配置中更改。

如果用 options 命令行参数调用,每个应用还必须报告其选项。输出是自由格式的文本,但必须包含关于接受的参数的清晰简明的信息。这里有一个例子:

$ disk/check options
percent <vol> - free space %
used <vol> - used in KB
free <vol> - free in KB

结果必须始终是单个浮点数或整数。不允许有额外的空格或字符,因为结果将被假定为一个数字并被视为数字。如果应用不能以所需的格式生成结果,您可以编写一个包装器 shell 脚本来删除多余的字符。示例检查命令的输出如下所示:

$ disk/check used /
432477328

最后,记录应用结束后的返回代码。对返回代码做如下假设:如果代码为 0,则表示应用没有遇到任何错误。如果返回代码不等于 0,这意味着应用不能正确地执行检查,它产生的结果不应该被信任。

所有传感器必须存储在预先配置的目录中;默认情况下,它位于 sensors/'中。更新后的传感器的备份副本必须放在单独的目录中,默认名称为 sensors_backup/'。

您可以在配置文件中设置所有这些选项,该文件必须存在并应命名为 client.cfg。以下是包含默认值的配置文件示例:

[sensor]
executable = check
help = options
path = sensors/
backup = sensors_backup/
[monitor]
url = http://localhost:8081/xmlrpc/

运行外部进程

监控代理最重要的功能之一是运行外部进程并读取它们产生的数据。调用外部工具和命令非常有用,您可能会发现自己在应用中经常这样做。因此,理解和探索 Python 库提供的所有选项是至关重要的。

在 Python 版本之前,有许多不同的库提供了调用外部进程的方法,例如 os.system、os.spawn、os.popen、popen2 和命令。在 2.4 版本中,引入了一个新的库,它旨在取代旧库的功能。新的库被称为子进程,它提供产生新进程的功能;从它们的输入、输出和错误管道发送和接收信息;并获取进程返回代码。

使用子流程库

子进程模块定义了一个类,用于产生新的进程 Popen 类。外部程序的名称作为第一个参数传递给 Popen 类构造函数。传递命令名时有两种选择:使用字符串或使用数组。根据您是否使用 shell 来执行命令,对它们的处理会有所不同。

默认设置是不使用外壳。在这种情况下,Popen 类希望第一个参数是可执行文件的名称。如果找到传递给它的列表,列表中的第一个元素将被视为命令名,列表中的其余元素将作为命令行参数传递给进程:

>>> import subprocess
>>> subprocess.Popen('date')
<subprocess.Popen object at 0x10048ca90>
Wed 17 Mar 2010 22:29:24 GMT
>>> subprocess.Popen(('echo', 'this is a test'))
<subprocess.Popen object at 0x10048ca10>
this is a test
>>>

因此,如果您试图在同一个字符串中指定要执行的命令及其参数,将会失败,因为 Popen 类会按照字符串中指定的方式查找可执行文件名称,这显然会失败:

>>> subprocess.Popen('echo "this is a test"')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/System/Library/Frameworks/Python.framework/Versions/2.6/lib/python2.6/
subprocess.py", line 595, in __init__
    errread, errwrite)
  File "/System/Library/Frameworks/Python.framework/Versions/2.6/lib/python2.6/
subprocess.py", line 1106, in _execute_child
    raise child_exception
OSError: [Errno 2] No such file or directory
>>>

另一种方法是使用 shell 运行命令。您必须通过将 shell 变量设置为 True 来指示 Popen 使用 shell:

>>> subprocess.Popen('echo "this is a test"', shell=True)
<subprocess.Popen object at 0x10048cb10>
this is a test
>>>

正如您所看到的,这一次示例按预期工作。如果您使用的是 shell,并且命令是一个字符串,那么它将以精确的形式传递给 shell,所以请确保您对字符串的格式与您在 shell 提示符下直接键入命令时完全相同;这包括在文件名中添加反斜杠来转义空格字符。

使用 shell 执行命令实际上等同于生成 shell 可执行文件并传递命令及其参数,如下例所示:

>>> import subprocess
>>> subprocess.Popen(('/bin/sh', '-c', 'echo "this is a test"'))
<subprocess.Popen object at 0x10048cc10>
this is a test
>>>

在 Unix/Linux 系统上,用于运行命令的缺省 shell 是/usr/sh。Python 文档指出,通过将可执行参数设置为不同的二进制文件,您可以指定您选择的任何其他 shell 然而,这并不能正常工作,事实上只是使用另一个 shell 来生成缺省 shell。下面是负责设置替代可执行文件的子进程库的摘录:

if shell:
    args = ["/bin/sh", "-c"] + args

if executable is None:
    executable = args[0]

[...]

os.execvp(executable, args)

从这个代码片段中可以看出,如果设置了 shell 变量,参数列表将使用默认的 shell 二进制文件位置和参数-c 进行扩展,这指示 shell 将它后面的任何内容都视为命令字符串。下一个检查是验证可执行参数是否不为空。如果它是空的,那么它将被设置为参数列表中的第一项,这将是默认的 shell 或/bin/sh。最后,使用两个参数调用 os.execvp 函数: executable,它是要加载的程序的文件名,以及参数列表。

假设我们只指定了 shell=True,那么应该使用默认的 shell,因为 args0 被赋给了可执行变量。然而,如果我们试图同时使用 shell 和 executable 参数,我们最终会从另一个可执行文件中调用相同的缺省 shell,这与手册所说的相反!我们可以通过一个简单的实验来证实这一点:

>>> import subprocess
>>> subprocess.Popen('echo $0', shell=True)
<subprocess.Popen object at 0x10048ca50>
/bin/sh
>>> subprocess.Popen('echo $0', shell=True, executable='/bin/csh')
<subprocess.Popen object at 0x10048ca90>
/bin/sh
>>>

在这两种情况下,结果是相同的,这意味着运行我们的命令的有效 shell 是相同的/bin/sh。否决缺省 shell 的最简单、最简洁的方法是使用“无 shell”Popen 调用,并将 shell 可执行文件指定为命令名:

>>> subprocess.Popen(('/bin/csh', '-c', 'echo $0'))
<subprocess.Popen object at 0x10048cad0>
/bin/csh
>>>

如果您在 shell=None(这是默认设置)的情况下使用 Popen 命令,但是不想在每次调用外部工具时都构造数组,那么您可能需要考虑以下模式:创建一个看起来像在 shell 提示符下使用的命令的字符串,然后使用 string split()方法创建一个包含程序名及其参数的数组:

>>> import subprocess
>>> cmd = "echo argument1 argument2 argument3"
>>> subprocess.Popen(cmd.split())
argument1 argument2 argument3
<subprocess.Popen object at 0x10048cad0>
>>>

Popen 命令的一个有用参数是 preexec_fn 参数,它允许您在新进程启动之前运行任何函数。需要注意的是,这段代码是在 system fork()调用之后、exec()调用之前调用的,这意味着新进程已经创建并在内存中,但尚未启动。你可能想要使用这个功能的一个典型情况是改变新进程的有效用户 ID,如清单 10-3 所示。

清单 10-3 。运行外部流程时更改用户 ID

#!/usr/bin/env python

import subprocess
import os

print "I am running with the following user id: ", os.getuid()
subprocess.Popen(('/bin/sh', '-c', 
                  'echo "I am an external shell process with effective user id:"; 
                   id'), 
                  preexec_fn=os.setuid(501))

以 root 用户身份运行此代码将产生类似于下面的结果,这表明新进程获得了一个新的用户 ID:

$ sudo ./setsid_example.py 
Password:
I am running with the following user id:  0
I am an external shell process with effective user id:
uid=501(rytis) gid=20(staff)

您还可以通过将 cwd 参数设置为新路径来更改正在运行的进程的当前目录:

>>> import subprocess
>>> import os
>>> print os.getcwd()
/home/rytis/
>>> subprocess.Popen('pwd', cwd='/etc')
<subprocess.Popen object at 0x10048cb50>
/etc
>>>

也可以覆盖默认的 shell 环境变量。这些是从当前进程继承的,但是如果您希望创建一组新的变量,您可以通过将一个映射分配给 env 参数来实现:

>>> import subprocess, os
>>> os.environ['PATH']
'/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games'
>>> subprocess.Popen('echo $PATH', shell=True, env={'PATH': '/bin/'})
<subprocess.Popen object at 0x2b461ac0dd90>
/bin/
>>>

如果您只想更改一个变量,而让其他变量保持不变,请复制一份 os.environ 字典,然后修改要更改的条目。当你定义一个新的字典时,最好使用 dict 函数,它会复制一个现有的字典,而不仅仅是创建一个对它的引用:

>>> import os
>>> new = dict(os.environ)
>>> new['PATH'] = '/bin/'
>>> os.environ['PATH']
'/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games'
>>> new['PATH']
'/bin/'

控制正在运行的流程

你必须记住,进程可能不会立即终止。因此,您需要能够检查该进程是否仍在运行,它的进程 ID 是什么,以及当它结束运行时返回的代码是什么,您甚至需要显式地终止该进程。

清单 10-4 展示了如何启动一个新进程,然后等待它结束。Popen 类还有一个 pid 属性,它包含已启动进程的进程 id。

清单 10-4 。等待进程终止

import subprocess
import time
from datetime import datetime

p = subprocess.Popen('sleep 60', shell=True)

while True:
    rc = p.poll()
    if rc is None:
        print "[%s] Process with PID: %d is still running..." % (datetime.now(), p.pid)
        time.sleep(10)
    else:
        print "[%s] Process with PID: %d has terminated. Exit code: %d" % 
                                                         (datetime.now(), p.pid, rc)
        break

如果运行此示例,您将得到类似于以下内容的结果:

[2010-03-18 20:56:33.844824] Process with PID: 81203 is still running...
[2010-03-18 20:56:43.845769] Process with PID: 81203 is still running...
[2010-03-18 20:56:53.846158] Process with PID: 81203 is still running...
[2010-03-18 20:57:03.846568] Process with PID: 81203 is still running...
[2010-03-18 20:57:13.846975] Process with PID: 81203 is still running...
[2010-03-18 20:57:23.847360] Process with PID: 81203 is still running...
[2010-03-18 20:57:33.847819] Process with PID: 81203 has terminated. Exit code: 0

或者,您可以使用 Popen 类方法 wait() ,它会阻塞并等待进程完成,然后将控制权返回给您的应用。在大多数情况下,它非常有用,可以让您不用编写自己的等待循环,但是请注意,如果您运行的进程生成大量输出,wait()可能会陷入死锁:

>>> import subprocess
>>> from datetime import datetime
>>> def now():
...  print datetime.now()
... 
>>> p = subprocess.Popen('sleep 60', shell=True, preexec_fn=now)
2010-03-18 21:06:14.767768
>>> p.wait()
0
>>> now()
2010-03-18 21:07:20.119642
>>>

让我们修改前面的例子,插入一个 kill()命令,强制终止正在运行的进程。清单 10-5 显示了代码。

清单 10-5 。终止正在运行的进程

import subprocess
import time
from datetime import datetime

p = subprocess.Popen('sleep 60', shell=True)

while True:
    rc = p.poll()
    if rc is None:
        print "[%s] Process with PID: %d is still running..." % (datetime.now(), p.pid)
        time.sleep(10)
        p.kill()
    else:
        print "[%s] Process with PID: %d has terminated. Exit code: %d" % 
                                                         (datetime.now(), p.pid, rc)
        break

现在,如果您运行该脚本,您将看到以下结果:

[2010-03-18 21:11:45.146796] Process with PID: 81242 is still running...
[2010-03-18 21:11:55.147579] Process with PID: 81242 is still running...
[2010-03-18 21:12:05.148198] Process with PID: 81242 has terminated. Exit code: -9

请注意,返回代码变为负值。负返回值表示进程已经终止,并且没有自己完成执行。数值将指示终止过程的信号编号。表 10-1 列出了最常用的信号及其数值表示。

表 10-1 。信号数值

|

信号名称

|

数值

|

描述

嘘嘘嘘 one 挂断
信号情报 Two 终端中断,通常来自键盘
继续走 three 终端退出,通常从键盘退出
西格布 four 中止信号
SIGKILL(消歧义) nine 杀人信号,无法捕捉
西格 1 号 Ten 用户定义信号 1
西格玛瑟鲁瑟鲁瑟鲁瑟鲁瑟鲁瑟鲁瑟鲁瑟鲁瑟鲁瑟鲁瑟鲁瑟鲁瑟鲁瑟鲁瑟鲁瑟鲁瑟鲁瑟鲁瑟鲁瑟鲁瑟鲁瑟鲁瑟鲁 Twelve 用户定义信号 2
是 SIGTERM Fifteen 终止信号
停下来 Nineteen 停止执行,不能被抓

与外部流程通信

知道如何调用外部进程是很好的,但是如果你不能与它们交流,它们就没什么用了。大多数 shell 进程有三个通信通道:标准输入、标准输出和标准错误,通常称为 stdin、stdout 和 stderr。当您创建 Popen 类的新实例时,您可以定义这些通道中的任何一个,并将其设置为下列值之一:

  • 现有的文件描述符
  • 现有的文件对象
  • 子过程的一个特殊值。管道,它指示应该创建一个到标准流的管道
  • 子过程的一个特殊值。STDOUT,可用于将错误消息重定向到标准输出流

使用文件描述符

在我们继续之前,让我提醒你什么是文件描述符。文件描述符是与正在运行的进程打开的文件相对应的整数。在 Linux 中,通常为每个正在运行的进程分配三个文件描述符:0 代表标准输入,1 代表标准输出,2 代表标准错误。运行时打开的任何其他文件、套接字或管道都将被分配后续编号,从 3 开始。

在 Python 中,您会将文件描述符用于低级 I/O 操作,因此不经常使用它们。这是因为 Python 提供了额外的抽象级别,并且大多数文件操作可以使用 Python file 对象来执行,这些对象提供了多种文件操作。文件描述符由 os.open()或 os.pipe()方法返回。考虑以下示例,其中创建了一个新文件,然后命令的输出被重定向到该文件。如果您运行这个示例,您将不会在终端上看到任何输出,但是日期字符串将被写入 out.txt 文件。

import subprocess
import os

f = os.open('out.txt', os.O_CREAT|os.O_WRONLY)
subprocess.Popen('date', stdout=f)

使用文件对象

前面的例子使用了低级文件 I/O 操作符来处理文件描述符。内置的 Python 函数 open()更易于使用,并为文件操作提供了更高级别的 API,如 read()和 write()。该对象本身也是一个迭代器,因此您可以使用方便的 Python 语言构造,比如 for...在...:循环访问文件的内容。

将文件对象传递给 Popen 构造函数完全没有区别,结果实际上与使用文件描述符时一样:

import subprocess
import os

f = open('out.txt', 'w')
subprocess.Popen('date', stdout=f)

使用管道对象

前面描述的方法允许您将程序的输入/输出重定向到一个文件,但是您如何从 Python 应用中访问这些数据呢?一种选择是等到程序完成后再读取文件,但这样做效率很低,而且还要求您对执行应用的当前目录具有读/写权限。或者,您可以创建一个管道,并在 Popen 调用中将读写文件描述符分配给不同的通信通道,但是这个选项看起来太复杂了。

子进程库提供了一种更简单的方法来实现这一点——通过为 stdin、stdout 和/或 stderr 参数分配一个特殊的变量:subprocess.PIPE。

子流程模块提供了比其他一些可用模块更高级别的接口。该库旨在取代 os.popen*)、os.system()、os.spawn*)、popen2 等函数。()、和命令。().

communicate()方法返回包含从流程返回的数据的两个字符串的元组:

>>> import subprocess
>>> p = subprocess.Popen(('echo', 'test'), stdout=subprocess.PIPE)
>>> out_data, err_data = p.communicate()
>>> print out_data
test
>>> print err_data
None
>>>

您还可以使用可选的参数输入将您需要的任何数据传递给流程:

>>> import subprocess
>>> p = subprocess.Popen(('wc', '-c'), stdout=subprocess.PIPE, stdin=subprocess.PIPE)
>>> out_data, err_data = p.communicate(input='test string')
>>> print out_data
      11
>>>

Image 注意该函数在内存中缓冲所有数据,因此不适合用于大型数据集。例如,如果您的应用预计会产生大量数据,这可能会导致意想不到的结果。“安全”数据的大小没有定义,很大程度上取决于确切的 Python 版本、Linux 版本以及系统中可用的内存量。

使用 communicate()的另一种方法是直接从通过 Popen 类实例可用的文件对象中读取和写入:

>>> import subprocess
>>> p = subprocess.Popen('cat /usr/share/dict/words', shell=True, stdout=subprocess.PIPE)
>>> i = 0
>>> for l in p.stdout:
...  i += 1
... 
>>> print i
234936
>>>

类似地,您可以使用与标准输入文件对象相关联的 stdin 变量写入流程。这种方法的优点是可以在需要时访问数据,而不是一次将数据加载到内存中。

一个额外的好处是,您可以长时间地监视流程活动,并在输出可用时对其进行处理。以下示例显示了如何从 tail 命令中读取行。启动 Python 应用后,我生成了几行日志,它们出现在 Python 输出中。如果您想重复这个练习,可以使用 Linux logger“message”命令将一些日志消息写入系统日志文件:

>>> import subprocess
>>> p = subprocess.Popen('tail -f /var/log/messages', shell=True, stdout=subprocess.PIPE)
>>> while True:
...   print p.stdout.readline()
... 
Mar 8 21:43:14 linux -- MARK --
Mar 8 22:03:15 linux -- MARK --
Mar 8 22:16:54 linux rytis: this is a test
Mar 8 22:17:01 linux rytis: this is a test 2

在更复杂的场景中,您可能希望运行一个单独的线程;这将观察生成的命令的输出,并将该数据传递给其他进程或线程进行进一步处理。

重定向标准误差

应用通常通过将错误信息写入标准错误文件描述符来区分错误信息和正常输出。有时,您真正需要的是将应用生成的所有输出放在一起,不管它是应用的正常输出还是错误消息。

为了处理这种情况,子流程库提供了特殊的变量子流程。STDOUT,您可以将其分配给 stderr 参数。这会将错误文件描述符的所有输出重定向到标准输出:

>>> import subprocess
>>> p = subprocess.Popen('/bin/sh -c "no_such_command"', shell=True,
 stdout=subprocess.PIPE, stderr=subprocess.PIPE)
>>> out_data, err_data = p.communicate()
>>> print out_data

>>> print err_data
/bin/sh: no_such_command: command not found

>>> p = subprocess.Popen('/bin/sh -c "no_such_command"', shell=True,
 stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
>>> out_data, err_data = p.communicate()
>>> print out_data
/bin/sh: no_such_command: command not found

>>> print err_data
None
>>>

自动更新传感器代码

最后,我们必须在代理应用中实现一种机制,允许我们从一个中心位置更新任何传感器。当您管理数千台服务器时,您最不想做的事情就是手动复制、解包、替换和验证您在每台服务器上更新的软件包。因此,我们要添加到代理代码中的一个功能是自动检索一个包(在本例中,它只是一个压缩的 TAR 归档文件)并将其部署到现有的包之上。因此,当需要时,我们可以在主服务器上用新的包替换它,然后指示所有代理检索它并相应地更新。

用 XML-RPC 发送和接收二进制数据

到目前为止,所有的通信流都是通过 XML-RPC 协议进行的,它可以很好地处理简单的数据结构,比如字符串、整数、数组等等;但是对于二进制数据,数据传输不再是微不足道的。正如您已经知道的,XML-RPC 是一种基于文本的协议,因此将原始二进制数据封装到 XML-RPC 消息中并不是一种选择。

我们需要做的是只使用被认为是文本并且被 XML-RPC 协议允许的字符来表示二进制数据。有一种专门为此开发的特殊编码方案,称为 base64。数字 64 代表编码中使用的字符数。在 base64 编码方案最流行的变体中,使用了以下字符:小写和大写字母 A–Z 和 A–Z,数字 0–9,以及两个额外的字符:+ 和/。因为有 64 个字符,所以可以用 6 位数字表示。因此,当执行二进制数据的编码时,二进制数据中的所有 8 位字节都以连续的位流表示,然后被分成 6 位块。每个 6 位数字映射到 base64 表中的 64 个字符之一,我们最终得到由 64 个字符构成的数据,这些字符可以作为字符串包含在 XML-RPC 消息中。因为每个字符仍然由 8 位字节表示,所以在编码之后,数据量增加了大约 33%(8/6 = 1.3(3))。

当我们收到数据时,我们需要将其转换回二进制表示。该过程与第一次转换相反:我们首先从编码/解码表中获取 6 位数字,并将所有 6 位块放入一个连续的比特流中,然后将其分成 8 位字节。

幸运的是,我们不需要担心这些,因为 XML-RPC 库提供了编码和解码二进制数据的类。因此,在将传输二进制文件的监控服务器端,我们公开了以下 XML-RPC 方法:

@cherrypy.expose
def cmd_get_sensor_code(self, sensor):
    with open("%s/%s.tar.bz2" % (self.cm.sensor.source_dir, sensor), 'rb') as f:
        return xmlrpclib.Binary(f.read())

如您所见,这段代码返回了 xmlrpclib 的一个实例。二进制类,接受一个参数——需要编码的比特流。当客户端接收到这样的对象时,它可以直接将其写入文件句柄,解码会自动执行并存储在对象的属性 data 中。因此,在客户端,对数据的请求以及将数据写入文件是通过以下代码实现的:

proxy = xmlrpclib.ServerProxy(self.cm.monitor.url)
tmp_dir = tempfile.mkdtemp(dir='.')
dst_file = "%s/%s.tar.bz2" % (tmp_dir, sensor)
with open(dst_file, 'wb') as f:
    f.write(proxy.cmd_get_sensor_code(sensor).data)
    f.close()

使用文件和归档(TAR 和 BZip2)

当我们在数据传输函数中读写文件时,我简要地提到了文件操作。让我们更仔细地研究一下您可能需要执行的常见文件操作,以及 Python 库提供的可以让您的生活更轻松的工具。

清单 10-6 显示了监控代理代码中的函数,它负责检索一个新的传感器包,打开包,测试包,最后用它替换原来的包。

清单 10-6 。自动包更新功能

01 @cherrypy.expose
02 def cmd_update_sensor_code(self, sensor):
03     # get the new file
04     proxy = xmlrpclib.ServerProxy(self.cm.monitor.url)
05     tmp_dir = tempfile.mkdtemp(dir='.')
06     dst_file = "%s/%s.tar.bz2" % (tmp_dir, sensor)
07     with open(dst_file, 'wb') as f:
08         f.write(proxy.cmd_get_sensor_code(sensor).data)
09         f.close()
10     # unpack it
11     arch = tarfile.open(dst_file)
12     arch.extractall(path=tmp_dir)
13     arch.close()
14     # check it
15     cmd = ["%s/%s/%s" % (tmp_dir, sensor, self.cm.sensor.executable), "options"]
16     p = subprocess.Popen(cmd, stdout=subprocess.PIPE)
17     p.communicate()
18     if p.returncode != 0:
19         # remove if fails
20         shutil.rmtree(tmp_dir)
21     else:
22         # back up the existing package
23         sens_dir = "%s/%s" % (self.cm.sensor.path, sensor)
24         bck_dir = "%s/%s_%s" % (self.cm.sensor.backup, sensor, 
                                   datetime.strftime(datetime.now(), 
'%Y-%m-%dT%H:%M:%S'))
25         try:
26             shutil.move(sens_dir, bck_dir)
27         except:
28             pass
29         os.remove(dst_file)
30         # replace with new
31         shutil.move("%s/%s" % (tmp_dir, sensor), sens_dir)
32         os.rmdir(tmp_dir)
33     return 'OK'

您可能已经熟悉了前面示例中的基本文件操作,如 open()、read()、write()和 close(),所以我将快速提醒您它们是做什么的,然后集中讨论那些不太为人所知但非常有用的函数,如果您不想依赖操作系统提供的外部工具和工具的话。

任何文件操作都以 open()命令开始,该命令接受两个参数:正在访问的文件的名称和访问模式。对于读取操作,访问模式参数可以是 r(如果省略,则为默认值),对于写入操作,可以是 w,对于追加操作,可以是 a。请记住,如果文件已经存在,w 模式会将其截断。您还可以将可选的 b 参数附加到 mode 参数,该参数指示文件是否包含二进制数据。指出文件是否包含任何二进制数据是一个很好的做法,因为这决定了如何处理换行符。默认情况下使用文本模式,这在某些情况下可能会将换行符转换为特定于平台的表示形式(例如,可能会转换为序列)。在适当的地方指定二进制模式既可以提高代码的可读性,也可以使代码在不同平台之间更容易移植。如果操作成功,open()函数返回一个文件对象。

文件打开后,可以使用 file 对象的 read()和 write()方法读写数据。如果您正在处理一个文本文件,您也可以使用 readline()函数,该函数从文件中读入下一行,或者使用 readlines()将所有行读入一个数组。当您完成文件操作时,不要忘记调用 close()方法来完成所有可能已经被缓冲的操作,并实际释放文件句柄。

有时你需要创建一个临时文件或者一个目录。在上面的例子中,我们希望在测试之前将传感器代码部署到一个临时位置。如果我们立即替换现有的代码,而新代码是错误的,我们就有麻烦了。不仅没有备份可以恢复,而且代码可以立即执行。为了处理临时文件和目录的创建,Python 提供了一个名为 tempfile 的模块。第 5 行使用 mkdtemp()函数,它创建一个临时目录。还可以传递一个可选参数 dir,它指定应该在哪里创建目录。如果省略该参数,则目录位置由以下环境变量之一确定:TMPDIR、TEMP 或 TMP,它们是特定于操作系统的。结果是一个目录名:

>>> import tempfile
>>> d = tempfile.mkdtemp()
>>> d
'/var/folders/7X/7XBjCSfXGbOoJog2bNb3uk+++TI/-Tmp-/tmpPBCHIc'

类似地,您可以通过调用 mkstemp()方法来创建一个临时文件。这个方法也接受相同的 dir 参数来指示应该创建文件的位置。打开临时文件时,还应该通过将另一个可选参数 text 设置为 False(默认值)或 True 来指示该文件是二进制文件(默认值)还是文本文件。该函数返回一个元组:一个文件描述符编号和一个文件名。但是,不要将文件描述符(它只是一个整数)与文件对象混合在一起。如果您想使用更高级别的 read()和 write()操作,您必须首先创建一个相应的 file 对象:

>>> import tempfile
>>> f = tempfile.mkstemp()
>>> f
(3, '/var/folders/7X/7XBjCSfXGbOoJog2bNb3uk+++TI/-Tmp-/tmpFsBEXt')
>>> import os
>>> fo = os.fdopen(f[0], 'w')
>>> fo.write('test')

临时目录和文件都将以最安全的方式创建,并且只能由创建它们的用户读写。

Image 注意还有一点很重要,删除临时文件和目录是进程的责任,库不会为你处理这件事。

使用 os.remove()函数(第 29 行)删除一个文件,使用 os.rmdir()删除一个目录:

>>> os.remove(f[1])
>>> os.rmdir(d)

你必须记住 os.rmdir()只删除空的目录。幸运的是,Python 有另一个有用的内置模块 shutil,它提供了许多用于管理文件和目录的高级操作。一个有用的函数是 rmtree()(第 20 行),它递归地删除目录树及其所有内容。您还可以使用 move()函数移动整个树结构(第 26 行和第 31 行)。

最后,我将介绍另一个内置的 Python 库——TAR file,它用于处理 TAR、BZip2 和 GZip 归档文件。正如您在第 11–13 行中看到的,使用这个库来解包归档文件非常简单。当使用 open()函数打开归档文件时,您不需要指定格式,因为它会被自动检测到。您可以通过提供一个可选的 mode 参数来指定它,该参数与内置函数 open() mode 参数具有相同的语法;但是,在这种情况下,它使用以下压缩参数之一进行扩展::bz2 表示 BZip2 压缩,或者:gz 表示 GZip 压缩。默认情况下,归档文件以读取模式打开。如果您需要写入归档文件(添加新文件),您必须指定写入模式:

$ ls -l
total 8
-rw-r--r--  1 rytis  rytis  26  1 Apr 14:35 test.txt
$ python
Python 2.6.1 (r261:67515, Feb 11 2010, 00:51:29) 
[GCC 4.2.1 (Apple Inc. build 5646)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import tarfile
>>> t = tarfile.open('archive.tar.bz2', 'w:bz2')
>>> t.add('test.txt')
>>> t.close()
>>> ^D
$ ls -l
total 16
-rw-r--r--  1 rytis  rytis 147 1 Apr 14:36 archive.tar.bz2
-rw-r--r--  1 rytis  rytis 26  1 Apr 14:35 test.txt
$ tar jtvf archive.tar.bz2 
-rw-r--r--  0 rytis  rytis 26  1 Apr 14:35 test.txt
$

摘要

在本章中,我们研究了监控代理组件的体系结构,以及它如何与操作系统交互。我们还研究了不同 Python 库提供的各种技术,这些技术抽象了一些文件和流程操作,我们还回顾了基本的文件操作,如 open()、read()、write()和 close。我们将在下一章继续讨论监控系统,在那里我们将添加统计计算和绘图功能。

需要注意的要点:

  • ConfigParser 库允许您使用 INI 类型的配置文件。
  • Python 为对文件和归档的操作提供了高级库:shutil 和 tarfile。
  • 子进程库用于运行外部命令和与外部进程通信。

十一、统计数据收集和报告

这是致力于开发监测系统的系列章节中的第三章。在前面的章节中,我们创建了两个组件:一个监控服务器和一个监控代理组件,它们可以收集和存储来自各种来源的统计数据。为了使这些数据真正有用,我们需要对其进行分析,得出一些结论,并将结果呈现给最终用户。在本章中,我们将创建一个简单的基于 web 的应用,它对数据执行统计分析 并生成一些报告。

应用要求和设计

统计表示系统应该相当简单且易于使用。以下是它需要提供的基本功能:

  • 系统应该提供一个列表,列出所有正在被监控的可用主机。
  • 对于每个可用的主机,应该有一个该主机可用的所有探测器的列表(一个探测器是一个运行在远程服务器上的简单检查脚本)。
  • 探测器应该分为两个标准:探测器名称和数据时间刻度。
  • 数据应该以不同的时间尺度呈现,例如过去 24 小时、过去 7 天和过去 30 天获得的读数。
  • 系统应报告达到设定阈值的次数。此信息可以表示为在某个时间范围内所有请求数量的百分比。
  • 系统应该提供数据的基本统计分析,例如平均值、数据趋势等。

该系统将是一个脚本,从监控数据库读取数据,然后生成静态 HTML 页面以及所需的数据图形图像。这个脚本可以使用 cron 等系统调度工具定期运行。

将通过使用 NumPy 和 matplotlib 库来执行图形和统计分析。

使用 NumPy 库

统计分析是科学家们已经做了很长时间的事情。因此,几乎每种计算机语言都有大量的科学库。Python 编程语言最流行的库可能是 NumPy(以前称为 Numeric)、和 SciPy,前者提供高级数学函数,后者提供超过 15 个不同的科学模块(具有各种用于优化、线性代数、信号处理和分析以及统计分析的科学算法)。

对于我们在这里要做的事情来说,大部分功能可能是多余的。然而,只调用一个函数并知道结果是可信的,这种便利胜过在系统上安装一些额外的包。我建议花些时间熟悉这两个库(还有图形绘图库 matplotlib,我们将在本章后面讨论),因为它们为分析和报告提供了有用的工具。

安装 NumPy

NumPy 包的可用性很大程度上取决于您使用的 Linux 发行版。一些发行版,如 Fedora 和 Ubuntu,试图保持应用的最新版本,将提供二进制包。在这种情况下,您可以使用操作系统软件包管理器(如 yum 或 aptitude)来为您安装软件包。例如,下面是如何在 Fedora 系统上安装 NumPy:

$ yum install numpy

一些发行版,尤其是企业级的发行版,如 Red Hat Enterprise Linux 和 CentOS,在包的选择上更加保守,可能不提供预编译的包。对于这些发行版,最好下载源代码包并从源代码构建库。你可以在 http://sourceforge.net/projects/numpy/找到 NumPy 的源代码。

NumPy 示例

大多数 NumPy 函数都经过优化,可以有效地处理数组。这些数组可以有一维或多维。在我们的大多数示例中,我们将对一维数组进行操作,数组中的数据是传感器在一段时间内的标量读数。

使用数组

NumPy 数组与常规 Python 数组数据类型不同。该数组结构经过精心设计,在 NumPy 函数使用时非常有效。类型实现是特定于 NumPy C 代码的。它在访问方法方面提供了一些兼容性,但是并不是所有的函数都是重复的,正如您可以从这个例子中看到的:

>>> import numpy
>>> array_py = [1, 4, 5, 7, 9]
>>> array_np = numpy.array([1, 4, 5, 7, 9])
>>> type(array_py)
<type 'list'>
>>> type(array_np)
<type 'numpy.ndarray'>
>>> array_np.append(2)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'numpy.ndarray' object has no attribute 'append'
>>>

因为我们将广泛使用 NumPy 数组,所以让我们仔细看看它们的基本功能。正如您已经注意到的,数组是通过调用 NumPy 的数组构造函数创建的。当您查看 array 对象的公开方法时,这种数据类型的科学性是显而易见的。它缺少一种相当简单的追加新值的方法,但是它提供了一些最常见的统计函数:

>>> a1 = numpy.array([1, 4, 5, 7, 9])
>>> a1.mean()   # calculate a mean value of the array
5.2000000000000002
>>> a1.std()    # calculate the standard deviation
2.7129319932501073
>>> a1.var()    # calculate the variance
7.3599999999999994
>>>

让我们先看看如何在列表中添加另一个元素。如您所见,标准列表方法 append()在这里不起作用。但是,NumPy 库有自己版本的 append 函数,可以用来追加元素:

>>> a1 = numpy.array([1, 2, 3])
>>> numpy.append(a1, [4])
array([1, 2, 3, 4])
>>>

与普通 Python 列表的另一个区别是如何访问多维数组:

>>> a1 = numpy.array([[1, 2, 3], [4, 5, 6]])
>>> a1[1, 1]   # second element of the second row
5
>>>

多维数组在每一行中必须有相同数量的条目,因为它们实际上是矩阵元素。只要数组中有足够的元素,就可以随时更改数组的形状:

>>> a = np.arange(16)
>>> a
array([ 0,  1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11, 12, 13, 14, 15])
>>> a.reshape(2, 8)
array([[ 0,  1,  2,  3,  4,  5,  6,  7],
       [ 8,  9, 10, 11, 12, 13, 14, 15]])
>>> a.reshape(4, 4)
array([[ 0,  1,  2,  3],
       [ 4,  5,  6,  7],
       [ 8,  9, 10, 11],
       [12, 13, 14, 15]])
>>> a.reshape(4, 5)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: total size of new array must be unchanged
>>>

因此,您已经看到了如何将元素追加到列表中,以及如何构造和使用多维数组。让我们尝试将另一行追加到二维数组中:

>>> numpy.append(a1, [7, 8, 9])
array([1, 2, 3, 4, 5, 6, 7, 8, 9])
>>>

这显然是错误的。我们希望出现第三行,但是我们得到的是一个一维列表,附加了额外的条目。所发生的是 NumPy 展平列表并向其追加新值,因为这就是 append()操作所做的——追加新元素,而不是子列表。

幸运的是,NumPy 还有另外两个函数,不仅允许向列表追加新行,还允许追加新列。vstack()函数追加新行,hstack()函数追加新列:

>>> numpy.vstack((a1, [7, 8, 9]))
array([[1, 2, 3],
       [4, 5, 6],
       [7, 8, 9]])
>>> numpy.hstack((a1, [[7], [8]]))
array([[1, 2, 3, 7],
       [4, 5, 6, 8]])
>>>

附加的便利函数允许您在数组中迭代:

>>> a = numpy.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
>>> # simple iterator returns subarrays
>>> for i in a: print i
...
[1 2 3]
[4 5 6]
[7 8 9]
>>> # the following flattens the array
>>> for i in a.flat: print i,
...
1 2 3 4 5 6 7 8 9
>>> # returns a tuple with the element "coordinates" and the element itself
>>> for i in numpy.ndenumerate(a): print i
...
((0, 0), 1)
((0, 1), 2)
((0, 2), 3)
((1, 0), 4)
((1, 1), 5)
((1, 2), 6)
((2, 0), 7)
((2, 1), 8)
((2, 2), 9)
>>>

显然,您可以像处理“普通”Python 数组一样,进行通常的切片和切块操作:

>>> a = numpy.array([[1, 2, 3, 4, 5], [6, 7, 8, 9, 0]])
>>> # get the middle 3 digits from the first row
... a[0, 1:4]
array([2, 3, 4])
>>> # same but from the second row this time
... a[1, 1:4]
array([7, 8, 9])
>>> # what about making a vertical cut at the third column?
... a[:,2]
array([3, 8])
>>>

最后,让我们看看一些高级的数组索引技术,我们将在本章后面用到。您已经熟悉了标准的 Python 数组索引,您可以在其中指定想要查看的特定项或一系列值。NumPy 数组对象也可以接受其他数组作为索引:

>>> a = np.arange(-10, 1)
>>> a
array([-10,  -9,  -8,  -7,  -6,  -5,  -4,  -3,  -2,  -1,   0])
>>> i = np.arange(0, 9, 2)
>>> i
array([0, 2, 4, 6, 8])
>>> a[i]
array([-10,  -8,  -6,  -4,  -2])
>>>

这些例子演示了数组操作的基础。我们将讨论其他主题,如排序、搜索和数组整形,因为我们的示例程序需要它们。

基本数学和统计运算

到目前为止,您可能已经得到了 NumPy 库是关于高级数组操作的印象。虽然数组数据类型是 NumPy 的核心,但是这个库不仅仅是关于数组操作的。NumPy 附带了一套广泛的科学例程,比如线性代数、统计和金融函数。在这里,我将向您展示一些我认为最有用的模块函数的基本示例。

NumPy 库提供了广泛的数学原语,比如所有元素的和、加、乘、除和乘方函数。大部分都是不言自明的,从下面的例子可以看出:

>>> import numpy as np
>>> a = np.linspace(1, 11, 8)
>>> a
array([  1.        ,   2.42857143,   3.85714286,   5.28571429,
         6.71428571,   8.14285714,   9.57142857,  11.        ])
>>># sum of all elements
... np.sum(a)
48.0
>>># round all elements to the nearest integer
... np.rint(a)
array([  1.,   2.,   4.,   5.,   7.,   8.,  10.,  11.])
>>># add two elements
... np.add(a, 100)
array([ 101.        ,  102.42857143,  103.85714286,  105.28571429,
        106.71428571,  108.14285714,  109.57142857,  111.        ])
>>># the second element can also be an array, but the shapes must match
... np.add(np.array([1, 2, 3]), np.array([10, 20, 30]))
array([11, 22, 33])
>>># similarly you can subtract the elements
... np.subtract(a, 10)
array([-9.        , -7.57142857, -6.14285714, -4.71428571, -3.28571429,
       -1.85714286, -0.42857143,  1.        ])
>>># multiply
... np.multiply(a, 10)
array([  10.        ,   24.28571429,   38.57142857,   52.85714286,
         67.14285714,   81.42857143,   95.71428571,  110.        ])
>>># ... or divide
... np.divide(a, 10)
array([ 0.1       ,  0.24285714,  0.38571429,  0.52857143,  0.67142857,
        0.81428571,  0.95714286,  1.1       ])
>>># ... raise each element to power from the second array
... np.power(a, 2)
array([   1.        ,    5.89795918,   14.87755102,   27.93877551,
         45.08163265,   66.30612245,   91.6122449 ,  121.        ])
>>>

以下是两个函数,可用于查找数组中的最大值和最小值:

>>> a
array([0, 7, 7, 2, 6, 3, 2, 8, 4, 3])
>>> np.amin(a)
0
>>> np.amax(a)
8
>>>

计算平均值和标准偏差

因为我们要构建一个报告系统,生成关于我们系统行为的统计报告,所以让我们来看看我们将要使用的一些统计函数。

很可能,最常用的函数是用于计算一系列元素的平均值。NumPy 库提供了两个函数来计算数组中所有数字的平均值:mean()和 average()。

mean()函数计算任意一组给定数字的简单数学平均值。

>>> a = np.arange(10.)
>>> a
array([ 0.,  1.,  2.,  3.,  4.,  5.,  6.,  7.,  8.,  9.])
>>> np.mean(a)
4.5
>>>

average()函数接受一个额外的参数,该参数允许您提供用于计算数组平均值的权重。请记住,权重数组必须与主数组长度相同。

>>> a = np.array([5., 5., 5., 6., 6.])
>>> np.mean(a)
5.4000000000000004
>>> np.average(a, weights=np.array([1, 1, 1, 5, 10]))
5.833333333333333
>>>

您可能想知道为什么要使用加权平均值。最受欢迎的用例之一是当您想要使一些元素比其他元素更重要时,尤其是当这些元素按时间顺序列出时。使用前面的例子,让我们假设我们最初使用的数字[5,5,5,6,6]代表系统负载读数,读数是每分钟获得的。现在,我们可以通过简单地将所有数字相加,然后除以数组中的元素总数来计算平均值(或算术平均值)(这就是 mean()函数的作用)。在我们例子中,结果是 5.4。然而,最后一次阅读——最近的——通常更令人感兴趣,也更重要。因此,我们在计算中使用权重,有效地告诉 average()函数哪些数字对我们更重要。从结果中可以看出,当我们指出它们的重要性时,6 的最后两个值对最终结果的影响更大。

较少为人所知和使用的统计函数是方差标准差。这两个指标彼此密切相关,并且是分布如何展开的度量。简而言之,这些是衡量数据集可变性的函数。方差计算为每个数据点距离平均值的平方的平均值。用数学术语来说,方差表示数据的统计离差。举个例子,假设我们在一个数组中有一组随机数据:[1,4,3,5,6,2]。这个数组的平均值是 3.5。现在我们需要计算数组中每个元素距离平均值的平方。距离的平方计算为(值*–*平均值) 2 。所以,比如第一个值是(1–3.5)2=(-2.5)2= 6.25。其余值如下:[6.25,0.25,0.25,2.25,6.25,2.25]。我们现在需要做的是计算这些数字的平均值,以获得原始数组的方差,在我们的例子中,平均值为 2.9(四舍五入)。下面是如何通过一个 NumPy 函数调用来执行所有这些计算:

>>> a
array([ 1.,  4.,  3.,  5.,  6.,  2.])
>>> np.var(a)
2.9166666666666665
>>>

我们确定这个数字表示距离平均值的平均平方距离,但是因为这个值是平方的,所以有点误导。这是因为它不是实际的距离,而是强调的距离值。我们现在需要获得这个值的平方根,以使它与其余的值一致。结果值代表数据集的标准差 。2.9 的平方根大约等于 1.7。这意味着数组中的大多数元素距离平均值不超过 1.7,在我们的例子中是 3.5。任何超出此范围的元素都是正常期望值的例外。图 11-1 说明了这个概念。在图表中,六个元素中有四个在标准偏差范围内,两个读数在范围之外。请记住,由于计算标准偏差的方式,数据集中总会有一些值距离平均值的距离大于集合的标准偏差。

9781484202180_Fig11-01.jpg

图 11-1 。数据集的均值和标准差

NumPy 库提供了一个方便的函数来计算任何数组的标准偏差值:

>>> a = np.array([1., 4., 3., 5., 6.,2.])
>>> a
array([ 1.,  4.,  3.,  5.,  6.,  2.])
>>> np.std(a)
1.707825127659933
>>>

到目前为止,我们示例中的数据集相当随机,数据点太少。大多数真实世界的数据,尽管看起来是随机的,却遵循一种被称为正态分布 的分布。例如,一个国家的人的平均身高可能是 5 英尺 11 英寸(大约是 1.80 米)。大多数人的身高接近这个值,但是当我们远离这个平均值时,我们会发现越来越少的人在这个范围内。分布在平均值处达到峰值,然后从平均值向两侧逐渐减小。分布模式呈钟形,由两个参数定义:数据集的平均值(分布的中点)和标准差(定义图形的“斜度”)。标准差越大,图形就越“平坦”,这意味着分布在可能值的范围内更加分散。因为分布是由标准偏差值描述的,所以可以得出一些有趣的观察结果:

  • 大约 68%的数据在平均值的一个标准偏差范围内。
  • 大约 95%的数据落在平均值的两个标准偏差距离内。
  • 几乎所有的数据(99.7%)都在平均值的三个标准偏差范围内。

为了更好地理解这一点,让我们来看看对一个大得多的数据集的分析。我生成了一组正态分布的随机数据。平均值(在数学文本中,通常标注为μ或 mu)为 4,标准差(也称为或西格玛)为 0.9。数据集由 10,000 个遵循正态分布模式的随机数组成。然后,我根据它们的值将所有这些数字放入适当的桶中,总共 28 个桶。该时段(或图上的条形)值是该时段范围内所有数字的总和。为了使它更有意义,我随后对桶值进行了规范化,因此所有桶的总和等于 1。因此,bucket 值现在表示数据集中出现的数字的概率或百分比。

你可以在图 11-2 中看到最终的数量分布直方图。条形由近似函数线包围,这有助于您形象化正态分布的形式。水平轴上 4 标记处的垂直线表示数据集中所有数字的平均值。从这条线,我们有三个标准差带:一个西格玛值距离,两个西格玛值距离,和三个西格玛值距离。如您所见,这直观地证明了几乎所有数据都包含在平均值的三个标准偏差距离内。

9781484202180_Fig11-02.jpg

图 11-2 。正态分布和标准差带

有几件事要记住。首先,图形形状几乎完全类似于正态分布模式的理论形状。这是因为我选择了一个大型数据集。对于较小的数据集,值更加随机,并且数据不会精确地遵循分布的理论形状。因此,如果想要获得有意义的结果,对大型数据集进行操作是很重要的。第二,正态分布被设计来模拟具有从–无穷大到+ 无穷大的任何值的过程。因此,它可能不太适合只有积极结果的过程。

假设您想要测量高速公路上的平均车速。显然,速度不可能是负的,但是正态分布允许这样。也就是说,理论模型允许,尽管概率极低,负速度。然而,在实践中,如果平均值距离 0 值超过四或五个标准偏差距离,则使用正态分布模型是相当安全的。

我们已经花了很多时间讨论和分析一个科学现象,但是这和本书的主题系统管理有什么关系呢?正如我提到的,大多数自然过程都是随机事件,但它们通常都聚集在一些值周围。以高速公路上汽车的平均速度为例。速度是有限制的,但这并不意味着所有的汽车都以那个速度行驶——有些会更快,有些会更慢。但是平均速度很有可能达到或低于限速。此外,大多数汽车将以接近平均水平的速度行驶。你离这个平均值的两边越远,以这个速度行驶的汽车就越少。如果你测量一组相当大的汽车的速度,你将得到速度分布图,它应该类似于正态分布图的理想模式。

这个模型也适用于系统使用。你的服务器只有在用户请求的时候才会执行工作。类似于高速公路上的汽车速度,系统负载将平均在某个值左右。

我选择了分布函数参数(平均值和标准偏差),以便它们在一个假想的四 CPU 服务器上模拟负载模式。正如你在图 11-2 中看到的,平均负载在 4 时达到峰值,这对于一个繁忙但没有过载的系统来说是很正常的。让我们假设服务器一直很忙,并且不遵循任何昼夜负载变化模式。虽然负荷几乎是恒定的,但总会有一些变化,但你离平均值越远,你达到那个读数的机会就越小。例如,下一次读数不太可能(准确地说是 32%的可能性)小于(大约)3 或大于(大约)5。同样,这条规则也适用于分别低于和高于 2 和 6 的读数——实际上,达到这些读数的几率不到 5%。

这告诉我们什么?好吧,知道了分布概率,我们就可以动态设置警报阈值。显然,我们并不太担心值太低,因为这不会对系统造成任何损害(虽然间接,它可能表明一些问题)。最有趣的是集合中的上限值。我们知道,每三个读数中有两个会落在第一个范围内(从平均值到每侧的一个标准偏差距离)。更高的百分比属于第二个级别;事实上,这将是大多数的阅读——超过 95%。您可以做出所有这些读数都正常并且系统运行正常的决定。然而,如果你遇到一个理论上只有 5%发生的读数,你可能想要得到一个警告信息。出现频率仅为 0.3%的读数令人担忧,因为它们与正常的系统行为相差甚远,因此您应该立即开始调查。

换句话说,您刚刚学习了如何定义什么是“正常”的系统行为,以及如何度量“异常”这是一个非常强大的工具,可以确定您在日常工作中可能使用的任何监视系统(比如 Nagios)的警告和错误阈值。我们将在应用中使用这种机制,它将自动更新阈值。

标准差和方差函数的补充函数是直方图计算 函数。它用于根据数值将数字分类到桶中。我用这个函数计算了图 11-2 中正态分布模式的条形大小。该函数接受一个需要排序的值的数组,还可以选择接受容器的数量(默认为 10),以及这些值是否应该进行规范化(默认为不进行规范化)。结果是两个数组的元组:一个包含 bin 大小,另一个包含 bin 边界。这里有一个例子:

>>> a = np.random.randn(1000)
>>> h, b = np.histogram(a, bins=8, normed=True, new=True)
>>> h
array([ 0.00238784,  0.02268444,  0.12416748,  0.30444912,  0.37966596,
        0.26146807,  0.08834994,  0.01074526])
>>> b
array([-3.63950476, -2.80192639, -1.96434802, -1.12676964, -0.28919127,
        0.5483871 ,  1.38596547,  2.22354385,  3.06112222])
>>>

函数 numpy . random . randn(<count>)用于生成均值为 0、标准差为 1 的正态分布集。还要记住,randn()从标准正态分布返回样本,因此不能保证两次运行的结果相同。

寻找数据集的趋势线

我们将在本章中构建的示例应用应该报告并帮助我们可视化各种读数的趋势。例如,假设我们正在收集关于 CPU 负载的数据。我们如何发现负载是否随着时间逐渐增加?一个显而易见的方法是看读数的图表;真正明显的趋势将立即显现。但是我们不想自己查看所有可能的图表,并试图找出一个趋势。如果负载的增加不是很明显,可能无法判断这些值在图上一般是趋于上升还是下降,因为它们将随机分散在某个平均值周围。

幸运的是,一个被称为回归曲线拟合 的成熟过程允许我们找到最适合任何给定数据集的函数。所得曲线是所提供值的近似值,通常是受随机噪声严重影响的一般函数或趋势。曲线拟合最流行和计算效率最高的方法之一叫做最小二乘法的方法。该方法假设最佳拟合曲线是与给定数据集的偏差平方和最小的曲线。换句话说,曲线应该尽可能接近所有的数据点。

定义这种曲线最常见的方法是使用多项式。多项式是仅使用加法和乘法运算就可以用固定长度函数表示的函数。作为乘法运算的一种表达方式,指数也是允许的,只要不是负数,用的是整数。

多项式函数的一个例子是y = 2 * x2*+x+4。*最大指数定义多项式函数的次数。在这个例子中,最大的指数是 2;因此,这是一个二次多项式。因此,通过使用最小二乘法,我们可以找到最适合给定数据集的多项式。为了简单起见,我们将只计算一次多项式,它定义了一个直线函数。该函数的斜率显示趋势是上升、下降还是不随时间显著变化。斜率由常数乘数定义。例如,对于 y = a * x + b ,直线的斜率由 a 的值定义。如果这个值是正数,这条线向上;如果是负数,这条线向下。第二个常数 b 定义了直线在垂直轴上的位置。

如您所见,一次多项式由两个常数定义:斜率和位置。在我们的函数中,这些分别是常数 ab 。现在的问题是如何从任何一组看似随机的数据中找到这些常数。实际计算过程有些冗长,这里就不赘述了。幸运的是,NumPy 提供了一个函数,该函数接受数据点的两个坐标数组( xy ),并返回多项式常数作为结果。您还可以指定所需多项式函数的次数,但我们将坚持一次多项式计算。以下示例生成一些随机数据,然后人为地在序列中引入一个斜率,然后计算得到的一次多项式常数:

>>> x = np.arange(100)
>>> y = np.random.normal(4., 0.9, 100)
>>> for i in range(100):
...    y[i] = y[i] + i/40
>>>a, b = np.polyfit(x, y, 1)

Image 注意你可以在en.wikipedia.org/wiki/Polynomial的维基百科页面上找到更多关于多项式函数以及常数是如何推导出来的细节。

在图 11-3 中,你可以看到原始数据(显示为点)以及最佳拟合或趋势线。尽管有些值比数据集的其余部分大得多,但实际趋势并不像您预期的那样陡峭。趋势函数常数也为我们提供了未来将要发生的事情的一个很好的指示。例如,在观察了 100 个值之后,我们确定了这个数据集的多项式函数是 y = 0.024 * x + 3.7 。因此,在一定的置信度下,我们可以假设再测量 100 次后的平均值为 0.024 * 200 + 3.7 = 8.5。如果我们假设这是我们系统的平均负载读数,我们将清楚地知道在不久的将来平均负载会是多少。这是一种可以用于容量规划的强大方法。

9781484202180_Fig11-03.jpg

图 11-3 。随机数据的最佳拟合趋势线

向文件读写数据

在某些情况下,您可能需要将数据写入文件,然后在以后读入数据以供进一步处理。NumPy 为此提供了几个输入/输出过程。在下面的示例中,数据存储在文本文件中,逗号字符用作分隔符。

>>> a = np.arange(16).reshape(4,4)
>>> a
array([[ 0,  1,  2,  3],
       [ 4,  5,  6,  7],
       [ 8,  9, 10, 11],
       [12, 13, 14, 15]])
>>> np.savetxt('data.txt', a, fmt="%G", delimiter=',')
>>> b = np.loadtxt('data.txt', delimiter=',')
>>> b
array([[  0.,   1.,   2.,   3.],
       [  4.,   5.,   6.,   7.],
       [  8.,   9.,  10.,  11.],
       [ 12.,  13.,  14.,  15.]])
>>>

许多流行的工具(如 Excel)都理解这种格式,因此您可以使用这种方法导出数据,并与使用不同工具的其他人交换文件。

用 matplotlib 表示数据

你可能想知道我用什么程序来生成你在本章中看到的图表。我使用了另一个 Python 库提供的工具,名为 matplotlib 。这个库的主要用途是创建和绘制各种科学图表。它允许您生成和保存图像文件,但它也带有一个图形界面,有缩放和平移选项。该库提供了用于生成 2D 和 3D 图的功能。

matplotlib 是一个复杂的软件,它提供的功能类似于 MATLAB 等商业产品。在这里,我们将只查看生成简单的 2D 图并向其添加注释。

Image 关于使用 matplotlib 的更多详细信息,请参见 Shai Vaingast 的开始 Python 可视化(Apress,2014)。

安装 matplotlib

通常,安装 matplotlib 有两种选择:使用 Python 包索引(PyPI)安装程序(pip)工具或从源代码构建包。下面是从 PyPI 安装库的命令:

$ sudo pip install matplotlib

Image 注意如果使用画中画工具,一定要检查安装的是哪个版本。我遇到过这样的情况,PyPI 上的 matplotlib 版本比最新版本要旧得多。

我推荐另一个选择:从最新的源代码包构建库。通过这种方式,您可以确保获得最新版本。这个过程并不复杂。首先,从 SourceForge 资源库下载源代码,网址为SourceForge . net/projects/matplotlib/files/matplotlib/。然后解包并运行以下命令来构建和安装 matplotlib 模块:

$ python setup.py build
$ sudo python setup.py install

根据您的 Linux 安装,您可能还需要安装一些 matplotlib 所依赖的、默认安装中不包含的附加包。例如,您可能需要安装 FreeType 开发库和头文件(适用于 Red Hat Linux 的 freetype-devel 包)以及用于操作 PNG 图像格式文件的程序的开发工具(适用于 Red Hat Linux 的 libpng-devel 包)。请查阅您的 Linux 发行版文档以了解具体的细节,例如安装过程和软件包名称。

安装完库后,可以通过发出以下命令来检查它是否正常工作:

$ python
Python 2.6.2 (r262:71600, Jan 25 2010, 18:46:45)
[GCC 4.4.2 20091222 (Red Hat 4.4.2-20)] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> import matplotlib
>>> matplotlib.__version__
'0.99.1.1'
>>>

了解库结构

matplotlib API 被组织成三层责任:

  • 第一层是 matplotlib.backend_bases。FigureCanvas 对象,表示在其上绘制图形的区域。
  • 第二层是 matplotlib.backend_bases。Renderer 对象,它知道如何在 FigureCanvas 对象上绘制。
  • 第三层是 matplotlib.artist.Artist 对象,它知道如何使用渲染对象。

通常,前两层负责与系统图形库对话,如 wxPython 和 PostScript 引擎,Artist 用于处理更高级别的图元,如线条和文本。大多数情况下,您将只使用艺术家对象。

艺术家分为两种不同的类型:画图原语和容器。图元是表示您想要绘制的对象的对象,例如直线、文本、矩形等等。容器是包含原语的对象。使用 matplotlib 创建图形的标准模式是创建一个主包含对象(Figure 类的实例),添加一个或多个轴或子图实例,然后使用这些实例的辅助方法来绘制图元。对于我的图,我通常使用 Subplot,因为它是 Axes 的子类,并提供更高级别的访问控制。

绘制图表

Subplot 类最广泛使用的方法之一是 plot()函数。它用于在子情节(或轴)上画线或标记。清单 11-1 演示了如何绘制正弦函数图。

清单 11-1 。画一个简单的图表

import matplotlib.pyplot as plt
import numpy as np

fig = plt.figure()
ax = fig.add_subplot(1, 1, 1)
x = np.arange(100)
y = np.sin(2 * np.pi * x / 100)
ax.plot(y)
plt.show()

如果你在运行 X 窗口管理器的系统上运行这个脚本,你会在一个单独的窗口中看到一个图表,如图图 11-4 所示。您将能够使用窗口功能,如平移和缩放,以及保存和打印文件。

9781484202180_Fig11-04.jpg

图 11-4 。一个 matplotlib 窗口实例的例子

更改绘图图元的外观

plot()函数更完整的语法是包含两个坐标数组, xy ,并指定绘图格式,例如绘图颜色和样式。下面的代码绘制了与清单 11-1 相同的图形,但是使用了一条红色虚线,这是由颜色的 r 快捷键和线型的:快捷键指定的。

x = np.arange(100)
y = np.sin(2 * np.pi * x / 100)
ax.plot(x, y, 'r:')

还可以使用关键字参数来指定图形的格式和绘图颜色。

ax.plot(x, y, linestyle='dashed', color='blue')

表 11-1 列出了最常用的格式化字符串字符和它们的关键字变元选项。

表 11-1 。图形样式格式化字符和关键字参数

|

样式快捷方式

|

关键字参数

|

描述

- linestyle='solid ' 实线
- linestyle= '虚线' 短划线
linestyle='dotted ' 点线
-. linestyle='dash_dot ' 点划线
O marker='circle ' 圆形标记(不与线条相连)
marker='dot ' 点标记(不与线条相连)
* marker='star ' 星形标记(没有连线)
+ 标记='plus ' 加号标记(不与线条相连)
X 标记 ='x' x 标记(不与线条相连)

当您使用快捷样式字符串时,有限的一组颜色可用,如表 11-2 所示。当您使用关键字参数来指定颜色时,您有更多的选择。

表 11-2 。图形颜色快捷键

|

样式快捷方式

|

颜色

K 黑色
W 白色的
B 蓝色
G 格林(姓氏);绿色的
稀有 红色
C 蓝绿色
M 品红
Y 黄色

如果只使用灰色阴影,可以将 color 关键字参数设置为表示 0 到 1 范围内的浮点数的字符串,其中 0 表示黑色,1 表示白色。请确保将其设置为字符串;不要直接分配浮点。

ax.plot(x, y, linestyle='dashed', color='0.5')  # good
ax.plot(x, y, linestyle='dashed', color=0.5)    # bad

也可以使用 HTML 十六进制字符串,比如#aa11bb *。*指定颜色的另一种方法是传递一个由 0 到 1 范围内的三个浮点数组成的元组,表示红色、绿色和蓝色分量,如下例所示:

ax.plot(x, y, linestyle='dashed', color=(0.2, 0.7, 0.3))

绘制条形图和使用多轴

另一种常用的绘图方法是使用 bar 原语,通过 bar()方法 创建。

清单 11-2 演示了创建一个有两个图形的图。第一个图形也放在极坐标系统上。两个图都使用条形图来显示数据。

清单 11-2 。使用笛卡尔坐标和极坐标绘制条形图

import matplotlib.pyplot as plt
import numpy as np

fig = plt.figure()

ax = fig.add_subplot(2, 1, 1, polar=True)
x = np.arange(25)
y = np.sin(2 * np.pi * x / 25)
ax.bar(x * np.pi * 2/ 25, abs(y), width=0.3, alpha=0.3)

ax2 = fig.add_subplot(2, 1, 2)
x2 = np.arange(25)
y2 = np.sin(2 * np.pi * x2 / 25)
ax2.bar(x2, y2)

plt.show()

请注意,我们现在有两个轴对象。它们是自动排列的,但是您必须指定它们在网格上的位置。所以当你初始化每个 Axes 对象时,你需要指定画布上将有多少行和多少列——在清单 11-2 的例子中是两行和一列。然后,对于每个轴对象,您需要给出序号,这将用于在画布网格上相应地放置它们。该示例分别使用 1 和 2:

ax = fig.add_subplot(2, 1, 1, polar=True) # rows, columns, id
...
ax2 = fig.add_subplot(2, 1, 2)            # rows, columns, id

polar 关键字参数指示轴将具有笛卡尔坐标系还是极坐标。如果将坐标系设置为极坐标,请记住完整的圆范围是从 0 到 2*π。

bar()方法使用两个可选的关键字参数:width 和 alpha,width 设置条形宽度,alpha 控制原语的透明度。你可以在图 11-5 中看到结果图。

9781484202180_Fig11-05.jpg

图 11-5 。在笛卡尔坐标和极坐标上绘制条形

使用文本字符串

您可能已经注意到,到目前为止,图表上显示的文本非常少。matplotlib 方便地将值添加到两个轴上,但这是它所能猜测的。添加像轴注释、图形标题和各种标签这样的文本是我们的责任。幸运的是,Axes 对象有多个辅助函数,可以帮助我们将文本添加到绘图中。您可以按如下方式放置文本:

  • xy 轴添加文本。
  • 添加情节标题。
  • 在图面上任意放置文本。
  • 在图上标注具体的点。

两个轴的标题和注释是在轴(或子情节)初始化期间通过使用适当的关键字参数来设置的。可以使用 text()方法并指定坐标和文本字符串来放置任意文本字符串。类似地,可以使用 annotate()函数创建注释。annotate()函数接受关键字参数,这些参数指示文本应该放置的位置(xytext 参数)和箭头应该指向的位置(xy 参数)。可选的 arrowprops 字典允许您广泛地配置注释箭头的外观,但是最简单的配置是 arrowstyle 字典项,您可以使用它来设置箭头的方向。

清单 11-3 展示了添加所有四种类型的文本。

清单 11-3 。向图表中添加文本

import matplotlib.pyplot as plt
import numpy as np

fig = plt.figure()
ax = fig.add_subplot(1, 1, 1,
                     title="Fourth degree polynomial",
                     xlabel='X Axis',
                     ylabel='Y Axis')
x = np.linspace(-5., 3)
y = 0.2 * x**4 + 0.5 * x**3 - 2.5 * x**2 - 1.2 * x - 0.6
ax.plot(x, y)
ax.grid(True)

ax.text(-4.5, 6, r'$y = 0.2 x⁴ + 0.4 x³ - 2.5 x² - 1.2 x - 0.6$', fontsize=14)
ax.annotate('Turning point',
            xy=(1.8, -7),
            xytext=(-0.8, -12.6),
            arrowprops=dict(arrowstyle="->",)
           )

plt.show()

注意文本字符串是如何格式化的。清单 11-3 使用 Python 原始字符串符号(只是提醒它被定义为 r'anystring ')并将整个表达式包含在$ characters 中。这指示 matplotlib 文本呈现引擎该文本将包含 TeX 标记指令的子集。

图 11-6 显示了清单 11-3 生成的图。

9781484202180_Fig11-06.jpg

图 11-6 。向图表中添加文本

将图保存到文件

到目前为止,我们已经研究了情节生成的各个方面。您已经看到,您生成的图显示在 GUI 的交互式窗口中。如果您只需要快速检查结果,这是完全可以接受的,但这也意味着您每次想要查看图表时都需要执行完整的计算。您可以选择从绘图显示窗口保存图形,但这是一个手动过程,不适合自动报告系统。

matplotlib 使用生成图像的图像后端进程。对于我们大多数只想使用最流行的格式(如 PNG、PDF、SVG、PS 和 EPS)的人来说,matplotlib 提供了反纹理几何(Agg)后端,它在幕后使用 C++ 反纹理图像渲染引擎。默认情况下,当您导入 pyplot 模块时,matplotlib 使用其中一个 GUI 引擎(例如,wxPython)。要改变这种行为,您必须首先指示它使用 Agg 后端,然后导入 pyplot。

清单 11-4 展示了如何用 Agg 后端初始化 matplotlib 并生成两个不同格式的文件。

清单 11-4 。将图像保存到文件

#!/usr/bin/env python

import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import numpy as np

fig = plt.figure()
ax = fig.add_subplot(1, 1, 1)
x = np.arange(100)
y = np.sin(2 * np.pi * x / 100)
ax.plot(y)
plt.savefig('sin-wave.png')
plt.savefig('sin-wave.pdf')

请注意,您不需要特别告诉 Agg 引擎文件类型。它很聪明,可以从文件扩展名中判断出来。如果必须使用非标准扩展名,或者根本不使用扩展名,可以使用可选的关键字参数来强制文件类型:

plt.savefig('sin-wave', format='png')

绘制统计数据

我们已经花了大量的时间讨论数据分析的各种统计方法。您知道如何检查数据集中是否有任何趋势,以及趋势是积极的还是消极的。您还知道如何计算数据集的平均值以及数据符合预定义边界的可能性(标准偏差)。现在让我们看看如何应用这些知识。我们将构建一个简单的应用,它定期运行并生成状态页面。这些页面是静态页面,将由 Apache web 服务器提供服务。

整理数据库中的数据

第九章详细介绍了我们的监控系统所使用的各种数据库表,以及它们之间的关系。因为我们对报告本章示例的探头读数感兴趣,所以我们最感兴趣的是探头读数表,它包含从传感器获得的原始数据。该表的值需要在处理前进行过滤,因此我们需要知道该读数属于哪个传感器,或者更准确地说,属于哪个探头。我们还需要按读取探针读数的主机对探针读数进行分组。换句话说,我们需要遍历主机表中的所有条目;然后,对于我们找到的每个主机,我们需要检查哪些探测器正在其上运行。一旦我们建立了完整的主机-探针组合,我们就需要获得一段时间内的传感器读数。

在我在本例中使用的测试数据库中,主机表中有两台主机(称为我的笔记本电脑和我的服务器)和两个探测器(称为 Used CPU %和 HTTP requests)。两台主机都在报告它们的 CPU 使用情况,但是只有服务器在为网页提供服务,因此它在监控传入的 HTTP 请求的数量。您可以下载包含数据的数据库文件以及本书的其余源代码。数据库预加载了随机生成的示例性能数据,但它试图遵循真实世界的使用模式。

在我们继续实现之前,让我们快速概述一下站点生成器脚本的基本结构。

显示可用主机

首先,我们需要找到数据库中存在的所有主机。一旦我们有了这个列表,我们将使用主机 ID 来搜索与这个主机相关联的所有探测器。我们需要收集探测器名称、警告和错误阈值以及主机探测器 ID,我们将使用它们来搜索探测器读数。清单 11-5 显示了用来收集这些信息的代码。

清单 11-5 。检索所有主机和关联的探测器

class SiteGenerator:
    def __init__(self, db_name):
        self.db_name = db_name
        self.conn = sqlite3.connect(self.db_name)
        self.hosts = []
        self._get_all_hosts()

    def _get_all_hosts(self):
        for h in self.conn.execute("SELECT * FROM host"):
            host_entry = list(h)
            query_str = """ SELECT hostprobe.id,
                                   probe.name,
                                   COALESCE(hostprobe.warning, probe.warning),
                                   COALESCE(hostprobe.error, probe.error)
                            FROM   probe,
                                   hostprobe
                            WHERE  probe.id = hostprobe.probe_id AND
                                   hostprobe.host_id = ?
                        """
            probes = self.conn.execute(query_str, (h[0],)).fetchall()
            host_entry.append(probes)
            self.hosts.append(host_entry)

在这段代码中,注意 COALESCE()函数,它从列表中返回第一个非空结果。请记住,我们可以在 probe 表中定义站点范围的阈值,但是我们也允许在 hostprobe 表中覆盖此设置。这使我们能够在每个主机的基础上设置阈值。因此,逻辑是检查特定于主机的阈值设置是否没有设置为 NULL,如果设置为 NULL,则返回默认值。下面是一个简单的例子来说明这个函数的行为:

sqlite> select coalesce(1, 2);
1
sqlite> select coalesce(NULL, 2);
2
sqlite> select coalesce(NULL, NULL);

sqlite>

绘制时间刻度图

现在我们有了进一步数据处理所需的所有信息:主机和相关的主机探测器。有许多不同的方法来表示我们收集的统计信息。在本例中,我们将按照两个参数之一对信息进行排序:探测器名称和时间刻度。为了简化实现,我们将使用预定义的可用时间表列表:1 天、7 天和 30 天。

我发现如果我将正在开发的网站结构可视化,开发模板和相应的代码会更容易。图 11-7 展示了我们网站的结构,以及样本 HTML 文件名(id 将被替换为实际值)和相应的 Jinja2 模板。

9781484202180_Fig11-07.jpg

图 11-7 。网站结构

索引页

索引页面是我们网站上最简单的页面。它需要生成最少的代码,因为我们不需要做任何计算。我们只是传入主机列表,这个列表已经在类初始化方法中生成了。

私有类方法加载模板并将主机列表传递给它:

def _generate_hosts_view(self):
        t = self.tpl_env.get_template('index.template')
        f = open("%s/index.html" % self.location, 'w')
        f.write(t.render({'hosts': self.hosts}))
        f.close()

模板遍历主机列表,并生成指向主机详细信息页面的链接:

<h1>Hosts</h1>
<ul>
{% for host in hosts %}
    <li><a href="host_{{ host[0] }}_details.html">{{ host[1] }}</a>
        ({{ host[2] }}:{{ host[3] }})</li>
{% endfor %}
</ul>

在这个例子中,我们将大量使用主机列表和主机探测列表。表 11-3 显示了每个字段的细节,所以你不需要记住每个字段包含的内容。

表 11-3 。主机和探测器列表字段

|

元素

|

元素字段

|

描述

自我主机 Zero 主机 ID
自我主机 one 主机的名称
自我主机 Two 主机的地址
自我主机 three 监控代理的端口号
自我主机 four 探测元素列表(以下字段)
主持人[4] Zero 主机探测器 ID
主持人[4] one 探测器的名称
主持人[4] Two 警告阈值(如果未定义,则为无)
主持人[4] three 错误阈值(如果未定义,则为无)

主机详细信息页面

对于主机详细信息页面,我们需要计算服务可用性数字,并在每个主机的 web 页面上显示它们。每个页面将有两个部分:一部分显示服务可用性统计信息,另一部分列出包含每个时间刻度/主机探测器组合的图表的页面链接。

清单 11-6 显示了两个私有方法,它们执行计算并生成网页。

清单 11-6 。生成主机详细资料网页

def _generate_host_toc(self, host):
    probe_sa = {}
    for probe in host[4]:
        probe_sa[probe[1]] = {}
        for scale in TIMESCALES:
            probe_sa[probe[1]][scale] =
            self._calculate_service_availability(probe, scale)
    t = self.tpl_env.get_template('host.template')
    f = open("%s/host_%s_details.html" % (self.location, host[0]), 'w')
    f.write(t.render({ 'host': host,
                       'timescales': TIMESCALES,
                       'probe_sa': probe_sa,
                     }))
    f.close()

def _calculate_service_availability(self, probe, scale):
    sa_warn = None
    sa_err  = None
    sampling_rate = self.conn.execute("""SELECT probeinterval
                                           FROM probingschedule
                                          WHERE hostprobe_id=?""",
                                      (probe[0],)).fetchone()[0]
    records_to_read = int(24 * 60 * scale / sampling_rate)
    query_str = """SELECT count(*)
                     FROM (SELECT probe_value
                             FROM probereading
                            WHERE hostprobe_id=?
                            LIMIT ?)
                    WHERE probe_value > ?"""
    if probe[2]:
        warning_hits = self.conn.execute(query_str,
                                         (probe[0], records_to_read, probe[2],)
                                        ).fetchone()[0]
        sa_warn = float(warning_hits) / records_to_read
    if probe[3]:
        error_hits   = self.conn.execute(query_str,
                                         (probe[0], records_to_read, probe[3],)
                                        ).fetchone()[0]
        sa_err  = float(error_hits) / records_to_read
    return (sa_warn, sa_err)

将为列表中找到的每个主机调用第一个函数 _generate_host_toc() 。作为一个参数,_generate_host_toc()函数接收一个主机结构,该主机结构还包含与之相关的所有探测器的列表(见表 11-3 )。然后,该函数遍历所有主机条目和所有时间刻度值,调用第二个函数 _ calculate _ service _ avail ability()。

_ calculate _ service _ avail ability()函数计算每个主机探测器在给定时间刻度内违反每个阈值的次数。为此,它需要计算出需要分析多少条记录。这取决于采样率。例如,如果我们每分钟都在读取一个探测器,我们每天将有 24 * 60 = 1440 条记录。但是,如果我们每 5 分钟执行一次检查,将会有 24 * (60/5) = 288 条记录。采样率存储在数据库中,因此我们只需获取该值并计算要分析的记录数。

下一步是计算值高于阈值设置的记录数。我们将使用的数据库查询对于两种值检查是相同的。所以我们构造一次,然后在 connection.execute()调用时使用它,并设置适当的阈值。让我们看一下 SQL 查询:

SELECT count(*)
  FROM (SELECT probe_value
          FROM probereading
         WHERE hostprobe_id=?
         LIMIT ?)
  WHERE probe_value > ?

这实际上是两个嵌套的查询。SQLite3 引擎将执行的第一个查询是内部 SELECT 语句,它为指定的主机探测器从列表中选择最后的 x 记录。外部 SELECT 语句计算列表中 probe_value 高于指定阈值的记录数。您可能会注意到,在内部 SELECT 语句中,我们没有对列表进行任何排序。那么我们有多确定我们真的会得到最后的记录,而不是从数据库中随机或半随机选择的记录呢?在 SQLite 中,每一行都有一个关联的 ROWID 值,所有行都按其行 ID 排序。如果我们不在 SELECT 语句中指定顺序,它将自动按行 id 排序。因为我们只是将行添加到数据库中,所以我们所有的行 id 都在序列中。因此,一个简单的 LIMIT SQL 语句保证我们将得到最后选中的行。

Image 注意你可以在官方 SQLite3 文档中找到更多关于行 ID 字段的信息,位于sqlite.org/lang_createtable.html#rowid。请注意,其他数据库引擎,如 PostgreSQL 和 MySQL,可能会有不同的行为。

仅当阈值可用时,才会执行 SQL 查询;否则,该函数返回 None 作为结果。一旦计算完成,我们加载模板并传递变量给它。该模板负责显示可用性统计信息,还负责生成指向包含图表的页面的链接。清单 11-7 显示了主机详细信息模板。

清单 11-7 。主机详细信息模板

<h1>Host details: {{ host[1] }}</h1>
  <h2>Views grouped by the timescales</h2>
    <p>Here you'll find all available probes for this host on the sametimescale.</p>
      <ul>
      {% for scale in timescales %}
        <li><a href="hsd_{{ host[0] }}_{{ scale }}.html">{{ scale }} day(s)view</a></li>
      {% endfor %}
      </ul>
  <h2>Views grouped by the probes</h2>
    <p>Here you'll find all available time scale views of the same probe</p>
      <ul>
      {% for probe in host[4] %}
        <li><a href="hpd_{{ probe[0] }}.html">{{ probe[1] }}</a></li>
      {% endfor %}
      </ul>
  <h2>Host statistics</h2>
    <h3>Service availability details</h3>
      {% for probe in probe_sa %}
      <h4>Availability of the "{{ probe }}" check</h4>
        <ul>
        {% for scale in probe_sa[probe] %}
          <li>On a {{ scale }} day(s) scale:
            <ul>
              <li>Warning: {{ probe_sa[probe][scale][0]|round(3) }}%</li>
              <li>Error: {{ probe_sa[probe][scale][1]|round(3) }}%</li>
            </ul>
          </li>
        {% endfor %}
        </ul>
      {% endfor %}

图表收集页面

图形收集页面从详细的主机信息页面链接。正如你在图 11-7 中看到的,我们有两种类型的图表收集页面:一种包含具有相同时间刻度的图表,但绘制来自不同探测器的数据,另一种绘制单个主机探测器的所有可用时间刻度图表。

虽然这些函数非常相似,但我将它们分成了两个函数调用,主要是为了保持代码的模块化结构。清单 11-8 显示了这两个函数。

清单 11-8 。生成图形收集页面

def _generate_host_probe_details(self, host_struct, probe_struct):
    t = self.tpl_env.get_template('host_probe_details.template')
    f = open("%s/hpd_%s.html" % (self.location, probe_struct[0]), 'w')
    images = []
    for scale in TIMESCALES:
        images.append([ scale,
                        "plot_%s_%s.png" % (probe_struct[0], scale),
                      ])
    f.write(t.render({'host': host_struct,
                      'probe': probe_struct,
                      'images': images,
                     }))
    f.close()

def _generate_host_scale_details(self, host_struct, scale):
    t = self.tpl_env.get_template('host_scale_details.template')
    f = open("%s/hsd_%s_%s.html" % (self.location, host_struct[0], scale), 'w')
    images = []
    for probe in host_struct[4]:
        images.append([ probe[1],
                        "plot_%s_%s.png" % (probe[0], scale),
                      ])
    f.write(t.render({'host': host_struct,
                      'scale': scale,
                      'images': images,
                     }))
    f.close()

_generate_host_probe_details()函数负责链接所有可用时标的所有主机探头图像。以下是该函数的模板代码:

<h1>Host: {{ host[1] }}</h1>
  <h2>Probe: {{ probe[1] }}</h2>
    {% for image in images %}
    <h3>Time scale: {{ image[0] }} day(s)</h3>
      <img src="{{ image[1] }}" />
    {% endfor %}

模板简单地遍历由函数生成的数据集。数据集包括图像文件名。

函数链接指定时间范围内的所有主机探测器。与第一个函数类似,这个函数生成图像文件名,并且这个列表在模板中使用。以下是该函数的模板代码:

<h1>Host: {{ host[1] }}</h1>
  <h2>Scale: {{ scale }} day(s)</h2>
    {% for image in images %}
    <h3>{{ image[0] }}</h3>
      <img src="{{ image[1] }}" />
    {% endfor %}

绘制性能图

我们已经参考了这些图像,但是我们还没有创建任何图表。在这一节中,我们将查看从数据库中读取数据并为每个可能的主机探测器/时间刻度组合生成单独图像的函数。如您所见,这些图像可以通过多种标准进行组合。在本例中,我们按照时间刻度值和探测器名称对它们进行分组。

除了简单的数据绘图,我们的函数还将计算数据集的一些统计参数:给定数据的趋势函数和标准偏差值,这将为我们提供新的警告和错误阈值的建议。当您刚刚开始监视一个新实体,并且不知道这些值应该是什么时,这可能特别有用。

清单 11-9 显示了绘制性能数据的函数。您应该从前面对 NumPy 和 matplotlib 模块的讨论中认识到数字和绘图函数。

清单 11-9 。绘制性能数据

def _plot_time_graph(self, hostprobe_id, time_window, sampling_rate, plot_title,
                     plot_file_name, warn=None, err=None):
    records_to_read = int(time_window / sampling_rate)
    records = self.conn.execute("""SELECT timestamp, probe_value
                                     FROM probereading
                                    WHERE hostprobe_id=?
                                    LIMIT ?""",
                                (hostprobe_id, records_to_read)).fetchall()
    time_array, val_array = zip(*records)

    mean = np.mean(val_array)
    std = np.std(val_array)
    warning_val = mean + 3 * std
    error_val = mean + 4 * std

    data_y = np.array(val_array)
    data_x = np.arange(len(data_y))
    data_time = [dateutil.parser.parse(s) for s in time_array]
    data_xtime = matplotlib.dates.date2num(data_time)
    a, b = np.polyfit(data_x, data_y, 1)
    matplotlib.rcParams['font.size'] = 10
    fig = plt.figure(figsize=(8,4))
    ax = fig.add_subplot(1, 1, 1)
    ax.set_title(plot_title + "\nMean: %.2f, Std Dev: %.2f, Warn Lvl: %.2f, Err Lvl:
                                       %.2f" %
                               (mean, std, warning_val, error_val))
    ax.plot_date(data_xtime, data_y, 'b')

ax.plot_date(data_xtime,
             data_x * a + b,
                 color='black', linewidth=3, marker='None', linestyle='-', alpha=0.5)
    fig.autofmt_xdate()
    if warn:
        ax.axhline(warn, color='orange', linestyle='--', linewidth=2, alpha=0.7)
    if err:
        ax.axhline(err, color='red', linestyle='--', linewidth=2, alpha=0.7)
    ax.grid(True)
    plt.savefig("%s/%s" % (self.location, plot_file_name))

_plot_time_graph()函数从一个 SQL 查询开始,该查询选择属于适当主机探测器的时间戳和探测器值字段。这里我们再次使用 LIMIT 语句从表中检索最新的结果。

请记住,只有当您使用 SQLite3 数据库时,这种方法才能保证有效,因为记录会自动按照它们的 ROWID 值进行排序。其他数据库的行为可能有所不同。同样,这个假设依赖于我们从不从数据库中删除任何记录的事实;因此,行 id 保证是连续的。

如果您正在使用不同的数据库引擎,或者如果您正在更新该表中的任何记录,并且您怀疑行 ID 可能会改变并且排序可能会改变,那么您可以通过时间戳字段强制排序。这确保了在 LIMIT 指令从结果列表中截取最后一部分之前,所有记录都将按照它们的时间戳进行排序。但是,这可能会对性能产生重大影响,可以通过在必填字段上添加索引来提高性能:

sqlite> .timer ON
sqlite> SELECT timestamp, probe_value FROM probereading WHERE hostprobe_id=1 LIMIT 5;
2009-12-16T21:30:20|0.0
2009-12-16T21:31:20|0.000431470294632392
2009-12-16T21:32:20|0.000311748085651205
2009-12-16T21:33:20|0.000777994331440024
2009-12-16T21:34:20|0.00475251893721452
CPU Time: user 0.000139 sys 0.000072
sqlite> SELECT timestamp, probe_value FROM probereading WHERE hostprobe_id=
1 ORDER BY timestamp LIMIT 5;
2009-12-16T21:30:20|0.0
2009-12-16T21:31:20|0.000431470294632392
2009-12-16T21:32:20|0.000311748085651205
2009-12-16T21:33:20|0.000777994331440024
2009-12-16T21:34:20|0.00475251893721452
CPU Time: user 0.192693 sys 0.018909
sqlite> CREATE INDEX idx_ts ON probereading (timestamp);
CPU Time: user 0.849272 sys 0.105697
sqlite> SELECT timestamp, probe_value FROM probereading WHERE hostprobe_id=
1 ORDER BY timestamp LIMIT 5;
2009-12-16T21:30:20|0.0
2009-12-16T21:31:20|0.000431470294632392
2009-12-16T21:32:20|0.000311748085651205
2009-12-16T21:33:20|0.000777994331440024
2009-12-16T21:34:20|0.00475251893721452
CPU Time: user 0.000169 sys 0.000136
sqlite>

我们正在绘制的数据是时间敏感的,因此根据对应的时间戳值绘制在轴上更有意义。matplotlib 有一个绘制定时数据的函数叫做 time_plot()。它的语法与 plot()函数的语法相同,但是数据参数(要么只有 X,要么同时有 X 和 Y 数据)必须是浮点数,表示自 0001-01-01 以来的天数,小数部分定义小时、分钟和秒。为此,我们需要执行两个操作:将文本字符串转换为 Python datetime 类型,然后将其转换为浮点数。这是通过下面这段代码完成的:

    import dateutil
...
    data_time = [dateutil.parser.parse(s) for s in time_array]
    data_xtime = matplotlib.dates.date2num(data_time)

如果可能,我们还绘制了警告和错误阈值线。每个图标题包括数据集的统计参数以及警告和错误阈值的建议值。图 11-8 显示了一个样本图。

9781484202180_Fig11-08.jpg

图 11-8 。性能数据图

摘要

在这一章中,我们看了使用 NumPy 库的基本统计分析。该库中的统计函数可以让您更好地了解正在监视的系统,尤其是在您记住以下要点的情况下:

  • 大多数现实生活中的数据,虽然看起来是随机的,但遵循正态分布模式。
  • 标准差告诉您每个值平均离数据集的平均值有多远。
  • 您可以使用标准差来确定警告和错误阈值的最佳值。
  • 一次多项式函数参数可用于识别数据集的总体趋势。
  • 使用数据趋势函数,您可以预测系统的未来行为。

十二、分布式信息处理系统

在前三章中,我们构建了一个基于 XML-RPC 消息协议的分布式监控系统。虽然它工作得很好,但它可能缺少一些功能,如消息优先级和任务调度。我们可以在已经编写的代码中添加额外的功能,但是我将向您展示如何用一个更健壮、功能更全的基于分布式任务队列 Celery 的系统来替换定制的消息传递平台。

消息和任务队列快速介绍

任务队列是一种强大的机制,允许您将工作分割成更小的块,将这些工作块发送到大量的机器,然后收集结果。根据您可以使用的机器数量,您可能会显著增加处理时间。

任务排队系统

从本质上讲,任务排队机制相对简单。主进程生成一个或多个需要处理的任务,然后将指令推入任务队列。一个(或多个)工作进程监视队列,一旦发现新任务,它就从队列中获取它。当任务完成时,结果(如果有)被发送回主进程。该过程如图 12-1 所示。

9781484202180_Fig12-01.jpg

图 12-1 。任务排队

这种机制可用于将任务分配给在单台机器或多台机器上运行的多个进程。

理解任务队列是在多个进程之间分配任务的一种方法是很重要的。它不是特定的实现或产品。这种任务分配可以应用于多个级别。例如,您可以使用一个线程作为主进程,多个线程作为辅助进程来创建应用级任务队列。然后线程可以使用共享变量在它们之间分配任务。另一个例子是主机级任务分发。一台主机可以是主控主机,它生成任务并将任务下推到处理这些任务的工作主机。一个实际的例子是 web 邮件系统,其中前端(主节点)节点接受用户输入并将其发送到邮件处理节点(工作节点),然后邮件处理节点充当邮件中继并将电子邮件发送出去。再者,任务队列甚至根本不需要和电脑有关系!例如,团队领导可以在便利贴上写下一天的任务,贴在白板上,然后团队成员可以在一天中拿起它们,做笔记上写的事情。

如果希望异步执行长时间运行的任务,任务队列非常有用。您需要任务被处理,但不需要马上得到结果。一个很好的例子是从 web 表单发送电子邮件。电子邮件可能需要一段时间才能发送,尤其是在远程邮件服务器不可用并且您需要多次重试发送邮件的情况下。同时,你不希望用户一直等到邮件发出。因此,web 前端从用户那里获取 web 表单数据,将其发送到邮件中继进行进一步处理,并指示用户电子邮件正在发送。

高度分布式任务队列的一个例子是 Google 的 Appengine 任务队列。你可以在developers . Google . com/app engine/docs/python/task queue上阅读关于实现的更多信息。任务队列的其他例子有 Resque(【https://github.com/resque/resque】)是 Ruby 应用的任务队列库;jesque(【https://github.com/gresrun/jesque】),这是一个用 Java 语言实现的 Resque 芹菜(www.celeryproject.org),我们将在本章中讨论。

理解任务队列是多个协同工作的组件的整体是非常重要的:主进程,它通常结合多个子系统,例如任务执行调度器;消息队列,用于通信目的;和实现任务执行算法的工作进程。

消息队列系统

任务队列中的核心组件之一是消息队列。消息队列是一种在进程和系统之间共享信息的机制。任务队列使用消息队列在任务队列系统的不同组件之间进行通信。例如,当主进程需要向一个工作进程发送任务时,它使用消息队列来传递消息。在前面的手动任务队列示例中,白板扮演消息队列的角色。团队领导(主流程)使用白板(消息队列)向团队成员(工作流程)发送消息(在便利贴上手写的文本)。

有时您会发现消息队列系统被称为消息代理。有许多不同的消息队列;一些流行的例子是:

  • ActiveMQ(activemq.apache.org)
  • rabbitq(http://www . rabbitq . com
  • zeromq(??)} http://zerocq . org 的缩写形式的缩写形式为 zerocq(零、零、零、零、零、零、零、零、零、零、零、零、零、零、零、零、零、零、零、零、零、零、零、零、零、零

通常,消息队列和任务队列是非常松散耦合的。例如,Celery 可以使用以下消息代理之一:

如您所见,您并不局限于专用的消息队列;您可以从各种各样的专用工具和通用数据库中进行选择。

设置 Celery 服务器和客户端

在这一节中,我们看看如何安装和配置 Celery 及其所有要求。我们还会看到一些基本的任务队列使用模式。

安装和设置 RabbitMQ

我将使用推荐的消息队列 RabbitMQ,它是受支持平台中功能最全、最稳定的。除非您有非常好的理由使用不同的平台,否则也要在部署中使用 RabbitMQ。如果您正在构建大型系统,请小心,因为用许多不同的队列扩展 RabbitMQ 可能会有问题;您可能需要先做一些性能测试。

RabbitMQ 在大多数流行的 Linux 发行版上都可以作为一个包获得。如果您使用的是基于 RedHat 的系统,可以使用以下命令安装 RabbitMQ:

$ sudo yum install rabbitmq-server

安装好软件包后,启动它并检查它是否正常运行:

$ sudo systemctl start rabbitmq-server
$ sudo systemctl status rabbitmq-server
rabbitmq-server.service - RabbitMQ broker
   Loaded: loaded (/usr/lib/systemd/system/rabbitmq-server.service; disabled)
   Active: active (running) since Sun 2014-07-20 12:30:22 BST; 25s ago
  Process: 304 ExecStartPost=/usr/lib/rabbitmq/bin/rabbitmqctl wait /var/run/rabbitmq/pid (code=exited, status=0/SUCCESS)
  Process: 32746 ExecStartPre=/bin/sh -c /usr/lib/rabbitmq/bin/rabbitmqctl status > /dev/null 2>&1 (code=exited, status=2)
 Main PID: 303 (beam)
   CGroup: /system.slice/rabbitmq-server.service
           ├─303 /usr/lib64/erlang/erts-5.10.4/bin/beam -W w -K true -A30 -P 1048576 -- -root /usr/lib64/erlang -progname erl -- -home /var/lib/rabbitmq -- -pa /usr/lib/rabbitmq/lib/rabbitmq_server-3.1.5/sbin/../ebin -noshell -noinput -s rabbit boot -sname rabbit@fedora -boot start...
           ├─334 /usr/lib64/erlang/erts-5.10.4/bin/epmd -daemon
           ├─403 inet_gethost 4
           └─404 inet_gethost 4

Jul 20 12:30:19 fedora.local rabbitmq-server[303]: RabbitMQ 3.1.5\. Copyright (C) 2007-2013 GoPivotal, Inc.
Jul 20 12:30:19 fedora.local rabbitmq-server[303]: ##  ##  Licensed under the MPL.  See http://www.rabbitmq.com/
Jul 20 12:30:19 fedora.local rabbitmq-server[303]: ##  ##
Jul 20 12:30:19 fedora.local rabbitmq-server[303]: ##########  Logs: /var/log/rabbitmq/rabbit@fedora.log
Jul 20 12:30:19 fedora.local rabbitmq-server[303]: ######  ##  /var/log/rabbitmq/rabbit@fedora-sasl.log
Jul 20 12:30:19 fedora.local rabbitmq-server[303]: ##########
Jul 20 12:30:22 fedora.local rabbitmq-server[303]: Starting broker... completed with 0 plugins.
Jul 20 12:30:22 fedora.local rabbitmqctl[304]: ...done.
Jul 20 12:30:22 fedora.local systemd[1]: Started RabbitMQ broker.
Jul 20 12:30:46 fedora.local systemd[1]: Started RabbitMQ broker.

如果您没有看到任何错误消息,这意味着 RabbitMQ 已经成功安装并启动。真的就这么简单!如果您需要对服务器配置进行任何更改(例如,更改服务器绑定的端口),您可以创建一个名为/etc/rabbitmq/rabbitmq.conf 的配置文件。有关配置参数的更多详细信息,可以在位于www.rabbitmq.com/configure.html的 rabbitmq 官方文档中找到。

安装和设置芹菜

一旦 RabbitMQ 服务器安装在主节点和工作节点上,就可以继续进行 Celery 的安装和配置了。请记住,主服务器和辅助服务器不一定需要位于不同的主机上;出于测试目的,两者都可以设置在同一台主机上。

大多数流行的 Linux 发行版上都有 Celery 软件包。在基于 RedHat 的系统上,您可以通过运行以下命令来安装 Celery:

$ sudo yum install python-celery

在基于 Debian 的系统上,包名是相同的:

$ sudo apt-get install python-celery

但是,我建议从 PyPI 存储库中安装 Celery 包,因为它将包含最新的稳定版本:

$ sudo pip install celery

创建芹菜系统用户和组

首先,您应该确保在所有工作节点上自动启动 Celery 进程。这不是必需的,您可以在每次需要时手动启动 Celery 进程,但是这种方法不可伸缩。以下示例假设您正在使用基于 RedHat 的系统,并且 systemd 控制系统服务。如果您使用的是不同的 Linux 发行版,那么您必须调整示例。

出于安全原因,不建议您以 root 用户身份运行 Celery。更好的方法是创建一个专用的用户和组,并在这些凭证下运行 Celery 守护程序。

要创建新用户并检查其 UID 和 GID,请运行以下命令:

# useradd --system -s /sbin/nologin celery
# id celery
uid=987(celery) gid=984(celery) groups=984(celery)

创建芹菜项目目录和示例应用

所有 Celery 应用必须位于一个项目目录中,该目录可以按如下方式创建:

# mkdir /opt/celery_project
# chown celery:celery /opt/celery_project

您还需要一个示例应用来测试您的配置。我们将在本章的后面查看应用开发的具体细节,但现在让我们创建一个名为/opt/celery_project/tasks.py 的文件,其内容如下:

from celery import Celery

app = Celery('tasks', broker='amqp://guest@localhost//', backend='amqp')

@app.task
def hello(name='Anonymous'):
    return "Hello, %s" % name

Image 注意如果你用的是比 3.1 更早的芹菜版本,用‘芹菜’对象名代替‘app’;早期版本希望找到这个特定的名称。在 3.1 及更高版本中,名称不再重要。

创建所需的系统目录

需要两个目录:一个用于存储日志文件,另一个用于存储临时 PID 文件。如果使用以下内容创建配置文件/usr/lib/tmpfile.d/celery.conf,systemd 进程会自动创建这些目录:

d /run/celery 0755 celery celery -
d /var/log/celery 0755 celery celery -

创建系统配置文件

systemd 进程管理守护进程需要一个名为/var/lib/systemd/system/celery . service 的系统定义文件,其内容如下:

[Unit]
Description=Celery workers
After=network.target

[Service]
Type=forking
User=celery
Group=celery
EnvironmentFile=-/etc/conf.d/celery
WorkingDirectory="${CELERYD_CHDIR}"
ExecStart=/bin/celery multi start –A "${CELERY_APP}" "${CELERYD_NODES}" \
          --pidfile="${CELERYD_PID_FILE}" \
          --logfile="${CELERYD_LOG_FILE}" --loglevel="${CELERYD_LOG_LEVEL}"
ExecStop=/bin/celery multi stopwait –A "${CELERY_APP}" "${CELERYD_NODES}" \
          --pidfile="${CELERYD_PID_FILE}"
ExecReload=/bin/celery multi restart –A "${CELERY_APP}" "${CELERYD_NODES}" \
           --pidfile="${CELERYD_PID_FILE}" \
           --logfile="${CELERYD_LOG_FILE}" --loglevel="${CELERYD_LOG_LEVEL}"

[Install]
WantedBy=multi-user.target

它需要名为/etc/conf.d/celery 的环境配置文件,包含以下内容:

CELERY_APP="tasks"
CELERYD_NODES="worker"
CELERY_BIN="/bin/celery"
CELERYD_PID_FILE="/run/celery/%n.pid"
CELERYD_LOG_FILE="/var/log/celery/%n.log"
CELERYD_LOG_LEVEL="DEBUG"
CELERYD_USER="celery"
CELERYD_GROUP="celery"

创建这些文件后,您可以使用以下命令启用并启动该服务:

# systemctl enable celery
# systemctl start celery

如果一切正常,您应该在日志文件/var/log/celery/celery.log 中看到以下输出:

[2014-07-20 19:00:52,594: WARNING/MainProcess] /usr/lib/python2.7/site-packages/celery/apps/worker.py:161: CDeprecationWarning:
Starting from version 3.2 Celery will refuse to accept pickle by default.

The pickle serializer is a security concern as it may give attackers
the ability to execute any command.  It's important to secure
your broker from unauthorized access when using pickle, so we think
that enabling pickle should require a deliberate action and not be
the default choice.

If you depend on pickle then you should set a setting to disable this
warning and to be sure that everything will continue working
when you upgrade to Celery 3.2::

    CELERY_ACCEPT_CONTENT = ['pickle', 'json', 'msgpack', 'yaml']

You must only enable the serializers that you will actually use.

  warnings.warn(CDeprecationWarning(W_PICKLE_DEPRECATED))
[2014-07-20 19:00:52,612: INFO/MainProcess] Connected to amqp://guest:**@127.0.0.1:5672//
[2014-07-20 19:00:52,620: INFO/MainProcess] mingle: searching for neighbors
[2014-07-20 19:00:53,628: INFO/MainProcess] mingle: all alone
[2014-07-20 19:00:53,638: WARNING/MainProcess] worker@fedora.local ready.

有一个警告消息,我们将很快解决,但除此之外,启动过程看起来很好。您还可以使用命令行工具来检查系统的运行状况:

# celery status
worker@fedora.local: OK

1 node online.
# celery inspect ping
-> worker@fedora.local: OK
        pong

测试对芹菜服务器的访问

在我们继续之前,让我们通过从一个简单的 Python 应用连接到 Celery 服务器来确保一切正常。您将导入我们之前编写的应用代码,因此请确保在/opt/celery_project/目录中运行以下命令:

# cd /opt/celery_project/
# python
Python 2.7.5 (default, Feb 19 2014, 13:47:28)
[GCC 4.8.2 20131212 (Red Hat 4.8.2-7)] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> from tasks import hello
>>> result = hello.delay('World')
>>> result
<AsyncResult: 926aabc8-6b1b-424e-be06-b15bcf92137e>
>>> result.id
'926aabc8-6b1b-424e-be06-b15bcf92137e'
>>> result.ready()
True
>>> result.result
'Hello, World'
>>> result.status
'SUCCESS'
>>>

芹菜基础知识

在这一节中,我们来看看芹菜的基本用法。

典型芹菜应用的布局

事实上,每个基于 Celery 的应用都是一个至少由两个组件组成的系统:一个生成工作并消耗结果的主进程,以及一个执行工作请求的工作进程。通常情况下,您会有多个工作进程,但您至少需要一个。正如您在前面发现的,这些进程之间的通信是通过消息队列完成的,因此这些进程不需要驻留在同一个物理操作系统上。

理解主进程和工作进程都需要访问正在执行的代码是很重要的。例如,如果您编写一个 web 邮件处理系统,您将编写一个处理发送邮件的库。相同的库需要在主机器和所有工作机器上可用。只有工作机器将执行代码,但是主机器需要能够检查代码,以便它能够通过发送任务名称和正确的参数来适当地构造工作请求。

这些模块的分发完全由您决定。芹菜不提供这样的功能。您可以使用 Celery 来分发这些模块,就像我们对定制的 XML-RPC 系统所做的那样,但是通常这是不可取的。如果你想自动化一个模块部署过程,最好看看配置管理工具,比如 ansi ble(www.ansible.com/)、salt stack(【http://www.saltstack.com)、Puppet()或者 Chef(www.getchef.com)。

创建任务模块

在上一节中,当我们对 Celery 设置进行基本测试时,我们创建了一个名为 tasks.py 的文件,其中包含一个方法。当您编写更大的应用时,您将有不止一个任务方法可供主流程使用。您可以将所有任务保存在一个文件中,但是该文件可能很快变得无法管理。因此,建议创建一个专用模块来封装所有任务。

工作进程和主进程应用文件

首先,删除前面创建的 tasks.py 文件,然后在/opt/celery_project 中创建以下目录结构。现在创建空文件,因为您将在本例中填充它们。这里,您创建了一个简单的 Celery 模块,它有两组任务——一组用于算术运算,一组用于几何运算:

# pwd
/opt/celery_project
# tree
.
├── calculator.py
└── celery_app
    ├── celeryconfig.py
    ├── celery.py
    ├── __init__.py
    └── tasks
        ├── arithmetics.py
        ├── geometry.py
        └── __init__.py

2 directories, 7 files

让我们讨论一下如何使用每个文件和目录:

  • 第一个文件 calculator.py 是实际运行的应用,它提交任务进行处理。这是主流程。
  • 名为 celery_app/的目录是一个 Python 模块,它将包含与 celery 后台任务处理相关的所有文件。
  • Celery.py 是 Celery 应用的主文件,它初始化 Celery 应用并设置其配置。
  • Celeryconfig.py 是一个配置文件。是芹菜. py 进口使用的。
  • init。py 是一个空文件,它的唯一目的是表明这个目录是一个 Python 模块。
  • 子目录 tasks/是包含按公共属性分组的特定子模块的子模块,例如,所有算术运算都放在 arithmetics.py 子模块中。

这可能看起来像是一个不必要的复杂布局,但实际上它并不难设置,如果您的应用变得更大,它会提供很大的灵活性。

芹菜配置文件概述

通常,Celery 应用不需要太多的配置。您必须告诉它在哪里寻找新的作业(这通常是运行在相同主机上的消息队列),在哪里存储结果(通常是相同的消息队列),并且可能提供一些特定于环境的设置。管理 Celery 配置文件最简单的方法是将所有配置项放在一个单独的文件中,并从主应用导入它。因此,在我们的例子中,配置文件名为 celeryconfig.py,它包含清单 12-1 中的设置。

清单 12-1 。芹菜配置设置

CELERY_TASK_SERIALIZER = 'json'         # Only allow object serialization using JSON
CELERY_RESULT_SERIALIZER = 'json'       # Previously the default was Python pickle objects,
CELERY_ACCEPT_CONTENT = [ 'json', ]     #   but they are not secure, and will be discontinued
BROKER_URL = 'amqp://guest@localhost//' # Where to look for new jobs
CELERY_RESULT_BACKEND ='amqp'           # Where to send job results
CELERY_IMPORTS = ('celery_app.tasks.geometry',     # Modules that contain Celery tasks
                  'celery_app.tasks.arithmetics',) #
CELERY_TASK_RESULT_EXPIRES=3600         # How long keep tasks results before purging them

你可以在官方的芹菜文档网页上找到配置选项的完整列表,docs.celeryproject.org/en/latest/configuration.html;然而,最常用的项目在表 12-1 中列出。

表 12-1 。一些最常用的芹菜配置物品

|

配置项目

|

描述

芹菜 _ 时区 默认情况下,芹菜采用 UTC 时区;如果需要在邮件时间戳中设置特定于位置的时区,可以修改此设置。要获得最新的时区名称列表,请查看en.wikipedia.org/wiki/List_of_tz_database_time_zones
CELERYD _ 并发 该设置允许您指定允许 Celery workers 运行多少个并发进程或线程。默认情况下,该设置被设置为可用 CPU 的数量;然而,这是非常保守的。除非您正在进行大量的计算,并且这些进程确实受到 CPU 的限制,否则您至少应该将这个数字增加一倍。如果您的进程主要是 I/O 绑定的,那么您通常可以比 CPU 的数量多 5 到 10 倍。
芹菜 _ 结果 _ 后端 默认情况下,Celery 不使用任何后端来存储任务结果。在大多数情况下,这可能是可以接受的行为。例如,如果您正在运行发送电子邮件的后台任务,您可能希望工作进程更新数据库中指示任务状态等的多个表。您可以将主流程视为分派任务的调度程序,并不真正关心任务发生了什么。更新系统取决于任务流程。这是一种有效的方法,尤其是当工作进程需要更新运行系统的许多方面时。然而,另一种方法是由主进程处理数据。在这种情况下,主进程向长时间运行的工作进程发出指令,然后获取结果,或者自己更新系统状态,或者将任务交给另一个工作进程。您选择哪个选项取决于系统和您的偏好。我的建议是将任务明确分为两类:一类是与外部系统(邮件服务器、文件服务器、web 服务器等)交互的任务。)和另一个处理内部系统状态(更新内部数据库、增加计数器等)。).所选择的后端系统不需要与用于通信的平台相同。因此,您可以使用 RabbitMQ(设置值为' amqp ')进行消息排队,并将结果存储在 redis 数据库中(设置值为' Redis ')。
芹菜 _ 结果 _ 序列化程序 当结果由工作进程生成时,它们需要存储在您使用以前的设置选择的任何后端媒体中。默认是 Python 的 pickle 序列化方法。简而言之,pickle 使用字节码序列化对象,然后在接收端评估这个字节码,而不首先验证它的安全性。因此,如果有人设法发送恶意数据,假装来自工作进程,您的主进程可能会执行收到的代码;这可以用来侵入你的系统。因此,默认值将会改变,如果不指定不同的序列化方法,Celery 会在启动时警告您。最方便和安全的选择之一是使用 JSON 数据结构。
芹菜 _ 接受 _ 内容 此设置是允许的序列化程序列表。如您所知,主进程为远程执行准备数据,并将其发送给工作进程。为此,主进程使用由 CELERY_TASK_SERIALIZER 设置指定的 serialize 来序列化数据。当 worker 节点完成处理数据时,结果(如果有)在被发回之前被序列化。如何序列化由 CELERY_RESULT_SERIALIZER 设置定义。CELERY_ACCEPT_CONTENT 没有说如何序列化任务参数或者结果;它只列出允许的序列化程序。这允许您让一些 worker 节点在 JSON 中生成结果,让一些 worker 节点在 YAML 中生成结果;如果您在这里列出这两种方法,它们将被接受。
芹菜 _ 任务 _ 结果 _ 过期 如果您存储结果,该设置会告诉 Celery 在删除之前要保存多长时间(以秒为单位)。默认设置是保留一天的所有结果;如果将其设置为零,则不会删除结果。
芹菜 _ 任务 _ 序列化程序 类似于 CELERY_RESULT_SERIALIZER,但是该设置指示在向远程工作进程发送数据时使用何种序列化方法。
芹菜 _ 进口 这是芹菜工启动时需要导入的模块列表。Celery worker 将搜索 Celery 任务兼容函数(由 Celery.task decorator 修饰)。

主芹菜申请文件

该文件用于初始化 Celery 应用,并从配置模块加载配置设置。文件的内容非常简单明了,如清单 12-2 所示。

清单 12-2 。主芹菜申请文件

from __future__ import absolute_import

from celery import Celery
from celery_app import celeryconfig

app = Celery()
app.config_from_object(celeryconfig)

if __name__ == '__main__':
    app.start()

您可能想知道第一个 import 语句是关于什么的。这是必要的,因为我们将我们的模块命名为 celery.py,但是还有一个系统范围的包具有相同的名称。因此,当 Python 解释器看到您想要“导入芹菜”时,它会感到困惑你要本地文件还是要官方包?为了解决这种不确定性,您告诉 Python 解释器,每当出现名称冲突时,优先考虑通过 sys.path 提供的模块。这允许您为您的 Celery 应用取一个方便的名称,并且仍然导入正式的包。

芹菜任务

正如您已经从目录结构中看到的,我们将 Celery 将要管理的所有后台任务移到了一个单独的子模块目录中。在那个模块中,我们有两个文件用于不同的任务集,算术运算,如清单 12-3 所示。

清单 12-3 。算术运算任务文件

from __future__ import absolute_import

from celery_app.celery import app

@app.task
def add(a, b):
    return a + b

@app.task
def sub(a, b):
    return a - b

和几何运算,如清单 12-4 所示。

清单 12-4 。几何运算任务文件

from __future__ import absolute_import

from celery_app.celery import app

@app.task
def rect_area(h, w):
    return h * w

@app.task
def circle_area(r):
    import math
    return math.pi * r

系统配置

我们需要调整 systemd 配置文件,以便它们与我们当前的项目布局相匹配。您修改了/etc/conf.d/celery 文件,使其指向您的新任务模块:

CELERY_APP="celery_app.celery"

现在,当您使用以下命令重新启动 Celery 守护程序时,它应该会获得新的 Celery 应用文件:

# systemctl restart celery

芹菜主应用

最后,创建 Celery master 应用,并测试 Celery 进程是否正在运行,以及任务是否可用。简单的测试应用代码在 calculator.py 文件中:

#!/usr/bin/env python

from celery_app import tasks

def test_tasks():
    print 'Submitting job...'
    r = tasks.geometry.rect_area.delay(2, 2)
    print r.info
    print 'Job completed'

if __name__ == '__main__':
    test_tasks()

如果运行它,您应该会看到以下结果:

# ./calculator.py
Submitting job...
4
Job completed
#

路由任务

消息队列系统提供的关键特性之一是路由发送到队列的消息的能力。由于任务队列系统(如 Celery)通常基于消息队列系统(如 RabbitMQ ),所以它们继承了相同的功能。

在较小和较简单的系统中,单个队列通常就足够了,但是对于较大的系统,您需要能够将任务分组到特定的工作人员组,这就是队列的设计目的。

在消息队列系统内部

图 12-2 是一个基于 AMQP 的典型消息队列系统如何工作的高级概述。芹菜隐藏了大部分的复杂性,但是在考虑消息路由之前,至少有一个总体的概念是很好的。

9781484202180_Fig12-02.jpg

图 12-2 。典型的消息队列系统架构

图 12-2 展示了消息分发机制中的主要参与者。

这是一个简化的工作流程:

  • 您的应用(生产者)调用一个后台任务(在我们的示例应用中,它是:tasks . geometry . rect _ area . delay(2,2)。
  • 任务细节,如名称、参数和目标队列(如果指定的话),被序列化并提交给交换。
  • 然后,作为 RabbitMQ 一部分的 Exchange 将邮件转发到一个可用的队列。该标准定义了四种不同类型的交换类型:直接(如果匹配消息的路由关键字,则将任务发送到一个队列)、扇出(将任务发送到绑定到它的所有队列)、主题(将消息发送到具有匹配路由关键字模式的所有队列)和头(基于消息头分发消息)。路由关键字是一个标记,每个消息都可以用它来标记。当队列被绑定到交换机时,它们也会被分配一个路由关键字(或路由关键字模式)。这允许 exchange 相应地路由邮件。
  • 消息到达消费者进程(工作进程),在那里消息被处理并从队列中删除。

如果你对《AMQP 议定书》的细节感兴趣,你可以在官方的 AMQP 车型描述页面找到更多信息:www.openamq.org/tutorial:the-amq-model

深入讨论消息队列系统超出了本书的范围,尤其是这个主题如此广泛。如果你对消息队列系统感兴趣,我推荐 Alvaro Videla 和 Jason J. W. Williams 的rabbit MQ in Action:Distributed Messaging for every one

将工作节点绑定到特定队列

对于基本的应用,您需要做两件事来有效地使用队列:首先,您需要指示 worker 节点绑定到特定的队列;然后,您需要标记任务,以便它们被正确地路由。

您在启动时将 worker 节点绑定到特定的队列。默认情况下,所有未标记的任务都被发送到名为(标记为)“celery”的默认队列中如果未指定任何要绑定到的队列,则工作进程将自动绑定到该队列。让我们创建一个新队列,并将其命名为“calc ”,以便所有与计算相关的任务都只发送给绑定到该队列的工作线程。

首先,您需要向/etc/conf.d/celery 添加新的设置:

CELERY_QUEUES="calc"

然后,确保当芹菜守护进程启动时,它使用这个参数。您需要修改系统服务定义文件/usr/lib/systemd/system/celery . service:

ExecStart=/bin/celery multi start "${CELERYD_NODES}" -A "${CELERY_APP}" -Q "${CELERY_QUEUES}" --pidfile="${CELERYD_PID_FILE}" --logfile="${CELERYD_LOG_FILE}" --loglevel="${CELERYD_LOG_LEVEL}"

如果您现在重新启动 Celery 进程,您将看到该进程只绑定到新队列:

# ps auxww | grep celery
celery    4760  0.7  1.0 246404 21472 ?        S    14:27   0:00 /usr/bin/python -m celery worker -n worker@fedora.local -A celery_app.celery --loglevel=INFO -Q calc --logfile=/var/log/celery/worker.log --pidfile=/run/celery/worker.pid
celery    4773  0.0  0.8 245600 17680 ?        S    14:27   0:00 /usr/bin/python -m celery worker -n worker@fedora.local -A celery_app.celery --loglevel=INFO -Q calc --logfile=/var/log/celery/worker.log --pidfile=/run/celery/worker.pid

那么,既然 worker 不再绑定到默认队列,那么如果您运行 calculator.py 应用 会发生什么情况呢?记住,默认情况下任务是没有标记的,因此它们都进入默认的“芹菜”队列,但是现在没有任何东西监听它。让我们试着运行几次:

# ./calculator.py
Submitting job...
None
Job completed
#
# ./calculator.py
Submitting job...
None
Job completed
#

没什么好惊讶的,是吧?没有工人在“芹菜”队列上工作,所以任务没有被处理。但是提交的任务实际上发生了什么呢?需要使用 rabbitmqctl 命令直接询问 rabbitmqctl】

# rabbitmqctl list_queues
Listing queues ...
1159cf27f68247da9885495e63c7dd1c        0
calc    0
celery  2
celeryev.601d558c-6354-4265-9704-a225948bb052   0
e289f4c20f754489944f75e1ee7c8ac6        0
worker@fedora.local.celery.pidbox       0
...done.

您可以看到有两个队列,一个名为“celery”,一个名为“calc”“calc”队列中没有消息,但是“celery”队列中有两条消息。

为了确保你的请求不会被发送到黑洞,你需要给它们做标记。这就像指定要将任务发送到的队列名称一样简单。您修改了 calculator.py 文件,以便任务调用在其中有一个队列名称(不幸的是,我们不能使用“延迟”快捷方式):

r = tasks.geometry.rect_area.apply_async((2, 2), queue="calc")

如果您再次运行 calculator.py,您将会看到任务现在已经得到处理,正如预期的那样:

# ./calculator.py
Submitting job...
4
Job completed
#

指定队列的另一种方法是在 Celery 应用文件中(在我们的示例中,这是 celeryconfig.py):

from kombu import Queue
CELERY_QUEUES = ( Queue("calc"), )

这样,您可以在所有处理机器上保持相同的 systemd 配置文件,即使它们绑定到不同的队列。

发送广播消息

默认的消息队列行为是一条消息只能到达一个收件人。这对于发送电子邮件(您希望只发送一封电子邮件!)或执行计算(不需要在所有可用的服务器上计算相同的东西)。但是,有时您需要向所有可用的服务器发送消息,监控系统就是一个例子。您希望告诉所有服务器运行它们的检查并相应地更新状态。

为了实现这个目标,芹菜有一个发送广播消息的机制。这意味着发送到队列的消息将被路由到所有监听该队列的工作线程。

同样,这可以在 **celeryconfig.py 文件中定义;**在示例中,您定义了两个队列,一个用于“正常”计算,一个用于“广播”计算:

from kombu import Queue
from kombu.common import Broadcast

CELERY_QUEUES = ( Queue("calc"),
                  Broadcast("broadcast_calc"), )

还要修改 calculator.py 应用,以便将任务提交到两个不同的队列:

def test_tasks():
    print "Submitting job..."
    r = tasks.geometry.rect_area.apply_async((2, 2), queue="calc")
    print r.info
    print "Job completed"
    print "Submitting broadcast job..."
    r = tasks.arithmetics.add.apply_async((1, 1), queue="broadcast_calc")
    print r.info
    print "Job completed"

如果再次运行示例代码,应该会得到两个结果:

# ./calculator.py
Submitting job...
4
Job completed
Submitting broadcast job...
2
Job completed

好的,这是我们所期望的,但是让我们想一想:如果您提交一个任务,那么队列中只有一个任务,但是它被转发给所有的工人。当工人回复他们的结果时,他们会发送一条消息,用简单的英语可以翻译成这样:“ID 为 A 的任务的结果是 XYZ。”如果您有多个任务 ID,这很好,因为您可以将任务结果与任务 ID 号相关联,但是如果您广播,那么将会有多个结果,但是只有一个任务 ID!

没有简单的方法来解决这个问题,除了忽略提交方(主进程)的结果,并确保工作进程在某个中心位置提交它们的结果——例如,在共享数据库上。

摘要

在本章中,我们简要地讨论了任务和消息队列系统。您可以使用这些知识来重写我们在前三章中编写的分布式监控应用。

  • 任务队列系统用于将任务分配给工作节点,以便它们可以在后台处理。
  • 任务队列系统通常使用底层消息队列系统在工作节点之间分发消息。
  • Celery 结合 RabbitMQ 可以用来调用远程 Python 函数。
  • 任务可以被路由到专用队列,工作进程可以监听一组预定义的队列。这允许您拥有专门化的工作进程,并根据这种专门化对它们进行分组。
  • 可以创建广播队列,让所有用户都能收到相同的消息。

十三、MySQL 数据库自动性能调优

在本章中,我们将扩展我们在第六章中构建的插件框架。您可能还记得,插件框架允许我们通过在主应用代码之外实现新方法来扩展应用的功能。新的框架将允许插件生成数据并将其提交回应用,因此其他插件也能够使用它。基于新的框架,我们将构建一个应用来检查 MySQL 数据库配置和实时统计数据,并提出性能调整建议。我们将查看一些调优参数,并编写一些插件。

需求规格和设计

作为一名系统管理员,您可能被要求提高 MySQL 数据库服务器的性能。这是一项富有创造性和挑战性的任务,但同时也可能令人望而生畏。数据库软件本身是一个复杂的软件,您还必须考虑外部因素,如运行环境 CPU 内核的数量和内存量。除此之外,实际的表布局和 SQL 语句结构扮演着非常重要的角色。

对于如何处理这个问题,你可能已经有了自己的策略。我提到“您自己的策略”的原因是,不幸的是,没有调优 MySQL 数据库的通用解决方案。每个安装都是独特的,需要单独的方法。有各种解决方案可以帮助您确定数据库中最常见的问题,包括商业选项,如 MySQL Enterprise Monitor(mysql.com/products/enterprise/monitor.html)和开源工具,如 MySQL tuner(blog.mysqltuner.com/)。这些工具的主要目的是通过提供对系统配置和行为的深入了解来自动化调优过程。

假设 SQL 语句调优是软件开发人员的工作,作为系统管理员,您实际上是在处理两个参数:数据库配置和操作环境配置。反馈以内部数据库计数器的形式提供,例如慢速查询的数量或连接的数量。

从这个角度来看,MySQL 社区服务器 5.6.19 有 443 个状态变量和 602 个配置变量。我甚至不考虑列出操作环境变量,因为这几乎是不可能的。人类不可能将所有变量联系起来,并在更大范围内进行有意义的观察。

可用的工具试图检查配置,并根据观察到的状态变量,提出一些如何改进配置的建议。这对于基本的调优很有效,但是随着您的深入研究,您可能会发现您需要修改该工具,以便根据您的需要进行调优,而不是基于一些一般性的观察。这就是你需要一个可扩展且易于调整的工具的地方。

基本应用要求

在第六章中,我们讨论了基于插件的架构的优势。在这种架构中,主(主机)应用向插件提供一些通用服务,这些插件或者扩展主应用的功能,或者实际上提供服务。从用户的角度来看,系统就像一个实体。

这就把我们带到了本章将要构建的应用的基本需求列表:

  • 应用应该易于扩展、修改和增加新功能。
  • 应用应该关注从 MySQL 数据库收集和处理性能观察。
  • 性能调整规则应该易于在应用的不同实例之间转移和交换。

系统设计

作为应用的基础,我们将使用我们在第六章中创建的插件框架。我们可以照原样使用,用 MySQL 数据收集函数替换日志行读取部分,并开始编写消耗数据的插件模块。这种方法在短期内会很好地为我们服务,但从长远来看,它可能不是最具可扩展性的解决方案。问题是,尽管我们可以立即识别 MySQL 配置参数和状态变量,但我们会与操作系统状态参数发生冲突。这是因为这一信息没有明确的来源。每个系统都不同,可能需要不同的工具来报告状态。

这个问题的解决方案是将产生信息的任务从主机应用转移到插件模块。换句话说,一些插件将产生数据,其他插件将依赖这些数据进行计算,并最终提出性能改进建议。在这种情况下,主机应用仅充当调度程序,它提供的唯一服务是到数据库服务器的连接。其余的功能由插件提供。图 13-1 显示了生产者/消费者插件架构的示意图。

9781484202180_Fig13-01.jpg

图 13-1 。生产者/消费者插件框架

正如您所看到的,主机应用仍然通过插件管理器对象发出命令。结果也通过插件管理器传递回来,但是为了清楚起见,图中显示了返回到主机应用的直接链接。一旦从生产者插件模块中收集到数据,它就会被传递回消费者模块。因此,主机应用负责向插件提供连接细节,并维护生产者优先、消费者最后调用的正确顺序。

除了插件框架的这些变化,我们还将提供三个基本的生产者插件:

  • 提供 MySQL 系统变量的插件
  • 提供配置细节的插件
  • 一个插件,提供系统上可用的物理和虚拟内存的详细信息,以及 CPU 核心的数量

这将是我们构建顾问插件的基本信息集。advisor 插件将根据收到的结果执行一些计算,并提供如何提高服务器性能的建议。

Image 注意 MySQL 调优是一个非常宽泛的话题。如果您想了解更多,我推荐您从 MySQL 性能博客(mysqlperformanceblog.com/)开始,它包含了大量的性能调优技巧和文章。其他有用的资源有dev . MySQL . com/doc/ref man/5.7/en/server-parameters . htmlwww.mysql.com/why-mysql/performance/

修改插件框架

不同组件之间的信息共享会很快变得复杂。以下是您可能需要解决的一些潜在问题:

  • 哪些插件可以访问哪些信息?您可能想要隐藏某些插件的某些信息。
  • 生产者插件也是消费者怎么办?一些插件可能需要其他插件产生的信息来完成它们的任务。
  • 如何在插件之间共享大量数据?例如,当产生的数据量不适合物理内存而需要存储在磁盘上时。

为了简单起见,我们将有一个平面访问模型,其中消费者模块可以访问生产者插件生成的所有信息。我们不会实现分层生产者布局,我们将假设生产者是自给自足的。

对宿主应用的更改

宿主应用的职责限于以下三个任务:

  • 从配置文件中读取 MySQL 数据库凭证
  • 建立与服务器的初始连接
  • 分三个阶段运行插件模块:运行生产者并收集数据,运行生产者的过程方法,然后运行生产者的报告模块

我们将使用 Python 的 ConfigParser 库从 Windows INI 风格的配置文件中访问配置,该配置文件包含以下内容(显然,您需要调整设置以匹配您的数据库细节):

[main]
user=root
passwd=password
host=localhost

清单 13-1 显示了主机应用的完整列表。如您所见,代码很简单。它在逻辑上分为三个主要阶段和三个插件处理阶段。注意,我们使用关键字来区分生产者和消费者模块。

清单 13-1 。宿主应用

#!/usr/bin/env python

import re
import os, sys
from ConfigParser import SafeConfigParser
import MySQLdb
from plugin_manager import PluginManager

def main():
    cfg = SafeConfigParser()
    cfg.read('mysql_db.cfg')

    plugin_manager = PluginManager()
    connection = MySQLdb.connect(user=cfg.get('main', 'user'),
                                 passwd=cfg.get('main', 'passwd'),
                                 host=cfg.get('main', 'host'))

    env_vars = plugin_manager.call_method('generate', keywords=['provider'],
                                          args={'connection': connection})
    plugin_manager.call_method('process', keywords=['consumer'],
                              args={'connection': connection, 'env_vars': env_vars})
    plugin_manager.call_method('report')

if __name__ == '__main__':
    main()

如果您将这个清单与第六章中的例子进行比较,您会注意到这次我们实际上期望从 call_method 函数中得到一些东西。该函数返回生产者插件模块生成的结果,并将它们存储在一个变量中。然后,这个变量作为名为 env_vars 的关键字参数被传递给消费者插件。消费者插件希望这个参数存在。我们将在下一节研究这个变量的结构。

修改插件管理器

主机应用只处理对 call_method 函数的一次调用,因为它不知道——也不需要知道——插件的确切数量和名称。将请求路由到适当的插件模块是插件管理器的责任。然而,这种方法带来了一个问题:如果对一个函数的单次调用实际上产生了来自多个函数的多个答案,我们如何存储结果呢?

更复杂的是,我们不知道插件将返回什么。它可能是一个字典、一个列表,甚至是一个自定义对象。我们不需要知道这些。由消费者来解密这些信息。编写生产者插件的人应该提供关于他们的模块所产生的数据结构的大量文档。

在我们的例子中,插件管理器组件将以非常简单的方式处理结果。它会将它们作为单独的条目存储在字典中。字典键将是插件类名,键值将是插件模块调用返回的任何对象。然后,这个字典作为参数传递给消费者插件调用。这将产生一个平面信息存储,其中所有信息都可以被所有插件访问。这可能会带来一些安全问题,但是对于像我们在这里构建的这样一个简单的应用,简单性起着重要的作用。

对插件管理器代码的唯一修改是 call_method()函数,如清单 13-2 所示。

清单 13-2 。插件管理器方法调度程序函数

def call_method(self, method, args={}, keywords=[]):
    result = {}
    for plugin in self.plugins:
        if not keywords or (set(keywords) & set(self.plugins[plugin])):
            try:
                name_space = plugin.__class__.__name__
                result[name_space] = getattr(plugin, method)(**args)
            except AttributeError:
                pass
    return result

我们现在有了一个插件框架,它能够在模块之间传递信息。

如果你真的需要多级生产者架构,仅仅是几个级别,你可以使用关键字来实现它。例如,您可能有关键字生产者 1、生产者 2 和生产者 3。然后,您可以调用 generate()方法三次,每次传递不同的关键字,并将中间结果提供给 producer2 和 producer3 实例。

编写生成器插件

我们需要为顾问插件生成一些数据。我们将从查询 MySQL 内部状态和配置表开始。首先,让我们看看如何从 Python 应用访问 MySQL 数据库。

从 Python 应用访问 MySQL 数据库

MySQL 数据库的支持是由 MySQLdb Python 模块提供的,该模块是大多数 Linux 发行版上的预构建包。例如,在 Fedora 系统上,可以用下面的命令安装这个模块:

$ sudo yum install MySQL-python

或者,您可以从项目主页的sourceforge.net/projects/mysql-python/下载最新的源代码包,并从源代码构建这个库。请记住,MySQLdb 使用用 C 编写的模块,因此您还需要安装一个 C 编译器(通常称为 gcc 的包)、MySQL 开发头(通常包名为 mysql-devel)和 Python 开发头(通常包名为 python-devel)。

一旦安装了库,请检查它是否正确加载:

$ python
Python 2.6.2 (r262:71600, Jan 25 2010, 18:46:45)
[GCC 4.4.2 20091222 (Red Hat 4.4.2-20)] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> import MySQLdb
>>> MySQLdb.__version__
'1.2.3c1'
>>>

MySQLdb 库与 Python DB-API 规范版本 2 兼容。该规范定义了兼容库必须实现的接口、对象、变量和错误处理规则。这是统一所有数据库访问模块接口的一种尝试。这种统一的好处是,作为开发人员,您不需要太担心数据库模块调用的细节,因为它们非常相似。您编写的用于连接 SQLite 3 的代码应该可以与 MySQL 数据库一起工作,而无需进行大的修改。这两个库之间的主要区别可能是 connect()方法,它用于连接数据库,因此对于您正在使用的数据库软件来说是非常特定的。

不管您使用哪个数据库模块,您将调用的第一个方法通常是 connect() 。此方法返回 connect 对象的一个实例,您将使用它来访问数据库。这些参数是特定于数据库的。因为我们在本章中讨论的是 MySQL 数据库,所以下面是如何建立到数据库服务器的连接:

>>> connection = MySQLdb.connect( host='localhost',
...                               user='root',
...                               passwd='password',
...                               db='test')
>>>

这四个参数——主机名、用户名、密码和数据库名——是您会发现自己大部分时间都在使用的参数。但是,MySQL 服务器也支持多个连接选项,您可能需要修改这些选项。表 13-1 列出了最重要的几个。有关参数的完整列表,请参考 MySQLdb 文档(【http://mysql-python.sourceforge.net/MySQLdb.html】)。

表 13-1 。常用的 MySQL 连接选项

|

参数

|

描述

圣体 要连接的主机的名称—主机的完全限定域名或 IP 地址。
用户 用于向数据库服务器进行身份验证的用户名。
修改密码 您用于验证的密码。
分贝 您正在连接的数据库的名称。如果省略,将不会选择默认数据库,您将需要使用 USE DATABASE SQL 命令来连接数据库。
港口 运行 MySQL 服务器的端口号。默认值为 3306。
unix_socket MySQL 服务器实例的 UNIX 套接字的位置。默认位置因发行版而异,但通常是/var/lib/mysql/mysql.sock。
压缩 指示应该启用还是禁用协议压缩的标志。默认情况下,它是禁用的。
连接超时 等待连接操作完成的秒数。如果未在指定的时间范围内完成,操作将引发错误。
初始化命令 建立连接后,服务器必须立即执行的初始化命令。
使用 unicode) 如果此标志设置为 true,则 CHAR、VARCHAR 和 TEXT 字段将作为 Unicode 字符串返回。否则,返回的结果只是普通的字符串。不管该设置如何,您总是可以以 Unicode 格式写入数据库。
字符集 连接字符集将被设置为指定为此参数值的字符集。

返回的 connect 对象实现了管理连接状态的四种基本方法。这些方法在表 13-2 中列出。

表 13-2 。连接对象方法

|

方法名称

|

描述

。关闭() 关闭已建立的连接,该连接从调用此方法时起就不可用。从此连接派生的所有游标对象也将不可用。请记住,如果您没有先提交更改就关闭连接,所有事务或更改都将回滚。
。提交() 强制数据库引擎提交所有未完成的事务。
。回滚() 如果您使用的是支持事务的 MySQL 数据库引擎(如 InnoDB ),则回滚最后一个未提交的事务。
。光标() 返回一个 cursor 对象,您将使用它来执行 SQL 命令并读取结果。MySQL 数据库不支持游标,但是 MySQLdb 库提供了这个包装器对象,它模拟游标功能。

数据库中的实际工作是使用游标对象完成的。游标对象充当查询执行的上下文,更重要的是,充当数据提取操作的上下文。一个连接对象可以创建多个游标。任何游标所做的更改都会被其他游标立即看到,只要它们属于同一个连接。表 13-3 列出了最常用的光标方法。表中示例中使用的连接上下文创建如下:

>>> connection = MySQLdb.connect( host='localhost',
...                               user='root',
...                               passwd='password',
...                               db='zm' )
>>>
>>> cursor = connection.cursor()

表 13-3 。常用的数据库光标方法

|

方法

|

描述

|

例子

。执行() 准备并执行 SQL 查询。它接受两个参数:需要执行的 SQL 语句(必需的)和可选的参数列表。SQL 字符串中的变量仅使用%s 字符串指定。第二个可选参数必须是元组,即使它只是单个值。 以下两个查询在功能上是相同的:> > > cursor.execute("选择类型shift-enter.jpg来自 id=1 的区域预设”)1L> > > cursor.execute("选择类型shift-enter.jpg来自区域预设,其中 id=%s ",(1,))1L>>>
。executemany() 类似于。execute()方法;接受选项列表并遍历它们。使用游标数据提取方法可以组合和访问结果。列表元素必须是元组,即使它们只包含一个值。 以下示例在一个命令中运行两个选择查询:>>> cursor.executemany("""SELECT type shift-enter.jpg从区域预设位置shift-enter.jpgid=%s,类型=%s " ",
[ (1,'活动'),(2,'活动')])2L>>>
.rowcount 一个只读属性(不是方法),指示最后一个。execute()语句已生成。
。费特乔内() 返回结果集中的下一行。如果没有更多的数据可用。它将返回 None 对象。结果总是一个元组。元素的顺序与查询集指定的顺序相同。 > > > cursor.execute("选择 id,shift-enter.jpg从 ZonePresets 键入”)6L>>游标. fetchone()(1L,“活跃”)[...]>>游标. fetchone()(6L,“活动”)>>游标. fetchone()>>>
。fetchall() 以元组的形式返回查询返回的所有行。 > > > cursor.execute("选择 id,shift-enter.jpg从 ZonePresets 键入”)6L> > > cursor.fetchall()((1L,“主动”),(2L,“主动”),shift-enter.jpg(3L,'主动'),(4L,'主动'),shift-enter.jpg(5L,'活动'),(6L,'活动'))>>>
。费奇曼尼() 返回由其参数指定的行数。如果没有提供参数,则读取的行数取决于。数组大小设置。 > > > cursor.execute("选择 id,键入shift-enter.jpg来自区域预设”)6L>>> cursor.fetchmany(2)((1L,“主动”),(2L,“主动”))>>>
。像素 一个读/写属性,它控制。fetchmany()方法必须返回。 > > > cursor.execute("选择 id,键入shift-enter.jpg来自区域预设”)6L>>游标. arraysizeone>>游标. arraysize=3>>> cursor.fetchmany()((1L,“主动”),(2L,“主动”),shift-enter.jpg(3L,“活跃”))>>>

查询配置变量

如果您想要检索服务器配置或系统状态变量,您实际上不需要连接到任何数据库。建立到数据库服务器的连接就足够了。

要获取 MySQL 变量,我们需要使用 MySQL SHOW 语句。它的语法类似于 SELECT 语句,允许使用 LIKE 和 where 修饰符来限制查询集。(记住有 287 个配置设置和 291 个状态变量!)

我们将从配置变量开始。这些变量表明服务器是如何配置的。有三种方法可以改变这些变量:

  • 使用命令行参数在服务器启动时设置它们。
  • 使用选项文件(通常是 my.cnf)在服务器启动时设置它们。
  • 在服务器运行时使用 MySQL SET 语句设置它们。

Image 你可以在dev . MySQL . com/doc/ref man/5.7/en/server-system-variables . html的 MySQL 官方文档中找到所有 MySQL 变量以及它们如何影响服务器功能的详细描述。

该命令的基本语法是 SHOW VARIABLES。该命令的默认行为是显示应用于当前会话的设置,等效于同一命令的扩展语法:SHOW LOCAL VARIABLES。如果您想知道哪些设置将应用于新连接,您需要使用 SHOW GLOBAL VARIABLES 命令。可以使用 LIKE 和 WHERE 子句进一步修改结果集,如下例所示:

>>> connection = MySQLdb.connect( host='localhost',
...                               user='root',
...                               passwd='password' )
>>> cursor = connection.cursor()
>>> cursor.execute("SHOW GLOBAL VARIABLES LIKE '%innodb%'")
37L
>>> for r in cursor.fetchmany(10): print r
...
('have_innodb', 'YES')
('ignore_builtin_innodb', 'OFF')
('innodb_adaptive_hash_index', 'ON')
('innodb_additional_mem_pool_size', '1048576')
('innodb_autoextend_increment', '8')
('innodb_autoinc_lock_mode', '1')
('innodb_buffer_pool_size', '8388608')
('innodb_checksums', 'ON')
('innodb_commit_concurrency', '0')
('innodb_concurrency_tickets', '500')
>>>
>>> cursor.execute("SHOW GLOBAL VARIABLES WHERE variable_name LIKE '%innodb%'
 AND value > 0")
18L
>>> for r in cursor.fetchmany(10): print r
...
('innodb_additional_mem_pool_size', '1048576')
('innodb_autoextend_increment', '8')
('innodb_autoinc_lock_mode', '1')
('innodb_buffer_pool_size', '8388608')
('innodb_concurrency_tickets', '500')
('innodb_fast_shutdown', '1')
('innodb_file_io_threads', '4')
('innodb_flush_log_at_trx_commit', '1')
('innodb_lock_wait_timeout', '50')
('innodb_log_buffer_size', '1048576')
>>>

Image 提示系统配置表的列名为变量名。您可以在 SHOW 命令中使用这些名称以及 LIKE 和 WHERE 语句。

让我们编写一个插件类,它从数据库中检索所有变量,并将数据返回给插件管理器。如你所知,默认情况下,结果是一个元组的元组。为了使它更有用,我们将把它转换成 dictionary 对象,其中变量名是字典键,变量值是字典值,如清单 13-3 所示。

清单 13-3 。用于检索 MySQL 服务器变量的插件

class ServerSystemVariables(Plugin):

    def __init__(self, **kwargs):
        self.keywords = ['provider']
        print self.__class__.__name__, 'initialising...'

    def generate(self, **kwargs):
        cursor = kwargs['connection'].cursor()
        cursor.execute('SHOW GLOBAL VARIABLES')
        result = {}
        for k, v in cursor.fetchall():
            result[k] = v
        cursor.close()
        return result

查询服务器状态变量

服务器状态变量通过显示内部计数器来提供对服务器操作的洞察。所有变量都是只读的,不能修改。

Image 注意你可以在 MySQL 文档中找到关于每个 MySQL 服务器状态变量的详细信息,可以在dev . MySQL . com/doc/ref man/5.7/en/server-status-variables . html上找到。

SHOW 命令语法是 SHOW STATUS。类似于不带修饰符的 SHOW VARIABLES 命令,该命令返回适用于当前会话的状态,等效于 SHOW LOCAL STATUS 命令。如果要检索服务器范围的状态,请使用 SHOW GLOBAL STATUS 命令。

此行为仅适用于 MySQL 服务器的 5.0 及更高版本。此版本之前的版本具有相反的行为,其中 SHOW STATUS 假定为全局状态,如果您想要检索特定于会话的计数器,则需要显式运行 SHOW LOCAL STATUS。如果您正在开发一个可能在不同版本的 MySQL 服务器上执行的插件,这可能会带来一个问题。不过,这个问题有一个简单的解决方案:在 SHOW 语句中指定版本选择器。以下查询正确地使用了适当的命令修饰符,并且可以跨所有版本的 MySQL server 使用:

SHOW /*!50000 GLOBAL */ STATUS

您也可以在此命令中使用 LIKE 和 WHERE 数据集修饰符,如下例所示:

>>> cursor.execute("SHOW GLOBAL STATUS WHERE variable_name LIKE '%innodb%' AND value > 0")
16L
>>> for r in cursor.fetchmany(10): print r
...
('Innodb_buffer_pool_pages_data', '19')
('Innodb_buffer_pool_pages_free', '493')
('Innodb_buffer_pool_pages_total', '512')
('Innodb_buffer_pool_read_ahead_rnd', '1')
('Innodb_buffer_pool_read_requests', '77')
('Innodb_buffer_pool_reads', '12')
('Innodb_data_fsyncs', '3')
('Innodb_data_read', '2494464')
('Innodb_data_reads', '25')
('Innodb_data_writes', '3')
>>>

清单 13-4 显示了检索系统状态变量的插件。这个插件类类似于查询系统配置设置的插件类。

清单 13-4 。用于检索系统状态变量的插件

class ServerStatusVariables(Plugin):

    def __init__(self, **kwargs):
        self.keywords = ['provider']
        print self.__class__.__name__, 'initialising...'

    def generate(self, **kwargs):
        cursor = kwargs['connection'].cursor()
        cursor.execute('SHOW /*!50000 GLOBAL */ STATUS')
        result = {}
        for k, v in cursor.fetchall():
            result[k] = v
        cursor.close()
        return result

收集主机配置数据

我们能够检索 MySQL 配置和状态数据,这很好,但是我们仍然需要将这些数据放到操作环境的上下文中,以便实际使用它们。

我们以系统配置列表中的 key_buffer_size 变量为例。此变量设置专用于 MyISAM 表索引的内存量。该设置会对 MySQL 服务器的性能产生重大影响。如果设置得太小,索引将不会缓存在内存中,并且对于每次查找,服务器都将执行磁盘读取操作,这比从内存读取操作要慢得多。

如果给这个缓冲区分配太多的内存,就会限制可用于其他操作的内存,比如文件系统缓存。如果文件系统缓存太小,所有读写操作都不会被缓存,因此磁盘 I/O 会受到负面影响。

这个缓冲区变量的标准建议是使用服务器上可用总内存的 30%到 40%。所以,要做这个推论,你实际上需要知道系统上的物理内存量!

您必须考虑许多不同的方面,但是最重要的是物理内存的数量、虚拟内存的数量(或者 Linux 系统上的交换空间大小)以及 CPU 内核的数量。

我们将使用 psutil 库,它提供了查询系统内存读数的 API。该库旨在获取关于正在运行的进程的信息,并执行一些基本的进程操作。它不包含在基本的 Python 模块集中,但是它在大多数 Linux 发行版上都有。例如,在 Fedora 系统上,可以使用以下命令安装这个库:

$ sudo yum install python-psutil

源代码和完整的文档可以在项目网站github.com/giampaolo/psutil获得。

不幸的是,这个库不提供关于可用 CPU 内核数量的信息。我们需要查询 Linux /proc/ file 系统来获得关于可用 CPU 的报告。这很容易做到。我们只需要计算/proc/cpuinfo 文件中以关键字 processor 开头的行数。

清单 13-5 显示了收集系统内存读数和 CPU 信息的插件代码。

清单 13-5 。用于检索系统信息的插件

import psutil

[...]

class HostProperties(Plugin):

    def __init__(self, **kwargs):
        self.keywords = ['provider']
        print self.__class__.__name__, 'initialising...'

    def _get_total_cores(self):
        f = open('/proc/cpuinfo', 'r')
        c_cpus = 0
        for line in f.readlines():
            if line.startswith('processor'):
                c_cpus += 1
        f.close()
        return c_cpus

    def generate(self, **kwargs):
        result = { 'mem_phys_total': psutil.TOTAL_PHYMEM,
                   'mem_phys_avail': psutil.avail_phymem(),
                   'mem_phys_used' : psutil.used_phymem(),
                   'mem_virt_total': psutil.total_virtmem(),
                   'mem_virt_avail': psutil.avail_virtmem(),
                   'mem_virt_used' : psutil.used_virtmem(),
                   'cpu_cores'     : self._get_total_cores(),
                 }
        return result

编写消费者插件

现在我们已经准备好开始编写顾问插件了。这些插件将基于它们从信息生产者模块接收的信息提出建议。到目前为止,我们已经收集了关于数据库设置和状态的基本信息,以及关于物理硬件和操作系统的一些信息。尽管信息集并不详尽,但它包括了做出一些有根据的结论所需的关键细节。在这里,我们将看三个例子,它们应该足以让您快速上手,这样您就可以开始开发自己的 advisor 插件了。

检查 MySQL 版本

您可能需要执行的第一个检查是 MySQL 版本号。让您的服务器安装保持最新非常重要。每个新版本都会修复服务器软件错误,并可能带来性能改进。

检查当前 MySQL 版本的插件基于最新的公开发行(GA)版本号做出决定,该版本号可从位于mysql.com/downloads/mysql/的 MySQL 下载页面获得。为了从网页中提取这些信息,我们将使用漂亮的 Soup HTML 解析库。页面结构相对简单,我们需要的数据包含在最后出现的< h1 >标签:中

[...]
<div id="page" class="sidebar" >
    <h1 class="page_header">Download MySQL Community Server</h1>
[...]
<div dojoType="dijit.layout.ContentPane" title="Generally Available (GA) Releases"
 id="current_pane" selected="true">

<h1>MySQL Community Server 5.6.19/h1>

<div id="current_os_selection">
[...]

插件代码将提取这些信息,并将其与 ServerSystemVariables 模块报告的信息进行比较。可以报告四种状态:

  • 如果主要版本号不匹配,这可能是一个严重的问题,因此被标记为关键。
  • 如果当前主要版本与最新版本匹配,但当前次要版本号低于最新版本号,则该问题被标记为警告。
  • 如果主版本和次版本都是最新的,只需注意补丁可能是有益的。
  • 如果以上都不是,我们将断定当前安装是最新的。

Image 注意另一个可能的检查是检查比当前 GA 版本更新的版本,这可能会导致潜在的问题,因为开发版本不能被彻底测试。为了简化代码,我们将在示例中排除这种情况。这种额外的检查应该相对容易包含在模块中。

检查当前 MySQL 版本的插件的完整列表如清单 13-6 所示。

清单 13-6 。用于根据最新 GA 版本检查当前版本的模块

class MySQLVersionAdvisor(Plugin):

    def __init__(self, **kwargs):
        self.keywords = ['consumer']
        self.advices = []
        self.installed_release = None
        self.latest_release = None

    def _check_latest_ga_release(self):
        html = urllib2.urlopen('http://www.mysql.com/downloads/mysql/')
        soup = BeautifulSoup(html)
        tags = soup.findAll('h1')
        version_str = tags[1].string.split()[-1]
        (major, minor, release) = [int(i) for i in version_str.split('.')]
        return (major, minor, release)

    def process(self, **kwargs):
        version = kwargs['env_vars']['ServerSystemVariables']['version'].split('-')[0]
        (major, minor, release) = [int(i) for i in version.split('.')]
        latest_major, latest_minor, latest_rel = self._check_latest_ga_release()
        self.installed_release = (major, minor, release)
        self.latest_release = (latest_major, latest_minor, latest_rel)
        if major < latest_major:
            self.advices.append(('CRITICAL',
                   'There is a newer major release available, you should upgrade'))
        elif major == latest_major and minor < latest_minor:
            self.advices.append(('WARNING',
                   'There is a newer minor release available, consider an upgrade'))
        elif major == latest_major and minor == latest_minor and release < latest_rel:
            self.advices.append(('NOTE',
                   'There is a newer update release available, consider a patch'))
        else:
            self.advices.append(('OK', 'Your installation is up to date'))

    def report(self, **kwargs):
        print self.__class__.__name__, 'reporting...'
        print "The running server version is: %d.%d.%d" % self.installed_release
        print "The latest available GA release is: %d.%d.%d" % self.latest_release
        for rec in self.advices:
            print "%10s: %s" % (rec[0], rec[1])

以下是在运行比当前版本稍旧的服务器的系统上执行的报告功能的输出:

MySQLVersionAdvisor reporting...
The running server version is: 5.6.19
The latest available GA release is: 5.6.19
      NOTE: There is a newer update release available, consider a patch

检查密钥缓冲区大小设置

我们已经讨论了 key_buffer_size 配置参数的含义以及该设置对 MySQL 数据库服务器性能的影响。如清单 13-7 中的所示,插件模块假设最佳设置是总可用物理内存的 40%。

清单 13-7 。检查密钥缓冲区大小的最佳设置

class KeyBufferSizeAdvisor(Plugin):

    def __init__(self, **kwargs):
        self.keywords = ['consumer']
        self.physical_mem = 0
        self.key_buffer = 0
        self.ratio = 0.0
        self.recommended_buffer = 0
        self.recommended_ratio = 0.4

    def process(self, **kwargs):
        self.key_buffer = \
                    int(kwargs['env_vars']['ServerSystemVariables']['key_buffer_size'])
        self.physical_mem = int(kwargs['env_vars']['HostProperties']['mem_phys_total'])
        self.ratio = float(self.key_buffer) / self.physical_mem
        self.recommended_buffer = int(self.physical_mem * self.recommended_ratio)

    def report(self, **kwargs):
        print self.__class__.__name__, 'reporting...'
        print "The key buffer size currently is %d" % self.key_buffer
        if self.ratio < self.recommended_ratio:
            print "This setting seems to be too small for the amount of memory \
                                             installed: %d" % self.physical_mem
        else:
            print "You may have allocated too much memory for the key buffer"
            print "You currently have %d, you must free up some memory"
        print "Consider setting key_buffer_size to %d, if the difference is \
                                               too high" % self.recommended_buffer

以下是报告的输出示例:

KeyBufferSizeAdvisor reporting...
The key buffer size currently is 8384512
This setting seems to be too small for the amount of memory installed: 1051463680
Consider setting key_buffer_size to 420585472, if the difference is too high

检查慢速查询计数器

由于各种原因,一些 SQL 查询可能需要很长时间才能执行。如果您有一个大型数据集,大多数查询需要相当长的时间来完成可能是完全正常的。在这种情况下,您可能需要增加 long_query_time 设置。另一种可能是您的表没有被正确索引。在这种情况下,您应该重新考虑表的结构和设置。

我们的最后一个插件模块读取两个状态变量:数据库服务器收到的请求总数和执行时间比 long_query_time 指定的时间长的查询总数。如果该比率大于 0.0001%(百万分之一以上的查询为慢速查询),报告会将其指示为问题。显然,您可能需要调整这个值以适应您的特定数据库环境。

MySQL 服务器上默认情况下不启用慢速查询跟踪,因此在执行插件代码之前,需要将 MySQL 属性文件/etc/my.cnf 中的 log_slow_queries 变量设置为 on。完整的模块代码如清单 13-8 中的所示。

清单 13-8 。检查慢速查询比率的插件

class SlowQueriesAdvisor(Plugin):

    def __init__(self, **kwargs):
        self.keywords = ['consumer']
        self.log_slow = False
        self.long_query_time = 0
        self.total_slow_queries = 0
        self.total_requests = 0
        self.long_qry_ratio = 0.0 # in %
        self.threshold = 0.0001   # in %
        self.advise = ''

    def process(self, **kwargs):
        if kwargs['env_vars']['ServerSystemVariables']['log_slow_queries'] == 'ON':
               self.log_slow = True
        self.long_query_time = \

float(kwargs['env_vars']['ServerSystemVariables']['long_query_time'])
        self.total_slow_queries = \
                   int(kwargs['env_vars']['ServerStatusVariables']['Slow_queries'])
        self.total_requests = \
                   int(kwargs['env_vars']['ServerStatusVariables']['Questions'])
        self.long_qry_ratio = (100\. * self.total_slow_queries) / self.total_requests

    def report(self, **kwargs):
        print self.__class__.__name__, 'reporting...'
        if self.log_slow:
            print "There are %d slow requests out of total %d, which is %f%%" % \
                                                       (self.total_slow_queries,
                                                        self.total_requests,
                                                        self.long_qry_ratio)
            print "Currently all queries taking longer than %f are considered \
                                                        slow" % self.long_query_time
            if self.long_qry_ratio < self.threshold:
                print 'The current slow queries ratio seems to be reasonable'
            else:
                print 'You seem to have lots of slow queries, investigate them and \
                                               possibly increase long_query_time'
        else:
            print 'The slow queries are not logged, set log_slow_queries to ON for tracking'

以下是该模块的输出示例:

SlowQueriesAdvisor reporting...
There are 0 slow requests out of total 15, which is 0.000000%
Currently all queries taking longer than 10.000000 are considered slow
The current slow queries ratio seems to be reasonable

摘要

在本章中,我们已经讨论了如何检查 MySQL 数据库的设置和当前运行状态。我们还修改了我们在第六章中创建的插件框架,以便它允许不同插件模块之间的信息交换。

  • 可以使用 SHOW GLOBAL VARIABLES 查询来查询 MySQL 服务器配置项。
  • 可以使用 SHOW GLOBAL STATUS 命令检查数据库状态变量。
  • 您可以使用 psutil 模块来获取有关可用系统内存的信息。

十四、使用亚马逊 EC2/S3 作为数据仓库解决方案

虚拟计算或云计算正变得越来越流行。有各种各样的原因,但主要是节约成本。许多大型供应商都提供云计算服务,如亚马逊、IBM、惠普、谷歌、微软和 VMWare。这些服务中的大多数都提供了 API 接口,允许控制虚拟机实例和虚拟存储设备。在本章中,我们将研究如何从您的 Python 应用中控制亚马逊弹性计算云(EC2)和亚马逊简单存储系统(S3)。

指定问题和解决方案

首先,我们需要了解这个解决方案在什么情况下适用。尽管按需计算是一种方便的方法,可以节省大量成本,但它并不适用所有情况。在本节中,我们将简要讨论按需计算可以成功使用的情况。

问题

让我们想象一个典型的小型网络创业公司。该公司在互联网上提供一些服务。用户群相对较小但在稳步增长,而且在地理上分布均匀,这意味着系统一天 24 小时都很忙。

该系统是典型的两层设计,由两个应用节点和两个数据库节点组成。应用服务器运行的是部署在 Apache Tomcat 应用服务器上的内部构建的 Java 应用,并使用 MySQL 数据库来存储数据。web 应用和数据库服务器相当繁忙,因此被认为不适合在虚拟化平台上运行。所有四台服务器都是从服务器托管公司租赁的,托管在远程数据中心。

现在,这个设置满足了当前的大部分需求,并且考虑到缓慢的用户基础增长,它应该在相当长的时间内保持不变。该公司的扩展策略是根据需要添加更多的应用和数据库节点。应用设计允许近乎线性的水平可伸缩性。

然而,随着公司的发展,所有者决定在市场研究上投入更多。为了更好地了解用户行为,更有针对性地进行销售,公司需要分析存储在数据库中的数据。然而,正如我们已经知道的,数据库服务器已经非常繁忙,运行额外的查询肯定会降低整个应用的速度。仅仅为了数据分析任务而添加新的数据库服务器是不划算的,因为这需要相当大的初始投资,并且会增加每月的维护成本。此外,分析很少执行,新系统大部分时间都处于闲置状态。

我们创业公司面临的第二个问题是缺乏备份策略。目前,所有数据都存储在数据库服务器上,尽管服务器是冗余的,但它们仍然位于同一场所。这些数据肯定应该在远程位置进行备份。

我们的解决方案

一种策略是使用按需计算解决方案,比如 Amazon EC2。由于该公司只是偶尔需要处理能力,因此可以在必要时创建虚拟服务器来执行计算。当计算完成后,公司可以安全地销毁虚拟服务器。在这种情况下,公司只为服务器处于活动状态的时间付费。在撰写本文时,这些虚拟实例的成本从每小时 0.02 美元到 6.82 美元不等,具体取决于使用的内存和分配的虚拟 CPU 数量。

如果每周进行一次数据分析,每次花费 8 小时,每月总成本不会超过 10 美元(假设一个超大高内存实例目前定价为每小时 0.28 美元)。这比公司决定租赁一台普通服务器的成本要低得多。

但是请记住,初创公司面临的第二个问题是缺少远程备份。Amazon 提供了一个高可用性和可伸缩性的存储解决方案:它的简单存储系统。与 EC2 类似,您只需为使用的内容付费,在 S3 上可以存储的内容没有限制。在撰写本文时,S3 的基本定价是每月每千兆字节 0.03 美元。如果你将数据上传到 S3,数据传输是免费的,但数据传出去(例如,如果你想从备份恢复)将花费你每 GB 0.12 美元。

这就是你要小心的地方,因为总价加起来可能相当大。1tb 的信息会让你每月花费 30 美元。考虑到当前的存储价格,这听起来可能是一大笔钱(1TB 外部 USB 价格为 60 美元,这是一次性费用),但请记住,您不仅获得了存储设备,还获得了数据保护。目前,标准的亚马逊 S3 提供“给定年份内 99.999999999%的持久性和 99.99%的可用性”(aws.amazon.com/s3/)。

设计规范

为了适应我们之前提出的所有需求和约束,我们将构建一个应用,该应用将在 EC2 中创建一个新的虚拟机实例。虚拟机将运行一个 MySQL 数据库服务器实例,并可用于接受外部连接。数据库文件将存储在一个单独的、高度可用的卷上,即弹性块存储卷。

应用将分三个阶段运行:初始化、处理和取消初始化。在初始化阶段,应用创建一个虚拟机,将卷设备连接到该虚拟机,并启动 MySQL 服务器。处理阶段取决于您的处理要求;它通常包含数据传输和数据处理任务。我们不打算详细讨论这个阶段,因为它确实取决于您自己的要求。最后,在取消初始化阶段,我们关闭远程 MySQL 实例,分离卷,创建快照,并销毁虚拟机。

创建快照的原因是为了在需要检查特定时间点的数据状态时,有一个可以恢复的参考点。你可以把它看作一个版本控制系统。显然,每个快照都会增加数据使用量,从而增加成本,因此您必须手动控制想要维护的快照映像的数量。

亚马逊 EC2 和 S3 速成班

在写这篇文章的时候,没有多少关于亚马逊 EC2 和 S3 的最新书籍。原因是这两种技术(尤其是 EC2)都在快速发展,这使它们成为快速移动的目标。有一些好书,可惜已经略显过时。

关于亚马逊网络服务的一个很好的手册是 James Murty 的《编程亚马逊网络服务:S3、EC2、SQS、FPS 和 simple db 》( O ' Reilly Media,2008)。这本书很好地概述了这些技术以及详细的 API 规范。另一个更加关注运营方面的文本是乔治·里斯(O'Reilly Media,2009)的云应用架构:在云中构建应用和基础设施

您还可以在每个 web 服务的文档页面上找到大量信息:

很难在一章中包含所有关于这些 web 服务的必要信息,所以我将描述一些基本概念。话虽如此,本章将为您提供足够的信息来开始使用 Amazon EC2 和 S3 web 服务,并且您可以在熟悉基本原则的基础上进行更多的探索。

理解这两个系统(EC2 和 S3)主要是 web 服务,并且被设计成使用标准的 web 服务协议(如 SOAP 和 REST)来控制,这一点很重要。许多工具为这些服务提供了用户友好的界面,但是它们都使用上述协议与 AWS (Amazon Web Services)进行交互。

如果你想使用这些服务,你必须在 http://aws.amazon.com/注册。您不必为每个服务创建一个帐户;事实上,你可以使用现有的亚马逊商店帐户,但你必须单独注册每项服务。

身份验证和安全性

当您使用 EC2 和 S3 服务时,您必须向 AWS 系统验证您自己。有不同的方法可以做到这一点,不同的服务需要您提供略有不同的信息。有时,这可能会造成混乱,不知道应该在哪里使用哪种方法,更重要的是,不知道从哪里获取这些信息。因此,在研究每个单独的服务之前,我将提供关于 AWS 中使用的安全性和认证机制的基本信息。

帐户标识符

每个账户都有一个唯一的 AWS 账户 ID 号,由 12 位数字组成,看起来像 1234-5678-9012。每个帐户还有一个指定的规范用户 ID ,它是一个包含 64 个字母数字字符的字符串。

AWS 帐号用于在不同帐户之间共享对象。例如,如果您想授权其他人访问您的虚拟机映像,您必须知道该人的 AWS 帐户 ID。该 ID 用于除 S3 之外的所有 AWS 服务。

规范用户 ID 仅在 S3 服务中使用。与 AWS 帐户 ID 类似,它的主要目的是控制访问。

你可以通过进入aws.amazon.com/account/,点击“安全证书”,并向右滚动到网页底部来获取这些信息。包含所需信息的部分称为“帐户标识符”

访问凭据

每个 REST API 调用都会用到访问凭证。这些键也在亚马逊 S3 SOAP 调用中使用。

访问凭证分为两部分。第一部分是访问密钥 ID ,用于识别请求者身份。第二部分是秘密访问密钥,它用于创建一个签名,该签名随每个 API 请求一起发送。当 AWS 收到请求时,它使用相应的秘密访问密钥(只有 AWS 知道)来验证签名。只有有效的秘密访问密钥才能创建能够被 AWS 秘密密钥对应方验证的签名。这确保了请求是从有效的请求者发出的。

这两个密钥都是长字母数字字符串,可以在“访问密钥”选项卡的访问凭据部分找到。建议定期轮换钥匙。此外,确保不要向任何人透露密钥。

最佳实践是创建新用户并生成访问密钥 id 和相应的秘密访问密钥,而不是依赖根访问密钥对。这可以通过导航到用户帐户管理控制台console.aws.amazon.com/iam/home?#users来完成。

X.509 证书

X.509 证书主要用于 SOAP API 请求。该证书由两个文件组成。第一个文件是 X.509 证书,它包含您的公钥和相关元数据。公钥与请求正文一起发送,用于解密请求中包含的签名信息。

证书的第二部分是私钥文件。该文件用于创建数字签名,它包含在每个 SOAP 请求中。你必须保守这个密钥的秘密。

当您生成 X.509 证书时,会为您提供这两个文件。然而,密匙并不存储在亚马逊系统上;因此,如果您丢失了私钥,您必须重新生成 X.509 证书。与访问凭据密钥一样,定期轮换证书是一种很好的做法。

您可以在 X.509 证书选项卡下的“访问凭据”部分生成新闻证书或上传您自己的证书。

EC2 密钥对

EC2 密钥对允许您登录到新的虚拟机实例。每个密钥对由三部分组成。

第一部分是密钥对名称。创建新实例时,通过选择适当的密钥对名称来选择要在该实例上使用的密钥对。

第二部分是私钥文件。该文件用于建立到新虚拟机实例的 SSH(安全 Shell)会话。这个密钥在任何时候都要保密和安全,这一点非常重要。拥有此密钥的任何人都可以访问您的任何虚拟机。

最后一部分是公钥,保存在 AWS 系统上。您不能下载此密钥。当虚拟机实例启动时,AWS 会将这个密钥复制到正在运行的系统,这允许您使用您的私有密钥文件连接到它。

您可以生成任意多的密钥对。与其他凭证不同,EC2 密钥只能从 EC2 管理控制台访问,该控制台位于console.aws.amazon.com/ec2/home

简单的存储系统概念

从用户的角度来看,在 S3 架构中只有两个实体:数据对象和存储桶。

最重要的实体是数据对象。数据对象是实际存储在 S3 基础设施上的东西。从技术上讲,每个数据对象都由两部分组成:元数据和数据负载。元数据部分描述对象,由键值对组成。作为开发人员,您可以定义任意数量的键值对。该元数据在请求的 HTTP 头中发送。第二部分是数据有效载荷,它是您实际想要存储在 S3 上的内容。数据有效负载大小可以是 1 字节到 5 千兆字节。您可以为对象指定任何名称,只要它符合 URI 命名标准。基本上,如果您将名称限制为字母数字字符、点、正斜杠和连字符,应该没问题。

第二个实体是 bucket 对象。是包含数据对象的实体。这些存储桶不能包含其他存储桶。对象名称空间在每个桶内;但是,bucket 名称在全局名称空间中。这意味着一个存储桶中的对象必须有唯一的名称,但是在不同的存储桶中可以有两个同名的对象。在 S3 系统中,存储桶必须具有唯一的名称,因此您可能会尝试使用其他人已经使用的存储桶名称。

每个帐户有 100 个存储桶的限制,但是每个存储桶中存储的对象的大小没有限制。

图 14-1 展示了桶和对象之间的关系,以及每个对象的一些示例名称。

9781484202180_Fig14-01.jpg

图 14-1 。亚马逊 S3 桶和物品

可以使用以下命名方案将这些名称映射到亚马逊 S3 资源 URL:

http://<bucket name>.s3.amazonaws.com/<object name>

因此,图 14-1 中的对象可以通过以下 URL 访问(假设启用了公共访问权限):

我有意在第二个桶中显示真实的 web URLs。当您导航到任何网站时,您的浏览器都会使用 HTTP GET 请求来获取页面。这些请求与用于访问 S3 系统对象的 REST 请求相同,因此您可以在 S3 上托管完整的网站(或动态站点的静态部分)。

弹性计算云概念

Amazon EC2 WS 是一个复杂的系统,它与其他服务(如 Amazon S3)交互,为您提供完整的按需计算解决方案。如果您熟悉任何虚拟化平台,如 Xen、KVM 或 VMWare,您会发现这里描述的大多数概念都非常相似。

Amazon 机器映像和实例

亚马逊机器映像(AMI) 是可以启动的操作系统的映像。该映像包含运行您的系统所需的所有包。你需要多少阿米就有多少。例如,如果您想要复制我们前面描述的两层 web 系统,您将创建两种类型的 AMI:web 服务器 AMI 和数据库 AMI。web 服务器 AMI 安装了 Apache web 服务器和 Apache Tomcat 应用服务器包。数据库 AMI 会安装一个 MySQL 实例。

公开提供了许多不同的 ami。有几个是亚马逊和其他公司提供的。一些 ami 是免费的,但是如果你想使用它们,也有一些 ami 是需要付费的。创建您自己的 AMI 的最简单的方法是克隆一个现有的 AMI 并进行您自己的修改。确保您使用来自可信来源的 AMI!

Image 注意尽量不要将你的操作建立在公开可用的 AMIs 上。当此类 AMI 的创建者决定销毁该 AMI 时,您将无法再次使用它。如果你发现一个你认为合适的 AMI,就把它复制一份,创建一个私有 AMI。即使你不打算对它做任何修改,也要这样做。这确保了每当您需要使用 AMI 时,您总是能够找到相同的 AMI。S3 上典型的 Linux AMI 大小不到 1GB。假设标准费用为每月 0.03 美元,维护自己的 AMI 每年只需花费 0.36 美元。

您不能运行 AMI 本身;您必须创建一个想要运行的 AMI 实例。该实例是运行 AMI 中安装的软件的实际虚拟机。类似的可以是 Python 类和类实例(或对象)。该类定义了方法和属性(或操作系统术语中的软件包)。当您想要运行已定义的方法时,您可以创建该特定类的对象。类似地,AMI 是虚拟机的内容,而实例是实际运行的虚拟机。

您有两种选择来存储 ami:在亚马逊 S3 存储上或者在亚马逊弹性块存储快照上(我们将在下一节讨论)。存储 AMI 的方法决定了它是如何创建的,并影响它的行为。

表 14-1 总结了这两种储存 ami 方法之间的差异。

表 14-1 。比较 S3 和 EBS 支持的非盟特派团

|

方面

|

EBS 支持的 AMI

|

S3 支持的 AMI

尺寸限制 EBS 卷限制为 1TB。这对于大型安装非常方便。 S3 支持的根分区最大可达 10GB。如果您的根分区需要比这个大,您就不能使用这个方法。
停止正在运行的实例 您可以停止实例,这意味着虚拟机没有运行,您也没有被收费,但是根分区没有被释放,仍然作为 EBS 卷存在。然后,您可以从同一个实例卷重新启动该实例。 您不能停止实例。如果您停止实例,它将被终止,根分区也被销毁;因此,存储在该分区上的所有信息都将丢失。
数据持久性 本地数据存储是附加的,可用于存储临时数据。停止实例时,不会分离根分区,但会丢失本地存储。您可以连接任意数量的 EBS 卷来永久存储数据。 本地数据存储是附加的,可用于存储临时数据。终止实例时,根分区和本地存储中的数据都会丢失。您可以连接任意数量的 EBS 卷来永久存储数据。
启动时间 引导时间更快,因为根分区上的数据在 EBS 卷上立即可用。但是,虚拟机在开始时会运行得较慢,因为数据是逐渐从快照中获取的。 启动时间较慢,因为所有数据都需要在部署到根分区之前从 S3 中检索。
创建新的图像 单个 API 调用将现有运行的 AMI 克隆到一个新卷。 您必须创建一个包含所有必需包的操作系统映像,然后创建一个映像包并将其上传到 S3。然后用捆绑的映像注册 AMI。
充电 将收取以下费用:
  • 卷快照(完整卷大小)的费用
  • 实例处于停止状态时使用的卷空间的费用(完整卷大小)
  • 对正在运行的实例收费

| 将收取以下费用:

  • 存储 AMI 图像的 S3 费用(压缩)
  • 对正在运行的实例收费

|

下图显示了 S3 支持的和 EBS 支持的实例的生命周期。

图 14-2 显示了一个典型的 S3 支持的实例的生命周期。该实例是从存储在 S3 上的 AMI 映像创建的。当实例终止时,卷将被销毁,所有数据都将丢失。您只需支付 S3 商店和虚拟机的运行成本。

9781484202180_Fig14-02.jpg

图 14-2 。S3 支持的实例生命周期

图 14-3 显示了 EBS 支持的实例的典型生命周期。初始启动时,根卷是从 EBS 快照创建的。实例可以有两种不同的状态:运行和停止。当实例运行时,您需要为处理能力和 EBS 容量付费。当实例停止时,您只需为 EBS 卷付费。如果您恢复实例,它将保留其根卷中的所有数据;所以,你买单。最后,如果您选择销毁实例,卷也会被销毁,并且您不再为卷付费。

9781484202180_Fig14-03.jpg

图 14-3 。EBS 支持的实例生命周期

从图中可以看出,无论实例类型如何,它们都有一个本地存储。这个存储称为临时存储,它的生命周期受限于实例处于运行状态的时间。它还可以在操作系统重新启动后继续存在(有意或无意),但是一旦您停止实例,临时设备上的所有数据都会丢失。

弹性块存储

弹性块存储(EBS) 是一个块级设备,可用于 EC2 实例。卷完全独立于实例,当实例被终止和销毁时,数据不会丢失。EBS 卷是高度可用和可靠的存储设备。

每个 EBS 卷的大小从 1GB 到 1TB 不等。您可以将多个卷附加到一个正在运行的 EC2 实例。如果需要大于 1TB 的卷,可以使用 LVM(逻辑卷管理器)等操作系统工具将多个 EBS 卷合并成一个更大的卷。

正如我提到的,EBS 卷是块设备,所以在使用它们之前,您必须在其上创建一个文件系统。或者,您可以在支持原始设备访问的应用中将这些设备用作原始设备。

Amazon WS 还提供了获取卷快照的功能。A ) 卷快照是卷内容的时间点副本。拷贝将备份到 S3 存储。您可以根据需要创建任意数量的快照。第一个快照是卷的完整副本,但顺序快照仅记录最后一个快照和当前卷状态之间的差异。

拍摄卷快照的操作可以颠倒过来,您可以从现有快照创建卷。如果您必须向多个 EC2 实例提供相同的数据,这将非常有用。您还可以在 Amazon WS 帐户之间共享快照。

其他重要且有用的 EBS 功能包括:

  • 调配的 IOPS,允许您预定义特定级别的 I/O 性能。
  • EBS 卷加密,可用于加密卷和保护敏感数据。
  • 性能指标监控,可通过 AWS 管理控制台获得。

安全组

使用安全组控制对实例的网络访问。安全组是一组网络访问规则,类似于 IPTables 规则集。您可以定义目的网络地址、端口号和通信协议,例如 TCP 或 UDP。

当您启动一个新实例时,您可以为其分配一个或多个安全组。例如,您可以有一个允许 TCP 访问端口 3306 (MySQL 服务端口)的数据库安全组。当您创建一个新的数据库实例时,您选择这个安全组,它允许外部访问您的 MySQL 服务器。

确保您允许管理 SSH 访问您的实例;否则,您将无法连接和管理它们。

弹性 IPs 和负载平衡器

默认情况下,每个实例都会收到一个动态分配的公共 IP 地址。显然,这不适合服务于 web 内容或提供其他公共可用服务的服务器。每次重新启动实例时,您可能会获得不同的 IP 地址。

您可以请求一个弹性 IP 地址,它总是附属于一个 EC2 实例。这允许您为您的服务器创建一个 DNS 条目,并且该条目不需要随着时间而改变。弹性 IP 的额外好处是您可以为它分配一个故障转移实例。这意味着,如果主实例出现故障,IP 将被重新定位到另一个能够处理请求的实例。这种方法允许您实现简单的活动-备用系统配置。

您还可以使用 Amazon EC2 负载平衡功能,将传入的请求分布在两个或更多实例之间。虚拟负载平衡器的作用类似于传统的硬件负载平衡器,如 Cisco、Citrix Netscaler 或 A10 range 负载平衡器。

创建新的负载平衡器实例相对简单。您必须选择外部可用的服务端口—例如,用于 HTTP 流量的端口 80。然后,在实例上选择服务端口。例如,假设您在 EC2 实例的端口 8080 上运行一个 Tomcat 实例,但是您想通过标准的 HTTP 端口 80 提供这个服务。在这种情况下,外部服务端口 80 将被映射到内部服务端口 8080。最后,将 EC2 实例分配给负载平衡器。

用户界面

您可以通过位于console.aws.amazon.com/console/home的 AWM 管理控制台管理所有 AWS 服务。

创建自定义 EC2 图像

现在,您已经对 EC2 和 S3 服务有了基本的了解,让我们将这些知识付诸实践。正如您已经知道的,我们需要创建一个 AMI,用于启动我们的实例。我将向您展示如何基于现有的图像创建自定义 AMI。我们将创建一个 S3 支持的 AMI 映像,因为在我们的实例中,它将更加经济高效,并且我们不需要实例停止功能。当数据被传输和处理后,我们可以销毁实例。

重用现有图像

让我们从可用图像列表中选择一个现有图像开始。在本练习中,我将使用标准的 Amazon AWS 管理控制台。

  1. 我们首先从主仪表板中选择 EC2 管理控制台。我们将看到您所有 EC2 服务的概述。

  2. 在左侧菜单中,我们选择“图像”部分下的“AMIs”。

  3. The default filter is to show all images that we own. We will need to select “Public Images” from the dropdown menu. For this exercise, I am going to use a CentOS 6.5 image created by a company called RightScale. This is a well-known company, which specializes in deploying mission-critical systems in the cloud environment; therefore, the images produced by them can be trusted. The AMIs ID we are looking for is ami-2e32c646. We can find it by using a search field in the filter.

    图 14-4 是选择了 AMI 的 AWS 管理控制台的屏幕截图。

    9781484202180_Fig14-04.jpg

    图 14-4 。选择要克隆的 AMI

  4. 找到 AMI 后,我们右键单击它并选择“Launch”来启动实例启动过程。

Image 注意确保您已经创建了安全组,并启用了端口 3306 (MySQL)和 22 (SSH)以便从所有 IP 进行访问。您还需要生成一个密钥对,并将私钥下载到您的本地机器上。安全地保存私钥,并记下它的名称。在本文中,我们将这个文件称为<密钥对名> .pem

我们可以通过单击导航菜单中的 Instances 链接来监视 EC2 实例的状态。一旦实例处于运行状态,我们就可以使用 SSH 来连接它。单击实例名称,详细信息将显示在单独的窗口中。记下实例的公共 DNS 名称。

我们使用以下命令连接到实例:

$ ssh -i <key-pair name>.pem root@<instance public DNS>

进行修改

我们现在准备对图像进行修改。正如您所记得的,我们的目标是使这个映像成为一个 MySQL 数据库实例,将所有文件存储在一个专用的持久 EBS 卷上。

安装附加软件包

首先,我们需要安装额外的包——特别是 MySQL 服务器。我们首先执行这一步的原因是,在挂载新的文件系统时,我们需要 MySQL 用户帐户,它是由我们现在要安装的包创建的。

我们使用 Yum 安装程序来安装附加的包:

# yum install mysql mysql-server

该命令将从操作系统存储库中安装默认的 MySQL 包版本。在 CentOS 6.5 的情况下,这将是 MySQL 5.1 版本。(如果需要使用更高版本的 MySQL server,就必须使用 Oracle 提供的 MySQL 存储库。)要添加存储库配置,我们在安装 MySQL 包之前运行以下命令:

# yum install http://repo.mysql.com/mysql-community-release-el6-5.noarch.rpm

Image 注意如果你决定从源代码构建 MySQL,确保你设置好了,这样当你的机器启动时它会自动启动。如果你安装的是标准的操作系统包,你就不需要担心这个问题。

创建和设置弹性块存储卷

接下来,我们将设置一个新的 EBS 卷。我们返回并单击“Create Volume ”,然后使用弹出窗口指定新卷所需的选项。

我们确保为数据分配足够的空间。可用性区域必须与正在运行的实例的可用性区域相匹配。我们可以通过单击 Instances 部分中的实例名称来找出实例的可用性区域。

根据卷的大小,卷可能需要一段时间才能变得可用。当卷变得可用时(如卷状态列所示),我们可以将其附加到正在运行的 EC2 实例。我们右键单击卷名并选择“附加卷”菜单项。然后,我们将看到可用的运行 EC2 实例的列表。我们选择之前创建的实例。我们还将被要求为新卷指定本地设备名称。在选择设备名称(如/dev/sdf)时,我们确保该名称没有被任何其他设备使用。

当设备可用时(在实例的文件系统上创建了设备文件/dev/xvdf),我们可以使用以下命令在其上创建文件系统:

# mke2fs -F -j /dev/xvdf
...
# e2label /dev/xvdf mysqlvol

Image 注意你可能想知道为什么我们创建了/dev/sdf 设备,却在实际的操作系统上使用/dev/xvdf。这是因为如果设备是虚拟磁盘,较新的内核会对其进行重命名。

现在,我们创建一个新目录,它将用于挂载新创建的文件系统并更改所有权,以便 MySQL 进程能够写入卷:

# mkdir /mysql-db
# chown mysql.mysql /mysql-db
# mount LABEL=mysqlvol /mysql-db

配置 MySQL 实例

接下来,我们将配置 MySQL 实例。我们必须更改 MySQL 配置文件(位于/etc/my.cnf 中)的内容,以便 socket 文件和所有数据文件都存储在 EBS 卷上。这确保了数据在系统重新启动时不会丢失。MySQL 配置文件的新内容在清单 14-1 中给出。

清单 14-1 。将 MySQL 数据库指向新位置

[mysqld]
datadir=/mysql-db
socket=/mysql-db/mysql.sock
user=mysql
symbolic-links=0

[mysqld_safe]
log-error=/var/log/mysqld.log
pid-file=/var/run/mysqld/mysqld.pid

现在,让我们启动 MySQL 守护进程并设置默认密码。

Image 注意显然,你必须使用比我在这个例子中使用的更安全和不可预测的东西。

# chkconfig --levels 235 mysqld on
# service mysqld start
# mysqladmin -u root -S /mysql-db/mysql.sock password 'password'
# mysql -p -S /mysql-db/mysql.sock
[...]
mysql> grant all privileges on *.* to 'root'@'%' identified by 'password' with grant option;
Query OK, 0 rows affected (0.00 sec)

mysql> flush privileges;
Query OK, 0 rows affected (0.00 sec)

最后,我们关闭 MySQL 守护进程并卸载卷:

# service mysqld stop
# umount /mysql-db

捆绑新 AMI

一旦我们完成了所有的修改并且对运行的实例感到满意,我们就可以通过捆绑它来创建一个新的 AMI。

首先,我们必须在 Linux 实例上安装 AMI 工具。这些说明可能会随着时间的推移而略有变化,因此我们遵循这里描述的安装说明:docs . AWS . Amazon . com/AWS C2/latest/command line reference/set-up-ami-tools . html安装 AMI 工具包,以及docs . AWS . Amazon . com/AWS C2/latest/command line reference/set-up-ec2-cli-Linux . html安装 CLI 工具。

我们检查安装是否成功:

# ec2-ami-tools-version
1.5.3 20071010

Copyright 2008-2014 Amazon.com, Inc. or its affiliates.  All Rights Reserved.
Licensed under the Amazon Software License (the "License").  You may not use
this file except in compliance with the License. A copy of the License is
located at http://aws.amazon.com/asl or in the "license" file accompanying this
file.  This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
CONDITIONS OF ANY KIND, either express or implied. See the License for the
specific language governing permissions and limitations under the License.

# ec2-describe-regions
REGION  eu-west-1       ec2.eu-west-1.amazonaws.com
REGION  sa-east-1       ec2.sa-east-1.amazonaws.com
REGION  us-east-1       ec2.us-east-1.amazonaws.com
REGION  ap-northeast-1  ec2.ap-northeast-1.amazonaws.com
REGION  us-west-2       ec2.us-west-2.amazonaws.com
REGION  us-west-1       ec2.us-west-1.amazonaws.com
REGION  ap-southeast-1  ec2.ap-southeast-1.amazonaws.com
REGION  ap-southeast-2  ec2.ap-southeast-2.amazonaws.com

然后,我们必须准备我们的 X.509 证书文件,并用访问凭证设置一些环境变量。这些将在捆绑命令中使用,因此我们确保事先做好准备,以避免在运行命令时出现任何问题。从账户管理控制台(console.aws.amazon.com/iam/home?#security_credential ,我们创建一个新的 X.509 证书文件(证书文件和私钥)。我们在本地将下载的文件保存为 pk.pem(私钥)和 cert.pem(证书)。当我们拥有这两个文件时,我们将它们复制到正在运行的实例的/mnt/目录中。

我们返回到正在运行的 EC2 实例上的 shell 提示符,并将以下环境变量设置为适当的值,这些值可以从帐户管理 web 页面获得:

export AWS_USER=<12 digit account ID>
export AWS_ACCESS_KEY=<REST access key>
export AWS_SECRET_KEY=<REST secret access key>

我们现在准备捆绑正在运行的实例。我们发出下面的命令,并等待它完成。这是一个相当漫长的过程,可能需要 10 分钟:

# ec2-bundle-vol -u $AWS_USER -k /mnt/pk.pem -c /mnt/cert.crt -p CentOS-6.5-x86_64-mysql -r x86_64
Setting partition type to bundle "/" with...
Auto-detecting partition type for "/"
Partition label detected using parted: "loop"
Using partition type "none"
Copying / into the image file /tmp/CentOS-6.5-x86_64-mysql...
Excluding:
         /proc/sys/fs/binfmt_misc
         /sys
         /proc
         /dev/pts
         /dev
         /media
         /mnt
         /proc
         /sys
         /tmp/CentOS-6.5-x86_64-mysql
         /mnt/img-mnt

Image 注意 rsync 似乎成功了,但退出时出现错误代码 23。这可能意味着您的 rsync 版本是针对定义了 HAVE_LUTIMES 的内核构建的,尽管当前的内核没有启用该选项。因此,捆绑过程将忽略该错误并继续捆绑。如果捆绑成功完成,您的图像应该完全可用。然而,我们建议您安装一个版本的 rsync 来更好地处理这种情况。

Image file created: /tmp/CentOS-6.5-x86_64-mysql
Volume cloning done.
Bundling image file...
Splitting /tmp/CentOS-6.5-x86_64-mysql.tar.gz.enc...
Created CentOS-6.5-x86_64-mysql.part.00
Created CentOS-6.5-x86_64-mysql.part.01
[ . . . ]
Created CentOS-6.5-x86_64-mysql.part.35
Generating digests for each part...
Digests generated.
Unable to read instance meta-data for ancestor-ami-ids
Unable to read instance meta-data for ramdisk-id
Unable to read instance meta-data for product-codes
Creating bundle manifest...
Bundle manifest is /tmp/CentOS-6.5-x86_64-mysql.manifest.xml
ec2-bundle-vol complete.

一旦图像捆绑完成,我们必须上传到 S3 存储。以下命令中的–b 选项表示存储桶名称。如您所知,桶名在整个 S3 系统中必须是唯一的,所以我们要小心选择。我们不需要事先创建桶;如果存储桶不存在,将会为您创建一个存储桶。上传过程比捆绑过程稍微快一点,但是我们预计它也会花费相当多的时间:

# ec2-upload-bundle -b pro-python-system-administration -m /tmp/CentOS-6.5-x86_64-mysql.manifest.xml -a "$AWS_ACCESS_KEY" -s "$AWS_SECRET_KEY"
Uploading bundled image parts to the S3 bucket pro-python-system-administration ...
Uploaded CentOS-6.5-x86_64-mysql.part.00
[ . . . ]
Uploaded CentOS-6.5-x86_64-mysql.part.35
Uploading manifest ...
Uploaded manifest.
Manifest uploaded to: pro-python-system-administration/CentOS-6.5-x86_64-mysql.manifest.xml
Bundle upload completed.

最后,我们必须注册新创建的 AMI。一旦命令执行完毕,我们将得到 AMI ID 字符串提示。我们还将在您的私人 AMI 选择屏幕中看到新 AMI:

# ec2-register --name 'pro-python-system-administration/CentOS-6.5-x86_64-mysql' \
               pro-python-system-administration/CentOS-6.5-x86_64-mysql.manifest.xml \
               -K /mnt/pk.pem

IMAGE   ami-2a58a342

使用 Boto Python 模块控制 EC2

我们最终进入了创建代码来自动管理 EC2 实例的阶段。您可以使用 SOAP 或 REST API 来访问这些服务,但是您不必亲自动手,因为有许多不同的库可供使用。尽管缺少印刷文档,但该主题在互联网上有很好的文档记录,并且大多数流行的编程语言都可以使用这些库,如 Java、Ruby、C#、Perl,当然还有 Python。

用于访问 Amazon web 服务的最流行的 Python 库之一是 Boto 库。这个库提供了到以下 AWS 的接口:

  • 简单存储服务(S3)
  • 简单队列服务(SQS)
  • 弹性计算云(EC2)
  • 机械土耳其人
  • 简单数据库(SDB)
  • 内容推送服务
  • 虚拟专用云(VPC)

大多数 Linux 发行版上都有这个库。例如,在 Fedora 系统上,可以使用以下命令安装库:

$ sudo yum install python-boto

您也可以从项目主页github.com/boto/boto下载源代码。官方文件可在 http://docs.pythonboto.org/en/latest/[获得。](http://docs.pythonboto.org/en/latest/)

设置配置变量

将有两种类型的配置数据。特定于帐户的配置(REST API 访问键)并不特定于我们的应用,可以存储在名为。用户目录中的 boto。

该配置文件包含访问 ID 密钥和秘密访问密钥:

[Credentials]
aws_access_key_id = <Access key>
aws_secret_access_key = <Secret access key>

我们将在 backup.cfg 文件中存储特定于应用的配置,并通过使用 ConfigParser 库来访问它。文件的内容在下面的代码中描述:

[main]
volume_id=vol-7556353c           # the EBS volume ID which we mount to the EC2 DB instances
vol_device=/dev/sdf              # the name of the device of the attached volume
mount_dir=/mysql-db              # the name of the mount directory
image_id=ami-2a58a342            # the name of the custom created AMI image
key_name=<private key>           # the name of the key pair (and the pem file)
key_location=/home/rytis/EC2/    # the location of the key pair file
security_grp=database            # the name of the security group (with SSH and MySQL ports)

以编程方式初始化 EC2 实例

首先,让我们创建应用的框架结构。在清单 14-2 中,我们从创建 BackupManager 类开始。这个类将实现管理自定义 EC2 实例的方法。我们还设置了一个 logger 对象,用于记录应用的状态。

清单 14-2 。应用的结构

#!/usr/bin/env python

import sys
import logging
import time
import subprocess
import boto
import boto.ec2
from ConfigParser import SafeConfigParser
import MySQLdb
from datetime import datetime

CFG_FILE = 'backup.cfg'

class BackupManager:

    def __init__(self, cfg_file=CFG_FILE, logger=None):
        self.logger = logger
        self.config = SafeConfigParser()
        self.config.read(cfg_file)
        self.aws_access_key = boto.config.get('Credentials', 'aws_access_key_id')
        self.aws_secret_key = boto.config.get('Credentials',
                                              'aws_secret_access_key')
        self.ec2conn = boto.ec2.connection.EC2Connection(self.aws_access_key,
                                                         self.aws_secret_key)
        self.image = self.ec2conn.get_image(self.config.get('main', 'image_id'))
        self.volume = self.ec2conn.get_all_volumes([self.config.get('main',
                                                                'volume_id')])[0]
        self.reservation = None
        self.ssh_cmd = []
[...]
def main():
    console = logging.StreamHandler()
    logger = logging.getLogger('DB_Backup')
    logger.addHandler(console)
    logger.setLevel(logging.DEBUG)
    bck = BackupManager(logger=logger)

if __name__ == '__main__':
    main()

如您所见,在初始化过程中我们已经建立了与 AWS 的连接。EC2Connection()调用返回的结果是连接对象,我们将使用它来访问 AWS 系统。

self.ec2conn = boto.ec2.connection.EC2Connection(self.aws_access_key,
                                                 self.aws_secret_key)

例如,以下两个调用返回 AMI 映像和卷对象:

self.image = self.ec2conn.get_image(self.config.get('main', 'image_id'))
self.volume = self.ec2conn.get_all_volumes([self.config.get('main', 'volume_id')])[0]

这些对象中的每一个都公开了可用于控制它们的方法。例如,volume 对象实现 attach 方法,该方法可用于将特定卷附加到 EC2 实例。我们将在下面几节中发现最常用的方法。

启动 EC2 实例

我们的第一个任务是启动实例。这可以通过 run()方法来完成,该方法在我们前面创建的 image 对象中可用。

这个调用的结果是一个 reservation 对象,它列出了从这个调用开始的所有实例。目前,我们只启动了一个实例,但是您可以从同一个 AMI 映像启动多个实例。

run()方法需要设置两个参数:密钥对名称和安全组。我还指定了可选的 placement zone 参数,它指示实例需要在哪个 EC2 zone 中启动。我们并不真正关心该分区是什么,只要它是创建卷的同一个分区。您不能从不同的区域附加卷,因此实例必须在同一区域中运行。您可以通过检查卷对象的区域属性来发现卷的区域。

如您所知,该实例不会立即可用;因此,我们必须实现一个简单的循环,定期检查实例的状态,并等待直到它将状态更改为“正在运行”(见清单 14-3 )。

清单 14-3 。启动 EC2 实例

def _start_instance(self):
    self.logger.debug('Starting new instance...')
    self.reservation = self.image.run(key_name=self.config.get('main', 'key_name'),
                           security_groups=[self.config.get('main', 'security_grp')],
                           placement=self.volume.zone)
    instance = self.reservation.instances[0]
    while instance.state != u'running':
        time.sleep(60)
        instance.update()
        self.logger.debug("instance state: %s" % instance.state)
    self.logger.debug("Instance %s is running and available at %s" % (instance.id,
instance.public_dns_name))

连接 EBS 卷

实例运行后,我们可以将卷连接到它。如清单 14-4 所示,只需一个方法调用就可以附加这个卷。然而,有一个警告。即使您等待卷更改其状态以指示它已成功“连接”,您仍可能会发现设备未准备好。我发现通常多等 5 秒钟就足够了,但是为了安全起见,我们会再等 10 秒钟。

清单 14-4 。连接 EBS 卷

def _attach_volume(self, volume=None):
    if not volume:
        volume_to_attach = self.volume
    else:
        volume_to_attach = volume
    instance_id = self.reservation.instances[0].id
    self.logger.debug("Attaching volume %s to instance %s as %s" %
                                              (volume_to_attach.id,
                                              instance_id,
                                              self.config.get('main', 'vol_device')))
    volume_to_attach.attach(instance_id, self.config.get('main', 'vol_device'))
    while volume_to_attach.attachment_state() != u'attached':
        time.sleep(20)
        volume_to_attach.update()
        self.logger.debug("volume status: %s", volume_to_attach.attachment_state())
    time.sleep(10) # give it some extra time
                   # aws sometimes is mis-reporting the volume state
    self.logger.debug("Finished attaching volume")

安装 EBS 设备

卷已连接,但文件系统对操作系统尚不可见。不幸的是,没有 API 调用来挂载文件系统,因为这是操作系统的功能,Amazon WS 对此无能为力。

因此,我们必须使用 ssh 命令远程发出 mount 命令。建立远程通信链接的 ssh 命令总是相同的,所以我们使用清单 14-5 中的方法构建一个,并且我们将在每次需要在远程系统上发出操作系统命令时重用它。

清单 14-5 。构造 ssh 命令参数

def _init_remote_cmd_args(self):
    key_file = "%s/%s.pem" % (self.config.get('main', 'key_location'),
                              self.config.get('main', 'key_name'))
    remote_user = 'root'
    remote_host = self.reservation.instances[0].public_dns_name
    remote_resource = "%s@%s" % (remote_user, remote_host)
    self.ssh_cmd = ['ssh',
                    '-o', 'StrictHostKeyChecking=no',
                    '-i', key_file,
                    remote_resource]

我们必须使用 OpenSSH 选项 StrictHostKeyChecking=no,因为我们将连接到新主机,默认情况下,OpenSSH 会警告您它接收的主机密钥以前从未出现过。它还会要求确认是否接受远程键——这是您不希望在自动化系统中看到的行为。

一旦构造了默认的 ssh 参数字符串,我们就可以向正在运行的实例发出远程卷挂载命令,如清单 14-6 所示。

清单 14-6 。在远程主机上挂载文件系统

def _mount_volume(self):
    self.logger.debug("Mounting %s on %s" % (self.config.get('main', 'vol_device'),
                                             self.config.get('main', 'mount_dir')))
    remote_command = "mount %(dev)s %(mp)s && df -h %(mp)s" %    \
                                      {'dev': self.config.get('main', 'vol_device'),
                                       'mp': self.config.get('main', 'mount_dir')}
    rc = subprocess.call(self.ssh_cmd + [remote_command])
    self.logger.debug('done')

启动 MySQL 实例

正如我们对 mount 命令所做的那样,我们将使用相同的机制来启动和停止远程服务器上的 MySQL 守护进程。我们将使用标准的 Red Hat distribution/sbin/service 命令来运行初始化脚本,如清单 14-7 所示。

清单 14-7 。远程启动和停止 MySQL 守护进程

def _control_mysql(self, command):
    self.logger.debug("Sending MySQL DB daemon command to: %s" % command)
    remote_command = "/sbin/service mysqld %s; pgrep mysqld" % command
    rc = subprocess.call(self.ssh_cmd + [remote_command])
    self.logger.debug('done')

传输数据

此时,我们已经让远程系统准备好接受 MySQL 数据库连接。正如我们之前讨论过的,实际的数据传输和处理是特定的任务,没有通用的方法。通常,所涉及的步骤如下:

5.建立到本地数据库的连接。

6.建立到运行在 EC2 实例上的远程数据库的连接。

7.找出哪些本地数据还不存在于远程数据库中。

8.从本地数据库中读取记录集,并相应地更新远程数据库。

9.如果不需要,从本地数据库中删除旧数据。

10.通过在远程 EC2 实例上使用复杂的 SQL 查询或函数来执行任何统计计算。

不过话说回来,这个过程很大程度上取决于您的需求,所以我将把这个任务的实现留给您。在我们的示例应用中,我们将使用一个只等待一小段时间的虚拟函数:

def _copy_db(self):
    self.logger.debug('Backing up the DB...')
    time.sleep(60)

以编程方式销毁 EC2 实例

当我们完成远程数据库的更新和所有数据处理任务之后,我们就可以开始销毁 EC2 实例了。该实例将被销毁,但数据库卷将与其上的数据文件一起保留。作为辅助安全措施,我们还将创建该卷的快照。

关闭 MySQL 实例

我们从关闭 MySQL 数据库服务器开始。您已经熟悉了代码,如清单 14-7 中的所示。唯一的不同是,这一次,我们将把“stop”参数传递给方法调用。

卸载文件系统

当 MySQL 服务器没有运行时,我们可以安全地卸载文件系统。同样,我们将通过使用 ssh 连接机制发出 OS 命令来做到这一点,如清单 14-8 中的所示。

清单 14-8 。卸载文件系统

def _unmount_volume(self):
    self.logger.debug("Unmounting %s" % self.config.get('main', 'mount_dir'))
    remote_command = "sync; sync; umount %(mp)s; df -h %(mp)s" % \
                             {'mp':self.config.get('main', 'mount_dir')}
    rc = subprocess.call(self.ssh_cmd + [remote_command])
    self.logger.debug('done')

分离 EBS 卷

从技术上讲,此时不需要分离卷;一旦 EC2 实例终止,它就会自动分离。但是,我建议您首先分离这个卷(如清单 14-9 所示),因为如果 EC2 WS 行为改变,假设默认行为可能会在将来导致不必要的问题。

清单 14-9 。分离卷

def _detach_volume(self, volume=None):
    if not volume:
        volume_to_detach = self.volume
    else:
        volume_to_detach = volume
    self.logger.debug("Detaching volume %s" % volume_to_detach.id)
    volume_to_detach.detach()
    while volume_to_detach.attachment_state() == u'attached':
        time.sleep(20)
        volume_to_detach.update()
        self.logger.debug("volume status: %s", volume_to_detach.attachment_state())
    self.logger.debug('done')

拍摄卷的快照

分离卷后,我们将拍摄当前状态的快照。同样,它只是一个单一的方法调用。我们还将使用拍摄快照时的当前时间戳填充描述字段;参见清单 14-10 。

清单 14-10 。拍摄卷快照

def _create_snapshot(self, volume=None):
    if not volume:
        volume_to_snapshot = self.volume
    else:
        volume_to_snapshot = volume
    self.logger.debug("Taking a snapshot of %s" % volume_to_snapshot.id)
    volume_to_snapshot.create_snapshot(description="Snapshot created on %s" % \
                                                  datetime.isoformat(datetime.now()))
    self.logger.debug('done')

关闭实例

最后,我们将终止 EC2 实例。虽然没有必要,但我们将等待实例完全终止后再继续,如清单 14-11 所示。

清单 14-11 。终止正在运行的实例

def _terminate_instance(self):
        instance = self.reservation.instances[0]
        self.logger.debug("Terminating instance %s" % instance.id)
        instance.terminate()
        while instance.state != u'terminated':
            time.sleep(60)
            instance.update()
            self.logger.debug("instance state: %s" % instance.state)
        self.logger.debug('done')

控制序列

虽然我按照调用方法的顺序描述了这些方法,但是为了方便起见,下面是从主应用函数执行的方法调用的顺序:

def main():
    console = logging.StreamHandler()
    logger = logging.getLogger('DB_Backup')
    logger.addHandler(console)
    logger.setLevel(logging.DEBUG)
    bck = BackupManager(logger=logger)
    bck._start_instance()
    bck._init_remote_cmd_args()
    bck._attach_volume()
    bck._mount_volume()
    bck._control_mysql('start')
    bck._copy_db()
    bck._control_mysql('stop')
    bck._unmount_volume()
    bck._detach_volume()
    bck._create_snapshot()
    bck._terminate_instance()

正在运行的应用的示例输出如下。请注意,第二个 df 命令的输出显示了不同的装载点和不同的设备,因为 EBS 卷上的文件系统已被成功卸载。

# ./db_backup.py
Starting new instance...
instance state: running
Instance i-02139929 is running and available at ec2-54-90-194-188.compute-1.amazonaws.com
Attaching volume vol-7556353c to instance i-02139929 as /dev/xvdf
volume status: attached
Finished attaching volume
Mounting /dev/xvdf on /mysql-db
Warning: Permanently added 'ec2-54-90-194-188.compute-1.amazonaws.com,54.90.194.188' (RSA) to the list of known hosts.
Filesystem      Size  Used Avail Use% Mounted on
/dev/xvdf       9.9G  172M  9.2G   2% /mysql-db
done
Sending MySQL DB daemon command to: start
Starting mysqld:  [  OK  ]
1063
1165
done
Backing up the DB...
Sending MySQL DB daemon command to: stop
Stopping mysqld:  [  OK  ]
done
Unmounting /mysql-db
Filesystem      Size  Used Avail Use% Mounted on
/dev/xvda       9.9G  1.3G  8.2G  13% /
done
Detaching volume vol-7556353c
volume status: None
done
Taking a snapshot of vol-7556353c
done
Terminating instance i-02139929
instance state: terminated
done

摘要

在这一章中,我们研究了亚马逊网络服务(AWS ),以及如何使用简单存储系统(S3)和弹性计算云(EC2)来执行临时计算任务。除了按需计算任务之外,您还了解了如何对重要数据执行远程备份。我们在本章中构建的简单应用可以作为在虚拟计算云上构建您自己的数据仓库的基础。记住本章的这些要点:

  • EC2 和 S3 主要是被设计成可编程控制的 web 服务。
  • 主要的 S3 组件是数据对象和包含它们的存储桶。
  • Amazon 机器映像(ami)被用作启动 EC2 实例的模板。
  • EC2 实例是实际运行的虚拟机。
  • 您可以使用 Python Boto 库来控制大多数 AWS 服务。
posted @ 2024-08-10 15:28  绝不原创的飞龙  阅读(5)  评论(0编辑  收藏  举报