一、什么是服务?
早在农业革命之前,服务就已经存在于人类之中。每当我们走进商店或餐馆,我们都在使用一种服务。有些服务比其他服务更复杂,提供给我们的商品更符合我们的口味,或者需要更少的工作,而有些服务更专业,专注于一项任务,并且做得非常好。让我们看一个简单的例子。
市场和餐馆都可以算作服务,尽管它们提供给我们不同的东西,但它们都给我们一般的食物。当你进入一个市场,你可以从各种各样的卖家那里买到各种各样的食材。之后,你可以在家里将所有这些材料组合成你喜欢的各种菜肴。然而,一家豪华的餐馆会为你提供现场制作的美味佳肴。在餐馆的后台有许多流程和系统在工作,其中大部分你都不知道,但是在你的请求被处理后,你的饭菜就被送到了。
当我们谈论服务时,餐馆/市场摊位的类比只是我们可以提出的众多类比之一。从概念上讲,它们的工作方式与软件中的服务相同。
-
您,即客户端,向服务发出请求。
-
服务,在我们的例子中是餐馆,接收你的订单。
-
后台的一些算法和过程(菜谱和厨师)会为你准备一个回应。
-
您会收到上述回复(至少在大多数情况下)。
一个服务可以是你的应用,它通过人们的电话提供他们昨天离家次数的信息。或者一个服务可以是一个大规模的持续集成系统,你只能看到运行你的应用测试的一小部分。服务也可以是软件中的小东西,一个提供接口获取匈牙利布达佩斯时间信息的应用。
在本书中,我们将重点关注驻留在 web 上的服务,为最终用户和其他内部服务提供数据。让我们先来看看行业中出现的不同定义。
服务行话
在过去的几年中,围绕服务设计、软件即服务、微服务、单片、面向服务的体系结构等等有很多讨论。在下图中,可以清楚地看到微服务越来越受欢迎。图 1-1 提供了过去五年谷歌搜索“微服务”的图形视图。
图 1-1
2014 年以来“微服务”一词的流行,谷歌
在接下来的几节中,我们将试着弄清楚其中每一个的含义,以及当你在这样的系统中工作时需要记住的重要术语。请记住,这些术语不是刻在石头上的。
软件即服务
术语“软件即服务”(或 SaaS)主要描述一种许可模式,在这种模式下,你可以为某种在线服务的使用付费。这些软件中的大部分都存在于云中,并为最终用户提供各种方式来修改和查询他们系统中的数据。Spotify 就是一个例子,这是一个在线音乐流媒体软件,最终用户可以用它来听音乐和创建自己的播放列表。此外,Spotify 有一个广泛的软件界面和软件包,工程师可以使用它们以编程方式获取和修改 Spotify 云中的数据。
面向服务的架构
面向服务的架构(或 SoA)可能是业界最受欢迎的术语之一。简单地说,这种风格的架构设计比任何东西都更支持服务。正如我们在上面所了解的,服务需要服务于某种业务需求,并且需要围绕现实世界的需求进行建模。服务需要是自包含的,并且有一个干净的接口,通过它可以进行通信。它们是独立部署和开发的,在抽象层次上代表一个功能单元。该架构还涉及这些服务之间使用的通信协议。一些软件即服务公司使用面向服务的架构向他们的最终用户交付高质量的产品。
整体服务
对软件工程师来说,这是十年来最可怕的词汇之一。单一的应用和单一的服务是单一的服务,它们增长得太大了,以至于无法进行推理。“太大”到底意味着什么,将是本书的核心话题之一。我们还会看到,这个可怕的词不一定意味着不好的事情。
微服务
软件工程师十年来的另一个可怕的词(尽管原因不同)。简而言之,微服务是一种存在于面向服务的架构中的服务,并且易于推理。这些是松散耦合的轻量级组件,具有定义良好的接口、单一用途,并且易于创建和处理。由于细粒度的性质,更多的人可以并行处理它们,特性的所有权成为组织要解决的一个更干净的问题。
现在,我们已经看了一下高层次的定义,让我们深入研究一下独石。
了解整块石头
正如我们所了解到的,monoliths 是一个开发人员甚至一个开发团队都难以理解的庞大的代码库,其复杂性已经达到了一个程度,即使只更改几行代码也会在其他部分产生意想不到的未知后果。
首先,我们需要在这里打下一个基础,那就是独石本身并不坏。有许多公司已经在单片应用上建立了成功的 IT 业务。
事实上,独石实际上对发展你的业务很有帮助。只需很少的管理费用,您就可以为您的产品添加更多的功能。在 IDE 中只需几个组合键就可以轻松导入模块,这使得开发变得轻而易举,并让您对向雇主交付大量代码(无论是高质量还是低质量)充满信心。就像生活中的大多数成长过程一样,公司和软件需要快速启动并经历快速迭代。软件需要快速增长,这样企业才能得到更多的钱,这样软件才能增长,这样企业才能得到更多的钱。你会看到它在哪里生长。所以下次你参加一个会议,听到一个演讲者说独石是旧设计的系统,一定要半信半疑。顺便提一句,我想说的是,如果你的初创公司正在努力维护你的遗留单片应用,这通常是一个健康业务的标志。
然而,趋势表明,随着时间的推移,软件的可靠性和可伸缩性变得越来越重要,这种快速增长往往会放缓。拥有单个应用可能会为您的团队赢得快速部署和易于维护的基础设施,但这也意味着单个工程师的错误可能会导致整个应用瘫痪。越多的人开始在一个应用上工作,他们就越开始干扰彼此的工作,他们就越需要进行不必要的交流,他们就越需要等待他们的构建和部署。
想象一下下面的情况:你是一名基础设施工程师,在一家通过互联网提供视频流服务的公司工作。该公司正在挣扎,但不知何故,CMO 设法让你的国家的政府流总统选举演讲。这对你的公司来说是一个惊人的增长机会,所以有大量的投资进入,以确保系统很好地承担负载。在活动开始前的几周,你已经做了大量的负载测试,并指示后端工程师修复某些不能正常运行的代码部分。你已经说服首席运营官投入一些钱到更重的基础设施上,这样系统的负荷就会减轻。演讲的一切看起来都很完美,你和你的同事在活动期间焦急地等待着,以体验你在过去几周如此专注地工作的奇迹。然后它发生了。就在演讲之前,溪流停止了。你完全不知道发生了什么,并开始疯狂地寻找答案。你试着用 SSH 连接到机器上,但没有成功,被无情地拒绝了,时间一分一秒地过去。停电已经 13 分钟了,演讲也快结束了。Twitter 在你的公司上贴上可以观看总统演讲的标签,并指责首席执行官的无能。盛怒之下,决定硬重启你正在使用的云提供商网站上的机器,但为时已晚。活动结束了。数百万人感到失望,该公司的状况不太好,无法获得下一轮投资。两天后,你回去工作,开始调查发生了什么。原来,ads 团队在 2 天前推出了一些更改,导致后端应用出现内存泄漏。服务器上的内存已满,阻塞了所有进程。这时,您开始梦想公司的基础设施有一个更好、更可靠的未来。
图 1-2 简单展示了该视频流公司在应用中发生的事情。
图 1-2
由紧密耦合的损坏组件导致的全面停机
上面的故事,尽管完全是虚构的,却发生在每个成功的软件系统和每个成功的软件公司的生命周期中。如果你认为脸书或谷歌在其一生中从未在重要时刻出现过长达一小时的宕机,那你就大错特错了。你可以在网上找到关于各种谷歌 1 宕机的文档,其中(有时)详细描述了系统的哪些部分导致了这个和那个部分在这段时间内宕机。在这些中断之后,他们学习、适应并使他们的系统更有弹性,这样同样的问题就不会再次发生。
许多人没有想到单片应用的一个方面,我喜欢称之为“单片瀑布”或“级联单片”架构。这基本上意味着当你在一个单一的应用上工作时,你几乎被鼓励以一种单一的方式来设计你的代码和数据的架构。围绕你的模块设计接口变成了一件苦差事和额外开销,所以你只需要导入你需要的代码行。为订阅信息添加一个新的数据库会花费太多的时间,而且只会带来更多的复杂性,所以您需要在用户和产品信息所在的数据库中创建模型。如果你在一个整体上工作,你的代码和架构的所有层都将是整体的。自然,严格的编码指南、架构原则和富有挑战性的工程文化可以构建和维护一个具有清晰内部接口边界的整体,这个整体以后可以更容易地分解为微服务。
现在,在这一部分的结尾不提及独石的好处是不公平的。抛开上面所有的恐怖故事和消极因素,建议是利用单一模型来发展你的业务和工程。只要确保紧紧抓住方向盘,每个人都在技术债务上保持一致。
现在我们已经了解了 monolith 的外观和行为,我们将看看它在本书中的对应部分,微服务。
了解微服务
简单回顾一下:微服务是一个单一用途的软件应用,驻留在 web 上的某个地方,是一个小型的代码库,即使是一个工程师也可以很容易地对其进行推理。
就像我们了解到独石不是撒旦的后代一样,我们将了解到微服务也不是银弹。
让我们回到上一个故事中的基础设施工程师。事件发生后,他与公司的其他人进行了一次事后分析(如果你不确切知道那是什么,不要担心,我们将在本书的后面探讨它),并得出结论,公司可以采取的避免像几周前在新年致辞中那样的灾难的最佳解决方案之一是适应和实施基于微服务的架构,并放弃整体架构。他概述了工程师和投资者将承担的成本,并确保每个人都了解迁移的利弊。不久之后,董事会批准了这个项目。一段时间后,该公司已经有十几项服务在云中运行(仍然包括 monolith 的剩余部分),另一场运动开始了。这一次负载很高,但系统保持了弹性。尽管聊天服务停止了,但视频播放仍然正常,这确保了公司的声誉完好无损,投资者把钱投到了好的地方。我们的工程师回到床上,知道核心业务不会再有问题,从此过上了幸福的生活。在下图 1-3 中,我们可以看到我们是如何从之前的图中失去耦合架构的,现在只有部分中断。
图 1-3
松散耦合的组件可能只会导致部分停机
就这样吗?嗯,不。在上面的故事中,我们公司有一个运行聊天系统的服务,但核心业务仍然最有可能驻留在 monolith 中。如果聊天系统在那里,这是完全可能的营销活动也将是一场灾难,导致该公司真正的结束。总之,在关键情况下,在系统中构建或提取哪怕是很小的模块都会带来巨大的成功。
当你去参加会议的时候,你会听到很多这样令人惊奇的故事,我自己也讲过几个。在大多数成功故事中,都有一件微小但意义重大的事情,是演讲者(包括我自己)不太喜欢谈论的。这是投入的时间量,以确保上面描述的系统既能在软件层面上工作,也能在组织层面上工作。在大多数情况下,实现一个有效的微服务架构需要数年的工程工作,不仅包括编码,还包括架构计划、大量的工具、当前系统的研究、发布计划、会议、会议,最后但同样重要的是耐心。
请记住,像这样的转换是一种很好的方式,可以让您组织中的工程师成长,并测试他们的知识,不仅仅是技术方面,还有组织方面。这是一个很好的机会,可以留住贵公司的高级工程师,并使正式员工和初级员工能够探索他们还不需要接触的新领域。
在这一点上,用微服务开始你的 entre 架构似乎是一个好主意。让我们在下一节探讨这个选项。
早期设计选择
从经验来看,如今公司可能犯的最大错误之一是开始使用微服务构建他们的架构。尽管今天的工具远远优于 5 年前的工具。我们有令人惊叹的协调器,如 Kubernetes,容器化工具,如 Docker,AWS 上的各种服务,如 EBS,它们帮助我们构建和部署服务。然而,尽管有惊人的工具,开发可能会很快陷入困境。
企业在开始时需要的东西(正如我们已经简要讨论过的)是敏捷性。数据、逻辑和表示层的快速变化,以提供尽可能多的用户特性。另一方面,设计和构建服务需要时间、奉献和核心流程。随后,用微服务架构开始你的工程文化和开发过程可能会导致灾难。
我自己工作的系统从一开始就被设计成面向“微服务”的。理论上,一切看起来都很棒。这些服务有清晰的接口定义,并且都有所有者。这些服务被编译在一起,并通过一个异步的、基于事件的系统相互通信。构建过程使用一个非常成熟的构建协调器作为基础系统,并经过优化以帮助所有工程师取得成功。受到如此多的赞扬后,你可以预料结果是负面的。是的,它是。理论上一切看起来都很完美,但实际上大多数事情都失败了。系统过于分散,依赖性成了负担,而不是促进因素。工程师们需要的是速度,而不是一个当他们更新另一个组件时需要升级每个组件版本的系统。不久之后,维护接口定义变成了一场噩梦,因为很难理解组件之间的交叉依赖和版本控制。工程师们更多地是在寻找定制的、不受监管的捷径,而不是目前的工作方式。构建系统,虽然非常专业,但被认为是为时过早,减缓了开发人员的速度,而不是使他们能够加速开发。
后来,该公司决定将微服务架构合并到一个整体应用中,此后速度开始加快。最终证明该系统是成功的。最终,代码库变得太大,团队开始考虑再次拆分的方法。
从微服务开始是个坏主意吗?当工程规范要求你应该转向另一个方向时,转向一个整体是个好主意吗?
第一个问题的答案不是那么明显。如果公司规模更大,工程文化是围绕这种类型的架构设计的,并且会有更多的时间投入到工具开发中,那么它可能会成功。然而,中小型公司通常负担不起专门的工具工程师。另一方面,拥有闲置产能的大公司可以从一开始就使用这些理念来设计他们的系统。
至于第二个问题,不管你问谁,答案都会是响亮的“是”。在这个故事中,一个很好的例子是能够回头看,并后退两步,从长远来看,向前迈出巨大的一步。
这个故事的寓意之一是,为了实现这一飞跃,你不仅需要了解行业中的技术和最佳实践,还需要了解你的业务、组织以及在你公司工作的人。可能适用于其他公司(在某些情况下,甚至只是你的工程组织中的其他团队)的东西对你来说可能是一个巨大的失误。
摘要
在这一章中,我们学习了在办公室喝咖啡时与同事谈论微服务时可以使用的基本语言学。我们还了解到,monoliths 本身并不坏,它们甚至可以帮助您快速发展业务,就像如果您在错误的时间使用微服务会减慢您的速度一样,但从长远来看,可以更好地改变您的用户与您的系统交互的方式。在本书的其余部分,我们将探索对您,工程师、产品所有者、架构师的确切要求,以确保适应这种开发心态将是对您公司的良好补充,而不是敏捷灾难。
https://googleblog.blogspot.com/2014/01/todays-outage-for-several-google.html
二、一点 Django
在设计软件服务时,需要考虑的最重要的一点是编程语言和在项目中使用的相关框架。
出于教育目的,在本书中,我们将使用 Python 编程语言和 Django Web 框架以及相关的工具链来实现我们的目标。为了稍微了解一下知识,我们将学习 Django 的核心概念,并自己构建一个服务。
引入问题
在本书中,我们将致力于尽可能贴近现实生活的问题。因此,我们将根据业务需求开展工作,并尝试遵循整个产品的服务设计流程,而不仅仅是其中的一小部分。以下是我们的问题空间:
提扎。Tizza 是一个移动优先应用,用户可以通过照片和对披萨的描述来决定他们是否喜欢披萨。喜欢一个地方后,用户将收到使用该应用组织到给定比萨饼店的团体参观的通知。最终用户有一个朋友列表,他们应该能够管理。用户可以设置私人(仅朋友)和公共(不仅仅是朋友)事件。
自然,我们公司需要以某种方式赚钱。为此,我们从比萨店收取广告费/排名费,以放在我们的平台上。根据销售系统,我们需要确定一家公司在我们的排名中的位置,以及最终用户应该以什么顺序查看餐厅。
入门指南
在我们深入研究服务设计之前,我们将在终端中运行几个命令来开始我们的 Django 项目。
注意
强烈建议将您的代码和依赖项组织到虚拟环境或容器中。如果您不完全熟悉这些概念,我强烈推荐您使用 virtualenv 或 Docker 查看 Python 开发。
要安装 Django,您只需在终端中运行以下代码:
pip install django
当我们看到 Django 应用作为一个整体运行时,我们称之为项目。要创建 Django 项目,只需在终端中执行以下命令:
django-admin startproject tizza
这样,一个简单的 Django 应用将被创建在一个名为 tizza 的文件夹中,如图 2-1 所示。
图 2-1
裸露 django 文件夹结构
如果我告诉你,在这一点上,你已经有一个功能正常的网站?在您的终端中键入以下内容:
python manage.py runserver
并在您的浏览器中访问 http://localhost:8000
您应该会看到图 2-2 中的屏幕。
图 2-2
Django 已成功安装
恭喜你!现在真正的工作开始了。
Django 应用
Django 在其通用文件夹结构中有第二层逻辑。这些被称为应用。命名可能会有点混乱,因为它们与智能手机上运行的软件无关。应用通常用作业务案例和/或应用功能之间的逻辑分隔符。你可以认为项目能够在不同的应用之间进行编排,而一个应用只能属于一个项目。要创建新的应用,我们只需运行以下命令:
python manage.py startapp pizza
现在我们的目录结构已经更新,如图 2-3 所示。
图 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
代码本身并不可怕,但是有几个非常严重的问题,有经验的工程师可能已经注意到了:
-
保安。如果 pizza id 来自用户(很可能来自用户),他们可以简单地通过巧妙的注入删除整个数据库。如果您希望原始查询具有额外的安全性,您需要自己实现它。对你来说可能是一个很好的练习,但是,对你的生意来说绝对是一个糟糕的练习。
-
可维护性:使用文本对象是…至少可以说很难。在上面这段代码中,您在文本中隐藏了一个条件,没有 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 )。
图 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 中看到我们创建的注册页面。
图 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 。
图 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
我知道,上面的东西是很多代码,但是让我们运行一个快速分析和思想实验,看看每一行是如何有意义的:
-
我们的大多数应用都会有相同的 <头> 信息。meta 标签很少改变,而且肯定有 css 文件和样式,我们希望应用于我们所有的页面,但是,我们很可能希望将不同的 css 文件分发到我们网站的不同页面,所以 CSS 块在那里完全有意义。
-
页眉很可能会出现在我们所有的页面上,但是,有时我们希望页眉消失,或者以完全不同的方式出现(可能出现在营销页面上)。在这种情况下,我们允许覆盖整个标题。页脚也一样。
-
内容块本质上是您希望在页面中覆盖的内容。
-
在页面的最后,我们将有加载的 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 应用下的模型。
图 3-1
想象中的 tizza 应用后端系统
嗯,至少是其中的一些,我们将在几章中看到。您可以在图中看到的另一个服务是 auth 服务,我们将使用它来处理所有用户相关信息和身份验证。一些团队也使用类似 auth 的服务进行授权,根据您的喜好,您也可以将该层移动到您架构的一个单独部分。但是请记住,存在于相似或相同业务领域中的数据通常应该保持接近。
这里值得一提的是,这些服务的设计是由它们托管的数据和它们工作的领域共同驱动的。在构建微服务时,创建仅托管单一特定数据类型的服务是一个非常常见的错误。当不同类型的数据位于相似的业务领域时,它们应该在物理上和逻辑上彼此靠近。然而,这总是一个艰难的决定。这里有几个案例,你可以在午休时和同事一起哀叹:
-
比萨饼和比萨饼店应该托管在同一数据存储中的同一服务上吗?如果我们开始储存比萨饼的配料呢?在那种情况下,你的观点会改变吗?
-
我们应该在哪里存储权限?它应该绑定到比萨饼上还是绑定到我们系统的用户上?
-
我们应该把喜欢的东西存放在哪里?
以上所有问题都有多个好答案。我的建议是这样考虑:如果数据不是耦合的或者是松散耦合的,那么您可以安全地将其分解。如果耦合紧密,试着测量耦合有多紧密。例如,您可以随时测量从数据存储中一起查询不同资源的次数,这可能有助于您做出决定。
将所有数据保存在同一个地方可以为您的公司工作一段时间。事实上,在开始时,将数据保存在同一个地方会大大加快操作速度。然而,过一段时间后,我们在第一章提到的问题和故事会一次又一次地出现。如果您的数据库中只有一个不是非常关键的表,但却填满了您的存储空间,导致您的核心业务中断,那就太遗憾了。另一方面,将所有内容分开将会使您无法创建连接和在存储级别进行快速操作,您将需要从不同的源获取数据并手动连接它们,有时会编写低效的查询。
这个演讲可能会给你一个在不同的存储器中进行数据复制的想法。让我们绕过这个话题快速说一下。
关于数据复制的一个注记
既然我们已经谈了很多关于数据服务如何工作的内容,我想稍微绕一下,谈谈当您迁移到微服务时,您将使用的不同数据存储之间的数据复制。
使用微服务时,数据复制会变得非常自然。毕竟,您的服务中确实需要电子邮件地址,对吗?为什么不在创建用户时存储它,这样您就可以确信这些数据在任何时候都是可用的。
这样的想法可能很有欺骗性。当你使用微服务(实际上,软件中的任何东西)时,你总是想减少的一件事就是维护工作。当您在服务中引入一个新的数据库、表甚至只是一个字段时,您正在为自己创建所有权和维护工作。在上面提到的电子邮件示例中,您需要确保电子邮件始终保持最新,这意味着您必须确保如果您的用户在 auth 服务中更改了它,您也需要在自己的服务中更改它!当客户想要使用他们被遗忘的权利时,您也需要确保删除或匿名化您数据存储中的电子邮件地址。从长远来看,这会导致很多不一致和令人头痛的问题。
在许多系统中保持数据一致是一个非常困难的问题。几十年来,数据库工程师一直在与上限定理作斗争,创造了像暗示移交或草率仲裁这样的算法,最终实现了各种数据库副本之间的一致性。在您的应用中实现这样复杂的一致性算法值得吗?
如您所知,我不太喜欢数据复制。当然,有些情况是你无法避免的,但是,我通常会推荐以下替代方案:
-
虚拟对象:如果您可以存储一个标识符,使用该标识符可以从另一个系统查询该对象,那么为什么您需要存储整个用户对象呢?
-
客户端和服务器端缓存:想想你正在处理的数据。跟上时代有多重要?数据的所有者服务总是可以很容易地实现一个缓存层,但是同样的事情也可能发生在客户端!
在开始从其他服务复制数据之前,请考虑替代方案。从长远来看,这可能会让你付出昂贵的代价。
既然我们已经很好地理解了数据将存储在哪里以及如何存储,那么让我们来看看将消费它们的服务类型。
前端服务
前端服务的存在是为了将呈现在用户机器上的前端应用容器化。乍一看,它们的存在可能没有多大意义,但是,有几个原因可以解释为什么设计(几乎)完全前端的服务对您和您的团队来说可能有意义:
-
关注点分离——您可能还记得(或者仍然使用)MVC 模型,以及它们将应用的各个部分分离开来的好处。在某种程度上,您可以将前端服务视为 MVC 的“视图”层。开发人员可以专门处理这些服务,只利用其他人的接口并与他们不拥有的数据进行交互。
-
独立的工具——如果有不同的团队在前端服务上工作,那么将会有不同的工具围绕着它,并且有更多的专业人员在这个领域工作。并不是所有熟悉 gradle 的人都熟悉 Webpack。然而,这并不一定意味着他们不能互相学习!
前端服务可以直接使用来自后端服务和系统的数据,后端服务和系统可以将后端服务提供的数据集成为由特定业务逻辑定义的更易于理解的格式。让我们来看看混合服务。
混合服务
按照 SoA 的理念,有时我们需要系统只为我们的业务做一件事,而且它做得很好。没有前端或后端专业知识的工程师需要负责这些服务。也完全有可能这些业务组件没有严格地绑定到工程部门。这本书的主要焦点将围绕后端和混合服务。
如果所有权或者缺乏维护系统的人,我们完全可以考虑系统,我喜欢称之为“混合服务”。在野外,它们有时被称为后端到前端服务,或 BFF。
混合服务将大量前端和后端组件连接在一起,以实现简单的业务目标。在开始编写代码之前,让我们先看一个例子:
让我们想象一个世界,在遥远的未来,我们成为 tizza 中最重要的团队之一的技术领导,这就是 tizza-admin 团队。我们的使命是确保所有的披萨创建者可以轻松管理他们的披萨,并可以在应用中推广营销活动。他们需要一个单页应用来使体验更加流畅。阅读规范后,可能会出现以下问题:
-
这里有很多数据,我们应该从哪里获取呢?
-
我们应该从前端分别调用所有的服务端点吗?
-
移动呢?他们能处理所有的数据吗?
所有这些都是每个全栈(和非全栈)在构建具有多个数据源的单页面应用时应该问自己的有效问题。我们不想做的第一件事是连接到现有的数据库(我们将在本章的后面有更多的推理),所以我们将限制自己调用 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 包和框架,如 asyncio 和 aiohttp-jobs ,在这里很容易进入将请求存储在内存中并在进程重启时完全丢失请求的危险区域。
7。使用 端口绑定 导出您的服务
更具体一点的 web 开发(但是,嘿,这本书的大部分内容都是关于 web 开发的),这个规则规定应用应该是完全自包含的,不应该依赖于 web 服务器的运行时注入,而是通过绑定到一个端口并通过该端口服务请求来导出它的接口。
在我们的情况下,Django 会处理所有的事情。
8。使用流程向外扩展
每个应用的基础应该是进程,它应该被解释为类似 Unix 的服务守护进程。应该设计各种类型的进程来处理各种类型的有效负载。计算量大、运行时间长的任务可能需要工作进程或其他异步进程,而 HTTP 请求可能需要 web 进程来处理。
这并不意味着你的进程运行时不鼓励线程,在 Python 的情况下,你的应用完全可以利用“线程”库,或“asyncio”。另一方面,您的应用需要能够扩展为在相同或多个物理和/或虚拟机上运行的多个进程。
确保不要在操作系统层面上使事情过于复杂,只需使用标准的工具来管理您的流程,如“systemd”或“supervisor”。
第 6 点实现了这一点。
9。流程应该易于启动和处理
不要依赖你的 12 因素应用的流程,因为它们应该很容易摆脱,也很容易创建。一接到通知。不过,这有几个要求。
-
启动应该很快——进程应该只需要几秒钟就能启动。这是简化缩放和快速发布过程所需要的。实现这一点可能相当棘手。您应该确保在加载应用时没有昂贵的操作——比如远程调用不同的 web 服务器。如果你正在使用许多模块,你可能想研究一下 importlib 的 lazy_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 中看到该页面的示意图。
图 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 中,你可以看到现在实现的分页的第一页是什么样子:
图 4-2
数据库中前五个比萨饼的列表
正如你所看到的,我们的数据库中总共有 6 个比萨饼,端点返回给我们其中的 5 个,并给了我们以后可以使用的下一页的 URL。在查询了那个之后,你可以在图 4-3 中看到,我们在第二页上只收到了 1 个披萨。
图 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 中的架构计划。
图 4-4
GDPR 的简单解决方案
正如您所看到的,解决方案非常简单:当用户从 auth 数据库中删除时,会有一个对每个保存用户虚拟值的服务的远程调用。作为第一个版本,这可能是一个好主意,但是,这带来了各种问题:
-
它将如何扩展?-那么,现在这已经完成了,每次我们引入一个新的服务,它包含一个指向用户对象的“指针”需要被添加到这个列表中?当我们有太多的请求,用户删除变得难以忍受时,还有什么意义呢?
-
谁拥有代码?-如果第二次远程调用失败会怎样?拥有用户信息的人应该保持这些代码的维护和可靠性,还是在这个逻辑中每个团队都拥有自己的一小段代码?如果这个代码需要一些按摩,谁会在半夜醒来?
-
谁依赖谁?-到目前为止,其他服务都依赖于 auth 服务中的用户信息。然而,现在 auth 服务也依赖于其他应用,这使得系统的耦合性超出了预期。
如你所见,这种方法有很多问题。幸运的是,有解决方案可以减少耦合,使系统中的所有权更清晰。我们称这些系统为队列。
队列的概念
你可能记得在高中或大学学过排队。现在,想象同样的概念,只是在建筑层面上。系统中的应用将消息发布到代理或主题,然后代理或主题将消息放到队列中,供工作人员使用。图 4-5 简单概述了上面的例子是如何工作的。
图 4-5
GDPR 的队列概念
如您所见,auth 服务发布了一条消息,表明用户已从系统中删除。消息被发布到代理,代理将消息推送到三个单独的队列。一个删除赞,一个删除出席率,一个删除上传的披萨。
这样有什么好处?
-
我们已经扩展了——现在我们不需要为我们需要处理的每个删除创建一个新的调用,我们只需要创建一个新的队列并将其绑定到代理。这样,我们在呼叫端保持快速。
-
我们已经清理了所有权-您需要删除客户信息?只要听听这条消息,为它实现一个处理程序,就可以了。删除用户信息现在归每个团队所有,这意味着拥有 auth 服务的团队不需要了解存储用户信息的每个服务。
-
依赖关系变得松散——通过消除各种服务之间的硬性依赖关系,系统变得耦合性更低。耦合还是有的,但至少发布端不需要知道这些。假设从明天开始,事件将由第三方处理,我们不需要在那里照顾 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.delete 和pizzas . 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。这意味着消息也需要更新。那么,在这种情况下,版本迁移是什么样的呢?
-
在项目开始时,auth 团队发出一个通知,告诉所有团队标识符中的用户模型将发生变化。
-
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')
-
auth 团队向公司发送了一份关于他们的 1.0.0 消息的反对通知,并给出了一个合理的迁移截止日期。
-
点赞团队计划并实施监听用户删除新版本。
-
团队删除了旧版本的代码。
我知道这听起来很理想化,但是,这是您可以确保在突破性特性的实现过程中不会出现中断的方法。
处理代理停机
排队系统的一个问题是代理中断。基本上,当中央排队组件停止工作时,可能会出现无数问题:
-
丢失的消息:可能是最糟糕的。当消息丢失时,无论如何也找不到它们的踪迹,应用的整体状态就会发生扭曲。这不仅会在给定时刻引起问题,还可能在将来引起矛盾。想象一下,一个支付状态机对客户的当前状态感到困惑。
-
断开的生产者和消费者:在代理中断期间,网络也可能会放弃,生产者和消费者通常会悄悄地与代理断开连接。如果忘记重新连接这些系统,将来可能会引起不愉快的意外。
为了避免这样的灾难,您需要做的第一件事就是在您的代理集群上建立适当的监控和警报系统。您越早知道停机,就能越快做出反应。如果你有资源,一个更好的解决方案可能是将你的经纪人托管业务外包给专业人士。如果这不是你的核心能力,你可能不应该试图去设计它。
另一个可以在内部实现的解决方案是使用各种设计模式来提高集群的弹性。您需要保护的最重要的事情是数据完整性,为此,有一种称为发件箱的模式。
你们中的一些人可能熟悉发件箱模式,对于那些不熟悉的人,这里有一个复习:发件箱是一个软件工程模式,主要用在发送同步或异步消息的应用中,其中所有的消息都存储在一个存储中,然后由一个外部进程从给定的存储中调度。调度程序通常被称为发件箱工作程序。图 4-6 架构的快速概览。
图 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 能够处理来自其他服务的错误,您就可以开始了。然而,在现实生活中,竞争条件的复杂性可能比这里解释的要高得多。我还建议始终监控连接、未被确认的消息、异常和队列吞吐量,以确保数据不会丢失。
-
结论
我们已经讨论了很多关于系统中各种服务之间的通信。所有的交流方式都有其优点和缺点。每一种情况都要求你重新考虑你要用什么工具来解决给定的问题。在构建微服务时,固定通信层可能是最大的挑战。有了这些工具,你肯定不会犯巨大的错误。