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

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

## 環境
- 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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/486966/13b86198-2994-a437-0e8e-1b3ba18982c3.png)


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

### firebase/firebase.go
firebaseアプリケーションを初期化します。
```go
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への画像アップロードのメイン処理を担います。
```go
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
ビジネスロジックの中核です。
```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
リクエスト＆レスポンスの処理を行います。
```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を呼び出せるようにします。
```go
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
```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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/486966/0bd81049-fa80-27e3-cc87-7cbe84c9a4ec.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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/486966/96125d06-d1c7-c931-37dd-ce806aca15b9.png)

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

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