一休.com Developers Blog

一休のエンジニア、デザイナー、ディレクターが情報を発信していきます

GraphQL + Go による画像投稿機能の実装談・・・Exif 情報の削除、AWS S3 での画像管理、ユーザー体験の模索など

こんにちは。宿泊プロダクト開発部 UI開発チーム エンジニアの香西です。

半年ほど前に、一休.comとヤフートラベルで、クチコミ画像の投稿機能をリリースしました。
一休.comとヤフートラベルでは、ユーザーに画像をアップロードしてもらう機能の実装は前例が無かったため、試行錯誤しながらの開発となりました。
今回はその時の開発についてお話したいと思います。

背景

クチコミを投稿する機能自体は、以前から存在していました。
実際に宿泊したユーザーだけがクチコミを投稿できるため、信憑性の高いクチコミではあるものの、投稿できるのは文字情報のみでした。近年、あらゆるサービスにおいてクチコミの重要性が高まってきているため、視覚情報を増やしてクチコミの質をあげるべく、画像を投稿できるようにしよう!ということで、クチコミ画像の投稿機能を開発することになりました。

全体像

画像の保管場所には、Amazon S3 を使用することにしました。
以前から一休.comとヤフートラベルで使用しており、imgIX と連携する仕組みがすでに整っていたため、サイト上で扱いやすいというのが一番の理由です。
もう一つの理由は、「外部からアクセスできる保管場所」「外部からアクセスできない保管場所」をそれぞれ用意したかったためです。
ユーザーが投稿した画像をそのままサイト上に公開するのではなく、社内で掲載チェック(不適切な画像を取り除く)をしてからサイト上に公開したいという要件がありました。つまり、掲載チェックが済んでいない画像は、「外部からアクセスできない場所」に置いておく必要があります。 Amazon S3 で「公開バケット」「非公開バケット」を用意し、それぞれ適切なアクセス設定を行うことで、今回の要件が実現できることがわかったので、Amazon S3 を使用することにしました。

ただし、掲載チェックが済んでいない「非公開バケット」にある画像であっても、

  • 投稿者のユーザーに対してのみ表示したい(自分がどんな画像を投稿したか確認できるようにするため)
  • 掲載チェックを行うために社内の管理画面上には表示したい

という要件が出てきました。この点については、後述する「署名付きURL」の仕組みを使って、「非公開バケット」にある画像を特定の画面上でのみ表示できるようにしました。

フロントエンドの実装

GraphQL のリクエスト送信

Apollo でファイルをアップロードする方法はいくつかありますが、multipart リクエストを使用して mutation を実行し画像をアップロードする方法を採用しました。

uploadFile(file: File): void {
  const input: ReviewImageInput = {
    id: 12345678,
    file: null,
  }
  const formData = new FormData()
  formData.append(
    'operations',
    `{ "query": "mutation($input: ReviewImageInput!) { registerReviewImage(input: $input) { id error __typename }}",
        "variables": { "input": ${JSON.stringify(input)} } }`,
  )
  formData.append('map', '{ "0": ["variables.input.file"] }')
  formData.append('0', file)

// 以下略

どのタイミングで画像をアップロードするか

クチコミ投稿を行うときの画面の構成は、以下の三画面です。1~3の順に遷移します。

  1. クチコミ入力画面(ここで投稿する画像を選択する)
  2. クチコミ入力確認画面(選択した画像を確認する)
  3. クチコミ投稿完了画面

どのタイミングで、画像アップロードのリクエストを送信するのがよいでしょうか。

クチコミ入力画面で、画像を選択するたびにリクエストを送信する?
もしくは、クチコミ確認画面で「投稿する」ボタンを押したときに、全画像まとめてリクエストを送信する?

全画像まとめてアップロード処理を行った場合、処理が完了するまでユーザーを待たせることになり、画像枚数が多いと煩わしさを感じるかもしれません。 また、アップロードに失敗したときに最初から画像を選択し直すとなるとユーザーのモチベーションが下がってしまうので、どの画像が失敗したのかユーザーに伝えつつ成功した画像のみ復元して...といったケアをしようとすると、処理がどんどん複雑化していきそうです。

開発メンバーで検討した結果、ユーザーが画像を選択したタイミングで、1枚ずつ画像アップロードのリクエストを送信することにしました。 それがユーザーにとって最もスムーズな体験であり、かつ実装上もシンプルだという結論に至りました。

アップロード進捗状況を表示したい

各画像のアップロード処理がどのくらい進んでいるのか?が視覚的に分かると、ユーザーにとって安心感があると思います。
しかし、fetch API / Apollo client ではアップロードの進捗を確認する機能がサポートされていなかったため、XMLHttpRequest の upload プロパティで進捗を監視し、プログレスバーでアップロードの進捗を表示するようにしました。

fetch(
  url: string,
  opts: any,
  onProgress: (ev: ProgressEvent<EventTarget>) => void,
): Promise<string> {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest()
    xhr.open(opts.method || 'get', url)
    xhr.timeout = 60000
    for (const k in opts.headers || {})
      xhr.setRequestHeader(k, opts.headers[k])
    xhr.onload = () => resolve(xhr.response)
    xhr.onerror = (e) => reject(e)
    xhr.ontimeout = (e) => reject(e)
    if (xhr.upload) xhr.upload.onprogress = onProgress
    xhr.send(opts.body)
  })
},

バックエンドの実装

フロントエンドから画像アップロードのリクエストが飛んできたとき、大まかに以下の4つの処理を行っています。

  • 画像のバリデーション
  • 画像のデコード・エンコード
  • S3 非公開画像用バケットに画像をアップロード
  • データベースに画像情報を登録

画像のバリデーション

画像の条件については、OWASP のチートシートや他サービスなどを参考にしながら以下の仕様に決めました。

チェック項目 制限
1画像ファイルサイズ上限 10MBまで
画像ファイル種類(MIMEタイプ) image/jpeg, image/png
画像ファイルの縦横比 4:1まで許容
画像ファイルの最小幅 80px

ユーザーから送信される content-type ヘッダーは偽装される可能性があるため信頼せず、画像のバイナリデータの先頭 512byte を見てファイル種類(MIMEタイプ)の判定を行うようにしました。
Go の http パッケージの DetectContentType を使用しています。

head := make([]byte, 512)
n, err := r.Read(head)
if err != nil && !errors.Is(err, io.EOF) {
  return nil, err
}

contentType := http.DetectContentType(head[:n])
if contentType != "image/jpeg" && contentType != "image/png" {
  return nil, ErrRegisterInvalidType
}

画像のデコード・エンコード

セキュリティ観点から、Amazon S3 に画像をアップロードする前に、画像のバイナリデータに含まれている Exif 情報(位置情報・撮影日時など)を削除する必要があります。
Exif 情報には画像の向き(Orientation)が含まれているため、この情報は削除したくありません。

Go の imaging パッケージを使用してデコードを行うと、Exif 情報が取り除かれた Image が返ってきます。 また、引数に imaging.AutoOrientation(true) のオプションを渡すと画像の向き(Orientation)を自動で適用してくれます。

img, err := imaging.Decode(r, imaging.AutoOrientation(true))
if err != nil {
  return nil, err
}

デコードで Exif 情報を取り除いた Image を、今度はエンコードし、Amazon S3 にアップロードする画像データを用意します。
JPEG は、引数に imaging.JPEGQuality(75) のオプションを渡して品質を指定することができます。 デフォルト値 95 のままエンコードすると、画像によってはファイル容量が2倍程度大きくなるケースが見受けられたため 75 を指定することにしました。

import (
  "bytes"
    "github.com/disintegration/imaging"
)

func (i *Image) Encode() (*bytes.Reader, error) {
    b := new(bytes.Buffer)

    if i.ContentType == "image/jpeg" {
        err := imaging.Encode(b, i.Image, imaging.JPEG, imaging.JPEGQuality(75))
        if err != nil {
            return nil, err
        }
    } else {
        err := imaging.Encode(b, i.Image, imaging.PNG, imaging.PNGCompressionLevel(png.DefaultCompression))
        if err != nil {
            return nil, err
        }
    }

    return bytes.NewReader(b.Bytes()), nil
}

(余談)JPEG のエンコードでメモリを大量に使用してハマった

画像のデコード・エンコードでは imaging パッケージを使用したとお話しましたが、開発当初は Go 標準 の image パッケージを使用して、デコード・エンコードを行い、Exif 情報を削除しようとしていました。
ところが、いざ処理を実行してみるとすごく重かったのです。

testing パッケージでベンチマークを測定したところ、メモリを大量に使用していることが判明しました。

デバッグしながら調査していくと、image パッケージの jpeg.Encode の処理が怪しそうだという事がわかってきました。
さらに深堀してみると、画像データに書き出している処理 writeSOS のなかで、rgba ycbcr がどちらも nil になっていたため、 toYCbCr の処理に入っていました。

// writeSOS writes the StartOfScan marker.
func (e *encoder) writeSOS(m image.Image) {

  // 中略

    default:
        rgba, _ := m.(*image.RGBA)    // nil になっていた
        ycbcr, _ := m.(*image.YCbCr)  // nil になっていた
        for y := bounds.Min.Y; y < bounds.Max.Y; y += 16 {
            for x := bounds.Min.X; x < bounds.Max.X; x += 16 {
                for i := 0; i < 4; i++ {
                    xOff := (i & 1) * 8
                    yOff := (i & 2) * 4
                    p := image.Pt(x+xOff, y+yOff)
                    if rgba != nil {
                        rgbaToYCbCr(rgba, p, &b, &cb[i], &cr[i])
                    } else if ycbcr != nil {
                        yCbCrToYCbCr(ycbcr, p, &b, &cb[i], &cr[i])
                    } else {
                        toYCbCr(m, p, &b, &cb[i], &cr[i])  // ここの処理に入っていた
                    }
                    prevDCY = e.writeBlock(&b, 0, prevDCY)
                }
                scale(&b, &cb)
                prevDCCb = e.writeBlock(&b, 1, prevDCCb)
                scale(&b, &cr)
                prevDCCr = e.writeBlock(&b, 1, prevDCCr)
            }
        }
 
  // 以下略

toYCbCr の処理なかを見ていくと、Image.At を使って各ピクセルの色情報(RGBA)を取得していました。ここでメモリを大量に使用していました。

// toYCbCr converts the 8x8 region of m whose top-left corner is p to its
// YCbCr values.
func toYCbCr(m image.Image, p image.Point, yBlock, cbBlock, crBlock *block) {
    b := m.Bounds()
    xmax := b.Max.X - 1
    ymax := b.Max.Y - 1
    for j := 0; j < 8; j++ {
        for i := 0; i < 8; i++ {
            r, g, b, _ := m.At(min(p.X+i, xmax), min(p.Y+j, ymax)).RGBA()  // ここで Image.At
            yy, cb, cr := color.RGBToYCbCr(uint8(r>>8), uint8(g>>8), uint8(b>>8))
            yBlock[8*j+i] = int32(yy)
            cbBlock[8*j+i] = int32(cb)
            crBlock[8*j+i] = int32(cr)
        }
    }
}

こちらの issue でも言及されておりこちらで修正されていましたが、ycbcr が nil ではない場合に yCbCrToYCbCr の処理に入るようになっているため、そもそも ycbcr が nil になってしまうと、toYCbCr の処理のほうに入って Image.At によってメモリが大量に使われてしまう、ということが起きていました。

ちなみに、JPEG のデータがどうなっているかを理解する際、 Ange Albertini さんの作ったイメージに助けてもらったので貼っておきます。

引用: https://github.com/corkami/pics/blob/master/binary/JPG.png

さてどうしようかと頭を悩ませていましたが、社内メンバーに助言をもらい imaging パッケージを使ってデコード・エンコードしてみたところ、メモリの使用量が抑えられたのでした。
さらには Orientation も自動設定してくれるので、自力で Orientation を設定するコードも不要になりました。

imaging パッケージの Encode でも、内部では image パッケージの jpeg.Encode を使っていますが、事前に rgba を作成し、jpeg.Encodergba を渡していました。

// Encode writes the image img to w in the specified format (JPEG, PNG, GIF, TIFF or BMP).
func Encode(w io.Writer, img image.Image, format Format, opts ...EncodeOption) error {
    cfg := defaultEncodeConfig
    for _, option := range opts {
        option(&cfg)
    }

    switch format {
    case JPEG:
        if nrgba, ok := img.(*image.NRGBA); ok && nrgba.Opaque() {
            rgba := &image.RGBA{
                Pix:    nrgba.Pix,
                Stride: nrgba.Stride,
                Rect:   nrgba.Rect,
            }
            return jpeg.Encode(w, rgba, &jpeg.Options{Quality: cfg.jpegQuality})  // jpeg.Encode に rgba を渡していた
        }
        return jpeg.Encode(w, img, &jpeg.Options{Quality: cfg.jpegQuality})

  // 以下略

そうすることで、例の writeSOS の処理のなかで rgba が nil にならず、rgbaToYCbCr の処理のほうへ入るようになりました。
rgbaToYCbCr の処理のなかではすでに色情報(RGBA)が分かっているため Image.At を実行する必要もなく、大量にメモリを使うことなくエンコードが出来ていました。

// writeSOS writes the StartOfScan marker.
func (e *encoder) writeSOS(m image.Image) {

  // 中略

                for i := 0; i < 4; i++ {
                    xOff := (i & 1) * 8
                    yOff := (i & 2) * 4
                    p := image.Pt(x+xOff, y+yOff)
                    if rgba != nil {
                        rgbaToYCbCr(rgba, p, &b, &cb[i], &cr[i])  // こちらの処理に入るようになった
                    } else if ycbcr != nil {
                        yCbCrToYCbCr(ycbcr, p, &b, &cb[i], &cr[i])
                    } else {
                        toYCbCr(m, p, &b, &cb[i], &cr[i])  // もともとは、ここの処理に入っていた
                    }
                    prevDCY = e.writeBlock(&b, 0, prevDCY)
                }

  // 以下略

ベンチマーク計測

imaging パッケージを使った場合・image パッケージのみを使った場合でベンチマークを比較してみると、その差は明らかです。
imaging パッケージを使ったほうが、処理速度・メモリ割当領域・メモリアロケーション回数が小さく高パフォーマンスであることが分かります。

$ go test -bench . -benchmem
goos: windows
goarch: amd64
cpu: Intel(R) Core(TM) i7-10700 CPU @ 2.90GHz

BenchmarkImagingPkg-16     3     435976333 ns/op      71321248 B/op          122 allocs/op     -- imaging パッケージ使用
BenchmarkImagePkg-16       2     614217700 ns/op     120224332 B/op     12193780 allocs/op     -- image パッケージ使用

以下がベンチマーク測定のために用意したコードです。
※確認用のため処理を簡易化し、エラーハンドリングはしていません。

image パッケージを使用していたときは、わざわざ Orientation を設定する関数 setOrientation を書いて、画像データが持つ Orientation の値を見て Image を回転させる、ということをやっていました。

import (
    "bytes"
    "image"
    "image/jpeg"
    "os"
    "testing"

    "github.com/disintegration/imaging"
    "github.com/rwcarlsen/goexif/exif"
)

// imaging パッケージ使用
func BenchmarkImagingPkg(t *testing.B) {
    for i := 0; i < t.N; i++ {
        file, _ := os.Open("C://dev/test-exif-orientation-2842.jpg")
        defer file.Close()

        // デコード
        img, _ := imaging.Decode(file, imaging.AutoOrientation(true))

        // エンコード
        b := new(bytes.Buffer)
        _ = imaging.Encode(b, img, imaging.JPEG, imaging.JPEGQuality(75))
    }
}

// image パッケージ使用
func BenchmarkImagePkg(t *testing.B) {
    for i := 0; i < t.N; i++ {
        file, _ := os.Open("C://dev/test-exif-orientation-2842.jpg")
        defer file.Close()

        // デコード
        img, _, _ := image.Decode(file)

        _, _ = file.Seek(0, 0)

        // 画像の Exif 情報から Orientation を取得し、デコードした Image に Orientation を設定する
        ex, _ := exif.Decode(file)
        tag, _ := ex.Get(exif.Orientation)
        orientation, _ := tag.Int(0)
        newImg, _ := setOrientation(img, orientation)

        // エンコード
        b := new(bytes.Buffer)
        _ = jpeg.Encode(b, newImg, nil)
    }
}

func setOrientation(img image.Image, orientation int) (image.Image, error) {
    var newImg image.Image
    // @see: https://www.jeita.or.jp/japanese/standard/book/CP-3451E_J/#target/page_no=34
    switch orientation {
    case 1:
        newImg = img
    case 2:
        newImg = imaging.FlipH(img)
    case 3:
        newImg = imaging.Rotate180(img)
    case 4:
        newImg = imaging.FlipV(img)
    case 5:
        newImg = imaging.Rotate90(img)
        newImg = imaging.FlipH(newImg)
    case 6:
        newImg = imaging.Rotate90(img)
    case 7:
        newImg = imaging.Rotate270(img)
        newImg = imaging.FlipH(newImg)
    case 8:
        newImg = imaging.Rotate270(img)
    default:
        return nil, errors.New("invalid value: " + strconv.Itoa(orientation))
    }
    return newImg, nil
}

Amazon S3 バケットに画像をアップロード

「非公開バケット」に画像をアップロードするときは、AWS SDK for Go の PutObjectWithContext を使用しています。

import (
  "context"
    "github.com/aws/aws-sdk-go/aws"
    "github.com/aws/aws-sdk-go/service/s3"
)

func (c *client) Put(ctx context.Context, resource string, input PutInput) error {
    in := &s3.PutObjectInput{
        Bucket:        aws.String(input.Target.Bucket),
        Key:           aws.String(input.Target.Key),
        Body:          input.Body,
        ContentType:   aws.String(input.ContentType),
        ContentLength: aws.Int64(input.ContentLength),
    }
    ctx = httptrace.WithSpan(ctx, c.service, resource, map[string]any{
        "http.content_length": input.ContentLength,
        "http.content_type":   input.ContentType,
    })
    _, err := c.s3.PutObjectWithContext(ctx, in)
    return err
}

S3 署名付きURLを使用

掲載チェックが済んでいない「非公開バケット」にある画像であっても、

  • 投稿者のユーザーに対してのみ表示したい(自分がどんな画像を投稿したか確認できるようにするため)
  • 掲載チェックするために社内の管理画面上には表示したい

という話を冒頭でしました。

これを実現するために「署名付きURL」の仕組みを使用することにしました。
外部からアクセスできないように制御している画像に対して、署名付きURLを発行することができます。署名付きURLを <img> タグの src に指定し、特定の画面上に画像を表示しています。
なお、署名付きURLの有効時間は、自由に指定することができます。

import (
    "github.com/aws/aws-sdk-go/aws"
    "github.com/aws/aws-sdk-go/service/s3"
)

func (c *client) Presign(bucket string, key string) (string, error) {
    req, _ := c.s3.GetObjectRequest(&s3.GetObjectInput{
        Bucket: aws.String(bucket),
        Key:    aws.String(key),
    })
    url, err := req.Presign(5 * time.Minute)
    if err != nil {
        return "", err
    }
    return url, nil
}

使いやすいユーザーインターフェースを求めて

フロントエンド、バックエンド、両方の実装がだいたい完了して動作する状態になったらすぐにデモ環境にデプロイし、プロジェクトメンバーに触り心地を確認してもらうようにしました。UI開発チームでは、他のプロジェクトにおいても、なるべく早い段階でデモ環境にデプロイしてみんなで触ってみる、ということを大切にしています。

そこで出てきたフィードバックをもとに修正し、再びユーザー体験を確認し...を繰り返して改善していきます。今回、サービス初の画像アップロード機能ということで、実際に触ってみるとさまざまな問題が出てきましたが、デザイナーと密に連携しながらユーザーインターフェースを詰めていきました。

▽ Slack 上のフィードバックのやりとり

最後に

クチコミ画像の投稿機能の他にも、社内での掲載チェック機能や、非公開画像の削除機能など、関連機能がいろいろあるのですが、今回は画像の投稿機能に焦点をあててお話してみました。

UI開発チームでは、ユーザー体験に関わる部分はフロントエンド・バックエンドに関わらず開発できるため、全体像を把握しながら実装することができます。
一休では、ともに良いサービスをつくっていく仲間を積極募集中です。応募前にカジュアルに面談をすることも可能ですので、お気軽にご連絡ください。