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)を使ってみた
- チーム開発参加の記録【2023-06~2023-08】(3) sqlc + jackc/pgx/v5(v5.4.1)からPostgreSQLの複合型の配列を使ってみた
- チーム開発参加の記録【2023-06~2023-08】(4) sqlc + jackc/pgx/v5 からPostgreSQLの複合型の配列を更新してみた
- チーム開発参加の記録【2023-06~2023-08】(5) gocronでスケジュール処理し、定期的にバッチジョブを起動してみた
- チーム開発参加の記録【2023-06~2023-08】(6) PostgreSQLの複合型の配列の更新について、もう少し煮詰める
※ 本記事のソースコードは主に学習・検証目的で書いたものであり、プロダクトにそのまま使用できる品質を目指していません。
本記事で行うこと
- Go+Ginで、画像ファイルをダウンロード/アップロードするAPIを作成します。
- アップロードするAPIは2種類作成します。
- アップロード先はCloud Storageとします。
- アプリのデプロイ先はCloud Runとします。
ソースコード
ソースコード全体は後に載せることにして、まずは各APIを見ていきます。
画像ダウンロードAPI(GET)
- クエリパラメータ
- path:Cloud Storageのpathを指定
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
Postmanを使ってAPIを呼ぶと、以下のように画像が表示されました。
画像アップロードAPI:WebP形式に変換バージョン(POST)
- リクエストボディ
- dir:Cloud Storageの格納先ディレクトリ
- uploadFile:画像ファイル
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使用)。
APIを呼んだら、Cloud Storage側に新たに「うちの雪の日の庭.jpg.webp」が現れました。
ファイルサイズが元のjpgファイルより小さくなっています。
次にpngファイル「開発画面.png」もアップロードしてみます(Postman使用)。
APIを呼んだら、Cloud Storage側に新たに「開発画面.png.webp」が現れました。
画像アップロード(画像形式そのままバージョン) POST API
WebP形式の画像をアップロードするために用意したAPIですが、WebP以外でも使えます。
- リクエストボディ
- dir:Cloud Storageの格納先ディレクトリ
- uploadFile:画像ファイル
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使用)。
APIを呼んだら、Cloud Storage側に新たに「開発画面.png」が現れました。
ソースコード全体
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
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を使用します。
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しました。
アプリをCloud Runにデプロイする
お次はアプリ用のDockerfileを用意します。
Docker Hubに上げた「kanedasmec/golang_opencv:1.20.5_4.7.0」を利用しています。
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にはタグ(バージョン)を設定します。
gcloud builds submit --tag gcr.io/your_project/exercise:your_tag
ビルドに成功したら、Cloud Runにデプロイします。
各パラメータ等は調節した方が良いでしょう。
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を呼んでみました。
POST APIの動作確認は、Postmanを使用すると良いでしょう。