Транзакции в Laravel

28 September 2018
#Linux#PHP#Lumen

В документации laravel на транзакции не так уж и много сказано. Там есть примеры кода обращений к базе данных в пределах одного замыкания, но что делать, если нам нужно больше свободы? Давайте копнём глубже, что бы посмотреть, что происходит за кадром и какие инструменты мы можем использовать для работы с транзакциями.

Данная статья ждала своей очереди на перевод года 4, надеюсь она и сейчас не потеряла своей актуальности. Оставить не переведённой её я не мог.

Что такое Транзакции

Вы возможно уже знаете, что это такое, тем не менее, давайте рассмотрим. Транзакции дают возможность безопасно выполнять набор запросов изменяющие базу данных (например update, insert). Безопасно, потому что вы можете откатить все запросы сделанные в рамках транзакции в любое время их выполнения.

Представим, что у нас есть приложение, которое позволяет регистрировать пользователя и создавать учётные записи. Каждый пользователь может иметь несколько учётных записей связанных с ними.

Стоит задача, во время регистрации пользователя обработать "исключение" если учётная запись была создана успешно, но пользователь из-за каких-то ошибок не смог создаться.

Пример кода:

    // Создание учётной записи
    $newAcct = Account::create([
        'accountname' => Input::get('accountname'),
    ]);
    
    // Создание пользователя
    $newUser = User::create([
        'username' => Input::get('username'),
        'account_id' => $newAcct->id,
    ]);

Тут две ситуации которые могут вызвать проблемы:

1. Учётная запись не была создана.

Если учетная запись не была создана то и id, необходимое для передачи пользователю для поля account_id мы не получили. В этом случае, учётная запись и пользователь не будет созданы, так что это не обязательно нарушение целостности данных, нам просто нужно обработать эту ситуацию (в коде выше это не показано).

2. Пользователь не был создан.

Если же учётная запись была создана, но создание пользователя не было, то мы сталкиваемся с некоторыми вопросами. Теперь у вас есть учётная запись но к ней не привязаны пользователи, мы имеем нарушение целостности данных в БД. Можно дописать код с различными проверками, удалять в ручную созданные данные, но это же не наш метод! Давайте просто используем транзакции.

Инструментарий транзакций

Транзакции предоставляют нам три "инструмента" для работы:

  1. Создание транзакции
  2. rollback (Откат транзакции) - Отмена всех запросов в рамках транзакции. Завершение транзакции.
  3. commit (Фиксация транзакции) - Выполнение всех запросов в рамках созданной транзакции, данные не будут затронуты, пока транзакция не будет зафиксирована. Завершение транзакции.

Псевдокод, как мог бы выглядеть предыдущий код с транзакциями:

    // Создание транзакции
    beginTransaction();
    
    // Выполняем запросы
    $acct = createAccount();
    $user = createUser();
    
    // Если какой то из запросов вернул false то делаем откат
    if( !$acct || !$user )
    {
        rollbackTransaction();
    } else {
        // Иначе фиксируем изменения
        commitTransaction();
    }

Простые транзакции. Laravel

Первый и простой способ выполнить транзакцию в Laravel - это разместить ваши запросы в функции-замыкании метода DB::transaction():

    DB::transaction(function()
    {
        $newAcct = Account::create([
            'accountname' => Input::get('accountname')
        ]);
    
        $newUser = User::create([
            'username' => Input::get('username'),
            'account_id' => $newAcct->id,
        ]);
    });

Одна вещь, которая тут не очевидна - как этот метод знает, что нам нужно откатить или совершить транзакцию?

Узнать это можно посмотрев код фреймворка

    	public function transaction(Closure $callback)
    	{
    		$this->beginTransaction();
    		// We'll simply execute the given callback within a try / catch block
    		// and if we catch any exception we can rollback the transaction
    		// so that none of the changes are persisted to the database.
    		try
    		{
    			$result = $callback($this);
    			$this->commit();
    		}
    		// If we catch an exception, we will roll back so nothing gets messed
    		// up in the database. Then we'll re-throw the exception so it can
    		// be handled how the developer sees fit for their applications.
    		catch (\Exception $e)
    		{
    			$this->rollBack();
    			throw $e;
    		}
    		return $result;
    	}

Тут всё работает очень просто, если в функции-замыкании брошено исключение любого рода, то транзакция откатывается назад. Это означает, что любая ошибка SQL выполнит откат транзакций. Так же мы можем и сами вызвать исключение, что приведёт к откату. Что-то вроде этого:

    DB::transaction(function()
    {
        $newAcct = Account::create([
            'accountname' => Input::get('accountname')
        ]);
    
        $newUser = User::create([
            'username' => Input::get('username'),
            'account_id' => $newAcct->id,
        ]);
    
        if( !$newUser )
        {
            throw new \Exception('User not created for account');
        }
    });

Расширенные транзакции в Laravel

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

    try {
        // Валидация и создание, если данные верны
        $newAcct = Account::create( ['accountname' => Input::get('accountname')] );
    } catch(ValidationException $e)
    {
        // Редирект на страницу с ошибкой
        return Redirect::to('/form')
            ->withErrors( $e->getErrors() )
            ->withInput();
    }
    
    try {
        // Валидация и создание, если данные верны
        $newUser = User::create([
            'username' => Input::get('username'),
            'account_id' => $newAcct->id
        ]);
    } catch(ValidationException $e)
    {
        // Редирект на страницу с ошибкой
        return Redirect::to('/form')
            ->withErrors( $e->getErrors() )
            ->withInput();
    }

Встаёт вопрос, как поместить это в замыкание, если ValidationException всегда ловятся? Размещение этого когда в замыкании DB::transaction() не верно, т.к. гарантированно, что он никогда не вызовет откат, если проверка не завершилась при создании аккаунта или юзера.

Однако, более внимательно изучив документацию, мы видим, что можем вручную вызвать beginTransaction, rollback и commit!

Главное, что сделать это очень просто:

    // Старт транзакции!
    DB::beginTransaction();
    
    try {
        // Валидация и создание, если данные верны
        $newAcct = Account::create( ['accountname' => Input::get('accountname')]);
    } catch(ValidationException $e)
    {
        // Откат и редирект на страницу с ошибкой
        DB::rollback();
        return Redirect::to('/form')
            ->withErrors( $e->getErrors() )
            ->withInput();
    } catch(\Exception $e)
    {
        // Откат
        DB::rollback();
        throw $e;
    }
    
    try {
        // Валидация и создание, если данные верны
        $newUser = User::create([
            'username' => Input::get('username'),
            'account_id' => $newAcct->id
        ]);
    } catch(ValidationException $e)
    {
        // Откат и редирект на страницу с ошибкой
        DB::rollback();
        return Redirect::to('/form')
            ->withErrors( $e->getErrors() )
            ->withInput();
    } catch(\Exception $e)
    {
        // Откат
        DB::rollback();
        throw $e;
    }
    
    // Если всё хорошо - фиксируем
    DB::commit();

Обратите внимание, что на всякий случай ловится и общий Exception (upd: Актуально для версий 5.*), чтобы обеспечить целостность данных, если будет выброшено какое-либо другое исключение, отличное от ValidationException.

То что нужно! Мы полностью контролируем транзакции с базами данных в Laravel!

Ссылки

Эта статья - вольный перевод http://fideloper.com/laravel-database-transactions

Русская документация по транзакциям http://laravel.su/docs/5.4/database#database-transactions

Update

Кажется статья несколько устарела и нуждается в обновлении, возникает ряд вопросов почему "так а не так", но в целом полезная.