В одной из наших предыдущих статей мы описали принципы работы с несколькими базами данных. Затем мы использовали NestJS и TypeORM, чтобы продемонстрировать, как реализовать требуемую функциональность и не использовать неправильные соединения при сохранении данных. Но есть еще одна проблема, с которой сталкиваются разработчики, работая с одновременным доступом к нескольким источникам данных, что обусловлено необходимостью обеспечить их целостность во всех базах данных.

Вы не должны недооценивать важность этого вопроса, поскольку он может иметь серьезные последствия для бизнеса. Например, ваше веб-приложение обслуживает множество организаций, взаимодействующих друг с другом для организации мероприятий. Данные каждой организации хранятся в отдельной базе данных по соображениям безопасности. Сотрудник компании A заполняет веб-форму, чтобы сделать сложный заказ на аренду автомобилей у компании B и бронирование питания у компании C. Отправка заказа приводит к проблеме. Данные о кейтеринге не сохраняются в системе. Сотрудник изменяет данные, затем нажимает кнопку «Отправить», и на этот раз они успешно сохраняются. Однако компания Б получает два заказа на аренду автомобиля. Таким образом, компании А может быть выставлен счет дважды. Несомненно, вы будете получать жалобы, и ваша деловая репутация будет запятнана из-за того, как работает ваше веб-приложение.

Когда вы имеете дело с одной базой данных, все довольно просто, и begin, commit и rollback достаточно, чтобы все заработало. В этом случае разработка веб-приложений проще простого. Работать с данными, хранящимися в нескольких распределенных базах данных, требующих одновременного подключения, гораздо сложнее. В этом сценарии нет другого выбора, кроме как начать транзакции для каждой базы данных, держать транзакции открытыми до тех пор, пока все данные не будут обновлены, и только затем фиксировать во всех базах данных. Но если некоторые данные не сохраняются в одной из этих баз данных из-за каких-либо проблем, мы должны откатить все открытые транзакции. В противном случае вы столкнетесь с серьезными проблемами целостности данных.

На этом этапе важность обработки транзакций должна быть довольно очевидной. Теперь пришло время продемонстрировать, как эффективно реализовать эту функцию с помощью TypeORM.

Как TypeORM обрабатывает транзакции

Давайте посмотрим, как мы можем реализовать транзакцию с помощью TypeORM. Есть два варианта сделать это. Мы можем использовать функцию DataSource или QueryRunner. Обратите внимание, что код в этой статье использует TypeORM версии 0.3.7.

Читайте также Лучшее время для начала обучения — сейчас. Пять полноценных проектов для улучшения ваших навыков в 2023 году

Во-первых, давайте посмотрим, как работает функция DataSource:

await customDataSource.transaction(async (transactionEntityManager) => {
    await transactionEntityManager.save(users);
});

В этом случае мы можем использовать одну функцию, а TypeORM сделает всю остальную работу. Основное преимущество этого подхода заключается в том, что нам нужна только одна функция для достижения нашей цели. Недостатки в том, что мы не имеем полного контроля над текущей транзакцией, и такая транзакция применяется только к одному DataSource одновременно.

Теперь давайте посмотрим на реализацию транзакций с помощью QueryRunner:

const queryRunner = customDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();

try {
    await queryRunner.manager.save(users);

    await queryRunner.commitTransaction();
} catch (err) {
    await queryRunner.rollbackTransaction();
} finally {
    await queryRunner.release();
}

Здесь мы используем функции QueryRunner для реализации транзакции. По сравнению с предыдущим примером преимущества этого подхода в том, что мы контролируем процесс транзакции, а также можем обрабатывать ошибки. Недостатки в том, что нам нужно больше строк кода, и мы всегда должны помнить об управлении транзакциями.

Для реализации транзакций с несколькими базами данных мы будем использовать параметр QueryRunner.

Реализация одновременных транзакций с несколькими базами данных

Для реализации работы с несколькими одновременными транзакциями создадим следующую функцию:

async function transaction<T>(
        dataSources: DataSource[],
        tryBlock: (...queryRunners: QueryRunner[]) => Promise<T>,
        catchBlock?: () => void
    ): Promise<T> {
        const queryRunners: QueryRunner[] = [];
        for (const ds of dataSources) {
            if (ds) {
                const qr = ds.createQueryRunner();
                await qr.connect();
                await qr.startTransaction();
                queryRunners.push(qr);
            }
        }

        try {
            const result = await tryBlock(...queryRunners);

            for (const qr of queryRunners) {
                await qr.commitTransaction();
            }

            return result;
        } catch (err) {
            await catchBlock?.();

            for (const qr of queryRunners) {
                await qr.rollbackTransaction();
            }

            throw err;
        } finally {
            for (const qr of queryRunners) {
                await qr.release();
            }
        }
    }

Давайте проанализируем этот код, чтобы лучше понять, что здесь происходит.

Функция принимает три параметра: список используемых источников данных, функцию, которую необходимо выполнить (включая действия с таблицами), и необязательную функцию, которая запускается при возникновении ошибки.

Затем мы используем цикл for, который перебирает все DataSources и создает соответствующие QueryRunners. Также в этом цикле мы формируем массив из созданных QueryRunners.

За этим кодом следует блок try, который вызывает переданную функцию. Результат переданной функции сохраняется в переменной с именем result и будет возвращен исходной функцией. Цикл for перебирает все QueryRunners и вызывает функцию commitTransaction.

Далее идет блок catch. Он срабатывает, если возникает какая-либо ошибка при выполнении функции commitTransaction. Здесь выполняется переданная функция для блока catch. Он также запускает цикл for, который перебирает все QueryRunners и вызывает функцию rollbackTransaction.

Кроме того, нам нужно выполнить функцию release для наших QueryRunners, поэтому в блоке finally мы запускаем функцию for цикл, в котором мы вызываем функцию release для всех наших QueryRunners.

Читайте также Выяснить, что под капотом. Основные типы систем управления базами данных

Таким образом, созданный нами метод позволяет нам выполнять множество транзакций с разными базами данных и обрабатывать ошибки, если они возникают.

Пример использования функции

Приведем пример использования созданной нами функции на практике.

Предположим, наша задача — хранить общие пользовательские данные в одной базе данных, а конфиденциальные — в другой. Также нам нужно сохранить аватарку пользователя в каком-нибудь файловом хранилище. Код для реализации этой возможности может выглядеть так:

let path: string;
const savedUserData = await transaction<MyUserEntity>(
    [firstDBDataSource, secondDBDataSource],
    async (firstDBQueryRunner, secondDBQueryRunner) => {
        path = await fileManager.uploadFile(avatar);
        user.avatar = path;

        const dbuser = await firstDBQueryRunner.manager.save(user);
        await secondDBQueryRunner.manager.save(userConfidentialData);

        return dbuser;
    },
    async () => {
        await fileManager.removeFile(path);
    }
);
return savedUserData;

Здесь мы взяли два DataSource, принадлежащих нужным нам базам данных. На основе полученных DataSources функция создала два QueryRunner. В функции, переданной вторым параметром, мы использовали созданный QueryRunners и сохранили файл и данные. Если задача не удалась, будет выполнена функция, переданная в качестве третьего параметра. Внутри него мы удалим загруженный файл, так как он не будет удален автоматически при откате транзакции.

Проверьте полный исходный код проекта.

Загрузить сейчас

Выводы

Используя эту функцию, вы можете легко запустить несколько одновременных транзакций, которые будут выполняться или откатываться при необходимости. Чтобы улучшить читаемость кода, вы можете изменить функции TypeORM, добавив необязательный параметр для EntityManager. В этом случае функцию сохранения можно записать так:

await customRepository.save(user, firstDBQueryRunner.manager);

Свяжитесь с нами, если вы ищете опытную команду разработчиков, которая может создать веб-приложение с привлекательным пользовательским интерфейсом и надежной серверной частью.