- 投稿日:2020-11-27T19:03:58+09:00
Testify を使った Go 言語で書かれたプログラムのテスト
ここ数か月ほど仕事で Go 言語を使ってプログラムを書いています。その中で、テストの記述が楽になる testify というライブラリを見つけました。この記事では、その使い方をチュートリアル形式でご紹介します。
テスト対象の HTTP サーバを作る
以下の仕様で HTTP サーバを実装する、というストーリーに沿って testify の機能を紹介します。
/probe
にアクセスするとhttp://example.com
にアクセスして、200 が返ってくるかどうかをチェックする- 上記に成功した場合、
/probe
のレスポンスはprobe_success 1
となり、失敗した場合はprobe_success 0
が返ってくる (Prometheus の exporter と同じ形式)テストコード
まず testify なしで、標準ライブラリだけでテストを書きます。
handleProbe
がテスト対象の関数です。http.HandleFunc
に渡せるようにシグネチャを合わせています。Go 言語にはnet/http
パッケージのテストを書くためのヘルパとしてnet/http/httptest
というパッケージが用意されているので、これを使いました。main_test.gopackage main import ( "net/http" "net/http/httptest" "testing" ) func Test_handleProbe(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest("GET", "/probe", nil) handleProbe(rec, req) expectedCode := http.StatusOK if rec.Code != expectedCode { t.Errorf("unexpected status code, expected %d but got %d", expectedCode, rec.Code) } expectedBody := "probe_success 1\n" if rec.Body.String() != expectedBody { t.Errorf("unexpected body, expected %s but got %s", expectedBody, rec.Body.String()) } }プロダクションコード
テストが通るようにプロダクションコードを実装していきます。
handleProbe
は以下のように実装しました。main.gofunc handleProbe(w http.ResponseWriter, req *http.Request) { var ret int if new(probe).Probe("http://example.com") { ret = 1 } w.WriteHeader(http.StatusOK) w.Write([]byte(fmt.Sprintf("probe_success %d\n", ret))) }
Probe
メソッドで与えた URL にアクセスできるかどうかチェックします。
Probe
は以下のように実装しました。main.gotype Prober interface { Probe(string) bool } type probe struct{} func (p *probe) Probe(url string) bool { res, err := http.Get(url) if err != nil { return false } return res.StatusCode == http.StatusOK }単に GET でアクセスして
http.StatusOK
(200) かどうかチェックしているだけです。err
が返ってきた場合はプロトコル的なエラー (URL が間違ってる、サーバの HTTP レスポンスが不正等) が起きていたり、接続がタイムアウトしたりしている訳ですが、今回は簡単のためこれらも接続できなかったという扱いでfalse
にしています。ここまで書くと
go test
がパスするようになりました。assert を使ったテスト
assert.Equal
標準ライブラリだけでテストを書くと、山ほど
if hoge { t.Errorf("fuga") }
のようなコードを書くことになるのですが、assert
パッケージを使うとすっきり書けます。main_test.gofunc Test_handleProbe(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest("GET", "/probe", nil) handleProbe(rec, req) assert.Equal(t, http.StatusOK, rec.Code) // *testing.T, 期待する値、実際の値 を順に渡す assert.Equal(t, "probe_success 1\n", rec.Body.String()) }すっきりして読みやすくなったと思います。
テスト失敗時の出力
assert
パッケージの良いところは、記述が簡潔になることだけではなく、失敗したアサーションが分かりやすく出力されるところにあります。試しにhandleProbe
を以下のように書き換えてテストしてみます。main.gofunc handleProbe(w http.ResponseWriter, req *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte("probe_success 0\n")) // 常に 0 を返す }$ go test ./... --- FAIL: Test_handleProbe (0.00s) main_test.go:18: Error Trace: server_test.go:18 Error: Not equal: expected: "probe_success 1\n" actual : "probe_success 0\n" Diff: --- Expected +++ Actual @@ -1,2 +1,2 @@ -probe_success 1 +probe_success 0 Test: Test_handleProbe FAIL FAIL qiita-testify-example 0.004s FAILこのように、期待する結果と実際の結果、そして値の diff が表示されます。アサーションライブラリを使わないと自分でエラーメッセージを丁寧に書く必要がありますが、その手間が省けて便利です。
HTTP ハンドラ用のアサーション
testify
には HTTP ハンドラに特化したアサーションメソッドもあるので、さらに短く書くこともできます。main_test.gofunc Test_handleProbe(t *testing.T) { assert.HTTPStatusCode(t, handleProbe, "GET", "/probe", nil, http.StatusOK) assert.HTTPBodyContains(t, handleProbe, "GET", "/probe", nil, "probe_success 1\n") }mock を使ったテスト
ここまでに書いたテストは実際に http://example.com へとアクセスしています。そのため、インターネットに繋がらない環境でテストすると失敗します。また、 http://example.com が落ちるといった、開発者がコントロールできない要因でテストが失敗することもあります。こういった環境や外部システムへの依存をなくす手段がモックです。このプログラムにおいては、
probe
構造体がテスト時にモックで差し替えたいオブジェクトです。プロダクションコードの修正
始めに、テストから
probe
を差し替えられるよう、パッケージ変数を定義し、handleProbe
からパッケージ変数を参照するように変更します。main.govar defaultProbe Prober = new(probe) func handleProbe(w http.ResponseWriter, req *http.Request) { var ret int if defaultProbe.Probe("http://example.com") { ret = 1 } w.WriteHeader(http.StatusOK) w.Write([]byte(fmt.Sprintf("probe_success %d\n", ret))) }モックを使ったテストの記述
モックに使う
mockProbe
構造体を新たに定義し、mock.Mock
を埋め込みます。mock.Mock
にはモックオブジェクトに必要な機能がいろいろとあるのですが、今回はモックオブジェクトのメソッドが期待された形式で呼び出されていることのチェックと、そのメソッドの戻り値を指定する機能を使います。main_test.goimport ( // 省略 "github.com/stretchr/testify/mock" ) type mockProbe struct { mock.Mock } func (m *mockProbe) Probe(url string) bool { args := m.Called(url) return args.Bool(0) } func Test_handleProbe(t *testing.T) { m := &mockProbe{} m.On("Probe", "http://example.com").Return(true) defaultProbe = m assert.HTTPStatusCode(t, handleProbe, "GET", "/probe", nil, http.StatusOK) assert.HTTPBodyContains(t, handleProbe, "GET", "/probe", nil, "probe_success 1\n") }
On
メソッドに、テスト中に呼び出されるメソッド名とそのメソッドに渡される引数を与えます。今回の場合はProbe
メソッドに"http://example.com"
という文字列が渡されるはずなので、これらを指定しています。さらに、Return
メソッドでProbe
が返す戻り値を指定します。今回は戻り値がtrue
1つですが、複数ある場合はReturn(false, fmt.Errorf("foobar"))
のように複数の戻り値を指定できます。モックを導入したことで
Probe
に失敗したケースもテストできるようになりました。テストケースを追加してみます。main_test.gofunc Test_handleProbe_success(t *testing.T) { m := new(mockProbe) m.On("Probe", "http://example.com").Return(true) defaultProbe = m assert.HTTPStatusCode(t, handleProbe, "GET", "/probe", nil, http.StatusOK) assert.HTTPBodyContains(t, handleProbe, "GET", "/probe", nil, "probe_success 1\n") } func Test_handleProbe_fail(t *testing.T) { m := &mockProbe{} m.On("Probe", "http://example.com").Return(false) defaultProbe = m assert.HTTPStatusCode(t, handleProbe, "GET", "/probe", nil, http.StatusOK) assert.HTTPBodyContains(t, handleProbe, "GET", "/probe", nil, "probe_success 0\n") }suite を使ったテスト
suite
パッケージを使うとテストをグルーピングすることができます。これによって、パッケージよりも細かい単位 (テストスイート) でテストをまとめることができ、テストスイートごとに Setup, TearDown といったテストの前処理、後処理や、ヘルパ関数の定義ができるようになります。テストスイートの定義
まず
suite.Suite
を組み込んだ構造体を定義します。この構造体にはテストスイート内で共有したいフィールドを定義できます。今回はモックオブジェクトをm
として定義しました。main_test.gotype ProbeSuite struct { suite.Suite m *mockProbe }
SetupTest
ProbeSuite 構造体に対して
SetupTest
を定義して前処理を記述します。SetupTest
はテスト1つ1つの実行直前に実行されます (RSpec をご存知の方はbefore(:each)
をイメージしてください)。今回はここでモックオブジェクトを初期化します。main_test.gofunc (s *ProbeSuite) SetupTest() { s.m = new(mockProbe) defaultProbe = s.m }テストスイートに対するテスト定義
個々のテストで初期化したモックオブジェクトを使ってテスト対象を実行します。テストも先述の
SetupTest
と同じようにProbeSuite
のメソッドとして定義します。また、引数のt *testing.T
は不要となり、代わりにレシーバのs *ProbeSuite
を使います。main_test.gofunc (s *ProbeSuite) TestSuccess() { s.m.On("Probe", "http://example.com").Return(true) s.HTTPStatusCode(handleProbe, "GET", "/probe", nil, http.StatusOK) s.HTTPBodyContains(handleProbe, "GET", "/probe", nil, "probe_success 1\n") } func (s *ProbeSuite) TestFail() { s.m.On("Probe", "http://example.com").Return(false) s.HTTPStatusCode(handleProbe, "GET", "/probe", nil, http.StatusOK) s.HTTPBodyContains(handleProbe, "GET", "/probe", nil, "probe_success 0\n") }テストスイートの実行
最後に、テストスイートを実行するために 通常の テストを書きます。これを忘れるとテストスイート全体が実行されなくなるので注意が必要です。
main_test.gofunc TestProbeSuite(t *testing.T) { suite.Run(t, new(ProbeSuite)) }まとめ
最終的に全ての機能を使うと以下のようなコードになりました。
プロダクションコード:
main.gopackage main import ( "fmt" "log" "net/http" ) var defaultProbe Prober = new(probe) type Prober interface { Probe(string) bool } type probe struct{} func (p *probe) Probe(url string) bool { res, err := http.Get(url) if err != nil { return false } return res.StatusCode == http.StatusOK } func handleProbe(w http.ResponseWriter, req *http.Request) { var ret int if defaultProbe.Probe("http://example.com") { ret = 1 } w.WriteHeader(http.StatusOK) w.Write([]byte(fmt.Sprintf("probe_success %d\n", ret))) } func main() { http.HandleFunc("/probe", handleProbe) log.Fatal(http.ListenAndServe(":8080", nil)) }テストコード:
main_test.gopackage main import ( "net/http" "testing" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" ) type mockProbe struct { mock.Mock } func (m *mockProbe) Probe(url string) bool { args := m.Called(url) return args.Bool(0) } type ProbeSuite struct { suite.Suite m *mockProbe } func (s *ProbeSuite) SetupTest() { s.m = new(mockProbe) defaultProbe = s.m } func (s *ProbeSuite) TestSuccess() { s.m.On("Probe", "http://example.com").Return(true) s.HTTPStatusCode(handleProbe, "GET", "/probe", nil, http.StatusOK) s.HTTPBodyContains(handleProbe, "GET", "/probe", nil, "probe_success 1\n") } func (s *ProbeSuite) TestFail() { s.m.On("Probe", "http://example.com").Return(false) s.HTTPStatusCode(handleProbe, "GET", "/probe", nil, http.StatusOK) s.HTTPBodyContains(handleProbe, "GET", "/probe", nil, "probe_success 0\n") } func TestProbeSuite(t *testing.T) { suite.Run(t, new(ProbeSuite)) }今回は基本的な機能を一通り紹介するために全パッケージを使いましたが、
assert
を導入するだけでも十分便利だと思います。参考資料
- 投稿日:2020-11-27T17:17:27+09:00
Golangのテストで関数名に日本語を使ってわかりやすくする方法
標準テストの場合
package hoge_test import ( "reflect" "runtime" "strings" "testing" ) func Test(t *testing.T) { t.Run("足し算テスト", func(t *testing.T) { Run(t, 卍1プラス1イコール2) }) } // Run [標準のテスト] 関数名を説明文として設定する処理 func Run(t *testing.T, f func(*testing.T)) { fv := reflect.ValueOf(f) funcFullName := runtime.FuncForPC(fv.Pointer()).Name() list := strings.Split(funcFullName, ".") funcName := list[len(list)-1] t.Run(funcName, f) } // テスト本体 先頭の卍は、数字全角が先頭にくるとコンパイルエラーとなるための苦し紛れ func 卍1プラス1イコール2(t *testing.T) { actual := 1 + 2 expected := 2 if actual != expected { t.Errorf("actual=%d, expected=%d", actual, expected) } } 実行結果 --- FAIL: Test (0.00s) --- FAIL: Test/足し算テスト (0.00s) --- FAIL: Test/足し算テスト/卍1プラス1イコール2 (0.00s) hoge_test.go:33: actual=3, expected=2 FAILテストライブラリ:goblinを使った場合
package hoge_test import ( "reflect" "runtime" "strings" "testing" "github.com/franela/goblin" . "github.com/franela/goblin" ) func Test(t *testing.T) { g := Goblin(t) g.Describe("足し算テスト", func() { RunG(g, 卍1プラス1イコール2) }) } // RunG [goblinテスト] 関数名を説明文として設定する処理 func RunG(g *goblin.G, f func(g *goblin.G)) { fv := reflect.ValueOf(f) funcFullName := runtime.FuncForPC(fv.Pointer()).Name() list := strings.Split(funcFullName, ".") funcName := list[len(list)-1] g.It(funcName, func() { f(g) }) } // テスト本体 先頭の卍は、数字全角が先頭にくるとコンパイルエラーとなるための苦し紛れ func 卍1プラス1イコール2(g *goblin.G) { actual := 1 + 2 expected := 2 g.Assert(actual).Equal(expected) } 実行結果 === RUN Test 足し算テスト 1) 卍1プラス1イコール2 0 tests complete (0 ms) 1 tests failed: 1) 足し算テスト 卍1プラス1イコール2: 3 does not equal 2 /home/daijin/go/pkg/mod/github.com/franela/goblin@v0.0.0-20200825194134-80c0062ed6cd/assertions.go:67 +0x197 /home/daijin/src/testp/hoge_test.go:61 +0x9e /home/daijin/src/testp/hoge_test.go:53 +0x2e /home/daijin/go/pkg/mod/github.com/franela/goblin@v0.0.0-20200825194134-80c0062ed6cd/goblin.go:228 +0x24 /home/daijin/go/pkg/mod/github.com/franela/goblin@v0.0.0-20200825194134-80c0062ed6cd/goblin.go:351 +0x7f /home/daijin/go/pkg/mod/github.com/franela/goblin@v0.0.0-20200825194134-80c0062ed6cd/goblin.go:228 +0x7b /home/daijin/go/pkg/mod/github.com/franela/goblin@v0.0.0-20200825194134-80c0062ed6cd/goblin.go:225 +0x371 --- FAIL: Test (0.00s)上記のようにはせず、普通に書く場合、タイトルと関数名の同じものを並んで2個書くことになり冗長。
日本語関数名だけセットすれば、それがタイトルになると良いなぁという感じで書きました。
githubにでも上げるか?とかおもったのですが、コード量少ないので、使うのであれば、テストのユーティリティにでも入れてもらえばと思います。
終わりに一言
TDD Boot Camp 2020 Online #1 基調講演/ライブコーディング
twadaさんのテスト駆動開発ライブコーディングで、Javaだと日本語でテスト書けていてうらやまだったので、Golangでも同じように出来ないか?頑張ってみたという感じでした。
先頭が数字から始まるとコンパイルエラーとなります。
Javaだとアンダーバーでしたが、Golangだと無理だったので適当な漢字(卍)を使ってみました。
余談ですが、シャープっぽく見える井口さんの井の字も候補でした。
- 投稿日:2020-11-27T12:17:40+09:00
golangCI、Code Climateで品質管理を行った話
こんにちは
最近アプリケーションエンジニアと言いつつ、サーバーだらけ触ってるキムです。
今回はPJの品質管理と、今回品質管理で投入したツールの設定と、使い方について共有しようと思ってます。入る前に
- golangciとcode climateを利用した経験になります。
- 詳細設定は各自PJの状況に合わせて調整したほうが良いです。
- 無理してプッシュするよりチームメンバーが納得できる範囲内で妥協することと、メンバーとの認識合わせを通じて、メンバー自ら努力するように環境を作るのが大事だと思います。
- golangciはGo言語に特化されてるツールです。言語ごとに色んなツールが存在するので、Go言語以外の言語を使う方はそれぞれ調べtください。
- code climate以外にもウェブを利用して、PJレポジトリを管理してくれるサービスはいくつかあります。こちらも調べてみてください。
- Sonar Qubeなどがありますね
品質管理てなんでするのか
品質管理はこんな意味を持ってます。
皆さんはコードの品質を管理してますか??
ユーザーに提供してるアプリケーションの品質を管理するのはものすごく重要なものです。 品質をちゃんと管理してないていうのは比較すると、 普段我々が使ってる色んな商品が何も品質保証無しで我々提供してると同じ意味合いになります。 そんな商品だと、皆さんは安心して購入できるでしょうか?
もちろんサービスをリリースする前には様々なテストを実装してると思います。これを専門的に行う人々をQAエンジニアと様な名前で読んでますし、彼らの存在は単純にリリース前のテストを行う人々ていう意味からはじめ、様々な役割を果たしてます。
この図は一番簡単に書いた、サービスがリリースされる前までのプロセスになります。省略された部分もあると思いますが、大きく見るとほとんどこのパターンで進めてると思います。
上記で話した、QAエンジニアが活躍するところは③の周りになります。
この記事で話したいのは図の①と②になります。
①はGolangCiの部分です、 ②はCodeClimate部分になります。
高いレベルのテストに入る前にコードレベルでの品質管理を厳しく行うことで、QAエンジニアの負担を減らせるし(簡単な動作バグや修正のデグレはまじで疲れますね。)、自分たちのコード品質に自身を持つことができ、コードを管理する基準を作ることでチーム員たちが共通の認識を持つことができます。golangci について
Goには
built-in
で提供してるPackageではfmtを含めていくつか有用なLintを提供してます。
このようなツールは単純にコーディングミスを探すことから、コードのインデントを整理したり(fmt)、不要なImportを消してくれたり(goimport)、変数や・関数を定義したけど使ってないことを教えてくれたりして、よりコードをきれいに管理できるように手伝ってくれます。
その中に、Golangを使う場合一番Publicなものがgolangciていうツールであります。 このツールは簡単なCiからはじめ、いろんなLinterを組み合わせて利用することで、プロジェクトをより簡単に管理してくれます。
提供してくれるLinterもこんなに多いので、どんな役割をするのか確認しながら、本人のPJで使えそうなものを投入しましょう。code climateにていて
code climateは私達が、基本的にCLIベースでチェックしてたことをウェブ画面で、数値化されてより理解しやすいようにやってくれるし、PJが持ってる潜在的なIssueを事前に対応できるように、表示してくれたりするUI/UXを提供してくれるサービスであります。
独自的な計算アルゴリズムを利用して、PJのメンテナンス数値や、該当Issuesを対応することにかかる予想時間を表示してくれたりします。
更にGithubとの連携を利用して簡単にCodeClimateのIssueをGithubのIssuesにしてわかりやすくしたり、PRにコメントを作成してくれるので、いろんなワークフローが作れます。ざっくり見る全体図
事前準備
- CodeClimateアカウントを持ってるのか
- Code Climateと、Githubのレポジトリを連動させる。
作業の流れ
- Localで作業する
- GitにPushする
- Github Actionsが走って
golangci-lint
チェック、coverage report
を作成- 作成したレポートをCode Climateに転送
- Code ClimateでPJの状態を確認
カスタム設定について
golangci
公式ドキュメントを見ると、PJのRootに下記4パターンのフォーマットに設定ファイルを準備することで、ルールをカスタマイズするのが可能になります。
- golangci.yml
- golangci.yaml
- golangci.toml
- golangci.json
## golangci.yml ## code climate 側でチェックしてることと一番関係高い部分をカスタムで入れてます。 linters-settings: nestif: # minimal complexity of if statements to report, 5 by default min-complexity: 4 gocognit: # minimal code complexity to report, 30 by default (but we recommend 10-20) min-complexity: 20 gofmt: # simplify code: gofmt with `-s` option, true by default simplify: true nakedret: # make an issue if func has more lines of code than this setting and it has naked returns; default is 30 max-func-lines: 100 issues: # Excluding configuration per-path, per-linter, per-text and per-source # テストコードの場合除外する。 exclude-rules: - path: _test\.go linters: - errcheck - gocognit - nestif - gofmt linters: # 利用するLinter enable: - nestif - gocognit - gofmt - nakedret fast: falseそして、このページをみると、普段提供してるDefaultのLinter以外に色んなLinterを提供してるので、この設定を追加して利用できます。
code climate
同じく公式ドキュメントを見ると会のフォーマットでチェック内容をカスタマイズするのができます。
このファイルがない場合、Defaultの値でチェックを行うし、簡単にチェックのOn/Offぐらいなら、設定ファイルを準備するより、ウェブページを利用するのがより簡単にできる。
- .codeclimate.yml
- .codeclimate.json
このページを見ると、各項目ごとにどんな内容をチェックするのかが、確認でき・設定するのができます。
version: "2" # required to adjust maintainability checks checks: argument-count: # parameter count config: threshold: 4 complex-logic: config: threshold: 15 file-lines: config: threshold: 500 method-complexity: config: threshold: 15 method-count: config: threshold: 20 method-lines: config: threshold: 50 nested-control-flow: config: threshold: 4 return-statements: config: threshold: 4 similar-code: config: threshold: # language-specific defaults. an override will affect all languages. identical-code: config: threshold: # language-specific defaults. an override will affect all languages. # 管理画面で除外するパターン exclude_patterns: - "config/" - "db/" - "dist/" - "features/" - "**/node_modules/" - "script/" - "**/spec/" - "**/test/" - "**/tests/" - "**/vendor/" - "**/*.d.ts" - "**/*_mock.go"ちょっと気になる項目がありますよね? おそらく下記2項目かと思います。
complex-logic: ## ロジックの複雑さ config: threshold: 4 method-complexity: ## メソッドの複雑さ config: threshold: 5他の項目はなんとなくわかるが、この項目だけはなんか気になりますね。
どんな内容をチェックするのか…
公式ドキュメントを見ると、CodeClimate側では2つの軸で、複雑さを計算してるといいます。
- Cognitive Complexity
- Cyclomatic Complexity本当にざっくり説明すると Cognitive Complexityは「人間が理解するのに問題ないのか」、をチェックすることです。 そして、Cyclomatic Complexityは「マシンが理解するのに問題ないのか」をチェックする方法です。
もし、より深く理解したいのであれば、こちらを読んでみてください。
そして、Qiitaではこのページでかならい優しく説明してくれてます。 おすすめです。そして下記の2項目もまります。
similar-code: ## コードのかぶり config: threshold: # language-specific defaults. an override will affect all languages. identical-code: ## 処理のかぶり config: threshold: # language-specific defaults. an override will affect all languages.それぞれかぶりをチェックする項目であります。 基本的に各言語の設定に頼ってます。
簡単に説明すると、func test1(a, b) int { a = a + 1 return a + b } func test2(a, b) int { a = a * 3 return a + b }ほんとにざっくり書きましたが、話してる内容は似てると思います。
similar
は、コード自体の重複ですね。 コードを書く時になんとなく名前だけ微妙に異なって、ほぼ同じぐらいのコードがあります。 このような場合検知され、Issuesになります。
identical
はやってる処理がほぼ似てる場合になります。
Aを取得して、Bを処理
、Cを取得して、Bを処理
のように名前だけ微妙に異なって、処理はほぼ同じ場合、このようなものがあるから、Refactorしたほうがいいよ的な案内を出してくれるものです。
基準が言語に依存されてるので、自分もよくわかりません… もし、関連情報があれば教えていただくと助かります。まとめ、
この作業を行いながら、コード品質管理について色々勉強する機械になりました。
単純に「テストコードを書いて、カバレッジを達成する」的なエンジニアとして活動してきましたが、このプロセスと投入しながら自ら品質てなんで管理しなきゃいけないのかとかあれこれ考えるチャンスになりました。
そして、このような指標を設定して、チーム員たちの目標を設定して、より高いチームワークを持つことができる機械になると思います。
ぜひ、みなさんもコード品質について深く考えて皆さんのPJにも投入して見てください。
まだ内容も足りないし、きれいじゃない記事ですが、読んで頂いてありがとうございます。
- 投稿日:2020-11-27T01:42:47+09:00
WebAssemblyを使ってBackend(Golang)のバリデーションロジックをFrontend(Nuxt.js)のバリデーションに流用する試み
お題
ここ数年、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/amd64IDE - Goland
GoLand 2020.2.3 Build #GO-202.7319.61, built on September 16, 2020今回の全ソース
https://github.com/sky0621/try-wasm/tree/v0.1.0/try02
実践
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.gomain関数
cmd/server.gopackage 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.gopackage 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.gopackage 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
という名前で呼び出せるようにする。wasm/main.gopackage 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.wasmFrontend
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.locklayout~page~component
今回の趣旨ではないのでFrontendの各ファイルの内容はさらりと。
見た目は下記。
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」という独自ルールを当て込むという意味。
そのルールに合致しない入力の場合はバリデーションエラーとしてエラーメッセージが画面に表示される。
例えば、こう。
バリデーション独自ルール
以下で独自ルール「
todoText
に割り当てたチェック関数内で使っているvalidateTodoText(value)
がBackendのバリデーションロジックをWebAssemblyファイル化したものから呼び出した関数。
これにより、Frontendで「if (value === '') {
」とか「if (value.length < 4) {
」とかBackendと同じバリデーションロジックを書かなくて済む。plugins/vee-validate.tsimport 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.jsconst 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秒弱はプロダクトとしては使えない。。。
まとめ
一応、機能としては実現できた。ただ、
go.wasm
のDLに5秒とかかかる現状では、そのままプロダクトとして扱うのは厳しいか。
syscall/js
を使いつつもwasm
の軽量化、これが実現できれば(それでも時期尚早かもしれないけど)採用もありか。
- 投稿日:2020-11-27T00:27:44+09:00
Goでstructをmapに変換する
はじめに
似たような内容の記事はあったのですが、バージョンが変わったせいかうまく動かなくて苦労したので記事にしました。
JSONを使うバージョン
var result map[string]interface{} indirect, _ := json.Marshal(data) json.Unmarshal(indirect, &result) // 値がなかったら、キーの削除 for key, value := range result { if value == nil { delete(result, key) } }これだと、time.Time型がStringになってしまい、僕の用途には合いませんでした。。
reflectを使うバージョン
result := make(map[string]interface{}) v := reflect.Indirect(reflect.ValueOf(data)) t := v.Type() for i := 0; i < t.NumField(); i++ { ft := t.Field(i) fv := v.FieldByName(ft.Name) // nullの場合のみセット if !fv.IsNil() { result[ft.Name] = reflect.Indirect(reflect.ValueOf(fv.Interface())) } }
reflect.Indirect
は、fv.Interface()
の値がポインタだった場合にそれを外す処理です。
ポインタのままmapに入れたい!って方にはいらないです。参考文献
https://qiita.com/keitaj/items/440a50a53c8980ee338f
https://qiita.com/chimatter/items/b0879401d6666589ab71
https://qiita.com/nirasan/items/b6b89f8c61c35b563e8c
- 投稿日:2020-11-27T00:19:49+09:00
LeetCodeに毎日挑戦してみた 28. Implement strStr()(Python、Go)
はじめに
無料英単語サイトE-tanを運営中の@ishishowです。
プログラマとしての能力を上げるために毎日leetcodeに取り組み、自分なりの解き方を挙げていきたいと思います。
Leetcodeとは
leetcode.com
ソフトウェア開発職のコーディング面接の練習といえばこれらしいです。
合計1500問以上のコーデイング問題が投稿されていて、実際の面接でも同じ問題が出されることは多いらしいとのことです。golang入門+アルゴリズム脳の強化のためにgoとPythonで解いていこうと思います。(Pythonは弱弱だが経験あり)
10問目(問題28)
28. Implement strStr()
- 問題内容
Implement strStr().
Return the index of the first occurrence of needle in haystack, or
-1
ifneedle
is not part ofhaystack
.Clarification:
What should we return when
needle
is an empty string? This is a great question to ask during an interview.For the purpose of this problem, we will return 0 when
needle
is an empty string. This is consistent to C's strstr() and Java's indexOf().**
Example 1:
Input: haystack = "hello", needle = "ll" Output: 2Example 2:
Input: haystack = "aaaaa", needle = "bba" Output: -1Example 3:
Input: haystack = "", needle = "" Output: 0考え方
haystack - needle回ループを回します ← それ以上する必要がないため
一文字ごとに判定して違ったらその判定処理を抜けます
needle の文字数と一致したらその時のindexを返します
- 解答コード
def strStr(self, haystack, needle): if needle == "": return 0 for i in range(len(haystack)-len(needle)+1): for j in range(len(needle)): if haystack[i+j] != needle[j]: break if j == len(needle)-1: return i return -1
- Goでも書いてみます!
func strStr(haystack string, needle string) int { for i := 0; i < len(haystack)-len(needle)+1; i++ { j := 0 for ; j < len(needle); j++ { if needle[j] != haystack[i+j] { break } } if j == len(needle) { return i } } return -1 }別解
(1)
class Solution(object): def strStr(self, haystack, needle): return haystack.find(needle)findを使うとstrstr同様にその単語のindexを表示してくれます
(2)
class Solution(object): def strStr(self, haystack, needle): for i in range(len(haystack) - len(needle)+1): if haystack[i:i+len(needle)] == needle: return i return -1こちらは文字列の長さで比較しています。私は最初こちらを作成しました。
(3)
import "strings" func strStr(haystack string, needle string) int { return strings.Index(haystack, needle) }goのfind版です