超越 ORM:我为什么转向直接用 SQL 操作数据库

我一直对ORM的设计很感兴趣——我想知道它们是如何工作的,不同的方法是什么,以及它们的使用方式。我尝试了很多不同的,甚至自己创造了.但在过去几年里,我慢慢转向更直接地与数据库打交道——用纯SQL发出查询,处理来自数据库的原始数据,或者为关系型数据库构建自定义的低层抽象。

我代码与数据库交互方式的演变,正是我创建这些内容的原因摘录, 一个用于处理 SQLite 数据库的 Ruby 宝石。

为什么我更喜欢直接与数据库交互,而不是使用ORM?对于熟悉SQL的开发者来说,ORM的局限性应该很明显——它不支持窗口函数、常用表表达式(CTE),甚至不支持RETURNING子句;ORM层本身是一个重要的依赖(ActiveRecord代码库约为~43KLoC),它在内存和CPU时间方面带来了性能成本;其定义性特征是将行映射到对象,是一种反模式,被描述为”计算机科学的越南”.

我很清楚,ActiveRecord 是 Ruby/Rails 社区中与数据库通信最流行的方式,并且对许多 Ruby 及其他语言的 ORM 产生了巨大影响。但对我来说,ActiveRecord 一直感觉不对劲。

我越是构建网页应用和平台,越觉得这一点清晰。ActiveRecord 为关系数据提供了错误的抽象。ActiveRecord 专注于单一记录,即每条记录本身就是一个实体。

然而,实际上,规范化数据库会包含许多带有附加数据的表,比如标签或标签,这些数据不必被视为实体。有时间序列数据和事件日志。

如今,关系数据库甚至被用作键值存储和作业队列。对于这类数据,我们通常关注记录集合而不是单独记录,我们其实也不需要实体抽象。

对于这些用途,ActiveRecord 显得笨拙且浪费。

由于 ActiveRecord 的 API 围绕着为每一行单独一个对象,源自ActiveRecord::Base拥有数百个实例和类方法,程序员可能会误以为他们实际上处理的是离散对象,而不是记录集中的条目。

有了 ActiveRecord,初学者可能想删除或更新多个记录,使用#each,例如:

def make_bracelet(material)
  beads = Bead.where(material:).all
  bracelet = Bracelet.new(beads)
  beads.each(&:delete) # <== a separate DELETE query for each bead
  bracelet
end

在上面的例子中,我们发了一个SELECT查询以获取我们感兴趣的珠子,然后每篇帖子我们都会发布DELETE查询,所以我们得到了N+1情况。

当然,一个简单的解决办法是打电话delete_all我们没有绕过珠子,而是读:

def make_bracelet(material)
  beads = Bead.where(material:).all
  bracelet = Bracelet.new(beads)
  Bead.where(material:).delete_all
  bracelet
end

所以现在我们只剩下两个查询,一个SELECT查询和DELETE查询,两者都具有相同的WHERE条款:

select * from beads where material = ?;
delete from beads where material = ?;

其中一个问题是,除非在交易中运行这两个查询,否则我们无法保证这两个查询会接触到相同的记录。第二个问题是,我们仍然在对数据库进行两次往返。

我们不能在一次查询中同时读取和删除记录吗?可以的,至少在像PostgreSQL和SQLite这样的数据库中,可以使用RETURNING条款:

delete from beads where material = ? returning *;

运行这个查询会删除相关记录,并返回它们的内容。据我所知,ActiveRecord 中没有针对这类查询的 API。但我们只需要一个简单的 SQL 查询,使用 Extralite 应该很简单:

def make_bracelet(material)
  beads = @db.query <<~SQL
    delete from beads where material = ? returning *
  SQL
  Bracelet.new(beads)
end

我们在这里实现的是将与数据库的交互简化为一个查询,既删除相关行又返回其内容。是的,这意味着我们用纯SQL表达查询,这可能会让你犹豫,但请耐心听我说,我有点重点。

DSL陷阱

ActiveRecord 和 ORM 的主要卖点之一是 DSL 的便利性:无需编写 SQL,只需使用魔法DSL 可以构建你想要的任何查询。实际上,你会得到一个极好的通用 API,拥有数百个实例和类方法,允许你以任何方式动态构建查询。

但我们真的需要那么多数百种方法吗?

我在几乎所有我看过的网页应用代码库中观察到的,除了极少数情况下,不同某个应用会发出的查询是有限的,实际上相对较小。所有那些CRUD应用——作者:CRUD 猴子 😉- 它们基本上只是发出几种不同类型的查询。

例如,一个简单的博客应用可能会执行以下不同的查询:

Post.Create(title: 'foo', body: 'bar')            # create
post = Post.find(42)                              # read
post.save                                         # update
post.delete                                       # delete
Post.order_by(:stamp).all                         # list
Post.where(category: 'baz').order_by(:stamp).all  # list by category

你的应用可能想引入一些关联,可能想用不同方式筛选和排序帖子,所以它需要做一些其他查询,但它们的数量平均,我猜大约每张表有十几个不同的查询。

除非你在构建一个功能齐全的可视化查询构建器应用!

所以我认为应该问的问题是:如果我们只需要做几十个不同的查询,为什么还要用DSL?我们真的需要ActiveRecord的所有魔力和表现力吗?

为什么不直接用SQL表达这些查询?相比你应用里的Ruby代码量,你要写的SQL只是杯水车薪!

现在,有人可能会说,使用ActiveRecord相较于写普通SQL查询的最大优势是没有模板模板,而且你能免费获得所有功能,其中关联可能是最重要的。

不过,在我看来,一旦你尝试做一些更高级或晦涩的事情,比如在查询中加入非实体数据,或者使用窗口函数,这些抽象就会在它们自身的重量下崩溃。

我认为没有基本SQL基础就与关系型数据库互动不是个好主意。是的,氛围编码已经风靡我们的行业,很多人显然认为代码已经不重要了,我们甚至不应该去看代码,我们现在都是提示猴子我们都应该花时间花大量钱给Anthropic,注意我们的“配额”,想出有创意的方式来节省代币(多么愚蠢的想法!),打造那些酷炫的超复杂循环、线束和“技能”,AGENTS.md文件什么的胡说而不是,你知道,直接写正常的代码。

本文作者认为,理解你的代码依然重要,了解你的数据库在做什么,性能依然重要,并且在使用计算资源时至少保持一定的节俭也很重要(实际上,考虑到我们当前的环境挑战,这一点更为重要!)

此外,我觉得有趣的是,一方面投入了大量精力让Ruby运行时更快,而使用Ruby on Rails的开发者却对性能表现出一种漫不经心、几乎无知的态度:“谁在乎呢,计算是无限资源,代理会处理它,我们只需告诉它让代码变快。”事实上,谁能责怪他们呢?

只要AI平台的价格不反映AI计算的真实成本,那些喜欢Vi-Code的人为什么要关心自己代码的性能(他们根本没看过!),为什么要在意最大化利用他们的计算基础设施?

从这个意义上说,几位非常有才华的开发者在让Ruby本身更快方面所做的惊人工作,是一项艰巨的任务,考虑到每天不断给实际的Ruby on Rails应用添加的荒谬垃圾。

不过我离题了。

重新思考MVC中的M。

长期以来,人们普遍认为MVC中的M应该是某种ORM,一个主要负责将表行映射到实体对象的层,并为获取和操作这些对象提供一个表达式的API。

但也许我们可以自己设计一个通用查询构建器,而不是用通用查询构建器与数据库交互定制 API用来与数据库交互。让我们重举一个简单的博客应用的例子,想象我们有一个专门处理帖子的界面:

posts = PostsStore.new(db)
id = posts.create(title: 'foo', body: 'bar')  # create
post = posts.by_id(id)                        # id
posts.update_by_id(id, title: 'FOO')          # update
posts.delete_by_id(id)                        # delete
posts.all                                     # list
posts.all_by_category(category: 'baz')        # list by category

这六个查询和之前一样,但返回行的方法使用普通的 Ruby 哈希,而不是自定义对象。从应用的角度来看,这只是界面的改变,我们本质上为博客应用创建了一个专门的 API,用于阅读和操作帖子。

就像 ActiveRecord 一样,整个数据库层被抽象成一组方法,控制器/业务逻辑代码不需要使用Post类拥有其深度且可链式的API,它只需调用接口上的方法。

无论如何,最重要的变化是,每当我们处理帖子时,不能随便发明新的查询,我们必须使用现有的查询,或者在PostsStore类。实现PostsStore非常简单:

class PostsStore
  def initialize(db)
    @db = db
  end

  def create(title:, body:)
    @db.query_splat <<~SQL, title, body
      insert into posts (title, body)
      values (?, ?)
      returning id
    SQL
  end

  def by_id(id)
    @db.query_single_row <<~SQL, id
      select id, title, body
      from posts
      where id = ?
    SQL
  end

  ...

  def all
    @db.query <<~SQL
      select id, title, body, stamp
      from posts
      order by stamp desc
    SQL
  end
end

这里我们利用了 Extralite 的一个标志性功能——能够以任意形式提取数据,无论是单个值、单行还是一组行。Extralite 还有一些更高级的功能,下面我会展示。

看看我们没做的所有事情:没有实体对象,我们想要的数据以我们需要的形式从数据库返回,用纯 Ruby 哈希,一切都显式且易于理解——查询、参数、列。

我们大幅减少了分配数量。当然,这类代码可以轻松搭建支架(用于 CRUD),甚至如果你愿意,可以由你喜欢的 slop agent 生成。

另外,看看我们是如何去除所有不必要的抽象的:没有实体类,也没有 DSL 驱动查询构建。我们直接与数据库通信,借助 Extralite,并提供了一个方便的 API 来抽象数据库层。

模型层是一个API

这种设计一开始可能让人困惑——在应用开发过程中,哪里能随时创建查询?与实体的交互在哪里?我该把业务逻辑放在哪里?所有这些问题的答案都是:存储类。

存储类封装了与某种特定类型的数据相关的一切。存储类应该作为用于与帖子交互的界面。无论你需要用帖子做什么,都应该在PostsStore类。

例如,如果你需要阅读带有关联的帖子,只需添加一个方法,执行正确的查询并返回包含关联的数据。在这方面,Extralite 也能帮到我们,因为它能连接行的变换投影,有效地将结果集转换为目标图:

class PostsStore
  POSTS_WITH_AUTHORS = Extralite::Transform do
    {
      id:       integer.identity, # posts.id
      title:    text,             # posts.title
      body:     text,             # posts.body
      stamp:    integer,          # posts.stamp
      author:   {
        id:     integer.identity, # authors.id
        name:   text              # authors.name
      }]
    }
  end
  
  def all_with_authors
    @db.query POSTS_WITH_AUTHORS, <<~SQL
      select posts.id, posts.title, posts.body, posts.stamp,
             authors.id, authors.name
      from postss
      join authors on authors.id = posts.author_id
      order by posts.stamp desc
    SQL
  end
end

你可能会说:这么多代码,我本可以用ActiveRecord免费获得的东西!是的,实现它需要写一些SQL和支持代码,但从你的应用角度看,API和以前一样简单,从商店界面获得的数据包含了你需要的所有内容,只是用普通的Ruby哈希来表达:

# here's how a view might look like, using Papercraft:
POSTS_VIEW = ->(posts:) {
  div(id: 'posts') {
    posts.each { |p|
      div(class: 'item') {
        h3 p[:title]
        h4 p[:author][:name]
        markdown snippet(p[:body])
      }
    
  }
}

# Here's how a controller might look like, Using Syntropy:
def call(req)
  posts = @posts_store.all_with_authors
  html = LAYOUT.render(posts:, &POSTS_VIEW)
  req.render_html(html)
end

坦率地说,这比用ActiveRecord难吗?此外,与深度、链式的方法调用不同,比如Post.where(...).order_by(...)我们在整个应用代码库中,专门为我们特定类型的实体(博客文章)定制了一个专门的界面,处理我们想用它们做的所有事情,并通过常规的方法调用抽象化,没有任何魔法。

我想说明的另一个细节是,Posts Store 是以类形式实现的,实际上它更像是单例。我把它作为类实现的原因是能够向存储对象注入数据库连接。

根据你的需求,可以用很多其他方式实现。例如,你可能想传递连接池而不是连接,或者直接将接口实现为全局单例模块。归根结底是同一个理念:模型层作为接口,而不是驱动实体对象的 DSL 类。

存储抽象(其实只是接口)也可以用来与非实体数据交互,比如时间序列数据、辅助数据、键值存储、作业队列等。存储甚至不需要对应到单个表。

由于其构建模块是SQL查询,你可以访问任意数量的表,使用全部可用的SQL特性,以便读取和操作相关数据。

接口传递

该设计中值得进一步讨论的一个方面是将接口作为参数传递给方法调用的理念。在 Ruby 中,我们其实并不真正谈论接口,而是直接交互的离散对象。

接口也是一个对象,但它并不封装数据(虽然可能有一些状态),而是封装功能。它实际上,是方法的容器。

我已经用这个“界面模式”有一段时间了。在乌灵机器例如,I/O是通过接口执行的,该接口是UringMachineUM简而言之:

require 'uringmachine'

machine = UM.new
machine.write(UM::STDOUT_FILENO, "hello, world!")
machine.open('foo.txt', UM::O_RDONLY) do |fd|
  buf = +''
  size = machine.read(fd, buf, 8192)
  machine.write(UM::STDOUT_FILENO, buf)
end

本质上,所有I/O操作都通过该接口完成,这意味着你在做I/O时必须有接口的引用。这种设计并非UringMachine独有。最显著的是,Zig编程语言现在将I/O作为接口实现,作为参数传递(此外还有分配器接口)。

Go是接口无处不在使用的另一个例子。

虽然这意味着你需要将界面对象传递到应用的不同部分,但你可以使用各种技术来简化界面操作。一种方法是使用依赖注入。我们上面看到了一个示例,将数据库实例注入到PostsStore实例。

同样的应用也适用于UringMachine,我们将机器实例传递给抽象HTTP连接的对象:

class HTTPConnection
  def initialize(machine, fd, &handler)
    @fd = fd
    @machine = machine
    @handler = handler
  end

  def respond_empty(status = 200)
    @machine.write(@fd, "HTTP/1.1 #{status}\r\nContent-Length: 0\r\n\r\n")
  end
end

一种相关的方法是使用闭包,这在处理可调用函数时尤其有用:

def make_posts_handler(posts_store)
  ->(req) {
    posts = posts_store.all
    req.respond_html(render_posts(posts))
  }
end

app.start(&make_posts_handler(@posts_store))

使用界面对象的一个重要后果是,它鼓励你以更负责任的方式构建你的应用。例如,除非你懂得更清楚,否则你可能会被诱惑去阅读或操作视图模板的某个内部帖子。

但用这种设计,除非模板代码控制了PostsStore比如,这其实是个坏主意,应该是禁止.因此,你可以确保代码中任何不包含引用的部分PostsStore界面无法操作数据库,这应该能大大帮你保持更多“毛发”!

利用准备好的陈述

但让我们回到 ORMs。ORM 的另一个缺失特性是不能使用预备语句。预备语句,在 SQLite 和我相信 PostgreSQL 中,都是事先准备好的查询,应用程序可以反复执行,数据库每次执行时都不需要反复解析查询。

预备语句是短暂的——它们只存在于数据库连接期间。在 SQLite 中,这些只是普通查询(陈述用SQLite技术术语来说),这些数据被保存在内存中以便重复使用,而不是在执行后直接丢弃。

既然我们处理的是(大多数情况下)有限数量的不同查询,为什么数据库还要反复解析相同的查询?我们可以用预备语句来实现这一点。

虽然 Extralite 有Extralite::Query实现预备语句(或查询)的类,我最近一直在研究数据库层级自动缓存语句,这样任何带参数的查询都会存储在缓存中,并在给出相同SQL时自动重用Database#query.

代码还没发布(希望月底前能完成),我也还没做过基准测试看看它对性能的影响。使用这个功能,你会获得略高的内存使用(每个准备的查询占用几KB内存),但你会减少CPU时间,也会减少内存分配。

我很期待看到未来发展,以及我们能在多大程度上最大化利用 SQLite 数据库的理念。Ruby 现在速度非常快,而且只会越来越快、越来越好。

现在轮到我们去剔除浪费和不必要的抽象,重新投入到编写更快、更精简、更好的软件上了。

添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论