【Next.js編】Next.js × Go × AWSでJWT認証付きGraphQLアプリとCI/CDを構築してみよう

# はじめに

:::note info
■ご案内■
本連載の背景/作成できるアプリケーション/進め方をご理解頂く上でも[【環境構築編】](https://qiita.com/WebEngrChild/items/d4bc23bf352e7aa46361) をご一読頂けると幸いです。

- [【環境構築編】](https://qiita.com/WebEngrChild/items/d4bc23bf352e7aa46361) 
- **[【Next.js編】](https://qiita.com/WebEngrChild/items/6e9e3232046301879089)**　👈いまここです
- [【Go編】](https://qiita.com/WebEngrChild/items/d9b87944235c5220ae5b)
- [【AWS編】](https://qiita.com/WebEngrChild/items/ca93783f8ae6bee7f985)

これからも頑張ってハンズオン系の記事を書いていきたいと思っているので、いいねっと思って頂けたら**LGTM**押していただけると励みになります...！

:::

## 環境構築

本サンプルアプリの環境構築方法は[【環境構築編】](https://qiita.com/WebEngrChild/items/d4bc23bf352e7aa46361)に記載しているので、そちらをご参照ください。

掲載しているコードは割愛している箇所が多数あります。お手元でクローンいただき適宜、不足部分をご確認いただけると幸いです。

https://github.com/WebEngrChild/next-graphql-front

# Yarn .ver3/Berry

今回`Next.js`のパッケージ管理としてYarn`v3`系を利用しています。
`v1`系との大きな違いは以下2つです。

 **(1)Plug'n'Play (PnP)**:
PnPはノードモジュールの依存関係の解決方法を`v1`系の仕組みとは異なる方法で実現しています。その結果、パフォーマンス向上、リソース節約を行うことができます。また、`v1`系まで使用されてきた、`node_modules`フォルダを利用しません。

 **(2)Zero-Installs**:
Zero-Installsを利用することで、リポジトリ内にパッケージの依存関係を直接含めることができます。依存関係のファイル群もgit管理することで、クローン時には開発者が`yarn install`を実行する必要ありません。

プロジェクトのセットアップを迅速化し、開発の効率化を実現できます。

※ただし、pnp非対応なライブラリを利用する場合等は、`yarn install`などの依存関係インストールが必要になるのでご注意ください。

公式のベースイメージを確認しても分かるとおり、デフォルトでは`1.22`がインストールされています。

したがって、本サンプルアプリのフロントのDockerfileでは、以下のように個別で指定しています。

https://github.com/nodejs/docker-node/blob/7a8d51d0e6acd67d14059140231ea52c7be8f023/19/bullseye/Dockerfile

```Dockerfile:.docker/front/Dockerfile
FROM node:19.9.0-bullseye-slim AS dev

WORKDIR /app

RUN apt-get update && \
    apt-get upgrade -y && \
    apt-get autoremove -y && \
    # Yarnのバージョンを上げる
    yarn set version berry && \
```

以下の記事がわかりやすいのでおすすめです。

https://www.wantedly.com/companies/wantedly/post_articles/325643

# マルチステージビルド

マルチステージビルドはイメージサイズの削減、ビルド過程の分離、安全性向上を図る事ができるそうです。

また、`From`句を重ね、前ステージから必要なファイルのみを取得することで、不要なビルドツールや依存性を最終イメージから除くことが可能です。

今回はAWSへのデプロイを考慮しているため、通常の`node`イメージではなく、軽量な`bullseye-slim`も利用しています。

https://zenn.dev/jrsyo/articles/e42de409e62f5d

```Dockerfile:.docker/front/Dockerfile
# 開発ステージ
FROM node:19.9.0-bullseye-slim AS dev

## ...省略

# ベースイメージ
FROM node:19.9.0-bullseye-slim AS base

## ...省略

# ビルドステージ
FROM base AS builder

## ...省略

# プロダクション用ステージ
FROM builder AS runner
WORKDIR /app

ENV NODE_ENV=production

## ...省略
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000

ENV PORT 3000

CMD ["yarn", "start"]
```

## GitHubActionsを用いたCI環境構築

本リポジトリではpush時にGitHubActions上でWorkflowを実行しています。

- テスト（`jest`）
- 静的解析（`eslint/prettier`）

また、今回は同Workflow上で`docker-compose`を用いてCIを実施しています。
`docker-compose`を用いて実行することで、ローカルマシンと同様なCI環境を実現しています。

```yml:.github/workflows/ci.yml
name: next-front-app
on:
  push:
    branches:
      - main
  pull_request:
  workflow_dispatch:
defaults:
  run:
    working-directory: ./
env:
# CIで利用するファイルを指定
  DOCKER_COMPOSE_FILE: docker-compose.ci.yml
jobs:
  Linter:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v3
      - name: List files
        run: |
          ls -la
      - name: Docker Set Up
        run: |
          docker-compose -f ${{ env.DOCKER_COMPOSE_FILE }} build front
          docker-compose -f ${{ env.DOCKER_COMPOSE_FILE }} up -d front
      - name: Container Status
        run: |
          docker-compose -f ${{ env.DOCKER_COMPOSE_FILE }} ps
      - name: Install
        run: |
          docker-compose -f ${{ env.DOCKER_COMPOSE_FILE }} exec -T front yarn
      - name: Run TEST
        run: |
          docker-compose -f ${{ env.DOCKER_COMPOSE_FILE }} exec -T front yarn jest
      - name: Run CI
        run: |
          docker-compose -f ${{ env.DOCKER_COMPOSE_FILE }} exec -T front yarn prettier
          docker-compose -f ${{ env.DOCKER_COMPOSE_FILE }} exec -T front yarn lint
```

本記事では`GitHubActions`自体の初期設定は割愛いたします。以下を参考にしてみてください。

https://qiita.com/HeRo/items/935d5e268208d411ab5a#permission

https://eslint.org/

https://prettier.io/

# フロントエンドアーキテクチャ

`UI`部分と`ロジック`部分を切り分け、Container層/presenter層を意識したComponent設計を試してみました。

カスタムフックも`hooks`ディレクトリに詰め込むのではなく、`Component`に閉じる内容は同じ階層のディレクトリに配置しています。

```sh: src/components/
src/
　├ Component/
　│　└ List/
　│　　　├─ index.module.scss
　│　　　├─ index.test.tsx
　│　　　├─ index.tsx
　│　　　├─ presenter.tsx
　│　　　└─ useGetMessages.ts
```

また、以下の食べログさんの記事が非常に参考になりました。

https://note.com/tabelog_frontend/n/n4b8bcb44294c

https://note.com/tabelog_frontend/n/n07b4077f5cf3

# Next.jsのLayout機能を使ったレイアウトの共通化

レイアウトの共通化、ページ単位でのレイアウトの切り替えも行う事ができます。

https://nextjs.org/docs/pages/building-your-application/routing/pages-and-layouts#with-typescript


```tsx:src/components/Layout/index.tsx
import { Presenter } from '@/components/Layout/presenter';
import useLogout from '@/components/Layout/useLogout';
import type { ReactNode } from 'react';

type LayoutProps = {
  children: ReactNode;
};

export function Layout({ children }: LayoutProps) {
  const { logout } = useLogout();

  return (
    <>
      <Presenter logout={logout} />
      {children}
    </>
  );
}
```

レイアウトを利用するページでは以下のように記述します。

```tsx:src/pages/timeLine/index.tsx
import { Layout } from '@/components/Layout';
import { List } from '@/components/List';
import type { NextPageWithLayout } from '@/pages/_app';
import React from 'react';

const Page: NextPageWithLayout = () => {
  return (
    <>
      <List />
    </>
  );
};

Page.getLayout = function getLayout(page) {
  return <Layout>{page}</Layout>;
};

export default Page;

```

# React Hook Formを使ったフォーム管理

同ライブラリを使うことで、エラーハンドリング、バリデーションを簡単に行う事ができます。

また、非制御コンポーネントを使用することで、再レンダリングを最小限に抑えつつパフォーマンスを向上させることができます。

https://www.react-hook-form.com/get-started/

```tsx:src/components/Post/index.tsx
// ...省略

export function Post() {
  // ...省略

  // React Hook Formのhooksの呼び出し
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<Create>();

  const onSubmit: SubmitHandler<Create> = async (data) => mutation.mutate(data);

//  ...省略

  return (
    <>
      <Presenter
        handleSubmit={handleSubmit}
        onSubmit={onSubmit}
        register={register}
        errors={errors}
        userId={user.userId}
        router={router}
      />
    </>
  );
}

```

```tsx:src/components/Post/presenter.tsx
//  ...省略

export function Presenter(props: Props) {
  return (
    <>
      <div className={styles.inputWrapper}>
        <div className={styles.error}>
          {/* React Hook Formでのエラーハンドリング */}
          {props.errors.userId && <span>※Please login again</span>}
          {props.errors.text && <span>※Please input text</span>}
        </div>
        <div className={styles.textareaWrapper}>
          <input
            type='hidden'
            defaultValue={props.userId}
            {...props.register('userId', { required: true })}
          />
          {/* React Hook Formでのバリデーション */}
          <textarea className={styles.textarea} {...props.register('text', { required: true })} />
        </div>
        {/* ...省略 */}
      </div>
    </>
  );
}

```

# Recoilを使ったグローバル状態管理

最近では、かなり有名になってきた状態管理ライブラリですね。本アプリでもグローバル状態管理として利用してみました。

`Recoil`はアトム（`atom`）と呼ばれる単位で定義・管理していきます。

また、`recoilPersist`というライブラリを利用することで、値の格納先をセッションストレージやローカルストレージに指定することで永続化することができます。

https://recoiljs.org/

https://github.com/polemius/recoil-persist

```tsx: src/atoms/userAtom.ts
import { useMemo } from 'react';
import { atom, useRecoilState, RecoilEnv } from 'recoil';
import { recoilPersist } from 'recoil-persist';

// `Duplicate atom key` Error対策
RecoilEnv.RECOIL_DUPLICATE_ATOM_KEY_CHECKING_ENABLED = false;

type UserState = { userId: string } | null;

// 永続化
const { persistAtom } = recoilPersist({
  key: 'recoil-persist',
  storage: typeof window === 'undefined' ? undefined : localStorage,
});

// Atomの定義
const userState = atom<UserState>({
  key: 'userId',
  default: null,
  effects_UNSTABLE: [persistAtom],
});

export const useUserState = () => {
  const [user, setUser] = useRecoilState<UserState>(userState);

  return { user, setUser };
};

```

`Duplicate atom key` Errorを消すことができると最近知りました。

https://github.com/facebookexperimental/Recoil/releases/0.7.6

# react-cookieを使ったクッキー管理

`react-cookie`を使用すると、Reactアプリにクッキー管理機能を簡単に実装することができます。

本アプリでは`csrf`トークンのハンドリングのために利用しています。

https://www.npmjs.com/package/react-cookie

```ts:src/components/Login/useLogin.ts

// ..省略
export const useLogin = () => {
  const { setUser } = useUserState();
  const { setCsrf } = useSetCsrf();
  const router = useRouter();
  const params = useMemo(() => new URLSearchParams(), []);

  // "_csrf"という名前のクッキーをセット
  const [cookies, setUseCookies] = useCookies(['_csrf']);

  useEffect(() => {
    setUseCookies('_csrf', cookies._csrf);
  }, [cookies._csrf, setUseCookies]);

// ..省略
```

# GraphQL

GraphQLでは必要なデータ以上に情報を取得する「オーバーフェッチ」、または必要なデータを一度のリクエストで完全に取得できない「アンダーフェッチ」という、REST APIで頻発する問題を低減できるといったメリットがあります。

## GraphQL Code Generator

このツールは、GraphQLスキーマから型定義などを自動生成することができるツールです。

TypeScriptを利用する場合、GraphQLのSchemaを元に対応する型定義を都度作成する必要があります。

一方で、このツールを利用することで、型定義ファイル等を自動コード生成を行ってくれ、効率的で一貫性のあるGraphQL開発が可能になります。

https://the-guild.dev/graphql/codegen/docs/getting-started

### Schema file

```graphql:src/graphql/schema/input.graphql
input NewMessage {
    text: String!
    userId: String!
}
```

```graphql:src/graphql/schema/mutation.graphql
type Mutation {
    createMessage(input: NewMessage!): Message!
}

```

```graphql:src/graphql/schema/query.graphql
type Query {
    getMessages: [Message!]!
}
```

```graphql:src/graphql/schema/type.graphql
type Message {
  id: ID!
  text: String!
  user: User!
  created_at: String!
  updated_at: String!
}
type User {
  id: ID!
  name: String!
  email: String!
  password: String!
}
```

### コード自動生成

上記のスキーマファイルを定義してコマンドを実行すると当該ファイルが自動生成されます。

```shell:Terminal
# docker-compose exec front yarn generate
$ make generate
```

```
/graphql/
　├ generated/
　│　└ fragment-masking.ts
　│　└ gql.ts
　│　└ graphql.ts
```

|ファイル名  |内容  |
|---|---|
|fragment-masking.ts  |GraphQLのフラグメントに関する型情報|
|gql.ts  |query, mutationに関する型情報|
|graphql.ts  |GraphQLスキーマから生成された型情報|

### GraphQL Document

ここでは、自動生成されたスキーマファイルに対応する型定義を元に`query`や`mutation`を実行するための、GraphQL Documentを定義しています。

```ts:src/graphql/document.ts
import { graphql } from '@/graphql/generated/gql';

export const query = graphql(/* GraphQL */ `
  query getMessagesQueryDocument {
    getMessages {
      id
      text
      created_at
      user {
        name
      }
    }
  }
`);

export const create = graphql(/* GraphQL */ `
  mutation createMessage($userId: String!, $text: String!) {
    createMessage(input: { userId: $userId, text: $text }) {
      id
      text
      user {
        id
      }
      created_at
    }
  }
`);
```

開発の中で、取得や更新対象のフィールドが変更になった場合はこのドキュメントに追記を行います。

上記の`$ make generate`コマンドを実行すると、変更が監視され自動的に該当するデータ型が自動的に変更&自動修正されます。

![スクリーンショット 2023-05-24 15.56.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1824740/73ae9eb5-b0bd-5db0-2782-945e74c0e322.png)

## graphql-request

`graphql-request`というライブラリを使ってGraphQLクライアントを作成しています。

ここでは、GraphQLサーバーに対してクエリやミューテーションを送信するための`client`関数を定義しています。

フロントとAPIを別ドメインで運用するため、`cors`サポートと リクエストへの`cookie`付与の設定も行っています。

```ts:src/graphql/client.ts
import { GraphQLClient } from 'graphql-request';

export const client = (token: string) =>
  new GraphQLClient(`${process.env.NEXT_PUBLIC_API_URL}/query`, {
    headers: {
      'X-CSRF-TOKEN': token,
    },
    mode: 'cors',
    credentials: 'include',
  });

```

## Tanstack Query（旧React Query）

`Tanstack Query`はReactのデータ管理ライブラリで、サーバーサイドの状態管理、データのフェッチ、キャッシュ、エラーハンドリングをシンプルかつ宣言的な方法で行うことができます。

GraphQLにもサポートしており、公式ドキュメントにもサンプル例が載っています。

本アプリでは、メッセージの取得と作成にて、同ライブラリを用いています。また、各処理自体はカスタムフックを使ってロジックを切り出しています。

https://tanstack.com/query/latest

https://tanstack.com/query/latest/docs/react/graphql

```ts:src/components/List/useGetMessages.ts
import { client } from '@/graphql/client';
import { query } from '@/graphql/document';
import { useQuery } from '@tanstack/react-query';
import { useCookies } from 'react-cookie';

export const useGetMessages = () => {
  const [cookies] = useCookies(['_csrf']);
  const requestQuery = async () => client(cookies._csrf).request(query);
  const { isLoading, isError, data, error } = useQuery({
    queryKey: ['messages'],
    queryFn: requestQuery,
  });

  return {
    isLoading,
    isError,
    data,
    error,
  };
};

```

`isLoading`や`isError`を使うことで、データフェッチ時のハンドリングもわかりやすく記述することができます。

```ts:src/components/List/index.tsx
import { useUserState } from '@/atoms/userAtom';
import { Presenter } from '@/components/List/presenter';
import { useGetMessages } from '@/components/List/useGetMessages';
import { useRouter } from 'next/router';
import React from 'react';

export function List() {
  const { setUser } = useUserState();
  const router = useRouter();
  const { isLoading, isError, data, error } = useGetMessages();

  if (isLoading) <span>Loading...</span>;
  if (isError) {
    console.error('Error: useGetMessages', error);
    setUser(null);
    router.push('/');
  }

  return <>{data && <Presenter data={data} router={router} />}</>;
}

```

`useMutation`を使うことでデータ更新時のハンドリングを細かく指定する事ができます。

ここでは、新規メッセージ作成が成功した場合、キャッシュをクリアして、`/timeLine`へ遷移しています。また、エラーが発生した場合は、コンソールにログを出力しています。

```ts:src/components/Post/useCreateMessages.ts
import { client } from '@/graphql/client';
import { create } from '@/graphql/document';
import { Create } from '@/types/form';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import Cookies from 'react-cookies';

export const useCreateMessages = () => {

  // ...省略
  const mutation = useMutation({
    mutationFn: requestQuery,

    // 成功
    onSuccess: async () => {
      await queryClient.invalidateQueries({ queryKey: ['messages'] });
      router.push('/timeLine');
    },

    // 失敗
    onError: (error, variables) => console.error(`error: ${error} variables: ${variables}`),
  });

  return { mutation };
};

```

https://tanstack.com/query/v4/docs/react/reference/useMutation

# フロントエンドのテスト戦略

フロントエンドのテストコードはAPI側のテストと比較して対照範囲やテスト戦略について悩む事が多いですよね。

私も改めて学習する中で、以下の記事が参考になったのでリンクを貼らしていただきます。

https://zenn.dev/koki_tech/articles/a96e58695540a7

https://blog.cybozu.io/entry/2022/08/29/110000

https://blog.cybozu.io/entry/2022/11/14/120000

# コンポーネントテスト

ここでは、`List`コンポーネントが適切にデータを取得し、表示する機能をテストしてみました。

また、`msw`ライブラリを使ってGraphQLクエリのモックサーバーを設定し、react-queryを使ってデータ取得を行い、`testing-library`を使ってレンダリング結果の検証を行っています。

## msw

msw（Mock Service Worker）は、ブラウザとNode.jsの両方で動作するAPIモッキングライブラリです。

mswはService Workerを利用してネットワークリクエストをインターセプトし、あらかじめ定義したレスポンスを返すことができます。これにより、テストや開発時にリアルなAPIサーバーと同じような動作をエミュレートすることができます。

https://mswjs.io/

https://mswjs.io/docs/api/graphql/query

## React Testing Library

React Testing Libraryは、Reactコンポーネントのテストを行うためのライブラリで、DOMに対するクエリを提供します。ユーザー視点でUIコンポーネントをテストするのに役立ちます。

https://testing-library.com/docs/react-testing-library/intro/

https://qiita.com/ossan-engineer/items/4757d7457fafd44d2d2f

```tsx:src/components/List/index.test.tsx
import '@testing-library/jest-dom/extend-expect';
import { List } from '@/components/List';
import { GetMessagesQueryDocumentQuery } from '@/graphql/generated/graphql';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen } from '@testing-library/react';
import { graphql } from 'msw';
import { setupServer } from 'msw/node';
import { RecoilRoot } from 'recoil';

// 対策： NextRouter was not mounted. https://nextjs.org/docs/messages/next-router-not-mounted
jest.mock('next/router', () => ({
  useRouter: jest.fn(),
}));

// Mock Serverの設定
const server = setupServer(
  graphql.query<GetMessagesQueryDocumentQuery>('getMessagesQueryDocument', (req, res, ctx) => {
    return res(
      ctx.data({
        getMessages: [
          {
            id: '1',
            text: 'test message',
            user: {
              name: 'test-user1',
            },
            created_at: '2023-01-30T12:07:06Z',
          },
        ],
      }),
    );
  }),
);

// set up
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

// Test
describe('mocking API', () => {
  it('Fetch success Should display fetched data correctly', async () => {
    // 対策： No QueryClient set, use QueryClientProvider to set one
    const queryClient = new QueryClient();

    render(
      <RecoilRoot>
        <QueryClientProvider client={queryClient}>
          <List />
        </QueryClientProvider>
      </RecoilRoot>,
    );

    // アサート実行
    expect(screen.queryByText(/test-user1/)).toBeNull();
    expect(await screen.findByText(/test-user1/)).toBeInTheDocument();

    // テスト下でのレンダリング表示内容確認用
    // screen.debug();
  });
});

```

# この後は

:::note info
■ご案内■

- [【環境構築編】](https://qiita.com/WebEngrChild/items/d4bc23bf352e7aa46361)　
- [【Next.js編】](https://qiita.com/WebEngrChild/items/6e9e3232046301879089)
- **[【Go編】](https://qiita.com/WebEngrChild/items/d9b87944235c5220ae5b)** 　👈次はこちらへ
- [【AWS編】](https://qiita.com/WebEngrChild/items/ca93783f8ae6bee7f985)

これからも頑張ってハンズオン系の記事を書いていきたいと思っているので、いいねっと思って頂けたら**LGTM**押していただけると励みになります！

:::