5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

お題は不問!Qiita Engineer Festa 2024で記事投稿!
Qiita Engineer Festa20242024年7月17日まで開催中!

【完全保存版】Next.js + GraphQL + Caliban で安全に型安全したハイパフォーマンス Web アプリを実装する

Last updated at Posted at 2024-06-23

はじめに

型の安全性がコード品質の向上やデバッグ・テストの手間を軽減することは広く知られていますが、複数のテクノロジーを組み合わせている場合は性質や開発者体験を損なうことなく異なるパラダイムを橋渡しできる仕組みも必要です。
本稿では、TypeScript, GraphQL, Scala といった型安全なテクノロジーを組み合わせ、堅牢かつハイパフォーマンスな Web アプリケーションを実装する方法について具体的な例を交えてご紹介します。

タイトルに Next.js と入っていますが React や Vue でも活用できる内容となっています

サンプルコード

Caliban のサンプルを Next.js + MUI で動くようにしています。
見た目では分かりませんがデータの削除結果は WebSocket で非同期に通知されています。

nextjs-example.gif

目次

  1. 自動生成ツール GraphQL Codegen の使い方
    • GraphQL Codegen v2 と v3 の違い
    • Fragment Colocation とは
    • Next.js キャッシュと URQL キャッシュとのハマりポイント
  2. Next.js と外部 API の連携方法
    • カスタムサーバーによる CORS エラー回避
    • WebSocket と Server-Sent Events(SSE)
  3. ハイパフォーマンスな GraphQL ライブラリ Caliban
    • QickAdapter
    • Resolver chain と @￰defer

環境

  • フロントエンド
    • Next.js 14 AppRouter
    • GraphQL Codegen
    • Typescript
    • URQL 4+
  • バックエンド
    • Caliban 2.7+
    • Scala 3.4

自動生成ツール GraphQL Codegen の使い方

GraphQL はサーバーとクライアント間でデータ構造を保証することで型の不整合を防止できる堅牢な API 仕様であり実装です。
RESTful API の仕様である OpenAPI・Swagger とよく比較されますが、開発効率という側面ではどちらにもコードの自動生成ツールが存在します。
以降では GraphQL + Typescript のフロントエンドアプリ実装ではもはや必須(?)の自動生成ツールである GraphQL Codegen について解説します。

GraphQL Codegen v2 と v3 の違い

GraphQL Codegen は GraphQL スキーマからコードを自動生成するためのツールです。
v2 から v3 へのバージョンアップにおいて構成方法は大きく変わり、クライアントライブラリ毎のプラグインのインポートが必要だった v2 に比べ、 v3 は client-preset により特定のライブラリに依存した記述がなくなりました1

クライアントライブラリが urql の場合を例に、具体的に記述がどのように変わったか見てみましょう。
セットアップでは v2 では typescript-urql が必要でした。

# v2
npm i -D @graphql-codegen/typescript \
         @graphql-codegen/typescript-operations \
         @graphql-codegen/typescript-urql

v3 では特定のライブラリに依存したプラグインの指定はなくなっています。

# v3
npm i -D @graphql-codegen/cli \
         @graphql-codegen/client-preset \
         @parcel/watcher

@parcel/watcher は変更検知用のプラグインなので必須ではありませんが、v3 ではクエリとコンポーネントをシームレスに実装していくことになるので next devreact-scripts start などの開発モードの変更検知に合わせて typescript 定義も自動生成されるようにすると良いでしょう。
詳細はプラグインのドキュメントを参照してください。

それではより詳細に設定の違いを見ていきます。
v2 では、GraphQL のクエリを個別のファイル( .graphql など)に書き出し、ファイルを読み込んで生成した Hooks をコンポーネント内で呼び出す形式でした。
設定ファイルは yaml で以下のように記述し、package.json にスクリプトを定義します。

codegen.yml
schema: https://your-graphql-endpoint.com/graphql
documents: 
  - './src/generated/**/*.graphql'
generates:
  ./src/generated/graphql.ts:
    plugins:
      - typescript
      - typescript-operations
      - typescript-urql
package.json
 "scripts": {
    "codegen": "graphql-codegen --config codegen.yml"
  },

一方 v3 はコンポーネントに直接クエリを記述する形式に変わったため、読み取るファイルは .tsx を指定します。
設定ファイルも yaml から Typescript で記述する方法が推奨されるようになりました。

codegen.ts
import { CodegenConfig } from '@graphql-codegen/cli'

const config: CodegenConfig = {
  schema: ['https://your-graphql-endpoint.com/graphql'],
  documents: ['./src/app/**/*.tsx', './src/components/**/*.tsx'],
  ignoreNoDocuments: true, // for better experience with the watcher
  generates: {
    './src/gql/': {
      preset: 'client'
    }
  }
}

export default config
package.json
 "scripts": {
    "codegen": "graphql-codegen --config codegen.ts"
  },

v2 はクエリファイルとコンポーネントのソースコードが分離していたため依存関係が曖昧でしたが、v3 はコンポーネントのソースコードの中にクエリを含める形式となったため、可読性と保守性が上がり堅牢な開発を実践できるようになりました。

以降では実際にサンプルコードをベースにクエリとコンポーネントの関係を見ていきます。

Fragment Colocation について

Fragment Colocation とは、GraphQL のフラグメントをクエリやミューテーションと同じファイルに配置する手法(設計パターン)のことです。ここまで読んだ方は既にお気づきかと思いますが、 GraphQL Codegen v3 は Fragment Colocation を型安全に補完するための実装を提供します。

以下ではサンプルのコードを簡略化して Fragment Colocation と GraphQL Codegen がどのように機能するかを説明していきます。

まずは GraphQL Codegen を設定します。
以下は localhost:8090 で起動している GraphQL サーバーから一度スキーマ定義ファイルを書き出し、スキーマ定義ファイルから Typescript の型定義を生成する例です。
スキーマ定義ファイルを生成するために schema-ast プラグインが必要なのでインストールしておきます。

npm i -D @graphql-codegen/schema-ast

スキーマ情報をファイルに出力する定義は以下のとおりです。

codegen-schema.ts
import { CodegenConfig } from '@graphql-codegen/cli'

const config: CodegenConfig = {
  schema: ['http://localhost:8090/api/graphql'],
  generates: {
    './schema/schema.graphql': {
      plugins: ['schema-ast']
    }
  }
}
export default config

スキーマ情報をファイルに出力せずサーバーのエンドポイントから直接 Typescript コードを生成することもできますが、以下のメリットがあります。

  • GraphQL サーバーが起動していなくても自動生成が可能
  • Git 等でスキーマ定義ファイルの差分を確認できる

次に、Typescript 定義を出力する設定を記述します。
schema は URL ではなく先ほど出力した定義ファイルを指定しています。

codegen-preset.ts
import { CodegenConfig } from '@graphql-codegen/cli'

const config: CodegenConfig = {
  schema: ['./schema/schema.graphql'],
  documents: ['./src/app/**/*.tsx', './src/components/**/*.tsx'],
  ignoreNoDocuments: true, // for better experience with the watcher
  generates: {
    './src/gql/': {
      preset: 'client'
    }
  }
}

export default config

package.json に記述するスクリプトは以下です。
必要に応じて前述した watch-mode を設定しても良いでしょう。

package.json
  "scripts": {
    "gen-schema": "graphql-code-generator --config ./codegen-schema.ts",
    "gen-preset": "graphql-code-generator --config ./codegen-preset.ts"
  }

それでは実際にクエリとコンポーネントを書いていきます。
一覧の取得(Table)と項目表示(TableRow)で Fragment Colocation で表現します。
以下のコードは自動生成された型定義がない場合はエラーが発生します。

CharacterTable.tsx
import { useQuery } from '@urql/next'
import { graphql } from '@/gql'
import CharacterRow from '@/components/CharacterRow'

const GetCharactersQuery = graphql(`
  query GetCharacters($origin: Origin) {
    characters(origin: $origin) {
      ...CharacterFields
    }
  }
`)

export default function CharactersTable({ origin }: Props) {

  const [{ data }] = useQuery({
    query: GetCharactersQuery,
    variable: {...}
  })

  return (
    <TableContainer component={Paper}>
      <Table>
        <TableHead>
          <TableRow>
            <TableCell padding="checkbox" />
            <TableCell>name</TableCell>
            <TableCell>nicknames</TableCell>
            <TableCell>origin</TableCell>
          </TableRow>
        </TableHead>
        <TableBody>
          {data?.characters.map((v, idx) => (
            <CharacterRow key={idx} fields={v} />
          ))}
        </TableBody>
      </Table>
    </TableContainer>
  )
}

TableRow では使用する GraphQL のフィールドとコンポーネントを型定義で制約をかけつつ関心事を分離しています。
これにより、大規模で複雑なアプリケーションであってもより安全で質の高いコンポーネント管理を実現できます。

CharacterRow.tsx
import { FragmentType, useFragment } from '@/gql/fragment-masking'
import { graphql } from '@/gql'

export const CharacterFieldsFragment = graphql(`
  fragment CharacterFields on Character {
    name
    nicknames
    origin
  }
`)

export default function CharacterRow (props: {
  fields: FragmentType<typeof CharacterFieldsFragment>
}) {
  const field = useFragment(CharacterFieldsFragment, props.fields)

  return (
    <TableRow>
      <TableCell>
        <Button name={field.name} />
      </TableCell>
      <TableCell>{field.name}</TableCell>
      <TableCell>{field.nicknames}</TableCell>
      <TableCell>{field.origin}</TableCell>
    </TableRow>
  )
}

ちなみに useFragment は React の Hooks ではありません。
ESLint のエラーを回避したい場合は Codegen の設定ファイルで名前を変更することもできます
上記はかなりシンプルな例ですが、実際のプロダクト開発ではコンポーネントは複雑な階層構造となっていきます。
GraphQL Codegen の開発元である THE GUILD のブログではコンポーネントツリーとフラグメントの関係を視覚的に分かりやすく説明されていますので、是非参考にしてみてください。

Next.js キャッシュと URQL キャッシュとのハマりポイント

今回のサンプルは Next.js と GraphQL のクライアントライブラリである URQL を組み合わせたアプリケーションです。
それぞれが効果的なキャッシュ機構を持っておりハイパフォーマンスな UI/UX を実現できる反面、よく理解していないと「データが更新されない」「更新前のデータがちらつく」といったキャッシュ特有の問題が発生してしまいます。
以下ではそれぞれにおいて引っかかりやすいキャッシュのポイントを簡単に解説します。

URQL のドキュメントキャッシュ

URQL にはデフォルトで Document Caching という機能が備わっておりユーザーは意識することなくキャッシュを活かした効率的な GraphQL クエリを発行できます。
しかし、非常に便利な反面キャッシュの更新や削除が暗黙的に行われているために実装によっては意図しない動作となる場合があります。

例えば今回のサンプルの例であれば、Delete ミューテーションが問題となります。
サンプルコードではデータの削除結果は WebSocket によるサブスクリプションを通じて非同期で受け取る仕様となっているため、戻り値として削除したデータを返しません。
しかし、URQL のドキュメントキャッシュはキャッシュのデータ更新においてミューテーションの戻り値の型(___typename)を元に判別するため、URQL では一覧の取得とデータ削除を関連づけることができず表示中の画面に反映することができません。
そのため、Delete ミューテーションの実行後に画面表示を更新するには additionalTypenames で更新対象と同じ型情報を追加する必要があります。

DeleteCharacterButton.tsx
const DeleteCharacterMutation = graphql(`
  mutation DeleteCharacter($name: String!) {
    deleteCharacter(name: $name)
  }
`)

export default function DeleteCharacterButton({ name }: Props) {

  const [result, execute] = useMutation(DeleteCharacterMutation)

  const submit = () => {
    execute({ name }, {
      additionalTypenames: ['Character'] // 一覧を取得するクエリと同じ Character 型であることを教える
    })
  }

...

}

より厳密にキャッシュを管理したい場合は Document Caching の代わりに Graphcache (Normalized Caching) という機能を使うことができますので、ドキュメントキャッシュに限界を感じた場合やオフライン対応が必要な場合は利用を検討してください。

Next.js のフェッチキャッシュ

Next.js の AppRouter には高度なキャッシュ機構がいくつもあります。
詳細な説明は割愛しますが、ここでは URQL と組み合わせた場合に問題となる fetchCache について解説します。

Next.js ~14 を使用して開発している場合は特に fetchCache に注意する必要があります。

Next.js ではデータ取得を効率的に行うためにビルド時やリクエスト時にフェッチしたデータをデフォルトでキャッシュします。
キャッシュデータは .next フォルダに格納され、データの再検証期間(revalidate)を経過するか再デプロイしない限り消えません。

本機能は更新頻度が低いデータの場合は有効に機能しますが、変更を伴う一覧など更新頻度の高いデータでは意図せず過去の表示が残ってしまうことがあります。

ファントムリード2のような現象が発生し、しかもブラウザをリロードしても開発サーバーを落とし上げしてもデータが見えてしまう...という怪現象に私も当初は悩まされました。
この問題を解消するには revalidate オプションを 0 など短く指定するか、以下のようにフェッチの発行時にキャッシュしない指示を与える必要があります。3

データフェッチをキャッシュさせない場合はリクエスト時に cache: 'no-store' を指定しますが、URQL で fetchOption を指定する方法は以下の通りです。

CharactersTable.tsx
export default function CharactersTable({ origin }: Props) {

  const [{ data }] = useQuery({
    query: GetCharactersQuery,
    variables: { ... },
    context: useMemo(
      () => ({
        fetchOptions: { cache: 'no-store' }
      }),
      []
    ),
  })

  ...
}

余談ですが、このキャッシュ問題は(私も含め)多くの開発者が悩まされたためか、v15 ではデフォルトで無効となるようです。

(Next.js 15 RC) fetch Requests are no longer cached by default

とはいえキャッシュを活用した方が良いケースもありますので、キャッシュと向き合い理解した上で使いこなしていく必要があるでしょう。

Next.js と外部 API の連携方法

今回のサンプルでは開発時にバックエンドの GraphQL サーバーを異なるサーバーとして動作させ、かつリアルタイム処理として WebSocket を採用しています。
また、認証を含むリクエストを想定しクライアントサイドからリクエストを発行する仕様としています。
そのため CORS(オリジン間リソース共有)に関するブラウザのエラーを回避しつつ、WebSocket の接続を確立をするという少し踏み込んだ実装を行います。

カスタムサーバーによる CORS エラー回避

Next.js では CORS エラーを回避する方法がいくつかありますが、今回は開発サーバーをカスタムして回避する方法をご紹介します。
開発サーバーをプロキシサーバーとして起動し http-proxy-middleware を使用して異なるオリジン(ドメイン)間でもエラーが発生しないようにリクエストを書き換えます。http-proxy-middleware は WebSocket にも対応しています。

Next.js のカスタムサーバー機能は開発時のみの利用に限定されますので、本番環境ではフロントエンドとバックエンドのドメインを揃えるか、別の仕組みを入れる必要がある点にはご注意ください。

まずはカスタムサーバーを構築するために以下をインストールします。
Typescript 教徒は一切の Javascript を書くことが禁じられているため、実行環境として tsx をインストールしています。
こちらは ts-nodeesbuild-register など使い慣れているものでも問題ありません。

npm i -D tsx \
         express \
         @types/express \
         http-proxy-middleware

dev ディレクトリにカスタムサーバーを実装します。
ディレクトリ名は server など何でも良いですが、ここでの名前が package.jsonscripts で指定するサーバー名となります。

.
├── node_modules
├── package.json
└── dev/
    ├── index.ts
    └── tsconfig.json
package.json
"scripts": {
    "dev": "tsx --tsconfig ./dev/tsconfig.json dev",
}

それでは実際にプロキシサーバーを実装します。
http-proxy-middleware は 2022年4月21日 に v2.0.6 がリリースされて以降 v3.x.x の安定版はリリースされていませんでしたが、2年の時を経た 2024年4月2日 に満を持して v3.0.0 にアップデートされました。
v2 から v3 にアップデートすると記述を修正する必要がありますので公式の MIGRATION.md を参照してください。

dev/index.ts
import express from 'express'
import next from 'next'
import { createProxyMiddleware } from 'http-proxy-middleware'
import { IncomingMessage, ServerResponse } from 'http'

const port = parseInt(process.env.PORT as string, 10) || 3000
const dev = process.env.NODE_ENV !== 'production'
const app = next({ dev })
const handle = app.getRequestHandler()

app.prepare().then(() => {

  const server = express()
  const host = 'http://localhost:8090'
  const apiOptions = {
    target: host,
    changeOrigin: true,
    pathFilter: '/api',
  }
  const wsOptions = {
    target: host,
    changeOrigin: true,
    ws: true,
    pathFilter: '/ws',
  }
  server.use(createProxyMiddleware(apiOptions))
  server.use(createProxyMiddleware(wsOptions))
  server.all('*', (req: IncomingMessage, res: ServerResponse) => {
    return handle(req, res)
  })
  server.listen(port, () => {
    console.log(`> Ready on http://localhost:${port}`)
    console.log(`> Ready on ws://localhost:${port}`)
  })
})

最後に Next.js における環境ごとのエンドポイント管理は .env で行います。
.env(全ての環境) .env.develpment(next dev) .env.production(next start) のそれぞれのファイルで環境ごとに切り替えることが可能です。
.env.local は前述したファイルよりも常に優先される特殊な環境変数ファイルです。固有のシークレットを格納することを想定しますが、あくまで開発者個人のローカル環境でのみ使用し .gitignore などを用いてソースリポジトリから除外するようにしましょう。4

.env.development
NEXT_PUBLIC_GRAPHQL_ENDPOINT=http://localhost:3000/api/graphql
NEXT_PUBLIC_GRAPHQL_WS_ENDPOINT=ws://localhost:3000/ws/graphql

後は通常の Next.js の開発時と同じように起動すると、ブラウザからの localhost:3000 へのリクエストは開発サーバー上で localhost:8090 に転送されるため、CORS エラーが発生することはありません。

npm run dev
> nextjs-urql-codegen-v3-example@0.1.0 dev
> tsx --tsconfig ./dev/tsconfig.json dev

> Ready on http://localhost:3000
> Ready on ws://localhost:3000

カスタムサーバーを用いずに Next.js の設定ファイルだけで CORS エラーを回避する方法は以下の公式ドキュメントを参照してください。
こちらが最も簡単にクロスオリジンなプロキシを実現する方法となりますが、比較的大きな規模の筆者の環境ではパフォーマンスが出なかったため、 http-proxy-middleware を使用したカスタムサーバーを利用しています。

WebSocket と Server-Sent Events (SSE)

冒頭でも少し触れましたが、サンプルコードでは削除処理の通知を GraphQL のサブスクリプション + Websocket を使用してリアルタイムに通知しています。
WebSocket は双方向通信を可能にし、クライアントとサーバーが持続的にデータを送受信できるプロトコルです。
Server-Sent Events (SSE) は、HTTP 接続を介してサーバーからクライアントへ一方向にデータストリームを送信できます。
詳細な比較は他の方のブログや生成 AI に聞いていただくとして、今回のように削除処理の通知だけであれば後者の方が適していると言えるでしょう。
URQL ではどちらにも対応していますので要件に合う方を選択してください。
実装方法は以下のとおりです。

Websocket with URQL

npm i graphql-ws

THE GUILD / GraphQL-Websocket: Client usage with urql

Server-Sent Events (SSE) with URQL

npm i graphql-sse

THE GUILD / GraphQL-SSE: With urql

ハイパフォーマンスな GraphQL ライブラリ Caliban

最後にバックエンドについてです。
最近 GraphQL 界隈が少し盛り上がっている56ようですが、個人的には GraphQL の開発体験はとても気に入っています。
特に、Scala の GraphQL ライブラリである Caliban は GraphQL の抱える弱みを解消しながら Scala の型安全性そのままに GraphQL の API として公開することができます。
Caliban は他の言語で作られた GraphQL ライブラリと比較しても非常にハイパフォーマンスであり、巷で人気の Rust で書かれたライブラリと比較しても遜色のないスコアが出ているようです。

Caliban はクライアントとサーバー両方の機能も有していますが、本稿では GraphQL サーバーとしての活用方法をご紹介します。

QickAdapter

Caliban は http4s Play Akka Pekko などの Scala で有名な HTTP フレームワークを用いて API を公開できます。敢えてそれらのフレームワークを使わず、カスタマイズ性に制約があるとしてもパフォーマンスを追求したい場合は新たに追加された QuickAdapter を使用することができます。
QuickAdapter は v2.4.3 から追加され、 v2.6.0 からは Websocket にも対応しています。

(サンプルではありますが)僅か 30 行程度でサーバーの基本的な設定を記述できます。
また、これはサンプルコードでは使用していませんが、GraphQL の実装で問題となりがちなファイルアップロードにも QuickAdapter は対応しています。7

Main.scala
object Main extends ZIOAppDefault {

  // Only as a convenience of shutting down the server via CLI, don't add to production code!
  private val cliShutdownSignal =
    Console.printLine("Server online at http://localhost:8090/") *>
      Console.printLine("Press RETURN to stop...") *>
      Console.readLine.unit

  private val serve =
    ZIO
      .serviceWithZIO[GraphQL[Any]] {
        _.runServer(
          port = 8090,
          apiPath = "/api/graphql",
          graphiqlPath = Some("/graphiql"),    // GraphiQL IDE
          webSocketPath = Some("/ws/graphql"),  // WebSocket
          uploadPath = Some("/upload/graphql") // File Upload
        )
      }
      .provide(
        ExampleService.make(sampleCharacters),
        ExampleApi.layer
      )

  override def run: ZIO[Any, Throwable, Unit] = serve race cliShutdownSignal

}

Resolver chain と @￰defer

GraphQL のクエリを実行するとき、各フィールドはそれぞれのリゾルバ関数によって解決されます。
また、ネストされたフィールドやリレーションを解決する際に、複数のリゾルバ関数が連鎖的に呼び出されることもあります。
この一連のリゾルバ関数の呼び出しをResolver chain と呼ぶことがあります。

さらに、GraphQL の @￰defer ディレクティブを使うと、特定のフィールドの処理を遅延させることでき、クライアントはデータの一部分を異なるタイミングで受け取ることができます。

これらが組み合わせると非常に強力な効果を発揮します。
どういうメリットがあるかをサンプルの例で具体的に説明します。
まずは以下の GraphQL の API 定義を見てください。

object ExampleApi {

...

    // レスポンスの型
  @GQLName("Character")
  case class CharacterZIO(
    name: String,
    nicknames: UIO[List[UIO[String]]],
    origin: Origin,
    role: UIO[Option[Role]],
    connections: ConnectionArgs => URIO[Any, List[CharacterZIO]]
  )

...

  def makeApi(exampleService: ExampleService): GraphQL[Any] = {

    // Resolver chain
    def character2CharacterZIO(ch: Character): CharacterZIO =
      CharacterZIO(
        name = ch.name,
        nicknames = ZIO.succeed(ch.nicknames.map(ZIO.succeed(_))),
        origin = ch.origin,
        role = ZIO.succeed(ch.role),
        connections = args =>
          // .delay(5.seconds) で5秒の遅延を発生させる
          (...).map(_.filter(_.name != ch.name).map(character2CharacterZIO)).delay(5.seconds)
      )

        // character2CharacterZIO でラップしてレスポンスを返す
    // フィールドに connections がなければ chain されたリゾルバは実行されない
    graphQL(
      RootResolver(
      Queries(
          args => exampleService.getCharacters(args.origin).map(_.map(character2CharacterZIO)),
          args => exampleService.findCharacter(args.name).map(_.map(character2CharacterZIO))
        ),
      )
    ) @@
      maxFields(300) @@
      maxDepth(30) @@
      timeout(3 seconds) @@  // 3秒でタイムアウトさせる
      printSlowQueries(500 millis) @@
      printErrors @@
      apolloTracing() @@
      DeferSupport.defer     // @deferを有効にする
  }

かなり省略していますが、Resolver chain で繋いだ connections を 5 秒遅延させています(ZIO で書かれていますが .delay(5.seconds) と書くだけで良いのが素晴らしいですね!)。

さて、クエリを実行するとどうなるか QuickAdapter にデフォルトで組み込まれている IDE の GraphiQL を使用して見てみましょう。
localhost:8090/graphiql にアクセスするとブラウザで実行できるエディタが表示されます。

まずは何も考えずに以下のクエリを実行してみます。

query {
  characters(origin:BELT) {
    name
    nicknames
    origin
    ...OriginConnectionsFragment
  }
}

fragment OriginConnectionsFragment on Character {
  connections(by: Origin) {
    name
  }
}
{
  "data": null,
  "errors": [
    {
      "message": "Query was interrupted after timeout of 3 s"
    }
  ]
}

タイムアウトに関するエラーメッセージが表示されました(実際の JSON はもっと長いですが省略しています)。
connections のフラグメント取得に 5 秒かかってしまうので 3 秒のタイムアウト制限に引っかかってしまいます。
勿論、...OriginConnectionsFragment の記述を削除すればスローな処理は実行されませんので、目的のフィールド以外の結果は取得することができます。
さて、次は @￰defer をつけて実行してみます。

query {
  characters(origin:BELT) {
    name
    nicknames
    origin
    ...OriginConnectionsFragment @defer
  }
}

fragment OriginConnectionsFragment on Character {
  connections(by: Origin) {
    name
  }
}
{
  "data": {
    "characters": [
      {
        "name": "Naomi Nagata",
        "nicknames": [],
        "origin": "BELT",
        "connections": [
          {
            "name": "Josephus Miller"
          }
        ]
      },
      {
        "name": "Josephus Miller",
        "nicknames": [
          "Joe"
        ],
        "origin": "BELT",
        "connections": [
          {
            "name": "Naomi Nagata"
          }
        ]
      }
    ]
  },
  ...
}

このクエリでは 5 秒の遅延にも関わらず実行することができました!
IDE では全ての JSON が揃うまで表示されないので遅延ロードを判別することはできませんが、リゾルバが異なるレスポンスとして返していることが確認できます。

上記の通り、@￰defer をつけるだけで遅延するデータを後から取得するロード制御が簡単に実現できました。
この機能によって GraphQL のクエリを繋げることができるメリットを活かしつつ、全てのクエリの実行を待つ必要があったデメリットを解消することができます。

但し、@￰defer はレスポンスを分けてクライアントに返すという特性上、通常のクエリよりもサーバーリソースを使用しますので、使い所には注意しましょう。
Caliban では timeout や defer といったリゾルバの制御を簡単に設定できますので、GraphQL で懸念されるリソースやパフォーマンス問題にも柔軟に対応できます。
Caliban では defer はまだ実験的な機能でありクライアントの対応状況にも左右されますのでお使いの環境で利用できるかご確認ください。
GraphQL Codgen と URQL はどちらも対応していますが、詳細な対応状況については以下を参照してください。

GraphQL Codegen

Fragment Masking with @￰defer Directive

URQL

Defer & Stream Directives

おわりに

若干(?)寄り道も多かったですが、Next.js, GraphQL, Caliban で型安全な Web アプリケーションを構築するサンプルをご紹介しました。
サンプルでは異なる型理論や集合論のパラダイムを橋渡しすることで、堅牢性と高性能を両立しつつ、開発者の使いやすさも損なわないアプリケーション構築を目指しました。
本稿が皆様のアプリケーション開発の一助となれば幸いです。

おまけ
Caliban の v2.7 では Scala3 の union types が GraphQL の Union 型に変換できるようになりました。8
Typescript, GraphQL, Scala の Union Types が綺麗に繋がった瞬間ですね!

Schema.scala
type Payload = Foo | Bar
given Schema[Any, Payload] = Schema.unionType[Payload] // in GraphQL => union Payload = Foo | Bar
  1. メジャーな GraphQL クライアントに対応していますが、 React や Vue で対応に違いがありますので公式ドキュメントを参照してください

  2. データベースにおいてトランザクション中に存在しないデータを読み込んでしまう現象

  3. fetchCache のオプトアウトの方法は他にもいくつかあります。詳細は公式ドキュメントを参照してください。https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating

  4. https://nextjs.org/docs/pages/building-your-application/configuring/environment-variables#default-environment-variables

  5. Why, after 6 years, I’m over GraphQL

  6. Why, after 8 years, I still like GraphQL sometimes in the right context

  7. 様々なライブラリで採用されている仕様に準拠しています:https://github.com/jaydenseric/graphql-multipart-request-spec

  8. https://github.com/ghostdogpr/caliban/releases/tag/v2.7.0

5
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?