0%

pocket-random 開發紀錄 - 4. 寫後端的小感想

這篇文章會提一些這次寫後端的一些感想,最後同場加映一個用 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 建立狀態

處理登入流程的時候,會是以下的狀況(以這次為例):

  1. 使用者按下登入
  2. 伺服器幫 session 加入 request_token ( session 新增資料 )
  3. 將使用者導向到 Pocket API 登入頁面認證
  4. 使用者被導向回來,伺服器對 Pocket API 確認使用者是否同意存取
  5. 伺服器對 session 加入 access_token 、 username ( 更新 session )
  6. 伺服器查詢使用者是否已有帳號,若有,則將必要資料新增到 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 畢竟是個滿重要的東西,所以當他改變的時候,會強迫症的希望改變他的動作明顯一點。
但目前還沒有比較好的做法。

錯誤處理

這個有點難,我想不到好的處理方法。大概有幾個問題:

  1. 回傳 狀態碼 & 錯誤訊息 給請求端
  2. 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 400Headers : 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
//..........省略前面很多 Promise then
.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 處理登入流程

哈哈哈,來分享一個這次的登入流程

登入流程:

  1. 使用者按下登入
  2. 伺服器幫 session 加入 request_token ( session 新增 request_token )
  3. 將使用者導向到 Pocket API 登入頁面認證
  4. 使用者被導向回來,伺服器對 Pocket API 確認使用者是否同意存取
  5. 伺服器對 session 加入 access_token 、 username ( 更新 session access_token )
  6. 伺服器查詢使用者是否已有帳號,若有,則將必要資料新增到 session (更新 session setting)
  7. 登入成功

從 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)

// 1. 有沒有 request_token ?
Promise.resolve(hasReqToken ? true : Promise.reject("User unAuthorize"))
.then(() => req.session.access_token ? true
// 2. 有沒有 access_token ? 下一步 : 請求 access_token;
: Pocket.getAccessToken(req.session.request_token)
.then(response => {
// 2.5 取得 access_token 成功,下一步;
req.session.access_token = response.access_token
req.session.username = response.username
return true
})
// 2.75 取得 access_token 失敗,跳 catch 區塊;
.catch(err => Promise.reject("Request_token expired, please try again."))
)
.then(() => {
// 3. 驗證 access_token ? 下一步 : 跳 catch 區塊;
return Pocket.authenticate(req.session.access_token)
.catch(err => Promise.reject("Access_token invalid, user might disable app authorize once."))
})
.then(() => {
// 4. 有沒有帳號?;
CustomModel.getUserByName(req.session.username)
.then(doc => doc)
.catch(() => {
// 4.5 沒有帳號,創一個;
const user = new User({})
return user.initUser(req.session.username)
})
// 4.5 創帳號成功,下一步;
.then(doc => {
req.session.setting = {
accomplishTag : doc.viewer.accomplishTag
}
})
// 4.75 創帳號失敗,跳 catch 區塊;
.catch(err => Promise.reject("error"))
})
// 5. login 成功;
.then(() => {
console.log("Login successfully. direct to /app")
next()
})
// 5.5 login 失敗;
.catch(err =>{
res.redirect("/login");
})
}

好像還是很難讀。尤其是很難一看就看出流程,腦袋必須要在 Promise 的連鎖中繞來繞去的,才能判斷下一步會接到哪裡,傳了什麼資料…。

最後我用了另一個版本。

我一直想,這東西能不能用 switch 處理? switch 適合寫這種樹枝狀的判斷嗎?switch 到底適合處理什麼邏輯狀況?

前面有提到,並不是每一個請求都要從 「是否有 request_token」 這個步驟開始,已經登入過的使用者會有 access_token,他可以直接跳到「是否有 access_token」的問題。甚至是這兩個都沒有,直接踢出去的請求也有。

所以進入者可以分成三種狀態:

  1. 甚麼都沒有 (noState)
  2. 有 req token ,沒有 acc token (wait Authorize App)
  3. 兩個都有 (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."))
})// will continue to case 2
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 交給各自的物件去處理的話,這個函式還能再弄得更乾淨一點。