PHP-微服务(全)
PHP 微服务(全)
原文:
zh.annas-archive.org/md5/32377e38e7a2e12adc56f6a343e595a0
译者:飞龙
前言
微服务是新生力量。它们是解决问题的一种方式,但这并不意味着它们总是最佳解决方案。微服务是一个完整而复杂的架构,您需要深入了解,才能成功地将它们应用于现实世界。
我们知道,理解和准备微服务可能需要时间,可能非常复杂,因此出于这个原因,我们写了这本书——为您提供微服务的简要指南,从 A 到 Z。这本书只是微服务的冰山一角,但我们希望它至少能成为进入这个世界的有益起点。
在本书中,我们将从解释这种架构的基础知识开始,您需要掌握的基本知识。随着您在本书中的进展,我们将增加难度。然而,我们将引导您完成每个步骤,因此在本书结束时,您将能够知道微服务架构是否是解决问题的最佳方案,以及如何应用这种架构。
享受您的微服务!
本书涵盖内容
第一章,“什么是微服务?”,将教授您微服务的所有基础知识。
第二章,“开发环境”,将带您设置开发机器,以成功构建微服务。
第三章,“应用程序设计”,将帮助您开始设计应用程序,创建项目的基础。
第四章,“测试和质量控制”,探讨了测试应用程序的重要性以及如何向应用程序添加测试。
第五章,“微服务开发”,将介绍构建微服务应用程序,解释每个步骤。
第六章,“监控”,涵盖了如何监控应用程序,以便您始终了解应用程序的行为。
第七章,“安全”,着重介绍了如何向应用程序添加额外的复杂性,使其更加安全。
第八章,“部署”,解释了如何成功部署您的应用程序。
第九章,“从单体架构到微服务”,讨论了单体应用程序如何转变为微服务的示例。
第十章,“可扩展性策略”,概述了如何创建可扩展的应用程序。
第十一章,“最佳实践和惯例”,将更新您对应用程序中应该使用的最佳实践和惯例的了解。
第十二章,“云和 DevOps”,探讨了不同的云提供商和 DevOps 世界。
本书需要什么
要跟随本书,您需要一台连接互联网的计算机,以下载所需的软件和 Docker 镜像。在某个时候,您至少需要一个文本编辑器或 IDE,我们强烈推荐 PHPStorm。
本书适合对象
本书适合有经验的 PHP 开发人员,他们希望在职业生涯中迈出一步,开始构建成功的可扩展和可维护的应用程序,所有这些都基于微服务。阅读本书后,他们将能够知道微服务是否是解决问题的最佳方案,如果是这种情况,创建一个成功的应用程序。
惯例
在本书中,您将找到一些文本样式,用于区分不同类型的信息。以下是这些样式的一些示例及其含义的解释。
文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:“下面的代码行读取链接并将其分配给BeautifulSoup
函数。”
一块代码设置如下:
version: '2'
services:
当我们希望引起您对代码块的特定部分的注意时,相关的行或项目以粗体显示:
version: '**2**'
services:
任何命令行输入或输出都以以下方式编写:
**$ uname -r
3.10.0-229.el7.x86_64**
新术语和重要单词以粗体显示。您在屏幕上看到的单词,例如菜单或对话框中的单词,以这种方式出现在文本中:“单击工具栏上的鲸鱼图标以打开首选项...和其他选项。”
注意
警告或重要说明以这样的框出现。
提示
提示和技巧显示为这样。
第一章:什么是微服务?
好的项目需要好的解决方案;这就是为什么开发人员总是在寻找更好的工作方式。没有适用于所有项目的最佳解决方案,因为每个项目都有不同的需求,架构师(或开发人员)必须为该特定项目找到最佳解决方案。
微服务可能是解决问题的一个好方法;在过去几年中,像 Netflix、PayPal、eBay、亚马逊和 Spotify 这样的公司选择在他们自己的开发团队中使用微服务,因为他们认为这是他们项目的最佳解决方案。要理解他们为什么选择微服务,并了解应该在哪种项目中使用它们,有必要了解什么是微服务。
首先,了解什么是单片应用是至关重要的,但基本上,我们可以将微服务定义为扩展的面向服务的架构。换句话说,这是一种通过遵循将应用程序转变为各种小服务所需的步骤来开发应用程序的方式。每个服务将自行执行并通过请求与其他服务通信,通常使用 HTTP 上的 API。
要进一步了解什么是微服务,我们首先需要了解什么是单片应用。这是我们过去几年一直在开发的典型应用,例如在 PHP 中使用像 Symfony 这样的框架;换句话说,我们一直在开发的所有应用都被划分为不同的部分,如前端、后端和数据库,并且还使用 MVC 模式。重要的是要区分 MVC 和微服务。MVC 是一种设计模式,而微服务是一种开发应用的方式;因此,使用 MVC 开发的应用仍然可能是单片应用。人们可能会认为,如果我们将应用程序分割到不同的机器上,并将业务逻辑与模型和视图分开,那么应用程序就是基于微服务的,但这是不正确的。
然而,使用单片架构仍然有其优势。还有一些巨大的网络应用,比如 Facebook,使用它;我们只需要知道何时需要使用单片架构,何时需要使用微服务。
单片与微服务
现在,我们将讨论使用单片应用的优缺点以及微服务如何通过提供一个基本示例来改进特定项目。
想象一下像 Uber 这样的出租车平台;在这个例子中,平台将是一个小型平台,只有基本的东西,以便理解定义。有顾客、司机、城市,以及一个实时跟踪出租车位置的系统:
在单片系统中,所有这些都在一起---我们有一个与顾客和司机相关联的公共数据库,这些都与城市相关联,并且所有这些都与使用外键跟踪出租车的系统相关联。
所有这些也可以托管在不同的机器上,使用主从数据库;后端可以在不同的实例中使用负载均衡器或反向代理,前端可以使用不同的技术,如 Node.js 甚至纯 HTML。即便如此,平台仍然是一个单片应用。
让我们看一个在单片应用中可能面临的问题的例子:
-
两位开发人员 Joe 和 John 正在基本的出租车示例项目上工作;Joe 需要更改一些关于司机的代码,而 John 需要更改一些关于顾客的代码。第一个问题是,这两位开发人员都在同一个基本代码上工作;这不是一个独占的单片问题,但如果 Joe 需要在司机模型上添加一个新字段,他可能也需要更改顾客的模型,因此 Joe 的工作不仅仅局限在司机方面;换句话说,他的工作没有被界定。这就是当我们使用单片应用时会发生的情况。
-
乔和约翰意识到,跟踪出租车的系统必须与调用外部 API 的第三方进行通信。应用程序的负载并不是很大,但跟踪出租车的系统却有很多来自顾客和司机的请求,因此在那一方面存在瓶颈。乔和约翰必须对其进行扩展以解决跟踪系统上的问题:他们可以获得更快的机器、更多的内存和负载均衡器,但问题是他们必须对整个应用程序进行扩展。
-
乔和约翰很高兴;他们刚刚解决了跟踪出租车系统的问题。现在他们将把更改放到生产服务器上。他们将不得不在网站应用程序的流量较低时工作;风险很高,因为他们必须部署整个应用程序,而不仅仅是他们修复的跟踪出租车系统。
-
在几个小时内,应用程序出现了 500 错误。乔和约翰知道问题与跟踪系统有关,但整个应用程序将会崩溃,只是因为应用程序的一部分出了问题。
微服务是一个简单的、独立的实体,具有明确的目标。它是独立的,并通过约定的通道与其他微服务进行通信,如下图所示:
对于习惯于面向对象编程的开发人员来说,微服务的概念就像是一个封装的对象在不同的机器上运行,并且与其他在不同机器上运行的对象隔离。
按照之前的例子,如果我们在跟踪出租车的系统上出现问题,就需要隔离与该部分应用程序相关的所有代码。这有点复杂,将在第九章“从单体架构到微服务”中详细解释,但总的来说,这是一个专门用于跟踪出租车的系统的数据库,因此我们需要提取出这部分,并修改代码以使其与提取出的数据库一起工作。一旦目标实现,我们将拥有一个可以被单体应用程序的其余部分调用的微服务 API(或任何其他渠道)。
这将避免之前提到的问题——乔和约翰可以独立处理他们自己的问题,因为一旦应用程序被划分为微服务,他们将处理顾客或司机微服务。如果乔需要更改代码或包含新字段,他只需要在自己的实体中进行更改,约翰将使用司机 API 从顾客部分进行通信。
可扩展性只需针对这个微服务进行,因此不需要花费金钱和资源来扩展整个应用程序,如果跟踪出租车的系统出现问题,其余应用程序将可以正常工作。
使用微服务的另一个优势是它们对语言是不可知的;换句话说,每个微服务可以使用不同的语言。一个用 PHP 编写的微服务可以与用 Python 或 Ruby 编写的其他微服务进行通信,因为它们只向其他微服务提供 API,因此它们只需共享相同的接口来相互通信。
面向服务的架构与微服务
当开发人员遇到微服务并了解面向服务的架构(SOA)软件设计风格时,他们首先要问自己的问题是 SOA 和微服务是否是同一回事,或者它们是否相关,答案有点具有争议性;取决于你问的人,答案会有所不同。
根据马丁·福勒(Martin Fowler)的说法,SOA 专注于将单体应用程序相互集成,并使用企业服务总线(ESB)来实现这一目标。
当 SOA 架构开始出现时,它们试图将不同的组件连接在一起,这是微服务的特点之一,但 SOA 的问题在于它需要许多围绕架构工作的东西,如 ESB、业务流程管理(BPM)、服务存储库、注册表等等,因此这使得开发变得更加困难。此外,为了更改代码的某些部分,需要先与其他开发团队达成一致。
所有这些因素使得维护和代码演进变得困难,上市时间变长;换句话说,这种架构并不适合经常需要实时更改的应用程序。
关于 SOA 还有其他观点。有人说 SOA 和微服务是一样的,但 SOA 是理论,微服务是一个很好的实现。使用 ESB 或使用 WSDL 或 WADL 进行通信并不是必须的,但它被定义为 SOA 的标准。如下图所示,使用 SOA 和 ESB 的架构如下所示:
请求通过不同的方式到达;这与微服务的工作方式相同,但所有请求都到达 ESB,它知道应该调用哪里来获取数据。
微服务特点
接下来,我们将看一下微服务架构的关键要素:
-
准备失败:微服务被设计为失败。在 Web 应用程序中,微服务相互通信,如果其中一个失败,其余部分应该继续工作以避免级联故障。基本上,微服务试图避免使用同步通信,而是使用异步调用、队列、基于 actor 的系统和流。这个话题将在接下来的章节中讨论。
-
Unix 哲学:微服务应该遵循 Unix 哲学。每个微服务必须设计为只做一件事,并且应该小而独立。这使我们作为开发人员能够独立调整和部署每个微服务。Unix 哲学强调构建简单、简短、清晰、模块化和可扩展的代码,这样可以方便开发人员进行维护和重用,而不仅仅是其创建者。
-
通信层:每个微服务通过 HTTP 请求和消息与其他微服务通信,执行业务逻辑,查询数据库,与所需系统交换消息,最终返回 JSON(或 HTML/XML)响应。
-
可扩展性:选择微服务架构的主要原因是可以轻松扩展应用程序。应用程序越大,流量越大,选择微服务的合理性就越大。微服务可以在不影响应用程序的其他部分的情况下扩展所需的部分。
成功案例
了解微服务在现实生活中有多重要的最好方法是了解一些决定发展并使用微服务的平台,考虑未来并使维护和扩展更容易、更快速、更有效:
-
Netflix:在线视频流媒体的头号应用在几年前将其架构转变为微服务。有一个关于他们决定使用微服务的原因的故事。在对评论模块进行更改时,一名开发人员忘记在行尾加上
;
,导致 Netflix 宕机了很多小时。该平台每天约 30%的总流量来自美国,因此 Netflix 必须向每月付费的客户提供稳定的服务。为了实现这一点,Netflix 对每个我们发出的请求进行五次请求,并且可以使用其流媒体视频 API 从 800 个不同的设备获取请求。 -
eBay:在 2007 年,eBay 决定将其架构改为微服务;他们曾在 C++和 Perl 上使用单片应用程序,后来他们转向了基于 Java 构建的服务,最终实施了微服务架构。它的主要应用程序有许多服务,每个服务都执行自己的逻辑,以供每个领域的客户使用。
-
Uber:微服务使得这家公司能够快速增长,因为它允许使用不同的语言(Node.js、Go、Scala、Java 或 Python)用于每个微服务,而且招聘工程师的过程更容易,因为他们不受语言代码的限制。
-
亚马逊:也许亚马逊不是互联网流量之王,但它几年前就转向了微服务,成为最早使用实时微服务的公司之一。工程师们表示,使用旧的单片应用程序无法提供他们提供的所有服务,比如网络服务。
-
Spotify:对于 Spotify 来说,比对手更快是必须的。主要工程师尼古拉斯·古斯塔夫森表示,快速、自动化一切以及拥有更小的开发团队对应用程序非常重要。这就是为什么 Spotify 使用微服务的原因。
微服务的缺点
接下来,我们将看看微服务架构的缺点。当询问开发人员时,他们认为微服务的主要问题是在生产服务器上进行调试。
基于微服务的应用程序调试可能会有点繁琐,特别是当你的应用程序中有数百个微服务时,你需要找到问题出在哪里;你需要花时间寻找给你报错的微服务。每个微服务都像一个独立的机器,所以要查看特定的日志,你必须去特定的微服务。幸运的是,有一些工具可以帮助我们解决这个问题,可以从应用程序中的所有不同微服务中获取日志,并将它们汇总到一个位置。在接下来的章节中,我们将看看这些工具。
另一个缺点是需要将每个微服务都作为一个完整的服务器进行维护;换句话说,每个微服务都可以有一个或多个数据库、日志、不同的服务或库版本,甚至代码可以使用不同的语言,因此如果维护单个服务器困难,那么维护数百个服务器将浪费金钱和时间。
此外,微服务之间的通信非常重要---它们必须像时钟一样工作,因此通信对于应用程序至关重要。为了做到这一点,开发团队之间的沟通将是必要的,彼此告知他们需要什么,同时为每个微服务编写良好的文档也是必要的。
与微服务一起工作的一个好的做法是自动化一切,或者至少尽可能自动化一切。也许最重要的部分是部署。如果需要部署数百个微服务,这可能会很困难。因此,最好的方法是自动化这些任务。我们将在接下来的章节中看看如何做到这一点。
如何将开发重点放在微服务上
开发微服务是一种新的思维方式。因此,当您第一次遇到应用程序的设计和构建时,可能会感到困难。然而,如果您始终记住微服务背后最重要的思想是将应用程序分解为更小的逻辑部分的需求,那么您已经完成了一半。
一旦理解了这一点,以下核心思想将有助于您的应用程序的设计和构建过程。
始终创建小的逻辑黑匣子
作为开发人员,你总是从你将要构建的大局开始。尝试将大局分解为只做一件事情的小逻辑块。一旦多个小块准备就绪,你可以开始构建复杂的系统,确保你的应用程序的基础是坚实的。
你的每个微服务都像一个黑匣子,有一个公共接口,这是与你的软件交互的唯一方式。你需要牢记的主要建议是构建一个非常稳定的 API。你可以在不引起太多问题的情况下更改 API 调用的实现,但是如果你改变调用的方式甚至是这个调用的响应,你将陷入麻烦。在对 API 进行重大更改的情况下,确保使用某种版本控制,以便你可以支持旧版本和新版本。
网络延迟是你隐藏的敌人
服务之间的通信是通过使用网络作为连接管道的 API 调用进行的。这种消息交换需要一些时间,由于多种因素,它的时间不会总是相同。想象一下,你在一台机器上有service_a,在另一台机器上有service_b。你认为网络延迟会一直是一样的吗?如果,例如,其中一台服务器负载很高,需要一些时间来处理请求,会发生什么?为了减少时间,始终关注你的基础设施,监控一切,并在可用时使用压缩。
始终考虑可扩展性
微服务应用的主要优势之一是每个服务都可以进行扩展。通过将有状态服务的数量减少到最低,可以实现这种灵活性。有状态服务依赖于数据持久性,这使得在没有数据一致性问题的情况下移动或共享数据变得困难。
使用自动发现和自动注册技术,你可以构建一个系统,它始终知道谁将处理每个请求。
使用轻量级通信协议
没有人喜欢等待,即使是你的微服务也是如此。不要试图重新发明轮子或使用一个晦涩但很酷的通信协议,使用 HTTP 和 REST。它们为所有的 Web 开发人员所熟知,它们快速、可靠、易于实现,非常容易调试。如果需要增加安全性,实现 SSL/TSL。
使用队列来减少服务负载或进行异步执行。
作为开发人员,你希望使你的系统尽可能快。因此,增加 API 调用的执行时间只因为它在等待可以在后台完成的某些操作是没有意义的。在这些情况下,最好的方法是使用队列和作业运行程序来处理后台处理。
想象一下,你有一个通知系统,在微服务电子商务上下订单时向客户发送电子邮件。你认为客户想要等待看到支付成功页面,只是因为系统正在尝试发送电子邮件吗?在这种情况下,更好的方法是将消息加入队列,这样客户将会有一个非常快速的感谢页面。稍后,作业运行程序将提取排队的通知,并将电子邮件发送给客户。
为最坏的情况做好准备
你有一个建立在微服务之上的漂亮、新颖、好看的网站。当一切都出错的时候,你准备好了吗?如果你遇到了网络分区,你知道你的应用程序是否能从这种情况中恢复吗?现在,想象一下你有一个推荐系统,它出现了问题,你会在尝试恢复死掉的服务时给客户一个默认的推荐吗?欢迎来到分布式系统的世界,在这里,一旦出现问题,情况可能会变得更糟。始终牢记这一点,并尝试为任何情况做好准备。
每个服务都是不同的,因此保持不同的存储库和构建环境
我们正在将一个应用程序分解成小部分,我们希望对其进行扩展和独立部署,因此将源代码保留在不同的存储库中是有意义的。拥有不同的构建环境和存储库意味着每个微服务都有自己的生命周期,并且可以在不影响应用程序的其他部分的情况下进行部署。
在接下来的章节中,我们将更深入地研究所有这些想法,以及如何使用不同的驱动开发来实现它们。
使用 PHP 构建微服务的优势
要理解为什么 PHP 是构建微服务的合适编程语言,我们需要稍微了解一下它的历史,它的起源,它试图解决的问题以及语言的演变。
PHP 的简短历史
1994 年,Rasmus Lerdorf 创建了我们可以说是 PHP 的第一个版本。他用 C 语言构建了一套小型的公共网关接口(CGI)来维护他的个人网页。这套脚本被称为个人主页工具,但更常被称为PHP 工具。
时间过去了,Rasmus 重写并扩展了这套脚本,使其能够与 Web 表单一起工作,并具有与数据库通信的能力。这个新的实现被称为个人主页/表单解释器或PHP/FI,并作为其他开发人员可以构建动态 Web 应用程序的框架。1995 年 6 月,源代码以个人主页工具(PHP 工具)版本 1.0的名义向公众开放,允许世界各地的开发人员使用它,修复错误并改进这套脚本。
PHP/FI 的最初想法并不是创建一种新的编程语言,Lerdorf 让它自然生长,导致了一些问题,比如函数名称或参数的不一致性。有时函数名称与 PHP 正在使用的低级库相同。
1995 年 10 月,Rasmus 发布了代码的新重写;这是第一个被认为是高级脚本接口的发布,PHP 开始成为今天的编程语言。
作为一种语言,PHP 的设计与 C 语言的结构非常相似,因此对于熟悉 C、Perl 或类似语言的开发人员来说更容易被接受。随着语言功能的增长,早期采用者的数量也开始增长。1998 年 5 月的 Netcraft 调查显示,几乎有 6 万个域名包含了 PHP 的头部信息(占当时互联网域名的 1%左右),这表明主机服务器已经安装了 PHP。
PHP 历史上的一个重要时刻是 1997 年以色列特拉维夫的 Andi Gutmans 和 Zeev Suraski 加入了这个项目。在这个时候,他们对解析器进行了另一次完全重写,并开始开发一种新的独立编程语言。这种新语言被命名为 PHP,意思是递归缩写---PHP(超文本预处理器)。
PHP 3 的官方发布是在 1998 年 6 月,包括了许多功能,使该语言适用于各种项目。其中一些功能包括成熟的多数据库接口,支持多种协议和 API,以及扩展语言本身的便利性。在所有功能中,最重要的是包括了面向对象编程和更强大、一致的语言语法。
Andi Gutmans 和 Zeev Suraski 成立了 Zend Technologies,并于 1999 年开始重写 PHP 的核心,创建了 Zend 引擎。Zend Technologies 成为了最重要的 PHP 公司,也是源代码的主要贡献者。
这只是一个开始,随着时间的推移,PHP 在功能、语言稳定性、成熟度和开发者采用方面不断增长。
PHP 的里程碑
现在我们有了一些历史背景,我们可以专注于 PHP 在这些年里取得的主要里程碑。每个版本都增加了语言的稳定性,并添加了越来越多的功能。
4.x 版本
PHP 4 是第一个包含 Zend 引擎的版本。这个引擎提高了 PHP 的平均性能。除了 Zend 引擎,PHP 4 还包括对更多 Web 服务器、HTTP 会话、输出缓冲和增强安全性的支持。
5.x 版本
PHP 5 于 2004 年 7 月 13 日发布,使用了 Zend Engine II,再次提高了语言的性能。这个版本包括了对面向对象(OO)编程的重要改进,使语言更加灵活和健壮。现在,用户可以选择以过程化或稳定的面向对象方式开发应用程序;他们可以兼得两者的优点。在这个版本中,还包括了连接到数据存储的最重要的扩展之一---PHP 数据对象(PDO)扩展。
随着 PHP 5 在 2008 年成为最稳定的版本,许多开源项目开始在他们的新代码中终止对 PHP 4 的支持。
6.x 版本
这个版本是 PHP 最著名的失败之一。这个主要版本的开发始于 2005 年,但在 2010 年因 Unicode 实现的困难而被放弃。并非所有的工作都被抛弃,大部分功能,其中包括命名空间,都被添加到了之前的版本中。顺便说一句,版本 6 通常与技术世界中的失败联系在一起:PHP 6、Perl 6,甚至 MySQL 6 都从未发布。
7.x 版本
这是一个期待已久的发布---一个统治所有的发布,一个具有前所未有的性能水平的发布。
2015 年 12 月 3 日,发布了最后一个 Zend 引擎的 7.0.0 版本。仅通过在您的机器上更改运行版本,性能提高了 70%,内存占用非常小。
语言也在不断发展,PHP 现在具有更好的 64 位支持和安全的随机数生成器。现在你可以创建匿名类或定义返回类型等其他重大改变。
这个版本成为了其他所谓的企业语言的严肃竞争对手。
优势
PHP 是你可以用来构建你的 Web 项目的最常用的编程语言之一。如果你还不确定 PHP 是否适合你的下一个应用程序,让我们告诉你使用 PHP 的主要优势:
-
庞大的社区:很容易参加全球各地的 ZendCon、PHP[world]或国际 PHP 大会等会议、活动和聚会。你不仅可以在活动和会议上与其他 PHP 开发者交流,还可以加入 IRC/Slack 频道或邮件列表提问和保持更新。你可以在官方网站上的
php.net/cal.php
找到附近地区的活动地点之一。 -
文档齐全:关于这种语言的主要信息可以在 PHP 网站上找到(
php.net/docs.php
)。这个参考指南涵盖了 PHP 的每个方面,从基本的控制流到高级主题。你想读一本书吗?没问题,你很容易在 15000 多本亚马逊参考书中找到合适的书。即使你需要更多信息,快速在谷歌上搜索就会给你超过 93 亿的结果。 -
稳定:PHP 项目经常发布版本,并同时维护几个主要版本,直到它们计划的生命周期结束(EOL)。通常,发布和 EOL 之间的时间足以转移到下一个主流版本。
-
易于部署:PHP 是最受欢迎的服务器端编程语言,在 2016 年 8 月的使用率达到 81.8%,所以市场也朝着同一个方向发展。很容易找到一个预安装了 PHP 并且可以直接使用的托管提供商,所以你只需要处理部署的问题。
有许多部署代码到生产环境的方法。例如,你可以在 Git 存储库中跟踪你的代码,然后稍后在服务器上进行 Git 拉取。你也可以通过 FTP 将文件推送到公共位置。你甚至可以使用 Jenkins、Docker、Terraform、Packer、Ansible 或其他工具构建一个持续集成/持续交付(CI/CD)系统。部署的复杂性将始终与项目的复杂性相匹配。
-
易于安装:PHP 在主要操作系统上都有预构建的软件包:它可以安装在 Linux、Mac、Windows 或 Unix 上。有时软件包可以在软件包系统内获得(例如 apt)。在其他情况下,你需要外部工具来安装它,比如 Homebrew。在最坏的情况下,你可以下载源代码并在你的机器上编译它。
-
开源:所有 PHP 源代码都可以在 GitHub 上找到,因此任何开发人员都可以轻松地深入了解一切是如何工作的。这种开放性允许程序员参与项目,扩展语言或修复错误。PHP 使用的许可证是伯克利软件发行(BSD)风格的许可证,没有与 GPL 相关的版权限制。
-
高运行速度(PHP 7):过去,PHP 的速度不够快,但这种情况在 PHP 7 中完全改变了。这个主要版本是基于PHP Next-Gen(PHPNG)项目,由 Zend Technologies 领导。PHPNG 的理念是加速 PHP 应用程序,他们做得非常好。仅通过更改 PHP 版本,性能提升可以在 25%到 70%之间变化。
-
大量可用的框架和库:PHP 生态系统在库和框架方面非常丰富。找到适合你的项目的库的常见方法是使用 PEAR 和 PECL。
关于可用的框架,你可以使用最好的之一,例如 Zend Framework、Symfony、Laravel、Phalcon、CodeIgniter,或者如果找不到符合你要求的框架,你可以从头开始构建一个。最好的部分是所有这些都是开源的,所以你可以随时扩展它们。
-
高开发速度:PHP 具有相对较小的学习曲线,这可以帮助你从头开始编码。此外,与 C、Perl 和其他语言的语法相似性可以让你很快理解一切是如何工作的。PHP 避免了等待编译器生成我们的构建所浪费的时间。
-
易于调试:你可能知道,PHP 是一种解释性语言。因此,在解决问题时,你有多种选择。简单的方法是放置一些
var_dump
或print_r
调用来查看代码的执行情况。为了更深入地查看执行情况,你可以将你的 IDE 链接到 Xdebug 或 Zend Debug,并开始跟踪你的应用程序。 -
易于测试:没有现代编程语言能在野外生存而没有适当的测试套件,所以你必须确保你的应用程序将继续按预期运行。不用担心,PHP 社区支持你,因为你有多种工具可进行任何类型的测试。例如,你可以使用 PHPUnit 进行你的测试驱动开发(TDD),或者如果你遵循行为驱动开发(BDD),可以使用 Behat。
-
你可以做任何事情:PHP 是一种现代编程语言,在现实世界中有多种应用。因此,只有天空是极限!你可以使用 GTK 扩展构建 GUI 应用程序,或者你可以在 phar 存档中创建一个包含所有所需文件的终端命令。这种语言没有限制,所以任何东西都可以构建。
缺点
像任何编程语言一样,PHP 也有一些缺点。一些最常见的缺点包括:安全问题,不适合大型应用程序和弱类型。PHP 最初是一组 CGI 的集合,并且随着多年的发展变得更加现代和健壮,因此与其他语言相比,它非常健壮和灵活。
无论如何,有经验的开发人员在构建应用程序时,如果使用最佳实践,就不会遇到这些缺点。
正如你已经看到的,PHP 的发展是巨大的。它拥有最活跃的社区之一,是为网络而生,并且具有创建任何类型项目所需的所有功能。毫无疑问,PHP 将是您表达最佳创意的正确语言。
总结
在本章中,我们探讨了微服务与单体架构和 SOA 的对比。我们了解了微服务架构的基本组件及其优缺点。在本章的后半部分,我们讨论了如何实施微服务架构以及在转向微服务之前需要考虑的先决条件。随后,我们介绍了 PHP 版本的历史以及它们的优缺点。
第二章:开发环境
在本章中,我们将开始构建基于微服务的应用程序,现在我们知道为什么微服务对于应用程序的开发是必要的,以及如果我们基于微服务构建应用程序可以享受到的优势。
我们将在本书中开发的应用程序(类似于 Pokemon GO)被称为“寻找秘密”。这个应用程序将像一个使用地理位置信息来寻找世界各地不同秘密的游戏。整个世界都隐藏着许多秘密,玩家们必须尽快找到它们。有 100 种不同类型的秘密,它们每天会在世界的不同地方生成和出现,因此玩家可以通过在不同地区四处走动并检查附近是否有任何类型的秘密来找到它们。
秘密将保存在应用程序钱包中,如果玩家发现他们已经拥有的秘密,他们将无法收集它。
玩家可以在附近与其他玩家进行决斗。决斗包括掷骰子以获得最高点数,输掉的玩家将向另一名玩家随机透露一个秘密。
在接下来的章节中,具体功能将更加详细,但在本章中,我们只需要了解应用程序的工作方式,以便对整个应用程序有一个总体概述,从而开始构建基于微服务的基本平台。
用于构建微服务基本平台的设计和架构
创建基于微服务的应用程序不像单片应用程序。因此,我们必须将功能划分为不同的服务。为此,根据其要求,按照适当的设计和结构每个微服务是很重要的。
设计负责将应用程序分成逻辑部分,并根据它们的现有关系对它们进行分组。架构负责定义哪些具体元素支持每个微服务,例如数据存储位置或服务之间的通信。
在整本书中,我们将遵循每个微服务的给定结构。在下图中,您将看到一个微服务的结构,其余的微服务类似;但是,有些部分是可选的:
我们所有微服务的请求都来自反向代理,因为这样可以平衡负载。此外,我们使用 NGINX 作为 PHP 构建的 API 的网关。为了减少负载并提高 PHP 和 NGINX 的性能,我们可以使用缓存层。
如果我们需要执行大型、消耗资源的任务,或者任务不需要在具体时间窗口内执行,我们的 API 可以使用队列系统。
如果我们需要存储一些数据,我们的 API 负责管理访问并将数据保存在我们的数据存储中。
注意
在本书中,我们将使用容器化,这是一种新的虚拟化方法,它会启动容器而不是完整的虚拟机。每个容器只会安装运行应用程序所需的最低资源和软件。
我们可以使用遥测(它是一个从容器获取统计数据的系统)和自动发现(它是一个帮助我们查看哪些容器正常工作的系统)来监督容器生态系统。
开始使用微服务的要求
现在您了解了为什么可以使用 PHP(特别是最新版本 7)来进行下一个项目,是时候谈谈您的微服务项目成功的其他要求了。
您可能已经意识到应用程序的可扩展性的重要性,但如何在预算内实现呢?答案是虚拟化。使用这项技术,您将浪费更少的资源。过去,同一硬件上一次只能执行一个操作系统(OS),但随着虚拟化的诞生,您可以同时运行多个操作系统。您的项目最大的成就将是,您将运行更多专用于您的微服务的服务器,但使用更少的硬件。
鉴于虚拟化和容器化提供的优势,如今在基于微服务的应用程序开发中使用容器已成为默认标准。有多个容器化项目,但最常用和受支持的是 Docker。因此,这是开始使用微服务的主要要求。
以下是我们将在 Docker 环境中使用的不同工具/软件:
-
PHP 7
-
数据存储:Percona,MySQL,PostgreSQL
-
反向代理:NGINX,Fabio,Traefik
-
依赖管理:composer
-
测试工具:PHPUnit,Behat,Selenium
-
版本控制:Git
在第五章中,微服务开发,我们将解释如何将它们添加到我们的项目中。
由于我们的主要要求是容器化套件,我们将在接下来的章节中解释如何安装和测试 Docker。
Docker 安装
Docker 可以从两个不同的渠道安装,各有优缺点:
-
稳定渠道:顾名思义,您从该渠道安装的所有内容都经过充分测试,您将获得 Docker 引擎的最新 GA 版本。这是最可靠的平台,因此适用于生产环境。通过该渠道,发布遵循版本计划,经过长时间的测试和 beta 时间,只是为了确保一切都应该按预期工作。
-
Beta channel: 如果您需要拥有最新功能,这是您的渠道。所有安装程序都配备了 Docker 引擎的实验版本,可能会出现错误,因此不建议用于生产环境。该渠道是 Docker beta 计划的延续,您可以提供反馈,没有版本计划,因此会有更频繁的发布。
我们将开发一个稳定的生产环境,所以现在您可以忘记 beta 渠道,因为您所需的一切都在稳定版本中。
Docker 诞生于 Linux,因此最佳实现是针对该操作系统完成的。对于其他操作系统,如 Windows 或 macOS,您有两个选择:本机实现和如果无法使用本机实现,则进行工具箱安装。
在 macOS 上安装 Docker
在 macOS 上,您有两种安装 Docker 的选择,取决于您的机器是否符合最低要求。对于相对较新的机器(OS X 10.10 Yosemite 及更高版本),您可以安装使用 Hyperkit 的本机实现,这是一个轻量级的 OS X 虚拟化解决方案,构建在Hypervisor.Framework之上。如果您有一台不符合最低要求的旧机器,您可以安装 Docker 工具箱。
Docker for Mac(别名,本机实现)与 Docker 工具箱
Docker 工具箱是 macOS 上 Docker 的第一个实现,它没有深度的操作系统集成。它使用 VirtualBox 来启动一个 Linux 虚拟机,Docker 将在其中运行。在虚拟机中运行所有容器会有很多问题,最明显的是性能不佳。但是,如果您的机器不符合本机实现的要求,这是理想的选择。
Mac 上的 Docker 是一个本地 Mac 应用程序,具有本地用户界面和自动更新功能,并且与 OS X 本地虚拟化(Hypervisor.Framework)、网络和文件系统深度集成。这个版本比 Docker Toolbox 更快、更易于使用,更可靠。
最低要求
-
Mac 必须是 2010 年或更新的型号,具有英特尔硬件对内存管理单元(MMU)虚拟化的支持;也就是扩展页表(EPT)。
-
OS X 10.10.3 Yosemite 或更新版本
-
至少 4GB 的 RAM
-
在安装 Docker for Mac 之前,不能安装 VirtualBox 4.3.30 之前的版本(它与 Mac 上的 Docker 不兼容)
Mac 上的 Docker 安装过程
如果你的机器符合要求,你可以从官方页面下载 Mac 上的 Docker,即www.docker.com/products/docker
。
一旦你在你的机器上下载了镜像,你可以执行以下步骤:
- 双击下载的镜像(名为
Docker.dmg
)以打开安装程序。一旦镜像被挂载,你需要将 Docker 应用程序拖放到Applications
文件夹中:
Docker.app
在安装过程中可能会要求你输入密码,以特权模式安装和设置网络组件。
-
安装完成后,Docker 将出现在你的 Launchpad 和
Applications
文件夹中。执行该应用程序以启动 Docker。一旦 Docker 启动,你将在工具栏中看到鲸鱼图标。这将是你快速访问设置的方式。 -
点击工具栏上的鲸鱼图标以获取首选项...和其他选项:
-
点击关于 Docker以查看你是否在运行最新版本。
在 Linux 上安装 Docker
Docker 生态系统是在 Linux 上开发的,因此在这个操作系统上的安装过程更容易。在接下来的页面中,我们将只涵盖在Community ENTerprise Operating System(CentOS)/Red Hat Enterprise Linux(RHEL)(它们使用 yum 作为包管理器)和 Ubuntu(使用 apt 作为包管理器)上的安装。
CentOS/RHEL
Docker 可以在 CentOS 7 上执行,也可以在任何其他二进制兼容的 EL7 发行版上执行,但 Docker 在这些兼容的发行版上没有经过测试或支持。
最低要求
安装和执行 Docker 的最低要求是拥有 64 位操作系统和 3.10 或更高版本的内核。如果你需要知道你当前的版本,你可以打开终端并执行以下命令:
**$ uname -r
3.10.0-229.el7.x86_64**
请注意,建议你的操作系统保持最新,以避免任何潜在的内核错误。
使用 yum 安装 Docker
首先,你需要有一个具有 root 权限的用户;你可以以这个用户登录你的机器,或者在你选择的终端上使用sudo
命令。在接下来的步骤中,我们假设你正在使用一个 root 或特权用户(如果你没有使用 root 用户,请在命令中添加 sudo)。
首先确保你所有现有的包都是最新的:
**yum update**
现在你的机器已经有了最新的可用包,你需要添加官方 Docker yum
存储库:
**# tee /etc/yum.repos.d/docker.repo <<-'EOF'
[dockerrepo]
name=Docker Repository
baseurl=https://yum.dockerproject.org/repo/main/centos/7/
enabled=1
gpgcheck=1
gpgkey=https://yum.dockerproject.org/gpg
EOF**
在将 yum 存储库添加到你的 CentOS/RHEL 之后,你可以使用以下命令轻松安装 Docker 包:
**yum install docker-engine**
你可以使用systemctl
命令将 Docker 服务添加到你的操作系统的启动中(这一步是可选的):
**systemctl enable docker.service**
同样的systemctl
命令可以用来启动服务:
**systemctl start docker**
现在你已经安装并运行了所有东西,所以你可以开始测试和玩 Docker 了。
安装后设置 - 创建 Docker 组
Docker 作为绑定到 Unix 套接字的守护程序执行。此套接字由 root 拥有,因此其他用户访问它的唯一方式是使用sudo
命令。每次使用任何 Docker 命令都要使用sudo
命令可能很痛苦,但您可以创建一个名为docker
的 Unix 组,并将用户分配给该组。通过进行这个小改变,Docker 守护程序将启动并将 Unix 套接字的所有权分配给这个新组。
以下是创建 Docker 组的命令:
**groupadd docker**
**usermod -aG docker my_username**
执行完这些命令后,您需要注销并重新登录以刷新权限。
在 Ubuntu 上安装 Docker
Ubuntu 得到官方支持,主要建议使用 LTS(始终建议使用最新版本):
-
Ubuntu Xenial 16.04(LTS)
-
Ubuntu Trusty 14.04(LTS)
-
Ubuntu Precise 12.04(LTS)
就像之前的 Linux 安装步骤一样,我们假设您是使用 root 或特权用户来安装和设置 Docker。
最低要求
与其他 Linux 发行版一样,需要 64 位版本,并且您的内核版本至少需要 3.10。较旧的内核版本存在已知的错误,会导致数据丢失和频繁的内核崩溃。
要检查当前的内核版本,请打开您喜欢的终端并运行:
**$ uname -r
3.11.0-15-generic**
使用 apt 安装 Docker
首先确保您的 apt 源指向 Docker 存储库,特别是如果您之前是通过 apt 安装 Docker。另外,更新您的系统:
**apt-get update**
现在您的系统已经更新,是时候安装一些必需的软件包和新的 GPG 密钥了:
**apt-get install apt-transport-https ca-certificates
apt-key adv --keyserver hkp://p80.pool.sks-keyservers.net:80 --recv-keys \ 58118E89F3A912897C070ADBF76221572C52609D**
在 Ubuntu 上,很容易添加官方 Docker 存储库;您只需要在您喜欢的编辑器中创建(或编辑)/etc/apt/sources.list.d/docker.list
文件。
如果您的旧存储库中有上述行,请删除所有内容并添加以下条目之一。确保与您当前的 Ubuntu 版本匹配:
**Ubuntu Precise 12.04 (LTS):**
deb https://apt.dockerproject.org/repo ubuntu-precise main
**Ubuntu Trusty 14.04 (LTS):**
deb https://apt.dockerproject.org/repo ubuntu-trusty main
**Ubuntu Xenial 16.04 (LTS):**
deb https://apt.dockerproject.org/repo ubuntu-xenial main
保存文件后,您需要更新apt
软件包索引:
**apt-get update**
如果您的 Ubuntu 上有以前的 Docker 存储库,则需要清除旧存储库:
**apt-get purge lxc-docker**
在 Trusty 和 Xenial 上,建议安装linux-image-extra-*
内核包,允许您使用 AFUS 存储驱动程序。要安装它们,请运行以下命令:
**apt-get update && apt-get install linux-image-extra-$(uname -r) linux-image-extra-virtual**
在 Precise 上,Docker 需要 3.13 内核版本,因此请确保您有正确的内核;如果您的版本较旧,则必须升级。
此时,您的机器将准备好安装 Docker。可以使用单个命令,如yum
:
**apt-get install docker-engine**
现在您已经安装并运行了所有内容,可以开始使用和测试 Docker。
Ubuntu 上的常见问题
如果您在使用 Docker 时看到与交换限制相关的错误,则需要在系统上启用内存和交换。可以通过 GNU GRUB 按照给定的步骤完成:
-
编辑
/etc/default/grub
文件。 -
将
GRUB_CMDLINE_LINUX
设置如下:
GRUB_CMDLINE_LINUX="cgroup_enable=memory swapaccount=1"
- 更新 grub:
**update-grub**
- 重新启动系统。
UFW 转发
Ubuntu 配备了简化防火墙(UFW),如果在运行 Docker 的同一主机上启用了它,您需要进行一些调整,因为默认情况下,UFW 将丢弃任何转发流量。此外,UFW 将拒绝任何传入流量,使得无法从不同主机访问您的容器。在干净的安装中,Docker 在未启用 TLS 的情况下运行时,Docker 默认端口为 2376。让我们配置 UFW!
首先,您可以检查 UFW 是否已安装并启用:
**ufw status**
现在您确定 UFW 已安装并运行,可以使用您喜欢的编辑器编辑config
文件/etc/default/ufw
,并设置DEFAULT_FORWARD_POLICY
:
vi /etc/default/ufw
DEFAULT_FORWARD_POLICY="ACCEPT"
现在您可以保存并关闭config
文件,在重新启动 UFW 后,您的更改将生效:
**ufw reload**
允许传入连接到 Docker 端口可以使用ufw
命令完成:
**ufw allow 2375/tcp**
DNS 服务器
Ubuntu 及其衍生产品在/etc/resolv.conf
文件中使用 127.0.0.1 作为默认名称服务器,因此当您使用此配置启动容器时,您将看到警告,因为 Docker 无法使用本地 DNS 名称服务器。
如果要避免这些警告,您需要指定一个 DNS 服务器供 Docker 使用,或者在NetworkManager
中禁用dnsmasq
。请注意,禁用dnsmasq
会使 DNS 解析变慢一点。
要指定 DNS 服务器,您可以使用您喜欢的编辑器打开/etc/default/docker
文件,并添加以下设置:
DOCKER_OPTS="--dns 8.8.8.8"
将8.8.8.8
替换为您的本地 DNS 服务器。如果您有多个 DNS 服务器,可以添加多个--dns
记录,用空格分隔。考虑以下示例:
DOCKER_OPTS="--dns 8.8.8.8 --dns 9.9.9.9"
一旦您保存了更改,您需要重新启动 Docker 守护程序:
**service docker restart**
如果您没有本地 DNS 服务器,并且想要禁用dnsmasq
,请使用您的编辑器打开/etc/NetworkManager/NetworkManager.conf
文件,并注释掉以下行:
dns=dnsmasq
保存更改并重新启动 NetworkManager 和 Docker:
**restart network-manager**
**restart docker**
安装后设置-创建 Docker 组
Docker 作为绑定到 Unix 套接字的守护程序执行。此套接字由 root 拥有,因此其他用户访问它的唯一方法是使用sudo
命令。每次使用任何 docker 命令都使用sudo
命令可能会很痛苦,但是您可以创建一个名为docker
的 Unix 组,并将用户分配给此组。通过进行这个小改变,Docker 守护程序将启动并将 Unix 套接字的所有权分配给这个新组。
执行以下步骤创建一个 Docker 组:
**groupadd docker
usermod -aG docker my_username**
完成这些步骤后,您需要注销并重新登录以刷新权限。
启动时启动 Docker
从 Ubuntu 15.04 开始,使用systemd
系统作为其引导和服务管理器,而对于 14.10 版本和之前的版本,则使用upstart
系统。
对于 15.04 及更高版本的系统,您可以通过运行给定的命令将 Docker 守护程序配置为在启动时启动:
**systemctl enable docker**
对于使用旧版本的情况,安装方法会自动配置 upstart 以在启动时启动 Docker 守护程序。
在 Windows 上安装 Docker
Docker 团队为将他们的整个生态系统带到任何操作系统做出了巨大的努力,他们没有忘记 Windows。与 macOS 一样,您在此操作系统上安装 Docker 有两个选项:工具箱和更本地的选项。
最低要求
Windows 版的 Docker 需要 64 位的 Windows 10 专业版、企业版和教育版(1511 年 11 月更新,版本 10586 或更高),并且必须启用Hyper-V
包。
如果您的计算机运行的是不同版本,您可以安装需要 64 位操作系统的工具箱,至少在 Windows 7 上运行,并且在计算机上启用了虚拟化。如您所见,它的要求更轻。
由于 Docker for Windows 至少需要专业/企业/教育版本,而大多数计算机都是使用不同版本出售的,我们将解释如何使用工具箱安装 Docker。
安装 Docker 工具
Docker 工具使用 VirtualBox 来启动运行 Docker 引擎的虚拟机。安装包可以从https://www.docker.com/products/docker-toolbox
下载。
一旦您获得安装程序,您只需要双击下载的可执行文件即可开始安装过程。
安装程序显示的第一个窗口允许您将调试信息发送到 Docker,以改善生态系统。允许 Docker 引擎从您的开发环境发送调试信息可以帮助社区找到错误并改善生态系统。建议在开发环境中至少启用此选项:
就像任何其他 Windows 安装程序一样,您可以选择安装的位置。在大多数情况下,默认设置对于您的开发环境来说是可以的。
默认情况下,安装程序将向您的计算机添加所有必需的软件包和一些额外的软件。在安装的这一步,您可以清除一些不必要的软件。以下是一些可选软件包:
-
Windows 的 Docker compose:在我们看来,这是必不可少的,因为我们将在我们的书中使用这个软件包。
-
Windows 的 Kitematic:这个应用程序是一个 GUI,可以轻松管理您的容器。如果您不习惯使用命令行,可以安装这个软件包。
-
Windows 的 Git:这是另一个必须安装的软件包;我们将使用 Git 来存储和管理我们的项目。
选择要安装的软件包后,是时候进行一些额外的任务了。默认选择的任务对于您的开发环境来说是合适的。
现在,您只需要在安装开始之前确认您在之前步骤中所做的所有设置。
安装可能需要几分钟才能完成,所以请耐心等待。
在安装过程中,您可能会收到有关安装 Oracle 设备的警报。这是因为工具正在使用 VirtualBox 来启动一个虚拟机来运行 Docker 引擎。安装此设备以避免将来的麻烦:
恭喜!您已经在 Windows 机器上安装了 Docker。别浪费时间,开始测试和玩弄您的 Docker 生态系统。
如何检查您的 Docker 引擎、compose 和 machine 版本
现在您已经安装了 Docker,您只需要打开您喜欢的终端,并输入以下命令:
**$ docker --version && docker-compose --version && docker-machine --version**
快速检查 Docker 安装的示例
您应该已经运行 Docker 并执行以下命令:
**$ docker run -d -p 8080:80 --name webserver-test nginx**
上述命令将执行以下操作:
-
使用
-d
在后台执行容器 -
使用
-p 8080:80
将您机器的 8080 端口映射到容器的 80 端口 -
使用
--name webserver-test
为您的容器分配一个名称 -
获取 NGINX Docker 镜像并使容器运行此镜像。
现在,打开您喜欢的浏览器,导航到http://localhost:8080
,您将看到一个默认的 NGINX 页面。
常见的管理任务
通过在终端上执行docker ps
,您可以查看正在运行的容器。
上述命令让我们更深入地了解了在您的机器上运行的容器,它们正在使用的镜像,它们的创建时间,状态以及端口映射或分配的名称。
玩完容器后,是时候停止它了。执行docker stop webserver-test
,容器将结束它的生命周期。
糟糕!您需要再次使用相同的容器。没问题,因为简单的docker start webserver-test
将再次为您启动容器。
现在,是时候停止并删除容器了,因为您不再需要它。在您的终端上执行docker rm -f webserver-test
就可以了。请注意,此命令将删除容器,但不会删除我们使用过的下载的镜像。对于最后一步,您可以执行docker rmi nginx
。
版本控制 - Git 与 SVN
版本控制是一种工具,它可以帮助您回顾以前的源代码版本,以便检查和处理它们;它与使用的语言或技术无关,并且可以在所有以纯文本开发的软件中使用版本控制。
我们可以将版本控制工具分类为以下几类:
-
集中式版本:控制系统需要一个集中的服务器来工作,所有开发人员都需要连接到它,以便从中同步和下载更改。
-
分布式版本:控制系统不是集中的;换句话说,每个开发人员在自己的机器上拥有整个管理版本控制系统,因此可以在本地工作,然后与公共服务器或每个开发人员同步。分布式版本控制系统(DVCS)更快,因为它们在集中或共享服务器上需要更少的更改。
Subversion(SVN)是一个集中式版本控制系统,因此一些开发人员认为这是尊重整个项目工作的最佳方式,因此开发人员只需在一个地方编写和读取访问控制器。
整个代码都托管在一个地方,因此可能认为这样更容易理解 SVN 而不是 Git。事实是 SVN 命令行更简单,而且有更多的 GUI 可用于 SVN。原因很明显:SVN 自 2000 年以来就存在,而 Git 是 5 年后才出现的。
SVN 的另一个优点是版本编号系统更清晰;它使用顺序编号系统(1,2,3,4...),而 Git 使用更难以阅读和理解的 SHA-1 代码。
最后,使用 SVN 可以获得一个子目录来处理它,而无需拥有整个项目。对于小项目来说,这不是问题,但对于大项目来说可能会很困难。
Git
在本书中,我们将使用 Git 进行版本控制。我们做出这个决定是因为 Git 绝对更快,更轻量级(占用的磁盘空间比 SVN 少 30 倍)。此外,Git 已成为 Web 开发版本控制的标准,我们的目标是基于 PHP 创建一个基于微服务的应用程序,因此 Git 是该项目的一个很好的解决方案。
Git 的优点如下:
-
分支比 SVN 更轻量级
-
Git 比 SVN 快得多
-
Git 从一开始就是一个分布式版本控制系统,因此开发人员对其本地拥有完全控制权
-
Git 在分支和合并方面提供更好的审计
在随后的章节中,我们将在我们的项目中使用 Git 命令,解释每一个并给出示例,但在那之前,让我们先看一下基本的命令:
如何创建一个新存储库:创建一个新文件夹,打开它,并在其中执行 Git 来创建一个新的 Git 存储库。
如何检出现有存储库:通过执行 Git clone /path/to/repository
从存储库创建一个本地副本。如果您正在使用远程服务器(托管集中式服务器将在以下行中解释),则执行 Git clone username@host:/path/to/repository
。
要理解 Git 的工作流程,有必要知道有三种不同的树:
-
工作目录:包含您的项目文件
-
INDEX:这起到中间区域的作用;文件将在此处,直到它们被提交
-
HEAD:指向最后一次提交
将文件添加到索引并提交它们是简单的任务。在项目中使用文件并对其进行更改后,您必须将它们添加到索引:
-
如何将文件添加到索引:建议在将文件添加到索引之前检查文件。您可以通过执行
git diff <file>
来做到这一点。它会显示您添加和删除的行,以及有关您修改的文件的一些更有趣的信息。一旦您对所做的更改满意,可以执行git add <filename>
来添加特定文件,或者将 Git 添加到您修改过的所有文件中。 -
如何将文件添加到 HEAD:一旦您在索引中包含了所有必要的文件,您必须提交它们。您可以通过执行
git commit -m "提交消息"
来做到这一点。现在文件已经包含在您的本地副本的 HEAD 中,但它们仍未在远程存储库中。 -
如何将更改发送到远程存储库:要将包含在您的本地副本的 HEAD 中的更改发送到远程存储库,执行
git push origin <branch name>;
您可以选择要包含更改的分支,例如 master。如果您没有克隆现有存储库,并且想要将本地存储库连接到远程存储库,执行git remote add origin <server>
。
分支用于开发隔离的功能,并且可以在将来与主分支合并。创建新存储库时的默认分支称为 master。工作流程概述将如下所示:
-
如何创建新分支:一旦您在要创建新分支的分支中,执行
git checkout -b new_feature
-
如何更改分支:您可以通过执行
git checkout <branch name>
来浏览分支。 -
如何删除分支:您可以通过执行
git branch -d new_feature
来删除分支 -
如何使您的分支对所有人可用:在将您的分支上传到远程存储库之前,分支将不会对其他开发人员可用,执行
git push origin <branch>
如果您想要将本地副本更新为远程存储库上的更改,您可以执行git fetch
来检查是否有任何新的更新,然后执行git pull
来获取该更新。
要将您的活动分支与不同的分支合并,执行git merge <branch>
,Git 将尝试融合两个分支,但有时,如果两个或更多的开发人员更改了相同的文件,可能会出现冲突,您需要在合并之前手动解决这些冲突,然后再次将修改后的文件放入 INDEX 中。
如果失败,也许您想要丢弃本地更改并再次从存储库获取更改。您可以通过执行git checkout -- <filename>
来做到这一点。如果您想要丢弃所有本地更改和提交,执行git fetch origin
和git reset --hard origin/master
。
托管
当我们在团队中工作时,也许我们希望有一个带有集中式服务器的公共存储库。请记住,Git 是一个分布式版本控制系统,不需要使用一个地方来集中存储代码,但是如果出于不同的原因想要使用它,我们将看看两个著名的地方。
托管为您提供了使用 Web 界面更好地管理存储库的方式。
GitHub
GitHub 是大多数开发人员选择托管代码的地方。它基于 Git,像 Twitter 和 Facebook 这样的公司使用这项服务来发布他们的开源项目。GitHub 在短短几年内成为最著名的源代码托管地,并且目前,许多公司在技术面试之前要求候选人提供他们的 GitHub 存储库。
这个托管对所有开发人员免费;他们可以创建无限的项目,有无限的合作者,唯一的条件是项目需要是开源和公共的。如果您想要一个私有项目,您将不得不付费。
在 GitHub 上将您的项目公开是向世界展示您的项目并利用庞大的 GitHub 社区的好机会。可以寻求帮助,因为有许多注册和活跃的开发人员。
您可以访问官方网站github.com/
。
BitBucket
BitBucket 是托管项目的另一个选择。它使用 Git,但您也可以使用 Mercurial。界面与 GitHub 非常相似。BitBucket 的一个巨大优势是 Atlassian 公司使其成为可能。它在其托管中包含许多开发人员的功能,例如集成其他 Atlassian 工具的可能性或一个小型的持续交付工具,允许您构建,测试和部署应用程序。
这个地方是免费的,无论您想要的项目是公共的还是私有的。唯一的限制是每个项目只允许五个合作者;如果您需要更多人参与您的项目,您将不得不付费。
官方网站:bitbucket.org/
。
版本控制策略
当您开发应用程序时,保持代码整洁是很重要的,但当您与其他开发人员一起工作时更重要。在本节中,我们将为您介绍一些最常见的版本控制策略,您可以在项目中使用。
集中式工作
这种策略对于以前使用 SVN(老派)或类似版本控制的开发人员来说是最常见的。与 Subversion 一样,项目托管在一个具有唯一入口点的中央存储库中。除了主分支(在 SVN 中为主干)之外,这种策略不需要更多的分支。
开发人员在本地机器上克隆整个项目,对项目进行工作,然后提交更改。当他们想要发布更改时,他们执行推送。
功能分支工作流
这是从集中式工作的下一步。它也与中央存储库一起工作,但开发人员在其副本中创建一个本地功能分支,并且这个分支也会发布到中央存储库,因此所有开发人员都有机会参与该功能。分支将具有描述性名称或问题编号。
在这种策略中,主分支永远不包含错误,因此这对于持续集成是一个很大的改进。此外,为每个功能设置特定的分支是一个很好的封装,以免干扰主代码库。
Gitflow 工作流
Gitflow 工作流不会添加比功能分支工作流更多的新概念。它只为每个分支分配不同的角色。Gitflow 工作流也与中央存储库一起工作,开发人员在其中创建分支,就像功能分支工作流一样。然而,这些分支有特定的功能,例如开发、发布或功能。因此,功能分支将与特定发布合并,然后与主分支合并。这样,同一个项目可以有不同的发布版本。
这种策略适用于大型项目或需要发布的项目。
分支工作流
最后一种策略与本章中我们看过的其他策略非常不同。与从集中服务器克隆副本并在其上工作不同,这种策略为每个开发人员提供了一个分支。这意味着每个开发人员都有项目的两个副本:一个私有副本和一个服务器端副本。
一旦开发人员做出他们想要的更改,它们将被发送给项目维护者进行审查和检查,以确保它们不会破坏项目,然后它们将被合并到主存储库中。
这种策略适用于开源项目,因此开发人员不能破坏当前项目。
语义化版本控制
在我们的微服务或 API 中拥有一个版本控制系统非常重要。这使得消费者和您自己都能够拥有一个一致的版本控制系统,以便每个人都能知道发布或功能的重要性。
将版本号设置为 MAJOR.MINOR.PATCH
:
-
MAJOR
在不兼容的 API 更改时会增加,因此开发人员需要废弃当前的 API 版本并使用新版本。 -
MINOR
在添加新功能并且与当前代码兼容时会增加。因此,开发人员不需要改变整个 API 来更新当前版本。 -
PATCH
在对当前版本进行新的错误修复时会增加。
这是语义化版本控制的摘要,但您可以在 semver.org/
找到更多信息。
为微服务设置开发环境
使用 Docker 及其容器生态系统的最大好处之一是您不需要在计算机上安装任何其他东西。例如,如果您需要一个 MySQL 数据库,您不需要在本地开发环境上安装任何东西;只需轻松创建一个包含所需版本的容器并开始使用它。
这种开发方式更加灵活,因此我们将在整本书中都使用 Docker 容器。在本节中,我们将学习如何构建一个基本的 Docker 环境;这将是我们的基础,并且我们将在随后的章节中改进和调整这个基础以适应我们的每个微服务。
为了简化我们项目的文件夹结构,我们将在开发机器上有一些根文件夹:
-
Docker
:此文件夹将包含所有 Docker 环境。 -
Source
:这个文件夹将包含我们每个微服务的源代码!为微服务设置开发环境
请注意,这个结构是灵活的,可以根据您的具体要求进行更改和调整,而不会出现任何问题。
所有所需的文件都可以在我们的 GitHub 存储库上找到,位于chapter-02
标签的主分支上,网址为github.com/php-microservices/docker
。
让我们深入了解 Docker 设置。打开您的 docker 文件夹,并创建一个名为docker-compose.yml
的文件,内容如下:
version: '2'
services:
这两行表示我们正在使用 Docker compose 的最新语法,并且它们定义了我们每次执行docker-compose up
时将会启动的服务列表。我们所有的服务都将在services
声明之后添加。
自动发现服务
自动发现是一种机制,我们不指定每个微服务的端点。我们的每个服务都使用一个共享注册表,它们在其中声明自己是可用的。当一个微服务需要知道另一个微服务的位置时,它可以查询我们的自动发现注册表以了解所需的端点。
对于我们的应用程序,我们将使用自动发现机制来确保我们的微服务可以轻松扩展,如果一个节点不健康,我们将停止向其发送请求。我们选择使用 Consul(由 HashiCorp 提供),这是一个非常小的应用程序,我们可以将其添加到我们的项目中。我们的 Consul 容器的主要作用是保持一切井然有序,保持可用和健康服务的列表。
让我们通过打开您喜欢的 IDE/editor 的docker-compose.yml
文件并在services:
行之后添加下一段代码来开始项目:
version: '2'
services:
autodiscovery:
build: ./autodiscovery/
mem_limit: 128m
expose:
- 53
- 8300
- 8301
- 8302
- 8400
- 8500
ports:
- 8500:8500
dns:
- 127.0.0.1
在 Docker compose 文件中,语法非常容易理解,并且始终遵循相同的流程。第一行定义了一个容器类型(对于开发人员来说,它就像一个类名);在我们的情况下,它是autodiscovery
,在这个容器类型内部,我们可以指定几个选项,以适应我们的要求。
通过build: ./autodiscovery/
,我们告诉 Docker 在哪里可以找到一个描述我们容器详细信息的 Dockerfile。
mem_limit: 128m
这句话将限制autodiscovery
类型的任何容器的内存消耗不超过 128 Mb。请注意,这个指令是可选的。
每个容器都需要打开不同的端口,默认情况下,当您启动一个容器时,这些端口都是关闭的。因此,您需要指定每个容器要打开哪些端口。例如,一个带有 Web 服务器的容器将需要打开端口 80
,但对于运行 MySQL 的容器,所需的端口可能是3306
。在我们的情况下,我们为我们的每个autodiscovery
容器打开了端口53
、8300
、8301
、8302
、8400
和8500
。
如果您尝试在打开的端口之一上访问容器,它将无法工作。容器生态系统位于一个单独的网络中,只有在您的环境和 Docker 网络之间创建一个桥接时,您才能访问它。我们的autodiscovery
容器运行 Consul,并且在端口8500
上有一个很好的 Web UI。我们希望能够使用这个 UI;因此,当我们使用ports
时,我们将我们本地的8500
端口映射到容器的8500
端口。
现在,是时候在与您的docker-compose.yml
文件相同的路径下创建一个名为autodiscovery
的新文件夹了。在这个新文件夹中,放置一个名为Dockerfile
的文件,内容如下:
FROM consul:v0.7.0
Dockerfile
中的这个小句子表明我们正在使用一个带有标签v0.7.0
的 Docker consul
镜像。这个镜像将从官方 Docker hub 获取,这是一个容器镜像的存储库。
此时,执行$ docker-compose up
将启动一个 Consul 机器,请试一试。由于我们没有指定-d
选项,Docker 引擎将把所有日志输出到您的终端。您可以通过简单的CTRL+C来停止您的容器。当您添加-d
选项时,Docker compose 将作为守护进程运行并返回提示符;您可以执行$ docker-compose stop
来停止容器。
微服务基础核心 - NGINX 和 PHP-FPM
PHP-FPM 是在我们的 Web 服务器中执行 PHP 的一种替代方法。使用 PHP-FPM 的主要好处是其小的内存占用和在高负载下的高性能。您可以找到的最好的 Web 服务器来运行您的 PHP-FPM 是 NGINX,这是一个非常轻量级的 Web 服务器和反向代理,用于最重要的项目。
由于我们的应用程序将使用自动发现模式,我们需要一种简单的方法来处理服务的注册、注销和健康检查。您可以使用的最简单和最快的应用程序之一是 ContainerPilot,这是由 Joyent 创建的一个小型微服务编排应用程序,可以与您喜欢的容器调度程序一起使用,在我们的案例中是 Docker compose。这个小应用程序作为 PID 1 执行,并在容器内部运行我们想要运行的应用程序。
我们将使用 ContainerPilot,因为它可以减轻开发人员处理自动发现的负担,所以我们需要在我们将要使用的每个容器上都安装最新版本。
让我们开始定义我们的基础php-fpm
容器。打开docker-compose.yml
,为php-fpm
添加一个新的服务:
microservice_base_fpm:
build: ./microservices/base/php-fpm/
links:
- autodiscovery
expose:
- 9000
environment:
- BACKEND=microservice_base_nginx
- CONSUL=autodiscovery
在上述代码中,我们正在定义一个新的服务,一个有趣的属性是链接。这个属性定义了我们的服务可以看到或连接哪些其他容器。在我们的例子中,我们希望将这种类型的容器链接到任何autodiscovery
容器。如果没有这个明确的定义,我们的fpm
容器将看不到autodiscovery
服务。
现在,在您的 IDE/编辑器中创建microservices/base/php-fpm/Dockerfile
文件,内容如下:
FROM php:7-fpm
RUN apt-get update && apt-get -y install \
git g++ libcurl4-gnutls-dev libicu-dev libmcrypt-dev
libpq-dev libxml2-dev
unzip zlib1g-dev \
&& git clone -b php7
https://github.com/phpredis/phpredis.git
/usr/src/php/ext/redis \
&& docker-php-ext-install curl intl json mbstring
mcrypt pdo pdo_pgsql
redis xml \
&& apt-get autoremove && apt-get autoclean \
&& rm -rf /var/lib/apt/lists/*
RUN echo 'date.timezone="Europe/Madrid"' >>
/usr/local/etc/php/conf.d/date.ini
RUN echo 'session.save_path = "/tmp"' >>
/usr/local/etc/php/conf.d/session.ini
ENV CONSUL_TEMPLATE_VERSION 0.16.0
ENV CONSUL_TEMPLATE_SHA1
064b0b492bb7ca3663811d297436a4bbf3226de706d2b76adade7021cd22e156
RUN curl --retry 7 -Lso /tmp/consul-template.zip \
"https://releases.hashicorp.com/
consul-template/${CONSUL_TEMPLATE_VERSION}/
consul-template_${CONSUL_TEMPLATE_VERSION}_linux_amd64.zip" \
&& echo "${CONSUL_TEMPLATE_SHA1} /tmp/consul-template.zip"
| sha256sum -c \
&& unzip /tmp/consul-template.zip -d /usr/local/bin \
&& rm /tmp/consul-template.zip
ENV CONTAINERPILOT_VERSION 2.4.3
ENV CONTAINERPILOT_SHA1 2c469a0e79a7ac801f1c032c2515dd0278134790
ENV CONTAINERPILOT file:///etc/containerpilot.json
RUN curl --retry 7 -Lso /tmp/containerpilot.tar.gz \
"https://github.com/joyent/containerpilot/releases/download/
${CONTAINERPILOT_VERSION}/containerpilot-
${CONTAINERPILOT_VERSION}.tar.gz"
\
&& echo "${CONTAINERPILOT_SHA1} /tmp/containerpilot.tar.gz"
| sha1sum -c \
&& tar zxf /tmp/containerpilot.tar.gz -C /usr/local/bin \
&& rm /tmp/containerpilot.tar.gz
COPY config/ /etc
COPY scripts/ /usr/local/bin
RUN chmod +x /usr/local/bin/reload.sh
CMD [ "/usr/local/bin/containerpilot", "php-fpm", "--nodaemonize"]
在这个文件中,我们告诉 Docker 如何创建我们的php-fpm
容器。第一行声明了我们想要用作容器基础的官方版本,这里是 php7 fpm。一旦镜像下载完成,第一个RUN
行将添加我们将使用的所有额外的PHP
包。
这两个RUN
语句将添加定制的 PHP 配置;请随时根据您的需求调整这些行。
一旦所有的 PHP 任务完成,就是时候在容器上安装一个小应用程序来帮助我们处理模板--consul-template
。这个应用程序用于使用我们在 Consul 服务上存储的信息动态构建配置模板。
正如我们之前所说,我们正在使用 ContainerPilot。因此,在consul-template
安装之后,我们正在告诉 Docker 如何安装这个应用程序。
此时,Docker 完成了安装所有所需的软件包,并复制了一些 ContainerPilot 所需的配置和 shell 脚本。
最后一行将 ContainerPilot 作为 PID 1 启动,并分叉php-fpm
。
现在,让我们解释 ContainerPilot 需要的配置文件。打开您的 IDE/编辑器,并创建microservices/base/php-fpm/config/containerpilot.json
文件,内容如下:
{
"consul": "{{ if .CONSUL_AGENT }}localhost{{ else }}{{ .CONSUL }}
{{ end }}:8500",
"preStart": "/usr/local/bin/reload.sh preStart",
"logging": {"level": "DEBUG"},
"services": [
{
"name": "microservice_base_fpm",
"port": 80,
"health": "/usr/local/sbin/php-fpm -t",
"poll": 10,
"ttl": 25,
"interfaces": ["eth1", "eth0"]
}
],
"backends": [
{
"name": "{{ .BACKEND }}",
"poll": 7,
"onChange": "/usr/local/bin/reload.sh"
}
],
"coprocesses": [{{ if .CONSUL_AGENT }}
{
"command": ["/usr/local/bin/consul", "agent",
"-data-dir=/var/lib/consul",
"-config-dir=/etc/consul",
"-rejoin",
"-retry-join", "{{ .CONSUL }}",
"-retry-max", "10",
"-retry-interval", "10s"],
"restarts": "unlimited"
}
{{ end }}]
}
这个 JSON 配置文件非常容易理解。首先,它定义了我们可以在哪里找到我们的 Consul 容器,以及我们希望在 ContainerPilot 的 preStart 事件上运行哪个命令。在services
中,您可以定义所有当前容器正在运行的服务。在backends
中,您可以定义所有您正在监听更改的服务。在我们的案例中,我们正在监听名为microservice_base_nginx
的服务的更改(BACKEND
变量在docker-compose.yml
中定义)。如果 Consul 上这些服务发生了变化,我们将在容器中执行onChange
命令。
有关 ContainerPilot 的更多信息,您可以访问官方页面,即www.joyent.com/containerpilot
。
是时候创建microservices/base/php-fpm/scripts/reload.sh
文件,内容如下:
#!/bin/bash
SERVICE_NAME=${SERVICE_NAME:-php-fpm}
CONSUL=${CONSUL:-consul}
preStart() {
echo "php-fpm preStart"
}
onChange() {
echo "php-fpm onChange"
}
help() {
echo "Usage: ./reload.sh preStart
=> first-run configuration for php-fpm"
echo " ./reload.sh onChange
=> [default] update php-fom config on
upstream changes"
}
until
cmd=$1
if [ -z "$cmd" ]; then
onChange
fi
shift 1
$cmd "$@"
[ "$?" -ne 127 ]
do
onChange
exit
done
在这里,我们创建了一个虚拟脚本,但是你可以根据自己的需求进行调整。例如,它可以更改为run execute consul-template
并在 ContainerPilot 触发脚本后重新构建 NGINX 配置。我们将在以后解释更复杂的脚本。
我们的基本php-fpm
容器已经准备就绪,但是我们的基本环境如果没有 web 服务器是不完整的。我们将使用 NGINX,一个非常轻巧和强大的反向代理和 web 服务器。
我们将构建我们的 NGINX 服务器的方式与php-fpm
非常相似,因此我们只会解释其中的区别。
提示
请记住,所有文件都可以在我们的 GitHub 存储库中找到。
我们将在docker-compose.yml
文件中为 NGINX 添加一个新的服务定义,并将其链接到我们的autodiscovery
服务以及我们的php-fpm
:
microservice_base_nginx:
build: ./microservices/base/nginx/
links:
- autodiscovery
- microservice_base_fpm
environment:
- BACKEND=microservice_base_fpm
- CONSUL=autodiscovery
ports:
- 8080:80
在我们的microservices/base/nginx/config/containerpilot.json
中,现在有一个新选项telemetry
。此配置设置允许我们指定用于收集我们服务的统计信息的远程遥测服务。包含这种类型的服务在我们的环境中可以让我们看到我们的容器的性能如何:
"telemetry": {
"port": 9090,
"sensors": [
{
"name": "nginx_connections_unhandled_total",
"help": "Number of accepted connnections that were not handled",
"type": "gauge",
"poll": 5,
"check": ["/usr/local/bin/sensor.sh", "unhandled"]
},
{
"name": "nginx_connections_load",
"help": "Ratio of active connections (less waiting) to
the maximum
worker connections",
"type": "gauge",
"poll": 5,
"check": ["/usr/local/bin/sensor.sh", "connections_load"]
}
]
}
正如您所看到的,我们使用一个定制的 bash 脚本来获取容器统计信息,我们的microservices/base/nginx/scripts/sensor.sh
脚本的内容如下:
#!/bin/bash
set -e
help() {
echo 'Make requests to the Nginx stub_status endpoint and
pull out metrics'
echo 'for the telemetry service. Refer to the Nginx docs
for details:'
echo 'http://nginx.org/en/docs/http/ngx_http_stub_status_module.html'
}
unhandled() {
local accepts=$(curl -s --fail localhost/nginx-health | awk 'FNR == 3
{print $1}')
local handled=$(curl -s --fail localhost/nginx-health | awk 'FNR == 3
{print $2}')
echo $(expr ${accepts} - ${handled})
}
connections_load() {
local scraped=$(curl -s --fail localhost/nginx-health)
local active=$(echo ${scraped}
| awk '/Active connections/{print $3}')
local waiting=$(echo ${scraped} | awk '/Reading/{print $6}')
local workers=$(echo $(cat /etc/nginx/nginx.conf | perl -n -e'/
worker_connections *(\d+)/ && print $1') )
echo $(echo "scale=4; (${active} - ${waiting}) / ${workers}" | bc)
}
cmd=$1
if [ ! -z "$cmd" ]; then
shift 1
$cmd "$@"
exit
fi
help
这个 bash 脚本获取了一些我们将使用 ContainerPilot 发送到我们的telemetry
服务器的nginx
统计信息。
我们的microservices/base/nginx/scripts/reload.sh
比我们之前为php-fpm
创建的要复杂一些:
#!/bin/bash
SERVICE_NAME=${SERVICE_NAME:-nginx}
CONSUL=${CONSUL:-consul}
preStart() {
consul-template \
-once \
-dedup \
-consul ${CONSUL}:8500 \
-template "/etc/nginx/nginx.conf.ctmpl:/etc/nginx/nginx.conf"
}
onChange() {
consul-template \
-once \
-dedup \
-consul ${CONSUL}:8500 \
-template "/etc/nginx/nginx.conf.ctmpl:/etc/nginx/
nginx.conf:nginx -s reload"
}
help() {
echo "Usage: ./reload.sh preStart
=> first-run configuration for Nginx"
echo " ./reload.sh onChange => [default] update Nginx config on
upstream changes"
}
until
cmd=$1
if [ -z "$cmd" ]; then
onChange
fi
shift 1
$cmd "$@"
[ "$?" -ne 127 ]
do
onChange
exit
done
正如您所看到的,我们使用consul-template
在启动时或当 ContainerPilot 检测到我们将要监视的后端服务列表中的更改时重建我们的 NGINX 配置。这种行为允许我们停止向不健康的节点发送请求。
在这一点上,我们的基本环境已经准备就绪,我们准备用一个简单的$ docker-compose up
来测试它。我们将使用所有这些部件来创建更大更复杂的服务。在接下来的章节中,我们将添加 telemetry 服务或数据存储等。
微服务框架
框架是我们可以用于软件开发的骨架。使用框架将帮助我们在应用程序中使用标准和稳健的模式,使其更稳定并为其他开发人员所熟知。PHP 有许多不同的框架可以在您的日常工作中使用。我们将看到一些最常见框架上使用的标准,以便您可以为您的项目选择最佳的。
PHP-FIG
多年来,PHP 社区一直在开发自己的项目并遵循自己的规则。自 PHP 最初的几年以来,已经发布了成千上万个不同的项目,由不同的开发人员开发,并且没有遵循任何共同的标准。
这对 PHP 开发人员来说是一个问题,首先是因为他们无法知道构建应用程序所遵循的步骤是否正确。只有他们自己的经验和互联网可以帮助开发人员猜测他们的代码是否正确编写,并且将来是否可读。
其次,开发人员觉得他们在试图重复造轮子。由于没有标准适应第三方应用程序到他们的项目中,开发人员为他们的项目制作了相同的现有应用程序。
2009 年,PHP 框架互操作性组(PHP-FIG)诞生,其主要目标是为 PHP 开发创建一个统一的标准。PHP-FIG 是一个由成员组成的大社区,致力于PHP 标准推荐(PSR),讨论如何最好地使用 PHP 语言。
PHP-FIG 得到大型项目的支持,如 Drupal,Magento,Joomla!,Phalcon,CakePHP,Yii,Zend 和 Symfony,这就是为什么它们提出的 PSR 被 PHP 框架实现的原因。
一些标准,如 PSR-1 和 PSR-2,涉及代码的使用和样式(使用制表符或空格,PHP 的开放标签,使用驼峰命名法或文件名),其他标准涉及自动加载(PSR-0 然后 PSR-4)。自 PHP 5.3 以来,命名空间已经包含,并且实现自动加载
是最重要的事情。
自动加载
可能是 PHP 中最重要的改进之一。在 PHP-FIG 之前,框架有它们自己的方法来实现自动加载,它们自己的格式化方式,初始化方式和命名方式,每个都不同,所以这是一场灾难(Java 已经通过其 bean 系统解决了这个问题)。
最后,Composer 实现了自动加载,这是由 PHP-FIG 编写的。因此,开发人员不再需要担心require_once()
,include_once()
,require()
或include()
。
您可以在www.php-fig.org/
找到有关 PHP-FIG 的更多信息。
PSR-7
在本书中,我们将使用PHP 标准推荐 7(PSR-7)。它涉及 HTTP 消息接口。这是 Web 开发的本质;这是与服务器通信的方式。HTTP 客户端或 Web 浏览器向服务器发送 HTTP 请求消息,服务器会回复一个 HTTP 响应消息。
这些类型的消息对普通用户是隐藏的,但开发人员需要了解结构以操纵它们。PSR-7 讨论了推荐的操纵方式,简单明了地进行操作。
我们将使用 HTTP 消息与微服务进行通信,因此有必要了解它们的工作原理和结构。
HTTP 请求具有以下结构:
**POST** /path **HTTP/**1.1 Host: example.com
foo=bar&baz=bat
在请求中,有所有必要的东西让服务器理解请求消息并能够回复。在第一行中,我们可以看到用于请求的方法(GET
,POST
,PUT
,DELETE
),请求目标和 HTTP 协议版本,然后是一个或多个 HTTP 头部,一个空行和主体。
HTTP 响应将如下所示:
**HTTP/**1.1 200 **OK** Content-Type: text/plain
这是响应主体。响应包含 HTTP 协议版本,例如请求和 HTTP 状态代码,后面跟着一个描述代码的文本。您将在接下来的章节中找到所有可用的状态代码。其余的行与请求类似:一个或多个 HTTP 头部,一个空行,然后是主体。
一旦我们了解了 HTTP 请求消息和 HTTP 响应消息的结构,我们将了解 PHP-FIG 关于 PSR-7 的建议。
消息
任何消息都是 HTTP 请求消息(RequestInterface)或 HTTP 响应消息(ResponseInterface)。它们扩展了 MessageInterface 并且可以直接实现。实现者应该实现 RequestInterface 和 ResponseInterface。
头部
标题字段名称不区分大小写:
$message = $message->withHeader('foo', 'bar');
echo $message->getHeaderLine('foo');
// Outputs: bar
echo $message->getHeaderLine('FoO');
// Outputs: bar
具有多个值的标题:
$message = $message ->withHeader('foo', 'bar') ->
withAddedHeader('foo', 'baz');
$header = $message->getHeaderLine('foo');
// $header contains: 'bar, baz'
$header = $message->getHeader('foo'); // ['bar', 'baz']
主机头
通常,主机头与 URI 的主机组件相同,并且在建立 TCP 连接时使用的主机相同,但可以进行修改。
RequestInterface::withUri()
将替换请求主机头。
通过传递第二个参数为 true,您可以保持主机的原始状态;它将保留主机头,除非消息没有主机头。
流
当读取或写入数据流时,StreamInterface 用于隐藏实现细节。
流使用以下三种方法公开其功能:
isReadable()
isWritable()
isSeekable()
请求目标和 URI
请求目标位于请求行的第二部分:
origin-form
absolute-form
authority-form
asterisk-form
getRequestTarget()
方法将使用 URI 对象来生成原始形式(最常见的请求目标)。
使用withRequestTarget()
,用户可以使用其他三个选项。例如,星号形式,如下所示:
$request = $request
->withMethod('OPTIONS')
->withRequestTarget('*')
->withUri(new Uri('https://example.org/'));
HTTP 请求将如下所示:
OPTIONS * HTTP/1.1
服务器端请求
RequestInterface 为 HTTP 请求提供了一般表示,但服务器端请求需要被处理成通用网关接口(CGI)。PHP 通过其超全局变量提供了简化:
$_COOKIE
$_GET
$_POST
$_FILES
$_SERVER
ServerRequestInterface 提供了对这些超全局变量的抽象,以减少对超全局变量的消费者的耦合。
上传的文件
$_FILES
超全局变量在处理数组或文件输入时存在一些众所周知的问题。例如,使用输入名称files
,提交 files[0
]和 files[1
],PHP 将表示如下:
array(
'files' => array(
'name' => array(
0 => 'file0.txt',
1 => 'file1.html',
),
'type' => array(
0 => 'text/plain',
1 => 'text/html',
),
/* etc. */ ), )
预期的表示如下:
array(
'files' => array(
0 => array(
'name' => 'file0.txt',
'type' => 'text/plain',
/* etc. */ ),
1 => array(
'name' => 'file1.html',
'type' => 'text/html',
/* etc. */
),
), )
因此,客户需要了解这些问题,并编写代码来修复给定的数据。
getUploadedFiles()
为消费者提供了规范化的结构。
您可以在www.php-fig.org/psr/psr-7/
找到更详细的信息和我们讨论的接口和类。
中间件
中间件是一种过滤应用程序的 HTTP 请求的机制;它允许您为业务逻辑添加额外的层。它在我们想要到达的代码片段之前和之后执行,以处理输入和输出通信。中间件使用 PSR-7 的建议来修改 HTTP 请求。这就是为什么 PSR-7 和中间件是相结合的原因。
中间件的最常见示例是在身份验证中。在需要登录以获取用户权限的应用程序中,中间件将决定用户是否可以查看应用程序的特定内容:
在前面的图片中,我们可以看到一个典型的 PSR-7 HTTP 请求和响应,带有两个中间件。通常,您会将中间件实现为 lambda(λ)。
现在,我们将看一些典型中间件实现的示例:
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
function (ServerRequestInterface $request, ResponseInterface $response,
callable $next = null)
{
// Do things before the program itself and the next middleware
// If exists next middleware call it and get its response
if (!is_null($next)) {
$response = $next($request, $response); }
// Do things after the previous middleware has finished
// Return response
return $response;
}
request
和response
是对象,最后一个参数$next
是要调用的下一个中间件的名称。如果中间件是最后一个,可以将其留空。在代码中,我们可以看到三个不同的部分:第一部分是在下一个中间件之前修改和执行操作,第二部分是中间件调用下一个中间件(如果存在),第三部分是在上一个中间件之后修改和执行操作。
在看到$next
($request
,$response
)形式之前和之后的代码是一种良好的实践,就像中间件周围的洋葱层一样,但是必须对中间件的执行顺序保持警惕。
另一个良好的实践是将真实应用程序(通常是中间件到达的代码部分,通常是控制器函数)也视为中间件,因为它接收请求和响应,并且必须返回响应,但这次没有下一个参数,因为它是最后一个;然而,执行在此之后继续。这就是我们必须以与最后一个中间件相同的方式查看最终代码的原因。
我们将看到一个完整的示例,以了解如何在基于微服务的应用程序中使用它。正如我们在前面的图片中看到的,有两个中间件,我们将称它们为第一个和第二个,然后结束函数将被调用 endfunction:
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
$first = function (ServerRequestInterface $request,
ResponseInterface $response, callable $next)
{
$request = $request->withAttribute('word', 'hello');
$response = $next($request, $response);
$response = $response->withHeader('X-App-Environment',
'development');
return $response;
} class Second {
public function __invoke(ServerRequestInterface $request,
ResponseInterface $response, callable $next)
{
$response->getBody()->write('word:'.
$request->getAttribute('word'));
$response = $next($request, $response);
$response = $response->withStatus(200, 'OK');
return $response;
}
}
$second = new Second; $endfunction = function (
ServerRequestInterface $request, ResponseInterface $response)
{
$response->getBody()->write('function reached');
return $response;
}
每个框架都有自己的中间件处理程序,但每个处理程序的工作方式都非常相似。中间件处理程序用于管理堆栈,因此您可以在其上添加更多中间件,并且它们将按顺序调用。这是一个通用的中间件处理程序:
class MiddlewareHandler {
public function __construct()
{ //this depends the framework and you are using
$this->middlewareStack = new Stack;
$this->middlewareStack[] = $first;
$this->middlewareStack[] = $second;
$this->middlewareStack[] = $endfunction;
}
... }
执行轨迹将是这样的:
-
用户向服务器请求特定路径。例如,
/
。 -
执行
first
中间件,添加word
等于hello
。 -
first
中间件将执行发送到second
。 -
second
中间件添加句子word: hello
。 -
second
中间件将执行发送到end
函数。 -
end
函数添加句子function reached
并完成自己的工作。 -
执行将继续由
second
中间件进行,这个中间件会将 HTTP 状态设置为 200 以响应。 -
执行将继续由
first
中间件进行,这个中间件会添加一个自定义标头。 -
响应将返回给用户。
当然,如果在中间件执行期间出现错误,您可以使用以下常见代码进行管理:
try { // Things to do }
catch (\Exception $e) {
// Error happened return
$response->withStatus(500);
}
响应将立即发送给用户,而不会结束整个执行。
可用的框架
有数百个可用于开发应用程序的框架,但当您需要一个用于制作微框架的框架时,有必要寻找一些特点:
-
一个能够处理最大请求数的微框架
-
在内存方面轻量级
-
如果可能的话,有一个伟大的社区为该框架开发应用程序
现在我们将看一下可能是目前使用最多的五个框架。找到最好的框架不是一个独特的观点,每个开发者都有自己的观点,所以让我向您介绍以下几个:
框架 | 每秒请求 | 峰值内存 |
---|---|---|
Phalcon 2.0 | 1,746.90 | 0.27 |
Slim 2.6 | 880.24 | 0.47 |
Lumen 5.1 | 412.36 | 0.95 |
Zend Expressive 1.0 | 391.97 | 0.80 |
Silex 1.3 | 383.66 | 0.86 |
来源:PHP 框架基准测试。该项目试图在现实世界中测量 PHP 框架的最小开销。
Phalcon
Phalcon 是一个流行的框架,它因其速度而闻名。Phalcon 非常优化和模块化;换句话说,它只使用您需要或想要的东西,而不会添加您不会使用的额外东西。
文档非常好,但它的缺点是社区没有 Silex 或 Slim 那么大,所以第三方社区很小,有时在出现问题时很难找到快速解决方案。
Phalcon ORM 基于 C 语言。如果您正在开发基于数据库的微服务,这一点非常重要。
总之,这是最好的框架;但是,不建议初学者使用。
您可以访问官方网站phalconphp.com
。
Slim 框架
Slim 是目前可用的最快的微型 RESTful 框架之一。它为您提供了框架应该具有的每个功能。此外,Slim 框架有一个非常庞大的社区:您可以在互联网上找到很多资源、教程和文档,因为有很多开发者在使用它。
自版本 3 发布以来,该框架具有更好的架构,从整体架构和安全性方面来看更好。这个新版本比版本 2 慢一点,但所有引入的更改使得这个框架适用于各种规模的项目。
文档不错,但可以更好。它太短了。
这是一个适合初学者的微框架。
请参阅官方网站www.slimframework.com/
。
Lumen
Lumen 是由 Laravel 制作的最快的微型 RESTful 框架之一。这个微框架是专门为超快的微服务和 API 而设计的。Lumen 确实很快,但有一些微框架更快,比如 Slim、Silex 和 Phalcon。
这个框架因为它与 CodeIgniter 语法非常相似而变得很有名,而且非常容易使用,所以也许这就是为什么 Lumen 是使用微框架开始使用微服务的最佳微框架。此外,Lumen 有非常好和清晰的文档;您可以在lumen.laravel.com/docs
找到它。如果您使用这个框架,您可以在很短的时间内开始工作,因为设置非常快。
Lumen 的另一个优点是您可以开始使用它,然后,如果将来需要完整的 Laravel,将框架转换和更新为 Laravel 非常容易。
请注意,Lumen 仍然强制执行应用程序结构(约定优于配置),这可能会在设计应用程序时限制您的选项。
如果您要使用 Lumen,那是因为您已经使用过 Laravel 并且喜欢它;如果您不喜欢 Laravel,Lumen 不是最好的解决方案。
您可以访问官方网站lumen.laravel.com/
。
Zend Expressive
Lumen 相当于 Laravel,Zend Expressive 相当于 Zend Framework。它是一个用于制作微服务的微框架,并且准备根据 PSR-7 进行专门使用,并基于中间件。
您可以在几分钟内设置它,并且在社区方面具有 Zend Framework 的所有优势。此外,作为 Zend 的产品是质量和安全的代名词。
它具有最小的核心,您可以选择要包含的组件。它具有非常好的灵活性和扩展能力。
访问官方网站zendframework.github.io/zend-expressive/
。
Silex(基于 Symfony)
Silex 也是一个非常好的微型 RESTful PHP 框架。它是五个最快的微框架之一,目前它是最知名的之一,因为 Silex 社区是最大的之一,他们开发了非常好的第三方,因此开发人员对其项目有许多解决方案。
社区及其与 Symfony 的连接保证了稳定的实现和许多可用资源。此外,文档真的很好,这个微框架特别适合大型项目。
Silex 和 Slim Framework 的优势非常相似;也许是竞争使它们变得更好。
请参阅官方网站silex.sensiolabs.org/
。
摘要
在本章中,我们讨论了本书中要构建的示例应用程序。我们还向您展示了如何使用 Docker 设置开发机器,甚至谈到了您可以使用的不同微框架。在下一章中,我们将学习如何设计我们的应用程序以及不同类型的微服务模式。
第三章:应用程序设计
在前几章中,我们看了一下我们将要构建的应用程序的简要描述。现在是时候让您深入了解整个项目了。
微服务结构
我们希望构建一个地理定位应用程序,并选择将其创建为一个游戏,以使其更有趣且更易于理解。请随意将示例调整为任何其他想法,例如嵌入地理定位的旅游应用程序。
我们的游戏将使用地理定位来发现世界各地的不同秘密(或者在特定地理区域内,如果您想要一个较小的地图)。后端系统将生成新的秘密并将它们随机放置在我们的地图上,允许用户探索他们的环境以找到它们。作为我们游戏的玩家,您将收集不同的秘密并将它们存储在您的钱包中,这是您将找到有关每个秘密的更多信息的地方。
为了使我们的游戏更有趣,我们将拥有一个战斗引擎。当您发现我们的秘密世界时,您可以与其他玩家进行战斗,以窃取他/她的秘密。战斗引擎将是一个简单的引擎--只需掷骰子,得分最高者赢得战斗。
这种类型的项目不能没有其他服务完成,例如用户/玩家管理系统等。
作为开发人员,您从规范开始,然后尝试将其分解为更小的部分。根据我们的简要描述,我们可以开始定义我们的微服务及其责任如下:
-
用户服务:这项服务的主要责任是用户注册和管理。为了使示例简单,我们还将添加额外的功能,例如用户通知和秘密钱包管理。
-
战斗服务:这项服务将负责用户的战斗,记录每场战斗并将秘密从失败者的钱包转移到获胜者的钱包。
-
秘密服务:这是我们游戏的核心服务之一,因为它将负责所有秘密事务。
-
位置服务:为了增加额外的复杂性,我们决定创建一个服务来管理与位置相关的任何任务。主要责任是知道所有东西的位置;例如,如果用户服务需要知道该区域是否有其他玩家,向该服务发送带有地理定位的消息,响应将告诉用户服务谁在该区域。
请注意,我们不仅为我们的游戏创建服务,而且我们将使用其他支持服务使一切运行顺利。
以下图表描述了我们不同服务之间的通信路径。每个服务都能够与其他服务交流,这样我们就可以组合更大更复杂的任务。以下图表描述了我们微服务之间的连接:
微服务模式
设计模式是在现实世界应用程序开发中解决重复问题的可重用解决方案。这些解决方案已经被证明具有成功的记录,并且被广泛使用,因此将它们添加到我们的项目中将使我们的软件更加稳定和可靠。
我们正在构建一个微服务应用程序,因为我们希望它尽可能稳定和可靠,所以我们将使用一些微服务模式,例如:API 网关、服务发现和注册表,以及共享数据库或每个服务一个数据库。
API 网关
我们将为用户提供前端注册和与我们的应用程序交互,并且它将是我们微服务的主要客户端。此外,我们计划将来拥有原生移动应用程序。由于不同的客户端使用我们的应用程序可能会给我们带来麻烦,因为他们对我们的微服务的使用可能会有很大不同。
为了统一任何客户端使用我们的微服务的方式,我们将添加一个额外的层--一个 API 网关。这个 API 网关成为任何客户端(例如浏览器和原生应用程序)的单一入口点。在这一层,我们的网关可以以两种方式处理请求:一些请求只是被代理,而其他请求则被分发到多个服务。我们甚至可以将这个 API 网关用作安全层,检查客户端的每个请求是否被允许使用我们的微服务:
资产请求
拥有 API 网关有许多好处,其中我们可以强调以下几点:
-
我们的应用程序将有一个单一的访问点,消除了客户端需要知道每个微服务位置的问题。
-
我们可以更好地控制我们的服务如何被使用,甚至可以为特定客户提供自定义端点。
-
它减少了请求/往返的次数。通过一次往返,客户端可以从多个服务中检索数据。
服务发现和注册
我们的服务将需要调用其他服务。在单体应用程序中,解决方案非常简单--我们可以调用方法或使用过程调用。我们正在构建一个运行在容器中的微服务应用程序,所以没有简单的方法可以知道某个服务的位置。我们的容器基础设施非常灵活,我们需要构建一个服务发现系统。
我们的每个服务将通过查询我们的服务注册表(一个使用 Consul 存储有关所有服务信息的地方)来获取所有其他链接服务的位置。我们的注册表将知道每个服务实例的位置。下图显示了自动发现模式:
为了实现这一点,我们将使用不同的工具:
-
Consul:这是我们的服务注册表,具有许多功能,如集群支持等。
-
Fabio:这是一个基于 Go 构建的反向代理,与 Consul 有深度集成。我们喜欢这个代理的原因是它与 Consul 的轻松连接以及其进行蓝绿部署的能力。另一个你可以尝试的有趣工具是 Træfik。
-
NGINX:这是一个强大的 HTTP 服务器和反向代理,对于大多数 Web 开发人员来说,这是一个非常知名的工具,由于其性能和低内存占用而被选择。我们将同时使用 Fabio 和 NGINX 作为反向代理。
-
ContainerPilot:这是一个用 Go 编写的小工具。我们将使用这个软件将我们的服务注册到 Consul 中,将我们容器的统计数据发送到一个集中的遥测系统,向 Consul 发送健康检查,并检测其他服务的变化。我们将使用这个工具创建一种自动修复系统。
共享数据库或每个服务一个数据库
以某种方式,应用程序会生成我们需要存储的数据。在单体应用程序中,毫无疑问,所有数据都存储在同一个地方。问题在于当你处理微服务应用程序时,没有简单的答案。每个应用程序域都是独特的,所以没有固定的规则来解决问题;你需要分析你的数据,并决定是否要将所有数据存储在共享存储中,每个服务是否有自己的数据存储,或者混合使用。
在我们的示例应用程序中,我们将涵盖这两种方法,但让我们解释每种选项的好处。
每个服务一个数据库
在这种方法中,我们将每个微服务的持久数据保持私有,数据只能通过其 API 访问,并具有许多好处:
-
它使服务之间的耦合度降低;你可以对战斗服务进行更改而不影响用户服务,例如。
-
由于数据只能通过其 API 访问,它增加了我们应用程序的灵活性;我们可以使用不同的存储引擎。例如,我们可以在用户服务中使用关系数据库,在位置服务中使用 NoSQL。
当然,这种解决方案也有一些缺点,最显著的问题是共享不同服务之间的数据的困难。
共享数据库
这种方法就像在单体应用程序中的数据库一样--所有数据都存储在同一个引擎中。主要好处是将所有内容放在一个地方的简单性。
这种简单性也有一些缺点;我们在其中突出以下几个:
-
任何数据库更改都可能破坏或影响其他服务
-
使用相同的引擎处理所有数据会导致应用程序不够灵活
-
如果数据存储出现问题,所有使用共享数据库的服务都会注意到这个问题。
作为开发人员,您的工作是为您需要解决的每个问题找到最佳解决方案。您需要决定如何存储应用程序数据,始终牢记每个选项的利弊。
RESTful 惯例
表述状态转移是用于与 API 通信的方法的名称。顾名思义,它是无状态的;换句话说,服务不会保留传输的数据,因此,如果您调用一个发送数据的微服务(例如,用户名和密码),微服务将不会在下次调用时记住数据。状态由客户端保留,因此客户端需要每次调用微服务时发送状态。
一个很好的例子是当用户登录并且用户能够调用特定方法时,每次都需要发送用户凭据(用户名和密码或令牌)。
Rest API 的概念不再是一个服务;相反,它就像一个资源容器,可以通过标识符(URI)进行通信。
在接下来的几行中,我们将定义一些有关 API 的有趣的惯例。了解这些提示很重要,因为在编写 API 时,您应该像在 API 上工作时一样做事情。换句话说,编写 API 就像为自己写书一样--它将被像您一样的开发人员阅读,因此完美的功能并不是唯一重要的事情,友好的交流方式也很重要。
如果您遵循一些惯例,为您和消费者创建一个 RESTful API 将会更容易让他们满意。我一直在我的 RESTful API 上使用一些建议,结果非常好。它们有助于组织您的应用程序及其未来的维护需求。此外,当 API 消费者享受与您的应用程序一起工作时,他们会感谢您。
安全
RESTful API 中的安全性很重要,但如果您的 API 将被您不认识的人使用,换句话说,如果它将对所有人开放,那么它就尤为重要。
-
到处使用 SSL--这对于您的 API 的安全性很重要。有许多没有 SSL 连接的公共场所,可以嗅探数据包并获取其他人的凭据。
-
使用令牌身份验证,但如果要使用令牌对用户进行身份验证,则必须使用 SSL。使用令牌可以避免每次需要识别当前用户时发送完整凭据。如果不可能,您可以使用 OAuth2。
-
提供响应头,以限制和避免同一消费者发送过多请求。大公司的一个问题是流量,甚至有人试图对您的 API 做坏事。有一种方法可以避免这类问题是很好的。
标准
PHP 和微服务的更多标准正在逐渐出现。正如我们在上一章中看到的,有一些团体,比如 PHP-FIG,正在努力建立它们。以下是一些使您的 API 更加标准的提示:
-
到处使用 JSON。避免使用 XML;如果有一个 RESTful API 的标准,那就是 JSON。它更紧凑,可以在 Web 语言中轻松加载。
-
使用驼峰命名法而不是蛇形命名法;这样更容易阅读。
-
使用 HTTP 状态码错误。每种情况都有标准状态,因此使用它们可以避免解释 API 的每个响应。
-
在 URL 中包含版本信息,不要将其放在头部。版本信息需要在 URL 中,以确保资源在不同版本之间的浏览器可探索性。
-
提供一种覆盖 HTTP 方法的方式。一些浏览器只允许
POST
和GET
请求,因此允许使用X-HTTP-Method-Override
头来覆盖PUT
、PATCH
和DELETE
将是有益的。
消费者便利设施
您的 API 的使用者是最重要的,因此您需要提供有用、友好的方法来使开发人员的工作更轻松。开发方法时要考虑到他们:
-
限制响应数据。开发人员不需要所有可用的数据,因此可以使用字段过滤器限制响应。
-
使用查询参数来过滤和排序结果。这将有助于简化您的 API。
-
记住您的 API 将被不同的开发人员使用,因此要注意您的文档——它需要非常清晰和友好。
-
在
POST
、PATCH
和PUT
请求上返回有用的内容。避免开发人员多次调用 API 以获取所需的数据。 -
提供一种在响应中自动加载相关资源表示的方式。这对开发人员来说将是有帮助的,以避免多次请求相同的内容以获取所有必要的数据。可以通过在 URL 中包含过滤器来定义特定参数来实现这一点。
-
使用链接头来进行分页,这样开发人员就不需要自己创建链接。
-
包括促进缓存的响应头。HTTP 已经包含了一个框架,只需添加一些头部即可实现这一点。
还有很多提示,但这些足以作为 RESTful 约定的第一步。在接下来的章节中,我们将看到这些 RESTful 约定的示例,并解释如何更好地使用它们。
缓存策略
Phil Karlton
“在计算机科学中只有两件难事:缓存失效和命名事物。”
缓存是一个组件,用于临时存储数据,以便将来对该数据的请求可以更快地提供。这种临时存储用于缩短我们的数据访问时间,减少延迟,并改善 I/O。我们可以在微服务架构中使用不同类型的缓存来提高整体性能。让我们来看看这个主题。
一般的缓存策略
为了维护缓存,我们有算法提供指示,告诉我们应该如何维护缓存。最常见的算法如下:
-
最不经常使用(LFU):此策略使用计数器来跟踪条目的访问频率,并首先删除计数最低的元素。
-
最近最少使用(LRU):在这种情况下,最近使用的项目总是靠近缓存的顶部,当我们需要一些空间时,将删除最近未被访问的元素。
-
最近最常使用(MRU):首先删除最近使用的项目。我们将在较老的项目更常被访问的情况下使用这种方法。
在设计应用程序所需的每个微服务时,开始考虑缓存策略的最佳时机。每当您的服务返回数据时,您需要问自己一些问题:
-
我们是否返回了无法在任何地方存储的合理数据?
-
如果输入相同,我们是否返回相同的结果?
-
我们可以存储这些数据多久?
-
我们想要如何使缓存失效?
您可以在应用程序中的任何位置添加缓存层。例如,如果您正在使用 Percona/MySQL/MariaDB 作为数据存储,可以正确启用和设置查询缓存。这个小设置将提升您的数据库性能。
即使在编码时,你也需要考虑缓存。你可以对对象和数据进行延迟加载,或者构建一个自定义的缓存层来提高整体性能。想象一下,你正在从外部存储请求和处理数据,请求的数据可能在同一次执行中重复多次。做类似下面的代码片段将减少对外部存储的调用:
<?php
class MyClass
{
protected $myCache = [];
public function getMyDataById($id)
{
if (empty($this->myCache[$id])) {
$externalData = $this->getExternalData($id);
if ($externalData !== false) {
$this->myCache[$id] = $externalData;
}
}
return $this->myCache[$id];
}
}
请注意,我们的示例省略了大量的代码,比如命名空间或其他函数。我们只想给你一个整体的想法,这样你就可以创建自己的代码。
在这种情况下,我们将我们的数据存储在$myCache
变量中,每当我们使用 ID 作为键标识符向外部存储发出请求时。下一次我们请求与之前相同 ID 的元素时,我们将从$myCache
中获取元素,而不是从外部存储请求数据。请注意,只有在同一次 PHP 执行中可以重用数据时,这种策略才会成功。
在 PHP 中,你可以访问最流行的缓存服务器,比如memcached和Redis;它们都以键值格式存储数据。访问这些强大的工具将允许我们提高微服务应用程序的性能。
让我们使用Redis
作为我们的缓存存储来重建我们之前的示例。在下面的代码片段中,我们将假设你的环境中有一个Redis
库可用(例如 phpredis),并且有一个Redis
服务器在运行:
<?php
class MyClass
{
protected $myCache = null;
public function __construct()
{
$this->myCache = new Redis();
$this->myCache->connect('127.0.0.1', 6379);
}
public function getMyDataById($id)
{
$externalData = $this->myCache->get($id);
if ($externalData === false) {
$externalData = $this->getExternalData($id);
if ($externalData !== false) {
$this->myCache->set($id, $externalData);
}
}
return $externalData;
}
}
在这里,我们首先连接到 Redis 服务器,并调整getMyDataById
函数以使用我们的新 Redis 实例。这个例子可能会更复杂,例如通过添加依赖注入和在缓存中存储 JSON 等无限的选项。使用缓存引擎而不是构建自己的一个好处是,它们都带有许多很酷和有用的功能。想象一下,你想将数据保留在缓存中只有 10 秒;这在 Redis 中非常容易实现--只需用$this->myCache->set($id, $externalData, 10)
替换 set 调用,十秒后你的记录将从缓存中删除。
比将数据添加到缓存引擎更重要的是使你存储的数据失效或删除。在某些情况下,使用旧数据是可以接受的,但在其他情况下,使用旧数据可能会导致问题。如果你没有添加 TTL 来使数据自动过期,请确保你有一种在需要时删除或使数据失效的方法。
记住这个例子和前面的例子,我们将在我们的微服务应用程序中使用这两种策略。
作为开发人员,你不需要被绑定到特定的缓存引擎;封装它,创建一个抽象,并使用该抽象,这样你就可以在任何时候更改底层引擎而不必更改所有的代码。
这种通用的缓存策略可以在应用程序的任何范围内使用--你可以在你的微服务代码中使用它,甚至在微服务之间使用。在我们的应用程序示例中,我们将处理秘密;它们的数据变化不是很频繁,所以我们可以在第一次访问时将所有这些信息存储在我们的缓存层(Redis)中。
将来的请求将从我们的缓存层获取秘密数据,而不是从我们的数据存储中获取,从而提高我们应用的性能。请注意,检索和存储秘密数据的服务是负责管理这个缓存的服务。
让我们看看我们将在微服务应用程序中使用的其他缓存策略。
HTTP 缓存
这种策略使用一些 HTTP 头来确定浏览器是否可以使用响应的本地副本,或者需要从源服务器请求新的副本。这种缓存策略是在应用程序之外管理的,所以你对它没有太多控制。
我们可以使用的一些 HTTP 头如下:
-
Expires:设置内容将过期的未来时间。当未来的这一点到达时,任何类似的请求都必须返回到原始服务器。
-
Last-modified:指定响应最后修改的时间;它可以作为您的自定义验证策略的一部分,以确保用户始终拥有新鲜内容。
-
Etag:此标头标记是 HTTP 提供的用于 Web 缓存验证的几种机制之一,它允许客户端进行条件请求。Etag 是服务器分配给资源特定版本的标识符。如果资源发生更改,Etag 也会更改,从而使我们能够快速比较两个资源表示以确定它们是否相同。
-
Pragma:这是一个旧的标头,来自 HTTP/1.0 实现。HTTP/1.1 Cache-control 实现了相同的概念。
-
Cache-control:此标头是 expires 标头的替代品;它得到了很好的支持,并允许我们实现更灵活的缓存策略。此标头的不同值可以组合以实现不同的缓存行为。
以下是可用的选项:
-
no-cache:表示必须在每个请求之前重新验证任何缓存的内容,然后才能发送给客户端。
-
no-store:表示内容无法以任何方式缓存。当响应包含敏感数据时,此选项很有用。
-
public:将内容标记为公共,可以由浏览器和任何中间缓存进行缓存。
-
private:将内容标记为私有;此内容可以由用户的浏览器存储,但不能由中间方存储。
-
max-age:设置内容在必须重新验证之前可以缓存的最长时间。此选项值以秒为单位,最长为 1 年(31,536,000 秒)。
-
s-maxage:这与 max-age 标头类似;唯一的区别是此选项仅应用于中间缓存。
-
must-revalidate:此标记表示必须严格遵守 max-age、s-maxage 或 expires 标头指示的规则。
-
proxy-revalidate:这与 s-maxage 类似,但仅适用于中间代理。
-
no-transform:此标头告诉缓存它们不得在任何情况下修改接收到的内容。
在我们的示例应用程序中,我们将拥有一个可以通过任何 Web 浏览器访问的公共 UI。使用正确的 HTTP 标头,我们可以避免对相同资产的重复请求。例如,我们的 CSS 和 JavaScript 文件不会经常更改,因此我们可以设置一个未来的到期日期,浏览器将保留它们的副本;未来的请求将使用本地副本而不是请求新副本。
您可以使用简单的规则在 NGINX 中为所有.jpg
、.jpeg
、.png
、.gif
、.ico
、.css
和.js
文件添加一个到浏览器访问时间未来 123 天的到期标头:
location ~* .(jpg|jpeg|png|gif|ico|css|js)$ {
expires 123d;
}
静态文件缓存
一些静态元素非常适合缓存,其中包括以下内容:
-
标志和非自动生成的图像
-
样式表
-
JavaScript 文件
-
可下载内容
-
任何媒体文件
这些元素往往很少更改,因此可以缓存更长时间。为了减轻服务器的负载,您可以使用内容交付网络(CDN),以便这些很少更改的文件可以由这些外部服务器提供。
基本上,CDN 有两种类型:
-
Push CDNs:这种类型要求您推送要存储的文件。您有责任确保将正确的文件上传到 CDN,并且推送的资源可用。它主要用于上传的图像,例如用户的头像。请注意,一些 CDN 在推送后可以返回 OK 响应,但您的文件实际上还没有准备好。
-
拉 CDN:这是懒惰的版本,您不需要将任何内容发送到 CDN。当请求通过 CDN 并且文件不在它们的存储中时,它们会从您的服务器获取资源并将其存储以供将来使用。它主要用于 CSS、图像和 JavaScript 资源。
在设计微服务应用程序时,您需要记住这一点,因为您可能允许用户上传一些文件。
您将在哪里存储这些文件?如果它们是公开的,为什么不使用 CDN 来传送这些文件,而不是从您的服务器中删除它们。
一些著名的 CDN 包括 CloudFlare、Amazon CloudFront 和 Fastly 等。它们的共同之处在于它们在世界各地都有多个数据中心,使它们可以尝试从最近的服务器为您提供文件的副本。
通过将 HTTP 与静态文件缓存策略结合起来,您将最大限度地减少服务器上的资源请求。我们不会解释其他缓存策略,比如完整页面缓存;根据我们所涵盖的内容,您已经有足够的知识来开始构建成功的微服务应用程序。
领域驱动设计
领域驱动设计(从这里开始称为 DDD)是一种在有复杂需求时进行开发的方法。这个概念并不新鲜;它是由 Eric Evans 在他 2004 年的同名书中创建的,但现在它是主流的,因为微服务在开发人员中很受欢迎,并且在大型项目中非常常见。
这是发生的,因为微服务概念(关于软件架构,将每个功能划分为服务)和 DDD 概念(关于有界上下文)之间有很好的兼容性。
在了解我们如何在微服务项目中使用 DDD 之前,有必要了解 DDD 是什么以及它是如何工作的,所以让我向您介绍这种方法的主要概念作为总结。
领域驱动设计的工作原理
Evans 引入了一些必要的概念,以了解领域驱动设计的工作原理:
-
上下文:这是一个词或语句出现的环境,决定了它的含义。
-
领域:这是知识(本体论)、影响或活动的领域。用户应用程序的主题领域是软件的领域。
-
模型:这是描述领域的选定方面的抽象系统,可以用来解决与该领域相关的问题。
-
普遍语言:这是围绕领域模型构建的语言,由所有团队成员使用,将团队的所有活动与软件连接起来。
软件领域与技术术语、编程或计算机无关。在大多数项目中,最具挑战性的部分是理解业务领域,因此 DDD 建议使用模型领域;这是在图表、代码或文字中复制的抽象、有序和选择性知识。
模型领域就像建立具有复杂功能的项目的路线图,需要遵循五个步骤才能实现。这五个步骤需要开发团队和领域专家达成一致。
-
头脑风暴和完善:开发团队和领域专家之间应该有一个沟通渠道。因此,项目中的所有人都应该能够与每个人交谈,因为他们都需要知道项目应该如何工作。
-
草稿领域模型:在对话过程中,有必要开始绘制领域模型的草稿,以便领域专家可以检查和纠正,直到他们达成一致。
-
早期类图:使用草稿,我们可以开始构建类图的早期版本。
-
简单原型:使用早期类图和领域模型的草稿,可以构建一个非常简单的原型。Evans 建议避免与领域无关的事物,以确保领域业务得到适当建模。它可以是一个非常简单的程序作为追踪。
-
原型反馈:领域专家与原型进行交互,以检查是否满足所有需求,然后整个团队将改进领域模型和原型。
这个过程将进行所有必要的迭代,直到领域模型正确为止:
模型、代码和设计必须一起发展和成长。它们不能不同步。如果模型上的概念更新了,代码和设计也应该自动更新,其他方面也是如此。
模型是描述领域的选择性概念的抽象系统,它可以用来解决与该领域相关的问题。如果模型中的某个部分在代码中没有反映出来,那么它应该被移除。
最后,领域模型是项目中通用语言的基础。DDD 中的这种通用语言称为普遍语言,它应该具有以下特点:
-
与领域相关的类名和它们的功能
-
讨论模型中包含的领域规则的术语
-
应用于领域模型的分析和设计模式的名称
项目所有成员,包括开发人员和领域专家,都应该使用普遍语言,因此开发人员应该能够描述所有的任务和功能。
在团队之间的所有讨论中绝对必须使用这种语言,比如会议、图表或文档,但这种语言并不是在过程的第一次迭代中诞生的,这意味着它可能需要多次重构,使模型、语言和代码保持同步。例如,如果开发人员发现领域中的一个类应该被重命名,他们不能在没有重构领域模型和普遍语言的情况下进行重构。
普遍语言、领域模型和代码应该作为一个单一的知识块一起发展。
关于 DDD 存在争议的概念。Eric Evans 说,领域专家必须使用与团队相同的语言,但有些人不喜欢这个想法。通常,领域专家不了解面向对象的概念或微服务,因为这些对非开发人员来说太抽象了。无论如何,DDD 认为,如果领域专家不理解领域模型,那是因为它存在问题。
领域模型中有图表,但 Evans 建议也使用文本,因为图表无法正确解释概念。此外,图表应该是表面的;如果你想看更多细节,可以查看代码。
一些项目受到领域模型和代码之间的连接的影响。这是因为分析和设计之间存在分歧。分析人员制定了一个独立于设计的模型,开发人员无法开发功能,因为缺少一些信息。此外,他们无法与领域专家交流。开发团队将不遵循模型,最终领域模型将不会得到更新,也不会起作用。因此,项目将无法满足要求。
总之,DDD 旨在将软件开发作为模型、设计和代码的迭代精炼过程,作为一个单一的任务块。
在微服务中使用领域驱动设计
正如我们之前所说,DDD 完全满足微服务的需求。微服务的一个常见问题是它们具有分散的数据管理;这有优势,但有时也会带来问题。
两个服务之间的概念模型将是不同的,这可能会在大型公司中造成问题。例如,用户可能会因服务而异,每个服务关于用户的属性可能会不同,而且属性语义也可能会不同。
当在一个大公司中,应用程序经历了很多年的更新,变得更加复杂时,情况变得更加复杂。每个服务可能对用户有不同的属性,通常它们不匹配。因此,使用 DDD 是解决这个问题的一个很好的方法。
与微服务一样,DDD 将复杂的领域划分为不同的上下文,建立它们之间的关系,并要求所有成员合作,以在特定领域和有界上下文中获得一种通用语言,通过迭代这个过程直到实现对问题的真正概念。
Evans 建议将每个微服务设计为 DDD 有界上下文,以便为系统内的微服务提供逻辑边界。每个微服务(或团队)将负责系统的一部分,并提供更清晰和可维护的代码。
Michael Plöd 提出了有关 DDD 如何帮助微服务的更多想法。在构建微服务方面有四个重要领域:
-
战略设计:这基本上是有界上下文,但上下文映射和其他模式也很重要。上下文映射应显示项目的所有有界上下文及其彼此之间的关系;它还描述它们之间的契约。上下文映射对于希望转向微服务的单片应用程序非常有用。
-
内部构建块:这指的是在设计有界上下文内部时使用战术模式,如聚合、实体或存储库。
-
大规模结构:用于使用不断发展的顺序和责任层创建结构。这也是微服务中的一个概念。在大型项目中,将大规模结构创建到边界上下文中是有帮助的。它们应该被设计为独立演变。
-
蒸馏:从已经成长的系统中提炼核心领域,在将单片应用程序迁移到微服务时非常有用。最重要的部分应该是识别和提取核心领域,以及识别子域、从核心中提取它并进行重构的迭代过程。
总之,微服务和 DDD 完全匹配,但需要有更大的范围并且了解超出边界上下文的内容。
事件驱动架构
事件驱动架构(EDA)是一种遵循生产、检测、消费和对事件做出反应的应用程序架构模式。
可以将事件描述为状态的改变。例如,如果门关闭了,有人打开了它,门的状态就从关闭变为打开。打开门的服务必须将此更改作为事件进行,其他服务可以知道这个事件。
事件通知是异步产生、发布、检测或消费的消息,它是由事件改变的状态。重要的是要理解事件并不在应用程序中传播,它只是发生。术语“事件”有点争议,因为它通常指的是消息事件通知,而不是事件,因此了解事件和事件通知之间的区别很重要。
这种模式通常用于基于组件或微服务的应用程序,因为它们可以应用于应用程序的设计和实现。由事件驱动的应用程序具有事件创建者和事件消费者或接收器(它们必须在事件可用时立即执行操作)。
事件创建者是事件的生产者;它只知道事件已发生,没有其他信息。然后我们有事件消费者,它们负责知道事件已被触发。消费者参与处理或更改事件。
事件消费者订阅了某种中间件事件管理器,一旦它收到来自创建者事件的通知,就会将事件转发给已注册的消费者来接收。
将应用程序开发为围绕诸如 EDA 之类的架构的微服务允许这些应用程序以一种更具响应性的方式构建,因为 EDA 应用程序是按设计准备好在不可预测和异步环境中运行的。
使用 EDA 的优势如下:
-
解耦系统:创建者服务不需要知道其他服务,其他服务也不知道创建者。因此,它允许解耦系统。
-
交互发布/订阅:EDA 允许多对多的交互,其中服务发布有关某个事件的信息,服务可以获取该信息并对事件进行必要的处理。因此,它使许多创建者事件和消费者事件能够实时交换状态并响应信息。
-
异步:EDA 允许服务之间的异步交互,因此它们不需要等待即时响应,而且在等待响应时不需要连接工作。
微服务中的事件驱动架构
在大型项目中,通常使用微服务将其服务划分为较小的服务。因此,它非常重要在它们之间有良好和有组织的通信。事件驱动架构可用于解决微服务之间通信的常见问题。
在基于微服务的项目中,通常每个微服务都使用 HTTP 请求相互通信。这会带来一些问题,我们现在来解释。
在我们的“寻找秘密”项目中,有一个函数用于为用户创建事件。创建新事件时,需要将事件名称和事件表单中附加的图像发送到一个服务,以从接收到的数据创建视频。生成视频后,事件将被更新并通过电子邮件发送给用户。
如果我们为每个服务进行 HTTP 请求,问题在于所有服务都需要了解其他服务。例如,生成视频的服务需要知道如何在生成视频后更新事件;换句话说,服务必须包含代码来执行此更新。
此外,一旦我们添加了许多服务,这将变得越来越困难,因为它将需要更多的通信。它将有更多的故障,并且主要问题是,如果一个微服务宕机,视频将无法生成。因此,在像这样的项目中,使用 HTTP 请求不会很好地扩展,我们应该使用不同的策略来进行微服务通信。
如果我们以不同的方式做事会怎样?换句话说,生成视频的服务不会直接更新事件,事件也不会要求视频服务生成视频。那么,我们如何使微服务进行通信?答案是使用事件驱动架构。
为此,我们需要以下内容:
-
每个微服务的事件队列
-
所有微服务都必须将事件发送到集中式总线(我们可以使用 AWS 来做这个)
-
每个微服务队列都必须订阅集中式总线
-
每个微服务都有一个后台工作程序监听事件队列,并在接收到事件时执行必要的操作
在下图中,您可以看到涉及的不同服务和流程流程,用箭头表示。下图显示了事件驱动的工作流程:
微服务中的事件驱动架构
当我们在事件服务 API(1)上创建一个新事件时,事件会进入集中式总线(2),相应的工作者会从集中式总线(3)获取事件;其他工作者会忽略这个事件。事件被放置在视频生成服务的队列中,并等待服务执行(4)。
一旦视频由服务工作者生成,服务就会向集中式总线(5)发出新的事件。然而,这次会由另一个工作者(6)接管,其他工作者会像之前一样忽略这个事件。更新事件的工作者和发送电子邮件的工作者会将事件放入他们的队列中,并且会执行(7)对每个服务执行相应的操作,如果有必要的话,他们会向集中式总线发送新的事件。
这是一个事件循环,它改进了服务之间的通信的 HTTP 请求方法。使用事件驱动架构的优势如上所述:
-
如果服务出现任何错误或异常,事件不会丢失,它会留在队列中,并且稍后会被执行。例如,如果发送电子邮件的服务出现问题,发送电子邮件的事件将被保留在队列中,等待服务再次启动。
-
服务不需要知道如何更新其他服务。这意味着服务的逻辑可以在每个服务中被隔离。
-
可以添加更多的微服务而不会产生影响。
-
它将更好地扩展。
持续集成、持续交付和工具
没有代码提交策略或测试/部署工作流程,软件项目是无法成功的。当你在团队中工作时,拥有一个策略更加重要。没有什么比在一个混乱的项目上工作更令人恼火,没有规则或没有人对他们所做的工作负责。在本节中,我们将解释最常见和成功的开发实践。
持续集成 - CI
持续集成是一个软件开发实践,团队成员频繁地集成他们的工作。每当新代码被推送到共享存储库时,将触发自动构建,以尽快检测任何集成错误。主要目标是避免长时间和不可预测的集成。
什么是持续集成?
让我们用一个简短的例子更好地解释持续集成的过程是什么样的。想象一下,你的游戏示例已经准备就绪,在生产中运行良好,你有一个新的想法,一个小功能,你的应用程序的用户会喜欢。这个新功能可以在几个小时内完成。
首先,在开发机器上获取当前源代码的副本;你将使用源代码控制系统,所以你只需要从主线检出一个工作副本。
现在你已经有了源代码的工作副本,你可以做任何你需要完成的功能,添加新代码,创建新测试等等。持续集成实践假设你的大部分代码将被自动化测试覆盖。对于 PHP 来说,一个流行的单元测试套件是 PHPUnit,这是一个简单而强大的工具,我们将在后面的章节中介绍。对我们的代码进行测试将有助于我们在未来的步骤中,并确保我们的代码质量高。
现在你已经完成了新功能,是时候在你的开发环境上启动一个自动构建了。这个过程将获取源代码,检查错误,并运行自动化测试。只有当构建和所有测试都通过没有错误时,我们才能认为构建是好的,并且可以将其添加到我们的存储库中。
这个过程的结果是,我们有一个稳定的软件,它能正常工作并且包含非常少的 bug。
持续集成的好处
持续集成的主要目标是降低风险,但这并不是采用这种开发实践的唯一好处。其中,我们可以强调以下好处:
-
减少集成时间
-
由于我们正在推送小的更改并且每个更改都经过了一次又一次的测试,因此可以早期发现错误
-
稳定构建的持续可用性,我们可以使用它进行新的测试,用作我们的客户演示,甚至再次部署
-
项目质量指标的持续监控
持续集成的工具
作为开发人员,您可能会担心如何自动化这个过程。不用担心,在市场上有多种方式可以创建和管理您的 CI 流水线。我们的最佳建议是,在决定在项目中使用哪种 CI 软件之前,花一些时间测试所有的选择。一些与 PHP 轻松集成的 CI 软件包括:
-
Jenkins:这是一个非常容易安装和管理的开源项目。它的多功能性使得这个软件可能是最广泛使用的 CI 软件之一。
-
Bamboo:这是一个基于订阅的软件。Atlassian 在开发世界以其生产力和开发支持工具而闻名。如果您需要与其他 Atlassian 工具深度集成,这是一个不错的选择。
-
Travis:这是另一个基于订阅的软件,针对开源项目有免费计划。
-
PHP CI:这个新的开源工具是基于 PHP 构建的,可以安装在您的服务器上,也可以作为基于云的工具使用。
在我们的示例项目中,我们将使用 Jenkins 并启动一个 Docker 容器。与此同时,您可以使用以下简单命令开始测试 Jenkins:
**$ docker run -p 8080:8080 -p 50000:50000 jenkins**
这个命令将创建一个包含 Jenkins 官方 Docker 镜像的容器,并将本地环境的 8080 和 50000 端口映射到容器中。如果您在浏览器中打开http://localhost:8080
,您将可以访问 Jenkins UI。
持续交付
持续交付是持续集成的延续,其主要目标是能够在任何时候部署软件的任何版本,而不会出现故障。我们可以通过确保我们的代码始终可供部署,并遵循持续集成的实践,来实现这一点,从而确保我们的源代码的质量和集成水平。
通过持续交付,每当我们对代码进行更改时,这些更改都会被构建、测试,然后发布到一个阶段环境。下图显示了 CD 流水线上的基本工作流程。正如您所看到的,如果任何测试步骤失败,我们需要重新开始,直到我们的代码通过测试。通过这种方式工作,我们可以始终确保我们的项目符合最高的质量标准。
以下是持续交付工作流程的图表:
持续交付的好处
持续交付有许多好处;其中,我们强调以下几点:
-
减少部署风险:我们将部署更小的更改,因此出错的可能性更小,而且更容易修复任何问题。即使我们应用了部署模式,比如蓝绿部署,我们的部署对我们的用户来说是不可察觉的。
-
进度跟踪:由于并非所有开发人员和经理以相同的方式跟踪工作进展,我们现在非常快速地部署小版本;当任务完成时毫无疑问——如果它在生产环境中,那么任务就完成了。
-
更高的质量:通过持续交付,我们以小批量工作;这使我们能够在交付生命周期中从用户那里获得反馈。我们甚至可以使用 A/B 测试来在构建完整功能之前测试想法。在我们的流水线中使用自动化测试工具使开发人员能够快速发现回归并避免发布不稳定的软件。
-
更快的上市时间:传统软件生命周期的集成和测试阶段可能需要几周的时间,但如果我们设法自动化构建和部署、环境配置和测试流程,我们可以将时间缩短到最低并将其纳入开发人员的日常工作中。
-
降低成本:如果我们投资于构建、测试、部署和环境自动化,就可以通过消除许多固定的相关成本来降低软件成本。
持续交付流水线的工具
如前所述,持续交付是持续集成的延续,因此我们可以使用我们之前提到的大多数 CI 工具,并用我们最喜欢的测试框架扩展我们的流水线。在 PHP 中,有许多可用的测试框架,但最知名的有以下几种:
-
phpUnit:这是最知名的用于创建单元测试的框架。每个 PHP 开发人员都需要了解这个框架,因为它将成为他们测试的基础。这是行业标准。
-
Codeception:这是为 PHP 提供的最完整的测试套件之一。使用 Codeception,你可以构建单元测试、功能测试和验收测试。
-
Behat:这是最流行的面向行为的 PHP 测试框架。你不需要编写代码,而是编写故事,框架会转换并测试它们。
-
PHPSec:这是另一个遵循面向行为测试的重要框架。
-
Selenium:这是最复杂的测试框架之一,用于自动化浏览器。有了这个框架,就可以编写用户验收测试。
在接下来的章节中,我们将使用其中一些测试框架。与此同时,尝试每一个并选择你最喜欢的框架。记住,你可以毫无问题地混合它们。
总结
在本章中,我们讨论了设计和开发应用程序的不同方式。我们涵盖了一些模式和策略,你可以轻松地整合到你的开发工作流程中,甚至谈到了最常见的开发实践。在接下来的章节中,我们将在我们的开发工作流程中应用所有这些概念。
第四章:测试和质量控制
在本章中,我们将看一下在开发过程之前、期间和之后可以使用的不同测试方法。正如你所知,测试你的应用程序可以避免未来出现问题,并为你提供更好的项目概述。
在你的应用程序中使用测试的重要性
在我们的应用程序中使用测试非常重要,因为这些步骤可以避免(或至少减少)未来可能出现的问题或错误,因为我们是人类,在开发过程中可能会犯错误,或者因为项目结构不正确,甚至开发人员的理解与客户的要求不符。
测试过程将有助于提高代码质量和功能的理解,进行回归测试以避免在持续集成中包含旧问题,并减少完成项目所需的时间。
测试用于减少应用程序中的失败或错误。开发团队花费大量时间进行错误修复,根据错误的发现时间不同,影响可能会更大或更小。以下图片显示了与开发阶段相关的错误修复的相对成本:
在开发中使用测试方法的原因是我们可以在开发的早期阶段发现代码中的错误,这样我们将花费更少的时间进行错误修复。
微服务测试
基于微服务的应用程序测试的挑战不在于测试每个单独的微服务,而在于集成和数据一致性。基于微服务的应用程序将需要开发人员对微服务架构及其工作流程有更好的理解,以便能够在其上进行测试。这是因为需要检查每个微服务的信息和功能,以及微服务之间的通信点。
在微服务上使用的测试如下:
-
单元测试:在所有基于微服务的应用程序中,甚至在单体应用程序中,都需要使用单元测试。通过使用它,我们将检查方法或代码模块的必要功能。
-
集成测试:单元测试仅检查孤立的组件,因此我们还需要检查方法之间的行为。我们将使用集成测试来检查同一微服务中方法之间的行为,因此微服务之间的调用将需要被模拟。
-
API 测试:微服务架构依赖于它们之间的通信。对于每个微服务,需要建立一个 API;这就像使用该微服务的合同。通过这种测试,我们将检查每个微服务的合同是否有效,并且所有微服务是否互相配合。
-
端到端测试:这些测试保证了应用程序的质量,没有任何模拟方法或调用。将运行测试来评估所有必需微服务之间的功能。在这些测试期间有一些规则可以避免问题:
-
端到端测试很难维护,因此只测试最重要的功能;其余功能使用单元测试
-
通过模拟对微服务的调用,可以测试用户功能
-
测试环境必须保持清洁,因为测试非常依赖数据,因此先前的测试可能会操纵数据,然后下一个测试
一旦我们知道如何根据微服务进行应用程序测试,我们将看一些在开发过程中进行测试的策略。
测试驱动开发
测试驱动开发(TDD)是敏捷哲学的一部分,它似乎解决了应用程序不断发展和成长以及代码变得混乱时常见的开发人员问题。开发人员修复问题以使其运行,但我们添加的每一行代码都可能是一个新的错误,甚至可能破坏其他功能。
TDD 是一种学习技术,它帮助开发人员以迭代、增量和建构主义的方式了解他们将构建的应用程序的领域问题:
-
迭代,因为该技术始终重复相同的过程以获得价值
-
增量,因为对于每个迭代,我们有更多的单元测试可供使用
-
建构主义,因为我们可以立即测试我们正在开发的一切,以便我们可以获得即时反馈
此外,当我们完成每个单元测试或迭代开发后,我们可以忘记它,因为它将在整个开发过程中保留,帮助我们通过单元测试记住领域问题。这对健忘的开发人员是一个很好的方法。
非常重要的是要理解 TDD 包括四个方面:分析、设计、开发和测试。换句话说,进行 TDD 就是理解领域问题并正确分析问题,良好设计应用程序,良好开发和测试。需要明确的是,TDD 不仅仅是实现单元测试,而是整个软件开发过程。
TDD 完全匹配基于微服务的项目,因为在大型项目中使用微服务是将其分成小微服务,我们的功能就像由通信渠道连接的小项目的聚合。项目的大小与使用 TDD 无关,因为在这种技术中,您将每个功能划分为小示例,为此,项目的大小无关紧要,甚至当我们的项目被微服务分割时更是如此。此外,微服务仍然比单块项目更好,因为单元测试的功能是在微服务中组织的,这将帮助开发人员知道他们可以从哪里开始使用 TDD。
如何进行 TDD?
进行 TDD 并不难,我们只需要遵循一些步骤并通过改进我们的代码来重复它们,并检查我们没有破坏任何东西:
-
编写单元测试:它需要是可能的最简单和最清晰的测试,并且一旦完成,它必须失败;这是强制性的,如果它没有失败,那意味着我们做错了什么。
-
运行测试:如果有错误(测试失败),这是开发最小代码以通过测试的时刻;只需做必要的事情,不要编写额外的代码。一旦开发了最小代码以通过测试,再次运行测试;如果通过了,就进入下一步,如果没有,修复它并再次运行测试。
-
改进测试:如果您认为可以改进您编写的代码,那就去做并再次运行测试。如果您认为它是完美的,那就编写一个新的单元测试。
以下图片说明了 TDD 的口号--红,绿,重构:
要进行 TDD,需要在实现函数之前编写测试;如果先开始实现然后再编写测试,那就不是 TDD,只是测试。
如果我们在开始开发应用程序后创建单元测试,那么我们正在进行经典测试,并且没有充分利用 TDD 的好处。编写单元测试将帮助您确保在开发过程中您对领域问题的抽象理解是正确的。
显然,进行测试总比不进行测试要好,但进行 TDD 仍然比仅进行经典测试要好。
为什么我应该使用 TDD?
TDD 是对问题的答案,比如“我应该从哪里开始?”,“我该如何做?”,“我如何编写可以修改而不会破坏任何东西的代码?”,以及“我如何知道我必须实现什么?”。
目标不是毫无意义地编写许多单元测试,而是根据要求正确设计 TDD。在 TDD 中,我们不是考虑实现功能,而是考虑与域问题相关的功能的良好示例,以消除域问题造成的歧义。
换句话说,我们应该通过 TDD 在 X 个示例中复制特定功能或用例,直到我们得到必要的示例来描述该功能或任务,而不会产生歧义或误解。
提示
TDD 可能是记录应用程序的最佳方法。
使用其他软件开发方法时,我们开始思考架构将是什么样子,将使用什么模式,微服务之间的通信将如何进行,但如果一旦我们计划了所有这些,我们意识到这是不必要的呢?我们要花多长时间才能意识到?我们将花费多少精力和金钱?
TDD 通过在许多迭代中创建小示例来定义我们应用程序的架构,直到我们意识到什么是架构。这些示例将逐渐向我们展示应该遵循的步骤,以便定义最佳结构、模式或要使用的工具,从而避免在应用程序的最初阶段浪费资源。
这并不意味着我们在没有架构的情况下工作。显然,我们必须知道我们的应用程序是网站还是移动应用,并使用适当的框架(您可以在第二章中了解哪种框架适合您的需求,开发环境),还要知道应用程序中的互操作性将是什么;在我们的情况下,它将是基于微服务的应用程序。因此,它将支持我们开始创建第一个单元测试。TDD 将成为我们开发应用程序的指南,并且将通过单元测试产生一个没有歧义的架构。
TDD 并非万能良药;换句话说,它对资深开发人员和初级开发人员的效果并不相同,但对整个团队都是有用的。让我们看看使用 TDD 的一些优势:
-
代码重用:这样可以仅使用必要的代码来通过第二阶段(绿色)的测试来创建每个功能。它可以让你看到是否有更多的功能使用相同的代码结构或特定功能的部分;因此,它可以帮助你重用先前编写的代码。
-
团队合作更容易:它让你对团队同事充满信心。一些架构师或资深开发人员不信任经验不足的开发人员,他们需要在提交更改之前检查他们的代码,从而在这一点上造成瓶颈,因此 TDD 帮助我们相信经验较少的开发人员。
-
增加沟通:增加团队同事之间的沟通。沟通更加流畅,因此团队可以分享他们在单元测试中反映的对项目的知识。
-
避免过度设计:不要在最初阶段过度设计应用程序。正如我们之前所说,进行 TDD 可以让你逐渐了解应用程序的概况,避免在项目中创建无用的结构或模式,也许在将来的阶段会被废弃。
-
单元测试是最好的文档:了解特定功能的最佳方法是阅读其单元测试,这有助于我们理解它的工作原理,而不是人类的语言。
-
在设计阶段发现更多用例:在每个测试中,您都将了解功能应该如何更好地工作,以及功能可能具有的所有可能阶段。
-
增加了工作完成的感觉:在每次提交代码时,您会感到它被正确地完成了,因为其余的单元测试都通过了,所以您不必担心破坏其他功能。
-
提高软件质量:在重构步骤中,我们努力使代码更高效和可维护,验证在更改后整个项目仍然正常工作。
TDD 算法
遵循 TDD 算法的技术概念和步骤是简单明了的,通过实践来改进实现它的正确方式。正如我们之前所看到的,只有三个步骤:红、绿和重构。
红 - 编写单元测试
即使代码尚未编写,也可以编写测试,您只需考虑是否可以在实现之前编写规范。因此,在第一步中,您应该考虑开始编写的单元测试不像单元测试,而像功能的示例或规范。
在 TDD 中,这个示例或规范并不是固定的;换句话说,单元测试可以在将来进行修改。在开始编写第一个单元测试之前,需要考虑被测试软件(SUT)将是什么样子,以及它将如何工作。我们需要考虑 SUT 代码将是什么样子,以及我们将如何检查它是否按我们想要的方式工作。
TDD 的工作方式首先让我们设计更舒适和清晰的东西,如果它符合要求的话。
绿 - 使代码工作
一旦示例编写完成,我们必须编写最少的代码使其通过测试;换句话说,设置单元测试为绿色。代码是否丑陋且未经优化并不重要,这将是我们在接下来的步骤和迭代中的任务。
在这一步中,重要的是只编写满足要求的必要代码,而不是不必要的东西。这并不意味着不考虑功能性地编写,而是考虑到它的高效性。看起来很容易,但您会意识到第一次会写出额外的代码。
如果您专注于这一步,您将考虑到关于 SUT 行为的不同输入的新问题。然而,您应该坚定不移地避免编写与当前功能相关的其他功能的额外代码。作为一个经验法则,不要编写新功能,而是做笔记,以便在未来的迭代中将它们转换为功能。
重构 - 消除冗余
重构不同于重写代码。您应该能够在不改变行为的情况下改变设计。
在这一步中,您应该消除代码中的重复,并检查代码是否符合良好实践的原则,考虑效率、清晰度和代码的未来可维护性。这部分取决于每个开发人员的经验。
提示
良好重构的关键是采取小步骤。
重构功能的最佳方式是改变一小部分并执行所有可用的测试,如果它们通过了,继续进行下一个小部分,直到你对得到的结果满意。
行为驱动开发
行为驱动开发(BDD)是一种扩展 TDD 技术并将其与其他设计思想和业务分析相结合的过程,以便提供给开发人员以改进软件开发。
在 BDD 中,我们测试场景和类的行为,以满足可以由许多类组成的场景。
使用 DSL 非常有用,以便客户、项目所有者、业务分析师或开发人员使用共同的语言。目标是拥有一个像我们在第三章中看到的那样的普遍语言,应用设计,在领域驱动设计部分。
什么是 BDD?
正如我们之前所说,BDD 是一种基于 TDD 和 ATDD 的敏捷技术,促进了项目整个团队之间的协作。
BDD 的目标是让整个团队了解客户的需求,让客户知道团队其他成员从他们的规范中理解了什么。大多数情况下,当项目开始时,开发人员和客户的观点并不相同,在开发过程中客户意识到也许他们没有解释清楚,或者开发人员没有正确理解,因此需要更多时间来更改代码以满足客户的需求。
因此,BDD 是使用规则或通用语言以人类语言编写测试用例,以便客户和开发人员能够理解。它还为测试定义了 DSL。
它是如何工作的?
需要将功能定义为用户故事(我们将在本章的 ATDD 部分解释这是什么),并检查它们的验收标准。
一旦用户故事被定义,我们必须专注于使用 DSL 描述项目行为的可能场景。步骤是:给定(上下文),当(事件发生),然后(结果)。
总之,为用户故事定义的场景为验收标准提供了检查功能是否完成的依据。
Cucumber 作为 BDD 的 DSL
Cucumber 是一个 DSL 工具,它执行以纯文本形式制作的示例作为自动测试,利用 BDD 的好处,将业务层和技术结合在一起,以了解用户最看重的功能,并在定义用例测试和记录项目的同时开发它们。
提示
Cucumber 最重要的是让开发人员和客户有相同的理解。
Gherkin是 Cucumber 使用的语言,它允许您将项目的规范翻译成接近人类语言,以便客户或其他没有技术技能的人能够理解。这个工具和语言可以用于 BDD 和 ATDD。让我们看一个样本代码:
Feature: Search secrets
In order to find secrets
Users should be able to search for near secrets
Scenario: Search secrets by distance
Given there are 996 secrets in the game which are no closer than 100
meters from me
And there are 4 secrets SEC001, SEC005, SEC054, SEC121 that are
within 100
meters from me
When I search for closer secrets
Then I should see the following secrets:
| Secret code |
| SEC001 |
| SEC005 |
| SEC054 |
| SEC121 |
这样可以让我们定义软件行为,而不用说出它是如何实现的。同时,它也让我们能够在编写自动测试用例的同时记录功能。使用 Cucumber 的优势如下:
-
易于阅读
-
易于理解
-
易于解析
-
易于讨论
DSL 在代码中有三个工具可以理解和处理的步骤;它们如下:
-
给定:这是将系统设置为适当状态以检查测试的必要步骤。
-
当:这是用户必须执行的必要步骤来激活功能。
-
然后:这指的是系统中发生变化的事物。在这里,我们能够看到它是否符合我们的期望。
此外,还有两个可选的步骤:And和But,它们可以在Given或Then中使用,当您需要超过一句话来满足要求时。
在本章中,我们将看到如何使用一个名为 Selenium 的工具来进行 BDD。这是另一个 DSL 工具,但是它是面向 Web 开发而不是纯文本的。
验收测试驱动开发
也许项目中最重要的方法是验收测试驱动开发(ATDD)或故事测试驱动开发(STDD);它是 TDD,但在不同的层面上。
验收(或客户)测试是项目满足客户需求的业务要求的书面标准。它们是由项目所有者编写的示例(就像 TDD 中的示例)。它是每次迭代开发的开始,是 Scrum 和敏捷开发之间的桥梁。
在 ATDD 中,我们以一种与传统方法不同的方式开始项目的实施。用人类语言编写的业务需求被一些团队成员和客户商定的可执行文件所取代。这并不是要替换整个文档,而只是部分需求。
使用 ATDD 的优势如下:
-
它提供了真实的例子和一个团队可以理解领域的共同语言
-
它使我们能够正确识别领域规则
-
可以在每次迭代中知道用户故事是否完成
-
工作流程从最初的步骤开始
-
开发直到团队定义并接受了测试才开始
用户故事
ATDD 的用户故事在名称或描述方面类似于用例,但工作方式不同。用户故事不定义需求,避免了人类语言的歧义问题。目标是让团队的其他成员能够无问题地理解这个想法。
每个用户故事都是关于客户对应用程序的期望的清晰简洁的例子列表。故事的名称是一个人类语言的句子,定义了功能必须做什么。考虑以下例子:
-
搜索我们位置周围的可用秘密
-
检查我们已经存储的秘密
-
检查战斗中谁是赢家
他们的目标是倾听客户并帮助他们定义他们对应用程序的期望。用户故事应该清晰明了,没有歧义,并且应该用人类语言而不是技术语言编写;客户应该能够理解他们所说的话。
一旦我们定义了一个用户故事,就会出现一些问题,这些问题应该通过为每个故事关联验收测试来回答。例如,对于检查战斗中谁是赢家的故事,一些可能的问题如下所列:
-
如果他们平局了会发生什么?
-
赢家会得到什么?
-
输家会失去什么?
-
一场战斗需要多长时间?
可能的验收测试如下:
-
如果他们平局了,没有人会赢或输任何东西;他们会保留他们的秘密
-
赢家将获得 10 分,并从输家的口袋里得到一个秘密
-
输家将给赢家一个秘密
-
一场战斗需要掷三次骰子
也许一个用户故事的问题和答案会产生新的用户故事,添加到待办列表中。
ATDD 算法
ATDD 的算法类似于 TDD,但覆盖的人员比开发人员更多。换句话说,进行 ATDD 时,每个故事的测试是在一个会议上编写的,该会议包括项目所有者、开发人员和 QA 技术人员,因为团队必须理解需要做什么以及为什么需要这样做,以便他们可以看到代码应该做什么。以下图片显示了 ATDD 的循环:
讨论
ATDD 算法的起点是讨论。在这一步中,业务与客户进行会议,澄清应用程序应该如何工作,分析师应该从对话中创建用户故事。此外,他们应该能够解释每个用户故事的满意条件,以便被翻译成例子,就像我们在用户故事部分解释的那样。
会议结束时,例子应该清晰简洁,这样我们就可以得到一个用户故事的例子列表,以满足客户审查和理解的所有需求。此外,团队将对项目有一个概览,以便理解用户故事的业务价值,如果用户故事太大,可以将其分成小的用户故事,获得第一个迭代的第一个用户故事。
提炼
高级验收测试由客户和开发团队编写。在这一步中,从讨论步骤中得到的测试用例的编写开始,并且团队可以参与讨论并帮助澄清信息或指定其真实需求。
测试应该覆盖在讨论步骤中发现的所有示例,并在这个过程中可以添加额外的测试;一点一点地我们正在更好地理解功能。
在这一步结束时,我们将获得以人类语言编写的必要测试,以便团队(包括客户)能够理解他们在下一步将要做什么。这些测试可以用作文档。
开发
在开发步骤中,验收测试用例开始由开发团队和项目所有者开发。在这一步骤中要遵循的方法与 TDD 相同--开发人员应该创建一个测试并观察它失败(红色),然后开发最少的代码行以通过测试(绿色)。一旦验收测试通过,就应该进行验证和测试,准备好交付。
在这个过程中,开发人员可能会发现需要添加到测试中的新场景,甚至,如果需要大量工作,它可能会被推到用户故事中。
在这一步结束时,我们将拥有一个通过验收测试的软件,甚至可能还有更全面的测试。
演示
通过运行验收测试用例并手动探索新功能的特性来展示创建的功能。演示完毕后,团队讨论用户故事是否做得恰当,是否满足产品所有者的需求,并决定是否可以继续下一个故事。
工具
现在您已经更多地了解了 TDD 和 BDD,是时候解释一些可以在开发工作流程中使用的工具了。有很多可用的工具,但我们只会解释最常用的工具。
Composer
Composer 是一个用于管理软件依赖关系的 PHP 工具。您只需要声明项目所需的库,Composer 将管理它们,在必要时安装和更新它们。这个工具只有一些要求--如果您有 PHP 5.3.2+,您就可以开始了。如果缺少某个要求,Composer 会提醒您。
您可以在开发机器上安装这个依赖管理器,但由于我们使用的是 Docker,我们将直接在我们的PHP-FPM(FastCGI 进程管理器)容器中安装它。在 Docker 中安装 Composer 非常容易;您只需要向 Dockerfile 添加以下规则:
RUN curl -sS https://getcomposer.org/installer
| php -- --install-dir=/usr/bin/ --filename=composer
PHPUnit
我们项目中需要的另一个工具是 PHPUnit,一个单元测试框架。在我们的情况下,我们将使用 4.0 版本。与之前一样,我们将把这个工具添加到我们的 PHP-FPM 容器中,以保持我们的开发机器干净。如果您想知道为什么除了 Docker 之外我们不在开发机器上安装任何东西,答案很明确--将所有东西放在容器中将帮助您避免与其他项目的冲突,并且可以灵活地更改版本而不必过于担心。
作为一个快速的方法,您可以在您的PHP-FPM
Dockerfile
中添加以下RUN
命令,这样您就可以安装并准备使用最新的 PHPUnit 版本了:
RUN curl -sSL https://phar.phpunit.de/phpunit.phar -o
/usr/bin/phpunit && chmod +x /usr/bin/phpunit
上述命令将在您的容器中安装最新的 Composer 版本,但推荐的安装方式是通过 Composer。打开您的composer.json
文件并添加以下行:
"phpunit/phpunit": "4.0.*",
一旦您更新了composer.json
文件,您只需要在容器命令行中执行 Composer 更新,Composer 就会为您安装 PHPUnit。
既然我们的要求都准备好了,现在是时候安装我们的 PHP 框架并开始做一些 TDD 的工作了。稍后,我们将继续更新我们的 Docker 环境,加入新的工具。
在前几章中,我们谈到了一些 PHP 框架,并选择了 Lumen 作为我们的示例。请随意将所有示例调整为您喜欢的框架。我们的源代码将存储在容器中,但在开发的这一阶段,我们不希望容器是不可变的。我们希望我们对代码所做的每一次更改都能立即在我们的容器中使用,因此我们将使用容器作为存储卷。
要创建一个包含我们的源代码并将其用作存储卷的容器,我们只需要编辑我们的docker-compose.yml
文件,并为我们的每个微服务创建一个源容器,如下所示:
source_battle:
image: nginx:stable
volumes:
- ../source/battle:/var/www/html
command: "true"
上述代码片段创建了一个名为source_battle
的容器映像,并存储了我们的 battle 源代码(位于docker-compose.yml
文件当前路径的../source/battle
)。一旦我们有了我们的源代码容器,我们可以编辑每个服务并分配一个卷。例如,我们可以将以下行添加到我们的microservice_battle_fpm
和microservice_battle_nginx
容器描述中:
volumes_from:
- source_battle
我们的 battle 源代码将在我们的源容器的/var/www/html
路径中可用,安装 Lumen 的剩下步骤是执行一个简单的 Composer 命令。首先,您需要确保您的基础设施已经准备好了:
**$ docker-compose up**
上述命令启动我们的容器并将日志输出到标准 IO。现在我们确信一切都已经准备就绪,我们需要进入我们的 PHP-FPM 容器并安装 Lumen。
提示
如果您需要知道每个容器分配的名称,可以在终端上执行docker ps
并复制容器名称。例如,我们将输入以下命令进入 battle PHP-FPM 容器:
**$ docker exec -it docker_microservice_battle_fpm_1 /bin/bash**
上述命令在您的容器中打开一个交互式 shell,以便您可以做任何您想做的事情。让我们用一个命令安装 Lumen:
**# cd /var/www/html && composer create-project --prefer-dist laravel/lumen .**
对每个微服务重复上述命令。
现在您已经准备好开始进行单元测试并编写应用程序代码了。
单元测试
单元测试是一小段代码,它在已知的上下文中使用其他代码,以便我们可以检查我们正在测试的代码是否有效。Lumen 默认带有 PHPUnit;因此,我们只需要将所有测试添加到 tests 文件夹中。框架安装默认带有一个非常小的示例文件--ExampleTest.php
--您可以在其中尝试单元测试。为了开始单元测试,直到您更加熟悉创建单元测试,选择一个您的微服务源代码,并创建app/Dummy.php
文件,内容如下:
<?php
namespace App;
class Dummy
{
}
提示
开始单元测试的最简单方法是每次在代码中创建一个新类时,您都可以为测试创建一个新类。以这种方式工作,您将记住您的新类需要用单元测试进行覆盖。例如,想象一下您需要一个Battle
类;因此,当您创建该类时,您还可以在tests
文件夹中创建一个以Test
为前缀的新类。
在理想的情况下,所有代码都应该由单元测试覆盖,但我们知道这是一个奇怪的情况。大多数情况下,如果幸运的话,您的代码覆盖率将达到 70%或 80%。我们鼓励您保持代码完全覆盖,但如果不可能,至少覆盖核心功能。有两种创建单元测试的方法:
-
先测试,后编码:在我们看来,当您有足够的时间开发项目时,这种工作流程更好。首先,创建测试,以确保您真正了解每个新功能。在测试就位后,您将编写必要的最小代码以通过测试。以这种方式编码,您将思考什么使您的代码有效,以及什么可能使您的代码失败。
-
先写代码,后写测试: 当您没有太多时间进行单元测试时,这是一个非常常见的工作流程。您像往常一样创建您的代码,一旦完成,就创建单元测试。这种方法会创建一个不太健壮的代码,因为您是将单元测试适应已创建的代码,而不是相反。
请记住,测试代码的时间非常重要;这是一项长期投资。在开始时花费时间将使您的代码更加健壮,并消除未来的错误。
运行测试
您可能想知道如何运行和检查您的测试。别担心,这很简单。您只需要进入您的 PHP-FPM 容器之一。例如,要进入 Battle PHP-FPM 容器,请打开终端并执行以下命令:
**$ docker exec -it docker_microservice_battle_fpm_1 /bin/bash**
执行上述命令后,您将进入容器。现在是时候确保您的当前路径是/var/www/html
文件夹。完成上一步后,您可以在该文件夹中执行 phpunit。所有这些操作都可以使用以下命令完成:
**# cd /var/www/html**
**# ./vendor/bin/phpunit**
phpunit
命令将读取phpunit.xml
文件。这个 XML 描述了我们的测试存储在哪里,并执行所有测试。执行此命令将为我们提供一个漂亮的屏幕,显示我们的测试通过或失败的结果。
断言
断言是在已知上下文中的语句,我们期望在代码中的某个时刻为真,并且这是单元测试的核心。断言用于测试用例内,一个测试用例可以包含多个断言。在 PHPUnit 中,创建测试非常简单,您只需要在方法名称前添加test
前缀。简单吧?为了澄清所有这些概念,让我们看一些您可以在单元测试中使用的断言及其示例。随时创建更复杂的测试,直到您熟悉 PHPUnit 为止。
assertArrayHasKey
assertArrayHasKey(mixed $key, array $array[, string $message = ''])
断言检查$array
是否具有$key
元素。想象一下,您有一个生成并返回某种配置数据的方法,并且有一个特定由storage
标识的元素,您需要确保它始终存在。将以下方法添加到我们的Dummy
类中以模拟配置生成:
public static function getConfigArray()
{
return [
'debug' => true,
'storage' => [
'host' => 'localhost',
'port' => 5432,
'user' => 'my-user',
'pass' => 'my-secret-password'
]
];
}
现在我们可以以任何方式测试此getConfigArray
的响应:
public function testFailAssertArrayHasKey()
{
$dummy = new App\Dummy();
$this->assertArrayHasKey('foo', $dummy::getConfigArray());
}
上面的测试将检查getConfigArray
返回的数组是否具有由foo
标识的元素,在我们的示例中失败了:
public function testPassAssertArrayHasKey()
{
$dummy = new App\Dummy();
$this->assertArrayHasKey('storage', $dummy::getConfigArray());
}
在这种情况下,此测试将确保getConfigArray
返回由storage
标识的元素。如果由于某种原因您将来更改getConfigArray
方法的实现,此测试将帮助您确保您至少继续接收由storage
标识的数组元素。
你可以使用assertArrayNotHasKey()
作为assertArrayHasKey
的反向操作;它使用相同的参数。
assertClassHasAttribute
assertClassHasAttribute(string $attributeName, string $className[, string $message = ''])
断言检查我们的$className
是否已定义$attributeName
。修改我们的Dummy
类并添加一个新属性,如下所示:
public $foo;
现在我们可以使用以下测试来测试此公共属性的存在:
public function testAssertClassHasAttribute()
{
$this->assertClassHasAttribute('foo', App\Dummy::class);
$this->assertClassHasAttribute('bar', App\Dummy::class);
}
上面的代码将通过foo
属性的检查,但在检查bar
属性时将失败。
您可以使用assertClassNotHasAttribute()
作为assertClassHasAttribute
的反向操作;它使用相同的参数。
assertArraySubset
assertArraySubset(array $subset, array $array[, bool $strict = '', string $message = ''])
断言检查给定的$subset
是否在我们的$array
中可用:
public function testAssertArraySubset()
{
$dummy = new App\Dummy();
$this->assertArraySubset(['storage' => 'failed-test'],
$dummy::getConfigArray()]);
}
上面的示例测试将失败,因为['storage' => 'failed-test']
子集不存在于我们的getConfigArray
方法的响应中。
assertClassHasStaticAttribute
assertClassHasStaticAttribute(string $attributeName, string $className[, string $message = ''])
断言检查给定$className
中静态属性的存在。我们可以向我们的Dummy
类添加一个静态属性,如下所示:
public static $availableLocales = [
'en_GB',
'en_US',
'es_ES',
'gl_ES'
];
有了这个静态属性,我们可以自由地测试$availableLocales
的存在:
public function testAssertClassHasStaticAttribute()
{
$this->assertClassHasStaticAttribute('availableLocales',
App\Dummy::class);
}
如果需要断言相反的情况,可以使用assertClassNotHasStaticAttribute();
它使用相同的参数。
assertContains()
有时您需要检查一个集合是否包含特定元素。您可以使用assertContains()
函数来实现:
-
assertContains(mixed $needle, Iterator|array $haystack[, string $message = ''])
-
assertNotContains(mixed $needle, Iterator|array $haystack[, string $message = ''])
-
assertContainsOnly(string $type, Iterator|array $haystack[, boolean $isNativeType = null, string $message = ''])
-
assertNotContainsOnly(string $type, Iterator|array $haystack[, boolean $isNativeType = null, string $message = ''])
-
assertContainsOnlyInstancesOf(string $classname, Traversable|array $haystack[, string $message = ''])
assertDirectory()和 assertFile()
PHPUnit 不仅允许您测试应用程序的逻辑,还可以测试文件夹和文件的存在和权限。所有这些都可以通过以下断言实现:
-
assertDirectoryExists(string $directory[, string $message = ''])
-
assertDirectoryNotExists(string $directory[, string $message = ''])
-
assertDirectoryIsReadable(string $directory[, string $message = ''])
-
assertDirectoryNotIsReadable(string $directory[, string $message = ''])
-
assertDirectoryIsWritable(string $directory[, string $message = ''])
-
assertDirectoryNotIsWritable(string $directory[, string $message = ''])
-
assertFileEquals(string $expected, string $actual[, string $message = ''])
-
assertFileNotEquals(string $expected, string $actual[, string $message = ''])
-
assertFileExists(string $filename[, string $message = ''])
-
assertFileNotExists(string $filename[, string $message = ''])
-
assertFileIsReadable(string $filename[, string $message = ''])
-
assertFileNotIsReadable(string $filename[, string $message = ''])
-
assertFileIsWritable(string $filename[, string $message = ''])
-
assertFileNotIsWritable(string $filename[, string $message = ''])
-
assertStringMatchesFormatFile(string $formatFile, string $string[, string $message = ''])
-
assertStringNotMatchesFormatFile(string $formatFile, string $string[, string $message = ''])
您的应用程序是否依赖于可写文件才能工作?别担心,PHPUnit 会帮你解决。您可以在测试中添加assertFileIsWritable()
,这样下次有人删除您在断言中指定的文件时,测试将失败。
assertString()
在某些情况下,您需要检查一些字符串的内容。例如,如果您的代码生成序列码,您可以检查生成的代码是否符合您的规格。您可以使用以下断言来处理字符串:
-
assertStringStartsWith(string $prefix, string $string[, string $message = ''])
-
assertStringStartsNotWith(string $prefix, string $string[, string $message = ''])
-
assertStringMatchesFormat(string $format, string $string[, string $message = ''])
-
assertStringNotMatchesFormat(string $format, string $string[, string $message = ''])
-
assertStringEndsWith(string $suffix, string $string[, string $message = ''])
-
assertStringEndsNotWith(string $suffix, string $string[, string $message = ''])
assertRegExp()
assertRegExp(string $pattern, string $string[, string $message = ''])
断言对您非常有用,因为您可以在一个断言中使用所有的正则表达式功能。让我们向我们的 Dummy 类添加一个静态函数:
public static function getRandomCode()
{
return 'CODE-123A';
}
这个新函数返回一个静态字符串代码。随意增加生成的复杂性。要测试这个生成的字符串代码,您现在可以在测试类中做如下操作:
public function testAssertRegExp()
{
$this->assertRegExp('/^CODE\-\d{2,7}[A-Z]$/',
App\Dummy::getRandomCode());
}
正如您所看到的,我们正在使用简单的正则表达式来检查getRandomCode
生成的输出。
assertJson()
在使用微服务时,您可能会与 JSON 请求和响应密切合作。因此,非常重要的是您有能力测试我们的 JSON。您可以将 JSON 作为文件或字符串:
-
assertJsonFileEqualsJsonFile()
-
assertJsonStringEqualsJsonFile()
-
assertJsonStringEqualsJsonString()
布尔断言
可以使用以下方法检查布尔结果或类型:
-
assertTrue(bool $condition[, string $message = ''])
-
assertFalse(bool $condition[, string $message = ''])
类型断言
有时您需要确保元素是特定类的实例或具有特定的内部类型。您可以在测试中使用以下断言:
-
assertInstanceOf($expected, $actual[, $message = ''])
-
assertInternalType($expected, $actual[, $message = ''])
其他断言
PHPUnit 具有大量的断言,如果没有以下一些断言应用于您的功能的结果或对象状态,您的测试将无法完成:
-
assertCount($expectedCount, $haystack[, string $message = ''])
-
assertEmpty(mixed $actual[, string $message = ''])
-
assertEquals(mixed $expected, mixed $actual[, string $message = ''])
-
assertGreaterThan(mixed $expected, mixed $actual[, string $message = ''])
-
assertGreaterThanOrEqual(mixed $expected, mixed $actual[, string $message = ''])
-
assertInfinite(mixed $variable[, string $message = ''])
-
assertLessThan(mixed $expected, mixed $actual[, string $message = ''])
-
assertLessThanOrEqual(mixed $expected, mixed $actual[, string $message = ''])
-
assertNan(mixed $variable[, string $message = ''])
-
assertNull(mixed $variable[, string $message = ''])
-
assertObjectHasAttribute(string $attributeName, object $object[, string $message = ''])
-
assertSame(mixed $expected, mixed $actual[, string $message = ''])
您可以在 PHPUnit 网站上找到有关您可以在其中使用的断言的更多信息,即phpunit.de/
。
从头开始的单元测试
此时,您可能对单元测试感到更加舒适,并且希望尽快开始编写您的应用程序,因此让我们开始测试吧!
我们的微服务应用程序使用地理定位来查找秘密和其他玩家。这意味着您的位置微服务将需要一种方法来计算两个地理空间点之间的距离。我们还需要根据起始点获取最接近的存储点的列表(它们可以是最接近的用户或秘密)。由于这是一个核心功能,您需要确保我们描述的内容经过充分测试。
在我们的应用程序中,定位有自己的服务。因此,使用您的 IDE 打开位置微服务的源代码,并创建app/Http/Controllers/LocationController.php
文件,内容如下:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class LocationController extends Controller
{
}
上述代码已在 Lumen 中创建了我们的位置控制器,并且正如我们之前提到的,一旦我们创建了这个类,我们需要为我们的单元测试创建一个类似的类。为了做到这一点,您只需要创建tests/app/Http/Controllers/LocationControllerTest.php
文件。正如您所看到的,我们甚至在复制文件夹结构;这是最好的方法,可以轻松知道我们正在为哪个类进行测试。
我们希望开始为距离计算和允许我们根据特定地理位置点获取最接近的秘密的功能创建测试。一种方法是创建两个不同的测试。因此,请使用以下代码填充您的LocationControllerTest.php
:
<?php
use Laravel\Lumen\Testing\DatabaseTransactions;
class LocationControllerTest extends TestCase
{
public function testDistance()
{
}
public function testClosestSecrets()
{
}
}
我们没有向我们的测试类添加任何特殊内容,我们只声明了两个测试。
让我们从testDistance()
开始。在这个测试中,我们希望确保给定两个地理空间点之间的计算距离对我们的目的来说足够准确。在单元测试中,你需要开始描述已知的场景——作为点,我们选择了伦敦(纬度:51.50
,经度:-0.13
)和阿姆斯特丹(纬度:52.37
,经度:4.90
)。这两个城市之间的已知距离大约为 358.06 公里,这是我们希望从我们的距离计算器中得到的结果。让我们用以下代码填充我们的测试:
public function testDistance()
{
$realDistanceLondonAmsterdam = 358.06;
$london = [
'latitude' => 51.50,
'longitude' => -0.13
];
$amsterdam = [
'latitude' => 52.37,
'longitude' => 4.90
];
$location = new App\Http\Controllers\LocationController();
$calculatedDistance = $location->getDistance($london, $amsterdam);
$this->assertClassHasStaticAttribute('conversionRates',
App\Http\Controllers\LocationController::class);
$this->assertEquals($realDistanceLondonAmsterdam,
$calculatedDistance);
}
在上述代码片段中,我们定义了已知的场景,我们两点的位置和它们之间的已知距离。一旦我们准备好了已知的场景,我们创建了一个LocationController
的实例,并使用定义的getDistance
函数来获得我们想要测试的结果。一旦我们得到了结果,我们测试我们的LocationController
是否有一个conversionRate
静态属性,我们可以用它来将距离转换为不同的单位。我们最后并且最重要的断言检查了计算出的距离与这两点之间的已知距离之间的匹配。我们已经准备好了基本的测试,现在是时候开始编写我们的getDistance
函数了。
两个地理空间点之间的距离计算可以用非常不同的方式计算。你可以在这里使用策略模式,但为了保持示例简单,我们将在控制器内的不同函数中编写不同的计算算法。
打开你的LocationController
并添加一些辅助代码:
const ROUND_DECIMALS = 2;
public static $conversionRates = [
'km' => 1.853159616,
'mile' => 1.1515
];
protected function convertDistance($distance, $unit = 'km')
{
switch (strtolower($unit)) {
case 'mile':
$distance = $distance * self::$conversionRates['mile'];
break;
default :
$distance = $distance * self::$conversionRates['km'];
break;
}
return round($distance, self::ROUND_DECIMALS);
}
在上述代码中,我们定义了我们的转换率、一个我们可以用来四舍五入结果的常量,以及一个简单的转换函数。我们将稍后使用这个convertDistance
函数。
我们计算距离的第一个方法是使用欧几里得函数来得到我们的结果。一个简单的实现如下所示:
public function getEuclideanDistance($pointA, $pointB, $unit = 'km')
{
$distance = sqrt(
pow(abs($pointA['latitude'] - $pointB['latitude']), 2) + pow(abs($pointA['longitude'] - $pointB['longitude']), 2)
);
return $this->convertDistance($distance, $unit);
}
现在我们的算法准备好了,我们可以将其添加到我们的getDistance
函数中,如下所示:
public function getDistance($pointA, $pointB, $unit = 'km')
{
return $this->getEuclideanDistance($pointA, $pointB, $unit);
}
此时,你已经准备好了一切,可以开始测试了。进入位置容器,在/var/www/html
中运行 PHPUnit。这是我们的第一次尝试;PHPUnit 的结果将是失败,应用程序的输出将告诉你问题所在。在我们的情况下,失败的主要原因是我们使用的算法对我们的应用程序来说不够准确。我们不能部署这个版本的应用程序,因为它未通过测试,我们必须更改我们的测试或实现测试的代码。
正如我们之前提到的,有多种计算两点之间距离的方法,每种方法都可能更或者更少准确。我们尝试的第一个实现失败了,因为它用于平面,而我们的世界是一个球体。
再次打开你的LocationController
,并使用 haversine 计算创建一个新的距离实现:
public function getHaversineDistance($pointA, $pointB, $unit = 'km')
{
$distance = rad2deg(
acos(
(sin(deg2rad($pointA['latitude'])) *
sin(deg2rad($pointB['latitude']))) +
(cos(deg2rad($pointA['latitude'])) *
cos(deg2rad($pointB['latitude'])) *
cos(deg2rad($pointA['longitude'] -
$pointB['longitude'])))
)
) * 60;
return $this->convertDistance($distance, $unit);
}
如你所见,这个距离计算函数稍微复杂一些,考虑了我们世界的球形形式。更改getDistance
函数以使用我们的新算法:
return $this->getHaversineDistance($pointA, $pointB, $unit);
现在再次运行 PHPUnit,一切应该没问题;测试将通过,我们的代码已经准备好投入生产。
使用单元测试和 TDD,流程总是一样的:
-
创建测试。
-
让你的代码通过测试。
-
运行测试,如果测试失败,从第 2 步重新开始。
我们想要在我们的位置微服务中拥有的另一个功能是获取我们当前位置附近最近的秘密。打开LocationControllerTest
文件并添加以下代码:
public function testClosestSecrets()
{
$currentLocation = [
'latitude' => 40.730610,
'longitude' => -73.935242
];
$location = new App\Http\Controllers\LocationController();
$closestSecrets = $location->getClosestSecrets($currentLocation);
$this->assertClassHasStaticAttribute('conversionRates',
App\Http\Controllers\LocationController::class);
$this->assertContainsOnly('array', $closestSecrets);
$this->assertCount(3, $closestSecrets);
// Checking the first element
$currentElement = array_shift($closestSecrets);
$this->assertArraySubset(['name' => 'amber'], $currentElement);
// Second
$currentElement = array_shift($closestSecrets);
$this->assertArraySubset(['name' => 'ruby'], $currentElement);
// Third
$currentElement = array_shift($closestSecrets);
$this->assertArraySubset(['name' => 'diamond'], $currentElement);
}
在上述代码片段中,我们定义了我们的当前位置(纽约),并要求我们的实现给我们一个最近秘密的列表。我们的位置实现将有一个秘密的缓存列表,我们知道它们的位置(这将帮助我们知道正确的顺序)。
打开LocationController.php
,首先添加一个秘密的缓存列表。在现实世界中,我们没有硬编码的值,但对于测试目的来说,这已经足够了:
public static $cacheSecrets = [
[
'id' => 100,
'name' => 'amber',
'location' => ['latitude' => 42.8805, 'longitude' => -8.54569,
'name' => 'Santiago de Compostela']
],
[
'id' => 100,
'name' => 'diamond',
'location' => ['latitude' => 38.2622, 'longitude' => -0.70107,
'name' => 'Elche']
],
[
'id' => 100,
'name' => 'pearl',
'location' => ['latitude' => 41.8919, 'longitude' => 12.5113,
'name' => 'Rome']
],
[
'id' => 100,
'name' => 'ruby',
'location' => ['latitude' => 53.4106, 'longitude' => -2.9779,
'name' => 'Liverpool']
],
[
'id' => 100,
'name' => 'sapphire',
'location' => ['latitude' => 50.08804, 'longitude' => 14.42076,
'name' => 'Prague']
],
];
一旦我们准备好秘密列表,我们可以添加我们的getClosestSecrets
函数如下:
public function getClosestSecrets($originPoint)
{
$closestSecrets = [];
$preprocessClosure = function($item) use($originPoint) {
return $this->getDistance($item['location'], $originPoint);
};
$distances = array_map($preprocessClosure, self::$cacheSecrets);
asort($distances);
$distances = array_slice($distances, 0,
self::MAX_CLOSEST_SECRETS, true);
foreach ($distances as $key => $distance) {
$closestSecrets[] = self::$cacheSecrets[$key];
}
return $closestSecrets;
}
在这段代码中,我们使用我们的缓存秘密列表来计算原点与我们每个秘密点之间的距离。一旦我们有了距离,我们就对结果进行排序并返回最接近的三个。
在我们的位置容器中运行 PHPUnit 将显示所有测试都已通过,这让我们有信心将代码部署到生产环境。
未来的提交可能会对距离计算或最接近功能进行更改,并且可能会破坏我们的测试。幸运的是,有一个单元测试覆盖它们,PHPUnit 会发出警报,因此您可以开始重新思考代码实现。
让您的想象力飞翔,并测试一切--从简单和小的情况到您能想象到的任何奇怪和模糊的情况。想法是您的应用程序将会崩溃,并且会非常严重,在半夜或者在您度假期间。除了尽可能添加尽可能多的测试以确保您在生产中的发布足够稳定以减少破坏风险之外,您无能为力。
Behat
Behat 是一个开源的行为驱动开发框架。所有 Behat 测试都是用简单的英语编写,并包装成可读的场景。该框架使用 Gherkin 语法,受到了 Ruby 工具 Cucumber 的启发。Behat 的主要优势在于,大多数测试场景都可以被任何人理解。
安装
使用 Composer 可以轻松安装 Behat。您只需要编辑每个微服务的composer.json
,并添加一行新的"behat/behat" : "3.*"
。您的require-dev
定义将如下所示:
"require-dev": {
"fzaninotto/faker": "~1.4",
"phpunit/phpunit": "~4.0",
"behat/behat": "3.*"
},
一旦您更新了dev
要求,您需要进入每个 PHP-FPM 容器并运行 Composer:
**# cd /var/www/html && composer update**
测试执行
运行 Behat 就像运行 PHPUnit 一样简单。您只需要进入 PHP-FPM 容器,转到/var/www/html
文件夹,并运行以下命令:
**# vendor/bin/behat**
从头开始的 Behat 示例
我们微服务应用程序的关键功能之一是查找秘密。用户应该能够保存这些秘密,为此,他们需要一个钱包。因此,让我们在用户微服务中编写我们的用户故事:
Feature: Secrets wallet
In order to play the game
As a user
I need to be able to put found secrets into a wallet
Scenario: Finding a single secret
Given there is an "amber"
When I add the "amber" to the wallet
Then I should have 1 secret in the wallet
Scenario: Finding two secrets
Given there is an "amber"
And there is a "diamond"
When I add the "amber" to the wallet
And I add the "diamond" to the wallet
Then I should have 2 secrets in the wallet
正如你所看到的,该测试可以被项目中的任何人理解,从开发人员到利益相关者。每个测试场景总是具有相同的格式:
Scenario: Some description of the scenario
Given some context
When some event
Then the outcome
您可以向上述基本模板添加一些修饰词(and 或 but)以增强场景描述。在这一点上,您的场景准备就绪后,可以将其保存为features/wallet.feature
文件。
第一次在项目中开始编写 Behat 测试时,您需要使用以下命令初始化套件:
**# vendor/bin/behat --init**
上述命令将创建 Behat 运行场景测试所需的文件。我们将使用的主要文件是features/bootstrap/FeatureContext.php
;这个文件将成为我们的测试环境。
一旦我们的FeatureContext
文件就位,就该开始创建我们的场景步骤了。例如,将以下方法放入您的FeatureContext
中:
/**
* @Given there is a(n) :arg1
*/
public function thereIsA($arg1)
{
throw new PendingException();
}
提示
Behat 使用文档块来定义步骤、步骤转换和钩子。
在上述代码片段中,我们告诉 Behat,thereIsA()
函数匹配每个Given there is a(n)
步骤。在我们的示例中,该定义将匹配以下情况中的步骤:
-
假设有一块琥珀
-
有一颗钻石
我们需要映射每个场景步骤,以便我们的FeatureContext
最终如下:
<?php
use Behat\Behat\Context\Context;
use Behat\Behat\Tester\Exception\PendingException;
use Behat\Gherkin\Node\PyStringNode;
use Behat\Gherkin\Node\TableNode;
/**
* Defines application features from the specific context.
*/
class FeatureContext implements Context
{
private $secretsCache;
private $wallet;
public function __construct()
{
$this->secretsCache = new SecretsCache();
$this->wallet = new Wallet($this->secretsCache);
}
/**
* @Given there is a(n) :secret
*/
public function thereIsA($secret)
{
$this->secretsCache->setSecret($secret);
}
/**
* @When I add the :secret to the wallet
*/
public function iAddTheToTheWallet($secret)
{
$this->wallet->addSecret($secret);
}
/**
* @Then I should have :count secret(s) in the wallet
*/
public function iShouldHaveSecretInTheWallet($count)
{
PHPUnit_Framework_Assert::assertCount(
intval($count),
$this->wallet
);
}
}
我们的测试使用需要定义的外部类。这些类实现了我们的逻辑,并且例如故意创建了features/bootstrap/SecretsCache.php
,其中包含以下内容:
<?php
final class SecretsCache
{
private $secretsMap = [];
public function setSecret($secret)
{
$this->secretsMap[$secret] = $secret;
}
public function getSecret($secret)
{
return $this->secretsMap[$secret];
}
}
您还需要创建features/bootstrap/Wallet.php
,其中包含以下示例代码:
<?php
final class Wallet implements \Countable
{
private $secretsCache;
private $secrets;
public function __construct(SecretsCache $secretsCache)
{
$this->secretsCache = $secretsCache;
}
public function addSecret($secret)
{
$this->secrets[] = $secret;
}
public function count()
{
return count($this->secrets);
}
}
前两个类是我们测试的实现,正如你所看到的,它们具有在钱包中存储秘密的逻辑。现在,如果你在控制台上运行 vendor/bin/behat
,这个工具将检查所有我们的测试场景,并让我们确信我们的代码将按我们想要的方式运行。
这是使用 Behat 测试应用程序的一个简单示例。在我们的 GitHub 存储库中,你可以找到更具体的示例。另外,随时探索 Behat 生态系统;你可以找到多个工具和扩展,可以帮助你测试你的应用程序。
Selenium
Selenium 是一套用于自动化多平台上的 Web 浏览器的工具,并且可以作为浏览器扩展使用,或者可以安装在服务器上来运行我们的浏览器测试。Selenium 的主要优势在于你可以轻松地记录完整的用户旅程并从记录中创建测试。这个测试可以稍后添加到你的流水线中,以便在每次提交时执行,以发现回归。
Selenium WebDriver
WebDriver 是你可以用来从其他工具运行浏览器测试的 API。它是一个强大的测试环境,通常放置在专用服务器上,等待运行浏览器测试。
Selenium IDE
Selenium IDE 是一个 Firefox 扩展,允许你记录、编辑和调试浏览器测试。这个插件不仅是一个录制工具,还是一个带有自动完成功能的完整 IDE。你甚至可以使用 IDE 记录和创建测试,然后用 WebDriver 稍后运行它们。
大多数情况下,Selenium 被用作补充测试工具,从另一个测试框架执行。例如,你可以通过 Mink 项目(mink.behat.org/en/latest/
)来改进你的 Behat 测试。这个项目是不同浏览器驱动程序的包装器,所以你可以在 BDD 工作流中使用它们。
我们将在第七章 Security中讨论我们应用程序的部署。我们将学习如何自动化所有这些测试,并将它们集成到我们的 CI/CD 工作流中。
总结
在本章中,你学习了在应用程序中使用测试的重要性,诸如 Behat 和 Selenium 之类的工具,以及关于实现驱动开发。在下一章中,你将学习错误处理、依赖管理和微服务框架。
第五章:微服务开发
在最后几章中,我们解释了如何安装 Docker、Composer 和 Lumen,这对每个微服务都是必要的。在本章中,我们将开发查找秘密应用程序的一些部分。
在本章中,我们将开发一些更关键的部分,例如路由、中间件、与数据库的连接、队列以及查找秘密应用程序的微服务之间的通信,这样您将能够在将来开发应用程序的其余部分。
我们的应用程序结构将包括以下四个微服务:
-
User: 管理注册和账户操作。它还负责存储和管理我们的秘密钱包。
-
Secrets: 在世界各地生成随机秘密,并允许我们获取有关每个秘密的信息。
-
Location: 检查最近的秘密和用户。
-
Battle: 管理用户之间的战斗。它还修改钱包以在战斗后添加和删除秘密。
依赖管理
依赖管理是一种方法论,允许您声明项目所需的库,并使其更容易安装或更新。PHP 最知名的工具称为Composer。在之前的章节中,我们对这个工具进行了简要概述。
对于我们的项目,我们将需要为每个微服务使用单个 Composer 设置。当我们安装 Lumen 时,Composer 为我们完成了工作并创建了配置文件,但现在我们将详细解释它是如何工作的。
一旦我们安装了 Docker 并且我们在 PHP-FPM 容器中,我们需要工作,就需要生成composer.json
文件。这是 Composer 的配置文件,我们在其中定义我们的项目和所有依赖项:
{
"name": "php-microservices/user",
"description": "Finding Secrets, User microservice",
"keywords": ["finding secrets", "user", "microservice", "Lumen" ],
"license": "MIT",
"type": "project",
"require": {
"php": ">=5.5.9",
"laravel/lumen-framework": "5.2.*",
"vlucas/phpdotenv": "~2.2"
},
"require-dev": {
"fzaninotto/faker": "~1.4",
"phpunit/phpunit": "~4.0",
"behat/behat": "3.*"
},
"autoload": {
"psr-4": {
"App": "app/"
}
},
"autoload-dev": {
"classmap": [
"tests/",
"database/"
]
}
}
composer.json
文件的前 6 行(名称、描述、关键字、许可证和类型)用于识别项目。如果您在任何存储库中分享项目,它将是公开的。
"require"
部分定义了项目中需要的必需库以及每个库的版本。"require-dev"
非常类似,但它们是需要在开发机器上安装的库(例如,任何测试库)。
"autoload"
和"autoload-dev"
定义了我们的类将如何加载以及要映射到项目的不同用途的文件夹。
创建了这个文件后,我们可以在我们的机器上执行以下命令:
**composer install**
此时,Composer 将检查我们的设置,并下载所有必需的库,包括 Lumen。
还有其他工具,但它们没有被使用得那么多,也不够灵活。
路由
路由是应用程序入口点(请求)和执行逻辑的源代码中的特定类和方法之间的映射。例如,您可以在应用程序中定义/users
路由和Users
类中的list()
方法之间的映射。一旦您放置了这个映射,一旦您的应用程序收到对/users
路由的请求,它将执行Users
类中list()
方法中的所有逻辑。路由允许 API 消费者与您的应用程序进行交互。在微服务中,最常用的是 RESTful 约定,我们将遵循它。
-
HTTP 方法:
-
GET: 用于检索有关指定实体或实体集合的信息。数据量不重要;我们将使用 GET 来获取一个或多个结果,还可以使用过滤器来过滤结果。
-
POST: 用于在应用程序中输入信息。它还用于发送新信息以创建新事物或发送消息。
-
PUT: 用于更新已存储在应用程序中的整个实体。
-
PATCH: 用于部分更新已存储在应用程序中的实体。
-
DELETE: 用于从应用程序中删除实体。
Lumen 中的路由文件位于app/Http/routes.php
,因此我们将为每个微服务有一个路由文件。对于User
微服务,我们将有以下端点:
$app->group([
'prefix' => 'api/v1',
'namespace' => 'App\Http\Controllers'],
function ($app) {
$app->get('user', 'UserController@index');
$app->get('user/{id}', 'UserController@get');
$app->post('user', 'UserController@create');
$app->put('user/{id}', 'UserController@update');
$app->delete('user/{id}', 'UserController@delete');
$app->get('user/{id}/location',
'UserController@getCurrentLocation');
$app->post('user/{id}/location/latitude/{latitude}
/longitude/{longitude}',
'UserController@setCurrentLocation');
}
);
在前面的代码中,我们为User
微服务定义了我们的路由。
在 Lumen 中,API 的版本可以在路由文件中通过包含'prefix'来指定。这个框架还允许我们为同一个微服务拥有不同的 API 版本,因此我们不需要修改现有的方法来在不同的版本中使用。
'namespace'
为同一组中包含的所有方法定义了相同的命名空间。以下行定义了每个入口点:
$app->get('user/{id}', 'UserController@get');
例如,前面的方法包含在前缀为'api/v1'
的组中;动词是 GET,入口点是user/{id}
,因此可以通过执行 HTTP GET 调用http://localhost:8080/api/v1/user/123
来检索。UserController@get
参数定义了我们需要在哪里开发此调用的逻辑--在这种情况下,它在控制器UserController
和名为get
的方法中。
在 Lumen 中,存储所有控制器的标准文件夹是app/Http/Controllers
,因此您只需要使用 IDE 创建app/Http/Controllers/UserController.php
文件,并包含以下内容:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class UserController extends Controller
{
public function index(Request $request)
{
return response()->json(['method' => 'index']);
}
public function get($id)
{
return response()->json(['method' => 'get', 'id' => $id]);
}
public function create(Request $request)
{
return response()->json(['method' => 'create']);
}
public function update(Request $request, $id)
{
return response()->json(['method' => 'update', 'id' => $id]);
}
public function delete($id)
{
return response()->json(['method' => 'delete', 'id' => $id]);
}
public function getCurrentLocation($id)
{
return response()->json(['method' => 'getCurrentLocation',
'id' => $id]);
}
public function setCurrentLocation(Request $request, $id,
$latitude, $longitude)
{
return response()->json(['method' => 'setCurrentLocation',
'id' => $id, 'latitude' => $latitude,
'longitude' => $longitude]);
}
}
前面的代码定义了我们在app/Http/routes.php
文件中指定的所有方法。例如,我们返回一个简单的 JSON 来测试每个路由是否正常工作。
提示
请记住,微服务之间通信使用的主要语言是 JSON,因此我们所有的响应都需要是 JSON 格式。
在 Lumen 中,返回 JSON 响应非常容易;您只需要使用响应实例的json()
方法,如下所示:
return response()->json(['method' => 'update', 'id' => $id]);
如果我们$id
变量中存储的值是123
,则前面的句子将返回一个格式良好的 JSON 响应:
{
"method" : "update",
"id" : 123
}
现在,我们已经为我们的User
微服务做好了一切准备。
也许,您想知道在我们的容器环境中,User
微服务的get()
方法分配了什么 URI。找到它非常容易--只需打开docker-compose.yml
文件,您就可以找到microservice_user_nginx
容器的端口映射。我们设置的端口映射表明我们的本地主机 8084 端口将重定向请求到容器的 80 端口。总之,我们的 URI 将是http://localhost:8084/api/v1/get/123
。
Postman
我们基于微服务的应用程序将不会有前端部分;API Rest 的目标是创建可以被不同平台(Web、iOS 或 Android)调用的微服务,只需调用路由文件中可用的方法;因此,为了执行对我们微服务的调用,我们将使用 Postman。这是一个工具,允许您执行包括您需要的参数在内的不同调用。使用 Postman,可以保存方法以便将来使用。
您可以从www.getpostman.com
下载或安装 Postman,如下所示:
Postman 工具概述
正如您在前面的 Postman 工具截图中所看到的,它具有许多功能,比如保存请求或设置不同的环境;但是现在,我们只需要知道执行调用我们应用程序的基本功能,如下所示:
-
设置动词--GET、POST、PUT、PATCH 或 DELETE。有些框架无法重现 PUT 或 PATCH 调用,因此您需要设置动词 POST,并包含一个键为
_method
值为 PUT 或 PATCH 的参数。 -
设置请求 URL。这是我们应用程序的期望入口点。
-
如果需要,添加更多参数--例如,用于过滤结果的参数。对于 POST 调用,Body 按钮将被启用,以便您可以在请求正文中发送参数,而不是在 URL 中发送。
-
点击发送以执行调用。
-
响应将显示状态代码和秒数。
中间件
正如我们在前面的章节中所解释的,中间件在基于微服务的应用程序中非常有用。让我们解释一下如何使用它们使用 Lumen。
Lumen 有一个目录用于放置所有的中间件,因此我们将在User
微服务上创建一个中间件,以检查消费者是否具有提供的API_KEY
以与我们的应用程序通信。
提示
为了识别我们的消费者,我们建议您使用API_KEY
。这种做法将避免不受欢迎的消费者使用我们的应用程序。
假设我们向客户提供了一个值为RSAy430_a3eGR
的API_KEY
,并且在每个请求中都需要发送这个值。我们可以使用中间件来检查是否提供了这个API_KEY
。创建一个名为App\Http\Middleware\ApiKeyMiddleware.php
的文件,并将以下代码放入其中:
<?php
namespace App\Http\Middleware;
use Closure;
class ApiKeyMiddleware
{
const API_KEY = 'RSAy430_a3eGR';
public function handle($request, Closure $next)
{
if ($request->input('api_key') !== self::API_KEY) {
die('API_KEY invalid');
}
return $next($request);
}
}
一旦我们创建了我们的中间件,我们必须将其添加到应用程序中。为此,请在bootstrap/app.php
文件中包含以下行:
$app->middleware([App\Http\Middleware\ApiKeyMiddleware::class]);
$app->routeMiddleware(['api_key' => App\Http\Middleware
\ApiKeyMiddleware::class]);
现在,我们可以将中间件添加到routes.php
文件中。它可以放在不同的地方;您可以将它放在单个请求中,甚至整个组中,如下所示:
$app->group([
**'middleware' => 'api_key'**,
'prefix' => 'api/v1',
'namespace' => 'App\Http\Controllers'],
function($app) {
$app->get('user', 'UserController@index');
$app->get('user/{id}', 'UserController@get');
$app->post('user', 'UserController@create');
在 Postman 上试试看;向http://localhost:8084/api/v1/user
发出 HTTP POST 调用。您将看到一个消息,上面写着API_KEY invalid
。现在做同样的调用,但添加一个名为API_KEY
值为RSAy430_a3eGR
的参数;请求通过中间件并到达函数。
实现微服务调用
既然我们知道如何发出调用,让我们创建一个更复杂的例子。我们将构建我们的战斗系统。如前几章所述,战斗是两名玩家之间为了从失败者那里获取秘密而进行的战斗。我们的战斗系统将由三轮组成,在每一轮中都会进行一次掷骰子;赢得大多数轮次的用户将成为赢家,并从失败者的钱包中获得一个秘密。
提示
我们建议使用一些测试开发实践(TDD、BDD 或 ATDD),正如我们之前解释的那样;您可以在前面的章节中看到一些例子。在本章中,我们不会再包含更多的测试。
在 Battle 微服务中,我们可以在BattleController.php
文件中创建一个用于战斗的函数;让我们看一个有效的结构方法:
public function duel(Request $request)
{
return response()->json([]);
}
不要忘记在routes.php
文件中添加端点,将 URI 链接到我们的方法:
$app->post('battle/duel', 'BattleController@duel');
在这一点上,Battle 微服务的duel
方法将可用;用 Postman 试试看。
现在,我们将实现决斗。我们需要为骰子创建一个新的类。为了存储一个新的类,我们将在根目录下创建一个名为Algorithm
的新文件夹,文件Dice.php
将包含骰子方法:
<?php
namespace App\Algorithm;
class Dice
{
const TOTAL_ROUNDS = 3;
const MIN_DICE_VALUE = 1;
const MAX_DICE_VALUE = 6;
public function fight()
{
$totalRoundsWin = [
'player1' => 0,
'player2' => 0
];
for ($i = 0; $i < self::TOTAL_ROUNDS; $i++) {
$player1Result = rand(
self::MIN_DICE_VALUE,
self::MAX_DICE_VALUE
);
$player2Result = rand(
self::MIN_DICE_VALUE,
self::MAX_DICE_VALUE
);
$roundResult = ($player1Result <=> $player2Result);
if ($roundResult === 1) {
$totalRoundsWin['player1'] =
$totalRoundsWin['player1'] + 1;
} else if ($roundResult === -1) {
$totalRoundsWin['player2'] =
$totalRoundsWin['player2'] + 1;
}
}
return $totalRoundsWin;
}
}
一旦我们开发了Dice
类,我们将从BattleController
中调用它,以查看谁赢得了战斗。首先要做的是在BattleController.php
文件的顶部包含Dice
类,然后我们可以创建一个我们将用于决斗的算法实例(这是一个很好的做法,以便在将来更改决斗系统;例如,如果我们想要使用基于能量点或卡牌游戏的决斗,我们只需要更改Dice
类为新的类)。
duel
函数将返回一个 JSON,其中包含战斗结果。请查看BattleController.php
中包含的新突出显示的代码:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
**use App\Algorithm\Dice;**
class BattleController extends Controller
{
**protected $battleAlgorithm = null;**
**protected function setBattleAlgorithm()**
**{**
**$this->battleAlgorithm = new Dice();**
**}**
/** ... Code omitted ... **/
**public function duel(Request $request)**
**{**
**$this->setBattleAlgorithm();**
**$duelResult = $this->battleAlgorithm->fight();**
**return response()->json(**
**[**
**'player1' => $request->input('userA'),**
** 'player2' => $request->input('userB'),**
** 'duelResults' => $duelResult**
** ]**
** );**
**}**
}
试试使用 Postman;记住这是一个 HTTP POST 请求到 URI http://localhost:8081/api/v1/battle/duel
(注意我们在 Docker 上设置了端口 8081 用于战斗微服务),并且需要发送参数userA
和userB
,其中包含您想要的用户名。如果一切正确,您应该会收到类似于这样的响应:
{
"player1": "John",
"player2": "Joe",
"duelResults": {
"player1": 2,
"player2": 1
}
}
请求生命周期
请求生命周期是请求被返回给消费者作为响应之前的地图。了解这个过程是很有趣的,以避免在请求过程中出现问题。每个框架都有自己的请求方式,但它们都非常相似,并遵循一些像 Lumen 一样的基本步骤:
-
每个请求都由
public/index.php
管理。它没有太多的代码,只是加载由 Composer 生成的自动加载程序定义,并从bootstrap/app.php
创建应用程序的实例。 -
请求被发送到 HTTP 内核,它定义了一些必要的事情,比如错误处理、日志记录、应用环境和其他在请求执行之前应该添加的必要任务。HTTP 内核还定义了请求在检索应用程序之前必须通过的中间件列表。
-
一旦请求通过了 HTTP 内核并到达应用程序,它就会到达路由并尝试将其与正确的路由匹配。
-
它执行控制器和对应路由的代码,并创建并返回一个响应对象。
-
HTTP 头和响应对象内容被返回给客户端。
这只是请求-响应工作流程的一个基本示例;真实的过程更加复杂。你应该考虑到 HTTP 内核就像一个大黑匣子,它做了一些对开发者来说并不可见的事情,所以理解这个例子对本章来说已经足够了。
使用 Guzzle 进行微服务之间的通信
在微服务中最重要的事情之一是它们之间的通信。大多数情况下,单个微服务并没有消费者所请求的所有信息,因此微服务需要调用不同的微服务来获取所需的数据。
例如,遵循最后一个例子,对两个用户之间的决斗,如果我们想在同一个调用中提供有关战斗中包含的所有用户的信息,并且我们在 Battle 微服务中没有特定的方法来获取用户信息,我们可以从用户微服务中请求用户信息。为了实现这一点,我们可以使用 PHP 核心功能 cURL,或者使用一个包装 cURL 的外部库,提供一个更简单的接口,比如GuzzleHttp
。
要在我们的项目中包含GuzzleHttp
,我们只需要在 Battle 微服务的composer.json
文件中添加以下行:
{
// Code omitted
"require": {
"php": ">=5.5.9",
"laravel/lumen-framework": "5.2.*",
"vlucas/phpdotenv": "~2.2",
**"guzzlehttp/guzzle": "~6.0"**
},
// Code omitted
}
一旦我们保存了更改,我们可以进入我们的 PHP-FPM 容器并运行以下命令:
**cd /var/www/html && compose update**
GuzzleHttp
将被安装并准备在项目中使用。
为了从User
微服务中获取用户信息,我们将构建一个方法,将信息提供给Battle
微服务。目前,我们将把用户信息存储在数据库中,所以我们有一个数组来存储它。在User
微服务的app/Http/Controllers/UserController.php
中,我们将添加以下行:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class UserController extends Controller
{
**protected $userCache = [**
**1 => [**
**'name' => 'John',**
**'city' => 'Barcelona'**
**],**
**2 => [**
**'name' => 'Joe',**
**'city' => 'Paris'**
**]**
**];**
/** ... Code omitted ... **/
**public function get($id)**
**{**
**return response()->json(**
**$this->userCache[$id]**
**);**
**}**
/** ... Code omitted ... **/
}
您可以通过在 Postman 上进行 GET 调用http://localhost:8084/api/v1/user/2
来测试这种新方法;你应该会得到类似这样的东西:
{
"name": "Joe",
"city": "Paris"
}
一旦我们知道获取用户信息的方法是有效的,我们将从Battle
微服务中调用它。出于安全原因,Docker 上的每个容器都与其他容器隔离,除非您在docker-composer.yml
文件的链接部分指定要连接。要这样做,使用以下方法:
- 停止 Docker 容器:
**docker-compose stop**
- 通过添加以下行来编辑
docker-compose.yml
文件:
microservice_battle_fpm:
build: ./microservices/battle/php-fpm/
volumes_from:
- source_battle
links:
- autodiscovery
**- microservice_user_nginx**
expose:
- 9000
environment:
- BACKEND=microservice-battle-nginx
- CONSUL=autodiscovery
- 启动 Docker 容器:
**docker-compose start**
从现在开始,Battle
微服务应该能够看到User
微服务,所以让我们调用User
微服务以获取来自 Battle 微服务的用户信息。为此,我们需要在BattleController.php
文件中包含GuzzleHttp\Client
,并在 duel 函数中创建一个 Guzzle 实例来使用它:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Algorithm\Dice;
**use GuzzleHttp\Client;**
class BattleController extends Controller
{
**const USER_ENDPOINT = 'http://microservice_user_nginx/api
/v1/user/';**
/** ... Code omitted ... **/
public function duel(Request $request)
{
$this->setBattleAlgorithm();
$duelResult = $this->battleAlgorithm->fight();
**$client = new Client(['verify' => false]);**
** $player1Data = $client->get(
self::USER_ENDPOINT . $request->input('userA'));**
** $player2Data = $client->get(
self::USER_ENDPOINT . $request->input('userB'));**
return response()->json(
[
'player1' => **json_decode($player1Data->getBody())**,
'player2' => **json_decode($player2Data->getBody())**,
'duelResults' => $duelResult
]
);
}
}
修改完成后,我们可以通过在 Postman 上执行与之前相同的调用来再次测试它--http://localhost:8081/api/v1/battle/duel
(记得进行 HTTP POST 调用,并发送参数userA
值为 1 和userB
值为 2)。响应应该类似于这样(请注意,这次用户信息来自User
微服务,尽管我们正在调用Battle
微服务):
{
"player1": {
"name": "John",
"city": "Barcelona"
},
"player2": {
"name": "Joe",
"city": "Paris"
},
"duelResults": {
"player1": 0,
"player2": 3
}
}
数据库操作
在前几章中,我们解释了您可以为应用程序拥有单个或多个数据库。这是微服务的优势之一;当您意识到某个微服务负载过大时,您可以将数据库分成单个数据库用于特定的微服务,从而实现单个微服务的扩展。
对于我们的示例,我们将为 secrets 微服务创建一个单独的数据库。对于存储软件,我们决定使用Percona(一个 MySQL 分支),但请随意使用您喜欢的任何数据库。
在 Docker 中创建数据库容器非常简单。我们只需要编辑我们的docker-compose.yml
文件,并将microservice_secret_fpm
服务的链接部分更改为以下内容:
links:
- autodiscovery
- microservice_secret_database
在我们所做的更改中,我们告诉 Docker 现在我们的microservice_secret_fpm
可以与我们的microservice_secret_database
容器进行通信。让我们创建我们的数据库容器。要做到这一点,我们只需要在docker-compose.yml
文件中定义服务,如下所示:
microservice_secret_database:
build: ./microservices/secret/database/
environment:
- CONSUL=autodiscovery
- MYSQL_ROOT_PASSWORD=mysecret
- MYSQL_DATABASE=finding_secrets
- MYSQL_USER=secret
- MYSQL_PASSWORD=mysecret
ports:
- 6666:3306
在上述代码中,我们告诉应用程序 Docker 可以在哪里找到Dockerfile
,我们在其中设置了一些环境变量,并且我们正在将我们机器的端口 6666 映射到容器上的默认 Percona 端口。关于 Docker 和 Percona 官方镜像的一个重要事项是,使用一些特殊的环境变量,容器将为您创建数据库和一些用户。
您可以在我们的 Docker GitHub 存储库中找到所有所需的文件,标签为chapter-05-basic-database
。
现在我们的容器准备就绪,是时候设置我们的数据库了。Lumen 为我们提供了一个工具来进行迁移和管理迁移,因此我们可以知道我们的数据库是否是最新的,如果我们正在与团队合作。迁移是一个用于在我们的数据库中创建和回滚操作的脚本。
要在 Lumen 中进行迁移,首先需要进入您的 secrets PHP-FPM 容器。要做到这一点,您只需要打开终端并执行以下命令:
**docker exec -it docker_microservice_secret_fpm_1 /bin/bash**
上述命令将在容器中创建一个交互式终端,并运行 bash 控制台,以便您可以开始输入命令。请确保您在项目根目录下:
**cd /var/www/html**
一旦您在项目根目录下,您需要创建一个迁移;可以通过以下命令完成:
**php artisan make:migration create_secrets_table**
上述命令将在database/migrations/2016_11_09_200645_create_secrets_table.php
文件中创建一个空的迁移模板,如下所示:
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateSecretsTable extends Migration
{
public function up()
{
}
public function down()
{
}
}
上述代码片段是由 artisan 命令生成的示例。如您所见,迁移脚本中有两种方法。在up()
方法中编写的所有内容都将在执行迁移时使用。在执行回滚时,down()
方法中的所有内容将用于撤消您的更改。让我们用以下内容填充我们的迁移脚本:
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateSecretsTable extends Migration
{
public function up()
{
Schema::create(
'secrets',
function (Blueprint $table) {
$table->increments('id');
$table->string('name', 255);
$table->double('latitude');
$table->double('longitude')
->nullable();
$table->string('location_name', 255);
$table->timestamps();
}
);
}
public function down()
{
Schema::drop('secrets');
}
}
上述示例非常容易理解。在up()
方法中,我们正在创建一个带有一些列的 secrets 表。这是创建表的一种快速简单的方法,类似于使用CREATE TABLE
SQL 语句。我们的down()
方法将撤消所有更改,而在我们的情况下,撤消更改的方法是从我们的数据库中删除 secrets 表。
现在,您可以通过以下命令从终端执行迁移:
**php artisan migrate
Migrated: 2016_11_09_200645_create_secrets_table**
迁移命令将运行我们迁移脚本的up()
方法并创建我们的 secrets 表。
如果您需要了解迁移脚本的执行状态,您可以执行php artisan migrate:status
,输出将告诉您当前状态:
+------+----------------------------------------+
| Ran? | Migration |
+------+----------------------------------------+
| Y | 2016_11_09_200645_create_secrets_table |
+------+----------------------------------------+
在这一点上,您可以连接到您的机器的 6666 端口,使用您喜欢的数据库客户端;我们的数据库已经准备好在我们的应用程序中使用。
现在想象一下,您需要对数据库所做的更改进行回滚;在 Lumen 中很容易做到,您只需要运行以下命令:
**php artisan migrate:rollback**
一旦我们创建了表,我们可以通过在 Lumen 上进行种子或手动填充我们的表。我们建议您使用种子,因为这是一种轻松跟踪任何更改的方法。要填充我们的新表,我们只需要创建一个新文件database/seeds/SecretsTableSeeder.php
,其中包含以下内容:
<?php
use Illuminate\DatabaseSeeder;
class SecretsTableSeeder extends Seeder
{
public function run()
{
DB::table('secrets')->delete();
DB::table('secrets')->insert([
[
'name' => 'amber',
'latitude' => 42.8805,
'longitude' => -8.54569,
'location_name' => 'Santiago de Compostela'
],
[
'name' => 'diamond',
'latitude' => 38.2622,
'longitude' => -0.70107,
'location_name' => 'Elche'
],
[
'name' => 'pearl',
'latitude' => 41.8919,
'longitude' => 2.5113,
'location_name' => 'Rome'
],
[
'name' => 'ruby',
'latitude' => 53.4106,
'longitude' => -2.9779,
'location_name' => 'Liverpool'
],
[
'name' => 'sapphire',
'latitude' => 50.08804,
'longitude' => 14.42076,
'location_name' => 'Prague'
]
]);
}
}
在前面的类中,我们定义了一个run()
方法,每当我们想要向数据库中填充一些数据时,它都会被执行。在我们的示例中,我们添加了我们在应用程序中硬编码的不同秘密。现在我们的SecretsTableSeeder
类已经准备好了,我们需要编辑database/seeds/DatabaseSeeder.php
,以调用我们的自定义 seeder。如果您更改run()
方法以匹配以下代码片段,您的微服务将具有一些数据:
public function run()
{
$this->call('SecretsTableSeeder');
}
一旦一切就绪,现在是执行 seeder 的时候了,所以再次进入 secrets PHP-FPM 容器,并运行以下命令:
**php artisan db:seed**
提示
如果artisan
抛出一个错误,告诉您找不到表,那是由于 composer 自动加载系统。执行composer dump-autoload
将解决您的问题,然后您可以再次运行artisan
命令而不会出现任何问题。
在这一点上,您将创建并填充了您的秘密表,其中包含一些示例记录。
在 Lumen 中使用数据库是开箱即用的,并使用 Eloquent 作为 ORM。
对象关系映射(ORM)是一种编程模型,它将数据库表转换为实体,使开发人员的工作更容易,使他们能够更快地进行基本查询并使用更少的代码。
我们建议在将来要将数据库迁移到不同系统时使用 ORM,以避免语法问题。正如您所知,SQL 语言之间有一些差异--例如,获取确定数量的行的方式:
SELECT * FROM secrets LIMIT 10 //MySQL
SELECT TOP 10 * FROM secrets //SqlServer
SELECT * FROM secrets WHERE rownum<=10; //Oracle
因此,如果您使用 ORM,您不需要记住 SQL 语法,因为它抽象了开发人员与数据库操作的关系,开发人员只需要考虑开发。
以下是 ORM 的优势:
-
数据访问层中的安全性防御攻击
-
与数据库一起工作很容易和快速
-
您使用的数据库并不重要
在开发公共 API 时,建议使用 ORM,以避免安全问题,并使查询对团队的其他成员更容易和更清晰。
要设置您的 Lumen 项目与 Eloquent 一起工作,您只需要打开bootstrap/app.php
文件,并取消注释以下行(大约在第 28 行附近):
$app->withEloquent();
此外,您需要设置位于.env.example
文件中的数据库参数,您可以在每个微服务的根文件夹中找到它。编辑文件完成后,您需要将其重命名为.env
(从文件名中删除.example
):
DB_CONNECTION=mysql
DB_HOST=microservice_secret_database
DB_PORT=3306
DB_DATABASE=finding_secrets
DB_USERNAME=secret
DB_PASSWORD=mysecret
正如您所看到的,我们在 Docker 中设置的数据库、用户名和密码在数据库操作部分的开始时已经设置好了。
为了使用我们的数据库,我们需要创建我们的模型,因为我们有一个finding_secrets
数据库,所以在app/Models/Secret.php
文件中拥有一个秘密模型是有意义的:
<?php
namespace App\Model;
use Illuminate\Database\Eloquent\Model;
class Secret extends Model
{
protected $table = 'secrets';
protected $fillable = [
'name',
'latitude',
'longitude',
'location_name'
];
}
上面的代码非常容易理解;我们只需要定义我们的模型类和数据库$table
之间的关系以及$fillable
字段的列表。这是您的模型所需的最少内容。
Fractal 是一个为我们的 RESTful API 提供演示和转换层的库。使用这个库将使我们的响应保持一致,美观和干净。要安装这个库,我们只需要打开我们的 PHP-FPM 容器的composer.json
,将"league/fractal": "⁰.14.0"
添加到所需元素的列表中,并执行composer update
。
提示
安装 fractal 的另一种方法是在您的 PHP-FMP 终端上运行以下命令:composer require league/fractal
。请注意,此命令将使用最新版本,可能与我们的示例不兼容。
现在安装了 fractal,现在是时候定义我们的秘密转换器了。您可以将转换器视为一种简单的方式,将模型转换为一个一致的响应。在您的 IDE 中创建app/Transformers/SecretTransformer.php
文件,并插入以下内容:
<?php
namespace App\Transformers;
use App\Model\Secret;
use League\Fractal\Transformer\Abstract;
class SecretTransformer extends TransformerAbstract
{
public function transform(Secret $secret)
{
return [
'id' => $secret->id,
'name' => $secret->name,
'location' => [
'latitude' => $secret->latitude,
'longitude' => $secret->longitude,
'name' => $secret->location_name
]
];
}
}
从上述代码中可以看出,我们正在指定秘密模型的转换,因为我们希望所有位置都被分组,所以我们在位置密钥内添加了秘密的所有位置信息。将来,如果您需要添加新字段或修改结构,现在一切都在一个地方,将会让作为开发人员的生活变得轻松。
为了示例目的,我们将修改我们的 secrets 控制器的 index 方法,以使用 fractal 从数据库返回响应。打开您的app/Http/Controllers/SecretController.php
并插入以下用法:
use App\Model\Secret;
use App\Transformers\SecretTransformer;
use League\Fractal\Manager;
use League\Fractal\Resource\Collection;
现在,您需要更改index()
如下:
public function index(
Manager $fractal,
SecretTransformer $secretTransformer,
Request $request)
{
$records = Secret::all();
$collection = new Collection(
$records,
$secretTransformer
);
$data = $fractal->createData($collection)
->toArray();
return response()->json($data);
}
首先,我们在方法签名中添加了一些我们将需要的对象实例,由于 Lumen 内置了依赖注入,我们不需要做任何额外的工作。它们将准备好在我们的方法内使用。我们的方法定义了以下内容:
-
从数据库获取所有秘密记录
-
使用我们的转换器创建一个秘密集合
-
fractal 库从我们的集合创建一个数据数组
-
我们的控制器将我们转换后的集合作为 JSON 返回
如果您在 Postman 中尝试,响应将类似于这样:
{
"data": [
{
"id": 1,
"name": "amber",
"location": {
"latitude": 42.8805,
"longitude": -8.54569,
"name": "Santiago de Compostela"
}
},
/** Code omitted ** /
{
"id": 5,
"name": "sapphire",
"location": {
"latitude": 50.08804,
"longitude": 14.42076,
"name": "Prague"
}
}
]
}
我们所有的记录现在以一致的方式从数据库返回,所有都在我们的"data"
响应键内具有相同的结构。
错误处理
在接下来的部分中,我们将解释如何验证我们微服务中的输入数据以及如何处理可能的错误。过滤我们收到的请求非常重要,不仅是为了通知消费者请求无效,还要避免安全问题或我们不希望的参数。
验证
Lumen 有一个很棒的验证系统,所以我们不需要安装任何东西来验证我们的数据。请注意,以下验证规则可以放在routes.php
或控制器内的每个函数中。我们将在函数内使用它以更清晰地使用。
要使用我们的数据库进行验证系统,我们需要对其进行配置。这非常简单;我们只需要在根目录中创建一个config/database.php
文件(和文件夹),并插入以下代码:
<?php
return [
'default' => 'mysql',
'connections' => [
'mysql' => [
'driver' => 'mysql',
'host' => env('DB_HOST'),
'database' => env('DB_DATABASE'),
'username' => env('DB_USERNAME'),
'password' => env('DB_PASSWORD'),
'collation' => 'utf8_unicode_ci'
]
]
];
然后,您需要在bootstrap/app.php
文件中添加数据库行:
$app->withFacades();
$app->withEloquent();
**$app->configure('database');**
完成此操作后,Lumen 验证系统已准备就绪。因此,让我们编写规则来验证创建Secrets
微服务上的新秘密的 POST 方法:
public function create(Request $request)
{
**$this->validate(**
**$request,**
**[**
**'name' => 'required|string|unique:secrets,name',**
**'latitude' => 'required|numeric',**
** 'longitude' => 'required|numeric',**
** 'location_name' => 'required|string'**
**]**
** );**
在上述代码中,我们确认参数应该通过规则。字段'name'
是必需的;它是一个字符串,而且在secrets
表中应该是唯一的。字段'latitude'
和'longitude'
是数字且也是必需的。此外,'location_name'
字段也是必需的,它是一个字符串。
提示
在 Lumen 文档中(lumen.laravel.com/docs
),您可以查看所有可用的选项来验证您的输入。
您可以在 Postman 中尝试它;创建一个带有以下application/json
参数的 POST 请求来检查插入失败(请注意,您也可以像表单数据键值一样发送它):
{
"name": "amber",
"latitude":"1.23",
"longitude":"-1.23",
"location_name": "test"
}
上述请求将尝试验证一个与之前记录相同名称的新密钥。根据我们的验证规则,我们不允许消费者创建具有相同名称的新密钥,因此我们的微服务将以422
错误响应,并返回以下内容:
{
"name": [
"The name has already been taken."
]
}
请注意,状态码(或错误码)对于通知您的消费者其请求发生了什么非常重要;如果一切正常,应该返回200
状态码。Lumen 默认返回200
状态码。
在第十一章最佳实践和约定中,我们将看到您可以在应用程序中使用的所有可用代码的完整列表。
一旦验证规则通过,我们应该将数据保存在数据库中。这在 Lumen 中非常简单,只需这样做:
$secret = Secret::create($request->all());
if ($secret->save() === false) {
// Manage Error
}
完成后,我们将在数据库中获得我们的新记录。Lumen 提供了其他方法来创建其他任务,如填充、更新或删除。
管理异常
有必要知道,我们必须管理应用程序中发生的可能错误。为此,Lumen 为我们提供了可以使用的异常列表。
因此,现在我们将尝试在尝试调用另一个微服务时获得异常。为此,我们将从用户微服务调用密钥微服务。
请记住,出于安全原因,如果您没有将一个容器与另一个容器链接起来,它们就无法相互看到。编辑您的docker-compose.yml
,并从microservice_user_fpm
到microservice_secret_nginx
添加链接,如下所示:
microservice_user_fpm:
build: ./microservices/user/php-fpm/
volumes_from:
- source_user
links:
- autodiscovery
**- microservice_secret_nginx**
expose:
- 9000
environment:
- BACKEND=microservice-user-nginx
- CONSUL=autodiscovery
现在,您应该再次启动您的容器:
**docker-compose start**
还要记住,我们需要像之前在Battle
微服务和User
微服务上一样安装GuzzleHttp
,以便调用Secret
微服务。
我们将在User
微服务中创建一个新的函数,以显示user
钱包中保存的秘密。
将此添加到app/Http/routes.php
:
$app->get(
'user/{id}/wallet',
'UserController@getWallet'
);
然后,我们将创建一个从user
钱包中获取秘密的方法--例如,看一下这个:
public function getWallet($id)
{
/* ... Code ommited ... */
$client = new Client(['verify' => false]);
try {
$remoteCall = $client->get(
'http://microservice_secret_nginx /api/v1/secret/1');
} catch (ConnectException $e) {
/* ... Code ommited ... */
throw $e;
} catch (ServerException $e) {
/* ... Code ommited ... */
} catch (Exception $e) {
/* ... Code ommited ... */
}
/* ... Code ommited ... */
}
我们正在调用Secret
微服务,但我们将修改 URI 以获得ConnectException
,所以请修改它:
$remoteCall = $client->get(
**'http://this_uri_is_not_going_to_work'
**);
在 Postman 上试一试;您将收到一个ConnectException
错误。
现在,再次正确设置 URI,并在密钥微服务端放入一些错误代码:
public function get($id)
{
this_function_does_not_exist();
}
上述代码将为密钥微服务返回错误500;但我们是从User
微服务调用它,所以现在我们将收到ServerException
错误。
在 Lumen 中,通过捕获它们来处理所有异常的类是Handler
类(位于app/Exceptions/Handler.php
)。这个类有两个定义的方法:
-
report()
: 这允许我们将异常发送到外部服务--例如,一个集中的日志系统。 -
render()
: 这将我们的异常转换为 HTTP 响应。
我们将更新render()
方法以返回自定义错误消息和错误码。想象一下,我们想捕获 Guzzle 的ConnectException
并返回一个更友好和易于管理的错误。看一下以下代码:
/** Code omitted **/
use SymfonyComponentHttpFoundationResponse;
use GuzzleHttpExceptionConnectException;
/** Code omitted **/
public function render($request, Exception $e)
{
switch ($e) {
case ($e instanceof ConnectException) :
return response()->json(
[
'error' => 'connection_error',
'code' => '123'
],
Response::HTTP_SERVICE_UNAVAILABLE
);
break;
default :
return parent::render($request, $e);
break;
}
}
在这里,我们正在检测 Guzzle 的ConnectException
并提供自定义的错误消息和代码。使用这种策略有助于我们知道哪里出了问题,并允许我们根据我们正在处理的错误采取行动。例如,我们可以将代码123
分配给所有连接错误;因此,当我们检测到这个问题时,我们可以避免其他服务的级联故障或通知开发人员。
异步和队列
在微服务中,队列是帮助提高性能和减少执行时间的最重要的事情之一。
例如,如果您需要在客户完成应用程序的注册流程时向客户发送电子邮件,应用程序不需要立即发送它;它可以放入队列中,在服务器不太忙的时候几秒钟后发送。此外,它是异步的,因为客户不需要等待电子邮件。应用程序将显示消息注册完成,并且电子邮件将被放入队列并同时处理。
另一个例子是当您需要处理非常繁重的工作负载时,您可以有一台专用的硬件更好的机器来执行这些任务。
最著名的内存数据结构存储之一是Redis。您可以将其用作数据库、缓存层、消息代理,甚至作为队列存储。Redis 的关键点之一是它支持不同的结构类型,例如字符串、哈希、列表和有序集等。这个软件被设计成易于管理和具有高性能,因此它是 Web 行业的事实标准。
Redis 的主要用途之一是作为缓存存储。您可以永久存储数据,也可以添加过期时间,而无需担心何时需要删除数据;Redis 会为您完成。由于易用性、良好的支持和可用的库,Redis 适用于任何规模的项目。
我们将在User
微服务上构建一个示例,使用基于 Redis 的队列发送电子邮件。
Lumen 为我们提供了使用数据库的队列系统;但也有其他选项可用,可以使用外部服务。在我们的示例中,我们将使用 Redis,因此让我们看看如何在 Docker 上安装它。
打开docker-compose.yml
并添加以下容器描述:
microservice_user_redis:
build: ./microservices/user/redis/
links:
- autodiscovery
expose:
- 6379
ports:
- 6379:6379
您还需要更新microservice_user_fpm
容器的链接部分以匹配以下内容:
links:
- autodiscovery
- microservice_secret_nginx
- microservice_user_redis
在前面的代码片段中,我们为 Redis 定义了一个新的容器,并将其链接到microservice_user_fpm
容器。现在打开microservices/user/redis/Dockerfile
文件,并添加以下代码以使最新的 Redis 版本可用:
FROM redis:latest
要在我们的 Lumen 项目中使用 Redis,我们需要通过 composer 安装一些依赖项。因此,打开您的composer.json
,并将以下行添加到所需部分,然后在用户 PHP-FPM 容器内执行 composer update:
"predis/predis": "~1.0",
"illuminate/redis": "5.2.*"
对于电子邮件支持,您只需要将以下行添加到 composer.json 文件的 require 部分:
"illuminate/mail": "5.2.*"
安装完 Redis 后,我们需要设置环境。首先,我们需要在.env
文件中添加以下行:
QUEUE_DRIVER=redis
CACHE_REDIS_CONNECTION=default
REDIS_HOST=microservice_user_redis
REDIS_PORT=6379
REDIS_DATABASE=0
现在,我们需要在config/database.php
文件中添加 Redis 配置;如果您添加了其他数据库(例如 MySQL),请将其放在那之后,但在返回数组内部:
<?php
return [
'redis' => [
'client' => 'predis',
'cluster' => false,
'default' => [
'host' => env('REDIS_HOST', 'localhost'),
'password' => env('REDIS_PASSWORD', null),
'port' => env('REDIS_PORT', 6379),
'database' => env('REDIS_DATABASE', 0),
],
]
];
还需要将vendor/laravel/lumen-framework/config/queue.php
文件复制到config/queue.php
。
最后,不要忘记在bootstrap/app.php
文件上注册所有内容,并添加以下行,这样我们的应用程序就能够读取我们刚刚设置的配置了:
$app->register(
Illuminate\Redis\RedisServiceProvider::class
);
$app->configure('database');
$app->configure('queue');
现在,我们将解释如何在我们的User
微服务中构建一个队列。想象一下,在应用程序中,当创建新用户时,我们希望将第一个秘密作为礼物赠送给他们;因此,在用户创建之后,我们将调用秘密微服务以获取用户的第一个秘密。这不是一个优先级很高的任务,这就是为什么我们将使用队列来执行此任务的原因。
创建一个新文件app/Jobs/GiftJob.php
,其中包含以下代码:
<?php
namespace AppJobs;
use GuzzleHttpClient;
class GiftJob extends Job
{
public function __construct()
{
}
public function handle()
{
$client = new Client(['verify' => false]);
$remoteCall = $client->get(
'http://microservice_secret_nginx /api/v1/secret/1'
);
/* Do stuff with the return from a remote service, for
example save it in the wallet */
}
}
您可以修改类构造函数以向作业传递数据,例如,包含所有用户信息的对象实例。
现在,我们需要从我们的app/Http/Controllers/UserController.php
控制器实例化作业:
** use AppJobsGiftJob;**
public function create(Request $request)
{
/* ... Code omitted (validate & save data) ... */
**$this->dispatch(new GiftJob());**
/* ... Code omitted ... */
}
一旦队列任务完成,我们必须在后台启动队列工作程序。以下代码将为您完成这项工作,并且它将一直运行直到线程死亡,您可以添加一个监督程序来确保队列继续工作:
php artisan queue:work
您可以通过调用http://localhost:8084/api/v1/user
在 Postman 上尝试一下。一旦您调用此方法,Lumen 将把工作放在 Redis 上,并且它将可供队列工作者使用。一旦工作者从 Redis 获取并处理任务,您将在终端中看到以下下一个消息:
** [2016-11-13 17:59:23] Processed: AppJobsGiftJob**
Lumen 为我们提供了更多的队列可能性。例如,您可以为队列设置优先级,为作业指定超时,甚至为任务设置延迟。您可以在 Lumen 文档中找到这些信息。
缓存
许多时候,消费者请求相同的内容,应用程序返回相同的信息。在这种情况下,缓存是避免不断处理相同请求并更快地返回所需数据的解决方案。
缓存用于不经常更改的数据,以便在不处理请求的情况下获得预先计算的响应。工作流程如下:
-
消费者第一次请求某些信息时,应用程序会处理请求并获取所需的数据。
-
它将请求所需的数据保存在缓存中,并设置我们定义的过期时间。
-
它将数据返回给消费者。
下一次消费者请求某些内容时,您需要执行以下操作:
-
检查请求是否在应用程序缓存中,并且尚未过期。
-
返回缓存中的数据。
因此,在我们的示例中,我们将在位置微服务中使用缓存,以避免多次请求最接近的秘密。
我们应用程序中需要使用缓存层的第一件事是具有一个带有 Redis 的容器(您可以在其他地方找到其他缓存软件,但我们决定使用 Redis,因为它非常容易安装和使用)。打开docker-compose.yml
文件,并添加新的容器定义,如下所示:
microservice_location_redis:
build: ./microservices/location/redis/
links:
- autodiscovery
expose:
- 6379
ports:
- 6380:6379
一旦我们添加了容器,您需要更新microservice_location_fpm
定义的链接部分,以连接到我们的新 Redis 容器,如下所示:
links:
- autodiscovery
**- microservice_location_redis**
在这种情况下,我们的docker/microservices/location/redis/Dockerfile
文件将只包含以下内容(如果需要,可以随意向容器添加更多内容):
FROM redis:latest
不要忘记执行docker-compose stop
以成功终止所有容器,并使用docker-compose up -d
再次启动它们以应用我们的更改。您可以通过在终端中执行docker ps
来检查新容器是否正在运行。
现在是时候对我们的位置微服务源代码进行一些更改,以使用我们的新缓存层。我们需要做的第一个更改是在composer.json
中;将以下所需的库添加到"require"
部分:
"predis/predis": "~1.0",
"illuminate/redis": "5.2.*"
一旦您对composer.json
文件进行更改,请记得执行composer update
以获取库。
现在,打开位置微服务的.env
文件,添加 Redis 设置,如下所示:
CACHE_DRIVER=redis
CACHE_REDIS_CONNECTION=default
REDIS_HOST=microservice_location_redis
REDIS_PORT=6379
REDIS_DATABASE=0
由于我们的环境变量现在已设置好,我们需要创建config/database.php
,内容如下:
<?php
return [
'redis' => [
'client' => 'predis',
'cluster' => false,
'default' => [
'host' => env('REDIS_HOST', 'localhost'),
'password' => env('REDIS_PASSWORD', null),
'port' => env('REDIS_PORT', 6379),
'database' => env('REDIS_DATABASE', 0),
],
]
];
在上述代码中,我们定义了如何连接到我们的 Redis 容器。
Lumen 没有缓存配置,因此您可以将vendor/laravel/lumen-framework/config/cache.php
文件复制到config/cache.php
中。
我们需要对bootstrap/app.php
进行一些小的调整--取消注释$app->withFacades();
并添加以下行:
$app->configure('database');
$app->configure('cache');
$app->register(
Illuminate\Redis\RedisServiceProvider::class
);
我们将更改我们的getClosestSecrets()
方法,以使用缓存而不是每次计算最接近的秘密。打开app/Http/Controllers/LocationController.php
文件,并添加缓存所需的使用:
**use Illuminate\Support\FacadesCache;**
/* ... Omitted code ... */
**const DEFAULT_CACHE_TIME = 1;**
public function getClosestSecrets($originPoint)
{
**$cacheKey = 'L' . $originPoint['latitude'] .
$originPoint['longitude'];**
**$closestSecrets = Cache::remember(
$cacheKey,
self::DEFAULT_CACHE_TIME,
function () use($originPoint) {**
$calculatedClosestSecrets = [];
$distances = array_map(
function($item) use($originPoint) {
return $this->getDistance(
$item['location'],
$originPoint
);
},
self::$cacheSecrets
);
asort($distances);
$distances = array_slice(
$distances,
0,
self::MAX_CLOSEST_SECRETS,
true
);
foreach ($distances as $key => $distance) {
$calculatedClosestSecrets[] =
self::$cacheSecrets[$key];
}
return $calculatedClosestSecrets;
**});**
**return $closestSecrets;**
}
/* ... Omitted code ... */
在上述代码中,我们通过添加缓存层改变了方法的实现;因此,我们首先使用remember()
检查我们的缓存,而不是总是计算最接近的点。如果缓存中没有返回任何内容,我们进行计算并存储结果。
在 Lumen 缓存中保存数据的另一个选项是使用Cache::put('key', 'value', $expirationTime);
,其中$expirationTime
可以是以分钟为单位的时间(整数)或日期对象。
提示
密钥由您定义,因此一个好的做法是生成一个您可以记住的密钥,以便将来重新生成。在我们的示例中,我们使用L
(表示位置),然后是纬度
和经度
来定义密钥。然而,如果您要保存一个 ID,它应该作为密钥的一部分包含在内。
在 Lumen 中,与我们的缓存层一起工作很容易。
要从缓存中获取元素,可以使用"get"
。它允许两个参数--第一个是指定您想要的密钥(必需的),第二个是在缓存中未存储密钥时要使用的值(显然是可选的):
$value = Cache::get('key', 'default');
存储数据的类似方法是Cache::forever($cacheKey, $cacheValue);
,这个调用将永久地将$cacheValue 存储在我们的缓存层中,直到您删除或更新它。
如果您没有为存储的元素指定过期时间,那么了解如何删除它们就很重要。在 Lumen 中,如果您知道分配给元素的\(cacheKey,可以使用`Cache::forget(\)cacheKey);来删除它。如果需要删除缓存中存储的所有元素,可以使用简单的
Cache::flush();`来实现。
总结
在本章中,您已经学会了如何开发基于微服务的应用程序的不同部分。现在,您已经具备了处理数据库存储、缓存、微服务之间的通信、队列以及从入口点到应用程序(路由)的请求工作流程以及数据验证的必要知识,直到将数据提供给消费者的时间。在下一章中,您将学习如何监控您的应用程序,以避免和解决应用程序执行过程中发生的问题。
第六章:监控
在上一章中,我们花了一些时间开发我们的示例应用程序。现在是时候开始更高级的主题了。在本章中,我们将向您展示如何监视您的微服务应用程序。跟踪应用程序中发生的一切将帮助您随时了解整体性能,甚至可以找到问题和瓶颈。
调试和性能分析
在开发复杂和大型应用程序时,调试和性能分析是非常必要的,因此让我们解释一下它们是什么,以及我们如何利用这些工具。
什么是调试?
调试是识别和修复编程错误的过程。这主要是一个手动任务,开发人员需要运用他们的想象力、直觉,并且需要有很多耐心。
大多数情况下,需要在代码中包含新的指令,以在执行的具体点或代码中读取变量的值,或者停止执行以了解它是否通过函数。
然而,这个过程可以由调试器来管理。这是一个工具或应用程序,允许我们控制应用程序的执行,以便跟踪每个执行的指令并找到错误,避免必须在我们的代码中添加代码指令。
调试器使用一个称为断点的指令。断点就像它的名字所暗示的那样,是应用程序停止的一个点,以便由开发人员决定要做什么。在那一点上,调试器会提供有关应用程序当前状态的不同信息。
我们稍后将更多地了解调试器和断点。
什么是性能分析?
像调试一样,性能分析是一个过程,用于确定我们的应用在性能方面是否正常工作。性能分析调查应用程序的行为,以了解执行不同代码部分所需的专用时间,以找到瓶颈或在速度或消耗资源方面进行优化。
性能分析通常在开发过程中作为调试的一部分使用,并且需要由专家在适当的环境中进行测量,以获得真实的数据。
有四种不同类型的性能分析器:基于事件的、统计的、支持代码的工具和模拟的。
使用 Xdebug 在 PHP 中进行调试和性能分析
现在我们将在我们的项目中安装和设置 Xdebug。这必须安装在我们的 IDE 上,因此取决于您使用的是哪个,此过程将有所不同,但要遵循的步骤相当相似。在我们的情况下,我们将在 PHPStorm 上安装它。即使您使用不同的 IDE,在安装 Xdebug 之后,在任何 IDE 中调试代码的工作流程基本上是相同的。
调试安装
在我们的 Docker 上安装 Xdebug,我们应该修改适当的Dockerfile
文件。我们将在用户微服务上安装它,所以打开docker/microservices/user/php-fpm/Dockerfile
文件,并添加以下突出显示的行:
**FROM php:7-fpm**
**RUN apt-get update && apt-get -y install**
**git g++ libcurl4-gnutls-dev libicu-dev libmcrypt-dev libpq-dev libxml2-dev unzip zlib1g-dev**
**&& git clone -b php7 https://github.com/phpredis/phpredis.git /usr/src/php/ext/redis**
**&& docker-php-ext-install curl intl json mbstring mcrypt pdo pdo_mysql redis xml**
**&& apt-get autoremove && apt-get autoclean**
**&& rm -rf /var/lib/apt/lists/***
**RUN apt-get update && apt-get upgrade -y && apt-get autoremove -y**
**&& apt-get install -y git libmcrypt-dev libpng12-dev libjpeg-dev libpq-dev mysql-client curl**
**&& rm -rf /var/lib/apt/lists/***
**&& docker-php-ext-configure gd --with-png-dir=/usr --with-jpeg-dir=/usr**
**&& docker-php-ext-install mcrypt gd mbstring pdo pdo_mysql zip**
**&& pecl install xdebug**
**&& rm -rf /tmp/pear**
**&& echo "zend_extension=$(find /usr/local/lib/php/extensions/ -name xdebug.so)n" >> /usr/local/etc/php/conf.d/xdebug.ini**
**&& echo "xdebug.remote_enable=on" >> /usr/local/etc/php/conf.d/xdebug.ini**
**&& echo "xdebug.remote_autostart=off" >> /usr/local/etc/php/conf.d/xdebug.ini**
**&& echo "xdebug.remote_port=9000" >> /usr/local/etc/php/conf.d/xdebug.ini**
**&& curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer**
**RUN echo 'date.timezone="Europe/Madrid"' >> /usr/local/etc/php/conf.d/date.ini
RUN echo 'session.save_path = "/tmp"' >> /usr/local/etc/php/conf.d/session.ini
{{ Omited code }}
RUN curl -sSL https://phar.phpunit.de/phpunit.phar -o /usr/bin/phpunit && chmod +x /usr/bin/phpunit
ADD ./config/php.ini /usr/local/etc/php/
CMD [ "/usr/local/bin/containerpilot",
"php-fpm",
"--nodaemonize"]**
第一个突出显示的块是安装 xdebug
所必需的。&& pecl install xdebug
行用于使用 PECL 安装 Xdebug,其余行设置了xdebug.ini
文件上的参数。第二个是将php.ini
文件从我们的本地机器复制到 Docker。
还需要在php.ini
文件上设置一些值,因此打开它,它位于docker/microservices/user/php-fpm/config/php.ini
,并添加以下行:
memory_limit = 128M
post_max_size = 100M
upload_max_filesize = 200M
[Xdebug]
xdebug.remote_host=**YOUR_LOCAL_IP_ADDRESS**
您应该输入您的本地 IP 地址,而不是YOUR_LOCAL_IP_ADDRESS
,以便在 Docker 中可见,因此 Xdebug 将能够读取我们的代码。
提示
您的本地 IP 地址是您网络内部的 IP,而不是公共 IP。
现在,您可以通过执行以下命令进行构建,以安装调试所需的一切:
**docker-compose build microservice_user_fpm**
这可能需要几分钟。一旦完成,Xdebug 将被安装。
调试设置
现在是时候在我们喜爱的 IDE 上设置 Xdebug 了。正如我们之前所说,我们将使用 PHPStorm,但是请随意使用任何其他 IDE。
我们必须在 IDE 上创建一个服务器,在 PHPStorm 中,可以通过导航到首选项 | 语言和框架 | PHP来完成。因此,添加一个新的,并将name
设置为users
,例如,host
设置为localhost
,port
设置为8084
,debugger
设置为xdebug
。还需要启用使用路径映射以便映射我们的路由。
现在,我们需要导航到工具 | DBGp 代理配置,确保 IDE 密钥字段设置为PHPSTORM
,Host
设置为users
(这个名称必须与你在服务器部分输入的名称相同),Port
设置为9000
。
通过执行以下命令停止和启动 Docker:
**docker-compose stop**
**docker-compose up -d**
设置 PHPStorm 能够像调试器一样监听:
PHPStorm 中监听连接的 Xdebug 按钮
调试输出
现在你已经准备好查看调试器的结果。你只需要在你的代码中设置断点,执行将在那一点停止,给你所有的数据值。要做到这一点,转到你的代码,例如,在UserController.php
文件中,并点击一行的左侧。它会创建一个红点;这是一个断点:
在 PHPStorm 中设置断点
现在,你已经设置了断点并且调试器正在运行,所以现在是时候用 Postman 发起一个调用来尝试调试器了。通过执行一个 POST 调用到http://localhost:8084/api/v1/user
,参数为api_key = RSAy430_a3eGR 和 XDEBUG_SESSION_START = PHPSTORM
。执行将在断点处停止,从那里开始你就有了执行控制:
PHPStorm 中的调试器控制台
注意你在变量侧的所有参数的当前值。在这种情况下,你可以看到test
参数设置为"this is a test"
;我们在断点之前的两行分配了这个值。
正如我们所说,现在我们控制了执行;三个基本功能如下:
-
步过: 这将继续执行下一行。
-
步入: 这将在函数内部继续执行。
-
步出: 这将在函数外部继续执行。
所有这些基本功能都是逐步执行的,所以它将在下一行停止,不需要任何其他断点。
正如你所看到的,这对于找到代码中的错误非常有用。
性能分析安装
一旦我们安装了 Xdebug,我们只需要在docker/microservices/user/php-fpm/Dockerfile
文件中添加以下行以启用性能分析:
**RUN apt-get update && apt-get upgrade -y && apt-get autoremove -y
&& apt-get install -y git libmcrypt-dev libpng12-dev libjpeg-dev libpq-dev mysql-client curl
&& rm -rf /var/lib/apt/lists/*
&& docker-php-ext-configure gd --with-png-dir=/usr --with-jpeg-dir=/usr
&& docker-php-ext-install mcrypt gd mbstring pdo pdo_mysql zip
&& pecl install xdebug
&& rm -rf /tmp/pear
&& echo "zend_extension=$(find /usr/local/lib/php/extensions/ -name xdebug.so)n" >> /usr/local/etc/php/conf.d/xdebug.ini
&& echo "xdebug.remote_enable=onn" >> /usr/local/etc/php/conf.d/xdebug.ini
&& echo "xdebug.remote_autostart=offn" >> /usr/local/etc/php/conf.d/xdebug.ini
&& echo "xdebug.remote_port=9000n" >> /usr/local/etc/php/conf.d/xdebug.ini**
**&& echo "xdebug.profiler_enable=onn" >> /usr/local/etc/php/conf.d/xdebug.ini**
**&& echo "xdebug.profiler_output_dir=/var/www/html/tmpn" >> /usr/local/etc/php/conf.d/xdebug.ini**
**&& echo "xdebug.profiler_enable_trigger=onn" >> /usr/local/etc/php/conf.d/xdebug.ini**
**&& curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer**
通过profiler_enable
,我们启用了性能分析器,并且输出目录由profiler_output_dir
设置。这个目录应该存在于我们的用户微服务中,以便获取性能分析器输出文件。因此,如果还没有创建,请在/source/user/tmp
上创建。
现在,你可以通过执行以下命令进行构建,以安装调试所需的一切:
**docker-compose build microservice_user_fpm**
这可能需要几分钟。一旦完成,Xdebug 就会被安装。
性能分析设置
它不需要设置,所以只需通过执行以下命令停止和启动 Docker:
**docker-compose stop**
**docker-compose up -d**
设置 PHPStorm 能够像调试器一样监听。
分析输出文件
为了生成性能分析文件,我们需要执行一个调用,就像之前在 Postman 中做的那样,所以随时执行你想要的方法。它将在我们之前创建的文件夹中生成一个名为cachegrind.out.XX
的文件。
如果你打开这个文件,你会注意到它很难理解,但有一些工具可以读取这种类型的内容。PHPStorm 有一个工具位于工具 | 分析 Xdebug Profiler Snapshot**。一旦打开它,你可以选择要分析的文件,然后工具将向你展示所有文件和函数在调用中执行的详细分析。显示花费的时间,调用的次数,以及其他有趣的东西非常有用,可以优化你的代码并找到瓶颈。
错误处理
错误处理是我们管理应用程序中的错误和异常的方式。这对于检测和组织开发中可能发生的所有可能的错误非常重要。
什么是错误处理?
术语错误处理在开发中用于指代在执行过程中响应异常发生的过程。
通常,异常的出现会打断应用程序执行的正常工作流程,并执行注册的异常处理程序,为我们提供更多关于发生了什么以及有时如何避免异常的信息。
PHP 处理错误的方式非常基础。默认的错误消息由文件名、行号和关于浏览器接收到的错误的简短描述组成。在本章中,我们将看到三种不同的处理错误的方式。
为什么错误处理很重要?
大多数应用程序非常复杂和庞大,它们也是由不同的人甚至不同的团队开发的,如果我们正在开发基于微服务的应用程序,那么这些团队可能会更多。想象一下,如果我们混合所有这些东西,项目中可能出现的潜在错误有多少?要意识到应用程序可能存在的所有可能问题或用户可能在其中发现的问题是不可能的。
因此,错误处理以以下两种方式帮助:
- 用户或消费者:在微服务中,错误处理非常有用,因为它允许消费者知道 API 可能存在的问题,也许他们可以弄清楚这是否与 API 调用中引入的参数有关,或者与图像文件大小有关。此外,在微服务中,使用不同的错误状态代码对于让消费者知道发生了什么是非常有用的。您可以在第十一章最佳实践和约定中找到这些代码。
在商业网站上,错误处理可以避免向用户或客户显示诸如PHP 致命错误:无法访问空属性
之类的奇怪消息。而是可以简单地说出现错误,请与我们联系
。
- 开发人员或您自己:它可以让团队的其他成员甚至您自己意识到应用程序中的任何错误,帮助您调试可能出现的问题。有许多工具可以获取这些类型的错误并通过电子邮件将它们发送给您,写入日志文件,或者放在事件日志中,详细说明错误跟踪、函数参数、数据库调用等更有趣的事情。
在微服务中管理错误处理时的挑战
如前所述,我们将解释三种不同的处理错误的方式。当我们使用微服务时,我们必须监视所有可能的错误,以便让微服务知道问题所在。
基本的 die()函数
在 PHP 中,处理错误的基本方法是使用 die()命令。让我们来看一个例子。假设我们想要打开一个文件:
<?php
$file=fopen("test.txt","r");
?>
当执行到达那一点并尝试打开名为test.txt
的文件时,如果文件不存在,PHP 会抛出这样的错误:
**Warning: fopen(test.txt) [function.fopen]: failed to open stream:
No such file or directory in /var/www/html/die_example.php on line 2**
为了避免错误消息,我们可以使用die()
函数,并在其中写上原因,以便执行不会继续:
<?php
if(!file_exists("test.txt")) {
die("The file does not exist");
} else {
$file=fopen("test.txt","r");
}
?>
这只是 PHP 中基本错误处理的一个例子。显然,有更好的方法来处理这个问题,但这是管理错误所需的最低限度。换句话说,避免 PHP 应用程序的自动错误消息比手动停止执行并向用户提供人类语言的原因更有效。
让我们来看一个替代的管理方式。
自定义错误处理
创建一个系统来管理应用程序中的错误比使用die()
函数更好。Lumen 为我们提供了这个系统,并且在安装时已经配置好了。
我们可以通过设置其他参数来配置它。首先是错误详情。通过将其设置为true
,可以获取有关错误的更多信息。为此,需要在你的.env
文件中添加APP_DEBUG
值并将其设置为true
。这是在开发环境中工作的推荐方式,这样开发人员可以更多地了解应用程序的问题,但一旦应用程序部署到生产服务器上,这个值应该设置为false
,以避免向用户提供更多信息。
这个系统通过AppExceptionsHandler
类来管理所有的异常。这个类包含两个方法:report
和render
。让我们来解释一下它们。
报告方法
Report
方法用于记录在你的微服务中发生的异常,甚至可以将它们发送到 Sentry 等外部工具。我们将在本章中详细介绍这一点。
正如前面提到的,这个方法只是在基类上记录问题,但你可以按照自己的需求管理不同的异常。看看下面的例子,你可以如何做到这一点:
public function report(Exception $e)
{
if ($e instanceof CustomException) {
//
} else if ($e instanceof OtherCustomException) {
//
}
return parent::report($e);
}
管理不同错误的方法是instanceof
。正如你所看到的,在前面的例子中,你可以针对每种异常类型有不同的响应。
还可以通过向$dontReport
类添加一个变量来忽略一些异常类型。这是一个你不想报告的不同异常的数组。如果我们在Handle
类上不使用这个变量,那么默认情况下只有404
错误会被忽略。
protected $dontReport = [
HttpException::class,
ValidationException::class
];
渲染方法
如果report
方法用于帮助开发者或你自己,那么渲染方法是用来帮助用户或消费者的。这个方法会将异常以 HTTP 响应的形式返回给用户(如果是网站)或者返回给消费者(如果是 API)。
默认情况下,异常被发送到基类以生成响应,但可以进行修改。看看这段代码:
public function render($request, Exception $e)
{
if ($e instanceof CustomException) {
return response('Custom Message');
}
return parent::render($request, $e);
}
正如你所看到的,render
方法接收两个参数:请求和异常。通过这些参数,你可以为你的用户或消费者做出适当的响应,提供你想要为每个异常提供的信息。例如,通过在 API 文档中给消费者一个错误代码,他们可以在 API 文档中查看。看下面的例子:
public function render($request, Exception $e)
{
if ($e instanceof CustomException) {
return response()->json([
'error' => $e->getMessage(),
'code' => 44 ,
],
Response::HTTP_UNPROCESSABLE_ENTITY);
}
return parent::render($request, $e);
}
消费者将收到一个带有代码 44
的错误消息;这应该在我们的 API 文档中,以及适当的状态码。显然,这可能会因消费者的需求而有所不同。
使用 Sentry 进行错误处理
拥有一个监控错误的系统甚至更好。市场上有很多错误跟踪系统,但其中一个脱颖而出的是 Sentry,它是一个实时的跨平台错误跟踪系统,为我们提供了理解微服务中发生的情况的线索。一个有趣的特性是它支持通过电子邮件或其他媒介进行通知。
使用一个知名的系统有利于你的应用,你正在使用一个值得信赖和知名的工具,而在我们的情况下,它与我们的框架 Lumen 有着简单的集成。
我们需要做的第一件事是在我们的 Docker 环境中安装 Sentry;所以,像往常一样,停止所有的容器,使用docker-compose stop
。一旦所有的容器都停止了,打开docker-compose.yml
文件并添加以下容器:
sentry_redis:
image: redis
expose:
- 6379
sentry_postgres:
image: postgres
environment:
- POSTGRES_PASSWORD=sentry
- POSTGRES_USER=sentry
volumes:
- /var/lib/postgresql/data
expose:
- 5432
sentry:
image: sentry
links:
- sentry_redis
- sentry_postgres
ports:
- 9876:9000
environment:
SENTRY_SECRET_KEY: mymicrosecret
SENTRY_POSTGRES_HOST: sentry_postgres
SENTRY_REDIS_HOST: sentry_redis
SENTRY_DB_USER: sentry
SENTRY_DB_PASSWORD: sentry
sentry_celery-beat:
image: sentry
links:
- sentry_redis
- sentry_postgres
command: sentry celery beat
environment:
SENTRY_SECRET_KEY: mymicrosecret
SENTRY_POSTGRES_HOST: sentry_postgres
SENTRY_REDIS_HOST: sentry_redis
SENTRY_DB_USER: sentry
SENTRY_DB_PASSWORD: sentry
sentry_celery-worker:
image: sentry
links:
- sentry_redis
- sentry_postgres
command: sentry celery worker
environment:
SENTRY_SECRET_KEY: mymicrosecret
SENTRY_POSTGRES_HOST: sentry_postgres
SENTRY_REDIS_HOST: sentry_redis
SENTRY_DB_USER: sentry
SENTRY_DB_PASSWORD: sentry
在上面的代码中,我们首先创建了一个特定的redis
和postgresql
容器,这将被 Sentry 使用。一旦我们有了所需的数据存储容器,我们就添加并链接了 Sentry 核心的不同容器。
**docker-compose up -d sentry_redis sentry_postgres sentry**
上述命令将启动我们设置 Sentry 所需的最小容器。一旦我们第一次启动它们,我们需要配置和填充数据库和用户。我们可以通过在我们为 Sentry 可用的容器上运行一个命令来完成:
**docker exec -it docker_sentry_1 sentry upgrade**
上述命令将完成 Sentry 运行所需的所有设置,并要求您创建一个帐户以作为管理员访问 UI;保存并稍后使用。一旦完成并返回到命令路径,您可以启动我们项目的其余容器:
**docker-compose up -d**
一切准备就绪后,您可以在浏览器中打开http://localhost:9876
,您将看到类似以下的屏幕:
Sentry 登录页面
使用在上一步中创建的用户登录,并创建一个新项目来开始跟踪我们的错误/日志。
提示
不要使用单个 Sentry 项目来存储所有的调试信息,最好将它们分成逻辑组,例如,一个用于用户微服务 API 等。
创建项目后,您将需要分配给该项目的 DSN;打开项目设置并选择客户端密钥选项。在此部分,您可以找到分配给项目的DSN密钥;您将在您的代码中使用这些密钥,以便库知道需要发送所有调试信息的位置:
Sentry DSN 密钥
恭喜!此时,您已经准备好在项目中使用 Sentry。现在是时候使用 composer 安装sentry/sentry-laravel
包了。要安装此库,您可以编辑您的composer.json
文件,或者使用以下命令进入您的用户微服务容器:
**docker exec -it docker_microservice_user_fpm_1 /bin/bash**
一旦您进入容器,使用以下命令使用 composer 更新您的composer.json
并为您安装:
**composer require sentry/sentry-laravel**
安装完成后,我们需要在我们的微服务上进行配置,因此打开bootstrap/app.php
文件并添加以下行:
**$app->register('SentrySentryLaravelSentryLumenServiceProvider');**
# Sentry must be registered before routes are included
require __DIR__ . '/../app/Http/routes.php';
现在,我们需要像之前看到的那样配置报告方法,因此转到app/Exceptions/Handler.php
文件,并在报告函数中添加以下行:
public function report(Exception $e)
{
if ($this->shouldReport($e)) {
app('sentry')->captureException($e);
}
parent::report($e);
}
这些行将向 Sentry 报告异常,因此让我们创建config/sentry.php
文件,并进行以下配置:
<?php
return array(
'dsn' => '___DSN___',
'breadcrumbs.sql_bindings' => true,
);
应用程序日志
日志是调试信息的记录,将来可能对查看应用程序的性能或查看应用程序的运行情况或甚至获取一些统计信息非常重要。实际上,几乎所有已知的应用程序都会产生某种日志信息。例如,默认情况下,所有对 NGINX 的请求都记录在/var/log/nginx/error.log
和/var/log/nginx/access.log
中。第一个error.log
存储应用程序生成的任何错误,例如 PHP 异常。第二个access.log
由每个命中 NGINX 服务器的请求创建。
作为一名经验丰富的开发人员,您已经知道在应用程序中保留一些日志非常重要,并且您并不孤单,您可以找到许多可以让您的生活更轻松的库。您可能想知道重要的地方在哪里,以及您可以放置日志调用和您需要保存的信息。没有一成不变的规则,您只需要考虑未来,以及在最坏的情况下(应用程序崩溃)您将需要哪些信息。
让我们专注于我们的示例应用程序;在用户服务中,我们将处理用户注册。您可以在保存新用户注册之前放置一个日志调用的有趣点。通过这样做,您可以跟踪您的日志,并知道我们正在尝试保存和何时保存的信息。现在,假设注册过程中有一个错误,并且在使用特殊字符时出现问题,但您并不知道这一点,您唯一知道的是有一些用户报告了注册问题。现在你会怎么做?检查日志!您可以轻松地检查用户正在尝试存储的信息,并发现使用特殊字符的用户没有被注册。
例如,如果您没有使用日志系统,可以使用error_log()
将消息存储在默认日志文件中:
**error_log('Your log message!', 0);**
参数0
表示我们要将消息存储在默认日志文件中。此函数允许我们通过电子邮件发送消息,将0
参数更改为1
并添加一个额外的参数,其中包含电子邮件地址。
所有的日志系统都允许您定义不同的级别;最常见的是(请注意,它们在不同的日志系统中可能有不同的名称,但概念是相同的):
-
信息:这指的是非关键信息。通常,您可以使用此级别存储调试信息,例如,您可以在特定页面呈现时存储一个新记录。
-
警告:这些是不太重要或系统可以自行恢复的错误。例如,缺少某些信息可能会导致应用程序处于不一致的状态。
-
错误:这是关键信息,当然,所有这些都是发生在您的应用程序中的错误。这是您在发现错误时将首先检查的级别。
微服务中的挑战
当您使用单体应用程序时,您的日志将默认存储在相同位置,或者至少在只有几台服务器上。如果出现任何问题,您需要检查日志,您可以在几分钟内获取所有信息。挑战在于当您处理微服务架构时,每个微服务都会生成日志信息。如果您有多个微服务实例,每个实例都会创建自己的日志数据,情况会变得更糟。
在这种情况下,您会怎么做?答案是使用像 Sentry 这样的日志系统将所有日志记录存储在同一位置。拥有日志服务可以让您扩展基础架构而不必担心日志。它们将全部存储在同一位置,让您轻松地找到有关不同微服务/实例的信息。
Lumen 中的日志
Lumen 默认集成了Monolog(PSR-3 接口);这个日志库允许您使用不同的日志处理程序。
在 Lumen 框架中,您可以在.env
文件中设置应用程序的错误详细信息。APP_DEBUG
设置定义了将生成多少调试信息。主要建议是在开发环境中将此标志设置为true
,但在生产环境中始终设置为false
。
要在代码中使用日志记录功能,您只需要确保已取消注释bootstrap/app.php
文件中的$app->withFacades();
行。一旦启用了门面,您就可以在代码的任何地方开始使用 Log 类。
提示
默认情况下,没有任何额外配置,Lumen 将日志存储在storage/logs
文件夹中。
我们的记录器提供了 RFC 5424 中定义的八个日志级别:
-
Log::emergency($error);
-
Log::alert($error);
-
Log::critical($error);
-
Log::error($error);
-
Log::warning($error);
-
Log::notice($error);
-
Log::info($error);
-
Log::debug($error);
一个有趣的功能是您必须添加一个上下文数据数组的选项。想象一下,您想记录一个失败的用户登录记录。您可以执行类似以下代码的操作:
Log::info('User unable to login.', ['id' => $user->id]);
在上述代码片段中,我们正在向我们的日志消息添加额外信息--尝试登录到我们的应用程序时出现问题的用户的 ID。
使用像 Sentry 这样的自定义处理程序设置 Monolog(我们之前解释了如何在项目中安装它)非常容易,您只需要将以下代码添加到bootstrap/app.php
文件中:
$app->configureMonologUsing(function($monolog) {
$client = new Raven_Client('sentry-dsn');
$monolog->pushHandler(
new MonologHandlerRavenHandler($client,
MonologLogger::WARNING)
);
return $monolog;
});
上述代码更改了 Monolog 的工作方式;在我们的情况下,它将不再将所有调试信息存储在storage/logs
文件夹中,而是使用我们的 Sentry 安装和WARNING
级别。
我们向您展示了在 Lumen 中存储日志的两种不同方式:像单体应用程序一样在本地文件中存储,或者使用外部服务。这两种方式都可以,但我们建议微服务开发使用像 Sentry 这样的外部工具。
应用程序监控
在软件开发中,应用程序监控可以被定义为确保我们的应用程序以预期的方式执行的过程。这个过程允许我们测量和评估我们的应用程序的性能,并有助于发现瓶颈或隐藏的问题。
应用程序监控通常是通过专门的软件进行的,该软件从运行您的软件的应用程序或基础架构中收集指标。这些指标可以包括 CPU 负载、事务时间或平均响应时间等。您可以测量的任何内容都可以存储在遥测系统中,以便以后进行分析。
监控单体应用程序很容易;您可以在一个地方找到所有内容,所有日志都存储在同一个地方,所有指标都可以从同一主机收集,您可以知道您的 PHP 线程是否在消耗服务器资源。您可能遇到的主要困难是找到应用程序中性能不佳的部分,例如,您的 PHP 代码中的哪一部分在浪费资源。
当您使用微服务时,您的代码被分割成逻辑部分,使您能够知道应用程序的哪一部分性能不佳,但代价很大。您的所有指标被分隔在不同的容器或服务器之间,这使得很难获得整体性能的全貌。通过建立遥测系统,您可以将所有指标发送到同一位置,从而更容易地检查和调试您的应用程序。
按层次监控
作为开发人员,您需要了解您的应用程序在各个层面的表现,从顶层即您的应用程序到底层即硬件或虚拟化层。在理想的情况下,我们将能够控制所有层面,但最有可能的情况是您只能监控到基础架构层。
以下图片显示了不同层次和与服务器堆栈的关系:
监控层
应用程序级别
应用程序级别存在于您的应用程序内;所有指标都是由您的代码生成的,例如我们的 PHP。不幸的是,您无法找到专门用于 PHP 的应用程序性能监控(APM)的免费或开源工具。无论如何,您可以找到有趣的第三方服务,并尝试其免费计划。
PHP 的两个最知名的 APM 服务是 New Relic 和 Datadog。在这两种情况下,安装都遵循相同的路径--您在容器/主机上安装一个代理(或库),这个小软件将开始将指标发送到其服务,为您提供一个仪表板,您可以在其中操作您的数据。使用第三方服务的主要缺点是您无法控制该代理或指标系统,但这个缺点可以转化为一个优点--您将拥有一个可靠的系统,无需管理,您只需要关注您的应用程序。
Datadog
Datadog 客户端的安装非常简单。打开其中一个微服务的composer.json
文件,并在required
定义中添加以下行:
"datadog/php-datadogstatsd": "0.3.*"
保存更改并进行 composer 更新后,您就可以在代码中使用Datadogstatsd
类并开始发送指标了。
想象一下,您想监控您的秘密微服务在获取数据库中所有服务器所花费的时间。打开您的秘密微服务的app/Http/Controllers/SecretController.php
文件,并修改您的类,如下所示:
use Datadogstatsd;
/** … Code omitted ... **/
const APM_API_KEY = 'api-key-from-your-account';
const APM_APP_KEY = 'app-key-from-your-account';
public function index(Manager $fractal, SecretTransformer
$secretTransformer, Request $request)
{
Datadogstatsd::configure(self::APM_API_KEY, self::APM_APP_KEY);
$startTime = microtime(true);
$records = Secret::all();
$collection = new Collection($records, $secretTransformer);
$data = $fractal->createData($collection)->toArray();
Datadogstatsd::timing('secrets.loading.time', microtime(true) -
$startTime, [‘service’ => ‘secret’]);
return response()->json($data);
}
上述代码片段定义了你的应用程序和 Datadog 账户的 API 密钥,我们使用它们来设置我们的Datadogstatsd
接口。这个例子记录了检索所有秘密记录所花费的时间。Datadogstatsd::timing()
方法将指标发送到我们的外部遥测服务。在你的应用程序内部进行监控可以让你决定在你的代码中生成指标的位置。在监控这个级别时没有硬性规定,但你需要记住重要的是要知道你的应用程序在哪里花费了大部分时间,所以在你认为可能成为瓶颈的代码的每个地方添加指标(比如从另一个服务获取数据或从数据库获取数据)。
使用这个库,你甚至可以使用以下方法增加和减少自定义指标点:
Datadogstatsd::increment('another.data.point');
Datadogstatsd::increment('my.data.point.with.custom.increment', .5);
Datadogstatsd::increment('your.data.point', 1, ['mytag’' => 'value']);
他们三个增加了一个点:第一个将another.data.point
增加了一个单位,第二个将我们的点增加了0.5
,第三个增加了点,并且还向度量记录添加了自定义标签。
你也可以使用Datadogstatsd::decrement()
来减少点,它与::increment()
具有相同的语法。
基础设施级别
这个层控制着操作系统和你的应用程序之间的一切。在这一层添加一个监控系统可以让你知道你的容器是否使用了太多内存,或者特定容器的负载是否过高。你甚至可以跟踪你的应用程序的一些基本指标。
在高街上,有多种监控这个层的选项,但我们将给你一些有趣的项目。它们都是开源的,尽管它们使用不同的方法,但你可以将它们结合起来。
Prometheus
Prometheus是一个开源的监控和警报工具包,是在 SoundCloud 创建的,并且属于Cloud Native Computing Foundation的一部分。作为新生力量并不意味着它没有强大的功能。除其他外,我们可以强调以下主要功能:
-
通过 HTTP 拉取进行时间序列收集
-
通过服务发现(kubernetes、consul 等)或静态配置进行目标发现
-
带有简单图形支持的 Web 界面
-
强大的查询语言,允许你从数据中提取所有你需要的信息
使用 Docker 安装 Prometheus 非常简单,我们只需要为我们的遥测系统添加一个新的容器,并将其与我们的自动发现服务(Consul)进行链接。将以下行添加到docker-compose.yml
文件中:
telemetry:
build: ./telemetry/
links:
- autodiscovery
expose:
- 9090
ports:
- 9090:9090
在上述代码中,我们只告诉 DockerDockerfile
的位置,链接了没有自动发现容器的容器,并暴露和映射了一些端口。现在,是时候创建telemetry/Dockerfile
文件,内容如下:
**FROM prom/prometheus:latest
ADD ./etc/prometheus.yml /etc/prometheus/**
正如你所看到的,创建我们的遥测容器并不需要太多的工作;我们使用官方镜像并添加我们的 Prometheus 配置。创建etc/prometheus.yml
配置,内容如下:
global:
scrape_interval: 15s
evaluation_interval: 15s
external_labels:
monitor: 'codelab-monitor'
scrape_configs:
- job_name: 'containerpilot-telemetry'
consul_sd_configs:
- server: 'autodiscovery:8500'
services: ['containerpilot']
同样,设置非常简单,因为我们正在定义一些全局的抓取间隔和一个名为containerpilot-telemetry
的作业,它将使用我们的自动发现容器,并监视存储在 consul 中以containerpilot
名称宣布的所有服务。
Prometheus 有一个简单而强大的 Web 界面。打开localhost:9090
,你就可以访问到这个工具收集的所有指标。创建一个图表非常简单,选择一个指标,Prometheus 会为你完成所有工作:
Prometheus 图形界面
此时,您可能会想知道如何声明指标。在前面的章节中,我们介绍了containerpilot
,这是一个我们将在容器中用作 PID 来管理自动发现的工具。containerpilot
具有声明指标以供支持的遥测系统使用的能力,例如 Prometheus。例如,如果您打开docker/microservices/battle/nginx/config/containerpilot.json
文件,您可以找到类似以下代码的内容:
"telemetry": {
"port": 9090,
"sensors": [
{
"name": "nginx_connections_unhandled_total",
"help": "Number of accepted connnections that were not
handled",
"type": "gauge",
"poll": 5,
"check": ["/usr/local/bin/sensor.sh", "unhandled"]
},
{
"name": "nginx_connections_load",
"help": "Ratio of active connections (less waiting) to the
maximum worker connections",
"type": "gauge",
"poll": 5,
"check": ["/usr/local/bin/sensor.sh", "connections_load"]
}
]
}
在上述代码中,我们声明了两个指标:"nginx_connections_unhandled_total"
和"nginx_connections_load"
。ContainerPilot
将在容器内部运行在"check"
参数中定义的命令,并且结果将被 Prometheus 抓取。
您可以使用 Prometheus 监控基础架构中的任何内容,甚至是 Prometheus 本身。请随意更改我们的基本安装和设置,并将其调整为使用自动驾驶模式。如果 Prometheus 的 Web UI 不足以满足您的图形需求,并且您需要更多的功能和控制权,您可以轻松地将我们的遥测系统与 Grafana 连接起来,Grafana 是创建各种指标仪表板的最强大工具之一。
Weave Scope
Weave Scope是用于监视容器的工具,它与 Docker 和 Kubernetes 配合良好,并具有一些有趣的功能,将使您的生活更轻松。Scope 为您提供了对应用程序和整个基础架构的深入全面视图。使用此工具,您可以实时诊断分布式容器化应用程序中的任何问题。
忘记复杂的配置,Scope 会自动检测并开始监视每个主机、Docker 容器和基础架构中运行的任何进程。一旦获取所有这些信息,它将创建一个漂亮的地图,实时显示所有容器之间的互联关系。您可以使用此工具查找内存问题、瓶颈或任何其他问题。您甚至可以检查进程、容器、服务或主机的不同指标。您可以在 Scope 中找到的一个隐藏功能是能够从浏览器 UI 中管理容器、查看日志或附加终端。
部署 Weave Scope 有两种选择:独立模式,其中所有组件在本地运行,或作为付费云服务,您无需担心任何事情。独立模式作为特权容器在每个基础架构服务器内运行,并且具有从集群或服务器中收集所有信息并在 UI 中显示的能力。
安装非常简单-您只需要在每个基础架构服务器上运行以下命令:
**sudo curl -L git.io/scope -o /usr/local/bin/scope
sudo chmod a+x /usr/local/bin/scope
scope launch**
一旦您启动了 Scope,请打开服务器的 IP 地址(如果您像我们一样在本地工作,则为 localhost)http://localhost:4040
,您将看到类似于以下屏幕截图的内容:
Weave Scope 容器图形可视化
上述图像是我们正在构建的应用程序的快照;在这里,您可以看到我们所有的容器及其之间的连接在特定时间点。试一试,当您调用我们不同的 API 端点时,您将能够看到容器之间的连接发生变化。
您能在我们的微服务基础架构中找到任何问题吗?如果可以,那么您是正确的。正如您所看到的,我们没有将一些容器连接到自动发现服务。Scope 帮助我们找到了一个可能的未来问题,现在请随意修复它。
正如您所看到的,您可以使用 Scope 从浏览器监视您的应用程序。您只需要注意谁可以访问特权 Scope 容器;如果您计划在生产中使用 Scope,请确保限制对此工具的访问。
硬件/虚拟化监控
这一层与我们的硬件或虚拟化层相匹配,是您可以放置指标的最低位置。这一层的维护和监控通常由系统管理员完成,他们可以使用非常知名的工具,如Zabbix或Nagios。作为开发人员,您可能不会担心这一层。如果您在云环境中部署应用程序,您将无法访问由这一层生成的任何指标。
摘要
在本章中,我们解释了如何调试和对微服务应用程序进行性能分析,这是软件开发中的重要过程。在日常工作中,您不会花费所有时间来调试或对应用程序进行性能分析;在某些情况下,您将花费大量时间来尝试修复错误。因此,重要的是要有一个地方可以存储所有错误和调试信息,这些信息将使您更深入地了解应用程序的情况。最后,作为全栈开发人员,我们向您展示了如何监视应用程序堆栈的顶部两层。
第七章:安全
当我们开发应用程序时,我们应该始终考虑如何使我们的微服务更加安全。有一些技术和方法,每个开发人员都应该了解,以避免安全问题。在本章中,您将发现如何在您的微服务中使用身份验证和授权,以及在用户登录后如何管理每个功能的权限。您还将发现可以用来加密数据的不同方法。
微服务中的加密
我们可以将加密定义为将信息转换为只有授权方能够阅读的过程。这个过程实际上可以在您的应用程序的任何级别进行。例如,您可以加密整个数据库,或者您可以在传输层使用 SSL/TSL 或JSON Web Token(JWT)进行加密。
如今,加密/解密过程是通过现代算法完成的,加密添加的最高级别是在传输层。在这一层中使用的所有算法至少提供以下功能:
-
认证:此功能允许您验证消息的来源
-
完整性:此功能可为您提供消息内容在从原始内容到目的地的过程中未更改的证据
加密算法的最终任务是为您提供一个安全层,以便您可以在不必担心有人窃取您的信息的情况下交换或存储敏感数据,但这并非免费。您的环境将使用一些资源来处理加密、解密或其他相关事项中的握手。
作为开发人员,您需要考虑到您将被部署到一个敌对的环境——生产环境是一个战区。如果您开始这样思考,您可能会开始问自己以下问题:
-
我们将部署到硬件还是虚拟化环境?我们将共享资源吗?
-
我们能相信我们应用程序的所有可能的邻居吗?
-
我们将把我们的应用程序分割成不同的和分离的区域吗?我们将如何连接我们的区域?
-
我们的应用程序是否符合 PCI 标准,或者由于我们存储/管理的数据,它是否需要非常高的安全级别?
当您开始回答所有这些问题(以及其他问题)时,您将开始确定应用程序所需的安全级别。
在本节中,我们将向您展示加密应用程序数据的最常见方法,以便您可以随后选择要实施的方法。
请注意,我们不考虑全盘加密,因为它被认为是保护数据的最弱方法。
数据库加密
当您处理敏感数据时,保护数据的最灵活且开销最低的方法是在应用程序层中使用加密。然而,如果由于某种原因您无法更改您的应用程序,接下来最强大的解决方案是加密您的数据库。
对于我们的应用程序,我们选择了关系数据库;具体来说,我们使用的是 Percona,一个 MySQL 分支。目前,您在该数据库中有两种不同的加密数据的选项:
-
通过 MariaDB 补丁启用加密(另一个与 Percona 非常相似的 MySQL 形式)。此补丁在 10.1.3 及更高版本中可用。
-
InnoDB 表空间级加密方法可从 Percona Server 5.7.11 或 MySQL 5.7.11 开始使用。
也许您想知道为什么我们在选择了 Percona 后还在谈论 MariaDB 和 MySQL。这是因为它们三者具有相同的核心,共享大部分核心功能。
提示
所有主要的数据库软件都允许您加密数据。如果您没有使用 Percona,请查看您的数据库的官方文档,找到允许加密所需的步骤。
作为开发人员,你需要了解在应用中使用数据库级加密的弱点。除其他外,我们可以强调以下几点:
-
特权数据库用户可以访问密钥环文件,因此在你的数据库中要严格控制用户权限。
-
数据在存储在服务器的 RAM 中时并不加密,只有在数据写入硬盘时才会加密。一个特权且恶意的用户可以使用一些工具来读取服务器内存,因此也可以读取你的应用数据。
-
一些工具,比如 GDB,可以用来更改根用户密码结构,从而允许你无任何问题地复制数据。
MariaDB 中的加密
想象一下,如果你不想使用 Percona,而是想使用 MariaDB;由于file_key_management
插件,数据库加密是可用的。在我们的应用示例中,我们正在使用 Percona 作为 secrets 微服务的数据存储,所以让我们添加一个新的 MariaDB 容器,以便以后尝试并交换这两个 RDBMS。
首先,在与数据库文件夹处于同一级别的 secrets 微服务内的 Docker 存储库中创建一个mariadb
文件夹。在这里,你可以添加一个包含以下内容的Dockerfile
:
**FROM mariadb:latest
RUN apt-get update \
&& apt-get autoremove && apt-get autoclean \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p /volumes/keys/
RUN echo
"1;
C472621BA1708682BEDC9816D677A4FDC51456B78659F183555A9A895EAC9218" >
/volumes/keys/keys.txt
RUN openssl enc -aes-256-cbc -md sha1 -k secret -in
/volumes/keys/keys.txt -out /volumes/keys/keys.enc
COPY etc/ /etc/mysql/conf.d/**
在上述代码中,我们正在拉取最新的官方 MariaDB 镜像,更新它,并创建一些我们加密需要的证书。在keys.txt
文件中保存的长字符串是我们自己生成的密钥,使用以下命令生成:
**openssl enc -aes-256-ctr -k secret@phpmicroservices.com -P -md sha1**
我们Dockerfile
的最后一个命令将我们定制的数据库配置复制到容器内。在etc/encryption.cnf
中创建我们的自定义数据库配置,内容如下:
[mysqld]
plugin-load-add=file_key_management.so
file_key_management_filekey = FILE:/mount/keys/server-key.pem
file-key-management-filename = /mount/keys/mysql.enc
innodb-encrypt-tables = ON
innodb-encrypt-log = 1
innodb-encryption-threads=1
encrypt-tmp-disk-tables=1
encrypt-tmp-files=0
encrypt-binlog=1
file_key_management_encryption_algorithm = AES_CTR
在上述代码中,我们告诉我们的数据库引擎我们存储证书的位置,并启用了加密。现在,你可以编辑我们的docker-compose.yml
文件,并添加以下容器定义:
microservice_secret_database_mariadb:
build: ./microservices/secret/mariadb/
environment:
- MYSQL_ROOT_PASSWORD=mysecret
- MYSQL_DATABASE=finding_secrets
- MYSQL_USER=secret
- MYSQL_PASSWORD=mysecret
ports:
- 7777:3306
从上述代码中可以看出,我们并没有定义任何新的内容;你现在可能已经有足够的 Docker 经验来理解我们正在定义Dockerfile
的位置。我们设置了一些环境变量,并将本地的7777
端口映射到容器的3306
端口。一旦你做出所有的更改,一个简单的docker-compose build microservice_secret_database
命令将生成新的容器。
构建完容器后,是时候检查一切是否正常运行了。使用docker-compose up microservice_secret_database
启动新容器,并尝试将其连接到我们本地的7777
端口。现在,你可以开始在这个容器中使用加密。考虑以下示例:
CREATE TABLE `test_encryption` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`text_field` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB `ENCRYPTED`=YES `ENCRYPTION_KEY_ID`=1;
在上述代码中,我们为我们的 SQL 添加了一些额外的标签;它们启用了表中的加密,并使用我们在keys.txt
中存储的 ID 为1
的加密密钥(我们用它来启动数据库的文件)。试一试,如果一切顺利,随时可以进行必要的更改,使用这个新的数据库容器代替我们项目中的另一个容器。
InnoDB 加密
Percona 和 MySQL 5.7.11+版本自带一个新功能--支持InnoDB表空间级加密。有了这个功能,你可以在不需要太多麻烦或配置的情况下加密所有的 InnoDB 表。在我们的示例应用中,我们正在使用 Percona 5.7 来处理 secrets 微服务。让我们看看如何加密我们的表。
首先,我们需要对我们的 Docker 环境进行一些小的修改;首先,打开microservices/secret/database/Dockerfile
,并用以下代码替换所有内容:
FROM percona:5.7
**RUN mkdir -p /mount/mysql-keyring/ \**
**&& touch /mount/mysql-keyring/keyring \**
**&& chown -R mysql:mysql /mount/mysql-keyring**
**COPY etc/ /etc/mysql/conf.d/**
在本书的这一部分,你可能不需要解释我们在Dockerfile
中做了什么,所以让我们创建一个新的config
文件,稍后将其复制到我们的容器中。在secret microservice
文件夹中,创建一个etc
文件夹,并生成一个名为encryption.cnf
的新文件,内容如下:
[mysqld]
early-plugin-load=keyring_file.so
keyring_file_data=/mount/mysql-keyring/keyring
在我们之前创建的配置文件中,我们正在加载keyring
库,数据库可以在其中找到并存储用于加密数据的生成密钥环。
此时,您已经拥有了启用加密所需的一切,因此使用docker-compose build microservice_secret_database
重新构建容器,并使用docker-compose up -d
再次启动所有容器。
如果一切正常,您应该能够无问题地打开数据库,并且可以使用以下 SQL 命令更改我们存储的表:
**ALTER TABLE `secrets` ENCRYPTION='Y'**
也许您会想知道为什么我们修改了secrets
表,如果我们已经在数据库中启用了加密。背后的原因是因为加密不是默认启用的,因此您需要明确告诉引擎您想要加密哪些表。
性能开销
在数据库中使用加密将降低应用程序的性能。您的机器/容器将使用一些资源来处理加密/解密过程。在某些测试中,当您不使用表空间级加密(MySQL/Percona +5.7)时,这种开销可能超过 20%。我们建议您测量启用和未启用加密时应用程序的平均性能。这样,您可以确保加密不会对应用程序产生很大影响。
在本节中,我们向您展示了两种快速增加应用程序安全性的方法。使用这些功能的最终决定取决于您和您的应用程序的规格。
TSL/SSL 协议
传输层安全(TSL)和安全套接字层(SSL)是用于在不受信任的网络中保护通信的加密协议,例如,互联网或 ISP 的局域网。 SSL 是 TSL 的前身,它们两者经常可以互换使用或与 TLS/SSL 一起使用。如今,SSL 和 TSL 实际上是一回事,如果您选择使用其中之一,选择另一个没有区别,您将使用服务器规定的相同级别的加密。例如,如果应用程序(例如电子邮件客户端)让您在 SSL 或 TSL 之间进行选择,您只是选择了安全连接的启动方式,没有其他区别。
这些协议的所有功能和安全性都依赖于我们所知的证书。TSL/SSL 证书可以定义为将加密密钥与组织或个人的详细信息数字绑定的小型数据文件。您可以找到各种公司出售 TSL/SSL 证书,但如果您不想花钱(或者您处于开发阶段),您可以创建自签名证书。这些类型的证书可用于加密数据,但客户端将不信任它们,除非您跳过验证。
TSL/SSL 协议的工作原理
在您开始在应用程序中使用 TSL/SSL 之前,您需要了解它的工作原理。还有许多其他专门解释这些协议工作原理的书籍,因此我们只会给您一个初步了解。
以下图表总结了 TSL/SSL 协议的工作原理;首先,您需要知道 TSL/SSL 是一个 TCP 客户端-服务器协议,加密在经过几个步骤后开始:
TSL/SSL 协议
TSL/SSL 协议的步骤如下:
-
我们的客户希望与使用 TSL/SSL 保护的服务器/服务建立连接,因此它要求服务器进行身份验证。
-
服务器响应请求并向客户端发送其 TSL/SSL 证书的副本。
-
客户端检查 TSL/SSL 证书是否是受信任的,如果是,则向服务器发送消息。
-
服务器返回数字签名的确认以开始会话。
-
在所有先前的步骤(握手)之后,加密数据在客户端和服务器之间共享。
正如你所能想象的,术语客户端和服务器是模棱两可的;客户端可以是试图访问你的页面的浏览器,也可以是试图与另一个微服务通信的微服务。
TSL/SSL 终止
正如你之前学到的,为你的应用添加 TSL/SSL 层会给应用的整体性能增加一些开销。为了缓解这个问题,我们有所谓的 TSL/SSL 终止,一种 TSL/SSL 卸载形式,它将加密/解密的责任从服务器转移到应用的不同部分。
TSL/SSL 终止依赖于这样一个事实,即一旦所有数据被解密,你就信任你正在使用的所有通信渠道来传输这些解密后的数据。让我们以一个微服务为例;看一下下面的图片:
微服务中的 TSL/SSL 终止
在上述图片中,所有的输入/输出通信都是使用我们微服务架构的特定组件加密的。这个组件将充当代理,并处理所有的 TSL/SSL 事务。一旦来自客户端的请求到来,它就会处理所有的握手并解密请求。一旦请求被解密,它就会被代理到特定的微服务组件(在我们的案例中,它是 NGINX),我们的微服务就会执行所需的操作,例如从数据库中获取一些数据。一旦微服务需要返回响应,它就会使用代理,其中我们所有的响应都是加密的。如果你有多个微服务,你可以扩展这个小例子并做同样的事情--加密不同微服务之间的所有通信,并在微服务内部使用加密数据。
使用 NGINX 进行 TSL/SSL
你可以找到多个软件,可以用来进行 TSL/SSL 终止。以下列出了一些最知名的:
-
负载均衡器:Amazon ELB 和 HaProxy
-
代理:NGINX、Traefik 和 Fabio
在我们的案例中,我们将使用 NGINX 来管理所有的 TSL/SSL 终止,但是请随意尝试其他选项。
你可能已经知道,NGINX 是市场上最多才多艺的软件之一。你可以将其用作反向代理或具有高性能水平和稳定性的 Web 服务器。
我们将解释如何在 NGINX 中进行 TSL/SSL 终止,例如对于 battle 微服务。首先,打开microservices/battle/nginx/Dockerfile
文件,并在 CMD 命令之前添加以下命令:
**RUN echo 01 > ca.srl \
&& openssl genrsa -out ca-key.pem 2048 \
&& openssl req -new -x509 -days 365 -subj "/CN=*" -key ca-key.pem -out ca.pem \
&& openssl genrsa -out server-key.pem 2048 \
&& openssl req -subj "/CN=*" -new -key server-key.pem -out server.csr \
&& openssl x509 -req -days 365 -in server.csr -CA ca.pem -CAkey ca-key.pem -out server-cert.pem \
&& openssl rsa -in server-key.pem -out server-key.pem \
&& cp *.pem /etc/nginx/ \
&& cp *.csr /etc/nginx/**
在这里,我们创建了一些自签名证书,并将它们存储在nginx
容器的/etc/nginx
文件夹中。
一旦我们有了证书,就是时候改变 NGINX 配置文件了。打开microservices/battle/nginx/config/nginx/nginx.conf.ctmpl
文件,并添加以下服务器定义:
server {
listen 443 ssl;
server_name _;
root /var/www/html/public;
index index.php index.html;
ssl on;
ssl_certificate /etc/nginx/server-cert.pem;
ssl_certificate_key /etc/nginx/server-key.pem;
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log error;
sendfile off;
client_max_body_size 100m;
location / {
try_files $uri $uri/ /index.php?_url=$uri&$args;
}
location ~ /\.ht {
deny all;
}
{{ if service $backend }}
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass {{ $backend }};
fastcgi_index /index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME
$document_root$fastcgi_script_name;
fastcgi_intercept_errors off;
fastcgi_buffer_size 16k;
fastcgi_buffers 4 16k;
}
{{ end }}
}
上述代码片段在nginx
服务器中设置了一个新的监听器,在443
端口。正如你所看到的,它与默认服务器设置非常相似;不同之处在于端口和我们在上一步中创建的证书的位置。
为了使用这个新的 TSL/SSL 端点,我们需要对docker-compose.yml
文件进行一些小的更改,并映射443
NGINX 端口。你只需要去microservice_battle_nginx
定义中添加一个新的端口声明行,如下所示:
- 8443:443
新的行将我们的8443
端口映射到nginx
容器的443
端口,允许我们通过 TSL/SSL 连接。你现在可以用 Postman 试一试,但是由于它是一个自签名证书,默认情况下是不被接受的。打开首选项并禁用SSL 证书验证。作业时,你可以将我们所有的示例服务都改为只使用 TSL/SSL 层来相互通信。
在本章的这一部分,我们向您展示了如何为您的应用程序添加额外的安全层,加密数据和用于交换消息的通信渠道。现在我们确信我们的应用程序至少具有一定程度的加密,让我们继续讨论应用程序的另一个重要方面--认证。
认证
每个项目的起点是认证系统,通过它可以识别将使用我们的应用程序或 API 的用户或客户。有许多库可以实现不同的用户认证方式;在本书中,我们将看到两种最重要的方式:OAuth 2和JWT。
正如我们已经知道的,微服务是无状态的,这意味着它们应该使用访问令牌而不是 cookie 和会话与彼此和用户进行通信。因此,让我们看看使用它进行认证的工作流程是什么样的:
通过令牌进行认证的工作流程
正如您在上图中所看到的,这应该是获取客户或用户所需的秘密列表的过程:
-
USER向FRONTEND LOGIN请求秘密列表。
-
FRONTEND LOGIN向BACKEND请求秘密列表。
-
BACKEND向FRONTEND LOGIN请求用户访问令牌。
-
FRONTEND LOGIN向GOOGLE(或任何其他提供者)请求访问令牌。
-
GOOGLE向USER请求他们的凭据。
-
USER向GOOGLE提供凭据。
-
GOOGLE向FRONTEND LOGIN提供用户访问令牌。
-
FRONTEND LOGIN提供BACKEND用户访问令牌。
-
BACKEND与GOOGLE检查使用该访问令牌的用户是谁。
-
GOOGLE告诉BACKEND用户是谁。
-
BACKEND检查用户并告诉FRONTEND LOGIN秘密列表。
-
FRONTEND LOGIN向USER显示秘密列表。
显然,在这个过程中,一切都是在用户不知情的情况下发生的。用户只需要向适当的服务提供他/她的凭据。在前面的例子中,服务是GOOGLE,但它甚至可以是我们自己的应用程序。
现在,我们将构建一个新的 docker 容器,以便使用 OAuth 2 和 JWT 来创建和设置一个用于认证用户的数据库。
在 docker 用户微服务的docker/microservices/user/database/Dockerfile
数据库文件夹下创建一个Dockerfile
,并添加以下行。我们将像我们为 secret 微服务所做的那样使用 Percona:
FROM percona:5.7
创建了Dockerfile
之后,打开docker-composer.yml
文件,并在用户微服务部分的末尾添加用户数据库微服务配置(就在源容器之前)。还要将microservice_user_database
添加到microservice_user_fpm
链接部分,以使数据库可见:
microservice_user_fpm:
{{omitted code}}
links:
{{omitted code}}
**- microservice_user_database**
**microservice_user_database:**
**build: ./microservices/user/database/**
**environment:**
**- CONSUL=autodiscovery**
**- MYSQL_ROOT_PASSWORD=mysecret**
**- MYSQL_DATABASE=finding_users**
**- MYSQL_USER=secret**
**- MYSQL_PASSWORD=mysecret**
**ports:**
**- 6667:3306**
一旦我们设置了配置,就该构建它了,所以在您的终端上运行以下命令来创建我们刚刚设置的新容器:
**docker-compose build microservice_user_database**
这可能需要一些时间;当它完成时,我们必须通过运行以下命令再次启动容器:
**docker-compose up -d**
您可以通过执行docker ps
来检查用户数据库微服务是否正确创建,因此请检查其中的新microservice_user_database
。
现在是时候设置用户微服务以便能够使用我们刚刚创建的数据库容器了,所以将以下行添加到bootstrap/app.php
文件中:
$app->configure('database');
还要创建config/database.php
文件,并添加以下配置:
<?php
return [
'default' => 'mysql',
'migrations' => 'migrations',
'fetch' => PDO::FETCH_CLASS,
'connections' => [
'mysql' => [
'driver' => 'mysql',
'host' => env('DB_HOST','microservice_user_database'),
'database' => env('DB_DATABASE','finding_users'),
'username' => env('DB_USERNAME','secret'),
'password' => env('DB_PASSWORD','mysecret'),
'collation' => 'utf8_unicode_ci',
]
]
];
请注意,在上述代码中,我们使用了与docker-compose.yml
文件中用于连接到数据库容器的相同凭据。
就是这样。我们现在有一个新的数据库容器连接到用户微服务,它已经准备好使用了。通过创建迁移或在您喜爱的 SQL 客户端中执行以下查询来添加一个新的用户表:
CREATE TABLE `users` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`email` varchar(255) NOT NULL,
`password` varchar(255) NOT NULL,
`api_token` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=latin1;
OAuth 2
让我们介绍一种安全且特别适用于微服务的基于访问令牌的认证系统。
OAuth 2 是一种标准协议,允许我们将 API REST 的某些方法限制为特定用户,而无需要求用户提供用户名和密码。
这个协议非常常见,因为它更安全,可以避免在 API 之间通信时共享密码或敏感凭据。
OAuth 2 使用访问令牌,用户需要获取该令牌才能使用应用程序。令牌将具有过期时间,并且可以在无需再次提供用户凭据的情况下进行刷新。
如何在 Lumen 上使用 OAuth 2
现在,我们将解释如何在 Lumen 上安装、设置和尝试 OAuth 2 身份验证。这样做的目的是让微服务使用 OAuth 2 来限制方法;换句话说,在使用需要身份验证的方法之前,消费者需要提供一个令牌。
OAuth 2 安装
通过在 docker 文件夹上执行以下命令进入用户微服务:
**docker-compose up -d
docker exec -it docker_microservice_user_fpm_1 /bin/bash**
一旦我们进入用户微服务,就需要通过在composer.json
文件的require
部分中添加以下行来安装 OAuth 2:
"lucadegasperi/oauth2-server-laravel": "⁵.0"
然后,执行composer update
,该包将在您的微服务上安装 OAuth 2。
设置
安装完包后,我们必须设置一些重要的东西才能运行 OAuth 2。首先,我们需要将位于/vendor/lucadegasperi/oauth2-server-laravel/config/oauth2.php
的 OAuth 2 配置文件复制到/config/oauth2.php
;如果config
文件夹不存在,则创建它。此外,我们需要将包含在/vendor/lucadegasperi/oauth2-server-laravel/database/migrations
中的迁移文件复制到/database/migrations
文件夹中。
不要忘记通过在/bootstrap/app.php
中添加以下行来注册 OAuth 2:
$app-
>register(\LucaDegasperi\OAuth2Server\Storage\
FluentStorageServiceProvider::class);
$app- >register(\LucaDegasperi\OAuth2Server\
OAuth2ServerServiceProvider::class);
$app->middleware([
\LucaDegasperi\OAuth2Server\Middleware\
OAuthExceptionHandlerMiddleware::class
]);
在app->withFacades();
行之前的文件顶部(如果没有取消注释,请这样做),添加以下行:
class_alias('Illuminate\Support\Facades\Config', 'Config');
class_alias(\LucaDegasperi\OAuth2Server\Facades\Authorizer::class,
'Authorizer');
现在,我们将执行迁移以在数据库中创建必要的表:
**composer dumpautoload
php artisan migrate**
提示
如果在执行迁移时遇到问题,请尝试将'migrations' => 'migrations', 'fetch' => PDO::FETCH_CLASS,
添加到config/database.php
文件中,然后执行php artisan migrate:install --database=mysql
。
一旦我们创建了所有必要的表,可以使用 Lumen seeders 在oauth_clients
表中插入一个注册,或者通过在您喜爱的 SQL 客户端上执行以下查询来执行:
INSERT INTO `finding_users`.`oauth_clients`
(`id`, `secret`, `name`, `created_at`, `updated_at`)
VALUES
('1', 'YouAreTheBestDeveloper007', 'PHPMICROSERVICES', NULL, NULL);
现在,我们必须在/app/Http/routes.php
中添加一个新路由,以便为我们刚刚创建的用户获取有效的令牌。例如,路由可以是oauth/access_token
:
$app->post('**oauth/access_token**', function() {
return response()->json(Authorizer::issueAccessToken());
});
最后,修改/config/oauth2.php
文件中的grant_types
值,将其更改为以下代码行:
'grant_types' => [
'client_credentials' => [
'class' => '\League\OAuth2\Server\Grant\
ClientCredentialsGrant',
'access_token_ttl' => 0
]
],
让我们尝试 OAuth2
现在,我们可以通过在 Postman 上对http://localhost:8084/api/v1/oauth/access_token
进行 POST 调用来获取我们的令牌,包括在 body 中包含以下参数:
**grant_type:** client_credentials
**client_id:** 1
**client_secret:** YouAreTheBestDeveloper007
如果输入错误的凭据,将会得到以下响应:
{
"error": "invalid_client",
"error_description": "Client authentication failed."
}
如果凭据正确,我们将在 JSON 中获得access_token
:
{
"access_token": "**anU2e6xgXiLm7UARSSV7M4Wa7u86k4JryKWrIQhu**",
"token_type": "Bearer",
"expires_in": 3600
}
一旦我们获得有效的访问令牌,我们可以限制一些未注册用户的方法。这在 Lumen 上非常容易。我们只需在/bootstrap/app.php
上启用路由中间件,因此在该文件中添加以下代码:
$app->routeMiddleware(
[
'check-authorization-params' =>
\LucaDegasperi\OAuth2Server\Middleware\
CheckAuthCodeRequestMiddleware::class,
'csrf' => \Laravel\Lumen\Http\Middleware\
VerifyCsrfToken::class,
'oauth' =>
\LucaDegasperi\OAuth2Server\Middleware\
OAuthMiddleware::class,
'oauth-client' => \LucaDegasperi\OAuth2Server\Middleware\
OAuthClientOwnerMiddleware::class,
'oauth-user' => \LucaDegasperi\OAuth2Server\Middleware\
OAuthUserOwnerMiddleware::class,
]
);
转到UserController.php
文件并添加一个带有以下代码的__construct()
函数:
public function __construct(){
$this->middleware('oauth');
}
这将影响控制器上的所有方法,但我们可以使用以下代码排除其中一些方法:
public function __construct(){
$this->middleware('oauth', **['except' => 'index']**);
}
public function index()
{
return response()->json(['method' => 'index']);
}
现在,我们可以通过在http://localhost:8084/api/v1/user
上进行 GET 调用来测试 index 函数。不要忘记在Authorization
标头中包含Bearer anU2e6xgXiLm7UARSSV7M4Wa7u86k4JryKWrIQhu
值。
如果我们排除了 index 函数,或者如果我们正确输入了令牌,我们将获得状态码 200 的 JSON 响应:
{"method":"index"}
如果我们没有排除 index 方法并输入了错误的令牌,我们将收到错误代码 401 和以下消息:
{"error":"access_denied","error_description":"The resource owner or
authorization server denied the request."}
现在您有一个安全且更好的应用程序。请记住,您可以将上一章中学到的错误处理添加到您的授权方法中。
JSON Web Token
JSON Web Token(JWT)是一组安全方法,用于 HTTP 请求和客户端与服务器之间的传输。JWT 令牌是使用 JSON Web 签名进行数字签名的 JSON 对象。
为了使用 JWT 创建令牌,我们需要用户凭据、秘密密钥和要使用的加密类型;可以是 HS256、HS384 或 HS512。
如何在 Lumen 上使用 JWT
可以使用 composer 在 Lumen 上安装 JWT。因此,一旦您在用户微服务容器中,就在终端中执行以下命令:
**composer require tymon/jwt-auth:"¹.0@dev"**
安装该库的另一种方法是打开您的composer.json
文件,并将"tymon/jwt-auth": "¹.0@dev"
添加到所需库列表中。安装后,我们需要像在 OAuth 2 中注册服务提供程序一样在注册服务提供程序上注册 JWT。在 Lumen 上,可以通过在bootstrap/app.php
文件中添加以下行来实现:
$app->register('Tymon\JWTAuth\Providers\JWTAuthServiceProvider');
还要取消以下行的注释:
$app->register(App\Providers\AuthServiceProvider::class);
您的bootstrap/app.php
文件应如下所示:
<?php
require_once __DIR__.'/../vendor/autoload.php';
try {
(new Dotenv\Dotenv(__DIR__.'/../'))->load();
} catch (Dotenv\Exception\InvalidPathException $e) {
//
}
$app = new Laravel\Lumen\Application(
realpath(__DIR__.'/../')
);
// $app->withFacades();
**$app->withEloquent();**
$app->singleton(
Illuminate\Contracts\Debug\ExceptionHandler::class,
App\Exceptions\Handler::class
);
$app->singleton(
Illuminate\Contracts\Console\Kernel::class,
App\Console\Kernel::class
);
**$app->routeMiddleware([**
**'auth' => App\Http\Middleware\Authenticate::class,**
**]);**
$app->register(App\Providers\AuthServiceProvider::class);
**$app->register
(Tymon\JWTAuth\Providers\LumenServiceProvider::class);**
$app->group(['namespace' => 'App\Http\Controllers'],
function ($app)
{
require __DIR__.'/../app/Http/routes.php';
});
return $app;
设置 JWT
现在我们需要一个秘密密钥,因此运行以下命令以生成并将其放置在 JWT 配置文件中:
**php artisan jwt:secret**
生成后,您可以在.env
文件中看到放置的秘密密钥(您的秘密密钥将不同)。检查并确保您的.env
如下所示:
APP_DEBUG=true
APP_ENV=local
SESSION_DRIVER=file
DB_HOST=microservice_user_database
DB_DATABASE=finding_users
DB_USERNAME=secret
DB_PASSWORD=mysecret
**JWT_SECRET=wPB1mQ6ADZrc0ouxMCYJfiBbMC14IAV0**
CACHE_DRIVER=file
现在,转到config/jwt.php
文件;这是 JWT config
文件,请确保您的文件如下所示:
<?php
return [
'secret' => env('JWT_SECRET'),
'keys' => [
'public' => env('JWT_PUBLIC_KEY'),
'private' => env('JWT_PRIVATE_KEY'),
'passphrase' => env('JWT_PASSPHRASE'),
],
'ttl' => env('JWT_TTL', 60),
'refresh_ttl' => env('JWT_REFRESH_TTL', 20160),
'algo' => env('JWT_ALGO', 'HS256'),
'required_claims' => ['iss', 'iat', 'exp', 'nbf', 'sub',
'jti'],
'blacklist_enabled' => env('JWT_BLACKLIST_ENABLED', true),
'blacklist_grace_period' => env('JWT_BLACKLIST_GRACE_PERIOD',
0),
'providers' => [
'jwt' => Tymon\JWTAuth\Providers\JWT\Namshi::class,
'auth' => Tymon\JWTAuth\Providers\Auth\Illuminate::class,
'storage' =>
Tymon\JWTAuth\Providers\Storage\Illuminate::class,
],
];
还需要正确设置config/app.php
。确保您正确输入了用户模型,它将定义 JWT 应该搜索用户和提供的密码的表:
<?php
return [
'defaults' => [
'guard' => env('AUTH_GUARD', 'api'),
'passwords' => 'users',
],
'guards' => [
'api' => [
'driver' => 'jwt',
'provider' => 'users',
],
],
'providers' => [
'users' => [
'driver' => 'eloquent',
**'model' => \App\Model\User::class,**
],
],
'passwords' => [
'users' => [
'provider' => 'users',
'table' => 'password_resets',
'expire' => 60,
],
],
];
现在,我们准备通过编辑/app/Http/routes.php
来定义需要身份验证的方法:
<?php
$app->get('/', function () use ($app) {
return $app->version();
});
use Illuminate\Http\Request;
use Tymon\JWTAuth\JWTAuth;
$app->post('login', function(Request $request, JWTAuth $jwt) {
$this->validate($request, [
'email' => 'required|email|exists:users',
'password' => 'required|string'
]);
if (! $token = $jwt->attempt($request->only(['email',
'password']))) {
return response()->json(['user_not_found'], 404);
}
return response()->json(compact('token'));
});
$app->group(**['middleware' => 'auth']**, function () use ($app) {
$app->post('user', function (JWTAuth $jwt) {
$user = $jwt->parseToken()->toUser();
return $user;
});
});
您可以在上述代码中看到,我们的中间件只影响我们在其中定义了中间件的组中包含的方法。我们可以创建所有我们想要的组,以便通过我们选择的中间件传递方法。
最后,编辑/app/Providers/AuthServiceProvider.php
文件,并添加以下突出显示的代码:
<?php
namespace App\Providers;
use App\User;
use Illuminate\Support\ServiceProvider;
class AuthServiceProvider extends ServiceProvider
{
public function register()
{
//
}
public function boot()
{
**$this->app['auth']->viaRequest('api', function ($request) {**
**if ($request->input('email')) {**
**return User::where('email', $request->input('email'))-
>first();**
**}**
**});**
}
}
最后,我们需要对用户模型文件进行一些更改,因此转到/app/Model/User.php
并将以下行添加到类实现列表中的JWTSubject
:
<?php
namespace App\Model;
use Illuminate\Contracts\Auth\Access\Authorizable as
AuthorizableContract;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Auth\Authenticatable;
use Laravel\Lumen\Auth\Authorizable;
use Illuminate\Contracts\Auth\Authenticatable as
AuthenticatableContract;
**use Tymon\JWTAuth\Contracts\JWTSubject;**
class User extends Model implements **JWTSubject**,
AuthorizableContract,
AuthenticatableContract {
use Authenticatable, Authorizable;
protected $table = 'users';
protected $fillable = ['email', 'api_token'];
protected $hidden = ['password'];
**public function getJWTIdentifier()**
**{**
**return $this->getKey();**
**}**
**public function getJWTCustomClaims()**
**{**
**return [];**
**}**
}
不要忘记添加getJWTIdentifier()
和getJWTCustomClaims()
函数,如上述代码所示。这些函数是实现JWTSubject
所必需的。
让我们尝试 JWT
为了测试这一点,我们必须在数据库的用户表中创建一个新用户。因此,通过执行迁移或在您喜欢的 SQL 客户端中执行以下查询来添加它:
INSERT INTO `finding_users`.`users`
(`id`, `email`, `password`, `api_token`)
VALUES
(1,'john@phpmicroservices.com',
'$2y$10$m5339OpNKEh5bL6Erbu9r..sjhaf2jDAT2nYueUqxnsR752g9xEFy',
NULL,);
手动插入的哈希密码对应于'123456'。Lumen 会为安全原因保存您的用户密码的哈希值。
打开 Postman 并尝试通过对http://localhost:8084/user
进行 POST 调用。您应该收到以下响应:
Unauthorized.
这是因为http://localhost:8084/user
方法受到身份验证中间件的保护。您可以在routes.php
文件中检查这一点。为了获取用户,需要提供有效的访问令牌。
获取有效访问令牌的方法是http://localhost:8084/login
,因此使用与我们添加的用户对应的参数进行 POST 调用,email = john@phpmicroservices.com
,密码为123456
。如果它们是正确的,我们将获得有效的访问令牌:
{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.
eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODQvbG9naW4iLCJ
pYXQiOjE0ODA4ODI4NTMsImV4cCI6MTQ4MDg4NjQ1MywibmJmIjox
NDgwODgyODUzLCJqdGkiOiJVVnRpTExZTFRWcEtyWnhsIiwic3
ViIjoxfQ.jjgZO_Lf4dlfwYiOYAOhzvcTQ4EGxJUTgRSPyMXJ1wg"}
现在,我们可以使用前面的访问令牌进行 POST 调用http://localhost:8084/user
,就像以前一样。这次,我们将获得用户信息:
{"id":1,"email":"john@phpmicroservices.com","api_token":null}
如您所见,使用有效的访问令牌保护您的方法非常简单。这将使您的应用程序更加安全。
访问控制列表
这是所有应用程序中非常常见的系统,无论其大小如何。访问控制列表(ACL)为我们提供了一种简单的方式来管理和过滤每个用户的权限。让我们更详细地看一下。
ACL 是什么?
应用程序用于识别应用程序的每个单个用户的方法是 ACL。这是一个系统,它告诉应用程序特定任务或操作的用户或用户组有什么访问权限或权限。
每个任务(函数或操作)都有一个属性来标识哪些用户可以使用它,ACL 是一个将每个任务与每个操作(如读取、写入或执行)关联的列表。
对于使用 ACL 的应用程序,ACL 具有以下两个特点优势:
-
管理:在我们的应用程序中使用 ACL 允许我们将用户添加到组中,并管理每个组的权限。此外,可以更容易地向许多用户或组添加、修改或删除权限。
-
安全性:为每个用户设置不同的权限对应用程序的安全性更好。它可以避免虚假用户或利用通过给予普通用户和管理员不同的权限来破坏应用程序。
对于我们基于微服务的应用程序,我们建议为每个微服务设置不同的 ACL;这样可以避免整个应用程序只有一个入口点。请记住,我们正在构建微服务,其中一个要求是微服务应该是隔离和独立的;因此,有一个微服务来控制其他微服务并不是一个好的做法。
这并不是一项困难的任务,这是有道理的,因为每个微服务应该有不同的任务,每个任务在权限方面对每个用户都是不同的。想象一下,我们有一个用户,该用户具有以下权限。该用户可以创建秘密并检查附近的秘密,但不允许创建战斗或新用户。在全局管理 ACL 将成为一个问题,因为当新的微服务添加到系统中时,甚至当新的开发人员加入团队并且他们必须理解全局 ACL 的复杂系统时,会出现可扩展性问题。正如你所看到的,最好为每个微服务设置一个 ACL 系统,这样当你添加一个新的微服务时,就不需要修改其余的 ACL。
如何使用 ACL
Lumen 为我们提供了一个身份验证过程,以便让用户注册、登录、注销和重置密码,并且还提供了一个名为Gate
的 ACL 系统。
Gate
允许我们知道特定用户是否有权限执行特定操作。这非常简单,可以在 API 的每个方法中使用。
要在 Lumen 上设置 ACL,必须通过从app->withFacades();
行中删除分号来启用门面;如果您的文件中没有这一行,请添加它。
在config/Auth.php
上创建一个新文件是必要的,文件中包含以下代码:
<?php
return [
'defaults' => [
'guard' => env('AUTH_GUARD', 'api'),
],
'guards' => [
'api' => [
'driver' => 'token',
'provider' => 'users'
],
],
'providers' => [
'users' => [
'driver' => 'eloquent',
// We should get model name from JWT configuration
'model' => app('config')->get('jwt.user'),
],
],
];
在我们的控制器上使用Gate
类来检查用户权限,需要上述代码。
设置好这些后,我们必须定义特定用户可用的不同操作或情况。为此,打开app/Providers/AuthServiceProvider.php
文件;在boot()
函数中,我们可以通过编写以下代码来定义每个操作或情况:
<?php
/* Code Omitted */
use Illuminate\Contracts\Auth\Access\Gate;
class AuthServiceProvider extends ServiceProvider
{
/* Code Omitted */
public function boot()
{
Gate::define('update-profile', function ($user, $profile) {
return $user->id === $profile->user_id;
});
}
一旦我们定义了情况,我们就可以将其放入我们的函数中。有三种不同的使用方法:allows、checks和denies。前两种是相同的,当定义的情况返回 true 时,它们返回 true,最后一种在定义的情况返回 false 时返回 true:
if (Gate::**allows**('update-profile', $profile)) {
// The current user can update their profile...
}
if (Gate::**denies**('update-profile', $profile)) {
// The current user can't update their profile...
}
正如你所看到的,不需要发送$user
变量,它会自动获取当前用户。
源代码的安全性
最有可能的情况是,您的项目将使用一些凭证连接到外部服务,例如数据库。您会把所有这些信息存储在哪里?最常见的方法是在源代码中有一个配置文件,您可以在其中放置所有凭证。这种方法的主要问题是您会提交凭证,任何有权访问源代码的人都将能够访问它们。不管您信任有权访问存储库的人,将凭证存储起来都不是一个好主意。
如果您不能在源代码中存储凭证,您可能想知道如何存储它们。您有两个主要选项:
-
环境变量
-
外部服务
让我们来看看每一个,这样您就可以选择哪个选项更适合您的项目。
环境变量
这种存储凭证的方式非常容易实现--您只需定义要存储在环境中的变量,稍后可以在源代码中获取它们。
我们选择的项目框架是 Lumen,使用这个框架非常容易定义您的环境变量,然后在代码中使用它们。最重要的文件是位于源代码根目录的.env
文件。默认情况下,这个文件在gitignore
中,以避免被提交,但框架附带了一个.env.example
示例,以便您可以查看如何定义这些变量。在这个文件中,您可以找到以下定义:
DB_CONNECTION=mysql
DB_HOST=localhost
DB_PORT=3306
DB_DATABASE=homestead
DB_USERNAME=homestead
DB_PASSWORD=secret
前面的定义将创建环境变量,您可以使用简单的env('DB_DATABASE');
或env('DB_DATABASE', 'default_value');
在代码中获取值。env()
函数支持两个参数,因此您可以定义一个默认值,以防您要获取的变量未定义。
使用环境变量的主要好处是您可以拥有不同的环境,而无需更改源代码中的任何内容;您甚至可以在不对代码进行任何更改的情况下更改值。
外部服务
这种存储凭证的方式使用外部服务来存储所有凭证,它们的工作方式与环境变量差不多。当您需要任何凭证时,您必须向该服务请求。
这些天主流的凭证存储系统之一是 HashiCorp Vault 项目,这是一个开源工具,允许您创建一个安全的地方来存储您的凭证。它有多个好处,我们其中一些重点包括以下几点:
-
HTTP API
-
密钥滚动
-
审计日志
-
支持多个秘密后端
使用外部服务的主要缺点是您为应用程序增加了额外的复杂性;您将添加一个新组件来管理和保持最新状态。
跟踪和监控
当您处理应用程序中的安全性时,重要的是要跟踪和监视其中发生的事情。在第六章 监控中,我们实现了 Sentry 作为日志和监控系统,并且还添加了 Datadog 作为我们的 APM,因此您可以使用这些工具来跟踪发生的情况并发送警报。
然而,您想要跟踪什么?让我们想象一下,您有一个登录系统,这个组件是一个很好的地方来添加您的跟踪。如果您跟踪每次用户登录失败,您就可以知道是否有人试图攻击您的登录系统。
您的应用程序是否允许用户添加、修改和删除内容?跟踪内容的任何更改,以便您可以检测到不受信任的用户。
在安全方面,没有关于要跟踪什么和不要跟踪什么的标准,只需运用常识。我们的主要建议是创建一个敏感点列表,至少涵盖用户可以登录、创建内容或删除内容的地方,并将这些列表用作添加跟踪和监控的起点。
最佳实践
与应用程序的任何其他部分一样,当您处理安全性时,有一些众所周知的最佳实践需要遵循,或者至少要意识到以避免未来的问题。在这里,您可以找到与 Web 开发相关的最常见的最佳实践。
文件权限和所有权
文件/文件夹权限和所有权是最基本的安全机制之一。假设您正在使用 Linux/Unix 系统,主要建议是将您的源代码的所有权分配给 Web 服务器或 PHP 引擎用户。关于文件权限,您应该使用以下设置:
-
目录的 500 权限(dr-x------):此设置防止意外删除或修改目录中的文件。
-
文件的 400 权限(-r--------):此设置防止任何用户覆盖文件。
-
700 权限(drwx------):这适用于任何可写目录。它给予所有者完全控制,并用于上传文件夹。
-
600 权限(-rw-------):这个设置适用于任何可写文件。它避免了任何非所有者的用户对您的文件进行修改。
PHP 执行位置
通过仅允许在选定路径上执行 PHP 脚本并拒绝在敏感(可写)目录中执行任何类型的执行,例如,任何上传目录,避免任何未来问题。
永远不要相信用户
作为一个经验法则,永远不要相信用户。过滤来自任何人的任何输入,您永远不知道表单提交背后的黑暗意图。当然,永远不要仅依赖于前端过滤和验证。如果您在前端添加了过滤和验证,请在后端再次进行过滤和验证。
SQL 注入
没有人希望他们的数据被暴露或被未经许可的人访问,对您的应用程序的这种攻击是由于输入的过滤或验证不当。想象一下,您使用一个字段来存储未经正确过滤的用户名称,恶意用户可以使用此字段执行 SQL 查询。为了帮助您避免这个问题,当您处理数据库时,请使用 ORM 过滤方法或您喜欢的框架中可用的任何过滤方法。
跨站脚本 XSS
这是对您的应用程序的另一种攻击类型,是由于过滤不当。如果您允许用户在页面上发布任何类型的内容,一些恶意用户可能会未经您的许可向页面添加脚本。想象一下,您的页面上有评论部分,您的输入过滤不是最好的,恶意用户可以添加一个作为评论的脚本,打开垃圾邮件弹出窗口。记住我们之前告诉过您的--永远不要相信您的用户--过滤和验证一切。
会话劫持
在这种攻击中,恶意用户窃取另一个用户的会话密钥,使恶意用户有机会像其他用户一样。想象一下,您的应用程序涉及财务信息,一个恶意用户可以窃取管理员会话密钥,现在这个用户可以获得他们需要的所有信息。大多数情况下,会话是通过 XSS 攻击窃取的,所以首先要尽量避免任何 XSS 攻击。另一种减轻这个问题的方法是防止 JavaScript 访问会话 ID;您可以在php.ini
中使用session.cookie.httponly
设置来做到这一点。
远程文件
从您的应用程序包含远程文件可能非常危险,您永远无法 100%确定您包含的远程文件是否可信。如果在某个时刻,被包含的远程文件受到损害,攻击者可以为所欲为,例如,从您的应用程序中删除所有数据。
避免这种问题的简单方法是在您的php.ini
中禁用远程文件。打开它并禁用以下设置:
-
allow_url_fopen
:默认情况下启用 -
allow_url_include
:默认情况下禁用;如果禁用allow_url_fopen
设置,它会强制禁用此设置。
密码存储
永远不要以明文存储任何密码。当我们说永远不要,我们是指永远不要。如果你认为你需要检查用户的密码,那么你是错误的,任何恢复或补充丢失密码的操作都需要通过恢复系统进行。当你存储一个密码时,你存储的是与一些随机盐混合的密码哈希。
密码策略
如果你保留敏感数据,并且不希望你的应用程序因用户的密码而暴露,那么请制定非常严格的密码策略。例如,你可以创建以下密码策略来减少破解和字典攻击:
-
至少 18 个字符
-
至少 1 个大写字母
-
至少 1 个数字
-
至少 1 个特殊字符
-
以前未使用过
-
不是用户数据的串联,将元音变成数字
-
每 3 个月过期
源代码泄露
将源代码放在好奇的眼睛看不见的地方,如果你的服务器出了问题,所有的源代码都将以明文形式暴露出来。避免这种情况的唯一方法是只在 web 服务器根目录中保留所需的文件。另外,要小心特殊文件,比如composer.json
。如果我们暴露了我们的composer.json
,每个人都会知道我们每个库的不同版本,从而轻松地了解可能存在的任何错误。
目录遍历
这种攻击试图访问存储在 web 根目录之外的文件。大多数情况下,这是由于代码中的错误导致的,因此恶意用户可以操纵引用文件的变量。没有简单的方法可以避免这种情况;然而,如果你使用外部框架或库,保持它们最新将有所帮助。
这些是你需要注意的最明显的安全问题,但这并不是一个详尽的列表。订阅安全新闻通讯,并保持所有代码最新,以将风险降到最低。
总结
在这一章中,我们谈到了安全和认证。我们向您展示了如何加密数据和通信层;我们甚至向您展示了如何构建一个强大的登录系统,以及如何处理应用程序的秘密。安全是任何项目中非常重要的一个方面,所以我们给出了一个常见安全风险的小列表,当然,主要建议是——永远不要相信你的用户。
第八章:部署
在前几章中,您已经学会了如何基于微服务开发应用程序。现在,是时候学习如何部署您的应用程序,学习最佳的自动化策略和回滚应用程序的方法,以及在需要时进行备份和恢复。
依赖管理
正如我们在第五章中提到的,微服务开发,Composer是最常用的依赖管理工具;它可以帮助我们在部署过程中将项目从开发环境移动到生产环境。
关于部署过程的最佳工作流有不同的观点,因此让我们看一下每种情况的优缺点。
Composer require-dev
Composer 在他们的composer.json
中提供了一个名为require-dev
的部分,用于在开发环境中使用,并且当我们需要在应用程序中安装一些不需要在生产环境中的库时,我们必须使用它。
正如我们已经知道的,使用 Composer 安装新库的命令是composer require library-name
,但如果我们想安装新的库,比如测试库、调试库或者其他在生产环境下没有意义的库,我们可以使用composer require-dev library-name
。它会将库添加到require-dev
部分,当我们将项目部署到生产环境时,我们应该在执行composer install --no-dev
或composer update --no-dev
时使用--no-dev
参数,以避免安装开发库。
.gitignore 文件
通过.gitignore
文件,可以忽略您不想跟踪的文件或文件夹。尽管 Git 是一个版本控制工具,但许多开发人员在部署过程中使用它。.gitignore
文件包含一系列在更改时不会在存储库中跟踪的文件和文件夹。这通常用于上传包含用户上传的图像或其他文件的文件夹,也用于 vendor 文件夹,该文件夹包含项目中使用的所有库。
Vendor 文件夹
vendor
文件夹包含我们应用程序中使用的所有库。如前所述,关于如何使用vendor
文件夹有两种不同的思考方式。在生产中包含 Composer 以便在应用程序部署后从存储库获取vendor
文件夹,或者在生产中将开发中使用的库下载到生产中。
部署工作流
部署工作流可能因项目需求而异。例如,如果您想在存储库中保留整个项目,包括vendor
文件夹,或者如果您希望在项目部署后从 Composer 获取库。在本章中,我们将看一下一些最常见的工作流。
存储库中的 Vendor 文件夹
第一个部署工作流在存储库中有整个应用程序。这是当我们在开发环境中第一次使用 Composer 并将vendor
文件夹推送到我们的存储库时,所有的库都将保存在存储库中。
因此,在生产中,我们将从存储库中获取整个项目,而无需进行 Composer 更新,因为我们的库已经在部署中投入生产。因此,在生产中不需要 Composer。
在存储库中包含vendor
文件夹的优点如下:
-
您知道相同的代码(包括库)在开发中是可以工作的。
-
在生产中更新库的风险较小。
-
在部署过程中,您不依赖外部服务。有时,库在特定时刻不可用。
在存储库中包含vendor
文件夹的缺点如下:
-
你的存储库必须存储已经存储在 Composer 上的库。如果你需要许多或大型库,所需的空间可能会成为一个问题。
-
你正在存储不属于你的代码。
生产环境中的 Composer
第二个部署工作流程有两种不同的进行方式,但它们都不需要将vendor
文件夹存储在存储库中;一旦代码部署到生产环境,它们将从 Composer 获取库。
一旦代码部署到生产环境,将会执行composer update
命令,可以是手动或自动在部署过程中执行。
在生产环境中运行 Composer 的优点如下:
-
你可以在你的存储库中节省空间
-
你可以在生产环境中执行–optimize-autoload 以映射添加的库
在生产环境中运行 Composer 的缺点如下:
-
部署过程将取决于外部服务。
-
在更新包时,某些情况下存在重大风险。例如,如果一个库突然被修改或损坏,你的应用程序将会崩溃。
前端依赖关系
需要知道在前端也可以管理依赖关系,因此可以选择是将其放在存储库中还是不放。Grunt 和 Gulp 是两种最常用的工具,用于自动化应用程序中的任务。此外,如果基于微服务的应用程序有前端部分,你应该使用以下工具来管理样式和资产。
Grunt
Grunt是一个用于自动化应用程序任务的工具。Grunt 可以帮助你合并或压缩 JS 和 CSS 文件,优化图像,甚至帮助你进行单元测试。
每个任务都是由 JavaScript 开发的 Grunt 插件实现的。它们也使用 Node.js,因此使 Grunt 成为一种多平台工具。你可以在 gruntjs.com/plugins
上查看所有可用的插件。
学习 Node.js 并不是必要的,只需安装 Node.js,你就可以安装 Grunt(以及许多其他包)所需的 Node Packaged Modules。一旦安装了 Node.js,运行以下命令:
**npm install grunt-cli -g**
现在,你可以创建一个package.json
,它将被 NPM 命令读取:
{
"name": "finding-secrets",
"version": "0.1.0",
"devDependencies": {
"grunt": "~0.4.1"
}
}
然后,npm install
将安装package.json
文件中包含的依赖项。Grunt 将存储在node_modules
文件夹中。一旦安装了 Grunt,就需要创建一个Gruntfile.js
来定义自动化任务,如下面的代码所示:
'use strict';
module.exports = function (grunt) {
grunt.**initConfig**({
pkg: grunt.file.readJSON('package.json'),
});
//grunt.**loadNpmTasks**('grunt-contrib-xxxx');
//grunt.**registerTask**('default', ['xxxx']);
};
有三个部分来定义自动化任务:
-
InitConfig:这是指由 Grunt 执行的任务
-
LoadNpmTask:这用于加载所需的插件以执行任务
-
RegisterTask:这注册将运行的任务
一旦决定安装哪个插件并定义所有必要的任务,就在终端上运行 grunt 来执行它们。
Gulp
与 Grunt 一样,Gulp也是一个用于自动化任务的工具,它也是基于 NodeJS 开发的,因此需要安装 Node.js 才能安装 NPM。一旦安装了 Node.js,就可以通过运行以下命令全局安装 Gulp:
**npm install -g gulp**
另一种安装 gulp 的方式,也是推荐的选项,是本地安装,你可以使用以下命令来完成:
**npm install --save-dev gulp**
所有任务都应该包含在位于根项目上的gulpfile.js
中以进行自动化:
var gulp = require('gulp');
gulp.task('default', function () {
});
上述代码非常简单。正如你所看到的,代码是gulp.task
,任务名称,然后是为该任务名称定义的function
。
一旦你定义了函数,你就可以运行gulp
。
SASS
CSS 很复杂,庞大,难以维护。你能想象维护一个有成千上万行的文件吗?这就是 Sass 可以发挥作用的地方。这是一个预处理器,为 CSS 添加了变量、嵌套、混合、继承等功能,使 CSS 成为一种真正的开发语言。
Syntactically Awesome Stylesheets (SASS)是 CSS 的元语言。它是一种被翻译成 CSS 的脚本语言。SassScript 是 Sass 语言,它有两种不同的语法:
-
缩进语法: 这使用缩进来分隔块代码,换行符来分隔规则
-
SCSS: 这是 CSS 语法的扩展,它使用大括号来分隔代码块,分号来分隔块内的行
缩进的语法有.sass
扩展名,SCSS 有.scss
扩展名。
Sass 非常简单易用。一旦安装,只需在终端上运行sass input.scss output.css
。
Bower
Bower是一个类似 Composer 的依赖管理工具,但它适用于前端。它也基于 Node.js,因此一旦安装了 Node.js,您就可以使用 NPM 安装 Bower。使用 Bower,可以更新所有前端库,而无需手动更新它们。一旦安装了 Node.js,安装 Bower 的命令如下:
**npm install -g bower**
然后,您可以执行bower init
来创建项目上的bower.json
文件。
bower.json
文件会让您想起composer.json
:
{
"name": “bower-test”,
"version": "0.0.0",
"authors": [
"Carlos Perez and Pablo Solar"
],
"dependencies": {
"jquery": "~2.1.4",
"bootstrap": "~3.3.5"
"angular": "1.4.7",
"angular-route": "1.4.7",
}
}
在上述代码中,您可以看到添加到项目中的依赖项。它们可以被修改,以便像 Composer 一样在您的应用程序上安装这些依赖项。此外,与 Composer 一起使用 Bower 的命令非常相似:
-
bower install: 这是为了安装
bower.json
中的所有依赖项 -
bower update: 这是为了更新
bower.json
中包含的依赖项 -
bower install package-name: 这会在 Bower 上安装一个包
部署自动化
在某个时刻,您的应用将被部署到生产环境。如果您的应用很小,只使用了少量容器/服务器,那么一切都会很好,您可以轻松地手动管理所有资源(容器、虚拟机、服务器等)在每次部署时。但是,如果您有数百个资源需要在每次部署时更新,那该怎么办呢?在这种情况下,您需要某种部署机制;即使您有一个小项目和一个容器/服务器,我们也建议自动化您的部署。
使用自动部署流程的主要好处如下列出:
-
易于维护: 大多数时候,部署所需的步骤可以存储在文件中,这样您就可以编辑它们。
-
可重复: 您可以一遍又一遍地执行部署,每次都会按照相同的步骤进行。
-
更少出错: 我们是人类,作为人类,我们在多任务处理时会犯错。
-
易于跟踪: 有多种工具可用于记录每次提交发生的一切。这些工具也可以用于创建可以进行部署的用户组。您可以使用的最常见的工具是Jenkins、Ansible Tower和Atlassian Bamboo。
-
更容易更频繁地发布: 拥有一个部署流水线将帮助您更快地开发和部署,因为您将不会花时间处理将代码推送到生产环境。
让我们看看一些自动化部署的方法,从最简单的选项开始,逐渐增加复杂性和功能。我们将分析每种方法的优缺点,这样,在本章结束时,您将能够选择适合您项目的完美部署系统。
简单的 PHP 脚本
这是您可以自动化部署的最简单方式--您可以向您的代码中添加一个脚本(在公共位置),如下所示:
<?php
define('MY_KEY', 'this-is-my-secret-key');
if ($_SERVER['REQUEST_METHOD'] ===
'POST' && $_REQUEST['key'] === MY_KEY) {
echo shell_exec('git fetch && git pull origin master');
}
在上述脚本中,只有在使用正确的密钥到达脚本时,我们才会从主分支中拉取。正如您所看到的,这非常简单,任何知道秘钥的人都可以触发它,例如,通过浏览器。如果您的代码仓库允许设置 webhook,您可以使用它们在每次推送或提交时触发您的脚本。
这是这种部署方法的优点:
-
如果所需工作很小,例如
git pull
,那么创建起来很容易 -
很容易跟踪脚本的更改
-
它很容易被您或任何外部工具触发
以下是这种部署方法的缺点:
-
Web 服务器用户需要能够使用存储库
-
当您需要处理分支或标签时,它会变得更加复杂
-
当您需要部署到多个实例时,它不容易使用,您将需要像 rsync 这样的外部工具
-
不太安全。如果您的密钥被第三方发现,他们可以在您的服务器上部署任何他们想要的东西
在理想的世界中,您对生产的所有提交都将是完美和纯净的,但您知道事实--在将来的某个时候,您将需要回滚所有更改。如果您已经使用了这种部署方法,并且想要创建一个回滚策略,您必须增加您的 PHP 脚本的复杂性,以便它可以管理标签。另一个不推荐的选择是,而不是向您的脚本添加回滚,您可以执行,例如,git undo
并再次推送所有更改。
Ansible 和 Ansistrano
Ansible是一个 IT 自动化引擎,可用于自动化云配置,管理配置,部署应用程序或编排服务等其他用途。该引擎不使用代理,因此无需额外的安全基础设施,它被设计为通过 SSH 使用。用于描述自动化作业(也称为playbooks)的主要语言是 YAML,其语法类似于英语。由于所有 playbooks 都是简单的文本文件,因此可以轻松地将它们存储在存储库中。在 Ansible 中可以找到的一个有趣的功能是其 Galaxy,这是一个可以在 playbooks 中使用的附加组件中心。
Ansible 要求
Ansible 使用 SSH 协议管理所有主机,您只需要在一台机器上安装此工具--您将用来管理主机群的机器。控制机器的主要要求是 Python 2.6 或 2.7(从 Ansible 2.2 开始支持 Python 3),您可以使用除 Microsoft Windows 之外的任何操作系统。
托管主机的唯一要求是 Python 2.4+,这在大多数类 UNIX 操作系统中默认安装。
Ansible 安装
假设您的控制机器上有正确的 Python 版本,使用包管理器很容易安装 Ansible。
在 RHEL、CentOS 和类似的 Linux 发行版上执行以下命令来安装 Ansible:
**sudo yum install ansible**
Ubuntu 命令如下:
**sudo apt-get install software-properties-common \
&& sudo apt-add-repository ppa:ansible/ansible \
&& sudo apt-get update \
&& sudo apt-get install ansible**
FreeBSD 命令如下:
**sudo pkg install ansible**
Mac OS 命令如下:
**sudo easy_install pip \
&& sudo pip install ansible**
什么是 Ansistrano?
Ansistrano是一个由ansistrano.deploy
和ansistrano.rollback
组成的开源项目,这两个 Ansible Galaxy 角色用于轻松管理您的部署。它被认为是 Capistrano 的 Ansible 端口。
一旦我们在我们的机器上有了 Ansible,使用以下命令很容易安装 Ansistrano 角色:
**ansible-galaxy install carlosbuenosvinos.ansistrano-deploy \ carlosbuenosvinos.ansistrano-rollback**
执行此命令后,您将能够在您的 playbooks 中使用 Ansistrano。
Ansistrano 是如何工作的?
Ansistrano 遵循 Capistrano 流程部署您的应用程序:
-
设置阶段:在此阶段,Ansistrano 创建将容纳应用程序发布的文件夹结构。
-
代码更新阶段:在此阶段,Ansistrano 将您的发布放在您的主机上;它可以使用 rsync、Git 或 SVN 等其他方法。
-
符号链接阶段(见下文):在部署新发布后,它会更改当前软链接,将可用发布指向新发布位置。
-
清理阶段:在此阶段,Ansistrano 会删除存储在您的主机上的旧发布。您可以通过
ansistrano_keep_releases
参数在您的 playbooks 中配置发布的数量。在以下示例中,您将看到此参数的工作方式
提示
使用 Ansistrano,您可以挂钩自定义任务以在每个任务之前和之后执行。
让我们看一个简单的示例来解释它是如何工作的。假设你的应用程序部署到/var/www/my-application
;在第一次部署后,这个文件夹的内容将类似于以下示例:
**-- /var/www/my-application
|-- current -> /var/www/my-application/releases/20161208145325
|-- releases
| |-- 20161208145325
|-- shared**
如前面的示例所示,当前的符号链接指向我们在主机上拥有的第一个版本。你的应用程序将始终可在相同路径/var/www/my-application/current
中使用,因此你可以在任何需要的地方使用这个路径,例如 NGINX 或 PHP-FPM。
随着你的部署继续进行,Ansistrano 将为你处理部署。下一个示例将展示在第二次部署后你的应用程序文件夹将会是什么样子:
**-- /var/www/my-application
|-- current -> /var/www/my-application/releases/20161208182323
|-- releases
| |-- 20161208145325
| |-- 20161208182323
|-- shared**
如前面的示例所示,现在我们的主机上有两个版本,并且符号链接已更新,指向你的代码的新版本。如果你使用 Ansistrano 进行回滚会发生什么?很简单,这个工具将删除你的主机上的最新版本,并更新符号链接。在我们的示例中,你的应用程序文件夹内容将类似于这样:
**-- /var/www/my-application
|-- current -> /var/www/my-application/releases/20161208145325
|-- releases
| |-- 20161208145325
|-- shared**
提示
为了避免问题,如果你尝试回滚并且 Ansistrano 找不到要移动到的先前版本,它将不执行任何操作,保持你的主机没有变化。
使用 Ansistrano 进行部署
现在,让我们使用 Ansible 和 Ansistrano 创建一个小型的自动化系统。我们假设你有一个已知且持久的基础架构可用,你将在其中推送你的应用程序或微服务。在你的开发环境中创建一个文件夹,用于存放所有的部署脚本。
在我们的情况下,我们之前在本地环境中创建了三个启用了 SSH 的虚拟机。请注意,我们没有涵盖这些虚拟机的配置,但如果你愿意,你甚至可以使用 Ansible 来为你完成这些配置。
你需要创建的第一件事是一个hosts
文件。在这个文件中,你可以存储和分组所有的服务器/主机,以便以后在部署中使用它们:
**[servers:children]**
**production**
**staging**
**[production]
192.168.99.200
192.168.99.201
[stageing]
192.168.99.100**
在上面的配置中,我们创建了两组主机-production
和staging
。在每一个组中,我们有一些可用的主机;在我们的情况下,我们设置了我们本地虚拟机的 IP 地址以进行测试,但如果你愿意,你也可以使用 URI。将主机分组的一个优势是你甚至可以创建更大的组;例如,你可以创建一个由其他组组成的组。例如,我们有一个servers
组,包含了所有的生产和测试主机。如果你想知道如果你有一个动态环境会发生什么,没问题;Ansible 可以帮你,并提供了多个连接器,你可以使用它们来获取你的动态基础架构,例如来自 AWS 或 Digital Ocean 等。
一旦你的hosts
文件准备好了,现在是时候创建我们的deploy.yml
文件了,我们将在其中存储所有我们想要在部署中执行的任务。创建一个包含以下内容的deploy.yml
文件:
**---
- name: Deploying a specific branch to the servers
hosts: servers
vars:
ansistrano_allow_anonymous_stats: no
ansistrano_current_dir: "current"
ansistrano_current_via: "symlink"
ansistrano_deploy_to: "/var/www/my-application"
ansistrano_deploy_via: "git"
ansistrano_keep_releases: 5
ansistrano_version_dir: "releases"
ansistrano_git_repo: "git@github.com:myuser/myproject.git"
ansistrano_git_branch: "{{ GIT_BRANCH|default('master') }}"
roles:
- { role: carlosbuenosvinos.ansistrano-deploy }**
多亏了 Ansistrano,我们的部署任务非常容易定义,如前面的示例所示。我们所做的是创建一个新任务,它将在标记为 servers 的所有主机上执行,并为 Ansistrano 角色定义一些可用的变量。在这里,我们定义了我们将在每个主机上部署我们的应用程序的位置,我们将使用的部署方法(Git),我们将在主机上保留多少个版本(5),以及我们想要部署的分支。
Ansible 的一个有趣的特性是你可以从命令行传递变量到你的通用部署过程中。这就是我们在下面这行中所做的:
**ansistrano_git_branch: "{{ GIT_BRANCH|default('master') }}"**
在这里,我们使用GIT_BRANCH
变量来定义我们想要部署的分支;如果 Ansible 找不到这个定义的变量,它将使用 master 分支。
你准备好测试我们所做的了吗?打开一个终端,转到存储部署任务的位置。假设你想要将最新版本的代码部署到生产主机上,你可以使用以下命令来完成:
**ansible-playbook deploy.yml --extra-vars "GIT_BRANCH=master" --limit production -i hosts**
在上述命令中,我们告诉 Ansible 使用我们的deploy.yml
playbook,并且我们还定义了我们的GIT_BRANCH
为 master,以便部署该分支。由于我们在 hosts 文件中有所有的主机,并且我们只想将部署限制在production
主机上,我们使用--limit
production
将执行限制到所需的主机。
现在,想象一下,您已经准备好一个新版本,您的所有代码都已提交并标记为v1.0.4
标签,您想将此版本推送到您的演示环境。您可以使用一个非常简单的命令来完成:
**ansible-playbook deploy.yml --extra-vars "GIT_BRANCH=v1.0.4" --limit staging -i hosts**
正如您所看到的,使用 Ansible/Ansistrano 部署您的应用非常容易,甚至可以更轻松地回滚到先前部署的版本。要管理回滚,您只需要创建一个新的 playbook。创建一个名为rollback.yml
的文件,内容如下:
**---
- name: Rollback
hosts: servers
vars:
ansistrano_deploy_to: "/var/www/my-application"
ansistrano_version_dir: "releases"
ansistrano_current_dir: "current"
roles:
- { role: carlosbuenosvinos.ansistrano-rollback }**
在上述代码片段中,我们使用了 Ansistrano 回滚角色来回滚到先前部署的版本。如果您的主机中只有一个版本,Ansible 将不会撤消更改,因为这是不可能的。您还记得我们在deploy.yml
文件中设置的名为ansistrano_keep_releases
的变量吗?这个变量非常重要,可以知道您的主机上可以执行多少次回滚,因此根据您的需求进行调整。要将生产服务器回滚到先前的版本,您可以使用以下命令:
**ansible-playbook rollback.yml --limit production -i hosts**
正如您所看到的,Ansible 是一个非常强大的工具,您可以用它进行部署,但它不仅仅用于部署;您甚至可以用它进行编排,例如。有了充满活力的社区和 RedHat 支持该项目,Ansible 是一个必不可少的工具。
提示
Ansible 有一个企业版的 Web 工具,您可以用它来管理所有的 Ansible playbooks。尽管它需要付费订阅,但如果您管理的节点少于十个,您可以免费使用它。
其他部署工具
正如您可以想象的,有多种不同的工具可以用来进行部署,我们无法在本书中涵盖所有这些工具。我们想向您展示一个简单的(PHP 脚本)和一个更复杂和强大的(Ansible),但我们不希望您在不了解其他可以使用的工具的情况下完成本章:
-
Chef:这是一个有趣的开源工具,您可以用它来管理基础架构作为代码。
-
Puppet:这是一个开源的配置管理工具,有一个付费的企业版本。
-
Bamboo:这是 Atlassian 的一个持续集成服务器,当然,您需要付费才能使用这个工具。这是您可以与 Atlassian 产品目录结合使用的最完整的工具。
-
Codeship:这是一个云持续部署解决方案,旨在成为一个专注于运行测试和部署应用的端到端解决方案的工具
-
Travis CI:这是一个类似于 Jenkins 用于持续集成的工具;您也可以使用它进行部署。
-
Packer、Nomad 和 Terraform:这些是 HashiCorp 的不同工具,您可以用它们来编写您的基础架构作为代码。
-
Capistrano:这是一个众所周知的远程服务器自动化和部署工具,易于理解和使用。
高级部署技术
在前面的部分,我们向您展示了一些部署应用程序的方法。现在,是时候使用一些在大型部署中使用的高级技术来增加复杂性了。
使用 Jenkins 进行持续集成
Jenkins 是最知名的持续集成应用程序;作为一个开源项目,它允许您以高度灵活的方式创建自己的流水线。它是用 Java 构建的,因此这是您安装此工具时的主要要求。使用 Jenkins,一切都更容易,甚至安装。例如,您可以只用几个命令启动一个带有最新版本的 Docker 容器:
**docker pull jenkins \
&& docker run -d -p 49001:8080 -t jenkins**
上述命令将下载并创建一个带有最新 Jenkins 版本的新容器,准备好使用。
Jenkins 背后的主要思想是工作的概念。工作是一系列可以自动或手动执行的命令或步骤。通过工作和插件的使用(可以从 Web UI 下载),你可以创建自定义的工作流程。例如,你可以创建一个类似下一个的工作流程,它会在提交/推送发生时由你的存储库触发:
-
一个单元测试插件开始测试你的应用程序。
-
一旦通过,一个代码嗅探器插件检查你的代码。
-
如果前面的步骤都没问题,Jenkins 通过 SSH 连接到远程主机。
-
Jenkins 拉取远程主机中的所有更改。
上面的例子很简单;你可以改进和复杂化这个例子,使用 Ansible playbook 而不是 SSH。
这个应用程序非常灵活,你可以用它来检查主从数据库的复制状态。在我们看来,这个应用程序值得一试,你可以找到适应这个软件的各种任务的例子。
蓝绿部署
这种部署技术依赖于拥有基础设施的副本,这样你就可以在当前版本的应用程序旁边安装新版本。在应用程序前面,你有一个路由器或负载均衡器(LB),用于将流量重定向到所需的版本。一旦你的新版本准备好,你只需要更改你的路由器/LB,将所有流量重定向到新版本。拥有两套发布版本可以让你灵活地进行回滚,并且可以确保新版本运行良好。参考以下图表:
微服务的蓝绿部署
正如你从上面的图像中看到的,蓝绿部署可以在应用程序的任何级别进行。在我们的示例图像中,你可以看到一个微服务正在准备部署一个新版本,但尚未发布,你还可以看到一些微服务发布了他们的最新代码版本,保留了以前的版本以便回滚。
这种技术被大型科技公司广泛使用,没有任何问题;主要的缺点是你需要运行应用程序的资源增加了--更多的资源意味着在基础设施上花更多的钱。如果你想试一试,这种部署中最常用的负载均衡器是ELB、Fabio和Traefik等。
金丝雀发布
金丝雀发布是一种类似于蓝绿部署的部署技术,只是一次只升级少量主机。一旦你有了你想要的部分主机的发布版本,使用 cookie、lb 或代理,一部分流量被重定向到新版本。
这种技术允许你用一小部分流量测试你的更改;如果应用程序表现如预期,我们继续将更多主机迁移到新版本,直到所有流量都被重定向到你的应用程序的新版本。看一下下面的图表:
微服务的金丝雀发布
正如你从上面的图像中看到的,有四个微服务实例;其中三个保留了旧版本的应用程序,只有一个有最新版本。LB 用于在不同版本之间分配流量,将大部分流量发送到v1.0.0,只有一小部分流量发送到v2.0.0。如果一切正常,下一步将是增加v2.0.0实例的数量,减少v1.0.0实例的数量,并将更多的流量重定向到新版本。
这种部署技术会给您当前的基础设施增加一些复杂性,但允许您开始使用小部分用户/流量测试您的更改。另一个好处是重复使用您现有的基础设施;您不需要复制一套主机来进行部署。
不可变基础设施
如今,技术行业的一个趋势是使用不可变基础设施。当我们说不可变基础设施时,我们的意思是您在开发环境中拥有的内容稍后会在没有任何更改的情况下部署到生产环境中。您可以通过容器化技术和一些工具(如 Packer)实现这一点。
使用 Packer,您可以创建应用程序的映像,然后通过您的基础设施分发这个映像。这种技术的主要好处是您确保您的生产环境的行为与您的开发环境相同。另一个重要的方面是安全性;想象一下您的 NGINX 容器中发生了安全漏洞,通过基础映像更新的新版本将解决问题,并且将在不需要外部干预的情况下与您的应用程序一起传播。
备份策略
在任何项目中,备份是避免数据丢失的最重要方式之一。在本章中,我们将学习在应用程序中使用的备份策略。
什么是备份?
备份是将代码或数据保存在与通常存储代码或数据的地方不同的地方的过程。这个过程可以使用不同的策略来完成,但它们都有相同的目标--不丢失数据,以便将来可以访问。
为什么重要?
备份可以出于两个原因而进行。第一个原因是由于黑客攻击、数据损坏或在生产服务器上执行查询时出现任何错误而导致数据丢失。此备份将帮助恢复丢失或损坏的数据。
第二个原因是政策。法律规定必须保存用户数据多年。有时,这个功能是由系统完成的,但备份是存储这些数据的另一种方式。
总之,备份让我们保持冷静。我们确保我们正在正确地做事情,并且在任何灾难发生时,我们有解决方案可以快速修复它们,而且没有(重大的)数据丢失。
我们需要备份什么和在哪里备份
如果我们在应用程序中使用一些仓库,比如 Git,这可以是我们的文件备份位置。用户上传的资产或其他文件也应该备份。
查看.gitignore
文件并确保我们已备份该文件夹中包括的所有文件和文件夹是备份所有必要文件的一个良好做法。
此外,最重要和宝贵的备份是数据库。这应该更频繁地备份。
提示
不要将备份存储在应用程序正在运行的相同位置。尝试为备份副本选择不同的位置。
备份类型
备份可以是完整的、增量的或差异的。我们将看看它们之间的区别以及它们的工作原理。应用程序通常将不同类型的备份结合在一起:完整备份与增量或差异备份。
完整备份
完整备份是基本备份;它包括生成当前应用程序的完整副本。大型应用程序定期使用此选项,而小型应用程序可以每天使用它。
优点如下:
-
完整的应用程序备份在一个文件中
-
它总是生成完整副本
缺点如下:
-
生成它可能需要很长时间
-
备份将需要大量的磁盘空间
请注意,通常最好在备份文件名中包含日期/时间,这样您只需查看文件名就可以知道何时创建的。
增量和差异备份
增量备份复制自上次备份以来发生变化的数据。这种备份应该包括datetime
,以便在下次生成新备份时由备份工具检查。
优点如下:
-
比完整备份更快
-
占用更少的磁盘空间
缺点如下:
- 整个应用程序不会存储在单个生成的备份中
还有另一种类型,称为差异备份。这类似于增量备份(复制自上次备份以来发生变化的所有数据);它在第一次执行后将继续复制自上次完整备份以来的所有修改数据。
因此,它会生成比增量备份更多的数据,但在第一次之后比完整备份少。这种类型介于完整和增量之间。它需要比增量备份更多的空间和时间,但比完整备份少。
备份工具
可以找到许多备份工具。在大型项目中最常见的工具是 Bacula。对于小型项目,也有其他类似的工具,比如经常运行的自定义脚本。
Bacula
Bacula是一个备份管理工具。这个应用程序管理和自动化备份任务,非常适合大型应用程序。这个工具设置有点复杂,但一旦准备好,就不需要进行任何其他更改,它将可以正常工作。
Bacula 有三个不同的部分,每个部分都需要安装在不同的软件包中:
-
管理者:这个管理所有备份过程
-
存储:这是备份存储的地方
-
文件:这是我们的应用程序运行的客户端机器
在我们基于微服务的应用程序中,我们将有许多文件(每个微服务一个文件),还可以有许多存储位置(为了备份有不同的位置)和管理者。
这个工具使用守护进程。每个部分都有自己的守护进程,并且每个守护进程都遵循自己的配置文件。配置文件在安装过程中设置,只需要更改一些小的东西,比如远程 IP 地址、证书或计划自动化备份。
Bacula 的安全性非常出色--每个部分(管理者、存储和文件)都有自己的密钥,并且根据连接进行加密。此外,Bacula 允许 TLS 连接以提供更多安全性。
Bacula 允许进行完整、增量或差异备份,并且可以在管理者部分自动化。
Percona xtrabackup
XtraBackup是一个开源工具,可以在不阻塞数据库的情况下对应用程序进行热备份。这可能是这个应用程序最重要的特性。
这个工具允许 MySQL 数据库(如 MariaDB 和 Percona)执行流式传输和压缩,并进行增量备份。
优点如下:
-
快速备份和恢复
-
备份期间无中断的事务处理
-
节省磁盘空间和网络带宽
-
自动备份验证
自定义脚本
在生产中使用自定义脚本是创建备份的最快方法。这是一个脚本,当运行时,通过执行mysqldump
(如果我们使用的是 MySQL 数据库),压缩所需的文件,并将它们放在所需的位置(理想情况下是远程的不同机器)来创建备份。
这些脚本应该由 cronjob 执行,可以设置为每天或每周运行一次。
验证备份
作为备份策略的一部分,有技术来验证备份中存储的数据是一个好习惯。如果备份中有错误,就像没有任何备份一样。
为了检查我们的备份是否有效,没有损坏,并且按预期工作,需要经常进行模拟恢复,以避免在将来需要恢复时出现故障。
做好末日的准备
没有人想要恢复备份,但是在微服务出现故障或损坏并且我们需要快速反应的情况下,做好准备是必要的。
第一步是知道你的应用程序最近的备份在哪里,以便尽快恢复它。
如果问题与数据库有关,我们必须使应用程序停机,恢复数据库备份,检查其是否正常工作,然后再次使应用程序上线。
如果问题与资产或文件之类的东西有关,可以在不使应用程序停机的情况下进行恢复。
保持冷静并备份你的数据。
总结
现在你知道如何将你的应用程序部署到生产环境并自动化部署过程。此外,你还学会了需要部署什么以及可以从任何依赖管理中获取它,如何在必要时进行回滚,以及备份应用程序的不同策略。
第九章:从单块到微服务
在本章中,我们将探讨在必须将单块应用程序转换为微服务时可以遵循的一些可能策略,以及一些示例。如果我们已经有一个庞大而复杂的应用程序,这个过程可能会有点困难,但幸运的是,有一些众所周知的策略可以遵循,以避免在整个过程中出现问题。
重构策略
将单块应用程序转换为微服务的过程是为了重构代码,以使您的应用程序现代化。这应该是逐步进行的。试图一步将整个应用程序转换为微服务可能会导致问题。逐渐地,它将创建一个基于微服务的新应用程序,最终,您当前的应用程序将消失,因为它将被转换为小的微服务,使原始应用程序变为空或者也可能成为一个微服务。
停止潜水
当您的应用程序已经是一个洞时,您必须停止潜水。换句话说,停止让您的单块应用程序变得更大。这是当您必须实现一个新功能时,需要创建一个新的微服务并将其连接到单块应用程序,而不是继续在单块中开发新功能。
为此,当实现新功能时,我们将有当前的单块、新功能,以及另外两个东西:
-
路由器:这负责处理 HTTP 请求;换句话说,这就像一个网关,知道需要将每个请求发送到哪里,无论是旧的单体还是新功能。
-
粘合代码:这负责将单块应用程序连接到新功能。新功能通常需要访问单块应用程序以获取数据或任何必要的功能:
停止潜水策略
关于粘合代码,有 3 种不同的可能性可以从新功能访问应用程序到单块:
-
在单体侧创建一个 API,供新功能使用
-
直接连接单块数据库
-
在功能方面有一个与单块数据库同步的副本
正如您所看到的,这种策略是在当前的单块应用程序中开始开发微服务的一种不错的方式。此外,新功能可以独立于单块进行扩展、部署和开发,从而改进您的应用程序。然而,这并不能解决问题,只是避免让当前问题变得更大,所以让我们再看看另外两种策略。
分离前端和后端
另一种策略是将逻辑呈现部分与数据访问层分开。一个应用程序通常至少有 3 个不同的部分:
-
呈现层:这是用户界面,换句话说,是网站的 HTML 语言
-
业务逻辑层:这由用于实现业务规则的组件组成
-
访问数据层:这包含访问数据库的组件
通常呈现层和业务逻辑以及访问数据层之间有一个分离。业务层具有一个 API,其中有一个或多个封装业务逻辑组件的门面。从这个 API,可以将单块分成 2 个较小的应用程序。
分割后,呈现层调用业务逻辑。看下面的例子:
分离前端和后端策略
这种分割有两个不同的优势:
-
它允许您扩展和开发两个不同和独立的应用程序
-
它为您提供了一个可以供未来微服务使用的 API
这种策略的问题在于它只是一个临时解决方案,它可以转变为一个或两个单体应用程序,因此让我们看看下一个策略,以便移除剩余的单体应用程序。
提取服务
最后的策略是从结果单体应用程序中隔离模块。我们可以逐步从中提取模块,并从每个模块创建一个微服务。一旦我们提取了所有重要的模块,结果单体应用程序也将成为一个微服务,甚至可能消失。总体思路是创建将成为未来微服务的功能的逻辑组。
单体应用程序通常有许多潜在的模块可以提取。优先级必须通过首先选择更容易的模块,然后选择最有益的模块来设置。更容易的模块将为您提供必要的经验,以将模块提取为微服务,以便稍后处理重要的模块。
以下是一些提示,以帮助您选择最有益的模块:
-
经常变化的模块
-
需要与单体应用程序不同资源的模块
-
需要昂贵硬件的模块
寻找现有的粗粒度��界是有用的,它们更容易且更便宜转换为微服务。
如何提取模块
现在,让我们看看如何提取一个模块,我们将使用一个示例来使解释更容易理解一些。想象一下,您的单体应用是一个博客系统。正如您可以想象的那样,核心功能是用户创建的帖子,每个帖子都支持评论。从我们的简要描述中可以看出,您可以定义应用程序的不同模块,并决定哪个最重要。
一旦您清楚了应用程序的描述和功能,就可以继续使用用于从单体应用程序中提取模块的一般步骤:
-
在模块和单体代码之间创建一个接口。最佳解决方案是双向 API,因为单体应用程序将需要来自模块的数据,而模块将需要来自单体应用程序的数据。这个过程并不容易,您可能需要更改单体应用程序的代码,以使 API 正常工作。
-
一旦实现了粗粒度接口,将模块转换为独立的微服务。
例如,想象一下POST
模块是要被提取的候选模块,它们的组件被Users
和Comments
模块使用。正如第一步所说,需要实现一个粗粒度的 API。第一个接口是由Users
用来调用POST
模块的入口 API,第二个接口是由POST
用来调用Comments
的。
在提取的第二步中,将模块转换为独立的微服务。一旦完成,生成的微服务将是可扩展和独立的,因此可以使其增长,甚至从头开始编写。
逐步,单体应用程序将变得更小,您的应用程序将拥有更多的微服务。
教程:从单体应用到微服务
在本章的示例中,我们将不使用框架,并且将不使用 MVC 架构编写代码,以便专注于本章的主题,并学习如何将单体应用程序转换为微服务。
没有比实践更好的学习方法了,因此让我们看一个完整的博客平台示例,这是我们在前一节中定义的。
提示
博客平台示例可以从我们的 PHP 微服务存储库中下载,因此,如果您想跟随我们的步骤,可以通过下载并按照本指南进行操作。
我们的示例是一个基本的博客平台,具有最低限度的功能,可以通过本教程。这是一个允许以下操作的博客系统:
-
注册新用户
-
用户登录
-
管理员可以发布新文章
-
注册用户可以发布新评论
-
管理员可以创建新类别
-
管理员可以创建新文章
-
管理员可以管理评论
-
所有用户都可以看到文章
因此,将单块应用程序转换为微服务的第一步是熟悉当前应用程序。在我们的想象中,当前应用程序可以分为以下微服务:
-
用户
-
文章
-
评论
-
类别
在这个例子中很清楚,但在一个真实的例子中,应该深入研究,以便按照我们在本章中之前解释的优先级将项目划分为小的微服务,这些微服务将通过执行特定功能来完成任务。
停止潜水
现在我们知道如何按照之前解释的策略进行操作,想象一下我们想要在我们的博客平台中添加一个新功能,即在用户之间发送私人消息。
为了弄清楚这一点,我们需要知道新的发送私人消息功能将具有哪些功能,以便找到粘合代码和从新微服务获取信息(路由)的请求所在的位置。
因此,新微服务的功能可以如下:
-
向用户发送消息
-
阅读你的消息
正如你所看到的,这些功能非常基本,但请记住,这只是为了让你熟悉在单块应用程序中创建新微服务的过程。
我们将创建私人消息微服务,并且当然,我们将再次使用 Lumen。为了快速创建骨架,在终端上运行以下命令:
**composer create-project --prefer-dist laravel/lumen private_messages**
上述命令将创建一个带有 Lumen 安装的文件夹。
在第二章中,我们解释了如何创建 Docker 容器。现在,你有机会运用你学到的一切,在 Docker 环境中实现单块和不同的新微服务。根据前面的章节,你应该能够自己做到这一点。
我们的新功能需要一个存储私人消息的地方,所以现在我们将创建私人消息微服务将使用的表。这可以在单独的数据库中创建,甚至可以在同一个应用程序的数据库中创建。请记住,如果情况允许,微服务可以共享同一个数据库,但想象一下,这个微服务将会有很多流量,所以对我们来说,最好的解决方案是将其放在一个单独的数据库中。
创建一个新的数据库或连接应用程序数据库并执行以下查询:
CREATE TABLE `messages` (
`id` INT NOT NULL AUTO_INCREMENT,
`sender_id` INT NULL,
`recipient_id` INT NULL,
`message` TEXT NULL,
PRIMARY KEY (`id`));
一旦我们创建了表,就需要将新的微服务连接到它,所以打开.env.example
文件并修改以下行:
DB_CONNECTION=mysql
DB_HOST=localhost
DB_PORT=3306
DB_DATABASE=**private_messages**
DB_USERNAME=root
DB_PASSWORD=root
如果你的数据库不同,请在上述代码中进行更改。
将.env.example
文件重命名为.env
,并将$app->run();
代码更改为public/index.php
文件中的以下代码;这将允许你调用这个微服务:
$app->run(
$app->make('request')
);
现在,你可以通过在 Postman 上进行 GET 调用http://localhost/private_messages/public/
来检查你的微服务是否正常工作。记得对你的开发基础设施进行所有必要的更改。
你将收到一个带有安装的 Lumen 版本的 200 状态码。
在我们的微服务中,我们至少需要包括以下调用:
-
GET
/messages/user/id
:这是获取用户的消息所需的 -
POST
/message/sender/id/recipient/id
:这是向用户发送消息所需的
因此,现在我们将在/private_messages/app/Http/routes.php
上创建路由,通过在routes.php
文件的末尾添加以下行:
$app->get('messages/user/{userId}',
'MessageController@getUserMessages');
$app->post('messages/sender/{senderId}/recipient/{recipientId}',
'MessageController@sendMessage');
下一步是在/app/Http/Controllers/MessageController.php
上创建一个名为MessageController
的控制器,并包含以下内容:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class MessageController extends Controller
{
public function getUserMessages(Request $request, $userId) {
// getUserMessages code
}
public function sendMessage(Request $request, $senderId,
$recipientId) {
// sendMessage code
}
}
现在,我们必须告诉 Lumen 需要使用数据库,所以取消注释/bootstrap/app.php
中的以下行:
$app->withFacades();
$app->withEloquent();
现在,我们可以创建这两个功能:
public function getUserMessages(Request $request, $userId)
{
**$messages = Message::where('recipient_id',$userId)->get();**
**return response()->json($messages);**
}
public function sendMessage(Request $request, $senderId,
$recipientId)
{
**$message = new Message();**
**$message->fill([
'sender_id' => $senderId,
'recipient_id' => $recipientId,
'message' => $request->input('message')]);**
**$message->save();**
**return response()->json([]);**
}
一旦我们的方法完成,微服务就完成了。因此,现在我们必须将单块应用程序连接到私人消息微服务。
我们需要在单体应用程序的header.php
文件中为注册用户创建一个新按钮:
<?php if (empty($arrUser['username'])) : ?>
<li role="presentation"><a href="login.php">Log in</a></li>
<li role="presentation"><a href="signup.php">Sign up</a></li>
<?php else : ?>
<?php if ($arrUser['type'] === 'admin') : ?>
<li role="presentation">
<a href="admin/index.php">Admin Panel</a>
</li>
<?php endif; ?>
**<li role="presentation">
<a href="messages.php">Messages</a>
</li>**
<li role="presentation">
<a href="index.php?logout=true">Log out</a>
</li>
<?php endif; ?>
然后,我们需要在root
文件夹中创建一个名为messages.php
的新文件,其中包含以下代码:
<?
include_once 'libraries.php';
$url = "http://localhost/private_messages/public/messages
/user/".$arrUser['id'];
$ch = curl_init();
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_URL,$url);
$result=curl_exec($ch);
curl_close($ch);
$messages = json_decode($result, true);
正如你所看到的,我们正在调用微服务以获取用户消息列表。此外,我们需要获取用户列表以填充发送消息的用户选择器。这段代码可以被视为粘合代码,因为需要将微服务数据与单体数据匹配。我们将使用以下粘合代码:
$arrUsers = array();
$query = "SELECT id, username FROM `users` ORDER BY username ASC";
$result = mysql_query ($query, $dbConn);
while ( $row = mysql_fetch_assoc ($result)) {
array_push( $arrUsers,$row );
}
现在我们可以构建 HTML 代码来显示用户消息和发送消息所需的表单:
**include_once 'header.php';
?>**
<p class="bg-success">
**<?php if ($_GET['sent']) { ?>
The message was sent!
<?php } ?>**
</p>
<h1>Messages</h1>
<?php foreach($messages as $message) { ?>
<div class="panel panel-primary">
<div class="panel-heading">
<h3 class="panel-title">
Message from **<?php echo $arrUsers[$message['sender_id']]
['username'];?>** </h3>
</div>
<div class="panel-body">
**<?php echo $message['message']; ?>** </div>
</div>
**<?php } ?>** <h1>Send message</h1>
<div>
<form action="messages.php" method="post">
<div class="form-group">
<label for="category_id">Recipient</label>
<select class="form-control" name="recipient">
<option value="">Select User</option>
<option value="">------------------------</option>
**<?php foreach($arrUsers as $user) { ?>** <option value="**<?php echo $user['id']; ?>**">
**<?php echo $user['username']; ?>**
</option>
**<?php } ?>** </select>
</div>
<div class="form-group">
<label for="name">
Message
</label>
<br />
<input name="message" type="text" value=""
class="form-control" />
</div>
<input name="sender" type="hidden"
value="<?php echo $arrUser['id']; ?>" />
<div class="form-group">
<input name="submit" type="submit" value="Send message"
class="btn btn-primary" />
</div>
</form>
</div>
**<?php include_once 'footer.php'; ?>**
请注意,有一个用于发送消息的表单,因此我们必须添加一些代码来调用微服务以发送消息。在$messages = json_decode($result, true);
行后添加以下代码:
if (!empty($_POST['submit'])) {
if (!empty($_POST['sender'])) {
$sender = $_POST['sender'];
}
if (!empty($_POST['recipient'])) {
$recipient = $_POST['recipient'];
}
if (!empty($_POST['message'])) {
$message = $_POST['message'];
}
if (empty($sender)) {
$error['sender'] = 'Sender not found';
}
if (empty($recipient)) {
$error['recipient'] = 'Please select a recipient';
}
if (empty($message)) {
$error['message'] = 'Please complete the message';
}
if (empty($error)) {
$url = 'http://localhost/private_messages/public/messages
/sender/'.$sender.'/recipient/'.$recipient;
$handler = curl_init();
curl_setopt($handler, CURLOPT_URL, $url);
curl_setopt($handler, CURLOPT_POST,true);
curl_setopt($handler, CURLOPT_RETURNTRANSFER,true);
curl_setopt($handler, CURLOPT_POSTFIELDS, "message=".$message);
$response = curl_exec ($handler);
curl_close($handler);
header( 'Location: messages.php?sent=true' );
die;
}
}
就是这样。我们的第一个微服务已经包含在单体应用程序中。这就是在当前的单体应用程序中添加新功能时的操作方式。
前端和后端分离
如前所述,第二种策略是将表示层与业务逻辑隔离开来。这可以通过创建一个包含所有业务逻辑和数据访问的完整微服务来实现,也可以通过将表示层与业务层隔离开来实现,就像模型-视图-控制器(MVC)结构一样。
这并不是使用单体应用程序的问题的完整解决方案,因为这将导致我们有两个单体应用程序,而不是一个。
要做到这一点,我们应该首先在root
文件夹中创建一个新的Controller.php
文件。我们可以将这个类称为Controller
,它将包含视图需要的所有方法。例如,Article
视图需要getArticle
,postComment
和getArticleComments
:
<?php
class Controller
{
public function connect () {
$db_con = mysql_pconnect(DB_SERVER,DB_USER,DB_PASS);
if (!$db_con) {
return false;
}
if (!mysql_select_db(DB_NAME, $db_con)) {
return false;
}
return $db_con;
}
public function **getArticle**($id)
{
$dbConn = $this->connect();
$query = "SELECT articles.id, articles.title, articles.extract,
articles.text, articles.updated_at,
categories.value as category,
users.username FROM `articles`
INNER JOIN
`categories` ON categories.id = articles.category_id
INNER JOIN
`users` ON users.id = articles.user_id
WHERE articles.id = " . $id . " LIMIT 1";
$result = mysql_query ($query, $dbConn);
return mysql_fetch_assoc ($result);
}
public function **getArticleComments**($id)
{
$dbConn = $this->connect();
$arrComments = array();
$query = "SELECT comments.id, comments.comment, users.username
FROM `comments` INNER JOIN `users`
ON comments.user_id = users.id
WHERE comments.status = 'valid'
AND comments.article_id = " . $id . "
ORDER BY comments.id DESC";
$result = mysql_query ($query, $dbConn);
while ($row = mysql_fetch_assoc($result)) {
array_push($arrComments,$row);
}
return $arrComments;
}
public function **postComment**($comment,$user_id,$article_id)
{
$dbConn = $this->connect();
$query = "INSERT INTO `comments` (comment, user_id, article_id)
VALUES ('$comment','$user_id','$article_id')";
mysql_query($query, $dbConn);
}
}
文章视图应包含Controller.php
文件中包含的方法。看一下以下代码:
<?
include_once 'libraries.php';
**include_once 'Controller.php';**
**$controller = new Controller();**
if ( !empty($_POST['submit']) ) {
// Validation
if (!empty($_POST['comment'])) {
$comment = $_POST['comment'];
}
if (!empty($_GET['id'])) {
$article_id = $_GET['id'];
}
if (!empty($arrUser['id'])) {
$user_id = $arrUser['id'];
}
if (empty($comment)) {
$error['comment'] = true;
}
if (empty($article_id)) {
$error['article_id'] = true;
}
if (empty($user_id)) {
$error['user_id'] = true;
}
if ( empty($error) ) {
**$controller->postComment($comment,$user_id,$article_id);**
header ( 'Location: article.php?id='.$article_id);
die;
}
}
**$article = $controller->getArticle($_GET['id']);**
**$comments = $controller->getArticleComments($_GET['id']);**
include_once 'header.php';
?>
<h1>Article</h1>
<div class="panel panel-primary">
<div class="panel-heading">
<h3 class="panel-title">
**<?php echo $article['title']; ?>**
</h3>
</div>
<div class="panel-body">
<span class="label label-primary">Published</span> by
<b>**<?php echo $article['username']; ?>**</b> in
<i>**<?php echo $article['category']; ?>**</i>
<b>**<?php echo date_format(date_create($article
['fModificacion']),
'd/m/y h:m'); ?>** </b>
<hr/>
**<?php echo $article['text']; ?>** </div>
</div>
<h2>Comments</h2>
**<?php foreach ($comments as $comment) { ?>**
<div class="panel panel-warning">
<div class="panel-heading">
<h3 class="panel-title">**<?php echo $comment['username']; ?>** said</h3>
</div>
<div class="panel-body">
**<?php echo $comment['comment']; ?>** </div>
</div>
**<?php } ?>**
<div>
**<?php if ( !empty( $arrUser ) ) { ?>**
<form action="article.php?id=**<?php echo $_GET['id']; ?>**"
method="post">
<div class="form-group">
<label for="user">Post a comment</label>
<textarea class="form-control" rows="3" cols="50"
name="comment" id="comment">
</textarea>
</div>
<div class="form-group">
<input name="submit" type="submit" value="Send"
class="btn btn-primary" />
</div>
</form>
**<?php } else { ?>** <p>
Please sign up to leave a comment on this article.
<a href="signup.php">Sign up</a> or
<a href="login.php">Log in</a>
</p>
**<?php } ?>** </div>
<?php include_once 'footer.php'; ?>
这些是我们应该遵循的步骤,以便将业务逻辑层与视图隔离开来。
提示
如果你愿意,可以将所有视图文件(header.php
,footer.php
,index.php
,article.php
等)放入一个名为views
的文件夹中,以便在同一个文件夹中组织它们。
一旦我们将所有视图与业务逻辑隔离开来,我们将在控制器中包含所有方法,而不是在表示层中包含它们。正如我们之前所说,这只是一个临时解决方案,因此我们将寻找真正的解决方案,以将模块提取为微服务。
提取服务
在这种策略中,我们必须选择要隔离的第一个模块,以便从中制作一个微服务。在这种情况下,我们将从Categories
模块开始做。
类别在管理员面板上使用最多。可以在其中创建、修改和删除类别,然后在创建新文章时选择它们,并在文章中显示以指示文章类别。
提取过程并不容易;我们必须确保我们知道模块被使用的所有地方。为了做到这一点,我们将创建一个双向 API 或在控制器中创建所有类别方法,然后将它们隔离在一个微服务中。
打开admin/categories.php
文件,我们必须做与前端和后端分离相同的事情--找到所有引用类别的地方,并在控制器上创建一个新方法。看一下这个:
<?
session_start ();
require_once 'config.php';
require_once 'connection.php';
require_once 'isUser.php';
**include_once '../Controller.php';**
**$controller = new Controller();**
$dbConn = connect();
/* Omitted code */
if (!empty($_GET['del'])) {
**$controller->deleteCategory($_GET['del']);**
header( 'Location: categories.php?dele=true' );
die;
}
if (!empty($_POST['submit'])) {
if (!empty($_POST['name'])) {
$name = $_POST['name'];
}
if (empty($name)) {
$error['name'] = 'Please enter category name';
}
if (empty($error)) {
**$controller->createCategory($name);**
header( 'Location: categories.php?add=true' );
die;
}
}
if (!empty($_POST['submitEdit'])) {
/* Ommited code */
if (empty($error)) {
**$controller->updateCategory($id, $name);**
header( 'Location: categories.php?edit=true' );
die;
}
}
**$arrCategories = $controller->getCategories();**
if (!empty($_GET['id'])) {
**$row = $controller->getCategory($_GET['id']);**
}
include_once 'header.php';
?>
<!-- Omitted HTML code -->
controller.php
文件必须包含类别方法:
public function createCategory($name)
{
$dbConn = $this->connect();
$query = "INSERT INTO `categories` (value) VALUES ('$name')";
mysql_query($query, $dbConn);
}
public function updateCategory($id,$name)
{
$dbConn = $this->connect();
$query = "UPDATE `categories` set value = '$name'
WHERE id = $id";
mysql_query($query, $dbConn);
}
public function deleteCategory($id)
{
$dbConn = $this->connect();
$query = "DELETE FROM `categories` WHERE id = ".$id;
mysql_query($query, $dbConn);
}
public function getCategories()
{
$dbConn = $this->connect();
$arrCategories = array();
$query = "SELECT id, value FROM `categories` ORDER BY value ASC";
$resultado = mysql_query ($query, $dbConn);
while ($row = mysql_fetch_assoc ($resultado)) {
array_push( $arrCategories,$row );
}
return $arrCategories;
}
public function getCategory($id)
{
$dbConn = $this->connect();
$query = "SELECT id, value FROM `categories` WHERE id = ".$id;
$resultado = mysql_query ($query, $dbConn);
return mysql_fetch_assoc ($resultado);
}
在admin/articles.php
文件中有更多关于类别的引用,因此打开它并在require_once
行后添加以下行:
include_once '../Controller.php';
$controller = new Controller();
这些行将允许您在articles.php
文件中使用controller.php
文件中包含的类别方法。将用于获取类别的代码修改为以下代码:
$arrCategories = $controller->getCategories();
最后,需要对文章视图进行一些更改。这是用于显示文章的视图,并且在创建文章时包含所选的类别。
要获取文章,执行的查询如下:
SELECT articles.id, articles.title, articles.extract,
articles.text, articles.updated_at,
categories.value as category,
users.username FROM `articles`
**INNER JOIN `categories` ON categories.id = articles.category_id**
INNER JOIN `users` ON users.id = articles.user_id
WHERE articles.id = " . $id . " LIMIT 1;
如您所见,查询需要类别表。如果要为类别微服务使用不同的数据库,您将需要从查询中删除突出显示的行,选择查询中的articles.category_id
,然后使用创建的方法获取类别名称。因此,查询将如下所示:
SELECT articles.id, articles.title, articles.extract,
articles.text, articles.updated_at, articles.category_id,
users.username FROM `articles`
INNER JOIN `users` ON users.id = articles.user_id
WHERE articles.id = " . $id . " LIMIT 1;
以下是从提供的类别 ID 获取类别名称的代码:
public function getArticle($id)
{
$dbConn = $this->connect();
$query = "SELECT articles.id, articles.title, articles.extract,
articles.text, articles.updated_at,
articles.category_id,
users.username FROM `articles`
INNER JOIN `users` ON users.id = articles.user_id
WHERE articles.id = " . $id . " LIMIT 1;";
$result = mysql_query ($query, $dbConn);
$data = mysql_fetch_assoc($result);
**$data['category'] = $this->getCategory($data['category_id']);**
**$data['category'] = $data['category']['value'];**
return $data;
}
一旦我们进行了所有这些更改,就可以将类别表隔离到不同的数据库中,因此我们可以从controller.php
文件中创建的方法创建一个类别微服务:
-
public function createCategory($name)
-
public function updateCategory($id,$name)
-
public function deleteCategory($id)
-
public function getCategories()
-
public function getCategory($id)
正如您所想象的那样,这些函数用于创建类别微服务的routes.php
文件。因此,让我们创建一个新的微服务,就像我们使用停止潜水策略一样。
通过执行以下命令创建新的类别微服务:
**composer create-project --prefer-dist laravel/lumen categories**
上述命令将在名为 categories 的文件夹中安装 Lumen,因此我们可以开始创建新类别微服务的代码。
现在我们有两个选项--第一个是使用位于当前数据库上的相同表--我们可以将新微服务指向当前数据库。第二个选项是在新数据库中创建一个新表,因此新微服务将使用自己的数据库。
如果要在新数据库中创建一个新表,可以按照以下步骤进行:
-
将当前类别表导出到 SQL 文件中。它将保留当前存储的数据。
-
将 SQL 文件导入新数据库。它将在新数据库中创建导出的表和数据。
提示
导出/导入过程可以使用 SQL 客户端执行,也可以通过控制台执行mysqldump
。
一旦新表被导入到新数据库,或者您决定使用当前数据库,就需要设置.env.example
文件,以便将新微服务连接到正确的数据库,因此打开它并在其中放入正确的参数:
DB_CONNECTION=mysql
DB_HOST=localhost
DB_PORT=3306
DB_DATABASE=**categories**
DB_USERNAME=root
DB_PASSWORD=root
不要忘记将.env.example
文件重命名为.env
,并在public/index.php
中更改$app->run()
行,就像我们之前所做的那样,更改为以下代码:
**$app->run(**
**$app->make('request')**
**);**
还要取消注释/bootstrap/app.php
中的以下行:
$app->withFacades();
$app->withEloquent();
现在我们准备在routes.php
文件中添加必要的方法。我们必须添加单体应用程序Controller.php
中的类别方法,并将它们转换为路由:
-
public function createCategory($name)
: 这是用于创建新类别的 POST 方法。因此,它可能类似于$app->post('category', 'CategoryController@createCategory');
。 -
public function updateCategory($id,$name)
: 这是一个 PUT 方法,用于编辑现有的类别。因此,它可能类似于app->put('category/{id}', 'CategoryController@updateCategory');
。 -
public function deleteCategory($id)
: 这是用于删除现有类别的 DELETE 方法。因此,它可能类似于app->delete('category/{id}', 'CategoryController@deleteCategory');
。 -
public function getCategories()
: 这是用于获取所有现有类别的 GET 方法。因此,它可能类似于app->get('categories', 'CategoryController@getCategories');
。 -
public function getCategory($id)
: 这也是一个 GET 方法,但它只获取一个单一的类别。因此,它可能类似于app->get('category/{id}', 'CategoryController@getCategory');
。
因此,一旦我们在routes.php
文件中添加了所有路由,就是创建类别模型的时候了。要做到这一点,在/app/Model
中创建一个新文件夹和一个文件/app/Model/Category.php
,就像这样:
<?php
namespace App\Model;
use Illuminate\Database\Eloquent\Model;
class Category extends Model {
protected $table = 'categories';
protected $fillable = ['value'];
public $timestamps = false;
}
创建模型后,创建一个/app/Http/Controllers/CategoryController.php
文件,其中包含必要的方法:
<?php
namespace App\Http\Controllers;
use App\Model\Category;
use Illuminate\Http\Request;
class CategoryController extends Controller
{
public function **createCategory**(Request $request) {
$category = new Category();
$category->fill(['value' => $request->input('value')]);
$category->save();
return response()->json([]);
}
public function **updateCategory**(Request $request, $id) {
$category = Category::find($id);
$category->value = $request->input('value');
$category->save();
return response()->json([]);
}
public function **deleteCategory**(Request $request, $id) {
$category = Category::find($id);
$category->delete();
return response()->json([]);
}
public function **getCategories**(Request $request) {
$categories = Category::get();
return $categories;
}
public function **getCategory**(Request $request, $id) {
$category = Category::find($id);
return $category;
}
}
现在,我们已经完成了我们的类别微服务。您可以在 Postman 中尝试一下,以检查所有方法是否有效。例如,可以通过 Postman 使用http://localhost/categories/public/categories
URL 调用getCategories
方法。
一旦我们创建了新的类别微服务并且正常工作,就是时候断开类别模块并将单体应用程序连接到微服务了。
返回到单体应用程序并查找所有对类别方法的引用。我们必须通过调用新的微服务来替换它们。我们将使用原生 curl 调用进行这些调用,但是您应该考虑使用 Guzzle 或类似的包,就像我们在之前的章节中所做的那样。
为此,首先我们应该在Controller.php
文件中创建一个函数来进行调用。可以是这样的:
function call($url, $method, $field = null)
{
$ch = curl_init();
if(($method == 'DELETE') || ($method == 'PUT')) {
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
}
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_URL,$url);
if ($method == 'POST') {
curl_setopt($ch, CURLOPT_POST,true);
}
if($field) {
curl_setopt($ch, CURLOPT_POSTFIELDS, 'value='.$field);
}
$result=curl_exec($ch);
curl_close($ch);
return json_decode($result, true);
}
上述代码将用于在每次调用类别微服务时重用代码。
转到/admin/categories.php
文件并用以下代码替换controller->createCategory($name);
行:
$controller->call('http://localhost/categories/public/category',
'POST', $name);
在上述代码中,您可以检查我们正在使用 POST 调用创建类别方法,并将值参数设置为$name
变量。
在/admin/categories.php
文件中,找到并用以下代码替换controller->updateCategory($id, $name);
行:
$controller->call('http://localhost/categories/public/category/'.$id,
'PUT', $name);
在同一文件中,找到并用以下代码替换$controller->deleteCategory($_GET['del']);
:
$controller->call('http://localhost/categories/public
/category/'.$_GET['del'], 'DELETE');
在同一文件中,再次以及在/admin/articles.php
文件中,找到并用以下代码替换$arrCategories = $controller->getCategories();
:
$arrCategories = $controller->call('http://localhost/categories
/public/categories', 'GET');
最后一个位于/admin/categories.php
文件中。找到并用以下代码替换row = $controller->getCategory($_GET['id']);
行:
$row = $controller->call('http://localhost/categories
/public/category/'.$_GET['id'], 'GET');
一旦我们完成了在单体应用程序中用新的类别微服务调用替换所有类别方法,我们可以删除对单体类别模块的所有引用。
转到Controller.php
文件并删除以下函数,因为它们不再需要,因为它们引用了单体类别模块:
-
public function deleteCategory($id)
-
public function createCategory($name)
-
public function updateCategory($id,$name)
-
public function getCategories()
-
public function getCategory($id)
最后,如果您为类别微服务创建了新的数据库,可以通过执行以下查询来删除位于单体应用程序数据库中的类别表:
DROP TABLE categories;
我们已经从单体应用程序中提取了类别服务。下一步将是选择另一个模块,再次按照相同步骤进行,并重复此过程,直到单体应用程序消失或成为微服务为止。
在第七章中,安全我们谈到了微服务中的安全性。为了练习您所学到的知识,请查看本章中的所有代码,并找出我们示例中的弱点。
摘要
在本章中,您学习了转换单体应用程序为微服务的策略,并为每个步骤使用了示例代码。从现在开始,您已经准备好告别单体应用程序,并进行转换,以开始使用微服务。
第十章:可扩展性策略
您的应用程序已准备就绪。现在是计划未来的时候了。在本章中,我们将为您全面介绍如何检查应用程序可能的瓶颈以及如何计算应用程序的容量。在本章结束时,您将具备创建自己的可扩展性计划的基本知识。
容量规划
容量规划是确定应用程序所需的基础设施资源的过程,以满足应用程序未来的工作负载需求。该过程确保您在需要时有足够的资源可用,从而将成本降至最低。如果您知道应用程序的使用方式和当前资源的限制,您可以推断数据并大致了解未来的需求。为您的应用程序创建容量规划具有一些好处,其中我们可以突出以下好处:
-
最小化成本并避免过度配置的浪费:您的应用程序将仅使用所需的资源,因此,例如,当您只使用 8GB 时,为数据库拥有 64GB RAM 服务器是没有意义的。
-
预防瓶颈并节省时间:您的容量计划突出显示基础设施的每个元素何时达到峰值,为您提供有关瓶颈可能出现的提示。
-
提高业务生产力:如果您有一个详细的计划,指出基础设施的每个元素的限制,并知道每个元素何时达到其限制,那么您就有了空余时间来专注于其他业务任务。您将有一套指令,以便在需要增加应用程序容量的确切时刻遵循。当您遇到瓶颈并不知道该怎么办时,就不会再有疯狂的时刻了。
-
用作业务目标的映射:如果您的应用程序对您的业务至关重要,此文档可用于突出一些业务目标。例如,如果业务想要达到 1,000 个用户,您的基础设施需要支持它们,标记一些需要满足此要求的投资。
了解您的应用程序的限制
了解您的应用程序的限制的主要目的是在开始出现问题之前知道我们在任何给定时间点还有多少容量。我们需要做的第一件事是创建我们应用程序组件的清单。尽可能详细地制作清单;这将帮助您了解项目中所有工具。在我们的示例应用程序中,不同组件的清单可能类似于以下内容:
-
自动发现服务:
-
Hashicorp Consul
-
遥测服务:
-
Prometheus
-
战斗微服务:
-
代理:NGINX
-
应用引擎:PHP 7 FPM
-
数据存储:Percona
-
缓存存储:Redis
-
位置微服务:
-
代理:NGINX
-
应用引擎:PHP 7 FPM
-
数据存储:Percona
-
缓存存储:Redis
-
秘密微服务:
-
代理:NGINX
-
应用引擎:PHP 7 FPM
-
数据存储:Percona
-
缓存存储:Redis
-
用户微服务:
-
代理:NGINX
-
应用引擎:PHP 7 FPM
-
数据存储:Percona
-
缓存存储:Redis
一旦我们将应用程序减少到基本组件,我们需要分析并确定每个组件的使用情况以及适当测量中的最大容量。
某些组件可能有多个���联的测量,例如数据存储层(在我们的案例中为 Percona)。对于此组件,我们可以测量 SQL 事务数量、使用的存储量、CPU 负载等。在前几章中,我们添加了一个遥测服务;您可以使用此服务从每个组件中收集基本统计信息。
您可以为应用程序的每个组件记录的一些基本统计信息如下:
-
CPU 负载
-
内存使用
-
网络使用
-
IOPS
-
磁盘利用率
在某些软件中,您需要收集一些特定的测量。例如,在数据库上,您可以检查以下内容:
-
每秒事务数
-
缓存命中率(如果启用了查询缓存)
-
用户连接
下一步是确定应用程序的自然增长。如果没有进行特殊操作(如 PPC 广告活动和新功能),则可以将此测量定义为应用程序的增长。这个测量可以是新用户的数量或活跃用户的数量,例如。想象一下,您将应用程序部署到生产环境,并停止添加新功能或进行营销活动。如果过去一个月新用户的数量增加了 7%,那么这个数量就是您的应用程序的自然增长。
一些企业/项目具有季节性趋势,这意味着在特定日期,您的应用程序的使用量会增加。想象一下,您是一个礼品零售商,您的大部分销售可能是在情人节或年底(黑色星期五,圣诞节)左右完成的。如果这是您的情况,请分析您拥有的所有数据以建立季节性数据。
现在您已经了解了应用程序的一些基本统计数据,是时候计算所谓的剩余空间了。剩余空间可以定义为在资源耗尽之前您拥有的资源量。可以用以下公式计算:
剩余空间公式
从上述公式中可以看出,计算特定组件的剩余空间非常简单。在我们举例之前,让我们解释每个变量:
-
理想使用率: 这是一个百分比,描述了我们计划使用的应用程序特定组件的容量。理想使用率永远不应该达到 100%,因为当接近资源限制时,可能会出现无法保存数据库中数据等奇怪的行为。我们建议将此量设置在 60%至 75%之间,为高峰时刻留出足够的额外空间。
-
最大容量: 这是指我们研究对象组件的最大容量。例如,一个能够处理最多 250 个并发连接的 Web 服务器。
-
当前使用率: 这是指我们正在研究的组件的当前使用率。
-
增长: 这是指我们应用程序的自然增长的百分比。
-
优化: 这是可选变量,描述了在特定时间内我们可以实现的优化量。例如,如果您当前的数据库每秒可以处理 35 个查询,经过一些优化后,您可以实现每秒 50 个查询。在这种情况下,优化量为 15。
假设您正在计算我们的一个 NGINX 可以处理的每秒请求的剩余空间。对于我们的应用程序,我们已经决定将理想使用率设置为 60%(0.6)。根据我们的测量和从负载测试中提取的数据(稍后在本章中解释),我们知道每秒请求的最大数量(RPS)为 215。在我们当前的统计数据中,我们的 NGINX 服务器今天提供了最高 193 RPS,并且我们已经计算出了下一年的增长至少为 11 RPS。
我们想要测量的时间段是 1 年,我们认为我们可以在这段时间内实现最大容量 250 RPS,因此我们的剩余空间值将如下所示:
剩余空间= 0.6 * 215 - 123 - (11 - 35) = 30 RPS
这个计算意味着什么?由于结果是正数,这意味着我们有足够的预留空间。如果我们将结果除以增长和优化的总和,我们可以计算出我们达到资源限制之前还有多少时间。
由于我们的时间段是 1 年,我们可以计算出我们达到资源限制之前还有多少时间,如下所示:
Headroom 时间= 30 rpm / 24 = 1.25 年
您可能已经推断出,我们的 NGINX 服务器还有 1.25 年才能达到 RPS 的极限。在本节中,我们向您展示了如何计算特定组件的余量;现在轮到您为您的每个组件和每个组件可用的不同指标进行计算了。
可用性数学
可用性可以定义为网站在特定时间段内的可用性,例如一周,一天,一年等。根据您的应用程序对您或您的业务的重要性,停机时间可能等于丢失的收入。正如您可以想象的那样,可用性可能成为应用程序由客户/用户使用并且他们需要您的服务的任何时间的情况下最重要的指标。
我们对可用性有了理论概念。现在是时候做一些数学运算了,拿出你的计算器。根据早期的一般定义,可用性可以计算为您的应用程序可以被用户/客户使用的时间除以时间范围(我们正在测量的特定时间段)。
假设我们想要测量我们的应用程序在一周内的可用性。一周内,我们有10,080
分钟:
- 7 天 x 每天 24 小时 x 每小时 60 分钟= 7 * 24 * 60 = 10,080 分钟*
现在,假设您的应用程序在那周发生了一些故障,并且您的应用程序的可用分钟数减少到10,000
。要计算我们示例的可用性,我们只需要进行一些简单的数学运算:
- 10,000 / 10,080 = 0.9920634921*
可用性通常以百分比(%)表示,因此我们需要将结果转换为百分比:
- 0.9920634921 * 100 = 99.20634921%〜99.21*
我们的应用程序在一周内的可用性为99.21%
。不算太糟糕,但离我们的目标结果还差得远,即尽可能接近100%
。大多数情况下,可用性百分比被称为数量的九,并且它们越接近100%
,就越难以维护应用程序的可用性。为了让您了解达到100%
可用性将有多困难,这里有一些可用性和可能停机时间的示例:
-
99.21%(我们的示例):
-
每周:1 小时 19 分钟 37.9 秒
-
每月:5 小时 46 分钟 15.0 秒
-
每年:69 小时 14 分钟 59.9 秒
-
99.5%:
-
每周:50 分钟 24.0 秒
-
每月:3 小时 39 分钟 8.7 秒
-
每年:43 小时 49 分钟 44.8 秒
-
99.9%:
-
每周:10 分钟 4.8 秒
-
每月:43 分钟 49.7 秒
-
每年:8 小时 45 分钟 57.0 秒
-
99.99%:
-
每周:1 分钟 0.5 秒
-
每月:4 分钟 23.0 秒
-
每年:52 分钟 35.7 秒
-
99.999%:
-
每周:6.0 秒
-
每月:26.3 秒
-
每年:5 分钟 15.6 秒
-
99.9999%:
-
每周:0.6 秒
-
每月:2.6 秒
-
每年:31.6 秒
-
99.99999%:
-
每周:0.1 秒
-
每月:0.3 秒
-
每年:3.2 秒
正如您所看到的,接近100%
的可用性变得越来越困难,停机时间变得更紧。但是,您如何减少停机时间或至少确保尽力保持低水平呢?这个问题没有简单的答案,但我们可以给您一些建议,告诉您可以做的不同事情:
-
最坏的情况将发生,因此您应该经常模拟故障,以便随时准备应对应用程序的大灾难。
-
找出应用程序可能的瓶颈。
-
测试,到处都是测试,当然,要保持它们更新。
-
记录任何事件,任何指标,您可以测量或保存为日志的任何内容,并保存以供将来参考。
-
了解您的应用程序的限制。
-
至少要有一些良好的开发实践,至少要分享应用程序构建的知识。在所有这些实践中,您可以执行以下操作之一:
-
对于任何热修复或功能,需要第二次批准
-
成对编程
-
创建持续交付管道。
-
制定备份计划并确保您的备份安全并随时可用。
-
记录所有内容,任何小的更改或设计,并始终保持文档最新。
现在您已经全面了解了“可用性”意味着什么,以及每个速率的最大停机时间。如果您向用户/客户提供 SLA(服务级别协议),请注意,您将会对应用程序的可用性做出承诺,您将需要履行这个承诺。
负载测试
负载测试可以定义为在应用程序中施加需求(负载)以测量其响应的过程。这个过程可以帮助您确定应用程序或基础设施的最大容量,并且可以突出显示应用程序或基础设施的瓶颈或问题元素。进行负载测试的正常方式是首先在应用程序中进行“正常”条件下的测试,也就是在应用程序中进行正常负载的测试。在正常条件下测量系统的响应可以让您拥有一个基线,您将用它来与未来的测试进行比较。
让我们看一些您可以用于负载测试的最常见工具。有些简单易用,而其他一些更复杂和强大。
Apache JMeter
Apache JMeter 应用程序是一个用 Java 构建的开源软件,旨在进行负载测试和性能测量。起初,它是为 Web 应用程序设计的,但随着时间的推移,它扩展到测试其他功能。
Apache JMeter 的一些最有趣的功能如下:
-
支持不同的应用程序/服务器/协议:HTTP(S)、SOAP/Rest、FTP、LDAP、TCP 和 Java 对象。
-
与第三方持续集成工具轻松集成:它具有用于 Maven、Gradle 和 Jenkins 的库。
-
命令行模式(非 GUI/无头模式):这使您可以在安装了 Java 的任何操作系统上进行测试。
-
多线程框架:这允许您通过许多线程进行并发样本,并通过单独的线程组同时对不同功能进行采样。
-
高度可扩展:它可以通过库或插件等进行扩展。
-
完整的测试 IDE:它允许您创建、记录和调试您的测试计划。
正如您所看到的,这个项目是一个有趣的工具,您可以在负载测试中使用。在接下来的部分中,我们将向您展示如何构建一个简单的测试场景。不幸的是,我们的书中没有足够的空间来涵盖所有功能,但至少您将了解未来更复杂测试的基础知识。
安装 Apache JMeter
由于是用 Java 开发的,这个应用程序可以在安装了 Java 的任何操作系统上使用。让我们在开发机器上安装它。
第一步是满足主要要求——您需要一个 JVM 6 或更高版本来使应用程序工作。您可能已经在您的计算机上安装了 Java,但如果不是这种情况,您可以从 Oracle 页面下载最新的 JDK。
要检查您的 Java 运行时版本,您只需要在您的操作系统中打开终端并执行以下命令:
**java -version**
上述命令将告诉您在您的计算机上可用的版本。
一旦我们确定了正确的版本,我们只需要转到官方的 Apache JMeter 页面(jmeter.apache.org
)并下载最新的 ZIP 或 TGZ 格式的二进制文件。一旦二进制文件完全下载到您的计算机上,您只需要解压下载的 ZIP 或 TGZ,Apache JMeter 就可以使用了。
使用 Apache JMeter 执行负载测试
打开您解压缩 Apache JMeter 二进制文件的文件夹。在那里,您可以找到一个bin
文件夹和一些不同操作系统的脚本。如果您使用 Linux/UNIX 或 Mac OS,您可以���行jmeter.sh
脚本来打开应用程序的 GUI。如果您使用 Windows,有一个jmeter.bat
可执行文件,您可以用它来打开 GUI:
Apache JMeter GUI
Apache JMeter GUI 允许您构建不同的测试计划,正如您在前面的截图中所看到的,即使不阅读手册,界面也非常容易理解。让我们用 GUI 构建一个测试计划。
提示
一个测试计划可以被描述为 Apache JMeter 将按特定顺序运行的一系列步骤。
为了创建我们的测试计划的第一步,需要在测试计划节点下添加一个线程组。在 Apache JMeter 中,线程组可以被定义为并发用户的模拟。按照给定的步骤创建一个新的组:
-
右键单击测试计划节点。
-
在上下文菜单中,选择添加 | 线程(用户) | 线程组。
前面的步骤将在我们的测试计划节点中创建一个子元素。选择它,以便我们可以对我们的组进行一些调整。参考以下截图:
线程组设置
如前面的截图所示,每个线程组都允许您指定测试的用户数量和测试的持续时间。主要可用的选项如下所示:
-
样本错误后要采取的操作:这个选项允许您控制测试在抛出样本错误时的行为。最常用的选项是继续行为。
-
线程数(用户数):这个字段允许您指定要用来击打您的应用程序的并发用户数量。
-
ramp-up 周期(以秒为单位):这个字段用于告诉 Apache JMeter 可以用多少时间来创建您在前一个字段中指定的所有线程。例如,如果您将此��段设置为 60 秒,并且线程数(用户数)设置为 6,Apache JMeter 将花费 60 秒来启动所有 6 个线程,每 10 秒一个。
-
循环计数和永远:这些字段允许您在特定次数的执行后停止测试。
其余选项都是不言自明的,在我们的示例中,我们将只使用上述字段。
假设您想使用 25 个线程(就像用户),并将 ramp-up 设置为 100 秒。数学会告诉您,每 4 秒将创建一个新的线程,直到有 25 个线程在运行(100/25 = 4)。这两个字段允许您设计您的测试,以便在合适的时间开始缓慢增加击打您的应用程序的用户数量。
一旦我们定义了我们的线程/用户,就是时候添加一个请求了,因为没有请求,我们的测试将无法进行。要添加一个请求,您只需要选择线程组节点,右键单击上下文菜单,然后选择添加 | 取样器 | HTTP 请求。前面的操作将在我们的线程组中添加一个新的子节点。选择新节点,Apache JMeter 将向您显示一个类似于以下截图的表单:
HTTP 请求选项
如前面的截图所示,我们可以设置要用我们的测试击打的主机。在我们的情况下,我们决定在端口 8083 上用GET
请求击打localhost
的/api/v1/secret/
路径。随意探索高级选项或添加自定义参数。Apache JMeter 非常灵活,几乎涵盖了每种可能的场景。
在这一点上,我们已经建立了一个基本的测试,现在是时候看看结果了。让我们探索一些有趣的方法来收集测试的信息。为了查看和分析我们测试的每次迭代的结果,我们需要添加一个监听器。要做到这一点,就像在之前的步骤中一样,右键单击线程组,然后导航到添加 | 监听器 | 在表中查看结果。这个操作将在我们的测试中添加一个新的节点,一旦我们开始测试,结果将出现在应用程序中。
如果您在线程组中选择了永远选项,则需要手动停止测试。您可以使用绿色播放旁边显示的红色十字图标来停止。此按钮将停止等待每个线程结束其操作的测试。如果单击停止图标,Apache JMeter 将立即终止所有线程。
让我们试一试,点击绿色播放图标开始测试。点击查看结果表节点,您将看到测试结果的所有结果出现:
Apache JMeter 表中的结果
如前面的屏幕截图所示,Apache JMeter 为每个请求记录了不同的数据,例如发送/返回的字节数,状态或请求延迟等。当您更改负载量时,所有这些数据都很有趣。使用此监听器,您甚至可以导出数据,以便使用外部工具分析结果。
如果您没有外部工具来分析数据,但是您想要一些基本的统计数据来与您的应用程序暴露给不同负载进行比较,您可以添加另一个有趣的监听器。与之前一样,打开线程组的右键上下文菜单,导航到添加
| 监听器 | 摘要报告
。此监听器将为您提供一些基本统计数据,供您用于比较结果:
Apache JMeter 摘要报告
如前面的屏幕截图所示,此监听器为我们提供了一些测量的平均值。
使用表格显示结果是可以的。但是众所周知,一张图片胜过千言万语,因此让我们添加一些图形监听器,以便您完成负载测试报告。右键单击线程组,在上下文菜单中转到添加 | 监听器 | 响应时间图。您将看到一个类似于以下的屏幕:
Apache JMeter 响应时间图
随意对默认设置进行一些更改。例如,您可以减少间隔(毫秒)。如果再次运行测试,测试生成的所有数据将用于生成一个漂亮的图表,如下图所示:
响应时间图
从我们的测试结果生成的图表中可以看出,线程(用户)的增加导致响应时间的增加。你认为这意味着什么?如果你说我们的测试基础设施需要扩大以适应负载的增加,那么你的回答是正确的。
Apache JMeter 具有多个选项来创建您的负载测试。我们只向您展示了如何创建基本测试以及如何查看结果。现在轮到您探索所有可用的不同选项来创建高级测试,并发现哪些功能更适合您的项目。让我们看看您可以用于负载测试的其他工具。
使用 Artillery 进行负载测试
Artillery是一个开源工具包,您可以使用它对应用程序进行负载测试,它类似于 Apache JMeter。除其他功能外,我们可以强调此工具的以下优点:
-
支持多种协议,HTTP(S)或 WebSockets 可以直接使用
-
易于与实时报告软件或 DataDog 和 InfluxDB 等服务集成
-
高性能,因此可以在普通硬件/服务器上使用
-
非常容易扩展,因此可以根据您的需求进行调整
-
具有详细性能指标的不同报告选项
-
非常灵活,因此您可以测试几乎任何可能的场景
安装 Artillery
Artillery 是基于 node.js 构建的,因此主要要求是在您将用于执行测试的计算机上安装此运行时。
我们喜欢容器化技术,但不幸的是,在 Docker 上使用 artillery 没有简单的方法,除非进行一些不干净的操作。无论如何,我们建议您使用专用的 VM 或服务器进行负载测试。
要使用 Artillery,您需要在您的 VM/server 中安装 node.js,这个软件非常容易安装。我们不打算解释如何创建本地 VM(您可以使用 VirtualBox 或 VMWare 创建一个),我们只会向您展示如何在 RHEL/CentOS 上安装它。对于其他操作系统和选项,您可以在 node.js 文档中找到详细信息(nodejs.org/en/download/
)。
打开您的 RHEL/CentOS 虚拟机或服务器的终端,并下载 LTS 版本的设置脚本:
**curl --silent -location https://rpm.nodesource.com/setup_6.x | bash -**
一旦上一个命令完成,您需要以 root 身份执行下一个命令,如下所示:
**yum -y install nodejs**
执行上述命令后,您的 VM/server 将安装并准备好 Node.js。现在是时候使用 Node.js 包管理器npm
命令全局安装 Artillery 了。在您的终端中,执行以下命令以全局安装 Artillery:
**npm install -g artillery**
一旦上一个命令完成,您就可以使用 Artillery 了。
我们可以做的第一件事是检查 Artillery 是否已正确安装并可用。输入以下命令进行检查:
**artillery dino**
上述命令将向您显示一个可爱的恐龙,这意味着 Artillery 已经准备好使用了。
使用 Artillery 执行负载测试
Artillery 是一个非常灵活的工具包。您可以从控制台运行测试,也可以使用描述测试场景的 YAML 或 JSON 文件运行它们。请注意,在我们的以下示例中,我们使用microservice_secret_nginx
作为我们要测试的主机,您需要将此主机调整为您本地环境的 IP 地址。让我们来看看这个工具;在我们的负载测试 VM/server 中运行以下命令:
**artillery quick --duration 30 --rate 5 -n 1
http://microservice_secret_nginx/api/v1/secret/**
上述命令将在 30 秒的时间内进行快速测试。在此期间,Artillery 将创建五个虚拟用户;每个用户将对提供的 URL 进行一次 GET 请求。一旦执行了上述命令,Artillery 将开始测试并每 10 秒打印一些统计信息。在测试结束时(30 秒),此工具将向您显示一个类似于以下的小报告:
**Complete report @ 2016-12-17T16:09:34.140Z
Scenarios launched: 150
Scenarios completed: 150
Requests completed: 150
RPS sent: 4.87
Request latency:
min: 578.1
max: 1223.7
median: 781.5
p95: 1146.5
p99: 1191.1
Scenario duration:
min: 583.2
max: 1226.8
median: 786.1
p95: 1150
p99: 1203.8
Scenario counts:
0: 150 (100%)
Codes:
200: 150**
上述报告非常易于理解,并为您提供了基础设施和应用程序的绩效概述。
在我们开始分析 Artillery 报告之前,您需要了解的一个基本概念是场景的概念。简而言之,场景是您想要测试的一系列任务或操作,它们是相关的。想象一下,您有一个电子商务应用程序;一个测试场景可以是用户在完成购买之前执行的所有步骤。考虑以下示例:
-
用户加载主页。
-
用户搜索产品。
-
用户向购物篮中添加产品。
-
用户去结账。
-
用户进行购买。
所有提到的操作都可以转换为对您的应用程序的请求,模拟用户操作,这意味着一个场景是一组请求。
现在我们清楚了这个概念,我们可以开始分析 Artillery 输出的报告。在我们的示例中,我们只有一个场景,只有一个请求(GET
)到http://microservice_secret_nginx/api/v1/secret/
。这个测试场景由五个虚拟用户执行,他们在 30 秒内只发出一个GET
请求。一个简单的数学计算,5 * 1 * 30
,给出了我们测试的场景总数(150
),这与我们的情况下的请求总数相同。RPS sent
字段给出了我们的测试服务器在测试期间平均每秒发送的请求。这不是一个非常重要的字段,但它可以让您了解测试的执行情况。
让我们来看看 Artillery 给出的Request latency
和Scenario duration
统计数据。您需要知道的第一件事是,这些组的所有测量都是以毫秒为单位的。
在Request latency
的情况下,数据向我们展示了应用程序处理我们发送的请求所用的时间。两个重要的统计数据是 95%(p95
)和 99%(p99
)。您可能已经知道,百分位数是统计学中用于指示给定百分比观察值落在其下的值的度量。从我们的示例中,我们可以看到 95%的请求在 1146.5 毫秒或更短的时间内被处理,或者 99%的请求在 1191.1 毫秒或更短的时间内被处理。
在我们的示例中,Scenario duration
中显示的统计数据与Request latency
几乎相同,因为每个场景只包含一个请求。如果您创建了更复杂的场景,每个场景包含多个请求,那么这两组数据将有所不同。
创建 Artillery 脚本
正如我们之前告诉过您的,Artillery 允许您创建 YAML 或 JSON 文件来进行负载测试场景。让我们将我们的快速示例转换为一个 YAML 文件,这样您就可以将其保存在存储库中以备将来执行。
要做到这一点,您只需要在我们的测试容器中创建一个名为test-secret.yml
的文件,内容如下:
**config:
target: 'http://microservice_secret_nginx/'
phases:
- duration: 30
arrivalRate: 5
scenarios:
- flow:
- get:
url: "api/v1/secret/"**
正如您在上文中所看到的,它与我们的artillery quick
命令类似,但现在您可以将它们存储在您的代码存储库中,以便反复针对您的应用程序运行。
您可以使用artillery run test-secret.yml
命令运行您的测试,结果应该与快速命令生成的结果类似。
Docker 容器镜像只包含所需的最小软件,因此您可能无法在我们的负载测试镜像中找到文本编辑器。在本书的这一部分,您将能够创建一个 Docker 卷并将其附加到我们的测试容器,以便您可以共享文件。
高级脚本编写
这个工具包的一个突出特点是能够创建自定义脚本,但您不仅仅局限于发送静态请求。该工具允许您使用外部 CSV 文件、解析 JSON 响应或脚本中的内联值来随机化请求。
假设您想要测试负责在您的应用程序中创建新帐户的 API 端点,而不是使用 YAML 文件,您正在使用 JSON 脚本。您可以使用外部 CSV 文件与以下调整一起在测试中使���用户数据:
"config": {
"payload": {
"path": "./relative/path/to/test-data.csv",
"fields": ["name", "surname", "email"]
}
}
// ... omitted config ...//
"scenarios": [
{
"flow": [
{
"post": {
"url": "/api/v1/user",
"json": {
"name": {{ name }},
"surname": {{ surname }},
"email": {{ email }}
}
}
}
]
}
]
config
字段将告诉 Artillery 我们的 CSV 文件的位置以及 CSV 中使用的不同列。设置好外部文件后,我们可以在场景中使用这些数据。在我们的示例中,Artillery 将从test-data.csv
中随机选择行,并使用这些数据生成对/api/v1/user
的 post 请求。payload
中的字段将创建我们可以使用的变量,比如{{ variableName }}
。
创建这种类型的脚本似乎很容易,但是在创建脚本的过程中,您可能需要一些调试信息来了解您的脚本在做什么。如果您想查看每个请求的详细信息,可以按照以下方式运行您的脚本:
**DEBUG=http artillery run test-secret.yml**
如果您想要查看响应,可以按照以下方式运行负载测试脚本:
**DEBUG=http:response artillery run myscript.yaml**
不幸的是,本书中没有足够的空间来详细介绍 Artillery 中的所有可用选项。但是,我们想向您展示一个有趣的工具,您可以使用它进行负载测试。如果您需要更多信息,甚至如果您想要为项目做出贡献,您只需要访问项目的页面(artillery.io
)。
使用 siege 进行负载测试
Siege 是一个有趣的多线程 HTTP(s)负载测试和基准测试工具。与其他工具相比,它似乎小而简单,但它高效且易于使用,例如,对您最新更改进行快速测试。此工具允许您使用可配置数量的并发虚拟用户命中 HTTP(S)端点,并且可以在三种不同模式下使用:回归、互联网模拟和暴力。
Siege 是为 GNU/Linux 开发的,但已成功移植到 AIX、BSD、HP-UX 和 Solaris。如果您想要编译它,在大多数 System V UNIX 变体和大多数较新的 BSD 系统上都不应该有任何问题。
在 RHEL、CentOS 和类似的操作系统上安装 siege
如果您使用启用了额外存储库的 CentOS,您可以使用一个简单的命令安装 EPEL 存储库:
**sudo yum install epel-release**
一旦您有了 EPEL 存储库,您只需要执行sudo yum install siege
就可以在您的操作系统中使用此工具。
有时,例如当您不使用 Centos 时,sudo yum install epel-release
命令不起作用,您的发行版是 RHEL 或类似的发行版。在这些情况下,您可以使用以下命令手动安装 EPEL 存储库:
**wget https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm
sudo rpm -Uvh epel-release-latest-7*.rpm**
一旦 EPEL 存储库在您的操作系统中可用,您可以使用以下命令安装 siege
:
**sudo yum install siege**
在 Debian 或 Ubuntu 上安装 siege
在 Debian 或 Ubuntu 上安装 siege 非常简单,只需使用官方存储库。如果您有这些操作系统的最新版本之一,您只需要执行以下命令:
**sudo apt-get update
sudo apt-get install siege**
上述命令将更新您的系统并安装siege
软件包。
在其他操作系统上安装 siege
如果您的操作系统在之前的步骤中没有涵盖,您可以通过编译源代码来完成,互联网上有很多说明您需要做什么的教程。
快速 siege 示例
让我们快速创建一个文本到我们的一个端点。在这种情况下,我们将在 30 秒内使用 50 个并发用户测试我们的端点。打开您安装了 siege 的机器的终端,并输入以下命令。随意更改命令以正确的主机或端点:
**siege -c50 -d10 -t30s http://localhost:8083/api/v1/secret/**
上述命令在以下几点中得到解释:
-
-c50
:创建 50 个并发用户 -
-d10
:每个模拟用户之间的延迟为 1 到 10 秒之间的随机秒数 -
-t30s
:运行测试的时间;在我们的情况下为 30 秒 -
http://localhost:8083/api/v1/secret/
:要测试的端点
一旦您按下Enter,siege
命令将开始向服务器发送请求,并且您将获得类似以下的输出:
**filloa:~ psolar$ siege -c50 -d10 -t30s http://localhost:8083/api/v1/secret/**
**** SIEGE 3.1.3
** Preparing 50 concurrent users for battle.
The server is now under siege...
HTTP/1.1 200 0.50 secs: 577 bytes ==> GET /api/v1/secret/
/** ... omitted lines ... **/
Lifting the server siege... done.**
大约 30 秒后,siege 将停止请求并向您显示一些统计信息,例如以下内容:
**Transactions: 149 hits
Availability: 100.00 %
Elapsed time: 29.91 secs
Data transferred: 0.08 MB
Response time: 3.33 secs
Transaction rate: 4.98 trans/sec
Throughput: 0.00 MB/sec
Concurrency: 16.57
Successful transactions: 149
Failed transactions: 0
Longest transaction: 5.89
Shortest transaction: 0.50**
从上述结果中,我们可以得出结论,我们所有的请求都没有问题,没有一个请求失败,平均响应时间为 3.33 秒。正如您所看到的,这个工具更简单,可以在日常基础上使用,以检查您的应用程序从哪个并发用户级别开始出现错误,或者在您检查其他指标时将应用程序置于压力之下。
可扩展性计划
可扩展性计划是一份描述应用程序的所有不同组件以及在需要时扩展应用程序所需步骤的文件。可扩展性计划是一份实时文件,因此您需要经常审查并保持更新。
由于可扩展性计划更多地是一个内部文件,其中包含您需要做出关于应用程序可扩展性的正确决策的所有信息,因此没有准备好填写的主模板。我们建议使用可扩展性计划作为您的指南,包括您的容量计划的所有内容,甚至可以将如何雇佣新员工添加到此文档中。
您的可扩展性计划中可能包括以下部分:
-
应用程序及其组件的概述
-
云提供商或您将部署应用程序的地点的比较
-
您的容量计划和应用程序的理论极限的总结
-
可扩展性阶段或步骤
-
配置时间和成本
-
组织可扩展性步骤
前面的部分只是一个建议,随时可以添加或删除任何部分以适应您的业务计划。
以下是容量计划的一些部分概述。假设我们的示例微服务应用程序已准备就绪,并且希望从最低资源开始扩展。首先,我们可以将我们应用程序中的不同元素描述为基本清单,从而使我们的应用程序得以发展:
-
战斗微服务
-
NGINX
-
PHP 7 fpm
-
位置微服务
-
NGINX
-
PHP 7 fpm
-
秘密微服务
-
NGNIX
-
PHP 7 fpm
-
用户微服务
-
NGINX
-
PHP 7 fpm
-
数据存储层
-
数据库:Percona
正如您所看到的,我们已经描述了我们应用程序所需的每个组件,并开始在所有微服务之间共享数据层。我们没有添加任何缓存层;此外,我们也没有添加任何自动发现和遥测服务(我们将在接下来的步骤中添加额外功能)。
一旦我们满足了最低要求,让我们来看看我们的可扩展性计划中可以有哪些不同步骤。
第 0 步
在这一步中,即使应用程序尚未准备好投入生产,我们将在一台机器上满足所有我们的要求,因为您的应用程序无法在机器出现问题时生存。以下特征的单个服务器将足够:
-
8 GB RAM
-
500 GB 磁盘
基本操作系统将是 RHEL 或 CentOS,并安装以下软件:
-
带有多个虚拟主机设置的 NGINX
-
Git
-
Percona
-
PHP 7 fpm
在这一步中,配置时间可能需要几个小时。我们不仅需要启动服务器,还需要设置所需服务(NGINX、Percona 等)。使用诸如 Ansible 之类的工具可以帮助我们快速和可重复地进行配置。
第 1 步
在这一点上,您正在为生产环境准备应用程序,选择虚拟机或容器(在我们的情况下,我们决定使用容器以获得灵活性和性能),将单个服务器配置拆分为专用于每个所需服务的多个服务器,如我们之前的要求,并添加自动发现和遥测服务。
在这一步,您可以找到我们应用程序架构的简要描述:
-
自动发现
-
带有 ContainerPilot 的 Hashicorp Consul 容器
-
遥测
-
带有 ContainerPilot 的 Prometheus 容器
-
战斗微服务
-
带有 ContainerPilot 的 NGINX 容器
-
带有 ContainerPilot 的 PHP 7 fpm 容器
-
位置微服务
-
带有 ContainerPilot 的 NGINX 容器
-
带有 ContainerPilot 的 PHP 7 fpm 容器
-
秘密微服务
-
带有 ContainerPilot 的 NGINX 容器
-
带有 ContainerPilot 的 PHP 7 fpm 容器
-
用户微服务
-
带有 ContainerPilot 的 NGINX 容器
-
带有 ContainerPilot 的 PHP 7 fpm 容器
-
数据存储层
-
带有 ContainerPilot 的 Percona 容器
在这一步中,配置时间将从前一步的几小时减少到几分钟。我们已经有了一个自动发现服务(HashiCorp Consul),并且由于 ContainerPilot,我们的每个不同组件将在自动发现注册中注册自己,并自动设置。几分钟内,我们可以完成所有容器的配置和设置。
第 2 步
在您的可扩展性规划的这一步中,您将为所有应用程序微服务添加缓存层,以减少请求数量并提高整体性能。为了提高性能,我们决定使用 Redis 作为我们的缓存引擎,因此您需要在每个微服务上创建一个 Redis 容器。这一步的配置时间将与上一步相似,但以分钟为单位。
第 3 步
在这一步中,您将把存储层移动到每个微服务中,使用 ContainerPilot 和 Consul 自动设置 Master-Slave 模式的三个 Percona 容器。
这一步的配置时间将与上一步相似,以分钟为单位。
第 4 步
在可扩展性计划的这一步中,您将研究应用程序的负载和使用模式。您将在 NGINX 容器前面添加负载均衡器,以获得更大的灵活性。由于这个新层,我们可以进行 A/B 测试或蓝/绿部署,以及其他功能。在这种情况下,您可以使用一些有趣的开源工具,如 Fabio 代理和 Traefik。
这一步的预配时间将与上一步相似,以分钟为单位。
第 5 步
在这最后一步中,您将再次检查应用程序基础设施,使其保持最新,并在必要时进行水平扩展。
这一步的预配时间将与上一步相似,以分钟为单位。
正如我们之前告诉过您的,可扩展性计划是一个动态文件,因此您需要经常进行修订。想象一下,几个月后会有一种新的数据库软件问世,它非常适合高负载;您可以审查您的可扩展性计划,并将这个新数据库引入您的基础设施。请随意添加您认为对应用程序的可扩展性重要的所有信息。
总结
在本章中,我们向您展示了如何检查应用程序的限制,这可以让您了解可能会遇到的瓶颈。我们还向您展示了创建容量和可扩展性计划所需的基本概念。我们还向您展示了一些对应用程序进行负载测试的选项。您应该有足够的知识,使您的应用程序能够应对高负载使用,或者至少了解您应用程序的薄弱点。
第十一章:最佳实践和约定
这一章将教你在其他开发人员中脱颖而出。这是通过以风格开发和执行本书中学到的策略,并遵循具体的标准来实现的。
代码版本控制最佳实践
随着时间的推移,你的应用程序将不断发展,最终你会想知道你将如何处理任何微服务的 API。你可以尽量减少更改并对你的 API 的用户透明,或者你可以创建不同版本的代码。最佳解决方案是对你的代码(API)进行版本控制。
代码版本控制的众所周知和常用的方式如下:
-
URL:在这种方法中,你在请求的 URL 中添加 API 的版本。例如,
https://phpmicroservices.com/api/v2/user
的 URL 表示我们正在使用我们 API 的v2
。我们在本书中的示例中使用了这种方法。 -
自定义请求头:在这种方法中,我们不在 URL 中指定版本。相反,我们使用 HTTP 头来指定我们想要使用的版本。例如,我们可以对
https://phpmicroservices.com/api/user
进行 HTTP 调用,但附加一个额外的头部"api-version: 2"
。在这种情况下,我们的服务器将检查 HTTP 头并使用我们 API 的v2
。 -
接受头:这种方法与前一种方法非常相似,但是我们将使用
Accept
头而不是自定义头。例如,我们将对https://phpmicroservices.com/api/user
进行调用,但我们的 Accept 头将是"Accept: application/vnd.phpmicroservices.v2+json"
。在这种情况下,我们指示我们想要版本 2,并且数据将以 JSON 格式呈现。
正如你可以想象的那样,在你的代码中实现版本控制的最简单方法是在 URL 中使用版本代码,但不幸的是,这并不被认为是最佳选项。大多数开发人员认为最佳的代码版本控制方式是使用 HTTP 头来指定你想要使用的版本。我们建议使用最适合你的项目的方法。分析谁将使用你的 API 以及如何使用,你将发现你需要使用的版本控制方法。
缓存最佳实践
缓存是一个可以存储临时数据的地方;它用于提高应用程序的性能。在这里,你可以找到一些小贴士来帮助你处理缓存。
性能影响
向你的应用程序添加缓存层总是会产生性能影响,你需要进行测量。无论你在应用程序的哪个位置添加缓存层,你都需要测量影响,以了解新的缓存层是否是一个好选择。首先,在没有缓存层的情况下进行一些度量,一旦你有了一些统计数据,启用缓存层并进行比较。有时你会发现,缓存层的好处变成了一个管理上的困扰。你可以使用我们在前几章中谈到的一些监控服务来监控性能影响。
处理缓存未命中
缓存未命中是指请求未保存在缓存中,应用程序需要从服务/应用程序中获取数据。确保你的代码可以处理缓存未命中和随之而来的更新。为了跟踪缺失缓存命中率,你可以使用任何监控软件或甚至日志系统。
分组请求
尽可能地尝试将你的缓存请求分组。想象一下,你的前端需要从缓存服务器中获取五个不同的元素来渲染一个页面。你可以尝试将请求分组,而不是进行五次调用,从而节省时间。
想象一下,你正在使用 Redis 作为缓存层,并希望将一些值保存在foo
和bar
变量中。看一下以下代码:
$redis->set('foo', 'my_value');
/** Some code **/
$redis->set('bar', 'another_value');
而不是这样做,你可以在一个事务中完成两个集合:
$redis->mSet(['foo' => 'my_value', 'bar' => 'another_value']);
上述示例将在一个提交中完成两个集合,节省时间并提高应用程序的性能。
缓存中存储的元素大小
将大型项目存储在缓存中比存储小型项目更有效。如果你开始缓存大量小项目,整体性能将会降低。在这种情况下,序列化大小、时间、缓存提交时间和容量使用将会增加。
监控你的缓存
如果你决定添加一个缓存层,至少要监控它。保持一些关于你的缓存的统计数据将有助于你了解它的表现如何(缓存命中率),或者它是否达到了容量限制。大多数缓存软件都是稳定而强大的,但这并不意味着如果你不加管理就不会遇到任何问题。
仔细选择你的缓存算法
大多数缓存引擎支持不同的算法。每种算法都有其优点和问题。我们建议你深入分析你的需求,并在确定它是你用例的正确算法之前,不要使用你选择的缓存引擎的默认模式。
性能最佳实践
如果你正在阅读这本书,很可能是因为你对 Web 开发感兴趣,而在过去几年中,Web 应用程序(如 API)的性能变得越来越重要。以下是一些统计数据,以便让你有一个概念:
-
亚马逊多年前报告称,每增加 100 毫秒的加载时间,他们的销售额就会减少 1%。
-
谷歌发现,将页面大小从 100 KB 减少到 80 KB 会使他们的流量减少 25%。
-
57%的在线消费者在等待页面加载 3 秒后会放弃一个网站。
-
80%的放弃网站的人不会回来。大约 50%的这些人会告诉其他人他们的负面经历。
正如你所看到的,你的应用程序的性能可能会影响你的用户甚至你的收入。在本节中,我们将为你提供一些改善 Web 应用程序整体性能的建议。
最小化 HTTP 请求
每个 HTTP 请求都有一个有效负载。因此,提高性能的一种简单方法是减少 HTTP 请求的数量。你需要在开发的每个方面都牢记这个想法。尽量减少 APIs/后端中对其他服务的最小外部调用。在前端,你可以合并文件以满足只有一个请求。你只需要在请求的数量和每个请求的大小之间取得平衡。
想象一下,你的前端的 CSS 被分成了几个不同的文件;而不是每次加载一个文件,你可以将它们合并成一个或几个文件。
你可以通过 HTTP 请求进行另一个快速而小的更改,那就是尽量避免在你的 CSS 文件中使用@import
函数。使用链接标签而不是@import
函数将允许你的浏览器并行下载 CSS 文件。
最小化 HTML、CSS 和 JavaScript
作为开发人员,我们试图以对我们来说更容易阅读的格式编写代码--一种人类友好的格式。通过这种方式开发,我们增加了我们的纯文本文件的大小,其中包括不必要的字符。不必要的字符可能包括空格、注释和换行符。
我们并不是说你需要编写混淆的代码,但是一旦你准备好了一切,为什么不删除不必要的字符呢?
想象一下,你的一个 JavaScript 文件(myapp.js
)的内容如下:
/**
* This is my JS APP
*/
var myApp = {
// My app variable
myVariable : 'value1',
// Main action of my JS app
doSomething : function () {
alert('Doing stuff');
}
};
在最小化之后,你的代码可以保存到一个不同的文件(myapp.min.js
),它可能如下所示:
var myApp={myVariable:"value1",doSomething:function()
{alert("Doing stuff")}};
在新的代码中,我们将文件大小减少了大约 60%,节省了大量空间。请注意,我们的存储库将同时拥有文件的两个版本:人类可读的版本用于进行更改,以及我们将在前端加载的最小化版本。
您可以使用在线工具进行最小化,或者您可以将gulp
或grunt
等工具集成到您的流程中。设置这些工具后,它们将跟踪某些特定文件(CSS、JS 和其他文件)的更改,一旦您对这些文件中的任何一个进行保存,工具将最小化内容。使用最小化工具的另一个隐藏的好处是,大多数工具还会检查代码或重命名变量以使其更小。
图像优化
在 Web 开发中最常用的资源之一可能就是图像。它们让您的网站看起来很棒,但也可能使您的网站变得非常缓慢。主要建议是将图像数量保持最少,但如果您需要保留图像,至少在将它们发送给用户之前尝试优化它们。在本节中,我们将向您展示一些可以优化图像的方法,从而提高应用程序的性能。
使用精灵图
精灵图是由多个图像组成的图像;稍后,您可以使用此图像并仅显示您感兴趣的部分。想象一下,您有一个漂亮的网站,在每个页面上都有一些社交图标(Facebook、Twitter、Instagram 等)。您可以将它们合并在一起,并使用 CSS 仅显示您想要的每个图标的部分,而不是为每个社交图标都有一个图像。这样做,您将只需加载一次所有社交图标,从而减少请求次数。
我们建议保持您的精灵图小,并只包含其中最常用和共享的图像。
使用无损图像压缩
并非所有图像格式都适合 Web,因为某些格式要么太大,要么不支持压缩。当今 Web 上使用最多的三种图像类型如下:
-
JPG:这是最常用的无损压缩方法之一
-
PNG:这是具有无损数据压缩的最佳格式
-
GIF:这是一种老式格式,每个图像支持最多 8 位像素,并以其动画效果而闻名
目前 Web 的推荐格式是PNG。它得到了浏览器的良好支持,易于创建,支持压缩,并且为您提供了改善网站性能所需的所有功能。
缩放图像
如果您使用图像而不是数据 URI,则应以其原始尺寸发送图像。您应该避免使用 CSS 调整图像大小,并将具有正确尺寸的图像发送到浏览器。唯一建议使用 CSS 缩放图像的情况是在流体图像(响应式设计)中。
您可以使用像 Imagick 或 GD 这样的 PHP 库轻松缩放图像。使用这些库和几行代码,您可以在几秒钟内缩放图像。通常情况下,您不会即时缩放图像。大多数情况下,一旦图像上传到您的应用程序,批处理过程会处理图像,创建应用程序所需的不同尺寸。
想象一下,您可以将任何尺寸的图像上传到您的应用程序,但在前端只显示最大宽度为350
px 的图像。您可以使用 Imagick 轻松缩放以前存储的图像:
$imagePath = '/tmp/my_uploaded_image.png';
$myImage = new Imagick($imagePath);
$myImage->resizeImage(350, 0, Imagick::FILTER_LANCZOS, 1);
$myImage->writeImage('/tmp/my_uploaded_image_350.png');
上述代码将加载my_uploaded_image.png
文件,并使用 Lanczos 滤镜将图像调整为宽度为350
px(请参阅 PHP Imagick 文档,了解您可以使用的所有可用滤镜)。
这是一种方法,另一种(也许更有效的)常见方法是按需调整图像大小(即在客户端首次请求时),然后将调整大小的图像存储在缓存或永久存储中。
使用数据 URI
另一种快速减少 HTTP 请求次数的方法是将图像嵌入数据 URI 中。这样,您将在代码中将图像作为字符串,避免了对图像的请求,这种方法最适合静态页面。生成这种 URI 的最佳方式是使用外部或在线工具。
以下示例将向您展示它在您的 HTML 中的外观:
<img src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgA..."
alt="My Image">
缓存,缓存,还有更多的缓存
Web 性能完全取决于尽快提供数据,如果我们的应用程序已经发送的数据仍然有效,为什么要再次发送呢?默认情况下,现代浏览器会尝试减少它们对同一站点发出的请求数量,因此它们会在内部缓存中保留一些资产/资源的副本以供将来使用。由于这种行为,如果您正在浏览网站,我们不会在您在各个部分之间移动时一次又一次地尝试加载所有资产。
您可以通过指定每个请求响应来帮助浏览器,使用以下Cache-Control
HTTP 标头:
-
max-age=[秒]:这设置了响应被视为新鲜的最长时间。此指令是相对于请求的时间。
-
s-maxage=[秒]:这类似于 max-age,但适用于共享缓存。
-
public:此标记将响应标记为可缓存。
-
private:此标记允许您将响应存储到一个用户。不允许共享缓存存储响应。
-
no-cache:此标记指示缓存将请求提交给原始服务器进行验证。
-
no-store:此标记指示缓存不保留响应的副本。
-
must-revalidate:此标记告诉缓存它们必须遵循您提供的有关响应的任何新信息。
-
proxy-revalidate:这类似于 must-revalidate,但适用于代理缓存。
主要建议是将静态资产标记为至少一周或长寿命资产的一天到期。对于频繁更改的资产,建议将到期日期设置为两天或更短。根据其生命周期调整资产的缓存到期日期。
想象一下,您有一张图片,每 6 小时更改一次;在这种情况下,您不应该将到期日期设置为一周,最好的选择将是大约 6 小时。
避免不良请求
没有比坏请求更令人讨厌的了,因为这种请求会严重降低应用程序的性能。您可能知道,浏览器对于同一主机可以同时管理的并发连接数量是有限的。如果您的网站发出了大量请求,这些可用连接插槽的列表可能已满,剩下的请求将被排队。
想象一下,您的浏览器最多可以管理 10 个并发连接,而您的 Web 应用程序却发出了 20 个请求。并非所有请求都可以同时处理,其中一些请求被排队。现在,如果您的应用程序正在尝试获取一个不存在的资产会发生什么?在这种情况下,浏览器将浪费时间(和插槽)等待不存在的资产被提供,但这永远不会发生。
作为建议,密切关注您的浏览器开发人员工具(一组内置于浏览器中的 Web 调试工具,可用于调试和分析您的站点)。这些工具可以帮助您发现问题请求,甚至可以检查每个请求使用的时间。在大多数浏览器中,您可以按下F12键打开嵌入式开发人员工具,但是,如果您的浏览器按下此键不打开工具,请查看浏览器的文档。
使用内容交付网络(CDN)
内容交付网络在旨在快速响应并从最近的服务器响应的服务器上托管您的资产的副本。这样,如果您将请求从您的服务器转移到 CDN 服务器,您的 Web 服务器将处理更少的请求,从而提高应用程序的性能。
想象一下,如果您在前端使用 jQuery;如果您将代码更改为从官方 CDN 加载库,则用户在其浏览器缓存中拥有该库的概率会增加。
我们的主要建议是至少为您的 CSS、JavaScript 和图像使用 CDN。
依赖管理
您有多个 PHP 库、框架、组件和工具可供在项目中使用。直到几年前,PHP 没有一种现代的管理项目依赖关系的方式。此刻我们有 Composer,一个灵活的项目,已经成为依赖管理的事实标准。
您可能对 Composer 很熟悉,因为我们在整本书中都在使用这个工具来在vendor
文件夹中安装新库。此时,您可能会想知道是否应该提交vendor
文件夹的依赖关系。没有快速的答复,但一般的建议是不要,您不应该将vendor
文件夹提交到您的存储库中。
提交供应商文件夹的主要缺点可以总结如下:
-
增加您的存储库的大小
-
复制了您的依赖关系的历史记录
正如我们之前告诉过你的,不提交供应商是主要的建议,但如果你真的需要这样做,这里有一些建议:
-
使用标记的发布(不是开发版本),以便 Composer 获取压缩的源代码
-
在您的配置文件中使用
--prefer-dist
标志或将preferred-install
设置为dist
-
将
/vendor/**/.git
规则添加到您的.gitignore
文件中
语义版本
在您开始的任何项目中,您应该在主分支上使用语义版本。语义版本是一组规则,您可以遵循这些规则来标记您应用程序的代码在您的版本控制软件中。通过遵循这些规则,您将了解您的生产环境的当前状态。在您的代码中使用标签的另一个好处是,它允许我们在不同版本之间移动或以一种简单快捷的方式进行回滚。
拥有带有发布标签的源代码的另一个优势是,它允许您使用发布分支,从而使您能够更好地规划和控制对代码所做的更改。
语义版本如何工作
在语义版本中,您的代码标记为vX.Y.Z
形式的标签,这意味着您代码的版本。您的每个标签部分都有特定的含义:
-
X(主要):此版本号的增加表示正在进行重大改变;它们足够重要,与当前版本不兼容
-
Y(次要):此版本号的增加表示我们正在向项目添加新功能
-
Z(补丁):此版本号的增加表示我们向源代码添加了一个补丁
发布标签的更新通常由将代码推送到生产环境的开发人员进行。请记住在部署代码之前更新发布标签。
语义版本在行动
想象一下,您开始在别人的项目中,主分支被标记为v1.2.3
。让我们看一些例子。
我们被告知要向项目添加一个新功能
在进行实时项目时,会收到新功能的请求。在这种情况下,我们明显正在增加次要版本号,因为我们正在添加新代码,这与实际基础代码不兼容。在我们的情况下,如果我们的主分支是v1.2.3
,新版本标签将是v1.3.0
。我们增加了次要版本号,同时重置了补丁号,因为我们正在添加新代码。
我们被告知我们的项目中有一个错误
在日常工作中,您将修复代码中的错误。在这种情况下,我们正在处理的是一个小改变,主要功能是解决我们的问题,因此我们需要增加补丁版本。在我们的例子中,如果当前生产版本是v1.2.3
,新版本标签将是v1.2.4
。我们只增加了补丁号,因为我们的修复不涉及其他更大的改变。
我们被要求进行重大改变
现在想象一下,我们被要求对我们的源代码进行重大更改;一旦应用了我们的更改,我们的源代码的某些部分将与以前的版本不兼容。例如,想象一下,您正在使用library_a
,我们改用library_b
,它们是互斥的。在这种情况下,我们正在处理一个非常重大的变化,这表明我们需要增加我们的主要版本号,同时还需要重置次要和补丁号。例如,如果我们的生产代码标记为v1.2.3
,则应用更改后的新版本代码将为v2.0.0
。
正如您所看到的,进行语义版本控制将帮助您保持源代码的清洁,并使得通过查看版本号就能知道正在进行哪种类型的代码更改。
错误处理
当我们因为应用程序执行期间发生了某些事情而抛出异常时,我们应该向我们的用户或消费者提供更多关于发生了什么的信息。通过添加可描述的标准代码,也称为状态代码,可以实现这一点。在响应中使用这些标准代码将帮助您(和您的同事)快速了解应用程序中是否出现了问题。查看以下列表,了解在 API 中使用的正确和最常见的 HTTP 状态代码。
客户端请求成功
如果您的应用程序需要通知 API 客户端请求成功,通常会回复以下 HTTP 状态代码之一:
-
200 - 正常:请求成功完成
-
201 - 已创建:成功创建了客户端指定的 URI
-
202 - 已接受:已接受处理,但服务器尚未完成处理
-
204 - 无内容:请求已完成,响应中没有发送任何信息
请求重定向
当您的应用程序需要回复请求被重定向时,您将使用以下 HTTP 状态代码之一:
-
301 - 永久移动:所请求的资源在服务器上不存在。服务器发送一个位置标头给客户端,将其重定向到新的 URL。客户端在将来的请求中继续使用新的 URL。
-
302 - 暂时移动:所请求的资源已暂时移动。服务器发送一个位置标头给客户端,将其重定向到新的 URL。客户端在将来的请求中继续使用旧的 URL。
-
304 - 未修改:用于响应
If-Modified-Since
请求标头。它表示自指定日期以来所请求的文档未被修改,客户端应使用缓存副本。
客户端请求不完整
如果您需要向 API 客户端发送的信息是关于不完整或错误的请求,您将返回以下 HTTP 代码之一:
-
400 - 错误的请求:服务器在客户端的请求中检测到语法错误。
-
401 - 未经授权:请求需要用户身份验证。服务器发送 WWW-Authenticate 标头以指示所请求资源的身份验证类型和领域。
-
402 - 需要付款:这是为将来保留的。
-
403 - 禁止:禁止访问所请求的资源。客户端不应重复请求。
-
404 - 未找到:所请求的文档在服务器上不存在。
-
405 - 方法不允许:客户端使用的请求方法是不可接受的。服务器发送“允许”标头,说明可以接受哪些方法来访问所请求的资源。
-
408 - 请求超时:客户端未能在服务器使用的请求超时期内完成其请求。但是,客户端可以重新请求。
-
410 - 已消失:所请求的资源已永久从服务器中消失。
-
413 - 请求实体太大:服务器拒绝处理请求,因为其消息主体太大。服务器可以关闭连接以阻止客户端继续请求。
-
414 - 请求 URI 太长:服务器拒绝处理请求,因为指定的 URI 太长。
-
415 - 不支持的媒体类型:服务器拒绝处理请求,因为它不支持消息正文的格式。
服务器错误
在应用程序不幸需要通知 API 客户端存在问题时,您将返回以下 HTTP 代码之一:
-
500 - 内部服务器错误:服务器配置设置或外部程序导致错误。
-
501 - 未实现:服务器不支持满足请求所需的功能。
-
502 - 错误的网关:服务器遇到上游服务器或代理的无效响应。
-
503 - 服务不可用:服务暂时不可用。服务器可以发送
Retry-After
头来指示服务何时可能再次可用。 -
504 - 网关超时:网关或代理已超时。
编码实践
您的代码是应用程序的核心;因此,您希望以正确、清晰和高效的方式编写它。在本节中,我们将为您提供一些改进代码的提示。
处理字符串
行业标准之一是在应用程序的所有级别中使用 UTF-8 格式。如果您忽略了这个建议,您将在整个项目的生命周期中处理编码问题。在撰写本书时,PHP 不支持低级别的 Unicode,因此在处理字符串时需要小心,特别是在处理 UTF-8 时。以下建议仅适用于使用 UTF-8 的情况。
在 PHP 中,基本的字符串操作,如赋值或连接,在 UTF-8 中不需要任何特殊处理;在其他情况下,您可以使用核心函数来处理字符串。大多数情况下,这些函数都有一个对应的函数(以mb_*
为前缀)来处理 Unicode。例如,在 PHP 核心中,您可以找到substr()
和mb_substr()
函数。每当您操作 Unicode 字符串时,都必须使用多字节函数。想象一下,如果您需要获取 UTF-8 字符串的一部分;如果您使用substr()
而不是mb_substr()
,有很大的机会得到您不期望的结果。
单引号与双引号
单引号字符串不会被 PHP 解析,因此您的字符串中有什么并不重要,PHP 将原样返回字符串。在双引号字符串的情况下,它们会被 PHP 引擎解析,并且字符串中的任何变量都将被评估。对于双引号字符串,转义字符(例如 t 或 n)也将被评估。
在现实应用中,使用其中一种方法的性能差异可能会被忽略,但在高负载应用中,性能可能会有所不同。我们建议保持一致,如果需要变量和转义字符被评估,请只使用双引号。在其他情况下,请使用单引号。
空格与制表符
开发人员之间存在着使用空格和使用制表符来缩进他们的代码的战争。每种方法都有其自身的好处和不便,但 PHP FIG 建议使用四个空格。只使用空格可以避免与差异、补丁、历史和注释相关的问题。
正则表达式
在 PHP 中,您有两种选项来编写您的正则表达式:PCRE 和 POSIX 函数。主要建议使用 PCRE 函数(以preg_*
为前缀),因为在 PHP 5.3 中,POSIX 函数族已被弃用。
连接和对数据库的查询
在 PHP 中,您有多种连接到数据库的方式,但在所有这些方式中,连接的推荐方式是使用 PDO。使用 PDO 的好处之一是它具有标准接口,可以连接到多个不同的数据库,使您能够在不太麻烦的情况下更改数据存储。当您对数据库进行查询时,如果不想出现任何问题,请确保始终使用预处理语句。这样,您将避免大部分 SQL 注入攻击。
使用===运算符
PHP 是一种松散类型的编程语言,当您比较变量时,这种灵活性会带来一些注意事项。如果使用===
运算符,PHP 会确保您进行严格比较,避免错误的结果。请注意,===
比is_null()
和is_bool()
函数略快。
使用发布分支的工作
一旦我们的项目遵循语义版本控制,我们就可以开始在版本控制系统(例如 Git)中使用发布和发布分支。使用发布和发布分支可以让我们更好地计划和组织我们对代码的更改。
与发布版的工作基于语义版本控制,因为每个发布分支通常是从最新的主分支版本创建的(例如 v1.2.3)。
使用发布分支的主要好处如下:
-
帮助您遵循严格的方法将代码推送到生产环境
-
帮助您轻松计划和控制对代码的更改
-
尝试避免将不需要的代码拖入生产环境的常见问题
-
允许您阻止特殊分支(例如 dev 或 stage)以避免未经 pull 请求的提交
请注意,这只是一个建议;每个项目都是不同的,这种工作流程可能不适合您的项目。
快速示例
要在项目中使用发布版,您需要使用一个发布分支和另一个临时分支来对代码进行更改。对于以下示例,请想象我们的项目将主分支标记为 v1.2.3。
第一步是检查我们是否已经有一个发布分支,我们将在其上进行工作。如果不是这种情况,您需要从主分支创建一个新的发布分支:
-
首先,我们需要决定我们的下一个版本号;我们将使用从语义版本控制中学到的所有内容。
-
一旦我们知道我们的下一个版本号,我们将从主分支创建一个发布分支。下一个命令将向您展示如何获取最新的主分支并创建并推送一个新的发布分支:
**git checkout master
git fetch
git pull origin master
git checkout -b release/v1.3.0
git push origin release/v1.3.0**
- 在上述步骤之后,我们的存储库将拥有一个干净的发布分支,准备好使用。
此时,我们的发布分支已准备就绪。这意味着任何代码修改都将在从我们的发布分支创建的临时分支中进行:
- 假设我们需要向项目添加一个新功能,因此我们需要从发布分支创建一个临时分支:
**git checkout release/v1.3.0
git fetch
git pull origin release/v1.3.0
git checkout -b feature/my_new_feature**
- 一旦我们有了
feature/my_new_feature
,我们可以将所有更改提交到这个新分支。一旦所有更改都已提交并准备就绪,我们可以将feature/my_new_feature
与发布分支合并。
上述步骤可以重复任意次数,直到您为发布计划的所有任务完成为止。
一旦完成了所有发布任务并且所有更改都已经获得批准,您可以将发布分支与主分支合并。一旦完成与主分支的合并,请记得更新发布标签。
我们可以用以下提醒笔记总结我们的示例:
-
新的发布分支始终是从主分支创建的
-
临时分支始终是从发布分支创建的
-
尽量避免将其他临时分支与当前临时分支合并
-
尽量避免将非计划分支与发布分支合并
在上述工作流程中,我们建议使用以下分支前缀来了解与分支关联的更改类型:
-
release/*
: 这个前缀表示所有包含的更改将在将来的发布中部署,版本号相同 -
feature/*
: 这个前缀表示添加到分支的任何更改都是新功能 -
hotfix/*
: 这个前缀表示包含的更改是为了修复错误/问题而提交的
通过这种方式工作,将更难将不需要的代码推送到生产环境。请随意根据您的需求调整前述工作流程。
总结
在本章中,我们为您介绍了一些关于您项目中可以使用的常见最佳实践和约定的要点。它们都是建议,但它们使得项目与其他项目脱颖而出。
第十二章:云和 DevOps
我们不希望在没有谈论云和 DevOps 功能的情况下结束这本书。当云平台存在时,在家中拥有服务器不是一个好的解决方案;因此,在本章中,您将了解为什么应该为您的应用程序使用云以及哪个提供商最适合您的需求。此外,您还将学习如何使用自动化工具将您的应用程序部署到这些云平台中。
DevOps 的角色与云密切相关,因此我们将介绍这个主题以及 DevOps 的任务是什么。
什么是云?
解释我们所知的云的最快方法是说云是托管在互联网上的在线服务的交付,但我们也可以说云允许我们以非常简单的方式消耗数字资源。如今使用的一些常见云服务是磁盘存储、虚拟机或电视服务等。正如您所想象的,云的主要好处是我们无需在家中建立和维护这些基础设施。
作为开发人员,您会知道云对我们的应用程序是一个很好的方法。让我们看看一些优势。
可自动扩展和弹性
当您的应用程序在线时,不可能预测未来几个月甚至几天的流量是否会非常高。云允许我们拥有一个自动扩展的基础设施,以匹配我们应用程序的流量或消耗资源。如果您的流量更高,它可以增长;如果您的应用程序没有您希望的流量,它可以减少。
通常,当您想要调整服务器大小时,有三种选项。在下一张图片中,我们将以图形方式向您展示调整服务器大小的不同选项。黄线是您的应用程序可以处理的最大负载,蓝线是您站点的当前负载:
-
图片 1:在高峰流量发生时使用比您需要的更多的服务器,以避免流量问题。
-
图片 2:为正常流量使用足够的服务器。您应该知道在特定日期可能会出现问题。例如,如果您的应用是一个在线商店,可能会在像黑色星期五这样的日子出现问题。
-
图片 3:使用弹性云;根据高峰流量的增加或减少自动添加或移除服务器,以便您始终拥有所需的基础设施。
调整方式
降低管理工作量
如果您花费时间设置服务器并执行维护任务,那么您正在浪费可以用来改进应用程序的时间。云允许我们只关注我们的应用程序,因为它为我们提供了一种新的开发应用程序的方式,提供了预配置的资源,使我们能够开发应用程序而不必担心我们正在使用的基础设施。
此外,云通常为我们提供完整而有用的仪表板来管理机器,因此我们不再需要使用 SSH 控制台,使我们的任务更加轻松。它甚至为我们提供了更好的方法来管理我们的数据库和负载均衡器或证书。
更便宜
使用云比在家中拥有服务器更便宜。这些节省是因为以下原因:
-
您只需一直支付您所需的基础设施,因此当您的应用程序的流量增长时,您无需更改您的机器
-
IT 人员(如果您需要他们)将更加高效,因为他们只会专注于云无法帮助解决的问题
请注意,当您支付云服务器时,您不仅支付服务器;此服务器还包括存储、操作系统、虚拟化、物理空间、更新、冷却系统以及许多其他东西,如能源或数据中心运营。
快速增长
这一点与上一点密切相关。如果您的应用程序是新的,而且您不知道它是否会成功,那么购买物理服务器和所有相关事项将不是一个好主意,因为这样可以将您的应用程序上线。
使用云,您可以按月支付所需的服务器,如果应用程序不如预期,您可以降低计划甚至关闭它,这样您将花费更少的资金。
此外,在开始阶段节省资金将使您能够更快地增长,关注并将资金投入应用程序而不是花费在硬件上。
上市时间
如果您想测试新想法并将其上线,云服务将更快。这是云的主要优势,在互联网上非常宝贵。对于大公司来说,要像小公司一样快速发展是非常困难的,云允许它们以一种简单快速的方式在线包含变化,使其成为非常具有竞争力的优势。
选择您的云服务提供商
选择最佳的云服务提供商并不容易,但您可以根据应用程序需求选择最适合它的提供商。
考虑以下事项:
-
确保您的提供商了解您的需求:您的团队与云服务提供商之间的沟通至关重要。非常重要的是,您的提供商了解诸如每秒读/写次数、用户所在地、是否有并发用户、您的部署脚本如何工作,或者您的开发、暂存和生产环境是什么样的等事项。
-
我的数据在哪里:云服务器位于某个地方,因此了解它们的位置很重要,因为如果您存储客户数据,法律可能不允许您在某些国家存储数据。
-
安全性:如果您的应用程序不安全,您随时都面临风险,因此了解您的云服务提供商具有哪些保护系统(如防火墙或硬件隔离)是很重要的,以避免入侵,并了解他们是否提供 24 小时支持。
-
在迁移应用程序之前进行测试:您的云服务提供商可能允许您在迁移整个应用程序之前测试服务,因此使用此选项以检查服务器是否足够应对您的流量和资源。
亚马逊网络服务(AWS)
互联网巨头亚马逊.com 拥有自己的云。它提供网络托管以及许多其他服务来帮助公司。亚马逊网络服��(AWS)上最重要的功能是负载均衡器和将应用程序的某些任务(如处理数据或网络托管)发送到亚马逊的可能性。
总之,AWS 不仅仅是一个简单的云,它包括许多服务(超过 50 个)供网络专家和其他需要亚马逊提供的特定功能的人使用。
亚马逊根据使用时间为我们提供了一些不同的价格计划选项--按小时、按年,甚至 3 年的价格都是 AWS 的可能选择。
云上的一个重要事项是服务级别协议(SLA)。对于亚马逊来说,这包括每月 99.95%的正常运行时间保证。
AWS 上的定制工作方式类似于模板;换句话说,目前不可能对应用程序的 CPU、RAM 和空间进行全面配置;您应该在一些模板选项之间进行选择,因此如果您只需要更多的 RAM,您不能只升级 RAM,您应该选择一个可以升级 CPU 和硬盘的不同模板。
通常,云服务器位于世界各地的许多国家。亚马逊 EC2(AWS 的服务器)目前位于北美(16)、南美(3)、欧洲(7)、亚洲(14)。
值得注意的是,目前根据一般共识,AWS 在带宽和处理能力等方面给我们提供了最差的成本效益比。它仍然是市场上功能最完整的解决方案,但可能并不是最适合您的选择。
微软 Azure
Azure 是微软的操作系统,为我们提供了在云上执行和部署应用程序和服务的环境。它为我们提供了自定义环境和位于微软数据中心的服务器。
我们在 Azure 上存储的应用程序应该在 Windows Server 2008 R2 上运行,并且可以在.NET、PHP、C++、Ruby 或 Java 上开发。此外,Azure 还为我们提供了一些数据库机制,如 NoSQL、blobs、消息队列和 NTFS 驱动器来进行读/写磁盘操作。
Windows Azure 的主要优势如下:
-
降低应用程序的运营成本和配置
-
对客户需求变化的快速响应
-
可扩展性
Azure 为我们提供了不同的付款方式,可以按小时支付,也可以进行年度付款。Azure 的 SLA 与亚马逊相同,包括 99.95%的正常运行时间保证,以月为周期;定制也可以使用模板。
Azure 云服务器目前位于北美(9)、南美(1)、欧洲(6)、亚洲(9)和澳大利亚(2)。
总之,亚马逊和 Azure 非常相似;它们之间的主要区别是所使用的操作系统。如果您的应用程序是在.NET 上开发的,或者需要 Windows 服务器,Azure 是最佳选择。
Rackspace
Rackspace 不是最大的(如亚马逊或微软),但在谈论云服务时,它被认为是我们应该提到的一个。
当我们雇佣 Rackspace 时,我们是在支付使用服务的费用,例如当我们需要在特定时刻增加应用程序的容量。Rackspace 的服务器由他们管理,甚至可以只雇佣支持系统,将我们的服务器放在 Rackspace 之外。
Rackspace 为我们提供了支付少于一小时、年度甚至 3 年的选项。SLA 是 99.90%的正常运行时间保证,以月为周期,定制使用模板,与亚马逊和 Azure 一样。
服务器目前位于北美(3)、欧洲(1)、亚洲(1)和澳大利亚(1)。
总之,Rackspace 比亚马逊或 Azure 便宜,是一个非常好的开始使用云的解决方案。此外,它拥有非常好的分布式 DNS,并且他们是 OpenStack 的创造者,这是一种用于通过虚拟化实现云服务器的不同软件组件的开源堆栈。这提供了一个新的仪表板,附加服务与数据库、服务器监控、块存储和虚拟网络的创建。
DigitalOcean
DigitalOcean 是世界上第二大的托管公司。他们的计划是最便宜的,DigitalOcean 社区非常好 - 他们为开发人员提供论坛和许多关于管理服务器的教程。
可以选择按小时或按月支付。此外,SLA 是 99.99%的正常运行时间保证,甚至比亚马逊或 Azure 更好,定制使用模板,就像亚马逊、Azure 和 Rackspace 一样。
他们目前在北美有五个服务器,在欧洲有五个,在亚洲有一个。
DigitalOcean 对于专家来说是一个很好的解决方案,因为他们不管理服务器。服务器始终是 Linux,因此对于需要 Windows 的项目来说,这不是一个解决方案。此外,使用 DigitalOcean 的一个优势是,如果您的项目增长,可以轻松快速地扩展服务器。
Joyent
三星收购了 Joyent。这个云具有巨大的潜力。它是为了与亚马逊 EC2 竞争而创建的,并且有一些重要的客户,如 Twitter 和 LinkedIn。
Joyent 创建了 Node.js,并且他们拥有最好的容器技术;它是从 Solaris 继承并实现在他们自己的操作系统 SmartOS 上(一种为云设计的操作系统)。如果您寻求最佳性能并且不在乎价格,Joyent 是您的最佳选择。
您可以选择按小时、按年甚至每 3 年付款。此外,SLA 是 100%的正常运行时间保证,每 30 分钟循环一次,是最好的。定制使用模板,就像亚马逊、Azure、Rackspace 或 DigitalOcean 一样。
他们目前在北美有三台服务器,在欧洲有一台。
Rackspace 和 Joyent 拥有开源基础设施,因此可以下载并在自己的机器上使用。
谷歌计算引擎
谷歌计算引擎是一个完整的产品,包括基础设施和服务,允许我们在与谷歌相同的基础设施上执行带有 Linux 的虚拟机。
谷歌计算引擎仪表板无可挑剔。它干净且易于导航。此外,在部署和可伸缩过程中非常快速,以及此云中包含的工具使谷歌计算引擎成为分析和大数据的良好解决方案。
对于谷歌计算引擎,SLA 是 99.95%的正常运行时间保证,每月循环一次。它允许我们免费存储最多七个快照。
他们目前在北美有九台服务器,在欧洲有三台,在亚洲有六台。
将您的应用程序部署到云中
在整本书中,我们一直在使用容器;我们已经告诉过您它们对您的项目有多么有益。现在,是时候将您的应用程序部署到云中了。市场上有不同的提供商,我们已经为您提供了一些关于如何选择最佳提供商的提示。在本节中,我们将向您展示一些有趣的选项,让您在生产中编排和管理容器。
Docker Swarm
在整本书中,我们一直在使用 Docker 及其Docker 引擎。使用 Docker 引擎,我们能够启动和关闭我们应用程序中使用的容器。您可以想象,您可以在生产服务器上安装 Docker 引擎,并像我们的开发环境一样使用它,但您认为这种方法是容错的吗?显然答案是否定的。您可以尝试在不同服务器上拥有多个 Docker 引擎,但设置和维护将会很困难。幸运的是,Docker 创建了 Docker Swarm;这个软件为您提供了本地集群功能,并将您的一组 Docker 引擎转换为单个虚拟 Docker 引擎。就像在您的开发机器上工作一样;Swarm 将为您处理所有困难的事情,您只需要关注您的应用程序。
由于我们希望您全面了解 Docker Swarm,以下是此工具的主要功能:
-
与 Docker 工具兼容:Docker Swarm 使用并提供标准的 Docker API,因此任何已经使用 Docker API 的工具都可以使用 Docker Swarm 来透明地扩展到多个主机。
-
高可伸缩性和性能:与所有 Docker 软件一样,Swarm 已经准备好投入生产,并且经过测试,可以扩展到一千(1,000)个节点和五万(50,000)个容器。这些测试结果表明,您可以在节点集群中实现这些高部署数字,而不会降低性能。
-
故障转移和高可用性:Docker Swarm 已准备好管理故障转移并为您提供高可用性。您可以创建多个 Swarm 主机并指定主机故障时的领导者选举策略。在撰写本书时,有一个实验性支持从失败节点重新调度容器的支持。
-
灵活的容器调度:Swarm 配备了内置调度程序,负责最大化您基础设施的性能和资源利用率。
-
可插拔的调度器和节点发现:如果 Swarm 附带的内置调度器不符合您的要求,您可以插入外部调度器,例如Apache Mesos或Kubernetes。为了满足应用程序的所有自动发现要求,您可以在 Swarm 中选择不同的可用方法:托管的发现服务、静态文件、Consul 或 Zookeeper。
安装 Docker Swarm
从 Docker 1.12 及更高版本开始,Swarm 模式已经内置,因此安装非常容易。您只需要按照我们在第二章中展示的步骤进行操作,开发环境,但是不是在您的开发机器上执行,而是在您的生产服务器上执行。我们建议在生产节点上使用 Linux/Unix,因此我们在这里描述的所有步骤都是针对 Linux/Unix 系统的。
我们将构建一个 Swarm 集群,因此在继续阅读之前,请确保您已准备好以下要求:
-
至少三台安装了 Docker Engine 1.12(或更高版本)的主机
-
其中一台机器将是管理机器,因此确保您拥有所有主机的 IP 地址
-
打开主机之间的端口
-
TCP 端口 2377:此端口用于我们的集群管理消息
-
TCP 和 UDP 端口 7946:这些端口用于节点之间的通信
-
TCP 和 UDP 端口 4789:这些端口用于覆盖网络流量
总之,我们的生产环境将由以下主机组成:
-
管理节点:这个节点负责所有编排和调度的繁重工作。我们将称这台机器为
manager_01
。 -
两个工作节点:这些是我们将用来托管容器的虚拟节点。我们将这些主机称为
worker_01
和worker_02
。
正如我们之前提到的,您需要知道不同 Docker 主机的 IP 地址,其中最重要的是管理机器的 IP 地址。工作节点将连接到此 IP 地址以了解他们需要做什么。例如,假设我们的管理主机的 IP 是192.168.99.100
。
在这一点上,您已经准备好设置 Swarm 集群。首先确保所有节点上都运行着 Docker 引擎。一旦您检查了主机上的引擎是否正在运行,您需要通过 SSH 或控制台进入您的manager_01
节点。在您的manager_01
节点中,运行以下命令启动 Swarm:
**docker swarm init --advertise-addr 192.168.99.100**
上述命令将初始化 Swarm 并将当前节点设为管理节点。它还会给出您需要运行的命令,以添加更多的管理节点或将工作节点添加到集群中。您的init
命令的输出应类似于以下输出:
**Swarm initialized: current node (b33ldnwlqda735xirme3rmq7t) is now a manager.**
一旦您初始化了 Swarm,您需要获取一个令牌,用于加入其他机器到集群中。要获取此令牌,您只需要在任何时候运行以下命令:
**docker swarm join-token worker**
假设您需要向 Swarm 添加一个新的worker
;在这种情况下,您只需要从上述命令中获取令牌并运行以下命令:
**docker swarm join \
--token SWMTKN-1-12m5gw74y2eo8llzj9oi2b2ij3z3fwwjg830svx3go5pig1stl-ev5x8obsajn4yhzy774lvhhfu \
192.168.99.100:2377**
上述命令将向 Swarm 添加一个worker
,如果您需要向此集群添加另一个管理节点,可以运行docker swarm join-token manager
并按照说明进行操作。
理论上,init 命令启动了 Swarm;如果您想检查正确的集群初始化,只需执行以下命令:
**docker info**
上述info
命令将给出类似于以下输出的一些输出;请注意,我们删除了一些信息以适应本书:
**Containers: 44
Running: 0
Paused: 0
Stopped: 44
Images: 171
Server Version: 1.12.5
Swarm: active
NodeID: b33ldnwlqda735xirme3rmq7t
Is Manager: true
ClusterID: 705ic3oomoadlhocvcluszfwz
Managers: 1
Nodes: 1
Orchestration:
Task History Retention Limit: 5
Node Address: 192.168.99.100**
如您所见,我们的 Swarm 已经激活并准备就绪;如果您想获取有关节点的更多信息,只需执行以下命令:
**docker node ls**
上述info
命令将给出类似于以下输出的输出。
**ID HOSTNAME STATUS AVAILABILITY MANAGER STATUS
b33ldnwlqda... * moby Ready Active Leader**
在这一点上,您有一个主机,即您的管理节点,但您没有任何可以启动容器的工作节点。让我们向我们的集群添加一些工作节点。
使用 Swarm 向我们的集群添加工作节点非常容易——您只需要通过 SSH 或控制台访问主机并运行在管理节点上由 Swarm init 输出的命令:
**docker swarm join \
--token SWMTKN-1-12m5gw74y2eo8llzj9oi2b2ij3z3fwwjg830svx3go5pig1stl-ev5x8obsajn4yhzy774lvhhfu \
192.168.99.100:2377**
如果一切顺利,上述命令将给出以下输出,指示当前节点已添加到集群中:
**This node joined a swarm as a worker.**
假设您没有保存加入令牌;您可以再次从管理节点获取它。您只需要登录到管理节点并执行以下命令:
**docker swarm join-token worker**
上述命令将为您提供您需要在工作节点中再次运行的命令。
如您所见,向您的集群添加节点非常容易;添加所有剩余的工作节点。要检查集群节点的状态,您可以在管理节点中执行以下命令:
**docker node ls**
向我们的 Swarm 添加服务
此时,您的生产环境已准备好进行部署;让我们部署一些内容来测试新环境。我们将部署一个非常简单的镜像,它会对google.com
进行 ping。一旦您感到舒适地将服务部署到 Swarm 中,您可以尝试部署我们的示例应用。
打开与管理节点的连接或登录,并运行以下命令:
**docker service create --replicas 1 --name pingtest alpine ping google.com**
上述命令在集群中创建了一个新服务,使用--name
标志为我们的服务分配了一个漂亮的名称,在本例中是pingtest
。--replicas
参数指示我们希望为我们的服务创建的实例数量;在我们的例子中,我们只指定了一个实例。使用alpine ping google.com
,我们告诉我们的 Swarm 我们想要使用哪个镜像(alpine
)以及我们想要在该镜像中执行的命令(ping google.com
)。
如您所见,部署新的测试服务非常容易。如果您想查看在您的集群中运行哪些服务,请登录到您的管理节点并执行docker service ls
命令,输出将类似于以下内容:
**ID NAME REPLICAS IMAGE COMMAND
3ojsox6wioz4 pingtest 1/1 alpine ping google.com**
一旦您的服务在生产环境中运行,您将需要更多关于服务的信息。使用 Docker 非常容易,您只需要在管理节点中执行以下命令:
**docker service inspect --pretty pingtest**
您的输出可能类似于以下内容:
**ID: 3ojsox6wioz45d37xkcgn1xwv
Name: pingtest
Mode: Replicated
Replicas: 1
Placement:
UpdateConfig:
Parallelism: 1
On failure: pause
ContainerSpec:
Image: alpine
Args: ping google.com
Resources:**
如果您希望输出以 JSON 格式返回,您只需要从命令中删除--pretty
参数:
**docker service inspect pingtest**
上述命令的输出将类似于以下内容:
**[
{
"ID": "3ojsox6wioz45d37xkcgn1xwv",
"Version": {
"Index": 23
},
"CreatedAt": "2017-01-07T12:53:18.132921602Z",
"UpdatedAt": "2017-01-07T12:53:18.132921602Z",
"Spec": {
"Name": "pingtest",
"TaskTemplate": {
"ContainerSpec": {
"Image": "alpine",
"Args": [
"ping",
"google.com"
]
},
"Resources": {
"Limits": {},
"Reservations": {}
},
"RestartPolicy": {
"Condition": "any",
"MaxAttempts": 0
},
"Placement": {}
},
"Mode": {
"Replicated": {
"Replicas": 1
}
},
"UpdateConfig": {
"Parallelism": 1,
"FailureAction": "pause"
},
"EndpointSpec": {
"Mode": "vip"
}
},
"Endpoint": {
"Spec": {}
},
"UpdateStatus": {
"StartedAt": "0001-01-01T00:00:00Z",
"CompletedAt": "0001-01-01T00:00:00Z"
}
}
]**
如您所见,JSON 格式包含更多信息;请随意使用适合您的选项。如果您需要知道您的服务在哪里运行,您可以在管理节点中执行docker service ps pingtest
命令,就像以往一样。
在 Swarm 中扩展您的服务
我们向您展示了在 Swarm 集群中创建新服务有多么容易,现在是时候让您知道如何扩展您的服务了。转到您的管理节点并运行以下命令:
**docker service scale pingtest=10**
上述命令将创建(或销毁)所需数量的容器,以调整到您的需求;在我们的例子中,我们希望我们的pingtest
服务有 10 个容器。您可以使用docker service ps pingtest
命令来检查命令的正确执行,将给出以下输出:
从上述输出中,您可以检查服务在 10 个容器中运行,还可以检查它在哪个节点上运行。在我们的例子中,它们都在同一台主机上运行,因为我们只向我们的集群添加了一个节点。
您现在知道如何创建您的 Swarm 集群以及如何轻松启动新服务,现在是时候向您展示如何停止任何正在运行的服务了。
连接到您的管理节点并执行以下命令:
**docker service rm pingtest**
上述命令将从 Swarm 集群中删除pingtest
服务。与往常一样,您可以使用docker service inspect pingtest
命令或通过检查运行的容器使用docker ps
命令来检查服务是否已停止。
在本章的这一点上,你将能够创建一个 Swarm 集群并启动任何服务;试一试,将我们的示例应用程序迁移到 Swarm。
正如你所想象的,我们喜欢 Docker 如何简化开发周期以及部署的简单性,但是还有其他项目可以在生产环境中使用。让我们看看这些天最常用的项目,这样你就可以选择哪个选项更适合你的项目。
Apache Mesos 和 DC/OS
Apache Mesos 将所有计算资源从机器中抽象出来,实现了容错和弹性的分布式系统。创建 Apache Mesos 分布式系统可能会很复杂,因此 Mesosphere 创建了 DC/OS,这是一个建立在 Apache Mesos 之上的操作系统。由于 DC/OS,你可以拥有 Mesos 的所有功能,但安装或管理起来更容易。
一些 Apache Mesos 和当然 DC/OS 中可用的功能如下:
-
线性可扩展性: 你可以扩展到 10,000 个主机而不会出现任何问题
-
高可用性: Mesos 使用 Zookeeper 提供容错的复制主节点和代理
-
容器支持: 原生支持使用 Docker 和 AppC 镜像启动容器
-
可插拔隔离: 支持 CPU、内存、磁盘、端口、GPU 的隔离和自定义隔离模块
-
API 和 Web UI: 内置的 API 和 Web UI 允许你轻松管理 Mesos 的任何方面
-
跨平台: 你可以在 Linux、OSX 甚至 Windows 上运行 Mesos
正如你所看到的,Apache Mesos 和 DC/OS 是 Docker 或 Kubernetes 的一个有趣的替代方案。这些项目统一了所有节点之间分隔的所有资源,并将它们转换为一个分布式系统。这让你感觉你只是在管理一台单一的机器。
Kubernetes
Kubernetes 是用于自动化部署、扩展和管理容器的主流开源系统之一。它由谷歌创建,拥有活跃的社区。
它是一个完整的编排服务,拥有各种功能,我们可以突出以下功能:
-
自愈: 这是一个有趣的功能,可以在节点死机时重新启动失败的容器,并替换和重新安排容器。它负责终止不健康的容器,并避免广告未准备好的容器。
-
服务发现和负载均衡: 这个功能允许你忘记创建和管理自己的发现服务;你可以使用开箱即用的功能。Kubernetes 还为每个容器分配了自己的 IP 地址和一组容器的 DSN 名称。由于所有这些,你可以轻松进行负载均衡。
-
自动部署和回滚: 没有比部署和回滚更重要的事情了。Kubernetes 可以为你管理这些操作;它将监视你的应用程序,以确保在进行部署/回滚时它仍然平稳运行。
-
水平扩展: 你可以使用单个命令扩展或缩小你的应用程序,使用用户界面甚至定义一些规则来自动执行。
-
自动装箱: Kubernetes 负责根据容器的资源需求和其他约束条件来放置容器。决策是通过尝试最大化可用性来做出的。
正如你所看到的,Kubernetes 在大型项目中具备大部分所需的功能。因此,它是最常用的容器编排系统之一。我们建议你对这个项目进行更多的调查。你可以在官方页面上找到你需要的所有信息。如果你有一个在官方文档中找不到答案的具体问题,这个项目背后的大社区可以帮助你。
部署到 Joyent Triton
早些时候,我们向您展示了如何构建 Swarm 集群。这是一种管理基础设施的有趣方式,但是如果您需要云的全部功能,但又不想处理编排的麻烦,会发生什么?在以下示例中,我们假设您没有预算或时间来使用您选择的编排软件设置云服务器。
在本章的开头,我们谈到了主要的云提供商,其中包括 Joyent。这家公司有一个名为 Triton 的托管解决方案;您可以使用此解决方案通过单击或 API 调用创建 VM 或容器。
如果您想使用他们的托管服务,第一件事就是在他们的www.joyent.com
页面上创建一个帐户。一旦您的帐户准备好,您将完全访问他们的环境。
一旦您的帐户准备好,就向您的帐户添加一个 SSH 密钥。此密钥将用于对您的容器和 Joyent 的 API 进行身份验证。如果您没有要使用的 SSH 密钥,可以手动创建一个。创建 SSH 密钥非常容易,例如,在 Mac OS 中,您只需要执行以下命令:
**ssh-keygen -t rsa**
此命令将询问您有关密钥存储位置或要用于保护密钥的密码短语的一些问题。一旦您回答了所有问题,通常密钥将存储在~/.ssh/id_rsa.pub
文件中。您只需要将此文件的内容复制到您的 Joyent 帐户中。如果您使用 Linux,创建 SSH 密钥的过程非常类似。
一旦您的帐户准备好,您就可以开始创建容器;您可以使用他们的 Web UI 来完成,但在我们的情况下,我们将向您展示如何从终端完成。
我们使用 Docker 和 Docker API 在 Triton 中实现的 Joyent,所以您将看到部署有多么容易。您需要做的第一件事是安装 Triton CLI 工具;这个应用程序是基于 Node.js 构建的,所以您需要在开发机器上安装 Node.js (nodejs.org
)。一旦您安装了 node,您只需要执行以下命令来安装 Triton CLI:
**sudo npm install -g triton**
上述命令将在您的机器上将 Triton 安装为全局应用程序。一旦 Triton 在您的计算机上可用,您需要配置该工具;输入以下命令并回答所有问题:
**triton profile create**
此时,您的 Triton CLI 工具将准备就绪。现在,是时候配置 Docker 以使用 Triton 了。打开您的终端并执行以下命令:
**eval $(triton env)**
上述命令将配置您的本地 Docker 以使用 Triton。从现在开始,您所有的 Docker 命令都将发送到 Triton。让我们尝试部署我们的示例应用程序--转到您的docker-compose.yml
文件的位置并执行下一个命令:
**docker-compose up -d**
上述命令将像往常一样工作,但是,不再使用我们的开发机引擎,而是在云中启动我们的容器。Triton 的一个优势是他们为每个容器分配至少一个 IP 地址,因此如果您需要获取特定容器的 IP 地址,只需执行docker ps
以获取所有正在运行的容器(在 Triton 中)及其名称。一旦您获得容器的名称,只需执行以下命令即可从 Triton CLI 获取 IP 地址:
**docker inspect --format '{{ .NetworkSettings.IPAddress }}' container_name**
上述命令将给出您选择的容器的 IP 地址。另一种获取 IP 的方法是从 Web UI 获取。
提示
docker-compose stop 将终止部署到云中的所有容器。
现在,您可以使用本书中学到的一切,并且不会遇到太多问题就可以部署到 Joyent Triton 云中。这可能是目前市场上部署 Docker 容器最简单的方法。使用 Joyent Triton 就像在本地工作一样。
请注意,这不是你在生产环境中部署 Docker 的唯一选择;我们只展示了一种简单易行的方法。你可以尝试其他选项,比如 CoreOS 和 Mesosphere(DC/OS)等。
什么是 DevOps?
DevOps 是一套强调开发和运维(IT)之间协作和沟通的实践方法。其主要目标是建立一个快速、频繁和更可靠的软件发布文化。为了实现这一目标,DevOps 通常会尽可能地自动化。如果你的项目(或公司)发展壮大,你将会在某个时候采用一些 DevOps 原则来确保你的应用程序的未来。
在你的组织中采用这种文化的一些技术好处可能如下:
-
旨在最大化持续集成和持续交付的使用
-
减少需要修复的问题的复杂性
-
减少故障数量
-
提供更快的问题解决
DevOps 的主要支柱是建立在你的应用程序开发中涉及的所有部分之间的沟通文化,特别是在开发和运维人员之间的沟通。你的组织内部的沟通不足可能会导致整个项目的关闭,无论你的应用程序有多么好或令人惊叹。
一旦你的组织在软件开发中涉及的所有部分之间建立了良好的沟通渠道,你就可以分析并实施 DevOps 文化的下一个支柱——自动化。为了创建一个可靠的系统,你需要投资于重复手动任务和流程的自动化;这就是持续集成和持续交付发挥作用的地方。创建你的 CI/CD 管道将帮助你自动化重复的任务,如单元测试或部署,提高应用程序的整体质量。它甚至会帮助你节省一些日常任务的时间。想象一下,手动将你的应用程序部署到生产环境平均每次需要 8 分钟;如果你至少每天部署一次,你将在一整年中浪费超过 30 小时的时间。
DevOps 关乎你的应用程序以及围绕开发和部署的流程,其核心原则如下:
-
敏捷软件开发
-
持续集成
-
持续交付管道
-
自动化和持续测试
-
积极监控
-
改善沟通和协作
正如你所看到的,DevOps 文化不是你可以在你的组织中在几个小时内实施的东西,这是一个长期的过程,你需要分析你的组织中的开发流程以及你需要进行的必要变化,以找到更灵活和敏捷的方式。我们在本书中涵盖了大部分核心原则;现在轮到你来填补空白,并在你的组织中实施 DevOps 文化了。
总结
在本章中,我们讨论了云是什么,以及选择托管提供商所需了解的内容。我们还告诉了你在云中编排应用程序的不同选项。现在,轮到你分析并选择最适合你项目的最佳选项了。