雖然 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
環境變數。
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 ApolloServer
與 gql
外,還需要 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,然後連同 typeDefs
、resolvers
、 subscriptions
與 context
一併傳入 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 回傳目前所有資料,共兩筆。
Subscription
subscription {
newBook {
title
price
}
}
執行 newBook
subscription,由於還沒被觸發,因此處在 listening 狀態。
Mutation
mutation {
addBook(book: {
title: "Speaking JavaScript"
price: 300
}) {
title
price
}
}
執行 addBook
mutation,由此 mutation 觸發 newBook
subscription。
Subscription
subscription {
newBook {
title
price
}
}
原本 newBook
subscription 由 listening 得到回傳資料。
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