15
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-05-26

はじめに

■ご案内■
本連載の背景/作成できるアプリケーション/進め方をご理解頂く上でも【環境構築編】 をご一読頂けると幸いです。

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

環境構築

本サンプルアプリの環境構築方法は【環境構築編】に記載しているので、そちらをご参照ください。

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

Yarn .ver3/Berry

今回Next.jsのパッケージ管理としてYarnv3系を利用しています。
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では、以下のように個別で指定しています。

.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 && \

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

マルチステージビルド

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

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

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

.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環境を実現しています。

.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自体の初期設定は割愛いたします。以下を参考にしてみてください。

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

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

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

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

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

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

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

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}
    </>
  );
}

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

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を使ったフォーム管理

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

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

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}
      />
    </>
  );
}

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

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を消すことができると最近知りました。

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

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

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

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開発が可能になります。

Schema file

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

src/graphql/schema/query.graphql
type Query {
    getMessages: [Message!]!
}
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!
}

コード自動生成

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

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

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

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

graphql-request

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

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

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

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にもサポートしており、公式ドキュメントにもサンプル例が載っています。

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

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,
  };
};

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

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へ遷移しています。また、エラーが発生した場合は、コンソールにログを出力しています。

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 };
};

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

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

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

コンポーネントテスト

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

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

msw

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

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

React Testing Library

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

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();
  });
});

この後は

■ご案内■

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

15
8
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
15
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?