チーム開発参加の記録【2023-06～2023-08】(1) Go+Ginで画像をダウンロード／アップロードするAPIを作る

2023年6月から、あるオンラインサロンでチーム開発が始まりまして、学習目的で参加しています（2023年8月に終了します）。
私はチーム03のバックエンド側メンバーに加わりました。
チーム03のバックエンドは、Go+Ginを使用することになりました。
チーム開発に参加しながら、私の学習の軌跡を記事にしていきたいと思います。

# 本シリーズのリンク

- チーム開発参加の記録【2023-06～2023-08】(1) Go+Ginで画像をダウンロード／アップロードするAPIを作る
- [チーム開発参加の記録【2023-06～2023-08】(2) sqlc + jackc/pgx/v5（v5.4.0）を使ってみた](https://qiita.com/kanedaq/items/dcc6b3e50a0b2690033d)
- [チーム開発参加の記録【2023-06～2023-08】(3) sqlc + jackc/pgx/v5（v5.4.1）からPostgreSQLの複合型の配列を使ってみた](https://qiita.com/kanedaq/items/8b47443df2c42a15eaa9)
- [チーム開発参加の記録【2023-06～2023-08】(4) sqlc + jackc/pgx/v5 からPostgreSQLの複合型の配列を更新してみた](https://qiita.com/kanedaq/items/43ff8c6146bbde2f2f0c)
- [チーム開発参加の記録【2023-06～2023-08】(5) gocronでスケジュール処理し、定期的にバッチジョブを起動してみた](https://qiita.com/kanedaq/items/520deffb9b271714a68c)
- [チーム開発参加の記録【2023-06～2023-08】(6) PostgreSQLの複合型の配列の更新について、もう少し煮詰める](https://qiita.com/kanedaq/items/6c86d8dff79a5fa1dda3)

※ 本記事のソースコードは主に学習・検証目的で書いたものであり、プロダクトにそのまま使用できる品質を目指していません。

# 本記事で行うこと

- Go+[Gin](https://gin-gonic.com/ja/)で、画像ファイルをダウンロード／アップロードするAPIを作成します。
- アップロードするAPIは2種類作成します。
    - [GoCV](https://gocv.io/)を使用して[WebP](https://ja.wikipedia.org/wiki/WebP)形式に変換してアップロードするAPI
    - 画像形式を変換せずにそのままアップロードするAPI
- アップロード先はCloud Storageとします。
- アプリのデプロイ先はCloud Runとします。

# ソースコード

ソースコード全体は後に載せることにして、まずは各APIを見ていきます。

## 画像ダウンロードAPI（GET）

- クエリパラメータ
    - path：Cloud Storageのpathを指定

```go

func getImage(c *gin.Context) {
	ctx := context.Background()

	// クエリパラメータ
	path := c.Query("path")

	// Cloud Storageのclientとbucket
	client, err := storage.NewClient(ctx)
	if err != nil {
		c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
		return
	}
	defer client.Close()
	bucket := client.Bucket(bucketName)

	// Cloud Storageから画像read
	obj := bucket.Object(path)
	reader, err := obj.NewReader(ctx)
	if err != nil {
		c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
		return
	}
	defer reader.Close()

	c.DataFromReader(http.StatusOK, reader.Attrs.Size, reader.Attrs.ContentType, reader, nil)
}
```

このAPIの動作確認のために、あらかじめCloud Storageに私が撮影した写真ファイルを用意しました。

- path：2023-06-10/うちの雪の日の庭.jpg

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327312/18d79ef9-3fbf-cd60-06c6-af39174b07c9.png)

[Postman](https://www.postman.com/)を使ってAPIを呼ぶと、以下のように画像が表示されました。

- APIのURL：http://localhost:8080/getImage?path=2023-06-10/うちの雪の日の庭.jpg

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327312/0d514dca-2b6e-15a9-5769-d6c59646d29c.png)

## 画像アップロードAPI：WebP形式に変換バージョン（POST）

- リクエストボディ
    - dir：Cloud Storageの格納先ディレクトリ
    - uploadFile：画像ファイル

```go

type postWebpRequest struct {
	Dir string `form:"dir"`
}

type postWebpResponse struct {
	Message string `form:"message"`
	Path    string `form:"path"`
}

func postWebp(c *gin.Context) {
	ctx := context.Background()

	// リクエストボディ（uploadFile以外）
	var request postWebpRequest
	err := c.Bind(&request)
	if err != nil {
		c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
		return
	}

	// リクエストボディ（uploadFile）
	fi, uploadedFile, err := c.Request.FormFile("uploadFile")
	if err != nil {
		c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
		return
	}
	defer fi.Close()

	// Cloud Storageのclientとbucket
	client, err := storage.NewClient(ctx)
	if err != nil {
		c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
		return
	}
	defer client.Close()
	bucket := client.Bucket(bucketName)

	// ファイルをバイナリ形式で読み込み
	buf := bytes.NewBuffer(nil)
	if _, err := io.Copy(buf, fi); err != nil {
		c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
		return
	}

	// バイナリを画像形式に変換
	img, err := gocv.IMDecode(buf.Bytes(), gocv.IMReadColor)
	if err != nil {
		c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
		return
	}

	// 画像をWebP形式に変換。圧縮率80にしました。
	webp, err := gocv.IMEncodeWithParams(".webp", img, []int{gocv.IMWriteWebpQuality, 80})
	if err != nil {
		c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
		return
	}

	// アップロード先pathの調整（画像ファイル名に拡張子「.webp」を追加）
	dir := request.Dir
	if 0 < len(dir) {
		if dir[0] == '/' {
			dir = dir[1:]
		}
	}
	if 0 < len(dir) {
		if dir[len(dir)-1] == '/' {
			dir = dir[:len(dir)-1]
		}
	}
	var path string
	if 0 < len(dir) {
		path = dir + "/" + uploadedFile.Filename + ".webp"
	} else {
		path = uploadedFile.Filename + ".webp"
	}

	// WebP画像をCloud Storageにwrite
	obj := bucket.Object(path)
	writer := obj.NewWriter(ctx)
	if _, err := writer.Write(webp.GetBytes()); err != nil {
		c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
		return
	}
	if err := writer.Close(); err != nil {
		c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
		return
	}

	c.JSON(http.StatusOK, postWebpResponse{
		Message: "file uploaded successfully",
		Path:    writer.Attrs().Name,
	})
}
```

このAPIを使って、まずjpegファイル「うちの雪の日の庭.jpg」をアップロードしてみます（[Postman](https://www.postman.com/)使用）。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327312/3657230c-2271-e5cd-9fa8-f64d93b0075e.png)

APIを呼んだら、Cloud Storage側に新たに「うちの雪の日の庭.jpg.webp」が現れました。
ファイルサイズが元のjpgファイルより小さくなっています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327312/5fb1f1c3-b115-0b04-04b2-0999541b655d.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327312/df50c443-74a1-54ca-391d-578f2b678c86.png)

次にpngファイル「開発画面.png」もアップロードしてみます（[Postman](https://www.postman.com/)使用）。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327312/70c48de2-5d03-f75c-f139-5c3248d452a9.png)

APIを呼んだら、Cloud Storage側に新たに「開発画面.png.webp」が現れました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327312/07252290-e865-586c-2f89-322cf2ced2e1.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327312/4d87c0f4-42c8-939c-9b57-a5042883a27d.png)

## 画像アップロード（画像形式そのままバージョン） POST API

WebP形式の画像をアップロードするために用意したAPIですが、WebP以外でも使えます。

- リクエストボディ
    - dir：Cloud Storageの格納先ディレクトリ
    - uploadFile：画像ファイル

```go

type postImageRequest struct {
	Dir string `form:"dir"`
}

type postImageResponse struct {
	Message string `form:"message"`
	Path    string `form:"path"`
}

func postImage(c *gin.Context) {
	ctx := context.Background()

	// リクエストボディ（uploadFile以外）
	var request postImageRequest
	err := c.Bind(&request)
	if err != nil {
		c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
		return
	}

	// リクエストボディ（uploadFile）
	fi, uploadedFile, err := c.Request.FormFile("uploadFile")
	if err != nil {
		c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
		return
	}
	defer fi.Close()

	// Cloud Storageのclientとbucket
	client, err := storage.NewClient(ctx)
	if err != nil {
		c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
		return
	}
	defer client.Close()
	bucket := client.Bucket(bucketName)

	// アップロード先pathの調整
	dir := request.Dir
	if 0 < len(dir) {
		if dir[0] == '/' {
			dir = dir[1:]
		}
	}
	if 0 < len(dir) {
		if dir[len(dir)-1] == '/' {
			dir = dir[:len(dir)-1]
		}
	}
	var path string
	if 0 < len(dir) {
		path = dir + "/" + uploadedFile.Filename
	} else {
		path = uploadedFile.Filename
	}

	// 画像をCloud Storageにwrite
	obj := bucket.Object(path)
	writer := obj.NewWriter(ctx)
	if _, err := io.Copy(writer, fi); err != nil {
		c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
		return
	}
	if err := writer.Close(); err != nil {
		c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
		return
	}

	c.JSON(http.StatusOK, postImageResponse{
		Message: "file uploaded successfully",
		Path:    writer.Attrs().Name,
	})
}
```

このAPIを使って、pngファイル「開発画面.png」をアップロードしてみます（[Postman](https://www.postman.com/)使用）。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327312/6fc46a2b-b382-5999-2cc5-344cbc5471fc.png)

APIを呼んだら、Cloud Storage側に新たに「開発画面.png」が現れました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327312/2bd7c00b-4660-b7d4-1053-caa83a4a3609.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327312/a2bb1e20-160e-8ddc-e3b7-b84c38100fc4.png)

## ソースコード全体

### main.go

```main.go

package main

import (
	"bytes"
	"cloud.google.com/go/storage"
	"context"
	"github.com/gin-gonic/gin"
	"gocv.io/x/gocv"
	"io"
	"net/http"
)

type httpError struct {
	Error string `json:"error"`
}

var bucketName string = "your_bucket_0"

func healthCheck(c *gin.Context) {
	c.JSON(http.StatusOK, gin.H{
		"message": "ok",
	})
}

func getImage(c *gin.Context) {
	ctx := context.Background()

	// クエリパラメータ
	path := c.Query("path")

	// Cloud Storageのclientとbucket
	client, err := storage.NewClient(ctx)
	if err != nil {
		c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
		return
	}
	defer client.Close()
	bucket := client.Bucket(bucketName)

	// Cloud Storageから画像read
	obj := bucket.Object(path)
	reader, err := obj.NewReader(ctx)
	if err != nil {
		c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
		return
	}
	defer reader.Close()

	c.DataFromReader(http.StatusOK, reader.Attrs.Size, reader.Attrs.ContentType, reader, nil)
}

type postWebpRequest struct {
	Dir string `form:"dir"`
}

type postWebpResponse struct {
	Message string `form:"message"`
	Path    string `form:"path"`
}

func postWebp(c *gin.Context) {
	ctx := context.Background()

	// リクエストボディ（uploadFile以外）
	var request postWebpRequest
	err := c.Bind(&request)
	if err != nil {
		c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
		return
	}

	// リクエストボディ（uploadFile）
	fi, uploadedFile, err := c.Request.FormFile("uploadFile")
	if err != nil {
		c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
		return
	}
	defer fi.Close()

	// Cloud Storageのclientとbucket
	client, err := storage.NewClient(ctx)
	if err != nil {
		c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
		return
	}
	defer client.Close()
	bucket := client.Bucket(bucketName)

	// ファイルをバイナリ形式で読み込み
	buf := bytes.NewBuffer(nil)
	if _, err := io.Copy(buf, fi); err != nil {
		c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
		return
	}

	// バイナリを画像形式に変換
	img, err := gocv.IMDecode(buf.Bytes(), gocv.IMReadColor)
	if err != nil {
		c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
		return
	}

	// 画像をWebP形式に変換。圧縮率80にしました。
	webp, err := gocv.IMEncodeWithParams(".webp", img, []int{gocv.IMWriteWebpQuality, 80})
	if err != nil {
		c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
		return
	}

	// アップロード先pathの調整（画像ファイル名に拡張子「.webp」を追加）
	dir := request.Dir
	if 0 < len(dir) {
		if dir[0] == '/' {
			dir = dir[1:]
		}
	}
	if 0 < len(dir) {
		if dir[len(dir)-1] == '/' {
			dir = dir[:len(dir)-1]
		}
	}
	var path string
	if 0 < len(dir) {
		path = dir + "/" + uploadedFile.Filename + ".webp"
	} else {
		path = uploadedFile.Filename + ".webp"
	}

	// WebP画像をCloud Storageにwrite
	obj := bucket.Object(path)
	writer := obj.NewWriter(ctx)
	if _, err := writer.Write(webp.GetBytes()); err != nil {
		c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
		return
	}
	if err := writer.Close(); err != nil {
		c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
		return
	}

	c.JSON(http.StatusOK, postWebpResponse{
		Message: "file uploaded successfully",
		Path:    writer.Attrs().Name,
	})
}

type postImageRequest struct {
	Dir string `form:"dir"`
}

type postImageResponse struct {
	Message string `form:"message"`
	Path    string `form:"path"`
}

func postImage(c *gin.Context) {
	ctx := context.Background()

	// リクエストボディ（uploadFile以外）
	var request postImageRequest
	err := c.Bind(&request)
	if err != nil {
		c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
		return
	}

	// リクエストボディ（uploadFile）
	fi, uploadedFile, err := c.Request.FormFile("uploadFile")
	if err != nil {
		c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
		return
	}
	defer fi.Close()

	// Cloud Storageのclientとbucket
	client, err := storage.NewClient(ctx)
	if err != nil {
		c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
		return
	}
	defer client.Close()
	bucket := client.Bucket(bucketName)

	// アップロード先pathの調整
	dir := request.Dir
	if 0 < len(dir) {
		if dir[0] == '/' {
			dir = dir[1:]
		}
	}
	if 0 < len(dir) {
		if dir[len(dir)-1] == '/' {
			dir = dir[:len(dir)-1]
		}
	}
	var path string
	if 0 < len(dir) {
		path = dir + "/" + uploadedFile.Filename
	} else {
		path = uploadedFile.Filename
	}

	// 画像をCloud Storageにwrite
	obj := bucket.Object(path)
	writer := obj.NewWriter(ctx)
	if _, err := io.Copy(writer, fi); err != nil {
		c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
		return
	}
	if err := writer.Close(); err != nil {
		c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
		return
	}

	c.JSON(http.StatusOK, postImageResponse{
		Message: "file uploaded successfully",
		Path:    writer.Attrs().Name,
	})
}

func main() {
	router := gin.Default()
	router.GET("/", healthCheck)
	router.GET("/getImage", getImage)
	router.POST("/postWebp", postWebp)
	router.POST("/postImage", postImage)

	router.Run("0.0.0.0:8080")
}
```

### go.mod

```go.mod

module exercise_image

go 1.20

require (
	cloud.google.com/go/storage v1.30.1
	github.com/gin-gonic/gin v1.9.1
	gocv.io/x/gocv v0.32.1
)

require (
	cloud.google.com/go v0.110.0 // indirect
	cloud.google.com/go/compute v1.18.0 // indirect
	cloud.google.com/go/compute/metadata v0.2.3 // indirect
	cloud.google.com/go/iam v0.12.0 // indirect
	github.com/bytedance/sonic v1.9.1 // indirect
	github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
	github.com/gabriel-vasile/mimetype v1.4.2 // indirect
	github.com/gin-contrib/sse v0.1.0 // indirect
	github.com/go-playground/locales v0.14.1 // indirect
	github.com/go-playground/universal-translator v0.18.1 // indirect
	github.com/go-playground/validator/v10 v10.14.0 // indirect
	github.com/goccy/go-json v0.10.2 // indirect
	github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect
	github.com/golang/protobuf v1.5.2 // indirect
	github.com/google/go-cmp v0.5.9 // indirect
	github.com/google/uuid v1.3.0 // indirect
	github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect
	github.com/googleapis/gax-go/v2 v2.7.1 // indirect
	github.com/json-iterator/go v1.1.12 // indirect
	github.com/klauspost/cpuid/v2 v2.2.4 // indirect
	github.com/leodido/go-urn v1.2.4 // indirect
	github.com/mattn/go-isatty v0.0.19 // indirect
	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
	github.com/modern-go/reflect2 v1.0.2 // indirect
	github.com/pelletier/go-toml/v2 v2.0.8 // indirect
	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
	github.com/ugorji/go/codec v1.2.11 // indirect
	go.opencensus.io v0.24.0 // indirect
	golang.org/x/arch v0.3.0 // indirect
	golang.org/x/crypto v0.9.0 // indirect
	golang.org/x/net v0.10.0 // indirect
	golang.org/x/oauth2 v0.6.0 // indirect
	golang.org/x/sys v0.8.0 // indirect
	golang.org/x/text v0.9.0 // indirect
	golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
	google.golang.org/api v0.114.0 // indirect
	google.golang.org/appengine v1.6.7 // indirect
	google.golang.org/genproto v0.0.0-20230320184635-7606e756e683 // indirect
	google.golang.org/grpc v1.53.0 // indirect
	google.golang.org/protobuf v1.30.0 // indirect
	gopkg.in/yaml.v3 v3.0.1 // indirect
)
```

# Cloud Runにデプロイ

## OpenCVビルド済のコンテナイメージをDocker Hubにpushする

GoCVを使うために、OpenCV 4.7.0をソースコードからビルドする必要がありました。
ビルドに時間がかかるため、ビルドしたらDockerイメージをDocker Hubにpushして、以後使い回すことにしました。
以下のDockerfileを使用します。

```Dockerfile

FROM golang:1.20.5-bullseye

RUN apt-get update && apt-get install -y \
    unzip \
    cmake \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

RUN curl -SL https://github.com/opencv/opencv/archive/4.7.0.zip -o opencv-4.7.0.zip \
    && curl -SL https://github.com/opencv/opencv_contrib/archive/refs/tags/4.7.0.zip -o opencv_contrib-4.7.0.zip \
    && unzip opencv-4.7.0.zip \
    && unzip opencv_contrib-4.7.0.zip -d ./opencv-4.7.0/
RUN cd opencv-4.7.0 \
    && mkdir build \
    && cd build \
    && cmake -DOPENCV_GENERATE_PKGCONFIG=ON -DOPENCV_EXTRA_MODULES_PATH=../opencv_contrib-4.7.0/modules .. \
    && cmake --build . \
    && make \
    && make install \
    && ldconfig
```

Docker Hubの以下の場所にpushしました。

https://hub.docker.com/repository/docker/kanedasmec/golang_opencv/general

## アプリをCloud Runにデプロイする

お次はアプリ用のDockerfileを用意します。
Docker Hubに上げた「kanedasmec/golang_opencv:1.20.5_4.7.0」を利用しています。

```Dockerfile

FROM kanedasmec/golang_opencv:1.20.5_4.7.0 as builder

ENV APP_HOME /app
WORKDIR $APP_HOME
COPY ./ ./
RUN go build main.go

FROM debian:bullseye-slim
LABEL version="0.1"

RUN apt-get update && apt-get install -y \
    lsb-release \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

COPY --from=builder /usr/local/ /usr/local/
COPY --from=builder /app/main /app/

ENV APP_HOME /app
WORKDIR $APP_HOME
RUN ldconfig

EXPOSE 8080
CMD ["./main"]
```

このDockerfileを使って、Cloud Run用にビルドします。
your_projectにはGoogle Cloudのプロジェクト名を入れます。
your_tagにはタグ（バージョン）を設定します。

```sh

gcloud builds submit --tag gcr.io/your_project/exercise:your_tag
```

ビルドに成功したら、Cloud Runにデプロイします。
各パラメータ等は調節した方が良いでしょう。

```sh

gcloud run deploy exercise \
    --image gcr.io/your_project/exercise:your_tag \
    --platform managed \
    --cpu 1 \
    --max-instances 1 \
    --concurrency 1 \
    --memory 1024Mi \
    --timeout 3600 \
    --allow-unauthenticated \
    --region us-central1 \
    --service-account your_identity
```

デプロイ後、ブラウザでGET APIを呼んでみました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327312/18f42a4a-d2b2-6e83-a382-96c797a009af.png)

POST APIの動作確認は、[Postman](https://www.postman.com/)を使用すると良いでしょう。