Как работать с реляционными данными в Redux?

В приложении, которое я создаю, есть много сущностей и отношений (база данных реляционная). Чтобы получить представление, существует 25 + сущностей с любыми типами отношений между ними (один-ко-многим, многие-ко-многим).

В приложении используется React + Redux. Для получения данных из Магазина мы используем Reselect библиотеку.

Проблема, с которой я столкнулся, - это когда я пытаюсь получить сущность с ее отношениями из Хранилища.

Чтобы лучше объяснить проблему, я создал простое демонстрационное приложение, имеющее аналогичную архитектуру. Я расскажу о самой важной кодовой базе. В конце я включу фрагмент (скрипку), чтобы играть с ним.

Демо-приложение

Бизнес-логика

У нас есть книги и авторы. В одной книге есть один автор. У одного автора много книг. Насколько это возможно.

const authors = [{
  id: 1,
  name: 'Jordan Enev',
  books: [1]
}];

const books = [{
  id: 1,
  name: 'Book 1',
  category: 'Programming',
  authorId: 1
}];

Магазин Redux

Магазин организован в плоской структуре, совместимой с лучшими практиками Redux - Нормализация формы состояния.

Вот начальное состояние как для книг, так и для авторов:

const initialState = {
  // Keep entities, by id:
  // { 1: { name: '' } }
  byIds: {},
  // Keep entities ids
  allIds:[]
};

Компоненты

Компоненты организованы как контейнеры и презентации.

Компонент

<App /> действует как Контейнер (получает все необходимые данные):

const mapStateToProps = state => ({
  books: getBooksSelector(state),
  authors: getAuthorsSelector(state),
  healthAuthors: getHealthAuthorsSelector(state),
  healthAuthorsWithBooks: getHealthAuthorsWithBooksSelector(state)
});

const mapDispatchToProps = {
  addBooks, addAuthors
}

const App = connect(mapStateToProps, mapDispatchToProps)(View);
Компонент

<View /> предназначен только для демонстрации. Он подталкивает фиктивные данные в хранилище и отображает все компоненты презентации как <Author />, <Book />.

Селекторы

Для простых селекторов это выглядит просто:

/**
 * Get Books Store entity
 */
const getBooks = ({books}) => books;

/**
 * Get all Books
 */
const getBooksSelector = createSelector(getBooks,
    (books => books.allIds.map(id => books.byIds[id]) ));


/**
 * Get Authors Store entity
 */
const getAuthors = ({authors}) => authors;

/**
 * Get all Authors
 */
const getAuthorsSelector = createSelector(getAuthors,
    (authors => authors.allIds.map(id => authors.byIds[id]) ));

Это становится беспорядочным, когда у вас есть селектор, который вычисляет/запрашивает реляционные данные. Демо-приложение включает следующие примеры:

  • Получение всех авторов, имеющих по крайней мере одну книгу в определенной категории.
  • Получение одинаковых авторов, но вместе со своими Книгами.

Вот скверные селектора:

/**
 * Get array of Authors ids,
 * which have books in 'Health' category
 */  
const getHealthAuthorsIdsSelector = createSelector([getAuthors, getBooks],
    (authors, books) => (
    authors.allIds.filter(id => {
      const author = authors.byIds[id];
      const filteredBooks = author.books.filter(id => (
        books.byIds[id].category === 'Health'
      ));

      return filteredBooks.length;
    })
)); 

/**
 * Get array of Authors,
 * which have books in 'Health' category
 */   
const getHealthAuthorsSelector = createSelector([getHealthAuthorsIdsSelector, getAuthors],
    (filteredIds, authors) => (
    filteredIds.map(id => authors.byIds[id])
)); 

/**
 * Get array of Authors, together with their Books,
 * which have books in 'Health' category
 */    
const getHealthAuthorsWithBooksSelector = createSelector([getHealthAuthorsIdsSelector, getAuthors, getBooks],
    (filteredIds, authors, books) => (
    filteredIds.map(id => ({
        ...authors.byIds[id],
      books: authors.byIds[id].books.map(id => books.byIds[id])
    }))
));

Подведение итогов

  • Как вы можете видеть, вычисление/запрос реляционных данных в селекторах становится слишком сложным.
    • Загрузка дочерних отношений (Автор- > Книги).
    • Фильтрация дочерними объектами (getHealthAuthorsWithBooksSelector()).
  • Слишком много параметров выбора, если у сущности много дочерних отношений. Оформить заказ getHealthAuthorsWithBooksSelector() и представить, имеет ли автор больше отношений.

Итак, как вы относитесь к отношениям в Redux?

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

* Я проверил redux-orm, и это выглядит многообещающим, но его API по-прежнему нестабилен, и я не уверен, что он готов к производству.

const { Component } = React
const { combineReducers, createStore } = Redux
const { connect, Provider } = ReactRedux
const { createSelector } = Reselect

/**
 * Initial state for Books and Authors stores
 */
const initialState = {
  byIds: {},
  allIds:[]
}

/**
 * Book Action creator and Reducer
 */

const addBooks = payload => ({
  type: 'ADD_BOOKS',
  payload
})

const booksReducer = (state = initialState, action) => {
  switch (action.type) {
  case 'ADD_BOOKS':
    let byIds = {}
    let allIds = []

    action.payload.map(entity => {
      byIds[entity.id] = entity
      allIds.push(entity.id)
    })

    return { byIds, allIds }
  default:
    return state
  }
}

/**
 * Author Action creator and Reducer
 */

const addAuthors = payload => ({
  type: 'ADD_AUTHORS',
  payload
})

const authorsReducer = (state = initialState, action) => {
  switch (action.type) {
  case 'ADD_AUTHORS':
    let byIds = {}
    let allIds = []

    action.payload.map(entity => {
      byIds[entity.id] = entity
      allIds.push(entity.id)
    })

    return { byIds, allIds }
  default:
    return state
  }
}

/**
 * Presentational components
 */
const Book = ({ book }) => <div>{`Name: ${book.name}`}</div>
const Author = ({ author }) => <div>{`Name: ${author.name}`}</div>

/**
 * Container components
 */

class View extends Component {
  componentWillMount () {
    this.addBooks()
    this.addAuthors()
  }

  /**
   * Add dummy Books to the Store
   */
  addBooks () {
    const books = [{
      id: 1,
      name: 'Programming book',
      category: 'Programming',
      authorId: 1
    }, {
      id: 2,
      name: 'Healthy book',
      category: 'Health',
      authorId: 2
    }]

    this.props.addBooks(books)
  }

  /**
   * Add dummy Authors to the Store
   */
  addAuthors () {
    const authors = [{
      id: 1,
      name: 'Jordan Enev',
      books: [1]
    }, {
      id: 2,
      name: 'Nadezhda Serafimova',
      books: [2]
    }]

    this.props.addAuthors(authors)
  }

  renderBooks () {
    const { books } = this.props

    return books.map(book => <div key={book.id}>
      {`Name: ${book.name}`}
    </div>)
  }

  renderAuthors () {
    const { authors } = this.props

    return authors.map(author => <Author author={author} key={author.id} />)
  }

  renderHealthAuthors () {
    const { healthAuthors } = this.props

    return healthAuthors.map(author => <Author author={author} key={author.id} />)
  }

  renderHealthAuthorsWithBooks () {
    const { healthAuthorsWithBooks } = this.props

    return healthAuthorsWithBooks.map(author => <div key={author.id}>
      <Author author={author} />
      Books:
      {author.books.map(book => <Book book={book} key={book.id} />)}
    </div>)
  }

  render () {
    return <div>
      <h1>Books:</h1> {this.renderBooks()}
      <hr />
      <h1>Authors:</h1> {this.renderAuthors()}
      <hr />
      <h2>Health Authors:</h2> {this.renderHealthAuthors()}
      <hr />
      <h2>Health Authors with loaded Books:</h2> {this.renderHealthAuthorsWithBooks()}
    </div>
  }
};

const mapStateToProps = state => ({
  books: getBooksSelector(state),
  authors: getAuthorsSelector(state),
  healthAuthors: getHealthAuthorsSelector(state),
  healthAuthorsWithBooks: getHealthAuthorsWithBooksSelector(state)
})

const mapDispatchToProps = {
  addBooks, addAuthors
}

const App = connect(mapStateToProps, mapDispatchToProps)(View)

/**
 * Books selectors
 */

/**
 * Get Books Store entity
 */
const getBooks = ({ books }) => books

/**
 * Get all Books
 */
const getBooksSelector = createSelector(getBooks,
  books => books.allIds.map(id => books.byIds[id]))

/**
 * Authors selectors
 */

/**
 * Get Authors Store entity
 */
const getAuthors = ({ authors }) => authors

/**
 * Get all Authors
 */
const getAuthorsSelector = createSelector(getAuthors,
  authors => authors.allIds.map(id => authors.byIds[id]))

/**
 * Get array of Authors ids,
 * which have books in 'Health' category
 */
const getHealthAuthorsIdsSelector = createSelector([getAuthors, getBooks],
  (authors, books) => (
    authors.allIds.filter(id => {
      const author = authors.byIds[id]
      const filteredBooks = author.books.filter(id => (
        books.byIds[id].category === 'Health'
      ))

      return filteredBooks.length
    })
  ))

/**
 * Get array of Authors,
 * which have books in 'Health' category
 */
const getHealthAuthorsSelector = createSelector([getHealthAuthorsIdsSelector, getAuthors],
  (filteredIds, authors) => (
    filteredIds.map(id => authors.byIds[id])
  ))

/**
 * Get array of Authors, together with their Books,
 * which have books in 'Health' category
 */
const getHealthAuthorsWithBooksSelector = createSelector([getHealthAuthorsIdsSelector, getAuthors, getBooks],
  (filteredIds, authors, books) => (
    filteredIds.map(id => ({
      ...authors.byIds[id],
      books: authors.byIds[id].books.map(id => books.byIds[id])
    }))
  ))

// Combined Reducer
const reducers = combineReducers({
  books: booksReducer,
  authors: authorsReducer
})

// Store
const store = createStore(reducers)

const render = () => {
  ReactDOM.render(<Provider store={store}>
    <App />
  </Provider>, document.getElementById('root'))
}

render()
<div id="root"></div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react-dom.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-core/5.8.24/browser.js"></script>
<script src="https://npmcdn.com/[email protected]/dist/reselect.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/redux/3.3.1/redux.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-redux/4.4.6/react-redux.min.js"></script>

Ответ 1

Это напоминает мне, как я начал один из моих проектов, где данные были очень реляционными. Вы слишком много думаете о бэкэнде, чтобы делать что-то, но вам нужно подумать о том, как JS может делать что-то (по крайней мере, это страшная мысль для некоторых).

1) Нормализованные данные в состоянии

Вы неплохо выполнили нормализацию своих данных, но на самом деле это только несколько нормализовалось. Почему я так говорю?

...
books: [1]
...
...
authorId: 1
...

У вас есть те же концептуальные данные, которые хранятся в двух местах. Это может легко выходить из строя. Например, скажем, вы получаете новые книги с сервера. Если у всех их есть authorId из 1, вам также необходимо изменить саму книгу и добавить к ней эти идентификаторы! Это много дополнительной работы, которую не нужно делать. И если это не будет сделано, данные будут не синхронизированы.

Одно общее правило с архитектурой стиля redux никогда не хранит (в состоянии) то, что вы можете вычислить. Это включает это отношение, оно легко вычисляется authorId.

2) Денормализованные данные в селекторах

Мы упоминали о том, что нормализованные данные в штате не были хорошими. Но денормализация его в селекторах в порядке? Ну, это так. Но вопрос в том, нужен ли он? Я сделал то же самое, что вы делаете сейчас, получив селектор в основном, как бэкэнд-ORM. "Я просто хочу иметь возможность называть author.books и получать все книги!" вы можете думать. Было бы так просто, чтобы иметь возможность author.books в вашем компоненте React и отображать каждую книгу, правильно?

Но действительно ли вы хотите нормализовать каждую часть данных в своем штате? Реакция не нуждается в этом. Фактически, это также увеличит использование вашей памяти. Почему это?

Потому что теперь у вас будет две копии одного и того же author, например:

const authors = [{
  id: 1,
  name: 'Jordan Enev',
  books: [1]
}];

а также

const authors = [{
  id: 1,
  name: 'Jordan Enev',
  books: [{
      id: 1,
      name: 'Book 1',
      category: 'Programming',
      authorId: 1
  }]
}];

Таким образом, getHealthAuthorsWithBooksSelector теперь создает новый объект для каждого автора, который не будет === который находится в состоянии.

Это неплохо. Но я бы сказал, что это не идеально. В дополнение к избыточному (<- ключевому слову) использованию памяти лучше иметь одну единственную авторитетную ссылку на каждый объект в вашем магазине. Прямо сейчас для каждого автора существуют две сущности, которые одинаково концептуально, но ваша программа рассматривает их как совершенно разные объекты.

Итак, теперь, когда мы смотрим на ваш mapStateToProps:

const mapStateToProps = state => ({
  books: getBooksSelector(state),
  authors: getAuthorsSelector(state),
  healthAuthors: getHealthAuthorsSelector(state),
  healthAuthorsWithBooks: getHealthAuthorsWithBooksSelector(state)
});

В основном вы предоставляете компонент с 3-4 различными копиями всех тех же данных.

Думая о решениях

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

const mapStateToProps = state => ({
  books: getBooksSelector(state),
  authors: getAuthors(state),
});

Ahh, единственные данные, которые действительно нужны этому компоненту! books и authors. Используя данные в нем, он может вычислить все, что ему нужно.

Обратите внимание, что я изменил его с getAuthorsSelector на просто getAuthors? Это связано с тем, что все данные, которые нам нужны для вычислений, находятся в массиве books, и мы можем просто вытащить авторов по id который у нас есть!

Помните, что мы пока не беспокоимся о том, чтобы использовать селекторы, просто подумайте об этой проблеме простыми словами. Итак, внутри компонента, позвольте построить "индекс" книг их автора.

const { books, authors } = this.props;

const healthBooksByAuthor = books.reduce((indexedBooks, book) => {
   if (book.category === 'Health') {
      if (!(book.authorId in indexedBooks)) {
         indexedBooks[book.authorId] = [];
      }
      indexedBooks[book.authorId].push(book);
   }
   return indexedBooks;
}, {});

И как мы его используем?

const healthyAuthorIds = Object.keys(healthBooksByAuthor);

...
healthyAuthorIds.map(authorId => {
    const author = authors.byIds[authorId];

    return (<li>{ author.name }
       <ul>
         { healthBooksByAuthor[authorId].map(book => <li>{ book.name }</li> }
       </ul>
    </li>);
})
...

И т.д.

Но но вы упомянули ранее, что мы не getHealthAuthorsWithBooksSelector вещи с помощью getHealthAuthorsWithBooksSelector, так? Верный! Но в этом случае мы не занимаемся памятью с избыточной информацией. Фактически, каждая отдельная сущность, books и author справка - это просто ссылка на оригинальные объекты в магазине! Это означает, что единственная новая память занята самими контейнерами/объектами, а не фактическими элементами в них.

Я нашел такое решение идеальным для многих случаев использования. Конечно, я не сохраняю его в компоненте, как указано выше, я извлекаю его в функцию многократного использования, которая создает селекторы на основе определенных критериев. Хотя, я соглашусь, у меня не было проблемы с такой же сложностью, как у вас, поскольку вам нужно отфильтровать конкретный объект через другой объект. Хлоп! Но все же выполнимо.

Позвольте извлечь функцию индексатора в функцию многократного использования:

const indexList = fieldsBy => list => {
 // so we don't have to create property keys inside the loop
  const indexedBase = fieldsBy.reduce((obj, field) => {
    obj[field] = {};
    return obj;
  }, {});

  return list.reduce(
    (indexedData, item) => {
      fieldsBy.forEach((field) => {
        const value = item[field];

        if (!(value in indexedData[field])) {
          indexedData[field][value] = [];
        }

        indexedData[field][value].push(item);
      });

      return indexedData;
    },
    indexedBase,
  );
};

Теперь это похоже на чудовище. Но мы должны сделать некоторые части нашего кодового комплекса, поэтому мы можем сделать еще много деталей чистыми. Чистота как?

const getBooksIndexed = createSelector([getBooksSelector], indexList(['category', 'authorId']));
const getBooksIndexedInCategory = category => createSelector([getBooksIndexed],
    booksIndexedBy => {
        return indexList(['authorId'])(booksIndexedBy.category[category])
    });
    // you can actually abstract this even more!

...
later that day
...

const mapStateToProps = state => ({
  booksIndexedBy: getBooksIndexedInCategory('Health')(state),
  authors: getAuthors(state),
});

...
const { booksIndexedBy, authors } = this.props;
const healthyAuthorIds = Object.keys(booksIndexedBy.authorId);

healthyAuthorIds.map(authorId => {
    const author = authors.byIds[authorId];

    return (<li>{ author.name }
       <ul>
         { healthBooksByAuthor[authorId].map(book => <li>{ book.name }</li> }
       </ul>
    </li>);
})
...

Разумеется, это не так легко понять, потому что он в основном опирается на составление этих функций и селекторов для построения представлений данных вместо их перенормировки.

Дело в том, что мы не пытаемся воссоздать копии состояния с нормализованными данными. Мы пытаемся * создавать индексированные представления (чтение: ссылки) этого состояния, которые легко усваиваются компонентами.

Индексирование, представленное здесь, очень многократно используется, но не без определенных проблем (я позволю всем понять их). Я не ожидаю, что вы его используете, но я ожидаю, что вы узнаете об этом из этого: вместо того, чтобы пытаться принудить ваших селекторов предоставить вам похожие на ORM вложенные версии ваших данных, используйте присущую способность связывать ваши данные с помощью инструментов, которые у вас уже есть: идентификаторы и ссылки на объекты.

Эти принципы могут применяться даже к вашим текущим селекторам. Вместо того, чтобы создавать множество специализированных селекторов для каждой мыслимой комбинации данных... 1) Создавать функции, которые создают для вас селекторы на основе определенных параметров. 2) Создавать функции, которые могут использоваться как resultFunc из множества разных селекторов

Индексация не для всех, я позволю другим предлагать другие методы.

Ответ 2

Когда вы запускаете "перегрузку" ваших селекторов (например, getHealthAuthorsSelector) другими именованными селекторами (например, getHealthAuthorsWithBooksSelector,...), вы можете получить что-то вроде getHealthAuthorsWithBooksWithRelatedBooksSelector и т.д. и т.д.

Это не является устойчивым. Я предлагаю вам придерживаться высоких уровней (т.е. getHealthAuthorsSelector) и использовать механизм, чтобы всегда были доступны их книги и связанные книги из этих книг и т.д.

Вы можете использовать TypeScript и превратить author.books в getter или просто работать с функциями covenience, чтобы получать книги из магазина, когда они нужны. С помощью действия вы можете комбинировать получение из хранилища с извлечением из db, чтобы отображать (возможно) устаревшие данные напрямую, и Redux/React заботится о визуальном обновлении после того, как данные будут получены из базы данных.

Я не слышал об этом Reselect, но кажется, что это может быть хороший способ иметь всевозможные фильтры в одном месте, чтобы избежать дублирования кода в компонентах.
Простые, как они есть, они также легко проверяются. Логическое тестирование бизнес-процессов обычно является хорошей идеей (особенно?), Особенно если вы сами не являетесь экспертом в области.

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

Ответ 3

Автор вопроса здесь!

Через год я собираюсь обобщить свой опыт и мысли здесь.

Я рассматривал два возможных подхода к обработке реляционных данных:

1. Индексирование

aaronofleonard, уже дал нам большой и очень подробный ответ здесь, где его основная концепция такова:

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

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

  1. Передача случаев "многие ко многим". Пример: у одной книги много авторов, через BookStore.
  2. Обработка Глубокая фильтрация. Пример. Получите все книги из категории "Здоровая", где, по крайней мере, автор находится в определенной стране. Теперь просто представьте, есть ли у нас еще много вложенных уровней сущностей.

Конечно, это выполнимо, но, как вы можете видеть, все может скоро стать серьезным.

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

Я продолжал искать решение, потому что создание такой утилиты индексирования выглядит полностью вне проекта для проекта. Это больше похоже на создание сторонней библиотеки.

Поэтому я решил попробовать библиотеку Redux-ORM.

2. Редукс-ОРМ

Небольшой, простой и неизменный ORM для управления реляционными данными в вашем магазине Redux.

Не будучи подробным, вот как я справился со всеми требованиями, просто используя библиотеку:

// Handing many-to-many case.
const getBooks = createSelector({ Book } => {
  return Books.all().toModelArray()
   .map( book => ({ 
     book: book.ref,
     authors: book.authors.toRefArray()
   })
})

// Handling Deep filtration.
// Keep in mind here you can pass parameters, instead of hardcoding the filtration criteria.
const getFilteredBooks = createSelector({ Book } => {
  return Books.all().toModelArray()
   .filter( book => {
     const authors = book.authors.toModelArray()
     const hasAuthorInCountry = authors.filter(a => a.country.name === 'Bulgaria').length

     return book.category.type === 'Health' && hasAuthorInCountry
   })
   .map( book => ({ 
     book: book.ref,
     authors: book.authors.toRefArray()
   })
})

Как вы можете видеть - библиотека обрабатывает все отношения для нас, и мы можем легко получить доступ ко всем дочерним объектам и выполнить сложное вычисление.

Кроме того, используя .ref мы возвращаем ссылку на хранилище объектов вместо создания новой копии объекта (вас беспокоит память).

Поэтому, имея этот тип селекторов, мой поток выглядит следующим образом:

  1. Контейнерные компоненты извлекают данные через API.
  2. Селекторы получают только необходимый фрагмент данных.
  3. Отобразить компоненты презентации.

Однако ничто не является совершенным, как кажется. Redux-ORM имеет дело с реляционными операциями как запрос, фильтрация и т.д. Очень простым способом. Круто!

Но когда мы говорим о повторном использовании селекторов, составе, расширении и т.д. - это какая-то сложная и неловкая задача. Это не проблема Redux-ORM, а не reselect библиотеки и способ ее работы. Здесь мы обсуждали эту тему.

Заключение (личное)

Для более простых реляционных проектов я бы попытался использовать метод индексирования.

В противном случае я бы придерживался Redux-ORM, поскольку использовал его в приложении, для которого я задал вопрос. Там у меня 70 моделей и все еще подсчитываю!