Full-Stack ด้วย Angular, Node.js และ MySQL: 100 ตัวอย่างตั้งแต่ศูนย์ถึงระดับมืออาชีพ
แอปแบบ full-stack มีสามส่วนที่ต้องทำงานสอดคล้องกัน ได้แก่ หน้าเว็บ Angular ที่ผู้ใช้เปิดในเบราว์เซอร์ API ด้วย Node.js ที่บังคับใช้กฎ และฐานข้อมูล MySQL ที่เก็บข้อมูลอย่างปลอดภัย บทความนี้สร้างร้านค้าขนาดเล็กหนึ่งร้านตั้งแต่โฟลเดอร์ว่างจนถึงการปล่อยเวอร์ชันที่คำนึงถึงโปรดักชัน ใน 100 ตัวอย่าง คุณจะตั้งค่าโปรเจกต์ เรียน Node.js และ Express เพียงพอสร้าง REST API ออกแบบตาราง MySQL และเขียนคิวรีที่ปลอดภัย เชื่อม API กับฐานข้อมูลด้วย transaction ตรวจสอบข้อมูลนำเข้า เพิ่มการเข้าสู่ระบบด้วยรหัสผ่านแฮชและโทเค็น JWT สร้างคอมโพเนนต์ เซอร์วิส เส้นทาง ฟอร์ม และ interceptor ของ Angular เชื่อมทุกส่วนด้วย proxy และไฟล์ environment ทดสอบทุกชั้น บรรจุระบบด้วย Docker และเตรียมพร้อมสำหรับโปรดักชัน หมายเหตุตามจริง: MySQL และ Angular CLI ไม่ได้ติดตั้งบนเครื่องที่ใช้เตรียมบทความนี้ จึงไม่ได้รันตัวอย่างทั้ง 100 ข้อ โค้ดเป็นไปตามไวยากรณ์ปัจจุบันของ Node.js, Express, mysql2, Angular และ MySQL แต่คุณควรรันเองก่อนพึ่งพา
สิ่งที่ต้องเตรียม
Node.js 20 ขึ้นไปพร้อม npm, 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 และอีกหนึ่งสำหรับสคริปต์ฐานข้อมูล โครงสร้างที่ดีทำให้แต่ละส่วนทดสอบ ปล่อย และอธิบายให้คนใหม่เข้าใจได้ง่าย
สามส่วนในรีโพซิทอรีเดียว
เก็บ API เว็บแอป และสคริปต์ฐานข้อมูลไว้ในรีโพซิทอรีเดียว เพื่อให้การเปลี่ยนแปลงเพียงครั้งเดียวอัปเดตทั้งสามส่วนพร้อมกันได้ แต่ละส่วนมีไฟล์ package และคำสั่งของตัวเอง
เครื่องมือและเวอร์ชัน
ตรวจ Node.js, npm และ MySQL ก่อน แนะนำ Node.js 20 ขึ้นไปเพราะมีตัวรันทดสอบในตัวและ fetch ที่ทันสมัย Angular CLI ใช้สร้างโค้ดและรันเซิร์ฟเวอร์พัฒนา
ความลับไม่เข้า Git
รหัสผ่านและคีย์สำหรับลงนามควรอยู่ในไฟล์ .env ที่ระบุไว้ใน .gitignore คอมมิตไฟล์ตัวอย่างที่มีค่าเป็นตัวยึดตำแหน่ง เพื่อให้คนอื่นรู้ว่าต้องตั้งค่าอะไรบ้าง
ภาพประกอบ
ตัวอย่างที่ 1: ตรวจเครื่องมือ
ยืนยันว่าติดตั้ง Node.js, npm และ MySQL แล้ว
node --version
npm --version
mysql --version
*ตัวอย่างนี้ยังไม่ได้รันที่นี่ เพราะเครื่องที่เตรียมบทความไม่มี MySQL หรือ Angular CLI*
ตัวอย่างที่ 2: สร้างโฟลเดอร์โปรเจกต์
แยก API เว็บแอป และฐานข้อมูลออกจากกัน
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: โครงสร้างโปรเจกต์
เก็บสามส่วนไว้ในโฟลเดอร์ที่ชัดเจน
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 และ commit ไฟล์ตัวอย่างแทน
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 นอกเบราว์เซอร์ เรียนรู้โมดูล โค้ดแบบ asynchronous การตั้งค่าจากตัวแปรสภาพแวดล้อม และแนวคิดเล็กๆ ที่ไฟล์เซิร์ฟเวอร์ทุกไฟล์ใช้
โมดูลและโค้ดแบบ asynchronous
แต่ละไฟล์เป็นโมดูลที่ export สิ่งที่ไฟล์อื่นต้องใช้ งานฝั่งเซิร์ฟเวอร์ส่วนใหญ่เป็น asynchronous จึงควรใช้ Promise กับ async และ await และจัดการข้อผิดพลาดด้วย try และ catch เพื่อไม่ให้ความล้มเหลวเดียวทำโปรเซสล่ม
การตั้งค่าจากสภาพแวดล้อม
อ่านค่าเช่นพอร์ตและที่อยู่ฐานข้อมูลจาก process.env แพ็กเกจ dotenv โหลดไฟล์ .env ตอนพัฒนา ส่วนเซิร์ฟเวอร์โปรดักชันมักได้ตัวแปรจากแพลตฟอร์ม
สคริปต์และ event loop
สคริปต์ npm ทำให้สมาชิกทีมทุกคนใช้คำสั่งเดียวกัน จำไว้ว่าการทำงาน synchronous ที่ช้าเพียงครั้งเดียวจะบล็อกทุกคำขอ จึงควรแยกงานหนักออกจากตัวจัดการคำขอ
ตัวอย่างที่ 7: อ่านไฟล์ด้วย Node.js
การอ่านไฟล์แบบ synchronous และข้อแลกเปลี่ยน
const fs = require("node:fs");
const text = fs.readFileSync("notes.txt", "utf8");
console.log(text.split("\n").length, "lines");
*ตัวอย่างนี้ยังไม่ได้รันที่นี่ เพราะเครื่องที่เตรียมบทความไม่มี MySQL หรือ Angular CLI*
ตัวอย่างที่ 8: อ่านไฟล์แบบ asynchronous
ใช้ Promise กับ async และ await
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: ตัวแปรสภาพแวดล้อม
อ่านการตั้งค่าจาก .env ด้วย dotenv
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
เริ่ม พัฒนา และทดสอบ API ด้วยคำสั่งเดียว
{
"name": "api",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"test": "node --test"
}
}
*ตัวอย่างนี้ยังไม่ได้รันที่นี่ เพราะเครื่องที่เตรียมบทความไม่มี MySQL หรือ Angular CLI*
หัวข้อที่ 3: REST API ด้วย Express
Express จับคู่เมธอด HTTP และเส้นทางกับฟังก์ชัน REST API ใช้ GET เพื่ออ่าน POST เพื่อสร้าง PUT หรือ PATCH เพื่อแก้ไข และ DELETE เพื่อลบ แล้วคืน JSON ที่หน้าเว็บแสดงได้
เส้นทาง พารามิเตอร์ และ body
app.get, app.post และตัวอื่นๆ จับคู่เมธอดกับเส้นทาง อ่านพารามิเตอร์จาก req.params, query string จาก req.query และ JSON body จาก req.body หลังเรียก app.use(express.json())
router และ middleware
router รวมเส้นทางของทรัพยากรหนึ่งไว้ในไฟล์เดียว middleware ทำงานตามลำดับกับทุกคำขอ จึงเป็นที่เหมาะสำหรับการบันทึก log การยืนยันตัวตน และการจัดการข้อผิดพลาด
รหัสสถานะที่มีความหมาย
คืน 200 สำหรับการอ่าน 201 เมื่อสร้างทรัพยากร 204 เมื่อลบโดยไม่มี body 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: พารามิเตอร์และ query string
อ่านค่าจาก 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 จาก body
รับข้อมูลจากฟอร์มหรือจาก 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: จัดเส้นทางด้วย router
เก็บแต่ละฟีเจอร์ในไฟล์ของตัวเอง
// 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: middleware บันทึกคำขอ
บันทึกเมธอด เส้นทาง สถานะ และเวลา
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 เพราะหากรหัสผ่านรั่ว เซิร์ฟเวอร์ทั้งเครื่องจะถูกเปิดเผย
ตาราง คีย์ และชนิดข้อมูล
ให้ทุกตารางมี primary key เลือกชนิดข้อมูลที่ตรงกับข้อมูล เช่น DECIMAL สำหรับเงิน และใช้ foreign key เพื่อไม่ให้คำสั่งซื้ออ้างถึงสินค้าที่ไม่มีอยู่ ใช้ UNIQUE กับค่าที่ห้ามซ้ำ เช่นอีเมล
การอ่านและแก้ไขแถว
SELECT ที่มี WHERE, ORDER BY และ LIMIT คืนแถวที่ต้องการ ส่วน 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: เพิ่มแถวข้อมูล
เพิ่มหลายแถวในคำสั่งเดียว
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 ผ่าน connection pool พารามิเตอร์แบบ placeholder แยกข้อมูลผู้ใช้ออกจาก SQL transaction รวมการเปลี่ยนแปลงที่เกี่ยวข้องเข้าด้วยกัน และการแบ่งหน้าทำให้การตอบสนองมีขนาดเล็ก
connection pool
pool เก็บชุดการเชื่อมต่อที่เปิดไว้ และส่งให้แต่ละคำขอใช้ แล้วนำคืน สร้าง pool ครั้งเดียวตอนเริ่มระบบและนำกลับมาใช้ซ้ำ แทนการเปิดการเชื่อมต่อใหม่ทุกคำขอ
placeholder ป้องกัน SQL injection
อย่านำข้อมูลผู้ใช้ไปต่อในข้อความ SQL เขียน ? ในคิวรีแล้วส่งค่าเป็น array ไดรเวอร์จะ escape ค่าให้ นิสัยข้อนี้ข้อเดียวป้องกันความล้มเหลวด้านความปลอดภัยที่พบบ่อยที่สุดบนเว็บ
transaction และการแบ่งหน้า
transaction รวมหลายคำสั่งให้สำเร็จทั้งหมดหรือไม่สำเร็จเลย ซึ่งสำคัญเมื่อคำสั่งซื้อลดสต็อกและสร้างแถวคำสั่งซื้อ ใช้ LIMIT และ OFFSET หรือดีกว่าคือ keyset เพื่อคืนผลทีละหน้า
ตัวอย่างที่ 31: connection pool
ใช้การเชื่อมต่อซ้ำแทนการเปิดใหม่ทุกคำขอ
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: placeholder กัน SQL injection
อย่าต่อข้อมูลผู้ใช้เข้าไปในข้อความ 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: transaction
จองสต็อกและสร้างออเดอร์พร้อมกัน หรือไม่ทำเลย
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 ช่วยให้ใช้งานสะดวกขึ้น แต่เซิร์ฟเวอร์ต้องตรวจอีกครั้งเพราะใครก็ส่งคำขอตรงได้ ไลบรารี schema อธิบายฟิลด์เพียงครั้งเดียวและรายงานปัญหาทั้งหมดในการตอบสนองเดียว
รูปแบบข้อผิดพลาดเดียว
ใช้ตัวจัดการข้อผิดพลาดกลางที่คืน JSON รูปแบบเดียวกันสำหรับทุกความล้มเหลว แปลงข้อผิดพลาดที่รู้จักของฐานข้อมูลเช่นคีย์ซ้ำเป็น 409 และบันทึกข้อผิดพลาดเต็มไว้ที่เซิร์ฟเวอร์ แต่ส่งให้ไคลเอนต์เพียงข้อความที่ปลอดภัย
ข้อผิดพลาดจากเส้นทาง async
Express 4 ไม่จับ Promise ที่ถูก reject จากตัวจัดการเส้นทางโดยอัตโนมัติ จึงต้องห่อเส้นทาง async เพื่อให้ข้อผิดพลาดไปถึงตัวจัดการ หรือใช้ 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: ตรวจสอบด้วย schema
อธิบายข้อมูลครั้งเดียวและใช้กฎซ้ำได้
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: CORS สำหรับ dev server ของ Angular
อนุญาตให้เบราว์เซอร์เรียก API จากอีกพอร์ตหนึ่ง
const cors = require("cors");
app.use(cors({ origin: "http://localhost:4200" }));
*ตัวอย่างนี้ยังไม่ได้รันที่นี่ เพราะเครื่องที่เตรียมบทความไม่มี MySQL หรือ Angular CLI*
ตัวอย่างที่ 46: ส่วนหัวความปลอดภัย
ตั้งค่า HTTP header ที่ปลอดภัยด้วย helmet
const helmet = require("helmet");
app.use(helmet());
app.disable("x-powered-by");
*ตัวอย่างนี้ยังไม่ได้รันที่นี่ เพราะเครื่องที่เตรียมบทความไม่มี MySQL หรือ Angular CLI*
หัวข้อที่ 7: การยืนยันตัวตนและการกำหนดสิทธิ์
การยืนยันตัวตนพิสูจน์ว่าผู้ใช้เป็นใคร การกำหนดสิทธิ์ตัดสินว่าผู้ใช้นั้นทำอะไรได้บ้าง เก็บรหัสผ่านเป็นแฮช ออกโทเค็นที่มีอายุสั้น และตรวจบทบาทในทุกเส้นทางที่ต้องป้องกัน
รหัสผ่านแฮช
bcrypt เปลี่ยนรหัสผ่านเป็นแฮชทางเดียวพร้อม salt ในตัวและค่า cost เก็บเฉพาะแฮช เทียบด้วย bcrypt.compare ตอนเข้าสู่ระบบ และไม่บันทึกหรือส่งรหัสผ่านกลับในการตอบสนอง
โทเค็นและอายุการใช้งาน
JSON Web Token บรรจุรหัสผู้ใช้และบทบาท ลงนามด้วยความลับ ตั้งอายุสั้น เช่นหนึ่งชั่วโมง และใช้ refresh token ที่เพิกถอนได้สำหรับเซสชันที่ยาวขึ้น เก็บ refresh token เป็นแฮชในฐานข้อมูล
บทบาทและการป้องกันเส้นทาง
ตรวจโทเค็นใน middleware แล้วตรวจบทบาทสำหรับการกระทำที่เฉพาะผู้ดูแล จำไว้ว่าหน้าเว็บซ่อนปุ่มได้ แต่มีเพียงเซิร์ฟเวอร์เท่านั้นที่บังคับใช้กฎได้
ตัวอย่างที่ 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: เข้าสู่ระบบและออก token
คืน 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 ใน middleware
อ่าน bearer token และปฏิเสธโทเค็นที่ไม่ถูกต้องหรือหมดอายุ
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: เก็บ refresh token
เก็บแฮชของแต่ละ refresh token เพื่อเพิกถอนได้
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 สร้างอินเทอร์เฟซจากคอมโพเนนต์ แต่ละคอมโพเนนต์มีคลาสที่เก็บตรรกะ และเทมเพลตที่เก็บมาร์กอัป คอมโพเนนต์แบบ standalone การควบคุมโฟลว์ และ binding คือวิธีเขียนแบบปัจจุบัน
คอมโพเนนต์แบบ standalone
คอมโพเนนต์ standalone ระบุ import ของตัวเองและไม่ต้องใช้ NgModule สร้างด้วย ng generate component แล้วใช้ selector ในเทมเพลตอื่นเพื่อแสดงผล
Binding
interpolation แสดงค่าด้วยวงเล็บปีกกาคู่ property binding ตั้งค่า property ของ DOM ด้วยวงเล็บเหลี่ยม และ event binding เรียกเมธอดด้วยวงเล็บกลม การผูกสองทางทำได้ แต่การไหลของข้อมูลทางเดียวดีบักง่ายกว่า
การควบคุมโฟลว์
ใช้ @if แสดงเนื้อหาตามเงื่อนไข @for พร้อม track expression เพื่อวนรายการ และ @switch เพื่อเลือกมุมมองหนึ่งจากหลายแบบ การ track ด้วย id ทำให้หน้าเว็บมีประสิทธิภาพเมื่อรายการเปลี่ยน
ตัวอย่างที่ 55: คอมโพเนนต์ Angular แบบ standalone
คอมโพเนนต์ที่ไม่ต้องใช้ 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: data binding ในเทมเพลต
แสดงค่า ผูกแอตทริบิวต์ และรับการคลิก
<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: input และ output
ส่งข้อมูลเข้าคอมโพเนนต์ลูก และส่งเหตุการณ์กลับขึ้นไป
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: เริ่มแอป standalone
ตั้งค่า router และ 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 ทำให้คอมโพเนนต์เล็กลง router จับคู่ URL กับคอมโพเนนต์ guard ป้องกันหน้า และการโหลดแบบ lazy ทำให้การดาวน์โหลดครั้งแรกเล็กลง
เซอร์วิสที่ inject ได้และ HttpClient
กำหนด providedIn เป็น root ให้เซอร์วิส เพื่อให้ Angular สร้างอินสแตนซ์เดียวสำหรับแอป inject HttpClient และคืน Observable ที่มีชนิดตามอินเทอร์เฟซของการตอบสนอง คอมไพเลอร์จึงตรวจฟิลด์ที่คุณใช้
เส้นทางและพารามิเตอร์
กำหนดเส้นทางเป็น array ของคู่ path และ component อ่านพารามิเตอร์จาก ActivatedRoute และเพิ่มเส้นทาง wildcard ไว้ท้ายสุดเพื่อแสดงหน้าไม่พบ
guard และการโหลดแบบ lazy
guard แบบ CanActivate คืน true หรือการเปลี่ยนเส้นทาง เส้นทางแบบ lazy ใช้ loadComponent กับ dynamic import ทำให้โค้ดของผู้ดูแลถูกดาวน์โหลดเมื่อผู้ใช้เปิดเท่านั้น
ตัวอย่างที่ 61: เซอร์วิสที่ inject ได้
แชร์สถานะระหว่างคอมโพเนนต์ด้วยอินสแตนซ์เดียว
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: เรียก API ด้วย HttpClient
คำขอที่มีชนิดข้อมูลและคืนค่าเป็น 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: route guard
ส่งผู้ใช้ที่ยังไม่เข้าสู่ระบบไปหน้า login
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
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: lazy-load ฟีเจอร์
ดาวน์โหลดโค้ดของส่วนผู้ดูแลเมื่อจำเป็นเท่านั้น
export const routes: Routes = [
{
path: "admin",
canActivate: [authGuard],
loadComponent: () => import("./admin/admin.component").then((m) => m.AdminComponent),
},
];
*ตัวอย่างนี้ยังไม่ได้รันที่นี่ เพราะเครื่องที่เตรียมบทความไม่มี MySQL หรือ Angular CLI*
หัวข้อที่ 10: ฟอร์มและการตรวจสอบ
ฟอร์มรวบรวมข้อมูลที่ API จะเก็บ reactive form เก็บโมเดลไว้ใน TypeScript ทำให้การตรวจสอบและการทดสอบตรงไปตรงมา และทำให้เทมเพลตเรียบง่าย
reactive form
สร้างฟอร์มด้วย FormBuilder อธิบายแต่ละฟิลด์พร้อมค่าเริ่มต้นและ validator แล้วผูกเทมเพลตด้วย formGroup และ formControlName ค่าของฟอร์มมีชนิดข้อมูล จึงตรวจโค้ดตอนส่งได้จากคอมไพเลอร์
validator แบบกำหนดเองและแบบ async
validator คือฟังก์ชันที่คืน null เมื่อค่าถูกต้อง หรือคืนออบเจกต์ที่อธิบายข้อผิดพลาด เขียนเองสำหรับกฎที่ validator มาตรฐานไม่ครอบคลุม เช่น ค่าที่มีแต่ช่องว่าง
แสดงข้อผิดพลาดและส่งข้อมูล
แสดงข้อความหลังผู้ใช้แตะฟิลด์แล้วเท่านั้น เพื่อไม่ให้ฟอร์มเริ่มในสถานะผิดพลาด เมื่อเซิร์ฟเวอร์คืนข้อผิดพลาดการตรวจสอบ ให้แสดงไว้ใกล้ฟอร์มแทนการแสดงกล่องแจ้งเตือนทั่วไป
ตัวอย่างที่ 67: reactive form
สร้างฟอร์มใน TypeScript พร้อม validator
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: validator แบบกำหนดเอง
ปฏิเสธค่าที่มีแต่ช่องว่าง
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 และ Interceptor
ข้อมูลเปลี่ยนไปตามเวลา และคำขอจำนวนมากต้องประสานกัน Observable และ signal อธิบายการเปลี่ยนแปลงนั้น ส่วน interceptor เพิ่มพฤติกรรมที่ใช้กับทุกคำขอ เช่น โทเค็นและการบันทึกข้อผิดพลาด
Observable และ async pipe
Observable ปล่อยค่าตามเวลา async pipe สมัครรับค่าในเทมเพลตและยกเลิกเองอัตโนมัติ จึงหลีกเลี่ยงหน่วยความจำรั่วและโค้ดการสมัครรับที่ต้องเขียนเอง
signal สำหรับสถานะภายใน
signal เก็บค่าที่แจ้งเทมเพลตเมื่อมีการเปลี่ยนแปลง computed คำนวณค่าจาก signal และ effect รันโค้ดเมื่อค่าเปลี่ยน เหมาะกับสถานะง่ายๆ เช่นตัวนับหรือยอดรวมตะกร้า
Interceptor
HTTP interceptor เห็นทุกคำขอและการตอบสนอง ใช้ตัวหนึ่งแนบ header Authorization และอีกตัวบันทึกความล้มเหลว หรือพาไปหน้าเข้าสู่ระบบเมื่อโทเค็นหมดอายุ
ตัวอย่างที่ 73: Observable และ async pipe
แสดงรายการโดยไม่ต้อง subscribe เอง
@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: interceptor ที่เพิ่ม token
แนบ header 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: interceptor จัดการข้อผิดพลาดแบบรวม
บันทึกความล้มเหลวของ API ไว้ในที่เดียว
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: store ขนาดเล็กด้วย signal
เก็บสถานะตะกร้าไว้ในเซอร์วิสเดียว
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: ยกเลิกการ subscribe อัตโนมัติ
หยุดสตรีมเมื่อคอมโพเนนต์ถูกทำลาย
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: การเชื่อมต่อทั้งระบบ
จุดที่โปรเจกต์จำนวนมากพังคือตอนเชื่อมส่วนต่างๆ เข้าด้วยกัน proxy ระหว่างพัฒนา ไฟล์ environment สำหรับแต่ละการ deploy และ type ที่ใช้ร่วมกันช่วยให้หน้าเว็บและ API ไปในทิศทางเดียวกัน
proxy ระหว่างพัฒนา
ส่ง /api จากเซิร์ฟเวอร์พัฒนาของ Angular ไปยังเซิร์ฟเวอร์ Node.js เบราว์เซอร์จะเห็นต้นทางเดียว จึงไม่ต้องตั้งค่า CORS ระหว่างพัฒนา หลังแก้ไฟล์ proxy ให้เริ่ม ng serve ใหม่
ไฟล์ environment
ไฟล์ environment ของ Angular ให้แต่ละ build มีการตั้งค่าของตัวเอง เช่นที่อยู่ของ API เก็บที่อยู่โปรดักชันให้พ้นจากซอร์สหากเป็นความลับ และอ่านจากการตั้งค่า build แทน
type ที่ใช้ร่วมกัน
อธิบายโครงสร้าง Product ครั้งเดียวในโฟลเดอร์ที่ใช้ร่วมกัน แล้ว import ทั้งใน API และหน้าเว็บ เมื่อเปลี่ยนชื่อฟิลด์ที่ฝั่งหนึ่ง อีกฝั่งจะแสดงข้อผิดพลาดของคอมไพเลอร์ แทนที่จะเป็นบั๊กตอนรัน
ตัวอย่างที่ 79: proxy คำขอ API ระหว่างพัฒนา
ส่ง /api ไปยังเซิร์ฟเวอร์ Node.js
{
"/api": {
"target": "http://localhost:3000",
"secure": false,
"changeOrigin": true
}
}
*ตัวอย่างนี้ยังไม่ได้รันที่นี่ เพราะเครื่องที่เตรียมบทความไม่มี MySQL หรือ Angular CLI*
ตัวอย่างที่ 80: ไฟล์ environment
ใช้ที่อยู่ 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: สร้าง URL ของ API
รวมที่อยู่หลักกับ path ไว้ที่เดียว
import { environment } from "../environments/environment";
export const api = (path: string) => `${environment.apiBaseUrl}${path}`;
*ตัวอย่างนี้ยังไม่ได้รันที่นี่ เพราะเครื่องที่เตรียมบทความไม่มี MySQL หรือ Angular CLI*
ตัวอย่างที่ 82: รัน API และหน้าเว็บพร้อมกัน
เริ่มเซิร์ฟเวอร์ทั้งสองในสองเทอร์มินัล
# 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: ตรวจสุขภาพ API จาก Angular
แสดงว่าแบ็กเอนด์เข้าถึงได้หรือไม่
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 ตรวจผลลัพธ์ ทดสอบทั้งกรณีสำเร็จและอย่างน้อยหนึ่งกรณีที่ล้มเหลว เช่นไม่มีโทเค็นหรือราคาไม่ถูกต้อง
การทดสอบ Angular
TestBed สร้างคอมโพเนนต์พร้อมตัวพึ่งพา HttpTestingController ตอบการเรียก HTTP ด้วยข้อมูลจำลอง จึงทดสอบคอมโพเนนต์ได้โดยไม่ต้องรัน API
การทดสอบการทำงานร่วมและ coverage
รันการทดสอบแบบ integration กับฐานข้อมูลทดสอบที่รีเซ็ตก่อนทุกการรัน เพื่อไม่ให้การทดสอบแก้ข้อมูลจริง coverage แสดงโค้ดที่ไม่มีการทดสอบใดเข้าถึง ซึ่งเป็นจุดเริ่มต้น ไม่ใช่การรับประกัน
ตัวอย่างที่ 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: จำลอง HTTP ด้วย HttpTestingController
ตอบคำขอโดยไม่ต้องใช้เซิร์ฟเวอร์จริง
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: รายงาน coverage
วัดว่าการทดสอบครอบคลุมโค้ดมากเพียงใด
cd api && npx jest --coverage
cd ../web && ng test --code-coverage --watch=false
*ตัวอย่างนี้ยังไม่ได้รันที่นี่ เพราะเครื่องที่เตรียมบทความไม่มี MySQL หรือ Angular CLI*
หัวข้อที่ 14: Docker และการ deploy
คอนเทนเนอร์บรรจุแต่ละส่วนพร้อม dependency ที่แน่นอน Docker Compose เริ่มฐานข้อมูล API และเว็บเซิร์ฟเวอร์พร้อมกัน และอิมเมจชุดเดียวกันรันได้ทั้งบนแล็ปท็อป เซิร์ฟเวอร์ทดสอบ หรือบริการคลาวด์
อิมเมจของ API และเว็บแอป
ใช้ multi-stage build ติดตั้งและ build ในสเตจหนึ่ง แล้วคัดลอกเฉพาะสิ่งที่อิมเมจสุดท้ายต้องใช้ อิมเมจ API รันด้วยผู้ใช้ที่ไม่ใช่ root และอิมเมจ Angular มีเพียงไฟล์ที่ build แล้วซึ่ง Nginx เสิร์ฟ
Compose และการเริ่มฐานข้อมูล
Compose เริ่ม MySQL ก่อนแล้วรอให้ health check ผ่านจึงเริ่ม API ใส่สคริปต์ schema ในโฟลเดอร์ init ซึ่ง MySQL รันเฉพาะตอนที่ volume ของข้อมูลว่างเปล่า
reverse proxy
Nginx เสิร์ฟไฟล์ของ Angular ส่งต่อ /api ไปยังคอนเทนเนอร์ API และจัดการกฎ try_files ที่ทำให้เส้นทางฝั่งไคลเอนต์ยังอยู่ได้เมื่อรีเฟรชหน้า
ตัวอย่างที่ 91: Dockerfile สำหรับ API
อิมเมจโปรดักชันขนาดเล็กที่รันด้วยผู้ใช้ที่ไม่ใช่ 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: Dockerfile สำหรับแอป Angular
build แอปและเสิร์ฟด้วย 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: Compose ที่มี MySQL API และเว็บ
เริ่มทั้งระบบด้วยคำสั่งเดียว
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: รัน schema ตอนเริ่มครั้งแรก
สร้างตารางอัตโนมัติใน volume ใหม่
# 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: การดำเนินงานบนโปรดักชัน
การปล่อยเวอร์ชันยังไม่เสร็จเมื่อรันได้ครั้งเดียว โปรดักชันต้องการการตั้งค่าที่ปลอดภัย log ที่มีโครงสร้าง การสำรองข้อมูล pipeline ของ CI และเช็กลิสต์ที่หยุดการปล่อยที่ไม่ดี
การตั้งค่าที่ปลอดภัย
ปฏิเสธการเริ่มทำงานเมื่อไม่มีคีย์สำหรับลงนาม เชื่อ header ของพร็อกซีเฉพาะเมื่อมีพร็อกซีอยู่ข้างหน้า และปิด header ของเฟรมเวิร์กที่เผยเซิร์ฟเวอร์
log และการสำรองข้อมูล
เขียน log เป็น JSON ที่มี request id เพื่อตามปัญหาเดียวข้ามทั้งระบบได้ สำรองฐานข้อมูลด้วย mysqldump พร้อม --single-transaction และทดสอบการกู้คืนบนเครื่องอื่น
pipeline และเช็กลิสต์ก่อนปล่อย
workflow ของ CI รันการทดสอบ API การทดสอบ Angular และการ build โปรดักชันทุกครั้งที่ push สคริปต์ปล่อยเวอร์ชันตรวจ endpoint สุขภาพและคอนเทนเนอร์ก่อนประกาศเวอร์ชันใหม่
ตัวอย่างที่ 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: reverse proxy ด้วย 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: log แบบมีโครงสร้างด้วย pino
เขียน log เป็น 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
สร้าง dump ที่สอดคล้องกันและบีบอัด
mysqldump -h db -u shop -p shop --single-transaction > backup-$(date +%F).sql
gzip backup-$(date +%F).sql
*ตัวอย่างนี้ยังไม่ได้รันที่นี่ เพราะเครื่องที่เตรียมบทความไม่มี MySQL หรือ Angular CLI*
ตัวอย่างที่ 99: workflow CI บน GitHub Actions
ทดสอบและ build ทุกครั้งที่ push ไป 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*
แล็บ: สร้าง ทดสอบ และปล่อยร้านค้าขนาดเล็ก
รวมบทความเป็นโปรเจกต์เดียว: API ที่มีการเข้าสู่ระบบและเส้นทางสินค้า สคีมา MySQL พร้อมชั้นคิวรีที่ปลอดภัย หน้าสินค้า Angular พร้อมฟอร์ม reactive การทดสอบแต่ละชั้น และ Docker Compose
1. สร้างโฟลเดอร์ api และ web ติดตั้ง Express, mysql2, dotenv และ bcrypt ใน API แล้วสร้างแอป Angular พร้อมการกำหนดเส้นทาง
2. สร้างตาราง products และ users ใน MySQL ด้วยผู้ใช้ที่มีสิทธิ์จำกัด แล้วเพิ่มสินค้าตัวอย่างบางรายการ
3. เขียนเส้นทางสินค้าโดยใช้ placeholder การตรวจสอบ และตัวจัดการข้อผิดพลาดกลาง แล้วทดสอบด้วย curl หรือ Supertest
4. เพิ่มการลงทะเบียนและเข้าสู่ระบบด้วยรหัสผ่านแฮชและ JWT แล้วป้องกันเส้นทางคำสั่งซื้อด้วย middleware
5. สร้างรายการสินค้า Angular ด้วย HttpClient เส้นทางรายละเอียดสินค้า และฟอร์ม reactive สำหรับเพิ่มสินค้า
6. เพิ่มไฟล์ proxy แล้วรัน API และเว็บแอปพร้อมกัน จากนั้นทดสอบทั้งกระบวนการในเบราว์เซอร์
7. เขียนการทดสอบ API หนึ่งรายการและการทดสอบคอมโพเนนต์หนึ่งรายการ รันให้ผ่าน แล้วเริ่มทั้งระบบด้วย docker compose up และรันเช็กลิสต์ก่อนปล่อยจากตัวอย่างที่ 100
ขั้นต่อไป
หลังแล็บ ให้อ่านเอกสารทางการของ Angular, Express และ MySQL เพิ่มการแบ่งหน้าและการค้นหาให้ร้านค้า และสร้างฟีเจอร์ที่สองตั้งแต่ต้นจนจบโดยไม่คัดลอกโค้ด เก็บตัวอย่างในรีโพซิทอรี และรันการทดสอบใหม่ทุกครั้งที่อัปเกรด dependency เพราะเฟรมเวิร์กเปลี่ยนไปในแต่ละเวอร์ชันหลัก
สรุป
แอป full-stack เชื่อมสามส่วนที่ต้องทำงานร่วมกัน คุณได้เรียนการตั้งค่าโปรเจกต์และเครื่องมือ เขียนโค้ด Node.js และ Express ออกแบบตาราง MySQL และคิวรีที่ปลอดภัย เชื่อม API ด้วย pool และ transaction ตรวจสอบข้อมูลและจัดการข้อผิดพลาด ป้องกันเส้นทางด้วยรหัสผ่านแฮชและโทเค็น สร้างคอมโพเนนต์ เซอร์วิส เส้นทาง ฟอร์ม และ interceptor ของ Angular เชื่อมหน้าเว็บกับ API ทดสอบทุกชั้น บรรจุระบบด้วย Docker และเตรียมพร้อมสำหรับโปรดักชัน