В этом руководстве описывается процесс создания Scheduler с использованием Node.js и REST API на стороне сервера. Если вы работаете с другими технологиями, ознакомьтесь с вариантами интеграции, приведёнными ниже:
В нашем примере Scheduler на Node.js будет взаимодействовать с сервером через REST API. К счастью, для Node.js уже существует несколько готовых решений, поэтому нет необходимости разрабатывать всё с нуля.
В этом руководстве используется фреймворк Express и MySQL для хранения данных.
Полный исходный код доступен на GitHub.
Начните с создания нового приложения с помощью yarn или npm:
$ mkdir scheduler-howto-nodejs
$ cd ./scheduler-howto-nodejs
$ yarn init // или npm init
В процессе инициализации вам потребуется ответить на несколько простых вопросов:
$ question name (scheduler-howto-nodejs):
$ question version (1.0.0):
$ question description: My scheduler backend
$ question entry point (index.js): server.js
$ question repository url:
$ question author: Me
$ question license (MIT): MIT
$ question private:
$ success Saved package.json
В результате будет создан файл package.json, который может выглядеть следующим образом:
{
"name": "scheduler-backend",
"version": "1.0.0",
"main": "server.js",
"author": "Me",
"license": "MIT",
}
Как уже упоминалось, в примере используются Express и MySQL.
Убедитесь, что ваш сервер MySQL настроен, либо воспользуйтесь сервисом, например, Free MySQL Hosting.
Установите express, mysql, body-parser и date-format-lite следующей командой:
$ yarn add express mysql body-parser date-format-lite
или
$ npm install express mysql body-parser date-format-lite
Поскольку в качестве точки входа был выбран server.js, создайте этот файл со следующим содержимым:
server.js
const express = require("express"); // используем Express
const bodyParser = require("body-parser"); // для разбора POST-запросов
const app = express(); // создаём приложение
const port = 3000; // порт для прослушивания
// Необходимо для разбора POST-запросов
// строка ниже используется для разбора application/x-www-form-urlencoded
app.use(bodyParser.urlencoded({extended:true}));
// запуск сервера
app.listen(port, () => {
console.log("Server is running on port " + port + "...");
});
Далее обновите ваш package.json, добавив секцию "scripts":
"scripts": {
"start": "node server.js"
}
После этого ваш package.json должен выглядеть так:
{
"name": "scheduler-howto-node",
"version": "1.0.0",
"main": "server.js",
"license": "MIT",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"body-parser": "^1.20.0",
"date-format-lite": "^17.7.0",
"express": "^4.18.1",
"mysql": "^2.18.1",
}
}
Теперь вы можете запустить сервер командой:
$ yarn start
или
$ npm start
Создайте директорию для хранения файлов HTML, CSS и JS фронтенда:
$ mkdir ./public
В папке public создайте файл index.html со следующим содержимым:
public/index.html
<!doctype html>
<html>
<head>
<title>DHTMLX Sсheduler example</title>
<meta charset="utf-8">
<!-- scheduler -->
<script src="https://cdn.dhtmlx.com/scheduler/edge/dhtmlxscheduler.js"
charset="utf-8"></script>
<link href="https://cdn.dhtmlx.com/scheduler/edge/dhtmlxscheduler.css"
rel="stylesheet" type="text/css" charset="utf-8">
<style> html, body{
margin:0px;
padding:0px;
height:100%;
overflow:hidden;
}
</style>
</head>
<body>
<div id="scheduler_here" class="dhx_cal_container"
style='width:100%; height:100%;'>
<div class="dhx_cal_navline">
<div class="dhx_cal_prev_button"> </div>
<div class="dhx_cal_next_button"> </div>
<div class="dhx_cal_today_button"></div>
<div class="dhx_cal_date"></div>
<div class="dhx_cal_tab" name="day_tab"></div>
<div class="dhx_cal_tab" name="week_tab"></div>
<div class="dhx_cal_tab" name="month_tab"></div>
</div>
<div class="dhx_cal_header"></div>
<div class="dhx_cal_data"></div>
</div>
<script> scheduler.config.load_date="%Y-%m-%d %H:%i";
scheduler.init("scheduler_here", new Date(2022, 0, 20), "week");
scheduler.setLoadMode("day");
// загрузка данных с бэкенда
scheduler.load("/events", "json");
// подключение бэкенда к scheduler
const dp = scheduler.createDataProcessor({
url: "/events",
mode: "REST"
});
</script>
</body>
</html>
Этот код создаёт базовую HTML-разметку, подключает dhtmlxScheduler с CDN и инициализирует scheduler с помощью метода init. Обратите внимание, что и body документа, и контейнер scheduler имеют высоту 100%, чтобы компонент корректно занимал всё доступное пространство.
Чтобы сделать новую страницу доступной, добавьте следующий код в server.js перед строкой "app.listen(...);"
:
server.js
// отдаём статические страницы из директории "./public"
app.use(express.static(__dirname + "/public"));
Перезапустите приложение, чтобы изменения вступили в силу.
Теперь, открыв http://localhost:3000/ в браузере, вы увидите страницу index.html.
После того, как интерфейс scheduler готов, следующим шагом будет подключение к базе данных и определение методов для чтения и записи событий.
Сначала создайте базу данных. Это можно сделать с помощью любого удобного клиента MySQL или через консоль.
Через MySQL-клиент выполните следующий код:
CREATE DATABASE IF NOT EXISTS `scheduler`;
USE `scheduler`;
DROP TABLE IF EXISTS `events`;
CREATE TABLE `events` (
`id` bigint(20) unsigned AUTO_INCREMENT,
`start_date` datetime NOT NULL,
`end_date` datetime NOT NULL,
`text` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARSET=utf8;
Либо сохраните этот SQL в файл dump.sql и импортируйте его через консоль MySQL:
$ mysql -uuser -ppass scheduler < dump.sql
Далее определите настройки подключения к MySQL в server.js как константу для дальнейшего использования:
server.js
// MySQL будет использоваться для доступа к базе данных, util — для promisify запросов
const util = require("util");
const mysql = require('mysql');
// используйте свои параметры для подключения к базе данных
const mysqlConfig = {
"connectionLimit": 10,
"host": "localhost",
"user": "root",
"password": "",
"database": "scheduler"
};
После этого подключитесь к базе данных из вашего приложения следующим образом:
server.js
// открываем соединение с mysql
const connectionPool = mysql.createPool(mysqlConfig);
connectionPool.query = util.promisify(connectionPool.query);
Здесь используется pool соединений и обёртка для запросов в Promises с помощью util.promisify. Это не строго обязательно, но делает код чище и проще для поддержки.
На следующем этапе доступ к базе данных будет инкапсулирован в отдельном классе Storage, который реализует соединение и CRUD-операции.
Вся логика для чтения и записи данных будет организована в модуле Storage
. Этот класс принимает соединение с MySQL и реализует CRUD-операции для заданной таблицы: получение всех событий, добавление новых, обновление существующих и удаление событий.
Создайте файл storage.js и добавьте следующий код:
storage.js
require("date-format-lite"); // добавляем форматирование дат
class Storage {
constructor(connection, table) {
this._db = connection;
this.table = "events";
}
// получение событий из таблицы, поддержка динамической загрузки при наличии параметров
async getAll(params) {
let query = "SELECT * FROM ??";
let queryParams = [
this.table
];
let result = await this._db.query(query, queryParams);
result.forEach((entry) => {
// форматирование даты и времени
entry.start_date = entry.start_date.format("YYYY-MM-DD hh:mm");
entry.end_date = entry.end_date.format("YYYY-MM-DD hh:mm");
});
return result;
}
// создание нового события
async insert(data) {
let result = await this._db.query(
"INSERT INTO ?? (`start_date`, `end_date`, `text`) VALUES (?,?,?)",
[this.table, data.start_date, data.end_date, data.text]);
return {
action: "inserted",
tid: result.insertId
}
}
// обновление события
async update(id, data) {
await this._db.query(
"UPDATE ?? SET `start_date` = ?, `end_date` = ?, `text` = ? WHERE id = ?",
[this.table, data.start_date, data.end_date, data.text, id]);
return {
action: "updated"
}
}
// удаление события
async delete(id) {
await this._db.query(
"DELETE FROM ?? WHERE `id`=? ;",
[this.table, id]);
return {
action: "deleted"
}
}
}
module.exports = Storage;
Далее необходимо настроить маршруты, чтобы scheduler на странице мог обращаться к storage.
Для этого создайте ещё один вспомогательный модуль с именем router
:
router.js
function callMethod (method) {
return async (req, res) => {
let result;
try {
result = await method(req, res);
} catch (e) {
result = {
action: "error",
message: e.message
}
}
res.send(result);
}
};
module.exports = {
setRoutes (app, prefix, storage) {
app.get(`${prefix}`, callMethod((req) => {
return storage.getAll(req.query);
}));
app.post(`${prefix}`, callMethod((req) => {
return storage.insert(req.body);
}));
app.put(`${prefix}/:id`, callMethod((req) => {
return storage.update(req.params.id, req.body);
}));
app.delete(`${prefix}/:id`, callMethod((req) => {
return storage.delete(req.params.id);
}));
}
};
Этот модуль настраивает приложение на прослушивание URL-запросов, которые будет отправлять scheduler, и вызывает соответствующие методы storage.
Имейте в виду, что все методы обёрнуты в блоки try-catch
для перехвата ошибок и возврата корректного ответа об ошибке клиенту. Подробнее о обработке ошибок можно прочитать по ссылке.
Также обратите внимание, что сообщение об ошибке возвращается непосредственно в ответе API. Это удобно во время разработки, однако в production рекомендуется скрывать такие сообщения, чтобы не раскрывать чувствительную информацию, например, детали ошибок MySQL.
Когда все части готовы, вы можете подключить Storage к приложению через Router:
server.js
const router = require("./router");
// открываем соединение с mysql
const connectionPool = mysql.createPool(mysqlConfig);
connectionPool.query = util.promisify(connectionPool.query);
// добавляем обработчики для основных CRUD-запросов
const Storage = require("./storage");
const storage = new Storage(connectionPool);
router.setRoutes(app, "/events", storage);
После перезапуска приложения вы сможете создавать, удалять и изменять события в планировщике, и все изменения будут сохраняться после перезагрузки страницы.
В данный момент планировщик загружает все записи из таблицы events при запуске. Это хорошо работает, если объём данных небольшой. Однако для приложений, связанных с планированием или бронированием, где старые записи не удаляются и не архивируются, объём данных быстро растёт. Через несколько месяцев приложение может загружать по нескольку мегабайт данных при каждом открытии страницы.
Динамическая загрузка помогает избежать этой проблемы. Планировщик добавляет отображаемый диапазон дат в параметры запроса, и сервер возвращает только события, попадающие в этот диапазон. Каждый раз при изменении диапазона дат планировщик запрашивает соответствующий сегмент данных.
Чтобы включить динамическую загрузку на клиенте, используйте опцию setLoadMode с одним из значений: "day", "week" или "month". Обычно хорошо работает "day".
Начните с активации динамической загрузки на клиентской стороне с помощью метода setLoadMode:
public/index.html
scheduler.config.load_date="%Y-%m-%d %H:%i";
scheduler.init("scheduler_here", new Date(2022, 0, 20), "week");
scheduler.setLoadMode("day");
// загружаем данные с сервера
scheduler.load("/events", "json");
Планировщик будет добавлять параметры from
и to
в строку запроса, поэтому вы можете добавить простой оператор WHERE
, чтобы загружать только нужный диапазон дат:
storage.js
async getAll(params) {
let query = "SELECT * FROM ??";
let queryParams = [
this.table
];
if (params.from && params.to) { query += " WHERE `end_date` >= ? AND `start_date` < ?";
queryParams.push(params.from);
queryParams.push(params.to);
}
let result = await this._db.query(query, queryParams);
result.forEach((entry) => {
// форматируем дату и время
entry.start_date = entry.start_date.format("YYYY-MM-DD hh:mm");
entry.end_date = entry.end_date.format("YYYY-MM-DD hh:mm");
});
return result;
}
Для поддержки повторяющихся событий (например, "повторять событие ежедневно") потребуется выполнить несколько дополнительных шагов.
Активируйте расширение повторяющихся событий на странице планировщика:
public/index.html
<!-- расширение повторяющихся задач для планировщика -->
scheduler.plugins({
recurring: true
});
Далее, обновите модель данных, добавив три дополнительных поля:
Вы можете добавить эти столбцы в существующую таблицу events следующими SQL-командами:
ALTER TABLE `events` ADD COLUMN `event_pid` bigint(20) unsigned DEFAULT '0';
ALTER TABLE `events` ADD COLUMN `event_length` bigint(20) unsigned DEFAULT '0';
ALTER TABLE `events` ADD COLUMN `rec_type` varchar(25) DEFAULT '""';
Либо создайте таблицу с нуля:
CREATE TABLE `events` (
`id` bigint(20) unsigned AUTO_INCREMENT,
`start_date` datetime NOT NULL,
`end_date` datetime NOT NULL,
`text` varchar(255) DEFAULT NULL,
`event_pid` bigint(20) unsigned DEFAULT '0',
`event_length` bigint(20) unsigned DEFAULT '0',
`rec_type` varchar(25) DEFAULT '""',
PRIMARY KEY (`id`)
) DEFAULT CHARSET=utf8;
Наконец, обновите методы storage для поддержки повторяющихся событий.
Сначала обновите метод insert
, чтобы добавить новые столбцы в SQL-запрос.
Также обработайте особый случай, когда при удалении одного экземпляра из серии повторяющихся событий необходимо создать новую запись. Клиент вызовет для этого действие insert:
storage.js
// создать новое событие
async insert(data) {
let sql = "INSERT INTO ?? " +
"(`start_date`, `end_date`, `text`, `event_pid`, `event_length`, `rec_type`) " + "VALUES (?, ?, ?, ?, ?, ?)";
const result = await this._db.query(
sql,
[
this.table,
data.start_date,
data.end_date,
data.text,
data.event_pid || 0, //!
data.event_length || 0, //!
data.rec_type //!
]);
// удаление одного экземпляра из серии повторяющихся событий
let action = "inserted"; if (data.rec_type == "none") { action = "deleted"; }
return {
action: action,
tid: result.insertId
};
}
Метод update
требует аналогичных изменений в SQL-запросе.
Кроме того, при изменении серии повторяющихся событий все изменённые экземпляры этой серии должны быть удалены:
storage.js
// обновить событие
async update(id, data) {
if (data.rec_type && data.rec_type != "none") { // все изменённые экземпляры должны быть удалены при обновлении серии повторяющихся событий
// https://docs.dhtmlx.com/scheduler/server_integration.html#recurringevents
await this._db.query(
"DELETE FROM ?? WHERE `event_pid`= ?;",
[this.table, id]);
}
await this._db.query(
"UPDATE ?? SET " +
"`start_date` = ?, `end_date` = ?, `text` = ?, " +
"`event_pid` = ?, `event_length`= ?, `rec_type` = ? "+ "WHERE id = ?",
[
this.table,
data.start_date,
data.end_date,
data.text,
data.event_pid || 0, data.event_length || 0, data.rec_type, id
]);
return {
action: "updated"
};
}
Наконец, обновите метод delete
, чтобы обработать два особых случая:
event_pid
, значит удаляется изменённый экземпляр повторяющейся серии. Вместо удаления записи установите rec_type='none'
, чтобы планировщик пропустил этот экземпляр.storage.js
// удалить событие
async delete(id) {
// логика для поддержки повторяющихся событий
// https://docs.dhtmlx.com/scheduler/server_integration.html#recurringevents
let event = await this._db.query(
"SELECT * FROM ?? WHERE id=? LIMIT 1;",
[this.table, id]);
if (event.event_pid) {
// удаление изменённого экземпляра из серии повторяющихся событий
// Вместо удаления обновляем rec_type на "none"
event.rec_type = "none";
return await this.update(id, event);
}
if (event.rec_type && event.rec_type != "none") {
// удаляется серия повторяющихся событий, удаляем все изменённые экземпляры
await this._db.query(
"DELETE FROM ?? WHERE `event_pid`=? ;",
[this.table, id]);
}
await this._db.query(
"DELETE FROM ?? WHERE `id`= ?;",
[this.table, id]);
return {
action: "deleted"
}
}
dhtmlxScheduler — это клиентский компонент, ориентированный на гибкость, и не содержит встроенных средств безопасности. Поскольку только клиентский код не может обеспечить надёжную защиту, ответственность за безопасность приложения лежит на backend-разработчике.
Ключевые моменты:
SQL-инъекции: В этом примере используются параметризованные SQL-запросы, что помогает предотвратить атаки типа SQL injection.
XSS-атаки: Клиент не фильтрует пользовательский ввод перед отправкой на сервер, как и сервер не фильтрует данные перед отображением.
Одним из простых способов снизить риски является использование модуля helmet
, который добавляет базовые заголовки безопасности.
Установите helmet следующим образом:
$ yarn install helmet
Затем добавьте эту строку в server.js перед app.listen(...)
:
server.js
const helmet = require("helmet");
app.use(helmet());
Благодаря настройке router
, backend API возвращает статус error
, если возникает исключение.
На клиентской стороне вы можете обработать эти ошибки с помощью события onAfterUpdate объекта dataProcessor:
public/index.html
dp.attachEvent("onAfterUpdate", function(id, action, tid, response){
if (action == "error") {
// обработка ошибки
alert("Server error: " + response.message);
}
});
Если вы выполнили все шаги по интеграции Scheduler с Node.js, но события не отображаются на странице, ознакомьтесь со статьёй Устранение проблем с интеграцией Backend. В ней описаны способы выявления и устранения распространённых проблем.
На этом этапе у вас есть полностью рабочий Scheduler. Полный исходный код доступен на GitHub — вы можете клонировать репозиторий или скачать его для использования в своих проектах.
Вы также можете изучить руководства по различным возможностям Scheduler или ознакомиться с туториалами по интеграции Scheduler с другими backend-фреймворками.
Наверх