這篇文章會提一些這次寫後端的一些感想,最後同場加映一個用 Promise 處理登入流程的狀況題。
這是我第二次寫後端,但畢竟當前目標是走前端,所以對後端只了解到「反正只要可以在 heroku 上動」就好的程度。即使是這樣,還是會遇到一些問題,而且是兩次開發都遇到,於是想把這些問題以及當前的對策整理下來,下次就可以沿用。
流程分層與分工
這或許是個共通的程式問題。
我是從 MDN 開始學到一個後端的架構。裏頭教的概念應該就是 MVC 的架構
- M: Model 對資料庫API
- V: View 前端渲染的部分,這次是 SPA ,所以沒處理這部分
- C: Controll 溝通協調 M V,做各種事
我很乖的按照教學範例把功能分檔案,放到不同資料夾中。可是不夠。當要處理的功能變多時,就會發現所有功能的 Code 全部擠在 Controller 的函式裡面,即使你按照流程步驟切成幾個函式也沒用。
我這次有兩塊主要功能在 Controller 上
- 資料庫 CRUD
- 對 Pocket API 請求資料
後來我將這兩個功能全部寫在獨立物件上,讓他們就負責處理該功能的流程、邏輯,最後用 Promise 丟資料給我就好。而 Controller 上就會只剩下
1 2 3
| Pocket.getDatas() .then(............)
|
而不是剩下:
1 2 3 4 5
| fetch(pocketApi,options) .then(response => { return response.ok ? response.json() : Promise.reject("API request error") }).catch(err => Promise.reject(err) ) .then(.............)
|
所以,兩個主要物件處理兩大主要功能。
Controller 函式則是負責調度這兩大主要物件,處理他們帶回來的資料,分配以及回應前端。
換句話說,他本身很像一個小主管,他只管高階一點的流程處理。
而兩大主要物件則是比較低階的現場人員,他們只管他們現場的流程,最後把結果報回來就好。
我發覺這種模式對我很有用,至今用了兩三次吧@@
下次我可能會想先從這個模式當切入點去思考我要怎麼寫。
幫 Session 建立狀態
處理登入流程的時候,會是以下的狀況(以這次為例):
- 使用者按下登入
- 伺服器幫 session 加入 request_token ( session 新增資料 )
- 將使用者導向到 Pocket API 登入頁面認證
- 使用者被導向回來,伺服器對 Pocket API 確認使用者是否同意存取
- 伺服器對 session 加入 access_token 、 username ( 更新 session )
- 伺服器查詢使用者是否已有帳號,若有,則將必要資料新增到 session (更新 session)
在 express 中新增 session 很容易的
1
| req.session.request_token = "xxxxxx"
|
然後,如果要判斷他是否在登入狀態,可以
1 2 3
| if (req.session.access_token && req.session.username){ }
|
不知道會不會有人跟我一樣強迫症覺得這段 code 不好讀,而且可能會有些問題。如果只是在必須要登入的頁面,可以寫個路由用的函式判斷,可能還夠用。但如果是在登入程序中,要處理的可能有多種身分,這時候如果用上面的寫法,Code 一樣會變得不好看。
所以這次我寫了個這樣的東西:
1 2 3 4 5 6 7 8 9 10
| function getSessionStatus(session){ return !session.request_token ? "noState" :!session.access_token ? "waitAuthorizeApp" :!session.setting ? "needAuthenticateAccToken" : "Logged in" }
if (getSessionStatus(req.session) == "Logged in") { }
|
根據 session 持有的資料判斷他在哪個狀態,把這個邏輯寫進一個函式。
這樣應該……會比較好讀一點。
此外還有個問題就是「任何地方都可以加入 session」。
只要想,req.session.xxx = 'xxx'
就可以新增資料。
這句 code 可能藏在 controller 任何一個函式裡面,如果只是眼睛掃過去是看不出來的。而 session 畢竟是個滿重要的東西,所以當他改變的時候,會強迫症的希望改變他的動作明顯一點。
但目前還沒有比較好的做法。
錯誤處理
這個有點難,我想不到好的處理方法。大概有幾個問題:
- 回傳 狀態碼 & 錯誤訊息 給請求端
- bug & Error 處理
bug & Error 處理
尤其改用 Promise 之後的某個困擾,就是如果我不好好處理 catch 的部分,他就會只跳 Unhandle Promise error
,搞不好還不會停下來 orz。
關於第二個問題,有時候在出現異常狀況(bug & Error)的時候,必須要去找原因。如果 console 沒有跳錯誤就更煎熬了,我可能要跑到每個區塊去,都夾個一句 console.log
確認每一個步驟的結果,找到之後又要把 console.log
全刪掉。
你告訴我這是什麼地獄…… 。゚ヽ(゚´Д`)ノ゚。,為什麼我要重複這無謂的動作?
可能會有「其他函式的實作沒有做到可以信任的程度」之類的說法,但除非真的超強,不然跳 bug 跟 error ,然後追原因解決,100 % 會發生。難道每次都要笨笨的插 log 嗎?இдஇ
這個等我長大一點再來解決好了……………
回傳 狀態碼 & 錯誤訊息 給請求端
第一個問題我第一次實作他,尤其這次要實作一個 API 給前端 Ajax,你總要告訴人家
「你成功了喔~ 200 ~」
或是
「你寫這什麼鬼東西? 500啦!」
這樣大家都比較好寫。這次剛好有跟 Pocket API 互動,在他的說明文件上,也會很明確地告訴你 : statusCode 400
及 Headers : x-error = 'xxx'
代表什麼意思,你才知道為什麼錯。所以還是寫一下吧。
比如下面這樣
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| .then(result => res.status(200).sendJson(result)) .catch(err => responseByUserError(res,err) )
function responseByUserError(res,err){ console.trace("handle err:" + err) switch (err) { case this.ERROR.noDoc: res.status(403).send("Can't find user.") break; case this.ERROR.articleNotExist: res.status(400).send("Article not exist in records.") break; case this.ERROR.validation: res.status(400).send("format error") break; case this.ERROR.unknown: default : res.status(500).send("There is an unknown error occur.") break; } }
|
其實 express 有預設的錯誤處理機制,比如發生錯誤時會丟個頁面回去。
如何有機會再寫一次這種東西,應該來想想怎麼善用這個機制orz。
同場加映:Promise 處理登入流程
哈哈哈,來分享一個這次的登入流程
登入流程:
- 使用者按下登入
- 伺服器幫 session 加入 request_token ( session 新增 request_token )
- 將使用者導向到 Pocket API 登入頁面認證
- 使用者被導向回來,伺服器對 Pocket API 確認使用者是否同意存取
- 伺服器對 session 加入 access_token 、 username ( 更新 session access_token )
- 伺服器查詢使用者是否已有帳號,若有,則將必要資料新增到 session (更新 session setting)
- 登入成功
從 4 開始進入以下判斷:
根據 session 上的資料判斷要做出什麼處理,如果失敗的話,就要把請求端踢回 login 頁面,並且要告知錯誤訊息。
第一次進入的使用者如果誤入這個路由,會沒有 request_token,要導向回 login 去拿 request_token。
使用者有 request_token 的情況,有可能還沒授權,這情況 token 是無效的,會在請求 access_token 時失敗。把他導回 login 去。
使用者如果曾經登入成功,他就會有access_token,所以可以跳過 「有沒有 req_token?」。
如果發生錯誤的話要回傳相對的錯誤訊息
主要使用 Promise 處理。如果是你的話會怎麼寫?
開始使用 Promise 之後,以為我從此從 callback hell 逃出了,code 變好看了~~ 但實際上走到中段的時候,我的 code 長下面這樣
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
| function loginProcess1(req,res,next){ const hasReqToken = Boolean(req.session.request_token) Promise.resolve(hasReqToken ? true : Promise.reject("User unAuthorize")) .then(() => req.session.access_token ? true : Pocket.getAccessToken(req.session.request_token) .then(response => { req.session.access_token = response.access_token req.session.username = response.username return true }) .catch(err => Promise.reject("Request_token expired, please try again.")) ) .then(() => { return Pocket.authenticate(req.session.access_token) .catch(err => Promise.reject("Access_token invalid, user might disable app authorize once.")) }) .then(() => { CustomModel.getUserByName(req.session.username) .then(doc => doc) .catch(() => { const user = new User({}) return user.initUser(req.session.username) }) .then(doc => { req.session.setting = { accomplishTag : doc.viewer.accomplishTag } }) .catch(err => Promise.reject("error")) }) .then(() => { console.log("Login successfully. direct to /app") next() }) .catch(err =>{ res.redirect("/login"); }) }
|
好像還是很難讀。尤其是很難一看就看出流程,腦袋必須要在 Promise 的連鎖中繞來繞去的,才能判斷下一步會接到哪裡,傳了什麼資料…。
最後我用了另一個版本。
我一直想,這東西能不能用 switch 處理? switch 適合寫這種樹枝狀的判斷嗎?switch 到底適合處理什麼邏輯狀況?
前面有提到,並不是每一個請求都要從 「是否有 request_token」 這個步驟開始,已經登入過的使用者會有 access_token,他可以直接跳到「是否有 access_token」的問題。甚至是這兩個都沒有,直接踢出去的請求也有。
所以進入者可以分成三種狀態:
- 甚麼都沒有 (noState)
- 有 req token ,沒有 acc token (wait Authorize App)
- 兩個都有 (need Authenticate Acc Token)
根據他的狀態,決定他要從哪個流程開始:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
|
function loginProcess(req,res,next) { const sessionState = Custom.getSessionStatus(req.session); let process = Promise.resolve();
switch (sessionState) { case "noState" : process = Promise.reject("User unAuthorize"); break; case "waitAuthorizeApp" : process = process.then(()=>{ return Pocket.getAccessToken(req.session.request_token) .then(response => { req.session.access_token = response.access_token req.session.username = response.username return true }).catch(err => Promise.reject("Request_token expired, please try again.")) }) case "needAuthenticateAccToken": process = process.then(()=>{ return Pocket.authenticate(req.session.access_token) .catch(err => Promise.reject("Access_token invalid, user might disable app authorize once.")) }) } const fullProcess = process.then(()=>{ return CustomModel.getUserByName(req.session.username) .then(doc => doc) .catch(() => { const user = new User({}) return user.initUser(req.session.username) }) .then(doc => { req.session.setting = {} req.session.setting.accomplishTag = doc.viewer.accomplishTag ; }) .catch(()=> Promise.reject("Can not establish user document. Database unknown error.")) }).then(()=>{ console.log("Login successfully. direct to /app") next() })
fullProcess.catch(err =>{ Custom.resetSession(req.session) res.redirect("/login"); }) }
|
根據狀態決定從哪個區塊(case)的流程開始,因為有些狀態接下來的流程是共通的,所以這邊利用到了 switch case 不加 break 就會繼續執行下個區塊的特性。
我覺得這樣寫可以呈現這個流程的特性,也就是不同狀態的進入者可以只做一部分流程就好。除此之外在switch後面的部分可以凸顯出是他們共通的部分,以及共同的錯誤處理 「回去 /login」。
大概是這樣。
這是第二次處理登入流程了,第一次的狀況還更複雜 orz。因為這次的處理結果滿奇怪的,所以我想記錄下來。
結尾
以上記錄一些這次後端遇到的一些坑以及狀況。是小弟一點不成熟的淺見啦,拜託大大鞭我的時候輕一點 QAQ。
2020/4/24 追加 : 使用 async/await
感謝 王澍 的留言建議,使用 async/await 去處理,的確是會變得再舒服點:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| async function loginProcess(req,res,next) { const sessionState = Custom.getSessionStatus(req.session); try { switch (sessionState) { case "noState" : await Promise.reject("User unAuthorize"); break; case "waitAuthorizeApp" : var pocketRes = await Pocket.getAccessToken(req.session.request_token) .catch(err => Promise.reject("Request_token expired, please try again.")) req.session.access_token = pocketRes.access_token req.session.username = pocketRes.username case "needAuthenticateAccToken": await Pocket.authenticate(req.session.access_token) .catch(err => Promise.reject("Access_token invalid, user might disable app authorize once.")) } const userDoc = await CustomModel.getUserByName(req.session.username) .catch(() => { const user = new User({}) return user.initUser(req.session.username) }).catch(()=> Promise.reject("Can not establish user document. Database unknown error.")) req.session.setting = {}; req.session.setting.accomplishTag = userDoc.viewer.accomplishTag ; console.log("Login successfully. direct to /app") next() }catch(e){ console.log(e) Custom.resetSession(req.session); res.redirect("/login");; } }
|
如果把上述的 noState
的部分之外的 Promise.reject
交給各自的物件去處理的話,這個函式還能再弄得更乾淨一點。