“Cloud Native” (или «облачно-ориентированный») — это подход к разработке приложений, который нацелен упростить процессы их создания и развертывания, а также улучшить их масштабируемость и удобство сопровождения. Моя цель в этой статье — показать на практике, как создавать, развертывать, запускать и мониторить простое облачное приложение в Microsoft Azure, используя общедоступные опенсорсные технологии. Эта статья научит вас создавать облачные приложения, шаг за шагом демонстрируя все этапы разработки на приближенном к реальным сценариям учебном примере. Без сомнения, одним из самых актуальных трендов в разработке программного обеспечения является термин “cloud native”. Но что же представляет из себя «облачное приложение»? Облачные приложения — это просто приложения, созданные на основе различных облачных технологий или сервисов, предназначенные для размещения в (приватном или общедоступном) облаке. Облачный подход к разработке приложений нацелен упростить процессы их создания и развертывания, а также улучшить их масштабируемость и удобство сопровождения. Зачастую они представляют собой распределенные системы (обычно имеющие микросервисную архитектуру), которые также полагаются на DevOps-практики автоматизации создания и развертывания для того, чтобы это можно было сделать в любое время по первой необходимости. Обычно эти приложения предоставляют API, реализующие стандартные протоколы, такие как REST или gRPC, благодаря чему с ними можно взаимодействовать с помощью стандартных инструментов, таких как Swagger (OpenAPI). Приложение, которое мы будем использовать в качестве примера, довольно простое, но в то же время в рамках его разработки мы рассмотрим ряд факторов и технологий, которые используются и в реальных сценариях. Мы не будем затрагивать аутентификацию и авторизацию в этом руководстве, потому что, на мой взгляд, это добавило бы неоправданно много сложности, которую можно опустить без ущерба для темы, раскрываемой в этой статье. Simple Second-Hand Store (далее 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 не требуется прямая связь, поскольку сервис Notifications просто реагирует на некоторые события, происходящие в сервисе ProductCatalog. Очевидно, что это можно реализовать как асинхронную операцию через сообщение в очереди. По тем же причинам, которые мы обсуждали в разделе «Связь между микросервисами», чтобы сохранить независимость сервисов друг от друга, для каждого сервиса требуется отдельное хранилище. Неважно, есть ли у сервиса одно или несколько хранилищ, использующих сразу несколько технологий (зачастую это и SQL, и NoSQL), каждый сервис должен иметь эксклюзивный доступ к своему репозиторию; не только из-за производительности, но и исходя из соображений целостности данных и нормализации. Предметные области сервисов могут быть совершенно разные, и каждому сервису нужна собственная схема базы данных, которая может сильно разниться от одного микросервиса к другому. С другой стороны, приложение обычно декомпозируется в соответствии с бизнес-контекстами, и вполне нормально видеть, что с течением времени схемы приобретают все больше отличий, даже если вначале они могли выглядеть одинаково. Подводя итог, использование единого для всех микросервисов хранилища приводит к проблемам, типичным для монолитных приложений, — зачем мы тогда вообще используем распределенную систему? Сервису Notifications хранить в репозитории нечего, в то время как ProductCatalog предлагает CRUD API для работы с загруженными товарами. Они сохраняются в SQL базе данных, поскольку мы имеем дело с четко определенной схемой, а гибкость, обеспечиваемая NoSQL-хранилищем, в этом случае нам не требуется. Оба сервиса представляют собой ASP.NET-приложения на .NET 6, которые можно создавать и развертывать с помощью современных методов непрерывной интеграции (CI) и развертывания. Сам репозиторий размещается в GitHub, а конвейеры сборки и развертывания реализованы с помощью GitHub Actions. В написании облачной инфраструктуры задействован декларативный подход, чтобы обеспечить полный IaC (инфраструктура-как-код) опыт с применением Terraform. Сервис ProductCatalog хранит данные в базе данных Postgresql и взаимодействует с сервисом Notifications, используя очередь в шине событий. Конфиденциальные данные, такие как строки подключения, хранятся в безопасном месте в Azure и не отражены в репозитории исходного кода. Перед тем как мы начнем: следующие разделы не объясняют каждый шаг подробно (например, создание солюшенов и проектов) и нацелены на разработчиков, знакомых с Visual Studio или подобными средствами. Однако вы можете найти ссылку на GitHub-репозиторий в конце этого руководства. Разработка приложения SSHS начинается с создания репозитория и определения структуры папок. Структура репозиторий SSHS должна выглядеть следующим образом: .github workflows build-deploy.yml src Notifications  Notifications.csproj ProductCatalog  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 должен иметь API для управления товарами. Чтобы предоставить пользователям некоторую документацию и упростить процесс разработки, мы будем использовать Swagger (Open API). Затем идут зависимости: база данных и шина событий. Для получения доступа к базе данных мы будем использовать Entity Framework. Наконец, для безопасного хранения строк подключения потребуется защищенное хранилище — Azure KeyVault. Новые шаблоны ASP.NET Core 6 приложений в Visual Studio больше не предоставляют класс Startup, теперь все находится в классе Program. К сожалению, как мы увидим в разделе “развертывание ProductCatalog”, в этом подходе есть ошибка, поэтому давайте сами создадим класс Startup: Затем заменим содержимое Program.cs следующим кодом: Следующим шагом является написание нескольких простых CRUD API для работы с товарами. Вот как будет выглядеть контроллер: Определение ProductService: И, наконец, определяем (очень простые) DTO-классы: Свойство Owner должно содержать адрес электронной почты для уведомления о добавлении товара в систему. Я не добавлял сюда никаких проверок, так как это вообще отдельная тема, на которой мы не будем концентрироваться в этом руководстве. Затем зарегистрируйте ProductService в IoC-контейнере посредством services.AddScoped<IPProductService, ProductService>(); в классе Startup. Часто облачные приложения используют Open API, чтобы упростить тестирование и документирование. Официальное определение: Спецификация OpenAPI (OAS) определяет стандартный, не зависящий от языка интерфейс для RESTful API, который позволяет как людям, так и компьютерам обнаруживать и понимать возможности сервиса без доступа к исходному коду, документации или проверки сетевого трафика. При правильном определении пользователь получает возможность понимать и взаимодействовать с удаленным сервисом с минимальным количеством реализуемой логики. Если вкратце: OpenAPI позволяет сгенерировать удобный пользовательский интерфейс, облегчающий использование API и работу с документацией, идеально подходящий для сред разработки и тестирования, но никак НЕ продакшена. Однако, поскольку наше приложение создается в демонстрационных целях, я оставил его во всех средах. Но чтобы обратить на этот момент ваше внимание, я все-таки добавил закомментированный код, чтобы вы могли отключить его в сборках, предназначенных для работы в продакшене. Чтобы добавить поддержку Open API, вам нужно будет установить NuGet-пакет Swashbuckle.AspNetCore в проект ProductCatalog и обновить класс Startup: Включите генерацию XML-файла документации в csproj-файле. Swagger считывает эти файлы документации и отображаетс в пользовательском интерфейсе: Примечание: Добавьте в файл appsettings.json раздел с именем SwaggerApiInfo с двумя свойствами со значениями по вашему выбору: Name и Title. Добавьте документацию к API, как в показано следующем примере: Теперь запустите приложение и перейдите на localhost:<port>/index.html. Здесь вы можете наблюдать, как пользовательский интерфейс Swagger отображает все детали, указанные в документации по коду C#: описание API, схемы допустимых типов, коды состояния, поддерживаемый тип медиа, пример запроса и т. д. Это очень облегчает жизнь, когда вы работаете в команде. Несмотря на то, что это всего лишь пример, хорошей практикой будет применять к ответам API сжатие GZip в целях повышения производительности. Откройте класс Startup и добавьте туда следующие строчки кода: Для обработки ошибок используются кастомные исключения и middleware: Приложение 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: Создайте класс DbContext — он будет служить в качестве шлюза для доступа к базе данных, и определите правила отображения между SQL и CLR объектами: Свойство 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 с последними изменениями: Строка подключения к базе данных является конфиденциальной информацией, поэтому ее не следует хранить в 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 должен выглядеть следующим образом: Теперь мы реализуем ProductService: Следующий шаг связан с созданием схемы базы данных посредством миграций. Инструмент Migrations постепенно обновляет зарегистрированную файловую базу данных, чтобы синхронизировать ее с моделью данных приложения, сохраняя при этом существующие данные. Сведения о примененных к базе данных миграциях хранятся в таблице под названием "__EFMigrationHistory". Затем эта информация используется для выполнения непримененных миграций только в базу данных, указанную в строке подключения. Чтобы определить первую миграцию, откройте командную строку в папке с csproj и запустите dotnet-ef migrations, добавьте "InitialMigration" — она хранится в папке Migration. Затем обновите базу данных: dotnet-ef database update с только что созданной миграцией. Примечание: Если вы собираетесь выполнять миграцию впервые, сначала установите инструмент командной строки, используя dotnet tool install --global dotnet-ef. Как я уже говорил, UserSecrets работают только в среде разработки, поэтому вам необходимо добавить поддержку Azure KeyVault. Установите пакет Azure.Identity и отредактируйте Program.cs: где <keyvault> — это имя KeyVault, которое позже будет объявлено в скриптах Terraform. ASP.NET Core SDK предлагает библиотеки для создания отчетов о работоспособности приложений через конечные точки REST. Установите NuGet-пакет Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore и настройте конечные точки в классе Startup: Приведенный выше код добавляет две конечные точки: в конечной точке /health/ping приложение отвечает состоянием работоспособности системы. Значения по умолчанию — Healthy, Unhealthy или Degraded, но их можно настроить. Конечная точка /health/dbcontext возвращает текущий статус Entity Framework DbContext, т.е. по сути может ли приложение взаимодействовать с базой данных. Обратите внимание, что упомянутый выше NuGet-пакет предназначен специально для Entity Framework и внутренне ссылается на Microsoft.Extensions.Diagnostics.HealthChecks. Если вы не используете EF, то вам придется работать только с одной конечной точкой. Больше информации по этой теме вы найдете в официальной документации. Последним шагом в завершении проекта для ProductCatalog является добавление Dockerfile. Поскольку ProductCatalog и Notifications являются независимыми проектами, важно иметь отдельные Dockerfile для каждого проекта. Создайте папку Docker в проекте ProductCatalog, определите файл .dockerignore и Dockerfile: Примечание: Не забудьте также добавить файл .dockerignore. В интернете есть куча примеров под конкретные технологии — в данном случае для .NET Core. Примечание: Если ваша сборка Docker зависает на команде dotnet restore, вы столкнулись с багом, описанным здесь. Чтобы исправить это, добавьте этот нод в csproj: и добавьте /p:IsDockerBuild=true для команд restore и publish в Dockerfile, как описано в этом комментарии. Чтобы проверить этот Dockerfile локально, перейдите в командной строке в папку проекта и запустите: где: -t дает имя образу; -f указывает расположение файла Dockerfile и контекст сборки, представленный расширением . (точка, обозначающая текущую папку) в приведенной выше команде. На всякий случай, команда COPY относится к папке ProductCatalog. Затем запустите образ, используя: --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 и работу со встроенными механизмами валидации модели. Регистрация доступна по ссылке.