Using EF Core and SQLite to persist SignalR Group messages in ASP.NET Core

The article shows how SignalR messages can be saved to a database using EF Core and SQLite. The post uses the SignalR Hub created in this blog; SignalR Group messages with ngrx and Angular, and extends it so that users can only join an existing SignalR group. The group history is then sent to the client that joined.

Code: https://github.com/damienbod/AspNetCoreAngularSignalR

Posts in this series

History

2023-01-08 Updated Angular 15, .NET 7
2021-01-25 Updated Angular 11.1.0 .NET 5, ngrx implementation
2020-03-21 updated packages, fixed Admin UI STS
2019-08-18 Updated ASP.NET Core 3.0, Angular 8.2.2
2019-02-06 Updated Angular 7.2.4, latest NGRX, SignalR CORS fix
2018-12-12 Updated .NET Core 2.2, ASP.NET Core SignalR 1.1.0, Angular 7.1.3
2018-05-31 Updated Microsoft.AspNetCore.SignalR 2.1
2018-05-08 Updated Microsoft.AspNetCore.SignalR 2.1 rc1, Angular 6
2018-03-15 Updated signalr Microsoft.AspNetCore.SignalR 1.0.0-preview1-final, Angular 5.2.8, @aspnet/signalr 1.0.0-preview1-update1

Create the Database Store.

To create a store for the SignalR Hub, an EF Core Context is created and also the store logic which is responsible for accessing the database. The NewsContext class is really simple and just provides 2 DbSets, NewsItemEntities which will be used to save the SignalR messages, and NewsGroups which is used to validate and create the groups in the SignalR Hub.

using Microsoft.EntityFrameworkCore;

namespace AspNetCoreAngularSignalR.Providers;

public class NewsContext : DbContext
{
    public NewsContext(DbContextOptions<NewsContext> options) :base(options){ }

    public DbSet<NewsItemEntity> NewsItemEntities => Set<NewsItemEntity>();

    public DbSet<NewsGroup> NewsGroups => Set<NewsGroup>();
}

The NewsStore provides the methods which will be used in the Hub and also an ASP.NET Core Controller to create, select the groups if required. The NewsStore uses the NewsContext class.

sing AspNetCoreAngularSignalR.SignalRHubs;

namespace AspNetCoreAngularSignalR.Providers;

public class NewsStore
{
    public NewsStore(NewsContext newsContext)
    {
        _newsContext = newsContext;
    }

    private readonly NewsContext _newsContext;

    public void AddGroup(string group)
    {
        _newsContext.NewsGroups.Add(new NewsGroup
        {
            Name = group
        });
        _newsContext.SaveChanges();
    }

    public bool GroupExists(string group)
    {
        var item = _newsContext.NewsGroups.FirstOrDefault(t => t.Name == group);
        if(item == null)
        {
            return false;
        }

        return true;
    }

    public void CreateNewItem(NewsItem item)
    {
        if (GroupExists(item.NewsGroup))
        {
            _newsContext.NewsItemEntities.Add(new NewsItemEntity
            {
                Header = item.Header,
                Author = item.Author,
                NewsGroup = item.NewsGroup,
                NewsText = item.NewsText
            });
            _newsContext.SaveChanges();
        }
        else
        {
            throw new System.Exception("group does not exist");
        }
    }

    public List<NewsItem> GetAllNewsItems(string group)
    {
        return _newsContext.NewsItemEntities.Where(item => item.NewsGroup == group).Select(z => 
            new NewsItem {
                Author = z.Author,
                Header = z.Header,
                NewsGroup = z.NewsGroup,
                NewsText = z.NewsText
            }).ToList();
    }

    public List<string> GetAllGroups()
    {
        return _newsContext.NewsGroups.Select(t =>  t.Name ).ToList();
    }
}

The NewsStore and the NewsContext are registered in the ConfigureServices method in the Startup class. The SignalR Hub is a singleton and so the NewsContext and the NewsStore classes are added as singletons. The AddDbContext requires the ServiceLifetime.Singleton parameter as this is not default. This is not optimal when using the NewsContext in the ASP.NET Core controller, as you need to consider the possible multiple client requests.

var builder = WebApplication.CreateBuilder(args);

builder.WebHost.ConfigureKestrel(serverOptions =>
{
    serverOptions.AddServerHeader = false;
});

var services = builder.Services;
var configuration = builder.Configuration;
var env = builder.Environment;

services.AddTransient<ValidateMimeMultipartContentFilter>();

var sqlConnectionString = configuration.GetConnectionString("DefaultConnection");

services.AddDbContext<NewsContext>(options =>
    options.UseSqlite(
        sqlConnectionString
    ), ServiceLifetime.Singleton
);

services.AddCors(options =>
{
    options.AddPolicy("AllowAllOrigins",
        builder =>
        {
            builder
                .AllowCredentials()
                .WithOrigins(
                    "https://localhost:4200")
                .SetIsOriginAllowedToAllowWildcardSubdomains()
                .AllowAnyHeader()
                .AllowAnyMethod();
        });
});

services.AddSingleton<NewsStore>();

services.AddSignalR()
  .AddMessagePackProtocol();

services.AddControllersWithViews();

Updating the SignalR Hub

The SignalR NewsHub uses the NewsStore which is injected using constructor injection. If a message is sent, or received, it is persisted using the CreateNewItem method from the store. When a new user joins an existing group, the history is sent to the client by invoking the “History” message.

using AspNetCoreAngularSignalR.Providers;
using Microsoft.AspNetCore.SignalR;

namespace AspNetCoreAngularSignalR.SignalRHubs;

public class NewsHub : Hub
{
    private NewsStore _newsStore;

    public NewsHub(NewsStore newsStore)
    {
        _newsStore = newsStore;
    }

    public Task Send(NewsItem newsItem)
    {
        if(!_newsStore.GroupExists(newsItem.NewsGroup))
        {
            throw new System.Exception("cannot send a news item to a group which does not exist.");
        }

        _newsStore.CreateNewItem(newsItem);
        return Clients.Group(newsItem.NewsGroup).SendAsync("Send", newsItem);
    }

    public async Task JoinGroup(string groupName)
    {
        if (!_newsStore.GroupExists(groupName))
        {
            throw new System.Exception("cannot join a group which does not exist.");
        }

        await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
        await Clients.Group(groupName).SendAsync("JoinGroup", groupName);

        var history = _newsStore.GetAllNewsItems(groupName);
        await Clients.Client(Context.ConnectionId).SendAsync("History", history);
    }

    public async Task LeaveGroup(string groupName)
    {
        if (!_newsStore.GroupExists(groupName))
        {
            throw new System.Exception("cannot leave a group which does not exist.");
        }

        await Clients.Group(groupName).SendAsync("LeaveGroup", groupName);
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName);
    }
}

A NewsController is used to select all the existing groups, or add a new group, which is used by the SignalR Hub.

using Microsoft.AspNetCore.Mvc;
using AspNetCoreAngularSignalR.Providers;

namespace AspNetCoreAngularSignalR.Controllers;

[Route("api/[controller]")]
public class NewsController : Controller
{
    private NewsStore _newsStore;

    public NewsController(NewsStore newsStore)
    {
        _newsStore = newsStore;
    }

    [HttpPost]
    public IActionResult AddGroup([FromQuery] string group)
    {
        if (string.IsNullOrEmpty(group))
        {
            return BadRequest();
        }
        _newsStore.AddGroup(group);
        return Created("AddGroup", group);
    }

    public List<string> GetAllGroups()
    {
        return _newsStore.GetAllGroups();
    }
}

Using the SignalR Hub

The NewsService Angular service, listens for SignalR events and handles these using the ngrx store.

import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { HubConnection } from '@microsoft/signalr';
import { NewsItem } from './models/news-item';
import { Store } from '@ngrx/store';
import * as newsAction from './store/news.action';
import * as signalR from '@microsoft/signalr';
import { Observable } from 'rxjs';

@Injectable()
export class NewsService {
  private _hubConnection: HubConnection | undefined;
  private actionUrl: string;
  private headers: HttpHeaders;

  constructor(private http: HttpClient, private store: Store<any>) {
    this.init();
    this.actionUrl = 'https://localhost:44324/api/news/';

    this.headers = new HttpHeaders();
    this.headers = this.headers.set('Content-Type', 'application/json');
    this.headers = this.headers.set('Accept', 'application/json');
  }

  send(newsItem: NewsItem): NewsItem {
    if (this._hubConnection) {
      this._hubConnection.invoke('Send', newsItem);
    }
    return newsItem;
  }

  joinGroup(group: string): void {
    if (this._hubConnection) {
      this._hubConnection.invoke('JoinGroup', group);
    }
  }

  leaveGroup(group: string): void {
    if (this._hubConnection) {
      this._hubConnection.invoke('LeaveGroup', group);
    }
  }

  getAllGroups(): Observable<string[]> {
    return this.http.get<string[]>(this.actionUrl, { headers: this.headers });
  }

  private init() {
    this._hubConnection = new signalR.HubConnectionBuilder()
      .withUrl('https://localhost:44324/looney')
      .configureLogging(signalR.LogLevel.Information)
      .build();

    this._hubConnection.start().catch((err) => console.error(err.toString()));

    this._hubConnection.on('Send', (newsItem: NewsItem) => {
      this.store.dispatch(
        newsAction.receiveNewsItemAction({ payload: newsItem })
      );
    });

    this._hubConnection.on('JoinGroup', (data: string) => {
      console.log('received data from the hub');
      console.log(data);
      this.store.dispatch(
        newsAction.receiveGroupJoinedAction({ payload: data })
      );
    });

    this._hubConnection.on('LeaveGroup', (data: string) => {
      this.store.dispatch(newsAction.receiveGroupLeftAction({ payload: data }));
    });

    this._hubConnection.on('History', (newsItems: NewsItem[]) => {
      console.log('received history from the hub');
      console.log(newsItems);
      this.store.dispatch(
        newsAction.receiveNewsGroupHistoryAction({ payload: newsItems })
      );
    });
  }
}

In the Angular application, when the user joins a group, he/she receives all the existing messages.

original pic: https://damienbod.files.wordpress.com/2017/09/signlargroups.gif

Links

https://learn.microsoft.com/en-us/aspnet/core/signalr/introduction

https://github.com/aspnet/SignalR#readme

https://github.com/ngrx

https://dotnet.myget.org/F/aspnetcore-ci-dev/api/v3/index.json

https://dotnet.myget.org/F/aspnetcore-ci-dev/npm/

https://dotnet.myget.org/feed/aspnetcore-ci-dev/package/npm/@aspnet/signalr-client

https://www.npmjs.com/package/msgpack5

2 comments

  1. […] Using EF Core and SQLite to persist SignalR Group messages in ASP.NET Core (Damien Bowden) […]

  2. […] Using EF Core and SQLite to persist SignalR Group messages in ASP.NET Core – Damien Bowden […]

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.

%d bloggers like this: