• בלוג
  • גישה לבסיסי נתונים יחסיים באמצעות Sequelize

גישה לבסיסי נתונים יחסיים באמצעות Sequelize

19/07/2019

בסיס נתונים מונגו לא מתאים לכל אחד: לפעמים יש מערכות קיימות שכבר משתמשות ב SQL, או שהמתכנתים בצוות מכירים כבר SQL ומעדיפים להישאר עם מה שעובד להם. הפריימוורק הפופולרי לעבודה עם SQL ב Node.JS נקרא Sequelize ועליו נלמד בפרקים הקרובים.

1. מה זה ORM? ולמה להשתמש באחד?

לפני שאוכל להתחיל לתאר את Sequelize חשוב להתעכב על הפער בין בסיס נתונים מבוסס מסמכים (כמו מונגו) לבסיס נתונים טבלאי (או יחסי) שמשתמש ב SQL. מונגו שימש אותנו כמו מחסן של אוביקטים, ובסופו של דבר לקחנו אוביקטים ש JavaScript יודע לעבוד איתם, זרקנו אותם למחסן ואחרי זה שלפנו אותם לפי מאפיינים מסוימים שחיפשנו.

בעבודה עם בסיס נתונים טבלאי המידע שלנו מפוצל בין כמה טבלאות שונות ובשביל לשלוף אותו נשתמש הרבה פעמים בפקודת JOIN שמחברת מידע ממספר טבלאות. שיטת העבודה של כתיבת שאילתות SQL, קבלת המידע וחיבורו בין מספר טבלאות היא שונה מאוד משיטת החשיבה מונחית העצמים אליה אנו רגילים בעבודה עם שפת תכנות.

את הפער הזה מתכנתים ממלאים עם ספריות שנקראות ORM או Object Relational Mapping. ספריה כזו ממפה בין טבלא בבסיס הנתונים למחלקה (class) במערכת, כך שבמקום לעבוד עם מידע גולמי שמגיע מהטבלא אנחנו נשתמש בתוכנית באוביקטים ונקרא למתודות שלהם, כמו שאנחנו יודעים לעשות בתכנות מונחה עצמים.

ב Node.JS ספריית Sequelize היא ספריית ORM. היא מספקת תחביר באמצעותו אנחנו מסתכלים על הטבלאות שלנו כמו מחלקות ועל בסיס הנתונים עצמו כמו קוד. הספריה תספק לנו את הכלים והתשתית כדי שנוכל:

  1. לקרוא, לעדכן, ליצור ולמחוק מידע מבסיס הנתונים בלי לכתוב קוד SQL אלא רק דרך פונקציות של ספריית Sequelize.

  2. לכתוב Reusable Code שייצג את הפעולות שאנחנו רוצים לעשות שוב ושוב על המידע ולהשתמש בו מכמה מקומות בתוכנית.

  3. להגדיר אילוצים (Constraints) ושאילתות מותאמות מראש בהן נוכל להשתמש לאורך התוכנית שוב ושוב.

  4. להתיחס למבנה בסיס הנתונים עצמו כמו קוד באמצעות מנגנון של Migrations, וכך לחזור אחורה למצב ישן יותר של בסיס הנתונים.

ספריות ORM הן ענקיות ובכוונה. הספריה צריכה לתמוך בהרבה מאוד מצבים ובהרבה מאוד התנהגויות. אנחנו נלמד כאן את הדברים החשובים כדי ליצור ארכיטקטורה טובה לפרויקט שמשתמש בבסיס נתונים מבוסס SQL.

2. התקנה ויצירת פרויקט ראשון

ספריית Sequelize כוללת בנוסף לספריה עצמה גם כלי שורת פקודה שיעזרו לכם ליצור פרויקט חדש כמו גם סטארטרים לקבצים בפרויקט קיים. אנחנו נתקין את כלי שורת הפקודה בהתקנה גלובאלית, ואת הספריה עצמה בהתקנה מקומית לתיקיית הפרויקט. בנוסף לספריה אנחנו צריכים להתקין גם דרייבר לבסיס הנתונים הטבלאי איתו נרצה לעבוד. לשם הפשטות אבחר לעבוד כאן עם בסיס נתונים שנקרא SQLite. בסיס נתונים זה לא דורש התקנה מיוחדת על המחשב ונשמר כולו בקובץ בודד.

תחילה נתקין את כלי שורת הפקודה בהתקנה גלובלית עם הפקודה:

$ npm install -g sequelize-cli

לאחר מכן ניצור תיקיה חדשה עבור הפרויקט שלנו - אקרא לה sequelize-intro:

$ mkdir sequelize-intro
$ cd sequelize-intro

בתוך התיקיה נתקין את Sequelize ואת הדרייבר של SQLite:

$ npm install --save sequelize sqlite3

וניצור שלד לפרויקט עם:

$ sequelize init

הנה עץ התיקיות שנוצר (בלי node_modules כמובן):

.
├── config
│   └── config.json
├── migrations
├── models
│   └── index.js
├── package-lock.json
└── seeders

3. עדכון הגדרות הפרויקט להשתמש ב SQLite בפיתוח

הקובץ היחיד שבינתיים מעניין אותי בפרויקט החדש הוא הקובץ config.json בתוך תיקיית config. קובץ זה כולל את הגדרות בסיס הנתונים. זה הקובץ שנוצר באופן דיפולטי אצלי:

{
  "development": {
    "username": "root",
    "password": null,
    "database": "database_development",
    "host": "127.0.0.1",
    "dialect": "mysql",
    "operatorsAliases": false
  },
  "test": {
    "username": "root",
    "password": null,
    "database": "database_test",
    "host": "127.0.0.1",
    "dialect": "mysql",
    "operatorsAliases": false
  },
  "production": {
    "username": "root",
    "password": null,
    "database": "database_production",
    "host": "127.0.0.1",
    "dialect": "mysql",
    "operatorsAliases": false
  }
}

הקובץ מגדיר אוביקט ובו שלושה מפתחות: מפתח לכל סביבה. הסביבה נקבעת לפי משתנה הסביבה NODE_ENV כך שעל מכונת הפיתוח שלכם היא תוגדר להיות development, במצב בדיקות בדרך כלל נגדיר את הסביבה ל test וכמובן על השרת במצב ייצור הסביבה תוגדר להיות production.

החלוקה לסביבות מאפשרת להגדיר בסיסי נתונים שונים עם פרטי התחברות שונים לבסיסי הנתונים עבור הסביבות השונות. הבעיה שבדרך כלל נרצה לחשב בצורה דינמית חלק מהערכים, במיוחד במצב ייצור שם נרצה לקחת את סיסמת ההתחברות ממשתנה סביבה ולא לשמור אותו בקובץ (כדי שלא בטעות ייכנס ל Source Control).

לכן אנחנו נמחק את הקובץ config/config.json ובמקומו ניצור קובץ בשם config/config.js עם התוכן הבא:

const path = require('path');

module.exports = {
  development: {
    storage: path.resolve(__dirname, '..', 'data', 'development.db'),
    dialect: 'sqlite',
  },
  test: {
    storage: path.resolve(__dirname, '..', 'data', 'test.db'),
    dialect: 'sqlite',
  },
  production: {
    storage: path.resolve(__dirname, '..', 'data', 'production.db'),
    dialect: 'sqlite',
    logging: false,
  },
};

לאחר מכן ניצור תיקיה בשם data בתוך תיקיית הפרויקט בה יישמרו כל בסיסי הנתונים שלנו:

$ mkdir data

4. נוודא שהכל עובד

הפעילו את הפקודה הבאה כדי לוודא שהכל עובד ולאתחל את בסיס הנתונים של הבדיקות:

$ sequelize db:migrate

אם הכל עבד כמו שצריך תמצאו קובץ חדש בשם development.db בתוך תיקיית data.

5. יצירת מודל ספרים

בעבודה עם Sequelize כל טבלא בבסיס הנתונים ממופה למחלקה במערכת דרכה אנחנו עובדים עם המידע שבטבלא, ובנוסף כוללת קובץ קוד שיוצר או מוחק את הטבלא. קובץ הקוד נקרא מיגרציה ואוסף כל המיגרציות של היישום יאפשר לנו "לטייל" בבסיס הנתונים למצב ישן יותר, בדיוק כמו שכלי ניהול גירסאות מאפשר לנו לטייל בקוד למצב ישן יותר.

בשביל הדוגמא ניצור טבלא של ספרים כאשר לכל ספר נרצה לשמור את שם הספר, שם המחבר, מחיר ומספר העמודים בספר. ניצור את קובץ המודל וקובץ המיגרציה בצורה אוטומטית עם הפקודה:

$ sequelize model:generate --name Book --attributes title:string,author:string,price:integer,pages:integer

Sequelize CLI [Node: 10.15.3, CLI: 5.5.0, ORM: 5.10.1]

New model was created at /Users/ynonperek/work/courses/nodejs/sequelize/sequelize-intro/models/book.js .
New migration was created at /Users/ynonperek/work/courses/nodejs/sequelize/sequelize-intro/migrations/20190715092149-Book.js .

בתגובה Sequelize יצר שני קבצים: קובץ המודל שמתאר את הטבלא, וקובץ המיגרציה שמתאר איך יוצרים או מוחקים את הטבלא. נתבונן תחילה בקובץ המודל:

'use strict';
module.exports = (sequelize, DataTypes) => {
  const Book = sequelize.define('Book', {
    title: DataTypes.STRING,
    author: DataTypes.STRING,
    price: DataTypes.INTEGER,
    pages: DataTypes.INTEGER
  }, {});
  Book.associate = function(models) {
    // associations can be defined here
  };
  return Book;
};

הקובץ מגדיר מחלקה באמצעות הפקודה sequelize.define. שם המחלקה עובר בתור פרמטר ראשון והפרמטר השני הוא אוביקט שמתאר את העמודות בטבלא, שיהפכו למאפיינים שנוכל לגשת אליהם דרך אוביקטי Book שניצור.

הערך DataTypes.STRING הגיע מהספריה Sequelize. בתיעוד שלהם אפשר למצוא את רשימת כל המאפיינים האפשריים, וכאן אני מדביק בשבילנו רשימה חלקית של סוגי המידע המרכזיים:

Sequelize.STRING                      // VARCHAR(255)
Sequelize.STRING(1234)                // VARCHAR(1234)
Sequelize.STRING.BINARY               // VARCHAR BINARY
Sequelize.TEXT                        // TEXT

Sequelize.INTEGER                     // INTEGER
Sequelize.BIGINT                      // BIGINT

Sequelize.FLOAT                       // FLOAT
Sequelize.DOUBLE                      // DOUBLE
Sequelize.DECIMAL                     // DECIMAL
Sequelize.DATE                        // DATETIME for mysql / sqlite, TIMESTAMP WITH TIME ZONE for postgres
Sequelize.BOOLEAN                     // TINYINT(1)

Sequelize.ENUM('value 1', 'value 2')  // An ENUM with allowed values 'value 1' and 'value 2'
Sequelize.ARRAY(Sequelize.TEXT)       // Defines an array. PostgreSQL only.
Sequelize.ARRAY(Sequelize.ENUM)       // Defines an array of ENUM. PostgreSQL only.

Sequelize.JSON                        // JSON column. PostgreSQL, SQLite and MySQL only.
Sequelize.JSONB                       // JSONB column. PostgreSQL only.

Sequelize.BLOB                        // BLOB (bytea for PostgreSQL)
Sequelize.BLOB('tiny')                // TINYBLOB (bytea for PostgreSQL. Other options are medium and long)

Sequelize.UUID                        // UUID datatype for PostgreSQL and SQLite, CHAR(36) BINARY for MySQL (use defaultValue: Sequelize.UUIDV1 or Sequelize.UUIDV4 to make sequelize generate the ids automatically)

הקובץ השני הוא קובץ המיגרציה. קובץ זה מגדיר מה צריך לעשות בשביל ליצור או למחוק את הטבלא מבסיס הנתונים. ככה הוא נראה אצלי:

'use strict';
module.exports = {
  up: (queryInterface, Sequelize) => {
    return queryInterface.createTable('Books', {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.INTEGER
      },
      title: {
        type: Sequelize.STRING
      },
      author: {
        type: Sequelize.STRING
      },
      price: {
        type: Sequelize.INTEGER
      },
      pages: {
        type: Sequelize.INTEGER
      },
      createdAt: {
        allowNull: false,
        type: Sequelize.DATE
      },
      updatedAt: {
        allowNull: false,
        type: Sequelize.DATE
      }
    });
  },
  down: (queryInterface, Sequelize) => {
    return queryInterface.dropTable('Books');
  }
};

הקובץ מגדיר אוביקט שמחזיק שני מפתחות: המפתח up מחזיק פונקציה שיוצרת את הטבלא והמפתח down מחזיק פונקציה שמוחקת את הטבלא. כדאי לחשוב על מיגרציות כמו נקודות ציון במסלול ואתם יכולים ללכת קדימה ואחורה במסלול לפי נקודות הציון האלה. כל הליכה קדימה תפעיל את הפונקציה up והליכה אחורה את הפונקציה down.

האוביקט queryInterface שעובר למיגרציות מאפשר ביצוע שינויים בבסיס הנתונים. אפשר למצוא את כל הדברים שהוא יודע לעשות בתיעוד בקישור כאן:

http://docs.sequelizejs.com/class/lib/query-interface.js~QueryInterface.html

הפונקציה dropTable שלו מקבלת שם של טבלא ומוחקת אותה, ולכן פונקציית down שלנו יחסית פשוטה להבנה: היא בסך הכל מוחקת את הטבלא Books.

הפונקציה creaetTable קצת יותר מורכבת כי בנוסף לשם הטבלא היא מקבלת גם אוביקט שמתאר את השדות בטבלא. באופן אוטומטי סקוולייז לקח את השדות משורת הפקודה כשיצרנו את המודל, ואנחנו מקבלים עכשיו הזדמנות לשנות חלק מהערכים או להוסיף מאפיינים יותר ספציפיים לחלק מהשדות. נוסיף בשביל הדוגמא ערך ברירת מחדל לשדה המחיר כך שספר חדש שלא הגדרנו עבורו מחיר יעלה 0. בנוסף נגדיר שמספר העמודים הוא שדה הכרחי באמצעות העברת המפתח allowNull עם הערך false בשדה זה. אחרי השינויים הקובץ נראה כך (השינויים מסומנים בכוכבית):

'use strict';
module.exports = {
  up: (queryInterface, Sequelize) => {
    return queryInterface.createTable('Books', {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.INTEGER
      },
      title: {
        type: Sequelize.STRING
      },
      author: {
        type: Sequelize.STRING
      },
      price: {
        type: Sequelize.INTEGER,
        defaultValue: 0, /* Set default value for price */
      },
      pages: {
        type: Sequelize.INTEGER,
        allowNull: false, /* pages is mandatory */
      },
      createdAt: {
        allowNull: false,
        type: Sequelize.DATE
      },
      updatedAt: {
        allowNull: false,
        type: Sequelize.DATE
      }
    });
  },
  down: (queryInterface, Sequelize) => {
    return queryInterface.dropTable('Books');
  }
};

6. הפעלת המיגרציה לצורך יצירת הטבלא

באופן אוטומטי סיקוולייז שומר את כל המיגרציות שהוא הפעיל בתוך טבלא מיוחדת בבסיס הנתונים שנקראת SequelizeMeta. כרגע הטבלא ריקה: יצרנו קובץ מיגרציה חדש אבל עדיין לא הפעלנו אותו. נפעיל את המיגרציה כדי ליצור את הטבלא Books בבסיס הנתונים וכדי לעדכן את הטבלא SequelizeMeta:

$ sequelize db:migrate

Sequelize CLI [Node: 10.15.3, CLI: 5.5.0, ORM: 5.10.1]

Loaded configuration file "config/config.js".
Using environment "development".
== 20190715092149-create-book: migrating =======
== 20190715092149-create-book: migrated (0.015s)

סיקוולייז מראה לי איזה מיגרציות הוא הפעיל. שם הקובץ נשמר בבסיס הנתונים ולכן עכשיו אם אנסה להפעיל שוב את הפקודה db:migrate התוצאה תהיה שונה:

$ sequelize db:migrate

Sequelize CLI [Node: 10.15.3, CLI: 5.5.0, ORM: 5.10.1]

Loaded configuration file "config/config.js".
Using environment "development".
No migrations were executed, database schema was already up to date.

בגלל שמיגרציה היא קובץ הוראות אנחנו יכולים לבטל את הפעלת המיגרציה האחרונה, מה שיוביל להפעלת הפונקציה down שלה ולמחיקה שלה מרשימת המיגרציות שפעלו על בסיס הנתונים:

$ sequelize db:migrate:undo

Sequelize CLI [Node: 10.15.3, CLI: 5.5.0, ORM: 5.10.1]

Loaded configuration file "config/config.js".
Using environment "development".
== 20190715092149-create-book: reverting =======
== 20190715092149-create-book: reverted (0.012s)

עכשיו אני יכול לתקן טעויות שעשיתי בקובץ המיגרציה או לבדוק איך היה נראה העולם לפני הרצתה ובסוף להריץ אותה מחדש עם:

$ sequelize db:migrate

Sequelize CLI [Node: 10.15.3, CLI: 5.5.0, ORM: 5.10.1]

Loaded configuration file "config/config.js".
Using environment "development".
== 20190715092149-create-book: migrating =======
== 20190715092149-create-book: migrated (0.018s)

7. כתיבת תוכנית ראשונה לעבודה עם הטבלא

עכשיו שיש לנו את המודל והטבלא נוכל לראות איך להשתמש במודל כדי לכתוב מידע, לקרוא מידע, לשנות מידע וגם למחוק מידע מהטבלא. בשביל זה אכתוב תוכנית שניתן להפעיל משורת הפקודה שתספק לי ממשק ל-4 הפעולות:

  1. נוכל להפעיל את התוכנית עם פרמטר create ואז מאפייני הספר יישמרו לבסיס הנתונים.

  2. נוכל להפעיל את התוכנית עם פרמטר show ואז היא תדפיס על המסך את מאפייני כל הספרים ששמורים בבסיס הנתונים.

  3. נוכל להפעיל את התוכנית עם פרמטר rename שתקבל מזהה ספר ושם חדש ותשמור את שם הספר החדש.

  4. נוכל להפעיל את התוכנית עם פרמטר delete שתקבל מזהה ספר ותמחק אותו מבסיס הנתונים.

הפונקציה הראשונה לכן תקרא createBook ותפקידה ליצור ספר חדש לפי פרמטרים שקיבלה:

const db = require('./models');

async function createBook(title, author, pages, price) {
  return await db.Book.create(
    { title, author, pages, price }
  );
}

השורה הראשונה טוענת את הקובץ models/index.js. קובץ זה כולל קוד שטוען באופן אוטומטי את כל המודלים ומחזיר אותם בתור שדות של האוביקט db. מכאן אנחנו יכולים להשתמש ב db.Book כדי לדבר על המודל שמתאים לטבלת הספרים שלנו.

הפונקציה createBook לכן לוקחת את הפרמטרים ופשוט מפעילה פונקציה בשם create שקיבלנו מספריית סיקוולייז. הפונקציה תוסיף שורה לטבלת הספרים ומחזירה Promise שמתממשת כשהשורה מתווספת בהצלחה (או נדחית אם היתה תקלה בהוספה).

הפונקציה השניה renameBook משנה שם של ספר:

async function renameBook(id, newTitle) {
  const book = await db.Book.findByPk(id);
  book.title = newTitle;
  await book.save();
}

הפונקציה מקבלת מזהה id של ספר ושם חדש לספר. היא מוצאת בבסיס הנתונים את נתוני הספר, מעדכנת את השם ושומרת את הגירסא החדשה.

שימו לב שהפונקציה db.Book.findByPk מחזירה הבטחה ולכן אני יכול להשתמש ב await לפניה כדי לקבל את הערך שהבטחה זו מחזירה בצורה אסינכרונית. בלי ה await הערך ב book לא היה ספר אלא Promise שתחזיר ספר.

הפונקציה השלישית שוב משתמשת בפונקציות של סיקוולייז כדי למצוא את כל הספרים ולהציג אותם על המסך:

async function listBooks() {
  const books = await db.Book.findAll();
  for (let book of books) {
    console.log(`[${book.id}] ${book.title} (Author: ${book.author}), ${book.price}$`);
  }
}

וגם כאן אנחנו חייבים את ה await לפני פונקציית השליפה כדי לקבל את הספרים עצמם ולא הבטחה שתחזיר אותם.

והפונקציה הרביעית משתמשת בפונקציה destroy של Sequelize כדי למחוק את הספר שאני רוצה למחוק:

async function deleteBook(id) {
  await db.Book.destroy({ where: { id }})
}

לסיום קוד main שמקבל פרמטרים משורת הפקודה ומפעיל את הפונקציה הרלוונטית לפי הפרמטרים שהתקבלו:

async function main() {
  const cmd = process.argv[2];
  const commands = {
    list: listBooks,
    create: createBook,
    rename: renameBook,
    delete: deleteBook,
  };
  commands[cmd](...process.argv.slice(3))
}

main();

8. תיקון קטן לסיום

ניסיון ראשון להפעיל את התוכנית ייכשל בגלל שהקוד שסיקוולייז יצר באופן אוטומטי בתוך הקובץ models/inex.js מחפש את קובץ ההגדרות config/config.json אבל אנחנו החלפנו את הקובץ לקובץ דינמי עם סיומת js. לכן כדי שהתוכנית תעבוד ניכנס לקובץ models/index.js ובמקום השורה:

const config = require(__dirname + '/../config/config.json')[env];

נכתוב את השורה:

const config = require(__dirname + '/../config/config.js')[env];

אחרי זה הריצו את התוכנית, צרו ספרים והציגו אותם כדי לראות שהכל עובד.

9. איפה ללמוד עוד

רוצים ללמוד עוד על עבודה יעילה עם Node.JS? פיתוח שרתי ווב ו APIs? עבודה עם בסיסי נתונים מסוגים שונים וגם מנגנוני אבטחה ב Node.JS, והכל דרך דוגמאות פרקטיות והסברים מעמיקים? נו, יש לי קורס בשבילכם. קורס Node.JS כאן באתר ילמד אתכם את כל זה ויותר. פרטים והרשמה בעמוד הקורס:

https://www.tocode.co.il/bundles/nodejs