发布于 2026-01-06 0 阅读
0

探索 dry-rb - 结果直觉 DEV 的全球展示挑战赛,由 Mux 呈现:展示你的项目!

探索 dry-rb - 结果的直觉

由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!

dry-rb这是一套引人入胜的工具和库,但它们的用途可能并不显而易见。既然有更简单的技术就能满足需求,为什么还要添加这些库呢?这些抽象背后的逻辑是什么?它们能给我们带来哪些好处,从而证明它们的必要性?

这就是本系列文章的目的。我们将探讨一些更具挑战性的例子,并解释人们为什么需要这些工具,因为确实存在一些情况下,由此产生的复杂性会带来非常可观的收益。

但要建立这种直觉,我们首先需要探索一些理论。

形状和 HTTP 响应

在编程领域,HTTP 响应和 HTTP 响应代码或许是最强大的概念之一,也是我们常常习以为常的概念。使用 REST/JSON 端点时,我们可以(相当)确定会收到类似这样的响应:

HTTP::Response(body: "<json content>", code: 200)
Enter fullscreen mode Exit fullscreen mode

列举 100 个使用 REST 和 JSON 的不同 API,你就能列举出 100 个与此非常相似的 API。这些响应具有非常清晰的“结构”,使我们能够对如何处理它们做出合理的假设,例如 Rack响应

response = get_data # Rack::Response returned
response_body = JSON.parse(response.body)

if response.successful? # Rack::Response::Helpers
  process(response_body)
else
  handle_errors(response_body)
end
Enter fullscreen mode Exit fullscreen mode

这非常强大,是我们现代网络的基础,但却常常被我们视为理所当然。响应这一概念本身就包含了数据,以及数据的上下文信息,例如请求是否成功、是否出错、是否格式错误等等,谁也说不准,但它始终保持一致(只要我们遵守其规则)。

违规者

诚然,正如上文提到的,这存在一个危险,那就是它预设了理性,并假定我们遵循一套允许我们做出这些假设的规则。有些 API 可能会这样做:

HTTP::Response(body: "ERROR! IT BLEW UP!", code: 200)
Enter fullscreen mode Exit fullscreen mode

这样做违背了合理的预期,也失去了对界面的信任,因此我们有责任警惕并遵守这些规则,因为该界面的强大功能值得我们为此付出额外的代价。

脱离语境

假设我们暂时弃用代码,响应中只包含正文。在继续之前,请花点时间思考一下,为什么这样做会使使用起来复杂得多。

明白了吗?那么,让我们来看看移除数据中的这些额外背景信息会带来哪些影响。

如何定义成功?是错误吗?错误信息是否出现在响应体中?如果响应为空、nil、falsy 或其他任何值呢?JSON 数据完全可以包含上述任何一种值:

# Inline status
{ content: "<data>", status: "success" }

# Empty response
{  }

# No response
""

# String error
"Error: Something went boom"

# Number code
123456
Enter fullscreen mode Exit fullscreen mode

现在,考虑到我们对成功的定义可能存在不一致,并将其应用到世界上所有(大部分)符合 REST 和 JSON 标准的 API 中(哦,JSON 也有规则),您就会明白这很快就会变成一件令人头疼的事情。

从现在开始,你使用的每一个 API 都遵循其独特的规则,因此,实现错误和成功处理的开销都转嫁给了用户,而不是服务本身。这简直是噩梦,尤其是在你同时使用多个 API,并且需要为每个 API 创建封装层才能让它们在你的领域内协同工作时。

哦,还有那些你必须编写的包装器?你最终很可能会得到一些模糊标准化的响应类型,但这些响应类型可能在你自己的应用程序中仍然会不一致。

跨公司耦合

系统之间的耦合程度越高,对底层技术的深入了解就越重要,因此,接口连接就越困难。

如果一家公司像上面那样在 HTTP 之上创建一个新的伪标准,200 Error那么他们现在就将你的所有代码与他们自己的错误处理系统直接耦合在一起了。

现在,你需要编写一个包装器,并了解他们自己奇怪的实现方式,才能使其在你的应用程序中合理地工作,如上所述,这使得你与他们的成功定义紧密相关。

我给你一个关于下一主要部分的提示:想象一下,在你自己的应用程序/服务网格内部,这会有多么麻烦。

一致性

虽然采用 HTTP 响应标准确实会带来一些成本,但不可否认的是,遵循其规则能给我们带来诸多好处。这就是为什么包括 Rack 在内的 Web 服务器都使用它的原因。

底层数据是什么样子并不重要,重要的是,我们可以期望任何符合标准的合理 API 都能以相同的格式和相同的语言对“成功”一词的含义做出一致的响应。

无论你去任何一家公司,使用任何 API,或者以其他方式涉足这个领域,你都极有可能发现很多东西都非常熟悉。这非常强大,强大到难以置信,然而我们却习以为常。

API 不仅限于外部

这固然很好,但这与我们的应用程序有何关系?为什么它很重要?为什么我们应该关心这种形状和 HTTP 响应的一致性?

很简单,API 指的是公共接口,而且并不一定是指外部接口。每个公共方法都是你应用程序、类、库或其他任何东西的 API。

请仔细思考以上内容,想想你可以在申请材料中以哪些方式体现失败,然后再继续。

失败意味着什么?

你想到了什么方法?可能有很多种,每种方法都有各种各样的假设和使用要求。我们快速来看几个例子:

# Exceptions - String based
raise "It failed!"

# Exceptions - Class based
raise MyCustomSpecificUsefulError, "Something went wrong"

# Returning false or nil (what if falsy is valid? That's fun)
false
nil

# Reasonable empty defaults
""
[]
{}

# ...and probably many more
Enter fullscreen mode Exit fullscreen mode

更糟糕的是,当你意识到这种令人头疼的问题也适用于成功的定义时,但我们暂且略过这一点。

你可以看到,这些都可能对依赖于该接口的代码产生非常有趣的影响。现在你必须了解所有底层异常,或者假返回值是否有效,或者在更实用的情况下,例如对于Enumerable方法,你会得到一个合理的空结果,如下所示:

[2, 4, 6].select { |v| v.odd? } # => []
Enter fullscreen mode Exit fullscreen mode

最后一点尤其有趣,因为我们可以继续在 SELECT 语句的末尾添加其他操作,如果查询结果确实包含数据,它就会沿着这条管道继续执行。听起来很有用,对吧?我们稍后会解释为什么这一点至关重要,但首先……

异常处理

如果select改为抛出如下异常呢:

# Please don't actually do this to `select`:
module Enumerable
  def select(&block)
    found_elements = []
    self.each do |element|
      # If calling the block on the element is truthy, add it
      found_elements.push(element) if block.call(element)
    end

    raise NoResultsHere, "No data!" if found_elements.empty?
    found_elements
  end
end
Enter fullscreen mode Exit fullscreen mode

你能想象和那种环境一起工作吗?你可能需要做类似这样的事情:

begin
    [1, 2, 3, 4].select { |v| v > 5 }
rescue NoResultsHere
  []
end
Enter fullscreen mode Exit fullscreen mode

现在,如果让每个 Enumerable 方法都这样做,我们就把这种错误处理强加给了消费者,使它成为一个更难用的接口。

可枚举的力量

该方法select和其他Enumerable方法继续返回Enumerable特定形状的对象这一事实确实非常有用。它甚至允许我们这样做:

(1..100)
  .select { |v| v.even? }
  .map { |v| v * 5 }
  .group_by { |v| v > 50 }
  .transform_values { |vs| vs.sum }
# => {false=>150, true=>12600}
Enter fullscreen mode Exit fullscreen mode

之所以能获得所有这些功能,是因为它Enumerable同意返回什么形状的对象,从而允许你随意链式调用,甚至允许你以某种方式绕过失败并取得结果。

合理默认值这一概念有一个专门的名称,那就是“身份”。它甚至是一条定律,想想看,当它与其他两条定律结合起来时,会呈现出更有趣的东西。

如果你还没读过《Ruby并行模式入门》 ,请停下来读一读。我保证你会发现它很有意思,能启发你思考我之前提到的这些模式。

这些形状有个名字,叫做“幺半群”。不过说实话,名字本身并不重要,重要的是那种直觉:这些同类型的形状可以随意组合和连接。一旦掌握了这种直觉,就会涌现出许多非常有趣的可能性。

然后呢?朋友们,这就是dry-rb字体出现的地方了Result

隆重介绍我们的朋友 Result

我为此花了不少时间,对此我深感抱歉。但正如一位朋友所说,要证明止痛药的价值远比证明维生素的价值容易得多。某种程度上,你必须体验过某种东西的缺失,才能体会到它可能带来的价值,而不是仅仅被它固有的优点所吹捧。

铺垫

让我们先快速交代一下背景。假设你正在开发一个大型 Rails 应用,并且使用了类似Packwerk 的工具来清晰地划分 Rails 单体应用中的不同包。假设你有 100 个包,这在大型应用中并不罕见。

现在,把这 100 个包都拿出来,给它们提供尽可能少耦合的公共接口。我们假设这些接口是在包的某个文件中实现的,比如 `package.json`public_api.rb或类似的东西,当然,你可以自由发挥。关键是每个包都应该只有一个入口点。

打开其中一个公共 API,我们可能会发现类似这样的内容:

# packs/app/public/service_name.rb
class ServiceName
  class << self
    def offering_a; end
    def offering_b; end
    def offering_c; end
    def offering_d; end
    def offering_e; end
  end
end
Enter fullscreen mode Exit fullscreen mode

所有这些操作都会深入到软件包内部执行非常重要的业务操作,可能还会用到一些 ActiveRecord,并返回一个结果。

还记得关于如何定义失败,以及关于如何定义成功的提示吗?哦,对了:

# packs/app/public/service_name.rb
class ServiceName
  class << self
    # Exception
    def offering_a(id)
      ServiceModel.find(id)
    end

    # Nil
    def offering_b(id)
      ServiceModel.find_by(id: id)
    end

    # False
    def offering_c(id)
      ServiceModel.find_by(id: id) || false
    end

    # Empty Collection
    def offering_d(*ids)
      ServiceModel.where(id: ids)
    end

    # Invalid Object
    def offering_e(**model_info)
      ServiceModel.create!(**model_info)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

每个服务都有自己对成功和失败的定义。现在,假设有100个服务,每个服务都采用自己独特且可能非常合理的方法来解决这个问题,那么你可以想象,这将变得多么难以理解。更何况,当你依赖多个服务的输入来生成自己的输出时,这意味着你也会受到所有这些服务的下游影响。

现在,所有的错误处理或故障处理都转嫁给了消费者,尽管使用了包并建立了非常明确的界限,但耦合问题依然存在,一切又回到了原点。即使直接返回 ActiveRecord 对象或集合也会引入耦合,但这又是另一个我们暂且不讨论的问题了。

如果他们就成功或失败的含义达成一致的单一定义和标准呢?嗯,这就是问题的关键所在Result

结果类型

dry-rb针对这个问题,它提出了一个名为 Result 的非常有趣的解决方案,并附有非常精美的文档。我建议您稍等片刻再阅读,文档链接在此。啊,还有,别在意“Monad”这个词,它目前并不重要。

Success它向我们展示了“和”的概念Failure,这两个部分构成了较大的Result类型:

require "dry/monads"

extend Dry::Monads[:result]

result = if foo > bar
  Success(10)
else
  Failure("wrong")
end
Enter fullscreen mode Exit fullscreen mode

你可以把它想象成 HTTP 响应状态码提供的额外Success上下文Failure信息。它是一个包装器,清晰地告诉我们某个操作是成功了,还是失败了。

与 Enumerable 类似,我们可以将Result类型链接在一起,因为它们的合理默认值仍然是一种Result类型:

require "dry/monads"

extend Dry::Monads[:result]

# Pretend it's a DB of some sort
IDS = { "a" => "Red", "b" => "Blue" }

def offering_a(id)
  return Failure("ID not found: #{id}") unless IDS.key?(id)

  Success(IDS[id])
end

offering_a("a")
# => Success("Red")

offering_a("nope")
# => Failure("ID not found: nope")

# Remember Enumerable? What happens if we chain each of these?

offering_a("a").fmap { |name| "We found #{name}!" }
# => Success("We found Red!")

offering_a("nope").fmap { |name| "We found #{name}!" }
# => Failure("ID not found: nope")
Enter fullscreen mode Exit fullscreen mode

对于Result某些类型,我们可以调用一个特殊方法fmap来操作其中的值,但如果操作失败呢?它什么也不做,失败状态会一直持续下去,直到最后我们将其取出。

不过,当将其应用于 Ruby 2.7+ 中的模式匹配时,它会变得更有趣:

result = offering_a("a")

case result
in Success("Red" | "Blue") then "Found who we were looking for"
in Success then "Not who we expected, but still ok"
in Failure(/_why/) then "He's still in our hearts though"
in Failure then "I give, you win"
end
Enter fullscreen mode Exit fullscreen mode

我们可以根据每个值的类型来访问它们Result,或者实际上我们甚至根本不需要访问它们:

result = offering_a("a")
result.value_or("Yellow")
Enter fullscreen mode Exit fullscreen mode

不过,您或许更想要选择您熟悉的if那个分支:

if offering_a.success?
Enter fullscreen mode Exit fullscreen mode

利用这种概念可以做很多事情,但就像 HTTP 响应一样,因为我们已经就这种格式达成了一致,如果我们的所有服务都能很好地与之配合,那么所有消费者代码都可以做出相同的假设并以相同的方式处理它们。

嘿,如果所有形状都对齐,那甚至意味着我们可以将它们组合起来,或者做各种其他有趣的技巧,比如 Javascript 中异步是如何Promise工作Future的,或者如何Task拥有事务执行列表,或者Validated在尝试创建不太正确的东西时合并多个错误。

在浏览代码库时减轻的集体精神负担,随着时间的推移会带来丰厚的回报,就像你很可能永远不会花太多时间去思考 HTTP 响应一样。

好的代码很有用,但优秀的代码是透明的。HTTP 响应是透明的,我认为结果类型也很有可能如此。

是的,但基础数据

唱片尖叫声

没错,关于这一点。形状相同固然好,但这并不意味着你就可以忽略容器内部的数据。容器本身可能很棒,也很合理,但这并不意味着内容也同样出色。

如果你想在不同包之间保持清晰的边界,直接向它们传递一个仍然可以直接访问数据库、无法序列化,甚至可能需要在数据库上下文之外进行验证的 ActiveRecord 对象可能并不明智。以上仅指成功的情况。

失败分支更有意思。我们现在要处理字符串错误吗?上下文、回溯信息、调用参数,以及其他那些有用的元信息呢?当然,追踪失败Result功能确实提供了这些信息,但这并非重点。

标准

应用规模越大,标准就越能发挥倍增器的作用,而不是基础设施部门某人命令你遵守的烦人规则。这里没提到的是,定义和创建这些标准,尤其是像错误处理这样的标准,真的非常非常难。

解决这些问题的方法可能有成百上千种,但总的来说,我发现的是什么呢?选一种方法,在此基础上进行拓展,只要你们能就边界的形状达成一致,就可以从那里开始着手。

对我来说Promise,HTTP 响应(甚至请求)、结果和其他类似类型都是很好的切入点。

总结

Rails 不是一天建成的,你的应用程序也一样。这是一个持续演进的过程,我们需要不断学习和成长,随着理解的加深,我们的观点也会随之改变。最好的建议是:让系统易于修改,易于在失败后恢复,并尽可能减少系统间相互了解的信息。

也许有一天我的观点也会改变,事实上已经改变过好几次了,但这就是我目前的想法。

dry-rb对我而言,它提供了一套工具,可以定义能够承载大型应用程序负载并降低耦合度和复杂性的边界。它们是免费的吗?不,但世上哪有免费的?它们值得吗?这取决于您,但我认为值得。

在本系列文章中,我将更深入地探讨这些dry-rb库,并采用类似的方法来论证它们的优势,而不是简单地告诉你它们有多棒或多么了不起。我们来这里不是为了炫技,而是为了解决问题,或许这些库也能解决你的问题。

文章来源:https://dev.to/baweaver/exploring-dryrb-intuition-of-results-1lnd