在这篇文章里,我们将讨论为什么在项目中不应该使用ORM(对象关系映射)。
虽然本文讨论的概念适用于所有的语言和平台,代码示例还是使用了Javascript编写的Nodejs来说明,并从NPM库中获取包。
首先,我无意diss任何在本文中提到的任何模块。它们的作者都付诸了大量的辛勤劳动。同时,它们被很多应用程序用在生产环境,并且每天都响应大量的请求。我也用ORM部署过应用程序,并不觉得后悔。
快跟上!
ORM 是强大的工具。我们将在本文中研究的ORM能够与SQL后端进行通信,例如SQLite, PostgreSQL, MySQL 和 MSSQL。 本篇示例将会使用PostgreSQL,它是一种强大的SQL服务器。另外还有一些ORM可以和NoSQL通讯,例如由MongoDB支持的Mongoose ORM,这些ORM不在本篇讨论范围之内。
首先,运行下述命令启动一个本地的PostgreSQL实例,该实例将以这种方式被配置:对本地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)
在下面的示例中,我们将研究最受欢迎的ORM,Sequelize。我们还将使用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
前缀并将其发送到PostgreSQL服务器时,花费为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,就像您正在使用基础数据库驱动程序一样。通过Bookshelf 和 Objection,你可以访问在实例化期间提供的原始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
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。