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

Posted

技术标签:

【中文标题】如何在服务器端 Blazor 中存储会话数据【英文标题】:How to store session data in server-side blazor 【发布时间】:2019-05-23 14:45:27 【问题描述】:

在服务器端 Blazor 应用程序中,我想存储一些在页面导航之间保留的状态。我该怎么做?

常规 ASP.NET Core 会话状态似乎不可用,因为Session and app sate in ASP.NET Core 中的以下注释很可能适用:

SignalR 不支持会话 应用程序,因为 SignalR Hub 可能 独立于 HTTP 上下文执行。例如,这可能发生 当一个长轮询请求被一个集线器保持打开超过生命周期时 请求的 HTTP 上下文。

GitHub 问题Add support to SignalR for Session 提到您可以使用Context.Items。但我不知道如何使用它,即我不知道如何访问HubConnectionContext 实例。

我有哪些会话状态选项?

【问题讨论】:

您可以在 DI 中注册一个作用域对象以跟踪状态 你确定它有效吗? blazor.net/docs/dependency-injection.html 页面说:Blazor 目前没有 DI 范围的概念。 Scoped 的行为类似于 Singleton。因此,更喜欢 Singleton 并避免 Scoped。 不确定 - 认为我搞混了应用状态 我用 scoped 测试了 DI。它的行为不像单例。因此,该描述可能是指客户端 Blazor。但是,它只持续很短的时间,类似于请求的持续时间。从一个页面导航到另一个页面时传递数据就足够了。但在那之后,它就丢失了。 @JohnB:经过更多测试,我发现作用域 DI 或多或少适用于会话状态。它的寿命比我最初想象的要长。只要您不重新加载页面或手动修改 URL,它就与 SignalR 连接绑定并保持活动状态。因此,这是一个开始,但与其他系统提供的功能相去甚远。 【参考方案1】:

这里是 ASP.NET Core 5.0+ 的相关解决方案(ProtectedSessionStorageProtectedLocalStorage):https://docs.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);
    

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

【讨论】:

ProtectedSessionStorage 和 ProtectedLocalStorage 很棒,它不会将数据保存为纯文本并使用加密/解密保存在浏览器存储中。我不知道为什么人们甚至考虑使用其他东西。【参考方案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 证书,还是?我找不到更多关于此的信息【参考方案3】:

我找到了一种在服务器端会话中存储用户数据的方法。我通过使用 CircuitHandler Id 作为用户访问系统的“令牌”来做到这一点。只有用户名和 CircuitId 存储在客户端 LocalStorage 中(使用 Blazored.LocalStorage);其他用户数据存储在服务器中。我知道代码很多,但这是我能找到的在服务器端保护用户数据安全的最佳方式。

UserModel.cs(用于客户端 LocalStorage)

public class UserModel

    public string Username  get; set; 

    public string CircuitId  get; set; 

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

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 这是获取当前电路 ID 的最佳技巧,太棒了。 Blazor Server 范围完全符合 SignalR 电路,因此再好不过了。【参考方案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");
    


【讨论】:

微软现在有关于这个docs.microsoft.com/en-us/aspnet/core/blazor/…的官方文档 @Jesper 用于客户端 Blazor (WASM),OP 专门表示服务器端。 @McGuireV10 不,不是。页面顶部显示“选择 Blazor 托管模型”。只需选择您需要的那个 @Jesper 哈!我不知何故完全错过了。有趣的。是时候放假了。谢谢。【参考方案5】:

注意:此答案来自 2018 年 12 月,当时服务器端 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 身份验证令牌。但是,如果您将状态存储在浏览器端,就像微软的家伙使用 Blazor 浏览器存储包所做的那样,您就放弃了 Blazor 的主要优势之一。 @Codo 您是否认为这仍然与 Blazor 相关:learnrazorpages.com/razor-pages/session-state?否则我会等到 Blazor 最终发布并且文档是最新的。 @FranzHuber23:我不能告诉你,因为我不再是最新的了。我怀疑它仅适用于 ASP.NET,不适用于 Blazor。 嗯,它适用于 Blazor,但只能在服务器端正确使用(据我检查过)。 Github 上有一个问题:github.com/aspnet/AspNetCore/issues/12432。也许他们会更新文档或提供示例。 服务器端会话实现请参考以下仓库:github.com/alihasan94/BlazorSessionApp【参考方案6】:

请参阅以下存储库以了解服务器端会话实现: https://github.com/alihasan94/BlazorSessionApp

Login.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>

【讨论】:

微软文档出于安全原因说,“您不得在 Blazor 应用程序中使用 IHttpContextAccessor”:docs.microsoft.com/en-us/aspnet/core/security/blazor/server/…【参考方案7】:

根本不使用会话状态(我没有尝试过,但我怀疑AddSession 甚至在 Blazor 下都不起作用,因为会话 ID 是基于 cookie 的,而且 HTTP 大多不在图片中)。即使对于非 Blazor Web 应用程序,也没有可靠的机制来检测会话结束,因此会话清理充其量是混乱的。

相反,注入支持持久性的IDistributedCache 的实现。最流行的例子之一是Redis cache。在我的一个工作项目中,我正在尝试使用 Microsoft Orleans 进行分布式缓存。我不能随意分享我们的内部实现,但你可以在我的 repo here 中看到一个早期的例子。

实际上,会话状态只是一个字典(以会话 ID 为键),其中包含另一个键值对字典。使用长期可靠的密钥(例如经过身份验证的用户 ID)重现该方法是微不足道的。不过,我什至没有走那么远,因为当我通常只需要一个或两个键时,不断地序列化和反序列化整个字典是很多不必要的开销。相反,我用我唯一的用户 ID 作为单个值键的前缀,并直接存储每个值。

【讨论】:

很遗憾这是正确答案,请自行选择。其他方法是会话存储和本地存储,它们是存在于客户端 Web 浏览器上的非常有限的存储。这仅适用于存储密钥等。【参考方案8】:

这是一个完整的代码示例,说明如何使用Blazored/LocalStorage 保存会话数据。例如用于存储登录用户等。确认工作自版本3.0.100-preview9-014004

@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();
    

【讨论】:

【参考方案9】:

Steve Sanderson goes in depth how to save the state.

对于服务器端 blazor,您需要使用 JavaScript 中的任何存储实现,可以是 cookie、查询参数,或者例如您可以使用 local/session storage。

目前有 NuGet 包通过 IJSRuntime 实现该功能,例如 BlazorStorage 或 Microsoft.AspNetCore.ProtectedBrowserStorage

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

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

禁用预渲染:

(...) 打开您的_Host.razor 文件,并删除对Html.RenderComponentAsync 的调用。然后,打开您的Startup.cs 文件,并将对endpoints.MapBlazorHub() 的调用替换为endpoints.MapBlazorHub&lt;App&gt;("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's blog、Steve Sanderson's note 或Microsoft docs 上进行了解释

更新:Microsoft has published new docs explaining Blazor's state management

更新 2:请注意,当前 BlazorStorage 无法在具有最新 .NET SDK 预览版的服务器端 Blazor 中正常工作。您可以关注this issue,我在其中发布了一个临时解决方法

【讨论】:

ComponentContext 还存在吗?我似乎找不到任何提及它。 @JonathanAllen 不,它已被删除,别无选择。

以上是关于如何在服务器端 Blazor 中存储会话数据的主要内容,如果未能解决你的问题,请参考以下文章

Blazor webassembly pwa 会话存储在部署到 Azure 后不持久

如何从 blazor(服务器端)Web 应用程序获取访问令牌?

如何在服务器端 Blazor 组件中访问实体框架 DbContext 实体

服务器端 Blazor:刷新页面后如何保持用户身份验证?

添加第二个 EF 核心数据库后 Blazor 服务器端 docker 容器分段错误

如何限制 Blazor 服务器端请求大小