はじめに
タイトルの通り、画像をアップロードする処理を作ったので、忘れないように残します。
環境
- 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を指定しています。
実装について
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で起動したことを確認できました。
次はファイルを送ります。
今回はダウンロードディレクトリの中にあるこの画像を送ってみます。
リクエスト前にファイルをチェックします。
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を確認してみます。
無事アップロードできています。問題ないと思います。
最後に
今回はキャッシュの時間や署名URLの期限とかをあまり意識してないですが、
ちゃんとした設計にするのであれば、気にしたほうがいいかなと思います。
あとファイル名についても、今回投げられたファイル名をまんま出してますが、
そこも要件で変わったりすると思います。