В том, как я разрабатываю фичи для full-stack-приложений, есть паттерн. Уверен, что ничего нового не изобретаю — это скорее описание для себя, чтобы внести ясность, но может быть кому-то тоже пригодится.

Когда думаешь о разработке фронтенда и бэкенда, мысли сводятся к обмену данными. Всё — это поток данных: что каждый слой стека отправляет и получает. Разрабатывать этот поток данных за один раз было для меня сложно — приходилось воображать всю цепочку от начала до конца. Я много переделывал, стоимость разработки была высокой.

Мне хотелось шагов поменьше и попроще. Я попробовал исследовательский подход, при котором весь процесс — это эксперимент: «это должно как-то заработать, но я позволяю себе исследовать как».

Для ясности скажем, что у нас есть приложение для управления точками на карте со следующим стеком — хотя стек здесь не главное:

  • Frontend
    • UI: React
    • State: Redux
    • Middleware: Redux-Observable
    • GraphQL client: Apollo Client
  • Backend
    • GraphQL server: AWS AppSync
    • Backend API: AWS Lambda
    • DB: AWS DynamoDB

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

Инфраструктуру и техники тестирования опускаю — иначе статья стала бы слишком длинной. Использую TypeScript, но пропущу типы и импорты.

Точки входа и выхода потока

Начинаю с фронтенда — здесь поток данных начинается и заканчивается. Начинается с клика по кнопке, поэтому добавляю кнопку, которая пока ничего не делает:

export function AddPointButton() {
  return <button>Add new point</button>;
}

Есть начало потока данных, теперь нужно создать и его конец:

export function UserAddedPoint() {
  return (
    <div className="point-container">
      <span className="point-description">
        Point id '123-456' located at lat '10', lon '20'
      </span>
    </div>
  );
}

Этот шаг позволяет работать над UI, не задумываясь о логике за ним.

Текущий поток данных: UI (нет потока)

Подключаем state

Теперь, когда точки входа и выхода (AddPointButton() и UserAddedPoint()) определены, нужно наладить поток данных между ними простейшим способом — через Redux state. Убираю данные из принимающего компонента:

export function UserAddedPoints() {
  const points = useSelector((state: RootState) => state.pointsState);

  const userAddedPoints = points.map((point, index) => {
    <UserAddedPoint point={point} key={index} />;
  });

  return <div className="points-container">{userAddedPoints}</div>;
}

export function UserAddedPoint(point) {
  return (
    <div className="point-container">
      <span className="point-description">
        Point {point.id} located at lat {point.lat}, lon {point.lon}
      </span>
    </div>
  );
}

И замыкаю поток, отправляя данные из отправляющего компонента:

export function AddPointButton() {
  const dispatch = useDispatch();

  const onClick = () => {
    dispatch(addPoint({ id: "123-456", lat: 10, lon: 20 }));
  };

  return <button onClick={onClick}>Add new point</button>;
}

Обратите внимание, что все данные захардкожены, включая id. Это может ощущаться неправильно — и должно. Именно в этом и есть смысл исследовательской динамики. Именно то ощущение, которое возникло при вводе этого id, дало мне понять, что его здесь не должно быть. Держим это в уме и идем дальше. Следом очевидно нужен reducer, добавляющий точку:

export function points(state = initialState, action: PointAction) {
    switch (action.type) {
        case PointActions.ADD_POINT:
            return { ...state, points: [...state.point, action.point] };
        default:
            return state;
    }

На этом шаге приложение умеет добавлять точку в state по клику кнопки и рендерить state — создан очень простой поток данных.

Текущий поток данных: UI <–> State

Очевидно, пользователь не должен добавлять точку только в lat: 10, lon: 20, а в любую точку карты — это можно сделать позже, делегировать и т.д. Сейчас это не главная тема, поэтому продолжаю поток данных.

Добавляем epic

Для получения данных с бэкенда буду использовать Redux-observable epic, но не тороплюсь подключать к нему бэкенд. Лучше расширю поток данных новым epic. Вспоминаю, что кнопка не должна отправлять id, поэтому добавляю другой action — requestToAddPoint():

export function AddPointButton() {
  const dispatch = useDispatch();

  const onClick = () => {
    dispatch(requestToAddPoint({ lat: 10, lon: 20 }));
  };

  return <button onClick={onClick}>Add new point</button>;
}

Этот action будет обрабатываться epic’ом — он станет входным action’ом, а прежний addPoint() — выходным action’ом epic’а.

export function remotePoints(action$) {
  return action$.pipe(
    ofType(PointActions.REQUEST_TO_ADD_POINT),
    mergeMap((action) => {
      return of(addPoint({ id: "123-456", lat: action.lat, lon: action.lon }));
    }),
  );
}

Беру lat и lon из action’а, id задается в epic’е. Все они используются в уже существующем action’е addPoint(), поэтому менять reducer не нужно — я просто вставил еще один элемент в цепочку потока данных.

Теперь я знаю, что буду отправлять на бэкенд { lat, lon } и ожидать в ответ { id, lat, lon }. В этом простом случае это могло быть известно с самого начала, но в более сложных реальных ситуациях выбор может быть нетривиальным.

Текущий поток данных: UI <–> State <–> Epic

Подключаем бэкенд

Да, его еще не существует, знаю, всё нормально. Код писать можно. Возможно, напишу отдельную статью о том, как это делать — это отдельная большая тема. В любом случае UI и reducer мне сейчас не нужны: расширяю поток данных из epic’а, добавляя Apollo Client:

export function remotePoints(action$, _, { pointsClient }) {
  return action$.pipe(
    ofType(PointActions.REQUEST_TO_ADD_POINT),
    mergeMap((action) => sendAddPointRequest(action, pointsClient)),
  );
}

function sendAddPointRequest(action, pointsClient) {
  return fromPromise(
    pointsClient.mutate({
      mutation: addPointMutation,
      variables: { lat: action.lat, lon: action.lon },
    }),
  ).pipe(
    mergeMap((response: AddPointResponse) => of(addPoint(response.AddPoint))),
    catchError(reportError),
  );
}

Это упадет, потому что бэкенд не реализован — значит, пришло время заняться его реализацией и завершить поток.

Текущий поток данных: UI <–> State <–> Epic >–< API (нет потока с API)

Реализуем бэкенд

Что самое простое, чтобы бэкенд заработал? Добавить статические данные — так же, как я сделал на фронтенде:

export default async function addPoint(location) {
  const newPoint = {
    id: "123-456",
    lat: location.lat,
    lon: location.lon,
  };

  return newPoint;
}

Даже с такой минимальной реализацией можно тестировать поток данных между фронтендом и бэкендом, проводить исследовательское тестирование всей фичи или рефакторить — например, упаковать lat и lon в interface Location { lat: number, lon: number } и на бэкенде, и на фронтенде.

Текущий поток данных: UI <–> State <–> Epic <–> API

Подключаем БД

После последнего шага еще есть что сделать — добавить генератор id и сохранять данные в базу.

import { DocumentClient } from "aws-sdk/clients/dynamodb";

export default async function addPoint(location) {
  const newPoint = {
    id: generateId(),
    lat: location.lat,
    lon: location.lon,
  };

  const itemToPut = {
    TableName: "PointsTable",
    Item: newDevice,
  };

  await new DocumentClient().put(scanInput).promise();

  return newPoint;
}

И этим шагом поток замкнут!

Текущий поток данных: UI <–> State <–> Epic <–> API <–> DB

Этот метод может показаться наивным. Зачем не разработать все сразу? Для меня это означало бы сделать огромную ставку, в успехе которой я не уверен. С самого начала нужно угадывать, как работает вся система. Проигрыш такой ставки означает переделку уже потраченного времени. А значит, оно потрачено впустую.

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

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