Руководство по созданию облачного приложения под Microsoft Azure на основе опенсорсных технологий. Часть 1

от автора

“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

Пока вам нужно будет обратить внимание только на пару вещей:

Примечание: Отключите флаг 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *