- 投稿日:2020-05-24T23:56:28+09:00
GitHub Wikiの目次(ToC)をURLから作成してくれるツールをGo+Ginで作る
GitHubのWikiを充実させたい => 目次手書きで書くのが面倒!
ということでGo + Ginを使ってToCジェネレータを作成しました。
GitHub WikiのURLを入力するとToC(Table of Contents(目次))を作成してくれます。
デプロイは Zeit Vercel(旧 Zeit Now)で行おうとしたのですがうまくいきませんでした。(このサービスめっちゃ好きなので残念)
https://github.com/yousan/toc-generator/
Gin
Goのウェブ用フレームワークです。ルーティングやテンプレートなどの機能が揃っています。
go getでインストールします。
$ go get github.com/gin-gonic/ginコード
いくつかの機能に分けて実装を進めます。
URL変換
GitHub WikiのURLを生のマークダウンで落とせる形式に変換します。
URLの一部を入れ替え、末尾に.md
を付与します。/** * GitHub WikiのURLをパースして生のMarkdownが取得できるURLにする */ func ParseUrl(ctx *gin.Context) string { urlstr, nil := getPostUrl(ctx) u, err := url.Parse(urlstr) if err != nil { panic(err) } // この形式に変換する https://raw.github.com/wiki/user/repo/page.md?login=login&token=token path := ConvertWikiUrl(u.Path) rawUrl := "https://raw.github.com/wiki" + path + ".md" fmt.Println(rawUrl) return rawUrl }マークダウンデータ取得
http.Get()
を使いデータを拾ってきます。/* * URLデータを読み込む */ func getContent(url string) string { resp, _ := http.Get(url) defer resp.Body.Close() byteArray, _ := ioutil.ReadAll(resp.Body) return string(byteArray) }マークダウンのUL形式に変換
取得したマークダウンデータを行ごとに分割し、正規表現でヘディング(#で始まる行)を拾い出します。
func ParseMarkdownToUl(content string) []string { var ret []string var s = strings.Split(content, "\n") for i := 0; i < len(s); i ++ { r := regexp.MustCompile(`^(#+)(.*)$`) if r.Match([]byte(s[i])) { // マッチした場合のみ反応させる headingMark := r.ReplaceAllString(s[i], "$1") headingStr := r.ReplaceAllString(s[i], "$2") if len(headingMark) > 0 { fmt.Printf("%s %d\n", headingMark, len(headingMark)) ret = append(ret, ToUL(len(headingMark), headingStr)) } } } return ret } func ToUL(num int, heading string) string { var ret string for i := 0; i < num - 1; i++ { ret = ret + " " } ret = ret + "* " + heading return ret }このとき
regexp.MustCompile()
で抜き出すのですが、/^(#+)/
にマッチしなかった場合でも/(.*)/
に反応してしまい、後ろのif文で判定を入れています。Regex Checkerで確認しても反応しないはず…と思っていたのですが、どうやらGolangはPCREなどとは別の正規表現エンジンのようでした。
HTML部分の作成
Ginのテンプレートエンジンに沿ってHTMLを作成します。
{{.varname}}
で変数を代入してくれます。index.html<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous"> <title>Sample App</title> <style> div { padding: 5px } .form-group { margin-bottom: 1px; } button { padding: 5px; } input { padding: 5px } hr { margin: 0; } </style> </head> <body style="padding: 30px"> <h1>ToC Generator</h1> <div style="padding: 20px"> <form> <div class="form-group"> <label for="exampleInputEmail1">GitHub Wiki URL</label> <small id="emailHelp" class="form-text text-muted" style="display: inline"> e.g. https://github.com/yousan/toc-generator/wiki/testpage </small> <input type="text" class="form-control" id="exampleInputEmail1" aria-describedby="emailHelp" placeholder="Enter wiki URL" name="url" {{if .url}} value="{{.url}}" {{else}} value="https://github.com/yousan/toc-generator/wiki/testpage" {{end}} > <button type="submit" class="btn btn-primary" style="margin-top: 5px;">Submit</button> </div> <hr> <div> <span>Markdown raw URL</span> <input type="text" class="form-control" readonly id="rawurl" aria-describedby="emailHelp" placeholder="Enter wiki URL" value="{{.rawurl}}" > </div> <div> <h5>Raw Markdown body</h5> <textarea style="width:100%; height: 200px">{{.rawbody}}</textarea> </div> <div> <h5>ToC Markdown</h5> <textarea style="width:100%; height: 200px">{{.toc}}</textarea> </div> </form> </div> </body> </html>表示用に変数類を整えます。
router.GET("/", func(ctx *gin.Context) { url, _ := getPostUrl(ctx) vars := make(map[string]string) vars["url"] = url if len(url) > 0 { rawurl := ParseUrl(ctx) vars["rawurl"] = rawurl content := getContent(rawurl) vars["rawbody"] = content uls := ParseMarkdownToUl(content) toc := "# ToC\n" for i := 0; i<len(uls); i++ { toc = toc + uls[i] + "\n" } vars["toc"] = toc } ctx.HTML(200,"index.html", vars) })終わり
上記でGoを動かせるようになりました。
デプロイ
Zeit社のVercel(旧 Now)でGoが動くという事でこちらで公開しようと思ったのですが動きませんでした。
まずGinのようにHTTPの待ち受けを行うプログラムの場合、
handler
パッケージ化が必要です。https://vercel.com/docs/runtimes#official-runtimes/go
https://vercel.com/blog/introducing-go-modules-on-vercel-go
https://github.com/mini-eggs/go-now-example
この方法ではコード本体はGitHub上にモジュールとして公開し、それを動かすための
zeit.go
を作成します。zeit.gopackage handler import ( "net/http" app "github.com/yousan/toc-generator/app" ) func H(w http.ResponseWriter, r *http.Request) { app.Default().ServeHTTP(w, r) }ここまでは動くようになったのですが、
app.go
からテンプレートファイルの読み込みに失敗してしまいました。ディレクトリを個別に指定しようと
ioutil.ReadDir()
を使ったところ失敗してしまったことや、以前 Node.js でも似たようにファイルが読み込めないという問題があり、セキュリティ的に読み込みに制限をしているのではないか…、と思われます。Node.jsについては同じように困っている人が多く解決方法が公開されており、
__dirname
を使うと解決します。const file = readFileSync(join(__dirname, 'config', 'ci.yml'), 'utf8');https://docs-git-add-config-reference.zeit.now.sh/docs/builders/builders-mdx/advanced/advanced
残念ながらひとまず Zeit Vercel では諦めることとなりました。
せっかくなのでGKEあたりで試してみることができればと思っていますが、意外とお金が掛かってしまうことがネックですね…。
実はさくらのレンタルでGoが動く(!)1のでそちらを使うのも良いかも知れません。この件に付き合ってくれたくれた @naname さんありがとうございます!
また
zeit.go
化をしてしまうと realizeが動かなくなります。
二重メンテとなってしまいますがmain.go
とapp.go
を管理するか、自動デプロイ機能を組み入れる必要があります。今後
今後時間があれば下記のように改善していければと思っています。
デプロイ
上記のVercelでのデプロイが失敗しているため、なんとか突破できれば…。
デプロイ自動化
GitHub Actions + GKE などができれば。
テスト
今回は実装優先で書いてしまったため、テストコードを書いていません。残念。
未実装項目
ToCジェネレータといいつつheadingがリンク化されていないため、その点を実装する必要があります。
ただし日本語のアンカー名の決定が難しそうなため、マークダウン => マークアップ化された内容を見る…といった方法が必要そうです。
GitHub 上の Markdown が TOC(目次) を表示してくれないのでどうしようか → ツール自製したよって話 - Qiita
- 投稿日:2020-05-24T22:08:15+09:00
xerrorsの誤った使い方について
お題
Go 1.13 で
%w
によるログのラップが可能になった。
が、その方式でログ出力すると(エラー時の)スタックトレースが吐けない。
ので、スタックトレース吐くために xerrorsパッケージが用意されている。
これで、エラー時にスタックトレース付きでログが吐ける。
といった話は、既にたくさん記事があるので、このxerrors
パッケージをこうやって間違って使ってしまったという事例を2つほど。開発環境
# OS - Linux(Ubuntu)
$ cat /etc/os-release NAME="Ubuntu" VERSION="18.04.4 LTS (Bionic Beaver)"# バックエンド
# 言語 - Golang
$ go version go version go1.13.9 linux/amd64IDE - Goland
GoLand 2020.1.2 Build #GO-201.7223.97, built on May 1, 2020今回の全ソース
https://github.com/sky0621/tips-go/tree/xerrors/try/xerrors
実践
%w を使ってエラーをラップ
まずは、Go1.13 から導入された %w を使った表現でエラーログをラップしてみる。
構成
$ tree . ├── fna │ └── fn_a.go ├── fnb │ └── fn_b.go ├── fnc │ └── fn_c.go ├── fnd │ └── fn_d.go ├── go.mod └── main.gomain から fna -> fnb -> fnc -> fnd の順でメソッドコールし、fnd で起きたエラーをラップしつつ上位に戻し、main でログ出力。
実行結果
以下のように、全ラップ箇所のログが出力されている。
ただし、ラップ箇所のスタックトレースは吐かれない。[before]main error occurred: fna.Exec error occurred: fnb.Exec error occurred: fnc.Exec error occurred: fnd.Exec error occurred: unexpected error occurredソース群
main.gopackage main import ( "fmt" "github.com/sky0621/tips-go/try/xerrors/before/fna" ) func main() { e := fna.Exec() if e != nil { lastErr := fmt.Errorf("[before]main error occurred: %w", e) fmt.Printf("%+v\n", lastErr) } }
その他のソース
fna/fn_a.gopackage fna import ( "fmt" "github.com/sky0621/tips-go/try/xerrors/before/fnb" ) func Exec() error { e := fnb.Exec() if e != nil { return fmt.Errorf("fna.Exec error occurred: %w", e) } return nil }fnb/fn_b.gopackage fnb import ( "fmt" "github.com/sky0621/tips-go/try/xerrors/before/fnc" ) func Exec() error { e := fnc.Exec() if e != nil { return fmt.Errorf("fnb.Exec error occurred: %w", e) } return nil }fnc/fn_c.gopackage fnc import ( "fmt" "github.com/sky0621/tips-go/try/xerrors/before/fnd" ) func Exec() error { e := fnd.Exec() if e != nil { return fmt.Errorf("fnc.Exec error occurred: %w", e) } return nil }fnd/fn_d.gopackage fnd import ( "errors" "fmt" ) func Exec() error { e := errors.New("unexpected error occurred") return fmt.Errorf("fnd.Exec error occurred: %w", e) }"github.com/pkg/errors"パッケージを使用
そもそもGoではgithub.com/pkg/errorsパッケージの
Wrap
関数を使うことでエラーのラップが可能だった。実行結果
以下のようにスタックトレース付きでログ出力される。(なんか過剰に出てる気がするけど)
unexpected error occurred github.com/sky0621/tips-go/try/xerrors/before2/fnd.Exec /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/before2/fnd/fn_d.go:8 github.com/sky0621/tips-go/try/xerrors/before2/fnc.Exec /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/before2/fnc/fn_c.go:9 github.com/sky0621/tips-go/try/xerrors/before2/fnb.Exec /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/before2/fnb/fn_b.go:9 github.com/sky0621/tips-go/try/xerrors/before2/fna.Exec /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/before2/fna/fn_a.go:9 main.main /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/before2/main.go:11 runtime.main /usr/local/go/src/runtime/proc.go:203 runtime.goexit /usr/local/go/src/runtime/asm_amd64.s:1357 fnd.Exec error occurred github.com/sky0621/tips-go/try/xerrors/before2/fnd.Exec /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/before2/fnd/fn_d.go:9 github.com/sky0621/tips-go/try/xerrors/before2/fnc.Exec /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/before2/fnc/fn_c.go:9 github.com/sky0621/tips-go/try/xerrors/before2/fnb.Exec /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/before2/fnb/fn_b.go:9 github.com/sky0621/tips-go/try/xerrors/before2/fna.Exec /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/before2/fna/fn_a.go:9 main.main /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/before2/main.go:11 runtime.main /usr/local/go/src/runtime/proc.go:203 runtime.goexit /usr/local/go/src/runtime/asm_amd64.s:1357 fnc.Exec error occurred github.com/sky0621/tips-go/try/xerrors/before2/fnc.Exec /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/before2/fnc/fn_c.go:11 github.com/sky0621/tips-go/try/xerrors/before2/fnb.Exec /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/before2/fnb/fn_b.go:9 github.com/sky0621/tips-go/try/xerrors/before2/fna.Exec /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/before2/fna/fn_a.go:9 main.main /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/before2/main.go:11 runtime.main /usr/local/go/src/runtime/proc.go:203 runtime.goexit /usr/local/go/src/runtime/asm_amd64.s:1357 fnb.Exec error occurred github.com/sky0621/tips-go/try/xerrors/before2/fnb.Exec /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/before2/fnb/fn_b.go:11 github.com/sky0621/tips-go/try/xerrors/before2/fna.Exec /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/before2/fna/fn_a.go:9 main.main /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/before2/main.go:11 runtime.main /usr/local/go/src/runtime/proc.go:203 runtime.goexit /usr/local/go/src/runtime/asm_amd64.s:1357 fna.Exec error occurred github.com/sky0621/tips-go/try/xerrors/before2/fna.Exec /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/before2/fna/fn_a.go:11 main.main /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/before2/main.go:11 runtime.main /usr/local/go/src/runtime/proc.go:203 runtime.goexit /usr/local/go/src/runtime/asm_amd64.s:1357 [before2]main error occurredソース群
main.gopackage main import ( "fmt" "github.com/pkg/errors" "github.com/sky0621/tips-go/try/xerrors/before2/fna" ) func main() { e := fna.Exec() if e != nil { lastErr := errors.Wrap(e, "[before2]main error occurred") fmt.Printf("%+v\n", errors.Unwrap(lastErr)) } }
その他のソース
fna/fn_a.gopackage fna import ( "github.com/pkg/errors" "github.com/sky0621/tips-go/try/xerrors/before2/fnb" ) func Exec() error { e := fnb.Exec() if e != nil { return errors.Wrap(e, "fna.Exec error occurred") } return nil }fnb/fn_b.gopackage fnb import ( "github.com/pkg/errors" "github.com/sky0621/tips-go/try/xerrors/before2/fnc" ) func Exec() error { e := fnc.Exec() if e != nil { return errors.Wrap(e, "fnb.Exec error occurred") } return nil }fnc/fn_c.gopackage fnc import ( "github.com/pkg/errors" "github.com/sky0621/tips-go/try/xerrors/before2/fnd" ) func Exec() error { e := fnd.Exec() if e != nil { return errors.Wrap(e, "fnc.Exec error occurred") } return nil }fnd/fn_d.gopackage fnd import ( "github.com/pkg/errors" ) func Exec() error { e := errors.New("unexpected error occurred") return errors.Wrap(e, "fnd.Exec error occurred") }"golang.org/x/xerrors"パッケージを使用
Go1.13で %w を使いつつスタックトレースも吐きたい場合、golang.org/x/xerrorsを使う。
実行結果
なんだかすごくきれいにスタックトレースを表示してくれてる。
[after]main error occurred: main.main /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/after/main.go:13 - fna.Exec error occurred: github.com/sky0621/tips-go/try/xerrors/after/fna.Exec /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/after/fna/fn_a.go:11 - fnb.Exec error occurred: github.com/sky0621/tips-go/try/xerrors/after/fnb.Exec /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/after/fnb/fn_b.go:11 - fnc.Exec error occurred: github.com/sky0621/tips-go/try/xerrors/after/fnc.Exec /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/after/fnc/fn_c.go:11 - fnd.Exec error occurred: github.com/sky0621/tips-go/try/xerrors/after/fnd.Exec /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/after/fnd/fn_d.go:9 - unexpected error occurred: github.com/sky0621/tips-go/try/xerrors/after/fnd.Exec /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/after/fnd/fn_d.go:8ソース群
main.gopackage main import ( "fmt" "github.com/sky0621/tips-go/try/xerrors/after/fna" "golang.org/x/xerrors" ) func main() { e := fna.Exec() if e != nil { lastErr := xerrors.Errorf("[after]main error occurred: %w", e) fmt.Printf("%+v\n", lastErr) } }
その他のソース
fna/fn_a.gopackage fna import ( "github.com/sky0621/tips-go/try/xerrors/after/fnb" "golang.org/x/xerrors" ) func Exec() error { e := fnb.Exec() if e != nil { return xerrors.Errorf("fna.Exec error occurred: %w", e) } return nil }fnb/fn_b.gopackage fnb import ( "github.com/sky0621/tips-go/try/xerrors/after/fnc" "golang.org/x/xerrors" ) func Exec() error { e := fnc.Exec() if e != nil { return xerrors.Errorf("fnb.Exec error occurred: %w", e) } return nil }fnc/fn_c.gopackage fnc import ( "github.com/sky0621/tips-go/try/xerrors/after/fnd" "golang.org/x/xerrors" ) func Exec() error { e := fnd.Exec() if e != nil { return xerrors.Errorf("fnc.Exec error occurred: %w", e) } return nil }fnd/fn_d.gopackage fnd import ( "golang.org/x/xerrors" ) func Exec() error { e := xerrors.New("unexpected error occurred") return xerrors.Errorf("fnd.Exec error occurred: %w", e) }xerrorsの誤った使い方事例1
エラーをラップしつつ呼び出し元に返していったログを最終的に出力する箇所で、"github.com/pkg/errors"を使っていた時に
Unwrap
関数を使ったりしていたのだけど、同じようにUnwrap
した結果をログ出力するようにした。
すると、最後のラップしたログが出力されなかった。実行結果
一見、ちゃんとスタックトレース出てるし、いいかなと思ったんだけど、実は最後にラップした「
[after3]main error occurred: 〜〜
」のログが出力されていない。fna.Exec error occurred: github.com/sky0621/tips-go/try/xerrors/after3/fna.Exec /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/after3/fna/fn_a.go:11 - fnb.Exec error occurred: github.com/sky0621/tips-go/try/xerrors/after3/fnb.Exec /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/after3/fnb/fn_b.go:11 - fnc.Exec error occurred: github.com/sky0621/tips-go/try/xerrors/after3/fnc.Exec /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/after3/fnc/fn_c.go:11 - fnd.Exec error occurred: github.com/sky0621/tips-go/try/xerrors/after3/fnd.Exec /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/after3/fnd/fn_d.go:9 - unexpected error occurred: github.com/sky0621/tips-go/try/xerrors/after3/fnd.Exec /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/after3/fnd/fn_d.go:8ソース群
main.gopackage main import ( "fmt" "github.com/sky0621/tips-go/try/xerrors/after3/fna" "golang.org/x/xerrors" ) func main() { e := fna.Exec() if e != nil { lastErr := xerrors.Errorf("[after3]main error occurred: %w", e) fmt.Printf("%+v\n", xerrors.Unwrap(lastErr)) } }ログ出力時に
xerrors.Unwrap
を呼んでるんだけど、これ、やっちゃダメみたい。
その他のソース
fna/fn_a.gopackage fna import ( "github.com/sky0621/tips-go/try/xerrors/after3/fnb" "golang.org/x/xerrors" ) func Exec() error { e := fnb.Exec() if e != nil { return xerrors.Errorf("fna.Exec error occurred: %w", e) } return nil }fnb/fn_b.gopackage fnb import ( "github.com/sky0621/tips-go/try/xerrors/after3/fnc" "golang.org/x/xerrors" ) func Exec() error { e := fnc.Exec() if e != nil { return xerrors.Errorf("fnb.Exec error occurred: %w", e) } return nil }fnc/fn_c.gopackage fnc import ( "github.com/sky0621/tips-go/try/xerrors/after3/fnd" "golang.org/x/xerrors" ) func Exec() error { e := fnd.Exec() if e != nil { return xerrors.Errorf("fnc.Exec error occurred: %w", e) } return nil }fnd/fn_d.gopackage fnd import ( "golang.org/x/xerrors" ) func Exec() error { e := xerrors.New("unexpected error occurred") return xerrors.Errorf("fnd.Exec error occurred: %w", e) }xerrorsの誤った使い方事例2
xerrorsを使う前の時点のソースで、ログ出力部分をユーティリティ関数にしていた。
面倒だったので、そのユーティリティ関数の中身をxerrors.Errorf("〜〜: %w", e)
のように書き換えた。
すると、ログ出力部分をユーティリティ関数に任せていた箇所のスタックトレースが軒並みユーティリティ関数のものになってしまった。実行結果
スタックトレースは吐かれているけど、途中すべてが
util/log.go
の行番号が出ていて、結局、メソッド呼び出し関係がまったくわからない。[Log] error occurred: github.com/sky0621/tips-go/try/xerrors/after2/util.Log /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/after2/util/log.go:9 - [Log] error occurred: github.com/sky0621/tips-go/try/xerrors/after2/util.Log /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/after2/util/log.go:9 - [Log] error occurred: github.com/sky0621/tips-go/try/xerrors/after2/util.Log /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/after2/util/log.go:9 - [Log] error occurred: github.com/sky0621/tips-go/try/xerrors/after2/util.Log /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/after2/util/log.go:9 - [Log] error occurred: github.com/sky0621/tips-go/try/xerrors/after2/util.Log /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/after2/util/log.go:9 - unexpected error occurred: github.com/sky0621/tips-go/try/xerrors/after2/fnd.Exec /home/sky0621/work/src/github.com/sky0621/tips-go/try/xerrors/after2/fnd/fn_d.go:9ソース群
main.gopackage main import ( "fmt" "github.com/sky0621/tips-go/try/xerrors/after2/util" "github.com/sky0621/tips-go/try/xerrors/after2/fna" ) func main() { e := fna.Exec() if e != nil { lastErr := util.Log(e) fmt.Printf("%+v\n", lastErr) } }
その他のソース
fna/fn_a.gopackage fna import ( "github.com/sky0621/tips-go/try/xerrors/after2/fnb" "github.com/sky0621/tips-go/try/xerrors/after2/util" ) func Exec() error { e := fnb.Exec() if e != nil { return util.Log(e) } return nil }fnb/fn_b.gopackage fnb import ( "github.com/sky0621/tips-go/try/xerrors/after2/fnc" "github.com/sky0621/tips-go/try/xerrors/after2/util" ) func Exec() error { e := fnc.Exec() if e != nil { return util.Log(e) } return nil }fnc/fn_c.gopackage fnc import ( "github.com/sky0621/tips-go/try/xerrors/after2/fnd" "github.com/sky0621/tips-go/try/xerrors/after2/util" ) func Exec() error { e := fnd.Exec() if e != nil { return util.Log(e) } return nil }fnd/fn_d.gopackage fnd import ( "github.com/sky0621/tips-go/try/xerrors/after2/util" "golang.org/x/xerrors" ) func Exec() error { e := xerrors.New("unexpected error occurred") return util.Log(e) }util/log.gopackage util import "golang.org/x/xerrors" func Log(org error) error { if org == nil { return nil } return xerrors.Errorf("[Log] error occurred: %w", org) }まとめ
「いや、こんな誤り、しないでしょ?」と思うかもしれないけど、、、実際、やっちゃってるんだよなぁ。。。
早く直したい。
- 投稿日:2020-05-24T21:39:05+09:00
Golang sync.WaitGroupとsemaphoreを使って10並列で画像ダウンロード
sync.WaitGroupとsemaphoreを使って10並列で画像ダウンロードするサンプルスクリプトです。
並列実行数が10を超過しないように、semaphoreを使っています。便利です
sync.semaphore の実装を読んでみると面白いですよgolang.org/x/sync/semaphoreを使ってゴルーチンの同時実行数を制御する を参考にしつつ実装してみました
main.gopackage main import ( "context" "fmt" "io" "net/http" "os" "sync" "golang.org/x/sync/semaphore" ) var wg *sync.WaitGroup var sm = semaphore.NewWeighted(10) // 10並列で取得 var maxPage = 50 // 最大ページ取得数 // main 実行方法: GO111MODULE=off go run main.go func main() { // 検索元: https://www.jisc.go.jp/app/jis/general/GnrJISSearch.html target := map[string]string{ "X0001": "kAIAALDEpH2u0O895seN", "X0002": "UQMAALzrEixtvmtcZ4zt", "X0003": "VAMAACJ6MCttlyDSqCTz", } for name, pageUrl := range target { if pageUrl == "" { continue } download(name, pageUrl, maxPage) } } func download(name string, pageUrl string, maxPage int) { wg = &sync.WaitGroup{} makeDir(fmt.Sprintf("page/%s", name)) for i := 1; i <= maxPage; i++ { wg.Add(1) go getImage(name, pageUrl, uint(i)) } wg.Wait() } func makeDir(path string) { os.Mkdir(path, 0777) } func getImage(name string, pageUrl string, page uint) { defer wg.Done() // 並列実行数を制御 if err := sm.Acquire(context.Background(), 1); err != nil { // 指定した同時実行数制限smから1つ実行権限を取得。上限に達していて取得できない場合は、取得でき次第、実行を開始 fmt.Println(fmt.Sprintf("sm.Acquire(ctx, 1) error. err: %+v", err)) return } defer sm.Release(1) // 最後にsm実行権限枠を1つ空ける sb := map[string]string{ "X0001": "pdfb2", "X0002": "pdfa7", "X0003": "pdfb5", } serverName, _ := sb[name] // https://www.jisc.go.jp/pdfb5/PDFView/GetImage/VAMAACJ6MCttlyDSqCTz?pageNo=1&width=1200&seq=0 urlBase := "https://www.jisc.go.jp/%s/PDFView/GetImage/%s?pageNo=%d&width=1200&seq=0" url := fmt.Sprintf(urlBase, serverName, pageUrl, page) fmt.Println(fmt.Sprintf("[+]start PagetNo.%d url:%s", page, url)) // 画像データ取得と取得結果をHTTP Statusで振り分け response, err := http.Get(url) if err != nil { return } if response.StatusCode >= 300 { return // HTTP Statusが300以上で取得失敗 } defer response.Body.Close() // 画像ファイル保存 filePath := fmt.Sprintf("./page/%s/%d.png", name, page) file, err := os.Create(filePath) if err != nil { return } defer file.Close() io.Copy(file, response.Body) }
- 投稿日:2020-05-24T20:04:14+09:00
【Go】構造体のフィールドに定義してある`_ struct{}`はどういう意味?
【Go】構造体のフィールドに定義してある
_ struct{}
はどういう意味?ライブラリのコードを読んでいると
_ struct{}
というフィールドが定義してある構造体をみかけることがありますが、これはどういう意味なのか?というお話です。結論としては、これは構造体を初期化する際に、フィールド名の指定を強制する意図で宣言されています。
blank identifierを用いた構造体の定義とComposite literalsによる構造体の初期化
type SomeType struct { Field1 string Field2 bool _ struct{} }
_
はblank identifierと呼ばれるものです。
これは、dev/null
に似ていて、blank identifierに割当てられた値や宣言は、無害な方法で、虚無のブラックホールへと捨て去られます。(_
がブラックホールの穴に見えてくる…)ここでは詳しく説明しませんが、Effective Goに詳しい使用用途などが紹介されています。
また、Composite literalsで構造体を初期化した場合以下の2つの方法があります。
- フィールド名を指定して初期化する
- フィールド名を省略して初期化する
// フィールド名を指定して初期化: _ = SomeType{Field1: "hello", Field2: true} // フィールド名を省略して初期化: _ = SomeType{"hello", true} // too few values in SomeType literalフィールド名を指定した場合
フィールド名を指定した場合、指定をしなかったフィールドは、ゼロ値になります。
また、フィールドの順序が入れ替わったり、フィールドが追加されても正しく動作します。フィールド名を省略した場合
フィールド名を省略した場合、構造体の宣言と同じ順序ですべてのフィールドを宣言して初期化する必要があります。
この場合、フィールド名を省略すると、
_ struct{}
に対する初期化を行っていないので、すべてのフィールドを初期化できず、コンパイルエラーになります。なので結果として、構造体の初期化の際にフィールド名の指定を強制することができるのですね。
パッケージ外からの初期化
また、以下のように同じパッケージ内で
struct{}{}
で初期化した場合は、コンパイルエラーは発生せず、初期化できてしまいます。(struct{}{}
で初期化するのは少々、無理矢理感がありますが…)SomeType{"hello", true, struct{}{}}しかしながら、外部のパッケージから構造体を初期化しようとした場合、エラーになります。これは、非公開のフィールドは外部パッケージから初期化できないためです。
// implicit assignment of unexported field '_' in hoge.SomeType literal _ = hoge.SomeType{"hello", true, struct{}{}}所感
_ struct{}
という宣言を初めて見たときは、一見なんのこっちゃ感があるので、この手法はメリットとデメリットの両方がありそうだなと個人的には感じました。
意図しない動作を防ぐことができるため、ライブラリを作成したりする場合は特に使う場面があるのかもしれないですね。このようなテクニックを使用しない場合でも、明示的でわかりやすく、予期しない動作をしないよう、構造体の初期化時にはフィールド名は指定していきたいおきもちです。
- 投稿日:2020-05-24T16:31:00+09:00
bykof/statefulを使って、Golangで状態遷移(State machine)を扱う
はじめに
ビジネスロジックを実装していると、状態の管理はいつもついて回ります。
この辺のgolangでの実装について、bykof/statefulがいい感じだったので紹介します。bykof/statefulの使い方
例として、以下のようなとある注文システムの状態遷移図を実現してみます
1. 状態を定義する
まずは状態遷移図の○部分、状態の定義を
stateful.DefaultState
を使って行います。statemachine/definition.goimport "github.com/bykof/stateful" var ( BEGIN = stateful.DefaultState("BEGIN") InCart = stateful.DefaultState("InCart") Ordered = stateful.DefaultState("Ordered") Canceled = BEGIN Shipped = BEGIN )そして
State() state stateful.State
,SetState(state stateful.State) error
のinterface実装を行います。statemachine/order_state.gotype OrderState struct { state stateful.State deposit int } func NewOrderState(deposit int) OrderState { return OrderState{ state: BEGIN, deposit: deposit, } } func (s *OrderState) State() stateful.State { return s.state } //this will be called after calling a transition function func (s *OrderState) SetState(state stateful.State) error { s.state = state return nil }この実装がstateを持つobjectになります。
2. 状態遷移を定義する
状態遷移は以下のように、
stateful.StateMachine
のAddTransition
を利用します。
statefulパッケージではstate machineに1で定義したobjectを渡して使うため、登録する関数はそのobjectのメソッドで定義するのが扱いやすそうです。statemachine/state_machine.gostateMachine := stateful.StateMachine{ StatefulObject: object, } //Add transition stateMachine.AddTransition( object.Order,//状態遷移時に実行する関数 stateful.States{BEGIN, InCart},//状態遷移元 stateful.States{Ordered},//状態遷移先 )状態遷移時に実行する関数では、
stateful.TransitionArguments
というinterfaceが引数として渡されます。これは、状態遷移時を走らせる時に呼ぶRun
関数で渡す、任意の構造体となります。なので関数内でcastして使います。errなしの場合は次に遷移させる状態を返します。
statemachine/state_machine.gofunc (s OrderState) Order(transitionArguments stateful.TransitionArguments) (stateful.State, error) { //transition argument is in Run parameter p, ok := transitionArguments.(*Product) if !ok { return nil, errors.New("Invalid argument") } if s.deposit < p.Fee { return nil, errors.New("The deposit is not enough") } s.deposit -= p.Fee return Ordered, nil//遷移後の状態を返す }3. 状態遷移の定義を確認する。
1, 2で状態遷移が作成できました。これらが正しく作成できているかは、
github.com/bykof/stateful/statefulGraph
を使って確認ができます。statemachine/state_machine.go//check graph stateMachineGraph := statefulGraph.StateMachineGraph{StateMachine: machine}//machineは2で作ったもの _ = stateMachineGraph.DrawGraph()こんな感じに出力されます。PlantUMLで使用したい場合は、
DrawGraphWithName
を利用くださいdigraph { BEGIN->InCart[ label="SelectProduct" ]; BEGIN->Ordered[ label="Order" ]; InCart->Ordered[ label="Order" ]; Ordered->BEGIN[ label="Cancel" ]; Ordered->BEGIN[ label="ShopProblem" ]; Ordered->BEGIN[ label="Ship" ]; BEGIN; InCart; Ordered; }また、AddTransitionは同じ状態からの遷移に対して複数設定可能なので、使い勝手が良さそうです。
statemachine/state_machine.gostateMachine.AddTransition( object.Cancel, stateful.States{Ordered}, stateful.States{Canceled}, ) stateMachine.AddTransition( object.ShopProblem, stateful.States{Ordered}, stateful.States{Canceled}, )4. 状態遷移を回す
あとは2で作ったstate machineに対して
Run
を実行するだけです。
状態遷移の識別はAddTransaction
で指定した関数で行われます。main.goerr = machine.Run(order.Order, &product1) if err != nil { fmt.Printf("Error") return }sample code
本家のsampleはこちら
この記事に記載したコードはこちらに置いてあります。
参考
- 投稿日:2020-05-24T16:29:36+09:00
Cognito UserPoolsのAuthorizerをGoでデコードする
概要
- AWSのAPI Gatewayの認証にCognito UserPoolsのAuthorizerを用いるときに、API側でもユーザーの一意性を保つために認証します
- その際Cognitoから取得できるJWTのデコードが必要になるのでGo言語でのデコード方法についてまとめました
- クライアント側でAmplifyやリダイレクト後のURLなどを用いてCognitoからIDトークンを取得する方法については既知とします
Amazon Cognito ユーザープール認証
- Cognitoを用いたログインをクライアントから行うと、ユーザープールからJWT(JSON Web Token)が返されます
- JWTはBase64でエンコードされたJSON文字列であり、ユーザーに関する情報(クレーム)が含まれています
- (このBase64でエンコードされていると言う情報によりかなり苦しむことになりました...)
- エンコードされた文字列は
***.***.***
のようにドット(.)
で3つのセクションに分けられています- 3つのセクションはヘッダー、ペイロード、署名で構成されています
ヘッダーは以下のような情報があります
(キーID("kid")と、トークンの署名に使用されるアルゴリズム("alg")が含まれています){ "kid": "abcdefghijklmnopqrsexample=", "alg": "RS256" }ペイロードには以下のような情報があります
(ユーザーに関する情報と、トークンの作成および有効期限のタイムスタンプが含まれています){ "sub": "aaaaaaaa-bbbb-cccc-dddd-example", "aud": "xxxxxxxxxxxxexample", "email_verified": true, "token_use": "id", "auth_time": 1500009400, "iss": "https://cognito-idp.ap-southeast-2.amazonaws.com/ap-southeast-2_example", "cognito:username": "example", "exp": 1500013000, "given_name": "Example", "iat": 1500009400, "email": "example@example.com" }署名には、ヘッダーとペイロードの組み合わせをハッシュして暗号化したものが入ります
ヘッダーとペイロードのデコード
署名の認証周りは一旦なしにして、JWTをデコードしてトークンに含まれるヘッダとペイロードを取得する方法を説明します
Base64でエンコードされているとのことだったのでBase64でデコードすればいいやと思い、デコードしていたのですが、たまにデコードできない現象が発生してしまい、かなり苦しみました...
最終的な結論としてはBase64URLでデコードすれば間違いがありません
Base64とBase64URLの違い
Base64エンコードはデータを印字可能な64種類のデータで表現するエンコード方式です
しかし、URLの一部として利用する場合は「+」や「/」とと言ったURLセーフではない文字列が含まれるのでURLセーフにエンコードしなければなりません
そこで用いられるのがBase64URLのようです
おそらくAmplifyなどで直接JWTを取得する場合は問題ないと思いますが、リダイレクト後のURLなどからJWTをゲットした場合にURLセーフにエンコードされているのでBase64ではデコードできなかったものと考えられます
どちらにせよBase64URLでデコードすれば、ヘッダーとペイロードの値を取得できます
コーディング例
base64urlのデコーディングには
github.com/dvsekhvalnov/jose2go/base64url
をimportして用います
(そのほかには"strings"
や"fmt"
や"encoding/json"
のimportが必要です)jwt := "***.***.***" // Cognitoユーザープールから取得したJWT // // API GatewayでLambda Proxyを用いてる場合、AuthorizationヘッダにJWTを付与するので、以下で取得可能 // jwt := events.APIGatewayProxyRequest.Headers["Authorization"] // ドットで文字列を分割して配列にして、エンコーディングされたヘッダとペイロードを取得 headerEnc := strings.Split(jwt, ".")[0] payloadEnc := strings.Split(jwt, ".")[1] // base64urlを用いてヘッダーとペイロードをデコードします headerDec, _ := base64url.Decode(headerEnc) // 第二戻り値のエラーは無視しています payloadDec, _ := base64url.Decode(payloadEnc) // 同様 // 文字列として出力 fmt.Println(string(headerDec)) fmt.Println(string(payloadDec)) // デコードされた戻り値は[]byte型なので、以下のように構造体に入れたり、マップに入れ込んだりできます type Header struct { KeyID string `json:"kid"` Algorithm string `json:"alg"` } headerStruct := Header{} json.Unmarshal(headerDec, &headerStruct) headerMap := map[string]string{} json.Unmarshal(headerDec, &headerMap)以上のようにデコード自体は難しくないのですが、あまり載っていなかったので苦労しました
- 投稿日:2020-05-24T14:06:37+09:00
プロセスをあいまい検索してkillするツールをGoで作った
はじめに
仕事しているとプロセスをkillすることがたまにあると思います。
大体は
ps
、awk
、grep
で必要なプロセスIDを抽出してkill
コマンドに渡していますが、
ぼくはそれがとても面倒に感じているので、あいまい検索してプロセスをkillしたいなと思ってfk(fuzzy-finder-killer)
ってコマンドを作りました。導入と使い方
go get github.com/skanehira/fk
もしくはreleasesからバイナリをダウンロードしてください。使い方は
fk
を実行するだけです。
fk
を実行するとあいまい検索できる状態になるので、任意の単語を入力して、CTRL-i
で選択します。
Enter
で選択済みのプロセスをkillします。キーバインドは次になります。
key description CTRL-i select/unselect CTRL-j go to next CTRL-k go to prev CTRL-c abort Enter kill 仕組み
あいまい検索のUIはgo-fuzzyfinderというライブラリを使っています。
こちらのライブラリはfzf
と似たインターフェイスを持っていてかつとてもシンプルなので、GoでfzfのようなUIを使いたい場合はぜひ使ってみてください。プロセス一覧はgo-psというライブラリを使っています。このライブラリでプロセスIDとコマンド名を取得できるので、それを
go-fuzzyfinder
に渡します。メインの処理は次の部分になります。
func processes() ([]process, error) { var processes []process procs, err := ps.Processes() if err != nil { return nil, err } for _, p := range procs { // skip pid 0 if p.Pid() == 0 { continue } processes = append(processes, process{ Pid: p.Pid(), Cmd: p.Executable(), }) } return processes, nil } func main() { ... procs, err := processes() if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } idx, err := fuzzyfinder.FindMulti( procs, func(i int) string { return fmt.Sprintf("%d: %s", procs[i].Pid, procs[i].Cmd) }, ) ... for _, i := range idx { pid, cmd := procs[i].Pid, procs[i].Cmd fmt.Println(pid, cmd) if err := kill(pid); err != nil { fmt.Fprintln(os.Stderr, err) } } }おわり
小さなツールですが、プロセスをkillする面倒さから開放されます。
是非試してみてください
- 投稿日:2020-05-24T13:19:22+09:00
Diff+Historyコマンド=hifferで快適なターミナル作業しようぜ
まず、こういう記事がありまして。
テストに対する考え方「Testing Manifesto」を翻訳したので紹介します
で、モヤっと
「俺らSIerは何で全部設定してから最後にまとめてテストやるんだろう?
途中で設定崩れたり、最後一回きりの確認じゃ見過ごしも起こるよな。
もっと、テスト駆動っぽく作業すれば良いのに」とか思って、
シェルをラップして一回設定したら、 その値が崩れないように裏でチェックして お知らせしてくれるツール作ったら 臨時の手動設定/運用作業がすこぶる捗るな。こういう事をピコーンと思いついてツイートしたわけです。
続いて今度作ってみよう。と書いてしまったのでニーズはわからんが、似たようなもの無いし、有言実行!
作ってみた。
どんなのですかー
シェルをラップして、コマンドを実行したらその出力をハッシュで
記録しHistoryコマンドみたいにリストで持っておきます。
テストは自動的にもう一度実行して差分があれば表示してくれます。(Alter Linuxは見栄えが良いですね。)
だから何に使うんだってばよ
普通にいつもの設定作業して、ちょこまかテスト回して差が出ないかを見れば
テスト回数がリニアに上がって品質爆上げになりますよ!(たぶん)Linuxだけじゃなくて、Windows版もクロスプラットフォームであります。バイナリも作っておきましたので拾ってくださいな。
シェルのラップとして使いものになるようにファイル名の自動補完も付けました。
どこにあるんだい?
ここにおいときますんで、詳しくはリポジトリ側を見てくださいな。
あとがき
どっかのコミュニティでOSSを見てくれるトコがあったらこれ見てもらいたいので是非教えてもらえると非常に嬉しいです。
よろしくお願いします!!
あ、テストツールなのになんでテストコード無いって話ですか!?
動かしたいって声一個もらえたらの低モチベーションでして・・FYI
- 投稿日:2020-05-24T09:36:51+09:00
【Golang】newとmakeの違い
【Golang】newとmakeの違い
Golangの基礎学習〜Webアプリケーション作成までの学習を終えたので、復習を兼ねてまとめていく。 基礎〜応用まで。
package main //new makeの違い //new メモリーにポインタが入る空の領域を確保したい場合 //ここ調査 import ( "fmt" ) type Person struct{ id int name string } func main() { //アドレスだけ付与する var p *int = new(int) fmt.Println(p) //>>0xc000016068 fmt.Println(**&p) //>>0 fmt.Println(*p) //>>0 //メモリーはあるので、(0なので)カウントアップする *p++ fmt.Println(*p) //まだアドレスがない /* var p2 *int fmt.Println(p2) //>>nil //アドレスはnilなのでエラーになる *p2++ fmt.Println(p2) */ //newとmakeの違い //newはポインタを返す //makeは空を返す //スライス、マップ、チャネルはmake //変数、structはnew //で使い分ける s := make([]int, 0) fmt.Println(s) //>>[] fmt.Printf("%T\n", s)//型を表示 m := make(map[string]int) fmt.Println(m) //>>map[] fmt.Printf("%T\n", m) ch := make(chan int) fmt.Println(ch) //>>0xc00005c060 //new //変数 var p2 *int = new(int) //*int fmt.Printf("%T\n", p2) //>>*int //struct var st = new(struct{}) fmt.Println(st) //>>&{} fmt.Printf("%T\n", st) //*struct {} //newと& //動作上違いはあまりない man := new(Person) man.id = 0 man.name = "genius" fmt.Println(man) //>>&{0 genius} woman := &Person{id:1, name:"nancy"} fmt.Println(woman) //>>&{1 nancy} }
- 投稿日:2020-05-24T06:40:48+09:00
golang raceについて 競合チェック
golan raceについて試してみた
-raceをつけることで、競合チェックができる
race.gopackage main import "fmt" func main() { c := make(chan bool) m := make(map[string]string) go func() { m["1"] = "a" // First conflicting access. c <- true }() m["2"] = "b" // Second conflicting access. <-c for k, v := range m { fmt.Println(k, v) } }結果
$ go run -race race.go ================== WARNING: DATA RACE Write at 0x00c00011c180 by goroutine 7: runtime.mapassign_faststr() /usr/local/Cellar/go/1.14.2_1/libexec/src/runtime/map_faststr.go:202 +0x0 main.main.func1() /Users/nagasaki/development/go/src/race/race.go:9 +0x5d Previous write at 0x00c00011c180 by main goroutine: runtime.mapassign_faststr() /usr/local/Cellar/go/1.14.2_1/libexec/src/runtime/map_faststr.go:202 +0x0 main.main() /Users/nagasaki/development/go/src/race/race.go:12 +0xc6 Goroutine 7 (running) created at: main.main() /Users/nagasaki/development/go/src/race/race.go:8 +0x97 ================== 2 b 1 a Found 1 data race(s) exit status 66参考
日本語訳(2020/5/24時点では、なんとかコピペ翻訳して、自分解説もしてみた。少しずつ更新、加筆してみたい)見出しは、### を青バックに、###にしたけど、原文ではh2なので、## に直そうかな。
イントロ
データレースは、並行システムのバグの中で最も一般的でデバッグの難しいタイプの一つです。データ競合は、2つのゴルーチンが同時に同じ変数にアクセスし、アクセスのうち少なくとも1つが書き込みである場合に発生します。詳細については、「Go Memory Model」を参照してください。
ここでは、クラッシュやメモリ破損につながるデータ競合の例を示します。
func main() { c := make(chan bool) m := make(map[string]string) go func() { m["1"] = "a" // First conflicting access. c <- true }() m["2"] = "b" // Second conflicting access. <-c for k, v := range m { fmt.Println(k, v) } }使用方法
このようなバグを診断するのに役立つように、Goにはデータ競合検出器が組み込まれています。これを使用するには、go コマンドに -race フラグを追加します。
レポートフォーマット
競合検出器は、プログラム内でデータ競合を見つけると、レポートを表示します。レポートには、競合するアクセスのスタックトレースと、関係するゴローチンが作成されたスタックが含まれています。以下に例を示します。
WARNING: DATA RACE Read by goroutine 185: net.(*pollServer).AddFD() src/net/fd_unix.go:89 +0x398 net.(*pollServer).WaitWrite() src/net/fd_unix.go:247 +0x45 net.(*netFD).Write() src/net/fd_unix.go:540 +0x4d4 net.(*conn).Write() src/net/net.go:129 +0x101 net.func·060() src/net/timeout_test.go:603 +0xaf Previous write by goroutine 184: net.setWriteDeadline() src/net/sockopt_posix.go:135 +0xdf net.setDeadline() src/net/sockopt_posix.go:144 +0x9c net.(*conn).SetDeadline() src/net/net.go:161 +0xe3 net.func·061() src/net/timeout_test.go:616 +0x3ed Goroutine 185 (running) created at: net.func·061() src/net/timeout_test.go:609 +0x288 Goroutine 184 (running) created at: net.TestProlongTimeout() src/net/timeout_test.go:618 +0x298 testing.tRunner() src/testing/testing.go:301 +0xe8オプション
GORACE環境変数は、レース検出器のオプションを設定します。フォーマットは以下の通りです。
GORACE="option1=val1 option2=val2"オプションは以下の通りです。
log_path(デフォルトは標準エラー):レース検出器は、レポートを log_path.pid という名前のファイルに書き出します。特別な名前 stdout と stderr は、それぞれ標準出力と標準エラーにレポートを書き出します。
exitcode(デフォルト66)。競合が検出された後に終了するときに使用する終了ステータス。
strip_path_prefix (デフォルト ""): 報告されたすべてのファイルからこの接頭辞を削除します。レポートをより簡潔にするために、報告されたすべてのファイルパスからこの接頭辞を取り除きます。
history_size (デフォルトは1):goroutineごとのメモリアクセス履歴は32K * 2**history_size要素です。この値を大きくすると、メモリ使用量の増加を犠牲にして、レポートでの "スタックの復元に失敗しました" エラーを回避することができます。
halt_on_error (デフォルトは0):最初のデータ競合を報告した後にプログラムが終了するかどうかを制御します。
atexit_sleep_ms (default 1000): 最初のデータレースを報告した後にプログラムが終了するかどうかを制御する。終了する前にメインゴローチンでスリープするミリ秒数。例
$ GORACE="log_path=/tmp/race/report strip_path_prefix=/my/go/sources/" go test -raceExcluding Tests テストを除く (なんだろこれ?意味わからないな)
// +build !race package foo // The test contains a data race. See issue 123. func TestFoo(t *testing.T) { // ... } // The test fails under the race detector due to timeouts. func TestBar(t *testing.T) { // ... } // The test takes too long under the race detector. func TestBaz(t *testing.T) { // ... }使い方
開始するには、レース検出器を使用してテストを実行します (go test -race)。競合検出器は実行時に発生する競合のみを検出するので、実行されないコードパスの競合は検出できません。テストのカバレッジが不完全な場合は、現実的な作業負荷の下で -race を使ってビルドされたバイナリを実行することで、より多くのレースを見つけることができるでしょう。
典型的なデータレース
ここでは、代表的なデータレースをいくつか紹介します。これらはすべてレース検出器で検出することができます。
ループカウンターでのレース
func main() { var wg sync.WaitGroup wg.Add(5) for i := 0; i < 5; i++ { go func() { fmt.Println(i) // Not the 'i' you are looking for. wg.Done() }() } wg.Wait() }(自分解説:これだと、5回処理が終わったときに、iを出力しようとすると、iが5になっているので、全部5が出力される。下記だと、jは1つ1つのgoroutineの中で使われているローカル変数なので、競合することなく、12345と表示される)
関数リテラル内の変数 i はループで使用されるのと同じ変数なので、ループのインクリメントに合わせて goroutine での読み込みが競合します(このプログラムは通常 01234 ではなく 55555 を表示します)。このプログラムは、変数のコピーを作成することで修正することができます。func main() { var wg sync.WaitGroup wg.Add(5) for i := 0; i < 5; i++ { go func(j int) { fmt.Println(j) // Good. Read local copy of the loop counter. wg.Done() }(i) } wg.Wait() }誤って共有された変数
// ParallelWrite writes data to file1 and file2, returns the errors. func ParallelWrite(data []byte) chan error { res := make(chan error, 2) f1, err := os.Create("file1") if err != nil { res <- err } else { go func() { // This err is shared with the main goroutine, // so the write races with the write below. _, err = f1.Write(data) res <- err f1.Close() }() } f2, err := os.Create("file2") // The second conflicting write to err. if err != nil { res <- err } else { go func() { _, err = f2.Write(data) res <- err f2.Close() }() } return res }修正点は、goroutinesに新しい変数を導入することです(:=の使用に注意してください)。
... _, err := f1.Write(data) ... _, err := f2.Write(data) ...保護されていないグローバル変数
以下のコードが複数の goroutine から呼び出されると、サービス マップ上で競合が発生します。同じマップの同時読み書きは安全ではありません。
var service map[string]net.Addr func RegisterService(name string, addr net.Addr) { service[name] = addr } func LookupService(name string) net.Addr { return service[name] }コードを安全にするには、アクセスをミューテックスで保護します。
(自分解説:でたミューテックス!よくわかってない)var ( service map[string]net.Addr serviceMu sync.Mutex ) func RegisterService(name string, addr net.Addr) { serviceMu.Lock() defer serviceMu.Unlock() service[name] = addr } func LookupService(name string) net.Addr { serviceMu.Lock() defer serviceMu.Unlock() return service[name] }原始的な保護されていない変数
この例のように、プリミティブ型の変数(bool、int、int64 など)でもデータ競合が発生することがあります。
type Watchdog struct{ last int64 } func (w *Watchdog) KeepAlive() { w.last = time.Now().UnixNano() // First conflicting access. } func (w *Watchdog) Start() { go func() { for { time.Sleep(time.Second) // Second conflicting access. if w.last < time.Now().Add(-10*time.Second).UnixNano() { fmt.Println("No keepalives for 10 seconds. Dying.") os.Exit(1) } } }() }このような「無害な」データ競合であっても、メモリアクセスの非原子性、コンパイラの最適化への干渉、プロセッサメモリへのアクセスの順序変更の問題などにより、デバッグが困難な問題が発生することがあります。
この競合に対する一般的な対処法は、チャネルまたはミューテックスを使用することです。ロックフリーな動作を維持するために、sync/atomic パッケージを使用することもできます。
type Watchdog struct{ last int64 } func (w *Watchdog) KeepAlive() { atomic.StoreInt64(&w.last, time.Now().UnixNano()) } func (w *Watchdog) Start() { go func() { for { time.Sleep(time.Second) if atomic.LoadInt64(&w.last) < time.Now().Add(-10*time.Second).UnixNano() { fmt.Println("No keepalives for 10 seconds. Dying.") os.Exit(1) } } }() }Supported Systems
レースディテクタは、linux/amd64、linux/ppc64le、linux/arm64、freebsd/amd64、netbsd/amd64、darwin/amd64、windows/amd64で動作します。
ランタイムオーバーヘッド
競合検出のコストはプログラムによって異なりますが、一般的なプログラムの場合、メモリ使用量が5~10倍、実行時間が2~20倍になることがあります。
競合検出器は現在、ディフェールおよびリカバリ文ごとに8バイトの余分なバイトを割り当てています。これらの余分な割り当ては、goroutineが終了するまで回復されません。これは、長期的に実行されているゴローチンがdeferとrecover呼び出しを定期的に発行している場合、プログラムのメモリ使用量が制限なく増加する可能性があることを意味しています。これらのメモリ割り当ては、runtime.ReadMemStatsやruntime/pprofの出力には表示されません。
- 投稿日:2020-05-24T01:23:33+09:00
テストで外部APIを実際に叩いたりしませんか? GoでDIによるテストモック
はじめに
TwitterAPIや形態素解析APIを使うサービスでテストをしたいときがあると思います。
また、レイヤードアーキテクチャ等で下層の処理を含めたテストしたいときがあると思います。
まさか、TwitterAPIを実際に叩いたりしたテストをしていませんか?このような場合はテストモックを使ってテストを行うのが一般的です。
Goではモックライブラリを使わずに、自前でモックすることが多いのです。テストと強気にでましたが、ここではユニットテストを指しています。
実装
以下のTwitterAPIクライアントをサービス層で利用するとします。
twitter.gopackage qiita_test_mock import ( "net/url" "github.com/ChimeraCoder/anaconda" ) type ITwitterApiClient interface { PostTweet(string, url.Values) (anaconda.Tweet, error) } // Golangでよく見るanacondaのTwitterClient // *anaconda.TwitterApiはPostTweet(string, url.Values) (anaconda.Tweet, error)を実装している // したがって、TwitterApiClientはITwitterApiClientを満たしている type TwitterApiClient *anaconda.TwitterApiサービス層では以下のように書くことで、DIを実現することができます。
TwitterAPIクライアントをレシーバ、もしくは引数からインジェクションします。service.gopackage qiita_test_mock import "fmt" // このInterfaceの宣言はなくてもPostHelloWorldは動作します // ただ、ここもInterfaceとすることでService層をさらに上のUseCase層等へインジェクションすることができます // そうすると、UseCase層はService層のモックを用いて実装やテストを行うことができます type Service interface { PostHelloWorld(string) (string, error) } type ServiceImpl struct { TwitterApiClient ITwitterApiClient } // ITwitterApiClientを利用して、ツイートする // 構造体の要素としてDIする(フィールドインジェクション) func (s ServiceImpl) PostHelloWorld(name string) (string, error) { content := fmt.Sprintf("Hello World by %s", name) tweet, err := s.TwitterApiClient.PostTweet(content, nil) if err != nil { return "", err } return tweet.Text, nil } // 上と同様の処理を行う // 引数としてDIする(あまり見たことはないし非推奨) func PostHelloWorld(name string, client ITwitterApiClient) (string, error) { content := fmt.Sprintf("Hello World by %s", name) tweet, err := client.PostTweet(content, nil) if err != nil { return "", err } return tweet.Text, nil }テストは以下のように書くことができます。
TwitterAPIクライアントを外部からインジェクションしない場合は、テストでも実際のTwitterAPIを叩くことになったでしょう。service_test.gopackage qiita_test_mock import ( "net/url" "testing" "github.com/ChimeraCoder/anaconda" "github.com/google/go-cmp/cmp" ) type TwitterApiClientMock struct{} // ITwitterApiClientを満たすようにPostTweetを実装する func (m TwitterApiClientMock) PostTweet(content string, values url.Values) (anaconda.Tweet, error) { return anaconda.Tweet{Text: content}, nil } func TestPostHelloWorld(t *testing.T) { cases := []struct { input string output string }{ { input: "Bob", output: "Hello World by Bob", }, { input: "Alice", output: "Hello World by Alice", }, { input: "Mike", output: "Hello World by Mike", }, } // TwitterApiClientMockをインジェクションすることで、anacondaのAPIクライアントを用いない // したがって、実際にツイートすることなくテストを実行することができる serviceImpl := ServiceImpl{TwitterApiClient: TwitterApiClientMock{}} for _, tt := range cases { tweetContent, err := serviceImpl.PostHelloWorld(tt.input) if err != nil { t.Fatal(err) } if diff := cmp.Diff(tweetContent, tt.output); diff != "" { t.Errorf("Diff: (-got +want)\n%s", diff) } tweetContent, err = PostHelloWorld(tt.input, TwitterApiClientMock{}) if err != nil { t.Fatal(err) } if diff := cmp.Diff(tweetContent, tt.output); diff != "" { t.Errorf("Diff: (-got +want)\n%s", diff) } } }# テスト結果 $ go test -v === RUN TestPostHelloWorld --- PASS: TestPostHelloWorld (0.00s) PASS ok github.com/kotaroooo0/for_output/qiita_test_mock 0.386sソースコードはこちら
https://github.com/kotaroooo0/for_output/tree/master/qiita_test_mock最後に
今回は外部APIを例に実装を紹介しました。
記事の途中でも紹介しましたが、この技法はレイヤードアーキテクチャなど多層のアーキテクチャの層をモックしたり、DBをモックしたりすることにも応用することができます。
あくまでもこれはユニットテストの話です。手でポチポチしたりして実際のAPIやDBを叩いたりするようにことも必要です。参考
https://deeeet.com/writing/2016/10/25/go-interface-testing/
https://irof.hateblo.jp/entry/2017/04/16/222737
- 投稿日:2020-05-24T01:23:33+09:00
テストで外部APIを実際に叩いたりしていませんか? GoでDIによるテストモック
はじめに
TwitterAPIや形態素解析APIを使うサービスでテストをしたいときがあると思います。
また、レイヤードアーキテクチャ等で下層の処理を含めたテストしたいときがあると思います。
まさか、TwitterAPIを実際に叩いたりしたテストをしていませんか?このような場合はテストモックを使ってテストを行うのが一般的です。
Goではモックライブラリを使わずに、自前でモックすることが多いのです。テストと強気にでましたが、ここではユニットテストを指しています。
実装
以下のTwitterAPIクライアントをサービス層で利用するとします。
twitter.gopackage qiita_test_mock import ( "net/url" "github.com/ChimeraCoder/anaconda" ) type ITwitterApiClient interface { PostTweet(string, url.Values) (anaconda.Tweet, error) } // Golangでよく見るanacondaのTwitterClient // *anaconda.TwitterApiはPostTweet(string, url.Values) (anaconda.Tweet, error)を実装している // したがって、TwitterApiClientはITwitterApiClientを満たしている type TwitterApiClient *anaconda.TwitterApiサービス層では以下のように書くことで、DIを実現することができます。
TwitterAPIクライアントをレシーバ、もしくは引数からインジェクションします。service.gopackage qiita_test_mock import "fmt" // このInterfaceの宣言はなくてもPostHelloWorldは動作します // ただ、ここもInterfaceとすることでService層をさらに上のUseCase層等へインジェクションすることができます // そうすると、UseCase層はService層のモックを用いて実装やテストを行うことができます type Service interface { PostHelloWorld(string) (string, error) } type ServiceImpl struct { TwitterApiClient ITwitterApiClient } // ITwitterApiClientを利用して、ツイートする // 構造体の要素としてDIする(フィールドインジェクション) func (s ServiceImpl) PostHelloWorld(name string) (string, error) { content := fmt.Sprintf("Hello World by %s", name) tweet, err := s.TwitterApiClient.PostTweet(content, nil) if err != nil { return "", err } return tweet.Text, nil } // 上と同様の処理を行う // 引数としてDIする(あまり見たことはないし非推奨) func PostHelloWorld(name string, client ITwitterApiClient) (string, error) { content := fmt.Sprintf("Hello World by %s", name) tweet, err := client.PostTweet(content, nil) if err != nil { return "", err } return tweet.Text, nil }テストは以下のように書くことができます。
TwitterAPIクライアントを外部からインジェクションしない場合は、テストでも実際のTwitterAPIを叩くことになったでしょう。service_test.gopackage qiita_test_mock import ( "net/url" "testing" "github.com/ChimeraCoder/anaconda" "github.com/google/go-cmp/cmp" ) type TwitterApiClientMock struct{} // ITwitterApiClientを満たすようにPostTweetを実装する func (m TwitterApiClientMock) PostTweet(content string, values url.Values) (anaconda.Tweet, error) { return anaconda.Tweet{Text: content}, nil } func TestPostHelloWorld(t *testing.T) { cases := []struct { input string output string }{ { input: "Bob", output: "Hello World by Bob", }, { input: "Alice", output: "Hello World by Alice", }, { input: "Mike", output: "Hello World by Mike", }, } // TwitterApiClientMockをインジェクションすることで、anacondaのAPIクライアントを用いない // したがって、実際にツイートすることなくテストを実行することができる serviceImpl := ServiceImpl{TwitterApiClient: TwitterApiClientMock{}} for _, tt := range cases { tweetContent, err := serviceImpl.PostHelloWorld(tt.input) if err != nil { t.Fatal(err) } if diff := cmp.Diff(tweetContent, tt.output); diff != "" { t.Errorf("Diff: (-got +want)\n%s", diff) } tweetContent, err = PostHelloWorld(tt.input, TwitterApiClientMock{}) if err != nil { t.Fatal(err) } if diff := cmp.Diff(tweetContent, tt.output); diff != "" { t.Errorf("Diff: (-got +want)\n%s", diff) } } }# テスト結果 $ go test -v === RUN TestPostHelloWorld --- PASS: TestPostHelloWorld (0.00s) PASS ok github.com/kotaroooo0/for_output/qiita_test_mock 0.386sソースコードはこちら
https://github.com/kotaroooo0/for_output/tree/master/qiita_test_mock最後に
今回は外部APIを例に実装を紹介しました。
記事の途中でも紹介しましたが、この技法はレイヤードアーキテクチャなど多層のアーキテクチャの層をモックしたり、DBをモックしたりすることにも応用することができます。
あくまでもこれはユニットテストの話です。手でポチポチしたりして実際のAPIやDBを叩いたりするようにことも必要です。参考
https://deeeet.com/writing/2016/10/25/go-interface-testing/
https://irof.hateblo.jp/entry/2017/04/16/222737
- 投稿日:2020-05-24T01:23:33+09:00
[テスト入門]外部APIを実際に叩いたりしていませんか? GoでDIによるテストモック
はじめに
TwitterAPIや形態素解析APIを使うサービスでテストをしたいときがあると思います。
また、レイヤードアーキテクチャ等で下層の処理を含めたテストしたいときがあると思います。
まさか、TwitterAPIを実際に叩いたりしたテストをしていませんか?このような場合はテストモックを使ってテストを行うのが一般的です。
Goではモックライブラリを使わずに、自前でモックすることが多いのです。テストと強気にでましたが、ここではユニットテストを指しています。
実装
以下のTwitterAPIクライアントをサービス層で利用するとします。
twitter.gopackage qiita_test_mock import ( "net/url" "github.com/ChimeraCoder/anaconda" ) type ITwitterApiClient interface { PostTweet(string, url.Values) (anaconda.Tweet, error) } // Golangでよく見るanacondaのTwitterClient // *anaconda.TwitterApiはPostTweet(string, url.Values) (anaconda.Tweet, error)を実装している // したがって、TwitterApiClientはITwitterApiClientを満たしている type TwitterApiClient *anaconda.TwitterApiサービス層では以下のように書くことで、DIを実現することができます。
TwitterAPIクライアントをレシーバ、もしくは引数からインジェクションします。service.gopackage qiita_test_mock import "fmt" // このInterfaceの宣言はなくてもPostHelloWorldは動作します // ただ、ここもInterfaceとすることでService層をさらに上のUseCase層等へインジェクションすることができます // そうすると、UseCase層はService層のモックを用いて実装やテストを行うことができます type Service interface { PostHelloWorld(string) (string, error) } type ServiceImpl struct { TwitterApiClient ITwitterApiClient } // ITwitterApiClientを利用して、ツイートする // 構造体の要素としてDIする(フィールドインジェクション) func (s ServiceImpl) PostHelloWorld(name string) (string, error) { content := fmt.Sprintf("Hello World by %s", name) tweet, err := s.TwitterApiClient.PostTweet(content, nil) if err != nil { return "", err } return tweet.Text, nil } // 上と同様の処理を行う // 引数としてDIする(あまり見たことはないし非推奨) func PostHelloWorld(name string, client ITwitterApiClient) (string, error) { content := fmt.Sprintf("Hello World by %s", name) tweet, err := client.PostTweet(content, nil) if err != nil { return "", err } return tweet.Text, nil }テストは以下のように書くことができます。
TwitterAPIクライアントを外部からインジェクションしない場合は、テストでも実際のTwitterAPIを叩くことになったでしょう。service_test.gopackage qiita_test_mock import ( "net/url" "testing" "github.com/ChimeraCoder/anaconda" "github.com/google/go-cmp/cmp" ) type TwitterApiClientMock struct{} // ITwitterApiClientを満たすようにPostTweetを実装する func (m TwitterApiClientMock) PostTweet(content string, values url.Values) (anaconda.Tweet, error) { return anaconda.Tweet{Text: content}, nil } func TestPostHelloWorld(t *testing.T) { cases := []struct { input string output string }{ { input: "Bob", output: "Hello World by Bob", }, { input: "Alice", output: "Hello World by Alice", }, { input: "Mike", output: "Hello World by Mike", }, } // TwitterApiClientMockをインジェクションすることで、anacondaのAPIクライアントを用いない // したがって、実際にツイートすることなくテストを実行することができる serviceImpl := ServiceImpl{TwitterApiClient: TwitterApiClientMock{}} for _, tt := range cases { tweetContent, err := serviceImpl.PostHelloWorld(tt.input) if err != nil { t.Fatal(err) } if diff := cmp.Diff(tweetContent, tt.output); diff != "" { t.Errorf("Diff: (-got +want)\n%s", diff) } tweetContent, err = PostHelloWorld(tt.input, TwitterApiClientMock{}) if err != nil { t.Fatal(err) } if diff := cmp.Diff(tweetContent, tt.output); diff != "" { t.Errorf("Diff: (-got +want)\n%s", diff) } } }# テスト結果 $ go test -v === RUN TestPostHelloWorld --- PASS: TestPostHelloWorld (0.00s) PASS ok github.com/kotaroooo0/for_output/qiita_test_mock 0.386sソースコードはこちら
https://github.com/kotaroooo0/for_output/tree/master/qiita_test_mock最後に
今回は外部APIを例に実装を紹介しました。
記事の途中でも紹介しましたが、この技法はレイヤードアーキテクチャなど多層のアーキテクチャの層をモックしたり、DBをモックしたりすることにも応用することができます。
あくまでもこれはユニットテストの話です。手でポチポチしたりして実際のAPIやDBを叩いたりするようにことも必要です。参考
https://deeeet.com/writing/2016/10/25/go-interface-testing/
https://irof.hateblo.jp/entry/2017/04/16/222737
- 投稿日:2020-05-24T00:36:44+09:00
GraphQLにおける認証認可事例(Auth0 RBAC仕立て)
お題
以下の組み合わせで作成しているWebアプリケーションにAuth0による認証認可機能を入れてみる。
認証はID(メールアドレス)とパスワードによる方式を採用。■通信方式
・GraphQL
■フロントエンド
・Vue.js
・Nuxt.js
・TypeScript
・Apollo
■バックエンド
・Golang
・gqlgen挙動としては以下のようになる。
(2)Auth0のログイン画面(カスタマイズもできるらしい)に飛ばされる。メアドとパスワードを入れて「Continue」ボタンを押下する。
(3)認証が通るとアクセストークン付きでコールバック(あらかじめAuth0に設定しておく)が呼ばれる。
(5)サイドメニューから「Movie」を選択して動画一覧ページに遷移する。
(6)ログインユーザに権限があるので動画一覧ページが表示される。
(8)別の(動画一覧表示権限が与えられていない)ユーザでログインする。
(9)サイドメニューから「Movie」を選択して動画一覧ページに遷移する。
(10)ログインユーザに権限がないので動画一覧ページが表示されない。
画面的には、そっけないエラーページだけど、コンソールログを見ると権限がない旨のエラーログが出ている。
前提
- Auth0のSign in方法から各機能の具体的な操作方法など特に細かくは説明しない。
- Vue.jsやGolangの実装方法について1行単位で詳しく解説はしない。
開発環境
# 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.4IDE - WebStorm
WebStorm 2020.1 Build #WS-201.6668.106, built on April 7, 2020# バックエンド
# 言語 - Golang
$ go version go version go1.13.9 linux/amd64IDE - Goland
GoLand 2020.1.2 Build #GO-201.7223.97, built on May 1, 2020関連記事索引
- 第9回「GraphQLにおける認証認可事例(Auth0 RBAC仕立て)」
- 第8回「GraphQL/Nuxt.js(TypeScript/Vuetify/Apollo)/Golang(gqlgen)/Google Cloud Storageの組み合わせで動画ファイルアップロード実装例」
- 第7回「GraphQLにおけるRelayスタイルによるページング実装(後編:フロントエンド)」
- 第6回「GraphQLにおけるRelayスタイルによるページング実装(前編:バックエンド)」
- 第5回「DB接続付きGraphQLサーバ(by Golang)をローカルマシン上でDockerコンテナ起動」
- 第4回「graphql-codegenでフロントエンドをGraphQLスキーマファースト」
- 第3回「go+gqlgenでGraphQLサーバを作る(GORM使ってDB接続)」
- 第2回「NuxtJS(with Apollo)のTypeScript対応」
- 第1回「frontendに「nuxtjs/apollo」、backendに「go+gqlgen」の組み合わせでGraphQLサービスを作る」
全ソース
バックエンド分
https://github.com/sky0621/fs-mng-backend/tree/v0.2
フロントエンド分
https://github.com/sky0621/fs-mng-frontend/tree/v0.2
実践
Auth0の設定
ApplicationとWebAPIの作成
Auth0は、ドキュメントやチュートリアルが充実してるので、ちゃんと読めば、わりかしハマることは少ないと思う。
フロントエンドの設定について
フロントエンドはモードとしてはSSRなんだけど、形式としてSPAを選択している。
それが正しいのかは、ちょっとわかっていない。
ログインやログアウトの処理でAuth0のSDKを使うわけだけど、その際の接続先に関する情報として以下が必要。
また、Auth0での認証処理が成功した後に再びアプリ側に戻ってくるためのコールバックURLを指定。
あと、ログアウト後の遷移先URLも。
バックエンドの設定について
バックエンドはWebAPIとして機能するのでAPIsで設定。
フロントエンドから渡されたJWTのチェックをするために Identifier を使う。
あと、今回は認可機能の実装のために、Auth0が提供しているRBACの仕組みを使うので、Auth0上で定義したRoleとPermissionがJWTのClaimに積まれるようにしておく。
RoleとPermissionの定義
WebAPIが提供する(予定の)各機能にPermissionを定義していく。
この部分は、WebAPIがどんな機能を提供するかや、どういった粒度でPermission制御が必要かについて設計した上で設定する。
今回は仮に「組織」、「ユーザ」、「コンテンツ(今回だと動画コンテンツ)」といったリソースに対するCRUD操作それぞれに認可制御をする想定で設計。
また、APIを叩いた(認証済み)ユーザが、自分で所有(自分で登録したものや他の人から権限を与えられたもの)するものしか権限がないのか、すべてのリソースに対する権限があるのかという観点でも分けてみた。
まあ、実際のアプリケーションでは、もっといろんな状況があると思うので、このあたりは、要件によりけり。
また、Auth0では、別にAuth0上のWebAPI定義にPermissionを細かく作っていかなくても、実際に動かすアプリケーションの方で機能ごとにPermissionを定義して、それをユーザ作成時にアプリケーションの機能としてAuth0ユーザのMetadataに貼り付けるといったこともできる。
柔軟に制御したいといった場合は、Metadata使う方式の方が後々困らないかもしれない。ともあれ、とりあえず今回は以下みたいな感じでPermissionを定義。
・
・
・
で、ユーザにはそれぞれのPermissionを1つ1つ付与していくのではなくRoleを付与するので、Roleを作成。
これも、Roleの定義は作りたいアプリケーションの要件によってまちまちなので、今回のは参考程度に見てもらえると。
このRoleに先ほどのPermissionを割り当てていく。
要するに、このRoleはこのWebAPIに関してどれだけの機能を叩くPermissionを有しているかを設定していく。
先ほどのPermissionもそうだけど、こういった作業を画面でポチポチやるのが地味に時間を要する。
1回きりといえばそうなんだけど、例えば、実際の開発現場では、開発環境、ステージング環境、本番環境を用意し、Auth0のテナントもそれぞれに用意するはず。
各環境用にこの画面ポチポチをつどつどやれって言われたら、正直しんどい。
まあ、たぶん、設定のExport/Import機能くらいあるんだろう、きっと。
Auth0の設定画面になくても、Auth0が用意するManagement系のAPIを使うと、わりとさくっとできるかな。話がズレた。とりあえずRoleごとのPermission割り当てだけど、AdminとNoneの事例を。
・
・
・Adminは要するに全Permission持ってるということ。
Userを作成
このあたりは実際のサービスなら、まず、1人だけAdmin的な権限のあるユーザを作成。
Adminの人だけが触れる管理画面でも用意して、Auth0のManagement系APIを使って、別のRole別のユーザを作成していくといった流れになるんだろう。
今回は、まだそういう機能は用意してないので、とりあえず適当に3ユーザくらいAuth0の画面から作成。
フロントエンド実装
Auth0ではSPAアプリ用にSDKが用意されている。
でも、今回ぐらいの例だとNuxtが提供しているAuthモジュールを使うことで事足りるので、SDKは使わない。nuxtjs/auth導入
以下参照。
https://auth.nuxtjs.org/guide/setup.html今回用の設定は下記。
src/nuxt-config.jsexport default { mode: 'universal', 〜〜〜 modules: [ 〜〜〜 '@nuxtjs/apollo', '@nuxtjs/auth' ], 〜〜〜 apollo: { 〜〜〜 tokenName: 'auth._token.auth0', authenticationType: '' // default の Bearer だと「Bearer: Bearer」というように重複が起きるため }, 〜〜〜 auth: { redirect: { login: '/login', logout: '/login', callback: '/callback', home: '/' }, strategies: { auth0: { domain: process.env.AUTH0_DOMAIN, client_id: process.env.AUTH0_CLIENT_ID, audience: process.env.AUTH0_AUDIENCE } }, plugins: ['~/plugins/auth.ts'] }, 〜〜〜Auth0用の設定事例は下記に記載している。
https://auth.nuxtjs.org/providers/auth0.html#usage
domain
とclient_id
はAuth0のフロントエンド分のApplication設定にて表示されていたやつ。
audience
がちょっとわかりづらいけど、バックエンドのWebAPI設定にて表示されていた「Identifier
」の値を指定。
このへんの説明は↓に少しだけ書かれている。
https://auth.nuxtjs.org/providers/auth0.html#obtaining-client-id-domain-and-audience
client_id
とか晒したくないのでnuxtjs/dotenvを使ってる。
あと、GraphQLライブラリとしてApolloを使っていて、
authenticationType
はデフォルトだとBearer
と付くようで、そのままバックエンドに送ると以下のようになってしまう。
なので、明示的にブランクを指定してBearer
が重複しないようにしている。authorization: Bearer Bearer eyJhbVZn〜〜〜ログインページ
関連ソース
$ tree . ├── layouts │ └── unCertified.vue ├── pages | ├── callback.vue │ ├── login.vue未認証時専用のレイアウト
src/layouts/unCertified.vue<template> <v-app dark> <v-container> <nuxt /> </v-container> </v-app> </template>ログインページ
src/pages/login.vue<template> <v-row justify="center"> <v-col md="auto"> <div>fs-mng-app</div> </v-col> <v-col md="auto"> <v-btn @click="login">Log in</v-btn> </v-col> </v-row> </template> <script lang="ts"> import { Component, Vue } from 'nuxt-property-decorator' @Component({ layout: 'unCertified' }) export default class LoginPage extends Vue { async login() { try { await this.$auth.loginWith('auth0') } catch (err) { this.$toast.error(err) } } } </script>
nuxt.config.js
にてauth
の名で設定した内容を踏まえたオブジェクトが$auth
という名前でアクセスできる。
で、$auth.loginWith('auth0')
と書いただけでAuth0へ認証をかけにいくことができる。
つまり、先述の↓に飛ばしてくれる。
なんだかもう、いろいろなことを隠蔽してくれていて、便利なんだけど怖い。ログイン完了後、Cookieに
auth._token.auth0
という名前でトークンが書き込まれている。
たぶん設定の問題だけど、デフォルトでLocalStorageの方にも同じ内容で書き込まれている。
コールバックページ
Auth0上での認証成功後に、アプリケーションに戻ってこないといけないので、そのためのページを用意。
Auth0の設定で用意したページを認証後のリダイレクト先として指定している。src/pages/callback.vue<template> <v-row justify="center"> <v-col md="auto"> <div>Trying to callback</div> </v-col> </v-row> </template> <script lang="ts"> import { Component, Vue } from 'nuxt-property-decorator' @Component({ layout: 'unCertified' }) export default class LoginPage extends Vue { created() { this.$router.push('/') } } </script>やってることは、単にトップページに遷移するだけ。
動画一覧ページ
ここが今回、認可処理を実装した部分ではあるのだけど、まずは、そもそも認証通ってないと、このページ開けないよの部分から。
バックエンドの方の実装は後ほど示すとして、とりあえずここでは、バックエンドではJWT(要するにCookieに積まれていたトークン)をヘッダに積んでないとエラーを返す実装が入っていることを前提とする。まずは、先述の通りログイン完了後にCookieにトークンが入っている状態で動画一覧ページの表示を試みると、はい、開く。
このとき、ChromeのDevツールでNetworkタブより「Headers」を見てみると、
authorization
ヘッダにCookieに入っていたトークンが指定されている。
つまり、
nuxt.config.js
に記述したapollo
モジュールとauth
モジュールの設定だけで、Auth0ログインにて取得したJWTをHTTPヘッダに積んでGraphQL通信する流れが出来上がっている。さて、試しに、CookieとLocalStorageからトークンを消して、動画一覧ページを開こうとすると、
失敗する。(ただ、これはどちらかというと通信自体に失敗した感じ)
トークンを消すのではなく改ざんっぽく変えてみてから、再度、動画一覧ページを開こうとすると、
画面上は同じエラー画面だけど、通信ログがこのようになる。
「401 Unauthorized
」これは、わかりやすい。
ともかく、適切なトークンを積んでないとGraphQL通信に失敗する仕組みができている。
ログアウト
関連ソース
$ tree . ├── layouts │ └── default.vueログアウト
「ログアウト」ボタンは、ログイン後のどのページからでも即座に実行できるようメニューの右端に仕込んである。
src/layouts/default.vue<template> <v-app dark> 〜〜〜 <v-navigation-drawer v-model="rightDrawer" :right="right" fixed> <v-list> <v-list-item @click="logout"> <v-list-item-action> <v-icon> mdi-eject </v-icon> </v-list-item-action> <v-list-item-title>Log out</v-list-item-title> </v-list-item> </v-list> </v-navigation-drawer> 〜〜〜 </v-app> </template> <script lang="ts"> import { Component, Provide, Vue } from 'nuxt-property-decorator' @Component({}) export default class DefaultLayout extends Vue { 〜〜〜 @Provide() right: boolean = true @Provide() rightDrawer: boolean = false 〜〜〜 async logout() { try { await this.$auth.logout() } catch (err) { this.$toast.error(err) } } } </script>例によって
$auth
オブジェクトが使えるので、$auth.logout()
とするだけでAuth0的にログアウト処理を実行してくれる。
あ〜、便利。
ログアウトされて、ログインページが表示される。
この状態で、あえて、ログイン後に遷移できるはずのトップページの表示を試みると、
表示できず、ログインページにリダイレクトされる。
バックエンド実装
さて、バックエンド側。
WebAPIサーバ起動動線
src/cmd/main.go〜〜〜 func main() { // 環境変数からJWTのチェックに使うAuth0のドメインやオーディエンスを取得 e := loadEnv() 〜〜〜 var router *chi.Mux { router = chi.NewRouter() router.Group(func(r chi.Router) { 〜〜〜 // 認証認可チェック用(今はJWTのチェックのみ実装) a := auth.New(e.Auth0Domain, e.Auth0Audience, e.AuthDebug, e.AuthCredentialsOptional) r.Use(a.CheckJWTHandlerFunc()) r.Use(a.HoldPermissionsHandler) // GraphQLリゾルバー resolver := &graph.Resolver{ DB: db, 〜〜〜 } // GraphQLエンドポイント r.Handle("/query", graph.DataLoaderMiddleware(resolver, graphQlServer(resolver))) }) } log.Printf("connect to http://localhost:%s/ for GraphQL playground", e.ServerPort) if err := http.ListenAndServe(":"+e.ServerPort, router); err != nil { fmt.Println(err) } }WebAPIサーバとしてはライブラリとしてchiを使っている。
で、各リクエストのつどデコレートするためにカスタムミドルウェアを2つ定義している。
・CheckJWTHandlerFunc
・HoldPermissionsHandler
これにより、JWTのチェックと認可チェックをしている。JWTのチェック
src/auth/auth.gopackage auth import( 〜〜〜 jwtMiddleware "github.com/auth0/go-jwt-middleware" "github.com/dgrijalva/jwt-go" ) type Auth struct { domain string audience string debug bool credentialsOptional bool } func New(domain, audience string, debug, credentialsOptional bool) *Auth { return &Auth{domain, audience, debug, credentialsOptional} } func (a *Auth) CheckJWTHandlerFunc() func(next http.Handler) http.Handler { middleware := jwtMiddleware.New(jwtMiddleware.Options{ ValidationKeyGetter: func(token *jwt.Token) (interface{}, error) { // Verify 'aud' claim checkAud := token.Claims.(jwt.MapClaims).VerifyAudience(a.audience, false) if !checkAud { return token, xerrors.New("Invalid audience.") } // Verify 'iss' claim checkIss := token.Claims.(jwt.MapClaims).VerifyIssuer(fmt.Sprintf("https://%s/", a.domain), false) if !checkIss { return token, xerrors.New("Invalid issuer.") } cert, err := a.getPemCert(token) if err != nil { return token, xerrors.New("Invalid token.") } result, _ := jwt.ParseRSAPublicKeyFromPEM([]byte(cert)) return result, nil }, SigningMethod: jwt.SigningMethodRS256, Debug: a.debug, CredentialsOptional: a.credentialsOptional, }) return middleware.Handler } func (a *Auth) getPemCert(token *jwt.Token) (string, error) { cert := "" resp, err := http.Get(fmt.Sprintf("https://%s/.well-known/jwks.json", a.domain)) if err != nil { return cert, xerrors.Errorf("failed to http.Get: %w", err) } defer func() { if err := resp.Body.Close(); err != nil { fmt.Println(err) } }() var jwks = Jwks{} err = json.NewDecoder(resp.Body).Decode(&jwks) if err != nil { return cert, xerrors.Errorf("failed to decode jwks: %w", err) } for k, _ := range jwks.Keys { if token.Header["kid"] == jwks.Keys[k].Kid { cert = "-----BEGIN CERTIFICATE-----\n" + jwks.Keys[k].X5c[0] + "\n-----END CERTIFICATE-----" } } if cert == "" { return cert, xerrors.New("cert is blank.") } return cert, nil } type Jwks struct { Keys []JSONWebKeys `json:"keys"` } type JSONWebKeys struct { Kty string `json:"kty"` Kid string `json:"kid"` Use string `json:"use"` N string `json:"n"` E string `json:"e"` X5c []string `json:"x5c"` }goにおけるJWTのチェック用ライブラリとしてgo-jwt-middlewareを使う。
チェックの内容については以下を丸コピぐらいの勢いで流用。
https://auth0.com/docs/quickstart/backend/golang/01-authorization#validate-access-tokens認可チェックのためのPermission取得
さて、JWTのチェックはしたものの、「このログインしたユーザは、この叩かれた機能を実行できていいんだっけ?」の部分は、まだチェックできていない。
それ用のミドルウェアが「HoldPermissionsHandler
」。
下記は先述の「CheckJWTHandlerFunc
」と同じファイルにあるのでAuth
構造体やgetPemCert
メソッドは共有している。src/auth/auth.gopackage auth 〜〜〜 func (a *Auth) HoldPermissionsHandler(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authHeaderParts := strings.Split(r.Header.Get("Authorization"), " ") tokenString := authHeaderParts[1] parser := func(token *jwt.Token) (interface{}, error) { cert, err := a.getPemCert(token) if err != nil { return nil, xerrors.Errorf("failed to getPemCert: %w", err) } result, err := jwt.ParseRSAPublicKeyFromPEM([]byte(cert)) if err != nil { return nil, xerrors.Errorf("failed to ParseRSAPublicKeyFromPEM: %w", err) } return result, nil } token, err := jwt.Parse(tokenString, parser) if err != nil { log.Printf("failed to ParseWithClaims: %v", err) next.ServeHTTP(w, r) return } if !token.Valid { log.Print("invalid token") next.ServeHTTP(w, r) return } claims, ok := token.Claims.(jwt.MapClaims) if !ok { log.Print("not implemented MapClaims") next.ServeHTTP(w, r) return } authenticatedUser := &AuthenticatedUser{ PermissionSet: util.NewBlankStringSet(), } for k, v := range claims { switch k { case "sub": authenticatedUser.ID = v.(string) case a.audience + "/email": authenticatedUser.EMail = v.(string) case "permissions": if permissionArray, ok := v.([]interface{}); ok { for _, permission := range permissionArray { authenticatedUser.PermissionSet.Add(permission.(string)) } break } } } next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), authenticatedUserKey, authenticatedUser))) }) }ざっくりと、このメソッドの要点だけ言うと、
claims, ok := token.Claims.(jwt.MapClaims)
によってJWTから取り出したクレームの中から、ログインユーザが持つPermissionを取得してリクエストスコープのコンテキストに格納している。クレームの中身をデバッグしてみると、こんな感じ。
Auth0のAPIのPermission設定で登録していった全てのPermissionが列挙されている。
これは、Adminロールを持つユーザでログインしたため。(例えばこれがNoneロールを持つユーザでログインした場合だと、持ってるPermissionは「user:read:mine
」だけとなる)認可チェック
実際の認可チェックはGraphQLの各リゾルバーの中で行う。
src/graph/movie.resolver.go〜〜〜 func (r *queryResolver) Movies(ctx context.Context) ([]*model.Movie, error) { user := auth.GetAuthenticatedUser(ctx) if !user.HasReadMinePermission("content") { err := xerrors.New("no permissions") log.Print(err) return nil, err } 〜〜〜 return results, nil }
user := auth.GetAuthenticatedUser(ctx)
によって、リクエストスコープのコンテキストに格納したPermission群を保持する以下構造体を取得できる。// 認証チェック済みのユーザー情報を保持 type AuthenticatedUser struct { ID string EMail string PermissionSet *util.StringSet }そして、
user.HasReadMinePermission("content")
により、Permission群の中に”自分の”content
にREAD
権限があるかどうかがチェックできる。
このあたりのチェック用に以下のユーティリティメソッド群を用意している。func (u *AuthenticatedUser) HasPermission(funcName string, crud operation, t target) bool { if u == nil || u.PermissionSet == nil { return false } return u.PermissionSet.Contains(fmt.Sprintf("%s:%v:%v", funcName, crud, t)) } func (u *AuthenticatedUser) HasNoTargetPermission(funcName string, crud operation) bool { if u == nil || u.PermissionSet == nil { return false } return u.PermissionSet.Contains(fmt.Sprintf("%s:%v", funcName, crud)) } func (u *AuthenticatedUser) HasReadAllPermission(funcName string) bool { return u.HasPermission(funcName, READ, ALL) } func (u *AuthenticatedUser) HasReadMinePermission(funcName string) bool { return u.HasPermission(funcName, READ, MINE) } func (u *AuthenticatedUser) HasCreatePermission(funcName string) bool { return u.HasNoTargetPermission(funcName, CREATE) } type operation string type target string const ( READ operation = "read" CREATE = "create" UPDATE = "update" DELETE = "delete" ALL target = "all" MINE = "mine" )これにより、リゾルバー毎に求めるPermissionを指定して認可チェックすることができる。
まとめ
いつも、まとめようとする頃には力尽きている・・・。
次は、Management系APIを使った機能の実装事例かdataloadenの事例かな。