开发人员手册:DDD食谱(第5部分,过程)

引言


在前面的文章中,我们描述了: 范围方法论基础架构结构 的示例 。 在本文中,我想告诉您如何描述流程,收集需求的原理,业务需求与功能需求之间的区别以及如何从需求转换为代码。 讨论使用用例的原理以及它们如何为我们提供帮助。 探索Interactor和Service Layer设计模式的实现选项示例。


likeyourgrandmom


本文提供的示例是使用我们的LunaPark解决方案提供的,它将帮助您完成所描述方法的第一步。


将功能需求与业务需求分开。


一次又一次地发生了这样的情况,许多商业想法并没有真正变成最终的预期产品。 这通常是由于无法理解业务需求和功能需求之间的差异而导致的,最终导致需求收集不足,不必要的文档,项目延迟和重大项目失败。


有时我们会面临这样的情况,尽管最终的解决方案可以满足客户的需求,但是却无法实现业务目标。


因此,在开始定义它们之前,必须将业务需求与功能需求分开。 让我们举个例子。


假设我们正在为一家披萨外卖公司编写一个应用程序,并且我们决定创建一个快递跟踪系统。 业务要求如下:


“引入一个基于Web的系统和一个基于移动员工的员工跟踪系统,该系统可以捕获快递员的路线并通过监视快递员的活动,他们的缺勤和劳动生产率来提高效率。”


在这里,我们可以区分许多特征,这些特征将表明这些是企业的要求:


  • 业务需求总是从客户的角度来编写的;
  • 这些是广泛的高层需求,但仍然是面向部分的;
  • 它们不是公司目标,而是帮助公司实现目标;
  • 回答问题“ 为什么 ”和“ 什么 ”。 公司想收到什么? 她为什么需要它。

功能需求是系统为实现业务需求而必须执行的操作 。 因此,功能要求与开发的解决方案或软件有关。 我们为以上示例制定功能要求:


  • 系统应通过GPS / GLONASS显示员工的经度和纬度;
  • 系统应在地图上显示员工的位置;
  • 该系统应允许经理将通知发送给他们的现场下属。

我们重点介绍以下功能:


  • 功能要求总是从系统的角度来编写的;
  • 它们更加具体和详细;
  • 得益于功能需求的满足,开发出了满足业务需求和客户目标的有效解决方案;
  • 回答“ 如何 ”的问题。 系统如何解决业务需求。

关于对设计或实现施加限制的非功能性要求(也称为“质量要求”),应该说几句话(例如,对性能,安全性,可用性,可靠性的要求)。 这些要求回答了系统应为“ 什么 ”的问题。


发展是将业务需求转换为功能需求。 应用编程是功能需求的实现,而系统是非功能性的。


用例


在商业系统中,实现功能需求通常是最复杂的。 在纯体系结构中,功能需求是通过用例层实现的。


但是对于初学者来说,我想转向源头。 Ivar Jacobson- 用例的定义的作者,UML的作者之一和RUP方法,在他的文章Use-Case 2.0中。软件开发中心确定了使用用例的 6条原则:


  1. 通过讲故事使它们变得简单;
  2. 制定战略计划,了解整体情况;
  3. 专注于意义;
  4. 将系统分层排列;
  5. 逐步交付系统;
  6. 满足团队的需求。

我们简要地考虑了每个原则,它们对于我们进一步理解很有用。 以下是我的免费翻译,带有缩写和插入词,强烈建议您熟悉原始译文。


讲故事简单


叙事是我们文化的一部分; 这是将知识,信息从一个人转移到另一个人的最简单,最有效的方法。 这是传达系统应该做什么并帮助团队专注于共同目标的最佳方法。


用例反映了系统目标。 要了解用例,我们要讲一个故事。 故事讲述了如何实现目标以及如何解决在此过程中出现的问题。 用例(如故事书)提供了一种以简单,全面的方式识别和覆盖所有不同但相关的故事的方法。 这使得收集,分发和理解系统需求变得容易。


该原理与DDD方法中的Ubiques语言相关。


了解整体情况


无论您开发的系统是大型,小型,软件,硬件还是业务,了解全局都是非常重要的。 如果不了解整个系统,就无法做出正确的决定,包括要包含在系统中的内容,要排除的内容,花费的成本以及带来的好处。


Ivar Jacobson建议使用用例图 ,这对于收集需求非常方便。 如果需求已被编译并且明确,那么Eric Evans的Context map是最佳选择。 通常,对Scrum方法进行解释是为了使人们不必花时间在战略计划上,而是在两个多星期后考虑规划过去的遗迹。 杰夫·萨瑟兰(Jeff Sutherland)的宣传工作落在了水流上,那些完成了为期两周的Scrum Master培训课程的人们得以完成工作。 但是常识承认战略计划的重要性。 无需制定详细的战略计划,但应该这样做。


注重价值


当试图了解系统的使用方式时,始终着重于将其提供给用户和其他相关方的价值始终很重要。 只有在使用系统时才形成值。 因此,将重点放在系统将如何应用上比将其提供的功能或功能列表长得多。


用例提供了这一重点,可帮助您专注于特定用户将如何使用该系统来实现其目标。 用例涵盖了许多使用系统的方法:成功实现目标的方法和解决出现的任何困难的方法。


此外,作者提出了一个很棒的方案,应该密切注意:



该图显示了一个用例“在ATM上提取现金”。 基本方向 (基本流程)中描述了实现目标的最简单方法。 其他情况称为替代流程。 这些指导有助于讲故事,构建系统并帮助编写测试。


分层


大多数系统在准备使用之前需要进行大量工作。 他们有许多要求,其中大多数取决于其他要求,必须在满足和评估要求之前先实施它们。


一次创建一个这样的系统是一个很大的错误。 该系统应该由各个部分组成,每个部分都对用户具有明确的价值。


这些想法与敏捷方法和领域想法共鸣。


分步产品发布


大多数软件系统已经发展了许多代。 它们不是一次生产的。 它们是作为一系列发行版本构建的,每个发行版本都是在先前发行版本的基础上构建的。 即使是发行版本身也常常不会一次发布,而是通过一系列中间版本进行开发。 每个步骤都提供了系统的清晰,可用版本。 这是应该创建所有系统的方式。


满足团队需求


不幸的是,没有通用的解决方案可以解决软件开发问题。 不同的团队和不同的情况需要使用不同的样式和不同的详细程度。 无论选择哪种方法和技术,都必须确保它们具有足够的适应性,可以满足团队的当前需求。


埃里克·埃文斯(Eric Evans)在他的书中敦促您不要花费大量时间描述通过UML进行的所有过程。 使用任何视觉方案就足够了。 正如UML作者自己所说的那样,不同的团队,不同的项目需要不同的详细程度。


实作


在纯架构中,Robert Martin定义了以下用例


这些用例协调了与实体之间的数据流,并指示这些实体使用其关键业务规则来实现用例的目标。

让我们尝试将这些想法转化为代码。 让我们从使用用的第三原则中回顾该方案,并将其作为基础。 考虑一个非常复杂的业务流程:“烹饪白菜派”。


让我们尝试分解它:


  • 检查产品可用性;
  • 从库存中取出它们;
  • 揉面团;
  • 让面团上升;
  • 准备馅料;
  • 做馅饼
  • 烤馅饼。

我们通过Interactor来实现整个序列,并且每个步骤都将通过服务层上的功能或功能对象来实现。


动作顺序(交互者)


循序渐进


我强烈建议您通过《 行动序列》开始开发复杂的业务流程。 更确切地说,不是这样,您必须确定业务流程所属的域域 。 阐明所有业务需求。 识别过程中涉及的所有实体 。 在知识库中记录每个实体的要求和定义。


逐步将所有内容画在纸上。 有时您需要一个序列图。 它的作者与发明用例的人相同-Ivar Jacobson。 该图是他在基于中继电路为Erickson开发电话网络维护系统时发明的。 我真的很喜欢这张图,我认为术语“ 序列”比术语“ 交互器”更具表达力。 但是鉴于后者的普遍性,我们将使用熟悉的术语-Interactor


当您描述业务流程时,有一点提示对您有帮助,文档管理的主要规则可能变成:“由于任何业务活动,都应草拟文档”。 例如,我们正在开发折扣系统。 提供折扣,实际上,从业务的角度来看,我们达成了公司与客户之间的协议。 所有条件必须在本合同中阐明。 也就是说,在DiscountSystem域中,您将拥有Entites :: Contract。 不要将折扣与客户绑定,而是创建一个实体合同,该合同描述了其提供的规则。


在业务流程对所有参与其开发的人员都变得透明并且您的所有知识都已固定之后,让我们返回对业务流程的描述。 我建议您从“动作序列”开始编写代码。


序列设计模板负责:


  • 行动顺序;
  • 动作之间传输数据的协调;
  • 处理动作在执行期间犯下的错误;
  • 返回一组已完成的动作的结果;
  • 重要说明 :此设计模式最重要的职责是实现业务逻辑。

如果我们有某种复杂的流程,我想更详细地说明最后的责任-我们必须以一种清晰的方式描述它,而无需深入技术细节。 您应该在编程技能允许的情况下,将其描述为富有表现力的 。 将此课程委托给团队中经验最丰富的成员。


让我们回到馅饼:让我们尝试通过Interactor描述其准备过程。


实作


我在上一篇文章中介绍了我们的LunaPark解决方案的示例实现。


module Kitchen module Sequences class CookingPieWithabbage < LunaPark::Interactors::Sequence TEMPERATURE = Values::Temperature.new(180, unit: :cel) def call! Services::CheckProductsAvailability.call list: ingredients dough = Services::BeatDough.call from: Repository::Products.get(beat_ingredients) filler = Services::MakeabbageFiller.call from: Repository::Products.get(filler_ingredients) pie = Services::MakePie.call dough, with: filler bake = Services::BakePie.new pie, temp: TEMPERATURE sleep 5.min until bake.call pie end private attr_accessor :beat_ingredients, :filler_ingredients attr_accessor :pie def ingredients_list beat_ingredients_list + filler_ingredients_list end end end end 

如我们所见, call! 描述了蛋糕烘焙过程的整个业务逻辑。 而且使用它来了解应用程序的逻辑很方便。


另外,我们可以通过用MakeabbageFiller代替MakeabbageFiller轻松描述烤鱼饼的过程。 因此,我们无需更改大量代码即可快速更改业务流程。 而且,我们可以同时保留两个序列 ,以扩展业务案例。


安排


  • call!方法call! 是必需的方法;它描述了Actions的顺序。
  • 每个初始化参数都可以通过setter或attr_acessor描述:

 class Foo < LunaPark::Interactors::Sequence # ... private attr_accessor :bar end Foo.call(bar: 42) 

  • 其余方法应该是私有的。

使用范例


 beat_ingredients = [ Entity::Product.new :flour, 500, :gr, Entity::Product.new :oil, 50, :gr, Entity::Product.new :salt, 1, :spoon, Entity::Product.new :milk, 150, :ml, Entity::Product.new :egg, 1, :unit, Entity::Product.new :yeast, 1, :spoon ] filler_ingredients = [ Entity::Product.new :cabbage, 500, :gr, Entity::Product.new :salt, 1, :spoon, Entity::Product.new :pepper, 1, :spoon ] cooking = CookingPieWithabbage.call( beat_ingredients: beat_ingredients, filler_ingredients: filler_ingredients ) #   : cooking.success? # => true cooking.fail # => false cooking.fail_message # => '' cooking.data # => Entity::Pie #   : cooking.success? # => false cooking.fail # => true cooking.fail_message # => 'The pie burned out' cooking.data # => nil 

该过程通过该对象表示,并且我们具有调用该对象的所有必要方法-调用是否成功,调用期间是否发生任何错误,如果是,则选择哪个?


错误处理


如果现在我们回想起用例应用程序的第三项原则,那么请注意以下事实:除主线外 ,我们还有替代指导。 这些是我们必须处理的错误。 举一个例子:我们当然不希望事件发生那样的事情,但是我们对此无能为力,严酷的现实是馅饼会定期燃烧。


Interactor拦截从LunaPark::Errors::Processing类继承的所有错误。


我们如何跟踪蛋糕? 为此,请在BakePie Action中定义Burned错误。


 module Kitchen module Errors class Burned < LunaPark::Errors::Processing; end end end 

并在烘烤过程中,检查我们的馅饼是否未燃尽:


 module Kitchen module Services class BakePie < LunaPark::Callable def call # ... rescue Errors::Burned, 'The pie burned out' if pie.burned? # ... end end end end 

在这种情况下,错误陷阱将起作用,并且我们将能够在处理它们。
未从Processing继承的错误被视为系统错误,并将在服务器级别被拦截。 除非另有说明,否则用户将收到500 ServerError。


练习使用


1.尝试在call方法中描述所有调用!


您不应该单独的方法中实现每个Action ,这会使代码更加膨胀。 您必须多次看全班才能理解它的工作原理。 破坏烤馅饼的配方:


 module Service class CookingPieWithabbage < LunaPark::Interactors::Sequence def call! check_products_availability make_cabbage_filler make_pie bake end private def check_products_availability Services::CheckProductsAvailability.call list: ingredients end # ... end end 

直接在教室中使用动作通话。 从红宝石的角度来看,这种方法可能看起来很不寻常,因此看起来更具可读性:


 class DrivingStart < LunaPark::Interactors::Sequence def call! Service::CheckEngine.call Service::StartUpTheIgnition.call car, with: key Service::ChangeGear.call car.gear_box, to: :drive Service::StepOnTheGas.call car.pedals[:right] end end 

2.如果可能,使用调用类方法


 # good - ,   ,  . #    . Sequence::RingingToPerson.call(params) # good -   ,      e, #    ,     , #    . ring = Sequence::RingingToPerson.new(person) unless ring.success? ring.call sleep 5.min end 

3.不要为了键入代码而创建Functional对象 ;请根据情况进行查看


 # bad -        ,  #     . module Services class BuildUser < LunaPark::Callable def initialize(first_name:, last_name:, phone:) @first_name = first_name @last_name = last_name @phone = phone end def call Entity::User.new( first_name: first_name, last_name: last_name, phone: phone ) end private attr_reader :first_name, :last_name, :phone end end module Sequences class RegisteringUser < LunaPark::Interactors::Sequence attr_accessor :first_name, :last_name, :phone def call! user = Service::BuildUser.call(first_name: first_name, last_name: last_name, phone: phone) end end end # good -     ,  . #        , #       . module Sequences class RegisteringUser < LunaPark::Interactors::Sequence attr_accessor :first_name, :last_name, :phone def call! user #... end private def user @user = Entity::User.new( first_name: first_name, last_name: last_name, phone: phone ) end end end 

服务层


数码相机


正如我们所说,Interactor在最高层次上描述了业务逻辑。 服务层 (Service layer)已经揭示了功能需求实施的细节。 如果我们正在谈论做馅饼,那么在Interactor的层面上,我们只是说“揉面团”,而没有涉及如何揉面团的细节。 捏合过程在“ 服务”级别进行描述。 让我们回到原始的蓝皮书


在应用域中,有些操作无法在Entity或Value Object类型的对象中找到自然位置。 它们本质上不是对象,而是活动。 但是,由于建模范例的基础是对象方法,因此我们将尝试将它们变成对象。


在这一点上,很容易犯一个常见的错误:放弃将操作放在适合它的合适对象中的尝试,从而进行过程编程。 但是,如果将操作强行置于定义与其无关的对象中,则会使该对象本身失去其纯度,从而使其难以理解和重构。 如果在一个简单的对象中实现许多复杂的操作,它可能会变成难以理解的内容,您正在做的事情不清楚。 这样的操作通常涉及主题区域的其他对象,并且它们之间的协调被执行以执行联合任务。 附加责任在对象之间创建了依赖链,混合了可以独立考虑的概念。


选择功能的实现位置时,请始终使用常识。 您的任务是使模型更具表现力。 让我们看一个例子,“我们需要砍木头”:


 module Entities class Wood def chop # ... end end end 

此方法将是一个错误。 木柴不会自己砍,我们需要一把斧头:


 module Entities class Axe def chop(sacrifice) # ... end end end 

如果我们使用简化的业务模型,那就足够了。 但是,如果需要对流程进行更详细的建模,我们将需要一个人将这些柴砍掉,也许还需要一些原木作为流程的架子。


 module Entities class Human def chop_firewood(wood, axe, chock) # ... end end end 

您可能已经猜到了,这不是一个好主意。 并非我们所有人都从事砍伐木材,这不是一个人的直接职责。 我们经常看到Ruby on Rails中的模型有多超载,其中包含相似的逻辑:获得折扣,向购物篮中添加商品,向余额中取钱。 此逻辑不适用于实体,但适用于该实体所涉及的过程。


 module Services class ChopFirewood # ... end end 

在弄清楚我们存储在服务中的逻辑之后我们将尝试实现其中一种。 通常,服务是通过方法或功能对象实现的。


功能对象


功能对象满足一个功能要求。 功能对象以其最原始的形式具有一个单一的公共方法call


 module Serivices class Sum def initialize(x, y) @x = x @y = y end def call x + y end def self.call(x,y) new(x,y).call end private attr_reader :x, :y end end 

这样的对象有几个优点:简洁,易于测试。 有一个缺点,这种对象可能会变成很多。 有几种方法可以对相似的对象进行分组;在我们的项目的一部分中,我们按类型对它们进行划分:


  • 服务对象 (Service)-一个对象,创建一个新的对象;
  • Command (命令)-更改当前对象;
  • 监护人 (Guard)-如果出现问题,则返回错误。

服务对象


在我们的实现中, 服务 -实现功能要求并始终返回值。


 module KorovaMilkBar module Services class FindMilk < LunaPark::Callable GLASS_SIZE = Values::Unit.wrap '200g' def initialize(fridge:) @fridge = fridge end def call fridge.shelfs.find { |shelf| shelf.has?(GLASS_SIZE, of: :milk) } end private attr_reader :fridge end end end FindMilk.call(fridge: the_red_one) # => #<Glass: ... > 

指挥部


在我们的实现中, Command-执行一个Action ,如果true返回true,则修改该对象。 实际上, 团队不会创建对象,而是修改现有对象。


 module KorovaMilkBar module Commands class FillGlass < LunaPark::Callable def initialize(glass, with:) @glass = glass @content = with end def call glass << content true end private attr_reader :fridge end end end glass = Glass.empty milk = Milk.new(200, :gr) glass.empty? # => true FillGlass.call glass, with: milk # => true glass.empty? # => false 

守护者(Guard)


值班人员执行逻辑检查,如果出现故障,则给出处理错误。 这种类型的对象不会以任何方式影响主方向 ,但是如果出现问题,则会将我们切换到替代方向


提供牛奶时,重要的是要确保牛奶新鲜:


 module KorovaMilkBar module Guards class IsFresh < LunaPark::Callable def initialize(product) @products = products end def call products.each do |product| raise Errors::Rotten, "#{product.title} is not fresh" if product.expiration_date > Date.today end nil end private attr_reader :products end end end 

您可能会发现按类型分隔功能对象很方便。 您可以添加自己的工具,例如Builder-基于参数创建对象。


安排


  • call方法是唯一的强制公共方法。
  • initialize方法是唯一的可选public方法。
  • 其余方法应该是私有的。
  • 逻辑错误必须从LunaPark::Errors::Processing类继承。

错误处理


操作操作期间可能发生两种错误。


运行时错误

由于违反处理逻辑,可能会发生此类错误。


例如:


  • 创建用户电子邮件时保留;
  • 当您尝试喝牛奶时,牛奶就结束了;
  • 另一个微服务拒绝了该操作(出于逻辑原因,而不是因为该服务不可用)。

用户极有可能希望了解这些错误。 另外,这些可能是错误
我们可以预见。


此类错误应继承自LunaPark::Errors::Processing


系统错误

由于系统崩溃而发生的错误。


例如:


  • 数据库不起作用;
  • 东西除以零。

我们很可能无法预见这些错误,也不能对用户说任何话,除非一切都非常糟糕,并向开发人员发送报告以要求采取行动。 此类错误应继承自SystemError


还有验证错误 ,我们将在下一篇文章中更详细地讨论。


练习使用


1.使用变量提高可读性


 module Fishing # bad -   Serivices::Catch.call(fish, rod) # bad -  Serivices::Catch.call(fish: fish, rod: rod) # good -   Serivices::Catch.call(fish, with: rod) module Serivices class Catch def initialize(fish, with:) @fish = fish @rod = with #      #   . end # ... private attr_reader :fish, :rod end end end 

2.传递对象,而不传递参数


如果不是参数处理的目的,请尝试简化初始化程序。
传递对象,而不传递参数。


 module Service # bad -        -.  #      ,   . class Foo def initialize(foo_params:, bar_params:) @foo = Values::Foo.new(*foo_params) @bar = Values::Bar.new(*bar_params) end # ... end Services::Foo.call(foo: {a: 1, b: 2}, bar: 34) # good -   -. class Bar def initialize(foo:, bar:) @foo = foo @bar = bar # ... end end foo = Values::Foo.new(a: 1, b: 2) bar = Values::Bar.new(34) Services::Bar.call(foo: foo, bar: bar) # good -       - Builder. class BuildFoo def initialize(param_1:, param_2:) @param_1 = param_1 @param_1 = param_1 end def call Foo.new( param_1: param_1.foo, param_2: param_2.bar, param_3: some_magick ) end # ... end end 

3.使用名称Actions-动作的动词和影响的对象。


 # bad module Services class Milk; end class Work; end class FooBuild; end class PasswordGenerator; end end # good module Services class GetMilk; end class WorkOnTable; end class BuildFoo; end class GeneratePassword; end end 

4.如果可能,使用调用类方法


通常是Actions类的实例,除了编写调用之外很少使用。


 # good -    . Services::BuildFoo.call(params) # good -     Services::BuildFoo.(params) # good -   ,      , #    ,     ,   #  . ring = Services::RingToPhone.new(phone: neighbour) 10.times do ring.call end 

5.错误处理不是服务任务


 # bad -    ,   . def call #... rescue SystemError => e return false end 

模组


在此之前,我们将服务层的实现视为一组功能对象。 但是我们可以轻松地将方法放置在这一层上:


 module Services def sum(a, b) a + b end end 

我们面临的另一个问题是大量的服务设施。 取而代之的是“服务胖文件夹”,而不是“边缘胖模型”。 有几种方法来组织结构以减少悲剧的规模。 埃里克·埃文斯(Eric Evans)通过将多个功能组合到一个类中来解决此问题。 想象一下,我们需要对保姆Arina Rodionovna的业务流程进行建模,她可以喂养普希金并将其卧床不起:


 class NoonService def initialize(arina_radionovna, pushkin) # ... end def to_feed # ... end def to_sleep # ... end end 

从OOP的角度来看,这种方法更为正确。 但是我们建议至少在初始阶段就放弃它。 经验不足的程序员开始在此类中编写大量代码,最终导致连接性增加。 相反,您可以使用将活动表示为某种抽象的模块:


 module Services module Noon class ToFeed def call! # ... end end class << self #    ,   #    def to_feed(arina_radionovna, pushkin) ToFeed.new(arina_radionovna, pushkin).call end #    ,    def to_sleep(arina_radionovna, pushkin) arina_radionovna.tell_story pushkin pushkin.state = :sleep end end end end 

当划分为模块时,应观察到较低的外部耦合(低耦合)和较高的内部凝聚力(高凝聚力),但是我们使用诸如服务或交互器之类的模块,这也与纯体系结构的思想背道而驰。 这是有意识的选择,有助于感知。 通过文件的名称,我们了解该类或该类实现的设计模式,如果对于有经验的程序员而言,这是显而易见的,那么对于初学者而言,并非总是如此。 团队准备就绪后,请丢弃多余的部分。


引用另一本大蓝皮书的小摘录:


选择能说明系统历史并包含一致概念集的模块。 因此,模块之间相互之间的依赖性很低。 但是,如果不是这样,请找到一种方法来更改模型,以使概念彼此分离,或者寻找模型中缺少的概念,这可能成为模块的基础,从而以一种自然,有意义的方式将模型的元素整合在一起。 从某种意义上说,可以相互独立地分析和感知不同模块中的概念,从而实现模块之间的低依赖性。 优化模型,直到根据主题区域的高级概念在其中出现自然边界,并且相应的代码未作相应划分。


为模块名称指定将包含在UNIFIED LANGUAGE中的名称。 模块本身及其名称都应反映出对主题领域的了解和理解。


这些模块的主题很大且很有趣,但是显然它超出了本文的主题范围。 下次我们将与您讨论存储库适配器 。 我们打开了一个舒适的电报频道 ,我们希望在此共享有关DDD的资料。 我们欢迎您的问题和反馈。




Source: https://habr.com/ru/post/zh-CN454668/


All Articles