Всем привет, в этой статье я хочу рассказать вам о том как из базового шаблона asp.net MVC сделать интернет магазин с docker-compose и nginx в главных ролях. В увидите реализации api для таких технологий как Amazon S3, Kafka. Сможете лицезреть работу такого дуо как Asp.Net и Nginx. Увидеть реализацию таких протоколов как http/https и smtp. Созерцать написание сервисов нотификации, валидации, и др. Цель этой работы создать интернет-магазин, при этом включив в этот проект как можно больше технологий используемых разработчиками и devops`ами

Предисловие

Знаете, когда я только начал разработку этого проекта, я понял как иногда трудно внедрить какую-либо технологию в другую. В некоторых случаях я сталкивался с такой ситуацией: когда из огромной документации определённой технологии мне нужно выделить лишь пару моментов. И, конечно же, все эти моменты мне нужно совместить со своим проектом. Меня на протяжении всего проекта раздражал тот факт что на просторах интернета(всех возможных статьях, обучающих роликах на ютубе) вы не найдёте простого решения для своей задачи. Вот к примеру хочу я реализовать такую связку технологий: Asp.Net + Amazon S3 + Nginx + MsSql. Вы хотите? Ну и хотите дальше, ведь точно такой связки технологий вы не найдёте. А если случиться обратное, то считайте себя большим везунчиком. Оно и понятно не на все “крайние” случаи есть решения и даже после прочтения моей статьи вы наверняка ещё не раз столкнётесь с подобным.

Поэтому одной из целей этой статьи создать и продемонстрировать такую связку технологий, которая удовлетворит многим, По крайне мере автор очень надеется на это.

1. Начало

  1. Начать хотелось бы с базового описания проекта. По мере этого этапа  вы, возможно, мысленно представите какие технологии можно использовать  в данных кейсах.

    Мой интернет магазин будет продавать 3d фигурки распечатанные на 3d принтере.

    Приложение будет включать:

    1. Страницы регистрации и авторизации(С возможностью авторизации через сторонние сервисы, к примеру яндекс)

    2. Главную страницу и по совместительству каталог товаров

    3. Страницы, содержащие информацию об авторе, оплате, доставки

    4. Страницу с информацией об пользователе

    5. Страницу корзины товаров

    6. Страницу подтверждения эл.почты

    7. Страницу оплаты

    8. Страницу администратора

    Если у вас уже появились предположения то, возможно, они оправдаются в следующих главах.

    2. Глава о проектировке

    2.1. Asp.Net

    Первое что стоит сделать на этом этапе выбрать фреймворк. Но, естественно, если вы читали вступление, то знаете что это Asp.Net. И на этом этапе предлагаю начать вести визуальную связку технологий. Запишем туда первого кандидат: Asp.Net.  

    2.2. PostgerSql

    Исходя из изложенного ранее тз можно сделать вывод что этому проекту не обойтись без бд. Нам нужна "простая" реляционная база данных.

    Кандидатов на это место огромное количество. В самом начале я планировал использовать MsSql, так как это, весьма, распространённое решение. Однако позже я перешёл на Podtgresql, потому что:

    a) Был ранее знаком с этой базой данных

    b) Работал с её docker образом

    Ну и для большей убедительности вот несколько причин использовать postgresql в своих проектах:

    1. Отличная масштабируемость и производительность

    2. Открытый исходный код и поддержка сообщества

    Теперь связка технологий выглядит так:

    Asp.Net + Postgresql

    2.3.  Архитектура(Монолит)

     Одна из самых важных составляющих любого качественного приложения. С самого начала я решил использовать Onion архитектуру, потому что она обеспечивает более точное дробление данных и сущностей. К примеру в 3-ёх слойной архитектуре всего 3 уровня, поэтому там на одном уровне могут существовать как интерфейсы паттерна репозитория так и сущности базы данных. Такой подход не нагляден и не очень удобен, хотя более компактен чем Onion. По итогу получаем такой результат:

Onion в Visual Studio
Onion в Visual Studio

Здесь Application - это клиент(приложение которое обращается к веб-серверу, зачастую это подобное приложение открывается когда вы попадаете на какой-нибудь сайт). Клиент реализован на .Net MVC. Именно на нём содержатся все статические файлы(html,css,js), а также views( Расширенный вариант обычных html файлов, позволяющий передавать в него зависимости из контроллера)

View логина. Как видите дериктива model позволяет передать модель
View логина. Как видите дериктива model позволяет передать модель

Слои Buisnes logic - Infrastructure представляют собой простые библиотеки классов.(О них более подробно позже в статье)

Слой WebServer представляет собой Asp.Net Web Api. К нему по http протоколу обращается клиент.(Веб сервер приложение которое запускается на сервере)

3. Глава о фронтенде

В наше время существует множество фреймворков для фронта приложения. По сути клиент и является этим фронтендом. В проекте используется простейший bootstrap + Razor pages. Я думаю на этой части приложения заострять внимание не стоит, могу лишь поделиться ссылкой на хорошее веб-приложение для html,css разметки https://codepen.io.

  1. 3.1 Глава о three js

Three.js — кроссбраузерная библиотека JavaScript, используемая для создания и отображения анимированной компьютерной 3D графики при разработке веб-приложений.

Так следует из определения в википедии. В моём приложении я использовал её лишь 1 раз при создании страницы продукта:

А вот код позволяющий сделать такое:

import * as THREE from "three"; 
import { GLTFLoader } from "https://unpkg.com/three@0.165.0/examples/jsm/loaders/GLTFLoader.js";
import { OrbitControls } from 'https://unpkg.com/three@0.165.0/examples/jsm/controls/OrbitControls.js';
let urlsfromstr = document.getElementById("test").innerHTML;
var scene = new THREE.Scene();
var camera = new THREE.PerspectiveCamera(40, window.innerWidth / window.innerHeight, 1, 1000);
var renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
let loader = new GLTFLoader();
let obj = null;
renderer.setClearColor(0x000000, 0);
renderer.setAnimationLoop(animate);
renderer.setSize(500, 500);
let view = document.getElementById("images-control");
let container_in_view = document.getElementById("canvas-container");
renderer.domElement.setAttribute("id", "Model");
view.insertBefore(renderer.domElement, container_in_view);
const controls = new OrbitControls(camera, renderer.domElement);
// Обратите внимание на этот класс. Он позволяет добавить вращение продукта.
controls.minDistance = 5;
controls.maxDistance = 200;
controls.maxPolarAngle = Math.PI / 2;
camera.position.set(15, 20, 30);
const light = new THREE.DirectionalLight(0xFFFFFF, 1);
scene.add(light);
light.position.set(3, 3, 3);
camera.add(new THREE.PointLight(0xffffff, 3, 0, 0));
function animate() {
    requestAnimationFrame(animate);
    renderer.render(scene, camera);
    camera.lookAt(scene.position)
}
loader.load(urlsfromstr, function (object) {
    obj = object.scene;
    obj.position.set(0, 0, 0);
    scene.add(obj);
});
animate();

Из более сложных моментов при создании скрипта хотелось бы выделить:

a) Какой loader использовать для загрузки?

б) В каком формате загружать модели?

с) Как вращать модель?

На вопросы а и с есть ответ в коде, а вот формат я решил использовать .glb т.к он предоставляет модель с текстурами в одном файле, что удобно при загрузке со сторонних источников.

4. Глава об облаке

В последнем предложении предыдущей главы я упомянул сторонний источник. И тут возникает вопрос: 3d модель, она ведь довольно много весит. Где её хранить? На сервере там же где и веб-сервер? Ответ: Amazon S3 хранилище. Весьма удобное решение для хранения файлов любых форматов. Также если вам нужно хранить где-то большие объёмы данных S3 хранилище вам в помощь. Но в нынешнее время не очень удобно пользоваться сервисами Amozona, поэтому в качестве альтернативы я буду использовать YandexCloud. Не переживайте, клиент написанный мной подходит и для Amozona. Код представлен ниже.

public class YandexCloudClient : IYandexClient
{
    private AmazonS3Config configurationAws = new AmazonS3Config() { ServiceURL = "https://s3.yandexcloud.net" };
    public string baseBucket { get; set; }
    public YandexCloudClient(string clientawsid, string clientawssecret, string basebucket)
    {
        if(string.IsNullOrWhiteSpace(clientawsid) | string.IsNullOrWhiteSpace(clientawssecret) | string.IsNullOrWhiteSpace(basebucket)) throw new ArgumentNullException("args was null");
        var options = new CredentialProfileOptions()
        {
            AccessKey = clientawsid,
            SecretKey = clientawssecret,
        };
        var profile = new CredentialProfile("default", options)
        {
            Region = RegionEndpoint.USEast1
        };
        var sharedFile = new SharedCredentialsFile();
        sharedFile.RegisterProfile(profile);
        baseBucket = basebucket;
    }
    /// <summary>
    ///  Add Your file 
    /// </summary>
    /// <param name="file">bucketpath </param>
    /// <param name="postfix"> Is work from scheme basebacket + postfix</param>
    public async Task<AwsActionResultModel> AddModel(Stream file, string postfix, string nameofmodel)
    {
        if (string.IsNullOrWhiteSpace(postfix) | string.IsNullOrWhiteSpace(nameofmodel)) throw new NullReferenceException("args was null");
        AwsActionResultModel model = new AwsActionResultModel() { resultUrlFromModel = null, isCorrect = false };
        using (var client = new AmazonS3Client(configurationAws))
        {
            var conditionContinue = await Continue(client, baseBucket, file.CanRead);
            if (!conditionContinue) return model;
            var request = new PutObjectRequest()
            {
                BucketName = baseBucket,
                Key = postfix + "/" + nameofmodel,
                InputStream = file,
                StorageClass = S3StorageClass.Standard,
            };
            var resp = await client.PutObjectAsync(request);
            if (resp.HttpStatusCode == HttpStatusCode.OK)
            {
                var url = await GetUrl(client, postfix + "/" + nameofmodel);
                model.isCorrect = true;
                model.resultUrlFromModel = url;
                return model;
            }
            return model;
        }
    }
    public async Task<bool> DeleteModel(string postfix, string nameofmodel)
    {
        if (string.IsNullOrWhiteSpace(postfix) | string.IsNullOrWhiteSpace(nameofmodel)) throw new NullReferenceException("args was null");
        using (var client = new AmazonS3Client(configurationAws))
        {
            var conditionContinue = await Continue(client, baseBucket);
            if (!conditionContinue) { return false; }
            var request = new DeleteObjectRequest()
            {
                BucketName = baseBucket,
                Key = postfix + "/" + nameofmodel,
            };
            var task = client.DeleteObjectAsync(request);
            task.Wait();
            var resp = task.Result;
            return resp.HttpStatusCode == HttpStatusCode.NoContent ? true : false;
        }
    }
    public async Task<AwsActionResultModel> UpdateModel(Stream newfile, string postfix, string nameofmodel)
    {
        if (string.IsNullOrWhiteSpace(postfix) | string.IsNullOrWhiteSpace(nameofmodel)) throw new NullReferenceException("args was null");
        AwsActionResultModel model = new AwsActionResultModel() { resultUrlFromModel = null, isCorrect = false };
        // Ну то есть тут 3 операции Get later Delete later Put
        using (var client = new AmazonS3Client(configurationAws))
        {
            var conditionContinue = await Continue(client, baseBucket);
            if (!conditionContinue)
            {
                return model;
            }
            var request = new GetObjectRequest()
            {
                BucketName = baseBucket,
                Key = postfix + "/" + nameofmodel
            };
            var resp = client.GetObjectAsync(request);
            resp.Wait();
            if (resp.Result.HttpStatusCode != HttpStatusCode.OK)
            {
                return model;
            }
            var resp2 = DeleteModel(postfix, nameofmodel);
            resp2.Wait();
            if (!resp2.Result)
            {
                return model;
            }
            model = await AddModel(newfile, postfix, nameofmodel);
            return model;
        }
    }
    private async Task<bool> Continue(AmazonS3Client client, string bucket, bool condition = true)
    {
        var bucketExist = await AmazonS3Util.DoesS3BucketExistV2Async(client, bucket);
        bool res = bucketExist & condition ? true : false;
        if (!res) throw new Exception();
        return res;
    }
    private async Task<string> GetUrl(AmazonS3Client client, string pathforfile)
    {
        var request = new ListObjectsV2Request()
        {
            BucketName = baseBucket,
            Prefix = pathforfile,
        };
        var resp = await client.ListObjectsV2Async(request);
        var fileurl = resp.S3Objects.Where(o => o.Key == pathforfile).Select(o =>
        {
            return client.GetPreSignedURL(new GetPreSignedUrlRequest() { BucketName = baseBucket, Expires = DateTime.MaxValue, Key = o.Key });
        }).SingleOrDefault();
        return fileurl ?? throw new Exception();

    }


}

Пройдёмся по коду. Здесь используется SDK для .Net от Amazona. (Грубо говоря библиотека/nuget пакет AWSSDK.S3). В конструкторе класса передаём идентификатор и секрет(подробнее где их взять опишу ниже), а также базовый бакет (бакет удобнее всего представлять как папку куда вы складываете файлы) SharedCredentialsFile создаёт конфиг на сервере(если захотите потестировать, файлы конфигурации появиться C:\Users\user\.aws в этой папке). В остальном сказать нечего. На каждую операцию здесь создаётся объект класса с окончанием Request.

Где же достать всё для конфига?

  1. Заходим на сайт https://yandex.cloud/ru/

  2. Регистрируемся

  3. Переходи в консоль:

  1. Находим Object storage хранилище

  1. Добавляем этот сервис

  2. Создаём новый бакет

  1. Возвращаемся обратно в консоль

  2. Заходим в сервисные аккаунты

  1. Создаём сервисный аккаунт и добавляем ему роль storage.editor

  2. Нажимаем на созданный сервисный аккаунт и создаём новый ключ

  3. Выбираем 1 ключ тот что для object storage(там будет написано)

  4. После создания будет дан id и secret.( Их нужно будет использовать в коде)

  5. Далее переходим обратно в сервис Object Storage.

  6. Нажимаем на вот эти 3 точки

  1. Выбираем ACL бакета и добавляем(или редактируем) наш сервисный аккаунт так чтобы разрешения были на Read и Write.

  2. Далее выбираем наш бакет

  3. В настройках выбираем доступ на чтение публичный

  4. В безопасности устанавливаем Cors для нашего хоста с которого будут идти запросы

    Всё. Простите за такую большую инструкцию, но я хотел сделать всё кратко и подробно. Подобную инструкцию можно найти на оф.сайте документации яндекс облака.https://yandex.cloud/ru/docs/storage/s3. На данный момент наша цепочка технологий выглядит так Asp.Net + Postgersql + YandexCloudS3Storage(Amozon S3)

    После создания сервиса вы можете его добавить в Program.cs на WebServer( web api)

services.AddSingleton<IYandexClient, YandexCloudClient>(arg => new YandexCloudClient("ClientId", "ClientSecret","ClientBucket"));

IYandexClient просто интерфейс с методами.

public interface IYandexClient
{
    public Task<bool> DeleteModel(string postfix, string nameofmodel);
    public Task<AwsActionResultModel> AddModel(Stream file, string postfix, string nameofmodel);
    public Task<AwsActionResultModel> UpdateModel(Stream newfile, string postfix, string nameofmodel);
}

AwsActionResultModel моя собственная модель

public class AwsActionResultModel
{
    public bool isCorrect { get; internal set; }
    public string resultUrlFromModel { get; set; } = null!;
}

5. Глава об Kafka

Apache Kafka — распределённый программный брокер сообщений

На данный момент права на использование Kafka в Net принадлежат компании Confluent. Поэтому самый удобный функционал в их nuget пакете. Для интеграции кафки в наше приложение потребуется сделать несколько "не очень приятных действий" в частности сделать клиент и веб-сервер микросервисами. Подробнее о том как это происходило будет описано позже. Пока что разберёмся с кодом. Для работы с кафкой я также как и для облака написал собственного клиента.

public sealed partial class KafkaClient<TModel> : IMessageBrokerClient<TModel>
{
    private readonly ProducerConfig _producerConf;
    private readonly ConsumerConfig _consumeConf;
    private readonly ILogger<KafkaClient<TModel>> _logger;
    public string BaseTopic { get; init; }
    public ObservableCollection<TModel> Models { get; set; }

    // Решение с int временное
    public KafkaClient(ILogger<KafkaClient<TModel>> logger, string host, string basetopic, string clientid, string groupid)
    {
        if(string.IsNullOrWhiteSpace(host) | string.IsNullOrWhiteSpace(basetopic) | string.IsNullOrWhiteSpace(clientid) | string.IsNullOrWhiteSpace(groupid)) throw new ArgumentNullException(nameof(host));
        _producerConf = new ProducerConfig()
        {
            BootstrapServers = host,
            Acks = Acks.All,
            Partitioner = Confluent.Kafka.Partitioner.ConsistentRandom
        };
        _consumeConf = new ConsumerConfig()
        {
            BootstrapServers = host,
            ClientId = clientid,
            GroupId = groupid,
        };
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
        BaseTopic = basetopic;
        Models = new ObservableCollection<TModel>();
    }
    public async Task<ProduceResultModel> Produce(TModel model, CancellationTokenSource cts, string? topic = null)
    {
        if(model is null) throw new ArgumentNullException(nameof(model));
        if (cts is null) throw new ArgumentNullException(nameof(cts));
        string? json = null;
        try
        {
            json = JsonConvert.SerializeObject(model);
        }
        catch(Exception ex)
        {
            string msg = "producer got this error: " + ex.Message + Environment.NewLine + ex.StackTrace;
            _logger.LogError(msg);
            return new ProduceResultModel() { Success = false, ErrorDescription = msg, Exception = ex };
        }
        if(string.IsNullOrEmpty(json))
        {
            string msg = "data convert to json was incorrect";
            _logger.LogError(msg);
            return new ProduceResultModel() { Success = false, ErrorDescription = msg};
        }
        var message = new Message<string, string>()
        {
            Key = new Guid().ToString(),
            Value = json
        };
        using (var producer = new ProducerBuilder<string, string>(_producerConf).SetKeySerializer(Serializers.Utf8).Build())
        {
            try
            {
                await producer.ProduceAsync(topic ?? BaseTopic, message, cts.Token);
                return new ProduceResultModel() { Success = true };
            }
            catch (Exception ex)
            {
                 string msg = "producer got this error: " + ex.Message + Environment.NewLine + ex.StackTrace;
                _logger.LogError(msg);
                return new ProduceResultModel() { Success = false, ErrorDescription = msg, Exception = ex };
            }
        }
    }
    public void Consume(uint consuming_time, CancellationTokenSource cts, string? topic = null)
    {

        using (var consumer = new ConsumerBuilder<string, string>(_consumeConf).Build())
        {
            consumer.Subscribe(topic ?? BaseTopic);
            
            while (!cts.IsCancellationRequested)
            {
                TModel? data = default;
                try
                {
                    var res = consumer.Consume(TimeSpan.FromSeconds(consuming_time));
                    Task.Delay(TimeSpan.FromSeconds(consuming_time));
                    if (res is not null)
                    {
                         data = JsonConvert.DeserializeObject<TModel>(res.Message.Value); 
                        _logger.LogInformation(DateTime.UtcNow + this.ToString() + " User Added");
                    }
                }
                catch (Exception ex)
                {
                    _logger.LogError(DateTime.UtcNow + Environment.NewLine + ex.Message + Environment.NewLine + ex.StackTrace);
                }
                const string jsonError = "data can`t deserialize from json";
                if(data is not null)
                {
                    AddModel(data);
                }
              
            }
        }

    }

}
public partial class KafkaClient<TModel>
{
    private void AddModel(TModel model)
    {
        lock (Models)
        {
            Models.Add(model);
        }
    }
}

Пройдёмся по коду. Как можно увидеть кафка здесь весьма узкоспециализированная. Всё что делает этот сервис, добавляет в список объекты. Если вглядеться в реализацию детальнее можно увидеть хороший пример паттерна Observer. В частности класс ObservableCollection . IMessageBrokerClient самописный интерфейс. Подробно о том как работают брокеры сообщений я рассказывать не буду. Коротко есть тот кто отсылает сообщения(какие-либо данные)Produce и тот кто "слушает" место куда отправляются данные Consume . Топик - это определённый канал на котором происходит то что я описал в предыдущем предложении.

Работа кафки
Работа кафки

Как видите данные отправляются в формате json, что не всегда удобно.

Главным недостатком брокеров сообщений является плохая обратная связь. Изначально я хотел использовать кафку для регистрации пользователей, но позже отказался от этого как раз из-за этого недостатка. Единственное место где я добавил кафку это добавление продуктов.

Однако как же заставить работать брокер сообщений на постоянной основе как например контроллер. Для этого стоит использовать такой малоизвестный функционал Asp.Net приложений как BackgroundService сервис:

public class ProductOperationService : BackgroundService
{
    private readonly IConsumeClient<ProductContractModelJson> _messageBroker;
    private readonly ILogger<Program> _logger;
    private readonly IServiceProvider _serviceProvider;
    public ProductOperationService(ILogger<Program> logger, IMessageBrokerClient<ProductContractModelJson> client, IServiceProvider serviceProvider)
    {
        _messageBroker = client ?? throw new ArgumentNullException(nameof(client));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
        _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
        _messageBroker.Models.CollectionChanged += Add_Product;
    }
    private void Add_Product(object? sender, NotifyCollectionChangedEventArgs e)
    {
        if (e.Action == NotifyCollectionChangedAction.Add)
        {
            Register(e.NewItems!.Cast<ProductContractModelJson>());
        }
    }
    protected override Task ExecuteAsync(CancellationToken stoppingToken)
    {
        CancellationTokenSource _tokenSource = new CancellationTokenSource();
        var factoryTask = new TaskFactory(stoppingToken);
        var tasks = new[]
        {
          factoryTask.StartNew(() => _messageBroker.Consume(20, _tokenSource, KafkaTopics.AddProductTopic)),
          factoryTask.StartNew(() => _messageBroker.Consume(20, _tokenSource, KafkaTopics.AddProductTopic))
        };
        return Task.CompletedTask;
    }
    private async void Register(IEnumerable<ProductContractModelJson> products)
    {
        using (var scope = _serviceProvider.CreateScope())
        {
            foreach (var product in products)
            {
                MemoryStream stream = new MemoryStream();
                stream.Write(product.FileInBytes!, 0, product.FileInBytes!.Length);
                await scope.ServiceProvider.GetRequiredService<IProductOperation>().CreateProductAsync(product, stream, product.FileName!);
            }
        }
          
    }
 }

Здесь всё достаточно просто. Создаём что-то вроде контроллера и наследуемся от класс BackgroundService. Я решил сделать 2 Consumera т.к по сути не важно кто заберёт сообщение главное что оба выполнят работу.

Для добавления на веб-сервер, нужно добавить 2 сервиса.

Для кафки я написал отдельный метод расширения:

 public static IServiceCollection AddKafkaClient<TModel>(this IServiceCollection services, IConfiguration configuration)
 {
     using (ILoggerFactory factory = LoggerFactory.Create(builder => builder.AddConsole()))
     {
         ILogger<KafkaClient<TModel>> logger = factory.CreateLogger<KafkaClient<TModel>>();
         return services.AddSingleton<IMessageBrokerClient<TModel>, KafkaClient<TModel>>(arg => new KafkaClient<TModel>(logger, "Host", "DefaultTopic"));
     }
        
 }

И вот так добавил

builder.Services.AddKafkaClient<ProductContractModelJson>(builder.Configuration);
 builder.Services.AddHostedService<ProductOperationService>();

Теперь наша связка технологий выглядит так Asp.Net + Postgresql + YandexCloud + Kafka

6. Глава о переходе от монолита к микросервисам

В этой главе будет описано как я "перешёл" от монолита к "микросервисам" ну вначале в этом была практическая нужда т.к postgersql, kafka со всем этим я работал только в рамках докер образов, поэтому решено была выбрать именно этот путь. Но вначале немного теории.

Docker — программное обеспечение для автоматизации развёртывания и управления приложениями в средах с поддержкой контейнеризации

Моя ОС поддерживает docker контейнеры linux и имеет включённую виртуализацию так что поддержка контейнеризации есть. Подробнее о том как установить и настроить поддержку докер https://docs.docker.com/desktop/install/windows-install/. Также не забудьте зарегистрироваться на docker hub https://hub.docker.com/. После установки можно приступать.

В visual studio очень удобная поддержка контейнеризации собственных приложений так что просто нажимаем на web-server и application пкм и выбираем Добавить -> Поддержка докер. И всё. Теперь наш клиент и веб-сервер микросервисы. Ну по факту да, но по определению нет. Далее чтобы добавить кафку и postgesql контейнеры добавим систему оркестрации: Docker Compose.

Чтобы добавить поддержку оркестрации нажимаем на любое из приложений и добавляем. Далее создаётся 2 файла docker-compose и docker-compose-override. Изменяем их подобным образом:

Docker-compose:

services:
  webclient:
    image: ${DOCKER_REGISTRY-}application
    build:
      context: .
      dockerfile: Application/Dockerfile
    depends_on:
       - webserver
  webserver:
     image: ${DOCKER_REGISTRY-}webserver
     build:
       context: .
       dockerfile: WebServer/Dockerfile
     depends_on:
        - database
     ports:
     - "8000:8000"

Docker-compose-override:

services:
  webclient:
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - ASPNETCORE_HTTP_PORTS=7000
    ports:
      - "7000"
  webserver:
     environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - ASPNETCORE_HTTP_PORTS=8000
     ports:
      - "8000"

Далее добавляем туда кафку и postgres

Docker-compose:

database:
    container_name: database-postgres
    image: ${DOCKER_REGISTRY-}postgres
    environment:
         - POSTGRES_USER=postgresRU
         - POSTGRES_PASSWORD=rtps
    ports:
      - ":"
zookeeper:
  image: docker.io/bitnami/zookeeper:3.9
  ports:
    - ":"
  environment:
   ALLOW_ANONYMOUS_LOGIN: yes
message_broker:
    image: ${DOCKER_REGISTRY-}bitnami/kafka
    ports:
    - ":"
    environment:
     KAFKA_CFG_ZOOKEEPER_CONNECT: zookeeper:
     KAFKA_CFG_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
     KAFKA_CFG_ADVERTISED_LISTENERS: PLAINTEXT://message_broker:9092
     KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
     # JMX_PORT: 9997
     # KAFKA_CFG_PROCESS_ROLES: controller,broker
     # KAFKA_CFG_CONTROLLER_LISTENER_NAMES: CONTROLLER
    depends_on:
    - zookeeper
kafka_ui:
    container_name: kafka-ui
    image: ${DOCKER_REGISTRY-}provectuslabs/kafka-ui
    ports:
    - ":"
    environment:
      KAFKA_CLUSTERS_0_NAME: transitional_path
      KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: message_broker:
      KAFKA_CLUSTERS_0_ZOOKEEPER: zookeeper:
      KAFKA_CLUSTERS_0_JMXPORT: 9997
      DYNAMIC_CONFIG_ENABLED: true
    depends_on: 
    - message_broker

По конфигурации особо сказать нечего. Это просто конфиг собранный с разных источников. Единственное, рекомендую чаще использовать директиву depends_on т.к она позволяет ждать включения одного микросервиса другим.

Теперь наша связка технологий выглядит так Asp.Net + Postgresql + YandexCloud + Kafka + Docker compose + Docker

7. Глава об Ngin

docker-compose-override

admin:
    environment:
     - ASPNETCORE_ENVIRONMENT=Development
     - ASPNETCORE_HTTP_PORTS=9000
    ports:
      - "9000"

docker-compose:

admin:
   image: ${DOCKER_REGISTRY-}adminapp
   build:
       context: .
       dockerfile: AdminApp/Dockerfile
   depends_on:
       - webserver

По сути это такой же клиент как и основное приложение. После вынесения админки как отдельный микросервис, встал вопрос о том как связать 2 приложения под 1 доменом. Тут и пригодится Nginx в роли обратного прокси сервера. Если коротко то Nginx просто перенаправляет запрос на нужный микросервис.

Чтобы nginx заработал требуется добавить его докер образ в докер compose.

proxy:
    container_name: web-proxy
    image: ${DOCKER_REGISTRY-}nginx
    volumes:
     - ./nginx.conf:/etc/nginx/nginx.conf
    ports:
      - "80:80"
      - "443:443" 
    depends_on:
      - database

Также создать в папке проекта файл nginx.conf. В нём и будет вся основная конфигурация прокси сервера.

worker_processes 4;
events {worker_connections 1024;}
http{
server {
    listen 80;
    server_name localhost;
    return 301 https://$server_name$request_uri;
}
server {
        listen  [::]:443;
        listen  443 ssl;
        ssl_certificate     /etc/nginx/certs/server.crt;
        ssl_certificate_key /etc/nginx/certs/server.key;
        server_name localhost;
	    location / {
	     proxy_set_header   X-Forwarded-For $remote_addr;
         proxy_set_header   Host $http_host;
	     proxy_pass http://webclient:7000;
	    }
	    location /Admin {
	     proxy_set_header   X-Forwarded-For $remote_addr;
         proxy_set_header   Host $http_host;
	     proxy_pass http://admin:7000;
	   }
}
}

Коротко о конфигурации. Здесь добавлена поддержка сразу 2 протоколов http/https. Директива listen 443 ssl и listen 80 указывает на то что nginx слушает, указанные порты. Директива location определяет запрос и перенаправляет на нужный микросервис.

Теперь наша связка технологий выглядит так Asp.Net + Postgresql + YandexCloud + Kafka + Docker compose + Docker + Nginx.

8. Глава об Smtp и сервисе нотификации

Есть несколько способов оповещения пользователей веб-приложения. К примеру по смс или эл.почте. Я решил использовать оповещения через почту т.к реализовать это проще и бесплатно. Как всегда немного теории.

SMTP — это широко используемый сетевой протокол, предназначенный для передачи электронной почты в сетях TCP/IP

Пояснение из википедии в данном случае лаконично и понятно описывает суть кода показанного ниже.

public class PostSender : ISender
{
    private IConfiguration _config;
    private readonly ILogger<PostSender> _logger;

    public PostSender(ILogger<PostSender> logger, IConfiguration configuration)
    {
        _config = configuration ?? throw new ArgumentNullException(nameof(configuration));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }
    public void SendMailSmtp(MimeMessage message, SmtpMessageModel config_message)
    {
        const string protocol = "smtp";
        string connectionString = string.Format("{0}.{1}.{2}", protocol, config_message.provider, config_message.domain_region);
        using (var smtpClient = new SmtpClient())
        {
            try
            {
                smtpClient.Connect(connectionString, config_message.port,config_message.useSsl);
                smtpClient.Authenticate(_config[$"SmtpClients:{config_message.provider}:User"], _config[$"SmtpClients:{config_message.provider}:Pass"]);
                message.To.Add(new MailboxAddress(string.Empty, config_message.RecipientEmail));
                message.From.Add(new MailboxAddress(config_message.nameCompanyOrAdministration, config_message.SenderEmail));
                smtpClient.Send(message);
            }
            catch(Exception ex)
            {
                _logger.LogError(DateTime.UtcNow + " " + this.ToString() + Environment.NewLine + "Smtp connect get exception: " + ex.Message);
            }
            finally
            {
                smtpClient.Dispose();
            }
        }
    }
}

SmtpClient это класс из nuget пакета MailKit. SmtpMessageModel - это самописная модель

public record SmtpMessageModel(string nameCompanyOrAdministration,string RecipientEmail, string SenderEmail, string provider, string domain_region, int port = 465, bool useSsl = true);

Итог 1 части

Была проделана довольно объёмная работа по внедрению выше упомянутых технологий. Во 2 части я постараюсь описать как эти технологии взаимодействуют друг с другом. Однако большая часть того чего я хотел написать уже написана. Я надеюсь что это статья была полезна тем кто дочитал до конца.

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


  1. Limansky
    14.08.2024 08:55

    Спасибо, отличная и интересная получилась статья. Продолжайте в том же духе.


  1. aaoo
    14.08.2024 08:55

    вопросы по кафке. в методе Consume у вас константа jsonError вроде бы нигде не используется. в этом же методе: почему мы не await-им вызов task.delay. и в целом, можете, пожалуйста, объяснить назначение этой строки? спасибо