LoginSignup
7
7

More than 3 years have passed since last update.

GraphQL/Nuxt.js(TypeScript/Vuetify/Apollo)/Golang(gqlgen)/Google Cloud Storageの組み合わせで動画ファイルアップロード実装例

Last updated at Posted at 2020-04-19

お題

GraphQLでは、(まあ、要するにmultipartなわけだけど)Upload という名のscalarでスキーマ定義することでファイルアップロードを実現する。
この機能を使ったお題として、表題の組み合わせでやってみた。

機能

概要

  • ローカル環境にある動画ファイル(mp4限定)をVideoタグでプレビュー確認後、Google Cloud Storageにアップロード
  • Google Cloud Storage上のファイルを署名付きURLを用いてVideoタグでプレビュー表示

実際の挙動

こんな画面を用意する。赤枠をクリックすると、
screenshot-localhost_3000-2020.04.19-22_57_41.png

ローカル上のファイル(選択可能なのは mp4 だけに制御)を選択させる。例えば、「jesse_wood-contrast.mp4」を選択する。
Screenshot at 2020-04-19 23-00-34.png

選択結果が画面に反映される。動画の再生ボタンを押下すると、
screenshot-localhost_3000-2020.04.19-23_07_49.png

(まだサーバには未反映の)動画がプレビューできる。
そして、Name欄を埋め、「登録」ボタンを押下すると、
screenshot-localhost_3000-2020.04.19-23_11_29.png

動画が登録され、一覧に表示される。
※ちなみに、動画の秒数はローカル上のファイル選択時にVideoタグの機能でメタ情報から取得
※ID欄はバックエンドでのDB登録時に自動でUUIDを採番
screenshot-localhost_3000-2020.04.19-23_15_04.png

適当にもう2件ほど動画をアップロードしてみた結果はこんな感じ。(一斉に動画プレビューも可能)
screenshot-localhost_3000-2020.04.19-23_19_31.png

動画はGoogle Cloud Storage上にこのようにアップロードされている。
screenshot-console.cloud.google.com-2020.04.19-23_21_01.png

DBには、このように格納されている。
Screenshot at 2020-04-19 23-23-59.png

関連記事索引

開発環境

# 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スキーマ

schema/schema.graphqls
# 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
}
schema/movie.graphqls
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!
}

機能として「動画を登録する」ものと「動画(情報)を一覧表示する」ものを用意しているので mutationquery を1つずつ用意。
情報としては特にひねりはない。multipartの動画情報受け口として Upload を型とする要素を定義しているのがポイント。

main関数

DBは SQL Boiler というO/Rマッパーを使用。
Google Cloud Storageにアクセスするのでそのためのクライアント初期化処理も実行。
Webサーバとしての機能は chi というフレームワークを使用。
GraphQLなのでエンドポイントは1つ。ハンドリングは gqlgen というライブラリで行う。

src/cmd/main.go
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生成機能が必要なので、それぞれをメソッドとして用意。

src/gcp/cloud_storage_client.go
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リクエストを処理する部分。

src/graph/resolver.go
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
}
src/graph/movie.resolvers.go
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

apollo/mutations/movies.gql
mutation CreateMovie($object: MovieInput!) {
  createMovie(input: $object) {
    id
  }
}

動画一覧 query

apollo/queries/movies.gql
query movies {
  movies {
    id
    name
    movieUrl
    scale
  }
}

動画登録ページ

ページ
pages/new.vue
<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タグとファイル入力部品、そして「登録」ボタン(と「キャンセル」ボタン)とで構成。

コンポーネント
components/MovieForm.vue
<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-inputchangeイベントを仕込んで、ローカルから動画ファイルが指定されたら、その動画ファイル名から URL.createObjectURL を用いてVideoタグのsrcとして指定可能なURLを生成し、Videoタグのsrcにセット。

Videoタグにはloadedmetadataイベントを仕込んで、動画からメタ情報が取れるようになったら、秒数を(整数に丸めて)登録フォームの要素にセット。

動画一覧ページ

ページ
pages/index.vue
<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クエリを介して、バックエンドから動画情報一覧を取得し、コンポーネントに渡す。

コンポーネント
components/MoviesCard.vue
<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の何かの制約に引っかかっている?)
7
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
7