ควบคุม Flow ของแอพ React Native ด้วย Redux Saga
ปัญหาคือ…
งานหลักๆของการทำแอพบนมือถือดูแล้วมันก็มีไม่กี่อย่างทำซ้ำๆ วนไป เช่น วาง User Interface, จัดการ Interaction ต่างๆของ User Interface, เรียก API, บันทึกข้อมูลที่ได้จาก API และอัพเดทการแสดงผลของ User Interface เป็นต้น แต่ความยากลำบากจริงๆมันอยู่ที่รายละเอียดของแต่ละงานมากกว่า
การพัฒนาแอพด้วย React Native งานต่างๆข้างต้นนั้น พวกเราต่างก็ใช้ Redux เข้ามาช่วยอำนวยความสะดวกแล้ว แต่รายละเอียดของงานจริงมันมีมากกว่าแค่การเรียก API แล้วเอาข้อมูลมาอัพเดท User Interface การใช้งาน Redux รวมถึง redux-thunk จึงเริ่มมีความซับซ้อนมากยิ่งขึ้น โดยปกติการทำแอพใดๆอย่างน้อยที่สุดจะต้องมีความต้องการพื้นฐานดังนี้แน่นอน
- ตรวจสอบว่ามีข้อมูลใน Cache ไหม ถ้ามีนำมาแสดงเลย
- ตรวจสอบสถานะการเข้าถึง Internet ของอุปกรณ์
- (แสดงหน้าจอสำหรับในกรณีไม่สามารถเข้าถึง Internet ได้)
- ถ้าไม่มีข้อมูลใน Cache ให้แสดงอนิเมชั่น Loading ก่อน
- เรียก API
- (ในบางกรณีต้องการหน่วงเวลา x วินาทีก่อนเรียก API)
- (ในบางกรณีต้องการเรียก API มากกว่า 1 API ในเวลาเดียวกัน)
- ได้รับตอบรับจาก API
- ตรวจสอบสถานะของ API ว่า สำเร็จ หรือ ล้มเหลว
- (เรียก API อื่นต่อ)
- หยุดการแสดงอนิเมชั่น Loading
- บันทึกข้อมูลตอบกลับและอัพเดทหน้าจอแสดงผล
- (ในบางกรณี Dismiss หน้านั้นทิ้ง)
- (ดึงรูป ในกรณีที่มี URL ของรูปมาในข้อมูลตอบกลับ)
- (จัดการกับ Error จากข้อมูลตอบกลับและอัพเดทหน้าจอแสดงผล
- (บันทึกล็อค หรือเรียก Analytics tool บางอย่างต่อ)
จะเห็นได้ว่าแค่การเรียก API 1 ครั้ง เราต้องจัดการ Flow การทำงานหลายอย่าง ดังนั้นเราจึงต้องการตัวช่วยที่จะช่วยการันตีให้เราได้ว่าการทำงานทุกขั้นตอนจะถูกต้องปลอดภัย เป็นกลุ่มก้อน ไม่ไปรบกวนหรือสร้างความสับสนกับส่วนงานอื่นๆของแอพ และที่สำคัญเราต้องสามารถเทส Flow การทำงานเหล่านั้นได้ง่ายด้วย
แล้วที่พวกเราทำกันอยู่มันผิดยังไง ?
ทุกคนที่อ่านบทความนี้ผ่านการใช้งาน Redux มาแล้วในระดับหนึ่ง และใช้ Middleware ที่ชื่อว่า redux-thunk มาช่วยในการจัดการ Flow การทำงานในแอพกันแล้ว ยกตัวอย่างงานพื้นฐานง่ายๆ ที่เราใช้ redux-thunk เข้ามาช่วยในการจัดการ Flow การทำงาน ดังนี้
const getPromotions = () => async (dispatch) => { try { dispatch(fetchPromotions()) // {type: FETCH_PROMOTION}
const response = await api.post('/get.promotion')
const result = response.data.result
dispatch(fetchPromotionsSuccess(result))
// {type: FETCH_PROMOTION_SUCCESS, payload: result} } catch (error) { const message = error.value || 'Unknown Fail'
dispatch(fetchPromotionsFailure(message))
// {type: FETCH_PROMOTION_FAILURE, payload: message} }}
การทำงานของโค้ดข้างต้นสามารถสรุปได้ดังนี้
- ส่ง Action ที่สร้างจาก fetchPromotion ไป (ส่วนมากเราจะใช้ประโยชน์จาก Action นี้ในการแสดงอนิเมชั่น Loading ระหว่างรอการตอบกลับจาก API)
- เรียก API get.promotion แบบ await เมื่อได้รับการตอบกลับมาจะนำค่าจะใส่ให้กับตัวแปร result
- ส่ง Action ที่สร้างจาก fetchPromotionSuccess ไปพร้อมกับ result เพื่อนำ result ไปเก็บเป็น state ใน Redux store
- ในกรณีที่ล้มเหลว จะส่ง Action ที่สร้างจาก fetchPromotionFailure ไปพร้อมกับ Error
การจัดการ Flow โดยใช้ redux-thunk ข้างต้นมันสามารถทำงานได้ดี อ่านเข้าใจง่าย แต่ปัญหาคือการเขียนเทสไม่ง่ายสักเท่าไหร่
การเขียนเทส redux-thunk ในปัจจุบันเราต้องใช้วิธีการม็อค Redux Store ขึ้นมาโดยการใช้ redux-mock-store มาทำม็อคสโตว์ให้และต้องม็อค fetch ปลอมๆขึ้นมา แล้วเทสว่า Actions ที่คาดหวังว่าจะได้กับ Actions ที่ได้มาจริงเหมือนกันหรือไม่ (การเทสอะไรก็แล้วแต่ใน thunk เราต้องม็อคทุกๆฟังก์ชั่นที่ถูกเรียกเอง)
นอกจากเรื่องการเขียนเทสแล้ว การควบคุม Flow ด้วย redux-thunk ยังอาจทำให้เกิดการทำงานที่ซ้ำซ้อนกันกระจายอยู่หลายที่ เช่น การดักการแสดงอนิเมชั่น Loading ที่ Action แรกของทุก request และการดักปิดการแสดงอนิเมชั่น Loading ที่ Action Success และ Failure ของทุก request เป็นต้น แต่เห็นว่าโลจิกการจัดการอนิเมชั่น Loading นี้กระจายออกมาอยู่ที่ components ต่างๆ แทนที่จะควบคุมได้จากที่เดียว
ถ้าเรามีความต้องการที่ซับซ้อนขึ้นมากไปกว่านั้นเช่น ต้องการจัดการกับ Error ต่างๆในที่เดียว ต้องการบันทึกล็อคหรือติด Analytics ต่างๆ ต้องการตรวจสอบสถานะของอินเตอร์เนตก่อนเรียก API ต้องการยกเลิก request ที่เรียกไปแล้วแต่ยังไม่เสร็จ เป็นต้น การใช้ redux-thunk จัดการสิ่งเหล่านั้นทั้งหมด เริ่มจะไม่ใช่เรื่องง่ายอีกต่อไป
อีกเรื่องที่สำคัญมากๆคือ การเขียน Action creator ด้วย redux-thunk นั้น ทำให้เราได้ Action creator ที่เป็นฟังก์ชั่น Impure ขึ้นมาด้วย ซึ่งเราก็ต่างไม่อยากให้ Action creator ของเราเป็นฟังก์ชั่น Impure
ซาก้า
ซาก้าคืออะไร คำว่า “ซาก้า” ตามงานวิจัยของ Hector Garcia-Molina & Kenneth Salem เป็นศัพท์เทคนิคที่ใช้เรียก ชุดของคำสั่งที่ใช้เวลายาวนานในการดำเนินการจนกว่าจะเสร็จสิ้นและชุดของคำสั่งนั้นต้องสามารถเขียนแยกเป็นลำดับได้และสลับตำแหน่งได้
อ่านแล้วก็จะงงๆนิดหน่อย แต่ถ้าเรากลับไปอ่านลิสต์ของงานที่เราต้องทำในย่อหน้าก่อนหน้า แล้วก็อาจจะร้องอ้อขึ้นมาเบาๆได้ ว่าลิสต์ข้างบนนั้นแหละที่เราสามารถเรียกงานแต่ละงานว่า “ซาก้า” ได้
สำหรับวันนี้เราจะมาเรียนรู้ redux-saga ซึ่งเป็นไลบรารี่ตัวช่วยที่จะเข้ามาจัดการความยุ่งยากของ actions ต่างๆใน Redux นั่นเอง โดยหนึ่งในความสามารถของ redux-saga จะทำการเปลี่ยนคำสั่ง Promise, Callback, Try/Catch, Fetch และอื่นๆที่เราใช้กันให้กลายเป็นลิสต์ของชุดคำสั่งซาก้า และอีกหนึ่งความสามารถของไลบรารี่นี้คือการจัดเตรียม sagaMiddleware มาให้เพื่อใช้ในการจัดการลิสต์ของชุดคำสั่งซาก้าเหล่านั้น เมื่อทุกอย่างกลายเป็นซาก้า การเทส การม็อคและการจัดการ Flow ก็จะง่ายขึ้นนั่นเอง
อาจจะยังงงอยู่ ค่อยๆดูไปจะเข้าใจมากขึ้น
ลองใช้ redux-saga ให้ดูหน่อย
redux-saga นั้นใช้ความสามารถพิเศษของฟังก์ชั่น generator ที่อยู่ใน ES2015 ตัวฟังก์ชั่น generator นี้มีความสามารถที่พิเศษกว่าฟังก์ชั่นทั่วๆไปคือ มันเป็นฟังก์ชั่นที่สามารถถูก pause ถูก resume เมื่อไรก็ได้ตามต้องการ และยังสามารถ return ค่าออกไปได้มากกว่า 1 ครั้งด้วย การเขียนฟังก์ชั่น generator ทำได้โดยการเขียนฟังก์ชั่นที่มีดอกจันทร์หน้าชื่อฟังก์ชั่น (* ออกเสียงว่า ซุปเปอร์สตาร์)
function *helloGenerator() {
}
เราจะสร้างลำดับการทำงานของ Flow ในฟังก์ชั่น generator โดยการใช้คำสั่ง yield นำหน้า statement ต่างๆ คำสั่ง yield ของฟังก์ชั่น generator นั้นให้เรามองว่ามันเป็นเหมือนกับคำสั่ง return ของฟังก์ชั่นปกติ คือ มีหน้าที่ในการคืนค่าผลลัพท์ แต่เนื่องจากฟังก์ชั่น generator สามารถคืนค่าได้หลายครั้ง เราจึงสามารถสั่ง yield ได้หลายครั้งในหนึ่งฟังก์ชั่น generator ดังนี้
function *getPromotions() {
yield 'display loading indicator'
yield 'fetch promotions'
yield ...
}
ไลบรารี่ของ redux-saga จัดเตรียม 2 ส่วนสำคัญไว้ให้เรา คือ sagaMiddleware และ Effect creator ที่เราสามารถ yield ได้จำนวนหนึ่ง บางอันใช้ได้ทั่วไป (ไม่ต้องมี redux ก็สามารถใช้ได้ เช่น call) บางอันต้องใช้กับ redux เท่านั้น (เช่น put และ take) บางอันมีไว้จำลองการทำ thread (เช่น fork, join, cancel และ race) ดูรายละเอียดในหัวข้อถัดไป
เมื่อเราใช้ redux-saga โลจิกของการทำงานทุกอย่างจะถูก encapsulate ไว้ใน saga ทั้งหมด ส่วน component จะทำแค่ก็การ dispatch action ปกติ คุณมีหน้าที่สร้าง saga ขึ้นมาเพื่อ watch การ dispatch action ที่เกิดขึ้น (Watcher) แล้วเริ่มทำงานบางอย่าง (Worker) ตามลำดับ
function *getPromotions() {
yield take('FETCH_PROMOTIONS')
yield put({ type: 'SHOW_LOADING_INDICATOR' })
try {
const response = yield call(api.post, '/get.promotion')
yield put({ type: 'FETCH_PROMOTION_SUCCESS'
, payload: response.data.result })
yield put({ type: 'HIDE_LOADING_INDICATOR' })
} catch(error) {
yield put({ type: 'FETCH_PROMOTION_FAILURE'
, payload: error })
yield put({ type: 'HIDE_LOADING_INDICATOR' })
}
}
โค้ดข้างต้นเป็นการแสดงตัวอย่างง่ายๆของการเขียนซาก้า ดูผ่านๆก็แทบจะไม่มีอะไรต่างจากการใช้ redux-thunk ในเขียน Action creator เลยแต่จริงๆแล้วมีส่วนที่ต่างดังนี้
- โลจิกของการแสดงและซ้อนอนิเมชั่น Loading จะไม่ต้องซ้อนอยู่ใน action FETCH_PROMOTION หรือ FETCH_XXX แล้ว และโลจิกก็ไม่ต้องมีกระจายอยู่ตาม components ต่างๆด้วย เพราะเราสามารถเขียนซาก้ามาดัก action XXX_SUCCESS และ XXX_FAILURE ได้เลย
- โลจิกการจัดการกับ Error ที่ server ส่งมา สามารถจัดการได้ในที่เดียว คือเราสามารถเขียนซาก้าไว้ดัก action XXX_ERROR ได้ และเขียนโลจิกในการจัดการ Error ต่างในซาก้าเดียว
- เขียนเทสง่ายขึ้นมากเพราะทุกๆ yield effect ที่อยู่ในฟังก์ชั่น generator สามารถเขียนเทสได้เลย โดยไม่ต้องม็อคฟังก์ชั่นหรือม็อคสโตว์ใดๆ (มันง่ายกว่าเดิมยังไงหรอ…ดูรายละเอียดในหัวข้อเทสด้านล่าง)
Effect Creatorใน Redux Saga
หลายคนอ่านตัวอย่างของซาก้าข้างบนอาจจะสงสัยและงงๆ ดัก action ยังไง ทำไมไม่มี async/await มันถึงหยุดทำงานได้? ตามที่กล่าวไปข้างต้น redux-saga ใช้ความสามารถของฟังก์ชั่น generator ที่สามารถถูก pause ถูก resume เมื่อไรก็ได้ตามต้องการ และยังสามารถ return ค่าออกไปได้มากกว่า 1 ครั้ง ตัวไลบรารี่ redux-saga จึงจัดเตรียม effects จำนวนหนึ่งมาไว้อำนวยความสะดวกในการจัดการ Flow ต่างๆดังนี้
- Fork คือ การสั่งประมวลผลฟังก์ชั่นที่ส่งไปเป็นพารามิเตอร์เลยทันที โดยปราศจากการปิดกั้นใดๆ (เสมือนการแตก thread มาอีก thread เพื่อทำงานนั้นทันที)
- Take คือ การสั่งหยุดการทำงานของฟังก์ชั่น generator นั้นแบบชั่วคราว จนกว่าจะได้รับ action ที่กำหนดไว้
- Race คือ การสั่งให้ประมวล effects ทั้งหมดพร้อมกัน (เสมือนการแตก thread ทำทุก effects พร้อมกัน) ทันทีที่มี effect ใดเสร็จสิ้นก่อน effects ที่เหลืออื่นๆจะถูกยกเลิกทันที
- Call คือ การสั่งให้ประมวลผลฟังก์ชั่นที่ส่งมานั้น (พร้อมพารามิเตอร์) ทันที แต่ถ้าฟังก์ชั่นนั้น return ออกมาเป็น Promise ฟังก์ชั่น generator นี้จะหยุดการทำงานชั่วคราวทันที เพื่อรอผลของ Promise นั้น
- Put คือ การส่ง action เข้าไปให้สโตว์โดยตรง action ที่ถูกส่งผ่านทาง put จะไม่ถูกดักจับโดยซาก้าอื่นๆอีกแล้ว
- Select คือ การดึงข้อมูลของ state ออกมาจากสโตว์โดยตรง
- TakeLatest คล้ายๆ Take แต่ในกรณีที่เข้ามาพร้อมกันหลายครั้ง จะทำการละเว้นผลลัพท์ของครั้งก่อนหน้าทั้งหมด และ return ออกไปแค่ผลลัพท์ล่าสุดเท่านั้น
- TakeEvery คล้ายๆ TakeLatest แต่จะ return ผลลัพท์ของทุกอันออกไป
พอเข้าใจความหมายของแต่ละ Effect Creator แล้วย้อนกลับไปอ่านตัวอย่างโค้ดข้างบนน่าจะเข้าใจมากขึ้น แต่ในการทำงานจริงๆเราจะไม่เขียน Effect ที่เป็น Watcher (เช่น Take, TakeEvery) กับ Worker (เช่น Call, Put) รวมกัน นอกจากนั้น ในตัวอย่างข้างต้น Take ดักจับ action FETCH_PROMOTIONS แค่เพียงครั้งเดียวเท่านั้น
ในการทำงานจริงเราควรจะเขียน Watcher กับ Worker แยกกันประมาณนี้
// Watcher
function* watchFetchPromotions() {
while(true) {
const action = yield take('FETCH_PROMOTION')
yield fork(fetchPromotions)
}
}// Worker
function* fetchPromotions() {
try {
const response = yield call(api.post, '/get.promotion')
yield put({ type: 'FETCH_PROMOTION_SUCCESS'
, payload: response.data.result })
} catch(error) {
yield put({ type: 'FETCH_PROMOTION_FAILURE'
, payload: error })
}
}
มาลองเขียนเทสซาก้ากัน
เราใช้ Effect creator กันเป็นหมดแล้ว ทีนี้เราจะได้มารู้แล้วว่า Effect creator มีประโยชน์กับเราอย่างไร ก็ตรงตามชื่อของมันเลย Effect creator ทุกตัวเป็นเพียงฟังก์ชั่นที่ return Effect ออกมาในรูปแบบของ Javascript object ง่ายๆ หน้าตาประมาณนี้ออกมา
// take('FETCH_PROMOTIONS')
{ TAKE: 'FETCH_PROMOTIONS' }// call(api.post, '/get.promotion')
{
CALL: {
fn: api.post,
args: ['./get.promotion']
}
}// put({ type: 'FETCH_PROMOTION_FAILURE' , payload: error })
{ PUT: { type: 'FETCH_PROMOTION_SUCCESS'
, payload: error } }
เวลาที่ซาก้าเหล่านั้นถูกส่งเข้าไปรันใน sagaMiddleware มันจะรันตามคำสั่งที่เห็นเลย เช่น call(api.post, ‘/get.promotion’) มันก็ไปเรียก API ที่พาทดังกล่าวจริงๆ แต่สำหรับการเทส มันจะไม่ทำงานตามนั้นแต่จะ return ออกมาเป็น Javascript object ข้างต้นออกมาเท่านั้น ซึ่งเราสามารถเอา Javascript object นั้นไปเทสได้อย่างง่ายดาย
test('Test fetchPromotions success', (t) => {
const saga = fetchPromotions() let actual = null
let expected = null // test step 1
actual = saga.next().value
expected = call(api.post, '/get.promotion')
t.deepEqual(actual, expected, 'call api get promotion') // test step 2
const result = [1, 2]
actual = saga.next(result).value
expected = put({ type: 'FETCH_PROMOTION_SUCCESS'
, payload: result })
t.deepEqual(actual, expected, 'dispatch a fetch promotions success action')
}
เราใช้ข้อดีของฟังก์ชั่น generator ที่ว่าเราสามารถรับค่า return (yeild) ของฟังก์ชั่น generator ได้โดยการใช้คำสั่ง next() ในแต่ละครั้งที่เราเรียก next() เราจะได้ object คืนกลับมา object หนึ่ง ซึ่ง object นั้นจะมี 2 คุณสมบัติให้เรียกใช้ คือ .value กับ .done
ตัว .value มันก็คือ object ของ Effect (ซาก้า) ตามตัวอย่างข้างต้นนั่นเอง ในการเทสเราก็แค่นำค่าดังกล่าวมาเปรียบเทียบกับค่าที่เราคาดหวังกว่าตรงกันหรือไม่
ตัวอย่างการเขียน Unit test ข้างต้น แสดงการเทสฟังก์ชั่น fetchPromotions ในส่วน Flow การทำงานที่สำเร็จ (success case) ดังนี้
- ทดสอบว่ามีการเรียกฟังก์ชั่น api.post พร้อมกับพารามิเตอร์ ‘/get.promotion’ จริงหรือไม่
- ทดสอบว่าหลังจากได้ผลลัพท์จากการขั้นตอนก่อนหน้า แล้วนำผลลัพท์ดังกล่าวมาสร้างเป็น action FETCH_PROMOTION_SUCCESS แล้วส่ง action นั้นไปให้กับสโตว์จริงหรือไม่
จะเห็นว่าการเทส Flow ของเราไม่ต้องพึ่งพาการม็อคสโตว์ redux และไม่ต้องม็อคฟังก์ชั่น api.post ขึ้นมาด้วย
สรุป…ใช้ซาก้าแล้วได้อะไร
- เทสง่ายมาก โดยปราศจากม็อค
- Action creator ทุกตัวเป็นฟังกชั่น Pure
- ลด boilerplate ของ Promise
- มีโมเดลในการจัดการการประมวลผลคู่ขนานได้ง่าย
- แยก Side effects มาอยู่รวมกันที่เดียว แยกโลจิกและการจัดการ Flow ออกมาจาก component ได้ทั้งหมด
- โค้ดอ่านง่าย เขียนโค้ด Async ในรูปแบบของ Sync ทำให้จัดการ Flow ที่ซับซ้อนง่ายขึ้น
ดูอะไรกันต่อดี
บทความนี้เป็นเพียงการแนะนำ redux-saga กับวิธีการใช้งานเบื้องต้นเท่านั้น แต่ปัจจุบันมีการประยุกต์ใช้ redux-saga ในหลากหลายรูปแบบ เพื่ออำนวนความสะดวกในการจัดการ Flow ของแอพ บทความต่อไปจะเป็นการนำเสนอการประยุกต์ใช้ redux-saga ที่น่าสนใจและน่าจะได้นำมาใช้ในโปรเจคจริงบ่อยๆ โปรดรอติดตามๆ