让我们尝试使用Next.js × Go × AWS构建具有JWT身份验证和CI/CD的GraphQL应用

首先

■提醒■
希望您能阅读【环境搭建篇】以了解本连载的背景、可创建的应用程序以及操作方法。【环境搭建篇】

【Next.js篇】 ?您正在此处
【Go篇】
【AWS篇】

因为我希望继续努力撰写实操类文章,所以如果您觉得不错的话,请点击LGTM以鼓励我…!

环境搭建

请参阅【环境搭建篇】以了解本示例应用程序的环境搭建方法。

在这里的代码中有很多部分被省略了。希望您能克隆到您的本地,以便适时确认缺失的部分。

 

纱线.版本3/浆果

本次我们使用Yarnv3版本作为Next.js的包管理工具。与v1版本相比,有以下两个主要区别。

(1) Plug’n’Play(即插即用):
Plug’n’Play通过一种与v1版本的节点模块解析机制不同的方法来实现。这样可以提高性能并节省资源。同时,它不再使用v1版本中常用的node_modules文件夹。

(2)零安装:
通过使用零安装,您可以直接将包的依赖关系包含在仓库中。通过对依赖关系文件进行git管理,开发者在克隆时不需要执行yarn install命令。

可以加快项目的设置并实现开发的效率化。

请注意,如果您使用了不支持 PnP 的库,需要进行诸如 yarn install 之类的依赖项安装。

如您在确认公式的基础样式时会发现,默认情况下,已安装的版本为1.22。

因此,在本示例应用程序的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 && \

推荐以下的文章,因为它很容易理解。

 

多层次的建筑

据说多阶段构建可以减小图像尺寸、分离构建过程,并提高安全性。

此外,只需通过叠加从前一个阶段所需的句子来获取所需的文件,即可从最终图像中剔除不必要的构建工具和依赖项。

考虑到这次部署到AWS上的需求,我们使用了轻量级的bullseye-slim镜像,而不是常规的node镜像。

 

# 開発ステージ
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"]

使用GitHub Actions构建CI环境

在本仓库中,当进行push操作时,会在GitHubActions上执行工作流程。

    • テスト(jest)

 

    静的解析(eslint/prettier)

此外,本次我们使用docker-compose在同一工作流上进行CI。通过使用docker-compose来执行,实现了与本地环境类似的CI环境。

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

在本文中,我们将跳过GitHub Actions的初始设置。请参考以下内容。

 

前端架构

我尝试将UI部分和逻辑部分分开,并意识到Container层/presenter层的组件设计。

不是将自定义钩子(hooks)放入hooks目录中,而是将其内容放在组件(Component)所在的相同层级目录中。

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

另外,食べログ的这篇文章非常有参考价值。

 

使用Next.js的Layout功能来实现布局的共享

我们可以对布局进行共通化,并且可以在页面层面进行布局切换。

 

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

在使用布局的页面上,请按照以下方式进行描述。

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 进行表单管理

通过使用同一库,可以轻松进行错误处理和验证。

另外,通过使用非受控组件,可以在尽量减少重新渲染的同时提升性能。

 

// ...省略

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

//  ...省略

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将以被称为“原子”的单位来进行定义和管理。

同时,通过使用recoilPersist库,可以将值存储到会话存储或本地存储中,以实现持久化。

 

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进行cookie管理

使用react-cookie可以轻松实现React应用的cookie管理功能。

本应用程序使用csrf令牌进行处理。

 


// ..省略
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)
图灵语义库 (Turing Graph Query Language)

GraphQL有助于减少在REST API中经常出现的问题,例如过度获取信息(Overfetching),即获取超过所需的数据,或者无法在一次请求中完全获取所需的数据(Underfetching)的问题。

GraphQL代码生成器

这个工具可以根据GraphQL模式自动生成类型定义等。

如果使用TypeScript,就需要根据GraphQL Schema即时创建相应的类型定义。

通过使用这个工具,我们可以自动生成类型定义文件等代码,从而实现高效且一致的GraphQL开发。

 

模式文件

input NewMessage {
    text: String!
    userId: String!
}
type Mutation {
    createMessage(input: NewMessage!): Message!
}

type Query {
    getMessages: [Message!]!
}
type Message {
  id: ID!
  text: String!
  user: User!
  created_at: String!
  updated_at: String!
}
type User {
  id: ID!
  name: String!
  email: String!
  password: String!
}

自动生成代码

如果定义上述的模式文件并执行命令,该文件将自动生成。

# docker-compose exec front yarn generate
$ make generate
/graphql/
 ├ generated/
 │ └ fragment-masking.ts
 │ └ gql.ts
 │ └ graphql.ts
ファイル名内容fragment-masking.tsGraphQLのフラグメントに関する型情報gql.tsquery, mutationに関する型情報graphql.tsGraphQLスキーマから生成された型情報

GraphQL 文档

在这里,我们根据自动生成的模式文件对应的类型定义,定义了用于执行查询和变更操作的GraphQL文档。

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

我正在使用名为graphql-request的库来创建一个GraphQL客户端。

在这里,我们定义了一个用于向GraphQL服务器发送查询和变更请求的client函数。

为了在前端和API之间使用不同的域名进行运营,我们还进行了CORS支持和向请求中添加cookie的配置。

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 查询 (原先的 React 查询)

Tanstack Query是React的数据管理库,可以通过简单和声明性的方式进行服务器端状态管理、数据获取、缓存和错误处理。

GraphQL也提供支持,并且官方文档中也包含示例。

在本应用程序中,我们使用相同的库来获取和创建消息。此外,各个处理过程通过使用自定义钩子将逻辑分离出来。

 

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,可以更清晰地描述数据获取时的处理。

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”页面。另外,如果发生错误,则输出日志到控制台。

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(模拟服务工作者)是一个在浏览器和Node.js两种环境中运行的API模拟库。

通过使用 Service Worker ,MSW 可以拦截网络请求,并返回预定义的响应。这样,在测试和开发期间就可以模拟与真实的 API 服务器相似的行为。

 

React 测试库

React Testing Library是一个用于测试React组件的库,它提供了针对DOM的查询功能。它可以帮助使用用户角度来测试UI组件。

 

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

接下来

【环境搭建篇】
【Next.js篇】【Go篇】  ?请点击这里
【AWS篇】

因为我希望将来能够继续努力写一些实践性的文章,如果您觉得不错,欢迎给我一个“LGTM”来鼓励我!

bannerAds