9
15

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 3 years have passed since last update.

NextJS・NestJS・PostgreSQL・Dockerでフルスタックアプリを開発してみよう!ハンズオン②(フロントエンド・Docker編)

Posted at

はじめに

お待たせしました! 前回のハンズオン記事の第2弾です。

今回は、フロントエンド(NextJS)、Nginx サーバーとDocker周りの設定に着手します。

これからこの技術スタックを試したい方のためにテンプレート的なコードになればいいと思いこの記事を書きました。

では、早速やっていきましょう!

前提条件

  • Dockerがインストールされていること。(Dockerhubをインストールした方が楽)
  • Nodeがインストールされていること(使用するパッケージの都合上、v12.0.0を推奨します。nvmをインストールしておくとバージョンの切り替えが便利です。)
  • ハンズオンをしたい方は、サーバーサイド編を読み終えていること

対象読者

  • Docker、React、NodeJSなど単独で勉強してきたが、全てを網羅的に学習したい方
  • とりあえず、フルスタックのアプリをTypescriptだけで開発してみたい方
  • NextJS・NestJS・Dockerは聞いたことあるけれど、使ったことがない方・使ってみたい方

完成したコードをみたい方はこちらをクリック

本編

前回は、サーバーサイド(NestJS)周りを中心に実装しました。NestJSというフレームワークの特殊な記法・アーキテクチャーを用いて、データベースへの接続コードやHTTPリクエスト(GETやPOST)に対応したコードを書いていきました。

今回はNextJSを使ってフロントエンドから開発していきましょう!

その前に、前回のおさらいとして、アプリの構成図を載せておきます!

アプリ構成

Architecture.png

  • ポイント①:nginxサーバーをブラウザとシステムの間に挟むことで、プロキシサーバー・ルーターとしての役割を与えたこと。リクエストのパラメーターに「/」だけ指定してある場合はNextJS、パラメータに「/api」が指定されている場合はNestJSに振り分けるように設定します。
  • ポイント②:Dockerコンテナ内でアプリを起動すること。こうすることで、環境依存しない開発が可能になります。

NextJSでフロントエンドを実装しよう!

プロジェクトの作成

まずは、フロントエンド! 以下のコマンドを叩いてプロジェクトを作成しましょう!

NextJSのドキュメンテーションはこちら

# npm派はこちら
npx create-next-app
# yarn派はこちら
yarn create next-app

すると、以下のようにプロジェクトが作成されます!

スクリーンショット 2021-01-05 17.26.34.png

Typescriptの設定ファイルを作成

ただ、今回はTypescriptを使っていきたいのでtsconfig.json設定ファイルをルートディレクトリーに作成していきましょう!

touch tsconfig.json

# yarn run devで試しに実行してみると、以下のdependenciesをインストールする
ように怒られるのでしておく

yarn add --dev typescript @types/react @types/node

tsconfig.jsonファイルも以下のように修正

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に設定しておきましょう。

ついでにESLINTPrettierの設定もしておく

開発コードは予め可読性を高くしておきたいです、、、ウムウム

個人の好みはあると思いますが、最低限の設定はしておきましょう!

私はVSCodeをエディタとして使用しているので、ESLintとPrettierのプラグインを入れておきます。

.prettierrc.js
module.exports = {
  semi: true,
  trailingComma: 'es5',
  singleQuote: true,
  printWidth: 100,
  tabWidth: 2,
  useTabs: false,
}
.eslintrc.js
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の修正だけで十分です。

_app.tsx
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;

index.tsx
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サーバーが識別できるようにするために必要なコードになります!

次に、型安全としてインタフェースも定義しておきましょう。基本的に、サーバーサイドのエンティティクラスに合わせておきます。

movielist.ts
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を作成しましょう!

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

成功したら映画を登録できるはずです!

ezgif.com-video-to-gif.gif

終わりに

ここまでお付き合いして下さった皆さん!お疲れ様です!
これで、あなたもフルスタックエンジニアですw

今回このブログを書くことで、この分野についてかなり理解が深まったと実感しています。

このブログ記事がこれからNextJS、NestJS、Typescript、Dockerなどで開発を考えている方にとって少しても助けになれば嬉しいです。

正直、NestJSはまだまだ奥が深いです。GraphQL、MicroService、Prisma等々、今ホットな技術スタックにも対応しています。

そこら辺を単発で深掘りするような記事をこれからかけていけたらと思います。

このハンズオンシリーズですが、次回はKubernetesを使用したバージョンの記事も書いていきます。正直、こっちの方がDockerより実践的だと思うので!

ではまた次の機会でお会いしましょう!

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?