Blazor: SPA без джаваскрипта для SaaS на практике
Когда в любой момент времени стало понятно, что такое this... Когда неявное преобразование типов осталось только в былинах аксакалов эпохи зарождения веба... Когда умные книжки по Javascript нашли свой бесславный конец в мусорках...
Всё это случилось когда мир фронтенда спас Он.
Ну ладно, сбавим обороты у нашей машины пафоса.
Сегодня я предлагаю вам взглянуть на возможности Blazor в версии .Net 6. Внезапно, под катом не будет очередного ПриветМир, а окажется полноценное SaaS веб-приложение, написанное на Блейзоре, пощупав которое вы сможете гораздо лучше оценить: убийца или чижика съел?
Приложение
Мы написали приложение-парсер для поиска рекламной аудитории в социальной сети ВКонтакте, где в качестве фронтенда выступает Blazor в клиентском варианте (WebAssembly). Приложение достаточно простое (всего несколько страниц), тем не менее, в нём удалось реализовать следующий функционал:
Аутентификация пользователей
Совместная работа пользователей (тенанты, права доступа)
Валидация в формах ввода
Интеграция с внешними Javascript-компонентами
Использование браузерных API (локальное хранилище, Page Visibility API)
Работающее приложение можно посмотреть здесь: https://app.venando.ru
Для любителей обмазаться, исходники доступны здесь: https://github.com/a-postx/YA.BlazorVkParser
Приступим к препарированию.
Компоненты
Несмотря на то, что технология WebAssembly (WASM) поддерживается большинством браузеров, скорость её распространения пока достаточно скромная. В случае с Blazor, одним из главных тормозов остаётся скудная система компонентов. Но сегодня (конец 2021-го года) на рынке даже сугубо бесплатных компонентов уже появился какой-никакой выбор.
Мы в своём WASM-клиенте воспользовались одним из самых развитых наборов компонент, который не привязан к конкретному визуальному стеку - Blazorise. С помощью него можно очень просто создавать компоненты уже под свои конкретные нужды и эти компоненты будут изменяться, если вы решите сменить визуал, скажем, с Bootstrap на Material.
Например, мы хотим создать диалоговое окно подтверждения действия, которое будет открываться пользователю в самых разных местах приложения. Для этого с помощью компонентов Блейзорайса создаём сам компонент
@using System.ComponentModel
@using YA.WebClient.Application.Interfaces
@inject IJSRuntime JS
@inject IThemeOptionsState ThemeOptions
<Modal @ref="_modal" Closing="@OnModalClosing" ShowBackdrop="true">
<ModalContent Centered="true" Size="ModalSize.Default" Class="@ThemeOptions.ModalContentClass">
<ModalBody MaxHeight="65">
<Field>
@MessageText
</Field>
</ModalBody>
<ModalFooter>
<Button Color="Color.Primary" Block="false" Clicked="@(() => Confirm())">
Да
</Button>
<Button Color="Color.Secondary" Block="false" Clicked="@(() => Hide())">
Отмена
</Button>
</ModalFooter>
</ModalContent>
</Modal>
@code
{
[Parameter]
public string MessageText { get; set; }
[Parameter]
public EventCallback<string> MessageTextChanged { get; set; }
[Parameter]
public Action OnYesAction { get; set; }
[Parameter]
public EventCallback<Action> OnYesActionChanged { get; set; }
// если уже открыто, то выпадает исключение, поэтому контролируем это на нашей стороне
public bool IsShowing { get; set; }
private Modal _modal;
public void Show()
{
if (!IsShowing)
{
_modal.Show();
IsShowing = true;
}
}
public void Hide()
{
if (IsShowing)
{
_modal.Hide();
}
}
private void Confirm()
{
Hide();
OnYesAction?.Invoke();
}
private void OnModalClosing(CancelEventArgs e)
{
IsShowing = false;
}
}
И используем его во всех нужных местах, задав привязку к соответствующим колбекам.
<ActionConfirmationModal @ref="_confirmationModal"
@bind-OnYesAction="_confirmationAction"
@bind-MessageText="_confirmationText" />
@code
{
private ActionConfirmationModal _confirmationModal;
private string _confirmationText = string.Empty;
private Action _confirmationAction = () => Empty();
private void ShowInvitationDeletionConfirmation(InvitationVm invitation)
{
_confirmationAction = async () => await DeleteInvitation(invitation.YaInvitationID);
_confirmationText = $"Вы действительно хотите отменить приглашение в аккаунт для {invitation.Email}?";
_confirmationModal.Show();
}
private void ShowMembershipDeletionConfirmation(MembershipVm membership)
{
_confirmationAction = async () => await DeleteMembership(membership.MembershipID);
_confirmationText = $"Вы действительно хотите удалить доступ {membership.User?.Email} к этому аккаунту?";
_confirmationModal.Show();
}
}
Также просто мы ловим события из дочернего компонента в родительском.
Как вы видите по комментарию в коде, в Blazorise ещё довольно много болячек, но с ними уже можно как-то уживаться.
Несмотря на подобного рода болезни роста, ключевым моментом, который побуждает к использованию Blazor как технологии, остаётся то, что на фронте появляется вся мощь .Net.
Обильный послужной список дотнета в бекенде даёт условному фронтендеру широчайший набор средств по решению тех или иных задач в коде, но когда дело доходит до браузеров, HTML и прочих этих ваших компонентов, то тут уже не всё так радужно.
Безусловно, экосистема Блейзора пока не идёт ни в какое сравнение с джаваскриптовой. Хоть сообщество и активно притаскивает из окружающего мира всё новые компоненты, и дотнетчику в дилемме "взять готовый компонент из сообщества" и "написать компонент самому, погрузившись в Javascript" всё меньше приходится прибегать к последнему варианту, но для сколь-либо масштабного приложения без JS не обойтись.
С другой стороны, в папке js нашего SPA-клиента всего пяток файлов, поэтому в итоге можно сказать, что для написания схожего по функционалу приложения Джаваскрипта уже почти не нужно. В кои-то веке кликбейтный заголовок оказался правдой.
Аутентификация
В нашем проекте хочется обслуживать только аутентифицированных пользователей. Для реализации достаточно в корневом компоненте (App.razor) обрамить приложение в компонент CascadingAuthenticationState, после чего на любой странице (или в компоненте) вы сможете получить текущее состояние аутентификации с помощью проброса параметра AuthenticationState
@code
{
[CascadingParameter]
public Task<AuthenticationState> AuthState { get; set; }
}
Вам остаётся разместить атрибут [Authorize] в файле _Imports.razor соответствующего уровня приложения, чтобы закрыть анонимный доступ ко всем нижележащим страницам. Можно также размещать атрибуты на каждой странице, если у вас будут и страницы для анонимусов.
Сама по себе настройка аутентификации в Blazor очень простая и много где описана. В нашем приложении в качестве поставщика мы выбрали Auth0, поскольку не было желания поддерживать свой сервер токенов и дополнительную логику. Единственный нюанс при использовании Auth0 - необходимость указывать аудиторию вашего бекенд-АПИ в дополнительном параметре AdditionalProviderParameters. Если этого не сделать, то вместо JWT-токена на клиент будет возвращаться токен внутреннего формата Auth0, с помощью которого на ваше серверное приложение аутентифицироваться уже не получится.
"OauthOptions": {
"Authority": "https://yaapp-80.eu.auth0.com",
"ClientId": "OYXLTT9VPs2Ll6w0e6tOI3g4RAT3fZNS",
"MetadataUrl": "https://yaapp-80.eu.auth0.com/.well-known/openid-configuration",
"RedirectUri": "https://app.venando.ru/authentication/login-callback",
"AdditionalProviderParameters": {
"audience": "https://yaapp-prod.ru"
}
}
Состояние
Сервисы в серверной части нашего приложения бессостоятельные, но во фронтальной части без контроля состояния не обойтись. Например, нам надо контролировать какой тарифный план у нашего пользователя в текущем тенанте или какую тему он сейчас выбрал для визуальных элементов.
Поскольку наше приложение достаточно простое и не имеет большого количества изменений состояния из разных компонентов, мы воспользовались простым синглтоном, в котором и стали хранить соответствующее состояние, а также модифицировать его.
public class RuntimeState : IRuntimeState
{
public RuntimeState()
{
}
public event EventHandler<TenantUpdatedEventArgs> TenantUpdated;
private TenantVm _tenant;
private TenantVm Tenant
{
get
{
return _tenant;
}
set
{
if (value != _tenant)
{
_tenant = value;
NotifyTenantUpdated(value);
}
}
}
private void NotifyTenantUpdated(TenantVm tenant)
{
TenantUpdated?.Invoke(this, new TenantUpdatedEventArgs { Tenant = tenant });
}
public TenantVm GetTenant() => Tenant;
public void PutTenant(TenantVm tenant)
{
Tenant = tenant;
}
public void RemoveTenant()
{
Tenant = null;
}
}
Компоненты, которые используют модель арендатора, просто подписываются на событие TenantUpdated и выполняют необходимую для себя логику.
Планируете разрабатывать более развесистое приложение? Отлично, тогда вам в помощь будет редукс-подобный контроль состояния - например, проект Fluxor.
Мультитенант
Когда нам нужно отделить пользователя от сущностей, которыми он оперирует в приложении, то необходимо добавить слой тенанта. Работа пользователей через абстрактного "арендатора" также даст им возможность шарить сущности между собой, что весьма полезно при создании корпоративных приложений и является частой фишкой продвинутых тарифных планов SaaS-сервисов.
Поскольку аутентификация на бекенде нашего приложения работает через JWT-токены, в клиенте мы добавили шаг по созданию идентификатора арендатора при регистрации нового пользователя: клиент дёргает соответствующий метод сервера, после чего переполучает токен.
На клиенте интересная дилемма возникла при добавлении переключения между тенантами: в Блейзоре существует стандартный механизм переопределения удостоверений пользователя, которым можно воспользоваться, чтобы добавить идентификатор арендатора или любые другие атрибуты.
public class CustomUserFactory : AccountClaimsPrincipalFactory<CustomUserAccount>
{
public CustomUserFactory(IAccessTokenProviderAccessor accessor) : base(accessor)
{
_tokenProviderAccessor = accessor;
}
private readonly IAccessTokenProviderAccessor _tokenProviderAccessor;
private readonly JsonSerializerOptions _serializerOptions = new() { PropertyNameCaseInsensitive = true };
public override async ValueTask<ClaimsPrincipal> CreateUserAsync(CustomUserAccount account, RemoteAuthenticationUserOptions options)
{
ClaimsPrincipal user = await base.CreateUserAsync(account, options);
if (user.Identity.IsAuthenticated)
{
ClaimsIdentity identity = (ClaimsIdentity)user.Identity;
AccessTokenResult tokenResult = await _tokenProviderAccessor.TokenProvider.RequestAccessToken();
if (tokenResult.TryGetToken(out AccessToken token))
{
JwtSecurityTokenHandler handler = new();
SecurityToken jsonToken = handler.ReadToken(token.Value);
JwtSecurityToken tokenS = jsonToken as JwtSecurityToken;
Claim metadataClaim = tokenS.Claims.FirstOrDefault(e => e.Type == "http://yaapp.app_metadata");
if (metadataClaim != null)
{
AppMetadata appMetadata = JsonSerializer
.Deserialize<AppMetadata>(metadataClaim.Value, _serializerOptions);
if (!string.IsNullOrEmpty(appMetadata.Tid))
{
identity.AddClaim(new Claim("tid", appMetadata.Tid));
}
}
}
}
return user;
}
}
Однако этот механизм работает только при смене статуса пользователя (напр. с НеАутентифицирован на Аутентифицирован), поэтому для переключения между тенантами пользователю приходится делать дополнительный цикл выхода-входа, что ухудшает пользовательский опыт (в случае OAuth цикл не такой уж быстрый).
Ввиду этой печали для работы с арендаторами было решено отказаться от встроенного механизма удостоверений - идентификатор тенанта просто стал частью состояния приложения, что дало полный контроль над механизмом его переключения. Для этого в корне приложения (App.razor) мы добавили компонент UserAndTenantManager, который и отвечает за подготовку состояния перед тем, как пользователь сможет что-то делать в приложении.
<CascadingAuthenticationState>
<Blazorise.ThemeProvider Theme="@_theme" WriteVariables="true">
@* этап определения пользователя и арендатора, установки состояния.
Из-за асинхронности страницы инициализируются ещё до окончания установки состояния после логина,
поэтому объектам приложения необходимо подписываться на обновления соответствующих данных *@
<UserAndTenantManager>
<Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="@true">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(LoadingLayout)">
<Authorizing>
<AuthorizingInProgress />
</Authorizing>
<NotAuthorized>
@if (context.User.Identity?.IsAuthenticated ?? false)
{
<UserNotAuthorized />
}
else
{
<RedirectToLogin />
}
</NotAuthorized>
</AuthorizeRouteView>
</Found>
<NotFound>
<PageNotFound />
</NotFound>
</Router>
</UserAndTenantManager>
<ToastContainer />
</Blazorise.ThemeProvider>
</CascadingAuthenticationState>
Интеграции
В любом клиентском приложении скорее рано, чем поздно встаёт вопрос интеграции сторонних JS-компонентов и внешних сервисов. В нашем случае потребовалось прикрутить чат и плеер Ютуба.
С плеером всё получилось достаточно просто, благо в мире Джаваскрипт есть VideoJs. Добавляем в наше приложение скрипт плеера, плагина к нему и мааааленький скриптик
<script src="https://vjs.zencdn.net/7.14.3/video.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/videojs-youtube/2.6.1/Youtube.min.js"></script>
<script src="js/videoplayer.js" asp-append-version="true"></script>
Скриптик нужен потому что запуск плеера из Blazor не работает, а вот из JS - работает (не спрашивайте меня почему).
Затем на странице мы просто добавляем элемент плеера и передаём в него необходимые параметры.
<div class="row align-items-center">
<div class="col-8">
<video id="@_playerId" controls class="video-js"></video>
</div>
</div>
@code
{
private string _playerId;
protected override void OnInitialized()
{
_playerId = "home-player";
base.OnInitialized();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await JS.InvokeVoidAsync("loadPlayer", _playerId, new
{
controls = true,
autoplay = false,
preload = "auto",
width = 640,
height = 360,
techOrder = new[] { "youtube" },
sources = new[] {
new { type = "video/youtube", src = "https://www.youtube.com/watch?v=YhrzIDkNYPk" }
},
youtube = new { ytControls = 2 }
});
await base.OnAfterRenderAsync(firstRender);
}
}
Это конечно мало похоже на идеальный код, но что есть то есть.
С чатом повозиться пришлось побольше, но в итоге и он был побеждён. Среди большого количества разработок выбор пал на Чатру. Для добавления чата в наше Blazor-приложение мы традиционно добавляем их скрипт.
<script>
window.ChatraSetup = {
startHidden: true
};
(function (d, w, c) {
w.ChatraID = '9Fff8PkPWdEwP6fAG';
var s = d.createElement('script');
w[c] = w[c] || function () {
(w[c].q = w[c].q || []).push(arguments);
};
s.async = true;
s.src = 'https://call.chatra.io/chatra.js';
if (d.head) d.head.appendChild(s);
})(document, window, 'Chatra');
</script>
Обратите внимание на скрытый виджет при запуске. Мы не хотим, чтобы чат показывался не аутентифицированным пользователям, но при загрузке документа index.html все скрипты оттуда выполняются автоматически, запуская наш чат ещё до того, как запустится WASM. При запуске мы скрываем виджет, а уже после входа пользователя из C# мы дёргаем за ручки управления чатом.
@using YA.WebClient.Application.Interfaces
@using YA.WebClient.Application.Models.ViewModels
@using YA.WebClient.Application.Models.Dto
@using YA.WebClient.Extensions
@using YA.WebClient.Application.Events
@inject IJSRuntime JS
@inject IThemeOptionsState ThemeOptions
@inject IRuntimeState RuntimeState
@implements IDisposable
@code
{
[CascadingParameter]
public Task<AuthenticationState> AuthState { get; set; }
[CascadingParameter]
public UserAndTenantManager UserManager { get; set; }
private TenantVm _userTenant;
private EventHandler<TenantUpdatedEventArgs> _tenantUpdatedHandler;
protected async override Task OnInitializedAsync()
{
TenantVm tenantVm = UserManager.GetTenant();
if (tenantVm != null)
{
_userTenant = tenantVm;
}
_tenantUpdatedHandler = async (s, args) => await RefreshTenantAsync(args);
RuntimeState.TenantUpdated += _tenantUpdatedHandler;
await ShowSupportChat();
await base.OnInitializedAsync();
}
private async Task RefreshTenantAsync(TenantUpdatedEventArgs args)
{
_userTenant = args.Tenant;
StateHasChanged();
await ShowSupportChat();
}
private async Task ShowSupportChat()
{
SupportChatUserInfo userInfo = new SupportChatUserInfo();
if (_userTenant != null)
{
userInfo.TenantId = _userTenant.TenantId.ToString();
userInfo.ТарифныйПлан = _userTenant.PricingTier?.Title;
}
AuthenticationState authState = await AuthState;
userInfo.name = !string.IsNullOrEmpty(authState?.User?.Identity?.Name)
? authState?.User?.Identity?.Name
: null;
await JS.InvokeVoidAsync("Chatra", "setIntegrationData", userInfo);
await JS.InvokeVoidAsync("Chatra", "show");
}
public void Dispose()
{
RuntimeState.TenantUpdated += _tenantUpdatedHandler;
}
}
Chatra также позволяет добавлять свои атрибуты в виджет, поэтому мы передаём туда идентификатор арендатора (компонент обновляет его, если пользователь переключился в другой тенант) и его тарифный план, чтобы агент поддержки не запрашивал у пользователя дополнительную информацию.
Таким образом, в Blazor худо-бедно можно использовать сервисы и код с пометкой "только для Javascript", чтобы брать лучшее из двух миров.
А поясни за скорость загрузки
Демо-приложение https://app.venando.ru лежит в статическом хранилище Azure, поверх которого подключён CDN от Майкрософта, поэтому загрузка должна быть достаточно быстрой как во Владивостоке, так и в Калининграде. Желающие могут самостоятельно оценить насколько внезапность загрузки клиента на Blazor отличается от стремительности на вашем любимом фреймворке.
Итоги
Что можно сказать по итогам этого опыта: писать SPA на .Net очень приятно, его возможности дают разработчику отличные инструменты для создания надёжного кода.
Самая большая боль остаётся в долгой перекомпиляции и запуске приложения под дебагом. Хорошая новость в том, что в .Net 6 и VS2022 появилась горячая перезагрузка, которая заметно улучшает эффективность разработчика при дебаге.
Плохая новость в том, что по части производительности всё ещё есть куда стремиться: представленная AOT-компиляция помогает только ценой сильного раздувания размера приложения, что для публичных одностраничников неприемлемо.
Итак: убийца? Пока нет, но технология из разряда "вот поднатореет..."
Пишите в комментариях, насколько плох или хорош Блейзор, поплачем/посмеёмся вместе.
Напоминаю: рассмотреть детальки можно в https://github.com/a-postx/YA.BlazorVkParser