新手问题 Repository 模式如何处理多层关联?

mizuhashi · 2016年12月07日 · 最后由 mizuhashi 回复于 2016年12月09日 · 3025 次阅读

场景 1:我们有一个两层关联,在 ActiveRecord 里面这样写:

orders = Order.where(id: [1,2]).includes(order_items: :product)

在 Lotus 里应该怎么写呢?按照官方的教学,查询应该直接写成一个方法:

order_repo = OrderRepository.new
orders = order_repo.find_with_order_items_with_product_by_id([1,2]) # => Array

但是问题来了,order_items 和 product 的关联是写在 OrderItemRepository 里的,OrderRepository 怎么知道存在这个关联呢?

为了保持 OrderRepository 的纯洁,我们只能把脏活交给 Controller 干:

order_repo = OrderRepository.new
orders = order_repo.find_by_id([1,2]) # => Array
order_items = OrderItemRepository.new.find_with_product_by_orders(orders) # => Array
order_repo.bind_order_items(orders, order_items)

数据总算是取出来了,可是 order_items 和 order 的关联弄丢了,没关系,我们再用一个方法补上。

取数据的部分完成了,看上去干了不少(?),但是总觉得怪怪的,Repository 的读逻辑往外层移动了,而且很难界定各部分逻辑应该由哪个 Repository 完成。

=======

场景 2:我们要创建一个聊天,并加上一条回复,其中涉及到三个表的关联,在 Rails 里这样写:

conversation = Conversation.new
reply = conversation.replies.new(message: 'xxx')
params[:attachments].each{|x| reply.attachments.new(file: x)}
conversation.save

在 Lotus 里,必须写成:

reply = Reply.new(message: 'xxx')
attachments = params[:attachments].map{|x| Attachment.new(file: x)}
ConversationRepository.new.create_with_reply_with_attachments(conversation, reply, attachments)

class ConversationRepository < Hanami::Repository
  def create_with_reply_with_attachments(conversation, reply, attachments)
    conversation = create
    reply.conversation_id = conversation.id
    saved_reply = ReplyRepository.new.save(reply)
    attachments.each do |att|
      att.reply_id = saved_reply.id
      AttachmentRepository.new.save(att)
    end
  end
end
#其实真正lotus的repository是没有save的,我这里假装可以save entity,避免了传hash参数,如果是hash参数的话repository会承担更多的逻辑

为什么要把 reply 和 attachment 的数据都传到 create_with_reply_with_attachments 里呢?因为在 create reply 之前,attachment 不知道 reply 的 id,是不能保存的。所以你必须自己在 create_with_reply_with_attachments 里按顺序保存数据。

但是其实我们的 ConversationRepository 并不应该有 Attachment 甚至 Reply 的知识。这样的后果是,要么 Repository 变得越来越肥,要么是你还得像 Rails 一样,把 create 的逻辑写到 controller 里,这样 Repository 又再一次被架空了。

=======

以上两种场景都会有些逻辑的异位,不知道有什么好方法避免?

按我的理解,Repository 的正确用法应该是:

data = repository.query # complex sql
service.process(data)
repository.commit(data) # complex sql

其中 process 不操作数据库,只在内存里处理 entities,但是 query 和 commit 的 sql 其实都得自己写,而且这堆 sql 不知道如何 SRP。大概最后也会变成薄 service,厚 repository。

一种想法是把 data 做成一个数据表的 diff patch,在 commit 的时候自动 apply,但是这其实就是 AR 的做法。。只需要在内存里构建好数据和关联,然后 save 就够了。

的确值得思考

讲一下个人理解 Repository 的用法,不一定是 Lotus 最好的用法。

逻辑上以 Repository 和模型一对一(不一定是一张表),多个 Repository 支持一个 Service 操作的方式区分代码职责。比如

Order.where(id: [1,2]).includes(order_items: :product)

拆分为

class OrderService
  attr_reader :order_repository
  attr_reader :order_item_repository
  attr_reader :product_repository

  def initialize
    @order_repository = nil
    @order_item_repository = nil
    @product_repository = nil
  end

  def list_with_item_and_product(id_list)
    orders = @order_repository.list(id_list)
    # fill orders with items and products
    # from order_item_repository and product repository
  end
end

controllers 不处理类似代码,因为不利于代码复用。 第二个场景也是类似的方式。

class ConversationService
  attr_reader :reply_repository
  attr_reader :attachment_repository

  def initialize
    @reply_repository = nil
    @attachment_repository = nil
  end

  def save_reply(message, attachments)
    # validate parameters

    # begin transaction

    reply = Reply.new
    reply.message = message
    @reply_repository.save(reply)

    # create and save attachments

    # end transaction

    reply
  end
end

遇到类似的代码职责问题可以通过建立一个中间层来解决(但是记住不能建太多),这里实际上是一个业务逻辑层。

Rails 自身在设计的时候有意忽视了这点(因为目标客户的原因),把 Repository 和模型合并(后果就是在没有数据库的时候你无法测试),预期 Controller 中不会有太复杂逻辑,采用了一些不是最佳的代码复用手段。理解这点后,你就可以不拘泥于 Rails 的设计而按照常规的软件架构(不仅仅是 MVC)进行设计和编码。

以上仅作参考。

#2 楼 @xnnyygn 比较赞同你的做法。补充一点就是,repo 是否应该用参数初始化,这样便于测试的时候替换 repo。

#2 楼 @xnnyygn 你这个就是普通的 service 嘛,我说放 controller 换成放 service 也一样,两者的抽象层次是等同的,问题是在这种情况下看不出 Repository 有什么意义,而且事实上也不能和别的 Repository/Service 解耦。

测试的话,其实厂妹也可以没有数据库的时候测试啊,或者用 sqlite::memory 也行,而且 repository 现实中不见得就能多数据源,因为 join 这些操作也不是所有数据源都有实现的。

先粘贴两个链接,不知道 lz 是不是需要:

领域模型的价值与困境

再论领域模型的困境


然后说点题外话。实际上我觉得写代码就是 3 件事:实现功能,去除重复,最后使表达清晰——主要是命名。

但是在完成后面两个目标的时候,特别是是“去除重复”这个目标的时候,我们会不断的遇到困难。这时候就不得不动用你所在的那个世界(那门语言)所能提供的所有方法资源:封装、组合、继承、mixin、元编程等等。

极端地去除重复必然导致模块粒度细化,各种最细粒度的模块互相结合又成为更粗粒度一些的模块,最后软件成为各种不同粒度(不同抽象级别)组合而成的一个组合体。它的内部因为没有重复而条理清晰。似乎很多写不干净代码的人会嘲笑这种情况只是个梦。

所以,我觉得没有必要去纠结太多概念,也没有必要迷信各种框架的写法。有了原则,什么都好办,没有太多固定的模式。

其实以我自己的一点经验,多写写脱离现有框架的东西。比如公司项目里看似比较边缘的,跟整体框架关系不是很紧密的,又没有现成框架可用的东西。把那些东西写干净的话,对自己的提升其实比在一个框架(比如 Rails)里写代码来得大得多。

希望以上这些能有点帮助。

#5 楼 @emanon 并不是纠结概念,因为要做 side project,只是为了确认 repository 是无意义的,便可以从选型中去掉了,而且 lotus 这么多人捧不可能这些基础的都没考虑过啊…

目前的状况看 lotus 划的抽象比较蠢,写法也十分繁琐,肯定不会用他了,虽然我觉得他的 container 比 rails engine 简洁很多…

需要 登录 后方可回复, 如果你还没有账号请 注册新账号