Hanami, Why?: Model Citizens
摘要
博客系列文章,深入介绍 Hanami 框架的数据库层,讲解其由 Relations、Repos 和 Structs 三部分组成的设计,以及它与 Rails 传统 model 层的区别。
<p><a href="https://lobste.rs/s/ygp18d/hanami_why_model_citizens">评论</a></p>
查看缓存全文
缓存时间: 2026/10/19 16:02
# Hanami,为什么?:模型公民
来源:https://aaronmallen.me/writing/hanami-why-model-citizens
欢迎回到我的"Hanami,为什么?"系列。如果你错过了上一期,务必在开始阅读本篇之前先读一下 [Hanami,为什么?:集体诉讼](https://aaronmallen.me/writing/hanami-why-class-action)。
今天我们将探讨 Hanami 的数据库层。需要注意的是我使用了"数据库层"这个措辞,因为 Hanami 并不像 Rails 那样拥有传统的"模型"层。
再次强调,这个系列叫做"Hanami,**为什么?**",所以就像上一期一样,我们会先从宏观层面介绍基础概念,然后深入探讨为什么事情是这个样子的。我要提前告诉你,我自己也仍在积极地形成对 Hanami 数据库层的看法并不断学习中。Adam Lassek(https://github.com/alassek)几乎每天都在向我灌输关于这个主题的各种随机 TIL。
特别感谢 [Hanakai Discord](https://discord.com/invite/KFCxDmk3JQ);那是一个很棒的地方,可以学到很多有趣的小技巧和窍门。让我们直接开始吧。
## 速成课
Hanami 的数据库层由三个主要部分组成:[Relations](https://hanakai.org/learn/hanami/v3.0/database/relations)、[Repos](https://hanakai.org/learn/hanami/v3.0/database#repositories) 和 [Structs](https://hanakai.org/learn/hanami/v3.0/database#structs)。
Relations 本质上是你定义对象的模式(schema)以及与其他对象之间关系的地方。它们也是定义 scopes 的好地方,或者用 Hanami 的术语来说就是 [datasets](https://hanakai.org/learn/hanami/v3.0/database#datasets)。
Repos 是[仓库模式](https://martinfowler.com/eaaCatalog/repository.html)的一个相当直观的实现。在 Hanami 中,Repos 实际上是完全可选的;如果你愿意,仅用 relations 也能轻松应对。Repos 的优势在于可以在多个 relations 上进行操作,我强烈建议你使用它们。
最后,structs 是你的对象本身的简单、冻结的表示。Structs 是你为对象定义简单展示逻辑的地方。
让我们看一个如何使用 Hanami 数据库层的示例。
```ruby
ROM::SQL.migration do
change do
create_table :users do
primary_key :id
column :email, :varchar, index: { unique: true }, null: false, size: 255
column :password_digest, null: false
column :first_name, :text
column :last_name, :text
column :created_at, :timestamptz, null: false, default: Sequel.function(:now)
column :updated_at, :timestamptz, null: false, default: Sequel.function(:now)
end
end
end
```
这里我们有一个相当直接的模式迁移。我不会深入讲解迁移,因为 [Hanami 文档](https://hanakai.org/learn/hanami/v3.0/database/migrations)已经对此做了充分的说明。我只是把它放在这里,让你能看到 `users` 表的模式。
```ruby
module MyApp
module Relations
class Users < DB::Relation
schema :users, infer: true do
attribute :email, Types::Normalized::EmailAddress
associations do
has_many :oauth_tokens
end
end
end
end
end
```
这里我们定义了 relation。它会直接从 users 表推断其模式。我们将 `:email` 属性覆盖为一个经过规范化处理的电子邮件地址类型。最后,我们将此 relation 与 `oauth_tokens` 表通过 `has_many` 关系进行关联。
ROM 当然也支持 `belongs_to` 和 `has_one` 关系。
```ruby
module MyApp
module Repos
class UserRepo < DB::Repo
def create(email:, password_digest:, first_name: nil, last_name: nil)
users.command(:create).call(email:, password_digest:, first_name:, last_name:)
end
def find(id)
users.by_pk(id).one
end
def find_by_access_token(token)
users.join(oauth_tokens).where(oauth_tokens[:token].is(token)).one
end
def find_by_email(email)
users.where(email:).one
end
end
end
end
```
这里我们定义了 `UserRepo`,其中包含几个帮助查找用户的方法,以及一个创建用户的方法。你会注意到在 repo 中我们可以直接访问 `users` 表和 `oauth_tokens` 表。
```ruby
module MyApp
module Structs
class User < DB::Struct
def display_name
candidate = full_name
candidate.empty? ? email : candidate
end
def full_name
[first_name, last_name].compact.reject(&:empty?).join(" ")
end
end
end
end
```
最后,这里我们定义了 `User` struct,其中包含几个锦上添花的展示逻辑方法。你会注意到我们可以直接访问 `email` 和姓名字段,而无需在 struct 中显式定义它们。
这里的核心思想是:repo 是访问数据层的入口。repo 中的所有方法**应该**返回你的 struct 之一。你可以通过在集合上调用 `to_a`,在单个项目的查询上调用 `one` 来实现。`create` 方法中的 command 也会返回一个 `User` struct。
基本上就是这么多了;恭喜,你现在知道如何使用 Hanami 的数据层了。为了完整性,让我们看一个小型的实际使用示例。
```ruby
module MyApp
module Operations
class CreateUser < Operation
include Deps["argon.hasher", "contracts.new_user_contract", "repos.user_repo"]
def call(params)
validated = step validate_params(params)
step validate_email_is_unique(validated[:email])
attributes = step build_payload(validated)
user_repo.create(**attributes)
end
private
def build_payload(params)
built = params.slice(:email, :first_name, :last_name)
built[:password_digest] = hasher.call(params[:password])
Success(built)
rescue Argon2::Error => e
Failure(e)
end
def validate_email_is_unique(email)
return Success() if user_repo.find_by_email(email).nil?
Failure(:email_taken)
end
def validate_params(params)
result = new_user_contract.call(params)
result.success? ? Success(result.to_h[:user]) : Failure(result.errors.to_h)
end
end
end
end
```
就是这样;注意我们从未直接使用 relation。我们纯粹通过 repo 层进行交互。再次强调,这完全是可选的,你可以**只**直接使用 relations。稍后在本篇中我会讨论为什么我认为那不是一个好主意。
## Repo 模式的优势
我不会深入讨论 ActiveRecord 带给你的各种[问](https://ryanbigg.com/2024/12/scoping-an-active-record-join)[题](https://steveklabnik.com/writing/active-record-considered-harmful/)[(](https://calpaterson.com/activerecord.html)[丑](https://damieng.com/blog/2007/activerecord-the-ugly-design-pattern/)[陋](https://reinteractive.com/articles/ActiveRecord-callbacks-considered-harmful)[的](https://destroyallsoftware.com/blog/2011/announcing-do-not-want)[设](https://blog.appsignal.com/2018/04/24/active-record-performance-the-n-1-queries-antipattern.html)[计](https://piechowski.io/post/why-is-default-scope-bad-rails/))。相反,我将向你展示一个非常简单的例子,说明 repo 模式解决的一个**巨大**问题。
```ruby
class FreshnessController < ApplicationController
def fresh
Issue.where(created_at: 7.days.ago..)
end
def stale
Issue.where(created_at: 30.days.ago...7.days.ago)
end
def expired
Issue.where(created_at: ...90.days.ago)
end
end
```
好吧,我们有一个相当简单的控制器来判断 issue 的"新鲜度"。情况是这样的:产品团队认为依赖 `created_at` 来做这个判断不太行。相反,他们决定每小时运行一个后台任务,更新 issue 上的一个新字段 `fresh_status`。猜怎么着?你现在需要为这个更改更新所有三个调用点。想象一下如果这涉及到几十甚至几百个调用点;那是多么大的痛苦。
现在我知道你在想什么:"这太蠢了,用 scope 就行了,问题解决。"对此我百分之百同意,因为 Rails 中的 scope **就是**仓库模式的一种实现!
Hanami 默认就鼓励你使用这个模式,让你免受现在自己的坑害未来自己的困扰。太棒了。
那么这在 Hanami 中是什么样子的?下面是相同的新鲜度逻辑,放在它应该在的地方——repo 中。
```ruby
module MyApp
module Repos
class IssueRepo < DB::Repo
def fresh
issues.where(created_at: days_ago(7)..).to_a
end
def stale
issues.where(created_at: days_ago(30)...days_ago(7)).to_a
end
def expired
issues.where(created_at: ...days_ago(90)).to_a
end
private
def days_ago(days)
Time.now - (days * 86_400)
end
end
end
end
```
你的 action 请求 repo 并调用它。就这么简单。
```ruby
module MyApp
module Actions
module Issues
class Fresh < MyApp::Action
include Deps["repos.issue_repo"]
def handle(request, response)
response.render(view, issues: issue_repo.fresh)
end
end
end
end
end
```
现在产品团队带着他们闪亮的新 `fresh_status` 字段来了。这是全部的更改。
```ruby
def fresh
issues.where(fresh_status: "fresh").to_a
end
def stale
issues.where(fresh_status: "stale").to_a
end
def expired
issues.where(fresh_status: "expired").to_a
end
```
一个文件。你的 actions、views、mailers 以及任何调用 `issue_repo.fresh` 的地方都不知道有任何变化。你还会注意到这里没有 `7.days.ago`;Hanami 不附带 ActiveSupport,所以我们做了一些简单的 Ruby 算术运算。
但真正的胜利在于,repo 之外的人甚至**没有** `issues` 可以调用 `where`,除非他们特意在 `Deps` 中请求 relation。通往数据库的便捷路径经过 repo,所以"新鲜"的含义只存在于一个地方,并且停留在那里。
这也是我之前说直接使用 relations 是个坏主意的原因。当你将 `relations.issues` 注入到一个 action 中的那一刻,你就回到了我们的 `FreshnessController`,"新鲜"的含义分散在每一个请求它的文件中。
## 持久化的泄漏
ActiveRecord 还有一个更隐蔽的问题,与你有多少调用点无关。每次你将一个 ActiveRecord 对象交到某人手中时,你同时也交出了**整个**持久化层。
看看这个序列化器。
```ruby
class IssueSerializer
def initialize(issue)
@issue = issue
end
def as_json(*)
@issue.touch(:last_viewed_at) {
title: @issue.title,
comments: @issue.comments.map(&:body)
}
end
end
```
这里我们有一个序列化器,它唯一的工作就是将一个 issue 转换为 JSON,但它却在写数据库,并且悄悄地为每条评论触发了一个查询。没有什么能阻止它。序列化器被交给了一个活跃的记录,所以它可以保存、更新、销毁以及懒加载任何它想要的东西,而你直到凌晨两点盯着日志才会发现。
现在让我们在 Hanami 中看看同样的事情。
```ruby
module MyApp
module Repos
class IssueRepo < DB::Repo
def find_with_comments(id)
issues.combine(:comments).by_pk(id).one
end
end
end
end
issue = issue_repo.find_with_comments(id)
issue.comments.map(&:body)
issue.update(last_viewed_at: Time.now)
# => ROM::Struct::MissingAttribute
```
Repo **提前**决定了要加载哪些数据,返回的是一个冻结的 struct。那个 struct 不能保存自己,不能回头访问数据库,当然也不能在你背后偷偷加载更多数据。如果你想写入任何东西,你必须通过 repo,而在 Hanami 中这意味着你必须在 `Deps` 中显式请求它。
你的 views、serializers 和 mailers 得到的是数据,仅此而已。这就是重点;持久化层留在持久化层,其他一切都只负责读取。
## 我所学到的
与上一期不同,我给了你具体要做的事情和要避免的事情,这一次我们将稍微有些不同。正如我所说,我自己每天仍在学习 Hanami 应用的这个方面的很多东西。所以,与其假装自己是某种权威来告诉你应该如何构建这个层,我更想告诉你一些我学到的东西,以及我目前**非常**谨慎地持有的一些看法。
我在 Hanami 探索中遇到的主要摩擦点之一是,我不认为我喜欢跨 slice 导出 repos 的做法。具体来说,我对导出执行变更操作的 repos 感到不安。我**强烈**认为所有的变更操作都应该放到 operations 中。
遗憾的是,目前 Hanami 中没有"只读"的抽象。最初我尝试过 query objects,但感觉很别扭,因为十有八九 query objects 只是在委托给 repo。这不是一个好的抽象,还增加了不必要的间接层。
所以我最终决定将我的 repos 拆分为两个。一个 repo **只**包含读取方法,命名为 `UserQueries`。另一个 repo 包含所有 `create`、`update` 和 `delete` 变更操作,命名为 `UserMutations`。接下来就是约定问题:`*_queries.rb` 文件允许被其他 slice 导出和导入,`*_mutations.rb` 则不允许。
我甚至为此[写了一个 rubocop 规则](https://github.com/aaronmallen/rubocop-hanami)来强制执行这个约定。我现在不确定这是否是我以后会继续采用的方式,但目前看来效果相当不错。
也有人告诉我,解决这个问题的另一种方法是简单地在 slice A 和 slice B 中重复编写 relation 和 struct 的代码,然后为每个特定的 slice 编写所需的 repo 方法。再次强调,Hanami 给你选择如何构建应用程序的自由。
## 粗糙的边缘
我必须承认,从 ActiveRecord 直接跳到 Sequel 和 ROM 确实有一定的学习曲线。它们之间有很多相似之处;查询语言并不觉得陌生。然而,也有许多小的差异,需要时间和反复尝试才能真正理解和掌握。
例如,与其写 `MyModel.where(published: true).order(created_at: :desc)`,你需要写 `my_model.where(published: true).order { created_at.desc }`。
Sequel 与 ActiveRecord 相比,有几十个处理方式上的细小差异。话虽如此,不同并不意味着**不好**。到目前为止我已经克服了遇到的每一个障碍,我相信你也会的。
另一件让我困扰的事情是,时间戳的概念并不像 Rails 那样深度集成到 Hanami 中。你的迁移中没有 `timestamps` 快捷方式可以调用。ROM 中内置了一些时间戳功能,但并不是最好的。为了确保 `updated_at` 被递增,你需要这样做:
```ruby
user.changeset(:update, **attrs).map(:touch).commit
```
我对这有两个主要问题:
1. 如果你在变更操作中忘记调用 `map(:touch)`,你就不会递增你的 `updated_at` 属性。
2. ROM 硬编码了填充的时间为 `Time.now`,意味着 `updated_at` 的时间取决于你服务器的时区设置。
我倾向于用以下扩展来扩展 ROM:
```ruby
require "rom/changeset"
require "sequel"
module MyApp
module Extensions
module ROM
module Changeset
module PipeRegistry
def touch(data)
Hash(updated_at: Sequel.function(:now)).merge(data)
end
end
end
end
end
end
ROM::Changeset::PipeRegistry.singleton_class.prepend(MyApp::Extensions::ROM::Changeset::PipeRegistry)
```
```ruby
require "my_app/extensions/rom/changeset/pipe_registry"
require "rom/changeset"
ROM::Changeset::Update.extend { touch }
```
这确保了时间戳使用的是数据库的时间(你也可以使用 `Time.now.utc`),并确保每次更新都递增 `updated_at` 时间戳。
我不认为这是一件坏事;我认为这是将 Hanami 塑造成你想要的样子是多么容易的证明。
好了,关于 Hanami 数据库层的内容就到这里了。如果本期你只记住一件事,我希望是:relations、repos 和 structs 之间的分工是有原因的。每一块都有且只有一个职责:relations 描述你的数据,repos 决定你如何获取它,structs 将数据交给你而无需赋予它额外的能力。
相似文章
Hanami, Why?: 杂七杂八
一篇关于 Hanami Ruby Web 框架的技术博客文章,讲解了框架的"系统级抽象",重点介绍了通过 Deps 模块实现的依赖注入机制,包括文件路径即容器键、依赖实例注入、以及自定义 initialize 时的注意事项等粗糙边缘。
Hanami,为什么?:引言
一位 Hanami 维护者开启了一个新的博客系列,解释为什么他为自己的开源个人网站选择了 Hanami Ruby Web 框架而非 Rails,并重点介绍了 Actions、Relations、Repos 和 Operations 等抽象。
Hanami 3.0:全面绽放
Hanami 3.0,一个Ruby Web框架,现已发布,内置邮件发送、国际化支持及Minitest集成,性能更快,开发者体验更佳。
Hanakai 和 Hanami 3.0 全面绽放
文章宣布了 Hanami、dry 和 rom 合并到 Hanakai 社区,并发布了 Hanami 3.0,其内置了邮件发送、国际化(i18n)支持、Minitest 支持,性能提升高达 3 倍,且内存分配更少。
抱歉,但你仍然需要思考
一篇评论文章分析了 DHH 让 AI 代理将 Campfire 从 Rails 重写为 Rust/Elixir/Go 的结果,指出在未人工审阅代码的情况下,重写引入了大量隐含的架构决策与性能问题(如阻塞式数据库操作、锁的误用),说明目前仍不能完全放弃阅读代码和思考。