为什么你需要避免使用ORM含Nodejs示例

26次阅读

共计 12468 个字符,预计需要花费 32 分钟才能阅读完成。

在这篇文章里,我们将讨论为什么在项目中不应该使用 ORM(对象关系映射)。
虽然本文讨论的概念适用于所有的语言和平台,代码示例还是使用了 Javascript 编写的 Nodejs 来说明,并从 NPM 库中获取包。

首先,我无意 diss 任何在本文中提到的任何模块。它们的作者都付诸了大量的辛勤劳动。同时,它们被很多应用程序用在生产环境,并且每天都响应大量的请求。我也用 ORM 部署过应用程序,并不觉得后悔。

快跟上!

ORM 是强大的工具。我们将在本文中研究的 ORM 能够与 SQL 后端进行通信,例如 SQLite, PostgreSQL, MySQLMSSQL。本篇示例将会使用PostgreSQL,它是一种强大的 SQL 服务器。另外还有一些ORM 可以和 NoSQL 通讯,例如由 MongoDB 支持的 Mongoose ORM,这些ORM 不在本篇讨论范围之内。

首先,运行下述命令启动一个本地的 PostgreSQ L 实例,该实例将以这种方式被配置:对本地 5432 端口(localhost:5432) 的请求将被转发到容器。同时,文件将会储存至根目录,随后的实例化将保存我们已经创建的数据。

mkdir -p ~/data/pg-node-orms
docker run 
  --name pg-node-orms 
  -p 5432:5432 
  -e POSTGRES_PASSWORD=hunter12 
  -e POSTGRES_USER=orm-user 
  -e POSTGRES_DB=orm-db 
  -v ~/data/pg-node-orms:/var/lib/postgresql/data 
  -d 
  postgres

现在我们将拥有一个数据库,可供我们新建表和插入数据。这将使我们能够查询数据并更好地理解各个抽象层,运行下一个命令以进入 PostgreSQL 交互。

docker run 
  -it --rm 
  --link pg-node-orms:postgres 
  postgres 
  psql 
  -h postgres 
  -U orm-user 
  orm-db


在提示符下,输入上一个代码块中的密码,hunter12。连接成功后,复制下述查询代码并执行。

CREATE TYPE item_type AS ENUM ('meat', 'veg', 'spice', 'dairy', 'oil');

CREATE TABLE item (
  id    SERIAL PRIMARY KEY,
  name  VARCHAR(64) NOT NULL,
  type  item_type
);

CREATE INDEX ON item (type);

INSERT INTO item VALUES
  (1, 'Chicken', 'meat'), (2, 'Garlic', 'veg'), (3, 'Ginger', 'veg'),
  (4, 'Garam Masala', 'spice'), (5, 'Turmeric', 'spice'),
  (6, 'Cumin', 'spice'), (7, 'Ground Chili', 'spice'),
  (8, 'Onion', 'veg'), (9, 'Coriander', 'spice'), (10, 'Tomato', 'veg'),
  (11, 'Cream', 'dairy'), (12, 'Paneer', 'dairy'), (13, 'Peas', 'veg'),
  (14, 'Ghee', 'oil'), (15, 'Cinnamon', 'spice');

CREATE TABLE dish (
  id     SERIAL PRIMARY KEY,
  name   VARCHAR(64) NOT NULL,
  veg    BOOLEAN NOT NULL
);

CREATE INDEX ON dish (veg);

INSERT INTO dish VALUES
  (1, 'Chicken Tikka Masala', false), (2, 'Matar Paneer', true);

CREATE TABLE ingredient (dish_id   INTEGER NOT NULL REFERENCES dish (id),
  item_id   INTEGER NOT NULL REFERENCES item (id),
  quantity  FLOAT DEFAULT 1,
  unit      VARCHAR(32) NOT NULL
);

INSERT INTO ingredient VALUES
  (1, 1, 1, 'whole breast'), (1, 2, 1.5, 'tbsp'), (1, 3, 1, 'tbsp'),
  (1, 4, 2, 'tsp'), (1, 5, 1, 'tsp'),
  (1, 6, 1, 'tsp'), (1, 7, 1, 'tsp'), (1, 8, 1, 'whole'),
  (1, 9, 1, 'tsp'), (1, 10, 2, 'whole'), (1, 11, 1.25, 'cup'),
  (2, 2, 3, 'cloves'), (2, 3, 0.5, 'inch piece'), (2, 13, 1, 'cup'),
  (2, 6, 0.5, 'tsp'), (2, 5, 0.25, 'tsp'), (2, 7, 0.5, 'tsp'),
  (2, 4, 0.5, 'tsp'), (2, 11, 1, 'tbsp'), (2, 14, 2, 'tbsp'),
  (2, 10, 3, 'whole'), (2, 8, 1, 'whole'), (2, 15, 0.5, 'inch stick');

你现在拥有一个填充的数据库,你现在可以输入 quit 和 psql 断开连接,并重新控制终端。如果你需要再次输入原始 SQL 语句,你可以再次运行 docker run 命令。

最后,你还需要创建一个 connection.json 文件,如下所示。这个文件稍后将会被 Node 应用用于连接数据库。

{
  "host": "localhost",
  "port": 5432,
  "database": "orm-db",
  "user": "orm-user",
  "password": "hunter12"
}

抽象层

在深入研究过多代码之前,让我们先弄清楚一些不同的抽象层。就像其他所有的计算机科学一样,在我们增加抽象层时也要进行权衡。在每增加一个抽象层时,我们都尝试以降低性能为代价,以提高开发人员生产力(尽管并非总是如此)。

底层:数据库驱动程序

基本上是我们所能达到的最低级别,再往下就是手动生成 TCP 包并发送至数据库了。数据库驱动将处理连接到数据库 (有时是连接池) 的操作。在这一层,我们将编写原始 SQL 语句发送至数据库,并接收响应。在 Node.js 生态系统中,有许多库在此层运行,下面是三个最流行的库:

  • mysql: MySQL (13k stars / 330k weekly downloads)
  • pg: PostgreSQL (6k stars / 520k weekly downloads)
  • sqlite3: SQLite (3k stars / 120k weekly downloads)

这些库基本上都是以相同的方式工作:

  • 获取数据库凭据,
  • 实例化一个新的数据库实例,
  • 连接到数据库,
  • 然后以字符串形式向其发送查询并异步处理结果

下面是一个简单的示例,使用 pg 模块获取做 Chicken Tikka Masala 所需的原料清单:

#!/usr/bin/env node

// $ npm install pg

const {Client} = require('pg');
const connection = require('./connection.json');
const client = new Client(connection);

client.connect();

const query = `SELECT
  ingredient.*, item.name AS item_name, item.type AS item_type
FROM
  ingredient
LEFT JOIN
  item ON item.id = ingredient.item_id
WHERE
  ingredient.dish_id = $1`;

client
  .query(query, [1])
  .then(res => {console.log('Ingredients:');
    for (let row of res.rows) {console.log(`${row.item_name}: ${row.quantity} ${row.unit}`);
    }

    client.end();});

中层:查询构造器

该层是介于使用简单的数据库驱动和成熟的 ORM 之间的一层,
在此层运行的最著名的模块是Knex。该模块能够为几种不同的 SQL 语言生成查询语句。这个模块依赖上面提到的几个数据库驱动库 – 你需要安装特定的库来使用Knex

  • Knex:Query Builder (8k stars / 170k weekly downloads)

创建 Knex 实例时,提供连接详细信息以及计划使用的 sql 语言,然后便可以开始进行查询。你编写的查询将与基础 SQL 查询非常相似。一个好处是,与将字符串连接在一起形成 SQL 相比(通常会引发安全漏洞),你能够以一种更加方便的方式 - 以编程方式生成动态查询。

下面是一个使用 Knex 模块获取烹饪 Chicken Tikka Masala 材料清单的一个示例:

#!/usr/bin/env node

// $ npm install pg knex

const knex = require('knex');
const connection = require('./connection.json');
const client = knex({
  client: 'pg',
  connection
});

client
  .select([
    '*',
    client.ref('item.name').as('item_name'),
    client.ref('item.type').as('item_type'),
  ])
  .from('ingredient')
  .leftJoin('item', 'item.id', 'ingredient.item_id')
  .where('dish_id', '=', 1)
  .debug()
  .then(rows => {console.log('Ingredients:');
    for (let row of rows) {console.log(`${row.item_name}: ${row.quantity} ${row.unit}`);
    }

    client.destroy();});

上层:ORM

这是我们要讨论的最高抽象级别。当我们使用 ORM 时,都要在使用前进行一大堆的配置。顾名思义,ORM的要点是将关系数据库中的记录映射到应用程序中的对象(一般来说是一个类实例,但并非全部)。这意味着我们在应用程序代码中定义这些对象的结构及其关系。

  • sequelize: (16k stars / 270k weekly downloads)
  • bookshelf: Knex based (5k stars / 23k weekly downloads)
  • waterline: (5k stars / 20k weekly downloads)
  • objection: Knex based (3k stars / 20k weekly downloads)

在下面的示例中,我们将研究最受欢迎的 ORMSequelize。我们还将使用Sequelize 对原始 PostgreSQL 模式中表示的关系进行建模, 下面是一个使用 Sequelize 模块获取烹饪 Chicken Tikka Masala 材料清单的一个示例:

#!/usr/bin/env node

// $ npm install sequelize pg

const Sequelize = require('sequelize');
const connection = require('./connection.json');
const DISABLE_SEQUELIZE_DEFAULTS = {
  timestamps: false,
  freezeTableName: true,
};

const {DataTypes} = Sequelize;
const sequelize = new Sequelize({
  database: connection.database,
  username: connection.user,
  host: connection.host,
  port: connection.port,
  password: connection.password,
  dialect: 'postgres',
  operatorsAliases: false
});

const Dish = sequelize.define('dish', {id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true},
  name: {type: DataTypes.STRING},
  veg: {type: DataTypes.BOOLEAN}
}, DISABLE_SEQUELIZE_DEFAULTS);

const Item = sequelize.define('item', {id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true},
  name: {type: DataTypes.STRING},
  type: {type: DataTypes.STRING}
}, DISABLE_SEQUELIZE_DEFAULTS);

const Ingredient = sequelize.define('ingredient', {dish_id: { type: DataTypes.INTEGER, primaryKey: true},
  item_id: {type: DataTypes.INTEGER, primaryKey: true},
  quantity: {type: DataTypes.FLOAT},
  unit: {type: DataTypes.STRING}
}, DISABLE_SEQUELIZE_DEFAULTS);

Item.belongsToMany(Dish, {through: Ingredient, foreignKey: 'item_id'});

Dish.belongsToMany(Item, {through: Ingredient, foreignKey: 'dish_id'});

Dish.findOne({where: {id: 1}, include: [{model: Item}]}).then(rows => {console.log('Ingredients:');
  for (let row of rows.items) {
    console.log(`${row.dataValues.name}: ${row.ingredient.dataValues.quantity} ` +
      row.ingredient.dataValues.unit
    );
  }

  sequelize.close();});

你已经看到了如何使用不同的抽象层执行类似查询的示例,现在,让我们深入了解您应该谨慎使用 ORM 的原因。

理由一:你在学习错误的东西

许多人选择 ORM 是因为他们不想花时间学习基础 SQL,人们通常认为 SQL 很难学习,并且通过学习 ORM,我们可以使用一种语言而不是两种来编写应用程序。乍一看,这似乎是一个好理由。ORM将使用与应用程序其余部分相同的语言编写,而 SQL 是完全不同的语法。

但是,这种思路存在问题。问题是 ORM 代表了你可以使用的一些最复杂的库。ORM的体积很大,从内到外学习它不是一件容易的事。

一旦你掌握了特定的 ORM,这些知识可能无法很好地应用在其他语言中。假设你从一种平台切换到另一种平台(例如 JS / Node.js 到 C#/NET)。但也许更不易被考虑到的是,如果您在同一平台上从一个 ORM 切换到另一个,例如在 Nodejs 中从 Sequelize 切换到Bookshelf。例如:

Sequelize

#!/usr/bin/env node

// $ npm install sequelize pg

const Sequelize = require('sequelize');
const {Op, DataTypes} = Sequelize;
const connection = require('./connection.json');
const DISABLE_SEQUELIZE_DEFAULTS = {
  timestamps: false,
  freezeTableName: true,
};

const sequelize = new Sequelize({
  database: connection.database,
  username: connection.user,
  host: connection.host,
  port: connection.port,
  password: connection.password,
  dialect: 'postgres',
  operatorsAliases: false
});

const Item = sequelize.define('item', {id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true},
  name: {type: DataTypes.STRING},
  type: {type: DataTypes.STRING}
}, DISABLE_SEQUELIZE_DEFAULTS);

// SELECT "id", "name", "type" FROM "item" AS "item"
//     WHERE "item"."type" = 'veg';
Item
  .findAll({where: {type: 'veg'}})
  .then(rows => {console.log('Veggies:');
    for (let row of rows) {console.log(`${row.dataValues.id}t${row.dataValues.name}`);
    }
    sequelize.close();});

Bookshelf:

#!/usr/bin/env node

// $ npm install bookshelf knex pg

const connection = require('./connection.json');
const knex = require('knex')({
  client: 'pg',
  connection,
  // debug: true
});
const bookshelf = require('bookshelf')(knex);

const Item = bookshelf.Model.extend({tableName: 'item'});

// select "item".* from "item" where "type" = ?
Item
  .where('type', 'veg')
  .fetchAll()
  .then(result => {console.log('Veggies:');
    for (let row of result.models) {console.log(`${row.attributes.id}t${row.attributes.name}`);
    }
    knex.destroy();});

Waterline:

#!/usr/bin/env node

// $ npm install sails-postgresql waterline

const pgAdapter = require('sails-postgresql');
const Waterline = require('waterline');
const waterline = new Waterline();
const connection = require('./connection.json');

const itemCollection = Waterline.Collection.extend({
  identity: 'item',
  datastore: 'default',
  primaryKey: 'id',
  attributes: {id: { type: 'number', autoMigrations: {autoIncrement: true} },
    name: {type: 'string', required: true},
    type: {type: 'string', required: true},
  }
});

waterline.registerModel(itemCollection);

const config = {
  adapters: {'pg': pgAdapter},

  datastores: {
    default: {
      adapter: 'pg',
      host: connection.host,
      port: connection.port,
      database: connection.database,
      user: connection.user,
      password: connection.password
    }
  }
};

waterline.initialize(config, (err, ontology) => {
  const Item = ontology.collections.item;
  // select "id", "name", "type" from "public"."item"
  //     where "type" = $1 limit 9007199254740991
  Item
    .find({type: 'veg'})
    .then(rows => {console.log('Veggies:');
      for (let row of rows) {console.log(`${row.id}t${row.name}`);
      }
      Waterline.stop(waterline, () => {});
    });
});

Objection:

#!/usr/bin/env node

// $ npm install knex objection pg

const connection = require('./connection.json');
const knex = require('knex')({
  client: 'pg',
  connection,
  // debug: true
});
const {Model} = require('objection');

Model.knex(knex);

class Item extends Model {static get tableName() {return 'item';}
}

// select "item".* from "item" where "type" = ?
Item
  .query()
  .where('type', '=', 'veg')
  .then(rows => {for (let row of rows) {console.log(`${row.id}t${row.name}`);
    }
    knex.destroy();});


在这些示例之间,简单读取操作的语法差异巨大。随着你尝试执行的操作的复杂性增加,例如涉及多个表的操作,ORM 语法在不同的实现之间差异会更大。

仅 Node.js 就有至少几十个 ORM,而所有平台至少有数百个 ORM。学习所有这些工具将是一场噩梦!

对我们来说幸运的是,实际上只需要学习有限的几种 SQL 语言。通过学习如何使用原始 SQL 生成查询,可以轻松地在不同平台之间传递此知识。

理由二:复杂的 ORM 调用效率低下

回想一下,ORM的目的是获取存储在数据库中的基础数据并将其映射到我们可以在应用程序中进行交互的对象中。当我们使用 ORM 来获取某些数据时,这通常会带来一些效率低下的情况。

例如,看一下我们在抽象层章节中做的查询。在该查询中,我们只需要特定配方的成分及其数量的列表。首先,我们通过手工编写 SQL 进行查询。接下来,我们使用查询构造器 Knex 进行查询。最后,我们使用 Sequelize 进行了查询。
让我们来看一下由这三个命令生成的查询:

用 ”pg” 驱动手工编写 SQL

第一个查询正是我们手工编写的查询。它代表了获取所需数据的最简洁方法。

SELECT
  ingredient.*, item.name AS item_name, item.type AS item_type
FROM
  ingredient
LEFT JOIN
  item ON item.id = ingredient.item_id
WHERE
ingredient.dish_id = ?;

当我们为该查询添加 EXPLAIN 前缀并将其发送到PostgreSQ L 服务器时,花费为 34.12。

用“knex”查询构造器生成

下一个查询主要是 Knex 帮我们生成的,但是由于 Knex 查询构造器的明确特性,性能上应该有一个很好的预期。

select
  *, "item"."name" as "item_name", "item"."type" as "item_type"
from
  "ingredient"
left join
  "item" on "item"."id" = "ingredient"."item_id"
where
"dish_id" = ?;

为了便于阅读,我添加了换行符。除了我手写的示例中的一些次要格式和不必要的表名外,这些查询是相同的。实际上,运行 EXPLAIN 查询后,我们得到的分数是 34.12。

用“Sequelize”ORM 生成

现在,让我们看一下由 ORM 生成的查询:

SELECT
  "dish"."id", "dish"."name", "dish"."veg", "items"."id" AS "items.id",
  "items"."name" AS "items.name", "items"."type" AS "items.type",
  "items->ingredient"."dish_id" AS "items.ingredient.dish_id",
  "items->ingredient"."item_id" AS "items.ingredient.item_id",
  "items->ingredient"."quantity" AS "items.ingredient.quantity",
  "items->ingredient"."unit" AS "items.ingredient.unit"
FROM
  "dish" AS "dish"
LEFT OUTER JOIN (
  "ingredient" AS "items->ingredient"
  INNER JOIN
  "item" AS "items" ON "items"."id" = "items->ingredient"."item_id"
) ON "dish"."id" = "items->ingredient"."dish_id"
WHERE
"dish"."id" = ?;

为了便于阅读,我添加了换行符。如你所见,此查询与前两个查询有很大不同。为什么行为如此不同?由于我们已定义的关系,Sequelize试图获得比我们要求的更多的信息。直白讲就是,当我们只在乎属于该菜的配料时,会获得有关菜本身的信息。根据 EXPLAIN 的结果,此查询的花费为 42.32

理由三:ORM 不是万能的

并非所有查询都可以表示为 ORM 操作。当我们需要生成这些查询时,我们必须回过头来手动生成 SQL 查询。这通常意味着使用大量 ORM 的代码库仍然会有一些手写查询。意思是,作为从事这些项目之一的开发人员,我们最终需要同时了解 ORM 语法和一些基础 SQL 语法。一种普遍的情况是,当查询包含子查询时,ORM通常不能很好的工作。考虑一下这种情况,我想在数据库中查询 1 号菜所需的所有配料,但不包含 2 号菜的配料。为了实现这个需求,我可能会运行以下查询:

SELECT *
FROM item
WHERE
  id NOT IN
    (SELECT item_id FROM ingredient WHERE dish_id = 2)
  AND id IN
(SELECT item_id FROM ingredient WHERE dish_id = 1);

据我所知,无法使用上述 ORM 清晰地表示此查询。为了应对这些情况,ORM通常会提供将原始 SQL 注入到查询接口的功能。Sequelize提供了一个 .query() 方法来执行原始 SQL,就像您正在使用基础数据库驱动程序一样。通过 BookshelfObjection,你可以访问在实例化期间提供的原始 Knex 对象,并将其用于查询构造器功能。Knex 对象还具有 .raw() 方法来执行原始 SQL。使用 Sequelize,你还可以使用 Sequelize.literal() 方法,将原始 SQL 散布在 Sequelize 调用的各个部分中。但是在每种情况下,你仍然需要了解一些基础 SQL 才能生成这些查询。

查询构造器:最佳选择

使用底层的数据库驱动程序模块很有吸引力。生成数据库查询时没有多余的开销,因为 SQL 语句是我们手动编写的。我们项目的依赖也得以最小化。但是,生成动态查询可能非常繁琐,我认为这是使用数据库驱动最大的缺点。

例如,在一个 Web 界面中,用户可以在其中选择想要检索项目的条件。如果用户只能输入一个选项(例如颜色),我们的查询可能如下所示:

SELECT * FROM things WHERE color = ?;

这个简单的查询在驱动程序下工作的非常好。但是,如果颜色是可选的,还有另一个名为 is_heavy 的可选字段。现在,我们需要支持此查询的一些不同排列:

SELECT * FROM things; -- Neither
SELECT * FROM things WHERE color = ?; -- 仅 Color
SELECT * FROM things WHERE is_heavy = ?; -- 仅 Is Heavy
SELECT * FROM things WHERE color = ? AND is_heavy = ?; -- 两者

但是,由于上章节提到的种种原因,功能齐全的 ORM 并不是我们想要的工具。

在这些情况下,查询构造器最终成为一个非常不错的工具。Knex开放的接口非常接近基础 SQL 查询,以至于我们最终还是能大概知道 SQL 语句是怎样的。
这种关系类似于 TypeScript 转换为 JavaScript 的方式。

只要你完全理解生成的基础 SQL,使用查询构造器是一个很好的解决方案。切勿使用它作为隐藏底层的工具,而是用于方便起见并且在你确切了解它在做什么的情况下。如果对生成的 SQL 语句有疑问,可以在用 Knex() 实例化时添加调试字段。像这样:

const knex = require('knex')({
  client: 'pg',
  connection,
  debug: true // Enable Query Debugging
});

实际上,本文中提到的大多数库都提供有方法用于调试正在执行的调用。


我们研究了与数据库交互的三个不同的抽象层,即底层数据库驱动程序,中层查询构造器和上层ORM。我们还研究了使用每一层的利弊以及生成的 SQL 语句。包括使用数据库驱动程序生成动态查询会很困难,但 ORM 会使复杂性增加,最后得出结论:使用查询构造器是最佳选择。

感谢您的阅读,在构建下一个项目时一定要考虑到这一点。


完成之后,您可以运行以下命令以完全删除 docker 容器并从计算机中删除数据库文件:

docker stop pg-node-orms
docker rm pg-node-orms
sudo rm -rf ~/data/pg-node-orms

正文完
 0