【Golang + echo】S3のファイルをバイナリデータでレスポンスする

はじめに

この記事はGo 4 Advent Calendar 2020 17日目の記事です。

こんにちは、株式会社カミナシのエンジニア @ImuKnskです。

早速ですが、S3にあるファイルをバイナリデータで取得してレスポンスに含めたいと思ったことはありませんか?

『カミナシ』ではバッチ処理でファイルを作成してS3にファイルをアップロードした後、Client側でファイルをダウンロードする機能があります。

よくある処理はClient側からAWSにアクセスをしてファイルをダウンロードする方法ですが、今回はAPI側を経由してファイルをダウンロードする方法について書きたいと思います!

ファイルをバイナリデータとしてレスポンスに含めたいがうまくいかず、困っている方の参考になれば幸いです!

この記事で分かること

  • API側でS3ファイルをダウンロードせずに取得する方法
  • 取得したファイルをレスポンスに含める方法(Golang + echo)
  • Client側で自動ダウンロードさせる方法(React Native)

開発環境

  • go (1.14.2)
  • echo (v3.3.10+incompatible)
  • aws-sdk-go (v1.34.4)
API側でS3ファイルをダウンロードせずに取得する方法
package main

import (
    "fmt"

    "github.com/aws/aws-sdk-go/aws"
    "github.com/aws/aws-sdk-go/aws/awserr"
    "github.com/aws/aws-sdk-go/aws/session"
    "github.com/aws/aws-sdk-go/service/s3"
    "github.com/labstack/gommon/log"
)

func main() {
    key := "xxx_key"
    bucket := "xxx_bucket"
    region := "xxx_region"

    // sessionの作成
    config := &aws.Config{
        Region: aws.String(region),
    }
    session := s3.New(session.Must(session.NewSession(config)))

    // S3からファイルをダウンロードせずに読み込む
    obj, err := session.GetObject(&s3.GetObjectInput{
        Bucket: aws.String(bucket),
        Key:    aws.String(key),
    })
    if err != nil {
        if aerr, ok := err.(awserr.Error); ok {
            switch aerr.Code() {
            case s3.ErrCodeNoSuchBucket:
                err := fmt.Sprintf("bucket %s does not exist", bucket)
                log.Error(err)
            case s3.ErrCodeNoSuchKey:
                err := fmt.Sprintf("object with key %s does not exist in bucket %s", key, bucket)
                log.Error(err)
            default:
                log.Error(aerr)
            }
        }
    }
    defer obj.Body.Close()
}

S3のセッションを作成した後に、GetObjectを使ってファイルをダウンロードせずにオブジェクトとして扱いましょう。 GetObjectに関しては公式サイトを参照ください。

docs.aws.amazon.com

取得したファイルをレスポンスに含める方法(Golang + echo)
package main

import (
    "io"

    "github.com/labstack/echo"
)

func setResponse(c echo.Context, body io.Reader) {
    // レスポンスヘッダ
    response := c.Response()
    response.Header().Set("Cache-Control", "no-store")
    response.Header().Set(echo.HeaderContentType, echo.MIMEOctetStream)
    response.Header().Set(echo.HeaderAccessControlExposeHeaders, "Content-Disposition")
    response.Header().Set(echo.HeaderContentDisposition, "attachment; filename="+"ファイル名+拡張子")
    // ステータスコード
    response.WriteHeader(200)
    // レスポンスのライターに対して、バイナリデータをコピーする
    io.Copy(response.Writer, body)
}

echo.ContextのResponseに対して必要なヘッダ情報、ステータスコードを設定、最後にWriterに対してデータをコピーする流れになります。

ここでハマったのが、Responseの組み立てるところです。 ステータスコード、ヘッダ、データコピーの順番が違うと意図した結果にならず困っていました。

レスポンスの中身をAPI側とClient側でデバッグしたりしてみましたが、何が原因なのか分からず こちらを 参考にして解決できました。どうやらResponseを組み立てる順番があり、順番通りにしないと正常に動作しないようでした。

API側のまとめ
package main

import (
    "errors"
    "fmt"
    "io"
    "net/http"
    "net/url"

    "github.com/aws/aws-sdk-go/aws"
    "github.com/aws/aws-sdk-go/aws/awserr"
    "github.com/aws/aws-sdk-go/aws/session"
    "github.com/aws/aws-sdk-go/service/s3"
    "github.com/labstack/echo"
    echomw "github.com/labstack/echo/middleware"
    "gopkg.in/go-playground/validator.v9"
)

type S3KeyRequest struct {
    Key string `json:"key"`
}

func main() {
    router := initRouter()
    router.Logger.Fatal(router.Start(":8080"))
}

func initRouter() *echo.Echo {
    e := echo.New()
    e.Use(echomw.CORSWithConfig(echomw.CORSConfig{
        AllowOrigins: []string{"*"},
        AllowHeaders: []string{echo.HeaderContentType},
        AllowMethods: []string{echo.POST},
    }))
    e.POST("/sample", getS3FileResponseBinary)
    return e
}

func getS3FileResponseBinary(c echo.Context) error {
    var req S3KeyRequest
    if err := c.Bind(&req); err != nil {
        return err
    }

    v := validator.New()
    if err := v.Struct(req); err != nil {
        return err
    }

    obj, err := getS3ObjectOutput(req.Key)
    if err != nil {
        return err
    }
    defer obj.Body.Close()

    setResponse(c, obj.Body)

    return c.NoContent(http.StatusOK)
}

func getS3ObjectOutput(key string) (*s3.GetObjectOutput, error) {
    bucket := "xxx_bucket"
    region := "xxx_region"

    // sessionの作成
    config := &aws.Config{
        Region: aws.String(region),
    }
    session := s3.New(session.Must(session.NewSession(config)))

    // S3からファイルをダウンロードせずに読み込む
    obj, err := session.GetObject(&s3.GetObjectInput{
        Bucket: aws.String(bucket),
        Key:    aws.String(key),
    })
    if err != nil {
        if aerr, ok := err.(awserr.Error); ok {
            switch aerr.Code() {
            case s3.ErrCodeNoSuchBucket:
                err := fmt.Sprintf("bucket %s does not exist", bucket)
                return nil, errors.New(err)
            case s3.ErrCodeNoSuchKey:
                err := fmt.Sprintf("object with key %s does not exist in bucket %s", key, bucket)
                return nil, errors.New(err)
            default:
                return nil, aerr
            }
        }
    }
    return obj, nil
}

func setResponse(c echo.Context, body io.Reader) {
    // ファイル名:この名前でファイルダウンロードされます
    encodeName := url.QueryEscape("ファイル名.xlsx")
    // レスポンスヘッダ
    response := c.Response()
    response.Header().Set("Cache-Control", "no-store")
    response.Header().Set(echo.HeaderContentType, echo.MIMEOctetStream)
    response.Header().Set(echo.HeaderAccessControlExposeHeaders, "Content-Disposition")
    response.Header().Set(echo.HeaderContentDisposition, "attachment; filename="+encodeName)
    // ステータスコード
    response.WriteHeader(200)
    // レスポンスのライターに対して、バイナリデータをコピーする
    io.Copy(response.Writer, body)
}
Client側から自動ダウンロードしてみましょう!
import React from 'react'
import { View, Button } from 'react-native'

const downloadFile = async (body: string) => {
  const options = {
    method: 'POST',
    body: body,
    headers: {
      'Content-Type': 'application/json'
    }
  }

  let fileName = ''

  return fetch(`http://localhost:8080/sample`, options)
    .then(res => {
      // content-dispositionからファイル名を取得します
      // ここはAPI側で設定したファイル名をそのまま利用するイメージです
      fileName = getFileNameFromContentDisposition(res.headers.get('content-disposition') ?? '') ?? ''
      if (fileName !== '') return res.blob()
    })
    .then(blob => {
      const anchor = document.createElement('a')
      anchor.download = fileName
      anchor.href = window.URL.createObjectURL(blob)
      anchor.click()
    })
    .catch(e => {
      throw Error(e)
    })
}

const getFileNameFromContentDisposition = (disposition: string) => {
  const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/
  const matches = filenameRegex.exec(disposition)
  if (matches != null && matches[1]) {
    const fileName = matches[1].replace(/['"]/g, '')
    return decodeURI(fileName)
  } else {
    return undefined
  }
}

const App: React.FC = () => {
  return (
    <View>
      <Button
        title="ファイルダウンロード"
        onPress={() => {
          const key = JSON.stringify({ key: 'xxx_key' })
          downloadFile(key)
        }}
      ></Button>
    </View>
  )
}

export default App

Client、APIを繋いでファイルダウンロードした結果はこちらです。 gyazo.com

おわりに

いかがだったでしょうか。今回紹介した機能自体はよく記事にされていると思いますが、一部だけ紹介されているケースがほとんどかなと思います。私も一部だけ紹介されている記事を参考にしながら実装をしていましたが、いざClientとAPIを繋いだときに意図しない結果になり、困ることが多かったです。 そのため全体を通してまとめてみましたので、困っていた方に少しでも参考になれば幸いです!

最後に…

弊社ではエンジニアを募集しており、興味がある、話を聞いてみたい、応募したいという方はWantedlyTwitterのDMからお待ちしております!