Введение
Когда бизнес-операция затрагивает несколько сущностей одновременно (например, «оформить заказ» должно одновременно создать запись заказа, уменьшить остаток товара на складе и записать движение по счёту), вызов нескольких независимых методов репозитория подряд, каждый в своей отдельной неявной транзакции, не даёт атомарности — если второй шаг провалится, первый уже останется применённым. Паттерн Unit of Work группирует несколько изменений в одну явную транзакцию. В статье разберём, как реализовать этот паттерн поверх репозиториев из предыдущих статей.
Концепция
Unit of Work инкапсулирует транзакцию: открывает её при создании, отслеживает все операции, выполненные в её рамках, и предоставляет единый метод commit()/rollback() для завершения. Репозитории, работающие внутри Unit of Work, должны использовать то же самое подключение к базе данных (QSqlDatabase), что и сам Unit of Work — это гарантирует, что все операции действительно происходят в рамках одной транзакции на уровне СУБД, а не в нескольких параллельных, не связанных друг с другом.
Пример кода: Unit of Work
// unitofwork.h
#pragma once
#include <QSqlDatabase>
#include <QSqlError>
#include <QDebug>
#include <functional>
class UnitOfWork
{
public:
explicit UnitOfWork(QSqlDatabase db) : m_db(db)
{
m_started = m_db.transaction();
if (!m_started) {
qWarning() << "Не удалось начать транзакцию:" << m_db.lastError().text();
}
}
~UnitOfWork()
{
// Если commit() не был вызван явно — откатываем все изменения при разрушении объекта
if (m_started && !m_finished) {
m_db.rollback();
}
}
bool commit()
{
if (!m_started || m_finished) return false;
m_finished = true;
if (!m_db.commit()) {
qWarning() << "Ошибка коммита транзакции:" << m_db.lastError().text();
m_db.rollback();
return false;
}
return true;
}
void rollback()
{
if (!m_started || m_finished) return;
m_finished = true;
m_db.rollback();
}
QSqlDatabase database() const { return m_db; }
private:
QSqlDatabase m_db;
bool m_started = false;
bool m_finished = false;
};
// Использование: оформление заказа как единая атомарная операция
bool placeOrder(QSqlDatabase db, int productId, int quantity, int customerId)
{
UnitOfWork unitOfWork(db);
SqlProductRepository productRepository(unitOfWork.database());
SqlOrderRepository orderRepository(unitOfWork.database());
SqlInventoryRepository inventoryRepository(unitOfWork.database());
auto product = productRepository.findById(productId);
if (!product || product->stockQuantity < quantity) {
qWarning() << "Недостаточно товара на складе";
unitOfWork.rollback();
return false;
}
Order newOrder;
newOrder.customerId = customerId;
newOrder.productId = productId;
newOrder.quantity = quantity;
if (!orderRepository.save(newOrder)) {
unitOfWork.rollback();
return false;
}
if (!inventoryRepository.decreaseStock(productId, quantity)) {
unitOfWork.rollback(); // заказ уже создан, но мы откатим И ЭТО изменение тоже —
// поскольку обе операции в одной транзакции
return false;
}
return unitOfWork.commit(); // только теперь обе операции фиксируются одновременно
}
Пояснения к коду
Конструктор UnitOfWork сразу начинает транзакцию через m_db.transaction(), а деструктор содержит защитную логику — если ни commit(), ни rollback() не были вызваны явно до разрушения объекта (например, из-за раннего return без явного вызова, или исключения), транзакция автоматически откатывается, что предотвращает случайное «зависание» незакрытой транзакции, аналогично паттерну QMutexLocker для мьютексов.
В placeOrder() все три репозитория (productRepository, orderRepository, inventoryRepository) создаются с одним и тем же объектом QSqlDatabase, полученным через unitOfWork.database() — это критично: если бы каждый репозиторий получил собственное, отдельное подключение к базе данных, они оказались бы в разных транзакциях, и атомарность всей операции была бы нарушена. Если уменьшение остатка на складе провалится после того, как заказ уже был сохранён, явный вызов unitOfWork.rollback() откатывает и созданный заказ — без Unit of Work это было бы невозможно сделать одним вызовом, пришлось бы вручную удалять уже созданный заказ отдельной операцией.
Подводные камни
- Передача разных объектов
QSqlDatabase(с разными именами подключений) в разные репозитории внутри одного Unit of Work полностью разрушает идею атомарности — в Qt каждое именованное подключение (QSqlDatabase::addDatabase(driver, name)) имеет собственное состояние транзакции, и операции через разные подключения не объединяются в одну транзакцию на уровне СУБД, даже если физически это одна и та же база данных. - Длительные операции (сетевые запросы, ожидание пользовательского ввода) внутри границ Unit of Work, удерживающие транзакцию открытой — как уже отмечалось в основном цикле статей про транзакции, это удерживает блокировки на затронутые строки на длительное время, существенно снижая параллелизм для других клиентов базы данных; вся логика внутри
UnitOfWorkдолжна выполняться максимально быстро. - Вложенные Unit of Work внутри друг друга (например, если
placeOrder()вызывает другую функцию, которая сама создаёт собственныйUnitOfWorkс тем же подключением) — повторный вызовm_db.transaction()для уже находящегося в транзакции подключения в большинстве драйверов не создаёт настоящую вложенную транзакцию с независимым частичным откатом, и нужно либо избегать такой вложенности в архитектуре, либо явно использовать SAVEPOINT для имитации вложенности, как упоминалось в основном цикле статей про транзакции. - Забытая проверка результата
transaction()в конструкторе. Если транзакция не была успешно начата (например, подключение уже находится в каком-то проблемном состоянии),m_startedостаётсяfalse, но код, использующийUnitOfWork, может не проверить это явно и продолжить выполнение операций, ошибочно считая, что они защищены транзакцией, хотя на самом деле выполняются без неё.