Saga Pattern 在前端的应用
这是篇长文,你可以直接跳到你想看的地方就好
或是直接在 github 上面看我 step by step 的教学
redux-thunk-to-saga-tutorial
先把结论讲在一开始,这并不只是一个 library 的使用方法介绍而已,
因为学习 saga pattern 对于前端工程师是有帮助的,
主要不出以下三个概念:
好的 UI/UX 该是一个画面的 transaction
User 随时能够取消 transaction
满足上述条件实作出来的数据流是要容易被测试的
那redux-saga
到底是在解决什么问题呢?
答案:
让我们的异步 action 能够更好被开发、维护、测试。
让我们用不同的方式来思考异步的前端数据流
saga 的中文翻译是冒险故事
这里来举个例子:我们要登入
|
|
你会怎样去设计这个数据流呢?
画面要有什么 state ?
假如登入要可以取消,你要怎样改变画面的 state 呢?
这个流程看似简单,
但要处理的干净、又好测试,
是不是事情就没有那么直觉了?
目前看起来好像很抽象,但瞭解后,
redux-saga
并没有什么神奇的黑魔法。
我不认为 redux-saga
的只是拿来取代 redux-thunk
的工具,
重要的应该是 saga 这个 pattern 背后的概念,
给了你新的方式去思考前端数据流。
送出数据 => loading 动画 => 完成
其实前端的画面也隐含着 transaction 的概念在里面。
我认为如果有出现以下几个现象,
那 redux-saga
值得你一试:
学会 generator function 却无处可应用
处理异步的 action 时,总觉得哪里怪怪的 => 回传 promise 时要怎么测试
纯粹好奇
redux-saga
能帮助你什么
Catalogue
Introduction
有些人会说 redux-saga
的学习曲线比较陡峭,
其实并不尽然。
会觉得 redux-saga
太过困难,
通常就是因为一次就想直接学会、并应用,
忽略有些预先知识必须要一步一步学习,
而且有些情况,必须拉高一点视角会比较好看清楚,
从概念的角度去看,而不是只关注在前端的实作。
我认为这里只有三件事情要掌握
什么是 saga?
saga 跟前端开发有什么关系?
redux-saga 的基础用法
什么是 Saga
要学一个东西,把名词搞懂是很重要的。
像 router 就是个很直觉又常见的名词,
saga 是什么呢?
redux-saga
有提供一些资源供参考,
包括了最原始提出 saga 这个 pattern 的论文。
一共 11 页,不过扣掉 acknowledgment 跟 References ,
就只有 9 页半啦!
不过论文中是从 Database 的角度看,
另一个影片,是从应用在分散式系统的角度去解释,
提高了不少复杂度。
基于以前端的角度,这篇讲解 saga 主要会以 paper 上为主。
saga 其实是个很简单的概念,
要应用它也并不困难,
这篇论文在 DBMS 上实作的原因,
主要只是要阐明如何实做一个简洁、有效率的 sagas,
所以不要担心接下来讲的例子看起来跟 redux 或前端开发没有关系,
稍后会提到要怎样在前端开发中应用 saga 这个 pattern。
所以看个几分钟之后,脑袋里会冒出许多的问号:「所以 saga 是⋯⋯?」。
这里我试着用最简单的语言解释 saga 是什么。
Saga,就是个满足特殊条件的 LLT(Long lived transaction)。
待会会说是什么特殊条件。
如果你不知道什么是 Transaction:
是 Database 上常会用到(但不仅止局限于 Database)的名词,
即是「交易」。
「交易」听起来很抽象,
其实他要叙述的就是银货两讫后,
一个交易才算是完成,
假如银货不两讫的话,那要退回最一开始的时候,
买卖双方的状态会退回交易前的状态,不会有任何改变。
Long lived transaction (LLT)有什么问题
Long lived transaction 是什么呢?
而 LLT 就是一个长时间的 transaction,
就算没有受到其他影响,
整个完成可能也需要数小时或数天。
听起来,似乎是很糟糕的概念对吧?
因为为了实现 transaction,我们通常会把正在 transaction 中的 object lock 住,
让其他人没办法更动它。
(维持数据的 consistency)
所以这么长时间的 transaction,
会造成两个问题:
较高的失败率
dead lock 造成的长时间 delay
举个很实际的例子,就是江蕙演唱会的订票。
购票的时间可能会是某一段时间,
而我们最终要确认订票的数,这就会是一个 LLT。
为解决这个问题,
我们这里可以假设这个 LLT:T
可以被拆成许多相互独立的 subtransaction的集合:t_1
~t_n
。
但如果我们不会希望t_1
~t_n
分别被送进 DB 并且记录下来。
以上述江蕙演唱会的例子,
每个小t
就会是每笔订票纪录
如下图:
假如每个 transaction 都一次就成功,
而且没有人退票的话,那个 transaction 就会正常的被执行:
因为假如有一个失败的话,
那 T
就不算是完成的 transaction。
尽管如此,这样做也比一般的 transaction 带来了一些弹性,
我们可以随意的插入 subtransaction。
接着就来解释 saga 运用什么样的设计方式来解决这些问题。
Saga 是一种特殊的 LLT
第一件要注意到的事就是 saga 仍然是个 LLT。
saga
: LLT that can be broken up into a collection of subtransactions that can be iterleaved in any way with other transactlons
作为一个 LLT,
假如任何一个 saga 中的 subtransaction: t_i
单独执行了,
我们应该要有一个 compensating transaction c_i
可以将它 undo。
这里的 compensating transaction,
指的是从语意上的观点来看,
而不是整个系统都得还原到 t_i
发生的那个时间点。
再看一次上面这段话,魔鬼就藏在细节里,
这正是 saga 为什么可以解决 LLT 问题的关键。
你可能会觉得这两件事不是差不多吗?
举个例子:
如果有个 LLT :
T
是要记住所有买江蕙票的座位数,底下每个订票都是一个 subtransaction:
t
。假设
t_i
要被买票的人取消,我们执行
c_i
时,只是把买的座位数从 database 里面减掉
而不是让 database 回到
t_i
发生前的时间点
所以我们可以得到一个简单的公式,
Saga’s gurantee:
如果全部都执行成功(Successful saga):
t_1
,t_2
….,t_n
示意图:
失败的话(Unsuccessful saga):
t_1
,t_2
….,t_n
,c_n
…,c_1
这里可以注意到其实
c4
是没有做任何事情的,在实作时候如果是最后一个 transaction failed 掉的话,可以忽略
c4
不过就算执行了也不应该会出错
因为每个执行应该都是 idempotent(幂等)的
如此一来我们就掌握了对 saga 的基本知识了!
在进入redux-saga
前,先来看看我们会遇到什么问题
Front-end perspective
Login flow
讲了这么多抽象概念的事情,
让我们回到实务上来看,
来看最开始的这个例子:
|
|
画面出来大概是这样:
以下部分你可能必须要熟悉
redux
,或是任何单向数据流的架构,
我尽量不缺省读者有任何预备知识来写以下的文章 XD
不过真的不行的时候,会放上参考数据
在 redux 中,如果要改变画面的状态(state),
我们必须 dispatch 一个 action 到 store 去,
而映射的 reducer 会根据 action 帮我们生出下一个 state,
并且将 store 中的 state 更新成映射的新 state。
reducer(state , action) => nextState
假如还是很模糊的话,可以看看 redux 优秀的文件:
来看一下 login
的 reducer 会长什么样子:
这里为了简化,有删去一些东西
|
|
归类成以下几个结果:
LOGIN_REQUEST
:当我们送出LOGIN_REQUEST
这个 action 时,会进入 loading 状态LOGIN_SUCCESS
:登入成功,会拿到username
以及映射的token
LOGIN_ERROR
:登入失败,会拿到错误讯息
那真正执行的时候该如何执行呢?
Redux thunk 的解法与问题
Thunk?Is it good to drink?
来看一下维基百科的解释:
In computer programming, a thunk is a subroutine that is created, often automatically, to assist a call to another subroutine.
只截录一小段,剩下的多看也只是搞混。
简单说就是我们为了把一个 subroutine A 的工作,
带到另一个 subroutine B 做完,
中间需要一个桥梁:subroutine C,
这个 C 就是 thunk 啦!
在 redux 中,我们如果要让一个 action 能够更新,
必须要 dispatch 它。
所以上述的login
流程大概会长这个样子:
|
|
loginRequest
是一个 action creator,
会回传{type: LOGIN_REQUEST}
这个 object。
这里回传的就是一个 thunk,
因为我们在这个 action 里面同时得完成:
送request
收到 response data
处理错误
所以我们必须把 dispatch 给传进来,
完成原本只靠单个 subroutine(一般的 action creator) 无法做到的事情。
这里有什么问题呢?
你要如何去测试这个一连串的动作?
这里回传的是一个 promise,它无法被 abort,如果我们今天想加上取消按钮呢?
- 更 low level 一点的问法:你要在哪里 dispatch
loginCancel
这个 action 呢?
- 更 low level 一点的问法:你要在哪里 dispatch
当然, login 是一个相对简易的流程,
假如遇到有更多 state 要处理,
无法写出测试以及不那么直觉的语法,
将会为我们的开发带来一些问题。
Front-end 中的 saga
这里的一整个 loginFlow
,其实就是一个 LLT(长时间的 transaction),
可以看完这一段再回到这里 XD
底下的 subtransaction 就是各个 action(request, success, error)。
有了这样的概念之后,剩下来的事就简单多了。
而且 saga 就是底下每个 transaction 都附带 compensating transaction 的 LLT,
也就是说上述的 abort ,在 saga pattern 之下是内置的。
Refactor with redux-saga
Setup
这里跟概念比较没关系,
但环境设置绝对是许多人卡关的第一步。
首先要创建一个 sagas 文件夹,
底下有一个 rootSaga,它会是一个 generator function:
|
|
接着在 middleware 中将它跑起来。
|
|
这里的基本设置,其实每次都大同小异,
所以就不再多着墨底下发生什么事情。
Effect
前面有提到的 subtransaction,可以很粗略的映射到这里的 effect
。
saga 不出以下几种情形:
监听 action 发生 -> take, takeEvery
执行 transaction -> put
取消 transaction -> cancel
右边的就是我们在 redux-saga 中映射到的 helper function,
他们就是 action creactor 一样,会回传一个物件,
不过这一次是回传一个 effect ,而不是 action,
e.q: take({type: LOGIN_REQUEST})
就是产生一个拿到 loginRequest 的 effect。
接着就来把 code 改写吧!
Watch action
|
|
值得注意的是这里都是 generator function,
假如你完全对 generator function 没有概念的话,
推荐你看这篇文章。
是我写的 XD
这里的 code 还蛮语义化的,
就是当我们遇到一个 LOGIN_REQUEST
的 action ,
就会执行 loginFlow
这个 function。
接着是前面提到的好测试,
我们来测试这个 saga 吧!
|
|
这里比较 tricky 是我们测试的是 effect 的名字,
为什么不是直接 deepEqual 两个 effect?
我们回传的 effect 其实就是个 object,长相是下面这样:
|
|
只要 name 是对的,我们就知道他在映射的 LOGIN_REQUEST
进来时,
会执行loginFlow
这个 function。
而且在JavaScript中会判断这两个 next 是不同 function XD
直接测试名字,是我现在想到比较直观的方法
Migrate Login Flow to saga
Talk is cheap:
|
|
call 跟我们熟悉的 Function.prototype.call
很像!
不一样的是,这里的 call 会回传的是一个 effect
,
这代表什么?代表我们能够很好的测试它,
而不是真的去 call loginAPI,带来了无止尽的 mock。
我们把 loginFlow 的 test 拆成四个部分来看
Initialize
Call loginAPI
Handle login success
Handle login error
前面的 watch function 会把 request 这个 action 丢进来这里,
所以我们要先制造出一个待会会用到的 iterator:
执行 Generator function 会返回一个 iterator,
然后我们去对这个 iterator 调用next
function
感谢 CT 的指正。
|
|
再来则是 call API,注意我们测试的是 call effect,
而不是真的去调用这个 API:
|
|
|
|
这里我们可以运用 generator 的特性来把假 error 丢进去XD
里面的 catch 接到 error 之后,就会执行 login error 的流程了。
|
|
Combine loginFlow saga
首先要把 login 的 saga 接到 root saga 去
接着我们要来把原本 dispatch 的 loginFlow action 换成 loginFlowSaga 了。
|
|
再来我们只要把原本放 loginFlow action 的地方,
换成 loginRequest
这个相对简单的 action creator 就行了。
这样也更符合实际在运作的方式,
他按下这个按钮做的 action 就只是送出 request 而已,
剩下的部分就是让 saga 中的 generator 去管理,
而且经由这样的拆分,我们发现接下来能够实作 cancel
。
就是 saga 中的 compensating
这里的 code 就请到 github 上面去看了 XD
总之我们得到了一样的效果,但是更容易测试以及维护:
Abortable flow(compensating transaction)
前面有说到要实作取消这个功能,
在 promise 中是很困难的,因为 promise 没有办法 abort。
不过我们活用 generator 的,就有办法很直观的实作出这个功能来。
首先当然是先做出 cancel 这个 action,
以及让 reducer 根据这个 action 作出映射的改变。
完成了之后,接下来就是 saga 的重头戏了。
fork
and cancel
首先我们要将原本的 loginFlow 拆分成两部分,
第一部分是原本的 login 流程:
|
|
第二部分则是取消 login:
|
|
这里我们看到两个新的 effect,第一个是 fork,
语法基本上跟 call 相同,
不同的部分是 fork 跟我们在 git 上面的 fork 一样会开一支 branch出来处理,
当 yield fork effect 之后,
就会自动开一条 branch 执行下去,这里有个 @kuy 做的图:
而如果我们在上述 task 完成之前,就接收到了 loginCancel
这个 action,
那所有在 task
里面的动作就会被 abort 掉!
是不是觉得有 race condition 的概念在里面,
没错,redux-saga
也提供了race
这个 effect
Test for cancelable flow
这里一样也测试以下几件事情
是否有 fork 一个新的 task
是否能处理 cancel 这个 function
拆分出来的 authorize 是否正常运作
首先当然是先看进入 loginFlow 之后有没有 fork :
|
|
接下来是是否能处里 cancel,
这里我们就需要用到 mock 了,
在最外层的地方从 redux-saga/utils
引用 createMockTask
:
|
|
这里仍然是运用了 generator 的特性来做 mock,
因为我们再隔一个动作才能取消 task,
所以在这之前我们要先把 mock 起来的 task 丢进去。
最后则是确认原本的 authorize 流程还是能正常运作,
基本上只是把原本的 test case 丢进另一个 describe 的 block 而已,
详情可以去看 repo 里的 code。
Combine cancelable loginFlow
其实这里蛮简单的,
只是添加一个按钮,按了会 dispatchcancelLogin
这个action,
一切就结束了。
像是底下这个样子:
Conclusion
结论就是我们现在终于将 saga pattern 应用在前端了,
每一个好的 UX 都会是一个 transaction,
而且比起原本的论文中,我们多了一些弹性,
可以选择要不要加上 compensating transiction。
如此一来我们的异步 action 变得更好测试,
而且也不用担心在每次处理过度复杂的数据流时,
没有依据可找了,因为我们都是在组合各种 effect 而已XD