Skip to content

Latest commit

 

History

History
1011 lines (682 loc) · 47 KB

File metadata and controls

1011 lines (682 loc) · 47 KB

四、将模块用于实际编程

在本章中,我们将使用模块化编程技术来实现一个有用的实际系统。特别是,我们将:

  • 设计并实现用于生成图表的 Python 包
  • 了解不断变化的需求如何导致成功系统的崩溃
  • 探索模块化编程技术可以帮助您以最佳方式处理不断变化的需求的方法
  • 了解不断变化的需求可能是好的,因为它们让您有机会重新思考您的程序,从而产生更健壮和设计良好的代码

让我们先看看我们将要实现的 Python 图表生成包,我们将其称为Charter

引入特许权

Charter 将是一个用于生成图表的 Python 库。开发人员将能够使用 Charter 将原始数字转换为美观的线条图和条形图,然后将其保存为图像文件。以下是 Charter library 能够生成的图表类型示例:

Introducing Charter

Charter library 将支持线条图和条形图。虽然我们将通过只支持两种类型的图表来保持 Charter 相对简单,但该软件包的设计将使您可以轻松添加更多图表类型和其他图表选项(如果您愿意)。

设计章程

当查看上一节所示的图表时,您可以识别所有类型图表所使用的许多标准元素。这些元素包括标题、xy轴以及一个或多个数据系列:

Designing Charter

要使用 Charter 软件包,程序员将创建一个新图表,并设置标题、xy轴以及要显示的数据系列。然后,程序员会要求 Charter 生成图表,并将结果保存为磁盘上的图像文件。通过以这种方式组合和配置各种元素,程序员可以创建他们希望生成的任何图表。

更复杂的图表库将允许添加其他元素,如右侧的y轴、轴标签、图例和多个重叠数据系列。然而,对于 Charter,我们希望代码保持简单,因此我们将忽略这些更复杂的元素。

让我们仔细看看程序员是如何与特许库交互的,然后开始思考它是如何实现的。

我们希望程序员能够通过导入charter包,然后调用各种函数来处理图表,从而与 Charter 进行交互。例如:

import charter
chart = charter.new_chart()

要设置图表的标题,程序员将调用set_title()函数:

charter.set_title(chart, "Wild Parrot Deaths per Year")

提示

请注意,我们的 Charter 库不使用面向对象编程技术。使用面向对象技术,图表标题将使用一条语句(如chart.set_title("Wild Parrot Deaths per Year"))进行设置。然而,面向对象技术超出了本书的范围,因此我们将在 Charter 库中使用更简单的过程编程风格。

要设置图表的xy轴,程序员必须提供足够的信息,以便 Charter 能够生成图表并显示这些轴。为了理解这是如何工作的,让我们考虑一下轴的外观。

对于某些图表,轴可能表示一系列值:

Designing Charter

在这种情况下,将通过计算点沿轴的位置来显示数据点。例如,x=35的数据点将显示在该轴上3040点的中间。

我们将这种类型的轴称为 a连续轴。请注意,对于这种类型的轴,标签是如何定位在记号标记下方的。将其与以下轴进行比较,该轴分为多个离散的“桶”:

Designing Charter

在这种情况下,每个数据点对应一个桶,标签将出现在刻度线之间的空间中。这种类型的轴称为离散轴

请注意,对于连续轴,标签显示在记号标记上,而对于离散轴,标签显示在记号标记之间。此外,离散轴的值可以是任何值(在本例中为月份名称),而连续轴的值必须是数字。

对于 Charter library,我们将使x轴成为离散轴,而y轴将是连续轴。理论上,您可以为xy轴使用任何一种类型的轴,但我们保持这种简单性,以使库更易于实现。

了解了这一点,我们现在可以了解如何在创建图表时定义各种轴。

要定义 x 轴,程序员将调用带有标签列表的set_x_axis()函数,用于离散轴内的每个铲斗:

charter.set_x_axis(chart,
                   ["2009", "2010", "2011", "2012", "2013",
                    "2014", "2015"])

列表中的每个条目对应于轴内的单个铲斗。

对于y轴,我们需要定义将显示的值的范围以及如何标记这些值。为此,我们需要为set_y_axis()函数提供最小值、最大值和标签值:

charter.set_y_axis(chart, minimum=0, maximum=700,
                   labels=[0, 100, 200, 300, 400, 500, 600, 700])

为了简单起见,我们假设y轴使用线性刻度。我们可能支持其他类型的缩放,例如实现对数轴,但我们将忽略这一点,因为这将使 Charter 库更加复杂。

现在我们知道了如何定义轴,我们可以看看如何指定数据系列。首先,我们需要程序员告诉 Charter 要显示的数据系列类型:

charter.set_series_type(chart, "bar")

如前所述,我们将支持直线图和条形图。

程序员需要指定数据系列的内容。由于我们的x轴是离散的,而y轴是连续的,因此我们可以将一个数据系列定义为y轴值的列表,每个离散的x轴值对应一个:

charter.set_series(chart, [250, 270, 510, 420, 680, 580, 450])

这就完成了图表的定义。定义后,程序员可以要求 Charter 库生成图表:

charter.generate_chart(chart, "chart.png")

综上所述,这里有一个完整的程序,可生成本章开头所示的条形图:

import charter
chart = charter.new_chart()
charter.set_title(chart, "Wild Parrot Deaths per Year")
charter.set_x_axis(chart,
                   ["2009", "2010", "2011", "2012", "2013",
                    "2014", "2015"])
charter.set_y_axis(chart, minimum=0, maximum=700,
                   labels=[0, 100, 200, 300, 400, 500, 600, 700])
charter.set_series(chart, [250, 270, 510, 420, 680, 580, 450])
charter.set_series_type(chart, "bar")
charter.generate_chart(chart, "chart.png")

因为 Charter 是一个供程序员使用的库,所以这段代码为 Charter 库的 API 提供了一个相当完整的规范。从这个示例程序中可以清楚地看到应该发生什么。现在让我们看看如何实现这一点。

执行章程

我们知道 Charter library 的公共接口将由许多在包级别访问的函数组成,例如charter.new_chart()。但是,使用上一章中介绍的技术,我们知道不必在包初始化文件中定义库的 API,就可以在包级别使用这些函数。相反,我们可以在别处定义函数,并将它们导入到__init__.py文件中,以便其他人可以使用它们。

让我们先创建一个目录来保存我们的charter包。创建一个名为charter的新目录,并在其中创建一个空包初始化文件__init__.py。这为我们提供了编写库的基本框架:

Implementing Charter

根据我们的设计,我们知道生成图表的过程将涉及以下三个步骤:

  1. 通过调用new_chart()函数创建新图表。
  2. 通过调用各种set_XXX()函数定义图表的内容和外观。
  3. 通过调用generate_chart()函数生成图表并保存为图像文件。

为了使代码保持良好的组织,我们将把生成图表的过程与创建和定义图表的过程分开。为此,我们将有一个名为chart的模块来处理图表的创建和定义,还有一个名为generator的单独模块来处理图表的生成。

继续创建这两个新的空模块,将它们放在charter包中:

Implementing Charter

现在我们已经有了包的总体结构,让我们为我们知道必须实现的各种函数创建一些占位符。编辑chart.py模块,并在此文件中输入以下内容:

def new_chart():
    pass

def set_title(chart, title):
    pass

def set_x_axis(chart, x_axis):
    pass

def set_y_axis(chart, minimum, maximum, labels):
    pass

def set_series_type(chart, series_type):
    pass

def set_series(chart, series):
    pass

同样,编辑generator.py模块,并在其中输入以下内容:

def generate_chart(chart, filename):
    pass

这些都是我们知道需要为 Charter 库实现的功能。但是,它们不在正确的位置,我们希望用户能够调用charter.new_chart(),而不是charter.chart.new_chart()。要解决此问题,请编辑__init__.py文件,并在此文件中输入以下内容:

from .chart     import *
from .generator import *

如您所见,我们使用相对导入将这些模块中的所有函数加载到主charter包的命名空间中。

我们的特许图书馆开始成形了!现在让我们依次研究这两个模块中的每一个。

实现 chart.py 模块

因为我们在 Charter 库的实现中避免使用面向对象编程技术,所以我们不能使用对象来存储图表的信息。相反,new_chart()函数将返回一个图表值,各种set_XXX()函数将获取该图表并向其添加信息。

存储图表信息的最简单方法是使用 Python 字典。这使得我们的new_chart()功能的实现非常简单;编辑chart.py模块,并将new_chart()的占位符替换为以下内容:

def new_chart():
    return {}

一旦我们有了一个保存图表数据的字典,就可以很容易地将我们想要的各种值存储到这个字典中。例如,编辑set_title()函数的定义,使其如下所示:

def set_title(chart, title):
    chart['title'] = title

通过类似的方式,我们可以实现其余的set_XXX()功能:

def set_x_axis(chart, x_axis):
    chart['x_axis'] = x_axis

def set_y_axis(chart, minimum, maximum, labels):
    chart['y_min']    = minimum
    chart['y_max']    = maximum
    chart['y_labels'] = labels

def set_series_type(chart, series_type):
    chart['series_type'] = series_type

def set_series(chart, series):
    chart['series'] = series

这就完成了我们的chart.py模块的实现。

实现 generator.py 模块

不幸的是,generate_chart()函数将更难实现,这就是为什么我们将此函数移到一个单独的模块中。生成图表的过程将涉及以下步骤:

  1. 创建一个空图像以保存生成的图表。
  2. 画出图表的标题。
  3. 画出x轴。
  4. 画出y轴。
  5. 绘制数据系列。
  6. 将生成的图像文件保存到磁盘。

因为生成图表的过程需要我们处理图像,所以我们需要找到一个库来生成图像文件。我们现在就拿一个。

枕头图书馆

Python 图像库PIL是一个古老的库,用于生成图像。不幸的是,PIL 不再被积极开发。然而,有一个新版本的 PIL,名为Pillow,该将继续得到支持,并允许我们创建和保存图像文件。

枕头库的主要网站位于http://python-pillow.org/ ,文件可在查阅 http://pillow.readthedocs.org/

让我们继续安装枕头。最简单的方法是使用pip install pillow,尽管安装指南(http://pillow.readthedocs.org/en/3.0.x/installation.html 为您提供了多种选择,如果这对您不起作用。

查看枕头文档,我们似乎可以使用以下代码创建空图像:

from PIL import Image
image = Image.new("RGB", (CHART_WIDTH, CHART_HEIGHT), "#7f00ff")

这将创建具有给定宽度和高度的新 RGB(红、绿、蓝)图像,并用给定颜色填充。

#7f00ff是紫色的十六进制色码。每对十六进制数字代表一个颜色值:7f表示红色,00表示绿色,ff表示蓝色。

要绘制此图像,我们将使用ImageDraw模块。例如:

from PIL import ImageDraw
drawer = ImageDraw.Draw(image)
drawer.line(50, 50, 150, 200, fill="#ff8010", width=2)

绘制图表后,我们可以通过以下方式将图像保存到磁盘:

image.save("image.png", format="png")

这个对 Pillow 库的简要介绍告诉我们如何实现前面描述的图表生成过程的步骤 1 和步骤 6。它还告诉我们,对于步骤 2 到 5,我们将使用ImageDraw模块绘制各种图表元素。

渲染器

当我们绘制图表时,我们希望能够选择要绘制的元素。例如,我们可以根据用户想要显示的数据系列类型在"bar""line"元素之间进行选择。执行此操作的一个非常简单的方法是按如下方式构造绘图代码:

if chart['series_type'] == "bar":
    ...draw the data series using bars
elif chart['series_type'] == "line":
    ...draw the data series using lines

然而,这不是很容易扩展,如果绘图逻辑变得复杂,或者如果我们向库中添加了更多的图表选项,那么很快就会很难阅读。为了使 Charter 库更加模块化,并支持进一步增强它,我们将使用渲染器模块为我们进行实际绘制。

在计算机图形学中,渲染器是程序的一部分,用于绘制某物。其思想是,您可以选择适当的渲染器,并要求它绘制所需的元素,而不必担心如何绘制该元素的细节。

使用渲染器模块,我们的绘图逻辑如下所示:

from renderers import bar_series, line_series

if chart['series_type'] == "bar":
    bar_series.draw(chart, drawer)
elif chart['series_type'] == "line":
    line_series.draw(chart, drawer)

这意味着我们可以将每个元素如何绘制的实际细节留给渲染器模块本身,而不必用大量详细的绘制代码来扰乱我们的generate_chart()函数。

为了跟踪渲染器模块,我们将创建一个名为renderers的子包,并将所有渲染器模块放在这个子包中。现在让我们创建这个子包。

charter主目录中新建一个名为renderers的新目录,并在其中新建一个名为__init__.py的文件作为包初始化文件。这个文件可以是空的,因为我们不需要做任何特殊的事情来初始化这个子包。

Charter library 总共需要五个不同的渲染器模块:

  • title.py
  • x_axis.py
  • y_axis.py
  • bar_series.py
  • line_series.py

继续在charter.renderers目录中创建这五个文件,并在每个文件中输入以下占位符文本:

def draw(chart, drawer):
    pass

这为我们提供了渲染器模块的总体结构。现在让我们使用这些渲染器来实现我们的generate_chart()功能。

编辑generate.py模块,并将generate_chart()功能的占位符定义替换为以下内容:

def generate_chart(chart, filename):
    image  = Image.new("RGB", (CHART_WIDTH, CHART_HEIGHT),
                       "#ffffff")
    drawer = ImageDraw.Draw(image)

    title.draw(chart, drawer)
    x_axis.draw(chart, drawer)
    y_axis.draw(chart, drawer)
    if chart['series_type'] == "bar":
        bar_series.draw(chart, drawer)
    elif chart['series_type'] == "line":
        line_series.draw(chart, drawer)

    image.save(filename, format="png")

如您所见,我们创建了一个Image对象来保存生成的图表,并使用十六进制颜色代码#ffffff将其初始化为白色。然后,我们使用ImageDraw模块定义一个drawer对象来绘制图表,并调用各种渲染器模块来完成所有工作。最后,我们调用image.save()将图像文件保存到磁盘。

为了使此函数正常工作,我们需要在generator.py模块的顶部添加几个import语句:

from PIL import Image, ImageDraw
from .renderers import (title, x_axis, y_axis,
                        bar_series, line_series)

还有一件事我们还没有处理:当我们创建图像时,我们使用两个常数来告诉枕头要创建的图像的尺寸:

    image = Image.new("RGB", (CHART_WIDTH, CHART_HEIGHT),
                       "#ffffff")

我们需要在某个地方定义这两个常量。

事实证明,我们需要定义更多的常量,并在 Charter 库中使用它们。为了实现这一点,我们将创建一个特殊的模块来保存各种常量。

在顶级charter目录中创建一个名为constants.py的新文件。在此模块内,添加以下值:

CHART_WIDTH  = 600
CHART_HEIGHT = 400

然后,将以下import语句添加到您的generator.py模块中:

from .constants import *

测试代码

虽然我们还没有实现任何渲染器,但我们有足够的代码开始测试。为此,创建一个名为test_charter.py的空文件,并将其放置在包含charter包的目录中。然后,在此文件中输入以下内容:

import charter
chart = charter.new_chart()
charter.set_title(chart, "Wild Parrot Deaths per Year")
charter.set_x_axis(chart,
                   ["2009", "2010", "2011", "2012", "2013",
                    "2014", "2015"])
charter.set_y_axis(chart, minimum=0, maximum=700,
                   labels=[0, 100, 200, 300, 400, 500, 600, 700])
charter.set_series(chart, [250, 270, 510, 420, 680, 580, 450])
charter.set_series_type(chart, "bar")
charter.generate_chart(chart, "chart.png")

这只是我们前面看到的示例代码的副本。此脚本将允许您测试 Charter 库;打开终端或命令行窗口,cd进入包含test_charter.py文件的目录,并键入以下内容:

python test_charter.py

一切顺利,程序应该没有任何错误地完成。然后您可以查看chart.png文件,它应该是一个填充了白色背景的空图像文件。

呈现标题

我们接下来需要实现各种渲染器模块,从图表的标题开始。编辑renderers/title.py文件,并将draw() 函数的占位符定义替换为以下内容:

def draw(chart, drawer):
    font = ImageFont.truetype("Helvetica", 24)
    text_width,text_height = font.getsize(chart['title'])

    left = CHART_WIDTH/2 - text_width/2
    top  = TITLE_HEIGHT/2 - text_height/2

    drawer.text((left, top), chart['title'], "#4040a0", font)

此渲染器首先获取绘制标题时使用的字体。然后,它计算标题文本的大小(以像素为单位)和标签的位置,使其在图表上居中。请注意,我们使用一个名为TITLE_HEIGHT的常量来指定图表标题的空间大小。

此函数的最后一行使用指定的位置和字体将标题绘制到图表上。字符串#4040a0是用于文本的十六进制颜色代码,这是深蓝色。

由于本模块使用ImageFont模块加载字体,以及constants.py模块中的一些常量,我们需要在模块顶部添加以下import语句:

from PIL import ImageFont
from ..constants import *

请注意,我们使用..从父包导入constants模块。

最后,我们需要将TITLE_HEIGHT常数添加到我们的constants.py模块中:

TITLE_HEIGHT = 50

如果您现在运行test_charter.py脚本,您应该会看到图表的标题出现在生成的图像中:

Rendering the title

渲染 x 轴

如果您还记得,x轴是一个离散轴,在每个记号之间显示标签。为了绘制这个图,我们必须计算轴上每个“桶”的宽度,然后画线来表示轴和记号,以及绘制每个桶的标签。

首先编辑renderers/x_axis.py文件,并将占位符draw()功能替换为以下内容:

def draw(chart, drawer):
    font = ImageFont.truetype("Helvetica", 12)
    label_height = font.getsize("Test")[1]

    avail_width = CHART_WIDTH - Y_AXIS_WIDTH - MARGIN
    bucket_width = avail_width / len(chart['x_axis'])

    axis_top = CHART_HEIGHT - X_AXIS_HEIGHT
    drawer.line([(Y_AXIS_WIDTH, axis_top),
                 (CHART_WIDTH - MARGIN, axis_top)],
                "#4040a0", 2) # Draw main axis line.

    left = Y_AXIS_WIDTH
    for bucket_num in range(len(chart['x_axis'])):
        drawer.line([(left, axis_top),
                     (left, axis_top + TICKMARK_HEIGHT)],
                    "#4040a0", 1) # Draw tickmark.

        label_width = font.getsize(chart['x_axis'][bucket_num])[0]
        label_left = max(left,
                         left + bucket_width/2 - label_width/2)
        label_top  = axis_top + TICKMARK_HEIGHT + 4

        drawer.text((label_left, label_top),
                    chart['x_axis'][bucket_num], "#000000", font)

        left = left + bucket_width

    drawer.line([(left, axis_top),
                 (left, axis_top + TICKMARK_HEIGHT)],
                "#4040a0", 1) # Draw final tickmark.

您还需要在模块顶部添加以下import语句:

from PIL import ImageFont
from ..constants import *

最后,您应该在constants.py模块中添加以下定义:

X_AXIS_HEIGHT   = 50
Y_AXIS_WIDTH    = 50
MARGIN          = 20
TICKMARK_HEIGHT = 8

这些定义了图表中固定元素的大小。

如果您现在运行test_charter.py脚本,您应该会看到x轴沿图表底部显示:

Rendering the x axis

剩余的渲染器

正如您可以看到的,生成的图像开始看起来更像图表。因为这个包的目的是展示如何构造代码,而不是如何实现这些模块的详细信息,所以让我们跳过前面,添加其余的渲染器,无需进一步讨论。

开始编辑您的renderers/y_axis.py文件,如下所示:

from PIL import ImageFont

from ..constants import *

def draw(chart, drawer):
    font = ImageFont.truetype("Helvetica", 12)
    label_height = font.getsize("Test")[1]

    axis_top    = TITLE_HEIGHT
    axis_bottom = CHART_HEIGHT - X_AXIS_HEIGHT
    axis_height = axis_bottom - axis_top

    drawer.line([(Y_AXIS_WIDTH, axis_top),
                 (Y_AXIS_WIDTH, axis_bottom)],
                "#4040a0", 2) # Draw main axis line.

    for y_value in chart['y_labels']:
        y = ((y_value - chart['y_min']) /
             (chart['y_max']-chart['y_min']))

        y_pos = axis_top + (axis_height - int(y * axis_height))

        drawer.line([(Y_AXIS_WIDTH - TICKMARK_HEIGHT, y_pos),
                     (Y_AXIS_WIDTH, y_pos)],
                    "#4040a0", 1) # Draw tickmark.

        label_width,label_height = font.getsize(str(y_value))
        label_left = Y_AXIS_WIDTH-TICKMARK_HEIGHT-label_width-4
        label_top = y_pos - label_height / 2

        drawer.text((label_left, label_top), str(y_value),
                    "#000000", font)

接下来,将renderers/bar_series.py编辑为如下:

from PIL import ImageFont
from ..constants import *

def draw(chart, drawer):
    avail_width  = CHART_WIDTH - Y_AXIS_WIDTH - MARGIN
    bucket_width = avail_width / len(chart['x_axis'])

    max_top      = TITLE_HEIGHT
    bottom       = CHART_HEIGHT - X_AXIS_HEIGHT
    avail_height = bottom - max_top

    left = Y_AXIS_WIDTH
    for y_value in chart['series']:

        bar_left = left + MARGIN / 2
        bar_right = left + bucket_width - MARGIN / 2

        y = ((y_value - chart['y_min']) /
             (chart['y_max'] - chart['y_min']))

        bar_top = max_top + (avail_height - int(y * avail_height))

        drawer.rectangle([(bar_left, bar_top),
                          (bar_right + 1,
                           bottom)],
                         fill="#e8e8f4", outline="#4040a0")

        left = left + bucket_width

最后,将renderers.line_series.py编辑为如下:

from PIL import ImageFont
from ..constants import *

def draw(chart, drawer):
    avail_width  = CHART_WIDTH - Y_AXIS_WIDTH - MARGIN
    bucket_width = avail_width / len(chart['x_axis'])

    max_top      = TITLE_HEIGHT
    bottom       = CHART_HEIGHT - X_AXIS_HEIGHT
    avail_height = bottom - max_top

    left   = Y_AXIS_WIDTH
    prev_y = None
    for y_value in chart['series']:
        y = ((y_value - chart['y_min']) /
             (chart['y_max'] - chart['y_min']))

        cur_y = max_top + (avail_height - int(y * avail_height))

        if prev_y != None:
            drawer.line([(left - bucket_width / 2, prev_y),
                         (left + bucket_width / 2), cur_y],
                        fill="#4040a0", width=1)
        prev_y = cur_y
        left = left + bucket_width

这就完成了 Charter 库的实现。

测试章程

如果您运行test_charter.py脚本,您应该会看到一个完整的条形图:

Testing Charter

显然,我们可以用 Charter library 做更多的事情,但即使在它目前的状态下,它也工作得很好。如果需要,可以使用它为各种数据生成线条图和条形图。出于我们的目的,我们可以声明 Charter 库已完成,并开始将其用作生产系统的一部分。

美中不足——不断变化的需求

当然,没有什么是真正完成的。让我们假设您编写了 Charter 库,并且几个月来一直忙于扩展它,添加了更多的数据系列类型和许多选项。该库正在贵公司的几个大项目中使用,输出看起来很棒,每个人似乎都很满意,直到有一天,你的老板进来说:“它太模糊了。你能消除模糊性吗?”

你问他是什么意思,他说他一直在用高分辨率激光打印机打印图表。结果不足以让他在公司报告中使用。他拿出一份打印件,指向标题。仔细看,你可以看出他的意思:

The fly in the ointment – changing requirements

果不其然,文本是像素化的,即使是线条在高分辨率打印时看起来也有点参差不齐。您尝试增大生成的图表的大小,但看起来仍然不够好,当您尝试增大大小以匹配该公司高分辨率激光打印机每英寸 1200 点时,您的程序崩溃。

“但这个项目从来就不是为这个而设计的,”你抱怨道。“我们写它是为了在屏幕上显示图表。”

“我不在乎,”你的老板说。“我希望您以矢量格式生成输出。这样打印效果总是很好,一点也不模糊。”

为了防止您以前没有遇到过这种情况,有两种根本不同的存储图像数据的方法:位图图像(由像素组成)和矢量图像(在其中保存单独的绘图指令(例如,“写入一些文本”、“绘制一条线”、“填充一个矩形”等),然后每次显示图像时都会遵循这些说明。位图图像受到像素化或“模糊”的影响,而矢量图像即使在高分辨率放大或打印时也很好看。

你在谷歌上快速搜索,确认枕头库无法保存矢量格式的图像;它仅适用于位图数据。你的老板并不同情你,“只要让它以矢量格式工作,为那些已经使用它的人保存为 PDF 和 PNG。”

怀着一颗沮丧的心,你想知道如何才能满足这些新的要求。整个 Charter 库从头开始构建,以生成位图 PNG 图像。你不需要从头重写整件事吗?

重新设计章程

由于 Charter 库现在需要选择性地将图表保存为矢量格式的 PDF 文件,因此我们需要找到一个替代 Python 映像库的方法,该库支持写入 PDF 文件。有一个明显的候选者:ReportLab

ReportLab 是一款商用 PDF 生成器,也是根据开源许可证发布的。有关 ReportLab 工具包的更多信息,请访问http://www.reportlab.com/opensource/ 。安装 ReportLab 最简单的方法是使用pip install reportlab。如果这对您不起作用,请查看上的安装说明 https://bitbucket.org/rptlab/reportlab 了解更多详情。ReportLab 工具包的文档可在中找到 http://www.reportlab.com/docs/reportlab-userguide.pdf

在许多方面,ReportLab 的工作方式与 Python 图像库的工作方式相同:初始化文档(ReportLab 中称为画布,调用各种方法将元素绘制到画布上,然后使用save()方法将 PDF 文件保存到磁盘。

但是,还有一个额外的步骤:因为 PDF 文件格式支持多页,所以在保存文档之前,需要调用showPage()函数呈现当前页面。虽然 Charter library 不需要多页,但我们可以在绘制每个页面后调用showPage()创建多页 PDF 文档,然后在绘制完成后调用save()将文件保存到磁盘。

现在我们有了一个工具,可以让我们生成 PDF 文件,让我们来看看我们如何重构宪章包来支持 PNG 或 PDF 文件格式的渲染。

generate_chart()功能似乎是用户可以选择输出格式的逻辑点。事实上,我们可以根据文件名自动检测格式,如果filename参数以.pdf结尾,那么我们应该以 PDF 格式生成图表,如果filename.png结尾,那么我们应该以 PNG 格式生成文件。

不过,更一般地说,我们的渲染器存在一个问题:它们都是为使用 Python 图像库而设计的,并且使用ImageDraw模块将每个图表绘制为位图图像。

由于这一点,以及每个渲染器模块内部代码的复杂性,让这些渲染器单独工作并编写使用 ReportLab 以 PDF 格式生成图表元素的新渲染器是有意义的。为此,我们需要重构呈现代码。

在我们跃进并开始做出改变之前,让我们想想我们想要实现什么。我们需要每个渲染器有两个单独的版本,一个生成 PNG 格式的元素,另一个生成 PDF 格式的相同元素:

Redesigning Charter

由于所有这些模块都执行相同的操作,因此最好使用一个函数调用相应的渲染器模块的draw()函数,以所需的输出格式绘制给定的图表元素。这样,剩下的代码只需要调用一个函数,而不是根据所需的元素和格式在十个不同的draw()函数之间进行选择。

要做到这一点,我们将在renderers包中添加一个名为renderer.py的新模块,并保留对该模块的单独渲染器的调用。这将大大简化我们的设计。

最后,我们的generate_chart()函数必须创建一个 ReportLab 画布,以生成 PDF 格式的图表,然后在生成图表时保存此画布,就像目前对位图图像所做的那样。

所有这些都意味着,虽然我们需要做一些工作来实现渲染器模块的新版本,创建一个新的renderer.py模块并更新generate_chart()功能,但系统的其余部分将保持完全相同。我们不需要从头重写所有内容,特别是我们的其他模块,现有的渲染器根本不需要更改。唷!

重构代码

我们将通过将现有的 PNG 渲染器移动到一个名为renderers.png的新子包中开始重构。在renderers目录中新建一个名为png的目录,并将title.pyx_axis.pyy_axis.pybar_series.pyline_series.py模块移动到此目录中。然后,在png目录中创建一个空的包初始化文件__init__.py,以便 Python 将其识别为包。

我们将对现有的 PNG 渲染器进行一个小小的更改:因为每个渲染器模块都使用相对导入来导入constants.py模块,所以我们需要更新这些模块,以便它们仍然可以从新位置找到constants模块。要执行此操作,请依次编辑每个 PNG 渲染器模块,并找到如下所示的行:

from ..constants import *

在每一行中添加一个额外的.,使它们看起来像这样:

from ...constants import *

我们的下一个任务是创建一个包来保存 PDF 格式的渲染器。在renderers目录中创建一个名为pdf的子目录,并在该目录中创建一个空的包初始化文件,使其成为 Python 包。

接下来我们要实现前面提到的renderer.py模块,这样generate_chart()函数就可以专注于绘制图表元素,而不用担心每个元素定义在哪个模块中。在renderers目录中新建一个名为renderer.py的文件,并在此文件中添加以下代码:

from .png import title       as title_png
from .png import x_axis      as x_axis_png
from .png import y_axis      as y_axis_png
from .png import bar_series  as bar_series_png
from .png import line_series as line_series_png

renderers = {
    'png' : {
        'title'       : title_png,
        'x_axis'      : x_axis_png,
        'y_axis'      : y_axis_png,
        'bar_series'  : bar_series_png,
        'line_series' : line_series_png
    },
}

def draw(format, element, chart, output):
    renderers[format][element].draw(chart, output)

这个模块正在做一些你以前可能没有遇到过的棘手的事情:在使用import...as导入每个 PNG 格式渲染器模块之后,我们将导入的模块当作 Python 变量来处理,并在renderers字典中存储对每个模块的引用。然后,我们的draw()函数使用renderers[format][element]从该字典中选择适当的模块,并调用该模块中的draw()函数来进行实际绘图。

这个 Python 技巧为我们节省了大量的编码,如果没有它,我们将不得不编写一系列if...then语句,根据所需的元素和格式调用相应模块的draw()函数。以这种方式使用字典可以节省我们大量的输入,并使代码更易于阅读和调试。

我们还可以使用 Python 标准库的importlib模块按名称加载渲染器模块。这会使我们的renderer模块更短,但会使代码更难理解。使用import...as和字典来选择所需的模块是复杂度和可理解性之间的一个很好的折衷。

我们接下来需要更新我们的generate_report()函数。如前一节所述,我们希望根据生成的文件的文件扩展名选择输出格式。我们还需要更新此函数以使用新的renderer.draw()函数,而不是直接导入和调用渲染器模块。

编辑generator.py模块,并用以下代码替换该模块的内容:

from PIL import Image, ImageDraw
from reportlab.pdfgen.canvas import Canvas

from .constants import *
from .renderers import renderer

def generate_chart(chart, filename):

    # Select the output format.

    if filename.lower().endswith(".pdf"):
        format = "pdf"
    elif filename.lower().endswith(".png"):
        format = "png"
    else:
        print("Unsupported file format: " + filename)
        return

    # Prepare the output file based on the file format.

    if format == "pdf":
        output = Canvas(filename)
    elif format == "png":
        image  = Image.new("RGB", (CHART_WIDTH, CHART_HEIGHT),
                           "#ffffff")
        output = ImageDraw.Draw(image)

    # Draw the various chart elements.

    renderer.draw(format, "title",  chart, output)
    renderer.draw(format, "x_axis", chart, output)
    renderer.draw(format, "y_axis", chart, output)
    if chart['series_type'] == "bar":
        renderer.draw(format, "bar_series", chart, output)
    elif chart['series_type'] == "line":
        renderer.draw(format, "line_series", chart, output)

    # Finally, save the output to disk.

    if format == "pdf":
        output.showPage()
        output.save()
    elif format == "png":
        image.save(filename, format="png")

这个模块中有很多代码,但是注释应该有助于解释发生了什么。如您所见,我们使用提供的文件名将format变量设置为"pdf""png"(视情况而定)。然后我们准备output变量来保存生成的图像或 PDF 文件。接下来,我们调用renderer.draw()依次绘制每个图表元素,传入formatoutput变量,以便渲染器可以完成其工作。最后,我们将输出保存到磁盘,以便将图表保存到适当的 PDF 或 PNG 格式文件中。

有了这些更改,您应该能够使用更新的 Charter 包生成 PNG 格式的文件。PDF 文件还不能工作,因为我们还没有编写 PDF 渲染器,但是 PNG 格式的输出应该可以工作。继续并通过运行test_charter.py脚本来测试这一点,只是为了确保没有输入任何错误的代码。

现在我们已经完成了对现有代码的重构,让我们添加我们的 PDF 渲染器。

实现 PDF 渲染器模块

我们将一次完成一个不同的渲染器模块。首先在pdf目录中创建titles.py模块,并在此文件中输入以下代码:

from ...constants import *

def draw(chart, canvas):
    text_width  = canvas.stringWidth(chart['title'],
                                     "Helvetica", 24)
    text_height = 24 * 1.2

    left   = CHART_WIDTH/2 - text_width/2
    bottom = CHART_HEIGHT - TITLE_HEIGHT/2 + text_height/2

    canvas.setFont("Helvetica", 24)
    canvas.setFillColorRGB(0.25, 0.25, 0.625)
    canvas.drawString(left, bottom, chart['title'])

在某些方面,此代码与此渲染器的 PNG 版本非常相似:我们计算文本的宽度和高度,并使用此值计算标题在图表上的绘制位置。然后我们用 24 点 Helvetica 字体,深蓝色绘制标题。

然而,有一些重要的区别:

  • 我们计算文本宽度和高度的方法是不同的。对于宽度,我们调用画布的stringWidth()函数,而对于高度,我们将文本的字体大小乘以 1.2。默认情况下,ReportLab 在文本行之间留出 20%的字体大小间隙,因此将字体大小乘以 1.2 是计算文本行高度的准确方法。
  • 用于计算元素在页面上的位置的单位不同。ReportLab 使用而不是像素测量所有位置和大小。一个点大约是 1/72 英寸。幸运的是,一个点相当接近于典型计算机屏幕上的像素大小;这使得我们可以忽略不同的测量系统,并使 PDF 输出看起来仍然良好。
  • PDF 文件使用与 PNG 文件不同的坐标系。在 PNG 格式文件中,图像顶部的y值为零,而对于 PDF 文件y=0位于图像底部。这意味着我们在页面上的所有位置都必须相对于页面底部进行计算,而不是像 PNG 渲染器那样相对于图像顶部进行计算。
  • 颜色是使用 RGB 颜色值指定的,其中颜色的每个分量都是一个介于 0 和 1 之间的数字。例如,(0.25,0.25,0.625)的颜色值相当于十六进制颜色代码#4040a0

无需进一步的 ado,让我们实现其余的 PDF 渲染器模块。x_axis.py模块应如下所示:

def draw(chart, canvas):
    label_height = 12 * 1.2

    avail_width  = CHART_WIDTH - Y_AXIS_WIDTH - MARGIN
    bucket_width = avail_width / len(chart['x_axis'])

    axis_top = X_AXIS_HEIGHT
    canvas.setStrokeColorRGB(0.25, 0.25, 0.625)
    canvas.setLineWidth(2)
    canvas.line(Y_AXIS_WIDTH, axis_top,
                CHART_WIDTH - MARGIN, axis_top)

    left = Y_AXIS_WIDTH
    for bucket_num in range(len(chart['x_axis'])):
        canvas.setLineWidth(1)
        canvas.line(left, axis_top,
                    left, axis_top - TICKMARK_HEIGHT)

        label_width  = canvas.stringWidth(
                               chart['x_axis'][bucket_num],
                               "Helvetica", 12)
        label_left   = max(left,
                           left + bucket_width/2 - label_width/2)
        label_bottom = axis_top - TICKMARK_HEIGHT-4-label_height

        canvas.setFont("Helvetica", 12)
        canvas.setFillColorRGB(0.0, 0.0, 0.0)
        canvas.drawString(label_left, label_bottom,
                          chart['x_axis'][bucket_num])

        left = left + bucket_width

    canvas.setStrokeColorRGB(0.25, 0.25, 0.625)
    canvas.setLineWidth(1)
    canvas.line(left, axis_top, left, axis_top - TICKMARK_HEIGHT)

同样地,y_axis.py模块应实现如下:

from ...constants import *

def draw(chart, canvas):
    label_height = 12 * 1.2

    axis_top    = CHART_HEIGHT - TITLE_HEIGHT
    axis_bottom = X_AXIS_HEIGHT
    axis_height = axis_top - axis_bottom

    canvas.setStrokeColorRGB(0.25, 0.25, 0.625)
    canvas.setLineWidth(2)
    canvas.line(Y_AXIS_WIDTH, axis_top, Y_AXIS_WIDTH, axis_bottom)

    for y_value in chart['y_labels']:
        y = ((y_value - chart['y_min']) /
             (chart['y_max'] - chart['y_min']))

        y_pos = axis_bottom + int(y * axis_height)

        canvas.setLineWidth(1)
        canvas.line(Y_AXIS_WIDTH - TICKMARK_HEIGHT, y_pos,
                    Y_AXIS_WIDTH, y_pos)

        label_width = canvas.stringWidth(str(y_value),
                                         "Helvetica", 12)
        label_left  = Y_AXIS_WIDTH - TICKMARK_HEIGHT-label_width-4
        label_bottom = y_pos - label_height/4

        canvas.setFont("Helvetica", 12)
        canvas.setFillColorRGB(0.0, 0.0, 0.0)
        canvas.drawString(label_left, label_bottom, str(y_value))

对于bar_series.py模块,输入以下内容:

from ...constants import *

def draw(chart, canvas):
    avail_width  = CHART_WIDTH - Y_AXIS_WIDTH - MARGIN
    bucket_width = avail_width / len(chart['x_axis'])

    bottom       = X_AXIS_HEIGHT
    max_top      = CHART_HEIGHT - TITLE_HEIGHT
    avail_height = max_top - bottom

    left = Y_AXIS_WIDTH
    for y_value in chart['series']:
        bar_left  = left + MARGIN / 2
        bar_width = bucket_width - MARGIN

        y = ((y_value - chart['y_min']) /
             (chart['y_max'] - chart['y_min']))

        bar_height = int(y * avail_height)

        canvas.setStrokeColorRGB(0.25, 0.25, 0.625)
        canvas.setFillColorRGB(0.906, 0.906, 0.953)
        canvas.rect(bar_left, bottom, bar_width, bar_height,
                    stroke=True, fill=True)

        left = left + bucket_width

最后,line_series.py模块应如下所示:

from ...constants import *

def draw(chart, canvas):
    avail_width  = CHART_WIDTH - Y_AXIS_WIDTH - MARGIN
    bucket_width = avail_width / len(chart['x_axis'])

    bottom       = X_AXIS_HEIGHT
    max_top      = CHART_HEIGHT - TITLE_HEIGHT
    avail_height = max_top - bottom

    left   = Y_AXIS_WIDTH
    prev_y = None
    for y_value in chart['series']:
        y = ((y_value - chart['y_min']) /
             (chart['y_max'] - chart['y_min']))

        cur_y = bottom + int(y * avail_height)

        if prev_y != None:
            canvas.setStrokeColorRGB(0.25, 0.25, 0.625)
            canvas.setLineWidth(1)
            canvas.line(left - bucket_width / 2, prev_y,
                        left + bucket_width / 2, cur_y)

        prev_y = cur_y
        left = left + bucket_width

正如您所看到的,这些模块看起来非常类似于它们的 PNG 版本。只要我们考虑到这两个库工作方式的差异,我们可以用 Python Imaging 库做的任何事情也可以用 ReportLab 做。

这就给我们留下了完成 Charter 库的新实现所需的又一个更改:我们需要更新renderer.py模块,使这些新的 PDF 渲染器模块可用。为此,在本模块顶部添加以下import语句:

from .pdf import title       as title_pdf
from .pdf import x_axis      as x_axis_pdf
from .pdf import y_axis      as y_axis_pdf
from .pdf import bar_series  as bar_series_pdf
from .pdf import line_series as line_series_pdf

然后,在本模块定义renderers字典的部分中,通过向代码中添加以下突出显示的行,为字典创建一个新的pdf条目:

renderers = {
    ...
    'pdf' : {
 'title'       : title_pdf,
 'x_axis'      : x_axis_pdf,
 'y_axis'      : y_axis_pdf,
 'bar_series'  : bar_series_pdf,
 'line_series' : line_series_pdf
 }
}

一旦完成,您就完成了 Charter 模块的重构和重新实现。假设您没有犯任何错误,您的库现在应该能够生成 PNG 和 PDF 格式的图表。

测试代码

为确保您的程序正常运行,请编辑您的test_charter.py程序,并将输出文件的名称从chart.png更改为chart.pdf。如果随后运行此程序,您将得到一个 PDF 文件,其中包含图表的高质量版本:

Testing the code

请注意,图表显示在页面底部,而不是顶部。这是因为 PDF 文件的y=0位置位于页面底部。通过计算页面高度(以点为单位)并添加适当的偏移量,可以轻松地将图表移动到页面顶部。如果您愿意,可以随意实施,但目前我们的任务已经完成。

如果你放大,你会看到图表的文本看起来仍然不错:

Testing the code

这是因为我们现在生成的是矢量格式的 PDF 文件,而不是位图图像。此文件可在高质量激光打印机上打印,无需任何像素。更好的是,您库的现有用户仍然可以请求图表的 PNG 版本,并且他们根本不会注意到任何更改。

恭喜你做到了!

经验教训

虽然 Charter 库只是模块化 Python 编程的一个示例,而且您并没有一个老板坚持要以 PDF 格式生成图表,但之所以选择这些示例,是因为问题绝不简单,而且您需要进行的更改也非常具有挑战性。回顾我们取得的成就,您可能会注意到以下几点:

  • 当面对需求的重大变化时,我们的第一反应通常是消极的:“哦,不!我怎么可能做到这一点呢?”“它永远不会工作”等等。
  • 与其插手并开始修补代码,不如退一步考虑现有代码库的结构,以及可能需要更改哪些内容以满足新的需求。
  • 如果新的需求涉及到一个您以前没有使用过的库或工具,那么在您开始更新代码之前,花一些时间研究可能的选项,并可能编写一个简单的示例程序来检查库是否会执行您想要的操作是值得的。
  • 通过明智地使用模块和包,可以将对现有代码所需的更改保持在最低限度。在 Charter 中,我们可以使用所有现有的渲染器模块,只需对源代码进行一点小的更改。在编写每个渲染器的新 PDF 版本之前,我们只需重写一个函数(generate_chart()函数),并添加一个新的renderer模块以简化对渲染器的访问。通过这种方式,模块化编程技术的使用有助于隔离对程序受影响部分的更改。
  • 正如经常发生的那样,由此产生的系统比我们开始使用的系统更好。支持 PDF 生成的需求导致了一个更模块化、结构更好的库,而不是将我们的程序转换成意大利面代码。特别是,renderer模块处理了以各种格式呈现各种图表元素的复杂性,允许系统的其余部分简单地调用renderer.draw()来完成工作,而不必直接导入和使用大量模块。由于这种变化,我们可以轻松地添加更多的图表元素,或更多的输出格式,只需对代码进行最小的进一步更改。

这里的整体教训很清楚:与其抵制对需求的更改,不如接受它们。最终的结果是一个更好的系统,更健壮,更可扩展,并且通常组织得更好。当然,前提是你做得对。

总结

在本章中,我们使用模块化编程技术实现了一个名为 Charter 的假想图表生成包。我们看到了图表是如何由标准元素组成的,以及这个组织是如何被翻译成程序代码的。在成功创建了一个可以将图表呈现为位图图像的工作图表生成库之后,我们看到了需求的根本性变化起初似乎是一个问题,但实际上是一个重构和改进代码的机会。

按照这个假设的示例,我们重构了 Charter 库以处理 PDF 格式的图表。在这样做的过程中,我们了解到,使用模块化技术来响应需求中的重大变化有助于隔离需要进行的变化,重构代码通常会产生一个比我们开始时更好组织、更可扩展和更健壮的系统。

在下一章中,我们将学习如何使用标准模块化编程“模式”来应对一系列编程挑战。