Skip to content

Commit 9bc8168

Browse files
committed
新增 Node.js 檔案操作 mkdir 的正確姿勢學習筆記,說明競態條件問題及解決方案
1 parent 1f081c4 commit 9bc8168

File tree

1 file changed

+116
-0
lines changed

1 file changed

+116
-0
lines changed
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
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

Comments
 (0)