Angular、Node.js、MySQL によるフルスタック開発: ゼロからプロレベルまでの 100 の例

Angular、Node.js、MySQL によるフルスタック開発: ゼロからプロレベルまでの 100 の例

フルスタックのアプリケーションには、互いに整合していなければならない 3 つの部分があります。利用者がブラウザーで使う Angular のフロントエンド、ルールを適用する Node.js の API、そしてデータを安全に保つ MySQL のデータベースです。この記事では、空のフォルダーから本番を意識したリリースまで、小さなショップを 100 の例で作り上げます。プロジェクトの設定、REST API を作るための Node.js と Express の基礎、MySQL のテーブル設計と安全なクエリ、トランザクションを使った API とデータベースの接続、入力の検証、パスワードのハッシュと JWT によるログイン、Angular のコンポーネント、サービス、ルート、フォーム、インターセプター、プロキシと環境ファイルによる接続、各層のテスト、Docker によるパッケージ化、そして本番への準備を扱います。正直に伝えると、この記事を準備したコンピューターには MySQL も Angular CLI もインストールされていなかったため、100 の例はここでは一つも実行していません。コードは現在の Node.js、Express、mysql2、Angular、MySQL の構文に従っていますが、信頼する前にご自身で各部分を実行してください。

必要なもの

npm を含む Node.js 20 以降、MySQL 8.4(コンテナーで動かすなら Docker)、Angular CLI、Visual Studio Code などのエディター。JavaScript と HTML の基本知識があると役立ちます。<strong-password> のようなプレースホルダーのパスワードと、shop.example.com のようなプレースホルダーのドメインを使い、本物の .env ファイルをコミットしたり、本物の認証情報をコードやコマンドに貼り付けたりしないでください。

例の使い方

bash の例は npm、Angular CLI、Docker を使います。JavaScript と TypeScript の例は、本文で指定された Node.js と Angular のプロジェクトファイルに書きます。SQL の例は MySQL 8.4 で実行し、HTML の例は Angular のテンプレートに書きます。

トピック 1: プロジェクトの設定

きれいな構造から始めましょう。API 用、Angular アプリ用、データベースのスクリプト用に、それぞれフォルダーを 1 つずつ用意します。良い構造にすると、各部分をテストし、デプロイし、初めての人に説明するのが容易になります。

3 つの部分を 1 つのリポジトリに

API、Web アプリ、データベースのスクリプトを 1 つのリポジトリにまとめると、1 つの変更で 3 つすべてを同時に更新できます。各部分には独自のパッケージファイルと独自のコマンドがあります。

ツールとバージョン

まず Node.js、npm、MySQL を確認します。組み込みのテストランナーと最新の fetch を含むため、Node.js 20 以降を推奨します。Angular CLI はコードの生成と開発サーバーの起動を行います。

秘密情報は Git に入れない

パスワードと署名キーは .gitignore に記載した .env ファイルに置きます。プレースホルダーの値を入れた例のファイルをコミットして、他の人が必要な設定を分かるようにします。

図解

アプリケーションの各部分の連携: ブラウザー内の Angular アプリが /api を通じて Node.js の API を呼び、API はトークンを確認して MySQL に問い合わせ、結果が画面に戻ります
アプリケーションの各部分の連携: ブラウザー内の Angular アプリが /api を通じて Node.js の API を呼び、API はトークンを確認して MySQL に問い合わせ、結果が画面に戻ります

例 1: ツールを確認

Node.js、npm、MySQL がインストールされているか確かめます。

node --version
npm --version
mysql --version

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 2: プロジェクトのフォルダーを作成

API、Web アプリ、データベースを分けます。

mkdir shop-fullstack && cd shop-fullstack
mkdir api web db
git init

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 3: Node.js API を設定

Express、MySQL ドライバー、dotenv を入れます。

cd api
npm init -y
npm install express mysql2 dotenv
npm install --save-dev nodemon

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 4: Angular CLI をインストール

ルーティング付きの Angular フロントエンドを作ります。

npm install -g @angular/cli
cd ..
ng new web --routing --style=css --skip-git

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 5: プロジェクトの構成

3 つの部分を分かりやすいフォルダーに置きます。

shop-fullstack/
  api/        Node.js + Express + MySQL
  web/        Angular front end
  db/         SQL schema and seed data
  docker-compose.yml

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 6: 秘密情報を Git に入れない

.env を無視し、代わりに例のファイルをコミットします。

cat > .gitignore <<'EOF'
node_modules/
.env
dist/
EOF
cat > api/.env.example <<'EOF'
DB_HOST=localhost
DB_USER=shop
DB_PASSWORD=<strong-password>
DB_NAME=shop
PORT=3000
EOF

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

トピック 2: Node.js の基礎

Node.js はブラウザーの外で JavaScript を動かします。モジュール、非同期コード、環境変数、そしてすべてのサーバーファイルで使う少数の考え方を学びます。

モジュールと非同期コード

各ファイルは、他のファイルが必要とするものを export するモジュールです。サーバーの処理の多くは非同期なので、Promise を async と await で使い、try と catch でエラーを処理して、1 つの失敗でプロセスが落ちないようにします。

環境からの設定

ポートやデータベースのアドレスなどの設定は process.env から読みます。dotenv パッケージは開発時に .env ファイルを読み込みます。本番のサーバーでは、通常はプラットフォームから変数を受け取ります。

スクリプトとイベントループ

npm スクリプトは、チームの全員に同じコマンドを提供します。遅い同期処理が 1 つあるとすべてのリクエストが止まるので、重い処理はリクエストハンドラーの外に出してください。

例 7: Node.js でファイルを読む

同期的な読み込みとそのトレードオフ。

const fs = require("node:fs");

const text = fs.readFileSync("notes.txt", "utf8");
console.log(text.split("\n").length, "lines");

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 8: 非同期でファイルを読む

async と await で Promise を使います。

const fs = require("node:fs/promises");

async function main() {
  const text = await fs.readFile("notes.txt", "utf8");
  console.log(text.trim());
}
main().catch(console.error);

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 9: Node.js のモジュール

関数を export して、別のファイルで import します。

// math.js
exports.add = (a, b) => a + b;

// main.js
const { add } = require("./math");
console.log(add(2, 3));

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 10: 環境変数

dotenv で .env から設定を読みます。

require("dotenv").config();

const port = Number(process.env.PORT || 3000);
console.log("port", port);

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 11: 配列とフィルター

API と同じ方法でデータを絞り込み、変換します。

const orders = [
  { id: 1, total: 120 },
  { id: 2, total: 80 },
  { id: 3, total: 200 },
];
const big = orders.filter((o) => o.total >= 100).map((o) => o.id);
console.log(big); // [1, 3]

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 12: Promise と await

サーバーを止めずに処理を待ちます。

const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function run() {
  console.log("start");
  await wait(500);
  console.log("done after 500 ms");
}
run();

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 13: try と catch でエラー処理

失敗を扱い、安全な既定値を返します。

async function readConfig(path) {
  try {
    const fs = require("node:fs/promises");
    return JSON.parse(await fs.readFile(path, "utf8"));
  } catch (err) {
    console.error("could not read config:", err.message);
    return {};
  }
}

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 14: npm スクリプト

1 つのコマンドで API を起動、開発、テストします。

{
  "name": "api",
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js",
    "test": "node --test"
  }
}

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

トピック 3: Express による REST API

Express は HTTP メソッドとパスを関数に対応させます。REST API では、読み取りに GET、作成に POST、変更に PUT または PATCH、削除に DELETE を使い、フロントエンドが表示できる JSON を返します。

ルート、パラメーター、ボディ

app.get、app.post などは、メソッドとパスに一致します。URL のパラメーターは req.params から、クエリ文字列は req.query から、JSON のボディは app.use(express.json()) の後で req.body から読みます。

ルーターとミドルウェア

ルーターは 1 つのリソースのルートを 1 つのファイルにまとめます。ミドルウェアはすべてのリクエストに対して順番に動くため、ログ記録、認証、エラー処理を置くのに適しています。

意味のあるステータスコード

読み取りには 200、作成されたリソースには 201、本文のない削除には 204、不正な入力には 400、未ログインには 401、権限がない場合には 403、項目が存在しない場合には 404 を返します。

例 15: 最初の Express サーバー

数行でリクエストに応答します。

const express = require("express");
const app = express();

app.get("/", (req, res) => {
  res.send("Shop API is running");
});

app.listen(3000, () => console.log("listening on http://localhost:3000"));

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 16: JSON を返すルート

フロントエンド向けに構造化されたデータを返します。

app.get("/api/health", (req, res) => {
  res.json({ status: "ok", time: new Date().toISOString() });
});

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 17: ルートパラメーターとクエリ文字列

URL から値を読みます。

app.get("/api/products/:id", (req, res) => {
  const id = Number(req.params.id);
  const detail = req.query.detail === "1";
  res.json({ id, detail });
});

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 18: JSON のボディを解析

フォームや Angular からのデータを受け取ります。

app.use(express.json());

app.post("/api/products", (req, res) => {
  const { name, price } = req.body;
  res.status(201).json({ name, price });
});

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 19: ルーターでルートを整理

機能ごとに 1 つのファイルにします。

// routes/products.js
const { Router } = require("express");
const router = Router();

router.get("/", (req, res) => res.json([]));
router.get("/:id", (req, res) => res.json({ id: req.params.id }));

module.exports = router;

// server.js
app.use("/api/products", require("./routes/products"));

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 20: リクエストを記録するミドルウェア

メソッド、パス、ステータス、時間を記録します。

app.use((req, res, next) => {
  const started = Date.now();
  res.on("finish", () => {
    console.log(req.method, req.originalUrl, res.statusCode, Date.now() - started, "ms");
  });
  next();
});

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 21: 中央のエラーハンドラー

すべてのルートから同じ形式のエラーを返します。

app.use((err, req, res, next) => {
  console.error(err);
  res.status(err.status || 500).json({ error: "Something went wrong" });
});

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 22: 未知のルートに 404

存在しない URL にはっきり答えます。

app.use((req, res) => {
  res.status(404).json({ error: "Not found" });
});

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

トピック 4: MySQL の基礎と SQL

MySQL は、型のある列、キー、リレーションを持つテーブルにデータを保存します。SQL は行を作成、読み取り、変更、削除します。良い設計は、コードが動く前に多くのバグを防ぎます。

データベース、ユーザー、最小権限

ショップ用のデータベースと、そのデータベースだけを読み書きできるユーザーを作ります。アプリを root アカウントで接続させてはいけません。パスワードが漏れるとサーバー全体が露出するからです。

テーブル、キー、型

すべてのテーブルに主キーを持たせ、データに合う型を選びます。たとえば金額には DECIMAL を使います。外部キーを使えば、存在しない商品を参照する注文は作れません。メールアドレスのように重複してはいけない値には UNIQUE を使います。

行の読み取りと変更

WHERE、ORDER BY、LIMIT を使った SELECT は、必要な行だけを返します。UPDATE と DELETE には必ず WHERE 句を付け、先に SELECT で確かめてください。条件が欠けると、すべての行が変わってしまいます。

例 23: データベースとユーザーを作成

アプリに必要な権限だけを与えます。

CREATE DATABASE IF NOT EXISTS shop CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS 'shop'@'localhost' IDENTIFIED BY '<strong-password>';
GRANT SELECT, INSERT, UPDATE, DELETE ON shop.* TO 'shop'@'localhost';

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 24: テーブルを作成

列、キー、既定値を定義します。

USE shop;
CREATE TABLE products (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(120) NOT NULL,
  price DECIMAL(10,2) NOT NULL,
  stock INT NOT NULL DEFAULT 0,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 25: 行を追加

1 つの文で複数の行を追加します。

INSERT INTO products (name, price, stock)
VALUES ('Keyboard', 790.00, 12),
       ('Mouse', 350.00, 30),
       ('Monitor', 5490.00, 4);

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 26: フィルターと並べ替えで SELECT

必要な行だけを順番に読みます。

SELECT id, name, price
FROM products
WHERE stock > 0
ORDER BY price DESC;

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 27: 行を更新

WHERE 句で値を変更します。

UPDATE products
SET price = 690.00
WHERE name = 'Keyboard';

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 28: 安全に行を削除

削除は必ず意図した行に限定します。

DELETE FROM products
WHERE id = 3
LIMIT 1;

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 29: JOIN と集計

GROUP BY で商品ごとの売上数を数えます。

CREATE TABLE orders (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  product_id INT UNSIGNED NOT NULL,
  quantity INT NOT NULL,
  ordered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (product_id) REFERENCES products(id)
);
SELECT p.name, SUM(o.quantity) AS sold
FROM orders o
JOIN products p ON p.id = o.product_id
GROUP BY p.name
ORDER BY sold DESC;

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 30: 検索する列にインデックス

検索を速くし、EXPLAIN で実行計画を確認します。

CREATE INDEX idx_products_name ON products (name);
EXPLAIN SELECT * FROM products WHERE name = 'Mouse';

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

トピック 5: Node.js からのデータアクセス

API は接続プールを通じて MySQL と通信します。プレースホルダー付きのクエリは利用者の入力を SQL から分離し、トランザクションは関連する変更をまとめ、ページングは応答を小さく保ちます。

接続プール

プールは開いた接続の集合を保持し、各リクエストに 1 つ渡して、終わったら戻します。プールは起動時に 1 回作成して再利用します。リクエストごとに新しい接続を開くのはやめましょう。

プレースホルダーによる SQL インジェクション対策

利用者の入力を SQL の文字列に連結してはいけません。クエリに ? を書き、値を配列で渡します。ドライバーが値をエスケープします。この一つの習慣で、Web で最も多いセキュリティ上の失敗の一つを防げます。

トランザクションとページング

トランザクションは複数の文をまとめ、すべて成功するか、すべて失敗するかのどちらかにします。注文が在庫を減らし、注文の行を作る場合に重要です。LIMIT と OFFSET を使うか、より良いのは keyset を使って、1 回に 1 ページずつ返します。

例 31: 接続プール

リクエストごとに開かず、接続を再利用します。

const mysql = require("mysql2/promise");

const pool = mysql.createPool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  waitForConnections: true,
  connectionLimit: 10,
});
module.exports = pool;

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 32: Node.js からクエリ

ルートから SQL を送り、行を返します。

const pool = require("./db");

app.get("/api/products", async (req, res, next) => {
  try {
    const [rows] = await pool.query("SELECT id, name, price, stock FROM products ORDER BY name");
    res.json(rows);
  } catch (err) {
    next(err);
  }
});

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 33: SQL インジェクションを防ぐプレースホルダー

ユーザー入力を SQL 文字列に連結しません。

// Unsafe: never build SQL by joining user input
// const [rows] = await pool.query("SELECT * FROM products WHERE name = '" + name + "'");

// Safe: the driver escapes the value
const [rows] = await pool.query("SELECT * FROM products WHERE name = ?", [name]);

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 34: 行を追加して新しい id を返す

行を作成し、その id をクライアントに返します。

app.post("/api/products", async (req, res, next) => {
  try {
    const { name, price, stock = 0 } = req.body;
    const [result] = await pool.execute(
      "INSERT INTO products (name, price, stock) VALUES (?, ?, ?)",
      [name, price, stock]
    );
    res.status(201).json({ id: result.insertId, name, price, stock });
  } catch (err) {
    next(err);
  }
});

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 35: 確認付きの更新

一致する行がなければ 404 を返します。

app.put("/api/products/:id", async (req, res, next) => {
  try {
    const { price, stock } = req.body;
    const [result] = await pool.execute(
      "UPDATE products SET price = ?, stock = ? WHERE id = ?",
      [price, stock, req.params.id]
    );
    if (result.affectedRows === 0) return res.status(404).json({ error: "Not found" });
    res.json({ updated: true });
  } catch (err) {
    next(err);
  }
});

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 36: 確認付きの削除

結果に応じて 204 か 404 を返します。

app.delete("/api/products/:id", async (req, res, next) => {
  try {
    const [result] = await pool.execute("DELETE FROM products WHERE id = ?", [req.params.id]);
    res.status(result.affectedRows ? 204 : 404).end();
  } catch (err) {
    next(err);
  }
});

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 37: トランザクション

在庫の確保と注文の作成を、まとめて成功させるか全く行わないか。

async function placeOrder(productId, quantity) {
  const conn = await pool.getConnection();
  try {
    await conn.beginTransaction();
    const [stock] = await conn.execute("SELECT stock FROM products WHERE id = ? FOR UPDATE", [productId]);
    if (stock[0].stock < quantity) throw new Error("not enough stock");
    await conn.execute("UPDATE products SET stock = stock - ? WHERE id = ?", [quantity, productId]);
    await conn.execute("INSERT INTO orders (product_id, quantity) VALUES (?, ?)", [productId, quantity]);
    await conn.commit();
  } catch (err) {
    await conn.rollback();
    throw err;
  } finally {
    conn.release();
  }
}

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 38: ページング

テーブル全体ではなく、ページ単位で結果を返します。

app.get("/api/products", async (req, res, next) => {
  const page = Math.max(1, Number(req.query.page) || 1);
  const size = Math.min(50, Number(req.query.size) || 20);
  try {
    const [rows] = await pool.execute(
      "SELECT id, name, price FROM products ORDER BY id LIMIT ? OFFSET ?",
      [size, (page - 1) * size]
    );
    res.json({ page, size, items: rows });
  } catch (err) {
    next(err);
  }
});

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

トピック 6: 入力の検証とエラー処理

API はブラウザーを信用してはいけません。すべての入力を確認し、間違いには分かりやすいメッセージを返し、データベースのエラーは内部の詳細を漏らさずに、フロントエンドが表示できる応答に変換します。

サーバーで検証する

Angular での検査は操作性を高めますが、誰でもリクエストを直接送れるため、サーバーでも必ず再検査します。スキーマライブラリは項目を一度定義し、問題をすべて 1 つの応答にまとめて報告します。

エラー形式を 1 つに

すべての失敗で同じ JSON の形を返す、中央のエラーハンドラーを使います。重複キーのような既知のデータベースエラーは 409 に対応させ、完全なエラーはサーバー側に記録しますが、クライアントには安全なメッセージだけを送ります。

async ルートのエラー

Express 4 は、ルートハンドラーから拒否された Promise を自動では捕まえません。非同期ルートをラップしてエラーがエラーハンドラーに届くようにするか、自動で処理してくれる Express 5 を使います。

例 39: 入力を検証

項目をデータベースに渡す前に確かめます。

function validateProduct(body) {
  const errors = [];
  if (typeof body.name !== "string" || body.name.trim().length < 2) errors.push("name must be at least 2 characters");
  if (typeof body.price !== "number" || body.price < 0) errors.push("price must be a number >= 0");
  return errors;
}

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 40: 不正な入力に 400 を返す

検証エラーをひとつずつクライアントに説明します。

app.post("/api/products", async (req, res, next) => {
  const errors = validateProduct(req.body);
  if (errors.length) return res.status(400).json({ errors });
  // ...insert as in example 34
});

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 41: スキーマで検証

データの定義を一度書き、ルールを再利用します。

const { z } = require("zod");

const productSchema = z.object({
  name: z.string().min(2).max(120),
  price: z.number().nonnegative(),
  stock: z.number().int().nonnegative().default(0),
});

const parsed = productSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ errors: parsed.error.issues });

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 42: データベースのエラーを応答に変換

重複や参照先の欠落を分かりやすいコードに対応させます。

app.use((err, req, res, next) => {
  if (err.code === "ER_DUP_ENTRY") return res.status(409).json({ error: "Already exists" });
  if (err.code === "ER_NO_REFERENCED_ROW_2") return res.status(422).json({ error: "Related item not found" });
  next(err);
});

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 43: 独自のエラークラス

HTTP ステータスをエラーメッセージと一緒に持たせます。

class HttpError extends Error {
  constructor(status, message) {
    super(message);
    this.status = status;
  }
}

// usage
throw new HttpError(404, "Product not found");

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 44: async ルートのラッパー

async ルートのエラーをハンドラーに渡します。

const asyncRoute = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);

app.get("/api/products/:id", asyncRoute(async (req, res) => {
  const [rows] = await pool.execute("SELECT * FROM products WHERE id = ?", [req.params.id]);
  if (!rows.length) throw new HttpError(404, "Product not found");
  res.json(rows[0]);
}));

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 45: Angular 開発サーバー用の CORS

別のポートからブラウザーが API を呼べるようにします。

const cors = require("cors");

app.use(cors({ origin: "http://localhost:4200" }));

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 46: セキュリティヘッダー

helmet で安全な HTTP ヘッダーを設定します。

const helmet = require("helmet");

app.use(helmet());
app.disable("x-powered-by");

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

トピック 7: 認証と認可

認証は利用者が誰であるかを証明します。認可は、その利用者が何をしてよいかを決めます。パスワードはハッシュとして保存し、有効期間の短いトークンを発行し、保護されたすべてのルートでロールを確認します。

ハッシュ化されたパスワード

bcrypt は、組み込みのソルトとコストパラメーターを使い、パスワードを一方向のハッシュに変えます。ハッシュだけを保存し、ログイン時には bcrypt.compare で比較します。パスワードを記録したり、応答で返したりしてはいけません。

トークンとその有効期間

JSON Web Token は、ユーザー ID とロールを秘密鍵で署名して運びます。たとえば 1 時間のように有効期間を短くし、長いセッションには取り消せるリフレッシュトークンを使います。リフレッシュトークンはハッシュにしてデータベースに保存します。

ロールとルートの保護

ミドルウェアでトークンを検証し、管理者だけの操作ではロールを確認します。フロントエンドはボタンを隠せますが、ルールを強制できるのはサーバーだけだということを忘れないでください。

例 47: パスワードのハッシュ化

パスワードそのものではなく bcrypt のハッシュを保存します。

const bcrypt = require("bcrypt");

async function hashPassword(plain) {
  return bcrypt.hash(plain, 12);
}

async function checkPassword(plain, hash) {
  return bcrypt.compare(plain, hash);
}

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 48: ユーザーテーブル

一意のメール、パスワードのハッシュ、ロールを保存します。

CREATE TABLE users (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  email VARCHAR(190) NOT NULL UNIQUE,
  password_hash VARCHAR(255) NOT NULL,
  role ENUM('customer', 'admin') NOT NULL DEFAULT 'customer',
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 49: ユーザーを登録

ハッシュ化したパスワードでアカウントを作成します。

app.post("/api/auth/register", asyncRoute(async (req, res) => {
  const { email, password } = req.body;
  const hash = await hashPassword(password);
  const [result] = await pool.execute(
    "INSERT INTO users (email, password_hash) VALUES (?, ?)",
    [email.toLowerCase(), hash]
  );
  res.status(201).json({ id: result.insertId, email });
})));

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 50: ログインしてトークンを発行

パスワードが正しければ署名付き JWT を返します。

const jwt = require("jsonwebtoken");

app.post("/api/auth/login", asyncRoute(async (req, res) => {
  const [rows] = await pool.execute("SELECT id, password_hash, role FROM users WHERE email = ?", [req.body.email]);
  if (!rows.length || !(await checkPassword(req.body.password, rows[0].password_hash))) {
    throw new HttpError(401, "Wrong email or password");
  }
  const token = jwt.sign({ sub: rows[0].id, role: rows[0].role }, process.env.JWT_SECRET, { expiresIn: "1h" });
  res.json({ token });
})));

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 51: ミドルウェアで JWT を検証

Bearer トークンを読み、不正または期限切れのものを拒否します。

const jwt = require("jsonwebtoken");

function requireAuth(req, res, next) {
  const header = req.headers.authorization || "";
  const token = header.startsWith("Bearer ") ? header.slice(7) : null;
  if (!token) return res.status(401).json({ error: "Missing token" });
  try {
    req.user = jwt.verify(token, process.env.JWT_SECRET);
    next();
  } catch {
    res.status(401).json({ error: "Invalid or expired token" });
  }
}

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 52: 一部のロールだけを許可

ユーザーにロールがなければ 403 を返します。

function requireRole(...roles) {
  return (req, res, next) => {
    if (!req.user || !roles.includes(req.user.role)) {
      return res.status(403).json({ error: "Forbidden" });
    }
    next();
  };
}

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 53: ルートを保護

注文にはログインを必須にし、削除は管理者だけに許可します。

app.get("/api/orders", requireAuth, asyncRoute(async (req, res) => {
  const [rows] = await pool.execute("SELECT id, product_id, quantity FROM orders WHERE user_id = ?", [req.user.sub]);
  res.json(rows);
})));

app.delete("/api/products/:id", requireAuth, requireRole("admin"), asyncRoute(async (req, res) => {
  await pool.execute("DELETE FROM products WHERE id = ?", [req.params.id]);
  res.status(204).end();
}));

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 54: リフレッシュトークンを保存

取り消せるように各トークンのハッシュを保存します。

CREATE TABLE refresh_tokens (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  user_id INT UNSIGNED NOT NULL,
  token_hash CHAR(64) NOT NULL,
  expires_at DATETIME NOT NULL,
  revoked TINYINT(1) NOT NULL DEFAULT 0,
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

トピック 8: Angular のコンポーネントとテンプレート

Angular はコンポーネントから画面を組み立てます。各コンポーネントは、ロジックを持つクラスと、マークアップを持つテンプレートから成ります。スタンドアロンコンポーネント、制御フロー、バインディングが現在の書き方です。

スタンドアロンコンポーネント

スタンドアロンコンポーネントは自分の import を列挙し、NgModule を必要としません。ng generate component で生成し、別のテンプレートでセレクターを使って表示します。

バインディング

補間は二重の波かっこで値を表示します。プロパティバインディングは角かっこで DOM のプロパティを設定し、イベントバインディングは丸かっこでメソッドを呼びます。双方向バインディングも可能ですが、一方向のデータフローの方がデバッグしやすいです。

制御フロー

@if で条件付きに内容を表示し、@for に track 式を付けて項目を繰り返し、@switch で複数の表示から選びます。id で追跡すると、リストが変わってもページを効率よく保てます。

例 55: スタンドアロンの Angular コンポーネント

NgModule を必要としないコンポーネントです。

import { Component } from "@angular/core";

@Component({
  selector: "app-hello",
  standalone: true,
  template: `<h1>Welcome to the shop</h1>`,
})
export class HelloComponent {}

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 56: テンプレートのデータバインディング

値を表示し、属性をバインドし、クリックを処理します。

<h2>{{ product.name }}</h2>
<p>Price: {{ product.price | currency:'THB' }}</p>
<button [disabled]="product.stock === 0" (click)="addToCart(product)">Add to cart</button>

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 57: @if と @for による制御フロー

テンプレートでリストと空の状態を表示します。

@if (products.length > 0) {
  <ul>
    @for (p of products; track p.id) {
      <li>{{ p.name }} ({{ p.stock }} left)</li>
    }
  </ul>
} @else {
  <p>No products yet.</p>
}

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 58: 入力と出力

子コンポーネントにデータを渡し、イベントを親に返します。

import { Component, Input, Output, EventEmitter } from "@angular/core";

@Component({ selector: "app-product-card", standalone: true, template: "" })
export class ProductCardComponent {
  @Input({ required: true }) product!: { id: number; name: string; price: number };
  @Output() added = new EventEmitter<number>();

  add() {
    this.added.emit(this.product.id);
  }
}

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 59: スタンドアロンアプリの起動

起動時にルーターと HttpClient を提供します。

import { bootstrapApplication } from "@angular/platform-browser";
import { provideRouter } from "@angular/router";
import { provideHttpClient } from "@angular/common/http";
import { AppComponent } from "./app/app.component";
import { routes } from "./app/app.routes";

bootstrapApplication(AppComponent, {
  providers: [provideRouter(routes), provideHttpClient()],
});

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 60: CLI で生成して実行

コンポーネントとサービスを作り、アプリを起動します。

cd web
ng generate component products/product-list
ng generate service products/product
ng serve --open

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

トピック 9: サービス、HTTP、ルーティング

サービスはロジックを保持して API と通信するため、コンポーネントは小さく保てます。ルーターは URL をコンポーネントに対応させ、ガードはページを保護し、遅延読み込みは最初のダウンロードを小さくします。

注入可能なサービスと HttpClient

サービスに providedIn root を指定すると、Angular はアプリ全体で 1 つのインスタンスを作ります。HttpClient を注入し、応答のインターフェースで型付けした Observable を返すと、コンパイラーが使うフィールドを検査します。

ルートとパラメーター

ルートはパスとコンポーネントの組の配列として定義します。パラメーターは ActivatedRoute から読み、最後にワイルドカードのルートを追加して、見つからないページを表示します。

ガードと遅延読み込み

CanActivate ガードは true かリダイレクトを返します。遅延ルートは動的 import を使う loadComponent を使い、利用者が開いたときだけ管理画面のコードをダウンロードします。

例 61: 注入可能なサービス

1 つのインスタンスでコンポーネント間の状態を共有します。

import { Injectable } from "@angular/core";

@Injectable({ providedIn: "root" })
export class CartService {
  private items: { id: number; qty: number }[] = [];

  add(id: number) {
    const found = this.items.find((i) => i.id === id);
    if (found) found.qty++;
    else this.items.push({ id, qty: 1 });
  }

  count() {
    return this.items.reduce((sum, i) => sum + i.qty, 0);
  }
}

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 62: HttpClient で API を呼ぶ

Observable を返す型付きのリクエストです。

import { Injectable, inject } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { Observable } from "rxjs";

export interface Product { id: number; name: string; price: number; stock: number; }

@Injectable({ providedIn: "root" })
export class ProductService {
  private http = inject(HttpClient);

  list(): Observable<Product[]> {
    return this.http.get<Product[]>("/api/products");
  }
}

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 63: ルートを定義

URL をコンポーネントに対応させ、予備のページも用意します。

import { Routes } from "@angular/router";

export const routes: Routes = [
  { path: "", pathMatch: "full", redirectTo: "products" },
  { path: "products", component: ProductListComponent },
  { path: "products/:id", component: ProductDetailComponent },
  { path: "**", component: NotFoundComponent },
];

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 64: ルートガード

未ログインの利用者をログインページへ送ります。

import { inject } from "@angular/core";
import { CanActivateFn, Router } from "@angular/router";
import { AuthService } from "./auth.service";

export const authGuard: CanActivateFn = () => {
  const auth = inject(AuthService);
  return auth.isLoggedIn() || inject(Router).createUrlTree(["/login"]);
};

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 65: ルートパラメーターを読む

URL から 1 つの商品を読み込みます。

import { Component, inject } from "@angular/core";
import { ActivatedRoute } from "@angular/router";

@Component({ selector: "app-product-detail", standalone: true, template: "" })
export class ProductDetailComponent {
  private route = inject(ActivatedRoute);
  readonly id = Number(this.route.snapshot.paramMap.get("id"));
}

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 66: 機能の遅延読み込み

管理画面のコードは必要になったときだけ取得します。

export const routes: Routes = [
  {
    path: "admin",
    canActivate: [authGuard],
    loadComponent: () => import("./admin/admin.component").then((m) => m.AdminComponent),
  },
];

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

トピック 10: フォームと検証

フォームは API が保存するデータを集めます。リアクティブフォームはモデルを TypeScript で保持するため、検証とテストが分かりやすくなり、テンプレートも単純に保てます。

リアクティブフォーム

FormBuilder でフォームを作り、各項目を既定値とバリデーターで定義して、テンプレートを formGroup と formControlName で結び付けます。フォームの値には型があるため、送信のコードはコンパイラーが検査します。

カスタムと非同期の検証

バリデーターは、値が正しければ null を、誤りがあれば誤りを表すオブジェクトを返す関数です。組み込みのバリデーターで扱えない規則、たとえば空白だけの値などには、独自に書きます。

エラーの表示とデータの送信

利用者が項目に触れた後にだけメッセージを表示し、フォームを最初からエラー状態にしないようにします。サーバーが検証エラーを返したら、汎用のアラートではなくフォームの近くに表示します。

例 67: リアクティブフォーム

バリデーターを使って TypeScript でフォームを作ります。

import { Component, inject } from "@angular/core";
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";

@Component({ selector: "app-product-form", standalone: true, imports: [ReactiveFormsModule], templateUrl: "./product-form.html" })
export class ProductFormComponent {
  private fb = inject(FormBuilder);
  form = this.fb.nonNullable.group({
    name: ["", [Validators.required, Validators.minLength(2)]],
    price: [0, [Validators.required, Validators.min(0)]],
    stock: [0, [Validators.min(0)]],
  });
}

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 68: フォームのテンプレート

入力欄をフォームモデルにバインドします。

<form [formGroup]="form" (ngSubmit)="save()">
  <label>Name <input formControlName="name"></label>
  <label>Price <input type="number" formControlName="price"></label>
  <label>Stock <input type="number" formControlName="stock"></label>
  <button type="submit" [disabled]="form.invalid">Save</button>
</form>

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 69: カスタムバリデーター

空白だけの値を拒否します。

import { AbstractControl, ValidationErrors } from "@angular/forms";

export function noSpacesOnly(control: AbstractControl): ValidationErrors | null {
  const value = String(control.value ?? "");
  return value.trim().length === 0 && value.length > 0 ? { spacesOnly: true } : null;
}

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 70: バリデーションメッセージの表示

利用者が項目に触れた後にエラーを表示します。

@if (form.controls.name.touched && form.controls.name.invalid) {
  <p class="error">
    @if (form.controls.name.errors?.['required']) { Name is required. }
    @if (form.controls.name.errors?.['minlength']) { Name must be at least 2 characters. }
  </p>
}

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 71: フォームを API に送信

データを送り、成功したら画面を移動します。

save() {
  if (this.form.invalid) return;
  this.http.post("/api/products", this.form.getRawValue()).subscribe({
    next: () => this.router.navigate(["/products"]),
    error: (err) => (this.serverError = err.error?.errors ?? ["Could not save"]),
  });
}

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 72: サーバーのエラーを表示

API が返したバリデーションメッセージを表示します。

// After a 400 response, the API sends { errors: ["price must be a number >= 0"] }
serverError: string[] = [];

// in the template
// @for (e of serverError; track e) { <p class="error">{{ e }}</p> }

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

トピック 11: 状態、Observable、インターセプター

データは時間とともに変わり、多くのリクエストを調整する必要があります。Observable と Signal はその変化を表し、インターセプターはトークンやエラー記録のような横断的な処理を、すべてのリクエストに加えます。

Observable と async パイプ

Observable は時間をかけて値を発行します。async パイプはテンプレート内で購読し、自動で購読を解除するため、メモリリークや手動の購読コードを避けられます。

ローカル状態のための Signal

Signal は、変化したときにテンプレートへ通知される値を保持します。computed は Signal から値を導出し、effect は値が変わったときにコードを実行します。カウンターやカートの合計のような単純な状態に向いています。

インターセプター

HTTP インターセプターは、すべてのリクエストと応答を見ることができます。1 つで Authorization ヘッダーを付け、別の 1 つで失敗を記録したり、トークンが期限切れのときにログインページへ誘導したりします。

例 73: Observable と async パイプ

手動の購読なしでリストを描画します。

@Component({
  selector: "app-products",
  standalone: true,
  imports: [AsyncPipe],
  template: `
    @for (p of products$ | async; track p.id) {
      <div>{{ p.name }}</div>
    }`,
})
export class ProductsComponent {
  products$ = inject(ProductService).list();
}

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 74: トークンを付けるインターセプター

すべてのリクエストに Authorization ヘッダーを付けます。

import { HttpInterceptorFn } from "@angular/common/http";
import { inject } from "@angular/core";
import { AuthService } from "./auth.service";

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = inject(AuthService).token();
  return next(token ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : req);
};

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 75: 共通のエラーインターセプター

API の失敗を 1 か所で記録します。

import { HttpErrorResponse, HttpInterceptorFn } from "@angular/common/http";
import { catchError, throwError } from "rxjs";

export const errorInterceptor: HttpInterceptorFn = (req, next) =>
  next(req).pipe(
    catchError((err: HttpErrorResponse) => {
      console.error("API error", err.status, req.url);
      return throwError(() => err);
    })
  );

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 76: ローカル状態のための Signal

値を追跡し、そこから別の値を導出します。

import { Component, computed, signal } from "@angular/core";

@Component({ selector: "app-counter", standalone: true, template: "" })
export class CounterComponent {
  count = signal(0);
  double = computed(() => this.count() * 2);

  increment() {
    this.count.update((n) => n + 1);
  }
}

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 77: Signal による小さなストア

カートの状態を 1 つのサービスに保持します。

import { Injectable, computed, signal } from "@angular/core";

@Injectable({ providedIn: "root" })
export class CartStore {
  private readonly lines = signal<{ id: number; qty: number }[]>([]);
  readonly totalQty = computed(() => this.lines().reduce((s, l) => s + l.qty, 0));

  add(id: number) {
    this.lines.update((ls) => [...ls, { id, qty: 1 }]);
  }
}

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 78: 自動で購読を解除

コンポーネントが破棄されたらストリームを止めます。

import { Component, DestroyRef, inject } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";

export class ClockComponent {
  constructor() {
    interval(1000).pipe(takeUntilDestroyed()).subscribe(() => this.tick());
  }
  tick() {}
}

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

トピック 12: フルスタックの統合

プロジェクトが壊れやすいのは、各部分をつなぐときです。開発時のプロキシ、デプロイごとの環境ファイル、共有の型が、フロントエンドと API の足並みをそろえます。

開発用プロキシ

Angular の開発サーバーから /api を Node.js サーバーに転送すると、ブラウザーからは 1 つのオリジンに見え、開発中に CORS の設定は不要になります。プロキシのファイルを変更したら ng serve を再起動してください。

環境ファイル

Angular の環境ファイルは、ビルドごとに API のアドレスなどの独自の設定を与えます。秘密の本番アドレスはソースに入れず、代わりにビルド設定から読み込みます。

共有の型

Product の形を共有フォルダーに一度書き、API とフロントエンドの両方から import します。一方で名前を変えると、もう一方ではランタイムのバグではなくコンパイラーのエラーとして表示されます。

例 79: 開発時の API 呼び出しをプロキシ

/api を Node.js サーバーへ転送します。

{
  "/api": {
    "target": "http://localhost:3000",
    "secure": false,
    "changeOrigin": true
  }
}

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 80: 環境ファイル

本番では別の API アドレスを使います。

// src/environments/environment.ts
export const environment = {
  production: false,
  apiBaseUrl: "/api",
};

// src/environments/environment.prod.ts
export const environment = {
  production: true,
  apiBaseUrl: "https://api.shop.example.com",
};

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 81: API の URL を組み立てる

ベースアドレスとパスを 1 か所で結合します。

import { environment } from "../environments/environment";

export const api = (path: string) => `${environment.apiBaseUrl}${path}`;

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 82: API とフロントエンドを同時に実行

2 つのターミナルで両方のサーバーを起動します。

# terminal 1
cd api && npm run dev
# terminal 2
cd web && ng serve --proxy-config proxy.conf.json

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 83: フロントエンドとバックエンドで型を共有

Product の形を両側で同じに保ちます。

// shared/product.ts (used by both web and api through a path alias)
export interface Product {
  id: number;
  name: string;
  price: number;
  stock: number;
}

export type CreateProduct = Omit<Product, "id">;

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 84: Angular から API の状態を確認

バックエンドに到達できるかを表示します。

export class AppComponent {
  private http = inject(HttpClient);
  apiOk = signal<boolean | null>(null);

  constructor() {
    this.http.get<{ status: string }>("/api/health").subscribe({
      next: (r) => this.apiOk.set(r.status === "ok"),
      error: () => this.apiOk.set(false),
    });
  }
}

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

トピック 13: 各層のテスト

テストは、利用者より先に間違いを捕まえます。純粋な関数、API のルート、Angular のコンポーネントとサービスを個別にテストし、最後に別のデータベースを使ってシステム全体をテストします。

API のテスト

Supertest は実際のポートを開かずに Express アプリへリクエストを送り、Jest か node:test が結果を確認します。成功のケースと、トークンがない、価格が不正などの失敗のケースを少なくとも 1 つずつテストします。

Angular のテスト

TestBed は依存関係と一緒にコンポーネントを作ります。HttpTestingController は偽のデータで HTTP の呼び出しに応答するため、API を動かさずにコンポーネントをテストできます。

統合テストとカバレッジ

統合テストは、実行のたびにリセットされる別のテスト用データベースに対して行います。こうすればテストが実際のデータを変えることはありません。カバレッジは、どのコードにテストが届いていないかを示す出発点であり、保証ではありません。

例 85: Express のルートをテスト

Supertest でルートを呼び、応答を確かめます。

const request = require("supertest");
const app = require("../app");

test("GET /api/health returns ok", async () => {
  const res = await request(app).get("/api/health");
  expect(res.status).toBe(200);
  expect(res.body.status).toBe("ok");
});

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 86: 純粋な関数をテスト

組み込みの node:test ランナーを使います。

const { test } = require("node:test");
const assert = require("node:assert/strict");
const { validateProduct } = require("../validate");

test("rejects a short name", () => {
  assert.equal(validateProduct({ name: "A", price: 10 }).length, 1);
});

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 87: コンポーネントをテスト

TestBed でコンポーネントを作成します。

import { TestBed } from "@angular/core/testing";
import { ProductListComponent } from "./product-list.component";

it("creates the component", () => {
  TestBed.configureTestingModule({ imports: [ProductListComponent] });
  const fixture = TestBed.createComponent(ProductListComponent);
  expect(fixture.componentInstance).toBeTruthy();
});

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 88: HttpTestingController で HTTP をモック

本物のサーバーなしでリクエストに応答します。

import { TestBed } from "@angular/core/testing";
import { provideHttpClient } from "@angular/common/http";
import { HttpTestingController, provideHttpClientTesting } from "@angular/common/http/testing";
import { ProductService } from "./product.service";

it("loads products", () => {
  TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
  const service = TestBed.inject(ProductService);
  const http = TestBed.inject(HttpTestingController);
  service.list().subscribe((items) => expect(items.length).toBe(1));
  http.expectOne("/api/products").flush([{ id: 1, name: "Mouse", price: 350, stock: 3 }]);
  http.verify();
});

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 89: テスト用データベースで統合テスト

別のデータベースに対して API を実行します。

# create a separate test database
mysql -u root -p -e "CREATE DATABASE IF NOT EXISTS shop_test"
DB_NAME=shop_test npm test -- --runInBand

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 90: カバレッジレポート

テストがどれだけのコードに届くかを測ります。

cd api && npx jest --coverage
cd ../web && ng test --code-coverage --watch=false

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

トピック 14: Docker とデプロイ

コンテナーは、各部分をその正確な依存関係とともにパッケージ化します。Docker Compose はデータベース、API、Web サーバーを一緒に起動し、同じイメージをノートパソコン、テストサーバー、クラウドサービスで実行できます。

API と Web アプリのイメージ

マルチステージビルドを使います。1 つのステージでインストールとビルドを行い、最終イメージに必要なものだけをコピーします。API のイメージは root 以外のユーザーで動かし、Angular のイメージには Nginx が配信するビルド済みのファイルだけを入れます。

Compose とデータベースの起動

Compose は最初に MySQL を起動し、そのヘルスチェックが通ってから API を起動します。スキーマのスクリプトは init フォルダーに置きます。MySQL はデータのボリュームが空のときにだけそれを実行します。

リバースプロキシ

Nginx は Angular のファイルを配信し、/api を API のコンテナーへ転送し、try_files のルールを扱います。このルールによって、ページを再読み込みしてもクライアント側のルートが残ります。

例 91: API 用の Dockerfile

root 以外のユーザーで動く小さな本番イメージです。

FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev

FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
USER node
EXPOSE 3000
CMD ["node", "server.js"]

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 92: Angular アプリ用の Dockerfile

アプリをビルドして Nginx で配信します。

FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx ng build --configuration production

FROM nginx:1.27-alpine
COPY --from=build /app/dist/web/browser /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 93: MySQL、API、Web を含む Compose

1 つのコマンドでスタック全体を起動します。

services:
  db:
    image: mysql:8.4
    environment:
      MYSQL_DATABASE: shop
      MYSQL_USER: shop
      MYSQL_PASSWORD: <strong-password>
      MYSQL_ROOT_PASSWORD: <root-password>
    volumes:
      - db-data:/var/lib/mysql
      - ./db/init:/docker-entrypoint-initdb.d:ro
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 5s
      retries: 20
  api:
    build: ./api
    env_file: ./api/.env
    depends_on:
      db:
        condition: service_healthy
  web:
    build: ./web
    ports:
      - "80:80"
    depends_on:
      - api
volumes:
  db-data:

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 94: 初回起動時にスキーマを実行

新しいボリュームにテーブルを自動で作ります。

# db/init/01-schema.sql is run once when the data volume is empty
services:
  db:
    volumes:
      - ./db/init:/docker-entrypoint-initdb.d:ro

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

トピック 15: 本番運用

一度動いたからといってリリースが完了したわけではありません。本番には、安全な設定、構造化ログ、バックアップ、継続的インテグレーションのパイプライン、そして悪いリリースを止めるチェックリストが必要です。

安全な設定

署名キーがないときは起動を拒否します。プロキシが前にあるときだけプロキシのヘッダーを信頼し、サーバーのソフトウェアを明かすフレームワークのヘッダーを無効にします。

ログとバックアップ

リクエスト ID を含む構造化された JSON ログを書き、1 つの問題をスタック全体で追えるようにします。mysqldump と --single-transaction でデータベースをバックアップし、復元は別のマシンでテストします。

パイプラインとリリースのチェックリスト

CI のワークフローは、push のたびに API のテスト、Angular のテスト、本番ビルドを実行します。リリーススクリプトは、新しいバージョンを公開する前にヘルスのエンドポイントとコンテナーを確認します。

例 95: API の本番設定

秘密鍵がなければ起動を拒否します。

const isProd = process.env.NODE_ENV === "production";

if (isProd && !process.env.JWT_SECRET) {
  throw new Error("JWT_SECRET must be set in production");
}
app.set("trust proxy", 1);

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 96: Nginx によるリバースプロキシ

アプリを配信し、/api を API に転送します。

server {
  listen 80;
  root /usr/share/nginx/html;
  location /api/ {
    proxy_pass http://api:3000;
    proxy_set_header Host $host;
  }
  location / {
    try_files $uri /index.html;
  }
}

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 97: pino による構造化ログ

ツールで検索できる JSON ログを出力します。

const pino = require("pino");
const logger = pino({ level: process.env.LOG_LEVEL || "info" });

app.use(require("pino-http")({ logger }));
logger.info({ port: 3000 }, "API started");

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 98: MySQL データベースのバックアップ

整合性のあるダンプを作り、圧縮します。

mysqldump -h db -u shop -p shop --single-transaction > backup-$(date +%F).sql
gzip backup-$(date +%F).sql

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 99: GitHub Actions の CI ワークフロー

main へのプッシュごとにテストとビルドを行います。

name: ci
on:
  push:
    branches: [main]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci --prefix api
      - run: npm test --prefix api
      - run: npm ci --prefix web
      - run: npx ng test --watch=false --prefix web
      - run: npx ng build --configuration production --prefix web

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

例 100: リリース前チェックのスクリプト

チェックが失敗したらリリースを止めます。

#!/usr/bin/env bash
set -euo pipefail
curl -fsS https://shop.example.com/api/health | grep -q '"status":"ok"'
docker compose ps --format json | grep -q '"Health":"healthy"'
gzip -t backup-latest.sql.gz
echo "release checks passed"

*この例は、この記事を準備したコンピューターに MySQL も Angular CLI もインストールされていなかったため、ここでは実行していません。*

ラボ: 小さなショップを作り、テストして、リリースする

記事の内容を 1 つのプロジェクトにまとめます。ログインと商品のルートを持つ API、安全なクエリ層を持つ MySQL のスキーマ、リアクティブフォームを持つ Angular の商品ページ、各層のテスト、そして Docker Compose のスタックです。

1. api と web のフォルダーを作成し、API に Express、mysql2、dotenv、bcrypt を入れ、ルーティング付きの Angular アプリを生成します。

2. 権限を制限したユーザーで MySQL に products と users のテーブルを作り、いくつかの商品を追加します。

3. プレースホルダー、検証、中央のエラーハンドラーを使って商品のルートを書き、curl か Supertest で確認します。

4. ハッシュ化したパスワードと JWT による登録とログインを追加し、注文のルートをミドルウェアで保護します。

5. HttpClient で Angular の商品一覧、商品詳細のルート、商品を追加するリアクティブフォームを作ります。

6. プロキシのファイルを追加し、API と Web アプリを一緒に動かして、ブラウザーで全体の流れを確かめます。

7. API のテストを 1 つと、コンポーネントのテストを 1 つ書いて実行します。その後 docker compose up でスタック全体を起動し、例 100 のリリースチェックリストを実行します。

次のステップ

ラボの後は、Angular、Express、MySQL の公式ドキュメントを読み、ショップにページングと検索を加え、コードをコピーせずに 2 つ目の機能を最初から最後まで作ってください。例はリポジトリに保存し、依存関係を更新するたびにテストを再実行してください。フレームワークはメジャーバージョンごとに変わるからです。

まとめ

フルスタックのアプリケーションは、互いに一致していなければならない 3 つの部分をつなぎます。プロジェクトとツールの設定、Node.js と Express のコード、MySQL のテーブルと安全なクエリの設計、プールとトランザクションによる API の接続、入力の検証とエラー処理、ハッシュ化したパスワードとトークンによるルートの保護、Angular のコンポーネント、サービス、ルート、フォーム、インターセプターの作成、フロントエンドと API の接続、各層のテスト、Docker によるパッケージ化、そして本番への準備を学びました。