ORM 仍然是一种反模式吗?


ORM是软件作者们喜欢挑剔的东西之一。网上有许多文章都是以同样的调子进行的:"ORMs是一种反模式。它们是初创公司的玩具,但最终伤害多于帮助"。

这是个夸张的说法。ORMs并不坏。它们是完美的吗?肯定不是,就像软件中的其他东西一样。同时,这些批评也是意料之中的--两年前,我还会全心全意地同意这个刻板的标题。我有过这样的经历:"你说ORM把服务器的内存用完了是什么意思?"。

但实际上,ORM被滥用的情况多于被过度使用的情况。

具有讽刺意味的是,这篇 "ORM并没有那么糟糕 "的辩护文章的灵感来自于我们在Lago经历的一次负面ORM事件,该事件使我们对我们对Ruby on Rails ORM--Active Record的依赖性产生了怀疑。这篇文章的一个非常诱人的标题是 "ORMs kinda suck"。但是在对这个问题进行了一些思考之后,我们认为ORM并不糟糕。它们只是一种抽象,具有典型的优点和缺点--它们抽象出一些可见性,偶尔会产生一些性能上的影响。就这样吧。

今天,让我们深入了解一下ORM,它们常见的批评,以及在今天的背景下那些令人信服的批评。

效率
对 ORM 的一个常见批评是它们效率低下。

这大多是错误的。ORM 比大多数程序员认为的要高效得多。然而,ORM 鼓励不良实践,因为依赖主机语言逻辑(即 JavaScript 或 Ruby)来组合数据是非常容易的。

例如,看一下这个优化不佳的 TypeORM 代码,它使用 JavaScript 来扩展数据条目:

const authorRepository = connection.getRepository(Author);
const postRepository = connection.getRepository(Post);

// Fetch all authors who belong to a certain company
const authors = await authorRepository.find({ where: { company: 'Hooli' } });

// Loop through each author and update their posts separately
for (let i = 0; i < authors.length; i++) {
  const posts = await postRepository.find({ where: { author: authors[i] } });
  
 
// Update each post separately
  for (let j = 0; j < posts.length; j++) {
    posts[j].status = 'archived';
    await postRepository.save(posts[j]);
  }
}

相反,开发人员应该使用 TypeORM 的内置功能来构造单个查询:

const postRepository = connection.getRepository(Post);

await postRepository
  .createQueryBuilder()
  .update(Post)
  .set({ status: 'archived' })
  .where("authorId IN (SELECT id FROM author WHERE company = :company)", { company: 'Hooli' })
  .execute();

ORM 和原始 SQL 查询类似物之间没有性能差异。
ORM 例子:

InvoiceSubscription
      .joins('INNER JOIN subscriptions AS sub ON invoice_subscriptions.subscription_id = sub.id')
      .joins('INNER JOIN customers AS cus ON sub.customer_id = cus.id')
      .joins('INNER JOIN organizations AS org ON cus.organization_id = org.id')
      .where("invoice_subscriptions.properties->>'timestamp' IS NOT NULL")
      .where(
       
"DATE(#{Arel.sql(timestamp_condition)}) = DATE(#{today_shift_sql(customer: 'cus', organization: 'org')})",
        today,
      )
      .recurring
      .group(:subscription_id)
      .select('invoice_subscriptions.subscription_id, COUNT(invoice_subscriptions.id) AS invoiced_count')
      .to_sql

对应原始 SQL:

SELECT
          invoice_subscriptions.subscription_id,
          COUNT(invoice_subscriptions.id) AS invoiced_count
        FROM invoice_subscriptions
          INNER JOIN subscriptions AS sub ON invoice_subscriptions.subscription_id = sub.id
          INNER JOIN customers AS cus ON sub.customer_id = cus.id
          INNER JOIN organizations AS org ON cus.organization_id = org.id
        WHERE invoice_subscriptions.recurring = 't'
          AND invoice_subscriptions.properties->>'timestamp' IS NOT NULL
          AND DATE(
            (
              -- TODO: A migration to unify type of the timestamp property must performed
              CASE WHEN invoice_subscriptions.properties->>'timestamp' ~ '^[0-9\.]+$'
              THEN
                -- Timestamp is stored as an integer
                to_timestamp((invoice_subscriptions.properties->>'timestamp')::integer)::timestamptz
              ELSE
                -- Timestamp is stored as a string representing a datetime
                (invoice_subscriptions.properties->>'timestamp')::timestamptz
              END
            )#{at_time_zone(customer: 'cus', organization: 'org')}
          ) = DATE(:today#{at_time_zone(customer: 'cus', organization: 'org')})
        GROUP BY invoice_subscriptions.subscription_id


ORM 不如原始SQL 查询高效。它们通常效率较低,在某些选择情况下,效率非常低。

  • 第一个问题是,ORM 在将查询转换为对象时有时会产生大量计算开销(TypeORM 是一个特别严重的问题)。
  • 第二个问题是,ORM 有时会通过循环一对多或多对多关系来多次往返数据库。这称为 N+1 问题(1 个原始查询 + N 个子查询)。

N+1 是 ORM 面临的一个常见问题。然而,通常可以通过使用数据加载器将查询折叠为两个查询而不是 N + 1 来处理。因此,与大多数其他常见 ORM“问题”一样,通常可以通过充分利用 ORM 功能集来避免 N+1 场景。

能见度
ORM 的最大问题是可见性。由于 ORM 是有效的查询编写器,因此除了明显的场景(例如不正确的基本类型)之外,它们并不是最终的错误调度程序。相反,ORM 需要消化返回的 SQL 错误并将其翻译给用户。

Active Record 正在努力解决这个问题,这就是我们重构计费订阅查询的原因。每当我们得到意想不到的结果时,我们就必须检查渲染的 SQL 查询,重新运行它,然后将 SQL 错误转换为 Active Record 更改。这种来回的过程破坏了使用 Active Record 的初衷,即避免直接与 SQL 数据库交互。

总结
ORM 并不是一件坏事。如果正确利用,它们几乎与原始 SQL 一样高效。不幸的是,ORM 经常被错误地利用。开发人员可能过于依赖主机语言逻辑结构来创建数据结构,而对 ORM 的本机 SQL 模拟功能不够依赖。

网友评论:
1、从Hibernate刚出现时,我就一直在Java上做ORM,它一直很糟糕。其中一个卖点是你可以使用不同的数据库,这一点现在已经被理解为是垃圾了。但是没有人使用不同的数据库。另一个卖点是 "你不需要知道SQL",这也是垃圾。每一个非微不足道的长期应用都需要在字符串层面上对单个查询进行调整。构建数据层的正确方法是一次一个查询,作为一个字符串,用字符串插值。你越接近于原始JDBC越好。

哦,对了,ORM的另一个坏理由:支持 "领域模型"。这总是,我是说总是,没有任何逻辑。所谓的 "贫血的领域模型 "是一种反模式。有多少人的时间被浪费在ORM、XML、注解、调试生成的SQL等方面?这让我哭泣。

2、有些 ORM 具有良好的优缺点。Python 有特别好的功能:

  • - Django 带有一个笨重且性能较差的版本,但它集成得非常好,超级实用,高效,并且具有良好的人体工程学设计。
  • - Peewee 为您提供了一个小包中的 ORM,当您不需要任何花哨的东西但又感到懒惰时,这使得编写这些小程序成为一种乐趣。
  • - SQLAlchemy 需要更多的投资,但非常灵活,生成干净的 SQL 并且具有极其正确的行为。当您不需要 OOP 范式并希望表达惯用的 SQL 行为但用 Python 进行抽象时,它还公开了一个较低级别的查询构建器。

3、我来自Java,四年前转到nodejs做后端。我现在使用 sequelize 作为 orm。你的大部分痛苦是由于hibernate本身造成的。它可能是唯一真正的企业级OM,但它是一个痛苦的屁股。

与hibernate相比,Sequelize非常简单,用它写代码是一件很愉快的事情。

当你说没有人使用不同的数据库时,99%的情况下这是真的,但我曾与一家公司合作,该公司开发的工具必须放在客户的基础设施中,这种类型的客户迫使你选择数据库,因为他们有高薪的数据库支持团队(金融部门),所以他们必须支持多个数据库。

一年前,我不得不在没有ORM的情况下开发一个大型的Java应用(CTO的选择):我不记得在没有OM的情况下开发是多么的乏味、容易出错和缓慢!!!再也不会这样做了!

我认为最好的方法是用orm来完成普通的垃圾任务,当事情变得有点复杂时,再添加特定的sql查询。


4、我不喜欢ORM,但好的ORM总比没有好。它们作为数据库和应用程序之间的一个相当简单的层,对于没有经验的工程师来说是很好的开始,有经验的工程师偶尔会帮助他们获得性能提示。
此外,它们一般都是一个很好的适配器。将世界的样子转换为数据库的样子,而将世界的样子转换为网络应用的样子,总是一项耗时的工作。

我不得不说,总的来说,多年来ORMs为我节省的时间比它们浪费的时间要多,Ruby中的AREL几乎可以节省80%的时间。当然,在某些情况下,我也会放弃使用SQL,但在大多数情况下,这只是一种胜利。它是一个完美的库:让简单的事情变得简单,让复杂的事情变得可能。这包括rails 7中令人惊叹的调试日志。就像改变游戏规则的风格。

我过去曾使用过Hibernate,我觉得它可能为我节省了20%的时间,但需要我学习一个复杂的系统。它确实有一些不错的功能,比如缓存之类的。

ORM本身很重要,我坚决不认为ORM是一种反模式。

至于最初的卖点......我不知道。

- 使用任何数据库,甚至不值得考虑这个问题。如果你切换数据库,你会有问题。

- 你不需要知道Sql。没错,但最终你将需要学习它。但我已经看到小工程师们走得很远,并将此作为一种学习经验。我认为从高级和初级两个层面来考虑问题是很重要的。我碰巧有大量的数据库经验,而且经常是调试sql的人,即使我是在前端团队,但许多人还没有达到这个水平。

编辑:对于那些说 "只要让我写正常的sql "的人。我想挑战一下你,想想如何将关系和查询模式的知识提取到代码库中,而不不断重复相同的该死的连接。SQL是一种糟糕的语言,但它是我们拥有的一种语言。所以ORM解决了很多SQL的弱点。它们对于编写自定义报告来说是非常糟糕的,但对于制作结构化的后端来说是非常好的。最后,如果你想把数据关系的知识抽象到代码库中,你要做的知识抽象最终就是......ORM。