为何你须要避免使用ORM(含Node.js示例)

图片描述

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

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

快跟上!

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

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

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

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命令。npm

最后,你还须要建立一个connection.json文件,以下所示。这个文件稍后将会被Node应用用于链接数据库。编程

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

抽象层

在深刻研究过多代码以前,让咱们先弄清楚一些不一样的抽象层。就像其余全部的计算机科学同样,在咱们增长抽象层时也要进行权衡。在每增长一个抽象层时,咱们都尝试以下降性能为代价,以提升开发人员生产力(尽管并不是老是如此)。json

底层:数据库驱动程序

基本上是咱们所能达到的最低级别,再往下就是手动生成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前缀并将其发送到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,就像您正在使用基础数据库驱动程序同样。经过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
相关文章
相关标签/搜索