Чат на Blazor Server

Все исходники /  Язык программирования C# /  OS Windows /  Web ASP.NET /  Blazor / Чат на Blazor Server

Технология Blazor .NET

Сайт чата или форума

Достоинство платформы Blazor в скорости разработки интерактивных веб-страниц. Технология позволяет значительно уменьшить объем написания JavaScript кода, и во многих случаях исключить затраты времени на создание скриптов JavaScript.

Особенно это касается рутинных стандартных операций: обработка событий onclick кнопок, обработка событий HTML DOM Events - onchange, onmouseover, onmouseout, onkeydown, onload и т. д., получение значений текстовых полей и многое другое. Необходимый посреднический сервер-клиент JavaScript код для приложения Blazor создается автоматически.

Основное кодирование в Blazor происходит на прикладном языке C#, при этом поддерживается полное взаимодействие с самостоятельно написанными JavaScript скриптами и сторонними JavaScript-библиотеками.

Чат на платформе Blazor Server

Приложение-чат Blazor создан по принципу стандартной функциональности веб-чатов. Несколько пользователей обмениваются сообщениями на открытой веб-странице приложения. Пользователи могут отправлять сообщения всей чат-группе или персональные сообщения только выбранным участникам. Приложение демонстрационное, во многом упрощенное, не имеет процедур идентификации пользователей, но предлагает вполне работоспособный принцип создания приложений-чатов на платформе Blazor. На базе данного примера можно создать рабочую версию чата или форума пользователей.

Веб-приложение чата хранит сообщения в базе данных, представляющую из себя текстовый файл json-формата. Данные пользователей фиксированы в виде константных свойств класса User.

Служба Singleton-класса DispatcherChat производит обновление страниц чата всех пользователей приложения. Singleton-класс создаётся и существует всегда в единственном экземпляре: независимо от количества открытых в браузерах веб-страниц приложения Blazor, все компоненты будут взаимодействовать с данными и событиями одного экземпляра.

Часть кода файла Program.cs:
var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();

// Создание службы единственного класса, все компоненты 
// приложения Blazor будут получать один и тот же экземпляр класса.
builder.Services.AddSingleton();

// Буквенное отображение вместо символов unicode для всех языков мира.
builder.Services.Configure(options =>
{
    options.TextEncoderSettings = new TextEncoderSettings(UnicodeRanges.All);
});

var app = builder.Build();

Класс Singleton обновления интерфейса

В приложении чата класс DispatcherChat содержит определение события, которое генерируется после отправки сообщения пользователем. Тип события создан собственный и не имеет параметров, задача события простая: обновлять веб-страницу чата всех пользователей после добавления нового сообщения в чат.

// Глобальный Singleton класс обновления страниц чата.
public class DispatcherChat
{ 
    // Тип события
    public delegate void MessageEventHandler();
    // Событие обновления интерфейса веб-страниц пользователей чата.
    public event MessageEventHandler? Refresh;
    // Метод вызова события.
    public virtual void OnRefresh() => Refresh?.Invoke();
}

Классы данных пользователей

Модель-класс User, объявляет минимальный набор данных пользователя. Модель только описывает пользователя, инициализация свойств происходит в классе Users, в принципе представляющего таблицу базы данных.

Модель пользователя:
public class User
{
    public readonly int Id;
    public readonly string Name;
    public readonly string Photo;

    public User(int id, string name, string photo)
    {
        Id = id;
        Name = name;
        Photo = photo;
    }
}

Класс Users имитирует таблицу базы данных. Здесь инициируются данные всех пользователей чата. Веб-приложения Blazor могут работать с любой базой данных и единственная цель использования объектов класса Users - это упрощение и уменьшение программного кода. Несмотря на то, что компоненты Blazor чат будут использовать различные экземпляры класса Users, инициализация у всех будет одинаковая. С некоторой степенью точности можно утверждать, что класс Users обеспечивает функциональность Singleton-класса.

Изменение количества инициализированных пользователей автоматически уменьшит или увеличит число участников чата. Распределение сообщений в новом составе также будет происходить автоматически.

Класс, исполняющий обязанности таблицы базы данных:
public class Users
{
    public readonly List ListUsers;
    // Конструктор класса инициализирует данные пользователей чата.
    public Users()
    {
        ListUsers = new(){
            new(1, "Витя", "chat-vitya.png"),
            new(2, "Петя", "chat-petya.png"),
            new(3, "Sarah", "chat-sarah.png"),
            new(4, "Michel", "chat-michel.png"),
            new(5, "Таня", "chat-tanya.png"),
        };
    }
}

Классы данных сообщений

Модель сообщения сформирована классом Message. Модель представляет данные одного сообщения (один элемент списка сообщений) в текстовом json-файле. После отправки сообщения экземпляр класса Message добавляется в список сообщений, который немедленно сохраняется в текстовом файле.

public class Message
{
    public DateTime Date { get; set; }
    public string? Letter { get; set; }
    public int IdFrom { get; set; }
    public int IdTo { get; set; }
}

Список сообщений создается классом ChatMessages. Класс имеет одно свойство автоматического типа.
Примечание. По умолчанию System.Text.Json.JsonSerializer сериализует только открытые свойства класса. Для сериализации полей необходимо пометить их атрибутом [JsonInclude] или установить опции JsonSerializerOptions.IncludeFields = true.

public class ChatMessages
{
    public List Messages { get; set; } = new();
}

Чтение и запись в json-файл

Взаимодействие с json-базой данных происходит посредством объектов класса ReadUpdateDB. Класс осуществляет операции чтения, записи и удаления сообщений. Чтение и запись происходит в асинхронном режиме. Для простоты разбора кода в методах отсутствуют функциональность обеспечения параллельного доступа к файловой базе данных нескольких пользователей одновременно. Замена файловой базы на клиент-серверную СУБД успешно решает проблему параллельного доступа к списку сообщений.

Принцип чтения и записи: считывание сообщений в память, добавление нового сообщения и запись обновленных данных обратно в файл.

public class ReadUpdateDB
{
    public async Task ReadFromDBAsync()
    {
        return await Task.Run(() =>
        {
            try
            {
                // Чтение сообщений из json-файла.
                using FileStream fs = File.OpenRead(Constants.PathDB);
                ChatMessages? messages = JsonSerializer.Deserialize(fs);

                // Избегаем объектов null.
                return messages ?? new();
            }
            catch
            {
                // При возможном повреждении json-файла, создаем
                // пустой новый.
                File.WriteAllText(Constants.PathDB, "{}");
                return new ChatMessages();
            }

        });
    }

    public async Task WriteToDBAsync(ChatMessages messages)
    {
        await Task.Run(() =>
        {
            var serializerOptions = new JsonSerializerOptions
            {
                // Формирует вид, удобный для чтения и печати.
                WriteIndented = true,

                // Настройка кодировки символов для кириллицы.
                // По умолчанию сериализатор выполняет escape - последовательность символов,
                // отличных от ASCII.То есть он заменяет их \uxxxx,
                // где xxxx является кодом Юникода символа.
                Encoder = JavaScriptEncoder.Create(UnicodeRanges.All)
            };

            // Запись массива сообщений в json-файл.
            using FileStream fs = File.OpenWrite(Constants.PathDB);
            JsonSerializer.Serialize(fs, messages, typeof(ChatMessages), serializerOptions);

        });

    }

    // Удаление всех сообщений из файловой базы данных.
    public void DeleteMessages()
    {
        File.WriteAllText(Constants.PathDB, "{}");
    }
}

Алгоритм работы чата

После загрузки приложения сообщения считываются в память и выводятся на веб-страницы пользователей:

  1. Пользователи создают сообщения и нажимают на одну из кнопок выбора получателей.
  2. Обновленный массив сообщений записывается в файловую базу. (При использовании файловой базы данных возможны артефакты из-за отсутствия возможности параллельной записи в файл)
  3. Возбуждается событие обновления веб-страниц пользователей.
  4. В методе события сообщения из обновленной базы считываются в память и выводятся на страницы пользователей.
  5. Переход к пункту 1, и так далее.

Веб-страница чата

Веб-страница чата в браузере

Веб-страница вывода сообщений представляет компонент Blazor адрес которой определён директивой @page. Компонент отвечает за взаимодействие с пользователями: считывает введенный в окно textarea текста сообщения, сохраняет его в базе и генерирует событие обновления интерфейса у всех пользователей. Для уменьшения программного кода веб-страница имеет вложенный компонент обработки сообщений.

Немного отступая от темы, можно заметить виртуозную работу синтаксиса разметки Razor, логика которого четко отделяет код языка C# от разметки HTML. Работу синтаксиса Razor можно увидеть в дополнительных компонентах ParseMessages и ItemMessage.

Программный код Blazor компонента веб-страницы чата ChatPage.razor:

@page "/chat/{id:int?}"
@* id - регистр букв не учитывается*@

@using BlazorServerChat.Data
@using BlazorServerChat.Models
@using BlazorServerChat.Pages.Components

@inject DispatcherChat _classChat

<PageTitle>
    Пользователь: @(userCurrent == null ? "..." : userCurrent.Name)
</PageTitle>

<header>
    <img src="assets/img/group_chat_96x96.png">
    <h1 class="display-6">Комната чата</h1>
    <p class="lead">Технологии Blazor</p>
</header>
<div class="row">
    <div class="col-lg-8 offset-lg-2">
        <section class="text-start">
            <a href="">На главную &gt;</a>
            <h4 class="text-start text-secondary">
                Пользователь: @(userCurrent == null ? "..." : userCurrent.Name)
            </h4>
        </section>
        <section class="px-2 pt-1" >
            @* Вложенный компонент распределения сообщений между пользователями *@
            <ParseMessages Id=@Id
                           ListUsers=@users.ListUsers
                           Messages=@chatMessages.Messages />
        </section>
        <section class="text-start mt-3">
            <label class="form-label">Ввод сообщения</label>
            <textarea style="width: 100%;"
                      rows="3" maxlength="50"
                      @bind="@textMessage" autofocus></textarea>
             @* Кнопки выбора получателя сообщения *@
            <div class="text-center mt-1">
                @foreach (var user in users.ListUsers)
                {
                    <button class="btn btn-primary m-1"
                        @onclick="() => SendMessage(user.Id)"
                        type="button">
                        @user.Name
                    </button>
                }
                <button class="btn btn-success m-1"
                        @onclick="() => SendMessage(0)"
                        type="button">
                    Всем
                </button>
                <div class="mt-2">
                    @* Для удобства тестирования создана кнопка удаления всех сообщений *@
                    <button class="btn btn-danger"
                            @onclick="() => DeleteMessages()"
                            type="button">
                        Удалить все сообщения
                    </button>
                </div>
            </div>
        </section>
    </div>
</div>

@code {
    // в url id - регистр букв не учитывается
    [Parameter]
    public int Id { get; set; }
    // Переменная считывания введенного в textarea текста сообщения.
    string? textMessage;
    // Все пользователи чата.
    private readonly Users users = new();
    // Интерфейс чтения-записи сообщений.
    private readonly ReadUpdateDB chat = new();
    // Текущий пользователь
    private User userCurrent = new(-1, "", "");
    // Интерфейс хранения сообщений в памяти.
    private ChatMessages chatMessages = new();



    protected override async Task OnInitializedAsync()
    {
        // Избегаем объекта null.
        userCurrent = users.ListUsers.Find(d => d.Id == Id) ?? new User(-1, "", "");
        _classChat.Refresh += Update;
        // Читаем все сообщения из базы.
        chatMessages = await chat.ReadFromDBAsync();
    }
    // Обновление веб-страниц пользователей. 
    void Update()
    {
        InvokeAsync(async () =>
        {
            chatMessages = await chat.ReadFromDBAsync();
            StateHasChanged();
        });
    }

    // Отправка сообщения выбранному пользователю или всем при id=0.
    private async Task SendMessage(int id)
    {
        User? u = users.ListUsers.Find(u => u.Id == Id);
        if (textMessage == null || u == null) return;
        Message m = new()
            {
                Date = DateTime.UtcNow,
                Letter = textMessage,
                IdFrom = Id,
                IdTo = id
            };

        // Новое сообщение добавляем в начало списка,
        // чтобы последнее сообщение было первым в окне сообщений.
        chatMessages.Messages.Insert(0, m);

        // Запишем новое сообщение в базу данных
        await chat.WriteToDBAsync(chatMessages);
        // Отправляем всем приложениям команду обновления интерфейса.
        _classChat.OnRefresh();
        // Очистка окна сообщения.
        textMessage = null;
    }

    private void DeleteMessages()
    {
        // Удаление всех сообщений.
        chat.DeleteMessages();
        // Отправляем всем приложениям команду обновления интерфейса.
        // Очистка окна сообщений.
        _classChat.OnRefresh();
    }
}

Вложенные компоненты

Веб-страница ChatPage.razor имеет вложенный компонент ParseMessages. В этом компоненте находится логика распределения сообщений. В итоге программный код обработки и вывода сообщений сжат до одной строчки:

<ParseMessages Id=@Id ListUsers=@users.ListUsers Messages=@chatMessages.Messages />

В свою очередь, компонент ParseMessage также имеет вложенный компонент визуализации сообщений. Чтобы сосредоточить работу над логикой распределения сообщений визуальная часть сообщений выведена в дополнительный компонент.

<ItemMessage LightBG=false Heading=@heading Letter=@m.Letter />

Одной из главных целей системы компонентов Blazor является уменьшение количества кода выходной веб-страницы путем распределения логической специализации между вложенными компонентами. Такое построение структуры компонентов способствует повышению качества и читабельности программного кода каждого отдельного компонента. Примеры реализации различных компонентов есть на странице Компоненты Blazor данного сайта.

Программный код компонента ParseMessages:
@using BlazorServerChat.Models

@foreach (Message m in Messages)
{
    // Избегаем объекта null.
    User usercurrent =
    ListUsers.Find(d => d.Id == Id) ?? new User(-1, "", "");
    User userreceiver =
    ListUsers.Find(d => d.Id == m.IdTo) ?? new User(-1, "", "");
    User usersender =
    ListUsers.Find(d => d.Id == m.IdFrom) ?? new User(-1, "", "");

    // Если отправитель не текущий пользователь (не логично от самого себя принимать сообщения),
    // но отправление предназначено для текущего пользователя
    // или всей группе чата.
    if ((m.IdTo == 0 || m.IdTo == Id) && m.IdFrom != Id)
    {
        string heading =
        usersender.Name + "-> для " + usercurrent.Name + " [ " + m.Date + "]";
        if (m.IdTo == 0) heading = usersender.Name + "-> всем [ " + m.Date + "]";
        // Компонент визуализации сообщений.
        <ItemMessage LightBG=true Heading=@heading Letter=@m.Letter />
    }

    // Если отправитель сообщения текущий пользователь.
    if (m.IdFrom == Id)
    {
        string heading = "Я -> для всех [ " + m.Date + " ] ";
        // Если сообщение определенному пользователю.
        if (m.IdTo != 0) heading = "Я -> для " + userreceiver.Name + " [ " + m.Date + " ] ";

        <ItemMessage LightBG=false Heading=@heading Letter=@m.Letter />
    }
}

@code {
    [Parameter]
    public int Id { get; set; }

    [Parameter]
    public List<Message> Messages { get; set; } = new();

    [Parameter]
    public List<User> ListUsers { get; set; } = new();
}
Программный код компонента ItemMessage:
<article class="text-start my-1 p-1"
         style="background: @SetBgColor(LightBG)[0];">
    <span class="d-block" 
	style="border-bottom: 1px dotted @SetBgColor(LightBG)[1];">
        @Heading
    </span>
    <div>
        <p class="text-start">&gt; @Letter</p>
    </div>
</article>

@code {
    [Parameter]
    public string? Heading { get; set; }
    [Parameter]
    public string? Letter { get; set; }
    [Parameter]
    public bool LightBG { get; set; }

    // Настройки цветовой гаммы элемента сообщения.
    private readonly string[] bgDark = new[] { "#d3d3d3", "#787878" };
    private readonly string[] bgLight = new[] { "#ffffff", "#B0B0B0" };

    private string[] SetBgColor(bool light)
    {
        if (light == true) return bgLight;
        else return bgDark;
    }
}

Модернизация чата до уровня Professional

Доводка данного примера веб-приложения до уровня рабочей версии многопользовательского чата или форума включает несколько шагов:
  • Замена файловой базы данных на клиент-серверную СУБД.
  • Сохранение данных пользователей в базе данных.
  • Добавление функциональности регистрации и идентификации пользователей.
  • Шифрование сообщений. (Совсем круто 😀)

После теории - практический исходник на C#

Исходник чата на технологии Blazor Server написан на языке C# в MS Visual Studio 2022, платформа .NET6. Веб-страницы построены на каркасе Bootstrap 5.

Демоверсия чата Blazor 🔎

Скачать исходник