Привет, Хабр! Меня зовут Сергей Сорокин, я .NET-разработчик с 12-летним стажем. Занимаюсь бэкендом, архитектурой и высокими нагрузками.
Знаю, о чем вы подумали, прочитав заголовок: "О боже, еще одна ORM? В 2025 году? Зачем, если есть Dapper и EF Core?".
Я тоже так думал. Но когда ты работаешь в Enterprise-системах, где производительность критична, а база данных — это не просто хранилище, а мощный инструмент обработки данных, стандартные решения начинают показывать свои слабые места.
Сегодня я хочу рассказать о Visor — ORM, которую мы создали, чтобы превратить работу с базой данных в вызов типизированного API, убрать оверхед рефлексии и решить извечную боль с передачей списков (TVP) в SQL Server. А заодно показать, как Source Generators позволяют писать код, который работает быстрее, чем то, что вы пишете руками.
Философия: База данных как API
Давайте сразу расставим точки над «i». Я не призываю переносить бизнес-логику в хранимые процедуры. Это плохая практика, которая ведет к боли при тестировании и масштабировании. Бизнес-логика должна жить в коде приложения.
Но есть нюанс.
Есть логика доступа к данным. Типовые, тяжелые, оптимизированные выборки. Групповые вставки. Агрегации. Зачем тянуть мегабайты сырых данных в приложение, чтобы отфильтровать их в памяти, если SQL Server сделает это за миллисекунды, имея под рукой индексы и статистику?
Мы пришли к концепции, где База данных выступает как сервис (API).
Endpoint — это хранимая процедура или функция.
Contract — это сигнатура процедуры и Table-Valued Parameters (TVP) или композитные типы (Postgres).
При таком подходе происходит четкое разделение ответственности:
DBA / SQL Developer отвечает за план запроса, индексы и целостность данных. Он предоставляет нам идеальный "черный ящик" — процедуру.
Backend Developer отвечает за бизнес-процессы, оркестрацию и API для фронтенда. Мы не думаем, как база достает пользователей по email. Мы просто вызываем метод
GetUserByEmail.
Золотое правило масштабирования
Чтобы не превратить базу в клубок спагетти, мы следуем строгому правилу: Никакой вложенности. Процедуры должны быть одноуровневыми. Одна процедура не вызывает другую. Это спасает от каскадного рефакторинга и позволяет менять реализацию внутри процедуры, не ломая контракт с бэкендом.
Почему Dapper и EF Core нам не подошли?
Мы искали инструмент, который сочетал бы удобство интерфейсов (как Refit для HTTP) и максимальную производительность (как ручной ADO.NET).
Претензия к Dapper: «Черный ящик» и боль с TVP
Dapper — это стандарт скорости, но:
Runtime Reflection: Вся магия маппинга происходит в рантайме через IL Emit. Это «черный ящик». Вы не видите код маппинга, не можете его отладить. Ошибки (например, несовпадение типов) вылетают только при выполнении.
Table-Valued Parameters (TVP): Это главная боль. Чтобы передать список объектов в процедуру, в Dapper нужно либо создавать
DataTable(что дико аллоцирует память и медленно), либо писать вручную кастомные классы-наследникиIEnumerable<SqlDataRecord>. Это тонна бойлерплейта.«Молчаливые» ошибки: Если вы переименовали колонку в базе, а в DTO забыли — Dapper часто просто оставит поле пустым (
nullили0). В Enterprise это недопустимо. Нам нужен Strict Mapping — если колонки нет, приложение должно упасть с четкой ошибкой, а не работать с некорректными данными.
Претензия к EF Core: Слишком тяжелый
EF Core — отличный комбайн, но для работы с процедурами он избыточен.
Overhead: ChangeTracker, построение графов, Snapshots. В наших тестах на массовых операциях EF потреблял в 60 раз больше памяти, чем наше решение.
Второй сорт: Процедуры в EF всегда ощущаются как костыль сбоку от LINQ.
Visor: Концепция «White Box» (Прозрачный ящик)
Так родился Visor. Главная идея — Source Generators. Мы переносим всю работу по созданию SQL-команд, параметров и маппингу результатов с этапа выполнения (Runtime) на этап компиляции (Compile Time).
Как это выглядит для разработчика?
Вы просто описываете интерфейс, как будто это REST-клиент:
[Visor(VisorProvider.SqlServer)]
public interface IUserRepository
{
// Простой вызов
[Endpoint("sp_GetUserById")]
Task<UserDto> GetUserAsync(int id);
// Массовая вставка (TVP)
[Endpoint("sp_ImportUsers")]
Task ImportUsersAsync(List<UserItemDto> users);
}
Во время сборки Visor генерирует класс UserRepository, который реализует этот интерфейс.
Киллер-фича: Zero Allocation TVP Streaming
Самое интересное — как мы реализовали передачу списков. Вместо создания DataTable (как это делают почти все), генератор создает код, который использует IEnumerable<SqlDataRecord> с yield return.
Что это дает? Мы стримим данные из вашего List<UserDto> напрямую в сетевой поток SQL Server. Без промежуточных буферов, без копирования массивов, без лишних аллокаций памяти.
Сгенерированный код выглядит примерно так (упрощенно):
private static IEnumerable<SqlDataRecord> MapToSqlDataRecord(IEnumerable<UserDto> rows)
{
var record = new SqlDataRecord(metadata);
foreach (var row in rows)
{
record.SetInt32(0, row.Id);
record.SetString(1, row.Name);
yield return record; // <--- Магия здесь
}
}
Бенчмарки: Момент истины
Мы сравнили вставку 10 000 записей в MS SQL Server через процедуру с TVP. Соперники:
Visor (TVP Streaming)
EF Core 10 (Bulk Insert / AddRange)
Dapper (Стандартная вставка в цикле / Execute)
Результаты (BenchmarkDotNet):
Method |
Time (Mean) |
Memory Allocated |
GC Gen0/1/2 |
Visor (TVP) |
51.82 ms |
1.07 MB |
0 / 0 / 0 |
EF Core 10 |
517.73 ms |
65.04 MB |
8 / 3 / 1 |
Dapper |
43,069.73 ms |
15.34 MB |
1 / 0 / 0 |
Выводы:
Visor быстрее EF Core в 10 раз. И потребляет в 60 раз меньше памяти.
Visor быстрее Dapper (loop) в 800 раз. Конечно, Dapper можно ускорить, если вручную реализовать
SqlDataRecord, но Visor делает это за вас автоматически.Zero GC: Обратите внимание на колонку GC. Visor не создал ни одного мусорного объекта в поколениях 0/1/2.
Итоги
Мы создали инструмент не для того, чтобы «убить» EF Core или Dapper. Мы создали его для конкретной ниши: High-Load Enterprise системы, где база данных используется на полную мощность через хранимые процедуры.
Что дает Visor:
Скорость: Работает на уровне ручного ADO.NET.
Надежность: Строгая типизация и валидация схемы на этапе компиляции.
Чистота: Ваш код не зависит от деталей реализации доступа к данным.
Мульти-провайдерность: Сейчас поддерживается MSSQL и PostgreSQL (да, там мы используем массивы и композитные типы, но API для разработчика остается тем же).
Проект полностью Open Source. Если вам близок подход «Database as an API» или вы просто хотите посмотреть, как работают Source Generators в .NET 10 — добро пожаловать в репозиторий.
Буду рад конструктивной критике и пул-реквестам!
Комментарии (6)

ALexKud
30.11.2025 15:30Хороший подход. Но у меня не знтерпрайз -приложения и в общем широкое использование хранимых процедур, где сконцентрирована обработка данных и возврат результатов. Насчет использования tvp пока не задумывался, возможно надо тему лучше изучить. Пока хватает оптимизации в sql server планов запросов, приемов создания sql кода и использования cte,
00username
npgsqlдрайвер не поддерживаетDataTable, т.е с PostgreSQL не заведется.Вместо
SqlDataRecordможно использовать реализовать DbDataReader, который будет итерировать по свойствам-колонкам и отдавать данные. Поддерживается ADO из коробки, кроме того, нет надобности тянутьMicrosoft.SqlServer.*специфичные пространства имен.Что касается аллокации и скорости, то тут можно пулинг использовать, постоянно пересоздавать
DataTableнеобязательно (но не уверен на счетDataRow, вероятно их потребуется пересоздавать, но экономия все-таки будет). Метаданные (информация о колонках) так или иначе статичная, ее можно сохранить на уровне класса иDataTable(если пулить).И не факт, что кода будет больше при таких модификациях (но скорость, конечно, надо замерять), но точно проще чем код для Source Generator
AcheronSoft Автор
Спасибо за дельный комментарий! Вы затронули важные архитектурные моменты, давайте проясним.
Про PostgreSQL и DataTable: Вы абсолютно правы, Npgsql не работает с TVP через DataTable так, как это делает MSSQL. Именно поэтому в Visor реализован паттерн Strategy. Для MSSQL генератор создает код с SqlDataRecord, а для PostgreSQL - использует нативную передачу массивов (Arrays) и композитных типов, которые Npgsql поддерживает из коробки. Никаких DataTable в реализации для Postgres нет, я работаю с драйвером напрямую.
Про DbDataReader vs SqlDataRecord: Идея с DbDataReader отличная для SqlBulkCopy, но я работаю с хранимыми процедурами. В Microsoft.Data.SqlClient параметр типа Structured (TVP) принимает либо DataTable, либо IEnumerable. Передать туда DbDataReader нельзя - API драйвера этого не позволяет. Мой подход с IEnumerable + yield return как раз и реализует ленивый стриминг данных (по сути то, что вы предлагаете), но в рамках ограничений SqlParameter.
Про аллокации и пулинг: Пулинг DataTable - это рабочее решение, но оно требует управления состоянием (вернуть в пул, очистить строки). Мой подход - Zero Allocation. Я вообще не создаю промежуточных буферов (ни DataTable, ни DataRow). Данные читаются из свойств POCO-объекта и сразу пишутся в сетевой поток через SqlDataRecord. Это проще (не нужен пул) и быстрее, так как нет накладных расходов на обслуживание структур данных.
Итог: Я не тяну Microsoft.Data.SqlClient в Postgres. У меня модульная архитектура: Visor.SqlServer зависит от SqlClient, а Visor.PostgreSql - только от Npgsql.
00username
Либо я что-то путаю, либо внутри оно все-таки работает. Некоторое время назад, как раз, реализовывал то, о чём писал выше (+
npgsqlплагин для обработкиDataTable,DbDataReader, логика представления композитных типов точно такая же).Входных ограничений на тип параметра нет, но под котопом он как раз перепроверяется (ссылка выше).
Получается обертка, которая реализует
DbDataReader, внутри держимIEnumerable<TRow>, внутри вызоваReadвызываетсяMoveNextдля нашей коллекции и все работает отлично (и по ходу дела драйвер дергает методы вродеGetDataTypeName/GetChar/GetString/etcв которых мы отдаем метаданные/данные нашей коллекции).С
Microsoft.Data.SqlClientверсии 6.0.2 точно работает.AcheronSoft Автор
Вы правы, технически можно подружить Npgsql с DataTable через расширения или плагины, и реализовать DbDataReader для стриминга тоже можно (это валидный паттерн).
Но моя цель была не просто "сделать, чтобы работало", а убрать накладные расходы (Zero Allocation).
Про DataTable: Даже если заставить драйвер его принять, сам DataTable - это тяжелая структура. Его создание, заполнение DataRow (боксинг значений) и хранение в памяти - это лишние аллокации. В высоконагруженной системе (3.5 млн RPS) это создает давление на GC. Мой подход берет обычный List, который уже есть в бизнес-логике, и мапит его в базу без промежуточного создания DataTable.
Про DbDataReader vs IEnumerable: Реализация кастомного DbDataReader - это отличное решение для стриминга, но оно требует написания большого количества бойлерплейта. Метод с yield return new SqlDataRecord (для MSSQL) делает ровно то же самое - обеспечивает потоковую передачу данных драйверу по мере их чтения, но генерируется гораздо проще и работает "из коробки" с SqlClient.
По сути, я боролся за то, чтобы между List в коде и сетевым пакетом в базу было 0 лишних преобразований.
00username
В итоге, как я понимаю, сейчас две отдельные реализации, которые работают совершенно по-разному. В случае единой системы типов можно написать более универсальный код, но для этого потребуется плагин к драйверу
npgsql(как в моём случае).Мысли вслух