Full-stack разработка через поток данных
В том, как я разрабатываю фичи для 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-представлением статуса каждой фичи.