はじめに
お待たせしました! 前回のハンズオン記事の第2弾です。
今回は、フロントエンド(NextJS)、Nginx サーバーとDocker周りの設定に着手します。
これからこの技術スタックを試したい方のためにテンプレート的なコードになればいいと思いこの記事を書きました。
では、早速やっていきましょう!
前提条件
- Dockerがインストールされていること。(Dockerhubをインストールした方が楽)
- Nodeがインストールされていること(使用するパッケージの都合上、v12.0.0を推奨します。nvmをインストールしておくとバージョンの切り替えが便利です。)
- ハンズオンをしたい方は、サーバーサイド編を読み終えていること
対象読者
- Docker、React、NodeJSなど単独で勉強してきたが、全てを網羅的に学習したい方
- とりあえず、フルスタックのアプリをTypescriptだけで開発してみたい方
- NextJS・NestJS・Dockerは聞いたことあるけれど、使ったことがない方・使ってみたい方
本編
前回は、サーバーサイド(NestJS)周りを中心に実装しました。NestJSというフレームワークの特殊な記法・アーキテクチャーを用いて、データベースへの接続コードやHTTPリクエスト(GETやPOST)に対応したコードを書いていきました。
今回はNextJSを使ってフロントエンドから開発していきましょう!
その前に、前回のおさらいとして、アプリの構成図を載せておきます!
アプリ構成
- ポイント①:nginxサーバーをブラウザとシステムの間に挟むことで、プロキシサーバー・ルーターとしての役割を与えたこと。リクエストのパラメーターに「/」だけ指定してある場合はNextJS、パラメータに「/api」が指定されている場合はNestJSに振り分けるように設定します。
- ポイント②:Dockerコンテナ内でアプリを起動すること。こうすることで、環境依存しない開発が可能になります。
NextJSでフロントエンドを実装しよう!
プロジェクトの作成
まずは、フロントエンド! 以下のコマンドを叩いてプロジェクトを作成しましょう!
# npm派はこちら
npx create-next-app
# yarn派はこちら
yarn create next-app
すると、以下のようにプロジェクトが作成されます!
Typescriptの設定ファイルを作成
ただ、今回はTypescriptを使っていきたいのでtsconfig.json
設定ファイルをルートディレクトリーに作成していきましょう!
touch tsconfig.json
# yarn run devで試しに実行してみると、以下のdependenciesをインストールする
ように怒られるのでしておく
yarn add --dev typescript @types/react @types/node
tsconfig.jsonファイルも以下のように修正
{
"compilerOptions": {
"sourceMap": true,
"noImplicitAny": true,
"module": "esnext",
"target": "es6",
"jsx": "preserve",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"baseUrl": "./",
"skipLibCheck": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleResolution": "node"
},
"include": [
"pages/**/*",
],
"exclude": [
"node_modules"
]
}
今回は、pages/
配下の全てのフォルダやファイルを対象に再帰的にチェックするよう設定しました。もし、厳しめにTypescriptの確認をする場合はstrict:true
にしてください。
これでNextJSのプロジェクトはTypescriptに対応するようになっています。あとは、pages
配下の拡張子を全て.ts
に設定しておきましょう。
ついでにESLINTとPrettierの設定もしておく
開発コードは予め可読性を高くしておきたいです、、、ウムウム
個人の好みはあると思いますが、最低限の設定はしておきましょう!
私はVSCodeをエディタとして使用しているので、ESLintとPrettierのプラグインを入れておきます。
module.exports = {
semi: true,
trailingComma: 'es5',
singleQuote: true,
printWidth: 100,
tabWidth: 2,
useTabs: false,
}
module.exports = {
overrides: [
{
files: ['*.ts', '*.tsx'],
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: __dirname,
sourceType: 'module',
},
},
],
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/eslint-recommended',
'plugin:prettier/recommended',
'prettier/@typescript-eslint',
],
plugins: ['@typescript-eslint', 'react', 'prettier'],
parser: '@typescript-eslint/parser',
env: {
browser: true,
node: true,
es6: true,
},
parserOptions: {
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
rules: {
'react/prop-types': 'off',
'react/react-in-jsx-scope': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'prettier/prettier': 'error',
},
settings: {
react: {
version: 'detect',
},
},
};
トラブルシューティング: もし、ESLintやPrettierが上手く動作しない場合は、package.jsonに必要なdependenciesがないかVSCodeの設定で「formatOnSave」がfalseのどちらかの場合が多いので注意してくださいね!
pagesのファイルを修正
今回は意図的にアプリの構成をシンプルにしました。なので、_app.ts
と index.ts
の修正だけで十分です。
import React from 'react';
import type { AppProps /*, AppContext */ } from 'next/app';
import '../styles/global.scss';
function MyApp({ Component, pageProps }: AppProps): JSX.Element {
return <Component {...pageProps} />;
}
// Only uncomment this method if you have blocking data requirements for
// every single page in your application. This disables the ability to
// perform automatic static optimization, causing every page in your app to
// be server-side rendered.
//
// MyApp.getInitialProps = async (appContext: AppContext) => {
// // calls page's `getInitialProps` and fills `appProps.pageProps`
// const appProps = await App.getInitialProps(appContext);
// return { ...appProps }
// }
export default MyApp;
import Head from 'next/head';
import React, { useEffect, useState } from 'react';
import axios from 'axios';
import { MovieListType } from '../interface/movie';
export default function Home(): React.ReactElement {
const [movieName, setMovieName] = useState<string>('');
const [movieList, setMovieList] = useState<Array<MovieListType>>([]);
useEffect(() => {
console.log(movieList);
}, [movieList]);
useEffect(() => {
let tmp: any = '';
const fetchMovieList = async (): Promise<void> => {
const { data } = await axios.get('/api/movielist');
tmp = data;
setMovieList(tmp);
};
fetchMovieList();
}, []);
function onMovieNameInput(event: React.ChangeEvent<HTMLInputElement>) {
const inputValue = (event.target as HTMLInputElement).value;
setMovieName(inputValue);
}
async function onClickWatchLater() {
await axios.post('/api/movielist', {
movieName,
});
const { data } = await axios.get('/api/movielist');
setMovieList(data);
}
return (
<div>
<Head>
<title>I Theater</title>
<link rel="icon" href="/favicon.ico" />
<link rel="preconnect" href="https://fonts.gstatic.com" />
<link
href="https://fonts.googleapis.com/css2?family=Unlock&display=swap"
rel="stylesheet"
/>
</Head>
<div className="wrapper">
<div className="search">
<div>
<h1 className="title">ITheater</h1>
</div>
<div>
<input
className="input u-margin-bottom-medium"
value={movieName}
onChange={onMovieNameInput}
/>
</div>
<div>
<button className="btn" onClick={onClickWatchLater}>
Watch Later!
</button>
</div>
</div>
<div>
{movieList.map((el: MovieListType, index: number) => {
if (movieList.length === 0) {
return <div></div>;
} else {
return (
<div key={index} className="result result__element">
<div className="result__row--number">{el.id}</div>
<div className="result__row--border"></div>
<div className="result__row--title">{el.movieName}</div>
</div>
);
}
})}
</div>
</div>
</div>
);
}
ポイントとしては、新しい映画の名前を登録するときのリクエスト先のパス名を「/api」から始めていることです。後ほど実装するnginxサーバーが識別できるようにするために必要なコードになります!
次に、型安全としてインタフェースも定義しておきましょう。基本的に、サーバーサイドのエンティティクラスに合わせておきます。
export type MovieListType = {
id: number;
movieName: string;
};
CSSの設定
CSSファイルにはSCSS/SASS(CSSのコンパイラ)を使用しています。今回は、フロントエンドの設計記法として「BEM」を採用しました。
アーキテクチャーとしては、「7−1」パターンを使っています。
こちらのリンクからソースをダウンロードし、stylesフォルダを置き換えておきましょう。
これでフロントエンドの実装は終わりです!
Nginxサーバーの実装
さて、nginxの実装に取り掛かるとしますか〜
と意気込んだものの、nginxの設定は本当に初歩的な部分にしか触れませんw
まぁ、正確にはそこしかまだ触れられませんw
基本的に、URIのパスによってクライアント(∴ NextJS)かサーバー(∴NestJS)にリクエストを送るか判断するための仲介人的な役割をはたしていただきます。
追々、単発の記事でnginxを深掘りしていきたいですが、このアプリにはルート機能だけで十分、、、ク、スミマセンマケオシミデス
では気を取り直して、ルートディレクトリーに nginx
フォルダを作成し、設定ファイルdefault.conf
を作成しましょう!
# NextJS
upstream client {
# ここでいう、clientは後ほど作成するdocker-compose.ymlで定義しているエンドポイントです。
server client:3000;
}
# NestJS
upstream api {
# ここでいう、apiは後ほど作成するdocker-compose.ymlで定義しているエンドポイントです。
server api:5000;
}
server {
listen 80;
# ここで振り分けのルールを定義します
location / {
proxy_pass http://client;
}
location /api {
rewrite /api/(.*) /$1 break;
proxy_pass http://api;
}
}
Dockerの設定
いよいよ終盤です。それぞれのDockerfileを指定の場所に配置しましょう!
client/
│ ├ ...
│ └ Dockerfile.dev
│
nginx/
│ ├ default.conf
│ └ Dockerfile.dev
│
server/
│ ├ ...
│ └ Dockerfile.dev
│
└ docker-compose.yml
プロダクション用のDockerfileではないので、拡張子に .dev
をつけます。AWSやHerokuにデプロイする場合はDockerfile.devとは別に拡張子なしのDockerfileを作成するのが一般的です。
/client/Dockerfile.dev
FROM node:alpine
WORKDIR /app
COPY ./package.json ./
RUN yarn
COPY . .
CMD ["yarn", "dev"]
/nginx/Dockerfile.dev
FROM nginx
COPY default.conf /etc/nginx/conf.d/default.conf
/server/Dockerfile.dev
FROM node:alpine
WORKDIR /app
COPY ./package.json ./
RUN yarn
COPY . .
CMD ["yarn", "start:dev"]
ポイント①: clientとserverにnode:alpineを使用していること。このイメージ内にnodeがインストール済みなので、nodeアプリケーションを扱う場合に利点がある。
ポイント②: clientとserverのDockerfileでCOPYを2回に分けて行っていること。
COPY ./package.json ./
RUN yarn
COPY . .
これは、ホスト内でコードに修正を加えた際に、一々パッケージをインストール(yarn)し直さないようにするためです。開発コストを抑えることができます。
docker-compose.yml
version: "3.9"
services:
postgres:
image: "postgres:latest"
environment:
- POSTGRES_PASSWORD=postgres_password
client:
build:
context: ./client
dockerfile: Dockerfile.dev
volumes:
- /app/node_modules
- ./client:/app
api:
build:
context: ./server
dockerfile: Dockerfile.dev
volumes:
- /app/node_modules
- ./server:/app
environment:
- PGUSER=postgres
- PGHOST=postgres
- PGDATABASE=postgres
- PGPASSWORD=postgres_password
- PGPORT=5432
nginx:
depends_on:
- client
- api
restart: always
build:
context: ./nginx
dockerfile: Dockerfile.dev
ports:
- "3050:80"
ポイント①:clientとapiのコンテナにvolumes値を設定していること。ホストパス上のファイルをDocker上にマウントすることで、修正後のコードを保存時に自動的にアップデートしてくれます。
volumes:
- /app/node_modules
- ホストパス:/app
ポイント②:apiの環境変数にpostgresの設定用に必要な値を予め登録しておくこと。こうすることで、nest.js(サーバーサイド)は環境変数から必要な情報を取得することができます。
api:
build:
context: ./server
dockerfile: Dockerfile.dev
volumes:
- /app/node_modules
- ./server:/app
environment:
- PGUSER=postgres
- PGHOST=postgres
- PGDATABASE=postgres
- PGPASSWORD=postgres_password
- PGPORT=5432
サーバーサイド側だと process.env.PGUSER
という感じで取得することができるようになります。
ポイント③:nginxをclientとapiに関連づけること。
nginx:
depends_on:
- client
- api
restart: always
restart: always
を指定することで、他のコンテナが起動し終わるまで再起動し続けることができます。
いざ、起動してみよう!
ここまで読んでくれた皆さん、ありがとうございます。そして、お疲れ様です!
いよいよ運命の時、、、
実行してみましょう!
docker-compose up --build
# 一回でnginxが起動できない場合があります。その場合は、もう一度以下のコマンドを叩いてください!
docker-compose down && docker-compose up
成功したら映画を登録できるはずです!
終わりに
ここまでお付き合いして下さった皆さん!お疲れ様です!
これで、あなたもフルスタックエンジニアですw
今回このブログを書くことで、この分野についてかなり理解が深まったと実感しています。
このブログ記事がこれからNextJS、NestJS、Typescript、Dockerなどで開発を考えている方にとって少しても助けになれば嬉しいです。
正直、NestJSはまだまだ奥が深いです。GraphQL、MicroService、Prisma等々、今ホットな技術スタックにも対応しています。
そこら辺を単発で深掘りするような記事をこれからかけていけたらと思います。
このハンズオンシリーズですが、次回はKubernetesを使用したバージョンの記事も書いていきます。正直、こっちの方がDockerより実践的だと思うので!
ではまた次の機会でお会いしましょう!