超シンプルなTODOアプリを環境モリモリで構築してみた（Nuxt＋OpenAPI＋NestJS環境をdocker-composeで作る）

最近Nuxt.jsにハマっていて、１つ自分用のベースとなる環境を作っておきたいなと思い、TODOアプリを環境モリモリで作成してみることにしてみました。

# 作ったモノ

GitHubに置いてあります

https://github.com/teracy55/todo-nuxt-nest

### 機能紹介

- TODOアプリの機能としては一覧表示／新規追加／編集／完了⇄未完切り替え／削除だけのシンプルなものになります
- その他、開発用にSwaggerUIやpgadminを使える様にしています

- メイン画面
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/603521f5-c5f9-e31d-57c3-c64b4cc5a83a.png)

- 登録／編集ダイアログ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/4d7bbb58-6160-94d7-326b-7f06dba9879e.png)

- 削除確認
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/871371e5-4d12-09da-08ec-ffa9f495b379.png)

- SwaggerUI（ローカル環境のみ）
  - API仕様確認およびAPIテスト用のつーる 

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/1e815a54-f5c8-0fa7-722e-b1b39df6682a.png)

- Pgadmin（ローカル環境のみ）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/693d23c8-8da1-126f-168a-a8d2654134d8.png)


# 環境

## 共通

### Lernaでモノリポ管理

- ルートプロジェクトをLernaで作成します

```shell
mkdir todo
cd todo
npx lerna init
```

- スクリプトを追加
  - 後述しますが、.envおよびdocker-compose.{dev|prod}.ymlにて環境の切り分けを行います
  - シンプルに切り替えができる様に全てスクリプトに記載しておきます
    - 詳細はREADMEに記載してます 

```package.json
  "scripts": {
    "init:dev": "cp -a .env.local .env && npm install",
    "init:prod": "cp -a .env.prod .env && npm install",
    "postinstall": "lerna bootstrap",
    "build": "lerna run build:prod",
    "dev": "docker-compose --file=docker-compose.dev.yml up",
    "start": "docker-compose --file=docker-compose.prod.yml up"
  },
```

### サーバー⇄フロント間のやりとりはOpenAPIでタイプセーフに行う

#### サーバー側の対応

- NestJS側をOpenAPI対応させます
  - 参考：https://docs.nestjs.com/openapi/introduction


#### フロント側の対応

- ↓の投稿で記載した対応を行います

https://qiita.com/teracy55/items/4c6d475b98deaa23790b


## ローカル用ポイント

### docker-compose.dev.yml

```docker-compose.dev.yml
version: '3'
services:
  server:
    image: node:12-alpine
    container_name: todo-server-dev
    working_dir: /develop
    volumes:
      - ./packages/server:/develop
    depends_on:
      - db
    command: 'npm run start:dev'
    environment:
      - PORT=${SERVER_PORT}
      - NODE_ENV=${NODE_ENV}
      - DATABASE_HOST=db
      - DATABASE_PORT=5432
      - DATABASE_NAME=${DATABASE_NAME}
      - DATABASE_USER=${DATABASE_USER}
      - DATABASE_PASSWORD=${DATABASE_PASSWORD}

  front:
    image: node:12-alpine
    container_name: todo-front-dev
    working_dir: /develop
    volumes:
      - ./packages/front:/develop
    command: 'npm run dev'
    environment:
      - PORT=${FRONT_PORT}

  db:
    image: postgres:12-alpine
    container_name: todo-db-dev
    ports:
      - 5432:5432
    volumes:
      - .db/dev/data:/var/lib/postgresql/data
      - .db/dev/initdb:/docker-entrypoint-initdb.d
    environment:
      - POSTGRES_PASSWORD=${DATABASE_PASSWORD}
      - POSTGRES_USER=${DATABASE_USER}
      - POSTGRES_DB=${DATABASE_NAME}

  db-client:
    image: adminer
    container_name: db-client
    links:
      - db
    ports:
      - 8080:8080

  # フロントもNuxtサーバーでコンテナを立てているため、リバプロサーバーを立てて
  # localhostでフロント／サーバー両方にアクセスできるようにする
  proxy:
    image: nginx
    container_name: todo-proxy
    depends_on:
      - server
      - front
    volumes:
      - ./nginx/dev.conf.template:/etc/nginx/templates/default.conf.template
    ports:
      - 80:80
    environment:
      - SERVER_PORT=${SERVER_PORT}
      - FRONT_PORT=${FRONT_PORT}

  pgadmin:
    image: dpage/pgadmin4
    container_name: pgadmin
    ports:
      - 8000:80
    volumes:
      - ./.db/pgadmin4:/var/lib/pgadmin
    environment:
      - PGADMIN_DEFAULT_EMAIL=admin@example.com
      - PGADMIN_DEFAULT_PASSWORD=admin
    depends_on:
      - db
```

### Nginx

- フロント側に流す設定は[Nuxtのリファレンス](https://ja.nuxtjs.org/docs/2.x/deployment/nginx-proxy/)に倣って記載しています
- nginxにdocker-compose.ymlで指定した環境変数を指定する方法については以下で投稿してますので参考にしてください

https://qiita.com/teracy55/items/45680956a887e83cb05d

```nginx/dev.conf.template
# 参考 https://nuxtjs.org/docs/2.x/deployment/nginx-proxy
map $sent_http_content_type $expires {
    "text/html"                 epoch;
    "text/html; charset=utf-8"  epoch;
    default                     off;
}

server {
    listen 80;
    server_name localhost;

    gzip            on;
    gzip_types      text/plain application/xml text/css application/javascript;
    gzip_min_length 1000;

    location /api {
        proxy_pass http://server:${SERVER_PORT}/api;
        proxy_redirect off;
    }
    location / {
        # 参考 https://nuxtjs.org/docs/2.x/deployment/nginx-proxy
        expires $expires;

        proxy_redirect                      off;
        proxy_set_header Host               $host;
        proxy_set_header X-Real-IP          $remote_addr;
        proxy_set_header X-Forwarded-For    $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto  $scheme;
        proxy_read_timeout          1m;
        proxy_connect_timeout       1m;
        proxy_pass http://front:${FRONT_PORT}/;
    }
}
```

### フロント側の改修

- コンテナの外からアクセスできる様に`host: '0.0.0.0'`を設定
- portは環境変数のPORTを参照する様にして可変にできる様にする（別になくても問題ない）

```packages/front/nuxt.config.ts
  server: {
    host: '0.0.0.0', // デフォルト: localhost
    port: process.env.PORT || 3000,
  },
```

### サーバー側の改修

- ローカル環境の場合のみSwaggerUIを使える様にしておきます

```packages/server/src/main.ts
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';

const env = process.env.NODE_ENV || 'develop';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.setGlobalPrefix('api');
  app.useGlobalPipes(new ValidationPipe({}));
  if (env === 'develop') {
    // OpenAPIの設定
    const config = new DocumentBuilder().setTitle('TODO App API example').setVersion('1.0').build();
    const document = SwaggerModule.createDocument(app, config);
    SwaggerModule.setup('api', app, document);
  }
  await app.listen(process.env.PORT || 3000);
}
bootstrap();
```

- ローカル環境で起動している場合、`http://localhost/api`にアクセスするとSwaggerUIが使えます


## 本番用ポイント

### docker-compose.yml

- serverコンテナのvolumesにfront側のビルドディレクトリを連携する

```docker-compose.prod.yml
version: '3'
services:
  server:
    image: node:12-alpine
    container_name: todo-server
    working_dir: /develop
    volumes:
      # 最低限のディレクトリのみ連携する
      - ./packages/server/package.json:/develop/package.json
      - ./packages/server/node_modules:/develop/node_modules
      - ./packages/server/dist:/develop/dist
      # フロント側のビルドディレクトリは名前がかぶるため、リネームして配置
      - ./packages/front/dist:/develop/public
    links:
      - db
    command: 'npm run start:prod'
    environment:
      - PORT=${SERVER_PORT}
      - NODE_ENV=${NODE_ENV}
      - DATABASE_HOST=db
      - DATABASE_PORT=5432
      - DATABASE_NAME=${DATABASE_NAME}
      - DATABASE_USER=${DATABASE_USER}
      - DATABASE_PASSWORD=${DATABASE_PASSWORD}

  db:
    image: postgres:12-alpine
    container_name: todo-db
    ports:
      - 5432:5432
    volumes:
      - .db/prod/data:/var/lib/postgresql/data
      - .db/prod/initdb:/docker-entrypoint-initdb.d
    environment:
      - POSTGRES_PASSWORD=${DATABASE_PASSWORD}
      - POSTGRES_USER=${DATABASE_USER}
      - POSTGRES_DB=${DATABASE_NAME}

  proxy:
    image: nginx
    container_name: proxy
    depends_on:
      - server
    volumes:
      - ./nginx/prod.conf.template:/etc/nginx/templates/default.conf.template
    ports:
      - 80:80
    environment:
      - SERVER_PORT=${SERVER_PORT}

```

### Nginx

- port:80へのアクセスをサーバー側のポートに流します

```nginx/dev.conf.template
server {
    listen 80;
    server_name localhost;

    gzip            on;
    gzip_types      text/plain application/xml text/css application/javascript;
    gzip_min_length 1000;

    location / {
        proxy_redirect                      off;
        proxy_pass http://server:${SERVER_PORT};
    }
}
```

### NestJS側でフロントのビルドファイルを静的ファイルとして公開できる様にする

[公式リファレンス](https://docs.nestjs.com/recipes/serve-static)を参考に静的ファイル公開対応をします

```shell
npm install --save @nestjs/serve-static
```

- docker-compose.prod.ymlでフロント側のビルドディレクトリをpublicディレクトリに配置しているため、そこにパスを指定する

```packages/server/src/app.module.ts
@Module({
  imports: [
    ServeStaticModule.forRoot({
      rootPath: join(__dirname, '..', 'public'),
    }),
```


# 最後に

実際に開発をする場合はCIの設定だったり他にもやることはありますが、とりあえずこれだけの環境があればある程度のWebアプリ開発で困ることはないかなーと思います。

難点としてはそこそこスペックがないと、ローカル環境が重いですね。
それでもホストOSに色々ソフトをインストールしたりするのに比べれば環境構築が楽なので今後何か開発する際は使って行こうかと思います。