让我们尝试使用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
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命令后,将自动监视并自动更改相应的数据类型。

请求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”来鼓励我!