GraphQL の Subscription

May 03, 2023

概要

GraphQL には Subscription という操作が存在し、Mutation によりデータソースの操作をした際に、クライアント側でサーバーから発行された変更情報を受け取ることができます。ユースケースとしてはチャットアプリ等が考えられます。
今回は Apollo サーバーを利用して、Subscription の動作を確認していきたいと思います。

Apollo とは

Apollo はデータグラフを構築するためのプラットフォームであり、アプリケーションクライアントをバックエンドにシームレスに接続する GraphQL のオープンソースです。Apollo プラットフォームは、既存のアーキテクチャに組み込むことも可能で、システム全体 (サーバー/クライアント) で GraphQL をスケールするのに役立つような本番環境用のツールも備えています。

環境構築

新規でプロジェクトディレクトリを作成し、必要なディレクトリ・ファイル群を作成、最後に npm 経由で apollo-server をインストールします。

$ mkdir subscription
$ cd subscription/
$ mkdir resolver
$ touch index.js db.js schema.js resolver/{Query.js,Mutation.js,Subscription.js}
$ npm init
$ npm install apollo-server@2.19.0 graphql@15.4.0

最終的に以下のようなディレクトリ構成となります (node_modules は除いています)。

$ tree -L 2 -I node_modules
.
├── db.js
├── index.js
├── package-lock.json
├── package.json
├── resolver
│   ├── Mutation.js
│   ├── Query.js
│   └── Subscription.js
└── schema.js

それでは実装に入っていきます。

スキーマの定義

まずはスキーマの定義です。以下のように schema.js 内にスキーマを定義していきます。なお gql というタグ付きテンプレートリテラルを利用し、GraphQL に関連する定義を JavaScript の文字列として表現しています。

schema.js
const {gql} = require("apollo-server");

const typeDefs = gql`
  type Query {
    posts(query: String): [Post!]!
  }

  type Mutation {
    createPost(data: CreatePostInput!): Post!
    deletePost(id: ID!): Post!
    updatePost(id: ID!, data: UpdatePostInput!): Post!
  }

  type Subscription {
    post: PostSubscriptionPayload!
  }

  input CreatePostInput {
    title: String!
    author: String!
  }

  input UpdatePostInput {
    title: String
    author: String!
  }

  type Post {
    id: ID!
    title: String!
    author: String!
  }

  enum MutationType {
    CREATED
    UPDATED
    DELETED
  }

  type PostSubscriptionPayload {
    mutation: MutationType!
    data: Post!
  }
`

module.exports = typeDefs;

テストデータの用意

次に、動作確認のためのテストデータを用意します。今回は動作確認用なので、実際の RDBMS や NoSQL といったデータベースサーバーを用意するのではなく、JavaScript のファイルを利用します。

db.js
const posts = [
  {
    id: "1",
    title: "Do the right things",
    author: "Milan"
  },
  {
    id: "2",
    title: "Dealing with ambiguity",
    author: "Katie"
  },
  {
    id: "3",
    title: "Moving forward",
    author: "Issac"
  }
];

const db = {
  posts
};

module.exports = db;

リゾルバの実装

テストデータの用意ができたら、次はリゾルバの実装に取り掛かります。なお、リゾルバ関数には固定の役割を持つ以下の 4 つの引数があり、よく利用されます。

引数 説明
parent 親リゾルバからの戻り値
args フィールドに提供された全ての GraphQL 引数を含むオブジェクト
context 特定の操作に対して実行される全てのリゾルバ間で共有される。これを利用して認証情報やデータソースへのアクセスなどの操作ごとの状態を共有できる
info フィールド名、ルートからフィールドへのパスなど、操作の実行状態に関する情報

Query リゾルバ

Query のリゾルバ関数では引数の有無で条件分岐をします。発行したクエリに引数がなかった時は存在するデータを全て返し、引数があった場合は title もしくは author の値と一致したもののみ返却されます。

resolver/Query.js
const Query = {
  posts(parent, args, { db }, info) {

    // List posts if there is no arg
    if (!args.query) {
      return db.posts;
    } else {
      return db.posts.filter((post) => {
        const isTitleMatch = post.title.toLowerCase().includes(args.query.toLowerCase());
        const isAuthorMatch = post.author.toLowerCase().includes(args.query.toLowerCase());
        return isTitleMatch || isAuthorMatch;
      });
    }
  }
}

module.exports = Query;

Mutation リゾルバ

Mutation のリゾルバ関数ではデータベースの更新 (createPost/updatePost/deletePost) 及び Subscription チャネルへの Publish をおこなっています。

resolver/Mutatoin.js
const Mutation = {
  createPost(parent, args, {db, pubsub}, info) {
    const postNumTotal = String(db.posts.length + 1);
    const post = {
      id: postNumTotal,
      ...args.data
    }

    db.posts.push(post);

    pubsub.publish("post", {
      post: {
        mutation: "CREATED",
        data: post
      }
    });
    return post;
  },
  updatePost(parent, args, {db, pubsub}, info) {
    const {id, data} = args;
    const post = db.post.find((post) => post.id === id);

    if (!post) {
      throw new Error("Post not found")
    }

    if (typeof data.title === "string" && typeof data.author === "string") {
      post.title = data.title;
      post.author = data.author;
      pubsub.publish("post", {
        post: {
          mutation: "UPDATED",
          data: post
        }
      });
    }
    return post;
  },
  deletePost(parent, args, {db, pubsub}, info) {
    const post = db.posts.find((post) => post.id === args.id);
    const postIndex = db.posts.findIndex((post) => post.id === args.id);

    if (postIndex === -1) {
      throw new Error("Post not found");
    }

    db.posts.splice(postIndex, 1);
    pubsub.publish("post", {
      post: {
        mutation: "DELETED",
        data: post
      }
    });
    return post;    
  }
}

Subscription リゾルバ

Subscription のリゾルバ関数では Subscription のイベントを非同期でリッスンする AsyncIterator を戻り値として指定する必要があります。引数には Mutation リゾルバ関数内の pubsub.publish() で指定したトピック名である post が入ります。

resolver/Subscription.js
const Subscription = {
  post: {
    subscribe(parent, args, {pubsub}, info) {
      return pubsub.asyncIterator("post");
    }
  }
}

module.exports = Subscription;

Apollo サーバーの起動

ここまでできたら後は Apollo サーバーを起動するための処理を書くだけです。今回は Subscription を利用するため、PubSub のインスタンスを作成します。

index.js
const {ApolloServer, PubSub} = require("apollo-server");
const db = require("./db");
const Query = require("./resolver/Query");
const Mutation = require("./resolver/Mutation");
const Subscription = require("./resolver/Subscription");
const typeDefs = require("./schema");

//Create PubSub instance to subscribe 
const pubsub = new PubSub();

//Create ApolloServer instance
const server = new ApolloServer({
  typeDefs: typeDefs,
  resolvers: {
    Query,
    Mutation,
    Subscription
  },
  context: {
    db,
    pubsub
  }
});

//Launch the server
server.listen().then(({ url, subscriptionsUrl }) => {
  console.log(`Server ready at ${url}`);
  console.log(`Subscriptions ready at ${subscriptionsUrl}`);
});

動作の確認

それでは実際に動作確認をします。まずはサーバーを起動します。

$ node index.js 
Server ready at http://localhost:4000/
Subscriptions ready at ws://localhost:4000/graphql

次にブラウザから http://localhost:4000/ にアクセスしますが、今回は Apollo サーバーを利用しているため、Prisma によって作成される GraphQL Playground を使用する形となります。基本的な使い方は GraphQL IDE と同じです。

まず、Query でテストデータの読み込みが行えるか確認しましょう。

Query
query {
  posts {
    id
    title
    author
  }
}
Response
{
  "data": {
    "posts": [
      {
        "id": "1",
        "title": "Do the right things",
        "author": "Milan"
      },
      {
        "id": "2",
        "title": "Dealing with ambiguity",
        "author": "Katie"
      },
      {
        "id": "3",
        "title": "Moving forward",
        "author": "Issac"
      }
    ]
  }
}

f:id:shiro_kochi:2018××××××××:plain:w100:left

用意したテストデータが返ってきていますね。
それでは本題の Subscription です。以下のクエリを実行すると Subscription が開始されます。

Query
subscription {
  post { 
    mutation
    data {
      id
      title
      author
    }
  }
}

f:id:shiro_kochi:2018××××××××:plain:w100:left

実際に WebSocket で通信を行なっていることも確認できます。

f:id:shiro_kochi:2018××××××××:plain:w100:left

次に、別のタブを開いて以下のように新規でデータを挿入してみます。

Query
mutation {
  createPost(data: {
    title: "A new world"
    author: "Loyd"
  }) {
    id
    title
    author
  }
}
Response
{
  "data": {
    "createPost": {
      "id": "4",
      "title": "A new world",
      "author": "Loyd"
    }
  }
}

f:id:shiro_kochi:2018××××××××:plain:w100:left

すると、Subscription 側にメッセージが Publish されていることが確認できます。

f:id:shiro_kochi:2018××××××××:plain:w100:left

同じように更新や削除もやっていきます。

更新

Query
mutation {
  updatePost(id: 4, data: {
    title: "Hello world"
    author: "Loyd"
  }) {
    id
    title
    author
  }
}
Response
{
  "data": {
    "updatePost": {
      "id": "4",
      "title": "Hello world",
      "author": "Loyd"
    }
  }
}

削除

Query
mutation {
  deletePost(id: 4) {
    id
    title
    author
  }
}
Response
{
  "data": {
    "deletePost": {
      "id": "4",
      "title": "Hello world",
      "author": "Loyd"
    }
  }
}

すると Subscription 側ではそれらに対応する内容が Publish されていることがわかります。

f:id:shiro_kochi:2018××××××××:plain:w100:left


 © 2023, Dealing with Ambiguity