Про Result pattern
В C# основной способ обработки ошибок - Exceptions. Под него заточено много инструментария.
Есть удобный stacktrace, конструкции обработки ошибок try catch, exception filters и прочее.
И самый главный плюс - выброшенное исключение невозможно проигнорировать - кто-то его обязан обработать.
Но есть и минусы:
1. Производительность
Исключения жрут много ресурсов. Основная причина - необходимость в stack unwinding, в поисках того кто выше по стеку сможет обработать исключение чтобы войти в catch блок.
Еще из-за stack unwinding мы теряем данные в AsyncLocal, а следовательно всё что реализовано на его основе, например LoggingScope создаваемый с помощью ILogger.BeginScope будут тоже потеряны.
Исключения в асинхронном коде еще кратно дороже из-за множества стейт машин которые то и дело ловят и перевыбрасывают исключения, пока не дойдут до самого верха стека. А если еще сделать асинхронные middlware (чем пронизан современный .NET) , то там всё еще печальней.
К счастью, майкрософт работает над улучшением производительности и в .NET 9 они уже оптимизировали обработку исключений от 2 до 4 раз.
Возможно, еще когда .NET откажется от async/await на уровне языка в пользу реализации её в рантайме (эксперимент async2),
то поведение AsyncLocal починится и перформанс обработки ошибок заметно станет лучше и будет все хорошо.
2. Исключения - неявный контракт.
Ваш метод может выбросить любое исключение, но оно никак не будет отражено в контрактах (сигнатуре) метода.
Максимум что можно сделать, это добавить в документацию метода.
///
/// When was invalid.
///
Но можно ли доверять этому? Метод может вызывать что-нибудь из BCL, который допустим выбросит InvalidOperationException.
Это что, теперь всевозможные виды исключений перечислять в каждом методе? Никогда не знаешь что будет выброшено.
Альтернативой этому подходу является Result pattern, который является основным способом обработки ошибок в таких языках как Rust и Go, и по факту является монадой т.е. одной из основ функциональных ЯП.
Для наглядности я отрефакторил приложение BookLibrary: перевёл юзкейсы и агрегаты на него. Также сохранил всю функциональность кодов ошибок, форматы ответов API в ProblemDetails, логирование и сбор метрик.
А чтобы бенчмарк был актуален - обновил до .NET 9
Стало определенно лучше с точки зрения контракта.
Я теперь точно знаю по сигнатуре метода может ли он вернуть ошибку. В тоже время это меня теперь обязывает обрабатывать результаты вызовов каждого такого метода, но об этом можно легко забыть и занести баг :)
Здесь очень нужна языковая поддержка и статические анализаторы.
В целом сама концепция хороша тем, что заставляет заранее продумать различные ошибочные сценарии.
Теперь что касается перформанса, здесь всё еще интересней:
Я решил сделать нагрузочное тестирование на API, которое возвращает книгу по его идентификатору.
Тестов будет 2:
Лучший кейс - 100% запросов вернут книгу
Худший кейс - 100% запросов не найдут книгу в бд
Логирование в консоль отключено, чтобы не влияло на результаты.
Проект запущен через консоль, сбилжено в релизной конфигурации. Postgres 16ый в докере.
Получились такие результаты.
Result pattern (Худший кейс):
'https://t.me/sh_dotnet/97?comment=279' rel='nofollow'>100 VUs p95 13ms RPS 13703
'https://t.me/sh_dotnet/97?comment=280' rel='nofollow'>150 VUs p95 19.23ms RPS 13644
'https://t.me/sh_dotnet/97?comment=281' rel='nofollow'>200 VUs p95 24.13ms RPS 13323
Result pattern (Лучший кейс):
'https://t.me/sh_dotnet/97?comment=282' rel='nofollow'>100 VUs p95 12.99ms RPS 13667
Exception (Худший кейс):
'https://t.me/sh_dotnet/97?comment=283' rel='nofollow'>100 VUs p95 171.77ms RPS 817
'https://t.me/sh_dotnet/97?comment=284' rel='nofollow'>150 VUs p95 232.87ms RPS 969
'https://t.me/sh_dotnet/97?comment=285' rel='nofollow'>200 VUs p95 353.16ms RPS 880
Exception (Лучший кейс):
'https://t.me/sh_dotnet/97?comment=286' rel='nofollow'>100 VUs p95 14.41ms RPS 13241
У исключений высокая цена если использовать их не по назначению.
Исключения только для исключительных ситуаций, когда дальнейшая обработка невозможна или это может испортить данные.
Если Ваше приложение высоконагруженное и чувствительное к latency и работает в очень нестабильных условиях (много сетевых вызовов) - Result pattern ваш выбор :)
Причем необязательно всё приложение переводить на Result. Достаточно там, где нестабильность максимальная.
В комментарии закинул скриншоты нагрузочных тестов.
В C# основной способ обработки ошибок - Exceptions. Под него заточено много инструментария.
Есть удобный stacktrace, конструкции обработки ошибок try catch, exception filters и прочее.
И самый главный плюс - выброшенное исключение невозможно проигнорировать - кто-то его обязан обработать.
Но есть и минусы:
1. Производительность
Исключения жрут много ресурсов. Основная причина - необходимость в stack unwinding, в поисках того кто выше по стеку сможет обработать исключение чтобы войти в catch блок.
Еще из-за stack unwinding мы теряем данные в AsyncLocal, а следовательно всё что реализовано на его основе, например LoggingScope создаваемый с помощью ILogger.BeginScope будут тоже потеряны.
Исключения в асинхронном коде еще кратно дороже из-за множества стейт машин которые то и дело ловят и перевыбрасывают исключения, пока не дойдут до самого верха стека. А если еще сделать асинхронные middlware (чем пронизан современный .NET) , то там всё еще печальней.
К счастью, майкрософт работает над улучшением производительности и в .NET 9 они уже оптимизировали обработку исключений от 2 до 4 раз.
Возможно, еще когда .NET откажется от async/await на уровне языка в пользу реализации её в рантайме (эксперимент async2),
то поведение AsyncLocal починится и перформанс обработки ошибок заметно станет лучше и будет все хорошо.
2. Исключения - неявный контракт.
Ваш метод может выбросить любое исключение, но оно никак не будет отражено в контрактах (сигнатуре) метода.
Максимум что можно сделать, это добавить в документацию метода.
///
/// When was invalid.
///
Но можно ли доверять этому? Метод может вызывать что-нибудь из BCL, который допустим выбросит InvalidOperationException.
Это что, теперь всевозможные виды исключений перечислять в каждом методе? Никогда не знаешь что будет выброшено.
Альтернативой этому подходу является Result pattern, который является основным способом обработки ошибок в таких языках как Rust и Go, и по факту является монадой т.е. одной из основ функциональных ЯП.
Для наглядности я отрефакторил приложение BookLibrary: перевёл юзкейсы и агрегаты на него. Также сохранил всю функциональность кодов ошибок, форматы ответов API в ProblemDetails, логирование и сбор метрик.
А чтобы бенчмарк был актуален - обновил до .NET 9
Стало определенно лучше с точки зрения контракта.
Я теперь точно знаю по сигнатуре метода может ли он вернуть ошибку. В тоже время это меня теперь обязывает обрабатывать результаты вызовов каждого такого метода, но об этом можно легко забыть и занести баг :)
Здесь очень нужна языковая поддержка и статические анализаторы.
В целом сама концепция хороша тем, что заставляет заранее продумать различные ошибочные сценарии.
Теперь что касается перформанса, здесь всё еще интересней:
Я решил сделать нагрузочное тестирование на API, которое возвращает книгу по его идентификатору.
Тестов будет 2:
Лучший кейс - 100% запросов вернут книгу
Худший кейс - 100% запросов не найдут книгу в бд
Логирование в консоль отключено, чтобы не влияло на результаты.
Проект запущен через консоль, сбилжено в релизной конфигурации. Postgres 16ый в докере.
Получились такие результаты.
Result pattern (Худший кейс):
'https://t.me/sh_dotnet/97?comment=279' rel='nofollow'>100 VUs p95 13ms RPS 13703
'https://t.me/sh_dotnet/97?comment=280' rel='nofollow'>150 VUs p95 19.23ms RPS 13644
'https://t.me/sh_dotnet/97?comment=281' rel='nofollow'>200 VUs p95 24.13ms RPS 13323
Result pattern (Лучший кейс):
'https://t.me/sh_dotnet/97?comment=282' rel='nofollow'>100 VUs p95 12.99ms RPS 13667
Exception (Худший кейс):
'https://t.me/sh_dotnet/97?comment=283' rel='nofollow'>100 VUs p95 171.77ms RPS 817
'https://t.me/sh_dotnet/97?comment=284' rel='nofollow'>150 VUs p95 232.87ms RPS 969
'https://t.me/sh_dotnet/97?comment=285' rel='nofollow'>200 VUs p95 353.16ms RPS 880
Exception (Лучший кейс):
'https://t.me/sh_dotnet/97?comment=286' rel='nofollow'>100 VUs p95 14.41ms RPS 13241
У исключений высокая цена если использовать их не по назначению.
Исключения только для исключительных ситуаций, когда дальнейшая обработка невозможна или это может испортить данные.
Если Ваше приложение высоконагруженное и чувствительное к latency и работает в очень нестабильных условиях (много сетевых вызовов) - Result pattern ваш выбор :)
Причем необязательно всё приложение переводить на Result. Достаточно там, где нестабильность максимальная.
В комментарии закинул скриншоты нагрузочных тестов.