お題
GraphQLでは、(まあ、要するにmultipartなわけだけど)Upload
という名のscalarでスキーマ定義することでファイルアップロードを実現する。
この機能を使ったお題として、表題の組み合わせでやってみた。
機能
概要
- ローカル環境にある動画ファイル(mp4限定)をVideoタグでプレビュー確認後、Google Cloud Storageにアップロード
- Google Cloud Storage上のファイルを署名付きURLを用いてVideoタグでプレビュー表示
実際の挙動
ローカル上のファイル(選択可能なのは mp4 だけに制御)を選択させる。例えば、「jesse_wood-contrast.mp4
」を選択する。
(まだサーバには未反映の)動画がプレビューできる。
そして、Name欄を埋め、「登録」ボタンを押下すると、
動画が登録され、一覧に表示される。
※ちなみに、動画の秒数はローカル上のファイル選択時にVideoタグの機能でメタ情報から取得
※ID欄はバックエンドでのDB登録時に自動でUUIDを採番
適当にもう2件ほど動画をアップロードしてみた結果はこんな感じ。(一斉に動画プレビューも可能)
動画はGoogle Cloud Storage上にこのようにアップロードされている。
関連記事索引
- 第12回「GraphQLにおけるRelayスタイルによるページング実装再考(Window関数使用版)」
- 第11回「Dataloadersを使ったN+1問題への対応」
- 第10回「GraphQL(gqlgen)エラーハンドリング」
- 第9回「GraphQLにおける認証認可事例(Auth0 RBAC仕立て)」
- 第8回「GraphQL/Nuxt.js(TypeScript/Vuetify/Apollo)/Golang(gqlgen)/Google Cloud Storageの組み合わせで動画ファイルアップロード実装例」
- 第7回「GraphQLにおけるRelayスタイルによるページング実装(後編:フロントエンド)」
- 第6回「GraphQLにおけるRelayスタイルによるページング実装(前編:バックエンド)」
- 第5回「DB接続付きGraphQLサーバ(by Golang)をローカルマシン上でDockerコンテナ起動」
- 第4回「graphql-codegenでフロントエンドをGraphQLスキーマファースト」
- 第3回「go+gqlgenでGraphQLサーバを作る(GORM使ってDB接続)」
- 第2回「NuxtJS(with Apollo)のTypeScript対応」
- 第1回「frontendに「nuxtjs/apollo」、backendに「go+gqlgen」の組み合わせでGraphQLサービスを作る」
開発環境
# OS - Linux(Ubuntu)
$ cat /etc/os-release
NAME="Ubuntu"
VERSION="18.04.4 LTS (Bionic Beaver)"
# フロントエンド
Nuxt.js
$ cat yarn.lock | grep "@nuxt/vue-app"
"@nuxt/vue-app" "2.12.2"
"@nuxt/vue-app@2.12.2":
resolved "https://registry.yarnpkg.com/@nuxt/vue-app/-/vue-app-2.12.2.tgz#cc4b68356996eb71d398a30f3b9c9d15f7d531bc"
パッケージマネージャ - Yarn
$ yarn -v
1.22.4
IDE - WebStorm
WebStorm 2020.1
Build #WS-201.6668.106, built on April 7, 2020
# バックエンド
Golang
$ go version
go version go1.13.9 linux/amd64
IDE - Goland
GoLand 2020.1
Build #GO-201.6668.125, built on April 9, 2020
前提
以下技術の導入手順については特に説明しない。(ググれば、いくらでもわかりやすい資料は出てくるので)
- GraphQL
- Vue.js/Nuxt.js
- TypeScript
- Vuetify.js
- Apollo
- Golang
- gqlgen
- Google Cloud Platform全般
また、上記を前提にするので、Google Cloud Storageにアクセスする上で必要となるサービスアカウントの発行やらクレデンシャルの使い方なども特に説明しない。
さらに、今回、docker-compose を使って PostgreSQL コンテナを立ち上げ、動画情報の永続先としているのだけど、Docker関連のセットアップやらなども特に説明しない。
実践
■全ソース
バックエンド(GraphQLサーバ)
フロントエンド
■個別ソース説明
バックエンド
ディレクトリ構成
$ tree
.
├── README.md
├── go.mod
├── go.sum
├── local
│ ├── docker
│ │ └── db
│ │ └── init
│ │ └── 1_init.sql
│ └── docker-compose.yml
├── schema
│ ├── movie.graphqls
│ └── schema.graphqls
└── src
├── cmd
│ └── main.go
├── gcp
│ └── cloud_storage_client.go
├── gqlgen.yml
├── graph
│ ├── generated
│ │ └── generated.go
│ ├── model
│ │ └── models_gen.go
│ ├── movie.resolvers.go
│ ├── resolver.go
│ └── schema.resolvers.go
├── models
│ ├── boil_main_test.go
│ ├── boil_queries.go
│ ├── boil_queries_test.go
│ ├── boil_suites_test.go
│ ├── boil_table_names.go
│ ├── boil_types.go
│ ├── movie.go
│ ├── movie_test.go
│ ├── psql_main_test.go
│ ├── psql_suites_test.go
│ └── psql_upsert.go
├── sqlboiler.toml
└── util
└── time_util.go
GraphQLスキーマ
# Global Object Identification ... 全データを共通のIDでユニーク化
interface Node {
id: ID!
}
schema {
query: Query
mutation: Mutation
}
type Query {
node(id: ID!): Node
}
type Mutation {
noop(input: NoopInput): NoopPayload
}
input NoopInput {
clientMutationId: String
}
type NoopPayload {
clientMutationId: String
}
type MutationResponse {
id: ID
}
extend type Mutation {
"動画をアップロード"
createMovie(input: MovieInput!): MutationResponse
}
input MovieInput {
"名称"
name: String!
"動画URL"
movieFile: Upload!
"秒数"
scale: Int!
}
"動画アップロード用"
scalar Upload
extend type Query {
movies: [Movie!]!
}
type Movie implements Node {
"UUID"
id: ID!
"名称"
name: String!
"動画URL"
movieUrl: String!
"秒数"
scale: Int!
}
機能として「動画を登録する」ものと「動画(情報)を一覧表示する」ものを用意しているので mutation
と query
を1つずつ用意。
情報としては特にひねりはない。multipartの動画情報受け口として Upload
を型とする要素を定義しているのがポイント。
main関数
DBは SQL Boiler というO/Rマッパーを使用。
Google Cloud Storageにアクセスするのでそのためのクライアント初期化処理も実行。
Webサーバとしての機能は chi というフレームワークを使用。
GraphQLなのでエンドポイントは1つ。ハンドリングは gqlgen というライブラリで行う。
package main
import (
"context"
"database/sql"
"fmt"
"log"
"net/http"
"os"
"time"
"github.com/99designs/gqlgen/graphql/handler/transport"
"github.com/sky0621/fs-mng-backend/src/gcp"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/rs/cors"
"github.com/go-chi/chi"
"github.com/99designs/gqlgen/graphql/handler"
_ "github.com/lib/pq"
"github.com/sky0621/fs-mng-backend/src/graph"
"github.com/sky0621/fs-mng-backend/src/graph/generated"
"github.com/volatiletech/sqlboiler/boil"
)
const defaultPort = "5050"
func main() {
/*
* setup db client
*/
var db *sql.DB
{
var err error
// MEMO: ひとまずローカルのコンテナ相手の接続前提なので、べたに書いておく。
db, err = sql.Open("postgres", "dbname=localdb user=postgres password=localpass sslmode=disable port=19999")
if err != nil {
panic(err)
}
defer func() {
if db != nil {
if err := db.Close(); err != nil {
panic(err)
}
}
}()
boil.DebugMode = true
var loc *time.Location
loc, err = time.LoadLocation("Asia/Tokyo")
if err != nil {
panic(err)
}
boil.SetLocation(loc)
}
/*
* setup GCP client
*/
gcsClient, err := gcp.NewCloudStorageClient(context.Background(), os.Getenv("BUCKET"))
if err != nil {
panic(err)
}
/*
* setup web server
*/
var router *chi.Mux
{
router = chi.NewRouter()
cors := cors.New(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"Link"},
AllowCredentials: true,
MaxAge: 300, // Maximum value not ignored by any of major browsers
})
router.Use(cors.Handler)
srv := handler.NewDefaultServer(generated.NewExecutableSchema(generated.Config{
Resolvers: &graph.Resolver{
DB: db,
GCSClient: gcsClient,
},
}))
var mb int64 = 1 << 20
srv.AddTransport(transport.MultipartForm{
MaxMemory: 128 * mb,
MaxUploadSize: 100 * mb,
})
router.Handle("/", playground.Handler("fs-mng-backend", "/query"))
router.Handle("/query", srv)
}
port := os.Getenv("PORT")
if port == "" {
port = defaultPort
}
log.Printf("connect to http://localhost:%s/ for GraphQL playground", port)
if err := http.ListenAndServe(":"+port, router); err != nil {
fmt.Println(err)
}
}
Google Cloud Storage クライアント
今回は、GCSへのファイルアップロード及び(一時的な)動画参照用の署名付きURL生成機能が必要なので、それぞれをメソッドとして用意。
package gcp
import (
"context"
"io"
"net/http"
"time"
"cloud.google.com/go/storage"
"golang.org/x/oauth2/google"
"golang.org/x/xerrors"
)
type CloudStorageClient interface {
ExecSignedURL(object string, expire time.Time) (url string, err error)
ExecUploadObject(object string, reader io.Reader) error
}
type cloudStorageClient struct {
bucket string
signedURLFunc SignedURLFunc
uploadObjectFunc UploadObjectFunc
}
func NewCloudStorageClient(ctx context.Context, bucket string) (CloudStorageClient, error) {
var credentialJSON []byte
{
credential, err := google.FindDefaultCredentials(ctx, storage.ScopeReadOnly)
if err != nil {
return nil, xerrors.Errorf("failed to google.FindDefaultCredentials: %w", err)
}
if credential == nil || credential.JSON == nil {
return nil, xerrors.New("defaultCredentials is nil")
}
credentialJSON = credential.JSON
}
conf, err := google.JWTConfigFromJSON(credentialJSON, storage.ScopeReadOnly)
if err != nil {
return nil, xerrors.Errorf("failed to google.JWTConfigFromJSON: %w", err)
}
if conf == nil {
return nil, xerrors.New("config is nil")
}
var options = storage.SignedURLOptions{
GoogleAccessID: conf.Email,
PrivateKey: conf.PrivateKey,
Method: http.MethodGet,
}
return &cloudStorageClient{
bucket: bucket,
signedURLFunc: func(bucket, object string, expire time.Time) (string, error) {
options.Expires = expire
url, err := storage.SignedURL(bucket, object, &options)
if err != nil {
return "", xerrors.Errorf("failed to storage.SignedURL(bucket:%s, object:%s): %w", bucket, object, err)
}
return url, nil
},
uploadObjectFunc: func(bucket, object string, reader io.Reader) error {
client, err := storage.NewClient(ctx)
if err != nil {
return xerrors.Errorf("failed to storage.NewClient: %w", err)
}
wc := client.Bucket(bucket).Object(object).NewWriter(ctx)
wc.ObjectAttrs.ContentType = "application/octet-stream"
wc.ObjectAttrs.CacheControl = "no-cache"
if _, err = io.Copy(wc, reader); err != nil {
return xerrors.Errorf("failed to storage.NewClient.Bucket(%s).Object(%s).NewWriter: %w", bucket, object, err)
}
defer func() {
if wc == nil {
return
}
err = wc.Close()
}()
return nil
},
}, nil
}
func (c *cloudStorageClient) ExecSignedURL(object string, expire time.Time) (string, error) {
if c == nil || object == "" {
return "", xerrors.Errorf("does not meet the preconditions: [object:%s]", object)
}
return c.signedURLFunc(c.bucket, object, expire)
}
func (c *cloudStorageClient) ExecUploadObject(object string, reader io.Reader) error {
if c == nil || object == "" || reader == nil {
return xerrors.Errorf("does not meet the preconditions: [object:%s]", object)
}
return c.uploadObjectFunc(c.bucket, object, reader)
}
type SignedURLFunc func(bucket, object string, expire time.Time) (url string, err error)
type UploadObjectFunc func(bucket, object string, reader io.Reader) error
リゾルバー
GraphQLリクエストを処理する部分。
package graph
import (
"database/sql"
"github.com/sky0621/fs-mng-backend/src/gcp"
)
// This file will not be regenerated automatically.
//
// It serves as dependency injection for your app, add any dependencies you require here.
type Resolver struct {
DB *sql.DB
GCSClient gcp.CloudStorageClient
}
package graph
// This file will be automatically regenerated based on the schema, any resolver implementations
// will be copied through when generating and any unknown code will be moved to the end.
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/sky0621/fs-mng-backend/src/graph/model"
. "github.com/sky0621/fs-mng-backend/src/models"
"github.com/sky0621/fs-mng-backend/src/util"
"github.com/volatiletech/sqlboiler/boil"
"golang.org/x/xerrors"
)
func (r *mutationResolver) CreateMovie(ctx context.Context, input model.MovieInput) (*model.MutationResponse, error) {
// トランザクションを貼る
tx, err := r.DB.BeginTx(ctx, nil)
if err != nil {
return nil, xerrors.Errorf("failed to BeginTx: %w", err)
}
defer func() {
if tx != nil {
// コミット発行されてなければ必ずロールバックされる
if err := tx.Rollback(); err != nil {
fmt.Println(err)
}
}
}()
// DB登録
m := Movie{
ID: uuid.New().String(),
Name: input.Name,
Filename: input.MovieFile.Filename,
Scale: input.Scale,
}
if err := m.Insert(ctx, r.DB, boil.Infer()); err != nil {
// トランザクションロールバックされる
return nil, xerrors.Errorf("failed to Insert: %w", err)
}
// ファイルをCloud Storageにアップ
if err := r.GCSClient.ExecUploadObject(input.MovieFile.Filename, input.MovieFile.File); err != nil {
// トランザクションロールバックされる
return nil, xerrors.Errorf("failed to GCSClient.ExecUploadObject: %w", err)
}
if err := tx.Commit(); err != nil {
// トランザクションロールバックされる
return nil, xerrors.Errorf("failed to Commit: %w", err)
}
return &model.MutationResponse{
ID: &m.ID,
}, nil
}
func (r *queryResolver) Movies(ctx context.Context) ([]*model.Movie, error) {
records, err := Movies().All(ctx, r.DB)
if err != nil {
return nil, xerrors.Errorf("failed to Movies ALL: %w", err)
}
var results []*model.Movie
for _, record := range records {
url, err := r.GCSClient.ExecSignedURL(record.Filename, util.GetExpire(30*time.Second))
if err != nil {
return nil, xerrors.Errorf("failed to GCSClient.ExecSignedURL: %w", err)
}
results = append(results, &model.Movie{
ID: record.ID,
Name: record.Name,
MovieURL: url,
Scale: record.Scale,
})
}
return results, nil
}
ファイルアップロードの方は、一応、トランザクション貼って、DB登録を先に行い、その後のGCSへの動画アップロードがエラーになったらロールバックが効くようにしている。
動画一覧表示の方は、いったんDBから動画情報を取得した後、ファイル名を元にGCS上のファイルへの署名付きURLを1ファイルずつ生成してレスポンスに含めるようにしている。今回はとりあえず署名付きURLの期限を 30分
にしているので、期限到達後はフロントエンドでこのURLへのアクセスはできなくなる。
フロントエンド
ディレクトリ構成
$ tree
.
├── README.md
├── apollo
│ ├── mutations
│ │ └── movies.gql
│ └── queries
│ └── movies.gql
├── assets
│ ├── README.md
│ └── variables.scss
├── codegen.yml
├── components
│ ├── MovieForm.vue
│ ├── MoviesCard.vue
│ └── README.md
├── gql-types.d.ts
├── jest.config.js
├── jsconfig.json
├── layouts
│ ├── README.md
│ ├── default.vue
│ └── error.vue
├── middleware
│ └── README.md
├── nuxt.config.js
├── package-lock.json
├── package.json
├── pages
│ ├── README.md
│ ├── index.vue
│ └── new.vue
├── plugins
│ ├── README.md
│ ├── apollo-error-handler.ts
│ └── filter.ts
├── static
│ ├── README.md
│ ├── favicon.ico
│ ├── v.png
│ └── vuetify-logo.svg
├── store
│ └── README.md
├── test
│ └── Logo.spec.js
├── tsconfig.json
├── types
│ └── vuetify
│ └── index.d.ts
├── vue-shim.d.ts
└── yarn.lock
GraphQL
動画登録 mutation
mutation CreateMovie($object: MovieInput!) {
createMovie(input: $object) {
id
}
}
動画一覧 query
query movies {
movies {
id
name
movieUrl
scale
}
}
動画登録ページ
ページ
<template>
<div>
<MovieForm @submit="save" @cancel="moveToIndex" />
</div>
</template>
<script lang="ts">
import { Vue, Component } from 'nuxt-property-decorator'
import 'vue-apollo'
import MovieForm from '~/components/MovieForm.vue'
import { MovieInput } from '~/gql-types'
import CreateMovie from '~/apollo/mutations/movies.gql'
@Component({
components: { MovieForm }
})
export default class NewPage extends Vue {
async save(input: MovieInput) {
try {
const res = await this.$apollo.mutate({
mutation: CreateMovie,
variables: {
object: {
name: input.name,
movieFile: input.movieFile,
scale: input.scale
}
}
})
if (res) {
console.log(res)
await this.$router.push('/')
} else {
console.log('no res')
this.$toast.error('no response')
}
} catch (err) {
// TODO: エラーハンドリング実装!
console.log(err)
this.$toast.error(err)
}
}
moveToIndex(): void {
this.$router.push('/')
}
}
</script>
動画情報登録フォームコンポーネントからsubmit
の通知を受けたら、動画登録のmutationを実行するページ。
Name
テキストボックスと動画プレビュー用Videoタグとファイル入力部品、そして「登録」ボタン(と「キャンセル」ボタン)とで構成。
コンポーネント
<template>
<v-container>
<v-form>
<v-row justify="center">
<v-col md="auto">
Name: <v-text-field v-model="input.name"></v-text-field>
<video
:src="uploadTargetUrl"
width="800"
height="450"
controls
@loadedmetadata="setMovieDuration"
></video>
<v-file-input
v-model="input.movieFile"
accept=".mp4"
@change="getUploadTargetFileUrl"
></v-file-input>
<v-btn @click="save">登録</v-btn>
<v-btn @click="cancel">キャンセル</v-btn>
</v-col>
</v-row>
</v-form>
</v-container>
</template>
<script lang="ts">
import { Vue, Component, Emit } from 'nuxt-property-decorator'
import 'vue-apollo'
import { MovieInput } from '~/gql-types'
class MovieInputImpl implements MovieInput {
movieFile: any
name!: string
scale!: number
}
@Component({})
export default class MovieForm extends Vue {
// 入力フォームの初期化
input: MovieInput = new MovieInputImpl()
// ローカルにある動画を取得したら、videoタグでプレビュー表示させるためにURL生成
private uploadTargetUrl: string = ''
getUploadTargetFileUrl(): void {
if (this.input.movieFile) {
this.uploadTargetUrl = URL.createObjectURL(this.input.movieFile)
}
}
// ローカルにある動画のメタ情報を読み込んだタイイングで発火
// 動画の秒数を取得し、登録フォームの要素に自動的に格納
setMovieDuration(e: Event): void {
if (e && e.target) {
const t: any = e.target
this.input.scale = Math.round(t.duration) // 少数->整数に四捨五入して丸める
}
}
save() {
// TODO: バリデーション実装
this.$emit('submit', this.input)
}
@Emit('cancel')
cancel(): void {}
}
</script>
v-file-input
にchange
イベントを仕込んで、ローカルから動画ファイルが指定されたら、その動画ファイル名から URL.createObjectURL
を用いてVideoタグのsrcとして指定可能なURLを生成し、Videoタグのsrcにセット。
Videoタグにはloadedmetadata
イベントを仕込んで、動画からメタ情報が取れるようになったら、秒数を(整数に丸めて)登録フォームの要素にセット。
動画一覧ページ
ページ
<template>
<div>
<MoviesCard :movies="movies" @move="moveToNew" />
</div>
</template>
<script lang="ts">
import { Vue, Component } from 'nuxt-property-decorator'
import 'vue-apollo'
import MoviesCard from '~/components/MoviesCard.vue'
import movies from '~/apollo/queries/movies.gql'
import { Movie } from '~/gql-types'
@Component({
components: { MoviesCard },
apollo: {
movies: {
prefetch: true,
query: movies
}
}
})
export default class IndexPage extends Vue {
private readonly movies: Movie[] = []
moveToNew(): void {
this.$router.push('/new')
}
}
</script>
GraphQLクエリを介して、バックエンドから動画情報一覧を取得し、コンポーネントに渡す。
コンポーネント
<template>
<v-container>
<v-row justify="center">
<v-col md="1">
<v-btn fab @click="move">
<v-icon>mdi-plus</v-icon>
</v-btn>
</v-col>
<v-col md="auto">
<v-data-table :items="movies" :headers="headers">
<template v-slot:item.movieUrl="{ item }">
<video
:src="item.movieUrl"
width="160"
height="90"
style="margin: 12px"
controls
></video>
</template>
<template v-slot:item.scale="{ item }">
{{ item.scale | scale }}
</template>
</v-data-table>
</v-col>
</v-row>
</v-container>
</template>
<script lang="ts">
import { Vue, Component, Prop, Emit } from 'nuxt-property-decorator'
import { DataTableHeader } from '@/types/vuetify'
import 'vue-apollo'
import { Movie } from '~/gql-types'
@Component({})
export default class MoviesCard extends Vue {
@Prop({ default: () => {} })
readonly movies!: Movie[]
get headers(): DataTableHeader[] {
return [
{ text: 'ID', value: 'id' },
{ text: 'Name', value: 'name' },
{ text: 'Preview', value: 'movieUrl' },
{ text: 'Scale', value: 'scale' }
]
}
@Emit('move')
move(): void {}
}
</script>
まとめ
ファイルアップロード自体はGraphQLでやる必要性はないのだけど、GraphQLでもできるよという話。
ただ、実は以下、解決しないままいったん放置している。(折を見て、そのうち解消する予定)
-
@nuxt/toast
を導入したはいいけど、this.$toast
がTypeScript的に(?)怒られているのでyarn dev
は動くけど、おそらくyarn build
は動かないはず。 - ファイルの容量として
35MB
を超えたあたりからファイルアップロードでネットワークエラーが起きて正常終了しない。(おそらくNuxt、Apolloないしgqlgenの何かの制約に引っかかっている?)