🐱

柔軟にデータ取得が可能なGraphQLのIDを考える

に公開

今回は、Relay仕様のGraphQLサーバーにおいて、Node Resolverで柔軟な検索を可能にするIDフォーマットについての考察を紹介します。

IDフィールドを使用したデータの取得

Relay仕様のGraphQLサーバーでは、以下のように単一のIDフィールドを持つNode InterfaceをimplementしたObjectを、Query.nodeで取得できる必要があります。

interface Node {
  id: ID!
}

type Query {
  node(id: ID!): Node
}

type User implements Node {
  id: ID!
  userId: String!
}

type Post implements Node {
  id: ID!
  postId: String!
  userId: String!
}

この際、IDはクライアント側のキャッシュキーとして使用される都合などから、全体で一意であることが求められます。そのため、{typename}:{識別子} のように型名を含めるなど、重複を避ける構成が推奨されています。
(なお、これに加えてBase64エンコードするのが慣例ですが、今回の実装例では可読性を優先して省略しています)

上記のスキーマでは、以下のようなクエリを実行してQuery.nodeからUserやPostを取得することができます。

query {
  user:node(id: "User:01K4812QSP9PSS417KF1J5EF4M") {
    # インラインフラグメントを使用してUserのフィールドを指定
    ... on User {
      id
      userId
    }
  }
  post:node(id: "Post:01K4812QSPW47ZEKZAYKFWCSAE") {
     # インラインフラグメントを使用してPostのフィールドを指定
     ... on Post {
       id
       userId
       postId
     }
  }
}

サーバー側では以下のようにtypenameからどのデータソースを参照するかを判別することができるでしょう。

export const nodeResolver = (id: string) => {
  const [typename, primaryId] = id.split(':');
  
  switch(typename) {
    case 'User': {
      return findUser(primaryId);
    }
    case 'Post': {
      return findPost(primaryId);
    }
  }
}

この形式のIDの課題

GraphQLサーバーが、外部APIをデータソースとして利用する場合を考えてみましょう。

以下のようなエンドポイントにリクエストしたい場合、GraphQLサーバーは少なくともuserIdとpostIdを知っていなければなりません。

/v1/users/:userId/posts/:postId

よって、以下のように単一の識別子のみでIDが構成されている場合は目的のエンドポイントへリクエストすることができません。

query {
  node(id: "Post:01K4812QSPW47ZEKZAYKFWCSAE") { # postIdしかわからない
    ... on Post {
      __typename
    }
  }
}

バックエンド側に単一の識別子のみで取得できるように依頼して対応してもらえれば問題は解決しますが、設計上の制約がある場合など、単一の識別子では取得できないケースも現実には多いでしょう。

複数の識別子を含んだIDフォーマットをどうするか

この問題をクリアするためには、NodeのID自体が複数の識別子を包含できるように設計されている必要があります。

思いつく一番シンプルな方法は、以下のようなJSON形式にすることです。

const id = JSON.stringify({
  typename: 'Post',
  node: {
    postId: "01K4812QSPW47ZEKZAYKFWCSAE",
    userId: "01K4812QSP9PSS417KF1J5EF4M",
  },
})

この方式であればIDをparseして中身を取り出すことで必要な識別子を全て展開することができるでしょう。

ただし、この場合postIduserIdといったkey名がIDフィールドの中に含まれてしまい、かなり長くなってしまいます。(Base64エンコードするとなおさら)

IDフィールドはクライアント側でキャッシュのキーとしても使用しているため、あまりに長いとメモリを無駄に消費することにも繋がりそうです。

// eyJ0eXBlbmFtZSI6IlBvc3QiLCJub2RlIjp7InBvc3RJZCI6IjAxSzQ4MTJRU1BXNDdaRUtaQVlLRldDU0FFIiwidXNlcklkIjoiMDFLNDgxMlFTUDlQU1M0MTdLRjFKNUVGNE0ifX0=
const id = btoa(
  JSON.stringify({
    typename: 'Post',
    node: {
      postId: "01K4812QSPW47ZEKZAYKFWCSAE",
      userId: "01K4812QSP9PSS417KF1J5EF4M",
    },
  })
);

Metaはどのように実装しているか

MetaではIDをどのように扱っているのか気になり、FacebookのGraphQLレスポンスを確認してみたところ、bookmarkというオブジェクトのIDには
bookmark:{1つ目の識別子}:{2つ目の識別子}
のような形式がBase64でエンコードされていました。

確かにこの形式なら、Post:{postId}:{userId}のように必要な識別子を短くまとめてIDに含めることができそうです。

同様の形式のIDを安全に実装する

Post:{postId}:{userId} というフォーマットは短い一方で、誤って Post:{userId}:{postId} のような形式(userIdとpostIdの順序が逆)でIDを生成してしまい、意図しないデータを取得してしまう可能性があります。

このような事態を避けるためには、以下のように各識別子の順番をキー名でソートしてからIDに含めるような関数を作成しておくと、クライアントとサーバー双方で同じロジックでIDが生成・パースされ、意図しないIDが生成される事故はある程度防ぐことができるでしょう。

import { z } from 'zod';

export type NodeId<Node extends Record<string, unknown>> = {
  typename: string;
  node: Node;
};

export const generateNodeId = (typename: string, node: Record<string, unknown>) => {
  const values = Object.keys(node)
    // 各keyの順番を常に固定することで、IDの構成順序を保証する
    .sort((a, b) => a.localeCompare(b))
    .map((key) => node[key])
    .join(':');

  return `${typename}:${values}`;
};

export const parseNodeId = <
  Schema extends z.ZodObject<Record<string, z.ZodTypeAny>, 'strip', z.ZodTypeAny>,
>(
  id: string,
  schema: Schema,
): NodeId<z.infer<Schema>> => {
  const [typename, ...values] = id.split(':');

  const node = Object.keys(schema.shape)
    // 各keyの順番を常に固定することで、IDの構成順序を保証する
    .sort((a, b) => a.localeCompare(b))
    .reduce((acc, key, index) => {
      return {
        ...acc,
        [key]: values[index],
      };
    }, {} as Record<string, unknown>);

  schema.parse(node);

  return {
    typename,
    node,
  };
};

上記関数をクライアントとサーバー双方で使用すれば、比較的安全にMetaと同様の形式のIDを使用できると思います。

const Post = () => {
  const { node } = useLazyLoadQuery<PostQuery>(
    graphql`
      query PostQuery($id: ID!) {
        node(id: $id) {
          ... on Post {
            __typename
          }
        }
      }
    `,
    {
      // Post:01K4812QSPW47ZEKZAYKFWCSAE:01K4812QSP9PSS417KF1J5EF4M
      id: generateNodeId('Post', {
        postId: '01K4812QSPW47ZEKZAYKFWCSAE',
        userId: '01K4812QSP9PSS417KF1J5EF4M',
      }),
    },
  );

  return <p>{node?.__typename}</p>;
};

const userSchema = z.object({
  userId: z.string().min(1),
});

const postSchema = z.object({
  postId: z.string().min(1),
  userId: z.string().min(1),
});

export const nodeResolver = (id: string) => {
  const [typename] = id.split(':');

  switch (typename) {
    case 'User': {
      const { node } = parseNodeId(id, userSchema);

      return findUser(node.userId);
    }
    case 'Post': {
      const { node } = parseNodeId(id, postSchema);

      return findPost(node.userId, node.postId);
    }
  }
};

より型安全にするなら、以下のようなディレクティブを定義し、型の自動生成などをしても良いでしょう。

directive @nodeId(
  fields: String!
) on OBJECT

type Post @nodeId(fields: """
  userId: String!
  postId: String!
""") { ... }

まとめ

本記事では、Relay仕様におけるNodeのID設計に関して、実際に直面する課題とその解決アプローチについて紹介しました。

  • 単一の識別子によるID設計では、複数の識別子を必要とする外部APIとの連携が難しいケースがある
  • JSON形式で情報を持たせることで柔軟な構造を実現できるが、IDが長くなりやすいという課題がある
  • Metaのように{typename}:{id1}:{id2}形式にすることで、簡潔かつ複数の識別子を含む対応が可能
  • キーの順序を固定し、共通の関数で生成・解析することで、安全性と一貫性を保てる

Relay仕様のサーバーで柔軟かつ安全なID設計を行うためには、どの情報が最低限必要かを明確にした上で、クライアントとサーバー間で整合性のあるID生成ロジックを持っておくと運用がしやすくなるのではないでしょうか。

以上!

Discussion