Skip to content

Minminzei/example-graphql-relay

Repository files navigation

GraphQL / Relay サンプル

このレポジトリは GraphQL, React Relay を使ったサンプルです。このサンプルシステムでは次のことをカバーしています。

  • Query/ Mutation の基本的な使い方
  • Fragment Colocation
  • Connection の使い方
  • Relay-style に基づいたページネーション
  • ユーザーフレンドリーなエラー UX
  • Recoil を使った画面をまたがったメッセージ処理
2022-09-04.17.18.25.mov

目次

  1. 環境構築

  2. Relay の特徴

  3. 宣言的なデータ通信

  4. Relay のキャッシュ管理

  5. Fragment Colocation

  6. エラー UX の設計

  7. mutation と connection

  8. relay-style による pagination

1. 環境構築

  1. docker 環境構築
## ソースを取得してdocker build
git clone
cd pengin
yarn
docker-compose up -d

## サーバーにログインして database 初期化
docker-compose exec app bash
yarn initPrisma
  1. サーバー起動
docker-compose exec app bash
yarn server
  1. アプリ起動
cd pengin
yarn ios

2. Relay の特徴

Relay は高いパフォーマンスと、バグが生じない堅牢なシステム開発を目的としたライブラリです。この特徴のため、レギュレーションが厳しく、初期学習が非常に大変です。しかし非常に良くできたライブラリなので、自社サービスの開発など堅牢性とメンテナンス性が重要なシーンではオススメです。

項目 Apollo Relay
レギュレーション 柔軟 ガチガチ
初期学習 優しい 難しい
堅牢性
パフォーマンス

3. 宣言的なデータ通信

GraphQL の最大の特徴はリクエストとレスポンスを静的に宣言するDeclarative Data-fetchingです。こうすることでサーバーサイドとクライアントサイドの開発を分離でき、データのunder-fetchやサーバー/クライアント間の型の不整合によるバグの発生を防ぐことができます。

4. Relay のキャッシュ管理

どうやってキャッシュを効率的かつ安全に管理するか?Relay はこのキャッシュ戦略上の課題にNormalized Cache というコンセプトでアプローチします。JSON のようなドキュメント型データには関連テーブルを階層的に埋め込むembeded型のデータ設計と、参照キーで関連付けるreferenced型のデータ設計があります。GraphQL スキーマ上のデータ構造はembeded型で構成されていますが、embeded型データのキャッシュ管理は非効率で堅牢でもないため、Relay はキャッシュ管理においてembeded型データをreferenced型データに変換します。この操作をNormalized Cacheと呼びます。

## GraphQL query
query {
  users {
    id
    name
    photo
    club {
      id
      name
    }
    friends {
      id
      name
      photo
    }
  }
}

## Embed型データオブジェクト = GraphQL通信で取得したデータ
{
  "user:1": {
    id: "user:1",
    name: "田中",
    photo: "photo1.jpg",
    club: {
      id: "club:1",
      name: "サッカー部",
    },
    friends: [
      {
        id: "user:2",
        name: "佐藤",
        photo: "photo2.jpg",
      }
    ],
  },
  "user:2": {
    id: "user:2",
    name: "佐藤",
    photo: "photo2.jpg",
    club: {
      id: "club:1",
      name: "サッカー部",
    },
    friends: [
      {
        id: "user:1",
        name: "田中",
        photo: "photo1.jpg",
      }
    ],
  }
}

## Reference型データオブジェクト = Relayのキャッシュ管理のためにNormalizeされたデータ
{
  "user:1": Map {
    name: "田中",
    photo: "photo1.jpg",
    club: Link("club:1"),
    friends: [Link("user:2")],
  },
  "club:1": Map {
    id: "club:1",
    name: "サッカー部",
  }
  "user:2": Map {
    name: "佐藤",
    photo: "photo2.jpg",
    club: Link("club:1"),
    friends: [Link("user:1")],
  },
}

例えばuser:2photoを変更すると embeded 型の場合、"user:1".friends."users:2""user:2" の 2 箇所を更新しないといけませんが、reference型の場合は"user:2"だけを更新すれば影響するデータ全てが最新状態に同期されます。このようにデータの更新回数を最小限にすることでキャッシュ管理を効率的で安全なものにしてデータの完全性を担保します。ちなみに Relay は ID をキーとしたデータに Normalize するために Relay における GlobalId はシステム全体でユニークにしないといけません(Inconsistent __typename error)

5. Fragment Colocation

前章のNormalized Cache によってキャッシュデータの一貫性を担保していると説明しましたが、React におけるキャッシュデータと View の一貫性をどう保つかが次の論点になります(Data Binding)。この論点をもう少し掘り下げると、① どうやって View で使用するデータと取得データの不整合を防ぐか?(under-fetch及び型エラーの問題)と、② キャッシュが更新されたとき、どうやって関連する View を再レンダリングするか?という2つに分解できます。この課題に対して Relay はFragment Colocationというコンセプトで効率的なパフォーマンスと堅牢性を実現します。

Co-location とは 2 対の対象物を関連付けて配置することで、Relay においてはデータと View Component を関連付けて実装することを意味しています。Fragment Colocationという概念は、 View Component が自身が使いたいデータを Fragment として宣言することで、View が過不足なく使用するデータを受け取ることができ(① の論点)、受け取ったデータが更新された場合、View 自身を再レンダリングすることができるようになる(② の論点)、ということを意味しています。早速UserScreens.tsxをサンプルにFragment Colocationを理解していきましょう。

[components/organisms/User/UserProfile.tsx]
fragment UserProfile_user on User {
  name
  image
  email
  division
}

[components/templates/ChatCreate.tsx]
fragment User_data on User {
  ...UserProfile_user
}

[screens/UserScreens.tsx]
query UserScreenQuery($id: ID!) {
  user(id: $id) {
    ...User_data
  }
}

Fragment Colocationでは各 Component が使いたいデータを宣言し、親のコンポーネントがそれらを集約してクエリを投げます。components/organisms/User/UserProfile.tsxではschema.grapqlに記載されたtype Userのうちname/ image/ email/ divisionを使いたいということを FragmentUserProfile_userで宣言します。components/templates/ChatCreate.tsxは受け取った FragmentUserProfile_userUser_dataというフラグメント名で宣言し、親のscreens/UserScreens.tsxquery userに対して、User_dataを使いたいと宣言しています。

このように Component が実際に使用するデータを宣言することでデータのunder-fetchや型エラーを回避し、親子間のデータのやり取りを Fragment で行うことで親子間の依存関係を最小にします。例えばcomponents/organisms/User/UserProfile.tsxで新たにgenderを使いたくなったときはfragment UserProfile_usergenderを追記するだけで良く、親のcomponents/templates/ChatCreate.tsxは子の変更について知る必要がありません。

次に View の再レンダリングについて。各コンポーネントが宣言したデータに変更があった場合、そのコンポーネントに再レンダリングが発火します。例えばusers.nameに変更があった場合、components/organisms/User/UserProfile.tsxのみに再レンダリングが走り、users.nameを使うと宣言していない親のcomponents/templates/ChatCreate.tsxscreens/UserScreens.tsxには発火しません。このように最小限の再レンダリングに抑えることで効率的なデータバインディングを実現しています。

6. エラー UX の設計

GraphQL のレスポンスはスキーマに定義されたデータを格納するdataと、エラー情報を格納するerrorsから構成されていますが、errorsを使ったエラー運用にはいくつか課題が残ります。

## GraphQLのレスポンス フォーマット
{
  "data": {
    スキーマに定義されたデータ
  },
  "errors": [
    {
      "message": "情報の取得に失敗しました",
      "locations": [{ "line": 6, "column": 7 }],
      "path": ["hero", "heroFriends", 1, "name"],
      "extensions": {
        "code": "NOT_FOUND",
      }
    }
  ],
}

一つ目はerrorsはスキーマの定義外になってしまうため、GraphQL の宣言的データ通信の利点が消えてしまうこと、二つ目はエラーの帰属先が曖昧になり、抽象的なエラー UX になってしまうことです。 本来エラーの多くは具体的な Component 内の操作に帰属するが、errorsを使うとエラーがリクエストに帰属してしまい、エラーメッセージと UIUX が乖離しやすくなってしまいます。例えば CTA は「資料を請求する」なのにエラーメッセージが「送信できませんでした」や「システムエラーが発生しました」では適切なエラー UX になっていません。エラー情報を静的に宣言し、具体的な Component と関連付ける(colocate)ことで、実際のユーザーの利用シーンを意識付け、役に立つエラー UX をユーザーに提供しやすくなります。Accessing errors in GraphQL Responses

このことを踏まえてcomponents/templates/ChatCreate.tsxcreateChatではチャット作成時のエラー UX を設計しています。

[schema.graphql]
type ChatDuplicateNameError {
  message: String!
}

type ChatCreatedError {
  message: String!
}

union ChatCreatedResult = ChatEdges | ChatDuplicateNameError | ChatCreatedError

type Mutation {
  createChat(input: CreateChatInput!): ChatCreatedResult!
}

----
[components/templates/ChatCreate.tsx]
mutation ChatCreateMutation($input: CreateChatInput!, $connections: [ID!]!) {
  createChat(input: $input) {
    __typename
    ... on ChatEdges {
      chatEdges @appendEdge(connections: $connections) {
        node {
          id
          title
        }
      }
    }
    ... on ChatDuplicateNameError {
      message
    }
    ... on ChatCreatedError {
      message
    }
  }
}

まずschema.graphqlで createChat のレスポンスとして、作成成功時のChatEdges、エラー発生時のChatDuplicateNameError, ChatCreatedErrorをスキーまで定義し、これら 3 つのいずれかを返すということを union 型で宣言しています。重要な論点として、ChatDuplicateNameErrorというエラー型を宣言することで、エラーの内容を予想できるようになりコードレビュー時に適切性を判断できるようになります。例えばChatDuplicateNameErrorのメッセージが「チャットを作成できませんでした」では不適切であるということがレビューしやすくなります。他にも UI 上に「一度作成したチャット名は利用できません」などを表示するとより分かりやすくなると判断できるようになり、実装レベルで UIUX の質を改善できます。

7. mutation と connection の更新

mutation の基本的な使い方はcomponents/organisms/Chat/ChatPost.tsxの createPost を参照にします。ここではチャットのタイムラインにメッセージを投稿しています。

[components/organisms/Chat/ChatPost.tsx]
mutation ChatPostMutation($input: CreatePostInput!, $connections: [ID!]!) {
  createPost(input: $input) {
    __typename
    ... on PostEdges {
      postEdges @prependEdge(connections: $connections) {
        cursor
        node {
          id
          content
          user {
            name
            image
          }
        }
      }
    }
    ... on CreatePostError {
      message
    }
  }
}

createPost のレスポンスは、投稿が成功した場合は投稿メッセージであるPostEdgesを、失敗した場合はCreatePostErrorを union 型で受け取ります。特筆点はメッセージが新規投稿された場合、既存のタイムラインに追加される点です。これは@prependEdgeによって新規メッセージを対象のタイムライン($connections)に追加すると宣言することで実現されます。connection の更新はupdater を使っても実現できますが、経験上@appendEdge / @prependEdge / @deleteEdgeを使った宣言でほとんどの機能は実装できるため、updater を使う場合は本当に必要なのかを一度立ち止まると良いです。 @deleteEdgeを使ったサンプルはcomponents/organisms/Chat/ChatMessage.tsxの removePost を参考にしてください。

8. relay-style による pagination の実装

relay-style pagination 自体は多くの記事があるので、そちらを参照してください。このレポジトリではcomponents/templates/Chats.tsxでのチャット一覧の取得やcomponents/templates/Chat.tsxでのチャットに紐づくメッセージの一覧取得など複数箇所で pagination を使用しています。前者はPagination on Query、後者はPagination on Typeと違った処理を実装しているので、詳しく知りたい方はソースをおってください。

[schema.graphql]
type Query {
  chats(
    after: String
    first: Int
    before: String
    last: Int
    user_id: ID
  ): ChatConnection!
}

----
[components/templates/Chats.tsx]
fragment Chats_list on Query
@refetchable(queryName: "Chats_list_pagination")
@argumentDefinitions(after: { type: "String" }, first: { type: "Int!" }) {
  chats(first: $first, after: $after) @connection(key: "Chats_chats") {
    edges {
      node {
        id
        title
        user {
          id
          name
          image
          division
        }
      }
    }
  }
}
~~ 略 ~~
const { data, loadNext, hasNext, refetch } = usePaginationFragment(
  chatsQuery,
  chatsFragment
);

まずschema.graphqlを見ると、chatsというチャットコネクションを取得するためのqueryを確認できます。パラメーターとして記載されたafter/ first/ befrore/ lastrelay-styleに準拠したもので、取得位置や件数などを指定するものです。このスキーマに従って、components/templates/Chats.tsxではページネーションを fragment として宣言し、usePaginationFragmentで実体化してます。その結果、取得したdataや追加取得用のloadNext、レフレッシュ用のrefetchを利用できるようになります。

About

graphQL, React Relayを使ったサンプルです

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages