首页 > 代码库 > 从Apache的日志文件收集和提供统计数据(一个Python插件架构的简单实现)
从Apache的日志文件收集和提供统计数据(一个Python插件架构的简单实现)
从Apache的日志文件收集和提供统计数据
这一章我们将介绍基于插件程序的架构和实现。作为例子,我们将构建一个分析Apache服务器log文件的框架。这一次我们不再使用单片机的方式来创建,而是改为采用模块化的方式。一旦我们有了一个基本框架,我们就可以为它创建一个插件。这个插件可以基于请求者的地理位置执行分析。
程序的结构和功能
在数据维护和统计收集领域,很难有一个单一的应用程序可以适合多个用户的需求。让我们以分析Apache的web服务器日志文件为例。web服务器接受到的每一个请求都被记录在日志文件中。在日志文件的每一行都记录着一堆不同的数据,以及请求进来的时间。
让我们假想你被要求开发一个程序,分析那些日志文件,并且生成一个报告。这是那些对统计信息感兴趣用户通常情况下的要求。显然,目前你没有多少信息可以帮助你处理这个请求,所以你要求知道更多,比如究竟用户想要在报告中看到什么内容。因此假设的用户将更多的参与到设计阶段,告诉你他们想看到的是一个特定文件的下载总量。好,这很容易做到。但是,之后你又拿到了另一个用户的请求,他想要知道的确是网站每小时的点击量。于是,你又把它写到脚本中。之后又有一个用户要求关联分析一天中的一个时间的浏览器类型。这样的例子举不胜举。即使是你为一个特定的组织编写工具,需求也是呈多样化。特别一直处于需求收集阶段的话,几乎不太可能捕捉得到一定的需求。所以,在这样的情况下你应当怎样做呢?
通过一些专门提取和处理不同信息的模块,可以对其自身进行扩展的通用应用程序难道不是一个很好的解决办法吗?他的每一个模块负责一个特定的计算并生成报告。这些模块根据需要可以被添加或者删除,而不会影响到系统的其他模块的功能。更重要的是,不会对主程序带来任何变化。这种模块化的结构通常被称作插件架构(plug-in architecture)。
插件指的是软件中的一个小程序,它可以扩展主程序的功能。这种技术非常受欢迎,并且被用在许许多多不同的应用程序中。一个很好的例子就是浏览器。市场上绝大多数的浏览器都是支持插件的。一个网页可以嵌入一段Adobe的Flash影片,但是浏览器本身不知道(也不需要知道)如何处理这个类型的文件。所以,它会查找一个有能力处理和显示Flash影片的插件。如果找到这样的插件,它就将文件对象传给插件进行处理。如果找不到,就简单地不能显示给最终的用户。缺少合适的插件并不影响网页被显示出来。
我们将用这样的方式创建一个分析Apache日志文件的应用程序。让我们从完成特定统计数据分析任务的应用程序的需求开始。
应用程序需求
在应用程序中,我们需要实现的需求主要有两个:
- 主应用程序负责Apache日志文件的解析,并且提取每一行日志的字段。由于日志每一行的格式在不同的web服务器安装下会有不同,因此主程序需要能够根据日志文件格式进行配置。
- 程序应当能够发现已经安装的插件,并且将提取的日志字段发送到正确的插件模块进行进一步的处理。添加新的插件应对已存在的模块和主程序的功能不产生任何影响。
应用程序设计
需求文件意味着程序应当被被分成以下两个部分:
- 主应用程序:通过命令行参数,主程序得到一个文件目录的列表,并对其中日志文件进行解析。在一个时间,每个日志文件只有一行在被处理。程序不保证日志文件按时间顺序进行处理。每个日志行都是以单词为界,而字段的分隔符是空格字符。在一些字段中也可能会有空格字符存在,这要求这些字段必须用双引号包起来。如Apache的文档所描述的,为了便于使用,字段由符合相应日志格式的字段代码来描述。
- 插件管理组件:插件管理器负责发现和注册可用的插件模块。只有一些特殊的Python类将被当做插件对待。每个插件只向它感兴趣的日志段进行公开。当主程序在对日志文件进行解析时,它会检查已订阅插件的表格,并把相关信息传递给一些有关的插件。
下面,让我们看一下如何在Python中实现一个插件框架。
Python中插件框架的实现
当在python中实现一个插件框架时,有好消息,同时也有坏消息。坏消息是目前没有一个实现插件架构的标准方式。有许多不同的技术,也同时有商业和开源的产品可以使用。但是,它们解决问题的方式都不相同。可能在某个方面很好的,在其它方面却有所欠缺。为实现这个架构,你所选择的方式很大程度上取决于你想要达到什么。
好消息是还没有实现插件框架的事实标准,我们可以自己写一个。通过编写实现代码,你可以学到一些Pyhon语言和编程技术的新东西,比如类型检查,鸭子类型,以及动态模块加载。
在我们进入技术细节之前,让我们先弄清插件到底是什么,以及它与主程序或者宿主程序的关系。
插件框架的机制
无论是日志解析引擎的日志,一个浏览器的HTML文件,或者其它类型的文件,主程序都会对它接受到的数据进行处理。它的工作也完全不会受已存在插件及其功能的影响。不过,主程序始终为插件模块提供着服务。
在日志处理程序中,主程序唯一的职责就是从文件读取数据,识别日志的格式,将数据转换称合适的数据结果。这正是它向插件提供的服务。主程序不关心它生成的数据是否会被它的某一个插件使用,也不关心被如何使用。
插件模块很大程度上依赖于主应用程序,让我们以一个计算请求数量的插件为例。在取得数据以前,插件是没有办法做任何计数。所以,离开主程序插件是没有什么用的。
你可能疑惑为什么需要如此在意这样的分离。为什么插件自己不能读取数据,以及对数据做任何她需要的做的处理?如我们所讨论过的,这样可能存在让许多不同的应用程序对相同的数据做不同的计算。让所有程序中的都有一个模块具有对相同的数据进行读取和解析功能,从开发的角度来说是效率低下的。这将耗费时间一次又一次的去开发相同的处理。
显然,这只是一个极其简单的例子。通常来说,最终的用户并不会注意到主程序和插件之间的这种分离。用户体验到的应用程序是程序与插件合并的结果。
让我在思考一下web浏览器的例子。HTML页面由浏览器引擎渲染并呈现给用户。插件模块则对网页中的多钟组件进行渲染。例如,Adobe的Flash影片又Flash插件进行渲染,Windows播放器文件则由Windows Media插件模块进行渲染。用户只是看到这样的结果:经过渲染的网页。通过安装新的插件可以很轻松的扩展应用程序的功能。在部署了新的插件之后,用户就可以访问在安装插件之前不能够正常显示或访问的网站。
基于插件程序的另一个很好例子是Eclipse项目(http://eclipse.org/)。它一开始只是作为Java的一个开发环境。但随后却成长为一个的平台,可以支持多种编程语言,集成了多个版本的控制系统,提供建模和软件报告。这一切都感谢它的插件架构。基础应用程序做不会做太多,但是你可以对它进行扩展,用你所需要的适当插件对程序进行量身定制。因此,同样的"应用程序"可能会做着完全不同的事情。对我来说,它是一个Python的开发平台。对其他某个人来说,它可以使一个UML建模工具。
接口模型
正如你可能已经想到的,主应用程序和插件模块通常是极非常散耦合的实体。因此,两者之间的交互需要定义一个协议。通常情况,主程序公开一些具备完善文档的服务接口,比如一些函数名。当需要从主程序获取一些东西的时候,插件的方法则对这些接口进行调用。
类似的,插件也公开接口。因此,主应用程序可以向它们发送数据,或者通知插件正在发生一些事件。这正是事情变得更复杂的地方,通常情况下,插件模块实现的功能可能不会被主应用程序意识到。因此,插件需要宣布它们具有的功能,比如有能力显示Flash影片文件。功能的类别通常会与模块的方法名相联系。所以,主应用程序知道哪个方法实现这类功能。
我们以一个简化的浏览器模型为例来进行考虑。我们有一个基本应用程序,它接受HTML页面,并且会下载所有链接的资源。每个资源都有一个MIME类型与之联系,Flash对象的类型是application/x-shockwave-flash。当浏览器遇到这样的对象时,它会查看插件注册表,并且搜索一个声称有能力处理这个类型文件的插件。一旦插件及其方法名被发现,主应用程序就对插件方法进行调用,并将文件对象传递给它。
插件注册和发现
那么主应用程序所检查的插件注册表到底是什么呢?简单一点说,它其实就是可以被主应用程序找到和加载的插件模块的表单。这个表单通常包含对象实例、功能以及实现这些功能的方法。注册表是一个存储插件实例的中心。所以,主应用程序在允许时可以找到已注册的插件。
插件注册表是在发现插件的过程中创建的。发现过程在不同实现下会有不同,但一般包括发现适当的程序文件以及将其加载到内存中。在主程序处理插件管理的事务中,通常包含一个单独的处理过程,比如发现、注册、控制。表6-1显示的是一个对所有这些组件以及他们之间关系的概览。
Figure 6-1典型的插件架构
创建插件框架
如我所说的,在Python中实现基于插件架构的方式有许多种。在这里,我采用其中一种最简单的方式。它足够灵活,可以应对大多数的小型应用程序的需求。
— 提示:在2009年PyCon上,André Roberge博士做过一个将许多不同的插件机制进行比较的报告。你可以在网络上查找一下,报告的标题是“Plugins and monkeypatching: increasing flexibility,
dealing with inflexibility”。如果你确定需要一个更复杂的实现的话,可以看一看Zope(http://zope.org/)所提供的Grok(http://grok.zope.org/),以及Envisage(http://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.__subclass__()
[<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>]
>>>
所以,发现和注册的过程是按下面的步骤进行:
- 所有的插件类都从同一个基类继承,这个基类被看作是插件管理器。
- 插件管理器导入一个或者多个包含插件类定义的模块。
- 插件管理器调用基类方法
__subclass__()
,发现所有插件类(这些类都已通过模块导入)。 - 插件管理器创建实例。
我们现在有了一些问题需要解决。首先,插件类需要被存放在一个独立的位置,最好是单独的文件中。这样你可以放心的安装新的插件,以及移除过时的插件,而不必担心会不小心覆盖应用程序文件。所以,我们需要一种机制来导入任意的包含插件类定义的Python模块。你可以使用Python内建的__import__
,在运行时以模块名来加载一个模块。不过,模块文件需要放在Python系统的搜索路径下。
让我们就从管理器类的初始化开始。我们将会允许主应用程序传递任何的可选参数给插件对象。这样,插件对象就可以执行任何它们需要的运行时初始化。我们完全不知道这些参数是什么,或者有没有。因此,我们将只传递关键字参数来,来代替列表结构中的实际参数。管理器的__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
方法是一样的,都意味着试图加载的模块需要位于Python的一个搜索路径之下。显然,并非一定如此。因此,我们需要将包含插件模块的目录添加到系统路径。那么,我们可以通过向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()
定义插件模块
到目前为止,我们需要插件类满足的需求只有两个:每个类都必须从基类Plugin
继承,以及__init__
方法必须接受关键字参数。在初始化的时候,插件类可以选择完全忽略穿件给它了什么,但是它仍必须能够接受这些参数。否则,在主应用程序向其传递没有预计接受的参数时,我们将得到一个非法参数列表的异常。
插件模块的骨架大致如下:
#!/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 or /etc/httpd/conf/httpd.conf,这取决于你的Linux系统的部署情况。配置的例子如下:
LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" combined
这个配置行被分成了三个部分。最先是指令名。第二部分是定义日志行结构的格式字符串。我们将很快回过头来看格式字符串的定义。最后一部分是记录格式的名字。
只要你喜欢,你可以定义多个不同的记录行格式。随后,将它们分配给日志文件的定义。例如,你可以给一个虚拟主机定义区段添加如下的指令,告诉Apache Web服务器将以combined日志格式指令所描述的格式的日志行,记录到 logs/access.log 日志文件中:
CustomLog logs/access.log combined
你可以有多个CustomLog 指令,每一个有不同的文件名和格式指令。
提示 参考Apache的官方文档了解更多有关日志文件的信息。你可以在以下的网址找到:http://httpd.apache.org/docs/2.2/logs.html
LogFormat指令配置语句所使用的格式字符串包含一个或者多个以%符号开头指令。当日志行被记录到日志文件的时候,这些指令将会被详细的值替换。表格6-1列出了最常使用的一些指令。
日志文件读取器
正如你所见,日志格式几乎都依与在Apache配置中定义的格式。我们需要适应格式之间的这种差异。为了更好的与插件模块进行交流,我们将从日志行中抽取的值映射到一个数据结果中,并将其传递给插件代码进行处理。
首先,我们需要将Apache日志格式指令映射到一些更具描述性的字符串中,指令可以被当作字典的key。下面是我们将要用到的映射表:
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])
日志字符串一般是以空格字符分隔的简单格式。如果一个字段的值包含空格字符,则用引号括起来,如下面的日志行的样本:
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)"
Note 记住"\"符号指明一行的内容已折叠。在真实的日志文件中,这些内容只有单独的一行。
我们将使用内建的Python模块来解析以逗号分隔值的CSV(comma-separated values)格式文件。尽管文件的格式意味着这些值是以逗号作为分隔。但这个库还是非常灵活,允许你指定任何字符作为分隔符。此外,你还可以指定引用符号。在我们的案例中,分隔符是空格,引用符号时双引号(用来包裹请求和用户平台字符串)。
我想你已经注意到了这里有一个问题。时间字段包含了一个空格,但没有用双引号包起来,而是采用了方括符。所以我们要使用正则表达式将所有出现的方括符替换为双引号。匹配方括符的正则表达式定义在类的构造函数中。我们将在后面的代码中使用这个预编译好的正则表达式:
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
最后,我们需要从日志行提取所有读入的字段,并创建字典对象。字段的键是我们早前创建的映射表中的指令名,值是从日志行中抽取的字段。这听起来是一个复杂的任务,但实际不是,因为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
我们现在可以创建一个生成器类的实例,并且对指定目录下的所有文件的所有日志行进行迭代处理。
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
插件方法与调用机制
现在我们标记了所有的插件,在理论上,我们需要知道那些方法在哪些插件对象是可以使用的。不过,达到这个目的的途径不是很灵活。在添加标记之后,插件的方法得到优化,并且不必要的插件不会被调用。但是仍然存在这样的情况,当一个插件声称他对一些类型的调用感兴趣时,它却没有实现与主应用程序的这些关键字相关联的函数方法。
鉴于主应用程序和插件软件是非常松散地耦合,并且他们通常是由完全不同的组织开发。因此,实际上两者的开发不可能同步。比如,假设一个主应用程序设计为在所有的插件上调用对关键字foobar感兴趣的方法function_A()。后来,主机应用程序作出修改,将对与相同关键字标记在一起的两个方法 function_A 和 function_B 进行调用。但是,一些插件可能不再做维护,或者他们只是简单地不对实现新的方法感兴趣,他们觉得只实现一个方法已经足够达到他们的目标了。
这看起来是一个问题,但实际不是。主程序在调用方法的时候没有检查方法是否可用。如果插件实现了这个方法,则执行它。如果没有或未定义,那好,我们就忽略这个异常。这个技术被称为鸭子类型
(duck typing)。
我们将给管理器函数一个下面的新方法,它负责调用所有的插件方法。主程序将用想要运行的插件方法的名字来调用这个方法,也可以选择传递使用关键字参数列表。如果有关键字的定义,这个调用将被会只调度给与列表中与一个或多个关键字相标记的插件。
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‘)
什么是鸭子类型(duck typing)
“鸭子类型”这个术语引用自James Whitcomb Riley的话:“当我看见一只鸟的时候,如果它走起来像只鸭子,游起来像只鸭子,并且叫起来像只鸭子,那么我就说这只鸟是只鸭子”
在面向对象编程语言中,鸭子类型意味着一个对象的行为是由一组有效的方法或属性来决定,而不是继承关系。换句话说,我们并不在意一个对象所属的类的类型,而是我们感兴趣的方法和属性是否在存在和有效。因此,鸭子类型不依赖于对象类型的测试。
当你需要一个对象中的什么东西的时候,你就简单问它要。如果对象不知道,那么会产生一个异常。这就意味着这个对象不知道如何“叫”,因此他不是只鸭子。这种”测试看看到底发生什么“的方法有的时候被称为EAFP原则(Easier to Ask for Forgiveness than Permission)。下面的代码中可以更好的说明:
class Cow():
def moo(self):
print ‘moo..‘
class Duck():
def quack(self):
print ‘quack!‘
animal1 = Cow()
animal2 = Duck()
for animal in [animal1, animal2]:
if hasattr(animal, ‘quack‘):
animal.quack()
else:
print animal, ‘cannot quack‘
>>><__main__.Cow instance at 0x100491a28> cannot quack
>>>quack!
for animal in [animal1, animal2]:
try:
animal.quack()
except AttributeError:
print animal, ‘cannot quack‘
>>><__main__.Cow instance at 0x100491a28> cannot quack
>>>quack!
#在一个迭代中,在调用方法之前我们对方法的有效性进行显式地检查(我们请求许可)。
#在第二个迭代中,我们调用方法时没有检查是否有效。\
如果方法真的不存在,我们就可以捕捉到一个异常(我们请求原谅),并进行相应的处理。
插件模块
现在我们处于开始编写插件模块,并使用脚本解析Apache Web服务器的日志文件。在这一节中,我们将创建一个脚本,对所有请求进行计数,并且按它们源自的国家进行排序。我们将使用Python的GeoIP库来完成IP到国家名字的映射。
注意 GeoIP的数据是由MaxMind公司制造,它提供的数据库对个人是免费,商用则需付费。你可以在 http://maxmind.com/app/ip-location找到更多MaxMind公司的产品和服务有关的信息。
GeoIP数据的目的是提供根据IP地址定位的地理位置信息(比如国家、城市,以及坐标),它适用于各种目的。比如,它使你可以提供本地化的广告服务,即根据用户的位置显示广告。
安装需要的库
GeoIP数据库是由C语言编写,但也有可用的Python绑定。库中的包在大多数的Linux平台上都是可以使用的。比如,在一个Fedora系统,可以执行下面的命令安装库:
$ sudo yum install GeoIP GeoIP-python
这将安装带有帮助工具和Python绑定的C语言库。程序包中会包含初始数据库,它含有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
# 数据每次被访问的时候都要从硬盘读取
# 这是最慢的访问方法
gi = GeoIP.new(GeoIP.GEOIP_STANDARD)
# 数据被缓存在内存中
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
Download from Wow! eBook <www.wowebook.com>
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
#coding=utf-8
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)
将这段代码以plugingeoiplookup.py保存于plugins/目录下(实际是,任何文件名以"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
总结
在这一章中,我们用Python编写了一个简单但可扩展的和强大的插件框架。并且我们实现了一个简单的Apache Web服务器日志解析器,以及一个对请求进行计数并根据访问来源国家进行排序的插件。
需要记住的知识点:
- 插件应让主程序从其扩展中解耦出来-插件模块
- 插件架构通常由三部分组件构成:主应用程序、插件框架、插件模块。
- 插件框架负责查找和注册插件模块。
- 任何Python类都可以找到从它继承的其他类,并且这个机制可以被用类的查找和分组。这个类的特性还可以被用于找到所有的插件类。
- 你可以使用MaxMind的GeoIP数据库查找某个IP地址对应的物理地址。
翻译自《Pro Python System Administration》,Rytis Sileika. Apresss. 2010。
代码下载:http://pan.baidu.com/s/1eQ3pz1o
从Apache的日志文件收集和提供统计数据(一个Python插件架构的简单实现)