Это вторая версия статьи. Первая версия: https://habr.com/ru/articles/906522/
Введение
В мире современной разработки на .NET Core и Entity Framework Core (EF Core) доминирует подход Domain-Driven Design (DDD), где бизнес-логика преимущественно сосредоточена в коде приложения. LINQ, как часть EF Core, предлагает удобный и типобезопасный способ взаимодействия с базой данных, абстрагируя разработчика от деталей SQL. Однако, как подсказывает 25-детний опыт автора программирования баз данных MS SQL Server и обсуждение к предыдущей версии этой статьи, такой подход не всегда является оптимальным, особенно когда речь идет о сложных запросах, аналитике и работе с большими объемами данных.
Эта статья, переработанная с учетом ценных комментариев и разъяснений, предлагает взглянуть на альтернативный, гибридный подход, который сочетает преимущества EF Core с мощью T-SQL, языка, нативно оптимизированного для работы с реляционными данными в Microsoft SQL Server. Мы рассмотрим, как можно эффективно использовать возможности T-SQL (представления, хранимые процедуры, пользовательские функции) в связке с EF Core, чтобы достичь лучшей производительности и упростить разработку в определенных сценариях.
Переосмысление роли СУБД: От универсального движка к специализированному инструменту
Часто, приверженцы Domain-Driven Design (DDD) и ООП-разработки склонны воспринимать СУБД, такие как MSSQL, как некий универсальный SQL-движок, ограниченный функционалом, определенным в ANSI SQL. При этом, мощные дополнительные возможности проприетарных диалектов, таких как T-SQL, остаются за периферией внимания. Эта "несправедливость" и нерачительность упускает огромный потенциал для оптимизации и упрощения разработки в массе индивидуальных случаев.
С точки зрения машины для решения бизнес-задач, MSSQL является чрезвычайно гибким конструктором. Настроив его с использованием T-SQL, можно превратить его в совершенно иную, уникальную сущность сервера. Эта сущность будет способна с эффективностью чистого SQL обрабатывать расширенные запросы сколь угодно более высокого абстрактного уровня бизнес-логики, нежели просто операции с плоскими таблицами (CRUD). MSSQL может быть перенастроен в Workflow Engine, в Message Queue Engine и т.п.
Таким образом, бизнес-логику, которая также может делиться на изолированные слои, частично можно поместить внутрь движка MSSQL и уже с ней работать из C# EF LINQ. Это не отказ от Domain-Driven Design (DDD), а скорее его расширение, признающее, что наиболее эффективное место для выполнения определенной логики может находиться ближе к данным. Во многих случаях преимущества от привязки к конкретному движку сторицей оправдывают отказ от эфемерной универсальности и независимости от конкретной реализации СУБД, принятой в Domain-Driven Design (DDD) за базовый принцип.
Суть гибридного подхода: Data-Driven Design в контексте Domain-Driven Design (DDD)
В основе гибридного подхода лежит идея о том, что бизнес-логика, по своей природе, может быть распределенной. Вместо того, чтобы стремиться сконцентрировать всю логику исключительно в приложении (Domain-Driven Design - DDD), мы можем осознанно переносить часть ее в ядро базы данных (Data-Driven Design - DDD в другом смысле), особенно ту часть, которая тесно связана с манипуляциями данными и вычислениями.
Как справедливо отметил один из комментаторов, вопрос "где большая ценность сосредоточена: в кодах или в данных?" является фундаментальным и не имеет однозначного ответа. В контексте Data-Driven Design (DDD), данные и их эффективная обработка выходят на первый план. Архитектор или команда принимают решение о том, где целесообразнее реализовать ту или иную часть бизнес-логики, исходя из таких факторов, как:
Сложность запроса: Многоэтапные запросы с использованием CTE, оконных функций, рекурсии или сложной агрегации могут быть значительно проще и понятнее реализованы на T-SQL, чем на LINQ.
Производительность: T-SQL, будучи нативным языком СУБД, часто обеспечивает более высокую производительность для сложных операций с данными по сравнению с LINQ, который генерирует SQL-запросы, не всегда оптимальные.
Объем данных: Работа с большими объемами данных, пакетная обработка и аналитические запросы находят более эффективное решение на уровне базы данных.
Специализированные возможности СУБD: Использование специфических функций T-SQL (например, для работы с XML, JSON, пространственными данными) может быть затруднительно или невозможно через LINQ.
Почему LINQ не всегда идеален для сложных задач?
Обсуждение выявило несколько ключевых моментов, подтверждающих ограничения LINQ в сложных сценариях:
Отсутствие прямого преобразования T-SQL в LINQ: Как было отмечено, нет прямого соответствия между всеми возможностями T-SQL и LINQ. Это означает, что сложные T-SQL конструкции (например, CTE) могут быть трудно или невозможно перевести в эквивалентные LINQ-выражения, что приводит к громоздкому и менее читаемому коду на C#.
Генерация неоптимальных SQL-запросов: EF Core, несмотря на свои улучшения, не всегда генерирует наиболее эффективные SQL-запросы, особенно для сложных сценариев. Это может приводить к проблемам с производительностью, таймаутам и дедлокам, как справедливо указал один из участников обсуждения.
Ограниченный контроль над планом выполнения: Разработчик, использующий LINQ, имеет ограниченный контроль над тем, как СУБД будет выполнять запрос. В T-SQL же есть возможность использовать подсказки (hints) и анализировать план выполнения для тонкой настройки производительности.
Сложность отладки сложных LINQ-выражений: Отладка сложных LINQ-выражений, которые преобразуются в многострочные SQL-запросы, может быть непростой задачей.
В значительной степени "нелюбовь" к проприетарным функциям СУБД и даже универсальному SQL продиктована у C# девелоперов довольно поверхностным знакомством с ними. Поэтому такие разработчики считают благом отсутствие необходимости писать код на SQL или T-SQL. Но это аргумент слабости и жертвы. С появлением и развитием ИИ таким разработчиками становится проще управлять внутренним кодом СУБД. Цель данной статьи обратить на это внимание и вызвать потенциальный интерес к скрытым возможностям гибридного подхода, которые не учитываются и игнорируются сообществом, в результате либо страдает качество финального продукта, либо увеличивает сложность приложений за счет обходных путей борьбы с ограничениями производительности и гибкости LINQ.
Интеграция T-SQL в EF Core: Практические способы и суть кодирования
Статья изначально выделяла четыре основных способа интеграции T-SQL. Обсуждение показало, что это деление может быть воспринято по-разному, особенно с точки зрения SQL-разработчика. Однако, с точки зрения .NET-разработчика, эти способы представляют собой различные интерфейсы взаимодействия с базой данных через EF Core. Давайте рассмотрим их подробнее, с учетом контекста обсуждения и добавим краткую суть кодирования. Важно отметить, что бизнес логика, кристаллизованная внутри MSSQL, конечно должна быть качественно реализована и задокументирована, чтобы снизить риски ее изменения в дальнейшем и облегчить работу C# разработки.
-
Сырой SQL (FromSqlRaw/FromSqlInterpolated): Этот подход позволяет выполнять произвольные SQL-запросы напрямую из EF Core. Он полезен для выполнения запросов, которые сложно или невозможно выразить с помощью LINQ, или для использования специфических возможностей T-SQL.
Суть кодирования: Используются методы
FromSqlRaw
илиFromSqlInterpolated
объектаDbSet
. В качестве аргумента передается строка с T-SQL запросом. Результат запроса маппится на сущность или анонимный тип.-
Пример:
var result = context.Products .FromSqlRaw("SELECT ProductId, ProductName FROM Products WHERE Category = {0}", category) .ToList();
Преимущества: Полный контроль над SQL-запросом, возможность использовать любые конструкции T-SQL.
Недостатки: Потеря типобезопасности, необходимость вручную управлять параметрами (хотя FromSqlInterpolated упрощает это), усложнение поддержки и отладки по сравнению с LINQ. Как было отмечено, это может быть "сырой" код, который требует внимательного отношения.
-
Представления (Views): Представления в SQL Server представляют собой виртуальные таблицы, основанные на результате запроса. Они могут инкапсулировать сложную логику выборки данных. EF Core может работать с представлениями как с обычными сущностями.
Суть кодирования: В
DbContext
определяетсяDbSet
для представления. В методеOnModelCreating
указывается, что сущность маппится на представление, а не на таблицу, с помощьюToView("ИмяПредставления")
.-
Пример:
public class MonthlyCategoryRevenue { public string SaleMonth { get; set; } public string Category { get; set; } public decimal MonthlyRevenue { get; set; } public int NumberOfSales { get; set; } } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<MonthlyCategoryRevenue>().ToView("MonthlyCategoryRevenueView"); // Дополнительная настройка, если требуется }
Затем можно выполнять LINQ-запросы к
DbSet
. Преимущества: Инкапсуляция сложной логики выборки на уровне БД, упрощение LINQ-запросов к представлению, возможность повторного использования логики представления в разных частях приложения. Как было подчеркнуто в обсуждении, View, в отличие от Table, содержит в себе логику, что делает его мощным инструментом для Data-Driven Design (DDD).
Недостатки: Представления могут иметь ограничения на операции вставки, обновления и удаления (хотя обновляемые представления существуют), могут быть менее производительными, чем прямые запросы к таблицам в некоторых случаях.
-
Пользовательские функции (User-Defined Functions - UDF): UDF позволяют инкапсулировать логику вычислений или выборки данных в виде функции, которую можно вызывать в SQL-запросах. EF Core поддерживает вызов скалярных и табличных UDF.
Суть кодирования: В
DbContext
определяется метод, который будет представлять UDF. Этот метод помечается атрибутом[DbFunction]
или настраивается вOnModelCreating
. Для табличных UDF определяется сущность, на которую маппится результат функции.-
Пример (скалярная UDF):
public static class MyDbFunctions { [DbFunction("CalculateDiscount", "dbo")] public static decimal CalculateDiscount(decimal price, int quantity) { throw new NotSupportedException(); // Этот метод не выполняется в C# } } // Использование в LINQ: var discountedPrices = context.Sales .Select(s => MyDbFunctions.CalculateDiscount(s.Price, s.Quantity)) .ToList();
-
Пример (табличная UDF):
public class ProductsByCategory { public int ProductId { get; set; } public string ProductName { get; set; } } public DbSet<ProductsByCategory> GetProductsByCategory(string category) { throw new NotSupportedException(); } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.HasDbFunction(typeof(MyDbContext).GetMethod(nameof(GetProductsByCategory)), b => b.HasName("GetProductsByCategory").HasSchema("dbo")); modelBuilder.Entity<ProductsByCategory>().HasNoKey(); // Табличные функции часто не имеют ключа } // Использование: var products = context.GetProductsByCategory("Electronics").ToList();
Преимущества: Повторное использование логики, упрощение сложных вычислений в запросах, возможность использования в LINQ-запросах (для скалярных UDF).
Недостатки: UDF могут иметь ограничения на операции с данными (например, нельзя выполнять DML-операции внутри скалярных UDF), могут быть менее производительными, чем эквивалентная логика, реализованная напрямую в запросе.
-
Хранимые процедуры (Stored Procedures - SP): Хранимые процедуры представляют собой блоки T-SQL кода, которые хранятся и выполняются на сервере базы данных. Они могут содержать сложную бизнес-логику, включая DML-операции, управление транзакциями и вызов других SP или UDF. EF Core позволяет вызывать хранимые процедуры и получать результаты.
Суть кодирования: Используются методы
FromSqlRaw
илиFromSqlInterpolated
для вызова хранимой процедуры. Результат маппится на сущность или анонимный тип. Для хранимых процедур, выполняющих DML-операции, используется методExecuteSqlRaw
илиExecuteSqlInterpolated
.-
Пример (выборка данных):
var result = context.Set<ProductSummary>() .FromSqlInterpolated($"EXEC GetProductSummary @productId = {productId}") .ToList();
-
Пример (выполнение DML):
context.Database.ExecuteSqlInterpolated($"EXEC UpdateProductPrice @productId = {productId}, @newPrice = {newPrice}");
Преимущества: Инкапсуляция сложной бизнес-логики на уровне БД, повышение производительности (за счет компиляции и кэширования), улучшение безопасности (ограничение прямого доступа к таблицам), возможность управления транзакциями на уровне БД. Как было отмечено, для выполнения сложных процедур "все делать через EF - рехнуться можно".
Недостатки: Снижение типобезопасности при работе с результатами SP в C#, усложнение отладки по сравнению с C# кодом, необходимость синхронизации изменений в SP и коде приложения.
Когда применять гибридный подход?
Гибридный подход не является универсальным решением и не должен полностью заменять LINQ. Он наиболее эффективен в следующих сценариях:
Сложные аналитические отчеты: Генерация отчетов с использованием агрегации, оконных функций, CTE и других сложных SQL-конструкций.
Обработка иерархических данных: Работа с древовидными структурами (например, организационные структуры, многоуровневые каталоги, спецификации).
Критичные по производительности запросы: Оптимизация запросов, которые являются узким местом в приложении.
Пакетная обработка данных: Быстрая загрузка или обработка больших объемов данных.
Реализация сложной бизнес-логики, тесно связанной с данными: Триггеры, ограничения на уровне БД, сложные процедуры изменения данных.
Пример из обсуждения: Сложный аналитический запрос
Приведенный в обсуждении пример T-SQL запроса с использованием CTE для расчета скользящей средней выручки по категориям является отличной иллюстрацией сценария, где T-SQL демонстрирует свои преимущества. Попытка реализовать подобную логику исключительно на LINQ, вероятно, привела бы к гораздо более громоздкому, менее читаемому и потенциально менее производительному коду.
Вызовы и компромиссы
Гибридный подход не лишен своих вызовов:
Разделение ответственности: Как было отмечено, "размазывание" бизнес-логики между приложением и базой данных может усложнить понимание системы в целом. Требуется четкое определение границ ответственности и хорошая документация.
Поддержка и отладка: Отладка кода, распределенного между C# и T-SQL, может быть сложнее.
Синхронизация изменений: Изменения в схеме БД или T-SQL объектах требуют синхронизации с кодом приложения.
Зависимость от конкретной СУБД: Использование специфических возможностей T-SQL делает приложение менее переносимым на другие СУБД.
Миграции и Code-First: Не препятствие для гибридного подхода
Важно подчеркнуть, что гибридный подход, описанный в этой статье, не противоречит использованию Entity Framework Core и его возможностей. Напротив, он показывает, как EF Core может служить эффективным инструментом для взаимодействия с функционалом MSSQL, настроенным "изнутри". Методы, описанные выше (FromSqlRaw/FromSqlInterpolated, маппинг на представления, вызов UDF и SP), являются легальными и предусмотренными Microsoft способами работы с базой данных через EF Core.
Таким образом, миграции и подход Code-First в Entity Framework не являются препятствием для реализации гибридного подхода. Вы можете использовать Code-First для управления схемой таблиц, а затем дополнять функционал базы данных представлениями, хранимыми процедурами и пользовательскими функциями, к которым будет осуществляться доступ из приложения через EF Core.
Резюме
Представленный в статье гибридный подход к разработке на .NET Core с использованием EF Core и T-SQL — это не просто техническое решение, а смещение акцентов в сторону Data-Driven Design (DDD) в рамках более широкого Domain-Driven Design (DDD). В его основе лежит признание фундаментальной ценности данных. В отличие от подходов, где алгоритмы (коды) считаются основным капитальным активом, Data-Driven Design (DDD) ставит базу данных в центр приложения, рассматривая сами данные как главное "золото" в экономическом смысле.
Этот взгляд на вещи не является универсальным, но, вероятно, применим как минимум в половине случаев. Гибридный подход, признавая эту ценность данных, предлагает осознанно переносить часть бизнес-логики туда, где она может быть обработана наиболее эффективно — в ядро СУБД, используя мощь T-SQL. Это позволяет достичь высокой производительности и упростить разработку сложных операций с данными, которые трудно или неэффективно реализовать исключительно средствами LINQ.
Важно понимать, что этот подход требует осознанного выбора и понимания компромиссов. Он не является панацеей и не должен полностью заменять LINQ. Однако, в определенных сценариях, где критична производительность, сложность запросов или работа с большими объемами данных, гибридный подход может стать ключом к созданию более эффективных, масштабируемых и поддерживаемых приложений.
Обсуждение под предыдущей версией статьи показало, что тема гибридного подхода вызывает живой интерес и различные точки зрения. Это подтверждает актуальность и неоднозначность данного вопроса в современном мире разработки. Надеюсь, эта переработанная версия статьи, обогащенная контекстом дискуссии, поможет читателям лучше понять суть гибридного подхода и принять обоснованные решения при проектировании своих систем.
Комментарии (13)
totsamiynixon
31.05.2025 03:22Можно попробовать всю бизнес логику агрегатов написать на хранимых процедурах и вероятнее будет работать. Есть агрегат, необходимо выполнить команду для перевода его из состояния А в состояние Б, проверить возможность перехода, валидацию входных данных и тд. Возможно ли это на хранимых процедурах? В принципе да . Но лучшее ли это решение? Есть куда более продвинутые языки, чем SQL, которые более элегантно и лаконично решат такую задачу. Помимо всего прочего, я плохо себе представляю юнит тестирование хранимых процедур. Как там с фреймворками для тестирования как там с расчетом кавереджа? Рискну предположить, что никак.
Так что по идее обработку command лучше делать на каком-то logic-driven языке типа C#. Там тебе и ООП, где-то с оттенками функциональщины и АОП. А вот query по натуре data-driven и их реализовывать на вьюхах и хранимках как будто самое то. Query абстрагировать от хранилища данных и их типа невозможно.
Далее для оркестрации условного веб приложения вам все равно нужен язык, который умеет делать http запросы в сеть, умеет работать с брокерами сообщений и тд.
В целом гибрид это всегда путь, к результату можно придти и по нему.
terabucks Автор
31.05.2025 03:22Спасибо за Ваш комментарий.
Вы верно выбрали пример агрегата. Да, бизнес логику можно сделать на T-SQL. Да, это другой образ мышления и парадигма, прямого аналога юнит-тестированию я не подскажу. Но мой личный опыт показывает, что да, тестирование кода конечно производится по ходу разработки. В итоге, если все сделано грамотно, работает очень надежно годами, если не десятилетиями. В SQL существует достаточно развитая типизация данных. Есть TRY..CATCH... Обычно решения получаются железобетонные. Да могут появиться в работе какие-то сбои, но все это внутри сервера довольно хорошо диагностируется, находится и исправляется, если на первоначальном этапе закралась ошибка. Конечно все зависит от квалификации разработчика и степени его осознания, что он творит и в какой предметной области.
Заканчивая ответ на Ваш вопрос, главное преимущество и наиболее заметная разница наступает в случае массовой обработки агрегатов (как одно действие). Например несколько тысяч. Решение на C# и EF будет работать точно медленнее и возможно на пару порядков раз.
Еще раз подчеркиваю, сравнивать надо между C# LINQ и T-SQL при достаточно квалифицированном подходе в каждой области. T-SQL предоставляет множество возможностей, о которых не глубокие специалисты не имеют представления.
terabucks Автор
31.05.2025 03:22Кроме вышеописанного примера, наверное одно из основных преимуществ SQL в том, что на нем проще писать код с мгновенной проверкой на соответствие со сложными структурами данных, по ходу разработки. Что просто позволяет намного быстрее находить нужные решения. Когда все процедуры и их параметры определены, их легко инкапсулировать и вызывать через SQL, LINQ, EF из C# приложений. На мой взгляд это также на порядки быстрее и проще, т.к. существенно меньше накладных расходов и посредников в виде ненужной разработки промежуточной системы классов, методов и т.д...
В этом суть ориентации на подход Data-Driven Design. На T-SQL, в принципе, можно сделать всё.
Важное замечание. В ряде случаев, при этом, работа с данными необходима через ООП и ORM, обычно для пользовательских интерфейсов и обработки единичных экземпляров агрегатов. Хотя ORM может быть очень легковесным при этом, т.к. любая логика может быть на SQL.
totsamiynixon
31.05.2025 03:22В целом выглядит интересно. Я бы посмотрел на проект, реализованный таким образом.
Но есть одно ограничение, насколько я знаю такое невозможно сделать на всяких key/value базах вроде DynamoDB. Ну вроде как ещё Mongo поддерживает хранимые процедуры. Можно и в Redis такое добавлять на lua.
И тут сразу приходит в голову завязка бизнес логики на движок данных. Т.е. условно в процессе разработки и жизнедеятельности приложения выясняется, что наилучшим решением будет другой движок. И теперь задача на только мигрировать данные в другой движок (с час справляются инструменты вроде Debezium), но ещё и как-то перенести логику. С другой стороны логику query в любом случае придется переписывать. А с третьей стороны логика query чаще всего менее критична, чем логика command.
В общем тут есть над чем подумать.
terabucks Автор
31.05.2025 03:22Тема только для тех у кого MSSQL уже является неотъемлемой частью корпоративной ИТ-инфраструктуры. Или не корпоративной.
oss2007
Как разрботчик баз данных с многолетним опытом скажу: за всё время работы на проектах могу смело утверждать, что единственное взаимодействие с данными, того же c#, должно быть исключительно через хранимые процедуры или функции. Будь то простой SELECT или сложная аналитика данных. Нет ниодного серьезного аргумента, обработки данных, написания кода SQL вне DB. Единственный код который пишет разработчик бэка для взаимодействия с данными - это вызов хранимых процедур и функций. Точка. Остальное от лукавого и в конечном итоге рано или поздно вылезет проблемами
ZetaTetra
Где вот та магическая линия разграничения, которая разделяет обработку данных и интеграцию бизнес логики в хранимки?
AccountForHabr
Это проще, достаточно знать только C# что бы писать сложные запросы.
Это позволяет писать один код, для всех баз для которых есть linq провайдер.
Код на C# легче отлаживать и тестировать.
Развертывание обычно проще
ZetaTetra
Только в случае чего-то лёгкого, а когда начинаются оптимизации и хинты на высокой загрузке: rowlock, nolock, merge и т.п., то универсальность linq уходит за фасад, завод или в те самые хранимки.
terabucks Автор
Извините, я не до конца понял, Вы за код SQL внутри БД или против?
ZetaTetra
Я как разработчик который пользовался типизированными датасетами, писавший свой ORM для .NET Framework 2.0, использовавший Linq to SQL, Entity Framework, писавший хранимки и видел проекты где логику пихают в хранимки, могу сказать что каждый инструмент подходит для свой задачи.
Главное не переусердствовать и не отказываться от другого по необоснованной предвзятости.
terabucks Автор
Радикальный подход. Хотя, конечно, имеет право на существование. А как Вы сочетаете с ORM (EF в частности)? На мой взгляд для простых атомарных операций и небольших выборок CRUD все же довольно удобно использовать LINQ.
AccountForHabr
Del