6.Паттерн Unit of Work(Единица работы)


Паттерн Unit of Work(Единица работы)


В этой главе мы познакомимся с заключительной частью головоломки, которая связывает воедино паттерны уровня Репозитория и Уровня Сервиса: паттерн Unit of Work.

Если шаблон хранилища-это наша абстракция по отношению к идее постоянного хранения, то шаблон "Единицы Работы" (UoW) - это наша абстракция по отношению к идее атомарных операций. Это позволит нам окончательно и полностью отделить наш уровень обслуживания от уровня данных.

[before_uow_diagram] показывает, что в настоящее время много взаимодействий происходит между уровнями нашей инфраструктуры: API обращается напрямую к уровню базы данных, чтобы начать сеанс, он обращается к уровню репозитория для инициализации SQLAlchemyRepository, и он связывается с уровнем сервиса, чтобы выделить позиции.

Tip

Код для этой главы находится в ветке chapter_06_uow on GitHub:

git clone https://github.com/cosmicpython/code.git
cd code
git checkout chapter_06_uow
# or to code along, checkout Chapter 4:
git checkout chapter_04_service_layer
images/apwp_0601.png
Figure 1. Без UoW: API напрямую общается с тремя уровнями

[after_uow_diagram] показывает наше целевое состояние. API Flask теперь делает только две вещи: он инициализирует единицу работы и вызывает службу. Сервис сотрудничает с UoW (нам нравится думать о UoW как о части сервисного уровня), но ни сама сервисная функция, ни Flask теперь не нуждаются в непосредственном общении с базой данных.

И мы сделаем все это с помощью прекрасного синтаксиса Python - диспетчера контекста.

images/apwp_0602.png
Figure 2. С помощью UoW: UoW теперь управляет состоянием базы данных

Unit of Work взаимодействует с репозиторием

Давайте посмотрим, как работает единица работы (или UoW, что мы произносим как «you-wow»). Вот как будет выглядеть сервисный слой, когда мы закончим:

Example 1. Предварительный просмотр Unit of Work в действии (src/allocation/service_layer/services.py)
def allocate(
        orderid: str, sku: str, qty: int,
        uow: unit_of_work.AbstractUnitOfWork
) -> str:
    line = OrderLine(orderid, sku, qty)
    with uow:  #1
        batches = uow.batches.list()  #2
        ...
        batchref = model.allocate(line, batches)
        uow.commit()  #3
1Мы запустим UoW в качестве менеджера контекста.
2uow.batches это пакетный репо, поэтому UoW предоставляет нам доступ к нашему постоянному хранилищу.
3Когда мы закончили, мы фиксируем или откатываем нашу работу, используя UoW.

UoW действует как единственная точка входа в наше постоянное хранилище и отслеживает, какие объекты были загружены, и последнее состояние.[1]

Это дает нам три полезных преимущества:

  • Стабильный моментальный снимок базы данных для работы, поэтому объекты, которые мы используем, не меняются на полпути операции.

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

  • Простой API для наших проблем персистентности и удобное место для получения репозитория

Тест-драйв UoW с интеграционными тестами

Here are our integration tests for the UOW:

Example 2. A basic "round-trip" test for a UoW (tests/integration/test_uow.py)
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 = unit_of_work.SqlAlchemyUnitOfWork(session_factory)  #1
    with uow:
        batch = uow.batches.get(reference='batch1')  #2
        line = model.OrderLine('o1', 'HIPSTER-WORKBENCH', 10)
        batch.allocate(line)
        uow.commit()  #3

    batchref = get_allocated_batch_ref(session, 'o1', 'HIPSTER-WORKBENCH')
    assert batchref == 'batch1'
1Мы инициализируем UoW, используя нашу настраиваемую фабрику сеансов, и возвращаем объект uow для использования его в нашем блоке with.
2UoW дает нам доступ к репозиторию batch через uow.batches.
3Мы вызываем для него commit(), когда закончим.

Для любопытных хелперы insert_batch и get_allocated_batch_ref выглядят так:

Example 3. Помощники для работы с SQL (tests/integration/test_uow.py)
def insert_batch(session, ref, sku, qty, eta):
    session.execute(
        'INSERT INTO batches (reference, sku, _purchased_quantity, eta)'
        ' VALUES (:ref, :sku, :qty, :eta)',
        dict(ref=ref, sku=sku, qty=qty, eta=eta)
    )


def get_allocated_batch_ref(session, orderid, sku):
    [[orderlineid]] = session.execute(
        'SELECT id FROM order_lines WHERE orderid=:orderid AND sku=:sku',
        dict(orderid=orderid, sku=sku)
    )
    [[batchref]] = session.execute(
        'SELECT b.reference FROM allocations JOIN batches AS b ON batch_id = b.id'
        ' WHERE orderline_id=:orderlineid',
        dict(orderlineid=orderlineid)
    )
    return batchref

Unit of Work и её менеджер контекста

В наших тестах мы неявно определили интерфейс для того, что должен делать UoW. Давайте сделаем это явным с помощью абстрактного базового класса:

Example 4. Абстрактный менеджер контекста UoW (src/allocation/service_layer/unit_of_work.py)
1UoW предоставляет атрибут под названием .batches, который дает нам доступ к репозиторию пакетов.
2Если вы никогда не видели контекстного менеджера, __enter__ и __exit__ это два волшебных метода, которые выполняются, когда мы входим в блок with и когда выходим из него, соответственно. Это наши фазы setup и teardown.
3Мы вызовем этот метод, чтобы явно зафиксировать нашу работу, когда будем готовы.
4Если мы не фиксируем, или если мы выходим из диспетчера контекста, вызывая ошибку, мы выполняем «откат» rollback. (Откат не возымеет никакого эффекта, если была вызвана функция commit(). Читайте дальше для более подробного обсуждения этого вопроса.)

Реальная Unit of Work Использует Сеансы SQLAlchemy

Главное, что добавляет наша конкретная реализация, - это сеанс базы данных:

Example 5. The real SQLAlchemy UoW (src/allocation/service_layer/unit_of_work.py)
DEFAULT_SESSION_FACTORY = sessionmaker(bind=create_engine(  #1
    config.get_postgres_uri(),
))

class SqlAlchemyUnitOfWork(AbstractUnitOfWork):

    def __init__(self, session_factory=DEFAULT_SESSION_FACTORY):
        self.session_factory = session_factory  #1

    def __enter__(self):
        self.session = self.session_factory()  # type: Session  #2
        self.batches = repository.SqlAlchemyRepository(self.session)  #2
        return super().__enter__()

    def __exit__(self, *args):
        super().__exit__(*args)
        self.session.close()  #3

    def commit(self):  #4
        self.session.commit()

    def rollback(self):  #4
        self.session.rollback()
1Модуль определяет фабрику сеансов по умолчанию, которая будет подключаться к Postgres, но мы позволяем переопределить это в наших интеграционных тестах, чтобы вместо этого мы могли использовать SQLite.
2Метод __enter__ отвечает за запуск сеанса базы данных и создание экземпляра реального репозитория, который может использовать этот сеанс.
3Закрываем сессию при выходе.
4Наконец, мы предоставляем конкретные методы commit() и rollback(), которые используют наш сеанс базы данных.

Иммитация Unit of Work для теста

Вот как мы используем фиктивный UoW в наших тестах уровня сервиса:

Example 6. Fake UoW (tests/unit/test_services.py)
class FakeUnitOfWork(unit_of_work.AbstractUnitOfWork):

    def __init__(self):
        self.batches = FakeRepository([])  #1
        self.committed = False  #2

    def commit(self):
        self.committed = True  #2

    def rollback(self):
        pass



def test_add_batch():
    uow = FakeUnitOfWork()  #3
    services.add_batch("b1", "CRUNCHY-ARMCHAIR", 100, None, uow)  #3
    assert uow.batches.get("b1") is not None
    assert uow.committed


def test_allocate_returns_allocation():
    uow = FakeUnitOfWork()  #3
    services.add_batch("batch1", "COMPLICATED-LAMP", 100, None, uow)  #3
    result = services.allocate("o1", "COMPLICATED-LAMP", 10, uow)  #3
    assert result == "batch1"
...
1FakeUnitOfWork и FakeRepository тесно связаны, так же как реальные классы UnitofWork и Repository. Это прекрасно, потому что мы признаем, что объекты являются соавторами.
2Обратите внимание на сходство с фальшивой функцией commit() из FakeSession (от которой теперь мы можем избавиться). Но это существенное улучшение, потому что мы сейчас подделываем код, который мы написали, а не сторонний код. Как гласит народная мудрость, "Не твоё — не трогай".
3В наших тестах мы можем создать экземпляр UoW и передать его на наш уровень обслуживания, а не передавать репозиторий и сеанс. Это значительно изящнее.
Не твоё — не мОкай

Почему мы чувствуем себя более комфортно, мокая UoW, а не сессию? Обе наши имитации преднназначены для одного и того же: дать нам возможность изменить уровень персистентности, чтобы мы могли запускать тесты в памяти вместо того, чтобы связываться с реальной базой данных. Разница заключается в полученном дизайне.

Если бы мы заботились только о написании тестов, которые выполняются быстро, мы могли бы создавать макеты, заменяющие SQLAlchemy, и использовать их во всей нашей кодовой базе. Проблема в том, что Session - это сложный объект, который предоставляет множество функций, связанных с постоянством. Session легко использовать для выполнения произвольных запросов к базе данных, но это быстро приводит к тому, что код доступа к данным разбрызгивается по всей кодовой базе. Чтобы этого избежать, мы хотим ограничить доступ к нашему уровню сохранения, чтобы каждый компонент имел именно то, что ему нужно, и ничего более.

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

«Не смейтесь над тем, что вам не принадлежит» - это эмпирическое правило, которое заставляет нас строить эти простые абстракции над беспорядочными подсистемами. Это дает тот же выигрыш в производительности, что и имитация сеанса SQLAlchemy, но побуждает нас тщательно обдумать наши проекты.

Использование UoW в сервисном слое

Вот как выглядит наш новый уровень обслуживания:

Example 7. Уровень обслуживания с использованием UoW (src/allocation/service_layer/services.py)
def add_batch(
        ref: str, sku: str, qty: int, eta: Optional[date],
        uow: unit_of_work.AbstractUnitOfWork  #1
):
    with uow:
        uow.batches.add(model.Batch(ref, sku, qty, eta))
        uow.commit()


def allocate(
        orderid: str, sku: str, qty: int,
        uow: unit_of_work.AbstractUnitOfWork  #1
) -> str:
    line = OrderLine(orderid, sku, qty)
    with uow:
        batches = uow.batches.list()
        if not is_valid_sku(line.sku, batches):
            raise InvalidSku(f'Invalid sku {line.sku}')
        batchref = model.allocate(line, batches)
        uow.commit()
    return batchref
1Наш уровень обслуживания теперь имеет только одну зависимость, опять же от abstract UoW.

Явные тесты для режима Commit/Rollback

Чтобы убедиться, что поведение commit/rollback фиксации/отката работает, мы написали несколько тестов:

Example 8. Интеграционные тесты на поведение отката (tests/integration/test_uow.py)
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)

    new_session = session_factory()
    rows = list(new_session.execute('SELECT * FROM "batches"'))
    assert rows == []


def test_rolls_back_on_error(session_factory):
    class MyException(Exception):
        pass

    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 == []
TipМы не показывали его здесь, но, возможно, стоит протестировать некоторые из более "неясных" действий базы данных, таких как транзакции, против "реальной" базы данных—то есть того же самого движка. На данный момент нам сходит с рук использование SQLite вместо Postgres, но в [chapter_07_aggregate] мы переключим некоторые тесты на использование реальной базы данных. Очень удобно, что наш класс UoW делает это легко!

Явные и неявные коммиты

Теперь мы вкратце остановимся на различных способах реализации паттерна UoW.

Мы могли бы представить себе несколько иную версию UoW, которая фиксируется по умолчанию и откатывается только в том случае, если замечает исключение:

Example 9. UoW с неявной фиксацией … (src/allocation/unit_of_work.py)
1Должны ли мы иметь на счастливом пути неявную фиксацию?
2И откатиться только при исключении?

Это позволило бы нам сохранить строку кода и удалить явную фиксацию из нашего клиентского кода:

Example 10. ...это сэкономило бы нам строку кода (src/allocation/service_layer/services.py)

Это субъективное мнение, но мы, как правило, предпочитаем требовать явной фиксации, так что нам приходится выбирать, когда сбросить состояние.

Хотя мы используем дополнительную строку кода, это делает программное обеспечение безопасным по умолчанию. Поведение по умолчанию - "ничего не менять". В свою очередь, это делает наш код более простым для рассуждения, потому что есть только один путь кода, который ведет к изменениям в системе: полный успех и явная фиксация. Любой другой путь кода, любое исключение, любой ранний выход из области действия UoW приводит к безопасному состоянию.

Точно так же мы предпочитаем откат по умолчанию, потому что это легче понять; это откат к последней фиксации, поэтому пользователь либо выполнил задание, или мы сдуем их изменения. Сурово, но просто.

Примеры: Использование UoW для группировки нескольких операций в атомарную единицу

Ниже приведены некоторые примеры используемых схем работы. Это может привести к более простому рассуждению о том, как блоки кода работают совместно.

Пример 1: Перераспределение

Предположим, что мы хотим отменить распределение, а затем передислоцировать заказ:

Example 11. Перераспределить сервисную функцию
1Если deallocate() не работает, очевидно мы не хотим вызывать allocate().
2Если allocate() терпит неудачу, вероятно мы, так же не хотим фиксить deallocate()

Пример 2: Изменить размер партии

Наша судоходная компания звонит нам, чтобы сообщить, что одна из дверей контейнера открылась, и половина наших диванов упала в Индийский океан. Ой!

Example 12. Изменение количества
1Здесь нам может понадобиться разобраться с любым количеством строк. Если мы получим неудачу на каком-то этапе, мы, вероятно, не захотим вносить никаких изменений.

Уборка интеграционных тестов

Теперь у нас есть три набора тестов, все из которых, по сути, направлены на базу данных: test_orm.pytest_repository.py, и test_uow.py. Может, выкинем что-нибудь?

└── tests
    ├── conftest.py
    ├── e2e
    │   └── test_api.py
    ├── integration
    │   ├── test_orm.py
    │   ├── test_repository.py
    │   └── test_uow.py
    ├── pytest.ini
    └── unit
        ├── test_allocate.py
        ├── test_batches.py
        └── test_services.py

Вы всегда можете отказаться от тестов, если считаете, что они не принесут пользы в долгосрочной перспективе. Мы бы сказали, что test_orm.py был в первую очередь инструментом для изучения SQLAlchemy, поэтому в дальнейшем он не понадобится, особенно если основные вещи, которые он делает, описаны в test_repository.py. Этот последний тест вы можете оставить, но мы, безусловно, видим аргумент в пользу того, чтобы просто держать все на максимально возможном уровне абстракции (так же, как мы делали это в юнит-тестах).

Упражнение для читателя

Для этой главы, пожалуй, лучшее, что можно попробовать, это реализовать UoW с нуля. Код, как всегда, здесь на GitHub. Вы можете либо достаточно внимательно следовать нашей модели, либо, возможно, поэкспериментировать с отделением UoW (в обязанности которого входит commit()rollback() и предоставление репозитория .batches) от контекстного менеджера, чья работа заключается в инициализации объектов, а затем выполнить коммит или откат при выходе. Если вы чувствуете, что хотите работать полностью функционально, а не возиться со всеми этими классами, вы можете использовать @contextmanager из contextlib.

Мы удалили как фактический UoW, так и подделки, а также сократили абстрактный UoW. Почему бы не прислать нам ссылку на ваше репо, если вы придумали что-то, чем особенно гордитесь?

TipЭто еще один пример урока из [chapter_05_high_gear_low_gear]: по мере того как мы строим лучшие абстракции, мы можем перемещать наши тесты, чтобы работать с ними, что оставляет нам свободу изменять лежащие в их основе детали.

Заключение

Надеюсь, мы убедили вас, что шаблон «Единица работы» полезен и что диспетчер контекста - действительно хороший питонический способ визуальной группировки кода в блоки, которые мы хотим реализовать атомарно.

Этот шаблон настолько полезен, что SQLAlchemy уже использует UoW в форме объекта Session. Объект Session в SQLAlchemy - это способ, которым ваше приложение загружает данные из базы данных.

Каждый раз, когда вы загружаете новую сущность из базы данных, сеанс начинает отслеживать изменения в сущности, и когда сеанс сбрасывается, все ваши изменения сохраняются вместе. Зачем нам пытаться абстрагировать сеанс SQLAlchemy, если он уже реализует нужный нам паттерн?

[chapter_06_uow_tradeoffs] обсуждает некоторые компромиссы.

Table 1. Паттерн Единица Работы: компромиссы
ПлюсыМинусы
  • У нас есть хорошая абстракция над концепцией атомарных операций, и контекстный менеджер позволяет легко увидеть, визуально, какие блоки кода сгруппированы вместе атомарно.

  • У нас есть явный контроль над тем, когда транзакция начинается и заканчивается, и наше приложение выходит в случае сбоя таким образом, который безопасен по умолчанию. Нам никогда не приходётсяся беспокоиться о том, что операция завершена лишь частично.

  • Это хорошее место для размещения всех репозиториев, доступных для клиентского кода.

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

  • В вашем ORM, вероятно, уже есть отличные абстракции вокруг атомарности. В SQLAlchemy даже есть диспетчеры контекста. Вы можете пройти долгий путь, просто пропуская сеанс.

  • Мы сделали так, чтобы это выглядело легко, но вы должны очень тщательно подумать о таких вещах, как откаты, многопоточность и вложенные транзакции. Возможно, просто придерживаясь того, что дает вам Django или Flask-SQLAlchemy, вы упростите свою жизнь.

Во-первых, Session API богат и поддерживает операции, которые нам не нужны или не нужны в нашем домене. Наш UnitOfWork упрощает сеанс до его основного ядра: его можно запустить, зафиксировать или выбросить.

Во-вторых, мы используем UnitOfWork для доступа к нашим объектам Repository. Это добавит удобства в использовании разработчиками, и это то, то мы не смогли бы сделать с помощью простого SQLAlchemy Session.

Краткий обзор шаблона Unit of Work

Шаблон Unit of Work - это абстракция вокруг целостности данных

Он помогает обеспечить согласованность нашей модели предметной области и повышает производительность, позволяя нам выполнять одну _flush_операцию в конце операции.

Он тесно работает с шаблонами Уровня репозитория и сервиса

Шаблон Unit of Work завершает наши абстракции над доступом к данным, представляя атомарные обновления. Каждый из наших вариантов использования сервисного уровня выполняется в одной единице работы, которая успешно или неудачно выполняется как блок.

Это прекрасный случай для контекстного менеджера

Менеджеры контекста - это идиоматический способ определения области видимости в Python. Мы можем использовать диспетчер контекста для автоматического отката нашей работы в конце запроса, что означает, что система по умолчанию безопасна.

SQLAlchemy уже реализует этот шаблон: Мы вводим еще более простую абстракцию над объектом SQLAlchemy Session, чтобы "сузить" интерфейс между ORM и нашим кодом. Это помогает нам сохранять слабую связь.

Наконец, мы снова мотивированы принципом инверсии зависимостей: наш уровень сервиса зависит от тонкой абстракции, и мы прикрепляем конкретную реализацию к внешнему краю системы. Это хорошо согласуется с собственной рекомендацией SQLAlchemy:

Держите жизненный цикл сеанса (и, как правило, транзакции) отдельным и внешним. Наиболее комплексный подход, рекомендуемый для более существенных приложений, будет стараться держать детали сеанса, транзакции и управления исключениями как можно дальше от деталей программы, выполняющей свою работу.

— SQLALchemy "Session Basics" Documentation

1. Возможно, вы встречали слово collaborators для описания объектов, которые работают вместе для достижения цели. Единица работы и репозиторий - отличный пример сотрудничества в объектном моделировании. В дизайне, ориентированном на ответственность, кластеры объектов, взаимодействующих в своих ролях, называются object neighborhoods ближайшими соседями, что, по нашему профессиональному мнению, совершенно восхитительно.

Комментарии

Популярные сообщения из этого блога

4.Наш первый Use Case или пример использования: Flask API и Service Layer

Введение

2.Repository Pattern