正统后端



现代的后端是多种多样的,但是仍然遵守一些潜规则。 我们中许多开发服务器应用程序的人都面临着公认的方法,例如Clean Architecture,SOLID,Persistence Ignorance,Dependency Injection等。 服务器开发的许多属性是如此刻板,以至于它们不会引起任何问题,而且会被不加思索地使用。 他们谈论很多,但从未使用过。 其余的含义被错误地解释或扭曲。 本文讨论了如何构建一个简单,完全典型的后端体系结构,该体系结构不仅可以遵循著名编程理论家的戒律,而且不会造成任何损害,还可以在一定程度上对其进行改进。

献给所有不认为编程没有美感并且在荒谬之中不接受美感的人。

领域模型


建模是理想世界中软件开发的起点。 但是我们都不是十全十美的,我们谈论了很多,但是我们像往常一样做所有事情。 通常的原因是现有工具的不完善。 老实说,我们的懒惰和害怕承担责任以摆脱“最佳做法”。 在一个不完美的世界中,软件开发充其量只是从脚手架开始,而最糟糕的是,性能优化无法完成任何工作。 尽管如此,我还是想抛弃“杰出”建筑师的辛勤事例,而去猜测更平凡的事情。

因此,我们有一项技术任务,甚至还有一个用户界面设计(如果没有提供UI,则没有)。 下一步是在域模型中反映需求。 首先,为了清晰起见,您可以绘制模型对象图:



然后,通常,我们开始根据模型的实现方式(即编程语言,对象关系转换器(Object-Relational Mapper,ORM))或某种复杂的框架(例如ASP.NET MVC或Ruby on Rails)来投影模型-开始编写代码。 在这种情况下,我们遵循框架的路径,我认为这在基于模型的开发框架中是不正确的,无论最初看起来多么方便。 在这里,您做出了一个巨大的假设,该假设随后否定了基于域的开发的好处。 作为一种较自由的选择,不受任何工具范围的限制,我建议仅使用编程语言的语法工具来构建主题区域的对象模型。 在我的工作中,我使用几种编程语言-C#,JavaScript,Ruby。 命运已下令,Java和C#生态系统是我的灵感来源,JS是我的主要收入,而Ruby是我喜欢的语言。 因此,我将继续在Ruby中展示一些简单的示例:我相信这不会给其他语言的开发人员带来麻烦。 因此,将模型移植到Ruby中的Invoice类:

class Invoice attr_reader :amount, :date, :created_at, :paid_at def initialize(attrs, payment_service) @created_at = DateTime.now @paid_at = nil @amount = attrs[:amount] @date = attrs[:date] @subscription = attrs[:subscription] @payment_service = payment_service end def pay credit_card = @subscription.customer.credit_card amount = @subscription.plan.price @payment_service.charge(credit_card, amount) @paid_at = DateTime.now end end 

即 我们有一个其构造函数接受属性的哈希值,对象依赖项并初始化其字段的类,以及一个可以更改对象状态的pay方法。 一切都非常简单。 现在,我们不再考虑如何以及在何处显示和存储该对象。 它只是存在,我们可以创建它,更改其状态,与其他对象进行交互。 请注意,该代码不包含任何外来工件,例如BaseEntity和与该模型无关的其他垃圾。 这很重要。 顺便说一句,在这个阶段,我们已经可以使用存根对象而不是诸如payment_service之类的依赖项通过测试(TDD)开始开发了:

 RSpec.describe Invoice do before :each do @payment_service = double(:payment_service) allow(@payment_service).to receive(:charge) @amount = 100 @credit_card = CreditCard.new({...}) @customer = Customer.new({credit_card: @credit_card, ...}) @subscription = Subscription.new({customer: customer, ...}) @invoice = Invoice.new({amount: @amount, date: DateTime.now, @subscription: subscription}, payment_service) end describe 'pay' do it "charges customer's credit card" do expect(@payment_service).to receive(:charge).with(@credit_card, @amount) @invoice.pay end it 'makes the invoice paid' do expect(@invoice.paid_at).not_to be_nil @invoice.pay end end end 

甚至使用解释器中的模型(Ruby的Irb),尽管不是很友好,但它可能是用户界面:

 irb > invoice = Invoice.new({amount: @amount, date: DateTime.now, @subscription: subscription}, payment_service) irb > invoice.pay 

为什么在此阶段避免“外来人工制品”如此重要? 事实是,模型完全不知道如何保存或是否保存。 最后,对于某些系统,将对象直接存储在内存中可能是非常合适的。 在建模时,我们必须完全从这个细节中抽象出来。 这种方法称为持久性无知。 应该强调的是,无论是关系数据库还是任何其他数据库,我们都不会忽略使用存储库的问题,我们只会在建模阶段忽略与存储库交互的细节。 持久性无知意味着从模型本身中有意消除使用模型状态的机制以及与此过程相关的所有元数据。 范例:

 #  class User < Entity #     table :users #     # mapping  field :name, type: 'String' #   def save ... end end user = User.load(id) #     user.save #     

 #  class User #   ,      attr_accessor :name, :lastname end user = repo.load(id) #     repo.save(user) #     

这种方法也是由于根本原因-遵守唯一责任原则(单一责任原则,在SOLID中为S)。 如果该模型除了其功能组件之外还描述了状态保存参数并处理其保存和加载,那么显然它承担了太多的责任。 持久性无知所带来的并非最后的优势是能够在开发过程中替换存储工具,甚至替换存储本身的类型。

模型视图控制器


MVC概念在不同语言和平台的各种(不仅是服务器)应用程序的开发环境中非常流行,以至于我们不再考虑它是什么以及为什么需要它。 这个缩写词给我带来的最多疑问是“控制器”。 从组织代码结构的角度来看,对模型进行操作分组是一件好事。 但是控制器根本不应该是一个类,而应该是一个包含用于访问模型的方法的模块。 不仅如此,它是否应该有位置? 作为遵循.NET-> Ruby-> Node.js路径的开发人员,我对在express.js框架内实现的JS(ES5)控制器很感兴趣。 有能力以更实用的方式解决分配给控制器的任务,开发人员如痴如醉,一次又一次地写出了神奇的“控制器”。 为什么典型的控制器不好?

典型的控制器是一组彼此之间不密切相关的方法,仅由一个方法(模型的某种本质)结合在一起。 有时不仅如此,更糟。 每个单独的方法可能需要不同的依赖性。 展望未来,我注意到我是依赖反转实践(Dependency Inversion,SOLID中的D)的支持者。 因此,我需要在外部的某个地方初始化这些依赖项,并将它们传递给控制器​​构造函数。 例如,在创建新帐户时,我必须将通知发送给需要通知服务的会计,而在其他方法中,则不需要:

 class InvoiceController def initialize(invoice_repository, notification_service) @repository = invoice_repository @notification_service = notification_service end def index @repository.get_all end def show(id) @repository.get_by_id(id) end def create(data) @repository.create(data) @notification_service.notify_accountant end end 

在这里,这个想法可分为用于处理模型的方法分成单独的类,为什么不呢?

 class ListInvoices def initialize(invoice_repository) @repository = invoice_repository end def call @repository.get_all end end class CreateInvoice def initialize(invoice_repository, notification_service) @repository = invoice_repository @notification_service = notification_service end def call @repository.create(data) @notification_service.notify_accountant end end 

好吧,现在有了代替控制器的一组用于访问模型的“功能”,顺便说一句,这些功能也可以使用例如文件系统目录来构造。 现在您需要将这些方法“开放”到外部,即 整理路由器之类的东西。 作为一个对各种DSL(特定于域的语言)感兴趣的人,我更希望对Web应用程序的指令进行更直观的描述,而不是Ruby或其他用于指定路由的通用语言中的技巧:

 `HTTP GET /invoices -> return all invoices` `HTTP POST /invoices -> create new invoice` 

或至少

 `HTTP GET /invoices -> ./invoices/list_invoices` `HTTP POST /invoices -> ./invoices/create` 

这与典型的路由器非常相似,唯一的区别是它不与控制器交互,而直接与模型上的动作交互。 显然,如果要发送和接收JSON,则必须注意对象的序列化和反序列化以及更多其他工作。 一种或另一种方式,我们可以摆脱控制器,将其部分责任转移到目录结构和更高级的路由器上。

依赖注入


我故意写了“更高级的路由器”。 为了使路由器能够真正在声明级别使用依赖注入机制允许模型上的动作流,它在内部可能应该非常复杂。 他的工作总体方案应如下所示:



如您所见,我的整个路由器都使用IoC容器进行了依赖注入。 为什么还要这样做? “依赖关系注入”的概念可以追溯到依赖关系反转技术,该技术旨在通过将依赖关系初始化移出对象的使用范围来减少对象的连接性。 一个例子:

 class Repository; end #  (   ) class A def initialize @repo = Repository.new end end #  (   ) class A def initialize(repo) @repo = repo end end 

这种方法极大地帮助了那些使用测试驱动开发的人。 在上面的示例中,我们可以轻松地在构造函数中放置一个存根,而不是与其接口相对应的实际存储库对象,而无需“破解”对象模型。 这不是唯一的DI奖励:正确应用此方法将为您的应用程序带来很多令人愉悦的魔力,但是首先要考虑的是。 依赖注入是一种允许您将依赖倒置技术集成到完整体系结构解决方案中的方法。 实施工具通常是IoC-(控制反转)容器。 在Java和.NET世界中,有很多非常酷的IoC容器,其中有数十个。 不幸的是,在JS和Ruby中,没有适合我的选项。 我特别看了干容器( dry-container )。 这就是我的班级使用它的样子:

 class Invoice include Import['payment_service'] def pay credit_card = @subscription.customer.credit_card amount = @subscription.plan.price @payment_service.charge(credit_card, amount) end end 

我们不用笨拙地使用构造函数,而是通过引入我们自己的依赖关系来加重类的负担,这在初始阶段使我们脱离了干净且独立的模型。 好吧,有些事情,该模型完全不应该了解IoC! 对于诸如CreateInvoice之类的操作,这是正确的。 对于给定的情况,在我的测试中,我已经不得不使用IoC作为不可剥夺的东西。 这是完全错误的。 大多数情况下,应用程序对象不应该知道IoC的存在。 经过大量的搜索和思考, 我画出了自己的IoC ,这并不那么令人讨厌。

保存和加载模型


持久性无知需要一个不引人注目的对象转换器。 在本文中,我的意思是使用关系数据库,要点对于其他类型的存储来说都是正确的。 对象关系转换器-ORM(对象关系映射器)用作关系数据库的类似转换器。 在.NET和Java的世界中,有大量真正强大的ORM工具。 它们都有一些或其他小的缺陷,您可以闭上眼睛。 JS和Ruby中没有好的解决方案。 所有这些都以一种或另一种方式将模型严格地绑定到框架上,并强制声明外来元素,更不用说持久性无知的不适用性了。 与IoC一样,我考虑过自己实现ORM,这就是Ruby中的事务状态。 我并没有从头开始做所有事情,而是以一个简单的ORM Sequel为基础,它提供了用于处理不同关系DBMS的简单工具。 首先,我对以常规SQL形式执行查询的能力感兴趣,能够在输出处接收字符串数组(哈希对象)。 只剩下实现您的Mapper和提供持久性无知。 如前所述,我不想将映射字段混入域模型中,因此我实现了Mapper,以便它使用类型格式的单独配置文件:

 entity Invoice do field :amount field :date field :start_date field :end_date field :created_at field :updated_at reference :user, type: User reference :subscription, type: Subscription end 

使用Repository类型的外部对象,实现Persistence Ignorance非常简单:

 repository.save(user) 

但是我们将更进一步,实现工作单元模式。 为此,您需要强调会议的概念。 会话是一个随时间而存在的对象,在此期间,对模型执行一组操作,这是一个逻辑操作。 在会话过程中,可能会发生加载和更改模型对象的情况。 在会话结束时,将保存模型的事务状态。
工作单元示例:

 user = session.load(User, id: 1) plan = session.load(Plan, id: 1) subscription = Subscription.new(user, plan) session.attach(subscription) invoice = Invoice.new(subscription) session.attach(invoice) # ... # -       if Date.today.yday == 1 subscription.comment = 'New year offer' invoice.amount /= 2 end session.flush 

结果,将在数据库中执行2条指令,而不是4条指令,并且两条指令都将在同一事务中执行。

然后突然想起存储库! 就像控制器一样,这里有种似曾相识的感觉:存储库不是同一基本实体吗? 展望未来,我会回答-是的。 存储库的主要目的是保存业务逻辑层免于与实际存储进行交互。 例如,在关系数据库的上下文中,这意味着直接在业务逻辑代码中编写SQL查询。 无疑,这是一个非常合理的决定。 但是回到我们摆脱控制器的那一刻。 从OOP的角度来看,存储库实质上是相同的控制器-相同的方法集,不仅用于处理请求,而且用于处理存储库。 存储库也可以分为动作。 所有迹象表明,这些动作与我们提出的代替控制器的建议没有任何不同。 也就是说,我们可以拒绝存储库和控制器,而只支持一个统一的Action!

 class LoadPlan def initialize(session) @session = session end def call sql = <<~SQL SELECT p.* AS ENTITY plan FROM plans p WHERE p.id = 1 SQL @session.fetch(Plan, sql) end end 

您可能已经注意到,我使用SQL而不是某种对象语法。 这是一个品味问题。 我更喜欢SQL,因为它是一种查询语言,是一种用于处理数据的DSL。 显然,编写Plan.load(id)总是比相应的SQL容易,但这是很普通的情况。 当涉及到稍微复杂的事情时,SQL成为非常受欢迎的工具。 有时,您会诅咒另一个ORM,以使它像纯SQL一样运行,“我会在几分钟内完成”。 对于那些有疑问的人,我建议查看MongoDB文档 ,其中的解释是以类似于SQL的形式给出的,看起来很有趣! 因此,我为我的目的而编写的ORM JetSet中用于查询的接口是具有最少浸渍的SQL,例如“ AS ENTITY”。 顺便说一句,在大多数情况下,我不使用模型对象,各种DTO等来显示表格数据-我只是编写一个SQL查询,获取一个哈希对象数组并将其显示在视图中。 通过某种方式,很少有人通过将相关表投影到模型上来设法“滚动”大数据。 实际上,更可能使用平面投影(视图),并且当开始使用更复杂的解决方案(例如CQRS(命令和查询责任隔离))时,非常成熟的产品进入优化阶段。

全部放在一起


所以我们有:

  • 我们找到了如何加载和保存模型的方法,还设计了模型的网络交付工具(即特定的路由器)的粗略体系结构;
  • 我们得出的结论是,不属于主题领域的所有逻辑都可以被带入Actions(动作),而不是控制器和存储库。
  • 动作必须支持依赖注入
  • 实施了体面的工具依赖注入;
  • 实现了必要的ORM。

剩下的唯一事情就是实现相同的“路由器”。 由于我们摆脱了存储库和控制者的青睐,而倾向于使用操作,因此很明显,对于一个请求,我们将需要执行多个操作。 行动是自主的,我们不能相互投资。 因此,作为Dandy框架的一部分我实现了一个路由器,该路由器使您可以创建动作链。 配置示例(注意/计划):

 :receive .-> :before -> common/open_db_session GET -> welcome -> :respond <- show_welcome /auth -> :before -> current_user@users/load_current_user /profile -> GET -> plan@plans/load_plan \ -> :respond <- users/show_user_profile PATCH -> users/update_profile /plans -> GET -> current_plan@plans/load_current_plan \ -> plans@plans/load_plans \ -> :respond <- plans/list :catch -> common/handle_errors 

“获取/授权/计划”显示所有可用的订阅计划,并“突出显示”当前的订阅计划。 发生以下情况:

  1. “:之前-> common / open_db_session”-打开JetSet会话
  2. / auth“:之前-> current_user @用户/ load_current_user”-加载当前用户(通过令牌)。 结果以current_user(current_user @指令)的形式记录在IoC容器中。
  3. / auth / plans“ current_plan @ plans / load_current_plan”-加载当前计划。 为此,从容器中获取值@current_user。 结果记录为IoC容器中的current_plan(current_plan @指令):

     class LoadCurrentPlan def initialize(current_user, session) @current_user = current_user @session = session end def call sql = <<~SQL SELECT p.* AS ENTITY plan FROM plans p INNER JOIN subscriptions s ON s.user_id = :user_id AND s.current = 't' WHERE p.id = :user_id LIMIT 1 SQL @session.execute(sql, user_id: @current_user.id) do |row| map(Plan, row, 'plan') end end end 

  4. “计划@计划/ load_plans”-加载所有可用计划的列表。 结果作为计划(“ @计划”指令)注册在IoC容器中。
  5. “:响应<-计划/列表”-已注册的ViewBuilder,例如JBuilder,绘制类型为“

     json.plans @plans do |plan| json.id plan.id json.name plan.name json.price plan.price json.active plan.id == @current_plan.id end 


与@plans和@current_plan一样,从容器中检索在先前步骤中注册的值。 通常,在Action构造函数中,您可以“排序”所需的所有内容,或者更确切地说,可以对容器中注册的所有内容进行“排序”。 细心的读者很可能会提出问题,但是在“多用户”模式下是否可以隔离此类变量? 是的,确实如此。 事实是Hypo IoC容器可以设置对象的生存期,并且可以将其绑定到其他对象的生存期。 在Dandy中,@ plans,@ current_plan,@ current_user之类的变量绑定到请求对象,并在请求完成时被销毁。 顺便说一下,JetSet会话也与请求相关联-当Dandy请求完成时,还将重置其状态。 即 每个请求都有其自己的隔离上下文。 Hypo统治着Dandy的整个生命周期,无论这种双关语在名字的字面意义上带来多么有趣。

结论


在给定体系结构的框架内,我使用对象模型来描述主题区域。 我使用诸如依赖注入之类的适当方法; 我什至可以使用继承。 但是,与此同时,所有这些动作本质上都是普通的功能,可以在声明级别上链接在一起。 当您在抽象和测试代码方面没有遇到问题时,我们以功能样式获得了所需的后端,但是具有对象方法的所有优点。 以DSL路由器Dandy为例,我们可以自由创建描述路由等所需的语言。

结论


作为本文的一部分,我对创建后端的基本方面进行了一次游览。 我再说一遍,本文是肤浅的,没有涉及许多重要的主题,例如性能优化。 我只尝试着眼于那些对社区真正有用的事情,而不是从空到空,SOLID,TDD,MVC方案的外观等等。 好奇的读者对这些术语和其他术语的严格定义可以在庞大的网络中轻松找到,更不用说商店的同事了,这些缩写是他们日常讲话的一部分。 最后,我强调,请不要将精力集中在解决问题所需要的工具上。这仅是思想有效性的证明,而不是思想本质的证明。如果您对本文感兴趣,我将编写有关这些库的单独材料。

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


All Articles