點燈坊

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

如何處理相依性 API ?

Sam Xiao's Avatar 2020-07-05

實務上有些 API 呼叫有順序性,無論 Promise 或 Observable 都是 Asynchronous,不保證回傳順序也不保證一定成功,因此處理這些 Asynchronous 動作並不像 Synchronous 般簡單。

Version

macOS Catalina 10.15.5
WebStorm 2020.1.2
Vue 2.6.11
ECMAScript 2017

Browser

api000

Asynchronous 若成功回傳,可正確顯示 titlecategory

Data

{
  "id": 1,
  "title": "FP in JavaScript",
  "price": 100,
  "categoryId": 1,
  "image": "fpjs.jpg"
}

http://localhost:3000/books/:id 會回傳指定 id 的書籍,但沒有包含 category,必須由 categoryId 呼叫其他 API 取得。

{
  "id": 1,
  "category": "FP"
}

http://localhost:3000/categories/:id 會回傳指定 idcategory

Async Await

<template>
  <div>
    <button @click="onClick">Get Book</button>
    {{ title }} / {{ category }}
  </div>
</template>

<script>
import axios from 'axios'

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

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

let onClick = async function() {
  try {
    let { title, categoryId } = await fetchBook(1)
    this.title = title

    let { category } = await fetchCategory(categoryId)
    this.category = category
  } catch (e) {
    console.log(e)
  }
}

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

11 行

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

API function 專門負責打 API, 使用 await 取得 Promise 內部值,由於 Axios 將資料放在 data property 下,先使用 object destructuring 取得 data property 再回傳,比起傳統 . 寫法可省去一個 variable。

22 行

try {
  let { title, categoryId } = await fetchBook(1)
  this.title = title

  let { category } = await fetchCategory(categoryId)
  this.category = category
} catch (e) {
  console.log(e)
}

使用 await 取得 fetchBookfetchCategory 所回傳 Promise 內部值,並立即 object destructuring 取得 property 與寫入 side effect。

對於 Rejected Promise,Async Await 會搭配 Imperative 的 try catch 處理。

可發現 Async Await 寫法 處理 data寫入 side effect 會同時寫在一起

Promise Chain

<template>
  <div>
    <button @click="onClick">Get Book</button>
    {{ title }} / {{ category }}
  </div>
</template>

<script>
import axios from 'axios'

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

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

let onClick = function() {
  fetchBook(1)
    .then(x => (this.title = x.title, x))
    .then(x => fetchCategory(x.categoryId))
    .then(x => this.category = x.category)
    .catch(console.log)
}

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

11 行

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

API function 使用 axios.get() 打 API,回傳為 Promise,只直接使用 then() 取出 data property 再回傳 Promise。

.then() 兼具 map()bind() 特性,還可處理 side effect

18 行

fetchBook(1)
  .then(x => (this.title = x.title, x))
  .then(x => fetchCategory(x.categoryId))
  .then(x => this.category = x.category)
  .catch(console.log)

第一個 .then()xfetchBook() 回傳的 book,先取其 title 寫入 side effect 後再回傳 book,這種原本該寫兩行,但可透過 comma operator 寫成一行。

第二個 .then()x 仍為 book,取出 categoryId 後傳進 fetchCategory(),回傳為 Promise。

第三個 .then()xcategory,原本應該是 Promise of Promise,但因為 then() 具有 bind() 特性,因此攤平為一層 Promise,取出 category 寫入 side effect。

對於 Rejected Promise,Promise Chain 寫法會搭配 Method Chaining 的 .catch() 處理。

可發現 Promise Chain 的每個 then() 只處理一件事情並維持一行,可搭配 comma operator 將原本要多行的動作一行解決,且 pure function 與 side effect 會隔離在不同的 then(),不會如 Async Await 都混在一起

Ramda

<template>
  <div>
    <button @click="onClick">Get Book</button>
    {{ title }} / {{ category }}
  </div>
</template>

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

let { get: ajax } = axios

let fetchBook = pipe(
  toString,
  concat(`http://localhost:3000/books/`),
  ajax,
  andThen(prop('data'))
)

let fetchCategory = pipe(
  toString,
  concat(`http://localhost:3000/categories/`),
  ajax,
  andThen(prop('data'))
)

let fetchCategory_ = pipe(
  prop('categoryId'),
  fetchCategory
)

let onClick = function() {
  pipe(
    fetchBook,
    andThen(x => (this.title = x.title, x)),
    andThen(fetchCategory_),
    andThen(x => this.category = x.category),
    otherwise(console.log)
  )(1)
}

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

12 行

let { get: ajax } = axios

Ramda 習慣使用 free function,故入境隨俗從 axios 抽出 get(),並取 alias 為 ajax()

14 行

let fetchBook = pipe(
  toString,
  concat(`http://localhost:3000/books/`),
  ajax,
  andThen(prop('data'))
)

API function 亦改用 Ramda 的 Function Pipeline 實作,唯 concat() 只接受 String,並沒有提供自動轉型,因此要特別加上 toString() 將 Number 轉成 String。

andThen() 的 callback 原為 x => x.data,改用 Ramda 的 prop('data') 使其 point-free。

34 行

pipe(
  fetchBook,
  andThen(x => (this.title = x.title, x)),
  andThen(fetchCategory_),
  andThen(x => this.category = x.category),
  otherwise(console.log)
)(1)

Ramda 也是基於 Promise,只是從 Method Chaining 的 .then().catch() 改成 Function Pipeline 的 andThen()otherwise(),觀念完全一樣。

唯第二個 andThen() 的 callback 由 fetchCategory() 改成 fetchCategory_()

28 行

let fetchCategory_ = pipe(
  prop('categoryId'),
  fetchCategory
)

因為 fetchCategory_() 改以 Function Pipeline 實作。

可發現 Ramda 為 Function Pipeline 與 Point-free 風格,盡量讓 callback 以 Point-free 呈現,也如同 Promise Chain 一樣,每個 andThen() 只處理一件事情並維持一行,可搭配 comma operator 將原本要多行的動作一行解決,且 pure function 與 side effect 會隔離在不同的 andThen(),不會如 Async Await 都混在一起

RxJS

<template>
  <div>
    <button v-stream:click="{ subject: click$, data: 1 }">Get Book</button>
    {{ title$ }} / {{ category$ }}
  </div>
</template>

<script>
import { EMPTY } from 'rxjs'
import { ajax } from 'rxjs/ajax'
import { catchError, concatMap, pluck, share } from 'rxjs/operators'

let fetchBook$ = x => ajax(`http://localhost:3000/books/${x}`).pipe(
  pluck('response'),
  catchError(e => (console.log(e), EMPTY))
)

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

let subscriptions = function() {
  let book$ = this.click$.pipe(
    pluck('data'),
    concatMap(fetchBook$),
    share()
  )

  let title$ = book$.pipe(
    pluck('title'),
  )

  let category$ = book$.pipe(
    pluck('categoryId'),
    concatMap(fetchCategory$),
    pluck('category')
  )

  return { title$, category$ }
}

export default {
  name:'App',
  domStreams:[
    'click$'
  ],
  subscriptions
}
</script>

以上的 Async Await、Promise Chain 與 Ramda 都是基於 Promise,但 RxJS 則是基於 Observable,思維完全不同。

13 行

let fetchBook$ = x => ajax(`http://localhost:3000/books/${x}`).pipe(
  pluck('response'),
  catchError(e => (console.log(e), EMPTY))
)

API function 改回傳 Observable,由於 data 放在 response property 下,因此使用 pluck() 取出。

Observable 與 Promise 一大差異是處理 asynchronous error 觀念不同。

Promise 不會在 API function 內處理 asynchronous error,而是留在 event handler 內使用 Async Await 的 try catch 或 Promise Chain 的 .catch() 處理,但 Observable 必須在 API function 內處理,若在 event handler 才處理,會造成 stream 中斷。

RxJS 使用 catchError() 處理 asynchronous error,要回傳 Observable 才不會造成 stream 中斷,因此回傳 EMPTY Observable,這會使 Observable 直接進入 complete state,因此後續的 operator 都不會執行。

實務上我們會希望 error handling 能印出 error log,此時可用 comma operator 結合 console.log()EMPTY Observable,只要一行就能解決。

24 行

let book$ = this.click$.pipe(
  pluck('data'),
  concatMap(fetchBook$),
  share()
)

RxJS 另外一個不同處是將 DOM event 視為 Observable,而 API request 亦是 Observable,因此造成了 Higher Order Observable,而 RxJS 又不像 Promise Chain 的 .then() 具有 bind() 特性能攤平 Observable,必須明確使用 concatMap() 將 Higher Order Observable 攤平。

因為稍後將使用 book$ 建立其他 Observable,為避免額外 API request,故最後加上 share()

30 行

let title$ = book$.pipe(
  pluck('title'),
)

book$ Observable 取出 title property。

34 行

let category$ = book$.pipe(
  pluck('categoryId'),
  concatMap(fetchCategory$),
  pluck('category')
)

book$ Observable 取出 categoryId property 後傳進 fetchCategory$(),因為 book$ 為 Observable,fetchCategory$() 又回傳 Observable,因此一樣要使用 concatMap() 攤平 Higher Order Observable。

最後使用 pluck() 取出 category property。

40 行

return { title$, category$ }

RxJS 只要將 Observable 回傳即可,並不必處理 side effect,因為 HTML template 可直接 binding Observable,且 Vue-rx 會自動處理 subscribe()unsubscribe(),這些都不必我們擔心。

可發現 RxJS 不用處理 side effect,因此整個流程都是 pure function

Conclusion

  • 本文分別展示了 4 種方式處理相依性 API:Async Await、Promise Chain、Ramda 與 RxJS,無論使用哪種方式,都看不到 Callback Hell,這也是 ECMAScript 逐漸進步之處
  • Async Await、Promise Chain 與 Ramda 都基於 Promise,而 RxJS 則基於 Observable
  • 既然 Axios 回傳為 Promise,就必須善用 .then()bind() 特性,讓相依 API 在下一個 .then() 執行即可,而不必使用巢狀 .then(),這樣才可避免 Callback Hell
  • Observable 沒有如 .then() 那樣兼具 bind() 特性,因此必須明確搭配 mergeMap()concatMap()switchMap()exhaustMap(),至於該用哪個要視需求決定
  • Async Await 為 Imperative 風格,讓 asynchronous 寫起來很像 synchronous,但別忘其本質仍是 asynchronous
  • Promise Chain 與 Ramda 都應避免 pure function 與 side effect 混在一起
  • Ramda 以 Function Pipeline 方式處理 Promise Chain,也應避免 pure function 與 side effect 混在一起
  • RxJS 基於 Observable,其本質為 stream,重點在 error handling 時不使 stream 中斷,並以 Function Pipeline 處理 Observable Chain
  • 因為 Vue-rx 支援 HTML template 對 Observable 直接 binding,因此 RxJS 不用處理 side effect,整個流程都是 pure function