前後端可能發生的錯誤有千百種,為了方便維護,還有統一管理,我決定在 Isomorphic 的 Flux Store 中掛上 errors
陣列,無論是前端還是後端,發生了任何錯誤,只要 Dispatch Action 把錯誤送進 Reducer 集中處理即可。
為了讓錯誤處理更方便,我在 Express Server 上會搭配 2 個 Helpers 來運作,這些 Helpers 是在 src/server/middlewares/mountHelper.js 中掛在 res 物件上的,實際功能其實只是包裝 Dispatch Action 的動作。
res.pushError()
是用來把錯誤塞進 Error Queue 裡,Server 還不會送出 Response:
import Errors from './path/to/constants/Errors';
// ...
if (err) {
res.pushError(Errors.ODM_VALIDATION, err);
}
當所有錯誤都被 Push 進到 Error Queue,可以呼叫 res.errors()
送出 Response。
import Errors from './path/to/constants/Errors';
// ...
if (err) {
res.pushError(Errors.ODM_VALIDATION);
res.pushError(Errors.ODM_OPERATION_FAIL);
res.pushError(...);
return res.errors();
}
從這兩個例子中還可以看到,我們集中管理了 Errors
物件:
import ErrorCodes from './ErrorCodes';
export default {
// ...
[ErrorCodes.PERMISSION_DENIED]: {
code: ErrorCodes.PERMISSION_DENIED,
status: 403,
title: 'Permission Denied',
detail: 'You are not allowed to access the resource.',
},
// ...
};
前一篇教過各位以 Promise 的形式撰寫 API,但其實我刻意忽略了示範 API 的錯誤處理,怕混淆各位所以留到這篇說明。
API 的錯誤處理必須追朔到 ApiEngine 的撰寫,在 Expose 出來的 get
、post
、put
、del
四個 Methods 回傳值都是 Promise,所以我們便能自訂什麼情況下要觸發 Promise 的錯誤,我是統一在 API Response 中使用 errors
這個 key 來儲存錯誤陣列,所以 ApiEngine 只要偵測到 res.errors.length > 0
就代表收到的 Response 發生錯誤了。
class ApiEngine {
get(path, { params, data, files } = {}) => {
return new Promise((resolve, reject) => {
// ...
request.end((err, { body } = {}) => {
// Superagent 發生內部錯誤
if (err) {
return reject(body || err);
}
// API Server 回傳自訂錯誤
if (body.errors && body.errors.length > 0) {
return reject(body.errors);
}
return resolve(body);
});
});
}
// ...
}
了解 ApiEngine 的錯誤處理機制後,就可以搭配 pushError 把它套用在實際的 API 呼叫上了:
// Server 端用法
export default {
// ...
fetchTodo: (req, res, next) => {
todoAPI(req.store.getState().apiEngine)
.read(req.params.todoId)
.catch(() => {
res.pushError(Errors.STATE_PRE_FETCHING_FAIL, {
detail: 'Cannot read todo',
});
next();
})
.then((json) => {
req.store.dispatch(setTodo(json.todo));
next();
});
},
};
// Client 端用法
componentDidMount() {
let { dispatch, apiEngine } = this.props;
todoAPI(apiEngine)
.read(9527)
.catch((err) => {
dispatch(pushErrors(err));
// 這行是必要的,否則即使錯誤發生了,一樣會繼續執行 then 的部分
throw err;
})
.then((json) => {
dispatch(setTodo(json.todo));
});
}
接下來讓我們看看 Server-Only 的錯誤該怎麼處理。
第一種狀況是 Server Process 本身發生了沒有處理到的 Error,或是 Promise 中發生例外狀況,利用 Node 本身的語法就能處理掉了,通常是直接重啟 Server,這樣才能確保可靠度。
process.on('uncaughtException', (err) => {
process.exit(1);
});
process.on('unhandledRejection', (reason, p) => {
throw reason;
});
另一種狀況是錯誤只發生在某一個 Request 上,參考 Express 文件的 Error handling 寫法即可,另外,在 Boilerplate 中額外做了一件事情,我們把錯誤處理的行為區隔為 production
與 non-production
,production
的環境下最好不要暴露 Error Stack,規避安全議題;development
與 test
環境下為了 Debug,所以可以顯示 Error Stack。
app.use((err, req, res, next) => {
if (env !== 'production') {
res.status(500).send(`<pre>${err.stack}</pre>`);
} else {
res.status(500).send('Service Unavailable');
}
});
除了上述的幾種錯誤以外,Node 本身非同步的寫法必須經常處理 Callback 中的 Error,一般教學中都是直接 throw err:
doSomething((err, ...data) => {
if (err) {
throw err;
}
// ...
})
這般作法其實很粗糙,只是把 Error 送入前面提到的 Express Request Error
Handler 中集中處理,這對一個 API Server 而言是明顯不合格的,為了更方便維護,還有為了在 Response 中更明確的知道錯誤發生在哪個環節,我在 Boilerplate 實做了 handleError
這個模組。
用法如下,直接把 handleError 包住原本的 Callback Function,並且消耗掉 Callback 的第一個 err 參數,所以在 Callback 中就不用再處理錯誤了。
import { handleSomeError } from '../decorators/handleError';
doSomething(handleSomeError(res)((...data) => {
// ...
}))
使用上會把 res 物件傳遞進去,如此便能在錯誤發生時,透過 handleError 模組發送 Response。此模組廣泛用在 ODM 的操作上,請看以下例子:
import { handleDbError } from '../decorators/handleError';
export default {
list(req, res) {
User.paginate({ page: req.query.page }, handleDbError(res)((page) => {
User
.find({})
.limit(page.limit)
.skip(page.skip)
.exec(handleDbError(res)((users) => {
res.json({
users: users,
page: page,
});
}));
}));
},
};
每一次的 ODM 操作都可能發生錯誤,如果沒有透過 handleError
模組來簡化,程式碼的可讀性會非常差,那絕對會是一場災難。
Client-Only 的錯誤處理只需要延用 res.pushError 使用到的 Actions 即可,只是在 Client 端沒有提供 Helper,要自己 Dispatch Actions。範例其實在上面已經出現過了,請見 API 的錯誤處理
的 Client 端使用範例。
至於錯誤的顯示方式,每個開發者、每個應用都不相同,有的人習慣用 Bootstrap 的 Alert,有的人喜歡 Popup 提醒視窗,在 Boilerplate 中,我是用前者寫成一個 ErrorList
元件,在此不贅述了,請直接參考原始碼。