0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GraphQLにおけるファイルアップロード

Posted at

1. なぜGraphQLでファイルアップロードが問題になるのか

GraphQLはもともとJSONベースでのやり取りを想定して設計されています。つまり、クエリやミューテーショはJSON形式でサーバーに送られ、レスポンスもJSONで返ってくるのが標準的な流れです。バイナリファイルをそのままJSONに含めることはできないため、ファイルアップロードを単純に行おうとしてもハードルがあります。

一方で、写真共有アプリやユーザーアバターの設定、ドキュメント管理システムなど、ファイルのアップロードが必要になるケースは多々あります。ここで「GraphQLでどう実装するか?」という課題がしばしば出てきます


2. 一般的なアプローチ:署名付きURLを用いたアップロード

多くのプロジェクトで採用されるシンプルかつ安全な方法は、GraphQLとは別にファイルアップロード用のエンドポイントを用意し、アップロード処理をそちらで完結させるというパターンです。

2.1 具体的な流れ

  1. GraphQLミューテーションで、アップロード用URL(例: S3署名付きURL)を取得する
    • たとえば、getUploadUrl というミューテーションを定義し、ファイル名やファイル種別などを引数として送ります。
    • サーバー側でAWS S3などのストレージサービスにアクセスし、一時的な署名付きURL(Presigned URL)を生成。
    • そのURLをクライアントに返す。
  2. クライアントは受け取ったURLに対して直接ファイルをアップロードする
    • これは通常のHTTP PUTなどで行います。S3などのクラウドストレージに直接バイナリを送信できる。
    • この時点ではGraphQLを通さず、純粋にREST的なHTTP通信を行うイメージです。
  3. 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 を導入し、fileUpload型にマッピングする。

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 = 単一エンドポイントですべてカバー」 という考え方に固執せず、柔軟に使い分けることが大事!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?