Laravel: Lock

17.11.2020 в 18:29
3214
+3

Обзор небольшого и мало кому известного компонента под общим названием Lock. Программистам на php почти не приходится иметь дело с блокировками ресурсов (речь не про блокировки на уровне базы).

Это актуально в основном для языков с нативной поддержкой конкурентной модели, где за право работать с данными могут бороться несколько корутин или потоков. Чтобы программа была уверена, что в конкретный момент времени доступ к данным есть только у нее, необходимо использовать блокировки. Бывают несколько видов блокировок: мьютексы, семафоры, каналы (актуально для go) и некоторые другие.

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

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

Итак, чтобы использовать блокировки в Laravel, вы можете настроить один из провайдеров: обычная база данных, redis, memcached или dynamodb. Есть еще провайдер array, но он, если вы в курсе про модель работы php, находит свое применение только в тестах.

Мы будем рассматривать на примере redis, так как именно этот провайдер я чаще всего использую в своих проектах и проблем с ним не имел.

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

final class ArticleController
{
    public function manage(int $managedArticleId, ManagingArticleQuery $query)
    {
        return new JsonResponse(['article_data' => $query->for($managerArticleId)]);
    }

    public function store(int $managedArticleId, ArticleStoreDataDto $dto)
    {
        // пробуем запросить блокировку
        // если блокировку получили, спокойно редактируем статью
        // если блокировку не получили, возвращаем пользователю ответ о том,
        // что статья пока недоступна для редактирования
    }
}

Нам нужен LockProvider, однако по умолчанию для него нет биндинга в контейнере. Можно заинжектить RedisStore, но в этом случае мы теряем гибкость использования различных провайдеров блокировок и усложняем тестирование. Давайте просто забиндим наш интерфейс в каком-нибудь AppServiceProvider:

$this->app->bind(LockProvider::class, RedisStore::class);

Теперь, если вы настроили redis соединение, вы вольны заинжектить LockProvider. Перепишем контроллер:

use Illuminate\Contracts\Cache\LockProvider;

final class ArticleController
{
    private LockProvider $locks;
    private AuthManager $authManager;
    private Dispatcher $dispatcher;

    public function __construct(LockProvider $locks, AuthManager $authManager, Dispatcher $dispatcher)
    {
        $this->locks = $locks;
        $this->authManager = $authManager;
        $this->dispatcher = $dispatcher;
    }

    public function manage(int $managedArticleId, ManagingArticleQuery $query)
    {
        return new JsonResponse(['article_data' => $query->for($managerArticleId)]);
    }

    public function store(int $managedArticleId, ArticleStoreDataDto $dto)
    {
        $currentUserId = $this->authManager->guard('api')->id();

        // пробуем запросить блокировку
        $lock = $this->locker->lock(
              sprintf('manage_article_%d', $managedArticleId),
              3600,
              sprintf('owner_%d', $currentUserId),
        );

        if (!$lock->get()) {
            // упс, ждите своей очереди
        }

        // если блокировку получили, спокойно редактируем статью
        $this->dispatcher->dispatch(new ProcessArticleStore($managedArticleId, $currentUserId));
    }
}

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

Чтобы освободить блокировку, необходимо ее зарелизить:

final class ProcessArticleStore implements ShouldQueue
{
    public function __construct(int $managedArticleId, int $currentUserId) {}

    public function handle(LockProvider $locks)
    {
        // выполняем все необходимые действия
        $lock = $locks->restoreLock(
                        sprintf('manage_article_%d', $this->managedArticleId),
            sprintf('owner_%d', $this->currentUserId),
        );

        $lock->release();
    }
}

Также при блокировке мы задали время 3600 секунд, что означает, что блокировка автоматически снимется сама по окончанию часа, если мы ее не зарелизили.

Вы могли бы передать в метод lock замыкание, что позволило бы вам не думать о релизе лока, так как фреймворк это сделает автоматически:

$lock = $this->locker->lock(sprintf('manage_article_%d', $managedArticleId));

$lock->get(function () {
    // do something
});

// release!

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

public function get($callback = null)
    {
        $result = $this->acquire();

        if ($result && is_callable($callback)) {
            try {
                return $callback();
            } finally {
                $this->release();
            }
        }

        return $result;
    }

Если вам нужно подождать, прежде чем запросить блокировку, вы можете использовать метод block у лока:

$lock = $locks->lock(sprintf('manage_article_%d', $id));

try {
    $lock->block(5);

    // Можешь здесь работать с данными
} catch (LockTimeoutException $e) {
    // Блокировку взять так и не вышло
} finally {
    optional($lock)->release(); // так или иначе снимаем блокировку
}

Релиз помещаем в блок finally, чтобы однозначно снять блокировку. А еще оборачиваем блокировку в специальную ф-цию optional, которую предоставляет фреймворк. Она следит затем, чтобы мы не вызывали методы у null-объектов. Проще говоря, вы можете дергать любые методы объектов, и если он null, функция просто ничего не сделает. Так не будет работать при чейнинге методов, если только они все не обернуты в optional.

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

loader
17.11.2020 в 18:29
3214
+3
Комментарии
Новый комментарий

Логические задачи с собеседований