Привет, Хабр! Меня зовут Сергей Сорокин, я .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 — добро пожаловать в репозиторий.
Буду рад конструктивной критике и пул-реквестам!