Магазин на JavaScript, часть 1 из 19. Серверное приложение, база данных, ORM Sequelize

10.11.2021

Теги: BackendFrontendJavaScriptORMReact.jsWeb-разработкаБазаДанныхИнтернетМагазинКаталогТоваровКорзинаФреймворк

Простой интернет-магазин на Node.js (сервер) и React.js (клиент). Данные будем хранить в базе данных PostgreSQL. Для серверной части используем фреймворк Express.js. Приложение не имеет практической ценности, сделано с целью изучения.

Простой сервер

Создаем директорию проекта shop, внутри нее — еще две директории, server и client. Переходим в директорию shop/server, создаем проект:

> npm init -y

Устанавливаем зависимости, которые нам потребуются в работе:

> npm install express pg pg-hstore sequelize cors dotenv

Чтобы отслеживать изменения в коде и перезапускать сервер, установим как dev-зависимость пакет nodemon:

> npm install nodemon --save-dev

Внесем изменения в файл package.json — добавим в него команду запуска сервера через nodemon и type:module (чтобы использовать import вместо require).

{
  "name": "shop-server",
  "version": "1.0.0",
  "description": "Интернет магазин, сервер",
  "main": "index.js",
  "type": "module",
  "scripts": {
    "start": "node index.js",
    "start-dev": "nodemon index.js"
  },
  "keywords": [
    "Backend",
    "JavaScript",
    "Node.js",
    "Express.js"
  ],
  "author": "Евгений Токмаков",
  "license": "ISC",
  "devDependencies": {
    "nodemon": "^2.0.15"
  },
  "dependencies": {
    "cors": "^2.8.5",
    "dotenv": "^10.0.0",
    "express": "^4.17.1",
    "pg": "^8.7.1",
    "pg-hstore": "^2.3.4",
    "sequelize": "^6.9.0"
  }
}

Создаем файл index.js, добавляем в него следующий код:

import express from 'express'

const PORT = 7000

const app = express()

app.listen(PORT, () => console.log('Сервер запущен на порту', PORT))

Запускаем сервер в режиме разработки через nodemon:

> npm run start-dev
[nodemon] 2.0.15
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `node index.js`
Сервер запущен на порту 7000

Вся конфигурация у нас будет в переменных окружения, создаем файл .env:

PORT=7000

И будем получать номер порта в index.js следующим образом:

import config from 'dotenv/config'
import express from 'express'

const PORT = process.env.PORT || 5000

const app = express()

app.listen(PORT, () => console.log('Сервер запущен на порту', PORT))

База данных

Идем на сайт postgresql.org, в раздел Downloads — и скачиваем последнюю версию. Установка там простая, так что не будем на этом останавливаться. Вместе с базой данных будет установлен клиент pgAdmin для работы с сервером БД — запускаем его и создаем БД online_store.

Будем использовать ORM-библиотеку Sequelize, которая осуществляет сопоставление таблиц в БД и отношений между ними с классами моделей. Модели мы создадим чуть позже, а пока создаем файл sequelize.js, где укажем настройки подключения к серверу базы данных, сами настройки получим из файла .env.

import {Sequelize} from 'sequelize'

export default new Sequelize(
    process.env.DB_NAME, // база данных
    process.env.DB_USER, // пользователь
    process.env.DB_PASS, // пароль
    {
        dialect: 'postgres',
        host: process.env.DB_HOST,
        port: process.env.DB_PORT
    }
)
PORT=7000
DB_HOST=localhost
DB_NAME=online_store
DB_USER=postgres
DB_PASS=qwerty
DB_PORT=5432

Вносим изменения в index.js, чтобы перед запуском сервера установить соединение с базой данных:

import config from 'dotenv/config'
import express from 'express'
import sequelize from './sequelize.js'

const PORT = process.env.PORT || 5000

const app = express()

const start = async () => {
    try {
        await sequelize.authenticate()
        await sequelize.sync()
        app.listen(PORT, () => console.log('Сервер запущен на порту', PORT))
    } catch(e) {
        console.log(e)
    }
}

start()

Метод sync() синхронизирует структуру базы данных с определением моделей. Например, если для какой-то модели отсутствует соответствующая таблица в БД, то эта таблица создается.

ORM (Object-relational mapping)

Создаем директорию models, внутри нее — файл mapping.js, добавляем в него следующий код:

import sequelize from '../sequelize.js'
import database from 'sequelize'

const { DataTypes } = database

/*
 * Описание моделей
 */

// модель «Пользователь», таблица БД «users»
const User = sequelize.define('user', {
    id: {type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true},
    email: {type: DataTypes.STRING, unique: true},
    password: {type: DataTypes.STRING},
    role: {type: DataTypes.STRING, defaultValue: 'USER'},
})

// модель «Корзина», таблица БД «baskets»
const Basket = sequelize.define('basket', {
    id: {type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true},
})

// связь между корзиной и товаром через промежуточную таблицу «basket_products»
// у этой таблицы будет составной первичный ключ (basket_id + product_id)
const BasketProduct = sequelize.define('basket_product', {
    quantity: {type: DataTypes.INTEGER, defaultValue: 1},
})

// модель «Товар», таблица БД «products»
const Product = sequelize.define('product', {
    id: {type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true},
    name: {type: DataTypes.STRING, unique: true, allowNull: false},
    price: {type: DataTypes.INTEGER, allowNull: false},
    rating: {type: DataTypes.INTEGER, defaultValue: 0},
    image: {type: DataTypes.STRING, allowNull: false},
})

// модель «Категория», таблица БД «categories»
const Category = sequelize.define('category', {
    id: {type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true},
    name: {type: DataTypes.STRING, unique: true, allowNull: false},
})

// модель «Бренд», таблица БД «brands»
const Brand = sequelize.define('brand', {
    id: {type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true},
    name: {type: DataTypes.STRING, unique: true, allowNull: false},
})

// связь между товаром и пользователем через промежуточную таблицу «rating»
// у этой таблицы будет составной первичный ключ (product_id + user_id)
const Rating = sequelize.define('rating', {
    rate: {type: DataTypes.INTEGER, allowNull: false},
})

// свойства товара, у одного товара может быть много свойств
const ProductProp = sequelize.define('product_prop', {
    id: {type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true},
    name: {type: DataTypes.STRING, allowNull: false},
    value: {type: DataTypes.STRING, allowNull: false},
})

/*
 * Описание связей
 */

// связь many-to-many товаров и корзин через промежуточную таблицу basket_products;
// товар может быть в нескольких корзинах, в корзине может быть несколько товаров
Basket.belongsToMany(Product, { through: BasketProduct, onDelete: 'CASCADE' })
Product.belongsToMany(Basket, { through: BasketProduct, onDelete: 'CASCADE' })

// super many-to-many https://sequelize.org/master/manual/advanced-many-to-many.html
// это обеспечит возможность любых include при запросах findAll, findOne, findByPk
Basket.hasMany(BasketProduct)
BasketProduct.belongsTo(Basket)
Product.hasMany(BasketProduct)
BasketProduct.belongsTo(Product)

// связь категории с товарами: в категории может быть несколько товаров, но
// каждый товар может принадлежать только одной категории
Category.hasMany(Product, {onDelete: 'RESTRICT'})
Product.belongsTo(Category)

// связь бренда с товарами: у бренда может быть много товаров, но каждый товар
// может принадлежать только одному бренду
Brand.hasMany(Product, {onDelete: 'RESTRICT'})
Product.belongsTo(Brand)

// связь many-to-many товаров и пользователей через промежуточную таблицу rating;
// за один товар могут проголосовать несколько зарегистрированных пользователей,
// один пользователь может проголосовать за несколько товаров
Product.belongsToMany(User, {through: Rating, onDelete: 'CASCADE'})
User.belongsToMany(Product, {through: Rating, onDelete: 'CASCADE'})

// super many-to-many https://sequelize.org/master/manual/advanced-many-to-many.html
// это обеспечит возможность любых include при запросах findAll, findOne, findByPk
Product.hasMany(Rating)
Rating.belongsTo(Product)
User.hasMany(Rating)
Rating.belongsTo(User)

// связь товара с его свойствами: у товара может быть несколько свойств, но
// каждое свойство связано только с одним товаром
Product.hasMany(ProductProp, {as: 'props', onDelete: 'CASCADE'})
ProductProp.belongsTo(Product)

export {
    User,
    Basket,
    Product,
    Category,
    Brand,
    Rating,
    BasketProduct,
    ProductProp,
    Order,
    OrderItem
}

В index.js импортируем модели, чтобы при вызове метода sync() были созданы все таблицы:

/* .......... */
import * as mapping from './models/mapping.js';
/* .......... */
-- Дамп структуры для таблицы public.baskets
CREATE TABLE IF NOT EXISTS "baskets" (
    "id" INTEGER NOT NULL DEFAULT 'nextval(...)',
    "created_at" TIMESTAMPTZ NOT NULL,
    "updated_at" TIMESTAMPTZ NOT NULL,
    PRIMARY KEY ("id")
)

-- Дамп структуры для таблицы public.basket_products
CREATE TABLE IF NOT EXISTS "basket_products" (
    "quantity" INTEGER NULL DEFAULT '1',
    "created_at" TIMESTAMPTZ NOT NULL,
    "updated_at" TIMESTAMPTZ NOT NULL,
    "basket_id" INTEGER NOT NULL,
    "product_id" INTEGER NOT NULL,
    PRIMARY KEY ("basket_id", "product_id"),
    CONSTRAINT "basket_products_basket_id_fkey" FOREIGN KEY ("basket_id")
    REFERENCES "public"."baskets" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
    CONSTRAINT "basket_products_product_id_fkey" FOREIGN KEY ("product_id")
    REFERENCES "public"."products" ("id") ON UPDATE CASCADE ON DELETE CASCADE
);

-- Дамп структуры для таблицы public.brands
CREATE TABLE IF NOT EXISTS "brands" (
    "id" INTEGER NOT NULL DEFAULT 'nextval(...)',
    "name" VARCHAR(255) NOT NULL,
    "created_at" TIMESTAMPTZ NOT NULL,
    "updated_at" TIMESTAMPTZ NOT NULL,
    PRIMARY KEY ("id"),
    UNIQUE INDEX "brands_name_key" ("name")
);

-- Дамп структуры для таблицы public.categories
CREATE TABLE IF NOT EXISTS "categories" (
    "id" INTEGER NOT NULL DEFAULT 'nextval(...)',
    "name" VARCHAR(255) NOT NULL,
    "created_at" TIMESTAMPTZ NOT NULL,
    "updated_at" TIMESTAMPTZ NOT NULL,
    PRIMARY KEY ("id"),
    UNIQUE INDEX "categories_name_key" ("name")
);

-- Дамп структуры для таблицы public.products
CREATE TABLE IF NOT EXISTS "products" (
    "id" INTEGER NOT NULL DEFAULT 'nextval(...)',
    "name" VARCHAR(255) NOT NULL,
    "price" INTEGER NOT NULL,
    "rating" INTEGER NULL DEFAULT '0',
    "image" VARCHAR(255) NOT NULL,
    "created_at" TIMESTAMPTZ NOT NULL,
    "updated_at" TIMESTAMPTZ NOT NULL,
    "category_id" INTEGER NULL DEFAULT NULL,
    "brand_id" INTEGER NULL DEFAULT NULL,
    PRIMARY KEY ("id"),
    UNIQUE INDEX "products_name_key" ("name"),
    CONSTRAINT "products_brand_id_fkey" FOREIGN KEY ("brand_id")
    REFERENCES "public"."brands" ("id") ON UPDATE CASCADE ON DELETE RESTRICT,
    CONSTRAINT "products_category_id_fkey" FOREIGN KEY ("category_id")
    REFERENCES "public"."categories" ("id") ON UPDATE CASCADE ON DELETE RESTRICT
);

-- Дамп структуры для таблицы public.product_props
CREATE TABLE IF NOT EXISTS "product_props" (
    "id" INTEGER NOT NULL DEFAULT 'nextval(...)',
    "name" VARCHAR(255) NOT NULL,
    "value" VARCHAR(255) NOT NULL,
    "created_at" TIMESTAMPTZ NOT NULL,
    "updated_at" TIMESTAMPTZ NOT NULL,
    "product_id" INTEGER NULL DEFAULT NULL,
    PRIMARY KEY ("id"),
    CONSTRAINT "product_props_product_id_fkey" FOREIGN KEY ("product_id"
    REFERENCES "public"."products" ("id") ON UPDATE CASCADE ON DELETE CASCADE
);

-- Дамп структуры для таблицы public.ratings
CREATE TABLE IF NOT EXISTS "ratings" (
    "rate" INTEGER NOT NULL,
    "created_at" TIMESTAMPTZ NOT NULL,
    "updated_at" TIMESTAMPTZ NOT NULL,
    "product_id" INTEGER NOT NULL,
    "user_id" INTEGER NOT NULL,
    PRIMARY KEY ("product_id", "user_id"),
    CONSTRAINT "ratings_product_id_fkey" FOREIGN KEY ("product_id")
    REFERENCES "public"."products" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
    CONSTRAINT "ratings_user_id_fkey" FOREIGN KEY ("user_id")
    REFERENCES "public"."users" ("id") ON UPDATE CASCADE ON DELETE CASCADE
);

-- Дамп структуры для таблицы public.users
CREATE TABLE IF NOT EXISTS "users" (
    "id" INTEGER NOT NULL DEFAULT 'nextval(...)',
    "email" VARCHAR(255) NULL DEFAULT NULL,
    "password" VARCHAR(255) NULL DEFAULT NULL,
    "role" VARCHAR(255) NULL DEFAULT 'USER',
    "created_at" TIMESTAMPTZ NOT NULL,
    "updated_at" TIMESTAMPTZ NOT NULL,
    PRIMARY KEY ("id"),
    UNIQUE INDEX "users_email_key" ("email")
);

Кроме описанных нами полей у каждой таблицы будут созданы поля createdAt и updatedAt типа datetime — это время создания и последнего обновления строки в таблице. Если эти поля не нужны — редактируем файл sequelize.js:

import {Sequelize} from 'sequelize'

export default new Sequelize(
    process.env.DB_NAME, // база данных
    process.env.DB_USER, // пользователь
    process.env.DB_PASS, // пароль
    {
        dialect: 'postgres',
        host: process.env.DB_HOST,
        port: process.env.DB_PORT,
        define: {
            underscored: true, // использовать snake_case вместо camelCase для полей таблиц БД
            timestamps: false, // не добавлять поля created_at и updated_at при создании таблиц
        }
    }
)

Поиск: JavaScript • ORM • React.js • Web-разработка • Frontend • Backend • База данных • Интернет магазин • Каталог товаров • Корзина • Фреймворк

Каталог оборудования
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Производители
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Функциональные группы
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.