6. Паттерн UoW (стр. 120-135)
Что такое Unit of Work?
Unit of Work (UoW) — абстракция над атомарными операциями. Если репозиторий абстрагирует доступ к данным, то UoW абстрагирует целостность транзакций.
Проблема без UoW: сервисный слой зависит и от репозитория, и от сеанса БД:
# Без UoW: много зависимостей
def allocate(line: OrderLine, repo, session):
batches = repo.list()
batchref = model.allocate(line, batches)
session.commit() # ← Зависимость от session
Решение: UoW объединяет репозиторий и транзакцию в одной абстракции:
# С UoW: одна зависимость
def allocate(orderid: str, sku: str, qty: int, uow: AbstractUnitOfWork):
line = OrderLine(orderid, sku, qty)
with uow:
batches = uow.batches.list()
batchref = model.allocate(line, batches)
uow.commit()
return batchref
Три преимущества UoW
- Стабильный снимок БД — объекты не меняются во время операции
- Атомарность — всё или ничего (нет частичных обновлений)
- Единый API — одно место для получения репозиториев и фиксации
Реализация UoW
Абстрактный базовый класс
# unit_of_work.py
class AbstractUnitOfWork(abc.ABC):
batches: repository.AbstractRepository
def __exit__(self, *args):
self.rollback()
@abc.abstractmethod
def commit(self):
raise NotImplementedError
@abc.abstractmethod
def rollback(self):
raise NotImplementedError
Реализация для SQLAlchemy
class SqlAlchemyUnitOfWork(AbstractUnitOfWork):
def __init__(self, session_factory=DEFAULT_SESSION_FACTORY):
self.session_factory = session_factory
def __enter__(self):
self.session = self.session_factory()
self.batches = repository.SqlAlchemyRepository(self.session)
return super().__enter__()
def __exit__(self, *args):
super().__exit__(*args)
self.session.close()
def commit(self):
self.session.commit()
def rollback(self):
self.session.rollback()
Контекстный менеджер (with uow:) — идиоматичный способ для Python:
__enter__— создаёт сеанс и репозиторий__exit__— закрывает сеанс, делает откат если не было commit()
Поддельный UoW для тестов
class FakeUnitOfWork(AbstractUnitOfWork):
def __init__(self):
self.batches = FakeRepository([])
self.committed = False
def commit(self):
self.committed = True
def rollback(self):
pass
Тестирование UoW
Интеграционный тест
def test_uow_can_retrieve_a_batch_and_allocate_to_it(session_factory):
# Подготовка: вставляем данные напрямую в БД
session = session_factory()
insert_batch(session, 'batch1', 'HIPSTER-WORKBENCH', 100, None)
session.commit()
# Тест: используем UoW
uow = unit_of_work.SqlAlchemyUnitOfWork(session_factory)
with uow:
batch = uow.batches.get(reference='batch1')
line = model.OrderLine('o1', 'HIPSTER-WORKBENCH', 10)
batch.allocate(line)
uow.commit()
# Проверка: данные сохранились
batchref = get_allocated_batch_ref(session, 'o1', 'HIPSTER-WORKBENCH')
assert batchref == 'batch1'
Тесты на откат
def test_rolls_back_uncommitted_work_by_default(session_factory):
uow = unit_of_work.SqlAlchemyUnitOfWork(session_factory)
with uow:
insert_batch(uow.session, 'batch1', 'MEDIUM-PLINTH', 100, None)
# commit() не вызываем!
# Данные не сохранились
new_session = session_factory()
rows = list(new_session.execute('SELECT * FROM batches'))
assert rows == []
def test_rolls_back_on_error(session_factory):
uow = unit_of_work.SqlAlchemyUnitOfWork(session_factory)
with pytest.raises(MyException):
with uow:
insert_batch(uow.session, 'batch1', 'LARGE-FORK', 100, None)
raise MyException()
# Откат произошёл
new_session = session_factory()
rows = list(new_session.execute('SELECT * FROM batches'))
assert rows == []
Явная vs неявная фиксация
Вариант 1: Явная фиксация (рекомендуется)
with uow:
uow.batches.add(batch)
uow.commit() # ← Явно вызываем
Преимущества:
- Безопасно по умолчанию (ничего не меняется без commit())
- Понятно, когда происходит фиксация
- Один путь к изменениям: полный успех + явная фиксация
Вариант 2: Неявная фиксация
class AbstractUnitOfWork:
def __exit__(self, exn_type, exn_value, traceback):
if exn_type is None:
self.commit() # ← Автоматически при успехе
else:
self.rollback()
# Не нужно писать commit()
with uow:
uow.batches.add(batch)
Недостатки:
- Менее явно, когда происходит фиксация
- Ранний выход из блока может зафиксировать частичные изменения
Мы предпочитаем явную фиксацию — безопаснее и понятнее.
Примеры использования UoW
Пример 1: Повторное размещение
def reallocate(line: OrderLine, uow: AbstractUnitOfWork):
with uow:
batch = uow.batches.get(sku=line.sku)
if batch is None:
raise InvalidSku(f'Недопустимый артикул {line.sku}')
batch.deallocate(line) # ← Если ошибка — откат
allocate(line) # ← Если ошибка — откат
uow.commit() # ← Всё или ничего
Если deallocate() или allocate() выбросит ошибку — всё откатится.
Пример 2: Изменение количества товара
def change_batch_quantity(batchref: str, new_qty: int, uow: AbstractUnitOfWork):
with uow:
batch = uow.batches.get(reference=batchref)
batch.change_purchased_quantity(new_qty)
# Если товара стало меньше — отменяем размещения
while batch.available_quantity < 0:
line = batch.deallocate_one()
uow.commit()
Если на каком-то этапе возникнет ошибка — все изменения откатятся.
Почему UoW, а не просто Session?
Вопрос: зачем создавать UoW, если SQLAlchemy Session уже делает то же самое?
Ответ: UoW проще и понятнее:
Session (SQLAlchemy) — сложный объект с кучей методов, можно делать произвольные запросы, сложно подделывать в тестах.
UoW — простой интерфейс (commit(), rollback(), .batches), только репозитории и транзакции, легко создать FakeUnitOfWork.
Принцип: «Не владеешь — не имитируй». Лучше создать простую абстракцию, чем зависеть от сложного Session.
Структура тестов после внедрения UoW
tests/
├── unit/
│ ├── test_allocate.py # Модель предметной области
│ └── test_services.py # Сервисный слой с FakeUoW
├── integration/
│ └── test_uow.py # UoW с реальной БД
└── e2e/
└── test_api.py # Сквозные тесты API
Правило: тестировать на максимально высоком уровне абстракции. Если test_uow.py покрывает то же, что test_repository.py — удаляем последний.
Выводы
- UoW — абстракция атомарных операций — всё или ничего
- Контекстный менеджер — идиоматичный Python-способ (
with uow:) - Явная фиксация безопаснее — по умолчанию ничего не меняется
- UoW + репозиторий работают в паре — коллабораторы
- Откат по умолчанию — система безопасна при ошибках
- Проще чем Session — не нужно зависеть от сложного ORM
Вопросы
- Что такое Unit of Work и зачем он нужен?
- Почему контекстный менеджер удобен для UoW?
- В чём разница между явной и неявной фиксацией?
- Как UoW помогает с атомарностью операций?
- Почему не использовать напрямую Session из SQLAlchemy?
- Что такое принцип «Не владеешь — не имитируй»?
