Python-编程蓝图-全-

Python 编程蓝图(全)

原文:zh.annas-archive.org/md5/86404db5905a76ae5db4e50dd816784e

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

如果你在过去 20 年里一直在软件开发行业中,那么你肯定听说过一种名为 Python 的编程语言。Python 由 Guido van Rossum 创建,于 1991 年首次亮相,并自那时起就一直受到全球许多软件开发人员的喜爱。

然而,一个已经有 20 多年历史的语言为何仍然存在,并且每天都在变得越来越受欢迎呢?

嗯,这个问题的答案很简单。Python 对于一切(或几乎一切)都很棒。Python 是一种通用编程语言,这意味着你可以创建简单的终端应用程序、Web 应用程序、微服务、游戏,以及复杂的科学应用程序。尽管可以用 Python 来实现不同的目的,但 Python 是一种以易学著称的语言,非常适合初学者以及没有计算机科学背景的人。

Python 是一种“电池包含”的编程语言,这意味着大多数时候在开发项目时你不需要使用任何外部依赖。Python 的标准库功能丰富,大多数时候包含了你创建程序所需的一切,而且即使你需要标准库中没有的东西,PyPI(Python 包索引)目前也包含了 117,652 个包。

Python 社区是一个欢迎、乐于助人、多元化且对这门语言非常热情的社区,社区中的每个人都乐意互相帮助。

如果你还不相信,知名网站 StackOverflow 发布了今年关于编程语言受欢迎程度的统计数据,基于用户在网站上提出的问题数量,Python 是排名前列的语言,仅次于 JavaScript、Java、C#和 PHP。

现在是成为 Python 开发者的完美时机,所以让我们开始吧!

本书适合对象

这本书适用于熟悉 Python 并希望通过网络和软件开发项目获得实践经验的软件开发人员。需要有 Python 编程的基础知识。

本书内容包括

第一章,实现天气应用程序,将指导你开发一个终端应用程序,显示特定地区的当前天气和未来 5 天的预报。本章将介绍 Python 编程的基本概念。你将学习如何解析命令行参数以增加程序的交互性,并最终学会如何使用流行的 Beautiful Soup 框架从网站上抓取数据。

第二章,使用 Spotify 创建远程控制应用程序,将教你如何使用 OAuth 对 Spotify API 进行身份验证。我们将使用 curses 库使应用程序更有趣和用户友好。

第三章,在 Twitter 上投票,将教你如何使用 Tkinter 库使用 Python 创建美观的用户界面。我们将使用 Python 的 Reactive Extensions 来检测后端的投票情况,然后在用户界面中发布更改。

第四章,汇率和货币转换工具,将使你能够实现一个货币转换器,它将实时从不同来源获取外汇汇率,并使用数据进行货币转换。我们将开发一个包含辅助函数来执行转换的 API。首先,我们将使用开源外汇汇率和货币转换 API(fixer.io/)。

本章的第二部分将教你如何创建一个命令行应用程序,利用我们的 API 从数据源获取数据,并使用一些参数获取货币转换结果。

第五章《使用微服务构建 Web Messenger》将教您如何使用 Nameko,这是 Python 的微服务框架。您还将学习如何为外部资源(如 Redis)创建依赖项提供程序。本章还将涉及对 Nameko 服务进行集成测试以及对 API 的基本 AJAX 请求。

第六章《使用用户认证微服务扩展 TempMessenger》将在第五章《使用微服务构建 Web Messenger》的基础上构建您的应用程序。您将创建一个用户认证微服务,将用户存储在 Postgres 数据库中。使用 Bcrypt,您还将学习如何安全地将密码存储在数据库中。本章还涵盖了创建 Flask Web 界面以及如何利用 cookie 存储 Web 会话数据。通过这些章节的学习,您将能够创建可扩展和协调的微服务。

第七章《使用 Django 创建在线视频游戏商店》将使您能够创建一个在线视频游戏商店。它将包含浏览不同类别的视频游戏、使用不同标准进行搜索、查看每个游戏的详细信息,最后将游戏添加到购物车并下订单等功能。在这里,您将学习 Django 2.0、管理 UI、Django 数据模型等内容。

第八章《订单微服务》将帮助您构建一个负责接收来自我们在上一章中开发的 Web 应用程序的订单的微服务。订单微服务还提供其他功能,如更新订单状态和使用不同标准提供订单信息。

第九章《通知无服务器应用》将教您有关无服务器函数架构以及如何使用 Flask 构建通知服务,并使用伟大的项目 Zappa 将最终应用部署到 AWS Lambda。您还将学习如何将在第七章《使用 Django 创建在线视频游戏商店》中开发的 Web 应用程序和在第八章《订单微服务》中开发的订单微服务与无服务器通知应用集成。

为了充分利用本书

为了在本地计算机上执行本书中的代码,您需要以下内容:

随着我们逐步学习,所有其他要求都将被安装。

本章中的所有说明都针对 macOS 或 Debian/Ubuntu 系统;但是,作者已经注意只使用跨平台依赖项。

下载示例代码文件

您可以从www.packtpub.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便直接通过电子邮件接收文件。

您可以按照以下步骤下载代码文件:

  1. 登录或注册www.packtpub.com

  2. 选择“SUPPORT”选项卡。

  3. 单击“代码下载和勘误”。

  4. 在搜索框中输入书名并按照屏幕上的说明进行操作。

下载文件后,请确保使用以下最新版本解压或提取文件夹:

  • Windows 的 WinRAR/7-Zip

  • Mac 的 Zipeg/iZip/UnRarX

  • 7-Zip/PeaZip for Linux

本书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Python-Programming-Blueprints。我们还有来自丰富书籍和视频目录的其他代码包,可在github.com/PacktPublishing/上找到。去看看吧!

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。这是一个例子:“这个方法将调用Runnerexec方法来执行执行请求 Twitter API 的函数。”

代码块设置如下:

def set_header(self):
    title = Label(self,
                  text='Voting for hasthags',
                  font=("Helvetica", 24),
                  height=4)
    title.pack()

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

def start_app(args):
    root = Tk()
    app = Application(hashtags=args.hashtags, master=root)
    app.master.title("Twitter votes")
    app.master.geometry("400x700+100+100")
    app.mainloop()

任何命令行输入或输出都以以下方式编写:

python app.py --hashtags debian ubuntu arch

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。这是一个例子:“它说,以您的用户名登录,然后在其后有一个注销链接。试一试,点击链接注销。”

警告或重要说明会显示为这样。提示和技巧会显示为这样。

第一章:实现天气应用程序

本书中的第一个应用程序将是一个网络爬虫应用程序,它将从weather.com爬取天气预报信息并在终端中呈现。我们将添加一些选项,可以将其作为应用程序的参数传递,例如:

  • 温度单位(摄氏度或华氏度)

  • 您可以获取天气预报的地区

  • 用户可以在我们的应用程序中选择当前预报、五天预报、十天预报和周末的输出选项

  • 补充输出的方式,例如风和湿度等额外信息

除了上述参数之外,此应用程序将被设计为可扩展的,这意味着我们可以为不同的网站创建解析器来获取天气预报,并且这些解析器将作为参数选项可用。

在本章中,您将学习如何:

  • 在 Python 应用程序中使用面向对象编程概念

  • 使用BeautifulSoup包从网站上爬取数据

  • 接收命令行参数

  • 利用inspect模块

  • 动态加载 Python 模块

  • 使用 Python 推导

  • 使用Selenium请求网页并检查其 DOM 元素

在开始之前,重要的是要说,当开发网络爬虫应用程序时,您应该牢记这些类型的应用程序容易受到更改的影响。如果您从中获取数据的网站的开发人员更改了 CSS 类名或 HTML DOM 的结构,应用程序将停止工作。此外,如果我们获取数据的网站的 URL 更改,应用程序将无法发送请求。

设置环境

在我们开始编写第一个示例之前,我们需要设置一个环境来工作并安装项目可能具有的任何依赖项。幸运的是,Python 有一个非常好的工具系统来处理虚拟环境。

Python 中的虚拟环境是一个广泛的主题,超出了本书的范围。但是,如果您不熟悉虚拟环境,知道虚拟环境是一个与全局 Python 安装隔离的 Python 环境即可。这种隔离允许开发人员轻松地使用不同版本的 Python,在环境中安装软件包,并管理项目依赖项,而不会干扰 Python 的全局安装。

Python 的安装包含一个名为venv的模块,您可以使用它来创建虚拟环境;语法非常简单。我们将要创建的应用程序称为weatherterm(天气终端),因此我们可以创建一个同名的虚拟环境,以使其简单。

要创建一个新的虚拟环境,请打开终端并运行以下命令:

$ python3 -m venv weatherterm

如果一切顺利,您应该在当前目录中看到一个名为weatherterm的目录。现在我们有了虚拟环境,我们只需要使用以下命令激活它:

$ . weatherterm/bin/activate

我建议安装并使用virtualenvwrapper,这是virtualenv工具的扩展。这使得管理、创建和删除虚拟环境以及快速在它们之间切换变得非常简单。如果您希望进一步了解,请访问:virtualenvwrapper.readthedocs.io/en/latest/#

现在,我们需要创建一个目录,我们将在其中创建我们的应用程序。不要在创建虚拟环境的同一目录中创建此目录;相反,创建一个项目目录,并在其中创建应用程序目录。我建议您简单地使用与虚拟环境相同的名称命名它。

我正在设置环境并在安装了 Debian 9.2 的机器上运行所有示例,并且在撰写本文时,我正在运行最新的 Python 版本(3.6.2)。如果您是 Mac 用户,情况可能不会有太大差异;但是,如果您使用 Windows,步骤可能略有不同,但是很容易找到有关如何在其中设置虚拟环境的信息。现在,Windows 上的 Python 3 安装效果很好。

进入刚创建的项目目录并创建一个名为requirements.txt的文件,内容如下:

beautifulsoup4==4.6.0
selenium==3.6.0

这些都是我们这个项目所需的所有依赖项:

  • BeautifulSoup这是一个用于解析 HTML 和 XML 文件的包。我们将使用它来解析从天气网站获取的 HTML,并在终端上获取所需的天气数据。它非常简单易用,并且有在线上有很好的文档:beautiful-soup-4.readthedocs.io/en/latest/

  • selenium这是一个用于测试的知名工具集。有许多应用程序,但它主要用于自动测试 Web 应用程序。

要在我们的虚拟环境中安装所需的软件包,可以运行以下命令:

pip install -r requirements.txt

始终使用 GIT 或 Mercurial 等版本控制工具是一个好主意。它非常有助于控制更改,检查历史记录,回滚更改等。如果您对这些工具不熟悉,互联网上有很多教程。您可以通过查看 GIT 的文档来开始:git-scm.com/book/en/v1/Getting-Started

我们需要安装的最后一个工具是 PhantomJS;您可以从以下网址下载:phantomjs.org/download.html

下载后,提取weatherterm目录中的内容,并将文件夹重命名为phantomjs

在设置好我们的虚拟环境并安装了 PhantomJS 后,我们准备开始编码!

核心功能

首先,创建一个模块的目录。在项目的根目录内,创建一个名为weatherterm的子目录。weatherterm子目录是我们模块的所在地。模块目录需要两个子目录-coreparsers。项目的目录结构应该如下所示:

weatherterm
├── phantomjs
└── weatherterm
    ├── core
    ├── parsers   

动态加载解析器

这个应用程序旨在灵活,并允许开发人员为不同的天气网站创建不同的解析器。我们将创建一个解析器加载器,它将动态发现parsers目录中的文件,加载它们,并使它们可供应用程序使用,而无需更改代码的其他部分。在实现新解析器时,我们的加载器将需要遵循以下规则:

  • 创建一个实现获取当前天气预报以及五天、十天和周末天气预报方法的类文件

  • 文件名必须以parser结尾,例如weather_com_parser.py

  • 文件名不能以双下划线开头

说到这里,让我们继续创建解析器加载器。在weatherterm/core目录中创建一个名为parser_loader.py的文件,并添加以下内容:

import os
import re
import inspect

def _get_parser_list(dirname):
    files = [f.replace('.py', '')
             for f in os.listdir(dirname)
             if not f.startswith('__')]

    return files

def _import_parsers(parserfiles):

    m = re.compile('.+parser$', re.I)

    _modules = __import__('weatherterm.parsers',
                          globals(),
                          locals(),
                          parserfiles,
                          0)

    _parsers = [(k, v) for k, v in inspect.getmembers(_modules)
                if inspect.ismodule(v) and m.match(k)]

    _classes = dict()

    for k, v in _parsers:
        _classes.update({k: v for k, v in inspect.getmembers(v)
                         if inspect.isclass(v) and m.match(k)})

    return _classes

def load(dirname):
    parserfiles = _get_parser_list(dirname)
    return _import_parsers(parserfiles)

首先,执行_get_parser_list函数并返回位于weatherterm/parsers中的所有文件的列表;它将根据先前描述的解析器规则过滤文件。返回文件列表后,就可以导入模块了。这是由_import_parsers函数完成的,它首先导入weatherterm.parsers模块,并利用标准库中的 inspect 包来查找模块中的解析器类。

inspect.getmembers函数返回一个元组列表,其中第一项是表示模块中的属性的键,第二项是值,可以是任何类型。在我们的情况下,我们对以parser结尾的键和类型为类的值感兴趣。

假设我们已经在weatherterm/parsers目录中放置了一个解析器,inspect.getmembers(_modules)返回的值将看起来像这样:

[('WeatherComParser',
  <class 'weatherterm.parsers.weather_com_parser.WeatherComParser'>),
  ...]

inspect.getmembers(_module)返回了更多的项目,但它们已被省略,因为在这一点上展示它们并不相关。

最后,我们循环遍历模块中的项目,并提取解析器类,返回一个包含类名和稍后用于创建解析器实例的类对象的字典。

创建应用程序的模型

让我们开始创建将代表我们的应用程序从天气网站上爬取的所有信息的模型。我们要添加的第一项是一个枚举,用于表示我们应用程序的用户将提供的天气预报选项。在weatherterm/core目录中创建一个名为forecast_type.py的文件,内容如下:

from enum import Enum, unique

@unique
class ForecastType(Enum):
    TODAY = 'today'
    FIVEDAYS = '5day'
    TENDAYS = '10day'
    WEEKEND = 'weekend'

枚举自 Python 3.4 版本以来一直存在于 Python 标准库中,可以使用创建类的语法来创建。只需创建一个从enum.Enum继承的类,其中包含一组设置为常量值的唯一属性。在这里,我们为应用程序提供的四种类型的预报设置了值,可以访问ForecastType.TODAYForecastType.WEEKEND等值。

请注意,我们正在分配与枚举的属性项不同的常量值,原因是以后这些值将用于构建请求天气网站的 URL。

应用程序需要另一个枚举来表示用户在命令行中可以选择的温度单位。这个枚举将包含摄氏度和华氏度项目。

首先,让我们包含一个基本枚举。在weatherterm/core目录中创建一个名为base_enum.py的文件,内容如下:

from enum import Enum

class BaseEnum(Enum):
    def _generate_next_value_(name, start, count, last_value):
        return name

BaseEnum是一个非常简单的类,继承自Enum。我们在这里想要做的唯一一件事是覆盖_generate_next_value_方法,以便从BaseEnum继承的每个枚举和具有值设置为auto()的属性将自动获得与属性名称相同的值。

现在,我们可以为温度单位创建一个枚举。在weatherterm/core目录中创建一个名为unit.py的文件,内容如下:

from enum import auto, unique

from .base_enum import BaseEnum

@unique
class Unit(BaseEnum):
    CELSIUS = auto()
    FAHRENHEIT = auto()

这个类继承自我们刚刚创建的BaseEnum,每个属性都设置为auto(),这意味着枚举中每个项目的值将自动设置。由于Unit类继承自BaseEnum,每次调用auto()时,BaseEnum上的_generate_next_value_方法将被调用,并返回属性本身的名称。

在我们尝试这个之前,让我们在weatherterm/core目录中创建一个名为__init__.py的文件,并导入我们刚刚创建的枚举,如下所示:

from .unit import Unit

如果我们在 Python REPL 中加载这个类并检查值,将会发生以下情况:

Python 3.6.2 (default, Sep 11 2017, 22:31:28) 
[GCC 6.3.0 20170516] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from weatherterm.core import Unit
>>> [value for key, value in Unit.__members__.items()]
[<Unit.CELSIUS: 'CELSIUS'>, <Unit.FAHRENHEIT: 'FAHRENHEIT'>]

我们还想要添加到我们应用程序的核心模块的另一项内容是一个类,用于表示解析器返回的天气预报数据。让我们继续在weatherterm/core目录中创建一个名为forecast.py的文件,内容如下:

from datetime import date

from .forecast_type import ForecastType

class Forecast:
    def __init__(
            self,
            current_temp,
            humidity,
            wind,
            high_temp=None,
            low_temp=None,
            description='',
            forecast_date=None,
            forecast_type=ForecastType.TODAY):
        self._current_temp = current_temp
        self._high_temp = high_temp
        self._low_temp = low_temp
        self._humidity = humidity
        self._wind = wind
        self._description = description
        self._forecast_type = forecast_type

        if forecast_date is None:
            self.forecast_date = date.today()
        else:
            self._forecast_date = forecast_date

    @property
    def forecast_date(self):
        return self._forecast_date

    @forecast_date.setter
    def forecast_date(self, forecast_date):
        self._forecast_date = forecast_date.strftime("%a %b %d")

    @property
    def current_temp(self):
        return self._current_temp

    @property
    def humidity(self):
        return self._humidity

    @property
    def wind(self):
        return self._wind

    @property
    def description(self):
        return self._description

    def __str__(self):
        temperature = None
        offset = ' ' * 4

        if self._forecast_type == ForecastType.TODAY:
            temperature = (f'{offset}{self._current_temp}\xb0\n'
                           f'{offset}High {self._high_temp}\xb0 / '
                           f'Low {self._low_temp}\xb0 ')
        else:
            temperature = (f'{offset}High {self._high_temp}\xb0 / '
                           f'Low {self._low_temp}\xb0 ')

        return(f'>> {self.forecast_date}\n'
               f'{temperature}'
               f'({self._description})\n'
               f'{offset}Wind: '
               f'{self._wind} / Humidity: {self._humidity}\n')

在 Forecast 类中,我们将定义我们将要解析的所有数据的属性:

current_temp 表示当前温度。仅在获取今天的天气预报时才可用。
humidity 一天中的湿度百分比。
wind 有关今天当前风级的信息。
high_temp 一天中的最高温度。
low_temp 一天中的最低温度。
description 天气条件的描述,例如部分多云
forecast_date 预测日期;如果未提供,将设置为当前日期。
forecast_type 枚举ForecastType中的任何值(TODAYFIVEDAYSTENDAYSWEEKEND)。

我们还可以实现两个名为forecast_date的方法,使用@property@forecast_date.setter装饰器。@property装饰器将方法转换为Forecast类的_forecast_date属性的 getter,而@forecast_date.setter将方法转换为 setter。之所以在这里定义 setter,是因为每次需要在Forecast的实例中设置日期时,我们都需要确保它将被相应地格式化。在 setter 中,我们调用strftime方法,传递格式代码%a(缩写的星期几名称),%b(缩写的月份名称)和%d(月份的第几天)。

格式代码%a%b将使用在运行代码的机器上配置的区域设置。

最后,我们重写__str__方法,以便在使用printformatstr函数时以我们希望的方式格式化输出。

默认情况下,weather.com使用的温度单位是华氏度,我们希望我们的应用程序用户可以选择使用摄氏度。因此,让我们继续在weatherterm/core目录中创建一个名为unit_converter.py的文件,内容如下:

from .unit import Unit

class UnitConverter:
    def __init__(self, parser_default_unit, dest_unit=None):
        self._parser_default_unit = parser_default_unit
        self.dest_unit = dest_unit

        self._convert_functions = {
            Unit.CELSIUS: self._to_celsius,
            Unit.FAHRENHEIT: self._to_fahrenheit,
        }

    @property
    def dest_unit(self):
        return self._dest_unit

    @dest_unit.setter
    def dest_unit(self, dest_unit):
        self._dest_unit = dest_unit

    def convert(self, temp):

        try:
            temperature = float(temp)
        except ValueError:
            return 0

        if (self.dest_unit == self._parser_default_unit or
                self.dest_unit is None):
            return self._format_results(temperature)

        func = self._convert_functions[self.dest_unit]
        result = func(temperature)

        return self._format_results(result)

    def _format_results(self, value):
        return int(value) if value.is_integer() else f'{value:.1f}'

    def _to_celsius(self, fahrenheit_temp):
        result = (fahrenheit_temp - 32) * 5/9
        return result

    def _to_fahrenheit(self, celsius_temp):
        result = (celsius_temp * 9/5) + 32
        return result

这个类将负责将摄氏度转换为华氏度,反之亦然。这个类的初始化器有两个参数;解析器使用的默认单位和目标单位。在初始化器中,我们将定义一个包含用于温度单位转换的函数的字典。

convert方法只接受一个参数,即温度。在这里,温度是一个字符串,因此我们需要尝试将其转换为浮点值;如果失败,它将立即返回零值。

您还可以验证目标单位是否与解析器的默认单位相同。在这种情况下,我们不需要继续执行任何转换;我们只需格式化值并返回它。

如果需要执行转换,我们可以查找_convert_functions字典,找到需要运行的conversion函数。如果找到我们正在寻找的函数,我们调用它并返回格式化的值。

下面的代码片段显示了_format_results方法,这是一个实用方法,将为我们格式化温度值:

return int(value) if value.is_integer() else f'{value:.1f}'

_format_results方法检查数字是否为整数;如果value.is_integer()返回True,则表示数字是整数,例如 10.0。如果为True,我们将使用int函数将值转换为 10;否则,该值将作为具有精度为 1 的定点数返回。Python 中的默认精度为 6。最后,有两个实用方法执行温度转换,_to_celsius_to_fahrenheit

现在,我们只需要编辑weatherterm/core目录中的__init__.py文件,并包含以下导入语句:

from .base_enum import BaseEnum
from .unit_converter import UnitConverter
from .forecast_type import ForecastType
from .forecast import Forecast

从天气网站获取数据

我们将添加一个名为Request的类,负责从天气网站获取数据。让我们在weatherterm/core目录中添加一个名为request.py的文件,内容如下:

import os
from selenium import webdriver

class Request:
    def __init__(self, base_url):
        self._phantomjs_path = os.path.join(os.curdir,
                                          'phantomjs/bin/phantomjs')
        self._base_url = base_url
        self._driver = webdriver.PhantomJS(self._phantomjs_path)

    def fetch_data(self, forecast, area):
        url = self._base_url.format(forecast=forecast, area=area)
        self._driver.get(url)

        if self._driver.title == '404 Not Found':
            error_message = ('Could not find the area that you '
                             'searching for')
            raise Exception(error_message)

        return self._driver.page_source

这个类非常简单;初始化程序定义了基本 URL 并创建了一个 PhantomJS 驱动程序,使用 PhantomJS 安装的路径。fetch_data方法格式化 URL,添加预测选项和区域。之后,webdriver执行请求并返回页面源代码。如果返回的标记标题是404 Not Found,它将引发异常。不幸的是,Selenium没有提供获取 HTTP 状态代码的正确方法;这比比较字符串要好得多。

您可能会注意到,我在一些类属性前面加了下划线符号。我通常这样做是为了表明底层属性是私有的,不应该在类外部设置。在 Python 中,没有必要这样做,因为没有办法设置私有或公共属性;但是,我喜欢这样做,因为我可以清楚地表明我的意图。

现在,我们可以在weatherterm/core目录中的__init__.py文件中导入它:

from .request import Request

现在我们有一个解析器加载器,可以加载我们放入weatherterm/parsers目录中的任何解析器,我们有一个表示预测模型的类,以及一个枚举ForecastType,因此我们可以指定要解析的预测类型。该枚举表示温度单位和实用函数,用于将温度从华氏度转换为摄氏度和从摄氏度转换为华氏度。因此,现在,我们应该准备好创建应用程序的入口点,以接收用户传递的所有参数,运行解析器,并在终端上呈现数据。

使用 ArgumentParser 获取用户输入

在我们第一次运行应用程序之前,我们需要添加应用程序的入口点。入口点是在执行应用程序时将首先运行的代码。

我们希望为我们的应用程序的用户提供尽可能好的用户体验,因此我们需要添加的第一个功能是能够接收和解析命令行参数,执行参数验证,根据需要设置参数,最后但并非最不重要的是,显示一个有组织且信息丰富的帮助系统,以便用户可以查看可以使用哪些参数以及如何使用应用程序。

听起来很繁琐,对吧?

幸运的是,Python 自带了很多功能,标准库中包含一个很棒的模块,可以让我们以非常简单的方式实现这一点;该模块称为argparse

另一个很好的功能是让我们的应用程序易于分发给用户。一种方法是在weatherterm模块目录中创建一个__main__.py文件,然后可以像运行常规脚本一样运行模块。Python 将自动运行__main__.py文件,如下所示:

$ python -m weatherterm

另一个选项是压缩整个应用程序目录并执行 Python,传递 ZIP 文件的名称。这是一种简单、快速、简单的分发 Python 程序的方法。

还有许多其他分发程序的方法,但这超出了本书的范围;我只是想给你一些使用__main__.py文件的例子。

有了这个说法,让我们在weatherterm目录中创建一个__main__.py文件,内容如下:

import sys
from argparse import ArgumentParser

from weatherterm.core import parser_loader
from weatherterm.core import ForecastType
from weatherterm.core import Unit

def _validate_forecast_args(args):
    if args.forecast_option is None:
        err_msg = ('One of these arguments must be used: '
                   '-td/--today, -5d/--fivedays, -10d/--tendays, -
                    w/--weekend')
        print(f'{argparser.prog}: error: {err_msg}', 
        file=sys.stderr)
        sys.exit()

parsers = parser_loader.load('./weatherterm/parsers')

argparser = ArgumentParser(
    prog='weatherterm',
    description='Weather info from weather.com on your terminal')

required = argparser.add_argument_group('required arguments')

required.add_argument('-p', '--parser',
                      choices=parsers.keys(),
                      required=True,
                      dest='parser',
                      help=('Specify which parser is going to be  
                       used to '
                            'scrape weather information.'))

unit_values = [name.title() for name, value in Unit.__members__.items()]

argparser.add_argument('-u', '--unit',
                       choices=unit_values,
                       required=False,
                       dest='unit',
                       help=('Specify the unit that will be used to 
                       display '
                             'the temperatures.'))

required.add_argument('-a', '--areacode',
                      required=True,
                      dest='area_code',
                      help=('The code area to get the weather 
                       broadcast from. '
                            'It can be obtained at 
                              https://weather.com'))

argparser.add_argument('-v', '--version',
                       action='version',
                       version='%(prog)s 1.0')

argparser.add_argument('-td', '--today',
                       dest='forecast_option',
                       action='store_const',
                       const=ForecastType.TODAY,
                       help='Show the weather forecast for the 
                       current day')

args = argparser.parse_args()

_validate_forecast_args(args)

cls = parsers[args.parser]

parser = cls()
results = parser.run(args)

for result in results:
    print(results)

我们的应用程序将接受的天气预报选项(今天、五天、十天和周末预报)不是必需的;但是,至少必须在命令行中提供一个选项,因此我们创建了一个名为_validate_forecast_args的简单函数来执行此验证。此函数将显示帮助消息并退出应用程序。

首先,我们获取weatherterm/parsers目录中可用的所有解析器。解析器列表将用作解析器参数的有效值。

ArgumentParser对象负责定义参数、解析值和显示帮助,因此我们创建一个ArgumentParser的实例,并创建一个必需参数的参数组。这将使帮助输出看起来更加美观和有组织。

为了使参数和帮助输出更有组织,我们将在ArgumentParser对象中创建一个组。此组将包含我们的应用程序需要的所有必需参数。这样,我们的应用程序的用户可以轻松地看到哪些参数是必需的,哪些是不必需的。

我们通过以下语句实现了这一点:

required = argparser.add_argument_group('required arguments')

在为必需参数创建参数组之后,我们获取枚举Unit的所有成员的列表,并使用title()函数使只有第一个字母是大写字母。

现在,我们可以开始添加我们的应用程序能够在命令行接收的参数。大多数参数定义使用相同的一组关键字参数,因此我不会覆盖所有参数。

我们将创建的第一个参数是--parser-p

required.add_argument('-p', '--parser',
                      choices=parsers.keys(),
                      required=True,
                      dest='parser',
                      help=('Specify which parser is going to be 
                       used to '
                            'scrape weather information.'))

让我们分解创建解析器标志时使用的add_argument的每个参数:

  • 前两个参数是标志。在这种情况下,用户可以使用-p--parser在命令行中传递值给此参数,例如--parser WeatherComParser

  • choices参数指定我们正在创建的参数的有效值列表。在这里,我们使用parsers.keys(),它将返回一个解析器名称的列表。这种实现的优势是,如果我们添加一个新的解析器,它将自动添加到此列表中,而且不需要对此文件进行任何更改。

  • required参数,顾名思义,指定参数是否为必需的。

  • dest参数指定要添加到解析器参数的结果对象中的属性的名称。parser_args()返回的对象将包含一个名为parser的属性,其值是我们在命令行中传递给此参数的值。

  • 最后,help参数是参数的帮助文本,在使用-h--help标志时显示。

转到--today参数:

argparser.add_argument('-td', '--today',
                       dest='forecast_option',
                       action='store_const',
                       const=ForecastType.TODAY,
                       help='Show the weather forecast for the 
                       current day')

这里有两个我们以前没有见过的关键字参数,actionconst

行动可以绑定到我们创建的参数,并且它们可以执行许多操作。argparse模块包含一组很棒的操作,但如果您需要执行特定操作,可以创建自己的操作来满足您的需求。argparse模块中定义的大多数操作都是将值存储在解析结果对象属性中的操作。

在前面的代码片段中,我们使用了store_const操作,它将一个常量值存储到parse_args()返回的对象中的属性中。

我们还使用了关键字参数const,它指定在命令行中使用标志时的常量默认值。

记住我提到过可以创建自定义操作吗?参数 unit 是自定义操作的一个很好的用例。choices参数只是一个字符串列表,因此我们使用此推导式获取Unit枚举中每个项目的名称列表,如下所示:

unit_values = [name.title() for name, value in Unit.__members__.items()]

required.add_argument('-u', '--unit',
                      choices=unit_values,
                      required=False,
                      dest='unit',
                      help=('Specify the unit that will be used to 
                       display '
                            'the temperatures.'))

parse_args()返回的对象将包含一个名为 unit 的属性,其值为字符串(CelsiusFahrenheit),但这并不是我们想要的。我们可以通过创建自定义操作来更改此行为。

首先,在weatherterm/core目录中添加一个名为set_unit_action.py的新文件,内容如下:

from argparse import Action

from weatherterm.core import Unit

class SetUnitAction(Action):

    def __call__(self, parser, namespace, values,    
     option_string=None):
        unit = Unit[values.upper()]
        setattr(namespace, self.dest, unit)

这个操作类非常简单;它只是继承自argparse.Action并覆盖__call__方法,当解析参数值时将调用该方法。这将设置为目标属性。

parser参数将是ArgumentParser的一个实例。命名空间是argparser.Namespace的一个实例,它只是一个简单的类,包含ArgumentParser对象中定义的所有属性。如果您使用调试器检查此参数,您将看到类似于这样的东西:

Namespace(area_code=None, fields=None, forecast_option=None, parser=None, unit=None)

values参数是用户在命令行上传递的值;在我们的情况下,它可以是摄氏度或华氏度。最后,option_string参数是为参数定义的标志。对于单位参数,option_string的值将是-u

幸运的是,Python 中的枚举允许我们使用项目访问它们的成员和属性:

Unit[values.upper()]

在 Python REPL 中验证这一点,我们有:

>>> from weatherterm.core import Unit
>>> Unit['CELSIUS']
<Unit.CELSIUS: 'CELSIUS'>
>>> Unit['FAHRENHEIT']
<Unit.FAHRENHEIT: 'FAHRENHEIT'>

在获取正确的枚举成员之后,我们设置了命名空间对象中self.dest指定的属性的值。这样更清晰,我们不需要处理魔术字符串。

有了自定义操作,我们需要在weatherterm/core目录中的__init__.py文件中添加导入语句:

from .set_unit_action import SetUnitAction

只需在文件末尾包含上面的行。然后,我们需要将其导入到__main__.py文件中,就像这样:

from weatherterm.core import SetUnitAction

然后,我们将在单位参数的定义中添加action关键字参数,并将其设置为SetUnitAction,就像这样:

required.add_argument('-u', '--unit',
                      choices=unit_values,
                      required=False,
                      action=SetUnitAction,
                      dest='unit',
                      help=('Specify the unit that will be used to 
                       display '
                            'the temperatures.'))

所以,当我们的应用程序的用户使用摄氏度标志-u时,parse_args()函数返回的对象的属性单位的值将是:

<Unit.CELSIUS: 'CELSIUS'>

代码的其余部分非常简单;我们调用parse_args函数来解析参数并将结果设置在args变量中。然后,我们使用args.parser的值(所选解析器的名称)并访问解析器字典中的项。请记住,值是类类型,所以我们创建解析器的实例,最后调用 run 方法,这将启动网站抓取。

创建解析器

为了第一次运行我们的代码,我们需要创建一个解析器。我们可以快速创建一个解析器来运行我们的代码,并检查数值是否被正确解析。

让我们继续,在weatherterm/parsers目录中创建一个名为weather_com_parser.py的文件。为了简单起见,我们只会创建必要的方法,当这些方法被调用时,我们唯一要做的就是引发NotImplementedError

from weatherterm.core import ForecastType

class WeatherComParser:

    def __init__(self):
        self._forecast = {
            ForecastType.TODAY: self._today_forecast,
            ForecastType.FIVEDAYS: self._five_and_ten_days_forecast,
            ForecastType.TENDAYS: self._five_and_ten_days_forecast,
            ForecastType.WEEKEND: self._weekend_forecast,
            }

    def _today_forecast(self, args):
        raise NotImplementedError()

    def _five_and_ten_days_forecast(self, args):
        raise NotImplementedError()

    def _weekend_forecast(self, args):
        raise NotImplementedError()

    def run(self, args):
        self._forecast_type = args.forecast_option
        forecast_function = self._forecast[args.forecast_option]
        return forecast_function(args)

在初始化器中,我们创建了一个字典,其中键是ForecasType枚举的成员,值是绑定到任何这些选项的方法。我们的应用程序将能够呈现今天的、五天的、十天的和周末的预报,所以我们实现了所有四种方法。

run方法只做两件事;它使用我们在命令行中传递的forecast_option查找需要执行的函数,并执行该函数返回其值。

现在,如果你在命令行中运行命令,应用程序终于准备好第一次执行了:

$ python -m weatherterm --help

应该看到应用程序的帮助选项:

usage: weatherterm [-h] -p {WeatherComParser} [-u {Celsius,Fahrenheit}] -a AREA_CODE [-v] [-td] [-5d] [-10d] [-w]

Weather info from weather.com on your terminal

optional arguments:
 -h, --help show this help message and exit
 -u {Celsius,Fahrenheit}, --unit {Celsius,Fahrenheit}
 Specify the unit that will be used to display 
 the temperatures.
 -v, --version show program's version number and exit
 -td, --today Show the weather forecast for the current day

require arguments:
 -p {WeatherComParser}, --parser {WeatherComParser}
 Specify which parser is going to be used to scrape
 weather information.
 -a AREA_CODE, --areacode AREA_CODE
 The code area to get the weather broadcast from. It
 can be obtained at https://weather.com

正如你所看到的,ArgumentParse模块已经提供了开箱即用的帮助输出。你可以按照自己的需求自定义输出的方式,但我觉得默认布局非常好。

注意,-p参数已经给了你选择WeatherComParser的选项。因为解析器加载器已经为我们完成了所有工作,所以不需要在任何地方硬编码它。-u--unit)标志也包含了枚举Unit的项。如果有一天你想扩展这个应用程序并添加新的单位,你唯一需要做的就是在这里添加新的枚举项,它将自动被捡起并包含为-u标志的选项。

现在,如果你再次运行应用程序并传递一些参数:

$ python -m weatherterm -u Celsius -a SWXX2372:1:SW -p WeatherComParser -td

你会得到类似于这样的异常:

不用担心——这正是我们想要的!如果您跟踪堆栈跟踪,您会看到一切都按预期工作。当我们运行我们的代码时,我们在__main__.py文件中选择了所选解析器上的run方法,然后选择与预报选项相关联的方法,例如_today_forecast,最后将结果存储在forecast_function变量中。

当执行存储在forecast_function变量中的函数时,引发了NotImplementedError异常。到目前为止一切顺利;代码完美运行,现在我们可以开始为这些方法中的每一个添加实现。

获取今天的天气预报

核心功能已经就位,应用程序的入口点和参数解析器将为我们的应用程序的用户带来更好的体验。现在,终于到了我们一直在等待的时间,开始实现解析器的时间。我们将开始实现获取今天的天气预报的方法。

由于我在瑞典,我将使用区号SWXX2372:1:SW(瑞典斯德哥尔摩);但是,您可以使用任何您想要的区号。要获取您选择的区号,请转到weather.com并搜索您想要的区域。选择区域后,将显示当天的天气预报。请注意,URL 会更改,例如,搜索瑞典斯德哥尔摩时,URL 会更改为:

weather.com/weather/today/l/SWXX2372:1:SW

对于巴西圣保罗,将是:

weather.com/weather/today/l/BRXX0232:1:BR

请注意,URL 只有一个部分会更改,这就是我们要作为参数传递给我们的应用程序的区号。

添加辅助方法

首先,我们需要导入一些包:

import re

from weatherterm.core import Forecast
from weatherterm.core import Request
from weatherterm.core import Unit
from weatherterm.core import UnitConverter

在初始化程序中,我们将添加以下代码:

self._base_url = 'http://weather.com/weather/{forecast}/l/{area}'
self._request = Request(self._base_url)

self._temp_regex = re.compile('([0-9]+)\D{,2}([0-9]+)')
self._only_digits_regex = re.compile('[0-9]+')

self._unit_converter = UnitConverter(Unit.FAHRENHEIT)

在初始化程序中,我们定义了要使用的 URL 模板,以执行对天气网站的请求;然后,我们创建了一个Request对象。这是将代表我们执行请求的对象。

只有在解析今天的天气预报温度时才使用正则表达式。

我们还定义了一个UnitConverter对象,并将默认单位设置为华氏度

现在,我们准备开始添加两个方法,这两个方法将负责实际搜索某个类中的 HTML 元素并返回其内容。第一个方法称为_get_data

def _get_data(self, container, search_items):
    scraped_data = {}

    for key, value in search_items.items():
        result = container.find(value, class_=key)

        data = None if result is None else result.get_text()

        if data is not None:
            scraped_data[key] = data

    return scraped_data

这种方法的想法是在匹配某些条件的容器中搜索项目。container只是 HTML 中的 DOM 元素,而search_items是一个字典,其中键是 CSS 类,值是 HTML 元素的类型。它可以是 DIV、SPAN 或您希望获取值的任何内容。

它开始循环遍历search_items.items(),并使用 find 方法在容器中查找元素。如果找到该项,我们使用get_text提取 DOM 元素的文本,并将其添加到一个字典中,当没有更多项目可搜索时将返回该字典。

我们将实现的第二个方法是_parser方法。这将使用我们刚刚实现的_get_data

def _parse(self, container, criteria):
    results = [self._get_data(item, criteria)
               for item in container.children]

    return [result for result in results if result]

在这里,我们还会得到一个containercriteria,就像_get_data方法一样。容器是一个 DOM 元素,标准是我们要查找的节点的字典。第一个推导式获取所有容器的子元素,并将它们传递给刚刚实现的_get_data方法。

结果将是一个包含所有已找到项目的字典列表,我们只会返回不为空的字典。

我们还需要实现另外两个辅助方法,以便获取今天的天气预报。让我们实现一个名为_clear_str_number的方法:

def _clear_str_number(self, str_number):
    result = self._only_digits_regex.match(str_number)
    return '--' if result is None else result.group()

这种方法将使用正则表达式确保只返回数字。

还需要实现的最后一个方法是 _get_additional_info 方法:

def _get_additional_info(self, content):
    data = tuple(item.td.span.get_text()
                 for item in content.table.tbody.children)
    return data[:2]

这个方法循环遍历表格行,获取每个单元格的文本。这个推导式将返回有关天气的大量信息,但我们只对前 2 个感兴趣,即风和湿度。

实施今天的天气预报

现在是时候开始添加 _today_forecast 方法的实现了,但首先,我们需要导入 BeautifulSoup。在文件顶部添加以下导入语句:

from bs4 import BeautifulSoup

现在,我们可以开始添加 _today_forecast 方法:

def _today_forecast(self, args):
    criteria = {
        'today_nowcard-temp': 'div',
        'today_nowcard-phrase': 'div',
        'today_nowcard-hilo': 'div',
        }

    content = self._request.fetch_data(args.forecast_option.value,
                                       args.area_code)

    bs = BeautifulSoup(content, 'html.parser')

    container = bs.find('section', class_='today_nowcard-container')

    weather_conditions = self._parse(container, criteria)

    if len(weather_conditions) < 1:
        raise Exception('Could not parse weather foreecast for 
        today.')

    weatherinfo = weather_conditions[0]

    temp_regex = re.compile(('H\s+(\d+|\-{,2}).+'
                             'L\s+(\d+|\-{,2})'))
    temp_info = temp_regex.search(weatherinfo['today_nowcard-hilo'])
    high_temp, low_temp = temp_info.groups()

    side = container.find('div', class_='today_nowcard-sidecar')
    humidity, wind = self._get_additional_info(side)

    curr_temp = self._clear_str_number(weatherinfo['today_nowcard- 
    temp'])

    self._unit_converter.dest_unit = args.unit

    td_forecast = Forecast(self._unit_converter.convert(curr_temp),
                           humidity,
                           wind,
                           high_temp=self._unit_converter.convert(
                               high_temp),
                           low_temp=self._unit_converter.convert(
                               low_temp),
                           description=weatherinfo['today_nowcard-
                            phrase'])

    return [td_forecast]

这是在命令行上使用-td 或--today 标志时将被调用的函数。让我们分解这段代码,以便我们可以轻松理解它的作用。理解这个方法很重要,因为这些方法解析了与此非常相似的其他天气预报选项(五天、十天和周末)的数据。

这个方法的签名非常简单;它只获取args,这是在__main__ 方法中创建的Argument 对象。在这个方法中,我们首先创建一个包含我们想要在标记中找到的所有 DOM 元素的criteria 字典:

criteria = {
    'today_nowcard-temp': 'div',
    'today_nowcard-phrase': 'div',
    'today_nowcard-hilo': 'div',
}

如前所述,criteria 字典的关键是 DOM 元素的 CSS 类的名称,值是 HTML 元素的类型:

  • today_nowcard-temp 类是包含当前温度的 DOM 元素的 CSS 类

  • today_nowcard-phrase 类是包含天气条件文本(多云,晴天等)的 DOM 元素的 CSS 类

  • today_nowcard-hilo 类是包含最高和最低温度的 DOM 元素的 CSS 类

接下来,我们将获取、创建和使用BeautifulSoup 来解析 DOM:

content = self._request.fetch_data(args.forecast_option.value, 
                                   args.area_code)

bs = BeautifulSoup(content, 'html.parser')

container = bs.find('section', class_='today_nowcard-container')

weather_conditions = self._parse(container, criteria)

if len(weather_conditions) < 1:
    raise Exception('Could not parse weather forecast for today.')

weatherinfo = weather_conditions[0]

首先,我们利用我们在核心模块上创建的Request 类的fetch_data 方法,并传递两个参数;第一个是预报选项,第二个参数是我们在命令行上传递的地区代码。

获取数据后,我们创建一个BeautifulSoup 对象,传递content和一个parser。因为我们得到的是 HTML,所以我们使用html.parser

现在是开始寻找我们感兴趣的 HTML 元素的时候了。记住,我们需要找到一个容器元素,_parser 函数将搜索子元素并尝试找到我们在字典条件中定义的项目。对于今天的天气预报,包含我们需要的所有数据的元素是一个带有 today_nowcard-container CSS 类的section 元素。

BeautifulSoup 包含了 find 方法,我们可以使用它来查找具有特定条件的 HTML DOM 中的元素。请注意,关键字参数称为class_ 而不是class,因为class 在 Python 中是一个保留字。

现在我们有了容器元素,我们可以将其传递给_parse 方法,它将返回一个列表。我们检查结果列表是否至少包含一个元素,并在为空时引发异常。如果不为空,我们只需获取第一个元素并将其分配给weatherinfo 变量。weatherinfo 变量现在包含了我们正在寻找的所有项目的字典。

下一步是分割最高和最低温度:

temp_regex = re.compile(('H\s+(\d+|\-{,2}).+'
                         'L\s+(\d+|\-{,2})'))
temp_info = temp_regex.search(weatherinfo['today_nowcard-hilo'])
high_temp, low_temp = temp_info.groups()

我们想解析从带有 today_nowcard-hilo CSS 类的 DOM 元素中提取的文本,文本应该看起来像 H 50 L 60H -- L 60 等。提取我们想要的文本的一种简单方法是使用正则表达式:

H\s+(\d+|\-{,2}).L\s+(\d+|\-{,2})

我们可以将这个正则表达式分成两部分。首先,我们想要得到最高温度—H\s+(\d+|\-{,2});这意味着它将匹配一个H后面跟着一些空格,然后它将分组一个匹配数字或最多两个破折号的值。之后,它将匹配任何字符。最后,第二部分基本上做了相同的事情;不过,它开始匹配一个L

执行搜索方法后,调用groups()函数返回了正则表达式组,这种情况下将返回两个组,一个是最高温度,另一个是最低温度。

我们想要向用户提供的其他信息是关于风和湿度的信息。包含这些信息的容器元素具有一个名为today_nowcard-sidecar的 CSS 类:

side = container.find('div', class_='today_nowcard-sidecar')
wind, humidity = self._get_additional_info(side)

我们只需找到容器并将其传递给_get_additional_info方法,该方法将循环遍历容器的子元素,提取文本,最后为我们返回结果。

最后,这个方法的最后一部分:

curr_temp = self._clear_str_number(weatherinfo['today_nowcard-temp'])

self._unit_converter.dest_unit = args.unit

td_forecast = Forecast(self._unit_converter.convert(curr_temp),
                       humidity,
                       wind,
                       high_temp=self._unit_converter.convert(
                           high_temp),
                       low_temp=self._unit_converter.convert(
                           low_temp),
                       description=weatherinfo['today_nowcard- 
                        phrase'])

return [td_forecast]

由于当前温度包含一个我们此时不想要的特殊字符(度数符号),我们使用_clr_str_number方法将weatherinfo字典的today_nowcard-temp项传递给它。

现在我们有了所有需要的信息,我们构建Forecast对象并返回它。请注意,我们在这里返回一个数组;这是因为我们将要实现的所有其他选项(五天、十天和周末天气预报)都将返回一个列表,为了使其一致;也为了在终端上显示这些信息时更方便,我们也返回一个列表。

还要注意的一点是,我们正在使用UnitConverter的转换方法将所有温度转换为命令行中选择的单位。

再次运行命令时:

$ python -m weatherterm -u Fahrenheit -a SWXX2372:1:SW -p WeatherComParser -td

你应该看到类似于这样的输出:

恭喜!你已经实现了你的第一个网络爬虫应用。接下来,让我们添加其他的预报选项。

获取五天和十天的天气预报

我们目前正在从(weather.com)这个网站上爬取天气预报,它也提供了风和湿度的天气预报。

五天和十天,所以在这一部分,我们将实现解析这些预报选项的方法。

呈现五天和十天数据的页面的标记非常相似;它们具有相同的 DOM 结构和共享相同的 CSS 类,这使得我们可以实现只适用于这两个选项的方法。让我们继续并向wheater_com_parser.py文件添加一个新的方法,内容如下:

def _parse_list_forecast(self, content, args):
    criteria = {
        'date-time': 'span',
        'day-detail': 'span',
        'description': 'td',
        'temp': 'td',
        'wind': 'td',
        'humidity': 'td',
    }

    bs = BeautifulSoup(content, 'html.parser')

    forecast_data = bs.find('table', class_='twc-table')
    container = forecast_data.tbody

    return self._parse(container, criteria)

正如我之前提到的,五天和十天的天气预报的 DOM 结构非常相似,因此我们创建了_parse_list_forecast方法,可以用于这两个选项。首先,我们定义了标准:

  • date-time是一个span元素,包含代表星期几的字符串

  • day-detail是一个span元素,包含一个日期的字符串,例如,SEP 29

  • description是一个TD元素,包含天气状况,例如,Cloudy

  • temp是一个TD元素,包含高低温度等温度信息

  • wind是一个TD元素,包含风力信息

  • humidity是一个TD元素,包含湿度信息

现在我们有了标准,我们创建一个BeatufulSoup对象,传递内容和html.parser。我们想要获取的所有数据都在一个名为twc-table的 CSS 类的表格中。我们找到表格并将tbody元素定义为容器。

最后,我们运行_parse方法,传递container和我们定义的criteria。这个函数的返回将看起来像这样:

[{'date-time': 'Today',
  'day-detail': 'SEP 28',
  'description': 'Partly Cloudy',
  'humidity': '78%',
  'temp': '60°50°',
  'wind': 'ESE 10 mph '},
 {'date-time': 'Fri',
  'day-detail': 'SEP 29',
  'description': 'Partly Cloudy',
  'humidity': '79%',
  'temp': '57°48°',
  'wind': 'ESE 10 mph '},
 {'date-time': 'Sat',
  'day-detail': 'SEP 30',
  'description': 'Partly Cloudy',
  'humidity': '77%',
  'temp': '57°49°',
  'wind': 'SE 10 mph '},
 {'date-time': 'Sun',
  'day-detail': 'OCT 1',
  'description': 'Cloudy',
  'humidity': '74%',
  'temp': '55°51°',
  'wind': 'SE 14 mph '},
 {'date-time': 'Mon',
  'day-detail': 'OCT 2',
  'description': 'Rain',
  'humidity': '87%',
  'temp': '55°48°',
  'wind': 'SSE 18 mph '}]

我们需要创建的另一个方法是一个为我们准备数据的方法,例如,解析和转换温度值,并创建一个Forecast对象。添加一个名为_prepare_data的新方法,内容如下:

def _prepare_data(self, results, args):
    forecast_result = []

    self._unit_converter.dest_unit = args.unit

    for item in results:
        match = self._temp_regex.search(item['temp'])
        if match is not None:
            high_temp, low_temp = match.groups()

        try:
            dateinfo = item['weather-cell']
            date_time, day_detail = dateinfo[:3], dateinfo[3:]
            item['date-time'] = date_time
            item['day-detail'] = day_detail
        except KeyError:
            pass

        day_forecast = Forecast(
            self._unit_converter.convert(item['temp']),
            item['humidity'],
            item['wind'],
            high_temp=self._unit_converter.convert(high_temp),
            low_temp=self._unit_converter.convert(low_temp),
            description=item['description'].strip(),
            forecast_date=f'{item["date-time"]} {item["day-
             detail"]}',
            forecast_type=self._forecast_type)
        forecast_result.append(day_forecast)

    return forecast_result

这个方法非常简单。首先,循环遍历结果,并应用我们创建的正则表达式来分割存储在item['temp']中的高温和低温。如果匹配成功,它将获取组并将值分配给high_templow_temp

之后,我们创建一个Forecast对象,并将其附加到稍后将返回的列表中。

最后,我们添加一个在使用-5d-10d标志时将被调用的方法。创建另一个名为_five_and_ten_days_forecast的方法,内容如下:

def _five_and_ten_days_forecast(self, args):
    content = self._request.fetch_data(args.forecast_option.value, 
    args.area_code)
    results = self._parse_list_forecast(content, args)
    return self._prepare_data(results)

这个方法只获取页面的内容,传递forecast_option值和区域代码,因此可以构建 URL 来执行请求。当数据返回时,我们将其传递给_parse_list_forecast,它将返回一个Forecast对象的列表(每天一个);最后,我们使用_prepare_data方法准备要返回的数据。

在运行命令之前,我们需要在我们实现的命令行工具中启用此选项;转到__main__.py文件,并在-td标志的定义之后,添加以下代码:

argparser.add_argument('-5d', '--fivedays',
                       dest='forecast_option',
                       action='store_const',
                       const=ForecastType.FIVEDAYS,
                       help='Shows the weather forecast for the next         
                       5 days')

现在,再次运行应用程序,但这次使用-5d--fivedays标志:

$ python -m weatherterm -u Fahrenheit -a SWXX2372:1:SW -p WeatherComParser -5d

它将产生以下输出:

>> [Today SEP 28]
 High 60° / Low 50° (Partly Cloudy)
 Wind: ESE 10 mph / Humidity: 78%

>> [Fri SEP 29]
 High 57° / Low 48° (Partly Cloudy)
 Wind: ESE 10 mph / Humidity: 79%

>> [Sat SEP 30]
 High 57° / Low 49° (Partly Cloudy)
 Wind: SE 10 mph / Humidity: 77%

>> [Sun OCT 1]
 High 55° / Low 51° (Cloudy)
 Wind: SE 14 mph / Humidity: 74%

>> [Mon OCT 2]
 High 55° / Low 48° (Rain)
 Wind: SSE 18 mph / Humidity: 87%

为了结束本节,让我们在__main__.py文件中添加一个选项,以便获取未来十天的天气预报,就在-5d标志定义的下面。添加以下代码:

argparser.add_argument('-10d', '--tendays',
                       dest='forecast_option',
                       action='store_const',
                       const=ForecastType.TENDAYS,
                       help='Shows the weather forecast for the next  
                       10 days')

如果您运行与获取五天预报相同的命令,但将-5d标志替换为-10d,如下所示:

$ python -m weatherterm -u Fahrenheit -a SWXX2372:1:SW -p WeatherComParser -10d

您应该看到十天的天气预报输出:

>> [Today SEP 28]
 High 60° / Low 50° (Partly Cloudy)
 Wind: ESE 10 mph / Humidity: 78%

>> [Fri SEP 29]
 High 57° / Low 48° (Partly Cloudy)
 Wind: ESE 10 mph / Humidity: 79%

>> [Sat SEP 30]
 High 57° / Low 49° (Partly Cloudy)
 Wind: SE 10 mph / Humidity: 77%

>> [Sun OCT 1]
 High 55° / Low 51° (Cloudy)
 Wind: SE 14 mph / Humidity: 74%

>> [Mon OCT 2]
 High 55° / Low 48° (Rain)
 Wind: SSE 18 mph / Humidity: 87%

>> [Tue OCT 3]
 High 56° / Low 46° (AM Clouds/PM Sun)
 Wind: S 10 mph / Humidity: 84%

>> [Wed OCT 4]
 High 58° / Low 47° (Partly Cloudy)
 Wind: SE 9 mph / Humidity: 80%

>> [Thu OCT 5]
 High 57° / Low 46° (Showers)
 Wind: SSW 8 mph / Humidity: 81%

>> [Fri OCT 6]
 High 57° / Low 46° (Partly Cloudy)
 Wind: SW 8 mph / Humidity: 76%

>> [Sat OCT 7]
 High 56° / Low 44° (Mostly Sunny)
 Wind: W 7 mph / Humidity: 80%

>> [Sun OCT 8]
 High 56° / Low 44° (Partly Cloudy)
 Wind: NNE 7 mph / Humidity: 78%

>> [Mon OCT 9]
 High 56° / Low 43° (AM Showers)
 Wind: SSW 9 mph / Humidity: 79%

>> [Tue OCT 10]
 High 55° / Low 44° (AM Showers)
 Wind: W 8 mph / Humidity: 79%

>> [Wed OCT 11]
 High 55° / Low 42° (AM Showers)
 Wind: SE 7 mph / Humidity: 79%

>> [Thu OCT 12]
 High 53° / Low 43° (AM Showers)
 Wind: NNW 8 mph / Humidity: 87%

如您所见,我在瑞典写这本书时天气并不是很好。

获取周末天气预报

我们将在我们的应用程序中实现的最后一个天气预报选项是获取即将到来的周末天气预报的选项。这个实现与其他实现有些不同,因为周末天气返回的数据与今天、五天和十天的天气预报略有不同。

DOM 结构不同,一些 CSS 类名也不同。如果您还记得我们之前实现的方法,我们总是使用_parser方法,该方法为我们提供容器 DOM 和带有搜索条件的字典作为参数。该方法的返回值也是一个字典,其中键是我们正在搜索的 DOM 的类名,值是该 DOM 元素中的文本。

由于周末页面的 CSS 类名不同,我们需要实现一些代码来获取结果数组并重命名所有键,以便_prepare_data函数可以正确使用抓取的结果。

说到这一点,让我们继续在weatherterm/core目录中创建一个名为mapper.py的新文件,内容如下:

class Mapper:

    def __init__(self):
        self._mapping = {}

    def _add(self, source, dest):
        self._mapping[source] = dest

    def remap_key(self, source, dest):
        self._add(source, dest)

    def remap(self, itemslist):
        return [self._exec(item) for item in itemslist]

    def _exec(self, src_dict):
        dest = dict()

        if not src_dict:
            raise AttributeError('The source dictionary cannot be  
            empty or None')

        for key, value in src_dict.items():
            try:
                new_key = self._mapping[key]
                dest[new_key] = value
            except KeyError:
                dest[key] = value
        return dest

Mapper类获取一个包含字典的列表,并重命名我们想要重命名的特定键。这里的重要方法是remap_keyremapremap_key接收两个参数,sourcedestsource是我们希望重命名的键,dest是该键的新名称。remap_key方法将其添加到一个名为_mapping的内部字典中,以便以后查找新的键名。

remap方法只是获取包含字典的列表,并对该列表中的每个项目调用_exec方法,该方法首先创建一个全新的字典,然后检查字典是否为空。在这种情况下,它会引发AttributeError

如果字典有键,我们循环遍历其项,搜索当前项的键是否在映射字典中具有新名称。如果找到新的键名,将创建一个具有新键名的新项;否则,我们只保留旧名称。循环结束后,返回包含所有具有新名称键的字典的列表。

现在,我们只需要将其添加到weatherterm/core目录中的__init__.py文件中:

from .mapper import Mapper

而且,在weatherterm/parsers目录中的weather_com_parser.py文件中,我们需要导入Mapper

from weatherterm.core import Mapper

有了映射器,我们可以继续在weather_com_parser.py文件中创建_weekend_forecast方法,如下所示:

def _weekend_forecast(self, args):
    criteria = {
        'weather-cell': 'header',
        'temp': 'p',
        'weather-phrase': 'h3',
        'wind-conditions': 'p',
        'humidity': 'p',
    }

    mapper = Mapper()
    mapper.remap_key('wind-conditions', 'wind')
    mapper.remap_key('weather-phrase', 'description')

    content = self._request.fetch_data(args.forecast_option.value,
                                       args.area_code)

    bs = BeautifulSoup(content, 'html.parser')

    forecast_data = bs.find('article', class_='ls-mod')
    container = forecast_data.div.div

    partial_results = self._parse(container, criteria)
    results = mapper.remap(partial_results)

    return self._prepare_data(results, args)

该方法首先通过以与其他方法完全相同的方式定义标准来开始;但是,DOM 结构略有不同,一些 CSS 名称也不同:

  • weather-cell:包含预报日期:FriSEP 29

  • temp:包含温度(高和低):57°F48°F

  • weather-phrase:包含天气条件:多云

  • wind-conditions:风信息

  • humidity:湿度百分比

正如你所看到的,为了使其与_prepare_data方法很好地配合,我们需要重命名结果集中字典中的一些键——wind-conditions应该是windweather-phrase应该是description

幸运的是,我们引入了Mapper类来帮助我们:

mapper = Mapper()
mapper.remap_key('wind-conditions', 'wind')
mapper.remap_key('weather-phrase', 'description')

我们创建一个Mapper对象并说,将wind-conditions重新映射为wind,将weather-phrase重新映射为description

content = self._request.fetch_data(args.forecast_option.value,
                                   args.area_code)

bs = BeautifulSoup(content, 'html.parser')

forecast_data = bs.find('article', class_='ls-mod')
container = forecast_data.div.div

partial_results = self._parse(container, criteria)

我们获取所有数据,使用html.parser创建一个BeautifulSoup对象,并找到包含我们感兴趣的子元素的容器元素。对于周末预报,我们有兴趣获取具有名为ls-mod的 CSS 类的article元素,并在article中向下移动到第一个子元素,这是一个 DIV,并获取其第一个子元素,这也是一个 DIV 元素。

HTML 应该看起来像这样:

<article class='ls-mod'>
  <div>
    <div>
      <!-- this DIV will be our container element -->
    </div>
  </div>
</article>

这就是我们首先找到文章,将其分配给forecast_data,然后使用forecast_data.div.div,这样我们就可以得到我们想要的 DIV 元素。

在定义容器之后,我们将其与容器元素一起传递给_parse方法;当我们收到结果时,我们只需要运行Mapper实例的remap方法,它将在我们调用_prepare_data之前为我们规范化数据。

现在,在运行应用程序并获取周末天气预报之前的最后一个细节是,我们需要将--w--weekend标志包含到ArgumentParser中。打开weatherterm目录中的__main__.py文件,并在--tenday标志的下方添加以下代码:

argparser.add_argument('-w', '--weekend',
                       dest='forecast_option',
                       action='store_const',
                       const=ForecastType.WEEKEND,
                       help=('Shows the weather forecast for the 
                             next or '
                             'current weekend'))

太好了!现在,使用-w--weekend标志运行应用程序:

>> [Fri SEP 29]
 High 13.9° / Low 8.9° (Partly Cloudy)
 Wind: ESE 10 mph / Humidity: 79%

>> [Sat SEP 30]
 High 13.9° / Low 9.4° (Partly Cloudy)
 Wind: SE 10 mph / Humidity: 77%

>> [Sun OCT 1]
 High 12.8° / Low 10.6° (Cloudy)
 Wind: SE 14 mph / Humidity: 74%

请注意,这次我使用了-u标志来选择摄氏度。输出中的所有温度都以摄氏度表示,而不是华氏度。

总结

在本章中,您学习了 Python 中面向对象编程的基础知识;我们介绍了如何创建类,使用继承,并使用@property装饰器创建 getter 和 setter。

我们介绍了如何使用 inspect 模块来获取有关模块、类和函数的更多信息。最后但并非最不重要的是,我们利用了强大的Beautifulsoup包来解析 HTML 和Selenium来向天气网站发出请求。

我们还学习了如何使用 Python 标准库中的argparse模块实现命令行工具,这使我们能够提供更易于使用且具有非常有用的文档的工具。

接下来,我们将开发一个小包装器,围绕 Spotify Rest API,并使用它来创建一个远程控制终端。

第二章:使用 Spotify 创建远程控制应用程序

Spotify 是一家总部位于瑞典斯德哥尔摩的音乐流媒体服务。第一个版本于 2008 年发布,如今它不仅提供音乐,还提供视频和播客。Spotify 从瑞典的初创公司迅速发展成为世界上最大的音乐服务,其应用程序在视频游戏机和手机上运行,并与许多社交网络集成。

该公司确实改变了我们消费音乐的方式,也使得不仅是知名艺术家,而且小型独立艺术家也能与世界分享他们的音乐。

幸运的是,Spotify 也是开发人员的绝佳平台,并提供了一个非常好的和有文档的 REST API,可以通过艺术家、专辑、歌曲名称进行搜索,还可以创建和分享播放列表。

在本书的第二个应用程序中,我们将开发一个终端应用程序,其中我们可以:

  • 搜索艺术家

  • 搜索专辑

  • 搜索曲目

  • 播放音乐

除了所有这些功能之外,我们将实现一些函数,以便通过终端控制 Spotify 应用程序。

首先,我们将经历在 Spotify 上创建新应用程序的过程;然后,将是开发一个小框架的时间,该框架将包装 Spotify 的 REST API 的某些部分。我们还将致力于实现 Spotify 支持的不同类型的身份验证,以便消耗其 REST API。

当所有这些核心功能都就位后,我们将使用 Python 附带的curses软件包来开发终端用户界面。

在本章中,您将学习:

  • 如何创建Spotify应用程序

  • 如何使用OAuth

  • 面向对象的编程概念

  • 使用流行的Requests软件包来消耗 REST API

  • 使用 curses 设计终端用户界面的方法

我不知道你们,但我真的很想写代码并听一些好听的音乐,所以让我们开始吧!

设置环境

让我们继续配置我们的开发环境。我们需要做的第一件事是创建一个新的虚拟环境,这样我们就可以工作并安装我们需要的软件包,而不会干扰全局 Python 安装。

我们的应用程序将被称为musicterminal,因此我们可以创建一个同名的虚拟环境。

要创建一个新的虚拟环境,请运行以下命令:

$ python3 -m venv musicterminal

确保您使用的是 Python 3.6 或更高版本,否则本书中的应用程序可能无法正常工作。

要激活虚拟环境,可以运行以下命令:

$ . musicterminal/bin/activate

太好了!现在我们已经设置好了虚拟环境,我们可以创建项目的目录结构。它应该具有以下结构:

musicterminal
├── client
├── pytify
│   ├── auth
│   └── core
└── templates

与第一章中的应用程序一样,我们创建一个项目目录(这里称为musicterminal)和一个名为pytify的子目录,其中将包含包装 Spotify 的 REST API 的框架。

在框架目录中,我们将auth拆分为两个模块,这两个模块将包含 Spotify 支持的两种身份验证流程的实现——授权代码和客户端凭据。最后,core模块将包含从 REST API 获取数据的所有方法。

客户端目录将包含与我们将构建的客户端应用程序相关的所有脚本。

最后,templates目录将包含一些 HTML 文件,这些文件将在我们构建一个小的 Flask 应用程序来执行 Spotify 身份验证时使用。

现在,让我们在musicterminal目录中创建一个requirements.txt文件,内容如下:

requests==2.18.4
PyYAML==3.12

要安装依赖项,只需运行以下命令:

$ pip install -r requirements.txt

如您在输出中所见,其他软件包已安装在我们的虚拟环境中。这是因为我们项目所需的软件包也需要其他软件包,因此它们也将被安装。

Requests 是由 Kenneth Reitz 创建的www.kennethreitz.org/,它是 Python 生态系统中使用最广泛且备受喜爱的软件包之一。它被微软、谷歌、Mozilla、Spotify、Twitter 和索尼等大公司使用,它是 Pythonic 且非常直观易用的。

查看 Kenneth 的其他项目,尤其是pipenv项目,这是一个很棒的 Python 打包工具。

我们将使用的另一个模块是 curses。curses 模块只是 curses C 函数的包装器,相对于在 C 中编程,它相对简单。如果您之前使用过 curses C 库,那么 Python 中的 curses 模块应该是熟悉且易于学习的。

需要注意的一点是,Python 在 Linux 和 Mac 上包含 curses 模块;但是,在 Windows 上,默认情况下不包含它。如果您使用 Windows,curses 文档在docs.python.org/3/howto/curses.html上推荐由 Fredrik Lundh 开发的 UniCurses 包。

在我们开始编码之前,还有一件事。在尝试导入 curses 时,您可能会遇到问题;最常见的原因是您的系统中未安装libncurses。在安装 Python 之前,请确保您的系统上已安装libncurseslibncurses-dev

如果您使用 Linux,您很可能会在我们首选发行版的软件包存储库中找到libncurses。在 Debian/Ubuntu 中,您可以使用以下命令安装它:

$ sudo apt-get install libncurses5 libncurses5-dev

太好了!现在,我们已经准备好开始实施我们的应用程序了。

创建 Spotify 应用程序

我们需要做的第一件事是创建一个 Spotify 应用程序;之后,我们将获取访问密钥,以便我们可以进行身份验证并使用 REST API。

前往beta.developer.spotify.com/dashboard/,在页面下方您可以找到登录按钮,如果您没有帐户,可以创建一个新帐户。

在撰写本文时,Spotify 开始更改其开发者网站,并且目前处于测试阶段,因此登录地址和一些截图可能会有所不同。

如果您没有 Spotify 帐户,您首先需要创建一个。如果您注册免费帐户,应该能够创建应用程序,但我建议您注册高级帐户,因为它是一个拥有丰富音乐目录的优秀服务。

当您登录 Spotify 开发者网站时,您将看到类似以下页面:

目前,我们还没有创建任何应用程序(除非您已经创建了一个),所以继续点击“CREATE AN APP”按钮。将显示一个对话框屏幕来创建应用程序:

在这里,我们有三个必填字段:应用程序名称、描述,以及一些复选框,您需要告诉 Spotify 您正在构建什么。名称应该是pytify,在描述中,您可以随意填写,但让我们添加类似“用于从终端控制 Spotify 客户端的应用程序”的内容。我们正在构建的应用程序类型将是网站。

完成后,点击对话框屏幕底部的“NEXT”按钮。

应用程序创建过程的第二步是告知 Spotify 您是否正在创建商业集成。对于本书的目的,我们将选择NO;但是,如果您要创建一个将实现货币化的应用程序,您应该选择YES

在下一步中,将显示以下对话框:

如果您同意所有条件,只需选择所有复选框,然后点击“SUBMIT”按钮。

如果应用程序已成功创建,您将被重定向到应用程序的页面,如下所示:

单击“显示客户端密钥”链接,并复制客户端 ID 和客户端密钥的值。我们将需要这些密钥来使用 Spotify 的 REST API。

应用程序的配置

为了使应用程序更灵活且易于配置,我们将创建一个配置文件。这样,我们就不需要硬编码 URL 和访问密钥;而且,如果需要更改这些设置,也不需要更改源代码。

我们将创建一个 YAML 格式的配置文件,用于存储我们的应用程序用于认证、向 Spotify RESP API 端点发出请求等的信息。

创建配置文件

让我们继续在musicterminal目录中创建一个名为config.yaml的文件,内容如下:

client_id: '<your client ID>'
client_secret: '<your client secret>'
access_token_url: 'https://accounts.spotify.com/api/token'
auth_url: 'http://accounts.spotify.com/authorize'
api_version: 'v1'
api_url: 'https://api.spotify.com'
auth_method: 'AUTHORIZATION_CODE'

client_idclient_secret是我们创建 Spotify 应用程序时为我们创建的密钥。这些密钥将用于获取访问令牌,每次我们需要向 Spotify 的 REST API 发送新请求时都必须获取访问令牌。只需用您自己的密钥替换<your client ID><your client secret>

请记住,这些密钥必须保存在安全的地方。不要与任何人分享密钥,如果您在 GitHub 等网站上有项目,请确保不要提交带有您的秘密密钥的配置文件。我通常会将配置文件添加到我的.gitignore文件中,这样它就不会被源代码控制;否则,您可以像我一样提交文件,使用占位符而不是实际密钥。这样,就很容易记住您需要在哪里添加密钥。

client_idclient_secret键之后,我们有access_token_url。这是我们必须执行请求的 API 端点的 URL,以便获取访问令牌。

auth_url是 Spotify 的账户服务的端点;当我们需要获取或刷新授权令牌时,我们将使用它。

api_version,顾名思义,指定了 Spotify 的 REST API 版本。在执行请求时,这将附加到 URL 上。

最后,我们有api_url,这是 Spotify 的 REST API 端点的基本 URL。

实现配置文件读取器

在实现读取器之前,我们将添加一个枚举,表示 Spotify 提供给我们的两种认证流程。让我们继续在musicterminal/pytify/auth目录中创建一个名为auth_method.py的文件,内容如下:

from enum import Enum, auto

class AuthMethod(Enum):
    CLIENT_CREDENTIALS = auto()
    AUTHORIZATION_CODE = auto()

这将定义一个枚举,具有CLIENT_CREDENTIALSAUTHORIZATION_CODE属性。现在,我们可以在配置文件中使用这些值。我们还需要做的另一件事是在musicterminal/pytify/auth目录中创建一个名为__init__.py的文件,并导入我们刚刚创建的枚举:

from .auth_method import AuthMethod

现在,我们可以继续创建将为我们读取配置的函数。在musicterminal/pytify/core目录中创建一个名为config.py的文件,然后让我们开始添加一些导入语句:

import os
import yaml
from collections import namedtuple

from pytify.auth import AuthMethod

首先,我们导入os模块,这样我们就可以访问一些函数,这些函数将帮助我们构建 YAML 配置文件所在的路径。我们还导入yaml包来读取配置文件,最后,我们从 collections 模块导入namedtuple。稍后我们将更详细地讨论namedtuple的作用。

我们最后导入的是我们刚刚在pytify.auth模块中创建的AuthMethod枚举。

现在,我们需要一个表示配置文件的模型,因此我们创建一个名为Config的命名元组,如下所示:

Config = namedtuple('Config', ['client_id',
                               'client_secret',
                               'access_token_url',
                               'auth_url',
                               'api_version',
                               'api_url',
                               'base_url',
                               'auth_method', ])

namedtuple不是 Python 中的新功能,自 2.6 版本以来一直存在。namedtuple是类似元组的对象,具有名称,并且可以通过属性查找访问字段。可以以两种不同的方式创建namedtuple;让我们开始 Python REPL 并尝试一下:

>>> from collections import namedtuple
>>> User = namedtuple('User', ['firstname', 'lastname', 'email'])
>>> u = User('Daniel','Furtado', 'myemail@test.com')
User(firstname='Daniel', lastname='Furtado', email='myemail@test.com')
>>>

此结构有两个参数;第一个参数是namedtuple的名称,第二个是表示namedtuple中每个字段的str元素数组。还可以通过传递一个由空格分隔的每个字段名的字符串来指定namedtuple的字段,例如:

>>> from collections import namedtuple
>>> User = namedtuple('User', 'firstname lastname email')
>>> u = User('Daniel', 'Furtado', 'myemail@test.com')
>>> print(u)
User(firstname='Daniel', lastname='Furtado', email='myemail@test.com')

namedtuple构造函数还有两个关键字参数:

Verbose,当设置为True时,在终端上显示定义namedtuple的类。在幕后,namedtuple是类,verbose关键字参数让我们一睹namedtuple类的构造方式。让我们在 REPL 上实践一下:

>>> from collections import namedtuple
>>> User = namedtuple('User', 'firstname lastname email', verbose=True)
from builtins import property as _property, tuple as _tuple
from operator import itemgetter as _itemgetter
from collections import OrderedDict

class User(tuple):
    'User(firstname, lastname, email)'

    __slots__ = ()

    _fields = ('firstname', 'lastname', 'email')

    def __new__(_cls, firstname, lastname, email):
        'Create new instance of User(firstname, lastname, email)'
        return _tuple.__new__(_cls, (firstname, lastname, email))

    @classmethod
    def _make(cls, iterable, new=tuple.__new__, len=len):
        'Make a new User object from a sequence or iterable'
        result = new(cls, iterable)
        if len(result) != 3:
            raise TypeError('Expected 3 arguments, got %d' % 
            len(result))
        return result

    def _replace(_self, **kwds):
        'Return a new User object replacing specified fields with  
         new values'
        result = _self._make(map(kwds.pop, ('firstname', 'lastname',  
                             'email'), _self))
        if kwds:
            raise ValueError('Got unexpected field names: %r' %  
                              list(kwds))
        return result

    def __repr__(self):
        'Return a nicely formatted representation string'
        return self.__class__.__name__ + '(firstname=%r,  
                                           lastname=%r, email=%r)' 
        % self

    def _asdict(self):
        'Return a new OrderedDict which maps field names to their  
          values.'
        return OrderedDict(zip(self._fields, self))

    def __getnewargs__(self):
        'Return self as a plain tuple. Used by copy and pickle.'
        return tuple(self)

    firstname = _property(_itemgetter(0), doc='Alias for field  
                          number 0')

    lastname = _property(_itemgetter(1), doc='Alias for field number  
                         1')

    email = _property(_itemgetter(2), doc='Alias for field number  
                      2')

另一个关键字参数是rename,它将重命名namedtuple中具有不正确命名的每个属性,例如:

>>> from collections import namedtuple
>>> User = namedtuple('User', 'firstname lastname email 23445', rename=True)
>>> User._fields
('firstname', 'lastname', 'email', '_3')

如您所见,字段23445已自动重命名为_3,这是字段位置。

要访问namedtuple字段,可以使用与访问类中的属性相同的语法,使用namedtuple——User,如前面的示例所示。如果我们想要访问lastname属性,只需写u.lastname

现在我们有了代表我们配置文件的namedtuple,是时候添加执行加载 YAML 文件并返回namedtuple——Config的工作的函数了。在同一个文件中,让我们实现read_config函数如下:

def read_config():
    current_dir = os.path.abspath(os.curdir)
    file_path = os.path.join(current_dir, 'config.yaml')

    try:
        with open(file_path, mode='r', encoding='UTF-8') as file:
            config = yaml.load(file)

            config['base_url'] = 
 f'{config["api_url"]}/{config["api_version"]}'    auth_method = config['auth_method']
            config['auth_method'] = 
            AuthMethod.__members__.get(auth_method)

            return Config(**config)

    except IOError as e:
        print(""" Error: couldn''t file the configuration file 
        `config.yaml`
 'on your current directory.   Default format is:',   client_id: 'your_client_id' client_secret: 'you_client_secret' access_token_url: 'https://accounts.spotify.com/api/token' auth_url: 'http://accounts.spotify.com/authorize' api_version: 'v1' api_url: 'http//api.spotify.com' auth_method: 'authentication method'   * auth_method can be CLIENT_CREDENTIALS or  
          AUTHORIZATION_CODE""")
        raise   

read_config函数首先使用os.path.abspath函数获取当前目录的绝对路径,并将其赋给current_dir变量。然后,我们将存储在current_dir变量上的路径与文件名结合起来,即 YAML 配置文件。

try语句中,我们尝试以只读方式打开文件,并将编码设置为 UTF-8。如果失败,将向用户打印帮助消息,说明无法打开文件,并显示描述 YAML 配置文件结构的帮助。

如果配置文件可以成功读取,我们调用yaml模块中的 load 函数来加载和解析文件,并将结果赋给config变量。我们还在配置中包含了一个额外的项目base_url,它只是一个辅助值,包含了api_urlapi_version的连接值。

base_url的值将如下所示:api.spotify.com/v1.

最后,我们创建了一个Config的实例。请注意我们如何在构造函数中展开值;这是可能的,因为namedtuple——Config具有与yaml.load()返回的对象相同的字段。这与执行以下操作完全相同:

return Config(
    client_id=config['client_id'],
    client_secret=config['client_secret'],
    access_token_url=config['access_token_url'],
    auth_url=config['auth_url'],
    api_version=config['api_version'],
    api_url=config['api_url'],
    base_url=config['base_url'],
    auth_method=config['auth_method'])

最后一步是在pytify/core目录中创建一个__init__.py文件,并导入我们刚刚创建的read_config函数:

from .config import read_config

使用 Spotify 的 Web API 进行身份验证

现在我们已经有了加载配置文件的代码,我们将开始编写框架的认证部分。Spotify 目前支持三种认证方式:授权码、客户端凭据和隐式授权。在本章中,我们将实现授权码和客户端凭据,首先实现客户端凭据流程,这是最容易开始的。

客户端凭据流程与授权码流程相比有一些缺点,因为该流程不包括授权,也无法访问用户的私人数据以及控制播放。我们现在将实现并使用此流程,但在开始实现终端播放器时,我们将改为授权码。

首先,我们将在musicterminal/pytify/auth目录中创建一个名为authorization.py的文件,内容如下:

from collections import namedtuple

Authorization = namedtuple('Authorization', [
    'access_token',
    'token_type',
    'expires_in',
    'scope',
    'refresh_token',
])

这将是认证模型,它将包含我们在请求访问令牌后获得的数据。在下面的列表中,您可以看到每个属性的描述:

  • access_token:必须与每个对 Web API 的请求一起发送的令牌

  • token_type:令牌的类型,通常为Bearer

  • expires_inaccess_token的过期时间,为 3600 秒(1 小时)

  • scope:范围基本上是 Spotify 用户授予我们应用程序的权限

  • refresh_token:在过期后可以用来刷新access_token的令牌

最后一步是在musicterminal/pytify/auth目录中创建一个__init__.py文件,并导入Authorization,这是一个namedtuple

from .authorization import Authorization

实施客户端凭据流

客户端凭据流非常简单。让我们分解一下直到获得access_token的所有步骤:

  1. 我们的应用程序将从 Spotify 帐户服务请求访问令牌;请记住,在我们的配置文件中,有api_access_token。这是我们需要发送请求以获取访问令牌的 URL。我们需要发送请求的三件事是客户端 ID、客户端密钥和授权类型,在这种情况下是client_credentials

  2. Spotify 帐户服务将验证该请求,检查密钥是否与我们在开发者网站注册的应用程序的密钥匹配,并返回一个访问令牌。

  3. 现在,我们的应用程序必须使用此访问令牌才能从 REST API 中获取数据。

  4. Spotify REST API 将返回我们请求的数据。

在开始实现将进行身份验证并获取访问令牌的函数之前,我们可以添加一个自定义异常,如果从 Spotify 帐户服务获得了错误请求(HTTP 400)时,我们将抛出该异常。

让我们在musicterminal/pytify/core目录中创建一个名为exceptions.py的文件,内容如下:

class BadRequestError(Exception):
    pass

这个类并没有做太多事情;我们只是继承自Exception。我们本可以只抛出一个通用异常,但是在开发其他开发人员将使用的框架和库时,最好创建自己的自定义异常,并使用良好的名称和描述。

因此,不要像这样抛出异常:

raise Exception('some message')

我们可以更明确地抛出BadRequestError,如下所示:

raise BadRequestError('some message')

现在,使用此代码的开发人员可以在其代码中正确处理此类异常。

打开musicterminal/pytify/core目录中的__init__.py文件,并添加以下导入语句:

from .exceptions import BadRequestError

太好了!现在是时候在musicterminal/pytify/auth目录中添加一个名为auth.py的新文件了,我们要添加到此文件的第一件事是一些导入:

import requests
import base64
import json

from .authorization import Authorization
from pytify.core import BadRequestError

我通常首先放置来自标准库模块的所有导入,然后是来自我的应用程序文件的函数导入。这不是必需的,但我认为这样可以使代码更清晰、更有组织。这样,我可以轻松地看出哪些是标准库项目,哪些不是。

现在,我们可以开始添加将发送请求到Spotify帐户服务并返回访问令牌的函数。我们要添加的第一个函数称为get_auth_key

def get_auth_key(client_id, client_secret):
    byte_keys = bytes(f'{client_id}:{client_secret}', 'utf-8')
    encoded_key = base64.b64encode(byte_keys)
    return encoded_key.decode('utf-8')

客户端凭据流要求我们发送client_idclient_secret,它必须是 base 64 编码的。首先,我们将字符串转换为client_id:client_secret格式的字节。然后,我们使用 base 64 对其进行编码,然后解码它,返回该编码数据的字符串表示,以便我们可以将其与请求有效负载一起发送。

我们要在同一文件中实现的另一个函数称为_client_credentials

def _client_credentials(conf):

    auth_key = get_auth_key(conf.client_id, conf.client_secret)

    headers = {'Authorization': f'Basic {auth_key}', }

    options = {
        'grant_type': 'client_credentials',
        'json': True,
        }

    response = requests.post(
        'https://accounts.spotify.com/api/token',
        headers=headers,
        data=options
    )

    content = json.loads(response.content.decode('utf-8'))

    if response.status_code == 400:
        error_description = content.get('error_description','')
        raise BadRequestError(error_description)

    access_token = content.get('access_token', None)
    token_type = content.get('token_type', None)
    expires_in = content.get('expires_in', None)
    scope = content.get('scope', None)    

    return Authorization(access_token, token_type, expires_in, 
    scope, None)

这个函数接收配置作为参数,并使用get_auth_key函数传递client_idclient_secret来构建一个 base 64 编码的auth_key。这将被发送到 Spotify 的账户服务以请求access_token

现在,是时候准备请求了。首先,我们在请求头中设置Authorization,值将是Basic字符串后跟auth_key。这个请求的载荷将是grant_type,在这种情况下是client_credentialsjson将设置为True,告诉 API 我们希望以 JSON 格式获取响应。

我们使用 requests 包向 Spotify 的账户服务发出请求,传递我们配置的头部和数据。

当我们收到响应时,我们首先解码并将 JSON 数据加载到变量 content 中。

如果 HTTP 状态码是400 (BAD_REQUEST),我们会引发一个BadRequestError;否则,我们会获取access_tokentoken_typeexpires_inscope的值,最后创建一个Authorization元组并返回它。

请注意,当创建一个Authenticationnamedtuple时,我们将最后一个参数设置为None。这样做的原因是,当身份验证类型为CLIENT_CREDENTIALS时,Spotify 的账户服务不会返回refresh_token

到目前为止,我们创建的所有函数都是私有的,所以我们要添加的最后一个函数是authenticate函数。这是开发人员将调用以开始身份验证过程的函数:

def authenticate(conf):
    return _client_credentials(conf)

这个函数非常直接;函数接收一个Config的实例作为参数,namedtuple,其中包含了从配置文件中读取的所有数据。然后我们将配置传递给_client_credentials函数,该函数将使用客户端凭据流获取access_token

让我们在musicterminal/pytify/auth目录中打开__init__.py文件,并导入authenticateget_auth_key函数:

from .auth import authenticate
from .auth import get_auth_key

很好!让我们在 Python REPL 中尝试一下:

Python 3.6.2 (default, Oct 15 2017, 01:15:28)
[GCC 6.3.0 20170516] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from pytify.core import read_config
>>> from pytify.auth import authenticate
>>> config = read_config()
>>> auth = authenticate(config)
>>> auth
Authorization(access_token='BQDM_DC2HcP9kq5iszgDwhgDvq7zm1TzvzXXyJQwFD7trl0Q48DqoZirCMrMHn2uUml2YnKdHOszAviSFGtE6w', token_type='Bearer', expires_in=3600, scope=None, refresh_token=None)
>>>

正是我们所期望的!下一步是开始创建将消耗 Spotify 的 REST API 的函数。

实现授权码流程

在这一部分,我们将实现授权码流程,这是我们将在客户端中使用的流程。我们需要使用这种身份验证流程,因为我们需要从用户那里获得特殊的访问权限,以便使用我们的应用程序执行某些操作。例如,我们的应用程序将能够向 Spotify 的 Web API 发送请求,在用户的活动设备上播放某个曲目。为了做到这一点,我们需要请求user-modify-playback-state

以下是授权码流程中涉及的步骤:

  1. 我们的应用程序将请求授权以访问数据,并将用户重定向到 Spotify 网页上的登录页面。在那里,用户可以看到应用程序需要的所有访问权限。

  2. 如果用户批准,Spotify 账户服务将向回调 URI 发送一个请求,发送一个代码和状态。

  3. 当我们获得了代码后,我们发送一个新的请求,传递client_idclient_secretgrant_typecode来获取access_token。这一次,它将与客户端凭据流不同;我们将获得scoperefresh_token

  4. 现在,我们可以正常地向 Web API 发送请求,如果访问令牌已过期,我们可以发送另一个请求来刷新访问令牌并继续执行请求。

说到这里,在musicterminal/pytify/auth目录中打开auth.py文件,让我们添加一些更多的函数。首先,我们将添加一个名为_refresh_access_token的函数;你可以在get_auth_key函数之后添加这个函数:

def _refresh_access_token(auth_key, refresh_token):

    headers = {'Authorization': f'Basic {auth_key}', }

    options = {
        'refresh_token': refresh_token,
        'grant_type': 'refresh_token',
        }

    response = requests.post(
        'https://accounts.spotify.com/api/token',
        headers=headers,
        data=options
    )

    content = json.loads(response.content.decode('utf-8'))

    if not response.ok:
        error_description = content.get('error_description', None)
        raise BadRequestError(error_description)

    access_token = content.get('access_token', None)
    token_type = content.get('token_type', None)
    scope = content.get('scope', None)
    expires_in = content.get('expires_in', None)

    return Authorization(access_token, token_type, expires_in, 
    scope, None)

它基本上与处理客户端凭据流的函数做同样的事情,但这次我们发送refresh_tokengrant_type。我们从响应对象中获取数据并创建一个Authorizationnamedtuple

我们接下来要实现的下一个函数将利用标准库的os模块,因此在开始实现之前,我们需要在auth.py文件的顶部添加以下导入语句:

import os

现在,我们可以继续添加一个名为_authorization_code的函数。您可以在get_auth_key函数之后添加此函数,并包含以下内容:

def _authorization_code(conf):

    current_dir = os.path.abspath(os.curdir)
    file_path = os.path.join(current_dir, '.pytify')

    auth_key = get_auth_key(conf.client_id, conf.client_secret)

    try:
        with open(file_path, mode='r', encoding='UTF-8') as file:
            refresh_token = file.readline()

            if refresh_token:
                return _refresh_access_token(auth_key, 
                 refresh_token)

    except IOError:
        raise IOError(('It seems you have not authorize the 
                       application '
                       'yet. The file .pytify was not found.'))

在这里,我们尝试在musicterminal目录中打开一个名为.pytify的文件。这个文件将包含我们将用来刷新access_tokenrefresh_token

从文件中获取refresh_token后,我们将其与auth_key一起传递给_refresh_access_token函数。如果由于某种原因我们无法打开文件或文件不存在于musicterminal目录中,将引发异常。

我们现在需要做的最后修改是在同一文件中的authenticate函数中。我们将为两种身份验证方法添加支持;它应该是这样的:

def authenticate(conf):
    if conf.auth_method == AuthMethod.CLIENT_CREDENTIALS:
        return _client_credentials(conf)

    return _authorization_code(conf)

现在,我们将根据配置文件中的指定开始不同的身份验证方法。

由于身份验证函数引用了AuthMethod,我们需要导入它:

from .auth_method import AuthMethod

在我们尝试这种类型的身份验证之前,我们需要创建一个小型的 Web 应用程序,它将为我们授权我们的应用程序。我们将在下一节中进行这方面的工作。

使用授权码流授权我们的应用程序

为了使我们的 Spotify 终端客户端正常工作,我们需要特殊的访问权限来操作用户的播放。我们通过使用授权码来做到这一点,我们需要专门请求user-modify-playback-state访问权限。

如果您打算为此应用程序添加更多功能,最好从一开始就添加一些其他访问权限;例如,如果您想要能够操作用户的私人和公共播放列表,您可能希望添加playlist-modify-privateplaylist-modify-public范围。

您可能还希望在客户端应用程序上显示用户关注的艺术家列表,因此您还需要将user-follow-read包含在范围内。

对于我们将在客户端应用程序中实现的功能,请求user-modify-playback-state访问权限将足够。

我们的想法是使用授权码流授权我们的应用程序。我们将使用 Flask 框架创建一个简单的 Web 应用程序,该应用程序将定义两个路由。/根将只呈现一个简单的页面,其中包含一个链接,该链接将重定向我们到 Spotify 认证页面。

第二个根将是/callback,这是 Spotify 在我们的应用程序用户授权我们的应用程序访问其 Spotify 数据后将调用的端点。

让我们看看这是如何实现的,但首先,我们需要安装 Flask。打开终端并输入以下命令:

pip install flask

安装后,您甚至可以将其包含在requirements.txt文件中,如下所示:

$ pip freeze | grep Flask >> requirements.txt

命令pip freeze将以 requirements 格式打印所有已安装的软件包。输出将返回更多项目,因为它还将包含我们已安装的软件包的所有依赖项,这就是为什么我们使用 grep Flask并将其附加到requirements.txt文件中。

下次您要设置虚拟环境来处理这个项目时,只需运行:

pip install -r requirements.txt

太棒了!现在,我们可以开始创建 Web 应用程序。创建一个名为spotify_auth.py的文件。

首先,我们添加所有必要的导入:

from urllib.parse import urlencode

import requests
import json

from flask import Flask
from flask import render_template
from flask import request

from pytify.core import read_config
from pytify.core import BadRequestError
from pytify.auth import Authorization
from pytify.auth import get_auth_key

我们将使用urllib.parse模块中的urlencode函数来对要附加到授权 URL 的参数进行编码。我们还将使用 requests 来发送请求,以在用户授权我们的应用程序后获取access_token,并使用json包来解析响应。

然后,我们将导入与 Flask 相关的内容,以便创建一个 Flask 应用程序,render_template,以便将渲染的 HTML 模板返回给用户,最后是请求,以便我们可以访问 Spotify 授权服务返回给我们的数据。

我们还将导入一些我们在pytify模块的核心和 auth 子模块中包含的函数:read_config用于加载和读取 YAML 配置文件,以及_authorization_code_request。后者将在稍后详细解释。

我们将创建一个 Flask 应用程序和根路由:

app = Flask(__name__)

@app.route("/")
def home():
    config = read_config()

    params = {
        'client_id': config.client_id,
        'response_type': 'code',
        'redirect_uri': 'http://localhost:3000/callback',
        'scope': 'user-read-private user-modify-playback-state',
    }

    enc_params = urlencode(params)
    url = f'{config.auth_url}?{enc_params}'

    return render_template('index.html', link=url)

太棒了!从头开始,我们读取配置文件,以便获取我们的client_id,还有 Spotify 授权服务的 URL。我们使用client_id构建参数字典;授权代码流的响应类型需要设置为coderedirect_uri是回调 URI,Spotify 授权服务将用它来将授权代码发送回给我们。最后,由于我们将向 REST API 发送指令来播放用户活动设备中的曲目,应用程序需要具有user-modify-playback-state权限。

现在,我们对所有参数进行编码并构建 URL。

返回值将是一个渲染的 HTML。在这里,我们将使用render_template函数,将模板作为第一个参数传递。默认情况下,Flask 将在一个名为templates的目录中搜索这个模板。这个函数的第二个参数是模型。我们传递了一个名为link的属性,并设置了变量 URL 的值。这样,我们可以在 HTML 模板中渲染链接,比如:{{link}}

接下来,我们将添加一个函数,以在从 Spotify 的帐户服务获取授权代码后为我们获取access_tokenrefresh_token。创建一个名为_authorization_code_request的函数,内容如下:

def _authorization_code_request(auth_code):
    config = read_config()

    auth_key = get_auth_key(config.client_id, config.client_secret)

    headers = {'Authorization': f'Basic {auth_key}', }

    options = {
        'code': auth_code,
        'redirect_uri': 'http://localhost:3000/callback',
        'grant_type': 'authorization_code',
        'json': True
    }

    response = requests.post(
        config.access_token_url,
        headers=headers,
        data=options
    )

    content = json.loads(response.content.decode('utf-8'))

    if response.status_code == 400:
        error_description = content.get('error_description', '')
        raise BadRequestError(error_description)

    access_token = content.get('access_token', None)
    token_type = content.get('token_type', None)
    expires_in = content.get('expires_in', None)
    scope = content.get('scope', None)
    refresh_token = content.get('refresh_token', None)

    return Authorization(access_token, token_type, expires_in, 
    scope, refresh_token)

这个函数与我们之前在auth.py文件中实现的_refresh_access_token函数基本相同。这里唯一需要注意的是,在选项中,我们传递了授权代码,grant_type设置为authorization_code

@app.route('/callback')
def callback():
    config = read_config()
    code = request.args.get('code', '')
    response = _authorization_code_request(config, code)

    file = open('.pytify', mode='w', encoding='utf-8')
    file.write(response.refresh_token)
    file.close()

    return 'All set! You can close the browser window and stop the 
    server.'

在这里,我们定义了将由 Spotify 授权服务调用以发送授权代码的路由。

我们首先读取配置,解析请求数据中的代码,并调用_authorization_code_request,传递我们刚刚获取的代码。

这个函数将使用这个代码发送另一个请求,并获取一个我们可以用来发送请求的访问令牌,以及一个将存储在musicterminal目录中名为.pytify的文件中的刷新令牌。

我们获取的用于向 Spotify REST API 发出请求的访问令牌有效期为 3,600 秒,或 1 小时,这意味着在一个小时内,我们可以使用相同的访问令牌发出请求。之后,我们需要刷新访问令牌。我们可以通过使用存储在.pytify文件中的刷新令牌来实现。

最后,我们向浏览器发送一个成功消息。

现在,为了完成我们的 Flask 应用程序,我们需要添加以下代码:

if __name__ == '__main__':
    app.run(host='localhost', port=3000)

这告诉 Flask 在本地主机上运行服务器,并使用端口3000

我们的 Flash 应用程序的home函数将作为响应返回一个名为 index.html 的模板化 HTML 文件。我们还没有创建该文件,所以让我们继续创建一个名为musicterminal/templates的文件夹,并在新创建的目录中添加一个名为index.html的文件,内容如下:

<html>
    <head>
    </head>
    <body>
       <a href={{link}}> Click here to authorize </a>
    </body>
</html>

这里没有太多解释的地方,但请注意我们正在引用链接属性,这是我们在 Flask 应用程序的主页函数中传递给render_template函数的。我们将锚元素的href属性设置为链接的值。

太好了!在我们尝试这个并查看一切是否正常工作之前,还有一件事情。我们需要更改 Spotify 应用程序的设置;更具体地说,我们需要配置应用程序的回调函数,以便我们可以接收授权码。

说到这一点,前往beta.developer.spotify.com/dashboard/网站,并使用你的凭据登录。仪表板将显示我们在本章开头创建的pytify应用程序。点击应用程序名称,然后点击页面右上角的EDIT SETTINGS按钮。

向下滚动直到找到重定向 URI,在文本框中输入 http://localhost:3000/callback,然后点击添加按钮。你的配置应该如下所示:

太好了!滚动到对话框底部,点击保存按钮。

现在,我们需要运行我们刚刚创建的 Flask 应用程序。在终端中,进入项目的根目录,输入以下命令:

python spotify_auth.py

你应该会看到类似于这样的输出:

* Running on http://localhost:3000/ (Press CTRL+C to quit)

打开你选择的浏览器,转到http://localhost:3000;你将看到一个简单的页面,上面有我们创建的链接:

点击链接,你将被发送到 Spotify 的授权服务页面。

一个对话框将显示,要求将Pytify应用程序连接到我们的账户。一旦你授权了它,你将被重定向回http://localhost:3000/callback。如果一切顺利,你应该在页面上看到All set! You can close the browser window and stop the server的消息。

现在,只需关闭浏览器,你就可以停止 Flask 应用程序了。

请注意,现在在musicterminal目录中有一个名为.pytify的文件。如果你查看内容,你会看到一个类似于这样的加密密钥:

AQB2jJxziOvuj1VW_DOBeJh-uYWUYaR03nWEJncKdRsgZC6ql2vaUsVpo21afco09yM4tjwgt6Kkb_XnVC50CR0SdjWrrbMnr01zdemN0vVVHmrcr_6iMxCQSk-JM5yTjg4

现在,我们准备开始编写播放器。

接下来,我们将添加一些函数,用于向 Spotify 的 Web API 发送请求,搜索艺术家,获取艺术家专辑的列表和专辑中的曲目列表,并播放所选的曲目。

查询 Spotify 的 Web API

到目前为止,我们只是准备了地形,现在事情开始变得更有趣了。在这一部分,我们将创建基本函数来向 Spotify 的 Web API 发送请求;更具体地说,我们想要能够搜索艺术家,获取艺术家专辑的列表,获取该专辑中的曲目列表,最后我们想要发送一个请求来实际播放 Spotify 客户端中当前活动的曲目。可以是浏览器、手机、Spotify 客户端,甚至是视频游戏主机。所以,让我们马上开始吧!

首先,我们将在musicterminal/pytify/core目录中创建一个名为request_type.py的文件,内容如下:

from enum import Enum, auto

class RequestType(Enum):
    GET = auto()
    PUT = auto()

我们之前已经讨论过枚举,所以我们不会详细讨论。可以说我们创建了一个包含GETPUT属性的枚举。这将用于通知为我们执行请求的函数,我们想要进行GET请求还是PUT请求。

然后,我们可以在相同的musicterminal/pytify/core目录中创建另一个名为request.py的文件,并开始添加一些导入语句,并定义一个名为execute_request的函数:

import requests
import json

from .exceptions import BadRequestError
from .config import read_config
from .request_type import RequestType

def execute_request(
        url_template,
        auth,
        params,
        request_type=RequestType.GET,
        payload=()):

这个函数有一些参数:

  • url_template:这是将用于构建执行请求的 URL 的模板;它将使用另一个名为params的参数来构建 URL

  • auth:是Authorization对象

  • params:这是一个包含我们将放入我们将要执行请求的 URL 中的所有参数的dict

  • request:这是请求类型;可以是GETPUT

  • payload:这是可能与请求一起发送的数据

随着我们继续实现相同的功能,我们可以添加:

conf = read_config()

params['base_url'] = conf.base_url

url = url_template.format(**params)

headers = {
    'Authorization': f'Bearer {auth.access_token}'
}

我们读取配置并将基本 URL 添加到参数中,以便在url_template字符串中替换它。我们在请求标头中添加Authorization,以及认证访问令牌:

if request_type is RequestType.GET:
    response = requests.get(url, headers=headers)
else:
    response = requests.put(url, headers=headers, data=json.dumps(payload))

    if not response.text:
        return response.text

result = json.loads(response.text)

在这里,我们检查请求类型是否为GET。如果是,我们执行来自 requests 的get函数;否则,我们执行put函数。函数调用非常相似;这里唯一不同的是数据参数。如果返回的响应为空,我们只返回空字符串;否则,我们将 JSON 数据解析为result变量:

if not response.ok:
    error = result['error']
    raise BadRequestError(
        f'{error["message"]} (HTTP {error["status"]})')

return result

解析 JSON 结果后,我们测试请求的状态是否不是200(OK);在这种情况下,我们引发BadRequestError。如果是成功的响应,我们返回结果。

我们还需要一些函数来帮助我们准备要传递给 Web API 端点的参数。让我们继续在musicterminal/pytify/core文件夹中创建一个名为parameter.py的文件,内容如下:

from urllib.parse import urlencode

def validate_params(params, required=None):

    if required is None:
        return

    partial = {x: x in params.keys() for x in required}
    not_supplied = [x for x in partial.keys() if not partial[x]]

    if not_supplied:
        msg = f'The parameter(s) `{", ".join(not_supplied)}` are 
        required'
        raise AttributeError(msg)

def prepare_params(params, required=None):

    if params is None and required is not None:
        msg = f'The parameter(s) `{", ".join(required)}` are 
        required'
        raise ValueErrorAttributeError(msg)
    elif params is None and required is None:
        return ''
    else:
        validate_params(params, required)

    query = urlencode(
        '&'.join([f'{key}={value}' for key, value in 
         params.items()])
    )

    return f'?{query}'

这里有两个函数,prepare_paramsvalidate_paramsvalidate_params函数用于识别是否有参数需要进行某种操作,但它们尚未提供。prepare_params函数首先调用validate_params,以确保所有参数都已提供,并将所有参数连接在一起,以便它们可以轻松附加到 URL 查询字符串中。

现在,让我们添加一个枚举,列出可以执行的搜索类型。在musicterminal/pytify/core目录中创建一个名为search_type.py的文件,内容如下:

from enum import Enum

class SearchType(Enum):
    ARTIST = 1
    ALBUM = 2
    PLAYLIST = 3
    TRACK = 4

这只是一个简单的枚举,列出了四个搜索选项。

现在,我们准备创建执行搜索的函数。在musicterminal/pytify/core目录中创建一个名为search.py的文件:

import requests
import json
from urllib.parse import urlencode

from .search_type import SearchType
from pytify.core import read_config

def _search(criteria, auth, search_type):

    conf = read_config()

    if not criteria:
        raise AttributeError('Parameter `criteria` is required.')

    q_type = search_type.name.lower()
    url = urlencode(f'{conf.base_url}/search?q={criteria}&type=
    {q_type}')

    headers = {'Authorization': f'Bearer {auth.access_token}'}
    response = requests.get(url, headers=headers)

    return json.loads(response.text)

def search_artist(criteria, auth):
    return _search(criteria, auth, SearchType.ARTIST)

def search_album(criteria, auth):
    return _search(criteria, auth, SearchType.ALBUM)

def search_playlist(criteria, auth):
    return _search(criteria, auth, SearchType.PLAYLIST)

def search_track(criteria, auth):
    return _search(criteria, auth, SearchType.TRACK)

我们首先解释_search函数。这个函数获取三个标准参数(我们要搜索的内容),Authorization对象,最后是搜索类型,这是我们刚刚创建的枚举中的一个值。

这个函数非常简单;我们首先验证参数,然后构建 URL 以进行请求,我们使用我们的访问令牌设置Authorization头,最后,我们执行请求并返回解析后的响应。

其他功能search_artistsearch_albumsearch_playlistsearch_track只是获取相同的参数,标准和Authorization对象,并将其传递给_search函数,但它们传递不同的搜索类型。

现在我们可以搜索艺术家,我们必须获取专辑列表。在musicterminal/pytify/core目录中添加一个名为artist.py的文件,内容如下:

from .parameter import prepare_params
from .request import execute_request

def get_artist_albums(artist_id, auth, params=None):

    if artist_id is None or artist_id is "":
        raise AttributeError(
            'Parameter `artist_id` cannot be `None` or empty.')

    url_template = '{base_url}/{area}/{artistid}/{postfix}{query}'
    url_params = {
        'query': prepare_params(params),
        'area': 'artists',
        'artistid': artist_id,
        'postfix': 'albums',
        }

    return execute_request(url_template, auth, url_params)

因此,给定一个artist_id,我们只需定义 URL 模板和我们要发出请求的参数,并运行execute_request函数,它将负责为我们构建 URL,获取和解析结果。

现在,我们想要获取给定专辑的曲目列表。在musicterminal/pytify/core目录中添加一个名为album.py的文件,内容如下:

from .parameters import prepare_params
from .request import execute_request

def get_album_tracks(album_id, auth, params=None):

    if album_id is None or album_id is '':
        raise AttributeError(
            'Parameter `album_id` cannot be `None` or empty.')

    url_template = '{base_url}/{area}/{albumid}/{postfix}{query}'
    url_params = {
        'query': prepare_params(params),
        'area': 'albums',
        'albumid': album_id,
        'postfix': 'tracks',
        }

    return execute_request(url_template, auth, url_params)

get_album_tracks函数与我们刚刚实现的get_artist_albums函数非常相似。

最后,我们希望能够向 Spotify 的 Web API 发送指令,告诉它播放我们选择的曲目。在musicterminal/pytify/core目录中添加一个名为player.py的文件,并添加以下内容:

from .parameter import prepare_params
from .request import execute_request

from .request_type import RequestType

def play(track_uri, auth, params=None):

    if track_uri is None or track_uri is '':
        raise AttributeError(
            'Parameter `track_uri` cannot be `None` or empty.')

    url_template = '{base_url}/{area}/{postfix}'
    url_params = {
        'query': prepare_params(params),
        'area': 'me',
        'postfix': 'player/play',
        }

    payload = {
        'uris': [track_uri],
        'offset': {'uri': track_uri}
    }

    return execute_request(url_template,
                           auth,
                           url_params,
                           request_type=RequestType.PUT,
                           payload=payload)

这个函数与之前的函数(get_artist_albumsget_album_tracks)非常相似,只是它定义了一个有效负载。有效负载是一个包含两个项目的字典:uris,是应该添加到播放队列的曲目列表,和offset,其中包含另一个包含应该首先播放的曲目的 URI 的字典。由于我们只对一次播放一首歌感兴趣,urisoffset将包含相同的track_uri

这里的最后一步是导入我们实现的新函数。在musicterminal/pytify/core目录下的__init__.py文件中,添加以下代码:

from .search_type import SearchType

from .search import search_album
from .search import search_artist
from .search import search_playlist
from .search import search_track

from .artist import get_artist_albums
from .album import get_album_tracks
from .player import play

让我们尝试在 python REPL 中搜索艺术家的函数,以检查一切是否正常工作:

Python 3.6.2 (default, Dec 22 2017, 15:38:46)
[GCC 6.3.0 20170516] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from pytify.core import search_artist
>>> from pytify.core import read_config
>>> from pytify.auth import authenticate
>>> from pprint import pprint as pp
>>>
>>> config = read_config()
>>> auth = authenticate(config)
>>> results = search_artist('hot water music', auth)
>>> pp(results)
{'artists': {'href': 'https://api.spotify.com/v1/search?query=hot+water+music&type=artist&market=SE&offset=0&limit=20',
 'items': {'external_urls': {'spotify': 'https://open.spotify.com/artist/4dmaYARGTCpChLhHBdr3ff'},
 'followers': {'href': None, 'total': 56497},
 'genres': ['alternative emo',
 'emo',
 'emo punk', 

其余输出已被省略,因为太长了,但现在我们可以看到一切都正如预期地工作。

现在,我们准备开始构建终端播放器。

创建播放器

现在我们已经拥有了认证和使用 Spotify Rest API 所需的一切,我们将创建一个小型终端客户端,可以在其中搜索艺术家,浏览他/她的专辑,并选择要在 Spotify 客户端中播放的曲目。请注意,要使用客户端,我们将不得不从高级账户中发出访问令牌,并且我们需要在这里使用的认证流程是AUTHENTICATION_CODE

我们还需要从我们应用程序的用户那里要求user-modify-playback-state范围,这将允许我们控制播放。说到这里,让我们开始吧!

首先,我们需要创建一个新目录,将所有客户端相关的文件保存在其中,所以继续创建一个名为musicterminal/client的目录。

我们的客户端只有三个视图。在第一个视图中,我们将获取用户输入并搜索艺术家。当艺术家搜索完成后,我们将切换到第二个视图,在这个视图中,将呈现所选艺术家的专辑列表。在这个视图中,用户将能够使用键盘的箭头键选择列表上的专辑,并通过按Enter键选择专辑。

最后,当选择了一个专辑后,我们将切换到我们应用程序的第三个和最后一个视图,用户将看到所选专辑的曲目列表。与之前的视图一样,用户还可以使用键盘的箭头键选择曲目;按Enter将向 Spotify API 发送请求,在用户可用设备上播放所选曲目。

一种方法是使用curses.panel。面板是一种窗口,非常灵活,允许我们堆叠、隐藏和显示、切换面板,返回到面板堆栈的顶部等等,非常适合我们的目的。

因此,让我们在musicterminal/client目录下创建一个名为panel.py的文件,内容如下:

import curses
import curses.panel
from uuid import uuid1

class Panel:

    def __init__(self, title, dimensions):
        height, width, y, x = dimensions

        self._win = curses.newwin(height, width, y, x)
        self._win.box()
        self._panel = curses.panel.new_panel(self._win)
        self.title = title
        self._id = uuid1()

        self._set_title()

        self.hide()

我们所做的就是导入我们需要的模块和函数,并创建一个名为Panel的类。我们还导入uuid模块,以便为每个新面板创建一个 GUID。

面板的初始化器有两个参数:title,是窗口的标题,和dimensionsdimensions参数是一个元组,遵循 curses 的约定。它由heightwidth和面板应该开始绘制的位置yx组成。

我们解包dimensions元组的值,以便更容易处理,然后我们使用newwin函数创建一个新窗口;它将具有我们在类初始化器中传递的相同尺寸。接下来,我们调用 box 函数在终端的四个边上绘制线条。

现在我们已经创建了窗口,是时候为我们刚刚创建的窗口创建面板了,调用curses.panel.new_panel并传递窗口。我们还设置窗口标题并创建一个 GUID。

最后,我们将面板的状态设置为隐藏。继续在这个类上工作,让我们添加一个名为hide的新方法:

def hide(self):
    self._panel.hide()

这个方法非常简单;它所做的唯一的事情就是调用我们面板中的hide方法。

我们在初始化器中调用的另一个方法是_set_title;现在让我们创建它:

def _set_title(self):
    formatted_title = f' {self._title} '
    self._win.addstr(0, 2, formatted_title, curses.A_REVERSE)

_set_title中,我们通过在标题字符串的两侧添加一些额外的填充来格式化标题,然后我们调用窗口的addstr方法在零行、二列打印标题,并使用常量A_REVERSE,它将颠倒字符串的颜色,就像这样:

![

我们有一个隐藏面板的方法;现在,我们需要一个显示面板的方法。让我们添加show方法:

def show(self):
    self._win.clear()
    self._win.box()
    self._set_title()
    curses.curs_set(0)
    self._panel.show()

show方法首先清除窗口并用box方法绘制其周围的边框。然后,我们再次设置titlecursers.curs_set(0)调用将禁用光标;我们在这里这样做是因为当我们在列表中选择项目时,我们不希望光标可见。最后,我们在面板中调用show方法。

也很好有一种方法来知道当前面板是否可见。因此,让我们添加一个名为is_visible的方法:

def is_visible(self):
    return not self._panel.hidden()

在这里,我们可以在面板上使用hidden方法,如果面板隐藏则返回true,如果面板可见则返回false

在这个类中的最后一步是添加比较面板的可能性。我们可以通过覆盖一些特殊方法来实现这一点;在这种情况下,我们想要覆盖__eq__方法,每当使用==运算符时都会调用它。记住我们为每个面板创建了一个id吗?我们现在可以使用那个id来测试相等性:

def __eq__(self, other):
    return self._id == other._id

太好了!现在我们有了Panel基类,我们准备创建一个特殊的面板实现,其中将包含选择项目的菜单。

为专辑和曲目选择添加菜单

现在,我们将在musicterminal/client/目录中创建一个名为menu_item.py的文件,并且我们将从中导入一些我们需要的函数开始:

from uuid import uuid1

我们只需要从uuid模块中导入uuid1函数,因为和面板一样,我们将为列表中的每个菜单项创建一个id(GUID)

让我们首先添加类和构造函数:

class MenuItem:
    def __init__(self, label, data, selected=False):
        self.id = str(uuid1())
        self.data = data
        self.label = label

        def return_id():
            return self.data['id'], self.data['uri']

        self.action = return_id
        self.selected = selected

MenuItem初始化器有三个参数,label项,data将包含 Spotify REST API 返回的原始数据,以及一个指示项目当前是否被选中的标志。

我们首先为项目创建一个 id,然后使用传递给类初始化器的参数值设置数据和标签属性的值。

列表中的每个项目都将有一个在选择列表项时执行的操作,因此我们创建一个名为return_id的函数,它返回一个包含项目 id 的元组(不同于我们刚刚创建的 id)。这是 Spotify 上项目的 id,URI 是 Spotify 上项目的 URI。当我们选择并播放一首歌时,后者将会很有用。

现在,我们将实现一些特殊方法,这些方法在执行项目比较和打印项目时将对我们很有用。我们要实现的第一个方法是__eq__

def __eq__(self, other):
    return self.id == other.id

这将允许我们使用index函数在MenuItem对象列表中找到特定的MenuItem

我们要实现的另一个特殊方法是__len__方法:

def __len__(self):
    return len(self.label)

它返回MenuItem标签的长度,当测量列表中菜单项标签的长度时将会用到。稍后,当我们构建菜单时,我们将使用max函数来获取具有最长标签的菜单项,并基于此,我们将为其他项目添加额外的填充,以便列表中的所有项目看起来对齐。

我们要实现的最后一个方法是__str__方法:

def __str__(self):
    return self.label

这只是在打印菜单项时的便利性;我们可以直接调用print(menuitem)而不是print(menuitem.label),它将调用__str__,返回MenuItem标签的值。

实现菜单面板

现在,我们将实现菜单面板,它将是一个容器类,容纳所有菜单项,处理事件,并在终端屏幕上执行呈现。

在我们开始实现菜单面板之前,让我们添加一个枚举,表示不同的项目对齐选项,这样我们就可以更灵活地显示菜单中的菜单项。

musicterminal/client目录中创建一个名为alignment.py的文件,内容如下:

from enum import Enum, auto

class Alignment(Enum):
    LEFT = auto()
    RIGHT = auto()

如果您在第一章中跟随代码,您应该是一个枚举专家。这里没有什么复杂的;我们定义了一个从 Enum 继承的Alignment类,并定义了两个属性,LEFTRIGHT,它们的值都设置为auto(),这意味着值将自动设置为12

现在,我们准备创建菜单。让我们继续在musicterminal/client目录中创建一个名为menu.py的最终类。

让我们添加一些导入和构造函数:

import curses
import curses.panel

from .alignment import Alignment
from .panel import Panel

class Menu(Panel):

    def __init__(self, title, dimensions, align=Alignment.LEFT, 
                 items=[]):
        super().__init__(title, dimensions)
        self._align = align
        self.items = items

Menu类继承自我们刚刚创建的Panel基类,类初始化器接收一些参数:titledimensions(包含heightwidthyx值的元组),默认为LEFTalignment设置,以及items。items 参数是一个MenuItems对象的列表。这是可选的,如果没有指定值,它将设置为空列表。

在类初始化器中的第一件事是调用基类的__init__方法。我们可以使用super函数来做到这一点。如果您记得,Panel类上的__init__方法有两个参数,titledimension,所以我们将它传递给基类初始化器。

接下来,我们为属性alignitems赋值。

我们还需要一个方法,返回菜单项列表中当前选定的项目:

def get_selected(self):
    items = [x for x in self.items if x.selected]
    return None if not items else items[0]

这个方法非常简单;推导返回一个选定项目的列表,如果没有选定项目,则返回None;否则,返回列表中的第一个项目。

现在,我们可以实现处理项目选择的方法。让我们添加另一个名为_select的方法:

def _select(self, expr):
    current = self.get_selected()
    index = self.items.index(current)
    new_index = expr(index)

    if new_index < 0:
        return

    if new_index > index and new_index >= len(self.items):
        return

    self.items[index].selected = False
    self.items[new_index].selected = True

在这里,我们开始获取当前选定的项目,然后立即使用数组中的索引方法获取菜单项列表中项目的索引。这是因为我们在Panel类中实现了__eq__方法。

然后,我们开始运行作为参数传递的函数expr,传递当前选定项目索引的值。

expr将确定下一个当前项目索引。如果新索引小于0,这意味着我们已经到达菜单项列表的顶部,因此我们不采取任何行动。

如果新索引大于当前索引,并且新索引大于或等于列表中菜单项的数量,则我们已经到达列表底部,因此此时不需要采取任何操作,我们可以继续选择相同的项目。

但是,如果我们还没有到达列表的顶部或底部,我们需要交换选定的项目。为此,我们将当前项目的 selected 属性设置为False,并将下一个项目的 selected 属性设置为True

_select方法是一个private方法,不打算在外部调用,因此我们定义了两个方法——nextprevious

def next(self):
    self._select(lambda index: index + 1)

def previous(self):
    self._select(lambda index: index - 1)

下一个方法将调用_select方法,并传递一个 lambda 表达式,该表达式将接收一个索引并将其加一,而上一个方法将执行相同的操作,但是不是增加索引1,而是减去。因此,在_select方法中,当我们调用:

new_index = expr(index)

我们要么调用lambda index: index + 1,要么调用lambda index: index + 1

太好了!现在,我们将添加一个负责在屏幕上呈现菜单项之前格式化菜单项的方法。创建一个名为_initialize_items的方法,如下所示:

def _initialize_items(self):
    longest_label_item = max(self.items, key=len)

    for item in self.items:
        if item != longest_label_item:
            padding = (len(longest_label_item) - len(item)) * ' '
            item.label = (f'{item}{padding}'
                          if self._align == Alignment.LEFT
                          else f'{padding}{item}')

        if not self.get_selected():
            self.items[0].selected = True

首先,我们获取具有最大标签的菜单项;我们可以通过使用内置函数max并传递items,以及作为键的另一个内置函数len来实现这一点。这将起作用,因为我们在菜单项中实现了特殊方法__len__

在发现具有最大标签的菜单项之后,我们循环遍历列表的项目,在LEFTRIGHT上添加填充,具体取决于对齐选项。最后,如果列表中没有被选中标志设置为True的菜单项,我们将选择第一个项目作为选定项目。

我们还想提供一个名为init的方法,它将为我们初始化列表上的项目:

def init(self):
    self._initialize_items()

我们还需要处理键盘事件,这样当用户特别按下箭头键以及Enter键时,我们就可以执行一些操作。

首先,我们需要在文件顶部定义一些常量。您可以在导入和类定义之间添加这些常量:

NEW_LINE = 10 CARRIAGE_RETURN = 13

让我们继续包括一个名为handle_events的方法:

    def handle_events(self, key):
        if key == curses.KEY_UP:
            self.previous()
        elif key == curses.KEY_DOWN:
            self.next()
        elif key == curses.KEY_ENTER or key == NEW_LINE or key == 
         CARRIAGE_RETURN:
            selected_item = self.get_selected()
            return selected_item.action

这个方法非常简单;它获取一个key参数,如果键等于curses.KEY_UP,那么我们调用previous方法。如果键等于curses.KEY_DOWN,那么我们调用next方法。现在,如果键是ENTER,那么我们获取选定的项目并返回其操作。操作是一个将执行另一个函数的函数;在我们的情况下,我们可能会在列表上选择艺术家或歌曲,或执行一个将播放音乐曲目的函数。

除了测试key是否为curses.KEY_ENTER之外,我们还需要检查键是否为换行符\n或回车符\r。这是必要的,因为Enter键的代码可能会根据应用程序运行的终端的配置而有所不同。

我们将实现__iter__方法,这将使我们的Menu类表现得像一个可迭代的对象:

    def __iter__(self):
        return iter(self.items)

这个类的最后一个方法是update方法。这个方法将实际工作渲染菜单项并刷新窗口屏幕:

def update(self):
    pos_x = 2
    pos_y = 2

    for item in self.items:
        self._win.addstr(
                pos_y,
                pos_x,
                item.label,
                curses.A_REVERSE if item.selected else 
                curses.A_NORMAL)
        pos_y += 1

    self._win.refresh()

首先,我们将xy坐标设置为2,这样窗口上的菜单将从第2行和第2列开始。我们循环遍历菜单项,并调用addstr方法在屏幕上打印项目。

addstr方法获取y位置,x位置,将在屏幕上写入的字符串,在我们的例子中是item.label,最后一个参数是style。如果项目被选中,我们希望以突出显示的方式显示它;否则,它将以正常颜色显示。以下截图说明了渲染列表的样子:

创建 DataManager 类

我们已经实现了身份验证和从 Spotify REST API 获取数据的基本功能,但现在我们需要创建一个类,利用这些功能,以便获取我们需要在客户端中显示的信息。

我们的 Spotify 终端客户端将执行以下操作:

  • 按名称搜索艺术家

  • 列出艺术家的专辑

  • 列出专辑的曲目

  • 请求播放一首曲目

我们要添加的第一件事是一个自定义异常,我们可以引发,而且没有从 Spotify REST API 返回结果。在musicterminal/client目录中创建一个名为empty_results_error.py的新文件,内容如下:

class EmptyResultsError(Exception):
    pass

为了让我们更容易,让我们创建一个称为DataManager的类,它将为我们封装所有这些功能。在musicterminal/client目录中创建一个名为data_manager.py的文件:

from .menu_item import MenuItem

from pytify.core import search_artist
from pytify.core import get_artist_albums
from pytify.core import get_album_tracks
from pytify.core import play

from .empty_results_error import EmptyResultsError

from pytify.auth import authenticate
from pytify.core import read_config

class DataManager():

    def __init__(self):
        self._conf = read_config()
        self._auth = authenticate(self._conf)

首先,我们导入MenuItem,这样我们就可以返回带有请求结果的MenuItem对象。之后,我们从pytify模块导入函数来搜索艺术家,获取专辑,列出专辑曲目,并播放曲目。此外,在pytify模块中,我们导入read_config函数并对其进行身份验证。

最后,我们导入刚刚创建的自定义异常EmptyResultsError

DataManager类的初始化器开始读取配置并执行身份验证。身份验证信息将存储在_auth属性中。

接下来,我们将添加一个搜索艺术家的方法:

def search_artist(self, criteria):
    results = search_artist(criteria, self._auth)
    items = results['artists']['items']

    if not items:
        raise EmptyResultsError(f'Could not find the artist: 
        {criteria}')

    return items[0]

_search_artist方法将criteria作为参数,并调用python.core模块中的search_artist函数。如果没有返回项目,它将引发一个EmptyResultsError;否则,它将返回第一个匹配项。

在我们继续创建将获取专辑和音轨的方法之前,我们需要两个实用方法来格式化MenuItem对象的标签。

第一个方法将格式化艺术家标签:

def _format_artist_label(self, item):
    return f'{item["name"]} ({item["type"]})'

在这里,标签将是项目的名称和类型,可以是专辑、单曲、EP 等。

第二个方法格式化音轨的名称:

def _format_track_label(self, item):

    time = int(item['duration_ms'])
    minutes = int((time / 60000) % 60)
    seconds = int((time / 1000) % 60)

    track_name = item['name']

    return f'{track_name} - [{minutes}:{seconds}]'

在这里,我们提取音轨的持续时间(以毫秒为单位),将其转换为分钟:秒的格式,并使用音轨的名称和持续时间在方括号之间格式化标签。

之后,让我们创建一个获取艺术家专辑的方法:

def get_artist_albums(self, artist_id, max_items=20):

     albums = get_artist_albums(artist_id, self._auth)['items']

     if not albums:
         raise EmptyResultsError(('Could not find any albums for'
                                  f'the artist_id: {artist_id}'))

     return [MenuItem(self._format_artist_label(album), album)
             for album in albums[:max_items]]

get_artist_albums方法接受两个参数,artist_idmax_item,它是该方法返回的专辑最大数量。默认情况下,它设置为20

我们在这里首先使用pytify.core模块中的get_artist_albums方法,传递artist_idauthentication对象,并从结果中获取项目的属性,将其分配给变量专辑。如果albums变量为空,它将引发一个EmptyResultsError;否则,它将为每个专辑创建一个MenuItem对象的列表。

我们还可以为音轨添加另一个方法:

def get_album_tracklist(self, album_id):

    results = get_album_tracks(album_id, self._auth)

    if not results:
        raise EmptyResultsError('Could not find the tracks for this 
        album')

    tracks = results['items']

    return [MenuItem(self._format_track_label(track), track)
            for track in tracks]

get_album_tracklist方法以album_id作为参数,我们首先使用pytify.core模块中的get_album_tracks函数获取该专辑的音轨。如果没有返回结果,我们会引发一个EmptyResultsError;否则,我们会构建一个MenuItem对象的列表。

最后一个方法实际上是将命令发送到 Spotify REST API 播放音轨的方法:

def play(self, track_uri):
    play(track_uri, self._auth)

非常直接。在这里,我们只是将track_uri作为参数,并将其传递给pytify.core模块中的play函数,以及authentication对象。这将使音轨开始在可用设备上播放;可以是手机、您计算机上的 Spotify 客户端、Spotify 网络播放器,甚至您的游戏机。

接下来,让我们把我们建立的一切放在一起,并运行 Spotify 播放器终端。

是时候听音乐了!

现在,我们拥有了开始构建终端播放器所需的所有部件。我们有pytify模块,它提供了 Spotify RESP API 的包装器,并允许我们搜索艺术家、专辑、音轨,甚至控制运行在手机或计算机上的 Spotify 客户端。

pytify模块还提供了两种不同类型的身份验证——客户端凭据和授权代码——在之前的部分中,我们实现了构建使用 curses 的应用程序所需的所有基础设施。因此,让我们将所有部分粘合在一起,听一些好音乐。

musicterminal目录中,创建一个名为app.py的文件;这将是我们应用程序的入口点。我们首先添加导入语句:

import curses
import curses.panel
from curses import wrapper
from curses.textpad import Textbox
from curses.textpad import rectangle

from client import Menu
from client import DataManager

我们当然需要导入cursescurses.panel,这次我们还导入了wrapper。这用于调试目的。在开发 curses 应用程序时,它们极其难以调试,当出现问题并抛出异常时,终端将无法返回到其原始状态。

包装器接受一个callable,当callable函数返回时,它将返回终端的原始状态。

包装器将在 try-catch 块中运行可调用项,并在出现问题时恢复终端。在开发应用程序时非常有用。让我们使用包装器,这样我们就可以看到可能发生的任何问题。

我们将导入两个新函数,Textboxrectangle。我们将使用它们创建一个搜索框,用户可以在其中搜索他们喜欢的艺术家。

最后,我们导入在前几节中实现的Menu类和DataManager

让我们开始实现一些辅助函数;第一个是show_search_screen

def show_search_screen(stdscr):
    curses.curs_set(1)
    stdscr.addstr(1, 2, "Artist name: (Ctrl-G to search)")

    editwin = curses.newwin(1, 40, 3, 3)
    rectangle(stdscr, 2, 2, 4, 44)
    stdscr.refresh()

    box = Textbox(editwin)
    box.edit()

    criteria = box.gather()
    return criteria

它以窗口实例作为参数,这样我们就可以在屏幕上打印文本并添加我们的文本框。

curses.curs_set函数用于打开和关闭光标;当设置为1时,光标将在屏幕上可见。我们希望在搜索屏幕上这样做,以便用户知道可以从哪里开始输入搜索条件。然后,我们打印帮助文本,以便用户知道应输入艺术家的名称;最后,他们可以按Ctrl + GEnter执行搜索。

创建文本框时,我们创建一个新的小窗口,高度为1,宽度为40,并且它从终端屏幕的第3行,第3列开始。之后,我们使用rectangle函数在新窗口周围绘制一个矩形,并刷新屏幕以使我们所做的更改生效。

然后,我们创建Textbox对象,传递我们刚刚创建的窗口,并调用edit方法,它将设置框为文本框并进入编辑模式。这将停止应用程序,并允许用户在文本框中输入一些文本;当用户点击Ctrl + GEnter时,它将退出。

当用户完成编辑文本后,我们调用gather方法,它将收集用户输入的数据并将其分配给criteria变量,最后返回criteria

我们还需要一个函数来轻松清理屏幕,让我们创建另一个名为clean_screen的函数:

def clear_screen(stdscr):
    stdscr.clear()
    stdscr.refresh()

太好了!现在,我们可以开始应用程序的主入口,并创建一个名为 main 的函数,内容如下:

def main(stdscr):

    curses.cbreak()
    curses.noecho()
    stdscr.keypad(True)

    _data_manager = DataManager()

    criteria = show_search_screen(stdscr)

    height, width = stdscr.getmaxyx()

    albums_panel = Menu('List of albums for the selected artist',
                        (height, width, 0, 0))

    tracks_panel = Menu('List of tracks for the selected album',
                        (height, width, 0, 0))

    artist = _data_manager.search_artist(criteria)

    albums = _data_manager.get_artist_albums(artist['id'])

    clear_screen(stdscr)

    albums_panel.items = albums
    albums_panel.init()
    albums_panel.update()
    albums_panel.show()

    current_panel = albums_panel

    is_running = True

    while is_running:
        curses.doupdate()
        curses.panel.update_panels()

        key = stdscr.getch()

        action = current_panel.handle_events(key)

        if action is not None:
            action_result = action()
            if current_panel == albums_panel and action_result is 
            not None:
                _id, uri = action_result
                tracks = _data_manager.get_album_tracklist(_id)
                current_panel.hide()
                current_panel = tracks_panel
                current_panel.items = tracks
                current_panel.init()
                current_panel.show()
            elif current_panel == tracks_panel and action_result is  
            not None:
                _id, uri = action_result
                _data_manager.play(uri)

        if key == curses.KEY_F2:
            current_panel.hide()
            criteria = show_search_screen(stdscr)
            artist = _data_manager.search_artist(criteria)
            albums = _data_manager.get_artist_albums(artist['id'])

            clear_screen(stdscr)
            current_panel = albums_panel
            current_panel.items = albums
            current_panel.init()
            current_panel.show()

        if key == ord('q') or key == ord('Q'):
            is_running = False

        current_panel.update()

try:
    wrapper(main)
except KeyboardInterrupt:
    print('Thanks for using this app, bye!')

让我们将其分解为其组成部分:

curses.cbreak()
curses.noecho()
stdscr.keypad(True)

在这里,我们进行一些初始化。通常,curses 不会立即注册按键。当按键被输入时,这称为缓冲模式;用户必须输入一些内容,然后按Enter。在我们的应用程序中,我们不希望出现这种行为;我们希望按键在用户输入后立即注册。这就是cbreak的作用;它关闭 curses 的缓冲模式。

我们还使用noecho函数来读取按键并控制何时在屏幕上显示它们。

我们做的最后一个 curses 设置是打开键盘,这样 curses 将负责读取和处理按键,并返回表示已按下的键的常量值。这比尝试自己处理并测试键码数字要干净得多,更易于阅读。

我们创建DataManager类的实例,以便获取我们需要在菜单上显示的数据并执行身份验证:

_data_manager = DataManager()

现在,我们创建搜索对话框:

criteria = show_search_screen(stdscr)

我们调用show_search_screen函数,传递窗口的实例;它将在屏幕上呈现搜索字段并将结果返回给我们。当用户输入完成时,用户输入将存储在criteria变量中。

在获取条件后,我们调用get_artist_albums,它将首先搜索艺术家,然后获取艺术家专辑列表并返回MenuItem对象的列表。

当专辑列表返回时,我们可以创建其他带有菜单的面板:

height, width = stdscr.getmaxyx()

albums_panel = Menu('List of albums for the selected artist',
                    (height, width, 0, 0))

tracks_panel = Menu('List of tracks for the selected album',
                    (height, width, 0, 0))

artist = _data_manager.search_artist(criteria)

albums = _data_manager.get_artist_albums(artist['id'])

clear_screen(stdscr)

在这里,我们获取主窗口的高度和宽度,以便我们可以创建具有相同尺寸的面板。albums_panel将显示专辑,tracks_panel将显示曲目;如前所述,它将具有与主窗口相同的尺寸,并且两个面板将从第0行,第0列开始。

之后,我们调用clear_screen准备窗口以渲染带有专辑的菜单窗口:

albums_panel.items = albums
albums_panel.init()
albums_panel.update()
albums_panel.show()

current_panel = albums_panel

is_running = True

我们首先使用专辑搜索结果设置项目的属性。我们还在面板上调用init,这将在内部运行_initialize_items,格式化标签并设置当前选定的项目。我们还调用update方法,这将实际打印窗口中的菜单项;最后,我们展示如何将面板设置为可见。

我们还定义了current_panel变量,它将保存当前在终端上显示的面板的实例。

is_running标志设置为True,并将在应用程序的主循环中使用。当我们想要停止应用程序的执行时,我们将其设置为False

现在,我们进入应用程序的主循环:

while is_running:
    curses.doupdate()
    curses.panel.update_panels()

    key = stdscr.getch()

    action = current_panel.handle_events(key)

首先,我们调用doupdateupdate_panels

  • doupdate:Curses 保留两个表示物理屏幕(在终端屏幕上看到的屏幕)和虚拟屏幕(保持下一个更新的屏幕)的数据结构。doupdate更新物理屏幕,使其与虚拟屏幕匹配。

  • update_panels:在面板堆栈中的更改后更新虚拟屏幕,例如隐藏、显示面板等。

更新屏幕后,我们使用getch函数等待按键按下,并将按下的键值分配给key变量。然后将key变量传递给当前面板的handle_events方法。

如果您还记得Menu类中handle_events的实现,它看起来像这样:

def handle_events(self, key):
    if key == curses.KEY_UP:
        self.previous()
    elif key == curses.KEY_DOWN:
        self.next()
    elif key == curses.KEY_ENTER or key == NEW_LINE or key ==  
    CARRIAGE_RETURN:
    selected_item = self.get_selected()
    return selected_item.action

它处理KEY_DOWNKEY_UPKEY_ENTER。如果键是KEY_UPKEY_DOWN,它将只更新菜单中的位置并设置新选择的项目,这将在下一个循环交互中更新在屏幕上。如果键是KEY_ENTER,我们获取所选项目并返回其操作函数。

请记住,对于两个面板,它将返回一个函数,当执行时,将返回包含项目 ID 和项目 URI 的元组。

接下来,我们处理返回的操作:

if action is not None:
    action_result = action()
    if current_panel == albums_panel and action_result is not None:
        _id, uri = action_result
        tracks = _data_manager.get_album_tracklist(_id)
        current_panel.hide()
        current_panel = tracks_panel
        current_panel.items = tracks
        current_panel.init()
        current_panel.show()
    elif current_panel == tracks_panel and action_result is not 
    None:
        _id, uri = action_result
        _data_manager.play(uri)

如果当前面板的handle_events方法返回一个可调用的action,我们执行它并获取结果。然后,我们检查活动面板是否是第一个面板(带有专辑)。在这种情况下,我们需要获取所选专辑的曲目列表,因此我们在DataManager实例中调用get_album_tracklist

我们隐藏current_panel,将当前面板切换到第二个面板(曲目面板),使用曲目列表设置项目属性,调用 init 方法使项目正确格式化并设置列表中的第一个项目为选定项目,最后我们调用show以便曲目面板可见。

在当前面板是tracks_panel的情况下,我们获取操作结果并在DataManager上调用 play,传递曲目 URI。它将请求在 Spotify 上活跃的设备上播放所选的曲目。

现在,我们希望有一种方法返回到搜索屏幕。当用户按下F12功能键时,我们这样做:

if key == curses.KEY_F2:
    current_panel.hide()
    criteria = show_search_screen(stdscr)
    artist = _data_manager.search_by_artist_name(criteria)
    albums = _data_manager.get_artist_albums(artist['id'])

    clear_screen(stdscr)
    current_panel = albums_panel
    current_panel.items = albums
    current_panel.init()
    current_panel.show()

对于上面的if语句,测试用户是否按下了F12功能键;在这种情况下,我们希望返回到搜索屏幕,以便用户可以搜索新的艺术家。当按下F12键时,我们隐藏当前面板。然后,我们调用show_search_screen函数,以便呈现搜索屏幕,并且文本框将进入编辑模式,等待用户的输入。

当用户输入完成并按下Ctrl+ GEnter时,我们搜索艺术家。然后,我们获取艺术家的专辑,并显示带有专辑列表的面板。

我们想要处理的最后一个事件是用户按下qQ键,将is_running变量设置为False,应用程序关闭:

if key == ord('q') or key == ord('Q'):
    is_running = False

最后,我们在当前面板上调用update,以便重新绘制项目以反映屏幕上的更改:

current_panel.update()

在主函数之外,我们有代码片段,其中我们实际执行main函数:

try:
    wrapper(main)
except KeyboardInterrupt:
    print('Thanks for using this app, bye!')

我们用try catch 包围它,所以如果用户按下Ctrl + C,将会引发KeyboardInterrupt异常,我们只需优雅地完成应用程序,而不会在屏幕上抛出异常。

我们都完成了!让我们试试吧!

打开终端并输入命令—python app.py

您将看到的第一个屏幕是搜索屏幕:

让我搜索一下我最喜欢的艺术家:

按下EnterCtrl + G后,您应该会看到专辑列表:

在这里,您可以使用箭头键()来浏览专辑,并按Enter来选择一个专辑。然后,您将看到屏幕显示所选专辑的所有曲目:

如果这个屏幕是一样的,您可以使用箭头键()来选择曲目,Enter将发送请求在您的 Spotify 活动设备上播放这首歌曲。

总结

在本章中,我们涵盖了很多内容;我们首先在 Spotify 上创建了一个应用程序,并学习了其开发者网站的使用方法。然后,我们学习了如何实现 Spotify 支持的两种认证流程:客户端凭据流程和授权流程。

在本章中,我们还实现了一个完整的模块包装器,其中包含了一些来自 Spotify 的 REST API 的功能。

然后,我们实现了一个简单的终端客户端,用户可以在其中搜索艺术家,浏览艺术家的专辑和曲目,最后在用户的活动设备上播放一首歌曲,这可以是计算机、手机,甚至是视频游戏主机。

在下一章中,我们将创建一个桌面应用程序,显示通过 Twitter 标签的投票数。

第三章:在 Twitter 上投票

在上一章中,我们实现了一个终端应用程序,作为流行音乐服务 Spotify 的远程控制器。在这个应用程序中,我们可以搜索艺术家,浏览专辑,以及浏览每张专辑中的曲目。最后,我们甚至可以请求在用户的活动设备上播放曲目。

这一次,我们将开发一个将与 Twitter 集成的应用程序,利用其 REST API。 Twitter 是一个自 2006 年以来就存在的社交网络,拥有超过 3 亿活跃用户。私人用户、公司、艺术家、足球俱乐部,你几乎可以在 Twitter 上找到任何东西。但我认为让 Twitter 如此受欢迎的是它的简单性。

与博客文章不同,Twitter 的帖子或推文必须简短并直奔主题,而且准备发布的时间也不需要太长。另一个使 Twitter 如此受欢迎的原因是该服务是一个很好的新闻来源。如果你想要了解世界上正在发生的事情,政治、体育、科技等等,Twitter 就是你要去的地方。

除此之外,Twitter 对于开发者来说有一个相当不错的 API,为了利用这一点,我们将开发一个应用程序,用户可以使用标签投票。在我们的应用程序中,我们将配置要监视的标签,并且它将自动定期获取与该标签匹配的最新推文,对它们进行计数,并在用户界面中显示它们。

在本章中,您将学习以下内容:

  • 创建一个推文应用程序

  • 使用OAuth库并实现三步验证流程

  • 使用 Twitter API 搜索最新的推文

  • 使用Tkinter构建一个简单的用户界面

  • 学习多进程和响应式编程的基础知识

设置环境

首先,我们要做的事情通常是设置我们的开发环境,第一步是为我们的应用程序创建一个虚拟环境。我们的应用程序将被称为twittervotes,所以让我们继续创建一个名为twittervotes的虚拟环境:

virtualenv环境创建好后,您可以使用以下命令激活它:

. twittervotes/bin/activate

太好了!现在让我们设置项目的目录结构。它应该如下所示:

twittervotes
├── core
│   ├── models
│   └── twitter
└── templates

让我们深入了解一下结构:

twittervotes 应用程序的根目录。在这里,我们将创建应用程序的入口点,以及一个小的辅助应用程序来执行 Twitter 身份验证。
twittervotes/core 这将包含我们项目的所有核心功能。它将包含身份验证代码、读取配置文件、向 Twitter API 发送请求等等。
twittervotes/core/models 用于保存应用程序数据模型的目录。
twittervotes/core/twitter twitter目录中,我们将保留与 Twitter API 交互的helper函数。
twittervotes/templates 在这里,我们将保存我们应用程序将使用的所有 HTML 模板。

接下来,是时候添加我们项目的依赖关系了。继续在twittervotes目录中创建一个名为requirements.txt的文件,内容如下:

Flask==0.12.2
oauth2==1.9.0.post1
PyYAML==3.12
requests==2.18.4
Rx==1.6.0

以下表格解释了前面的依赖关系的含义:

Flask 我们将在这里使用 Flask 创建一个简单的 Web 应用程序,以便与 Twitter 进行身份验证。
oauth2 这是一个很棒的包,它将在执行OAuth身份验证时抽象出很多复杂性。
PyYAML 我们将使用这个包来创建和读取 YAML 格式的配置文件。
Requests 允许我们通过 HTTP 访问 Twitter API。
Rx 最后,我们将使用 Python 的 Reactive Extensions,以便在新的推文计数到达时,可以对我们的 UI 进行响应式更新。

文件创建后,运行命令pip install -r requirements.txt,您应该会看到类似以下的输出:

如果运行命令pip freeze,您将获得以 pip 格式列出的依赖项列表,并且您将注意到输出列出了比我们实际添加到requirements文件中的依赖项更多的依赖项。 原因是我们的项目需要的软件包也有依赖项,并且它们也将被安装。 因此,如果您安装的软件包比您在requirements文件中指定的要多,请不要担心。

现在我们的环境已经设置好,我们可以开始创建我们的 Twitter 应用程序。 通常,在开始编码之前,请确保您的代码已经在 Git 等源代码控制系统下; 有很多在线服务可以免费托管您的存储库。

通过这种方式,您可以回滚项目的不同版本,如果您的计算机出现问题,也不会丢失工作。 话虽如此,让我们创建我们的 Twitter 应用程序。

创建 Twitter 应用程序

在本节中,我们将创建我们的第一个 Twitter 应用程序,以便可以使用 Twitter REST API。 如果您还没有帐户,则需要创建一个帐户。 如果您不使用 Twitter,我强烈建议您使用; 这是一个了解所有新闻和开发世界正在发生的事情的好方法,也是在 Python 社区中结交新朋友的好方法。

创建帐户后,转到apps.twitter.com/,使用您的登录凭据登录,您将进入一个页面,您可以在该页面上看到您已经创建的应用程序的列表(第一次,您可能会有一个空的应用程序列表),并且在同一页上,您将有可能创建新的应用程序。 单击右上角的“创建新应用程序”按钮,它将打开以下页面:

在此表单中,有三个必填字段-名称,描述和网站:

  • 名称:这是您的应用程序的名称; 这也是在执行授权时将呈现给您的应用程序用户的名称。 名称不需要遵循任何特定的命名约定,您可以随意命名。

  • 描述:顾名思义,这是您的应用程序的描述。 这个字段也将呈现给您的应用程序用户,因此最好有描述您的应用程序的好文本。 在这种情况下,我们不需要太多文本。 让我们添加用于在 Twitter 上使用标签投票的应用程序

  • 网站:指定您的应用程序的网站; 它也将在授权期间呈现给用户,并且是用户可以下载或获取有关您的应用程序的更多信息的网站。 由于我们处于开发阶段,我们可以添加一个占位符,例如www.example.com

  • 回调 URL:这与上一章中的 Spotify 终端应用程序中的回调 URL 的工作方式相同。 这是 Twitter 将调用以发送授权代码的 URL。 这不是必需的字段,但我们需要它,所以让我们继续添加;http://localhost:3000/callback

填写所有字段后,您只需要勾选 Twitter 开发者协议并单击“创建 Twitter 应用程序”按钮。

如果一切顺利,您将被引导到另一个页面,您可以在该页面上看到您新创建的应用程序的更多详细信息。 在应用程序名称下方,您将看到一个带有选项卡的区域,显示有关应用程序的设置和不同信息的选项卡:

在第一个选项卡“详细信息”中,我们要复制所有我们将用于执行身份验证的 URL。滚动到“应用程序设置”,并复制“请求令牌 URL”、“授权 URL”和“访问令牌 URL”:

太好了!现在让我们转到“密钥和访问令牌”选项卡,复制“消费者密钥”和“消费者密钥”

现在我们已经复制了所有必要的信息,我们可以创建一个将被我们的应用程序使用的配置文件。将所有这些内容保存在配置文件中是一种良好的做法,这样我们就不需要在代码中硬编码这些 URL。

我们将消费者密钥消费者密钥添加到我们项目中的配置文件;正如名称所示,这个密钥是秘密的,所以如果您计划在 GitHub 等服务中为您的代码创建存储库,请确保将配置文件添加到.gitignore文件中,以便密钥不被推送到云存储库。永远不要与任何人分享这些密钥;如果您怀疑有人拥有这些密钥,您可以在 Twitter 应用的网站上为您的应用生成新的密钥。

添加配置文件

在这一部分,我们将为我们的应用程序创建配置文件;配置文件将采用 YAML 格式。如果您想了解有关 YAML 的更多信息,可以查看网站yaml.org/,在那里您将找到示例、规范,以及可以用于操作 YAML 文件的不同编程语言的库列表。

对于我们的应用程序,我们将使用 PyYAML,它将允许我们以非常简单的方式读取和写入 YAML 文件。我们的配置文件非常简单,所以我们不需要使用库的任何高级功能,我们只想读取内容并写入,我们要添加的数据非常平坦;我们不会有任何嵌套对象或任何类型的列表。

让我们获取我们从 Twitter 获取的信息,并将其添加到配置文件中。在应用程序的twittervotes目录中创建一个名为config.yaml的文件,内容如下:

consumer_key: '<replace with your consumer_key>'
consumer_secret: '<replace with your consumer secret>'
request_token_url: 'https://api.twitter.com/oauth/request_token'
authorize_url: 'https://api.twitter.com/oauth/authorize'
access_token_url: 'https://api.twitter.com/oauth/access_token'
api_version: '1.1'
search_endpoint: 'https://api.twitter.com/1.1/search/tweets.json'

太好了!现在我们将在我们的项目中创建第一个 Python 代码。如果您已经阅读了前几章,那么读取配置文件的函数对您来说将是熟悉的。这个想法很简单:我们将读取配置文件,解析它,并创建一个我们可以轻松使用来访问我们添加到配置中的数据的模型。首先,我们需要创建配置模型。

twittervotes/core/models/中创建一个名为models.py的文件,内容如下:

from collections import namedtuple

Config = namedtuple('Config', ['consumer_key',
                               'consumer_secret',
                               'request_token_url',
                               'access_token_url',
                               'authorize_url',
                               'api_version',
                               'search_endpoint', ])

在上一章中对namedtuple进行了更详细的介绍,所以我不会再详细介绍它;如果您还没有阅读第二章,只需知道namedtuple是一种类,这段代码将使用第二个参数中指定的字段定义一个名为Confignamedtuple

太好了,现在让我们在twittervotes/core/models中创建另一个名为__init__.py的文件,并导入我们刚刚创建的namedtuple

from .models import Config

现在是时候创建读取 YAML 文件并将其返回给我们的函数了。在twittervotes/core/中创建一个名为config.py的文件。让我们开始添加导入语句:

import os
import yaml

from .models import Config

我们将使用os包轻松获取用户当前目录并操作路径。我们还导入 PyYAML,以便读取 YAML 文件,最后,从models模块中导入我们刚刚创建的Config模型。

然后我们定义两个函数,首先是_read_yaml_file函数。这个函数有两个参数——filename,是我们要读取的配置文件的名称,还有cls,可以是我们用来存储配置数据的classnamedtuple

在这种情况下,我们将传递Config——namedtuple,它具有我们将要读取的 YAML 配置文件相同的属性:

def _read_yaml_file(filename, cls):
    core_dir = os.path.dirname(os.path.abspath(__file__))
    file_path = os.path.join(core_dir, '..', filename)

    with open(file_path, mode='r', encoding='UTF-8') as file:
        config = yaml.load(file)
        return cls(**config)

首先,我们使用os.path.abspath函数,将特殊变量__file__作为参数传递。当一个模块被加载时,变量__file__将被设置为与模块同名。这将使我们能够轻松找到加载配置文件的位置。因此,以下代码段将返回核心模块的路径。

/projects/twittervotes/core

core_dir = os.path.dirname(os.path.abspath(__file__)) will return

我们知道配置文件将位于/projects/twittervotes/,所以我们需要将..与路径连接起来,以在目录结构中向上移动一级,以便读取文件。这就是我们构建完整配置文件路径的原因。

file_path = os.path.join(core_dir, '..', filename)

这将使我们能够从系统中的任何位置运行此代码。

我们以 UTF-8 编码以读取模式打开文件,并将其传递给yaml.load函数,将结果赋给config变量。config变量将是一个包含配置文件中所有数据的字典。

这个函数的最后一行是有趣的部分:如果你还记得,cls参数是一个class或者namedtuple,所以我们将配置字典的值作为参数展开。在这里,我们将使用Config——namedtuple,所以cls(**config)等同于Config(**config),使用**传递参数将等同于逐个传递所有参数:

Config(
    consumer_key: ''
    consumer_secret: ''
    app_only_auth: 'https://api.twitter.com/oauth2/token'
    request_token_url: 'https://api.twitter.com/oauth/request_token'
    authorize_url: 'https://api.twitter.com/oauth/authorize'
    access_token_url: 'https://api.twitter.com/oauth/access_token'
    api_version: '1.1'
    search_endpoint: '')

现在我们要添加我们需要的第二个函数,read_config函数:

def read_config():
    try:
        return _read_yaml_file('config.yaml', Config)
    except IOError as e:
        print(""" Error: couldn\'t file the configuration file 
        `config.yaml`
        'on your current directory.

        Default format is:',

        consumer_key: 'your_consumer_key'
        consumer_secret: 'your_consumer_secret'
        request_token_url: 
        'https://api.twitter.com/oauth/request_token'
        access_token_url:  
        'https://api.twitter.com/oauth/access_token'
        authorize_url: 'https://api.twitter.com/oauth/authorize'
        api_version: '1.1'
        search_endpoint: ''
        """)
        raise

这个函数非常简单;它只是利用我们刚刚创建的_read_yaml_file函数,将config.yaml文件作为第一个参数传递,并将Confignamedtuple作为第二个参数传递。

我们捕获IOError异常,如果文件在应用程序目录中不存在,则会抛出该异常;在这种情况下,我们会抛出一个帮助消息,向您的应用程序用户显示配置文件应该如何结构化。

最后一步是将其导入到twittervotes/core目录中的__init__.py中:

from .config import read_config

让我们在 Python REPL 中尝试一下:

太棒了,它的工作原理就像我们想要的那样!在下一节中,我们可以开始创建执行认证的代码。

执行认证

在本节中,我们将创建一个程序,该程序将为我们执行认证,以便我们可以使用 Twitter API。我们将使用一个简单的 Flask 应用程序来实现这一点,该应用程序将公开两个路由。第一个是根路径/,它将加载和呈现一个简单的 HTML 模板,其中包含一个按钮,该按钮将重定向我们到 Twitter 认证对话框。

我们要创建的第二个路由是/callback。还记得我们在 Twitter 应用程序配置中指定的回调 URL 吗?这是在我们授权应用程序后将被调用的路由。它将返回一个授权令牌,该令牌将用于向 Twitter API 发出请求。所以让我们开始吧!

在我们开始实现 Flask 应用程序之前,我们需要在我们的模型模块中添加另一个模型。这个模型将代表请求授权数据。打开twittervotes/core/models中的models.py文件,并添加以下代码:

RequestToken = namedtuple('RequestToken', ['oauth_token',
                                         'oauth_token_secret',
                                        'oauth_callback_confirmed'])

这将创建一个名为RequestTokennamedtuple,包含字段oauth_tokenoauth_token_secretouth_callback_confirmed;这些数据对我们执行认证的第二步是必要的。

最后,在twittervotes/core/models目录中打开__init__.py文件,并导入我们刚刚创建的RequestToken namedtuple,如下所示:

from .models import RequestToken

既然我们已经有了模型,让我们开始创建 Flask 应用程序。让我们添加一个非常简单的模板,显示一个按钮,该按钮将启动认证过程。

twittervotes目录中创建一个名为templates的新目录,并创建一个名为index.html的文件,内容如下:

<html>
    <head>
    </head>
    <body>
       <a href="{{link}}"> Click here to authorize </a>
    </body>
</html>

创建 Flask 应用程序

完美,现在让我们在twittervotes目录中添加一个名为twitter_auth.py的文件。我们将在其中创建三个函数,但首先让我们添加一些导入:

from urllib.parse import parse_qsl

import yaml

from flask import Flask
from flask import render_template
from flask import request

import oauth2 as oauth

from core import read_config
from core.models import RequestToken

首先,我们从urllib.parse模块中导入parser_qls来解析返回的查询字符串,以及yaml模块,这样我们就可以读取和写入YAML配置文件。然后我们导入构建 Flask 应用程序所需的一切。我们在这里要导入的最后一个第三方模块是oauth2模块,它将帮助我们执行OAuth认证。

最后,我们导入我们的函数read_config和我们刚刚创建的RequestToken namedtuple

在这里,我们创建了我们的 Flask 应用程序和一些全局变量,这些变量将保存客户端、消费者和RequestToken实例的值:

app = Flask(__name__)

client = None
consumer = None
req_token = None

我们要创建的第一个函数是一个名为get_req_token的函数,内容如下:

def get_oauth_token(config):

    global consumer
    global client
    global req_token

    consumer = oauth.Consumer(config.consumer_key, 
     config.consumer_secret)
    client = oauth.Client(consumer)

    resp, content = client.request(config.request_token_url, 'GET')

    if resp['status'] != '200':
        raise Exception("Invalid response 
        {}".format(resp['status']))

    request_token = dict(parse_qsl(content.decode('utf-8')))

    req_token = RequestToken(**request_token)

这个函数的参数是一个配置实例,全局语句告诉解释器函数中使用的req_token将引用全局变量。

我们使用在创建 Twitter 应用程序时获得的消费者密钥和消费者密钥创建一个消费者对象。当消费者创建后,我们可以将其传递给客户端函数来创建客户端,然后我们调用请求函数,这个函数将执行请求到 Twitter,传递请求令牌 URL。

当请求完成时,响应和内容将被存储在变量respcontent中。紧接着,我们测试响应状态是否不是200HTTP.OK;在这种情况下,我们会引发一个异常,否则我们解析查询字符串以获取发送回来的值,并创建一个RequestToken实例。

创建应用程序路由

现在我们可以开始创建路由了。首先,我们要添加根路由:

@app.route('/')
def home():

    config = read_config()

    get_oauth_token(config)

    url = f'{config.authorize_url}?oauth_token=
    {req_token.oauth_token}'

    return render_template('index.html', link=url)

我们读取配置文件并将其传递给get_oauth_token函数。这个函数将用oauth_token的值填充全局变量req_token;我们需要这个令牌来开始授权过程。然后我们使用从配置文件中获取的authorize_url值和OAuth请求令牌构建授权 URL。

最后,我们使用render_template来渲染我们创建的index.html模板,并且还向函数传递了第二个参数,即上下文。在这种情况下,我们创建了一个名为link的项目,其值设置为url。如果你还记得index.html模板,那里有一个"{{url}}"的占位符。这个占位符将被我们在render_template函数中分配给link的值所替换。

默认情况下,Flask 使用 Jinja2 作为模板引擎,但可以更改为您喜欢的引擎;我们不会在本书中详细介绍如何做到这一点,因为这超出了我们的范围。

我们要添加的最后一个路由是/callback路由,这将是 Twitter 在授权后调用的路由:

@app.route('/callback')
def callback():

    global req_token
    global consumer

    config = read_config()

    oauth_verifier = request.args.get('oauth_verifier', '')

    token = oauth.Token(req_token.oauth_token,
                        req_token.oauth_token_secret)

    token.set_verifier(oauth_verifier)

    client = oauth.Client(consumer, token)

    resp, content = client.request(config.access_token_url, 'POST')
    access_token = dict(parse_qsl(content.decode('utf-8')))

    with open('.twitterauth', 'w') as req_auth:
        file_content = yaml.dump(access_token, 
        default_flow_style=False)
        req_auth.write(file_content)

    return 'All set! You can close the browser window and stop the 
    server.'

回调路由的实现从使用全局语句开始,这样我们就可以使用全局变量req_tokenconsumer

现在我们来到了有趣的部分。在授权后,Twitter 会返回一个outh_verifier,所以我们从请求参数中获取它并将其设置为变量oauth_verifier;我们使用在授权过程的第一部分中获得的oauth_tokenoauth_token_secret创建一个Token实例。

然后我们在Token对象中设置oauth_verifier,最后创建一个新的客户端,我们将使用它来执行一个新的请求。

我们解码从请求接收到的数据,并将其添加到访问令牌变量中,最后,我们将access_token的内容写入twittervotes目录中的.twitterauth文件。这个文件也是 YAML 格式,所以我们将在config.py文件中添加另一个模型和一个新的函数来读取新的设置。

请注意,这个过程只需要做一次。这就是我们将数据存储在.twitterauth文件中的原因。进一步的请求只需要使用这个文件中包含的数据。

如果您检查.twitterauth文件的内容,您应该有类似以下的内容:

oauth_token: 31******95-**************************rt*****io
oauth_token_secret: NZH***************************************ze8v
screen_name: the8bitcoder
user_id: '31******95'
x_auth_expires: '0'

要完成 Flask 应用程序,我们需要在文件末尾添加以下代码:

if __name__ == '__main__':
    app.run(host='localhost', port=3000)

让我们在twittervotes/core/models/中的models.py文件中添加一个新的模型,内容如下:

RequestAuth = namedtuple('RequestAuth', ['oauth_token',
                                         'oauth_token_secret',
                                         'user_id',
                                         'screen_name',
                                         'x_auth_expires', ])

太棒了!还有一件事——我们需要在twittervotes/core/models目录中的__init__.py文件中导入新的模型:

from .models import RequestAuth

另外,让我们在twittervotes/core中的config.py文件中添加一个函数来读取.twittervotes文件。首先,我们需要导入我们刚刚创建的RequestAuth——namedtuple

from .models import RequestAuth

然后我们创建一个名为read_reqauth的函数,如下所示:

def read_reqauth():
    try:
        return _read_yaml_file('.twitterauth', RequestAuth)
    except IOError as e:
        print(('It seems like you have not authorized the  
        application.\n'
               'In order to use your twitter data, please run the '
               'auth.py first.'))

这个函数非常简单:我们只是调用_read_yaml_file,将.twitterauth文件和我们刚刚创建的新的namedtupleRequestAuth作为参数传递进去。同样,如果发生错误,我们会引发异常并显示帮助消息。

现在我们可以尝试进行身份验证。在twittervotes目录中,执行脚本twitter_auth.py。您应该会看到以下输出:

太棒了!服务器已经启动,所以我们可以打开浏览器,转到http://localhost:3000。您应该会看到一个非常简单的页面,上面有一个链接可以进行身份验证:

如果您使用浏览器开发工具检查链接,您将看到链接指向授权端点,并传递了我们创建的oauth_token

继续点击链接,您将被发送到授权页面:

如果您点击“授权应用”按钮,您将被重定向回本地主机,并显示成功消息:

如果您注意到 Twitter 发送给我们的 URL,您会发现一些信息。这里的重点是oauth_verifier,我们将其设置为请求令牌,然后我们执行最后一个请求以获取访问令牌。现在您可以关闭浏览器,停止 Flask 应用程序,并在twittervotes目录中的.twitterauth文件中查看结果:

oauth_token: 31*******5-KNAbN***********************K40
oauth_token_secret: d**************************************Y3
screen_name: the8bitcoder
user_id: '31******95'
x_auth_expires: '0'

现在,我们在这里实现的所有功能对于其他用户使用我们的应用程序非常有用;然而,如果您正在授权自己的 Twitter 应用程序,有一种更简单的方法可以获取访问令牌。让我们看看如何做到这一点。

返回到apps.twitter.com/中的 Twitter 应用程序设置;选择 Keys and Access Tokens 选项卡并滚动到最底部。如果您已经授权了这个应用程序,您将在.twitterauth文件中看到与我们现在相同的信息,但如果您还没有授权该应用程序,您将看到一个看起来像下面这样的 Your Access Token 部分:

如果您点击“创建我的访问令牌”,Twitter 将为您生成访问令牌:

访问令牌创建后,您只需将数据复制到.twitterauth文件中。

构建 Twitter 投票应用程序

现在我们的环境已经设置好,我们已经看到了如何在 Twitter 上创建一个应用程序并执行三条腿的身份验证,现在是时候开始构建实际的应用程序来计算 Twitter 投票了。

我们首先创建一个模型类来表示一个标签。在twittervotes/core/twitter目录中创建一个名为hashtag.py的文件,内容如下:

class Hashtag:
    def __init__(self, name):
        self.name = name
        self.total = 0
  self.refresh_url = None

这是一个非常简单的类。我们可以将一个名称作为参数传递给初始化程序;名称是没有井号(#)的标签。在初始化程序中,我们定义了一些属性:名称,将设置为我们传递给初始化程序的参数,然后是一个名为total的属性,它将为我们保留标签的使用次数。

最后,我们设置refresh_urlrefresh_url将用于执行对 Twitter API 的查询,这里有趣的部分是refresh_url已经包含了最新返回的 tweet 的id,因此我们可以使用它来仅获取我们尚未获取的 tweet,以避免多次计数相同的 tweet。

refresh_url看起来像下面这样:

refresh_url': '?since_id=963341767532834817&q=%23python&result_type=mixed&include_entities=1

现在我们可以打开twittervotes/core/twitter目录中的__init__.py文件,并导入我们刚刚创建的类,如下所示:

from .hashtag import Hashtag

太棒了!现在继续在twittervotes/core/目录中创建一个名为request.py的文件。

像往常一样,我们开始添加一些导入:

import oauth2 as oauth
import time
from urllib.parse import parse_qsl
import json

import requests

from .config import read_config
from .config import read_reqauth

首先,我们导入oauth2包,我们将使用它来执行身份验证;我们准备请求,并用SHA1密钥对其进行签名。我们还导入time来设置OAuth时间戳设置。我们导入函数parse_qsl,我们将使用它来解析查询字符串,以便我们可以准备一个新的请求来搜索最新的 tweets,以及json模块,这样我们就可以反序列化 Twitter API 发送给我们的 JSON 数据。

然后,我们导入我们自己的函数read_configread_req_auth,这样我们就可以读取两个配置文件。最后,我们导入json包来解析结果和requests包来执行对 Twitter 搜索端点的实际请求:

def prepare_request(url, url_params):
    reqconfig = read_reqauth()
    config = read_config()

    token = oauth.Token(
        key=reqconfig.oauth_token,
        secret=reqconfig.oauth_token_secret)

    consumer = oauth.Consumer(
        key=config.consumer_key,
        secret=config.consumer_secret)

    params = {
        'oauth_version': "1.0",
        'oauth_nonce': oauth.generate_nonce(),
        'oauth_timestamp': str(int(time.time()))
    }

    params['oauth_token'] = token.key
    params['oauth_consumer_key'] = consumer.key

    params.update(url_params)

    req = oauth.Request(method="GET", url=url, parameters=params)

    signature_method = oauth.SignatureMethod_HMAC_SHA1()
    req.sign_request(signature_method, consumer, token)

    return req.to_url()

这个函数将读取两个配置文件——config.org配置文件包含我们需要的所有端点 URL,以及消费者密钥。.twitterauth文件包含我们将用于创建Token对象的oauth_tokenoauth_token_secret

之后,我们定义一些参数。根据 Twitter API 文档,oauth_version应该始终设置为1.0。我们还发送oauth_nonce,这是我们必须为每个请求生成的唯一令牌,最后是oauth_timestamp,这是请求创建的时间。Twitter 将拒绝在发送请求之前太长时间创建的请求。

我们附加到参数的最后一件事是oauth_token,它是存储在.twitterath文件中的令牌,以及消费者密钥,它是存储在config.yaml文件中的密钥。

我们执行一个请求来获取授权,如果一切顺利,我们用 SHA1 密钥对请求进行签名,并返回请求的 URL。

现在我们要添加一个函数,该函数将执行一个请求来搜索特定的标签,并将结果返回给我们。让我们继续添加另一个名为execute_request的函数:

def execute_request(hashtag):
    config = read_config()

 if hashtag.refresh_url:
        refresh_url = hashtag.refresh_url[1:]
        url_params = dict(parse_qsl(refresh_url))
 else:
        url_params = {
            'q': f'#{hashtag.name}',
            'result_type': 'mixed'
  }

    url = prepare_request(config.search_endpoint, url_params)

    data = requests.get(url)

    results = json.loads(data.text)

    return (hashtag, results, )

这个函数将以Hashtag对象作为参数,并且在这个函数中我们要做的第一件事是读取配置文件。然后我们检查Hashtag对象的refresh_url属性是否有值;如果有,我们将删除refresh_url字符串前面的?符号。

之后,我们使用函数parse_qsl来解析查询字符串,并返回一个元组列表,其中元组中的第一项是参数的名称,第二项是其值。例如,假设我们有一个看起来像这样的查询字符串:

'param1=1&param2=2&param3=3'

如果我们使用parse_qsl,将这个查询字符串作为参数传递,我们将得到以下列表:

[('param1', '1'), ('param2', '2'), ('param3', '3')]

然后,如果我们将这个结果传递给dict函数,我们将得到一个像这样的字典:

{'param1': '1', 'param2': '2', 'param3': '3'}

如我之前所示,refresh_url的格式如下:

refresh_url': '?since_id=963341767532834817&q=%23python&result_type=mixed&include_entities=1

在解析和转换为字典之后,我们可以使用它来获取底层标签的刷新数据。

如果Hashtag对象没有设置refresh_url属性,那么我们只需定义一个字典,其中q是标签名称,结果类型设置为mixed,告诉 Twitter API 它应该返回热门、最新和实时的推文。

在定义了搜索参数之后,我们使用上面创建的prepare_request函数来授权请求并对其进行签名;当我们得到 URL 后,我们使用从prepare_request函数得到的 URL 执行请求。

我们使用json.loads函数来解析 JSON 数据,并返回一个包含第一项,即标签本身的元组;第二项将是我们从请求中得到的结果。

最后一步,像往常一样,在核心模块的__init__.py文件中导入execute_request函数:

from .request import execute_request

让我们看看这在 Python REPL 中是如何工作的:

上面的输出比这个要大得多,但其中很多都被省略了;我只是想演示一下这个函数是如何工作的。

增强我们的代码

我们还希望为我们的用户提供良好的体验,因此我们将添加一个命令行解析器,这样我们的应用程序的用户可以在开始投票过程之前指定一些参数。我们将只实现一个参数,即--hashtags,用户可以传递一个以空格分隔的标签列表。

说到这一点,我们将为这些参数定义一些规则。首先,我们将限制我们要监视的标签的最大数量,因此我们将添加一个规则,即不能使用超过四个标签。

如果用户指定了超过四个标签,我们将简单地在终端上显示一个警告,并选择前四个标签。我们还希望删除重复的标签。

在显示我们谈论过的这些警告消息时,我们可以简单地在终端上打印它们,这肯定会起作用;然而,我们想要让事情变得更有趣,所以我们将使用日志包来做这件事。除此之外,实现适当的日志记录将使我们对我们想要拥有的日志类型以及如何向用户呈现它有更多的控制。

在我们开始实现命令行解析器之前,让我们添加日志记录器。在twittervotes/core目录中创建一个名为app_logger.py的文件,内容如下:

import os
import logging
from logging.config import fileConfig

def get_logger():
    core_dir = os.path.dirname(os.path.abspath(__file__))
    file_path = os.path.join(core_dir, '..', 'logconfig.ini')
    fileConfig(file_path)
    return logging.getLogger('twitterVotesLogger')

这个函数并没有做太多事情,但首先我们导入os模块,然后导入日志包,最后导入fileConfig函数,它从配置文件中读取日志配置。这个配置文件必须是configparser格式的,你可以在docs.python.org/3.6/library/logging.config.html#logging-config-fileformat获取有关这种格式的更多信息。

在我们读取配置文件之后,我们只返回一个名为twitterVotesLogger的记录器。

让我们看看我们的应用程序的配置文件是什么样的。在twittervotes目录中创建一个名为logconfig.ini的文件,内容如下:

[loggers]
keys=root,twitterVotesLogger

[handlers]
keys=consoleHandler

[formatters]
keys=simpleFormatter

[logger_root]
level=INFO
handlers=consoleHandler

[logger_twitterVotesLogger]
level=INFO
handlers=consoleHandler
qualname=twitterVotesLogger

[handler_consoleHandler]
class=StreamHandler
level=INFO
formatter=simpleFormatter
args=(sys.stdout,)

[formatter_simpleFormatter]
format=[%(levelname)s] %(asctime)s - %(message)s
datefmt=%Y-%m-%d %H:%M:%S

因此,我们在这里定义了两个记录器,roottwitterVotesLogger;记录器负责公开我们可以在运行时使用的记录消息的方法。也是通过记录器,我们可以设置严重程度的级别,例如INFODEBUG等。最后,记录器将日志消息传递给适当的处理程序。

在我们的twitterVotesLogger的定义中,我们将严重级别设置为INFO,将处理程序设置为consoleHandler(我们将很快描述这一点),并设置一个限定名称,以便在需要获取twitterVotesLogger时使用。

twitterVotesLoggers的最后一个选项是propagate。由于twitterVotesLogger是子记录器,我们不希望通过twittersVotesLogger发送的日志消息传播到其祖先。如果将propagate设置为0,则由于twitterVotesLogger的祖先是root记录器,每条日志消息都会显示两次。

日志配置中的下一个组件是处理程序。处理程序是将特定记录器的日志消息发送到目的地的组件。我们定义了一个名为consoleHandler的处理程序,类型为StreamHandler,这是日志模块的内置处理程序。StreamHandler将日志消息发送到诸如sys.stdoutsys.stderr或文件之类的流。这对我们来说非常完美,因为我们希望将消息发送到终端。

consoleHandler中,我们还将严重级别设置为INFO,并设置了格式化程序,该格式化程序设置为customFormatter;然后我们将 args 的值设置为(sys.stdout, )。Args 指定日志消息将被发送到的位置;在这种情况下,我们只设置了sys.stdout,但如果需要,可以添加多个输出流。

此配置的最后一个组件是格式化程序customFormatter。格式化程序简单地定义了日志消息应该如何显示。在我们的customFormatter中,我们只定义了消息应该如何显示并显示日期格式。

现在我们已经设置好了日志记录,让我们添加解析命令行的函数。在twittervotes/core中创建一个名为cmdline_parser.py的文件,并添加一些导入:

from argparse import ArgumentParser

from .app_logger import get_logger

然后我们需要添加一个函数来验证命令行参数:

def validated_args(args):

    logger = get_logger()

    unique_hashtags = list(set(args.hashtags))

    if len(unique_hashtags) < len(args.hashtags):
        logger.info(('Some hashtags passed as arguments were '
                     'duplicated and are going to be ignored'))

        args.hashtags = unique_hashtags

    if len(args.hashtags) > 4:
        logger.error('Voting app accepts only 4 hashtags at the 
        time')
        args.hashtags = args.hashtags[:4]

    return args

validate_args函数只有一个参数,即由ArgumentParser解析的参数。在此函数中,我们首先获取刚刚创建的记录器,以便向用户发送日志消息,通知可能存在的命令行参数问题。

接下来,我们将标签列表转换为集合,以便删除所有重复的标签,然后将其转换回列表。之后,我们检查唯一标签的数量是否小于在命令行传递的原始标签数量。这意味着我们有重复,并记录一条消息通知用户。

我们进行的最后一个验证是确保我们的应用程序最多监视四个标签。如果标签列表中的项目数大于四,则我们对数组进行切片,仅获取前四个项目,并且我们还记录一条消息,通知用户只会显示四个标签。

让我们添加另一个函数parse_commandline_args

def parse_commandline_args():
    argparser = ArgumentParser(
        prog='twittervoting',
        description='Collect votes using twitter hashtags.')

    required = argparser.add_argument_group('require arguments')

    required.add_argument(
        '-ht', '--hashtags',
        nargs='+',
        required=True,
        dest='hashtags',
        help=('Space separated list specifying the '
 'hashtags that will be used for the voting.\n'
 'Type the hashtags without the hash symbol.'))

    args = argparser.parse_args()

    return validated_args(args)

当我们在第一章开发应用程序时,我们看到了ArgumentParser的工作原理,即天气应用程序。但是,我们仍然可以了解一下这个函数的作用。

首先,我们定义了一个ArgumentParser对象,定义了一个名称和描述,并创建了一个名为required的子组,正如其名称所示,它将包含所有必填字段。

请注意,我们实际上不需要创建这个额外的组;但是,我发现这有助于保持代码更有组织性,并且在将来有必要添加新选项时更容易维护。

我们只定义了一个参数hashtags。在hashtags参数的定义中,有一个名为nargs的参数,我们将其设置为+;这意味着我可以传递由空格分隔的无限数量的项目,如下所示:

--hashtags item1 item2 item3

在这个函数中我们做的最后一件事是使用parse_args函数解析参数,并将参数通过之前展示的validate_args函数进行验证。

让我们在twittervotes/core目录中的__init__.py文件中导入parse_commandline_args函数:

from .cmdline_parser import parse_commandline_args

现在我们需要创建一个类,帮助我们管理标签并执行诸如保持标签的得分计数、在每次请求后更新其值等任务。因此,让我们继续创建一个名为HashtagStatsManager的类。在twittervotes/core/twitter中创建一个名为hashtagstats_manager.py的文件,内容如下:

from .hashtag import Hashtag

class HashtagStatsManager:

    def __init__(self, hashtags):

        if not hashtags:
            raise AttributeError('hashtags must be provided')

        self._hashtags = {hashtag: Hashtag(hashtag) for hashtag in 
         hashtags}

    def update(self, data):

        hashtag, results = data

        metadata = results.get('search_metadata')
        refresh_url = metadata.get('refresh_url')
        statuses = results.get('statuses')

        total = len(statuses)

        if total > 0:
            self._hashtags.get(hashtag.name).total += total
            self._hashtags.get(hashtag.name).refresh_url = 
            refresh_url

    @property
    def hashtags(self):
        return self._hashtags

这个类也非常简单:在构造函数中,我们获取一个标签列表并初始化一个属性_hashtags,它将是一个字典,其中键是标签的名称,值是Hashtag类的实例。

更新方法获取一个包含Hashtag对象和 Twitter API 返回结果的元组。首先,我们解包元组值并将其设置为hashtagresults变量。results字典对我们来说有两个有趣的项目。第一个是search_metadata;在这个项目中,我们将找到refresh_url,而statuses包含了使用我们搜索的标签的所有推文的列表。

因此,我们获得了search_metadatarefresh_url和最后statuses的值。然后我们计算statuses列表中有多少项。如果statuses列表中的项目数大于0,我们将更新底层标签的总计数以及其refresh_url

然后我们在twittervotes/core/twitter目录中的__init__.py文件中导入了我们刚刚创建的HashtagStatsManager类:

from .hashtagstats_manager import HashtagStatsManager

这个应用程序的核心是Runner类。这个类将执行一个函数并将其排入进程池。每个函数将在不同的进程中并行执行,这将使程序比我逐个执行这些函数要快得多。

让我们来看看Runner类是如何实现的:

import concurrent.futures

from rx import Observable

class Runner:

    def __init__(self, on_success, on_error, on_complete):
        self._on_success = on_success
        self._on_error = on_error
        self._on_complete = on_complete

    def exec(self, func, items):

        observables = []

        with concurrent.futures.ProcessPoolExecutor() as executor:
            for item in items.values():
                _future = executor.submit(func, item)
                observables.append(Observable.from_future(_future))

        all_observables = Observable.merge(observables)

        all_observables.subscribe(self._on_success,
                                  self._on_error,
                                  self._on_complete)

Runner类有一个初始化器,接受三个参数;它们都是在执行的不同状态下将被调用的函数。当项目的执行成功时将调用on_success,当一个函数的执行由于某种原因失败时将调用on_error,最后当队列中的所有函数都执行完毕时将调用on_complete

还有一个名为exec的方法,它以一个函数作为第一个参数,这个函数将被执行,第二个参数是一个Hashtag实例的列表。

Runner类中有一些有趣的东西。首先,我们使用了concurrent.futures模块,这是 Python 的一个非常好的补充,自 Python 3.2 以来一直存在;这个模块提供了异步执行可调用对象的方法。

concurrent.futures模块还提供了ThreadPoolExecutor,它将使用线程执行异步操作,以及ProcessPollExecutor,它使用进程。您可以根据自己的需求轻松切换这些执行策略。

经验法则是,如果您的函数是 CPU 绑定的,最好使用ProcessPollExecutor,否则,由于 Python 的全局解释器锁GIL),您将遇到性能问题。对于 I/O 绑定的操作,我更喜欢使用ThreadPoolExecutor

如果您想了解更多关于 GIL 的信息,可以查看以下维基页面:wiki.python.org/moin/GlobalInterpreterLock

由于我们没有进行任何 I/O 绑定的操作,我们使用ProcessPoolExecutor。然后,我们循环遍历项目的值,这是一个包含我们的应用程序正在监视的所有标签的字典。对于每个标签,我们将其传递给ProcessPollExecutorsubmit函数,以及我们要执行的函数;在我们的情况下,它将是我们应用程序的核心模块中定义的execute_request函数。

submit函数不会返回execute_request函数返回的值,而是返回一个future对象,它封装了execute_request函数的异步执行。future对象提供了取消执行、检查执行状态、获取执行结果等方法。

现在,我们希望有一种方法在执行状态改变或完成时得到通知。这就是响应式编程派上用场的地方。

在这里,我们获取future对象并创建一个ObservableObservables是响应式编程的核心。Observable是一个可以被观察并在任何给定时间发出事件的对象。当Observable发出事件时,所有订阅该Observable的观察者都将得到通知并对这些变化做出反应。

这正是我们在这里要实现的:我们有一系列未来的执行,我们希望在这些执行状态改变时得到通知。这些状态将由我们作为Runner初始化器参数传递的函数处理——_on_sucess_on_error_on_complete

完美!让我们在twittervotes/core目录的__init__.py中导入Runner类:

from .runner import Runner

我们项目的最后一部分是添加应用程序的入口点。我们将使用标准库中的Tkinter包添加用户界面。所以让我们开始实现它。在twittervotes目录中创建一个名为app.py的文件,然后让我们从添加一些导入开始:

from core import parse_commandline_args
from core import execute_request
from core import Runner

from core.twitter import HashtagStatsManager

from tkinter import Tk
from tkinter import Frame
from tkinter import Label
from tkinter import StringVar
from tkinter.ttk import Button

在这里,我们导入了我们创建的命令行参数解析器,execute_request来执行对 Twitter API 的请求,还有Runner类,它将帮助我们并行执行对 Twitter API 的请求。

我们还导入HashtagStatsManager来为我们管理标签投票结果。

最后,我们有与tkinter相关的所有导入。

在同一个文件中,让我们创建一个名为Application的类,如下所示:

class Application(Frame):

    def __init__(self, hashtags=[], master=None):
        super().__init__(master)

        self._manager = HashtagStatsManager(hashtags)

        self._runner = Runner(self._on_success,
                              self._on_error,
                              self._on_complete)

        self._items = {hashtag: StringVar() for hashtag in hashtags}
        self.set_header()
        self.create_labels()
        self.pack()

        self.button = Button(self, style='start.TButton', 
                             text='Update',
                             command=self._fetch_data)
        self.button.pack(side="bottom")

因此,在这里,我们创建了一个名为Application的类,它继承自Frame。初始化器接受两个参数:标签,这些是我们将要监视的标签,以及 master 参数,它是一个Tk类型的对象。

然后我们创建一个HashtagStatsManager的实例,传递标签列表;我们还创建Runner类的一个实例,传递三个参数。这些参数是在一个执行成功时将被调用的函数,执行失败时将被调用的函数,以及所有执行完成时将被调用的函数。

然后我们有一个字典推导式,它将创建一个字典,其中键是标签,值是Tkinter的字符串变量,Tkinter世界中称为StringVar。我们这样做是为了以后更容易更新标签的结果。

我们调用即将实现的set_headercreate_labels方法,最后调用packpack函数将组织小部件,如按钮和标签,并将它们放在父小部件中,本例中是Application

然后我们定义一个按钮,当点击时将执行_fetch_data函数,并使用pack将按钮放在框架的底部:

def set_header(self):
    title = Label(self,
                  text='Voting for hasthags',
                  font=("Helvetica", 24),
                  height=4)
    title.pack()

这是我之前提到的set_header方法;它只是创建Label对象并将它们放在框架的顶部。

现在我们可以添加create_labels方法:

def create_labels(self):
    for key, value in self._items.items():
        label = Label(self,
                      textvariable=value,
                      font=("Helvetica", 20), height=3)
        label.pack()
        self._items[key].set(f'#{key}\nNumber of votes: 0')

create_labels方法循环遍历self._items,如果您记得的话,这是一个字典,其中键是标签的名称,值是一个字符串类型的Tkinter变量。

首先,我们创建一个Label,有趣的部分是textvariable参数;我们将其设置为value,这是与特定标签相关的Tkinter变量。然后我们将Label放在框架中,最后,我们使用set函数设置标签的值。

然后我们需要添加一个方法来为我们更新Labels

def _update_label(self, data):
    hashtag, result = data

    total = self._manager.hashtags.get(hashtag.name).total

    self._items[hashtag.name].set(
        f'#{hashtag.name}\nNumber of votes: {total}')

_update_label,顾名思义,更新特定标签的标签。数据参数是 Twitter API 返回的结果,我们从管理器中获取标签的总数。最后,我们再次使用set函数来更新标签。

让我们添加另一个函数,实际上会发送请求到 Twitter API 的工作:

def _fetch_data(self):
    self._runner.exec(execute_request,
                      self._manager.hashtags)

这种方法将调用Runnerexec方法来执行执行请求 Twitter API 的函数。

然后我们需要定义处理Runner类中创建的Observable发出的事件的方法;我们首先添加处理执行错误的方法:

def _on_error(self, error_message):
    raise Exception(error_message)

这是一个helper方法,只是为了在请求执行出现问题时引发异常。

然后我们添加另一个处理Observable执行成功的方法:

def _on_success(self, data):
    hashtag, _ = data
    self._manager.update(data)
    self._update_label(data)

_on_success方法将在Runner的一个执行成功完成时被调用,它将只是更新管理器的新数据,并在 UI 中更新标签。

最后,我们定义一个处理所有执行完成的方法:

def _on_complete(self):
    pass

_on_complete将在所有Runner的执行完成时被调用。我们不会使用它,所以我们只使用pass语句。

现在是时候实现设置应用程序并初始化 UI 的函数start_app了:

def start_app(args):
    root = Tk()

    app = Application(hashtags=args.hashtags, master=root)
    app.master.title("Twitter votes")
    app.master.geometry("400x700+100+100")
    app.mainloop()

此函数创建根应用程序,设置标题,定义其尺寸,并调用mainloop函数,以便应用程序保持运行。

最后一步是定义main函数:

def main():
    args = parse_commandline_args()
    start_app(args)

if __name__ == '__main__':
    main()

main函数非常简单。首先,我们解析命令行参数,然后启动应用程序,并将命令行参数传递给它。

让我们看看应用程序的运行情况!运行以下命令:

python app.py --help

您将看到以下输出:

假设我们希望投票过程运行 3 分钟,并且它将监视#debian#ubuntu#arch这些标签:

python app.py --hashtags debian ubuntu arch

然后您应该看到以下 UI:

如果您点击更新按钮,每个标签的计数都将被更新。

总结

在本章中,我们开发了一个在 Twitter 上投票的应用程序,并学习了 Python 编程语言的不同概念和范式。

通过创建标签投票应用程序,您已经学会了如何创建和配置 Twitter 应用程序,以及如何实现三条腿的OAuth身份验证来消费 Twitter API 的数据。

我们还学会了如何使用日志记录模块向我们的应用程序用户显示信息消息。与之前的模块一样,我们还使用标准库中的ArgumentParser模块创建了一个命令行解析器。

我们还介绍了使用Rx(Python 的响应式扩展)模块进行响应式编程。然后我们使用concurrent.futures模块来增强我们应用程序的性能,以并行方式运行多个请求到 Twitter API。

最后,我们使用Tkinter模块构建了一个用户界面。

在下一章中,我们将构建一个应用程序,该应用程序将从网站fixer.io获取汇率数据以进行货币转换。

第四章:汇率和货币转换工具

在上一章中,我们构建了一个非常酷的应用程序,用于在 Twitter 上计算投票,并学习了如何使用 Python 进行身份验证和消费 Twitter API。我们还对如何在 Python 中使用响应式扩展有了很好的介绍。在本章中,我们将创建一个终端工具,该工具将从fixer.io获取当天的汇率,并使用这些信息来在不同货币之间进行价值转换。

Fixer.io是由github.com/hakanensari创建的一个非常好的项目;它每天从欧洲央行获取外汇汇率数据。他创建的 API 使用起来简单,并且运行得很好。

我们的项目首先通过创建围绕 API 的框架来开始;当框架就位后,我们将创建一个终端应用程序,可以在其中执行货币转换。我们从fixer.io获取的所有数据都将存储在 MongoDB 数据库中,因此我们可以在不一直请求fixer.io的情况下执行转换。这将提高我们应用程序的性能。

在本章中,我们将涵盖以下内容:

  • 如何使用pipenv来安装和管理项目的依赖项

  • 使用 PyMongo 模块与 MongoDB 一起工作

  • 使用 Requests 消费 REST API

说了这么多,让我们开始吧!

设置环境

像往常一样,我们将从设置环境开始;我们需要做的第一件事是设置一个虚拟环境,这将允许我们轻松安装项目依赖项,而不会干扰 Python 的全局安装。

在之前的章节中,我们使用virtualenv来创建我们的虚拟环境;然而,Kenneth Reitz(流行包requests的创建者)创建了pipenv

pipenv对于 Python 来说就像 NPM 对于 Node.js 一样。但是,pipenv用于远不止包管理,它还为您创建和管理虚拟环境。在我看来,旧的开发工作流有很多优势,但对我来说,有两个方面很突出:第一个是您不再需要两种不同的工具(pipvirtualenv),第二个是在一个地方拥有所有这些强大功能变得更加简单。

我非常喜欢pipenv的另一点是使用Pipfile。有时,使用要求文件真的很困难。我们的生产环境和开发环境具有相同的依赖关系,您最终需要维护两个不同的文件;而且,每次需要删除一个依赖项时,您都需要手动编辑要求文件。

使用pipenv,您无需担心有多个要求文件。开发和生产依赖项都放在同一个文件中,pipenv还负责更新Pipfile

安装pipenv非常简单,只需运行:

pip install pipenv

安装后,您可以运行:

pipenv --help

您应该看到以下输出:

我们不会详细介绍所有不同的选项,因为这超出了本书的范围,但在创建环境时,您将掌握基础知识。

第一步是为我们的项目创建一个目录。让我们创建一个名为currency_converter的目录:

mkdir currency_converter && cd currency_converter

现在您在currency_converter目录中,我们将使用pipenv来创建我们的虚拟环境。运行以下命令:

pipenv --python python3.6

这将为当前目录中的项目创建一个虚拟环境,并使用 Python 3.6。--python选项还接受您安装 Python 的路径。在我的情况下,我总是下载 Python 源代码,构建它,并将其安装在不同的位置,因此这对我非常有用。

您还可以使用--three选项,它将使用系统上默认的 Python3 安装。运行命令后,您应该看到以下输出:

如果你查看Pipfile的内容,你应该会看到类似以下的内容:

[[source]]

url = "https://pypi.python.org/simple"
verify_ssl = true
name = "pypi"

[dev-packages]

[packages]

[requires]

python_version = "3.6"

这个文件开始定义从哪里获取包,而在这种情况下,它将从pypi下载包。然后,我们有一个地方用于项目的开发依赖项,在packages中是生产依赖项。最后,它说这个项目需要 Python 版本 3.6。

太棒了!现在你可以使用一些命令。例如,如果你想知道项目使用哪个虚拟环境,你可以运行pipenv --venv;你将看到以下输出:

如果你想为项目激活虚拟环境,你可以使用shell命令,如下所示:

完美!有了虚拟环境,我们可以开始添加项目的依赖项。

我们要添加的第一个依赖是requests

运行以下命令:

pipenv install requests

我们将得到以下输出:

正如你所看到的,pipenv安装了requests以及它的所有依赖项。

pipenv的作者是创建流行的 requests 库的同一个开发者。在安装输出中,你可以看到一个彩蛋,上面写着PS: You have excellent taste!

我们需要添加到我们的项目中的另一个依赖是pymongo,这样我们就可以连接和操作 MongoDB 数据库中的数据。

运行以下命令:

pipenv install pymongo

我们将得到以下输出:

让我们来看看Pipfile,看看它现在是什么样子:

[[source]]

url = "https://pypi.python.org/simple"
verify_ssl = true
name = "pypi"

[dev-packages]

[packages]

requests = "*"
pymongo = "*"

[requires]

python_version = "3.6"

正如你所看到的,在packages文件夹下,我们现在有了两个依赖项。

与使用pip安装包相比,没有太多改变。唯一的例外是现在安装和移除依赖项将自动更新Pipfile

另一个非常有用的命令是graph命令。运行以下命令:

pipenv graph

我们将得到以下输出:

正如你所看到的,graph命令在你想知道你安装的包的依赖关系时非常有帮助。在我们的项目中,我们可以看到pymongo没有任何额外的依赖项。然而,requests有四个依赖项:certifichardetidnaurllib3

现在你已经对pipenv有了很好的介绍,让我们来看看这个项目的结构会是什么样子:

currency_converter
└── currency_converter
    ├── config
    ├── core   

currency_converter的顶层是应用程序的root目录。然后,我们再往下一级,有另一个currency_converter,那就是我们将要创建的currency_converter模块。

currency_converter模块目录中,我们有一个核心,其中包含应用程序的核心功能,例如命令行参数解析器,处理数据的辅助函数等。

我们还配置了,与其他项目一样,哪个项目将包含读取 YAML 配置文件的函数;最后,我们有 HTTP,其中包含所有将执行 HTTP 请求到fixer.io REST API 的函数。

现在我们已经学会了如何使用pipenv以及它如何帮助我们提高生产力,我们可以安装项目的初始依赖项。我们也创建了项目的目录结构。拼图的唯一缺失部分就是安装 MongoDB。

我正在使用 Linux Debian 9,我可以很容易地使用 Debian 的软件包管理工具来安装它:

sudo apt install mongodb

你会在大多数流行的 Linux 发行版的软件包存储库中找到 MongoDB,如果你使用 Windows 或 macOS,你可以在以下链接中看到说明:

对于 macOS:docs.mongodb.com/manual/tutorial/install-mongodb-on-os-x/

对于 Windows:docs.mongodb.com/manual/tutorial/install-mongodb-on-windows/

安装完成后,您可以使用 MongoDB 客户端验证一切是否正常工作。打开终端,然后运行mongo命令。

然后你应该进入 MongoDB shell:

MongoDB shell version: 3.2.11
connecting to: test

要退出 MongoDB shell,只需键入*CTRL *+ D.

太棒了!现在我们准备开始编码!

创建 API 包装器

在这一部分,我们将创建一组函数,这些函数将包装fixer.io API,并帮助我们在项目中以简单的方式使用它。

让我们继续在currency_converter/currency_converter/core目录中创建一个名为request.py的新文件。首先,我们将包括一些import语句:

import requests
from http import HTTPStatus
import json

显然,我们需要requests,以便我们可以向fixer.io端点发出请求,并且我们还从 HTTP 模块导入HTTPStatus,以便我们可以返回正确的 HTTP 状态码;在我们的代码中也更加详细。在代码中,HTTPStatus.OK的返回要比只有200更加清晰和易读。

最后,我们导入json包,以便我们可以将从fixer.io获取的 JSON 内容解析为 Python 对象。

接下来,我们将添加我们的第一个函数。这个函数将返回特定货币的当前汇率:

def fetch_exchange_rates_by_currency(currency):
    response = requests.get(f'https://api.fixer.io/latest?base=
                            {currency}')

    if response.status_code == HTTPStatus.OK:
        return json.loads(response.text)
    elif response.status_code == HTTPStatus.NOT_FOUND:
        raise ValueError(f'Could not find the exchange rates for: 
                         {currency}.')
    elif response.status_code == HTTPStatus.BAD_REQUEST:
        raise ValueError(f'Invalid base currency value: {currency}')
    else:
        raise Exception((f'Something went wrong and we were unable 
                         to fetch'
                         f' the exchange rates for: {currency}'))

这个函数以货币作为参数,并通过向fixer.io API 发送请求来获取使用该货币作为基础的最新汇率信息,这是作为参数给出的。

如果响应是HTTPStatus.OK200),我们使用 JSON 模块的 load 函数来解析 JSON 响应;否则,我们根据发生的错误引发异常。

我们还可以在currency_converter/currency_converter/core目录中创建一个名为__init__.py的文件,并导入我们刚刚创建的函数:

from .request import fetch_exchange_rates_by_currency

太好了!让我们在 Python REPL 中试一下:

Python 3.6.3 (default, Nov 21 2017, 06:53:07)
[GCC 6.3.0 20170516] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from currency_converter.core import fetch_exchange_rates_by_currency
>>> from pprint import pprint as pp
>>> exchange_rates = fetch_exchange_rates_by_currency('BRL')
>>> pp(exchange_rates)
{'base': 'BRL',
 'date': '2017-12-06',
 'rates': {'AUD': 0.40754,
 'BGN': 0.51208,
 'CAD': 0.39177,
 'CHF': 0.30576,
 'CNY': 2.0467,
 'CZK': 6.7122,
 'DKK': 1.9486,
 'EUR': 0.26183,
 'GBP': 0.23129,
 'HKD': 2.4173,
 'HRK': 1.9758,
 'HUF': 82.332,
 'IDR': 4191.1,
 'ILS': 1.0871,
 'INR': 19.963,
 'JPY': 34.697,
 'KRW': 338.15,
 'MXN': 5.8134,
 'MYR': 1.261,
 'NOK': 2.5548,
 'NZD': 0.4488,
 'PHP': 15.681,
 'PLN': 1.1034,
 'RON': 1.2128,
 'RUB': 18.273,
 'SEK': 2.599,
 'SGD': 0.41696,
 'THB': 10.096,
 'TRY': 1.191,
 'USD': 0.3094,
 'ZAR': 4.1853}}

太棒了!它的工作方式正如我们所期望的那样。

接下来,我们将开始构建数据库辅助类。

添加数据库辅助类

现在我们已经实现了从fixer.io获取汇率信息的函数,我们需要添加一个类,该类将检索并保存我们获取的信息到我们的 MongoDB 中。

那么,让我们继续在currency_converter/currency_converter/core目录中创建一个名为db.py的文件;让我们添加一些import语句:

  from pymongo import MongoClient

我们唯一需要import的是MongoClientMongoClient将负责与我们的数据库实例建立连接。

现在,我们需要添加DbClient类。这个类的想法是作为pymongo包函数的包装器,并提供一组更简单的函数,抽象出一些在使用pymongo时重复的样板代码。

class DbClient:

    def __init__(self, db_name, default_collection):
        self._db_name = db_name
        self._default_collection = default_collection
        self._db = None

一个名为DbClient的类,它的构造函数有两个参数,db_namedefault_collection。请注意,在 MongoDB 中,我们不需要在使用之前创建数据库和集合。当我们第一次尝试插入数据时,数据库和集合将被自动创建。

如果您习惯于使用 MySQL 或 MSSQL 等 SQL 数据库,这可能看起来有些奇怪,在那里您必须连接到服务器实例,创建数据库,并在使用之前创建所有表。

在这个例子中,我们不关心安全性,因为 MongoDB 超出了本书的范围,我们只关注 Python。

然后,我们将向数据库添加两个方法,connectdisconnect

    def connect(self):
        self._client = MongoClient('mongodb://127.0.0.1:27017/')
        self._db = self._client.get_database(self._db_name)

    def disconnect(self):
        self._client.close()

connect方法将使用MongoClient连接到我们的本地主机上的数据库实例,使用端口27017,这是 MongoDB 安装后默认运行的端口。这两个值可能在您的环境中有所不同。disconnect方法只是调用客户端的 close 方法,并且,顾名思义,它关闭连接。

现在,我们将添加两个特殊函数,__enter____exit__

    def __enter__(self):
        self.connect()
        return self

    def __exit__(self, exec_type, exec_value, traceback):
        self.disconnect()

        if exec_type:
            raise exec_type(exec_value)

        return self

我们希望DbClient类在其自己的上下文中使用,并且这是通过使用上下文管理器和with语句来实现的。上下文管理器的基本实现是通过实现这两个函数__enter____exit__。当我们进入DbClient正在运行的上下文时,将调用__enter__。在这种情况下,我们将调用connect方法来连接到我们的 MongoDB 实例。

另一方面,__exit__方法在当前上下文终止时被调用。上下文可以由正常原因或抛出的异常终止。在我们的情况下,我们从数据库断开连接,如果exec_type不等于None,这意味着如果发生了异常,我们会引发该异常。这是必要的,否则在DbClient上下文中发生的异常将被抑制。

现在,我们将添加一个名为_get_collection的私有方法:

    def _get_collection(self):
        if self._default_collection is None:
            raise AttributeError('collection argument is required')

        return self._db[self._default_collection]

这个方法将简单地检查我们是否定义了default_collection。如果没有,它将抛出一个异常;否则,我们返回集合。

我们只需要两个方法来完成这个类,一个是在数据库中查找项目,另一个是插入或更新数据:

    def find_one(self, filter=None):
        collection = self._get_collection()
        return collection.find_one(filter)

    def update(self, filter, document, upsert=True):
        collection = self._get_collection()

        collection.find_one_and_update(
            filter,
            {'$set': document},
            upsert=upsert)

find_one方法有一个可选参数叫做 filter,它是一个带有条件的字典,将用于执行搜索。如果省略,它将只返回集合中的第一项。

在 update 方法中还有一些其他事情。它有三个参数:filterdocument,以及可选参数upsert

filter参数与find_one方法完全相同;它是一个用于搜索我们想要更新的集合项的条件。

document参数是一个包含我们想要在集合项中更新或插入的字段的字典。

最后,可选参数upsert,当设置为True时,意味着如果我们要更新的项目在数据库的集合中不存在,那么我们将执行插入操作并将项目添加到集合中。

该方法首先获取默认集合,然后使用集合的find_on_and_update方法,将filter传递给包含我们要更新的字段的字典,还有upsert选项。

我们还需要使用以下内容更新currency_converter/currency_converter/core目录中的__init__.py文件:

from .db import DbClient

太好了!现在,我们可以开始创建命令行解析器了。

创建命令行解析器

我必须坦白一件事:我是一个命令行类型的人。是的,我知道有些人认为它已经过时了,但我喜欢在终端上工作。我绝对更有生产力,如果你使用 Linux 或 macOS,你可以结合工具来获得你想要的结果。这就是我们要为这个项目添加命令行解析器的原因。

我们需要实现一些东西才能开始创建命令行解析器。我们要添加的一个功能是设置默认货币的可能性,这将避免我们的应用用户总是需要指定基础货币来执行货币转换。

为了做到这一点,我们将创建一个动作,我们已经在第一章中看到了动作是如何工作的,实现天气应用程序,但是为了提醒我们,动作是可以绑定到命令行参数以执行某个任务的类。当命令行中使用参数时,这些动作会自动调用。

在进行自定义操作的开发之前,我们需要创建一个函数,从数据库中获取我们应用程序的配置。首先,我们将创建一个自定义异常,用于在无法从数据库中检索配置时引发错误。在currency_converter/currency_converter/config目录中创建一个名为config_error.py的文件,内容如下:

    class ConfigError(Exception):
      pass

完美!这就是我们创建自定义异常所需要的全部内容。我们本可以使用内置异常,但那对我们的应用程序来说太具体了。为您的应用程序创建自定义异常总是一个很好的做法;当排除错误时,它将使您和您的同事的生活变得更加轻松。

currency_converter/currency_converter/config/目录中创建一个名为config.py的文件,内容如下:

from .config_error import ConfigError
from currency_converter.core import DbClient

def get_config():
    config = None

    with DbClient('exchange_rates', 'config') as db:
        config = db.find_one()

    if config is None:
        error_message = ('It was not possible to get your base 
                        currency, that '
                       'probably happened because it have not been '
                         'set yet.\n Please, use the option '
                         '--setbasecurrency')
        raise ConfigError(error_message)

    return config

在这里,我们首先从import语句开始。我们开始导入我们刚刚创建的ConfigError自定义异常,还导入DbClient类,以便我们可以访问数据库来检索应用程序的配置。

然后,我们定义了get_config函数。这个函数不会接受任何参数,函数首先定义了一个值为None的变量 config。然后,我们使用DbClient连接到exchange_rate数据库,并使用名为config的集合。在DbClient上下文中,我们使用find_one方法,没有任何参数,这意味着将返回该配置集合中的第一项。

如果config变量仍然是None,我们会引发一个异常,告诉用户数据库中还没有配置,需要再次运行应用程序并使用--setbasecurrency参数。我们将很快实现命令行参数。如果我们有配置的值,我们只需返回它。

我们还需要在currency_converter/currency_converter/config目录中创建一个__init__.py文件,内容如下:

from .config import get_config

现在,让我们开始添加我们的第一个操作,它将设置默认货币。在currency_converter/currency_converter/core目录中添加一个名为actions.py的文件:

  import sys
  from argparse import Action
  from datetime import datetime

  from .db import DbClient
  from .request import fetch_exchange_rates_by_currency
  from currency_converter.config import get_config

首先,我们导入sys,这样我们就可以在程序出现问题时终止执行。然后,我们从argparse模块中导入Action。在创建自定义操作时,我们需要从Action继承一个类。我们还导入datetime,因为我们将添加功能来检查我们将要使用的汇率是否过时。

然后,我们导入了一些我们创建的类和函数。我们首先导入DbClient,这样我们就可以从 MongoDB 中获取和存储数据,然后导入fetch_exchange_rates_by_currency以在必要时从fixer.io获取最新数据。最后,我们导入一个名为get_config的辅助函数,这样我们就可以从数据库的配置集合中获取默认货币。

让我们首先添加SetBaseCurrency类:

class SetBaseCurrency(Action):
    def __init__(self, option_strings, dest, args=None, **kwargs):
        super().__init__(option_strings, dest, **kwargs)

在这里,我们定义了SetBaseCurrency类,继承自Action,并添加了一个构造函数。它并没有做太多事情;它只是调用了基类的构造函数。

现在,我们需要实现一个特殊的方法叫做__call__。当解析绑定到操作的参数时,它将被调用:

    def __call__(self, parser, namespace, value, option_string=None):
        self.dest = value

        try:
            with DbClient('exchange_rates', 'config') as db:
                db.update(
                    {'base_currency': {'$ne': None}},
                    {'base_currency': value})

            print(f'Base currency set to {value}')
        except Exception as e:
            print(e)
        finally:
            sys.exit(0)

这个方法有四个参数,解析器是我们即将创建的ArgumentParser的一个实例。namespace是参数解析器的结果的对象;我们在第一章中详细介绍了命名空间对象,实现天气应用程序。值是传递给基础参数的值,最后,option_string是操作绑定到的参数。

我们通过为参数设置值、目标变量和创建DbClient的实例来开始该方法。请注意,我们在这里使用with语句,因此我们在DbClient上下文中运行更新。

然后,我们调用update方法。在这里,我们向update方法传递了两个参数,第一个是filter。当我们有{'base_currrency': {'$ne': None}}时,这意味着我们将更新集合中基础货币不等于 None 的项目;否则,我们将插入一个新项目。这是DbClient类中update方法的默认行为,因为我们默认将upsert选项设置为True

当我们完成更新时,我们向用户打印消息,说明默认货币已设置,并且当我们触发finally子句时,我们退出代码的执行。如果出现问题,由于某种原因,我们无法更新config集合,将显示错误并退出程序。

我们需要创建的另一个类是UpdateForeignerExchangeRates类:

class UpdateForeignerExchangeRates(Action):
    def __init__(self, option_strings, dest, args=None, **kwargs):
        super().__init__(option_strings, dest, **kwargs)

与之前的类一样,我们定义类并从Action继承。构造函数只调用基类中的构造函数:

def __call__(self, parser, namespace, value, option_string=None):

        setattr(namespace, self.dest, True)

        try:
            config = get_config()
            base_currency = config['base_currency']
            print(('Fetching exchange rates from fixer.io'
                   f' [base currency: {base_currency}]'))
            response = 
            fetch_exchange_rates_by_currency(base_currency)
            response['date'] = datetime.utcnow()

            with DbClient('exchange_rates', 'rates') as db:
                db.update(
                    {'base': base_currency},
                    response)
        except Exception as e:
            print(e)
        finally:
            sys.exit(0)

我们还需要实现__call__方法,当使用此操作绑定到的参数时将调用该方法。我们不会再次讨论方法参数,因为它与前一个方法完全相同。

该方法开始时将目标属性的值设置为True。我们将用于运行此操作的参数不需要参数,并且默认为False,因此如果我们使用参数,我们将其设置为True。这只是一种表明我们已经使用了该参数的方式。

然后,我们从数据库中获取配置并获取base_currency。我们向用户显示一条消息,告诉他们我们正在从fixer.io获取数据,然后我们使用我们的fetch_exchange_rates_by_currency函数,将base_currency传递给它。当我们得到响应时,我们将日期更改为 UTC 时间,这样我们就可以更容易地计算给定货币的汇率是否需要更新。

请记住,fixer.io在中欧时间下午 4 点左右更新其数据。

然后,我们创建DbClient的另一个实例,并使用带有两个参数的update方法。第一个是filter,因此它将更改与条件匹配的集合中的任何项目,第二个参数是我们从fixer.io API 获取的响应。

在所有事情都完成之后,我们触发finally子句并终止程序的执行。如果出现问题,我们会在终端向用户显示一条消息,并终止程序的执行。

创建货币枚举

在开始命令行解析器之前,我们还需要创建一个枚举,其中包含我们的应用程序用户可以选择的可能货币。让我们继续在currency_converter/currency_converter/core目录中创建一个名为currency.py的文件,其中包含以下内容:

from enum import Enum

class Currency(Enum):
    AUD = 'Australia Dollar'
    BGN = 'Bulgaria Lev'
    BRL = 'Brazil Real'
    CAD = 'Canada Dollar'
    CHF = 'Switzerland Franc'
    CNY = 'China Yuan/Renminbi'
    CZK = 'Czech Koruna'
    DKK = 'Denmark Krone'
    GBP = 'Great Britain Pound'
    HKD = 'Hong Kong Dollar'
    HRK = 'Croatia Kuna'
    HUF = 'Hungary Forint'
    IDR = 'Indonesia Rupiah'
    ILS = 'Israel New Shekel'
    INR = 'India Rupee'
    JPY = 'Japan Yen'
    KRW = 'South Korea Won'
    MXN = 'Mexico Peso'
    MYR = 'Malaysia Ringgit'
    NOK = 'Norway Kroner'
    NZD = 'New Zealand Dollar'
    PHP = 'Philippines Peso'
    PLN = 'Poland Zloty'
    RON = 'Romania New Lei'
    RUB = 'Russia Rouble'
    SEK = 'Sweden Krona'
    SGD = 'Singapore Dollar'
    THB = 'Thailand Baht'
    TRY = 'Turkish New Lira'
    USD = 'USA Dollar'
    ZAR = 'South Africa Rand'
    EUR = 'Euro'

这非常简单。我们已经在之前的章节中介绍了 Python 中的枚举,但在这里,我们定义了枚举,其中键是货币的缩写,值是名称。这与fixer.io中可用的货币相匹配。

打开currency_converter/currency_converter/core目录中的__init__.py文件,并添加以下导入语句:

from .currency import Currency

创建命令行解析器

完美!现在,我们已经准备好创建命令行解析器。让我们继续在currency_converter/currency_converter/core目录中创建一个名为cmdline_parser.py的文件,然后像往常一样,让我们开始导入我们需要的一切:

import sys
from argparse import ArgumentParser

from .actions import UpdateForeignerExchangeRates
from .actions import SetBaseCurrency
from .currency import Currency

从顶部开始,我们导入sys,这样如果出现问题,我们可以退出程序。我们还包括ArgumentParser,这样我们就可以创建解析器;我们还导入了我们刚刚创建的UpdateforeignerExchangeRatesSetBaseCurrency动作。在Currency枚举中的最后一件事是,我们将使用它来在解析器中的某些参数中设置有效的选择。

创建一个名为parse_commandline_args的函数:

def parse_commandline_args():

    currency_options = [currency.name for currency in Currency]

    argparser = ArgumentParser(
        prog='currency_converter',
        description=('Tool that shows exchange rated and perform '
                     'currency convertion, using http://fixer.io 
                       data.'))

这里我们要做的第一件事是只获取Currency枚举键的名称;这将返回一个类似这样的列表:

在这里,我们最终创建了ArgumentParser的一个实例,并传递了两个参数:prog,这是程序的名称,我们可以称之为currency_converter,第二个是description(当在命令行中传递help参数时,将显示给用户的描述)。

这是我们要在--setbasecurrency中添加的第一个参数:

argparser.add_argument('--setbasecurrency',
                           type=str,
                           dest='base_currency',
                           choices=currency_options,
                           action=SetBaseCurrency,
                           help='Sets the base currency to be 
                           used.')

我们定义的第一个参数是--setbasecurrency。它将把货币存储在数据库中,这样我们就不需要在命令行中一直指定基础货币。我们指定这个参数将被存储为一个字符串,并且用户输入的值将被存储在一个名为base_currency的属性中。

我们还将参数选择设置为我们在前面的代码中定义的currency_options。这将确保我们只能传递与Currency枚举匹配的货币。

action指定了当使用此参数时将执行哪个动作,我们将其设置为我们在actions.py文件中定义的SetBaseCurrency自定义动作。最后一个选项help是在显示应用程序帮助时显示的文本。

让我们添加--update参数:

 argparser.add_argument('--update',
                           metavar='',
                           dest='update',
                           nargs=0,
                           action=UpdateForeignerExchangeRates,
                           help=('Update the foreigner exchange 
                                  rates '
                                 'using as a reference the base  
                                  currency'))

--update参数,顾名思义,将更新默认货币的汇率。它在--setbasecurrency参数之后使用。

在这里,我们使用名称--update定义参数,然后设置metavar参数。当生成帮助时,metavar关键字--update将被引用。默认情况下,它与参数的名称相同,但是大写。由于我们没有任何需要传递给此参数的值,我们将metavar设置为无。下一个参数是nargs,它告诉argparser这个参数不需要传递值。最后,我们设置action为我们之前创建的另一个自定义动作,即UpdateForeignExchangeRates动作。最后一个参数是help,它指定了参数的帮助文本。

下一个参数是--basecurrency参数:

argparser.add_argument('--basecurrency',
                           type=str,
                           dest='from_currency',
                           choices=currency_options,
                           help=('The base currency. If specified it 
                                  will '
                                 'override the default currency set 
                                  by'
                                 'the --setbasecurrency option'))

这个参数的想法是,我们希望允许用户在请求货币转换时覆盖他们使用--setbasecurrency参数设置的默认货币。

在这里,我们使用名称--basecurrency定义参数。使用string类型,我们将把传递给参数的值存储在一个名为from_currency的属性中;我们还在这里将选择设置为currency_option,这样我们就可以确保只有在Currency枚举中存在的货币才被允许。最后,我们设置了帮助文本。

我们要添加的下一个参数称为--value。这个参数将接收我们的应用程序用户想要转换为另一种货币的值。

这是我们将如何编写它的方式:

argparser.add_argument('--value',
                           type=float,
                           dest='value',
                           help='The value to be converted')

在这里,我们将参数的名称设置为--value。请注意,类型与我们之前定义的参数不同。现在,我们将接收一个浮点值,并且参数解析器将把传递给--value参数的值存储到名为 value 的属性中。最后一个参数是help文本。

最后,我们要添加的最后一个参数是指定值将被转换为哪种货币的参数,将被称为--to

   argparser.add_argument('--to',
                           type=str,
                           dest='dest_currency',
                           choices=currency_options,
                           help=('Specify the currency that the value 
                                  will '
                                 'be converted to.'))

这个参数与我们在前面的代码中定义的--basecurrency参数非常相似。在这里,我们将参数的名称设置为--to,它将是string类型。传递给此参数的值将存储在名为dest_currency的属性中。在这里,我们还将参数的选择设置为我们从Currency枚举中提取的有效货币列表;最后,我们设置帮助文本。

基本验证

请注意,我们定义的许多参数是必需的。然而,有一些参数是相互依赖的,例如参数--value--to。您不能尝试转换价值而不指定要转换的货币,反之亦然。

这里的另一个问题是,由于许多参数是必需的,如果我们在不传递任何参数的情况下运行应用程序,它将接受并崩溃;在这里应该做的正确的事情是,如果用户没有使用任何参数,我们应该显示帮助菜单。也就是说,我们需要添加一个函数来执行这种类型的验证,所以让我们继续添加一个名为validate_args的函数。您可以在import语句之后的顶部添加此函数:

def validate_args(args):

    fields = [arg for arg in vars(args).items() if arg]

    if not fields:
        return False

    if args.value and not args.dest_currency:
        return False
    elif args.dest_currency and not args.value:
        return False

    return True

因此,args将被传递给这个函数。args实际上是timenamespace的对象。这个对象将包含与我们在参数定义中指定的相同名称的属性。在我们的情况下,namespace将包含这些属性:base_currencyupdatefrom_currencyvaluedest_currency

我们使用一个理解来获取所有未设置为None的字段。在这个理解中,我们使用内置函数vars,它将返回args__dict__属性的值,这是Namespace对象的一个实例。然后,我们使用.items()函数,这样我们就可以遍历字典项,并逐一测试其值是否为None

如果在命令行中传递了任何参数,那么这个理解的结果将是一个空列表,在这种情况下,我们返回False

然后,我们测试需要成对使用的参数:--value(value)和--todest_currency)。如果我们有一个值,但dest_currency等于None,反之亦然,它将返回False

现在,我们可以完成parse_commandline_args。让我们转到此函数的末尾,并添加以下代码:

      args = argparser.parse_args()

      if not validate_args(args):
          argparser.print_help()
          sys.exit()

      return args

在这里,我们解析参数并将它们设置为变量args,请记住args将是namespace类型。然后,我们将args传递给我们刚刚创建的函数,即validate_args函数。如果validate_args返回False,它将打印帮助信息并终止程序的执行;否则,它将返回args

接下来,我们将开发应用程序的入口点,它将把我们到目前为止开发的所有部分粘合在一起。

添加应用程序的入口点

这是本章我们一直在等待的部分;我们将创建应用程序的入口点,并将迄今为止编写的所有代码粘合在一起。

让我们在currency_converter/currency_converter目录中创建一个名为__main__.py的文件。我们之前在第一章中已经使用过__main__文件,实现天气应用程序。当我们在模块的root目录中放置一个名为__main__.py的文件时,这意味着该文件是模块的入口脚本。因此,如果我们运行以下命令:

python -m currency_converter 

这与运行以下命令相同:

python currency_converter/__main__.py

太好了!让我们开始向这个文件添加内容。首先,添加一些import语句:

import sys

from .core.cmdline_parser import parse_commandline_args
from .config import get_config
from .core import DbClient
from .core import fetch_exchange_rates_by_currency

我们像往常一样导入sys包,以防需要调用 exit 来终止代码的执行,然后导入到目前为止我们开发的所有类和实用函数。我们首先导入parse_commandline_args函数进行命令行解析,然后导入get_config以便我们可以获取用户设置的默认货币,导入DbClient类以便我们可以访问数据库并获取汇率;最后,我们还导入fetch_exchange_rates_by_currency函数,当我们选择尚未在我们的数据库中的货币时将使用它。我们将从fixer.io API 中获取这个。

现在,我们可以创建main函数:

def main():
    args = parse_commandline_args()
    value = args.value
    dest_currency = args.dest_currency
    from_currency = args.from_currency

    config = get_config()
    base_currency = (from_currency
                     if from_currency
                     else config['base_currency'])

main函数首先通过解析命令行参数来开始。如果用户输入的一切都正确,我们应该收到一个包含所有参数及其值的namespace对象。在这个阶段,我们只关心三个参数:valuedest_currencyfrom_currency。如果你还记得之前的话,value是用户想要转换为另一种货币的值,dest_currency是用户想要转换为的货币,from_currency只有在用户希望覆盖数据库中设置的默认货币时才会传递。

获取所有这些值后,我们调用get_config从数据库中获取base_currency,然后立即检查是否有from_currency可以使用该值;否则,我们使用数据库中的base_currency。这将确保如果用户指定了from_currency值,那么该值将覆盖数据库中存储的默认货币。

接下来,我们实现将实际从数据库或fixer.io API 获取汇率的代码,如下所示:

    with DbClient('exchange_rates', 'rates') as db:
        exchange_rates = db.find_one({'base': base_currency})

        if exchange_rates is None:
            print(('Fetching exchange rates from fixer.io'
                   f' [base currency: {base_currency}]'))

            try:
                response = 
                fetch_exchange_rates_by_currency(base_currency)
            except Exception as e:
                sys.exit(f'Error: {e}')

            dest_rate = response['rates'][dest_currency]
            db.update({'base': base_currency}, response)
        else:
            dest_rate = exchange_rates['rates'][dest_currency]

        total = round(dest_rate * value, 2)
        print(f'{value} {base_currency} = {total} {dest_currency}')

我们使用DbClient类创建与数据库的连接,并指定我们将访问汇率集合。在上下文中,我们首先尝试找到基础货币的汇率。如果它不在数据库中,我们尝试从fixer.io获取它。

之后,我们提取我们要转换为的货币的汇率值,并将结果插入数据库,这样,下次运行程序并想要使用这种货币作为基础货币时,我们就不需要再次发送请求到fixer.io

如果我们找到了基础货币的汇率,我们只需获取该值并将其分配给dest_rate变量。

我们要做的最后一件事是执行转换,并使用内置的 round 函数将小数点后的位数限制为两位,并在终端中打印值。

在文件末尾,在main()函数之后,添加以下代码:

if __name__ == '__main__':
    main()

我们都完成了!

测试我们的应用程序

让我们测试一下我们的应用程序。首先,我们将显示帮助消息,看看我们有哪些选项可用:

很好!正如预期的那样。现在,我们可以使用--setbasecurrency参数来设置基础货币:

在这里,我已将基础货币设置为 SEK(瑞典克朗),每次我需要进行货币转换时,我都不需要指定我的基础货币是 SEK。让我们将 100 SEK 转换为 USD(美元):

正如你所看到的,我们在数据库中没有该货币的汇率,所以应用程序的第一件事就是从fixer.io获取并将其保存到数据库中。

由于我是一名居住在瑞典的巴西开发人员,我想将 SEK 转换为 BRL(巴西雷亚尔),这样我就知道下次去巴西看父母时需要带多少瑞典克朗:

请注意,由于这是我们第二次运行应用程序,我们已经有了以 SEK 为基础货币的汇率,所以应用程序不会再次从fixer.io获取数据。

现在,我们要尝试的最后一件事是覆盖基础货币。目前,它被设置为 SEK。我们使用 MXN(墨西哥比索)并从 MXN 转换为 SEK:

总结

在本章中,我们涵盖了许多有趣的主题。在设置应用程序环境时,您学会了如何使用超级新的、流行的工具pipenv,它已成为python.org推荐的用于创建虚拟环境和管理项目依赖项的工具。

您还学会了面向对象编程的基本概念,如何为命令行工具创建自定义操作,Python 语言中关于上下文管理器的基础知识,如何在 Python 中创建枚举,以及如何使用Requests执行 HTTP 请求,这是 Python 生态系统中最受欢迎的包之一。

最后但并非最不重要的是,您学会了如何使用pymongo包在 MongoDB 数据库中插入、更新和搜索数据。

在下一章中,我们将转变方向,使用出色且非常流行的 Django web 框架开发一个完整、非常实用的网络应用程序!

第五章:使用微服务构建 Web Messenger

在当今的应用程序开发世界中,微服务已成为设计和构建分布式系统的标准。像 Netflix 这样的公司开创了这一变革,并从拥有小型自治团队到设计轻松扩展的系统的方式,彻底改变了软件公司的运营方式。

在本章中,我将指导您完成创建两个微服务的过程,这两个微服务将共同工作,创建一个使用 Redis 作为数据存储的消息传递 Web 应用程序。消息在可配置的时间后会自动过期,因此在本章中,让我们称其为 TempMessenger。

在本章中,我们将涵盖以下主题:

  • 什么是 Nameko?

  • 创建您的第一个 Nameko 微服务

  • 存储消息

  • Nameko 依赖提供程序

  • 保存消息

  • 检索所有消息

  • 在 Web 浏览器中显示消息

  • 通过POST请求发送消息

  • 浏览器轮询消息

TempMessenger 目标

在开始之前,让我们为我们的应用定义一些目标:

  • 用户可以访问网站并发送消息

  • 用户可以查看其他人发送的消息

  • 消息在可配置的时间后会自动过期

为了实现这一点,我们将使用 Nameko - 一个用于 Python 的微服务框架。

如果在本章的任何时候,您想要参考本章中的所有代码,请随时查看:url.marcuspen.com/github-ppb

要求

为了参与本章,您的本地计算机将需要以下内容:

随着我们在本章中的进展,所有其他要求都将被安装。

本章中的所有说明都针对 macOS 或 Debian/Ubuntu 系统。但是,我已经注意到只使用跨平台依赖项。

在本章中,将会有代码块。不同类型的代码将有它们自己的前缀,如下所示:

$:在您的终端中执行,始终在您的虚拟环境中

>>>:在您的 Nameko/Python shell 中执行

无前缀:要在您的编辑器中使用的 Python 代码块

什么是 Nameko?

Nameko 是一个用于构建 Python 微服务的开源框架。使用 Nameko,您可以创建使用AMQP高级消息队列协议)通过RPC远程过程调用)相互通信的微服务。

RPC

RPC 代表远程过程调用,我将用一个基于电影院预订系统的简短示例来简要解释这一点。在这个电影院预订系统中,有许多微服务,但我们将专注于预订服务,它负责管理预订,以及电子邮件服务,它负责发送电子邮件。预订服务和电子邮件服务都存在于不同的机器上,并且都不知道对方在哪里。在进行新预订时,预订服务需要向用户发送电子邮件确认,因此它对电子邮件服务进行远程过程调用,可能看起来像这样:

def new_booking(self, user_id, film, time): 
    ... 
    self.email_service.send_confirmation(user_id, film, time) 
    ... 

请注意在上述代码中,预订服务如何进行调用,就好像它在本地执行代码一样?它不关心网络或协议,甚至不提供需要发送到哪个电子邮件地址的详细信息。对于预订服务来说,电子邮件地址和任何其他与电子邮件相关的概念都是无关紧要的!这使得预订服务能够遵守单一责任原则,这是由 Robert C. Martin 在他的文章面向对象设计原则url.marcuspen.com/bob-ood)中介绍的一个术语,该原则规定:

“一个类应该只有一个变化的原因”

这个引用的范围也可以扩展到微服务,并且在开发它们时我们应该牢记这一点。这将使我们能够保持我们的微服务自包含和内聚。如果电影院决定更改其电子邮件提供商,那么唯一需要更改的服务应该是电子邮件服务,从而最小化所需的工作量,进而减少错误和可能的停机时间的风险。

然而,与其他技术(如 REST)相比,RPC 确实有其缺点,主要是很难看出调用是否是远程的。一个人可能在不知情的情况下进行不必要的远程调用,这可能很昂贵,因为它们需要通过网络并使用外部资源。因此,在使用 RPC 时,重要的是要使它们在视觉上有所不同。

Nameko 如何使用 AMQP

AMQP 代表高级消息队列协议,Nameko 将其用作 RPC 的传输。当我们的 Nameko 服务相互进行 RPC 时,请求被放置在消息队列中,然后被目标服务消耗。Nameko 服务使用工作程序来消耗和执行请求;当进行 RPC 时,目标服务将生成一个新的工作程序来执行任务。任务完成后,工作程序就会终止。由于可以有多个工作程序同时执行任务,Nameko 可以扩展到其可用的工作程序数量。如果所有工作程序都被耗尽,那么消息将保留在队列中,直到有空闲的工作程序可用。

您还可以通过增加运行服务的实例数量来水平扩展 Nameko。这被称为集群,也是Nameko名称的由来,因为 Nameko 蘑菇是成簇生长的。

Nameko 还可以响应来自其他协议(如 HTTP 和 Websockets)的请求。

RabbitMQ

RabbitMQ 被用作 Nameko 的消息代理,并允许其利用 AMQP。在开始之前,您需要在您的机器上安装它;为此,我们将使用 Docker,在所有主要操作系统上都可用。

对于那些不熟悉 Docker 的人来说,它允许我们在一个独立的、自包含的环境中运行我们的代码,称为容器。容器中包含了代码独立运行所需的一切。您还可以下载和运行预构建的容器,这就是我们将要运行 RabbitMQ 的方式。这样可以避免在本地机器上安装它,并最大程度地减少在不同平台(如 macOS 或 Windows)上运行 RabbitMQ 时可能出现的问题。

如果您尚未安装 Docker,请访问url.marcuspen.com/docker-install获取详细的安装说明。本章的其余部分将假定您已经安装了 Docker。

启动 RabbitMQ 容器

在您的终端中执行以下命令:

$ docker run -d -p 5672:5672 -p 15672:15672 --name rabbitmq rabbitmq 

这将使用以下设置启动一个 RabbitMQ 容器:

  • -d:指定我们要在守护进程模式(后台进程)下运行容器。

  • -p:允许我们在容器上将端口567215672暴露到本地机器。这些端口是 Nameko 与 RabbitMQ 通信所需的。

  • --name:将容器名称设置为rabbitmq

您可以通过执行以下命令来检查您的新 RabbitMQ 容器是否正在运行:

$ docker ps

安装 Python 要求

对于这个项目,我将使用 Python 3.6,这是我写作时的最新稳定版本的 Python。我建议始终使用最新的稳定版本的 Python,不仅可以获得新功能,还可以确保环境中始终应用最新的安全更新。

Pyenv 是一种非常简单的安装和切换不同版本的 Python 的方法:url.marcuspen.com/pyenv

我还强烈建议使用 virtualenv 创建一个隔离的环境来安装我们的 Python 要求。在没有虚拟环境的情况下安装 Python 要求可能会导致与其他 Python 应用程序或更糟糕的操作系统产生意外的副作用!

要了解有关 virtualenv 及其安装方法的更多信息,请访问:url.marcuspen.com/virtualenv

通常,在处理 Python 包时,您会创建一个requirements.txt文件,填充它的要求,然后安装它。我想向您展示一种不同的方法,可以让您轻松地跟踪 Python 包的版本。

要开始,请在您的虚拟环境中安装pip-tools

pip install pip-tools 

现在创建一个名为requirements的新文件夹,并创建两个新文件:

base.in 
test.in 

base.in文件将包含运行我们服务核心所需的要求,而test.in文件将包含运行我们测试所需的要求。将这些要求分开是很重要的,特别是在微服务架构中部署代码时。我们的本地机器可以安装测试包,但是部署版本的代码应尽可能简洁和轻量。

base.in文件中,输入以下行:

nameko 

test.in文件中,输入以下行:

pytest 

假设您在包含requirements文件夹的目录中,运行以下命令:

pip-compile requirements/base.in 
pip-compile requirements/test.in 

这将生成两个文件,base.txttest.txt。以下是base.txt的一个小样本:

... 
nameko==2.8.3 
path.py==10.5             # via nameko 
pbr==3.1.1                # via mock 
pyyaml==3.12              # via nameko 
redis==2.10.6 
requests==2.18.4          # via nameko 
six==1.11.0               # via mock, nameko 
urllib3==1.22             # via requests 
... 

注意我们现在有一个文件,其中包含了 Nameko 的所有最新依赖和子依赖。它指定了所需的版本,还指出了每个子依赖被安装的原因。例如,six是由namekomock需要的。

这样可以非常容易地通过跟踪每个代码发布版本之间的版本更改来解决未来的升级问题。

在撰写本文时,Nameko 当前版本为 2.8.3,Pytest 为 3.4.0。如果有新版本可用,请随时使用,但如果在本书中遇到任何问题,请通过在您的base.intest.in文件中附加版本号来恢复到这些版本:

nameko==2.8.3 

要安装这些要求,只需运行:

$ pip-sync requirements/base.txt requirements/test.txt 

pip-sync命令安装文件中指定的所有要求,同时删除环境中未指定的任何包。这是保持您的虚拟环境干净的好方法。或者,您也可以使用:

$ pip install -r requirements/base.txt -r requirements/test.txt 

创建您的第一个 Nameko 微服务

让我们首先创建一个名为temp_messenger的新文件夹,并在其中放置一个名为service.py的新文件,其中包含以下代码:

from nameko.rpc import rpc 

class KonnichiwaService: 

    name = 'konnichiwa_service' 

    @rpc 
    def konnichiwa(self): 
        return 'Konnichiwa!' 

我们首先通过从nameko.rpc导入rpc开始。这将允许我们使用rpc装饰器装饰我们的方法,并将它们公开为我们服务的入口点。入口点是 Nameko 服务中的任何方法,它作为我们服务的网关。

要创建一个 Nameko 服务,我们只需创建一个名为KonnichiwaService的新类,并为其分配一个name属性。name属性为其提供了一个命名空间;这将在我们尝试远程调用服务时使用。

我们在服务上编写了一个简单返回单词Konnichiwa!的方法。注意这个方法是用rpc装饰的。konnichiwa方法现在将通过 RPC 公开。

在测试这段代码之前,我们需要创建一个小的config文件,告诉 Nameko 在哪里访问 RabbitMQ 以及使用什么 RPC 交换。创建一个新文件config.yaml

AMQP_URI: 'pyamqp://guest:guest@localhost' 
rpc_exchange: 'nameko-rpc' 

这里的AMQP_URI配置对于使用先前给出的说明启动 RabbitMQ 容器的用户是正确的。如果您已调整了用户名、密码或位置,请确保您的更改在这里反映出来。

现在你应该有一个类似以下的目录结构:

. 
├── config.yaml 
├── requirements 
│   ├── base.in 
│   ├── base.txt 
│   ├── test.in 
│   └── test.txt 
├── temp_messenger 
    └── service.py 

现在在您的终端中,在项目目录的根目录中,执行以下操作:

$ nameko run temp_messenger.service --config config.yaml 

您应该会得到以下输出:

starting services: konnichiwa_service 
Connected to amqp://guest:**@127.0.0.1:5672// 

对我们的服务进行调用

我们的微服务现在正在运行!为了进行我们自己的调用,我们可以启动一个集成了 Nameko 的 Python shell,以允许我们调用我们的入口点。要访问它,请打开一个新的终端窗口并执行以下操作:

$ nameko shell 

这将为您提供一个 Python shell,可以进行远程过程调用。让我们试一试:

>>> n.rpc.konnichiwa_service.konnichiwa() 
'Konnichiwa!' 

成功了!我们已成功调用了我们的 Konnichiwa 服务,并收到了一些输出。当我们在 Nameko shell 中执行此代码时,我们将一条消息放入队列,然后由我们的 KonnichiwaService 接收。然后它生成一个新的 worker 来执行 konnichiwa RPC 的工作。

单元测试 Nameko 微服务

根据文档,url.marcuspen.com/nameko,Nameko 是:

“一个用于 Python 的微服务框架,让服务开发人员专注于应用逻辑并鼓励可测试性。”

现在我们将专注于 Nameko 的可测试性部分;它提供了一些非常有用的工具,用于隔离和测试其服务。

创建一个新文件夹 tests,并在其中放入两个新文件,__init__.py(可以留空)和 test_service.py

from nameko.testing.services import worker_factory 
from temp_messenger.service import KonnichiwaService 

def test_konnichiwa(): 
    service = worker_factory(KonnichiwaService) 
    result = service.konnichiwa() 
    assert result == 'Konnichiwa!' 

在测试环境之外运行时,Nameko 会为每个被调用的入口点生成一个新的 worker。之前,当我们测试我们的 konnichiwa RPC 时,Konnichiwa 服务会监听 Rabbit 队列上的新消息。一旦它收到了 konnichiwa 入口点的新消息,它将生成一个新的 worker 来执行该方法,然后消失。

要了解更多关于 Nameko 服务解剖学的信息,请参阅:url.marcuspen.com/nam-key

对于我们的测试,Nameko 提供了一种通过 woker_factory 模拟的方法。正如您所看到的,我们的测试使用了 worker_factory,我们将我们的服务类 KonnichiwaService 传递给它。这将允许我们调用该服务上的任何入口点并访问结果。

要运行测试,只需从代码目录的根目录执行:

pytest 

就是这样。测试套件现在应该通过了。尝试一下并尝试使其出错。

暴露 HTTP 入口点

现在我们将创建一个新的微服务,负责处理 HTTP 请求。首先,让我们在 service.py 文件中修改我们的导入:

from nameko.rpc import rpc, RpcProxy 
from nameko.web.handlers import http 

在我们之前创建的 KonnichiwaService 下面,插入以下内容:

class WebServer: 

    name = 'web_server' 
    konnichiwa_service = RpcProxy('konnichiwa_service') 

    @http('GET', '/') 
    def home(self, request): 
        return self.konnichiwa_service.konnichiwa() 

注意它如何遵循与 KonnichiwaService 类似的模式。它有一个 name 属性和一个用于将其公开为入口点的方法。在这种情况下,它使用 http 入口点进行装饰。我们在 http 装饰器内指定了它是一个 GET 请求以及该请求的位置 - 在这种情况下,是我们网站的根目录。

还有一个至关重要的区别:这个服务通过 RpcProxy 对象持有对 Konnichiwa 服务的引用。RpcProxy 允许我们通过 RPC 调用另一个 Nameko 服务。我们使用 name 属性来实例化它,这是我们之前在 KonnichiwaService 中指定的。

让我们试一试 - 只需使用之前的命令重新启动 Nameko(这是为了考虑代码的任何更改),然后在您选择的浏览器中转到 http://localhost:8000/

成功了!我们现在成功地创建了两个微服务——一个负责显示消息,一个负责提供 Web 请求。

Nameko 微服务的集成测试

之前我们通过生成单个 worker 来测试隔离的服务。这对于单元测试来说很好,但不适用于集成测试。

Nameko 使我们能够在单个测试中测试多个服务协同工作的能力。看看下面的内容:

def test_root_http(web_session, web_config, container_factory): 
    web_config['AMQP_URI'] = 'pyamqp://guest:guest@localhost' 

    web_server = container_factory(WebServer, web_config) 
    konnichiwa = container_factory(KonnichiwaService, web_config) 
    web_server.start() 
    konnichiwa.start() 

    result = web_session.get('/') 

    assert result.text == 'Konnichiwa!' 

正如您在前面的代码中所看到的,Nameko 还为我们提供了以下测试装置:

  • web_session:为我们提供了一个会话,用于向服务发出 HTTP 请求

  • web_config:允许我们访问服务的配置(在测试之外,这相当于config.yaml文件)

  • container_factory:这允许我们模拟整个服务而不仅仅是一个工作实例,这在集成测试时是必要的

由于这是运行实际服务,我们需要通过将其注入到web_config中来指定 AMQP 代理的位置。使用container_factory,我们创建两个容器:web_serverkonnichiwa。然后启动两个容器。

然后只需使用web_session发出GET请求到我们网站的根目录,并检查结果是否符合我们的预期。

当我们继续阅读本章的其余部分时,我鼓励您为代码编写自己的测试,因为这不仅可以防止错误,还可以帮助巩固您对这个主题的知识。这也是一个尝试您自己的想法和对代码进行修改的好方法,因为它们可以快速告诉您是否有任何错误。

有关测试 Nameko 服务的更多信息,请参见:url.marcuspen.com/nam-test

存储消息

我们希望应用程序显示的消息需要是临时的。我们可以使用关系数据库,如 PostgreSQL,但这意味着必须为像文本这样简单的东西设计和维护数据库。

Redis 简介

Redis 是一个内存数据存储。整个数据集可以存储在内存中,使读写速度比关系数据库快得多,这对于不需要持久性的数据非常有用。此外,我们可以在不制作模式的情况下存储数据,如果我们不需要复杂的查询,这是可以接受的。在我们的情况下,我们只需要一个数据存储,它将允许我们存储消息,获取消息并使消息过期。Redis 完全符合我们的用例!

启动 Redis 容器

在您的终端中,执行以下操作:

$ docker run -d -p 6379:6379 --name redis redis

这将使用以下设置启动一个 Redis 容器:

  • -d:指定我们要在守护程序模式(后台进程)中运行容器。

  • -p:允许我们将容器上的端口6379暴露到本地机器上。这对于 Nameko 与 Redis 通信是必需的。

  • --name:将容器名称设置为redis

您可以通过执行以下操作来检查新的 Redis 容器是否正在运行:

$ docker ps

安装 Python Redis 客户端

您还需要安装 Python Redis 客户端,以便通过 Python 与 Redis 进行交互。为此,我建议您修改之前的base.in文件以包括redis并重新编译它以生成新的base.txt文件。或者,您可以运行pip install redis

使用 Redis

让我们简要地看一下对于 TempMessenger 对我们有用的 Redis 命令类型:

  • SET:设置给定键来保存给定的字符串。它还允许我们设置以秒或毫秒为单位的过期时间。

  • GET:获取存储在给定键处的数据的值。

  • TTL:以秒为单位获取给定键的生存时间。

  • PTTL:以毫秒为单位获取给定键的生存时间。

  • KEYS:返回数据存储中的所有键的列表。

要尝试它们,我们可以使用redis-cli,这是随 Redis 容器一起提供的程序。要访问它,首先通过在终端中执行以下操作登录到容器:

docker exec -it redis /bin/bash

然后通过简单地运行以下内容在同一个终端窗口中访问redis-cli

redis-cli 

以下是如何使用redis-cli的一些示例;如果您对 Redis 不熟悉,我鼓励您自己尝试使用这些命令。

将一些数据hello设置为键msg1

127.0.0.1:6379> SET msg1 hello
OK

获取存储在键msg1处的数据:

127.0.0.1:6379> GET msg1
"hello"

在键msg2处设置一些更多的数据hi there并检索它:

127.0.0.1:6379> SET msg2 "hi there"
OK
127.0.0.1:6379> GET msg2
"hi there"

检索当前存储在 Redis 中的所有键:

127.0.0.1:6379> KEYS *
1) "msg2"
2) "msg1"

msg3上保存数据,过期时间为 15 秒:

127.0.0.1:6379> SET msg3 "this message will die soon" EX 15
OK

获取msg3的生存时间(以秒为单位):

127.0.0.1:6379> TTL msg3
(integer) 10

以毫秒为单位获取msg3的生存时间:

127.0.0.1:6379> PTTL msg3
(integer) 6080

msg3过期之前检索:

127.0.0.1:6379> GET msg3
"this message will die soon"

msg3过期后检索:

127.0.0.1:6379> GET msg3
(nil)

Nameko 依赖提供者

在构建微服务时,Nameko 鼓励使用依赖提供程序与外部资源进行通信,如数据库、服务器或我们的应用程序所依赖的任何东西。通过使用依赖提供程序,您可以隐藏掉只对该依赖性特定的逻辑,使您的服务级代码保持干净,并且对于与这个外部资源进行交互的细节保持不可知。

通过这种方式构建我们的微服务,我们可以轻松地在其他服务中交换或重用依赖提供程序。

Nameko 提供了一系列开源依赖提供程序,可以直接使用:url.marcuspen.com/nam-ext

添加一个 Redis 依赖提供程序

由于 Redis 是我们应用程序的外部资源,我们将为它创建一个依赖提供程序。

设计客户端

首先,让我们在temp_messenger文件夹内创建一个名为dependencies的新文件夹。在里面,放置一个新文件redis.py。我们现在将创建一个 Redis 客户端,其中包含一个简单的方法,将根据一个键获取一条消息:

from redis import StrictRedis 

class RedisClient: 

    def __init__(self, url): 
        self.redis = StrictRedis.from_url( 
            url, decode_responses=True 
        ) 

我们通过实现__init__方法来开始我们的代码,该方法创建我们的 Redis 客户端并将其分配给self.redisStrictRedis可以接受许多可选参数,但是我们只指定了以下参数:

  • url:我们可以使用StrictRedisfrom_url,而不是分别指定主机、端口和数据库号,这将允许我们使用单个字符串指定所有三个,如redis://localhost:6379/0。当将其存储在我们的config.yaml中时,这将更加方便。

  • decode_responses:这将自动将我们从 Redis 获取的数据转换为 Unicode 字符串。默认情况下,数据以字节形式检索。

现在,在同一个类中,让我们实现一个新的方法:

def get_message(self, message_id): 
    message = self.redis.get(message_id) 

    if message is None: 
        raise RedisError( 
            'Message not found: {}'.format(message_id) 
        ) 

    return message 

在我们的新类之外,让我们还实现一个新的错误类:

class RedisError(Exception): 
    pass 

在这里,我们有一个方法get_message,它接受一个message_id作为我们的 Redis 键。我们使用 Redis 客户端的get方法来检索具有给定键的消息。当从 Redis 检索值时,如果键不存在,它将简单地返回None。由于这个方法期望有一条消息,我们应该自己处理引发错误。在这种情况下,我们制作了一个简单的异常RedisError

创建依赖提供程序

到目前为止,我们已经创建了一个具有单个方法的 Redis 客户端。现在我们需要创建一个 Nameko 依赖提供程序,以利用这个客户端与我们的服务一起使用。在同一个redis.py文件中,更新您的导入以包括以下内容:

from nameko.extensions import DependencyProvider 

现在,让我们实现以下代码:

class MessageStore(DependencyProvider): 

    def setup(self): 
        redis_url = self.container.config['REDIS_URL'] 
        self.client = RedisClient(redis_url) 

    def stop(self): 
        del self.client 

    def get_dependency(self, worker_ctx): 
        return self.client 

在上述代码中,您可以看到我们的新MessageStore类继承自DependencyProvider类。我们在新的 MessageStore 类中指定的方法将在我们的微服务生命周期的某些时刻被调用。

  • setup:这将在我们的 Nameko 服务启动之前调用。在这里,我们从config.yaml获取 Redis URL,并使用我们之前制作的代码创建一个新的RedisClient

  • stop:当我们的 Nameko 服务开始关闭时,这将被调用。

  • get_dependency:所有依赖提供程序都需要实现这个方法。当入口点触发时,Nameko 创建一个 worker,并将get_dependency的结果注入到服务中指定的每个依赖项的 worker 中。在我们的情况下,这意味着我们的 worker 都将可以访问RedisClient的一个实例。

Nameko 提供了更多的方法来控制您的依赖提供程序在服务生命周期的不同时刻如何运行:url.marcuspen.com/nam-writ

创建我们的消息服务

在我们的service.py中,我们现在可以利用我们的新的 Redis 依赖提供程序。让我们首先创建一个新的服务,它将替换我们之前的 Konnichiwa 服务。首先,我们需要在文件顶部更新我们的导入:

from .dependencies.redis import MessageStore 

现在我们可以创建我们的新服务:

class MessageService: 

    name = 'message_service' 
    message_store = MessageStore() 

    @rpc 
    def get_message(self, message_id): 
        return self.message_store.get_message(message_id) 

这与我们之前的服务类似;但是,这次我们正在指定一个新的类属性message_store。我们的 RPC 入口点get_message现在可以使用这个属性,并调用我们的RedisClient中的get_message,然后简单地返回结果。

我们本可以通过在我们的 RPC 入口点内创建一个新的 Redis 客户端并实现 Redis 的GET来完成所有这些。然而,通过创建一个依赖提供者,我们促进了可重用性,并隐藏了 Redis 在键不存在时返回None的不需要的行为。这只是一个小例子,说明了为什么依赖提供者非常擅长将我们的服务与外部依赖解耦。

将所有内容整合在一起

让我们尝试一下我们刚刚创建的代码。首先使用redis-cli将一个新的键值对保存到 Redis 中:

127.0.0.1:6379> set msg1 "this is a test"
OK

现在启动我们的 Nameko 服务:

$ nameko run temp_messenger.service --config config.yaml

我们现在可以使用nameko shell来远程调用我们的新MessageService

>>> n.rpc.message_service.get_message('msg1') 
'this is a test' 

如预期的那样,我们能够通过我们的MessageService入口点使用redis-cli检索到我们之前设置的消息。

现在让我们尝试获取一个不存在的消息:

    >>> n.rpc.message_service.get_message('i_dont_exist')
    Traceback (most recent call last):
      File "<console>", line 1, in <module>
      File "/Users/marcuspen/.virtualenvs/temp_messenger/lib/python3.6/site-packages/nameko/rpc.py", line 393, in __call__
        return reply.result()
      File "/Users/marcuspen/.virtualenvs/temp_messenger/lib/python3.6/site-packages/nameko/rpc.py", line 379, in result
        raise deserialize(error)
    nameko.exceptions.RemoteError: RedisError Message not found: i_dont_exist

这并不是最漂亮的错误,有一些事情我们可以做来减少这个回溯,但最后一行说明了我们之前定义的异常,并清楚地显示了为什么该请求失败。

我们现在将继续保存消息。

保存消息

之前,我介绍了 Redis 的SET方法。这将允许我们将消息保存到 Redis,但首先,我们需要在我们的依赖提供者中创建一个新的方法来处理这个问题。

我们可以简单地创建一个调用redis.set(message_id, message)的新方法,但是我们如何处理新的消息 ID 呢?如果我们期望用户为他们想要发送的每条消息输入一个新的消息 ID,那将会有些麻烦,对吧?另一种方法是让消息服务在调用依赖提供者之前生成一个新的随机消息 ID,但这样会使我们的服务充斥着依赖本身可以处理的逻辑。

我们将通过让依赖创建一个随机字符串来解决这个问题,以用作消息 ID。

在我们的 Redis 客户端中添加保存消息的方法

redis.py中,让我们修改我们的导入以包括uuid4

from uuid import uuid4 

uuid4为我们生成一个唯一的随机字符串,我们可以用它来作为我们消息的 ID。

现在我们可以将我们的新的save_message方法添加到RedisClient中:

    def save_message(self, message): 
        message_id = uuid4().hex 
        self.redis.set(message_id, message) 

        return message_id 

首先,我们使用uuid4().hex生成一个新的消息 ID。hex属性将 UUID 作为一个 32 字符的十六进制字符串返回。然后我们将其用作键来保存消息并返回它。

添加一个保存消息的 RPC

现在让我们创建一个 RPC 方法,用来调用我们的新客户端方法。在我们的MessageService中,添加以下方法:

    @rpc 
    def save_message(self, message): 
        message_id = self.message_store.save_message(message) 
        return message_id 

这里没有什么花哨的,但请注意,向我们的服务添加新功能变得如此容易。我们正在将属于依赖的逻辑与我们的入口点分离,并同时使我们的代码可重用。如果我们将来创建的另一个 RPC 方法需要将消息保存到 Redis 中,我们可以轻松地这样做,而不必再次创建相同的代码。

让我们通过使用nameko shell来测试一下 - 记得重新启动 Nameko 服务以使更改生效!

>>> n.rpc.message_service.save_message('Nameko is awesome!')
    'd18e3d8226cd458db2731af8b3b000d9'

这里返回的 ID 是随机的,与您在会话中获得的 ID 不同。

>>>n.rpc.message_service.get_message
   ('d18e3d8226cd458db2731af8b3b000d9')
    'Nameko is awesome!'

正如您所看到的,我们已成功保存了一条消息,并使用返回的 UUID 检索了我们的消息。

这一切都很好,但是为了我们应用的目的,我们不希望用户必须提供消息 UUID 才能读取消息。让我们把这变得更实用一点,看看我们如何获取我们 Redis 存储中的所有消息。

检索所有消息

与我们之前的步骤类似,为了添加更多功能,我们需要在我们的 Redis 依赖中添加一个新的方法。这次,我们将创建一个方法,它将遍历 Redis 中的所有键,并以列表的形式返回相应的消息。

在我们的 Redis 客户端中添加一个获取所有消息的方法

让我们将以下内容添加到我们的RedisClient中:

def get_all_messages(self): 
    return [ 
        { 
            'id': message_id, 
            'message': self.redis.get(message_id) 
        } 
        for message_id in self.redis.keys() 
    ] 

我们首先使用self.redis.keys()来收集存储在 Redis 中的所有键,这在我们的情况下是消息 ID。然后,我们有一个列表推导式,它将遍历所有消息 ID 并为每个消息 ID 创建一个字典,其中包含消息 ID 本身和存储在 Redis 中的消息,使用self.redis.get(message_id)

对于生产环境中的大型应用程序,不建议使用 Redis 的KEYS方法,因为这将阻塞服务器直到完成操作。更多信息,请参阅:url.marcuspen.com/rediskeys

就我个人而言,我更喜欢在这里使用列表推导式来构建消息列表,但如果您在理解这种方法方面有困难,我建议将其编写为标准的 for 循环。

为了举例说明,可以查看以下代码,该代码是使用 for 循环构建的相同方法:

def get_all_messages(self): 
    message_ids = self.redis.keys() 
    messages = [] 

    for message_id in message_ids: 
        message = self.redis.get(message_id) 
        messages.append( 
            {'id': message_id, 'message': message} 
        ) 

    return messages 

这两种方法都是完全相同的。你更喜欢哪个?我把这个选择留给你...

每当我编写列表或字典推导式时,我总是从测试函数或方法的输出开始。然后我用推导式编写我的代码并测试它以确保输出是正确的。然后,我将我的代码更改为 for 循环并确保测试仍然通过。之后,我会查看我的代码的两个版本,并决定哪个看起来最可读和干净。除非代码需要非常高效,我总是选择阅读良好的代码,即使这意味着多写几行。当以后需要阅读和维护该代码时,这种方法在长远来看是值得的!

我们现在有一种方法可以获取 Redis 中的所有消息。在上述代码中,我本可以简单地返回一个消息列表,而不涉及任何字典,只是消息的字符串值。但是,如果我们以后想要为每条消息添加更多数据呢?例如,一些元数据来表示消息创建的时间或消息到期的时间...我们以后会涉及到这部分!在这里为每条消息使用字典将使我们能够轻松地以后演变我们的数据结构。

我们现在可以考虑向我们的MessageService中添加一个新的 RPC,以便我们可以获取所有消息。

添加获取所有消息的 RPC

在我们的MessageService类中,只需添加:

@rpc 
def get_all_messages(self): 
    messages = self.message_store.get_all_messages() 
    return messages 

我相信到现在为止,我可能不需要解释这里发生了什么!我们只是调用了我们之前在 Redis 依赖中制作的方法,并返回结果。

将所有内容放在一起

在您的虚拟环境中,使用nameko shell,我们现在可以测试这个功能。

>>> n.rpc.message_service.save_message('Nameko is awesome!')
'bf87d4b3fefc49f39b7dd50e6d693ae8'
>>> n.rpc.message_service.save_message('Python is cool!')
'd996274c503b4b57ad5ee201fbcca1bd'
>>> n.rpc.message_service.save_message('To the foo bar!')
'69f99e5863604eedaf39cd45bfe8ef99'
>>> n.rpc.message_service.get_all_messages()
[{'id': 'd996274...', 'message': 'Python is cool!'},
{'id': 'bf87d4b...', 'message': 'Nameko is awesome!'},
{'id': '69f99e5...', 'message': 'To the foo bar!'}]

我们现在可以检索数据存储中的所有消息了。(出于空间和可读性考虑,我已经截断了消息 ID。)

这里返回的消息存在一个问题-你能发现是什么吗?我们将消息放入 Redis 的顺序与我们再次取出它们的顺序不同。我们以后会回到这个问题,但现在让我们继续在我们的 Web 浏览器中显示这些消息。

在 Web 浏览器中显示消息

之前,我们添加了WebServer微服务来处理 HTTP 请求;现在我们将对其进行修改,以便当用户登陆根主页时,他们会看到我们数据存储中的所有消息。

其中一种方法是使用 Jinja2 等模板引擎。

添加 Jinja2 依赖提供程序

Jinja2 是 Python 的模板引擎,与 Django 中的模板引擎非常相似。对于熟悉 Django 的人来说,使用它应该感觉非常熟悉。

在开始之前,您应该修改您的base.in文件,包括jinja2,重新编译您的要求并安装它们。或者,只需运行pip install jinja2

创建模板渲染器

在 Jinja2 中生成简单的 HTML 模板需要以下三个步骤:

  • 创建模板环境

  • 指定模板

  • 渲染模板

通过这三个步骤,重要的是要确定哪些部分在我们的应用程序运行时永远不会改变(或者至少极不可能改变)...以及哪些会改变。在我解释以下代码时,请记住这一点。

在您的依赖目录中,添加一个新文件jinja2.py,并从以下代码开始:

from jinja2 import Environment, PackageLoader, select_autoescape 

class TemplateRenderer: 

    def __init__(self, package_name, template_dir): 
        self.template_env = Environment( 
            loader=PackageLoader(package_name, template_dir), 
            autoescape=select_autoescape(['html']) 
        ) 

    def render_home(self, messages): 
        template = self.template_env.get_template('home.html') 
        return template.render(messages=messages) 

在我们的__init__方法中,我们需要一个包名称和一个模板目录。有了这些,我们就可以创建模板环境。环境需要一个加载器,这只是一种能够从给定的包和目录加载我们的模板文件的方法。我们还指定我们要在我们的 HTML 文件上启用自动转义以确保安全。

然后我们创建了一个render_home方法,它将允许我们渲染我们的home.html模板。请注意我们如何使用messages来渲染我们的模板...稍后你会明白的!

你能看出我为什么以这种方式构建代码吗?由于__init__方法总是被执行,我把我们的模板环境的创建放在那里,因为这在我们的应用程序运行时几乎不会改变。

然而,我们要渲染的模板以及我们给该模板的变量总是会改变的,这取决于用户尝试访问的页面以及在那个特定时刻可用的数据。有了上述结构,为我们应用程序的每个网页添加一个新方法变得微不足道。

创建我们的主页模板

现在让我们看看我们模板所需的 HTML。让我们首先在我们的依赖旁边创建一个名为templates的新目录。

在我们的新目录中,创建以下home.html文件:

<!DOCTYPE html> 

<body> 
    {% if messages %} 
        {% for message in messages %} 
            <p>{{ message['message'] }}</p> 
        {% endfor %} 
    {% else %} 
        <p>No messages!</p> 
    {% endif %} 
</body> 

这个 HTML 并不花哨,模板逻辑也不复杂!如果你对 Jinja2 或 Django 模板不熟悉,那么你可能会觉得这个 HTML 看起来很奇怪,到处都是花括号。Jinja2 使用这些花括号允许我们在模板中输入类似 Python 的语法。

在上面的例子中,我们首先使用if语句来查看是否有任何消息(messages的格式和结构将与我们之前制作的get_all_messages RPC 返回的消息相同)。如果有,那么我们有一些更多的逻辑,包括一个 for 循环,它将迭代并显示我们messages列表中每个字典的'message'的值。

如果没有消息,那么我们将只显示没有消息!文本。

要了解更多关于 Jinja2 的信息,请访问:url.marcuspen.com/jinja2

创建依赖提供者

现在我们需要将我们的TemplateRenderer公开为 Nameko 依赖提供者。在我们之前创建的jinja2.py文件中,更新我们的导入以包括以下内容:

from nameko.extensions import DependencyProvider 

然后添加以下代码:

class Jinja2(DependencyProvider): 

    def setup(self): 
        self.template_renderer = TemplateRenderer( 
            'temp_messenger', 'templates' 
        ) 

    def get_dependency(self, worker_ctx): 
        return self.template_renderer 

这与我们之前的 Redis 依赖非常相似。我们指定了一个setup方法,用于创建我们的TemplateRenderer的实例,以及一个get_dependency方法,用于将其注入到 worker 中。

现在可以被我们的WebServer使用了。

创建 HTML 响应

现在我们可以在我们的WebServer中使用我们的新的 Jinja2 依赖项。首先,我们需要在service.py的导入中包含它:

from .dependencies.jinja2 import Jinja2 

现在让我们修改我们的WebServer类如下:

class WebServer: 

    name = 'web_server' 
    message_service = RpcProxy('message_service') 
    templates = Jinja2() 

    @http('GET', '/') 
    def home(self, request): 
        messages = self.message_service.get_all_messages() 
        rendered_template = self.templates.render_home(messages) 

        return rendered_template 

请注意,我们已经像之前在我们的MessageService中使用message_store一样,为它分配了一个新的属性templates。我们的 HTTP 入口现在与我们的MessageService通信,从 Redis 中检索所有消息,并使用它们来使用我们的新 Jinja2 依赖项创建一个渲染模板。然后我们返回结果。

将所有内容放在一起

重新启动您的 Nameko 服务,让我们在浏览器中尝试一下:

它起作用了...有点!我们之前存储在 Redis 中的消息现在存在,这意味着我们模板中的逻辑正常运行,但我们也有来自home.html的所有 HTML 标签和缩进。

这是因为我们还没有为我们的 HTTP 响应指定任何头部,以表明它是 HTML。为了做到这一点,让我们在WebServer类之外创建一个小的辅助函数,它将把我们的渲染模板转换为一个带有正确头部和状态码的响应。

在我们的service.py中,修改我们的导入以包括:

from werkzeug.wrappers import Response 

然后在我们的类之外添加以下函数:

def create_html_response(content): 
    headers = {'Content-Type': 'text/html'} 
    return Response(content, status=200, headers=headers) 

这个函数创建一个包含正确内容类型 HTML 的头部字典。然后我们创建并返回一个Response对象,其中包括 HTTP 状态码200,我们的头部和内容,而在我们的情况下,内容将是渲染的模板。

我们现在可以修改我们的 HTTP 入口点以使用我们的新的辅助函数:

@http('GET', '/') 
def home(self, request): 
    messages = self.message_service.get_all_messages() 
    rendered_template = self.templates.render_home(messages) 
    html_response = create_html_response(rendered_template) 

    return html_response 

我们的home HTTP 入口点现在使用create_html_reponse,给它渲染的模板,然后返回所做的响应。让我们在浏览器中再试一次:

现在你可以看到,我们的消息现在按我们的期望显示,没有找到任何 HTML 标签!尝试使用redis-cli中的flushall命令删除 Redis 中的所有数据,然后重新加载网页。会发生什么?

我们现在将继续发送消息。

通过 POST 请求发送消息

到目前为止,我们取得了很好的进展;我们有一个网站,它有能力显示我们数据存储中的所有消息,还有两个微服务。一个微服务处理我们消息的存储和检索,另一个充当我们用户的 Web 服务器。我们的MessageService已经有了保存消息的能力;让我们通过POST请求在我们的WebServer中暴露它。

添加发送消息的 POST 请求

在我们的service.py中,添加以下导入:

import json 

现在在我们的WebServer类中添加以下内容:

@http('POST', '/messages') 
def post_message(self, request): 
    data_as_text = request.get_data(as_text=True) 

    try: 
        data = json.loads(data_as_text) 
    except json.JSONDecodeError: 
        return 400, 'JSON payload expected' 

    try: 
        message = data['message'] 
    except KeyError: 
        return 400, 'No message given' 

    self.message_service.save_message(message) 

    return 204, '' 

有了我们的新的POST入口点,我们首先从请求中提取数据。我们指定参数as_text=True,因为否则我们会得到数据的字节形式。

一旦我们有了那些数据,我们就可以尝试将其从 JSON 加载到 Python 字典中。如果数据不是有效的 JSON,那么这可能会在我们的服务中引发JSONDecodeError,因此最好处理得体,并返回一个400的错误请求状态码。如果没有这个异常处理,我们的服务将返回一个内部服务器错误,状态码为500

现在数据以字典格式存在,我们可以获取其中的消息。同样,我们有一些防御性代码,它将处理任何缺少'message'键的情况,并返回另一个400

然后我们继续使用我们之前在MessageService中创建的save_message RPC 来保存消息。

有了这个,TempMessenger 现在有了通过 HTTP POST请求保存新消息的能力!如果你愿意,你可以使用 curl 或其他 API 客户端来测试这一点,就像这样:

$ curl -d '{"message": "foo"}' -H "Content-Type: application/json" -X POST http://localhost:8000/messages

我们现在将更新我们的home.html模板,以包括使用这个新的POST请求的能力。

在 jQuery 中添加一个 AJAX POST 请求

在我们开始之前,让我说一下,写作时,我绝对不是 JavaScript 专家。我的专长更多地在于后端编程而不是前端。话虽如此,如果你在网页开发中工作超过 10 分钟,你就会知道试图避免 JavaScript 几乎是不可能的。在某个时候,我们可能会不得不涉足一些 JavaScript 来完成一些工作。

有了这个想法,请不要被吓到

你即将阅读的代码是我仅仅通过阅读 jQuery 文档学到的,所以它非常简单。如果你对前端代码感到舒适,我相信可能有一百万种不同的,可能更好的方法来用 JavaScript 做到这一点,所以请根据自己的需要进行修改。

你首先需要在<!DOCTYPE html>之后添加以下内容:

<head> 
  <script src="https://code.jquery.com/jquery-latest.js"></script> 
</head> 

这将在浏览器中下载并运行最新版本的 jQuery。

在我们的home.html中,在闭合的</body>标签之前,添加以下内容:

<form action="/messages" id="postMessage"> 
  <input type="text" name="message" placeholder="Post message"> 
  <input type="submit" value="Post"> 
</form> 

我们从一个简单的 HTML 开始,添加一个基本的表单。这只有一个文本输入和一个提交按钮。单独使用时,它将呈现一个文本框和一个提交按钮,但不会做任何事情。

现在让我们用一些 jQuery JavaScript 跟随这段代码:

<script> 

$( "#postMessage" ).submit(function(event) { # ① 
  event.preventDefault(); # ② 

  var $form = $(this), 
    message = $form.find( "input[name='message']" ).val(), 
    url = $form.attr("action"); # ③ 

  $.ajax({ # ④ 
    type: 'POST', 
    url: url, 
    data: JSON.stringify({message: message}), # ⑤ 
    contentType: "application/json", # ⑥ 
    dataType: 'json', # ⑦ 
    success: function() {location.reload();} # ⑧ 
  }); 
}); 
</script> 

现在,这将为我们的提交按钮添加一些功能。让我们简要地介绍一下这里发生了什么:

  1. 这将为我们的页面创建一个监听器,监听postMessage事件。

  2. 我们还使用event.preventDefault();阻止了提交按钮的默认行为。在这种情况下,它将提交我们的表单,并尝试在/messages?message=I%27m+a+new+message上执行GET

  3. 一旦触发了,我们就可以在我们的表单中找到消息和 URL。

  4. 有了这些,我们就构建了我们的 AJAX 请求,这是一个 POST 请求。

  5. 我们使用JSON.stringify将我们的有效负载转换为有效的 JSON 数据。

  6. 还记得之前,当我们需要构建一个响应并提供头信息以说明我们的内容类型是text/html时吗?好吧,我们在我们的 AJAX 请求中也在做同样的事情,但这次我们的内容类型是application/json

  7. 我们将datatype设置为json。这告诉浏览器我们期望从服务器返回的数据类型。

  8. 我们还注册了一个回调函数,如果请求成功,就重新加载网页。这将允许我们在页面上看到我们的新消息(和任何其他新消息),因为它将再次获取所有消息。这种强制页面重新加载并不是处理这个问题的最优雅方式,但现在可以这样做。

让我们重新启动 Nameko 并在浏览器中尝试一下:

只要您没有清除 Redis 中的数据(可以通过手动删除或简单地重新启动您的机器来完成),您应该仍然可以看到之前的旧消息。

输入消息后,点击“发布”按钮提交您的新消息:

看起来好像成功了!我们的应用程序现在可以发送新消息了。我们现在将继续进行我们应用程序的最后一个要求,即在一定时间后过期消息。

在 Redis 中过期的消息

现在我们要实现应用程序的最后一个要求,即过期消息。由于我们使用 Redis 来存储消息,这变得非常简单。

让我们回顾一下我们 Redis 依赖中的save_message方法。Redis 的SET有一些可选参数;我们在这里最感兴趣的是expx。两者都允许我们设置要保存的数据的过期时间,但有一个区别:ex是以秒为单位的,而px是以毫秒为单位的:

def save_message(self, message): 
    message_id = uuid4().hex 
    self.redis.set(message_id, message, ex=10) 

    return message_id 

在上面的代码中,您可以看到我对代码所做的唯一修改是在redis.set方法中添加了ex=10;这将导致我们所有的消息在 10 秒后过期。现在重新启动您的 Nameko 服务并尝试一下。当您发送新消息后,等待 10 秒并刷新页面,它应该消失了。

请注意,如果在您进行此更改之前 Redis 中有任何消息,它们仍将存在,因为它们是在没有过期时间的情况下保存的。要删除它们,请使用redis-cli使用flushall命令删除 Redis 中的所有数据。

随意尝试设置过期时间,使用expx参数将其设置为您希望的任何时间。您可以将过期时间常量移到配置文件中,然后在启动 Nameko 时加载,这样可以使其更好,但现在这样就足够了。

排序消息

您很快会注意到我们应用程序的当前状态是,消息根本没有任何顺序。当您发送新消息时,它可能会被插入到消息线程的任何位置,这使得我们的应用程序非常不方便,至少可以这么说!

为了解决这个问题,我们将按剩余时间对消息进行排序。首先,我们将不得不修改我们的 Redis 依赖中的get_all_messages方法,以便为每条消息获取剩余时间:

def get_all_messages(self): 
    return [ 
        { 
            'id': message_id, 
            'message': self.redis.get(message_id), 
            'expires_in': self.redis.pttl(message_id), 
        } 
        for message_id in self.redis.keys() 
    ] 

如前面的代码中所示,我们为每条消息添加了一个新的expires_in值。这使用了 Redis 的 PTTL 命令,该命令返回给定键的存活时间(以毫秒为单位)。或者,我们也可以使用 Redis 的 TTL 命令,该命令返回以秒为单位的存活时间,但我们希望尽可能精确,以使我们的排序更准确。

现在,当我们的MessageService调用get_all_messages时,它还将知道每条消息的存活时间。有了这个,我们可以创建一个新的辅助函数来对消息进行排序。

首先,将以下内容添加到我们的导入中:

from operator import itemgetter 

MessageService类之外,创建以下函数:

def sort_messages_by_expiry(messages, reverse=False): 
    return sorted( 
        messages, 
        key=itemgetter('expires_in'), 
        reverse=reverse 
    ) 

这使用了 Python 内置的sorted函数,该函数能够从给定的可迭代对象返回一个排序后的列表;在我们的情况下,可迭代对象是messages。我们使用key来指定我们希望messages按照什么进行排序。由于我们希望messages按照expires_in进行排序,因此我们使用itemgetter来提取它以用作比较。我们给sort_messages_by_expiry函数添加了一个可选参数reverse,如果设置为True,则会使sorted以相反的顺序返回排序后的列表。

有了这个新的辅助函数,我们现在可以修改我们MessageService中的get_all_messages RPC:

@rpc 
def get_all_messages(self): 
    messages = self.message_store.get_all_messages() 
    sorted_messages = sort_messages_by_expiry(messages) 
    return sorted_messages 

我们的应用现在将返回我们的消息,按照最新的消息在底部排序。如果您希望最新的消息在顶部,则只需将sorted_messages更改为:

sorted_messages = sort_messages_by_expiry(messages, reverse=True) 

我们的应用现在符合我们之前指定的所有验收标准。我们有发送消息和获取现有消息的能力,并且它们在可配置的时间后都会过期。不太理想的一点是,我们依赖浏览器刷新来获取消息的最新状态。我们可以通过多种方式来解决这个问题,但我将演示解决这个问题的最简单的方法之一;通过轮询。

通过轮询,浏览器可以不断地向服务器发出请求,以获取最新的消息,而无需强制刷新页面。为了实现这一点,我们将不得不引入一些更多的 JavaScript,但任何其他方法也都需要。

浏览器轮询消息

当浏览器进行轮询以获取最新消息时,我们的服务器应以 JSON 格式返回消息。为了实现这一点,我们需要创建一个新的 HTTP 端点,以 JSON 格式返回消息,而不使用 Jinja2 模板。我们首先构建一个新的辅助函数来创建一个 JSON 响应,设置正确的标头。

在我们的 WebServer 之外,创建以下函数:

def create_json_response(content): 
    headers = {'Content-Type': 'application/json'} 
    json_data = json.dumps(content) 
    return Response(json_data, status=200, headers=headers) 

这类似于我们之前的create_html_response,但是这里将 Content-Type 设置为'application/json',并将我们的数据转换为有效的 JSON 对象。

现在,在 WebServer 中,创建以下 HTTP 入口点:

@http('GET', '/messages') 
def get_messages(self, request): 
    messages = self.message_service.get_all_messages() 
    return create_json_response(messages) 

这将调用我们的get_all_messages RPC,并将结果作为 JSON 响应返回给浏览器。请注意,我们在这里使用与我们在端点中使用的相同 URL/messages,来发送新消息。这是 RESTful 的一个很好的例子。我们使用 POST 请求到/messages来创建新消息,我们使用 GET 请求到/messages来获取所有消息。

使用 JavaScript 进行轮询

为了使我们的消息在没有浏览器刷新的情况下自动更新,我们将创建两个 JavaScript 函数——messagePoll,用于获取最新消息,以及updateMessages,用于使用这些新消息更新 HTML。

从我们的home.html中替换 Jinja2 if块开始,该块遍历我们的消息列表,并使用以下行替换:

<div id="messageContainer"></div> 

这将在稍后用于保存我们的 jQuery 函数生成的新消息列表。

在我们的home.html<script>标签中,编写以下代码:

function messagePoll() { 
  $.ajax({ 
    type: "GET", # ① 
    url: "/messages", 
    dataType: "json", 
    success: function(data) { # ② 
      updateMessages(data); 
    }, 
    timeout: 500, # ③ 
    complete: setTimeout(messagePoll, 1000), # ④ 
  }) 
} 

这是另一个 AJAX 请求,类似于我们之前发送新消息时所做的请求,但有一些不同之处:

  1. 在这里,我们执行了一个GET请求到我们在WebServer中创建的新端点,而不是一个POST请求。

  2. 如果成功,我们使用success回调来调用我们稍后将创建的updateMessages函数。

  3. timeout设置为 500 毫秒 - 这是我们应该在放弃之前从服务器收到响应的时间。

  4. 使用complete,它允许我们定义successerror回调完成后发生的事情 - 在这种情况下,我们设置它在 1000 毫秒后再次调用poll,使用setTimeout函数。

现在我们将创建updateMessages函数:

function updateMessages(messages) { 
  var $messageContainer = $('#messageContainer'); # ① 
  var messageList = []; # ② 
  var emptyMessages = '<p>No messages!</p>'; # ③ 

  if (messages.length === 0) { # ④ 
    $messageContainer.html(emptyMessages); # 
  } else { 
    $.each(messages, function(index, value) { 
      var message = $(value.message).text() || value.message; 
      messageList.push('<p>' + message + '</p>'); # 
    }); 
    $messageContainer.html(messageList); # ⑤ 
  } 
} 

通过使用这个函数,我们可以替换 Jinja2 模板中生成消息列表的所有代码。让我们一步一步来:

  1. 首先,我们获取 HTML 中的messageContainer,以便我们可以更新它。

  2. 我们生成一个空的messageList数组。

  3. 我们生成emptyMessages文本。

  4. 我们检查消息的数量是否等于 0:

  5. 如果是,我们使用.html()messageContainer的 HTML 替换为"没有消息!"

  6. 否则,对于messages中的每条消息,我们首先使用 jQuery 的内置.text()函数去除可能存在的任何 HTML 标签。然后我们将消息包装在<p>标签中,并使用.push()将它们附加到messageList中。

  7. 最后,我们使用.html()messageContainer的 HTML 替换为messagesList

4b点,重要的是要转义消息中可能存在的任何 HTML 标签,因为恶意用户可能发送一条恶意脚本作为消息,这将被每个使用该应用程序的人执行!

这绝不是解决不得不强制刷新浏览器以更新消息的问题的最佳方法,但对我来说,这是在本书中演示的最简单的方法之一。可能有更优雅的方法来实现轮询,如果你真的想要做到这一点,那么 WebSockets 绝对是你在这里的最佳选择。

总结

这样,我们就结束了编写 TempMessenger 应用程序的指南。如果你以前从未使用过 Nameko 或编写过微服务,我希望我已经为你提供了一个很好的基础,以便在保持服务小而简洁方面进行构建。

我们首先创建了一个具有单个 RPC 方法的服务,然后通过 HTTP 在另一个服务中使用它。然后我们看了一下我们如何使用允许我们生成工作者甚至服务本身的固定装置来测试 Nameko 服务。

我们引入了依赖提供程序,并创建了一个 Redis 客户端,具有获取单个消息的能力。然后,我们扩展了 Redis 依赖,增加了允许我们保存新消息、过期消息并以列表形式返回它们的方法。

我们看了如何使用 Jinja2 将 HTML 返回给浏览器,并创建了一个依赖提供程序。我们甚至看了一些 JavaScript 和 JQuery,使我们能够从浏览器发出请求。

你可能已经注意到的一个主题是需要将依赖逻辑与服务代码分开。通过这样做,我们使我们的服务对只有该依赖特定的工作保持不可知。如果我们决定将 Redis 替换为 MySQL 数据库呢?在我们的代码中,只需创建一个新的 MySQL 依赖提供程序和映射到我们的MessageService期望的方法的新客户端方法。然后我们只需最小的更改,将 Redis 替换为 MySQL。如果我们没有以这种方式编写代码,那么我们将不得不投入更多的时间和精力来对我们的服务进行更改。我们还会引入更多的错误可能性。

如果你熟悉其他 Python 框架,你现在应该看到 Nameko 如何让我们轻松创建可扩展的微服务,同时与像 Django 这样的框架相比,它给我们提供了更多的不包括电池的方法。当涉及编写专注于后端任务的小服务时,Nameko 可能是一个完美的选择。

在下一章中,我们将使用 PostgreSQL 数据库来扩展 TempMessenger,添加一个用户认证微服务。

第六章:使用用户认证微服务扩展 TempMessenger

在上一章中,我们创建了一个基于 Web 的信使 TempMessenger,它由两个微服务组成——一个负责存储和检索消息,另一个负责提供 Web 请求。

在本章中,我们将尝试通过用户认证微服务扩展我们现有的 TempMessenger 平台。这将包括一个具有 PostgreSQL 数据库依赖项的 Nameko 服务,该服务具有创建新用户和验证现有用户的能力。

我们还将用一个更合适的 Flask 应用替换我们的 Nameko Web 服务器微服务,这将允许我们跟踪用户的 Web 会话。

有必要阅读上一章才能跟上本章的内容。

我们将涵盖以下主题:

  • 创建一个 Postgres 依赖项

  • 创建用户服务

  • 在数据库中安全存储密码

  • 验证用户

  • 创建 Flask 应用

  • Web 会话

TempMessenger 目标

让我们为我们的新的和改进的 TempMessenger 增加一些新目标:

  • 用户现在可以注册该应用

  • 要发送消息,用户必须登录

  • 未登录的用户仍然可以阅读所有消息

如果您在任何时候想要查看本章中的所有代码,请随时在以下网址查看带有测试的完整代码:url.marcuspen.com/github-ppb

要求

为了在本章中运行,您的本地计算机需要以下内容:

  • 互联网连接

  • Docker:如果您尚未安装 Docker,请参阅官方文档:url.marcuspen.com/docker-install

  • 运行 Python 3.6 或更高版本的 virtualenv;您可以重用上一章的 virtualenv。

  • pgAdmin:请参阅官方文档以获取安装说明:url.marcuspen.com/pgadmin

  • 运行在默认端口上的 RabbitMQ 容器:这应该是上一章第五章中的内容,使用微服务构建 Web Messenger

  • 运行在默认端口上的 Redis 容器:这应该是上一章第五章中的内容,使用微服务构建 Web Messenger

随着我们在本章的学习过程中,所有其他要求都将被安装。

本章中的所有说明都针对 macOS 或 Debian/Ubuntu 系统;但是,我已经努力只使用多平台依赖项。

在本章中,将会有一些代码块。不同类型的代码将有它们自己的前缀,如下所示:

$:在您的终端中执行,始终在您的 virtualenv 中

>>>:在您的 Nameko/Python shell 中执行

无前缀:要在您的编辑器中使用的 Python 代码块

创建一个 Postgres 依赖项

以前,我们想要存储的所有数据都是临时的。消息有固定的生命周期,并且会自动过期;如果我们的应用程序发生灾难性故障,那么最坏的情况就是我们的消息会丢失,对于 TempMessenger 来说几乎没有问题!

然而,用户帐户是完全不同的问题。他们必须被存储,只要用户愿意,他们必须被安全地存储。我们还需要一个适当的模式来保持这些帐户的数据一致。我们还需要能够轻松地查询和更新数据。

因此,Redis 可能不是最佳解决方案。构建微服务的许多好处之一是,我们不会被特定的技术所束缚;仅因为我们的消息服务使用 Redis 进行存储并不意味着我们的用户服务也必须跟随...

启动一个 Postgres Docker 容器

首先,在终端中启动一个 Postgres Docker 容器:

$ docker run --name postgres -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=users -p 5432:5432 -d postgres

这将启动一个带有一些基本设置的 Postgres 容器:

  • --name设置容器的名称

  • -e 允许我们设置环境变量:

  • POSTGRES_PASSWORD:用于访问数据库的密码

  • POSTGRES_DB:数据库的名称

  • -p 允许我们将容器上的端口5432暴露到本地机器上的端口5432

  • -d 允许我们以守护程序模式启动容器(在后台运行)

如果您正在为生产环境创建数据库,则设置更安全的密码并将其保密是非常重要的!

您可以通过执行以下操作并确保您的postgres容器存在来检查容器是否正在运行:

$ docker ps

创建用户模型

为了在 Postgres 中存储关于我们用户的数据,我们首先需要创建一个模型,该模型将定义我们要存储的字段和数据类型。

我们首先需要安装两个新的 Python 包:SQLAlchemy 和 Psycopg。SQLAlchemy 是一个工具包和对象关系映射器,将作为我们进入 SQL 世界的入口。Psycopg 是 Python 的 PostgreSQL 数据库适配器。

首先将sqlalchemy在撰写本文时为 1.2.1 版本)和psycopg2在撰写本文时为 2.7.4 版本)添加到您的base.in文件中。从项目文件夹的根目录,在您的虚拟环境中运行:

$ pip-compile requirements/base.in
$ pip-sync requirements/base.txt requirements/test.txt

这将向我们的要求中添加sqlalchemypsycopg2,并确保我们的虚拟环境包与它们完全匹配。或者,如果您选择不使用 pip-tools,也可以使用pip install它们。

在我们的依赖文件夹中,创建一个新文件users.py。通常,您会为您的数据库模型有一个不同的文件,但为了简单起见,我们将它嵌入到我们的依赖中。首先,让我们定义我们的导入和我们的模型将使用的基类:

from sqlalchemy import Column, Integer, Unicode 
from sqlalchemy.ext.declarative import declarative_base 

Base = declarative_base() 

我们首先导入Column,它将用于声明我们的数据库列,以及一些基本字段类型:IntegerUnicode。至于declarative_base,我们使用它来创建我们的Base类,从而我们的用户模型将继承自它。这将创建我们的模型与数据库表之间的映射。

现在让我们为我们的users定义一个基本模型:

class User(Base): 
    __tablename__ = 'users' 

    id = Column(Integer, primary_key=True) 
    first_name = Column(Unicode(length=128)) 
    last_name = Column(Unicode(length=128)) 
    email = Column(Unicode(length=256), unique=True) 
    password = Column(Unicode(length=512)) 

正如您所看到的,我们的User类继承自我们之前定义的Base类。__tablename__设置表的名称。让我们简要地回顾一下我们定义的一些数据库列:

  • id:我们数据库中每个用户的唯一标识符和主键。对于简单起见,数据库模型通常将其 ID 设置为整数。

  • first_namelast_name:128 个字符的最大长度对于任何名称应该足够了。我们还使用Unicode作为我们的类型,以适应包含诸如中文之类的符号的名称。

  • email:同样,一个大的字段长度和Unicode来适应符号。我们还使这个字段是唯一的,这将防止创建具有相同电子邮件地址的多个用户。

  • password:我们不会在这里以明文存储密码;我们稍后会回到这个问题!

要了解更多关于 SQLAlchemy 的信息,请参阅url.marcuspen.com/sqlalchemy

创建用户依赖项

现在我们已经定义了一个基本的用户模型,我们可以为其创建一个 Nameko 依赖项。幸运的是,nameko-sqlalchemy已经为我们做了一些工作,这是一个开源的 Nameko 依赖项,它将处理围绕数据库会话的所有语义,并为我们提供一些非常有用的 Pyest 固定装置进行测试。

通过将其添加到requirements/base.in文件中安装nameko-sqlalchemy在撰写本文时为 1.0.0 版本),并按照之前的相同步骤安装sqlalchemy

现在我们将创建一个包装器类,用于封装管理用户的所有逻辑。在users.py中,添加以下代码:

class UserWrapper: 

    def __init__(self, session): 
        self.session = session 

这将是我们包装器的基础,并且将需要一个数据库会话对象,形式为session。稍后,我们将向这个类添加更多的方法,比如createauthenticate。为了创建我们的用户依赖项,首先让我们将以下内容添加到我们的导入中:

from nameko_sqlalchemy import DatabaseSession 

现在让我们创建一个新的类,User Store,它将作为我们的依赖:

class UserStore(DatabaseSession): 

    def __init__(self): 
        super().__init__(Base) 

    def get_dependency(self, worker_ctx): 
        database_session = super().get_dependency(worker_ctx) 
        return UserWrapper(session=database_session) 

解释这段代码,首先让我们谈谈DatabaseSession。这是一个为 Nameko 预先制作的依赖提供者,由nameko-sqlalchemy提供给我们,已经包括了setupget_dependency等方法,就像上一章介绍的那样。因此,我们的UserStore类只是继承它来使用这个现有的功能。

DatabaseSession类的__init__方法以我们的模型的声明性基础作为它唯一的参数。在我们的UserStore类中,我们用我们自己的__init__方法覆盖了这个方法,它修改为使用我们的Base并执行与原始功能相同的功能,使用 Python 内置的super函数。

要了解更多关于 Python 的super方法,请参见:url.marcuspen.com/python-super

DatabaseSession类中的原始get_dependency方法只是返回一个数据库会话;然而,我们希望我们的方法返回一个UserWrapper的实例,这样我们就可以轻松调用后面将要创建的createauthenticate方法。为了以一种优雅的方式覆盖它,以便我们仍然保留生成数据库会话的所有逻辑,我们再次使用super函数来生成database_session并返回我们的UserWrapper的实例。

创建用户

现在我们已经有了 Nameko 的依赖,我们可以开始为我们的UserWrapper添加功能。我们将从创建用户开始。将以下内容添加到UserWrapper类中:

def create(self, **kwargs): 
    user = User(**kwargs) 
    self.session.add(user) 
    self.session.commit() 

这个create方法将创建一个新的User对象,将其添加到我们的数据库会话中,提交到数据库的更改,并返回用户。这里没有什么花哨的!但让我们谈谈self.session.addself.session.commit的过程。当我们首次将用户添加到会话中时,这将把用户添加到我们本地数据库会话中的内存中,而不是将它们添加到我们的实际数据库中。新用户已经被暂存,但实际上并没有在我们的数据库中进行任何更改。这是非常有用的。假设我们想对数据库进行多次更新,多次调用数据库可能会很昂贵,所以我们首先在内存中进行所有想要的更改,然后使用单个数据库事务commit它们。

在前面的代码中你会注意到的另一件事是,我们使用了**kwargs而不是定义实际的参数来创建一个新的User。如果我们要更改用户模型,这样可以最小化所需的更改,因为关键字参数直接映射到字段。

创建用户服务

在上一章中,我们只是在同一个模块中有两个服务,这对于任何小规模项目来说都是可以的。然而,现在我们的平台开始增长,服务之间定义了新的角色,让我们开始通过将它们放在不同的模块中来拆分它们。在你的service.py旁边,创建一个新文件,user_service.py

添加以下代码:

from nameko.rpc import rpc 
from .dependencies.users import UserStore 

class UserService: 

    name = 'user_service' 
    user_store = UserStore() 

    @rpc 
    def create_user(self, first_name, last_name, email, password): 
        self.user_store.create( 
            first_name=first_name, 
            last_name=last_name, 
            email=email, 
            password=password, 
        ) 

如果你读过上一章,那么这里没有什么新的。我们创建了一个新的UserService,给它了UserStore依赖,并进行了一个 RPC,这只是一个对依赖的create方法的透传。然而,在这里我们选择定义创建用户的参数,而不像我们在依赖方法中使用**kwargs。这是因为我们希望 RPC 定义它与其他服务的契约。如果另一个服务发出无效调用,我们希望 RPC 尽快拒绝它,而不是浪费时间去调用依赖,或者更糟糕的是进行数据库查询。

我们已经接近可以测试这个功能的点了,但首先我们需要更新我们的config.yaml文件,加入我们的数据库设置。如果你使用了之前提供的命令来创建一个 Docker Postgres 容器,追加以下内容:

DB_URIS: 
  user_service:Base: 
    "postgresql+psycopg2://postgres:secret@localhost/ 
    users?client_encoding=utf8" 

DB_URISnameko-sqlalchemy用于将 Nameko 服务和声明性基础对映射到 Postgres 数据库。

我们还需要在我们的 Postgres 数据库中创建表。通常情况下,您可以使用数据库迁移工具(如 Alembic)来完成这项工作。但是,为了本书的目的,我们将使用一个小的一次性 Python 脚本来为我们完成这项工作。在项目目录的根目录中,创建一个名为setup_db.py的新文件,其中包含以下代码:

from sqlalchemy import create_engine 
from temp_messenger.dependencies.users import User 

engine = create_engine( 
    'postgresql+psycopg2://postgres:secret@localhost/' 
    'users?client_encoding=utf8' 
) 
User.metadata.create_all(engine) 

此代码使用我们依赖模块中的用户模型,并为我们在数据库中创建所需的表。create_engine是起点,因为它建立了与数据库的连接。然后我们使用我们的用户模型metadata(在我们的情况下包括表名和列)并调用create_all,它使用engine向数据库发出CREATE SQL 语句。

如果您要对用户模型进行更改并保留现有用户数据,那么学习如何使用数据库迁移工具(如 Alembic)是必不可少的,我强烈推荐这样做。

要了解有关如何使用 Alembic 的更多信息,请参阅url.marcuspen.com/alembic

要运行,请在您的虚拟环境中的终端中执行:

$ python setup_db.py

现在让我们使用数据库管理工具来查看我们的新表。有许多数据库管理工具,我个人最喜欢的是 Mac 上的 Postico,但是为了本书的目的,我们将使用适用于所有平台的 pgAdmin。

url.marcuspen.com/pgadmin下载并安装 pgAdmin。安装完成后,打开并选择“添加新服务器”,将会弹出以下窗口:

在“常规”选项卡中简单地给它一个您选择的名称,然后在“连接”选项卡中,您可以填写我们的数据库详细信息,这些详细信息是我们在之前创建 Postgres Docker 截图时设置的。但是,如果您没有对此进行任何更改,您可以简单地复制前面图像中的详细信息。请记住,密码设置为secret。填写完毕后,点击“保存”,它应该连接到我们的数据库。

连接后,我们可以开始查看我们数据库的详细信息。要查看我们的表,您需要展开并操作菜单,就像这样:

现在您应该能够看到我们的表,它代表了我们的用户模型:

现在我们可以尝试使用 Nameko shell 创建一个用户。通过在项目文件夹的根目录中,在虚拟环境中执行以下命令,在终端中启动我们的新用户服务:

$ nameko run temp_messenger.user_service --config config.yaml

在另一个终端窗口中,在您的虚拟环境中执行:

$ nameko shell

在 Nameko shell 中,执行以下命令以创建新用户:

>>> n.rpc.user_service.create_user('John', 'Doe', 'john@example.com', 'super-secret')

现在让我们检查 pgAdmin,看看用户是否成功创建。要刷新数据,只需按照之前的步骤显示用户表或单击“刷新”按钮即可:

成功了!我们现在有一个可以创建新用户的功能性用户服务。但是,这里有一个主要问题...我们刚刚犯了软件开发人员可以犯的最严重的错误之一——以明文形式存储密码!

在数据库中安全地存储密码

现在是 2018 年,到目前为止,我们可能已经听过数十个关于公司泄露我们的敏感数据,包括密码,给黑客的故事。在许多情况下,泄露的密码存储的加密非常差,这意味着它们可以轻松破解。在某些情况下,密码甚至以明文形式存储!

无论如何,这种疏忽导致了数百万用户的电子邮件和密码组合泄漏。如果我们为每个在线账户使用不同的密码,这可能不是一个问题...但不幸的是,我们很懒,密码重用是相当普遍的做法。因此,减轻黑客入侵我们服务器造成的一些损害的责任落在我们开发人员身上。

2016 年 10 月,流行的视频分享平台 Dailymotion 遭遇了一次数据泄露,其中有 8500 万个帐户被盗。在这 8500 万个帐户中,有 1800 万个帐户附带了密码,但幸运的是它们是使用 Bcrypt 进行散列的。这意味着黑客需要几十年,甚至几个世纪的暴力计算才能用今天的硬件破解它们(来源:url.marcuspen.com/dailymotion-hack)。

因此,尽管黑客成功侵入了 Dailymotion 的服务器,但通过使用散列算法(如 Bcrypt)存储密码,部分损害得到了缓解。考虑到这一点,我们现在将看看如何为我们的用户密码实现bcrypt散列,而不是以明文方式存储它们。

使用 Bcrypt

首先将bcrypt添加到您的base.in文件中,并使用与之前相同的过程安装它(在撰写本文时为 3.1.4 版本)。

如果您在安装 Bcrypt 时遇到问题,请参阅它们的安装说明,其中包括有关系统软件包依赖项的详细信息:url.marcuspen.com/pypi-bcrypt

为了bcrypt创建密码的散列,它需要两样东西——您的密码和一个saltsalt只是一串随机字符。让我们看看如何在 Python 中创建salt

>>> from bcrypt import gensalt
>>> gensalt()
b'$2b$12$fiDoHXkWx6WMOuIfOG4Gku'

这是创建与 Bcrypt 兼容的salt的最简单方法。$符号代表salt的不同部分,我想指出第二部分:$12。这部分表示生成密码散列所需的工作轮次,默认为12。我们可以这样配置:

>>> gensalt(rounds=14)
b'$2b$14$kOUKDC.05iq1ANZPgBXxYO'

注意这个salt,它已经改变成$14。通过增加这个值,我们也增加了创建密码的散列所需的时间。这也会增加后来检查密码尝试与散列的时间。这是有用的,因为我们试图阻止黑客在设法获取我们的数据库后对密码尝试进行暴力破解。然而,默认的轮次12已经足够了!现在让我们创建一个密码的散列:

>>> from bcrypt import hashpw, gensalt
>>> my_password = b'super-secret'
>>> salt = gensalt()
>>> salt
b'$2b$12$YCnmXxOcs/GJVTHinSoVs.'
>>> hashpw(my_password, salt)
b'$2b$12$YCnmXxOcs/GJVTHinSoVs.43v/.RVKXQSdOhHffiGNk2nMgKweR4u'

在这里,我们只是使用默认数量的轮次生成了一个新的salt,并使用hashpw生成了散列。注意我们的密码的salt也在散列的第一部分中?这非常方便,因为这意味着我们不必单独存储salt,这在以后验证用户时会需要。

由于我们使用了默认数量的轮次来生成salt,为什么不尝试设置自己的轮次?请注意,设置的轮次越高,hashpw所需的时间就越长。当轮次设置为 20 时,我的机器花了将近 2 分钟来创建一个散列!

现在让我们看看如何检查密码与散列相匹配:

>>> from bcrypt import hashpw, checkpw, gensalt
>>> my_password = b'super-secret'
>>> salt = gensalt()
>>> hashed_password = hashpw(my_password, salt)
>>> password_attempt = b'super-secret'
>>> checkpw(password_attempt, hashed_password)
True

正如你所看到的,checkpw接受我们正在检查的密码尝试和散列密码作为参数。当我们在我们的依赖项中实现这一点时,密码尝试将是来自 Web 请求的部分,散列密码将存储在数据库中。由于这是一个成功的尝试,checkpw返回True。让我们尝试使用一个无效的密码进行相同的操作:

>>> password_attempt = b'invalid-password'
>>> checkpw(password_attempt, hashed_password)
False

毫不奇怪!它返回了False

如果您想了解更多关于存储密码和某些方法的缺陷的信息,我建议您阅读 Dustin Boswell 的这篇简短文章:url.marcuspen.com/dustwell-passwords。它很好地解释了黑客如何尝试使用暴力破解和彩虹表来破解密码。它还更详细地介绍了 Bcrypt。

散列我们的用户密码

现在我们知道如何更安全地存储密码了,让我们修改我们的create方法,在将密码存储到数据库之前对其进行哈希处理。首先,在我们的users.py依赖文件的顶部,让我们将bcrypt添加到我们的导入中,并添加一个新的常量:

import bcrypt 

HASH_WORK_FACTOR = 15 

我们的新常量HASH_WORK_FACTOR将用于gensalt使用的轮次参数。我把它设置为 15,这将导致创建密码哈希和检查密码需要花费更长的时间,但会更安全。请随意设置,但请记住,增加这个值会导致我们的应用在以后创建和验证用户时需要更长的时间。

现在,在任何类之外,我们将定义一个新的辅助函数来哈希密码:

def hash_password(plain_text_password): 
    salt = bcrypt.gensalt(rounds=HASH_WORK_FACTOR) 
    encoded_password = plain_text_password.encode() 

    return bcrypt.hashpw(encoded_password, salt) 

这个辅助函数简单地获取我们的明文密码,生成一个salt,并返回一个哈希密码。现在,您可能已经注意到,当使用 Bcrypt 时,我们总是必须确保我们给它的密码是字节串。正如您从前面的代码中注意到的那样,我们必须在将密码(默认为 UTF-8)传递给hashpw之前对其进行.encode()处理。Bcrypt 还将以字节串格式返回哈希密码。这将带来的问题是,我们数据库中密码的字段当前设置为 Unicode,与我们的密码不兼容。我们有两个选择:要么在存储密码之前调用.decode(),要么修改我们的密码字段为可以接受字节串的类型,比如LargeBinary。让我们选择后者,因为它更清晰,可以避免我们每次访问数据时都需要转换数据。

首先,让我们修改导入字段类型的行,包括LargeBinary

from sqlalchemy import Column, Integer, LargeBinary, Unicode 

现在我们可以更新我们的User模型来使用我们的新字段类型:

class User(Base): 
    __tablename__ = 'users' 

    id = Column(Integer, primary_key=True) 
    first_name = Column(Unicode(length=128)) 
    last_name = Column(Unicode(length=128)) 
    email = Column(Unicode(length=256), unique=True) 
    password = Column(LargeBinary()) 

我们现在唯一的问题是我们现有的数据库与我们的新模式不兼容。为了解决这个问题,我们可以删除数据库表或执行迁移。在现实世界的环境中,删除整个表是绝对不可取的!如果您已经采纳了我之前的建议学习 Alembic,那么我鼓励您将您的知识付诸实践,并执行数据库迁移。但出于本书的目的,我将利用一次性的 Docker 容器并从头开始。为此,在您的项目根目录和虚拟环境内执行:

$ docker rm -f postgres
$ docker run --name postgres -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=users -p 5432:5432 -d postgres
$ python setup_db.py

这将删除您现有的 Postgres 容器,创建一个新的容器,并运行我们之前制作的setup_db.py脚本。如果您检查 pgAdmin,您现在会看到密码列标题中的字段类型已从character varying (512)更改为bytea

最后,我们现在准备更新我们的create方法来使用我们的新的hash_password函数:

def create(self, **kwargs): 
    plain_text_password = kwargs['password'] 
    hashed_password = hash_password(plain_text_password) 
    kwargs.update(password=hashed_password) 

    user = User(**kwargs) 
    self.session.add(user) 
    self.session.commit() 

正如您在方法的前三行中所看到的:

  1. kwargs中提取plain_text_password

  2. 调用hash_password来创建我们的hashed_password

  3. kwargs执行更新,以用哈希版本替换密码。

代码的其余部分与我们之前的版本相同。

让我们试一试。在您的虚拟环境中的终端中,启动(或重新启动)用户服务:

$ nameko run temp_messenger.user_service --config config.yaml

在您的虚拟环境中的另一个终端窗口中,启动您的 Nameko shell:

$ nameko shell

在您的 Nameko shell 中,执行以下操作再次添加新用户:

>>> n.rpc.user_service.create_user('John', 'Doe', 'john@example.com', 'super-secret')

您应该注意到(取决于您设置的HASH_WORK_FACTOR有多大),与上次创建新用户相比,现在会有一些延迟。

现在您应该在 pgAdmin 中看到以下内容:

处理重复用户

由于我们将 email 字段设置为唯一,我们的数据库已经阻止了重复的用户。但是,如果您自己尝试,您会发现返回的输出并不理想。尝试在 Nameko shell 中再次添加相同的用户。

另一个问题是,如果在创建新用户时出现任何其他错误,我们的外部服务没有很好的方式来对这些不同类型的错误做出反应,而不知道我们正在使用的数据库类型,这是我们要尽一切努力避免的。

为了解决这个问题,让我们首先在我们的users.py中创建两个新的异常类:

class CreateUserError(Exception): 
    pass 

class UserAlreadyExists(CreateUserError): 
    pass 

我们还需要更新我们的导入,包括IntegrityError,这是 SQLAlchemy 在唯一键违规时引发的错误类型:

from sqlalchemy.exc import IntegrityError 

同样,我们将修改我们的create方法,这次使用我们的两个新异常:

def create(self, **kwargs): 
    plain_text_password = kwargs['password'] 
    hashed_password = hash_password(plain_text_password) 
    kwargs.update(password=hashed_password) 

    user = User(**kwargs) 
    self.session.add(user) 

    try: 
        self.session.commit() # ① 
    except IntegrityError as err: 
        self.session.rollback() # ② 
        error_message = err.args[0] # ③ 

        if 'already exists' in error_message: 
            email = kwargs['email'] 
            message = 'User already exists - {}'.format(email) 
            raise UserAlreadyExists(message) # ④ 
        else: 
            raise CreateUserError(error_message) # ⑤ 

我们在这里所做的是:

  1. self.session.commit()包装在 try except 块中。

  2. 如果发生IntegrityError,回滚我们的会话,这将从我们的数据库会话中删除用户-在这种情况下并不完全必要,但无论如何都是一个好的做法。

  3. 提取错误消息。

  4. 检查它是否包含字符串'already exists'。如果是,那么我们知道用户已经存在,我们引发适当的异常UserAlreadyExists,并给它一个包含用户电子邮件的错误消息。

  5. 如果不是,那么我们有一个意外的错误,并引发更通用的错误,适合我们的服务,CreateUserError,并给出整个错误消息。

通过这样做,我们的外部服务现在将能够区分用户错误和意外错误。

为了测试这一点,重新启动用户服务,并尝试在 Nameko shell 中再次添加相同的用户。

验证用户

现在我们可以看看如何验证用户。这是一个非常简单的过程:

  1. 从数据库中检索我们要验证的用户。

  2. 执行bcrypt.checkpw,给出尝试的密码和用户的密码哈希。

  3. 如果结果是False,则引发异常。

  4. 如果是True,则返回用户。

从数据库中检索用户

从第一点开始,我们需要添加一个新的依赖方法get,如果存在,则返回用户的电子邮件。

首先,在users.py中添加一个新的异常类:

class UserNotFound(Exception): 
    pass 

这是我们在用户找不到时会引发的。现在我们将更新我们的导入,包括以下内容:

from sqlalchemy.orm.exc import NoResultFound 

NoResultFound,顾名思义,是 SQLAlchemy 在数据库中找不到请求的对象时引发的。现在我们可以为我们的UserWrapper类添加一个新方法:

def get(self, email): 
    query = self.session.query(User) # ① 

    try: 
        user = query.filter_by(email=email).one() # ② 
    except NoResultFound: 
        message = 'User not found - {}'.format(email) 
        raise UserNotFound(message) # ③ 

    return user 

让我们了解一下我们在前面的代码中做了什么:

  1. 为了查询我们的数据库,我们首先必须使用我们的用户模型作为参数来创建一个查询对象。

  2. 一旦我们有了这个,我们可以使用filter_by并指定一些参数;在这种情况下,我们只想按电子邮件过滤。filter_by总是返回一个可迭代对象,因为可能有多个结果,但由于我们在电子邮件字段上有一个唯一的约束,所以可以安全地假设如果存在,我们只会有一个匹配。因此,我们调用.one(),它返回单个对象,如果过滤器为空,则引发NoResultFound

  3. 我们处理NoResultFound并引发我们自己的异常UserNotFound,并附上错误消息,这更适合我们的用户服务。

验证用户的密码

我们现在将实现一个authenticate方法,该方法将使用我们刚刚创建的get方法。

首先,让我们创建一个新的异常类,如果密码不匹配,将引发该异常:

class AuthenticationError(Exception): 
    pass 

我们现在可以为我们的UserWrapper创建另一个方法来验证用户:

def authenticate(self, email, password): 
    user = self.get(email) # ① 

    if not bcrypt.checkpw(password.encode(), user.password): # ② 
        message = 'Incorrect password for {}'.format(email) 
        raise AuthenticationError(message) # ③ 
  1. 我们首先使用我们最近创建的get方法从数据库中检索我们要验证的用户。

  2. 然后,我们使用bcrypt.checkpw来检查尝试的密码是否与从数据库中检索的用户对象上存储的密码匹配。我们在密码尝试上调用.encode(),因为我们的外部服务不会为我们执行此操作。它也不应该;这是 Bcrypt 特有的逻辑,这样的逻辑应该留在依赖项中。

  3. 如果密码不正确,我们会引发AuthenticationError错误,并附上适当的消息。

现在剩下的就是在user_service.py中的UserService类上创建一个 RPC:

@rpc 
def authenticate_user(self, email, password): 
    self.user_store.authenticate(email, password) 

这里没有什么特别的,只是一个简单的传递到我们刚刚创建的user_store依赖方法。

让我们测试一下。重新启动user_service,并在您的 Nameko shell 中执行以下操作:

>>> n.rpc.user_service.authenticate_user('john@example.com', 'super-secret')
>>>

如果成功,它应该什么都不做!现在让我们尝试使用错误的密码:

>>> n.rpc.user_service.authenticate_user('john@example.com', 'wrong')
Traceback (most recent call last):
...
nameko.exceptions.RemoteError: PasswordMismatch Incorrect password for john@example.com
>>>

就是这样!这结束了我们对用户服务的工作。现在我们将看看如何将其与我们现有的服务集成。

如果您想看一下如何为用户服务编写一些测试,您会在本章开头提到的 Github 存储库中找到它们以及所有代码。

拆分服务

目前,我们在同一个service.py模块中有我们的MessageServerWebServer。现在是时候拆分它们了,特别是因为我们将删除WebServer,转而使用 Flask 服务器。在本章结束时,目标是有三个微服务共同工作,每个都有自己特定的角色:

上图显示了我们的服务将如何相互集成。请注意消息服务和用户服务是完全不知道彼此的。对用户服务的更改不应该需要对消息服务进行更改,反之亦然。通过拆分这些服务,我们还获得了能够在不影响其他服务的情况下部署新代码的优势。Nameko 使用 RabbitMQ 的一个额外好处是,如果一个服务短暂下线,任何工作将被排队,直到服务重新上线。我们现在将开始收获微服务架构的一些好处。

要开始这个重构,让我们在temp_messenger文件夹中创建一个新文件,名为message_service.py

from nameko.rpc import rpc 
from .dependencies.messages import MessageStore 

class MessageService: 

    name = 'message_service' 

    message_store = MessageStore() 

    @rpc 
    def get_message(self, message_id): 
        return self.message_store.get_message(message_id) 

    @rpc 
    def save_message(self, message): 
        message_id = self.message_store.save_message(message) 
        return message_id 

    @rpc 
    def get_all_messages(self): 
        messages = self.message_store.get_all_messages() 
        sorted_messages = sort_messages_by_expiry(messages) 
        return sorted_messages 

def sort_messages_by_expiry(messages, reverse=False): 
    return sorted( 
        messages, 
        key=lambda message: message['expires_in'], 
        reverse=reverse 
    ) 

我们在这里所做的就是从旧的service.py中取出MessageService和所有相关代码,放入我们的新的message_service.py模块中。

创建 Flask 服务器

我们现在将创建一个新的 Flask Web 服务器,它将取代我们的 Nameko Web 服务器。Flask 更适合处理 Web 请求,而且内置功能更多,同时还相当轻量级。我们将利用其中的一个功能,即会话,它将允许我们的服务器跟踪谁已登录。它还与 Jinja2 一起使用模板,这意味着我们现有的模板应该已经可以工作。

首先在我们的base.in文件中添加flask,然后使用与之前相同的过程pip-compile和安装(在撰写本文时为 0.12.2 版本)。

开始使用 Flask 非常简单;我们将从创建新的主页端点开始。在您的temp_messenger目录中,创建一个名为web_server.py的新文件,内容如下:

from flask import Flask, render_template # ① 

app = Flask(__name__) # ② 

@app.route('/') # ③ 
def home(): 
    return render_template('home.html') # ④ 
  1. 我们从flask中导入以下内容:
  • Flask:用于创建我们的 Flask 应用对象

  • render_template:渲染给定的模板文件

  1. 创建我们的app,唯一的参数是从__name__派生的模块名称。

  2. @app.route允许您使用 URL 端点装饰一个函数。

有了这个,我们将能够启动我们的新 Flask Web 服务器,尽管没有功能。要测试这一点,首先导出一些环境变量:

$ export FLASK_DEBUG=1
$ export FLASK_APP=temp_messenger/web_server.py

第一个将设置应用程序为调试模式,这是我喜欢的一个功能,因为当我们更新代码时,它将热重载,不像 Nameko 服务。第二个简单地告诉 Flask 我们的应用程序在哪里。

在启动 Flask 应用程序之前,请确保您当前没有运行旧的 Nameko Web 服务器,因为这将导致端口冲突。

在您的虚拟环境中,在项目的根目录中执行以下命令以启动服务器:

$ flask run -h 0.0.0.0 -p 8000

这将在端口8000上启动 Flask 服务器,与我们以前的 Nameko web 服务器运行的端口相同。只要您的本地网络允许,甚至可以让同一网络上的其他设备导航到您的机器 IP 并使用 TempMessenger!现在在浏览器中转到http://127.0.0.1:8000,您应该看到以下内容(尽管没有功能):

看起来与我们以前的类似,对吧?那是因为 Flask 已经使用 Jinja2 作为其默认的模板引擎,所以如果我们愿意,我们可以删除我们旧的jinja2.py依赖,因为它不再需要了。Flask 还会在与应用程序相同的目录中查找一个名为templates的文件夹,这就是它自动知道在哪里找到home.html的方式。

现在让我们添加从我们的消息服务中检索消息的功能。这与我们在两个 Nameko 服务之间通信时略有不同,因为 Flask 不知道如何执行 RPC。首先,让我们添加以下内容到我们的导入中:

from flask.views import MethodView 
from nameko.standalone.rpc import ClusterRpcProxy 
from flask.json import jsonify 

我们还需要添加一些配置,以便 Flask 知道在哪里找到我们的 RabbitMQ 服务器。我们可以将其添加到我们的模块中作为一个常量,但由于我们已经在config.yaml中有AMQP_URI,所以没有必要重复!在我们的web_server.py模块中,在app = Flask(__name__)之前,添加以下内容:

import yaml 
with open('config.yaml', 'r') as config_file: 
    config = yaml.load(config_file) 

这将从config.yaml中加载所有的配置变量。现在将以下类添加到web_server.py中:

class MessageAPI(MethodView): 

    def get(self): 
        with ClusterRpcProxy(config) as rpc: 
            messages = rpc.message_service.get_all_messages() 

        return jsonify(messages) 

而我们的主页端点有一个基于函数的视图,这里我们有一个基于类的视图。我们定义了一个get方法,它将用于对这个MessageAPI的任何GET请求。请注意,这里方法的名称很重要,因为它们映射到它们各自的请求类型。如果我们要添加一个post方法(我们稍后会添加),那么它将映射到MessageAPI上的所有POST请求。

ClusterRpcProxy允许我们在 Nameko 服务之外进行 RPC。它被用作上下文管理器,并允许我们轻松调用我们的消息服务。Flask 带有一个方便的辅助函数jsonify,它将我们的消息列表转换为 JSON。然后简单地返回该有效负载,Flask 会为我们处理响应头和状态码。

现在让我们添加发送新消息的功能。首先,修改你的 flask 导入以包括请求:

from flask import Flask, render_template, request 

现在在MessageAPI类中添加一个新的 post 方法:

def post(self): # ① 
    data = request.get_json(force=True) # ② 

    try: 
        message = data['message'] # ③ 
    except KeyError: 
        return 'No message given', 400 

    with ClusterRpcProxy(config) as rpc: # ④ 
        rpc.message_service.save_message(message) 

    return '', 204 # ⑤ 
  1. 您可能会注意到,与我们在 Nameko web 服务器中使用post参数获取request对象的方式不同,我们是从 Flask 中导入它的。在这种情况下,它是一个全局对象,为我们解析所有传入的请求数据。

  2. 我们使用get_json,这是一个内置的 JSON 解析器,将替换我们上一章的get_request_data函数。我们指定force=True,这将强制要求请求具有有效的 JSON 数据;否则它将返回400 Bad Request错误代码。

  3. 与我们旧的post_messageHTTP 端点一样,我们尝试获取data['message'],否则返回400

  4. 然后我们再次使用ClusterRpcProxy进行 RPC 以保存消息。

  5. 如果一切顺利,返回204。我们在这里使用204而不是200来表示,虽然请求仍然成功,但没有要返回的内容。

在这之前,我们还需要做一件事,那就是注册我们的MessageAPI到一个 API 端点。在我们的web_server.py的底部,在MessageAPI类之外,添加以下内容:

app.add_url_rule( 
    '/messages', view_func=MessageAPI.as_view('messages') 
) 

这将把任何请求重定向到/messagesMessageAPI

现在是时候重新启动我们的消息服务了。在一个新的终端窗口中,在您的虚拟环境中执行:

$ nameko run temp_messenger.message_service --config config.yaml

由于我们现在有多个服务,这需要在不同的终端窗口中运行多个实例。如果您的 Nameko 服务在您发出请求时关闭,这可能会导致功能无限期地挂起,直到该服务再次上线。这是 Nameko 使用消息队列来消耗新任务的一个副作用;任务只是在队列中等待服务接收。

假设您的 Flask 服务器仍在运行,现在您应该能够访问我们的应用程序,以前的所有功能都在http://127.0.0.1:8000上!

Web 会话

现在我们通过新的 Flask 服务器恢复了旧的功能,我们可以开始添加一些新功能,比如登录和注销用户,创建新用户,并且只允许已登录的用户发送消息。所有这些都严重依赖于 Web 会话。

Web 会话允许我们通过 cookie 在不同的请求之间跟踪用户。在这些 cookie 中,我们存储可以从一个请求传递到下一个请求的信息。例如,我们可以存储用户是否经过身份验证,他们的电子邮件地址是什么,等等。这些 cookie 使用一个密钥进行加密签名,我们需要在使用 Flask 的会话之前定义它。在config.yaml中,添加以下内容:

FLASK_SECRET_KEY: 'my-super-secret-flask-key' 

随意设置您自己的密钥,这只是一个例子。在类似生产环境中,这必须保持安全和安全,否则用户可以伪造自己的会话 cookie。

现在我们需要告诉我们的app使用这个密钥。在app = Flask(__name__)之后添加以下内容:

app.secret_key = config['FLASK_SECRET_KEY'] 

完成后,Flask 现在将使用我们在config.yaml中的FLASK_SECRET_KEY来签署 cookie。

创建注册页面

我们将通过为新用户添加注册功能来开始这些新功能。在web_server.py中,添加以下新类:

class SignUpView(MethodView): 

    def get(self): 
        return render_template('sign_up.html') 

这个新的SignUpView类将负责处理注册过程。我们添加了一个 get 方法,它将简单地渲染我们稍后将创建的sign_up.html模板。

web_server.py模块的末尾,创建以下 URL 规则:

app.add_url_rule( 
    '/sign_up', view_func=SignUpView.as_view('sign_up') 
) 

正如您可能已经知道的,这将把所有请求重定向到/sign_up到我们的新SignUpView类。

现在让我们创建我们的新模板。在templates文件夹中,创建一个新文件,sign_up.html

<!DOCTYPE html> 
<body> 
  <h1>Sign up</h1> 
  <form action="/sign_up" method="post"> 
    <input type="text" name="first_name" placeholder="First Name"> 
    <input type="text" name="last_name" placeholder="Last Name"> 
    <input type="text" name="email" placeholder="Email"> 
    <input type="password" name="password" placeholder="Password"> 
    <input type="submit" value="Submit"> 
  </form> 
  {% if error_message %} 
    <p>{{ error_message }}</p> 
  {% endif %} 
</body> 

这是一个基本的 HTML 表单,包括在我们的数据库中创建新用户所需的字段。actionmethod表单告诉它向/sign_up端点发出post请求。所有字段都是text字段,除了密码,它是password类型,这将导致用户输入被掩盖。我们还有一个 Jinja if语句,它将检查模板是否渲染了error_message。如果是,那么它将显示在段落块中。我们稍后将使用这个来向用户显示消息,比如“用户已存在”。

做出这些更改后,假设您的 Flask 服务器仍在运行,请导航到http://127.0.0.1:8000/sign_up,您应该看到新的注册页面:

这个表格目前还没有任何作用,因为我们还没有为SignUpView定义一个 post 方法。让我们继续创建。首先,在web_server.py中更新我们的导入,包括从 Nameko 导入RemoteError,从 Flask 导入sessionredirecturl_for

from nameko.exceptions import RemoteError 
from flask import ( 
    Flask, 
    Redirect, 
    render_template, 
    request, 
    session, 
    url_for, 
) 

在您的SignUpView类中,添加以下post方法:

def post(self): 
    first_name = request.form['first_name'] # ① 
    last_name = request.form['last_name'] 
    email = request.form['email'] 
    password = request.form['password'] 

    with ClusterRpcProxy(config) as cluster_rpc: 
        try: 
            cluster_rpc.user_service.create_user( # ② 
                first_name=first_name, 
                last_name=last_name, 
                email=email, 
                password=password, 
            ) 
        except RemoteError as err: # ③ 
            message = 'Unable to create user {} - {}'.format( 
                err.value 
            ) 
            app.logger.error(message) 
            return render_template( 
                'sign_up.html', error_message=message 
            ) 

    session['authenticated'] = True # ④ 
    session['email'] = email # ⑤ 

    return redirect(url_for('home')) # ⑥ 

这是一个相当长的方法,但它非常简单。让我们一步一步地来看:

  1. 我们首先从request.form中检索用户的所有相关字段。

  2. 然后我们使用ClusterRpcProxy向我们的user_service发出create_user RPC。

  3. 如果发生错误,通过以下方式处理:

  • 构建错误消息

  • 使用 Flask 的app.logger将该消息记录到控制台

  • 使用错误消息渲染sign_up.html模板

  1. 如果没有错误,那么我们继续向session对象添加一个Trueauthenticated布尔值。

  2. 将用户的电子邮件添加到session对象中。

  3. 最后,我们使用url_for重定向用户,它将寻找名为home的函数端点。

在测试之前,如果您还没有运行用户服务,请在虚拟环境中的新终端中执行:

nameko run temp_messenger.user_service --config config.yaml 

有了这个,现在您应该同时在不同的终端窗口中运行用户服务、消息服务和 Flask Web 服务器。如果没有,请使用之前的namekoflask命令启动它们。

转到http://127.0.0.1:8000/sign_up,尝试创建一个新用户:

一旦您点击提交,它应该将您重定向到主页,并且您的数据库中应该有一个新用户。检查 pgAdmin 以确保它们已经被创建。

现在返回http://127.0.0.1:8000/sign_up,尝试再次添加相同的用户。它应该让您保持在同一个页面上并显示错误消息:

拥有注册页面是很好的,但是我们的用户需要能够在不知道 URL 的情况下导航到它!让我们对home.html进行一些调整,添加一个简单的注册链接。与此同时,我们还可以隐藏发送新消息的功能,除非他们已登录!在我们的home.html中,修改现有的postMessage表单如下:

{% if authenticated %} 
  <form action="/messages" id="postMessage"> 
    <input type="text" name="message" placeholder="Post message"> 
    <input type="submit" value="Post"> 
  </form> 
{% else %} 
  <p><a href="/sign_up">Sign up</a></p> 
{% endif %} 

我们在这里所做的是将我们的表单包装在 Jinja 的if块中。如果用户经过身份验证,那么我们将显示postMessage表单;否则,我们将显示一个链接,引导用户转到注册页面。

现在我们还需要更新我们的主页端点,将session对象中的authenticated布尔值传递给模板渲染器。首先,让我们添加一个新的帮助函数,用于获取用户的认证状态。这应该位于web_server.py模块中任何类之外:

def user_authenticated(): 
    return session.get('authenticated', False) 

这将尝试从session对象中获取authenticated布尔值。如果它是一个全新的session,那么我们不能保证authenticated会在那里,所以我们将其默认为False并返回它。

web_server.py中,更新home端点如下:

@app.route('/') 
def home(): 
    authenticated = user_authenticated() 
    return render_template( 
        'home.html', authenticated=authenticated 
    ) 

这将调用user_authenticated来获取我们用户的authenticated布尔值。然后我们通过传递authenticated来渲染模板。

我们可以做的另一个不错的调整是,只有在用户未经过身份验证时才允许其转到注册页面。为此,我们需要更新SignUpView中的get方法如下:

def get(self): 
    if user_authenticated(): 
        return redirect(url_for('home')) 
    else: 
        return render_template(sign_up.html') 

如果我们经过身份验证,那么我们将用户重定向到home端点;否则,我们渲染sign_up.html模板。

如果您仍然打开了用于创建第一个用户的浏览器,那么如果您尝试导航到http://127.0.0.1:8000/sign_up,它应该将您重定向到我们网站的主页,因为您已经经过身份验证。

如果您打开一个不同的浏览器,在主页上,您应该看到我们制作的新的注册链接,发送新消息的功能应该已经消失,因为您有一个新的会话。

我们现在有一个新问题。我们已经阻止了用户从应用程序发送新消息,但是如果他们使用 Curl 或 REST 客户端,他们仍然可以发送消息。为了阻止这种情况发生,我们需要对MessageAPI进行一点小调整。在MessageAPI的 post 方法开头添加以下内容:

def post(self): 
    if not user_authenticated() 
        return 'Please log in', 401 
    ... 

确保不要调整任何其他代码;...表示我们post方法的其余代码。这将简单地拒绝用户的请求,并使用401响应告诉用户登录。

登出用户

现在我们需要实现用户注销的功能。在web_server.py中,添加以下logout函数端点:

@app.route('/logout') 
def logout(): 
    session.clear() 
    return redirect(url_for('home')) 

如果用户访问此端点,Flask 将清除他们的session对象并将其重定向到home端点。由于会话已清除,authenticated布尔值将被删除。

home.html中,让我们更新我们的页面,包括用户注销的链接。为此,我们将在postMessage表单之后添加一个新链接:

{% if authenticated %} 
  <form action="/messages" id="postMessage"> 
    <input type="text" name="message" placeholder="Post message"> 
    <input type="submit" value="Post"> 
  </form> 
  <p><a href="/logout">Logout</a></p> 
... 

保存后,只要我们已登录,现在我们应该在消息表单下面有一个注销链接:

点击注销链接后,您将被重定向回主页,您将无法再发送消息。

记录用户登录

我们的应用程序如果没有用户登录的能力就不完整!在我们的web_server.py中,创建一个新的类LoginView

class LoginView(MethodView): 

    def get(self): 
        if user_authenticated(): 
            return redirect(url_for('home')) 
        else: 
            return render_template('login.html') 

与我们的SignUpView中的 get 方法类似,这个方法将检查用户是否已经authenticated。如果是,则将重定向到home端点,否则,将呈现login.html模板。

在我们的web_server.py模块的末尾,添加以下 URL 规则以使用LoginView

app.add_url_rule( 
    '/login', view_func=LoginView.as_view('login') 
) 

任何对/login的请求现在都将被重定向到我们的LoginView

现在在我们的模板文件夹中创建一个新模板login.html

<!DOCTYPE html> 
<body> 
  <h1>Login</h1> 
  <form action="/login" method='post'> 
    <input type="text" name="email" placeholder="Email"> 
    <input type="password" name="password" placeholder="Password"> 
    <input type="submit" value="Post"> 
  </form> 
  {% if login_error %} 
    <p>Bad log in</p> 
  {% endif %} 
</body> 

正如您所看到的,这与我们的sign_up.html模板非常相似。我们创建一个表单,但这次我们只有emailpassword字段。我们还有一个 Jinja 的if块用于错误消息。但是,这个错误消息是硬编码的,而不是从LoginView返回的。这是因为告诉用户登录失败的原因是不好的做法。如果是恶意用户,我们告诉他们诸如此用户不存在密码不正确之类的东西,那么这就足以告诉他们我们数据库中存在哪些用户,他们可能会尝试暴力破解密码。

在我们的home.html模板中,让我们还添加一个用户登录的链接。为此,我们将在if authenticated块的else语句中添加一个新链接:

{% if authenticated %} 
... 
{% else %} 
  <p><a href="/login">Login</a></p> 
  <p><a href="/sign_up">Sign up</a></p> 
{% endif %} 

现在我们应该能够从主页导航到登录页面:

为了使我们的登录页面工作,我们需要在我们的LoginView中创建一个post方法。将以下内容添加到LoginView中:

def post(self): 
    email = request.form['email'] # ① 
    password = request.form['password'] 

    with ClusterRpcProxy(config) as cluster_rpc: 
        try: 
            cluster_rpc.user_service.authenticate_user( # ② 
                email=email, 
                password=password, 
            ) 
        except RemoteError as err: # ③ 
            app.logger.error( 
                'Bad login for %s - %s', email, str(err) 
            ) 
            return render_template( 
                'login.html', login_error=True 
            ) 

    session['authenticated'] = True # ④ 
    session['email'] = email # ⑤ 

    return redirect(url_for('home')) # ⑥ 

您会注意到这与我们的SignUpView中的 post 方法非常相似。让我们简要地了解一下正在发生的事情:

  1. 我们从request.form中检索电子邮件和密码。

  2. 我们使用ClusterRpcProxyuser_service发出authenticate_user RPC。

  3. 如果发生RemoteError,那么我们:

  • 使用 Flask 的app.logger将错误记录到控制台

  • 使用login_error设置为True呈现login.html模板

  1. 如果他们成功验证,我们将在session对象中将authenticated设置为True

  2. 将电子邮件设置为session对象中的用户email

  3. 将用户重定向到home端点。

通过上述代码,我们选择将错误消息记录到只有我们可以看到的控制台,而不是将错误消息返回给用户。这使我们能够查看我们的身份验证系统是否存在任何问题,或者恶意用户是否在做坏事,同时仍然让用户知道他们提供了无效的信息。

我们的服务仍在运行,现在您应该能够测试它了!我们现在为 TempMessenger 拥有一个完全运作的身份验证系统,我们的目标已经实现。

在我们的消息中添加电子邮件前缀

我们的 TempMessenger 缺少的一个重要功能是问责制。我们不知道哪些用户发布了什么,对于一个匿名的消息应用来说这是可以接受的(如果这是您想要的话,那么可以跳过这一部分)。为了做到这一点,当我们存储我们的消息时,我们还希望存储发送者的电子邮件。

让我们首先重新审视messages.py的依赖关系。将我们RedisClient中的save_message更新为以下内容:

def save_message(self, email, message): 
    message_id = uuid4().hex 
    payload = { 
        'email': email, 
        'message': message, 
    } 
    self.redis.hmset(message_id, payload) 
    self.redis.pexpire(message_id, MESSAGE_LIFETIME) 

    return message_id 

您会注意到的第一件事是,为了调用save_message,我们现在需要用户的电子邮件。

我们在这里所做的另一件事是将我们在 Redis 中存储的数据格式从字符串更改为哈希。Redis 哈希允许我们将类似字典的对象存储为值。它们还有一个额外的好处,就是可以选择以后从字典中获取哪个键,而不是获取整个对象。

在这里,我们创建了用户电子邮件和密码的字典,并使用hmset将其存储在 Redis 中。hmset没有pxex参数,所以我们调用pexpire,它会在给定的毫秒数后使给定的键过期。还有一个相当于秒的expire

要了解有关 Redis 哈希和其他数据类型的更多信息,请参阅:url.marcuspen.com/redis-data-types

现在我们将更新RedisClient中的get_all_messages方法如下:

def get_all_messages(self): 
    return [ 
        { 
            'id': message_id, 
            'email': self.redis.hget(message_id, 'email'), 
            'message': self.redis.hget(message_id, 'message'), 
            'expires_in': self.redis.pttl(message_id), 
        } 
        for message_id in self.redis.keys() 
    ] 

由于数据已更改为哈希,我们还必须以不同的方式从 Redis 中检索数据,使用hget方法。我们还获取与每条消息对应的电子邮件。

现在我们将继续进行message_service.py。在MessageService中,将save_message RPC 更新为以下内容:

@rpc 
def save_message(self, email, message): 
    message_id = self.message_store.save_message( 
        email, message 
    ) 
    return message_id 

我们所做的只是更新 RPC 的参数,包括email并将其传递给更新后的message_store.save_message

回到我们的web_server.py,我们需要更新MessageAPI的 post 方法,在调用MessageService时发送用户的电子邮件:

def post(self): 
    if not user_authenticated(): 
        return 'Please log in', 401 

    email = session['email'] # ① 
    data = request.get_json(force=True) 

    try: 
        message = data['message'] 
    except KeyError: 
        return 'No message given', 400 

    with ClusterRpcProxy(config) as rpc: 
        rpc.message_service.save_message(email, message) # ② 

    return '', 204 

我们刚刚做了两个小改动:

  1. session对象中获取email

  2. 更新 RPC 以传递email

为了在我们的页面上看到这些更改,我们还需要更新home.html模板。对于我们的 JavaScript 函数updateMessages,将其更新为以下内容:

function updateMessages(messages) { 
  var $messageContainer = $('#messageContainer'); 
  var messageList = []; 
  var emptyMessages = '<p>No messages!</p>'; 

  if (messages.length === 0) { 
    $messageContainer.html(emptyMessages); 
  } else { 
    $.each(messages, function(index, value) { 
      var message = $(value.message).text() || value.message; 
      messageList.push( 
        '<p>' + value.email + ': ' + message + '</p>' 
      ); 
    }); 
    $messageContainer.html(messageList); 
  } 
} 

这只是一个小调整。如果你没注意到,我们已经更新了messageList.push以包括email

在测试之前,请确保您的 Redis 存储为空,因为旧消息将以旧格式存在,这将破坏我们的应用程序。您可以通过在我们的 Redis 容器内使用redis-cli来做到这一点:

$ docker exec -it redis /bin/bash
$ redis-cli -h redis
redis:6379> flushall
OK
redis:6379>

还要确保重新启动我们的消息服务,以使新更改生效。一旦你做到了,我们就可以测试这个新功能:

总结

这就结束了我们对 TempMessenger 用户认证系统的工作。我们从本章开始使用 Python 和 Postgres 数据库,并创建了一个 Nameko 依赖项来封装它。这与上一章的 Redis 依赖项不同,因为数据是永久的,需要更多的规划。尽管如此,我们将这个逻辑隐藏起来,并简单地暴露了两个 RPC:create_userauthenticate_user

然后,我们研究了如何在数据库中安全存储用户密码。我们探讨了一些错误的存储密码的方式,比如以明文存储密码。我们使用 Bcrypt 对我们的密码进行加密哈希,以防止在数据库受到损害时被读取。

当涉及将新的用户服务链接到我们应用程序的其他部分时,我们首先将每个服务拆分为自己的模块,以便我们可以独立部署、更新和管理它们。通过展示如何在 Web 服务器中轻松替换一个框架(Nameko)为另一个框架(Flask),我们获得了微服务架构的一些好处,而不会影响平台的其他部分。

我们探索了 Flask 框架以及如何创建基于函数和基于类的视图。我们还研究了 Flask 会话对象以及如何从一个请求到下一个存储用户数据。

作为奖励,我们修改了消息列表,还包括发送者的电子邮件地址。

我鼓励你考虑为 TempMessenger 制定新的增强功能,并相应地计划如何添加它们,确保我们的依赖逻辑不会泄漏到属于它的服务之外——这是许多人犯的错误!保持我们的服务边界定义清晰是一项艰巨的任务,有时候从更单片的方式开始,等清晰之后再将它们分离出来会有所帮助。这与我们在上一章中对MessageServiceWebServer采取的方法类似。Sam Newman 的《构建微服务》(O'Reilly)很好地解释了这一点,并更详细地介绍了构建分布式系统所涉及的好处、缺点和挑战。

完成了这一章,我希望我已经让你更深入地了解了如何在实践中从微服务架构中受益。我们创建这个应用程序的过程是有意模块化的,不仅反映了微服务的模块化,而且演示了我们应该如何在对平台的影响最小的情况下添加新功能。

第七章:使用 Django 创建在线视频游戏商店

我出生在 70 年代末,这意味着我在视频游戏产业诞生时长大。我的第一款视频游戏主机是 Atari 2600,正是因为这款特定的视频游戏主机,我决定要成为一名程序员并制作视频游戏。然而,我从未在游戏行业找到工作,但我仍然喜欢玩视频游戏,在业余时间里,我尝试开发自己的游戏。

直到今天,我仍然在互联网上四处转悠,尤其是在 eBay 上,购买旧的视频游戏,以重温我美好的童年回忆,当时全家,我的父母和姐姐,都喜欢一起玩 Atari 2600 游戏。

由于我对复古视频游戏很感兴趣,我们将开发一个复古视频游戏在线商店;这将是一个很好的方式来开发有趣的东西,同时也学到很多关于流行的 Django web 框架的网页开发知识。

在本章中,我们将涵盖以下内容:

  • 设置环境

  • 创建一个 Django 项目

  • 创建 Django 应用程序

  • 探索 Django 管理界面

  • 学习如何创建应用程序模型并使用 Django ORM 执行查询

另外,作为额外的内容,我们将使用npmNode Package Manager)来下载客户端依赖项。我们还将介绍如何使用任务运行器 Gulp 创建简单的任务。

为了让我们的应用程序更漂亮,我们将使用 Bootstrap。

所以,让我们开始吧!

设置开发环境

像往常一样,我们将开始为开发设置环境。在第四章中,汇率和货币转换工具,你已经了解了pipenv,所以在本章和接下来的章节中,我们将使用pipenv来创建我们的虚拟环境和管理我们的依赖项。

首先,我们要创建一个目录,用来存放我们的项目。在你的工作目录中,创建一个名为django-project的目录,如下所示:

mkdir django-project && cd django-project

现在我们可以运行pipenv来创建我们的虚拟环境:

pipenv --three

如果你已经在其他位置安装了 Python 3,你可以使用参数--python并指定 Python 可执行文件的路径。如果一切顺利,你应该会看到如下输出:

现在我们可以使用pipenv命令行激活我们的虚拟环境:

pipenv shell

太棒了!我们现在要添加的唯一依赖项是 Django。

在撰写本书时,Django 2.0 已经发布。与之前相比,它有很多很好的功能。你可以在docs.djangoproject.com/en/2.0/releases/2.0/上查看新功能列表。

让我们在我们的虚拟环境中安装 Django:

pipenv install django

Django 2.0 已经停止支持 Python 2.0,所以如果你计划使用 Python 2 开发应用程序,你应该安装 Django 1.11.x 或更低版本。我强烈建议你使用 Python 3 开始一个新项目。Python 2 将在几年后停止维护,并且新的包将为 Python 3 创建。Python 2 的流行包将迁移到 Python 3。

在我看来,Django 2 最好的新功能是新的路由语法,因为现在不需要编写正则表达式。像下面这样写更加清晰和可读:

path('user/<int:id>/', views.get_user_by_id)

以前的语法更多地依赖于正则表达式:

url('^user/?P<id>[0-9]/$', views.get_user_by_id)

这样会简单得多。我在 Django 2.0 中真正喜欢的另一个功能是他们稍微改进了管理 UI,并使其响应式;这是一个很棒的功能,因为我曾经在小手机屏幕上使用非响应式网站时,创建新用户(当你在外出时无法访问桌面)会很痛苦。

安装 Node.js

在 Web 开发方面,几乎不可能远离 Node.js。 Node.js 是一个于 2009 年发布的项目。它是一个 JavaScript 运行时,允许我们在服务器端运行 JavaScript。如果我们使用 Django 和 Python 开发网站,为什么要关心 Node.js 呢?原因是 Node.js 生态系统有几个工具,将帮助我们以简单的方式管理客户端依赖关系。我们将使用其中一个工具,即 npm。

将 npm 视为 JavaScript 世界的pip。然而,npm 有更多功能。我们将使用的功能之一是 npm 脚本。

所以,让我们继续安装 Node.js。通常,开发人员需要转到 Node.js 网站并从那里下载,但我发现使用一个名为 NVM 的工具更简单,它允许我们轻松安装和切换不同版本的 Node.js。

要在我们的环境中安装 NVM,您可以按照github.com/creationix/nvm上的说明进行操作。

我们正在介绍在 Unix/Linux 和 macOS 系统上安装 NVM。如果您使用 Windows,有一个使用 Go 语言开发的 Windows 版本,可以在github.com/coreybutler/nvm-windows找到。

安装 NVM 后,您可以使用以下命令安装最新版本的 Node.js:

nvm install node

您可以使用以下命令验证安装是否正确:

node --version

在编写本书时,最新的 Node.js 版本是 v8.8.1。

您还可以在终端上输入npm,您应该看到类似于以下输出的输出:

创建一个新的 Django 项目

要创建一个新的 Django 项目,请运行以下命令:

django-admin startproject gamestore

请注意,django-admin创建了一个名为gamestore的目录,其中包含一些样板代码。我们将在稍后查看 Django 创建的文件,但首先,我们将创建我们的第一个 Django 应用程序。在 Django 世界中,您有项目和应用程序,根据 Django 文档,项目描述了 Web 应用程序本身,应用程序是一个提供某种功能的 Python 包;这些应用程序包含自己的一组路由、视图、静态文件,并且可以在不同的 Django 项目中重复使用。

如果您完全不理解,不要担心;随着我们的进展,您会学到更多。

说了这么多,让我们创建项目的初始应用程序。运行cd gamestore,一旦进入gamestore目录,执行以下命令:

python-admin startapp main

如果列出gamestore目录的内容,您应该会看到一个名为main的新目录;那是我们将要创建的 Django 应用程序的目录。

在不写任何代码的情况下,您已经拥有一个完全功能的 Web 应用程序。要运行应用程序并查看结果,请运行以下命令:

python manage.py runserver

您应该看到以下输出:

Performing system checks...

System check identified no issues (0 silenced).

You have 14 unapplied migration(s). Your project may not work properly until you apply the migrations for app(s): admin, auth, contenttypes, sessions.
Run 'python manage.py migrate' to apply them.

December 20, 2017 - 09:27:48
Django version 2.0, using settings 'gamestore.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.

打开您喜欢的 Web 浏览器,转到http://127.0.0.1:8000,您将看到以下页面:

当我们第一次启动应用程序时,需要注意的一点是以下警告:

You have 14 unapplied migration(s). Your project may not work properly until you apply the migrations for app(s): admin, auth, contenttypes, sessions.
Run 'python manage.py migrate' to apply them.

这意味着 Django 项目默认注册的应用程序adminauthcontenttypessessions有尚未应用到该项目的迁移(数据库更改)。我们可以使用以下命令运行这些迁移:

➜ python manage.py migrate
Operations to perform:
 Apply all migrations: admin, auth, contenttypes, sessions
Running migrations:
 Applying contenttypes.0001_initial... OK
 Applying auth.0001_initial... OK
 Applying admin.0001_initial... OK
 Applying admin.0002_logentry_remove_auto_add... OK
 Applying contenttypes.0002_remove_content_type_name... OK
 Applying auth.0002_alter_permission_name_max_length... OK
 Applying auth.0003_alter_user_email_max_length... OK
 Applying auth.0004_alter_user_username_opts... OK
 Applying auth.0005_alter_user_last_login_null... OK
 Applying auth.0006_require_contenttypes_0002... OK
 Applying auth.0007_alter_validators_add_error_messages... OK
 Applying auth.0008_alter_user_username_max_length... OK
 Applying auth.0009_alter_user_last_name_max_length... OK
 Applying sessions.0001_initial... OK

在这里,Django 在 SQLite 数据库中创建了所有表,您将在应用程序的root目录中找到 SQLite 数据库文件。

db.sqlite3文件是包含我们应用程序表的数据库文件。选择 SQLite 只是为了使本章的应用程序更简单。Django 支持大量数据库;最受欢迎的数据库,如 Postgres、Oracle,甚至 MSSQL 都受支持。

如果再次运行runserver命令,就不应该有任何迁移警告了:

→ python manage.py runserver
Performing system checks...

System check identified no issues (0 silenced).
December 20, 2017 - 09:50:49
Django version 2.0, using settings 'gamestore.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.

现在我们只需要做一件事来结束这一部分;我们需要创建一个管理员用户,这样我们就可以登录到 Django 管理界面并管理我们的 Web 应用程序。

与 Django 中的其他一切一样,这非常简单。只需运行以下命令:

python manage.py createsuperuser

你将被要求输入用户名和电子邮件,并设置密码,这就是你设置管理员帐户所需要做的一切。

在接下来的部分,我们将更仔细地查看 Django 为我们创建的文件。

探索 Django 项目的结构

如果你看一下 Django 的网站,它说Django:完美主义者的网络框架,有截止日期,我完全同意这个说法。到目前为止,我们还没有写任何代码,我们已经有了一个正在运行的网站。只需几个命令,我们就可以创建一个具有相同目录结构和样板代码的新项目。让我们开始开发。

我们可以设置一个新的数据库并创建一个超级用户,而且,Django 还带有一个非常好用和有用的管理界面,你可以在其中查看我们的数据和用户。

在这一部分,我们将探索 Django 在启动新项目时为我们创建的代码,以便我们熟悉结构。让我们继续添加项目的其他组件。

如果你查看项目的根目录,你会发现一个名为db.sqlite3的文件,另一个名为manage.py的文件,最后,还有一个与项目同名的目录,在我们的例子中是gamestoredb.sqlite3文件,顾名思义,是数据库文件;这个文件是在项目的根文件夹中创建的,因为我们正在使用 SQLite。你可以直接从命令行探索这个文件;我们很快会演示如何做到这一点。

第二个文件是manage.py。这个文件是由django-admin在每个 Django 项目中自动创建的。它基本上做的事情和django-admin一样,再加上两件额外的事情;它会将DJANGO_SETTINGS_MODULE设置为指向项目的设置文件,并将项目的包放在sys.path上。如果你执行manage.py而没有任何参数,你可以看到所有可用命令的帮助。

如你所见,manage.py有许多选项,比如管理密码,创建超级用户,管理数据库,创建和执行数据库迁移,启动新应用和项目,以及一个非常重要的选项runserver,正如其名字所示,它将为你启动 Django 开发服务器。

现在我们已经了解了manage.py以及如何执行它的命令,我们将退一步,学习如何检查我们刚刚创建的数据库。做到这一点的命令是dbshell;让我们试一试:

python manage.py dbshell

深入 SQLite

你应该进入 SQLite3 命令提示符:

SQLite version 3.16.2 2017-01-06 16:32:41
Enter ".help" for usage hints.
sqlite>

如果你想获取数据库的所有表的列表,可以使用命令.tables

sqlite> .tables
auth_group auth_user_user_permissions
auth_group_permissions django_admin_log
auth_permission django_content_type
auth_user django_migrations
auth_user_groups django_session

在这里你可以看到,我们通过migrate命令创建的所有表。

要查看每个表的结构,可以使用命令.schema,我们可以使用选项--indent,这样输出将以更可读的方式显示:

sqlite> .schema --indent auth_group
CREATE TABLE IF NOT EXISTS "auth_group"(
 "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
 "name" varchar(80) NOT NULL UNIQUE
 );

这些是我在使用 SQLite3 数据库时最常用的命令,但命令行界面提供了各种命令。你可以使用.help命令获取所有可用命令的列表。

当创建原型、概念验证项目或者创建非常小的项目时,SQLite3 数据库非常有用。如果我们的项目不属于这些类别中的任何一种,我建议使用其他 SQL 数据库,比如 MySQL、Postgres 和 Oracle。还有非 SQL 数据库,比如 MongoDB。使用 Django,你可以毫无问题地使用这些数据库;如果你使用 Django 的 ORM(对象关系模型),大部分时间你可以在不同的数据库之间切换,应用程序仍然可以完美地工作。

查看项目的包目录

接下来,让我们看看项目的包目录。在那里,你会找到一堆文件。你会看到的第一个文件是settings.py,这是一个非常重要的文件,因为你将在这里放置我们应用程序的所有设置。在这个设置文件中,你可以指定将使用哪些应用程序和数据库,你还可以告诉 Django 在哪里搜索静态文件和模板、中间件等。

然后你有urls.py;这个文件是你指定应用程序可用的 URL 的地方。你可以在项目级别设置 URL,也可以为每个 Django 应用程序设置 URL。如果你检查这个urls.py文件的内容,你不会找到太多细节。基本上,你会看到一些解释如何添加新的 URL 的文本,但 Django 已经定义了(开箱即用)一个 URL 到 Django 管理站点:

  from django.contrib import admin
  from django.urls import path

  urlpatterns = [
      path('admin/', admin.site.urls),
  ]

我们将逐步介绍如何向项目添加新的 URL,但无论如何我们都可以解释这个文件;还记得我提到过在 Django 中可以有不同的应用吗?所以django.contrib.admin也是一个应用,而一个应用有自己的一组 URL、视图、模板。那么它在这里做什么?当我们导入 admin 应用然后定义一个名为urlpatterns的列表时,在这个列表中我们使用一个名为 path 的函数,第一个参数是 URL,第二个参数可以是一个将要执行的视图。但在这种情况下,它传递了admin.site应用的 URL,这意味着admin/将是基本 URL,而admin.site.urls中定义的所有 URL 将在其下创建。

例如,如果在admin.site.url中,我定义了两个 URL,users/groups/,当我有path('admin/', admin.site.urls)时,我实际上将创建两个 URL:

  • admin/users/

  • admin/groups/

最后,我们有wsgi.py,这是 Django 在创建新项目时为我们创建的一个简单的 WSGI 配置。

现在我们对 Django 项目的结构有了一些了解,是时候创建我们项目的第一个应用了。

创建项目的主要应用

在这一部分,我们将创建我们的第一个 Django 应用程序。一个 Django 项目可以包含多个应用程序。将项目拆分为应用程序是一个很好的做法,原因有很多;最明显的是你可以在不同的项目中重用相同的应用程序。将项目拆分为多个应用程序的另一个原因是它强制实现关注点的分离。你的项目将更有组织,更容易理解,我们的同事会感谢你,因为这样维护起来会更容易。

让我们继续运行startapp命令,并且,如前所示,你可以使用django-admin命令或者使用manager.py。由于我们使用django-admin命令创建了项目,现在是一个很好的机会来测试manager.py命令。要创建一个新的 Django 应用程序,请运行以下命令:

python manager.py startapp main

在这里,我们将创建一个名为main的应用程序。不要担心没有显示任何输出,Django 会悄悄地创建项目和应用程序。如果你现在列出目录内容,你会看到一个名为main的目录,而在main目录中你会找到一些文件;我们将在添加更改时解释每个文件。

所以,我们想要做的第一件事是为我们的应用程序添加一个登陆页面。为此,我们需要做三件事:

  • 首先,我们添加一个新的 URL,告诉 Django 当我们网站的用户浏览到根目录时,它应该转到站点/并显示一些内容

  • 第二步是添加一个视图,当用户浏览到站点的根目录/时将执行该视图

  • 最后一步是添加一个包含我们希望向用户显示的内容的 HTML 模板

说到这一点,我们需要在main应用程序目录中包含一个名为urls.py的新文件。首先,我们添加一些导入:

from django.urls import path
from . import views

在前面的代码中,我们从django.urls中导入了 path 函数。path 函数将返回一个要包含在urlpatterns列表中的元素,我们还在同一目录中导入了 views 文件;我们想要导入这个视图,因为我们将在那里定义在访问特定路由时将执行的函数:

  urlpatterns = [
      path(r'', views.index, name='index'),
  ]

然后我们使用 path 函数来定义一个新的路由。函数 path 的第一个参数是一个包含我们希望在应用程序中提供的 URL 模式的字符串。这个模式可能包含尖括号(例如<int:user_id>)来捕获 URL 上传递的参数,但是在这一点上,我们不打算使用它;我们只是想为应用程序的根添加一个 URL,所以我们添加一个空字符串''。第二个参数是将要执行的函数,可选地,您可以添加关键字参数name,它设置 URL 的名称。我们很快就会看到为什么这很有用。

第二部分是在views.py文件中定义名为index的函数,如下所示:

  from django.shortcuts import render

  def index(request):
      return render(request, 'main/index.html', {})

由于此时没有太多事情要做,我们首先从django.shortcuts中导入 render 函数。Django 有自己的模板引擎,内置在框架中,可以将默认模板引擎更改为您喜欢的其他模板引擎(例如 Jinja2,这是 Python 生态系统中最受欢迎的模板引擎之一),但是为了简单起见,我们将使用默认引擎。render函数获取请求对象、模板和上下文对象;后者是一个包含要在模板中显示的数据的对象。

我们需要做的下一件事是添加一个模板,该模板将包含我们希望在用户浏览我们的应用程序时显示的内容。现在,大多数 Web 应用程序的页面包含永远不会改变的部分,例如顶部菜单栏或页面页脚,这些部分可以放入一个单独的模板中,可以被其他模板重用。幸运的是,Django 模板引擎具有这个功能。事实上,我们不仅可以在模板中注入子模板,还可以有一个基本模板,其中包含将在所有页面之间共享的 HTML。说到这一点,我们将在gamestore/templates目录中创建一个名为base.html的文件,其中包含以下内容:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, 
        initial-scale=1">
    <meta name="description" content="">
    <meta name="author" content="">
    <link rel="icon" href="../../favicon.ico">

    <title>Vintage video games store</title>

    {% load staticfiles %}
    <link href="{% static 'styles/site.css' %}" rel='stylesheet'>
    <link href="{% static 'styles/bootstrap.min.css' %}" 
       rel='stylesheet'>
    <link href="{% static 'styles/font-awesome.min.css' %}"
          rel='stylesheet'>
  </head>

  <body>

    <nav class="navbar navbar-inverse navbar-fixed-top">
      <div class="container">
        <div class="navbar-header">
          <button type="button" class="navbar-toggle 
             collapsed" data-toggle="collapse" data-target="#navbar"
             aria-expanded="false" aria-controls="navbar">
            <span class="sr-only">Toggle navigation</span>
            <span class="icon-bar"></span>
            <span class="icon-bar"></span>
            <span class="icon-bar"></span>
          </button>
          <a class="navbar-brand" href="/">Vintage video
         games store</a>
        </div>
        <div id="navbar" class="collapse navbar-collapse">
          <ul class="nav navbar-nav">
            <li>
              <a href="/">
                <i class="fa fa-home" aria-hidden="true"></i> HOME
              </a>
            </li>
            {% if user.is_authenticated%}
            <li>
              <a href="/cart/">
                <i class="fa fa-shopping-cart" 
                   aria-hidden="true"></i> CART
              </a>
            </li>
            {% endif %}
          </ul>          
        </div><!--/.nav-collapse -->
      </div>
    </nav>

    <div class="container">

      <div class="starter-template">
        {% if messages %}
          {% for message in messages %}
            <div class="alert alert-info" role="alert">
              {{message}}
            </div>
          {% endfor %}
        {% endif %}

        {% block 'content' %}
        {% endblock %}
      </div>
    </div>
  </body>
</html>

我们不打算逐个讨论所有 HTML 部分,只讨论 Django 模板引擎的特定语法部分:

  {% load static %}
  <link href="{% static 'styles/site.css' %}" rel='stylesheet'>
  <link href="{% static 'styles/bootstrap.min.css' %}" 
          rel='stylesheet'>
  <link href="{% static 'styles/font-awesome.min.css' %}" 
         rel='stylesheet'>

这里需要注意的第一件事是{% load static %},它将告诉 Django 的模板引擎我们要加载静态模板标签。静态模板标签用于链接静态文件。这些文件可以是图像、JavaScript 或样式表文件。你可能会问,Django 是如何找到这些文件的呢,答案很简单:通过魔法!不,开玩笑;静态模板标签将在settings.py文件中的STATIC_ROOT变量指定的目录中查找文件;在我们的情况下,我们定义了STATIC_ROOT = '/static/',所以当使用标签{% static 'styles/site.css' %}时,链接/static/styles/site.css将被返回。

你可能会想,为什么不只写/static/styles/site.css而不使用标签?这样做的原因是,标签为我们提供了更多的灵活性,以便在需要更新我们提供静态文件的路径时进行更改。想象一种情况,你有一个包含数百个模板的大型应用程序,在所有这些模板中,你都硬编码了/static/,然后决定更改该路径(而且你没有团队)。你需要更改每个文件来执行此更改。如果你使用静态标签,你只需将文件移动到不同的位置,标签就会更改STATIC_ROOT变量在设置文件中的值。

我们在这个模板中使用的另一个标签是block标签:

{% block 'content' %}
{% endblock %}

block标签非常简单;它定义了基本模板中可以被子模板用来在该区域注入内容的区域。当我们创建下一个模板文件时,我们将看到这是如何工作的。

第三部分是添加模板。index函数将呈现存储在main/index.html的模板,这意味着它将留在main/templates/main/目录中。让我们继续创建文件夹main/templates,然后main/templates/main

mkdir main/templates && mkdir main/templates/main

main/templates/main/目录中创建一个名为index.html的文件,内容如下:

{% extends 'base.html' %}

{% block 'content' %}
   <h1>Welcome to the gamestore!</h1>
{% endblock %}

正如你在这里看到的,我们首先扩展了基本模板,这意味着base.html文件的所有内容将被 Django 模板引擎用来构建 HTML,当用户浏览到/时,将提供给浏览器。现在,我们还使用了block标签;在这种情况下,它意味着引擎将在base.html文件中搜索名为'content'的块标签,如果找到,引擎将在'content'块中插入h1 html标签。

这一切都是关于代码的可重用性和可维护性,因为你不需要在我们应用程序的每个单个模板中插入菜单标记和加载 JavaScript 和 CSS 文件的标记;你只需要在基本模板中插入它们并在这里使用block标签。内容会改变。使用基本模板的第二个原因是,再次想象一种情况,你需要改变一些东西——比如我们在base.html文件中定义的顶部菜单,因为菜单只在base.html文件中定义。要执行更改,你只需要在base.html中更改标记,所有其他模板将继承更改。

我们几乎准备好运行我们的代码并查看应用程序目前的外观了,但首先,我们需要安装一些客户端依赖项。

安装客户端依赖项

现在我们已经安装了 NodeJS,我们可以安装项目的客户端依赖项。由于本章的重点是 Django 和 Python,我们不想花太多时间来设计我们的应用程序并浏览庞大的 CSS 文件。然而,我们希望我们的应用程序看起来很棒,因此我们将安装两样东西:Bootstrap 和 Font Awesome。

Bootstrap 是一个非常著名的工具包,已经存在多年了。它有一套非常好的组件、网格系统和插件,将帮助我们使我们的应用程序在用户在桌面上浏览应用程序或者甚至移动设备上浏览应用程序时看起来很棒。

Font Awesome 是另一个存在已久的项目,它是一个字体和图标框架。

要安装这些依赖项,我们可以直接运行 npm 的安装命令。然而,我们要做得更好。类似于pipenv,它为我们的 Python 依赖项创建一个文件,npm也有类似的东西。这个文件叫做package.json,它不仅包含了项目的依赖项,还包含了关于包的脚本和元信息。

让我们继续将package.json文件添加到gamestore/目录中,内容如下:

    {
      "name": "gamestore",
      "version": "1.0.0",
      "description": "Retro game store website",
      "dependencies": {
         "bootstrap": "³.3.7",
        "font-awesome": "⁴.7.0"
      }
    }

太棒了!保存文件,并在终端上运行以下命令:

npm install

如果一切顺利,您应该会看到一条消息,说明已安装了两个软件包。

如果列出gamestore目录的内容,您将看到npm创建了一个名为node_modules的新目录,npm安装了 Bootstrap 和 Font Awesome。

为简单起见,我们将只复制我们需要的 CSS 文件和字体到static文件夹。 但是,在构建应用程序时,我建议使用诸如webpack之类的工具,它将捆绑所有我们的客户端依赖项,并设置webpack开发服务器来为您的 Django 应用程序提供文件。 由于我们想专注于 Python 和 Django,我们可以继续手动复制文件。

首先,我们可以按以下方式创建 CSS 文件的目录:

mkdir static && mkdir static/styles

然后我们需要复制 bootstrap 文件。 首先是最小化的 CSS 文件:

cp node_modules/bootstrap/dist/css/bootstrap.min.css static/styles/

接下来,我们需要复制 Font Awesome 文件,从最小化的 CSS 开始:

cp node_modules/font-awesome/css/font-awesome.min.css static/styles/

和字体:

cp -r node_modules/font-awesome/fonts/ static/

我们将添加另一个 CSS 文件,其中将包含我们可能添加到应用程序中的一些自定义 CSS,以赋予应用程序个性化的外观。 在gamestore/static/styles目录中添加一个名为site.css的文件,内容如下:

  .nav.navbar-nav .fa-home,
  .nav.navbar-nav .fa-shopping-cart {
     font-size: 1.5em;
   }

   .starter-template {
      padding: 70px 15px;
   }

   h2.panel-title {
      font-size: 25px;
   }

我们需要做一些事情来第一次运行我们的应用程序; 首先,我们需要将我们创建的主应用程序添加到gamestore/gamestore目录中的settings.py文件的INSTALLED_APPS列表中。 它应如下所示:

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'main',
]

在同一设置文件中,您将找到列表TEMPLATES

TEMPLATES = [
    {
        'BACKEND': 
 'django.templates.backends.django.DjangoTemplates',
        'DIRS': [],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.templates.context_processors.debug',
                'django.templates.context_processors.request',
                'django.contrib.auth.context_processors.auth',

 'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

DIRS的值是一个空列表。 我们需要将其更改为:

'DIRS': [
    os.path.join(BASE_DIR, 'templates')
]

这将告诉 Django 在templates目录中搜索模板。

然后,在settings.py文件的末尾添加以下行:

STATICFILES_DIRS = [os.path.join(BASE_DIR, 'static'), ]

这将告诉 Django 在gamestore/static目录中搜索静态文件。

现在我们需要告诉 Django 注册我们在main应用程序中定义的 URL。 因此,让我们继续打开gamestore/gamestore目录中的文件urls.py。 我们需要在urlpatterns列表中包含"main.urls"。 更改后,urls.py文件应如下所示:

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('main.urls'))
]

请注意,我们还需要导入django.urls模块的include函数。

太好了! 现在我们已经准备好使用我们的应用程序中的所有客户端依赖项,并且可以第一次启动应用程序以查看我们迄今为止实施的更改。 打开终端,并使用runserver命令启动 Django 的开发服务器,如下所示:

python manage.py runserver

浏览到http://localhost:8000; 您应该看到一个页面,类似于以下截图所示的页面:

添加登录和注销视图

每个在线商店都需要某种用户管理。 我们应用的用户应该能够创建帐户,更改其帐户详细信息,显然登录到我们的应用程序,以便他们可以下订单,还可以从应用程序注销。

我们将开始添加登录和注销功能。 好消息是,在 Django 中实现这一点非常容易。

首先,我们需要在我们的登录页面上添加一个 Django 表单。 Django 有一个内置的身份验证表单; 但是,我们想要自定义它,所以我们将创建另一个类,该类继承自 Django 内置的AuthenticationForm并添加我们的更改。

gamestore/main/中创建一个名为forms.py的文件,内容如下:

from django import forms
from django.contrib.auth.forms import AuthenticationForm

class AuthenticationForm(AuthenticationForm):
    username = forms.CharField(
        max_length=50,
        widget=forms.TextInput({
            'class': 'form-control',
            'placeholder': 'User name'
  })
    )

    password = forms.CharField(
        label="Password",
        widget=forms.PasswordInput({
            'class': 'form-control',
            'placeholder': 'Password'
  })
    )

这个类非常简单。 首先,我们从django模块导入forms和从django.contrib.auth.forms导入AuthenticationForm,然后我们创建另一个类,也称为AuthenticationForm,它继承自 Django 的AuthenticationForm。 然后我们定义两个属性,用户名和密码。 我们将用户名定义为CharField的一个实例,并在其构造函数中传递一些关键字参数。 它们是:

  • max_length,顾名思义,限制字符串的大小为50个字符。

  • 我们还使用了widget参数,指定了如何在页面上呈现此属性。在这种情况下,我们希望将其呈现为输入文本元素,因此我们传递了一个TextInput实例。可以向widget传递一些选项;在我们的情况下,这里我们传递了'class',这是 CSS 类和占位符。

当模板引擎在页面上呈现此属性时,所有这些选项都将被使用。

我们在这里定义的第二个属性是密码。我们还将其定义为CharField,而不是传递max_length,这次我们将标签设置为'Password'。我们将widget设置为PasswordInput,这样模板引擎将在页面上将字段呈现为类型等于密码的输入,并且最后,我们为此字段类和占位符定义了相同的设置。

现在我们可以开始注册新的登录和注销 URL。打开文件gamestore/main/urls.py。首先,我们将添加一些import语句:

from django.contrib.auth.views import login
from django.contrib.auth.views import logout
from .forms import AuthenticationForm

import语句之后,我们可以开始注册身份验证 URL。在urlpattens列表的末尾,添加以下代码:

  path(r'accounts/login/', login, {
      'template_name': 'login.html',
      'authentication_form': AuthenticationForm
  }, name='login'),

因此,在这里我们创建了一个新的 URL,'accounts/login',当请求这个 URL 时,视图函数login将被执行。路径函数的第三个参数是一个带有一些选项的字典,template_name指定了浏览到底层 URL 时将呈现在页面上的模板。我们还使用AuthenticationForm值定义了authetication_form。最后,我们将关键字参数name设置为login;为这个 URL 命名在需要创建此 URL 的链接时非常有帮助,也提高了可维护性,因为 URL 本身的更改不会要求模板的更改,因为模板通过名称引用 URL。

现在登录已经就位,让我们添加注销 URL:

  path(r'accounts/logout/', logout, {
      'next_page': '/'
  }, name='logout'),

与登录 URL 类似,在注销 URL 中,我们使用路径函数首先传递 URL 本身(accounts/logout);我们传递了从 Django 内置认证视图中导入的函数 logout,并且作为一个选项,我们将next_page设置为/。这意味着当用户注销时,我们将用户重定向到应用程序的根页面。最后,我们还将 URL 命名为 logout。

很好。现在是时候添加模板了。我们要添加的第一个模板是登录模板。在gamestore/templates/下创建一个名为login.html的文件,内容如下:

{% extends 'base.html' %}

{% block 'content' %}

<div>
  <form action="." method="post" class="form-signin">

    {% csrf_token %}

    <h2 class="form-signin-heading">Login</h2>
    <label for="inputUsername" class="sr-only">User name</label>
    {{form.username}}
    <label for="inputPassword" class="sr-only">Password</label>
    {{form.password}}
    <input class="btn btn-lg btn-primary btn-block" 
        type="Submit" value="Login">
  </form>
  <div class='signin-errors-container'>
    {% if form.non_field_errors %}
    <ul class='form-errors'>
      {% for error in form.non_field_errors %}
        <li>{{ error }}</li>
      {% endfor %}
    </ul>
    {% endif %}
  </div>
</div>

{% endblock %}

在这个模板中,我们还扩展了基本模板,并且我们添加了登录模板的内容,其中包含在基本模板中定义的内容块。

首先,我们创建一个form标签,并将方法设置为POST。然后,我们添加csrf_token标签。我们添加此标签的原因是为了防止跨站点请求攻击,其中恶意站点代表当前登录用户执行请求到我们的站点。

如果您想了解更多关于这种类型的攻击,您可以访问网站www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)

在跨站点请求伪造标记之后,我们添加了我们需要的两个字段:用户名和密码。

然后我们有以下标记:

  <div class='signin-errors-container'>
    {% if form.non_field_errors %}
    <ul class='form-errors'>
      {% for error in form.non_field_errors %}
      <li>{{ error }}</li>
      {% endfor %}
    </ul>
    {% endif %}
  </div>

这是我们将显示可能的身份验证错误的地方。表单对象有一个名为non_field_error的属性,其中包含与字段验证无关的错误。例如,如果您的用户输入了错误的用户名或密码,那么错误将被添加到non_field_error列表中。

我们创建一个ul元素(无序列表)并循环遍历non_field_errors列表,添加带有错误文本的li元素(列表项)。

我们现在已经放置了登录,并且只需要将其包含到页面中-更具体地说,是到base.html模板。但是,首先,我们需要创建一个小的部分模板,它将在页面上显示登录和注销链接。继续添加一个名为_loginpartial.html的文件到gamestore/templates目录,其中包含以下内容:

  {% if user.is_authenticated %}
  <form id="logoutForm" action="{% url 'logout' %}" method="post"
       class="navbar-right">
      {% csrf_token %}
    <ul class="nav navbar-nav navbar-right">
      <li><span class="navbar-brand">Logged as: 
            {{ user.username }}</span></li>
      <li><a href="javascript:document.getElementById('
           logoutForm').submit()">Log off</a></li>
    </ul>

  </form>

  {% else %}

  <ul class="nav navbar-nav navbar-right">
      <li><a href="{% url 'login' %}">Log in</a></li>
  </ul>

  {% endif %}

这个部分模板将根据用户是否经过身份验证而呈现两种不同的内容。如果用户已经过身份验证,它将呈现注销表单。请注意,表单的操作使用了命名 URL;我们没有将其设置为/accounts/logout,而是设置为{% url 'logout' %}。Django 的 URL 标记将使用 URL 名称替换 URL。同样,我们需要添加csrf_token标记以防止跨站点请求伪造攻击,最后,我们定义了一个无序列表,其中有两个项目;第一项将显示文本Logged as:和用户的用户名,列表中的第二项将显示注销按钮。

请注意,我们在列表项元素中添加了一个锚标签,并且href属性中有一些 JavaScript 代码。该代码非常简单;它使用getElementById函数获取表单,然后调用表单的提交函数将请求提交到服务器的/accounts/logout

这只是对实现的偏好;您可以轻松地跳过此 JavaScript 代码并添加提交按钮。它会产生相同的效果。

如果用户未经过身份验证,我们只显示登录链接。登录链接还使用 URL 标记,该标记将使用 URL 替换名称login

太棒了!让我们将登录部分模板添加到基本模板中。打开gamestore/templates中的base.html文件,并找到无序列表,如下所示:

  <ul class="nav navbar-nav">
    <li>
      <a href="/">
        <i class="fa fa-home" aria-hidden="true"></i> HOME
      </a>
    </li>    
  </ul>

我们将使用include标签添加_loginpartial.html模板:

  {% include '_loginpartial.html' %}

include 标签将在标记中的此位置注入_loginpartial.html模板的内容。

最后一步是添加一些样式,使登录页面看起来像应用程序的其余部分一样好看。打开gamestore/static/styles目录中的site.css文件,并包含以下内容:

    /* Signin page */
    /* Styling extracted from http://getbootstrap.com/examples/signin/  
    */

    .form-signin {
        max-width: 330px;
        padding: 15px;
        margin: 0 auto;
    }

    .form-signin input[type="email"] {
        margin-bottom: -1px;
    }

    .form-signin input[type="email"] border-top {
        left-radius: 0;
       right-radius: 0;
    }

    .form-signin input[type="password"] {
        margin-bottom: 10px;
    }

    .form-signin input[type="password"] border-top {
        left-radius: 0;
        right-radius: 0;
    }

    .form-signin .form-signin-heading {
      margin-bottom: 10px;
    }

    .form-signin .checkbox {
      font-weight: normal;
    }

    .form-signin .form-control {
      position: relative;
      height: auto;
      -webkit-box-sizing: border-box;
      -moz-box-sizing: border-box;
      box-sizing: border-box;
      padding: 10px;
      font-size: 16px;
    }

    .form-signin .form-control:focus {
      z-index: 2;
    }

    .signin-errors-container .form-errors {
      padding: 0;
      display: flex;
      flex-direction: column;
      list-style: none;
      align-items: center;
      color: red;
    }

    .signin-errors-container .form-errors li {
      max-width: 350px;
     }

测试登录/注销表单

在尝试此操作之前,让我们打开gamestore/gamestore目录中的settings.py文件,并在文件末尾添加以下设置:

LOGIN_REDIRECT_URL = '/'

这将告诉 Django,在登录后,用户将被重定向到/

现在我们准备测试登录和注销功能,尽管您可能在数据库中没有任何用户。但是,我们在设置 Django 项目时创建了超级用户,所以继续尝试使用该用户登录。运行命令runserver再次启动 Django 开发服务器:

python manage.py runserver

浏览到http://localhost:8000,请注意您现在在页面的右上角有登录链接:

如果您点击,您将被重定向到/accounts/login,并且将呈现我们创建的登录页面模板:

首先,尝试输入错误的密码或用户名,以便我们可以验证错误消息是否正确显示:

太棒了!它有效!

现在使用超级用户登录,如果一切正常,您应该被重定向到应用程序根 URL。它说,以您的用户名登录,然后就会有一个注销链接。试一试,点击注销链接:

创建新用户

现在我们能够登录和注销我们的应用程序,我们需要添加另一个页面,以便用户可以在我们的应用程序上创建帐户并下订单。

在创建新帐户时,我们希望强制执行一些规则。规则是:

  • 用户名字段是必需的,并且必须对我们的应用程序是唯一的

  • 邮箱字段是必需的,并且必须在我们的应用程序中是唯一的

  • 名字和姓氏都是必需的

  • 两个密码字段都是必需的,并且它们必须匹配

如果这些规则中有任何一个没有被遵循,我们将不会创建用户账户,并且应该向用户返回一个错误。

说到这里,让我们添加一个小的辅助函数,用于验证字段是否具有数据库中已存在的值。打开gamestore/main目录下的forms.py文件。首先,我们需要导入 User 模型:

from django.contrib.auth.models import User

然后,添加validate_unique_user函数:

def validate_unique_user(error_message, **criteria):
    existent_user = User.objects.filter(**criteria)

    if existent_user:
        raise forms.ValidationError(error_message)

这个函数获取一个错误消息和关键字参数,这些参数将被用作搜索与特定值匹配的项目的条件。我们创建一个名为existent_user的变量,并通过传递条件来过滤用户模型。如果变量existent_user的值与None不同,这意味着我们找到了一个符合我们条件的用户。然后,我们使用传递给函数的错误消息引发一个ValidationError异常。

很好。现在我们可以开始添加一个包含用户在创建账户时需要填写的所有字段的表单。在gamestore/main目录下的forms.py文件中,添加以下类:

class SignupForm(forms.Form):
    username = forms.CharField(
       max_length=10,
       widget=forms.TextInput({
           'class': 'form-control',
           'placeholder': 'First name'
  })
    )

    first_name = forms.CharField(
        max_length=100,
        widget=forms.TextInput({
            'class': 'form-control',
            'placeholder': 'First name'
  })
    )

    last_name = forms.CharField(
        max_length=200,
        widget=forms.TextInput({
            'class': 'form-control',
            'placeholder': 'Last name'
  })
    )

    email = forms.CharField(
        max_length=200,
        widget=forms.TextInput({
            'class': 'form-control',
            'placeholder': 'Email'
  })
    )

    password = forms.CharField(
        min_length=6,
        max_length=10,
        widget=forms.PasswordInput({
           'class': 'form-control',
           'placeholder': 'Password'
  })
    )

    repeat_password = forms.CharField(
        min_length=6,
        max_length=10,
        widget=forms.PasswordInput({
            'class': 'form-control',
            'placeholder': 'Repeat password'
  })
    )

因此,我们首先创建一个名为SignupForm的类,它将继承自Form,我们为创建新账户所需的每个字段定义一个属性,然后添加一个用户名、名字和姓氏、一个电子邮件,然后两个密码字段。请注意,在密码字段中,我们将密码的最小和最大长度分别设置为610

在同一个类SignupForm中,让我们添加一个名为clean_username的方法:

  def clean_username(self):
      username = self.cleaned_data['username']

      validate_unique_user(
         error_message='* Username already in use',
          username=username)

      return username

这个方法的名称中的前缀clean将使 Django 在解析字段的发布数据时自动调用此方法;在这种情况下,它将在解析字段用户名时执行。

所以,我们获取用户名的值,然后调用validate_unique_user方法,传递一个默认的错误消息和一个关键字参数用户名,这将被用作过滤条件。

我们需要验证唯一性的另一个字段是电子邮件 ID,因此让我们实现clean_email方法,如下所示:

  def clean_email(self):
      email = self.cleaned_data['email']

      validate_unique_user(
         error_message='* Email already in use',
         email=email)

      return email

这基本上与清理用户名相同。首先,我们从请求中获取电子邮件并将其传递给validate_unique_user函数。第一个参数是错误消息,第二个参数是将用作过滤条件的电子邮件。

我们为创建账户页面定义的另一个规则是密码和(重复)密码字段必须匹配,否则将向用户显示错误。因此,让我们添加相同的并实现clean方法,但这次我们要验证repeat_password字段而不是password。这样做的原因是,如果我们实现一个clean_password函数,在那时repeat_passwordcleaned_data字典中还不可用,因为数据的解析顺序与它们在类中定义的顺序相同。因此,为了确保我们将有两个值,我们实现clean_repeat_password

    def clean_repeat_password(self):
      password1 = self.cleaned_data['password']
      password2 = self.cleaned_data['repeat_password']

      if password1 != password2:
         raise forms.ValidationError('* Passwords did not match')

     return password1

很好。所以这里我们首先定义了两个变量;password1,它是password字段的请求值,password2,它是repeat_password字段的请求值。之后,我们只是比较这些值是否不同;如果它们不同,我们引发一个ValidationError异常,其中包含错误消息,通知用户密码不匹配,账户将不会被创建。

创建用户创建的视图

有了表单和验证,我们现在可以添加处理创建新账户请求的视图。打开gamestore/main目录下的views.py文件,并首先添加一些import语句:

from django.views.decorators.csrf import csrf_protect
from .forms import SignupForm
from django.contrib.auth.models import User

因为我们将收到来自POST请求的数据,所以最好添加跨站点请求伪造检查,因此我们需要导入csrf_protect装饰器。

我们还导入了刚刚创建的SignupForm,这样我们就可以将其传递给视图或用它来解析请求数据。最后,我们导入了User模型。

所以,让我们创建signup函数:

@csrf_protect def signup(request):

    if request.method == 'POST':

        form = SignupForm(request.POST)

        if form.is_valid():
            user = User.objects.create_user(
                username=form.cleaned_data['username'],
                first_name=form.cleaned_data['first_name'],
                last_name=form.cleaned_data['last_name'],
                email=form.cleaned_data['email'],
                password=form.cleaned_data['password']
            )
            user.save()

            return render(request, 
 'main/create_account_success.html', {})

    else:
        form = SignupForm()

 return render(request, 'main/signup.html', {'form': form})

我们首先用csrf_protect装饰器装饰signup函数。函数首先检查请求的 HTTP 方法是否等于POST;在这种情况下,它将创建一个SignupForm的实例,将POST数据作为参数传递。然后我们在表单上调用is_valid()函数,如果表单有效,它将返回 true;否则返回 false。如果表单有效,我们将创建一个新用户并调用save函数,最后我们渲染create_account_success.html

如果请求的HTTP方法是GET,我们所做的唯一事情就是创建一个没有参数的SignupForm实例。之后,我们调用render函数,将request对象作为第一个参数传递,然后是我们要渲染的模板,最后一个参数是SignupForm的实例。

我们将很快创建这个函数中引用的两个模板,但首先,我们需要在gamestore/mainurl.py文件中创建一个新的 URL:

path(r'accounts/signup/', views.signup, name='signup'),

这个新的 URL 可以直接添加到urlpatterns列表的末尾。

我们还需要创建模板。我们从signup模板开始;在gamestore/main/templates/main中创建一个名为signup.html的文件,内容如下:

{% extends "base.html" %}

{% block "content" %}

    <div class="account-details-container">
        <h1>Signup</h1>

        <form action="{% url 'signup' %}" method="POST">
          {% csrf_token %}

          {{ form }}

          <button class="btn btn-primary">Save</button>

        </form>
    </div>

{% endblock %}

这个模板与我们之前创建的模板非常相似,它扩展了基本模板并向基本模板的内容块注入了一些数据。我们添加了一个带有标题文本的h1标签和一个动作设置为{% url 'signup' %}的表单,url标签将其更改为/accounts/signup,并将方法设置为POST

和通常一样,在表单中,我们使用csrf_token标签,它将与views文件中的signup函数中的@csrf_protect装饰器一起工作,以防止跨站请求伪造。

然后我们只需调用{{ form }},它将在这个区域中渲染整个表单,然后在字段后面添加一个提交表单的按钮。

最后,我们创建一个模板,用于显示账户已成功创建的信息。在gamestore/main/templates/main目录下添加一个名为create_account_success.html的文件,内容如下:

{% extends 'base.html' %}

{% block 'content' %}

    <div class='create-account-msg-container'>

        <div class='circle'>
          <i class="fa fa-thumbs-o-up" aria-hidden="true"></i>
        </div>

        <h3>Your account have been successfully created!</h3>

        <a href="{% url 'login' %}">Click here to login</a>

    </div>

{% endblock %}

太棒了!为了使它看起来更好,我们将在gamestore/static目录中的site.css文件中包含一些 CSS 代码。在文件末尾添加如下内容:

/* Account created page */
.create-account-msg-container {
    display: flex;
    flex-direction: column;
    align-items: center;
    margin-top: 100px;
}

.create-account-msg-container .circle {
    width: 200px;
    height: 200px;
    border: solid 3px;
    display: flex;
    flex-direction: column;
    align-items: center;
    padding-top: 30px;
    border-radius: 50%;
}

.create-account-msg-container .fa-thumbs-o-up {
    font-size: 9em;
}

.create-account-msg-container a {
    font-size: 1.5em;
}

/* Sign up page */

.account-details-container #id_password,
.account-details-container #id_repeat_password {
    width:200px;
}

.account-details-container {
    max-width: 400px;
    padding: 15px;
    margin: 0 auto;
}

.account-details-container .btn.btn-primary {
    margin-top:20px;
}

.account-details-container label {
    margin-top: 20px;
}

.account-details-container .errorlist {
    padding-left: 10px;
    display: inline-block;
    list-style: none;
    color: red;
}

这就是创建用户页面的全部内容;让我们试试吧!再次启动 Django 开发服务器,并浏览到http://localhost:8000/accounts/signup,您应该会看到创建用户表单,如下所示:

填写所有字段后,您应该被重定向到一个确认页面,如下所示:

自己进行一些测试!尝试添加无效的密码,以验证我们实现的验证是否正常工作。

创建游戏数据模型

好了,我们可以登录到我们的应用程序,我们可以创建新用户,我们还添加了前台模板,目前是空白的,但我们将解决这个问题。我们已经到了本章的核心;我们将开始添加代表商店中可以购买的物品的模型。

我们将在网站上拥有的游戏模型的要求是:

  • 商店将销售不同游戏平台的游戏

  • 首页将有一个列出精选游戏的部分

  • 商店的用户应该能够转到游戏详情页面并查看有关游戏的更多信息

  • 游戏应该可以通过不同的标准进行发现,例如开发者、发布商、发布日期等。

  • 商店的管理员应该能够使用 Django 管理界面更改产品详情。

  • 产品的图片可以更改,如果找不到,应该显示默认图片

话虽如此,让我们开始添加我们的第一个模型类。在gamestore/main/中打开文件models.py,并添加以下代码:

class GamePlatform(models.Model):
    name = models.CharField(max_length=100)

    def __str__(self):
        return self.name

在这里,我们添加了GamePlatform类,它将代表商店中可用的游戏平台。这个类非常简单;我们只需创建一个从Model类继承的类,并且我们只定义了一个名为name的属性。name属性被定义为最大长度为 100 个字符的CharField。Django 提供了各种各样的数据类型;你可以在docs.djangoproject.com/en/2.0/ref/models/fields/上看到完整的列表。

然后我们重写了__str__方法。这个方法将决定GamePlatform的实例在被打印出来时如何显示。我重写这个方法的原因是我想在 Django 管理界面的GamePlatform列表中显示GamePlatform的名称。

我们要添加的第二个模型类是Game模型。在同一个文件中,添加以下代码:

class Game(models.Model):
    class Meta:
        ordering = ['-promoted', 'name']

    name = models.CharField(max_length=100)

    release_year = models.IntegerField(null=True)

    developer = models.CharField(max_length=100)

    published_by = models.CharField(max_length=100)

    image = models.ImageField(
        upload_to='images/',
  default='images/placeholder.png',
  max_length=100
    )

    gameplatform = models.ForeignKey(GamePlatform,
                                     null=False,
                                     on_delete=models.CASCADE)

    highlighted = models.BooleanField(default=False)

与我们之前创建的模型类一样,Game类也继承自Model,我们根据规格定义了所有需要的字段。这里有一些新的需要注意的地方;release_year属性被定义为整数字段,并且我们设置了null=True属性,这意味着这个字段不是必需的。

另一个使用不同类型的属性是图片属性,它被定义为ImageField,这将允许我们为应用程序的管理员提供更改游戏图片的可能性。这种类型继承自FileField,在 Django 管理界面中,该字段将被呈现为文件选择器。ImageFile参数upload_to指定了图片将被存储的位置,默认是游戏没有图片时将呈现的默认图片。我们在这里指定的最后一个参数是max_length,这是图片路径的最大长度。

然后,我们定义了一个ForeignKey。如果你不知道它是什么,外键基本上是一个标识另一个表中行的字段。在我们的例子中,这里我们希望游戏平台与多个游戏相关联。我们传递给主键定义的一些关键字参数;首先我们传递了外键类型,null参数设置为False,这意味着这个字段是必需的,最后我们将删除规则设置为CASCADE,所以如果应用程序的管理员删除了一个游戏平台,该操作将级联并删除与该特定游戏平台相关联的所有游戏。

我们定义的最后一个属性是highlighted属性。你还记得我们的一个要求是能够突出一些产品,并且让它们出现在更显眼的区域,以便用户能够轻松找到它们吗?这个属性就是做这个的。它是一个布尔类型的属性,其默认值设置为False

另一个细节,我留到最后的是:你有没有注意到我们的模型类里有一个名为Meta的类?这是我们可以添加关于模型的元信息的方式。在这个例子中,我们设置了一个名为ordering的属性,其值是一个字符串数组,其中每个项代表Game模型的一个属性,所以我们首先有-highlighted,横杠符号表示降序排列,然后我们还有名称,它将以升序排列出现。

让我们继续向类中添加更多代码:

    objects = GameManager()

    def __str__(self):
      return f'{self.gameplatform.name} - {self.name}'

在这里,我们有两件事。首先,我们分配了一个名为GameManager的类的实例,我稍后会详细介绍,我们还定义了特殊方法__str__,它定义了当打印Game对象的实例时,它将显示游戏平台和一个符号破折号,后跟名称本身的名称。

Game类的定义之前,让我们添加另一个名为GameManager的类:

class GameManager(models.Manager):

    def get_highlighted(self):
        return self.filter(highlighted=True)

    def get_not_highlighted(self):
        return self.filter(highlighted=False)

    def get_by_platform(self, platform):
        return self.filter(gameplatform__name__iexact=platform)

在我们深入了解这个实现的细节之前,我只想说几句关于 Django 中的Manager对象。Manager是 Django 中数据库和模型类之间的接口。默认情况下,每个模型类都有一个Manager,可以通过属性对象访问,那么为什么要定义自己的 manager 呢?我为这个models类实现了一个Manager的原因是我想把所有关于数据库操作的代码都留在模型内部,因为这样可以使代码更清晰、更易于测试。

所以,在这里我定义了另一个类GameManager,它继承自Manager,到目前为止我们定义了三个方法——get_highlighted,它获取所有标记为True的游戏,get_not_highlighted,它获取所有标记为False的游戏,get_by_platform,它获取给定游戏平台的所有游戏。

关于这个类中的前两个方法:我本可以只使用过滤函数并传递一个参数,其中highlighted等于TrueFalse,但正如我之前提到的,将所有这些方法放在管理器内部会更清晰。

现在我们准备创建数据库。在终端中运行以下命令:

python manage.py makemigrations

这个命令将创建一个包含我们刚刚在模型中实现的更改的迁移文件。当创建迁移时,我们可以运行migrate命令,然后将更改应用到数据库:

python manage.py migrate

太棒了!接下来,我们将创建一个模型来存储游戏的价格。

创建价格列表数据模型

我们希望在我们的应用程序中拥有的另一个功能是能够更改产品的价格,以及知道价格是何时添加的,最重要的是,它是何时最近更新的。为了实现这一点,我们将在models.py文件中的gamestore/main/目录中创建另一个模型类,称为PriceList,使用以下代码:

class PriceList(models.Model):
    added_at = models.DateTimeField(auto_now_add=True)

    last_updated = models.DateTimeField(auto_now=True)

    price_per_unit = models.DecimalField(max_digits=9,
                                         decimal_places=2, 
 default=0)

    game = models.OneToOneField(
        Game,
        on_delete=models.CASCADE,
        primary_key=True)

    def __str__(self):
        return self.game.name

正如你在这里看到的,你有两个日期时间字段。第一个是added_at,它有一个属性auto_now_add等于True。它的作用是让 Django 在我们将这个价格添加到表中时自动添加当前日期。last_update字段是用另一个参数定义的,auto_now等于True;这告诉 Django 在每次更新发生时设置当前日期。

然后,我们有一个名为price_per_unit的价格字段,它被定义为一个最大为9位数和2位小数的DecimalField。这个字段不是必需的,它将始终默认为0

接下来,我们创建一个OneToOneField来创建PriceListGame对象之间的链接。我们定义当游戏被删除时,PriceList表中的相关行也将被删除,并将此字段定义为主键。

最后,我们重写__str__方法,使其返回游戏的名称。这在使用 Django 管理界面更新价格时会很有帮助。

现在我们可以再次生成迁移文件:

python manage.py makemigrations

使用以下命令应用更改:

python manage.py migrate

太棒了!现在我们准备开始添加视图和模板,以在页面上显示我们的游戏。

创建游戏列表和详细页面

创建了游戏和价格的模型之后,我们已经到达了本节的有趣部分,即创建将在页面上显示游戏的视图和模板。让我们开始吧!

所以,我们在main/templates/main中创建了一个名为index.html的模板,但我们没有在上面显示任何内容。为了使该页面更有趣,我们将添加两件事:

  1. 页面顶部的一个部分,将显示我们想要突出显示的游戏。它可以是新到店的游戏,非常受欢迎的游戏,或者某个目前价格很好的游戏。

  2. 在突出显示游戏的部分之后,我们将列出所有其他游戏。

我们要添加的第一个模板是一个部分视图,用于列出游戏。这个部分视图将被共享到我们想要显示游戏列表的所有模板中。这个部分视图将接收两个参数:gameslisthighlight_games。让我们继续添加一个名为 games-list.html 的文件,放在 gamestore/main/templates/main/ 中,内容如下:

{% load staticfiles %}
{% load humanize %}

<div class='game-container'>
    {% for game in gameslist %}
    {% if game.highlighted and highlight_games %}
      <div class='item-box highlighted'>
    {% else %}
      <div class='item-box'>
    {% endif %}
      <div class='item-image'>
      <img src="{% static game.image.url %}"></img>
    </div>
      <div class='item-info'>
        <h3>{{game.name}}</h3>
        <p>Release year: {{game.release_year}}</p>
        <p>Developer: {{game.developer}}</p>
        <p>Publisher: {{game.published_by}}</p>
        {% if game.pricelist.price_per_unit %}
          <p class='price'>
            Price: 
          ${{game.pricelist.price_per_unit|floatformat:2|intcomma}}
          </p>
        {% else %}
        <p class='price'>Price: Not available</p>
        {% endif %}
      </div>
     <a href="/cart/add/{{game.id}}" class="add-to-cart btn
 btn-primary">
       <i class="fa fa-shopping-cart" aria-hidden="true"></i>
       Add to cart
     </a>
   </div>
   {% endfor %}
</div>

这里需要注意的一点是,我们在页面顶部添加了 {% load humanize %};这是 Django 框架内置的一组模板过滤器,我们将使用它们来正确格式化游戏价格。为了使用这些过滤器,我们需要编辑 gamestore/gamestore 目录中的 settings.py 文件,并将 django.contrib.humanize 添加到 INSTALLED_APPS 设置中。

这段代码将创建一个容器,其中包含游戏图片、详细信息和一个添加到购物车的按钮,类似于以下内容:

现在我们想要修改 gamestore/main/templates/main 下的 index.html。我们可以用以下代码替换 index.html 文件的整个内容:

{% extends 'base.html' %}

{% block 'content' %}
  {% if highlighted_games_list %}
    <div class='panel panel-success'>
      <div class='panel-heading'>
        <h2 class='panel-title'><i class="fa fa-gamepad"
  aria-hidden="true"></i>Highlighted games</h2>
      </div>
      <div class='panel-body'>
        {% include 'main/games-list.html' with 
         gameslist=highlighted_games_list highlight_games=False%}
        {% if show_more_link_highlighted %}
        <p>
          <a href='/games-list/highlighted/'>See more items</a>
        </p>
        {% endif %}
      </div>
    </div>
  {% endif %}

  {% if games_list %}
    {% include 'main/games-list.html' with gameslist=games_list 
     highlight_games=False%}
    {% if show_more_link_games %}
      <p>
        <a href='/games-list/all/'>See all items</a>
      </p>
    {% endif %}
  {% endif %}

{% endblock %}

太棒了!有趣的代码是:

   {% include 'main/games-list.html' with 
     gameslist=highlighted_games_list 
       highlight_games=False%}

正如你所看到的,我们正在包含部分视图并传递两个参数:gameslisthighlight_gamesgameslist 显然是我们希望部分视图渲染的游戏列表,而 highlight_games 将在我们想要以不同颜色显示推广游戏时使用,以便它们可以很容易地被识别出来。在首页,highlight_games 参数没有被使用,但是当我们创建一个视图来列出所有游戏,不管它是否被推广,改变推广游戏的颜色可能会很有趣。

在推广游戏部分下面,我们有一个列出未推广游戏的部分,它也使用了部分视图 games-list.html

前端的最后一步是包含相关的 CSS 代码,所以让我们编辑 gamestore/static/styles/ 下的 site.css 文件,并添加以下代码:

.game-container {
    margin-top: 10px;
    display:flex;
    flex-direction: row;
    flex-wrap: wrap;
}

.game-container .item-box {
    flex-grow: 0;
    align-self: auto;
    width:339px;
    margin: 0px 10px 20px 10px;
    border: 1px solid #aba5a5;
    padding: 10px;
    background-color: #F0F0F0;
}

.game-container .item-box .add-to-cart {
    margin-top: 15px;
    float: right;
}

.game-container .item-box.highlighted {
    background-color:#d7e7f5;
}

.game-container .item-box .item-image {
    float: left;
}

.game-container .item-box .item-info {
    float: left;
    margin-left: 15px;
    width:100%;
    max-width:170px;
}

.game-container .item-box .item-info p {
    margin: 0 0 3px;
}

.game-container .item-box .item-info p.price {
    font-weight: bold;
    margin-top: 20px;
    text-transform: uppercase;
    font-size: 0.9em;
}

.game-container .item-box .item-info h3 {
    max-width: 150px;
    word-wrap: break-word;
    margin: 0px 0px 10px 0px;
}

现在我们需要修改 index 视图,所以编辑 gamestore/main/ 中的 views.py 文件,并对 index 函数进行以下更改:

def index(request):
    max_highlighted_games = 3
  max_game_list = 9    highlighted_games_list = Game.objects.get_highlighted()
    games_list = Game.objects.get_not_highlighted()

    show_more_link_promoted = highlighted_games_list.count() > 
    max_highlighted_games
    show_more_link_games = games_list.count() > max_game_list

    context = {
        'highlighted_games_list': 
         highlighted_games_list[:max_highlighted_games],
        'games_list': games_list[:max_game_list],
        'show_more_link_games': show_more_link_games,
        'show_more_link_promoted': show_more_link_promoted
    }

    return render(request, 'main/index.html', context)

在这里,我们首先定义了我们想要显示每个游戏类别的项目数量;对于推广游戏,将显示三款游戏,而非推广类别将最多显示九款游戏。

然后,我们获取推广和非推广游戏,并创建两个变量 show_more_link_promotedshow_more_link_games,如果数据库中的游戏数量超过我们之前定义的最大数量,它们将被设置为 True

我们创建一个包含我们想要在模板中呈现的所有数据的上下文变量,最后,我们调用 render 函数,并将 request 传递给我们想要呈现的模板,以及上下文。

因为我们使用了 Game 模型,我们需要导入它:

from .models import Game

现在我们准备在页面上看到结果了,但首先,我们需要创建一些游戏。为此,我们首先需要在管理员中注册模型。要做到这一点,编辑 admin.py 文件,并包含以下代码:

    from django.contrib import admin

    from .models import GamePlatform
    from .models import Game
    from .models import PriceList

    admin.autodiscover()

    admin.site.register(GamePlatform)
    admin.site.register(Game)
    admin.site.register(PriceList)

在 Django 管理站点中注册模型将允许我们添加、编辑和删除游戏、游戏平台和价格列表中的项目。因为我们将向游戏添加图片,我们需要配置 Django 应该保存我们通过管理站点上传的图片的位置。因此,让我们继续打开 gamestore/gamestore 目录中的 settings.py 文件,并在 STATIC_DIRS 设置的下面添加这一行:

MEDIA_ROOT  = os.path.join(BASE_DIR, 'static'</span>)

现在,启动网站:

python manage.py runserver

浏览到http://localhost:8000/admin,并使用我们创建的超级用户帐户登录。您应该会在页面上看到列出的模型:

如果您首先点击“游戏”平台,您将看到一个空列表。点击页面右上方的“游戏平台”行上的 ADD 按钮,将显示以下表单:

只需输入您喜欢的任何名称,然后单击“保存”按钮以保存更改。

在添加游戏之前,我们需要找到一个默认图像,并将其放置在gamestore/static/images/。图像的名称应为placeholder.png

我们构建的布局将更适合尺寸为 130x180 的图像。为了简化,当我创建原型时,我不想花太多时间寻找完美的图像,我会去网站placeholder.com/。在这里,您可以构建任何尺寸的占位图像。为了获得我们应用程序的正确尺寸,您可以直接转到via.placeholder.com/130x180

当您放置默认图像后,您可以开始添加游戏,方法与添加游戏平台相同,只需重复该过程多次以添加一些设置为推广的游戏。

添加游戏后,再次访问网站,您应该会在首页上看到游戏列表,如下所示:

在我的项目中,我添加了四个推广游戏。请注意,因为我们在第一页上只显示了三个推广游戏,所以我们呈现了“查看更多项目”链接。

添加列表游戏视图

由于我们没有在第一页上显示所有项目,因此我们需要构建页面,如果用户点击“查看更多项目”链接,将显示所有项目。这应该相当简单,因为我们已经有一个列出游戏的部分视图。

让我们在main应用的url.py文件中创建另外两个 URL,并将它们添加到urlpatterns列表中:

    path(r'games-list/highlighted/', views.show_highlighted_games),
    path(r'games-list/all/', views.show_all_games),

完美!现在我们需要添加一个模板来列出所有游戏。在gamestore/main/templates/main下创建一个名为all_games.html的文件,内容如下:

{% extends 'base.html' %}

{% block 'content' %}

 <h2>Highlighted games</h2>
 <hr/>

 {% if games %}
   {% include 'main/games-list.html' with gameslist=games
        highlight_promoted=False%}
   {% else %}
   <div class='empty-game-list'>
   <h3>There's no promoted games available at the moment</h3>
  </div>
 {% endif %}

 {% endblock %}

在同一文件夹中再添加一个名为highlighted.html的文件:

{% extends 'base.html' %}

{% block 'content' %}

<h2>All games</h2>
<hr/>

{% if games %}
  {% include 'main/games-list.html' with gameslist=games
    highlight_games=True%}
  {% else %}
  <div class='empty-game-list'>
    <h3>There's no promoted games available at the moment</h3>
  </div>
{% endif %}

{% endblock %}

这里没有我们以前没有见过的东西。这个模板将接收一个游戏列表,并将其传递给games-list.html部分视图,该视图将为我们渲染游戏。这里有一个if语句,检查列表中是否有游戏。如果列表为空,它将显示消息,说明目前没有可用的游戏。否则,它将呈现内容。

现在最后一件事是添加视图。打开gamestore/main/下的views.py文件,并添加以下两个函数:

def show_all_games(request):
    games = Game.objects.all()

    context = {'games': games}

    return render(request, 'main/all_games.html', context)

def show_highlighted_games(request):
    games = Game.objects.get_highlighted()

    context = {'games': games}

    return render(request, 'main/highlighted.html', context)

这些功能非常相似;一个获取所有游戏的列表,另一个获取仅推广游戏的列表

让我们再次打开应用程序。由于数据库中有更多的推广项目,让我们点击页面上突出显示游戏部分的“查看更多项目”链接。您应该会进入以下页面:

完美!它的工作就像预期的那样。

接下来,我们将为按钮添加功能,以便将这些项目添加到购物车中。

创建购物车模型

看起来现在我们有一个正在运行的应用程序,我们可以显示我们的游戏,但这里有一个大问题。你能猜到是什么吗?好吧,这个问题并不难,我在本节的标题中已经给出了答案。无论如何,我们的用户无法购买游戏,我们需要实现一个购物车,这样我们就可以开始让我们的用户开心了!

现在,您可以以许多种方式在应用程序上实现购物车,但我们将简单地将购物车项目保存在数据库中,而不是基于用户会话进行实现。

购物车的要求如下:

  • 用户可以添加任意数量的商品

  • 用户应该能够更改购物车中的商品;例如,他们应该能够更改商品的数量

  • 应该可以删除商品

  • 应该有一个清空购物车的选项

  • 所有数据都应该经过验证

  • 如果拥有该购物车的用户被删除,购物车及其商品也应该被删除

说到这里,打开gamestore/main目录下的models.py文件,让我们添加我们的第一个类:

class ShoppingCartManager(models.Manager):

    def get_by_id(self, id):
        return self.get(pk=id)

    def get_by_user(self, user):
        return self.get(user_id=user.id)

    def create_cart(self, user):
        new_cart = self.create(user=user)
        return new_cart

和我们为Game对象创建自定义Manager一样,我们也将为ShoppingCart创建一个Manager。我们将添加三个方法。第一个是get_by_id,顾名思义,根据 ID 检索购物车。第二个方法是get_by_user,它接收django.contrib.auth.models.User的实例作为参数,并将返回给定用户实例的购物车。最后一个方法是create_cart;当用户创建账户时将调用此方法。

现在我们有了需要的方法的管理器,让我们添加ShoppingCart类:

class ShoppingCart(models.Model):
    user = models.ForeignKey(User,
                             null=False,
                             on_delete=models.CASCADE)

    objects = ShoppingCartManager()

    def __str__(self):
        return f'{self.user.username}\'s shopping cart'

这个类非常简单。和以往一样,我们从Model继承,并为类型User定义一个外键。这个外键是必需的,如果用户被删除,购物车也会被删除。

在外键之后,我们将我们自定义的Manager分配给对象的属性,并且我们还实现了特殊方法__str__,这样在 Django 管理界面中购物车会以更好的方式显示。

接下来,让我们为ShoppingCartItem模型添加一个管理类,如下所示:

class ShoppingCartItemManager(models.Manager):

    def get_items(self, cart):
        return self.filter(cart_id=cart.id)

在这里,我们只定义了一个方法,名为get_items,它接收一个购物车对象,并返回底层购物车的商品列表。在Manager类之后,我们可以创建模型:

class ShoppingCartItem(models.Model):
    quantity = models.IntegerField(null=False)

    price_per_unit = models.DecimalField(max_digits=9,
                                         decimal_places=2,
                                         default=0)

    cart = models.ForeignKey(ShoppingCart,
                             null=False,
                             on_delete=models.CASCADE)
    game = models.ForeignKey(Game,
                             null=False,
                             on_delete=models.CASCADE)

    objects = ShoppingCartItemManager()

我们首先定义了两个属性:数量,这是一个整数值,和每件商品的价格,这是一个十进制值。在这个模型中我们也有price_per_item,因为当用户将商品添加到购物车时,如果管理员更改了产品的价格,我们不希望已经添加到购物车的商品的价格发生变化。价格应该与用户首次将产品添加到购物车时的价格相同。

如果用户完全删除商品并重新添加,新的价格应该得到反映。在这两个属性之后,我们定义了两个外键,一个是类型ShoppingCart,另一个是Game

最后,我们将ShoppingCartItemManager设置为对象的属性。

我们还需要导入 User 模型:

from django.contrib.auth.models import User

在我们尝试验证一切是否正常工作之前,我们应该创建并应用迁移。在终端上运行以下命令:

python manage.py makemigrations

和以前一样,我们需要运行迁移命令来将迁移应用到数据库:

python manage.py migrate

创建购物车表单

我们现在已经有了模型。让我们添加一个新的表单,用于在页面上显示购物车数据进行编辑。打开gamestore/main/目录下的forms.py文件,在文件末尾添加以下代码:

    ShoppingCartFormSet = inlineformset_factory(
      ShoppingCart,
      ShoppingCartItem,
      fields=('quantity', 'price_per_unit'),
      extra=0,
      widgets={
          'quantity': forms.TextInput({
             'class': 'form-control quantity',
          }),
          'price_per_unit': forms.HiddenInput()
      }
    )

在这里,我们使用inlineformset_factory函数创建一个内联formset。内联formset适用于当我们想通过外键与相关对象一起工作时。在我们这里非常方便;我们有一个与ShoppingCartItem相关的ShoppingCart模型。

因此,我们向inlineformset_factory函数传递了一些参数。首先是父模型(ShoppingCart),然后是模型(ShoppingCartItems)。因为在购物车中我们只想编辑数量并从购物车中移除商品,所以我们添加了一个包含我们想要在页面上呈现的ShoppingCartItem字段的元组——在这种情况下是quantityprice_per_unit。下一个参数extra指定表单是否应在表单上呈现任何空的额外行;在我们的情况下,我们不需要这样做,因为我们不希望将额外的商品添加到购物车视图中。

在最后一个参数widgets中,我们可以指定表单中字段的呈现方式。数量字段将呈现为文本输入,我们不希望price_per_unit可见,所以我们将其定义为隐藏输入,这样当我们将表单提交到服务器时,它会被发送回服务器。

最后,在同一个文件中,让我们添加一些必要的导入:

from django.forms import inlineformset_factory
from .models import ShoppingCartItem
from .models import ShoppingCart

打开views.py文件,让我们添加一个基于类的视图。首先,我们需要添加一些导入语句:

from django.views.generic.edit import UpdateView
from django.http import HttpResponseRedirect
from django.urls import reverse_lazy
from django.db.models import Sum, F, DecimalField

from .models import ShoppingCart
from .models import ShoppingCartItem
from .forms import ShoppingCartFormSet

然后,我们可以创建如下的类:

class ShoppingCartEditView(UpdateView):
    model = ShoppingCart
    form_class = ShoppingCartFormSet
    template_name = 'main/cart.html'    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)

        items = ShoppingCartItem.objects.get_items(self.object)

        context['is_cart_empty'] = (items.count() == 0)

        order = items.aggregate(
            total_order=Sum(F('price_per_unit') * F('quantity'),
                            output_field=DecimalField())
        )

        context['total_order'] = order['total_order']

        return context

    def get_object(self):
        try:
            return ShoppingCart.objects.get_by_user(self.request.user)
        except ShoppingCart.DoesNotExist:
            new_cart = ShoppingCart.objects.create_cart(self.request.user)
            new_cart.save()
            return new_cart

 def form_valid(self, form):
        form.save()
        return HttpResponseRedirect(reverse_lazy('user-cart'))

这与我们迄今为止创建的视图略有不同,因为这是一个从UpdateView继承的基于类的视图。实际上,在 Django 中,视图是可调用对象,当使用类而不是函数时,我们可以利用继承和混合。在我们的情况下,我们使用UpdateView,因为它是一个用于显示将编辑现有对象的表单的视图。

这个类视图首先定义了一些属性,比如模型,这是我们将在表单中编辑的模型。form_class是用于编辑数据的表单。最后,我们有将用于呈现表单的模板。

我们重写了get_context_data,因为我们在表单上下文中包含了一些额外的数据。因此,首先我们调用基类上的get_context_data来构建上下文,然后我们获取当前购物车的商品列表,以便确定购物车是否为空。我们将这个值设置为上下文项is_cart_empty,可以从模板中访问。

之后,我们想要计算当前购物车中商品的总价值。为此,我们需要首先通过(价格*数量)来计算每件商品的总价,然后对结果进行求和。在 Django 中,可以对QuerySet的值进行聚合;我们已经有了包含购物车中商品列表的QuerySet,所以我们只需要使用aggregate函数。在我们的情况下,我们向aggregate函数传递了两个参数。首先,我们得到字段price_per_unit乘以数量的总和,并将结果存储在一个名为total_order的属性中。aggregate函数的第二个参数定义了输出数据类型,我们希望它是一个十进制值。

当我们得到聚合的结果时,我们在上下文字典中创建了一个名为total_order的新项,并将结果赋给它。最后,我们返回上下文。

我们还重写了get_object方法。在这个方法中,我们尝试获取请求用户的购物车。如果购物车不存在,将引发一个ShoppingCart.DoesNotExist异常。在这种情况下,我们为用户创建一个购物车并返回它。

最后,我们还实现了form_valid方法,它只保存表单并将用户重定向回购物车页面。

创建购物车视图

现在是时候创建购物车视图了。这个视图将呈现我们刚刚创建的表单,用户应该能够更改购物车中每件商品的数量,以及移除商品。如果购物车为空,我们应该显示一条消息,说明购物车是空的。

在添加视图之前,让我们继续打开gamestore/main/中的urls.py文件,并添加以下 URL:

 path(r'cart/', views.ShoppingCartEditView.as_view(), name='user-
  cart'),

在这里,我们定义了一个新的 URL,'cart/',当访问时,它将执行基于类的视图ShoppingCartEditView。我们还为 URL 定义了一个名称,以简化操作。

我们将在gamestore/main/templates/main中创建一个名为cart.html的新文件,内容如下:

{% extends 'base.html' %}

{% block 'content' %}

{% load humanize %}

<div class='cart-details'>

<h3>{{ shoppingcart}}</h3>

{% if is_cart_empty %}

<h2>Your shopping cart is empty</h2>

{% else %}

<form action='' method='POST'>

  {% csrf_token %}

  {{ form.management_form }}

 <button class='btn btn-success'>
  <i class="fa fa-refresh" aria-hidden="true"></i>
     Updated cart
</button>
  <hr/>
  <table class="table table-striped">
  <thead>
    <tr>
      <th scope="col">Game</th>
      <th scope="col">Quantity</th>
      <th scope="col">Price per unit</th>
      <th scope="col">Options</th>
    </tr>
  </thead>
  <tbody>
   {% for item_form in form %}
   <tr>
     <td>{{item_form.instance.game.name}}</td>
     <td class=
  "{% if item_form.quantity.errors %}has-errors{% endif%}">
     {{item_form.quantity}}
   </td>
   <td>${{item_form.instance.price_per_unit|
            floatformat:2|intcomma}}</td>
   <td>{{item_form.DELETE}} Remove item</td>
   {% for hidden in item_form.hidden_fields %}
     {{ hidden }}
   {% endfor %}
  </tr>
  {% endfor %}
  <tbody>
 </table>
 </form>
<hr/>
<div class='footer'>
  <p class='total-value'>Total of your order:
     ${{total_order|floatformat:2|intcomma}}</p>
  <button class='btn btn-primary'>
     <i class="fa fa-check" aria-hidden="true"></i>
        SEND ORDER
  </button>
</div>
  {% endif %}
</div>
{% endblock %}

模板非常简单;我们只需循环遍历表单并渲染每一个。这里需要注意的一点是我们在模板开头加载了humanize

humanize是一组模板过滤器,我们可以在模板中使用它来格式化数据。

我们使用humanize中的intcomma过滤器来格式化购物车中所有商品的总和。intcomma过滤器将把整数或浮点值转换为字符串,并在每三位数字后添加一个逗号。

您可以在新视图上尝试它。但是,购物车将是空的,不会显示任何数据。接下来,我们将添加包含商品的功能。

将商品添加到购物车

我们即将完成购物车。现在我们将实现一个视图,将商品包含在购物车中。

我们需要做的第一件事是创建一个新的 URL。打开gamestore/main/目录中的url.py文件,并将此 URL 添加到urlpatterns列表中:

   path(r'cart/add/<int:game_id>/', views.add_to_cart),

完美。在此 URL 中,我们可以传递游戏 ID,并且它将执行一个名为add_to_cart的视图。让我们添加这个新视图。在gamestore/main中打开views.py文件。首先,我们添加导入语句,如下所示:

from decimal import Decimal
from django.shortcuts import get_object_or_404
from django.contrib import messages
from django.contrib.auth.decorators import login_required

现在,我们需要一种方法来知道特定商品是否已经添加到购物车中,因此我们转到gametore/main中的models.py,并向ShoppingCartItemManager类添加一个新方法:

def get_existing_item(self, cart, game):
    try:
        return self.get(cart_id=cart.id,
                        game_id=game.id)
    except ShoppingCartItem.DoesNotExist:
        return None

get_existing_item使用cart idgame id作为条件搜索ShoppingCartItem对象。如果在购物车中找不到该商品,则返回None;否则,它将返回购物车商品。

现在我们将视图添加到views.py文件中:

@login_required def add_to_cart(request, game_id):
    game = get_object_or_404(Game, pk=game_id)
    cart = ShoppingCart.objects.get_by_user(request.user)

    existing_item = ShoppingCartItem.objects.get_existing_item(cart, 
    game)

    if existing_item is None:

        price = (Decimal(0)
            if not hasattr(game, 'pricelist')
            else game.pricelist.price_per_unit)

        new_item = ShoppingCartItem(
            game=game,
            quantity=1,
            price_per_unit=price,
            cart=cart
        )
        new_item.save()
    else:
        existing_item.quantity = F('quantity') + 1
  existing_item.save()

        messages.add_message(
             request,
             messages.INFO,
             f'The game {game.name} has been added to your cart.')

        return HttpResponseRedirect(reverse_lazy('user-cart'))

此函数获取请求和游戏 ID,然后我们开始获取游戏和当前用户的购物车。然后我们将购物车和游戏传递给我们刚刚创建的get_existing函数。如果我们在购物车中没有特定的商品,我们就创建一个新的ShoppingCartItem;否则,我们只是更新数量并保存。

我们还添加了一条消息,通知用户该商品已添加到购物车中。

最后,我们将用户重定向到购物车页面。

最后一步,让我们打开gamestore/static/styles中的site.css文件,并为我们的购物车视图添加样式:

.cart-details h3 {
    margin-bottom: 40px;
}

.cart-details .table tbody tr td:nth-child(2) {
    width: 10%;
}

.cart-details .table tbody tr td:nth-child(3) {
    width: 25%;
}

.cart-details .table tbody tr td:nth-child(4) {
    width: 20%;
}

.has-errors input:focus {
    border-color: red;
    box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(255,0,0,1);
    webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(255,0,0,1);
}

.has-errors input {
    color: red;
    border-color: red;
}

.cart-details .footer {
    display:flex;
    justify-content: space-between;
}

.cart-details .footer .total-value {
    font-size: 1.4em;
    font-weight: bold;
    margin-left: 10px;
}

在尝试这个之前,我们需要在顶部菜单中添加到购物车视图的链接。在gamestore/templates中打开base.html文件,找到我们包含_loginpartial.html文件的位置,并在其之前包含以下代码:

{% if user.is_authenticated%}
<li>
  <a href="/cart/">
    <i class="fa fa-shopping-cart"
  aria-hidden="true"></i> CART
  </a>
</li>
{% endif %}

现在我们应该准备好测试它了。转到第一页,尝试向购物车中添加一些游戏。您应该会被重定向到购物车页面:

总结

这是一个漫长的旅程,在本章中我们涵盖了很多内容。在本章中,您已经看到使用 Django 构建应用是多么容易。这个框架真的很符合“完美主义者的截止日期”这句话。

您已经学会了如何创建一个新的 Django 项目和应用程序,并简要介绍了 Django 在我们启动新项目时为我们生成的样板代码。我们学会了如何创建模型并使用迁移来对数据库应用更改。

Django 表单也是本章我们涵盖的一个主题,您应该能够为您的项目创建复杂的表单。

作为奖励,我们学会了如何安装和使用NodeJS 版本管理器NVM)来安装 Node.js,以便使用 npm 安装项目依赖项。

在第五章中,使用微服务构建 Web Messenger,我们将扩展此应用程序,并创建将处理商店库存的服务。

第八章:订单微服务

在本章中,我们将扩展我们在第七章中实现的 Web 应用程序,使用 Django 创建在线视频游戏商店。我不知道您是否注意到,在该项目中有一些重要的东西缺失。首先是提交订单的能力。就目前而言,用户可以浏览产品并将商品添加到购物车;但是,没有办法发送订单并完成购买。

另一个缺失的项目是我们应用程序的用户能够查看已发送的所有订单以及其订单历史的页面。

说到这里,我们将创建一个名为order的微服务,它将处理网站上的所有订单相关事务。它将接收订单,更新订单等等。

在本章中,您将学到:

  • 创建微服务的基础知识

  • 如何使用 Django REST 框架创建 RESTful API

  • 如何使用服务并将其与其他应用程序集成

  • 如何编写测试

  • 如何在 AWS 上部署应用程序

  • 如何使用 Gunicorn 在 HTTP 代理nginx后运行我们的 Web 应用程序

所以,让我们开始吧!

设置环境

就像之前的所有章节一样,我们将从设置我们需要在其上开发服务的环境开始这一章。让我们首先创建我们的工作目录:

mkdir microservices && cd microservices

然后,我们使用pipenv创建我们的虚拟环境:

pipenv --python ~/Install/Python3.6/bin/python3.6

如果您不知道如何使用pipenv,在第四章的设置环境部分,汇率和货币转换工具中,有一个非常好的介绍,介绍了如何开始使用pipenv

创建虚拟环境后,我们需要安装项目依赖项。对于这个项目,我们将安装 Django 和 Django REST 框架:

pipenv install django djangorestframework requests python-dateutil

我们使用 Django 和 Django REST 框架而不是像 Flask 这样的简单框架的原因是,这个项目的主要目的是提供关注点的分离,创建一个将处理在前一章中开发的在线游戏商店中的订单的微服务。我们不仅希望提供供 Web 应用程序消费的 API。最好有一个简单的网站,以便我们可以列出订单,查看每个订单的详细信息,并执行更新,如更改订单状态。

正如您在上一章中看到的,Django 已经拥有一个非常强大和灵活的管理界面,我们可以自定义以向用户提供这种功能,而无需花费太多时间开发 Web 应用程序。

安装依赖项后,您的Pipfile应如下所示:

[[source]]

verify_ssl = true
name = "pypi"
url = "https://pypi.python.org/simple"

[packages]

django = "*"
djangorestframework = "*"

[dev-packages]

[requires]

python_version = "3.6"

完美!现在,我们可以开始一个新的 Django 项目。我们将使用django-admin工具创建项目。让我们继续创建一个名为order的项目:

django-admin startproject order

创建项目后,我们将创建一个 Django 应用程序。对于这个项目,我们将只创建一个名为main的应用程序。首先,我们将更改目录到服务目录:

cd order

然后,我们再次使用django-admin工具创建一个应用程序:

django-admin startapp main

创建 Django 应用程序后,您的项目结构应该类似于以下结构:

.
├── main
│   ├── admin.py
│   ├── apps.py
│   ├── __init__.py
│   ├── migrations
│   │   └── __init__.py
│   ├── models.py
│   ├── tests.py
│   └── views.py
├── manage.py
└── order
    ├── __init__.py
    ├── settings.py
    ├── urls.py
    └── wsgi.py

接下来,我们将开始创建我们服务的模型。

创建服务模型

在订单服务的第一部分,我们将创建一个模型,用于存储来自在线视频游戏商店的订单数据。让我们打开主应用程序目录中的models.py文件,并开始添加模型:

class OrderCustomer(models.Model):
    customer_id = models.IntegerField()
    name = models.CharField(max_length=100)
    email = models.CharField(max_length=100)

我们将创建一个名为OrderCustomer的类,它继承自Model,并定义三个属性;customer_id,它将对应于在线游戏商店中的客户 ID,客户的name,最后是email

然后,我们将创建存储订单信息的模型:

class Order(models.Model):

    ORDER_STATUS = (
        (1, 'Received'),
        (2, 'Processing'),
        (3, 'Payment complete'),
        (4, 'Shipping'),
        (5, 'Completed'),
        (6, 'Cancelled'),
    )

    order_customer = models.ForeignKey(
        OrderCustomer, 
        on_delete=models.CASCADE
    )    
    total = models.DecimalField(
        max_digits=9,
        decimal_places=2,
        default=0
    )
    created_at = models.DateTimeField(auto_now_add=True)
    last_updated = models.DateTimeField(auto_now=True)
    status = models.IntegerField(choices=ORDER_STATUS, default='1')   

Order类继承自Model,我们通过添加一个包含应用中订单状态的元组来开始这个类。我们还定义了一个外键order_customer,它将创建OrderCustomerOrder之间的关系。然后是定义其他字段的时间,从total开始,它是该订单的总购买价值。然后有两个日期时间字段;created_at,这是顾客提交订单的日期,last_update,这是在我们想知道订单何时有状态更新时将要使用的字段。

当将auto_now_add添加到DateTimeField时,Django 使用django.utils.timezone.now函数,该函数将返回带有时区信息的当前datetime对象。DateField 使用datetime.date.today(),它不包含时区信息。

我们要创建的最后一个模型是OrderItems。这将保存属于订单的项目。我们将像这样定义它:

class OrderItems(models.Model):
    class Meta:
        verbose_name_plural = 'Order items'

    product_id = models.IntegerField()
    name = models.CharField(max_length=200)
    quantity = models.IntegerField()
    price_per_unit = models.DecimalField(
        max_digits=9,
        decimal_places=2,
        default=0 
    )
    order = models.ForeignKey(
        Order, on_delete=models.CASCADE, related_name='items')

在这里,我们还定义了一个Meta类,以便我们可以为模型设置一些元数据。在这种情况下,我们将verbose_name_plural设置为Order items,以便在 Django 管理界面中正确拼写。然后,我们定义了product_idnamequantityprice_per_unit,它们指的是在线视频游戏商店中的Game模型。

最后,我们有项目数量和外键Order

现在,我们需要编辑microservices/order/order目录中的settings.py文件,并将主应用程序添加到INSTALLED_APPS中。它应该是这样的:

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'main',
]

唯一剩下的就是创建和应用数据库迁移。首先,我们运行makemigrations命令:

python manage.py makemigrations

然后迁移将更改应用到数据库:

python manage.py migrate

创建模型的管理器

为了使我们的应用程序更易读,不要在端点中充斥着大量业务逻辑,我们将为我们的模型类创建管理器。如果您遵循了上一章,您应该对此非常熟悉。简而言之,管理器是为 Django 模型提供查询操作的接口。

默认情况下,Django 为每个模型添加一个管理器;它存储在名为 objects 的属性上。Django 添加到模型的默认管理器有时是足够的,不需要创建自定义管理器,但是将所有与数据库相关的代码保持在模型内是一个好习惯。这将使我们的代码更一致、可读,并且更易于测试和维护。

在我们的情况下,我们感兴趣创建的唯一模型是名为 Order 的自定义模型管理器,但在我们开始实现订单管理器之前,我们需要创建一些辅助类。我们需要创建的第一个类是一个将定义在执行数据库查询时可能发生的自定义异常的类。当然,我们可以使用标准库中已经定义的异常,但是在应用程序的上下文中创建有意义的异常总是一个好习惯。

我们要创建的三个异常是InvalidArgumentErrorOrderAlreadyCompletedErrorOrderCancellationError

当将无效参数传递给我们将在管理器中定义的函数时,将引发异常InvalidArgumentError,因此让我们继续在主应用程序目录中创建一个名为exceptions.py的文件,并包含以下内容:

class InvalidArgumentError(Exception):
    def __init__(self, argument_name):
        message = f'The argument {argument_name} is invalid'
        super().__init__(message)

在这里,我们定义了一个名为InvalidArgumentError的类,它继承自Exception,并且我们在其中唯一要做的事情是重写构造函数并接收一个名为argument_name的参数。通过这个参数,我们可以指定引发异常的原因。

我们还将自定义异常消息,最后,我们将在超类上调用构造函数。

我们还将创建一个异常,当我们尝试取消状态为已取消的订单时,将引发异常,以及当我们尝试将订单的状态设置为已完成时,订单已经完成时:

class OrderAlreadyCompletedError(Exception):
    def __init__(self, order):
        message = f'The order with ID: {order.id} is already  
        completed.'
  super().__init__(message)

class OrderAlreadyCancelledError(Exception):
    def __init__(self, order):
        message = f'The order with ID: {order.id} is already  
        cancelled.'
  super().__init__(message)

然后,我们将添加另外两个自定义异常:

class OrderCancellationError(Exception):
    pass     class OrderNotFoundError(Exception):
    pass

这两个类并没有做太多事情。它们只是从Exception继承。我们将为每个异常配置和自定义消息,并将其传递给超类初始化程序。自定义异常类的价值在于它将提高我们应用程序的可读性和可维护性。

太好了!在开始管理之前,我们只需要添加一件事。我们将在模型管理器中创建函数,该函数将返回按状态过滤的数据。正如您所看到的,在Order模型的定义中,我们定义了状态如下:

ORDER_STATUS = (
    (1, 'Received'),
    (2, 'Processing'),
    (3, 'Payment complete'),
    (4, 'Shipping'),
    (5, 'Completed'),
    (6, 'Cancelled'),
)

这意味着,如果我们想要获取所有状态为Completed的订单,我们需要编写类似以下行的内容:

  Order.objects.filter(status=5)

这段代码只有一个问题,你能猜到是什么吗?如果你猜到了魔法数字5,那你绝对是对的!想象一下,如果我们的同事需要维护这段代码,并且只看到那里的数字5,并不知道 5 实际上代表什么,他们会有多沮丧。因此,我们将创建一个枚举,以便用来表示不同的状态。让我们在main应用程序目录中创建一个名为status.py的文件,并添加以下枚举:

from enum import Enum, auto

class Status(Enum):
    Received = auto()
    Processing = auto()
    Payment_Complete = auto()
    Shipping = auto()
    Completed = auto()
    Cancelled = auto()

因此,现在,当我们需要获取所有状态为Completed的订单时,我们可以这样做:

Order.objects.filter(Status.Received.value)

好多了!

现在,让我们为其创建模型管理器。在邮件应用程序目录中创建一个名为managers.py的文件,我们可以开始添加一些导入:

from datetime import datetime
from django.db.models import Manager, Q

from .status import Status

from .exceptions import InvalidArgumentError
from .exceptions import OrderAlreadyCompletedError
from .exceptions import OrderCancellationError

from . import models

然后,我们定义OrderManager类和第一个名为set_status的方法:

class OrderManager(Manager):

    def set_status(self, order, status):
        if status is None or not isinstance(status, Status):
            raise InvalidArgumentError('status')

        if order is None or not isinstance(order, models.Order):
            raise InvalidArgumentError('order')

        if order.status is Status.Completed.value:
            raise OrderAlreadyCompletedError()

        order.status = status.value
        order.save()

这种方法需要两个参数,订单和状态。orderOrder类型的对象,状态是我们之前创建的Status枚举的一个项目。

我们通过验证参数并引发相应的异常来开始这种方法。首先,我们验证字段是否具有值并且是正确的类型。如果验证失败,它将引发InvalidArgumentError。然后,我们检查我们正在为其设置状态的订单是否已经完成;在这种情况下,我们无法再更改它,因此我们引发OrderAlreadyCompletedError。如果所有参数都有效,我们设置订单的状态并保存。

在我们的应用程序中,我们希望能够取消尚未处理的订单;换句话说,我们只允许在状态为Received时取消订单。cancel_order方法应该如下所示:

def cancel_order(self, order):
    if order is None or not isinstance(order, models.Order):
        raise InvalidArgumentError('order')

    if order.status != Status.Received.value:
        raise OrderCancellationError()

    self.set_status(order, Status.Cancelled)

这种方法只获取order参数,首先,我们需要检查订单对象是否有效,并在无效时引发InvalidArgumentError。然后,我们检查订单的状态是否为not Received。在这种情况下,我们引发OrderCancellationError异常。否则,我们继续调用set_status方法,传递Status.Cancelled作为参数。

我们还需要获取给定客户的所有订单列表:

def get_all_orders_by_customer(self, customer_id):
    try:
        return self.filter(
            order_customer_id=customer_id).order_by(
            'status', '-created_at')
    except ValueError:
        raise InvalidArgumentError('customer_id')

get_all_orders_by_customer方法将customer_id作为参数。然后,我们使用 filter 函数来按customer_id过滤订单,同时按状态排序;仍在处理中的订单将位于 QuerySet 的顶部。

如果customer_id无效,例如,如果我们传递的是字符串而不是整数,则会引发ValueError异常。我们捕获此异常并引发我们的自定义异常InvalidArgumentError

我们在线视频游戏商店的财务部门要求获取特定用户的所有完整和不完整订单列表,因此让我们为其添加一些方法:

def get_customer_incomplete_orders(self, customer_id):
    try:
        return self.filter(
            ~Q(status=Status.Completed.value),
            order_customer_id=customer_id).order_by('status')
    except ValueError:
        raise InvalidArgumentError('customer_id')

def get_customer_completed_orders(self, customer_id):
    try:
        return self.filter(
            status=Status.Completed.value,
            order_customer_id=customer_id)
    except ValueError:
        raise InvalidArgumentError('customer_id')

第一个方法get_customer_incomplete_orders获取一个名为customer_id的参数。就像之前的方法一样;我们将捕获ValueError异常,以防customer_id无效,并引发InvalidArgumentError。这种方法的有趣之处在于过滤器。在这里,我们使用Q()对象,它封装了一个Python对象形式的 SQL 表达式。

在这里,我们有~Q(status=Status.Completed.value),这是not运算符,等同于状态不是Status.Complete。我们还过滤order_customer_id以检查它是否等于方法的customer_id参数,最后,我们按状态对 QuerySet 进行排序。

get_customer_completed_orders基本上是一样的,但这次我们过滤状态等于Status.Completed的订单。

Q()对象允许我们编写更复杂的查询,利用|(或)和&(与)运算符。

接下来,负责订单生命周期的每个部门都希望有一种简单的方式来获取处于特定阶段的订单;例如,负责发货游戏的工作人员希望获取所有状态等于“支付完成”的订单列表,以便将这些订单发货给客户。因此,我们需要添加一个方法来实现这一点:

def get_orders_by_status(self, status):
    if status is None or not isinstance(status, Status):
        raise InvalidArgumentError('status')

    return self.filter(status=status.value)

这是一个非常简单的方法;在这里,我们将状态作为参数。我们检查状态是否有效;如果无效,我们引发InvalidArgumentError。否则,我们继续并按状态过滤订单。

我们财务部门的另一个要求是获取特定日期范围内的订单列表:

def get_orders_by_period(self, start_date, end_date):
    if start_date is None or not isinstance(start_date, datetime):
        raise InvalidArgumentError('start_date')

    if end_date is None or not isinstance(end_date, datetime):
        raise InvalidArgumentError('end_date')

    result = self.filter(created_at__range=[start_date, end_date])
    return result

在这里,我们得到两个参数start_dateend_date。与所有其他方法一样,我们首先检查这些参数是否有效;在这种情况下,参数不能是None,并且必须是Datetime对象的实例。如果任何字段无效,将引发InvalidArgumentError。当参数有效时,我们使用created_at字段过滤订单,还使用了特殊的语法created_at__range,这意味着我们将传递一个日期范围,并将其用作过滤器。在这里,我们传递start_dateend_date

可能有一个有趣的方法可以实现,并且可以为我们应用程序的管理员增加价值。这里的想法是添加一个方法,当调用时,自动将订单更改为下一个状态:

def set_next_status(self, order):
    if order is None or not isinstance(order, models.Order):
        raise InvalidArgumentError('order')

    if order.status is Status.Completed.value:
        raise OrderAlreadyCompletedError()

    order.status += 1
    order.save()

这个方法只接受一个参数,即订单。我们检查订单是否有效,如果无效,我们引发InvalidArgumentError。我们还希望确保一旦订单达到“已完成”状态,就不能再更改。因此,我们检查订单是否处于“已完成”状态,然后引发OrderAlreadyCompleted异常。最后,我们将当前状态加 1 并保存对象。

现在,我们可以更改我们的Order模型,使其使用我们刚刚创建的OrderManager。打开主应用程序目录中的 model.py 文件,在Order类的末尾添加以下行:

objects = OrderManager()

现在,我们可以通过Order.objects访问我们在OrderManager中定义的所有方法。

接下来,我们将为我们的模型管理器方法添加测试。

学习测试

到目前为止,在本书中,我们还没有涵盖如何创建测试。现在是一个很好的时机,所以我们将为模型管理器中创建的方法创建测试。

我们为什么需要测试?对这个问题的简短回答是,测试将使我们知道方法或函数是否做了正确的事情。另一个原因(也是我认为最重要的原因之一)是,测试在进行代码更改时给我们更多的信心。

Django 在开箱即用的情况下提供了出色的工具来创建单元测试和集成测试,并结合像 Selenium 这样的框架,可以基本上测试我们应用的所有部分。

说了这些,让我们创建我们的第一个测试。当创建一个新的 Django 应用程序时,Django 会在app目录中创建一个名为test.py的文件。您可以在其中编写您的测试,或者如果您更喜欢通过将测试分成多个文件来使项目更有组织性,您可以删除该文件并创建一个名为tests的目录,并将所有测试文件放在其中。由于我们只打算为 Order 模型管理器创建测试,我们将把所有测试都放在 Django 为我们创建的tests.py文件中。

创建测试文件

打开test.py文件,让我们首先添加一些导入:

from dateutil.relativedelta import relativedelta

from django.test import TestCase
from django.utils import timezone

from .models import OrderCustomer, Order
from .status import Status

from .exceptions import OrderAlreadyCompletedError
from .exceptions import OrderCancellationError
from .exceptions import InvalidArgumentError

很好!我们首先导入相对增量函数,这样我们就可以轻松进行日期操作,比如向日期添加天数或月数。这在测试按一定时间段获取订单的方法时将非常有帮助。

现在,我们导入一些与 Django 相关的内容。首先是TestCase类,它是unittest.TestCase的子类。由于我们将编写与数据库交互的测试,最好使用django.tests.TestCase而不是unittest.TestCase。Django 的TestCase实现将确保您的测试在事务中运行,以提供隔离。这样,当运行测试时,由于测试套件中另一个测试创建的数据,我们将不会有不可预测的结果。

我们还导入了一些我们将在测试中使用的模型类,OrderOrderCustomer模型,以及在测试更改订单状态的方法时使用的 Status 类。

在为应用程序编写测试时,我们不仅要测试的情况,还要测试当出现问题时,当错误的参数传递给正在测试的函数和方法时。因此,我们导入我们自定义的错误类,以确保在正确的情况下引发正确的异常。

现在我们已经导入了必要的内容,是时候创建类和方法来为我们的测试设置数据了:

class OrderModelTestCase(TestCase):

    @classmethod
    def setUpTestData(cls):
        cls.customer_001 = OrderCustomer.objects.create(
            customer_id=1,
            email='customer_001@test.com'
        )

        Order.objects.create(order_customer=cls.customer_001)

        Order.objects.create(order_customer=cls.customer_001,
                             status=Status.Completed.value)

        cls.customer_002 = OrderCustomer.objects.create(
            customer_id=1,
            email='customer_002@test.com'
        )

        Order.objects.create(order_customer=cls.customer_002)

在这里,我们创建了一个名为OrderModelTestCase的类,继承自django.test.TestCase。然后,我们定义了setUpTestData方法,这个方法将负责设置每个测试将使用的数据。

在这里,我们创建了两个用户;第一个用户有两个订单,其中一个订单状态设置为Completed。第二个用户只有一个订单。

测试取消订单功能

我们要测试的第一个方法是cancel_orders方法。顾名思义,它将取消一个订单。在这个方法中,有一些我们想要测试的东西:

  • 第一个测试非常直接;我们只想测试是否可以取消订单,将其状态设置为Cancelled

  • 第二个测试是不应该取消尚未收到的订单;换句话说,只有当前状态设置为Received的订单才能被取消。

  • 我们需要测试如果将无效参数传递给cancel_order方法时是否会引发正确的异常。

说了这些,让我们添加我们的测试:

def test_cancel_order(self):
    order = Order.objects.get(pk=1)

    self.assertIsNotNone(order)
    self.assertEqual(Status.Received.value, order.status)

    Order.objects.cancel_order(order)

    self.assertEqual(Status.Cancelled.value, order.status)

def test_cancel_completed_order(self):
    order = Order.objects.get(pk=2)

    self.assertIsNotNone(order)
    self.assertEqual(Status.Completed.value, order.status)

    with self.assertRaises(OrderCancellationError):
        Order.objects.cancel_order(order)

def test_cancel_order_with_invalid_argument(self):
    with self.assertRaises(InvalidArgumentError):
        Order.objects.cancel_order({'id': 1})

第一个测试test_cancel_order,首先获取 ID 为 1 的订单。我们使用assertIsNotNone函数断言返回的值不是None,同时使用assertEqual函数确保订单的状态是Received

然后,我们从订单模型管理器中调用cancel_order方法传递订单,最后,我们再次使用assertEqual函数来验证订单的状态是否确实更改为Cancelled

第二个测试test_cancel_complated_order从获取 ID 等于2的订单开始;请记住,我们已将此订单设置为Completed状态。然后,我们做与上一个测试相同的事情;验证订单不等于None,并验证状态设置为Complete。最后,我们使用assertRaises函数测试,如果我们尝试取消已取消的订单,将引发正确的异常;在这种情况下,将引发OrderCancellationError类型的异常。

最后,我们有test_cancel_order_with_invalid_argument函数,它将测试如果我们向cancel_order函数传递无效参数,是否会引发正确的异常。

测试获取所有订单的功能

现在,我们将为get_all_orders_by_customer方法添加测试。对于这个方法,我们需要测试:

  • 当给定顾客 ID 时,返回正确数量的订单

  • 当向方法传递无效参数时引发正确的异常

def test_get_all_orders_by_customer(self):
    orders = Order.objects.get_all_orders_by_customer(customer_id=1)

    self.assertEqual(2, len(orders),
                     msg='It should have returned 2 orders.')

def test_get_all_order_by_customer_with_invalid_id(self):
    with self.assertRaises(InvalidArgumentError):
        Order.objects.get_all_orders_by_customer('o')

get_all_orders_by_customer方法的测试非常简单。在第一个测试中,我们获取 ID 为1的顾客的订单,并测试返回的项目数量是否等于2

在第二个测试中,我们断言调用get_all_orders_by_customer时使用无效参数,实际上会引发InvalidArgumentError类型的异常。在这种情况下,测试将成功通过。

获取顾客的不完整订单

get_customer_incomplete_orders方法返回给定顾客 ID 的状态与Completed不同的所有订单。对于这个测试,我们需要验证:

  • 该方法返回正确数量的项目,以及返回的项目是否没有状态等于Completed

  • 我们将测试当向该方法传递无效值时是否引发异常

def test_get_customer_incomplete_orders(self):
    orders = Order.objects.get_customer_incomplete_orders(customer_id=1)

    self.assertEqual(1, len(orders))
    self.assertEqual(Status.Received.value, orders[0].status)

def test_get_customer_incomplete_orders_with_invalid_id(self):
    with self.assertRaises(InvalidArgumentError):
        Order.objects.get_customer_incomplete_orders('o')

测试test_get_customer_incomplete_orders从调用get_customer_incomplete_orders函数开始,并将顾客 ID 设置为1作为参数传递。然后,我们验证返回的项目数量是否正确;在这种情况下,只有一个不完整的订单,所以应该是1。最后,我们检查返回的项目是否实际上具有与Completed不同的状态。

另一个测试与之前测试异常的测试完全相同,只是调用该方法并断言已引发了正确的异常。

获取顾客的已完成订单

接下来,我们将测试get_customer_completed_order。这个方法,正如其名称所示,返回给定顾客的所有状态为Completed的订单。在这里,我们将测试与get_customer_incompleted_orders相同的场景:

def test_get_customer_completed_orders(self):
    orders = Order.objects.get_customer_completed_orders(customer_id=1)

    self.assertEqual(1, len(orders))
    self.assertEqual(Status.Completed.value, orders[0].status)

def test_get_customer_completed_orders_with_invalid_id(self):
    with self.assertRaises(InvalidArgumentError):
        Order.objects.get_customer_completed_orders('o')

首先,我们调用get_customer_completed_orders,传递顾客 ID 等于1,然后验证返回的项目数量是否等于1。最后,我们验证返回的项目是否实际上具有状态设置为Completed

按状态获取订单

get_order_by_status函数根据状态返回订单列表。这里有两种情况需要测试:

  • 如果该方法返回给定状态的正确数量的订单

  • 当向方法传递无效参数时引发正确的异常

def test_get_order_by_status(self):
    order = Order.objects.get_orders_by_status(Status.Received)

    self.assertEqual(2, len(order),
                     msg=('There should be only 2 orders '
                          'with status=Received.'))

    self.assertEqual('customer_001@test.com',
                     order[0].order_customer.email)

def test_get_order_by_status_with_invalid_status(self):
    with self.assertRaises(InvalidArgumentError):
        Order.objects.get_orders_by_status(1)

很简单。我们首先调用get_orders_by_status进行第一项测试,将Status.Received作为参数传递。然后,我们验证只有两个订单被返回。

对于第二个测试,对于get_order_by_status方法,与之前的异常测试一样,运行该方法,传递无效参数,然后验证是否引发了InvalidArgumentError类型的异常。

按期获取订单

现在,我们将测试get_order_by_period方法,该方法在给定初始日期和结束日期时返回订单列表。对于这个方法,我们将执行以下测试:

  • 调用该方法,传递参数,应返回在该期间创建的订单

  • 调用方法,传递我们知道没有创建任何订单的有效日期,这应该返回一个空结果

  • 测试在调用方法时是否会引发异常,传递无效的开始日期

  • 测试在调用方法时是否会引发异常,传递无效的结束日期

def test_get_orders_by_period(self):

    date_from = timezone.now() - relativedelta(days=1)
    date_to = date_from + relativedelta(days=2)

    orders = Order.objects.get_orders_by_period(date_from, date_to)

    self.assertEqual(3, len(orders))

    date_from = timezone.now() + relativedelta(days=3)
    date_to = date_from + relativedelta(months=1)

    orders = Order.objects.get_orders_by_period(date_from, date_to)

    self.assertEqual(0, len(orders))

def test_get_orders_by_period_with_invalid_start_date(self):
    start_date = timezone.now()

    with self.assertRaises(InvalidArgumentError):
        Order.objects.get_orders_by_period(start_date, None)

def test_get_orders_by_period_with_invalid_end_date(self):
    end_date = timezone.now()

    with self.assertRaises(InvalidArgumentError):
        Order.objects.get_orders_by_period(None, end_date)

我们通过创建date_from方法来开始这个方法,它是当前日期减去一天。在这里,我们使用python-dateutil包的relativedelta方法执行日期操作。然后,我们定义date_to,它是当前日期加两天。

现在我们有了我们的时间段,我们可以将这些值作为参数传递给get_orders_by_period方法。在我们的情况下,我们设置了三个订单,全部都是用当前日期创建的,因此这个方法调用应该返回确切的三个订单。

然后,我们定义了一个我们知道不会有任何订单的不同时间段。date_from函数定义为当前日期加三天,因此date_from是当前日期加1个月。

再次调用该方法,传递date_fromdate_to的新值不应该返回任何订单。

get_orders_by_period的最后两个测试与我们之前实现的异常测试相同。

设置订单的下一个状态

我们将要创建的Order模型管理器的下一个方法是set_next_status方法。set_next_status方法只是一个方便使用的方法,它将设置订单的下一个状态。如果你记得,我们创建的Status枚举意味着枚举中的每个项目都设置为auto(),这意味着枚举中的项目将获得一个数字顺序号作为值。

当我们将订单保存在数据库中并将其状态设置为,例如Status.Processing时,数据库中状态字段的值将为2

该功能只是将1添加到当前订单的状态,因此它转到下一个状态项目,除非状态是Completed;那是订单生命周期的最后状态。

现在我们已经刷新了关于这种方法如何工作的记忆,是时候为它创建测试了,我们将不得不执行以下测试:

  • 当调用set_next_status时,订单会获得下一个状态

  • 测试在调用set_next_status并传递状态为Completed的订单时是否会引发异常

  • 测试在传递无效订单作为参数时是否会引发异常

def test_set_next_status(self):
    order = Order.objects.get(pk=1)

    self.assertTrue(order is not None,
                    msg='The order is None.')

    self.assertEqual(Status.Received.value, order.status,
                     msg='The status should have been 
                     Status.Received.')

    Order.objects.set_next_status(order)

    self.assertEqual(Status.Processing.value, order.status,
                     msg='The status should have been 
                     Status.Processing.')

def test_set_next_status_on_completed_order(self):
    order = Order.objects.get(pk=2)

    with self.assertRaises(OrderAlreadyCompletedError):
        Order.objects.set_next_status(order)

def test_set_next_status_on_invalid_order(self):
    with self.assertRaises(InvalidArgumentError):
        Order.objects.set_next_status({'order': 1})

第一个测试test_set_next_status开始通过获取 ID 等于1的订单。然后,它断言订单对象不等于 none,并且我们还断言订单状态的值为Received。然后,我们调用set_next_status方法,将订单作为参数传递。然后,我们再次断言以确保状态已经改变。如果订单的状态等于2,也就是Status枚举中的Processing,则测试将通过。

另外两个测试与订单测试非常相似,我们断言异常,但值得一提的是测试test_set_next_status_on_completed_order断言,如果我们尝试在状态等于Status.Completed的订单上调用set_next_status,那么将引发OrderAlreadyCompletedError类型的异常。

设置订单的状态

最后,我们将实现Order模型管理器的最后测试。我们将为set_status方法创建测试。set_status方法确实做了它的名字所暗示的事情;它将为给定的订单设置状态。我们需要执行以下测试:

  • 设置状态并验证订单的状态是否真的已经改变

  • 在已经完成的订单中设置状态;它应该引发OrderAlreadyCompletedError类型的异常

  • 在已经取消的订单中设置状态;它应该引发OrderAlreadyCancelledError类型的异常

  • 使用无效订单调用set_status方法;它应该引发InvalidArgumentError类型的异常

  • 使用无效状态调用set_status方法;它应该引发InvalidArgumentError类型的异常

def test_set_status(self):
    order = Order.objects.get(pk=1)

    Order.objects.set_status(order, Status.Processing)

    self.assertEqual(Status.Processing.value, order.status)

def test_set_status_on_completed_order(self):
    order = Order.objects.get(pk=2)

    with self.assertRaises(OrderAlreadyCompletedError):
        Order.objects.set_status(order, Status.Processing)

def test_set_status_on_cancelled_order(self):
    order = Order.objects.get(pk=1)
    Order.objects.cancel_order(order)

    with self.assertRaises(OrderAlreadyCancelledError):
        Order.objects.set_status(order, Status.Processing)

def test_set_status_with_invalid_order(self):
    with self.assertRaises(InvalidArgumentError):
        Order.objects.set_status(None, Status.Processing)

def test_set_status_with_invalid_status(self):
    order = Order.objects.get(pk=1)

    with self.assertRaises(InvalidArgumentError):
        Order.objects.set_status(order, {'status': 1})

我们不会遍历所有测试,因为它们测试异常的方式类似于我们之前实现的测试,但是值得浏览第一个测试。在test_set_status测试中,它将获取 ID 等于1的订单,正如我们在setUpTestData中定义的那样,其状态等于Status.Received。我们调用set_status方法,传递订单和新状态作为参数,在这种情况下是Status.Processing。设置新状态后,我们只需调用assertEquals来确保订单的状态实际上已更改为Status.Processing

创建订单模型序列化程序

现在我们已经有了一切我们需要开始创建 API 端点。在这一部分,我们将为Order管理器中实现的每个方法创建端点。

对于其中一些端点,我们将使用 Django REST 框架。使用 Django REST 框架的优势在于该框架包含了许多开箱即用的功能。它具有不同的身份验证方法,对对象的序列化非常强大,我最喜欢的是它将为您提供一个 Web 界面,您可以在其中浏览 API,还包含了大量的基类和混合类,当您需要创建基于类的视图时。

所以,让我们马上开始吧!

在这一点上,我们需要做的第一件事是为我们模型的实体创建序列化程序类,OrderOrderCustomerOrderItem

继续在主app目录中创建一个名为serializers.py的文件,并让我们从添加一些导入语句开始:

import functools

from rest_framework import serializers

from .models import Order, OrderItems, OrderCustomer

我们首先从标准库中导入functools模块;然后,我们从rest_framework模块中导入序列化程序。我们将使用它来创建我们的模型序列化程序。最后,我们将导入我们将用来创建序列化程序的模型,OrderOrderItemsOrderCustomer

我们要创建的第一个序列化程序是OrderCustomerSerializer

class OrderCustomerSerializer(serializers.ModelSerializer):
    class Meta:
        model = OrderCustomer
        fields = ('customer_id', 'email', 'name', )

OrderCustomerSerializer继承自ModelSerializer,它非常简单;它只是定义了一些类元数据。我们将设置模型,OrderCustomer,还有将包含一个包含我们要序列化的字段的元组的属性字段。

然后,我们创建OrderItemSerializer

class OrderItemSerializer(serializers.ModelSerializer):
    class Meta:
        model = OrderItems
        fields = ('name', 'price_per_unit', 'product_id', 'quantity', )

OrderItemSerializerOrderCustomerSerializer非常相似。该类也继承自ModelSerializer,并定义了一些元数据属性。第一个是模型,我们将其设置为OrderItems,然后是包含我们要序列化的每个模型字段的元组的字段。

我们要创建的最后一个序列化程序是OrderSerializer,所以让我们从定义一个名为OrderSerializer的类开始:

class OrderSerializer(serializers.ModelSerializer):
    items = OrderItemSerializer(many=True)
    order_customer = OrderCustomerSerializer()
    status = serializers.SerializerMethodField()

首先,我们定义两个属性。items属性设置为OrderItemSerializer,这意味着当我们需要序列化 JSON 数据时,它将使用该序列化程序,当我们想要添加新订单时。items属性指的是订单包含的商品。在这里,我们只使用一个关键字参数(many=True)。这将告诉你,序列化程序的 items 将是一个数组。

状态字段有点特殊;如果你还记得Order模型中的状态字段,它被定义为ChoiceField。当我们在数据库中保存订单时,该字段将存储值1,如果订单状态为Received,则存储值2,如果状态为Processing,依此类推。当我们的 API 的消费者调用端点获取订单时,他们将对状态的名称感兴趣,而不是数字。

因此,解决这个问题的方法是将字段定义为SerializeMethodField,然后我们将创建一个名为get_status的函数,它将返回订单状态的显示名称。我们很快就会看到get_status方法的实现是什么样子的。

我们还定义了order_customer属性,它设置为OrderCustomerSerializer,这意味着在尝试添加新订单时,框架将使用OrderCustomerSerializer类来反序列化我们发送的 JSON 对象。

然后,我们定义一个Meta类,以便我们可以向序列化器类添加一些元数据信息:

    class Meta:
        depth = 1
        model = Order
        fields = ('items', 'total', 'order_customer',
                  'created_at', 'id', 'status', )

第一个属性depth指定了在序列化之前应该遍历的关系深度。在这种情况下,它设置为1,因为在获取订单对象时,我们还希望获取有关客户和商品的信息。与其他序列化器一样,我们将模型设置为Order,并且 fields 属性指定了哪些字段将被序列化和反序列化。

然后,我们实现get_status方法:

    def get_status(self, obj):
        return obj.get_status_display()

这是一个将为ChoiceField状态获取显示值的方法。这将覆盖默认行为,并返回get_status_display()函数的结果。

_created_order_item方法只是一个辅助方法,我们将使用它来创建和准备订单项对象,然后执行批量插入操作:

    def _create_order_item(self, item, order):
        item['order'] = order
        return OrderItems(**item)

在这里,我们将获得两个参数。第一个参数将是一个包含有关OrderItem的数据的字典,以及一个类型为Order的对象的order参数。首先,我们更新传递给第一个参数的字典,添加order对象,然后我们调用OrderItem构造函数,将商品作为item字典的参数传递。

我马上就会向你展示它的用途。现在我们已经到了这个序列化器的核心,我们将实现create方法,这将是一个在每次调用序列化器的save方法时自动调用的方法:

def create(self, validated_data):
    validated_customer = validated_data.pop('order_customer')
    validated_items = validated_data.pop('items')

    customer = OrderCustomer.objects.create(**validated_customer)

    validated_data['order_customer'] = customer
    order = Order.objects.create(**validated_data)

    mapped_items = map(
        functools.partial(
        self._create_order_item, order=order), validated_items
    )

    OrderItems.objects.bulk_create(mapped_items)

    return order

因此,当调用save方法时,create方法将被自动调用,并将validated_data作为参数。validated_date是经过验证的、反序列化的订单数据。它看起来类似于以下数据:

{
    "items": [
        {
            "name": "Prod 001",
            "price_per_unit": 10,
            "product_id": 1,
            "quantity": 2
        },
        {
            "name": "Prod 002",
            "price_per_unit": 12,
            "product_id": 2,
            "quantity": 2
        }
    ],
    "order_customer": {
        "customer_id": 14,
        "email": "test@test.com",
        "name": "Test User"
    },
    "order_id": 1,
    "status": 4,
    "total": "190.00"
}

正如你所看到的,在这个 JSON 中,我们一次性传递了所有信息。这里,我们有orderitems属性,它是订单项的列表,以及order_customer,其中包含提交订单的客户的信息。

由于我们必须分别创建这些对象,我们首先弹出order_customeritems,所以我们有三个不同的对象。第一个validated_customer将只包含与下订单的人相关的数据。validated_items对象将只包含订单每个商品相关的数据,最后,validated_data对象将只包含订单本身的数据。

拆分数据后,我们现在可以开始添加对象。我们首先创建一个OrderCustomer

customer = OrderCustomer.objects.create(**validated_customer)

然后,我们可以创建订单。Order有一个外键字段叫做order_customer,它是与特定订单相关联的客户。我们需要在validated_data字典中创建一个名为order_customer的新项目,并将其值设置为我们刚刚创建的客户:

validated_data['order_customer'] = customer
order = Order.objects.create(**validated_data)

最后,我们将添加OrderItems。现在,要添加订单项,我们需要做一些事情。validated_items变量是属于底层订单的商品列表,我们首先需要为每个商品设置订单,并为列表中的每个商品创建一个OrderItem对象。

执行此操作的不同方式。例如,您可以分两部分进行;首先遍历项目列表并设置订单属性,然后再次遍历列表并创建OrderItem对象。然而,那样并不那么优雅,是吗?

这里更好的方法是利用 Python 是一种多范式编程语言的事实,我们可以以更加函数式的方式解决这个问题:

mapped_items = map(
    functools.partial(
        self._create_order_item, order=order), validated_items
)

OrderItems.objects.bulk_create(mapped_items)

在这里,我们利用了内置函数 map 之一。map函数将应用我指定的作为第一个参数的函数到作为第二个参数传递的可迭代对象上,然后返回一个包含结果的可迭代对象。

我们将作为 map 的第一个参数传递的函数称为partial,来自functools模块。partial函数是一个高阶函数,意味着它将返回另一个函数(第一个参数中的函数),并将参数和关键字参数添加到其签名中。在前面的代码中,它将返回self._create_order_item,第一个参数将是可迭代的validated_items中的一个项目。第二个参数是我们之前创建的订单。

之后,mapped_items的值应该包含一个OrderItem对象的列表,唯一剩下的事情就是调用bulk_create,它将为我们插入列表中的所有项目。

接下来,我们将创建视图。

创建视图

在创建视图之前,我们将创建一些辅助类和函数,这些类和函数将使视图中的代码更简单、更清晰。继续创建一个名为view_helper.py的文件,在主应用程序目录中,像往常一样,让我们从包含导入语句开始:

from rest_framework import generics, status
from rest_framework.response import Response

from django.http import HttpResponse

from .exceptions import InvalidArgumentError
from .exceptions import OrderAlreadyCancelledError
from .exceptions import OrderAlreadyCompletedError

from .serializers import OrderSerializer

在这里,我们从 Django REST Framework 导入了一些东西,主要是通用的,其中包含了我们将用来创建自定义视图的通用视图类的定义。状态包含了所有 HTTP 状态码,在向客户端发送响应时非常有用。然后,我们导入了Response类,它将允许我们向客户端发送内容,可以以不同的内容类型呈现,例如 JSON 和 XML。

然后,我们从 Django 中导入HttpResponse,以及在 rest 框架中的Response的等价物。

我们还导入了我们之前实现的所有自定义异常,这样我们就可以正确处理数据,并在出现问题时向客户端发送有用的错误消息。

最后,我们导入OrderSerializer,我们将用它来进行序列化、反序列化和验证模型。

我们将要创建的第一个类是OrderListAPIBaseView类,它将作为返回内容列表给客户端的所有视图的基类:

class OrderListAPIBaseView(generics.ListAPIView):
    serializer_class = OrderSerializer
    lookup_field = ''

    def get_queryset(self, lookup_field_id):
        pass

    def list(self, request, *args, **kwargs):
        try:
            result = self.get_queryset(kwargs.get(self.lookup_field, None))
        except Exception as err:
            return Response(err, status=status.HTTP_400_BAD_REQUEST)

        serializer = OrderSerializer(result, many=True)
        return Response(serializer.data, status=status.HTTP_200_OK)

OrderListAPIBaseView继承自通用的ListAPIView,它为我们提供了 get 和 list 方法,我们可以重写这些方法以添加满足我们要求的功能。

该类首先定义了两个属性;serializer_class,设置为OrderSerializer,以及lookup_field,在这种情况下我们将其设置为空字符串。我们将在子类中重写这个值。然后,我们定义了get_queryset方法,这也将在子类中被重写。

最后,我们实现了列表方法,它将首先运行get_queryset方法来获取将返回给用户的数据。如果发生错误,它将返回一个状态为400BAD REQUEST)的响应,否则,它将使用OrderSerializer来序列化数据。result参数是get_queryset方法返回的QuerySet结果,many关键字参数告诉序列化器我们将序列化一个项目列表。

当数据被正确序列化时,我们将发送一个状态为200(OK)的响应,其中包含查询的结果。

这个基类的想法是,所有子类只需要实现get_queryset方法,这将使视图类保持简洁整洁。

现在,我们将添加一个函数,它将帮助我们执行POST请求的方法。让我们继续添加一个名为set_status_handler的函数:

def set_status_handler(set_status_delegate):
    try:
        set_status_delegate()
    except (
            InvalidArgumentError,
            OrderAlreadyCancelledError,
            OrderAlreadyCompletedError) as err:
        return HttpResponse(err, status=status.HTTP_400_BAD_REQUEST)

    return HttpResponse(status=status.HTTP_204_NO_CONTENT)

这个函数非常简单;它只会将一个函数作为参数。运行该函数;如果发生异常之一,它将向客户端返回一个400BAD REQUEST)响应,否则,它将返回一个204NO CONTENT)响应。

添加视图

现在,是时候开始添加视图了!打开主app目录中的views.py文件,让我们添加一些导入语句:

from django.http import HttpResponse
from django.shortcuts import get_object_or_404

from rest_framework import generics, status
from rest_framework.response import Response

from .models import Order
from .status import Status
from .view_helper import OrderListAPIBaseView
from .view_helper import set_status_handler
from .serializers import OrderSerializer

首先,我们将从django.http模块导入HttpReponse,并从django.shortcuts模块导入get_object_or_404。后者只是一个帮助函数,它将获取一个对象,如果找不到它,将返回状态码为440NOT FOUND)的响应。

然后,我们导入 generics 以创建通用视图和状态,并从rest_framework中导入Response类。

最后,我们导入一些模型、帮助方法和函数,以及我们将在视图中使用的序列化器。

我们应该准备开始创建视图了。让我们创建一个视图,它将获取给定客户的所有订单:

class OrdersByCustomerView(OrderListAPIBaseView):
    lookup_field = 'customer_id'

    def get_queryset(self, customer_id):
        return Order.objects.get_all_orders_by_customer(customer_id)

很好!因此,我们创建了一个从基类(OrderListAPIBaseView)继承的类,我们在view_helpers.py中创建了这个基类,由于我们已经实现了列表方法,因此我们在这里需要实现的唯一方法是get_querysetget_queryset方法以customer_id作为参数,并简单地调用我们在Order模型管理器中创建的get_all_orders_by_customer,传递customer_id

我们还定义了lookup_field的值,它将用于获取传递给基类列表方法的kwargs的关键字参数的值。

让我们再添加两个视图来获取未完成和完成的订单:

class IncompleteOrdersByCustomerView(OrderListAPIBaseView):
    lookup_field = 'customer_id'

    def get_queryset(self, customer_id):
        return Order.objects.get_customer_incomplete_orders(
            customer_id
        )

class CompletedOrdersByCustomerView(OrderListAPIBaseView):
    lookup_field = 'customer_id'

    def get_queryset(self, customer_id):
        return Order.objects.get_customer_completed_orders(
            customer_id
        )

与我们实现的第一个视图基本相同,我们定义了lookup_field并重写了get_queryset以调用Order模型管理器中的适当方法。

现在,我们将添加一个视图,当给定特定状态时,将获取订单列表:

class OrderByStatusView(OrderListAPIBaseView):
    lookup_field = 'status_id'

    def get_queryset(self, status_id):
        return Order.objects.get_orders_by_status(
            Status(status_id)
        )

正如你在这里所看到的,我们将lookup_field定义为status_id,并重写get_queryset以调用get_orders_by_status,传递状态值。

在这里,我们使用Statusstatus_id),因此我们传递Enum项而不仅仅是 ID。

到目前为止,我们实现的所有视图都只接受GET请求,并且将返回订单列表。现在,我们将实现一个支持POST请求的视图,以便能够接收新订单:

class CreateOrderView(generics.CreateAPIView):

    def post(self, request, *arg, **args):
        serializer = OrderSerializer(data=request.data)

        if serializer.is_valid():
            order = serializer.save()
            return Response(
                {'order_id': order.id},
                status=status.HTTP_201_CREATED)

        return Response(status=status.HTTP_400_BAD_REQUEST)

现在,这个类与我们创建的前几个类有些不同,基类是通用的。CreateAPIView为我们提供了一个post方法,因此我们重写该方法以添加我们需要的逻辑。首先,我们获取请求的数据并将其作为参数传递给OrderSerializer类;它将对数据进行反序列化并将其设置为序列化器变量。然后,我们调用is_valid()方法,它将验证接收到的数据。如果请求的数据无效,我们返回一个400响应(BAD REQUEST),否则,我们继续调用save()方法。这个方法将在序列化器上内部调用create方法,并且它将创建新订单以及新订单的客户和订单项目。如果一切顺利,我们将返回一个202响应(CREATED),并附上新创建订单的 ID。

现在,我们将创建三个函数,用于处理订单取消、设置下一个订单状态,以及最后,设置特定订单的状态:

def cancel_order(request, order_id):
    order = get_object_or_404(Order, order_id=order_id)

    return set_status_handler(
        lambda: Order.objects.cancel_order(order)
    )

def set_next_status(request, order_id):
    order = get_object_or_404(Order, order_id=order_id)

    return set_status_handler(
        lambda: Order.objects.set_next_status(order)
    )

def set_status(request, order_id, status_id):
    order = get_object_or_404(Order, order_id=order_id)

    try:
        status = Status(status_id)
    except ValueError:
        return HttpResponse(
            'The status value is invalid.',
            status=status.HTTP_400_BAD_REQUEST)

    return set_status_handler(
        lambda: Order.objects.set_status(order, status)
    )

正如您所看到的,我们在这里没有使用 Django REST 框架的基于类的视图。我们只是使用常规函数。第一个函数cancel_order接收两个参数——请求和order_id。我们首先使用快捷函数get_object_or_404get_object_or_404函数会在无法找到与第二个参数中传递的条件匹配的对象时返回404响应(未找到)。否则,它将返回该对象。

然后,我们使用了我们在view_helpers.py文件中实现的辅助函数set_status_handler。这个函数接收另一个函数作为参数。因此,我们传递了一个将执行我们想要的Order模型管理器中的方法的lambda函数。在这种情况下,当执行lambda函数时,它将执行我们在Order模型管理器中定义的cancel_order方法,传递我们想要取消的订单。

set_next_status函数非常类似,但是我们将在lambda函数内部调用cancel_order,而不是调用set_next_status,传递我们想要设置为下一个状态的订单。

set_status函数包含了一些更多的逻辑,但它也很简单。这个函数将接收两个参数,order_idstatus_id。首先,我们获取订单对象,然后使用status_id查找状态。如果状态不存在,将引发ValueError异常,然后我们返回400响应(错误请求)。否则,我们调用set_status_handle,传递一个lambda函数,该函数将执行set_status函数,传递订单和状态对象。

设置服务 URL

现在我们已经将所有视图放在了适当的位置,是时候开始设置我们的订单服务用户可以调用以获取和修改订单的 URL 了。让我们继续打开主app目录中的urls.py文件;首先,我们需要导入所有要使用的视图类和函数:

from .views import (
    cancel_order,
    set_next_status,
    set_status,
    OrdersByCustomerView,
    IncompleteOrdersByCustomerView,
    CompletedOrdersByCustomerView,
    OrderByStatusView,
    CreateOrderView,
)

太好了!现在,我们可以开始添加 URL:

urlpatterns = [
    path(
        r'order/add/',
        CreateOrderView.as_view()
    ),
    path(
        r'customer/<int:customer_id>/orders/get/',
        OrdersByCustomerView.as_view()
    ),
    path(
        r'customer/<int:customer_id>/orders/incomplete/get/',
        IncompleteOrdersByCustomerView.as_view()
    ),
    path(
        r'customer/<int:customer_id>/orders/complete/get/',
        CompletedOrdersByCustomerView.as_view()
    ),
    path(
        r'order/<int:order_id>/cancel',
        cancel_order
    ),
    path(
        r'order/status/<int:status_id>/get/',
        OrderByStatusView.as_view()
    ),
    path(
        r'order/<int:order_id>/status/<int:status_id>/set/',
        set_status
    ),
    path(
        r'order/<int:order_id>/status/next/',
        set_next_status
    ),
]

要添加新的 URL,我们需要使用path函数来传递第一个参数,即URL。第二个参数是在发送请求到由第一个参数指定的URL时将执行的函数。我们创建的每个 URL 都必须添加到urlspatterns列表中。请注意,Django 2 简化了如何向 URL 添加参数。以前,您需要使用正则表达式;现在,您只需遵循<type:param>的表示法。

在我们尝试这个之前,我们必须打开urls.py文件,但这次是在订单目录中,因为我们需要包括我们刚刚创建的 URL。

urls.py文件应该类似于这样:

"""order URL Configuration

The `urlpatterns` list routes URLs to views. For more information please see:
    https://docs.djangoproject.com/en/2.0/topics/http/urls/
Examples:
Function views
    1\. Add an import: from my_app import views
    2\. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
    1\. Add an import: from other_app.views import Home
    2\. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
    1\. Import the include() function: from django.urls import include, path
    2\. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
]

现在,我们希望我们在主应用程序中定义的所有 URL 都位于/api/下。为了实现这一点,我们唯一需要做的就是创建一个新的路由,并包括来自主应用程序的 URL。在urlpatterns列表中添加以下代码:

path('api/', include('main.urls')),

并且不要忘记导入include函数:

from django.urls import include

将订单服务部署到 AWS 时,它不会是公共的;但是作为额外的安全措施,我们将为此服务启用令牌身份验证。

要调用服务的 API,我们必须发送身份验证令牌。让我们继续启用它。在order目录中打开settings.py文件,并添加以下内容:

REST_FRAMEWORK = {
    'DEFAULT_PERMISSION_CLASSES': (
        'rest_framework.permissions.IsAuthenticated',
    ),
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'rest_framework.authentication.TokenAuthentication',
    )
}

您可以将其放在INSTALLED_APPS之后。

DEFAULT_PERMISSION_CLASSES函数定义了全局权限策略。在这里,我们将其设置为rest_framework.permissions.IsAuthenticated,这意味着它将拒绝任何未经授权的用户访问。

DEFAULT_AUTHENTICATION_CLASSES函数指定了全局身份验证模式。在这种情况下,我们将使用令牌身份验证。

然后,在INSTALLED_APPS中,我们需要包括rest_framework.authtoken。您的INSTALLED_APPS应该如下所示:

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'main',
    'rest_framework',
    'rest_framework.authtoken',
]

太好了!保存文件,并在终端上运行以下命令:

python manage.py migrate

Django REST 框架具有开箱即用的视图,因此用户可以调用并获取令牌。但是,为简单起见,我们将创建一个可以访问 API 的用户。然后,我们可以手动创建一个身份验证令牌,该令牌可用于对订单服务 API 进行请求。

让我们继续创建这个用户。使用以下命令启动服务:

python manage.py runserver

然后浏览到https://localhost:8000/admin

在身份验证和授权选项卡下,您将看到Users模型。单击添加并创建一个用户名为api_user的用户。创建用户后,返回管理首页,在AUTH TOKEN下,单击添加。在下拉菜单中选择api_user,然后单击保存。您应该会看到以下页面:

复制密钥,让我们创建一个小脚本,只需添加一个订单,以便我们可以测试 API。

创建一个名为send_order.py的文件;它可以放在任何您想要的地方,只要您已激活虚拟环境,因为我们将使用 requests 包将订单发送到订单服务。将以下内容添加到send_order.py文件中:

import json
import sys
import argparse
from http import HTTPStatus

import requests

def setUpData(order_id):
    data = {
        "items": [
            {
                "name": "Prod 001",
                "price_per_unit": 10,
                "product_id": 1,
                "quantity": 2
            },
            {
                "name": "Prod 002",
                "price_per_unit": 12,
                "product_id": 2,
                "quantity": 2
            }
        ],
        "order_customer": {
            "customer_id": 14,
            "email": "test@test.com",
            "name": "Test User"
        },
        "order_id": order_id,
        "status": 1,
        "total": "190.00"
    }

    return data

def send_order(data):

    response = requests.put(
        'http://127.0.0.1:8000/api/order/add/',
        data=json.dumps(data))

    if response.status_code == HTTPStatus.NO_CONTENT:
        print('Ops! Something went wrong!')
        sys.exit(1)

    print('Request was successfull')

if __name__ == '__main__':

    parser = argparse.ArgumentParser(
        description='Create a order for test')

    parser.add_argument('--orderid',
                        dest='order_id',
                        required=True,
                        help='Specify the the order id')

    args = parser.parse_args()

    data = setUpData(args.order_id)
    send_order(data)

太棒了!现在,我们可以启动开发服务器:

python manage.py runserver

在另一个窗口中,我们将运行刚刚创建的脚本:

python send_order.py --orderid 10

您可以看到以下结果:

什么?这里出了些问题,你能猜到是什么吗?请注意在我运行 Django 开发服务器的终端中打印的屏幕截图中的日志消息:

[21/Jan/2018 09:30:37] "PUT /api/order/add/ HTTP/1.1" 401 58

好的,这里说服务器已收到了对/api/order/add/的 PUT 请求,这里需要注意的一件事是代码401表示未经授权。这意味着我们在settings.py文件中添加的设置运行正常。要调用 API,我们需要进行身份验证,而我们正在使用令牌身份验证。

要为用户创建一个令牌,我们需要在 Django 管理 UI 中登录。在那里,我们将找到如下所示的AUTH TOKEN部分:

单击右侧的绿色加号。然后,您可以选择要为其创建令牌的用户,当您准备好时,单击保存。之后,您将看到已创建的令牌列表:

该密钥是您要在请求的HEADER中发送的密钥。

现在我们有了一个令牌,我们可以修改send_order.py脚本,并将令牌信息添加到请求中,因此在send_order函数的顶部添加以下代码:

token = '744cf4f8bd628e62f248444a478ce06681cb8089'

headers = {
    'Authorization': f'Token {token}',
    'Content-type': 'application/json'
}

令牌变量是我们为用户api_user创建的令牌。要获取令牌,只需登录到 Django 管理 UI,在AUTH TOKEN下,您将看到已创建的令牌。只需删除我在此处添加的令牌,并用在您的应用程序上为api_user生成的令牌替换它。

然后,我们需要在请求中发送头。更改以下代码:

response = requests.put(
    'http://127.0.0.1:8000/api/order/add/',
    data=json.dumps(data))

将其替换为:

response = requests.put(
    'http://127.0.0.1:8000/api/order/add/',
    headers=headers,
    data=json.dumps(data))

现在,我们可以转到终端并再次运行我们的代码。您应该看到类似于以下屏幕截图中显示的输出:

请注意,现在我们得到了以下日志消息:

[21/Jan/2018 09:49:40] "PUT /api/order/add/ HTTP/1.1" 201 0

这意味着身份验证正常工作。继续花时间探索 Django 管理 UI,并验证我们现在在数据库中创建了一个客户和一个订单以及一些商品。

让我们尝试一些其他端点,看看它们是否按预期工作。例如,我们可以获取刚刚创建的客户的所有订单。

您可以使用任何工具对端点进行小型测试。有一些非常方便的浏览器插件可以安装,或者如果您像我一样喜欢在终端上做所有事情,您可以使用 cURL。或者,如果您想尝试使用 Python 构建一些东西,可以安装httpie包,使用 pip 命令pip install httpie --upgrade --user在本地目录下./local/bin安装httpie。所以,不要忘记将此目录添加到您的 PATH 中。我喜欢使用httpie而不是 cURL,因为httpie显示了一个漂亮和格式化的 JSON 输出,这样我就可以更好地查看从端点返回的响应。

所以,让我们尝试我们创建的第一个GET端点:

  http http://127.0.0.1:8000/api/customer/1/orders/get/ 'Authorization: Token 744cf4f8bd628e62f248444a478ce06681cb8089'

然后你应该看到以下输出:

HTTP/1.1 200 OK
Allow: GET, HEAD, OPTIONS
Content-Length: 270
Content-Type: application/json
Date: Sun, 21 Jan 2018 10:03:00 GMT
Server: WSGIServer/0.2 CPython/3.6.2
Vary: Accept
X-Frame-Options: SAMEORIGIN

[
 {
 "items": [
 {
 "name": "Prod 001",
 "price_per_unit": 10,
 "product_id": 1,
 "quantity": 2
 },
 {
 "name": "Prod 002",
 "price_per_unit": 12,
 "product_id": 2,
 "quantity": 2
 }
 ],
 "order_customer": {
 "customer_id": 14,
 "email": "test@test.com",
 "name": "Test User"
 },
 "order_id": 10,
 "status": 1,
 "total": "190.00"
 }
]

完美!正如预期的那样。继续尝试其他端点!

接下来,我们要回到在线视频游戏商店并发送订单。

与在线游戏商店的集成

现在我们的服务已经运行起来了,我们准备完成第七章中的 Django 在线视频游戏商店项目。我们不打算进行太多更改,但有两个改进我们要做:

  • 目前,在在线视频游戏商店中,无法提交订单。我们网站的用户只能将商品添加到购物车中,查看和编辑购物车中的商品。我们将完成这个实现,并创建一个视图,以便我们可以提交订单。

  • 我们将实现另一个视图,可以在其中查看订单历史记录。

所以,让我们开始吧!

我们要做的第一个变化是为我们在服务订单中创建的api_user添加身份验证令牌。我们还想要添加订单服务的基本 URL,这样我们就可以更容易地构建我们需要执行请求的 URL。在gamestore目录中的settings.py文件中添加这两个常量变量:

ORDER_SERVICE_AUTHTOKEN = '744cf4f8bd628e62f248444a478ce06681cb8089'
ORDER_SERVICE_BASEURL = 'http://127.0.0.1:8001'

这段代码放在哪里都可以,但也许最好是放在文件的末尾。

我们接下来要做的变化是添加一个名为OrderItemnamedtuple,只是为了帮助我们准备订单数据,使其与订单服务期望的格式兼容。在gamestore/main目录中的models.py文件中添加import

from collections import namedtuple

模型文件的另一个变化是,我们将在ShoppingCartManager类中添加一个名为empty的新方法,这样当调用它时,它将删除所有购物车中的商品。在ShoppingCartManager类中添加以下方法:

def empty(self, cart):
    cart_items = ShoppingCartItem.objects.filter(
        cart__id=cart.id
    )

    for item in cart_items:
        item.delete()

在文件末尾,让我们创建namedtuple

OrderItem = namedtuple('OrderItem', 
                         'name price_per_unit product_id quantity')

接下来,我们要更改cart.html模板。找到send order按钮:

<button class='btn btn-primary'>
  <i class="fa fa-check" aria-hidden="true"></i>
  &nbsp;SEND ORDER
</button>

用以下内容替换它:

<form action="/cart/send">
  {% csrf_token %}
  <button class='btn btn-primary'>
    <i class="fa fa-check" aria-hidden="true"></i>
    &nbsp;SEND ORDER
  </button>
</form>

很好!我们刚刚在按钮周围创建了一个表单,并在表单中添加了跨站点请求伪造令牌,这样当我们点击按钮时,它将发送一个请求到cart/send

让我们添加新的 URL。在主app目录中打开urls.py文件,然后添加两个新的 URL:

path(r'cart/send', views.send_cart),
path(r'my-orders/', views.my_orders),

您可以将这两个 URL 定义放在/cart/URL 的定义之后。

打开views.py文件并添加一些新的导入:

import json
import requests
from http import HTTPStatus
from django.core.serializers.json import DjangoJSONEncoder
from gamestore import settings

然后,我们添加一个函数,将帮助我们将订单数据序列化为要发送到订单服务的格式:

def _prepare_order_data(cart):

    cart_items = ShoppingCartItem.objects.values_list(
        'game__name',
        'price_per_unit',
        'game__id',
        'quantity').filter(cart__id=cart.id)

    order = cart_items.aggregate(
        total_order=Sum(F('price_per_unit') * F('quantity'),
                        output_field=DecimalField(decimal_places=2))
    )

    order_items = [OrderItem(*x)._asdict() for x in cart_items]

    order_customer = {
        'customer_id': cart.user.id,
        'email': cart.user.email,
        'name': f'{cart.user.first_name} {cart.user.last_name}'
    }

    order_dict = {
        'items': order_items,
        'order_customer': order_customer,
        'total': order['total_order']
    }

    return json.dumps(order_dict, cls=DjangoJSONEncoder)

现在,我们还有两个视图要添加,第一个是send_order

@login_required
def send_cart(request):
    cart = ShoppingCart.objects.get(user_id=request.user.id)

    data = _prepare_order_data(cart)

    headers = {
        'Authorization': f'Token {settings.ORDER_SERVICE_AUTHTOKEN}',
        'Content-type': 'application/json'
    }

    service_url = f'{settings.ORDER_SERVICE_BASEURL}/api/order/add/'

    response = requests.post(
        service_url,
        headers=headers,
        data=data)

    if HTTPStatus(response.status_code) is HTTPStatus.CREATED:
        request_data = json.loads(response.text)
        ShoppingCart.objects.empty(cart)
        messages.add_message(
            request,
            messages.INFO,
            ('We received your order!'
             'ORDER ID: {}').format(request_data['order_id']))
    else:
        messages.add_message(
            request,
            messages.ERROR,
            ('Unfortunately, we could not receive your order.'
             ' Try again later.'))

    return HttpResponseRedirect(reverse_lazy('user-cart'))

接下来是my_orders视图,这将是返回订单历史记录的新视图:

@login_required
def my_orders(request):
    headers = {
        'Authorization': f'Token {settings.ORDER_SERVICE_AUTHTOKEN}',
        'Content-type': 'application/json'
    }

    get_order_endpoint = f'/api/customer/{request.user.id}/orders/get/'
    service_url = f'{settings.ORDER_SERVICE_BASEURL}{get_order_endpoint}'

    response = requests.get(
        service_url,
        headers=headers
    )

    if HTTPStatus(response.status_code) is HTTPStatus.OK:
        request_data = json.loads(response.text)
        context = {'orders': request_data}
    else:
        messages.add_message(
            request,
            messages.ERROR,
            ('Unfortunately, we could not retrieve your orders.'
             ' Try again later.'))
        context = {'orders': []}

    return render(request, 'main/my-orders.html', context)

我们需要创建my-orders.html文件,这将是由my_orders视图呈现的模板。在main/templates/main/目录中创建一个名为my-orders.html的新文件,内容如下:

{% extends 'base.html' %}

{% block 'content' %}

<h3>Order history</h3>

{% for order in orders %}

<div class="order-container">
  <div><strong>Order ID:</strong> {{order.id}}</div>
  <div><strong>Create date:</strong> {{ order.created_at }}</div>
  <div><strong>Status:</strong> <span class="label label-success">{{order.status}}</span></div>
  <div class="table-container">
    <table class="table table-striped">
      <thead>
        <tr>
          <th>Product name</th>
          <th>Quantity</th>
          <th>Price per unit</th>
        </tr>
      </thead>
      <tbody>
        {% for item in order.items %}
        <tr>
          <td>{{item.name}}</td><td>{{item.quantity}}</td>  
          <td>${{item.price_per_unit}}</td>
        </tr>
        {% endfor %}
      </tbody>
    </table>
  </div>
  <div><strong>Total amount:</strong>{{order.total}}</div>
  <hr/>
</div>
{% endfor %}
{% endblock %}

这个模板非常基础;它只是循环订单,然后循环商品并构建一个包含商品信息的 HTML 表格。

我们需要在site.css中做一些更改,这是在线视频游戏商店的自定义样式。打开static/styles文件夹中的site.css文件,让我们做一些修改。首先,找到以下代码,如下所示:

.nav.navbar-nav .fa-home,
.nav.navbar-nav .fa-shopping-cart {
    font-size: 1.5em;
}

用以下内容替换它:

.nav.navbar-nav .fa-home,
.nav.navbar-nav .fa-shopping-cart,
.nav.navbar-nav .fa-truck {
    font-size: 1.5em;
}

在此文件的末尾,我们可以添加特定于订单历史页面的样式:

.order-container {
    border: 1px solid #000;
    margin: 20px;
    padding: 10px;
}

现在,我们将添加一个菜单选项,该选项将是指向新的my orders页面的链接。在应用程序root目录中的templates目录中打开base.html文件,并找到菜单选项CART

<li>
  <a href="/cart/">
    <i class="fa fa-shopping-cart" aria-hidden="true"></i> CART
  </a>
</li>

</li>标签结束后,添加以下代码:

<li>
  <a href="/my-orders/">
    <i class="fa fa-truck" aria-hidden="true"></i> ORDERS
  </a>
</li>

最后,我们要做的最后一项更改是改进我们在 UI 中显示的错误消息的布局。找到base.html文件末尾的此代码:

{% if messages %}
  {% for message in messages %}    
    {{message}}
    </div>
  {% endfor %}
{% endif %}

用以下代码替换它:

{% if messages %}
  {% for message in messages %}
    {% if message.tags == 'error' %}
      <div class="alert alert-danger" role="alert">
    {% else %}
      <div class="alert alert-info" role="alert">
    {% endif %}
    {{message}}
    </div>
  {% endfor %}
{% endif %}

测试集成

我们已经准备就绪。现在,我们需要启动网站和服务,以便验证一切是否正常工作。

需要记住的一件事是,为了测试,我们需要在不同的端口上运行 Django 应用程序。我们可以使用默认端口800运行网站(游戏在线商店),对于订单服务,我们可以使用端口8001

打开两个终端;在一个终端中,我们将启动在线视频游戏商店:

python manage.py runserver

然后,在第二个终端上,我们将启动订单服务:

python manage.py runserver 127.0.0.1:8001

太棒了!打开浏览器,转到http://localhost:8000并使用我们的凭据登录。登录后,您会注意到一些不同之处。现在,顶部菜单中有一个名为ORDERS的新选项。它应该是空的,所以继续向购物车中添加一些项目。完成后,转到购物车视图并单击发送订单按钮。

如果一切顺利,您应该在页面顶部看到通知,如下所示:

太棒了!它正如预期的那样工作。请注意,在将订单发送到订单服务后,购物车也被清空了。

现在,点击顶部菜单上的ORDERS选项,您应该看到我们刚刚提交的订单:

部署到 AWS

现在,是时候向世界展示我们迄今为止所做的工作了。

我们将在 Amazon Web 服务的 EC2 实例上部署 gamestore Django 应用程序和订单服务。

本节不涉及配置虚拟私有云、安全组、路由表和 EC2 实例。Packt 有许多关于这个主题的优秀书籍和视频可供参考。

相反,我们将假设您已经设置好了环境,并专注于:

  • 部署应用程序

  • 安装所有必要的依赖项

  • 安装和使用gunicorn

  • 安装和配置nginx

我的 AWS 设置非常简单,但绝对适用于更复杂的设置。现在,我有一个 VPC,一个子网和两个 EC2 实例(gamestore和 order-service)。请参阅以下截图:

我们可以从gamestore应用程序开始;通过 ssh 连接到您希望部署游戏在线应用程序的 EC2 实例。请记住,要在这些实例中的一个中进行ssh,您需要拥有.pem文件:

ssh -i gamestore-keys.pem ec2-user@35.176.16.157

我们将首先更新在该计算机上安装的任何软件包;虽然不是必需的,但这是一个很好的做法,因为其中一些软件包可能具有安全修复和性能改进,您可能希望在安装时拥有这些改进。Amazon Linux 使用yum软件包管理器,因此我们运行以下命令:

sudo yum update

只需对任何需要更新的软件包回答“是”y

这些 EC2 实例默认未安装 Python,因此我们也需要安装它:

sudo yum install python36.x86_64 python36-pip.noarch python36- setuptools.noarch

我们还需要安装nginx

sudo yum install nginx

然后,我们安装我们的项目依赖项:

sudo pip-3.6 install django requests pillow gunicorn

完美!现在,我们可以复制我们的应用程序,退出此实例,并从我们的本地机器上运行以下命令:

scp -R -i gamestore-keys.pem ./gamestore ec2-user@35.176.16.157:~/gamestore

此命令将递归地将本地机器上gamestore目录中的所有文件复制到 EC2 实例中我们的主目录中。

修改 settings.py 文件

这里有一件事情我们需要改变。在settings.py文件中,有一个名为ALLOWED_HOSTS的列表,在我们创建 Django 项目时为空。我们需要添加我们将部署应用程序的 EC2 的 IP 地址;在我的情况下,它将是:

ALLOWED_HOSTS=["35.176.16.157"]

我们还需要更改文件末尾定义的ORDER_SERVICE_BASEURL。它需要是我们将部署到订单服务的实例的地址。在我的情况下,IP 是35.176.194.15,所以我的变量看起来像这样:

ORDER_SERVICE_BASEURL = "http://35.176.194.15"

我们将创建一个文件夹来保存应用程序,因为将应用程序运行在ec2-user文件夹中不是一个好主意。因此,我们将在root目录中创建一个名为app的新文件夹,并将gamestore目录复制到新创建的目录中:

sudo mkdir /app && sudo cp -R ./gamestore /app/

我们还需要设置该目录的当前权限。当安装nginx时,它还会创建一个nginx用户和一个组。因此,让我们更改整个文件夹的所有权:

cd / && sudo chown -R nginx:nginx ./gamestore

最后,我们将设置nginx,编辑/etc/nginx/nginx.conf文件,在service下添加以下配置:

location / {
  proxy_pass http://127.0.0.1:8000;
  proxy_set_header Host $host;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

location /static {
  root /app/gamestore;
}

我们需要重新启动nginx服务,以便服务反映我们刚刚做的更改:

sudo service nginx restart

最后,我们转到application文件夹:

cd /app/gamestore

使用gunicorn启动应用程序。我们将以nginx用户的身份启动应用程序:

sudo gunicorn -u nginx gamestore.wsgi

现在,我们可以浏览到该网站。您不需要指定端口8000,因为nginx将把从端口80进来的请求路由到127.0.0.1:8000

部署订单服务

部署订单服务与gamestore项目基本相同,唯一的区别是我们将安装不同的 Python 依赖项并将应用程序部署到不同的目录。所以,让我们开始吧。

您几乎可以重复直到安装nginx步骤的所有步骤。还要确保您从现在开始使用另一个 EC2 实例的弹性 IP 地址。

安装nginx后,我们可以安装订单服务的依赖项:

sudo pip-3.6 install django djangorestframework requests

现在我们可以复制项目文件。转到您拥有服务目录的目录,并运行此命令:

scp -R -i order-service-keys.pem ./order ec2-user@35.176.194.15:~/gamestore

gamestore一样,我们还需要编辑settings.py文件并添加我们的 EC2 实例弹性 IP:

ALLOWED_HOSTS=["35.176.194.15"]

我们还将在root目录中创建一个文件夹,以便项目不会留在ec2-user的主目录中:

sudo mkdir /srv && sudo cp -R ./order /srv/

让我们也更改整个目录的所有者:

cd / && sudo chown -R nginx:nginx ./order

让我们编辑/etc/nginx/nginx.conf文件,在service下添加以下配置:

location / {
  proxy_pass http://127.0.0.1:8000;
  proxy_set_header Host $host;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

这次,我们不需要配置静态文件夹,因为订单服务没有像图像、模板、JS 或 CSS 文件这样的东西。

重新启动nginx服务:

sudo service nginx restart

转到服务的目录:

cd /srv/order

并使用gunicorn启动应用程序。我们将以nginx用户的身份启动应用程序:

sudo gunicorn -u nginx order.wsgi

最后,我们可以浏览到gamestore部署的地址,您应该看到网站正在运行。

浏览到该网站,您将看到第一页。所有产品都在加载,登录和注销部分也正常工作。这是我的系统的截图:

如果您浏览使用订单服务的视图,例如订单部分,您可以验证一切是否正常运行,如果您在网站上下了任何订单,您应该在这里看到订单列表,如下面的截图所示:

总结

在本章中,我们涵盖了许多主题;我们已经构建了订单服务,负责接收我们在上一章开发的网络应用程序的订单。订单服务还提供其他功能,例如能够更新订单状态并使用不同的标准提供订单信息。

这个微服务是我们在上一章开发的网络应用程序的延伸,接下来的章节中,我们将通过添加无服务器函数来进一步扩展它,以便在成功接收订单时通知我们应用程序的用户,以及当订单状态变更为已发货时通知他们。

第九章:通知无服务器应用程序

在本章中,我们将探索 AWS Lambda 函数和 AWS API Gateway。AWS Lambda 使我们能够创建无服务器函数。无服务器并不意味着没有服务器;实际上,它意味着这些函数不需要 DevOps 开销,如果您在运行应用程序,例如在 EC2 实例上,就会有。

无服务器架构并非银弹或解决所有问题的方案,但有许多优势,例如定价、几乎不需要 DevOps 以及对不同编程语言的支持。

在 Python 的情况下,像 Zappa 和由亚马逊开发的 AWS Chalice 微框架等工具使创建和部署无服务器函数变得非常容易。

在本章中,您将学习如何:

  • 使用 Flask 框架创建服务

  • 安装和配置 AWS CLI

  • 使用 CLI 创建 S3 存储桶并上传文件

  • 安装和配置 Zappa

  • 使用 Zappa 部署应用程序

因此,话不多说,让我们马上开始吧!

设置环境

让我们首先创建一个文件夹,我们将在其中放置应用程序文件。首先,创建一个名为notifier的目录,并进入该目录,以便我们可以创建虚拟环境:

mkdir notifier && cd notifier

我们使用pipenv创建虚拟环境:

pipenv --python ~/Installs/Python3.6/bin/python3.6

请记住,如果 Python 3 在我们的path中,您可以简单地调用:

pipenv --three

为构建此服务,我们将使用微型 Web 框架 Flask,因此让我们安装它:

pipenv install flask

我们还将安装 requests 包,该包在发送请求到订单服务时将被使用:

pipenv install requests

现在应该是我们目前所需的一切。接下来,我们将看到如何使用 AWS 简单邮件服务从我们的应用程序发送电子邮件。

设置 Amazon Web Services CLI

我们还需要安装 AWS 命令行界面,这将在部署无服务器函数和创建 S3 存储桶时节省大量时间。

安装非常简单,可以通过pip安装,AWS CLI 支持 Python 2 和 Python 3,并在不同的操作系统上运行,例如 Linux、macOS 和 Windows。

打开终端并输入以下命令:

pip install awscli --upgrade --user

升级选项将告诉 pip 更新所有已安装的要求,--user选项意味着 pip 将在我们的本地目录中安装 AWS CLI,因此它不会触及系统全局安装的任何库。在 Linux 系统上,使用--user选项安装 Python 包时,该包将安装在.local/bin目录中,因此请确保您的path中包含该目录。

只需验证安装是否正常工作,输入以下命令:

aws --version

您应该看到类似于此的输出:

aws-cli/1.14.30 Python/3.6.2 Linux/4.9.0-3-amd64 botocore/1.8.34

在这里,您可以看到 AWS CLI 版本,以及操作系统版本、Python 版本以及当前使用的botocore版本。botocore是 AWS CLI 使用的核心库。此外,boto 是 Python 的 SDK,允许开发人员编写与 Amazon 服务(如 EC2 和 S3)一起工作的软件。

现在我们需要配置 CLI,并且需要准备一些信息。首先,我们需要aws_access_key_idaws_secret_access_key,以及您首选的区域和输出。最常见的值,输出选项,是 JSON。

要创建访问密钥,点击 AWS 控制台页面右上角的用户名下拉菜单,并选择“我的安全凭证”。您将进入此页面:

在这里,您可以配置不同的帐户安全设置,例如更改密码或启用多因素身份验证,但您现在应该选择的是访问密钥(访问密钥 ID 和秘密访问密钥)。然后点击“创建新的访问密钥”,将打开一个对话框显示您的密钥。您还将有下载密钥的选项。我建议您下载并将其保存在安全的地方。

在这里docs.aws.amazon.com/general/latest/gr/rande.html查看 AWS 区域和端点。

现在我们可以配置CLI。在命令行中输入:

aws configure

您将被要求提供访问密钥、秘密访问密钥、区域和默认输出格式。

配置简单电子邮件服务

亚马逊已经有一个名为简单电子邮件服务的服务,我们可以使用它来通过我们的应用程序发送电子邮件。我们将在沙箱模式下运行该服务,这意味着我们还可以向经过验证的电子邮件地址发送电子邮件。如果您计划在生产中使用该服务,可以更改此设置,但是对于本书的目的,只需在沙箱模式下运行即可。

如果您计划在生产环境中运行此应用程序,并希望退出亚马逊 SES 沙箱,您可以轻松地提交支持案例以增加电子邮件限制。要发送请求,您可以转到 SES 主页,在左侧菜单下的“电子邮件发送”部分,您将找到“专用 IP”链接。在那里,您将找到更多信息,还有一个链接,您可以申请增加电子邮件限制。

要使其工作,我们需要有两个可用的电子邮件帐户。在我的情况下,我有自己的域。我还创建了两个电子邮件帐户——donotreply@dfurtado.com,这将是我用来发送电子邮件的电子邮件,以及pythonblueprints@dfurtado.com,这是将接收电子邮件的电子邮件。在线(视频)游戏商店应用程序中的用户将使用此电子邮件地址,我们将下订单以便稍后测试通知。

注册电子邮件

所以让我们开始添加电子邮件。首先我们将注册donotreply@dfurtado.com。登录到 AWS 控制台,在搜索栏中搜索“简单电子邮件服务”。在左侧,您将看到一些选项。在身份管理下,单击“电子邮件地址”。您将看到如下屏幕:

如您所见,列表为空,因此让我们继续添加两封电子邮件。单击“验证新电子邮件地址”,将出现一个对话框,您可以在其中输入电子邮件地址。只需输入您希望使用的电子邮件,然后单击“验证此电子邮件地址”按钮。通过这样做,将向您指定的电子邮件地址发送验证电子邮件,在其中您将找到验证链接。

对第二个电子邮件重复相同的步骤,该电子邮件将接收消息。

现在,再次转到左侧菜单,单击“电子邮件发送”下的 SMTP 设置。

在那里,您将看到发送电子邮件所需的所有配置,您还需要创建 SMTP 凭据。因此,单击“创建我的 SMTP 凭据”按钮,将打开一个新页面,您可以在其中输入您希望的 IAM 用户名。在我的情况下,我添加了python-blueprints。完成后,单击“创建”按钮。凭据创建后,您将看到一个页面,您可以在其中看到 SMTP 用户名和密码。如果愿意,您可以选择下载这些凭据。

创建 S3 存储桶

为了向用户发送模板电子邮件,我们需要将我们的模板复制到 S3 存储桶中。我们可以通过网络轻松完成这项工作,或者您可以使用我们刚刚安装的 AWS CLI。在es-west-2区域创建 S3 存储桶的命令类似于:

aws s3api create-bucket \
--bucket python-blueprints \
--region eu-west-2 \
--create-bucket-configuration LocationConstraint=eu-west-2

在这里,我们使用命令s3api,它将为我们提供与 AWS S3 服务交互的不同子命令。我们调用子命令create-bucket,正如其名称所示,将创建一个新的 S3 存储桶。对于这个子命令,我们指定了三个参数。首先是--bucket,指定 S3 存储桶的名称,然后是--region,指定要在哪个区域创建存储桶 - 在这种情况下,我们将在eu-west-2创建存储桶。最后,区域外的位置需要设置LocationConstraint,以便在我们希望的区域创建存储桶。

实现通知服务

现在我们已经准备好了一切,我们将要用作向在线(视频)游戏商店的客户发送电子邮件的模板文件已经放在了python-blueprints S3 存储桶中,现在是时候开始实现通知服务了。

让我们继续在notifier目录中创建一个名为app.py的文件,首先让我们添加一些导入:

import smtplib
from http import HTTPStatus
from smtplib import SMTPAuthenticationError, SMTPRecipientsRefused

from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart

import boto3
from botocore.exceptions import ClientError

from flask import Flask
from flask import request, Response

from jinja2 import Template
import json

首先,我们导入 JSON 模块,这样我们就可以序列化和反序列化数据。我们从 HTTP 模块导入HTTPStatus,这样我们就可以在服务端点发送响应时使用 HTTP 状态常量。

然后我们导入发送电子邮件所需的模块。我们首先导入smtplib,还有一些我们想要处理的异常。

我们还导入MIMEText,它将用于从电子邮件内容创建MIME对象,以及MIMEMultipart,它将用于创建我们将要发送的消息。

接下来,我们导入boto3包,这样我们就可以使用AWS服务。有一些我们将处理的异常;在这种情况下,这两个异常都与S3存储桶有关。

接下来是一些与 Flask 相关的导入,最后但并非最不重要的是,我们导入Jinja2包来为我们的电子邮件进行模板化。

继续在app.py文件上工作,让我们定义一个常量,用于保存我们创建的S3存储桶的名称:

S3_BUCKET_NAME = 'python-blueprints'

然后我们创建 Flask 应用程序:

app = Flask(__name__)

我们还将添加一个名为S3Error的自定义异常:

class S3Error(Exception):
    pass

然后我们将定义两个辅助函数。第一个是发送电子邮件的函数:

def _send_message(message):

    smtp = smtplib.SMTP_SSL('email-smtp.eu-west-1.amazonaws.com', 
     465)

    try:
        smtp.login(
            user='DJ********DER*****RGTQ',
            password='Ajf0u*****44N6**ciTY4*****CeQ*****4V')
    except SMTPAuthenticationError:
        return Response('Authentication failed',
                        status=HTTPStatus.UNAUTHORIZED)

    try:
        smtp.sendmail(message['From'], message['To'], 
         message.as_string())
    except SMTPRecipientsRefused as e:
        return Response(f'Recipient refused {e}',
                        status=HTTPStatus.INTERNAL_SERVER_ERROR)
    finally:
        smtp.quit()

    return Response('Email sent', status=HTTPStatus.OK)

在这里,我们定义了函数_send_message,它只接受一个参数message。我们通过创建一个封装了 SMTP 连接的对象来启动这个函数。我们使用SMTP_SSL,因为 AWS Simple Email Service 需要 TLS。第一个参数是 SMTP 主机,我们在 AWS Simple Email Service 中创建的,第二个参数是端口,在需要 SSL 连接的情况下将设置为456

然后我们调用登录方法,传递用户名和密码,这些信息也可以在 AWS Simple Email Service 中找到。在出现SMTPAuthenticationError异常的情况下,我们向客户端发送UNAUTHORIZED响应。

如果成功登录到 SMTP 服务器,我们调用sendmail方法,传递发送消息的电子邮件、目标收件人和消息。我们处理一些收件人拒绝我们消息的情况,如果是这样,我们返回INTERNAL SERVER ERROR响应,然后我们退出连接。

最后,我们返回OK响应,说明消息已成功发送。

现在,我们创建一个辅助函数,从 S3 存储桶中加载模板文件并返回一个渲染的模板:

def _prepare_template(template_name, context_data):

    s3_client = boto3.client('s3')

    try:
        file = s3_client.get_object(Bucket=S3_BUCKET_NAME, 
        Key=template_name)
    except ClientError as ex:
        error = ex.response.get('Error')
        error_code = error.get('Code')

        if error_code == 'NoSuchBucket':
            raise S3Error(
             f'The bucket {S3_BUCKET_NAME} does not exist') from ex
        elif error_code == 'NoSuchKey':
            raise S3Error((f'Could not find the file "
               {template_name}" '
               f'in the S3 bucket {S3_BUCKET_NAME}')) from ex
        else:
            raise ex

    content = file['Body'].read().decode('utf-8')
    template = Template(content)

    return template.render(context_data)

在这里,我们定义了函数_prepare_template,它接受两个参数,template_name是我们在 S3 存储桶中存储的文件名,context_data是一个包含我们将在模板中渲染的数据的字典。

首先,我们创建一个 S3 客户端,然后使用get_object方法传递存储桶名称和Key。我们将存储桶关键字参数设置为S3_BUCKET_NAME,我们在此文件顶部定义了该值为python-blueprintsKey关键字参数是文件的名称;我们将其设置为在参数template_name中指定的值。

接下来,我们访问从 S3 存储桶返回的对象中的Body关键字,并调用read方法。这将返回一个包含文件内容的字符串。然后,我们创建一个 Jinja2 模板对象,传递模板文件的内容,并最后调用渲染方法传递context_data

现在,让我们实现一个端点,用于向我们收到订单的顾客发送确认电子邮件:

@app.route("/notify/order-received/", methods=['POST'])
def notify_order_received():
    data = json.loads(request.data)

    order_items = data.get('items')

    customer = data.get('order_customer')
    customer_email = customer.get('email')
    customer_name = customer.get('name')

    order_id = data.get('id')
    total_purchased = data.get('total')

    message = MIMEMultipart('alternative')

    context = {
        'order_items': order_items,
        'customer_name': customer_name,
        'order_id': order_id,
        'total_purchased': total_purchased
    }

    try:
        email_content = _prepare_template(
            'order_received_template.html',
            context
        )
    except S3Error as ex:
        return Response(str(ex), 
 status=HTTPStatus.INTERNAL_SERVER_ERROR)

    message.attach(MIMEText(email_content, 'html'))

    message['Subject'] = f'ORDER: #{order_id} - Thanks for your 
    order!'
  message['From'] = 'donotreply@dfurtado.com'
  message['To'] = customer_email

    return _send_message(message)

在这里,定义一个名为notify_order_received的函数,并使用@app.route装饰器定义路由和调用此端点时允许的方法。路由定义为/notify/order-received/methods关键字参数接受一个允许的 HTTP 方法列表。在这种情况下,我们只允许 POST 请求。

我们通过获取在请求中传递的所有数据来开始这个函数。在 Flask 应用程序中,可以通过request.data访问这些数据;我们使用json.loads方法将request.data作为参数传递,以便将 JSON 对象反序列化为 Python 对象。然后我们获取项目,这是包含在订单中的所有项目的列表,并获取属性order_customer的值,以便获取顾客的电子邮件和顾客的名字。

然后,我们获取订单 ID,可以通过属性id访问,最后,我们获取已发送到此端点的数据的属性total中的总购买价值。

然后,我们创建一个MIMEMultiPart的实例,将alternative作为参数传递,这意味着我们将创建一个MIME类型设置为 multipart/alternative 的消息。之后,我们配置一个将传递给电子邮件模板的上下文,并使用_prepare_template函数传递我们要渲染的模板和包含在电子邮件中显示的数据的上下文。渲染模板的值将存储在变量email_content中。

最后,我们对电子邮件消息进行最终设置;我们将渲染的模板附加到消息中,设置主题、发件人和目的地,并调用_send_message函数发送消息。

接下来,我们将添加一个端点,用于在用户的订单状态更改为Shipping时通知用户。

@app.route("/notify/order-shipped/", methods=['POST'])
def notify_order_shipped():
    data = json.loads(request.data)

    customer = data.get('order_customer')

    customer_email = customer.get('email')
    customer_name = customer.get('name')

    order_id = data.get('id')

    message = MIMEMultipart('alternative')

    try:
        email_content = _prepare_template(
            'order_shipped_template.html',
            {'customer_name': customer_name}
        )
    except S3Error as ex:
        return Response(ex, status=HTTPStatus.INTERNAL_SERVER_ERROR)

    message.attach(MIMEText(email_content, 'html'))

    message['Subject'] = f'Order ID #{order_id} has been shipped'
  message['From'] = 'donotreply@dfurtado.com'
  message['To'] = customer_email

    return _send_message(message)

在这里,我们定义一个名为notify_order_shipped的函数,并使用@app.route装饰器装饰它,传递两个参数和路由,路由设置为/notify/order-shipped/,并定义此端点接受的方法为POST方法。

我们首先获取在请求中传递的数据 - 基本上与之前的函数notify_order_received相同。我们还创建了一个MIMEMultipart的实例,并将MIME类型设置为 multipart/alternative。接下来,我们使用_prepare_template函数加载模板,并使用传递的上下文渲染模板;在这种情况下,我们只传递了顾客的名字。

然后,我们将模板附加到消息中,并进行最终设置,设置主题、发送者和目的地。最后,我们调用_send_message发送消息。

接下来,我们将创建两个电子邮件模板,一个用于向用户发送订单确认通知,另一个用于订单已发货时的通知。

电子邮件模板

现在我们要创建用于向在线(视频)游戏商店的顾客发送通知邮件的模板。

在应用程序的root目录中,创建一个名为templates的目录,并创建一个名为order_received_template.html的文件,内容如下所示:

<html>
  <head>
  </head>
  <body>
    <h1>Hi, {{customer_name}}!</h1>
    <h3>Thank you so much for your order</h3>
    <p>
      <h3>Order id: {{order_id}}</h3>
    </p>
    <table border="1">
      <thead>
        <tr>
          <th align="left" width="40%">Item</th>
          <th align="left" width="20%">Quantity</th>
          <th align="left" width="20%">Price per unit</th>
        </tr>
      </thead>
      <tbody>
        {% for item in order_items %}
        <tr>
          <td>{{item.name}}</td>
          <td>{{item.quantity}}</td>
          <td>${{item.price_per_unit}}</td>
        </tr>
        {% endfor %}
      </tbody>
    </table>
    <div style="margin-top:20px;">
      <strong>Total: ${{total_purchased}}</strong>
    </div>
  </body>
</html>

现在,让我们在同一个目录中创建另一个名为order_shipped_template.html的模板,内容如下所示:

<html>
  <head>
  </head>
  <body>
    <h1>Hi, {{customer_name}}!</h1>
    <h3>We just want to let you know that your order is on its way! 
    </h3>
  </body>
</html>

如果你阅读过第七章,使用 Django 创建在线视频游戏商店,你应该对这种语法很熟悉。与 Django 模板语言相比,Jinja 2 语法有很多相似之处。

现在我们可以将模板复制到之前创建的 S3 存储桶中。打开终端并运行以下命令:

aws s3 cp ./templates s3://python-blueprints --recursive

太棒了!接下来,我们将部署我们的项目。

使用 Zappa 部署应用程序

现在我们来到了本章非常有趣的部分。我们将使用一个名为Zappagithub.com/Miserlou/Zappa)的工具部署我们创建的 Flask 应用程序。Zappa 是一个由Rich Jones开发的 Python 工具(Zappa 的主要作者),它使得构建和部署无服务器 Python 应用程序变得非常容易。

安装非常简单。在我们用来开发这个项目的虚拟环境中,你可以运行pipenv命令:

pipenv install zappa

安装完成后,你可以开始配置。你只需要确保你有一个有效的 AWS 账户,并且 AWS 凭据文件已经就位。如果你从头开始阅读本章并安装和配置了 AWS CLI,那么你应该已经准备就绪了。

要为我们的项目配置 Zappa,你可以运行:

zappa init

你会看到 ASCII Zappa 标志(非常漂亮顺便说一句),然后它会开始问一些问题。第一个问题是:

Your Zappa configuration can support multiple production stages, like 'dev', 'staging', and 'production'.
What do you want to call this environment (default 'dev'):

你可以直接按Enter键默认为dev。接下来,Zappa 会询问 AWS S3 存储桶的名称:

Your Zappa deployments will need to be uploaded to a private S3 bucket.
If you don't have a bucket yet, we'll create one for you too.
What do you want call your bucket? (default 'zappa-uc40h2hnc'):

在这里,你可以指定一个已存在的环境或创建一个新的环境。然后,Zappa 将尝试检测我们要部署的应用程序:

It looks like this is a Flask application.
What's the modular path to your app's function?
This will likely be something like 'your_module.app'.
We discovered: notify-service.app
Where is your app's function? (default 'notify-service.app'):

正如你所看到的,Zappa 自动找到了notify-service.py文件中定义的 Flask 应用程序。你可以直接按Enter键设置默认值。

接下来,Zappa 会问你是否想要全局部署应用程序;我们可以保持默认值并回答n。由于我们在开发环境中部署此应用程序,我们实际上不需要全局部署它。当你的应用程序投入生产时,你可以评估是否需要全局部署。

最后,完整的配置将被显示出来,在这里你需要更改并进行任何必要的修改。你不需要太担心是保存配置还是不保存,因为 Zappa 设置文件只是一个文本文件,以 JSON 格式保存设置。你可以随时编辑文件并手动更改它。

如果一切顺利,你应该在应用程序的根目录下看到一个名为zappa_settings.json的文件,内容与下面显示的内容类似:

{
    "dev": {
        "app_function": "notify-service.app",
        "aws_region": "eu-west-2",
        "project_name": "notifier",
        "runtime": "python3.6",
        "s3_bucket": "zappa-43ivixfl0"
    }
}

在这里,你可以看到dev环境设置。app_function指定了我在notify-service.py文件中创建的 Flask 应用程序,aws_region指定了应用程序将部署在哪个地区 - 在我的情况下,由于我在瑞典,我选择了eu-west-2伦敦)这是离我最近的地区。project_name将默认为你运行zappa init命令的目录名称。

然后我们有运行时,它指的是你在应用程序中使用的 Python 版本。由于我们为这个项目创建的虚拟环境使用的是 Python 3所以这个属性的值应该是 Python 3 的一个版本-在我的情况下,我安装了 3.6.2。最后,我们有 Zappa 将用来上传项目文件的 AWS S3 存储桶的名称。

现在,让我们部署刚刚创建的应用程序!在终端上,只需运行以下命令:

zappa deploy dev

Zappa 将为您执行许多任务,最后它将显示应用程序部署的 URL。在我的情况下,我得到了:

https://rpa5v43ey1.execute-api.eu-west-2.amazonaws.com/dev

你的情况可能会略有不同。因此,我们在 Flask 应用程序中定义了两个端点,/notify/order-received/notify/order-shipped。可以使用以下 URL 调用这些端点:

https://rpa5v43ey1.execute-api.eu-west-2.amazonaws.com/dev/notify/order-received
https://rpa5v43ey1.execute-api.eu-west-2.amazonaws.com/dev/notify/order-shipped

如果你想查看部署的更多信息,可以使用 Zappa 命令:zappa status

在下一节中,我们将学习如何限制对这些端点的访问,并创建一个可以用来进行 API 调用的访问密钥。

限制对 API 端点的访问

我们的 Flask 应用程序已经部署,在这一点上任何人都可以向 AWS API Gateway 上配置的端点发出请求。我们想要的是只允许包含访问密钥的请求访问。

为了做到这一点,登录到我们在 AWS 控制台上的帐户,并在 Services 菜单中搜索并选择 Amazon API Gateway.。在左侧菜单上的 API 下,你将看到 notifier-dev:

太棒了!在这里,我们将定义一个使用计划。点击使用计划,然后点击创建按钮,你将看到一个创建新使用计划的表单。输入名称up-blueprints,取消启用节流和启用配额的复选框,然后点击下一步按钮。

下一步是关联 API 阶段。到目前为止,我们只有 dev,所以让我们添加 dev 阶段;点击添加 API 阶段按钮,并在下拉列表中选择 notifier-dev 和阶段 dev。确保点击检查按钮,在下拉菜单的同一行,否则下一步按钮将不会启用。

点击下一步后,你将不得不向我们刚刚创建的使用计划添加一个 API 密钥。在这里你将有两个选项;添加一个新的或选择一个现有的:

让我们添加一个新的。点击标记为创建 API 密钥并添加到使用计划的按钮。API 密钥创建对话框将显示,只需输入名称notifiers-devs,然后点击保存。

太棒了!现在,如果你在左侧菜单中选择 API Keys,你应该会在列表中看到新创建的 API 密钥。如果你选择它,你将能够看到有关密钥的所有详细信息:

现在,在左侧菜单中,选择 API -> notifier-dev -> 资源,并在资源选项卡上,选择根路由/。在右侧面板上,你可以看到/方法:

请注意,ANY 表示授权无,API 密钥设置为不需要。让我们更改 API 密钥为必需。在资源面板上,点击 ANY,现在你应该看到一个类似于以下截图的面板:

点击 Method Request:

点击 API Key Required 旁边的笔图标,在下拉菜单中选择值 true.

太棒了!现在,对于 dev 阶段的 API 调用应该限制为请求中包含 API 密钥 notifier-dev。

最后,转到 API Keys,点击 notifier-keys。在右侧面板中,在 API Key中,点击显示链接,API 密钥将显示出来。复制该密钥,因为我们将在下一节中使用它。

修改订单服务

现在我们已经部署了通知应用程序,我们必须修改我们之前的项目,订单微服务,以利用通知应用程序,并在新订单到达时发送通知,以及在订单状态更改为已发货时发送通知。

我们首先要做的是在settings.py文件中包含通知服务 API 密钥和其基本 URL,在名为order的目录中,在订单的root目录中,并在文件末尾包含以下内容:

NOTIFIER_BASEURL = 'https://rpa5v43ey1.execute-api.eu-west-2.amazonaws.com/dev'

NOTIFIER_API_KEY = 'WQk********P7JR2******kI1K*****r'

用您环境中对应的值替换这些值。如果您没有NOTIFIER_BASEURL的值,可以通过运行以下命令来获取它:

zappa status

您想要的值是 API Gateway URL。

现在,我们要创建两个文件。第一个文件是在order/main目录中名为notification_type.py的文件。在这个文件中,我们将定义一个包含我们想要在我们的服务中提供的通知类型的枚举:

from enum import Enum, auto

class NotificationType(Enum):
    ORDER_RECEIVED = auto()
    ORDER_SHIPPED = auto()

接下来,我们将创建一个帮助函数的文件,该函数将调用通知服务。在order/main/目录中创建一个名为notifier.py的文件,并包含以下内容:

import requests
import json

from order import settings

from .notification_type import NotificationType

def notify(order, notification_type):
    endpoint = ('notify/order-received/'
                if notification_type is NotificationType.ORDER_RECEIVED
                else 'notify/order-shipped/')

    header = {
        'X-API-Key': settings.NOTIFIER_API_KEY
    }

    response = requests.post(
        f'{settings.NOTIFIER_BASEURL}/{endpoint}',
        json.dumps(order.data),
        headers=header
    )

    return response

从顶部开始,我们包括了一些导入语句;我们导入请求以执行对通知服务的请求,因此我们导入 json 模块,以便我们可以将数据序列化为要发送到通知服务的格式。然后我们导入设置,这样我们就可以获得我们在基本 URL 到通知服务和 API 密钥方面定义的常量。最后,我们导入通知类型枚举。

我们在这里定义的notify函数接受两个参数,订单和通知类型,这些值在枚举NotificationType中定义。

我们首先决定要使用哪个端点,这取决于通知的类型。然后我们在请求的HEADER中添加一个X-API-KEY条目,其中包含 API 密钥。

之后,我们进行POST请求,传递一些参数。第一个参数是端点的 URL,第二个是我们将发送到通知服务的数据(我们使用json.dumps函数,以便以 JSON 格式发送数据),第三个参数是包含标头数据的字典。

最后,当我们收到响应时,我们只需返回它。

现在,我们需要修改负责处理POST请求以创建新订单的视图,以便在数据库中创建订单时调用notify函数。让我们继续打开order/main目录中的view.py文件,并添加两个导入语句:

from .notifier import notify
from .notification_type import NotificationType

这两行可以添加在文件中的第一个类之前。

完美,现在我们需要更改CreateOrderView类中的post方法。在该方法中的第一个返回语句之前,在我们返回201CREATED)响应的地方,包括以下代码:

 notify(OrderSerializer(order),
        NotificationType.ORDER_RECEIVED)

因此,在这里我们调用notify函数,使用OrderSerializer作为第一个参数传递序列化的订单,以及通知类型 - 在这种情况下,我们想发送一个ORDER_RECEIVED通知。

我们将允许订单服务应用程序的用户使用 Django 管理界面更新订单。在那里,他们将能够更新订单的状态,因此我们需要实现一些代码来处理用户在 Django 管理界面上进行的数据更改。

为此,我们需要在order/main目录中的admin.py文件中创建一个ModelAdmin类。首先,我们添加一些导入语句:

from .notifier import notify
from .notification_type import NotificationType
from .serializers import OrderSerializer
from .status import Status

然后我们添加以下类:

class OrderAdmin(admin.ModelAdmin):

    def save_model(self, request, obj, form, change):
        order_current_status = Status(obj.status)
        status_changed = 'status' in form.changed_data

        if (status_changed and order_current_status is 
           Status.Shipping):
            notify(OrderSerializer(obj), 
            NotificationType.ORDER_SHIPPED)

        super(OrderAdmin, self).save_model(request, obj, form,  
        change)

在这里,我们创建了一个名为OrderAdmin的类,它继承自admin.ModelAdmin,并且我们重写了save_model方法,这样我们就有机会在保存数据之前执行一些操作。首先,我们获取订单的当前状态,然后我们检查字段status是否在已更改的字段列表之间。

if 语句检查状态字段是否已更改,如果订单的当前状态等于Status.Shipping,那么我们调用notify函数,传递序列化的订单对象和通知类型NotificationType.ORDER_SHIPPED

最后,我们调用超类的save_model方法来保存对象。

这个谜题的最后一部分是替换这个:

admin.site.register(Order)

替换为:

admin.site.register(Order, OrderAdmin)

这将为Order模型注册管理模型OrderAdmin。现在,当用户在 Django 管理界面中保存订单时,它将调用OrderAdmin类中的save_model

测试所有部分的整体功能

现在我们已经部署了通知应用程序,并且还对订单服务进行了所有必要的修改,是时候测试所有应用程序是否能够一起运行了。

打开一个终端,切换到您实施在线(视频)游戏商店的目录,并执行以下命令启动 Django 开发服务器:

python manage.py runserver

此命令将启动默认端口8000上运行的 Django 开发服务器。

现在让我们启动订单微服务。打开另一个终端窗口,切换到您实施订单微服务的目录,并运行以下命令:

python manage.py runserver 127.0.0.1:8001

现在我们可以浏览http://127.0.0.1:8000,登录应用程序并向购物车中添加一些商品:

如您所见,我添加了三件商品,此订单的总金额为 32.75 美元。单击“发送订单”按钮,您应该会在页面上收到订单已发送的通知。

太好了!到目前为止一切都按预期进行。现在我们检查用户的电子邮件,以验证通知服务是否实际发送了订单确认电子邮件。

还不错,用户刚刚收到了邮件:

请注意,发件人和收件人的电子邮件是我在 AWS 简单电子邮件服务中注册的。

现在让我们登录订单服务的 Django 管理界面,并更改相同订单的状态,以验证订单已发货的确认电子邮件是否会发送给用户。请记住,只有当订单将其状态字段更改为已发货时,才会发送电子邮件。

浏览http://localhost:8001/admin/并使用管理员凭据登录。您将看到一个带有以下菜单的页面:

点击订单,然后选择我们刚刚提交的订单:

在下拉菜单“状态”中,将值更改为“发货”,然后单击“保存”按钮。

现在,如果我们再次验证订单客户的电子邮件,我们应该已经收到另一封确认订单已发货的电子邮件:

总结

在本章中,您学习了有关无服务器函数架构的更多信息,如何使用 Web 框架 Flask 构建通知服务,以及如何使用伟大的项目 Zappa 将最终应用程序部署到 AWS Lambda。

然后,您学习了如何安装、配置和使用 AWS CLI 工具,并使用它将文件上传到 AWS S3 存储桶。

我们还学习了如何将我们在第七章Django 在线视频游戏商店中开发的 Web 应用程序与我们在第八章订单微服务中开发的订单微服务与无服务器通知应用程序集成。

posted @ 2025-09-19 10:36  绝不原创的飞龙  阅读(16)  评论(0)    收藏  举报