Рецепт идеального приложения 2

При изучении ASP.NET MVC у меня всегда возникал вопрос:

А почему в качестве "веб-морды" нельзя использовать клиента, написанного с помощью синтаксиса Razor?

И на протяжении нескольких лет это было нельзя сделать.

Собственно, а теперь это можно сделать с помощью Blazor Server.

Создадим 3 звенное приложение: клиент- сервер - база данных (на самом деле будет 2 звенное приложение без БД).

В качестве клиента будет выступать Blazor Server, в качестве “бэка” будет выступать ASP.NET Core 3.1.

Для иллюстрации возможностей Blazor Server в качестве веб-клиента, я создам приложения для работы с заметками.

Для начало создам сервер. Для создания сервера буду использовать шаблон “Empty”.

Empty template

Далее необходимо создать инфраструктуру для работы приложения как Web Api. Для этого я создам директорию /Models. Понятно из названия, что в данной папке будет лежат данные, которые необходимы для приложения.

Web Api

Models

Основным сущностью моего приложения будет класс Note. Данный класс будет представлять из себя заметку.

    public class Note
    {
        public int Id { get; set; }

        public string Title { get; set; }

        public string Description { get; set; }
    }

Сущность заметки не представляет из себя ничего сложного: id - идентификатор объекта в БД title - наименования объекта description - “тело” заметке

Repository

Далее нужно создать механизм для работы с заметками. Для этого я создам репозиторий.

    public interface INoteRepository
    {
        //Возвращение всех заметок

        Task<List<Note>> GetAllNotes(CancellationToken token);

        //Возвращение конкретной заметки (по индентификатору)

        Task<Note> GetNote(int noteId, CancellationToken token);

        //Добавление или обновление заметки

        Task AddOrUpdateNote(Note note, CancellationToken token);

        //хз что😁

        Task DeleteNote(int noteId, CancellationToken token);
    } 

Реализацию самого репозитория можно посмотреть по ссылки.

Controller

Без лишних слов, код контроллера

    [ApiController, Route("api/notes")]
    public class NotesController : ControllerBase
    {
        private readonly INoteRepository _repository;
        public NotesController(INoteRepository repository)
            => _repository = repository;

        [HttpGet, Route("list")]
        public Task<List<Models.Note>> GetAllNotes(CancellationToken token)
            => _repository.GetAllNotes(token);

        [HttpGet, Route("{noteId}")]
        public Task<Models.Note> GetNote(int noteId, CancellationToken token)
            => _repository.GetNote(noteId, token);

        [HttpPost, Route("createOrUpdate")]
        public async Task<IActionResult> GetNote(Models.Note note, CancellationToken token)
        {
            await _repository.AddOrUpdateNote(note, token);
            return Ok();
        }
        
        [HttpDelete, Route("delete/{noteId}")]
        public async Task<IActionResult> DeleteNote(int noteId, CancellationToken token)
        {
            await _repository.DeleteNote(noteId, token);
            return Ok();
        }
    }

Здесь следует упомянуть, что при создании контроллера я использовал атрибут Route. Из этого следует, что при создании маршрута не нужно указывать шаблон для маршрута.

Startup

Startup — это настройка сервисов необходимых для работы приложения, настройка самого приложения, и настройка среды. В данном случаи Startup представляет из себя почти базовую настройку приложения.

    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddTransient<INoteRepository, NoteRepsitory>();
            services.AddControllers();
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseRouting();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllers();
            });
        }
    }

Время проверки моей “Апишки” через Postman.

Web Api

Отлично! Контроллер отдает данные.

Но в текущей реализации есть один недостаток. Если я вызову мето api/notes/note/{number}, где number - идентификатор, которого нет, то я получу не красивую ошибку.

Error

Такая подробная информация очень полезная при отладке и её нужно записать в лог, но она абсолютно не нужна для клиента.

Для того, что выводить клиенту нужное сообщение я буду использовать Middleware. Суть промежуточного ПО в этом случаи, что я буду вызывать метод из MVC и ловить исключении, которые возникнуть входе исполнение Action’a.

Microsoft любезно предоставила возможность создавать Middleware как угодно. Единственное ограничение: пользовательский Middleware должен принимать RequestDelegate и иметь метод с сигнатурой:

 Task Invoke(HttpContext context)

Вот само промежуточное ПО:

    public class HttpExceptionMiddleware
    {
        private readonly RequestDelegate _next;
        public HttpExceptionMiddleware(RequestDelegate dlt)
            => _next = dlt;

        public async Task Invoke(HttpContext context)
        {
            try
            {
                await _next(context);
            }
            catch (Exception ex)
            {
                await context.Response.WriteAsync(ex.Message);
            }
        }
    }

И добавляем наш Middleware в класс Startup.cs:

 app.UseMiddleware<HttpExceptionMiddleware>();

И как результат я имею красивое сообщения для клиента об ошибке.

The nice message for client

Client

Как я писал выше в качестве клиента я буду использовать Blazor Server.

Для начала создадим проект на .NET Core 3.1 по типу Empty.

Первое что надо сделать настроить его для работы с Blazor. Для этого я внесу изменение в класс Startup.

    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddRazorPages();
            services.AddServerSideBlazor();
            services.AddHttpClient();
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

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

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapFallbackToPage("/_Host");
                endpoints.MapBlazorHub();
            });
        }
    }

После этого нужно создать директорию Pages. В данной директории будет храниться хост для запуска компонентов Blazor.

Для того, чтобы все компоненты могли использовать одинаковые сборки, без директивы using в начале файла, нужно создать файл _Imports.razor.

После внесенных изменений решение выглядит следующим образом:

The solution

Я подготовил проект для старта и запуска Blazor. Далее нужно реализовать сам интерфейс.

Я хочу сделать приложение похожее на изображение ниже:

The design

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

Судя по всему мне нужно реализовать 3 компонента: лист с заметками, компонент самой заметки и карточка заметки.

Компонент заметки — это то, как заметка будет представлена в списки, а карточка заметки — это отображение заметки, когда пользователь "кликает" на неё

Первым делом я создам компонент NoteComponent.razor, который будет отвечать за отображение заметки в списки. Данный компонент будет принимать в качестве параметра объект Note, будет менять стиль при взаимодействии с указателем мыши, и будет выкидывать событие о клике.

Без лишних слов, код:

<div class="@Class border-bottom" @onmouseover="OnMouseOver" @onmouseout="OnMouseOut" @onclick="@(()=>OnMouseClick(Model.Id))"
     pag-action="Edit" page-model="@Model.Id">
    <div class="note-title">
        @Model.Title
    </div>
    <div class="note-body">
        @Model.Description
    </div>
</div>

@code {

    public void OnMouseOver() => Class = "clr-selected";

    public void OnMouseOut() => Class = "";

    public Task OnMouseClick(int noteId) => OnClickNoteCallBack.InvokeAsync(Model);

    [Parameter]
    public Note Model { get; set; }

    [Parameter]
    public EventCallback<Note> OnClickNoteCallBack { get; set; }

    public string Class { get; set; }
}

Далее необходимо реализовать компонент, который отображаем список всё заметок, но для начало нужно реализовать сервис для “общение” с WebApi.

Сервис должен получать данные от WebApi по http, для этого ему нужен HttpClient. Т.к. при Dispose объекта HttpClient не всегда освобождается порт, я воспользуется новой возможностью, который появился в .Net Core, IHttpClientFactory.

У HttpClient нет методов, которые позволили мне отправлять и получать данные от сервера в формате Json. Для того, чтобы получить данные в нужном мне формате мне пришлось бы сначала сделать запрос к сервера, затем считать контент и только потом десериализовать его. А т.к. сервис, в клиенте, будет неоднократно получать и отправлять данные серверу, то придется писать данный код много раз. Чтобы этого не дать я вынес данный код в методы расширения для HttCliet.

    public static class HttpClientExtensions
    {
        public static async Task<T> GetJsonAsync<T>(this HttpClient httpClient, string requestedUrl)
        {
            var response = await httpClient.GetAsync(requestedUrl);

            if (!response.IsSuccessStatusCode)
                throw new HttpRequestException($"Can't call {requestedUrl}; Status code {response.StatusCode}");

            var content = await response.Content.ReadAsStringAsync();
            return JsonConvert.DeserializeObject<T>(content);
        }

        public static async Task<bool> PostJsonAsync<T>(this HttpClient httpClient, string requestedUrl, T obj)
        {
            var stringContent = new StringContent(JsonConvert.SerializeObject(obj), Encoding.UTF8, "application/json");
            var response = await httpClient.PostAsync(requestedUrl, stringContent);
            return response.IsSuccessStatusCode;
        }
    }

Сам сервис, который находится в клиенте:

    public class NoteService : INoteService, IDisposable
    {
        //Адрес сервера

        private const string Server = "https://localhost:44350/api/";
        private readonly HttpClient _httpClient;

        public NoteService(IHttpClientFactory httpFactory)
            => _httpClient = httpFactory.CreateClient();


        public async Task CreateOrUpdate(Note note)
        {
            const string method = "notes/createOrUpdate";
            await _httpClient.PostJsonAsync<Note>($"{Server}{method}", note);
        }

        public Task<List<Note>> GetNotes()
        {
            const string method = "notes/list";
            return _httpClient.GetJsonAsync<List<Note>>($"{Server}{method}");
        }

        public async Task Delete(int noteId)
        {
            const string method = "notes/delete/";
            await _httpClient.DeleteAsync($"{Server}{method}{noteId}");
        }

        public void Dispose()
            => _httpClient?.Dispose();
    }

Как по мне выглядит очень симпатично.

В самом компоненте нет ничего сложного. Если интеренсо то реализацию можно посмотреть тут.

Вот и результат:

The list of notes

Все что осталось для того, что реализовать то, что я хотел изначально, так это карточку заметки.

В карточке нужно отображать свойства объекта такие как: Title, Description. Так же в карточке должна быть возможность управлять конкретной заметки, а именно обновлять или удалять её.

Всё это реализовано здесь.

Результат карточки:

The list of notes

Заключение:

Наконец-то появилась возможность реализовывать Web-клиента на Razor.

Из основных недостатков можно отметить, что общение между сервером (где сидит клиент) и самим представление приходит через html. Если реализовать интерфейс, в котором есть 2^10 строчек в таблице. И при каждом клике добавляется ещё столько же. В этом случаи сервер будет гонять мегабайты html.

Как всегда, ссылка на репозиторий.

Написано 9 марта 2020