Меняем веб-сокеты на Livewire

Еще пару дней назад интерфейс нашего сервиса «Oh Dear» работал на веб-сокетах. А сейчас мы заменили их на компоненты Livewire.

В этой статье я объясню, почему и как мы это сделали.

Предположу, что вы уже знаете, что такое Livewire. Если нет, рекомендую почитать документации. Там даже есть небольшой видеокурс. Кратко говоря, Livewire позволяет создавать динамические пользовательские интерфейсы с рендерингом на сервере.

Почему мы отказались от веб-сокетов?

Существуют сотни сервисов проверки аптайма и большинство из них выглядят довольно убого. Когда мы, с моим приятелем Маттиасом, создавали «Oh Dear», то поставили себе цель создать красивый сервис, которым легко пользоваться. Наш сервис не обязательно должен быть уникальным, но имея прекрасные дизайн, UX и документацию, можно опередить большую часть конкурентов.

Одна из вещей, о которой мы сразу договорились, заключалась в том, что интерфейс должен быть рилтаймовый (real-time UI). Мы не хотели, что бы пользователю приходилось обновлять страницу для того, чтобы узнать результаты последних проверок.

Вот как это выглядит в «Oh Dear». После регистрации пользователи заполняют эту форму. При нажатии на ввод сайт добавляется в список, и результаты отображаются сразу же по мере их поступления.

Раньше это всё работало на веб-сокетах. Каждый раз, когда «Oh Dear» начинал и заканчивал проверку, он бродкастил события на фронтенд. Это делалось с использованием встроенной в Laravel функций широковещания и написанного нами пакета Laravel WebSockets. На фронтенде, для получения событий, мы использовали Laravel Echo и нескольких компонентов Vue.

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

Перейдя на Livewire, мы смогли полностью отказаться от всего вышеупомянутого. Для того чтобы обеспечить впечатления от показанного видеоролика, больше нет необходимости ни в сервере веб-сокетов, ни в Echo, ни в Vue. Фактически, в нём уже используется Livewire.

Давайте посмотрим на эти Livewire-компоненты.

Отображение списка сайтов

Вот так выглядит список сайтов.

Список сайтов на компонентах Livewire

А это blade-шаблон, который используется для отображения этого списка.

<x-app-layout title="Dashboard" :breadcrumbs="Breadcrumbs::render('sites')">
    <div class="wrap">
        <section class="card pb-8 cloak-fade" v-cloak>
            @include('app.sites.list.partials.notificationsNotConfigured')

            <livewire:site-list />

            <div class="flex items-center mt-8" id="site-adder">
                <livewire:site-adder />
            </div>
        </section>
    </div>
</x-app-layout>

Итак, у нас есть два Livewire-компонента: site-list и site-adder. В LivewireServiceProvider вы можете увидеть:

namespace App\Providers;

use App\Http\App\Livewire\Sites\SiteListComponent;
use App\Http\App\Livewire\SiteSettings\SiteAdderComponent;
use Illuminate\Support\ServiceProvider;
use Livewire\Livewire;

class LivewireServiceProvider extends ServiceProvider
{
    public function boot()
    {
        Livewire::component('site-list', SiteListComponent::class);
        Livewire::component('site-adder', SiteAdderComponent::class);
        
        // другие компоненты
    }
}

Далее рассмотрим класс SiteListComponent и его базовый шаблон. Сейчас вам не нужно всё это понимать. После исходного кода я объясню некоторые моменты. но, я думаю, хорошо бы сначала увидеть компонент в целом, чтобы иметь некий контекст.

namespace App\Http\App\Livewire\Sites;

use Livewire\Component;
use Livewire\WithPagination;

class SiteListComponent extends Component
{
    use WithPagination;

    protected $listeners = ['siteAdded' => '$refresh'];

    public int $perPage = 30;

    public string $search = '';

    public bool $onlySitesWithIssues = false;

    public bool $showSiteAdder = false;

    public function showAll()
    {
        $this->search = '';

        $this->onlySitesWithIssues = false;
    }

    public function showOnlyWithIssues()
    {
        $this->search = '';

        $this->onlySitesWithIssues = true;
    }

    public function showSiteAdder()
    {
        $this->showSiteAdder = true;
    }

    public function hideSiteAdder()
    {
        $this->showSiteAdder = false;
    }

    public function render()
    {
        return view('app.sites.list.components.siteList', [
            'sites' => $this->sites(),
            'sitesWithIssuesCount' => $this->sitesWithIssuesCount(),
        ]);
    }

    protected function sites()
    {
        $query = currentTeam()->sites()->search($this->search);

        if ($this->onlySitesWithIssues) {
            $query = $query->hasIssues();
        }

        return $query
            ->orderByRaw('(SUBSTRING(url, LOCATE("://", url)))')
            ->paginate($this->perPage);
    }

    protected function sitesWithIssuesCount(): int
    {
        return currentTeam()->sites()->hasIssues()->count();
    }
}

А это шаблон app.sites.list.components.siteList, который который рендерится в функции render:

<div>
    <div wire:poll.5000ms>
        <section class="flex items-center justify-between w-full mb-8">
            <div class="text-xs">
                <div class="switcher">
                    <button wire:click="showAll"
                            class="switcher-button {{ ! $onlySitesWithIssues ? 'is-active' : '' }}">Display all sites
                    </button>
                    <button wire:click="showOnlyWithIssues"
                            class="switcher-button {{ $onlySitesWithIssues ? 'is-active' : '' }}">
                        Display {{ $sitesWithIssuesCount }} {{ \Illuminate\Support\Str::plural('site', $sitesWithIssuesCount) }} with issues
                    </button>
                </div>
            </div>

            <div class="flex items-center justify-end">
                <a href="#site-adder" wire:click="$emit('showSiteAdder')" class="button is-secondary mr-4">Add another site</a>
                <input wire:model="search"
                       class="form-input is-small w-48 focus:w-64"
                       type="text"
                       placeholder="Filter sites..."/>
            </div>
        </section>


        <table class="site-list-table w-full">
            <thead>
            <th style="width: 27%;">Site</th>
            <th style="width: 14%;">Uptime</th>
            <th style="width: 14%;">Broken links</th>
            <th style="width: 14%;">Mixed Content</th>
            <th style="width: 14%;">Certificate Health</th>
            <th style="width: 17%;">Last check</th>
            </thead>
            <tbody>
            @forelse($sites as $site)
                <tr>
                    <td class="pr-2">
                        <div class="flex">
                            <span class="w-6"><i
                                    class="fad text-gray-700 {{ $site->uses_https ? 'fa-lock' : '' }}"></i></span> <a
                                href="{{ route('site', $site) }}"
                                class="flex-1 underline truncate">{{ $site->label }}</a>
                        </div>
                    </td>
                    <td class="pr-2">
                        <x-check-result :site="$site" check-type="uptime"/>
                    </td>
                    <td class="pr-2">
                        <x-check-result :site="$site" check-type="broken_links"/>
                    </td>
                    <td class="pr-2">
                        <x-check-result :site="$site" check-type="mixed_content"/>
                    </td>
                    <td class="pr-2">
                        <x-check-result :site="$site" check-type="certificate_health"/>
                    </td>
                    <td class="text-sm text-gray-700">
                        @if($site->latest_run_date)
                            <a href="{{ route('site', $site) }}">
                                <time datetime="{{ $site->latest_run_date->format('Y-m-d H:i:s') }}"
                                      title="{{ $site->latest_run_date->format('Y-m-d H:i:s') }}">{{ $site->latest_run_date->diffInSeconds() < 60 ? 'Less than a minute ago' : $site->latest_run_date->diffForHumans() }}</time>
                            </a>
                        @else
                            No runs yet
                        @endif
                    </td>
                </tr>
            @empty
                <tr><td class="text-center" colspan="6">There are no sites that match your search...</td></tr>
            @endforelse
            </tbody>
        </table>

        @if ($sites->total() > $perPage)
            <div class="flex justify-between mt-4">
                <div class="flex-1 w-1/2 mt-4">
                    {{ $sites->links() }}
                </div>

                <div class="flex w-1/2 text-right text-muted text-sm text-gray-700 mt-4">
                    <div class="w-full block">
                        Showing {{ $sites->firstItem() }} to {{ $sites->lastItem() }} out of {{ $sites->total() }} sites
                    </div>
                </div>
            </div>
        @endif
    </div>
</div>

Вероятно вы заметили, что в функции render сам компонент отвечает за получение сайтов.

public function render()
{
    return view('app.sites.list.components.siteList', [
        'sites' => $this->sites(),
        'sitesWithIssuesCount' => $this->sitesWithIssuesCount(),
    ]);
}

protected function sites()
{
    $query = currentTeam()->sites()->search($this->search);

    if ($this->onlySitesWithIssues) {
        $query = $query->hasIssues();
    }

    return $query
        ->orderByRaw('(SUBSTRING(url, LOCATE("://", url)))')
        ->paginate($this->perPage);
}

Фильтрация сайтов

Обратите внимание на проверку $this->onlySitesWithIssues. Давайте посмотрим, как устанавливается эта переменная. Если вы посмотрите на скриншот списка сайтов, то увидите небольшой фильтр вверху «Display all sites» (Показать все сайты) и «Display sites with issues» (Показать сайты с проблемами).

Вот эта часть шаблона, которая отрисовывает это.

<div class="switcher">
    <button wire:click="showAll"
            class="switcher-button {{ ! $onlySitesWithIssues ? 'is-active' : '' }}">Display all sites
    </button>
    <button wire:click="showOnlyWithIssues"
            class="switcher-button {{ $onlySitesWithIssues ? 'is-active' : '' }}">
        Display {{ $sitesWithIssuesCount }} {{ \Illuminate\Support\Str::plural('site', $sitesWithIssuesCount) }} with issues
    </button>
</div>

Обратите внимание на wire:click="showOnlyWithIssues". Когда пользователь кликнет на элемент с wire:click, то выполнится метод, имя которого находится в значении атрибута, и компонент будет перерисован. В нашем случае выполняется showOnlyWithIssues.

public function showOnlyWithIssues()
{
    $this->search = '';

    return $this->onlySitesWithIssues = true;
}

Таким образом, здесь изменяется наша переменная onlySitesWithIssues, что приводит к перерисовке нашего компонента. Поскольку onlySitesWithIssues теперь имеет значение true, то метод sites() теперь будет отфильтровывать сайты с проблемами.

if ($this->onlySitesWithIssues) {
    $query = $query->hasIssues();
}

Когда компонент отрендерится, то покажутся только сайты с проблемами. Livewire, под капотом, заботится о многих вещах, в то числе о вызове метода компонента через wire:click и о замене HTML-компонента на странице, при перерисовке.

Поиск сайтов

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

Blade-шаблон строки поиска

<input 
   wire:model="search"
   class="form-input is-small w-48 focus:w-64"
   type="text"
   placeholder="Filter sites..."
/>

Здесь используется еще одна Livewire-команда: wire:model. Она гарантирует, что каждый раз, когда вы печатате что-либо в этом элементе, Livewire обновляет переменную экземпляра с тем же именем в компоненте и перерисовывает его. По дефолту используется поведение debounce, то есть при быстром наборе только один запрос будет выполняться каждые 150 мс.

Таким образом, поскольку переменная search поменялась в компоненте, то он перерисуется. В первой строке метода sites() используется это значение.

$query = currentTeam()->sites()->search($this->search);

Вот так и работает поиск сайтов. Это довольно удивительно, что можно получить такой результат всего лишь добавив livewire:model в свой шаблон и использовать его значение в скоупе вашего запроса. Конечно, это можно сделать и на Vue, но тут это намного проще.

Для полноты картины, вот scopeSearch из модели Site.

public static function scopeSearch(Builder $query, string $searchFor): void
{
    if ($query === '') {
        return;
    }

    $query->where('url', 'like', "%{$searchFor}%");
}

Пагинация сайтов

Laravel изначально имеет отличную поддержку разбивки результатов запроса на страницы. Вам нужно только вызвать paginate в запросе, что мы и сделали в методе sites().

// в методе sites()

return $query
    ->orderByRaw('(SUBSTRING(url, LOCATE("://", url)))')
    ->paginate($this->perPage);

Вызов $sites->links() в шаблоне отобразит URL с параметром page. Под капотом метода paginate() этот параметр используется для получения результатов этой страницы. Так, вкратце, работает пагинация Laravel.

Добавить пагинации в Livewire-компонент очень просто. Нужно просто использовать трейт Livewire\WithPagination. Он добавит немного магии и ссылки пагинации будут содержать не адрес страницы, а внутренние маршруты Livewire. Вот так выглядит вывод $sites()->links() в шаблоне компонента Livewire.

<ul>
    <li class="page-item active" aria-current="page"><span class="page-link">1</span></li>
    <li class="page-item"><a class="page-link" href="https://ohdear.app/livewire/message/site-list?page=2">2</a></li>
    <li class="page-item"><a class="page-link" href="https://ohdear.app/livewire/message/site-list?page=3">3</a></li>
    <li class="page-item"><a class="page-link" href="https://ohdear.app/livewire/message/site-list?page=4">4</a></li>

    <li class="page-item">
        <a class="page-link" href="https://ohdear.app/livewire/message/site-list?page=2" rel="next" aria-label="Next »">›</a>
    </li>
</ul>

Если кликнуть на такую ссылку, то Livewire установит параметр запроса page в адресе и перерисует компонент. Очень элегантное решение и оно отлично вписывается в дефолтную пагинацию Laravel.

Вам, как пользователю Livewire, единственное, что нужно сделать, это применить трейт WithPagination. Прекрасно!

Обновление списка

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

Решение простое: будем опрашивать изменения. Livewire поддерживает polling из коробки. В верхней части шаблона вы могли заметить строку:

<div wire:poll.5000ms>

Это заставит Livewire автоматически обновлять компонент каждые пять секунд. Для повторного рендеринга компонента в браузере используется morphdom. Сравнивается HTML-код компонента с HTML-кодом перерендеренной версии и в DOM обновятся только изменения, что позвляет сделать стоимость повторого рендеринга в браузере очень низкой.

Пока я это набираю этот текст, то уже слышу крики своих читателей: «Фрик, ты чё делаешь?!?! Это же охренеть как неэффективно, он же будет постоянно дергать базу и отправлять запросы», Да, так и есть.

Использование Livewire с пулингом — это компромисс. С одной стороны, да, у нас будет несколько запросов, но только тогда, когда пользователи просматривают свои списки сайтов. Но это будет происходить не так уж и часто. Чтобы узнать состояние своих сайтов, большинство пользователей полагаются на отправляемые нами оповещения (через Slack, Mail, Telegram, …) и они не будут постоянно смотреть своих список сайтов.

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

Всего лишь небольшой пулинг и нам уже не нужно настраивать веб-сокеты или Vue, и не нужно постоянно бродкастить события. Это значительно упрощает наш технический стек. Я думаю, что в данном случае стоит пойти на компромисс.

Заключение

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

Работать с Livewire было очень приятно. Осмысленное API, хорошо написанная документация, возможность легко и просто создавать мощные компоненты за короткое время.

Автор: Freek Van der Herten
Перевод: Алексей Широков

Наш Телеграм-канал — следите за новостями о Laravel.