はじめに
GraphQLとはBFF(backend for frontend)とはなんぞやということで、触ってみました。
今回作成したものこちら
とんでもなく参考にした記事
そもそもGraphQLとは?
GraphQLは、Facebookによって開発されたデータクエリ言語および実行環境です。RESTful APIの代替として設計され、クライアントが必要とするデータを効率的に取得できるようにします。REST APIとは異なり、GraphQLではクライアントが必要なデータの構造とフィールドを明確に指定できます。
メリット
- 柔軟性: クライアントが必要なデータを指定できるため、データの取得が柔軟になります
- パフォーマンス: 不要なデータの取得を防ぐことができ、ネットワークの帯域幅を節約できます
- シンプルなクライアント: フロントエンド開発者は、必要なデータを簡単に取得できます
- 単一エンドポイント: GraphQLでは1つのエンドポイントで複数のデータ要求を処理できます
デメリット
- 学習コスト: RESTと比較して新しいコンセプトやクエリ言語を学ぶ必要があります
- キャッシングの難しさ: REST APIよりもキャッシングが難しい場合があります
- オーバーフェッチング/アンダーフェッチング: クエリの設計に失敗すると、不要なデータを取得する「オーバーフェッチング」や、必要なデータが不足する「アンダーフェッチング」が発生する可能性があります
サンプル
以下はGraphQLのクエリの例です。
このクエリは、IDが"123"のユーザーのID、名前、メールアドレスを取得します。
query {
user(id: "123") {
id
name
email
}
}
やっぱりメールアドレスはいらないと思えば、以下のクエリにすることでIDが"123"のユーザーのID、名前だけ取得できます。
query {
user(id: "123") {
id
name
}
}
構成
構成は以下の通りです。
Backendは本来であればDBとのやり取りを行う部分になりますが、本題ではないので、jsonをREST APIとして返却してくれるjson-server
を使用します。
Backend(json-server)
まずはバックエンドを構築します。
backendフォルダを作成し、フォルダ内にデータとなるjsonを作成します。
{
"posts": [
{
"id": "1",
"title": "a title",
"views": 100
},
{
"id": "2",
"title": "another title",
"views": 200
}
],
"comments": [
{
"id": "1",
"text": "a comment about post 1",
"postId": "1"
},
{
"id": "2",
"text": "another comment about post 1",
"postId": "1"
},
{
"id": "3",
"text": "another comment about post 2",
"postId": "2"
}
],
"profile": {
"name": "typicode"
}
}
package.jsonを作成して、scriptを追加します。
npm init
"scripts": {
+ "dev": "json-server --watch db.json -p 1334",
"test": "echo \"Error: no test specified\" && exit 1"
そして、起動
npm run dev
http://localhost:1334/ でアクセスができます。
例えば
http://localhost:1334/posts
にアクセスすると先ほど設定したjsonのpostsが返ってきます。
[
{
"id": "1",
"title": "a title",
"views": 100
},
{
"id": "2",
"title": "another title",
"views": 200
}
]
もちろんクエリパラメータを渡して、idが1のものを取得することもできます。
[
{
"id": "1",
"title": "a title",
"views": 100
}
]
これでバックエンドが作成できました。
BFF(Nest.js)
主題であるBFFサーバーをNest.jsを使用して作成していきます。
そもそもNest.jsとは
Nest.jsは、効率的でスケーラブルなNode.jsサーバーサイドアプリケーションを構築するためのフレームワークです。JavaScriptやTypeScriptを使用し、Angularのようなアーキテクチャに影響を受けています。主な目的は、テスト可能で、スケーラブルで、メンテナンスしやすいアプリケーションの開発をサポートすることです。
らしいです。
触ってみた体感はなかなか堅牢な感じがしました。たぶん。
TypeScriptの型の恩恵を受けられるのが大きいと思います。
おそらくですが、Spring Bootとかが近いと思います。
インストール方法
Nest CLIをインストールして、プロジェクトを作成します。
npm i -g @nestjs/cli
nest new bff
bffフォルダに移動し、npm run start
します。
そしてhttp://localhost:3000/ にアクセスするとHello World!が表示され、正しく実行できていることがわかります。
必要なライブラリをインストールしていきます。
npm i -D @nestjs/graphql @nestjs/axios graphql apollo-server-express
モジュールを作成していきます。
nest g module posts
nest g resolver posts --no-spec
nest g service posts --no-spec
nest g class posts/models/post --no-spec
必要なファイルが自動で作成されます。
module
モジュールは、関連する機能をグループ化し、アプリケーションの構造を整理するために使用します。
コントローラーやプロバイダーを定義します。
resolver
リゾルバーは、クライアントからのGraphQLクエリに対してデータを解決する場所です。
service
サービスは、アプリケーション内でのビジネスロジックやデータの操作を行う場所です。
今回は実際にバックエンドに問い合わせを行う場所になります。
models
データを表すためのモデルクラスとして使用されます。これを元にスキーマを自動生成します。
moduleを定義
モジュールを定義します。
autoSchemaFileは、自動的にスキーマが生成されるファイルのパスを指定します。
import { HttpModule } from '@nestjs/axios'
import { Module } from '@nestjs/common'
import { PostsResolver } from './posts.resolver'
import { PostsService } from './posts.service'
import { GraphQLModule } from '@nestjs/graphql'
import { ApolloDriver } from '@nestjs/apollo'
@Module({
imports: [
HttpModule,
GraphQLModule.forRoot({
driver: ApolloDriver,
installSubscriptionHandlers: true,
autoSchemaFile: 'schema/posts.gql',
}),
],
providers: [PostsResolver, PostsService],
})
export class PostsModule {}
modelを定義
今回はjsonに従ってIDとtitleを定義します。
import { Field, ObjectType } from '@nestjs/graphql'
@ObjectType()
export class Post {
@Field((type) => String)
id: string
@Field((type) => String)
title: string
}
resolverを定義
サービスのfindAll関数とfindOne関数を呼び出します。
ここではクエリにidを引数として定義しており、
存在しない場合は全件取得、存在する場合はidで一件取得するように定義しています。
import { Query, Resolver, Args, Int } from '@nestjs/graphql'
import { Post } from '../posts/models/post/post'
import { PostsService } from './posts.service'
import { Observable, map } from 'rxjs'
@Resolver(() => Post)
export class PostsResolver {
constructor(private postService: PostsService) {}
@Query(() => [Post], { name: 'posts', nullable: true })
posts(@Args('id', { type: () => Int, nullable: true }) id?: number): Observable<Post[]> {
if (id !== undefined) {
return this.postService.findOne(id).pipe(map((post) => [post]))
} else {
return this.postService.findAll()
}
}
}
serviceを定義
サービスのfindAll関数とfindOne関数を定義します。
実際にデータを取得する部分はこの場所になります。
import { Injectable } from '@nestjs/common'
import { map, switchMap } from 'rxjs'
import { HttpService } from '@nestjs/axios'
interface Post {
id: string
title: string
views: number
}
@Injectable()
export class PostsService {
constructor(private readonly http: HttpService) {}
findAll() {
return this.http.get<Post[]>('http://localhost:1334/posts')
}
findOne(id: number) {
return this.http.get<Post>(`http://localhost:1334/posts?id=${id}`)
}
これで、Nest.jsの実装は終わりました。
npm run start
を実行し、http://localhost:3000/graphql にアクセスすると実際にgraphqlのクエリを投げて結果を確認できます。
これで正しく実行されていることがわかりました。
最後にフロントエンドと正しく通信できるように少しmain.tsを加工します。
3000ポートではなく5000ポートに変更し、corsを有効にします。
import { NestFactory } from '@nestjs/core'
import { AppModule } from './app.module'
async function bootstrap() {
const app = await NestFactory.create(AppModule)
- await app.listen(3000)
+ app.enableCors()
+ await app.listen(5000)
}
bootstrap()
Frontend(Next.js)
最後にフロントエンドです。
Next.jsとライブラリをインストール
サクッと対話形式でインストールします。
npx create-next-app frontend
Typescript,App routerを指定し、残りの設定は何でも大丈夫です。
(今回はTailwindcssも使用しています。)
次にライブラリです。
今回はGraphQL clientとしてurql
を使用しています。
npm i next-urql react-is urql graphql-tag @urql/next
npm i -D graphql
諸々作成する
appフォルダにpostsフォルダを作成し、ここに取得した結果を表示するページを作成します。
ここでurqlクライアントを初期化します。
requestPolicyがデフォルトだとcache-first
になっており、バックエンドの最新データが表示できなかったため、変更しています。
"use client";
import { useMemo } from "react";
import {
UrqlProvider,
ssrExchange,
cacheExchange,
fetchExchange,
createClient,
} from "@urql/next";
export default function Layout({ children }: React.PropsWithChildren) {
const [client, ssr] = useMemo(() => {
const ssr = ssrExchange({
isClient: typeof window !== "undefined",
});
const client = createClient({
url: "http://localhost:5000/graphql",
exchanges: [cacheExchange, ssr, fetchExchange],
suspense: true,
requestPolicy:"cache-and-network"
});
return [client, ssr];
}, []);
return (
<UrqlProvider client={client} ssr={ssr}>
{children}
</UrqlProvider>
);
}
今回はコンポーネントとページで分けています。
また、クエリはここで作成し、結果を取得して、コンポーネントに渡します。
拡張性を考えて型定義にはコメントも追加しています。
現在実装はないですか、投稿に対して複数のコメントが表示されることを想定しています。
'use client'
import { TopPage } from '@/components/pages'
import { gql, useQuery } from '@urql/next'
import { Suspense } from 'react'
type Posts = {
id: string
title: string
comments?: {
id: string
text: string
}[]
}[]
const Loading = <div>loading...</div>
const Query = gql`
query {
posts {
id
title
}
}
`
export default function Page() {
const [result] = useQuery<{ posts: Posts }>({ query: Query })
const { data } = result
return (
<Suspense fallback={Loading}>
<TopPage data={data} />
</Suspense>
)
}
取得したデータを画面に出します。
type Props = {
data: { posts: Posts } | undefined
}
type Posts = {
id: string
title: string
comments?: {
id: string
text: string
}[]
}[]
export const TopPage = ({ data }: Props) => {
return (
<main className="p-8">
{data ? (
<table className="w-full">
<thead>
<tr>
<th className="border px-4 py-2">ID</th>
<th className="border px-4 py-2">Title</th>
<th className="border px-4 py-2">Comments</th>
</tr>
</thead>
<tbody>
{data.posts.map((post) => (
<tr key={post.id}>
<td className="border px-4 py-2">{post.id}</td>
<td className="border px-4 py-2">{post.title}</td>
<td className="border px-4 py-2">
{post.comments && post.comments.length ? (
<table>
<tbody>
{post.comments.map((comment) => (
<tr key={comment.id}>
<td className="px-4 py-2">{comment.id}</td>
<td className="px-4 py-2">{comment.text}</td>
</tr>
))}
</tbody>
</table>
) : (
<p>コメントはありません</p>
)}
</td>
</tr>
))}
</tbody>
</table>
) : (
<p>データがありません</p>
)}
</main>
)
}
実行する
Next.jsを起動して、全体の動作確認を行います。
npm run dev
そして、http://localhost:3000/posts にアクセスするとバックエンドのデータが正しく表示されました。
いい感じですね。
拡張してみる
GraphQLの実行をすることができましたが、コメントの表示までおこなってみようと思います。
バックエンドは既に http://localhost:1334/comments で取得することができるので、BFFから拡張していきます。
modelを追加
postsの中のコメントとしてモデルを作成します。
nest g class posts/models/comments/comment --no-spec
import { Field, ObjectType } from '@nestjs/graphql'
@ObjectType()
export class Comment {
@Field((type) => String)
id: string
@Field((type) => String)
text: string
@Field((type) => String)
postId: string
}
また、postのモデルも修正します。
import { Field, ObjectType } from '@nestjs/graphql'
+ import { Comment } from './comments/comment'
@ObjectType()
export class Post {
@Field((type) => String)
id: string
@Field((type) => String)
title: string
+ @Field((type) => [Comment], { nullable: true })
+ comments?: Comment[]
}
serviceを修正
サービスのfindAll関数とfindOne関数を、ポストとコメントを統合して返却するように修正します。
import { Injectable } from '@nestjs/common'
import { map, switchMap } from 'rxjs'
import { HttpService } from '@nestjs/axios'
interface Post {
id: string
title: string
views: number
}
interface Comment {
id: string
text: string
postId: string
}
@Injectable()
export class PostsService {
constructor(private readonly http: HttpService) {}
findAll() {
// 投稿データを取得するリクエスト
const posts$ = this.http.get<Post[]>('http://localhost:1334/posts')
// コメントデータを取得するリクエスト
const comments$ = this.http.get<Comment[]>('http://localhost:1334/comments')
// 投稿データとコメントデータを結合して返す
return posts$.pipe(
switchMap((posts) => {
// 投稿ごとのコメントを取得して結合
return comments$.pipe(
map((comments) => {
return posts.data.map((post) => ({
...post,
comments: comments.data.filter((comment) => comment.postId === post.id),
}))
}),
)
}),
)
}
findOne(id: number) {
// 投稿データを取得するリクエスト
const posts$ = this.http.get<Post>(`http://localhost:1334/posts/${id}`)
// コメントデータを取得するリクエスト
const comments$ = this.http.get<Comment[]>(`http://localhost:1334/comments?postId=${id}`)
// 投稿データとコメントデータを結合して返す
return posts$.pipe(
switchMap((post) => {
return comments$.pipe(
switchMap((comments) => {
return [{ ...post.data, comments: comments.data }]
}),
)
}),
)
}
}
これでBFFの修正は終わりました。
フロントエンドのクエリを修正し、実行してみましょう。
'use client'
import { TopPage } from '@/components/pages'
import { gql, useQuery } from '@urql/next'
import { Suspense } from 'react'
type Posts = {
id: string
title: string
comments?: {
id: string
text: string
}[]
}[]
const Loading = <div>loading...</div>
const Query = gql`
query {
posts {
id
title
comments {
id
text
}
}
}
`
export default function Page() {
const [result] = useQuery<{ posts: Posts }>({ query: Query })
const { data } = result
return (
<Suspense fallback={Loading}>
<TopPage data={data} />
</Suspense>
)
}
http://localhost:3000/posts にアクセスすると…
コメントも表示することができました!
まとめ
今回はBFFアーキテクチャとGraphQLというものに入門してみました。
BFFというものがバックエンドに対しての処理を吸収してくれているため、フロントエンドは考える必要がないのはメリットだと感じました。
また、GraphQLを合わせて使用することで好きなデータをバックエンドを考えずに処理でき、GraphQLを使用することで様々なデバイスに対応できることは魅力的に感じました。
ただ、今回はRestAPIで通信を行っているためか、BFFがバックエンドのロジックに依存しているように感じました。
バックエンドとして動くマイクロサービスをgRPCやRestなど様々な形で吸収することができるので、考慮すべきことは多いとは思いますが、メリットも大きいため、今回触ることができてよかったです。
参考