Перейти к содержанию

Внедрение зависимостей

Внедрение зависимостей отделяет использование объекта от решения о его создании. Обработчику каталога нужно получить курсы, но ему не обязательно знать, кто построил хранилище и сколько времени оно живёт. В этом уроке введём небольшой интерфейс доступа к данным и зарегистрируем его реализацию в контейнере ASP.NET Core.

Начальное состояние — результат урока о конфигурации. Название каталога уже поступает через options, а GET /api/courses всё ещё обращается к CatalogSeed напрямую. В lesson-04/after заменим это обращение зависимостью ICatalogRepository. HTTP-адрес и четыре исходные записи останутся прежними; базы данных и изменяющих операций пока нет.

Контракт поведения вместо конкретного массива

Интерфейс описывает две необходимые операции: получить все курсы и найти курс по идентификатору. Вместе с временной реализацией он находится в Data/ICatalogRepository.cs:

namespace CatalogApi;
public interface ICatalogRepository
{
    IReadOnlyList<Course> GetAll();
    Course? Find(string id);
}
public sealed class InMemoryCatalogRepository : ICatalogRepository
{
    public IReadOnlyList<Course> GetAll() => CatalogSeed.Courses.ToArray();
    public Course? Find(string id) => CatalogSeed.Courses.FirstOrDefault(c => c.Id == id);
}

Название repository здесь обозначает границу получения данных. Оно не требует сложной иерархии классов или скрытой бизнес-логики. Контракт достаточно мал, чтобы понять результат каждого метода: список курсов существует всегда, а отдельный курс может отсутствовать. Поэтому Find возвращает Course?; отсутствие записи представлено явно и позднее станет HTTP-ответом 404.

IReadOnlyList<Course> ограничивает операции, видимые через интерфейс списка. Вызывающая сторона может читать элементы и их количество, но не получает метод Add. Реализация дополнительно создаёт новый массив через ToArray. Таким образом, замена элемента в полученном массиве не заменит элемент исходного seed. Это важно, поскольку readonly у поля seed само по себе не запрещает изменение содержимого массива.

Мы не вводим асинхронность для чтения четырёх объектов из памяти. Доступ не ожидает внешнее устройство, сеть или базу. Когда появится такой источник, контракт изменится на асинхронный вместе с передачей отмены. Пока синхронная форма точнее объясняет текущую работу, чем искусственный Task ради будущей технологии.

Регистрация и разрешение зависимости

В Program.cs до Build зарегистрируем соответствие интерфейса и реализации:

builder.Services.AddSingleton<ICatalogRepository, InMemoryCatalogRepository>();

Эта строка сообщает контейнеру, какой объект использовать, когда требуется ICatalogRepository. После построения приложения обработчик может объявить такой параметр. Полный endpoint текущего урока имеет вид:

namespace CatalogApi;
public static class CatalogEndpoints
{
    public static void MapCatalog(this WebApplication app)
    {
        app.MapGet("/api/courses", (ICatalogRepository repository) => repository.GetAll());
    }
}

Зарегистрированный тип распознаётся как сервис. Framework получает его из контейнера, а не пытается разобрать ICatalogRepository из тела JSON или query-параметра. Это отличие важно при чтении сигнатуры: некоторые параметры handler приходят из HTTP-запроса, другие — из инфраструктуры. Позднее у одного метода будут обе группы, и источник каждого параметра следует понимать отдельно.

Сам endpoint не вызывает new InMemoryCatalogRepository() и не обращается к CatalogSeed. Поэтому будущая замена памяти на PostgreSQL потребует изменить регистрацию и реализацию, а не копировать SQL во все обработчики. Интерфейс при этом должен остаться правдивым: если хранилище начинает ждать I/O, прежняя синхронная форма уже недостаточна.

Документация Microsoft по dependency injection различает регистрацию сервиса и разрешение экземпляра. В нашем случае регистрация выполняется один раз при настройке host, а разрешение происходит при обработке запроса. Отсутствие регистрации не означает пустой каталог: это ошибка подготовки приложения, поскольку framework не может предоставить объявленную зависимость как сервис.

Время жизни объекта

Для неизменяемой учебной реализации выбран singleton. Контейнер использует один экземпляр сервиса в течение жизни приложения. Внутри него нет изменяемого состояния запроса, открытого соединения или ссылки на конкретного пользователя, поэтому общий экземпляр допустим. Сам факт регистрации singleton не делает произвольный класс безопасным для параллельных обращений; безопасность следует из его данных и методов.

Scoped-сервис обычно создаётся в области отдельного HTTP-запроса. Это подходит объекту, чья работа должна согласовываться в пределах такого запроса. Transient создаётся при каждом разрешении зависимости. Разница касается экземпляров объектов, а не URL и не количества записей каталога. Два вызова endpoint могут получить одинаковые данные независимо от выбранного времени жизни.

Нельзя бездумно поместить scoped-зависимость в singleton и ожидать, что она будет обновляться на каждый запрос. Более долгоживущий объект удержит более короткоживущую зависимость. Такая связь нарушает замысел областей и может обнаружиться проверкой контейнера. При подключении PostgreSQL мы будем держать долговечный пул соединений в singleton, а repository — в scoped. Само открытое соединение будет жить лишь вокруг конкретной операции.

Время жизни интерфейса и время жизни ресурса также не обязаны совпадать. Repository может существовать весь запрос, но несколько последовательно выполненных SQL-команд способны каждый раз временно получать соединение из пула. Обратный вариант, когда singleton хранит одно открытое соединение для всех запросов, создаёт совсем другие ограничения и в нашем курсе использоваться не будет.

Ожидаемое поведение и границы проверки

После настоящего запуска GET /api/courses должен вернуть тот же массив из четырёх объектов, что и в предыдущих уроках. Сумма уроков равна 60; изменения конфигурации подписи не меняют данные repository. Сравнивать следует значения и имена JSON-полей, а не внутренний адрес экземпляра сервиса или порядок создания объектов в журнале.

Уже можно мысленно проследить обращение: сервер принимает GET, выбирает handler, разрешает ICatalogRepository, вызывает GetAll, сериализует возвращённые записи. Эти шаги раскрывают причину ответа. Код чтения данных находится за одним контрактом, однако маршрутизация и сериализация остаются обязанностями ASP.NET Core. Интерфейс не подменяет их и не превращает отсутствие записи в HTTP-статус автоматически.

В полезном варианте для самостоятельного чтения можно добавить в интерфейс метод вычисления суммы, но сначала оцените, действительно ли это обязанность хранилища. Если число легко получается из уже полученного списка, дополнительный метод может лишь увеличить контракт. DI помогает выражать нужные зависимости, а не обязывает сделать сервисом каждую строку вычисления.

Регистрация остаётся частью начальной композиции приложения. Если handler внезапно требует другой интерфейс, нужно оценить, какая реализация ему соответствует и подходит ли её время жизни. Простое добавление нужного имени в сигнатуру ещё не создаёт зависимость. Эта связка особенно заметна при чтении полного снимка: интерфейс показывает ожидаемое поведение, реализация раскрывает его механизм, а Program.cs соединяет выбранные части.

Наш singleton пока доступен только для чтения и работает с фиксированным seed. Создание и изменение объектов появятся позже, когда будет выбран источник постоянных данных. До этого не добавляйте в него общую изменяемую коллекцию и не считайте её готовой серверной базой. В следующем уроке разберём другой уровень приложения: последовательность обработки запроса до и после вызова handler.