1. なぜGraphQLでファイルアップロードが問題になるのか
GraphQLはもともとJSONベースでのやり取りを想定して設計されています。つまり、クエリやミューテーショはJSON形式でサーバーに送られ、レスポンスもJSONで返ってくるのが標準的な流れです。バイナリファイルをそのままJSONに含めることはできないため、ファイルアップロードを単純に行おうとしてもハードルがあります。
一方で、写真共有アプリやユーザーアバターの設定、ドキュメント管理システムなど、ファイルのアップロードが必要になるケースは多々あります。ここで「GraphQLでどう実装するか?」という課題がしばしば出てきます
2. 一般的なアプローチ:署名付きURLを用いたアップロード
多くのプロジェクトで採用されるシンプルかつ安全な方法は、GraphQLとは別にファイルアップロード用のエンドポイントを用意し、アップロード処理をそちらで完結させるというパターンです。
2.1 具体的な流れ
-
GraphQLミューテーションで、アップロード用URL(例: S3署名付きURL)を取得する
- たとえば、
getUploadUrl
というミューテーションを定義し、ファイル名やファイル種別などを引数として送ります。 - サーバー側でAWS S3などのストレージサービスにアクセスし、一時的な署名付きURL(Presigned URL)を生成。
- そのURLをクライアントに返す。
- たとえば、
-
クライアントは受け取ったURLに対して直接ファイルをアップロードする
- これは通常のHTTP PUTなどで行います。S3などのクラウドストレージに直接バイナリを送信できる。
- この時点ではGraphQLを通さず、純粋にREST的なHTTP通信を行うイメージです。
-
GraphQL側に「アップロード完了」を通知し、アップロードしたファイルのURIやIDを登録する
- ファイルのアップロードが完了したら、再度GraphQLのミューテーションを呼び、ファイルのURLやIDをサーバー側のDBに保存する。
- すると、GraphQLで管理したい情報(たとえば「ユーザーのアイコンURL」「投稿の画像URI」など)とストレージ上のファイルパスが紐付けられます。
2.2 メリット
- 実装がシンプル: GraphQLのJSON通信と、バイナリファイルの送信を分離することで、複雑なカスタムプロトコルを用意する必要がありません。
- セキュリティが高い: たとえばAWS S3では、期限付きかつ特定のオブジェクトにのみアクセス可能なPresigned URLを発行できます。これにより、特定のユーザー(リクエスト)以外からは無制限にアップロードされないように制御できます。
- スケーラビリティ: ファイルアップロードのトラフィックはCDNやストレージサービス側で処理され、GraphQLサーバーの負荷を回避できます。大きなファイルを大量にアップロードするケースでも、アプリケーションサーバーを圧迫しにくいのは大きな利点です。
2.3 デメリット
- 別のエンドポイントや手順を覚える必要がある: 「まずはURLを取得→次にファイルをPUT→最後にGraphQLに通知」という3ステップになるため、クライアント実装がやや複雑に感じる場合があります。
- 複数リクエストの管理が必要: 単一リクエストですべて済ませることは難しく、アップロードの成否やエラー処理をクライアント側でしっかり行う必要があります。
3. graphql-upload を使うアプローチ
3.1 multipart/form-data をGraphQLで扱う仕組み
一部のライブラリ(有名なのは graphql-upload
)では、multipart/form-dataという仕組みを使って、GraphQLリクエスト内にファイルを同梱できるようにしています。具体的には、クエリやミューテーションの「変数」の部分にファイルをマッピングする形で送ることが可能です。
mutation uploadFile($file: Upload!) {
uploadFile(file: $file) {
filename
mimetype
encoding
url
}
}
クライアントはこのとき、file
として選択したバイナリデータをmultipartフォームで送信し、ライブラリがそれをGraphQL resolverへ渡してくれます。
3.2 メリット
- よりGraphQLの思想に近い: 1つのGraphQLミューテーションで「ファイル+メタ情報」をまとめて送れるため、「ファイルをアップロードして、そのURLや関連情報をDBに登録する」といった操作を一括で行えます。
-
クライアント実装がシンプル(ライブラリ依存): ReactなどのフロントエンドではApollo Clientで
apollo-upload-client
を導入するなどして設定すれば、従来のGraphQLリクエストと同じように書けます。
3.3 デメリットと注意点
- CSRF対策などセキュリティ周りがより複雑: multipartリクエストを通す場合、通常のJSON APIと異なるセキュリティ設定が必要なケースがあります。例えば、CORS設定やCSRFトークンの適切な扱いなどに注意を払わないといけません。
- 巨大ファイルのハンドリング: Apollo ServerやExpressサーバーがファイル本体を受け取ることになるため、負荷が高まりがちです。大容量アップロードだとサーバーがメモリを圧迫するリスクもあります。
- クラウドストレージとの連携: 最終的には何らかのストレージにファイルを置くのが一般的なので、GraphQLサーバーがファイルを一時的に受け取り、再度S3などに転送する手間が増えます。転送失敗の処理、タイムアウト管理など運用上の負荷も大きいです。
4. アプローチの比較
項目 | 署名付きURL方式 | graphql-upload方式 |
---|---|---|
実装の複雑さ | クライアントは2~3段階のステップが必要だが、サーバー実装は比較的単純(URL生成のみ) | ミューテーションにバイナリを含められるが、サーバーでファイル受取処理が必要 |
サーバー負荷 | サーバーはファイルを直接受け取らず、トラフィックはストレージに向かう | サーバーが一旦ファイルを受け取って処理するため、メモリ/CPU負荷が高まる恐れ |
セキュリティ | 署名付きURLの期限/権限管理で安全性を担保しやすい | multipart/form-dataを受け取るためCSRF・CORS設定に注意が必要 |
運用規模 | 大容量ファイルや大量アップロードに向き、負荷分散が容易 | 小~中規模なら利用しやすいが、大容量ファイルには向かない場合あり |
実装方法 | REST的なPUT/POST + GraphQLでメタ情報登録 | GraphQLリクエストでファイルも一緒に送信 (graphql-upload など) |
基本的には、大容量ファイルや負荷を考慮したいプロダクション環境では、「署名付きURL」方式を推奨する場合が多いです。逆に、小規模プロジェクトやファイルが小さいケースなどでは「graphql-upload」でミューテーションにまとめるメリットもあるでしょう。
5. 具体的なサンプルコード
5.1 署名付きURLの例(Apollo Server + AWS S3)
サーバー側(一部のみ、TypeScript仮定):
// resolver.ts
import { S3 } from 'aws-sdk';
const s3 = new S3();
export const resolvers = {
Mutation: {
async getUploadUrl(_: any, { filename }: { filename: string }) {
const presigned = await s3.getSignedUrlPromise('putObject', {
Bucket: 'your-bucket-name',
Key: filename,
Expires: 60, // 有効期限(秒)
ContentType: 'image/png' // 必要に応じて
});
return presigned;
},
async saveFileMetadata(_: any, { filename, url }: { filename: string, url: string }, context) {
// DBにファイル情報を登録するなどの処理
// 例: userテーブルのavatarUrlを更新
// ...
return true; // 成功したら true とか返す
}
}
};
スキーマ:
type Mutation {
"""
ファイルをアップロードするための署名付きURLを取得
"""
getUploadUrl(filename: String!): String!
"""
アップロード完了後に、ファイルメタ情報を登録
"""
saveFileMetadata(filename: String!, url: String!): Boolean!
}
クライアント側(例: React + fetch):
async function handleUpload(file) {
// 1. GraphQLミューテーションでアップロード用URL取得
const { data } = await apolloClient.mutate({
mutation: gql`
mutation ($filename: String!) {
getUploadUrl(filename: $filename)
}
`,
variables: { filename: file.name },
});
const presignedUrl = data.getUploadUrl;
// 2. presignedUrl に対して PUT などでファイルをアップロード
await fetch(presignedUrl, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file,
});
// 3. GraphQLでメタ情報登録
await apolloClient.mutate({
mutation: gql`
mutation ($filename: String!, $url: String!) {
saveFileMetadata(filename: $filename, url: $url)
}
`,
variables: { filename: file.name, url: presignedUrl },
});
}
5.2 graphql-uploadの例
Apollo Server側:
import { ApolloServer } from 'apollo-server-express';
import { GraphQLUpload, graphqlUploadExpress } from 'graphql-upload';
const typeDefs = `
scalar Upload
type FileInfo {
filename: String!
mimetype: String!
encoding: String!
url: String!
}
type Mutation {
uploadFile(file: Upload!): FileInfo!
}
`;
const resolvers = {
Upload: GraphQLUpload,
Mutation: {
uploadFile: async (_: any, { file }: { file: any }) => {
const { createReadStream, filename, mimetype, encoding } = await file;
// readStreamを使ってファイルをクラウドストレージにアップロードする
// 例: tmpフォルダに一旦保存する場合
const stream = createReadStream();
// ... ストレージに保存する処理など
const url = `https://example.com/files/${filename}`;
return { filename, mimetype, encoding, url };
}
}
};
const app = express();
app.use(graphqlUploadExpress({ maxFileSize: 10000000, maxFiles: 1 })); // オプション
const server = new ApolloServer({ typeDefs, resolvers });
server.applyMiddleware({ app });
クライアント側: Apollo Clientで apollo-upload-client
を導入し、file
を Upload
型にマッピングする。
import { createUploadLink } from 'apollo-upload-client';
import { ApolloClient } from '@apollo/client';
const client = new ApolloClient({
link: createUploadLink({ uri: '/graphql' }),
cache: ...
});
// 使い方
const UPLOAD_FILE = gql`
mutation UploadFile($file: Upload!) {
uploadFile(file: $file) {
filename
url
}
}
`;
// 例: input type="file" から取得したFile
async function handleFileChange(file) {
const { data } = await client.mutate({
mutation: UPLOAD_FILE,
variables: { file },
});
console.log('Uploaded:', data.uploadFile);
}
6. セキュリティと注意点
6.1 CORSやCSRFの考慮
- 署名付きURLの方式では、署名URLを発行する先(S3など)に対してCORS設定を適切にしないとブラウザからのPUTが失敗する場合があります。
-
graphql-upload方式の場合でも、
multipart/form-data
が絡むので通常のJSON-based GraphQLと挙動が変わります。CSRFトークンやセキュリティヘッダの扱いを再確認しておきましょう。
6.2 大容量ファイル
- 大きなファイルを直接GraphQLサーバーで受け付けると、サーバーのメモリや処理時間に負担がかかりやすくなります。これが原因でタイムアウトが発生し、結果的にユーザー体験が悪くなる恐れがあります。
- 署名付きURL方式なら、ファイル転送はクラウドストレージが直接引き受けるため、サーバー側の負荷を大きく抑えられます。
6.3 通信の暗号化
- HTTPS経由でアップロードするなら暗号化されますが、大容量ファイルのアップロード中にネットワークが切れた場合の再送などは考慮が必要です。いずれの方式でもアプリケーション層でリトライ処理を行うかどうか検討するとよいでしょう。
7. まとめ
7.1 推奨アプローチの選択
- 実際の本番環境で大容量ファイルを扱うのであれば: 署名付きURLなどによってアップロードを分割するパターンが多く採用されています。サーバー負荷が低く、セキュリティやスケーラビリティ面でも優秀です。
-
比較的小さなファイルを少量だけ送る、小規模プロジェクト:
graphql-upload
を使って簡単にまとめて実装する利点もあります。1つのGraphQLミューテーションで完結する点が魅力です。
7.2 全体的な注意点
- セキュリティ: 署名付きURLの期限管理、CORS設定、CSRF対策など。
- サーバー負荷: GraphQLのリゾルバがファイルを扱うのは想定外なので、必要に応じて工夫を。
- 実装工数: 署名付きURL方式は手順が増えるが、運用が安定しやすい。一方、graphql-upload方式は手軽だが、大規模運用には向きにくい面もある。
7.3 参考リンク
- Apollo Docs: File uploads (Apollo Server)graphql-upload を使った導入方法、注意点がまとめられています。
- AWS Docs: Uploading objects (AWS S3)署名付きURLを発行してS3に直接アップロードする方法。
- graphql-upload GitHub repoライブラリ本体の詳細や設定方法を確認できます。
おわりに
GraphQL自体はファイルアップロードを標準でサポートしていませんが、上記のように**「別途アップロードエンドポイントを用意する」または「graphql-uploadでmultipartを扱う」**などの実装パターンが確立されています。プロジェクトの規模感や要件(ファイルサイズ、アップロード頻度、運用コストなど)に応じて最適な手法を選択するのが良いと思います。
特に本番運用では、サーバーリソースの消費を考慮すると署名付きURL方式がおすすめされることが多いです。一方で、小さいファイルをサクッとやり取りしたいだけなら、graphql-upload
による「すべてGraphQLリクエストで完結」パターンもメリットがあります。
「GraphQL = 単一エンドポイントですべてカバー」 という考え方に固執せず、柔軟に使い分けることが大事!