原文:Pro Python System Administration
协议:CC BY-NC-SA 4.0
这是讨论一个简单的分布式监控系统的实现细节的四章系列中的第二章。在前一章中,我展示了高级系统设计,并详细描述了服务器实现。本章专门介绍监控代理的实现、与传感器应用的交互以及安全模型。
设计
我将进一步阐述我在前一章中简要提到的客户机或监控代理的设计。如您所知,监控代理负责接受传感器读取命令并发回结果。它依赖外部工具来执行测量。
无源组件
监控代理组件将是一个被动组件,这意味着它只对传入的命令做出反应来执行动作。这种架构允许我们对整个系统操作和通信流进行细粒度的控制。监控服务器可以决定何时查询以及查询什么,并且这种行为可能会根据代理先前的响应而改变。
体系结构
监控代理的架构分为两个不同的部分:代理代码,作为守护进程运行,接受来自监控服务器的命令;以及负责检查系统状态的传感器。
传感器代码可以是任何应用,由代理在收到执行检查的命令时调用。由于传感器可以用任何编程或脚本语言编写,这为用于监控系统的工具提供了更大的灵活性。
行动
同样,监控代理的主要目的是调用传感器代码,读取结果,并将它们提交给监控服务器。除此之外,它还执行自我配置和自我更新任务。
接受新配置
安全模型意味着每个监控代理必须知道它的监控服务器地址,并使用它进行通信。换句话说,当被查询时,代理不回答请求者,而是启动到已知服务器的连接并提交所请求的数据。
这种方法要求服务器 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 ausername=Johnlocation=London[computer]name=Jons-PC; network nameoperatingsystem=OS X
ConfigParser 库还允许指定对其他配置项的引用。例如,可以将一个变量设置为某个值,然后在设置另一个变量时,可以重用第一个变量的值。这允许我们在一个地方定义公共条目。在清单 10-1 中,我们定义了数据库表名,并有一个最终用户可控制的自定义表名前缀。
清单 10-1 。一个示例配置文件
[database]ip=192.168.1.1name=my_databaseuser=myuserpassword=mypassword[tables]use_prefix=noprefix=mytablesuser_table=%(prefix)s_usersmailbox_table=%(prefix)s_mailboxes
您可能已经注意到,引用语法是使用字典名称的标准 Python 字符串格式:%( dictionary_key )s。虽然我们在其他使用它的项之前定义了我们引用的项,但是位置没有意义,该项可以出现在节中的任何位置。
使用 ConfigParser 类方法
现在我们知道了配置文件的样子,让我们看看如何访问其中的信息。在下面的例子中,我们将使用来自清单 10-1 的配置文件;文件名是 example.cfg。
让我们从打开和读取配置文件开始:
>>> import ConfigParser>>> c= ConfigParser.RawConfigParser()>>> c.read('example.cfg')
提示如果你需要使用文件指针而不是文件名,你也可以使用 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 sin c.sections():...print "Section: %s" %s...for oin c.options(s):... print " Option: %s" %o... Section: tablesOption: mailbox_tableOption: use_prefixOption: prefixOption: user_tableSection: databaseOption: ipOption: passwordOption: userOption: 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 oin 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">>> x30.0>>>
这显然是错误的,原因在于操作的顺序是错误的。这可能会导致意想不到的结果,并且可能很难确定问题出在哪里。只是通过以错误的顺序应用运算,我们最终评估了下面的公式:
((x +10 -12) *5 +45) /3
因此,如果您要求部分和/或键以特定的顺序出现,请确保对它们进行命名,以便允许简单的字符串排序,然后在使用之前对列表进行排序:
>>> x= 11.0>>> for oin 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">>> x76.0>>>
注意在我的例子中,我使用了一个带有附加整数的字符串。这是最简单的方法;如果超过 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 =hellokey1 =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):0203 class Section:04 def __init__(self, name, parser):05 self.__dict__['name'] =name06 self.__dict__['parser'] =parser0708 def __setattr__(self, option, value):09 self.__dict__[option] =str(value)10 self.parser.set(self.name, option, str(value))1112 def __init__(self, file_name):13 self.parser =SafeConfigParser()14 self.parser.read(file_name)15 self.file_name =file_name16 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))2021 def __getattr__(self, section):22 self.parser.add_section(section)23 setattr(self, section, Section(section, self.parser))24 return getattr(self, section)2526 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.newattr10>>>
因此,我们用配置文件中的节名创建了一个新属性。我们分配的值是另一个类的新实例—第 3–10 行中定义的 Section 类。我们稍后会回到这个课堂。现在,请注意,您可以将该类中的任何属性名赋值为实例。
一旦创建了具有部分名称的属性,我们将遍历该部分中的所有选项(或者键,因为我已经提到过它们),并创建与键同名的属性。我们还将配置文件中的值分配给这些属性。所有这些都发生在相当长的第 19 行,这里我们使用了 setattr()函数。正如我们已经知道的,函数的第一个参数是对一个对象的引用,但是如果在我们编写应用的时候变量名是未知的,我们如何获得那个引用呢?我们刚刚使用一个名称创建了属性,名称仍然以字符串的形式存储在另一个变量中,因此类似地,我们可以使用该字符串名称来访问它。通过名称访问对象属性的函数称为 getattr() ,它接受两个参数——对象的引用和我们正在访问的属性的名称。因此,语句 val = object.attribute 在功能上等同于 val = getattr(object,' attribute '),正如我们从下面的示例中看到的:
>>> dir(o)['__doc__', '__module__', 'newattr']>>> o.newattr10>>> getattr(o, 'newattr')10>>>
我们现在有了满足第一个需求的功能—当创建配置管理器类的实例时,构造函数方法打开配置文件,读取所有数据,并创建相应的对象属性。这允许我们读取配置文件中所有属性的值,并修改它们。本练习的第二部分是让模型接受新的属性并给它们赋值。我们已经知道如何在初始化过程中创建对象属性,但这是一个受控的过程,当类被初始化时,构造函数方法 init() 被调用。如果我们试图访问一个不存在的属性会发生什么?嗯,通常我们会得到一个由 Python 解释器引发的 AttributeError 异常,如果我们这样做的话:
>>> class C:...attribute ='value'... >>> o= C()>>> o.attribute'value'>>> o.does_not_existTraceback (most recent call last):File "<stdin>", line 1, in <module>AttributeError: Cinstance 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.a1>>> o.__dict__['a']1>>> o.bTraceback (most recent call last):File "<stdin>", line 1, in <module>AttributeError: Cinstance has no attribute 'b'>>> o.__dict__['b'] =2>>> o.b2>>>
正如每个类实例都有内置的类方法 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 =nameself.parser =parser
如果我们这样做,新的 setattr()方法将被调用。它将引用 name 属性(第 10 行,self.name ),这是我们正在尝试创建的!因此,为了绕过对 setattr()方法的调用,我们需要在构造函数方法中直接修改 dict dictionary。
注意记住 getattr()和 setattr()调用是不对称的。getattr()函数是在对属性执行了查找(并且失败)之后调用的。所以如果属性存在,这个方法就永远不会被调用。在执行内部字典中的查找之前,调用 setattr()函数*。*
最后,在第 26–29 行,我们定义了一个助手函数,它将我们所有的更改保存到同一个配置文件中。没有自动的变化检测,所以我们需要确保在配置对象发生变化时调用这个函数。
传感器设计
我们必须就传感器应用中的某些 结构达成一致,以便代理知道如何控制它。因此,我们需要确保每个传感器应用都符合以下标准:所有已安装的传感器的传感器名称必须相同。默认的应用名称是 check,可以在配置中更改。
如果用 options 命令行参数调用,每个应用还必须报告其选项。输出是自由格式的文本,但必须包含关于接受的参数的清晰简明的信息。这里有一个例子:
$ disk/check optionspercent <vol> -free space %used <vol> -used in KBfree <vol> -free in KB
结果必须始终是单个浮点数或整数。不允许有额外的空格或字符,因为结果将被假定为一个数字并被视为数字。如果应用不能以所需的格式生成结果,您可以编写一个包装器 shell 脚本来删除多余的字符。示例检查命令的输出如下所示:
$ disk/check used /432477328
最后,记录应用结束后的返回代码。对返回代码做如下假设:如果代码为 0,则表示应用没有遇到任何错误。如果返回代码不等于 0,这意味着应用不能正确地执行检查,它产生的结果不应该被信任。
所有传感器必须存储在预先配置的目录中;默认情况下,它位于 sensors/'中。更新后的传感器的备份副本必须放在单独的目录中,默认名称为 sensors_backup/'。
您可以在配置文件中设置所有这些选项,该文件必须存在并应命名为 client.cfg。以下是包含默认值的配置文件示例:
[sensor]executable =checkhelp =optionspath =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 atest'))<subprocess.Popen object at 0x10048ca10>this is atest>>>
因此,如果您试图在同一个字符串中指定要执行的命令及其参数,将会失败,因为 Popen 类会按照字符串中指定的方式查找可执行文件名称,这显然会失败:
>>> subprocess.Popen('echo "this is atest"')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_childraise child_exceptionOSError: [Errno 2] No such file or directory>>>
另一种方法是使用 shell 运行命令。您必须通过将 shell 变量设置��� True 来指示 Popen 使用 shell:
>>> subprocess.Popen('echo "this is atest"', shell=True)<subprocess.Popen object at 0x10048cb10>this is atest>>>
正如您所看到的,这一次示例按预期工作。如果您使用的是 shell,并且命令是一个字符串,那么它将以精确的形式传递给 shell,所以请确保您对字符串的格式与您在 shell 提示符下直接键入命令时完全相同;这包括在文件名中添加反斜杠来转义空格字符。
使用 shell 执行命令实际上等同于生成 shell 可执行文件并传递命令及其参数,如下例所示:
>>> import subprocess>>> subprocess.Popen(('/bin/sh', '-c', 'echo "this is atest"'))<subprocess.Popen object at 0x10048cc10>this is atest>>>
在 Unix/Linux 系统上,用于运行命令的缺省 shell 是/usr/sh。Python 文档指出,通过将可执行参数设置为不同的二进制文件,您可以指定您选择的任何其他 shell 然而,这并不能正常工作,事实上只是使用另一个 shell 来生成缺省 shell。下面是负责设置替代可执行文件的子进程库的摘录:
if shell:args =["/bin/sh", "-c"] +argsif 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 pythonimport subprocessimport osprint "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:0I 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 subprocessimport timefrom datetime import datetimep =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 subprocessimport timefrom datetime import datetimep =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 subprocessimport osf =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 subprocessimport osf =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_datatest>>> print err_dataNone>>>
您还可以使用可选的参数输入将您需要的任何数据传递给流程:
>>> import subprocess>>> p= subprocess.Popen(('wc', '-c'), stdout=subprocess.PIPE, stdin=subprocess.PIPE)>>> out_data, err_data =p.communicate(input='test string')>>> print out_data11>>>
注意该函数在内存中缓冲所有数据,因此不适合用于大型数据集。例如,如果您的应用预计会产生大量数据,这可能会导致意想不到的结果。“安全”数据的大小没有定义,很大程度上取决于确切的 Python 版本、Linux 版本以及系统中可用的内存量。
使用 communicate()的另一种方法是直接从通过 Popen 类实例可用的文件对象中读取和写入:
>>> import subprocess>>> p= subprocess.Popen('cat /usr/share/dict/words', shell=True, stdout=subprocess.PIPE)>>> i= 0>>> for lin p.stdout:...i += 1... >>> print i234936>>>
类似地,您可以使用与标准输入文件对象相关联的 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 821:43:14 linux -- MARK --Mar 822:03:15 linux -- MARK --Mar 822:16:54 linux rytis: this is atestMar 822:17:01 linux rytis: this is atest 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_dataNone>>>
自动更新传感器代码
最后,我们必须在代理应用中实现一种机制,允许我们从一个中心位置更新任何传感器。当您管理数千台服务器时,您最不想做的事情就是手动复制、解包、替换和验证您在每台服务器上更新的软件包。因此,我们要添加到代理代码中的一个功能是自动检索一个包(在本例中,它只是一个压缩的 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.exposedef 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.expose02 def cmd_update_sensor_code(self, sensor):03 #get the new file04 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 it11 arch =tarfile.open(dst_file)12 arch.extractall(path=tmp_dir)13 arch.close()14 #check it15 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 fails20 shutil.rmtree(tmp_dir)21 else:22 #back up the existing package23 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 pass29 os.remove(dst_file)30 #replace with new31 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')
临时目录和文件都将以最安全的方式创建,并且只能由创建它们的用户读写。
注意还有一点很重要,删除临时文件和目录是进程的责任,库不会为你处理这件事。
使用 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 -ltotal 8-rw-r--r--1 rytisrytis261 Apr 14:35 test.txt$ pythonPython 2.6.1 (r261:67515, Feb 11 2010, 00:51:29) [GCC 4.2.1 (Apple Inc. build 5646)] on darwinType "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 -ltotal 16-rw-r--r--1 rytisrytis 147 1Apr 14:36 archive.tar.bz2-rw-r--r--1 rytisrytis 261 Apr 14:35 test.txt$ tar jtvf archive.tar.bz2 -rw-r--r--0 rytisrytis 261 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,在包的选择上更加保守,可能不提供预编译的包。对于这些发行版,最好下载源代码包并从源代码构建库。你可以在 sourceforge.net/projects/nu… 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 amean value of the array5.2000000000000002>>> a1.std()# calculate the standard deviation2.7129319932501073>>> a1.var()# calculate the variance7.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 row5>>>
多维数组在每一行中必须有相同数量的条目,因为它们实际上是矩阵元素。只要数组中有足够的元素,就可以随时更改数组的形状:
>>> a= np.arange(16)>>> aarray([ 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 iin a: print i...[1 23][4 56][7 89]>>> # the following flattens the array>>> for iin a.flat: print i,...1 23 45 67 89>>> # returns atuple with the element "coordinates" and the element itself>>> for iin 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 3digits 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 avertical cut at the third column?... a[:,2]array([3, 8])>>>
最后,让我们看看一些高级的数组索引技术,我们将在本章后面用到。您已经熟悉了标准的 Python 数组索引,您可以在其中指定想要查看的特定项或一系列值。NumPy 数组对象也可以接受其他数组作为索引:
>>> a= np.arange(-10, 1)>>> aarray([-10,-9,-8,-7,-6,-5,-4,-3,-2,-1, 0])>>> i= np.arange(0, 9, 2)>>> iarray([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)>>> aarray([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.])>>>
以下是两个函数,可用于查找数组中的最大值和最小值:
>>> aarray([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.)>>> aarray([ 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 函数调用来执行所有这些计算:
>>> aarray([ 1.,4.,3.,5.,6.,2.])>>> np.var(a)2.9166666666666665>>>
我们确定这个数字表示距离平均值的平均平方距离,但是因为这个值是平方的,所以有点误导。这是因为它不是实际的距离,而是强调的距离值。我们现在需要获得这个值的平方根,以使它与其余的值一致。结果值代表数据集的标准差 。2.9 的平方根大约等于 1.7。这意味着数组中的大多数元素距离平均值不超过 1.7,在我们的例子中是 3.5。任何超出此范围的元素都是正常期望值的例外。图 11-1 说明了这个概念。在图表中,六个元素中有四个在标准偏差范围内,两个读数在范围之外。请记住,由于计算标准偏差的方式,数据集中总会有一些值距离平均值的距离大于集合的标准偏差。
图 11-1 。数据集的均值和标准差
NumPy 库提供了一个方便的函数来计算任何数组的标准偏差值:
>>> a= np.array([1., 4., 3., 5., 6.,2.])>>> aarray([ 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 标记处的垂直线表示数据集中所有数字的平均值。从这条线,我们有三个标准差带:一个西格玛值距离,两个西格玛值距离,和三个西格玛值距离。如您所见,这直观地证明了几乎所有数据都包含在平均值的三个标准偏差距离内。
图 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)>>> harray([ 0.00238784,0.02268444,0.12416748,0.30444912,0.37966596,0.26146807,0.08834994,0.01074526])>>> barray([-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 定义了直线在垂直轴上的位置。
如您所见,一次多项式由两个常数定义:斜率和位置。在我们的函数中,这些分别是常数 a 和 b 。现在的问题是如何从任何一组看似随机的数据中找到这些常数。实际计算过程有些冗长,这里就不赘述了。幸运的是,NumPy 提供了一个函数,该函数接受数据点的两个坐标数组( x 和 y ),并返回多项式常数作为结果。您还可以指定所需多项式函数的次数,但我们将坚持一次多项式计算。以下示例生成一些随机数据,然后人为地在序列中引入一个斜率,然后计算得到的一次多项式常数:
>>> x= np.arange(100)>>> y= np.random.normal(4., 0.9, 100)>>> for iin range(100):...y[i] =y[i] +i/40>>>a, b= np.polyfit(x, y, 1)
注意你可以在en.wikipedia.org/wiki/Polynomial的维基百科页面上找到更多关于多项式函数以及常数是如何推导出来的细节。
在图 11-3 中,你可以看到原始数据(显示为点)以及最佳拟合或趋势线。尽管有些值比数据集的其余部分大得多,但实际趋势并不像您预期的那样陡峭。趋势函数常数也为我们提供了未来将要发生的事情的一个很好的指示。例如,在观察了 100 个值之后,我们确定了这个数据集的多项式函数是 y = 0.024 * x + 3.7 。因此,在一定的置信度下,我们可以假设再测量 100 次后的平均值为 0.024 * 200 + 3.7 = 8.5。如果我们假设这是我们系统的平均负载读数,我们将清楚地知道在不久的将来平均负载会是多少。这是一种可以用于容量规划的强大方法。
图 11-3 。随机数据的最佳拟合趋势线
向文件读写数据
在某些情况下,您可能需要将数据写入文件,然后在以后读入数据以供进一步处理。NumPy 为此提供了几个输入/输出过程。在下面的示例中,数据存储在文本文件中,逗号字符用作分隔符。
>>> a= np.arange(16).reshape(4,4)>>> aarray([[ 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=',')>>> barray([[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 图并向其添加注释。
注关于使用 matplotlib 的更多详细信息,请参见 Shai Vaingast 的开始 Python 可视化(Apress,2014)。
安装 matplotlib
通常,安装 matplotlib 有两种选择:使用 Python 包索引(PyPI)安装程序(pip)工具或从源代码构建包。下面是从 PyPI 安装库的命令:
$ sudo pip install matplotlib
注意如果使用画中画工具,一定要检查安装的是哪个版本。我遇到过这样的情况,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 发行版文档以了解具体的细节,例如安装过程和软件包名称。
安装完库后,可以通过发出以下命令来检查它是否正常工作:
$ pythonPython 2.6.2 (r262:71600, Jan 25 2010, 18:46:45)[GCC 4.4.2 20091222 (Red Hat 4.4.2-20)] on linux2Type "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 pltimport numpy as npfig =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 所示。您将能够使用窗口功能,如平移和缩放,以及保存和打印文件。
图 11-4 。一个 matplotlib 窗口实例的例子
更改绘图图元的外观
plot()函数更完整的语法是包含两个坐标数组, x 和 y ,并指定绘图格式,例如绘图颜色和样式。下面的代码绘制了与清单 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')# goodax.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 pltimport numpy as npfig =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 中看到结果图。
图 11-5 。在笛卡尔坐标和极坐标上绘制条形
使用文本字符串
您可能已经注意到,到目前为止,图表上显示的文本非常少。matplotlib 方便地将值添加到两个轴上,但这是它所能猜测的。添加像轴注释、图形标题和各种标签这样的文本是我们的责任。幸运的是,Axes 对象有多个辅助函数,可以帮助我们将文本添加到绘图中。您可以按如下方式放置文本:
- 向 x 和 y 轴添加文本。
- 添加情节标题。
- 在图面上任意放置文本。
- 在图上标注具体的点。
两个轴的标题和注释是在轴(或子情节)初始化期间通过使用适当的关键字参数来设置的。可以使用 text()方法并指定坐标和文本字符串来放置任意文本字符串。类似地,可以使用 annotate()函数创建注释。annotate()函数接受关键字参数,这些参数指示文本应该放置的位置(xytext 参数)和箭头应该指向的位置(xy 参数)。可选的 arrowprops 字典允许您广泛地配置注释箭头的外观,但是最简单的配置是 arrowstyle 字典项,您可以使用它来设置箭头的方向。
清单 11-3 展示了添加所有四种类型的文本。
清单 11-3 。向图表中添加文本
import matplotlib.pyplot as pltimport numpy as npfig =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.6ax.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 生成的图。
图 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 pythonimport matplotlibmatplotlib.use('Agg')import matplotlib.pyplot as pltimport numpy as npfig =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_nameself.conn =sqlite3.connect(self.db_name)self.hosts =[]self._get_all_hosts()def _get_all_hosts(self):for hin 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, hostprobeWHEREprobe.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);1sqlite> select coalesce(NULL, 2);2sqlite> select coalesce(NULL, NULL);sqlite>
绘制时间刻度图
现在我们有了进一步数据处理所需的所有信息:主机和相关的主机探测器。有许多不同的方法来表示我们收集的统计信息。在本例中,我们将按照两个参数之一对信息进行排序:探测器名称和时间刻度。为了简化实现,我们将使用预定义的可用时间表列表:1 天、7 天和 30 天。
我发现如果我将正在开发的网站结构可视化,开发模板和相应的代码会更容易。图 11-7 展示了我们网站的结构,以及样本 HTML 文件名(id 将被替换为实际值)和相应的 Jinja2 模板。
图 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 =Nonesa_err= Nonesampling_rate =self.conn.execute("""SELECT probeinterval FROM probingscheduleWHERE hostprobe_id=?""",(probe[0],)).fetchone()[0]records_to_read =int(24 *60 *scale /sampling_rate)query_str ="""SELECT count(*) FROM (SELECT probe_value FROM probereadingWHERE 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_readif 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_readreturn (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_valueFROM probereading WHERE hostprobe_id=? LIMIT ?)WHERE probe_value > ?
这实际上是两个嵌套的查询。SQLite3 引擎将执行的第一个查询是内部 SELECT 语句,它为指定的主机探测器从列表中选择最后的 x 记录。外部 SELECT 语句计算列表中 probe_value 高于指定阈值的记录数。您可能会注意到,在内部 SELECT 语句中,我们没有对列表进行任何排序。那么我们有多确定我们真的会得到最后的记录,而不是从数据库中随机或半随机选择的记录呢?在 SQLite 中,每一行都有一个关联的 ROWID 值,所有行都按其行 ID 排序。如果我们不在 SELECT 语句中指定顺序,它将自动按行 id 排序。因为我们只是将行添加到数据库中,所以我们所有的行 id 都在序列中。因此,一个简单的 LIMIT SQL 语句保证我们将得到最后选中的行。
注意你可以在官方 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 probereadingWHERE 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 *stderror_val =mean +4 *stddata_y =np.array(val_array)data_x =np.arange(len(data_y))data_time =[dateutil.parser.parse(s) for sin time_array]data_xtime =matplotlib.dates.date2num(data_time)a, b= np.polyfit(data_x, data_y, 1)matplotlib.rcParams['font.size'] =10fig =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 ONsqlite> SELECT timestamp, probe_value FROM probereading WHERE hostprobe_id=1 LIMIT 5;2009-12-16T21:30:20|0.02009-12-16T21:31:20|0.0004314702946323922009-12-16T21:32:20|0.0003117480856512052009-12-16T21:33:20|0.0007779943314400242009-12-16T21:34:20|0.00475251893721452CPU Time: user 0.000139 sys 0.000072sqlite> SELECT timestamp, probe_value FROM probereading WHERE hostprobe_id=1 ORDER BY timestamp LIMIT 5;2009-12-16T21:30:20|0.02009-12-16T21:31:20|0.0004314702946323922009-12-16T21:32:20|0.0003117480856512052009-12-16T21:33:20|0.0007779943314400242009-12-16T21:34:20|0.00475251893721452CPU Time: user 0.192693 sys 0.018909sqlite> CREATE INDEX idx_ts ON probereading (timestamp);CPU Time: user 0.849272 sys 0.105697sqlite> SELECT timestamp, probe_value FROM probereading WHERE hostprobe_id=1 ORDER BY timestamp LIMIT 5;2009-12-16T21:30:20|0.02009-12-16T21:31:20|0.0004314702946323922009-12-16T21:32:20|0.0003117480856512052009-12-16T21:33:20|0.0007779943314400242009-12-16T21:34:20|0.00475251893721452CPU Time: user 0.000169 sys 0.000136sqlite>
我们正在绘制的数据是时间敏感的,因此根据对应的时间戳值绘制在轴上更有意义。matplotlib 有一个绘制定时数据的函数叫做 time_plot()。它的语法与 plot()函数的语法相同,但是数据参数(要么只有 X,要么同时有 X 和 Y 数据)必须是浮点数,表示自 0001-01-01 以来的天数,小数部分定义小时、分钟和秒。为此,我们需要执行两个操作:将文本字符串转换为 Python datetime 类型,然后将其转换为浮点数。这是通过下面这段代码完成的:
import dateutil...data_time =[dateutil.parser.parse(s) for sin time_array]data_xtime =matplotlib.dates.date2num(data_time)
如果可能,我们还绘制了警告和错误阈值线。每个图标题包括数据集的统计参数以及警告和错误阈值的建议值。图 11-8 显示了一个样本图。
图 11-8 。性能数据图
摘要
在这一章中,我们看了使用 NumPy 库的基本统计分析。该库中的统计函数可以让您更好地了解正在监视的系统,尤其是在您记住以下要点的情况下:
- 大多数现实生活中的数据,虽然看起来是随机的,但遵循正态分布模式。
- 标准差告诉您每个值平均离数据集的平均值有多远。
- 您可以使用标准差来确定警告和错误阈值的最佳值。
- 一次多项式函数参数可用于识别数据集的总体趋势。
- 使用数据趋势函数,您可以预测系统的未来行为。