Skip to content

11.8 - Notification Center Pattern ​

Tóm tắt: Trung tâm thông báo là nơi SignalR và REST phải làm việc cùng nhau, và ranh giới rất rõ: database là nguồn sự thật, SignalR chỉ là lớp tăng tốc. Thứ tự luôn là ghi trước, push sau — vì client offline thì push mất, còn bản ghi thì còn. Hai vấn đề thực tế mà mẫu này phải giải: chống trùng lặp, khi một thông báo tới cả qua push lẫn qua lần đồng bộ REST kế tiếp — giải bằng cách khử trùng theo Id ở client; và đồng bộ trạng thái đã đọc giữa các thiết bị, vì đánh dấu đã đọc trên điện thoại phải làm badge trên laptop giảm theo.

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

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

  • Thiết kế mô hình dữ liệu cho thông báo.
  • Kết hợp push và REST mà không trùng lặp.
  • Đồng bộ trạng thái đã đọc giữa nhiều thiết bị.
  • Gom nhóm thông báo để không làm phiền người dùng.
  • Dọn dẹp dữ liệu thông báo.

Nội dung bài học ​

11.8.1 — Mô hình dữ liệu ​

csharp
public sealed class Notification
{
    public Guid Id { get; init; } = Guid.CreateVersion7();   // sắp xếp theo thời gian
    public required string UserId { get; init; }
    public required NotificationType Type { get; init; }
    public required string Message { get; init; }

    public string? EntityType { get; init; }
    public string? EntityId { get; init; }
    public string? ActionUrl { get; init; }

    public DateTime CreatedAt { get; init; } = DateTime.UtcNow;
    public DateTime? ReadAt { get; set; }

    // Gom nhóm: "3 người đã bình luận"
    public string? GroupKey { get; init; }
    public int Count { get; set; } = 1;
}
csharp
modelBuilder.Entity<Notification>(e =>
{
    e.HasIndex(n => new { n.UserId, n.CreatedAt }).IsDescending(false, true);
    e.HasIndex(n => new { n.UserId, n.ReadAt });
    e.HasIndex(n => new { n.UserId, n.GroupKey });
});

Guid.CreateVersion7() (.NET 9) sinh GUID sắp xếp được theo thời gian — tránh phân mảnh index mà GUID ngẫu nhiên gây ra khi làm khoá chính. Với .NET cũ hơn, dùng bigint tự tăng hoặc thư viện sinh ULID.

ReadAt là DateTime? thay vì bool IsRead: nó cho bạn cả hai thông tin — đã đọc chưa, và đọc lúc nào — mà không tốn thêm cột.

Index đầu tiên phục vụ truy vấn chính "thông báo của tôi, mới nhất trước"; index thứ hai phục vụ đếm chưa đọc.

11.8.2 — Ghi trước, push sau ​

csharp
public sealed class NotificationService(
    AppDbContext db,
    INotificationPublisher publisher,
    ILogger<NotificationService> logger)
{
    public async Task<NotificationDto> SendAsync(
        CreateNotificationRequest request, CancellationToken ct)
    {
        // 1. Gom nhóm nếu đã có thông báo tương tự chưa đọc
        if (request.GroupKey is not null)
        {
            var existing = await db.Notifications.FirstOrDefaultAsync(
                n => n.UserId == request.UserId
                  && n.GroupKey == request.GroupKey
                  && n.ReadAt == null, ct);

            if (existing is not null)
            {
                existing.Count++;
                await db.SaveChangesAsync(ct);

                var updated = existing.ToDto();
                await publisher.PublishAsync(request.UserId, updated, ct);
                return updated;
            }
        }

        // 2. Tạo mới và LƯU trước
        var notification = new Notification { ... };
        db.Notifications.Add(notification);
        await db.SaveChangesAsync(ct);

        // 3. Push sau khi đã lưu
        var dto = notification.ToDto();
        await publisher.PublishAsync(request.UserId, dto, ct);

        return dto;
    }
}

Thứ tự này là bắt buộc (bài 11.7): client offline thì push mất, nhưng bản ghi vẫn còn và họ thấy khi mở lại ứng dụng.

INotificationPublisher là interface hẹp bọc IHubContext, nên NotificationService test được mà không cần SignalR.

Gom nhóm ở bước 1 giải quyết một vấn đề thật: 50 người thích bài viết của bạn không nên là 50 thông báo. GroupKey kiểu "post:{id}:like" cho phép gộp thành "50 người đã thích bài viết của bạn".

11.8.3 — API đồng bộ ​

csharp
[ApiController]
[Route("api/v1/notifications")]
[Authorize]
public sealed class NotificationsController(
    AppDbContext db, ICurrentUser user) : ControllerBase
{
    [HttpGet]
    public async Task<ActionResult<PagedResponse<NotificationDto>>> GetAll(
        [FromQuery] DateTime? since,
        [FromQuery] bool unreadOnly = false,
        [FromQuery] int limit = 20,
        CancellationToken ct = default)
    {
        var query = db.Notifications.AsNoTracking()
            .Where(n => n.UserId == user.Id);

        if (since is { } s)      query = query.Where(n => n.CreatedAt > s);
        if (unreadOnly)          query = query.Where(n => n.ReadAt == null);

        var items = await query
            .OrderByDescending(n => n.CreatedAt).ThenByDescending(n => n.Id)
            .Take(Math.Min(limit, 100))
            .Select(n => n.ToDto())
            .ToListAsync(ct);

        return Ok(items);
    }

    [HttpGet("unread-count")]
    public async Task<ActionResult<int>> GetUnreadCount(CancellationToken ct)
        => Ok(await db.Notifications
            .CountAsync(n => n.UserId == user.Id && n.ReadAt == null, ct));

    [HttpPost("{id:guid}/read")]
    public async Task<IActionResult> MarkAsRead(Guid id, CancellationToken ct)
    {
        var affected = await db.Notifications
            .Where(n => n.Id == id && n.UserId == user.Id && n.ReadAt == null)
            .ExecuteUpdateAsync(s => s.SetProperty(n => n.ReadAt, DateTime.UtcNow), ct);

        if (affected == 0) return NotFound();

        // Báo cho CÁC THIẾT BỊ KHÁC của chính người đó
        await _publisher.PublishReadStateAsync(user.Id, id, ct);
        return NoContent();
    }
}

Ba chi tiết:

since là tham số then chốt — nó cho client lấy đúng những gì đã bỏ lỡ sau khi kết nối lại (bài 11.4), thay vì tải lại toàn bộ.

ExecuteUpdateAsync cập nhật thẳng bằng một câu UPDATE, không nạp entity. Điều kiện n.UserId == user.Id vừa là phân quyền vừa là bộ lọc — không ai đánh dấu đọc thông báo của người khác (bài 10.6).

Push lại trạng thái đã đọc. Người dùng đọc trên điện thoại thì badge trên laptop phải giảm theo. Đây là chi tiết nhỏ mà thiếu nó thì trải nghiệm trông hỏng.

11.8.4 — Chống trùng lặp ở client ​

Một thông báo có thể tới hai lần: qua push, và qua lần đồng bộ REST kế tiếp. Khử trùng theo Id:

typescript
class NotificationStore {
  private readonly seen = new Set<string>();
  private items: NotificationDto[] = [];

  add(notification: NotificationDto): boolean {
    if (this.seen.has(notification.id)) return false;    // da co

    this.seen.add(notification.id);
    this.items = [notification, ...this.items];
    return true;
  }

  markRead(id: string): void {
    const item = this.items.find(n => n.id === id);
    if (item && !item.readAt) {
      item.readAt = new Date().toISOString();
      this.unreadCount--;
    }
  }
}

Client đăng ký cả hai nguồn vào cùng một store:

typescript
hub.on("ReceiveNotification", n => store.add(n));
hub.on("NotificationRead", ({ id }) => store.markRead(id));

connection.onreconnected(async () => {
  const missed = await api.getNotifications({ since: store.lastCreatedAt });
  missed.forEach(n => store.add(n));      // trùng thì bị bỏ qua
});

markRead kiểm tra !item.readAt trước khi giảm bộ đếm — nếu không, đánh dấu đọc hai lần (một từ hành động của chính mình, một từ push của thiết bị khác) sẽ làm bộ đếm âm.

Set này lớn dần theo phiên. Với ứng dụng chạy nhiều giờ, giới hạn kích thước hoặc chỉ giữ Id của N thông báo gần nhất.

11.8.5 — Dọn dẹp ​

Bảng thông báo phình rất nhanh — 1.000 người dùng × 20 thông báo mỗi ngày là 600.000 bản ghi mỗi tháng.

csharp
public sealed class NotificationCleanupWorker(
    IServiceScopeFactory scopeFactory,
    ILogger<NotificationCleanupWorker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromHours(24));

        while (await timer.WaitForNextTickAsync(ct))
        {
            try
            {
                await using var scope = scopeFactory.CreateAsyncScope();
                var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

                // Da doc + qua 30 ngay
                var readCutoff = DateTime.UtcNow.AddDays(-30);
                var removed = await db.Notifications
                    .Where(n => n.ReadAt != null && n.ReadAt < readCutoff)
                    .ExecuteDeleteAsync(ct);

                // Chưa đọc nhưng quá 90 ngày — không ai đọc nữa
                var staleCutoff = DateTime.UtcNow.AddDays(-90);
                removed += await db.Notifications
                    .Where(n => n.ReadAt == null && n.CreatedAt < staleCutoff)
                    .ExecuteDeleteAsync(ct);

                logger.LogInformation("Đã xoá {Count} thông báo cũ", removed);
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "Dọn dẹp thông báo thất bại");
            }
        }
    }
}

Hai mốc khác nhau có chủ đích: đã đọc thì xoá sớm, chưa đọc thì giữ lâu hơn — nhưng vẫn phải xoá, vì thông báo 3 tháng tuổi không còn ý nghĩa.

Nhớ khoá phân tán nếu chạy nhiều instance (bài 9.9), và ExecuteDeleteAsync trên bảng lớn nên chia lô để không giữ khoá quá lâu.

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

Danh sách rà soát trung tâm thông báo

  • Thông báo được ghi database trước, push sau.
  • Tầng nghiệp vụ dùng interface hẹp, không phụ thuộc IHubContext.
  • Có index trên (UserId, CreatedAt) và (UserId, ReadAt).
  • Khoá chính sắp xếp được theo thời gian, không phải GUID ngẫu nhiên.
  • API có tham số since để client lấy đúng phần đã bỏ lỡ.
  • Đánh dấu đã đọc có điều kiện UserId, vừa phân quyền vừa lọc.
  • Trạng thái đã đọc được push tới các thiết bị khác của cùng người dùng.
  • Client khử trùng lặp theo Id giữa push và đồng bộ REST.
  • Bộ đếm chưa đọc không giảm hai lần cho cùng một thông báo.
  • Thông báo tương tự được gom nhóm bằng GroupKey.
  • Có job dọn dẹp với hai mốc thời gian cho đã đọc và chưa đọc.

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

Bài 1 — Thông báo hiện hai lần ​

Đẩy một thông báo qua push và để nó xuất hiện trong lần đồng bộ REST kế tiếp. Xác nhận client hiển thị hai lần. Thêm khử trùng theo Id và kiểm chứng.

Tiêu chí hoàn thành: bạn nêu được vì sao trùng lặp là hệ quả tất yếu của thiết kế hai đường, chứ không phải một lỗi cần sửa ở phía server.

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

Gợi ý. Server không biết client đã nhận được push hay chưa. Nếu nó biết, bạn đã không cần đường đồng bộ REST.

Lời giải — tái hiện:

js
connection.on('ReceiveNotification', n => store.push(n));    // đường 1

async function sync() {
    const items = await fetch('/api/v1/notifications?unreadOnly=true').then(r => r.json());
    items.forEach(n => store.push(n));                       // đường 2
}

setInterval(sync, 30000);
text
09:00:00  push tới  -> store: [018f3a1b...]
09:00:30  sync chạy -> store: [018f3a1b..., 018f3a1b...]

Cùng một thông báo, hai bản ghi, hai dòng trên giao diện.

Câu trả lời cho tiêu chí. Hai đường tồn tại vì chúng bù cho nhau:

text
Push:  nhanh, nhưng MẤT khi client offline
Sync:  chậm, nhưng KHÔNG BAO GIỜ mất

Để tránh trùng, server phải biết client đã nhận push thành công hay chưa — nghĩa là cần cơ chế xác nhận cho từng thông báo, cho từng thiết bị, và phải nhớ trạng thái đó. Đó chính là thứ mà đường đồng bộ REST đang thay thế, chỉ đơn giản hơn nhiều.

Nên trùng lặp không phải lỗi: nó là cái giá cố ý của việc chọn "không bao giờ mất" thay vì "không bao giờ trùng". Và giữa hai loại lỗi, trùng là loại sửa được ở client bằng vài dòng, còn mất thì không.

Khử trùng theo Id:

ts
const seen = new Set<string>();

function add(n: Notification) {
    if (seen.has(n.id)) return;       // trùng thì bỏ qua
    seen.add(n.id);
    store.push(n);
}

connection.on('ReceiveNotification', add);

Hoặc dùng Map để vừa khử trùng vừa cập nhật được bản ghi đã có:

ts
const items = new Map<string, Notification>();

function upsert(n: Notification) {
    items.set(n.id, { ...items.get(n.id), ...n });     // đến hai lần cũng chỉ một bản
}

Map tốt hơn Set ở một chỗ quan trọng: khi thông báo được cập nhật — ví dụ đếm gom nhóm tăng từ 3 lên 4 — bản mới ghi đè bản cũ thay vì bị bỏ qua.

Hai điều kiện để khử trùng hoạt động:

  1. Id phải do server sinh, không phải client. Server là nơi duy nhất biết hai đường đang nói về cùng một bản ghi.
  2. Id phải ổn định. Cùng một thông báo phải mang cùng Id qua cả push lẫn REST — tức là push phải gửi bản ghi đã lưu, không phải một DTO dựng tạm:
csharp
_db.Notifications.Add(notification);
await _db.SaveChangesAsync(ct);                       // lưu trước, có Id

await _hub.Clients.User(userId).ReceiveNotification(Map(notification));   // push CÙNG bản ghi

Dùng Guid.CreateVersion7() cho Id vì nó sắp xếp được theo thời gian — cần cho truy vấn WHERE Id > @lastSeen ở đường đồng bộ, như bài 11.4 trình bày.


Bài 2 — Bộ đếm chưa đọc xuống dưới 0 ​

Đánh dấu đã đọc từ hai thiết bị gần như đồng thời và xác nhận bộ đếm chưa đọc xuống dưới 0. Thêm kiểm tra !item.readAt và kiểm chứng.

Tiêu chí hoàn thành: bạn nêu được vì sao bài này là một dạng của lost update, và vì sao sửa ở client vẫn chưa đủ.

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

Gợi ý. Hai thiết bị cùng giảm một bộ đếm dựa trên giá trị chúng đang thấy.

Lời giải — tái hiện:

ts
// Client giảm bộ đếm khi đánh dấu đã đọc
async function markRead(id: string) {
    await fetch(`/api/v1/notifications/${id}/read`, { method: 'POST' });
    unreadCount.value--;                    // giảm VÔ ĐIỀU KIỆN
}
text
Thiết bị A: markRead(X) -> unreadCount 3 -> 2
Thiết bị B: markRead(X) -> unreadCount 3 -> 2      (cùng thông báo X)

Server phát NotificationRead(X) cho CẢ HAI thiết bị
Thiết bị A: nhận sự kiện -> giảm tiếp -> 1
Thiết bị B: nhận sự kiện -> giảm tiếp -> 1

Thực tế chỉ có 1 thông báo được đọc, nhưng bộ đếm giảm 4 lần
-> unreadCount = -1

Vì sao đây là lost update. Mỗi thiết bị đọc giá trị hiện tại, tính giá trị mới từ đó, rồi ghi đè — đúng mẫu "đọc rồi ghi" ở bài 13.8. Khác biệt duy nhất là trạng thái nằm trong bộ nhớ trình duyệt thay vì trong database, nên không có transaction nào bảo vệ.

Sửa ở client — kiểm tra trạng thái trước khi giảm:

ts
function markReadLocal(id: string) {
    const item = items.get(id);
    if (!item || item.readAt) return;       // đã đọc rồi -> không giảm nữa

    item.readAt = new Date().toISOString();
    unreadCount.value = Math.max(0, unreadCount.value - 1);
}

connection.on('NotificationRead', markReadLocal);

Giờ thao tác idempotent: gọi bao nhiêu lần cho cùng một id cũng chỉ giảm một lần.

Nhưng sửa ở client chưa đủ — và đây là phần quan trọng của bài. Trạng thái trong bộ nhớ trình duyệt luôn có thể lệch với database vì những lý do nằm ngoài tầm kiểm soát của bạn:

  • người dùng mở tab mới, trạng thái bắt đầu từ đầu;
  • mất kết nối một lúc rồi quay lại, bỏ lỡ vài sự kiện;
  • một thiết bị thứ ba đánh dấu đã đọc hàng loạt.

Nên bộ đếm phải lấy từ server làm chuẩn:

csharp
[HttpGet("unread-count")]
public async Task<ActionResult<int>> GetUnreadCount(CancellationToken ct)
    => Ok(await _db.Notifications
        .CountAsync(n => n.UserId == User.GetUserId() && n.ReadAt == null, ct));
ts
connection.on('NotificationRead', async id => {
    markReadLocal(id);
    unreadCount.value = await fetchUnreadCount();     // server là nguồn sự thật
});

Và phía server cũng phải idempotent:

csharp
[HttpPost("{id:guid}/read")]
public async Task<IActionResult> MarkRead(Guid id, CancellationToken ct)
{
    var affected = await _db.Notifications
        .Where(n => n.Id == id && n.UserId == User.GetUserId() && n.ReadAt == null)
        .ExecuteUpdateAsync(s => s.SetProperty(n => n.ReadAt, DateTime.UtcNow), ct);

    if (affected > 0)                                  // CHỈ phát sự kiện khi thật sự đổi
        await _hub.Clients.User(User.GetUserId().ToString()).NotificationRead(id);

    return NoContent();
}

Điều kiện n.ReadAt == null nằm trong câu UPDATE, nên hai request đồng thời chỉ có một cái ảnh hưởng dòng — và chỉ một sự kiện được phát. Đây là mẫu "làm tất cả trong một câu lệnh" ở bài 12.6, áp dụng cho một bài toán rất đời thường.


Bài 3 — Gom nhóm thông báo ​

Sinh 50 thông báo cùng GroupKey và xác nhận chỉ một bản ghi tồn tại với Count = 50. Đo số dòng trong database so với khi không gom.

Tiêu chí hoàn thành: bạn nêu được hai lợi ích của gom nhóm, và một trường hợp không nên gom.

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

Gợi ý. Một lợi ích thuộc về người dùng, lợi ích kia thuộc về hệ thống.

Lời giải — logic gom:

csharp
public async Task NotifyAsync(Guid userId, string groupKey, string text, CancellationToken ct)
{
    // 1. Gom nhóm nếu đã có thông báo tương tự chưa đọc
    var existing = await _db.Notifications
        .FirstOrDefaultAsync(n => n.UserId == userId
                               && n.GroupKey == groupKey
                               && n.ReadAt == null, ct);

    if (existing is not null)
    {
        existing.Count++;
        existing.Text = text;
        existing.UpdatedUtc = DateTime.UtcNow;
        await _db.SaveChangesAsync(ct);

        await _hub.Clients.User(userId.ToString()).NotificationUpdated(Map(existing));
        return;
    }

    // 2. Tạo mới và LƯU trước
    var notification = new Notification
    {
        Id = Guid.CreateVersion7(), UserId = userId,
        GroupKey = groupKey, Text = text, Count = 1,
        CreatedUtc = DateTime.UtcNow
    };
    _db.Notifications.Add(notification);
    await _db.SaveChangesAsync(ct);

    // 3. Push sau khi đã lưu
    await _hub.Clients.User(userId.ToString()).ReceiveNotification(Map(notification));
}

Kiểm chứng:

csharp
for (int i = 1; i <= 50; i++)
    await _service.NotifyAsync(userId, $"deal:{dealId}:comment",
                               $"{i} người đã bình luận", ct);
sql
SELECT Id, GroupKey, Count, Text FROM Notifications WHERE UserId = '8f3a...';
text
018f3a1b...  deal:7c1d:comment  50  "50 người đã bình luận"

Một dòng thay vì 50.

Hai lợi ích:

1. Với người dùng — danh sách đọc được. Không gom thì mở chuông thông báo ra thấy 50 dòng giống hệt nhau, và thông báo quan trọng khác bị đẩy xuống dưới. Gom lại thành "50 người đã bình luận" là đúng thứ người ta muốn biết.

2. Với hệ thống — số dòng và số lần push giảm theo cùng tỷ lệ.

Không gomCó gom
Dòng trong database501
Tin đẩy qua SignalR5050 (nhưng loại NotificationUpdated)
Dòng client phải render501

Với một deal sôi nổi có 20 người theo dõi, con số là 1.000 dòng thành 20 dòng.

Thiết kế GroupKey quyết định chất lượng việc gom:

csharp
$"deal:{dealId}:comment"        // gom bình luận theo từng deal
$"lead:{leadId}:assigned"       // gom việc gán lead
$"invoice:overdue"              // gom mọi hoá đơn quá hạn

Quá hẹp thì không gom được gì; quá rộng thì gộp nhầm những việc không liên quan.

Khi nào KHÔNG nên gom. Khi mỗi sự kiện là một hành động riêng biệt cần phản hồi riêng:

text
KHÔNG gom: "Hợp đồng A cần bạn duyệt", "Hợp đồng B cần bạn duyệt"

Gom thành "2 hợp đồng cần duyệt" thì người dùng mất luôn đường dẫn tới từng cái, và phải tự đi tìm. Quy tắc phân biệt: nếu người dùng cần làm gì đó khác nhau cho từng thông báo thì đừng gom.

Một chi tiết cài đặt. Đoạn "tìm rồi cập nhật" ở trên có race condition khi hai thông báo tới gần như đồng thời — cả hai cùng thấy chưa có bản ghi và cùng tạo mới. Với thông báo thì hậu quả nhẹ (hai nhóm thay vì một), nhưng nếu muốn chặt chẽ thì thêm ràng buộc:

sql
CREATE UNIQUE INDEX UX_Notifications_Group ON Notifications(UserId, GroupKey)
    WHERE ReadAt IS NULL;

rồi bắt DbUpdateException và thử lại nhánh cập nhật — cùng mẫu với idempotency ở bài 9.12.

Tự kiểm tra ​

Vì sao phải ghi database trước rồi mới push?

Vì client offline thì push mất hoàn toàn, còn bản ghi thì vẫn còn và họ thấy khi mở lại ứng dụng. Database là nguồn sự thật còn SignalR chỉ là lớp tăng tốc.

Vì sao dùng ReadAt kiểu DateTime? thay vì bool IsRead?

Vì nó cho cả hai thông tin trong một cột: đã đọc chưa, và đọc lúc nào. Thông tin thời điểm đọc hữu ích cho việc dọn dẹp và cho phân tích, mà không tốn thêm cột nào.

Tham số since trong API thông báo để làm gì?

Để client lấy đúng những thông báo đã bỏ lỡ trong lúc mất kết nối, thay vì tải lại toàn bộ danh sách. Đó là phần bù cho việc SignalR không có hàng đợi và tin nhắn gửi khi client offline thì mất.

Vì sao trùng lặp xảy ra và khử thế nào?

Vì một thông báo có thể tới hai lần: qua push và qua lần đồng bộ REST kế tiếp. Khử bằng cách giữ một tập Id đã thấy ở client và bỏ qua những cái đã có. Nhớ giới hạn kích thước tập đó với ứng dụng chạy nhiều giờ.

Vì sao bộ đếm chưa đọc có thể bị âm?

Vì đánh dấu đã đọc có thể tới hai lần, một từ hành động của chính người dùng và một từ push của thiết bị khác. Phải kiểm tra thông báo đó chưa đọc trước khi giảm bộ đếm.

Gom nhóm thông báo giải quyết gì?

Nó tránh việc 50 người thích một bài viết thành 50 thông báo riêng. Dùng một GroupKey để gộp những thông báo tương tự chưa đọc thành một bản ghi với bộ đếm, vừa đỡ làm phiền người dùng vừa giảm đáng kể số dòng trong database.

Kết luận ​

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

  1. Database là nguồn sự thật, push là lớp tăng tốc. Ghi trước, push sau.
  2. Khử trùng lặp theo Id — push và đồng bộ REST sẽ chồng lên nhau.
  3. Đẩy cả trạng thái đã đọc để badge đồng bộ giữa các thiết bị.

Tham khảo ​

Điều hướng ​

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