🔥

Sveltekit で GraphQL

2021/06/13に公開

こんにちは

どうも、僕です。
先日、Svelte と Sveltekit を触ってみた という記事を書きました。そこでも触れたのですが、個人ブログを Sveltekit で作り直しました。
https://zenn.dev/takurinton/articles/5d540c30e72e27
その API のやり取りの形式を GraphQL にしてみたのでそれのまとめです。

技術選定

ここで一番時間を使いました。
Sveltekit で GraphQL を使用している例があまりなかったのでそこで苦労しました。
最初、svelte-apollo を使用しようと思いました。
https://github.com/timhall/svelte-apollo/

が、このコメント

svelte's getContext / setContext will never work with Svelte Kit's load function

と書かれていました。

また、このコメント では Sveltekit のライフサイクルについてわかりやすく述べられていました。

ということで、svelte-apollo は渋そうなので普通に @apollo/client を使用して頑張ればいいやということになりました。

https://www.apollographql.com/docs/react/

やること

機能的にはそこまで多くなく、投稿の一覧(ページネーションと最新の投稿5件)と投稿詳細の取得ができるようにします。
サーバサイドとクライアントサイドの両方を書き換える必要があります。

まずはサーバサイドを実装する

まずはサーバサイドにエンドポイントを生やしました。
以下はその Pull Request です。
https://github.com/takurinton/api.takurinton.com/pull/22

僕のブログは AWS の EC2(t2.micro) に Go の gin を使用した API サーバを置いていて、REST API を用いてやり取りを行なっています。
今回はそこに GraphQL のエンドポイントを1つ生やしてブログの投稿一覧と投稿詳細をとることができるようにしました。
本質からはずれるのでそこまで詳しくは話しませんが、リクエストとレスポンスの形式は以下です。

  • 投稿一覧
    • リクエスト
      • { "query": "{ getPosts(page:1, category:\"\") { next previous current results { id title } } }" }
    • レスポンス
      • {"data":{"getPosts":{"current":1,"next":2,"previous":1,"results":[{"id":60,"title":"svelte と sveltekit について"},{"id":59,"title":"バンドルツール作る"},{"id":58,"title":"GraphQL入門"},{"id":57,"title":"Nuxt入門した"},{"id":56,"title":"マルコフ連鎖実装してみた"}]}}}
  • 投稿詳細
    • リクエスト
      • { "query": "{ getPost(id:60) { id title contents pub_date } }" }
    • レスポンス
      • {"data":{"getPost":{"contents":"長いので省略....","id":60,"pub_date":"2021-06-05T23:59:14Z","title":"svelte と sveltekit について"}}}

Sveltekit でフロントエンドを実装する

本題です。
Pull Request は2個あります。(不備があるのにマージしてしまったため)
https://github.com/takurinton/blog.takurinton.com/pull/6
https://github.com/takurinton/blog.takurinton.com/pull/7

先述しましたが、svelte-apollo は使用できないので @apollo/client でやっていきます。

クエリ

クエリは lib の下に以下のように定義しています。

/src/lib/graphql/query.ts
import { gql } from '@apollo/client/core/core.cjs.js';

export const POST_QUERY = gql`
query postQuery($id: Int){
  getPost (id: $id){
    id
    title
    contents
    pub_date
  }
}
`

export const POSTS_QUERY = gql`
query postQuery($pages: Int, $category: String){
  getPosts (page: $pages, category: $category){
    current
    next
    previous
    category
    results {
      id
      title
      contents
      category
      pub_date
    }
  }
}
`

Apollo Client でリクエストを投げる

以下のような形でサーバにリクエストを投げることができます。

// クライアントを作成
const client = new ApolloClient({
  uri: 'https://api.takurinton.com/graphql', // エンドポイントを指定する
  cache: new InMemoryCache() // キャッシュを指定する
});

// クエリを作成する
// 投稿一覧取得の例(下で出てくる)
const res = await client.query({
  query: POSTS_QUERY, // 投げたいクエリを指定する
  variables: { pages, category } // 引数を指定する
})

投稿一覧取得

まずは投稿一覧を取得してみます。
元々のコードでは load 関数の fetch を使用していましたが、そこを Apollo Client に書き換えました。

/src/routes/index.svelte
<script context="module" lang="ts">
	import { enhance } from '$lib/form';
	import type { Load } from '@sveltejs/kit';
	
	// ApolloClient を React 以外で使用する場合は @apollo/client/core を読む必要がある
	// ESM として使用する場合はバンドルに失敗するので code.cjs.js を使用する必要がある
	import { ApolloClient, InMemoryCache } from '@apollo/client/core/core.cjs.js';
	import { POSTS_QUERY } from '../lib/graphql/query';

	export const prerender = true;

	// load 関数で SSR をする
	// Next.js でいう getInitialProps
	export const load: Load = async ({ page, fetch }) => {
	        // クエリパラメータの取得
		const category = page.query.get('category') ?? '';
		const pages = page.query.get('page') ?? 1;

		// クライアントを作成
		const client = new ApolloClient({
			uri: 'https://api.takurinton.com/graphql',
			cache: new InMemoryCache()
		});
		
		// クエリを作成してリクエストを投げる
		const res = await client.query({
			query: POSTS_QUERY, 
			variables: { pages, category }
		})

		// レスポンスを格納する
		const posts = res.data.getPosts;
		// マークアップで使えるようにする
		return {
			props: { posts }
		};
	};
</script>

投稿詳細取得

次に投稿詳細を取得します。
基本的には投稿一覧を同じで、引数が違うだけになります。
投稿詳細の引数は主キーとなるオートインクリメントのIDです。

/src/route/post/[id].svelte
<script context="module" lang="ts">
	import { enhance } from '$lib/form';
	import type { Load } from '@sveltejs/kit';
	import marked from 'marked';
	import { syntaxHighlight, markdownStyle } from './utils.ts';
	import { ApolloClient, InMemoryCache } from '@apollo/client/core/core.cjs.js';
	import { POST_QUERY } from '../../lib/graphql/query';

	export const prerender = true;

	export const load: Load = async ({ page, fetch }) => {
		const id: number = page.params.id;
		const client = new ApolloClient({
			uri: 'https://api.takurinton.com/graphql',
			cache: new InMemoryCache()
		});
		
		const res = await client.query({
			query: POST_QUERY, 
			variables: { id } // 引数は投稿のid(主キー)
		})

		let post = res.data.getPost;
	
		// 記事をマークダウンで表示するための処理
		syntaxHighlight();
		const r: marked.Renderer = markdownStyle();
		post.contents = marked(post.contents, { renderer: r });
	
		return {
			props: { post }
		};
	};
</script>

つまづいた点

いくつかつまづいた点をまとめていきます。

BFF の扱い

最初、svelte-apollo で load 関数が使用できないなら、svelte-apollo をサーバ側で実行してクライアントサイドは load 関数の fetch を使用してデータを取ってくればいいと思っていました。

つまり、TS ファイルを生成して自分自身のエンドポイントにリクエストを投げるような感覚です。

/src/routes/posts/index.ts
// これは BFF
import { ApolloClient, InMemoryCache } from '@apollo/client/core/core.cjs.js';
import { POSTS_QUERY } from '../query';

const POSTS_QUERY = gql`
query postQuery($page: Int, $category: String){
  getPosts (page: $page, category: $category){
    current
    next
    previous
    category
    results {
      id
      title
      contents
      category
      pub_date
    }
  }
}
`;

export const get = async (req) => {
  const category = req.query.get('category') ?? '';
  const page = req.query.get('page') ?? 1;

  const client = new ApolloClient({
      uri: 'https://api.takurinton.com/graphql',
      cache: new InMemoryCache()
  });

  const props = await client.query({
    query: POSTS_QUERY, 
    variables: { page, category }
  })

  return {
    body: props,
  };
} 

クライアントサイドはこう。

/src/routes/index.svelte

<script context="module" lang="ts">
	import { enhance } from '$lib/form';
	import type { Load } from '@sveltejs/kit';
	export const prerender = true;
	export const load: Load = async ({ page, fetch }) => {
		const category = page.query.get('category') ?? '';
		const pages = page.query.get('page') ?? '';
		let params = '';
		if (pages !== '' && category !== '') params = `?page=${pages}&category=${category}`;
		else if (pages === '' && category !== '') params = `?&category=${category}`;
		else if (pages !== '' && category === '') params = `?page=${pages}`;
		const res = await fetch(`/graphql/posts${params}`);
		if (res.ok) {
			const _posts = await res.json();
			const posts = _posts.data.getPosts;
			return {
				props: { posts }
			};
		}
		return {
			error: new Error('INTERNAL SERVER ERROR!!!')
		};
	};
</script>

これはうまくいきませんでした。

そもそも load 関数噛ませておいて BFF を生やすなということは置いておいて、そういえば Sveltekit にはこのようなバグがあることを思い出しました。
多分 request 周り全般で起きてるバグなのではないかなと思います。
https://github.com/sveltejs/kit/issues/669

完全に状況が一致してるわけではないのですが、結構このバグには悩まされているのでこれかなと。
svelte-aollo をやめたタイミングとしてはこのあたりです。

Apollo Client は React 依存してる

これは完全に僕が無知なだけでした。

import { ApolloClient } from '@apollo/client';

これを React がない環境で呼ぶと

Cannot find module ‘react’ Require stack

というエラーになります。

これはシンプルに core を呼べば良かったみたいです。

import { ApolloClient } from '@apollo/client/core';

と思いきや、ローカルでは動くけどプロダクションビルドをするとこける。

Did you mean to import @apollo/client/core/core.cjs.js?

というエラーが出ました。
おそらく、Vite を使用しているのでローカルではバンドルせずに普通に表示ができ、いざプロダクション用にバンドルした時に初めて依存関係がおかしくなるのではないかと思いました。
このような時にはシンプルにエラー通りにインポートしてあげれば直ります。

import { ApolloClient } from '@apollo/client/core/core.cjs.js';

これで良さそうです。

まとめ

こんな感じで Sveltekit で GraphQL を導入してみました。
あまり Sveltekit で実装している例を見なかったので参考にしてもらえればと思います。
GraphQL ガチ素人なので間違えてる点や改善点などありましたらコメントをいただけると嬉しいです。

最後に、GraphQL になったからといって何か変わるわけではないですが、僕のブログ読んでください(隙あらば宣伝)

https://blog.takurinton.com/

Discussion