Что кодим?
Итак, со стеком разобрались. У нас будет:
- ASP.NET Core для бэкенда;
- Blazor для фронтенда;
- RavenDB в качестве основной СУБД;
- Garnet для кеша.
Hello World’ом в мире веб-приложений принято считать различные to do листы. Подобное приложение мы и напишем.
Я создал пустое решение (empty solution) в IDE, а затем добавил туда проект Web API на ASP.NET Core:
Бэкенд: репозиторий и эндпоинты
RavenDB — это документно-ориентированная NoSQL база данных, разработанная для упрощения работы с данными и обеспечения высокой производительности. Она поддерживает ACID-транзакции, time-series, полнотекстовый поиск и многое другое. Подробнее можно ознакомиться на сайте проекта.
Для общения с RavenDB используется библиотека RavenDB.Client. Ее можно добавить в интерфейсе или с помощью команды:
dotnet add package RavenDB.Client
Далее добавим папку DataAccess и класс ToDoItem — это будет нашей моделью:
public class ToDoItem
{
public string Id { get; set; }
public string Title { get; set; }
public string Description { get; set; }
public DateTime Deadline { get; set; }
}
Туда же добавим класс ToDoRepository, который будет общаться с БД с помощью специальной абстракции IDocumentStore. Репозиторий отвечает за CRUD-операции: создание, чтение, обновление и удаление. Абстракция IDocumentStore позволяет создавать объекты-сессии, с помощью которых можно взаимодействовать с данными.
В файле Program.cs зарегистрируем наш репозиторий, а также инициализируем IDocumentStore коннектом к нашей БД. Вообще, взаимодействие с RavenDB очень похоже на работу с DBContext в Entity Framework.
Метод Create:
public async Task Create(ToDoItem item)
{
using var session = store.OpenAsyncSession();
await session.StoreAsync(item);
await session.SaveChangesAsync();
}
Метод GetById:
public async Task<ToDoItem> GetById(string id)
{
using var session = store.OpenAsyncSession();
return await session.LoadAsync<ToDoItem>(id);
}
Обновить сущность можно двумя способами:
- Получить сущность по идентификатору, обновить набор полей у сущности и вызвать SaveChanges.
- Вызвать метод Patch, в котором нужно указать сущность и ссылки на поля в ней.
public async Task Update(ToDoItem item)
{
using var session = store.OpenAsyncSession();
session.Advanced.Patch(item, x => x.Deadline, item.Deadline);
session.Advanced.Patch(item, x => x.Title, item.Title);
session.Advanced.Patch(item, x => x.Description, item.Description);
await session.SaveChangesAsync();
}
Также необходимо зарегистрировать наш репозиторий в DI-контейнере, в файле Program.cs:
var store = new DocumentStore
{
Urls = new[] { "http://localhost:8080" },
Database = "Todos"
};
store.Initialize();
builder.Services.AddSingleton<IDocumentStore>(store);
Бэкенд: кеш
В качестве кеша мы используем Garnet. Это remote cache-store от Microsoft Research. В основе своей это решение написано на C#. Этот кеш поддерживает протокол RESP, поэтому в качестве клиента мы сможем использовать библиотеку StackExchange.Redis.
Установим библиотеку:
dotnet add package StackExchange.Redis
Добавим класс CacheService и реализуем первый метод GetOrAdd:
public async Task<T> GetOrAdd<T>(string key, Func<Task<T>> itemFactory, int expirationInSecond)
{
// если такой элемент уже есть, возвращаем его
var existingItem = await _database.StringGetAsync(key);
if (existingItem.HasValue)
{
return JsonSerializer.Deserialize<T>(existingItem);
}
// забираем новый элемент
var newItem = await itemFactory();
// добавляем элемент в кеш
await _database.StringSetAsync(key, JsonSerializer.Serialize(newItem), TimeSpan.FromSeconds(expirationInSecond));
return newItem;
}
Метод Invalidate для очистки кеша:
public async Task Invalidate(string key)
{
await _database.KeyDeleteAsync(key);
}
Бэкенд: соединяем всё вместе
Теперь добавим новый класс ToDoService, который объединит в себе логику репозитория и кеша. При получении данных мы будем добавлять их в кеш, а при обновлении — инвалидировать.
public class ToDoService(ToDoRepository repository, CacheService cacheService)
{
public async Task<IEnumerable<ToDoItem>> GetAllAsync()
{
return await cacheService.GetOrAdd($"ToDoItem:all",
async () => await repository.GetAll(), 30);
}
public async Task<ToDoItem> GetByIdAsync(string id)
{
return await cacheService.GetOrAdd($"ToDoItem:{id}",
async () => await repository.GetById(id), 30);
}
public async Task CreateAsync(ToDoItem item)
{
await repository.Create(item);
await cacheService.Invalidate($"ToDoItem:all");
}
public async Task UpdateAsync(ToDoItem item)
{
await repository.Update(item);
await cacheService.Invalidate($"ToDoItem:{item.Id}");
await cacheService.Invalidate($"ToDoItem:all");
}
public async Task DeleteAsync(string id)
{
await repository.Delete(id);
await cacheService.Invalidate($"ToDoItem:{id}");
await cacheService.Invalidate($"ToDoItem:all");
}
}
Зарегистрируем всё необходимое в Program.cs:
var store = new DocumentStore
{
Urls = new[] { "http://localhost:8080" },
Database = "Todos"
};
store.Initialize();
builder.Services.AddSingleton<IDocumentStore>(store);
builder.Services.AddScoped<ToDoRepository>();
builder.Services.AddScoped<ToDoService>();
builder.Services.AddScoped<CacheService>();
builder.Services.AddSingleton<IConnectionMultiplexer>(ConnectionMultiplexer.Connect("localhost"));
И добавим эндпоинты с помощью подхода Minimal API в том же Program.cs:
app.MapGet("api/todo", async ([FromServices] ToDoService toDoService)
=> await toDoService.GetAllAsync());
app.MapPost("api/todo", async ([FromBody] ToDoItem item, [FromServices] ToDoService toDoService)
=> await toDoService.CreateAsync(item));
app.MapPut("api/todo", async ([FromBody] ToDoItem item, [FromServices] ToDoService toDoService)
=> await toDoService.UpdateAsync(item));
app.MapGet("api/todo/{id}", async (string id, [FromServices] ToDoService toDoService)
=> await toDoService.GetByIdAsync(id));
app.MapDelete("api/todo/{id}", async (string id, [FromServices] ToDoService toDoService)
=> await toDoService.DeleteAsync(id));
Infrastructure
Чтобы проверить наше приложение, необходимо поднять RavenDB и Garnet. Это можно сделать с помощью Docker Compose.
Добавим в проект папку Launcher и файл docker-compose.yml:
version: '3.8'
services:
ravendb:
image: ravendb/ravendb:latest
environment:
RAVEN_DB_URL: "http://0.0.0.0:8080"
RAVEN_DB_PUBLIC_URL: "http://ravendb:8080"
RAVEN_DB_TCP_URL: "tcp://0.0.0.0:38888"
ports:
- "8080:8080"
garnet:
image: 'ghcr.io/microsoft/garnet'
ulimits:
memlock: -1
ports:
- "6379:6379"
volumes:
- garnetdata:/data
volumes:
ravendb_data:
garnetdata:
Выполним команду docker compose up -d
. Теперь по адресу localhost:8080 доступна RavenDB, а по адресу localhost:6379 — Garnet.
На адрес RavenDB необходимо зайти и выполнить первичную настройку, а затем перейти в раздел Databases и создать БД Todos:
Теперь мы можем запустить наш API и проверить его работоспособность. Запустим приложение, откроем Swagger и выполним POST-запрос на создание задачи:
Запрос завершился успешно. Мы можем зайти в БД и увидеть задачу:
Кеш проще всего проверить в дебаггере: при первом выполнении GET-запроса мы должны сходить в БД, а при втором — уже в кеш:
Фронтенд
Теперь напишем UI для нашего таск-трекера. Будем использовать фреймворк Blazor, чтобы приложение было написано полностью на .NET 🙂
Добавим проект Blazor в наше решение:
По аналогии с бэкендом добавим классы ToDoItem для описания объекта задачи и ToDoService для взаимодействия с Backend.
ToDoItem:
public class ToDoItem
{
public string Id { get; set; }
[Required]
public string Title { get; set; }
[Required]
public string Description { get; set; }
public DateTime Deadline { get; set; }
}
ToDoService:
public class ToDoService(HttpClient httpClient)
{
public async Task<List<ToDoItem>> GetToDoItemsAsync()
=> await httpClient.GetFromJsonAsync<List<ToDoItem>>("todo");
public async Task<ToDoItem> GetToDoItemByIdAsync(string id)
=> await httpClient.GetFromJsonAsync<ToDoItem>($"todo/{id}");
public async Task CreateToDoItemAsync(ToDoItem item)
=> await httpClient.PostAsJsonAsync("todo", item);
public async Task UpdateToDoItemAsync(ToDoItem item)
=> await httpClient.PutAsJsonAsync($"todo/{item.Id}", item);
public async Task DeleteToDoItemAsync(string id)
=> await httpClient.DeleteAsync($"todo/{id}");
}
В файле Program.cs зарегистрируем сервис и HttpCilent:
builder.Services.AddScoped(_ =>
new HttpClient { BaseAddress = new Uri("http://localhost:5042/api/") });
builder.Services.AddScoped<ToDoService>();
Теперь визуал. Вся логика будет содержаться в файлах CreateItem.razor, EditItem.razor и ToDoList.razor. Полный код можно посмотреть на GitHub, здесь же сосредоточимся на ключевых моментах.
Для проброса различных сервисов на странице можно использовать хелпер @inject:
@inject ToDoService ToDoService
@inject NavigationManager NavigationManager
Для форм используется тег EditForm:
<EditForm EditContext="@editContext" Model="newItem" FormName="Create New Task" OnValidSubmit="HandleValidSubmit">
<DataAnnotationsValidator />
<ValidationSummary />
<div>
<label for="title">Title: </label>
<InputText id="title" class="form-control" @bind-Value="newItem.Title" />
</div>
<div>
<label for="description">Description: </label>
<InputText id="description" class="form-control" @bind-Value="newItem.Description" />
</div>
<div>
<label for="deadline">Deadline: </label>
<InputDate id="deadline" class="form-control" @bind-Value="newItem.Deadline" />
</div>
<button type="submit" class="btn btn-primary">Save</button>
</EditForm>
Если вы используете .NET8, в файлах страниц необходимо явно указать параметр rendermode для корректной работы методов:
@rendermode InteractiveServer
Запускаем приложения: сначала docker-compose для инфраструктуры, затем наш API и фронтенд:
Заключение
В этой статье мы попробовали использовать для создания веб-приложения всё, что написано на платформе .NET, — от фреймворка фронтенда до базы данных. Конечно, это не единственные приложения, написанные на C#. Ещё есть YARP, который очень удобно использовать в качестве прокси для микросервисов, или LiteDB — in-memory база данных, удобная для тестирования.
Полный код на GitHub.
Также я веду телеграм-канал Flexible Coding, где пишу про .NET и IT в целом.