# 第八堂: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. 新增:教練技能資料表 ![截圖_2025-02-03_晚上11_10_09](https://hackmd.io/_uploads/Hkw92LAdJg.png) # 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 運用方式 ![week4-2025-02-03-075916](https://hackmd.io/_uploads/rknDvlRuke.svg) ## 常見 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)