“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 и работу со встроенными механизмами валидации модели. Регистрация доступна по ссылке.

Комментарии (2)


  1. hbn3
    15.11.2022 17:40

    По внутренним ощущениям «Cloud Native» как то сдулся, одно время был подъём энтузиазма, а теперь всё больше облака рассматривают как IaaS и не затачиваются на специфику.


  1. wat4mon
    18.11.2022 05:25

    Можно пожалуйста ссылку на гитхаб проекта, а то я слепой, в статье не вижу