JavaRush /Курсы /Java Server /In-memory repository для reading list

In-memory repository для reading list

Java Server
24 уровень , 0 лекция
Открыта

1. Роль репозитория в read-only API

Когда слышишь слово «репозиторий», мозг иногда автоматически дорисовывает PostgreSQL, миграции, индексы и DBA, который строго смотрит на вас через монитор. Но сегодня мы гораздо скромнее: нам нужно место, где вообще есть данные. Даже если API «только читает», ему всё равно надо откуда-то читать, иначе получится API уровня «GET /reading-list возвращает вдохновение».

Если данные хранить прямо в handler (например, сделать там List<ReadingListItem>), то очень быстро случится классическое backend-болото: маршрутизация, парсинг запроса, бизнес-логика и хранение окажутся в одной куче. И в этот момент вы уже начинаете писать “мини-фреймворк” в одном классе, хотя клялись себе этого не делать.

Репозиторий в нашей архитектуре — это слой, который отвечает за простую вещь: хранить и отдавать доменные объекты. Он не знает ничего про HTTP, не выбирает статус-коды, не сериализует JSON и не должен пытаться «быть умнее, чем надо». Он просто «кладовщик»: у него есть склад (память процесса) и картотека (ключ id), и он умеет по запросу показать, что лежит на полках.

Ограничения in-memory хранилища

In-memory хранилище — это когда данные живут в памяти процесса Java-приложения. То есть внутри вашей запущенной JVM, рядом с вашими объектами, потоками и бессонными ночами. Вы перезапустили приложение — и хранилище «обнулилось». Это не баг, а честное свойство: никакой устойчивости между перезапусками мы сегодня не обещаем.

Почему это нормально именно в этом курсе? Потому что наша цель — научиться HTTP-цепочке и слоям приложения, не уезжая в соседний курс про SQL/JDBC/ORM. Если бы мы сейчас добавили базу данных, то 80% внимания ушло бы в настройку подключения, схемы, миграции и тонкости хранения. А у нас сегодня цель проще: «пусть сервер умеет вернуть список и один элемент по id», и пусть при этом код остаётся понятным.

Полезно прямо зафиксировать отличия в маленькой таблице — не ради академизма, а чтобы мозг не обманывал себя:

Вопрос In-memory репозиторий (Map) База данных
Данные переживают перезапуск? Нет Да
Нужны драйверы/SQL/миграции? Нет Да
Скорость доступа по id Очень высокая (в памяти) Зависит от индексов, сети и нагрузки
Можно хранить миллионы записей? Технически — можно, но быстро станет плохо Да, это как раз её работа
Цель в курсе Понять слои и HTTP-семантику Это отдельная дисциплина

И ещё важный нюанс: in-memory репозиторий — это почти всегда «учебное или прототипное» решение. В реальном проекте он может использоваться как временный стаб, как кеш или как тестовая заглушка, но не как «финальная история хранения». Однако для bridge-course это идеальный компромисс: минимум инфраструктуры, максимум фокуса на API.

Контракт репозитория: говорим на языке домена, а не HTTP

Очень хочется (особенно после недели HTTP) сделать метод вида findByIdOrThrow404 или findAllAsJson — и вроде бы даже «работает». Но это ловушка. Репозиторий — не часть web-слоя, а часть внутренней логики приложения. Его язык — это домен: ReadingListItem, ReadingStatus, идентификатор long. Его результат — это доменные объекты или отсутствие доменного объекта.

Это разделение важно не из-за «красоты», а из-за контроля сложности. Как только репозиторий начнёт знать про HTTP, вы получите класс, который одновременно и «хранит», и «решает, какой статус вернуть», и «формирует JSON», и «пишет в лог». Такой класс невозможно нормально менять: любое изменение будет цеплять все слои сразу.

Ещё одна причина держать репозиторий «глухим к HTTP» — это будущая заменяемость. Сегодня у нас Map. Завтра (в следующем этапе вашей траектории) может появиться база данных. Если репозиторий изначально не смешивал storage-логику с HTTP, то заменить InMemory... на «Database...Repository» будет в разы проще: контракт останется, реализация поменяется.

2. Интерфейс ReadingListRepository

Сегодняшний уровень посвящён read-only части API, значит репозиторию достаточно минимального контракта: «дай всё» и «дай по id». Не больше. Когда новичок видит слово “Repository”, часто начинается «а давайте сразу добавим save, update, delete, findByTitle, findByAuthor, findByStatusAndTitleContainsIgnoreCaseAnd…» — и через 15 минут у вас самодельный Spring Data JPA. Без Spring Data и без JPA, зато с полной болью.

Поэтому мы фиксируем простой интерфейс. Он живёт в пакете readinglist.repository, а возвращает домен из readinglist.domain. Это важная связка: репозиторий не возвращает DTO и не возвращает JSON-строку. Он возвращает ReadingListItem, а дальше другой слой решит, что с ним делать.

Ниже пример контракта именно на сегодня:

import com.example.readlater.readinglist.domain.ReadingListItem;
import java.util.List;

public interface ReadingListRepository {

    // Возвращаем все доменные объекты как есть (без DTO и без JSON).
    List<ReadingListItem> findAll();

    // Возвращаем доменный объект по id.
    // Если объект не найден — возвращаем null (а уже handler решит, что это 404).
    ReadingListItem findById(long id);
}

Обратите внимание на маленький, но принципиальный выбор: findById возвращает ReadingListItem, а если ничего не найдено — вернёт null. Можно было бы использовать Optional, и в больших проектах это часто уместно, но в учебном коде null иногда проще воспринимается, особенно когда вы параллельно учитесь различать 400 и 404 в handler-е. Главное — чтобы null был ожидаемым контрактом, а не сюрпризом.

3. InMemoryReadingListRepository на Map

Теперь переходим к самой «мясной» части лекции: как устроить хранение. Мы знаем, что ключ сценариев чтения сегодня — id из пути (/reading-list/{id}). Значит самая естественная структура — Map<Long, ReadingListItem>. Это буквально «картотека»: по номеру карточки (id) мы быстро находим конкретную запись.

Самый частый вопрос здесь — «почему не List?». Потому что List — это по сути “полка без номеров”: чтобы найти элемент по id, вам пришлось бы каждый раз пробегать все записи и сравнивать item.getId(). Для пары элементов это не страшно, но сам принцип не тот. Map сразу подсказывает мозгу: основной доступ — по ключу.

Давайте зафиксируем минимальные поля класса репозитория. Мы берём HashMap как базовую реализацию Map и отдельно держим счётчик id.

import com.example.readlater.readinglist.domain.ReadingListItem;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;

public class InMemoryReadingListRepository implements ReadingListRepository {

    // Внутреннее хранилище: ключ — id, значение — доменный объект.
    // Важно: наружу этот Map отдавать нельзя, иначе сломаем инкапсуляцию.
    private final Map<Long, ReadingListItem> storage = new HashMap<>();

    // Генератор уникальных id внутри процесса (in-memory).
    // AtomicLong полезен, если запросы придут параллельно.
    private final AtomicLong nextId = new AtomicLong(0);
}

Здесь важно две вещи. Первая: storage — это внутренняя структура, и мы не должны отдавать её наружу. Вторая: nextId — часть ответственности слоя хранения. Не handler генерирует id, не service, а именно репозиторий (в нашем учебном упрощении) знает, как выдавать уникальные идентификаторы внутри процесса.

4. AtomicLong: генератор id

Слово Atomic иногда звучит так, будто мы сейчас влезем в квантовую физику и будем обсуждать «суперпозицию идентификаторов». На самом деле всё проще: AtomicLong — это счётчик, который умеет увеличиваться безопасно (атомарно), даже если вдруг несколько потоков захотят получить новый id одновременно.

Да, сегодня мы не делаем курс по конкурентности. Но сервер — штука потенциально многопоточная, и даже на учебном HttpServer запросы могут приходить параллельно. Если бы мы использовали обычный long и делали nextId++, в теории можно было бы получить одинаковые id в гонке. AtomicLong даёт нам простой и надёжный «нумератор билетов», как в электронной очереди.

Минимальное использование выглядит так:

// Предположим, что nextId — это поле AtomicLong (как в репозитории выше).
long id1 = nextId.incrementAndGet(); // увеличили счётчик и получили новое значение
long id2 = nextId.incrementAndGet(); // ещё раз увеличили

System.out.println(id1); // 1
System.out.println(id2); // 2

Метод incrementAndGet делает ровно то, что написано на упаковке: увеличивает значение и возвращает новое. Мы стартуем с 0, чтобы первые значения были 1, 2, 3 — людям так проще читать, чем 0, 1, 2 (хотя компьютеру всё равно).

5. Стартовые данные для GET-эндпоинтов

Если репозиторий пустой, то list-endpoint (GET /reading-list) всё равно будет корректным: он вернёт items: [] и count: 0. Но с точки зрения обучения это скучно: вы не видите разницы между «всё работает» и «просто всегда пусто». Поэтому полезно заложить в репозиторий небольшие sample-данные прямо в конструкторе.

Тут важно не перепутать: мы не делаем полноценный механизм создания элементов через API (это другой уровень). Мы просто подготавливаем «стартовый набор», чтобы сегодня можно было проверить чтение списка и получение элемента по id и сразу увидеть осмысленные JSON-ответы.

Пример конструктора, который кладёт пару книг:

public InMemoryReadingListRepository() {
    // Стартовые данные нужны только для демонстрации read-only эндпоинтов.
    putSample("Clean Code", "Robert C. Martin", ReadingStatus.PLANNED, "OL12345M");
    putSample("Effective Java", "Joshua Bloch", ReadingStatus.IN_PROGRESS, "OL67890M");
}

А вот маленький приватный помощник putSample. Он инкапсулирует логику генерации id и укладывания объекта в Map, чтобы конструктор оставался читаемым.

private void putSample(String title, String author, ReadingStatus status, String externalId) {
    // Генерируем новый уникальный id внутри процесса.
    long id = nextId.incrementAndGet();

    // Создаём доменный объект (репозиторий не создаёт DTO и не формирует JSON).
    ReadingListItem item = new ReadingListItem(id, title, author, status, externalId, null);

    // Кладём запись в in-memory хранилище.
    storage.put(id, item);
}

Обратите внимание на спокойный стиль: мы создаём доменный объект ReadingListItem и кладём его в storage. Репозиторий не сериализует его, не превращает в DTO, не добавляет «HTTP-ошибки». Он просто хранит.

6. findAll и findById: реализация

Теперь два ключевых метода. На первый взгляд кажется: «ну там же две строчки, что тут объяснять». А объяснять есть что, потому что именно в таких местах новички часто случайно делают себе “дырку” в инкапсуляции и начинают ловить странные баги, которые выглядят как магия.

Начнём с findAll(). Самое плохое, что можно сделать — вернуть наружу «живой вид» внутренней коллекции и дать внешнему коду возможность случайно поломать репозиторий. Поэтому мы возвращаем новый список, собранный из значений Map. Это дешёво, читаемо и достаточно безопасно.

import java.util.ArrayList;
import java.util.List;

@Override
public List<ReadingListItem> findAll() {
    // Важно: возвращаем копию списка, чтобы внешний код не мог менять состав коллекции.
    return new ArrayList<>(storage.values());
}

Здесь есть тонкость: мы копируем список, но не копируем сами объекты. Если ReadingListItem — изменяемый класс (а он часто изменяемый, потому что статус книги меняется), то кто-то снаружи может изменить объект и это отразится в репозитории, потому что ссылка та же. Для учебного проекта это допустимо, но держите в голове: «копия списка» защищает от изменения состава коллекции, а не от изменения внутренностей объектов.

Теперь findById(long id). Он вообще должен быть максимально тупым: взять и вернуть из Map по ключу. Если не найдено — Map.get вернёт null. Это нормально. Решение «null404» будет приниматься в handler-е, потому что это именно HTTP-решение.

@Override
public ReadingListItem findById(long id) {
    // Map.get вернёт null, если такого ключа нет — это и есть контракт репозитория.
    return storage.get(id);
}

И вот здесь хорошо видно, почему мы не хотим мешать репозиторий с HTTP. Репозиторий не обязан знать, что null станет 404. Для него null означает простую вещь: «на складе нет такого товара». А уже внешний слой решит, является ли это «ошибка клиента», «не найдено», «конфликт» или что-то ещё.

7. Репозиторий в слоях приложения

Сейчас мы добавили новый кусок системы, и важно не потерять общую картину. Когда приложение растёт, мозг начинает путаться: «а где у меня что? почему я это делаю здесь, а не там?». Поэтому полезно один раз зафиксировать маршрут данных сверху вниз и обратно.

Представьте, что приходит запрос «дай список reading list». Обработчик (handler) принимает HTTP, сервис (service) решает прикладную задачу «получить данные», репозиторий (repository) отдаёт доменные объекты, и дальше всё поднимается обратно: сервис маппит домен в response DTO, handler выбирает статус и пишет JSON.

Вот маленькая схема, которая держит эту модель в голове:

flowchart TD
    Client[HTTP client / Postman] -->|GET /api/v1/reading-list| Handler[readinglist.http Handler]
    Handler --> Service[readinglist.service ReadingListService]
    Service --> Repo[readinglist.repository ReadingListRepository]
    Repo --> Storage["Map<Long, ReadingListItem>"]
    Storage --> Repo --> Service --> Handler --> Client

Самая важная мысль тут такая: репозиторий — это не «нижняя часть handler-а». Это отдельный слой, который можно понимать, тестировать и менять отдельно. Если вы удерживаете эту границу, дальше (когда появятся новые операции) проект будет расширяться предсказуемо. Если границы стереть — проект начнёт расширяться хаотично, как папка «Новая папка (17)».

8. Типичные ошибки при работе с in-memory репозиторием

Ошибка №1: хранить в репозитории response DTO вместо доменных объектов.
Это выглядит заманчиво: «зачем хранить ReadingListItem, если всё равно отдаём JSON?». Проблема в том, что response DTO — язык внешнего контракта, он живёт по правилам клиента и может меняться из-за требований API. Репозиторий же должен жить по правилам домена. Если смешать эти миры, вы получите слой хранения, который начинает зависеть от формы HTTP-ответа, и любое изменение контракта будет ломать внутреннюю часть приложения.

Ошибка №2: писать HTTP-статусы и JSON-логику в репозитории.
Репозиторий, который возвращает «404» или формирует ErrorResponse, быстро превращается в “всё-в-одном” класс. Это ломает обучающую модель слоёв: вы перестаёте понимать, где заканчивается хранение и начинается web-логика. Статус и JSON должны рождаться в handler-е, максимум с помощью общих утилит отправки ответа.

Ошибка №3: генерировать id где попало (обычно в handler-е).
Если генерация идентификатора разбросана по коду, вы гарантированно получите «неожиданные коллизии» и странные дубли. Даже в in-memory модели лучше держать генерацию в одном месте — рядом с хранилищем. Сегодня мы используем AtomicLong, чтобы id выдавались последовательно и предсказуемо, а код не превращался в охоту за тем, кто “последний увеличивал счётчик”.

Ошибка №4: отдавать наружу внутреннюю коллекцию или storage.values() без копии.
Если вернуть наружу живую коллекцию, внешний код может случайно её изменить. Даже если вы “точно не будете”, кто-нибудь другой (или вы через неделю) будет. Скопировать значения в новый ArrayList — дешёвый способ сохранить инкапсуляцию и не превращать репозиторий в общественную кухню, где каждый может переставить кастрюли.

Ошибка №5: ожидать, что in-memory данные сохранятся после перезапуска сервера.
Это самая честная ловушка: вы всё настроили, получили список, порадовались, перезапустили приложение — и «всё пропало». Тут не надо искать баг. Это нормальная цена in-memory модели. Мы специально выбрали её, чтобы фокусироваться на HTTP и структуре приложения. Устойчивое хранение — это отдельная большая тема, и она придёт позже, когда у вас будет крепкая основа.

1
Задача
Java Server, 24 уровень, 0 лекция
Недоступна
In-memory репозиторий со стартовыми данными
In-memory репозиторий со стартовыми данными
1
Задача
Java Server, 24 уровень, 0 лекция
Недоступна
Копия списка вместо утечки внутреннего хранилища
Копия списка вместо утечки внутреннего хранилища
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ