Exercise - Shared Live Counter

Shared Live Counter: Real-Time Updates with SignalR, DI & Logging in Blazor Server

Shared Live Counter: Real-Time Updates with SignalR, DI & Logging in Blazor Server

We'll build a "Shared Live Counter" where multiple browser tabs/users can see the same counter increment in real-time.

Simple Demo: Shared Live Counter (SignalR + DI + Logging)

Goal:

  • Demonstrate SignalR: All connected clients see the same counter update instantly.
  • Demonstrate Dependency Injection: A central service manages the counter's state and is easily accessed.
  • Demonstrate Blazor Logging: Track events on both the server and client (browser console).

Project Type: Blazor Server App (This simplifies SignalR setup as the server is already hosting Blazor).

Step 1: Create the Blazor Server Project

  • Open Visual Studio.
  • Select "Create a new project".
  • Choose "Blazor Server App" and click "Next".
  • Name the project LiveCounterDemo.
  • Click "Create".

Step 2: Server-Side: Implement the Shared Counter Service (for DI)

This service will hold our shared counter value and notify anyone interested when it changes. We'll use DI to make it available throughout the server.

2.1. Create Services Folder and SharedCounterService.cs:

In your LiveCounterDemo project, create a new folder called Services. Inside this folder, add a new class file named SharedCounterService.cs.

LiveCounterDemo/Services/SharedCounterService.cs:
using Microsoft.Extensions.Logging;
using System;
using System.Threading.Tasks;

namespace LiveCounterDemo.Services
{
    // Analogy: This is our 'scorekeeper'. It holds the actual score and announces changes.
    public class SharedCounterService
    {
        private int _currentCount = 0;
        private readonly ILogger<SharedCounterService> _logger; // Injected Logger

        // Event that others can subscribe to when the counter changes
        public event Func<int, Task>? OnCounterUpdate;

        public SharedCounterService(ILogger<SharedCounterService> logger)
        {
            _logger = logger;
            _logger.LogInformation("SharedCounterService initialized. Initial count: {Count}", _currentCount);
        }

        public int GetCurrentCount()
        {
            _logger.LogDebug("GetCurrentCount called. Current count: {Count}", _currentCount);
            return _currentCount;
        }

        public async Task IncrementCounter()
        {
            _currentCount++;
            _logger.LogInformation("Counter incremented to: {Count}", _currentCount);

            // Announce the new score to anyone listening (via the OnCounterUpdate event)
            if (OnCounterUpdate != null)
            {
                // We use Task.Run to avoid blocking the caller if there are many subscribers
                await Task.Run(() => OnCounterUpdate.Invoke(_currentCount));
                _logger.LogDebug("OnCounterUpdate event invoked for count: {Count}", _currentCount);
            }
        }
    }
}

2.2. Register the Service in Program.cs:

Now, we tell Blazor's Dependency Injection system about our SharedCounterService. We'll register it as a Singleton so that all users/connections share the exact same instance of the counter.

Open Program.cs in the root of your LiveCounterDemo project.

LiveCounterDemo/Program.cs (additions highlighted):
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting; // This might be there by default, keep it if present
using LiveCounterDemo.Data;
using LiveCounterDemo.Services; // Add this using statement
using LiveCounterDemo.Hubs; // Add this using statement for the Hub below
using Microsoft.Extensions.Logging; // Add this for logging config

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();
builder.Services.AddSingleton<WeatherForecastService>(); // Existing service

// Register our SharedCounterService as a Singleton.
// Analogy: We're telling the house's central management system to always provide the SAME scorekeeper.
builder.Services.AddSingleton<SharedCounterService>();

// Add SignalR services
// Analogy: We're installing the broadcast system into our house.
builder.Services.AddSignalR();

// Configure Logging:
// Analogy: Setting up our recording cameras.
builder.Logging.ClearProviders(); // Clear default console provider if you want to explicitly control
builder.Logging.AddConsole();   // Log to the console (where your server runs)
builder.Logging.AddDebug();     // Log to the debug output window in Visual Studio

// Set minimum log levels for specific categories.
builder.Logging.SetMinimumLevel(LogLevel.Debug); // Default to Debug for everything
builder.Logging.AddFilter("Microsoft.AspNetCore.SignalR", LogLevel.Debug); // Be verbose about SignalR
builder.Logging.AddFilter("LiveCounterDemo.Services.SharedCounterService", LogLevel.Information); // Our service
builder.Logging.AddFilter("LiveCounterDemo.Components.Pages.Counter", LogLevel.Information); // Our Blazor component


var app = builder.Build();

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();

app.MapBlazorHub(); // Needed for Blazor Server's SignalR connection
app.MapFallbackToPage("/_Host");

// Map our custom SignalR Hub
// Analogy: We're giving our broadcast system a specific channel name: '/counterhub'
app.MapHub<CounterHub>("/counterhub"); // Map the hub here after MapBlazorHub and before MapFallbackToPage

app.Run();

Step 3: Server-Side: Create the SignalR Hub

This hub will listen for client requests to increment the counter and broadcast updates.

3.1. Create Hubs Folder and CounterHub.cs:

In your LiveCounterDemo project, create a new folder called Hubs. Inside this folder, add a new class file named CounterHub.cs.

LiveCounterDemo/Hubs/CounterHub.cs:
using Microsoft.AspNetCore.SignalR;
using LiveCounterDemo.Services; // Add this using statement
using System;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging; // For logging

namespace LiveCounterDemo.Hubs
{
    // Analogy: This is the 'broadcaster'. It takes requests to update the score and sends out the new score.
    public class CounterHub : Hub
    {
        private readonly SharedCounterService _counterService; // Injected service
        private readonly ILogger<CounterHub> _logger; // Injected Logger

        public CounterHub(SharedCounterService counterService, ILogger<CounterHub> logger)
        {
            _counterService = counterService;
            _logger = logger;

            // Subscribe to the counter service's update event
            // Analogy: The broadcaster listens to the scorekeeper.
            _counterService.OnCounterUpdate -= OnCounterUpdateHandler; // Prevent duplicate subscriptions
            _counterService.OnCounterUpdate += OnCounterUpdateHandler;
            _logger.LogInformation("CounterHub initialized and subscribed to SharedCounterService updates.");
        }

        // Method clients can call to get the current count
        public Task GetCurrentCount()
        {
            _logger.LogInformation("Client {ConnectionId} requested current count.", Context.ConnectionId);
            return Clients.Caller.SendAsync("ReceiveCounterUpdate", _counterService.GetCurrentCount());
        }

        // Method clients can call to increment the count
        public async Task IncrementCounter()
        {
            _logger.LogInformation("Client {ConnectionId} requested counter increment.", Context.ConnectionId);
            await _counterService.IncrementCounter(); // Let the service handle the increment
            // The service's event will then trigger the broadcast below.
        }

        // This method is called when the SharedCounterService notifies of an update
        private async Task OnCounterUpdateHandler(int newCount)
        {
            _logger.LogInformation("Broadcasting new count: {NewCount} to all clients.", newCount);
            // Analogy: The broadcaster sends the new score to everyone connected.
            await Clients.All.SendAsync("ReceiveCounterUpdate", newCount);
        }

        public override Task OnConnectedAsync()
        {
            _logger.LogInformation("Client {ConnectionId} connected to CounterHub.", Context.ConnectionId);
            // When a new client connects, send them the current count immediately
            return Clients.Caller.SendAsync("ReceiveCounterUpdate", _counterService.GetCurrentCount());
        }

        public override Task OnDisconnectedAsync(Exception? exception)
        {
            _logger.LogInformation("Client {ConnectionId} disconnected from CounterHub. Exception: {ExceptionMessage}", Context.ConnectionId, exception?.Message);
            return base.OnDisconnectedAsync(exception);
        }
    }
}

Step 4: Client-Side: Modify the Blazor Counter Component

We'll modify the default Counter.razor page to connect to our SignalR Hub and display the shared count.

4.1. Install SignalR Client Package:

In your LiveCounterDemo project, right-click on the LiveCounterDemo project (the client part, not the solution). Select "Manage NuGet Packages...". Search for Microsoft.AspNetCore.SignalR.Client and install it.

4.2. Modify Components/Pages/Counter.razor:

Open Components/Pages/Counter.razor.

LiveCounterDemo/Components/Pages/Counter.razor:
@page "/counter"
@inject NavigationManager NavigationManager // Used to get the base URL for SignalR connection
@inject ILogger<Counter> Logger // Injected Logger
@using Microsoft.AspNetCore.SignalR.Client // Required for HubConnection
@implements IAsyncDisposable // To dispose the HubConnection properly

<PageTitle>Counter</PageTitle>

<h1>Shared Live Counter</h1>

<p>This counter is shared across all connected clients. Increment it and see it update everywhere!</p>

<p role="status">Current count: <b>@_currentCount</b></p>

<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>

@code {
    private int _currentCount = 0;
    private HubConnection? _hubConnection; // Our SignalR connection object

    protected override async Task OnInitializedAsync()
    {
        Logger.LogInformation("Counter component initialized. Setting up SignalR.");

        // Analogy: Our score display board (the Blazor component) connects to the broadcast channel.
        _hubConnection = new HubConnectionBuilder()
            .WithUrl(NavigationManager.ToAbsoluteUri("/counterhub")) // Connect to the hub we mapped in Program.cs
            .WithAutomaticReconnect() // Automatically try to reconnect if connection drops
            .Build();

        // Analogy: We tell the score display board, "When the broadcaster sends 'ReceiveCounterUpdate', update my display."
        _hubConnection.On<int>("ReceiveCounterUpdate", (newCount) =>
        {
            _currentCount = newCount;
            Logger.LogInformation("Received counter update from server: {NewCount}", newCount);
            InvokeAsync(StateHasChanged); // Tell Blazor to re-render the UI with the new count
        });

        // Start the connection
        try
        {
            await _hubConnection.StartAsync();
            Logger.LogInformation("SignalR connection started successfully to /counterhub.");

            // Request the current count immediately when connected (this will also be handled by OnConnectedAsync in hub)
            await _hubConnection.SendAsync("GetCurrentCount");
            Logger.LogDebug("Requested current count from hub.");
        }
        catch (Exception ex)
        {
            Logger.LogError(ex, "Error starting SignalR connection.");
        }
    }

    private async Task IncrementCount()
    {
        Logger.LogInformation("Increment button clicked. Sending increment request to hub.");
        // Analogy: We tell the score display board to tell the broadcaster, "Increment the score!"
        if (_hubConnection != null && _hubConnection.State == HubConnectionState.Connected)
        {
            await _hubConnection.SendAsync("IncrementCounter");
        }
        else
        {
            Logger.LogWarning("SignalR connection not active. Cannot send increment request.");
        }
    }

    // Important: Dispose the HubConnection when the component is removed
    public async ValueTask DisposeAsync()
    {
        if (_hubConnection is not null)
        {
            await _hubConnection.DisposeAsync();
            Logger.LogInformation("SignalR connection disposed.");
        }
    }
}

Step 5: Run the Demo and Observe

  • Set LiveCounterDemo as the startup project (it should be by default).
  • Run the application (F5 or Ctrl+F5).
  • A browser tab will open displaying your Blazor app.
  • Navigate to the "Counter" page (or it might be the default home page).
  • Open multiple browser tabs or even different browsers and navigate to the same http://localhost:XXXX/counter URL.
  • Click the "Click me" button on any of the tabs.
  • Observe: All other tabs will instantly update their counter value without refreshing!

Where to see the Logs:

  • Server-side Logs: In Visual Studio, go to View > Output. In the "Show output from:" dropdown, select "Web Server" or "ASP.NET Core Web Server". You'll see logs from SharedCounterService, CounterHub, and various SignalR messages.
  • Client-side Logs: In your browser, open the Developer Tools (usually by pressing F12). Go to the "Console" tab. You'll see logs from your Counter component and browser-side SignalR activities.

Explanation of Concepts in this Demo:

SignalR:

  • The Hub (CounterHub.cs): This is the central communication point on the server. Clients connect to it. It has methods (IncrementCounter, GetCurrentCount) that clients can call, and it uses Clients.All.SendAsync("ReceiveCounterUpdate", ...) to broadcast messages to all connected clients.
  • The Client Connection (HubConnection in Counter.razor): The Blazor component establishes a WebSocket connection to the /counterhub endpoint on the server.
  • _hubConnection.On<int>("ReceiveCounterUpdate", ...): This tells the client to listen for a message named "ReceiveCounterUpdate" from the server. When it receives one, it updates the local counter variable and triggers a UI refresh (StateHasChanged).
  • _hubConnection.SendAsync("IncrementCounter"): This is how the client tells the server's CounterHub to run its IncrementCounter method.

Dependency Injection (DI):

  • SharedCounterService: This is our custom service. It's a plain C# class that encapsulates the logic for managing the counter. It doesn't know anything about Blazor components or SignalR Hubs; it just holds the count and raises an event when it changes.
  • Registration in Program.cs (builder.Services.AddSingleton<SharedCounterService>();): We register SharedCounterService as a Singleton. This is crucial: it means only one instance of this service exists for the entire application's lifetime, ensuring all users share the exact same counter value.
  • Injection (private readonly SharedCounterService _counterService; in CounterHub.cs, and its constructor): When Blazor (or the SignalR framework) needs to create an instance of CounterHub, the DI container automatically "injects" the single instance of SharedCounterService into its constructor. The CounterHub then uses this injected service to interact with the counter logic. The SharedCounterService also has ILogger injected into its constructor.

Blazor Logging:

  • ILogger<T> Interface: This is the standard logging interface in .NET.
  • Injection (@inject ILogger<Counter> Logger in .razor files, or ILogger<SomeClass> logger in constructors): You simply declare that you need an ILogger for your component or service, and the DI container provides it. The <T> (e.g., ILogger<Counter>) specifies the "category" of the logger, making it easier to filter logs.
  • Log Methods (Logger.LogInformation, Logger.LogDebug, Logger.LogError, Logger.LogWarning): These methods allow you to emit log messages at different severity levels.
  • Configuration in Program.cs (builder.Logging.AddConsole(), builder.Logging.SetMinimumLevel(), builder.Logging.AddFilter()): This is where you configure where logs go (e.g., console, debug output) and what minimum severity level to log for different parts of your application. This allows you to control verbosity.
  • Observation: Logs appear in the server's console/output and the client's browser developer console, providing valuable insights for debugging and monitoring.

This simple demo effectively ties together these three fundamental concepts, showing how they cooperate to create a dynamic, real-time Blazor application.

Comments

Popular posts from this blog

Blazor: Building Web Apps with C# - Introduction

Blazor WebAssembly Hosted App Tutorial: Envelope Tracker System

Securing MVC-based Applications Using Blazor