web-dev-qa-db-ja.com

Reduxでリレーショナルデータを処理する方法

私が作成しているアプリには、多くのエンティティと関係があります(データベースはリレーショナルです)。アイデアを得るために、25を超えるエンティティがあり、それらの間には任意のタイプの関係(1対多、多対多)があります。

アプリはReact + Reduxベースです。ストアからデータを取得するために、 Reselect ライブラリーを使用しています。

私が直面している問題は、その関係を持つエンティティをストアから取得しようとするときです。

問題をよりよく説明するために、私は同様のアーキテクチャを持つ単純なデモアプリを作成しました。最も重要なコードベースを強調します。最後に、スニペット(フィドル)を組み込んで再生します。

デモアプリ

ビジネスの論理

本と著者がいます。 1つの本には1人の著者がいます。 1人の著者が多くの本を持っています。できるだけシンプル。

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

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

Reduxストア

ストアは、Reduxのベストプラクティスに準拠したフラット構造で編成されています- Normalizing State Shape

本と著者ストアの両方の初期状態は次のとおりです。

_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]) ));
_

リレーショナルデータを計算/クエリするセレクターがあると、面倒になります。デモアプリには次の例が含まれています。

  1. 特定のカテゴリに少なくとも1冊の本があるすべての著者を取得します。
  2. 同じ著者を取得するが、彼らの本と一緒に。

厄介なセレクタは次のとおりです。

_/**
 * 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])
    }))
));
_

まとめ

  1. ご覧のとおり、セレクターでのリレーショナルデータの計算/クエリは複雑すぎます。
    1. 子関係をロードしています(著者->ブック)。
    2. 子エンティティによるフィルタリング(getHealthAuthorsWithBooksSelector())。
  2. エンティティに多数の子関係がある場合、セレクターパラメータが多すぎます。 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>_

JSFiddle

27
Jordan Enev

これは、データが非常にリレーショナルであるプロジェクトの1つをどのように開始したかを思い出させます。あなたは物事を行うバックエンドの方法についてあまり考えすぎますが、JSの物事の方法についてもっと考え始める必要があります(確かに、いくつかは怖い考えです)。

1)状態の正規化されたデータ

データの正規化は順調に進んでいますが、実際には、ある程度正規化されています。なぜ私はそれを言うのですか?

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

同じ概念データが2か所に保存されています。これは簡単に同期しなくなる可能性があります。たとえば、サーバーから新しい本を受け取ったとします。それらすべてのauthorIdが1である場合は、ブック自体を変更してそれらのIDを追加する必要もあります!これは、実行する必要のない多くの追加作業です。そして、それが行われない場合、データは同期しなくなります。

Reduxスタイルアーキテクチャの一般的な経験則の1つは、計算できるものを(状態に)保存しないことです。これにはこの関係も含まれ、authorIdによって簡単に計算されます。

2)セレクターの非正規化データ

その状態でデータを正規化することは良くないと述べました。しかし、セレクターで非正規化しても大丈夫ですか?そうですね。しかし、問題は、それが必要かどうかです。私はあなたが今行っているのと同じことを行い、セレクターを基本的にバックエンドORMのように動作させました。 「author.booksに電話してすべての本を手に入れたい!」あなたは考えているかもしれません。 Reactコンポーネント内のauthor.booksをループして、各本をレンダリングできるようにするのはとても簡単ですよね?

しかし、あなたは本当にあなたの州のすべてのデータを正規化したいですか? Reactでは必要ありません。実際、メモリ使用量も増加します。何故ですか?

たとえば、同じauthorのコピーが2つあるためです。

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は作成者ごとに新しいオブジェクトを作成します。これは、状態のオブジェクトに対して===ではありません。

これは悪くない。しかし、私はそれがidealではないと言います。 redundant(<-keyword)メモリ使用量に加えて、ストア内の各エンティティへの単一の信頼できる参照を1つ持つことをお勧めします。現在、各作者には概念的に同じ2つのエンティティがありますが、プログラムではそれらを完全に異なるオブジェクトと見なしています。

それでは、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),
});

ああ、このコンポーネントが本当に必要とする唯一のデータ! books、およびauthors。その中のデータを使用して、必要なものをすべて計算できます。

getAuthorsSelectorからgetAuthorsに変更したことに注意してください。これは、計算に必要なすべてのデータがbooks配列にあり、作成者をidだけプルすることができるためです。

セレクターの使用についてはまだ心配していません。問題について簡単に考えてみましょう。それでは、insideコンポーネント、作者による本の「インデックス」を作成しましょう。

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で非正規化しませんでした。そうだね正解!ただし、この場合、redundant情報でメモリを消費していません。実際、booksauthorsの各エンティティは、ストア内の元のオブジェクトへの参照にすぎません。つまり、使用される新しいメモリは、コンテナの配列/オブジェクト自体によるものであり、実際のアイテムではありません。

この種類のソリューションは、多くのユースケースに理想的であることがわかりました。もちろん、上記のようにコンポーネントに保持するのではなく、特定の基準に基づいてセレクターを作成する再利用可能な関数に抽出します。 ただし、特定のエンティティthrough別のエンティティをフィルタリングする必要があるという点で、あなたと同じ複雑さで問題がなかったことは認めます。うわぁ!しかし、まだ実行可能です。

インデクサー関数を再利用可能な関数に抽出してみましょう。

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のようなネストされたバージョンを与えるのではなく、リンクする固有の機能を使用してください既存のツール(IDとオブジェクト参照)を使用したデータ。

これらの原則は、現在のセレクターにも適用できます。データの考えられるあらゆる組み合わせに対して高度に専門化されたセレクターの束を作成するのではなく... 1)特定のパラメーターに基づいてセレクターを作成する関数を作成します2)さまざまなresultFuncとして使用できる関数を作成しますセレクター

索引付けはすべての人のためのものではありません。他の人に他の方法を提案させます。

23
aaronofleonard

質問者はこちら!

1年後、今ここで私の経験と考えを要約します。

リレーショナルデータを処理するための2つの可能なアプローチを検討していました。

1.インデックス作成

aaronofleonard は、すでにすばらしい非常に詳細な回答を提供してくれました here ここで、彼の主な概念は次のとおりです。

正規化されたデータで状態のコピーを再作成することは考えていません。コンポーネントによって簡単にダイジェストされる、その状態の*インデックス付きの表現(読み取り:参照)を作成しようとしています。

それは例に完全に適合していると彼は述べています。しかし、彼の例が1対多の関係(1つの本には多くの著者がいる)の関係に対してのみインデックスを作成することを強調することが重要です。だから私はこのアプローチが私のすべての可能な要件にどのように適合するかについて考え始めました:

  1. man-to-manyケースの処理。例:BookStoreを通じて、1つのブックに多数の著者がいます。
  2. 取り扱い深層ろ過。例:少なくとも著者が特定の国からのものである、健康的なカテゴリからすべての書籍を取得します。ここで、エンティティのネストされたレベルがもっと多いと想像してみてください。

もちろんそれは可能ですが、ご覧のとおり、事態はすぐに深刻化する可能性があります。

インデックス作成でこのような複雑さを管理することに慣れている場合は、セレクタを作成してインデックス作成ユーティリティを構成するための十分な設計時間があることを確認してください。

そのようなインデックス作成ユーティリティを作成すると、プロジェクトの範囲から完全に外れるため、解決策を探し続けました。サードパーティのライブラリを作成するようなものです。

それで、私は Redux-ORM ライブラリを試してみることにしました。

2. Redux-ORM

Reduxストア内のリレーショナルデータを管理するための小さくシンプルで不変のORM。

冗長ではなく、ライブラリを使用してすべての要件を管理する方法を次に示します。

// 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は非常に使いやすい方法でクエリ、フィルタリングなどのリレーショナル操作を扱います。涼しい!

しかし、セレクターの再利用性、構成、拡張などについて話すとき、それは一種のトリッキーで厄介なタスクです。 reselectライブラリ自体とその動作方法よりも、Redux-ORMの問題ではありません。 ここ トピックについて話し合いました。

結論(個人)

より単純なリレーショナルプロジェクトの場合は、インデックス作成アプローチを試してみます。

それ以外の場合は、アプリで使用したRedux-ORMを使用します。そこには70以上のエンティティがありますが、まだ数えています!

7
Jordan Enev

他の名前付きセレクター(getHealthAuthorsSelectorなど)を使用してセレクター(getHealthAuthorsWithBooksSelectorなど)の "オーバーロード"を開始すると、getHealthAuthorsWithBooksWithRelatedBooksSelectorなどのようなものになる可能性があります。

それは持続可能ではありません。高レベルなもの(つまりgetHealthAuthorsSelector)に固執し、それらの本やそれらの本の関連本などが常に利用できるようにするメカニズムを使用することをお勧めします。

TypeScriptを使用してauthor.booksゲッターに入力するか、必要なときにいつでもストアから書籍を取得するための便利な関数を使用します。アクションを使用すると、ストアからの取得とdbからのフェッチを組み合わせて、古いデータを(場合によっては)直接表示し、データベースからデータが取得されると、Redux/Reactに視覚的な更新を処理させることができます。

私はこの再選択について聞いたことがありませんが、コンポーネント内のコードの重複を避けるためにすべての種類のフィルターを1か所に置くのは良い方法のように思えます。
シンプルなので、テストも簡単です。ビジネス/ドメインロジックのテストは、特に自分がドメインの専門家でない場合は、通常(非常に?)良いアイデアです。

また、複数のエンティティを新しいものに結合することは、たとえばエンティティをフラット化してグリッドコントロールに簡単にバインドできるようにするなど、時々役立ちます。

4
Laoujin