Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
“Cloud Native” (или «облачно-ориентированный») — это подход к разработке приложений, который нацелен упростить процессы их создания и развертывания, а также улучшить их масштабируемость и удобство сопровождения. Моя цель в этой статье — показать на практике, как создавать, развертывать, запускать и мониторить простое облачное приложение в Microsoft Azure, используя общедоступные опенсорсные технологии.
Эта статья научит вас создавать облачные приложения, шаг за шагом демонстрируя все этапы разработки на приближенном к реальным сценариям учебном примере.
Облачные приложения
Без сомнения, одним из самых актуальных трендов в разработке программного обеспечения является термин “cloud native”. Но что же представляет из себя «облачное приложение»?
Облачные приложения — это просто приложения, созданные на основе различных облачных технологий или сервисов, предназначенные для размещения в (приватном или общедоступном) облаке. Облачный подход к разработке приложений нацелен упростить процессы их создания и развертывания, а также улучшить их масштабируемость и удобство сопровождения. Зачастую они представляют собой распределенные системы (обычно имеющие микросервисную архитектуру), которые также полагаются на DevOps-практики автоматизации создания и развертывания для того, чтобы это можно было сделать в любое время по первой необходимости. Обычно эти приложения предоставляют API, реализующие стандартные протоколы, такие как REST или gRPC, благодаря чему с ними можно взаимодействовать с помощью стандартных инструментов, таких как Swagger (OpenAPI).
Приложение, которое мы будем использовать в качестве примера, довольно простое, но в то же время в рамках его разработки мы рассмотрим ряд факторов и технологий, которые используются и в реальных сценариях. Мы не будем затрагивать аутентификацию и авторизацию в этом руководстве, потому что, на мой взгляд, это добавило бы неоправданно много сложности, которую можно опустить без ущерба для темы, раскрываемой в этой статье.
Простое приложение для секонд-хенд магазина
Simple Second-Hand Store (далее SSHS) — так мы назовем наше простое приложения для магазина секонд-хенда, на примере которого мы опишем основные этапы создания облачного приложения.
Обзор системной архитектуры SSHS
Возможности приложения
SSHS — это простое облачное приложение для продажи бывших в употреблении товаров. Пользователи могут создавать, просматривать, обновлять и удалять товары. Когда товар добавляется на платформу, владелец этого товара должен получить электронное письмо с подтверждением.
Декомпозиция приложения
Разработка микросервисной архитектуры начинается с декомпозиции бизнес-требований в набор сервисов. Этот процесс должен следовать следующему набору принципов:
Сервисы должны следовать принципу открытости-закрытости:
Программный компонент должен быть закрыт для изменений, но открыт для расширения.
Если перенести этот принцип в реалии распределенных архитектур, то он будет означать, что изменение в компоненте (сервисе) не должно влиять на другие компоненты.
Сервисы должны быть слабо связаны; сервисы должны быть слабо связаны между собой, чтобы обеспечить максимальную гибкость для изменений или добавления нового функционала.
Сервисы должны быть автономными: если один сервис выходит из строя, то остальные сервисы не должны выходить из строя следом; если сервис расширяется или масштабируется, то остальным сервисам не нужно делать этого вместе с ним.
Эти простые принципы помогают создавать согласованные и надежные приложения со всеми преимуществами, которые может предоставить распределенная система. Но имейте в виду, что проектирование и разработка распределенных приложений — непростая задача, и пренебрежение хотя бы парой архитектурных принципов может вылиться в проблемы типичные как для монолита, так и для микросервисов. В следующем разделе мы рассмотрим на примерах, как применять эти принципы на практике.
Декомпозиция SSHS
В нашем приложении для секонд-хенда легко выделить два контекста: первый отвечает за обработку товаров — создание и сохранение. Второй контекст связан с уведомлениями и по сути является stateless компонентом.
Что касается архитектуры приложения, то мы можем разделить ее на два микросервиса:
ProductCatalog — предоставляет некоторое REST API, позволяющее клиенту создавать, просматривать, обновлять и удалять (все стандартные CRUD-операции) товары в базе данных.
Notifications — когда новый товар добавляется в репозиторий ProductCatalog, служба Notifications отправляет электронное письмо владельцу этого товара.
Связь между микросервисами
На более высоком уровне микросервисы можно рассматривать как группу подсистем, составляющих единое приложение. И, как и в традиционных приложениях, компоненты должны взаимодействовать друг с другом. В монолитном приложении вы можете реализовать это взаимодействие, добавив некую абстракцию между различными слоями, но, конечно, в микросервисной архитектуре так сделать не получится, поскольку мы имеем дело с несколькими разными кодовыми базами. Так как же микросервисы могут взаимодействовать между собой? Это проще всего реализовать через HTTP-протокол: каждый сервис предоставляет REST API для другого, по которым они могут общаться друг с другом. Но, хоть на первый взгляд это решение выглядит вполне разумным, оно добавляет в систему нежелательные зависимости. Например, сервису A необходимо вызвать сервис B, чтобы ответить клиенту. Что произойдет, если сервис B дал сбой или просто медленно работает? Почему производительность сервиса B влияет на работу сервиса A, распространяя сбой на все приложение?
Именно здесь выходят на сцену асинхронные модели взаимодействия, помогающие сохранить наши компоненты слабо связанными друг с другом. В асинхронных моделях вызывающей стороне не нужно ждать ответа от принимающей стороны, вместо этого она сгенерирует событие типа “отправил и забыл”, а затем кто-то перехватит это событие, чтобы выполнить какое-либо действие. Я использовал здесь слово “кто-то”, потому что вызывающая сторона понятия не имеет, кто получит это событие — возможно даже вообще никто.
Этот шаблон называется pub/sub (издатель-подписчик), когда один сервис публикует события, а другие могут подписываться на этот тип событий. События обычно публикуются в другой компонент, называемый шиной событий (event bus), который работает по принципу FIFO (первым пришел — первым ушел).
Хоть очередь FIFO довольно широко используются в реальных средах, существуют и более сложные шаблоны. Например, в качестве альтернативы очереди потребители (consumers) могут подписываться на топик (topic), копируя и потребляя сообщения только из этого топика и игнорируя остальные. Вообще, если рассуждать в терминах AMQP (Advanced Message Queuing Protocol), то топик является таким же свойством сообщения, как и его тема (subject).
Используя асинхронную модель взаимодействия, сервис B может реагировать на события, происходящие в сервисе A, но сервис A ничего не знает ни о самих потребителях, ни о том, что они делают. И, очевидно, на его производительность никак не влияют другие сервисы. Они полностью независимы друг от друга.
Примечание: К сожалению, иногда использование асинхронной модели взаимодействия невозможно, и не смотря на то, что синхронные модели взаимодействия являются антипаттерном, альтернативы нет. Хоть это не должно служить отговоркой, чтобы выбрать более быстрое в реализации решение, имейте в виду, что в некоторых конкретных сценариях такое все-таки может произойти. Не стоит сильно расстраиваться, если у вас действительно нет альтернатив.
Связь в SSHS
Микросервисам в нашем приложении SSHS не требуется прямая связь, поскольку сервис Notifications просто реагирует на некоторые события, происходящие в сервисе ProductCatalog. Очевидно, что это можно реализовать как асинхронную операцию через сообщение в очереди.
Хранение данных в микросервисной архитектуре
По тем же причинам, которые мы обсуждали в разделе «Связь между микросервисами», чтобы сохранить независимость сервисов друг от друга, для каждого сервиса требуется отдельное хранилище. Неважно, есть ли у сервиса одно или несколько хранилищ, использующих сразу несколько технологий (зачастую это и SQL, и NoSQL), каждый сервис должен иметь эксклюзивный доступ к своему репозиторию; не только из-за производительности, но и исходя из соображений целостности данных и нормализации. Предметные области сервисов могут быть совершенно разные, и каждому сервису нужна собственная схема базы данных, которая может сильно разниться от одного микросервиса к другому. С другой стороны, приложение обычно декомпозируется в соответствии с бизнес-контекстами, и вполне нормально видеть, что с течением времени схемы приобретают все больше отличий, даже если вначале они могли выглядеть одинаково. Подводя итог, использование единого для всех микросервисов хранилища приводит к проблемам, типичным для монолитных приложений, — зачем мы тогда вообще используем распределенную систему?
Хранение данных в SSHS
Сервису Notifications хранить в репозитории нечего, в то время как ProductCatalog предлагает CRUD API для работы с загруженными товарами. Они сохраняются в SQL базе данных, поскольку мы имеем дело с четко определенной схемой, а гибкость, обеспечиваемая NoSQL-хранилищем, в этом случае нам не требуется.
Используемые технологии
Оба сервиса представляют собой ASP.NET-приложения на .NET 6, которые можно создавать и развертывать с помощью современных методов непрерывной интеграции (CI) и развертывания. Сам репозиторий размещается в GitHub, а конвейеры сборки и развертывания реализованы с помощью GitHub Actions. В написании облачной инфраструктуры задействован декларативный подход, чтобы обеспечить полный IaC (инфраструктура-как-код) опыт с применением Terraform. Сервис ProductCatalog хранит данные в базе данных Postgresql и взаимодействует с сервисом Notifications, используя очередь в шине событий. Конфиденциальные данные, такие как строки подключения, хранятся в безопасном месте в Azure и не отражены в репозитории исходного кода.
Разработка SSHS
Перед тем как мы начнем: следующие разделы не объясняют каждый шаг подробно (например, создание солюшенов и проектов) и нацелены на разработчиков, знакомых с Visual Studio или подобными средствами. Однако вы можете найти ссылку на GitHub-репозиторий в конце этого руководства.
Разработка приложения SSHS начинается с создания репозитория и определения структуры папок. Структура репозиторий SSHS должна выглядеть следующим образом:
.github
workflows
build-deploy.yml
src
Notifications
[project files]
Notifications.csproj
ProductCatalog
[project files]
ProductCatalog.csproj
.editorconfig
Directory.Build.props
sshs.sln
terraform
main.tf
.gitignore
README.md
Пока вам нужно будет обратить внимание только на пару вещей:
специальная папка
.github
содержит yml-файл, который определяет CI и CD конвейерыв папке
terraform
есть скрипт для развертывания ресурсов в Azure (IaC),в
src
содержится исходный кодDirectory.Build.props
определяет свойства, которые наследуются всеми csprojs..editorconfig
-файл работает как линтер — я уже писал о них и о том, как поделиться одинаковыми настройками для всей команды в своем блоге..gitignore
для определения файлов, которые Git будет игнорировать
Примечание: Отключите флаг nullable в файле csproj
, который в шаблонах проектов Net Core 6 обычно включен по умолчанию.
Сервис ProductCatalog
Сервис ProductCatalog должен иметь API для управления товарами. Чтобы предоставить пользователям некоторую документацию и упростить процесс разработки, мы будем использовать Swagger (Open API).
Затем идут зависимости: база данных и шина событий. Для получения доступа к базе данных мы будем использовать Entity Framework.
Наконец, для безопасного хранения строк подключения потребуется защищенное хранилище — Azure KeyVault.
Создание проекта
Новые шаблоны ASP.NET Core 6 приложений в Visual Studio больше не предоставляют класс Startup, теперь все находится в классе Program. К сожалению, как мы увидим в разделе “развертывание ProductCatalog”, в этом подходе есть ошибка, поэтому давайте сами создадим класс Startup
:
namespace ProductCatalog
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
public void ConfigureServices(IServiceCollection services)
{
}
public void Configure(IApplicationBuilder app)
{
}
}
}
Затем заменим содержимое Program.cs следующим кодом:
var builder = WebApplication.CreateBuilder(args);
var startup = new Startup(builder.Configuration);
startup.ConfigureServices(builder.Services);
WebApplication app = builder.Build();
startup.Configure(app/*, app.Environment*/);
app.Run();
CRUD API
Следующим шагом является написание нескольких простых CRUD API для работы с товарами. Вот как будет выглядеть контроллер:
namespace ProductCatalog.Controllers
{
[AllowAnonymous]
[ApiController]
[Route("api/product/")]
public class ProductsController : ControllerBase
{
private readonly IProductService _productService;
public ProductsController(
IProductService productService)
{
_productService = productService;
}
[HttpGet]
[Route("product")]
public async Task<IActionResult> GetAllProducts()
{
var dtos = await _productService.GetAllProductsAsync();
return Ok(dtos);
}
[HttpGet]
[Route("{id}")]
public async Task<IActionResult> GetProduct(
[FromRoute] Guid id)
{
var dto = await _productService.GetProductAsync(id);
return Ok(dto);
}
[HttpPost]
[Route("product")]
public async Task<IActionResult> AddProduct(
[FromBody] CreateProductRequest request)
{
Guid productId = await _productService.CreateProductAsync(request);
Response.Headers.Add("Location", productId.ToString());
return NoContent();
}
[HttpPut]
[Route("{id}")]
public async Task<IActionResult> UpdateProduct(
[FromRoute] Guid id,
[FromBody] UpdateProductRequest request)
{
await _productService.UpdateProductAsync(id, request);
return NoContent();
}
[HttpDelete]
[Route("{id}")]
public async Task<IActionResult> DeleteProduct(
[FromRoute] Guid id)
{
await _productService.DeleteProductAsync(id);
return Ok();
}
}
}
Определение ProductService
:
namespace ProductCatalog.Services
{
public interface IProductService
{
Task<IEnumerable<ProductResponse>> GetAllProductsAsync();
Task<ProductDetailsResponse> GetProductAsync(Guid id);
Task<Guid> CreateProductAsync(CreateProductRequest request);
Task UpdateProductAsync(Guid id, UpdateProductRequest request);
Task DeleteProductAsync(Guid id);
}
public class ProductService : IProductService
{
public Task<Guid> CreateProductAsync(CreateProductRequest request)
{
throw new NotImplementedException();
}
public Task DeleteProductAsync(Guid id)
{
throw new NotImplementedException();
}
public Task<IEnumerable<ProductResponse>> GetAllProductsAsync()
{
throw new NotImplementedException();
}
public Task<ProductDetailsResponse> GetProductAsync(Guid id)
{
throw new NotImplementedException();
}
public Task UpdateProductAsync(Guid id, UpdateProductRequest request)
{
throw new NotImplementedException();
}
}
}
И, наконец, определяем (очень простые) DTO-классы:
public class ProductResponse
{
[JsonPropertyName("id")]
public Guid Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
}
public class UpdateProductRequest
{
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("price")]
public decimal Price { get; set; }
[JsonPropertyName("owner")]
public string Owner { get; set; }
}
public class ProductDetailsResponse
{
[JsonPropertyName("id")]
public Guid Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("price")]
public decimal Price { get; set; }
[JsonPropertyName("owner")]
public string Owner { get; set; }
}
public class CreateProductRequest
{
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("price")]
public decimal Price { get; set; }
[JsonPropertyName("owner")]
public string Owner { get; set; }
}
Свойство Owner должно содержать адрес электронной почты для уведомления о добавлении товара в систему. Я не добавлял сюда никаких проверок, так как это вообще отдельная тема, на которой мы не будем концентрироваться в этом руководстве.
Затем зарегистрируйте ProductService
в IoC-контейнере посредством services.AddScoped<IPProductService, ProductService>()
; в классе Startup
.
Swagger (Open API)
Часто облачные приложения используют Open API, чтобы упростить тестирование и документирование. Официальное определение:
Спецификация OpenAPI (OAS) определяет стандартный, не зависящий от языка интерфейс для RESTful API, который позволяет как людям, так и компьютерам обнаруживать и понимать возможности сервиса без доступа к исходному коду, документации или проверки сетевого трафика. При правильном определении пользователь получает возможность понимать и взаимодействовать с удаленным сервисом с минимальным количеством реализуемой логики.
Если вкратце: OpenAPI позволяет сгенерировать удобный пользовательский интерфейс, облегчающий использование API и работу с документацией, идеально подходящий для сред разработки и тестирования, но никак НЕ продакшена. Однако, поскольку наше приложение создается в демонстрационных целях, я оставил его во всех средах. Но чтобы обратить на этот момент ваше внимание, я все-таки добавил закомментированный код, чтобы вы могли отключить его в сборках, предназначенных для работы в продакшене.
Чтобы добавить поддержку Open API, вам нужно будет установить NuGet-пакет Swashbuckle.AspNetCore в проект ProductCatalog и обновить класс Startup
:
public void ConfigureServices(IServiceCollection services)
{
//if (env.IsDevelopment())
{
services.AddControllers();
services.AddEndpointsApiExplorer();
services.AddSwaggerGen(options =>
{
var contact = new OpenApiContact
{
Name = Configuration["SwaggerApiInfo:Name"],
};
options.SwaggerDoc("v1", new OpenApiInfo
{
Title = $"{Configuration["SwaggerApiInfo:Title"]}",
Version = "v1",
Contact = contact
});
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
options.IncludeXmlComments(xmlPath);
});
}
}
public void Configure(IApplicationBuilder app)
{
//if (env.IsDevelopment()))
{
app.UseSwagger();
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint("/swagger/v1/swagger.json", "API v1");
options.RoutePrefix = string.Empty;
options.DisplayRequestDuration();
});
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
endpoints.MapControllers();
});
}
}
Включите генерацию XML-файла документации в csproj-файле. Swagger считывает эти файлы документации и отображаетс в пользовательском интерфейсе:
<ItemGroup>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</ItemGroup>
Примечание: Добавьте в файл appsettings.json раздел с именем SwaggerApiInfo с двумя свойствами со значениями по вашему выбору: Name и Title.
Добавьте документацию к API, как в показано следующем примере:
/// <summary>
/// API для управления товарами
/// </summary>
[AllowAnonymous]
[ApiController]
[Route("api/" + "product/")]
[Consumes(MediaTypeNames.Application.Json)]
[Produces(MediaTypeNames.Application.Json)]
public class ProductsController : ControllerBase
{ }
/// <summary>
/// Получение конкретного товара
/// </summary>
/// <remarks>
/// Пример запроса:
///
/// GET /api/product/{id}
///
/// </remarks>
/// <param name="id">Product id</param>
/// <response code="200">Product details</response>
[HttpGet]
[Route("{id}")]
[ProducesResponseType(typeof(ProductDetailsResponse), StatusCodes.Status200OK)]
public async Task<IActionResult> GetProduct(
[FromRoute] Guid id)
{ /* Do stuff */}
Теперь запустите приложение и перейдите на localhost:<port>/index.html. Здесь вы можете наблюдать, как пользовательский интерфейс Swagger отображает все детали, указанные в документации по коду C#: описание API, схемы допустимых типов, коды состояния, поддерживаемый тип медиа, пример запроса и т. д. Это очень облегчает жизнь, когда вы работаете в команде.
Сжатие GZip
Несмотря на то, что это всего лишь пример, хорошей практикой будет применять к ответам API сжатие GZip в целях повышения производительности. Откройте класс Startup и добавьте туда следующие строчки кода:
public void ConfigureServices(IServiceCollection services)
{
services.Configure<GzipCompressionProviderOptions>(options =>
options.Level = System.IO.Compression.CompressionLevel.Optimal);
services.AddResponseCompression(options =>
{
options.EnableForHttps = true;
options.Providers.Add<GzipCompressionProvider>();
});
}
public void Configure(IApplicationBuilder app)
{
app.UseResponseCompression();
}
Обработка ошибок
Для обработки ошибок используются кастомные исключения и middleware:
public class BaseProductCatalogException : Exception
{ }
public class EntityNotFoundException : BaseProductCatalogException
{ }
namespace ProductCatalog.Models.DTOs
{
public class ApiResponse
{
public ApiResponse(string message)
{
Message = message;
}
[JsonPropertyName("message")]
public string Message { get; }
}
}
Update the Startup class:
public void Configure(IApplicationBuilder app)
{
app.UseExceptionHandler((appBuilder) =>
{
appBuilder.Run(async context =>
{
var exceptionHandlerPathFeature = context.Features.Get<IExceptionHandlerPathFeature>();
Exception exception = exceptionHandlerPathFeature?.Error;
context.Response.StatusCode = exception switch
{
EntityNotFoundException => StatusCodes.Status404NotFound,
_ => StatusCodes.Status500InternalServerError
};
ApiResponse apiResponse = exception switch
{
EntityNotFoundException => new ApiResponse("Product not found"),
_ => new ApiResponse("An error occurred")
};
context.Response.ContentType = MediaTypeNames.Application.Json;
await context.Response.WriteAsync(JsonSerializer.Serialize(apiResponse));
});
});
}
Entity Framework
Приложение ProductCatalog должно хранить данные о товарах в хранилище. Поскольку объект Product имеет четко определенную схему, SQL база данных отлично подходит для нашего случая. В частности, Postgresql — это транзакционная база данных с открытым исходным кодом, предлагаемая Azure в качестве PaaS сервиса.
Entity Framework — это ORM, инструмент, упрощающий конвертирование объектов между SQL и объектно-ориентированным языком. Хоть SSHS и выполняет очень простые запросы, наша цель заключается в том, чтобы смоделировать реальный сценарий, в котором ORM и, в конечном итоге, MicroORM, такие как Dapper, используются очень интенсивно.
Перед началом запустите локальный инстанс Postgresql для среды разработки. Мой вам совет (особенно для пользователей Windows) — используйте Docker. Теперь установите Docker, если у вас его еще нет, и запустите docker run -p 127.0.0.1:5432:5432/tcp --name postgres -e POSTGRES_DB=product_catalog -e POSTGRES_USER=sqladmin -e POSTGRES_PASSWORD=Password1! -d postgres
.
Больше информации по этой теме вы найдете в официальной документации.
Когда локальная база данных заработает должным образом, пора приступить к работе с Entity Framework для Postgresql. Давайте установим следующие NuGet-пакеты:
EFCore.NamingConventions
, чтобы использовать соглашения Postgresql при создании имен и свойств;Microsoft.EntityFrameworkCore.Design
, для design-time логики Entity Framework;Microsoft.EntityFrameworkCore.Proxies
, для ленивой загрузки столбцов;Microsoft.EntityFrameworkCore.Tools
, для управления миграциями и скаффолдинга DbContext’ов;Npgsql.EntityFrameworkCore.PostgreSQL
, для диалекта Postgresql.
Определяем сущности — класс Product
:
namespace ProductCatalog.Models.Entities
{
public class Product
{
/// <summary>
/// Конструктор зарезервирован для EF
/// </summary>
[ExcludeFromCodeCoverage]
protected Product()
{ }
public Product(
string name,
decimal price,
string owner)
{
Name = name;
Price = price;
Owner = owner;
}
public Guid Id { get; protected set; }
public string Name { get; private set; }
public decimal Price { get; private set; }
public string Owner { get; private set; }
internal void UpdateOwner(string owner)
{
Owner = owner;
}
internal void UpdatePrice(decimal price)
{
Price = price;
}
internal void UpdateName(string name)
{
Name = name;
}
}
}
Создайте класс DbContext
— он будет служить в качестве шлюза для доступа к базе данных, и определите правила отображения между SQL и CLR объектами:
namespace ProductCatalog.Data
{
public class ProductCatalogDbContext : DbContext
{
public ProductCatalogDbContext(
DbContextOptions<ProductCatalogDbContext> options)
: base(options)
{ }
public DbSet<Product> Products { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
optionsBuilder
.UseLazyLoadingProxies()
.UseNpgsql();
}
}
}
namespace ProductCatalog.Data.EntityConfigurations
{
public class ProductEntityConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.ToTable("product_catalog");
builder.HasKey(dn => dn.Id);
builder.Property(dn => dn.Id)
.ValueGeneratedOnAdd();
builder.Property(dn => dn.Name)
.IsRequired();
builder.Property(dn => dn.Price)
.IsRequired();
builder.Property(dn => dn.Owner)
.IsRequired();
}
}
}
Свойство DbSet<Product>
представляет коллекцию в памяти, в которой сохраняются данные в хранилище; переопределение метода OnModelCreating
сканирует работающую сборку в поисках всех классов, реализующих IEntityTypeConfiguration
, для применения кастомного отображения. Перегрузка OnConfiguring позволяет прокси-серверу Entity Framework выполнять ленивую загрузку связей между таблицами. Здесь это не актуально, поскольку у нас всего одна таблица, но это хороший совет для повышения производительности в реальном сценарии. Этот функционал предоставляется NuGet-пакетом Microsoft.EntityFrameworkCore.Proxies.
Наконец, класс ProductEntityConfiguration определяет некоторые правила отображения:
builder.ToTable("product_catalog")
; дает имя таблице; если оно не указано, он генерирует имя таблицы из имени сущности (в данном случае Product) на основе соглашений об именовании Postgresql благодаря пакету EFCore.NamingConventions.builder.HasKey(dn => dn.Id)
; устанавливает свойство Id в качестве первичного ключа..ValueGeneratedOnAdd()
; указывает автоматически генерировать новый Guid, когда в базе данных создается объект*..IsRequired()
добавляет ограничение SQL Not NULL.
*Важно напомнить, что Guid генерируется после создания SQL-объекта. Если вам нужно сгенерировать Guid перед SQL-объектом, вы можете использовать HiLo — подробнее об этом читайте здесь.
Наконец, обновите класс Startup с последними изменениями:
public void ConfigureServices(IServiceCollection services)
{
services.AddDbContext<ProductCatalogDbContext>(opt =>
{
var connectionString = Configuration.GetConnectionString("ProductCatalogDbPgSqlConnection");
opt.UseNpgsql(connectionString, npgsqlOptionsAction: sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 4,
maxRetryDelay: TimeSpan.FromSeconds(Math.Pow(2, 3)),
errorCodesToAdd: null);
})
.UseSnakeCaseNamingConvention(CultureInfo.InvariantCulture);
});
}
Строка подключения к базе данных является конфиденциальной информацией, поэтому ее не следует хранить в appsettings.json
. Для отладки можно использовать UserSecrets
. Это функция предоставляется .Net Framework для хранения конфиденциальной информации, которую не следует хранить в репозитории с исходным кодом. Если вы используете Visual Studio, кликните проект правой кнопкой мыши и выберите “Manage user secrets”; если вы используете другую среду разработки, откройте терминал и перейдите к местоположению csproj
-файла. Затем введите dotnet user-secrets init
. Файл csproj
теперь содержит UserSecretsId
с Guid для идентификации секретов проекта.
Существует три разных способа создать секрет приложения:
если вы использовали Visual Studio, у вас уже должен быть открыт файл secrets.json в результате клика правой кнопкой мыши;
с помощью команды
dotnet user-secrets set "Key" "12345" or dotnet user-secrets set "Key" "12345" --project "src\WebApp1.csproj"
;открыв файл вручную в одной из этих папок, даже если вы не можете найти этот файл, пока не добавите в него секрет:
Windows:
%APPDATA%\Microsoft\UserSecrets\<user_secrets_id>\secrets.json
;Unix:
~/.microsoft/usersecrets/<user_secrets_id>/secrets.json
.
secret.json
должен выглядеть следующим образом:
{
"ConnectionStrings": {
"ProductCatalogDbPgSqlConnection": "Host=localhost;Port=5432;Username=sqladmin;Password=Password1!;Database=product_catalog;Include Error Detail=true"
}
}
Теперь мы реализуем ProductService
:
public class ProductService : IProductService
{
private readonly ProductCatalogDbContext _dbContext;
private readonly ILogger<ProductService> _logger;
public ProductService(
ProductCatalogDbContext dbContext,
ILogger<ProductService> logger)
{
_dbContext = dbContext;
_logger = logger;
}
public async Task<Guid> CreateProductAsync(CreateProductRequest request)
{
var product = new Product(
request.Name,
request.Price,
request.Owner);
_dbContext.Products.Add(product);
await _dbContext.SaveChangesAsync();
return product.Id; // Generated at the SaveChangesAsync
}
public async Task DeleteProductAsync(Guid id)
{
Product product = await _dbContext.Products.FirstOrDefaultAsync(p => p.Id == id);
if (product == null)
throw new EntityNotFoundException();
_dbContext.Products.Remove(product);
await _dbContext.SaveChangesAsync();
}
public async Task<IEnumerable<ProductResponse>> GetAllProductsAsync()
{
List<Product> products = await _dbContext.Products.ToListAsync();
var response = new List<ProductResponse>();
foreach (Product product in products)
{
var productResponse = new ProductResponse
{
Id = product.Id,
Name = product.Name,
};
response.Add(productResponse);
}
return response;
}
public async Task<ProductDetailsResponse> GetProductAsync(Guid id)
{
Product product = await _dbContext.Products.FirstOrDefaultAsync(p => p.Id == id);
if (product == null)
throw new EntityNotFoundException();
var response = new ProductDetailsResponse
{
Id = product.Id,
Name = product.Name,
Owner = product.Owner,
Price = product.Price,
};
return response;
}
public async Task UpdateProductAsync(Guid id, UpdateProductRequest request)
{
Product product = await _dbContext.Products.FirstOrDefaultAsync(p => p.Id == id);
if (product == null)
throw new EntityNotFoundException();
product.UpdateOwner(request.Owner);
product.UpdatePrice(request.Price);
product.UpdateName(request.Name);
_dbContext.Products.Update(product);
await _dbContext.SaveChangesAsync();
}
}
Следующий шаг связан с созданием схемы базы данных посредством миграций. Инструмент Migrations постепенно обновляет зарегистрированную файловую базу данных, чтобы синхронизировать ее с моделью данных приложения, сохраняя при этом существующие данные. Сведения о примененных к базе данных миграциях хранятся в таблице под названием "__EFMigrationHistory". Затем эта информация используется для выполнения непримененных миграций только в базу данных, указанную в строке подключения.
Чтобы определить первую миграцию, откройте командную строку в папке с csproj
и запустите dotnet-ef migrations
, добавьте "InitialMigration"
— она хранится в папке Migration. Затем обновите базу данных: dotnet-ef database update с только что созданной миграцией.
Примечание: Если вы собираетесь выполнять миграцию впервые, сначала установите инструмент командной строки, используя dotnet tool install --global dotnet-ef.
KeyVault
Как я уже говорил, UserSecrets работают только в среде разработки, поэтому вам необходимо добавить поддержку Azure KeyVault
. Установите пакет Azure.Identity
и отредактируйте Program.cs
:
var builder = WebApplication.CreateBuilder(args);
builder.Host.ConfigureAppConfiguration((hostingContext, configBuilder) =>
{
if (hostingContext.HostingEnvironment.IsDevelopment())
return;
configBuilder.AddEnvironmentVariables();
configBuilder.AddAzureKeyVault(
new Uri("https://<keyvault>.vault.azure.net/"),
new DefaultAzureCredential());
});
где <keyvault>
— это имя KeyVault, которое позже будет объявлено в скриптах Terraform.
Проверки работоспособности (Health Checks)
ASP.NET Core SDK предлагает библиотеки для создания отчетов о работоспособности приложений через конечные точки REST. Установите NuGet-пакет Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore и настройте конечные точки в классе Startup:
public void ConfigureServices(IServiceCollection services)
{
services
.AddHealthChecks()
.AddDbContextCheck<ProductCatalogDbContext>("dbcontext", HealthStatus.Unhealthy);
}
public void Configure(IApplicationBuilder app)
{
app
.UseHealthChecks("/health/ping", new HealthCheckOptions { AllowCachingResponses = false })
.UseHealthChecks("/health/dbcontext", new HealthCheckOptions { AllowCachingResponses = false });
}
Приведенный выше код добавляет две конечные точки: в конечной точке /health/ping приложение отвечает состоянием работоспособности системы. Значения по умолчанию — Healthy, Unhealthy или Degraded, но их можно настроить. Конечная точка /health/dbcontext
возвращает текущий статус Entity Framework DbContext, т.е. по сути может ли приложение взаимодействовать с базой данных. Обратите внимание, что упомянутый выше NuGet-пакет предназначен специально для Entity Framework и внутренне ссылается на Microsoft.Extensions.Diagnostics.HealthChecks
. Если вы не используете EF, то вам придется работать только с одной конечной точкой.
Больше информации по этой теме вы найдете в официальной документации.
Docker
Последним шагом в завершении проекта для ProductCatalog является добавление Dockerfile
. Поскольку ProductCatalog и Notifications являются независимыми проектами, важно иметь отдельные Dockerfile
для каждого проекта. Создайте папку Docker в проекте ProductCatalog
, определите файл .dockerignore
и Dockerfile
:
FROM mcr.microsoft.com/dotnet/sdk:6.0 AS base
WORKDIR /app
COPY . .
RUN dotnet restore \
ProductCatalog.csproj \
RUN dotnet publish \
--configuration Release \
--self-contained false \
--runtime linux-x64 \
--output /app/publish \
ProductCatalog.csproj \
FROM mcr.microsoft.com/dotnet/aspnet:6.0 as final
WORKDIR /app
COPY --from=base /app/publish .
EXPOSE 80
ENTRYPOINT ["dotnet", "ProductCatalog.dll"]
Примечание: Не забудьте также добавить файл .dockerignore. В интернете есть куча примеров под конкретные технологии — в данном случае для .NET Core.
Примечание: Если ваша сборка Docker зависает на команде dotnet restore, вы столкнулись с багом, описанным здесь. Чтобы исправить это, добавьте этот нод в csproj:
<ItemGroup>
<Watch Include="..\**\*.env" Condition=" '$(IsDockerBuild)' != 'true' " />
</ItemGroup>
и добавьте /p:IsDockerBuild=true
для команд restore и publish в Dockerfile, как описано в этом комментарии.
Чтобы проверить этот Dockerfile
локально, перейдите в командной строке в папку проекта и запустите:
docker build -t productcatalog -f Docker\Dockerfile
где:
-t
дает имя образу;-f
указывает расположение файла Dockerfile и контекст сборки, представленный расширением.
(точка, обозначающая текущую папку) в приведенной выше команде. На всякий случай, команда COPY относится к папке ProductCatalog.
Затем запустите образ, используя:
docker run --name productcatalogapp -p 8080:80 -it productcatalog -e ConnectionStrings:ProductCatalogDbPgSqlConnection="Host=localhost;Port=5432;Username=sqladmin;Password=Password1!;Database=product_catalog;Include Error Detail=true":
--name
дает имя контейнеру-p
связывает порты хоста и контейнера. По умолчанию ASP.NET запускается на порту http:80, что даже объявлено вDockerfile
-e
устанавливает переменную среды — в данном случае строку подключения
Примечание: Команда docker run
запускает ваше приложение, но оно не будет работать правильно, если вы не создадите docker network между ProductCatalog и контейнерами Postgresql. Однако вы можете попытаться загрузить страницу Swagger, чтобы увидеть, запущено ли приложение. Больше информации об этом здесь.
Перейдите по адресу http://localhost:8080/index.html и, если все работает локально, перейдите к следующему шагу: определению инфраструктуры.
Конец первой части.
Сегодня вечером состоится открытый урок онлайн-курса «C# ASP.NET Core разработчик», на котором рассмотрим, как работает ModelBinding и работу со встроенными механизмами валидации модели. Регистрация доступна по ссылке.