🖼️

Go×ReactでGCSに画像をアップロードする

2023/11/27に公開

この記事ではGo×Reactを使って、GCSに画像データをアップロードする処理の実装方法をまとめています。

背景としては、個人開発で『自分の描いた絵をアップロードし、保存共有ができるWebサイト』の開発を行っており、そこでGCSに画像をアップロードする処理が必要だったからです。

いろいろ調べながら実装して学べることが多かったのでアウトプットとして記事を書こうと思いました!🐶

使用技術について

今回使用する技術は以下のものになります。

  • Golang(v1.21.2)
  • React(v18.2.15)
  • TypeScript(v5.0.2)
  • PostgreSQL(v14.9)
  • GCS(Google Cloud Storage)
  • axios

前提条件

GCSにすでにbucketを作成しているものとします。

bucketってなんや?

1 : 🐶 にーな
「GCSのバケットっていうのは、Google Cloud Storageの中でデータを保存するための場所だよ。」


2 : 🦊 もんた
「バケットってどんなものなの?」


3 : 🐶 にーな
「バケットは、まるで大きなバケツみたいなものだよ。色々なデータやファイルを入れておくことができるんだ。」


4 : 🦊 もんた
「どんなデータを入れるの?」


5 : 🐶 にーな
「写真や動画、ドキュメントなど、あらゆる種類のファイルを保存できるよ。」


6 : 🦊 もんた
「バケットはどうやって作るの?」


7 : 🐶 にーな
「Google Cloudのコンソールから簡単に作成できるよ。名前を決めて、いくつかの設定をするだけ。」


8 : 🦊 もんた
「バケットには名前が必要なの?」


9 : 🐶 にーな
「そうだよ。バケットにはユニークな名前をつける必要がある。これがバケットのアドレスみたいなものだね。」


10 : 🦊 もんた
「バケットに入れたデータはどうやって使うの?」


11 : 🐶 にーな
「バケットに保存したデータは、インターネット経由でいつでもアクセスできるよ。ウェブサイトやアプリから直接使うこともできるんだ。」


12 : 🦊 もんた
「それは便利だね!」


13 : 🐶 にーな
「とてもね。GCSのバケットは、大量のデータを安全に保存するのにとても役立つんだよ。」

作ったやつ

作ったやつはこんな感じです。

GCSにアップロードする画像を選択し、『Upload』をクリックすると、GCSに画像データをアップロード、PostgresにはGCSにアップロードされた画像のURLを保存しています。
画像投稿フォーム

Postgresから画像のURLを取得し、imgタグのsrcにそのURLを渡せば、以下のように画像が表示されます。
追加した画像はリストに表示される

これから画像データをアップロードする処理の実装方法を説明していきます!

Reactで画像データをGoに送信する処理の実装

はじめにフロント側で画像データをGoに送信する処理について説明します。

フロントエンドで画像データをGoに送信するための必要最低限の処理は以下のとおりです。

import React, { useState } from "react";
import axios from "axios";

const TestImageUpload: React.FC = () => {
  const [file, setFile] = useState<File | null>(null);

  const onFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const files = event.target.files;
    if (files && files.length > 0) {
      const selectedFile = files[0];
      setFile(selectedFile);
    } else {
      setFile(null);
    }
  };

  const uploadImage = async () => {
    if (!file) {
      console.error("No file selected");
      return;
    }
    const formData = new FormData();
    formData.append("file", file);

    try {
      const response = await axios.post(
        "{GO_API_ENDPOINT}",
        formData,
        {
          headers: {
            "Content-Type": "multipart/form-data",
          },
        }
      );
      console.log(response.data);
    } catch (error) {
      console.error("Upload failed:", error);
    }
  };

  return (
    <form
      onSubmit={(e) => {
        uploadImage();
        e.preventDefault();
      }}
    >
      <input type="file" accept="image/*" onChange={(e) => onFileChange(e)} />
      <button type="submit">upload image</button>
    </form>
  );
};

export default TestImageUpload;

画像データを格納する変数の定義

これから細かく説明していきます。

  const [file, setFile] = useState<File | null>(null);

画像ファイル格納する変数fileを、useStateをつかって定義します。

ReactのuseState()について

1: 🐶 にーな
「もんたくん、ReactのuseState()について話そうか。これはReactコンポーネントで状態を管理するためのフックだよ。」


2: 🦊 もんた
「状態を管理するって、どういうこと?」


3: 🐶 にーな
「状態ってのは、コンポーネントのデータのことだよ。例えば、フォームの入力値やボタンがクリックされたかどうかなど、コンポーネントの動作に影響するデータのこと。」


4: 🦊 もんた
「なるほどね。じゃあ、useState()はそのデータをどう扱うの?」


5: 🐶 にーな
「useState()を使うと、コンポーネント内で状態(データ)を作成できるんだ。そして、その状態を更新する関数も一緒に提供されるよ。」


6: 🦊 もんた
「それはどんな時に便利なの?」


7: 🐶 にーな
「例えば、ユーザーの入力を追跡するフォームがあるとしよう。ユーザーが入力するたびに、その入力値を状態として保持しておきたいよね。useState()を使えば、その入力値を簡単に管理できるんだ。」


8: 🦊 もんた
「へぇ、それでユーザーの入力に応じて画面が更新されるのか!」


9: 🐶 にーな
「その通り!useState()で作成された状態が変更されると、Reactはそのコンポーネントを再レンダリングするんだ。これによって、ユーザーの操作に応じてUIが動的に更新される。」


10: 🦊 もんた
「なるほど、だからリアルタイムでUIが変わるんだね。useState()って本当に便利だね!」


11: 🐶 にーな
「そうだね。useState()はReactで動的なUIを作るための基本的なツールの一つだよ。これを使いこなせば、もっと複雑なUIも作れるようになるよ!」

画像データに変更があったときに走る関数

次に画像データに変更があったときに走る関数を定義します。

 const onFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const files = event.target.files; // eventオブジェクトのプロパティであるfilesから画像データを取得
    if (files && files.length > 0) { // 画像データがあるかどうか
      const selectedFile = files[0]; // アップロードする画像は1枚だけなので、配列の先頭だけ取得
      setFile(selectedFile); // fileの状態を更新
    } else {
      setFile(null); // 画像が選択されていなければnull
    }
  };
<input type="file" accept="image/*" onChange={(e) => onFileChange(e)} /> // inputの値が変化すると、onFileChangeが走る

この関数はinputタグのvalueが変化した時に走る関数になっています。
ここでinputタグで選択した画像をfile変数に格納しています。

画像データをGoに送信する

ここで、buttonをクリックした時の処理について説明します。

buttonがsubmitされると、formより以下の関数が走ります。

  const uploadImage = async () => {
    if (!file) { // file変数がnullかどうかチェック
      console.error("No file selected");
      return;
    }
    const formData = new FormData(); // FormDataオブジェクトの作成
    formData.append("file", file); // サーバーサイドに送るデータをオブジェクトに追加

    try {
      const response = await axios.post(
        "{GO_API_ENDPOINT}", // サーバーサイドのAPIエンドポイント
        formData, // サーバーサイドに送信するオブジェクト
        {
          headers: {
            "Content-Type": "multipart/form-data", // headerオプション
          },
        }
      );
      console.log(response.data);
    } catch (error) {
      console.error("Upload failed:", error);
    }
  };

ここで重要になってくるのが、headerオプションです。
headerオプションでは、リクエストのヘッダーにzContent-Typeを設定しています。

なぜ "Content-Type: multipart/form-data" を設定する必要があるか

1: 🐶 にーな
「もんたくん、画像データをサーバーに送る時、なぜ "Content-Type: multipart/form-data" を設定する必要があるか知ってる?」


2: 🦊 もんた
「うーん、なんとなく使ってるけど、正確な理由はよくわからないなぁ。」


3: 🐶 にーな
「まず、"Content-Type" ヘッダーは、送信するデータの種類をサーバーに伝えるんだよ。」


4: 🦊 もんた
「データの種類って、どういうこと?」


5: 🐶 にーな
「例えば、テキストだけの場合は "text/plain"、フォームデータの場合は "application/x-www-form-urlencoded" とかね。でも、ファイルを含む複雑なデータを送る時は "multipart/form-data" を使うんだ。」


6: 🦊 もんた
「なるほど、ファイルを送る時は "multipart/form-data" が必要なんだね。でも、なぜそれが必要なの?」


7: 🐶 にーな
「"multipart/form-data" は、ファイルやテキストなど、異なる種類のデータを一つのリクエストで送れるようにするための形式なんだ。これによって、サーバーは各部分を正しく識別して処理できるようになるよ。」


8: 🦊 もんた
「へぇ、じゃあそれを指定しないとどうなるの?」


9: 🐶 にーな
「指定しないと、サーバーはリクエストボディの形式を正しく認識できなくなるよ。特にファイルの場合、適切に処理されない可能性が高いんだ。」


10: 🦊 もんた
「なるほどね!だから、画像をアップロードする時は、ちゃんと "Content-Type: multipart/form-data" を設定する必要があるんだね。」


11: 🐶 にーな
「その通り!これでサーバーがスムーズにデータを受け取って処理できるようになるんだよ。」

フロントから画像データを受け取り、それをGCSにアップロードする処理

続いてサーバーサイドでの処理です。

サーバーサイドでの処理は以下のようになっています。

ハンドラー関数の内容
func (server *Server) UploadImage(ctx *gin.Context) {
	file, err := ctx.FormFile("file")
	if err != nil {
		ctx.JSON(http.StatusInternalServerError, errorResponse(err))
		return
	}

	imgUrlPath, err := server.UploadToGCS(ctx, file)
	if err != nil {
		ctx.JSON(http.StatusInternalServerError, errorResponse(err))
		return
	}

	argImage := db.CreateImageParams{
		Title:  "test",
		Src:    imgUrlPath,
		TypeID: int64(1),
	}
	image, err := server.store.CreateImage(ctx, argImage)
	if err != nil {
		ctx.JSON(http.StatusInternalServerError, errorResponse(err))
		return
	}

	ctx.JSON(http.StatusOK, gin.H{
		"message": "image create successfully",
		"image":   image,
	})
}
UploadToGCS()の内容
func (server *Server) UploadToGCS(ctx *gin.Context, file *multipart.FileHeader) (string, error) {
	client, err := createGCSClient(ctx, server)
	if err != nil {
		return "", fmt.Errorf("cannot create client : %w", err)
	}

	bucket := client.Bucket(MY_BUCKET_NAME)
	currentTime := time.Now()
	gcsFileName := fmt.Sprintf("%s.png", currentTime.Format("20060102150405"))

	src, err := file.Open()
	if err != nil {
		return "", fmt.Errorf("error opening file : %w", err)
	}
	defer src.Close()
	obj := bucket.Object(gcsFileName)

	wc := obj.NewWriter(ctx)
	if _, err = io.Copy(wc, src); err != nil {
		return "", fmt.Errorf("error writing file : %w", err)
	}
	if err = wc.Close(); err != nil {
		return "", fmt.Errorf("error closing file : %w", err)
	}

	resImagePath := fmt.Sprintf("https://storage.googleapis.com/%s/%s", server.config.BucketName, gcsFileName)
	return resImagePath, nil
}
createGCSClient()の処理内容
func createGCSClient(ctx *gin.Context, server *Server) (*storage.Client, error) {
	credentialFilePath := "./path/to/your/credential_json_file"
	client, err := storage.NewClient(ctx, option.WithCredentialsFile(credentialFilePath))
	if err != nil {
		return nil, fmt.Errorf("failed to create GCSClient : %w", err)
	}

	defer client.Close()

	return client, err
}

詳しく説明していきます。

フロントエンドから受け取った画像データを変数に格納

はじめに、コンテキストよりフロント側でで定義したfileのデータを取得しています。

file, err := ctx.FormFile("file")
fmt.Println(err)
if err != nil {
	ctx.JSON(http.StatusInternalServerError, errorResponse(err))
	return
}
const formData = new FormData();
formData.append("file", file);

(説明不要かもしれませんが、)フロントエンドでfileというkey,valueのセットを追加しています。
サーバーサイドではfile, err := ctx.FormFile("file")によってfileというkeyのvalueを取得しています。

UploadToGCS関数について

この記事の最も重要なGCSに画像データを送信する処理を行う関数について説明します。

この関数では、ctx, fileを引数として受け取っています。
ctxはgoのフレームワークであるginのコンテキストを、fileはフロントより受け取った画像のデータになります。

imgUrlPath, err := server.TestUploadToGCS(ctx, file)

GCSのアップロードには以下の情報が必要になります。

  • 認証情報(credential.jsonに含まれる)
  • GCSのバケット情報

GCSクライアントを作成する処理

GCSのクライアント情報は、以下の関数を使って作成しています。

func createGCSClient(ctx *gin.Context, server *Server) (*storage.Client, error) {
	credentialFilePath := "./path/to/your/credential_json_file" // credential.jsonのパス
	client, err := storage.NewClient(ctx, option.WithCredentialsFile(credentialFilePath)) // GCSサービスにアクセスするためのクライアントを作成
	if err != nil {
		return nil, fmt.Errorf("failed to create client : %w", err)
	}
	defer client.Close()
	return client, err
}
NewClient()について

1 : 🐶 にーな
「このコードは、Google Cloud Storage(GCS)にアクセスするためのクライアントを作成するんだよ。」


2 : 🦊 もんた
「クライアントって何?」


3 : 🐶 にーな
「クライアントは、GCSのサービスと通信するためのツールみたいなものだよ。このコードで、GCSとやり取りするための接続を作っているんだ。」


4 : 🦊 もんた
「この storage.NewClient 部分はどういう意味?」


5 : 🐶 にーな
「それは新しいGCSクライアントを作成する関数だよ。ctx は操作のコンテキストを表していて、option.WithCredentialsFile(credentialFilePath) で認証情報を指定しているんだ。」


6 : 🦊 もんた
「認証情報って何?」


7 : 🐶 にーな
「それはGCSにアクセスするために必要な情報だよ。サービスアカウントのキーとかが含まれていて、これがないとGCSと安全に通信できないんだ。」


8 : 🦊 もんた
「エラーチェックの部分は?」


9 : 🐶 にーな
「クライアント作成時に何か問題があれば、このエラーチェックで捕捉して、エラーを報告するんだ。例えば、認証情報が間違っていたりするとね。」


10 : 🦊 もんた
defer client.Close() って何してるの?」


11 : 🐶 にーな
「これは関数が終了するときにクライアントを閉じるための命令だよ。これによって、使い終わったリソースをきちんと解放して、メモリリークを防ぐんだ。」


12 : 🦊 もんた
「なるほど、これでGCSとの通信を安全に管理できるんだね!」


13 : 🐶 にーな
「その通り!これでGCSにファイルをアップロードしたり、他の操作を行う準備が整ったんだよ。」

なぜcredential.jsonが必要か?

1 : 🐶 にーな
「GCSにファイルをアップロードする時、なぜcredential.jsonが必要か知ってる?」


2 : 🦊 もんた
「うーん、それは何のために使うの?」


3 : 🐶 にーな
credential.jsonは、Google Cloudのサービスにアクセスするための認証情報を含んでいるファイルだよ。」


4 : 🦊 もんた
「認証情報って、どういうこと?」


5 : 🐶 にーな
「Google Cloudのサービスを安全に使うためには、正しいユーザーやアプリケーションがアクセスしていることを確認する必要があるんだ。そのために、認証情報が使われるんだよ。」


6 : 🦊 もんた
「それはどうやって確認するの?」


7 : 🐶 にーな
credential.jsonには、APIを使うためのキーと秘密情報が含まれているんだ。このファイルを使って、Google Cloudに自分が認証されたユーザーであることを証明するんだよ。」


8 : 🦊 もんた
「なるほど、だからセキュリティのために必要なわけだね。」


9 : 🐶 にーな
「その通り!セキュリティアクセス管理が非常に重要なんだ。認証情報がないと、不正なアクセスを防ぐことができないし、誰でもGCSにアクセスできてしまうからね。」


10 : 🦊 もんた
「じゃあ、credential.jsonはどうやって手に入れるの?」


11 : 🐶 にーな
「Google Cloud Platformのコンソールでプロジェクトを作成し、認証情報を生成するんだ。それをダウンロードして、アプリケーションから参照できるようにするんだよ。」


12 : 🦊 もんた
「なるほど、セキュリティを保ちながらGCSを使うためには、credential.jsonが必要なわけだね。」


13 : 🐶 にーな
「正解!これにより、GCSへのアクセスが安全に管理されるんだ。」

GCSに画像をアップロードする

createGCSClient関数によって、GCSサービスにアクセスするための準備は整いました!

ようやく、GCSに画像データをアップロードする処理を実装することができます。

現在、GCSアップロードに必要な情報は以下のとおりです。

  • 認証情報(credential.jsonに含まれる)
  • GCSのバケット情報

認証情報は完了しているので、残りはGCSバケット情報のみになります。

bucket := client.Bucket(MY_BUCKET_NAME) // クライアントからバケット情報を取得(引数には"my_bucket_name"みたいに文字列が入ります)
currentTime := time.Now()
gcsFileName := fmt.Sprintf("%s.png", currentTime.Format("20060102150405")) // ここでは現在の時間を画像の名前にしている(例:2023/11/27 09:00 -> 202311270900.png)

src, err := file.Open() // fileのオープン
if err != nil {
	return "", fmt.Errorf("error opening file : %w", err)
}
defer src.Close() // fileのクローズ
obj := bucket.Object(gcsFileName) // 先ほど作成したgcsFileNameへの参照を作成している。この操作を行うことで、指定したファイルに対する様々な操作を行うことができる。
何でファイルをオープンしたりクローズする必要があるか?

1 : 🐶 にーな
「このGoのコードを見て、ファイルをオープンしてクローズする理由について話そう。」


2 : 🦊 もんた
「ファイルをオープンするのは、読み書きするためだよね?」


3 : 🐶 にーな
「その通り!ファイルをオープンすることで、そのファイルに対して読み書き操作ができるようになるんだ。」


4 : 🦊 もんた
「じゃあ、なぜファイルをクローズする必要があるの?」


5 : 🐶 にーな
「ファイルをクローズすることは非常に重要だよ。ファイルを開いたままにしておくと、システムリソースが無駄に消費されるからね。」


6 : 🦊 もんた
「システムリソースって何?」


7 : 🐶 にーな
「システムリソースには、メモリやファイルディスクリプタなどが含まれるよ。これらは限られているから、不要になったらすぐに解放する必要があるんだ。」


8 : 🦊 もんた
「ファイルをクローズしないとどうなるの?」


9 : 🐶 にーな
「ファイルをクローズしないと、ファイルディスクリプタの枯渇やメモリリークの原因になるよ。それに、ファイルの内容が正しく保存されないこともあるんだ。」


10 : 🦊 もんた
「それは困るね。でも、このコードではdeferを使ってファイルをクローズしているけど、それはどういう意味?」


11 : 🐶 にーな
deferを使うと、関数が終了する時に自動的にその行が実行されるんだ。つまり、ファイルを確実にクローズするために使われるんだよ。」


12 : 🦊 もんた
「なるほど、だからdefer src.Close()と書いてあるのか。」


13 : 🐶 にーな
「正解!これにより、ファイル操作が終わった後にファイルが確実にクローズされることが保証されるんだ。」

これでGCSに画像をアップロードするために必要な情報が揃いました!

  • 認証情報(credential.jsonに含まれる)
  • GCSのバケット情報
wc := obj.NewWriter(ctx) // writerの作成。オブジェクトに対してデータを書き込むために必要。
if _, err = io.Copy(wc, src); err != nil { // 先ほどファイルをオープンして作成したsrcをwriterにコピーする。これによってフロントから送られてきた画像データがGCSにコピーされる。
	return "", fmt.Errorf("error writing file : %w", err)
}
if err = wc.Close(); err != nil { // リソースを解放するためにクローズ
	return "", fmt.Errorf("error closing file : %w", err)
}

resImagePath := fmt.Sprintf("https://storage.googleapis.com/%s/%s", server.config.BucketName, gcsFileName) // 先ほどGCSにコピーした画像のパス
return resImagePath, nil
なんでwriterを作る必要があるか?

1 : 🐶 にーな
「GCSにデータをアップロードする際、wc := obj.NewWriter(ctx)がとても重要なんだ。」


2 : 🦊 もんた
「それはどうしてなの?」


3 : 🐶 にーな
「この行は、GCSの特定のオブジェクトにデータを書き込むための**ライター(Writer)**を作成しているんだ。」


4 : 🦊 もんた
「ライターって何?」


5 : 🐶 にーな
「ライターは、データを書き込むためのツールだよ。この場合、GCSのオブジェクトにデータを書き込むために使われるんだ。」


6 : 🦊 もんた
「なるほど、でもなぜそれが重要なの?」


7 : 🐶 にーな
データのアップロードには、正確で効率的な方法が必要だからね。NewWriterを使うことで、GCSにデータをスムーズかつ安全に書き込むことができるんだ。」


8 : 🦊 もんた
「安全にって、どういう意味?」


9 : 🐶 にーな
「GCSへのデータ転送は、セキュリティやデータ整合性を保ちながら行われる必要があるんだ。NewWriterは、これらの要件を満たすための適切な設定とプロトコルを備えているんだよ。」


10 : 🦊 もんた
「それは便利だね。だからobj.NewWriter(ctx)を使うわけだ。」


11 : 🐶 にーな
「その通り!これにより、アプリケーションはGCSに対して効率的かつ安全にデータをアップロードできるんだ。」

なぜ画像のURLが必要なのか?

1 : 🐶 にーな
「GCSに画像をアップロードした後、その画像のURLがとても重要になるんだよ。」


2 : 🦊 もんた
「どうしてURLがそんなに重要なの?」


3 : 🐶 にーな
「アップロードされた画像のURLは、その画像へのアクセスポイントになるんだ。」


4 : 🦊 もんた
「アクセスポイントって何?」


5 : 🐶 にーな
「それは、インターネット上でその画像を見るためのアドレスだよ。このURLを使って、どこからでもその画像にアクセスできるんだ。」


6 : 🦊 もんた
「なるほど、だからウェブサイトやアプリで画像を表示する時に使うのかな?」


7 : 🐶 にーな
「正解!例えば、ウェブサイトやアプリケーションで画像を表示したい時、そのURLを使って画像を読み込むんだ。」


8 : 🦊 もんた
「でも、どうしてGCSにアップロードするの?」


9 : 🐶 にーな
「GCSは安全で信頼性が高く、大量のデータを保存できるからね。特に大きな画像ファイルや多数の画像を扱う場合に便利だよ。」


10 : 🦊 もんた
「画像のURLを知っていれば、いつでもその画像を見ることができるわけだね。」


11 : 🐶 にーな
「その通り!さらに、そのURLを共有すれば、他の人も同じ画像を簡単に見ることができるんだ。」


12 : 🦊 もんた
「なるほど、だからGCSにアップロードした画像のURLが重要なんだね。」


13 : 🐶 にーな
「そうだよ。アクセスの容易さ共有のしやすさが、URLの重要性を高めているんだ。」

これでGCSに画像をアップロードすることができました!
あとは、GCSにアップロードした画像のパスをImageテーブルに保存するだけです。

フロントエンドではこのURLをimgタグのsrcに渡すことでGCSにアップロードした画像を参照できるようになります!

おわりっ。

お世話になった記事たち

大変お世話になりましたぁ!
https://qiita.com/KokiSakano/items/c16a8daf03acdbd6c911

Discussion