11
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

BFFアーキテクチャとGraphQLに入門します

Posted at

はじめに

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を作成します。

db.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
package.json
  "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は、自動的にスキーマが生成されるファイルのパスを指定します。

post.module.ts
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を定義します。

posts/models/post/post.ts
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で一件取得するように定義しています。

posts.resolver.ts
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関数を定義します。
実際にデータを取得する部分はこの場所になります。

posts.service.ts
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のクエリを投げて結果を確認できます。

全件取得の場合
image.png

IDを指定した場合
image.png

これで正しく実行されていることがわかりました。

最後にフロントエンドと正しく通信できるように少しmain.tsを加工します。
3000ポートではなく5000ポートに変更し、corsを有効にします。

main.ts
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になっており、バックエンドの最新データが表示できなかったため、変更しています。

posts/layout.tsx
"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>
  );
}

今回はコンポーネントとページで分けています。
また、クエリはここで作成し、結果を取得して、コンポーネントに渡します。
拡張性を考えて型定義にはコメントも追加しています。
現在実装はないですか、投稿に対して複数のコメントが表示されることを想定しています。

posts/page.tsx
'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>
  )
}

取得したデータを画面に出します。

components/pages/index.tsx
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 にアクセスするとバックエンドのデータが正しく表示されました。
いい感じですね。

image.png

拡張してみる

GraphQLの実行をすることができましたが、コメントの表示までおこなってみようと思います。
バックエンドは既に http://localhost:1334/comments で取得することができるので、BFFから拡張していきます。

modelを追加

postsの中のコメントとしてモデルを作成します。

nest g class posts/models/comments/comment --no-spec
posts/models/post/comments/comment.ts
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のモデルも修正します。

posts/models/post/post.ts
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関数を、ポストとコメントを統合して返却するように修正します。

posts.service.ts
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の修正は終わりました。
フロントエンドのクエリを修正し、実行してみましょう。

posts/page.tsx
'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 にアクセスすると…
image.png

コメントも表示することができました!

まとめ

今回はBFFアーキテクチャとGraphQLというものに入門してみました。
BFFというものがバックエンドに対しての処理を吸収してくれているため、フロントエンドは考える必要がないのはメリットだと感じました。
また、GraphQLを合わせて使用することで好きなデータをバックエンドを考えずに処理でき、GraphQLを使用することで様々なデバイスに対応できることは魅力的に感じました。
ただ、今回はRestAPIで通信を行っているためか、BFFがバックエンドのロジックに依存しているように感じました。
バックエンドとして動くマイクロサービスをgRPCやRestなど様々な形で吸収することができるので、考慮すべきことは多いとは思いますが、メリットも大きいため、今回触ることができてよかったです。

参考

11
7
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
11
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?