|
| 1 | +--- |
| 2 | +title: " [學習筆記] Node.js 檔案操作 mkdir 的正確姿勢" |
| 3 | +date: 2025/08/07 13:15:41 |
| 4 | +tags: |
| 5 | + - 學習筆記 |
| 6 | +--- |
| 7 | + |
| 8 | +## 前情提要 |
| 9 | + |
| 10 | +在 code review 中 RD 寫了以下的程式 |
| 11 | + |
| 12 | +```typescript |
| 13 | +if (!await this.exists(fullPath)) { |
| 14 | + await fs.promises.mkdir(fullPath, { recursive: true }) |
| 15 | +} |
| 16 | +``` |
| 17 | + |
| 18 | +看似合理的「檢查然後執行」(Check-Then-Act)模式可能導致的併發問題。 |
| 19 | + |
| 20 | +假設兩個請求同時上傳檔案到同一個不存在的目錄: |
| 21 | + |
| 22 | +```text |
| 23 | +時間線: |
| 24 | +T1: 請求A 執行 this.exists(fullPath) → 返回 false (目錄不存在) |
| 25 | +T2: 請求B 執行 this.exists(fullPath) → 返回 false (目錄不存在) |
| 26 | +T3: 請求A 執行 mkdir(fullPath) → 成功建立目錄 |
| 27 | +T4: 請求B 執行 mkdir(fullPath) → 可能拋出 EEXIST 錯誤 |
| 28 | +``` |
| 29 | + |
| 30 | +雖然使用了 `recursive: true`,但在某些作業系統或 Node.js 版本中,仍可能因為目錄已存在而拋出錯誤。 |
| 31 | + |
| 32 | +### 為什麼會這樣? |
| 33 | + |
| 34 | +問題的根源在於**時間窗口**。兩個步驟之間存在時間差,而這個時間差就是競態條件的溫床: |
| 35 | + |
| 36 | +```typescript |
| 37 | +// ❌ 有時間窗口的寫法 |
| 38 | +if (!await this.exists(fullPath)) { // 步驟1: 檢查 |
| 39 | + // 👆 這裡到下面之間就是危險的時間窗口 |
| 40 | + await fs.promises.mkdir(fullPath, { recursive: true }) // 步驟2: 執行 |
| 41 | +} |
| 42 | +``` |
| 43 | + |
| 44 | +## 解決方案 |
| 45 | + |
| 46 | +### 方法1: 直接使用 mkdir(推薦) |
| 47 | + |
| 48 | +```typescript |
| 49 | +async uploadFile(file: UploadedFile, pathToStore?: `/${string}`): Promise<string> { |
| 50 | + const fullPath = `${this.uploadDir}${pathToStore ?? ''}` |
| 51 | + // 直接建立目錄,recursive: true 會自動處理已存在的情況 |
| 52 | + await fs.promises.mkdir(fullPath, { recursive: true }) |
| 53 | + const uploadPath = path.join(fullPath, file.originalName) |
| 54 | + await fs.promises.writeFile(uploadPath, file.buffer) |
| 55 | + return uploadPath |
| 56 | +} |
| 57 | +``` |
| 58 | + |
| 59 | +## 為什麼 recursive: true 已經足夠 |
| 60 | + |
| 61 | +根據 Node.js 官方文件,`fs.promises.mkdir(path, { recursive: true })` 具有以下特性: |
| 62 | + |
| 63 | +1. **自動建立父目錄**:如果父目錄不存在會自動建立 |
| 64 | +2. **處理已存在目錄**:當 `recursive` 為 `true` 時,如果目錄已存在不會拋出錯誤 |
| 65 | +3. **簡化錯誤處理**:避免了手動檢查目錄是否存在的需要 |
| 66 | + |
| 67 | +官方範例: |
| 68 | + |
| 69 | +```javascript |
| 70 | +import { mkdir } from 'node:fs'; |
| 71 | + |
| 72 | +// Create ./tmp/a/apple, regardless of whether ./tmp and ./tmp/a exist. |
| 73 | +mkdir('./tmp/a/apple', { recursive: true }, (err) => { |
| 74 | + if (err) throw err; |
| 75 | +}); |
| 76 | + |
| 77 | +這就像餐廳點餐的差別: |
| 78 | + |
| 79 | +```typescript |
| 80 | +// ❌ 競態條件版本(不好的做法) |
| 81 | +if (餐廳沒有準備我要的餐) { // 檢查 |
| 82 | + 請廚師準備這道餐 // 執行 |
| 83 | +} |
| 84 | +// 問題:兩個客人可能同時檢查到「沒有」,然後都要求準備 |
| 85 | +
|
| 86 | +// ✅ 直接執行版本(好的做法) |
| 87 | +請廚師準備這道餐,如果已經有了就不用重複準備 |
| 88 | +// 廚師會自己判斷是否需要準備,避免重複工作 |
| 89 | +``` |
| 90 | + |
| 91 | +## 效能優勢 |
| 92 | + |
| 93 | +修復後還有意外的效能提升: |
| 94 | + |
| 95 | +```typescript |
| 96 | +// 修復前:2次系統調用 |
| 97 | +await this.exists(fullPath) // 系統調用1: stat() |
| 98 | +await fs.promises.mkdir() // 系統調用2: mkdir() |
| 99 | +
|
| 100 | +// 修復後:1次系統調用 |
| 101 | +await fs.promises.mkdir(fullPath, { recursive: true }) // 系統調用1: mkdir() |
| 102 | +``` |
| 103 | + |
| 104 | +## 經驗教訓 |
| 105 | + |
| 106 | +1. **避免 Check-Then-Act 模式**:這是併發程式設計的經典陷阱 |
| 107 | +2. **信任系統調用**:現代 API 通常已經考慮了併發場景 |
| 108 | +3. **簡單就是美**:移除不必要的檢查邏輯,程式碼更簡潔也更安全 |
| 109 | + |
| 110 | +## 小結 |
| 111 | + |
| 112 | +這次 code review 發現的問題提醒我們,併發問題往往藏在看似無害的程式碼中。「檢查然後執行」的模式在單執行緒環境下沒問題,但在併發環境下就是定時炸彈。 |
| 113 | + |
| 114 | +修復的核心思想很簡單:**信任系統調用,避免手動檢查**。Node.js 的 `mkdir({ recursive: true })` 就是為了處理這種情況而設計的,我們應該善用它。 |
| 115 | + |
| 116 | +(fin) |
0 commit comments