20201018のGoに関する記事は9件です。

gRPCサーバーから"unknown service"エラーが返ってきた時の対処方法について

最近流行りのgRPC.
スキーマ言語であるProtocol Buffersによってデータをシリアライズ化する事ができ、
これまで主流だったjson通信よりもより高速に通信を行えるとして、
マイクロサービス開発で採用されるケースが増えている。

この間gRPCで構築したクライアント・サーバー間の通信をやろうとした時にタイトルの通りのバグが発生し
だいぶ手こずったので、忘備録として原因と解決策を残しておこうと思う。

ソースコード

ソースはこちら。
https://github.com/yzmw1213/PostService

やろうとした事

goで書いたgRPCサーバーで、以下2つのサービスを実装する。
- 投稿サービス
- 投稿につけるタグの管理サービス(マスタデータとしての扱い)

そして、typescriptで実装している client側のコードから、上のサービスにリクエストを送り処理を行う。

client.ts
import { Tag, CreateTagRequest } from "~/grpc/tag_pb"
import { TagServiceClient } from "~/grpc/TagServiceClientPb"

post() {
  const client = new TagServiceClient(
    "http://localhost:8080", {}, {}
  )

  const request = new CreateTagRequest()
  var tag = new Tag()
  tag.setTagId(postTag.tagID)
  tag.setTagName(postTag.tagName)
  tag.setStatus(postTag.status)
  request.setTag(tag)
  // TagServiceのcreateTagメソッドにリクエストを送る
  client.createTag(request, {}, (err, res) => {
    if (err != null) {
      console.log(err)
    }
    console.log(res)
  })
}

上記のようにして、クライアント側からタグサービスにタグ作成のリクエストを行った際、次のエラーが起こった。

{ code: 12, message: "unknown service post_grpc.TagService" }

解決策

code: 12 は何の事や...と思って公式のgitを見てみた。

すると、

// HTTP Mapping: 501 Not Implemented
UNIMPLEMENTED = 12;

要するに、
「そのサービス、実装されてないで」 っていう意味の内容だった。

 そこで、呼び出しを行っているサービスがgRPCサーバーに登録されているかどうか確認する。

grpcサーバーの動作確認にはgrpcurlを使うといい。

この記事に色々詳しい事が書かれていた。
https://qiita.com/yukina-ge/items/a84693f01f3f0edba482

例えばポート50051番でgRPCサーバーを構築しているとすると、以下のように叩くと良い。

# ポートに登録されているサービスの一覧
$ grpcurl -plaintext localhost:50051 list
grpc.reflection.v1alpha.ServerReflection
post_grpc.PostService

ServerReflectionとPostServiceは登録されているようだ。
あれ、じゃあTagServiceは...と思ってサーバー側のコード読んだら明らかなミスに気がついた。

server.go
package grpc

import (
    "fmt"
    "log"
    "net"
    "os"
    "os/signal"

    "github.com/yzmw1213/PostService/grpc/post_grpc"
    "github.com/yzmw1213/PostService/usecase/interactor"
    "google.golang.org/grpc"
    "google.golang.org/grpc/reflection"
)

type server struct {
    PostUsecase interactor.PostInteractor
    TagUsecase  interactor.TagInteractor
}

// NewPostGrpcServer gRPCサーバー起動
func NewPostGrpcServer() {
    lis, err := net.Listen("tcp", "0.0.0.0:50051")
    if err != nil {
        log.Fatalf("Failed to listen: %v", err)
    }

    server := &server{}

    s := makeServer()
     // PostServiceを serverに登録
    post_grpc.RegisterPostServiceServer(s, server)

     // TagServiceの登録が抜けている!!!


    // Register reflection service on gRPC server.
    reflection.Register(s)
    log.Println("main grpc server has started")

    go func() {
        if err := s.Serve(lis); err != nil {
            log.Fatalf("failed to serve: %v", err)
        }
    }()

    ch := make(chan os.Signal, 1)
    signal.Notify(ch, os.Interrupt)

    // Block until a sgnal is received
    <-ch
    fmt.Println("Stopping the server")
    s.Stop()
    fmt.Println("Closing the client")
    lis.Close()
    fmt.Println("End of Program")
}

func makeServer() *grpc.Server {
    s := grpc.NewServer(
        grpc.UnaryInterceptor(grpc.UnaryServerInterceptor(transmitStatusInterceptor)),
    )

    return s
}

post_grpc.RegisterPostServiceServer で、PostServiceを登録はしているが
TagServiceも同様にgRPCサーバーに登録しなければならない。

次のコードを追加し、解決した。

server.go
     // PostServiceを serverに登録
    post_grpc.RegisterPostServiceServer(s, server)
    // 以下を追加
    // タグサービス登録
    post_grpc.RegisterTagServiceServer(s, server)

振り返り

今回、原因の特定にかなり時間を費やしてしまった。
clientとserverの間にenvoy Proxyを置いており、proxyの方に気を取られまくっていたので。。
悩んであれこれ試した割にはだいぶ初歩的なミスだった...。
grpcurl、これからは初手から使っていこう。

補足

マイクロサービス運用を想定しているgRPCサーバーの構築では、
1 service / 1 server の前提で書かれている記事が数多い印象があるが、必ずしも
全てのサービス毎に細くサーバーを分ける分ける必要もなく、上で実装しているように
それぞれRegisterServiceすれば運用に支障は無いと思っている。

関連性の高いサービス(たとえば、ユーザー登録サービスと認証サービスとか)は
この様に同一サーバーでの運用することが十分可能だと思う。

今後、認証サービスも書く予定なので試してみようと思う。

参考記事

gRPCサーバーの動作確認をgrpcurlでやってみた

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Go】実行・ビルド・パッケージのテストについて

一応 前提環境

・macOS
・goenv 2.0.0beta11
・go version go1.15.2 darwin/amd64

実行方法

今回は定番のHello Worldを実行します。

とりあえず$GOPATH直下にhello.goという名前でファイルを作成し、そこに下記のプログラムを書きます。

package main  //パッケージの宣言

import (  //使用するパッケージをインポート
    "fmt"
)

func main() {  //関数main(エントリーポイント)の定義
    fmt.Println("Hello World")  
}

そうしたらファイルを作成したディレクトリをターミナルで開き、$ go run hello.goを入力し、Hello Worldと表示されたら実行成功です。

ビルド

次はhello.goを実行ファイル形式にコンパイルします。

下記のようにbuildコマンドを入力、-oオプションを使用することで実行ファイル名を指定できます。

$ go build -o hello hello.go

するとhelloという実行ファイルができるので、ターミナルで下記のコマンドを入力するだけでHello Worldが実行できます。

$ ./hello
Hello World

パッケージのテスト

仮に下記のようなパッケージ構成になっているとして、testsディレクトリの配下のファイルたちをテストするとします。

また、依存モジュール管理ツール Modulesを使用しており、
go mod init github.com/username/testprojectにしてあります。
※go 1.13から取り込まれていますが、go 1.11から移行期ではありますが、export GO111MODULE=onにすることで使えるようになります。

testproject
│────── tests
│        │────testA
│        │────testB
│        │────testC
│        
│────── main.go

まず、testsディレクトリの配下に末尾が_test.go終わるようにファイルを作成します。これはパッケージをテストする際に決められたルールです。
例)tests_test.go

下記のようにtests_test.goファイルの内容を書きます。

package tests

import (
    "testing"

)

func TestJapaneseSubject(t *testing.T) {
    expect := "Japanese"
    actual := JapaneseSubject()


    if expect != actual {
        t.Errorf("%s != %s", expect, actual)
    }
}

func TestEnglishSubject(t *testing.T) {
    expect := "English"
    actual := EnglishSubject()


    if expect != actual {
        t.Errorf("%s != %s", expect, actual)
    }
}

func TestMathSubject(t *testing.T) {
    expect := "Math"
    actual := MathSubject()


    if expect != actual {
        t.Errorf("%s != %s", expect, actual)
    }
}

それではターミナルでコマンドを入力してテストを実行し、下記のように出力されれば成功です。

$ go test github.com/username/testproject/tests
ok      github.com/noa-1129/testproject/tests   0.506s

また、-vオプションをつけるとファイルごとに詳細が確認できます。

$ go test -v github.com/username/testproject/tests
=== RUN   TestJapaneseSubject
--- PASS: TestJapaneseSubject (0.00s)
=== RUN   TestEnglishSubject
--- PASS: TestEnglishSubject (0.00s)
=== RUN   TestMathSubject
--- PASS: TestMathSubject (0.00s)
PASS
ok      github.com/username/testproject/tests   0.230s

最後に

今回パッケージのテストでは依存モジュール管理ツールとしてGo Modulesを使ってテストをしましたが、Go modulesについては次の記事で書こうと思います!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Goには非短絡評価演算子がなかった

あまり意識することはなかったのですが、躓いてしまったのでメモ。
package, import文は省略しているので、完全なコードはリンク先をご確認ください。

TL;DR

Goには非短絡評価演算子がないため、関数を含む場合は

  • 事前に評価するか、
  • 関数を左辺に記述すべし

躓いた点

以下のようなコードがあるとします。

https://play.golang.org/p/en49-fRSNLU

func main() {
    funcCalled := true
    funcCalled = funcCalled || returnFalse()
    fmt.Println(funcCalled)
}

func returnFalse() bool {
    fmt.Println("returnFalse() called")
    return false
}
true

Program exited.

ここで returnFalse() を毎回 call したかったのですが、 "returnFalse() called" が出力されることはありませんでした。

これは || が短絡評価であり、左辺が true の場合に右辺は評価されずに処理が完了するためです。

よって funcCalled = funcCalled || returnFalse()

  • funcCalled := true のため
  • 右辺の returnFalse() が評価されずに処理を完了します

解決策

returnFalse() を call するには、一般的には

  1. 事前に関数をcallして評価しておく
  2. funcCalled = returnFalse() || funcCalled のように、関数callを先に記述する
  3. 短絡評価ではない論理演算子 | を使用する

のいずれかに修正します。

最初に1の、事前に関数をcallして評価しておく場合。

https://play.golang.org/p/EKTl6cQqsmx

func main() {
    funcCalled := true
    result := returnFalse() // 事前に評価
    funcCalled = result || funcCalled
    fmt.Println(funcCalled)
}

func returnFalse() bool {
    fmt.Println("returnFalse() called")
    return false
}

returnFalse() called
true

Program exited.

OKですね。

次に2の、関数callを先に記述した場合。

https://play.golang.org/p/Iq2jqGVE1j7

func main() {
    funcCalled := true
    funcCalled = returnFalse() || funcCalled // 関数callを先に記述
    fmt.Println(funcCalled)
}

func returnFalse() bool {
    fmt.Println("returnFalse() called")
    return false
}
returnFalse() called
true

Program exited.

良さそうです。

最後に3の短絡評価ではない演算子を使った場合。

https://play.golang.org/p/aNMVkYBw7LI

func main() {
    funcCalled := true
    funcCalled = funcCalled | returnFalse() // 短絡評価ではない演算子を使う
    fmt.Println(funcCalled)
}

func returnFalse() bool {
    fmt.Println("returnFalse() called")
    return false
}
./prog.go:9:26: invalid operation: funcCalled | returnFalse() (operator | not defined on bool)

Go build failed.

エラーとなってしまいました。定義されていないのでしょうか。

言語仕様を見てみましょう。

https://golang.org/ref/spec#Logical_operators

Logical operators apply to boolean values and yield a result of the same type as the operands. The right operand is evaluated conditionally.

&&    conditional AND    p && q  is  "if p then q else false"
||    conditional OR     p || q  is  "if p then true else q"
!     NOT                !p      is  "not p"

定義されていないようでした。

結論

Goには非短絡評価演算子がないため、関数を含む場合は

  • 事前に評価するか、
  • 関数を左辺に記述すべし
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Goには非短絡評価の論理演算子がなかった

あまり意識することはなかったのですが、躓いてしまったのでメモ。
package, import文は省略しているので、完全なコードはリンク先をご確認ください。

以下のコードのGoのバージョンは go1.14.9 です。

TL;DR

Goには非短絡評価の論理演算子がないため、関数を含む場合は

  • 事前に評価するか、
  • 関数を左辺に記述すべし

躓いた点

以下のようなコードがあるとします。

https://play.golang.org/p/en49-fRSNLU

func main() {
    funcCalled := true
    funcCalled = funcCalled || returnFalse()
    fmt.Println(funcCalled)
}

func returnFalse() bool {
    fmt.Println("returnFalse() called")
    return false
}
true

Program exited.

ここで returnFalse() を毎回 call したかったのですが、 "returnFalse() called" が出力されることはありませんでした。

これは || が短絡評価であり、左辺が true の場合に右辺は評価されずに処理が完了するためです。

よって funcCalled = funcCalled || returnFalse()

  • funcCalled := true のため
  • 右辺の returnFalse() が評価されずに処理を完了します

解決策

returnFalse() を call するには、一般的には

  1. 事前に関数をcallして評価しておく
  2. funcCalled = returnFalse() || funcCalled のように、関数callを先に記述する
  3. 短絡評価ではない論理演算子 | を使用する

のいずれかに修正します。

最初に1の、事前に関数をcallして評価しておく場合。

https://play.golang.org/p/EKTl6cQqsmx

func main() {
    funcCalled := true
    result := returnFalse() // 事前に評価
    funcCalled = result || funcCalled
    fmt.Println(funcCalled)
}

func returnFalse() bool {
    fmt.Println("returnFalse() called")
    return false
}

returnFalse() called
true

Program exited.

OKですね。

次に2の、関数callを先に記述した場合。

https://play.golang.org/p/Iq2jqGVE1j7

func main() {
    funcCalled := true
    funcCalled = returnFalse() || funcCalled // 関数callを先に記述
    fmt.Println(funcCalled)
}

func returnFalse() bool {
    fmt.Println("returnFalse() called")
    return false
}
returnFalse() called
true

Program exited.

良さそうです。

最後に3の短絡評価ではない論理演算子 | を使った場合。

https://play.golang.org/p/aNMVkYBw7LI

func main() {
    funcCalled := true
    funcCalled = funcCalled | returnFalse() // 短絡評価ではない演算子を使う
    fmt.Println(funcCalled)
}

func returnFalse() bool {
    fmt.Println("returnFalse() called")
    return false
}
./prog.go:9:26: invalid operation: funcCalled | returnFalse() (operator | not defined on bool)

Go build failed.

エラーとなってしまいました。定義されていないのでしょうか。

言語仕様を見てみましょう。

https://golang.org/ref/spec#Logical_operators

Logical operators apply to boolean values and yield a result of the same type as the operands. The right operand is evaluated conditionally.

&&    conditional AND    p && q  is  "if p then q else false"
||    conditional OR     p || q  is  "if p then true else q"
!     NOT                !p      is  "not p"

定義されていないようでした。

結論

Goには非短絡評価の論理演算子がないため、関数を含む場合は

  • 事前に評価するか、
  • 関数を左辺に記述すべし
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Goには完全評価演算子がなかった

あまり意識することはなかったのですが、躓いてしまったのでメモ。
package, import文は省略しているので、完全なコードはリンク先をご確認ください。

TL;DR

Goには完全評価論理演算子がないため、関数を含む場合は

  • 事前に評価するか、
  • 関数を左辺に記述すべし

躓いた点

以下のようなコードがあるとします。

https://play.golang.org/p/en49-fRSNLU

func main() {
    funcCalled := true
    funcCalled = funcCalled || returnFalse()
    fmt.Println(funcCalled)
}

func returnFalse() bool {
    fmt.Println("returnFalse() called")
    return false
}
true

Program exited.

ここで returnFalse() を毎回 call したかったのですが、 "returnFalse() called" が出力されることはありませんでした。

これは || が短絡評価であり、左辺が true の場合に右辺は評価されずに処理が完了するためです。

よって funcCalled = funcCalled || returnFalse()

  • funcCalled := true のため
  • 右辺の returnFalse() が評価されずに処理を完了します

解決策

returnFalse() を call するには、一般的には

  1. 事前に関数をcallして評価しておく
  2. funcCalled = returnFalse() || funcCalled のように、関数callを先に記述する
  3. 完全評価を行う論理演算子 | を使用する

のいずれかに修正します。

最初に1の、事前に関数をcallして評価しておく場合。

https://play.golang.org/p/EKTl6cQqsmx

func main() {
    funcCalled := true
    result := returnFalse() // 事前に評価
    funcCalled = result || funcCalled
    fmt.Println(funcCalled)
}

func returnFalse() bool {
    fmt.Println("returnFalse() called")
    return false
}

returnFalse() called
true

Program exited.

OKですね。

次に2の、関数callを先に記述した場合。

https://play.golang.org/p/Iq2jqGVE1j7

func main() {
    funcCalled := true
    funcCalled = returnFalse() || funcCalled // 関数callを先に記述
    fmt.Println(funcCalled)
}

func returnFalse() bool {
    fmt.Println("returnFalse() called")
    return false
}
returnFalse() called
true

Program exited.

良さそうです。

最後に3の完全評価演算子を使った場合。

https://play.golang.org/p/aNMVkYBw7LI

func main() {
    funcCalled := true
    funcCalled = funcCalled | returnFalse() // 完全評価演算子を使う
    fmt.Println(funcCalled)
}

func returnFalse() bool {
    fmt.Println("returnFalse() called")
    return false
}
./prog.go:9:26: invalid operation: funcCalled | returnFalse() (operator | not defined on bool)

Go build failed.

エラーとなってしまいました。定義されていないのでしょうか。

言語仕様を見てみましょう。

https://golang.org/ref/spec#Logical_operators

Logical operators apply to boolean values and yield a result of the same type as the operands. The right operand is evaluated conditionally.

&&    conditional AND    p && q  is  "if p then q else false"
||    conditional OR     p || q  is  "if p then true else q"
!     NOT                !p      is  "not p"

定義されていないようでした。

結論

Goには完全評価論理演算子がないため、関数を含む場合は

  • 事前に評価するか、
  • 関数を左辺に記述すべし
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

go言語学習雑記 1

ちょっと前にGo言語を勉強していて、備忘録がてらその時のメモを書いていきます。

パッケージ

goはパッケージと言う単位で構成され、その中の関数や変数の単位でimport,exportをしていきます。
できるかどうかは、文字の大文字小文字で判別されています。

 package main

 import (
    "fmt"
    "math"
 )

 func main() {
    fmt.Println(math.Pi) //OK 
    fmt.Println(math.pi)  // error
 }

大文字の場合はexport されず
小文字の場合はexport されます。

変数宣言

変数は var <変数名> <型名> の形で宣言されます。
また分割代入っぽくかけます。

セイウチ演算子(:=)を使うと型推論を効かせて型名を省略して宣言できます。その場合はの宣言は <変数名> := <値> となります

定数は const <変数名> = <値> となります。 定数の場合は勝手に型推論が働きます。

 var i, j int = 1, 2
 test := "string" // 型推論を効かせて変数初期化
 const Pi = 3.14 // 定数、この場合型推論を効かせて定数初期化はできない

基本型

go は zero valueと言う機能があり、特定の型で値が代入されなかった場合自動的に値が代入されます。
なので、値を入れ忘れてがnullになってると言うケースを防ぐことができます。

 bool // zero value はfalse

 string // zero value は ""

 int  int8  int16  int32  int64 // zero value は0
 uint uint8 uint16 uint32 uint64 uintptr

 byte // uint8 の別名

 rune // int32 の別名
      // Unicode のコードポイントを表す

 float32 float64

 complex64 complex128

なお、なぜか基本型に複素数があります。

型変換

<型名>(<変数名>)で型変換ができる

 var i int = 42
 var f float64 = float64(i)
 var u uint = uint(f)
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

golangでbashを呼び出す

コマンド結果を受け取らず、実行完了まで待機

  • Run
package main

import (
    "fmt"
    "os/exec"
)

func main() {
    err := exec.Command("pwd").Run()
    if err != nil {
        fmt.Println("Command Exec Error.")
    }
}

コマンド結果を受け取る

  • Output
package main

import (
    "fmt"
    "os/exec"
)

func main() {
    out, err := exec.Command("pwd").Output()
    if err != nil {
        fmt.Println("Command Exec Error.")
    }

    // 実行したコマンドの結果を出力
    fmt.Printf("pwd result: %s", string(out))
}

標準出力エラーを受け取る

- CombinedOutput

package main

import (
    "fmt"
    "os/exec"
)

func main() {
    out, err := exec.Command("ls", "dummy").CombinedOutput()
    if err != nil {
        fmt.Println("Command Exec Error.")
    }

    // 実行したコマンドの標準出力+標準エラー出力の内容
    fmt.Printf("ls result: \n%s", string(out))
}

コマンド実行終了を待たない

  • start
  • waitとの組み合わせで待つことができる
package main

import (
    "fmt"
    "os/exec"
)

func main() {
    cmd := exec.Command("sleep", "10")

    fmt.Println("Command Start.")

    err := cmd.Start()
    if err != nil {
        fmt.Println("Command Exec Error.")
    }

    //コメント外すと待機する
    //cmd.Wait()

    fmt.Println("Command Exit.")
}

参考

https://blog.y-yuki.net/entry/2017/04/28/000000

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

go-templateはチューリング完全?brainf*ck処理系を作ってみた

TL;DR

  • go-templateの標準関数/構文だけでbrainf*ck処理系を実装
  • kubectlをbrainf*ckインタープリターとして利用(パワーワード)
    • podのマニフェストにbrainf*ckコード記載
    • kubectlでpod情報取得時に、brainf*ckインタープリターのgo-templateにかける
  • リポジトリ: go-template-bf-interpreter
  • 実用性は... :smirk:

はじめに

C++のテンプレート、Pythonの内包表記、...有り余る自由度がもたらす黒魔術により、単体でチューリング完全になってしまった言語機能があります。

そして、ここにgo-templateが黒魔術の仲間入りを果たしたことを宣言します

go-templateといえば、ginのHTMLやkubectlのレスポンス成形等のテンプレート用ミニ言語というイメージが強いですが(というか名前的にそういう意図で作られていますが)、以下のことが全て可能です1

  • rangeブロックによるコレクションのループ
  • ifブロックによる条件分岐
  • 変数の宣言、更新(再代入)

参考(公式):template - GoDoc

というわけで、チューリング完全性の登竜門、brainf*ckの処理系をgo-template(Go言語に頼らず、標準関数/構文だけ2)で実装してみました。

動作方法

kubectlを使用します。

podのマニフェストファイルにbrainf*ckのソースコードを入れ、kubectlで取得する際にgo-templateを利用し成形(=go-templateのbrainf*ckインタープリターで評価)するという仕組みをとっています。

リポジトリ:go-template-bf-interpreter

kubectlを使用したのは、kubectlではじめてgo-templateを知ったからです。冒頭で「ginのHTML生成でおなじみ」などと書いていましたがエアプですすみません

マニフェストファイルにbrainf*ckソースコードを格納

metadata.annotationsには任意のキーと値(文字列)を格納できるのでここにソースコードを格納します。ついでにbrainf*ckへの標準入力も入れておきましょう。

hello.yaml
metadata:
  name: bf-source-pod
# add dummies to make annotations long enough to loop (see bf-interpreter.tpl for details)
  annotations:
# used for bf stdin
    input: ""
# bf source code
    src: >
      +++++++++[>++++++++>+++++++++++>+++>+<<<<-]>.>++.+++++++..+++.
      >+++++.<<+++++++++++++++.>.+++.------.--------.>+.>+.
    dummy1: dummy1
    dummy2: dummy2
    #...

ちなみに、ダミーのキーを大量に入れているのは、インタープリターのループ回数を稼ぐためです(後述)。

helloworldのコードは Brainfuck 超入門 - Qiita のものを使用させていただきました。

podのコンテナはどうせ使わないので何でもいいです。とりあえず起動が速いalpineイメージにしておきました。

実行の流れ

# k8sクラスターを構築 (例はkind)
$ kind create cluster

# 上記のhelloworldコードが入ったpodを作成
$ kubectl create -f hello.yaml
pod/bf-source-pod created

# pod情報(=ソースコード)を取得し、その内容をインタープリターとなるgo-templateで評価
$ kubectl get pods -o go-template-file=bf-interpreter.tpl
Hello World!

go-templateプログラミングデザインパターン(?)

brainf*ckインタープリターの実装はこんな感じです。インデント地獄。
bf-interpreter.tpl

以下、使用した小技について紹介していきます。

空白を詰める

{{}}の両端に-を付けると、かっこの外側の空白を詰めることが出来ます。これを使えば、{{}}の外側でインデント、改行を入れてもすべて無視されます。
go-templateプログラミングでは可読性のために必須です付けないとワンライナー縛りが始まります

  • ハイフンなし
withspace.tpl
{{if true}}
    {{println "got it!"}}
{{else}}
    {{println "no..."}}
{{end}}
無駄なスペースがそのまま出力される
 kubectl get pods -o go-template-file=withspace.tpl

    got it!

  • ハイフンあり
trimspace.tpl
{{- if true -}}
    {{- println "got it!" -}}
{{- else -}}
    {{- println "no..." -}}
{{- end -}}
無駄なスペースは消える
$ kubectl get pods -o go-template-file=trimspace.tpl
got it!

ループ

brainf*ckにはwhileループが必要です。ソースコード各文字のパースや[,]評価時のジャンプに使用します。

しかし、残念ながらstringrangeでイテレーションできません。
さらに、go-templateで作成できるリテラルは定数のみなので、配列やマップを新たに作ることもできません。

$ kubectl get pods -o go-template --template '{{range $c := "abc"}}{{println $c}}{{end}}'
...
error: error executing template "{{range $c := \"abc\"}}{{println $c}}{{end}}": template: output:1:14: executing "output" at <"abc">: range can't iterate over abc

そこで、pod情報のmetadata.annotations (map[string]string)をrangeでのループに使用します。アノテーションにダミーを混ぜて、16回ループできるようにしています。

hello.yaml
metadata:
  name: bf-source-pod
  annotations:
# used for bf stdin
    input: ""
# bf source code
    src: >
      +++++++++[>++++++++>+++++++++++>+++>+<<<<-]>.>++.+++++++..+++.
      >+++++.<<+++++++++++++++.>.+++.------.--------.>+.>+.
    dummy1: dummy1
    dummy2: dummy2
    #...
    dummy14: dummy14

このループを多段に使うことで、メモリの初期化やソースコードのパースを行っています。

bf-interpreter.tpl
{{- /* mapを代入し、rangeブロックに使用 */ -}}
{{- $Looper := (index .items 0).metadata.annotations -}}

{{- /* メモリの初期化 (len $Looper)^2バイトを0埋め) */ -}}
{{- $memory := "" -}}
{{- range $Looper -}}
    {{- range $Looper -}}
        {{- $memory = print $memory "\x00" -}}
    {{- end -}}
{{- end -}}

{{- /* ソースコードの読み込み (len $Looper)^3文字を頭からパース) */ -}}
{{- range $Looper -}}
    {{- range $Looper -}}
        {{- range $Looper -}}
            {{- /* NOTE: exists is implemented only in k8s parser */ -}}
            {{- if exists $Source (len $parsingBytePos) -}}
                {{- $tokenByte := index $Source (len $parsingBytePos) -}}
                {{- $token := printf "%c" $tokenByte -}}

                {{- /* トークンを評価(省略) */ -}}

                {{- /* increment pos */ -}}
                {{- $parsingBytePos = print $parsingBytePos " " -}}
            {{- end -}}
        {{- end -}}
    {{- end -}}
{{- end -}}

ちなみに、16回ループしているのはインタープリターのスペックのキリを良くするためです。

  • メモリサイズ: 256byte ($Looper 2段ループ)
  • パース可能ソースコード長上限: 4096文字 ($Looper 3段ループ)

加算、減算

残念ながら(2度目)、go-templateには整数の加算、減算の関数、演算子がありません。
しかし、brainf*ckにはメモリの値やポインタの更新の際加算、減算が必要です。

そこで、文字列の長さを整数の代わりに使用します。
文字列は結合、スライシングにより長さを変えることができ、len関数で長さを整数で取得できます。

  • 加算
inc.tpl
{{- /* go-templateのprintはGoのSprintに相当(副作用はない) */ -}}
{{- $numStr := " " -}}
{{- println (len $numStr) -}}
{{- $numStr = print $numStr " " -}}
{{- println (len $numStr) -}}
$ kubectl get pods -o go-template-file=inc.tpl
1
2
  • 減算
dec.tpl
{{- $numStr := " " -}}
{{- println (len $numStr) -}}
{{- $numStr = slice $numStr 1 -}}
{{- println (len $numStr) -}}
$ kubectl get pods -o go-template-file=dec.tpl
1
0

メモリの更新

前述の通りgo-templateでは配列を作成できません。また、既存オブジェクトの要素のみ更新することもできません。代入式の左辺値になれるのは変数のみです。

$ kubectl get pods -o go-template --template '{{(index .items 0) := "hoge"}}'
error: error parsing template {{(index .items 0) := "hoge"}}, template: output:1: unexpected ":=" in operand

そこで、文字列をメモリとして利用します。
Goでは文字列のインデックスを取る際文字列を[]byteとして扱うので、文字列自体をバイト列とみなすことが出来ます。

go言語のindex
s := "abc"
fmt.Println([]byte(s)) // [97 98 99]
fmt.Println(s[0]) // 97
fmt.Println([]byte(s)[0]) // 97

そして、+-等で文字列中のあるバイトのみ更新する場合は、「当該バイトのみ置き換えた新しいメモリ文字列」を作成しています。

bf-interpreter.tpl
{{- else if eq $token "+" -}}
    {{- /* ...参照アドレスの値を取り出しインクリメント(省略) */ -}}

    {{- /* メモリの更新 */ -}}
    {{- /* 参照アドレスのみ置き換えた新しいメモリで置き換える */ -}}

    {{- /* 参照アドレスより前のメモリ */ -}}
    {{- $former := slice $memory 0 (len $memoryPtr) -}}

    {{- /* 参照アドレスより後のメモリ */ -}}
    {{- /* NOTE: (len (print $memoryPtr " ") は参照アドレス+1 */ -}}
    {{- $latter := slice $memory (len (print $memoryPtr " ")) -}}

    {{- /* 置換(バイトの値をそのままprintすると整数が文字列化されてしまうので、printfで対応するアスキーコードの文字に変換) */ -}}
    {{- $memory = print $former (printf "%c" $incrementedValue) $latter -}}
{{- end -}}

おわりに

以上、go-templateのbrainf*ck処理系の紹介でした。

Let's go-templateプログラミング!


  1. これだけ見ると「そんなんerbでもできるわ!」という感じですが、erbはRubyの式を何でも書ける一方、go-templateは独立した文法(スカラーしか作れず、goの標準関数もそのままでは呼び出せない)という縛りがあります... 

  2. 一部、k8s独自のgo-template関数(index)は使っています。実装は こちら 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

GraphQL(gqlgen)エラーハンドリング

お題

Type-safe GraphQL for Goを謳うGolang製GraphQLライブラリであるgqlgenを使って、GraphQL Server側のエラーハンドリングについて検討。

想定する読者

  • Golangについてある程度書ける。
  • 「GraphQL is 何?」ではない。
  • gqlgenの getting-started で初期セットアップくらいはやったことがある。

関連記事索引

開発環境

# OS - Linux(Ubuntu)

$ cat /etc/os-release 
NAME="Ubuntu"
VERSION="18.04.5 LTS (Bionic Beaver)"

# バックエンド

# 言語 - Golang

$ go version
go version go1.15.2 linux/amd64

# gqlgen

v0.13.0

IDE - Goland

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

今回の全ソース

https://github.com/sky0621/study-gqlgen/tree/v0.2

実践

gqlgenを使ってサーバーサイド側でどうGraphQLのエラーハンドリングをするべきか、いくつかの方法を試行してみる。

1.ベーシックな方法でハンドリング

いくつかのパターンを列挙する。

server.go

package main

import (
    "log"
    "net/http"

    "github.com/99designs/gqlgen/graphql/handler"
    "github.com/99designs/gqlgen/graphql/playground"
    "github.com/sky0621/study-gqlgen/errorhandling/graph"
    "github.com/sky0621/study-gqlgen/errorhandling/graph/generated"
)

func main() {
    srv := handler.NewDefaultServer(generated.NewExecutableSchema(generated.Config{Resolvers: &graph.Resolver{}}))
    http.Handle("/", playground.Handler("GraphQL playground", "/query"))
    http.Handle("/query", srv)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

schema.graphqls

type Query {
  normalReturn: [Todo!]!
  errorReturn: [Todo!]!
  customErrorReturn: [Todo!]!
  customErrorReturn2: [Todo!]!
  customErrorReturn3: [Todo!]!
  customErrorReturn4: [Todo!]!
  panicReturn: [Todo!]!
}

type Todo {
  id: ID!
  text: String!
}

リゾルバー

schema.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"
    "errors"
    "fmt"
    "time"

    "github.com/99designs/gqlgen/graphql"
    "github.com/sky0621/study-gqlgen/errorhandling/graph/generated"
    "github.com/sky0621/study-gqlgen/errorhandling/graph/model"
    "github.com/vektah/gqlparser/v2/gqlerror"
)

func (r *queryResolver) NormalReturn(ctx context.Context) ([]*model.Todo, error) {
    return []*model.Todo{
        {ID: "001", Text: "something1"},
        {ID: "002", Text: "something2"},
    }, nil
}

func (r *queryResolver) ErrorReturn(ctx context.Context) ([]*model.Todo, error) {
    return nil, errors.New("error occurred")
}

func (r *queryResolver) CustomErrorReturn(ctx context.Context) ([]*model.Todo, error) {
    return nil, gqlerror.Errorf("custom error")
}

func (r *queryResolver) CustomErrorReturn2(ctx context.Context) ([]*model.Todo, error) {
    graphql.AddError(ctx, gqlerror.Errorf("add error"))
    graphql.AddErrorf(ctx, "add error2: %s", time.Now().String())
    return nil, nil
}

func (r *queryResolver) CustomErrorReturn3(ctx context.Context) ([]*model.Todo, error) {
    return nil, &gqlerror.Error{
        Extensions: map[string]interface{}{
            "code":  "A00001",
            "field": "text",
            "value": "トイレ掃除",
        },
    }
}

func (r *queryResolver) CustomErrorReturn4(ctx context.Context) ([]*model.Todo, error) {
    return nil, &gqlerror.Error{
        Extensions: map[string]interface{}{
            "errors": []map[string]interface{}{
                {
                    "code":  "A00001",
                    "field": "text",
                    "value": "トイレ掃除",
                },
                {
                    "code":  "A00002",
                    "field": "text",
                    "value": "トイレ掃除",
                },
            },
        },
    }
}

func (r *queryResolver) PanicReturn(ctx context.Context) ([]*model.Todo, error) {
    panic(fmt.Errorf("panic occurred"))
}

// Query returns generated.QueryResolver implementation.
func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} }

type queryResolver struct{ *Resolver }

パターン別の解説

参考

GraphQLにおけるレスポンスに関して。
正常系の場合、以下のような構造になる。

{
  "data": {
      〜〜〜〜
  }
}

リゾルバーで何かしらエラーを返すと、以下のような構造になる。

{
  "errors": [
    {
      "message": 〜〜〜〜,
      "path": [〜〜〜〜]
    }
  ],
  "data": null
}

その他、以下、参考にされたし。
https://gqlgen.com/reference/errors/

■正常系

func (r *queryResolver) NormalReturn(ctx context.Context) ([]*model.Todo, error) {
    return []*model.Todo{
        {ID: "001", Text: "something1"},
        {ID: "002", Text: "something2"},
    }, nil
}

正常系。設定したデータが返される。
screenshot-localhost_8080-2020.10.17-23_48_21.png

■go標準のエラーを返すパターン

func (r *queryResolver) ErrorReturn(ctx context.Context) ([]*model.Todo, error) {
    return nil, errors.New("error occurred")
}

指定したエラーメッセージがmessageに積まれている。
pathは勝手に付与される。
screenshot-localhost_8080-2020.10.17-23_51_16.png

■gqlgenが用意したメソッドを介してエラーを返すパターン

func (r *queryResolver) CustomErrorReturn(ctx context.Context) ([]*model.Todo, error) {
    return nil, gqlerror.Errorf("custom error")
}

やはり、指定したエラーメッセージがmessageに積まれている。
構造はgo標準のエラーを返すパターンと同じ。
screenshot-localhost_8080-2020.10.18-00_08_41.png

■複数のエラーを返すパターン

func (r *queryResolver) CustomErrorReturn2(ctx context.Context) ([]*model.Todo, error) {
    graphql.AddError(ctx, gqlerror.Errorf("add error"))
    graphql.AddErrorf(ctx, "add error2: %s", time.Now().String())
    return nil, nil
}

指定した2種類のエラーがそれぞれのmessageに積まれている。
これまでのエラー発生時と違い、datanullではなく空スライスが返されているのが、やや気になる。
(おそらくだけど、returnでerrorを返さなかったためかな。)
screenshot-localhost_8080-2020.10.18-00_19_32.png

■個別拡張領域を使うパターン

func (r *queryResolver) CustomErrorReturn3(ctx context.Context) ([]*model.Todo, error) {
    return nil, &gqlerror.Error{
        Extensions: map[string]interface{}{
            "code":  "A00001",
            "field": "text",
            "value": "トイレ掃除",
        },
    }
}

messageには何も積まず、用意されたextensionsにサービス固有の表現でエラー内容を定義する。
map[string]interface{}なので任意の構造が使える。
これにより、レスポンスを受けたフロントエンド側でcodeに応じたエラーメッセージの生成とエンドユーザーへの表示などが可能になる。
screenshot-localhost_8080-2020.10.18-00_20_47.png

■個別拡張領域を使うパターン2

func (r *queryResolver) CustomErrorReturn4(ctx context.Context) ([]*model.Todo, error) {
    return nil, &gqlerror.Error{
        Extensions: map[string]interface{}{
            "errors": []map[string]interface{}{
                {
                    "code":  "A00001",
                    "field": "text",
                    "value": "トイレ掃除",
                },
                {
                    "code":  "A00002",
                    "field": "text",
                    "value": "トイレ掃除",
                },
            },
        },
    }
}

返したいエラーは1つとは限らない。このようにマップのスライスという形で保持すれば複数のエラーを返すことも当然可能。
screenshot-localhost_8080-2020.10.18-00_24_29.png

■panicが発生した時のパターン

func (r *queryResolver) PanicReturn(ctx context.Context) ([]*model.Todo, error) {
    panic(fmt.Errorf("panic occurred"))
}

panic発生時に積まれたメッセージは無視してinternal system errormessageには積まれる。
screenshot-localhost_8080-2020.10.18-00_28_03.png

2.カスタマイズしたエラーハンドリング

よっぽど小規模なサービスでない限り、サービス固有のエラーハンドリング表現が必要になってくると思う。
gqlgenではhandler生成時に「エラー発生時」と「panic発生時」にフックして処理を追加する仕掛けがある。
この仕掛けを利用して、
リゾルバーからは(エラー発生時)サービス固有に定義したエラー構造体を返し、handlerでフックして、エラー構造体を加工してレスポンスとする実装をしてみる。

schema.graphqls

type Query {
  errorPresenter: [Todo!]!
  panicHandler: [Todo!]!
}

type Todo {
  id: ID!
  text: String!
}

schema.resolvers.go

サービス固有のエラー構造体としてAppErrorを作成。リゾルバーからはその構造体を返却。

package graph

import (
    "context"
    "fmt"

    "github.com/sky0621/study-gqlgen/errorhandling2/graph/generated"
    "github.com/sky0621/study-gqlgen/errorhandling2/graph/model"
)

type ErrorCode string

const (
    ErrorCodeRequired            ErrorCode = "1001"
    ErrorCodeUnexpectedSituation ErrorCode = "9999"
)

type AppError struct {
    Code ErrorCode
    Msg  string
}

func (e AppError) Error() string {
    return fmt.Sprintf("[%s]%s", e.Code, e.Msg)
}

func (r *queryResolver) ErrorPresenter(ctx context.Context) ([]*model.Todo, error) {
    return nil, AppError{
        Code: ErrorCodeRequired,
        Msg:  "text is none",
    }
}

func (r *queryResolver) PanicHandler(ctx context.Context) ([]*model.Todo, error) {
    panic("unexpected situation")
}

// Query returns generated.QueryResolver implementation.
func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} }

type queryResolver struct{ *Resolver }

server.go

SetErrorPresenter()でセットする関数の中で、リゾルバーで投げたエラーを受け取り、AppErrorだったら*gqlerror.Error{}の構造に編集し直している。
ちなみに、SetRecoverFunc()も用意してpanic発生時もサービス固有の想定したエラー表現になるよう編集している。

package main

import (
    "context"
    "errors"
    "log"
    "net/http"

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

    "github.com/99designs/gqlgen/graphql/handler"
    "github.com/99designs/gqlgen/graphql/playground"
    "github.com/sky0621/study-gqlgen/errorhandling2/graph"
    "github.com/sky0621/study-gqlgen/errorhandling2/graph/generated"
)

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

    srv.SetErrorPresenter(func(ctx context.Context, e error) *gqlerror.Error {
        err := graphql.DefaultErrorPresenter(ctx, e)

        var appErr graph.AppError
        if errors.As(err, &appErr) {
            return &gqlerror.Error{
                Message: appErr.Msg,
                Extensions: map[string]interface{}{
                    "code": appErr.Code,
                },
            }
        }
        return err
    })

    srv.SetRecoverFunc(func(ctx context.Context, err interface{}) error {
        return &gqlerror.Error{
            Extensions: map[string]interface{}{
                "code":  graph.ErrorCodeUnexpectedSituation,
                "cause": err,
            },
        }
    })

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

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

動作確認

エラーハンドリング

うまいこと、エラーコードとエラーメッセージの振り分けが出来ている。
screenshot-localhost_8080-2020.10.18-00_43_38.png

panicハンドリング

causeとしてpanic発生時のエラーを積むようにしたため、ちゃんとレスポンスに発生元のエラー内容も乗るようになった。
screenshot-localhost_8080-2020.10.18-00_45_38.png

3.汎用性を考慮してカスタマイズしたエラーハンドリング

エラーと一口に言っても、バリデーションエラー、認証系のエラー、DB接続エラー、等々、いろんな種類があり、エラー構造として必要な要素も変わってくると思う。
単一のエラーで返せばいい場合もあれば、(バリデーションエラーのように)エラー要素1つ1つが必要で、結果として複数のエラーを返す必要がある場合もある。
こういった状況を踏まえ、なるべく汎用的にエラーハンドリングすることを試みる。

サービス固有のエラー構造

apperror.go
package graph

import (
    "context"
    "net/http"

    "github.com/vektah/gqlparser/v2/gqlerror"

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

type AppError struct {
    httpStatusCode int          // http.StatusCodeXXXXXXX を入れる
    appErrorCode   AppErrorCode // サービス固有に定義したエラーコード

    /*
     * 以下、全てのエラー表現に必須ではない要素(オプションとして設定可能)
     */
    field string
    value string
}

func (e *AppError) AddGraphQLError(ctx context.Context) {
    extensions := map[string]interface{}{
        "status_code": e.httpStatusCode,
        "error_code":  e.appErrorCode,
    }
    if e.field != "" {
        extensions["field"] = e.field
    }
    if e.value != "" {
        extensions["value"] = e.value
    }
    graphql.AddError(ctx, &gqlerror.Error{
        Message:    "",
        Extensions: extensions,
    })
}

func NewAppError(httpStatusCode int, appErrorCode AppErrorCode, opts ...AppErrorOption) *AppError {
    a := &AppError{
        httpStatusCode: httpStatusCode,
        appErrorCode:   appErrorCode,
    }

    for _, o := range opts {
        o(a)
    }

    return a
}

// 認証エラー用
func NewAuthenticationError(opts ...AppErrorOption) *AppError {
    return NewAppError(http.StatusUnauthorized, AppErrorCodeAuthenticationFailure, opts...)
}

// 認可エラー用
func NewAuthorizationError(opts ...AppErrorOption) *AppError {
    return NewAppError(http.StatusForbidden, AppErrorCodeAuthorizationFailure, opts...)
}

// バリデーションエラー用
func NewValidationError(field, value string, opts ...AppErrorOption) *AppError {
    options := []AppErrorOption{WithField(field), WithValue(value)}
    for _, opt := range opts {
        options = append(options, opt)
    }
    return NewAppError(http.StatusBadRequest, AppErrorCodeValidationFailure, options...)
}

// その他エラー用
func NewInternalServerError(opts ...AppErrorOption) *AppError {
    return NewAppError(http.StatusInternalServerError, AppErrorCodeUnexpectedFailure, opts...)
}

type AppErrorCode string

// MEMO: サービスの定義によっては意味のある文字列よりもコード体系を決めるのもあり。
const (
    // 認証エラー
    AppErrorCodeAuthenticationFailure AppErrorCode = "AUTHENTICATION_FAILURE"
    // 認可エラー
    AppErrorCodeAuthorizationFailure AppErrorCode = "AUTHORIZATION_FAILURE"
    // バリデーションエラー
    AppErrorCodeValidationFailure AppErrorCode = "VALIDATION_FAILURE"

    // その他の予期せぬエラー
    AppErrorCodeUnexpectedFailure AppErrorCode = "UNEXPECTED_FAILURE"
)

type AppErrorOption func(*AppError)

func WithField(v string) AppErrorOption {
    return func(a *AppError) {
        a.field = v
    }
}

func WithValue(v string) AppErrorOption {
    return func(a *AppError) {
        a.value = v
    }
}

解説

まず、サービス固有のエラー構造体としてAppErrorを作成。
エラー要素として何を持つかはサービスによりまちまちだとは思うけど、とりあえず以下2つはエラーの内容問わず必須として定義。

  • HTTPステータスコード
  • サービス固有のエラーコード
type AppError struct {
    httpStatusCode int          // http.StatusCodeXXXXXXX を入れる
    appErrorCode   AppErrorCode // サービス固有に定義したエラーコード
     〜〜
}

func NewAppError(httpStatusCode int, appErrorCode AppErrorCode, opts ...AppErrorOption) *AppError {
    a := &AppError{
        httpStatusCode: httpStatusCode,
        appErrorCode:   appErrorCode,
    }
     〜〜
}

続いて、例えばバリデーションエラーのように「どのフィールドのどの値が」という情報も欲しくなるようなケースのために構造体内には(冗長でも)パターン別に必要な要素を持たせるようにする。

type AppError struct {
     〜〜
    /*
     * 以下、全てのエラー表現に必須ではない要素(オプションとして設定可能)
     */
    field string
    value string
}

ただし、今後、こういった要素の追加が必要になるたびにNew関数を修正(つまり、呼び元も全て修正)なんてしたくないので、Functional Option Patternを用いることにする。

オプション適用のための関数を定義し、New関数では可変引数で渡す(つまり、ないならないでOK)ようにする。

type AppErrorOption func(*AppError)

func NewAppError(httpStatusCode int, appErrorCode AppErrorCode, opts ...AppErrorOption) *AppError {
    a := &AppError{
        httpStatusCode: httpStatusCode,
        appErrorCode:   appErrorCode,
    }

    for _, o := range opts {
        o(a)
    }

    return a
}

で、AppErrorOptionの適用事例として以下2つを用意。

func WithField(v string) AppErrorOption {
    return func(a *AppError) {
        a.field = v
    }
}

func WithValue(v string) AppErrorOption {
    return func(a *AppError) {
        a.value = v
    }
}

こうすることで、今後、エラー構造体に追加したい要素が増えても、既存の呼び出し元を修正する必要なく拡張できる。

(説明が雑なのが一番の理由だけど)初見でこの仕組みを理解するのは、けっこうキツいと思うので、「Functional Option Pattern」でググってもらって易しい解説記事を読んでもらいたい。。。

あとは、サービス固有のエラーコードを以下のように定義して、

type AppErrorCode string

// MEMO: サービスの定義によっては意味のある文字列よりもコード体系を決めるのもあり。
const (
    // 認証エラー
    AppErrorCodeAuthenticationFailure AppErrorCode = "AUTHENTICATION_FAILURE"
    // 認可エラー
    AppErrorCodeAuthorizationFailure AppErrorCode = "AUTHORIZATION_FAILURE"
    // バリデーションエラー
    AppErrorCodeValidationFailure AppErrorCode = "VALIDATION_FAILURE"

    // その他の予期せぬエラー
    AppErrorCodeUnexpectedFailure AppErrorCode = "UNEXPECTED_FAILURE"
)

エラーのタイプ別に専用のNew関数でも用意してあげればOK。

// 認証エラー用
func NewAuthenticationError(opts ...AppErrorOption) *AppError {
    return NewAppError(http.StatusUnauthorized, AppErrorCodeAuthenticationFailure, opts...)
}

// 認可エラー用
func NewAuthorizationError(opts ...AppErrorOption) *AppError {
    return NewAppError(http.StatusForbidden, AppErrorCodeAuthorizationFailure, opts...)
}

// バリデーションエラー用
func NewValidationError(field, value string, opts ...AppErrorOption) *AppError {
    options := []AppErrorOption{WithField(field), WithValue(value)}
    for _, opt := range opts {
        options = append(options, opt)
    }
    return NewAppError(http.StatusBadRequest, AppErrorCodeValidationFailure, options...)
}

// その他エラー用
func NewInternalServerError(opts ...AppErrorOption) *AppError {
    return NewAppError(http.StatusInternalServerError, AppErrorCodeUnexpectedFailure, opts...)
}

リゾルバー

試しに、タイプ別にエラーを生成してGraphQLエラーとして追加してあげるとこんな感じ。
(認証エラーなんかは当然、ユーザーIDなどを積んだりするだろうけど、とりあえずはサンプルなので。)

schema.resolvers.go
package graph

import (
    "context"

    "github.com/sky0621/study-gqlgen/errorhandling3/graph/generated"
    "github.com/sky0621/study-gqlgen/errorhandling3/graph/model"
)

func (r *queryResolver) CustomErrorReturn(ctx context.Context) ([]*model.Todo, error) {
    // 認証エラーを追加
    NewAuthenticationError().AddGraphQLError(ctx)

    // 認可エラーを追加
    NewAuthorizationError().AddGraphQLError(ctx)

    // バリデーションエラーを追加
    NewValidationError("name", "taro").AddGraphQLError(ctx)

    // その他のエラーを追加
    NewInternalServerError().AddGraphQLError(ctx)

    return nil, nil
}

// Query returns generated.QueryResolver implementation.
func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} }

type queryResolver struct{ *Resolver }

schema.graphqls

type Query {
  customErrorReturn: [Todo!]!
}

type Todo {
  id: ID!
  text: String!
}

server.go

今回はhandlerへの仕込みは無し。

package main

import (
    "log"
    "net/http"

    "github.com/99designs/gqlgen/graphql/handler"
    "github.com/99designs/gqlgen/graphql/playground"
    "github.com/sky0621/study-gqlgen/errorhandling3/graph"
    "github.com/sky0621/study-gqlgen/errorhandling3/graph/generated"
)

func main() {
    srv := handler.NewDefaultServer(generated.NewExecutableSchema(generated.Config{Resolvers: &graph.Resolver{}}))
    http.Handle("/", playground.Handler("GraphQL playground", "/query"))
    http.Handle("/query", srv)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

動作確認

この通り、統一されたフォーマットになっているので、レスポンスを受け取る側でのハンドリングもしやすいはず。。。
screenshot-localhost_8080-2020.10.18-01_22_59.png

まとめ

とりあえずエラーが返せればいいといったシンプルなものから、一応、汎用性を考慮してサービス固有のエラー構造を定義したやり方まで複数のエラーハンドリング案を提示してみた。
もちろん、ここにあるパターン以外にもあるだろうし、ここにあげたものはプロダクションレベルとしては心もとない。
1サービスとして考えるなら、ここで返したエラー内容をフロントエンドではどのようにハンドリングするかも重要な要素だと思う。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む