一、奠定基础
领域驱动设计(DDD)已经存在了近二十年。在这段时间里,人们对它的兴趣急剧增加,因为它提供了清晰的指导方针、战术策略和方法来解决在任何行业开发应用时可能遇到的问题,尤其是复杂的应用。它在现实世界中非常实用,并为过去缺乏任何标准或实践的情况提供了解决方案,这些标准或实践建议如何最好地着手解决底层业务的特定领域问题…这种问题您在日常编程中不会看到,如果不是在您的业务运营以实现目标(可能是为了盈利,但可能是为了服务客户、支持用户、销售产品、跟踪指标等)的领域的上下文中,您甚至不会编写。).
这本书是如何设计的
在研究这本书的两个中心话题中的任何一个时,都有很多材料要涵盖。Laravel 和域驱动设计都有各自的博客文章、白皮书、书籍、教程,当然还有实际的例子。我们将回顾 Laravel 的一些基础知识,但是我强烈建议你在网上找到一些介绍性的教程,这样你就可以感受一下框架的组件是如何组合在一起的。Laracasts.com 是一个很好的资源。我强烈建议至少看看开头几集;或者,如果你喜欢阅读而不是看/听,你可以快速“搜索”(那是一个“谷歌搜索”)拉勒维尔教程的介绍。
然而,它是你熟悉框架的水平,你将需要应用一个领域驱动的 Laravel 应用是可以接受的。正是基于这些基础信息,我们将在本书的后面开发一个实际的、真实世界的例子,这个例子将建立在一个受控的环境中,使用 DDD 作为我们决策的基础。在此过程中,我将给出一些实用的建议和潜在的模式,供您在尝试实现域驱动的 Laravel 应用时参考。我希望你会发现这些材料在现实生活环境和团队中既有用又容易理解。
我并不期望你对领域驱动设计有全面的了解(或者以前听说过)。我在本书中用了大量的篇幅来描述和定义 DDD,并根据其在开发 web 应用中的应用来阐述这些概念。如果你已经熟悉了 DDD 的概念,那太好了!这将使学习曲线变平一点,但这不是必需的。我将描述一些必须对 DDD 的原始版本进行的修改,以作为开发现代 web 应用的实用方法(使用 Laravel 作为实现的手段,核心焦点是设计、建模和细化领域层。
我将在整本书中向您展示各种最佳实践,这些实践要么是关于软件开发的广泛接受的标准,要么是我自己的实践和捷径,这些实践和捷径花费了我作为一名专业 web 开发人员十年左右的时间,以及两倍多的时间来研究好奇心激发的主题(借助于我迄今为止阅读的大约 150 本 IT 和软件工程书籍)来辨别这些信息。通常,我会用代码演示这些概念,这样你就可以清楚地理解它们是什么,也可以从坚实的原则中获得一些背景知识。我将提供一些例子,这些例子可能是一堆你可能不记得的随机建议,而且几乎肯定不会在你自己的项目中使用。
这一章有一节介绍了交易的所有基本工具,一些基本概念和重要术语的定义,这些概念和定义与演示和例子相关,用来支持我提出的所有理论。然而,在更高的层面上,我想让你从本章学到的是对构造(双关语!).除了给你一些关于 web 开发的基础知识,使你能够跟随我们在本书后面的理论例子,我的目的还在于向你灌输学习领域驱动的设计原则的愿望,并激发你学习 Laravel 的高级用法和定制的兴趣,使它像你的应用所需要的那样灵活。
我将在这一章中稍微跳一下,让你对我们在这里试图学习的东西有一个很好的了解:什么是 DDD,它与我们的软件质量有什么关系?为了回答这个问题,我将向您介绍一些重要的概念、模式和实践,您将需要这些来成功地在软件中建模真实世界的领域。
领域驱动的什么?
DDD 本身是使用各种最佳实践和可靠的设计模式构建的,它起源于极限编程(XP)和敏捷开发。我将向您介绍 DDD 的一些更基本的方面,包括如果由于开发人员的无知或管理不善的开发工作而忽略了这些最佳实践和可靠的模式,那么软件恐怖就会变成现实。
我能想到的描述 DDD 的最简单的方式是:它是一系列实际的和有用的概念,过程, 和技术,通过关注底层业务规则的核心方面来帮助以系统化和结构化的方式对复杂软件系统建模,以便制造一个领域模型,该领域模型根据软件来真实地描述和表示该业务,然后让来自与各个领域专家的反复讨论和小组会议的知识和见解来指导软件的开发和构造,该软件最终被构建来服务于该业务的客户或前端用户。 DDD 基本上起源于软件业的一个空白,这个空白是关于如何正确地设计和开发最适合企业需求的软件。没有神奇的手册给出任何类型的标准或基本方法来构建专注于应用领域(以领域为中心)的应用,也没有任何公开发表的方法来学习业务核心流程的细节,以构建代表它的软件。
程序员在我们的代码工具箱中拥有设计模式已经很多年了,尽管直到 1994 年四人帮出版了设计模式书籍的圣杯设计模式:可重用的面向对象软件的元素,它们才被记录下来。设计模式是一套重要的、核心的、实用的、可重复的解决方案,用于解决您在构建计算机程序时可能会遇到的常见问题。它们是经过深思熟虑和全面测试的方法,可以解决几乎任何程序、任何编程语言中可能遇到的最常见的问题。一个这样的模式,著名的策略模式,当你需要在运行时通过将改变封装到行为族中来给对象提供额外的行为时,是非常有用的。另一个是适配器模式,用于集成两个不同的接口(比如那些运行在不同系统上或者完全用不同编程语言编程的接口)。如果您需要您的对象具有动态行为,可以在运行时添加或删除,并遵循类似的套件,允许对所述对象组进行聚合计算,您可以使用装饰模式。
我们所需要的正是开发解决方案的同样东西——就代码而言——为了响应软件开发需求的增长而出现的不断增长和大量的业务问题。换句话说,我们需要某种方法来开发应用的域层,以便我们可以用软件来表达任何这样的域(业务)模型。业务问题的本质是它们往往非常具体,并且它们各自的解决方案是多年来设计、开发、测试和提炼业务流程的产物,这些流程对于每个业务或行业都是独一无二的。因此,过去和现在都很难建立任何类型的标准或最佳实践来促进任何软件项目的最关键方面的开发:领域层。相比之下,有大量的参考资料、工具包和框架来帮助设计其他层(这些可能确实是与交谈,了解领域层),但是他们没有封装它。相反,他们包围了它。基础设施层基本上是应用层和领域模型之间的纽带。它方便了所有直接操作、管理和处理域对象的移动部分。
我们需要的是如何在我们工作的公司中寻找真实来源的策略,以便在软件中正确地建模,以及帮助我们以领域驱动的方式实现该模型的工具。业务信息并不总是直接或容易获得的,尤其是在跨多个组件的复杂业务流程的上下文中(至少我们希望它们被分成一些组件结构,但这并不总是现实)。如果应用中的代码被多年来的大量开发人员“践踏”或殴打,迫使其行为以非预期或设计的方式弯曲,实现领域驱动的方法将变得更加困难。但是仍然有可能避免整个应用的重写(这几乎总是一个糟糕的想法)。我们将在本书的后面探索如何做到这一点,但简单的答案是使用一个反腐败层来分割应用的部分,然后不断用较小的分割代码替换遗留代码,直到该层吸收旧代码,直到没有遗留代码存在。
体系结构
系统的架构修饰了系统领域模型的完整结构,包括领域对象、模块、有界上下文以及它们之间的各种交互。架构是应用中最重要的事情之一,因为它是软件中的基础结构,并且充当应用其余部分的“支撑梁”。
现实世界中往往会发生的是,这些业务流程和模型是在没有最佳实践或适当结构的情况下构建的,并且在生产中使用,因为它们“工作”不要误解我的意思,开发人员利用我们现有的东西,通常可以“黑”出一些确实“有用”的东西。然而,当我们在对过程本身进行了改进之后忽略了重构代码,或者未能对我们可能已经澄清的任何不清楚或模糊的定义进行微调,或者未能反映对业务模型以及这些洞察如何影响业务运营及其软件的洞察时,我们很可能会走向一个“泥巴大球”(或者我称之为 sh 的大球…pottå to)。
一个大泥球是一个:
“随意构建,蔓延,草率,胶带和打包线,意大利面条代码丛林。这些系统显示出不受控制的增长和重复的权宜修复的明显迹象…“
——布莱恩·福特
通常情况下,这些泥巴球是作为整体架构构建的,缺乏在“平台级”(物理层)上完全分离关注点的概念。整体架构是独立的,其中包含了应用的所有关注点(基础设施、数据库、应用级和表示关注点)。我们将在本章的后面讨论软件系统的架构层。
与单片应用相对的是微服务。微服务是分布在各种不同组件上的微小应用,这些组件共同构成了一个完整的可用系统。微服务架构中涉及的设置比简单地使用目录名要生动得多(这实际上是将各种结构物理地分成相关的组)。组件本身通常存在于不同的平台上,通常在云中的不同机器上,并且实现各种策略,旨在促进它们的使用,并实现它们自己和客户机之间的通信(调用代码)。
输入 laravel
DDD 的核心特征之一是它是一种不可知的系统架构设计方法。这意味着它不假设您正在使用什么框架、决定使用什么数据库,或者您是否使用数据持久性(如果您是一名 web 开发人员,当然,您很可能会这样做)。它更像是一种设计可伸缩企业系统的通用方法。那么,为什么我会建议不仅仅是一个框架的概念,而是一个与 DDD 提供的概念和策略相结合的特定的风格?
这个问题可以用一句日益证明正确的老话来回答:需要是发明之母。我注意到现实世界需要一套如何着手开发复杂 web 系统的指导方针,以及如何在现实世界场景中实现领域驱动应用的一些策略,而不需要重新发明轮子,同时使用 web 开发行业中一些更受欢迎的工具,如 Laravel 和口才——允许他们做他们最擅长的事情,以便我们可以专注于对业务本身进行建模,并构建一个丰富的领域层*,一个反映它被建立来管理的业务的需求和要求的领域层。*我们将使用 DDD 附带的工具和概念来完成所有这些工作,并针对 web 开发项目进行调整,同时在 Laravel 应用中实现这些概念。
直到最近,这两个概念(领域驱动的设计和框架)才足够接近,从而实际上产生了足够的吸引力,这表明使用领域驱动的方法来开发基于 web 和 Internet 的应用是有用的。最后,我意识到 Laravel 可以作为一种媒介来构建一个领域驱动的设计。
然而,由于 DDD 的创建方式以及 web 应用的基本结构,它与使用任何类型的框架的想法都不太协调。我们将回顾许多与 DDD 准则相关的模糊区域的例子。我们几乎需要一种定制的 DDD 实现,以便能够在我们需要的级别上使用它来构建 web 应用,使用它作为域设计的主干。如果我们考虑到在一个 web 开发环境中工作与一个系统中的环境边界有关,这个系统需要直接地并且主要通过网络操作。如果我们看看 DDD 的技术战略支柱中包含的知识类别,很明显它们可以与 Laravel 应用共存(例如,仓库、dto、工厂、工作等)。)…我们实际上可以看到,DDD 建议的大多数方法与拉勒韦尔的组件和内部工作方式非常吻合。在这方面,DDD 非常适合打造一个网络系统或互联网应用。
使用 Laravel 框架实现领域驱动设计的想法对我来说非常可行。如果我能改变策略和指导方针中的一些规则,我就能让这两种技术很好地协同工作。正因为如此,我会马上说,这不是 DDD 及其所有不同方面、模式、方法和指导方针的真正实现。DDD 需要大量的前期工作,但是当你把事情安排妥当的时候,你会得到很大的回报。话虽如此,我意识到这并不总是可取的,尤其是如果你是一家初创公司或正在为一家初创公司工作。成本可以成就一家创业公司,也可以毁掉一家创业公司,而且不可能总是分配如此大量的资源、时间和金钱来实施所有的工具、程序和架构结构,这些工具、程序和架构结构不仅是代码(如果您已经在代码库中工作)而且是业务核心功能的深度探索性分析的产物。
所以,在决定使用 DDD 的指导方针构建应用时,你应该非常小心:大多数领域都不够复杂,不需要 DDD 试图简洁地管理的复杂程度。另一方面,Laravel 提供了一种小范围的方法来从业务模型中创建某个领域的实现。我写这本书的兴趣在于在一个真实的项目环境中结合使用这两者。然而,只有在仔细检查了领域模型的需求以及底层业务模型作为软件的复杂性之后,才能决定采用基于 DDD 的设计。
选择性内容
那么,我的目标是为您提供 DDD 的主要要素、工具和策略,这样您就可以通过在自己的项目中实施它来获得真正的价值。这是可能的,因为我们最初放弃了传统 DDD 中常见的大量信息和开销——让我们专注于快速启动所需的核心方面和策略——并在本书稍后深入研究 DDD 和拉勒维尔时回到这些主题。
我还必须提到,当您试图实现一个半生不熟的 DDD 实现时,这有点冒险。如果你开始应用 DDD 的所有技术和技术模式,不管你愿不愿意,你将会在不完全知道它们做什么的情况下结束构建,或者更糟的是,将会在不正确的上下文中应用特定的技术或模式,最终迫使一个大的重构或完全重写。这样做的原因是因为 DDD 的纯粹的大小;这门学科本身有如此多的概念和想法,以至于很容易误解或混淆定义。然而,只要你以领域驱动的方式进行,也就是说,通过让你的业务需求和无处不在的语言来引导开发,这种情况发生的可能性是最小的。
避免陷阱
我要避免这种灾难的方法是通过讨论你需要的基础知识的坚实基础,以便理解为什么事情以某种方式完成,或者为什么最好在特定的环境中实现一些架构组件。通过反复灌输一种经过充分研究和提炼的通用语言的重要性,以及一些关于如何正确构建一种语言以及如何开始使用它作为构建实际应用的基础的方法,我完全有信心你会对领域驱动技术有一个全面的了解,并且不会误用或滥用它们。相信我,DDD 的糟糕实现有时等同于根本没有应用,因为由于以下一个或多个原因,你最终不得不重写整个该死的东西:
-
由于缺乏任何类型的“真实来源”来解释给定的业务术语的真实含义,或其应用的上下文,业务术语在应用中被歪曲和混淆。
-
最初创建系统(或其流程)时没有领域专家参与,因此概念要么应用不正确,要么根本没有应用——当向不正确的业务模型添加更复杂的逻辑时,经常会导致领域层(以及接触 it 的事物)内设计问题的连锁多米诺效应。
-
同样由于缺乏与领域专家的交流,许多关于某些业务流程做什么的假设被错误地实现,就像开发人员认为他们应该做什么一样。
-
当最初规划领域模型时,开发人员选择而不是来投资于知识发现或精化阶段,结果,构建了解决错误问题的组件和模块。
我打算用 DDD 在现实世界中的实际应用来武装你。我会给你一些我在这个行业从业 10 年后自己培养出来的建议,我会教你什么是“最佳实践”以及你遵循它们会得到什么回报。我们将学习 DDD 的基础,以及如何以实际可行的方式实现一个领域驱动的模型。然而,这不是一条容易走的路,迷失在 DDD 主题包含的大量信息和提炼的知识中并不困难。
着手领域驱动的设计
我们将着手以一种有组织的、深思熟虑的方式构建我们的领域模型,当我们对我们试图建模的业务操作了解得越来越多时,这将为我们提供扩展和改进的基础结构和形式。此外,我们将开发一种无处不在的语言,它是整个公司一致同意的业务术语的核心定义。然而,构建良好的无处不在的语言并不便宜。需要与不同的部门领导、领域专家、开发人员和利益相关者进行多次对话,以建立清晰的定义和边界,封装各种领域级组件及其交互,它们共同构成了业务系统的主体。
当然,采用以前没有的任何类型的标准、实践和范例都会有一点点开销。在初步了解了 DDD 的本质以及我们将如何在现实世界中使用它之后,我们将使用 Laravel 实现我们从各种对话、会议和探索性研究中吸收的内容。我们将把我们在开始时获得的所有知识压缩成一组文档化的组件和数据映射(即,创建一种无处不在的语言),然后规划我们实际上如何布局组件,这些组件将构成一个完整的工作分布式系统,该系统具有模型驱动的设计、文档化良好的策略和定义以及经过充分测试的代码,并且将使用您可以在现实世界项目中使用的经过验证和测试的最佳实践来构建。
这种开销来自于学习和理解交易的基本工具。我将简单介绍一下我每天使用的工具或服务,并提供替代建议,这样你就能很好地了解在真实的商业世界中编码是什么样子。此外,我希望给你一些关于如何提高你自己的工作效率和质量的想法。
我们还将回顾各种可以实现的最佳实践,以及当您忽略它们时,代码库和项目最终会发生什么。
前几节将带您了解一些更基本的部分,我们将需要将这台机器实际投入实践(在真实世界的场景中),并从中获得一个应用,该应用具有一个使用 DDD 的程序和技术开发的模型,该模型基于经过深思熟虑和实施的策略和核心最佳实践,将确保它能够处理业务可能需要在以后进行的任何更改或更新。
有趣的组合
在 web 开发应用中成功采用领域驱动方法的关键是不要重新发明轮子。我们将开始关注创建一种专门针对特定业务模型的无处不在的语言所涉及的战略和战术,以便我们可以使用它来驱动实体、值对象和域级组件的开发,我们稍后将围绕这些组件实现周围的服务和基础设施——同时保持关注点的清晰分离,最重要的是,允许我们的业务规则和核心流程不仅指导我们的开发工作,还创建结构并赋予代码实质。
仔细想想,web 框架——或者一般的框架——都旨在通过提供工具来帮助管理应用的流,在我们的例子中,就是请求/响应生命周期,从而减少构建应用所需的时间。还有许多组件可以帮助管理 web 开发项目的各个方面:验证、认证、数据库模型等。所有这些功能都在那里,所以你不必重新发明轮子。这让我们可以几乎完全专注于领域层,这很重要。
雄辩的 ORM
数据定义语言(DDL)中包含的一个重要组件违背了 DDD 中的实践和价值观。包含雄辩的 ORM(只允许稍微改变 DDD 没有 ORM 的建议)将允许我们做许多简洁的事情。
-
将我们的领域事件与雄辩的生命周期联系起来,允许我们的模型自动发出特定的领域事件,或者当我们关心的事情发生时
-
轻松地创建模型之间的关系,使我们能够创建和管理我们的领域模型,并使用表达性语法查询我们的数据库,使它们之间的交互变得简单明了
-
为级联更新设置我们的实体和模型(在给定模型的更新过程中被修改的任何关系模型也会得到更新)
-
扩展了口才的
Model
类,让我们继承了口才自带的一些很酷的特性-
批量分配
-
查询范围
-
急切装载
-
收集
-
模型事件
-
模型观察者
-
-
一个验证组件,它将确保我们的模型处于有效、一致的状态,并且有适当的限制来防止错误的使用
在 Laravel 中开发领域驱动设计的最重要的工具之一(在事情的“代码方面”)是雄辩的。雄辩是一个基于活动记录的 ORM,它提供了一系列内置的很酷的特性和工具(事实上在 Laravel 中是免费的)。这些是我们追求 DDL 应用目标时非常需要的一些关键东西。
在现代编程中,设计一个对象的数据和它的行为的分割已经成为一种趋势。实现这一点的方法是将一个对象包含的原始数据分离到一个称为域转移对象 (DTO)的专门类中。尽管我们将在本书后面详细讨论 dto,但现在要理解它是一个基本对象,对于对象上存在的每个需要检索或存储的属性,它都有 getter 和 setter 方法。它只保存数据,不保存行为。
我知道这听起来很奇怪,可能确实违背了你的内部程序员所说的一切是正确的,但是,在雄辩中,实体和它们的 DTO 对应物或多或少地以一种实用的方式混合在一起,并且免费出现在任何扩展雄辩的Model
类的 PHP 对象中。现在,这并不是说你不能在你的持久层和 ORM 之间有一个独立的 DTO 层,但是这真的是一个没有意义的努力,因为你所做的只是重写已经存在于雄辩的抽象Model
类中的特性。如果需要模型的原始数据库表示,只需直接从模型中访问任何字段。然而,如果您想要一个存在于一个Model
对象上的所有属性及其对应的未被该类的访问器修改过的原始值的列表(类似于您在 DTO 中会找到的),您可以只使用 concertive 的toArray()
方法。
雄辩的榜样
Note
我们将忽略这个例子中模型的设计是有缺陷的这一事实,本可以用比我在这里描述的更有条理的方式来处理,但是它为当前的上下文提供了一个很好的演示。
假设您有一个Customer
型号(以及数据库中相应的customer
表,该表有一个phone
字段(包含客户的电话号码)和一个phone_type
字段,包含 1 表示家庭电话,2 表示手机,3 表示工作电话。使用口才,我们可以实现清单 1-1 中所示的类来表示一个Customer
对象。
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Customer extends Model
{
public $table = 'customers';
protected $fillable = [‘name’, ‘phone’, ‘phone_type'];
}
Listing 1-1Sample Customer Class Implementation
基本上,这里我们有一个公共的$table
属性,它告诉口才 MySQL 中相应的表,其中这个类的一个对象将表示一个数据库表行。$fillable
属性是该表上的字段列表,您希望启用一个名为批量赋值的特性,我们将在后面的章节中更详细地介绍这个特性。
表 1-1 提供了数据库中的一些样本记录。
表 1-1
来自表客户的示例记录
|
编号
|
名字
|
电话
|
电话类型
|
| — | — | — | — |
| one | 杰西·格里芬 | Six billion one hundred and ninety-seven million seven hundred and seventy-nine thousand one hundred and twenty-five | one |
| Two | 埃里克·埃文斯 | Nine billion nine hundred and ninety-eight million eight hundred and eighty-seven thousand seven hundred and seventy-seven | Two |
| three | 泰勒·奥特韦尔 | Seven hundred and seventy-seven million eight hundred and eighty-eight thousand nine hundred and ninety-nine | three |
清单 1-2 提供了一个客户端代码如何使用Customer
模型的快速演示。
<?php
use App\Models\Customer;
$customer = Customer::first(); //gets the first in the db
//(with the lowest id)
echo $customer->phone;
//returns "6197779125"
echo $customer->phone_type;
//returns "1"
Listing 1-2Sample Code Retrieving a Customer Record from the Database and Acting on It
Note
口才的抽象Model
类有很多方便的方法,比如first()
、load()
、intersect()
、makeHidden()
、only()
等等。我们将在第十四章中探讨这些特性,但是要获得这些方法的列表,请查看 http://laravel.com/docs/6.x/eloquent-collections#available-methods
。
现在,假设我们想要动态返回客户的电话类型返回值。例如,当在某个地方的模板中显示电话类型时(最有可能在用户的配置文件配置页面上),我们可能希望显示一个漂亮的、漂亮的英文表示。然而,在其他情况下,我们希望使用存储在表中相应字段内的原始数据库值,比如说,在对其执行额外的逻辑之前进行比较或预检查。
有多种方法可以不用雄辩的来解决这个问题*。通常这需要定义某种类型的 DTO,它基本上充当一个代表数据库中给定客户行的瞬态对象。这些 d to 被称为瞬态,因为它们以特定的形式在应用中传递,就像前面描述的那样。最常见的是,它们用于将数据对象转换成发送给用户的响应,或者用于定制 API 响应。这可能是一个足够的解决方案,可以在数据层和应用层之间保持清晰的分离,但是您基本上是在分离同一个对象的两个表单*,这通常被认为是一种不好的做法。**
例如,在设计数据库模式时,您不希望字段customer_type_name
和 customer_type_id
都存在于相同的表中。这将是以不可取的冗余方式复制数据。
Note
在我给你的关于Customer
模型的例子中,用这个来提示应该重构什么。
除此之外,您仍然需要实现如何完成转换部分,这通常是由转换器完成的。
Note
一个 transformer 是一个专用于改变特定对象类的属性值的类,以便用不同的方式表示它们。
想想看,我们真的需要这个模型能够做什么?简而言之,我们需要它来改变特定属性的返回值;我们需要不同的形式的财产。在前面的例子中,我们希望一个表单是英文可读的单词,另一个是直接的原始数据库值。需要意识到的是:我们正在处理同一个数据库对象的不同的形式。唯一的区别是它们呈现给使用它们的客户的方式;它们代表相同的数据库实体。如果是这样的话,我看不出有什么理由把这些行为分开。
作为一个额外的好处,假设我们还希望返回的name
值的第一个字母大写,这样我们就可以在网页上正确地显示它,而不需要任何额外的格式逻辑。幸运的是,对于我们所描述的所有问题,雄辩术提供了一个通用的解决方案,它使用了雄辩术的一些内置特性:赋值函数和访问函数。参见清单 1-3 。
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Customer extends Model
{
public $table = 'customers';
protected $fillable = ['name','phone','phone_type'];
/**
* Accessor returning the name with capital first letter
*/
public function getNameAttribute($name)
{
return ucfirst($name);
}
/**
* Accessor returning English version of phone_type
*/
public function getPhoneTypeAttribute($phone_type)
{
switch ($phone_type) {
case 1:
return "home phone";
break;
case 2:
return "cell phone";
break;
case 3:
return "work phone";
break;
default:
throw new Exception("dang!");
}
}
}
Listing 1-3The Eloquentized Customers Class
在清单 1-3 中,我们添加了两个新方法:getNameAttribute()
和getPhoneTypeAttribute()
。这两者都被称为访问器。它们允许我们引用模型上的给定属性,并自动调用模型上定义的相应访问器(如果有的话)来返回该属性的特定形式。清单 1-4 展示了一个使用这个模型的例子。
<?php
use App\Models\Customer;
$customer = Customer::find(2); //retrieve customer Eric Evans
echo $customer->phone_type;
//displays "cell phone"
echo $customer->getAttribute('phone_type');
//displays the raw database value, "2"
Listing 1-4The Client Code for the New Eloquentized Customer Class
正如您所看到的,通过在模型类上指定访问器并编写转换原始值所需的逻辑,我们已经成功地为我们的模型添加了一定程度的动态性,在通过直接属性(即$customer->{field_name}
)请求模型上给定属性的值时,会立即调用该逻辑。
由于抽象模型的内部属性数组,我们还能够保持原始数据库值的完整性,甚至不需要尝试就能保持数据的完整性。
Note
在引擎盖下,Customer
模型的内部$attributes
阵列使这成为可能。这个数组保存数据库中的实际值*。值可以以类似于访问器的方式保存,只需在值进入数据库之前修改值,使用所谓的赋值器。*
这也表明我们总是将原始形式的数据库值保存到这个内部的$attributes
数组中,并且我们可以通过使用$this->attributes['property_name']
来访问这些值。
我发现使用这种方法消除了为您的每个模型实现单独的 d to 对象的需要(以及随之而来的转换器),当然,除非您正在处理一个过于复杂的领域,该领域可能有跨各种上下文的对象数据的许多不同的转换。在这种情况下,完整的 DTO 和变压器实现可能是一个可行的解决方案。
唯一的缺点(如果你认为这是一个缺点的话)是,本质上讲,雄辩混合了基础设施层和部分领域层,提供了一个无所不包的解决方案,几乎模糊了各层之间的界限。然而,由于 Laravel 的直接功能、富于表现力的语法和易于理解的特性,模糊应用各层之间的一些界限是值得的。这是 Laravel 或多或少与领域驱动设计方法不完全兼容的另一个例子。
这里要始终记住的关键点是我们让领域领导架构和应用决策,这些决策最终驱动开发工作,并有助于实现一个丰富而优雅的领域模型,该模型真正捕获了它们所代表的业务的需求和要求。
为 Web 开发定制 DDD
值得注意的是,在“纯”DDD 实现中,领域层(以及其中的模型)对于使用它们的外部世界是不可见的。这里的问题是,在 web 开发中,我们将不得不依靠其他代码来处理 Web 应用中涉及的基本功能和基本操作。在 DDD,关于依赖的概念是尽可能地避免它们…我们需要一种方法来更好地表达这种概念,从 web 开发的角度来看是有用的。
在我在本书中采用的方法中,这不完全是 ?? 的情况。相反,我稍微变通了一下规则,为雄辩的 ORM 让路。这个组件非常重要,值得我对 DDD 处理系统设计的方式进行调整。在架构层面上,关键的区别在于,雄辩中的领域模型带有一个数据库抽象层(DBAL ),可以用来(或多或少地)手动编写定制的 SQL 查询和,一个活动记录实现,便于在对象层面上管理模型。此外,您还获得了一个富有表现力的语法,允许您优雅地处理实体关系、执行内联突变和属性转换、设置急切加载、挂钩到雄辩的生命周期事件,以及使用全局和局部范围作为对数据库中底层模型对应表中所有对象的约束来构建无缝过滤器(由受保护的$table
属性表示)。
虽然我可能已经用这个例子吊起了你对雄辩的胃口,但我们不会一头扎进雄辩,直到本书的后面。决定使用雄辩作为表达领域驱动模型的方法的一个缺点是,它违反了 DDD 的自包含领域标准。这只是我必须修改以适应开发领域驱动的 web 应用的一个例子。正如您将看到的那样,这种违反肯定是合理的,因为用 Laravel 和口才实现的 DDD 作为开发 web 应用的实用解决方案配合得很好(正如您刚刚看到的,这只是冰山一角)。
为 DDD 定制 Laravel
领域驱动的设计,在本领域的常规意义上,建议围绕领域层(在这种情况下是基础设施层)构建应用的底层组件(例如,管理连接到数据存储所需的连接和配置的组件)。这意味着我们还必须手工构建管道代码,以便在持久性级别、应用级别和领域级别上简化这些模型——基本上是从头开始。当您分解所有涉及这样一件事的各种结构时,您需要开发各种驱动程序实现、DTO 级别的对象、ORM 类型的组件(如实体管理器)、存储库和一系列其他低级对象,否则这些对象将需要构建以允许模型能够与服务对话并持久化捕获的数据。
因此,我们需要的是一种以可扩展和动态的方式开发基于云的解决方案的方法,这种方法能够真正为核心业务流程提供解决方案。在企业级架构中构建定制应用时,如果有一个清晰的流程和路线图愿景就好了,这种架构使用最佳实践,并允许底层业务逻辑(即,领域模型/领域层)驱动丰富而强大的领域的开发和细化。定义一种无处不在的语言(领域驱动设计的先决条件)的一个核心必要性是,通过不断地参考您的领域/业务/组织中的业务领域专家,自己对领域信息有一个牢固的掌握,以便获得业务对象的清晰定义,应用的其余部分可以使用这些定义来执行它在领域层需要的任何服务和操作。
也就是说,只有利用最流行、最现代的工具,使 web 开发更容易、更快、更好,才是有意义的。输入 Laravel。Laravel 提供了开发任何基于 web 的应用时需要构建的最常见组件的各种实现。它是使用最佳实践构建的,并且是完全开源的,这使得修改它的内部结构来改变框架的行为变得容易和快速。它还附带了一个代码生成器,可以通过一个简单的命令调用它来构建完整的控制器、API 资源、模型、作业和一大堆我们可以随意使用的其他东西。
Laravel 的库存装置的问题是
Laravel 有一个重要的方面与领域驱动的设计理念不太相符,那就是它的目录和名称空间结构。Laravel 最初是作为一个整体应用建立起来的——它的所有组件都是松散耦合的,并促进最佳实践,但都在一个整体结构中。这是一个问题,因为 DDD 是用来创建分布式系统的,在这个系统中,不同的组件甚至可能不知道彼此。这是微服务架构的中心目标和关注点。
例如,在默认的 Laravel 安装中,您会发现它也有一个整体结构,目录名(和名称空间,因为 PSR-4)相对于应用的关注点,而不一定是*域。*图 1-1 提供了开箱即用的通用 Laravel 目录结构(带描述)。
图 1-1
默认 Laravel 结构
我必须修改的 Laravel 的一个核心方面是目录和名称空间结构。因为它是一个包罗万象的框架(旨在在一个单一的整体结构中实现应用的所有关注点),Laravel 被分解为特定于应用的边界——与相应的应用关注点相关的类的分割组,如日志记录、发出 API 请求或管理认证和访问控制。
数据库层的关注点也应该从任何使用它或者依赖它来执行自己的职责的逻辑中分离出来。关注点的分离有助于巩固这种思维方式,并作为开发的更重要的指导方针之一。忽略关注点分离规则可能会导致不仅仅是混乱的代码,功能被填充到跨越几个架构边界和部门的大量服务中,这是由于缺乏分离而发生的常见场景。这样的项目可能被领域分割,也可能不被领域分割,并且可能被聚集成一个包罗万象的整体结构,很难处理和维护。
当应用的核心业务逻辑主要存储在服务层内部时,它被称为贫血的服务层。在更高的层面上,这与古老的咒语“胖控制器,瘦模型”相关出于我们讨论的目的,将一个贫血的服务层想象成采用“胖服务,瘦模型”的方法——结果同样适得其反。相反,我们在本书中关注的是如何以一种“胖模型,瘦控制器”或者“胖模型,瘦控制器”的方式对领域建模。当我们将领域逻辑放在服务的范围内时,我们为将来的开发错误敞开了大门,因为当应用的其他地方需要该服务中的类似功能时,开发人员(或任何其他人)可能会忘记服务中的某个步骤。长话短说,最好将大多数业务逻辑放在模型本身中。这是我的观点,我们将在本书后面更详细地讨论这个概念。
什么时候在你的项目中使用 DDD
领域驱动的设计最适合于包含 30 个或更多用例的复杂应用,而整体架构更适合于简单和较小的项目。事实上,对这样一个简单的应用使用 DDD 可能有些矫枉过正,并且可能导致浪费大量精力来构建领域驱动设计所需的所有管道和支持结构。
这种设计以及这种设置适合开发 web 应用的原因有很多,例如简单、易于部署、快速上市和快速应用开发。
整体结构很简单。所有的类、事件、交互和进程都是独立的,存在于同一个服务器、同一个文件系统、甚至同一个根目录和名称空间中。它很容易实现,因为您不必担心跨网络的内部对象通信之类的事情,也不必将对各种服务的访问分成单独的 API 调用——甚至不必为您需要访问或利用来完成某项任务的每个特定于域的服务分配单独的客户端。在 web 开发的环境中,这意味着我们要使用任何这样的服务,要么直接导入它,要么使用静态方法(或facade—另一种设计模式)来访问它。虽然当你开发的东西本身简单时,简单是很好的,但是任何需要更大规模(超过 30 个用例)的更健壮的解决方案的项目可能会超出 Laravel 的扁平架构,在这种情况下,分布式系统可能更合适(DDD 在这种情况下是理想的)。
易于部署
由于整体架构的扁平结构,所有包、类和域服务都驻留在同一个根文件夹中,并且它们的依赖项存在于项目根目录下的不同文件夹中。这使得部署 web 应用变得简单。Laravel 本身实际上只需要几个命令就可以完全从头开始安装,如清单 1-5 所示。
curl -Ss getcomposer.org/installer | php
php composer.phar global require laravel/installer
laravel new blog
php artisan key:generate
php artisan serve
Listing 1-5Sample Laravel Installation via Command Line
通过这五个命令,我们已经成功地下载了 Laravel 框架,安装了它所需的所有依赖项,在 base64 中配置了一个安全的应用密钥,并使用artisan serve
命令启动了一个即时 web 服务器(它只是调用 PHP 的内置 web 服务器来提供来自位于public/
文件夹中的 web 根目录的文件),所有这些都不费吹灰之力(向史奇雷克斯大喊)。
当您拥有运行源代码的独立环境时,部署过程会变得更加容易。例如,您可能有一个配置为使用测试数据库的环境,您可以在该环境上进行开发工作(并且可能存在,因此如果您完全破坏了它,您可以很容易地重新创建它,而不会影响其他人的工作或生产站点)。开发服务器通常具有特定的配置,可以禁用缓存、允许在屏幕上显示错误、禁用公共访问或限制外部 IP 地址,并具有其他特定于开发的设置,允许程序员在上进行实际的开发工作。
然后,当然,每个人都有他们的功能,面向公众(或面向公司)的生产环境,为你的真实世界的用户服务。生产环境的配置很可能会连接生产数据库,默认情况下不显示任何错误,而是写入日志文件或引发异常,并且可能会有缓存来维护良好的用户体验。此外,在公司的内部网上可能存在其他应用,我们的应用必须能够访问这些应用,它们拥有自己的生产和开发环境设置。
这些情况很常见,因此 Laravel 采用了 dotenv 标准来定义其配置参数,允许每个环境拥有自己相应的配置,这些配置在位于项目根目录中的单独文件中指定,并以名称.env
作为后缀。您可以为您拥有的每个环境创建多个.env
文件:.env.testing
、.env.development
、.env.staging
等。
快速上市
如果你在开发行业或整个 IT 行业工作,你可能有也可能没有机会在创业公司工作。创业公司不同于企业组织,因为他们更关心成本、将想法推向市场所需的时间以及软件开发生命周期的速度。很多时候,这意味着为了节省时间而跳过正常的最佳实践和标准。创业公司的需求要么尽快得到满足,要么根本得不到满足,因为他们的经营方式是“要么成功,要么失败”。
为这样的公司构建一个复杂的应用可能会让你觉得分布式系统将是最好的方法,但是考虑到正确建立一个分布式系统所花费的时间、金钱和精力(以及实际实现一个领域驱动的设计),这通常是不可能的…至少一开始是这样。在这种情况下,最好使用整体结构,这样你就可以把产品提供给客户和最终用户,从而开始赚钱。一旦发生这种情况,你将处于一个更有利的位置,去说服上层管理人员以正确的方式做事。
我无法告诉你有多少次我走进(或者说继承)一个构造糟糕的单片应用,它只有很少的文档,在过去十年中一直运行着相同的过时技术、框架和实践,迫切需要重写和重新设计。唯一的问题是,到那时,代码已经不堪重负,而且充斥着黑客攻击、快速修复和变通办法,因此完全重写似乎是更好的解决方案(尽管情况很可能并非如此)。因为应用从未被开发,其内部结构也从未超出最初的“立即推出”版本,公司通常会在必要时处于非常不利的地位(例如,当现代浏览器失去对公司基础软件所基于的技术的支持时)。
在像这样的最坏情况下,完全重写实际上是需要的。不要到这个地步!重构和提炼代码,确保它是按照高标准编写的,并且能够经受住任何时候对领域模型的额外洞察而产生的变化和重构。怎么做?首先,请继续阅读这本书。
快速应用开发
在内部,Laravel 为日志记录、事件广播、作业和队列设施、缓存、路由、视图和模板(通过刀片)、认证和授权等问题提供了开箱即用的解决方案。它配有一个脚手架系统,使快速应用开发和概念验证变得简单快捷。它还附带了一整套通用的、通用的契约,这些契约提供了一组接口,可以实现这些接口,从而以一种内聚和松散耦合的方式实现各种排序功能。有许多内置工具来管理 API 创建,包括 API 资源、REST 开发、请求(输入)验证和认证。
像这样的支持组件有助于快速的应用开发,因为它们遵循了有史以来最古老的最佳实践之一:*不要重新发明轮子。*通过这样做,我们已经扫清了道路,让我们能够专注于对业务真正重要的东西:充分满足为其创作付费的公司需求的软件。无论是内部的还是面向公众的,整体的还是分布式的,或者任何其他真正的东西,通过利用这些由框架(比如 Laravel)提供的预制的、插入式的、即插即用的解决方案,面向领域驱动设计的开发工作可以变得更加容易和快速。
结论
在这一章中,我们看了一下我们将在整本书中关注的各种概念。我们还讨论了使用 Laravel 框架作为实现领域驱动设计的方法,包括我为了实现这两种技术的结合而不得不“变通”的一些规则。我们快速浏览了一下雄辩的 ORM,并回顾了它在通向领域驱动的 Laravel 的过程中的重要性。
我可能在这一章跳了一下,但这是有意的,目的是为了吊起你的胃口,我希望,灌输给你学习更多关于使用 Laravel 制作领域驱动设计的欲望。我们回顾了基本架构,对比了微服务架构和整体架构之间的差异,还回顾了 Laravel 对 web 应用部署便利性的影响。我们简要地讨论了 d to 和口才,以及口才如何基本上不需要提供单独的 dto(当然,除非需要)。
二、基础训练营
在本章中,您将学习一些核心工具和术语,以及足够的概念性材料,以帮助您阅读本书,并能够理解本书后面更难和技术性更强的章节。我还将介绍一些其他的关键概念,它们本身并不是我们将要学习的核心原则和标准,而是更多的周围“管道”,如果你愿意的话,这将帮助你在现实世界的设置中开始。这一章的一些内容将是我认为值得一提的理论。
我们还将了解一些 web 开发框架的历史,以及我们是如何使用 Laravel 作为我们应用的基础的(当然还有 DDD)。我们将退一步,从更广阔的角度来看问题。
Note
即使你不是为了学习拉弗尔或 DDD 的新概念而阅读这本书,你也会从阅读这一章中受益。它基于软件行业的普遍性。
做开发者意味着什么?
成为一名网站开发者需要付出很多,而成为一名成功的网站开发者需要付出更多。在过去的几十年里,关于我们所做的工作类型,web 开发人员的定义并没有太大的变化;然而,我们开发应用的方式已经以越来越快的速度发生了变化,可用于构建软件的可用工具、包和框架也是如此,并且在未来没有放缓的迹象。(值得注意的是,今天的大多数软件都遵循某种类型的设计模式或原则,所有这些模式或原则在编程的早期就已经存在,并在 1994 年左右由四人帮的对象模式书正式认可。)
这些进步已经改变了我们思考、构建、扩展和跟踪软件系统的方式,并开创了一个编程和开发的新时代。它们可以被认为是下列类别之一的一部分:软件开发工具、软件设计范例和编码标准,和/或一般的编程最佳实践。作为一名开发人员意味着利用这样的工具,不是因为它们是当时最新最棒的东西,而是因为它们为我们的代码以及最终产品增加了价值、速度和质量。DDD 本身是从开发行业的需求中诞生的,这种需求为制作特定于特定领域的软件提供了足够的指导,但也足够通用于我们试图建模的任何领域。
软件开发工具
软件开发工具(更确切地说,web 工具)是第三方软件或服务,您可以将它们合并到您的软件开发生命周期中——就像那些用于促进基于 web 的系统的创建的软件或服务一样。总的来说,该工具已经成为一种催化剂,促使人们普遍认识到创建高质量代码的重要性,并成功地改变了编程环境。该行业本身已经并正在使投入生产的代码质量突飞猛进——以至于它帮助延长了应用的生命周期,因为它们是为了以更可维护的方式创建代码而构建的,允许它们更容易(或至少更容易)扩展和协作。
对于软件工具,我指的是任何第三方开发工具,如 IDEs,在线服务,如 Google Cloud 和 AWS,软件版本控制系统,如 Git(有专有版本,如 Bitbucket 和 GitHub),以及其他公司提供的各种其他解决方案,以增强开发生命周期的某些方面(无论是专有/开源,免费或付费),并使开发定制软件和 web 应用更加简化和可靠,而且大多数时候,这恰好是一个自然可重复的过程。
从开发人员的角度来看,使用现代工具和新兴技术比使用 Notepad++作为 IDE 更令人愉快,这些工具和技术能够吸引我的注意力,并提供新的和改进的突破和体验。这在更大的范围内意味着,作为一名开发人员,意味着在为代码增加价值(为业务增加价值)的技术方面是高效的,并且有助于促进软件开发项目中的协作努力,在该项目中,多个开发人员一起工作。每个开发人员都使用相同的代码库,但是使用不同的版本,并且每个人都使用他们自己的变更部分来更新他们自己的副本,这通常是经常发生的(一天多次并不奇怪),同时确保他们使用其他人的最新代码变更,以便当所有的更新都合并到最终形式中时,不会引入任何不可预见的错误或合并问题。
PHP MVC 框架和开源
自 20 世纪 70 年代以来,模型-视图-控制器(MVC)架构就一直存在,在过去的 15 年中,它首次被引入“官方”web 应用。开源世界已经看到了这种 MVC 框架的一些重大进步,它们现在已经成为实现几乎任何基于 web 的系统的事实上的标准。
可以说“改变游戏”的原始框架是 2005 年左右发布的 Zend 框架和 Symfony 框架(版本 1)。从那以后,PHP MVC 领域出现了几十种不同的框架,今天最流行的是 Laravel 框架(它使用了各种 Symfony 和 Zend 组件)。
当今流行的大多数 MVC PHP 框架都是由 Symfony 的 Fabian Pontecier 最初发布的构建块构成的,他将它们创建为松散耦合、独立、可重用的组件,表示 HTTP 请求和 HTTP 响应,目的是封装一个对象以方便进入应用(请求)和对该请求的响应(响应),它封装并模仿了各种 HTTP 级属性、头和其他元数据,这些元数据出现在来自浏览器的典型客户机-服务器请求/响应循环中(显然是为了跨网络使用)。
集成开发环境
当我提到集成开发环境(ide)时,我指的不是诸如 TextPad、Notepad++或 Sublime 之类的东西。这些都是很棒的程序,但它们不是 ide。他们是文本编辑器。其中一些有很少的插件可以扩展文本编辑器的功能,但它们都旨在使编辑器的功能像 IDE 一样,具有语法突出显示、LINT-ing 功能、代码格式化程序等扩展。—基本上,一个好的 IDE 支持开箱即用的所有东西。
目前,我在 web 开发(PHP 开发)领域看到的最常用的两个 ide 是 JetBrains 的 PhpStorm 和微软的 VSCode。就我个人而言,我使用 PhpStorm 是因为它几乎包含了我用 PHP 编程所需要的一切,这将使我成为一个更快、更干净、更好的开发人员。此外,Laravel、Symfony 和 PHP 生态系统中其他常用组件的可用扩展使我几乎不必离开 IDE,它们允许我专注于开发项目的领域模型(稍后将详细介绍)。
这两种 ide 都带有自动完成功能(尽管 PHP 使用一种更本地的方法来自动完成和处理不同的文件类型)。此外,在两个 ide 中都有大量的颜色主题可供选择,这在检查 bug 时非常重要,并且使阅读代码变得更加容易和有趣(同样,也许我只是有点奇怪,但是嘿)。当然还有其他的 IDEs 我只是分享我见过的用 PHP 做 web 开发最多的。主要的一点是,它通过为 IDE 的高级功能提供快速简单的低级快捷方式,提高了代码的速度和正确性。其中一个特性是为迷宫般的供应商包名称空间提供自动完成功能。如果您曾经使用过 Composer 来管理您的依赖项,您就会知道自动完成是多么节省时间。
版本控制系统
尽管软件版本控制已经存在很长时间了,但是以一种可管理的、符合逻辑的方式签入代码并对代码进行修改,以适应整个团队同时从事同一个项目,这种想法从来没有像代码库行业的两个主要参与者 GitHub 和 Bitbucket 那样简单和直接。两者都提供相似的功能,尽管 Bitbucket 是免费的,也没有那么漂亮或功能齐全(但确实很好地集成到了 Atlassian 的任务管理软件吉拉中)。GitHub 提供了一个很棒的界面,以及关于其用户和项目的各种见解和统计数据,还有一个漂亮的个人资料页面,通常用于显示或炫耀对开源项目的贡献。Git 版本控制系统已经成为 web 开发团队和软件工程师跟踪他们代码的标准。
PHP 的进步
在过去的 15 到 20 年里,我们用来开发 web 应用的语言发生了很大的变化。以 PHP 为例,它最初是一种脚本语言,旨在(预)处理超文本标记,并向浏览器用来呈现在线内容的无逻辑 HTML 语言添加逻辑。它直到 PHP 版本 4 才支持对象、类或继承,直到版本 5 才真正开始提供真正的 OOP 支持——命名空间直到版本 5.3 才出现!随着 version 7 的发布,我们现在有了一些很酷的内置特性,比如返回类型声明、零合并操作符(??
)和宇宙飞船操作符(<=>
),更不用说与以前的版本相比性能有了巨大的提高。事实上,PHP7 比任何其他编程语言的任何新版本都有更大的性能提升。PHP 已经获得了巨大的普及,并在今天被用来运行大部分的网络。下面是一些将 PHP 用于他们自己的应用的公司:
-
松弛的
-
Etsy 的
-
云 flare
-
特斯拉
-
维基百科(一个基于 wiki 技术的多语言的百科全书协作计划ˌ也是一部用不同语言写成的网络百科全书ˌ 其目标及宗旨是为全人类提供自由的百科全书)ˌ开放性的百科全书
-
博客
-
Tumblr
使用 PHP 构建的框架已经变得足够流行,被认为是创建现代 web 应用和分布式系统的事实上的标准(尽管目前的趋势是从 PHP 转向 NodeJS 这样的服务器端语言,NodeJS 使用 JavaScript 作为其主要语言,但针对服务器端编程进行了重新设计)。尽管 PHP 的总体使用量最近有所下降,但基于它在互联网上作为基础语言的地位,以及 80%的 PHP 运行于其上的事实,我认为它不会很快消失。总是需要支持(并最终转换)遗留系统,所以精通 PHP 从来都不是坏事。
说到 JavaScript,前端开发在过去几年里已经取得了长足的进步。ECMAScript 6 在过去几年里一直是人们谈论的话题;React、Material、VueJS 和 Angular 等突破性技术的引入,以及 redux 模式和其他状态管理关注点等概念上的进步,为前端 web 开发带来了更复杂的方法,使其更像后端开发。
依赖性管理系统
包管理系统已经存在很长时间了,但是它的焦点是共享代码库,这些代码库必须是专门使用的,并且缺乏任何整体的平台或者获取的手段。现在,有两个这样的系统驱动着所有 PHP 框架的 web 应用的开发,并且是管理 web 应用中依赖关系的标准方法,包括前端和后端。它们被称为 Composer(一个 PHP 依赖管理器)和 Node Package Manager (NPM)。Composer 最常用于引入后端依赖项(并且几乎总是在为您的应用安装任何第三方依赖项时的第一步,包括 Laravel 之类的框架)。
构成任何现代 MVC 框架的几乎所有安装基础的流行命令如下所示:
composer install
这两个词足够强大,可以下载composer.json
文件中列出的所有所需依赖项的指定版本(称为版本锁定,它保存在composer.lock
文件中),并创建一个包罗万象的自动加载器,只需一行简单的代码就可以导入在composer.json
文件中定义的每个第三方依赖项(清单 2-1 )。
<?php
require_once('vendor/autoload.php');
//good to go! use your installed dependencies freely
Listing 2-1Example Use of Installed Composer Dependencies
NPM 与 Webpack 结合使用,提供前端资产,这些资产可以下载、安装、缩小并以类似于 Composer 的方式运行,只需使用一个命令:npm install
。Webpack 用于配置当前主导前端世界的所有高科技、新时代的库和包(如 React 和 Angular)的各种可用选项。不再需要进入网页,手动找到下载链接,然后在软件中使用它之前手动安装和配置它。这一切都是由 NPM 和 Webpack 完成的。尽管使用这些工具确实需要一些额外的知识(在其他人使用的生产/操作系统上部署东西时甚至需要更多的经验),但是它们为有时被称为依赖地狱的东西增加了理智。
只需一下子(基本上是两个命令),您就可以安装应用前端和后端所需的几乎所有第三方代码,并立即投入使用。这也为前端资产提供了一个整体结构,以流线型和流畅的方式围绕现代技术进行实践。
总的来说,这些和其他产品、库和平台所取得的进步让我们开发人员的生活变得更加轻松和有趣。我们不再承担管理我们自己的依赖关系并将这些依赖关系连接到一组可用的包含中的单调乏味的任务。我们可以只发布一个 Composer 命令或使用 NPM 来获取前端包…有了 PhpStorm、VSCode 这样的产品,以及 Bitbucket 和 GitHub 这样的版本控制系统,我们作为一个行业已经在全球范围内发展了 web 开发实践。这些进步对任何选择实现它们的企业的底线都有直接的影响,并且对开发人员的幸福和满足也有深远的影响,这两者都是成功的秘诀。
编码标准和实践
由于编程的本质(特别是 web 开发行业),在 web 应用和程序之间存在某种标准化的全球需求,这些应用和程序旨在通过网络从浏览器使用。感谢 PHP-FIG(类似于 web 开发的 RFC)这样的倡议,我们现在有了一套标准的建议,用于日志记录、结构化代码等方面,以便于阅读。开发人员还可以使用类似接口的东西来处理基本的请求/响应和关于流 web 响应的规范,以及自动加载问题。Web 编程通常只不过是几个非常大的文件(通常是“意大利面条式的代码”),所有的表示、业务逻辑和页面样式都打包成一个单一的整体结构,分布在两三个难以阅读、修改和维护的文件中。忽略这样的实践,因为它们具有清晰的关注点分离(在架构级别和领域级别上),使得负责维护代码的开发人员一想到要查看数英里构造不良和拼凑在一起的逻辑就畏缩不前,由于完全忽略基本的缩进和代码样式,这些逻辑的可读性也一样差。
这里要注意的另一件重要的事情是,代码的呈现不仅会影响下一个开发它的人,而且随着时间的推移,它会成为所谓的破窗理论的一个有效例子。在城市地区,当建筑物只有一扇破碎的窗户而没有及时修复时,自然会有更多的窗户被打破的趋势,这反过来又为更多的窗户打开了大门(或者更准确地说,窗户),这些窗户被打破而没有得到修复,这进一步延长了循环,最终导致不可避免的没有窗户的建筑物…一切都是从没有修好第一个开始的。
在软件中也是如此。未能修复应用中出现的第一个问题或问题只会使其他问题更有可能出现,如果不是很有可能的话,并且在这一点上,似乎“无论如何都是错误的”,所以只要事情仍然工作,谁会在乎是否有另一个小小的错误呢?这是一种糟糕的心态,因为在某些时候,这些无关紧要的小错误会累积起来,导致系统崩溃,导致严重的停机,影响公司的其他部门,可能会影响员工和客户。如果第一个 bug 被立即修复,那么任何其他的 bug 都会成为一个更大的问题。想想看:哪个开发人员想成为将一个错误引入一个没有错误的系统的人?(现实中可能有也可能没有“无 bug”系统这种东西,但你明白这一点。)
最佳实践是开发直接旨在防止这种类型灾难的软件的经过试验和测试的方法。有一些方法,当正确实施时,将有助于增加源代码的结构、深度和意义,并且最终将被用来创建一个高质量的软件工作件,该软件在其整个生命周期中确保其可维护性和可扩展性。
关键是要开发能够以任何速度伸缩的软件系统,并且能够承受系统的任何变化而不破坏系统的完整性。同样重要的是开发具有高内聚性的软件组件,并且用松散耦合的组件来表达,以便我们可以在应用的其他地方重用我们的代码。使用最佳实践意味着创建一个可靠的、可信赖的、可重复的连续迭代/连续开发(CI/CD)流程。这意味着频繁地向存储库提交和推送,在足够小的部分中进行微小的更改,以便于测试、管理和部署。让我们讨论一下开发环境中的内聚性是什么,以及如何将内聚性与低耦合性结合起来使用,以创建能够承受变化的可重用软件组件。
什么是凝聚力?
DDD 提供了一种组织应用的方法,这样它们可以作为独立的进程独立工作,但在协调运行时具有高度的内聚性。内聚是软件开发中的一个重要概念,还有耦合。与软件开发相关的术语内聚是由 Larry Constantine 在 20 世纪 60 年代末创造的,用来表达一个模块(或一组类)应该或多或少以统一的解决方案为目标的思想。在模块层次上,类应该足够分离,以便在没有太多开销的情况下可用和可扩展,并且应该通常专注于手头问题的一个方面。每个模块应该以这样一种方式相互关联,使它们作为一个组在功能上具有凝聚力——这意味着模块中的所有元素都应该有助于一个单一的、明确定义的任务。
请注意各种实践,并尝试认识到这里有两个主要原则,尽管是低级原则。一旦您能够识别单一责任原则和关注点分离,您将会发现这两者自然是相辅相成的。一旦你坚持认为一个类只专注于解决一个特定的问题(除了粒度),通过确保它的成员以一种最自然和最适合公司业务领域的方式,几乎总是直接根据无处不在的语言中定义的术语,很容易同时坚持关注点的分离。
-
凝聚力:当一个团体或社会的成员团结在一起时
-
(形容词):团结一致,有效地一起工作
与此同时,这些类最终需要实际“绑定”在一起,以产生手头问题的期望解决方案,其方式是可测试的、可重用的,并且根据其功能和核心业务逻辑,在关键系统组件之间划分出清晰的边界。
清单 2-2 提供了一个不那么内聚的类的例子。请记住,该示例没有验证或前置/后置条件,不应在生产环境中使用,而只是一个严格的学习练习。
<?php
namespace App\Registration;
use App\User;
class RegisterUser
{
protected $name;
protected $username;
protected $isAdmin = false;
protected $isPremierMember = false;
public function setName($name) {
$this->name = $name;
}
public function setUsername($username) {
$this->username = $username;
}
public function makeAdmin() {
$this->isAdmin = true;
}
public function getUserAttributes() {
return [
'name' => ucfirst($this->name),
'username' => $this->username,
'isAdmin' => $this->isAdmin == false ? "NO" : "YES",
'isPremierMember' => $this->isPremierMember == false ?
"NO" : "YES"
];
}
public function registerUser() {
$user = new User($this->name, $this->username,
$this->isAdmin, $this->isPremierMember);
return $user;
}
}
Listing 2-2Example of Low Cohesion Within a Class
有几件事你可能马上就能发现,那就是类的设计和缺乏语义的问题。例如,通常一个执行高级用户注册过程的类很可能会检查重复的用户名和用户名是否有效的特定标准。现在,我只想关注例子中成员函数内部引用其他成员函数的情况,以及它是如何毫无理由地在类中传递自己的成员的。更重要的是,为了使用它,很多工作都留给了客户端。清单 2-3 是清单 2-2 中类的示例客户端。
<?php
use App\Registration\RegisterUser;
//... collect user attributes--most likely via a form request
$params = [`name` => `Jesse`, `username` => `debdubstep`];
$userRegister = new RegisterUser();
$userRegister->setName($params[`name`]);
$userRegister->setUsername($params[`username`]);
$user = $userRegister->registerUser();
//now we have an unsaved $user...
Listing 2-3Example of Client Code for Low-Cohesive Class RegisterUser
总的来说,这是一个糟糕的设计。将特定的方法或例程留给客户端代码会产生它们可能不会被调用的可能性。与此同时,在registerUser()
方法中,它用给定的参数创建用户,但不把它保存到数据库中。如果开发人员期望返回的用户是成功保存到数据库的记录,这可能会是一个问题。当然,由于缺乏错误消息或异常,我们无法从应用中了解情况,直到我们开始注意到用户没有被持久保存到数据库(或者人们开始抱怨,这是一种更有可能的情况)。
在持久化用户对象之前,没有检查来确保所有需要的数据和方法都已经放在类中。当然,我们可以编写一个逻辑,在我们创建和持久化新用户之前,检查是否已经在对象上设置了本地参数$username
和$name
。然而,这样做的问题是,我们必须考虑如何通知用户必须首先设置这些属性(手动)。我们会抛出一个异常吗?
我们可以,但我不认为停止软件执行的完全错误是值得的,因为对象上的参数还没有设置,但也许你在一个上下文中它会。例如,在 web 表单上,当您注册某个网站的新帐户时,您会立即得到反馈(通常通过 JavaScript ),告知您错过了某个字段或者该字段的格式无效。一旦你点击了提交,这个请求就会被应用处理,然后(通常)你就会被带到某种类型的个人资料页面。您不知道或不想知道创建您的用户帐户的内部进程;你想知道的是,你有权访问该网站和登录。
以类似的方式,实际注册新用户的代码应该封装到一个单独的类中(可能在应用层中作为一个利用域层对象的服务),并且应该作为在系统中创建新用户的单一入口点。客户端代码应该根据需要初始化这个类的一个对象来执行请求,然后“点击提交”很明显,没有可以点击的提交按钮,但是像run()
、execute()
和handle()
这样的方法基本上是这样操作的,启动一个作业或初始化某种类型的注册服务,该服务将处理创建新用户的各个方面,并包含确保处理任务的先决条件得到满足的逻辑。
此外,使用技术分析会使我们得出这样的结论:注册用户的概念应该分解成不同的关注点,确保保持域模型的完整性。例如,我们可以引入一个存储库,减轻类处理User
对象持久化的负担,使Registration
类更干净、更轻便。我们还可以实现一个UserFactory
类,封装注册新用户所涉及的知识和逻辑。
虽然这个例子确实是这样,但我们已经超越了自己。让我们把注意力集中在我们在前面几段中描述的这个类的更明显的问题上。
清单 2-4 展示了一个更好的(但不是最好的)解决方案。
<?php
namespace App\Registration;
use App\User;
class RegisterUser
{
protected $safeAttributes;
protected $user;
public function __construct(array $params) {
$attributes = User::fillableFromArray($params);
$this->safeAttributes = $attributes;
$this->user = new User();
}
public function makeAdmin() {
$this->user->admin = true;
}
public function makePremiumMember() {
$this->user->premiumMember = true;
}
public function getUser() {
return $this->user;
}
public function registerUser() {
$this->user->fill($this->safeAttributes);
$this->user->save();
}
}
Listing 2-4A Refactored Version of RegisterUser with Higher Cohesion
在这个例子中,我们去掉了所有单独的 setter 方法,取而代之的是通过构造函数(构造函数注入),这是一种依赖注入技术。正如您所看到的,这些字段是通过使用一种叫做fillableFromArray()
的简便的雄辩方法来验证的。这个方法是一个功能强大的函数,它接受一个数组并返回另一个数组,数组中的值是模型中存在的有效属性名。当我说“有效”时,我的意思是属性被User
类认为是“可填充的”还请注意,我省略了任何检查,以确保参数不为空(或者传入的$params
变量不是空数组——为了简洁起见)。
我在这里留下了两个make()
方法,以防客户需要能够在用户被保存之前设置它们,但是我也可以很容易地在构造函数中包含这两个额外的参数,并在方法签名中设置它们的默认值。使用所谓的流畅界面是有益的(稍后会有更多介绍)。当该说的都说了,该做的都做了,getUser()
方法将简单地返回我们在调用registerUser()
后建立的用户。从客户端的角度来看,使用该类的简单性使客户端保持简单,只需调用更少的方法来实现某种结果。
什么变了?
属性的内部引用或内部参数的“传递”过于频繁会使类变得混乱,难以测试、维护和在其他地方重用。努力实现关注点的清晰分离是有好处的。在前面的RegisterUser
类中,它和User
对象之间有很高的内聚性。User
对象的属性由RegisterUser
类指定,但是它的持久化是在User
类内部处理的(User
是一个有说服力的模型)。在应用的这一小部分中存在着一定程度的内聚性,这样各个部分就可以一起工作以达到最终的结果,并且这一部分遵循了对象中的关注点的清晰分离,从而促进了它的实现。
这里起作用的另一个因素是我们为客户提供的灵活性,特别是通过方法getUser()
、makeAdmin()
和makePremiumMember()
。这些方法提供了额外的“特别”选项,由客户端负责调用。同样,我们也可以将它们实现为额外的构造函数参数,然后设置为默认值null
。在调用了registerUser()
方法之后,我们可以使用getUser
来检索现在持久化的新的User
对象。
这个设计远非完美。事实上,我们将以 Laravel 作业和队列的形式讨论一种更好的方法来处理这种功能,但是作为一个介绍性的例子,这种方法效果很好。
低内聚力
方法之间没有任何链接,类中的过程之间也没有任何共享资源的利用。它们都分别作用于单个成员变量,这里没有什么有趣的事情。尽管这个类能够并且可能实现关注点的分离,但是它很可能有一个错误的东西被分离出来。我们在领域层中的概念边界之间划的线对我们的对象相互作用(或不相互作用)以产生预期结果的方式有深远的影响。图 2-1 显示了一个低内聚类的例子。
图 2-1
低内聚的类
被认为具有高度内聚性的代码通常作为单独的片段存在,它们都有助于并致力于单一的定义的目标或任务。图 2-1 中的SomeClass
是一个类,它的结构取决于它的属性。每个函数都是独立的,并且每个函数都只使用在类中定义的相应参数——如果该结构是基于特定的业务问题并保证了它的使用,那么这是很好的。
一般来说,这样的类不可重用,也不容易扩展。也许我不应该说“容易”,但扩展真的“毫无意义”,因为子类将使用这种结构,并且通常只作为独立对象有用。这个类中没有内聚性,因为其中的每个方法只与类中定义的单个参数相关。这些方法类似于某种形式的(通常是不需要的)getter 和 setter 方法。对于获取和设置数据(我们将在本书的后面深入探讨),雄辩有一个更复杂的解决方案,即通过它的赋值函数(setter)和属性(setter),也通过它的魔法方法。
必须保持他们分开(关注,这是…)
不,我指的不是后代的老派歌曲,我说的是应用或软件中存在的问题。当实际“分离关注点”时,产生的配置应该是软件试图解决的特定业务问题的指示。你不应该“仅仅因为”把事情分成不同的类和组件如果不同类或类内参数之间的分离似乎不太适合领域的整体调整,那么它很可能不需要以这种方式分离,或者根本不需要分离。相比之下,如果在两个独立的对象中发现的两个对象或属性似乎过于紧密地联系在一起而不能分开,它们可能只是属于一起。这很难做到正确,当然这完全取决于你所从事的特定领域。
Note
在整本书中,你会经常在我关于代码结构的讨论中看到名称空间、文件夹结构和目录这些词。只要知道我的意思是他们可以互换使用。
通常,这种分离是以严格的名称空间和目录结构的形式出现的,其中类是按照它们是什么而不是它们做什么来分离的。大多数(如果不是全部的话)现代框架都是这种情况,这也是 Laravel 的默认名称空间结构和目录的基本设置方式。控制器都在一个App\Http\Controller
文件夹中。可能会在它们下面设置子文件夹,但它们仍然都源于主App\
名称空间下的这个单一目录,由它们是什么分开(图 2-2 )。
图 2-2
正在运行的 Laravel 应用的目录结构示例
这种类型的结构关系到每件作品是什么;存储库放在App\Repositories
名称空间中,控制器放在App\Http\Controllers
名称空间中。在一段时间内,这一切看起来都很好,但随着时间的推移,系统中会增加额外的功能和业务需求(实际上是无限的),很难在单个名称空间(甚至是模块化的名称空间)中管理所有控制器。在这样的结构中,领域的真正含义并不清楚——它并不特定于领域。
在我们开始使用 Laravel 之前,我将为您提供一种方法,将您的应用恰当地组织到单独的筒仓中。每个竖井将有其自己的名称空间,并且在每个竖井内将只有使该竖井起作用所需的组件和代码。顺便说一下,我使用筒仓和模块基本上是指同一件事。一旦您根据每个特定模块对领域的重要性开始分解应用的单一整体结构,您将发现管理和测试变得更加容易,因为所有的模块都是相互隔离的——然而所有的模块都以同步和谐的方式一起工作以产生最终的应用。
分离的先决条件
为了实现正确的结构,需要做一些必要的工作(“正确”是指对您的业务来说“正确”的任何东西)。要记住的主要事情是意识到可能的概念边界可能位于业务的核心中。如果我们努力保持模型驱动的设计,那么下一步将这些界限转化为实际的代码结构将会更容易,这取决于在这种无处不在的语言中描述的事物的质量和正确性。
有些事情将很难分开,如果,不管出于什么原因,在两个组件之间画一个适当的分离看起来太复杂或太困难,解决方案可能是使边界线比有界上下文的边界线更细。这可以通过使用每个组件的模块实现来完成,并且仔细地(并且显式地)将两者之间所需的通信抽象到单独的类中,或者作为服务或作业职责的一部分(然后可以在 RESTful 接口后面设置,并在多个有界上下文中使用)。模块提供了一种不太正式(但仍然显式)的机制,用于分离出您的业务(以及您的应用)所依赖的各种组件。
模块还提供了另一种在应用中对相似概念进行分组的方法,并且可以(并且应该)代表底层业务实际上是如何构建的。我们应该能够看到一个应用的模块图,并对其中发生的事情以及模块如何组合在一起形成一个完整的工作应用有一个很好的总体感觉。
当我们讨论有界上下文时,我们将研究一些不同的构建模块的方法,但是现在这里有一个模块结构的例子,在这个例子中我们可以清楚地理解它做什么以及它的操作中涉及的各种组件。
重构遗留系统
如果在试图将特定的概念或类集合移动到它们各自的模块或上下文中之后,它们似乎仍然不能自然地与应用的其余部分相适应,或者违反了在通用语言中定义的定义和关系,那么考虑它们实际上不需要被分离的可能性。这可能是由于缺乏管理所述概念和过程的业务规则的完整知识,或者是这种无处不在的语言的内部工作中的一个错误。我经常认为这是由于热情(或错误的信息/误解等)的结果。)离岸团队最终为一个应用留下了一个大烂泥球,而碰巧的是,他们成功地进行了数以千计的小黑客攻击,以获得一个 MVP 来同步并产生某种形式的系统,而无需做更多的工作。
如果这描述了你,并且你已经继承了一堆蹩脚的、离岸的代码,并且必须一行一行地通过遗留应用来弄清楚他们试图做什么(因为肯定没有文档可以帮助你),我同情你!在我的职业生涯中,我去过那里很多次。大多数时候(有充分的理由),从零开始重写遗留应用不是一个选项。
向前推进的最佳方式是尽可能使用最佳实践和标准来实现任何新功能,然后(可能在计划好的冲刺中)创建所谓的反腐败层。这一层基本上是应用特定部分的一个分割区域;通常这一部分与没有它的整个工作或过程的实现一样大。一旦您围绕遗留代码和新的反腐败层建立了参数(是的,这是一种奇怪的反腐败层的说法,但 ACL 已经被采用),在其中构建一个完整的功能,只与旧代码建立强制性的连接和集成点,并使用最佳实践在新代码中构建任何东西。最终,遗留系统基本上吸收了这个反腐败层,并将其视为根本不存在——就好像它只是遗留代码导入或引用的另一组类和对象。
之后,休息一下,喝杯啤酒,因为这是一个伟大的成就,在复杂的系统上很难做到!一旦你重新开始工作,重复这个过程,只是用一个不同的现有功能,甚至是一个新功能。构建另一个反腐败层,作为或多或少的“插入式”替换或添加,遗留应用可以固有地交谈、发出请求和委托(这通常最好通过 API、事件系统或队列来完成),以便处理用户的请求。在这种情况下,用户和遗留应用应该无法区分这两者。我们已经巧妙地创建了一个对外部几乎不可见的遗留应用边界,使用最佳实践封装了一个动态特性,并附带了单元测试和文档。现在你已经很好地掌握了这个过程,继续更新遗留应用的一小部分,并在它们各自的反腐败层中实现微小的功能,直到不再有遗留代码。
这是使用最佳实践和高质量代码构建任何新东西的可靠方法,同时使遗留应用越来越好,直到它从代码库中完全消失。当然,事情远不止如此,但你已经明白了。在我们深入这些概念之前,我想澄清一下我所说的“领域层”或“模型层”是什么意思这个定义是理解本书其余概念和思想的关键。
分层(洋葱)架构
那么,什么是领域层呢?很高兴你问了!从 DDD 的意义上来说,领域(或模型)层是软件架构背后的核心驱动力,它涉及的对象和过程代表了现实世界中的业务问题和需求,这些问题和需求是设计用来解决的(见图 2-3 )。领域层的成员通常是核心对象或业务流程,它们被表示为整体业务和应用的一等公民。
图 2-3
应用的层。有时被称为“洋葱”
本质上,模型层封装了业务实际做的上下文中存在的所有内容。它是应用的核心。所有其他代码、策略、过程和基础设施的存在只是为了方便在模型层中找到对象和组件。
因此,关键是能够将这些代表实际业务实体的业务规则和类“分割”到它们自己的层中。这是模型层。通过关注核心业务规则和基础领域知识,我们将构建一个强大而丰富的领域模型,它是业务本身的真实表现。各种模块将多个模型组成子部分——所有这些都是为了实现(理想的)关注点分离,其中每个模块对应于一个目标,或者整个业务的模型级关注点的一部分。
这可能表现为企业不同部门之间的明确区分,其中每个模块封装了整个部门的关注点,或者可能存在于更细粒度的级别上,而模块代表各种资源或特定于产品的关注点。
其他层在哪里?
在图 2-3 中,注意领域层是如何处于应用的核心的。该图描述了操作的“流程”(对应于每个特定层的客户端),以及每个层通常如何指向它下面的层,使得每个层只能依赖(并调用)它下面的层。
基本上,领域层,应用的核心,作为它自己的实体存在,因此应该依赖(或依赖)该层中的其他成员或组件。其上的一层,即基础设施层,用于促进这些域层对象的读写(因此必须直接“了解”它们)。最后一个外圈是应用层。应用通过基础设施层访问域层,但是在 Laravel 中,可以直接访问域模型。
应用层
应用层是其客户端通常在请求之外的层(通过一些输入源,如 HTTP 点击、API 调用或 SOAP 实现的操作)。这一层的客户通常是应用的用户。当需要处理业务规则或业务对象时,应用层不会直接处理它们。
相反,它处理基础设施层,基础设施层最了解如何处理域层(或模型层)中对象的各种细节。下面是对每一层中存在的组件的一些说明:
-
应用服务存在于应用层,但是没有任何领域逻辑。
-
它们控制持久性事务和安全性。
-
他们可以向其他系统发送基于事件的通知。
-
他们撰写给用户的电子邮件。
-
他们可以订阅从域层发出的事件。
-
这一层中的应用服务是域模型的直接客户。
基础设施层
基础设施层包含以下内容:
-
存放在基础设施层中为域中定义的接口实现的存储库
-
如果与依赖注入结合使用,可以“颠倒”过来,给予基础结构层在它下面的层中实现任何接口的能力
-
工厂可以存在于这一层(或领域层)
-
持久性机制
模型层(领域层)
模型层包含特定于业务的规则和实体,构建它们的公司使用这些规则和实体来运行。请记住,代码就是业务。照此处理。
-
域服务处理与域相关的特定过程和任务。
-
这一层发布领域事件,供应用的其余部分作出反应。
-
这种模式的客户端通常存在于应用层。
-
有界的上下文和业务组件的分离是关键。
注意一些关于模型层的事情是很重要的。
-
模型层是任何应用中最重要的一层。
-
应用中存在的其他层只是为了支持模型层中的对象。
-
在 DDD 分布式应用中,模型层的不断迭代、重组、澄清和细化是非常重要的。
-
模型层(领域层)是领域驱动设计的焦点。
-
对模型的改进通常是在开发人员和某类领域专家之间对业务如何运作和功能进行长时间讨论之后进行的。
服务层
服务层(或只是层)也有明确的区别,几乎每个应用都有一些概念。这些服务层几乎与它们的“架构层”对等物直接相关。例如,应用服务位于应用层中,但是针对域层中的对象执行操作(通常通过基础设施层)。类似地,跨越多个领域对象并直接涉及系统核心业务规则的业务流程将存在于领域层中,并由基础设施层中的对象或组件来操作。
应用服务程序
这些层与本章前面介绍的应用层非常相似,但是它们更细粒度,因为它们代表服务本身,主要关注业务的特定操作或流程,而不是应用的整个“层”。示例服务可以是诸如SendNotificationEmails
、SubscribeToNewsfeed
、ExportAccountingStatement
等。
首先是应用服务。这一层中的服务基本上充当外部客户机(请求)和域逻辑之间的中介。这一层可以(并且通常确实)作为组件而不是服务存在——控制器就是一个例子。他们当然是 MVC 架构中的“C”。控制器分派特定的服务和作业,并控制任何侦听器、订户或其他接收器来响应这些作业和服务调用。请求和响应也包含在这一层中。
应用有时使用控制器作为保存和运行域层过程的手段。通过将它们与控制器内联编码,它们极大地降低了代码的可重用性,同时也使得任何只针对领域逻辑的测试变得更加困难,因为这个过程不是孤立的。单元测试的关键是能够测试彼此独立的事物(以“单元”为单位)。
没有肥胖控制者
拥有肥胖的控制者不是好的做法;应该通过显式定义服务或其他领域层类和组件来避免这种情况,这些服务或其他领域层类和组件封装了此类流程的特定领域知识,然后从控制器内部调用这些知识,控制器传递流程并返回响应。除非过程非常简单,否则单独的类会显得多余,核心业务逻辑必须分离到应用的域层中,或者作为模型、域服务、事件监听器,或者任何最适合其目的的东西。根据通用语言命名领域层中的对象。
外部客户端(请求)是应用层中对象的直接客户端。然后,应用层根据该请求采取行动,通常包括与领域层中的对象进行交互。
在哪里划线?
这就是本书希望帮助回答的问题。我们需要一种在 web 环境中开发 Web 应用和系统级架构的方法。这就是 DDD 要发挥作用的地方。
我想简单地谈一下,因为它对于培养一种强大的、定义良好的通用语言至关重要——不是可选的,而是至关重要的,这种语言将用于对应用的核心结构进行建模,并确定其模块的粒度。这只有在对公司当前持有的“公认”标准进行一些挖掘和探索之后才会发生(通常是通过深入到他们如何工作和他们实际做什么的更细微之处)。很多时候,您最终发现的或多或少是假定的业务知识或模糊的边界,即,部门或核心流程的不清晰分离,经常看起来跨越几个服务或有多个主要关注点。
Eric Evan 的书 DDD:在软件的核心解决复杂性中有很大一部分是关于系统各个方面的概念边界以及在哪里画它们。通常,这些边界在应用中的正确位置并不总是像我们希望的那样清晰和直接。最有可能的是,领域模型中组件的分割需要开发人员和领域专家在一个被称为知识收集或信息收集的过程中进行许多彻底的讨论。
最后,这些上下文边界的产品划分出不同的功能组,这些功能组在业务本身的上下文中正常而自然地存在。当基于无处不在的语言中的概念和定义时,这变得更加健壮。不太正式的分离机制包括模块、域、子域或一般子域。从更广泛的意义上来说,作为分离机制的有界上下文也有助于业务范围内无处不在的语言的细化——特别是当设置为使用 REST 接口实现的发布语言时(我们将在后面讨论)。
现在,当您将 DDD 提供的策略和工具结合起来,并将它们与领先的 Laravel 框架相结合时,您就能够在一个可重复且简化的过程中满足几乎任何架构或业务领域规范的需求。该框架提供了各种组件的实现,这些组件是通过领域驱动设计的战略层发现的(它突出了围绕您正在构建的领域中各种解耦结构的概念边界的绘制)。我们将使用各种技术来更好地促进这一过程——比如依靠领域专家对业务概念的全面定义,以及以业务软件的形式实现所获得的知识,这些软件可以在企业范围内通过网络提供服务。这将为我们在应用的代码端实现我们需要的东西提供一条清晰的路径,并且基本上利用 Laravel 和 PHP 来实现我们已经获得的概念和过程。
我们从 DDL/DDD 得到了什么
最终,我们得到了一个功能完整的软件,它易于部署并建立在最佳实践之上,具有各种结构和名称空间,实际上与业务级的实体和结构相关联。该软件是完全可扩展的,因为我们明智地决定在云上推出我们的应用和基础设施,并且由于我们定义的关注点的明确分离,它们可以很容易地修改。我们也有一个清晰的模块路线图,需要构建这些模块来正确处理它所促进的核心级业务流程。
随着我们从最初的领域关注点分解进展到反映业务的真实需求和要求的细化的领域模型,我们开始看到事情以一种有机的方式排列起来。概念和理想似乎毫不费力地相互联系起来,并且在实现新功能或对现有功能进行更改时,开销开始大大减少,因为应用的底层结构是以与其领域模型紧密耦合(并表示)的方式制作的,这使得所有这些更容易理解、扩展和测试。在开发过程中,我们可以使用特定的单元测试作为快速简单的概念证明。我们将在后面的章节中详细讨论这一点。
结论
在这一章中,我们看了拉弗尔是如何构建的,并与 DDD 建议的指导原则进行了对比,以指出一些明显的差异。我们讨论了整体式应用是如何构建的,以及它们为什么不如一种更为分离的方法(比如微服务)理想。增加这种复杂程度的决定必须直接来自于与领域有关的决策,应该根据应用构建于其上的标准业务流程中存在的相同逻辑对领域进行分离。
我们还讨论了一些雄辩模型的例子,并回顾了一些有助于向数据库添加新用户的代码,以及为什么把以领域为中心的代码放在模型的范围内比放在控制器或服务的范围内更好。只有当实体或值对象的使用不适合您试图解决的问题的上下文时,才应该创建服务。
现在我们知道了 Laravel 在使用 DDD 作为指导构建架构方面的缺点,我们可以回顾一下我们将需要的各种更改,以使默认 Laravel 安装的结构实际可行。我希望我也激起了您对 Laravel 提供的其他组件的好奇,激发了您学习更多内容的欲望,这将在接下来的章节中介绍。
三、领域驱动是什么?
在前一章中,我给了你一些我们在本章和其他章节中继续探索的东西,关于 DDD 建立的思想和概念。在这个过程中,我希望能够激起你在 Laravel 学习领域驱动设计的兴趣。我们将继续探索 DDD 提出的策略和方法,然后讨论如何使用 Laravel 实现这些想法。
在这一章中,我们将更多地关注 DDD 提供的核心定义和策略,并且给出一些领域驱动设计的高层次概述以及它的内容是如何分解的。我的目的是给你足够的知识,用你在现实世界中构建一个基于 DDD 的项目所需的基本核心实践和过程武装你。我将为您提供在任何领域构建软件时可能会遇到的示例问题,并且我将为您提供各种解决方案,这些解决方案将突出一些不同的组件,并说明它们定义的各种概念和上下文。
软件的本质
软件很少会自我实现。相反,它是达到目的的一种手段…做某事的方法。很少为了*编码而写代码。*您很可能不会遇到太多最终产品是代码本身的情况,除非您正在为一本书编写代码,或者在一篇博客文章中强调一段源代码,甚至编写一个开源库。通常情况下,你的软件的目的是做一些与构建软件无关的事情。
例如,开展电子邮件活动的营销公司希望跟踪统计有多少用户打开了该电子邮件,有多少用户点击了该电子邮件中的广告,有多少用户实际购买了该产品,等等。这些报告具有很大的商业价值,有助于推动商业决策,并验证营销活动作为一个整体是否符合某些跟踪目标。首席执行官和其他高管经常使用这些数据来做出影响公司健康和重点的重要决策。
对于利益相关者和公司的 CEO 来说,他们想看到的只是他们的报告。他们很可能不太关心这些报告是如何构建的,而是这些报告对他们来说是可见的,并且是准确的。对他们和其他企业来说,代码只是收集数据、计算数字和生成报告的一种手段。他们通常不关心诸如单元测试、安全性(不幸的是)、架构、编码风格、编程语言或系统的任何其他技术方面…他们只想要漂亮的报告。
开发和业务目标之间的分歧如此之大,以至于大多数时候,在各个部门之间有一种单独的语言来描述相同的业务概念。在极端的情况下,围绕软件系统的部门本身变得如此分离,以至于他们每个人都有一个语言版本,开始是一个单一的定义,但发展到包括几个上下文有偏见的定义。每一个定义都对应于它们被构思的部门的中心焦点或方面,以这种方式来创建伪概念,这意味着几乎与它们的祖先一样的东西,但是有足够的细微差别,以至于该概念在整个组织中不成立。
业务主管/利益相关者和关心的是诸如报告准确性、用户界面设计、网站可用性、性能和总体用户体验,因为这是用户所看到的。在任何与技术相关的业务或软件努力的这两个基本方面之间,肯定会有,而且很可能永远会有一些摩擦:业务的需求和软件的质量。当质量因为时间的原因而得到补偿时,我在第一章中讨论的事情开始变成现实,你最终会以不开心的开发人员、比通常更长的周转时间、意想不到的火灾和一大堆其他糟糕的事情而告终,最终会变成一团烂泥。
金三角
图 3-1 代表了一个流行的图表,用来表达软件开发工作的三个主要方面之间的权衡。
图 3-1
软件开发的金三角
在图 3-1 中,对于一个给定的软件项目,你只能得到三个理想结果中的两个:开发的速度(时间)、源代码的质量(质量)和成本(费用)。我们可以根据表 3-1 来确定结果的可能性。
表 3-1
金三角组合的可能结果
|
选择#1
|
选择#2
|
结果
|
真实世界的例子
|
| — | — | — | — |
| 质量 | 时间 | 快速构建的高质量产品不会很便宜。 | 快速应用开发 |
| 时间 | 费用 | 快速廉价的产品质量会很差。 | 创业公司的旗舰应用 |
| 费用 | 质量 | 高质量的廉价产品需要时间来制造。 | 开源软件 |
| 质量 | 质量 | 一个超高质量的产品是要及时和昂贵的。 | 企业软件 |
| 时间 | 时间 | 一个超快的产品将会很贵而且缺乏质量。 | 任何微软软件 |
| 费用 | 费用 | 一个超级便宜的产品将会是低质量和耗时的。 | 外包海外开发 |
什么和如何
软件既是具体化的东西,也是抽象的东西。软件开发工作的结果是决定项目是否成功的最明显的方式。在 web 开发的情况下,web 应用中的自定义页面——包括其计算、外观、交互和条件——可以通过使用浏览器与应用进行交互来判断其正确性和完整性。用户体验几乎总是高层和主管们优先考虑的事情,一个页面的表现是证明花了十几个小时或更多时间完成的工作确实完成了的手段。因此,在这方面有一个具体化的概念:当你能在屏幕上看到一个模态时,为一个网页开发一个模态的结果就应该完成了,它的行为正是需求所规定的。这个区域可以被认为是。
软件在外部的行为方式(例如,从最终用户的角度看,它的外观和功能)并不一定表明在内部塑造它的代码的质量或结构。构成用户体验的代码可能非常糟糕,写得也很糟糕(例如,如果标准的安全措施因为时间的原因而被抛弃,或者验证的实践没有在 HTML 表单中实现),但是外部可能会也可能不会实际反映这一事实,因为代码本身与作为代码结果而存在的元素、框架和功能有概念上的差异。应用的这个内部区域可以被认为是 how。
如何在系统中测试通常是最困难的事情。这是因为直到我们开始遇到错误和奇怪的问题时,才知道代码写得有多好,这些问题似乎已经解决了很多次,但仍然出现在应用中。how 包括所有实际的代码,并且是关于代码本身质量问题的答案的真实来源,这并不总是像它在浏览器中显示的那样。你不能简单地看一张表格就断定 CSRF 保护措施没有被用来阻止 XSS 袭击。您必须检查文档并查看表单的代码,寻找某种类型的 CSRF 令牌或编码字符串。
当“如何做”和“做什么”之间相差太远时,问题就会出现。虽然产品经理和执行官通常更关心应用是什么,但是开发团队和工程人员关心的是(或者应该是)如何。例如,如果我们构建的模型看起来和工作起来都很棒,产品经理就会签字同意,对他们来说,这是一个很好的开始。
现在让我们说,在开发过程中,我们在代码中的某个地方留下了一个巨大的安全持有,它只在特定的条件下出现。以这段代码为例:
<?php
if ($request->parameter == 3) {
//to remove!
exec(‘cat .env’);
}
假设在我们构建的模型上有一个表单或输入,在这个表单上有一个带有下拉框的输入框,用于选择和设置表单元素。在开发这个模型时,我们进行了前面的检查,因为我们有兴趣看到(无论出于什么原因)在.env
文件上发出cat
命令的结果(.env
文件是保存您的应用的所有私有配置值的文件,不是存储库的一部分),但前提是名为parameter
的表单变量等于 3。是的,我完全知道这个例子中的代码有多糟糕,但它只是一个例子;不要看太多。
这是一个严重的安全缺陷,它基本上显示了整个.env
文件,其中包含我们所有的数据库密码和我们的应用运行所需的其他秘密数据。我们绝不会向任何用户透露这些信息,所以这是一个相当大的问题。这类似于导致切尔诺贝利核事故的问题。原本可以防止这种灾难的安全措施和实践被关闭,以“测试”系统。当问题真正出现时,它以巨大爆炸的形式出现,从反应堆核心释放出大量有毒的放射性物质。显然,我们的问题是多少损失惨重,但你明白了这一点。
产品经理很容易忽视这一点,他认为一切“看起来”都没问题,因为事实就是如此。然而,在幕后,是一个根深蒂固的问题,这个问题开始时只是为了开发而进行的简单测试,在他们推出最终的软件后并没有得到适当的解决。防止上述情况的责任完全落在开发团队的肩上。在这种情况下,应用的“是什么”(它是包含某种按预期工作的形式的模型)被验证为处于良好的状态,但是“如何”从来没有被另一个开发人员适当地审查过,这使我们对标准和实践产生了疑问,这些标准和实践要么在开始时没有实施,要么在该软件的开发过程中被完全忽略。
Note
作为对这个安全问题的额外思考,考虑现实情况:代码缺乏所有形式的代码审查;如果没有,错误就会被发现。还能做些什么来防止这个缺陷呢?单元测试。如果这个特性有足够的单元测试来覆盖模态逻辑的这一部分(当然应该),那么在合并到主分支之前进行的一个简单的自动化测试就会以一种相当明显的方式指出这个问题(在这种情况下,显示.env
文件的内容)!
如何构建软件来实现它实际做什么是我称为开发的抽象方面的领域。我成为程序员的原因是因为我对解决问题的方式感兴趣。我总是着迷于必须做出的决定,以及如何使用编程语言的工具和功能作为解决复杂问题的手段。这门科学有一定的艺术。不管最终的范例和最佳实践的实现如何,您都应该注意编写足够多的单元测试,以便应用中的每一部分代码都可以被相应的测试覆盖。这些测试作为自动化套件的一部分运行,该套件在每次将拉请求合并到主分支时被触发。一个好的 CI/CD 系统对于软件开发团队来说是无价的。
开发软件需要思维、学习和编码的平衡。代码并不是解决问题的唯一手段,很多问题可以(也应该)通过解决,而不需要*诉诸代码。
Note
代码是最后的手段。如果除了多写代码还有其他可行的方案就不要多写代码了。每一行代码都是必须维护和重构的另一行,每当我们编写代码时,我们都冒着将新的错误引入系统的风险。
DDD 概念的分解
DDD 涵盖的主题错综复杂,深入浅出,红皮书和蓝皮书都有详细讨论。我的目标是让你知道我们将在本书中关注什么,并让你熟悉我们需要覆盖的 DDD 的不同领域,以便使用 DDL 构建一个有用的系统。
请注意,图 3-2 中的图表省略了被称为蒸馏的整个 DDD 部分。这一部分中的概念包括重构策略和如何实现某些模式来逐步淘汰遗留软件,以及其他方法来细化您的领域模型,并获得一个更准确的模型,该模型用无处不在的语言中的适当术语来表达业务。
图 3-2
DDL 概念图
在图 3-2 中,我们有一个三角形的三个点(这三个点与金三角没有关系,只是为了让你知道),它们之间的相互作用用线条描述,并简要描述了它们之间的关系。然而,请注意,它们似乎都与无处不在的语言泡沫密切相关。那是因为它们都应该来源于或者直接使用无处不在的语言里面的物品。
战略设计将包括完成领域驱动设计的策略,以及包括最佳实践和高级架构讨论。它还包含着手设计您的领域模型的过程和方法(比如让一位领域专家在附近澄清任何缺点或复杂的逻辑区域,这些对于进入公司的新员工来说可能不完全理解)。我们在 DDD 的战略设计部分获得的知识将被用于制作和提炼一种正确的通用语言,这种语言将被整个公司使用,以传达实际商业模式中存在的各种含义。这些知识也将用于驱动我们应用的开发(在我们的例子中,使用 Laravel 框架来构建这些概念和过程)。
战术设计是我们需要的所有技术结构和考虑因素,以便建立我们通过位于 DDD 的战略设计支柱中的工具和方法实现的所有不同的对象、策略和结构。我们将使用它们作为一种手段,将无处不在的语言中的对象和作用于这些对象的业务流程转换成允许它们在 web 应用中运行的实际代码。战术设计经常被表达为 UML 图或者其他 OOP 类型的图和图像,以帮助说明创建领域驱动设计中涉及的复杂概念…DDL 也是如此。在我们的 Laravel 应用中,战术设计中的组件将直接对应于它帮助建模的代码。最后,我们的 Laravel 应用将是我们使用战术设计构建的这些对象和流程的实现。
Note
战术设计流程只能在初始知识发现/粗略建模阶段完成后才能完成。你只能从战术设计的角度开始对领域建模(例如,使用实际的代码结构*,比如仓库、工厂、实体、集合等等。)在您至少对您试图用代码重新创建的真实世界系统有了部分了解之后。这本书的内容将以这种方式工作。*
在图 3-1 的第三个气泡中,我们有我们的 Laravel 应用。这个应用将是许多关于系统核心功能和需求的讨论、会议和计划会议的结果。它将是与我们正在构建的组件或系统有任何联系的几个(如果不是全部)部门协作的产物。
领域建模的过程
人们常说,领域建模与其说是一门科学,不如说是一门艺术。这当然源于软件独特的流体状态。它的不断扩展的结构和可延展的趋势提供了无限的可能性,这只有在存在法律、限制、过程和约束的情况下才是有用的,这些法律、限制、过程和约束有助于为工作空间提供某种上下文,并将概念的混合划分到封装的逻辑边界的容器中,这对于程序员来说更容易理解。
这带来了另一个好的观点:从本质上讲,软件开发的复杂性只对我们这些阅读和操作它的人来说是不同的,而对实际执行和运行它的机器来说不是。我们在领域设计中建立的区别、封装、清晰度、符号、规范和描述,以及代码中相应的实现,都是专门为人类消费而制定的。另一方面,从理论上讲,机器更愿意被源源不断地输入 1 和 0,这样它们就可以做它们被创造出来要做的低级处理。他们不关心函数名或类名称空间结构,它们是为了我们开发人员在以后修改代码时阅读和洞察而存在的。
在开发应用时记住这一点是有好处的,因为我们需要善待下一个看代码的人。当我们忘记对doSomethingOdd()
进行额外的调用时,它也可以作为一个很好的提醒,直到我们看到这样一个冗长的注释:
/** !!!!IMPORTANT!!!! DO NOT, NOT CALL THIS FUNCTION! ITS MANDATORY*/
$this->value = doSomethingOdd() . doSomethingOdd();
信不信由你,这是我参与的一个真实项目的样本!它完全没有意义,并且给下一个必须阅读它的人增加了许多困惑。
变量、类和对象的名称可以让开发人员了解代码中发生了什么,根据复杂性,可以写在每一行来描述每一行的作用。它们或多或少是解释性的注释,有助于用简单的英语描述过程的细节。虽然注释非常有用,但是过度使用注释会破坏代码,降低可读性和开发速度。但评论绝不是无用的。不管你喜不喜欢,随着新功能的产生和新问题的增加,即使是最小的应用也总是面临着复杂性增加的风险。最后,找到注释和它们所描述的源代码之间的平衡是很重要的。
不要做什么
构建应用时最糟糕的事情就是错误地管理复杂性——无论是高估还是低估。当复杂性在项目中具体化时,试图避免复杂性的做法被证明与过度设计软件来处理预期的复杂性一样糟糕。对于后者,往往会发生的是你浪费了宝贵的开发时间来创建一些可能是或可能不是未来实际关注的东西,而你本可以将这些时间花在真正的应用需求上。除此之外,当第一次对一个“可能”的系统建模时所做的假设和前提条件很可能已经随着对底层业务规则(域模型)的细化和更新而改变了。只有在扔掉了大量系统无法使用的构建代码之后,您才意识到您可能必须完全重写系统的这一部分,因为您构建的解决方案不再满足新的问题范围。
避免复杂性是一种在开发过程中会出现的现象,并且通常开发人员没有意识到这一点,至少一开始没有。最初,开发人员从良好的意图出发,坚持最佳实践和良好的架构结构,但往往有些犹豫,并违背这些最佳实践(在某些情况下通过引入反模式)。但是,如果解决方案实际上有效,它就会被发货。如果团队定期参与代码审查,并不断努力完善和改进核心业务模型,这不是一个问题,因为他们最终会重构代码,并实现比第一个版本更好、更有知识的解决方案,而第一个版本只是第一个工作的东西。
然而,当重构不是开发软件的标准实践时,这些问题将会表现为一大团泥巴,上面充斥着陈旧过时的评论,通常不会让读者更好地了解实际发生了什么。在一些极端的情况下,逻辑可能会达到这样一个程度,即在同一个文件、类或模型中包含许多不同的关注点的整体结构中,被强加了太多的复杂性。如果没有大量的重构工作,增加复杂性甚至不再可能,重构工作通常会持续几周或几个月。
从本质上讲,这种可怕的情况会变成现实有两个主要原因第一个原因是没有遵循最佳实践。第二种情况是,在没有足够的领域知识和经验的情况下构建解决方案,而这些知识和经验对于正确地对业务领域(以及那些模型上的操作)建模是必要的。
如果从事软件工作的人没有正确理解软件背后的商业概念,并且没有投入精力为这些概念建立概念基础,他们会不顾一切地尝试任何可能有助于完成他们需要做的事情的解决方案。当这种情况发生时,代码会被随机地插入到各处,以测试各种东西或转储出只在特定条件或特定状态下实际存在的变量值(由于 PHP 和 web 开发的无状态特性)。测试变得如此复杂和繁琐,以至于测试本身在某些时候几乎被完全忽略,在系统代码库的测试覆盖范围中留下了一个大的缺口,并慢慢地恶化了 CI/CD 管道以及您的 DevOps 工程师花费数周时间准备的所有酷东西,因为它们完全无用。
如果发生这种情况,它几乎总是指向缺乏清晰的边界,通常是由于在领域级别上违反了关注点分离或者使用了在通用语言中没有正确定义的不正确的组件。
当“调试代码”的概念等同于依靠旧的“dump and die”或print_r($var);die;
命令来戳代码并试图弄清楚在代码执行中的这个特定位置发生了什么时,您知道您正在处理这些地狱般的应用中的一个,因为环境是以这样一种方式制造的,任何错误输出都几乎不存在,更不用说缺乏任何正确调试代码的手段。
处理复杂性
领域架构完全是关于在复杂性出现时处理它,并且首先将简单的实例放在适当的位置,随着获得关于业务领域的额外洞察力,对那些实例的关注和积极的改进。DDD 有各种技术和步骤来帮助我们开始为我们的应用建立一个坚实的基础。
DDD 流
我发现在我的日常 web 开发项目中实现 DDD 的最好方法是遵循这个一般过程。请记住,这些步骤与 DDD 的技术方面无关(我们将在后面讨论)。
-
深入了解你正在工作的领域。让自己沉浸其中,这样你就能对该领域的问题、解决方案和架构了如指掌。例如,如果你在医疗计费行业工作,你可能已经记住了 CPT 代码,并且可以背诵医疗病人资格地址。如果你从事金融行业,你可能知道贷款是如何构成的,以及如何在资产的生命周期内分配成本折旧。
-
与领域专家一起创造一种无处不在的语言。由于你很可能而不是在你工作的领域里(特别是如果你刚刚开始你的 web 开发生涯),你将需要依靠专家来构建一套通用的、公司范围的术语和定义,以及关于它们之间可能存在的关系的准确描述和文档。
-
根据您在第 1 步和第 2 步中获得的派生学习和对系统的部分理解,按照企业解决方案空间描述的以及您最初理解的那样,构建系统的粗略设计。即使最初的设计有缺失的部分,或者最初没有清楚地指出分隔各个部分的界限,也要获得某种基于核心领域模型的概念化设计,并用无处不在的语言进行描述。
-
**经常重构和细化模型的设计,很快失败。**因为我们重视敏捷开发实践,并且热衷于迭代开发,所以我们根据对业务领域的理解不断更新我们的模型。领域模型的设计应该总是反映领域本身最新获得和接受的知识。我们通过“快速失败”来做到这一点如果我们在开发某个系统时尽可能快地失败,我们被迫同样快地修复这些失败,每一个都丰富领域模型并精炼其结构(即使作为如何而不是做某事的一种方式)。
-
每当获得对领域模型的新见解时,重复步骤 4,并创建一系列可重复的步骤,这些步骤可用于持续地将领域变更部署到您的生产环境中(CI/CD 管道)。有一个好的持续部署管道可以让我们在对领域的概念理解和实现它的代码之间保持一定程度的连续性。
每隔一段时间,往往会发生的是,在领域模型本身(一个过程,一个假定属性的重新定义映射,或者甚至是一个无处不在的语言中的项目的错误标识)中,将会有一个重要的被忽略的步骤的实现,它将大到足以强制进行重大的更新或重构来适应。它最有可能是对一些不相关的事情发生的认识,例如,一个事件被激发的效果,它使侦听器分散在多个有界的上下文中。喊出“重构我”的变更是首先要考虑的,因为它们通常掩盖了真正的深层含义,并且可能没有在当前实现的领域中得到充分的体现。
对领域的每一次细化都是创建一个领域的又一小步,这个领域代表了业务的核心价值,反映了它的沉着,并概念化了它存在的本质。这意味着应用的每一次更新或更改都很重要,因为每一次更新或更改都代表了对业务模型的一些洞察或理解。
深入你工作的领域
如果有的话,你很可能对你刚被雇佣参与的领域知之甚少。你很可能熟悉软件开发(至少我希望如此,如果你真的得到了一份开发工作的话),并且掌握了各种模式、框架、工具和习惯用法来帮助管理逐渐进入应用范围的复杂性。尽管如此,很有可能你对你工作的核心业务领域知之甚少。
我鼓励你最大程度地拥抱你公司的领域。你的目标应该是最终成为领域专家,并获得业务领域模型的深入知识。那么你将被认为是你工作的公司的资产,并且处于一个很好的位置来为该公司设计领域驱动的设计。
如果不是这样,你不知道你在做什么,你将很难为任何项目实现领域驱动的设计。毕竟,当您对领域模型本身知之甚少甚至一无所知的时候,您怎么可能构建只存在于促进它们所代表的相应业务对象的实现细节呢?这个问题的答案和几乎任何可以想象到的领域的问题的答案是一样的:你受你的领域专家的支配,因为他们是收集关于领域和业务的足够信息的关键,以实际上制作一个代表它的领域模型。与领域专家的交流肯定会有帮助,但是根据领域的复杂性,他们很容易被一个新的开发人员淹没,并且他们可能需要时间来真正掌握信息。
与领域专家一起创造一种无处不在的语言
领域专家最终会成为开发人员最好的朋友,因为领域专家掌握着业务各个方面的真相,通常是在他们的头脑中。作为开发人员,我们的工作是将这些信息从他们的头脑中提取出来,放入软件中,这样知识就可以与应用的所有其他方面共享。从领域专家那里获取信息有一定的方法,我在下面的列表中总结了这些方法:
-
尽量不要用复杂的技术细节和开发人员行话来淹没他们。相反,尝试匹配专家用来描述业务的术语和语言,模仿他们用来描述系统的业务方面的上下文和组件。
-
做好确保澄清任何不清楚或难以理解的概念或商业模式的元素。
-
使用领域专家自己的行话作为形成通用语言的基础,并使通用语言中的结果条目存在于一个狭窄的范围内,以便其定义不会被误认为是业务方言中的另一个术语。
-
致力于建立清晰的领域模型术语定义,以及公司范围内公认的一组细节,这些细节属于使其独一无二的定义。
构建一个粗略的、幼稚的领域设计
一旦您对领域的高级方面有了某种感觉,就用它来创建一个领域模型的框架草图。这将最初是一个天真的尝试,描述业务如何在技术层面上运作,但不要太关心它对于早期版本来说有多小。请记住,领域驱动的设计实际上永远不会“完成”一旦我们为 DDD 准备好了所有需要的东西,我们就可以使用与持续集成/持续部署(CI/CD)相关的技术来确保我们的领域模型不断地被精炼、更新,并且随着团队获得的新的洞察力在应用本身中被描绘出来(通过它在代码中的实现)而变得更好。
Note
最初,您的领域模型可以由写在纸上的各种文档、绘图和注释构成。它可以由你需要的任何东西组成,以便成功地描述业务领域,用模型来满足它。
经常重构(经常失败)
伟大的托马斯·爱迪生在经过大约 10,000 次的尝试,终于成功地发明了一个能用的灯泡之后,说了这样一句话:
“我没有失败。我刚刚发现了一万种行不通的方法。生活中的许多失败都是因为人们在放弃时没有意识到自己离成功有多近。”
—爱迪生
在成功的过程中,失败是不可避免的。重要的是,你从这些失败中吸取教训,并利用这些知识帮助找到可行的解决方案。因此,“快速失败”的概念不是为了成功,而是为了失败。在一次又一次的失败中,你对如何而不是做某事有了一个很好的想法,这会让你离成功更近一步,因为如果有的话,这是一个如何不去做的例子。因此,显而易见的是,失败得越快,你就会越快意识到实际可行的解决方案。
这是敏捷开发中的一个重要概念,因为敏捷的核心意味着不断地给予和接受反馈。信息和知识的流动应该在各个方向自由流动,从开发人员到领域专家,再到高层管理人员。快速失败给你获得洞察力的能力,即使是以不能快速工作的形式,这样知识可以被封装在领域模型和所有使用它的人之中。在这些领域有帮助的行业工具是 SaaS,如 Atlassian 的 Asana,再加上吉拉的任务管理和构建定制文档和操作方法库的 Confluence。其他好的产品有用于设计的 Invision,用于存储库管理的 GitHub 和 Bitbucket,用于数据库导航/设计的 MySQL Workbench 或 Sequal Pro,以及用于团队沟通和协作的 Slack。
技术方面
到目前为止,我一直将我们的讨论集中在 DDD 的战略部分。软件系统的“技术方面”可以被认为是如何并且基本上是一组结构,供您用作工具(模板)以代码形式实现您的领域模型。这些工具包括我们将讨论的各种概念,它们都基于可靠的最佳实践。
在本书中,我将为您提供一些工具,这些工具将有助于使您的代码与业务需求保持一致,从而使软件成为业务本身的翻版。我将向您展示如何依靠您无处不在的语言来构建组件,这些组件将以领域驱动的方式驱动您的应用和软件开发。您将学会关注业务本身的各个方面,并使用这些知识来构建您的初始领域模型的框架。然后,您将使用 DDD 提供的技术设计和实践,在代码中实际表达这些领域概念,并定义对它们的任何操作、使用它们的服务或它们所代表的数据库模式。
框架为你提供了控制器、路径、响应和其他各种各样的东西,这些东西是用来作为工具来促进应用中对象的交互和使用的。这些工具通常采用某种形式的对象设计模式。这些是存在于对象/类级别的模式,它们是通用的。在领域驱动设计中,有许多组件是使用一个或多个这些设计模式来建模的。例如,DDD 提出了以下架构组件来帮助您的领域层中的对象的交互和使用:
-
实体
-
工厂
-
仓库
-
价值对象
-
总计
-
领域事件
-
域服务
-
模块
这些都是重要的概念,目的是通过使用最佳实践,促进领域模型,分离系统的不同关注点,并建立系统中对象存在和交互方式的一些标准。它们足够通用,不会将您工作的实际领域或上下文作为任何特定类型或方式。它们旨在为典型的 web 开发或软件应用项目中出现的常见业务问题提供与领域无关的解决方案。
就领域驱动的 Laravel 而言,它们对我们意味着什么,它们基本上是 DDL 的思想和实现的关键,因此被给予了充分的讨论空间(每个都用了整整一章来介绍和讨论更好的细节)。我们将研究这些并在 Laravel 中创建合理的实现,使用我们将在下一章创建的定制框架。
现在,我将使用下面的项目给你一点我到目前为止所讨论的内容的背景。
示例项目 3-1:仓库管理
这个示例项目将介绍一个虚构的仓库管理应用,我们负责从头开始开发。这个例子将作为一种指南来帮助捕捉软件的意图,获得解决软件问题所需的背景信息的知识,并形成一种无处不在的语言,然后我们将把它转换成一种粗略的架构,使其在现实世界中工作。我们将主要关注我们正在设计的系统的信息收集和高级形态。实际上,直到本书的后面,我们才开始编写使用 Laravel 框架的代码。我在示例中确实使用了 PHP 代码来演示各种概念,但是这些想法在本质上更加通用。在这一点上不需要以前有过 Laravel 的经验(但绝不会伤害!).
在知识收集阶段,我们希望以一种非平凡的、非技术性的方式关注模型。通过最初用简单的英语和流程图/图表对项目建模,我们通过延迟来摆脱设计系统需要运行的所有类、接口和对象图的开销。这允许我们用每个人(不仅仅是开发人员)都能理解的术语对领域建模。然后,我们将为所有这些常见的业务术语创建一个目录;这就是所谓的无处不在的语言。
这个业务术语和概念的目录很重要,因为它是我们将用来对系统的所有相关方面进行建模的文字基础,并且同样地,将被组织的所有成员用来传达业务思想和描述内部操作。这种语言是这个领域的核心。
Note
请理解,只有通过与领域专家和组织的其他成员进行无数次的讨论、辩论、研究和规划会议,才能完美地定义术语表中的项目。我在现实世界中看到的一种方法是让每个部门的一个人与领域专家和开发人员在同一个房间里。神奇的事情会发生。对于这个例子,我省略了这个方面。
需求概述
我们将建立一种仓库管理系统。假设我们有一个客户,他希望实现日常流程的自动化,以提高效率、发运更多产品并赚更多钱。他们已经有了一个模拟的、古老的系统,结合了印刷纸和手工流程。尽管订单和运输由电子商务平台管理,但该系统缺乏任何跟踪功能或库存管理,因此是手工完成的。该应用的所谓报告功能包括一个彩色的 Excel 电子表格,其中有各种公式和求和,只有在对公式计算中涉及的后续单元格进行正确输入后,才能进行计算。更重要的是,他们最近扩建了一个更大的仓库,这给工人完成新订单带来了新的挑战。该仓库(现在大得多)缺乏适当的产品位置管理解决方案,因此履行流程大大减慢,仅完成一个大订单就需要 10 到 45 分钟。
基本上,我们的工作是创建一个新的系统,为典型仓库的标准问题提供一个全面的解决方案,同时通过设计一个新的位置方案来解决正确的产品位置管理问题,使工人更容易找到产品并完成订单。我们将讨论如下内容:
-
订单管理
-
库存更新/审计
-
订单跟踪生命周期/工作流程
-
-
存货管理
- 项目跟踪
-
仓库中的位置
-
数量
-
保留量:已经被预定的产品数量
-
:产品在库存中准备出售的数量
** 期望:订购(延期交货)的产品数量*
-
-
** 项目生命周期流程/工作流*
** 完成-
挑选和打包
-
运输系统
-
订单履行流程/工作流程*
- 项目跟踪
*这些是我们将在这个例子中关注的中心概念。
订单管理
仓库通过接收订单并向客户发货来赚钱。成功处理和运送订单所需的时间对利润有着重大影响,因此仓库需要准确、可重复和快速的订单工作流,以便实现收入目标。
目前,该仓库有一个接受订单和付款的电子商务平台,因此我们不必关心订单的下达和计费,这使我们可以专注于管理和跟踪订单的履行及其生命周期,从跟踪处理订单时必须进行的库存调整,到挑选和包装订单上的行项目并将其运送给客户。这个过程就是订单工作流程。
需要跟踪订单工作流环境中的状态。订单是在其整个生命周期中经历不同阶段的实体。图 3-3 显示了订单所经历的生命周期的基本流程(基于我们对订购流程正常流程的初始假设)。
图 3-3
订单工作流程/生命周期
基本上,我们有一个相当简单的订单流。你可以很容易地称之为订单生命周期(事件建模为图中的线条)或订单流程,因为它实际上并没有那么重要。重要的是在整个文档和通用语言(UL)中保持名称一致。领域专家在通用业务相关行话中使用的术语和软件模型在 UL 中约定的概念方面必须保持一致。
这通常等同于就使用哪些术语来指代哪些业务对象达成共识,并在整个组织中使用一致同意的措辞和定义。我们不能在我们的 UL 中有重复的定义,主要是因为它增加了不必要的定义副本,这只是系统中可能被错误、误传或误解的一个额外的东西。更简单和更好的方法是清楚地定义一个术语的定义,它不会以相同的形式在另一个术语的定义中共享。
Note
我们最终将在软件中创建的东西应该是生活在业务领域中的所有真实的、标准的对象和过程的镜像。有时,对一个领域进行建模会暴露出需要解决的逻辑或流程中的错误,这样相同的错误就不会出现在代码中。
回到图 3-3 ,订单在在线电子商务系统上发出,仓库收到订单,并在使用自动银行和支付网关插件确认订单付款后,立即将其标记为订单待处理阶段。在此阶段,订单不被视为有资格完成(等待挑选&包装后的剩余步骤)。他们有资格进入提货&包装步骤的方式是,仓库工人核实订单上的产品实际上有库存,并且每件商品的数量可以说明。
一旦发生这种情况,订单就进入提货和包装阶段,在这里它现在位于“提货清单”上,并排队等待在仓库中实际找到商品(也称为提货)并将其放入箱子中运输给客户(包装)。发生这种情况后,可能会有一些额外的验证步骤,以确保箱子中的物品是正确的,并且数量正确,例如在将箱子打包发给客户之前直接进行人工二次检查。除此之外,包裹已经准备好运送,由快递公司(UPS,FedEx,无论什么)提供的日常取件服务运走。此时,订单最终进入订单完成阶段。这似乎是一个非常简单和半完整的模型(至少对于这个例子来说)。
Note
到目前为止,该项目的范围被有意地缩小了,这样我就可以演示如何着手在软件中建模一个领域(或者至少从哪里开始),而不用占用宝贵的页面空间来描述仓库管理系统的每个复杂方面。
存货管理
现在我们已经勾勒出了订单工作流的基本轮廓,我们可以开始研究系统中的更多细节了。在现实世界中,特性和需求可能随时出现,并且在规模、范围或重要性方面完全不可预测。出于我们的目的,我们将不得不在系统中构建更多的复杂性,这将是促进和管理仓库库存的附加组件的形式(因为这是仓库所做的),当然。
这意味着我们必须考虑仓库中的所有物品,以便我们能够正确管理实际数量,并获得问题的答案,帮助做出企业赖以成功的决策。库存管理的一个很好的例子是知道何时订购额外的库存,因为(很明显)如果我们没有任何库存,我们就无法完成订单。负责在典型仓库中购买卡车货物的团队使用该库存管理上下文提供的数据作为一种手段,来确定哪些货物需要在供应商订单上,以及应该订购多少。(这可以通过查看库存管理组件提供的延期交货数量清单以及涵盖过去 30 天的销售报告来确定,该清单显示了产品的近期需求。)
事实上,我们的电子商务团队很可能依赖于某种关于仓库在任何给定时间的库存产品数量的报告或通知,以便他们可以在网站上正确列出产品,并将没有库存的产品标记为“售完”(尽管这将是一些自动化的好地方)。这是一个微小的细节,在实际项目中,需要额外的澄清,因为我们有相同的数据驱动多种需求,这些需求在任何给定的时间都可能与事实的来源不同步,在这种情况下,事实的来源是仓库中实际产品的数量。我们暂时忽略这个细节,稍后再来讨论。
就像前面描述的订单管理组件有一个工作流(或状态机)一样,库存组件也有一个工作流,只适用于一个项目或产品的上下文。这里需要注意的是,库存管理组件之间必须有一些协作。
知识收集是软件建模时最重要的事情之一。它最终塑造了对象的内部结构(关于它们的命名空间),并且是创建可用 UL 的第一步。在这个例子中,我们要稍微作弊一下,假装我们已经和领域专家讨论了足够多的内容来构建一个粗略的 UL。
完成
因此,我们已经介绍了系统的订单管理方面,它将处理订单的生命周期,并跟踪从收到订单到发货的整个过程。我们还有系统的库存管理方面,它保存仓库内所有产品的记录和计数,以及它们的位置(这是一个重要的细节)。
履行将被“吸收”到订单工作流中,该工作流基本上包含最初被列为“履行”的所有项目履行流程只是一个通过其工作流程的活动订单。该工作流程的步骤如下:
-
在订单上找到每个产品。
-
抓取或挑选指定数量的物品(受影响的物品数量将列在“预期数量”下)。
-
将上述物品放入包装盒中,密封包装,并通过快递寄出。
到目前为止,这似乎相当简单。有一点你可能还没有考虑到,那就是第一步的介入。在现实生活中,我在许多仓库中看到货架上产品的适当“地图”或位置参考。通常,这些知识包含在领域专家的头脑中,由于缺乏适当的文档,使得教导新员工变得极其困难。这里有一个痛点。
在我们的例子中,假设这个痛点存在,仓库工人花了太多的时间来找到产品,以便他们可以运送订单。这种情况的根本原因是缺乏一个合适的编码系统或产品地图,以帮助工人快速找到和选择货架上的商品。因此,我们需要能够充分跟踪一个项目在仓库中的位置。
从哪里开始
这看起来像是一下子要处理很多,但是在现实世界中,像这样的需求通常要复杂和详细得多,并且在从头开始创建一个新系统的情况下,它们似乎都是一下子向你扑来的。有时这可能会让人不知所措,我们经常太急于将这种知识从我们的大脑转移到现实世界。在这种情况下,将需求中列出的每一个关注点作为一个单独的组来开始系统的架构解决方案可能很有诱惑力(图 3-4 )。
图 3-4
可能的架构分组
但是请想一想:我们已经将履行从系统的订单管理关注点中分离出来,它们确实是独立的需求,但是它们应该是同一上下文的一部分。在这种情况下,我们要做的是跟踪订单从进入仓库到离开(即发货)的生命周期。生命周期有多个阶段,从技术上讲,整个生命周期(工作流)可以被认为是订单的履行。
更好地理解堆积如山的需求和领域知识并开始区分实际需要做什么的一个方法是找到问题空间和解决方案空间在哪里。
问题空间
问题空间可以被认为是公司想要解决的所有事情。它包含需求,并被分解为域和子域。在我们的例子中,问题空间包含我们的仓库管理软件的三个主要组成部分:订单管理、库存管理和履行。基本上,图 3-5 中的图表可以很好地代表问题空间,或被认为是完整的项目所需的主要目标/验收标准,只要它在一个子域中有两个相似的要求。从现在开始,我们将把问题空间中的这三个项目称为模型的子域。一旦我们做了这样的调整,我们就会明白,事实上,有两个子域可以从需求列表中派生出来。
图 3-5
问题空间
解空间
如果你还没猜到的话,解空间是解决我们之前列出的子域中的问题的结果。为了知道从哪里开始根据项目的需求建模您的解决方案,您可以为每个子域建模一个解决方案组(也称为有界上下文)。在我们的例子中,我们得出结论,这个项目存在两个有界的上下文,每个子域一个(图 3-6 )。
图 3-6
通过子域定义的解空间
Note
一般的经验法则是每个有界上下文有一个子域,但是如果项目不是非常复杂,那么可能不需要所有的子域(每个问题空间都需要一个有界上下文来解决)。
在以这种方式分割项目时,我们可以清楚地了解到需要完成哪些工作(尽管没有任何特定子域优先级的指示)。我们还可以得到一个领域模型结构的基本概念,我们需要创建这个模型来以逻辑和领域驱动的方式充分地解决问题。这意味着努力实现可重用性,并通过细化和重构创建一个丰富、健壮的领域模型,最终建立一个领域的可靠表示,建模为软件组件。
创造一种无处不在的语言
既然我们已经定义了我们的项目需要解决的问题,以及解决这些问题需要哪些解决方案,我们就可以开始为软件创建一种无处不在的语言,这种语言来源于领域知识。
请注意(正如我所提到的),真正的 UL 只有在与领域专家进行了无数次的讨论之后,并且在领域中实现或产生了额外的洞察力时,通过重构业务术语的定义才能实现。
我们需要马上定义两个重要的术语。
-
Order :一个收到的表单,包含客户要求的产品及其相应数量的列表
-
库存:所有每件产品在仓库的位置和可供销售的数量的日志
添加完这些之后,我们还应该添加一些额外的术语。
-
产品:仓库中销售的单品;包含项目的价格、名称和简短描述等数据
-
订单行:属于一个订单的单行,由一个产品和该产品的数量组成
Note
请记住,这些不是我们的域对象,也绝对不是我们将在代码中使用的类,尽管其中一些很可能是,比如一个Order
和Product
。
UL 可以说是软件设计中最重要的事情。它是我们正在构建的整个平台的基础,将在整个公司范围内以定义如何与业务相关的方式为人所知。我们需要随时更新我们的模型,即使这意味着删除或更改 UL 中的术语,以更好地反映它们所源自的业务概念。
实现这一点的方法是不断地将 UL 和域模型作为一个整体进行重构,以最好地表示业务操作中涉及的业务对象和流程。即使在每次冲刺和每次拉取请求中,我们也在不断地重构应用,使其变得更好。我们希望我们的 UL 也是这样,这就是为什么我会在我们进行的过程中对它进行修正和修改。
定义要构建的内容
因此,我们有一个粗略的 UL,它似乎反映了用于描述问题空间和解决方案的总体措辞。它们在 UL(可以是文档、电子表格、纯文本文件或纸张)中清楚地表达出来,并且用领域专家和开发人员已经同意的术语来定义。那全是肉汁!
下一步是了解为完成应用所需的特性而发生(和需要发生)的操作。我们可以利用这个例子开始时给出的需求,用它们来描述我们希望应用做什么,以及它应该如何做。最好用简单的英语组成完整的句子,用无处不在的语言来描述每个需求。我们从用户的角度出发来形成句子,以捕捉他们的需求(这些被称为用户故事)。
-
作为一名运营助理,我希望能够快速导航到产品在仓库中的位置,并能够快速识别货架上的产品,以便准备好订单进行发货。
-
作为一名仓库管理员,我希望能够在仓库中找到一件商品,并以一种非侵入性的方式调整它的库存,这样我就可以让每一个正在增长的仓库都有存货。
-
作为销售代表,我希望能够快速识别产品及其库存水平,以便与客户协调订单。
-
作为运营经理,我需要实时跟踪产品数量,包括我们当前拥有的数量、从供应商处订购的数量以及正在订购的数量,以便我可以解决业务问题。
浏览这些用户故事,我们可以对我们将要创建的功能有一个很好的想法,并且可以开始按照领域驱动设计中使用的一些可靠的模式来组合一些实际的架构。
图 3-7 显示了它们在我们之前的有界环境图上的位置。
图 3-7
从用户故事到有界上下文的映射
工作流程/生命周期
似乎我们需要定义两个生命周期工作流:一个用于订单上下文,另一个用于库存上下文。我们需要的是一种通过这些转换来跟踪订单状态的方法,以便在状态(阶段)之间创建一个“流”。我们要做的在计算机科学中被称为工作流网,它是 petri 网的衍生物。
工作流由状态以及作用于一个状态到达下一个状态的事件(即转换)组成。转换对我们很重要,因为它们包含了对于底层实体所处的特定状态,哪些转换是有效的规范。它定义了状态和转换如何交互,并且是我们将用来控制我们的工作单工作流的。
订单工作流程
在本章的开始,我给了你一个工作流程,Order
对象将从头到尾经历这个流程,它非常适合我们的状态和转换模型,因为它们已经被分解了。在订单工作流的情况下,我们可以假设以下状态:
-
已下订单
-
订单待定
-
挑选和打包
-
订单已发货
-
订单完成
对于每个状态,我们需要指定可以对其执行的事件或转换,以前进到下一个状态。这里有一个例子:
-
从阶段 1 进入阶段 2 需要支付费用。
-
从阶段 2 进入阶段 3 意味着产品已经确认有货。
-
从第 3 阶段进入第 4 阶段需要将订单从货架上取下,并装入客户会收到的箱子中。
-
从阶段 4 转到阶段 5 将表明订单已经通过快递卡车成功发送给客户。
我们也将使用这种基本格式来创建产品的生命周期。
产品工作流程
简而言之,产品的生命周期始于产品在接收码头被接收,产品在接收时被登录到系统,该产品的正确数量条目将被更新(但仅当该产品已经在系统中时)。如果是,我们可以假设已经有一个与之相关的量。让我们假设这些数量的计数是正确的(即,预期计数的数量是准确的),这意味着我们将从系统帐簿中的预期数量计数中减去收到的数量,然后将该数量添加到该产品的可用数量计数中。
如果产品对系统来说是新的,我们必须在系统中为该产品创建新的记录,然后将收到的数量添加到该产品的可用数量计数中。我们可以马上看到,在系统中创建一个新产品并不像接收一个已有数量那样简单。我们很可能需要建立一个系统,在那里接收方可以输入新产品的规格,并可以将其添加到库存日志中,以供将来跟踪。现在,我们将忽略这种复杂性,只假设进入仓库的每个产品都有一个现有记录。
之后,产品被放在一辆手推车上,仓库保管员用它将产品放在仓库中各自的位置。它将在货架上以“可用”的状态等待,直到收到一个订单,该订单中有该产品的一行。此时,在操作人员从货架上拿起产品后,数量会进行调整,并将产品的最终状态标记为“产品已售出”图 3-8 提供了产品工作流程图。
图 3-8
产品工作流程/生命周期
Note
这将是一个很好的机会来停下来,将我们在过去几页中建立的产品工作流和订单工作流这两个新概念,以及这两个概念的简短而准确的定义,添加到这种无处不在的语言中。
完成
这列在软件要求中;我们之前决定“履行”只是一个订单通过其典型的工作流程。我们在订单约束的上下文中捕获了对履行的关注,因此我们可以从 UL 和系统中消除履行的概念,而只使用“订单工作流”来表示相同的东西。系统中的对象越少,我们在以后调试时需要跟踪的东西就越少。
提货和打包阶段也是如此,它属于订单上下文,甚至存在于订单工作流本身。因此,我们可以将挑选和打包的行为放在域工作流中,但是围绕挑选和打包阶段的数据值得探究。图 3-9 显示了我们的上下文和它们支持的特性的图表。
图 3-9
有界上下文
产品位置
如本例前面所述,仓库工作人员面临的一个棘手问题是,他们要花很长时间才能找到仓库中数千个货架中的一个。作为一个小练习,我们将提供一个合理的解决方案,它将有助于确定任何给定产品的确切位置,以便挑选和包装。这是一个不使用代码提供问题解决方案的例子。记住,代码是最后的手段。
为了给这个例子添加一些上下文,让我们假设这个仓库运输鞋子。该仓库占地 10,000 平方英尺,其中约 8,000 平方英尺用于存储产品。我们需要一种方法来快速识别仓库中的一只鞋。这意味着我们需要某种类型的编码方案,附加到每个产品上,指定它在仓库中的位置。
Note
在本文中,我交替使用术语鞋和鞋来表示一双鞋(显然,我们不需要一双单独的鞋…除了可能扔向政客之类的)。
首先,让我们更深入地思考这个问题。鞋子都需要在合理的时间内定位,所以我们显然必须组织鞋子来分解这个组织问题。基本上,我们需要将仓库和订单处理这个大问题分解成更小、更易管理的工作单元。一种方法是通过观察领域模型中概念自然分离的方式。我首先想到的是性别。鞋子的性别是分割库存最明显的方式。看似简单直接的分离,就是直到我们发现某些鞋品牌只做女鞋,有些严格意义上的男鞋,有些两者都有。
Note
目前,我们不打算考虑鞋子的模型,这在像这样的真实世界项目中通常是必须考虑的。我们只是暂时推迟它。
我们仍然可以让性别分离起作用,这将会给我们类似图 3-10 的东西。
图 3-10
按性别和品牌分隔仓库
唯一需要注意的是,单一品牌可能会有重复的位置,这对于企业来说可能是问题,也可能不是问题,完全取决于其特定需求。无论是哪种情况,我们都将依靠与领域专家的对话来确定这个仓库方案对于公司的日常运营是否可行。
领域专家告诉您,我们所描述的布局将会起作用,因为性别是附加到任何鞋子上的,所以我们可以使用它根据鞋子的性别来划分仓库。只要有一个足够的系统来跟踪和标记鞋子在仓库中的位置,重复的品牌就不会有太大的问题,这取决于品牌和性别。
身份
因此,我们需要拿出一个易于阅读的仓库示意图,将每只鞋分开,这样就不会有两只鞋有相同的标识符。我们可以假设需要有一个与每只鞋相关联的内部密钥形式的身份,该内部密钥可以在系统的其余部分中作为一种识别手段使用。让我们给鞋子一个内部生成的身份。
在思考了这个生成的身份之后,你就有了智慧的储备。这里我们需要的是一个条形码系统!这将允许我们通过以条形码的形式给每只鞋分配一个身份来跟踪每只鞋。为了确保唯一性,我们决定使用 UUID 方案,该方案可以使用第三方包轻松转换为条形码(要查看可用选项,请在谷歌上搜索 Laravel 条形码生成器)。
所以,条形码系统是正确的选择。然而,有一个问题。UUID 方案根本不是人类可读的,因为它的标准格式是 16 进制的 32 个十六进制字符(这里有一个例子:123e 4567-e89b-12 D3-a456-426655440000)。那并不完全是从舌尖上滚出来的。如果(不管出于什么原因)我们需要在没有条形码扫描仪的情况下找到一只给定的鞋*的位置,这也会引起一个问题。*通常,我们会发明某种 UUIDs 到货架上位置的内部映射,这只能通过扫描条形码并从内部数据库中检索产品位置来推断。简而言之,它将使鞋子的位置直接与条形码扫描仪相连。对于某些人来说,这可能是一个好的解决方案,但是对于这个例子,我们可以做得更好。
我们想要创造一种标准的方式来识别仓库中的鞋子,这种方式既可以扫描条形码,也可以被人类读取。我们放弃了 UUID 的实现,而是决定创建自己的实现。该方案必须有性别、品牌和尺码信息,以清楚地表明这是什么鞋。然后我们可以将这个代码与仓库货架联系起来,每个货架代表一组特定的鞋子。
我们很快就拼凑出了编码系统的草图。
(gender: m/f) - (brand: first 4 letters) - (size: 2 digit integer)
这似乎涵盖了几乎所有的要求。它内置了三个数据点,没有条形码扫描仪的人也能轻松阅读和理解。唯一的问题是,相同性别和尺码的同一品牌的鞋子会有完全相同的身份证号码。这是不好的,因为我们希望唯一地标识每只鞋。
考虑到这一点,我们对编码方案进行了修改。
(m/f) - (brand: 1st 4) - (size: 2 digits) - (Unix timestamp)
我想我们可能有一个可行的解决方案。通过将 Unix 时间戳添加到 ID 号的末尾,我们保持了一种独特的方式,除了保持人的可读性之外,还可以将一只鞋与大型仓库中的所有其他鞋区分开来。
图 3-11 显示了一双鞋的分解识别号。(我意识到最后一句话在任何其他背景下都毫无意义,但在这里却行得通。)
图 3-11
每只鞋的唯一标识符的分解
Unix 时间戳保证了每双鞋都需要我们的唯一标识号,至少虚拟地说是*,因为可能有两个相同的新产品在同一时刻被添加到系统中,从而产生相同的唯一标识符。然而,这种情况在现实生活中发生的几率很小,不足以承担这个风险。这很可能永远不会发生。*
*既然我们对进入系统(即接收)的每双鞋都有了一个标准身份,我们需要将这个字符串转换成一个条形码,以便于扫描和检索。
生成条形码
因为条形码只是用来区分每件产品,所以所有的工作基本上都已经完成了,因为我们已经为鞋子创建了编码方案,可以唯一地识别它们。现在,开始将该计划付诸实施所需的全部工作就是从该身份信息生成条形码。一种方法是使用 QR 码(或“快速响应”码)。
二维码是通用的,受到很好的支持,可以保存我们想要的任何数据,包括一双鞋上的识别字符串!在这一点上,我们不会过多地讨论实现细节,但是如果您有兴趣,您可以在 Google 上搜索 Laravel QR 条形码生成器,以找到一个支持库,该库将自动处理从身份到条形码图像的转换。这是像这样的系统在生产中工作的最终要求,主要是因为条形码需要在识别时打印出来,并实际放在鞋盒上,所有的数据点都包含在识别号中。图 3-12 显示了翻译标识符 M-VANS-105-156756631 产生的二维码。
图 3-12
从字符串 M-VANS-105-1567566317 生成的条形码图像
这是一个非常方便的解决方案,基本上可以应用于任何需要跟踪或说明的事情。最棒的是,你可以将任何数据编码到这个条形码中!无论你用什么来生成图像,都可以在你用条形码应用扫描后被解密。(您可以将手机用于开发目的,但在现实世界中,出于性能和人体工程学的原因,您可能需要购买一些专业的 3D 条形码扫描仪。)在我们的例子中,我们可以使用前面创建的身份编码方案来生成一个有效的、人类可以理解的、唯一的字符串,该字符串可以被扫描仪转换和反转。
消除隔阂
到目前为止,我们有以下内容:
-
每只鞋的唯一标识符
-
一个格式良好的字符串,表示人可识别的标识符,其中包含各种信息(性别、大小和品牌)。
-
一种创新且经过深思熟虑的读取条形码的方法
我们一直忽略了什么?实施细则!
到目前为止,还不清楚条形码系统在实践中如何工作。具体来说,我们在实际运行系统后端的软件和它们所基于的业务规则之间存在持续的脱节。我们需要详细说明货架组织的细节,以及我们如何使用条形码系统来管理这些细节。
搁置系统
我们决定的搁置方案需要支持人类可读性,就像我们的产品标识符一样。为了让系统自主工作,我们需要一种方法来将给定的货架与仓库中的一部分产品关联起来。在我们的案例中,已经通过性别和品牌进行了细分。我们只需要我们的仓库在物理上反映这个限定符。
让我们根据鞋企在货架上的位置,用理论数量来划分仓库(图 3-13 )。
图 3-13
仓库结构草图
这可能行得通,但有人可能会说,你不知道任何特定品牌的多会占据更多空间(如果有的话)。如果公司是新成立的,这将是一个合理的担忧,但实际上,你如何知道在任何给定的时间内,任何特定品牌将有多少在仓库内,以及你应该以什么间隔将品牌彼此分开?
鉴于我们正在进行的“代码作为最后手段”的教训,我认为这个问题可以通过查看去年的销售报告并估计大致相同水平的库存来轻松解决。
图 3-13 中的草图描绘了按品牌划分的仓库货架(实际上不止四个,但请配合我的工作),空间为所有品牌平均分配。这可能是一个解决方案,但是性别呢?我们在条形码标记中有性别,所以我们可以用它来区分实物库存。见图 3-14 。
图 3-14
按性别划分的品牌
如图 3-14 所示,品牌本身分为男鞋和女鞋。这给了我们所有可能的产品组合(假设只有四个品牌)。
真实世界的场景
你是一个仓库保管员,有一批鞋子要收货。收货人负责将物品及其数量记录到系统中,这意味着每个箱子上都有一个条形码供您扫描。然而,贵公司的网络开发人员相当聪明,他们在图片上方加入了英文版的条形码。这些鞋子属于以下类别:
W-GLB-65-1567566899
你告诉自己,“没问题,我有这个,”并对自己读代码的翻译版本:“女性地球仪,大小 6.5。”你去指定的货架拿着所有的 Globe 牌鞋子,去 Globe 的女款那一半,用它做什么?
哦不!我们忽略了系统设计中的一个关键细节。我们对货架问题的探索还不够深入。我们停在了一个更粗粒度的解决方案上,这在实现中留下了一个漏洞。不过,这没什么大不了的,因为所要做的只是向系统中添加另一个数据点,作为整理鞋子的额外划分。
我们可以用什么来分隔鞋架上的鞋子,每双鞋子都有,而且每双鞋子之间的差异足够大,因此可以作为一个很好的分隔机制。如果你还没猜到,很简单:大小!
精彩的演绎,华生!我们可以按性别分割每个货架区,每个货架由一个品牌的鞋子组成,并分成不同的“尺寸洞”(坦率地说,它们是什么;我想用我能想到的最直接的方式来称呼它)。
图 3-15 只是对鞋子组织问题的一个更详细的示例解决方案,鞋子的尺寸是鞋子属性的最终区别因素,因此它可以用作进一步分离进入仓库的大量鞋子的手段。
图 3-15
通过鞋码组织个人货架
该图是货架上单排鞋号的布局。大小之间的间隔马上会成为问题,这是一个需要咨询领域专家的细节。
作为程序员,我们可以也确实对我们正在做的工作做一些假设。这对于开发来说非常好。如果出现软件必须考虑的情况,但无法以精确的方式定义或当时未知,则进行有根据的猜测,并用此猜测值填补缺失的空白,直到您有机会回顾并确定正确的结果,这通常是通过与领域专家的进一步交谈来实现的,或者可能需要更大程度的投资来确定。
让我重新表述一下我之前说过的话:假设作为尚未知晓的价值/过程的占位符是完全可以的,假设创建它的团队/个人有定期的代码审查和技术债务消除会议,并为重构分配足够的时间。这些应该定期发生,有宗教信仰。
在与领域专家讨论了每个品牌的每个尺码应该有多大的空间后,你已经明白应该将它们分成不同的鞋号组,如图 3-16 所示。
图 3-16
属于一个品牌的每个货架的鞋号分布
这一尺码分布表明,大多数男性的尺码将在 9 至 11.5 之间。还有一个适合儿童尺寸的地方(在这种情况下,是男孩)。在这种情况下,我们假设组织因素应该是什么,我们的设计是正确的;然而,我们不知道如何以最适合业务的方式对货架系统进行建模,所以我们对其可能的样子进行了合理的估计,然后我们在有时间与领域专家讨论此事时再次对其进行了修改,他们让我们知道应该根据尺寸的流行程度来划分尺寸,我们能够在图 3-16 中正确地描绘出这一点。
结论
这一章占据了相当多的页面,主要是因为我们的示例场景(顺便说一下,我们已经完全解决了)。在编程中,我们经常面临与代码质量、时间估计和开发成本相关的选择。向三角形的四分之一倾斜会导致对另一个的轻视。随着时间的推移,质量、成本和时间总是会成为你能更好估计的问题。
此外,我们检查了一些可能的火灾,这些火灾可能是由于没有正确地维护您的代码库以及没有频繁地参与代码评审而引起的。不重构通常也属于这一类。只有在正确的开发周期中,并且组件设计是迭代的,由小的胜利和“低挂的果实”组成,DDD 模型才能很好地工作将较大的问题分割成较小的部分,使得为其开发解决方案变得更加容易和易于管理。试图将不同的组件混为一谈可能会导致灾难,如果关注点分离的原则对系统整体策略的上下文轮廓有意义的话,应该总是尝试。
我在本章中创建的例子很好地为我在本章第一部分中表达的观点提供了一些背景。我们经历了一个场景,在这个场景中,我们必须设计一个鞋类仓库,该仓库要为运行操作(即,促进订单)正确地设置,正确地跟踪库存,并提供一种组织货架的方法。我们决定使用 3D 条形码形式的技术,在每只鞋上正确地生成一个加密代码,以表明它在仓库中的相对位置。我们已经使入库功能变得更加可行,而且整个流程也更加严谨,以便在现实世界中发挥作用。我们设计的实现的某些细节被忽略了,这在现实世界中是绝对行不通的。通过领域专家的频繁对话和确认,确保您走在正确的方向上。他们是你在大多数领域相关问题上的真理来源。文档也可以根据领域专家对给定概念或过程的想法来编写。我们可以使用逻辑对开发进行有根据的猜测,但是一定要确保定期重新审视设计,并对您正在使用的值和变量(或者您当时选择忽略的细节)建立适当的约束。
顺便提一下,这个例子来自我作为 PHP 开发人员工作过的一家公司。我省略了一些次要的细节,以便更好地关注我在本书中已经讨论过的概念。
Note
我确实遗漏了一个细节,即“挑选和包装”问题,工人们不得不花费太多时间在货架上寻找特定尺寸和品牌的鞋子。我将把这个留给你去思考,并提出可能的解决方案。你应该考虑的是如何在比我们之前设计的架子系统更精细的细节层次上表达鞋子的位置。
现在我们已经有了系统的设计,我们将在以后继续关注技术问题…但是,首先,介绍一下拉勒维尔。***
四、Laravel 简介
在本章中,您将了解各种 Laravel 工具的来龙去脉,当这些工具与最佳实践结合使用时,可以在相对较短的时间内产生显著的效果。正确设置环境很重要,网上有很多教程(或者访问 Laravel.com,阅读安装文档,这很棒)。我们将讨论如何在您的本地环境中安装 Laravel(使用 Composer 和 Laravel 安装程序脚本)。
Note
如果您阅读了安装文档并决定使用homestead
虚拟主机选项,那么您不需要我在本章中概述的安装过程,因此可以随意跳到下一节。
在我们为 Laravel 开发做好适当的准备之后,我们将经历另一个您可能会在某个时候遇到的示例场景。我们将构建一个简单的表单,该表单接受并验证特定类型的文件,并点击定义为路由的各个端点,这些端点将请求转发到控制器,然后控制器将处理生成文件名并将其存储在文件系统中。
我们将复习 Laravel 基础知识,如下所示:
-
路由配置
-
控制器
-
Artisan 用于生成脚手架代码的命令行实用程序
-
请求和请求验证
-
前端和刀片模板
- 设置 Ajax 请求和响应(就前端方面而言,我给出的例子有点过时,但我在本书中并不太关注前端。有关最新和最棒的前端技术的更多信息,我建议您查看 React。)
-
Laravel 的请求/响应周期
-
一个基本的“流程”,你可以重复它来开始你自己的项目
我们将修改 Artisan 工具提供的脚手架,以调整路由、请求、控制器和其他预构建的模板。我们将允许外部通过基于 URI 的路由访问到应用。我们将学习 Laravel 中的请求,包括请求/响应生命周期和传入请求数据的验证。我将向您展示如何通过在控制器方法中简单地输入提示来无缝地验证您的请求,以及如何在请求的rules()
方法中设置适当的验证约束来自动验证整个请求对象。我们希望我们的 API 和应用尽可能地安全,Laravel 有许多优秀的特性,只需(通常)几行代码就可以轻松实现众所周知的安全实践。我将深入探讨这些问题,并讨论一些考虑因素和背景知识,这将有助于您开始理解事情。
你还将了解到 Laravel 的运作方式。如果我们要创建一个使用 Laravel 框架和最佳实践实现的具有模型驱动设计的应用,我们需要全面了解 Laravel 和这些实践。Laravel 将是本章的主题,但是我们将遵循 Laravel 本身所建立的最佳实践。
我已经包括了几个部分,这些部分偏离了一些选择的主题,但是仍然是你不应该跳过的重要概念。这些边栏旨在让您更深入地思考一个特定的概念,包括我们在软件实现方面所做决定的利弊,并为您提供一些想法,以找到以不同方式完成相同任务的替代方法。在 Laravel 中,您可以自由地定制应用的行为方式、做什么以及如何做。当然,Laravel 应用有一个标准的“流程”,它内置于框架本身(否则它就不是真正的框架),但是它提供了足够的扩展点和程序的不同执行方式,允许我们完成几乎任何我们在标准 PHP 中可以完成的事情,只需要通过一个建立在可靠组件之上的明确定义的 API。
在这一章中,我已经省略了雄辩的 ORM 概念;然而,我们将在后面深入讨论口才,因为它是 DDL 中的关键角色。
Note
假设您的本地系统上已经安装了一份 PHP。如果没有,可以在这里阅读如何安装: http://php.net/manual/en/install.php
。
现在,让我们继续学习 Laravel 的设置,并开始学习一些很酷的东西。
为什么是拉弗尔?
在过去的二十年里,我们已经看到了 web 开发领域发生了一些非常极端的变化——在史诗般的规模上。这些以前端和后端进步、系统设计模式以及新的方言和库的形式出现。2000 年,Cake PHP 推出了第一个 PHP web 框架,并在全球范围内对这种全新的推理和设计 web 应用的方式产生了反响。该系统的核心包括各种各样经过试验和测试的设计模式的实现,它本身被建模为 MVC 框架。在我看来,最重要的事情是,这是一种分离架构关注点的清晰方式,允许开发人员开始摆脱创建混杂层(UI+数据+基础设施)的混乱意大利式丛林,将它们塞进一个文件中。
在 MVC 出现之前,大多数 PHP 代码本质上都是过程化的。即使复杂的 web 应用也很少甚至没有层的划分。现实世界需要更好的东西。实现 MVC 模式的决定以新的(竞争的)PHP MVC 框架的形式引起了广泛的反响。这很可能是激发杨奇煜·普朗西开始 Symfony 框架项目的原因,也是促使 Zend 公司构建 Zend 框架的原因。随着 PHP v5.3 的发布,出现了各种更加进化的 PHP 框架。Symfony 2 和 ZF 2 完全改写了他们以前的自我,并接受了 PHP 5.3 中新的命名空间特性。隐藏在这些事情中间的是策划人泰勒·奥特威尔,他炮制了著名的拉勒韦尔框架。
我坚信熟能生巧的理念。也就是说,你从这一章中获得任何东西的最好方法是跟随并练习编写例子。复制和粘贴代码要容易得多(如果你正在阅读这本书的印刷版本,甚至更好),因为通过在计算机上实际尝试并试图获得预期的结果,你会学得更快更好,我也提供了这一点。读代码好,写代码更好。我在这一章中保留了非常实用的内容,以便更好地让你了解现实世界中的事情会如何发展。
使用 Composer 安装
Composer 既是包管理器又是依赖管理器。要安装它,打开一个终端并进入一个新目录。运行以下命令:
curl -Ss getcomposer.org/installer | php
您应该会看到如下所示的内容:
All settings correct for using Composer
Downloading...
Composer (version 1.9.0) successfully installed to: /home/vagrant/Projects/laravel/composer.phar
Use it: php composer.phar
虽然有许多方法可以设置一个新的 Laravel 应用,但是我们将通过 Laravel Composer 脚本来完成。要安装此脚本,请运行以下命令:
php composer.phar global require laravel/installer
这不仅会下载您在composer.json
文件中指定的包,还会生成一个漂亮的小自动加载程序,您可以在一行代码中“即插即用”。
require_once('vendor/autoload.php');
在我们即将设置的 Laravel stub 项目中,这个文件已经包含在系统内部的 public index.php
文件中,所以我们不用担心;只要知道它的存在。键入以下命令来启动 Laravel 安装程序,这将导致安装最新的框架版本(在撰写本文时,它是 6.0):
laravel new ddl
该命令将生成丰富的屏幕输出,如下所示:
Crafting application...
Loading composer repositories with package information
Installing dependencies (including require-dev) from lock file
1/3: https://codeload.github.com/laravel/framework/legacy.zip/f38711564c642ee88a58bf180010a0c7a7ab062e
2/3: https://codeload.github.com/facade/flare-client-php/legacy.zip/a276603dbb7b9b35636d573d5709df5816dd4d2b
3/3: https://codeload.github.com/facade/ignition/legacy.zip/1f92a209c1a5a60f43c5bbff196177810e817095
Finished: success: 3, skipped: 0, failure: 0, total: 3
Package operations: 84 installs, 0 updates, 0 removals
- Installing doctrine/inflector (v1.3.0): Loading from cache
{LONG LIST OF OTHER DEPENDENCIES INSTALLED FROM CACHE OR DOWNLOADED}
facade/ignition RUNS)
Discovered Package: suggests installing laravel/telescope (².0)
(LONG LIST OF OTHER SUGGESTED INSTALLS - IGNORE THESE)
Generating optimized autoload files
(A FEW COMMANDS THAT COMPOSER nunomaduro/collision
(A FEW OTHER DISCOVERED PACKAGES)
Package manifest generated successfully.
Application ready! Build something amazing.
如果这是您第一次安装应用的依赖项,您可能会看到一串Downloading...
行,而不是Loading from cache...
。如果是这种情况,耐心一点就好;有相当多的软件包需要让 Laravel 运行,可能需要几分钟。
您可以通过发出一个ls
命令来调查在文件系统中到底发生了什么。
我们已经在第一章中对 Laravel 目录结构有了一个很好的了解,但是这里还有一些细节:
-
这是我们的应用代码和域模型所在的源文件夹。它是典型的 Laravel 应用中大多数自定义代码的位置。
-
bootstrap/
:它保存了应用的启动脚本和一些类别映射文件。 -
config/
:保存应用的默认配置,并被.env
文件中定义的参数覆盖。 -
这存放了数据库文件,包括迁移、种子和测试工厂。
-
这是一个可公开访问的文件夹,保存着编译过的资产,当然还有 Laravel 的前门,即文件。
-
resources/
:它保存前端资产,如 JavaScript 文件、语言文件、CSS/SASS 文件和刀片模板。 -
routes/
:应用中的所有路线都在这里面;它们是 API 或浏览器特定的。 -
storage/
:保存系统中的所有临时文件,包括应用生成的缓存数据和会话,以及编译后的视图文件和日志文件。 -
tests/
:这包含单元和功能测试。 -
vendor/
:它保存了 Composer 安装的依赖项。
现在,让我们用 Artisan 命令设置应用的其余部分。Artisan 是 Laravel 附带的命令行实用程序,在开发 Laravel 应用时非常方便。.env
文件包含应用和外部包所需的所有值,并被用作覆盖在/config
文件夹中找到的默认配置的手段。
需要注意的是 Laravel 内部的Http
组件的结构。控制器、路由和中间件的概念已经被分组到一个名为应用的Http
名称空间的父名称空间中。位于/dll/app/Http
,分解如下:
-
App\Http\Controllers
-
App\Http\Middleware
-
App\Http\Requests
除了领域模型本身之外,我们还将与App\Http
名称空间中的项目进行大量的交互,所以最好熟悉这个结构以及其中的项目。我建议先浏览一下名称空间中的所有文件,这样您就可以了解 Laravel 是如何构造的,以及它是如何处理 HTTP 请求的。
项目 4-1: Laravel 文件上传
Note
在完成示例项目(以及本文中的所有项目)时,我建议花些时间将源代码输入到我们在本章前面设置的本地环境中。我不建议您键入注释每个类和方法的 PHP 文档块,因为这些是由 IDE 生成的(特别是 JetBrains PHPStorm,我强烈建议您尝试一下)。VSCode 是微软开发的另一个 IDE。也不值得输入所有的评论,除非你觉得有必要这样做。还要注意,在本文的任何示例中都没有使用或注释;这是有原因的,我稍后会解释。
在本教程中,我们将构建一个简单的应用,它将完成以下任务:
-
通过 HTTP 路由显示 web 表单。
-
提供一种在客户端通过 Ajax 提交表单的方法,并向用户返回一个有效的响应,指示成功或失败以及保存文件的名称。
-
创建一个表单请求对象,处理传入数据的验证(包括任何附加文件的验证),然后将这些数据注入控制器。
-
从请求中提取文件,生成文件名,并以新的名称将其存储在文件系统中。
-
在客户端,表单提交请求的响应将通过 Ajax 发送和接收,响应将被解析,并在成功或失败的事件中适当地通知用户。
Note
对于这个例子,我没有考虑数据库级或用户级的安全问题。在现实世界的应用中,这些类型的事情应该在任何时候都作为最高优先级*。*
*表 4-1 描述了我们对付这个怪物所需的各种组件。
表 4-2
我们的文件上传示例应用的路由配置
|
路线名称
|
URI 路由
|
目的
|
| — | — | — |
| upload
| /upload
| 这将是显示我们上传文件的 web 表单的主页。 |
| process
| /process
| 这是表单(位于/upload
)开始提交数据到表单action
值内路径的地方。 |
| list
| /list
| 这将列出系统中所有上传的文件。 |
表 4-1
文件上传演示应用所需的组件
|
组件类型
|
组件名称
|
描述
|
| — | — | — |
| 途径 | /upload``/process``/list
| 定义客户端使用的端点,这些端点指向位于指定控制器(在本例中是UploadController
)上的某个方法 |
| 模板 | upload.blade.php
| 保存 web 表单和用于提交表单和检索响应的 Ajax 调用 |
| 表单请求 | UploadFileRequest
| 用于描述表单的预期内容,还验证数据,并提供添加自定义身份验证逻辑的机制 |
| 控制器 | UploadController
| 处理请求/响应周期 |
配置路线
Laravel 中的路由基本上是一个 URI 或端点,允许通过已知的 URL 地址从外部世界到特定控制器方法的通信,并且可能包含 URI 参数(查询字符串)。它基本上是一个客户端向一个定义的路由发出请求,该路由根据一组要求验证表单数据,并将请求转发到控制器上的适当位置,然后控制器委托用于存储上传文件的逻辑,并返回某种类型的响应,指示请求成功(成功或失败)。
一个路由可以是自包含的,具有在传入的闭包函数内完成请求所需的逻辑,该函数运行并返回给客户端,或者一个路由可以简单地转发给控制器上的特定方法,该方法接受请求并返回响应。使用闭包定义的路由也是一样,但是最大的区别是不能使用 Laravel 的缓存工具来缓存基于闭包的路由。您还可以声明能够被路由组中定义的名称空间命中的特定 HTTP 方法。参见https://laravel.com/docs/6.0/routing#basic-routing
Laravel 文档中的路线部分。
Laravel 提供了一种方法来限制给定的路由只能通过特定的 HTTP 动词来使用,通过中间件将运行时逻辑附加到路由组,并用易于记忆(和键入)的名称来标记任何路由,这样在代码中引用不同的端点时就不必记住长串的 URL。它还为我们提供了许多关于如何构建路由和定义路由级参数的附加选项——这个组件有很大的灵活性。我建议查看文档,了解路由组件开箱即用的强大功能。整个应用的路由配置位于两个中心文件中(但是可以定制成任何您想要的结构):routes/web.php
和routes/api.php
。
在本书的后面,我们将通过将每一组定义的路由分离到我们的域模型中的相应模块中,来重新构建整个 Laravel 框架的目录和名称空间映射,使之更加领域驱动。我们将使用我们的域模型中定义的模块作为路由的直接“组”。在 Laravel 中,由于路由组件提供的健壮性和灵活性,这项任务变得很容易。
Note
如果您阅读了安装文档并决定使用homestead
虚拟主机选项,那么您不需要我在本章中概述的安装过程,因此可以随意跳到下一节。
正如我前面提到的,路由不仅可以转发给控制器方法,还可以用*回调函数自定义。*想法是一样的:运行回调函数中的逻辑并返回某种类型的响应。下面是一个定义为闭合函数的简单路径示例:
Route::get('/uri/something', function() {
return 'You are at the path : /uri/something';
});
这是最简单的路线。它匹配 URL 字符串/uri/something
并返回一个简单的“你在路径:/uri/什么地方。”你可以这样想,有一些 URI,在调用时,将返回回调函数的任何结果计算。它提供了与使用控制器相同的请求处理,并且它与使用控制器来定义特定路由的逻辑一样方便,所以实际上是由您和您的团队来使用。然而,我建议使用控制器来定义您的路由运行的逻辑,以便它与路由定义本身相分离。
如果您和我一样,不喜欢在一个(或多个)路由文件中混合路由配置和控制逻辑,您可以选择让路由将请求分派给控制器,在控制器中,您可以添加任何其他验证(稍后我们将看到)或定制逻辑,您需要这些来生成对请求的正确响应。
Route::get('/uri/something', 'SomethingController@something');
我个人认为这是一个更好的方法,因为它没有用业务逻辑膨胀路由文件,而是使用控制器和路由文件方法,如下所示:
/**
* Something Controller - something()
*/
Public function something()
{
return 'You are at the path : /uri/something';
}
这实现了与闭合定义的路线相同的效果。
Note
当将请求路由到控制器时,关于路由应该被指定的方式有其他方法和理论。例如,Symfony 的做法是将您的“控制逻辑”和您的路由定义放在一起,但作为注释存在于控制器中,注释是代码的注释,可以被解析和使用。在这方面,将它们放在一起是有意义的,因为它们在不同的上下文中,而不是在 routes 文件中。我的观点是,它使用注释来验证参数和传入数据的方式太冗长了。Laravel 提供了一个比这更好更干净的解决方案,我将在本章后面演示。就这一点而言,注释极其丑陋,使代码看起来就像一开始就被注释掉的一堆乱七八糟的垃圾,所以乍一看并不总是很明显它们实际上在做什么。
我们有多条路线可以定义,做不同的事情。尽管不鼓励使用控制器来容纳核心域逻辑,但是将它们分组在一个控制器中可以保持关注点的清晰分离。记住,业务逻辑的适当位置是在域模型中;控制器位于域模型之外,负责通过请求与客户机“握手”,将需要完成的任何工作分派给其他组件,并返回指示请求成功或失败的响应。此外,最好使用控制器来定义路由的行为,而不是内联闭包函数,因为 Laravel 中的路由只有在路由定义中没有内联回调时才能被缓存。
路由文件
路线存储在项目根目录下的/routes
文件夹下的文件中。默认情况下,有几个不同的文件对应于应用的不同“侧面”(术语侧面来自六角形架构)。这些边一起形成了一个边界,将应用的业务逻辑封装在一个假想的形状(可能是六边形)中,域模型位于该形状的正中心,所有进入该形状的请求必须使用某种类型的适配器,以允许它们跨越边界进入应用。
在 Laravel 中,有一些开箱即用的不同路由文件。
-
这是面向公众的基于浏览器的路由。这些是最常见的,也是网络浏览器会碰到的。它们通过 web 中间件组运行,还包含 CSRF 保护功能(有助于抵御基于表单的恶意攻击和黑客攻击),并且通常包含一个“状态”线程(我的意思是它们利用会话)以便在请求之间持久化数据。
-
api.php
:这有对应于一个 API 组的路由,因此默认启用 API 中间件。这些路由是无状态的,没有会话或交叉请求内存(一个请求不与任何其他请求共享数据或内存;每一个都是自封装的)。API 路由通常位于授权机制(如承载令牌)之后,必须包含在每个请求头中。 -
这些路径对应于定制的 Artisan 命令,我们运行这些命令来生成 Laravel 组件的框架。稍后,我们将创建我们自己的 Artisan 命令,这些命令可以通过命令行运行,以构建许多快速且肮脏的过程,这些过程可以被调度或发送到消息队列。
-
channels.php
:注册事件广播的路由。我们不会在本书中过多地讨论事件广播,但是 Laravel 确实为它提供了内置支持。要了解 Laravel 中事件广播的更多信息,请查看文档。
此时需要关注的主要文件是特定于浏览器的文件web.php
。默认情况下,已经定义了一个路由,当用户导航到应用的 web 根目录(web 根目录位于公共目录中)或主页时,这个路由会被正确命中。值得一提的是,特定于 API 的路由并不适合返回一个要在浏览器窗口中呈现的完整 HTML 页面。API 通常有更简单的响应,通常由 JSON 编码的字符串和元数据组成,比如状态代码、可读消息或错误通知。这就是为什么有两个单独的路由文件。当您访问一个网页时,该路由来自于web.php
路由文件。但是,如果您要在该页面上提交一个表单,您可能实际上会遇到一个 API route(在api.php
)来执行表单的处理。我们需要三个不同的路径来运行我们的上传应用示例(见图 4-2)。
Note
如果我们想把显示上传表单和文件列表的所有逻辑放在一个页面上,可能不需要/list
端点;然而,为了给我们的演示添加一些额外的上下文和范围,我暂时将它们分开。我们稍后将再次讨论这个问题。
让我们创建基本的 HTML 上传表单,从路由定义开始。打开你的routes/web.php
文件,输入清单 4-1 中的代码。
// routes/web.php
Route::get('/upload', 'UploadController@upload')->name('upload');
Route::post('/process', 'UploadController@process')->name('process');
Route::get('/list', 'UploadController@list')->name('list');
Listing 4-1The Routes We Use in the Example file-uploader Application
对于每个所需的路由,我们使用一个可用的特定于 HTTP 的请求方法(get()
、post()
、put()
、patch()
、delete()
和options()
)在相应的路由文件(在本例中为web.php
)中为其显式地创建一个单独的条目。要了解每一项的详细情况,请查看网站。这些方法的作用是指定允许哪些 HTTP 动词通过定义的 URI(或端点)访问给定的路由。如果您需要一个能够接受多个 HTTP 动词的路由(如果您使用一个页面来显示初始数据和提交的表单数据,就可能出现这种情况),您可以使用Route::any()
方法。一般来说,GET routes 用于提出问题或从应用中检索/读取一些数据,而 POST routes 用于提交应用中某些模型或数据结构的表单更新。PUT 用于创建模型或数据库实体新实例。
这些路由定义的第一个参数是您希望访问路由的 URI 或端点(web 服务器上的物理位置)。Route::get()
和Route::post()
方法(Route
facade 支持的任何其他 HTTP-verb 相关方法)的第二个参数是代码的位置,该代码在使用允许的 HTTP 动词(GET、POST、PATCH 等)到达路由端点时执行。).Laravel 提供了一种简单的方法来指定路由器将转发请求的控制器/控制器方法组合。我们将UploadController
用于所有三条路线,并以下列方式指定它们:
Route::get('/upload', '{CONTROLLER_NAME}@{CONTROLLER_METHOD}`)
我们在每个路由上调用的最后一个方法是它的name()
函数,该函数接受单个字符串作为参数,并用于用一个容易记住的名称(在我们的例子中是 upload、process 和 list)或多或少地“标记”一个特定的路由。我意识到,当 URL 名称相同时,给每个路由起自己的名字似乎不是一个很好的特性,但当你有一个像/users/profile/dashboard/config
这样的特定路由时,它确实很方便,因为它更容易被记住为profile-admin
或user-config
。
Note
Laravel 的许多组件上都提供了这种方法链,允许您以一种非常类似英语的易用方式调用返回对象上的其他方法。这就是所谓的流畅界面。下面是一个来自 Laravel 迁移的例子,它用于定义和跟踪数据库模式的变化(在下面的例子中,我们定义了一个外键约束):
$table->foreign('user_id')
->references('id')->on('users')
->onDelete('cascade');
正如您所看到的,方法链接的“语言”构成了一个完整的英语句子,这通常比任意的方法名称更容易记忆。
关于立面的一个注记
外观 为应用的服务容器中可用的类提供一个“静态”接口。
*> "它们提供了一种简洁、易记的语法,允许您使用 Laravel 的特性,而无需记住必须手动注入或配置的长类名。
—旅行证件
在前面的路由定义中,我们使用路由外观,而不是手动实例化一个新的Illuminate
/ Routing
/ Router
对象并在该对象上调用相应的方法。只是省打字的捷径。Laravel 框架中大量使用了外观;你可以也应该更熟悉他们。立面文件可在 https://laravel.com/docs/6.x/facades
找到。如果你不喜欢 facades,你可以使用静态方法调用来代替。
生成控制器
闲话少说(虽然这本书主要是 PHP,而不是 small talk),让我们继续并生成控制器,它将成为应用层的一部分,作为处理请求的机制,即接受请求并返回响应。这是所有控制器都应该做的!
值得重复的是:以下是管制员应该做的唯一的事情:
-
接受请求:路由将一个 URI 连接到一个通用或特定的
Request
对象,其数据在被自动注入到路由中定义的控制器方法之前被验证(通过简单地在控制器方法中类型提示请求对象来完成)。请求/响应生命周期的这一部分也可以被认为是客户端和应用之间的握手式机制。 -
返回响应:在分派工作(很可能是以作业、命令或其他可操作的域对象或外观的形式)之后,需要根据用户访问系统的特定方法给请求用户一个响应。例如,对 web 应用提供的 API 的调用通常需要某种 JSON 编码的响应,带有可读的成功消息、HTTP 状态代码,可能还有一些关于请求状态的附加数据。另一方面,对浏览器请求的响应可能是一系列不同的 HTML、CSS 和 JavaScript 代码,由浏览器解析并显示在窗口中。
好消息是,Laravel 提供了易于使用的设施来完成所有这些甚至更多!为了获得控制器的基本结构,我们可以使用这个例子,从项目的根目录中运行以下命令:
// inside the directory "ddl/"
php artisan make:controller UploadController
本质上,这个命令在主控制器目录中的/app/Http/Controllers/UploadController.php
处为名为UploadController
的控制器生成一个存根。打开来看一看。这很简单,因为它只是控制器的一个存根版本,具有正确的名称空间路径和它所扩展的必需类(参见清单 4-2 )。
// ddl/app/Http/Controllers/UploadController.php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class UploadController extends Controller
{
//
}
Listing 4-2Generated Controller Stub From
生成请求
在我们继续对UploadController
生成的存根进行一些更改之前,让我们一次生成所有的搭建代码。这样,我们可以获得整个过程的清晰、高层次的画面,而不会迷失在使其内部工作的更细粒度的细节中。
Note
理解表单请求和请求对象之间的区别很重要。一个表单请求是一种特定于标准 HTML web 表单的请求,包含提交的数据和任何附件,以及请求的rules()
方法中指定的验证需求。另一方面,request 对象是 Laravel 对 Symfony request 对象的扩展,它封装了一个真正的 HTTP 请求(例如,通过一个curl
命令),并用易于使用的访问器和操纵器对其进行打包(稍后将详细介绍),还包含诸如头信息、查询参数、请求体参数、cookies、会话值、HTTP 动词、URL 和引用 URL 等数据。
处理请求的控制器方法必须在其签名中键入特定请求类的提示。Laravel 中有一些快捷方式可以让您访问令人惊叹的验证特性,针对诸如请求验证、数据库验证和参数验证等问题,它们让我们有机会使用Illuminate\Contracts\Validation
契约的实现来创建额外的复杂域级验证。(在本章的后面你会学到更多。)现在,让我们再次使用 Artisan 命令来生成我们的请求存根。
php artisan make:request UploadFileRequest
这个命令将在app/Http/Requests/UploadFileRequest
中生成一个名为UploadFileRequest
的文件。打开存根看一眼。你会发现它很简单,只包含两种方法,authorize()
和rules()
.
我们刚刚生成的内容被称为表单请求。这意味着从 HTML 表单中捕获任何传入的数据,根据我们指定的验证检查来验证传入的数据,并将其注入到一个可以使用或修改它的控制器方法中(尽管由于请求是不可变的,所以不建议使用后者)。
清单 4-3 展示了生成的请求开箱后的样子。
// ddl/app/Http/Requests/UploadFileReq uest.php
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UploadFileRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return false;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
//
];
}
}
Listing 4-3A Generated Form Request Stub
定制生成的请求
让我们修改前面的请求存根,以满足我们的应用的需要。脚手架代码很好,因为它允许你基本上原型化你需要的各种组件,使你的应用做你需要它做的事情。
创建验证逻辑
打开UploadFileRequest
文件,将其更改为清单 4-4 。
// ddl/app/Http/Requests/UploadFileRequest.php
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UploadFileRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
// NOTE: This is a convenient location to include any auth
// checks or other authentication logic specific to this
// specific form request.
return true;
}
/**
* Get the validation rules that apply to the request. It is here
* that we specify how we want the data to be structured and what
* it should look like.
*
* @return array
*/
public function rules()
{
return [
'fileName' => 'required|string',
'userFile' => 'required|file'
];
}
}
Listing 4-4Modified Request Stub for Our Upload Application
清单 4-4 没有太多变化。首先,authorize()
方法现在返回 true 而不是 false。该方法负责允许请求传入应用。如果评估结果为 false,它将阻止请求进入系统(即,不将请求传递给路由中定义的相应控制器方法),而是返回某种错误响应。同样,这个响应将是特定于请求类型的:web、API 或。这将是一个方便的地方,可以对用户或任何其他逻辑进行授权检查,以确定请求是否可以前进到控制器方法,在控制器方法中处理请求并返回响应,或者,逻辑可以确定请求无效,而是发出带有 4xx 或 5xx 状态代码的错误响应(指示“未找到”错误或服务器问题), 这可以使用特定的Exception
类在后端记录错误来描述,但也可能在浏览器上显示为一般的错误页面。
现在,我们只需在这里返回 true,以允许任何东西使用请求,但这也是添加身份验证逻辑和其他与身份验证或授权相关的验证检查的好地方。请参阅下一节中更深入的示例。
另一个方法是rules()
,这是验证的神奇之处。想法很简单:返回一个包含一组规则的数组,格式如下:
'formFieldName' => 'pipe-delimited validators'
Laravel 支持各种现成的约束。如需完整列表,请查看在线文档 https://laravel.com/docs/6.x/validation#available-validation-rules
。对于我们的上传应用,将有两个字段通过来自前端表单的 POST 请求传递。fileName
参数必须包含在表单主体中(即必需的),并用作我们将在存储器中存储文件的文件名(这在控制器中完成——我们稍后会用到它)。我们还通过添加管道字符(|
)和单词string
来指定文件名必须是一个字符串。约束总是由管道分隔,允许您在一行中为给定字段指定许多附加条件。这是一些强大的东西!
第二个参数userFile
,是用户从网页上的表单上传的实际的文件。UserFile
也是必需的,必须是文件。
Note
如果我们期望上传的文件是一个图像,那么我们将使用 image 约束,这将限制文件类型为最流行的图像类型之一(JPEG、PNG、BMP、GIF 或 SVG)。由于我们希望允许用户上传任何类型的文件,我们将坚持文件验证约束,而不检查扩展名。这是一个有效的安全措施还是一个潜在的安全缺陷,我们现在通过指定我们的应用所接受的允许的文件扩展名的类型来引入它?我将在“更大的图片”一节的后面讨论这一点,但是,更具体地说,我将在接下来的“使用 MIME 类型来验证上传的文件”一节中演示如何使用 MIME 类型来验证文件是否如它们所声称的那样。
关于请求对象的rules()
方法需要注意的另一点是,如果由于某种原因我们省略了一个我们希望在请求中验证的参数,它显然不会有任何要求,并且会与用户使用表单提交的其他参数一起传递给控制器。只有当您在请求的rules()
方法中指定表单中的所有字段以及它们的约束时,自动验证才会正确工作。此外,当您在rules()
方法中包含一个不存在于表单传入值中的参数时,Laravel 将停止请求,因为它不符合相应路由定义中定义的控制器方法。
这就是请求对象的全部内容。它的主要工作是简单地保存一组可接受的标准(约束),表单的主体参数必须满足这些标准才能更深入地应用。另外需要注意的是,这两个字段(userFile
和filename
)也必须以输入字段的形式在 HTML 代码内部指定(字段名对应请求对象内部的名称)。
您可能会问:当然,这定义了表单请求应该包含的特征,但是在哪里执行对这些约束的实际检查呢?我们将通过使用我们的请求中配置的验证,发现以自动化方式执行这些检查的最佳方式;但是,手动执行身份验证检查的方式如下所示:
$validatedData = $request->validate([
'fileName' => 'required|string,
'userFile' => 'required|file',
]);
定制生成的控制器
我们准备定制控制器逻辑。打开app/Http/Controllers/UploadController.php
并修改它,使它看起来像清单 4-5 中列出的那个。请记住,如果您按照示例进行操作并手动输入应用,您不必担心转录注释,因为我在本书中没有使用注释来实现任何特殊功能。为了更有用的东西,避免额外的击键。然而,在生产环境中,或者在已发布语言或 API 文档的一部分中,文档块应该包含在每个类、方法和包中。
<?php
namespace App\Http\Controllers;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Http\Request;
use App\Http\Requests\UploadFileRequest;
use Illuminate\Support\Facades\Storage;
class UploadController extends Controller
{
/**
* This is the method that will simply list all the files uploaded
* by name and provide a link to each one so they may be
* downloaded
* @param $request : A standard form request object
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
* @throws BindingResolutionException
*/
public function list(Request $request)
{
$uploads = Storage::allFiles('uploads');
return view('list', ['files' => $uploads]);
}
/**
* @param $file
* @return \Symfony\Component\HttpFoundation\BinaryFileResponse
* @throws BindingResolutionException
*/
public function download($file)
{
return response()->download(storage_path('app/'.$file));
}
/**
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
* @throws BindingResolutionException
*/
public function upload()
{
return view('upload');
}
/**
* This method will handle the file uploads. Notice that the
* parameter's type-hint is the exact request class we generated
* in the last step. There is a reason for this!
*
* @param $request : The form request for uploading a file
* @return array | \Illuminate\Http\UploadedFile |
* \Illuminate\Http\UploadedFile[] | null
* @throws BindingResolutionException
*/
public function store(UploadFileRequest $request)
{
$filename = $request->fileName;
//the request is valid at this point because of the defined
//parameters we specified in the form request
$file = $request->file('userFile'); //no isset() req’d
//retrieve the original extension of uploaded file
$extension = $file->getClientOriginalExtension();
//create a new file name
$saveAs = $filename . "." . $extension;
//save the file to the local filesystem, inside uploads/
$file->storeAs('uploads', $saveAs, 'local');
//return a success message
return response()->json(['success' => true]);
}
}
Listing 4-5The Modified UploadController That Handles the Persistence of the Uploaded File
因此,这是一个相当简单的方法来保存上传到磁盘的文件。下面是对upload()
方法的分析:
-
在执行重要功能的控制器方法中键入提示请求类,这样我们就可以自动验证传入的数据。
-
从控制器方法内的请求对象中获取文件(现在已经过验证)。
-
从请求中获取文件名。
-
生成将用于保存文件的最终文件名。
getClientOriginalExtension()
方法检索上传文件的原始上传扩展名(当然,如果您猜不出名字所隐含的明显功能)。 -
使用其
storeAs()
方法将文件存储到本地文件系统,将/storage
目录中的命名路径作为第一个参数,将文件名作为第二个参数。 -
返回一个表明请求成功的 JSON 响应。
控制器中还包括一些方法来促进浏览器和应用之间的用户交互,例如下载给定的文件、查看过去上传的文件列表,或者在用户可以访问的页面上显示表单。
图 4-1 显示了正在发生的事情。
图 4-1
整个应用的概述,包括请求和响应
这种设计是可行的,但并不完美。仍有改进的余地。你能认出是哪里吗?如果你不能,我会给你一些提示(这样你就可以更好地处理现实生活中发生的这类事情)。我能给你的最重要的建议之一是:养成依靠最佳实践的习惯,熟悉设计模式、架构模式和核心软件设计原则,这样当你陷入困境或对如何构建应用感到困惑时,你就有地方可去了(正确的答案是与领域模型一致)。
如果你还记得在这一章的前面,我反复强调控制器应该被限制做两种可能的事情之一:接受一个请求或者返回一个响应。我之前创建的控制器确实可以工作,但是问题在于违反了核心软件设计原则:关注点分离。控制器的store()
方法包含保存上传文件所涉及的实际业务逻辑(完整地!)时,它实际上应该与控制器分离,作为域模型的一部分。在中心主题中存在多个关注点的事实暗示了它们应该位于除了与控制器内联之外的其他地方。这里有几个例子:
-
为上传的文件创建一个新文件名,然后在新文件上附加与上传文件相同的扩展名
-
指定特定文件系统上的位置
-
指定要保存文件的文件系统
如果我们想要更改这些内容,我们必须在控制器中进行,这并不理想,因为使用它的客户端可能每次都期望相同的响应,如果我们不断更改逻辑的这一部分,这是不可能的,因为对于每个实现,依赖于它的调用代码也必须每次都更改。
这里要注意的另一件事是,尽管我们为从表单(通过表单请求)传递到应用的数据设置了适当的验证,并且确信数据在到达控制器的store()
方法时应该是好的,但是我们没有对文件实际上成功保存进行额外的验证。按照现在的情况,如果文件没有正确保存到文件系统中,并且这导致了一个无声的错误,该错误记录到日志中,而不是显示在屏幕上,那么您将没有任何迹象表明保存上传文件实际上没有发生,并且会继续假设一切都很好,因为从该方法返回的唯一响应是一个基本的 JSON 类型的 API 响应, 只有打开您最喜欢的 web 浏览器的开发者工具插件并在一堆网络调用中搜索才能看到,这对于现实世界中的实际实现来说是完全低效的。 我们实际上缺乏对保存到指定文件系统的文件的任何验证。因为我们是专业的、高技能的(非常帅的)开发人员,他们欣赏他们工作的质量,我们会克服这个缺点并改正它。
如果这段时间还有其他事情困扰着你,那可能是因为我们使用了控制器的方法体来容纳我们对应用的主要关注:接受、验证和存储上传文件的过程。这些是应该在领域模型中处理的领域关注点。有许多不同的方法来处理这种情况。
让我们从UploadController
中的upload()
方法体内的逻辑错位开始。我们知道在这个控制器中发生了太多的事情,所以我们决定将涉及将上传的文件保存到存储器的域逻辑分开,我们将从我们的控制器内部委托一个调用。有许多其他方法可以解决这个问题,这将迫使我们将文件处理和存储逻辑下推到域层。我将重点介绍几种方法来做到这一点(还有许多其他方法也可以做到这一点)。
我们可以选择实现某种命令总线。本质上,命令总线是一种设计模式,它有两个组件,用于促进涉及应用服务层的操作,以及促进应用的响应:Command
对象和Handler
对象。Command
对象只是保存一个用户请求(或客户端请求),所有参数和传入的用户数据都封装到这个Command
对象中——这就是“是什么”。Handler
组件是执行者(或如何做),它封装了任何直接响应Command
对象请求的逻辑。理论上,每个Command
都有一个特定于那个Command
对象的处理程序对象。可以把处理程序看作是执行封装在Command
对象中的请求的一种方式。
由于命令总线架构的流行,出现了几个 PHP 特定的库,它们处理各种主题和工作命令总线的输入和输出。他们是百老汇( https://github.com/broadway/broadway
)和战术家( https://tactician.thephpleague.com/
)。这两个包都写得很好,有高质量的代码和支持它们的测试,如果你要建立一个完整的命令总线管道,有事件源、CQRS 支持、事件重放和预测,你可能要考虑看看百老汇,因为它支持所有这些,甚至更多。
另一方面,如果您正在寻求实现一个更小的基于微服务的架构,该架构将具有基本的命令和处理程序设置,用于相对少量的最近可能的请求和响应,那么 Tactician 可能是一个更好的选择。战术家在磁盘上有一个小尺寸,是超级快速,有效,易于学习。它支持各种不同的中间件(或者您可以推出自己的中间件),并支持自定义扩展点,例如日志记录、缓存以及对触发和跟踪事件的支持。
非凡软件包联盟是一个为 PHP 社区创建和维护战术家和其他高质量独立软件的组织。你可以在这里找到它们,以及非常高质量的文档: https://thephpleague.com
。
处理这种情况的另一种可能方式是将与处理和存储通过表单上传的文件相关的代码放在一个*域服务中。*每当我们对系统以及系统与我们的领域对象的交互进行建模时,对于我们向所有应用组件提出的基本问题,并不总是有一个简单明了、万无一失的答案:它是什么,它属于哪里?如果它不是一个东西,而是属于业务流程的范畴,那么就创建一个服务来封装业务逻辑,并从控制器中调用它,传递完成请求所需的任何变量或数据。然后,服务执行自己的任务,或者将结果返回给控制器,然后返回给客户端,或者将结果写入单独的日志,甚至将事件记录到任务或消息队列中。
另一个可能的解决方案是创建一个*职位。*作业存在于领域模型中,并作为独立的工作单元运行,可以从代码库中的任何地方调用。Laravel 完全支持创建和管理作业,甚至有一个单独的预建应用,它提供了一个强大的 UI,允许管理员以可视化的方式查看和管理系统中运行的不同作业。我们将在后面的章节中深入研究作业,但有一点需要注意(像 Laravel 中的大多数其他东西一样),有一种生成作业的机制,它将通过 Artisan 命令生成一个通用Job
类的脚手架。在本章后面的“使用 Laravel 作业封装业务逻辑”一节中,我们将讨论一个这样的作业的可能实现
Using Mime Types to Verify Uploaded Files
我想后退一步,重新审视一下系统接受未经验证的文件类型的问题。在我们的系统中,除了回答最简单的问题“它是一个文件吗?”之外,基本上没有其他验证这应该是一个非常响亮的信号,表明需要额外的安全性,这样我们就不会因为对允许用户上传的文件类型过于宽松而在系统中造成巨大的安全漏洞。
假设验证文件扩展名提供了某种程度的安全性来防止恶意文件进入系统,则可以并且应该对其进行验证,以便“剔除”除了附加了特定扩展名的文件类型之外的任何文件类型。然而,除了防止合法用户选择错误的文件之外,它并不能很好地防止用户上传恶意文件。在通过 web 表单上传已知恶意文件之前,人们可以很容易地更改其扩展名。在没有任何适当验证的情况下,理论上,用户可以将包含蠕虫算法的文件上传到我们的系统,因为我们决定不实施更高级的方法来验证文件的真实类型。
Laravel 确实为我们提供了一些工具,我们可以利用它们来增强应用的安全性。我们需要通过使用其声明的 MIME 类型来验证传入的文件,以确定真正的文件扩展名(与在文件名中提供的对应于最后一个句点之后的所有内容的扩展名相反,这对于用户来说很容易更改,就像在上传文件之前重命名文件一样)。然后将从文件中提取的扩展名类型与由文件名后缀指定的文件扩展名进行比较。
当在名为putFileAs()
的File
facade 上使用一个方法或者通过调用直接位于请求内的文件上的store()
方法时,这是自动完成的。
//calling the store() method in a chain-like manner from the request
$path = $request->file('customers')->store('customers.csv');
//explicitly using putFileAs() on the Storage facade
Storage::putFileAs(‘customers’, new File('/path/to/customers'), 'customers.csv’);
这样做的唯一问题是,得到验证的 MIME 类型实际上是从有问题的文件所指示的 MIME 类型中获得的,可以很容易地修改它,使其看起来像是其他类型。Laravel 提供了一个 MIME 类型验证,我们可以使用它来尝试从文件的内容中猜测任何给定文件的 MIME 类型,而不是依赖于它的元数据。我们所要做的就是在表单请求对象的rules()
方法中添加以下内容:
//in ddl/app/Http/Requests/UploadFileRequest.php
//replace the rule for userFile to look like the following in the
public function rules() {
//...
'userFile' => 'mimetypes:video/avi,video/mpeg,video/quicktime,image/bmp,image/jpeg,image/gif'
//...
重要提示:虽然这为我们的应用提供了额外的安全性,但作为一种折衷,我们确实损失了一些灵活性,因为所做的更改将文件的 MIME 类型限制为键userFile
处的rules()
方法中列出的受支持的视频或图像格式之一。这是一场永无止境的斗争,你将在现实世界中不断面对:安全与便利。我们将在后面的章节中更深入地讨论这一点。
刀片模板
我们需要的应用的最后一部分是在浏览器中实际显示表单并处理所有 Ajax 调用的部分,这些调用实际执行提交表单和将文件上传到服务器所需的逻辑。在ddl/resources/views/upload.blade.php
位置创建一个新文件(列表 4-6 )。
<-- ddl/resources/views/upload.blade.php -->
<body>
<h1>Upload a file</h1>
<form id="uploadForm" name="uploadForm"
action="{{route('upload')}}" enctype="multipart/form-data">
@csrf
<label for="fileName">File Name:</label>
<input type="text" name="fileName" id="fileName" required />
<br />
<label for="userFile">Select a File</label>
<input type="file" name="userFile" id="userFile" required />
<button type="submit" name="submit">Submit</button>
</form>
<h2 id="success" style="color:green;display:none">Successfully uploaded file</h2>
<h2 id="error" style="color:red;display:none">Error Submitting File</h2>
<script src="//ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js"></script>
<script>
$('#uploadForm').on('submit', function(e) {
e.preventDefault();
var form = $(this);
var url = form.attr('action');
$.ajax({
url: url,
type: "POST",
data: new FormData(this),
processData: false,
contentType: false,
dataType: "JSON",
success: function(data) {
$("#fileName").val("");
$("#userFile").val("");
}
}).done(function() {
$('#success').css('display', 'block');
window.setTimeout(()=>($("#success").css('display',
'none')), 5000);
}).fail(function() {
$('#error').css('display', 'block');
window.setTimeout(()=>($("#error").css('display',
'none')), 5000);
});
});
</script>
</body>
</html>
Listing 4-6The Blade Template
这是一个包含 HTML 表单和 JavaScript/jQuery 的刀片文件的典型示例,用于添加异步功能(因此我们可以调用服务器端点并从这些调用中接收数据,而无需刷新当前页面)。有一个基本的<form>
标签,没有方法属性(我马上会解释),有一个奇怪的action
属性,值为{{route('file.upload')}}
。在刀片中,这就是所谓的指令。指令只是函数的一个花哨名字;有一些特定于刀片模板的函数,这些函数执行不同的操作,这些操作对于构建网页和 web 应用是常见的。为了更好地理解 blade 可以做的所有很酷的事情,请查看 https://laravel.com/docs/6.x/blade
。在前一个例子中,我们使用 route 指令为表单提交生成一个 URL。
请记住,我们之前在应用的web.php
文件中定义了我们的路线,为每条路线指定了一个容易记住的名称。{{route()}}
指令接受一个路由的名称,在内部缓存的路由列表中查找,并基于web.php
文件中该路由的定义生成一个完整的 URL。对于第一种情况,我们指定希望表单将其提交的数据发送到/process
端点,这被定义为 POST 路由。
您可能已经注意到的下一个奇怪的事情是开始表单标签下面的@csrf
标签。在 blade 中,这个标签在表单上生成一个_token
参数,在允许处理表单数据之前,在应用内部对这个参数进行检查。这可以确保表单中的数据来源有效,并防止跨站点请求伪造攻击。有关这方面的更多信息,请参见 https://laravel.com/docs/6.x/csrf
。
Note
如果我们在前面的模板中省略了@csrf
标签,Laravel 将不接受表单,而是返回一个“419: Page Expired”错误*,除非*您将表单所在的特定页面添加到位于app/Http/Middleware/VerifyCsrfToken
的VerifyCsrfToken
中间件的$except
属性中,就像这样(在我们构建的示例项目中不要这样做;这仅用于演示):
//inside VerifyCsrfToken.php
/**
* The URIs that should be excluded from CSRF verification.
*
* @var array
*/
protected $except = [
'/payment-gateay-url'
];
现在,您应该能够使用以下命令启动本地开发服务器:
php artisan serve
这应该会给您一个类似于下面的消息:
Laravel development server started: <http://127.0.0.1:8000>
当您在浏览器中导航到此页面时,您将看到屏幕上显示的 Laravel 默认启动页面。继续并导航至http://127.0.0.1:8000/upload
在屏幕上查看基本表单。填写某种类型的文件名,从您的计算机中选择任何类型的文件用于上传输入,然后单击 Submit。一旦你这样做了,你应该在屏幕上看到我们前面定义的成功消息,或者你可能会得到错误消息,如果事情不顺利(无论什么原因)。
需要注意的是,如果在提交表单后收到“403 : Unauthorized”错误,很可能是因为您忘记将表单请求的authorize()
方法改为返回 true 而不是 false。
如果由于某种原因,您没有看到您期望看到的内容,或者如果表单过程由于某种原因失败,请确保您转录到自己的编辑器中的所有代码都是正确的。可能还有一些其他潜在的故障点,我们在编写这个功能时没有想到或预料到。这是对现实世界中 web 开发的准确描述。我们得到一个需求列表,通过对话和会议来确定确切的需求,设计满足这些需求的方法,并构建解决需求的解决方案的实现。当我们在软件中为一个请求编写规格说明时,我们不可能想到所有可能发生在这个请求上的事情。简直不可能。
相反,我们剩下的是依靠好的编程标准和最佳实践。在这种情况下,我们需要记住应用的概念是迭代开发,或者对应用进行小的、增量的更改,以在代码中充实所有的需求。
大局
让我们看看我们做了什么。
您应该还记得,我们在本教程开始时构建的请求对象应该在它的 rules 方法中定义了与 blade 模板中的表单相同的参数(如果没有,请重新阅读“创建验证逻辑”一节)。用户填写位于/upload
的网页上的表单,该表单通过刀片模板引擎呈现并提交。模板底部的 jQuery 代码停止默认提交(它将自动重定向到表单的action
参数中指定的页面),创建一个 Ajax POST 请求,加载带有表单数据和上传文件的请求,并将整个内容发送到我们的应用中,应用创建内部使用的请求对象,该对象被提供给包含该端点逻辑的 route 或 controller 方法。
通过将rules()
方法中的参数与提交的表单参数相关联来填充请求对象,然后根据每个指定的规则验证数据。如果满足所有规则,请求将被传递给与路由文件web.php
中定义的值相对应的任何控制器方法。在这种情况下,是UploadController
的process()
方法在起作用。一旦我们点击控制器,我们已经知道请求通过了验证,所以我们不必重新测试给定的文件名是否实际上是一个string
或userFile
参数实际上保存了某种类型的文件。我们可以像平常一样继续。
然后,控制器方法从请求对象中获取经过验证的参数,通过将传入的参数fileName
与userFile
的原始扩展名连接起来生成文件名,将文件存储在应用的目录中,然后返回一个简单的 JSON 编码的响应,验证请求是否成功。
A Note on Security Concerns
问题:我们已经实现的系统(到目前为止)中,有没有哪个部分引起你的注意,成为错误或混乱的潜在来源?
答:除了很可能有效但未在此列出的其他问题之外,还有以下我们在应用中尚未考虑或防范的场景:
-
安全问题
-
应用中没有用户级的授权,尽管一个可能的解决方案是实现一个策略,我将在本章后面的“验证表单请求和使用策略”一节中的示例中简要讨论这个问题。
-
表单请求中缺少指定的约束,尤其是围绕上载到服务器的文件。如果保持原样,将不会有检查来保证文件不包含恶意软件或某种类型的僵尸网络复制软件。检查扩展只能帮你完成一部分。这个问题的解决方案可以在本章前面的“使用 MIME 类型验证上传的文件”一节中找到。
-
-
持久性问题
- 一个不太明显的问题是表单中的另一个用户输入参数。
fileName
,必输字符串,是保存的文件进入系统后的对应名称。就目前的情况而言,用户可以提交任何有效的字符串,只要它既不是空的也没有被省略,我们的应用将很乐意接受它,验证字符串,并尝试使用它在给定的文件系统中保存文件。这个文件系统可能是一个 Dropbox 帐户,也可能是硬盘上的一个本地位置——我们不知道。因此,这些平台都对文件的命名方式、文件名的长度以及组成文件名的字符集有一定的限制。这个问题的一个解决方案是利用 Laravel 的漂亮的League\Flysystem\Util::normalizePath()
方法,只需在定义了$filename
变量之后,向UploadController
的store()
方法添加以下代码。
- 一个不太明显的问题是表单中的另一个用户输入参数。
-
建筑问题
- 业务逻辑的位置(它存在于控制器本身中)抛出了一面红旗,它尖叫着“重构我”,因为它明显忽略了关注点的分离。与业务或底层领域相关的最重要的逻辑应该移到领域层内的其他地方。控制器位于应用层,而域逻辑应该被分成…嗯,一个领域层。
注我鼓励你去看看非凡包联盟的 Flysystem 库中其他很酷的类似忍者的工具,我之前已经给你介绍过了。以防你忘记,这是他们的网站,特别是他们的 Flysystem 库,它帮助管理和执行对某些类型的支持文件系统的修改和添加: https://flysystem.thephpleague.com/docs/
。
$filename = League\Flysystem\Util::normalizePath($filename);
这一行简单的代码接受一个给定的字符串,并基于一个内部过程对其进行修改,该过程去除任何非法字符,并将字符串限制在特定长度,以便它可以用作正在上传的文件的有效名称。这样做的唯一缺点是,因为用户在表单中指定了文件的名称,所以他们会希望文件的名称与他们输入的名称完全一样,除非在提交文件后他们得到通知。我们可以通过多种方式解决这个问题,包括简单地通知用户文件以不同的名称存储,因为他们提供的名称无效,并且在响应中包含他们文件实际存储的名称。另一种解决方案是甚至不允许用户命名文件,而是生成文件名,然后将生成的文件名与拥有它的用户的 ID 和文件在文件系统上的位置一起存储在关系数据库中。清单 4-7 显示了一个可能的解决方案,它实现了给定上传的自动生成的 ID 号,而不是允许用户指定 name 参数。
//in ddl/app/Http/Controllers/UploadController.php
use App\UserUpload;
use Illuminate\Support\Facades\Auth;
//..
class UploadController extends Controller
{
public function store(UploadFileRequest $request)
{
$file = $request->file('userFile')
//save the file to the local filesystem, inside /uploads
//*NOTE*: this also runs the MIME type check automatically:
$path = $file->store('uploads');
// $path will be a string returned from the store() method
// corresponding to the saved path of the uploaded file
$upload = UserUpload::create([
'user_id' => Auth::user()->id,
'filename' => $path,
//a way to track the source of the uploaded file
‘form_id’ => $request->form_id //this is arbitrary
]);
//return a success message
return response()->json(['success' => true, ‘upload’ =>
json_encode($upload)]);
}
}
Listing 4-7Possible Solution
jQuery 逻辑(驻留在 blade 模板中,在后面的示例中显示)接收响应,它执行一些与 UI 相关的任务,比如显示成功(或错误)消息五秒钟,然后隐藏它,并清除以前的表单条目。这是为了让用户确信请求成功,如果他们愿意,可以上传另一个文件。
另外,请注意图 4-1 中客户端和服务器之间的分界线。理解这个概念对您来说绝对是至关重要的,它将帮助您解决将来在处理各种问题时可能遇到的难题,例如,在任何给定的时间都可能出现多个异步请求。
通过一个请求对象,客户端关注点与服务器端关注点的分离就存在于我们应用的边界。请求对象本身可以被认为是客户端希望对我们的应用采取的操作,路由以某种方式使用这些操作来生成响应并返回给客户端(用户),从而完成请求/响应生命周期。它通过运行我们在FormRequest
的rules()
方法中指定的验证,自动对从 web 浏览器传入的表单值进行初始验证和注册。
如果它们被认为是有效的,那么它们将被传递给控制器(或者路由定义的主体,如果它配置了闭包函数的话)。之前的一切都在前端(“客户端”字面意思是“在用户的计算机上”)。响应从应用返回到客户端,在客户端,我们的 jQuery 代码耐心地监听它的到达,并在收到响应后执行一些简单的 UI 任务,以便正确地通知用户请求成功或发生了错误。
其他注意事项
在本章的第一部分,我想把重点放在这个示例项目的核心功能上,以便让您清楚地了解这个过程是如何在一个较高的层次上完整地工作的,而不要过多地涉及这个项目在现实世界中开发时会遇到的细节和考虑事项。然而,在这样做的时候,我忽略了一些重要的部分,在现实世界的实现中,这些部分是需要纠正的。例如,在list()
方法中,我们只是抓取特定目录中的所有文件,并将它们全部显示给最终用户。在现实生活中,我们显然不想公开显示其他用户的文件。在这种情况下,我们可能会选择以特定的格式保存文件,以便轻松地确定文件属于哪个用户。下面是一个文件名格式,它允许我们确定哪些文件属于哪个用户:
{user-specified-file-name}.{userId}.{extension}
通过将用户 ID 硬编码到文件名中,我们可以确定每个文件的所有者。然而,这种方法会有一些其他的问题。如果我们考虑到应用可能包含许多用户和数百个文件,我们将不得不遍历给定目录中的每个用户和文件,以便按所有者来分隔文件。更好的方法可能是允许每个用户拥有自己的私有目录,该目录以标准格式命名,可能包括他们的user_id
或用户名来标识每个用户。这样,我们就可以按名称查找目录,并返回该目录中存在的所有文件的集合。
更复杂的方法是在关系数据库表中存储一条记录,该表基本上将每个文件链接到其对应的所有者(很可能通过user_id
字段上的外键)。这将是我们解决这个问题的最好方法,因为我们不需要在一个目录上执行 glob 并遍历每个文件,然后检查哪个文件名与哪个user_id
匹配,我们可以简单地发出一个查询来获取属于给定用户的所有文件名,并使用文件的路径来显示每个文件名的链接,然后用户可以单击并下载或接收关于这些文件名的元数据。
这是现实生活中的应用在某个时候会出现的东西(也就是说,将文件保存在磁盘上,甚至保存在离站存储中,并通过某种类型的接口管理文件)
)。 https://laravel.com/docs/6.x/filesystem#storing-files
的文档详细说明了如何在不同类型的文件系统(本地和远程)中存储和检索数据,如何处理和更改文件的元数据,如何上传和下载文件,以及如何通过文件权限管理对文件的访问。
在控制器内部仍然存在混合关注的问题。用于存储上传文件的逻辑与控制器方法内联。这是一个问题,因为我们已经认识到控制器只是一个接收请求和返回响应的地方;然而,在我们的例子中,情况显然不是这样。控制器处理上传文件的处理和存储中涉及的所有逻辑,在我们的例子中,这是核心域逻辑。我们将在本章和未来讨论解决这一难题的不同方法。
政策介绍
以下部分描述了在企业 web 应用中发现的与安全约束相关的常见问题,对于该问题,唯一明确的解决方案是实现某种健壮的用户管理和授权系统来处理用户管理,以及通过Role
实体管理每个用户的权限。通常,在执行完成请求所需的逻辑之前,您需要知道用户是谁以及用户可以做什么。我们可以在请求对象中使用这个漂亮的小点(在authorize()
方法中),作为在允许用户访问和提交特定表单之前正确检查用户类型的一种方法,但是如果我们想要实现这样的业务策略,比如在每个域模型的基础上进行授权,以便一个域类的任何给定实例都具有与相同类型的任何其他实例相同的安全设置集,那该怎么办呢?我们可以使用 Laravel 的政策做到这一点,我们将在接下来讨论。
Authenticating form Requests and using Policies
例如,假设您的应用支持一个企业、公司或其他一些大中型公司。在该企业中,有一个内置于应用核心的自定义身份验证层,该层具有一组定义的用户类型和一个相应的权限、角色和/或组表,它们共同定义了允许每个用户在应用中访问、查看和执行的所有操作。
比方说,这个应用管理不同医生使用的索赔提交流程,该流程允许他们为满足特定收入和贫困水平的人提供的医疗服务和治疗向联邦医疗计划收费。其工作方式是,患者与系统中的签约提供者预约,他们出现在医生的办公室并接受一些医疗需求的护理。提供护理后,医生获得服务报酬的方式是向完全合格的医疗保健中心(FQHCs)提交医疗索赔,该中心负责向医疗服务保护伞下的提供者支付报销费用。
联邦政府对不准确的索赔申请绝不手软,只有在百分之百准确的情况下才会接受和支付。为患者完成的所需数据、文件和程序(在称为 CPT 编码系统的复杂系统中建模)、患者信息、提供者信息以及一系列其他检查和平衡确保患者有资格接受护理在之前,FQHC 将向提供者开出支票。
为了帮助这一过程,创建了一个应用,允许不同类型的用户登录到应用的不同部分,以便他们可以在不干扰系统其他用户的情况下完成工作。一组可用的权限决定了每个用户有权做什么或看什么。这些用户类型包括提供护理的医生、来自 FQHC 的负责管理付款的记账人、有权访问每份索赔以便“清理”并验证所有数据 100%准确的审核人,以及可以访问所有内容的系统管理员。
让我们假设我们负责适当地构建一个表单,该表单接受一组通常在这些医疗声明中找到的(虚假)数据。我们知道我们只想让医生和管理人员能够将医疗索赔表实际提交到系统中。医生显然需要提交索赔来获得他们的钱,管理员显然需要能够将假索赔发送到系统中进行测试。我们将如何着手做这样的事?
利用表单请求的 authorize()方法
如前所述,负责表示(和验证)索赔表单的表单请求可以基于用户类型进行限制,这可能发生在表单请求类本身内部。我们还将使用 Auth facade 来帮助我们完成这项任务,因为这是一种访问我们需要的关于当前登录用户的几乎所有信息的简单方法。
public function authorize()
{
$user = Auth::user();
switch ($user->role) {
case 'Administrator':
case 'Provider':
return true;
break;
default:
return false;
break;
}
return false;
}
典型表单请求类中的这个authorize()
方法首先获取试图提交表单的登录用户,然后检查该用户的角色属性(在本例中,该属性评估为一个简单的字符串,描述用户类型的英文单词),以查看该用户是管理员还是医疗提供者。
注意在我们的 Laravel 应用中,用户的角色很可能是存储在数据库中的一些记录。例如,假设 MySQL 数据库中有一个包含两个外键的user_roles
表:一个表示用户的user_id
字段和一个表示用户所属特定角色的role_id
字段。根据应用的需要,可以为一个用户分配多个角色,也可以只分配一个角色。
如果用户是接受的用户类型之一,则该方法返回 true,请求被授权,将请求本身转发给路由中定义的控制器方法。如果除了用户拥有这两个可能角色中的一个之外,还有任何其他条件为真,则该方法返回 false,表单的执行将暂停,并出现一个异常,说明表单请求未被授权(或者,如果在生产环境中,异常信息和堆栈跟踪将被写入日志文件)。
让我们增加一些复杂性,假设系统中有一个额外的用户类型,对应于医生办公室内的办公室助理,他将已经提交的索赔的更新信息——比如说,更正信息——输入到索赔的表单中,而不是医生(这是常见的做法,因为医生的时间显然比通过计算机将数据输入到系统中更有价值)。只有当这些办公室助理被注册为特定办公室的一部分(即,在医生的工资单上)时,他们才被允许将索赔表提交到系统中并更新预先存在的索赔。这是为了防止不同办公室的助理代表他们不直接为之工作的医生更新报销申请。隐私在医疗行业是一件大事,需要满足某些措施来保护患者隐私和建立受保护的健康信息(PHI)准则。
我们需要我们的应用能够处理这种限制,并且只允许类型为OFFICE_ASSISTANT
的用户提交办公室的表单,前提是他们是为该办公室工作的注册用户。我们可能会想跳回到前面请求类,并更新authenticate()
方法来包含对该需求的额外检查。这样做的问题是,我们实际上无法访问系统用提交的表单数据创建的虚拟的Claim
对象*,也无法在调用authenticate()
方法时将实际数据传递到请求中,因此我们无法验证数据是否来自提供者在同一帐户上雇佣的相应 office 助手。为了正确地构造这个身份验证特性需求,我们在某个时候需要登录的用户对象以及我们在相同的上下文中对照验证的对象,以便正确地检查它们并决定是否允许该用户提交声明。*
Laravel Policies to Protect Resources
幸运的是,Laravel 附带了一个叫做策略的组件。策略是负责验证单个特定类型的域对象的类。它基本上是一种组织与特定资源或实体相关的任何给定身份验证或权限检查的方法。例如,为了创建一个核心业务对象的保护性包装(像Claim
模型),我们将使用 Artisan 命令(就像我们通常在创建新的 Laravel 文件时所做的那样)来创建将成为ClaimPolicy
的脚手架。
注意不要担心运行这些命令或者在这个部分中键入任何代码。它仅供参考,在这里使用是为了给你模型级安全性和策略主题的更多上下文。
php artisan make:policy --model=App\Claim ClaimPolicy
这个命令在目录ddl/app/Policies/UploadPolicy.php
中为我们创建了一个UploadPolicy.php
文件,看起来像清单 4-8 。
// ddl/app/Policies/ClaimPolicy.php
<?php
namespace App\Policies;
use App\User;
use App\Claim;
use Illuminate\Auth\Access\HandlesAuthorization;
class ClaimPolicy
{
use HandlesAuthorization;
/**
* Determine whether the user can view any Claim.
*
* @param \App\User $user
* @return mixed
*/
public function viewAny(User $user)
{
//
}
/**
* Determine whether the user can view the Claim.
*
* @param \App\User $user
* @param \App\Claim $claim
* @return mixed
*/
public function view(User $user, Claim $claim)
{
//
}
/**
* Determine whether the user can create claims.
*
* @param \App\User $user
* @return mixed
*/
public function create(User $user)
{
//
}
/**
* Determine whether the user can update the Claim
*
* @param \App\User $user
* @param \App\Claim $claim
* @return mixed
*/
public function update(User $user, Claim $claim)
{
//
}
/**
* Determine whether the user can delete the Claim.
*
* @param \App\User $user
* @param \App\Claim $claim
* @return mixed
*/
public function delete(User $user, Claim $claim)
{
//
}
/**
* Determine whether the user can restore the Claim.
*
* @param \App\User $user
* @param \App\Claim $claim
* @return mixed
*/
public function restore(User $user, Claim $claim)
{
//
}
/**
* Determine whether the user can permanently delete the Claim.
*
* @param \App\User $user
* @param \App\Claim $claim
* @return mixed
*/
public function forceDelete(User $user, Claim $claim)
{
//
}
}
Listing 4-8A Generated Policy Class That Provides Authentication for Claim Objects
一般的概念是由应用中的两个标准对象组成的:一个用户对象(代表试图访问我们的资源的用户)和一个Claim
对象(我们正在保护的资源)。支架代码已经为我们预先创建了所有的类型提示,因为我们在生成这个类时,通过初始命令传入的参数--model=
指定了资源。策略中的所有方法都对应于可以在任何给定的Claim
模型(业务对象)上采取的各种“动作”。我们有查看、更新、存储和删除Claim
资源的方法。剩下要做的就是指定您希望每个可操作的场景如何按照所需的逻辑运行,以确定用户是否被允许做某事。
在这种情况下,如果用户的角色是管理员或提供者,或者当且仅当助理被视为同一提供者办公室的注册员工时,如果角色是Office_Assistant
,我们希望仅允许存储给定的索赔模型。最初,我们将逻辑放在请求对象的authenticate()
方法中,但是策略提供了一种更健壮、可定制的验证方法,在处理资源(业务对象)时,这种方法更适合用于更高程度的控制。
为了充分利用这个讨论,我们将把重点放在需求的更新部分。如果他们是 office 的注册用户,office 助手可以更新已经提交的申请。清单 4-9 展示了create()
方法如何满足这个需求。
/**
* Determine whether the user can update the Claim
*
* @param \App\User $user
* @param \App\Claim $claim
* @return mixed
*/
public function update(User $user, Claim $claim)
{
switch ($user->role) {
case 'Administrator':
case 'Provider':
return true;
break;
case 'Office_Assistant':
$employeeManager = (new EmployeeManager());
$providerOffice = $employeeManager->
findRegistrationFor($user);
if ($claim->provider === $providerOffice->provider) {
return true;
}
Return false;
break;
default:
return false;
}
}
Listing 4-9Possible Implementation of a Policy’s update() Action on a Given Claim Object
这个例子定义了一个update()
方法,它采用一个User
对象和一个Claim
对象来确定请求是否应该被允许继续。如果用户的角色是管理员或提供者,那么他们可以更新特定的Claim
。如果用户属于Office Assistant
类型,那么在switch
语句中有额外的内嵌逻辑来创建一个新的域服务实例EmployeeManager,
,该实例又会找到助手注册到的提供商的办公室,并将其与附加到claim
的提供商进行比较。只有当这些值相同时,应用才允许请求进入系统内部。稍后我将向您展示如何使用您自己的策略。
此外,我已经省略了与实际实现该策略相关的所有代码,但是现在只需要知道它们可以通过几种不同的方式实现,这取决于您要完成的任务的上下文。
-
通过使用方法
can()
和cant()
的User
模型,这些方法接受要检查的模型以及该模型中对应于将用于授权检查的策略方法的动作 -
在给定的路线上,通过中间件
if ($user->can(‘update’, $claim) {
// perform update logic after the “update” method has
// been called on the ClaimPolicy
}
- 通过助手方法在控制器内部
Route::post(‘/claim/{claim}’, function (Claim $claim) {
// perform update logic
})->middleware(‘can:update,claim’);
/**
* Update the given claim
*
* @param Request $request
* @param Claim $claim
* @return Response
* @throws \Illuminate\Auth\Access\AuthorizeException;
/*
public function update(Request $request, Claim $claim)
{
$this->authorize(‘update’, $claim);
//the current user can update the claim
}
表 4-3 显示了控制器方法到其用于认证的相应方法的映射。
表 4-3
控制器方法到策略上相应方法的映射
|
控制器方法
|
路由定义的 HTTP 动词
|
政策方法
|
| — | — | — |
| index()
| 得到 | viewAny()
|
| show()
| 得到 | view()
|
| create()
| 得到 | create()
|
| store()
| 邮政 | create()
|
| edit()
| 得到 | update()
|
| update()
| 上传/修补 | update()
|
| destroy()
| 删除 | delete()
|
注意,对于这些选项中的任何一个,Laravel 都会自动检查是否有针对给定模型的策略被请求访问;然而,如果没有,它将退回到AuthServiceProvider
中定义的任何已定义的门验证关闭。
关于策略需要注意的另一件事是传递到策略方法中的域对象的类型,允许修改或删除该对象类型。传递给策略方法的对象类型是该策略所保护的模型(或资源)。这不要与资源模型混淆,资源模型是使用特定于特定模型上的动作的资源控制器来定义的。
关于它们的另一个真正伟大的事情是它们支持雄辩的关系,使得以直接和非介入的方式转换对应于其他对象的关系的对象变得容易。我们将在本书的后面部分触及所有这些内容。
设计 API 优先的应用
此时,我们知道了大多数请求细节(比如它们是什么以及如何验证它们),所以我们现在可以开始在脑海中看到应用的整体结构。我们已经包含了表单进入我们的应用时我们所期望的表单的正确定义,这是您将与之交互的数据类型的一个很好的高层次概述。这是构建应用的方法,所以如果你不知道从哪里开始一个项目,API 是一个很好的选择。
在本书的后面,我们将讨论如何使用 Laravel 中提供的设施和管道设置来构建和实现一个实际的 API,但是为了吊起你的胃口,我在侧边栏中加入了一个叫做 API 优先设计的东西。
API-First Design and the Open API Specification
在专业的 web 开发中,API 优先的设计与我在本章中描述的过程有很大的不同。这样做的原因是因为我想让你熟悉拉勒韦尔流动的方式;做到这一点的最佳方式是允许您专注于特定的概念元素组,而不被更多的架构实践和模式分散注意力。随着这本书的进展,我们将在适当的时候了解这些。
主要区别在于,在 API 优先的设计中,通常从创建 API 将遵循的模式开始,以便完成模型所需的各种应用任务。这通常是以一种与语言和数据库无关的方式完成的,因此您最终得到的是 API 层的严格准则,下至诸如所请求的参数类型之类的本质细节,甚至是进入和离开应用的请求和响应的整个定义结构,通过端点分组到类似的功能焦点。您首先定义 API 中的所有端点,然后编写一行实际代码来使用它。
开放的 API 规范和吹嘘
在现实世界中,有多种方法可以做到这一点。我向你们提出的一个解决方案是利用所谓的开放 API 规范,就像 SwaggerHub ( https://swagger.io/tools/swaggerhub/
)这样的 API 设计工具应用所使用的那样。在您自己的应用中开始使用 Swagger 需要一定的学习过程,但是花时间学习是非常值得的。SwaggerHUB 的优点在于它提供了一种独特的方式来查看您在 API 中定义的数据结构(使用开放 API 规范作为一种手段)。它还提供了一系列很酷的特性,比如快照创建、版本跟踪、分叉/合并和发布特性。版本跟踪在团队环境中特别有用,因为它使得团队的所有成员在 API 标准建立时更容易使用最新版本。
这里有一些我觉得非常酷的东西:当从不同角度查看 API 并导航到特定定义时,SwaggerHub 提供的可视化非常有用,它们都是使用开放 API 标记语言生成的。学习如何定义您自己的应用中需要的各种结构、请求和响应需要一些时间,但是这是非常值得的。一旦掌握了窍门,您就可以快速定义对应用的成功至关重要的 API 设计细节。使用像 SwaggerHub 和 Open API 规范这样的工具,您可以保证定义是有意义的(因为它大约每五秒钟被解析和验证一次,重新生成对应于路由定义的可视化工具),并且您不会重复每个特定请求和响应中涉及的实体的定义。
您只需定义一次这样的结构(从组件/模式节点中),然后就可以在 API 中的任何端点、请求或响应定义中引用它们。如果你引用了一个没有定义的特定结构,你会得到一个友好的错误标记,解释为什么会有这个问题。验证是有帮助的,因为您为应用指定的 API 定义由客户端实现精确地遵循*,这意味着您为自己的 API 布局的定义、类型和数据结构必须是准确的。*
*一旦您对组成您的 API 的所有结构的定义感到满意,那么您就可以将更改“发布”到一个特定的已发布版本,防止对该版本的进一步修改,除非创建了一个新版本(例如,使用形式为 version.major.minor 的增量版本控制)。您还可以指定使用哪个版本作为默认版本,这样您就可以拥有尽可能多的“进行中”版本,同时强制团队使用某个特定版本来开发使用该 API 的应用的其他区域。
中间件
中间件是一个在 PHP 世界获得广泛支持的概念,特别是在 Laravel 等 MVC 框架和 Node’s Express 等其他服务器端框架中。中间件被用作过滤进入应用的 HTTP 请求的机制,它们可以用于您可能想做的几乎任何事情,无论是之前、之后还是之间。它们通常在 web 应用中用于各种有效和合法的目的,包括:
-
认证和授权检查
-
会话验证和会话变量的修改
-
读取、设置或修改请求和响应头(对应于中间件之前和之后)
-
记录事务和 API 调用
-
重定向和内部站点“流”定制,并中断标准的请求/响应生命周期以完全替换其中任何一个
-
发出或响应特定的领域事件
-
修改对应用的每个调用的请求或响应,或者只在特定的端点上,从特定的 IP 地址修改请求或响应,这些 IP 地址也限制请求,因此它们必须包括具有适当值的有效请求头,以及您可能需要的几乎任何其他安全问题
-
更加
作为另一个例子,在 Laravel 中,从应用内部运行的检查和平衡(可以这么说)通过一个必需的认证会话(使用 Cookie 头)来确保请求的有效性和真实性,通常实现为某种类型的令牌认证,以限制应用中不应该对系统的非用户可用的部分。Laravel 使用中间件在整个系统中建立几乎所有的认证操作。
要获得 Laravel 应用中默认配置的中间件的高层次概述,您可以查看 Laravel 应用的Kernel
类,它位于/ddl/app/Http/Kernel.php
,是 Symfony 著名的HttpKernel
实现的扩展,几乎是所有现代 web 应用框架中事实上的标准Kernel
。
// ddl/app/Http/Kernel.php
<?php
namespace App\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
/**
* The application's global HTTP middleware stack.
*
* These middleware are run during every request to your application.
*
* @var array
*/
protected $middleware = [
\App\Http\Middleware\TrustProxies::class,
\App\Http\Middleware\CheckForMaintenanceMode::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ /
ConvertEmptyStringsToNull::class,
];
/**
* The application's route middleware groups.
*
* @var array
*/
protected $middlewareGroups = [
'web' =>
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
// \Illuminate\Session\Middleware\AuthenticateSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
'api' => [
'Throttle:60,1',
'Bindings',
],
];
/**
* The application's route middleware.
*
* These middleware may be assigned to groups or used individually.
*
* @var array
*/
protected $routeMiddleware = [
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' =>
\App\Http\Middleware\RedirectIfAuthenticated::class,
'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
'throttle' =>
\Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' =>
\Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
];
/**
* The priority-sorted list of middleware.
*
* This forces non-global middleware to always be in the given order.
*
* @var array
*/
protected $middlewarePriority = [
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\Authenticate::class,
\Illuminate\Routing\Middleware\ThrottleRequests::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\Illuminate\Auth\Middleware\Authorize::class,
];
}
Kernel
类提供了特定中间件到给定路由类型的分配。我在这里使用路由类型来表示 web 路由和 API 路由。相比之下,我使用路由组来指代在一个中央路由文件中定义的单个路由组。每个中间件都有一个快捷语法,您可以在前面的代码中看到,它对应于为每个路由类型定义的数组键。我们将在本章末尾讨论这两种路由类型的区别。
清单 4-10 展示了一个中间件的例子。看一下文件dll/app/Http/Middleware/RedirectIfAuthenticated.php
*。*该中间件对应于web
路由配置组中的guest
键(列表 4-10 )。
// dll/app/Http/Middleware/RedirectIfAuthenticated.php
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Support\Facades\Auth;
class RedirectIfAuthenticated
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @param string|null $guard
* @return mixed
*/
public function handle($request, Closure $next, $guard = null)
{
if (Auth::guard($guard)->check()) {
return redirect('/home');
}
return $next($request);
}
}
Listing 4-10An Included Middleware in Laravel That Redirects Users Already Authenticated to the /home Route
中间件很方便,因为它位于进入应用的请求和域模型(或应用的核心)之间,它可以充当任何类型的身份验证/授权检查、缓存机制、应用日志记录、会话/cookie 参数的机制,或者通过 CSRF 保护检查来确保表单的有效性以防止 XSS 攻击。这些都是如何在 Laravel 应用中调用和建立这些东西的例子。然而,给定特性的特定实现的实际定义、协作和依赖关系通常位于其他类或组件中。中间件只是实现那些列出的关注点的一种机制,但不应该直接内联实现它们相应的逻辑。相反,设计架构以在域层中容纳这样的问题,然后您可以从特定的中间件或控制器方法中调用它,但是只有在您正在处理的问题的上下文中这样做才有意义。
保持这种关注点的分离允许我们更好地组织应用的核心逻辑,并给我们一个清晰的边界,即哪个逻辑需要在哪个层,甚至当域模型被实现为一系列跨越多个有界上下文的独立和专门的模块时,允许更细粒度的控制,并且这些有界上下文中的每一个都可以被认为是其封闭模块的一部分。这有助于在域级别上组织代码,这正是我们想要的。
另一个需要注意的重要事情是,这个机制在进入应用的每个请求以及应用发出的每个响应时都会被触发(调用)。当然,有一些方法可以配置中间件,使它们只“激活”应用的一部分,而不是每个请求/响应,但实际上处理请求的额外过滤的方法是添加确定是否应该运行或跳过中间件的逻辑——该逻辑应该放在中间件定义的主体内,并且应该立即返回下一个可调用的中间件(中间件之前),或者在检查执行之前或之后在堆栈上的中间件主体的末尾(中间件之后)。在本书的稍后部分,我将向您展示如何配置前中间件和后中间件。
如果您不希望中间件在每个请求上触发,您也可以指定一个路由或路由组来强制使用特定的中间件,就像清单 4-10 中的那样。将特定的中间件分配给应用的相关部分,还有什么比使用路由配置更好的方法呢?因为我们使用特定于路由的 URIs 将所有不同的部分(组件)组合在一起,所以 Laravel 提供了一种直观和简单的方法,使特定的中间件只在特定的路由被客户端找到时才运行。
虽然我们还没有深入到在您自己的应用中实现中间件的细节,但是您对它们有一个基本的了解是很重要的,这样您就可以在本章的下一节中讨论路由文件。Laravel 中有两个主要的中间件组,对应于中间件实际涉及的范围,默认定义为:API 中间件组和 web 中间件组。这些组可以在位于此处的服务提供商内进行配置(和更改):app/Providers/RouteServiceProvider
。如果你打开这个文件看一看,你会注意到一个基本的map()
方法调用了同一个类中的两个默认方法:mapWebRoutes()
和mapApiRoutes()
。这两个中间件组由两个 URIs 确定,这是内部使用的机制,用于将给定的中间件映射到其激活的相应路由组(表 4-4 )。
表 4-4
Laravel 的默认中间件配置
|
中间件组名称
|
路由前缀
|
有效路线示例
|
路由文件
|
| — | — | — | — |
| Api
| /api/
| /api/users/create
| routes/api.php
|
| Web
| /
| /about
| routes/web.php
|
在典型的真实场景中,应用很可能包含其 API 的不同版本,这可以通过向一组选定的路由添加路由前缀来表示,该前缀指示给定客户端使用的 API 版本。以下是 API 路由端点的典型示例:
基于 Web 的路由的 Web 中间件➤
web 中间件组在routes/web.php
文件中配置,被认为是用户通过浏览器与之交互的应用的“公共”范围。默认情况下,它对应于所有不以/api
开头的 URIs,这通常是 web 应用中 URIs 的大部分。任何以/
(除了/api
)开头的东西都被认为是网络路由组的成员,因此有更多的公共设置,在这种情况下,允许非登录用户查看标准的网络路由。最基本的例子是应用的主页或它的“关于”页面。
用于基于 API 的路由的 API 中间件➤
无论如何,我们都不希望应用的所有 URIs(路线)对未经身份验证的用户(来宾)可用。应用的 API 也是如此,它通常包含已定义的 REST 接口,允许客户端访问和修改系统或域的核心部分。例如,如果我们要在我们的应用中建立一个新用户帐户的实现,我们会希望将 URIs (routes) 放在之后,以便在未经身份验证的用户尝试一些恶意行为时提供安全手段(并且您总是必须假设他们会尝试这样的事情)。在这种情况下,我们可能会指定一个/api/users
路由,它对应于一个 API 的 REST-ful 实现,允许该路由根据 HTTP 动词执行所需的功能,通过基于路由的闭包或者引用 Laravel 的快捷语法中定义的控制器方法来引用特定控制器上的单个方法。
这在 Laravel 中很容易配置,稍后我将向您展示细节。现在,只要确保您理解什么是中间件,并认识到可以(并且已经)通过 Laravel 应用中的每个中间件组配置的东西的类型。稍后我们将回到中间件的概念。
Laravel 工作简介
本节介绍了作业的概念,它基本上是一组封装到单个对象中的事务或操作,其内部流程是为了解决领域级别的业务问题而创建的。作业可以像命令一样使用,可以在控制器中轻松调用,可以放在队列中,可以用 supervisorD 监控,以便并发处理它们(即异步)。您可以为现代任务和消息队列 sch 设置一个流行的选择,如 RabbitMQ、Kafka 或 ActiveMQ,以异步方式处理各个任务的通信、管理和报告,以便操作看起来更加流畅,等待时间比同步处理少。然而,对于这个示例,我们将只创建一个同步实现,并将作业的结果立即返回给调用代码(在控制器中),而不是使用使异步处理如此流行的“设置好就忘了”技术。我们将深入研究如何使用消息队列。
Using Laravel Jobs to Encapsulate Business Logic
正如我们现在所知道的,在 MVC 架构中,控制器的目的应该仅仅是与客户端握手,将任何域工作委托给域层(域层依次一次处理一个或使用直接使用域层的应用服务),并返回指示给定请求成功或失败的响应。也就是说,我们最初设计的UploadController
的store()
方法包含了处理、命名和存储上传文件所需的所有逻辑。这个域逻辑应该封装在控制器方法体之外的某个地方,一种可能的方法是创建一个作业。
要生成新的作业支架,请使用以下命令:
php artisan make:job SaveUploadedFile
这个命令将在app/Jobs/SaveUploadedFile.php
产生文件内的代码(列表 4-11 )。
// ddl/app/Jobs/SaveUploadedFile.php
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
class SaveUploadedFile implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct()
{
//
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
//
}
}
Listing 4-11Scaffold Code Generated from make:job Command
这个类非常简单,只包含两个方法:构造函数和handle()
方法。构造函数被用作依赖注入的一种方式。完成这项工作所需的任何附加对象都在构造函数的签名中进行了类型提示,并由 Laravel 的依赖注入组件自动解析。事实上,如果你需要更多关于完成特定任务的对象的定制,你可以指定你想要如何使用 Laravel 的服务容器构建注入服务或作业的对象(在 https://laravel.com/docs/master/container
阅读)。我们将在本书的后面讨论服务容器的更高级的用法。
构造函数也是传递来自请求的任何数据的地方,这些数据是完成封装在该作业中的任务所需要的。因为我们关心的是存储用户提交的文件,所以我们需要包含任何曾经内嵌在控制器的store()
方法中的参数,并将它们作为单独的项放在Job
类的构造函数中。我们不会将整个请求对象传递给构造函数;这是不好的做法。
清单 4-11 中的第二个方法是handle()
方法,它是作业定义的核心。这是调用作业时主逻辑运行的地方。它基本上利用了您在构造函数中指定的任何对象(这些对象也应该作为类成员参数包含在作业定义中)。当作业被分派时(通常是从控制器),它会在构造函数中注入任何需要的东西,并调用handle()
方法。让我们看看保存用户上传的文件并将生成的文件 ID 存储在数据库中以跟踪哪个用户拥有哪个文件的作业是什么样子的。
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Symfony\Component\HttpFoundation\File\File;
use App\UserUpload;
class SaveUploadedFile implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $fileName;
protected $upload;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(File $upload)
{
$this->upload = $upload;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
//save the user’s file and grab its path on the filesystem
$path = $this->upload->store('uploads');
//create a record to track user’s uploaded files
$upload = UserUpload::create([
‘user_id’ => Auth::user()->id,
‘filename’ => $path,
]);
//do a final verification check that the saved file exists
if (!is_file($path)) {
throw new Exception(“Problem with saving the file”);
`}
return $path;
}
}
有了这个新的作业,我们可以修改我们的控制器,消除任何与文件存储相关的问题,而是简单地将该任务委派给新的SaveUploadedFile
作业。通过一个静态方法,这可能看起来是这样的(我们实际上使用了 Laravel 中所有作业类附带的内置方法):
public function store(UploadFileRequest $request)
{
$file = $request->file('userFile');
$filePath = SaveUploadedFile::dispatchNow($file);
return response()->json(['success' => true]);
}
正如您所看到的,我们的控制器方法在大小和复杂性上都有所降低,因为我们现在将存储上传文件的任务委托给了一个作业类,而不是与处理请求的控制器方法内联。现在我们有了一个清晰的关注点分离,加上我们正在实践一个叫做意图揭示接口的东西,在这里我们以一种清楚地表明它们的目的的方式命名类、对象和参数,同时也尽可能清楚地定义和揭示对那些参数采取的动作。
在现实世界中保持质量
管理这种迭代开发过程并帮助您专注于解决系统的领域问题的最佳方式是采用在持续集成技术的应用中发现的概念,这样您就可以不断地用高质量的代码来开发软件,这些代码包括单元测试以及自动测试机制,您设置这些机制是为了在每次提交代码库或每次从拉请求进行合并时运行。这有助于确保您编写的新代码不会破坏任何旧的功能,并为您提供一条在应用用于生产后进行升级和维护的清晰道路。不破坏旧的代码对于维护高质量的 web 软件是至关重要的,一个可靠的 CI/CD 管道肯定会有所帮助。
总的来说,我在本章中概述的过程是这样的:
设计领域和架构➤生成通用代码➤定制通用代码➤重申系统设计➤重构新见解。
从更高的层面来看,它看起来更像这样:
设计➤原型➤实现➤重构。
增量变化的概念是几种流行的编程范例的核心基本点。极限编程(XP)和敏捷开发都依赖于开发的迭代周期给 web 应用开发工作带来的价值。你甚至可以在 http://continuousiteration.com
找到关于这个主题的博客(不是“持续集成”,而是“持续迭代”)。所有好的域名都被占了,所以我试图想出一个意思相同,听起来接近相同的东西,这就是我想出来的。不讨厌。
结论
在这一章中,我使用 Composer 包管理器解决了依赖关系,检查了 Laravel 在本地系统上的安装。之后,我们看了一个使用 Laravel 构建的示例应用,了解了 Laravel 应用中涉及的主要组件。我向您展示了如何使用 Laravel 附带的 Artisan 命令行工具来为这些组件生成脚手架代码,然后我们使用它们来实现我们需要的功能。我们以与真实场景中大致相同的方式完成了这个例子:我们创建了一个天真的实现来满足系统的需求,然后我们返回并在设计中加入了一些额外的想法,并意识到它缺少一些重要的东西并违反了一些重要的规则。
我们用代码制作了模型的原型,很清楚它并不完美。我们慢慢地开始一遍又一遍地去除商业模式的弱点或不准确的表达,直到我们结束修补所有的漏洞。最终,这给我们留下了一个工作的软件,它与构建它的商业模型相一致。
在我们为上传应用构建了实现的粗略草案之后,我们回顾了应用的结构和组件的一些额外的考虑,还讨论了初始版本的一些缺点。您学习了如何尽可能地减少这些缺点,并对代码的结构和逻辑进行调整,让您(我希望)对如何处理基本请求、验证传入的请求参数、使用作业封装业务逻辑、使用控制器作为完成请求/响应生命周期的手段,以及使用 Laravel 提供的其他一些工具有一个坚实的了解。
现在你有了这样的理解,我们可以进入这本书的实质,讨论我们实际上在领域驱动的 Laravel 方面正在尝试做什么;然后,我们将解决如何在现实世界中实现它。***