GraphQLのFragment活用テクニック: colocationとmasking

GraphQLのFragment活用テクニック: colocationとmasking

こんにちは。N 予備校 Webフロントエンド開発チームの中村です。 現在開発中のZEN CompassではGraphQLを採用しました。我々のチームでは(そして私個人としても)GraphQLを採用したのは初めてだったのですが、実際に設計を進めていくうちに色々と知見を得ることができました。今回はその中でも特に重要だと思った、GraphQLのFragmentという仕様を活用したコンポーネント設計のテクニックについてお話ししようと思います。GraphQLを使用したWebアプリケーションに興味がある方にとって何か参考になりましたら幸いです。

【ZEN Compass】

学習者を導く先生方などが利用するコーチング支援Webサービスです。 LMS(Learning Management System)として学習状況を見ることができるだけではなく、より学習者を効率的に支援できるように、さまざまな指標で先生方を支えます。

前提

説明のためのコードの例としてStar Wars GraphQL API(SWAPIのGraphQL版)を利用して、スターウォーズ・シリーズの映画のタイトル一覧、タイトル毎に登場するキャラクター名を表示するクエリとコンポーネントを用意しました。

本文中で例示しているTypeScriptのコードは簡単のために省略している箇所が多々あります。例えば、文脈上重要でない型定義やNullチェックなどは割愛しています。

またコード中で使用している graphqluseFragment といった関数のインターフェースはGraphQL Code GeneratorおよびClient presetによって生成されるものを用いています。

query ExampleQuery {
  allFilms {
    films {
      id
      title
      characterConnection {
        characters {
          id
          name
        }
      }
    }
  }
}

その他、我々はNext.jsでアプリケーションを開発していて、サンプルコードもReactを前提としていますが、この記事で説明している内容は基本的に個別のUIフレームワークやGraphQLライブラリに依らない内容になっていると思います。

Fragmentの活用

GraphQLを使用する上でクエリをどこに・どのように記述するべきかというのは重要なテーマです。

GraphQLにはFragmentという仕様があります。これは、Queryの一部を個別に定義し、複数のQueryから再利用可能にするものです。

query ExampleQuery {
  allFilms {
    films {
      ...FilmFragment
    }
  }
}

fragment FilmFragment on Film {
  id
  title
  characterConnection {
    characters {
      ...CharacterFragment
    }
  }
}

fragment CharacterFragment on Person {
  id
  name
}

この記事ではこのFragmentの活用1について中心に解説していきます。

Fragmentを使わないとどうなるか

Fragmentの説明をする前に、Fragmentを使わなかった場合にどのような不都合があるのかについて考えてみましょう。

トップレベルのクエリに全てのフィールドを記述する

まず考えられるのは、Queryの中に全てのフィールドを書いてしまうことです。

// Root.tsx
const query = gql`
query ExampleQuery {
  allFilms {
    films {
      id
      title
      characterConnection {
        characters {
          id
          name
        }
      }
    }
  }
}`

export const Root = () => {
  const { data } = useQuery(query);
  const { films } = data.allFilms;
  return (
    <ul>
      {
        films.map(
          (film) => (
            <Film key={film.id} film={film} />
          )
        )
      }
    </ul>
  );
};

// Film.tsx
export const Film = (props: FilmProps) => {
  const { title, characters } = props.film.charactersConnection;
  return (
    <li>
      { title }
      <ul>
        {
          characters.map(
            (character) => (
              <Character key={character.id} character={character} />
            )
          )
        }
      </ul>
    </li>
  );
};

// Character.tsx
export const Character = (props: CharacterProps) => {
  const { name } = props.character;
  return (
    <li>
      { name }
    </li>
  );
};

このパターンの問題は、Queryでのフィールドの指定と、そのフィールドのデータを実際に表示するコンポーネントとで記述する場所が物理的に離れてしまうことです。特定のフィールドがどこでどのように利用するために指定されたものであるかを把握するためには、ソースコード上でデータの流れを追っていく必要があり、コードベースの保守性を低下させる要因になります。コンポーネントからQueryまで辿る場合も同様です。

また、コンポーネントツリーの末端に至るまでどのようなデータが必要なのかという知識をQueryが全て知ることになるため、暗黙的にQueryがデータを利用する全てのコンポーネントに依存することになります。

例えば複数のページ間で共有しているコンポーネントで利用するデータにフィールドを追加するような場合について考えてみてください。そのコンポーネントで表示しているデータの元となっているQueryを全て洗い出して修正する必要があります。これは開発が進んでQueryの数が増えて複雑になってくると困難になります。

コンポーネントやhookなど、データの利用箇所に直接クエリを記述する

もう1つ考えられるのは、上述の内容とは逆に個別のコンポーネントの中にQueryを細かく記述する方法です。

// Root.tsx
const query = gql`
query ExampleQuery {
  allFilms {
    films {
      id
    }
  }
}`

export const Root = () => {
  const { data } = useQuery(query);
  const { films } = data.allFilms;
  return (
    <ul>
      {
        films.map(
          (film) => (
            <Film key={film.id} id={film.id} />
          )
        )
      }
    </ul>
  );
};

// Film.tsx
const query = gql`
query FilmQuery($id: Int) {
  film(id: $id) {
    id
    title
    characterConnection {
      characters {
        id
      }
    }
  }
}`

export const Film = ({ id }: FilmProps) => {
  const { title, characterConnection } = useQuery(query, { id });
  const { characters } = characterConnection;
  return (
    <li>
      { title }
      <ul>
        {
          characters.map(
            (character) => (
              <Character key={character.id} id={character.id} />
            )
          )
        }
      </ul>
    </li>
  );
};

// Character.tsx
const query = gql`
query CharacterQuery($id: Int) {
  person(id: $id) {
    id
    name
  }
}`

export const Character = ({ id }: CharacterProps) => {
  const { name } = useQuery(query, { id });
  return (
    <li>
      { name }
    </li>
  );
};

こちらのパターンでは前述の問題は解決しますが、複数のQuery・複数のHTTPリクエストを送信することになり2、サーバ負荷やパフォーマンス上の問題があります。極端な例ですが、サンプルコードではN+1回のリクエストが二重に発生していて大変効率が悪いです。

また複数のリクエストが直列に行われてユーザの待ち時間が長くなる問題があるため、この点でも好ましくありません。

GraphQLとコンポーネントの記述を対応づける(colocate Fragments)

Fragmentを活用すれば、上で挙げたような問題を解決できます。 ZEN Compassではコンポーネント(やカスタムhook)側にフィールドの指定を記述しつつ、トップレベルのコンポーネント(各ページのコンポーネント)では可能な限り1つのQueryに集約するような設計を採用しました。

Fragmentを活用することでフィールドの指定をデータの利用箇所と同じファイルに記述しやすくなり、また個別のFragmentを1つのQueryに集約しているため、リクエストが直列に行われてしまう問題も解決できます。

// Root.tsx
const query = gql`
query ExampleQuery {
  allFilms {
    films {
      ...FilmFragment
    }
  }
}`

export const Root = () => {
  const { data } = useQuery(query);
  const { films } = data.allFilms;
  return (
    <ul>
      {
        films.map(
          (film) => (
            <Film key={film.id} film={film} />
          )
        )
      }
    </ul>
  );
};

// Film.tsx
// FilmPropsはFilmFragmentから生成された型を参照している
gql`
fragment FilmFragment on Film {
  id
  title
  characterConnection {
    characters {
      ...CharacterFragment
    }
  }
}`

export const Film = (props: FilmProps) => {
  const { title, characterConnection } = props.film;
  const { characters } = characterConnection;
  return (
    <li>
      { title }
      <ul>
        {
          characters.map(
            (character) => (
              <Character key={character.id} character={character} />
            )
          )
        }
      </ul>
    </li>
  );
};

// Character.tsx
// CharacterPropsはCharacterFragmentから生成された型を参照している
gql`
fragment CharacterFragment on Person {
  id
  name
}`

export const Character = (props: CharacterProps) => {
  const { name } = props.character;
  return (
    <li>
      { name }
    </li>
  );
};

上位のQueryやFragmentからは下位のFragmentでどのようなフィールドを指定しているかを知っている必要が無くなります。そのため、Fragmentの定義とUIコンポーネントの実装を同じファイルに記述していれば、特定のフィールドを追加・削除したような場合でもそのファイルだけ変更すればよく、影響範囲の把握も容易になります。もちろん、Fragmentの定義とUIコンポーネントの実装を近づけることでコードの読み手の認知負荷が下がるというメリットもあります。3

Fragment の可視性をコントロールする

Fragmentの定義とコンポーネントの実装を同じファイルに記述する場合、親側のコンポーネントは子が利用するFragmentが実際にどういうデータなのか知る必要はありません。しかし、データはJavaSctriptのオブジェクトですから、当然親側のコンポーネントからもFragmentとして定義したデータの中身にアクセスできてしまいます。そのため一度Fragmentとそれを利用するコンポーネントを対応づけて実装したとしても、開発が進むうちに子コンポーネント側に隠蔽したはずのFragment内部のフィールドに依存してしまう可能性があります。

GraphQL Code GeneratorのClient presetやRelayはFragment Masking(RelayではData Masking)という機能を提供しています。これはGraphQLのQuery・FragmentとUIコンポーネントのスコープの対応をより一層強力にする素晴らしい機能です。Fragment Maskingを使用することでFragmentデータのプロパティを上位のコンポーネントから隠蔽しデータの可視性をコントロールすることが可能になります。

いずれのツールでもFragmentを使用するために useFragment という関数を使うのですが、どちらもマスキングされたデータ(親からpropsとして受け取ることが想定されるもの)とGraphQLのFragmentの定義の両方を引数に渡す必要があります。つまり、基本的には親側からFragmentの内部が参照されることがないような作りになっています。

filmのフィールドがFilm.ts内でFragmentとして定義されている。Film.tsを参照しているRoot.ts内からはfilmオブジェクトのプロパティにアクセスできない。Film.ts内のflagment定義とpropsとして受け取ったfilmをuseFragmentに渡すことではじめてGraphQLのフィールドとして指定したプロパティにアクセスできる。

以下は useFragment を使用したコードの例です。

// Root.tsx
const query = graphql(`
query ExampleQuery {
  allFilms {
    films {
      ...FilmFragment
    }
  }
}`);

export const Root = () => {
  const { data } = useQuery(query);
  const { films } = data.allFilms;
  return (
    <ul>
      {
        films.map(
          (film) => {
            // console.log(film.title); <- error TS2339: Property 'title' does not exist on type...
            return <Film film={film} />;
          }
        )
      }
    </ul>
  );
};

// Film.tsx
const fragment = graphql(`
fragment FilmFragment on Film {
  title
}`);

export const Film = (props: FilmProps) => {
  // console.log(props.film.title); <- error TS2339: Property 'title' does not exist on type...
  const { title } = useFragment(fragment, props.film);
  console.log(title) // <- No Error
  // 割愛
};

実際には、親側からも子に渡すFragmentの一部のフィールドにアクセスする必要がある場面というのは結構あります。4その場合Fragmentの外側で重複してフィールドを指定することで、Fragmentの可視性に手を加えずにデータを参照できます。親・子で同じデータの同じフィールドを参照する必要あったとしても、それぞれそのフィールドにアクセスしたい事情が異なるわけですから、共通化せずに別々の記述にするのは理にかなっています。

// Root.tsx
// filmsのtitleはFilmFragment内での指定と重複するが問題無い
const query = graphql(`
query ExampleQuery {
  allFilms {
    films {
      id
      title
      ...FilmFragment
    }
  }
}`);

export const Root = () => {
  const { data } = useQuery(query);
  const { films } = data.allFilms;
  return (
    <ul>
      {
        films.map(
          (film) => {
            console.log(film.title); // No Error
            return <Film key={film.id} film={film} />;
          }
        )
      }
    </ul>
  );
};

また、Fragmentの定義を複数のコンポーネント間で再利用したい場合には useFragment 周りの処理をReactのカスタムhookあるいは通常の関数として別ファイルに切り出し、コンポーネントの場合と同様にFragmentの定義を合わせて記述するようにすると良いでしょう。

// useFilm.ts
const fragment = graphql(`
fragment FilmFragment on Film {
  title
  characterConnection {
    characters {
      id
      ...CharacterFragment
    }
  }
}`);

export const useFilm = (masked: FragmentType<typeof fragment>) => {
  const film = useFragment(fragment, masked);
  return film;
};

// FilmA.tsx
import { useFilm } from './useFilm';

export const FilmA = (props: FilmAProps) => {
  const { title, characterConnection } = useFilm(props.film);
  // 割愛
};

// FilmB.tsx
import { useFilm } from './useFilm';

export const FilmB = (props: FilmBProps) => {
  const { title, characterConnection } = useFilm(props.film);
  // 割愛
};
余談: GraphQL Code Generatorで生成される関数や型について

GraphQL Code GeneratorおよびClient presetによって生成される graphqluseFragment といった関数はGraphQLのスキーマ情報と graphql 関数の引数で受け取ったクエリ文字列を元に適切な型を推論してくれるため、プログラマが明示的に型引数を与える必要はありません。5

const fragment = graphql(`
fragment FilmFragment on Film {
  title
  characterConnection {
    characters {
      id
      ...CharacterFragment
    }
  }
}`);

export const Film = (props: { film: FragmentType<typeof fragment>}) => {
  const film = useFragment(fragment, props.film); // <- film is typed
}

これはとても便利なのですが、どうしてこのようにGraphQLの記述からTypeScriptの型が推論できるのでしょうか?

graphql 関数の定義は型生成時に生成されるディレクトリにある gql.ts にあります。GraphQL Code GeneratorおよびClient presetでは、クエリ箇所ごとに異なった graphql 関数シグネチャを生成しオーバーロードしていることが分かります。GraphQLの記述(TypeScriptの文字列リテラル)をリテラル型やキーとして使用することでこのような振る舞いを実現しているのですね。

/**
 * Map of all GraphQL operations in the project.
 *
 * This map has several performance disadvantages:
 * 1. It is not tree-shakeable, so it will include all operations in the project.
 * 2. It is not minifiable, so the string of a GraphQL query will be multiple times inside the bundle.
 * 3. It does not support dead code elimination, so it will add unused operations.
 *
 * Therefore it is highly recommended to use the babel or swc plugin for production.
 */
const documents = {
    "\nquery ExampleQuery {\n  allFilms {\n    films {\n      ...FilmFragment\n    }\n  }\n}": types.ExampleQueryDocument,
    "\nfragment FilmFragment on Film {\n  title\n  characterConnection {\n    characters {\n      ...CharacterFragment\n    }\n  }\n}": types.FilmFragmentFragmentDoc,
    "\nfragment CharacterFragment on Person {\n  name\n}": types.CharacterFragmentFragmentDoc,
};

/**
 * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
 *
 *
 * @example
 * ```ts
 * const query = graphql(`query GetUser($id: ID!) { user(id: $id) { name } }`);
 * ```
 *
 * The query argument is unknown!
 * Please regenerate the types.
 */
export function graphql(source: string): unknown;

/**
 * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
 */
export function graphql(source: "\nquery ExampleQuery {\n  allFilms {\n    films {\n      ...FilmFragment\n    }\n  }\n}"): (typeof documents)["\nquery ExampleQuery {\n  allFilms {\n    films {\n      ...FilmFragment\n    }\n  }\n}"];
/**
 * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
 */
export function graphql(source: "\nfragment FilmFragment on Film {\n  title\n  characterConnection {\n    characters {\n      ...CharacterFragment\n    }\n  }\n}"): (typeof documents)["\nfragment FilmFragment on Film {\n  title\n  characterConnection {\n    characters {\n      ...CharacterFragment\n    }\n  }\n}"];
/**
 * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
 */
export function graphql(source: "\nfragment CharacterFragment on Person {\n  name\n}"): (typeof documents)["\nfragment CharacterFragment on Person {\n  name\n}"];

export function graphql(source: string) {
  return (documents as any)[source] ?? {};
}

おわりに

以上、Webフロントエンド設計におけるGraphQLのFragmentの活用事例についてお話ししました。

Fragmentとコンポーネントの集約やFragment maskingの概念は非常に便利なので、GraphQLを利用しているプロジェクトでは是非活用してみてください。もしこの記事が少しでもお役に立てば幸いです。

参考にした記事・ドキュメントなど

We are hiring!

株式会社ドワンゴの教育事業では、一緒に未来の当たり前の教育をつくるメンバーを募集しています。 カジュアル面談も行っています。 お気軽にご連絡ください!

カジュアル面談応募フォームはこちら

www.nnn.ed.nico

開発チームの取り組み、教育事業の今後については、他の記事や採用資料をご覧ください。

speakerdeck.com


  1. この記事で紹介しているFragmentとコンポーネントなどの記述を同じ箇所に集約するテクニックは"Colocating Fragments"や"Colocated Fragments"などと呼ばれているもので、"colocating GlaphQL fragments"などで検索するとこの記事で紹介している内容と類似のテーマの記事がヒットするかと思います。(何故か日本語の記事では"Fragment Colocation"が使われやすい傾向があります)
  2. Apollo Clientには一定期間内のリクエストを集約するバッチ機能がありますが、パフォーマンス上の懸念やサーバ実装の互換性の問題があるため、採用する場合慎重に検討する必要があります。
  3. ここまで .tsx ファイル内にJavaScript(TypeScript)の文字列リテラルとしてGraphQLのQueryやFragmentを記述する前提で話を進めていますが、JavaScriptとGraphQLは別のシンタックスなのだから別々のファイル(例えば .tsx.gql など)に分けたいという考え方もあるかと思います。しかし、個人的にはファイルを分けないことをお勧めします。なぜなら記述言語・ファイルタイプによってコードを分離することはいわゆる関心の分離の達成に必要ではなく、それどころか同一の関心を持つコードベースを分割することになってしまうからです。今日のフロントエンドフレームワークを使用したコードベースではマークアップやスタイリング、ロジックなどがコンポーネントという単位で同一のファイルに記述されるのが一般的になっている背景を踏まえれば、「GraphQLのコードだから」というだけではあえてQueryやFragmentをコンポーネントから分離する合理的な理由にはならず、むしろ対応するコンポーネントの近くに記述されている方が好ましいことが分かると思います。RelayのコンパイラやGraphQL Code Generatorのような型生成ツールはJavaScriptやTypeScriptのファイル内でのGraphQLの記述に対応しているため問題なく静的解析が行えます。また、テキストエディタの支援ツール(Language Server)でも対応がなされています。
  4. 例えばリストレンダリングのkeyのためにidが必要な場合など。
  5. この点は react-relay パッケージの graphqluseFragment も同様です。