🌊

Goでfirebaseのstorageに画像保存する処理を作った

2024/02/16に公開

はじめに

タイトルの通り、画像をアップロードする処理を作ったので、忘れないように残します。

環境

  • Go: 1.20.4
  • echo: v4.10.2(あんまり関係ないですが一応)
  • firebase: v4.13.0
  • storage: v1.30.1

前提

firebaseのプロジェクトは作成できている前提で進めます。
以下の公式の記事を参考に導入しました。

https://firebase.google.com/docs/admin/migrate-go-v4?hl=ja

https://firebase.google.com/docs/storage/admin/start?hl=ja

また、今回の紹介でgormも登場自体はするのですが、今のアプリケーション内で使ってはいるものの、今回のケースには関係ないので無視してください。

ディレクトリ構成

以下の構成で実装しました。

- sdk
    - firebase
        - storage.go // 画像処理周りのメインロジック
        - firebase.go // firebaseのコネクションなどを管理
- service
    - hoge // 機能名です
        - http
            - route.go
            - handler
                - upload_image.go
        - usecase
            - upload_image.go
- main.go

また、service-account-file.jsonは環境変数として設定しています。
以下環境変数のイメージです

#firebase
FIREBASE_SERVICE_ACCOUNT='{
  "type": "service_account",
  "project_id": "hogehoge",
  "private_key_id": "xxxxxxx",
  "private_key": "-----BEGIN PRIVATE KEY----- .....",
  "client_email": "hoge",
  "client_id": "xxxxx",
  "auth_uri": "xxxx",
  "token_uri": "xxxxxx",
  "auth_provider_x509_cert_url": "xxx",
  "client_x509_cert_url": "xxxx",
  "universe_domain": "xxxx"
}'
FIREBASE_STORAGE_BUCKET=hogehoge-7c0dc.appspot.com

ちなみにFIREBASE_STORAGE_BUCKETにはコンソール画面で確認できるURLを指定しています。
image (1).png

実装について

DIなど、本来こうあるべき、みたいな部分はありますが今回は割愛します。
記載順はできるだけ実装順に寄せています。

firebase/firebase.go

firebaseアプリケーションを初期化します。

package firebase

import (
	"context"
	"fmt"
	"os"

	firebase "firebase.google.com/go/v4"
	"github.com/joho/godotenv"
	"google.golang.org/api/option"
)

func InitFirebaseApp(ctx context.Context) (*firebase.App, error) {
	if err := godotenv.Load(); err != nil {
		fmt.Println("No .env file found")

		return nil, fmt.Errorf("failed to load .env file: %v", err)
	}

	// 環境変数からアカウント情報を読み込み
    serviceAccount := os.Getenv("FIREBASE_SERVICE_ACCOUNT")
	if serviceAccount == "" {
		return nil, fmt.Errorf("FIREBASE_SERVICE_ACCOUNT environment variable is not set")
	}

	opt := option.WithCredentialsJSON([]byte(serviceAccount))
	app, err := firebase.NewApp(ctx, nil, opt)
	if err != nil {
		return nil, fmt.Errorf("error initializing firebase app: %v", err)
	}

	return app, nil
}

firebase/storage.go

storageへの画像アップロードのメイン処理を担います。

package firebase

import (
	"context"
	"fmt"
	"net/http"
	"time"

	cs "cloud.google.com/go/storage"
	firebase "firebase.google.com/go/v4"
	"firebase.google.com/go/v4/storage"
)

// HogeFirebaseStorage はFirebase Storageをラップするアプリケーション独自の構造体
type HogeFirebaseStorage struct {
	Client *storage.Client
}

// NewHogeFirebaseStorage は hogeFirebaseStorage の新しいインスタンスを生成
func NewHogeFirebaseStorage(ctx context.Context, app *firebase.App) (*HogeFirebaseStorage, error) {
	client, err := app.Storage(ctx)
	if err != nil {
		return nil, err
	}

	return &HogeFirebaseStorage{Client: client}, nil
}

// UploadImage は画像データをFirebase Storageにアップロードする
func (tfs *HogeFirebaseStorage) UploadImage(ctx context.Context, bucketName string, imageData []byte, path string) (string, error) {

	// バケットの参照を取得
	bucket, err := tfs.Client.Bucket(bucketName)
	if err != nil {
		return "", fmt.Errorf("failed to get bucket: %v", err)
	}

	// ファイルのContentTypeを推測
	contentType := http.DetectContentType(imageData)

	// ファイルへの書き込み用のWriterを作成
	wc := bucket.Object(path).NewWriter(ctx)
	wc.ContentType = contentType
	wc.CacheControl = "public, max-age=31536000" // 1年間キャッシュする

	// データをStorageにアップロード
	if _, err := wc.Write(imageData); err != nil {
		return "", fmt.Errorf("failed to write image to Firebase Storage: %v", err)
	}

	if err := wc.Close(); err != nil {
		return "", fmt.Errorf("failed to close writer: %v", err)
	}

	// 署名付きURLの生成
	signedURL, err := tfs.generateSignedURL(ctx, bucketName, path, 5) // 5年間有効
	if err != nil {
		return "", err
	}

	return signedURL, nil
}

// generateSignedURL 署名付きURLを生成
func (tfs *HogeFirebaseStorage) generateSignedURL(ctx context.Context, bucketName, objectName string, expiry time.Duration) (string, error) {
	// 署名付きURLのオプションを設定
	opts := &cs.SignedURLOptions{
		Scheme:  cs.SigningSchemeV4,
		Method:  "GET",
		Expires: time.Now().Add(15 * time.Minute), // 有効期限
	}

	// 署名付きURLを生成
	bucket, err := tfs.Client.Bucket(bucketName)
	if err != nil {
		return "", err
	}

	u, err := bucket.SignedURL(objectName, opts)
	if err != nil {
		return "", fmt.Errorf("Bucket(%q).SignedURL: %w", bucket, err)
	}
	fmt.Printf("Generated GET signed URL:\n%s\n", u)

	return u, nil
}

この処理はusecaseから呼び出して使用します。

usecase/upload_image.go

ビジネスロジックの中核です。

package usecase

import (
	"context"
	"errors"
	"fmt"
	"os"
	teFirebase "hoge-api/sdk/firebase"
)

// IUploadImage
type IUploadImage interface {
	UploadImage(ctx context.Context, imageData []byte, imageName string) (string, error)
}

type UploadImageUsecase struct {
	// storage.goで作った構造体を受け取る
	storageClient *teFirebase.TerratFirebaseStorage
}

// NewUploadImageService は UploadImageService の新しいインスタンスを生成
func NewUploadImageUsecase(ctx context.Context) (IUploadImage, error) {
	// firebaseAppの初期化
	firebaseApp, err := teFirebase.InitFirebaseApp(ctx)
	if err != nil {
		panic(err)
	}
 
	// Storageクライアントの初期化
	storageClient, err := teFirebase.NewTerratFirebaseStorage(ctx, firebaseApp)
	if err != nil {
		return nil, fmt.Errorf("failed to initialize storage client: %v", err)
	}

	return &UploadImageUsecase{
		storageClient: storageClient,
	}, nil
}

// UploadImage は画像データを受け取り、それを外部サービスへアップロードし、アップロードされた画像のURLを返却する
func (u *UploadImageUsecase) UploadImage(ctx context.Context, imageData []byte, imageName string) (string, error) {
	if len(imageData) == 0 {
		return "", errors.New("image data is empty")
	}

	path := fmt.Sprintf("images/%s", imageName) // 画像の保存先パスを構築、ディレクトリ中の/images/の中に保存される
	bucketName := os.Getenv("FIREBASE_STORAGE_BUCKET")

	// sdk/firebase/storage.goの保存処理を呼び出す
    url, err := u.storageClient.UploadImage(ctx, bucketName, imageData, path)
	if err != nil {
		return "", fmt.Errorf("failed to upload image to Firebase Storage: %v", err)
	}

	return url, nil
}

この処理をハンドラから呼び出します。

http/handler/upload_image.go

リクエスト&レスポンスの処理を行います。

package handler

import (
	"io"
	"net/http"
	"hoge-api/service/hoge/usecase"

	"github.com/labstack/echo/v4"
)

// IUploadImageHandler .
type IUploadImageHandler interface {
	UploadImage(c echo.Context) error
}

// uploadImageHandler .
type uploadImageHandler struct {
	uu usecase.IUploadImage
}

// NewUploadImageHandler はUploadImageHandlerインスタンスを生成
func NewUploadImageHandler(uu usecase.IUploadImage) IUploadImageHandler {
	return &uploadImageHandler{uu}
}

// HandleUploadImage .
func (h *uploadImageHandler) UploadImage(c echo.Context) error {
	// ファイルをリクエストから取得
    // このサンプルでは、「image」というパラメータで送られてくることを想定
	file, err := c.FormFile("image")
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "Invalid file")
	}

    // ファイルの開閉処理
	src, err := file.Open()
	if err != nil {
		return echo.NewHTTPError(http.StatusInternalServerError, "Could not open file")
	}
	defer src.Close()

	// 画像データを読み込み
	imageData, err := io.ReadAll(src)
	if err != nil {
		return echo.NewHTTPError(http.StatusInternalServerError, "Could not read file")
	}

    // usecaseのロジックを呼び出す
	imageName := file.Filename
	url, err := h.uu.UploadImage(c.Request().Context(), imageData, imageName)
	if err != nil {
		return echo.NewHTTPError(http.StatusInternalServerError, "Error uploading image: "+err.Error())
	}

    // リクエスト元が参照できるURLの文字列が欲しいので、Stringで返却する
	return c.String(http.StatusOK, url)
}

route.go

今回はechoを使っています。
handlerを呼び出せるようにします。

package http

import (
	"context"
	teHandler "hoge-api/service/hoge/http/handler"
	"hoge-api/service/hoge/usecase"

	"github.com/labstack/echo/v4"
	"gorm.io/gorm"
)

func HogeRoutes(
	g *echo.Group, handler IHogeHandler,
	uploadImageHandler teHandler.IUploadImageHandler,
) {

	g.POST("/uploadImage", uploadImageHandler.UploadImage)
}

func InitializeHogeRoutes(e *echo.Echo, db *gorm.DB) {
	uploadImageUsecase, err := usecase.NewUploadImageUsecase(context.Background())
	if err != nil {
		// エラーハンドリング: uploadは外部サービスを前提にしているので、接続できない場合はpanic
		panic("failed to initialize UploadImageUsecase: " + err.Error())
	}

	uploadImageHandler := teHandler.NewUploadImageHandler(uploadImageUsecase)

	hogeGroup := e.Group("/hoge")
	HogeRoutes(hogeGroup, uploadImageHandler)
}

最後にこのroute設定をmain.goで起動するようにします。

main.go

package main

import (
	"hoge-api/db"

	hogeRoutes "terrat-api/service/hoge/http"

	"github.com/labstack/echo/v4"
)

func main() {
	db := db.NewDB()
	e := echo.New()

	hogeRoutes.InitializeHogeRoutes(e, db)

	e.Logger.Fatal(e.Start(":8080"))
}

これでコードは準備OKです

動作確認

本来はWebやアプリなどのクライアントからのリクエストで動かすのですが、
今回はcurlで確認します。

まずはGoのローカルサーバを起動します。

$ go run main.go

   ____    __
  / __/___/ /  ___
 / _// __/ _ \/ _ \
/___/\__/_//_/\___/ v4.10.2
High performance, minimalist Go web framework
https://echo.labstack.com
____________________________________O/_______
                                    O\
⇨ http server started on [::]:8080

8080で起動したことを確認できました。

次はファイルを送ります。
今回はダウンロードディレクトリの中にあるこの画像を送ってみます。
buranko_boy_smile.png

リクエスト前にファイルをチェックします。

test -f /Users/min/Downloads/buranko_boy_smile.png && echo "File exists." || echo "File does not exist."
File exists.

ファイルの存在を確認できたので、POSTでリクエストします。

curl -X POST -F "image=@/Users/sakamichuushi/Downloads/buranko_boy_smile.png" \
     http://localhost:8080/hoge/uploadImage

https://storage.googleapis.com/hoge.hoge.com/images/buranko_boy_smile.png?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=firebase-adminsdk-qlsw6%40xxxxxxxxxxxxxxx%   

ファイルは実際にできたものを少しマスクしています。
実際にレスポンスで返却されたURLを確認してみます。

image (2).png

無事アップロードできています。問題ないと思います。

最後に

今回はキャッシュの時間や署名URLの期限とかをあまり意識してないですが、
ちゃんとした設計にするのであれば、気にしたほうがいいかなと思います。
あとファイル名についても、今回投げられたファイル名をまんま出してますが、
そこも要件で変わったりすると思います。

Discussion