Skip to content

11.3 - Setup SignalR Server ​

Tóm tắt: Bốn quyết định khi dựng Hub, và cái đầu tiên là dễ nhất nhưng hay bị bỏ qua: Hub<T> với interface khiến Clients.User(id).ReceiveNotification(dto) được kiểm tra lúc biên dịch — không có nó, gõ sai tên hàm client chỉ là một tin nhắn rơi vào hư không, không lỗi, không cảnh báo. Thứ hai: exception trong Hub method không giống trong controller — mặc định client chỉ nhận một thông điệp chung, và bật EnableDetailedErrors ở production là gửi stack trace cho client. Thứ ba: HubFilter là nơi đặt logging, xử lý lỗi và rate limit tập trung. Thứ tư: MessagePack giảm kích thước tin nhắn đáng kể khi lưu lượng lớn.

Mục tiêu bài học ​

Sau bài này bạn có thể:

  • Viết strongly-typed Hub và hiểu nó bảo vệ điều gì.
  • Xử lý exception trong Hub method đúng cách.
  • Viết HubFilter cho mối quan tâm cắt ngang.
  • Đặt các giới hạn cấu hình quan trọng.
  • Chọn giữa JSON và MessagePack.

Nội dung bài học ​

11.3.1 — Strongly-typed Hub ​

csharp
// Những gì SERVER gọi trên CLIENT
public interface INotificationClient
{
    Task ReceiveNotification(NotificationDto notification);
    Task ReceiveLeadUpdate(LeadUpdateDto update);
    Task UserPresenceChanged(string userId, bool isOnline);
}

public sealed class NotificationHub : Hub<INotificationClient>
{
    // Những gì CLIENT gọi trên SERVER — là method public của Hub
    public Task Ping() => Clients.Caller.ReceiveNotification(
        new NotificationDto(Guid.NewGuid(), "pong", NotificationType.Info, DateTime.UtcNow));
}

So sánh:

csharp
// KHÔNG strongly-typed — chuỗi ma thuật
await Clients.User(id).SendAsync("ReceiveNotifcation", dto);   // go sai -> im lang

// Strongly-typed — kiểm tra lúc biên dịch
await Clients.User(id).ReceiveNotification(dto);

Dòng đầu biên dịch được, chạy được, và tin nhắn không bao giờ tới client — vì client đăng ký "ReceiveNotification" chứ không phải "ReceiveNotifcation". Không có exception, không có log. Bạn chỉ biết khi người dùng báo "tôi không nhận được thông báo".

Đây là loại lỗi tốn nhiều giờ nhất trong SignalR, và interface xoá bỏ nó hoàn toàn.

Đặt interface vào một project chia sẻ thì client .NET (Blazor, MAUI) dùng lại được. Với client TypeScript, sinh kiểu từ interface đó bằng một công cụ như NSwag.

11.3.2 — Exception trong Hub method ​

csharp
public async Task MarkAsRead(Guid notificationId)
{
    var notification = await _repo.GetAsync(notificationId);
    if (notification is null)
        throw new NotFoundException(...);       // client nhận gì?
}

Khác với controller, ở đây không có IExceptionHandler, không có ProblemDetails, không có mã trạng thái. Mặc định client nhận:

An unexpected error occurred invoking 'MarkAsRead' on the server.

Không có chi tiết — đó là mặc định đúng, vì chi tiết sẽ lộ thông tin nội bộ.

Muốn client biết lý do thì ném HubException, thứ duy nhất được gửi nguyên văn:

csharp
public async Task MarkAsRead(Guid notificationId)
{
    var userId = Context.UserIdentifier
        ?? throw new HubException("Chưa xác thực");

    await using var scope = _scopeFactory.CreateAsyncScope();
    var repo = scope.ServiceProvider.GetRequiredService<INotificationRepository>();

    var notification = await repo.GetAsync(notificationId, Context.ConnectionAborted);

    if (notification is null || notification.UserId != userId)
        throw new HubException("Không tìm thấy thông báo");   // KHONG lo ton tai hay khong

    await repo.MarkAsReadAsync(notificationId, Context.ConnectionAborted);
}

Ba chi tiết:

HubException là kênh duy nhất để gửi thông điệp có ý nghĩa cho client. Mọi exception khác bị che đi.

Context.ConnectionAborted là CancellationToken của kết nối — truyền nó xuống mọi lời gọi async để công việc dừng khi client ngắt (bài 6.4).

Cùng một thông điệp cho "không tồn tại" và "không thuộc về bạn" — tránh lộ thông tin, đúng như IDOR ở bài 10.6. Phân quyền trong Hub cũng dễ bị bỏ sót y như trong controller.

EnableDetailedErrors = true gửi toàn bộ thông điệp exception cho client — chỉ bật ở Development.

11.3.3 — HubFilter ​

csharp
public sealed class LoggingHubFilter(ILogger<LoggingHubFilter> logger) : IHubFilter
{
    public async ValueTask<object?> InvokeMethodAsync(
        HubInvocationContext context,
        Func<HubInvocationContext, ValueTask<object?>> next)
    {
        var start = Stopwatch.GetTimestamp();

        try
        {
            return await next(context);
        }
        catch (HubException)
        {
            throw;                                   // loi nghiep vu — de nguyen
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Loi trong {Hub}.{Method} cua {User}",
                context.Hub.GetType().Name,
                context.HubMethodName,
                context.Context.UserIdentifier);

            throw new HubException("Đã xảy ra lỗi, vui lòng thử lại");   // che chi tiet
        }
        finally
        {
            logger.LogInformation("{Method} mat {Ms}ms",
                context.HubMethodName,
                Stopwatch.GetElapsedTime(start).TotalMilliseconds);
        }
    }

    public Task OnConnectedAsync(HubLifetimeContext context, Func<HubLifetimeContext, Task> next)
    {
        logger.LogInformation("{User} ket noi ({ConnectionId})",
            context.Context.UserIdentifier, context.Context.ConnectionId);
        return next(context);
    }

    public Task OnDisconnectedAsync(
        HubLifetimeContext context, Exception? exception, Func<HubLifetimeContext, Exception?, Task> next)
        => next(context, exception);
}
csharp
builder.Services.AddSignalR(options =>
{
    options.AddFilter<LoggingHubFilter>();
})
.AddHubOptions<NotificationHub>(options =>
{
    options.AddFilter<RateLimitHubFilter>();      // chi cho Hub nay
});

HubFilter là tương đương của middleware cho Hub — cùng vai trò như IExceptionHandler trong controller (bài 8.8).

Mẫu bắt lỗi ở trên rất đáng dùng: log đầy đủ ở server, gửi thông điệp chung cho client, đúng nguyên tắc bảo mật, và bạn không phải lặp try/catch trong từng Hub method.

Một ứng dụng khác của filter: rate limit cho Hub method — [EnableRateLimiting] không áp dụng cho lời gọi Hub, chỉ cho request HTTP. Client gọi một Hub method trong vòng lặp thì không có gì chặn.

11.3.4 — Cấu hình ​

csharp
builder.Services.AddSignalR(options =>
{
    options.EnableDetailedErrors      = builder.Environment.IsDevelopment();
    options.KeepAliveInterval         = TimeSpan.FromSeconds(15);
    options.ClientTimeoutInterval     = TimeSpan.FromSeconds(30);
    options.HandshakeTimeout          = TimeSpan.FromSeconds(15);
    options.MaximumReceiveMessageSize = 32 * 1024;
    options.StreamBufferCapacity      = 10;
    options.MaximumParallelInvocationsPerClient = 1;
});

app.MapHub<NotificationHub>("/hubs/notifications")
   .RequireAuthorization();
Tuỳ chọnÝ nghĩa
MaximumReceiveMessageSizeTrần tin nhắn từ client. 32KB mặc định — đủ cho tin nhắn, không dành cho file
MaximumParallelInvocationsPerClientMặc định 1: một client gửi 10 lời gọi thì chúng chạy tuần tự
StreamBufferCapacitySố item đệm khi stream
HandshakeTimeoutThời gian chờ handshake sau khi transport mở

MaximumParallelInvocationsPerClient = 1 là mặc định đáng biết: nó ngăn một client làm quá tải server, nhưng cũng nghĩa là một Hub method chậm sẽ chặn mọi lời gọi khác của chính client đó. Nếu Hub có method mất vài giây, hãy tăng giá trị này hoặc đẩy việc ra hàng đợi.

.RequireAuthorization() trên MapHub là cách gọn nhất để bắt buộc xác thực — chi tiết ở bài 11.5.

11.3.5 — MessagePack ​

bash
dotnet add package Microsoft.AspNetCore.SignalR.Protocols.MessagePack
csharp
builder.Services.AddSignalR().AddMessagePackProtocol();
javascript
new signalR.HubConnectionBuilder()
    .withUrl("/hubs/notifications")
    .withHubProtocol(new signalR.protocols.msgpack.MessagePackHubProtocol())
    .build();

MessagePack là định dạng nhị phân, thường nhỏ hơn JSON 30–50% và parse nhanh hơn. Đáng dùng khi lưu lượng tin nhắn lớn hoặc client trên mạng di động.

Đánh đổi: tin nhắn không đọc được trong DevTools, nên debug khó hơn đáng kể. Thực dụng nhất là giữ JSON ở Development và bật MessagePack ở production — SignalR hỗ trợ nhiều protocol cùng lúc, client chọn lúc kết nối.

11.3.6 — Rà lại code của bạn ​

Danh sách rà soát Hub server

  • Hub kế thừa Hub<T> với interface, không dùng SendAsync với chuỗi.
  • Interface client nằm ở project chia sẻ nếu có client .NET.
  • Chỉ ném HubException khi muốn client thấy thông điệp.
  • EnableDetailedErrors chỉ bật ở Development.
  • Context.ConnectionAborted được truyền xuống mọi lời gọi async.
  • Mọi Hub method đều kiểm tra quyền trên dữ liệu, không chỉ dựa vào [Authorize].
  • Có HubFilter bắt lỗi, log đầy đủ ở server và gửi thông điệp chung cho client.
  • Có cơ chế chặn client gọi Hub method quá nhanh — attribute rate limit không áp dụng.
  • MaximumReceiveMessageSize được đặt, không dùng SignalR để truyền file.
  • Biết MaximumParallelInvocationsPerClient mặc định là 1.

Bài tập áp dụng ​

Bài 1 — Gõ sai tên hàm và không có gì báo cho bạn ​

Gọi Clients.User(id).SendAsync("ReceiveNotifcation", dto) với một chữ sai, xác nhận không có lỗi nào và client không nhận gì. Chuyển sang strongly-typed và xác nhận nó không biên dịch được.

Tiêu chí hoàn thành: bạn giải thích được vì sao SignalR không thể báo lỗi ở đây, kể cả khi nó muốn.

Gợi ý và lời giải — Bài 1

Gợi ý. Server và client là hai chương trình khác nhau, có thể viết bằng hai ngôn ngữ khác nhau, và client có thể kết nối sau khi server đã chạy hàng tháng.

Lời giải — bản dùng chuỗi:

csharp
public class NotificationHub : Hub
{
    public Task Notify(string userId, NotificationDto dto)
        => Clients.User(userId).SendAsync("ReceiveNotifcation", dto);   // thiếu chữ 'i'
}
js
connection.on('ReceiveNotification', dto => showToast(dto));   // tên đúng
text
(không có gì xảy ra — không lỗi server, không lỗi client, không log)

Vì sao SignalR không thể báo lỗi. Server chỉ gửi đi một thông điệp mang tên phương thức dưới dạng chuỗi. Nó không có cách nào biết client đăng ký những tên nào:

  • client có thể là JavaScript, C#, Java, Python — server không đọc được code của họ;
  • client có thể đăng ký handler sau khi thông điệp tới;
  • một client có thể cố tình bỏ qua một số thông điệp;
  • nhiều client khác nhau đăng ký những tập tên khác nhau.

Nếu SignalR cảnh báo "không ai lắng nghe tên này", nó sẽ cảnh báo sai trong tất cả các trường hợp trên. Nên nó im lặng — đúng như thiết kế, và đó chính là vấn đề.

Bản strongly-typed:

csharp
public interface INotificationClient
{
    Task ReceiveNotification(NotificationDto dto);
    Task NotificationRead(Guid id);
}

public class NotificationHub : Hub<INotificationClient>
{
    public Task Notify(string userId, NotificationDto dto)
        => Clients.User(userId).ReceiveNotifcation(dto);      // gõ sai
}
text
error CS1061: 'INotificationClient' does not contain a definition for 'ReceiveNotifcation'

Không biên dịch được. Lỗi chuyển từ "im lặng lúc chạy" sang "đỏ lúc build".

Ba thứ được thêm cùng lúc:

ChuỗiStrongly-typed
Gõ sai tênIm lặngLỗi biên dịch
Sai số tham số hoặc kiểuIm lặngLỗi biên dịch
Đổi tên phương thứcTìm và thay bằng tayF2 trong IDE, đổi mọi nơi

Dòng thứ hai đáng chú ý: SendAsync("ReceiveNotification", dto, extraArg) cũng không báo gì. Client nhận một thông điệp có số tham số không khớp và bỏ qua nó.

Phía client vẫn còn là chuỗi — connection.on('ReceiveNotification', ...) trong JavaScript. Để đóng nốt khoảng trống đó, sinh hằng số từ interface:

ts
export const HubEvents = {
  ReceiveNotification: 'ReceiveNotification',
  NotificationRead: 'NotificationRead',
} as const;

connection.on(HubEvents.ReceiveNotification, dto => showToast(dto));

Kết hợp với bước sinh client từ OpenAPI ở bài 9.10, bạn có được kiểm tra hợp đồng ở cả hai đầu.

Kết luận: dùng Hub<T> cho mọi Hub mới. Chi phí là một interface; đổi lại là cả một lớp lỗi im lặng biến mất.


Bài 2 — Exception bị che, và HubException thì không ​

Ném InvalidOperationException trong một Hub method và ghi lại thông điệp client nhận. Đổi sang HubException và so sánh. Sau đó bật EnableDetailedErrors và xem client nhận được gì.

Tiêu chí hoàn thành: bạn nêu được vì sao SignalR che thông điệp theo mặc định, và vì sao EnableDetailedErrors không nên bật trên production.

Gợi ý và lời giải — Bài 2

Gợi ý. Thông điệp exception thường chứa những thứ không dành cho người ngoài — tên bảng, chuỗi kết nối, đường dẫn file.

Lời giải — exception thường:

csharp
public Task JoinRoom(string room)
{
    if (!_rooms.Exists(room))
        throw new InvalidOperationException(
            $"Phòng {room} không tồn tại trong bảng dbo.ChatRooms trên máy chủ sql-prod-02");

    return Groups.AddToGroupAsync(Context.ConnectionId, room);
}
js
try { await connection.invoke('JoinRoom', 'khong-ton-tai'); }
catch (e) { console.log(e.message); }
text
An unexpected error occurred invoking 'JoinRoom' on the server.

Thông điệp bị che. Client không biết gì, nhưng server có đầy đủ trong log.

Với HubException:

csharp
if (!_rooms.Exists(room))
    throw new HubException($"Phòng '{room}' không tồn tại");
text
Phòng 'khong-ton-tai' không tồn tại

Thông điệp tới nguyên vẹn. HubException là cách bạn nói với SignalR: "thông điệp này tôi cố ý viết cho client đọc".

Với EnableDetailedErrors = true:

csharp
builder.Services.AddSignalR(o => o.EnableDetailedErrors = true);
text
An unexpected error occurred invoking 'JoinRoom' on the server.
InvalidOperationException: Phòng khong-ton-tai không tồn tại trong bảng
dbo.ChatRooms trên máy chủ sql-prod-02
   at Crm.Api.Hubs.NotificationHub.JoinRoom(String room) in
      /src/Crm.Api/Hubs/NotificationHub.cs:line 42

Vì sao mặc định che. Thông điệp exception được viết cho lập trình viên đang gỡ rối, không cho người dùng cuối. Chúng thường chứa:

  • tên bảng, tên cột, tên máy chủ database,
  • đường dẫn file trên server và số dòng,
  • một phần câu truy vấn hoặc giá trị tham số,
  • đôi khi cả chuỗi kết nối khi lỗi ở tầng hạ tầng.

Ghép lại, đó là một bản đồ khá chi tiết về hệ thống của bạn — đúng loại thông tin mà bài 10.10 khuyên không để lọt ra ngoài.

Vì sao không bật trên production. Stack trace lộ cấu trúc thư mục và tên lớp nội bộ; thông điệp lộ tên bảng và tên máy chủ. Nó hữu ích ở môi trường phát triển và vô giá trị với người dùng thật — họ không làm gì được với NotificationHub.cs:line 42.

csharp
builder.Services.AddSignalR(o =>
    o.EnableDetailedErrors = builder.Environment.IsDevelopment());

Mẫu xử lý lỗi hợp lý trong Hub:

csharp
public async Task JoinRoom(string room)
{
    try
    {
        if (!await _rooms.ExistsAsync(room))
            throw new HubException($"Phòng '{room}' không tồn tại");   // cho client

        if (!await _authz.CanJoinAsync(Context.User!, room))
            throw new HubException("Bạn không có quyền vào phòng này");

        await Groups.AddToGroupAsync(Context.ConnectionId, room);
    }
    catch (HubException) { throw; }                 // đã dành cho client -> để nguyên
    catch (Exception ex)
    {
        _logger.LogError(ex, "JoinRoom thất bại cho {Room}", room);
        throw new HubException("Không vào được phòng, vui lòng thử lại");
    }
}

Chi tiết vào log, thông điệp chung chung tới client — cùng nguyên tắc với xử lý ngoại lệ toàn cục ở bài 8.8.


Bài 3 — Lời gọi từ cùng một client chạy tuần tự ​

Viết một Hub method mất 3 giây, gọi nó rồi gọi ngay một method nhanh từ cùng client. Đo thời gian method thứ hai hoàn thành và giải thích.

Tiêu chí hoàn thành: bạn nêu được vì sao SignalR chọn tuần tự thay vì song song, và cách xử lý việc dài mà không chặn kết nối.

Gợi ý và lời giải — Bài 3

Gợi ý. Nếu hai lời gọi từ cùng một client chạy song song, thứ tự hoàn thành sẽ không xác định. Hãy nghĩ tới một client gửi "vào phòng A" rồi "gửi tin vào phòng A".

Lời giải:

csharp
public async Task<string> SlowJob()
{
    await Task.Delay(3000);
    return "xong việc chậm";
}

public Task<string> Ping() => Task.FromResult("pong");
js
const t0 = performance.now();
connection.invoke('SlowJob').then(() => console.log('SlowJob', performance.now() - t0));
connection.invoke('Ping').then(()    => console.log('Ping',    performance.now() - t0));
text
SlowJob 3012
Ping 3018

Ping mất hơn 3 giây, dù bản thân nó trả về tức thì.

Vì sao SignalR chọn tuần tự. Mỗi kết nối xử lý các lời gọi lần lượt theo thứ tự nhận được, vì rất nhiều luồng nghiệp vụ phụ thuộc vào thứ tự:

text
invoke('JoinRoom', 'A')
invoke('SendMessage', 'A', 'xin chào')

Nếu hai lời gọi này chạy song song, SendMessage có thể xong trước JoinRoom và tin nhắn gửi vào hư không. Bảo đảm thứ tự làm cho hành vi tất định, và đó là đánh đổi có chủ ý.

Cho phép song song nếu bạn chắc chắn không cần thứ tự:

csharp
builder.Services.AddSignalR(o => o.MaximumParallelInvocationsPerClient = 4);
text
SlowJob 3011
Ping 4

Nhưng hãy cân nhắc kỹ: mọi giả định về thứ tự trong code của bạn sẽ im lặng trở thành sai.

Cách đúng cho việc dài — đừng làm nó trong Hub.

csharp
public async Task<string> StartExport()
{
    var jobId = Guid.NewGuid();
    await _queue.EnqueueAsync(new ExportJob(jobId, Context.UserIdentifier!), default);
    return jobId.ToString();              // trả về NGAY
}
csharp
// Worker — gửi kết quả qua IHubContext khi xong
public sealed class ExportWorker(
    EmailQueue queue,
    IHubContext<NotificationHub, INotificationClient> hub) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        await foreach (var job in queue.ReadAllAsync(ct))
        {
            var url = await _exporter.RunAsync(job, ct);
            await hub.Clients.User(job.UserId).ExportReady(job.Id, url);
        }
    }
}

Ba thứ tốt lên cùng lúc:

  1. Kết nối không bị chặn — Hub method trả về trong vài mili-giây.
  2. Việc sống sót qua restart — nó nằm trong hàng đợi, không nằm trong một Task trong bộ nhớ.
  3. Có retry — worker thử lại được, Hub method thì không.

Và nhớ rằng client cũng có giới hạn. Trình duyệt giữ một kết nối WebSocket cho mọi lời gọi; một method chậm làm mọi tương tác real-time khác của người dùng đó trông như bị treo. Với người dùng, "ứng dụng đơ" và "một nút bấm chậm" là hai cảm nhận rất khác nhau.

Tự kiểm tra ​

Strongly-typed Hub bảo vệ điều gì?

Nó khiến việc gọi hàm trên client được kiểm tra lúc biên dịch. Không có nó, gõ sai tên hàm chỉ làm tin nhắn rơi vào hư không, không exception và không log, nên bạn chỉ biết khi người dùng báo không nhận được thông báo. Đây là loại lỗi tốn nhiều giờ nhất trong SignalR.

Exception trong Hub method được xử lý thế nào?

Khác controller, không có IExceptionHandler hay ProblemDetails. Mặc định client chỉ nhận một thông điệp chung không chi tiết, và đó là mặc định đúng vì chi tiết sẽ lộ thông tin nội bộ. HubException là kênh duy nhất để gửi thông điệp có ý nghĩa cho client.

Context.ConnectionAborted dùng để làm gì?

Nó là CancellationToken của kết nối, nên truyền nó xuống mọi lời gọi async để công việc dừng khi client ngắt kết nối. Không truyền thì server vẫn chạy tiếp một việc mà không còn ai chờ kết quả.

HubFilter làm được gì?

Nó là tương đương của middleware cho Hub, đặt logging, đo thời gian và xử lý lỗi tập trung thay vì lặp try catch trong từng method. Một ứng dụng quan trọng khác là rate limit cho Hub method, vì attribute EnableRateLimiting chỉ áp cho request HTTP chứ không áp cho lời gọi Hub.

MaximumParallelInvocationsPerClient mặc định là bao nhiêu và có nghĩa gì?

Mặc định là 1, nghĩa là các lời gọi từ cùng một client chạy tuần tự. Nó ngăn một client làm quá tải server, nhưng cũng nghĩa là một Hub method chậm sẽ chặn mọi lời gọi khác của chính client đó. Với method mất vài giây thì nên tăng giá trị này hoặc đẩy việc ra hàng đợi.

Có nên dùng MessagePack không?

Nó nhỏ hơn JSON khoảng 30 tới 50 phần trăm và parse nhanh hơn, đáng dùng khi lưu lượng lớn hoặc client trên mạng di động. Đánh đổi là tin nhắn không đọc được trong DevTools nên debug khó hơn. Thực dụng nhất là giữ JSON ở Development và bật MessagePack ở production.

Kết luận ​

Ba điều đáng nhớ nhất:

  1. Hub<T> với interface. Gõ sai tên hàm client là tin nhắn biến mất im lặng.
  2. Chỉ HubException tới được client. Mọi exception khác bị che — và đó là điều đúng.
  3. Attribute rate limit không áp cho Hub method. Dùng HubFilter.

Tham khảo ​

Điều hướng ​

Phát hành theo giấy phép MIT.