RE:NODE

Приложения11 мин чтения

Blazor Server или WebAssembly: во что обходится хостинг каждого

Чем отличаются цепи Blazor Server и Blazor WebAssembly на хостинге: память на пользователя, SignalR, задержка, размер загрузки, пререндеринг и режимы Blazor Web App.

0 прочтений

Blazor Server выполняет ваши компоненты на сервере и отправляет обновления интерфейса в браузер через соединение SignalR: каждая открытая вкладка - это живая цепь (circuit), хранящая состояние в памяти сервера, каждый клик - это путь туда и обратно, а память сервера растёт с числом одновременных пользователей, а не с числом запросов. Blazor WebAssembly загружает runtime .NET и ваше приложение в браузер и выполняет их там: сервер отдаёт только статические файлы и API, но первый визит стоит загрузки в несколько мегабайт, и ничто на клиенте не может быть секретным. Начиная с .NET 8 больше не нужно выбирать что-то одно для всего приложения - модель Blazor Web App по умолчанию рендерит страницы статически на сервере и позволяет каждому компоненту выбрать интерактивность Server, WebAssembly или Auto.

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

Режимы рендеринга в одной таблице#

Blazor Web App (шаблон по умолчанию начиная с .NET 8) поддерживает четыре способа рендерить компонент:

Режим рендерингаГде выполняется кодИнтерактивностьЗатраты сервера на посетителя
Статический серверный (SSR)Сервер, на каждый запросНет (только формы и ссылки)Обычный HTTP-запрос
InteractiveServerСервер, в цепиДаЖивая цепь на весь визит
InteractiveWebAssemblyБраузерДаСтатические файлы, затем вызовы API
InteractiveAutoСначала сервер, потом браузерДаЦепь, пока runtime не закэширован

Статический SSR легко упустить из виду, а он часто и есть правильный ответ. Странице, которая показывает данные и отправляет форму, интерактивность вообще не нужна; она рендерится как Razor Page и ничего не стоит серверу после отправки ответа. Улучшенная навигация делает переходы между статическими страницами похожими на одностраничное приложение, а [StreamRendering] позволяет медленной странице сначала отправить макет, а данные - когда они будут готовы.

Интерактивность задаётся для отдельного компонента или для всего приложения:

Program.cs
builder.Services.AddRazorComponents()    .AddInteractiveServerComponents()    .AddInteractiveWebAssemblyComponents();app.MapRazorComponents<App>()    .AddInteractiveServerRenderMode()    .AddInteractiveWebAssemblyRenderMode()    .AddAdditionalAssemblies(typeof(Client._Imports).Assembly);

Затем компонент объявляет @rendermode InteractiveServer (или один из других режимов), и за интерактивность платят только этот компонент и его дочерние. Старых отдельных шаблонов Blazor Server и WebAssembly «ASP.NET Core hosted» начиная с .NET 8 больше нет; существующие приложения на их основе продолжают работать.

Как работает Blazor Server#

Когда загружается страница с интерактивным серверным компонентом, браузер открывает обратно к серверу соединение SignalR, предпочтительно через WebSocket. Сервер создаёт цепь: экземпляр каждого интерактивного компонента на странице, их состояние, дерево рендеринга и scoped-контейнер внедрения зависимостей. С этого момента:

  1. Пользователь нажимает кнопку. Браузер отправляет событие по соединению.
  2. Сервер выполняет обработчик события, заново рендерит затронутые компоненты и вычисляет разницу с последним рендером.
  3. Сервер отправляет разницу обратно, и браузер обновляет DOM.
SignalRобработчики событийВкладка браузераblazor.web.jsОбратный проксиапгрейд до WebSocketЦепьсостояние компонентовБаза данныхзапросы
Один клик в приложении Blazor Server

Вот почему Blazor Server ощущается как написание настольного приложения: код может обращаться к базе данных напрямую, слой API строить не нужно, и ничего чувствительного никогда не попадает в браузер. И вот почему его затраты не похожи на затраты обычного веб-приложения. Цепь живёт, пока открыта вкладка, делает пользователь что-нибудь или нет, и scoped-сервисы в ней - включая DbContext EF Core, если вы внедряете его напрямую, - живут столько же.

Память и CPU на пользователя#

Собственные рекомендации Microsoft называют минимум примерно в 250 КБ памяти сервера на подключённого пользователя для очень простого приложения. Реальные приложения держат гораздо больше: поля каждого компонента, все загруженные в них данные, таблицы с тысячами строк и scoped-сервисы. Цепь, которая держит таблицу на 5000 строк с данными, измеряется мегабайтами и держит их весь визит.

Грубый способ оценки: измерьте одну цепь. Откройте приложение в одной вкладке, запомните график памяти, откройте ещё двадцать вкладок на самой тяжёлой странице и разделите прирост на двадцать. Затем умножьте на число людей, у которых, по вашим ожиданиям, приложение будет открыто в один и тот же момент, - не за день, а одновременно.

Одновременных пользователейЛёгкие страницы (около 0,5 МБ каждая)Тяжёлые страницы (около 5 МБ каждая)
2010 МБ100 МБ
200100 МБ1 ГБ
1 000500 МБ5 ГБ

Эти цифры на пользователя иллюстративны, а не измерены на вашем приложении; суть в форме зависимости. Приложение Blazor Server, которое прекрасно справляется с целым офисом пользователей, может не подойти для публичного сайта, куда одновременно могут прийти тысячи. Базовая память самого процесса сверх цепей описана в статье память и сборка мусора в .NET.

Параметры цепи, которые это ограничивают:

Настройка CircuitOptionsПо умолчаниюЧто она делает
DisconnectedCircuitRetentionPeriod3 минутыСколько оборванная цепь хранится для переподключения
DisconnectedCircuitMaxRetained100Сколько оборванных цепей хранится одновременно
JSInteropDefaultCallTimeout1 минутаТаймаут вызовов в JavaScript
MaxBufferedUnacknowledgedRenderBatches10Пакеты рендеринга в очереди для медленного клиента
DetailedErrorsfalseОтправлять подробности исключений в браузер - только для разработки

По возможности не держите данные в компонентах: загружайте страницу результатов, а не всю таблицу. Используйте Virtualize для длинных списков, чтобы рендерились только видимые строки. Создавайте DbContext на каждую операцию через IDbContextFactory<T>, а не внедряйте один на всё время жизни цепи.

CPU обычно меньшая проблема, потому что большинство событий дешёвые. Он становится важен, когда многие пользователи одновременно запускают дорогие рендеры: дашборд, который перерисовывается по таймеру для каждого подключённого пользователя, - нагрузка, растущая вместе с аудиторией, даже когда никто ничего не трогает.

Задержка и соединение#

Каждое взаимодействие в Blazor Server ждёт сеть. При 20 мс между пользователем и сервером кнопка срабатывает мгновенно. При 150 мс ввод в поле, которое перерисовывается на каждое нажатие клавиши, кажется вязким, а drag-and-drop - сломанным. Если ваши пользователи на другом континенте относительно сервера, протестируйте оттуда, прежде чем выбирать режим Server. Что эти цифры значат на практике, объясняет статья задержка, джиттер и потеря пакетов.

Соединение к тому же должно выживать. Blazor Server нужно постоянное соединение через каждый прокси между браузером и Kestrel:

  • WebSocket должен проходить апгрейд. Если не может, SignalR откатывается на long polling, который работает, но замедляет каждое событие и стоит больше запросов. Когда WebSocket работает, вкладка сети в браузере показывает ответ 101 Switching Protocols.
  • Таймауты простоя на прокси должны быть длиннее keep-alive у SignalR, который по умолчанию отправляет ping каждые 15 секунд.
  • Приложение должно видеть настоящего клиента и схему через проксированные заголовки - см. статью ASP.NET Core за обратным прокси.

Когда соединение обрывается, браузер показывает оверлей переподключения и пробует снова. Если переподключиться удаётся в пределах периода хранения, цепь продолжает с того же места. Если нет, состояние потеряно, и пользователю придётся перезагрузить страницу. Мобильные пользователи, переключающиеся между сетями, и ноутбуки, просыпающиеся из сна, сталкиваются с этим постоянно; в .NET 9 и 10 переподключение заметно улучшили, а .NET 10 добавляет способ сохранять состояние цепи, чтобы пользователь мог продолжить после более долгого перерыва. Проверьте, что поддерживает ваша версия, прежде чем на это полагаться.

Как работает Blazor WebAssembly#

Режим WebAssembly отправляет ваше приложение в браузер. Первый визит загружает runtime .NET, скомпилированный в WebAssembly, используемые приложением сборки фреймворка и ваши собственные сборки, а затем выполняет всё локально. После этого браузер кэширует файлы, и последующие визиты запускаются быстро.

Компромиссы зеркальны режиму Server:

  • Загрузка. Опубликованное приложение обрезается (для WebAssembly trimming включён по умолчанию) и сжимается, но рассчитывайте на объём первой загрузки, измеряемый мегабайтами, а не килобайтами: небольшое приложение занимает где-то 2-4 МБ в сжатом виде, более крупное - больше. На медленном мобильном соединении это секунды экрана загрузки.
  • Никаких секретов. Всё на клиенте пользователь может прочитать. Строкам подключения, API-ключам и бизнес-правилам, которые нельзя обойти, место на сервере, за API.
  • Скорость выполнения. По умолчанию ваш код интерпретируется runtime WebAssembly. Компиляция ahead-of-time (<RunAOTCompilation>true</RunAOTCompilation>, для сборки нужен workload wasm-tools) делает код, нагружающий CPU, гораздо быстрее, а загрузку - значительно больше.
  • Затраты сервера. Для интерфейса почти никаких. Сервер отдаёт статические файлы один раз на пользователя, а затем отвечает на вызовы API, которые масштабируются как любой другой API.

Хостинг приложения WebAssembly#

Отдельное приложение Blazor WebAssembly (шаблон blazorwasm) публикуется в папку статических файлов под wwwroot, и отдавать её может любой статический хостинг. Работает ли оно, зависит от трёх деталей:

  1. Резервная маршрутизация. Blazor обрабатывает маршруты в браузере, так что запрос /orders/42 должен возвращать index.html, а не 404. Статическому хостингу нужно правило перезаписи для неизвестных путей; как его добавить, зависит от сервера. Частые случаи разобраны в статье хостинг статических сайтов.
  2. Сжатие и MIME-типы. Публикация создаёт заранее сжатые копии .br и .gz каждого файла. Отдача этих копий и отдача .wasm как application/wasm - это разница между первой загрузкой в 3 МБ и в 10 МБ.
  3. Заголовки кэширования. Файлы runtime снабжены отпечатками в именах, поэтому их можно кэшировать надолго; index.html кэшировать нельзя, иначе после деплоя пользователи продолжат загружать старые версии. Обоснование - в статье заголовки HTTP-кэширования простыми словами.

API, который вызывает клиент, - отдельное приложение ASP.NET Core. Если оно на другом origin, чем статические файлы, настройте CORS на API. В Blazor Web App с компонентами InteractiveWebAssembly сервер ASP.NET Core отдаёт и клиентские файлы, и API с одного origin, что полностью избавляет от CORS и является самой простой схемой для хостинга.

Пререндеринг и двойная загрузка#

Интерактивные компоненты в Blazor Web App по умолчанию пререндерятся: сервер один раз рендерит HTML, чтобы страница появилась сразу и поисковые системы могли её прочитать, затем компонент запускается снова в интерактивном режиме и рендерится второй раз. Поэтому любая загрузка данных в OnInitializedAsync выполняется дважды - один раз при пререндеринге и ещё раз, когда управление берёт цепь или runtime WebAssembly.

Справиться с этим можно двумя способами:

  • Сохранить состояние между двумя рендерами. PersistentComponentState (начиная с .NET 8) сериализует данные из пререндера в страницу и передаёт их интерактивному рендеру. .NET 10 добавляет декларативный атрибут [PersistentState] на свойство компонента, который делает то же самое с меньшим количеством кода.
  • Выключить пререндеринг для компонентов, где первая отрисовка не важна, через @rendermode @(new InteractiveServerRenderMode(prerender: false)). Тогда страница ничего не показывает на месте этого компонента, пока не запустится интерактивность.

Пререндеринг также означает, что код в OnInitializedAsync выполняется на сервере даже для компонента WebAssembly, поэтому он не должен предполагать, что находится в браузере, - никаких вызовов IJSRuntime там.

Деплои, перезапуски и потерянные цепи#

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

Поэтому для приложения Blazor Server: сохраняйте пользовательский ввод часто, деплойте в спокойное время и не используйте цепь как хранилище. Приложения WebAssembly гораздо снисходительнее: интерфейс продолжает работать в браузере, пока API перезапускается, и неудачными оказываются только вызовы, сделанные в этот промежуток. Чего можно и чего нельзя избежать с одним экземпляром, описано в статье деплой без простоя на небольшом сервере.

Ещё одно, что должно переживать перезапуск, - ключи Data Protection, используемые для antiforgery-токенов и cookie аутентификации. Если их сгенерировать заново, разлогинится каждый пользователь. Где их хранить, показано в статье деплой приложения ASP.NET Core с GitHub.

Как выбрать#

  • В основном контент и формы: статический SSR, с интерактивностью только у тех компонентов, которым она нужна. Самый дешёвый вариант для хостинга, с большим отрывом.
  • Внутренние инструменты и админки с десятками пользователей рядом с сервером: InteractiveServer. Не нужно строить API, быстрая первая загрузка, а память в таком масштабе не проблема.
  • Публичные приложения с множеством одновременных пользователей или пользователями далеко от сервера: InteractiveWebAssembly с API. Сервер масштабируется по запросам, а не по открытым вкладкам.
  • Нужна быстрая первая загрузка, а после неё WebAssembly: InteractiveAuto, с тем условием, что теперь вы пишете компоненты, которые должны работать в обоих местах.

Разобранный пример делает это разделение наглядным. У сайта бронирования спортивного клуба есть публичное расписание, форма бронирования и экран для персонала, где управляют кортами. Расписание - статический SSR с потоковым рендерингом: его читают гораздо чаще, чем всё остальное, и держать его открытым ничего не стоит. Форма бронирования - тоже статический SSR: обычной отправке формы с валидацией цепь не нужна. Экран персонала, которым пользуются четыре человека в собственной сети клуба, - InteractiveServer: богатый, мгновенный, работающий прямо с базой данных. В итоге всё спокойно работает на небольшом тарифе, потому что существуют только цепи тех четырёх людей, которым они приносят пользу. Тот же сайт, целиком построенный в режиме Server, держал бы цепь для каждого члена клуба, который в субботу утром смотрит расписание.

FAQ#

Сколько пользователей выдержит приложение Blazor Server на 2 ГБ?

Это целиком зависит от того, что держит каждая цепь. Лёгкое приложение с полумегабайтом на пользователя вместит несколько сотен одновременных пользователей помимо самого процесса; приложение, нагруженное данными, может справиться с несколькими десятками. Измерьте одну цепь и умножьте.

Нужен ли Blazor WebAssembly вообще .NET-сервер?

Для интерфейса - нет. Отдельное приложение WebAssembly - это статические файлы, и их может отдавать любой статический хостинг. .NET-сервер нужен только для API, который оно вызывает, а он у вас почти всегда будет.

Почему моё приложение Blazor Server отключается каждую минуту?

Таймаут простоя на прокси короче интервала keep-alive, или WebSocket не проходит, и резервный транспорт постоянно упирается в таймаут. Посмотрите во вкладке сети, какой транспорт используется.

Можно ли масштабировать Blazor Server на несколько серверов?

Да, с sticky sessions, чтобы соединение каждого пользователя всегда попадало на сервер, где хранится его цепь. На одном сервере этот вопрос не возникает; масштабирование для обычного SignalR описано в статье приложения реального времени на SignalR.

InteractiveAuto - это лучшее из обоих миров?

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


Комментарии

Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.

0/2000