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

Контроллер и хранилище заявок

Router группирует адреса, но обработчики всё ещё работают с массивом напрямую. При переходе на базу каждый из них пришлось бы переписать. Выделим хранилище, которое выполняет операции над заявками, и контроллер, который переводит HTTP-ввод в вызов операции. Это продолжает идею разделения модели и представления, но в нашем API представлением является JSON, а не серверная HTML-страница.

В состоянии stages/09 появляются tickets.js, ticket-controller.js и обновлённый router. Пока данные находятся в памяти, поэтому перезапуск восстанавливает JSON. Мы намеренно вводим границу до PostgreSQL: читатель сможет сопоставить две реализации одинаковых функций и увидеть, какая часть действительно зависит от хранения.

Договор хранилища

Полный модуль в памяти экспортирует фабрику. Она получает начальные строки и хранит внутренний массив. При создании нового объекта назначает номер, который больше всех исходных. Номер не вычисляется по длине массива, иначе удаление могло бы привести к повтору существующего ключа.

export function createMemoryStore(initial) {
  let tickets = initial.map((item) => ({ ...item }));
  let nextId = Math.max(0, ...tickets.map((item) => item.id)) + 1;
  return {
    async list() { return tickets.map((item) => ({ ...item })); },
    async get(id) { const ticket = tickets.find((item) => item.id === id); return ticket ? { ...ticket } : null; },
    async create(title) {
      const ticket = { id: nextId++, title, status: 'open' };
      tickets.push(ticket);
      return { ...ticket };
    },
    async setStatus(id, status) {
      const ticket = tickets.find((item) => item.id === id);
      if (!ticket) return null;
      ticket.status = status;
      return { ...ticket };
    },
    async remove(id) {
      const before = tickets.length;
      tickets = tickets.filter((item) => item.id !== id);
      return tickets.length !== before;
    }
  };
}

Функции объявлены async, хотя внутри пока нет ввода-вывода. Это явная подготовка общего договора: контроллер всегда ожидает результат, а SQL-реализация позднее сможет выполнять запрос без изменения вызывающей стороны. Не следует считать async способом ускорить обычный поиск в массиве. Здесь он выражает форму взаимодействия.

Объекты в списке и результате создания копируются, чтобы вызывающий код не мог случайно менять внутренний массив через возвращённую ссылку. Функция чтения также возвращает копию найденного объекта. При отсутствии результата она сохраняет договор null, а не создаёт пустой объект. Защита границы должна быть последовательной, а не существовать только в самом заметном методе.

Контроллер отвечает за HTTP

Контроллер получает store и возвращает обработчики. Он знает, где лежит заголовок запроса, какой статус соответствует созданию и что делать с отсутствующей записью. Хранилище не получает req и res, поэтому его можно вызвать из служебной программы без имитации Express.

export function createController(store) {
  return {
    async list(req, res) { res.json(await store.list()); },
    async create(req, res) {
      const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
      if (!title || Array.from(title).length > 120) {
        return res.status(400).json({ error: 'invalid_title' });
      }
      const ticket = await store.create(title);
      res.location(`/api/tickets/${ticket.id}`).status(201).json(ticket);
    }
  };
}

Это полный фрагмент двух обработчиков для разбора; полный файл состояния также содержит чтение, изменение и удаление. Перед листингом важно различать фрагмент и весь модуль, чтобы читатель не потерял остальные адреса. Router теперь связывает методы с контроллером, а приложение создаёт хранилище после загрузки начальных данных.

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

Ошибка отсутствия и изменение

Для чтения корректного номера, которого нет в хранилище, контроллер возвращает 404. Для неизвестного состояния изменения — 400. Пока проверка повторяется в нескольких местах; глава 13 объединит её в модуле. Это осознанная следующая ступень, а не предложение бесконечно копировать условия.

Хранилище возвращает null при отсутствии строки. Оно не знает, является ли этот факт ошибкой HTTP, нормальным результатом поиска либо причиной создать новую запись. Значение получает смысл в вызывающей операции. Такой договор особенно полезен при PostgreSQL: пустой rows можно преобразовать в прежний null, оставив контроллер без сведений о драйвере.

Для учебного удаления возвращается логическое значение. Но физическое удаление ещё не является подходящей политикой будущего сервиса с историей и вложениями. После появления связей маршрут будет убран, а завершение заявки останется изменением состояния на closed. Не смешивайте демонстрацию CRUD с решением о жизненном цикле всех предметных данных.

Граница проекта

Теперь зависимость направлена от приложения к router, от router к контроллеру, от контроллера к хранилищу. Модуль данных не импортирует HTTP-приложение обратно. Циклическая связь затруднила бы инициализацию и скрыла, кто создаёт ресурс. Для небольшого проекта обычных функций и параметров достаточно; сложная система классов здесь не добавляет ясности.

Эта структура основана на понятной ответственности, а не на обязательном числе каталогов. Можно разместить три небольших файла рядом. Если предметных модулей станет больше, появится смысл группировать их по области. Переименование каждого файла в service, manager или provider само по себе не создаёт архитектуру: договор операций важнее названия папки.

Ожидаемый итог читательского запуска — создание третьей заявки, её чтение и изменение без доступа router к массиву. После перезапуска новая запись исчезнет, потому что мы ещё не сохраняем её. Это наблюдение подводит к PostgreSQL: сохранятся идентификатор, заголовок и состояние, а источник истины переместится в таблицы. Следующая глава определяет их схему и ограничения.

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

Пример номера показывает различие алгоритма памяти и гарантии базы. Счётчик внутри одного процесса способен назначать разные новые номера, пока этот процесс работает. Он не координирует другой экземпляр и не сохраняется после перезапуска. PostgreSQL позже возьмёт эту обязанность через identity и первичный ключ. Контроллеру важен результат создания, поэтому способ назначения останется скрытым внутри хранилища.

Полезно мысленно заменить массив картотекой на диске: какие функции придётся изменить? Операции чтения и записи, но не выбор HTTP-статуса и не путь router. Если ответ получается другим, значит, часть хранения всё ещё просочилась наружу. Такое рассуждение является редакционной проверкой ответственности. Оно не подтверждает фактическую работу SQL, зато помогает подготовить последовательный переход без случайных переименований предметных сущностей.

Внутренний счётчик не следует отдавать клиенту как свободно изменяемое свойство. Клиент получает назначенный идентификатор после создания, а не выбирает следующий номер за хранилище. Иначе два параллельных клиента могли бы попросить одну запись. Такая граница остаётся и после PostgreSQL: столбец identity назначает значение на сервере базы. Контроллер принимает заголовок, а роль пользователя позже добавляется из проверенного контекста, сохраняя ответственность источника каждого поля.

Оглавление курса

Полные файлы учебного проекта. Примеры при подготовке текста не запускались; ответы и вывод в уроке описывают ожидаемый результат читателя.