點燈坊

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

使用 Apollo Express 建立 Subscription

Sam Xiao's Avatar 2019-12-14

雖然 Apollo Server 就可建立 Subscription,但若要使用 Middleware,則必須改用 Apollo Server Express,其作法與原本 Apollo Server 有些不同。

Version

macOS Catalina 10.15.2
WebStorm 2019.2.3
Node 13.2.0
Express 4.17.1
Apollo Server Express 2.9.13

Install Package

$ yarn add express apollo-server-express graphql
$ yarn add @babel/core @babel/cli @babel/preset-env @babel/node --dev
$ yarn add nodemon rimraf cross-env --dev

安裝 Apollo Server 與 Babel 所需 package。

$ yarn add express apollo-server-express graphql

安裝 Express、Apollo Server Express。

$ yarn add @babel/core @babel/cli @babel/preset-env @babel/node --dev

安裝 Babel 相關 package,由於只是轉譯用,安裝成開發用的 devDependency 即可。

其中 @babel/node 負責將 ES6+ 轉譯成 Node 能執行的 js。

$ yarn add nodemon rimraf cross-env --dev

安裝 nodemon 可監控檔案修改重新 Babel 轉譯與重啟 Node。

安裝 rimraf 可令 yarn clean 跨平台刪除 dist 目錄。

安裝 cross-env 可跨平台設定 NODE_ENV 環境變數。

subscription000

Apollo Server Express

src/index.js

import { ApolloServer, gql, PubSub } from 'apollo-server-express'
import express from 'express'
import { createServer } from 'http'

let data = [
  { title: 'FP in JavaScript', price: 100 },
  { title: 'RxJS in Action', price: 200 }
]

let pubsub = new PubSub()

let typeDefs = gql`
  type Query {
    books: [Book]
  }

  type Mutation {
    addBook(book: BookInput!): Book
  }

  type Subscription {
    newBook: Book
  }

  type Book {
    title: String
    price: Int
  }

  input BookInput {
    title: String!
    price: Int!
  }
`

let books = () => data

let addBook = (_, { book }, { pubsub }) => {
  data.push(book)

  pubsub.publish('bookAdded', { newBook: book })

  return book
}

let newBook = {
  subscribe: (_, __, { pubsub }) => pubsub.asyncIterator('bookAdded')
}

let resolvers = {
  Query: {
    books
  },
  Mutation: {
    addBook
  },
  Subscription: {
    newBook
  }
}

let subscriptions = { path: '/api' }
let context = () => ({ pubsub })
let apollo = new ApolloServer({
  typeDefs, resolvers,
  subscriptions, context
})

let app = express()
apollo.applyMiddleware({ app, path:'/api' })

let http = createServer(app)
apollo.installSubscriptionHandlers(http)

let port = 4000

http.listen({ port }, () => {
  console.log(`GraphQL ready at http://localhost:${port}${apollo.graphqlPath}`)
})

第 1 行

import { ApolloServer, gql, PubSub } from 'apollo-server-express'

若要使用 subscription,除了 import ApolloServergql 外,還需要 PubSub,注意是來自於 apollo-server-express,而非 apollo-server

PubSub 用來發布 event 與訂閱 event。

第 2 行

import express from 'express'

Apollo Server Express 無法獨立使用,還必須搭配 Express。

第 3 行

import { createServer } from 'http'

使用原生的 createServer 建立 HTTP Server。

第 5 行

let data = [
  { title: 'FP in JavaScript', price: 100 },
  { title: 'RxJS in Action', price: 200 }
]

原始只有 2 筆資料,第 3 筆將由 mutation 新增並通知 subscription。

第 10 行

let pubsub = new PubSub()

PubSub 為 factory class,實作了 PubSubEngine interface,稍後會以 publish() 發布 event。

13 行

type Query {
  books: [Book]
}

type Mutation {
  addBook(book: BookInput!): Book
}

type Book {
  title: String
  price: Int
}

input BookInput {
  title: String!
  price: Int!
}

type Subscription {
  bookAdded: Book
}

在 schema 宣告 books query、addBook mutation 與 newBook subscription,值得注意的是 subscription 宣告方式與 query 與 mutation 並無差異。

52 行

let resolvers = {
  Query: {
    books
  },
  Mutation: {
    addBook
  },
  Subscription: {
    bookAdded
  }
}

resolvers 宣告 books query、addBook mutation 與 newBook subscription,值得注意的是 subscription 宣告方式與 query 與 mutation 並無差異。

36 行

let books = () => data

實踐 books query,目前只是回傳所有資料。

38 行

let addBook = (_, { book }, { pubsub }) => {
  data.push(book)

  pubsub.publish('bookAdded', { newBook: book })

  return book
}

實踐 addBook mutation,重點在 mutation 內使用 pubsub.publish() 發布 bookAdded event。

pubsub 直接從 context argument 解構,稍後會建立 context。

publish() 的第一個 argument 為 event 名稱。

第二個 argument 為要發到 event 的資料,newBook 為 subscription 名稱,book 為要傳給 subscription 的資料。

46 行

let newBook = {
  subscribe: (_, __, { pubsub }) => pubsub.asyncIterator('bookAdded')
}

實踐 newBook subscription,實作 subscribe() 時由 context argument 直接解構出 pubsub,再由 pubsub.asyncIterator() 訂閱 bookAdded event。

Schema 與 resolver 寫法在 Apollo Server 與 Apollo Server Express 的寫法皆相同

62 行

let subscriptions = { path: '/api' }
let context = () => ({ pubsub })
let apollo = new ApolloServer({
  typeDefs, resolvers,
  subscriptions, context
})

一般我們會使用 Apollo Server Express,除了因為其支援 middleware 外,為了 reverse proxy 而需自訂 path 也是常見原因。

若要替 WebSocket 自訂 path,須建立 subscriptions object 並設定其 path property。

由於各 resolver 都要使用 pubsub,因此特別將 pubsub 放到 context 內。

context() 內回傳包含 pubsub 的 object,然後連同 typeDefsresolverssubscriptionscontext 一併傳入 ApolloServer 的 constructor。

69 行

let app = express()
apollo.applyMiddleware({ app, path:'/api' })

使用 express() 建立 app object,使用 applyMiddleware() 將 Apollo Server Express 以 middleware 掛到 Express 下。

72 行

let http = createServer(app)
apollo.installSubscriptionHandlers(http)

使用 createServer() 建立 HTTP Server,再使用 installSubscriptionHandlers() 使其支援 WebSocket。

75 行

http.listen({ port }, () => {
  console.log(`GraphQL ready at http://localhost:${port}${apollo.graphqlPath}`)
})

以指定 path 啟動 Apollo Server Express。

GraphQL Playground

Query

query {
  books {
    title
    price
  }
}

執行 books query 回傳目前所有資料,共兩筆。

subscription001

Subscription

subscription {
  newBook {
    title
    price
  }
}

執行 newBook subscription,由於還沒被觸發,因此處在 listening 狀態。

subscription002

Mutation

mutation {
  addBook(book: {
    title: "Speaking JavaScript"
    price: 300
  }) {
    title
    price
  }
}

執行 addBook mutation,由此 mutation 觸發 newBook subscription。

subscription002

Subscription

subscription {
  newBook {
    title
    price
  }
}

原本 newBook subscription 由 listening 得到回傳資料。

subscription004

Conclusion

  • Client 端使用 subscription 後,會由其他 client 執行 mutation,由該 mutation 觸發 subscription 通知已訂閱的 client
  • 實務上 mutation 會由其他 browser 執行, 因此在 GraphQL Playground 會另開一個 tab 執行 mutation
  • Subscription 會在 resolver 內使用 pubsub,因此特別適合將 pubsub 放在 context 內讓多個 resolver 共用
  • Subscription 底層使用了 WebSocket,但在使用上完全感覺不到 WebSocket 存在
  • Apollo Server 與 Apollo Server Express 在 GraphQL 寫法完全一樣,唯在 HTTP 與 WebSocket 建立方式稍有不同

Sample Code

完整範例可在我的 GitHub 上找到

Reference

Jefflowery, GraphQL Subscriptions Using Apollo 2
Apollo Docs, Subscription with Additional Middleware