🔥

【日本語訳】nitrogql 1.1リリース: hello type-safe resolvers!

2023/09/17に公開

この記事は、昨日公開された次の記事の日本語訳です。nitrogql 1.1がリリースされresolver向けの型生成機能が使えるようになり、nitrogqlだけでサーバー側とクライアント側両方を型安全に書けるようになりました。元記事はこちらです。

https://nitrogql.vercel.app/blog/release-1.1


本日、nitrogql 1.1をリリースしました!

nitrogqlはTypeScriptプロジェクトでGraphQLを使うためのツールチェインです。1.1では、resolver向けの型定義の生成機能を追加しました。これにより、nitrogqlを使うことでクライアント側とサーバー側の両方で型安全にGraphQLを使うことができるようになりました。

nitrogql 1.1の新機能

nitrogql 1.1では、1.0の機能に加えて新たに2つのTypeScriptファイルを生成できるようになりました。

  • Resolverの型定義ファイルは、実装すべきResolverの型を定義します。
  • サーバー用GraphQLスキーマファイルは、ランタイムにGraphQLスキーマをGraphQLサーバーに渡すのを簡単にします。

これらのファイルはGraphQLサーバーの実装に役立ちます。nitrogqlが採用しているスキーマファーストアプローチでは、まずGraphQLスキーマを書き、それを元にクライアントとサーバーの両方を実装します。1.1のリリースにより、サーバー側のギャップが埋まりました。これでクライアント側とサーバー側の両方で型安全にGraphQLを使うことができるようになったのです!

サーバー開発向けのnitrogql設定

これらの新しいファイルを生成するには、設定ファイルにいくつかのオプションを追加する必要があります。具体的には、generateオプションの下にresolversOutputserverGraphqlOutputを追加します。

schema: ./schema/*.graphql
documents: ./src/**/*.graphql
extensions:
  nitrogql:
    plugins:
      - "nitrogql:model"
    generate:
      schemaOutput: ./src/generated/schema.d.ts
      resolversOutput: ./src/generated/resolvers.d.ts
      serverGraphqlOutput: ./src/generated/server-graphql.ts
      # ...

この設定を追加することで、nitrogql generateを実行するとresolvers.d.tsserver-graphql.tsが生成されます。

型安全にresolverを実装する

生成されたresolvers.d.tsは、型安全にresolverを実装するのに役立ちます。このファイルからはResolvers型がエクスポートされており、これは実装すべきresolverたちのオブジェクトの型です。例えば、次のようなスキーマがあるとします。

type Query {
  me: User!
}

type User {
  id: ID! @model
  name: String! @model
  email: String!
  posts: [Post!]!
}

type Post {
  id: ID! @model
  title: String! @model
  content: String!
}

すると、生成されたResolvers型は次のように使うことができます。

import { Resolvers } from "./generated/resolvers";

type Context = {};

const resolvers: Resolvers<Context> = {
  Query: {
    me: async () => {
      // Returns the current user.
      return {
        id: "12345",
        name: "uhyo",
      }
    },
  },
  User: {
    email: async (user) => {
      const dbUser = await db.getUser(user.id);
      return dbUser.email;
    },
    posts: async (user) => {
      const dbPosts = await db.getPostsByUser(user.id);
      return dbPosts;
    },
  },
  Post: {
    content: async (post) => {
      const dbPost = await db.getPost(post.id);
      return dbPost.content;
    }
  },
};

Resolvers型はジェネリック型で、コンテキストオブジェクトの型を型引数として受け取ります。コンテキストはリクエストごとに作成され、すべてのresolverに渡されます。これはセッション情報やデータベース接続などをresolverに渡すために使うことができます。

ちょっと待って、その@modelとかいうやつは何?

そうですね。先ほどのスキーマには見慣れないものがありました。それは@modelディレクティブです。これはnitrogqlによって追加されたディレクティブです(より具体的には、nitrogql:modelプラグインによって追加たものです)。このディレクティブは1.1のリリースとともに導入されました。

@modelディレクティブが与えられたフィールドは、その型のモデルオブジェクトの一部となります。これには2つの意味があります。

  • @modelディレクティブが与えられたフィールドに対してはresolverを実装する必要がありません。デフォルトのresolverがそれらのフィールドを処理します。
  • Resolverからその型のオブジェクトを返すときには、@modelディレクティブが与えられたすべてのフィールドを含める必要があります。

@modelディレクティブはresolverを実装する際の実用性と型安全性を両立するために存在します。型安全性は、スキーマに存在するすべてのフィールドに対してresolverが実装されていることを保証することを指します。これが満たされないと、ランタイムエラーになってしまいます。しかし、すべてのフィールドに対して漏れなくresolverを実装しなければならないというのは実用性がありません。なぜなら、id: (user) => user.idのようなボイラープレートコードを大量に書かなければいけなくなるからです。デフォルトのresolverが役立つのがここです。デフォルトのresolverはこのような自明なresolverとして振る舞います。

@modelディレクティブは、そのフィールドにデフォルトのresolverを使いたいことをnitrogqlに伝えるものです。nitrogqlはこのディレクティブを認識し、あなたが実装しなければならないresolverのリストからそのフィールドを取り除きます。重要なのは、どのresolverを実装するか、どのresolverをデフォルトのresolverに任せるかはあなた次第であるということです。だからこそ、@modelディレクティブも、必要なフィールドに対してあなたが手動で書く必要があるのです。どのフィールドにデフォルトのresolverを使うかをnitrogqlが自動的に判断するという選択肢もありましたが、その実装にはしませんでした。それでは柔軟性が足りないからです。

デフォルトのresolverを使うことの帰結として、resolverから返すオブジェクトには@modelディレクティブが与えられたすべてのフィールドを含める必要があります(これをモデルオブジェクトと呼びます)。これは、デフォルトのresolverはモデルオブジェクトに含まれていないフィールドを解決することができないからです。

ご存知のとおり、GraphQLのresolverは、GraphQLクエリの実行中にチェーンを形成します。つまり、resolverからオブジェクトを返すと、チェーンの次のresolverはそのオブジェクトを親オブジェクトとして受け取ります。このため、resolverが受け取る最初の引数はモデルオブジェクトになります。@modelディレクティブはこのようにしてresolver間のデータの受け渡しにも影響を与えます。

@modelの使い方

これで、なぜ@modelディレクティブが導入されたのかが理解できましたね。では、先ほどの例をもう一度見てみましょう。😉

スキーマを見ると、User型のモデルオブジェクトにはidnameフィールドが含まれています。emailpostsフィールドはモデルオブジェクトに含まれていません。同様に、Post型のモデルオブジェクトにはidtitleフィールドが含まれていますが、contentフィールドは含まれていません。

type Query {
  me: User!
}

type User {
  id: ID! @model
  name: String! @model
  email: String!
  posts: [Post!]!
}

type Post {
  id: ID! @model
  title: String! @model
  content: String!
}

次に、me resolverの実装を見てみましょう。このresolverはidnameフィールドを含むオブジェクトを返しています。これは、User型のモデルオブジェクトにこれらのフィールドが含まれていることと合致していますね。

  Query: {
    me: async () => {
      // Userのモデルオブジェクトを返す
      return {
        id: "12345",
        name: "uhyo",
      }
    },
  },

Userのresolverを見ると、emailpostsのresolverが実装されています。これらのフィールドには@modelディレクティブが与えられていないからです。

  User: {
    email: async (user) => {
      // userは { id: string; name: string } 型
      const dbUser = await db.getUser(user.id);
      return dbUser.email;
    },
    posts: async (user) => {
      const dbPosts = await db.getPostsByUser(user.id);
      return dbPosts;
    },
  },

先ほども述べたように、user引数はUser型のモデルオブジェクトです。そのため、idnameフィールドが含まれています。emailのresolverはidフィールドを使ってデータベースからメールアドレスを取得します。

@modelディレクティブが与えられていないフィールドのresolverでは、追加のデータ取得が起こっていると考えることができます。idフィールドはデータベースからさらにデータを取得するためのキーであり、User型を返すresolverはモデルオブジェクトにidフィールドを含んでいるので、後続のresolver(emailpostsなど)はこれを使ってさらにデータを取得することができます。実際の状況では、DataLoaderのようなテクニックを使ってデータ取得を最適化することがあるかもしれませんが、考え方は同じです。

このことを考えると、User型のモデルオブジェクトにidフィールドが含まれているのは必然的なことです。一方で、nameフィールドはデータ取得に使われていないので、モデルオブジェクトに含まれている必然性というのはありません。

では、なぜnameフィールドがモデルオブジェクトに含まれているのでしょうか? 実は、これは最適化のためです。もしnameが頻繁に取得されるのであれば、最初のデータ取得(つまりme resolver)で一緒に取得しておいた方が良いでしょう。もしモデルオブジェクトに含まれていない場合、nameを取得するためにはもう1往復のデータ取得が必要になります。@modelディレクティブを使うことで、型安全性を保ちつつ簡単にデータ取得を最適化することができるのです。さらに高度な最適化をしたい場合は、resolverのチェーンに入る前にクエリ全体を調べる必要がありますが、それはこんなに簡単にできることではありません。

モデルオブジェクト全体の型を@modelで指定する

もしもあなたが勤勉な人なら、型ごとに専用のモデルクラスを定義しているかもしれません。例えば、次のようなコードを書いているかもしれません。

class User {
  readonly id: string;
  readonly name: string;

  constructor(id: string, name: string) {
    this.id = id;
    this.name = name;
  }
  async getEmail() {
    const dbUser = await db.getUser(this.id);
    return dbUser.email;
  }
  async getPosts() {
    const dbPosts = await db.getPostsByUser(this.id);
    return dbPosts;
  }
}

nitrogqlはこのようなモデルの定義方法もサポートしています(あまりおすすめではありませんが)。これは、GraphQL Code Generatorのmappersオプションに似ています。

このクラスをモデルオブジェクトとして使うには、@modelディレクティブを型そのものに大して与えます。例えば次のようになります。

type User @model(type: "import('@/model/user').User") {
  id: ID!
  name: String!
  email: String!
  posts: [Post!]!
}

これにより、GraphQLのUser型に対応するモデルオブジェクトはUserクラスのインスタンスであるとnitrogqlに伝えられます。この設定の場合、resolverの実装は次のようになるでしょう。

import { Resolvers } from "./generated/resolvers";
import { User } from "@/model/user";

type Context = {};

const resolvers: Resolvers<Context> = {
  Query: {
    me: async () => {
      // Returns the current user.
      return new User("12345", "uhyo");
    },
  },
  User: {
    // `user` is an instance of User class
    id: (user) => user.id,
    name: (user) => user.name,
    email: (user) => {
      return user.getEmail();
    },
    posts: (user) => {
      return user.getPosts();
    },
  },
  Post: {
    // ...
  },
};

この場合、Userの全てのフィールドに対してresolverを実装する必要があります。

サーバー用GraphQLスキーマファイルを使用する

賢い読者の方なら、nitrogql 1.1にはサーバー用GraphQLスキーマファイル生成機能も追加されたということを覚えているかもしれません。このファイルの役割は単純で、GraphQLスキーマを文字列としてエクスポートするだけです。例えば次のようになります。

// generated by nitrogql
export const schema = `
type Query {
  me: User!
}

// ...
`;

元々の.graphqlファイルが複数ある場合でも、それらは結合されて1つの文字列としてエクスポートされます。これにより、GraphQLサーバーを初期化する際にこれらのファイルを手動で読み込む手間が省けます。

また、このファイルは追加の安全性保証としても機能します。ランタイムで使われるスキーマが、型定義を生成する際に使ったスキーマと同じであることを保証できるためです。すべてを1つの設定ファイルにまとめるというのは、人為的なミスの可能性を減らすための素晴らしい原則です。

このファイルはGraphQLサーバーを初期化するときに利用できます。例えば、Apollo Serverを使う場合は次のようになります。

import { ApolloServer } from "@apollo/server";
import { schema } from "./generated/server-graphql";
import { Resolvers } from "./generated/resolvers";

const resolvers: Resolvers = { /* ... */ };

const server = new ApolloServer({
  typeDefs: schema,
  resolvers,
});

スキーマのクリーンアップ

実は、サーバー用GraphQLスキーマファイルは.graphqlファイルを単純に結合しただけではありません。@modelディレクティブをすべて削除する処理がされています。

これは、ランタイムの挙動に全く影響しないディレクティブをスキーマに含めることに抵抗があるという方がいることを分かっていたからです。

私たちの考え方としては、スキーマは、ランタイムとコンパイル時の両方に通ずるSingle Source of Truthとして利用するものであるということです。コンパイル時に利用するためにGraphQLの型に何らかのアノテーションをする必要があるのであれば、スキーマに書くのが我々の好みです。

とはいえ、コンパイル時のみに利用するディレクティブをランタイムのコードから削除するのは悪いことではありません。そのため、サーバー用GraphQLスキーマファイルにはこの処理が施されています。

nitrogql:modelプラグイン

実は、@modelディレクティブは全てnitrogql:modelという名前の組み込みプラグインによって実装されています。@modelディレクティブを使うには、このプラグインを有効にする必要があります。この記事の最初で少し触れているように、設定ファイルのpluginsオプションに追加することで有効化できます。

schema: ./schema/*.graphql
documents: ./src/**/*.graphql
extensions:
  nitrogql:
    plugins:
      - "nitrogql:model"
    # ...

このように、@modelディレクトリはオプトインの機能となっています。これは、デフォルトでカスタムディレクティブが追加されるというのはややopinionated過ぎると感じたからです。

しかし、@modelディレクティブなしではresolverの型定義の生成はほとんど使い物になりません。デフォルトの挙動では、各型のモデルオブジェクトにはその型のすべてのフィールドが含まれており、しかも全てのフィールドに対してresolverの実装もする必要があります。これは型安全ではありますが、実用的ではありません。

型安全性はnitrogqlにとって非常に重要なゴールです。どんなオプションの組み合わせでも型安全性は保たれるべきであり、nitrogqlは実用的よりも安全性を優先します。

型安全性を維持したままresolverの開発を実用的なものにするためには、どのフィールドをモデルオブジェクトに含むのかを開発者が指定できるようにする必要があります。これが、プラグインを通じて@modelディレクティブを導入した理由です。

ちなみに、プラグインがない場合のデフォルトの挙動については他の選択肢もありました。面白いかもしれないので紹介しておきます。

全てのフィールドがモデルに含まれ、全てのフィールドに対してresolverを実装する必要がある。 これが選ばれた選択肢です。

全てのフィールドがモデルに含まれ、resolverは全く実装する必要がない(全てのフィールドがデフォルトのresolverを使う)。 これも実は型安全です。しかし、これはGraphQL初心者を誤った方向に導く可能性があります。resolverを実装する必要がないと思わせてしまうからです。これはGraphQLの使い方としては本末転倒です。私たちは初心者にそういう使い方をするように勧めたいとは思いません。

モデルのフィールドはオプショナルで、全てのフィールドに対してresolverを実装する必要がある。 これは、全てのresolverが実装されている限り必要なデータを返すことはできるので、ある意味安全です。しかし、この設定では大量のボイラープレートコードを書かなければなりません。適切な型定義があればもっと開発者体験を改善できるはずです。

モデルのフィールドはオプショナルで、resolverもオプショナル。 これはGraphQL Code Generatorのデフォルトの挙動です。しかし、これは型安全ではないので採用できません。resolverの返り値からフィールドを省略し、かつそのフィールドに対してresolverを実装しない場合、ランタイムエラーになってしまいます。

次は?

実は、ロードマップには現在何もありません。これは、nitrogqlの開発が終わったということではありません。次のリリースに向けていくつかのアイデアを検討していますが、まだ決まっていないということですA

アイデアや要望があれば、GitHubで教えてください。あなたのフィードバックをお待ちしています!

まとめ

nitrogql 1.1は、クライアントとサーバーの両方で型安全性を実現するというゴールに向けて大きな一歩となりました。これで、同じGraphQLスキーマを使って両方の側で型安全性を得ることができます。これにより、GraphQLの開発がより楽しくなることを願っています。

前回の記事では、GraphQL Code Generatorのresolverの型定義の生成はデフォルトでは型安全ではないと言いました。実際、GraphQL Code Generatorで型安全性を得られてしかも実用的な方法は、mappersオプションだけです。

nitrogqlは、同じようなやり方(@modelディレクティブを型そのものに指定する方法)をサポートしていますが、フィールドごとにディレクティブを指定する方法もあります。私たちは、この方法の方が使いやすく、resolverの実装に外部の型定義を必要としないので、こちらの方法の方が好きです。

このリリースでは、あなたにとって見慣れないものを導入することになりましたが、とても良い方向性だと我々は信じています。あなたも気に入っていただけると嬉しいです。

GitHubで編集を提案

Discussion