
“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 и работу со встроенными механизмами валидации модели. Регистрация доступна по ссылке.
ссылка на оригинал статьи https://habr.com/ru/company/otus/blog/699448/
Добавить комментарий