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 ファイルに置きます。プレースホルダーの値を入れた例のファイルをコミットして、他の人が必要な設定を分かるようにします。
図解
例 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 によるパッケージ化、そして本番への準備を学びました。