LoginSignup
6
3

More than 3 years have passed since last update.

WebAssemblyを使ってBackend(Golang)のバリデーションロジックをFrontend(Nuxt.js)のバリデーションに流用する試み

Posted at

お題

ここ数年、FrontendにVue.js/Nuxt.js、BackendにGolang、間をREST-APIないしGraphQLで繋ぐという構成で開発している。
この構成でよく感じるのが、「フロントでもバックでも同じバリデーションロジックを両方で実装してるなぁ」ということ。
バリデーションと言っても、FrontendとBackendとでは厳密にはチェックすべき内容が違うのだけど、型・桁とかフォーマットとか結局どちらでも同じようなロジックを実装してたりする。
実際のところは、Frontend、Backend双方に便利なライブラリがあったりして、それほど開発負荷がかかっているわけではないのだけど、何が嫌って「同じ仕様を複数箇所で実装」していること。
そして、時々、同じ仕様のはずなのにFrontendとBackendとで微妙にバリデーション内容が違っていることが発生すること。

なんとかならないんだろうかと思っていたところに、WebAssemblyの記事を読んだことで、にわかに思いつき実装をしてみることにした。
BackendではGolangでバリデーションロジックを流用しやすい単位で関数化しておきBackendのバリデーションにはもちろんその関数を呼び出す。
そして、その関数をWebAssemblyとして使える形に変換(正直、WebAssemblyの技術仕様はまだよくわかっていない)してFrontendでも同じロジックを呼び出せるようにする。
そうしたら、同じ仕様を2箇所で実装することはなくなるんじゃないだろうか?

ちなみに、そもそも「バリデーションとは?」の定義問題や、どこで実装すべきという思想などは、この記事では触れない。

前提

以下に関して初心者向けな懇切丁寧な解説はしてないです。
・Vus.js/Nuxt.js/TypeScript/vee-validate
・Golang/gqlgen
・GraphQL
・WebAssembly/wasm

開発環境

# OS - Linux(Ubuntu)

$ cat /etc/os-release 
NAME="Ubuntu"
VERSION="20.04.1 LTS (Focal Fossa)"

# バックエンド

# 言語 - Golang

$ go version
go version go1.15.2 linux/amd64

IDE - Goland

GoLand 2020.2.3
Build #GO-202.7319.61, built on September 16, 2020

今回の全ソース

実践

Backend

まずはBackendのバリデーション実装から。
Frontendとの繋ぎは、たまたまGraphQLを使っているけど、今回の趣旨としてはGraphQLでもREST-APIでも何でも関係ない。

tree

$ tree
.
├── cmd
│   └── server.go
├── domain
│   ├── error.go
│   └── todo.go
├── generated.go
├── go.mod
├── go.sum
├── gqlgen.yml
├── models_gen.go
├── resolver.go
├── schema.resolvers.go
├── scripts
│   └── wasm.sh
└── wasm
    └── main.go

main関数

cmd/server.go
package main

import (
    "log"
    "net/http"
    "os"

    app "github.com/sky0621/try-wasm/try02/backend"

    "github.com/99designs/gqlgen/graphql/handler"
    "github.com/99designs/gqlgen/graphql/playground"
)

func main() {
    srv := handler.NewDefaultServer(app.NewExecutableSchema(app.Config{Resolvers: &app.Resolver{}}))

    http.Handle("/pg", playground.Handler("GraphQL playground", "/query"))
    http.Handle("/query", srv)

    log.Fatal(http.ListenAndServe(":8080", nil))
}

Resolver

今回の題材としては「TODOを登録」する機能としている。で、その(GraphQLの)リクエストがFrontendから飛んでくると発火するのがこの部分。
TODOを登録するロジック本体は domain パッケージの CreateTodo(~~) に任せる。
その結果、エラーが発生(今回はバリデーションエラーのみ想定)したら、そのエラーをGraphQL的なエラーの返し方でFrontendに返す。
ただし、通常、ここでエラーが返ることはない。
なぜなら、同じチェックをFrontendでもしている(はず)から。
Backendでこのチェックが必要なのは、Frontendを介さず外部から直接コールされることも想定(例えばそういう要件とする)するため。

schema.resolvers.go
package app

// 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"

    "github.com/99designs/gqlgen/graphql"
    "github.com/vektah/gqlparser/v2/gqlerror"

    "github.com/sky0621/try-wasm/try02/backend/domain"
)

func (r *mutationResolver) CreateTodo(ctx context.Context, input NewTodo) (*Todo, error) {
    todo, errs := domain.CreateTodo(domain.Todo{
        Text:   input.Text,
        UserID: input.UserID,
    })
    if errs != nil {
        for _, e := range errs {
            graphql.AddError(ctx, &gqlerror.Error{
                Message: "VALIDATION_FAILURE",
                Extensions: map[string]interface{}{
                    "status_code":   400,
                    "error_message": e.Err.Error(),
                    "field":         e.Field,
                    "value":         e.Value,
                },
            })
        }
        return nil, nil
    }

    return &Todo{
        ID:   "newID",
        Text: todo.Text,
        Done: false,
        User: &User{
            ID:   todo.UserID,
            Name: "user1",
        },
    }, nil
}

〜〜省略〜〜

domainロジック

内容はソース見ればわかるということで。
今回の肝は、バリデーションに特化した部分を ValidateTodoText(text) 関数に逃がしている点。
この関数をWebAssemblyとしても使う。

domain/todo.go
package domain

import (
    "errors"
    "unicode/utf8"
)

type Todo struct {
    Text   string `json:"text"`
    UserID string `json:"userId"`
}

func CreateTodo(todo Todo) (*Todo, []*Error) {
    /*
     * バリデーション
     */
    var domainErrors []*Error

    // ToDoテキストのバリデーション
    err := ValidateTodoText(todo.Text)
    if err != nil {
        domainErrors = append(domainErrors, err)
    }

    // MEMO: ユーザーIDのバリデーション
    // 今回はバリデーション1事例あれば十分なので省略。

    if domainErrors != nil {
        return nil, domainErrors
    }

    /*
     * MEMO: 何かしらの登録処理を行った後、結果を返却!
     * 今回の趣旨ではないので省略。
     */
    return &Todo{
        Text:   todo.Text,
        UserID: todo.UserID,
    }, nil
}

func ValidateTodoText(text string) *Error {
    // MEMO: 必須チェックを入れてみる。
    if text == "" {
        return &Error{Field: "text", Value: "nil", Err: errors.New("required")}
    }

    // MEMO: 文字列長チェックを入れてみる。
    cnt := utf8.RuneCountInString(text)
    if cnt < 4 {
        return &Error{Field: "text", Value: text, Err: errors.New("min")}
    }
    if cnt > 10 {
        return &Error{Field: "text", Value: text, Err: errors.New("max")}
    }
    return nil
}

動作確認

screenshot-localhost_8080-2020.11.27-01_03_25.png

WebAssembly

GoでのWebAssemblyファイルの作り方等は下記参照。
https://github.com/golang/go/wiki/WebAssembly#getting-started

とりあえず、以下のようなmain関数を用意して、先述の domain.ValidateTodoText(text)validateTodoText という名前で呼び出せるようにする。

wasm/main.go
package main

import (
    "syscall/js"

    "github.com/sky0621/try-wasm/try02/backend/domain"
)

func main() {
    done := make(chan struct{}, 0)
    global := js.Global()
    global.Set("validateTodoText", js.FuncOf(validateTodoText))
    <-done
}

func validateTodoText(this js.Value, args []js.Value) interface{} {
    if len(args) != 1 {
        return "ERROR: number of arguments doesn't match"
    }

    if res := domain.ValidateTodoText(args[0].String()); res != nil {
        return res.Err.Error()
    }
    return nil
}

wasmというファイル形式(?)に変換するコマンドは以下のようにShellにしておく。
作成したファイルの出力先は、Frontendから参照できる場所にしておく。

scripts/wasm.sh
#!/usr/bin/env bash
set -euox pipefail
SCRIPT_DIR=$(dirname "$0")
# shellcheck disable=SC2086
echo ${SCRIPT_DIR}
cd "${SCRIPT_DIR}" && cd ../wasm

GOOS=js GOARCH=wasm go build -o ../../frontend/static/go.wasm

Frontend

create-nuxt-appを使ってプロジェクト作成後、GraphQLやTypeScriptの設定を追加した状態。
staticディレクトリの下にBackendで生成したgo.wasmバイナリが置かれている。

tree

今回の試みに関係ない部分は極力省略している。

$ tree
.
├── codegen.yml
├── components
│   └── TodoForm.vue
├── gql-types.d.ts
├── layouts
│   ├── default.vue
│   └── error.vue
├── nuxt.config.js
├── package.json
├── pages
│   └── index.vue
├── plugins
│   └── vee-validate.ts
├── static
│   ├── go.wasm
│   ├── init_wasm.js
│   └── wasm_exec.js
├── tsconfig.json
└── yarn.lock

layout~page~component

今回の趣旨ではないのでFrontendの各ファイルの内容はさらりと。
見た目は下記。
screenshot-localhost_3000-2020.11.27-01_09_31.png

layouts/default.vue
<template>
  <v-app>
    <nuxt />
  </v-app>
</template>

<script lang="ts">
import { Vue, Component } from 'nuxt-property-decorator'

@Component({})
export default class DefaultLayout extends Vue {}
</script>
pages/index.vue
<template>
  <TodoForm />
</template>

<script lang="ts">
import { Vue, Component } from 'nuxt-property-decorator'
import TodoForm from '~/components/TodoForm.vue'
@Component({
  components: { TodoForm },
})
export default class IndexPage extends Vue {}
</script>
components/TodoForm.vue
<template>
  <v-container>
    <ValidationObserver
      ref="observer"
      v-slot="{ invalid }"
      @submit.prevent="submit"
      slim
    >
      <v-form>
        <v-row>
          <v-col cols="5">
            <v-card>
              <ValidationProvider
                v-slot="{ errors }"
                name="TODO"
                rules="todoText"
              >
                <v-text-field
                  v-model="todoInput.text"
                  :error-messages="errors[0]"
                  label="TODO"></v-text-field>
              </ValidationProvider>
            </v-card>
          </v-col>
        </v-row>
        <v-row>
          <v-col cols="5">
            <v-card>
              <ValidationProvider
                v-slot="{ errors }"
                name="ユーザーID"
                rules="required"
              >
                <v-text-field
                  v-model="todoInput.userId"
                  :error-messages="errors[0]"
                  label="ユーザーID"></v-text-field>
              </ValidationProvider>
            </v-card>
          </v-col>
        </v-row>
        <v-row>
          <v-col cols="5">
            <v-btn @click="submit">登録</v-btn>
          </v-col>
        </v-row>
      </v-form>
    </ValidationObserver>
  </v-container>
</template>
<script lang="ts">
import { Component, Vue } from '~/node_modules/nuxt-property-decorator'
import { ValidationObserver } from 'vee-validate'
import { NewTodo } from '~/gql-types'

export class TodoInput implements NewTodo {
  text: string = ''
  userId: string = ''
}

@Component({})
export default class TodoForm extends Vue {
  $refs!: {
    observer: InstanceType<typeof ValidationObserver>
  }

  todoInput: NewTodo = new TodoInput()

  async submit() {
    console.log('Submit!')
    const valid = await this.$refs.observer.validate()
    if (valid) {
      console.log('valid!!!')
      // MEMO: GraphQLを介してバックエンドに登録内容を送信。(今回の検証範囲外なので省略)
    }
  }
}
</script>

バリデーションには vee-validate というライブラリを使っている。
今回で言うと、rules="todoText" としている箇所は「todoText」という独自ルールを当て込むという意味。
そのルールに合致しない入力の場合はバリデーションエラーとしてエラーメッセージが画面に表示される。
例えば、こう。
screenshot-localhost_3000-2020.11.27-01_12_20.png

バリデーション独自ルール

以下で独自ルール「todoText に割り当てたチェック関数内で使っている validateTodoText(value) がBackendのバリデーションロジックをWebAssemblyファイル化したものから呼び出した関数。
これにより、Frontendで「 if (value === '') { 」とか「 if (value.length < 4) { 」とかBackendと同じバリデーションロジックを書かなくて済む。

plugins/vee-validate.ts
import Vue from 'vue'
import {
  extend,
  ValidationObserver,
  ValidationProvider
} from 'vee-validate'
import { required } from 'vee-validate/dist/rules'

Vue.component('ValidationProvider', ValidationProvider)
Vue.component('ValidationObserver', ValidationObserver)

extend('todoText', {
  validate: (value: string) => {
    // @ts-ignore
    const res = validateTodoText(value)
    console.log(res)
    if (res && res !== '') {
      return false
    }
    return true
  },
  message() {
    return 'TODOは4文字以上、10文字以内です。'
  }
})
extend('required', required)

wasm呼び出し関数

記載内容はお約束コードになっている。
以下でBackend起因のwasmファイルを読み込んで使えるようになる。

static/init_wasm.js
const go = new Go(); // Defined in wasm_exec.js
const WASM_URL = 'go.wasm';

let wasm;

if ('instantiateStreaming' in WebAssembly) {
  WebAssembly.instantiateStreaming(fetch(WASM_URL), go.importObject).then(function (obj) {
    wasm = obj.instance;
    go.run(wasm);
  })
} else {
  fetch(WASM_URL).then(resp =>
    resp.arrayBuffer()
  ).then(bytes =>
    WebAssembly.instantiate(bytes, go.importObject).then(function (obj) {
      wasm = obj.instance;
      go.run(wasm);
    })
  )
}

nuxt.config.js

以下のように script として「wasm_exec.js」と「init_wasm.js」が読み込まれるようにしておく。

nuxt.config.js(抜粋)
export default {
  // Global page headers (https://go.nuxtjs.dev/config-head)
  head: {
    titleTemplate: '%s - frontend',
    title: 'frontend',
    meta: [
      { charset: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      { hid: 'description', name: 'description', content: '' },
    ],
    link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }],
    script: [
      { src: 'wasm_exec.js' },
      { src: 'init_wasm.js' },
    ],
  },
 〜〜省略〜〜

wasmファイルDLにかかるコスト

Backendのバリデーションロジックを含むwasmファイルなのだけど、1.4MB あって、TODO入力画面を開こうとしてから5秒弱もかかっている。
wasmのビルドに使うGoのランタイムを tinygo に変えると数KB〜数10KBぐらいに出来るようだけど、そうすると今度は関数内外での値の受け渡し(syscall/jsが担う部分)を行う部分が機能的に対応してないのか、ビルドに失敗する。
このあたりは諦めて掘り下げてないけど、もう少し調査してみるかな。さすがに5秒弱はプロダクトとしては使えない。。。
Screenshot at 2020-11-27 01-27-13.png

まとめ

一応、機能としては実現できた。ただ、go.wasmのDLに5秒とかかかる現状では、そのままプロダクトとして扱うのは厳しいか。
syscall/jsを使いつつもwasmの軽量化、これが実現できれば(それでも時期尚早かもしれないけど)採用もありか。

6
3
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
6
3