Я не эксперт по Entity Framework и все еще учусь его использовать.
Я столкнулся с проблемой, которую не понимаю, выбираю ли я неправильный дизайн или вместо этого есть простое решение.
Этот вопрос относится конкретно к EF Core 8.
Рассмотрим эти объекты в качестве примера моего сценария:
public class City
{
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; }
public string Name { get; set; } = default!;
public ICollection<Student> Students { get; set; } = new List<Student>();
}
public class Student
{
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; }
public string Name { get; set; } = default!;
public int Age { get; set; }
public City City { get; set; } = default!;
public int CityId { get; set; }
}
public class ApplicationDbContext : DbContext
{
public DbSet<City> Cities { get; set; }
public DbSet<Student> Students { get; set; }
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options) { }
}
Предположим, что список городов для этого приложения меняется нечасто. В этом случае вместо того, чтобы каждый раз читать города из базы данных, имеет смысл прочитать их один раз и хранить в кеше на длительное время (например: 1 неделю).
Сервис ICityCache можно использовать для получения объекта города из настроенного уровня кэширования (например, кеша памяти):
public interface ICityCache
{
Task<City?> GetByName(string name, CancellationToken cancellationToken);
}
Наконец, рассмотрим метод действия, используемый для создания новых объектов Student, который использует службу ICityCache для чтения объектов городов из кэша и позволяет избежать дополнительных вызовов базы данных для извлечения городов из базы данных:
[ApiController]
[Route("api/students")]
public class StudentsController : ControllerBase
{
private readonly ICityCache _cityCache;
private readonly ApplicationDbContext _context;
public StudentsController(ICityCache cityCache, ApplicationDbContext context)
{
_cityCache = cityCache;
_context = context;
}
[HttpPost]
public async Task<IActionResult> Create(CreateStudentCommand command, CancellationToken cancellationToken)
{
var city = await _cityCache.GetByName(command.City, cancellationToken);
if (city is null)
{
return this.BadRequest("The specified City is unknown");
}
// if I don't add this line, the context tracks the city entity with Added state and it tries to INSERT the city when SaveChangesAsync is called
_context.Cities.Attach(city);
var student = new Student
{
Name = command.Name,
Age = command.Age,
City = city,
};
_context.Students.Add(student);
await _context.SaveChangesAsync(cancellationToken);
return this.Ok(new { id = student.Id });
}
}
Этот код работает, но у него есть проблема. Свойство Students кэшированных City объектов со временем продолжает расти. Как только новая сущность Student добавляется в контекст, сущность Student добавляется в коллекцию студентов для кэшированной сущности City. Я хочу избегать наличия этих кэшированных объектов, которые со временем изменяются и продолжают расти.
Как я могу решить эту проблему ? Должен ли я вообще удалить свойство навигации Students из класса City? Это странно, поскольку по сути я меняю определение объекта, чтобы решить проблему с кешированием. Могу ли я сделать что-нибудь более умное?
Может быть, я неправильно проектирую этот код, и такое повторное использование объектов в разных запросах не подходит для EF Core?
Спасибо, похоже это способ решить эту проблему. Таким образом, контекст не отслеживает сущность «Город», и поэтому город не изменяется при создании нового объекта «Студент». Не стесняйтесь ответить на вопрос, если хотите, и немного подробнее остановиться на этой теме.
Также подумайте, нужно ли городу вообще свойство навигации «Студенты». Студенту можно присвоить .HasOne(x => x.City).WithMany(); для однонаправленной ссылки. Не все свойства навигации должны быть двунаправленными, в целом я рекомендую избегать двунаправленной навигации, если она не представляет ценности. В том странном случае, когда вам нужны все студенты из города, вы всегда можете сделать запрос через Student. (context.Students.Where(x => x.City.Id == cityId);)





Ниже представлена реализация кэша для обработки объектов поиска, которая инкапсулирует проверки кэша, прикрепляет найденные записи кэша к соответствующему экземпляру DbContext и загружает данные, если необходимо. «AppDbContext» = DbContext вашего приложения, поэтому при использовании внедренного DbContext с ограниченным сроком действия запроса передайте текущую ссылку на вызов кэша GetById/GetByName.
public interface ILookupCache<TEntity> where TEntity: class // consider a contract interface for identifying supported lookup entities.
{
TEntity GetById(AppDbContext context, int id);
TEntity GetByName(AppDbContext context, string name);
}
// Singleton scoped
public class CityLookupCache : ILookupCache<City>
{
private IList<City>? _cache = null;
City ILookupCache<City>.GetById(AppDbContext context, int id)
{
var city = context.Set<City>()
.Local()
.FirstOrDefault(x => x.Id == id);
if (city != null) return city;
if (_cache == null) refreshCache(context);
city = _cache.FirstOrDefault(x => x.Id == id);
if (city != null)
{
context.Attach(city);
return city;
}
city = context.SingleOrDefault(x => x.Id == id);
if (city != null)
_cache.Add(city);
return city;
}
City ILookupCache<City>.GetByName(AppDbContext context, string name)
{
var city = context.Set<City>()
.Local()
.FirstOrDefault(x => x.Name == name);
if (city != null) return city;
if (_cache == null) refreshCache(context);
city = _cache.FirstOrDefault(x => x.Name == name);
if (city != null)
{
context.Attach(city);
return city;
}
city = context.SingleOrDefault(x => x.Name == name);
if (city != null)
_cache.Add(city);
return city;
}
private void refreshCache(AppDbContext context)
{
_cache = context.Set<City>()
.AsNoTracking()
.ToList();
}
}
По сути, эти вызовы управляют кэшем для данного поискового вызова. Не существует явного вызова «Загрузить» для инициализации кеша, он инициализируется при первом вызове. При этом используется переданный App DbContext, позволяющий кэшу функционировать как Singleton между запросами. Он внутренне обрабатывает, что найденный поиск связан с экземпляром DbContext.
Первое, что он проверит, — отслеживает ли приложение DbContext запрошенный город. Если да, то он просто возвращает этот экземпляр, проверка кэша не требуется.
При первом вызове кеш будет пуст, поэтому он будет заполнен запросом AsNoTracking(). Для больших наборов (более тысячи записей) этот шаг можно удалить и просто проверять кэш или загружать+кэшировать отдельные записи по запросу, если их нет в кеше. (см. шаг ниже)
С помощью существующего кеша или только что загруженного кеша мы находим элемент по идентификатору или имени и, если он найден, прикрепляем его к данному экземпляру DbContext и возвращаем его. Если элемент не найден в кеше, мы загрузим его, добавим в кеш и вернем. Это обрабатывает случай, когда город может быть добавлен после чтения кэша.
Здравствуйте, спасибо за пример кода. Чтобы устранить исходную проблему, описанную в вопросе, вы предлагаете удалить свойство навигации «Студенты» из объекта «Город», верно?
Я работал над аналогичным, но менее элегантным подходом и продолжал сталкиваться с проблемами, когда кэшированные неотслеживаемые объекты были связаны с отслеживаемыми объектами. Возникало две распространенные проблемы: 1) EF начинал отслеживать кэшированный объект и в конечном итоге генерировал ошибки «первичный ключ уже отслеживается» и 2) циклы проверки данных, вызывающие ошибку «превышение пределов рекурсивной проверки». Я пытался использовать неотслеживаемые объекты кэша и специально не добавлял кэшированный объект в контекст, поскольку многие из моих запросов не отслеживались. Стив, поможет ли твой подход предотвратить проблемы?
@pjs, если вы хотите избежать таких проблем, всегда клонируйте объекты перед присоединением. Присоединение дает EF Core контроль над экземпляром объекта, который может изменять объект, исправлять некоторые свойства. Это опасно в многопоточной среде.
@pjs: Да, этот подход помогает предотвратить подобные проблемы. Он централизует проверки DbContext, чтобы сначала найти и предоставить любые существующие отслеживаемые ссылки, которые следует использовать, прежде чем пытаться присоединить другую, неотслеживаемую ссылку. Однако это следует учитывать только для отношений типа ассоциации, таких как таблицы поиска, где кэшированные значения не изменяются. Это не помогает в других ситуациях, когда между экземплярами DbContext передается больше отношений «родитель-потомок» редактируемых данных. Там вам нужно сделать что-то подобное, но найти и обновить значения, а не прикрепить.
@Enrico: Если вам вообще не нужно свойство навигации, то установка только FK позволит избежать проблемы. Хотя, если более поздний код будет использовать обновленную сущность, свойство навигации не будет заполнено, поэтому, например, в любом месте, где вам может понадобиться City.Name, вам придется проверить, имеет ли City значение #null, и получить его явно. Для простого поиска таких вещей, как статусы и т. д. Я рекомендую использовать enum PK/FK, где у меня есть таблица статусов для ссылки. целостность, но нет свойства сущности/навигации.
Вместо присвоения свойства
CityназначьтеCityId, тогда присоединение не требуется.