Estado no SSR do Blazor: TempData, Session e CacheView
Objective
Uma página SSR estática esquece tudo quando a resposta sai: não há circuito, nem
instância de componente, nem campos. Mesmo assim, uma página real precisa de uma
mensagem "salvo" depois de um redirect, de um carrinho que sobrevive a um clique, de
um filtro que sobrevive a um reload. Este concept compara os cinco lugares onde o
estado pode morar em um app Blazor com SSR estático (a query string, o formulário
postado, o TempData, a sessão e a saída de um bloco em cache) e o que o .NET 11
acrescentou: [SupplyParameterFromTempData], [SupplyParameterFromSession],
ITempData e o componente CacheView. Tudo abaixo foi executado no .NET 11.0.100
GA com WebApplicationFactory, lendo o HTML e os headers Set-Cookie. Não havia
navegador, então o que só acontece no cliente é atribuído à documentação.
Use Cases
- Uma mensagem "salvo" depois de Post/Redirect/Get, mostrada uma vez e que some ao atualizar.
- Um carrinho ou o passo de um wizard guardado por visitante sem linha no banco.
- Um fragmento lento e compartilhado (caixa de notícias, menu) renderizado uma vez e servido da memória nas próximas requisições.
- Escolher entre cookie e servidor para guardar estado, sabendo o custo de cada um.
Deep Dive
Cinco lugares, uma pergunta: quem é dono do valor e por quanto tempo
| Mecanismo | Mora em | Duração | Uso típico |
|---|---|---|---|
Query string, [SupplyParameterFromQuery] |
a URL | enquanto o link existir | filtros, paginação |
Formulário, [SupplyParameterFromForm] |
o corpo da requisição | uma requisição | o próprio envio |
| TempData | um cookie (ou a sessão) | até ser lido uma vez | mensagem flash após redirect |
| Session | memória do servidor mais um cookie com o id | até expirar | carrinho, wizard |
CacheView |
memória do servidor, compartilhada | até expirar | um fragmento caro |
[PersistentState] não está na tabela de propósito: ele leva um valor do prerender
para a instância interativa da mesma página, que é outro problema. Veja
blazor-data-access-and-streaming-rendering. As linhas de formulário e query estão
em blazor-static-ssr-and-enhanced-navigation e blazor-routing-layouts-and-navigation;
o resto deste concept trata das três novidades.
TempData: uma mensagem flash através do redirect
A página abaixo grava no handler do POST e redireciona para si mesma. A propriedade é preenchida pelo TempData no GET seguinte:
plaintext@page "/flash" @inject NavigationManager Nav <p id="message">@Shown</p> <EditForm Model="Model" FormName="flash" OnValidSubmit="Submit" Enhance> <InputText @bind-Value="Model.Text" /> <button type="submit">Save</button> </EditForm> @code { [SupplyParameterFromTempData] private string? Message { get; set; } [SupplyParameterFromForm] private FlashModel Model { get; set; } = default!; private string? Shown; protected override void OnInitialized() { Model ??= new(); Shown = Message; Message = null; // consome o valor, veja abaixo } private void Submit() { Message = $"saved {Model.Text}"; Nav.NavigateTo("flash"); } }
Postando Text=hello pelo WebApplicationFactory:
textPOST /flash 302 Location: /flash Set-Cookie: .AspNetCore.Components.TempData=CfDJ8...; path=/; samesite=lax; httponly GET /flash <p id="message">saved hello</p> GET /flash <p id="message"></p>
O cookie é HttpOnly, SameSite=Lax e o valor começa com CfDJ8, o prefixo de um
payload do Data Protection, então o TempData usa o key ring descrito em
data-protection-key-ring-and-purposes (em um cluster, todos os nós precisam do
mesmo anel). Um segundo cliente, com outro conjunto de cookies, viu a mensagem
vazia: o valor pertence a um navegador.
Dois detalhes me custaram tempo. Primeiro, ler a propriedade não apaga o valor:
na minha primeira versão, sem Message = null, três GETs seguidos mostraram
saved a. Atribuir null depois da leitura é o que torna o valor de leitura única
com o atributo. Segundo, o serviço ITempData (um cascading parameter) funciona ao
contrário, e é a API que já traz a regra de leitura única:
plaintext[CascadingParameter] public ITempData? TempData { get; set; } // Peek("flash") lê sem remover // Get("flash") lê e agenda a remoção // Keep() / Keep("flash") mantém por mais uma requisição
Execução: depois que o handler gravou TempData["flash"] = "manual x" e
redirecionou, o primeiro GET leu manual x tanto com Peek quanto com Get, o
segundo GET não leu nada e a resposta limpou o cookie (expires=Thu, 01 Jan 1970).
Use o atributo no caso simples e ITempData quando precisar de Peek ou Keep.
Onde o TempData fica guardado é uma configuração:
RazorComponentsServiceOptions.TempDataProviderType aceita Cookie (o padrão) e
SessionStorage, e TempDataCookie ajusta o cookie. Troquei para SessionStorage
e a resposta do POST deixou de trazer o cookie do TempData; o valor ficou na sessão
sob a chave __BlazorTempData como
{"flash":{"type":"System.String","value":"manual x"}}. A opção de cookie mantém o
servidor sem estado e é limitada pelo tamanho de cookie do navegador; a opção de
sessão exige um armazenamento de sessão que todos os nós alcancem.
Repare também no warning do compilador ao escrever a página: o BL0008 avisa que
[SupplyParameterFromForm] com inicializador de propriedade pode ser sobrescrito
com null em um post. Use default! e Model ??= new() em OnInitialized, como
acima.
Session: o carrinho que sobrevive
[SupplyParameterFromSession] lê e grava um valor em HttpContext.Session. Ele
precisa dos serviços e do middleware de sessão, que não fazem parte de
AddRazorComponents():
csharpbuilder.Services.AddDistributedMemoryCache();
builder.Services.AddSession();
// ...
app.UseSession();
app.MapRazorComponents<App>();plaintext[SupplyParameterFromSession] private string? Items { get; set; } private void Add() { Items = string.IsNullOrEmpty(Items) ? Model.Item : $"{Items},{Model.Item}"; }
Dois POSTs (apple, pear) e depois dois GETs mostraram apple,pear nas duas
vezes, e um cliente novo viu empty. O que o atributo gravou na sessão não é uma
string simples:
textitems={"type":"System.String","value":"apple"}
A chave saiu como items, em minúsculas, e o valor é um pequeno envelope JSON.
Então Session.SetString("Items", "manual") em outro endpoint criou uma segunda
chave sem relação e a página continuou mostrando apple. Se outro código precisa
compartilhar o valor, passe pelo mesmo atributo ou pelo mesmo formato, ou leia com
ISession usando a chave e o envelope exatos que você viu. A sessão em si é a
usual do ASP.NET Core: um cookie com um id e os dados em IDistributedCache.
AddDistributedMemoryCache() é por processo, então mais de uma instância precisa
de um cache compartilhado, como Redis (veja caching-imemorycache-hybridcache-and-distributed).
CacheView: cachear um bloco da página
O CacheView renderiza o conteúdo filho uma vez e serve o HTML guardado depois.
Segundo a documentação de referência, em um acerto os componentes filhos nem são
instanciados nem renderizados. Ele varia pelo que você listar:
plaintext<CacheView VaryByQuery="category" ExpiresAfter="TimeSpan.FromMinutes(5)"> <p id="stamp">@DateTime.UtcNow.Ticks</p> <p id="category">@(Category ?? "all")</p> </CacheView>
Requisições com DateTime.UtcNow.Ticks como sonda:
text/news 639300741948323260 all /news 639300741948323260 all (mesmos ticks: servido do cache) /news?category=a 639300741948368530 a /news?category=a 639300741948368530 a (em cache) /news?category=b 639300741948375460 b (entrada nova)
As outras propriedades VaryBy* na referência 11.0.0 são VaryByRoute,
VaryByHeader, VaryByCookie, VaryByUser, VaryByCulture e uma string livre
VaryBy, além de Enabled, CacheKey, ExpiresAfter, ExpiresOn e
ExpiresSliding. O armazenamento é em memória por padrão, com um limite de tamanho
(CacheViewSizeLimit, 100 MB por padrão segundo a documentação); se um HybridCache
estiver registrado ele é usado no lugar, e então a expiração deslizante não é
suportada. Não exercitei esses últimos pontos. É uma ferramenta diferente do output
caching (output-caching-and-http-caching): aquele cacheia uma resposta inteira por
política, este cacheia uma parte da página dentro da árvore de componentes.
Adições menores e o lado CSRF de tudo isso
<BasePath /> renderiza <base href="/" /> a partir do base path do app, no lugar
da tag escrita à mão (execução: a saída foi exatamente <base href="/" />). As
notas de versão também listam EnvironmentView e DisplayName; não executei esses
dois.
Todo padrão aqui começa com um POST, então a regra de CSRF importa. Sem
UseAntiforgery() no pipeline, um POST para uma página se comportou assim no GA:
textsem Sec-Fetch-Site, sem Origin 302 Sec-Fetch-Site: same-origin 302 Sec-Fetch-Site: cross-site 400 Origin: http://evil.example 400 Sec-Fetch-Site: same-origin + Origin: http://localhost 302
É a checagem automática descrita em csrf-protection-antiforgery-tokens-and-fetch-metadata
e whats-new-in-aspnet-core-11, e ela protege os formulários de flash e de carrinho
sem token. Um cliente que não envia nenhum header de navegador (como o meu cliente de
teste) passa.
Trade-offs
- Um cookie é limitado e viaja em toda requisição. TempData em cookie não tem
estado no servidor, mas é limitado pelo tamanho do cookie, então mantenha as
mensagens curtas e objetos de fora.csharp
Message = "saved"; // um id ou um texto curto, não um modelo inteiro - O atributo não torna o TempData de leitura única sozinho. Ler a propriedade
deixa o valor no lugar; atribua
nulldepois da leitura ou useITempData.Get.csharpShown = Message; Message = null; - A sessão é por visitante, mas custa memória e um armazenamento compartilhado.
Funciona de imediato em uma instância com
AddDistributedMemoryCachee perde dados em silêncio em uma segunda instância se o cache não for compartilhado. - A chave e o formato da sessão pertencem ao atributo. Código que grava a mesma
chave à mão com
SetStringnão será lido de volta, e o contrário também. - O
CacheViewé compartilhado entre usuários a menos que você faça variar. Um bloco com qualquer dado pessoal deve variar por usuário (VaryByUser) ou ficar fora do cache, e dado velho é o preço da expiração escolhida.plaintext<CacheView VaryByUser="true" ExpiresAfter="TimeSpan.FromMinutes(1)">...</CacheView> - Este é estado de SSR estático. Componentes interativos têm o circuito para estado vivo; essas ferramentas existem porque páginas estáticas não têm.