20200524のGoに関する記事は14件です。

GitHub Wikiの目次(ToC)をURLから作成してくれるツールをGo+Ginで作る

GitHubのWikiを充実させたい => 目次手書きで書くのが面倒!

ということでGo + Ginを使ってToCジェネレータを作成しました。

Screen Shot 2020-05-24 at 23.23.39.png

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などとは別の正規表現エンジンのようでした。

image.png

https://regex101.com/

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.go
package 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からテンプレートファイルの読み込みに失敗してしまいました。

image.png

ディレクトリを個別に指定しようと 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.goapp.go を管理するか、自動デプロイ機能を組み入れる必要があります。

今後

今後時間があれば下記のように改善していければと思っています。

デプロイ

上記のVercelでのデプロイが失敗しているため、なんとか突破できれば…。

デプロイ自動化

GitHub Actions + GKE などができれば。

テスト

今回は実装優先で書いてしまったため、テストコードを書いていません。残念。

未実装項目

ToCジェネレータといいつつheadingがリンク化されていないため、その点を実装する必要があります。

ただし日本語のアンカー名の決定が難しそうなため、マークダウン => マークアップ化された内容を見る…といった方法が必要そうです。

GitHub 上の Markdown が TOC(目次) を表示してくれないのでどうしようか → ツール自製したよって話 - Qiita

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

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

IDE - 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.go

main から 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.go
package 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.go
package 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.go
package 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.go
package 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.go
package 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.go
package 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.go
package 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.go
package 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.go
package 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.go
package 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.go
package 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.go
package 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.go
package 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.go
package 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.go
package 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.go
package 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.go
package 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.go
package 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.go
package 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.go
package 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.go
package 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.go
package 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.go
package 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.go
package 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.go
package 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.go
package util

import "golang.org/x/xerrors"

func Log(org error) error {
    if org == nil {
        return nil
    }
    return xerrors.Errorf("[Log] error occurred: %w", org)
}

まとめ

「いや、こんな誤り、しないでしょ?」と思うかもしれないけど、、、実際、やっちゃってるんだよなぁ。。。
早く直したい。

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

Golang sync.WaitGroupとsemaphoreを使って10並列で画像ダウンロード

sync.WaitGroupとsemaphoreを使って10並列で画像ダウンロードするサンプルスクリプトです。
並列実行数が10を超過しないように、semaphoreを使っています。便利です
sync.semaphore の実装を読んでみると面白いですよ

golang.org/x/sync/semaphoreを使ってゴルーチンの同時実行数を制御する を参考にしつつ実装してみました

main.go
package 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-24 21.38.32.png

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

【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{} という宣言を初めて見たときは、一見なんのこっちゃ感があるので、この手法はメリットとデメリットの両方がありそうだなと個人的には感じました。
意図しない動作を防ぐことができるため、ライブラリを作成したりする場合は特に使う場面があるのかもしれないですね。

このようなテクニックを使用しない場合でも、明示的でわかりやすく、予期しない動作をしないよう、構造体の初期化時にはフィールド名は指定していきたいおきもちです。

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

bykof/statefulを使って、Golangで状態遷移(State machine)を扱う

はじめに

ビジネスロジックを実装していると、状態の管理はいつもついて回ります。
この辺のgolangでの実装について、bykof/statefulがいい感じだったので紹介します。

bykof/statefulの使い方

例として、以下のようなとある注文システムの状態遷移図を実現してみます

image.png

1. 状態を定義する

まずは状態遷移図の○部分、状態の定義を stateful.DefaultState を使って行います。

statemachine/definition.go
import "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.go
type 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.StateMachineAddTransition を利用します。
statefulパッケージではstate machineに1で定義したobjectを渡して使うため、登録する関数はそのobjectのメソッドで定義するのが扱いやすそうです。

statemachine/state_machine.go
        stateMachine := 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.go
func (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.go
        stateMachine.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.go
        err = machine.Run(order.Order, &product1)
        if err != nil {
                fmt.Printf("Error")
                return
        }

sample code

本家のsampleはこちら

この記事に記載したコードはこちらに置いてあります。

参考

Programming business processes in Golang

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

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)

以上のようにデコード自体は難しくないのですが、あまり載っていなかったので苦労しました

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

プロセスをあいまい検索してkillするツールをGoで作った

はじめに

仕事しているとプロセスをkillすることがたまにあると思います。

大体はpsawkgrepで必要なプロセス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する面倒さから開放されます。
是非試してみてください

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

Diff+Historyコマンド=hifferで快適なターミナル作業しようぜ

まず、こういう記事がありまして。

テストに対する考え方「Testing Manifesto」を翻訳したので紹介します

で、モヤっと
「俺らSIerは何で全部設定してから最後にまとめてテストやるんだろう?
途中で設定崩れたり、最後一回きりの確認じゃ見過ごしも起こるよな。
もっと、テスト駆動っぽく作業すれば良いのに」

とか思って、

シェルをラップして一回設定したら、
その値が崩れないように裏でチェックして
お知らせしてくれるツール作ったら
臨時の手動設定/運用作業がすこぶる捗るな。

こういう事をピコーンと思いついてツイートしたわけです。
続いて

今度作ってみよう。

と書いてしまったのでニーズはわからんが、似たようなもの無いし、有言実行!

作ってみた。

どんなのですかー

シェルをラップして、コマンドを実行したらその出力をハッシュで
記録しHistoryコマンドみたいにリストで持っておきます。
テストは自動的にもう一度実行して差分があれば表示してくれます。

hiffer.gif

(Alter Linuxは見栄えが良いですね。)

だから何に使うんだってばよ

普通にいつもの設定作業して、ちょこまかテスト回して差が出ないかを見れば
テスト回数がリニアに上がって品質爆上げになりますよ!(たぶん)

windows.png

Linuxだけじゃなくて、Windows版もクロスプラットフォームであります。バイナリも作っておきましたので拾ってくださいな

completer.gif

シェルのラップとして使いものになるようにファイル名の自動補完も付けました。

どこにあるんだい?

ここにおいときますんで、詳しくはリポジトリ側を見てくださいな。

あとがき

どっかのコミュニティでOSSを見てくれるトコがあったらこれ見てもらいたいので是非教えてもらえると非常に嬉しいです。

よろしくお願いします!!

あ、テストツールなのになんでテストコード無いって話ですか!?
動かしたいって声一個もらえたらの低モチベーションでして・・

FYI

Goで標準出力をキャプチャする

Go言語で文字コード変換

A Golang port of the UNIX Less utility

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

【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}
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

golang raceについて 競合チェック

golan raceについて試してみた

-raceをつけることで、競合チェックができる

race.go
package 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

参考

golang Data Race detector

日本語訳(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 -race

Excluding 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の出力には表示されません。

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

テストで外部APIを実際に叩いたりしませんか? GoでDIによるテストモック

はじめに

TwitterAPIや形態素解析APIを使うサービスでテストをしたいときがあると思います。
また、レイヤードアーキテクチャ等で下層の処理を含めたテストしたいときがあると思います。
まさか、TwitterAPIを実際に叩いたりしたテストをしていませんか?

このような場合はテストモックを使ってテストを行うのが一般的です。
Goではモックライブラリを使わずに、自前でモックすることが多いのです。

テストと強気にでましたが、ここではユニットテストを指しています。

実装

以下のTwitterAPIクライアントをサービス層で利用するとします。

twitter.go
package 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.go
package 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.go
package 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

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

テストで外部APIを実際に叩いたりしていませんか? GoでDIによるテストモック

はじめに

TwitterAPIや形態素解析APIを使うサービスでテストをしたいときがあると思います。
また、レイヤードアーキテクチャ等で下層の処理を含めたテストしたいときがあると思います。
まさか、TwitterAPIを実際に叩いたりしたテストをしていませんか?

このような場合はテストモックを使ってテストを行うのが一般的です。
Goではモックライブラリを使わずに、自前でモックすることが多いのです。

テストと強気にでましたが、ここではユニットテストを指しています。

実装

以下のTwitterAPIクライアントをサービス層で利用するとします。

twitter.go
package 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.go
package 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.go
package 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

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

[テスト入門]外部APIを実際に叩いたりしていませんか? GoでDIによるテストモック

はじめに

TwitterAPIや形態素解析APIを使うサービスでテストをしたいときがあると思います。
また、レイヤードアーキテクチャ等で下層の処理を含めたテストしたいときがあると思います。
まさか、TwitterAPIを実際に叩いたりしたテストをしていませんか?

このような場合はテストモックを使ってテストを行うのが一般的です。
Goではモックライブラリを使わずに、自前でモックすることが多いのです。

テストと強気にでましたが、ここではユニットテストを指しています。

実装

以下のTwitterAPIクライアントをサービス層で利用するとします。

twitter.go
package 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.go
package 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.go
package 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

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

GraphQLにおける認証認可事例(Auth0 RBAC仕立て)

お題

以下の組み合わせで作成しているWebアプリケーションにAuth0による認証認可機能を入れてみる。
認証はID(メールアドレス)とパスワードによる方式を採用。

■通信方式
 ・GraphQL
■フロントエンド
 ・Vue.js
 ・Nuxt.js
 ・TypeScript
 ・Apollo
■バックエンド
 ・Golang
 ・gqlgen

挙動としては以下のようになる。

(1)ログイン前。「LOG IN」ボタンを押下する。
Screenshot at 2020-05-23 15-31-42.png

(2)Auth0のログイン画面(カスタマイズもできるらしい)に飛ばされる。メアドとパスワードを入れて「Continue」ボタンを押下する。
Screenshot at 2020-05-23 15-32-02.png

(3)認証が通るとアクセストークン付きでコールバック(あらかじめAuth0に設定しておく)が呼ばれる。
Screenshot at 2020-05-23 15-33-43.png

(4)ログイン後のトップ画面を表示する。
Screenshot at 2020-05-23 15-34-01.png

(5)サイドメニューから「Movie」を選択して動画一覧ページに遷移する。
Screenshot at 2020-05-23 15-34-37.png

(6)ログインユーザに権限があるので動画一覧ページが表示される。
Screenshot at 2020-05-23 15-34-53.png

(7)いったんログアウトする。
Screenshot at 2020-05-23 15-35-12.png

(8)別の(動画一覧表示権限が与えられていない)ユーザでログインする。
(9)サイドメニューから「Movie」を選択して動画一覧ページに遷移する。
(10)ログインユーザに権限がないので動画一覧ページが表示されない。
Screenshot at 2020-05-23 15-38-31.png

画面的には、そっけないエラーページだけど、コンソールログを見ると権限がない旨のエラーログが出ている。
Screenshot at 2020-05-23 15-39-26.png

前提

  • 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.4

IDE - WebStorm

WebStorm 2020.1
Build #WS-201.6668.106, built on April 7, 2020

# バックエンド

# 言語 - Golang

$ go version
go version go1.13.9 linux/amd64

IDE - Goland

GoLand 2020.1.2
Build #GO-201.7223.97, built on May 1, 2020

関連記事索引

全ソース

バックエンド分

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を選択している。
それが正しいのかは、ちょっとわかっていない。
screenshot-manage.auth0.com-2020.05.23-17_07_36.png

ログインやログアウトの処理でAuth0のSDKを使うわけだけど、その際の接続先に関する情報として以下が必要。
screenshot-manage.auth0.com-2020.05.23-16_44_54.png

また、Auth0での認証処理が成功した後に再びアプリ側に戻ってくるためのコールバックURLを指定。
あと、ログアウト後の遷移先URLも。
screenshot-manage.auth0.com-2020.05.23-16_52_56.png

バックエンドの設定について

バックエンドはWebAPIとして機能するのでAPIsで設定。
screenshot-manage.auth0.com-2020.05.23-16_09_53.png

フロントエンドから渡されたJWTのチェックをするために Identifier を使う。
screenshot-manage.auth0.com-2020.05.23-17_12_51.png

あと、今回は認可機能の実装のために、Auth0が提供しているRBACの仕組みを使うので、Auth0上で定義したRoleとPermissionがJWTのClaimに積まれるようにしておく。
screenshot-manage.auth0.com-2020.05.23-17_15_21.png

RoleとPermissionの定義

WebAPIが提供する(予定の)各機能にPermissionを定義していく。
この部分は、WebAPIがどんな機能を提供するかや、どういった粒度でPermission制御が必要かについて設計した上で設定する。
今回は仮に「組織」、「ユーザ」、「コンテンツ(今回だと動画コンテンツ)」といったリソースに対するCRUD操作それぞれに認可制御をする想定で設計。
また、APIを叩いた(認証済み)ユーザが、自分で所有(自分で登録したものや他の人から権限を与えられたもの)するものしか権限がないのか、すべてのリソースに対する権限があるのかという観点でも分けてみた。
まあ、実際のアプリケーションでは、もっといろんな状況があると思うので、このあたりは、要件によりけり。
また、Auth0では、別にAuth0上のWebAPI定義にPermissionを細かく作っていかなくても、実際に動かすアプリケーションの方で機能ごとにPermissionを定義して、それをユーザ作成時にアプリケーションの機能としてAuth0ユーザのMetadataに貼り付けるといったこともできる。
柔軟に制御したいといった場合は、Metadata使う方式の方が後々困らないかもしれない。

ともあれ、とりあえず今回は以下みたいな感じでPermissionを定義。
screenshot-manage.auth0.com-2020.05.23-17_43_34.png
            ・
            ・
            ・
screenshot-manage.auth0.com-2020.05.23-17_45_15.png

で、ユーザにはそれぞれのPermissionを1つ1つ付与していくのではなくRoleを付与するので、Roleを作成。
これも、Roleの定義は作りたいアプリケーションの要件によってまちまちなので、今回のは参考程度に見てもらえると。
screenshot-manage.auth0.com-2020.05.23-17_29_53.png

このRoleに先ほどのPermissionを割り当てていく。
要するに、このRoleはこのWebAPIに関してどれだけの機能を叩くPermissionを有しているかを設定していく。
先ほどのPermissionもそうだけど、こういった作業を画面でポチポチやるのが地味に時間を要する。
1回きりといえばそうなんだけど、例えば、実際の開発現場では、開発環境、ステージング環境、本番環境を用意し、Auth0のテナントもそれぞれに用意するはず。
各環境用にこの画面ポチポチをつどつどやれって言われたら、正直しんどい。
まあ、たぶん、設定のExport/Import機能くらいあるんだろう、きっと。
Auth0の設定画面になくても、Auth0が用意するManagement系のAPIを使うと、わりとさくっとできるかな。

話がズレた。とりあえずRoleごとのPermission割り当てだけど、AdminとNoneの事例を。
screenshot-manage.auth0.com-2020.05.23-17_54_12.png
            ・
            ・
            ・

Adminは要するに全Permission持ってるということ。

screenshot-manage.auth0.com-2020.05.23-17_54_31.png

Userを作成

このあたりは実際のサービスなら、まず、1人だけAdmin的な権限のあるユーザを作成。
Adminの人だけが触れる管理画面でも用意して、Auth0のManagement系APIを使って、別のRole別のユーザを作成していくといった流れになるんだろう。
今回は、まだそういう機能は用意してないので、とりあえず適当に3ユーザくらいAuth0の画面から作成。
screenshot-manage.auth0.com-2020.05.23-18_01_49.png

フロントエンド実装

Auth0ではSPAアプリ用にSDKが用意されている。
でも、今回ぐらいの例だとNuxtが提供しているAuthモジュールを使うことで事足りるので、SDKは使わない。

nuxtjs/auth導入

以下参照。
https://auth.nuxtjs.org/guide/setup.html

今回用の設定は下記。

src/nuxt-config.js
export 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

domainclient_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〜〜〜

【参考】
https://stackoverflow.com/questions/61277352/nuxt-apollo-module-request-authorization-header-with-double-bearer

ログインページ

関連ソース

$ 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へ認証をかけにいくことができる。
つまり、先述の↓に飛ばしてくれる。
Screenshot at 2020-05-23 15-32-02.png
なんだかもう、いろいろなことを隠蔽してくれていて、便利なんだけど怖い。

ログイン完了後、Cookieにauth._token.auth0という名前でトークンが書き込まれている。
Screenshot at 2020-05-23 22-44-08.png

たぶん設定の問題だけど、デフォルトでLocalStorageの方にも同じ内容で書き込まれている。
Screenshot at 2020-05-23 22-44-39.png

コールバックページ

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にトークンが入っている状態で動画一覧ページの表示を試みると、はい、開く。
Screenshot at 2020-05-23 22-55-21.png

このとき、ChromeのDevツールでNetworkタブより「Headers」を見てみると、authorizationヘッダにCookieに入っていたトークンが指定されている。
Screenshot at 2020-05-23 22-57-01.png

つまり、nuxt.config.jsに記述した apolloモジュールとauthモジュールの設定だけで、Auth0ログインにて取得したJWTをHTTPヘッダに積んでGraphQL通信する流れが出来上がっている。

さて、試しに、CookieとLocalStorageからトークンを消して、動画一覧ページを開こうとすると、
Screenshot at 2020-05-23 23-08-54.png
Screenshot at 2020-05-23 23-09-10.png

失敗する。(ただ、これはどちらかというと通信自体に失敗した感じ)
Screenshot at 2020-05-23 23-12-07.png
Screenshot at 2020-05-23 23-12-26.png

トークンを消すのではなく改ざんっぽく変えてみてから、再度、動画一覧ページを開こうとすると、
画面上は同じエラー画面だけど、通信ログがこのようになる。
401 Unauthorized」これは、わかりやすい。
Screenshot at 2020-05-23 23-16-47.png

ともかく、適切なトークンを積んでないと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的にログアウト処理を実行してくれる。
あ〜、便利。
ログアウトされて、ログインページが表示される。
Screenshot at 2020-05-23 23-31-33.png
この状態で、あえて、ログイン後に遷移できるはずのトップページの表示を試みると、
Screenshot at 2020-05-23 23-31-49.png
表示できず、ログインページにリダイレクトされる。
Screenshot at 2020-05-23 23-32-06.png

バックエンド実装

さて、バックエンド側。

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.go
package 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.go
package 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を取得してリクエストスコープのコンテキストに格納している。

クレームの中身をデバッグしてみると、こんな感じ。
Screenshot at 2020-05-24 00-17-16.png
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群の中に”自分の” contentREAD 権限があるかどうかがチェックできる。
このあたりのチェック用に以下のユーティリティメソッド群を用意している。

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の事例かな。

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