お題
ここ数年、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関数
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を介さず外部から直接コールされることも想定(例えばそういう要件とする)するため。
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としても使う。
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
}
動作確認
WebAssembly
GoでのWebAssemblyファイルの作り方等は下記参照。
https://github.com/golang/go/wiki/WebAssembly#getting-started
とりあえず、以下のようなmain関数を用意して、先述の domain.ValidateTodoText(text)
が validateTodoText
という名前で呼び出せるようにする。
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から参照できる場所にしておく。
#!/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の各ファイルの内容はさらりと。
見た目は下記。
<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>
<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>
<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」という独自ルールを当て込むという意味。
そのルールに合致しない入力の場合はバリデーションエラーとしてエラーメッセージが画面に表示される。
例えば、こう。
バリデーション独自ルール
以下で独自ルール「todoText
に割り当てたチェック関数内で使っている validateTodoText(value)
がBackendのバリデーションロジックをWebAssemblyファイル化したものから呼び出した関数。
これにより、Frontendで「 if (value === '') {
」とか「 if (value.length < 4) {
」とかBackendと同じバリデーションロジックを書かなくて済む。
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ファイルを読み込んで使えるようになる。
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
」が読み込まれるようにしておく。
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秒弱はプロダクトとしては使えない。。。
まとめ
一応、機能としては実現できた。ただ、go.wasm
のDLに5秒とかかかる現状では、そのままプロダクトとして扱うのは厳しいか。
syscall/js
を使いつつもwasm
の軽量化、これが実現できれば(それでも時期尚早かもしれないけど)採用もありか。