# 第八堂:Node.js NPM 整合 PostgreSQL
* [簡報](https://whimsical.com/node-js-npm-postgresql-8BoSvAUAKR147dRaF2r8JR)
* [本週任務](https://rpg.hexschool.com/#/training/12062543649527256883/board/content/12062543649527256884_12062543649527256902?tid=12062543649555005326)、[Repo](https://github.com/hexschool/node-training-postgresql/tree/main)
* [週日 21:30 回覆大家職涯問題](https://youtube.com/live/jifsrcJcUcc?feature=share)、[職涯列表](https://discord.com/channels/801807326054055996/1319120911596519455)
## 線上健身平台資源
### API 開發參考:
- API 應用可參考線稿圖([方案](https://miro.com/app/board/uXjVLbiDml0=/?moveToWidget=3458764602221823414&cot=14)、[專長](https://miro.com/app/board/uXjVLbiDml0=/?moveToWidget=3458764602220846241&cot=14))
- API 開發規格請依照 [此 API 文件](https://liberating-turtle-5a2.notion.site/38e4dd6775894e94a8f575f20ca5b867?v=17c6a246851881208205000cacf67220)
- db.js 欄位設計請依照 [此資料庫欄位圖片](https://firebasestorage.googleapis.com/v0/b/hexschool-courses.appspot.com/o/hex-website%2Fnode%2F1737597781474-week4.png?alt=media&token=13d61db4-15af-480a-9b3d-af9ce698bc5a) 定義
### 小班制健身直播課
- **小班制健身直播課制**:教練會在平台開課,學生前往教練開設的課程頁面報名,課程時間到了後,就可點擊課程直播室進行上課
- **堂數售價**:每位健身教練的收費都一致,一堂 50 分鐘**小班制健身直播課**都是 200 元
### 堂數組合包方案
- 7 堂組合包方案:
- 價格:1,400 元
- 14 堂方案:
- 價格:2,520 元
- 21 堂方案:
- 價格:4,800 元
### 資料表設計
1. 新增:堂數組合包方案資料表
2. 新增:教練技能資料表

# Node.js + TypeORM + Docker 教學
## 講解大綱
1. **TypeORM 講解**
2. **Entity 設計**
3. **什麼是 Repository?**
4. **基本 CRUD 操作示範**
5. **Node.js + Docker + PostgreSQL 整合教學**
---
## NPM 套件
- **dotenv**
用來載入 `.env` 的環境變數。
- **pg**
PostgreSQL 在 Node.js 的官方用戶端 (client)。
- **typeorm**
支援多種 SQL 資料庫的 ORM,用物件的方式處理資料,不用直接撰寫 SQL。
---
# dotenv 使用教學
---
## 1. 安裝
```bash
npm install dotenv
```
---
## 2. 建立 `.env` 檔案
在專案根目錄下建立 `.env` 檔案,並在其中設定環境變數,例如:
```bash
# .env
PORT=3000
DB_USER=myuser
DB_PASSWORD=secret123
```
> **注意**:請在 `.gitignore` 中忽略 `.env` 檔,避免敏感資訊外洩
---
## 3. 在程式碼中使用
在要執行的 node 中:
```js
// server.js
require('dotenv').config(); // 讓程式可讀取 .env
// 讀取環境變數
const port = process.env.PORT || 3000;
const dbUser = process.env.DB_USER;
const dbPassword = process.env.DB_PASSWORD;
console.log('Server Port:', port);
console.log('DB User:', dbUser);
console.log('DB Password:', dbPassword);
// 其他程式邏輯...
```
---
## 4. 注意事項
1. **請確保程式一開始就執行 `require('dotenv').config()`**,不然後面取用 `process.env` 時讀不到變數
2. **在正式上線環境(Production)**,建議在主機或佈署平台上直接設定環境變數
3. **保護敏感資訊**:務必把 `.env` 加入 `.gitignore`,以防上傳到公開的 Git 儲存庫。
---
## 環境安裝、NPM 指令
* 先 fork [repo](https://github.com/hexschool/node-training-postgresql/tree/main) 後,再 clone 下來
* `npm install`:安裝套件
* 檢視 `.env` 設定,調整為 `localhost`
* `npm run start`: 運作 Docker,將資料庫環境在本地端運作
* `npm run dev`:開啟 Node 應用程式
* 使用 `DBeaver` 觀看資料庫狀態
* Host:localhost
* Database:test
* DB_USERNAME:testHexschool
* DB_PASSWORD:pgStartkit4test
## 介紹本課程架構圖,Docker 運用方式

## 常見 TypeORM 用法與指令
* [簡報](https://whimsical.com/node-js-npm-postgresql-8BoSvAUAKR147dRaF2r8JR)
### 1. 連線語法
在 `.env` 中設定:
```
DB_HOST=localhost
DB_PORT=5432
POSTGRES_USER=test
POSTGRES_PASSWORD=test
POSTGRES_DB=testDb
PORT=3000
```
在 `db.js` 透過 **TypeORM** 初始化 `DataSource`:
```js
// db.js
require("dotenv").config();
const { DataSource } = require("typeorm");
const AppDataSource = new DataSource({
type: "postgres",
host: process.env.DB_HOST,
port: process.env.DB_PORT,
username: process.env.POSTGRES_USER,
password: process.env.POSTGRES_PASSWORD,
database: process.env.POSTGRES_DB,
entities: [ /* 你的 Entity */ ],
synchronize: true, // 開發階段可先設 true
});
module.exports = AppDataSource;
```
> `synchronize: true` 在程式啟動時會自動依據 Entities 建立/更新資料表,開發很方便,但正式上線時會使用 false
---
### 2. 建立 Entity
在 TypeORM 中,**Entity** 代表資料表的定義。
```js
// db.js
const { DataSource, EntitySchema } = require("typeorm")
const CreditPackage = new EntitySchema({
name: "CreditPackage",
tableName: "CREDIT_PACKAGE",
columns: {
id: {
primary: true,
type: "uuid",
generated: "uuid",
nullable: false
},
name: {
type: "varchar",
length: 50,
nullable: false,
unique: true
},
credit_amount: {
type: "integer",
nullable: false
},
price: {
type: "numeric",
precision: 10,
scale: 2,
nullable: false
},
createdAt: {
type: "timestamp",
createDate: true,
name: "created_at",
nullable: false
}
}
})
const Skill = new EntitySchema({
name: "Skill",
tableName: "SKILL",
columns: {
id: {
primary: true,
type: "uuid",
generated: "uuid",
nullable: false
},
name: {
type: "varchar",
length: 50,
nullable: false,
unique: true
},
createdAt: {
type: "timestamp",
createDate: true,
name: "created_at",
nullable: false
}
}
})
const AppDataSource = new DataSource({
type: "postgres",
host: process.env.DB_HOST || "localhost",
port: process.env.DB_PORT || 5432,
username: process.env.DB_USERNAME || "root",
password: process.env.DB_PASSWORD || "test",
database: process.env.DB_DATABASE || "test",
entities: [CreditPackage, Skill],
synchronize: true
})
// 透過 entities 陣列將所有 EntitySchema 加入。
// 啟動時 TypeORM 會根據這些設定自動建立或更新表結構(若 synchronize: true)。
// 之後就能使用 AppDataSource.getRepository("CreditPackage") 或 AppDataSource.getRepository("Skill") 進行 CRUD。
module.exports = AppDataSource
```
### **name vs. tableName**
• name:程式層級的 Entity 識別字,在 getRepository 時使用。
• tableName:資料庫實際表名,可以自行指定。
### **欄位設定**
• primary: 是否是主鍵
• type: 資料庫欄位型態,如 varchar, integer, numeric, timestamp, uuid 等
• length, precision, scale: 用於控制字串長度或數字型態的小數位數
• unique: 是否唯一值
• nullable: 能否為空
• generated: 自動生成方式(對 uuid、自動遞增、或其他生成策略)
• createDate, updateDate 等 TypeORM 內建欄位屬性,可以自動更新時間戳。
### **時間戳**
• createDate: true 當存一筆新資料時,此欄位會自動以當前時間填入
## Repository :取得 Entity 特定資料表
- 使用範例:
```js
const postRepo = AppDataSource.getRepository("Post");
// 取得操作 "Post" 這個 Entity (資料表) 的所有方法
```
- 有了 Repository 之後,就能使用 ``.create()``, ``.save()``, ``.find()``, ``.delete()``, ``.update()`` 等方法操作資料表
## TypeORM 基本 CRUD 操作示範
#### 新增資料
```js
const postRepo = AppDataSource.getRepository("Post");
const newPost = postRepo.create({ content: "你好,TypeORM" });
await postRepo.save(newPost);
```
#### 查詢資料
```js
const allPosts = await postRepo.find();
```
#### 刪除資料
```js
await postRepo.delete(postId);
```
#### 更新資料
```js
await postRepo.update(postId, { content: "新的內容" });
```
### server.js 完整範例程式碼
```=JavaScript
require("dotenv").config()
const http = require("http")
const AppDataSource = require("./db")
function isUndefined (value) {
return value === undefined
}
function isNotValidSting (value) {
return typeof value !== "string" || value.trim().length === 0 || value === ""
}
function isNotValidInteger (value) {
return typeof value !== "number" || value < 0 || value % 1 !== 0
}
const requestListener = async (req, res) => {
const headers = {
"Access-Control-Allow-Headers": "Content-Type, Authorization, Content-Length, X-Requested-With",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "PATCH, POST, GET,OPTIONS,DELETE",
"Content-Type": "application/json"
}
let body = ""
req.on("data", (chunk) => {
body += chunk
})
if (req.url === "/api/credit-package" && req.method === "GET") {
try {
const packages = await AppDataSource.getRepository("CreditPackage").find({
select: ["id", "name", "credit_amount", "price"]
})
res.writeHead(200, headers)
res.write(JSON.stringify({
status: "success",
data: packages
}))
res.end()
} catch (error) {
res.writeHead(500, headers)
res.write(JSON.stringify({
status: "error",
message: "伺服器錯誤"
}))
res.end()
}
} else if (req.url === "/api/credit-package" && req.method === "POST") {
req.on("end", async () => {
try {
const data = JSON.parse(body)
if (isUndefined(data.name) || isNotValidSting(data.name) ||
isUndefined(data.credit_amount) || isNotValidInteger(data.credit_amount) ||
isUndefined(data.price) || isNotValidInteger(data.price)) {
res.writeHead(400, headers)
res.write(JSON.stringify({
status: "failed",
message: "欄位未填寫正確"
}))
res.end()
return
}
const creditPackageRepo = await AppDataSource.getRepository("CreditPackage")
const existPackage = await creditPackageRepo.find({
where: {
name: data.name
}
})
if (existPackage.length > 0) {
res.writeHead(409, headers)
res.write(JSON.stringify({
status: "failed",
message: "資料重複"
}))
res.end()
return
}
const newPackage = await creditPackageRepo.create({
name: data.name,
credit_amount: data.credit_amount,
price: data.price
})
const result = await creditPackageRepo.save(newPackage)
res.writeHead(200, headers)
res.write(JSON.stringify({
status: "success",
data: result
}))
res.end()
} catch (error) {
console.error(error)
res.writeHead(500, headers)
res.write(JSON.stringify({
status: "error",
message: "伺服器錯誤"
}))
res.end()
}
})
} else if (req.url.startsWith("/api/credit-package/") && req.method === "DELETE") {
try {
const packageId = req.url.split("/").pop()
if (isUndefined(packageId) || isNotValidSting(packageId)) {
res.writeHead(400, headers)
res.write(JSON.stringify({
status: "failed",
message: "ID錯誤"
}))
res.end()
return
}
const result = await AppDataSource.getRepository("CreditPackage").delete(packageId)
if (result.affected === 0) {
res.writeHead(400, headers)
res.write(JSON.stringify({
status: "failed",
message: "ID錯誤"
}))
res.end()
return
}
res.writeHead(200, headers)
res.write(JSON.stringify({
status: "success"
}))
res.end()
} catch (error) {
console.error(error)
res.writeHead(500, headers)
res.write(JSON.stringify({
status: "error",
message: "伺服器錯誤"
}))
res.end()
}
} else if (req.method === "OPTIONS") {
res.writeHead(200, headers)
res.end()
} else {
res.writeHead(404, headers)
res.write(JSON.stringify({
status: "failed",
message: "無此網站路由"
}))
res.end()
}
}
const server = http.createServer(requestListener)
async function startServer () {
await AppDataSource.initialize()
console.log("資料庫連接成功")
server.listen(process.env.PORT)
console.log(`伺服器啟動成功, port: ${process.env.PORT}`)
return server;
}
module.exports = startServer();
```
## 主線任務
1. [任務連結](https://rpg.hexschool.com/#/training/12062543649527256883/board/content/12062543649527256884_12062543649527256902?tid=12062543649555005326)
## Node 專題班
1. [採能力適配分組的 Node 專題班](https://mailchi.mp/hexschool/1-0990sk33tg-17521395)