Goでファイルアップロード機能を手早く作る
概要
Rails でよく採用していた fog と CarrierWave の組み合わせに近いことができないかと考えていました。
Goを使用する場合でも、画像アップロード機能など、あるあるな機能は効率よく実装したいものです。
そこで、tusd を知ったので、簡単な例を試してみたいと思います。
tusd
tusd はファイルの resumable upload protocol を実現するプロトコルである tus を実装しているそうです。
プロトコルの詳細は公式に記載があります。
tus 自体はプロトコルの話なので、言語による制約は受けず、Go以外でも利用可能です。
tusd としては、以下を利用できます。
- tusd バイナリ (ファイルアップローダー単体として起動)
- 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>
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 を取得できるので、PreUpload
と PreFinish
で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 でのクライアント実装が充実しているかどうか確認する必要があります。