Hanami, Why?: 杂七杂八

Lobsters Hottest 工具

摘要

一篇关于 Hanami Ruby Web 框架的技术博客文章,讲解了框架的"系统级抽象",重点介绍了通过 Deps 模块实现的依赖注入机制,包括文件路径即容器键、依赖实例注入、以及自定义 initialize 时的注意事项等粗糙边缘。

<p><a href="https://lobste.rs/s/zcthix/hanami_why_bits_bobs">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/10/02 14:37

# Hanami,为什么?:零碎点滴 来源:https://aaronmallen.me/writing/hanami-why-bits-bobs 欢迎回到我的「Hanami,为什么?」系列。如果你还没看过,开始阅读本期内容之前,请务必先阅读[Hanami,为什么?:简介](https://aarommallen.me/writing/hanami-why-introductions)。 我的初衷是尽可能让每一期都聚焦于单一主题。不过在本期中,我们将覆盖几个各自太小、不值得单独成篇,但又对建立上下文很重要的主题。 正如我之前提到的,Hanami 有很多重要的抽象,今天我们将讨论我认为属于「系统层级」的抽象。要讲的内容很多,但这些上下文对进一步理解 Hanami 的工作方式非常重要。 ## 依赖注入(Dependency Injection)(https://hanakai.org/learn/hanami/v3.0/app/container-and-components#dependency-injection) 大多数时候,Ruby 通过 `require` 很擅长告诉你某个特定文件依赖什么。然而在 Rails 这类框架中,所有内容都会自动加载(autoload),因此代码库中往往不会出现任何 `require` 调用。依赖散布在各个方法体内,而要知道某个类依赖什么,唯一的方法就是逐行阅读它的代码。 Hanami 同样会自动加载你的代码,但它还提供了一种方式,让你通过 `Deps` 模块预先声明依赖。让我们看一个例子: ``` # app/actions/users/create.rb module MyApp module Actions module Users class Create < Action include Deps["repos.user_repo"] def handle(request, response) user = user_repo.create(request.params[:user]) response.redirect_to routes.path(:user, id: user.id) end end end end end ``` 这里,`Users::Create` action 的依赖在文件顶部被明确地声明了出来。请注意,包含 `"repos.user_repo"` 会给我们一个名为 `user_repo` 的方法。默认情况下,方法名是键(key)的最后一段。如果你不喜欢这个名字,可以像这样进行别名(alias): ``` class Create < Action include Deps[users: "repos.user_repo"] def handle(request, response) user = users.create(request.params[:user]) # ... end end ``` 需要记住的重点是:**任何**类都可以被注入依赖。它不必是 action、operation 或 repo。下面是一个完全没有父类的普通 Ruby 对象: ``` # app/slugify.rb module MyApp class Slugify def call(title) title.downcase.strip.gsub(/[^a-z0-9]+/, "-") end end end ``` ``` # app/post_publisher.rb module MyApp class PostPublisher include Deps["repos.post_repo", "slugify"] def call(params) post_repo.create(**params, slug: slugify.call(params[:title])) end end end ``` `PostPublisher` 并不继承自 Hanami 提供的任何东西。它只是包含了 `Deps`,并像其他所有东西一样获取自己的依赖。 我想强调的另一点是:**文件就是键(key),而注入的是实例(instance)**。`app/` 中的每个文件都会以基于其路径构建的键注册到你应用的容器(container)中。`app/slugify.rb` 变成 `"slugify"`,而 `app/repos/post_repo.rb` 变成 `"repos.post_repo"`。如果你想知道某个依赖来自哪里,只需要查看它的键即可。 你还应该注意到,我们从未调用过 `Slugify.new.call`。Hanami 不仅仅是为你加载代码;它直接递给你一个即拿即用的依赖实例。 ### 不那么顺手的地方(Rough Edges) 第一件需要注意的事情是,`Deps` 的工作方式是替你编写类的初始化方法。如果你在初始化时需要自定义逻辑,就不得不绕过容器来实现。你仍然可以定义自己的 `initialize`,但你必须接受注入的依赖并把它们传递给 `super`: ``` module MyApp class PostPublisher include Deps["repos.post_repo", "slugify"] def initialize(reserved_slugs: %w[admin new edit], **deps) @reserved_slugs = Set.new(reserved_slugs).freeze super(**deps) end end end ``` 忘记调用 `super`,你的依赖将永远不会被设置。同时请注意,`reserved_slugs` 需要一个默认值。容器是通过调用无参数的 `.new` 来构建组件的,因此组件所需的任何东西都必须来自默认值或 `Deps` 本身。 这就引出了第二个「不那么顺手」的地方:Hanami 会把 `app/` 中的**每一个**文件都注册为组件,不管你是否打算注入它。如果你有一个永远不会被注入的类,比如一个需要传入参数的值对象(value object),你可以通过在文件顶部添加一个魔法注释来告诉 Hanami 跳过它: ``` # auto_register: false ``` 第三,你的键只是一些字符串。如果你在 `include Deps["repos.post_rep"]` 中打错了字,Ruby 不会捕获它,直到代码真正运行时你才会发现。编辑器的「跳转到定义」也无法跳转字符串键(不过[这方面正在开发中](https://github.com/hanami/ruby-lsp-hanami))。好的一面是,键会准确地告诉你该打开哪个文件,因此你离源代码永远只有一步之遥。 最后,默认情况下,容器中的每个组件都是记忆化(memoized)的。这意味着它只会被实例化一次,之后在**所有**需要它的地方注入的都是同一个实例。你的组件应该是无状态的。不要把请求数据存储在实例变量里,并指望它在下一次请求时会消失。 更棘手的是,组件在测试环境中**不会**被记忆化,因此这样的 bug 可以顺利通过你的测试套件,只在生产环境中才会显现。如果你确实每次都需要一个全新的实例,可以通过另一个魔法注释让单个组件退出记忆化: ``` # memoize: false ``` ## Provider(https://hanakai.org/learn/hanami/v3.0/app/) 你是否曾打开过 Rails 应用中的 initializers 目录,并观察到类似这样的东西? ``` initializers/ ├── 001_high_priority.rb ├── 002_medium_priority.rb └── 003_low_priority.rb ``` 我知道我见过,我也敢打赌你也见过类似的东西。这是因为 Rails 中的 initializer 是按字母顺序加载的。我亲眼见过,在初始化阶段未能按正确顺序加载依赖,会导致遍布整个应用的难以定位的故障。 让我们再看一个例子。你是否见过这样的东西? ``` $redis = Redis.new(...) ``` 先别误会,处理这件事当然有更好的方式,但归根结底,这里要解决的问题是:Rails 并没有提供一种规定好的方式来处理全局组件。而 Hanami 的 provider 正是为此而生。 Provider 是一种将组件注册到容器中的方式。Provider 在其生命周期中有三个阶段:`prepare`、`start` 和 `stop`。所有的 `prepare` 块都会在任何 `start` 块之前加载,因此这里就是你 require 依赖之类操作的地方。Provider 会在你的应用启动前自动地被准备和启动,就像 initializer 一样。 Provider 通常位于 `config/providers/` 或 `<slice>/config/providers` 下。让我们来看一个 provider 的例子,以及你可能的使用方式: ``` Hanami.app.register_provider :redis, namespace: true do prepare { require "redis" } start do register :client, Redis.new(url: slice["settings"].redis_url) end end ``` 这里面其实发生了不少事情,让我们逐项拆解: 1. 我们正在注册一个名为 `redis` 的命名空间(namespace)provider。由于它是带命名空间的,它注册的每个组件都位于 `redis` 键之下,因此稍后我们提到的 `client` 组件可以通过 `include Deps["redis.client"]` 在你的应用中的任何地方被调用。你也可以完全不用命名空间,那样你就通过 `include Deps["client"]` 来调用这个依赖。如果走这条路,我肯定会将其命名为 `redis_client` 而不仅仅是 `client`,但我相信你已经明白我的意思了。 2. 我们在 prepare 块中 require 了 `redis` 这个 gem。这确保了当 `start` 被调用时,该库已经准备好可以使用了。请记住,所有的 `prepare` 块都会在任何 `start` 块被调用之前被调用。所以,如果你有多个 provider,它们都会在**任何**一个 provider 启动之前被准备好。 3. 在 `start` 块中,我们注册了一个名为 `client` 的组件,并将一个 `Redis` 实例作为其值赋给它。现在你就有了一个全局组件,可以在应用中的任何地方注入它。 4. 你可以看到我们调用了一个名为 `slice` 的东西,并从中提取设置。在这个例子中,`slice` 就是 Hanami 应用本身;然而,如果你处理的是 [slice](https://hanakai.org/learn/hanami/v3.0/app/slices) 的 provider,那么 `slice` 就是那个 slice。在这个系列的后面,我们可能会深入探讨 slice;现在只需知道它们本质上是限界上下文(bounded context),并且在 Hanami 应用中是一等公民。 Provider 有各种各样有用的用途,是在 Hanami 应用中开发的众多乐趣之一。我们还应该注意到,一个 provider 实际上可以注册多个组件: ``` Hanami.app.register_provider :argon, namespace: true do prepare { require "argon2" } start do register :hasher, Argon2::Password.method(:create) register :verifier, Argon2::Password.new(...).method(:verify_password) end end ``` 在这个例子中,你会通过 `include Deps["argon.hasher", "argon.verifier"]` 来注入这些组件。 ### 不那么顺手的地方 这里最大的「不那么顺手」之处是,provider 在开发和测试环境中的行为与生产环境不同。在开发和测试环境中,provider 的启动周期只有在某个组件被请求时才会被调用。这是为了帮助确保开发和测试环境保持快速,而 Rails 无论在什么环境下都会加载你所有的 initializer。 在生产环境中,`Hanami.boot` 会预先启动每一个 provider。因此,一个设置有问题的 provider,比如缺失 `REDIS_URL`,在开发环境中可以安静地待着,却在生产环境中启动时直接导致应用崩溃。 ## 类型(Types)(https://hanakai.org/learn/dry/dry-types/)与校验(Validation)(https://hanakai.org/learn/dry/dry-validation/) 我知道,我知道:「呃,类型」。类型其实是一种非常有用的抽象,可以用来校验并规范化用户输入。让我们从一个例子开始: ``` module MyApp Types = Dry::Types() module Types EmailAddress = Types::String.constrained( format: URI::MailTo::EMAIL_REGEXP, max_size: 255 ) module Normalized EmailAddress = EmailAddress.constructor { it&.downcase&.strip } end end end ``` ``` module MyApp module Relations class Users < Relation schema :users, infer: true do attribute :email, Types::Normalized::EmailAddress end end end end ``` 好了,让我们来拆解一下吧?本质上,我们在这里创建了 `Types::EmailAddress`,可以用它来校验任何字符串是否是一个有效的电子邮件地址。你可以通过 `Types::EmailAddress["[email protected]"]` 来进行校验。如果电子邮件地址有效,它会以字符串形式返回;如果无效,则会抛出一个错误。你可以在诸如参数校验之类的地方使用它,以确保用户为你提供了有效的值。 接下来,我们创建了 `Types::Normalized::EmailAddress`,它带有与 `Types::EmailAddress` 完全相同的校验属性,但它还会确保电子邮件地址是小写的且不含空白字符。 最后,我们告诉 `Users` 关系(relation),`email` 字段应该是 `Types::Normalized::EmailAddress`。这确保了所有电子邮件在进入数据库之前都已被规范化。 在 Rails 中,`normalize` 方法通常与执行规范化操作的模型绑定在一起。这意味着,无论你需要在哪里规范化电子邮件地址,你都要在多个地方定义相同的逻辑。这有**几个**坏处,而你很可能希望将这些逻辑抽取到类似 concern(关注点)之类的东西中。 Hanami 为你提供了一个已经预期这种逻辑会被抽象出来的框架。类型是可复用的,可以在你的 Hanami 应用中到处调用。类型也可以用来帮助你校验输入。 ### Contract(契约) Hanami 不为 Contracts 提供任何生成器,在新的 Hanami 应用中它们默认也不被视为一等公民。然而,在我的 Hanami 应用中,它们就是这样被对待的。Dry Validation 默认作为依赖被 Hanami 引入,因此所需的组件已经就位了。你只需创建你的基类,并在应用中添加一个 `app/contracts/` 目录即可。 在 Rails 应用中,校验通常直接绑定到各个模型上。同样,这意味着如果你有在多个地方使用的校验逻辑,它要么在各个模型中被重复,要么被抽取到一个 concern 中。Dry Validation 免费为我们提供了这种抽象。 ``` module MyApp module Contracts class NewUserContract < Contract params do required(:user).filled(:hash) do required(:email).filled(Types::EmailAddress) required(:password).filled(:string, min_size?: 8) end end end end end ``` 我个人喜欢在 operation 层进行校验,因为这确保了只要发生任何变更(mutation),参数都会被校验。看起来大概是这样的: ``` module MyApp module Operations class CreateUser < Operation include Deps["contracts.new_user_contract"] def call(params) validated = step validate_params(params) # ... end private def validate_params(params) result = new_user_contract.call(params) result.success? ? Success(result.to_h) : Failure(result.errors.to_h) end end end end ``` 我可以在我的应用中的任何地方调用这个 contract,这意味着我可以把我的校验层部署到任何需要它的地方。 小技巧:Hanami action 中的 `params` 方法可以接收一个类作为参数。 ``` class Create < Action params Contracts::NewUserContract # ... end ``` 关于 contracts,我们需要讲的最后一点是[自定义规则(custom rules)](https://hanakai.org/learn/dry/dry-validation/v1.11/rules)。上面的例子会确保电子邮件的格式正确,并且密码至少有八个字符长。如果我还想校验电子邮件地址是唯一的呢?这时规则(rules)就派上用场了: ``` module MyApp module Contracts class NewUserContract < Contract include Deps["repos.user_repo"] params do required(:user).filled(:hash) do required(:email).filled(Types::EmailAddress) required(:password).filled(:string, min_size?: 8) end end rule user: :email do next unless user_repo.email_exist?(value) key.failure("email is already taken") end end end end ``` 就我个人而言,我不允许我的 contracts 依赖 repo。在我看来,校验层应该尽可能轻量,把一个数据库层注入到 contract 中有点违反了这个原则。我通常会在 operation 或 repo 层处理这种检查。不过,这正是 Hanami 的美妙之处之一:由你来决定如何构建你的应用。 ### 不那么顺手的地方 Contract 的[配置](https://hanakai.org/learn/dry/dry-validation/v1.11/configuration)可能会有点令人困惑,我经常发现自己为了弄清在想要自定义错误消息时应该调用哪些键,而不得不反复摆弄国际化(i18n)文件。不过,我知道 Hanakai 团队已经注意到这个问题了,在为 Hanami 3 添加国际化之后,我们正寻求简化这方面的工作。 此外,选择正确的类型**种类**也可能让人困惑。`Types::Integer` 会拒绝 `"42"` 这个值,而 `Types::Coercible::Integer` 会欣然接受它,并返回一个整数给你。我不太把这看作是「不那么顺手」,而更多地看作是一点学习曲线。我现在可以说,我通常不再需要访问 dry types 的文档来确定该用哪个类型了。 好了,今天我们涵盖的内容相当丰富。我知道我们还没有真正深入探讨 Hanami 作为应用框架的层面,但我向你保证,拥有这些上下文将对这个系列后续的内容非常有用。 我确实想花点时间感谢你们压倒性的积极回

相似文章

Hanami, Why?: Model Citizens

Lobsters Hottest

博客系列文章,深入介绍 Hanami 框架的数据库层,讲解其由 Relations、Repos 和 Structs 三部分组成的设计,以及它与 Rails 传统 model 层的区别。

Hanami,为什么?:引言

Lobsters Hottest

一位 Hanami 维护者开启了一个新的博客系列,解释为什么他为自己的开源个人网站选择了 Hanami Ruby Web 框架而非 Rails,并重点介绍了 Actions、Relations、Repos 和 Operations 等抽象。

Hanami 3.0:全面绽放

Lobsters Hottest

Hanami 3.0,一个Ruby Web框架,现已发布,内置邮件发送、国际化支持及Minitest集成,性能更快,开发者体验更佳。

抱歉,但你仍然需要思考

Hacker News Top

一篇评论文章分析了 DHH 让 AI 代理将 Campfire 从 Rails 重写为 Rust/Elixir/Go 的结果,指出在未人工审阅代码的情况下,重写引入了大量隐含的架构决策与性能问题(如阻塞式数据库操作、锁的误用),说明目前仍不能完全放弃阅读代码和思考。

@JoshARosen: https://x.com/JoshARosen/status/2107840212915077350

X AI KOLs Following

一篇架构对比文章,分析 Pi Durable 与 OpenCode 的设计差异:Pi Durable 将模型生成、工具调用等序列化为可调度的持久任务,使工作不再绑定于单个进程,而传统会话式 harness(如 OpenCode、Claude Code、Codex)仍依赖进程内的活动 agent 循环。