65
0
0
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники
Назад

Пишем приложение на C#-стеке

Время чтения 10 минут
Нет времени читать?
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники
65
0
0
Нет времени читать?
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники

Всем привет! Меня зовут Дмитрий Бахтенков, и я .NET-разработчик. Сегодня мы проведем эксперимент — напишем полноценное веб-приложение с использованием решений, которые написаны на C# и платформе .NET.

Что я имею в виду?

Как мы знаем, в общем случае веб-приложение состоит из бэкенда, фронтенда, базы данных и иногда из кеша. С бэкендом и фронтендом всё понятно: у нас есть замечательный фреймворк ASP.NET Core для сервера и blazor или razor pages для клиента. Однако инфраструктурные части приложения — БД, кеши — чаще всего пишутся на других, более низкоуровневых языках, таких как C и C++.

К счастью, недавно Microsoft выпустила решение для кеширования — аналог Redis, который называется Garnet. В качестве основной базы данных можно использовать документную БД RavenDB, которая как раз написана на C#.

Пишем приложение на C#-стеке

Что кодим?

Итак, со стеком разобрались. У нас будет:

  • 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 в целом.

Комментарии0
Тоже интересно
Комментировать
Поделиться
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники