Комплексные типы против владеемых сущностей в EF Core 11: что выбрать?
В EF Core 11 по умолчанию используйте комплексные типы для объектов-значений и переходите на владеемые сущности только тогда, когда вам нужна отдельная таблица или коллекция, отображённая на собственные строки.
В EF Core 11 (с .NET 11 и C# 14) отображайте объект-значение вроде Address, Money или DateRange как комплексный тип, и обращайтесь к владеемой сущности только тогда, когда вас к этому вынуждает форма хранения: значению нужна собственная таблица, либо вам нужна коллекция, хранимая как отдельные строки. Эта единственная ось решает почти каждый случай. Комплексные типы обладают семантикой значения и не имеют идентичности, что и есть в точности объект-значение; владеемые сущности же представляют собой полноценные типы сущностей, наряженные в костюм объекта-значения, и этот костюм постоянно сползает. EF Core 11, это тот выпуск, в котором последние причины предпочитать владеемые сущности по большей части исчезли, потому что комплексные типы теперь работают с наследованием TPT/TPC, поддерживают ExecuteUpdate, допускают коллекции при отображении на JSON и могут нести ключи и индексы.
Этот пост про решение, а не про механику. Если вам нужна пошаговая настройка, прочитайте как отобразить комплексный тип вместо владеемой сущности в EF Core 11. Здесь мы сравниваем два отображения лицом к лицу, показываем, где выигрывает каждое из них, и называем подводные камни, которые делают выбор за вас.
Матрица возможностей на один экран
Причина, по которой существуют оба отображения, в том, что они отвечают на разные вопросы. Владеемая сущность, это способ EF Core сказать “это зависимая сущность, которую я храню внутри её владельца”. Комплексный тип, это способ EF Core сказать “это значение, не имеющее собственной идентичности”. Всё, что ниже, вытекает из этого.
| Измерение | Комплексный тип | Владеемая сущность |
|---|---|---|
| Вид модели в основе | значение, без ключа | сущность, теневой первичный ключ |
| Семантика идентичности | по значению (содержимому) | по ссылке (идентичности) |
a == b в LINQ сравнивает | содержимое | идентичность |
Присваивание копирует поля (x.A = x.B) | да, копирует | throws (общая ссылка) |
| Та же таблица, что у владельца (расщепление таблицы) | да (по умолчанию) | да (по умолчанию) |
Отдельная таблица (ToTable) | нет | да |
Единый JSON-столбец (ToJson) | да | да |
| Коллекция как отдельные дочерние строки | нет | да (OwnsMany) |
| Коллекция внутри JSON-документа | да (ComplexCollection + ToJson) | да (OwnsMany + ToJson) |
ExecuteUpdate во вложенный член | да (EF Core 11) | нет |
CLR-тип может быть struct или record | да | только ссылочный тип |
| Ключи / индексы над вложенным скаляром | да (EF Core 11) | да |
| Наследование TPT / TPC на владельце | да (EF Core 11) | да |
| След в трекере изменений | на уровне столбцов, без отдельного узла | отдельный отслеживаемый узел + теневой ключ |
Прочитайте эту таблицу сверху вниз, и закономерность очевидна: комплексные типы выигрывают каждую строку, которая про семантику, а владеемые сущности выигрывают две строки, которые про форму хранения (отдельная таблица, отдельные дочерние строки). Это и есть всё сравнение в миниатюре. Версии здесь имеют значение, потому что три из этих ячеек “да” для комплексных типов стали истинными только в EF Core 11; в EF Core 9 расчёт был иным.
Когда выбирать комплексный тип
Обращайтесь к ComplexProperty (или атрибуту [ComplexType]) в этих случаях, которые покрывают подавляющее большинство объектов-значений в реальной кодовой базе:
- Тип полностью определяется своими данными.
Address,Money,GeoPoint,DateRange,PersonName. Если два экземпляра с идентичными полями взаимозаменяемы, это значение, а значение хочет семантики значения. В EF Core 11 вы пишетеb.ComplexProperty(c => c.ShippingAddress), и поля ложатся встроенно в таблицу владельца. - Вы хотите присваивать или сравнивать значение естественным образом.
customer.BillingAddress = customer.ShippingAddressкопирует поля и сохраняется чисто, аWhere(c => c.BillingAddress == c.ShippingAddress)фильтрует по содержимому. Оба этих сценария сломаны с владеемыми сущностями, как разобрано ниже. - Вы хотите, чтобы массовые записи дотягивались внутрь значения. EF Core 11 поддерживает
ExecuteUpdateв члены комплексного типа:ExecuteUpdateAsync(s => s.SetProperty(c => c.ShippingAddress.PostalCode, "010001")). Владеемые сущности никогда этого не допускали. Если вам важен быстрый путь записи, одно это решает дело; компромиссы здесь те же, что в ExecuteUpdate против загрузки сущностей и SaveChanges. - Значение является
structилиrecord. Владеемые сущности должны быть ссылочными типами, которые EF Core может снабдить ключом и отслеживать. Комплексный тип может бытьreadonly struct Moneyилиrecord, что согласуется с идеей “нет идентичности”. Взаимодействие с record стоит прочитать целиком в как правильно использовать records с EF Core 11.
Руководство Microsoft недвусмысленно об этом значении по умолчанию. Заметки о выпуске EF Core 11 утверждают, что работа по стабилизации комплексных типов была проделана специально “чтобы разблокировать использование комплексных типов как альтернативы подходу отображения владеемых сущностей”, а заметки EF Core 10 советовали существующим пользователям владеемых сущностей переключиться. Считайте комплексные типы значением по умолчанию, а владеемые сущности, исключением.
Когда выбирать владеемую сущность
Есть ровно две структурные причины и одна причина моделирования, чтобы остаться на OwnsOne / OwnsMany:
- Значение должно жить в собственной таблице. Комплексные типы всегда встроены: либо расщеплённые по таблице столбцы на владельце, либо один JSON-столбец на владельце. Нет никакого
ComplexProperty(...).ToTable("Addresses"). Если ваша схема требует данные в отдельной таблице с внешним ключом обратно на владельца (на неё завязано отчётное представление, на неё ссылается другая таблица, этого требует администратор базы данных), то это владеемая сущность, отображённая черезOwnsOne(...).ToTable(...). - Вам нужна коллекция как отдельные строки. Отношение “один ко многим” из объектов-значений, каждый из которых должен быть собственной строкой в дочерней таблице, это
OwnsMany. Расщеплённый по таблице комплексный тип должен быть единым значением, и хотя EF Core 11 добавилComplexCollectionдля коллекций, они хранятся внутри JSON-документа, а не как дочерние строки. Если вы хотите индексировать, соединять или запрашивать элементы как полноценные строки,OwnsManyпо-прежнему тот самый инструмент. - Это на самом деле не объект-значение. Если два экземпляра с одинаковым содержимым должны оставаться различимыми, либо у вещи есть жизненный цикл, переживающий её текущие данные, то у неё есть идентичность. Это настоящая связанная сущность, а не владеемый тип и не комплексный тип. Смоделируйте её обычным отношением “один ко многим” и ключом, которым управляете вы.
Обратите внимание, что ни одна из этих причин не про семантику или удобство. Они про физическую схему. Если ваш ответ на вопрос “нужна ли этому отдельная таблица или отдельные строки?” отрицательный, то у вас нет причины использовать владеемую сущность в EF Core 11.
Три острых угла владеемых сущностей, которые отталкивают людей
Сравнение становится конкретным, когда вы натыкаетесь на острые углы. Все три происходят из одной корневой причины: владеемая сущность является сущностью, поэтому EF Core даёт ей теневой ключ и рассуждает о ней по ссылочной идентичности.
Во-первых, вы не можете разделить экземпляр. Это выглядит так, будто должно работать, но не работает:
// .NET 11, EF Core 11 - owned entity mapping
var customer = await context.Customers.SingleAsync(c => c.Id == id);
customer.BillingAddress = customer.ShippingAddress;
await context.SaveChangesAsync(); // throws: the same owned instance is referenced twice
Поскольку оба свойства имеют один и тот же тип сущности, EF Core видит одну сущность, на которую ссылаются из двух мест, и отвергает это. С комплексным типом присваивание копирует поля и сохраняется чисто.
Во-вторых, равенство в LINQ сравнивает идентичность, а не содержимое:
// .NET 11, EF Core 11 - owned entity mapping
var same = await context.Customers
.Where(c => c.BillingAddress == c.ShippingAddress) // not what you meant
.ToListAsync();
С владеемой сущностью это не транслируется в сравнение поле за полем. С комплексным типом EF Core 11 сравнивает содержимое (включая вложенные комплексные типы, после конкретного исправления ошибки в EF Core 11), поэтому запрос означает “два адреса действительно равны”.
В-третьих, ExecuteUpdate вообще не поддерживает свойства владеемых сущностей, тогда как вариант с комплексным типом работает:
// .NET 11, EF Core 11 - complex type mapping
await context.Customers
.Where(c => c.ShippingAddress.City == "Bucuresti")
.ExecuteUpdateAsync(s =>
s.SetProperty(c => c.ShippingAddress.PostalCode, "010001"));
Если ваш код упирается в любой из этих трёх случаев, отображение владеемой сущности борется с вами, и решение в том, чтобы сменить отображение, а не обходить симптом.
Производительность: дело в узлах отслеживания и соединениях, а не в громком числе
Здесь нет драматического разрыва в пропускной способности, который можно поместить на график, и вам стоит с подозрением относиться к любому, кто такой вам показывает. Настоящая, структурная разница в производительности находится в двух местах.
Первое, это отслеживание изменений. Владеемая сущность отслеживается как собственный узел в трекере изменений, с теневым ключом, которым управляет EF Core. Комплексный тип не является отдельным узлом: его столбцы отслеживаются как часть владельца, на уровне разницы столбцов. В графе объектов с множеством объектов-значений на агрегат это меньше записей для снимка, фиксации и сравнения при SaveChanges. Разница обычно мала на одну сущность, но масштабируется с тем, сколько объектов-значений вы загружаете, и она строго в пользу комплексного типа, потому что учётной работы просто меньше.
Второе, это соединение, и оно применяется только к тому случаю владеемой сущности, который вы бы и правда выбрали по причинам хранения. Отображение OwnsOne(...).ToTable("Addresses") живёт в отдельной таблице, поэтому чтение владельца вместе с его объектом-значением, это соединение. Расщеплённый по таблице комплексный тип не имеет отдельной таблицы и потому не имеет соединения. Если вы перенесли объект-значение во владеемую сущность чисто по привычке, и оно всё равно приземлилось в таблицу владельца (значение по умолчанию), то оба варианта эквивалентны по хранению, и разница в отслеживании, это единственное, что остаётся. В момент, когда вы действительно используете громкую возможность владеемой сущности (отдельную таблицу), вы берёте на себя стоимость соединения, которого комплексные типы избегают по построению. Более широкую картину стоимости отслеживания рисуют те же силы в AsNoTracking против AsNoTrackingWithIdentityResolution в EF Core 11.
Итак, честное утверждение о производительности таково: комплексные типы никогда не медленнее эквивалентной владеемой сущности в той же таблице и структурно легче в отслеживании; владеемые сущности берут на себя соединение именно тогда, когда вы используете их для того единственного, что комплексные типы делать не могут.
Подводный камень, который делает выбор за вас: версия EF Core и правило nullable
Две вещи могут принять решение за вас независимо от предпочтений.
Первая, это ваша версия EF Core. Всё вышесказанное предполагает EF Core 11. В EF Core 9 и ранее комплексные типы нельзя было использовать на сущностях с наследованием TPT/TPC, ExecuteUpdate во вложенные члены имел ошибки, сравнение вложенных комплексных типов было неверным, и не было ComplexCollection. Если вы привязаны к EF Core 9, владеемые сущности всё ещё могут быть прагматичным выбором для унаследованного объекта-значения или коллекции, и вам стоит запланировать переключение как часть обновления. Руководство по миграции с EF Core 6 на EF Core 11 покрывает ломающие изменения, которые обычно всплывают заодно с этим, и учтите, что UseSqlServer в EF Core 11 теперь по умолчанию использует уровень совместимости 160 (SQL Server 2022), что влияет на некоторые трансляции JSON.
Вторая, это правило необязательного значения. Необязательный (nullable) комплексный тип должен иметь как минимум одно обязательное, не допускающее null свойство, потому что EF Core использует этот столбец, чтобы отличить “всё значение равно null” от “значение присутствует, но его необязательные поля равны null”. Если у вас есть объект-значение, где по-настоящему каждое поле допускает null, необязательный комплексный тип не соберётся, и вы либо добавляете дискриминатор, либо пересматриваете допустимость null, либо откатываетесь к владеемой сущности. На практике реальный Address или Money всегда имеет обязательное поле, поэтому это редко кусается, но это то единственное ограничение моделирования, которое может подтолкнуть вас к владеемым сущностям.
Фильтры запросов ведут себя одинаково для обоих: глобальный или именованный фильтр определяется на владеющей сущности, а не на объекте-значении, поэтому мягкое удаление и мультиарендность работают одинаково, какое бы отображение вы ни выбрали. Если это ваша забота, смотрите именованные фильтры запросов против единого глобального фильтра запросов в EF Core 11; это не является различием между комплексными типами и владеемыми сущностями.
Рекомендация, сказанная прямо
В EF Core 11 по умолчанию используйте комплексные типы для объектов-значений. Отображайте Address, Money, GeoPoint, DateRange и им подобных через ComplexProperty, получайте семантику значения бесплатно и наслаждайтесь ExecuteUpdate, поддержкой struct/record и чистым равенством. Переходите на владеемую сущность только тогда, когда этого требует физическая схема: значение должно сидеть в собственной таблице, либо коллекция значений должна храниться как отдельные дочерние строки. А если у вещи есть подлинная идентичность, переживающая её данные, то она никогда не была объектом-значением, поэтому моделируйте её как настоящую связанную сущность с ключом, которым владеете вы.
Практическое правило то же самое, что отделяет record от class: если вещь определяется своими данными, это значение, а значение, это комплексный тип. Если у неё есть идентичность, которую вам нужно отслеживать, это сущность. EF Core 11 наконец позволяет этой ментальной модели отобразиться один в один на фреймворк, оставляя владеемые сущности для узких случаев хранения, в которых они всегда были лучше всего.
Дополнительное чтение
- Как отобразить комплексный тип вместо владеемой сущности в EF Core 11, это полное пошаговое руководство, включая миграцию с
OwnsOneнаComplexProperty. - Как правильно использовать records с EF Core 11 глубже раскрывает records как комплексные типы против сущностей.
- Как отображать и запрашивать JSON-столбцы в EF Core 11 покрывает вариант хранения в JSON, который разделяют оба отображения.
- ExecuteUpdate против загрузки сущностей и SaveChanges обрамляет путь массового обновления, который комплексные типы разблокируют для объектов-значений.
- Как настроить отображение наследования “таблица на иерархию” (TPH) в EF Core 11, это спутник, когда ваш владелец сидит в иерархии наследования.
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.