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?

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

Posted at

はじめに

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

環境

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

前提

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

また、今回の紹介で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)
	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/min/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の期限とかをあまり意識してないですが、
ちゃんとした設計にするのであれば、気にしたほうがいいかなと思います。
あとファイル名についても、今回投げられたファイル名をまんま出してますが、
そこも要件で変わったりすると思います。

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?