Django-微服务设计指南-全-

Django 微服务设计指南(全)

原文:Designing Microservices with Django

协议:CC BY-NC-SA 4.0

一、什么是服务?

早在农业革命之前,服务就已经存在于人类之中。每当我们走进商店或餐馆,我们都在使用一种服务。有些服务比其他服务更复杂,提供给我们的商品更符合我们的口味,或者需要更少的工作,而有些服务更专业,专注于一项任务,并且做得非常好。让我们看一个简单的例子。

市场和餐馆都可以算作服务,尽管它们提供给我们不同的东西,但它们都给我们一般的食物。当你进入一个市场,你可以从各种各样的卖家那里买到各种各样的食材。之后,你可以在家里将所有这些材料组合成你喜欢的各种菜肴。然而,一家豪华的餐馆会为你提供现场制作的美味佳肴。在餐馆的后台有许多流程和系统在工作,其中大部分你都不知道,但是在你的请求被处理后,你的饭菜就被送到了。

当我们谈论服务时,餐馆/市场摊位的类比只是我们可以提出的众多类比之一。从概念上讲,它们的工作方式与软件中的服务相同。

  1. 您,即客户端,向服务发出请求。

  2. 服务,在我们的例子中是餐馆,接收你的订单。

  3. 后台的一些算法和过程(菜谱和厨师)会为你准备一个回应。

  4. 您会收到上述回复(至少在大多数情况下)。

一个服务可以是你的应用,它通过人们的电话提供他们昨天离家次数的信息。或者一个服务可以是一个大规模的持续集成系统,你只能看到运行你的应用测试的一小部分。服务也可以是软件中的小东西,一个提供接口获取匈牙利布达佩斯时间信息的应用。

在本书中,我们将重点关注驻留在 web 上的服务,为最终用户和其他内部服务提供数据。让我们先来看看行业中出现的不同定义。

服务行话

在过去的几年中,围绕服务设计、软件即服务、微服务、单片、面向服务的体系结构等等有很多讨论。在下图中,可以清楚地看到微服务越来越受欢迎。图 1-1 提供了过去五年谷歌搜索“微服务”的图形视图。

img/481995_1_En_1_Fig1_HTML.jpg

图 1-1

2014 年以来“微服务”一词的流行,谷歌

在接下来的几节中,我们将试着弄清楚其中每一个的含义,以及当你在这样的系统中工作时需要记住的重要术语。请记住,这些术语不是刻在石头上的。

软件即服务

术语“软件即服务”(或 SaaS)主要描述一种许可模式,在这种模式下,你可以为某种在线服务的使用付费。这些软件中的大部分都存在于云中,并为最终用户提供各种方式来修改和查询他们系统中的数据。Spotify 就是一个例子,这是一个在线音乐流媒体软件,最终用户可以用它来听音乐和创建自己的播放列表。此外,Spotify 有一个广泛的软件界面和软件包,工程师可以使用它们以编程方式获取和修改 Spotify 云中的数据。

面向服务的架构

面向服务的架构(或 SoA)可能是业界最受欢迎的术语之一。简单地说,这种风格的架构设计比任何东西都更支持服务。正如我们在上面所了解的,服务需要服务于某种业务需求,并且需要围绕现实世界的需求进行建模。服务需要是自包含的,并且有一个干净的接口,通过它可以进行通信。它们是独立部署和开发的,在抽象层次上代表一个功能单元。该架构还涉及这些服务之间使用的通信协议。一些软件即服务公司使用面向服务的架构向他们的最终用户交付高质量的产品。

整体服务

对软件工程师来说,这是十年来最可怕的词汇之一。单一的应用和单一的服务是单一的服务,它们增长得太大了,以至于无法进行推理。“太大”到底意味着什么,将是本书的核心话题之一。我们还会看到,这个可怕的词不一定意味着不好的事情。

微服务

软件工程师十年来的另一个可怕的词(尽管原因不同)。简而言之,微服务是一种存在于面向服务的架构中的服务,并且易于推理。这些是松散耦合的轻量级组件,具有定义良好的接口、单一用途,并且易于创建和处理。由于细粒度的性质,更多的人可以并行处理它们,特性的所有权成为组织要解决的一个更干净的问题。

现在,我们已经看了一下高层次的定义,让我们深入研究一下独石。

了解整块石头

正如我们所了解到的,monoliths 是一个开发人员甚至一个开发团队都难以理解的庞大的代码库,其复杂性已经达到了一个程度,即使只更改几行代码也会在其他部分产生意想不到的未知后果。

首先,我们需要在这里打下一个基础,那就是独石本身并不坏。有许多公司已经在单片应用上建立了成功的 IT 业务。

事实上,独石实际上对发展你的业务很有帮助。只需很少的管理费用,您就可以为您的产品添加更多的功能。在 IDE 中只需几个组合键就可以轻松导入模块,这使得开发变得轻而易举,并让您对向雇主交付大量代码(无论是高质量还是低质量)充满信心。就像生活中的大多数成长过程一样,公司和软件需要快速启动并经历快速迭代。软件需要快速增长,这样企业才能得到更多的钱,这样软件才能增长,这样企业才能得到更多的钱。你会看到它在哪里生长。所以下次你参加一个会议,听到一个演讲者说独石是旧设计的系统,一定要半信半疑。顺便提一句,我想说的是,如果你的初创公司正在努力维护你的遗留单片应用,这通常是一个健康业务的标志。

然而,趋势表明,随着时间的推移,软件的可靠性和可伸缩性变得越来越重要,这种快速增长往往会放缓。拥有单个应用可能会为您的团队赢得快速部署和易于维护的基础设施,但这也意味着单个工程师的错误可能会导致整个应用瘫痪。越多的人开始在一个应用上工作,他们就越开始干扰彼此的工作,他们就越需要进行不必要的交流,他们就越需要等待他们的构建和部署。

想象一下下面的情况:你是一名基础设施工程师,在一家通过互联网提供视频流服务的公司工作。该公司正在挣扎,但不知何故,CMO 设法让你的国家的政府流总统选举演讲。这对你的公司来说是一个惊人的增长机会,所以有大量的投资进入,以确保系统很好地承担负载。在活动开始前的几周,你已经做了大量的负载测试,并指示后端工程师修复某些不能正常运行的代码部分。你已经说服首席运营官投入一些钱到更重的基础设施上,这样系统的负荷就会减轻。演讲的一切看起来都很完美,你和你的同事在活动期间焦急地等待着,以体验你在过去几周如此专注地工作的奇迹。然后它发生了。就在演讲之前,溪流停止了。你完全不知道发生了什么,并开始疯狂地寻找答案。你试着用 SSH 连接到机器上,但没有成功,被无情地拒绝了,时间一分一秒地过去。停电已经 13 分钟了,演讲也快结束了。Twitter 在你的公司上贴上可以观看总统演讲的标签,并指责首席执行官的无能。盛怒之下,决定硬重启你正在使用的云提供商网站上的机器,但为时已晚。活动结束了。数百万人感到失望,该公司的状况不太好,无法获得下一轮投资。两天后,你回去工作,开始调查发生了什么。原来,ads 团队在 2 天前推出了一些更改,导致后端应用出现内存泄漏。服务器上的内存已满,阻塞了所有进程。这时,您开始梦想公司的基础设施有一个更好、更可靠的未来。

图 1-2 简单展示了该视频流公司在应用中发生的事情。

img/481995_1_En_1_Fig2_HTML.png

图 1-2

由紧密耦合的损坏组件导致的全面停机

上面的故事,尽管完全是虚构的,却发生在每个成功的软件系统和每个成功的软件公司的生命周期中。如果你认为脸书或谷歌在其一生中从未在重要时刻出现过长达一小时的宕机,那你就大错特错了。你可以在网上找到关于各种谷歌 1 宕机的文档,其中(有时)详细描述了系统的哪些部分导致了这个和那个部分在这段时间内宕机。在这些中断之后,他们学习、适应并使他们的系统更有弹性,这样同样的问题就不会再次发生。

许多人没有想到单片应用的一个方面,我喜欢称之为“单片瀑布”或“级联单片”架构。这基本上意味着当你在一个单一的应用上工作时,你几乎被鼓励以一种单一的方式来设计你的代码和数据的架构。围绕你的模块设计接口变成了一件苦差事和额外开销,所以你只需要导入你需要的代码行。为订阅信息添加一个新的数据库会花费太多的时间,而且只会带来更多的复杂性,所以您需要在用户和产品信息所在的数据库中创建模型。如果你在一个整体上工作,你的代码和架构的所有层都将是整体的。自然,严格的编码指南、架构原则和富有挑战性的工程文化可以构建和维护一个具有清晰内部接口边界的整体,这个整体以后可以更容易地分解为微服务。

现在,在这一部分的结尾不提及独石的好处是不公平的。抛开上面所有的恐怖故事和消极因素,建议是利用单一模型来发展你的业务和工程。只要确保紧紧抓住方向盘,每个人都在技术债务上保持一致。

现在我们已经了解了 monolith 的外观和行为,我们将看看它在本书中的对应部分,微服务。

了解微服务

简单回顾一下:微服务是一个单一用途的软件应用,驻留在 web 上的某个地方,是一个小型的代码库,即使是一个工程师也可以很容易地对其进行推理。

就像我们了解到独石不是撒旦的后代一样,我们将了解到微服务也不是银弹。

让我们回到上一个故事中的基础设施工程师。事件发生后,他与公司的其他人进行了一次事后分析(如果你不确切知道那是什么,不要担心,我们将在本书的后面探讨它),并得出结论,公司可以采取的避免像几周前在新年致辞中那样的灾难的最佳解决方案之一是适应和实施基于微服务的架构,并放弃整体架构。他概述了工程师和投资者将承担的成本,并确保每个人都了解迁移的利弊。不久之后,董事会批准了这个项目。一段时间后,该公司已经有十几项服务在云中运行(仍然包括 monolith 的剩余部分),另一场运动开始了。这一次负载很高,但系统保持了弹性。尽管聊天服务停止了,但视频播放仍然正常,这确保了公司的声誉完好无损,投资者把钱投到了好的地方。我们的工程师回到床上,知道核心业务不会再有问题,从此过上了幸福的生活。在下图 1-3 中,我们可以看到我们是如何从之前的图中失去耦合架构的,现在只有部分中断。

img/481995_1_En_1_Fig3_HTML.png

图 1-3

松散耦合的组件可能只会导致部分停机

就这样吗?嗯,不。在上面的故事中,我们公司有一个运行聊天系统的服务,但核心业务仍然最有可能驻留在 monolith 中。如果聊天系统在那里,这是完全可能的营销活动也将是一场灾难,导致该公司真正的结束。总之,在关键情况下,在系统中构建或提取哪怕是很小的模块都会带来巨大的成功。

当你去参加会议的时候,你会听到很多这样令人惊奇的故事,我自己也讲过几个。在大多数成功故事中,都有一件微小但意义重大的事情,是演讲者(包括我自己)不太喜欢谈论的。这是投入的时间量,以确保上面描述的系统既能在软件层面上工作,也能在组织层面上工作。在大多数情况下,实现一个有效的微服务架构需要数年的工程工作,不仅包括编码,还包括架构计划、大量的工具、当前系统的研究、发布计划、会议、会议,最后但同样重要的是耐心。

请记住,像这样的转换是一种很好的方式,可以让您组织中的工程师成长,并测试他们的知识,不仅仅是技术方面,还有组织方面。这是一个很好的机会,可以留住贵公司的高级工程师,并使正式员工和初级员工能够探索他们还不需要接触的新领域。

在这一点上,用微服务开始你的 entre 架构似乎是一个好主意。让我们在下一节探讨这个选项。

早期设计选择

从经验来看,如今公司可能犯的最大错误之一是开始使用微服务构建他们的架构。尽管今天的工具远远优于 5 年前的工具。我们有令人惊叹的协调器,如 Kubernetes,容器化工具,如 Docker,AWS 上的各种服务,如 EBS,它们帮助我们构建和部署服务。然而,尽管有惊人的工具,开发可能会很快陷入困境。

企业在开始时需要的东西(正如我们已经简要讨论过的)是敏捷性。数据、逻辑和表示层的快速变化,以提供尽可能多的用户特性。另一方面,设计和构建服务需要时间、奉献和核心流程。随后,用微服务架构开始你的工程文化和开发过程可能会导致灾难。

我自己工作的系统从一开始就被设计成面向“微服务”的。理论上,一切看起来都很棒。这些服务有清晰的接口定义,并且都有所有者。这些服务被编译在一起,并通过一个异步的、基于事件的系统相互通信。构建过程使用一个非常成熟的构建协调器作为基础系统,并经过优化以帮助所有工程师取得成功。受到如此多的赞扬后,你可以预料结果是负面的。是的,它是。理论上一切看起来都很完美,但实际上大多数事情都失败了。系统过于分散,依赖性成了负担,而不是促进因素。工程师们需要的是速度,而不是一个当他们更新另一个组件时需要升级每个组件版本的系统。不久之后,维护接口定义变成了一场噩梦,因为很难理解组件之间的交叉依赖和版本控制。工程师们更多地是在寻找定制的、不受监管的捷径,而不是目前的工作方式。构建系统,虽然非常专业,但被认为是为时过早,减缓了开发人员的速度,而不是使他们能够加速开发。

后来,该公司决定将微服务架构合并到一个整体应用中,此后速度开始加快。最终证明该系统是成功的。最终,代码库变得太大,团队开始考虑再次拆分的方法。

从微服务开始是个坏主意吗?当工程规范要求你应该转向另一个方向时,转向一个整体是个好主意吗?

第一个问题的答案不是那么明显。如果公司规模更大,工程文化是围绕这种类型的架构设计的,并且会有更多的时间投入到工具开发中,那么它可能会成功。然而,中小型公司通常负担不起专门的工具工程师。另一方面,拥有闲置产能的大公司可以从一开始就使用这些理念来设计他们的系统。

至于第二个问题,不管你问谁,答案都会是响亮的“是”。在这个故事中,一个很好的例子是能够回头看,并后退两步,从长远来看,向前迈出巨大的一步。

这个故事的寓意之一是,为了实现这一飞跃,你不仅需要了解行业中的技术和最佳实践,还需要了解你的业务、组织以及在你公司工作的人。可能适用于其他公司(在某些情况下,甚至只是你的工程组织中的其他团队)的东西对你来说可能是一个巨大的失误。

摘要

在这一章中,我们学习了在办公室喝咖啡时与同事谈论微服务时可以使用的基本语言学。我们还了解到,monoliths 本身并不坏,它们甚至可以帮助您快速发展业务,就像如果您在错误的时间使用微服务会减慢您的速度一样,但从长远来看,可以更好地改变您的用户与您的系统交互的方式。在本书的其余部分,我们将探索对您,工程师、产品所有者、架构师的确切要求,以确保适应这种开发心态将是对您公司的良好补充,而不是敏捷灾难。

二、一点 Django

在设计软件服务时,需要考虑的最重要的一点是编程语言和在项目中使用的相关框架。

出于教育目的,在本书中,我们将使用 Python 编程语言和 Django Web 框架以及相关的工具链来实现我们的目标。为了稍微了解一下知识,我们将学习 Django 的核心概念,并自己构建一个服务。

引入问题

在本书中,我们将致力于尽可能贴近现实生活的问题。因此,我们将根据业务需求开展工作,并尝试遵循整个产品的服务设计流程,而不仅仅是其中的一小部分。以下是我们的问题空间:

提扎。Tizza 是一个移动优先应用,用户可以通过照片和对披萨的描述来决定他们是否喜欢披萨。喜欢一个地方后,用户将收到使用该应用组织到给定比萨饼店的团体参观的通知。最终用户有一个朋友列表,他们应该能够管理。用户可以设置私人(仅朋友)和公共(不仅仅是朋友)事件。

自然,我们公司需要以某种方式赚钱。为此,我们从比萨店收取广告费/排名费,以放在我们的平台上。根据销售系统,我们需要确定一家公司在我们的排名中的位置,以及最终用户应该以什么顺序查看餐厅。

入门指南

在我们深入研究服务设计之前,我们将在终端中运行几个命令来开始我们的 Django 项目。

注意

强烈建议将您的代码和依赖项组织到虚拟环境或容器中。如果您不完全熟悉这些概念,我强烈推荐您使用 virtualenv 或 Docker 查看 Python 开发。

要安装 Django,您只需在终端中运行以下代码:

pip install django

当我们看到 Django 应用作为一个整体运行时,我们称之为项目。要创建 Django 项目,只需在终端中执行以下命令:

django-admin startproject tizza

这样,一个简单的 Django 应用将被创建在一个名为 tizza 的文件夹中,如图 2-1 所示。

img/481995_1_En_2_Fig1_HTML.png

图 2-1

裸露 django 文件夹结构

如果我告诉你,在这一点上,你已经有一个功能正常的网站?在您的终端中键入以下内容:

python manage.py runserver

并在您的浏览器中访问 http://localhost:8000

您应该会看到图 2-2 中的屏幕。

img/481995_1_En_2_Fig2_HTML.jpg

图 2-2

Django 已成功安装

恭喜你!现在真正的工作开始了。

Django 应用

Django 在其通用文件夹结构中有第二层逻辑。这些被称为应用。命名可能会有点混乱,因为它们与智能手机上运行的软件无关。应用通常用作业务案例和/或应用功能之间的逻辑分隔符。你可以认为项目能够在不同的应用之间进行编排,而一个应用只能属于一个项目。要创建新的应用,我们只需运行以下命令:

python manage.py startapp pizza

现在我们的目录结构已经更新,如图 2-3 所示。

img/481995_1_En_2_Fig3_HTML.png

图 2-3

带 app 的裸 django 文件夹结构

默认情况下,有些应用在 Django 项目中,所以我们不需要每次创建新项目时都处理它们。它们是与 Django 本身一起安装的,这就是为什么在文件夹结构中看不到它们。其中之一是包含用户创建和授权工具的 auth 应用。我们将大量使用的另一个工具是管理包,它让我们对应用的数据有很大的控制权。

模型和 ORM 的力量

每当您收到一个新功能或开始开发一个全新的产品或应用时,您通常需要考虑的第一件事就是驱动它的数据。一旦数据搞清楚了,借助 Django 和 ORMs 的力量,我们可以马上开始。

什么是 ORM?

ORM(或对象关系映射)充当数据库和应用之间的抽象层。您不希望每次在数据库上运行原始查询时都创建封送和解封代码。我们不仅在讨论漏洞问题,还在讨论这些未受保护的系统可能存在的巨大安全问题。

相反,我们将使用 ORM,大多数语言和 web 框架都有类似的东西。对于不使用 Django(或者不需要整个 web 框架)的 Python 开发人员来说,有 SQLAlchemy。对于 PHP 开发人员来说,这是一个信条,对于 Ruby 爱好者来说,这就是 ActiveRecord。简而言之,我强烈建议您开始使用并习惯 ORM,如果您还没有的话,因为它们会让您和您公司的其他开发人员的生活更加简单。

为了让您对 ORMs 有更多的了解,请想象以下情况:您是一名刚从学校毕业的工程师,渴望完成您的第一份工作,即维护和扩展 web 应用。您得到的第一个任务是从数据库中获取并显示一些数据。幸运的是,你上过数据库和网络课程,所以你对数据应该如何流动有一个模糊的概念。您编写的第一段代码如清单 2-1 所示。

import database

def get_pizzas(pid):
    cursor = database.cursor()
    return cursor.execute(f"""
SELECT * FROM pizzas WHERE id = {pid};
""")

Listing 2-1Simple query to get a pizza

代码本身并不可怕,但是有几个非常严重的问题,有经验的工程师可能已经注意到了:

  1. 保安。如果 pizza id 来自用户(很可能来自用户),他们可以简单地通过巧妙的注入删除整个数据库。如果您希望原始查询具有额外的安全性,您需要自己实现它。对你来说可能是一个很好的练习,但是,对你的生意来说绝对是一个糟糕的练习。

  2. 可维护性:使用文本对象是...至少可以说很难。在上面这段代码中,您在文本中隐藏了一个条件,没有 IDE 能够帮助您重构它。此外,如果查询越来越多,问题也会越来越多。这里您可能要考虑的另一个方面是多个数据库引擎的维护。如果您想将数据库从 Postgres 更改为 MySQL,您可能需要手动更新所有查询,这很容易出错。

总而言之,像上面这样写代码是危险的,会给数据完整性和工程寿命带来不必要的风险。当然,有些问题是无法用我们将要学习的方法解决的,在这种情况下,你总是可以使用原始的 SQL 查询,只是要特别注意你输入的内容。

披萨

在 Django 中,我们需要在应用中创建一个 models.py 文件来开始使用 ORM。该文件应该如下所示:

# pizza/models.py
from django.db import models

class Pizza(models.Model):
    title = models.CharField(max_length=120)
    description = models.CharField(max_length=240)

Listing 2-2Database model for our pizza

您在上面看到的是一个数据库表作为 Django 模型的表现形式。Pizza 类继承了 Django 提供的 Model 类。这将通知系统有一个我们想要使用的新数据类型,以及我们在接下来的几行中列出的字段。在我们的例子中,标题和描述都是字符字段。为了简单起见,我们将在数据库中创建我们的表。为此我们将利用移民的力量。

迁移

迁移就是从您的模型中生成脚本,您可以使用这些脚本自动搭建您的数据库,而无需运行手动 CREATE TABLE 等操作。迁移是一个非常强大的工具,我推荐阅读更多关于它的内容,就本书而言,我们只使用最基本的内容。

python manage.py makemigtations

在您的项目目录中运行上面的命令将导致 Django 收集每个应用中的模型,这些模型在 insalled_apps 下的设置文件中注册,然后为它们创建一个迁移计划。迁移计划本质上是包含数据库操作的 Python 文件,这些操作按照将在数据库上运行的执行顺序排列。也就是说,如果您运行以下命令:

python manage.py migrate

现在,您的表应该已经准备好了,该是我们探索数据库中的内容的时候了。

关于迁移的更多信息…

因此,您只需创建一个模型,在 shell 中运行 2 个命令,突然您就有了一个数据库设置,其中包含了您想要处理的所有表。刚刚发生了什么?生活怎么这么神奇?

Django 做的事情实际上非常不可思议。当您运行 makemigrations 命令时,Django 收集在您的应用中创建的所有模型(每个应用),在内存中注册它们,并解析它们的元数据,例如其中需要的列、索引和序列。之后,它运行一个代码生成模块,该模块将在数据库元信息的先前状态和当前状态之间创建一个差异,并在 migrations 文件夹中呈现一个文件。这个文件是一个简单的 Python 文件,可能看起来像这样:

from django.db import migrations, models

class Migration(migrations.Migration):
    initial = True
    dependencies = [
    ]

    operations = [
        migrations.CreateModel(
            name='Pizza',
            fields=[
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
                ('title', models.CharField(max_length=120)),
                ('description', models.CharField(max_length=240)),
            ],
        ),
    ]

Listing 2-3Migration file for the initial pizza

您可以查看并根据需要进行修改。请务必查看在 pizza 应用的 migrations 文件夹中创建的迁移文件。尝试向模型中添加一个新字段,再次运行 makemigrations,并查看所创建的两个文件之间的差异。

当您使用 migrate 命令应用迁移时,您只需使用数据库引擎按顺序执行这些 Python 文件。这里需要注意的一点是,根据表的大小、分配的资源以及迁移的复杂性,在实时数据库上运行迁移可能是一项开销很大的操作。我总是建议在实际环境中执行迁移时要小心!

移民并不总是 Django 核心的一部分。在很长一段时间里,我们不得不使用外部工具或原始 SQL 脚本来生成数据库。如今,几乎所有主流框架都有这样或那样的方式来运行迁移。请务必研究文档中的更多技术细节!

简单的 ORM 示例

访问项目运行时的行为方式非常简单。只需执行:

python manage.py shell

这将启动一个交互式 Python REPL,它具有从您的 Django 应用加载的所有设置,并且行为与您的应用在当前上下文中的行为完全一样。

>>> from pizza.models import Pizza
>>> Pizza.objects.all()
<QuerySet []>

我们可以看到数据库中目前没有比萨饼,所以让我们创建一个:

>>> Pizza.objects.create(title="Pepperoni and Cheese", description="Best pizza ever, clearly")
<Pizza: Pizza object (1)>
>>> Pizza.objects.all()|
<QuerySet [<Pizza: Pizza object (1)>]>
>>> pizza = Pizza.objects.get(id=1)
>>> pizza.title
'Pepperoni and Cheese'

在我们的数据存储中创建一个新对象就是这么简单。更新现有的也很简单。

>>> pizza.description
'Best pizza ever, clearly'
>>> pizza.description = "Actually the best pizza ever."
>>> pizza.save()
>>> pizza2 = Pizza.objects.get(id=1)
>>> pizza2.description
'Actually the best pizza ever.'

对于这个例子,我们很幸运,但是,我们还没有真正满足任何业务需求,所以让我们继续添加几个模型,以便在我们享受巨大乐趣的同时至少满足一些需求:

# pizza/models.py
from django.db import models

from user.models import UserProfile

class Pizzeria(models.Model):
    owner = models.ForeignKey(UserProfile, on_delete=models.CASCADE)
    address = models.CharField(max_length=512)
    phone = models.CharField(max_length=40)

class Pizza(models.Model):
    title = models.CharField(max_length=120)
    description = models.CharField(max_length=240)
    thumbnail_url = models.URLField()
    approved = models.BooleanField(default=False)
    creator = models.ForeignKey(Pizzeria, on_delete=models.CASCADE)

class Likes(models.Model):
    user = models.ForeignKey(UserProfile, on_delete=models.CASCADE)
    pizza = models.ForeignKey(Pizza, on_delete=models.CASCADE)

Listing 2-4Extended models file for our application

与此同时,我们创建了一个名为 auth 的新应用,其中我们添加了一个名为 UserProfile 的模型。在 Django 中,以这种方式扩展已经存在且功能完善的用户模型是很常见的。Django 的新版本也提供了扩展现有用户模型的不同方法,你可以在 Django 官方网站( https://www.djangoproject.com/ )上了解更多信息。由于我们已经是经验丰富的 Django 程序员,我们知道使用 UserProfile 作为外键通常比使用用户模型更稳定,这是由于它的灵活性。确保在每次模型更改后,都运行 makemigrations 和 migrate 命令来保持数据库最新。

现在我们已经创建了许多模型,我们可以在练习 2-1 中更多地使用 shell。

练习 2-1:玩贝壳

在外壳中创建几个新的比萨饼,并将它们放在新创建的比萨饼店下面。试着去买一家披萨店的所有披萨。为比萨饼创造几个赞。尝试访问所有喜欢的比萨饼店!这些都是很棒的特性,有一天会成为我们应用的一部分。请随意探索外壳和模型层。

意见交流

公开我们数据的主要方式是使用 Django 的视图。视图本质上是端点,您可以利用它向客户返回各种类型的数据,包括他们浏览器中的 HTML 页面。

为了设置视图,我们将在披萨应用中创建一个名为 views.py 的文件。

# pizza/views.py
from django.http import HttpResponse

from .models import Pizza

def index(request, pid):
    pizza = Pizza.objects.get(id=pid)
    return HttpResponse(
        content={
            'id': pizza.id,
            'title': pizza.title,
            'description': pizza.description,
        }
    )

Listing 2-5The first view we’ve created to return pizzas

我们还需要为此添加一个 url。我们可以通过在披萨tizza 模块中创建和编辑一个 urls.py 文件来实现。

# pizza/urls.py
from django.urls import include, path

from .views import index

urlpatterns = [
    path('<int:pid>', index, name="pizza"),
]

# tizza/urls.py
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path('admin/', admin.site.urls),
    path('pizzas/', include('pizza.urls')),
]

Listing 2-6The edited urls files so we can access the resources

太棒了!现在是我们开始利用互联网的力量获取数据的时候了。根据您使用的工具类型,您可以远程呼叫 pizzas 端点。这里有一个可以从终端运行的 curl:

curl http://localhost:8000/pizzas/1

你也可以把它输入你的浏览器,它仍然会工作。

注意

curl 是一个命令行工具,可以对 url 进行各种操作,我们将在整本书中使用它来测试我们的端点和代码。建议稍微熟悉一下。在 https://ec.haxx.se/ 查看。

如果您能看到我们几分钟前刚刚创建的比萨饼的 id、描述和标题,我有一个好消息要告诉您:您已经成功地通过互联网查询了数据库!如果您有问题,我建议检查您的服务器是否正在运行,如果没有,在重试之前运行以下命令:

python manage.py runserver

现在,让我们尝试以下内容:

curl http://localhost:8000/pizzas/42

是啊,这真的没用。如果我们想返回正确的响应,我们需要修改视图函数(练习 2-2)。

练习 2-2:修复丑陋的 42

比萨饼的视图功能现在实际上相当糟糕。我们花了大约 15 秒钟找到了一个非常讨厌的 bug。幸运的是,我们有一个周末的时间来研究这个问题,所以让我们确保如果我们的一个客户调用一个不存在的比萨饼的端点,我们返回一个合理的响应,比如:

{
    "status": "error",
    "message": "pizza not found"
}

我们的视图功能看起来很棒,但我们需要一些额外的功能来满足业务需求(练习 2-3)。

练习 2-3:随机披萨

让我们创建一个端点,它将从数据库中返回一个随机的比萨饼。端点应具有以下路径:

/pizzas/random

让我们确保在这样做的时候不会弄乱其他端点!

完成后,我们还可以向客户端返回 15 个随机的披萨(这样我们就不会对后端服务器进行那么多远程调用)。我们还应该确保,如果用户看到了一个比萨饼,他们不应该再次收到相同的。你可以使用 Likes 模型来实现。

管理面板

您可能已经注意到,我们已经在 url 模式中添加了一个名为 admin 的 url。默认情况下,Django 为整个应用提供了一个非常方便的管理视图。如果您需要手动管理数据库中的对象,这可能非常有用。要访问管理面板,首先您需要在数据库中创建一个超级用户。

python manage.py createsuperuser

添加您想要登录的凭证,启动开发服务器(如果还没有运行的话),并作为管理员用户登录到 /admin url。超级用户可以访问管理站点上的所有内容,这是你进入系统的第一个入口。

在这一点上,除了用户管理,你可能看不到很多有用的模块。如果您想在这里使用 pizza 资源,您需要将下面的代码添加到 tizza/pizza/admin.py 中,这将告诉管理面板注册 pizza 模型以进行管理。

from django.contrib import admin

from .models import Pizza

class PizzaAdmin(admin.ModelAdmin):
    pass

admin.site.register(Pizza, PizzaAdmin)

此时,我们可以访问 Django 管理面板,查看我们的模型在 UI 上的行为(图 2-4 )。

img/481995_1_En_2_Fig4_HTML.jpg

图 2-4

Django 管理面板

在这个屏幕上,您可以访问所有用户信息,所有在 Django 中注册的模型,包括用户组和权限。总的来说非常方便,是快速帮助客户的一个非常强大的工具。

当您检查应用是否正常工作时,它也是一个非常好的测试工具。只需点击几下鼠标,创建用户就变成了一项非技术性的任务。

管理面板也是一个高度可配置的界面。您可以向已经实现的各种模型添加自定义字段和自定义操作。如果您想了解更多关于 Django admin 的知识,我建议您查看文档。这里还有很多值得探索的地方。

注意

如果您希望以这种方式访问实时数据库,您需要为每个环境创建一个超级用户。

为了确保我们熟悉管理面板,让我们在练习 2-4 中创建几个用户和几个比萨饼。

练习 2-4:管理操场

让我们试着从我们创建的 API 访问比萨饼。

只是为了实践管理应用的多功能性,让我们为 pizza 添加一个新的字段,通过它我们可以选择 pizza 是肉、素食还是纯素食。提示:对于数据库模型,您应该检查 models.ChoiceField。添加它之后,运行迁移并尝试在管理面板中创建新的 pizza。有什么变化?

登录、注销和注册

在我们继续之前,我们真的需要让我们的用户能够注册、登录和退出我们的系统。让我们为此开发一个快速应用,好吗?

报名

可能我们想给我们的用户一些方法,让他们在我们希望他们登录之前注册(并不总是这样,但在这个例子中,我们将这样做)。

Django 提供了一个非常强大的工具集来为用户实现一个简单的注册表单。

首先,我们将创建一个注册视图。但是我们应该把它放在哪里呢?我猜用户应用现在应该没问题。我们将使用 Django 表单来解决这个问题,因为 auth 应用默认为注册用户提供了一个表单。

from django.contrib.auth.forms import UserCreationForm

为了给用户分配一个会话并确保他们是他们所声称的那个人,我们将使用来自认证应用的登录认证助手。

from django.contrib.auth import login, authenticate

在这一点上,这是一个将这些功能粘合在一起的问题。为了使我们的系统更加灵活,我们将使用基于类的视图,也是由 Django 提供的:

# user/views.py
from django.contrib.auth import login, authenticate
from django.contrib.auth.forms import UserCreationForm
from django.shortcuts import render, redirect
from django.views import View

class SignupView(View):
    template_name = 'signup.html'

    def post(self, request):
        if form.is_valid():
            form.save()
            username = form.cleaned_data.get('username')
            password = form.cleaned_data.get('password1')
            user = authenticate(username=username, password=password)
            login(request, user)
            return redirect('/')

    def get(self, request):
        return render(request, self.template_name, {'form': UserCreationForm()})

Listing 2-7Class based view for signing up

正如您所看到的,基于类的视图是一种更简洁的方式来描述在您的端点上会发生什么,这取决于您在其上使用的操作。我们现在只需要一个屏幕,向用户显示这些内容。

{# user/templates/signup.html #}

{% block content %}
<h2>Sign up</h2>
<form method="post">
{% csrf_token %}
    {{ form.as_p }}
    <button type="submit">Sign up</button>
</form>
{% endblock %}

Listing 2-8Template for the sign up form

这是一个使用 jinja2 模板语言编写的模板文档。你可以看到它的大部分看起来像普通的 HTML。有用{% block … %}操作符定义的可替换块,我们在渲染时用{{...}}个标签。我们还为 csrf 令牌使用了一个特殊的标签,用来确保只有授权的实体才能使用我们的表单,这是 Django 所要求的。as_p 方法将把表单呈现为作为段落列出的元素。现在,我们需要做的就是确保我们的端点是公开的。

from django.urls import path
from tizza.user.views import SignupView

urlpatterns = [
    path(r'^register/—', SignupView.as_view()),|
]

太好了。使用 as_view 方法,我们可以很容易地将基于类的视图转换成我们在本章前面遇到的常规 Django 视图。您可以在图 2-5 中看到我们创建的注册页面。

img/481995_1_En_2_Fig5_HTML.jpg

图 2-5

注册页面

登录和注销

正如我们之前看到的,Django 提供的 auth 包附带了许多内置功能。这些也包括客户端身份验证端点。

注意

身份验证与授权:混淆身份验证和授权是很常见的。辨别这两者最简单的方法如下:认证验证你是谁,而授权说明你可以在一个系统中做什么。只要记住这一点,你就再也不会在谈话中混淆它们了。

其中包括以下内容:

  • /登录 -用户可以在此页面验证您的系统。

  • /logout -用户可以从您的系统中取消认证的页面。

  • /password_change -用户可以修改密码的页面。

  • /password_reset -用户可以在忘记密码时重置密码的页面。

将这些端点添加到您的系统中并不是最难的事情,我们需要做的只是更改项目目录中的 urls.py 文件。

# tizza/urls.py
from django.contrib import admin
from django.urls import include, path
from django.contrib.auth import views as auth_views

urlpatterns = [
    path('admin/', admin.site.urls),
    path('pizzas/', include('pizza.urls')),
    path('login/', auth_views.login, name="login"),
    path('logout/', auth_views.logout, name="logout"),
]

Listing 2-9Urls for logging in and logging out

默认情况下,Django 会尝试呈现 registration/login.html 模板。让我们用下面的格式创建这个文件。,这与注册页面非常相似:

{% block title %}Login{% endblock %}

{% block content %}
  <h2>Login</h2>
  <form method="post">
    {% csrf_token %}
    {{ form.as_p }}
    <button type="submit">Login</button>
  </form>
{% endblock %}

既然我们已经向 urlpatterns 添加了身份验证 URL,我们将需要创建几个页面来确保用户可以登录到我们的系统。

现在让我们将pizza的端点再扩展一点,因为我们需要能够与它们进行交互。

首先,我们将为拥有比萨店的用户添加一个比萨创建端点,这可以通过多种方式完成,这次我们将使用 HTTP 动词来区分我们希望在实体上执行的各种操作。

# pizza/views.py
import json

from django.contrib.auth.decorators import login_required
from django.http import HttpResponse

from .models import Pizza

@login_required
def index(request, pid):
    if request.method == 'POST':
        data = json.loads(request.body)
        new_pizza = Pizza.objects.create(
            title=data['title'], description=data['description'],
            creator=request.user,
        )
        new_pizza.save()
        return HttpResponse(
            content={
                'id': new_pizza.id,
                'title': new_pizza.title,
                'description': new_pizza.description,
            }
        )
    elif request.method == 'GET':
        pizza = Pizza.objects.get(id=pid)
        return HttpResponse(
            content={
                'id': pizza.id,
                'title': pizza.title,
                'description': pizza.description,
            }
        )

Listing 2-10Extended pizzas endpoint

现在,我们的登录用户可以创建一个新的比萨饼。代码有点乱,但我们可以稍后修复它,对吗?好的。你还可以看到,我使用了一个装饰器来确保只有登录的用户才能制作披萨。Django 默认提供了各种装饰器和基于类的视图混合。我建议检查一下它们,选择适合你使用的。在设计服务时,装饰者是非常非常强大的工具,我们在本书的后面肯定会遇到他们。

您还可以注意到一件微妙的事情,我可以用 login_required 装饰器的力量来做这件事。这不亚于使用由认证中间件填充的请求用户。等等,什么是中间件?

中间件入门

中间件是 Django 的核心概念之一。就像食人魔和洋葱一样,Django 也有层次,当请求和响应进入和退出应用时,它们会经过这些层次。这个分层系统的核心是视图功能和基于类的视图本身。考虑图 2-6 。

img/481995_1_En_2_Fig6_HTML.png

图 2-6

Django 请求响应周期一览

在这里您可以看到,当一个请求进入您的应用时,它进入各种中间件层,这些中间件层可以做很多事情。中间件的几个例子:

AuthenticationMiddleware——确保 request.user 对象存在,并且您可以访问它。如果用户已经登录,那么它将被用户对象填充。如果不是,那么一个匿名用户将坐在这个属性上。通常,将这个中间件子类化,并用其他用户相关数据扩展它是非常方便的,比如我们前面提到的用户资料

安全中间件 -提供各种安全相关功能,如 HTTPS 重定向、重定向阻止、xss 保护。

common middleware——提供一些基本的功能,这些功能的实现很简单。例如发送禁止的用户代理,并确保 URL 以斜杠结尾。

正如你所看到的,中间件有各种各样的用途,但是要小心你放在中间件代码库中的东西。由于服务中的所有请求都将进入该中间件,计算密集型和网络操作可能会显著降低应用的速度。

练习 2-5:速率限制中间件

如果你在关注 tizza,你会知道我们的虚拟企业正在迅速发展。高速增长带来了高流量,而我们的服务器几乎无法承受负载。新机器将在几周内提供,但是,在此之前我们需要一个解决方案。创建一个中间件,将某段时间内来自同一个 IP 地址的调用次数限制在 Django 设置中可配置的数量。另外,检查一下您是否可以使用装饰器模式来更加注意您想要保护的端点。

练习 2-6:喜欢比萨饼

还记得我们扩展 pizza 端点时,根据用户是否看过,返回一组随机的 pizza 吗?好消息是,现在我们实际上有了实现“喜欢”功能的工具。因此,您的任务将是创建一个端点,它可以根据登录用户的喜好来决定是否喜欢某个比萨饼。可能还需要编辑我们的模型来实现这一点。幸运的是,我们在项目的早期。

模板

现在我们已经熟悉了 Django 的工作方式,并且我们已经创建了几个可以通过浏览器访问的简单页面,让我们更深入地了解一下,在我们公司请得起设计师之前,我们如何让用户体验变得更容易接受。

正如我们在登录表单中看到的,Django 后端和我们的用户之间的主要通信渠道是通过模板(以及 Javascript,但我们稍后会回到这个话题)。让我们快速提醒自己模板实际上是什么样子的:

{# user/templates/signup.html #}
{% extends 'base.html' %}

{% block content %}
<h2>Sign up</h2>
<form method="post">
{% csrf_token %}
    {{ form.as_p }}
    <button type="submit">Sign up</button>
</form>
{% endblock %}

Listing 2-11Reminder on templates

第一行是注释,如果你正在读这本书,你可能已经熟悉了注释。

第二行是扩展语句。这基本上意味着该模板将使用注册为 base.html 的模板中的所有块,然后扩展并覆盖其中指定的属性。这意味着,我们可以为我们的应用构建一个基础模板,其中我们需要指定网站中只需要在任何地方出现一次的部分。让我们看一个简单的例子:

<html>
    <head>
        <meta charset="utf-8"/>
        {% block css %}{% endblock %}
    </head>

    {% block header %}
    <header>This is the header of the website, the designers will probably want it to be sticky and we need to add a login button to the right if the customer is logged our or a logout button if they are logged in</header>
    {% endblock %}

    {% block content %}
    {% endblock %}

    {% block footer %}
    <footer>This will be our footer where we will put columns about our company values and the job openings.</footer>
    {% endblock %}

    {% block javascript %}{% endblock %}
</html>

Listing 2-12Simple base template

我知道,上面的东西是很多代码,但是让我们运行一个快速分析和思想实验,看看每一行是如何有意义的:

  1. 我们的大多数应用都会有相同的 <头> 信息。meta 标签很少改变,而且肯定有 css 文件和样式,我们希望应用于我们所有的页面,但是,我们很可能希望将不同的 css 文件分发到我们网站的不同页面,所以 CSS 块在那里完全有意义。

  2. 页眉很可能会出现在我们所有的页面上,但是,有时我们希望页眉消失,或者以完全不同的方式出现(可能出现在营销页面上)。在这种情况下,我们允许覆盖整个标题。页脚也一样。

  3. 内容块本质上是您希望在页面中覆盖的内容。

  4. 在页面的最后,我们将有加载的 javascript 文件。如果我们需要更多,我们只需将它们添加到页面上的覆盖块中,我们也完成了。

作为一个简单的例子,我们将创建一个视图和一个模板,显示我们在视图中返回的比萨饼的信息。

from django.shortcuts import render
from django.views import View

from pizza.models import Pizza

class GetTenPizzasView(View):
    template_name = 'ten_pizzas.html'

    def get(self, request)
        pizzas = Pizza.objects.order_by('?')[:10]
        return render(request, self.template_name, {'pizzas': pizzas})

Listing 2-13Pizza shuffling endpoint

上面的代码有点笨拙,但是现在它将为我们完成返回 10 个随机比萨饼的工作。让我们来看看我们将要构建的模板:

{# pizza/templates/ten_pizzas.html #}
{% extends 'base.html' %}

{% block content %}
<h2>Look at all the pizzas!</h2>
<table>
<th>
    <td>Name</td>
    <td>Description</td>
</th>
{% for pizza in pizzas %}
<tr>
    <td>{{ pizza.title }}</td>
    <td>{{ pizza.description }}</td>
</tr>
{% endfor %}
</table>

{% endblock %}

Listing 2-14Pizza shuffling template

很简单,但它能完成工作。因此,在这里我们看到,我们有十个比萨饼呈现在一个表中,一个接一个地显示它们的名称和描述。在 Django 模板中,我们有各种各样的控件,比如循环和条件。请记住,所有这些操作都占用了宝贵的服务器时间,因为一切都是在那里计算的。这可能对你的 web 应用在搜索引擎上的页面排名有好处,但是,这可能会给你的客户带来缓慢的客户端体验。如果你想要更快的感觉,我可能会建议你完全依赖 Javascript,让你的模板尽可能的薄。

关于模板的进一步阅读,我建议查阅文档。

许可

当你编写一个应用时,你总是需要确保实体只能被有权限的用户查看、编辑和删除。同样,我们很幸运选择 Django 作为我们构建 tizza 的工具,因为我们已经有了一个内置的许可系统。如果您已经熟悉基于 UNIX 的权限,您可能会跳过接下来的几段,直接进入代码示例,对于我们其他人来说,这里有一个术语入门:

  • 用户——我们已经遇到了用户,他们来自django . contrib . auth . models . User模型,在我们的系统中描述一个人(或一个注册)。

  • ——一个组描述一组用户。一个用户可以是许多组的一部分,一个组可以包含许多用户。来源于django . contrib . auth . models . group。群组是标记用户的一种简单方式。一个可能的用例是,在 tizza 应用中,我们希望限制餐馆老板由于我们缓存中的维护而更新他们的食谱。在这种情况下,我们可以从 restaurant_owners 组的所有用户中取消 restaurant_admin_page 权限。

  • 权限对象也存在于 Django 生态系统中,而不仅仅是标签。使用django . contrib . auth . models . permission类,您可以在数据库本身中创建权限对象。

用户对象有以下两个字段,在使用权限和组时会很方便:

  • user.groups -您可以通过该字段获取、设置、添加、删除和清除用户的组。

  • user.user_permissions -您可以通过该字段获取、设置、添加、删除和清除单个用户的单个权限。

让我们来看一个快速的权限示例:

# pizza/views.py
from django.contrib.auth.models import Permission

def index(request, pid):
    # ...
    elif request.method == 'DELETE':
        if 'can_delete' in request.user.user_permissions:
            pizza = Pizza.objects.get(id=pid)
            pizza.delete()
            return HttpResponse(
                content={
                    'id': pizza.id,
                }
        )
        else:
            return HttpResponse(status_code=404)

Listing 2-15Permissions example

在上面的简单例子中,我们检查用户是否有权限删除给定的比萨饼。当他们创建了这个披萨,或者如果他们加入了有权处理这个披萨的组,我们可以授予这个权限。

结论

到目前为止,我们在 tizza 应用上已经做了很多工作,但要完成产品还有很长的路要走。现在我们将把它留在这里。我为渴望的人增加了练习 2-7 到 2-9。如果您只想看到项目的运行,您可以访问下面的存储库,克隆代码库,并试用应用:

https://github.com/akoskaaa/dmswd-monolith

尽管代码库仍然只有几千行,但我们已经可以看到项目中的某些区域可能会更好。在下一章中,我们将探索将项目分割成小块的选择,并熟悉我们将在本书其余部分遵循的原则。

如果你在这一章之后需要更多的学习资料,我强烈推荐你查看 Django 的官方网站( https://djangoproject.com ),因为他们为初学者和高级用户提供了极好的文档和资料。还有另外一个由两勺出版社( https://www.twoscoopspress.com/ )提供的优秀资源,你可以在那里更深入地了解这个话题。

练习 2-7:比萨饼页面

很高兴我们已经为正在使用的模型创建了 API,然而,我们的大多数用户并不完全熟悉 curl 的神奇之处。对于这些人,让我们创建一个页面,在那里我们随机获取比萨饼,一个接一个地展示它们,并提供给他们喜欢或不喜欢的比萨饼。当我们卖完比萨饼时(或者更好:快卖完了),让我们去拿下一批。

练习 2-8:餐馆仪表板

如果您以前使用过电子商务应用,您会知道有两种类型的用户。买的人(客户)和卖的人(商家)。到目前为止,我们主要是迎合客户的用例,让我们确保商家也得到一些爱。对于拥有比萨饼店的用户来说,他们应该会收到一个仪表板页面,在那里他们可以管理所有他们想在我们的系统中显示的比萨饼(创建新的,更新和删除现有的)。

练习 2-9:支付服务

我们需要开始赚钱。创建一个新的应用,将从商家方面模拟我们的支付,所以他们可以从他们的管理页面“提高”他们的产品的可见性。

三、微服务剖析

现在,我们对微服务的鸟瞰图有了一个模糊的概念,是时候放大并仔细观察各种服务以及它们如何在内部相互交互了。为了更容易理解我们的架构,我们将把重点放在 3 个主要类别上,因此在讨论整个系统时,推理会更容易一些

首先,我们将查看特定服务在您的体系结构中的位置。我们将检查 3 种类型:

  • 前端服务

  • 混合服务

  • 后端服务

先从用户看不到的说起。你会明白为什么。

后端服务

每个系统都有用户不直接与之交互的组件,只是通过许多抽象层。例如,您可以想象一个状态机,它计算用户在当前系统中是如何表示的。他们是以前的客户,还是目前的客户?也许他们以前从未在你的网站上使用过付费功能,但出于营销目的,存储他们的数据是很重要的。

后端服务为您的应用提供主干。其中大多数封装了公司核心业务逻辑的功能。他们经常提供数据或者与数据转换有关。他们更可能是其他服务的数据提供者,而不是消费者。

设计纯粹的后端应用有时感觉像是一个微不足道的挑战,我们将从我们的 pizza 应用中查看几个例子,以确保我们理解这意味着什么。在图 3-1 中,您可以看到连接到数据存储的 pizza 微服务,该数据存储包含我们在前一章中定义的 pizza Django 应用下的模型。

img/481995_1_En_3_Fig1_HTML.png

图 3-1

想象中的 tizza 应用后端系统

嗯,至少是其中的一些,我们将在几章中看到。您可以在图中看到的另一个服务是 auth 服务,我们将使用它来处理所有用户相关信息和身份验证。一些团队也使用类似 auth 的服务进行授权,根据您的喜好,您也可以将该层移动到您架构的一个单独部分。但是请记住,存在于相似或相同业务领域中的数据通常应该保持接近。

这里值得一提的是,这些服务的设计是由它们托管的数据和它们工作的领域共同驱动的。在构建微服务时,创建仅托管单一特定数据类型的服务是一个非常常见的错误。当不同类型的数据位于相似的业务领域时,它们应该在物理上和逻辑上彼此靠近。然而,这总是一个艰难的决定。这里有几个案例,你可以在午休时和同事一起哀叹:

  • 比萨饼和比萨饼店应该托管在同一数据存储中的同一服务上吗?如果我们开始储存比萨饼的配料呢?在那种情况下,你的观点会改变吗?

  • 我们应该在哪里存储权限?它应该绑定到比萨饼上还是绑定到我们系统的用户上?

  • 我们应该把喜欢的东西存放在哪里?

以上所有问题都有多个好答案。我的建议是这样考虑:如果数据不是耦合的或者是松散耦合的,那么您可以安全地将其分解。如果耦合紧密,试着测量耦合有多紧密。例如,您可以随时测量从数据存储中一起查询不同资源的次数,这可能有助于您做出决定。

将所有数据保存在同一个地方可以为您的公司工作一段时间。事实上,在开始时,将数据保存在同一个地方会大大加快操作速度。然而,过一段时间后,我们在第一章提到的问题和故事会一次又一次地出现。如果您的数据库中只有一个不是非常关键的表,但却填满了您的存储空间,导致您的核心业务中断,那就太遗憾了。另一方面,将所有内容分开将会使您无法创建连接和在存储级别进行快速操作,您将需要从不同的源获取数据并手动连接它们,有时会编写低效的查询。

这个演讲可能会给你一个在不同的存储器中进行数据复制的想法。让我们绕过这个话题快速说一下。

关于数据复制的一个注记

既然我们已经谈了很多关于数据服务如何工作的内容,我想稍微绕一下,谈谈当您迁移到微服务时,您将使用的不同数据存储之间的数据复制。

使用微服务时,数据复制会变得非常自然。毕竟,您的服务中确实需要电子邮件地址,对吗?为什么不在创建用户时存储它,这样您就可以确信这些数据在任何时候都是可用的。

这样的想法可能很有欺骗性。当你使用微服务(实际上,软件中的任何东西)时,你总是想减少的一件事就是维护工作。当您在服务中引入一个新的数据库、表甚至只是一个字段时,您正在为自己创建所有权和维护工作。在上面提到的电子邮件示例中,您需要确保电子邮件始终保持最新,这意味着您必须确保如果您的用户在 auth 服务中更改了它,您也需要在自己的服务中更改它!当客户想要使用他们被遗忘的权利时,您也需要确保删除或匿名化您数据存储中的电子邮件地址。从长远来看,这会导致很多不一致和令人头痛的问题。

在许多系统中保持数据一致是一个非常困难的问题。几十年来,数据库工程师一直在与上限定理作斗争,创造了像暗示移交或草率仲裁这样的算法,最终实现了各种数据库副本之间的一致性。在您的应用中实现这样复杂的一致性算法值得吗?

如您所知,我不太喜欢数据复制。当然,有些情况是你无法避免的,但是,我通常会推荐以下替代方案:

  • 虚拟对象:如果您可以存储一个标识符,使用该标识符可以从另一个系统查询该对象,那么为什么您需要存储整个用户对象呢?

  • 客户端和服务器端缓存:想想你正在处理的数据。跟上时代有多重要?数据的所有者服务总是可以很容易地实现一个缓存层,但是同样的事情也可能发生在客户端!

在开始从其他服务复制数据之前,请考虑替代方案。从长远来看,这可能会让你付出昂贵的代价。

既然我们已经很好地理解了数据将存储在哪里以及如何存储,那么让我们来看看将消费它们的服务类型。

前端服务

前端服务的存在是为了将呈现在用户机器上的前端应用容器化。乍一看,它们的存在可能没有多大意义,但是,有几个原因可以解释为什么设计(几乎)完全前端的服务对您和您的团队来说可能有意义:

  • 关注点分离——您可能还记得(或者仍然使用)MVC 模型,以及它们将应用的各个部分分离开来的好处。在某种程度上,您可以将前端服务视为 MVC 的“视图”层。开发人员可以专门处理这些服务,只利用其他人的接口并与他们不拥有的数据进行交互。

  • 独立的工具——如果有不同的团队在前端服务上工作,那么将会有不同的工具围绕着它,并且有更多的专业人员在这个领域工作。并不是所有熟悉 gradle 的人都熟悉 Webpack。然而,这并不一定意味着他们不能互相学习!

前端服务可以直接使用来自后端服务和系统的数据,后端服务和系统可以将后端服务提供的数据集成为由特定业务逻辑定义的更易于理解的格式。让我们来看看混合服务。

混合服务

按照 SoA 的理念,有时我们需要系统只为我们的业务做一件事,而且它做得很好。没有前端或后端专业知识的工程师需要负责这些服务。也完全有可能这些业务组件没有严格地绑定到工程部门。这本书的主要焦点将围绕后端和混合服务。

如果所有权或者缺乏维护系统的人,我们完全可以考虑系统,我喜欢称之为“混合服务”。在野外,它们有时被称为后端到前端服务,或 BFF。

混合服务将大量前端和后端组件连接在一起,以实现简单的业务目标。在开始编写代码之前,让我们先看一个例子:

让我们想象一个世界,在遥远的未来,我们成为 tizza 中最重要的团队之一的技术领导,这就是 tizza-admin 团队。我们的使命是确保所有的披萨创建者可以轻松管理他们的披萨,并可以在应用中推广营销活动。他们需要一个单页应用来使体验更加流畅。阅读规范后,可能会出现以下问题:

  1. 这里有很多数据,我们应该从哪里获取呢?

  2. 我们应该从前端分别调用所有的服务端点吗?

  3. 移动呢?他们能处理所有的数据吗?

所有这些都是每个全栈(和非全栈)在构建具有多个数据源的单页面应用时应该问自己的有效问题。我们不想做的第一件事是连接到现有的数据库(我们将在本章的后面有更多的推理),所以我们将限制自己调用 API。在这里,我们可以选择从单独的数据源或从单个数据源调用数据的端点(在这种情况下,例如,我们需要比萨饼的列表、许可、活动选项和支付细节等)。借助事件循环和线程的力量,我们可以轻松地运行第一个选项,同时并行获取所有信息,但是,我们正在消耗大量网络带宽。

为什么这是一个重要的问题?2017 年,美国 63%的网络流量是通过移动设备完成的,其中很多是通过移动网络完成的。移动网络是变化无常的小生命。它们很脆弱,往返时间很长,人们把它们带到很少阳光照射的地方,这使得网络带宽优化成为我们作为工程师需要考虑的首要问题之一。

更改当前已有的端点来支持部分响应负载可能有点麻烦,因此出现了一个服务的想法,该服务将为我们聚合数据并以紧凑的响应进行响应。弊端?我们已经向 BFF 引入了额外的呼叫。

随着独立服务而来的是另一个美好的东西,那就是所有权。BFF 通常是系统中拥有最多业务逻辑的部分,这使得它成为产品团队所有权的完美候选。

现在,我们已经熟悉了如何对微服务进行分类的基本概念,我们将深入了解服务的高级架构应该是什么样子。

设计原则

我们将看看像 SOLID principles 这样的方法——最初用于整体服务来管理代码复杂性——以及它们如何提供一种考虑服务的有用方式。我们还将看看在服务设计过程中出现的一些常见的设计模式。

请记住,我们在这一部分要看的例子应该有所保留和思考。这些模式并不能解决设计服务时的所有困难。在实现过程中保持开放的心态,在将这些原则集成到您的系统中时专注于您的业务问题。

实心积木

你们中的一些人可能听说过传奇软件工程师制定的坚实原则,如 Sandi Metz 和 Robert C. Martin,如果没有,这可能是一个令人大开眼界的小片段。

坚实的原则本质上是关于如何设计您的代码和代码架构的指南,以便在未来的功能开发和维护上花费最少的时间。我们将通过一些例子简要介绍 5 项原则。如果你想了解更多,我强烈推荐罗伯特·c·马丁的《清洁建筑》作为阅读材料。这些原则与微服务设计没有严格的联系,但是在思考我和我的团队正在构建的系统时,我从中找到了大量的灵感。同样,理解并应用它们(如果需要的话)会客观地让你成为一个更好的程序员。

1。 单一责任原则——声明你系统中的一个成员(类、方法,甚至是整个微服务!)应该只有一个改变的理由。这是什么意思?考虑一个负责从数据存储中获取数据并将其显示在 web UI 上的函数。现在,这个组件可能有两个改变的理由。首先,如果它读取的数据或数据存储发生变化,比如向数据库表中添加新列。第二,如果显示数据的格式发生变化,比如允许使用“json”格式以及“xml”数据作为响应。理想情况下,您希望将这些层分开。因为它们变得更容易推理。

2。 开闭原则——声明你系统的部分应该对扩展开放,但对修改关闭。现在,这并不意味着你应该编写将来不可能修改和修复的代码,而是意味着如果你想给你的软件增加新的功能,你不需要修改已经存在的代码。

def pizzas(request):
    if request.method != 'GET':
        # we are post (I guess)
        return update_pizzas(request)
    else:
        return get_pizzas(request)

Listing 3-1Not conforming to open-close

向上面的代码中添加一个新的方法类型需要进行大量的修改

def pizzas(request):
    if request.method != 'GET' and request.method != 'PUT':
        # still post! (I guess)
        return update_pizzas(request)
    elif request.method == 'PUT':
        return create_pizzas(request)
    else:
        return get_pizzas(request)

Listing 3-2Still not conforming to open-close

相反,考虑下面的(仍然不是最好的,但是足够了):

PIZZA_METHOD_ROUTER = {
    'GET': get_pizzas,
    'PUT': create_pizzas,
    'POST': update_pizzas,
}

def pizzas(request):
    return PIZZA_METHOD_ROUTER.get(request.method)()

Listing 3-3Conforming to the open-close

3。 利斯科夫替换原则——声明如果你的程序中有包含子类型的类型,那么该类型的实例应该可以被子类型替换,而不会中断你的程序。这是 5 的更面向对象的原则之一,本质上是说如果需要的话,你的代码的抽象应该被具体的成员替换,这样从长远来看确保了系统的正确性。我发现,如果工程师使用 IDE 来告诉他们是否违反了超类的规则,那么 Liskov 替换原则是很容易遵循的。使遵循这个原则变得容易得多的另一件事是尽量减少元编程的使用。这一点我们将在本书后面讨论。

4。 接口分离原则——声明许多特定于客户端的接口比一些具有许多功能的大型抽象接口要好。换句话说,你不希望你的客户依赖于他们不需要的东西。这是现代软件工程中一个非常非常重要的原则,但却经常被忽视。基本上是面向服务架构原则背后的思想。

假设你是一名后端开发人员。您的工作是编写原始的、多用途的 API,供数百个内部和外部客户每分钟使用。这些年来,您的界面已经成长为巨大的怪物,其中一些没有限制它们返回的关于客户的数据量。从名字到他们去过的餐馆数量,每次访问的朋友列表都会在响应中返回。现在,这可能对你来说很容易,数据库就在你的下面,在你的 MySQL 集群上有了聪明的查询,你就能够保持 API 的高速运行。然而,移动团队突然开始抱怨。他们说,你不可能指望客户每次打开应用时下载数百千字节的数据!的确,大规模的 API 被分割成较小的 API 肯定会更好。这样,查询的数据更加具体,这种后端服务的重构和扩展将会更快。构建 API 的时候,一定要从客户端开始!

5。 依赖倒置原则——声明系统应该依赖抽象,而不是具体化。可能是 5 个中最著名的一个。基本上是说你应该在你的代码中使用明确定义的接口,你的组件应该依赖于这些接口。通过这种方式,您可以在实现层获得灵活性。

微服务——应该——都是关于依赖性反转原则的。在理想情况下,系统使用契约(如 API 定义)进行通信,以确保每个服务都在相同的页面上提供和使用哪种数据。可悲的是,现实世界并不总是充满阳光和幸福,但我们将看看如何实现这一目标的方法。

关于微服务设计,人们经常忘记的一件事是,它不允许你编写糟糕的代码,并在底层遵循糟糕的设计模式。确保你为你在低层和高层抽象上设计的系统感到自豪,并且这个服务不仅仅是可替换的,而且是可维护的。

12 个因素

更流行的服务设计方法之一是遵循 12 因素应用的规则。最初由 Adam Wiggins 编写,后来由 Heroku 负责维护,12 因素应用是一个微服务设计方法集,它为我们提供了构建可扩展和可维护的服务时应该遵循的 12 点。现在,这些方法涵盖的范围比这个厨师所能深入涵盖的要广得多,所以我建议在 12factor.net 多读一些。

1。版本控制系统中应该有一个被跟踪的代码库,被多次部署

我认为现在没有太多的代码库没有被各种修订系统跟踪,例如 Git 或 Subversion。如果你是一个还没有采用这些技术的人,我强烈建议你检查一下,并把它们集成到你的工作流程中。一个应用应该由一个代码库和一个或多个部署组成。在面向对象的术语中,你可以把你的代码库想象成一个类,而部署则是你的类的一个实例,带有各种参数,使它能够在生产、开发或测试环境中运行。

您的代码库在不同的部署中可以有不同的版本。例如,当您构建应用时,您的本地开发部署可以在不同版本的代码库上运行。

2。依赖关系应该被隔离并明确声明

正如我们将从本书的后面部分了解到的,依赖性管理是构建微服务的最大和最困难的问题之一。12 条规则中的第二条可以给我们一些经验法则,我们可以遵循这些法则来开始。

在 python 世界中,我们通常使用 pip 结合需求或设置文件作为依赖管理器。这条规则规定所有的依赖项都应该有固定的版本。这是什么意思?想象一下下面的情况:您在您的应用中使用带有一个非固定版本的包 A。一切都进行得很顺利,直到在包中发现了一个关键的安全性,而您却从未得到通知。此外,该项目的唯一维护者已经在 8 个月前失踪,导致您的所有用户数据被盗。现在,这听起来像是一个极端的情况,但是如果你曾经和依赖管理器如 npm 和版本指示器如 ^~ 一起工作过,你就会知道我在说什么。为了安全起见,使用==作为依赖项。

3。 店铺配置 环境

为了遵守规则#1,我们需要将依赖于部署的配置与部署本身分开存储。依赖于部署的配置可以是多种多样的,它们通常是您的应用运行所必需的。我们指的是以下变量:

  • 数据库和其他外部系统的 URIs

  • 证书

  • 日志记录和监控设置

4。将外部服务视为资源

外部服务可以从数据库和缓存到邮件服务,甚至是为应用提供某种服务的完全内部的应用。这些系统需要被视为资源,这意味着您的应用应该支持按需改变它们的来源。应用应该在第三方环境之间没有区别。

想象一下下面的情况:有一个大规模的营销活动即将到来,而你的第三方电子邮件提供商无法承担这一负担。升级您的计划可能需要一些时间,但是在第三方构建一个新的(更高吞吐量的计划)应用似乎是一个可行且快速的解决方案。一个 12 因素的应用应该能够没有太多问题地处理切换,因为它不关心它所处的环境,只关心它使用的配置。在本例中,更改应用的身份验证凭证挽救了局面。

5。非开发部署创建应支持构建-发布-运行周期??

一个包含 12 个因素的应用将部署创建分为 3 个独立的阶段

  • 构建——当您的代码和依赖项被组装成可执行文件时。

  • release——当您的可执行文件与环境配置组合在一起,并创建一个可以在给定环境中执行的版本。

  • run——汇编的可执行文件和配置现在在给定的环境中运行。

为什么我们分享这个过程如此重要?这是一个非常好的问题,我能给出的最简单的答案就是对应用进行推理。想象一下:你的支付系统在生产中有一个严重的缺陷。团队立即开始查看您的版本管理系统上的应用代码,检查还原发生时最近的提交。没有任何迹象表明这个问题应该在第一时间发生,但是团队仍然决定不重新发布损坏的版本,直到找到 bug。仅仅几天后,团队得知一名工程师修改了支付系统的生产代码。这是 12 因子应用希望通过此规则避免的示例之一。

现在,如果不对生产系统进行适当的安全限制,上述问题很难解决,但是,有一些工具可以阻止工程师首先这样做。例如,你可以使用一个合适的发布管理系统,其中应用的回滚很简单,比如 Kubernetes 的“helm”。此外,你的所有版本都应该附有版本和时间戳,最好存储在一个 changelog 中(我们将在后面的章节中更深入地研究这类系统)。

6。12 因子 app 分别是 无状态流程

12 因素应用假定没有东西会长期存储在主应用旁边的磁盘或内存中。同样,这样做的原因是能够推理出应用以及它将来可能会出现的错误。当然,这并不意味着您不能使用内存,建议将它视为单个请求缓存。如果您在内存中存储了许多东西,并且您的流程由于某种原因(例如新的部署)而重新启动,您将丢失所有这些数据,这可能对您的业务没有好处。

在具有持久会话的应用中,用户数据可以跨请求多次重用,这些应用仍然应该存储在某种数据存储中,在这种情况下,这可以是一个缓存。在本书的后面,我们将探索一些 python 包和框架,如 asyncioaiohttp-jobs ,在这里很容易进入将请求存储在内存中并在进程重启时完全丢失请求的危险区域。

7。使用 端口绑定 导出您的服务

更具体一点的 web 开发(但是,嘿,这本书的大部分内容都是关于 web 开发的),这个规则规定应用应该是完全自包含的,不应该依赖于 web 服务器的运行时注入,而是通过绑定到一个端口并通过该端口服务请求来导出它的接口。

在我们的情况下,Django 会处理所有的事情。

8。使用流程向外扩展

每个应用的基础应该是进程,它应该被解释为类似 Unix 的服务守护进程。应该设计各种类型的进程来处理各种类型的有效负载。计算量大、运行时间长的任务可能需要工作进程或其他异步进程,而 HTTP 请求可能需要 web 进程来处理。

这并不意味着你的进程运行时不鼓励线程,在 Python 的情况下,你的应用完全可以利用“线程”库,或“asyncio”。另一方面,您的应用需要能够扩展为在相同或多个物理和/或虚拟机上运行的多个进程。

确保不要在操作系统层面上使事情过于复杂,只需使用标准的工具来管理您的流程,如“systemd”或“supervisor”。

第 6 点实现了这一点。

9。流程应该易于启动和处理

不要依赖你的 12 因素应用的流程,因为它们应该很容易摆脱,也很容易创建。一接到通知。不过,这有几个要求。

  • 启动应该很快——进程应该只需要几秒钟就能启动。这是简化缩放和快速发布过程所需要的。实现这一点可能相当棘手。您应该确保在加载应用时没有昂贵的操作——比如远程调用不同的 web 服务器。如果你正在使用许多模块,你可能想研究一下 importliblazy_import 方法。

  • 关闭应该是优雅的——当你的进程从操作系统接收到一个 SIGINT (或者甚至是 SIGTERM )时,它应该确保一切按顺序关闭,这意味着正在运行的进程/请求在你的应用中结束,网络连接和文件处理程序关闭。在 django 的例子中,选择的 WSGI 服务器将会为您处理这些。

10。保持尽可能接近

**确保生产环境中运行的代码尽可能地接近开发机器上运行的代码。为了避免误解,我们所说的接近是指运行的应用版本之间的差异。根据 12 因素应用,要实现这一目标,您需要努力缩小 3 个“差距”:

  • 时间:开发人员将一个特性交付到产品中所花费的实际时间——无论对你和你的公司来说这是几天还是几周,目标是将它减少到几个小时甚至几分钟。

  • 人员:运营工程师部署 12 因素应用的代码开发人员应该能够参与部署过程并监控应用,而不需要运营工程师。

  • 工具:开发过程中使用的工具与生产中使用的工具(如数据库、远程系统等)。)-尽可能保持开发和生产工具的紧密联系。

你可能会认为这些大多说起来容易做起来难。十年前,如果运营人员不在几分钟内到位,几乎无法想象服务的部署。大多数持续开发和部署系统都是使用从运营人员那里收集的各种脚本手工构建的,这些人厌倦了每次有人更改代码库时运行“rsync”。如今,整个行业和技术分支都在发展,以使部署体验更快、更简单、更不容易出错。有些系统可以直接连接到您的 git 存储库,并为您的集群提供自动化部署,如 AWS CodePipeline、CircleCI 或 Jenkins。

注意

如果您不熟悉持续集成(CI)或持续部署(CD)管道,我建议您阅读一下。在 devops.com 上可以找到极好的资源。

关于工具,今天,在容器化的时代,您和您的开发人员可以使用多种工具来简化它。在我们浏览它们之前,让我们先来看看为什么这很重要:

想象一下下面的情况:您的一个开发人员正在处理一个非常复杂的查询,而您的系统的 ORM 无法处理这个查询,所以您决定使用一个原始查询作为解决方案。开发人员启动他们的本地系统,开始在本地 SQLite 数据库上构建查询。几天后,数百行查询就完成了,覆盖了自动和手动测试,一切都运行得很好。开发人员在他们的拉式请求上获得批准,并且在部署之后,您的监控系统提醒团队该特性不可操作。经过一些调试后,开发人员得出结论,他的本地 SQLite 数据库和生产环境中运行的 Postgres 之间存在语法差异,这是他所不知道的。

在过去,在您的本地开发部署上运行轻量级支持服务是有意义的,因为您的机器上的资源通常是有限且昂贵的。今天,有了我们使用的巨型开发机器,这不再是一个问题。另一个问题可能是后端服务类型的可用性。在您的本地机器上维护 Postgres 集群可能看起来很乏味,如果您没有工具备份的话,这是很乏味的,因为现在虚拟化,尤其是容器化的力量已经提供了工具备份。

如今,在本地机器上设置 Postgres 数据库就像编写一个 Docker compose 文件一样简单,如下所示:

version: '3'
services:
  postgres:
    image: postgres:11.6.1
    ports:
      - "5432:5432"

Listing 3-4Sample yaml to spin up a database with Docker Compose

再也没有借口了!确保在您的所有部署中使用类似的生态系统,以减少上面详述的错误类型。

11。 日志 应该由别的东西管理

这一点很简单。一个 12 因素的应用不应该关心管理和写入各种日志行,而应该把所有的日志作为一个事件流写入“stdout”。这使得开发非常容易,因为在本地机器上,开发人员可以看到他们的应用中发生了什么事件,从而加快了调试过程。

在登台和生产环境中,流由执行环境收集,然后发送以供查看和/或存档。这些目的地不应由 12 因素应用配置。

如今,有几十种优秀的日志解决方案供您使用。如果你不确定从哪里开始,我建议查看 Logstash、Graylog 或 Flume。

12。运行您的 行政流程 作为一次性流程

出于维护目的,开发人员经常需要在 12 因素应用上运行手动流程/脚本。一些例子包括:

  • manage.py migrate 用于 Django 应用上的数据库迁移

  • 修补数据库中用户数据的一次性脚本

  • manage.py shell 让 Python shell 检查应用状态和数据库

这些进程应该在与应用的长时间运行的进程相同的环境中运行。它们需要相同的代码库和相同的配置。管理代码必须与应用代码一起发布到各种环境中。

结论

现在我们已经了解了 12 因素应用的规则,我们可能对高性能微服务有一个模糊的概念。理想情况下,你已经听说过这些要点中的大部分,并认为它们是值得添加到你的设计服务库中的东西。在本书的某些部分,我们将会观察到,由于发展或业务的限制,这些规则是如何被打破的。无论我们在哪里打破 12 因素的规则,我都会让你知道,你可以评估自己是否值得。

我们采纳了一些高层次的设计理念,从鸟瞰的角度来看,我们的服务应该是什么样的。现在,我们将放大图片,了解它们应该如何相互交流。**

四、通信

因此,我们已经对微服务的外观以及如何从概念上开始创建微服务有了一个很好的基本概念。在这一章中,我们将更深入地探讨这些服务如何能够、应该以及将要彼此交互的主题。我们将讨论同步和异步通信、安全性和测试等主题。系好安全带,让我们直接进入休息的基础。

休息和同步世界

回到英雄时代,对于互联网上的交流没有真正高层次的定义。您可能还记得参加网络课程,学习 TCP 和 UDP 协议,所有关于 ACK 和 NACK 循环的知识,以及各种握手,以确保您可以连接到不同的系统。见鬼,你甚至可能用 C 语言编写了一个远程计算器,在那里你使用套接字与你机器上不同的开放端口通信。哦,那些日子!

的确,我们很幸运,更高层次的标准化在几十年前就已经开始了,而且从那以后就没有停止过。我们要关注的第一个协议是 HTTP 和 HTTPS 协议,我们将通过 REST 研究最佳实践,您可以遵循这些实践在您的服务之间创建简洁而同步的通信。

REST 代表代表性状态转移。这是一个协议,最初是在 2000 年罗伊·托马斯·菲尔丁的传奇论文中提出的,叫做架构风格和基于网络的软件架构的设计。没有进入太多关于这篇论文的细节,它描述了 90 年代系统设计的方法和术语,为今天仍然存在的最佳实践打下了坚实的基础。对于每一个想认真做系统设计的人来说,这绝对是必读书。REST 协议在论文的第五章中有详细介绍。

什么是休息

如前所述,REST 是一种消息传递协议,旨在允许 web 上各种服务之间的无状态通信,无状态通信意味着接收者接收的消息与之前的消息无关。遵循 REST 原则的服务允许通过请求中的文本表示来修改资源和实体。

如果这听起来有点不正常,看看清单 4-1 中的tizzaAPI。

def pizzas(request, pid):
    if request.method == 'PUT':
        data = request.json()
        pizza = Pizza.objects.create(**data)
        return HttpResponse(status_code=201, data={
            'id': pizza.id,
        })
    else:
        return HttpResponse(status_code=405)

Listing 4-1Example restful pizza API

在上面的例子中,我们可以看到一个视图函数,它要么创建一个比萨饼,要么向 API 的调用者返回一个奇怪的状态代码。您可以看到我们使用了一个 HTTP 动词,PUT 来检查操作。这是 REST 给我们的标准之一。根据您使用的动词,您应该在应用中执行某些操作,这样 API 的调用者就可以知道会发生什么。我们在响应中使用的状态代码是 201,代表“已创建”。状态代码类似于动词。如果我们看到一个 201,我们知道作为一个来电者会期待什么。405 代表不支持的方法。在清单 4-2 中,您可以看到一个未找到资源的 HTTP 响应的示例表示。

$ curl -v -X GET localhost:8000/pizzas/101
Note: Unnecessary use of -X or --request, GET is already inferred.
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8000 (#0)
> GET /pizzas/101 HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.54.0
> Accept: */*
> 

< HTTP/1.1 404 Not Found
< Date: Sat, 21 Sep 2019 14:13:07 GMT
< Server: WSGIServer/0.2 CPython/3.6.5
< Content-Type: text/html
< X-Frame-Options: SAMEORIGIN
< Content-Length: 3405
<
...

Listing 4-2Example HTTP response

当您设计服务和通信时,这些标准可能看起来像是一种负担,但是从长远来看,它们将使您的应用更加可用,并且可以减少公司工程师之间不必要的交互。现在,让我们来看看 REST 是如何工作的。

HTTP 动词,REST 说话的方式

让我们浏览一下 HTTP 动词列表,以便我们对我所说的 HTTP 动词的意思有一个共同的理解:

GET 可能是当今互联网上最常用的 HTTP 动词,是你在浏览器上访问网站时使用的默认动词。它所做的就是,从指定的端点获取资源。如果您正在开发一个使用 GET 动词为端点提供服务的 API,那么您的被调用者的一个期望就是充当端点的服务不会修改应用的服务器端状态(比如写入它的数据库),这意味着 GET 应该总是等幂的。这里有一个我们希望避免的例子:

$ curl -X GET localhost:8000/pizzas/1
{"id": 1, "title": "Pepperoni and Cheese", "description": "Yumm"}
$ curl -X GET localhost:8000/pizzas/1
{"id": 1, "title": "Salami Picante", "description": "Also YUMM"}

这里发生了什么?我们调用端点两次,它没有返回相同的响应。现在,在某些情况下可能会有争议,这可能是预期的结果,例如在端点上返回一个随机响应,或者在请求之间修改资源,但是,如果 GET 端点不像清单 4-3 中那样:

from django.http import JsonResponse

from pizza.models import Pizza

def get_pizza(request, pid):
    if request.method == 'GET':
        pizza = Pizza.objects.get(id=pid)
        pizza.title = 'Salami Picante'
        pizza.description = 'Also YUMM'
        pizza.save()
        return JsonResponse(
            data={
                'id': pizza.id,
                'title': pizza.title,
                'description': pizza.description,
            }
        )

Listing 4-3A view that changes the data on GET

如果要保持幂等性,就不做上面的代码。现在,在我们对应用状态的含义进行过多的哲学讨论之前,在本书的上下文中,我们将把通过 API 直接访问的对象称为应用状态。

注意

在上面的 curl 中,我使用了-X GET 标志,通常在使用 curl 进行 GET 请求时,这是不需要的。

PUT——另一个重要的 HTTP 动词之一。PUT 表示在请求的有效负载中发送的对象的替换或创建。对象标识符通常在请求本身的 URI 中表示,主体包含需要在系统中覆盖的成员。PUT 请求本质上应该是幂等的。意思是:

$ curl -i -X PUT localhost:8000/pizzas/1 -d '{"title": "Diavola", "description": "Spicy!"}'
HTTP/1.1 201 Created
...

{"id": 1, "title": "Diavola", "description": "Spicy!"}
$ curl -i -X PUT localhost:8000/pizzas/1 -d '{"title": "Pikante", "description": "Spicy!"}'
HTTP/1.1 200 OK
...

{"id": 1, "title": "Pikante", "description": "Spicy!"}

所以,不管发生了什么,我们总是得到相同的对象,有相同的标识符。

POST -经常与 PATCH 混淆,POST 是不幂等的对应词。这意味着每当您向资源的端点发送 POST 请求时,您应该总是期望在那里创建一个新的资源。

$ curl -i -X POST localhost:8000/pizzas/ -d '{"title": "Diavola", "description": "Spicy!"}'
HTTP/1.1 201 Created
...

{"id": 1, "title": "Diavola", "description": "Spicy!"}
$ curl -i -X POST localhost:8000/pizzas/ -d '{"title": "Diavola", "description": "Spicy!"}'
HTTP/1.1 200 OK
...

{"id": 2, "title": "Diavola", "description": "Spicy!"}

你还可以看到,在上面的曲线中,我们没有指定想要处理的对象的标识符。

补丁(PATCH)——PUT、POST 和 PATCH 之间的区别是 web 开发人员面试中一个众所周知的常见问题。既然我们知道 PUT 应该创建和替换 URL 中指定的对象,POST 应该在远程系统中创建新的对象,我们就可以推断出 PATCH 请求是为了对已经存在的资源进行修改。如果资源不存在,那么响应应该向我们声明这一点。这意味着 PATCH 不是一个幂等的 HTTP 动词。

$ curl -i -X PATCH localhost:8000/pizzas/2 -d '{"title": "Diavola", "description": "Spicy!"}'
HTTP/1.1 404 Not Found
...
$ curl -i -X POST localhost:8000/pizzas/1 -d '{"title": "Diavola", "description": "Spicy!"}'
HTTP/1.1 200 OK
...

{"id": 1, "title": "Diavola", "description": "Spicy!"}

删除——一个比较简单的动词,它是为了说明我们想要从系统中删除一个特定的资源。

有几个不太常用的 HTTP 动词,我们将快速浏览一下:

HEAD——这个动词用于在发出适当的 get 请求之前只获取请求的头部。如果您不确定需要处理的响应的内容类型或大小,这可能会派上用场,它可以为您的系统提供一个有根据的决定,决定是否发出请求。

选项——使用这个 HTTP 动词,您可以确定在给定的资源上还接受哪些 HTTP 动词。

响应代码,当它说话时,REST 真正的意思是什么

看完 HTTP 动词之后,我们还将快速浏览一下最流行的响应代码。响应代码本质上是 REST 如何传达请求在我们发送给它的系统中是如何处理的。让我们来看看上面的一些例子。

$ curl -i -X POST localhost:8000/pizzas/ -d '{"title": "Diavola", "description": "Spicy!"}'
HTTP/1.1 201 Created

通过这个 POST 请求,我们希望确保有一个 Diavola 披萨的描述是“辣的!”在 tizza 后端。响应可以按以下方式分解:

HTTP/1.1 - HTTP 版本,告诉我们请求-响应周期本身使用的 HTTP 版本。通常我们不需要关心这个,但是,有一些旧的系统不支持 1.1 版本,也有一些新的系统已经支持 2.0 版本。大多数情况下我们不需要担心这个。

201 -响应代码,这是一个数字,表示我们向其发送请求的系统中发生了什么。通常你需要根据响应代码编写逻辑来处理外部系统的响应,参见下面的清单 4-4 。

import requests

...
response = requests.get('pizza/1')
if 404 == response.status_code:
    # We couldn't find the pizza, panic!
    ...
...

Listing 4-4Example response status code handling

注意

上面,我们使用 requests 库从一个系统向另一个系统发出 HTTP 请求。对于初学 Python 的用户,我强烈推荐阅读关于 3.python-requests.org 的文档。另外,看看 Github 上的源代码,因为它是目前写得最好的 Python 包之一。

创建了 - HTTP 状态动词。这基本上是状态代码的书面形式。从程序上来说,处理起来有点麻烦,因此我们通常在处理响应时忽略它,而依赖于状态码。

在这个简单的例子之后,我们现在理解了响应状态代码是 RESTful 通信的核心成员。我们不会一一列举(因为有超过 60 个),但是,这里有一个我推荐遵循的列表。对于其他人,一定要查看维基百科或 httpstatuses.com 等资源。

2xx 状态代码通常类似于接受和成功。

200 OK -希望是您遇到的最常见的响应状态代码,通常它表示您对外部系统的意向已成功处理。这可能意味着从资源到资源的任何东西都是从数据存储中获取的。

201 创建-通常我认为区分不同的 200 响应有点浪费。然而,有时,让处理客户端看到外部系统中发生了什么会很有帮助。对我来说,201 和 202 是这些信息性消息中的一部分,如果需要的话,应该对它们进行处理。201 表示在外部系统中创建了新的资源。如果您还记得前几页,我们检查了 PUT HTTP 动词,在这里可以创建或更新资源。在这种情况下,201 对客户来说是一个很大的优势。

202 Accepted-Accepted 关键字以后会派上用场。基本上它表明请求已经被记录在被调用的系统中,但是,还没有响应。这个响应代码通常意味着请求已经进入了一个排队系统,该系统最终会处理它。

3xx 响应代码通常表示资源已被移动到不同的位置。

301 永久移动-此状态代码表示请求的资源已被移动到不同的 URI。通常,它自身也会带来重定向。在这种情况下,新的 URI 应该位于位置头参数中。默认情况下,许多浏览器使用该标题进行重定向。

304 未修改 3xx 系列中的例外。这个响应代码由服务器指示请求的资源没有被修改,因此客户机可以使用其上的任何缓存数据。只有当客户端指示请求是有条件的时,这才是真的,这意味着它已经在本地存储了所提到的数据。

4xx 响应表示客户端在访问所需资源时出现错误。在这些情况下,客户端应该在再次发送请求之前重新考虑修改请求。

400 错误请求——可能是 4xx 系列中最常见的响应代码。指示请求本身存在错误。在这一点上,这可能意味着数据验证(例如,比萨饼的名称太长)或者只是一个格式错误的请求设置,比如不受支持的内容类型。

401 未授权和 403 禁止-访问控制二人组。通常表示缺少凭据或缺少足够的凭据来访问资源。

404 Not found——表示在被调用的系统中找不到某个资源。如果我们想要隐藏我们想要访问的资源的存在,使其更加安全,但是对于客户端工程师来说更加混乱,那么这个响应代码通常被用作 401 和 403 的替代代码。

429 太多请求 4xx 系列的另一个重要请求。此响应代码表示在给定时间段内对资源持有系统的调用太多。我们也可以将这种响应称为“达到速率限制”重试这些请求可能会导致灾难性的后果,根据资源持有者的服务器实现,系统会被锁定数小时。

5xx 响应表示服务器端出现故障。如果您是资源持有者,您应该会收到警报,并测量系统中这些响应的数量。在客户端重试这些是合理的。

500 服务器错误 5xx 系列中最常见的形式。指示服务器端存在未处理(或已处理,但未说明)的异常。对于资源所有者来说,这些异常应该被记录和解释。

502 错误网关和 503 服务不可用-服务器之前的网关或代理从服务器本身收到无效响应,并且在满足请求时出现问题。可能是因为应用服务器没有响应,在这种情况下,请确保所有进程都在服务器端正常运行。

504 网关超时-应用服务器的网关或代理没有及时收到响应。这可能表示服务器端的各种故障,从不堪重负的 CPU 和/或内存到失效的数据库连接池。

这些是我喜欢在应用中使用的基本 HTTP 响应和动词。如果您在客户端和服务器端都遵循并尊重这些,我可以保证在开发软件时,您的工程团队和微服务之间的摩擦会更少。让我们来看一个 tizza 服务器的练习。

注意

RESTful 后端通常是由人类手工编写的,这意味着涉及到很大的错误率。大多数人对 REST 应该如何工作有自己的理解。对上面的部分有所保留(也许还有一些牛至),并确保当你在与外部系统一起工作时,你非常精通它是如何操作的。

练习 4-1:宁静的比萨饼

我们已经讨论了很多关于状态代码和 HTTP 动词的内容。我想请你现在回到你在第二章写的代码,并根据我们在本章学到的知识重新评估它。哪些端点和资源遵循 REST 原则,哪些不遵循?

现在我们已经熟悉了 REST,让我们看看 Django 为我们提供的关于这项技术的一些工具。

Django REST 框架

我知道你在想什么。我们已经了解了关于响应的所有这些事情,并确保我们的服务可以通过 HTTP 以简洁的方式相互通信,尽管这看起来像是一个可怕的大量工作。幸运的是,Django 有一个插件解决方案,可以让您的服务器端代码立刻变得 RESTful。让我向您介绍 Django REST 框架。

首先,我们需要安装框架本身。在您的虚拟环境中,运行以下代码:

pip install djangorestframework

Django REST 框架需要注册为 Django 项目的应用。为此,使用以下内容扩展您的 settings.py 文件:

INSTALLED_APPS = (
    ... # Here are the Django builtins
    'rest_framework',
    ... # Here are your custom apps
)

瞧啊。现在您可以随意使用 Django REST 框架了。让我们把它挂在外面。

序列化程序

首先,我们将创建一个序列化程序。序列化器的存在使得我们可以将驻留在数据存储中的模型转换成对 REST 更友好且可由其他系统处理的东西。我这么说是什么意思?当不同的系统相互交流时,它们需要对如何表示正在传输的数据有一个共同的理解。也可以传输来自数据库的原始模型,但是,消费者应用不太可能理解这些数据的实际含义。为此,我们需要将数据转换成一种通用格式,在当今世界中通常是 JSON。在这个演示中,我们将使用框架提供的默认序列化程序,但是您可以轻松地编写自己的序列化程序,或者使用 Python 包索引中的序列化程序。

让我们在清单 4-5 中创建一个名为serializer . py的文件。

from rest_framework import serializers

from tizza.pizza.models import Pizza

class PizzaSerializer(serializers.HyperlinkedModelSerializer):

    class Meta:
        model = Pizza
        fields = ('id', 'title', 'description')

Listing 4-5Our pizza serializer

这个声明基本上描述了如果将来有人想要访问 pizza 资源,他们将会收到 id、标题和描述字段,这些字段会被框架自动转换成正确的格式。如您所见,这不是序列化程序的唯一工作。例如,您还可以在这里过滤您希望作为响应发送给客户端的数据,这意味着您可以确保不发布敏感的或特定于服务器的数据。我们已经将我们的数据转换成客户端可以理解的东西,是时候为它创建一个视图了。

视图集

我们的下一步是创建我们称之为视图集的东西。这基本上描述了当我们试图访问资源本身时应该运行什么类型的查询。让我们在 pizza 应用中创建一个 viewsets.p y 文件,参见清单 4-6 。

from rest_framework import viewsets

from pizza.models import Pizza
from pizza.serializers import PizzaSerializer

class PizzaViewSet(viewsets.ModelViewSet):
    queryset = Pizza.objects.all()
    serializer_class = PizzaSerializer

Listing 4-6The pizza viewset

代码看起来很简单,但是它包含了很多功能。有了这个简单的视图集,我们将能够通过一个请求查询数据库中的所有比萨饼,以及通过标识符查询资源。

路由器

我们已经非常接近 REST 框架的工作解决方案了。我们需要做的最后一步是将路由添加到应用本身。首先,让我们在我们的 pizza 应用中创建一个 routes.py 文件,参见清单 4-7 。

from rest_framework import routers

from pizza.viewswets import PizzaViewSet

router = routers.DefaultRouter()
router.register(r'api/v1/pizzas', PizzaViewSet)

Listing 4-7Pizza router

路由器是将 RESTful 资源映射到一组标准化 URL 的工具,同时简化了它们的定义。在这里,我们只使用默认路由器,但是,您可以利用各种路由器,为您提供不同的功能,如自动前缀。

现在我们已经添加了路由器,我们将简单地将它链接到 tizza 模块中的 urls.py 文件,如清单 4-8 中所述。

from pizza.routers import router

...

urlpatterns = [
    ....
    url(r'^', include(router.urls)),
    url(r'^api-auth/', include('rest_framework.urls', namespace="rest_framework"))
]

Listing 4-8Pizza URL configs added

是时候尝试我们的新功能了。首先,让我们尝试获取带有第一个 id 的披萨。

curl -X GET http://localhost:8000/api/v1/pizzas/1/
{"id":1,"title":"Quattro formaggi","description":"Cheesy"}

开始了。现在让我们试试没有比萨饼的 id。

curl -X GET http://localhost:8000/api/v1/pizzas/
[{"id":1,"title":"Quattro formaggi","description":"Cheesy"},{"id":2,"title":"Diavolo","description":"Spicy!"}]

如您所见,API 已经自动返回了数据库中的所有比萨饼。这是一个非常方便的特性,在我们这边不需要额外的逻辑。让我们尝试一个在我们的数据库中不存在的比萨饼。

curl -i -X GET http://localhost:8000/api/v1/pizzas/42/
HTTP/1.1 404 Not Found
Date: Wed, 03 Jul 2019 18:00:29 GMT
Server: WSGIServer/0.2 CPython/3.6.5
Content-Type: application/json
Vary: Accept, Cookie
Allow: GET, PUT, PATCH, DELETE, HEAD, OPTIONS
X-Frame-Options: SAMEORIGIN
Content-Length: 23

{"detail":"Not found."}

那里。Django REST Framework 已经自动给了我们一个 404 响应,甚至不用在我们这边多写一行代码。这样,编写为其他系统托管数据的后端服务变得非常容易。

这已经很令人兴奋了,然而,这并不是一切。让我们导航到我们的浏览器并查看http://localhost:8000/api/v1/pizzas/。你可以在图 4-1 中看到该页面的示意图。

img/481995_1_En_4_Fig1_HTML.jpg

图 4-1

Django REST 框架提供的管理接口

我们还收到了一个完整的用户界面,在这里我们可以使用这些资源。当有多个团队维护多个服务并且资源无处不在时,这个功能非常有用。这个用户界面为 API 的消费者提供了一种与资源交互的方式,而无需阅读大量的文档。

一些更熟悉 web 服务和 REST 的人可能熟悉分页的概念。几个请求之前,我们已经从服务中查询了所有的比萨饼,这是一个有用的功能,但是,当我们的用户创建了数百甚至数千个资源,每次他们需要信息时,我们都会返回给客户,这将导致一个巨大的问题。当人们使用只有蜂窝数据的移动设备时,这可能会特别痛苦。这是分页概念出现的原因之一。本质上,这个想法是客户端给出一个偏移量和一个批处理大小,并接收资源,这些资源以某种方式从偏移量索引到偏移量+批处理大小。这个概念非常简单,尽管通常需要在客户端实现。为了使用 Django REST 框架实现分页,我们需要做的就是将清单 4-9 中的以下几行添加到我们的 settings.py 文件中。

REST_FRAMEWORK = {
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
    'PAGE_SIZE': 5
}

Listing 4-9Basic pagination setup for the REST framework

在图 4-2 中,你可以看到现在实现的分页的第一页是什么样子:

img/481995_1_En_4_Fig2_HTML.jpg

图 4-2

数据库中前五个比萨饼的列表

正如你所看到的,我们的数据库中总共有 6 个比萨饼,端点返回给我们其中的 5 个,并给了我们以后可以使用的下一页的 URL。在查询了那个之后,你可以在图 4-3 中看到,我们在第二页上只收到了 1 个披萨。

img/481995_1_En_4_Fig3_HTML.jpg

图 4-3

页码的第二页

练习 4-2:运行中的 Rest 框架

我们已经阅读了很多关于 Django REST 框架的内容,是时候将它付诸实践了。使用这个框架,为我们在第二章中创建的所有资源类型创建 RESTful APIs。

为资源服务是很棒的,但是,如果你不保护你的客户正在使用的资源,你还不如马上离开这个行业。让我们讨论一下认证和授权客户端,以确保它们只访问正确的资源。

证明

当我们学习 Django 提供的基本许可系统时,我们已经在第二章中讨论了很多关于保护数据的内容。到目前为止,我们忽略了这个功能,但是,借助 REST 框架的强大功能,我们可以轻松地实现保护客户数据的身份验证功能。如果我们研究了框架中提供的和可以从框架中派生的所有身份验证方法,我们可能会在这本书的剩余部分花费时间。所以我们在这里只谈冰山一角。

首先,让我们找到一个应该被保护的资源。我认为“喜欢”是关于客户的非常敏感的信息,所以让我们开始吧。在清单 4-10 中,您可以看到我们将创建的视图集的原始代码:

from rest_framework import viewsets

from pizza.models import Like
from pizza.serializers import LikeSerializer

class LikeViewSet(viewsets.ModelViewSet):
    queryset = Like.objects.all()
    serializer_class = LikeSerializer

Listing 4-10Like viewset

我们通常需要问自己的第一个问题是,谁应该能够访问这个资源?首先,我们可以说,如果有人有一个特定的主密码,他们可以访问所有的喜欢。为此,我们可以利用无记名令牌授权的力量。

无记名令牌授权基本上就是在资源所有者的机器上存储一个密码,能够访问该密码的客户端也可以访问服务器上的资源。您可能还记得在前一章中,我们说过 12 因素应用的凭据应该存储在环境中。尽管 REST 框架有一个内置的基于令牌的认证,但是它只支持存储在应用数据库中的令牌。由于这违背了我们在第二章中学到的原则,当我们谈到来自环境的配置时,我们将继续前进,自己创建一个身份验证类。请记住,这并不违背这里的原则,但是 Django REST 框架 100%支持和推荐它,参见清单 4-11 中的解决方案。

import base64

from rest_framework import authentication, exceptions

from tizza.settings import CLIENT_TOKENS

class BearerTokenAuthentication(authentication.BaseAuthentication):

    def authenticate(self, request):
        try:
            authorization_header = request.META['HTTP_AUTHORIZATION']
            _, token_base64 = authorization_header.split(' ')
            token = base64.b64decode(token_base64.encode())
            client, password = token.split(':')
            if CLIENT_TOKENS[client] == password:
                return None, None
            else:
                raise exceptions.AuthenticationFailed("Invalid authentication token")
        except KeyError:
            raise exceptions.AuthenticationFailed("Missing authentication header")
        except IndexError:|
            raise exceptions.AuthenticationFailed("Invalid authentication header")

Listing 4-11Bearer token authorization for the REST framework

让我们快速浏览一下代码,这样我们就都在同一页上了。第一件看起来奇怪的事情是,我们从应用的设置中加载了这个名为 CLIENT_TOKENS 的常量。这应该是一个用 os 模块填充的字典,其中包含所有启用的客户端标识符及其各自的令牌。这里有一个例子:

CLIENT_TOKENS = {
    'pizza-service': 'super-secret-password-for-the-pizza-service',
    'other-service': 'another-secret-password',
}

我们实现自定义身份验证方法的方式是覆盖框架提供的 BaseAuthentication 类。我们需要做的就是实现接收请求本身的 authenticate 方法。这里,我们获取授权头,然后使用 base64 模块解析它的内容。我们在这里期望的字符串看起来像这样:pizza-service:super-secret-password-for-the-pizza-service,我们用冒号将它分开,得到客户端应用的名称和它们用来访问资源的密码。如果我们在给定客户端的设置中找到散列密码,我们就可以开始了。通常,您会返回一个用户对象作为元组中返回的第一个值,但这在我们的例子中并不重要,因为我们在身份验证时给出了完全访问权。

要进行试验,您需要用 base 64 对密码进行编码,并发出如下请求:

curl -X GET -H "Authorization: Bearer cGl6emEtc2VydmljZTpzdXBlci1zZWNyZXQtcGFzc3dvcmQtZm9yLXRoZS1waXp6YS1zZXJ2aWNlCg==
" http://localhost:8000/api/v1/pizzas/6/
{"id":6,"title":"Nutella","description":"I can't even"}

您可以看到响应和以前一样工作。这里没什么特别的。不过,让我们在没有标题的情况下尝试一下。

curl -X GET http://localhost:8000/api/v1/pizzas/6/
{"detail":"Missing authentication header"}

此外,我们可以尝试使用无效的令牌:

curl -X GET -H "Authorization: Bearer ijustwantin=" http://localhost:8000/api/v1/pizzas/6/
{"detail":"Invalid authentication header"}

那么,我们到哪里了?我们已经能够为我们的服务创建一个基本的身份验证方法,我们可以在未来的各种其他服务中重用它。将来,我们可以在服务到服务的通信之间使用基于承载令牌的认证。您生成和分发这些令牌的方式完全由您决定。在理想情况下,令牌是动态的,可以根据需要经常轮换。

这一切都很好,但是,在理想世界中,我们有多种类型的身份验证。我们不希望所有用户都拥有对所有对象的完全访问权,对吗?现在,REST 框架有一种称为 SessionAuthentication 的身份验证。会话是 Django 处理登录用户识别的一种基本方式,会话是在用户登录到系统时设置的,当会话过期或用户故意注销时被禁用。让我们快速概述一下如何在 Django 中配置会话:

  • 数据库——在这个版本中,您将使用数据库作为会话后端。每次设置会话时,都会进行一次数据库调用。在这种情况下,会话信息仍然来自客户端,但是,它通常以 cookie 的形式表示会话的 ID。要使用它,您需要在您的已安装应用列表中启用 django.contrib.sessions ,并运行 manage.py 迁移来创建会话数据库表。这通常被认为是最基本的会话形式。它很容易维护,直到达到一定规模,之后您的数据库可能会很容易不堪重负。

  • 缓存 -缓存是存储会话的好方法。与数据库方法类似,这可能会在较高的负载下崩溃,而且它的容错性可能会较差,因为对于所有缓存重启,所有登录的用户都将被注销。可以与基于数据库的 backed 结合使用。要使用,您需要将django . contrib . sessions . backends . cache分配给 settings.py 中的 SESSION_ENGINE 变量。

  • Cookies - Cookies 也是存储会话的好方法。Cookies 可以保存用户向您的平台发出的每个请求的签名数据。使用签名的数据,您可以存储关于他们的会话、他们的身份验证信息等信息。会话 cookies 用 Django 应用的 settings.py 中设置的 SECRET_KEY 签名。会话 cookies 在服务器上的空间开销不大,但是在影响用户的网络通信中,它们的开销会很大,所以请记住这一点。要使用,将django . contrib . sessions . backends . signed _ cookies设置为 SESSION_ENGINE 的值。

在了解了我们在这里看到的 12 个因素和会话方法后,您可能会有这样的印象,分布式系统中会话的最佳解决方案之一可能是 cookies,您没有错。cookies 的分布式特性、您可以设置它们的过期时间以及您可以设置它们不能通过浏览器以编程方式访问的事实,使它们在这个列表中占据了首要位置。下面的练习 4-3 和 4-4 将重点扩展我们关于认证和 cookies 的知识。

练习 4-3:身份验证

现在我们已经了解了这一点,尝试一下基于 cookie 的身份验证,用它做一些探索。设置所需的值,并通过我们在前面章节中构建的登录页面登录。检查饼干,它们的有效期,如果你能理解它们的内容。

练习 4-4:扩展会话

我们已经了解了很多关于会话和会话 cookie 的知识,但是,还有很多需要探索的地方。在本练习中,我建议您创建一个新的身份验证后端,它获取一个名为 pizza-auth 的定制 cookie,并将其加载到请求 auth 中。应该对 pizza-auth cookie 进行加密,并将其分配给用户登录请求的响应。

我们已经谈了很多关于同步世界的问题。正如你所看到的,REST 和同步通信有很多好处,但是,如果你有太多的服务调用太多的其他服务,就很容易使你的系统变得迟钝。业界提出的解决这个问题的一个解决方案是异步通信和排队系统。

异步通信

系统之间的同步提供了清晰的通信和一种简单的方法来推理应用。每当你请求一个操作,请求被处理,完成,然后当你收到一个响应,你可以肯定你想要的是成功或失败,但它已经完成。然而,有时如果我们只坚持同步通信,我们会在应用设计中遇到各种各样的困难。考虑以下示例:

我们在 tizza 应用上进展顺利,人们正在使用它,架构也在不断发展,现在有多个团队拥有和开发多个微服务。这是 2018 年 2 月的一个阳光明媚的日子,我们的安全主管告诉我们,GDPR 即将到来。为了与 GDPR 兼容,我们需要确保所有用户上传的信息,包括喜欢和参加的活动,都需要被删除。现在,团队坐下来头脑风暴手头的问题,并提出了图 4-4 中的架构计划。

img/481995_1_En_4_Fig4_HTML.png

图 4-4

GDPR 的简单解决方案

正如您所看到的,解决方案非常简单:当用户从 auth 数据库中删除时,会有一个对每个保存用户虚拟值的服务的远程调用。作为第一个版本,这可能是一个好主意,但是,这带来了各种问题:

  1. 它将如何扩展?-那么,现在这已经完成了,每次我们引入一个新的服务,它包含一个指向用户对象的“指针”需要被添加到这个列表中?当我们有太多的请求,用户删除变得难以忍受时,还有什么意义呢?

  2. 谁拥有代码?-如果第二次远程调用失败会怎样?拥有用户信息的人应该保持这些代码的维护和可靠性,还是在这个逻辑中每个团队都拥有自己的一小段代码?如果这个代码需要一些按摩,谁会在半夜醒来?

  3. 谁依赖谁?-到目前为止,其他服务都依赖于 auth 服务中的用户信息。然而,现在 auth 服务也依赖于其他应用,这使得系统的耦合性超出了预期。

如你所见,这种方法有很多问题。幸运的是,有解决方案可以减少耦合,使系统中的所有权更清晰。我们称这些系统为队列。

队列的概念

你可能记得在高中或大学学过排队。现在,想象同样的概念,只是在建筑层面上。系统中的应用将消息发布到代理或主题,然后代理或主题将消息放到队列中,供工作人员使用。图 4-5 简单概述了上面的例子是如何工作的。

img/481995_1_En_4_Fig5_HTML.png

图 4-5

GDPR 的队列概念

如您所见,auth 服务发布了一条消息,表明用户已从系统中删除。消息被发布到代理,代理将消息推送到三个单独的队列。一个删除赞,一个删除出席率,一个删除上传的披萨。

这样有什么好处?

  1. 我们已经扩展了——现在我们不需要为我们需要处理的每个删除创建一个新的调用,我们只需要创建一个新的队列并将其绑定到代理。这样,我们在呼叫端保持快速。

  2. 我们已经清理了所有权-您需要删除客户信息?只要听听这条消息,为它实现一个处理程序,就可以了。删除用户信息现在归每个团队所有,这意味着拥有 auth 服务的团队不需要了解存储用户信息的每个服务。

  3. 依赖关系变得松散——通过消除各种服务之间的硬性依赖关系,系统变得耦合性更低。耦合还是有的,但至少发布端不需要知道这些。假设从明天开始,事件将由第三方处理,我们不需要在那里照顾 GDPR,如果我们有适当的排队系统,我们需要做的只是移除连接到事件应用的处理器,我们就完成了,授权团队不需要担心任何事情。然而,在前一个解决方案中,我们需要为 auth 团队创建一个变更请求。

不幸的是,Django 没有为这些队列编写消费者的超级方便的支持。因此,对于这本书的这一部分,我们将把 Djangoverse 留给几个段落,并研究 Python 中针对异步问题的框架不可知的解决方案。

示例解决方案- RabbitMQ

我们要看的第一个工具是 RabbitMQ。RabbitMQ 是在 2000 年代中期基于高级消息排队协议构建的。它是目前在大型系统中用于异步通信的最流行的工具之一。像 Reddit、9GAG、HelloFresh 这样的公司,甚至股票交易所,每天都在使用这种优秀工具的力量在他们的系统中产生和消费数百万条消息。如果你想了解更多关于替代品的信息,我推荐你去亚马逊 SQS 或者阿帕奇卡夫卡看看。出于 Python 工具的目的,我们将使用为 RabbitMQ 创建的 pika 包。

pip install pika

让我们来看看它是如何工作的核心概念。

生产者

生产者是 RabbitMQ 的一部分,他们将组装和发布我们希望异步消费的消息。在大多数情况下,这些代码将存在于您的 Django 服务中。让我们继续我们之前介绍的用户删除和 GDPR 问题。在我们的用户视图集中,我们将通过 API 发布一条关于用户被删除的消息。

首先,在清单 4-12 中,我们将创建一个小的助手类,这样我们可以用一种简单的方式进行制作。

# pizza/utils/producer.py
import json
import pika
import sys

from django.conf import settings

class Producer:
    def __init__(self, host, username, password)
        self.connection = pika.BlockingConnection(
            pika.URLParameters(f'amqp://{username}:{password}@{host}:5672')
        )
        self.channel = connection.channel()
        self.exchanges = []

    def produce(exchange, body, routing_key="):
        if exchange not in self.exchanges:
            channel.declare_exchange(exchange=exchange)
            self.exchanges.append(exchange)
        self.channel.basic_publish(
            exchange=exchange,
            routing_key=routing_key,
            body=json.dumps(body)
        )

producer = Producer(
    host=os.environ.get('RABBITMQ_HOST'),
    username=os.environ.get('RABBITMQ_USERNAME'),
    password=os.environ.get('RABBITMQ_PASSWORD'),
)

Listing 4-12Basic publisher for RabbitMQ

这里需要解释几件事:

  • 我们使用了 Python 的全局对象模式,当您想要创建行为类似于单例的对象时,这在 Python 世界中很常见。我们将要使用 publisher 的方式只是从 pizza.utils.producer 导入 producer

  • 当我们创建连接时,我们使用 AMQP DSN。这实际上是 AMQP 文档推荐的。确保为所有将连接到 RabbitMQ 代理的应用创建单独的用户和密码。

  • 出现了几个术语:

    • 交换:您可以将交换视为消息类型的逻辑分隔符。例如,您可能希望将有关用户的消息发布到用户交流,或者将有关喜欢的消息发布到喜欢交流。除了作为域分隔符,交换还决定有多少队列应该接收消息许多公司不使用交换,这完全没问题。需要手动创建交换(如您所见)。

    • 路由键:我们使用路由键来确保正确的消息到达正确的地方。例如,我们可以让 likes 系统和 pizzas 系统分别监听用户删除的消息。在这种情况下,我们将创建两个路由关键字, likes.deletepizzas . deleteonusersexchange。

  • 在消息的发布中隐藏了一个微小的最佳实践,即发送一个 JSON 主体。在这一章的后面,我们将讨论为什么组织你的消息是非常重要的(就像在 REST 中一样)。

我们可以使用上面的代码,如清单 4-13 所示:

from pizza.utils.producer import producer

...

class UsersViewSet(viewsets.ModelViewSet):
    queryset = User.objects.all()
    serializer_class = UserSerializer

    def destroy(self, request, *args, **kwargs):
        user = self.get_object()
        response = super().destroy(request, *args, **kwargs)
        producer.produce(
            exchange='user',
            body={'user_id': user.id},
            routing_key='user.deleted'
        )
        return response

Listing 4-13Using the basic publisher to publish user deleted information

现在,每当一个用户对象通过我们的 API 被删除时,我们将向用户交换发送一个用户被删除的消息。

顾客

现在,对该消息感兴趣的系统可以创建一个队列,并用给定的路由键将它绑定到交换机。清单 4-14 中有一个简单消费者的实现:

import os
import pika

class Consumer:
    def __init__(self, host, username, password):
        self.host = host
        self.username = username
        self.password = password

    def _init_channel(self):
        connection = pika.BlockingConnection(
            pika.URLParameters(f'amqp://{self.username}:{self.password}@{self.host}:5672')
        )
        return connection.channel()

    def _init_queue(exchange, queue_name, routing_key):
        queue = channel.queue_delcare(queue=queue_name)
        channel.queue_bind(exchange=exchange queue=queue_name, routing_key=routing_key)
        return result.method.queue

    def consume(self, exchange, queue_name, routing_key, callback):
        channel = self._init_channel()
        queue_name = self._init_queue()
        channel.basic_consume(
            queue=queue_name,
            on_message_callback=callback,
        )

consumer = Consumer(
    host=os.environ.get('RABBITMQ_HOST'),
    username=os.environ.get('RABBITMQ_USERNAME'),
    password=os.environ.get('RABBITMQ_PASSWORD'),
)

Listing 4-14Basic consumer implementation

现在,当我们想要使用这个消费者时,我们只需要编写一个简单的 Python 脚本,我们可以将它存储在后端应用代码本身旁边。我们实际上可以编写一个 Django 脚本来扩展我们的应用,并使用我们在运行 Django 应用或 shell 时会有的所有好处,如清单 4-15 所示。

import json

from django.core.management.base import BaseCommand, CommandError

from pizza.models import Likes
from pizza.utils.consumer import consumer

class ConsumeUserDeleted(BaseCommand):
    help = "Consumes user deleted messages from RabbitMQ"

    def _callback(channel, method, properties, body):
        payload = json.loads(body)
        user_id = payload.get('user_id')
        if user_id is not None:
            likes = Likes.objects.filter(user_id=user_id)
            likes.delete()

    def handle(self, *args, **options):
        consumer.consume(
            exchange='users',
            queue='users-deleted',
            routing_key='user.deleted',
            callback=self._callback,
        )

Listing 4-15Basic consumer usage

现在我们已经创建了一个简单的分布式消息传递系统。有趣的是,消费者和生产者可以生活在不同的机器上,甚至不同的云提供商。例如,您可以在自己的机器上设置一个系统,使用来自 exchange 的消息进行调试。尝试通过 API 删除用户,看看系统的其余部分如何优雅地处理请求。在下一节中,我们将研究一些在您将异步排队系统引入您的架构时值得遵循的最佳实践。

异步最佳实践

到目前为止,我们看到的所有这些东西似乎都很简单。然而,当你开始大规模工作时,可能会有点混乱。我们将介绍一些在构建这些异步组件时应该考虑的最佳实践。

消息有效负载

最困难的事情之一是确保当消息生产者改变消息有效负载时,消费者不会感到困惑并遇到异常。为此,对您的有效负载进行版本化可能是个好主意,就像我们对 REST APIs 进行版本化一样。

有多种方法可以对您的有效负载进行版本控制。最常见的方法是将消息的版本添加到路由关键字中。一些论坛建议对消息版本使用语义版本化,这使得路由关键字看起来像这样:

user.deleted.1.2.3

分解:

  • user.deleted -原始路由关键字

  • 1.消息的主要版本。当发布的消息有重大变化时,这个数字应该会增加。例如:用户 id 变成了字符串而不是数字。

  • 2.次要版本。一些新的功能已经进入信息,它不应该打破信息消费。例如,邮件开始还包含被删除用户的电子邮件地址。一些系统可能需要这些信息来完成它们的操作,但是这不会影响旧系统。

  • 3.补丁版本。消息中没有重大更改,也没有新功能发生变化,这可能表明消息负载中存在错误修复。

现在,如果你想让事情变得简单,你可以只保留 1 个数字,上面列表中的主要数字。那样的话,工作量会少一点。

让我们做一个简短的练习,其中有两个团队:auth 团队和 likes 团队。出于安全原因,auth 团队决定将用户标识符从数字改为 uuid。这意味着消息也需要更新。那么,在这种情况下,版本迁移是什么样的呢?

  1. 在项目开始时,auth 团队发出一个通知,告诉所有团队标识符中的用户模型将发生变化。

  2. auth 团队实现 uuids,同时发布以下消息:

    a. publisher.publish(
           exchange='user',
           body={'user_id': user.id},
           routing_key='user.deleted.1.0.0')
    
    b. publisher.publish(
           exchange='user',
           body={'user_uuid': user.uuid},
           routing_key='user.deleted.2.0.0')
    
    
  3. auth 团队向公司发送了一份关于他们的 1.0.0 消息的反对通知,并给出了一个合理的迁移截止日期。

  4. 点赞团队计划并实施监听用户删除新版本。

  5. 团队删除了旧版本的代码。

我知道这听起来很理想化,但是,这是您可以确保在突破性特性的实现过程中不会出现中断的方法。

处理代理停机

排队系统的一个问题是代理中断。基本上,当中央排队组件停止工作时,可能会出现无数问题:

  • 丢失的消息:可能是最糟糕的。当消息丢失时,无论如何也找不到它们的踪迹,应用的整体状态就会发生扭曲。这不仅会在给定时刻引起问题,还可能在将来引起矛盾。想象一下,一个支付状态机对客户的当前状态感到困惑。

  • 断开的生产者和消费者:在代理中断期间,网络也可能会放弃,生产者和消费者通常会悄悄地与代理断开连接。如果忘记重新连接这些系统,将来可能会引起不愉快的意外。

为了避免这样的灾难,您需要做的第一件事就是在您的代理集群上建立适当的监控和警报系统。您越早知道停机,就能越快做出反应。如果你有资源,一个更好的解决方案可能是将你的经纪人托管业务外包给专业人士。如果这不是你的核心能力,你可能不应该试图去设计它。

另一个可以在内部实现的解决方案是使用各种设计模式来提高集群的弹性。您需要保护的最重要的事情是数据完整性,为此,有一种称为发件箱的模式。

你们中的一些人可能熟悉发件箱模式,对于那些不熟悉的人,这里有一个复习:发件箱是一个软件工程模式,主要用在发送同步或异步消息的应用中,其中所有的消息都存储在一个存储中,然后由一个外部进程从给定的存储中调度。调度程序通常被称为发件箱工作程序。图 4-6 架构的快速概览。

img/481995_1_En_4_Fig6_HTML.png

图 4-6

发件箱架构

如您所见,该服务想要向 RabbitMQ 代理发布一条消息。首先,它将消息存储在数据库中,然后发件箱工作进程获取消息并将其发布给代理。乍一看,这似乎是浪费时间,但是,如果你仔细想想,只有在发布绝对成功的情况下,消息才会从冷存储中删除。这意味着,如果代理关闭,消息仍然保持完整。

正如您所看到的,异步通信是一个非常强大的工具,可以最大限度地提高速度、效率并进一步解耦您的系统。在我们进入下一章之前,我想提一下异步通信的缺点。

  • 数据重复:我想我已经在某个地方提到过这个问题。我想再次强调。对于异步系统,跨集群复制数据变得非常诱人,因为您必须保持速度,对吗?好吧,一旦你开始复制数据,你将陷入一个无休止的螺旋,以确保你这边的一切都是正确的。首先,您将只收听用户创建的事件,然后更新地址,然后有一天您会忘记收听在您的一个应用中更新的电子邮件,并且数据将开始在您的集群中不一致。你不会想要那种头痛的,相信我。我的建议是尽量减少数据重复。

  • 异步孕育异步:正如您在发件箱模式中看到的,我们已经创建了一个异步解决方案(定期从数据库中获取)来解决最初由异步通信引起的问题。这似乎是异步系统中的普遍现象。一般来说,异步系统更难测试,也更难推理。如果我们在它上面增加更多的异步,我们不会让它变得更容易。

  • Race conditions: Now, race conditions can exist in both synchronous and asynchronous systems, however, in my experience, they are way more manageable in synchronous systems. The biggest issues arise when you’re mixing the two without much thought. Imagine the following situation:

    • 服务 A 发布服务 B 和 C 监听的消息 M

    • 服务 C 需要来自服务 B 的一些数据来进行消息处理,这依赖于服务 B 已经处理了消息

    • 如果服务 B 不够快,我们就丢失了服务 C 中的信息

    如果您的团队决定采用代表上述描述的问题的解决方案,我建议为最依赖数据的服务(在本例中为 C)构建一个强大的重新排队逻辑。如果 C 能够处理来自其他服务的错误,您就可以开始了。然而,在现实生活中,竞争条件的复杂性可能比这里解释的要高得多。我还建议始终监控连接、未被确认的消息、异常和队列吞吐量,以确保数据不会丢失。

结论

我们已经讨论了很多关于系统中各种服务之间的通信。所有的交流方式都有其优点和缺点。每一种情况都要求你重新考虑你要用什么工具来解决给定的问题。在构建微服务时,固定通信层可能是最大的挑战。有了这些工具,你肯定不会犯巨大的错误。

五、从单体到微服务

现在,我们已经了解了我们在服务中的目标是什么,以及我们如何将它们相互联系起来,是时候让我们更仔细地了解实际技术了,这将有助于我们从单一应用迁移到微服务架构。请注意,这里描述的技术不是灵丹妙药,您肯定需要根据自己的使用情况对它们进行修改,但是,经验表明,这些通用方法为成功的迁移提供了一个极好的起点。请记住,本章中描述的一些步骤是并行的,所以如果有更多的人来帮助你,那么可以加快一点速度。

开始之前

到目前为止,您可能已经理解了将您的单片系统迁移到微服务并不只是在公园里散步。将会有严重的人力和财力成本。甚至估计交付给你的涉众也可能是一个巨大的困难(至少在开始的时候),所以让我们来看看你需要计算的基本成本。

人力成本

自然,我们谈论的主要是重构代码库的成本。在项目的早期阶段,您将需要比迁移多个组件时多得多的努力。一开始对你的估计要非常保守,在你准备好工具之后,对你自己和你的团队要更严格一点,我们将在第五章和稍后的第六章中讨论。

根据我的经验,有两个领域的迁移可能会非常困难,并且可能会显著增加您的迁移的编码成本:

  1. 运营相关——当迁移到一种新的架构类型时,如何部署和扩展您的新服务始终是一个关键且有分歧的问题。monolith 的部署可能是一个人运行几个脚本将数据同步到远程服务器,然后重新启动应用,监控(当您如此大规模地改变您的基础架构时,这是一个非常重要的方面)可能是在同一条船上。当您转向微服务时,从长远来看,这可能不会奏效。至少,您需要收集这些可执行文件,并以一种可用的方式组织它们,以便公司中的其他人也可以访问它们。我们将在下一章讨论更多与操作相关的主题。

  2. 代码相关——你知道什么时候代码像一碗意大利面条一样凌乱,而不是像一片切片的比萨饼一样整齐有序吗?在一个不断推进交付的高速环境中,保持代码库的整洁是一个巨大的挑战。当您想要迁移时,混乱的代码可能是另一个巨大的成本。

根据您的公司和单一应用的规模,最好有一个专门的团队来为其他团队处理工具、文档、指南和最佳实践,这些团队拥有组件迁移的领域知识。如果你和数百名工程师一起操作数百万行代码,这几乎是必须的。如果你的规模稍小,这可能是一个方便。

当一个关键组件由于弹性或其他问题需要迁移时,一些公司喜欢实施应急或“老虎”团队。这可能是将大量软件转移到不同系统的好方法,但是,强烈建议关注代码的移交,并在迁移和维护团队之间实施密集的知识共享会议。

现在,让我们看看我们需要实施的硬件和基础架构的成本。

基础设施成本

转移到微服务可能会有另一个昂贵的影响,即拥有足够的机器来运行新系统(以及一段时间内旧系统)的成本。这到底是什么意思?让我们考虑以下场景:

我们的 tizza 应用运行在两个 10 核机器上,内存为 128。在迁移规划期间,我们已经确定了 6 个系统,我们可以在逻辑上将应用分成 6 个系统。现在,让我们来计算一下:

根据系统的负载,我们需要单核或双核机器来提供新服务。处理认证等的系统可能需要两个内核和 8gb 的 ram,而披萨元数据存储可能只需要一个内核和 4gb 的 RAM。我们可以将整个集群的 CPU 数量平均为 8,总内存成本为 32gb。因为,我们曾经用 2 台机器来处理 monolith,我们也应该提高这里的数字,我们毕竟不想降低弹性。

当您试图将您的系统缩减为更小但更高效的部分时,缩小您的集群规模并低估安全运行您的软件所需的原始功率是一种非常人性化的反应。在创建新的微服务器时,我喜欢遵循的一般经验法则是在不同的(虚拟或物理)机器上运行服务的 3 个副本,以实现高可用性。

对于自信的人来说,有了优秀的云提供商、超轻量级的应用和配置良好的自动伸缩系统,就可以消除上述说法。

注意

自动扩展是指定义关于您希望在集群中运行的服务器数量的规则。该规则可以是内存或 CPU 使用量、集群的实时连接数、一天中的时间或云提供商可能允许您使用的其他值的函数。

正如您所看到的,我们将系统中的内核总数从 20 个增加到了 24 个,内存保持在 128 左右,总计为 96 个。您将很快注意到,在现实生活环境中,这些数字往往会比预期增长得更快,并且取决于您的提供商,这可能会给您的业务带来毁灭性的成本。

我的建议是,为了安全起见,在开始的时候过犹不及,并不时地重新审视你的应用,以确保硬件不会对软件造成过度破坏。

我打了电话,接下来呢?

到目前为止,在阅读本书时,你脑海中出现的最大问题可能是如何说服你的公司,这对他们来说是一项值得的投资,而不仅仅是一个有趣的重构。这是一场旷日持久的辩论,没有灵丹妙药。我会试着给你一些建议,这样你就可以开始了:

  • 作为一等项目公民的技术债务:通常人们认为这种类型的变化将需要大项目,其中多人以宏大的规模合作。从本章你会看到,事实并非如此。我能给出的第一个建议是将技术债务和重构转移到你需要交付给公司的特性项目中。确保你在这件事上是透明和合理的,这样你将来也可以这样做。此外,如果你收到一个“不”,那也没关系,只是要坚持下去,这都是关于谈话的。将与技术债务相关的任务放入特性项目中,可以使工程师在这两个方面都更有效率。上下文切换更少,使得特性开发和技术债务工作更有效。工程师们也会更开心,因为他们会把更高质量的工作抛在身后。

  • 衡量你的结果:如果你能够在这里或那里进行一些重构,向你的同事展示使用你的新数据库界面有多容易,或者用你提取的功能交付新功能有多快,并确保告诉你的产品所有者或经理。如果你有指标来证明你的工作是值得的,那就更好了。这些指标通常很难找到和提出,其中一些可能与应用速度有关,一些可能与交付速度有关(例如,由于我们在服务中进行了这种和这种技术更改,新功能发布的速度有多快),甚至是发给您团队的 bug 单的数量。

  • 坦诚:确保你衡量了成本并向所有利益相关者解释了成本,而且你诚实地做到了。这将是一个大项目,没有必要让它看起来很小。人们确实需要理解,特性开发将会放缓一段时间,并且将来围绕它的过程会有所不同。

  • 有时候没有也没关系:完全有可能你的公司还没有准备好像这样的大规模迁移。在这种情况下,确保你和你的公司尽可能成功,这样你就可以在不久的将来遇到弹性问题。在介绍性章节中,我们已经看到了流应用的灾难场景。像这样的冲击可以导致一家公司改变他们的思维模式,然而,你需要首先在业务上达到那个规模。如果你收到太多的“不”,那么可能是时候重新思考你自己的范围了,把它缩小到你能做到的最小范围,向公司展示这个过程是什么样的,它的价值是什么。

正如你所看到的,从很多方面来说,打这样的电话对一家公司来说是非常困难的。最好的策略通常是保持耐心,把你的挫折抛在脑后,使用你从本书中学到的重构技术和工具,它们在未来会派上用场。

既然我们已经了解了任务的成本,那么是时候开始迁移我们的应用了,首先,通过准备数据。

数据准备

在我们进入重构应用的有趣部分之前,我们需要确保我们想要传输的数据是可传输的,这意味着它很容易从一个地方复制到另一个地方,并且不会与系统中的其他数据域耦合太多。除此之外,我们需要找到从领域和业务角度看似乎生活在一起的数据集群。

域分片

如您所见,我们可以识别出以下共存的数据块:

  • 用户相关信息

  • 披萨和餐厅信息

  • 喜欢和匹配组

  • 事件和事件相关集成

领域规定了上述内容,然而,在上述内容的某些部分之间仍然有许多硬耦合。让我们来看看比萨店的模式:

class Pizzeria(models.Model):
    owner = models.ForeignKey(UserProfile, on_delete=models.CASCADE)
    address = models.CharField(max_length=512)
    phone = models.CharField(max_length=40)

如您所见,我们在用户配置文件模型的 owner 字段上有一个硬外键规则。您需要做的第一件事是确保这些外键将指向虚拟对象,其中外部对象可以被认为是这样的引用:

class Pizzeria(models.Model):
    owner_user_profile_id = models.PositiveIntegerField()
    address = models.CharField(max_length=512)
    phone = models.CharField(max_length=40)

为什么这是有益的?现在,对象之间的耦合性降低了,并且更加基于信任。比萨饼将信任系统,有一个用户具有给定的用户配置文件 id,并可以生活在他们自己的独立环境中。不再有硬编码的数据库规则将比萨饼和用户资料绑定在一起,这非常解放,但同时也非常可怕。

我们失去了什么?

  • 级联删除不见了。您需要手动删除链接的对象。

  • Django ORM 提供的一些方便的方法,比如 select_related,不再可用。

自然,您可以(也应该)保持驻留在同一个数据库中的模型之间的耦合,这样您就保留了方便的方法,并为您的查询提供了一些速度和可靠性。

如果您不是数据库专家,这似乎是一项艰巨的任务。然而,你可能记得我们在第二章中了解到的一个强大的工具,叫做迁移。您可以非常容易地创建一个新的迁移,用标识符替换外键。清单 5-1 为比萨店提供了一个范例。

def set_defaults(apps, schema_editor):
    Pizzeria = apps.get_model('pizza', 'pizzeria')
    for pizzeria in Pizzeria.objects.all().iterator():
        pizzeria.owner_user_profile_id = pizzeria.owner.id
        pizzeria.save()

def reverse(apps, schema_editor):
    pass

class Migration(migrations.Migration):

    dependencies = [
        ('pizza', '0002_pizzeria'),
    ]

    operations = [
        migrations.AddField(
            model_name='pizzeria',
            name='owner_user_profile_id',
            field=models.PositiveIntegerField(null=True),
            preserve_default=False,
        ),
        migrations.RunPython(set_defaults, reverse),
        migrations.AlterField(
            model_name='pizzeria',
            name='owner_user_profile_id',
            field=models.PositiveIntegerField(),
        ),
        migrations.RemoveField(
            model_name='pizzeria',
            name='owner',
        ),
    ]

Listing 5-1Example migration from model to id

让我们仔细看看这段代码。在我们更改了模型并运行了 makemigrations 命令后,系统会提示我们为我们创建的新字段提供一个默认值,这里我们可以给 0,这不会有太大影响。为了确保所有的值都设置正确,我们将以上述方式修改迁移代码。逻辑如下:

  1. 我们向表中添加一个名为 owner_user_profile_id 的新字段。我们将它设置为可空,因此迁移可以毫无问题地创建它。

  2. 我们运行一组 Python 代码,这些代码将相应地为我们设置值:

    1. set_defaults 函数从已经创建的 pizzerias 中获取所有值,并将它们添加到新字段中。正是我们需要的。

    2. 如果我们真的需要,我们可以为这个函数指定一个反函数。现在不需要它。

  3. 我们将 owner_user_profile_id 字段改为不可空。

  4. 我们将永久删除所有者字段。

你可以使用上面的模板来迁移几乎所有的文件。对于行数较多的表(即将整个数据库加载到内存中是很危险的),强烈建议将 set_defaults 函数中的查询改为批量操作。或者,对于非常大的表(我们这里讨论的是数百万个业务关键行),您可能希望让数据库专家来帮助迁移。

您可能会有这样的预感,如果您运行这个迁移,一切都会崩溃。嗯,这完全是真的。所有 pizzeria 对象上的 owner 字段将从那里开始破坏您代码库中的代码,这可能会引起一些麻烦。理想情况下,您将更改代码库中的所有代码,以使用为获取所有者对象而创建的新字段,然而,有一些方法可以保护我们不被破坏,例如使用 Python 属性,请参见下面的清单 5-2 。

class Pizzeria(models.Model):
    owner_user_profile_id = models.PositiveIntegerField()
    address = models.CharField(max_length=512)
    phone = models.CharField(max_length=40)

    @property
    def owner(self):
        return UserProfile.objects.get(id=self.owner_user_profile_id)

Listing 5-2Using properties as model fields

以上述方式使用属性可以大大加快迁移过程,但是,从长远来看,它可能会导致问题,特别是性能方面的问题,因为我们刚刚从一个非常高效的数据库连接操作转移到另一个要执行的查询。但是,您稍后会注意到,这并不是我们将获得的最大速度提升。让我们看一下迁移的后续步骤,我们将确保新旧系统都可以访问数据。

数据库复制

在决定了要迁移应用的哪一部分并相应地修改了数据库之后,就该在数据库级别上建立迁移计划了。也就是说,是时候准备您的新数据库来托管您的模型了。

也许最简单的开始方法是建立主数据库的副本。我们的想法是将所有写入内容拷贝到复制副本,该复制副本将用作只读。不要太担心只为特定的表设置复制,大多数时候这只会带来麻烦和额外的工作。通常更简单的方法是设置一个完整的复制,并在迁移准备就绪时,从新数据库中删除不需要的表。

注意

您还可以在两个数据库之间设置主-主复制,但是,这种技术需要大量的数据库专业知识,并且为发布后的错误提供了更多的空间。

根据数据库的大小和类型,复制可能需要几分钟到几天的时间,因此在与您的直线经理和团队通信时,请确保将这一点添加到您的估计中。首先,您可能想看看 Amazon RDS 是如何进行数据复制的。如果你想更深入地了解这项技术,dev.mysql.com 网站上有关于如何为 MySQL 设置复制的很好的文档,Postgres 维基百科上有关于 Postgres 的文档。

测试和覆盖范围

我们已经做了一些准备。现在是时候复制所有代码了…开个玩笑。理想情况下,这是您确保在将代码从一个系统迁移到另一个系统时应用不会中断的地方。

要做到这一点,你可以使用的最有用的工具就是测试。Django 自带内置的测试框架,你可以很容易地测试数据库级别的测试,包括内存数据库,然而,任何单元测试框架都可以完成这项工作,比如单元测试 2pytestnose

当谈到如何衡量你在测试方面做得好不好时,许多团队和工程师推荐使用像 coverage 这样的工具,用它你可以衡量你在应用中测试过的代码行数。然而,这个度量并不总是测量您的测试的真实价值。建议您覆盖视图和模型的核心业务功能。理想情况下,如果您在运行后端应用时暴露了一些外部通信方法,那么您还可以实现集成测试,测试整个端点或消费者提供的功能。如果你有人员,那么你也可以实施验收测试,这通常是非常高水平的测试,自动机器人点击通过你的网站,检查基本用户流是否成功。这些系统通常非常脆弱,维护起来也很昂贵,但是,在一个关键的 bug 投入生产之前,它们可以作为最后一道防线拯救生命。cucumber 是一个优秀的验收测试框架,你可以在 cucumber.io 上了解更多。

既然我们已经用测试覆盖了代码,是时候开始使用一些工具了,这样我们就可以将代码库从一个地方迁移到另一个地方。

移动服务

到目前为止,我们对想要迁移的模型做了一些工作,并准备了一个新的数据库。是时候开始实际迁移代码了。

远程模型

在我们能够复制想要在独立系统中运行的代码库部分之前,我们需要确保两个代码库之间的依赖关系是可管理的。到目前为止,我们已经了解到 Django 和 Python 是构建和维护服务的非常灵活的工具,然而,我们也了解到对模型形式的数据有很大的依赖性。考虑清单 5-3 中的代码片段,我们希望将它迁移到一个单独的服务中:

from pizza.models import Like
from user.models import UserProfile

def get_fullname_and_like_count(user_profile_id):
    user_profile = UserProfile.objects.get(id=user_profile_id)
    full_name = user_profile.first_name + ' ' + user_profile.last_name
    likes = Likes.objects.count()
    return full_name, likes

Listing 5-3Problematic function to extract

无论我们想从哪个服务中提取上面的代码,我们都会面临一个两难的境地。函数中存在对模型的交叉引用,这可能很难解决。如果我们想避免数据重复和清理领域,我们需要确保喜欢和用户配置文件不驻留在单独的服务和数据库中。为此,我们可以做一个重构技术,我们称之为远程模型。

远程模型是我在职业生涯中多次遇到的一个概念,它们是真正的救星。这个想法是,如果你的 API 是统一的,你可以很容易地用远程调用替换你的数据库模型调用,在你的代码库中使用简单的搜索和替换(至少在大多数情况下)。参见清单 5-4 中的远程模型实现示例。

注意

我们将看到的代码可能不完全符合您的需求,但它是一个很好的起点,可以让您开始用远程模型思考问题。

import requests
import urllib.parse

from settings import ENTITY_BASE_URL_MAP

class RemoteModel:

    def __init__(self, request, entity, version):
        self.request = request
        self.entity = entity
        self.version = version
        self.url = f'{ENTITY_BASE_URL_MAP.get(entity)}/api/{version}/{entity}'

    def _headers(self, override_headers=None):
        base_headers = {'content-type': 'application/json'}
        override_headers = override_headers or {}
        return {
            **request.META,
            **base_headers,
            **override_headers,
        }

    def _cookies(self, override_cookies=None):
        override_cookies = override_cookies or {}
        return {
            **self.request.COOKIES,
            **override_cookies,
        }

    def get(self, entity_id):
        return requests.get(
            f'{self.url}/{entity_id}',
            headers=self._headers(),
            cookies=self._cookies())

    def filter(self, **conditions):
        params = f'?{urllib.parse.urlencode(conditions)}' if conditions else "
        return requests.get(
            f'{self.url}/{params}',
            headers=self._headers(),
            cookies=self._cookies())

    def delete(self, entity_id):
        return requests.delete(
            f'{self.url}/{entity_id}',
            headers=self._headers(),
            cookies=self._cookies())

    def create(self, entity_id, entity_data):
        return requests.put(
            f'{self.url}/',
            data=json.dumps(entity_data),
            headers=self._headers(),
            cookies=self._cookies())

    def update(self, entity_id, entity_data):
        return requests.post(
            f'{self.url}/{entity_id}'
            data=json.dumps(entity_data),
            headers=self._headers(),
            cookies=self._cookies())

Listing 5-4The basic remote model

代码太多了。让我们仔细看看。您可能注意到的第一件事是,remote modelclass’接口公开了 Django 模型和标准的混合,这是我们在探索 REST 框架的过程中建立的。为了简单的重构和熟悉领域,get、filter、delete、create、update 方法公开了一个类似 Django 模型的接口,然而,实现本身包含了很多我们在研究 REST 范例时遇到的词汇。

ENTITY_BASE_URL_MAP 是一个方便的映射,您可以在设置文件中创建它来为您正在处理的每个实体指定唯一的 URL 基础。

到目前为止,所有这些都很简单。那么诀窍在哪里呢?您可能已经注意到,在创建远程模型的实例时,请求对象是一个必需的参数。这是为什么?简单地说,我们使用请求对象来传播我们在请求本身中收到的头。这样,如果您使用头或 cookies 进行身份验证,所有内容都将顺利传播。

在此之后,这些模型的使用应该是相当容易的。为了方便起见,您可以根据您的特定需求对 RemoteModel 进行子类化,就像我们在清单 5-5 中所做的那样:

class RemotePizza(RemoteModel):

    def __init__(self, request):
        super().__init__(request, 'pizza', 'v1')

Listing 5-5Simple remote pizza

然后,您可以在视图函数中执行以下操作,如清单 5-6 所示:

pizza = RemotePizza(request).get(1)
pizzas = RemotePizza(request).filter(title__startswith='Marg')
RemotePizza(request).delete(1)

Listing 5-6Examples of remote pizza usage

注意

过滤器函数需要在服务器端进行额外的实现,因为 Django REST 框架默认不支持它们。

远程模型的缺点:

  • 远程模型可能会很慢——取决于网络、实现、硬件,有时还取决于星的排列,远程模型可能会比它们的数据库对应物慢得多。当您开始在您的体系结构上“链接”远程方法时,通过调用调用其他系统的其他系统的系统,这种缓慢也会升级。

  • 它更脆弱——一般来说,远程模型比常规模型脆弱得多。与数据库的连接比通过 HTTP 进行的连接更加健壮和持久。

  • 需要彻底检查批量操作和循环——有时在迁移过程中会复制不理想的代码,比方说,通过模型调用数据库的 for 循环变成了通过远程模型的 HTTP 调用。由于第一点,如果我们查询大量的模型,这可能是毁灭性的。

  • 没有序列化——如果您使用这个简单的模型,您肯定会失去序列化的能力,这意味着您只会收到一个作为响应返回的 dict,而不一定是您所期望的一个模型或多个模型。这不是一个无法解决的问题,你可以研究一下 Python dataclass es 和类似英安岩的模块。

远程模型实现过程中出现的另一个好话题是缓存。缓存是一个很难解决的问题,所以我建议您不要在第一次迭代中实现它。我多年来注意到的一个简单而巨大的成功是在您的服务中实现请求级缓存。这意味着,每个远程调用的结果都以某种方式存储在请求中,不需要再次从远程服务中获取。这允许您在一个视图函数中从您的服务对同一个资源进行多个远程模型调用,而不实际使用网络来获取资源。这可以节省大量的网络流量,即使在开始。

让我们看一下练习 5-1、5-2 和 5-3,它们将帮助我们更多地使用远程模型。

练习 5-1:服务对服务的远程模型

上面的模型很好地解决了头和 cookie 传播的问题,因此我们可以使用认证方法(如我们在前面章节中看到的会话或认证头)从系统的各个点访问数据。然而,如果我们想在没有用户认证的情况下调用服务时使用不同的令牌,这可能会导致问题。在本练习中,鼓励您为 RemoteModel 设计一个扩展,通过它我们可以正确地分配覆盖身份验证令牌。上面已经有一些代码可供您使用。

练习 5-2:远程或非远程

远程模型看起来已经是一个非常强大的工具了,但是我们能让它们更加强大吗?当模型在本地数据库中可用时,尝试扩展 RemoteModel 类,以便能够处理数据库调用。进行这一更改可以让您在未来加快迁移速度。

练习 5-3:请求级缓存

我们之前提到过请求级缓存,现在是时候测试我们的 Python 和 Django 知识并实现它了。每次调用远程模型动作时,确保将响应存储在与请求本身相关的缓存中。为此,您可以使用各种缓存库,如 cachetools。

在我们的工具上工作是非常有趣的,是代码迁移的时间。

代码迁移

这可能是整个迁移过程中最不激动人心的部分。您需要复制您希望其他系统拥有的代码库。您需要为这些应用创建一个新的 Django 项目,找到设置和实用程序,并复制所有内容。当我处于迁移的这个阶段时,我喜欢遵循以下几个提示:

  • 保持简单——在这一点上,不需要太担心服务之间的代码重复(除非您已经有了一些工具来实现这一点)。只要确保您的应用尽快启动并运行即可。无论如何,我们都要删除 monolith 中的代码。

  • 遵循领域——就像数据分片一样,领域在这里也是关键。确保您想要移出的模块尽可能与系统隔离。你想要的目标,只是把一个应用从一个代码库复制到另一个代码库。

  • 测试是关键——您创建的一些微服务本身就是怪物。例如,您可能有一个支付服务,它有一个内部状态机和对各种支付提供商的多个集成,您已经决定将整个域提取出来。确保您的测试在适当的位置,运行并最终不会中断。手工测试如此庞大的系统几乎是不可能的。如果您遗漏了一些代码或功能,测试也可以帮助您进行迁移。

  • 接受你首先会降低速度的事实——迁移需要一段时间是一回事,但是应用通常在生命周期的早期会变得更慢。这是由我们用远程模型检查的上述负面因素造成的。你会注意到,从长远来看,所有者团队会像他们领域中最有知识的工程师一样,很好地维护他们的应用并实现各种速度增强特性。

释放;排放;发布

代码被复制,所有的测试都通过了,是时候发布了。

战略

新微服务的首次部署总是有点混乱,并且需要事先就策略和方法进行大量讨论。正如本书中的大多数地方一样,发布过程没有灵丹妙药,但是,有一些方法可以选择,这取决于您的准备、工具和您的团队是否愿意早点醒来。

先读,后写——这种策略意味着微服务将首先只以只读模式运行,也就是说其上的流量不会修改它所拥有的数据。这是我最喜欢的策略之一,它允许你同时使用 monolith 和新的微服务来访问数据。如果您选择设置新数据库的读取复制,那么使用新服务提供的读取功能的 API 应该是相当安全的,例如获取 pizza 元数据。这样,您可以确保您的应用在生产环境中运行,并且只有在您确信您的基础架构能够处理它时,才开始在其中写入数据。

滚动部署 -基本上意味着你将把总流量的一部分发送到新的微服务,而将其余部分留在 monolith 上,缓慢但稳定地让所有流量由新系统处理。借助现代负载平衡器和服务网格,这可以很容易地建立起来。如果您选择创建一个读取副本,这不是一个选项,因为在新的微服务数据库上发生的写入不会在 monolith 的数据库中注册。

全流量变化——可能是最容易实现也是恢复最快的。当您确信您的服务工作正常时,您可以将给定 url 上的流量切换到新服务。这个过程应该简单且容易逆转,例如改变网站或文件的配置。

注意

当然,我们可以在这里讨论许多其他的发布策略。主要的想法是围绕你所拥有的关于风险、难度和准备时间的选项有一个背景,这样你就可以就你想要如何解决这个问题做出一个有根据的决定。

既然我们已经知道了发布我们的服务应该采用什么样的策略,那么让我们来看看当事情不可避免地发生时,我们该如何应对。

处理停机

根据我的经验,当发布新的微服务时,总会有一些预期的停机时间。好消息是,如果您事先做了几个小的准备步骤,这种停机时间可以最小化:

  • 为恢复创建一个剧本——这可能是你能做的最重要的事情。确保你有一个一步一步的指南,让工程师将流量恢复到单一的应用。起初这看起来似乎微不足道,但在实际环境中,事情可能会变得非常糟糕,特别是在关键任务服务中,比如披萨元数据。确保也练习剧本,并让其他团队参与审查。

  • 日志记录和监控应该到位——您的日志记录和指标应该到位,并在发布期间得到适当的监控,包括 monolith、新服务和数据库。

  • 选择时间和地点——理想情况下,这样的发布应该发生在流量低的时候,你最了解你的应用,所以相应地选择时间。一般来说,周一早上或周六早上是这种迁移的好选择。如果有机会,让所有者团队和平台团队(如果有的话)的人员在现场进行有效的通信。

  • 关于阶段化的实践——许多团队忘记的是,他们的系统通常有一个预生产或阶段化的环境。您可以利用这个空间来练习几次发布,因为这里最好没有真实的客户数据。

  • 让公司的其他人知道-这是至关重要的一步,确保公共关系和客户服务团队了解即将进行的维护以及对客户可能产生的影响。他们知道的越多,如果事情变糟,他们就能更有效地通信。

  • 不要忘记数据——确保你也有一个数据回填的计划,因为在一个有问题的版本中,monolith 和 microservice 数据库之间可能会有数据差异。

这里有一个在遭受攻击的情况下恢复 tizza 应用的示例剧本。我们的目标是做发布的人不需要考虑任何事情,只需要按照说明去做。

  1. 先决条件:

    1. 确保您连接到了 VPN

    2. 确保您可以访问 http://ci.tizza.io

    3. 确保您可以通过 ssh 访问所请求的机器。

    4. 在你的机器上克隆最新的 https://github.com/tizza/tizza

  2. #alerts 频道用 @here 宣布你的版本有问题,需要恢复。

  3. 参观 http://ci.tizza.io/tizza/deploy

  4. 选择您想要部署的应用版本,然后点击绿色按钮。

  5. 如果部署工具报告失败,请继续。

  6. ssh 进入主机,可以使用 ssh -A <主机 ip >

  7. 运行以下命令:

    1. 【sudo su-

    2. bash-x ./maintenance/set-application-version . sh<应用版本>

    3. supervisor TL 重启保证-应用粉笔-engine

  8. 如果服务仍然没有响应,请拨打+36123456789

这个剧本非常简单,但是,它为开发人员提供了多种解决方案。它包括一个先决条件部分,因此运行这些命令的开发人员可以确保他们能够完成剧本要求的所有事情。它还包括一个灾难情况解决方案,其中提供了一个电话号码,该号码很可能与该领域中有经验的开发人员相关联。

第二步还有一个针对公司其他部门的通信计划。这是绝对重要的,因为如果出了什么差错,你公司的其他人也会感兴趣。

我们做到了!应用已经迁移,但是我们还没有完全准备好。最有趣的部分还在后面。让我们谈谈如何确保我们不会留下一个巨大的烂摊子。

清除

图表和日志看起来很棒。客户没有抱怨任何新的问题,系统是稳定的。恭喜你,你刚刚发布了一个新的微服务!现在最有趣的事情来了:收拾我们留下的烂摊子。

就像你处理你的厨房一样,确保你不会在旧代码库中留下不需要的东西。您可以慢慢来,事实上,将旧代码保留 2-3 周通常是一个好主意,因此如果有一些问题,您仍然可以使用您创建的行动手册恢复到旧逻辑。

在您的新服务成熟一段时间后,请确保完成以下清理清单:

  • 关闭 monolith 和微服务数据库之间的复制——如果您还没有这样做,现在可以关闭两个数据库之间的数据复制。

  • 从新服务中删除未使用的表——如果您进行了简单的完整数据库复制,现在可以从微服务的数据库中删除域中不涉及的表。这将释放大量存储空间。

  • 从 monolith 中移除不用的代码——移除不用的模块。确保进行一次彻底的清理,利用像 pycodestyle 这样的工具来找到可以删除的未使用的代码。

  • 从 monolith 中删除未使用的表——现在您已经确定没有代码访问已经迁移到新服务的表,您可以安全地删除它们了。将这些数据存档并存储一段时间也是一个好主意,花费不多。

结论

在这一章中,我们学到了很多可以用来加速微服务迁移的小技巧。与此同时,我们的新系统也搞得一团糟。有许多重复的代码,仍然不清楚谁拥有应用的哪些部分。在下一章中,我们将更深入地探讨这一话题,并确保我们不仅能增加我们拥有的服务数量,还能扩展我们的组织和开发,以利用这些系统实现最佳效率。

六、规模化发展

到目前为止,我们已经花了很多时间讨论设计和构建微服务的方法和原因。然而,有一个古老的原则,许多行业专业人士多年来创造了这个原则,即“代码读得多,写得少。”基础设施和系统也是如此。如果您想要确保您的系统可伸缩,以便人们一眼就能理解它们,并且能够快速地转移到软件工程的“编写”部分,您需要在您的组织中花费一些时间在工具和文化上工作。在本章中,我们将探索其中的一些领域。

使用 Python 包

我已经多次提到的一件事是,在我们上一章所做的假设迁移中,我们一直在处理大量的代码重复。每个软件工程师都知道 DRY 原则(如果你不知道,现在就去查一下),所以希望你们中的一些人对复制这么大量的代码感到不舒服。

在第一部分中,我们将探讨重用代码的另一种方式。通过使用 Python 包。

重复使用什么

您可能会问自己的第一个问题是,您应该为什么创建一个新的包?答案通常是:无论两个或更多微服务中存在什么代码,都应该迁移到一个单独的包中。有时也有例外,例如,如果您预计在短期或长期内重复的代码会有严重的代码漂移,那么您应该将它们保存在单独的服务中,让它们各自漂移。我们在本书中使用的一些例子应该放在不同的包中:

  • RabbitMQ 发布者和消费者——它们的基本代码在每个服务中都应该相同,因此它们应该有自己的包。

  • rest 框架的无记名令牌认证方法——在第四章中,我们也看到了 Django REST 框架的无记名令牌认证选项。这也应该在一个包中分发,因为如果它在一个地方改变,它应该在所有地方都改变。

  • 速率限制中间件——在第三章中,我们做了一个创建中间件的练习,这个中间件可以根据 IP 地址和一段时间内的呼叫量来限制呼叫。

  • 远程模型及其实例——第五章中描述的模型也是一个很好的包。如果系统中的模型发生了变化,所有者团队只需要相应地更新客户端并重新分发包。

当然,还有其他的例子。如果您的系统有一个定制的日期模块,那么您可能也想把它作为一个包来分发。如果您有 Django 基础模板,并希望在服务中分发,那么包是最适合您的。

创建新的包

在您决定了首先将哪个模块移入包中之后,您可以通过在您最喜欢的代码管理工具中创建一个新的存储库来开始迁移过程。但是,在此之前,强烈建议检查您想要移出的模块的内部和外部依赖关系。从长远来看,依赖于包而不处理向后兼容性的包通常会带来麻烦,应该避免(特别是如果依赖是循环的,在这种情况下,无论如何都要避免)。

如果您已经隔离了想要移出的代码,那么您可以创建一个新的存储库,并将您想要的代码迁移到一个单独的包中。确保也移动测试,就像您在迁移服务时所做的那样。迁移后,您应该拥有如图 6-1 所示的目录结构。

img/481995_1_En_6_Fig1_HTML.png

图 6-1

基本包目录结构

让我们一个文件一个文件地检查一下:

tizza -我们想要保存包源代码的目录。您可能想知道当有多个包时会发生什么?答案是 Python 模块系统很好地处理了同名模块,并如您所料加载了它们。一般来说,用一个像你的公司名称这样的前缀作为你的包的前缀是一个好主意,因为它可以是你的导入中的一个很好的指示器,不管一个特定的方法是否来自于一个包,而且,如果你将来开源这些包,它可以是一个营销你的公司的好方法。

tests——我们保存测试源代码的目录。

setup . py——这个包文件包含了关于包本身的元信息。它还描述了如何使用该包以及对它有什么要求。这些文件非常强大,可以围绕你的包做很多操作。我强烈推荐在 https://docs.python.org/3.7/distutils/setupscript.html 查阅文档。清单 6-1 是一个示例 setup.py 文件:

from setuptools import setup, find_packages

VERSION = "0.0.1"

setup(
    name="auth-client-python",
    version=VERSION,
    description="Package containing authentication and authorization tools",
    author_email='akos@tizza.com',
    install_requires=[
        'djangorestframework==3.9.3',
    ],
    packages=find_packages()
)

Listing 6-1An example setup.py file for a package

如你所见,这很简单。name 属性包含包本身的名称。这个版本是这个包的当前版本,它作为一个变量被移出,所以更容易修改它。这里有一个简短的描述,还有软件包所需的依赖项。在本例中,是 Django REST 框架的 3.9.3 版本。

使用一个新的包并不比创建一个更难。由于 pip 可以从各种代码托管站点下载包,比如 Github,我们可以简单地将下面一行插入到 requirements.txt 文件中:

git+git://github.com/tizza/auth-client-python.git#egg=auth-client-python

运行pip install-r requirements . txt现在将按照预期安装软件包。

这里我们可以提到的另一件事是关于包的版本控制。在本书的前面,我们已经提到,固定的依赖关系(有固定版本的依赖关系)通常比非固定的依赖关系更好,因为开发人员可以控制他们的系统。现在,在这里,你可以看到,我们总是拉最新版本的代码库,这违背了这个原则。幸运的是,pip 支持特定的包版本,即使它们来自代码版本控制系统,而不是“真正的”包存储库。允许使用以下引脚:标记、分支、提交和各种引用,如拉请求。

pip install git+git://github.com/tizza/auth-client-python.git@master#egg=auth-client-python

幸运的是,发布一个新标签非常容易,您可以用清单 6-2 中的 bash 脚本来完成:

#!/bin/sh

VERSION=`grep VERSION setup.py | head -1 | sed 's/.*"\(.*\)".*/\1/'`
git tag $VERSION
git push origin $VERSION

Listing 6-2example bash script for publishing tags

这个脚本从您的 setup.py 文件中获取版本信息,并在您所在的存储库中创建一个新的标签,假设其结构与上面的文件相同。因此,在运行脚本之后,您可以在您的需求文件中使用以下内容:

git+git://github.com/tizza/auth-client-python.git@0.0.1#egg=auth-client-python

当我们想要处理固定的包时,这是非常方便的。

注意

标记是管理包版本的一个很好的工具,但是,在理想的情况下,您不希望您的开发人员手动处理这个问题。如果您有资源,您应该将标记逻辑添加到您正在使用的构建系统的管道中。

我们已经设置了我们的包,是时候确保它在测试中得到很好的维护了。

测试包

在理想的情况下,测试您的包应该简单而优雅,运行一个测试命令,这很可能是您在您的单片应用中一直使用的命令,您的代码最初驻留在那里,然而,有时生活只是稍微困难一点。如果您的包需要在依赖关系互不相同的环境中使用,会发生什么情况?如果需要支持多个 Python 版本会怎么样?幸运的是,对于这些问题,我们在 Python 社区中有一个简单的答案: tox

tox 是一个简单的测试编排工具,旨在概括如何在 Python 中进行测试。这个概念围绕着一个名为 tox.ini 的配置文件。清单 6-3 向我们展示了一个简单的例子:

[tox]
envlist = py27,py36,py37

[testenv]
deps = pytest
commands =
    pytest

Listing 6-3Simple tox file

这个文件的意思是,我们希望使用命令 pytest 针对 Python 版本 2.7、3.6 和 3.7 运行我们的测试。该命令可以用您正在使用的任何测试工具来替换,甚至可以用您编写的定制脚本来替换。

您只需在终端中说: tox 就可以运行 tox。

这到底是什么意思?当你在一家在整个生态系统中使用多个 Python 版本的公司开发软件时,你可以确保你正在开发的包在所有版本中都可以工作。

即使从这一小段中,您也可以看出 tox 是维护您的包装的一个很好的工具。如果你想了解更多关于 tox 的信息,我推荐你登录它的网站 https://tox.readthedocs.io/

现在我们已经了解了包,它们应该如何被构造和测试,让我们看看如何存储关于我们的服务的元信息,这样公司的开发人员可以更容易地以最快的方式找到他们需要的信息。

服务存储库

随着您的系统随着越来越多的微服务而增长,您将面临与单一应用不同的问题。其中一个挑战是数据的可发现性,这意味着人们将很难找到系统中的某些数据。良好的命名惯例在短期内有助于这一点,例如,将存储披萨信息的服务命名为食品服务烹饪服务可能比命名为戈登冰箱更好(然而,我确实同意后两者更有趣)。从长远来看,您可能想要创建某种元服务,它将托管关于您的生态系统中的服务的信息。

设计服务存储库

服务存储库总是需要为给定的公司量身定制,但是,在默认情况下,您可以在模型设计中涉及一些东西,如清单 6-4 所示。

from django.db import models

class Team(models.Model):
    name = models.CharField(max_length=128)
    email = models.EmailField()
    slack_channel = models.CharField(max_length=128)

class Owner(models.Model):
    name = models.CharField(max_length=128)
    team = models.ForeignKey(Team)
    email = models.EmailField()

class Service(models.Model):
    name = models.CharField(max_length=128)
    owners = models.ManyToManyField(Team)
    repository = models.URLField()
    healthcheck_url = models.URLField()

Listing 6-4Basic service repository models

我们把它保持得很简单。我们的目标是让团队和工程师能够互相交流。我们创建了一个描述系统中的工程师或所有者的模型,我们还创建了一个描述团队的模型。一般来说,最好将团队视为所有者,它鼓励这些单位在团队内部共享知识。团队也有一个 slack 频道,理想情况下,它应该是一个单击连接,任何工程师都可以获得关于服务的信息。

您可以看到,对于服务模型,我们添加了几个基本字段。我们已经将 owners 设置为多对多字段,因为多个团队可能使用相同的服务。这在较小的公司和单一应用中很常见。我们还添加了一个简单的存储库 url 字段,因此可以立即访问服务代码。此外,我们还添加了一个健康检查 url,因此当有人对该服务是否正常工作感兴趣时,他们只需简单地点击一下即可完成。

拥有关于我们服务的基本元信息固然很好,但现在是时候添加更多日常使用的内容了。

寻找数据

现在,我们已经开始了这一部分,工程师可以寻找的最有趣的元数据之一是系统中存在的特定实体的位置以及如何访问它。为了在这个维度上扩展我们的服务存储库,我们还需要扩展现有服务的代码库。

记录通信

你可以要求你的团队做的第一件事是开始记录他们的通信方法。这意味着对于每个团队的每个服务,应该有某种形式的文档来描述给定服务存在什么实体、端点和消息。作为初学者,您可以要求您的团队在其服务的自述文件中包含这一点,但在这里,我们将了解更多选项。

Swagger 工具链

对于 API 文档,互联网上有很多工具可供参考,我们将深入研究的工具叫做 Swagger。你可以在 http://swagger.io 找到更多关于 Swagger 的信息。

Swagger API 项目由 Tony Tam 于 2011 年启动,目的是为各种项目生成文档和客户端 SDK。该工具已经发展成为当今业界正在使用的较大的 RESTful API 工具之一。

Swagger 生态系统的核心是一个 yaml 文件,它描述了您想要使用的 API。让我们看看清单 6-5 中的一个示例文件:

swagger: "2.0"
info:
  description: "Service to host culinary information for the tizza ecosystem: pizza metadata"
  version: "0.0.1"
  title: "culinary-service"
  contact:
    email: "team-culinary@tizza.com"
host: "tizza.com"
basePath: "/api/v1"
tags:
- name: "pizza"
  description: "Pizza metadata"
schemes:
- "https"
paths:
  /pizza:
    post:
      tags:
      - "pizza"
      summary: "Create a new pizza"
      operationId: "createPizza"
      consumes:
      - "application/json"
      produces:
      - "application/json"
      parameters:
      - in: "body"
        name: "body"
        description: "Pizza object to be created"
        required: true
        schema:
          $ref: "#/definitions/Pizza"
      responses:
        405:
          description: "Invalid input"
  /pizza/{id}:
    get:
      tags:
      - "pizza"
      produces:
      - "application/json"
      parameters:
      - in: "path"
        name: "id"
        required: true
        type: "integer"
      responses:
        404:
          description: "Pizza not found"
definitions:
  Pizza:
    type: "object"
    required:
    - "name"

    - "photoUrls"
    properties:
      id:
        type: "integer"
        format: "int64"
      title:
        type: "string"
        example: "Salami Pikante"
      description:
        type: "string"
        example: "Very spicy pizza with meat"

Listing 6-5Swagger file example for the pizza entity

因此,该文件包含一些关于服务和服务所有者的元信息。之后,我们定义了客户端可以使用的端点,我们甚至提到了基本 URL 和应该使用的路径。

每条路径都被分解为方法,您可以看到我们为 pizza/端点分配了一个 POST 方法来创建 pizza。我们还描述了可能的响应以及它们的含义,包括文件末尾的 pizza 对象的结构。这些定义还包括接受什么类型的数据,以及可以从某些端点返回什么类型的数据。在这种情况下,我们所有的端点只返回 application/json 作为响应。

乍一看,这个文件只是看起来像一些不可读的废话。然而,当你将它与 Swagger 生态系统的其他部分配对时,你会得到一些不可思议的东西。首先,图 6-2 中由可视化编辑器创建的文件可以在 https://editor.swagger.io 找到。

img/481995_1_En_6_Fig2_HTML.jpg

图 6-2

傲慢的编辑

Swagger 编辑器是一个动态工具,使用它创建 Swagger 文件非常容易和有趣。它还将文件验证为 Swagger 格式,这样可以确保 API 描述符文件保留在生态系统中。

您还可以在自己的系统中利用 Swagger UI(上图中的右边面板),如果愿意,您可以下载源代码并将其托管在服务存储库旁边,这样您就可以对 API 描述符和想要了解它的人拥有最终的控制权。

您可能会问自己的第一件事是,是否有任何方法可以从这些定义中生成客户端代码。答案是肯定的。Swagger 有一个代码生成器模块,涵盖了多种编程语言的客户端代码的生成,但是,我们不会在本书中讨论这些选项。如果你想了解更多这些工具,我推荐你在 https://github.com/swagger-api/swagger-codegen 阅读代码和用户手册。

Swagger 对于同步 API 来说绝对是不可思议的,但是它不支持许多异步特性。在下一节中,我们将了解另一个可以帮助我们做到这一点的工具。

AsyncAPI 工具链

与您的同步 API 类似,您也可以(并且应该)记录您的异步 API。不幸的是,Swagger 不支持像 AMQP 这样的协议定义,然而,我们有另一个优秀的工具来处理这个问题, AsyncAPI

AsyncAPI 构建在与 Swagger 类似的 yaml 文件上。清单 6-6 展示了一个我们已经在做的烹饪服务的简单例子:

asyncapi: '2.0.0-rc1'
id: 'urn:com:tizza:culinary-service:server'
info:
  title: culinary-service
  version: '0.0.1'
  description: |
    AMQP messages published and consumed by the culinary service

defaultContentType: application/json

channels:
  user.deleted.1.0.0:
    subscribe:
      message:
        summary: 'User deleted'
        description: 'A notification that a certain user has been removed from the system'
        payload:
          type: 'object'
          properties:
            user_id:
              type: "string"

  pizza.deleted.1.0.0:
    publish:
      message:
        summary: 'Pizza deleted'
        description: 'A notification that a certain pizza has been removed from the system'
        payload:
          type: 'object'
          properties:
            pizza_id:
              type: "string"

Listing 6-6AsyncAPI example descriptor file

这里的规范非常简单。我们有两个路由键,一个用于删除用户,这是我们消费的,另一个用于删除披萨,这是我们生产的。消息本身描述了消息的结构,但是,我们也可以创建类似于 Swagger 描述文件中的对象。

就像在同步世界中一样,我们有一个很好的编辑器 UI(图 6-3 ,我们也可以在这里工作,在 https://playground.asyncapi.io 找到。

img/481995_1_En_6_Fig3_HTML.jpg

图 6-3

AsyncAPI 编辑器

注意

正如您可能已经看到的,我们没有声明消息将被发布到哪个交换或从哪个交换消费。 AsyncAPI 规范没有描述这一点的固有方式,但是,对于这种情况,您总是可以添加一个 x 属性。现在,我们可以称之为 x-exchange 。这是规范所接受的。

是时候把我们新的闪亮的 API 描述符放到我们的服务存储库中了。

把它绑在一起

在这些文件就位之后,我们可以开始将它们链接到我们的服务存储库中。清单 6-7 向您展示了如何将它们作为额外字段添加到服务模型中。

class Service(models.Model):
    ...
    swagger_file_location = models.URLField()
    asyncapi_file_location = models.URLField()

Listing 6-7Updated service model

这些新链接将使我们能够即时访问所有服务中的 API 用户界面。如果我们愿意,我们还可以扩展模型来包含并定期加载 URL 的内容,我们可以在用户界面上对这些内容进行索引以便进行搜索。

其他有用的 Tields

现在,如果您的服务存储库中有这么多可用的数据,那么您在行业标准方面已经做得很好了,并且为您的工程师提供了非常先进的工具,以便在您复杂的微服务架构中导航。您可能希望在将来添加几个字段,所以我会在这里为您留下一些想法,您可以在将来改进:

图表、警报和跟踪 -向服务存储库添加图表、警报和服务跟踪信息是一个简单的方法。这些通常是简单的 URL,但是,如果你想变得更有趣,你总是可以将图形嵌入到一些 UI 元素中,这样开发服务的开发者一眼就能了解服务的状态。

日志 -维护和使用日志对于每个公司都是不同的。但是,有时很难发现给定服务的日志。您可能希望将文档、链接甚至服务的日志流(如果可能的话)包含到存储库中。对于那些试图找出服务是否有问题,但又不太熟悉的工程师来说,这可能会加快速度。

依赖关系健康 -自从 2016 年 JavaScript 生态系统的巨大丑闻以来,当一半的互联网因为一个依赖关系(左键盘)在节点包管理器中被禁用而崩溃时,人们非常重视依赖关系,你可能也想搭上火车。您可以使用一些工具来确定服务中依赖项的最新程度和安全性。例如,您可以使用安全来实现这一点。

构建健康状况(Build health)——有时,如果服务的构建管道健康与否,这可能是有用的信息。如果需要,这也可以显示在服务存储库 UI 上。

如您所见,服务存储库是非常强大的工具,不仅可以发现服务,还可以很好地概述生态系统的健康状况和整体性能。

在最后一节中,我们将快速看一下如何利用脚手架的力量加速开发新服务。

脚手架

我们已经在如何扩展我们的应用开发方面取得了很大进展。在结束本书之前,我们可能还想做一小步,那就是搭建服务。

当设计微服务架构时,您可以瞄准的最终目标之一是使团队能够尽快交付业务逻辑,而不中断技术领域。这意味着建立一项新的服务(如果业务需要)应该是几分钟的事,而不是几天的事。

搭建服务的想法并不新鲜。有许多工具可以让您编写尽可能少的代码,并尽可能少地点击您的云界面,以构建您的开发人员可以使用的新服务。让我们从前者开始,因为它与我们一直在研究的内容非常接近。

搭建代码库

当我们谈论脚手架代码时,我们谈论的是拥有一个包含各种占位符的目录结构,并用与服务相关的模板变量替换占位符。为您的服务设计一个基础模板一点也不困难。你想要实现的主要目标是保持尽可能的简约,同时保持每个服务中应该存在的技术特定需求。让我们来看看这些可能是什么:

  • 需求文件(Requirements files)——大多数服务喜欢维护它们自己的需求,所以在每个服务中分别维护这些需求通常是个好主意。有些团队喜欢在服务的基础映像中维护他们的需求,这也是一个解决方案。

  • 测试 -团队应该以相同的方式编写测试,这意味着单元测试、集成测试和验收测试的文件夹结构和执行应该在任何地方都是相同的。这是必需的,这样开发人员可以尽快跟上服务的速度。这里没有妥协。

  • 服务的基础结构——服务的 API 和模板在每个服务中都应该是一样的。公司中使用给定语言的每个人都应该对文件夹结构感到熟悉,在浏览代码库时应该不会感到意外。这个结构本身也应该可以工作,并且应该包含一些在模板完成后就可以工作的东西。

** 基本依赖关系 -可能是由需求文件暗示的,但是,我想强调一下基本依赖关系。这些依赖的主要目标是保持公共代码的完整性,而不是被公司的多个团队重写。您之前提取的包应该包含在基本依赖项中,如果不需要它们,可以从长远来看删除它们。

*   readmes 和基本文档 -基本模板还应该包括 Readmes 和其他文档文件的位置,例如默认情况下的 Swagger 和 AsyncAPI。如果可能的话,用于模板更新的脚本也应该鼓励以某种方式填写这些信息。

*   *健康检查和潜在图表* -服务的构架还应该包括如何访问服务以及如何检查服务是否工作。如果您正在使用 Grafana 这样的工具,这些工具使用 JSON 描述符文件构建服务图,那么您也可以在这里生成它们,这样所有服务的基础图看起来和感觉起来都是一样的。

*   Dockerfiles 和 Docker compose files——我们在本书中没有过多地讨论 Docker 和围绕它的生态系统,但是,如果你正在使用这些工具,你一定要确保你正在搭建的服务默认包含这些文件。* 

*每个人都应该可以接触到基础脚手架。我建议在你最喜欢的代码版本系统中创建一个新的存储库,用模板和脚本填充其中的模板,并让公司的所有开发人员都可以访问它。如果您愿意,也可以留下一个示例服务。

对于脚手架本身来说。我推荐使用非常简单的工具,比如 Python cookiecutter 模块。

我想在这里指出的一点是,脚手架会在短期内加快你的速度,然而,从长远来看,它会导致另一系列的问题。确保我们生成的所有这些文件在整个微服务生态系统中保持统一和可互换几乎是不可能的。在这一点上,如果您想使用一个健康的、可维护的基础设施,建议让专门的人来处理您的系统的统一性和可操作性。这种最近蓬勃发展的工程文化被称为“开发人员体验”我建议对它进行研究,评估适应对你和你的公司是否值得。

搭建基础设施

搭建代码库是一件事,另一件事是确保你的云提供商有资源来托管你的系统。以我的经验来看,在公司生命周期的每个周期,每个公司的这个领域都是极其不同的。因此,为了方便起见,我将提供一些指导并提到一些您可以在这里使用的工具。

HashiCorp 的 Terraform 是一个非常强大的工具,用于维护基础设施代码。基本思想是,Terraform 定义了各种提供者,如 Amazon Web Services、DigitalOcean 或 Google Cloud Platform,在这些提供者中,所有资源都以类似 JSON 的语法描述。参见清单 6-8 中的地形文件示例:

provider "aws" {
  profile    = "default"
  region     = "us-east-1"
}

resource "aws_instance" "example" {
  ami           = "ami-2757f631"
  instance_type = "t2.micro"
}

Listing 6-8A simple terraform file

上面的例子直接来自 Terraform 网站。它展示了几行代码,您可以用它们在 Amazon 上创建一个简单的实例。很整洁,是吧?如需了解更多关于搭建整个基础设施的 terraform 和工具的信息,您可以前往 http://terraform.io 查看教程。

过一段时间,你会发现不仅管理你的代码和服务会变得困难,而且管理你的秘密、密码、用户名、密钥对,一般来说,所有你不想与业务之外的人分享的东西也会变得困难。Vault 是 HashiCorp 开发的一个工具,也是为了让这方面的事情变得更容易。它提供了一个简单的接口,并与云生态系统的其余部分很好地集成。围绕它的 API 既简单又安全。

Chef-Chef 是最受欢迎的代码解决方案基础设施之一,被全球数百家公司(如脸书)用于增强其基础设施。Chef 使用 Ruby 编程语言的强大功能来扩展。

结论

在这一章中,我们已经了解了如何使用 Python 包,以及如何使用它们来确保开发人员不会在每次创建新服务时都重新发明轮子。我们还学习了服务存储库,以及如何通过创建同步和异步消息传递系统的详细文档来帮助自己。我们还了解了脚手架服务,以及我们希望工程师在创建新服务时使用的模板的最低要求。

我希望这一章已经为您提供了关于如何在您的组织中扩展微服务开发的有用信息。*

posted @ 2024-08-13 14:27  绝不原创的飞龙  阅读(8)  评论(0编辑  收藏  举报