爲何你須要避免使用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
相關文章
相關標籤/搜索