這陣子新建了一個 blog 在此: http://blog.mz026.rocks/
之後新的文章應該都會寫在那~~
Good bye Logdown, Hello Jekyll!!
Programmer
鼓手
人夫
孩子的爹
這陣子新建了一個 blog 在此: http://blog.mz026.rocks/
之後新的文章應該都會寫在那~~
Good bye Logdown, Hello Jekyll!!
最近做了一些跟 React Redux 有關的 projects。Redux 的設計有加入了很多 functional programming 的概念在裡面,其中我覺得最優雅的是 middleware 的設計和作法。在讀了它的 source code 之後,決定寫一篇關於它的文章。
這篇文章會假設大家都已經對於 React, Redux, Redux middleware 的用法和想要解決的問題都有一定程度的了解。如果還有不了解的地方,建議可以先看一下之前的這篇介紹 Redux 的文章喔 :D
在這篇文章裡面,我想要帶大家走過 Redux middleware 的 source code,並且從中討論關於 functional programming 的部份。
關於 javascript 究竟算不算是一個 functional programming 的語言,可能會有很多不同的看法。我認為,從定義上的 functional programming 來說,它可能算不上是一個 funcational language。畢竟它沒有天生的 immutable data,也沒有處處充滿 functional language 常見的遞迴。但是以 "function 是 first class citizen" 和 closure 來看,它倒是很 functional 沒錯。
什麼是 redux middleware 呢?
Redux 的 middleware 是介於 store.dispatch
和 reducers
之間的中間人。更精準地說,我們 call 的 store.dispatch()
其實是由一層一層的 middleware 所組成,到最後一層才會進到 reducer
。可以用下面這張圖來表示:
其中每一個 middleware 的工作都是根據某些條件,動態地修改/取消 action
,也就是說,當一個 action
進到一個 middleware 的時候,
store.getState()
) 被修改在定義一個 middleware 的時候,它的 function signature 長成這樣:
({dispatch, getState}) => next => action => {
//middleware content here
}
這個很多箭頭的東西是 ES6 的語法,如果用 ES5 寫的話是這樣:
function(store) {
return function(next) {
return function(action) {
var dispatch = store.dispatch;
var getState = store.getState;
//middleware content here
}
}
}
用這樣的作法,我們可以利用 javascript 的 closure,把 {getState, dispatch}
, next
pass 進去,進而讓 middleware 可以拿到這些東西。
用這樣一層一層的作法有什麼用意呢?答案是
store
(更準確的說是getState
和 dispatch
) 丟進去,而不用在每次執行 dispatch
的時候把 store
傳來傳去。next
用上面的結構傳進去,可以讓 middleware 被以 compose
的方式串起來 (下面會說明)我們從 middleware 的使用端這邊切入會比較好了解整體的架構。
在 Redux 裡面,我們把 middleware 掛上去 store 的作法是這樣:
// configureStore.js
import { applyMiddleware, createStore } from 'redux'
return createStore(
rootReducer,
initialState,
applyMiddleware(middleware1, middleware2)
)
首先遇到的是 createStore
:
createStore(reducer, [initialState], [enhancer])
:createStore
這個 function 接的參數分別是
createStore
的 function, 然後 return 一個和 createStore
一樣介面的 function"。事實上我們透過 applyMiddleware
return 出來的結果就是一個 enhancer。利用不同的 enhancer
,我們可以改變 store 的行為。事實上 applyMiddleware
所產生出來的 enhancer
就是改變了 store.dispatch
的行為。
我們如果仔細看 createStore
的程式碼的話,會發現這段:
if (typeof enhancer !== 'undefined') {
if (typeof enhancer !== 'function') {
throw new Error('Expected the enhancer to be a function.')
}
return enhancer(createStore)(reducer, initialState)
}
也就是說如果我們有把 enhancer 傳進 createStore 裡面的話,那基本上得到的 store 就會是透過 enhancer 傳出來的結果。
applyMiddleware
很明顯的,把 middleware 連起來的工作是由 applyMiddleware
這個 function 完成的。applyMiddleware
所回傳出來的結果就是上述的 enhancer
。source code 如下:
// src/appliyMiddleware.js
export default function applyMiddleware(...middlewares) {
return (createStore) => (reducer, initialState, enhancer) => {
var store = createStore(reducer, initialState, enhancer)
var dispatch = store.dispatch
var chain = []
var middlewareAPI = {
getState: store.getState,
dispatch: (action) => dispatch(action)
}
chain = middlewares.map(middleware => middleware(middlewareAPI))
dispatch = compose(...chain)(store.dispatch)
return {
...store,
dispatch
}
}
}
在上面的 configureStore
裡面,我們把 middleware1
, middleware2
傳進 applyMiddleware
。
applyMiddleware(middleware1, middleware2)
而接下來我們就可以好好看看在這個 enhancer 裡面,我們是怎樣利用 middleware1
, middleware2
去改變 dispatch
的行為
首先,我們先用"最原始的 createStore
建出一個 store",這個 store 是還沒被 enhancer 改變過的,我們在下面稱它作"原始的 store":
var store = createStore(reducer, initialState, enhancer)
接著,我們宣告一些 local variables, 其中的 middlewareAPI
有兩個 key:
getState
: 這是 "原始的store" 的 getState
, 事實上在後面我們會發現,在被 enhancer 改變的過程中,store.getState 其實是不受影響的。dispatch
: 這是我們企圖利用 applyMiddlware
來改變的部份。注意他是一個 function 包著外面的 local variable dispatch
, 但這個 local variable 會隨著程式的進行而改變。就是下面的這行: dispatch = compose(...chain)(store.dispatch)
接著,我們用上面的 middlwareAPI
map 出 chain
這個 local variable,根據上面 middleware 的定義,這個 chain
其實長得大概像這樣:
chain = [
function middlewareCreator1(next) {
// with getState, dispath
},
function middlewareCreator2(next) {
// with getState, dispath
},
...
]
在 chain 的每一個 element 裡面,都可以 access 到 middlewareAPI
的 getState
和 dispatch
在 map 出 chain
之後,我們先把些"接 next
" 的 function 們 compose
起來, 再把本來最原始的 store.dispath
(還沒被 enhancer 改變過的版本) 丟進去,並且 assign 給 dispatch
這個 local variable 讓 middlewareAPI.dispatch
可以拿到這個新版的 dispatch:
dispatch = compose(...chain)(store.dispatch)
這一行其實做了蠻多事情的,我們可以把它拆開來看:
var comopsed = comopse(...chain)
dispatch = composed(store.dispatch)
compose(...chain)
compose
做的事情是把傳進來的多個 function 串起來, 簡而言之大樣是這樣:
compose(f, g, h)
在結果上相當於
function(...args) {
f(g(h(...args)))
}
如下圖所示:
我們可以把每一個 function 想像成一個盒子,把一個球(input)丟進去這個盒子之後,會吐出另一個球(output)。由於我們要把多個盒子(function)串起來,也就是說從第一個盒子吐出來的球,會被丟進第二個盒子作為input,所以每個盒子的input 和 output 必需要有同樣的格式。而 compose
做的事情,就是把多個盒子拼裝起來變成一個大的盒子,所以這個大的盒子從 input/output 的格式來看,是會和每一個小盒子是一樣的。也就是我們可以把一個球丟進這個大盒子,這個大盒子會吐出另一個球。
有了上面 compose
的概念之後,我們可以回頭來看我們的 middleware chain。在上面的 chain
裡面,每一個盒子(middlewareCreator)的 input 都是一個叫 next
的 function,每一個盒子的 output 也會是一個 function, 用來丟進下一個 middleware 作為 next
。這正是 redux middleware 運作的方式:在每一個 middleware 裡面可以選擇性地呼叫下一個 middleware(next
)。
以上面的盒子、球的觀念來比喻的話,每一個middleware creator(function(next){...}
)就是我們說的"盒子",而實際上對每一個 action 進行操作的 function (function(action){...}
) 就是我們所說的"球"。在下面的程式裡
var comopsed = comopse(...chain)
dispatch = composed(store.dispatch)
我們把第一顆球 store.dispatch
(它會把 action
丟給 reducer
) 丟進 compose
過後的大盒子裡面,得到一個被層層 middleware 包裝過的大球。然後把這個大球作為新版的 dispatch
放回 store 裡面:
return {
...store,
dispatch
}
這樣一來,我們在 application 裡面所呼叫的 store.dispatch
就會變成這個被包裝過的大球。這也就是為什麼我們在 application 裡面 dispatch
一個 action
,它會先經過層層 middleware
才到達 reducer
的原因。
耶!講完惹!
對我自己來說,在接觸到 Redux 之後,覺得對於 Javascript 這個語言有了更進一步的了解和認識,其中很大的一部份就是來自於它和 functional programming 的關係。在 application 架構的設計上,運用 closure 可以把一些 dependencies 藏起來,讓程式的其他部份變得更單純。
這種方式的缺點可能就是程式讀起來要花比較多一點時間去習慣和了解,但以一個 library/framework 的角度出發,如果這樣可以換來更單純的 application code,我想是非常值得的。
而且這樣寫起來漂釀又有趣阿!
最近手上的 rails project 遇到了 memory 的問題。經過一番努力之後,有發現一些原因也做了相對應的處理。把一些東西記錄在這邊。
我們的 project 是 host 在 Heroku 上面的 Rails project。發現 memory 的問題的症狀是 Heroku 一直噴 R14 (memory quota exceeded)。就此開啟了 memory 狩獵之旅 XD
從 wiki 上面的定義上來說, memory leak 是指:
In computer science, a memory leak is a type of resource leak that occurs when a computer program incorrectly manages memory allocations in such a way that memory which is no longer needed is not released.
也就是說我們不需要用的的東西,由於某些失誤,導致我們無法釋放這些 memory, 久而久之隨著 app 越用越久,app 使用的 memory 就越來越多,最後機器上面的 memory 就不夠了。
在像 ruby 這種有 garbage collection 的語言裡面,通常這種狀況是發生在有不要用的東西被 reference 到,導致 GC 無法回收 memory。好比說在一個 global 的 namespace 下面不小心一直積太多東西等等。
Memory 的問題在各個環境下都有可能發生,包括在 mobile app, browser, 或 server 上面等。在 server 上的問題比較麻煩的地方就是,你不知道要怎麼 reproduce 它。由於 server 的運作是被動的接收 request,所以當我們發現有 leak 的時候,通常是看到某種 "時間-memory用量" 的數據之後才發現說 "阿,在某某時候 server 的 memory 爆衝了",接著我們才去看 request log, 看看那個當下到底發生了什麼事情。但是通常 server 的 request 數量,在短時間之內就會有超極多,所以幾乎無法用肉眼直接看出說 "喔喔!就是這個 page/api 害的!",我認為這是當 memory issue 發生在 server 上的時候的難處。
我們的 server app 是用 ruby on rails 寫的,其中 ruby 用的是 MRI (c ruby)。理論上 ruby 有行之有年的 garbage collection 機制,所以當我們 server 出現不尋常的 memory 使用爆衝的時候,應該是先從自己的 application 開始找才對,一方面是以機率上來說,我覺得自己 application 的失誤應該是 >> C ruby 的失誤。再者,即使最後真的是 c ruby 的失誤,那其實我們能做的大多也只有等他的 patch 而已 XD
在看到 server stats memory usage 步步高升之後,其實先不是急著找 memory leak。原因是因為很多時候 server 會 lazy load 很多東西,也就是說他一開起來的時候拿到的 memory 量會少於最後真正要用的。
要判斷是不是真的有 leak,要看 memory 用量狀況有沒有隨著時間而達到一個飽和的值。如果 memory 用量有隨著時間趨近於某一個值的話,那應該就不是 leak 才對。那當 memory 用量一直衝高的時候,要怎麼知道他會不會達到飽和呢?(就是要怎麼確定它是不會飽和,還是只是還沒飽和呢?)我的做法就是先把機器 memory 再開大一點,大到你覺得 "靠!都衝到這麼高了應該是沒救了吧!" 為止。
在確定 server 真的有 memory leak 之後,接下來就是要看到底是什麼地方出了問題。在經歷了一番 google 之後,發現 ruby 有一個叫 ObjectSpace
的東西,可以幫我們 dump 出在某一個特定時間之內的所有 objects, 包括它們的 size, file, address 等等。
OK!那我們 dump 出這些東西之後下一步是什麼呢?在正常的狀況下,dump 出來的東西會有成千上萬個,(在server上可能會有大約一兩百萬個左右) 也就是說用肉眼直接看是看不出什麼鬼的。但我們可以用比較聰明的方法來做(註1):
假設我們把任何一個時候,程式裡面所有的 object 分成三類:
很明顯的,我們真正在乎的是第三種:leak 的那些。
如果我們在程式運行當下,選擇三個不同的時間分別去 dump 那個當下的所有 objects, 我們會得到下面的圖:
其中:
在得到這三個 snapshot 之後,接下來我們要做的事情就是:
從第二個 snapshot 出發,
有了上面的概念之後,我們就可以實際上來做了。
要得到上面的 snapshot,我們要做的事情是,在程式的最一開始去 require 'objspace'
,然後再讓它開始 track, 最後在某個時候 dump 出來:
require 'objspace'
ObjectSpace.trace_object_allocations_start
# your application code here
GC.start
io = File.open("snapshot_file", "w")
ObjectSpace.dump_all(output: io)
io.close
在 rails project 的話,可以把最上面兩行放在 config/application.rb
之類的地方。
接下來的問題是,如果是 rails app 的話,要怎麼把 snapshot dump 出來呢?
如果是在 local 的話,(或你可以 ssh 進去 server 的話),可以用 rbtrace
來做到這件事情:
require 'rbtrace'
bundle exec rbtrace -p #{pid} -e 'Thread.new{GC.start;require "objspace";io=File.open("dump_file", "w"); ObjectSpace.dump_all(output: io); io.close}'
其中 pid
是 server process 的 id
那如果是在 heroku 上面,也就是我們無法 login 進去的話怎麼辦呢?
我之前的做法是把它寫成一個需要權限的 API,然後把 dump 出來的檔案下載下來。
(基本上在 production trace object allocation 會讓你的 app 變得很慢又很吃 memory, 所以如果可以在 local 做就儘量在 local 做,真的要在 production 上面做的話,弄完記得趕快刪掉 XD)
在經歷千辛萬苦終於到手的 snapshot 大概長這樣:
{"address":"0x7f9cff6dc150", "type":"STRING", "class":"0x7f9cff7340d0", "embedded":true, "bytesize":1, "value":"w", "encoding":"UTF-8", "file":"test.rb", "line":20, "method":"dump", "generation":7, "memsize":40, "flags":{"wb_protected":true}}
{"address":"0x7f9cff6dc290", "type":"STRING", "class":"0x7f9cff7340d0", "frozen":true, "embedded":true, "fstring":true, "bytesize":13, "value":"require_paths", "encoding":"US-ASCII", "memsize":40, "flags":{"wb_protected":true, "old":true, "uncollectible":true, "marked":true}}
{"address":"0x7f9cff6dc2e0", "type":"STRING", "class":"0x7f9cff7340d0", "embedded":true, "bytesize":12, "value":"result1.dump", "encoding":"UTF-8", "file":"test.rb", "line":29, "generation":7, "memsize":40, "flags":{"wb_protected":true}}
{"address":"0x7f9cff6dc3a8", "type":"STRING", "class":"0x7f9cff7340d0", "frozen":true, "embedded":true, "fstring":true, "bytesize":9, "value":"@metadata", "encoding":"US-ASCII", "memsize":40, "flags":{"wb_protected":true, "old":true, "uncollectible":true, "marked":true}}
基本上就是一個有很多行很多行的檔案,一行是一個 json
object,所以可以簡單的用各種東西去 parse, 進一步做上面提到的分析。如果是用 ruby 來做分析的話,我寫了一個簡單的版本在這兒。
在經過上面的分析之後,發現兇手竟然是 NewRelic!! 暫時把 newrelic 移掉之後,memory 用量的狀況是改善很多。以為從此天下太平,結果並沒有阿!
在把 newrelic 先移掉之後,發現時不時還是有 memory 狂噴的情形。像這樣:
但不幸的是,用上面 ObjectSpace
的做法也看不出個所以然來。這時候老衲心生一計:何不看看 Papertrail 的 request log?
在把 Papertrail 上面的 log archive 下載下來並且做了一些整理之後,做了以下的分析:
假設 memory 激增的時間是區間 A,為期十分鐘,
然後把上面三個拿來比較,看看區間 A 裡面究竟是比別人多做了什麼好事。
在分析之後,發現這個區間之內確實有比相鄰的區間多出某些 page 的 request, 但是重複打這些 page/api 發現也是無法 reproduce 阿!!怎麼辦??
這樣的結果雖然令人崩潰,但是卻開啟了另一道曙光:
如果 request 本身無法 reproduce memory 的爆衝的話,那不是代表真正的兇手在 request 以外的地方嗎?像是 backgroud job 之類的!(右手背拍左手心)
從這個方向出發,果然發現了有一些 background job 有一些很大的 query, 會一次 load 進來很多 objects, 所以嚴格來說這些不算是 leak, 而是在短時間之內突然需要很大量的 memory, 並且 ruby 不會在用完之後立刻把這些要來的 memory 還給 OS, 所以造成了 memory 不夠的狀況。
Memory 的問題遇到了就是有點麻煩阿,基本上沒有什麼快速又好的解法,就是要慢慢搞。但如果可以把問題 reproduce 出來應該就解了一半。
Papertrail 的 log 嚴格來說並不是很精準,原因是在 heroku 的 addon 上面的話,只有 method, url 而沒有更進一步的 request 資訊。這個部份我有試著做了一個 rack middleware 叫 log_spy,把每一個 request 的詳細資訊丟上 amazon SQS,讓後續的 service 可以接著把這些 queue 上面的東西存到各種 database 等等。
再者是找到問題的點之後要調整 memory 用量的話,調整的方法就看問題的點是什麼了。但原則上我認為在問題發生之前,不要太早做各種 optimization 而犧牲了 code 的架構和可讀性。因為通常在問題發生之前做的 optimization 也都不會是真正的問題點,反而把 code 寫整齊,要找問題或是要修正都會比較容易得多。
Premature optimization is the root of all evil.
註1:我覺得這個方法真的蠻聰明的,但可惜不是我發明的QQ。這個方法是從這篇 blog 上面來的。
在 React 的開發工具當中, Webpack 可以說是相當詭譎神妙的一員。
他在某些方面取代了很多本來用 gulp/grunt 做的事情,像是 pre-process, post-process javascript/css 等,但同時也因為它的設定比較複雜,有時候蠻令人頭大的。
我想在這篇文章分享我自己在使用 webpack 的時候,遇到關於 environment variables (環境變數) 的問題和解法。
在開發的時候,我們常常會把一些設定參數、thrid-party service keys 等等的東西放在環境變數裡面 (而不是直接 commit 進去 repository)。像是 API_HOST 的 url,或者是 AWS 的 access key 等等。
所以在我們的程式裡面可能會有以下這樣的做法:
// app.js
var config = require('./config');
var agent = require('superagent');
agent.get(config.apiHost + '/the-api-path');
而其中的 config 檔可能長這樣:
// config.js
module.exports = {
apiHost: process.env['API_HOST'] || 'http://localhost:3000'
}
如此一來,我們就可以把不同環境的 API_HOST 設定在 environment variable ($API_HOST
)裡面,並且給定一個預設的值 (http://localhost:3000
)。
但是在把上面的 code 用 webpack build 起來之後,我們發現事情並不是憨人所想的那麼簡單沒有那麼單純。
為了方便說明,我們可以先暫時把上面的 app.js
改成這樣:
var config = require('./config');
console.log(config.apiHost);
這樣我們就可以直接執行 build 好的 code 然後把結果 log 出來。
我們如果把上面的 code 直接 build 起來:
$ webpack app.js --output-filename bundle.js
然後接著執行 bundle.js
:
$ API_HOST=http://production-api.com node bundle.js
這邊我們預期要得到的結果應該是 http://production-api.com
但實際上的結果卻是 http://localhost:3000
也就是說,bundle.js
在執行的時候,完全不管我們 pass 進去的 environment variable 阿!
接著我們試著在 build 的當下就 pass environment variable 進去。其實這樣的做法比較接近實際上 deployment 的狀況 (像是在 Heroku 上面設定好相對應的 environment variables, 然後在 server 上面把 code build 起來)
$ API_HOST=http://production-api.com webpack app.js --output-filename bundle.js
然後同樣執行 build 好的 bundle.js
$ API_HOST=http://production-api.com node bundle.js
什麼?!!結果還是一樣阿靠!
執行的結果還是一樣是 http://localhost:3000
而不是我們想要的 http://production-api.com
!
在直接把 bundle.js
打開來看之後,我們發現他裡面寫著:
process.env = {};
難怪 pass 什麼東西進去都沒用阿。
在經過一番 google 之後,發現原來可以用 webpack 的 DefinePlugin 來解決這個問題。我們新加一個檔案 webpack.config.js
:
// webpack.config.js
var webpack = require('webpack');
var envToBeInjected = {
API_HOST: process.env.API_HOST
};
module.exports = {
plugins: [
new webpack.DefinePlugin({
'process.env': JSON.stringify(envToBeInjected)
})
]
};
然後再執行上面的 build script:
$ API_HOST=http://production-api.com webpack app.js --output-filename bundle.js
然後再試一下:
$ node bundle.js
耶!終於出現夢寐以求的 http://production-api.com
了。
上面我們在 webpack.config.js
裡面,用 DefinePlugin
把 process.env
這個變數用
{ API_HOST: process.env.API_HOST }
代換進去。
webpack.config.js
是在 build 時候被執行,這就是為什麼我們要在 build 的時候 pass API_HOST=http://production-api.com
的原因。而在 webpack.config.js
裡特別把 API_HOST
抽出來而不是直接 pass 整個 process.env
的原因是因為,在 server 上面的 environment variables 裡面可能還有其他的東西像是其他 service 要用到的 key 等等。這樣東西還是不要一起 build 進去比較妥當 XD
而 webpack build 好的 bundle.js
是直接把 build 的當下的 environment variable 寫進去,這也是為什麼我們在最後執行 bundle.js
的時候,不需要在另外 pass API_HOST
的原因。
最近陸續用 React + Redux 的組合,做了一些專案。有從頭到尾都用 Redux 的,也有把 Redux 接上現有的 Fluxxor 的。
目前 Redux 也算是相當夯阿,趁還沒忘記趕快來寫一下。
這個系列總共有三篇文章:
這篇文章是系列的第一篇,在這篇文章裡面我會介紹什麼是 Redux,Redux 怎麼運作的,還有一些有趣的東西。
我自己對於這邊文章的預期是,單看完這邊文章還是無法用 Redux 寫出一個 web app 的 (請不要立刻關掉 tab XD)。但看完這邊文章之後,再去讀 Redux 的 document,應該會更有一個全面的藍圖,在之後的開發需到問題應該也會比較有方向。
Redux 是一個在 javascript app 裡面用來控制 state 的 framework。根據他官網上寫的:
Redux is a predictable state container for JavaScript apps.
在一個 app 裡面,有著各式各樣會隨著時間、 user 行為或各式各樣其他原因而改變的的狀態 (state)。
我們可以把一個 app 視為一個改變 state 的過程,而 user 看到的東西(view),就是這些 state 的呈現。
好比說,在一個 Todo List 的 app 裡面,我們新增了一個 todo 項目,其實就是改變了這個 app 的 state, 從沒有 todo 項目到有一個 todo 項目;也因為 app 的 state 被改變了,又因為 view 是 state 的呈現,於是我們在畫面上(view)看到了一個我們剛才新增的 todo 項目。
如果說不幸地,我們的 app state 在某一個時候表現得和我們預期的不同,那 debug 的時間就到了。
例如說我們新增了一個 todo item,但我們的 state 卻增加了兩個而不是我們預期的一個,那這時候,我們就必須花時間去找究竟在哪一步 state 的改變發生了錯誤。
這件事情說來單純,但事情不是憨人所想得那麼簡單現實世界的狀況往往要來得複雜得多。有各種原因會讓這一切變得很可怕,包括寫程式的習慣不好、framework 設計的缺點、沒有好好寫測試等等。
Redux 企圖在 framework 的設計上去解決這個問題。更精準地說,Redux 是延伸了 Flux 的概念,然後簡化了一些重覆的東西而設計出來的 framework。就像他官網寫的:
Redux evolves the ideas of Flux, but avoids its complexity by taking cues from Elm.
本來的 framework 其實有千千萬萬種風情萬種。但大致上可以歸納成 MVC 或者是 MVC 的延伸。而即使都是 MVC,各家 implementation 的方式也都略有不同。
Flux/Redux 想解決的點是:在 app 使用的過程中,model 被連續 update 而導致有 bug 很難 trace 的問題。
就像這張圖畫得一樣:
from Quora
在這張圖下面,任何 view 都有可能改動到任何 model, 而任何 model 也有可能影響到每一個 view。這樣的缺點就是,當 view 和 model 的數量都很多的時候,當有一個 model 的 state 變成我們預期之外的樣子的時候,我們無法有效率地去追溯是哪一個 view 或 model 造成的,因為可能性太多了。
Flux/Redux 的解法就是把 model (或是叫 store) 的 setter 去掉,讓 store 透過 action
來 update 自己的 state,(自己的 state 自己管)(就是 Flux 官網上寫的 unidirection data flow
),並且在每一個 action
dispatch 裡面,不能再重覆 dispatch 另一個 action
。如此一來,store updating 從被動變成主動了;如果有什麼錯誤發生的話,要追溯只要看這個有問題的 store 他經歷的事件過程,就可以比較容易地發現 bug, 因為防守的面剩下一個了。
Redux 可以分成下面幾個部份:
store
:管理 state 的中心,主要有一個 dispatch
的 method 用來 dispatch action
。在 Redux app 裡面,整個 app 的 state 可以透過 store.getState()
來拿到。
action
:一些單純的 object (plain javascript object)。可以視為一些 用來改變 state 的指令.reducer
:state 改變的入口。功能是接到 action
之後,負責決定 state 要怎麼改變。reducer
是一些 function,它改變 state 的方法是把 action 當成參數接進來,然後 return
出新的 state。middleware
:介在 store.dispatch()
和 reducer
之間的協調者。它的作用是可以攔截被 dispatch
出來的 action
, 在它們抵達 reducer
之前,去修改甚至取消這些 action
。像上面的圖所示,假設我們今天要在 Redux 的架構下新增一個 todo item, 我們會建一個 action
帶有某一種 type(ADD_TODO
),然後用 store.dispatch()
把這個 action dispatch 出去。
// actionCreator
export function addTodo(text) {
return { type: types.ADD_TODO, text };
}
store.dispatch(addTodo({ text: 'Clean bedroom' });
接著這個 action
就會進入 middleware
,最後進到 reducer
。而在 reducer 裡面,則會根據不同的 action type 來處理 state 的轉換:
// reducer
function todos(state, action) {
switch(action.type) {
case 'ADD_TODO':
// handle action and return new state here
}
}
這樣我們就完成了最基本的 state updating 的行為了。
如果是在一個比較複雜的 app 裡面,通常我們會需要把 store 的 state 切成一些不同的區塊,就像是 namespace 那樣。
在 Redux 要做到這件事情的作法是,建立不同的 reducers 分別去管理不同區塊的 state,然後再用 combineReducers
把它們合起來。
如果延續上面的圖示,就會長這樣:
在剛才我們已經介紹了最近本的 state updating 的行為。但在一個前端的 app 裡面並不是一切都那麼單純地!
這邊要介紹的是,如何在 Redux 下完成 asynchronous(非同步) 的行為呢? 在下面我用一個發 http request 的行為作為例子說明和 async 有關的作法。
假設我們的 app 會呈現一個 questions
的列表。我們在某個時候(好比說 user 按下了按鈕之類的)要發一個 request 到 server 去拿 questions
的 data 下來;在發送 request 的過程中,我們要在 store 裡面表達 sending
的資訊,如果 request 成功了,就要把 questions
的 data 放在 store 裡面,如果失敗了,我們要在 store 裡面表達 request 失敗的訊息。
要達成上面的行為,最直覺的作法就是:在不同的時候分別 dispatch
不同的事件:
在下面的例子,我用 superagent 來作為發 request 的工具
import request from 'superagent';
const SENDING_QUESTIONS = 'SENDING_QUESTIONS';
const LOAD_QUESTIONS_SUCCESS = 'LOAD_QUESTIONS_SUCCESS';
const LOAD_QUESTIONS_FAILED = 'LOAD_QUESTIONS_FAILED';
store.dispatch({ type: SENDING_QUESTIONS });
request.get('/questions')
.end((err, res)=> {
if (err) {
store.dispatch({
type: LOAD_QUESTIONS_FAILED,
error: err
});
} else {
store.dispatch({
type: LOAD_QUESTIONS_SUCCESS,
questions: res.response
});
}
});
這樣我們就可以在 Redux 下面達成 async 的行為了。但是這個作法最主要的缺點是:如果我們有很多地方要發 http request, 我們必須在每一個地方都加入 async 的行為,這樣一來會大大提高 code 的維護成本,並且對 code 的測試也是相當不利,因為 async 的 code 還是比較難懂和比較難測阿。再者,如果加入了 React 之後,這樣的作法會讓我們被迫把這樣的邏輯放在 React Component 裡面。
比較理想的作法是我們可以把性質類似的 async 的行為抽象出來放在同一個地方,於是我們要來介紹 middleware
惹!
Redux 的 middleware 是介於 store.dispatch
和 reducers
之間的中間人。更精準地說,我們 call 的 store.dispatch()
其實是由一層一層的 middleware 所組成,到最後一層才會進到 reducer
。可以用下面這張圖來表示:
透過 middleware, 我們可以把上面的 async api request 的行為抽象出來,放在同一個 middleware 裡面。意思是說,我們在需要發 api request 的地方 dispatch
一種特定的 action
,然後在 middleware 去攔截這種 action
來發相對應的 api request。所以我們可以把 middleware 寫成這樣:
// middlewares/api.js
import superAgent from 'superagent';
export const CALL_API = Symbol('CALL_API');
export default store => next => action => {
if ( ! action[CALL_API] ) {
return next(action);
}
let request = action[CALL_API];
let { method, path, query, failureType, successType, sendingType } = request;
let { dispatch } = store;
dispatch({ type: sendingType });
superAgent[method](path)
.query(query)
.end((err, res)=> {
if (err) {
dispatch({
type: failureType,
response: err
});
} else {
dispatch({
type: successType,
response: res.body
});
}
});
};
在上面的 code 裡面,這個 middleware 會攔截有 CALL_API
這個 key 的 action
,然後根據裡面的 method
, path
, query
, successType
等,去決定怎麼發送 api request。
如此一來,我們在真正需要發 request 的地方就不需要特別處理 async 的邏輯,而只要 dispatch
出一個帶有 CALL_API
的 action 就行了。
import { CALL_API} from 'middlewares/api';
const SENDING_QUESTIONS = 'SENDING_QUESTIONS';
const LOAD_QUESTIONS_SUCCESS = 'LOAD_QUESTIONS_SUCCESS';
const LOAD_QUESTIONS_FAILED = 'LOAD_QUESTIONS_FAILED';
store.dispatch({
[CALL_API]: {
method: 'get',
path: '/questions',
sendingType: SENDING_QUESTIONS,
successType: LOAD_QUESTION_SUCCESS,
failureType: LOAD_QUESTION_FAILED
}
});
這樣把 async 的行為集中在 middleware 裡面的作法,除了讓要發送 request 的點不用寫重覆的邏輯之外,同時也讓測試變得非常單純:由於 "dispatch
帶有 CALL_API
的 action
" 這件事情現在是一個 synchronous(同步) 的行為了,因此避開了 async 的測試。
我覺得 Redux middleware 的設計,是這個 framework 之所以這麼優雅的主要原因之一。透過 middleware 我們可以把各種本來會重覆的邏輯抽象出來,像是上面舉例的 async api request, 或者是 log 等等的行為。這個概念我覺得和 Chain of Responsibility Pattern 有點像。同樣的概念也出現在 Express middleware 和 Rack middleware
在介紹完這麼長一串和 Redux 有關的內容之後,突然發現 咦?還沒提到 React!!
沒錯!Redux 本身確實和 React 沒有直接的關係。
Redux 的角色是控制 app 裡面 state 的轉換。而 React 則是根據這些 state 來把 view render 出來。
React 的強項是透過 virtual dom 讓開發者可以在 state 改變的時候,把整包新的 state (在 React 裡面叫 props
)丟給 React 重新 render,React 會有效率的完成 re-render 這件事情。也就是說,app 的開發者不用管 state 的轉換究竟是哪一部份的 state 被改變了,只要把整包都請 React 重新 render 就可以了。
在 Redux 的架構下,和 React 結合的方法就是,我們找到某些 "比較上層的 React Components", 在這些 components 裡面把 Redux 的 state 設成 component 的 state。然後在這些 state 改變的時候用我們熟悉的 component setState
來 trigger re-render。如此一來我們就把 Redux 的 state 和 React 結合在一起了。
有了這些 "比較上層的" components 之後,接著我們就可以把這些 components 進一步再切割成更小的 components,並且把 Redux 的 state 以 React 的 props
去建立這些 "更小的 components"。而這些"更小的 components" 其實和 Redux 就又沒有直接關係了,因為它們的行為完全由 props
來決定。對於這些 "更小的 components" 來說,它們並不在乎這些 prop
究竟是從 Redux 來的或者從其他地方來的。
上面提到的 "比較上層的 component" 其實在 Redux 裡的正式名字叫 Smart Component,而 "更小的 components" 叫作 Dumb Component。其中把 Redux state 和 Smart component 接起來的工作,則是由 React-Redux 裡提供的 connect
function 來負責。
下面的例子是 Redux 官方的 TodoMVC 範例,程式的進入點 index.js
長這樣:
import 'babel-core/polyfill';
import React from 'react';
import { render } from 'react-dom';
import { Provider } from 'react-redux';
import App from './containers/App';
import configureStore from './store/configureStore';
import 'todomvc-app-css/index.css';
const store = configureStore();
render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);
上面我們可以看到,我們的 app component App
被一個叫作 Provider
的 component 包起來,並且這個 Provider
component 還用 prop
的方式來指向我們 app 的 state: store
。
我們的 App
component containers/App.js
:
import React, { Component, PropTypes } from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import Header from '../components/Header';
import MainSection from '../components/MainSection';
import * as TodoActions from '../actions/todos';
class App extends Component {
render() {
const { todos, actions } = this.props;
return (
<div>
<Header addTodo={actions.addTodo} />
<MainSection todos={todos} actions={actions} />
</div>
);
}
}
App.propTypes = {
todos: PropTypes.array.isRequired,
actions: PropTypes.object.isRequired
};
function mapStateToProps(state) {
return {
todos: state.todos
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators(TodoActions, dispatch)
};
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(App);
這邊我們發現 App
其實也只是一個單純的 React component, 和 Redux 沒有直接的關係。但這個檔案最後 export
出去的東西並不是 App
component, 而是 connect
之後的結果。
也就是在這步 connect
的過程中,讓我們的 App
component 真正和 Redux 連結起來:
在 connect
這步,我們把 mapStateToProps
和 mapDispatchToProps
傳進去。
其中第一個參數 mapStateToProps
的功能是把 Redux 的 state (store.getState()
) 的結果 filter 出這個 Smart component 所需要的,然後用 prop
的方式 pass 給這個 component。
以上面的例子為例,這個 mapStateToProps
return 的東西是
{
todos: state.todos
};
因此我們在 connect
過後的 App
component 裡就可以用 this.props.todos
來拿到 store.getState().todos
。
而第二個參數 mapDispatchToProps
則是負責把本來只是單純 return action
object 的 action creator 加上 dispatch
的功能,並且也用 prop
的方式傳給 component。
用上面的例子說明的話,本來的 TodoActions.addTodo
只是單純地回傳一個 action
object:
// actions/todos.js
export function addTodo(text) {
return { type: types.ADD_TODO, text };
}
但透過上面的 connect
,我們在 connect
過後的 App
component 就可以得到一個 this.props.action.addTodo
, 而這個 function 的功能則是 "dispatch
本來 action creator 回傳的 action
object",相當於:
dispatch(ActionCreators.addTodo(...))
connect
是怎麼拿到 store
的呢?從剛才的 containers/App.js
我們可以發現,從頭到尾沒出現 store
!!
那這樣的話,connect
要把 component 和 Redux state 連結起來的時候,他是怎麼拿到 store
的呢?
答案就在一開始 index.js
那邊的 Provider
。
在index.js
我們把 store
用 prop
的方式傳進 Provider
,而在 Provider
裡面,則會把這個 store
設成它自己的 React context
(註1)。這樣一來,所有 Provider
的 child components 都可以用 context 的方式拿到這個 store
。所以 connect
之後的 App
就是用 context
的方式拿到 store
的。
我覺得 Redux 有很多部份的設計非常優雅。和之前用的 Fluxxor 比較起來可以少寫很多很多重覆的 code 和邏輯。
其中我最喜歡的部份是 middleware
的部份。在讀了 middleware 這邊的原始碼之後,發現他用很 functional 的方式來處理。我本身對於 functional programming 並不是很熟悉,所以對我來說算是開了眼界,也讓我一個有重新認識 javascript 這個語言的起點。
另外把和 React 結合的部份拆出來也是一個很棒的作法。
對於測試來說,和 view 相關的測試通常比較複雜一點。而 Redux 這樣的作法也讓測試有一個很容易的切入點:由於 Redux 把和動態 data 直接相關的部份關在 smart component,如此一來 dumb components 的行為就完全只和 props
有相關。如此一來要測試這些 dumb component 就變得單純很多。
在 React 0.13 以前,context
是 owner-based 的。而在 0.14 開始全面改成 parent-based。這也就是為什麼和 React 0.13 以前的版本一起用的時候,Provider
的進入點會需要在 App
外面多包一層 function, 目的是為了避開 parent-based and owner-based contexts differ
的錯誤
<Provider store={store}>
{ ()=> {<App />} }
</Provider>
最近陸續用 Redux 做了一些 projects,想要把一些心得整理出來。
目前預計整理三篇:
這篇文章是這個系列的第三部份,Unit Test。
Unit Test 整個主題其實涵蓋的主題很廣;從為什麼要做 unit test,到測試的環境設定,到把程式的架構設計成可以被測試的狀態等。在大部份的狀況下,testing code 會要根據 production code 選用的 library 或 framework 找到合適的點來切入。好比說我們的 app 如果是用 React + Redux 寫的,那要測這個 app 的做法和要測一個 AngularJS 的 app 的方法就會有很大的不同。
接下來我會大致上介紹一些測試的基本概念,然後花比較多篇幅在 Redux 和 React 相關的東西上面。
Unit Test 是自動化測試裡面最貼近程式的一種。意思是說,unit test 的測試對象通常是一個 class, 一個 function 或者一個 React component 等可以用 code 來切開的單位;用另一個角度來說,unit test 是給 developer 看的。
如果用比較高階的 acceptance test 來比較的話,acceptance test 就離程式碼本身比較遠一點,acceptance test 的測試對象可能是一個功能 (例如說 signup 的能功,從按按鈕到建立一個 user 等等)。也可以說,acceptance test 是寫給用嘴巴不會寫 code 的人看的。(像是 PM XD)
舉例來說,一個 unit test 可能是:
確保 questionReducer 在接到 QUESTION_LOADED 事件的時候,可以 return 一個新的 question 作為 state
而一個 acceptance test 可能是:
確保當 user 按下 question link 的時候,他會被帶到 question 的頁面並且看到 render 好的 question 內容
在 unit test 裡面,我們的目的是要確保我們寫的 code 和我們預期的行為一樣。
當同一份 code 越長越大,越來越多人加入一起開發之後,我們幾乎不太可能再用純人工去檢查每一個 function, 或每個 class 都如我們預期一般的運作。這時候如果有一個自動的測試幫我們在每次改動的時候,重覆地去檢查並且立刻回報給我們知道,那就可以大大地降低我們 debug 的時間。這代表參與開發的每一個人,都可以很大膽地去改動任何東西,因為只要 test 都跑過了,那幾乎就代表其他的東西沒有被不小心改壞。
這意味著 developer 可以放心地在任何時間去 refactor 程式的架構,久而久之就會形成一個良性的循環,讓這份 code 變得越來越穩定,也因為架構變好了,要加新功能或修改也可以再更短的時間內完成。總之就是好處多多潮爽der。
另外一層用意就是,測試是不會說謊的 document。
相信大家都有發生兩個星期之後回來看 code, 結果一邊看一邊罵說這誰寫的鬼,結果 git blame
一下發現靠腰是自己寫的。
Unit test 這時候扮演的角色就是,當你忘了這個 function 是幹麻用的,或者忘了要怎麼用它的時候,你可以看一下 testing code 他就會 demo 給你看。
要在一個 redux app 加上測試,大概可以分成幾個部份:
mocha
, jasmine
等等,並關把一些相關的設定弄好。在下面的介紹我會略過第一部份的細節,詳細的設定可以在這邊找到。並且假設大家對於 mocha 和 chai 的 api 都有基本的認識。
我選了 mocha + chai 的組合,然後讓測試在 nodejs 的環境下執行。在這之前我有試過用 karma 當作 test runner 讓測試跑在瀏覽器上面,最後把 karma 捨棄的原因是因為 webpack 每次的 build 還是有點慢,讓我要 TDD 的時候變得很痛苦。這個故事就比較長一點,之後有機會再說。
在下面的 code 裡面,我選用的 stack 有:
還沒 XD
在寫測試之前,我們可以把這個過程切成幾個步驟:
setup
) 去執行上面的那個行為 (execute
)verify
)在下面我不會帶到全部的 code, 但下面的 code 都放在這邊
在 Redux 的設計下,action 其實相對單純。從外面看 action 的話,action 的行為其實只有 return 一個 action object 而已。
假設我們有一個 actions/questions.js
長這樣,然後我們想測試他的 loadQuestions
的行為:
import { CALL_API } from 'middleware/api';
export const LOADED_QUESTIONS = 'LOADED_QUESTIONS';
export function loadQuestions() {
return {
[CALL_API]: {
method: 'get',
url: 'http://localhost:3000/questions',
successType: LOADED_QUESTIONS
}
};
}
套上上述的步驟:
question action creator
loadQuestions()
會回傳某個帶有 CALL_API
key 的 object,裡面有我們預期的內容我們的測試會長成這樣:
spec/actions/questions.test.js
import { CALL_API } from 'middleware/api';
// setup
import * as actionCreator from 'actions/questions';
import * as ActionType from 'actions/questions';
describe('Action::Question', function(){
describe('#loadQuestions()', function(){
it('returns action `CALL_API` info', function(){
// execute
let action = actionCreator.loadQuestions();
// verify
expect(action[CALL_API]).to.deep.equal({
method: 'get',
url: 'http://localhost:3000/questions',
successType: ActionType.LOADED_QUESTIONS
});
});
});
});
Redux 的 reducer 扮演的角色是一個 function,它接原來的 state 和一個 action,然後 return 新的 state。意思是說, reducer 是根據現有的 state 和接到的 action,來決定這個 state 要什麼樣的改變。
假設我們有一個 reducer reducers/questions.js
長這樣:
import * as ActionType from 'actions/questions';
function questionsReducer (state = [], action) {
switch(action.type) {
case ActionType.LOADED_QUESTIONS:
return action.response;
break;
default:
return state;
}
}
export default questionsReducer;
套上上述的步驟:
question reducer
LOADED_QUESTIONS
的 action 的時候,會把 action.response
當成新的 state於是我們得到的 reducer 的 testing spec/reducers/questions.test.js
:
import questionReducer from 'reducers/questions';
import * as ActionType from 'actions/questions';
describe('Reducer::Question', function(){
it('returns an empty array as default state', function(){
// setup
let action = { type: 'unknown' };
// execute
let newState = questionReducer(undefined, { type: 'unknown' });
// verify
expect(newState).to.deep.equal([]);
});
describe('on LOADED_QUESTIONS', function(){
it('returns the `response` in given action', function(){
// setup
let action = {
type: ActionType.LOADED_QUESTIONS,
response: { responseKey: 'responseVal' }
};
// execute
let newState = questionReducer(undefined, action);
// verify
expect(newState).to.deep.equal(action.response);
});
});
});
Middlware 在 Redux 裡面負責的行為是,在 "action 被 dispatch
出去之後,在到達 reducer 之前" 這個過程中去攔截 action,進而改變本來 action 原有的行為。Middleware 本身是一個 function,他的 signature 長這樣:
function(store) {
return function(next) {
return function(action) {
// middleware behavior...
};
};
}
如果用 ES6 的語法來表達,看起來會稍微乾淨一點,但基本上他的本體還一樣複雜:
store => next => action => {
// middleware behavior...
}
這個部份我覺得是 Redux 設計得最帥氣的部份之一。之後在 Redux 的介紹文章會詳細說明。現在還是先讓我們先搞定測試再說。
假設我們有一個 api 的 middleware middleware/api.js
長這樣:
import { camelizeKeys } from 'humps';
import superAgent from 'superagent';
import Promise from 'bluebird';
import _ from 'lodash';
export const CALL_API = Symbol('CALL_API');
export default store => next => action => {
if ( ! action[CALL_API] ) {
return next(action);
}
let request = action[CALL_API];
let { getState } = store;
let deferred = Promise.defer();
let { method, url, successType } = request;
superAgent[method](url)
.end((err, res)=> {
if ( !err ) {
next({
type: successType,
response: res.body
});
}
deferred.resolve();
});
return deferred.promise;
};
這個 middleware 做的事情是:
CALL_API
這個 key 的 action, CALL_API
的 value (假設它叫 request
) 裡面的 url
,method
去發一個 http api call 給 server。dispatch
一個 request.successType
的 action 出去。如果套上上面的步驟的話就會變成:
api middleware
CALL_API
的 actionaction[CALL_API]
去送一個 api call 給 serverdispatch
一個 action[CALL_API].successType
的 event所以我們的 test code 就會長這樣 spec/middleware/api.test.js
:
import nock from 'nock';
import apiMiddleware, { CALL_API } from 'middleware/api';
describe('Middleware::Api', function(){
let store, next;
let action;
let successType = 'ON_SUCCESS';
let url = 'http://the-url/path';
beforeEach(function(){
store = {};
next = sinon.stub();
action = {
[CALL_API]: {
method: 'get',
url,
successType
}
};
});
describe('when action is without CALL_API', function(){
it('passes the action to next middleware', function(){
action = { type: 'not-CALL_API' };
apiMiddleware(store)(next)(action);
expect(next).to.have.been.calledWith(action);
});
});
describe('when action is with `CALL_API`', function(){
let nockScope;
beforeEach(function(){
nockScope = nock(`http://the-url`)
.get('/path');
});
afterEach(function(){
nock.cleanAll();
});
it('sends request to `path` with query and body', function(){
nockScope = nockScope.reply(200, { status: 'ok' });
apiMiddleware(store)(next)(action);
nockScope.done();
});
it('resolves returned promise when response when success', function(){
nockScope = nockScope.reply(200, { status: 'ok' });
let promise = apiMiddleware(store)(next)(action);
return expect(promise).to.be.fulfilled;
});
it('dispatch successType with response when success', function(done){
nockScope = nockScope.reply(200, { status: 'ok' });
let promise = apiMiddleware(store)(next)(action);
promise.then(()=> {
expect(next).to.have.been.calledWith({
type: successType,
response: { status: 'ok' }
});
done();
});
});
});
});
這邊的測試行為比之前的複雜一些, 其中 nock
是一個用來測試 nodejs 上面 http request 行為的一個 library, 這個部份有點超過這篇文章的 scope 所以在這邊就假設大家都很熟悉 XD。除了 nock
之外,還有幾個可以詳細說明的點:
首先是 test 的巢狀 describe
和 beforeEach
,這樣的做法可以讓每一個 describe
下面的內容有一個 context,再者也可以利用在 describe
function 下面的 local variable, 讓同一個 describe
下面的測試可以 share 同樣的 variable。(像是 nockScope
就只有 when action is with CALL_API
這個 describe
block 下面的 code 可以 access 到)
再來是要怎麼在測試的環境裡執行 middleware 呢?
因為 middleware 本身是一個 function,只是被包了很多層而已,所以要執行它的方法就是一層一層地去 call 他:
apiMiddleware(store)(next)(action);
最後是 dispatch successType with response when success
這邊有 async(非同步) 的行為。Mocha 最基本的行為是不會等 async 的 code 執行完的。也就是說,在 async 的 code 被執行到之前,mocha 的 test case (it()
)就會先結束掉。這樣我們就會無法在 async 的行為完成之後再驗証一些行為。這個部份 mocha 的做法是在 it
後面跟的 function 帶一個參數(done
),而 mocha 會等到這個 done
在 test case 裡面被執行到之後才結束這個 test case。如此一來,我們就可以在 async 的動作執行完之後再呼叫 done
,這樣就可以確保 mocha 會在我們預期的時間點才結束 test case 了。
在 Redux 下面,我們把 Ract 的 Component 分成兩種:一種是 smart component, 另一種是 stupiddumb component。Smart component 指的是和 Redux 有連結的那些 component, 而 dumb component 則是完全根據 props 來動作的那些。
Dumb Component 的測試嚴格來說和 Redux 就比較沒有直接的關係了。因為它們就是一般的 React component。Component 的測試常常會和 mock 有關係,所以就在這邊作一個基本的介紹。
Smart component 在實作上面,其實和 dump component 差不多,只是多了 connect
的行為,
mapStateToProps
) 選擇一部份需要用到的 keys inject 進去變成 component 的 props在測試 smart component 的時候,根據 Redux document 上面的建議是,繞過 connect
的行為,直接測 component 的部份。
假設我們的 comopnent 長成這樣: containers/Question.js
import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import { loadQuestions } from 'actions/questions';
import { Link } from 'react-router';
import _ from 'lodash';
class Question extends Component {
static fetchData({ store }) {
return store.dispatch(loadQuestions());
}
componentDidMount() {
this.props.loadQuestions();
}
render() {
return (
<div>
<h2>Question</h2>
{
_.map(this.props.questions, (q)=> {
return (
<p key={q.id}> { q.content }</p>
);
})
}
<Link to="/">Back to Home</Link>
</div>
);
}
}
function mapStateToProps (state) {
return { questions: state.questions };
}
export { Question };
export default connect(mapStateToProps, { loadQuestions })(Question);
在這個 component 裡面,我們把 Question
特別 export
出來,這樣的話我們在測試的時候,就可以繞過 connect
完之後的 smart component, 而單純地測試 Question
。
一樣套上上面的步驟:
Qestion Component
component
會根據 props.questions
render 出對應的 elementscomponent
會 render 出一個指向 /
的 Link
於是我們的測試會長成這樣: spec/containers/Question.test.js
import Container, { Question } from 'containers/Question';
import React from 'react';
import TestUtils from 'react-addons-test-utils';
describe.only('Container::Question', function(){
let props;
let Link;
beforeEach(function(){
props = {
loadQuestions: sinon.stub(),
questions: [
{ id: 1, content: 'question content 1' },
{ id: 2, content: 'question content 1' }
]
};
Link = React.createClass({
render() {
return (<div>MOCK COMPONENT CLASS</div>)
}
});
Container.__Rewire__('Link', Link);
});
it('renders questions according to `props.questions`', function(){
let doc = TestUtils.renderIntoDocument(<Question {...props} />);
let questionElements = TestUtils.scryRenderedDOMComponentsWithTag(doc, 'p');
expect(questionElements.length).to.equal(props.questions.length);
});
it('renders a link back to `/`', function(){
let doc = TestUtils.renderIntoDocument(<Question {...props} />);
let link = TestUtils.findRenderedComponentWithType(doc, Link);
expect(link).not.to.be.undefined;
expect(link.props.to).to.equal('/');
});
});
在這邊我們用 react-addons-test-utils
來 query component 裡面的東西並且加以驗証。
其中我們要測試的第二個行為:"component
會 render 出一個指向 /
的 Link
" 比較需要說明一下。
在這個測試裡面,因為我們的測試對象是 Question Component
, 所以我們並不在乎別的 component 內部是怎麼運作的。我們只在乎 Question
component 和別的 component 之間的介面關係而已。 以 Question
為例,在它的測試裡面我們並不想要直接 render 別的 component (Link
),原因是因為別的 component 在 render 的時候有可能會衍生出其他的問題,當這種狀況發生的時候,我們很難確定究竟是 Question
的問題還是其他 component 的問題。
這樣的狀況在 component 的測試時常發生,因為大多數的時候,我們會在一個 component 去 render 其他很多不同的 component。
面對這種問題我的解決方法是*在測試裡面,用 __Rewire__
把其他 component mock 掉 *,
Link = React.createClass({
render() {
return (<div>MOCK COMPONENT CLASS</div>)
}
});
Container.__Rewire__('Link', Link);
這樣一來,Question
在測試裡面被 render 的時候,他看到的 Link
就會是我們提供的假的 component 而不是真正的 Link
component 了。接著我們就可以進一步去測試 Question
component 和 Link
component 之間的介面關係:
let doc = TestUtils.renderIntoDocument(<Question {...props} />);
let link = TestUtils.findRenderedComponentWithType(doc, Link);
expect(link).not.to.be.undefined;
expect(link.props.to).to.equal('/');
webpack 有很多很神妙的 loaders,可以讓我們在 javascript 裡面 require
各種不是 javascript 的東西,像是圖片、stylesheet 等等。在選用這樣的 loaders 的時候,當中會牽涉到一些取捨。
選用 loader 的好處是,所有的東西都被打包在一起了;但它的壞處是,用到這些 loader 的 code 就只能在 webpack 的環境下執行了。
如果說我們的 app 只會在瀏覽器上跑,那用各種 loader 基本上是沒什麼問題。但如果同一份 code 也要在 server 上執行的話 (universal rendering),那選用 loader 就代表在 server 上跑的 code 要用 webpack 分開打包,也代表著我們會需要兩份 webpack config。
我自己的偏好是,如果要做 universal rendering, 我會避開這類的 loader, 讓 server 的 code 可以不用透過 webpack 來 build,對我來說這樣比較單純。
下面我提供的例子是一個只有在瀏覽器裡執行的例子(不在這個範例裡面),其中用到了 url loader 來 require 圖片。
let SignupModal = React.createClass({
render() {
let cmLogo = require('Icons/white/icon_codementor.png');
...
}
})
這個 component 會在 render 的時候,去 require 一個圖片,然後在 nodejs 的環境下測試就壞掉惹。
我的解法是,用一個 function 把 require image 的行為包起來,這樣一來我們就可以在測試的時候去 mock 這個 function。
於是我們的 component 變成這樣:
import requireImage from 'lib/requireImage';
let SignupModal = React.createClass({
render() {
let cmLogo = requireImage('Icons/white/icon_codementor.png');
...
}
})
其中 requireImage
就只是單純地去 require
而已: lib/requireImage.js
:
export default function(path) {
return require(path);
}
這樣一來,我們就可以在測試裡面 mock 掉 requireImage
了:
describe('Component SignupModal', function(){
let requireImage;
beforeEach(function() {
requireImage = function() {}
SignupModal.__Rewire__('requireImage', requireImage);
});
it('can be rendered', function() {
// now we can render here
});
});
測試是一件需要花力氣去做的事情,同時也需要很多練習。但是透過這個過程,我們可以更了解整份 code 的運作方式。
在大部份的時候,容易測試的 code 也會是有好的結構的 code。
當一個 project 變得越來越大,開發者越來越多,沒有測試的 code 到後來幾乎會被 bug 壓垮。而當我們越來越熟練之後,其實寫測試的時間絕對是遠小於 debug 的時間的。
Redux 的設計讓測試變得很單純,我覺得這也是它很精妙的地方之一。
好測試,不寫嗎?
這幾個月身邊有好多的 projects 開始使用 React, 這一兩個月開始接觸到 Redux,覺得簡直驚為天人 XD。仔細讀了 Redux 的 source code 就發現他好強阿!覺得好像有更上一層樓的感覺。
這陣子用 Redux 做的東西有:
我打算把一些東西整理一下寫幾篇文章, 目前預計把 Redux 相關的東西整理成三篇:
我打算從第二個部份 Server Rendering 開始,原因是我覺得 Redux 的介紹應該很多了。然後 Server rendering 這個部份比較有趣一點。
在講 Server Rendering 之前,我們應該先了解在這個東西出現之前我們遇到了什麼問題。
當瀏覽器的效能越來越好之後,漸漸地在做網站的時候,越來越多的邏輯從 server side 移到 client side, 也就是我們常聽到的 webapp 或者是 single-page app,javascript 的明星也由早期的 jQuery 變成了 Backbone.js, AngularJS, EmberJS 到最近的 React。
Single Page App 讓網站的角色變成一個獨立的 app,就像是 iOS 或者是 android app 那樣,用 API 來跟 server 溝通。如此一來有很多好處:從 user 的角度切入,user 不再需要一直 reload 網頁,大大增進的 user experience。從 loading 的角度切入,本來集中在 server 的 rendering work 現在移到了每一個 end user 的瀏覽器裡面。從開發流程的角度切入,server 和 client 從此有了統一的介面(API),不會再因為改動了 database 裡面的一個欄位就讓前端的 code 大壞。前端的 app 變成一個 self-contained 的東西,在維護上也大大地減低了困難。
但是 Single Page App 並不是完全沒有缺點。最主要的問題有二:
SEO 的困難。因為 single page app 的 html element 都是由前端的 javascript render 出來的,搜尋引擎的 crawer 無法看到這些,所以對於 crawer 來說,看到的總是一個空空如也的 html body。(但最近這個問題好像有了轉機)。
initial loading 很慢。Single Page App 要等 javascript 先下來之後,才由 javascript 再把 html render 出來。所以 user 一開始要等比較久才可以看到網頁的內容。
在這邊我們把整個 architecture 分三塊:一塊是 API server 負責提供 data, 再者是 web server 負責和 client share code 和 render html(就是我們下面指的 server), 最後是 client,也就是在瀏覽器裡面執行的 code。
architecture 可以參考 airbnb 的 blog
在這邊先用一張圖來呈現:
(圖片來源:airbnb)
Server Rendering 的意思是說,讓一部份的 code 先在 server 執行,然後當 server 把畫出一開始的畫面所需要的 data 拿到之後,把 initial page 的 html 先 render 好,並且把這包 html 和剛才拿到的 data 打包起來一起送給 client。
當 client 拿到了 initial page html (所以同時解決了 SEO 和一開始畫面要等很久的問題)和這包 data 之後,再接續著剛才 server 完成的進度繼續讓 app 運作下去。這個概念其實相當直覺,但 React 出現之後才重新被認真討論的原因是,因為 React 本身的設計可以讓這件事情可以更優雅地被完成。
簡而言之呢,要做到 Server Rendering 可以分成三個部份:
在下面我不會帶到全部的 code, 但下面的 code 都放在這邊
假設我們現在有一個動態的頁面叫作 Question
,我們要 call 一個 API 把目前的 questions
拿下來然後把他 render 在畫面上。
我們有:
questions
reducer loadQuestions
action,會在 load 成功之後把 data 放進 questions
reducerRedux app 的進入點大概長這樣:
ReactDOM.render((
<Provider store={store}>
<Question />
</Provider>
), document.getElementById('root'));
containers/Question.js
大概長得像這樣:
import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import { loadQuestions } from 'actions/questions';
import _ from 'lodash';
class Question extends Component {
componentDidMount() {
this.props.loadQuestions();
}
render() {
return (
<div>
<h2>Question</h2>
{
_.map(this.props.questions, (q)=> {
return (
<p key={q.id}> { q.content }</p>
);
})
}
</div>
);
}
}
function mapStateToProps (state) {
return { questions: state.questions };
}
export { Question };
export default connect(mapStateToProps, { loadQuestions })(Question);
actions/questions.js
大概長這樣:
export const LOADED_QUESTIONS = 'LOADED_QUESTIONS';
export function loadQuestions() {
return function(getState, dispatch) {
request.get('http://localhost:3000/questions')
.end(function(err, res) {
if (!err) {
dispatch({ type: LOADED_QUESTIONS, response: res.body });
}
})
}
}
於是當 Question
mount 之後,就會送一個 API 給 server 把 data 拉下來, 進而 update view 了。
但是當我們要在 server 上 render 的時候,事情就變得比較複雜了。
我們預期最後做好的樣子是,server 的架構完成之後,是不需要隨著前端的邏輯改動而改變的。也就是說,這邊的 server 是不知道 component 的 business logic 的。
回顧一下我們要在 server 上做的三件事:
首先要解決的問題是,當一個 request 進來的時候,我們怎麼知道要 call 哪一個 api 或者是要怎麼準備 state 呢?
第二個問題是,我們在 call 了 async 的 API 之後,要怎麼知道什麼時候 data 才準備好了呢?
第一個問題其實是和 routing 有關。以上面的 Question
component 為例,真正拿 data 的點是在它的 componentDidMount()
裡面。在大部份的情況,我們會需要一個 router 來控制 url 和對應 component 的關係。所以我的做法是,在每個 routing 的 leaf node (如果有巢狀 routing 的話,就是最裡面的那個) 放一個 static method fetchData()
, 這樣的話,在 server 我們就可以用 react-router 去 match 最後被 route 到的 component, 進而得到 data 的進入點。
第二個問題我的解法是用 promise, 意思是說,讓上述的 fetchData()
return 一個 promise, 當這個 promise resolve 的時候就代表所有 async 的動作都完成了,也就是 data 都 ready 了的意思。
這個部份在 server 的 code 大概長這樣:
import { RoutingContext, match } from 'react-router'
import createMemoryHistory from 'history/lib/createMemoryHistory';
import Promise from 'bluebird';
import Express from 'express';
let server = new Express();
server.get('*', (req, res)=> {
let history = createMemoryHistory();
let store = configureStore();
let routes = crateRoutes(history);
let location = createLocation(req.url)
match({ routes, location }, (error, redirectLocation, renderProps) => {
if (redirectLocation) {
res.redirect(301, redirectLocation.pathname + redirectLocation.search)
} else if (error) {
res.send(500, error.message)
} else if (renderProps == null) {
res.send(404, 'Not found')
} else {
let [ getCurrentUrl, unsubscribe ] = subscribeUrl();
let reqUrl = location.pathname + location.search;
getReduxPromise().then(()=> {
let reduxState = escape(JSON.stringify(store.getState()));
let html = ReactDOMServer.renderToString(
<Provider store={store}>
{ <RoutingContext {...renderProps}/> }
</Provider>
);
res.render('index', { html, reduxState });
});
function getReduxPromise () {
let { query, params } = renderProps;
let comp = renderProps.components[renderProps.components.length - 1].WrappedComponent;
let promise = comp.fetchData ?
comp.fetchData({ query, params, store, history }) :
Promise.resolve();
return promise;
}
}
});
});
server view template index.ejs
<!DOCTYPE html>
<html>
<head>
<title>Redux real-world example</title>
</head>
<body>
<div id="root"><%- html %></div>
<script type="text/javascript" charset="utf-8">
window.__REDUX_STATE__ = '<%= reduxState %>';
</script>
<script src="http://localhost:3001/static/bundle.js"></script>
</body>
</html>
然後在 containers/Question.js
裡的加一個 fetchData()
static method:
class Question extends Component {
static fetchData({ store }) {
// return a promise here
}
// ...
}
然後到這裡,我們就遇到了第三個問題…
就是在 fetchData()
裡面,我們要怎麼 reuse 本來的 action (loadQuestions()
) 同時又 return 一個 promise 呢?
這個問題我的解法是做一個 middleware, 在這個 middleware 裡面去 call api,然後從這個 middleware return 一個 promise 給 fetchData()
。
這個 middleware 大概長這樣:
middleware/api.js
import { camelizeKeys } from 'humps';
import superAgent from 'superagent';
import Promise from 'bluebird';
import _ from 'lodash';
export const CALL_API = Symbol('CALL_API');
export default store => next => action => {
if ( ! action[CALL_API] ) {
return next(action);
}
let request = action[CALL_API];
let { getState } = store;
let deferred = Promise.defer();
// handle 401 and auth here
let { method, url, successType } = request;
superAgent[method](url)
.end((err, res)=> {
if ( !err ) {
next({
type: successType,
response: res.body
});
if (_.isFunction(request.afterSuccess)) {
request.afterSuccess({ getState });
}
}
deferred.resolve();
});
return deferred.promise;
};
如此一來,本來的 action 就可以改成:
actions/questions.js
export const LOADED_QUESTIONS = 'LOADED_QUESTIONS';
export function loadQuestions() {
return {
[CALL_API]: {
method: 'get',
url: 'http://localhost:3000/questions',
successType: LOADED_QUESTIONS
}
};
}
然後 fetchData()
就變成這樣:
containers/Question.js
import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import { loadQuestions } from 'actions/questions';
import _ from 'lodash';
class Question extends Component {
static fetchData({ store }) {
return store.dispatch(loadQuestions());
}
//...
}
然後我們就搞定了,YA!
這陣子做 universal rendering 的感想是,universal rendering 的效果確實是蠻不錯的。就是在一開始 load 網頁的時候真的會有 "哇好快!"的感覺。但是另一方面,在開發的時候確實也因為這樣,會要在很多地方多花一些工夫設計和繞開一些東西 (像是 webpack 的各種帥氣 loader 等等)。
我個人的感覺是,綜合各種 trade-off 下來之後,做 universal rendering 還算是一個划算的選擇。
註:上面的 code 可以到這邊去看,跑起來之後請找 http://localhost://3000/q/1/hello
。耶!
在和別人合作開發 project 的時候,常常會有一些只有自己想要 git ignore 的檔案,
像是每個 project 的 .vimrc
,或是編輯器的暫存檔 (.swp
, .swo
),或是自己要看的筆記等等。
這些檔案有時候不太適合 check in 進去 repository,好比說並不是每個人都有用 Vim,那這樣把 .vimrc
check in 進去就不太合理。
這時候可以在家目錄的 .gitconfig
檔裡面的 core.excludesfile
來指定一個 user-based 的 .gitignore
。
例如說:
# ~/.gitconfig
[core]
excludesfile = ~/.gitignore_global
# ~/.gitignore_global
*.swo
*.swp
.vimrc
_todo*
_note*
這樣就可以不用在每個 project 都 ignore 這些檔案,同時又可以避免他們被不小心 check in 進去惹。
最近(其實就是今天阿阿)在寫 webapp 又遭遇到了困難,是這樣地:
我用 yeoman host 了 project, 終於到了要 deploy 的這一刻惹。
這是候我們要先 grunt build
一下(把圖的 sprite 壓縮阿,code 壓縮,css 壓縮什麼的),然後再把這些東西丟上去 server 就搞定。但是
我不想把 build 出來的 code 也放進 version control 裡面,因為我覺得這種 generate 出來的東西沒有什麼邏輯在裡面,再者長久下來,這些東西會讓整個 repository 變得很肥,clone 一下就要好久。
以下是我 deploy 的設定:
大致上的流程就是,
這時候呢,Capistrano 就會 ssh 到 server, 然後從 server 上把 github 的 code 拉下來,然後再在 server 上做任何你叫他做的事情 (像是重開 worker 阿什麼的) 然後就可以用了。
回到這篇文章的主題,就是 我不想把 build 出來的 code 也放進 version control 裡面, 所以呢,在 server 上把 code 拉下來的時候就會沒有 build 好的版本。這時候要嘛
在 local build 好然後再叫 Capistrano 傳到 server 上: 這個作法我覺得相當可怕,因為變成真正在 server 上 run 的 code 是沒有任何版本的資訊的,意思是說有可能我們現在 master
上面是 commit-a, 但是真正在 server 上跑的版本可能是 commit-xyz。到時候如果要 rollback 或者有什麼問題要 trace 的時候有可能會欲哭無淚。
在 server 上把 grunt
, bower
等等開發的環境裝起來,然後在 server 上直接 build: 這個方法好一點,但又會遇到另一個問題就是 node 的版本跳得很快,所以如果 server 上有一個以上的 project 的話,有可能會有 node 的版本撞到的情形。(這樣想起來好像可以直接用 nvm 之類的解決,可是用 docker 就是比較潮阿)
好反正我就用了 docker, 我的目標是:
npm install
, bower install
要快,之前的 deployment 如果有裝過同樣的東西就不要再重裝所以我搞了一個有node, grunt, bower, compass 的 docker image,然後在每次的 deployment 的時候,在 server 上用這個 image run 一個 container 來 build
。在 build 的時候,把 project 的目錄 link 到 container 裡面這樣。
所以呢,以 npm install
來說:
docker run -t --rm \
-v $(pwd):/app \
-v $(pwd)/node_modules:/app/node_modules \
-w /app \
yeoman-builder:latest \
npm install
bower install
:docker run -t --rm \
-v $(pwd):/app \
-v $(pwd)/bower_components:/app/bower_components \
-w /app yeoman-builder:latest \
bower --allow-root --config.interactive=false install
grunt build
:docker run -t --rm \
-v $(pwd):/app \
-v $(pwd)/node_modules:/app/node_modules \
-v $(pwd)/bower_components:/app/bower_components \
-w /app yeoman-builder:latest \
grunt build
這樣子就大功告成惹!
我在 AWS EC2 的 micro instance 上面嘗試從 docker hub 上 pull image 上來結果竟然失敗… 但直接把 Dockerfile
copy 過去,在 server 上 build image 則沒有問題。我猜有可能是 micro instance 太弱小了所以 memory 之類的被吃完也有可能,但這點我沒有深究。
在 run container 的時候,如果有 symbolic link 要特別再 link 一次,不然在 container 裡面會沒辦法 access。以上面的 project 為例,我在 app/
裡面建立了一個 symbolic link app/node_modules
link 到上一層的一個資料夾(目的是為了在 deployments 之間共用 node modules), 在 run container 的時候雖然有 link app/
(-v $(pwd):/app
) 但還是要再另外 link node_modules
(-v $(pwd)/node_modules:/app/node_modules
) 才行。
desc 'link `node_modules` and `bower_components` to shared_path'
after :publishing, :link_shared do
on roles(:app) do
execute :ln, '-s', shared_path.join('node_modules'), release_path.join('node_modules')
execute :ln, '-s', shared_path.join('bower_components'), release_path.join('bower_components')
end
end
desc 'build by docker'
after :link_shared, :docker_build do
on roles(:app) do
within release_path do
execute :docker, 'run', '-t', '--rm', '-v', '$(pwd):/app',
'-v', '$(pwd)/node_modules:/app/node_modules',
'-w', '/app', 'mz026/yeoman-builder:latest', 'npm', 'install'
execute :docker, 'run', '-t', '--rm', '-v', '$(pwd):/app',
'-v', '$(pwd)/bower_components:/app/bower_components',
'-w', '/app', 'mz026/yeoman-builder:latest',
'bower', '--allow-root', '--config.interactive=false', 'install'
execute :docker, 'run', '-t', '--rm', '-v', '$(pwd):/app',
'-v', '$(pwd)/node_modules:/app/node_modules',
'-v', '$(pwd)/bower_components:/app/bower_components',
'-w', '/app', 'mz026/yeoman-builder:latest', 'grunt', 'build'
end
end
end
Docker hub 可以和 github / bitbucket 連結來做 automated build, 意思是說,我們在 github 上面開一個 repository 裡面放著 Dockerfile
,然後從 Docker hub 設定,這樣一來,只要 github 的這個 project 有改動,Docker hub 就會自己去拉新的 code 下來 build image,有點貼心喔!
以下是本日 nginx 的小記錄:
最近開始要做一個新的 web app, 狀態如下:
grunt serve
就在 localhost:9000
找到 app我遇到的問題是, 在 local 開發的時候,API 是從 localhost:3000
來的,但 web app 卻是從 localhost:9000
, 於是就 cross domain 惹。可是我
總之我找到一個解法,大意就是用 nginx reverse proxy:
7000
localhost:7000
proxy 到 localhost:9000
, 所以說 localhost:7000/hello/world
就會指到 localhost:9000/hello/world
localhost:7000/api
proxy 到 localhost:3000
, 所以說 localhost:7000/api/users/login
就會指到 localhost:3000/users/login
如此這般,以 login api 為例,在 webapp 就可以發 ajax 叫 /api/users/login
然後就不會 corss domain 惹。
下面是我的 nginx 設定:
# map localhost:7000 to localhost:9000
# map localhost:7000/api to localhost:3000
# to solve cross-domain issue on local dev
server {
listen 7000;
server_name localhost;
location / {
proxy_pass http://localhost:9000;
}
location ~* ^/api/(.*) {
rewrite ^/api/(.*) /$1 break;
proxy_pass http://localhost:3000;
}
}
其中有一個 rewrite
的 directive, 作用是把 url 的 /api
去掉。
我覺得 nginx 設定起來似乎比 apache 簡單明暸阿,doc 和語法都比較友善喔!
其實類似的問題做法,應該也可以用來解決 web app deployment 的問題:
domain.com
而不是 domain.com/path
)在上面的做法裡面,理論上可以根據不同的 user-agent, proxy 到不同的地方,
這樣應該就可以把 SEO 相關的東西獨立出來。
就是說,如果 user-agent
是 bot 的話,就叫它去找 SEO 的 server