กำจัด Callback Hell ด้วย Promise และ Async/Await

Nuttavut Thongjor

สวัสดี... เรา Callback Hell เอง

ผมเชื่อว่าหลายคนอาจเคยตกอยู่ในสถานการณ์นี้ที่โค๊ด JavaScript ของคุณอุดมไปด้วย Callback ยิ่งหากเป็น Callback ซ้อนกันอยู่หลายๆชั้นเช่นโค๊ดด้านล่าง ยิ่งลำบากต่อการอ่านและแก้ไขโค๊ด

สำหรับท่านใดที่ไม่เคยเจอกับปัญหาแบบนี้มาก่อนเพราะไม่ทราบว่า Callback คืออะไรวะแก ผมแนะนำให้เพิ่มความสับสนใส่ตนเองด้วยการอ่านบทความ รู้ซึ้งการทำงานแบบ Asynchronous กับ Event Loop

JavaScript
1User.findById(userId, (err, profile) => {
2 if (err) console.err(err)
3 // โค๊ดนี้เป็นใช้เป็นตัวอย่าง Gravatar ไม่ได้รับ email ผ่าน URL ตรงๆ
4 fetch(`http://www.gravatar.com/avatar/${profile.email}`, (err, avatar) => {
5 if (err) console.err(err)
6 User.update(userId, avatar, (err, res) => {
7 if (err) console.err(err)
8 doXXX(() => {
9 doXYZ(() => {
10 // .....
11 })
12 })
13 })
14 })
15})

Callback Hell คือเจ้าสถานการณ์เช่นที่ว่า และเพื่อให้บทความนี้ดูน่ารักมุ้งมิ้ง ผมขอเรียกตัวละคร Callback ของเราว่า น้องคอลลี่ และเรียกเจ้าปัญหานี้เป็นภาษาไทยเก๋ๆว่า ความหม่นหมองของน้องคอลลี่ แล้วกัน

Callback เกิดขึ้นได้อย่างไร

พื้นเพของน้องคอลลี่นั้นเป็นเด็กเสี่ย ป๋าขา เสร็จธุระแล้วเรียกหนูนะคะ จึงเป็นสโลแกนของน้องคอลลี่

Callback เป็นฟังก์ชั่นที่ส่งเข้าไปในฟังก์ชั่นอื่นและพร้อมทำงานเมื่อ ป๋าเสร็จธุระแล้ว ตัวอย่างจากโค๊ดข้างบนคือ เรามีฟังก์ชั่น findById ทำหน้าที่ร้องขอข้อมูลผู้ใช้งานระบบ เพื่อที่จะนำอีเมล์ไปดึง avatar จากบริการของ Gravatar ถัดมาในบรรทัดที่4 พบว่าเราจะขอรูปจาก Gravatar ได้ต้องมีอีเมล์ก่อน เหตุการณ์ fetch Gravatar จึงต้องเกิดหลัง findById เช่นนี้จึงสร้างน้องคอลลี่ครอบฟังก์ชั่นดังกล่าวดังนี้

JavaScript
1// err ส่งเข้ามาเมื่อมี error เกิดขึ้น
2(err, profile) => {
3 fetch(`http://www.gravatar.com/avatar/${profile.email}`, (avatar) => {
4 ...
5 }
6}

เนื่องจากน้องคอลลี้ต้องรอให้ป๋า findById ได้รับข้อมูล profile ก่อน เราจึงโยน Callback นี้ไปเป็นพารามิเตอร์ของ fetch Gravatar เพื่อให้ป๋าเรียกใช้งานเมื่อ..เสร็จ ดังนี้

JavaScript
1User.findById(userId, (err, profile) => {
2 fetch(`http://www.gravatar.com/avatar/${profile.email}`, (avatar) => {
3 ...
4 })
5})

จากโค๊ดทั้งหมดจึงสรุปได้ว่า เราร้องขอ profile ของ User ก่อนเพื่อเอาเฉพาะอีเมล์ไปดึง avatar จากบริการของ Gravatar จากนั้นจึงนำรูป avatar ไปอัพเดทข้อมูลผู้ใช้งานที่มี ID เป็น userId เมื่อทุกอย่างเรียบร้อยจึงค่อยทำ doXXX และ doXYZ ในลำดับถัดไป

ฟังแล้วเหนื่อยกับการเรียบเรียงลำดับความคิดใช่ไหม ในวันข้างหน้าหากเราต้องการแก้ไข doXYZ เราต้องขึ้นไปไล่ดูว่ามีอะไรเกิดขึ้นก่อนหน้านี้บ้าง ซึ่งเป็นเรื่องยากเพราะ Tab เยอะเหลือเกิน แถมน้องคอลลี่แต่ละนางก็เปลือยเปล่าไม่รู้ว่าชื่ออะไรบ้างมีแค่ (profile) => {...} หรือ (avatar) => {...} ที่เราไม่เข้าใจว่าตัวตนของน้องเขาคือใคร จนกว่าจะได้สัมผัสเนื้อตัว อ่านค้นโค๊ดข้างในให้ทะลุถึงจิตใจและตับไตไส้พุงป่ามป้ามของเธอ

ความพยายามครั้งที่ 1

หลงรักน้องคอลลี่ไปแล้วคงตัดใจได้ยาก ถ้ามันเข้าใจยากนักเราก็ตั้งชื่อให้น้องเสียเลย ลองมาพยายามกันเถอะ

JavaScript
1const userId = 13
2
3const updateAvatar = (err, avatar) => {
4 if (err) console.err(err)
5 User.update(userId, avatar, doXXX)
6}
7
8const getCurrentGravatar = (err, profile) => {
9 if (err) console.err(err)
10 fetch(`http://www.gravatar.com/avatar/${profile.email}`, updateAvatar)
11}
12
13const getUser = (userId) => {
14 User.findById(userId, getCurrentGravatar)
15}
16
17getUser(userId)

จุดนี้น้องคอลลี่ดูฟรุ้งฟริ้งขึ้นทันที เมื่อเรากลับมาอ่านโค๊ดอีกครั้งทุกอย่างดูเข้าใจได้ง่ายขึ้น เรารู้ได้ว่าโค๊ดแต่ละส่วนคืออะไรโดยอาศัยการเดาจากชื่อที่ตั้ง นอกจากนี้ยังเป็นการลดจำนวน Tab ที่น่าปวดหัวลงได้มาก

ตัวอย่างข้างต้นเราเพียงใส่ userId โปรแกรมของเราจะเริ่มหา profile เพื่อไปร้องขอ Avatar แล้วนำมาอัพเดท user ดั่งที่คาดหวังไว้

ถ้าทุกอย่างสมบูรณ์ไปหมด บทความนี้คงไม่เกิดขึ้น โค๊ดข้างต้นนั้นจะเกิดปัญหาเมื่อเราแยกฟังก์ชั่นยิบย่อยมากกว่านี้ ลองจินตนาการว่าหลังจากเราเรียก getUser(userId) แล้วมันไปเรียกฟังก์ชั่นอื่นให้ทำงานอีกซัก10แห่ง โปรแกรมเมอร์อย่างเราต้องกวาดสายตาขึ้นลง เพื่อหาฟังก์ชันน้องคอลลี่ที่เล่นซ่อนแอบอยู่ที่ไหนซักแห่งในไฟล์ เช่น ในบรรทัดที่2 หากเราประกาศ doXXX ไว้บนสุด เราต้องกวาดสายตาค้นหาว่าฟังก์ชันนี้นิยามไว้ที่ใด เป็นต้น

ปัญหาอีกประกาศคือการแบ่งแยกงานที่ไม่ชัดเจน ทุกครั้งที่เราเรียก getCurrentGravatar มันจะเรียก updateAvatar เสมอนั่นหมายความว่า หากเรามีโค๊ดชุดอื่นที่ต้องการ getCurrentGravatar เหมือนกัน ต้องการแค่นำรูปไปใช้แต่ไม่ต้องการอัพเดทรูปจะทำไม่ได้!

นอกเหนือจากนี้เรายังส่ง err เข้าไปในทุกฟังก์ชันที่เกี่ยวข้องซึ่งมันไม่สมเหตุผล เหตุเพราะไม่มีความจำเป็นใดๆที่ updateAvatar และ getCurrentGravatar ต้องรับรู้ว่ามีข้อผิดพลาดใดๆเกิดขึ้นบ้าง หากมี error เกิดขึ้นเราควรจะจัดการในฟังก์ชันก่อนหน้า เช่น ในบรรทัดที่14 เมื่อ findById ไม่สำเร็จเราควรจัดการ error ในฟังก์ชัน getUser ไม่ต้องเรียก getCurrentGravatar พร้อมส่ง error ไปเฉกเช่นตัวอย่างข้างบน

เราจะทำตามสัญญา...

จั่วหัวแบบนี้ไม่ได้เกี่ยวอะไรกับการเมืองนะครับ แต่เรากำลังจะพูดถึงสิ่งที่เรียกว่า Promise ใน JavaScript ก่อนที่เราจะทำความเข้าใจว่าคืออะไร ลองดูโค๊ดตัวอย่างก่อนครับ

JavaScript
1const doAsync = () => {
2 return new Promise((resolve, reject) => {
3 setTimeout(() => {
4 if (Math.random() >= 0.5) resolve('BabelCoder!')
5 else reject(new Error('Less than 0.5!'))
6 }, 2000)
7 })
8}
9
10doAsync()
11 .then((text) => {
12 console.log(text)
13 })
14 .catch((error) => {
15 console.error(error.message)
16 })

จุดเริ่มต้นของโปรแกรมคือการเรียก doAsync ในบรรทัดที่10 ที่มีการทำงานเริ่มตั้งแต่บรรทัดที่2 สังเกตบรรทัดที่2นะครับ เรา new Promise ขึ้นมา นั่นหมายความว่า Promise ของเราเป็นออบเจ็กต์ที่รับพารามิเตอร์ตัวหนึ่ง เพียงแต่พารามิเตอร์นั้นเป็นฟังก์ชันในคราบน้องคอลลี่คือ (resolve, reject) => {...}

การทำงานของ Promise นั้นไม่มีอะไรมาก เพียงแค่ส่ง resolve และ reject เข้ามาในฟังก์ชัน ส่วนที่เหลือเป็นหน้าที่ของโปรแกรมเมอร์แล้วครับว่าจะจัดการยังไงต่อ ผมจำลองสถานการณ์ที่ยาวนานด้วยการให้รอ2วินาทีผ่าน setTimeout เมื่อครบเวลาโค๊ดในบรรทัดที่4จะเริ่มทำงาน เอาหละถึงเวลาชำแหละ resolve และ reject แล้ว

resolve เป็นการบอกว่าโค๊ด Asynchronous ของเราทำงานเสร็จสิ้นไร้ปัญหาใดๆซึ่งตรงกันข้ามกับ reject ที่เป็นการแจ้งกลับว่าการทำงานนั้นมีข้อผิดพลาด ย้อนกลับไปที่ตัวอย่างครับ ผมสุ่มตัวเลขผ่านฟังก์ชัน random โดยกำหนดว่าหากตัวเลขที่ได้นั้นมากกว่าหรือเท่ากับ 0.5 ถือว่าทำงานสำเร็จจึงครอบผลลัพธ์สมมติของการทำงานคือ BabelCoder!กลับไปผ่าน resolve ทำนองกลับกันหากผลลัพธ์ที่ได้น้อยกว่า0.5ถือว่าล้มเหลวจึง reject พร้อมเหตุผลว่า Less than 0.5! กลับไป

แล้วยังไงต่อ? เราคงไม่ resolve หรือ reject เล่นๆให้เปลืองบรรทัดกันจริงไหม หากแต่จะนำมาใช้เพื่อบอกว่าเมื่อได้ผลลัพธ์แล้วจะทำยังไงต่อ อาศัย then และ catch เราจะได้ว่า หาก resolve หรือทำงานสำเร็จจะทำ then ต่อ ในทางกลับกันจะ catch เมื่อ reject หรือเมื่อมี Error และทำงานไม่สำเร็จ จึงสรุปได้ว่าโค๊ดตัวอย่างของเราจะพิมพ์ BabelCoder! ออกทางหน้าจอเมื่อตัวเลขจากการสุ่มมากกว่าหรือเท่ากับ0.5 ไม่เช่นนั้นจะพิมพ์ Less than 0.5! ออกหน้าจอ

ใช้ Promise แก้ Callback Hell

ถึงเวลาแก้ปัญหาแล้ว มาเปลี่ยนโค๊ดอัพเดท avatar ของเราโดยใช้ Promise กันเถอะ!

JavaScript
1const userId = 13
2
3const updateAvatar = (avatar) => {
4 return new Promise((resolve, reject) => {
5 User.update(userId, avatar, (error, user) => {
6 if (error) reject(error)
7 else resolve(user)
8 })
9 })
10}
11
12const getCurrentGravatar = (profile) => {
13 return new Promise((resolve, reject) => {
14 fetch(
15 `http://www.gravatar.com/avatar/${profile.email}`,
16 (error, avatar) => {
17 if (error) reject(error)
18 else resolve(avatar)
19 }
20 )
21 })
22}
23
24const getUser = (userId) => {
25 return new Promise((resolve, reject) => {
26 User.findById(userId, (error, profile) => {
27 if (error) reject(error)
28 else resolve(profile)
29 })
30 })
31}
32
33getUser(userId)
34 .then((profile) => {
35 return getCurrentGravatar(profile)
36 })
37 .then((avatar) => {
38 return updateAvatar(avatar)
39 })
40 .then((user) => {
41 return doXXX(user)
42 })
43 .catch((error) => {
44 console.error(error.message)
45 })

เห็นโค๊ดแล้วแทบอุทาน นี่หรือเมืองพุทธ นี่มันแทบจะยาวพอๆกับชื่อเต็มกรุงเทพฯแล้วนะ ถึงตรงนี้บางคนอาจรู้สึกว่า ฉันยอมกดแท็บเยอะๆต่อไปจะดีกว่า อย่าพึ่งคิดเช่นนั้นครับ ลองฟังคำอธิบายก่อน

เริ่มกันที่บรรทัดที่31เลย ผมมั่นใจว่าแม้ไม่อธิบายใดๆคุณยังสามารถเข้าใจโค๊ดนี้ได้ด้วยตัวคุณเอง ในบรรทัดที่ 31-39 ผมหาผู้ใช้ระบบที่มีไอดีเป็น13 หากค้นเจอให้หารูปปัจจุบัน เช่นเดียวกันหากพบรูปให้ทำการอัพเดท ทุกอย่างอยู่ภายใต้ประโยค ถ้า...แล้ว(then) เสมอ หากเกิดข้อผิดพลาดจากการ reject โค๊ดภายใต้ catch เท่านั้นที่จะดักจับและทำงาน เข้าใจได้ง่ายและใช้เพียงแท็บเดียวใช่ไหมครับ นั่นคือพลานุภาพแห่ง Promise!

ย้อนกลับไปดูเรื่องน่าปวดหัวบรรทัดที่ 3-29 นิดนึง พบว่าแต่ละฟังก์ชันแม้จะอ่านแล้วเข้าใจง่าย แต่ก็อุดมไปด้วยแก๊งค์ resolve และ reject หากเราเปลี่ยนจากการเขียนเองบางจุดไปใช้ Library ที่สนับสนุนการทำงานกับ Promise ทุกอย่างจะง่ายขึ้นครับ เช่น เปลี่ยน fetch ที่เราเขียนเองไปใช้ fetch และเปลี่ยน findById ที่เขียนเองไปใช้ Mongoose จะได้โค๊ดใหม่ที่สั้นกว่าเดิมดังนี้

JavaScript
1const userId = 13
2
3const updateAvatar = (avatar) => {
4 return User.update(userId, avatar)
5}
6
7const getCurrentGravatar = (profile) => {
8 return fetch(`http://www.gravatar.com/avatar/${profile.email}`)
9}
10
11const getUser = (userId) => {
12 return User.findById(userId)
13}
14
15getUser(userId)
16 .then((profile) => {
17 return getCurrentGravatar(profile)
18 })
19 .then((avatar) => {
20 return updateAvatar(avatar)
21 })
22 .then((user) => {
23 return doXXX(user)
24 })
25 .catch((error) => {
26 console.error(error.message)
27 })

ย้อนกลับไปดูกันครับว่าโค๊ดชุดปัจจุบันของเราแก้ปัญหาอะไรไปแล้วบ้าง

  • กำจัด ความหม่นหมองของน้องคอลลี่ ด้วยการลดจำนวนแท็บที่ต้องกดไป
  • ฟังก์ชันเป็นเอกเทศ ทุกครั้งที่เราเรียก getCurrentGravatar จะไม่ทำการ updateAvatar เองอีกต่อไป
  • ไม่มีการโยน Error ข้ามไปมาระหว่างฟังก์ชันเฉกเช่นที่ทำในตัวอย่างแรก

เนื่องจากโค๊ดของเราเป็นลักษณะเฉพาะ แต่ละฟังก์ชันแยกย่อยก็มีโค๊ดแค่บรรทัดเดียว เราจึงควรรวมโค๊ดทั้งหมดเป็นฟังก์ชันเดียว เพื่อประหยัดการกวาดสายตาหาว่าแต่ละฟังก์ชันทำอะไร ดังนี้

JavaScript
1const updateLatestAvatar = (userId) => {
2 return User.findById(userId)
3 .then((profile) => {
4 return fetch(`http://www.gravatar.com/avatar/${profile.email}`)
5 })
6 .then((avatar) => {
7 return User.update(userId, avatar)
8 })
9 .then((user) => {
10 return doXXX(user)
11 })
12 .catch((error) => {
13 console.error(error.message)
14 })
15}
16
17updateLatestAvatar(13)

ถึงเวลาพระเอกของเรื่องแล้ว

การแก้ปัญหา Callback Hell น่าจะจบลงที่ Promise หากเพียงแต่เรายังมีวิธีที่ทำให้โค๊ดของเราดูคล้ายโค๊ดแบบ Synchronous มากขึ้น เราจะใช้ Async/Await ใน ES7 ดังนี้

JavaScript
1async function updateLatestAvatar(userId) {
2 try {
3 const profile = await User.findById(userId)
4 const avatar = await fetch(
5 `http://www.gravatar.com/avatar/${profile.email}`
6 )
7 const user = await User.update(userId, avatar)
8 doXXX(user)
9 } catch (error) {
10 console.error(error.message)
11 }
12}
13
14updateLatestAvatar(13)

เมื่อเรากำจัด then ออกไปได้จะรู้สึกทันทีว่าโค๊ด Asynchronous ดูอ่านง่ายขึ้นเฉกเช่นเดียวกับการอ่านโค๊ดจากบนลงล่างในภาษา C/C++ สิ่งที่เราต้องทำมีดังนี้

  • ใช้ try..catch เพื่อดักจับ Error แทน catch ใน then..catch ของ Promise
  • ตรงไหนที่เป็นโค๊ดแบบ Asynchronous เราไม่ใช้ then แต่ใส่ await เข้าไปข้างหน้าในความหมายที่ว่า อ๊ะ... รอเค้าเสร็จก่อนนะ แทน
  • ฟังก์ชันที่จะใช้ Await ให้ใส่ async เข้าไปหน้า keyword function
  • ต้องเข้าใจเสมอว่า Promise ไม่ได้หายไปไหน อย่าลืมว่า findById, fetch หรือ update ล้วนคืนค่าเป็น Promise ทั้งสิ้น
  • ข้อควรจำคือ Async/Await ไม่ได้ทำให้โค๊ดของคุณเปลี่ยนจาก Asynchronous เป็น Synchronous แต่อย่างใด

Async/Await นั้นเป็นของใหม่ใน ES7 ที่ยังไม่มาในตอนนี้ (ปัจจุบันเป็น ES2015) หากคุณต้องการใช้ผมแนะนำให้ใช้ผ่าน Babel เช่นเดียวกันเพื่อให้คุณสามารถใช้ Promise ได้ในทุกที่คือทั้งทุก Browser และทั้งใน Node.js ผมขอแนะนำให้คุณใช้ Bluebird แล้วคุณจะค้นพบว่า Asynchronous code ไม่ยากอีกต่อไป(ซะที่ไหนหละ ปัดโถ่!)

เอกสารอ้างอิง

petkaantonov. API Reference | bluebird. Retrieved April, 17, 2016, from http://bluebirdjs.com/docs/api-reference.html

Joe Zimmerman (2015). Simplifying Asynchronous Coding with ES7 Async Functions. Retrieved April, 17, 2016, from http://www.sitepoint.com/simplifying-asynchronous-coding-es7-async-functions/

สารบัญ

สารบัญ

  • สวัสดี... เรา Callback Hell เอง
  • Callback เกิดขึ้นได้อย่างไร
  • ความพยายามครั้งที่ 1
  • เราจะทำตามสัญญา...
  • ใช้ Promise แก้ Callback Hell
  • ถึงเวลาพระเอกของเรื่องแล้ว
  • เอกสารอ้างอิง