Goでファイルアップロード機能を手早く作る

概要

Rails でよく採用していた fogCarrierWave の組み合わせに近いことができないかと考えていました。
Goを使用する場合でも、画像アップロード機能など、あるあるな機能は効率よく実装したいものです。
そこで、tusd を知ったので、簡単な例を試してみたいと思います。

tusd

tusd はファイルの resumable upload protocol を実現するプロトコルである tus を実装しているそうです。
プロトコルの詳細は公式に記載があります。

tus.io

tus 自体はプロトコルの話なので、言語による制約は受けず、Go以外でも利用可能です。
tusd としては、以下を利用できます。

  1. tusd バイナリ (ファイルアップローダー単体として起動)
  2. tusd を実装する機能

1 は、社内ツールなどで取り敢えずアップロードができれば良い場合など、特定のユースケースで利用する場合に良いですね。
2 での利用がメインだと思います。

Go での tusd 利用

example が用意されているので、試しに動作させてみます。

package main

import (
    "context"
    "fmt"
    "net/http"

    "github.com/tus/tusd/pkg/filestore"
    tusd "github.com/tus/tusd/pkg/handler"
)

const (
    basePath = "/files/"
)

func main() {
    if err := run(context.Background()); err != nil {
        panic(err)
    }
}

func run(ctx context.Context) error {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel()

    store := filestore.FileStore{
        Path: "./uploads",
    }
    composer := tusd.NewStoreComposer()
    store.UseIn(composer)

    handler, err := tusd.NewHandler(tusd.Config{
        BasePath:              basePath,
        StoreComposer:         composer,
        NotifyCompleteUploads: true,
    })
    if err != nil {
        return fmt.Errorf("unable to create handler: %w", err)
    }

    go func() {
        for {
            select {
            case event := <-handler.CompleteUploads:
                fmt.Printf("[event] upload %s finished\n", event.Upload.ID)
            }
        }
    }()

    http.Handle(basePath, http.StripPrefix(basePath, handler))

    if err := http.ListenAndServe(":8080", nil); err != nil {
        return fmt.Errorf("unable to listen: %w", err)
    }

    return nil
}

tusd を使用する場合は、form-data とは異なりクライアント側でも対応が必要になります。
今回は簡易的に試すために、uppy を使用します。
接続先ホストを変更し、ブラウザで表示します。

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Uppy</title>
    <link href="https://releases.transloadit.com/uppy/v1.25.0/uppy.min.css" rel="stylesheet">
  </head>
  <body>
    <div id="drag-drop-area"></div>

    <script src="https://releases.transloadit.com/uppy/v1.25.0/uppy.min.js"></script>
    <script>
      var uppy = Uppy.Core()
        .use(Uppy.Dashboard, {
          inline: true,
          target: '#drag-drop-area'
        })
        .use(Uppy.Tus, {endpoint: 'http://localhost:8080/files/'})

      uppy.on('complete', (result) => {
        console.log('Upload complete! We’ve uploaded these files:', result.successful)
      })
    </script>
  </body>
</html>

tusアップロード前
tusアップロード前

Upload を押すと、tus を実装したサーバーにログが出ます。

$ go run main.go
[tusd] 2021/01/31 17:11:59 event="RequestIncoming" method="OPTIONS" path="5be3a8fbb73293bde6baa61581617555" requestId=""
[tusd] 2021/01/31 17:11:59 event="ResponseOutgoing" status="200" method="OPTIONS" path="5be3a8fbb73293bde6baa61581617555" requestId=""
[tusd] 2021/01/31 17:11:59 event="RequestIncoming" method="HEAD" path="5be3a8fbb73293bde6baa61581617555" requestId=""
[tusd] 2021/01/31 17:11:59 event="ResponseOutgoing" status="404" method="HEAD" path="5be3a8fbb73293bde6baa61581617555" error="upload not found" requestId=""
[tusd] 2021/01/31 17:11:59 event="RequestIncoming" method="OPTIONS" path="" requestId=""
[tusd] 2021/01/31 17:11:59 event="ResponseOutgoing" status="200" method="OPTIONS" path="" requestId=""
[tusd] 2021/01/31 17:11:59 event="RequestIncoming" method="POST" path="" requestId=""
[tusd] 2021/01/31 17:11:59 event="UploadCreated" id="3a9ec02c54b7d4d09ccb0fb492a44440" size="388214" url="http://localhost:8080/files/3a9ec02c54b7d4d09ccb0fb492a44440"
[tusd] 2021/01/31 17:11:59 event="ResponseOutgoing" status="201" method="POST" path="" requestId=""
[tusd] 2021/01/31 17:11:59 event="RequestIncoming" method="OPTIONS" path="3a9ec02c54b7d4d09ccb0fb492a44440" requestId=""
[tusd] 2021/01/31 17:11:59 event="ResponseOutgoing" status="200" method="OPTIONS" path="3a9ec02c54b7d4d09ccb0fb492a44440" requestId=""
[tusd] 2021/01/31 17:11:59 event="RequestIncoming" method="PATCH" path="3a9ec02c54b7d4d09ccb0fb492a44440" requestId=""
[tusd] 2021/01/31 17:11:59 event="ChunkWriteStart" id="3a9ec02c54b7d4d09ccb0fb492a44440" maxSize="388214" offset="0"
[tusd] 2021/01/31 17:11:59 event="ChunkWriteComplete" id="3a9ec02c54b7d4d09ccb0fb492a44440" bytesWritten="388214"
[tusd] 2021/01/31 17:11:59 event="ResponseOutgoing" status="204" method="PATCH" path="3a9ec02c54b7d4d09ccb0fb492a44440" requestId=""
[event] upload 3a9ec02c54b7d4d09ccb0fb492a44440 finished

uploads/ 以下にファイルとメタ情報が保存されていることが分かります。

S3に保存したい場合

保存先をS3に変更したい場合、composer を以下のように変更します。

func run(ctx context.Context) error {
    ...

    s3Cli := s3.New(session.Must(session.NewSession()))
    s3 := s3store.New(bucket, s3Cli)
    composer := tusd.NewStoreComposer()
    s3.UseIn(composer)

    handler, err := tusd.NewHandler(tusd.Config{
        BasePath:              basePath,
        StoreComposer:         composer,
        NotifyCompleteUploads: true,
    })

    ...
}

処理を中断するとどうなるか

tus は resumable upload protocol であると謳っているので、アップロード処理が再開可能であることを確かめてみたいと思います。
アップロード処理中に SIGINT を送信し、サーバーをダウンさせます。
アップロード処理に時間のかかる動画ファイルを使用します。

アップロード処理中にサーバーをダウンさせたときの状態
アップロード処理中にサーバーをダウンさせたときの状態
しばらくすると失敗になる
しばらくすると失敗になる
サーバーをダウンさせたときの状態

Retry を押すことで、途中の状態からアップロードを再開できることが確認できました。

↓サーバーのログ (途中でサーバーをダウンさせている)

$ go run main.go
[tusd] 2021/01/31 17:31:30 event="RequestIncoming" method="OPTIONS" path="3059ba671e9aadbd39dcaaf05ff4519a" requestId=""
[tusd] 2021/01/31 17:31:30 event="ResponseOutgoing" status="200" method="OPTIONS" path="3059ba671e9aadbd39dcaaf05ff4519a" requestId=""
[tusd] 2021/01/31 17:31:30 event="RequestIncoming" method="HEAD" path="3059ba671e9aadbd39dcaaf05ff4519a" requestId=""
[tusd] 2021/01/31 17:31:30 event="ResponseOutgoing" status="404" method="HEAD" path="3059ba671e9aadbd39dcaaf05ff4519a" error="upload not found" requestId=""
[tusd] 2021/01/31 17:31:30 event="RequestIncoming" method="OPTIONS" path="" requestId=""
[tusd] 2021/01/31 17:31:30 event="ResponseOutgoing" status="200" method="OPTIONS" path="" requestId=""
[tusd] 2021/01/31 17:31:30 event="RequestIncoming" method="POST" path="" requestId=""
[tusd] 2021/01/31 17:31:30 event="UploadCreated" id="7d0595e10816348687d72586d3b5c3f2" size="418400795" url="http://localhost:8080/files/7d0595e10816348687d72586d3b5c3f2"
[tusd] 2021/01/31 17:31:30 event="ResponseOutgoing" status="201" method="POST" path="" requestId=""
[tusd] 2021/01/31 17:31:30 event="RequestIncoming" method="OPTIONS" path="7d0595e10816348687d72586d3b5c3f2" requestId=""
[tusd] 2021/01/31 17:31:30 event="ResponseOutgoing" status="200" method="OPTIONS" path="7d0595e10816348687d72586d3b5c3f2" requestId=""
[tusd] 2021/01/31 17:31:30 event="RequestIncoming" method="PATCH" path="7d0595e10816348687d72586d3b5c3f2" requestId=""
[tusd] 2021/01/31 17:31:30 event="ChunkWriteStart" id="7d0595e10816348687d72586d3b5c3f2" maxSize="418400795" offset="0"
^Csignal: interrupt
$ go run main.go
[tusd] 2021/01/31 17:32:11 event="RequestIncoming" method="OPTIONS" path="7d0595e10816348687d72586d3b5c3f2" requestId=""
[tusd] 2021/01/31 17:32:11 event="ResponseOutgoing" status="200" method="OPTIONS" path="7d0595e10816348687d72586d3b5c3f2" requestId=""
[tusd] 2021/01/31 17:32:11 event="RequestIncoming" method="HEAD" path="7d0595e10816348687d72586d3b5c3f2" requestId=""
[tusd] 2021/01/31 17:32:11 event="ResponseOutgoing" status="200" method="HEAD" path="7d0595e10816348687d72586d3b5c3f2" requestId=""
[tusd] 2021/01/31 17:32:11 event="RequestIncoming" method="OPTIONS" path="7d0595e10816348687d72586d3b5c3f2" requestId=""
[tusd] 2021/01/31 17:32:11 event="ResponseOutgoing" status="200" method="OPTIONS" path="7d0595e10816348687d72586d3b5c3f2" requestId=""
[tusd] 2021/01/31 17:32:11 event="RequestIncoming" method="PATCH" path="7d0595e10816348687d72586d3b5c3f2" requestId=""
[tusd] 2021/01/31 17:32:11 event="ChunkWriteStart" id="7d0595e10816348687d72586d3b5c3f2" maxSize="67930651" offset="350470144"
[tusd] 2021/01/31 17:32:11 event="ChunkWriteComplete" id="7d0595e10816348687d72586d3b5c3f2" bytesWritten="67930651"
[tusd] 2021/01/31 17:32:11 event="ResponseOutgoing" status="204" method="PATCH" path="7d0595e10816348687d72586d3b5c3f2" requestId=""
[event] upload 7d0595e10816348687d72586d3b5c3f2 finished

アップロードしたファイルのメタ情報について

tusd のハンドラーには、config として以下の情報を渡すことができます。

  • PreUploadCreateCallback
  • PreFinishResponseCallback

例えば、以下のようにメタ情報を得ることができます。

   handler, err := tusd.NewHandler(tusd.Config{
        BasePath:              basePath,
        StoreComposer:         composer,
        NotifyCompleteUploads: true,
        PreUploadCreateCallback: func(hook tusd.HookEvent) error {
            fmt.Println("[pre upload] ", hook.Upload.MetaData)
            return nil
        },
        PreFinishResponseCallback: func(hook tusd.HookEvent) error {
            fmt.Println("[pre finish] ", hook.Upload.MetaData)
            return nil
        },
    })

サーバーのログ

$ go run main.go
[tusd] 2021/01/31 17:43:38 event="RequestIncoming" method="OPTIONS" path="7d0595e10816348687d72586d3b5c3f2" requestId=""
[tusd] 2021/01/31 17:43:38 event="ResponseOutgoing" status="200" method="OPTIONS" path="7d0595e10816348687d72586d3b5c3f2" requestId=""
[tusd] 2021/01/31 17:43:38 event="RequestIncoming" method="HEAD" path="7d0595e10816348687d72586d3b5c3f2" requestId=""
[tusd] 2021/01/31 17:43:38 event="ResponseOutgoing" status="404" method="HEAD" path="7d0595e10816348687d72586d3b5c3f2" error="upload not found" requestId=""
[tusd] 2021/01/31 17:43:38 event="RequestIncoming" method="OPTIONS" path="" requestId=""
[tusd] 2021/01/31 17:43:38 event="ResponseOutgoing" status="200" method="OPTIONS" path="" requestId=""
[tusd] 2021/01/31 17:43:38 event="RequestIncoming" method="POST" path="" requestId=""
[pre upload]  map[filename:movie.MOV filetype:video/quicktime name:movie.MOV relativePath:null type:video/quicktime]
[tusd] 2021/01/31 17:43:38 event="UploadCreated" id="94cfafbed332e69aca184b503cd346ce" size="418400795" url="http://localhost:8080/files/94cfafbed332e69aca184b503cd346ce"
[tusd] 2021/01/31 17:43:38 event="ResponseOutgoing" status="201" method="POST" path="" requestId=""
[tusd] 2021/01/31 17:43:38 event="RequestIncoming" method="OPTIONS" path="94cfafbed332e69aca184b503cd346ce" requestId=""
[tusd] 2021/01/31 17:43:38 event="ResponseOutgoing" status="200" method="OPTIONS" path="94cfafbed332e69aca184b503cd346ce" requestId=""
[tusd] 2021/01/31 17:43:38 event="RequestIncoming" method="PATCH" path="94cfafbed332e69aca184b503cd346ce" requestId=""
[tusd] 2021/01/31 17:43:38 event="ChunkWriteStart" id="94cfafbed332e69aca184b503cd346ce" maxSize="418400795" offset="0"
[tusd] 2021/01/31 17:43:39 event="ChunkWriteComplete" id="94cfafbed332e69aca184b503cd346ce" bytesWritten="418400795"
[pre finish]  map[filename:movie.MOV filetype:video/quicktime name:movie.MOV relativePath:null type:video/quicktime]
[tusd] 2021/01/31 17:43:39 event="ResponseOutgoing" status="204" method="PATCH" path="94cfafbed332e69aca184b503cd346ce" requestId=""
[event] upload 94cfafbed332e69aca184b503cd346ce finished

filename と filetype を取得できるので、PreUploadPreFinish でDBの情報を更新することで、保存したファイルの管理も実現できそうです。
また、画像のみのアップロードを受け付ける場合は、PreUpload で filetype をチェックすれば良さそうです。

ginなどで利用したい場合

tusd の NewHandlerの実装 を見ると、UnroutedHandler をラップする実装になっていることが分かります。
UnroutedHandler を参照し、使い慣れた web フレームワークで使用することもできそうです。

   ...

    r := gin.Default()
    r.Use(cors.New(cors.Config{
        AllowAllOrigins: true,
        AllowMethods:    []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"},
        AllowHeaders:    []string{"Tus-Resumable", "Upload-Length", "Upload-Offset", "Upload-Metadata", "Upload-Defer-Length", "Upload-Concat", "Content-Type", "Content-Length", "Origin"},
        ExposeHeaders:   []string{"Tus-Version", "Tus-Resumable", "Tus-Max-Size", "Tus-Extension", "Upload-Offset", "Upload-Metadata", "Upload-Defer-Length", "Upload-Concat", "Upload-Length", "Location"},
    }))
    r.HEAD("files/:id", gin.WrapF(handler.HeadFile))
    r.GET("files/:id", gin.WrapF(handler.GetFile))
    r.POST("files/", gin.WrapF(handler.PostFile))
    r.PATCH("files/:id", gin.WrapF(handler.PatchFile))

    if err := r.Run(":8080"); err != nil {
        return fmt.Errorf("unable to listen: %w", err)
    }

    ...

許可するヘッダーには、tus 特有のものが含まれるため、注意が必要です。

まとめ

tusd を使用したファイルアップロードについて手元で試してみました。
Retry までを考慮したファイルアップロード処理の実装は意外と手間だったりするので、こういった手段が選択できるようになると楽ができそうです。
クライアントでも対応が必要なため、ユーザーの画像アップロード処理などで使用するためには swift や kotlin でのクライアント実装が充実しているかどうか確認する必要があります。