🙉

Nodejsフロントエンドとバックエンドの開発(統合編)

2024/01/30に公開

はじめに

TypeScriptベースで、Vue + QuasarのフロントエンドNodejsのバックエンド構成のSPA型のWebアプリケーションをつくります。
まずはVite + Vue3 + Quasarでフロントエンドを作り、フロントエンドをExpressのバックエンドに組み込んで1ポート配信のWebアプリケーションとして統合します。
開発するときはフロントエンドとバックエンドが分離されているが、製品をリリースするときは一体となる仕様です。

前編「Nodejsフロントエンドとバックエンドの開発(Quasarフロントエンド編)」からの続きです。

今回はバックエンドの解説です。バックエンドに前編のフロントエンドを組み込んでいきます。
主なポイントは以下です。

  • フロントエンド、バックエンドのVSCodeでのデバッグ方法
  • Webアプリケーションの統合方法

統合方法は、バックエンドの開発、フロントエンドの改造、両者の統合の順で解説します。

開発環境

開発環境は以下です。

サンプルプログラム

サンプルはここにあります。前編のフロントエンド開発と同じ簡単なメモアプリです。
変更点は、登録データはWebブラウザ(フロントエンド)ではなく、Webサーバ(バックエンド)側にストックしているところです。
DBは使用していないので特別な環境構築はありません。Nodejsが実行できれば動作します。

https://github.com/czbone/express-frontend-combined

使い方

完成したサンプルのWebアプリケーションを動作させる方法です。

ダウンロードしたGitリポジトリのルートディレクトリをカレントにしてターミナルを開きます。
以下のコマンドで、フロントエンドとバックエンドインストールビルドサーバ実行を行います。
サーバ起動後、Webブラウザからhttp://localhost:3000にアクセスします。
画面上でデータの追加、削除を行うだけのアプリケーションです。
初期データが2レコード入っています。登録データはサーバ起動ごとに初期化されます。

# yarnコマンドをインストールしておきます
npm install -g yarn

# サンプルの実行開始
yarn install:all
yarn build:all
yarn start
...
server started at http://localhost:3000

ディレクトリ構成

サンプルのディレクトリ構成です。
プロジェクト構成は、ルートにバックエンドプロジェクト、サブディレクトリにフロントエンドプロジェクトを含む二重構成になっています。

frontendディレクトリにフロントエンドプロジェクトがあります。frontendディレクトリは前編のフロントエンド開発で使用したサンプルをディレクトリごとそのまま配置、改造したものです。

ルートのバックエンドプロジェクトでは、バックエンド構築の処理にプラスして全体の構築処理も含まれています。
バックエンドのソースはsrcディレクトリにあります。
toolsディレクトリ以下にはビルド処理用のスクリプトファイルがあります。
その他は設定ファイルです。

ディレクトリ構成
.
├── .vscode/
│   ├── launch.json
│   ├── settings.json
│   └── tasks.json
├── dist/  // バックエンドのビルドモジュール
│   ├── controllers/
│   │   ├── index.js
│   │   ├── index.js.map
│   │   ├── note.js
│   │   └── note.js.map
│   ├── routes/
│   │   ├── index.js
│   │   ├── index.js.map
│   │   ├── note.js
│   │   └── note.js.map
│   ├── index.js
│   └── index.js.map
├── frontend/  // ここにフロントエンドプロジェクトを配置
│   ├── dist/  // フロントエンドのビルドモジュール
│   │   ├── assets/
│   │   ├── favicon.ico
│   │   └── index.html
│   └── node_modules/
├── node_modules/
├── src/
│   ├── controllers/
│   │   ├── index.ts
│   │   └── note.ts
│   ├── routes/
│   │   ├── index.ts
│   │   └── note.ts
│   └── index.ts
├── tools/
│   ├── buildFrontend.ts
│   ├── clean.mjs
│   └── copyAssets.ts
├── .env
├── .env.sample
├── .eslintrc
├── .gitignore
├── .prettierrc
├── package.json
└── tsconfig.json

package.json

package.jsonのコマンドの定義を見てみます。(A2)
尚、ここで言うビルドとはTypeScriptファイルをコンパイル(tsc) することを意味しています。
TypeScriptをコンパイル(TSC)するとJavaScriptのスクリプトファイルが生成されます。bundle(複数の.jsファイルを1つにまとめる)されていたりminify(コード圧縮)されたりしますが.jsファイルができます。
また、ビルド実行後の.jsファイルや画像などの関連リソースファイルがまとまって、1つのディレクトリに納まります。このディレクトリをビルドモジュールと呼んでいます。

フロントエンド、バックエンドのプロジェクトでは、それぞれのディレクトリをカレントにして単体実行するための共通のコマンドを定義しています。
以下のコマンドがあります。

  • パッケージインストール(yarn install)
  • デバッグプロセス起動(yarn dev)
  • モジュールをビルド(yarn build)

フロントエンドのデバッグプロセス起動とはViteサーバの起動です。
バックエンドでのデバッグプロセス起動とはnodemonでのデバッグ用サーバの起動です。

先ほどのコマンドyarn install:allでは、フロントエンドとバックエンドで必要なすべてのパッケージをインストールします。
コマンドyarn build:allでは、フロントエンドとバックエンドのビルドを実行します。
ビルドモジュールであるフロントエンドモジュール(frontend/dist)とバックエンドモジュール(dist)が生成され、Webアプリケーション一式になります。
yarn startでWebアプリケーションが起動します。

パッケージを見てみます。
typescript(tsc)でTypeScriptをビルドします。
ts-nodenodemonはバックエンドサーバの実行に使用します。
npm-run-allshelljsはビルド処理用のスクリプトで使用しています。
dotenvexpressmorganmodule-aliasは、バックエンドプログラムで使用しています。
それ以外はコードフォーマッター関係です。

package.json
{
  "name": "express-frontend-combined",
  "version": "0.9.0",
  "description": "TypeScript based web application with Express backend and Vue3, Quasar frontend",
  "author": "czbone",
  "private": true,
  "license": "MIT",
  "main": "dist/index.js",
  "_moduleAliases": {  // [A1]
    "@": "dist"
  },
  "scripts": {  // [A2]
    "dev": "nodemon --watch src -e ts --exec npm run build-start",
    "start": "node .",
    "start-debug": "node --enable-source-maps .",
    "install:all": "yarn install && cd frontend && yarn install",
    "build-start": "npm-run-all clean tsc copy-assets start",
    "build": "npm-run-all clean lint tsc copy-assets",
    "build:frontend": "ts-node tools/buildFrontend",
    "build:all": "npm-run-all build:frontend build",
    "copy-assets": "ts-node tools/copyAssets",
    "tsc": "tsc",
    "clean": "node tools/clean.mjs",
    "lint": "eslint src/**/*.ts"
  },
  "dependencies": {
    "dotenv": "^16.4.0",
    "express": "^4.18.2",
    "module-alias": "^2.2.3",
    "morgan": "^1.10.0"
  },
  "devDependencies": {
    "@types/express": "^4.17.21",
    "@types/morgan": "^1.9.9",
    "@types/node": "^20.11.5",
    "@types/shelljs": "^0.8.15",
    "@typescript-eslint/eslint-plugin": "^6.19.1",
    "@typescript-eslint/parser": "^6.19.1",
    "eslint": "^8.56.0",
    "eslint-config-prettier": "^9.1.0",
    "nodemon": "^3.0.3",
    "npm-run-all": "^4.1.5",
    "prettier": "^3.2.4",
    "shelljs": "^0.8.5",
    "ts-node": "^10.9.2",
    "typescript": "^5.3.3"
  }
}

アプリケーション構成

サンプルの完成時のアプリケーション構成は以下の図になります。

バックエンドは3000ポートでフロントエンドを配信します。
Webブラウザは、フロントエンドからREST API3000ポートのバックエンドに接続し、データにアクセスします。
URLパスの割り当ては、/apiREST API用(APIエンドポイント)で、それ以外はフロントエンドの配信用です。

デバッグ

VSCodeでのデバッグ時のアプリケーション構成は少し複雑です。
フロントエンドの開発にViteサーバを使用しているので、フロントエンドをデバッグする場合はWebブラウザとバックエンドサーバの間に3001ポートを使用するViteサーバが入る形になります。
デバッグのフローを図で示すと以下になります。括弧 () 内はプロジェクトでのディレクトリを示しています。

デバッグは以下の3パターンで実行できます。

  1. フロントエンドをデバッグ。バックエンドモジュールは固定。Viteサーバあり。
  2. バックエンドをデバッグ。フロントエンドモジュールは固定。Viteサーバなし。
  3. フロントエンドとバックエンドを同時にデバッグ。Viteサーバあり。

Viteの起動

Viteサーバの起動が必要な場合はフロントエンドディレクトリ(frontend)でViteを起動しておきます。

cd frontend
yarn dev

ホットリロード機能

フロントエンドでは、Viteサーバの機能によりVSCodeでデバッグ中でもホットリロード機能でソースの更新が即座に反映されます。

バックエンドの場合はホットリロード機能はありません。VSCode上でサーバを再起動します。
コマンドからデバッグ用サーバ(nodemon)を起動している場合はホットリロード機能が効きます。

0. デバッグ準備

あらかじめフロントエンドとバックエンドで必要なパッケージをインストールしておきます。

yarn install:all

次にパターンごとのデバッグの方法を示します。

1. フロントエンドをデバッグ

バックエンドをデバッグ用サーバとして起動し、フロントエンドはVSCodeで起動します。
Viteサーバを起動しておきます。

# バックエンドを起動
yarn dev

# --- 別ターミナルで ---
# フロントエンドを起動
cd frontend
code .
デバッグ開始

VSCodeの実行とデバッグの構成メニューからLaunch Chromeを選択します。
デバッグの開始ボタンでデバッガが起動し、Webブラウザが立ち上がります。

フロントエンドのソースを更新した場合はViteサーバのホットリロード機能により変更が即座に反映されます。

2. バックエンドをデバッグ

フロントエンドをビルドしfrontend/distディレクトリを生成しておきます。バックエンドをVSCodeで起動します。
Viteサーバは起動しません。

# フロントエンドをビルド
cd frontend
yarn build

# --- 別ターミナルで ---
# バックエンドを起動
code .
デバッグ開始

VSCodeの実行とデバッグの構成メニューからLaunch Serverを選択します。
デバッグの開始ボタンでデバッガが起動します。
Webブラウザからhttp://localhost:3000にアクセスします。

バックエンドのソースを変更した場合は、デバッグツールバー上の再起動ボタンでサーバを再起動します。

3. フロントエンド、バックエンドのデバッグを同時実行

フロントエンド、バックエンドのVSCodeプロジェクトを同時に実行して、サーバ-クライアント間を通してオンラインデバッグすることもできます。
Viteサーバを起動しておきます。

# バックエンドを起動
code .
デバッグ開始

実行とデバッグの構成メニューからFull-Stackを選択します。
デバッグの開始ボタンを押すと、フロントエンドとバックエンドのデバッグが開始されます。
デバッグツールバーのメニューからLaunch ChromeまたはLaunch Serverを選択すると、フロントエンド、バックエンドのデバッグの切り替えができます。

VSCodeの構成の設定とタスクの設定です。

.vscode/launch.json
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Launch Server",
      "type": "node",
      "request": "launch",
      "args": [
        "src/index.ts"
      ],
      "runtimeArgs": [
        "-r",
        "ts-node/register"
      ],
      "cwd": "${workspaceRoot}",
      "internalConsoleOptions": "openOnSessionStart",
      "outputCapture": "std",
      "preLaunchTask": "tsc: watch"
    },
    {
      "type": "chrome",
      "request": "launch",
      "name": "Launch Chrome",
      "url": "http://localhost:3001",
      "webRoot": "${workspaceFolder}/frontend"
    }
  ],
  "compounds": [
    {
       "name": "Full-Stack",
       "configurations": ["Launch Server", "Launch Chrome"],
       "stopAll": true
    }
 ]
}
.vscode/tasks.json
{
  "version": "2.0.0",
  "tasks": [
    {
      "type": "typescript",
      "tsconfig": "tsconfig.json",
      "option": "watch",
      "problemMatcher": [
        "$tsc-watch"
      ],
      "group": {
        "kind": "build",
        "isDefault": true
      },
      "label": "tsc: watch"
    }
  ]
}

ソースの解説

アプリケーション仕様

サンプルのWebアプリケーションは登録したテキストを一覧表示するだけの簡易なデータ処理をするものです。
前編はフロントエンドのStoreでデータを保持しましたが、今回はバックエンドサーバ内でデータを保持し、APIエンドポイントを通してフロントエンドとデータをやり取りする仕様に変わります。
バックエンドでは、URLパスに対してルータ⇒コントローラが起動されます。

1. バックエンドの開発

バックエンドプロジェクトのポイントを解説します。

デバッグ、ビルド構成

バックエンドではVSCodeでTypeScriptをデバッグするためにts-nodeモジュールを使用します。
ビルドはtscでビルドします。
ts-nodetscは共に設定ファイルtsconfig.jsonを参照しています。

設定を見てみます。

パスエイリアス

ソースコード上での相対パスを簡略化するためにパスエイリアスの設定をします。
パスエイリアスを設定するとパスの記述をこのように変更できます。

import { NoteController } from '../controllers'import { NoteController } from '@/controllers'

package.jsonmodule-aliasモジュールを追加し、module-aliasの設定を追記します。(A1)
ソースコードindex.tsにもコードを加えます。(B1)
ビルド処理を通すために、パスエイリアスの設定をtsconfig.jsonに追加します。(C1)

index.ts
import dotenv from 'dotenv'
import express from 'express'
import 'module-alias/register'  // [B1]
import morgan from 'morgan'
import path from 'path'
import router from './routes'

// 環境変数読み込み(.envファイル)
dotenv.config()

const port = process.env.SERVER_PORT
const app = express()

// デバッグ時ログ出力  // [B2]
if (process.env.NODE_ENV !== 'production') app.use(morgan('dev'))

app.use(express.json())

// フロントエンドをルーティング  // [B3]
app.use(express.static(path.resolve(__dirname, '../frontend/dist')))
app.use((req, res, next) => {
  if (req.originalUrl.startsWith('/api')) {  // 「/api」は除く
    next()
  } else {
    res.sendFile(path.resolve(__dirname, '../frontend/dist/index.html'))
  }
})

// APIをルーティング
app.use('/api', router)

// サーバ起動
app.listen(port, () => {
  console.log(`server started at http://localhost:${port}`)
})
tsconfig.json
{
  "compilerOptions": {
    "target": "esnext",
    "useDefineForClassFields": true,
    "module": "esnext",
    "moduleResolution": "node",
    "strict": true,
    "jsx": "preserve",
    "sourceMap": true,
    "resolveJsonModule": true,
    "esModuleInterop": true,
    "lib": [
      "esnext",
      "dom"
    ],
    "types": ["vite/client"],
    "skipLibCheck": true,
    "allowJs": true,
    "paths": {  // [C1]
      "@/*": ["./src/*"]
    }
  },
  "include": [
    "src/**/*.ts",
    "src/**/*.d.ts",
    "src/**/*.tsx",
    "src/**/*.vue"
  ]
}

ログ出力

VSCodeからの起動やデバッグ用サーバ(yarn dev)で起動すると、morganモジュールによりアクセスログがコンソールに出力されます。(B2)

アクセスログ例
server started at http://localhost:3000
GET / 304 6.165 ms - -
GET /api/note 200 3.321 ms - 81
DELETE /api/note/0001 200 0.946 ms - -
GET /api/note 200 0.664 ms - 41
POST /api/note 200 14.135 ms - -
GET /api/note 200 0.765 ms - 76
DELETE /api/note/hdt5454i 200 0.272 ms - -
GET /api/note 200 0.569 ms - 41

次にAPIの処理を見てみます。

ルーティング処理

ルーティング処理では、クラス化したコントローラのメソッドを単純にマッピングしています。
コントローラでは、メンバ変数notesを使用してメモデータを管理しています。

routes/note.ts
import { NoteController } from '@/controllers'
import { Router } from 'express'

const router = Router()

router.get('/', NoteController.list) // 一覧取得
router.post('/', NoteController.create) // 登録
router.delete('/:id', NoteController.delete) // 削除

export default router
controllers/note.ts
import { NextFunction, Request, RequestHandler, Response } from 'express'

type Note = {
  id: string
  message: string
}

class NoteController {
  notes: Array<Note>
  constructor() {
    this.notes = [
      { id: '0001', message: 'サンプル1' },
      { id: '0002', message: 'サンプル2' }
    ]
  }
  list: RequestHandler = (req: Request, res: Response, _next: NextFunction) => {
    res.json(this.notes)
  }
  create: RequestHandler = (req: Request, res: Response, _next: NextFunction) => {
    const { message } = req.body as Note

    let randomId: string
    for (;;) {
      randomId = Math.random().toString(36).slice(-8) // 文字列長8
      const index = this.notes.findIndex((note) => note.id === randomId)
      if (index === -1) break
    }

    const note: Note = {
      id: randomId,
      message: message
    }
    this.notes.push(note)

    res.json()
  }
  delete: RequestHandler = (req: Request, res: Response, _next: NextFunction) => {
    const noteId = req.params.id
    const index = this.notes.findIndex((note) => note.id === noteId)
    if (index === -1) {
      res.status(400).json({ message: 'IDが不正です' })
      return
    }

    this.notes.splice(index, 1)
    res.json()
  }
}
export default new NoteController()

コントローラ

ルータからコントローラの呼び出し部分はTypeScriptの型エラーが出るので、コントローラではアロー関数で記述する必要があります。
以下の部分です。

list: RequestHandler = (req: Request, res: Response, _next: NextFunction) => {

2. フロントエンドの改造

frontendディレクトリにあるフロントエンドプロジェクトのポイントを解説します。

前編のサンプルからの主な改造点を示します。
ソースコードはfrontend/srcディレクトリ以下です。

Viteの設定

フロントエンドをデバッグするためにViteサーバを使用しますが、ViteサーバにはAPIエンドポイント/apiへのアクセスをサーバに転送する設定を追加します。
フロントエンドプロジェクトのViteの設定ファイル(vite.config.ts)に設定を追加します。(D1D2)

.env
VITE_APP_TITLE="プロジェクトタイトル"
VITE_BACKEND_SERVER_URL=http://localhost:3000
vite.config.ts
import path from 'node:path'
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import { quasar, transformAssetUrls } from '@quasar/vite-plugin'

export default ({ mode }) => {
  // 環境変数(VITE_XXXXX)をvite.config.tsに読み込む
  const env = loadEnv(mode, process.cwd())  // [D1]

  return defineConfig({
    plugins: [
      vue({
        template: { transformAssetUrls }
      }),
      quasar({
        autoImportComponentCase: 'pascal',
        sassVariables: 'src/styles/quasar-variables.sass'
      })
    ],
    resolve: {
      alias: {
        '@': path.resolve(__dirname, 'src')
      }
    },
    server: {
      port: 3001,
      proxy: {  // [D2]
        '/api': {
          target: env.VITE_BACKEND_SERVER_URL
        }
      }
    },
    preview: {
      port: 3001
    }
  })
}

ソースの改造

バックエンドのAPIエンドポイントにアクセスするようにフロントエンドのソースを改造します。
StoreからAxiosでバックエンドに接続します。アクセスするURLはconfig.tsファイルに定義しています。

config.ts
export default {
  api_note: '/api/note'
}
stores/note.ts
import axios from 'axios'
import { defineStore } from 'pinia'
import config from '@/config'

type Note = {
  id: string
  message: string
}

export const useNoteStore = defineStore('note', {
  state: () => {
    return {
      notes: [] as Note[]
    }
  },
  actions: {
    async addNote(message: string) {
      try {
        await axios.post(config.api_note, {
          message: message
        })

        // 一覧再取得
        this.getAll()
      } catch (err) {
        if (axios.isAxiosError(err)) {
          console.log(err)
        }
      }
    },
    async removeNote(id: string) {
      try {
        await axios.delete(config.api_note + `/${id}`)

        // 一覧再取得
        this.getAll()
      } catch (err) {
        if (axios.isAxiosError(err)) {
          console.log(err)
        }
      }
    },
    async getAll() {
      try {
        const response = await axios.get(config.api_note)
        this.notes = response.data
      } catch (err) {
        if (axios.isAxiosError(err)) {
          console.log(err)
        }
      }
    }
  }
})

3. フロントエンドとバックエンドを統合

再びバックエンドプロジェクトに戻ります。
フロントエンドをバックエンドの一部として統合するには、バックエンド側のソースコードに処理を追加します。

バックエンドのindex.tsB3の場所に8行追加し、フロントエンドを統合します。
このコードの追加により、バックエンドサーバのWebルートフロントエンドモジュール(frontend/dist)にマッピングし、バックエンド側はAPIエンドポイント(/api)へのアクセスのみ受け付けるようになります。

おわりに

ここで使用したサンプルは環境を簡単にするためDBの処理は除きました。
今回は、あくまでフロントエンド、バックエンドのプロジェクトをどう一体化するかという点にしぼりました。
DB機能を追加すればよりWebアプリケーションらしくなります。それほど難しくないと思います。
(追記:Nodejsフロントエンドとバックエンドの開発(DB編)を追加しました。)

frontendディレクトリにあるフロントエンドはVue + Quasarでできていますが、ここはWebブラウザ上で動くものであれば何にでも置き換え可能です。ビルドしてfrontend/distになっていればバックエンドに統合できます。
フロントエンドはフロントエンドフレームワークやCSSフレームワークを自由に組み合わせて作ることができます。内部処理としてAxios等でバックエンドのAPIとデータのやり取りを行います。
別のフロントエンドのサンプルとして、前編の最後にリンクを載せています。Vue + Vuetify等のサンプルがあります。

フロントエンド、バックエンドとモジュールを分けて開発する方法に対して、Next.jsNuxt.jsなどのようにフロントエンドバックエンド一体型で開発する方法があります。
一体型開発はフロントエンドとバックエンドの仕組みをあまり考えなくてもWebアプリケーションが作れるという利点がありますが、例えばレンダリングがフロントエンド、バックエンドのどちらで行われるのか分かりにくいなど、処理フローのどこでどんな処理が行われるかをよく理解していないと、思わぬところにセキュリティホールを作ってしまったりします。
また、一体化ゆえに、フロントエンドを大きく変えたい場合は機能分離になかなか苦労します。

フロントエンド、バックエンドが別モジュール(別プロジェクト)の分離型にすると、それぞれのモジュールのビルドの設定が異なることに気づきます。フロントエンド、バックエンドでは実行される場所も目的も違うため、ビルドの設定が異なるのが当然ですが、一体型開発ではなかなか意識できません。ESLint等コーディングルールの設定も同様です。
実感として、これらを同じベースで一体化して開発することは少々無理があるように思います。
フロントエンド、バックエンドではミドルウェアの進化速度が大きく異なっています。
フロントエンドは日進月歩で進化していますが、それにくらべてバックエンドはゆっくりです。
長期に渡ってリリースする製品を考えた場合、急激に進化するフロントエンドに対応していくには、ときにフロントエンドフレームワークごと入れ替え可能な分離型がやはり有利です。

別モジュールでの開発に加えて、フロントエンド、バックエンドの統合を行いました。
フロントエンド、バックエンドが1ポート配信のWebアプリケーションになると、サーバへの配置が単純になり、CORSの問題も考える必要がなくなるなど、アプリケーションの管理が楽になります。
単純になれば起こりうる問題も少なくなります。

以上、Nodejsのフロントエンド、バックエンド分離型の開発について解説しました。
次回やるとすれば、このサンプルが動作する運用環境の構築をやってみたいと思います。

Discussion