最近、空いた時間でチマチマとApolloを触っているのだけど、どうも腑に落ちないことがあったのでメモを残しておきます。
先に言っておきますが、特に何の結論もないです。
GraphQLのFragment colocationとは
僕が直面している課題感を説明するために、クライアントサイドGraphQLにおけるFragment colocationについて整理してみましょう。
"colocate" という単語自体、普段はそうそう耳にしないと思います(僕もGraphQLの文脈以外で使ったことがない)。「一緒に配置する」といった意味のようです。
では、何と何を一緒に配置するのでしょうか。それはGraphQLクエリとコンポーネントです(ここでのコンポーネントは、ReactやAngularにおけるViewの部品単位を指します)。
GraphQLの大きな特徴の1つに「クエリのレスポンスに含まれるデータはクライアントが決定する」というものがあります1。REST APIではURLが決まればレスポンスの形式は一意に定まるのが普通だったことを考えると、これはクライアントサイドにとって大きな違いといっていいでしょう。
クライアント、とは平たく言ってしまえば画面のことです。したがって、先程の特徴は「クエリのレスポンスは、描画される画面のコンポーネントが決定する」ということになります。
先述した「クエリとコンポーネントを一緒に配置する」というcolocationの概念は、GraphQLの理念ととても相性が良いわけです。
ここからは、次のGitHub APIのGraphQLクエリを題材に、Fragmentについて考えてみます。
query AppQuery {
viewer {
repositories(first: 10) {
nodes {
name,
url,
description,
languages(first: 10, orderBy: { field: SIZE, direction: DESC }) {
nodes {
id,
name,
},
},
},
pageInfo {
hasPreviousPage,
hasNextPage,
endCursor,
startCursor,
},
totalCount,
},
},
}
自分自身のGitHubレポジトリ先頭10件を取得するクエリです。
さて、これを画面のコンポーネントに落としこむことを考えると、少し問題が残ります。
- レポジトリリストやレポジトリの詳細部分の再利用性が低い
- そもそも長い
そこで、次のようにクエリを分割します。
fragment RepoItem on Repository {
name,
url,
description,
languages(first: 10, orderBy: { field: SIZE, direction: DESC }) {
nodes {
id,
name,
},
},
}
fragment RepoList on User {
repositories(first: 10) {
nodes {
...RepoItem,
},
pageInfo {
hasPreviousPage,
hasNextPage,
endCursor,
startCursor,
},
totalCount,
},
}
query AppQuery {
viewer {
...RepoList,
},
}
GraphQLのデータ階層構造を部分的なfragmentに切り出すことによって、例えば「自分がスターをつけたレポジトリの一覧」のようなクエリに対しても RepoList
を再利用可能になりました。
「クエリのレスポンスは、描画される画面のコンポーネント決定する」の特徴を思い返すと、ここで切り出したfragmentは、対応するコンポーネントによって必要なフィールドが決定されるはずです。
すなわち、クエリのフラグメント階層と合致したコンポーネント階層となっているとよさそうです2。
図中の破線がcolocationのまとまりですね。
コンポーネントとfragmentのコロケーションについては、Facebook RelayやApollo Clientといった主要なGraphQLクライアントライブラリでも言及されています34。
コンポーネント実装例
実際に、先程のクエリについて、Angular + Apolloで実装した場合の例をこの投稿の末尾に貼り付けておきます。
colocationの様子が伝わりやすいよう、HTMLテンプレートとGraphQLを同じ.tsファイル内に記述していますが、colocationの本質は「一緒に配置すること」であるため、コンポーネントに相当するディレクトリを用意して、.htmlと.graphqlファイルに分けて配置してもよいです。
fragmentと変数
ここまでは特に問題ないのですが、fragment内で変数を利用しようと思ったあたりから雲行きが怪しくなってきました。
先程のクエリにページング用のパラメータを変数として付与してみます。
fragment RepoItem on Repository {
# 略
}
fragment RepoList on User {
repositories(first: $first, after: $after) {
nodes {
...RepoItem,
},
pageInfo {
hasPreviousPage,
hasNextPage,
endCursor,
startCursor,
},
totalCount,
},
}
query AppQuery($first: Int!, $after: String) {
viewer {
...RepoList,
},
}
GraphQLの文法上、変数を定義できるのはQuery(またはMutation)だけです。
fragmentに閉じた変数を定義するような記法は存在しません。
したがって、上記の repositories(first: $first, after: $after)
の部分は「RepoList fragmentが親であるクエリにどのような変数が定義されているか知ってしまっている」状態となってしまいます。
親階層の知識を、子である側が意識しなければならないという点が端的にいって気分悪いのです。
Client Libraryごとの現状
この問題は、そもそもGraphQLの文法としてfragmentスコープで変数が定義できれば解決しそうな話で、実際調べてみると https://github.com/facebook/graphql/issues/204 というissueでfragment scoped variableについて議論されています。
このissueはRelay Modernのディスカッションの際に持ち上がったようなのですが、結局graphql-jsにexperimentalFragmentVariables
というオプションがmergeされただけの状態です5。
このオプション、かなり微妙な代物で、もともとはFacebook内部で利用されていたGraphQL実装にあった機能らしいのですが、「fragmentがグローバルな変数を定義できる」という誰得仕様です。
先程の例でいうと、次のようなクエリを構成してもparserはエラーを吐きません。
fragment RepoList on User {
# クエリに変数定義が無くてもエラーにはならないが、 $firstはglobalなscope
repositories(first: $first, after: $after) {
# 略
},
}
query AppQuery {
viewer {
...RepoList,
},
}
結局、Relay Modernでは @arguments
/ @argumentsDefinitions
という独自のディレクティブを使ってfragment scopeな変数を定義可能にしています6。
fragment RepoList on User @argumentsDefinitions(
first: { type: "Int" },
after: { type: "String" },
) {
repositories(first: $first, after: $after) {
# 略
},
}
query AppQuery($first: Int!, $after: String) {
viewer {
...RepoList @arguments(first: $first, after: $after),
},
}
Relay Modernの動きを受けて、Apolloでも同様の機能がfeature requestと上がっているのですが、2019年1月現在で特に動きは無いようです。
- https://github.com/apollographql/apollo-client/issues/2723
- https://github.com/apollographql/apollo-feature-requests/issues/18
おわりに
今回書いた内容については、別に解決できないからといってアプリケーションが作れない、といった類の話ではないです。正直な話、変数がクエリグローバルであっても、適宜命名規約などを導入すればfragment同士で変数が衝突することはほぼ無いと思っています。
ただただApolloで解決できずに気分が悪いだけです。もしこの件について、何かしらの解を知っている方がいたら、コメントなりで教えていただければと。
Appendix: Angular + Apolloでfragment colocation
完全版は https://github.com/Quramy/apollo-angular-example に置いています。
import gql from 'graphql-tag';
import { Component, Input } from '@angular/core';
import { RepoItem } from './__generated__/RepoItem'
const fragment = gql`
fragment RepoItem on Repository {
id,
name,
url,
description,
languages(first: 10, orderBy: { field: SIZE, direction: DESC }) {
nodes { id, name },
},
}
`;
@Component({
selector: 'app-repo-item',
styleUrls: ['./repo-item.component.css'],
template: `
<section>
<header class="title">
<a [href]="repoItem.url" targe="_blank">
{{repoItem.name}}
</a>
</header>
<p class="desc" *ngIf="repoItem.description">{{repoItem.description}}</p>
<p class="desc" *ngIf="!repoItem.description">(no description)</p>
<ul class="langs" *ngIf="repoItem.languages.nodes">
<li class="lang-label" *ngFor="let lang of repoItem.languages.nodes">
{{lang.name}}
</li>
</ul>
</section>
`,
})
export class RepoItemComponent {
static fragment = fragment;
@Input() repoItem: RepoItem;
}
import gql from 'graphql-tag';
import { Component, Input } from '@angular/core';
import { RepoItemComponent } from '../repo-item/repo-item.component';
import { RepoList } from './__generated__/RepoList';
import { Apollo } from 'apollo-angular';
const fragment = gql`
${RepoItemComponent.fragment}
fragment RepoList on User {
repositories(first: 10) {
nodes {
...RepoItem,
},
pageInfo {
hasPreviousPage,
hasNextPage,
endCursor,
startCursor,
},
totalCount,
}
}
`;
@Component({
selector: 'app-repo-list',
styleUrls: ['./repo-list.component.css'],
template: `
<div *ngIf="repoList.repositories && repoList.repositories.nodes as repositories">
<app-repo-item
class="item"
*ngFor="let node of repositories"
[repoItem]="node"
></app-repo-item>
<app-simple-pager
[hasPrev]="repoList.repositories.pageInfo.hasPreviousPage"
[hasNext]="repoList.repositories.pageInfo.hasNextPage"
(prev)="prev()"
(next)="next()"
></app-simple-pager>
</div>
`,
})
export class RepoListComponent {
static fragment = fragment;
@Input() repoList: RepoList;
constructor(private apollo: Apollo) { }
prev() { /* 略 */ }
next() { /* 略 */ }
}
import gql from 'graphql-tag';
import { Component, OnInit } from '@angular/core';
import { Apollo } from 'apollo-angular';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { AppQuery } from './__generated__/AppQuery';
import { RepoListComponent } from './repo-list/repo-list.component';
const appQuery = gql`
${RepoListComponent.fragment}
query AppQuery {
viewer {
...RepoList,
},
}
`;
@Component({
selector: 'app-root',
styleUrls: ['./app.component.css'],
template: `
<div class="container">
<h1>
Your GitHub repositories
</h1>
<div *ngIf="data$ | async as data">
<app-repo-list [repoList]="data.viewer"></app-repo-list>
</div>
</div>
`,
})
export class AppComponent implements OnInit {
data$: Observable<AppQuery>;
constructor(private apollo: Apollo) { }
ngOnInit() {
this.data$ = this.apollo.watchQuery<AppQuery>({
query: cursorsQuery,
}).valueChanges.pipe(map(({ data }) => data));
}
}
-
Demand Driven Architecture と呼ばれることがあります。https://qconnewyork.com/ny2015/system/files/presentation-slides/qcon_dda_2015_iwork09_boguta_nolen.pdf ↩
-
コンポーネントをリファクタリングしつつ、階層にあわせてfragmentを切り出していく、という言い方のほうがより正確かもしれません ↩
-
https://facebook.github.io/relay/docs/en/quick-start-guide.html#using-fragments ↩
-
https://www.apollographql.com/docs/react/advanced/fragments.html#colocating-fragments ↩
-
https://facebook.github.io/relay/docs/en/fragment-container.html#passing-arguments-to-a-fragment ↩