.NET sh blog


Channel's geo and language: Russia, Russian
Category: Technologies


Блог про .NET
Тут различные заметки и все что покажется интересным для обсуждения
https://github.com/mt89vein
#dotnet #csharp #postgres #net #дотнет #сишарп #dev #developer #software #engineer #blog

Related channels

Channel's geo and language
Russia, Russian
Statistics
Posts filter


Как-то я писал про то, что хочется в API эндпойнтах видеть все коды ошибок которые она может вернуть и вот наконец-то дошли руки сделать анализатор который автоматически будет все это собирать на этапе компиляции, потому что руками поддерживать это нереально, так как если где-то в глубине добавили новый код ошибки или вызов другого метода, то всё становится неактуальным.

Работает оно следующим образом: incremental source generator бегает по методам, ищет в теле метода выбросы исключений или возврат кодов ошибок в виде result pattern, потом смотрит на все вызовы методов внутри него и пробегается и по ним и собирает коды ошибок по каждому методу.

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

В итоге генерируется мапа, где key = FullyQualified method name (или OperationId/Route для minimal api), а value = массив найденных кодов ошибок.

Эту мапу уже можно использовать по-разному. Например, отображать в сваггере или написать ассерты в тестах, также можно детектировать изменение кодов ошибок ответа API, ведь это по сути breaking change для потребителя.

В примере проекта я добавил отображение в сваггере, с ссылкой на документацию по коду ошибки (если он есть). 'https://t.me/sh_dotnet/138?comment=559' rel='nofollow'>См скриншот в комментариях.


Парочка полезностей при работе с NuGet пакетами:

1. Команда dotnet nuget why (доступа с .NET 8), задача которого пояснить почему была зарезолвлена та или иная версия пакета. Для этого он выводит в консоль граф зависимостей. Это помогает находить быстрее проблемы с конфликтами версий

2. willow - open sourced dotnet tool, который помогает найти неиспользуемые пакеты, пакеты с уязвимостями, а также в нем есть более удобный и гибкий willow why, аналог dotnet nuget why.

Эту тулу можно и нужно вызывать в CI/CD job, для проверок:

1. willow MyProject.csproj --no-prerelease - выдаст ошибку с деталями, если найдет preview версии пакетов. Полезно для команд, где существуют такие правила и надо их энфорсить в CI.
2. willow MyProject.csproj --vulnerable - выдаст ошибку с деталями, если найдет пакеты с уязвимостями. Полезно для любого проекта.

381 0 11 1 17

Как ускорить CI/CD пайплайны?

TLDR; шардим тесты, параллелим, для изоляции используем пул БД и чистим через db template. github

Это довольно частый вопрос, который я слышал во всех компаниях где я работал.
Поднятие рядом с раннерами NuGet proxy (кэш) за счет локализации ускоряет билды, но в конечном счете, самым дорогим шагом остается запуск тестов.

Если рассматривать пирамиду тестирования, то согласно ему, основным объёмом должны быть юнит тесты, далее интеграционные и e2e.

Здесь логика довольно простая - юниты легко пишутся и понимаются. Дают неплохой буст к уверенности что та или иная функция работает корректно и при этом обычно прекрасно параллелятся и в итоге даже тысячи тестов выполняются в сумме < 1 сек.
Но юниты чаще применимы в библиотечном коде, на агрегаты, ValueObjects и прочие мелкие функции. Например конвертеры, мапперы, шифровальщики. Такие тесты хороши, но их часто недостаточно.

Интеграционные тесты в свою очередь бывают очень разными. Это может быть проверка работы двух и более функций, классов или вызов API сервиса в изоляции от остального мира.
Стандартный способ написать интеграционный тест для AspNetCore это WebApplicationFactory. А если у сервиса есть база данных, то слой хранения данных либо мокают, используют InMemory providers или более легковесные СУБД, типа SqlLite в InMemory режиме. Чем это плохо, думаю, объяснять не нужно, поэтому всё чаще используется подход с TestContainers для поднятия реальной СУБД с которым сервис работает на проде. Так мы повышаем доверие к тестам, но тесты становятся значительно дольше.

Почему это всё еще интеграционный тест, а не E2E?
- Запускается не отдельный реальный процесс как на проде, а TestServer, который эмулирует HTTP вызовы.
- Тестируется только один сервис с замоканными ответами от внешних сервисов.
- В документации Microsoft тоже написано, что это интеграционный тест и коммьюнити с этим согласно.

Какие у них плюсы?
- Тестируется весь HTTP пайплайн (мидлвари, роутинг, сериализация, аутентификация и т.п.)
- Тестируется всё приложение: API (controllers), Infrastructure (db), Application (usecases), Domain (aggregates, domain events).
- Используется реальная база данных хоть она и поднята в контейнере.
- Приближено к реальному использованию сервиса, а значит обеспечивает высокий уровень доверия.
- Можно зафиксировать сразу и контракты API и исходящие события, если речь идет об event-driven architecture.

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

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

Но такие тесты уже не дешевые и легко сломать изоляцию, сделать их нестабильными.
Они выполняются сильно дольше. База данных поднимается перед всеми тестами и чистится содержимое перед каждым тест кейсом. Так же ради изоляции приходится запускать новый инстанс WebApplication на каждый тест кейс, что тоже дается не бесплатно.

Ускорить тесты помогает следование практикам:
- К тестам относимся как к бизнес коду т.е. пишем качественно, продумываем архитектуру, особенно для arrange части теста.
- Грамотно организуем работу с шаренными ресурсами.
- Тесты максимально изолированы и параллелятся.
- Периодическое профилирование, поиск узких мест
- Сбор метрик скорости работы тестов (jUnit, Reporting, own metrics)

продолжение поста в комментариях


Какие стандартные API могут быть полезны всем сервисам?

1. /health, /ready, /startup - для оркестраторов и балансеров. О них я рассказывал ранее.
2. /metrics - стандартный эндпойнт для скрэпинга метрик prometheus
3. /build-info - возвращает название сервиса, версию, номер и дату-время билда

{
"name": "my-awesome-service",
"version": "1.2.3",
"commit": "abc123",
"builtAt": "2026-02-01T12:00:00Z"
}

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

Версию и название, можно отдавать не только здесь, но и добавлять в трейсы и в заголовки ответов обычных API.

4. /whoami - возвращает информацию о текущем инстансе

Вся эта информация обычно поступает из переменных окружений, API просто выводит их для удобства.

{
"env": "prod", // название окружения
"hostname": "my-awesome-service-48805-2183412", // название пода/виртуалки
"node": "some-node-12", // воркер нода k8s
"zone": "west-1b" // зона доступности
}

5. /features - возвращает не секретные данные о текущих фича-флагах, рейт-лимитах и прочей информации которая не несет рисков компрометации

{
"Features": {
"SomethingEnabled": true
},
"RateLimits": {
"HeavyApis": {
"Permit": 1,
"Queue": 0
}
}
}

6. /config
Для локальной разработки можно сделать API который отдает весь конфиг, с информацией о том, откуда пришли эти данные. Но не рекомендую такое включать на стендах, так как это скомпрометирует секреты.
Обычно такое API не нужно, так как конфиги локальные достаточно простые. Но всякие проекты бывают.

А какие API можно еще добавить в этот список?

444 0 13 4 14

Интересное видео попалось, в котором показывается как для API тестов (с использованием WebApplicationFactory, XUnit) можно прикрутить OpenTelemetry трейсинг. Для визуализации трейсов используется Aspire, но можно взять любой другой.

Такой подход будет полезен в основном для локальной разработки, чтобы видеть тайминги различных этапов обработки запроса в наших API, посмотреть куда еще отправляются запросы в рамках него, проверить какие этапы обозначены спанами, а какие нет. А также достаточно ли обогащены спаны информацией (тегами, событиями) и таким образом можно улучшать трейсинг.

А вот для запусков в CI я бы рекомендовал отключать всё что не нужно, в т.ч. сбор самих метрик и трейсов (если на них у Вас нет ассертов), чтобы не тормозило. Вместо них для отслеживания таймингов тестов можно настроить junit logger.

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

А какие подходы используете Вы, чтобы Ваш трейсинг приносил пользу?


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

На одном проекте были Rich Model, хорошо структурированный код, но при этом агрегаты сами себя сохраняли в базу данных, получая доступ к репозиториям, через хитро завёрнутый DI в Ambient Context.
Таким образом репозитории активно использовались внутри агрегатов, практически реализуя паттерн ActiveRecord. Это работало, но доставляло много проблем, поэтому там вообще не было тестов.

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

Постараюсь систематизировать этот вопрос.

Бизнес-логика — это правила, ограничения, инварианты, знания, состояния и процессы, описывающие предметную область (т.е. как работает сам бизнес),

примеры правил:

- книга не может быть выдана более чем на N дней
- нельзя брать абоненту больше X книг
- если книга просрочена, то абонементу выписывается штраф
- книгу можно взять только если она свободна

Логика приложения — это UseCases (сценарии использования бизнес логики)

- обработка HTTP-запросов
- обработка сообщений
- работа с базой данных
- кэширование
- обработка ошибок
- аутентификация
- авторизация
- логирование
- трейсинг

Чтобы определить является ли бизнес логикой можно использовать несколько популярных техник:

1. Попробуйте объяснить эту логику бизнес-эксперту без использования технических терминов (агрегат, репозиторий, база данных, транзакция, HTTP статус, очередь и т.п.)

Если получается, то скорее всего это бизнес логика.

2. Если убрать всю инфраструктуру: базы данных, интерфейсы взаимодействия, останется ли эта логика?

Если да, то это бизнес логика.

3. Это правило или шаг сценария?

"книга должна быть доступна" - бизнес правило (Domain)
"получить книгу из БД" - шаг сценария (Application)

Так же есть несколько распространенных маркеров и нюансов:

1. Авторизация и прочая проверка прав - логика приложения.

К примеру, пользователю может быть выдано право на взятие книги из библиотеки. И оно будет просто проверять наличие такого права - это логика приложения.
А могут быть и специфичные для предметной области правила и ограничения:

- книга уже взята кем-то другим
- превышен лимит по количеству книг
- есть ещё не возвращенные в срок книги
- не состоит в VIP клубе или нет премиум подписки

это уже домен

2. Использование HTTP client, Repository и прочего - логика приложения.

Бизнес логика при этом обычно pure function, без I/O операций.

3. Валидация входных данных

Есть логика приложения: техническая валидация, когда мы хотим убедиться что нам корректно передали и мы корректно десериализовали:
BookId != Guid.Empty, это логика приложения.

А есть бизнес правила: книгу могут взять только люди старше 18 лет

В идеале вся бизнес логика должна быть сосредоточена в доменной модели (агрегаты, value objects, в сложных случаях в доменных сервисах), а логика приложения в UseCases, которая напоминает оркестрацию:

1. Получение входных данных от инфраструктуры (API controller, Kafka Consumer, etc)
2. Достают агрегаты из базы данных
3. Вызывают методы на агрегатах, передавая необходимые данные
4. Сохраняют изменения в базу данных
5. Возвращают результат наружу в виде DTO, сообщений в кафку и т.п.

для наглядности в BookLibrary

При этом домен не знает ничего о базе данных, очередях или HTTP.
А также не должен обращаться к Ambient Context. Например к DateTime.UtcNow, Guid.NewGuid(), и т.п, вместо этого они должны передаваться в виде параметров.

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

В тоже время логика приложения почти всегда подразумевает интеграционные тесты с настоящей инфраструктурой. Они будут намного медленней, но зато будут проверять наши сценарии вместе с бизнес логикой.

654 0 15 13 25

Хочу поделиться с Вами моим видением, как можно структурировать солюшен, когда в нём присутствуют Kafka Consumers, BackgroundJobs (HostedServices, Hangfire и т.п.) на примере BookLibrary.
Данный канал я начинал с более простого варианта и этот пост его дополняет.

Чтобы было проще понять структуру солюшена и знать что куда поместить - я 'https://t.me/sh_dotnet/132?comment=500' rel='nofollow'>нарисовал простенькую схему и выгрузил 'https://t.me/sh_dotnet/132?comment=501' rel='nofollow'>диаграмму зависимостей из Rider.

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

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

Так вот, в качестве запускаемого проекта будет BookLibrary.Host
Его задача быть упакованным в докер образ и уметь конфигурировать и запускать подключенные к нему независимые модули: Api, BackgroundJobs, Consumers. При этом модули хочется иметь возможность включать и выключать по флагам.

Например, запускаем образ передав переменные окружения:

BL_KAFKA_CONSUMERS_ENABLED=true - будут запущены Consumers
BL_BACKGROUND_JOBS_ENABLED=true - будут запущены фоновые воркеры
BL_API_ENABLED=true - будут работать API

Таким образом сможем из одного образа задеплоить одно приложение которое включает в себе всё или поделить на 2 или 3 деплоймента, в каждом из которых работают только нужные модули и всё из одного образа. Это упростит скейлинг в будущем.

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

Вся конфигурация идет через BookLibrary.Host, поэтому дублировать ничего не придется.
Инфраструктура настраивается тоже один раз и переиспользуется.

Отдельно я еще выделил сборку BookLibrary.Kafka, которая фактически является частью внешнего слоя.
Там держу в основном переиспользуемый библиотечный код работы с Kafka и методы регистрации их в DI.
А также кафка модели, например сгенерированный код из protobuf/avro и т.п.

Вообще редко вижу чтобы эту сборку выделяли. Вместо этого все реализуют в Infrastructure.
Но я все ещё считаю, что это плохая идея.
Потому что контракты кафки становятся доступны слишком большому числу сборок, в том числе инфраструктуре и она может начать где-нибудь использоваться их не по целевому назначению, а избавляться от такого потом может быть больно.

Как вариант, содержимое BookLibrary.Kafka можно держать в BookLibrary.Infrastructure или даже вынести в NuGet, а для контрактов сделать отдельную сборку BookLibrary.Kafka.Contracts и подключать их в нужных местах, но на текущий момент мне больше импонирует вариант, когда все что связано с кафкой лежит отдельно в одном месте.

Что думаете о такой структуре? О запуске из одного образа?

620 0 13 23 10

Представьте что есть некий enum
public enum SomeType
{
One,
Two,
Three,
...
}
и вам нужно сделать сортировку массива объектов по этому полю
public class SomeEnumData
{
public SomeType Type { get; set; }

...
}
Если это целевое предназначение enum, то можно сортировать прям по его значениям
items.OrderBy(x => x.Type);
Если же enum означает что-то другое - например какие-нибудь типы документов или проверок, то такая завязка на очередность объявления значений в enum будет очень неявной, подвержена ошибкам и неожиданностям для коллег в будущем.

Здесь лучше подойдет создание своей реализации IComparer
И В качестве решения "в лоб" возьмем словарь, а лучше его Frozen вариант, ведь он быстрее, ведь да?
public sealed class SomeTypeComparer : IComparer
{
private static readonly FrozenDictionary Order =
new Dictionary
{
{ SomeType.One, 4 },
{ SomeType.Two, 3 },
{ SomeType.Three, 1 }
}
.ToFrozenDictionary();

public static SomeTypeComparer Instance { get; } = new();

public int Compare(SomeType x, SomeType y)
{
return Order[x].CompareTo(Order[y]);
}
}
Плюс использования IComparer, что порядок сортировки можно задать любой который только захочется. Причем для разных задач можно сделать разную сортировку.

Но насколько сильно мы проиграем, если заменим сортировку по enum, на такую кастомную?

Когда я первый раз столкнулся с оценкой алгоритмической сложности, то оно меня ввело в заблуждение и хотелось для всего использовать словари просто потому что он обеспечивает доступ за O(1) вот только O(1) это просто оценка, а не реальная скорость.
К слову, обращение по индексу массива тоже O(1), но в абсолютных цифрах она будет работать быстрее словарей, просто потому что ему нужно сделать гораздо меньше операций чтобы получить значение.
Например, не нужно хэшировать ключ, искать бакет т.п.
Поэтому при выборе того или иного решения нужно смотреть на операции которые выполняются, а не только на алгоритмическую сложность. И конечно же бенчмаркать.


Напишем еще один вариант который использует индексный доступ по массиву:
public sealed class SomeTypeArrayComparer : IComparer
{
public static readonly int[] Order = Enum
.GetValues()
.Select(x => (int)x)
.ToArray();

static SomeTypeArrayComparer()
{
Order[(int)SomeType.One] = 3;
Order[(int)SomeType.Two] = 4;
Order[(int)SomeType.Three] = 1;
}

public static SomeTypeArrayComparer Instance { get; } = new();

public int Compare(SomeType x, SomeType y)
{
return Order[(int)x].CompareTo(Order[(int)y]);
}
}

В dotnet-tips выложил все исходники. Результаты бенчмарков в 'https://t.me/sh_dotnet/131?comment=492' rel='nofollow'>комментариях к посту.


Мне нравится подход, когда все API классифицируются (изолируются) по потребителям:

1. Пользовательские интерфейсы (UI)

- UI
- Admin UI
- Mobile (ios, android)

Эти API предназначены для использования исключительно на UI клиентах продукта.
Из потребностей можно выделить: агрегации данных из разных сервисов, обогащение по справочникам, удобные пагинации, сортировки и умный поиск (например, с учетом синонимов, автодополнения, инвертированной раскладки, нечеткого соответствия или полнотекстовый).
При этом выборки данных обычно небольшие.

Часто чтобы уложиться в НФТ и реализовать хороший поиск вынуждены делать read model.
Под мобилку могут быть переиспользованы API для UI и пересобраны на BFF, а могут быть написаны свои более подходящие.
А в админке могут быть управления ролями, правами, справочниками - и все такие изменения с обязательным аудитом.

2. Для внешних интеграций (Systems)

Чаще всего интеграции оперируют большими выборками без обогащений по справочникам.
Кому необходимо могут справочник скачать к себе и делать обогащения на своей стороне.
Поиск по текстовым полям отсутствует т.к. интеграциям важнее искать по конкретным идентификаторам сущностей.
Если и применяется пагинация, то стараются применять KeySet чтобы не нагружать БД бессмысленными сортировками или поисками, а данные часто просто стримятся в ответ, что еще и экономит память.

Эти API стараются держать максимально стабильными (без breaking changes) со строгим версионированием, как например делает Stripe. А OpenAPI спецификации тщательно описываются, чтобы адекватно работала кодогенерация клиентов.

3. Для внутреннего взаимодействия между сервисами в рамках продукта (Internal)

API которым придаются любые нужные формы для внутренних интеграций или используются даже другие протоколы (gRPC к примеру).
Релизный цикл очень высокий, легко договориться о breaking changes.

Аутентификация через выдачу внутренних JWT токенов (Client Credentials) или mTLS.

Примеры API:
- Передача данных о новом заказе от сервиса оформления заказов к сервису обработки платежей.
- Синхронизация данных между микросервисами в рамках одной платформы.

4. Для поддержки, миграций и прочего (maintenance)

Служебные API для healthchecks, readiness, metrics, отдачи версии приложения.
Служебные API для инженеров поддержки. Что-то пересчитать, подправить, синхронизировать и т.п.
Миграционные API могут быть полезны когда нужно создать схемы БД, мигрировать данные из одной версии в другую, что-то массово обновить, наполнить справочники или прогреть кэши.

Эти API могут быть опасными поэтому должен быть аудит и авторизация пользователей.


В итоге, классификация API по потребителям позволяет четко разделить ответственность, оптимизировать производительность и упростить разработку и поддержку, за счет некоторого дублирования кода.
Каждый потребитель имеет свои особенности, которые необходимо учитывать при проектировании, чтобы обеспечить удобство использования, масштабируемость, безопасность и стабильность системы.
А для того чтобы потребители не путались, все API стоит также группировать по разным OpenAPI спецификациям.


Недавно на youtube появилась запись доклада "10 Things I Do On Every .NET App - Scott Sauber"

Добавлю свои 5 копеек

1. BootstrapLogger pattern
public static async Task Main(string[] args)
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Override("Microsoft", LogEventLevel.Information)
.Enrich.FromLogContext()
.WriteTo.Console(new JsonFormatter())
.CreateBootstrapLogger();

try
{
Log.Information("Starting BookLibrary");

var host = CreateHostBuilder(args).Build();

await host.MigrateAsync();

await host.RunAsync();
}
catch (Exception ex)
{
Log.Fatal(ex, "BookLibrary terminated unexpectedly");
}
finally
{
await Log.CloseAndFlushAsync();
}
}
Позволяет отправить в логи отладочную информацию о падении сервиса или ошибке её запуска.
Без обработчика ошибки такая информация чаще всего теряется.

2. Глобальный таймаут на Regex

На одном проекте много времени убили на поиск причины почему сервис вдруг перестает потреблять сообщения из шины и ребут не помогает.
После полного воспроизведения ситуации локально на продовых данных удалось выяснить, что Regex.IsMatch вызывался без заданного таймаута
и иногда попадался такой текст из-за которого сервис зависал намертво.
Было неприятно, но это легко пофиксилось переписыванием регулярки с обработкой возможного таймаута и добавлением глобального таймаута для защиты от такой ситуации.
С тех пор стараюсь в Program.cs везде добавлять настройку:
AppDomain.CurrentDomain.SetData("REGEX_DEFAULT_MATCH_TIMEOUT", TimeSpan.FromSeconds(1));

3. Ограничение размера тела HTTP запроса в Kestrel
builder.Services.Configure(options =>
{
options.Limits.MaxRequestBodySize = 20 * 1024; // 20 кб при превышении вернет 413 Payload Too Large
});

По-умолчанию лимит стоит 28.6 мегабайт, что очень много.
Если мы знаем что наш API не будет или не должен принимать большой объём данных, то можно снизить это дефолтное значение для уменьшения возможных атак на сервис.
Для отдельных запросов типа загрузки файлов этот лимит можно точечно настроить атрибутом [RequestSizeLimit]

Обычно такие лимиты могут настраиваться на стороне reverse proxy, но приложение всегда лучше знает о своих потребностях и ограничениях
и нужно помнить, что далеко не всегда приложение будет хоститься именно за этим reverse proxy.

4. Глобальный таймаут на запросы
services.AddRequestTimeouts(o => o.DefaultPolicy = new RequestTimeoutPolicy
{
Timeout = TimeSpan.FromSeconds(30)
});

app.UseRequestTimeouts();

По-умолчанию никакого таймаута нет. Таймауты обычно проставляются по-умолчанию на reverse proxy, но лучше это сделать и в приложении.

Добавление этих трех строк выше не гарантирует что запросы начнут отменяться. Оно лишь будет инициировать отмену запроса не дожидаясь когда отменит запрос клиент (если вообще отменит).
Для того чтобы запросы отменялись и расходовали лишние ресурсы нужно использовать CancellationToken.

5. Сокрытие деталей исключений JSON сериализатора (и не только)

По-умолчанию, если в API передать кривой JSON (например null, там где ожидается Guid), то AspNetCore выдаст отладочную информацию,
раскрывая внутренние типы и детали работы сериализатора. Также могут протечь какие-нибудь более чувствительные стектрейсы, что откроет еще больше зоны для атаки.

{
"type": "https://httpstatuses.io/400",
"title": "Некорректные параметры запроса",
"status": 400,
"errors": {
"$.id": [
"The JSON value could not be converted to System.Guid. Path: $.id | LineNumber: 2 | BytePositionInLine: 8."
]
}
}

Для прода рекомендуется отключить:
services
.AddControllers()
.AddJsonOptions(o => o.AllowInputFormatterExceptionMessages = false);

Тогда ошибка уже в будет в таком виде
{
"type": "https://httpstatuses.io/400",
"title": "Некорректные параметры запроса",
"status": 400,
"errors": {
"$.id": [
"The input was not valid."
]
}
}

603 0 22 1 31

Как отслеживать все исключения которые возникают в .NET приложении?

Встроенных инструментов 2:

1. С помощью dotnet-counters можно подключиться к работающему .NET процессу и снимать данные с него
2. Метрики приложения (OpenTelemetry / AppMetrics / и т.п.)

Чтобы начать собирать счетчик исключений с OpenTelemetry достаточно вызвать AddRuntimeInstrumentation.
Помимо кол-ва исключений там будет много и других полезных метрик (полный список документируется здесь)

До .NET 9.0 метрика (dotnet_exceptions_total) собирает просто кол-во исключений, а начиная с .NET 9.0 можно смотреть в разрезе по типу исключения, что дает гораздо больше информации о происходящем, но всё еще недостаточно.

Данная метрика собирается по событию AppDomain.Current.FirstChanceException, которая срабатывает на каждый throw любого исключения в текущем процессе.
Поэтому обработчик события должен быть очень быстрым чтобы не тормозить приложение и при этом не выбрасывать новые исключения иначе можно уйти в бесконечный цикл или даже крашнуть рантайм. Полезная ремарка есть в документации события.

Еще важно помнить, что на текущий момент async await разворачивается компилятором в FSM (покрайне мере до async2), внутри которого в случае исключений будут их rethrow на каждый await, а значит чем глубже стек вызовов, тем больше лишних триггеров FirstChanceException. Поэтому метрика с одной стороны врёт, с другой нет. Тут смотря как посмотреть.

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

Полный листинг доступен на github.
Ниже разберем некоторые моменты:
[ThreadStatic]
private static Dictionary? _sampler;

private void HandleFirstChanceException(object? sender, FirstChanceExceptionEventArgs e)
{
var exceptionType = e.Exception.GetType();

ref var counter = ref CollectionsMarshal.GetValueRefOrAddDefault(_sampler, exceptionType, out var exists);

var needLog = !exists || Interlocked.Increment(ref counter) % LOG_EVERY_N_EXCEPTIONS == 0;

if (needLog)
{
LogExceptionSample(e.Exception, exceptionType.FullName);
}
}
Так как необходимо сэмплировать по каждому типу исключения отдельно, то нужно хранить эти счетчики и здесь подходит словарь (_sampler).
И чтобы атомарно увеличивать счетчики воспользуемся Interlocked. Чтобы с ней работать требуется передать ссылку на значимый тип. Если бы счетчик был просто полем или локальной переменной - то всё было бы просто.
Но так как значение находится в словаре, то воспользуемся CollectionsMarshal.GetValueRefOrAddDefault. Он возьмет ссылку на значение в словаре (если его не было добавит) и дальше передаем в Interlocked.Increment

В лог запишется первое появление исключения и каждые последующие кратные N.

Чтобы приложение не ушло в бесконечный цикл, нужно чтобы метод HandleFirstChanceException вообще не выбрасывал никаких исключений (даже неконтролируемые OOM). Это не всегда возможно обеспечить, особенно если вызывать сторонние библиотеки.

Для этого воспользуемся хаком, который я подглядел в исходниках Microsoft.
Её суть в оборачивании флагом небезопасного участка кода. И пока флаг поднят, порожденные новые исключения будут проигнорированны данным обработчиком в текущем потоке (т.к. ThreadStatic)
[ThreadStatic]
private static bool _handlingFirstChanceException;

private void HandleFirstChanceException(object? sender, FirstChanceExceptionEventArgs e)
{
if (_handlingFirstChanceException)
{
return;
}

_handlingFirstChanceException = true;

try
{
// unsafe code
}
catch (Exception _)
{
// no-op
}
finally
{
_handlingFirstChanceException = false;
}
}
Для любителей high performance кода может быть интересно заглянуть в метод Trim.

В итоге данный ExceptionTracker позволяет залогировать каждые N исключений каждого типа и тем самым иметь больше полезной информации для разбора причин, почему эти исключения возникают.


На днях возникла потребность вывести в OpenAPI спецификации сразу 2 варианта ответа от API для одного статус кода.
К примеру, API может вернуть 200ый статус с application/problem+json или application/json.

Это полезно для генераторов клиентов, чтобы они корректно обрабатывали разные кейсы ответов.

На рабочих проектах я использую ProblemDetails для ответов с ошибками и application/json для успешных ответов.
И OpenAPI спецификация к счастью позволяет задать для разных Content-Type свои варианты ответов с примером для каждого статус кода. Пример спеки 'https://t.me/sh_dotnet/126?comment=479' rel='nofollow'>см. в комментариях.

На текущий момент я не пишу спеку вручную, а использую популярный генератор Swashbuckle. И реализовывав IOperationFilter можно по-своему доработать генерацию спеки.

В моем случае при обнаружении нескольких атрибутов SwaggerResponseAttribute на один статус код я генерирую ему схему, пример и добавляю в описание текущего API.

Результаты на скриншотах

Полезные ссылки:
* Пример
* RFC ProblemDetails


Спустя примерно 2 года использования git hooks на базе Husky.NET захотелось внести изменения в их работу

Сперва пара слов как это работало раньше:

1. На pre-commit hook вызывается dotnet format, dotnet build и dotnet test
2. На commit hook проверяется соответствие сообщения коммита к conventional commits

Так было сделано чтобы каждый коммит был рабочим: и билдился и проходили тесты. Очень полезно, если в команде принято использовать git merge для слияния веток.
У меня в команде применяется только git rebase и squash commits при принятии PR. И сквош как раз лишал смысла делать каждый из коммитов "рабочим", так как история коммитов по сути переписывается.

К тому же оформление каждого коммита занимает много времени из-за запуска форматирования, билда и тестов.
И особенно досадно, когда прошли форматирования, билды и тесты, но ты просто ошибься в тексте коммита и надо все запускать сначала.

Сам Husky.NET шикарный инструмент, но есть ряд ограничений которые не позволяли сделать его использование по-настоящему удобным.


Что дают переосмысленные git hooks теперь:

1. После вызова команды git commit сразу же запускается хук prepare-commit-msg
Он берет номер задачи в формате Jira из названия ветки и добавляет в коммит. А если не задан тип коммита (fix/feat/perf etc), то добавляет и его в виде "chore".

К примеру был коммит с сообщением xx в ветке SSTV-123. Тогда текст сообщения коммита будет chore(SSTV-123): xx

Если коммит был feat: xx, то станет feat(SSTV-123): xx


2. В commit-msg хуке выполняется валидация что тип коммита задан, что номер задачи из текущего проекта (префикс) и в целом длина коммита не превышает 90 символов и нет лишних пробелов или точки в конце

3. Когда разработчик нажимает отправить в удаленный репозиторий, то в pre-push хуке проверяется, были ли изменения в *.cs файлах среди всех еще не запушенных коммитов в этой ветке.
Если такие есть - они форматируются, выполняется dotnet build и dotnet test.
Именно этот пункт не получится сделать через Husky.NET т.к. он заточен только под текущие изменения до фиксации коммита.

Далее если после форматирования появились изменения, то автоматически будет создан коммит "chore: auto formatting".
Следом запускается dotnet test. Если в тестах используются снепшот тесты, то они могут измениться. В таком случае пуш не пройдет т.к. нужно проверить почему они были изменены и закоммитить только если соответствуют новой логике.
Если хочется сделать пуш и при этом есть незакомиченные изменения - будет тоже самое. Здесь я пока не придумал как красиво поддержать оба сценария.

Полезные ссылки:
* Про написание снепшот тестов с помощью библиотеки Verify
* Подробнее про прошлый подход с Husky.NET
* Код с новым подходом


Вчера вышло видео от Derek Comartin на канале CodeOpinion - "You don’t need an Aggregate in DDD. Model Rules, Not Relationships". Несмотря на кликбейтность заголовка видео достойно просмотра.

Кратко о проблематике:

Как известно, агрегаты в DDD должны сохранять свои инварианты (непротиворечивость и консистентность данных). Это важнейшая задача, то ради чего в целом то агрегаты и нужны.
Но что делать если агрегат получается очень большим?

Допустим есть агрегат Chat, с правилом: в чате может состоять не более 100к пользователей.
Чтобы агрегат мог сохранить инварианты (к примеру не позволить вступить в чат больше 100к пользователей), нужно чтобы все нужные ему данные были подгружены в память. Тогда агрегат сможет делать необходимые ему валидации безопасно.
Согласитесь, никто в своем уме не захочет так делать, это слишком медленно и дорого.

У данной проблемы даже есть своё имя "DDD trilemma", о котором есть отличная статья от Владимира Хорикова - "Domain model purity vs. domain model completeness (DDD Trilemma)"

К счастью автор видео не оставляет нас без решения и предлагает в таких случаях отказаться от Domain Completeness или даже в целом от привычных агрегатов и "деградировать" до transaction script или переложить эту логику в доменные сервисы. Самое главное чтобы был подходящий под ваши требования способ обеспечения соблюдения необходимых бизнес правил.

К слову, в учебном проекте BookLibrary я тоже столкнулся с этой трилеммой, когда захотел реализовать правило - "Не более 3 книг в одни руки".

Здесь я решил пожертвовать Domain model completeness (т.к. логика немного размазана между application и domain) в пользу Domain model purity и performance.

В UseCase собираю информацию о количестве уже взятых книг и при попытке взять очередную книгу, вызывается проверка, на кол-во уже взятых книг.
Этот пример не идеален, так как нет никаких защит от параллельных запросов, это нужно учитывать.

653 0 12 3 15

Ранее я рассказывал про то, как настраиваю .NET приложение.

Если у Вас приложение запускается только из вашего одного CI/CD, то скорее всего с описанными ниже проблемами Вы не сталкивались и подход из поста выше будет вполне успешно работать.

Но если есть много стендов, которые еще и поддерживаются не вашей командой: e2e тесты, on-prem для заказчиков и прочее, то appsettings автоматически становится публичным контрактом.
А это означает что он должен быть и прямо и обратно-совместимым между минорными и патч версиями образов.

Когда у Вас много разных стендов, версии образов между ними непременно разъедутся и когда появится необходимость обновиться, то к Вам придут с вопросами почему приложение не запускается, потому что сами скорее всего не разберутся из-за непонятных ошибок в логах. В итоге придется appsettings.json сформировать заново ориентируясь на appsettings из исходного репозитория, что естественно займет не мало времени.

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

1. Использовать исключительно Environment Variables для настроек запуска. Никаких подкладываний файлов и монтирований вольюмов.
appsettings должен поставляться вместе с приложением т.е. быть внутри образа. В свою очередь, чтобы не нарушать twelve factor app, он должен быть один на все окружения и заполняться общими значениями, которые не зависят от окружения (например все URI приходят из env). Для локальной разработки env задаются через launchSettings.json, а для интеграционных тестов через Environment.SetEnvironmentVariable.

Плюсы:
- Упрощается поддержка множества разных стендов т.к. не нужно копировать или синхронизировать файлы между окружениями.
- Следуем DevOps практикам и собираем один образ для test, staging и prod
- Нет нужды сохранять совместимость в appsettings.json т.к. он внутри образа и больше не перезагружается в рантайме
Минусы:
- Некоторые вещи проще описать в JSON одним объектом, чем несколькими отдельными энвами. Задание одного энва может требовать заполнение и другого.
- У env есть ограничения как на названия (набор символов, длина), так и значения (размер и кол-во).
- У энвов свой стандарт именования в UPPER_UNDER_SCORE, поэтому в нужен маппинг env -> appsettings.json для сохранения привычной работы.
- С ростом приложения может стать очень много переменных окружения и помогает только структуризация и нейминг.

2. Приложение обязано проверять, что все переменные заданы и корректны.

Плюсы:
- Приложение не запустится с неверной конфигурацией, что предотвращает скрытые баги и порчу данных.
- Ускоряет отладку т.к. в логах сразу видно, какие переменные невалидны или отсутствуют.
- Поды с прошлого релиза не будут заменяться новыми, пока они успешно не поднимутся. А значит не нужен ручной откат.
- Все это повышает надежность релизов и снижает вероятность инцидентов.

3. Обязательные и опциональные переменные в зависимости от фич
Переменные делятся на обязательные и опциональные в зависимости от включенных feature flags (FF).
Если FF может меняться в рантайме, то следует требовать все переменные нужные для фичи.
Если же меняться в рантайме не может и фича выключена — нет смысла требовать лишние переменные.

4. Приложение должно предоставлять список всех переменных окружения, которые можно настроить

Плюсы:
- Из списка можно сгенерировать документацию которая будет всегда актуальна
- Проще определить наличие breaking changes в образе и лучше следовать semver. Например через сравнение набора обязательных env между версиями.

Чтобы было наглядно, я подготовил пример проекта в dotnet-tips, в котором используется данный подход.

648 0 11 16 12

На github попался интересный проект nugraph.

Это dotnet tool, который создан для визуализации графа зависимостей как отдельного NuGet пакета так и любой другой сборки (csproj).

Граф можно сохранить в файл и добавить в документацию проекта, например, формируя его в CI/CD пайплайне или в pre-push hook локально.

Если сгенерировать svg файл, то он будет снабжен кликабельными ссылками на пакеты в NuGet.org.

У него довольно много прикольных и продуманных настроек, посмотрите readme.

А вообще сам граф зависимостей всегда собирается при вызове dotnet restore и можно посмотреть в файле
bin/Debug/{framework version}/{project name}.deps.json
но визуализация сделает его изучение проще :)


Вышел интересный пост от Gérald Barré: Как найти public символы, которые можно сделать internal.

Автор написал простое консольное приложение, которое анализирует solution и выводит на экран символы на которые никто не ссылается снаружи сборки и их можно сделать internal.

Почему это важно?

Чем меньше публичного API (классов, методов и т.п.), тем проще развивать код. Всё внутреннее можно переписывать как угодно, оставляя нетронутым публичное, таким образом сохраняя обратную совместимость.


Недавно на работе обновил NUnit и NUnit.Analyzer до последней версии и в нём появился новый способ объединения Assert.

Вместо Assert.Multiple(() => {}) или Assert.MultipleAsync(async () => { await .. }) можно теперь писать в using:
using (Assert.EnterMultipleScope())
{
Assert.That(...);
}
Дабы эта фича не осталась незамеченной, в NUnit.Analyzer предусмотрели анализатор, который любезно подсказывает, что появился более удобный вариант.

Анализ NUnit2056 по-умолчанию с уровнем info, но так как у меня на проекте стоят агрессивные настройки статического анализатора, чтобы код писался хоть сколько-нибудь одинаково по стилю, то данный анализ превратился в Error :)

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

К счастью, вместе с этим анализатором поставляется и готовый рефакторинг, который вносит предлагаемые изменения. Таким образом в 2 клика можно заменить код в одном месте.
А если в консоли вызвать
dotnet format —diagnostics NUnit2056
то за несколько минут будет обновлен весь проект.

Аналогично можно быстро внести в PublicAPI.Unshipped.txt все новые API, которые обнаружил Microsoft.CodeAnalysis.PublicAPIAnalyzers.

Более того, можно делать dotnet format с нужными диагностиками pre commit hook, что будет автоматически исправлять код :)

Кстати, команду выше запоминать необязательно, в 'https://t.me/sh_dotnet/120?comment=446' rel='nofollow'>Rider в UI можно рефакторинг запустить на весь проект/солюшен из подсвеченного кусочка кода :)


Забыл совсем написать про настройку EnablePackageValidation в csproj, который использует под капотом тот же ApiCompat.
Он создан для разработчиков библиотек чтобы отловить breaking changes до их выпуска в продакшн.

Если задать EnablePackageValidation и PackageValidationBaselineVersion, то при вызове dotnet pack он будет проверять совместимость public API между текущей и базовой версией. Это очень удобно!

Допустим, у библиотеки была версия 1.1.1 (major.minor.patch соответственно).
В качестве базовой (PackageValidationBaselineVersion) задана 1.0.0.

Разработчик реализовал фичу. Для фичи по semver принято поднимать minor версию, поэтому версия поднимается до 1.2.0
Поднятие только minor или patch составляющих подразумевает сохранение обратной совместимости.

Валидатор возьмет API из базовой версии 1.0.0 и сравнит с текущей.
Если мы случайно внесли breaking change, то будет ошибка с описанием где именно проблема, с каким методом и т.п.

И здесь 2 варианта:
- (Лучше всего) поправить breaking change и зарелизить 1.2.0
- Если совсем никак, то поднять major версию до 2.0.0, написать в changelog информацию о breaking change и migration guide. И не забыть поднять PackageValidationBaselineVersion, иначе опубликовать пакет не удастся.

Ниже пример настройки валидатора:




net9.0
1.1.0
true
1.0.0




Так как валидация происходит при вызове dotnet pack, то в CI/CD нужно еще в Merge Request pipeline сделать dotnet pack, чтобы запустился валидатор и проверил на совместимость версий. Сразу поднимать текущую версию необязательно - валидатор все равно покажет ошибки.

Такой подход гораздо удобней применять при разработке своих NuGet пакетов
А для чужих библиотек - ApiCompat как из поста выше.

В тоже время анализатор позволяет ускорить inner loop, так как очевидные breaking changes подсвечивает прямо во время разработки в IDE. Но анализатор это не замена валидатора в CI/CD. Они скорее друг друга дополняют, потому что в CI проверки более глубокие, а анализатор не сложно отключить локально :)


Бывало ли у Вас такое, что обновили библиотеку на minor/patch версию и всё успешно скомпилировалось, но запускаете проект и получаете ошибку в рантайме случайным образом?
Например TypeNotFoundException или MissingMethodException, MissingMemberException и т.п.
Или когда обновили казалось бы на мажорную версию, но при этом всё прекрасно работает.

В первом случае, вероятнее всего, Вы столкнулись с пакетом в котором не особо следят за зависимостями или совместимостью публичного API.
Во втором случае, вероятно Вы не используете функции, совместимость которых была сломана и из-за которых пришлось поднять мажорную версию.
Либо же мажорная версия была присвоена "за компанию", когда разработчик версионирует пакеты вместе и выпускает их все, даже если что-то поменялось в одном. Это достаточно удобно и широко распространенная практика.

Есть несколько видов совместимостей API:

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

За редким исключением, почти все библиотеки в .NET поставляются уже скомпилированными (dll) и используется таковыми.
Поэтому самая коварная здесь - совместимость бинарная. Её легче всего пропустить и надо знать много нюансов.
А всё потому что, казалось бы, внесли незначительное изменение - например добавили необязательный параметр в метод, но бинарная совместимость уже нарушена.
В рантайме будет ошибка т.к. с его точки зрения это новый метод, а старый удалили.
Именно поэтому в BCL можно встретить массу API, где ради бинарной совместимости добавляются новые перегрузки, вместо добавления необязательного параметра в существующие методы.
О том, какие изменения потенциальные breaking changes, можно почитать здесь.

И так, разобрались с совместимостью API, теперь поговорим о такой штуке как "транзитивные зависимости".
Простейший пример - есть пакет (1), который зависит от другого пакета (2). При этом Ваш проект не подключал пакет (2) явно, оно "пришло" вместе с пакетом (1), так как ему она нужна для работы.
Вполне возможно что есть и пакет (3), который тоже зависит от пакета (2), но другой версии.
Хорошо если они отличаются на патч или минорную версию и разработчик пакета (2) следит за совместимостью API библиотеки. Тогда никаких проблем быть не должно.
Но может быть хуже - когда пакеты (1) и (3) ожидают совершенно разные версии и между этими версиями есть breaking changes. Ошибки будут в рантайме.

В .NET каждая библиотека в итоговую сборку попадает лишь один раз. Если к примеру подключены версии 1.0.0 и 1.1.0 - то в итоге будет выбран 1.1.0. Предпочтение всегда идет более свежей версии.
Если к запускаемой сборке явно подключить версию 2.0.0, то в итоговую сборку попадет именно она.
При этом библиотеки будут думать что работают с версией 1.0.0 и 1.1.0, но в реальности будут работать с 2.0.0.

А ведь зависимостей много! И чтобы руками всё не проверять, можно сбилдить любой проект и зайти в bin/Debug/{framework version}/{project name}.deps.json
В этом файле собраны все зависимости проекта и всех пакетов в нем, причем уже с разрешенными версиями.

Если у Вас возник вопрос можно ли обновить такой-то пакет на свежую версию, то лучше всего проверить совместимость с текущей специальными утилитами, например apicompat:
dotnet tool install --global Microsoft.DotNet.ApiCompat.Tool

сравниваем совместимость пакетов с текущей (baseline) и новой версией:
apicompat package "C:\path\to\package.1.0.0.nupkg" --baseline-package "C:\path\to\package.1.1.0.nupkg"
в ответ должны получить APICompat выполнен без обнаружения критических изменений. или же набор отличий между версиями, если они обнаружены.

Если вы разрабатываете библиотеку, то рекомендую подключить анализатор, чтобы контролировать публичные API ваших библиотек и строго придерживаться semver и тогда потребители ваших библиотек будут счастливы :)

20 last posts shown.

319

subscribers
Channel statistics