C# Portal | Программирование
14.9K subscribers
911 photos
106 videos
24 files
758 links
Присоединяйтесь к нашему каналу и погрузитесь в мир для C#-разработчика

Связь: @devmangx

РКН: https://clck.ru/3FocB6
Download Telegram
Понимание паттерна Observer это одна из базовых вещей, которую должен знать любой frontend-разработчик.

// Наблюдатели, которые выполняют Update, когда получают уведомление
interface IObserver
{
void Update(string message);
}

// Субъект, который уведомляет подписанных наблюдателей
interface ISubject
{
// Регистрация, удаление и уведомление наблюдателей
void Register(IObserver observer);
void Unregister(IObserver observer);
void Notify(string message);
}

// Конкретная реализация субъекта, который рассылает обновления подписчикам
class ConcreteSubject : ISubject
{
private readonly List<IObserver> observers = new();

public void Register(IObserver observer)
{
observers.Add(observer);
}

public void Unregister(IObserver observer)
{
observers.Remove(observer);
}

public void Notify(string message)
{
foreach (var observer in observers)
{
observer.Update(message);
}
}
}

// Конкретная реализация наблюдателя, который реагирует на уведомления субъекта
class ConcreteObserver : IObserver
{
private readonly string name;

public ConcreteObserver(string name)
{
this.name = name;
}

public void Update(string message)
{
Console.WriteLine($"{name} получил сообщение: {message}");
}
}

// Создаем субъект и наблюдателей
var subject = new ConcreteSubject();
var observer1 = new ConcreteObserver("Наблюдатель 1");
var observer2 = new ConcreteObserver("Наблюдатель 2");

// Подписываем наблюдателей на субъект
subject.Register(observer1);
subject.Register(observer2);

// Субъект меняет состояние и уведомляет подписчиков
subject.Notify("Состояние изменилось.");
subject.Notify("Доступно новое обновление.");


👉 @KodBlog
Please open Telegram to view this post
VIEW IN TELEGRAM
10👍5😁4
Это приложение прям отличный вариант для разработчиков. Показывает, какие порты у тебя открыты, и позволяет закрыть любой в один клик.

Опенсорсное и работает на Windows, Linux и macOS.

👉 @KodBlog
Please open Telegram to view this post
VIEW IN TELEGRAM
15🔥6👏1😁1🤔1
Есть два способа настроить оптимистичный контроль конкурентности в EF Core:

1. Использовать атрибут [Timestamp] в модели сущности

2. Определить поле row version через Fluent API

👉 @KodBlog
Please open Telegram to view this post
VIEW IN TELEGRAM
3👍3🌚1
Производительность платежной системы в проде была увеличена примерно в 15 раз — всего одной строкой кода в EF Core

Система обрабатывает массовые платежи, разбивая один запрос на множество аккаунтов получателей.

В тестах всё работало нормально, но при переходе в прод с реальными объёмами данных API стал тратить секунды на обработку одного запроса по нескольким тысячам счетов.

Каждый платежный запрос делает выборку из таблицы payment_accounts, чтобы определить счета, которые еще не были полностью оплачены.

Тест на 19 000 аккаунтов в SQL Server показал время обработки: 8.22 секунды.

Требование SLA (p99): 1 секунда.
API был примерно в 8 раз медленнее порога SLA.

Основная проблема оказалась в методе SaveChangesAsync.

EF Core умеет группировать несколько вставок, но база данных все равно выполняет их по отдельности.

В SQL Server EF Core использует оператор MERGE для массовых вставок, но подход ограничен лимитом SQL-параметров (2100 на батч). Если у сущностей много полей = производительность быстро падает.

После оптимизаций стало понятно, что штатными инструментами EF Core не удастся добиться нужной скорости.

Решением стала библиотека Entity Framework Extensions.

Она даёт простой, гибкий и быстрый способ для bulk-вставок, позволяя вставлять тысячи записей за один запрос к базе.

Методы BulkInsert и BulkInsertOptimized позволяют выполнить массовую вставку буквально одной строкой кода.

С их помощью время вставки удалось снизить до 521 мс, что укладывается в SLA.

👉 @KodBlog
Please open Telegram to view this post
VIEW IN TELEGRAM
👌9👍73🔥1
AsNoTracking() вроде ускоряет выполнение, но есть тонкий момент, о котором часто забывают, а именно о том , что он может создавать дубликаты сущностей в памяти.

Почему так происходит? Потому что без change tracker EF Core не делает identity resolution - он не проверяет, была ли эта сущность уже загружена ранее. В итоге можно получить несколько объектов, которые на самом деле представляют одну и ту же запись в базе.

Пример:

var orders = await context.Orders
.AsNoTracking()
.Include(o => o.Customer)
.Where(o => o.OrderDate > DateTime.Now.AddDays(-30))
.ToListAsync();


Если у нескольких заказов один и тот же пользователь, то Customer будет склонирован под каждый заказ. То есть:

• больше памяти
• сравнение сущностей по ссылке становится бесполезным
• нарушается связность графа объектов

Решение — AsNoTrackingWithIdentityResolution()

Этот метод даёт баланс:

• нет change tracking (то есть быстрый чтение-only режим)
• есть identity resolution (одна сущность — один объект)

Пример:

var orders = await context.Orders
.AsNoTrackingWithIdentityResolution()
.Include(o => o.Customer)
.Where(o => o.OrderDate > DateTime.Now.AddDays(-30))
.ToListAsync();


Теперь все заказы, у которых один и тот же Customer, будут ссылаться ровно на один объект пользователя.

Как это работает внутри:

• запрос выполняется без отслеживания
• EF Core создаёт временную identity map
• сущности с одинаковым ключом получат одинаковый объект
• карта удаляется после завершения запроса

То есть tracking нет, но дубликатов тоже нет.

Если это поведение нужно часто, его можно включить по умолчанию:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder
.UseSqlServer(connectionString)
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTrackingWithIdentityResolution);
}


Когда использовать:

1. Отслеживание по умолчанию:

- необходимо обновлять, удалять или отслеживать изменения сущностей;
- небольшие наборы результатов, где затраты на отслеживание незначительны;
- необходимо автоматическое обнаружение изменений.

2. AsNoTracking:

- операции только чтения;
- нет свойств навигации или связанных сущностей;
- максимальная производительность критически важна;
- результаты не содержат дублирующихся сущностей.

3. AsNoTrackingWithIdentityResolution:

- операции только чтения с include/join;
- требуется ссылочная целостность в графе объектов;
- запросы, возвращающие одну сущность несколько раз;
- требуется сравнивать сущности по ссылке.

👉 @KodBlog
Please open Telegram to view this post
VIEW IN TELEGRAM
14👍1
This media is not supported in your browser
VIEW IN TELEGRAM
Visual Studio 2026 официально вышла.

Раньше обновление Visual Studio автоматически тянуло за собой обновление .NET и C++ toolchain. Один пакет значит один риск. Хотел новые фичи IDE? Заодно получай новые компиляторы, нужные они тебе или нет.

В VS 2026 это разделили. IDE обновляется отдельно. Новые фичи прилетают раз в месяц. Build tools остаются в нужной версии.

Производительность подтянули по всем направлениям. Количество UI фризов уменьшилось на 50%. Запуск под F5 стал быстрее на 30% вместе с .NET 10. Большие решения загружаются ощутимо быстрее, а не только формально по цифрам.
Code coverage больше не только в Enterprise.

Теперь он доступен во всех версиях. Разработчики на Community и Professional могут анализировать покрытие тестами без апгрейда лицензии.

Интеграция с AI стала глубже. Copilot теперь понимает внешние символы в C#, а не только твой проект. Он может подтягивать контекст по URL. Debugger агент умеет автоматически чинить упавшие unit-тесты: предполагает причину, правит, проверяет и повторяет цикл, пока тест не пройдет.

Обратная совместимость полная. Проекты из VS 2022 открываются напрямую. Все 4000+ расширений работают без правок. Никакой миграции.

Fluent UI — самое заметное изменение, но далеко не главное. Главное то, что IDE стала быстрее, стабильнее и обновляется без принудительных обновлений compiler toolchain.

Что попробовать в первую очередь:
- Открой свою самую тяжелую solution и почувствуй разницу
- Запусти code coverage в Community/Professional
- Дай debugger агенту починить упавший тест

Уже доступно. Проекты и расширения из VS 2022 переносятся автоматически.

👉 @KodBlog
Please open Telegram to view this post
VIEW IN TELEGRAM
24👍3👎2
Поддержка Server-Sent Events (SSE)

В ASP.NET Core теперь можно возвращать результаты в формате ServerSentEvents через API TypedResults.ServerSentEvents. Эта фича работает как в Minimal APIs, так и в контроллерах.

Server-Sent Events это механизм push-уведомлений, где сервер может отправлять поток событий клиенту по одному HTTP-соединению. В .NET каждое сообщение представлено как SseItem<T>, где может быть тип события, его ID и payload типа T.

В TypedResults появилась новая статическая функция ServerSentEvents, которая возвращает результат типа ServerSentEvents. Первый аргумент это IAsyncEnumerable<SseItem<T>> — поток событий, который будет отправляться клиенту.

Ниже пример, как с помощью TypedResults.ServerSentEvents стримить JSON-события с данными пульса:

app.MapGet("/json-item", (CancellationToken cancellationToken) =>
{
async IAsyncEnumerable<HeartRateRecord> GetHeartRate(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
var heartRate = Random.Shared.Next(60, 100);
yield return HeartRateRecord.Create(heartRate);
await Task.Delay(2000, cancellationToken);
}
}

return TypedResults.ServerSentEvents(
GetHeartRate(cancellationToken),
eventType: "heartRate"
);
});


👉 @KodBlog
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥7👏2🕊21
Хватит выкатывать релиз каждый раз, когда появляется новый фильтр.

Пусть фильтры обновляются сами.

Что если твой LINQ Where принимал бы правило во время выполнения и при этом оставался нормальным SQL-транслируемым запросом?

Хардкодить фильтры кажется нормальным… пока продукт не приходит с очередным запросом: "добавьте ещё одно поле".

И вот уже вокруг if-джунгли, дублированные запросы и новый деплой ради мелкой правки. Динамические предикаты меняют всё → правила живут в конфиге/БД/UI, а код остаётся компактным и стабильным.

Где это реально помогает:

• Админки и отчёты → пользователь комбинирует поля/условия без твоих доп. веток.
• Поисковые API: query-string/JSON → динамический предикат, без плясок с expression trees.
• Multi-tenant/white-label → у каждого клиента свои правила, ты просто загружаешь и применяешь.
• Сегменты пользователей → маркетинг собирает аудитории вроде
"Активен в DACH И (последние 90 дней ИЛИ ≥ 500€) И подписан на рассылку" - без релиза.

Зачем оно нужно:

• Меньше условных веток → чище код и проще тестирование.
• Гибкость на рантайме → быстрее итерации, меньше релизов.
• Всё ещё IQueryable → EF прогоняет фильтры в SQL, а не в память.

Полезные практики:

• Ввести whitelist допустимых полей/операторов (безопасность и предсказуемость).
• Валидировать правила до сборки предиката.
• Оставлять всё на IQueryable до конца (пускай EF делает работу).
• Унифицировать формат дат и параметров, чтобы не ловить культуру-зависимые баги.
• Добавить snapshot-тесты для сохранённых фильтров/сегментов.

Когда это не нужно = если у вас всего 2–3 фиксированных фильтра, которые редко меняются, обычный LINQ проще и надёжнее.

Если у тебя когда-то было ощущение "я пишу фичи, а не переписываю запросы", значит этот подход тебе зайдёт.

Полный гайд и примеры

👉 @KodBlog
Please open Telegram to view this post
VIEW IN TELEGRAM
11🥴6👍1🔥1
Использование Git Conditional Includes для нескольких конфигураций

Когда работаешь с несколькими репозиториями Git, часто нужны разные настройки под разные контексты. Например, можно использовать личную почту для open-source проектов и рабочую почту для корпоративных репозиториев. Да, можно настроить Git глобально или в каждом репозитории вручную, но со временем это становится муторно и легко ошибиться.

Функция Git conditional includes решает эту проблему = она автоматически применяет нужную конфигурацию в зависимости от условий вроде пути репозитория, URL remotes или имени ветки. В итоге нужные настройки подтягиваются сами - без ручных переключений.

Вот пример, который подключает отдельную конфигурацию, если работаешь с личными репозиториями GitHub:

[includeIf "hasconfig:remote.*.url:https://github.com/meziantou/*"]
path = .gitconfig-github-personal


Git проверяет remotes и если URL совпадает с шаблоном, он автоматически подгружает настройки из .gitconfig-github-personal, где, например, могут храниться твоя личная почта и ключ подписи.

Понимаем, как работают Git Conditional Includes

Git поддерживает несколько типов условий для conditional includes. Каждый подходит под свой сценарий.

gitdir - совпадение по пути репозитория

Условие gitdir срабатывает, если путь к репозиторию совпадает с заданным. На Unix-системах проверка чувствительна к регистру.

[includeIf "gitdir:~/personal/"]
path = .gitconfig-personal

[includeIf "gitdir:~/work/"]
path = .gitconfig-work


Так настройки .gitconfig-personal применяются ко всем репозиториям под ~/personal/, а .gitconfig-work к тем, что в ~/work/.

gitdir/i - совпадение по пути без учета регистра

То же самое, что gitdir, но нечувствительное к регистру. Полезно на Windows или когда нужен более гибкий матчинг.

[includeIf "gitdir/i:c:/projects/company/"]
path = .gitconfig-company


onbranch - совпадение по текущей ветке

Это условие подключает конфигурацию в зависимости от того, какая ветка сейчас checkout -нута.

[includeIf "onbranch:main"]
path = .gitconfig-production

[includeIf "onbranch:feature/**"]
path = .gitconfig-development


Удобно, когда нужно автоматически переключаться между продовыми и девелоперскими настройками.

hasconfig - совпадение по существующей настройке Git

Это условие проверяет, существует ли конкретная настройка и подходит ли под шаблон. Особенно полезно, чтобы матчить remote-URL.

[includeIf "hasconfig:remote.*.url:https://github.com/meziantou/*"]
path = .gitconfig-github-personal

[includeIf "hasconfig:remote.*.url:[email protected]:*/**"]
path = .gitconfig-company


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

👉 @KodBlog
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥9❤‍🔥2
API Gateway и Load Balancer часто путают новички, но это база для любого бэкенд разработчика

API Gateway  работает как входная точка

• это единая точка, куда стучатся клиенты, он берет на себя всю первичную обработку запросов
• умный слой, который управляет авторизацией и аутентификацией (AuthN), rate limiting, логированием, мониторингом запросов и маршрутизацией
• идеально подходит для микросервисов, потому что централизует управление API во всей системе
• работает на уровне приложения L7

Load Balancer  работает как распределитель трафика

• его задача распределять входящие запросы между группой одинаковых серверов
• нужен чтобы повысить пропускную способность, уменьшить задержки и не допускать перегрузки одного сервера
• подходит для систем с высокой нагрузкой, где важна отказоустойчивость и стабильное распределение трафика
• может работать как на уровне приложения L7, так и на транспортном уровне L4

API Gateway управляет, валидирует и контролирует API запросы (про сложность)

Load Balancer равномерно распределяет трафик и оптимизирует нагрузку (про производительность)

👉 @KodBlog
Please open Telegram to view this post
VIEW IN TELEGRAM
👍82😁1
C# 14: Extension Members и как они помогают polyfill-библиотекам

C# давно поддерживает extension-методы, которые позволяют добавлять новые методы к существующим типам, не трогая исходный код. Но раньше это касалось только инстанс-методов. В C# 14 появились extension members, и область расширения заметно выросла. Теперь можно добавлять extension-свойства и extension-статические методы к существующим типам. Такая фича особенно полезна для polyfill-библиотек, которые позволяют использовать новые API в старых версиях .NET.

Polyfill-библиотеки помогают писать код с использованием современных API, но при этом таргетировать разные версии .NET. В предыдущем посте про polyfills я объяснял, что смысл таких библиотек заключается в том, чтобы предоставить реализацию новых API для старых фреймворков и уменьшить количество #if в проекте.

До выхода C# 14 polyfill-библиотеки могли добавлять только инстанс-методы через extension methods. Это означало, что статические методы и свойства из новых версий .NET нормально не покрывались polyfill'ами. C# 14 снимает это ограничение и позволяет polyfill-библиотекам выглядеть куда полнее и нативнее.

Хороший пример это статический метод ArgumentNullException.ThrowIfNull, который появился в .NET 6. Он дает удобный способ проверить аргументы на null:

public void ProcessData(string data)
{
ArgumentNullException.ThrowIfNull(data);

// Process the data...
}


До C# 14 polyfill-библиотеки не могли добавить ThrowIfNull как статический метод ArgumentNullException, потому что extension методы работали только с инстансами. В C# 14 можно определить extension static method, и polyfill-библиотеки могут предоставить идентичный API даже для старых версий .NET. Теперь можно использовать ArgumentNullException.ThrowIfNull независимо от target framework, и код будет выглядеть единообразно и чище.

static class PolyfillExtensions
{
extension(ArgumentNullException)
{
public static void ThrowIfNull([NotNull] object? argument, [CallerArgumentExpression(nameof(argument))] string? paramName = null)
{
if (argument is null)
throw new ArgumentNullException(paramName);
}
}
}


Конечно, можно писать свои extension static-методы и свойства, но проще воспользоваться уже поддерживаемой polyfill-библиотекой.

Пакет Meziantou.Polyfill (GitHub) может покрывать более 350 типов, методов и свойств. Он использует возможности C# 14, чтобы дать максимально полное polyfill-решение. Пакет основан на source generators, которые автоматически добавляют только те polyfills, которые реально нужны под ваш target framework.

Установка: dotnet add package Meziantou.Polyfill

После установки можно использовать новые API вроде ArgumentNullException.ThrowIfNull даже при таргете на старые платформы, вроде .NET Standard 2.0 или .NET Framework:

public class UserService
{
public void CreateUser(string username, string email)
{
// Работает в .NET Standard 2.0 благодаря extension static methods
ArgumentNullException.ThrowIfNull(username);
ArgumentNullException.ThrowIfNull(email);

// Create user logic...
}
}


Source generator автоматически определяет target framework, версию C# и опции компиляции, и генерирует extension members только тогда, когда это возможно и реально требуется. Если вы не используете C# 14 или ваш target framework уже содержит нужный API, polyfill просто не будет создан.

👉 @KodBlog
Please open Telegram to view this post
VIEW IN TELEGRAM
👍53🔥3
На Хабре разобрали YARP — это быстрый reverse proxy на .NET, и его можно удобно конфигурировать прямо в ASP.NET Core через appsettings.json.

Под капотом есть всё, что нужно для продовой нагрузки:

• поддержка HTTP/2 и gRPC
• трансформации путей и заголовков
• балансировка трафика (RoundRobin, PowerOfTwoChoices и другие алгоритмы)
• health-checks и session affinity
• TLS-терминация

В статье есть разбор, примеры конфигурации и демо от OTUS

👉 @KodBlog
Please open Telegram to view this post
VIEW IN TELEGRAM
👍54🔥2
C# expression-bodied члены просто топ.

А теперь к делу. Вот способ использовать enum для сортировки строк, которые сами по себе ничего не значат.

Бывало, что нужно отсортировать данные, пришедшие откуда-то извне, например из веб-API, где нет нормального поля для сортировки? Enum может стать небольшим хаком и избавить от магических строк.

👉 @KodBlog
Please open Telegram to view this post
VIEW IN TELEGRAM
8
В Visual Studio 2026 появилась возможность отключать отображение символов под файлами C# и C++ в Solution Explorer, и эта функция скоро станет доступна.

👉 @KodBlog
Please open Telegram to view this post
VIEW IN TELEGRAM
👍14😁5👎21👏1
Оптимизация производительности создания GUID в .NET приложениях

Во многих .NET проектах GUID используют как идентификаторы для активностей, interop-сценариев, ключей в базах данных и многого другого. Часто можно встретить код вида new Guid("01234567-8901-2345-6789-012345678901"). Такой способ читаемый и простой, но у него есть скрытая цена = он может замедлять старт приложения.

Важно отметить, что это микрооптимизация, которая в основном полезна в случаях, когда GUID создаются во время запуска приложения. Для обычного кода разница практически незаметна (смотри бенчмарк ниже).


Создание GUID из строки требует парсинга строкового представления, а это уже дополнительные операции. Плюс .NET поддерживает несколько форматов строковых GUID (с фигурными скобками, без, с дефисами и т.д.), из-за чего логика парсинга тоже усложняется.

Если GUID зашит в коде, эту нагрузку можно убрать, используя конструктор Guid, который принимает числовые параметры.

Например, строковое создание GUID:

var guid = new Guid("01234567-8901-2345-6789-012345678901");


Можно заменить на:

var guid = new Guid(0x01234567, 0x8901, 0x2345, 0x67, 0x89, 0x01, 0x23, 0x45, 0x67, 0x89, 0x01);


Так GUID собирается напрямую из числовых компонентов, без парсинга строки. Минус в том, что читаемость страдает, и вручную конвертировать строку в numeric-формат легко ошибиться. Плюс такой код сложнее искать в проекте. Решением будет оставлять строковое значение как комментарий.

Автоматическое обнаружение с Meziantou.Analyzer

Чтобы находить такие места автоматически, Meziantou.Analyzer содержит правило MA0176, которое ищет создание GUID из строковых литералов и предлагает использовать numeric-конструктор.

Устанавливается через .NET CLI или добавлением в csproj.

CLI: dotnet add package Meziantou.Analyzer

Или в .csproj:

<PackageReference Include="Meziantou.Analyzer" Version="2.0.224">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>


MA0176 ловит такие случаи и предлагает оптимизацию:

// Строковое создание (будет замечено анализатором)
_ = new Guid("01234567-8901-2345-6789-012345678901");
_ = Guid.Parse("01234567-8901-2345-6789-012345678901");


И автоматически заменяет на:

// Оптимизированный вариант
new Guid(0x01234567, 0x8901, 0x2345, 0x67, 0x89, 0x01, 0x23, 0x45, 0x67, 0x89, 0x01) /* 01234567-8901-2345-6789-012345678901 */;

Бенчмарк

Использовался BenchmarkDotNet для сравнения:

public class NewGuid
{
[Benchmark(Baseline = true)]
public Guid GuidParse() => Guid.Parse("01234567-8901-2345-6789-012345678901");

[Benchmark]
public Guid NewGuidString() => new Guid("01234567-8901-2345-6789-012345678901");

[Benchmark]
public Guid NewGuidComponents() => new Guid(0x01234567, 0x8901, 0x2345, 0x67, 0x89, 0x01, 0x23, 0x45, 0x67, 0x89, 0x01);
}


GuidParse:
Mean 23.4168 ns
Error 0.4823 ns
StdDev 0.4511 ns
Median 23.3622 ns

NewGuidString:
Mean 22.6531 ns
Error 0.3757 ns
StdDev 0.3514 ns
Median 22.7011 ns

NewGuidComponents:
Mean 0.0215 ns
Error 0.0170 ns
StdDev 0.0366 ns
Median 0.0000 ns


Разница огромная (×1000 в пользу числового конструктора), но в большинстве приложений создаётся мало фиксированных GUID, поэтому влияние на общую производительность минимальное. Но при старте приложения, а особенно в сценариях вроде Azure Functions или AWS Lambda, где важны cold start задержки, эта оптимизация может помочь.

👉 @KodBlog
Please open Telegram to view this post
VIEW IN TELEGRAM
🤔65🥴2👍1
Polly остаётся основным выбором для реализации отказоустойчивости в .NET, особенно теперь, когда он быстрее в V8+, и это важно, потому что HTTP-запросы могут падать из-за сетевых или серверных проблем, создавая риск каскадных отказов, а официальная библиотека resilience в .NET всего лишь обёртка над Polly с OpenTelemetry.

Вот как сделать приложение устойчивым: читать

👉 @KodBlog
Please open Telegram to view this post
VIEW IN TELEGRAM
4👍3
Перегрузка операторов в C# 14 выходит на новый уровень и теперь вопрос только в том, стоит ли принимать такие замороченные варианты в кодовой базе, потому что подобные штуки могут взорвать мозг многим разработчикам

👉 @KodBlog
Please open Telegram to view this post
VIEW IN TELEGRAM
🤯21👍53