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

62 阅读1小时+

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

原文:Pro Python System Administration

协议:CC BY-NC-SA 4.0

五、在 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(在我们的例子中,它是 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";"
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 上做了一个描述性的介绍,比较了几种不同的插件机制。你可以在 blip.tv/file/194930… 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] forin 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] forin 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 官方文档。你可以在 httpd.apache.org/docs/2.2/lo…找到。

与 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 公司制作,该公司提供数据库供个人(免费)和商业(付费)使用。你可以在 maxmind.com/app/ip-loca… 找到更多关于 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 = []
            forin 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
>>> forin 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 forin 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 forin 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()
>>> forin 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'):
    forin 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'):
    forin 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'):
            forin 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

        forin 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 = {}
    forin 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}
>>> forin d:
...  print i
...
a
c
b
d
>>> sorted(d)
['a', 'b', 'c', 'd']
>>>

显然这不是你真正想要的;您的结果中需要这两个值。字典有一个内置的方法,它将键/值对作为可迭代对象返回—iteritems()。如果您使用这个,您会得到稍微好一点的结果,显示每一对的键和值,但是它们仍然是根据键值排序的,这也不是您想要的:

>>> forin 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)]
>>>

类似地,我生成并打印例外组的列表。我添加一个统计计算,只是为了显示每个组的相对大小:

forin sorted(categories.iteritems(), key=operator.itemgetter(1), reverse=True):
    print "%8s (%6.2f%%) : %s" % (i[1], 100float(i[1]) / float(self.count), i[0] )

摘要

在这一章中,我详细解释了开源工具 Exctractor 是如何编写的,以及每个功能部分是做什么的。本章展示了如何应用 Python 知识构建一个相对复杂的命令行工具来分析大型文本文件。尽管 Python 本身不是一种文本处理语言,但它可以成功地用于这个目的。需要记住的要点:

  • 从定义一个问题和你希望你的应用实现什么开始。
  • 分析您将使用的数据结构,并根据这些信息做出设计决策。
  • 如果您正在处理大型数据集,请尝试通过使用生成器(动态生成值的 Python 函数)来最小化所需的内存量。
  • 如果您需要读取和搜索大型数据文件中的信息,请使用生成器构造一次读取一行。
  • Python 内置了对读取和写入压缩文件(如 bzip2 档案)的支持。
  • 保持配置的结构化格式,比如 XML,特别是当它包含很多条目的时候。