Рецепт идеального приложения 2
При изучении ASP.NET MVC у меня всегда возникал вопрос:
А почему в качестве "веб-морды" нельзя использовать клиента, написанного с помощью синтаксиса Razor?
И на протяжении нескольких лет это было нельзя сделать.
Собственно, а теперь это можно сделать с помощью Blazor Server.
Создадим 3 звенное приложение: клиент- сервер - база данных (на самом деле будет 2 звенное приложение без БД).
В качестве клиента будет выступать Blazor Server, в качестве “бэка” будет выступать ASP.NET Core 3.1.
Для иллюстрации возможностей Blazor Server в качестве веб-клиента, я создам приложения для работы с заметками.
Для начало создам сервер. Для создания сервера буду использовать шаблон “Empty”.
Далее необходимо создать инфраструктуру для работы приложения как 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.
Отлично! Контроллер отдает данные.
Но в текущей реализации есть один недостаток. Если я вызову мето api/notes/note/{number}
, где number - идентификатор, которого нет, то я получу не красивую ошибку.
Такая подробная информация очень полезная при отладке и её нужно записать в лог, но она абсолютно не нужна для клиента.
Для того, что выводить клиенту нужное сообщение я буду использовать 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>();
И как результат я имею красивое сообщения для клиента об ошибке.
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.
После внесенных изменений решение выглядит следующим образом:
Я подготовил проект для старта и запуска Blazor. Далее нужно реализовать сам интерфейс.
Я хочу сделать приложение похожее на изображение ниже:
Слева у меня будет часть, где будут отображаться всё существующее заметки. Справа будет карточка выделенной заметки.
Судя по всему мне нужно реализовать 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();
}
Как по мне выглядит очень симпатично.
В самом компоненте нет ничего сложного. Если интеренсо то реализацию можно посмотреть тут.
Вот и результат:
Все что осталось для того, что реализовать то, что я хотел изначально, так это карточку заметки.
В карточке нужно отображать свойства объекта такие как: Title, Description. Так же в карточке должна быть возможность управлять конкретной заметки, а именно обновлять или удалять её.
Всё это реализовано здесь.
Результат карточки:
Заключение:
Наконец-то появилась возможность реализовывать Web-клиента на Razor.
Из основных недостатков можно отметить, что общение между сервером (где сидит клиент) и самим представление приходит через html. Если реализовать интерфейс, в котором есть 2^10 строчек в таблице. И при каждом клике добавляется ещё столько же. В этом случаи сервер будет гонять мегабайты html.
Как всегда, ссылка на репозиторий.