如何在服务器端 Blazor 中存储会话数据

53
在服务器端Blazor应用程序中,我想存储一些在页面导航之间保留的状态。我该怎么做?
正常的ASP.NET Core会话状态似乎不可用,因为最可能适用于Session and app state in ASP.NET Core中的以下注释:
“会话不支持SignalR应用程序,因为SignalR Hub可以独立于HTTP上下文执行。例如,当长轮询请求由hub保持超出请求的HTTP上下文的生命周期时,就会发生这种情况。”
GitHub问题Add support to SignalR for Session提到,您可以使用Context.Items。但是我不知道如何使用它,即我不知道如何访问HubConnectionContext实例。
我的会话状态选项有哪些?

2
您可以在 DI 中注册一个作用域对象以跟踪状态。 - jazb
2
你确定它能工作吗?https://blazor.net/docs/dependency-injection.html页面上说:*Blazor目前没有DI范围的概念。Scoped的行为类似于Singleton。因此,最好使用Singleton并避免使用Scoped。* - Codo
不确定 - 我觉得我把应用程序状态搞混了。 - jazb
3
我已经测试了带有作用域的DI。它的行为不像一个单例。因此,这个描述可能是指客户端 Blazor。然而,它只持续了很短的时间,类似于请求的持续时间。在从一页导航到另一页时传递数据已经足够了。但在那之后,数据就会丢失。 - Codo
1
@JohnB:经过更多测试,我发现作用域 DI 对于会话状态基本上是有效的。它的生命周期比我最初想象的要长。它与 SignalR 连接绑定在一起,并且只要您不重新加载页面或手动修改 URL,它就会保持活动状态。所以这是一个开始,但仍然远远不及其他系统提供的功能。 - Codo
显示剩余3条评论
9个回答

17

注意:此回答来自 2018 年 12 月,当时早期版本的 Server-side Blazor 可用。现在可能已不再相关。

贫民的状态解决方案是由 @JohnB 暗示的:使用 scoped 服务。在服务器端 Blazor 中,作用域服务与 SignalR 连接绑定。这是您可以获得的最接近会话的东西。它肯定是私有的,仅适用于单个用户。但是也很容易丢失。重新加载页面或修改浏览器地址列表中的 URL 将启动新的 SignalR 连接,创建新的服务实例,并因此丢失状态。

因此,首先创建状态服务:

public class SessionState
{
    public string SomeProperty { get; set; }
    public int AnotherProperty { get; set; }
}

然后在 App 项目的 Startup 类中配置该服务(而不是服务器项目):

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddScoped<SessionState>();
    }

    public void Configure(IBlazorApplicationBuilder app)
    {
        app.AddComponent<Main>("app");
    }
}
现在您可以将状态注入到任何 Blazor 页面中:
@inject SessionState state

 <p>@state.SomeProperty</p>
 <p>@state.AnotherProperty</p>

非常欢迎更好的解决方案。


@FranzHuber:我已经放弃了Blazor,可能现在有更好的解决方案。服务器端Blazor对于安全敏感的应用程序非常重要,因为它将敏感数据保留在服务器端,例如JWT身份验证令牌。但是,如果您像Microsoft的那个人一样在浏览器端存储状态,使用Blazor Browser Storage包,那么您就放弃了Blazor的一个主要优势。 - Codo
@FranzHuber23:我不能告诉你,因为我已经不再更新了。我怀疑它仅适用于ASP.NET,而不适用于Blazor。 - Codo
请查阅以下存储库以获取服务器端会话实现: https://github.com/alihasan94/BlazorSessionApp - Ali Hasan
所以考虑一下,“会话实际上是什么?”我认为没有必要更加复杂化它,只需将数据存储在SessionOrWhatEverElse对象中。@AliHasan你的例子与Codo的回复相同,但稍微扩展了一下。 - Kamil
使用 AddScoped<SessionState> 后,当浏览器刷新时,SessionState 将会被重新创建。 - Omu
显示剩余2条评论

12
这里提供了ASP.NET Core 5.0+的相关解决方案(ProtectedSessionStorageProtectedLocalStorage),请参考以下链接: https://learn.microsoft.com/en-gb/aspnet/core/blazor/state-management?view=aspnetcore-5.0&pivots=server 以下是一个例子:
@page "/"
@using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage
@inject ProtectedSessionStorage ProtectedSessionStore

User name: @UserName
<p/><input value="@UserName" @onchange="args => UserName = args.Value?.ToString()" />
<button class="btn btn-primary" @onclick="SaveUserName">Save</button>

@code {
    private string UserName;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        await base.OnAfterRenderAsync(firstRender);
        if (firstRender)
        {
            UserName = (await ProtectedSessionStore.GetAsync<string>("UserName")).Value ?? "";
            StateHasChanged();
        }
    }
    
    private async Task SaveUserName() {
        await ProtectedSessionStore.SetAsync("UserName", UserName);
    }
}

请注意,此方法存储的数据是加密的。


2
ProtectedSessionStorage和ProtectedLocalStorage非常好,它们不会将数据保存为明文,并使用加密/解密来保存在浏览器存储中。我不知道为什么有人还考虑使用其他东西。 - Ali Poustdouzan
1
这看起来非常类似于 Chris Sainty 的“Blazored.SessionStorage”,除了它保持数据加密并且来自 Microsoft(不需要 nuget)。 - adinas
@AliPoustdouzan 你想使用其他东西的原因很简单。安全。你知道任何注入的JS代码或浏览器扩展都可以读取本地和会话存储。我需要将用户凭据存储在我的Blazor应用程序中,以便从另一个系统获取信息。将其存储在本地或会话中意味着数据可能会被窃取,从而会话也可能被用户窃取。这就是为什么应该使用HTTP only cookies。它可以被任何注入的脚本或浏览器扩展窃取。 - K-Dawg
这个能在多个标签页中工作吗?在我的情况下不起作用。请分享您的反馈。 - Safee

11

Steve Sanderson深入讲解了如何保存状态。

对于服务器端的Blazor,您需要使用任何JavaScript中的存储实现,例如cookies、查询参数或者您可以使用本地/会话存储

目前有NuGet包通过IJSRuntime实现,例如BlazorStorageMicrosoft.AspNetCore.ProtectedBrowserStorage

现在棘手的部分是,服务器端的Blazor是预渲染页面,因此您的Razor视图代码将在服务器上运行和执行,甚至在显示给客户端浏览器之前。这会导致一个问题,即在此时IJSRuntime和因此localStorage不可用。 您需要禁用预渲染或等待服务器生成的页面发送到客户端浏览器并建立与服务器的连接

在预渲染期间,没有与用户浏览器的交互式连接,浏览器还没有任何可以运行JavaScript的页面。因此,此时无法与localStorage或sessionStorage进行交互。如果尝试,您将收到类似于JavaScript互操作调用此时无法发出的错误。这是因为该组件正在进行预渲染。

要禁用预渲染:

当您想保留预渲染时:打开_Host.razor文件,删除对Html.RenderComponentAsync的调用。然后,打开Startup.cs文件,将对endpoints.MapBlazorHub()的调用替换为endpoints.MapBlazorHub<App>("app"),其中App是您的根组件类型,"app"是指定根组件应放置在文档中哪个CSS选择器中的选择器。
@inject YourJSStorageProvider storageProvider

    bool isWaitingForConnection;

    protected override async Task OnInitAsync()
    {
        if (ComponentContext.IsConnected)
        {
            // Looks like we're not prerendering, so we can immediately load
            // the data from browser storage
            string mySessionValue = storageProvider.GetKey("x-my-session-key");
        }
        else
        {
            // We are prerendering, so have to defer the load operation until later
            isWaitingForConnection = true;
        }
    }

    protected override async Task OnAfterRenderAsync()
    {
        // By this stage we know the client has connected back to the server, and
        // browser services are available. So if we didn't load the data earlier,
        // we should do so now, then trigger a new render.
        if (isWaitingForConnection)
        {
            isWaitingForConnection = false;
            //load session data now
            string mySessionValue = storageProvider.GetKey("x-my-session-key");
            StateHasChanged();
        }
    }

现在,关于如何在页面之间持久化状态,您应该使用CascadingParameter。Chris Sainty将其解释为“级联值和参数是一种将值从组件传递到其所有子代的方法,而无需使用传统的组件参数。”这将是一个参数,它将是一个类,保存所有状态数据并公开可以通过您选择的存储提供程序进行加载/保存的方法。这在Chris Sainty的博客Steve Sanderson的笔记Microsoft文档中有所解释。
更新:Microsoft已经发布了新的文档,解释了Blazor的状态管理
更新2:请注意,目前BlazorStorage对于最近的.NET SDK预览版本的服务器端Blazor无法正常工作。您可以关注此问题,我发布了一个临时解决方案。

1
ComponentContext 仍然存在吗?我似乎找不到任何有关它的提及。 - Jonathan Allen
@JonathanAllen 不,它已被删除,也没有替代品。 - Bin4ry

5
这里是一个完整的代码示例,展示了如何使用Blazored/LocalStorage保存会话数据。例如,用于存储已登录的用户等信息。在版本3.0.100-preview9-014004中已确认可用。请查看Blazored/LocalStorage
@page "/login"
@inject Blazored.LocalStorage.ILocalStorageService localStorage

<hr class="mb-5" />
<div class="row mb-5">

    <div class="col-md-4">
        @if (UserName == null)
        {
            <div class="input-group">
                <input class="form-control" type="text" placeholder="Username" @bind="LoginName" />
                <div class="input-group-append">
                    <button class="btn btn-primary" @onclick="LoginUser">Login</button>
                </div>
            </div>
        }
        else
        {
            <div>
                <p>Logged in as: <strong>@UserName</strong></p>
                <button class="btn btn-primary" @onclick="Logout">Logout</button>
            </div>
        }
    </div>
</div>

@code {

    string UserName { get; set; }
    string UserSession { get; set; }
    string LoginName { get; set; }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            await GetLocalSession();

            localStorage.Changed += (sender, e) =>
            {
                Console.WriteLine($"Value for key {e.Key} changed from {e.OldValue} to {e.NewValue}");
            };

            StateHasChanged();
        }
    }

    async Task LoginUser()
    {
        await localStorage.SetItemAsync("UserName", LoginName);
        await localStorage.SetItemAsync("UserSession", "PIOQJWDPOIQJWD");
        await GetLocalSession();
    }

    async Task GetLocalSession()
    {
        UserName = await localStorage.GetItemAsync<string>("UserName");
        UserSession = await localStorage.GetItemAsync<string>("UserSession");
    }

    async Task Logout()
    {
        await localStorage.RemoveItemAsync("UserName");
        await localStorage.RemoveItemAsync("UserSession");
        await GetLocalSession();
    }
}

4
我找到了一种在服务器端会话中存储用户数据的方法。我使用CircuitHandler Id作为用户访问系统的“令牌”来进行操作。只有用户名和CircuitId被存储在客户端LocalStorage(使用Blazored.LocalStorage);其他用户数据则被保存在服务器上。虽然这需要大量的代码,但这是我找到的最佳方法来保护服务器端的用户数据。 (用于客户端LocalStorage)
public class UserModel
{
    public string Username { get; set; }

    public string CircuitId { get; set; }
}

SessionModel.cs(我服务器端session的模型)

public class SessionModel
{
    public string Username { get; set; }

    public string CircuitId { get; set; }

    public DateTime DateTimeAdded { get; set; }  //this could be used to timeout the session

    //My user data to be stored server side...
    public int UserRole { get; set; } 
    etc...
}

SessionData.cs(保存服务器上所有活跃会话的列表)

public class SessionData
{
    private List<SessionModel> sessions = new List<SessionModel>();
    private readonly ILogger _logger;
    public List<SessionModel> Sessions { get { return sessions; } }

    public SessionData(ILogger<SessionData> logger)
    {
        _logger = logger;
    }

    public void Add(SessionModel model)
    {
        model.DateTimeAdded = DateTime.Now;

        sessions.Add(model);
        _logger.LogInformation("Session created. User:{0}, CircuitId:{1}", model.Username, model.CircuitId);
    }

    //Delete the session by username
    public void Delete(string token)
    {
        //Determine if the token matches a current session in progress
        var matchingSession = sessions.FirstOrDefault(s => s.Token == token);
        if (matchingSession != null)
        {
            _logger.LogInformation("Session deleted. User:{0}, Token:{1}", matchingSession.Username, matchingSession.CircuitId);

            //remove the session
            sessions.RemoveAll(s => s.Token == token);
        }
    }

    public SessionModel Get(string circuitId)
    {
        return sessions.FirstOrDefault(s => s.CircuitId == circuitId);
    }
}

CircuitHandlerService.cs

public class CircuitHandlerService : CircuitHandler
{
    public string CircuitId { get; set; }
    public SessionData sessionData { get; set; }

    public CircuitHandlerService(SessionData sessionData)
    {
        this.sessionData = sessionData;
    }

    public override Task OnCircuitOpenedAsync(Circuit circuit, CancellationToken cancellationToken)
    {
        CircuitId = circuit.Id;
        return base.OnCircuitOpenedAsync(circuit, cancellationToken);
    }

    public override Task OnCircuitClosedAsync(Circuit circuit, CancellationToken cancellationToken)
    {
        //when the circuit is closing, attempt to delete the session
        //  this will happen if the current circuit represents the main window
        sessionData.Delete(circuit.Id); 

        return base.OnCircuitClosedAsync(circuit, cancellationToken);
    }

    public override Task OnConnectionDownAsync(Circuit circuit, CancellationToken cancellationToken)
    {
        return base.OnConnectionDownAsync(circuit, cancellationToken);
    }

    public override Task OnConnectionUpAsync(Circuit circuit, CancellationToken cancellationToken)
    {
        return base.OnConnectionUpAsync(circuit, cancellationToken);
    }
}

Login.razor

@inject ILocalStorageService localStorage
@inject SessionData sessionData
....
public SessionModel session { get; set; } = new SessionModel();
...
if (isUserAuthenticated == true)
{
    //assign the sesssion token based on the current CircuitId
    session.CircuitId = (circuitHandler as CircuitHandlerService).CircuitId;
    sessionData.Add(session);

    //Then, store the username in the browser storage
    //  this username will be used to access the session as needed
    UserModel user = new UserModel
    {
        Username = session.Username,
        CircuitId = session.CircuitId
    };

    await localStorage.SetItemAsync("userSession", user);
    NavigationManager.NavigateTo("Home");
}

Startup.cs

public void ConfigureServices(IServiceCollection services)
{
    ...
    services.AddServerSideBlazor();
    services.AddScoped<CircuitHandler>((sp) => new CircuitHandlerService(sp.GetRequiredService<SessionData>()));
    services.AddSingleton<SessionData>();
    services.AddBlazoredLocalStorage();
    ...
}

services.AddScoped<CircuitHandler> 这是获取当前电路 ID 的最佳技巧,太棒了。Blazor Server 范围正好是每个 SignalR 电路,所以再好不过了。 - user3625699
2
注意!在Singleton(在本例中为SessionData)中使用Lists时要小心。由于这是一个Blazor Server应用程序,可能会导致竞争条件和不良行为。请使用ConcurrentDictionary或任何其他线程安全的集合。 - Tena

4
您可以使用Blazored.SessionStorage包在会话中存储数据。
安装Blazored.SessionStorage
`@inject Blazored.SessionStorage.ISessionStorageService sessionStorage` 

    @code {

    protected override async Task OnInitializedAsync()
    {
        await sessionStorage.SetItemAsync("name", "John Smith");
        var name = await sessionStorage.GetItemAsync<string>("name");
    }

}

1
微软现在已经有了官方文档,您可以在此链接中查看:https://learn.microsoft.com/en-us/aspnet/core/blazor/state-management?view=aspnetcore-3.1#protected-browser-storage-experimental-package - Jesper
@Jesper 那是针对客户端 Blazor(WASM)的,OP明确表示是服务器端。 - McGuireV10
@McGuireV10 不是的。页面顶部写着“选择 Blazor 托管模型”。只需选择您需要的即可。 - Jesper
@ Jesper Ha!我居然完全错过了这个。有趣。是时候休假了。谢谢。 - McGuireV10

2
不要使用会话状态(我没有尝试过,但我怀疑在 Blazor 下甚至不支持 AddSession,因为会话 ID 是基于 cookie 的,而 HTTP 大多数情况下并不涉及)。即使对于非 Blazor Web 应用程序,也没有可靠的机制来检测会话结束,因此会话清理最多也是混乱的。
相反,注入一个支持持久性的 IDistributedCache 实现。最受欢迎的示例之一是 Redis cache。在我的一个工作项目中,我正在尝试使用 Microsoft Orleans 进行分布式缓存。我无权分享我们的内部实现,但您可以在我的 repo 这里 中看到一个早期的示例。
在幕后,会话状态只是一个字典(以会话ID为键),其中包含另一个字典,用于存储您的键值对。使用长期可靠的密钥(如经过身份验证的用户ID)轻松复制该方法。不过,我甚至没有走那么远,因为经常序列化和反序列化整个字典,而我通常只需要一个或两个键,这是很多不必要的开销。相反,我使用我的唯一用户ID作为每个值键的前缀,并直接存储每个值。

1
遗憾的是,这是正确的答案,你需要自己编写代码。其他方法包括会话存储和本地存储,但它们的存储空间非常有限,只适合存储键等小数据。 - Jason Honingford

2

在 .net 5.0 中,现在有了 ProtectedSessionStorage,它可以为您提供加密的浏览器会话数据。

@using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage; 
@inject ProtectedSessionStorage storage 

// Set   
await storage.SetAsync("myFlag", "Green");  
  
// Get  
var myFlag= await storage.GetAsync<string>("myFlag");

使用JavaScript交互,因此不要在OnInitialize中使用,而是应该在OnAfterRender中使用。


你能提供更多关于它是如何加密的信息吗?是通过浏览器的HTTPS证书,还是其他方式?我在这方面找不到更多的信息。 - user3625699

1
请参考以下存储库以获取服务器端会话实现: https://github.com/alihasan94/BlazorSessionAppLogin.razor 页面上编写以下代码:
@page "/"

@using Microsoft.AspNetCore.Http
@using Helpers;
@using Microsoft.JSInterop;


@inject SessionState session
@inject IJSRuntime JSRuntime
@code{

    public string Username { get; set; }
    public string Password { get; set; }
}

@functions {
    private async Task SignIn()
    {
        if (!session.Items.ContainsKey("Username") && !session.Items.ContainsKey("Password"))
        {
            //Add to the Singleton scoped Item
            session.Items.Add("Username", Username);
            session.Items.Add("Password", Password);
//Redirect to homepage
            await JSRuntime.InvokeAsync<string>(
            "clientJsMethods.RedirectTo", "/home");
        }
    }
}

<div class="col-md-12">
    <h1 class="h3 mb-3 font-weight-normal">Please Sign In</h1>
</div>

<div class="col-md-12 form-group">
    <input type="text" @bind="Username" class="form-control" id="username"
           placeholder="Enter UserName" title="Enter UserName" />
</div>

<div class="col-md-12 form-group">
        <input type="password" @bind="Password" class="form-control" id="password"
               placeholder="Enter Password" title="Enter Password" />
</div>


<button @onclick="SignIn">Login</button>

SessionState.cs

using System.Collections.Generic;

namespace BlazorSessionApp.Helpers
{
    public class SessionState
    {
        public SessionState()
        {
            Items = new Dictionary<string, object>();
        }
       public Dictionary<string, object> Items { get; set; }
    }
}

SessionBootstrapper.cs(包含设置会话的逻辑)

using Microsoft.AspNetCore.Http;

namespace BlazorSessionApp.Helpers
{
    public class SessionBootstrapper
    {
        private readonly IHttpContextAccessor accessor;
        private readonly SessionState session;
        public SessionBootstrapper(IHttpContextAccessor _accessor, SessionState _session)
        {
            accessor = _accessor;
            session = _session;
        }
        public void Bootstrap() 
        {
            //Singleton Item: services.AddSingleton<SessionState>(); in Startup.cs

            //Code to save data in server side session

            //If session already has data
            string Username = accessor.HttpContext.Session.GetString("Username");
            string Password = accessor.HttpContext.Session.GetString("Password");

            //If server session is null
            if (session.Items.ContainsKey("Username") && Username == null)
            {
                //get from singleton item
                Username = session.Items["Username"]?.ToString();
                // save to server side session
                accessor.HttpContext.Session.SetString("Username", Username);
                //remove from singleton Item
                session.Items.Remove("Username");
            }

            if (session.Items.ContainsKey("Password") && Password == null)
            {
                Password = session.Items["Password"].ToString();
                accessor.HttpContext.Session.SetString("Password", Password);
                session.Items.Remove("Password");
            }

            //If Session is not expired yet then  navigate to home
            if (!string.IsNullOrEmpty(Username) && !string.IsNullOrEmpty(Password) && accessor.HttpContext.Request.Path == "/")
            {
                accessor.HttpContext.Response.Redirect("/home");
            }
            //If Session is expired then navigate to login
            else if (string.IsNullOrEmpty(Username) && string.IsNullOrEmpty(Password) && accessor.HttpContext.Request.Path != "/")
            {
                accessor.HttpContext.Response.Redirect("/");
            }
        }
    }
}

_Host.cshtml(在此初始化SessionBootstrapper类)

@page "/"
@namespace BlazorSessionApp.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@{
    Layout = null;
}
@using BlazorSessionApp.Helpers

@inject SessionBootstrapper bootstrapper

    <!DOCTYPE html>
    <html lang="en">
    <body>

        @{
            bootstrapper.Bootstrap();
        }
        <app>
            <component type="typeof(App)" render-mode="ServerPrerendered" />
        </app>

        <script src="_framework/blazor.server.js"></script>
        <script>
            // use this to redirect from "Login Page" only in order to save the state on server side session
            // because blazor's NavigateTo() won't refresh the page. The function below refresh 
            // the page and runs bootstrapper.Bootstrap(); to save data in server side session.
            window.clientJsMethods = {
              RedirectTo: function (path) {
                    window.location = path;
                }
            };
        </script>
    </body>
    </html>

1
微软的文档指出,出于安全原因,“您不应在 Blazor 应用程序中使用 IHttpContextAccessor”,请参见:https://learn.microsoft.com/en-us/aspnet/core/security/blazor/server/threat-mitigation?view=aspnetcore-3.1 - Daniel

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接