Нормализация. От нее мы или страдаем или пишем собственное решение с множеством проверок на существование сущности в общем хранилище. Попробуем разобраться и решить эту проблему!
Описание проблемы
Представим себе такую последовательность:
Клиентское приложение запрашивает список пользователей запросом к /users и получается пользователей с id от 1 до 10
Пользователь с id 3 меняет свое имя
Клиентское приложение запрашивает пользователя с id 3 с помощью запроса к /user/3
Вопрос: Какое имя пользователя с id 3 будет в приложении?
Ответ: Зависит от компонента, который запросил данные. В компоненте, который использует данные из запроса к /users, будет отображаться старое имя. В компоненте, который использует данные из запроса к /user/3, будет отображаться новое имя.
Вывод: В таком случае в системе существует несколько одинаковых по смыслу сущностей с разным набором данных.
Вопрос: Почему это плохо?
Ответ: В лучшем случае пользователь увидит разные имена одного человека в разных разделах сайта, в худшем переведет деньги на старые банковские реквизиты.
Варианты решения
В настоящее время существуют следующие варианты решения этой проблемы:
Не обращать внимание
Нормализовать данные собственноручно
Использовать клиент graphql (apollo или relay)
Не обращать внимание
Это самый очевидный и заманчивый вариант. В некоторых случаях клиентское приложение действительно может позволить себе иметь одинаковые сущности с разными данными. Но что делать со случаями, когда это недопустимое поведение? Как быть с разработчиками, которые не хотят создавать приложение с такими дефектами?
Нормализовать данные собственноручно
Примером собственноручной реализации может послужить код для mobx:
class Store {
users = new Map();
async getUsers() {
const users = await fetch(`/users`);
users.forEach((user) => this.users.set(user.id, user));
}
async getUser(id) {
const user = await fetch(`/user/${id}`);
this.users.set(user.id, user);
}
}
И если пример с mobx выглядит приемлемо, то нормализация в redux просто ужасает. Работать с таким кодом становится сложнее по мере его увеличения и совсем неинтересно
Использовать клиент graphql (apollo или relay)
Apollo и relay это библиотеки, которые из коробки умеют нормализовать данные. Однако такое решение заставляет нас использовать graphql и apollo, которые, по моему мнению, имеют множество недостатков.
Нормализация
Что такое нормализация и как она позволяет graphql клиентам бороться с указанной проблемой? Разберемся на примере apollo! Так apollo описывает свои действия с данными:
...normalizes query response objects before it saves them to its internal data store.
Что включает в себя указанное normalize?
Normalization involves the following steps:
1. The cache generates a unique ID for every identifiable object included in the response.
2. The cache stores the objects by ID in a flat lookup table.
То есть apollo формирует уникальный идентификатор для каждой сущности, для которой возможно его сформировать. Apollo использует его как ключ в хранилище всех сущностей. Вот как примерно выглядит формирование идентификатора и его хранение:
const store = new Map();
const user = {
id: '0',
type: 'user',
name: 'alex',
age: 24,
};
const id = `${user.type}:${user.id}`;
store.set(id, user);
Комбинация типа и id дает нам по-настоящему уникальный ключ. Мы можем быть уверены, что если встретим другого пользователя с таким же типом и id, то это будет тот же пользователь.
Получение уникального идентификатора
Apollo достигает указанного эффекта, запрашивая при каждом запросе внутреннее поле __typename, а как достигнуть похожего эффекта без graphql?
Поскольку мы не имеем внутренних полей с типами, то должны полагаться только на поля данных. Вот несколько решений:
сделать поле id или аналогичное поле глобально уникальным
добавить информацию о типах сущности в данные
добавить типы на сервере
добавить типы на клиенте
Сделать поле глобально уникальным
В таком случае хранение сущностей будет выглядеть вот так:
const store = new Map();
const user = {
id: '0',
};
const comment = {
id: '1',
};
store.set(user.id, user);
store.set(comment.id, comment);
// ...
store.get('0'); // user
store.get('1'); // comment
Решение выглядит достаточно удобным в использовании, однако реализация глобально уникальных полей id будет затруднительна. Как правило, сущности хранятся в базе данных и имеют id уникальный только внутри коллекции/таблицы (или другими словами какого-то типа). А значит, чтобы сделать id глобально уникальным, нужно приложить много усилий.
Добавить информацию о типах
В таком случае хранение сущностей выглядеть вот так:
const store = new Map();
const user = {
id: '0',
type: 'user', // <-- new field
};
const comment = {
id: '1',
type: 'comment', // <-- new field
};
function getStoreId(entity) {
return `${entity.type}:${entity.id}`;
}
store.set(getStoreId(user), user);
store.set(getStoreId(comment), comment);
// ...
store.get('user:0'); // user
store.get('comment:1'); // comment
По-прежнему удобно, но при этом требует от нас добавления особого поля в данных. Как мне кажется эта небольшая жертва окупается возможностью автоматического отслеживания изменения в данных. Именно этот вариант я выбрал предпочтительным для себя.
Где добавлять типы в данные?
Проблема нормализации данных особенно характерна для клиентских приложений. Поэтому рассмотрим вопрос - в какой момент добавлять информацию о типах в данные. Мы можем выбрать один из указанных вариантов для добавления типов.
На сервере, при отдаче данных:
app.get('/users', (req, res) => {
const users = db.get('users');
const typedUsers = users.map((user) => ({
...user,
type: 'user',
}));
res.json(typedUsers);
});
На клиенте, при получении данных:
function getUsers() {
const users = fetch('/users');
const typedUsers = users.map((user) => ({
...user,
type: 'user',
}));
return typedUsers;
}
Как мне кажется вариант добавления данных на сервере является предпочтительным. Api, которое отдает данные, знает о том какие данные и какого типа отдает. Однако в некоторых случаях нет возможности изменить код сервера для отдачи типа, в таких случаях можно добавить типы на клиенте.
Теперь разберемся как все это автоматизировать.
iresine
iresine это библиотека созданная для нормализации данных и оповещении об их изменении.
В данный момент iresine состоит из следующих модулей:
Так iresine работает с react-query:
@iresine/core
Основной модуль библиотеки, именно он отвечает за парсинг данных, их нормализацию и оповещении подписчиков об изменении конкретной сущности.
const iresine = new Iresine();
const oldRequest = {
users: [oldUser],
comments: {
0: oldComment,
},
};
// new request data have new structure, but it is OK to iresine
const newRequest = {
users: {
0: newUser,
},
comments: [newComment],
};
iresine.parse(oldRequest);
iresine.parse(newRequest);
iresine.get('user:0' /*identifier for old and new user*/) === newRequest.users['0']; // true
iresine.get('comment:0' /*identifier for old and new comment*/) === newRequest.comments['0']; // true
Как видим из идентификаторов, по которым мы получаем сущности из хранилища, @iresine/core использует следующую схему для создания идентификаторов:
entityType + ':' + entityId;
По умолчанию @iresine/core берет тип из поля type
, а id из поля id
. Это поведение можно изменить, передав собственные функции. Например попробуем использовать такой же идентификатор как в apollo:
const iresine = new Iresine({
getId: (entity) => {
if (!entity) {
return null;
}
if (!entity.id) {
return null;
}
if (!entity.__typename) {
return null;
}
return `${entity.__typename}:${entity.id}`;
},
});
Так же мы можем обрабатывать и глобально уникальное поле id:
const iresine = new Iresine({
getId: (entity) => {
if (!entity) {
return null;
}
if (!entity.id) {
return null;
}
return entity.id;
},
});
А что @iresine/core делает с сущностями, где идентификатор не обнаружен? Например такими:
const user = {
id: '0',
type: 'user',
jobs: [
{
name: 'milkman',
salary: '1$',
},
{
name: 'woodcutter',
salary: '2$',
},
],
};
user имеет своей идентификатор в хранилище, а как быть с jobs? У них нет ни поля type ни поля id! @iresine/core следует простому правилу: если у сущности нет идентификатора, то она становится частью ближайшей родительской сущности с идентификатором.
@iresine/core являет универсальной библиотекой, которая знает о том как распарсить данные и точечно уведомлять подписчиков. Но использовать ее напрямую довольно нудно и утомительно! Посмотрим как сделать этот процесс удобнее.
@iresine/react-query
react-query это прекрасная библиотека, с которой я бы посоветовал ознакомиться каждому. Но в ней отсутствует нормализация данных, и именно этот факт вдохновил меня на написание iresine.
@iresine/react-query это плагин для react-query. Он позволяет использовать функцию нормализации и обновления данных @iresine/core на данных хранилища react-query. Вся работа по нормализации происходит автоматически и клиент работает с react-query так, как бы работал без iresine.
import Iresine from '@iresine/core';
import IresineReactQuery from '@iresone/react-query';
import {QueryClient} from 'react-query';
const iresineStore = new IresineStore();
const queryClient = new QueryClient();
new IresineReactQueryWrapper(iresineStore, queryClient);
// now any updates in react-query store will be consumbed by @iresine/core
Схема взаимодействия выглядит так(была приведена выше):
Итог
Нормализация данных на клиенте это проблема. Сейчас она решается разными способами с разной степенью успешности. В написанном выше материале автор предлагает свой способ решения этой проблемы. Если сократить все предложение до нескольких слов, то они будут звучать как добавьте информацию о типах в данные, а после этого используйте iresine
DmitryKazakov8
По мне так строчка
this.users.set(user.id, user)
выигрывает по сравнению с созданием слоя нормализации с эвент-эмиттером, изменением большого количества сущностей с добавлением лишнего type, большого оверхеда по обучению команды и поддержанию единообразия. И в итоге идеально все равно не получится — будут легаси-ручки бэка или сторонних интеграций, данные в которых не поменять, и "нормализация" кашеобразно распараллелится на фронт и бэк. Да и в самом фронте соблюдать получится только при создании нового проекта, так как внедрять подобный слой в существующую кодовую базу дело недель работы без какого-либо бизнес-выхлопа, кроме негативного — увеличения сложности поддержки.Статью внимательно прочитал, но так и не понял, зачем это нужно. Хранилища в памяти с ключом по id в моей практике пригождались только 2 раза — при получении частичных данных (например, user) в десятке разных ручек (но выбранный подход с нормализацией оказался в итоге не самым эффективным, добавив значительный оверхед) и при необходимости скорости доступа О1 в требовательных к перфомансу интерфейсах при наличии 10к+ элементов в массиве. В остальных случаях
store.update(store.users, user)
, где update соответствующей стратегией находит целевой элемент или создает новый, идеальный вариант.utftufutukgyftryidytftuv Автор
До того момента когда я начал работать c клиентом apollo, строка
this.users.set(user.id, user)
не казалась мне такой страшной. Однако вернувшись из мира graphql где нормализация есть, есть ощущение что ты пишешь лишний код. Отсутсвие строки лучше чем ее наличие, аКонтраргументы:
Если бы в приложениях не использовался никакой state-manager, то я бы согласился что это лишний слой абстракции. Однако данный слой является дополнительным к слою state-manager-a с котором уже работает разработчик и который и так уже существует. То есть для разработчика ничего не меняется в работе, ни с получением данных ни с их отрисовкой.
Поле
__typename
в моей практике было достаточно полезным. Конечно было бы идеально не добавлять полей, однако использование JSON-a как самого популярного протокола диктует свои требования. В идеальном мире мы могли бы обойтись и без добавления поляid
в данные, и сравнивать только адрес объекта в памятиunknownUser === targetUser
. Но идеального решения не существует, и добавления данных о типе это минимальная цена.Как мне кажется это стоит 0 или почти 0. Как я упоминал разработчик не сталкивается со слоем нормализации напрямую, если только не считает количество обновлений в каждом подписанном компоненте. При этом он сталкивается с нормализацией при использовании строки
this.users.set(user.id, user)
.Поскольку iresine толератно работает с сущностями без типа, то можно добавлять нормализацию для сущностей постепенно. Никто не заставляет переписывать все и сразу. Тем более кажется это дело нескольких часов, а не недель. О бизнес выхлопе кажется можно вполне обосновано объяснить себе и менеджеру, что данные в разных частях приложения должны совпадать.
DmitryKazakov8
"Отсутствие строки лучше чем ее наличие" — так же относится и к двум дополнительным параметрам, дополнительному слою, дополнительной библиотеке и документации. Этих строк с partialAssign будет ну пара десятков на среднее приложение, а размер библиотеки, включенной в бандл и код обвязки займет явно намного больший объем, увеличив и количество поддерживаемых компонентов системы. Поэтому аргумент звучит неубедительно.
Я очень сомневаюсь, что это стоит 0, вообще-то это все довольно дорого.
Как я понял, в целом эта система нужна, если одинаковые запросы шлются из разных компонентов и данные складываются в разные хранилища, и именно для этого нужны "глобальные id для всех сущностей". В реальности за несколько лет мне не попадалось таких "архитектур" — если данные используются в нескольких компонентах, то они выносятся в глобальное хранилище (redux, mobx) и их обновление происходит явно, id нужен только для partialUpdate. Поэтому я так и негативно отнесся к предложениям в статье. Но если действительно приложение так построено, что данные и методы их получения многократно дублируются и раскиданы по локальным сторам, то подобная нормализация, безусловно, нужна. Как нужно и переписывание архитектуры ядра, на мой взгляд.
"для разработчика ничего не меняется в работе, ни с получением данных" — вот это все равно звучит очень странно. Нормализованные или ненормализованные данные все равно же надо положить в хранилище, не будет же iresine этим заниматься? Или подразумевается, что все полученные данные вместо redux или mobx будут храниться в некоем iresineStore и напрямую получаться оттуда вместо других хранилищ без системы синхронизации с основным стором?
utftufutukgyftryidytftuv Автор
Именно iresine и должна этим заниматься, синхронизируя данные в хранилище и собственном сторе.
Вероятно в описании я не слишком подробно остановился как использовать нормализацию.
В моем представлении iresine хорошо сочетается с менеджерами
server-state
(например react-query или swr) sandbox. Как видно из примера iresine действительно сама подменяет данные в хранилище, уведомляя об этом ui с помощью основного стейт-менеджера.DmitryKazakov8
То есть берется инструмент запросов с неявным внутренним хранилищем, неявно там что-то нормализуется и преобразовывается с помощью мидлвары… Для остальных интеракций, видимо, будет заведен другой стейт-менеджер, и каким-то образом нужно будет синхронизировать с этим react-query… Что-то вообще не понимаю смысла всего этого.
Я мыслю в категориях
api.getUser(user => store.createOrUpdate(store.user, user))
иrender() { return <>{this.context.store.users}</> }
с разделением слоев апи, модификаторов стора, хранилищ и реакт-компонентов, отвечающих за View. Запросы из компонентов и параллельные сторы, неявные трансформации и технические параметры в данных — это для меня другой мир...Ну, может, кому-то пригодится.