Netflix DGSで大規模用GraphQLサーバーをJavaで実装する

NetflixのTech blogに書かれていたGraphQL FrameworkがOSSとして公開されていたので、さっそく使ってみました。

* [How Netflix Scales its API with GraphQL Federation (Part 1)](https://netflixtechblog.com/how-netflix-scales-its-api-with-graphql-federation-part-1-ae3557c187e2)
* [How Netflix Scales its API with GraphQL Federation (Part 2)](https://netflixtechblog.com/how-netflix-scales-its-api-with-graphql-federation-part-2-bbe71aaec44a)

## Netflix DGS

DGS(Domain Graph Service)は、Netflixが開発したGraphQLサーバーを構築するためのJava/Kotlin用のフレームワークです。
大規模GraphQLサービスの構築を想定していて、[Apollo Federation](https://www.apollographql.com/docs/federation/)を採用しています。
Spring Boot上で動作するので、非常に扱いやすいフレームワークになっています。Spring SecurityやスキーマからのCode generationもサポートしています。
詳しくは[公式ドキュメント](https://netflix.github.io/dgs/)を見るのが良いです。

## NetflixがGraphQLフレームワークを開発した背景

NetflixがGraphQLを採用した理由は、フロントエンドに公開するAPIの複雑さを解消するためです。
当初、各マイクロサービスが公開APIを用意していたので、下のような状態になっていました。
![スクリーンショット 2021-01-17 20.59.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166268/513065bf-2834-802f-7392-91a622d22ef4.png)

非常に複雑ですね。フロンドエンドの観点でみると、問い合わせ先のサーバエンドポイントがたくさんあるので、その管理が大変そうです。
サーバサイドの観点でも、認証など何か前処理が必要な場合に、各マイクロサービスでそれぞれ同じような処理を実装しなければならない等の問題が発生しそうです。

そこでNetflixではGraphQLを採用することに決めました。GraphQL採用後の図は以下の通りです。

![スクリーンショット 2021-01-17 21.06.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166268/2dff9508-cd6d-d592-d09d-d9ef688a1432.png)

フロンドエンドの問い合わせ先が紫色の部分、GraphQLに集約され、非常にスッキリした構成になりました。
フロンドエンドからのリクエストは、GraphQLを経由して各マイクロサービスにリクエストされます。
認証などの前処理もGraphQLの部分に実装すれば、同じ処理を各マイクロサービスで実装する必要はなさそうです。

しかし、ここで１つ問題がありました。**GraphQLを運営するチームの負荷が大きい。**

そこでApollo Federationを採用し、GraphQLを開発する負荷を分散させました。
Apollo Federation採用後の図は以下の通りです。

![スクリーンショット 2021-01-17 21.18.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166268/8f7880b4-a660-1422-ae12-4b3690306c11.png)

Apollo Federationを採用することで、前段にGraphQL Gatewayを配置し、そのバックエンドに各GraphQLサービスを配置する構成が可能になります。上の図だと、紫色の部分がGraphQL Gatewayで、その右の青色の部分が各マイクロサービス用のGraphQLです。
各マイクロサービス用のGraphQLは各サービスチームが運営し、GraphQL Gatewayは必要最小限のみの実装を行うことで、GraphQLの運営負荷の分散を実現しています。

## 実装する機能を決める

さて、実際にフレームワークを使って実装してみます。
以前書いた記事 [GraphQLサーバーをJavaで実装してみる](https://qiita.com/policeman-kh/items/2714e4c138353041ce25)と同じ機能(Query、Mutation、Subscription)を実装することにしました。機能のユースケースは以下の通りです。

* Query: 本の一覧を取得する。
* Query: 本のIDを指定して、特定の本を取得する。
* Mutation: 新しい本を登録する。
* Subscription: 新たに登録された本を通知する。

Federationを実装したかったのですが、現時点ではFederationに関するドキュメントが見つからず、特にGateway部分の実装がよくわかりません。ドキュメントが公開されたら実装してみることにします。

## プロジェクトを構成する
プロジェクトはひな型は[Spring Initializer](https://start.spring.io/)で適当に作成します。必要なdependenciesは後から追加するので、ここではLombokのみ追加します。

## アプリケーションを作成する

### build.gradle

以下のように変更します。

```build.gradle
plugins {
	id 'org.springframework.boot' version '2.4.1'
	id 'io.spring.dependency-management' version '1.0.10.RELEASE'
	id 'java'
	// Added for "api".
	id 'java-library'
	// Added for code generation from scheme.
	id 'com.netflix.dgs.codegen' version '4.0.10'
}

sourceCompatibility = '11'

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
	// Added to fix error "Could not find com.apollographql.federation:federation-graphql-java-support".
	jcenter()
}

dependencies {
	api 'com.netflix.graphql.dgs:graphql-dgs-spring-boot-starter:latest.release'
	// Added for subscription.
	implementation 'com.netflix.graphql.dgs:graphql-dgs-subscriptions-websockets-autoconfigure:latest.release'

	implementation 'org.springframework.boot:spring-boot-starter-web'

	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'
}

// Added for code generation from scheme.
generateJava {
	schemaPaths = ["${projectDir}/src/main/resources/schema"]
	// Set package name of generated code.
	packageName = 'sandbox.dgs'
	// Set "false" not to generate client code.
	generateClient = false
}
```
### schema.graphqls
GraphQLのスキーマを定義します。
`schema.graphqls`は`src/main/resources/schema`以下に登録します。

```schema.graphqls
type Query {
    bookById(id: ID): [Book]!
    books: [Book]!
}

type Book {
    id: ID
    name: String
    pageCount: Int
}

type Mutation {
    registerBook (
        id: ID
        name: String
        pageCount: Int
    ): Book
}

type Subscription {
    subscribeBooks: Book
}
```

### Java Model

Code generation plugin を使って、スキーマからJavaのクラスを作成します。

```
$ ./gradlew generateJava 
$ ls -l build/generated/sandbox/dgs
total 8
-rw-r--r--  1 xxxxxxx  yyyyy  942 Jan 11 19:48 DgsConstants.java
drwxr-xr-x  4 xxxxxxx  yyyyy  128 Jan 11 19:48 types

$ ls -l build/generated/sandbox/dgs/types
total 16
-rw-r--r--  1 xxxxxxx  yyyyy  2223 Jan 11 19:48 Book.java
-rw-r--r--  1 xxxxxxx  yyyyy  1487 Jan 11 19:48 Subscription.java
```
Bookクラスが作成できました。

### DGS Component

次に、Query、Mutation、そしてSubscriptionを実装したServiceクラス(DGS component)を作成します。以下のことを行いました。

* クラスに`@DgsComponent`を付与します。
* メソッドに`@DgsData`を付与し、`parentType`にはスキーマのtype name、`field`にはfield nameを設定します。
* fieldの引数は、`@InputArgument`もしくは`DataFetchingEnvironment`を使用して取得します。
* subscriptionのreturn値は`reactive-streams`の`Publisher`を返します。

`DataProvider`と`IBookProcessor`は独自に作成したクラスです。以下の処理を行います。

* DataProvider : データ提供クラス。すべてのBookのList、または指定されたbookIdのBookを返す。
* IBookProcessor : 本の登録をイベントとして登録(emit)し、イベントのPublisherを発行(publish)する。

```BookService.java
@AllArgsConstructor
@DgsComponent
public class BookService {
    private final DataProvider dataProvider;
    private final IBookProcessor bookProcessor;

    @DgsData(parentType = "Query", field = "books")
    public List<Book> books() {
        return dataProvider.books();
    }

    @DgsData(parentType = "Query", field = "bookById")
    public List<Book> books(@InputArgument("id") String id) {
        if (id == null || id.isEmpty()) {
            return dataProvider.books();
        }
        return List.of(dataProvider.bookById(id));
    }

    @DgsData(parentType = "Mutation", field = "registerBook")
    public Book registerBook(DataFetchingEnvironment dataFetchingEnvironment) {
        final String id = dataFetchingEnvironment.getArgument("id");
        final String name = dataFetchingEnvironment.getArgument("name");
        final int pageCount = dataFetchingEnvironment.getArgument("pageCount");

        final Book book = new Book(id, name, pageCount);
        dataProvider.books().add(book);
        // Emit an event for subscription.
        bookProcessor.emit(book);
        return book;
    }

    @DgsData(parentType = "Subscription", field = "subscribeBooks")
    public Publisher<Book> subscribeBooks() {
        return bookProcessor.publish();
    }
}
```

### アプリケーションを実行する

作成したアプリケーションを実行します。GraphiQLが付属されているので、SpringBootのアプリケーションを起動し、エンドポイント(`http://localhost:8080/graphiql`)にブラウザでアクセスします。

* Query: 本の一覧を取得する。
![list.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166268/6295a25d-1f2d-6396-12d5-b284fcc47156.png)

* Query: 本のIDを指定して、特定の本を取得する。
![byId.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166268/f63893ba-230e-961d-0e36-9481bcf2eeb6.png)

* Mutation: 新しい本を登録する。
![mutation.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166268/62743a37-7b23-61d5-1f9f-e6611585133b.png)

* Subscription: 新たに登録された本を通知する。
![subscription.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166268/2095ef52-4649-0341-0c73-2fc6c9ecc8ee.png)

Subscriptionだけエラーになりました。debugログを見てみると、以下のエラーが出力されていました。

```
2021-01-11 21:05:52.305 DEBUG 70696 --- [nio-8080-exec-2] o.s.w.s.m.m.a.HttpEntityMethodProcessor  : Writing ["Trying to execute subscription on /graphql. Use /subscriptions instead!"]
2021-01-11 21:05:52.305 DEBUG 70696 --- [nio-8080-exec-2] o.s.web.servlet.DispatcherServlet        : Completed 400 BAD_REQUEST
```
Subscriptionの場合は、`/subscriptions`にリクエストする必要があるようです。
GraphiQLのリクエスト先を変更する方法がわからなかったので、以下のようなクライアントコードを実装して試したところ、うまく動きました。

```main.ts
import { WebSocketLink } from "@apollo/client/link/ws";
import { SubscriptionClient } from 'subscriptions-transport-ws';
import { ApolloClient, InMemoryCache } from "@apollo/client";
import gql from 'graphql-tag';
import * as $ from 'jquery';

const GRAPHQL_ENDPOINT = 'ws://localhost:8080/subscriptions';

const client = new SubscriptionClient(GRAPHQL_ENDPOINT, {
  reconnect: true,
});

const link = new WebSocketLink(client);

const apolloClient = new ApolloClient({
  link: link,
  cache: new InMemoryCache()
});

const asGql = gql`
subscription BookSubscription {
  subscribeBooks {
    id,
    name
  }
}
`

const s = apolloClient.subscribe({
  query: asGql
})
s.subscribe({
  next: ({ data }) => {
    const result = document.getElementById("result");
    $("#result").append(JSON.stringify(data));
    $("#result").append("<br>");
  }
});
```

### まとめ

以上、Netflix DGSを使ってGraphQLサーバーを実装する方法について記述しました。
今回、試したのはあくまでGraphQLの基本機能ですが、使い勝手は悪くない印象でした。
Federationの部分を早く試してみたいですね。

作成したソースコードは、以下のGitHub上に登録しています。参考にしていただければ幸いです。

* [Simple application of GraphQL server with DGS](https://github.com/policeman-kh/sandbox_dgs)