Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Abstract out session store from SessionManager #4567

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 26 additions & 25 deletions Source/Csla.AspNetCore/Blazor/State/SessionManager.cs
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really appreciate this refactoring - absolutely on the right track. While we're doing this, might as well also embrace async.

Perhaps also consider using file scoped namespaces so Simon doesn't need to come back later and clean that up?

Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
// <summary>Manages all user session data</summary>
//-----------------------------------------------------------------------

using System.Collections.Concurrent;
using Csla.State;

namespace Csla.Blazor.State
Expand All @@ -16,23 +15,30 @@ namespace Csla.Blazor.State
/// root DI container.
/// </summary>
/// <param name="sessionIdManager"></param>
public class SessionManager(ISessionIdManager sessionIdManager) : ISessionManager
/// <param name="sessionStore"></param>
public class SessionManager(ISessionIdManager sessionIdManager, ISessionStore sessionStore) : ISessionManager
{
private readonly ConcurrentDictionary<string, Session> _sessions = [];
private readonly ISessionIdManager _sessionIdManager = sessionIdManager;
private readonly ISessionStore _sessionStore = sessionStore;

/// <summary>
/// Gets the session data for the current user.
/// </summary>
public Session GetSession()
{
Session result;
var key = _sessionIdManager.GetSessionId();
if (!_sessions.ContainsKey(key))
_sessions.TryAdd(key, []);
result = _sessions[key];
result.Touch();
return result;
var session = _sessionStore.GetSession(key);
if (session == null)
{
session = [];
session.Touch();
_sessionStore.CreateSession(key, session);
return session;
}

session.Touch();
_sessionStore.UpdateSession(key, session);
return session;
}

/// <summary>
Expand All @@ -44,9 +50,19 @@ public void UpdateSession(Session newSession)
{
ArgumentNullException.ThrowIfNull(newSession);
var key = _sessionIdManager.GetSessionId();
var existingSession = _sessions[key];
var existingSession = _sessionStore.GetSession(key)!;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you suppress with !? You should check for it and handle the default case.

Replace(newSession, existingSession);
existingSession.Touch();
_sessionStore.UpdateSession(key, existingSession);
}

/// <summary>
/// Remove all expired session data.
/// </summary>
/// <param name="expiration">Expiration duration</param>
public void PurgeSessions(TimeSpan expiration)
{
_sessionStore.DeleteSessions(new SessionsFilter { Expiration = expiration });
}

/// <summary>
Expand All @@ -62,21 +78,6 @@ private static void Replace(Session newSession, Session oldSession)
oldSession.Add(key, newSession[key]);
}

/// <summary>
/// Remove all expired session data.
/// </summary>
/// <param name="expiration">Expiration duration</param>
public void PurgeSessions(TimeSpan expiration)
{
var expirationTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - expiration.TotalSeconds;
List<string> toRemove = [];
foreach (var session in _sessions)
if (session.Value.LastTouched < expirationTime)
toRemove.Add(session.Key);
foreach (var key in toRemove)
_sessions.TryRemove(key, out _);
}

// wasm client-side methods
Task<Session> ISessionManager.RetrieveSession(TimeSpan timeout) => throw new NotImplementedException();
Session ISessionManager.GetCachedSession() => throw new NotImplementedException();
Expand Down
24 changes: 24 additions & 0 deletions Source/Csla.Blazor/ConfigurationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
// <summary>Implement extension methods for .NET Core configuration</summary>
//-----------------------------------------------------------------------

using System.Diagnostics.CodeAnalysis;
using Csla.Blazor;
using Csla.Blazor.State;
using Csla.Core;
Expand Down Expand Up @@ -63,6 +64,7 @@ public static CslaOptions AddServerSideBlazor(this CslaOptions config, Action<Bl
// use Blazor state management
config.Services.AddTransient(typeof(ISessionIdManager), blazorOptions.SessionIdManagerType);
config.Services.AddSingleton(typeof(ISessionManager), blazorOptions.SessionManagerType);
config.Services.TryAddTransient(typeof(ISessionStore), blazorOptions.SessionStoreType);
config.Services.AddTransient<StateManager>();
}

Expand Down Expand Up @@ -105,5 +107,27 @@ public class BlazorServerConfigurationOptions
/// Gets or sets the type of the ISessionIdManager service.
/// </summary>
public Type SessionIdManagerType { get; set; } = Type.GetType("Csla.Blazor.State.SessionIdManager, Csla.AspNetCore", true);

/// <summary>
/// Gets or sets the type of the SessionStore.
/// </summary>
#if NET8_0_OR_GREATER
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)]
#endif
internal Type SessionStoreType { get; set; } = typeof(DefaultSessionStore);

/// <summary>
/// Sets the type of the SessionStore.
/// </summary>
/// <typeparam name="T"></typeparam>
public BlazorServerConfigurationOptions RegisterSessionStore<
#if NET8_0_OR_GREATER
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)]
#endif
T>() where T: ISessionStore
{
SessionStoreType = typeof(T);
return this;
}
}
}
71 changes: 71 additions & 0 deletions Source/Csla.Blazor/State/DefaultSessionStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//-----------------------------------------------------------------------
// <copyright file="StateManager.cs" company="Marimer LLC">
// Copyright (c) Marimer LLC. All rights reserved.
// Website: https://cslanet.com
// </copyright>
// <summary>Default implementation of session storage.</summary>
//-----------------------------------------------------------------------
#nullable enable

using System.Collections.Concurrent;
using Csla.State;

namespace Csla.Blazor.State
{
/// <summary>
/// Default implementation of <see cref="ISessionStore"/>
/// </summary>
public class DefaultSessionStore : ISessionStore
{
private readonly ConcurrentDictionary<string, Session> _store = new();

/// <inheritdoc />
public Session? GetSession(string key)
{
_store.TryGetValue(key, out var item);
return item;
}

/// <inheritdoc />
public void CreateSession(string key, Session session)
{
if (!_store.TryAdd(key, session))
{
throw new Exception("Key already exists");
}
}

/// <inheritdoc />
public void UpdateSession(string key, Session session)
{
ArgumentNullException.ThrowIfNull(session);
_store[key] = session;
}

/// <inheritdoc />
public void DeleteSession(string key)
{
_store.TryRemove(key, out _);
}

/// <inheritdoc />
public void DeleteSessions(SessionsFilter filter)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like this method name. Perhaps it should be more explicit that this is expiring sessions?

{
filter.Validate();

var query = _store.AsQueryable();
if (filter.Expiration.HasValue)
{
var expirationTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - filter.Expiration.Value.TotalSeconds;
query = query.Where(x => x.Value.LastTouched < expirationTime);
}

var keys = query.Select(x => x.Key).ToArray();

foreach (var key in keys)
{
_store.TryRemove(key, out _);
}
}
}
}
50 changes: 50 additions & 0 deletions Source/Csla/State/ISessionStore.cs
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think @StefanOssendorf would be much happier if this had async methods instead of sync methods 😁

Seriously though, if we're going to improve the code like this, I do think we should expect that people will use async stores for the data.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes please :)
You can always do sync with a Task/ValueTask method but not async with a void method :)

Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//-----------------------------------------------------------------------
// <copyright file="ISessionStore.cs" company="Marimer LLC">
// Copyright (c) Marimer LLC. All rights reserved.
// Website: https://cslanet.com
// </copyright>
// <summary>Manages session storage</summary>
//-----------------------------------------------------------------------
#nullable enable

namespace Csla.State
{
/// <summary>
/// Session store
/// </summary>
public interface ISessionStore
{
/// <summary>
/// Retrieves a session
/// </summary>
/// <param name="key"></param>
/// <returns>The session for the given key, or default if not found</returns>
Session? GetSession(string key);

/// <summary>
/// Creates a session
/// </summary>
/// <param name="key"></param>
/// <param name="session"></param>
void CreateSession(string key, Session session);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could the create/update be combined to a TryUpdate or something similar?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AddOrUpdate like the ConcurrentDictionary methods?


/// <summary>
/// Updates a session
/// </summary>
/// <param name="key"></param>
/// <param name="session"></param>
void UpdateSession(string key, Session session);

/// <summary>
/// Deletes a session
/// </summary>
/// <param name="key"></param>
void DeleteSession(string key);

/// <summary>
/// Deletes sessions based on the filter.
/// </summary>
/// <param name="filter"></param>
void DeleteSessions(SessionsFilter filter);
}
}
33 changes: 33 additions & 0 deletions Source/Csla/State/SessionsFilter.cs
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is basically internal to the default implementation right?

As in, if someone were to use a different session store they'd probably use their own approach for expiring old data.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No. Currently this class is part of the public API.
ISessionStore.DeleteSession(SessionsFilter)

Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//-----------------------------------------------------------------------
// <copyright file="SessionsFilter.cs" company="Marimer LLC">
// Copyright (c) Marimer LLC. All rights reserved.
// Website: https://cslanet.com
// </copyright>
// <summary>Filter for querying session storage</summary>
//-----------------------------------------------------------------------
#nullable enable

namespace Csla.State
{
/// <summary>
/// Filter to query sessions
/// </summary>
public class SessionsFilter
{
/// <summary>
/// A timespan to filter sessions last touched after the expiration
/// </summary>
public TimeSpan? Expiration { get; set; }

/// <summary>
/// Validates
/// </summary>
public void Validate()
{
if (!Expiration.HasValue)
{
throw new ArgumentNullException("Expiration is required.");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No hard-coded use of English is allowed in the framework. This needs to be from a resource string or be ArgumentException(nameof(HasValue))

Copy link

@ossendorf-at-hoelscher ossendorf-at-hoelscher Mar 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My suggestion to get rid of this exception would be: Don't allow the creation of an invalid object state.

  1. Make Expiration non nullable.
  2. Either add a constructor expecting the value and/or make the property required

With those changes the whole Validate() method becomes obsolete.

}
}
}
}
Loading