點燈坊

失くすものさえない今が強くなるチャンスよ

使用 mergeMap() 呼叫有相依性 API

Sam Xiao's Avatar 2020-06-10

由於 RESTful API 特性,實務上常會遇到無法單一 API 就能拿到結果,必須連續呼叫多次 API,本文分別以 Async Await、Promise Chain、Ramda 與 RxJS 示範,可比較其背後不同 Mental Model 。

Version

macOS Catalina 10.15.4
WebStorm 2020.1.1
Vue 2.6.11
Ramda 0.0.27
RxJS 6.5.5

Browser

api000

顯示 book id 為 1 的 category。

API

http://localhost:3000/categories/:id 能回傳該 categoryIdcategory,但 categoryId 必須先呼叫 http://localhost:3000/books/:id 取得 categoryId

也就是 API request 有其相依性,必須先呼叫某 API 取得其值,再根據該值呼叫其他 API。

Async Await

<template>
  <div>{{ category }}</div>
</template>

<script>
import axios from 'axios'

let fetchCategoryId = async x => {
  let { data: { categoryId }} = await axios.get(`http://localhost:3000/books/${x}`)
  return categoryId
}

let fetchCategory = async x => {
  let { data: { category }} = await axios.get(`http://localhost:3000/categories/${x}`)
  return category
}

let mounted = async function() {
  let categoryId = await fetchCategoryId(1)
  this.category = await fetchCategory(categoryId)
}

export default {
  name: 'App',
  data:() => ({
    category: ''
  }),
  mounted
}
</script>

第 8 行

let fetchCategoryId = async x => {
  let { data: { categoryId }} = await axios.get(`http://localhost:3000/books/${x}`)
  return categoryId
}

使用 Async Await 呼叫 API 取得 categoryId 回傳。

13 行

let fetchCategory = async x => {
  let { data: { category }} = await axios.get(`http://localhost:3000/categories/${x}`)
  return category
}

使用 Async Await 呼叫 API 取得 category 回傳。

19 行

let categoryId = await fetchCategoryId(1)
this.category = await fetchCategory(categoryId)

使用 await 呼叫 fetchCategoryId() 先取得 categoryId,再將 categoryId 傳入 fetchCategory()await 取出寫入 category data。

我們可發現 async await 寫法雖然直覺,但必須 categoryId 這種 intermediate variable 作銜接,也常因為要找可讀性高的 variable 名稱而想破頭

Promise Chain

<template>
  <div>{{ category }}</div>
</template>

<script>
import axios from 'axios'

let fetchCategoryId = x => axios.get(`http://localhost:3000/books/${x}`)
  .then(x => x.data.categoryId)

let fetchCategory = x => axios.get(`http://localhost:3000/categories/${x}`)
  .then(x => x.data.category)

let mounted = function() {
  fetchCategoryId(1)
    .then(fetchCategory)
    .then(x => this.category = x)
}

export default {
  name: 'App',
  data:() => ({
    category: ''
  }),
  mounted
}
</script>

第 8 行

let fetchCategoryId = x => axios.get(`http://localhost:3000/books/${x}`)
  .then(x => x.data.categoryId)

使用 Promise Chain 呼叫 API 取得 categoryId 回傳。

11 行

let fetchCategory = x => axios.get(`http://localhost:3000/categories/${x}`)
  .then(x => x.data.category)

使用 Promise Chain 呼叫 API 取得 category 回傳。

15 行

fetchCategoryId(1)
  .then(fetchCategory)
  .then(x => this.category = x)

使用 Promise Chain 在呼叫 fetchCategoryId() 之後,在 then() 中再呼叫 fetchCategory(),最後再寫入 category side effect。

Ramda

<template>
  <div>{{ category }}</div>
</template>

<script>
import axios from 'axios'
import { andThen, concat, path, pipe, toString } from 'ramda'

let fetchCategoryId = pipe(
  toString,
  concat(`http://localhost:3000/books/`),
  axios.get,
  andThen(path(['data', 'categoryId']))
)

let fetchCategory = pipe(
  toString,
  concat(`http://localhost:3000/categories/`),
  axios.get,
  andThen(path(['data', 'category']))
)

let mounted = async function() {
  pipe(
    fetchCategoryId,
    andThen(fetchCategory),
    andThen(x => this.category = x)
  )(1)
}

export default {
  name:'App',
  data:() => ({
    category:''
  }),
  mounted
}
</script>

第 9 行

let fetchCategoryId = pipe(
  toString,
  concat(`http://localhost:3000/books/`),
  axios.get,
  andThen(path(['data', 'categoryId']))
)

使用 Ramda 的 function 實現 Promise Chain。

16 行

let fetchCategory = pipe(
  toString,
  concat(`http://localhost:3000/categories/`),
  axios.get,
  andThen(path(['data', 'category']))
)

使用 Ramda 的 function 實現 Promise Chain。

24 行

pipe(
  fetchCategoryId,
  andThen(fetchCategory),
  andThen(x => this.category = x)
)(1)

使用 Ramda 的 function 實現 Promise Chain。

Ramda 提供 pipe()andThen() 執行 asynchronous,但本質仍是 Promise Chain,只是以 Function Pipeline 形式而非 Method Chaining,另外 callback 部分則使用 Ramda 的 function 盡量 point-free。

RxJS

<template>
  <div>{{ category }}</div>
</template>

<script>
import { ajax } from 'rxjs/ajax'
import { pluck } from 'rxjs/operators'

let fetchCategoryId$ = x => ajax(`http://localhost:3000/books/${x}`).pipe(
  pluck('response', 'categoryId')
)

let fetchCategory$ = x => ajax(`http://localhost:3000/categories/${x}`).pipe(
  pluck('response', 'category')
)

let mounted = function() {
  fetchCategoryId$(1)
    .subscribe(x => {
      fetchCategory$(x)
        .subscribe(x => this.category = x)
  })
}

export default {
  name:'app',
  data:() => ({
    category:''
  }),
  mounted
}
</script>

第 9 行

let fetchCategoryId$ = x => ajax(`http://localhost:3000/books/${x}`).pipe(
  pluck('response', 'categoryId')
)

使用 RxJS 內建的 ajax() 取代 Axios 讀取 API,與 axios.get() 不同的是回傳 Promise,而 ajax() 回傳 Observable,方便後續 RxJS 使用 operator。

RxJS 與 Axios 另一點不同是 Axios 將回傳資料藏在 data property,而 RxJS 則在 response proeprty 內,因此使用 pluck('response', 'categoryId') 回傳。

13 行

let fetchCategory$ = x => ajax(`http://localhost:3000/categories/${x}`).pipe(
  pluck('response', 'category')
)

使用 RxJS 的 ajax() 呼叫 API 取得 category 回傳,型別為 Observable。

18 行

fetchCategoryId$(1)
  .subscribe(x => {
    fetchCategory$(x)
      .subscribe(x => this.category = x)
  })

由於 fetchCategoryId$() 回傳 Observable,要在 subscribe() 內的 x 才能取出 categoryId,因此在 subscribe() 再次呼叫 fetchCategory$() ,並在其 subscribe() 寫入 category data。

這種 Higher Order Observable 很容易寫出 Nested Subscribe,可讀性很差,跟 Callback Hell 差不多。

<template>
  <div>{{ category$ }}</div>
</template>

<script>
import { ajax } from 'rxjs/ajax'
import { mergeMap, pluck } from 'rxjs/operators'

let fetchCategoryId$ = x => ajax(`http://localhost:3000/books/${x}`).pipe(
  pluck('response', 'categoryId')
)

let fetchCategory$ = x => ajax(`http://localhost:3000/categories/${x}`).pipe(
  pluck('response', 'category')
)

let subscriptions = _ => {
  let category$ = fetchCategoryId$(1).pipe(
    mergeMap(fetchCategory$)
  )

  return { category$ }
}

export default {
  name:'App',
  subscriptions
}
</script>

18 行

let category$ = fetchCategoryId$(1).pipe(
  mergeMap(fetchCategory$)
)

fetchCategoryId$() 回傳已是 Observable,又接著呼叫 fetchCategory$() 亦回傳 Observable,因此預期將會是 Higher Order Observable。

Observable 並不像 Promise 的 then() 會自動攤平 Promise,必須明確使用 mergeMap() 使 Observable 攤平。

mergeMap()
將兩層 Observable 攤平成一層 Observable

api001

22 行

return { category$ }

最後回傳 category$ Observable 供 data binding。

可發現 Observable 與 Promise 相比,Promise 的 then() 同時具有多重角色,兼具 map()mergeMap()subscribe() 功能,但 RxJS 則必須明確使用正確 operator,這也符合 FP 習慣

Conclusion

  • Async Await 最符合大部分人習慣 Imperative 寫法,唯 await 會 block 目前執行等待 asynchronous 回傳,若資料量大會明顯感覺延遲,須小心使用
  • Promise Chain 的 then() 兼具 map()mergeMap()subscribe() 特性,因此用起來比較簡單,但缺點是只有一個 then() 能用,而不像 RxJS 有 100 多個 operator,可在各式各樣情境靈活使用
  • Promise Chain 雖然已經有 FP 味道,但卻使用了 Method Chaining
  • Ramda 雖使用了 Function Pipeline,但因其 synchronous 本質,所以每行都必須使用 andThen() 才支援 Promise,整體略顯累贅
  • RxJS 寫法最為精簡,且提供豐富 operator 可用
  • 當寫出 Nested Subscribe 時,就是該使用 switchMap()exhaustMap()mergeMap() 攤平時刻,執於要使用哪個 operator,要依實際狀況而定

Reference

TRAN SON HOANG, Handle Multiple API request in Angular using mergeMap and forkJoin to Avoid Nested Subscription
RxJS, pluck()
RxJS, flatMap()
RxJS, mergeMap()