Пример кода с FormattableString в C#
- Создание из списка с цветом для вывода
- Достаём нужные свойства через extension-методы
- Фильтрация в простом варианте
Читать подробнее
👉 @KodBlog
- Создание из списка с цветом для вывода
- Достаём нужные свойства через extension-методы
- Фильтрация в простом варианте
Читать подробнее
Please open Telegram to view this post
VIEW IN TELEGRAM
Please open Telegram to view this post
VIEW IN TELEGRAM
👎4👍3🍌1
Вы всё ещё вызываете new Random() по каждому поводу? Пора перестать.
В сети часто повторяют: new Random() — это плохо.
Мол, он берёт сид из системных часов, и если создать несколько экземпляров подряд, то значения будут одинаковыми. Типа такой код:
выведет:
А вот этот вариант:
даст что-то вроде:
Откуда ноги растут?
В старом .NET Framework new Random() реально сидировался по DateTime.Now.Ticks, которые обновлялись примерно раз в 16 мс. Если создавать экземпляры чаще, сид совпадал, и результаты были одинаковыми. В .NET Core и дальше это уже не так. Можно прогнать код и убедиться.
А как насчёт потокобезопасности?
Наивный вариант, когда делают один общий генератор:
Выглядит норм, но потокобезопасным это не назовёшь. И проблемы могут всплыть не сразу.
Random при гонках в потоках часто возвращает 0, так что можно посмотреть масштаб проблемы:
В .NET 5 этот пример легко даёт 9000+ нулей, то есть почти всё упирается в проблемы потоков.
В .NET 8+ ситуация сильно лучше, но если задать сид вручную:
то проблемы возвращаются.
Что использовать?
Random.Shared создаёт свой экземпляр на поток (через [ThreadStatic]), поэтому для большинства кейсов на современных .NET это нормальный выбор. Если не нужен строго заданный сид — берите Random.Shared.
new Random() на новых версиях тоже обычно ок, но Shared безопаснее в многопоточке.
Про .NET Framework чуть позже.
👉 @KodBlog
В сети часто повторяют: new Random() — это плохо.
Мол, он берёт сид из системных часов, и если создать несколько экземпляров подряд, то значения будут одинаковыми. Типа такой код:
for (var i = 0; i < 5; i++)
{
var random = new Random();
Console.WriteLine(random.Next(1, 100));
}
выведет:
42 42 42 42 42
А вот этот вариант:
for (var i = 0; i < 5; i++)
Console.WriteLine(Random.Shared.Next(1, 100));
даст что-то вроде:
17 82 19 23 5
Откуда ноги растут?
В старом .NET Framework new Random() реально сидировался по DateTime.Now.Ticks, которые обновлялись примерно раз в 16 мс. Если создавать экземпляры чаще, сид совпадал, и результаты были одинаковыми. В .NET Core и дальше это уже не так. Можно прогнать код и убедиться.
А как насчёт потокобезопасности?
Наивный вариант, когда делают один общий генератор:
Random rng = new();
Parallel.For(0, 10, x =>
{
var value = rng.Next();
Console.WriteLine(value);
});
Выглядит норм, но потокобезопасным это не назовёшь. И проблемы могут всплыть не сразу.
Random при гонках в потоках часто возвращает 0, так что можно посмотреть масштаб проблемы:
Random rng = new();
Parallel.For(0, 10, x =>
{
var nums = new int[10_000];
for (int i = 0; i < nums.Length; ++i)
nums[i] = rng.Next();
Console.WriteLine($"{nums.Count(x => x == 0)} нулей");
});
В .NET 5 этот пример легко даёт 9000+ нулей, то есть почти всё упирается в проблемы потоков.
В .NET 8+ ситуация сильно лучше, но если задать сид вручную:
Random rng = new(123);
то проблемы возвращаются.
Что использовать?
Random.Shared создаёт свой экземпляр на поток (через [ThreadStatic]), поэтому для большинства кейсов на современных .NET это нормальный выбор. Если не нужен строго заданный сид — берите Random.Shared.
new Random() на новых версиях тоже обычно ок, но Shared безопаснее в многопоточке.
Про .NET Framework чуть позже.
Please open Telegram to view this post
VIEW IN TELEGRAM
❤16
Please open Telegram to view this post
VIEW IN TELEGRAM
👍7
Проблема с Random в .NET Framework
У Random в .NET Framework по сути две основные болячки:
При new Random() в .NET Framework сид берется от системных часов.
У системных часов ограниченная точность (разрешение).
Из-за этого при быстром, плотном создании new Random() можно легко получить два (или больше) инстанса Random с одинаковым seed. А значит, они выдадут одну и ту же последовательность чисел:
В результате часть чисел (а иногда и вообще все) могут совпасть, что нам обычно вообще не нужно.
Решение
Идея простая: держим один общий Random, который используется только чтобы раздавать seed’ы для остальных Random. Раз сиды разные, то и последовательности у локальных Random не будут коррелировать, и проблема с одинаковыми значениями уйдет.
В .NET Core / .NET это как раз поведение “из коробки”, поэтому баг проявляется именно в .NET Framework.
Ниже пример обертки ThreadSafeRandom. Она использует [ThreadStatic] и аккуратно обходит проблему инициализации в .NET Framework:
Эту потокобезопасную реализацию можно юзать на любых версиях фреймворка:
Если вы на .NET 6+, лучше использовать встроенный Random.Shared. Для более ранних версий подойдет ThreadSafeRandom выше. А если таргетитесь и в .NET 6+, и в .NET Framework, можно развести реализации через директивы #if под разные target framework.
👉 @KodBlog
У Random в .NET Framework по сути две основные болячки:
При new Random() в .NET Framework сид берется от системных часов.
У системных часов ограниченная точность (разрешение).
Из-за этого при быстром, плотном создании new Random() можно легко получить два (или больше) инстанса Random с одинаковым seed. А значит, они выдадут одну и ту же последовательность чисел:
// <TargetFramework>net48</TargetFramework>
Parallel.For(0, 10, x =>
{
Random rnd = new();
var value = rnd.Next();
Console.WriteLine(value);
});
В результате часть чисел (а иногда и вообще все) могут совпасть, что нам обычно вообще не нужно.
Решение
Идея простая: держим один общий Random, который используется только чтобы раздавать seed’ы для остальных Random. Раз сиды разные, то и последовательности у локальных Random не будут коррелировать, и проблема с одинаковыми значениями уйдет.
В .NET Core / .NET это как раз поведение “из коробки”, поэтому баг проявляется именно в .NET Framework.
Ниже пример обертки ThreadSafeRandom. Она использует [ThreadStatic] и аккуратно обходит проблему инициализации в .NET Framework:
internal static class ThreadSafeRandom
{
[ThreadStatic]
private static Random? _local;
private static readonly
Random Global = new Random();
private static Random Instance
{
get
{
if (_local is null)
{
int seed;
// избегаем конкурентного доступа
lock (Global)
{
seed = Global.Next();
}
_local = new Random(seed);
}
return _local;
}
}
public static int Next() => Instance.Next();
}
Эту потокобезопасную реализацию можно юзать на любых версиях фреймворка:
Parallel.For(0, 10, x =>
{
var value = ThreadSafeRandom.Next();
Console.WriteLine(value);
});
Если вы на .NET 6+, лучше использовать встроенный Random.Shared. Для более ранних версий подойдет ThreadSafeRandom выше. А если таргетитесь и в .NET 6+, и в .NET Framework, можно развести реализации через директивы #if под разные target framework.
Please open Telegram to view this post
VIEW IN TELEGRAM
❤9👍2
Оптимизация памяти в C# (и немного в Unity): эффективные методы и стратегии
Язык программирования C#, несмотря на то, что обеспечивает автоматическое управление памятью с помощью механизма сборки мусора (GC), требует от разработчиков специальных знаний и навыков для оптимизации работы с памятью
Итак, давайте рассмотрим различные стратегии и методы оптимизации памяти в C#, которые помогают создавать эффективные и быстрые приложения.
Читать статью...
👉 @KodBlog
Язык программирования C#, несмотря на то, что обеспечивает автоматическое управление памятью с помощью механизма сборки мусора (GC), требует от разработчиков специальных знаний и навыков для оптимизации работы с памятью
Итак, давайте рассмотрим различные стратегии и методы оптимизации памяти в C#, которые помогают создавать эффективные и быстрые приложения.
Читать статью...
Please open Telegram to view this post
VIEW IN TELEGRAM
👍8👎7
Библиотеки, которые реально используют в Microsoft.
Надоело, что на каждом углу советуют AutoMapper и Polly? Вот инструменты, которые инженеры Microsoft юзают в продакшене. Они прошли боевые условия, экономят память и закрывают задачи, которые масштабируются до уровня Bing и Azure.
1. Microsoft.IO.RecyclableMemoryStream
Проблема:
Когда в API гоняется большой трафик или крутятся экспортные задания, обычный new MemoryStream() вызывает фрагментацию LOH (Large Object Heap) и частые остановки GC. В проде типа Bing это легко выливается в
OutOfMemoryException.
Решение:
RecyclableMemoryStream:
- переиспользует буферы через пул,
- снижает аллокации и не лезет в LOH,
- даёт нормальную диагностику по памяти.
Где уместно:
ASP.NET API, которые возвращают PDF, изображения, Excel,
- фоновые задачи, работающие с файлами,
- бэкенды под gRPC и SignalR.
До:
После:
Бенч:
2. LoggerMessage (генератор кода)
Проблема:
В сервисах с высоким трафиком даже такие безобидные логи:
создают проблемы:
- лишние аллокации из-за интерполяции строк,
- боксинг значимых типов,
- нагруженный GC и просадка пропускной способности.
На масштабе Azure это превращается в задержки и прожорливость по памяти.
Решение:
LoggerMessage из .NET 8 генерирует методы на этапе компиляции:
- без интерполяции и упаковки типов,
- со структурированным форматированием через Span<T>,
- с IL без лишних аллокаций,
в 5–10 раз быстрее обычного ILogger.LogInformation(...).
Где уместно:
- нагруженные веб-API,
- воркфлоу/ивент-хендлеры, где логов много,
- телеметрия и критичные hot-path’ы.
До (аллокации):
После (генерация):
Можно вынести такие методы в статические хелперы и шарить между сервисами.
👉 @KodBlog
Надоело, что на каждом углу советуют AutoMapper и Polly? Вот инструменты, которые инженеры Microsoft юзают в продакшене. Они прошли боевые условия, экономят память и закрывают задачи, которые масштабируются до уровня Bing и Azure.
1. Microsoft.IO.RecyclableMemoryStream
Проблема:
Когда в API гоняется большой трафик или крутятся экспортные задания, обычный new MemoryStream() вызывает фрагментацию LOH (Large Object Heap) и частые остановки GC. В проде типа Bing это легко выливается в
OutOfMemoryException.
Решение:
RecyclableMemoryStream:
- переиспользует буферы через пул,
- снижает аллокации и не лезет в LOH,
- даёт нормальную диагностику по памяти.
Где уместно:
ASP.NET API, которые возвращают PDF, изображения, Excel,
- фоновые задачи, работающие с файлами,
- бэкенды под gRPC и SignalR.
До:
using var ms = new MemoryStream();
После:
var mgr = new RecyclableMemoryStreamManager();
using var ms = mgr.GetStream();
Бенч:
Method | Memory | Gen 0 | Gen 2
---------------- | -------| ----- | -----
MemoryStream | 85 KB | 1 | 1
RecyclableMemory | 2 KB | 0 | 0
2. LoggerMessage (генератор кода)
Проблема:
В сервисах с высоким трафиком даже такие безобидные логи:
_logger.LogInformation($"Обработка заказа {orderId}");создают проблемы:
- лишние аллокации из-за интерполяции строк,
- боксинг значимых типов,
- нагруженный GC и просадка пропускной способности.
На масштабе Azure это превращается в задержки и прожорливость по памяти.
Решение:
LoggerMessage из .NET 8 генерирует методы на этапе компиляции:
- без интерполяции и упаковки типов,
- со структурированным форматированием через Span<T>,
- с IL без лишних аллокаций,
в 5–10 раз быстрее обычного ILogger.LogInformation(...).
Где уместно:
- нагруженные веб-API,
- воркфлоу/ивент-хендлеры, где логов много,
- телеметрия и критичные hot-path’ы.
До (аллокации):
_logger.LogInformation($"Обработка запроса {orderId}");После (генерация):
[LoggerMessage(EventId = 100, Level = LogLevel.Information, Message = "Обработка запроса {OrderId}")]
partial void LogProcessingOrder(int orderId);
// вызов
LogProcessingOrder(orderId);Можно вынести такие методы в статические хелперы и шарить между сервисами.
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥22❤6👍3👎1🤣1
C# Portal | Программирование
Библиотеки, которые реально используют в Microsoft. Надоело, что на каждом углу советуют AutoMapper и Polly? Вот инструменты, которые инженеры Microsoft юзают в продакшене. Они прошли боевые условия, экономят память и закрывают задачи, которые масштабируются…
Ещё стоит упомянуть:
3. Microsoft.SemanticKernel
Проблема: Обычных запросов и ответов уже не хватает для продакшн-приложений на ИИ. Нужны память между диалогами, разбиение целей на шаги и доступ к API вроде Microsoft 365 Copilot. Реализовать всё это вручную в .NET — тяжело и долго.
Решение:
SemanticKernel — официальный SDK от Microsoft для сборки похожих на агентов ИИ-процессов. Он даёт:
* Вызов инструментов и плагинов (C# или OpenAPI);
* Оркестрацию цепочек запросов;
* Долговременную память между вызовами;
* Планировщик для разбиения задач на подзадачи.
Когда стоит брать:
* Корпоративные ИИ-ассистенты (CRM, генерация отчётов и т.п.);
* ИИ-процессы, которые ходят во внутренние API/функции;
* Многошаговые чат-боты с планированием и памятью.
До (одиночный запрос):
После (оркестрация через SemanticKernel):
Можно регистрировать плагины, которые вызывают API, делают авторизацию, исполняют SQL или дергают другие сервисы — и всё из .NET. Документация
4. System.Threading.Channels + IAsyncEnumerable
Проблема: BlockingCollection, ConcurrentQueue и кастомные очереди в паттерне «производитель/потребитель» часто упираются в блокировки, нестабильность и проблемы под нагрузкой. Особенно в телеметрии и потоковых пайплайнах.
Решение:
* Быстрый обмен сообщениями внутри процесса;
* Контроль пропускной способности через ограниченные каналы;
* Простую связку с IAsyncEnumerable<T>;
* Асинхронный стриминг без лишних выделений памяти.
Изначально жило внутри SignalR и gRPC, сейчас это нормальный способ собирать отказоустойчивые асинхронные конвейеры в .NET.
Когда использовать:
* Сбор телеметрии и обработка метрик;
* Асинхронные пайплайны логов/событий;
* Очередеподобное поведение без внешней брокерской инфраструктуры;
* Изоляция перегородками (Bulkhead) в фоновых сервисах.
До (BlockingCollection или Queue<T>):
После (каналы и async-потоки):
Отлично работает из коробки с await foreach и подходит для фоновых задач с I/O, стриминговых API и повторяющихся пайплайнов.
👉 @KodBlog
3. Microsoft.SemanticKernel
Проблема: Обычных запросов и ответов уже не хватает для продакшн-приложений на ИИ. Нужны память между диалогами, разбиение целей на шаги и доступ к API вроде Microsoft 365 Copilot. Реализовать всё это вручную в .NET — тяжело и долго.
Решение:
SemanticKernel — официальный SDK от Microsoft для сборки похожих на агентов ИИ-процессов. Он даёт:
* Вызов инструментов и плагинов (C# или OpenAPI);
* Оркестрацию цепочек запросов;
* Долговременную память между вызовами;
* Планировщик для разбиения задач на подзадачи.
Когда стоит брать:
* Корпоративные ИИ-ассистенты (CRM, генерация отчётов и т.п.);
* ИИ-процессы, которые ходят во внутренние API/функции;
* Многошаговые чат-боты с планированием и памятью.
До (одиночный запрос):
var result = await openAiClient
.GetCompletionAsync("Какая погода в Москве?");
После (оркестрация через SemanticKernel):
var kernel = Kernel.CreateBuilder().Build();
var prompt = kernel
.CreateFunctionFromPrompt("Какая погода в Москве?");
var result = await prompt.InvokeAsync();
Можно регистрировать плагины, которые вызывают API, делают авторизацию, исполняют SQL или дергают другие сервисы — и всё из .NET. Документация
4. System.Threading.Channels + IAsyncEnumerable
Проблема: BlockingCollection, ConcurrentQueue и кастомные очереди в паттерне «производитель/потребитель» часто упираются в блокировки, нестабильность и проблемы под нагрузкой. Особенно в телеметрии и потоковых пайплайнах.
Решение:
System.Threading.Channels даёт:* Быстрый обмен сообщениями внутри процесса;
* Контроль пропускной способности через ограниченные каналы;
* Простую связку с IAsyncEnumerable<T>;
* Асинхронный стриминг без лишних выделений памяти.
Изначально жило внутри SignalR и gRPC, сейчас это нормальный способ собирать отказоустойчивые асинхронные конвейеры в .NET.
Когда использовать:
* Сбор телеметрии и обработка метрик;
* Асинхронные пайплайны логов/событий;
* Очередеподобное поведение без внешней брокерской инфраструктуры;
* Изоляция перегородками (Bulkhead) в фоновых сервисах.
До (BlockingCollection или Queue<T>):
var queue = new Queue<int>();
lock (queue) { queue.Enqueue(42); }
int item;
lock (queue) { item = queue.Dequeue(); }
После (каналы и async-потоки):
var ch = Channel.CreateUnbounded<int>();
await ch.Writer.WriteAsync(42);
await foreach (var item in ch.Reader.ReadAllAsync())
Console.WriteLine(item);
Отлично работает из коробки с await foreach и подходит для фоновых задач с I/O, стриминговых API и повторяющихся пайплайнов.
Please open Telegram to view this post
VIEW IN TELEGRAM
❤7👍3
Может кому понадобится: проект генерирует архитектурные диаграммы из кода.
https://github.com/likec4/likec4/🤨
👉 @KodBlog
https://github.com/likec4/likec4/
Please open Telegram to view this post
VIEW IN TELEGRAM
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥10🤣2❤1
Как компилятор выводит тип из
Разберём простые варианты:
Тут всё понятно. Но что происходит в
Кажется логичным ожидать, что
Для этого конкретного примера разницы нет — и
Тип
Конструктор
В итоге
Если же явно указать тип в
То результатом
👉 @KodBlog
default?default в C# штука мощная. Для ссылочных типов он даёт null, для значимых — нулевое значение. Забавно, что default(T) и new T() для структур могут вести себя по-разному. Раньше в C# нужно было писать default(T), а сейчас, если тип очевиден для компилятора, можно оставить просто default. Но как именно компилятор понимает, что за тип там должен быть? И всегда ли можно доверять этому выводу?Разберём простые варианты:
// Тип тянется из левой части
int foo = default;
// Тип тянется из параметра
Foo(default);
void Foo(int bar) => throw null;
Тут всё понятно. Но что происходит в
switch-выражении?var sample = new byte[0];
ReadOnlyMemory<byte> foo = sample switch
{
byte[] value => value,
_ => default
};
Кажется логичным ожидать, что
default станет default(ReadOnlyMemory<byte>), ведь слева стоит такой тип. Но нет — фактически это будет default(byte[]). Компилятор берёт тип не из левой части, а из веток switch и пытается вывести общий тип. В данном случае общий тип — byte[], значит и default станет byte[].Для этого конкретного примера разницы нет — и
default(byte[]), и default(ReadOnlyMemory<byte>) в итоге дадут совместимый результат. Но последствия могут всплыть позже. Например, если заменить тип на nullable:var sample = new object();
ReadOnlyMemory<byte>? foo = sample switch
{
byte[] value => value,
_ => default
};
Тип
default снова выводится как default(byte[]), то есть null. А дальше начинает работать неявное преобразование:// из исходников .NET 9
public static implicit operator ReadOnlyMemory<T>(T[]? array)
=> new ReadOnlyMemory<T>(array);
Конструктор
ReadOnlyMemory<T>, который принимает T[]?, при null выдаёт пустое значение:// из исходников .NET 9
public ReadOnlyMemory(T[]? array)
{
if (array == null)
{
this = default;
return;
}
…
}
В итоге
foo окажется пустым ReadOnlyMemory<byte> — не null. То есть foo.HasValue будет true.Если же явно указать тип в
default:object sample = new();
ReadOnlyMemory<byte>? foo = sample switch
{
byte[] value => value,
_ => default(ReadOnlyMemory<byte>?)
};
То результатом
switch будет именно null, и foo.HasValue станет false.Please open Telegram to view this post
VIEW IN TELEGRAM
⚡6❤2
Самые частые способы уменьшить размер Docker-образа
Сохраните себе — это реально частый вопрос на собесах.
• Использовать минимальную базу
Брать
• Multi-stage сборка
Собрать всё тяжёлое и зависимости в одном этапе, а в финальный образ скопировать только готовые артефакты.
• Меньше слоёв
Группировать команды в один
• Убирать зависимости для сборки
Компиляторы, менеджеры пакетов и прочий мусор — только на build-стейдже, не в рантайме.
• Чистить кеш пакетного менеджера
Чистить
• .dockerignore
Не тащить лишнее в образ:
• Не копировать всё подряд
Вместо
• Меньше и легче рантайм
Статические бинарники, минимальные рантаймы, без лишних зависимостей.
• Чистить бинарники и ресурсы
Убирать отладочную информацию, неиспользуемые ассеты и т.п.
• Настраивать права сразу
Ставить
👉 @KodBlog
Сохраните себе — это реально частый вопрос на собесах.
• Использовать минимальную базу
Брать
alpine, distroless или slim, а не полноценные образы с целой ОС.• Multi-stage сборка
Собрать всё тяжёлое и зависимости в одном этапе, а в финальный образ скопировать только готовые артефакты.
• Меньше слоёв
Группировать команды в один
RUN, когда это уместно, и не плодить лишние слои.• Убирать зависимости для сборки
Компиляторы, менеджеры пакетов и прочий мусор — только на build-стейдже, не в рантайме.
• Чистить кеш пакетного менеджера
Чистить
apt, yum, apk и прочее в том же слое, где ставятся пакеты.• .dockerignore
Не тащить лишнее в образ:
.git, тесты, доки, локальные конфиги и т.д.• Не копировать всё подряд
Вместо
COPY . . копировать только то, что реально нужно для приложения.• Меньше и легче рантайм
Статические бинарники, минимальные рантаймы, без лишних зависимостей.
• Чистить бинарники и ресурсы
Убирать отладочную информацию, неиспользуемые ассеты и т.п.
• Настраивать права сразу
Ставить
chmod/chown рано, чтобы не плодить лишние слои с изменёнными разрешениями.Please open Telegram to view this post
VIEW IN TELEGRAM
🔥6❤3👍1
Уходим от анемичных моделей. Пример DDD-рефакторинга.
Если работали с древней кодовой базой на C#, то наверняка видели анемичную доменную модель. Открываешь какой-нибудь OrderService и ловишь фейспалм: в одном классе и ценообразование, и скидки, и проверка склада, и работа с БД. Оно вроде крутится, пока продукт маленький, но дальше каждая новая фича превращается в рулетку с регрессиями. Тесты тоже летят к черту, потому что доменная логика спрятана под инфраструктурой.
Классика анемичного домена: сущности как DTO с геттерами и сеттерами, а вся логика в сервисах. Понимать систему тяжело, любое изменение наугад, а поведение расползлось по слоям.
В этой серии мы:
* посмотрим на типичную анемичную реализацию
* найдем скрытые бизнес-правила, которые делают её хрупкой
* шаг за шагом переложим ответственность в агрегат и дадим ему нормальное поведение
* разберем выгоды, чтобы можно было аргументированно защитить изменения в команде
Старт. Божественный OrderService
Ниже довольно жизненный пример. Помимо подсчета итоговой суммы он:
* навешивает VIP-скидку 5 процентов
* падает, если товара нет на складе
* отклоняет заказ, если клиент вышел за кредитный лимит
Что здесь рыхлое
1. Правила размазаны по сервису. Скидки, склад, кредит — всё в кучу. Чтобы понять бизнес-логику, нужно разбирать инфраструктурный код.
2. Жесткая связка. OrderService тащит в себя знание о ценах, остатках и EF Core, хотя цель — просто разместить заказ.
3. Больно тестировать. Для юнит-теста нужно мокать БД, ценообразование, склад и сценарии для VIP и не VIP. Тесты получаются хрупкие и громоздкие.
Наша цель — втащить правила в домен, чтобы приложение занималось оркестрацией, а не бизнес-логикой.
Принципы рефакторинга
1. Инварианты держим рядом с данными. Проверки склада, скидки, кредит — в агрегате Order, а не в сервисе сверху.
2. Слой приложения читался как сценарий. Хочется "разместить заказ", а не "подсчитать сумму, проверить лимит, сохранить в БД".
3. Рефакторим кусками. Маленькие шаги, компилируемые на каждом этапе. Никаких переписываний с нуля.
4. Баланс чистоты и практичности. Меняем только там, где выигрыш в читабельности, безопасности и тестируемости действительно окупает вложения.
👉 @KodBlog
Если работали с древней кодовой базой на C#, то наверняка видели анемичную доменную модель. Открываешь какой-нибудь OrderService и ловишь фейспалм: в одном классе и ценообразование, и скидки, и проверка склада, и работа с БД. Оно вроде крутится, пока продукт маленький, но дальше каждая новая фича превращается в рулетку с регрессиями. Тесты тоже летят к черту, потому что доменная логика спрятана под инфраструктурой.
Классика анемичного домена: сущности как DTO с геттерами и сеттерами, а вся логика в сервисах. Понимать систему тяжело, любое изменение наугад, а поведение расползлось по слоям.
В этой серии мы:
* посмотрим на типичную анемичную реализацию
* найдем скрытые бизнес-правила, которые делают её хрупкой
* шаг за шагом переложим ответственность в агрегат и дадим ему нормальное поведение
* разберем выгоды, чтобы можно было аргументированно защитить изменения в команде
Старт. Божественный OrderService
Ниже довольно жизненный пример. Помимо подсчета итоговой суммы он:
* навешивает VIP-скидку 5 процентов
* падает, если товара нет на складе
* отклоняет заказ, если клиент вышел за кредитный лимит
// OrderService.cs
public void PlaceOrder(
Guid customerId,
IEnumerable<OrderItemDto> items)
{
var customer = _db.Customers.Find(customerId);
if (customer is null)
throw new ArgumentException("Клиент не найден");
var order = new Order { CustomerId = customerId };
foreach (var dto in items)
{
var inventory = _invService.GetStock(dto.ProductId);
if (inventory < dto.Quantity)
throw new InvalidOperationException("Товара недостаточно");
var price = _pricingService.GetPrice(dto.ProductId);
var lineTotal = price * dto.Quantity;
if (customer.IsVip)
lineTotal *= 0.95m;
order.Items.Add(new OrderItem
{
ProductId = dto.ProductId,
Quantity = dto.Quantity,
UnitPrice = price,
LineTotal = lineTotal
});
}
order.Total = order.Items.Sum(i => i.LineTotal);
if (customer.CreditUsed + order.Total > customer.CreditLimit)
throw new InvalidOperationException("Кредитный лимит превышен");
_db.Orders.Add(order);
_db.SaveChanges();
}
Что здесь рыхлое
1. Правила размазаны по сервису. Скидки, склад, кредит — всё в кучу. Чтобы понять бизнес-логику, нужно разбирать инфраструктурный код.
2. Жесткая связка. OrderService тащит в себя знание о ценах, остатках и EF Core, хотя цель — просто разместить заказ.
3. Больно тестировать. Для юнит-теста нужно мокать БД, ценообразование, склад и сценарии для VIP и не VIP. Тесты получаются хрупкие и громоздкие.
Наша цель — втащить правила в домен, чтобы приложение занималось оркестрацией, а не бизнес-логикой.
Принципы рефакторинга
1. Инварианты держим рядом с данными. Проверки склада, скидки, кредит — в агрегате Order, а не в сервисе сверху.
2. Слой приложения читался как сценарий. Хочется "разместить заказ", а не "подсчитать сумму, проверить лимит, сохранить в БД".
3. Рефакторим кусками. Маленькие шаги, компилируемые на каждом этапе. Никаких переписываний с нуля.
4. Баланс чистоты и практичности. Меняем только там, где выигрыш в читабельности, безопасности и тестируемости действительно окупает вложения.
Please open Telegram to view this post
VIEW IN TELEGRAM
❤12
C# Portal | Программирование
Уходим от анемичных моделей. Пример DDD-рефакторинга. Если работали с древней кодовой базой на C#, то наверняка видели анемичную доменную модель. Открываешь какой-нибудь OrderService и ловишь фейспалм: в одном классе и ценообразование, и скидки, и проверка…
Пример DDD-рефакторинга.
Пошаговый рефакторинг
Цель здесь не в догонялках за академической чистотой или «идеальным» DDD. Идея в том, чтобы постепенно усилить связность и дать домену пространство для нормального выражения своей логики. На каждом шаге задаём простой вопрос: это поведение должно жить в домене? Если да — переносим его туда.
Внедрение логики создания и проверки
Первый шаг — заставить агрегат сам собирать себя. Статический метод Create становится единой точкой входа, где инварианты сразу проверяются по принципу fail fast.
Проверка остатков прямо в Order делает код тестируемее, но жёстко привязывает поток создания заказа к доступности товара. Не всегда это хорошо: в некоторых доменах правильнее оформлять это через доменное событие и проверять асинхронно.
Зачем это всё?
Теперь заказ сразу падает с ошибкой, если нарушен какой-то инвариант. Сервис больше не занимается микроменеджментом скидок или склада — всё это живёт внутри домена.
Обратите внимание, как мы теперь следуем принципу «Скажи, не спрашивай». Вместо того, чтобы сервис проверял условия и затем манипулировал заказом, мы говорим заказу создать себя с необходимыми встроенными проверками. Это фундаментальный сдвиг в сторону инкапсуляции.
Замечание о передаче сервисов в методы домена
Передача зависимостей вроде IPricingService или IInventoryService в доменный метод Order.Create может выглядеть нестандартно, но это осмысленное решение. Так мы оставляем оркестрацию в домене, а не превращаем app-сервис в procedural god-object.
Зависимости передаются явно, без скрытых контейнеров внутри сущности. Подход пушка, но применять его стоит там, где операция реально принадлежит домену и выигрывает от прямого доступа к сервисам.
👉 @KodBlog
Пошаговый рефакторинг
Цель здесь не в догонялках за академической чистотой или «идеальным» DDD. Идея в том, чтобы постепенно усилить связность и дать домену пространство для нормального выражения своей логики. На каждом шаге задаём простой вопрос: это поведение должно жить в домене? Если да — переносим его туда.
Внедрение логики создания и проверки
Первый шаг — заставить агрегат сам собирать себя. Статический метод Create становится единой точкой входа, где инварианты сразу проверяются по принципу fail fast.
Проверка остатков прямо в Order делает код тестируемее, но жёстко привязывает поток создания заказа к доступности товара. Не всегда это хорошо: в некоторых доменах правильнее оформлять это через доменное событие и проверять асинхронно.
// Order.cs (фабричный метод)
public static Order Create(
Customer customer,
IEnumerable<(Guid productId, int quantity)> lines,
IPricingService pricingService,
IInventoryService invService)
{
var order = new Order(customer.Id);
foreach (var (id, quantity) in lines)
{
if (invService.GetStock(id) < quantity)
throw new InvalidOperationException("Товара недостаточно");
var price = pricingService.GetPrice(id);
order.AddItem(id, quantity, price, customer.IsVip);
}
order.EnsureCreditWithinLimit(customer);
return order;
}
Зачем это всё?
Теперь заказ сразу падает с ошибкой, если нарушен какой-то инвариант. Сервис больше не занимается микроменеджментом скидок или склада — всё это живёт внутри домена.
Обратите внимание, как мы теперь следуем принципу «Скажи, не спрашивай». Вместо того, чтобы сервис проверял условия и затем манипулировал заказом, мы говорим заказу создать себя с необходимыми встроенными проверками. Это фундаментальный сдвиг в сторону инкапсуляции.
Замечание о передаче сервисов в методы домена
Передача зависимостей вроде IPricingService или IInventoryService в доменный метод Order.Create может выглядеть нестандартно, но это осмысленное решение. Так мы оставляем оркестрацию в домене, а не превращаем app-сервис в procedural god-object.
Зависимости передаются явно, без скрытых контейнеров внутри сущности. Подход пушка, но применять его стоит там, где операция реально принадлежит домену и выигрывает от прямого доступа к сервисам.
Please open Telegram to view this post
VIEW IN TELEGRAM
❤8👍3🤔1
Принёс вам классный ресурс для изучения паттернов проектирования
На сайте Refactoring Guru собрали примеры для каждого паттерна на таких языках, как C#, Java, Python, PHP, Rust и ещё куча других.
Примеры суперпонятные, с кодом и пояснениями.
👉 @KodBlog
На сайте Refactoring Guru собрали примеры для каждого паттерна на таких языках, как C#, Java, Python, PHP, Rust и ещё куча других.
Примеры суперпонятные, с кодом и пояснениями.
Please open Telegram to view this post
VIEW IN TELEGRAM
👍11
Тестирование текущего времени с помощью TimeProvider и FakeTimeProvider
В тестах тяжело работать с вещами, которые зависят от конкретных данных. Представьте файловую систему: чтобы тесты корректно отрабатывали, нужно гарантировать, что структура файлов именно такая, как ожидается. С датами и временем та же проблема. Если вы пишете тесты, завязанные на текущую дату, при следующем запуске они просто упадут. Поэтому такие зависимости нужно абстрагировать, чтобы их можно было нормально тестировать.
В этом посте разберем класс TimeProvider, как его использовать и как подменять в тестах.
Раньше: ручной интерфейс
Раньше самый простой способ абстрагироваться от работы с датой и временем был в том, чтобы вручную завести интерфейс или абстрактный класс для доступа к текущему времени:
И его стандартную реализацию, которая возвращает время в UTC:
Либо похожий вариант через абстрактный класс:
Дальше этот класс просто регистрировался в DI-контейнере, и можно было использовать. Минус очевидный: в каждом проекте приходилось писать одно и то же.
Сейчас: класс TimeProvider
Начиная с .NET 8, команда .NET добавила абстрактный класс TimeProvider. Он не только даёт абстракцию над локальным временем, но и предоставляет методы для работы с высокоточными временными метками и часовыми поясами.
Важно: даты возвращаются в виде DateTimeOffset, а не DateTime.
TimeProvider доступен из коробки в консольных приложениях .NET:
Если вы используете dependency injection, его достаточно зарегистрировать как синглтон:
Пример использования:
Тестирование TimeProvider
Для тестов можно использовать NuGet-пакет
С помощью FakeTimeProvider можно вручную задавать текущее UTC-время, локальное время и другие параметры, которые предоставляет TimeProvider:
На самом деле TimeProvider умеет намного больше, чем просто возвращать UTC и локальное время. Подробности можно посмотреть в официальной документации
👉 @KodBlog
В тестах тяжело работать с вещами, которые зависят от конкретных данных. Представьте файловую систему: чтобы тесты корректно отрабатывали, нужно гарантировать, что структура файлов именно такая, как ожидается. С датами и временем та же проблема. Если вы пишете тесты, завязанные на текущую дату, при следующем запуске они просто упадут. Поэтому такие зависимости нужно абстрагировать, чтобы их можно было нормально тестировать.
В этом посте разберем класс TimeProvider, как его использовать и как подменять в тестах.
Раньше: ручной интерфейс
Раньше самый простой способ абстрагироваться от работы с датой и временем был в том, чтобы вручную завести интерфейс или абстрактный класс для доступа к текущему времени:
public interface IDateTimeWrapper
{
DateTime GetCurrentDate();
}
И его стандартную реализацию, которая возвращает время в UTC:
public class DateTimeWrapper : IDateTimeWrapper
{
public DateTime GetCurrentDate()
=> DateTime.UtcNow;
}
Либо похожий вариант через абстрактный класс:
public abstract class DateTimeWrapper
{
public virtual DateTime GetCurrentDate() => DateTime.UctNow;
}
Дальше этот класс просто регистрировался в DI-контейнере, и можно было использовать. Минус очевидный: в каждом проекте приходилось писать одно и то же.
Сейчас: класс TimeProvider
Начиная с .NET 8, команда .NET добавила абстрактный класс TimeProvider. Он не только даёт абстракцию над локальным временем, но и предоставляет методы для работы с высокоточными временными метками и часовыми поясами.
Важно: даты возвращаются в виде DateTimeOffset, а не DateTime.
TimeProvider доступен из коробки в консольных приложениях .NET:
DateTimeOffset utc = TimeProvider.System.GetUtcNow();
Console.WriteLine(utc);
DateTimeOffset local = TimeProvider.System.GetLocalNow();
Console.WriteLine(local);
Если вы используете dependency injection, его достаточно зарегистрировать как синглтон:
builder.Services.AddSingleton(TimeProvider.System);
Пример использования:
public class Vacation(TimeProvider _time)
{
public bool IsVacation
=> _time.GetLocalNow().Month == 8;
}
Тестирование TimeProvider
Для тестов можно использовать NuGet-пакет
Microsoft.Extensions.TimeProvider.Testing, в котором есть класс FakeTimeProvider. Это заглушка для абстрактного TimeProvider.С помощью FakeTimeProvider можно вручную задавать текущее UTC-время, локальное время и другие параметры, которые предоставляет TimeProvider:
[Fact]
public void WhenItsAugust_ShouldReturnTrue()
{
// Arrange
var fakeTime = new FakeTimeProvider();
fakeTime.SetUtcNow(
new DateTimeOffset(2025, 8, 14,
22, 24, 12, TimeSpan.Zero));
var sut = new Vacation(fakeTime);
Assert.True(sut.IsVacation);
}
На самом деле TimeProvider умеет намного больше, чем просто возвращать UTC и локальное время. Подробности можно посмотреть в официальной документации
Please open Telegram to view this post
VIEW IN TELEGRAM
👍9❤3
Настоятельно рекомендую фри PDF-книгу:
Algorithms от Jeff Erickson — с кучей наглядных иллюстраций и упражнений.
👉 @KodBlog
Algorithms от Jeff Erickson — с кучей наглядных иллюстраций и упражнений.
Please open Telegram to view this post
VIEW IN TELEGRAM
👍8😁1
Создание параметризованных тестов в xUnit
xUnit использует атрибуты для объявления тестовых методов.
Атрибут Fact описывает обычный тест, а Theory — параметризованный.
Допустим, у нас есть тест, который проверяет, что email-парсер корректно извлекает домен:
Рассмотрим четыре способа написания параметризованных тестов.
1. InlineData
Самый простой вариант — передать тестовые данные напрямую через атрибут:
В
Минус в том, что при большом количестве кейсов код быстро разрастается. Плюс можно использовать только константные значения параметров.
2. MemberData
В атрибуте
3. ClassData
Тестовые данные загружаются из класса, который реализует
Подход более громоздкий — нужно вручную реализовывать
4. TheoryData
Пример с использованием
Для добавления одного тестового кейса вызывается
👉 @KodBlog
xUnit использует атрибуты для объявления тестовых методов.
Атрибут Fact описывает обычный тест, а Theory — параметризованный.
Допустим, у нас есть тест, который проверяет, что email-парсер корректно извлекает домен:
public void Should_Return_Domain(
string email, string expectedDomain)
{
var parser = new EmailParser();
var domain = parser.GetDomain(email);
Assert.Equal(domain, expectedDomain);
}
Рассмотрим четыре способа написания параметризованных тестов.
1. InlineData
Самый простой вариант — передать тестовые данные напрямую через атрибут:
[Theory]
[InlineData("test@test.com", "test.com")]
[InlineData("user@github.io", "github.io")]
public void Should_Return_Domain(
string email, string expectedDomain)
{ … }
В
InlineData мы передаём значения аргументов тестируемого метода и ожидаемый результат. Атрибут можно указывать сколько угодно раз — по одному на каждый тестовый кейс.Минус в том, что при большом количестве кейсов код быстро разрастается. Плюс можно использовать только константные значения параметров.
2. MemberData
MemberData позволяет программно задавать тестовые данные через статическое свойство или метод:[Theory]
[MemberData(nameof(EmailTestData))]
public void Should_Return_Domain(
string email, string expectedDomain)
{ … }
public static IEnumerable<object[]>
EmailTestData => new List<object>
{
new object[] { "test@test.com", "test.com" },
new object[] { "user@github.io", "github.io" }
};
В атрибуте
MemberData указывается имя члена. Ограничение здесь одно — свойство или метод должны возвращать IEnumerable<object[]>, из-за чего строгая типизация отсутствует.3. ClassData
ClassData выносит тестовые данные в отдельный класс. Это удобно для структурирования данных и повторного использования.Тестовые данные загружаются из класса, который реализует
IEnumerable<object[]>:[Theory]
[ClassData(typeof(EmailTestData))]
public void Should_Return_Domain(
string email, string expectedDomain)
{ … }
public class EmailTestData : IEnumerable<object[]>
{
public IEnumerable<object[]> GetEnumerator()
{
yield return new object[] {
"test@test.com", "test.com" };
yield return new object[] {
"user@github.io", "github.io" };
}
IEnumerator IEnumerable.GetEnumerator()
=> GetEnumerator();
}
Подход более громоздкий — нужно вручную реализовывать
IEnumerable. При этом проблема с типобезопасностью всё ещё остаётся.4. TheoryData
TheoryData позволяет хранить тестовые данные с сохранением строгой типизации.Пример с использованием
ClassData (аналогично можно применять и с MemberData, возвращая TheoryData из свойства или метода):[Theory]
[ClassData(typeof(EmailTestData))]
public void Should_Return_Domain(
string email, string expectedDomain)
{ … }
public class EmailTestData :
TheoryData<string, string>
{
public EmailTestData()
{
Add("test@test.com", "test.com");
Add("user@github.io", "github.io");
}
}
TheoryData — это обобщённый класс, в котором явно задаются типы параметров теста.Для добавления одного тестового кейса вызывается
Add, а новые сценарии просто добавляются дополнительными вызовами этого метода.Please open Telegram to view this post
VIEW IN TELEGRAM
🔥3❤1👍1
This media is not supported in your browser
VIEW IN TELEGRAM
Это аккуратный и чистый способ работать с Git из терминала
Froggit — минималистичный терминальный UI для Git. Он помогает стейджить файлы, делать коммиты, управлять ветками и смотреть логи без необходимости заучивать сложные команды.
Всё управляется с клавиатуры, работает быстро и без лишних отвлекающих элементов, поэтому проще оставаться в потоке при работе с Git.
Тестим и оцениваем😁
👉 @KodBlog
Froggit — минималистичный терминальный UI для Git. Он помогает стейджить файлы, делать коммиты, управлять ветками и смотреть логи без необходимости заучивать сложные команды.
Всё управляется с клавиатуры, работает быстро и без лишних отвлекающих элементов, поэтому проще оставаться в потоке при работе с Git.
Тестим и оцениваем
Please open Telegram to view this post
VIEW IN TELEGRAM
👍4👏2
Разбираем генераторы исходного кода.
Генераторы исходного кода, появившиеся в C# 9, стали мощным инструментом метапрограммирования в .NET. Они позволяют разработчикам автоматически генерировать дополнительный C#-код. Генераторы выполняются на этапе компиляции: анализируют существующий код и создают новые файлы исходников. Эти файлы затем компилируются вместе с остальной кодовой базой, что даёт возможность динамически расширять проект на основе уже написанного кода.
Генераторы исходного кода можно использовать для разных задач: скафолдинга, валидации, повышения читаемости и упрощения поддержки кода.
В C# 12 генераторы получили дальнейшее развитие. Появилась поддержка более сложных сценариев и выросла производительность за счёт уменьшения шаблонного кода и более эффективных проверок на этапе компиляции.
1. Инкрементные генераторы
Инкрементные генераторы регенерируют код только тогда, когда это действительно нужно. Они кэшируют результаты предыдущих запусков и пересобирают сгенерированный код только при изменении входных данных, что заметно ускоряет сборку.
2. Анализ зависимостей исходного кода
Компилятор точнее определяет, какие части проекта зависят от сгенерированного кода. В результате сборки становятся эффективнее, а количество лишних перекомпиляций сокращается.
3. Улучшенная диагностика
Ошибки и предупреждения можно более точно сообщать прямо через сгенерированный код. Разработчик получает понятную обратную связь во время разработки, что уп shows упрощает отладку и сопровождение генераторов.
4. Улучшения API Roslyn
API Roslyn получил дополнительные возможности для анализа и модификации синтаксического дерева, что позволяет реализовывать более сложную логику генерации кода.
Простой пример генератора кода
1. Настройка проекта
Создадим проект библиотеки и установим необходимые NuGet-пакеты:
2. Генератор кода
Нам понадобится класс, реализующий интерфейс IIncrementalGenerator:
Этот генератор добавляет в проект класс HelloWorld с методом SayHello. Сгенерированный код компилируется вместе с основным проектом, поэтому метод HelloGenerated.HelloWorld.SayHello() можно вызывать напрямую из вашего кода.
3. Интеграция с проектом
Создадим консольное приложение:
Добавим ссылку на проект генератора:
Обратите внимание на два нестандартных атрибута:
OutputItemType="Analyzer" — указывает, что проект должен использоваться как анализатор;
ReferenceOutputAssembly="false" — гарантирует, что целевой проект не будет ссылаться на DLL генератора во время компиляции.
После этого сгенерированный класс HelloWorld станет доступен в консольном проекте.
4. Сборка и запуск
Соберём проект и запустим его. В консоли появится вывод из сгенерированного метода:
В след. посте про чтение кода из файла😌
👉 @KodBlog
Генераторы исходного кода, появившиеся в C# 9, стали мощным инструментом метапрограммирования в .NET. Они позволяют разработчикам автоматически генерировать дополнительный C#-код. Генераторы выполняются на этапе компиляции: анализируют существующий код и создают новые файлы исходников. Эти файлы затем компилируются вместе с остальной кодовой базой, что даёт возможность динамически расширять проект на основе уже написанного кода.
Генераторы исходного кода можно использовать для разных задач: скафолдинга, валидации, повышения читаемости и упрощения поддержки кода.
В C# 12 генераторы получили дальнейшее развитие. Появилась поддержка более сложных сценариев и выросла производительность за счёт уменьшения шаблонного кода и более эффективных проверок на этапе компиляции.
1. Инкрементные генераторы
Инкрементные генераторы регенерируют код только тогда, когда это действительно нужно. Они кэшируют результаты предыдущих запусков и пересобирают сгенерированный код только при изменении входных данных, что заметно ускоряет сборку.
2. Анализ зависимостей исходного кода
Компилятор точнее определяет, какие части проекта зависят от сгенерированного кода. В результате сборки становятся эффективнее, а количество лишних перекомпиляций сокращается.
3. Улучшенная диагностика
Ошибки и предупреждения можно более точно сообщать прямо через сгенерированный код. Разработчик получает понятную обратную связь во время разработки, что уп shows упрощает отладку и сопровождение генераторов.
4. Улучшения API Roslyn
API Roslyn получил дополнительные возможности для анализа и модификации синтаксического дерева, что позволяет реализовывать более сложную логику генерации кода.
Простой пример генератора кода
1. Настройка проекта
Создадим проект библиотеки и установим необходимые NuGet-пакеты:
dotnet new classlib -n MySourceGenerator
cd MySourceGenerator
dotnet add package Microsoft.CodeAnalysis.CSharp
2. Генератор кода
Нам понадобится класс, реализующий интерфейс IIncrementalGenerator:
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;
[Generator]
public class HelloWorldGenerator : IIncrementalGenerator
{
public void Initialize(
IncrementalGeneratorInitializationContext context)
{
var src =
"""
using System;
namespace HelloGenerated;
public static class HelloWorld
{
public static void SayHello()
=> Console.WriteLine("Hello from the generated code!");
}
""";
context.RegisterPostInitializationOutput(
ctx => ctx.AddSource(
"HelloWorldGenerated",
SourceText.From(src, Encoding.UTF8)));
}
}
Этот генератор добавляет в проект класс HelloWorld с методом SayHello. Сгенерированный код компилируется вместе с основным проектом, поэтому метод HelloGenerated.HelloWorld.SayHello() можно вызывать напрямую из вашего кода.
3. Интеграция с проектом
Создадим консольное приложение:
dotnet new console -n UseSourceGenerator
Добавим ссылку на проект генератора:
<ProjectReference
Include="..\MySourceGenerator\MySourceGenerator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
Обратите внимание на два нестандартных атрибута:
OutputItemType="Analyzer" — указывает, что проект должен использоваться как анализатор;
ReferenceOutputAssembly="false" — гарантирует, что целевой проект не будет ссылаться на DLL генератора во время компиляции.
После этого сгенерированный класс HelloWorld станет доступен в консольном проекте.
4. Сборка и запуск
Соберём проект и запустим его. В консоли появится вывод из сгенерированного метода:
// Program.cs
HelloGenerated.HelloWorld.SayHello();
В след. посте про чтение кода из файла
Please open Telegram to view this post
VIEW IN TELEGRAM
❤8👍1🤔1