RareJob Tech Blog

レアジョブのエンジニア・デザイナーによる技術ブログです

GoでRequest/Response BodyをロギングするHTTPサーバから見えてくるnet/http serverの実装

こんにちは。プラットフォームチームの池田です。

目だけは脱デジタルしようというきっかけでPodcastradiko、Audibleなどの音声コンテンツに最近ずっとハマっており、そのおかげでつよつよ家事マシンになりつつあります。

今回はゆるりとGoのnet/httpパッケージの内容を記事にします。

【目次】

やりたいこと

本記事にてやりたいことは以下2点です。

  • エラーレスポンス時にロギングとしてRequest/Response Bodyを標準出力するHTTPサーバを作りたい。
  • 上記に関連するGoの標準パッケージのnet/httpのserverな実装を追ってみたい。

弊社プラットフォームチームのGoなAPIサーバではGinフレームワークを採用していますが、今回はピュアなGoのnet/httpパッケージ内部を見てみることも目的としているのでミドルウェアを挟み込めるシンプルなHTTPルータを自作しサードパーティには依存しないようにします。

本記事で後述するロギングミドルウェアの実装方法はGinフレームワークでも同様になることを確認しています。

実行環境

$ go version
go version go1.17.2 darwin/amd64

準備 ~自作シンプルHTTPルータ~

上述の理由からシンプルなHTTPルータを作ります。

// シンプルHTTPルータ
type Router struct {
    pathFuncMap map[string]func(http.ResponseWriter, *http.Request)
    middlewares []func(next http.Handler) http.Handler
}

func (r *Router) HandleFunc(path string, f func(http.ResponseWriter, *http.Request)) {
    if r.pathFuncMap == nil {
        r.pathFuncMap = make(map[string]func(http.ResponseWriter, *http.Request))
    }
    r.pathFuncMap[path] = f
}

func (r *Router) AddMiddleware(mf func(handler http.Handler) http.Handler) {
    r.middlewares = append(r.middlewares, mf)
}

func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    // match path
    var handler http.Handler
    for p, f := range r.pathFuncMap {
        if p == req.URL.Path {
            handler = http.HandlerFunc(f)
        }
    }
    if handler == nil {
        handler = http.NotFoundHandler()
    } else {
        // set middleware
        for i := len(r.middlewares) - 1; i >= 0; i-- {
            handler = r.middlewares[i](handler)
        }
    }

    // serve http
    handler.ServeHTTP(w, req)
}

URLパスとアプリ用HTTPハンドラのペアをHandleFuncで、ミドルウェアAddMiddlewareで登録させます。リクエスト時にServeHTTP内でリクエストURLパスマッチ処理と登録されたミドルウェアを当て込んだハンドラ生成をします。

設定されるミドルウェアはスタックな後入れ先実行になり、一般的なフレームワークと同様に利用者は実装するミドルウェア上で次のハンドラーを呼び出すnext.ServeHTTPをする必要があります。

ミドルウェア設定無しで動かす

はじめに、ロギングしない場合でのサーバ実装と出力を示します。helloはRequest Bodyの中身に従って200 OKか400 Badを返すアプリ処理想定の単純なHTTPハンドラです。

// ロギング用ミドルウェア
func LoggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        next.ServeHTTP(w, r)
    })
}
// アプリケーションHTTPサーバ
func hello(w http.ResponseWriter, r *http.Request) {
    b, _ := io.ReadAll(r.Body)
    if string(b) != `{"a": "b"}` {
        w.WriteHeader(http.StatusBadRequest)
        w.Write([]byte(`{"res": "No hello"}`))
        return
    }
    w.WriteHeader(http.StatusOK)
    w.Write([]byte(`{"res": "hello"}`))
}

func main() {
    // 自作ルータをセット
    r := &Router{}
    r.HandleFunc("/hello", hello)
    r.AddMiddleware(LoggingMiddleware)

    http.ListenAndServe(":8090", r)
}

上記サーバを立ち上げリクエストすると以下のような動作になります。

$ curl -i -XPOST http://localhost:8090/hello -d '{"a": "b"}'
HTTP/1.1 200 OK
{"res": "hello"}

$ curl -i -XPOST http://localhost:8090/hello -d '{"a": "X"}'
HTTP/1.1 400 Bad Request
{"res": "No hello"}

このときサーバ側の標準出力にはまだ何も出てきません。

Request Bodyを出力する

Request Bodyをログとして出すようにしてみます。ハマりどころとして以下のように単純にやると処理がうまくいかなくなります。

失敗してしまう書き方

func LoggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        bufOfRequestBody, _ := io.ReadAll(r.Body)

        var out io.Writer = os.Stdout
        fmt.Fprintln(out, "request body: ", string(bufOfRequestBody))

        // アプリケーション処理
        next.ServeHTTP(w, r)
    })
}
$ curl -i -XPOST http://localhost:8090/hello -d '{"a": "b"}' # 200 OKが返るべきリクエスト
HTTP/1.1 400 Bad Request

こうなってしまう原因はロギング後のアプリ側helloハンドラでの b, _ := io.ReadAll(r.Body) にて空の結果しか返らなくなってしまうためです。

空になる原因を深掘る

net/httpの標準ライブラリを追うとわかります。

http.Request.Bodyio.ReadCloserインターフェース型であり実態であるインスタンスnet/httptransfer.goにあるbody構造体です。今回関係するフィールドのみ抜粋すると以下になります。

ソースコードリンク

type body struct {
    src          io.Reader
    // ... 省略
    sawEOF     bool
    closed     bool
    // ... 省略
}

io.ReadAll(req.Body)にてhttp.Request.BodyRead関数が呼ばれることになりますが、そこを追うと以下のコードがあります。

ソースコードリンク

func (b *body) Read(p []byte) (n int, err error) {
    b.mu.Lock()
    defer b.mu.Unlock()
    if b.closed {
        return 0, ErrBodyReadAfterClose
    }
    return b.readLocked(p)
}

// このメソッドを呼ぶときはb.muをロックしておかなければならない。
func (b *body) readLocked(p []byte) (n int, err error) {
    if b.sawEOF { // ② 読み込み済みならば即EOFを返す
        return 0, io.EOF
    }
    n, err = b.src.Read(p)

    if err == io.EOF { 
        b.sawEOF = true // ① 読み込み終わったらbodyにあるsawEOFフラグをtrueにする
        // Chunked case. Read the trailer.
        if b.hdr != nil {
            if e := b.readTrailer(); e != nil {
// ... 省略

上記の①②の箇所のようにbody.sawEOFフラグにより読み取り済みかそうでないかを制御しておりこれにより2回目以降は空の結果しか返らないことがわかります。

なぜRequest Bodyを読み切る制御をしているのか深掘る

結論として、クライアントとのTCP接続にてコネクションを再利用するかしないかの判定材料の1つにRequest Bodyを読み切ったかがあるためです。

以下の箇所はレスポンスヘッダー書き込みをする処理であり、この処理時にRequest BodyがClosedにも関わらずsawEOFがfalseのままである場合は接続自体が不良という判断になり、net/httpでのHTTP Responseの実態であるresponse構造体のcloseAfterReplyをtrueにします。

ソースコードリンク

func (cw *chunkWriter) writeHeader(p []byte) {
// ... 省略
            case bdy.closed:
                if !bdy.sawEOF {
                    // Body was closed in handler with non-EOF error.
                    w.closeAfterReply = true
                }

そして以下のようにcloseAfterReplyはServe処理の最後でTCP接続を保ち続けるか切るかの判断に使われます。

ソースコードリンク

func (w *response) shouldReuseConnection() bool {
    if w.closeAfterReply {
        // The request or something set while executing the
        // handler indicated we shouldn't reuse this
        // connection.
        return false
    }
// ... 省略

※ net/http内のコメントアウトを読む限りこれはHTTP1.1の仕様由来のようですが私の方ではRFCドキュメント内でそのような記述は確認できませんした。

成功する書き方

正しい書き方として、以下のようにio.NopCloserで消費されてしまったRequest Bodyを補修する必要があります。下記のような処理はリクエスト内容を出力するnet/http/httputil でのDumpRequestなどでも同様に実装されています。

       bufOfRequestBody, _ := io.ReadAll(r.Body)

+       // [For Request Body] 消費されてしまったRequest Bodyを補修する
+       r.Body = io.NopCloser(bytes.NewBuffer(bufOfRequestBody))

        var out io.Writer = os.Stdout
        fmt.Fprintln(out, "request body: ", string(bufOfRequestBody))

Response Bodyを出力する

Response Bodyを出力する場合も少し工夫が必要になります。というのもhttp.ResponseWriterインターフェースにはResponse Bodyにアクセスするメソッドが無いためです。

以下のようにGoの構造体への埋め込み(Embedding)を用いてhttp.ResponseWriterインターフェースの実態をハック(intercept)します。

type interceptWriter struct {
    http.ResponseWriter
    status int
    body   *bytes.Buffer
}

// Writeをハックする
func (w *interceptWriter) Write(b []byte) (int, error) {
    w.body.Write(b)
    return w.ResponseWriter.Write(b) // 埋め込み元を呼ぶことで元の動作を保証する
}

// WriteHeaderをハックする
func (w *interceptWriter) WriteHeader(statusCode int) {
    w.status = statusCode
    w.ResponseWriter.WriteHeader(statusCode)
}

// ロギング用ミドルウェア
func LoggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        bufOfRequestBody, _ := io.ReadAll(r.Body)
        // [For Request Body] 消費されてしまったRequest Bodyを修復する
        r.Body = io.NopCloser(bytes.NewBuffer(bufOfRequestBody))

        // [For Response Body] ResponseWriterへ割り込む (interceptする)
        iw := &interceptWriter{
            body:           bytes.NewBufferString(""),
            ResponseWriter: w, // 元のwriterインスタンスを埋め込む
        }
        w = iw

        // アプリケーション処理
        next.ServeHTTP(w, r)

        // レスポンスがエラーの場合、ログ出力する
        if iw.status >= 400 {
            var w io.Writer = os.Stdout
            fmt.Fprintln(w, "error status: ", iw.status)
            fmt.Fprintln(w, "request body: ", bytes.NewBuffer(bufOfRequestBody))
            fmt.Fprintln(w, "response body:", iw.body.String())
        }
    })
}

Go公式の以下にあるように埋め込み先の構造体で同名フィールドまたはメソッドを作った場合、そちらが優先される仕様になっています。

Embedding types introduces the problem of name conflicts but the rules to resolve them are simple. First, a field or method X hides any other item X in a more deeply nested part of the type.

埋め込み元としてのResponseWriterの実態はRequestの話にも出たresponse構造体です。以下リンクのWriteメソッドのコメントアウトの説明で最終的にコネクションのバッファに書き込まれる流れが記載されています。

ソースコードリンク

ログを確認する

ここまでのサーバを立ち上げ、エラーになるリクエストすると以下のようにサーバログ結果が確認できました。

クライアント側

curl -i -XPOST http://localhost:8090/hello -d '{"a": "X"}'
HTTP/1.1 400 Bad Request
{"res": "No hello"}

サーバ側ログ

error status:  400
request body:  {"a": "X"}
response body: {"res": "No hello"}

参考にさせていただいた記事

https://zenn.dev/imamura_sh/articles/retry-46aa586aeb5c3c28244e

https://tutuz-tech.hatenablog.com/entry/2020/03/23/162831

WebAIM の Contrast Checker を使いながら、コントラスト比を学ぶ

こんにちは。数ヶ月前からスキンフェードにはまっているネイティブアプリエンジニアの杉山です。
ぼかしを入れる作業の繊細さ、完成した時のグラデーションの美しさ、1週間程度で最高な状態を失ってしまう儚さ、全てが良い感じです。
今回は、「WebAIM」という Web サイトの Contrast Checker というツールを用いながら、コントラスト比の話をしたいと思います。

WebAIM とは

1999年に設立された非営利団体で、個人や組織が利用しやすいコンテンツを作成することで、障害を持つ人々への Web の可能性拡大をミッションとしています。

webaim.org

WebAIM の Contrast Checker を使ってみる

使い方は至って簡単で、Foreground Color と Background Color を入力するだけです。
これだけで、コントラスト比が適切かをチェックすることができます。
コントラス比が適切な場合は「Pass」と表示され、適切でない場合は「Fail」という表示が各項目に表示されます。
アプリなどで文字色指定や背景色指定する際に、試してみてください。

webaim.org

コントラスト比について

そもそも、コントラスト比とは 2色の輝度を使った比率のことを指します。
このコントラスト比に関しては、Web Content Accessibility Guidelines(WCAG)に Web で扱う際の適当な比率などが定義されています。

www.w3.org

コントラスト比がどのような計算式で求められているか見てみましょう。

(L1 + 0.05) / (L2 + 0.05)


L1 = 明るい色の相対輝度, L2 = 暗い色の相対輝度です。
相対輝度は、以下の式で求めることができます。

L = 0.2126 * R + 0.7152 * G + 0.0722 * B


RGBは、以下の式で求めることができます。

RsRGB <= 0.03928 then R = RsRGB/12.92 else R = ((RsRGB+0.055)/1.055) ^ 2.4
GsRGB <= 0.03928 then G = GsRGB/12.92 else G = ((GsRGB+0.055)/1.055) ^ 2.4
BsRGB <= 0.03928 then B = BsRGB/12.92 else B = ((BsRGB+0.055)/1.055) ^ 2.4

RsRGB = R8bit/255
GsRGB = G8bit/255
BsRGB = B8bit/255

値を当てはめて計算する際は、下から順にやっていくことで値を算出できます。
この値が WebAIM の Contrast Checker に表示されている Contrast Ratio です。

おまけ

上記の内容だけですとネイティブアプリエンジニアが記述する意味がない為、アプリに関することを少し記述します。
コントラスト比をお手軽に確認する手段として、Android ユーザーであれば「Accessibility Scanner」というアプリを使うのも良い方法です。
Android Studio Arctic Fox 以上であれば、開発環境上で不適切なコントラスト比の確認、修正が行えます。(修正に関しては、「Fix」ボタンを押すことで提案されたテキストカラーへ変更できます。)
・Accessibility のテストは、Espresso の 「AccessibilityChecks.enable()」を使用することで可能です。

redash v10 で追加された Excel データソースを試す

久しぶりの技術ブログになりました。塚田です。趣味は公園巡りです。

お昼休みに会社近くの代々木公園で鳩を眺めていたところで redash v10 のニュースが入ってきました。

github.com

change log を確認すると New Data Source: CSV/Excel Files との記載があり、新しく CSVExcel がデータソースとして加わったようです。

https://github.com/getredash/redash/blob/release/10.0.x/CHANGELOG.md

しかし オフィシャルサイトを見ると、これについてのドキュメントは 2021/10/4 現在見つかりません。(どこかにあったら教えてください)

そのため 今回 Excel に絞りソースコードを元に確認しました。

https://github.com/getredash/redash/blob/master/redash/query_runner/excel.py#L12

今回新しく追加されたデータソースで ガチガチに実装しているということではなく、Pandas のライブラリを使っているようです。

このため、集計に不要のレコード・カラムがあった場合スキップさせることができそうです。

https://pandas.pydata.org/docs/reference/api/pandas.read_excel.html

試してみた

ソースのデータ

サンプルとして、無料で公開されている Excel を使わせて貰いました。 https://file-examples.com/index.php/sample-documents-download/sample-xls-download/#google_vignette

f:id:sumito1984:20211004155537p:plain

redash から接続

以下のようなクエリを書いて接続しました。

url: 'https://file-examples-com.github.io/uploads/2017/02/file_example_XLS_10.xls'
user-agent: 'sample'
usecols: [1, 2, 4]
names:
  - First Name
  - Last Name
  - Country
skiprows: 0
skipfooter: 0
  • url ... 接続先の URL
  • user-agent ... 接続する際のユーザーエージェント
  • usecols: 利用するカラム
  • names: カラムに対するラベリング
  • skiprows & skipfooter: 読み込む際にスキップするカラム

結果

無事 Excel のデータを redash で描写することができました。

f:id:sumito1984:20211004160645p:plain

補足事項

Google driveExcel を置いて試しましたが Excel file format cannot be determined, you must specify an engine manually. というエラーが出ました。 Excel の拡張子で保存する必要があるようです。

それでは。

4種のPromiseメソッドの違いについて

長引くコロナ禍の中、お家時間をより充実したものにするために社内開発メンバーの皆さんにオススメ家電を聞いたり実際の使用感の伺ったりできるslackチャンネルを作ったことが上半期の自身の業務成果かなと思っているフロントエンドエンジニアの田原です。 \(^o^)/(お仕事もしてます

生活スタイルが皆さん異なるのでオススメ家電や感想等が違い色んな生の意見をもらえるので新しい発見もあり、まだまだリモートワークが続く中で比較的どんな人でも (家電は誰でもつかうと思うので)参加しやすい話題な気もしているのでコミュニケーションの一助にもなっていたら良いなと思ったりしております。

チームリーダーオススメのNature Remoはこの夏に購入して(というかこれ書いてる時にポチった)色んな家電をより使いやすくしたいなと画策しており、先行予約で手に入れたAladdin Connectorはまだ開封すらしていませんが、そろそろNintendo Switchと接続して夏のお家時間をより充実させたものにしたいなと思っております。

お話かわりまして、ES2021で追加予定の新しいメソッドのanyも含めてPromiseの静的メソッドについて違いや使い所等について今回は触れていきたいなと思います。

※尚、JavaScriptにおける非同期処理についてやPromiseの概要、await/async等については世の中に良い記事が溢れているので説明は割愛させて頂きます。

目次

4つの静的メソッド

1つの非同期処理についてはawait/asyncを使うことが多くなってきたかと思いますが、複数処理を扱う際のメソッドとして実験的なanyを含めて4つの静的メソッドが存在しており、これらを適所で使うことで非同期処理のハンドリングをよりパフォーマンスの良いものにすることが可能になります。

Promise.all

非同期処理を並列で同時実行しすべての非同期処理が解決(正常終了)するか、同時実行の内の1つでも拒否(Error等)されるまで待ちます。

【人で例えると】
約束の内容は全部守ろうとしてくれる義理堅い人(おそらく)

e.g.

async function all() {
  const first = new Promise((resolve, reject) => {
    setTimeout(() => resolve('1秒後に処理が完了'),1000)
  })
  const second = new Promise((resolve, reject) => {
    setTimeout(() => resolve('10秒後に処理が完了'),10000)
  })
  const result = await Promise.all([first, second]).catch((e) => cosole.info('error', e))
  if (result) {
    console.info('success', result)
  }
}
all()

【使い所】
複数のエンドポイントに並列的にrequestを投げ、全てのresponseが正常でない場合はerrorハンドリングする場合等

Promise.allSettled

非同期処理を並列で同時実行しすべての非同期処理が解決or拒否されるまで待ちます。

allは1つでも非同期処理がrejectされると途中まで走っていた別の処理の状況は無視して処理を止めてしまいますが、allSettledは各処理がどちらの 処理になろうとも設定された内容を返すまで待ちます。

ただし、rejectを行ってもcatch節に入らず(その為、allSettledの場合、catchは書かなくて良い)に必ず値が返り、 返ってくる値の内容も特殊で以下のようなobjectの形で返却されます。

{status: "fulfilled", value: xxxx } or
{status: "rejected", reason: xxxx } 

【人で例えると】
上手くいっても失敗しても最後まで約束したことはこなそうとしてくれる義理堅い人(きっと)

e.g.

async function allSettled() {
  const first = new Promise((resolve, reject) => {
    setTimeout(() => resolve('1秒後に処理が完了'),1000)
  })
  const second = new Promise((resolve, reject) => {
    setTimeout(() => resolve('10秒後に処理が完了'),10000)
  })
  const result = await Promise.allSettled([first, second])
  console.info('success', result)
}
allSettled()

【使い所】
複数のエンドポイントに並行的にrequestを投げるが、各responseの状態次第で振る舞いを変えたいとき等

Promise.race

非同期処理を並列で同時実行しどれか1つが解決or拒否されるまで待ちます。 拒否時はall同じになりますが、allと違うのは1つでも解決した場合はすぐに処理が終わるところです。

以下の例ではfirstが解決であっても拒否であっても必ず先に処理が走る為(1000msなので10000msより早い)、secondの処理は待たれることはありません。 番外編で後述しておりますが単体よりも組み合わせて使う際に効果を発揮する気がしております。

【人で例えると】
できる約束の内容を最速で対応してくれる義理堅い人(だと思う)

e.g.

async function race() {
  const first = new Promise((resolve, reject) => {
    setTimeout(() => resolve('1秒後に処理が完了'),1000)
  })
  const second = new Promise((resolve, reject) => {
    setTimeout(() => resolve('10秒後に処理が完了'),10000)
  })
  const result = await Promise.race([first, second]).catch((e) => console.info('error', e))
  if(result){
    console.info('success', result)
  }
}
race()

【使い所】
複数のエンドポイントに並行的にrequestを投げ、一番早く処理される内容次第でハンドリングしたい時等

Promise.any

ES2021に実装される実験的メソッドであり、1つでも解決した時点で終了します。 raceと似ていますが、raceは解決であっても拒否であってもであったのに対し、1つが解決しなくても他のものが解決すればその処理が終わるまで待ちます。 その為、全ての処理が拒否された時にのみcatch節に入ります。

【人で例えると】
約束が全部こなせないとわかるまで投げ出さない義理堅い人(多分)

e.g.

async function any() {
  const first = new Promise((resolve, reject) => {
    setTimeout(() => resolve('1秒後に処理が完了'),1000)
  })
  const second = new Promise((resolve, reject) => {
    setTimeout(() => resolve('10秒後に処理が完了'),10000)
  })
  const result = await Promise.any([first, second]).catch((e) => console.info('error', e))
  if(result){
    console.info('success', result)
  }
}
any()

【使い所】
並行的にrequestを投げた結果、全て拒否されるまではハンドリングするのを待ちたい&全部揃ってなくても返されるresponseだけで良い場合等

番外編

ちなみにここまで説明した各メソッドで他のメソッドをラップして実行を待つこともできます。

e.g.

async function allRace() {
  const first = new Promise((resolve, reject) => {
    setTimeout(() => resolve('1秒後に処理が完了'),1000)
  })
  const second = new Promise((resolve, reject) => {
    setTimeout(() => resolve('10秒後に処理が完了'),10000)
  })

  const result = await Promise.all([
    Promise.race([first, second]),
    Promise.race([first, second])
  ]).catch((e) => console.info('error', e))
  if(result){
    console.info('success', result)
  }
}
allRace()
  • この場合all->raceの処理となり:1秒後にfirstが2回raceとして返され、全ての値が返った状態のallとして配列が返ります。
  • その他、all->rase,allSettledの処理等もできるので組み合わせ次第で色んな待ち方ができそうです。

まとめ

各Promiseメソッドは人に例えるとみんな義理堅い人だったことがわかり.....

弊社フロントエンドではNuxtの機能であるserverMiddlewareを使ってBFFを導入しており、BFFサーバーの実装の中でAPIへのrequest処理をいい感じにしたい部分があったので色々と調べた結果、anyメソッドの登場もあったので(anyについては知らなかったので)備忘録も兼ねて書きました。 改めて色々と調べたりしたのでPromise完全に理解した(してない。でも少しは理解が深まった。)気がします。

弊社内のフロントの構成は以下スライドに内容記載しておりますのでご興味あればご一読頂けると嬉しいです。

参考

Figmaを使ってフロー図を書いてみた

お久しぶりです!デザイナーの渡辺です。

最近は自分が周りの人と違って
人間らしからぬ生活を送っているということに気づき
朝散歩をはじめました!
陽の光を浴びるのは大事ですね。(梅雨に入ったので休憩してます)

さて今回は業務中に、フロー図を書く機会がありましたので
せっかくならFigmaで書いてみようということで
その時どのように書いたのかを簡単にお話できればと思いますφ(..)

そもそもなんでFigmaでフロー図?

今回私がフロー図を書く際の前提として

  • 私はそこまで頻繁にフロー図を書くことはない
  • 大々的に共有するものではなく、軽く流れを提案したい程度
  • 使いやすければチーム内などで提案するというのあり

というとこから始まりました!
なので正直見やすいものが作れるならツールは何でも良かったです!

Figmaを選んだ理由としては
「見栄えのよさげなプラグインがありそう」
という理由です笑

色々見て選んだのは「Omnichart」というファイルです。
今回はプラグインではなくファイルを選んでます!

さっそく使ってみる

Communityから「Omnichart」を検索してDuplicateボタンをぽち。

f:id:yui_1027:20210623120946p:plain

ファイルが複製されるので開いてみます。

f:id:yui_1027:20210623121248p:plain
コンポーネント
f:id:yui_1027:20210623121311p:plain
フロー図とサイトマップの例

Omnichartにはコンポーネント集と
それを使って作ったフロー図とサイトマップの例が入っています。

もうこれコピーして中身変えれば終わりじゃん
そんな感じでサクッと作れてしまいました(゚∀゚)!

f:id:yui_1027:20210623120716p:plain
フロー図の一部

Omnichartのよいところ

基本的にカラー、パーツがコンポーネント化されているので
有料化しているチームにこのファイルを置き
assets→Librariesに登録してしまえば
チーム全員が同じカラー同じ見た目のフロー図が簡単に作れるというところですね!

f:id:yui_1027:20210623121631p:plain

コンポーネント化されているのでカラー変更や形状変更は簡単なので
Figmaに慣れていない方でも触りやすいのではないかなと思います!
カラーも決められている分、 「作る人によって色が違う!」ということになりません。

また、フロー図だけではなくサイトマップも作れるので
実際に作ったデザインとサイトマップを連携させるということも可能です。

Omnichartの注意点

Omnichartはファイルなので有料化されたチームでなければ
コンポーネントを共有できないという点と
ファイルの複製時にコンポーネント化されているパーツは一部になっているので
必要なパーツはチーム単位で1つ1つコンポーネント化する必要があるというところです。

コンポーネント化に関しては、1度やってしまえば終わりですので
少しめんどくさいのですが、やってもよいのかなと思います!

ただ、有料化に関しては使う場面によっては
難しい場合もあるので良し悪しは分かれそうです(._.)ムムム

まとめ

私個人としてはOmnichartを使うことでフロー図の見栄え統一、
サイトマップとのデザインの連携などメリットはあるものの
有料化しないと使い勝手は悪いという結論になりました。

ただ、我々デザインチームは有料チームを使用しているので チーム間での作成は問題なく、
業務効率を上げるためのフロー提案に使っていこうと思いました!

有料化チームがすでにある方は
デメリットはそんなにないと思うので一度試して見てはいかがでしょうか?
他にもよいプラグインやファイルがあるぞー!という方は
教えてくださると嬉しいです(`・ω・´)ゞ

Figmaについてはレアジョブアピールや
他のデザイナーも触れているのでそちらもご覧ください。

appeal.rarejob.co.jp

rarejob-tech-dept.hatenablog.com

それではノシ

GatlingとAWS Batchで作る負荷試験用攻撃サーバー

こんにちは。サービス開発チームの越です。

緊急事態宣言下で、在宅で仕事を行う時間が増えてきている中で、個人的に体調管理の目的でプチ断食を実践中です。

プチ断食をして、1日のうちに16時間程度ものを食べず、空腹時間をつくることで、オートファジーが働き、様々なメリットが得られるとのこと。

オートファジーとは人体の古くなった細胞を、内側から新しく生まれ変わらせる仕組みのことを言うらしいです。

さて、新しく生まれ変わらせるといえば、現在レアジョブ英会話のリプレース業務に携わっているところで、負荷試験を実施したときに使用したGatlingについての記事を書きたいと思います。

※ レアジョブ英会話のリプレース業務についての業務内容の説明はこちら

Gatlingとは

GatlingはHTTPリクエストとレスポンスを高速かつ並列に送受信して、Webアプリケーションの性能試験を行うためのツールになります。

Akkaフレームワークをベースに構築されているため、ノンブロッキングな非同期処理を軽量かつ並列に実行することが可能になっています。

Akka の特徴

Akkaのアクターは以下を提供します。

・分散性、並行性、並列性のためのシンプルで高レベルの抽象化。

・非同期でノンブロッキング、かつ、高性能のメッセージ駆動プログラミングモデル。

・とても軽量なイベント駆動処理(1 ギガバイトのヒープメモリにつき数百万のアクター)。

Akkaのアクターモデルは非常によくできており、Akkaの公式ページによると、1台のマシン上で毎秒5000万メッセージを送受信し、わずか1GBのヒープメモリの利用で250万ものアクターの生成を実現しています。

High Performance

Up to 50 million msg/sec on a single machine. Small memory footprint; ~2.5 million actors per GB of heap.

アクターは3つの要素で構成されており、1つのアクターは1つスレッドを持っています。

  1. 他のアクターから何らかのデータを受信するために用意するメッセージキュー
  2. メッセージキューから受け取ったデータを渡す先となるメッセージハンドラ
  3. メッセージキューからデータを取り出して、それをメッセージハンドラに渡す役目のスレッド

アクターモデルにより、Gatlingでは複数の仮想ユーザーによる、ユーザーシナリオの並列実行が実現されています。

マルチスレッドと非同期処理

非同期処理で重要になるマルチスレッドについて、ここでいうマルチスレッドとは複数のスレッドを使うということです。

OSはCPU上で実行するスレッドを適宜入れ替えます。

この時CPU上で完全に同時に実行可能なスレッドの上限はCPUコアの数と同じになるので、このあたりを踏まえてハードウェアの環境を用意する必要があります。

Gatlingの分散テスト環境を考える

負荷試験を実施しようとするときには、テストで使用するハードウェアの制限を考慮する必要があります。

過去にGatlingのgithubリポジトリのissueでも分散環境の議論がされていました。

[commented on 15 Feb 2012] Have a distributed mode for getting over the hardware and OS limits.

...

[commented on 29 Jul 2015] Done in commercial product

「ハードウェアとOSの制限を克服するための分散モードを用意します。」といって議論が始まり、「市販品の方(https://gatling.io/gatling-frontline/)で対応したよ」といってissueはcloseされている状況みたいなので、お金を払って分散テスト環境を用意するということもできます。

https://github.com/gatling/gatling/issues/415

そこで今回はAWS BatchとOSSのGatlingを使用して、分散テスト環境を構築することを考えてみます。

環境

用意する環境としては以下のような構成で実施する想定で考えます。

  • Gatlingによる負荷試験実施後に生成される実行結果のログファイルをアップロードするためのS3バケット
  • Gatlingを動かすAWS Batch
  • 攻撃対象サーバー

実行結果のログファイルがすべてS3にアップロードされたら、ログファイルをローカルにダウンロードして、レポートを生成します。

f:id:t_koshi:20210606022306p:plain

用意するファイル

Dockerfile
start.sh
gatling/
     ├── results/
     ├── conf/
     └── user-files/
          └── simulations/
                    └── gatlingTest/
                              ├── MainSimulation.scala
                              └── SubSimulation.scala

Dockerfile

  • Amazon Linux2
  • Amazon Corretto(無料の OpenJDK)
  • Gatling
  • aws-cli(S3への実施結果ログのアップロード用)
FROM amazonlinux AS base

WORKDIR /opt

ENV GATLING_VERSION 3.6.0

RUN mkdir -p gatling

RUN amazon-linux-extras enable corretto8 && \
    yum install -y java-1.8.0-amazon-corretto \
    wget \
    unzip

# gatlingのインストール
RUN mkdir -p /tmp/downloads && \
  wget -q -O /tmp/downloads/gatling-$GATLING_VERSION.zip \
  https://repo1.maven.org/maven2/io/gatling/highcharts/gatling-charts-highcharts-bundle/$GATLING_VERSION/gatling-charts-highcharts-bundle-$GATLING_VERSION-bundle.zip && \
  mkdir -p /tmp/archive && cd /tmp/archive && \
  unzip /tmp/downloads/gatling-$GATLING_VERSION.zip && \
  mv /tmp/archive/gatling-charts-highcharts-bundle-$GATLING_VERSION/* /opt/gatling/ && \
  rm -rf /tmp/*

# AWS CLIのインストール
RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" && \
    unzip awscliv2.zip && \
    ./aws/install && \
    rm -rf awscliv2.zip aws

WORKDIR  /opt/gatling
COPY start.sh .
RUN chmod 744 start.sh

ENV PATH /opt/gatling/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ENV GATLING_HOME /opt/gatling

FROM base AS local
VOLUME ["/opt/gatling/conf", "/opt/gatling/results", "/opt/gatling/user-files"]

FROM base AS aws
COPY gatling/conf /opt/gatling/conf
COPY gatling/user-files /opt/gatling/user-files
CMD ["start.sh"]

start.sh

内容

  1. Gatlingの負荷試験を実施する
  2. 実施した結果のログファイルをS3へアップロードする
#!/bin/sh
echo "AWS_BATCH_JOB_ID=$AWS_BATCH_JOB_ID";
aws --version
java -version

if [ $# != 1 ]; then
    echo ERROR: Uses className as the name of the simulation to be run;
    exit 1
fi

echo "gatling.sh -nr -s $1";
gatling.sh -nr -s $1

LOG_FILE_PATH=$(find /opt/gatling/results -type f -name "simulation.log" | head -n 1)
if [ -n "$LOG_FILE_PATH" ]; then
    echo "aws s3 cp";
    aws s3 ls s3://gatling-batch/$(date '+%Y%m%d')/
    aws s3 cp $LOG_FILE_PATH s3://gatling-batch/$(date '+%Y%m%d')/"$AWS_BATCH_JOB_ID.log" --dryrun
    aws s3 cp $LOG_FILE_PATH s3://gatling-batch/$(date '+%Y%m%d')/"$AWS_BATCH_JOB_ID.log"
else
    echo "simulation.log daes not found"
fi

負荷試験のシュミレーションファイルの用意

今回は以下の2つのシュミレーションファイルを用意しました。

  • MainSimulation.scala(0.5RPSのリクエストを60秒行うシナリオ)
  • SubSimulation.scala(0.2RPSのリクエストを60秒行うシナリオ)

※ RPS:Request Per Second

ローカル環境での動作確認

Docker imageのビルド

$ > docker build --target local -t=gatling-batch-local .

Dockerコンテナの作成とシナリオの実行

$ > docker run -it --rm -v "${PWD}/gatling/conf":/opt/gatling/conf \
-v "${PWD}/gatling/user-files":/opt/gatling/user-files \
-v "${PWD}/gatling/results":/opt/gatling/results \
gatling-batch-local /bin/sh -c "gatling.sh -s computerdatabase.BasicSimulation"

AWS Batchの環境用意

AWS ECRへpushするDocker imageのビルド

$ > docker build --target aws -t=gatling-batch .

Docker imageのビルドが完了したら、ECRへpushして、AWS Batchの環境構築を行います。

ECRにpushしたimageを実行するための環境を用意します。

  • ジョブ定義
  • ジョブキュー
  • コンピューティング環境(インスタンスロールにS3へアップロードできる権限を付与しておきます)

並列で実行したいので、ジョブキューにコンピューティング環境を複数台登録します。 f:id:t_koshi:20210606004057p:plain

AWS Batchの実行と結果

以下のようにジョブの送信を行います。

今回はMainSimulationを2つ、SubSimulationを1つ並列で実行させてみます。

$ > aws batch submit-job --job-name gatling-batch-job --job-queue gatling-batch-queue  --job-definition gatling-batch:2 --container-overrides command="/bin/sh","start.sh","gatlingTest.MainSimulation"

$ > aws batch submit-job --job-name gatling-batch-job --job-queue gatling-batch-queue  --job-definition gatling-batch:2 --container-overrides command="/bin/sh","start.sh","gatlingTest.MainSimulation"

$ > aws batch submit-job --job-name gatling-batch-job --job-queue gatling-batch-queue  --job-definition gatling-batch:2 --container-overrides command="/bin/sh","start.sh","gatlingTest.SubSimulation"

並列に実行されてそうですね。 f:id:t_koshi:20210606005227p:plain

実行が完了すると、S3のバケットに結果のログファイルがアップロードされました。 f:id:t_koshi:20210606005044p:plain

レポートの生成のために、実行結果のログをS3からローカルにダウンロードします。

$ > aws s3 cp s3://gatling-batch/$(date '+%Y%m%d')/ gatling/results/aws-batch/ --exclude "*" --include "*.log" --recursive

最後に実行結果のログからレポートを生成します。

$ > docker run -it --rm -v "${PWD}/gatling/conf":/opt/gatling/conf \
-v "${PWD}/gatling/user-files":/opt/gatling/user-files \
-v "${PWD}/gatling/results":/opt/gatling/results \
gatling-batch-local /bin/sh -c "gatling.sh -ro /opt/gatling/results/aws-batch"

f:id:t_koshi:20210606011323p:plain f:id:t_koshi:20210606011337p:plain

60秒間で、MainSimulationが60回(0.5RPS × 2台)、SubSimulationが12回(0.2RPS × 1台)、それぞれ意図したとおりに実行されていました。

まとめ

AWS Batchを利用することで、OSSのGatlingを使用して、分散環境を構築することができました。

攻撃用サーバーを分散して用意することで、ハードウェアの制限を気にせず、負荷試験のシナリオを用意して実行することができるようになりそうですね。

AppSync + DynamoDBの構成でマスターデータを入れる

どうも、@jumboOrNot です。 弊社ではAppSyncのユースケースが少しずつ増え始めており、色々と調査しています。今回はその中の一つのデータストアがDynamoDBの場合のマスターデータのセットアップについて話します。

ユースケース

お題の通り。 AWSでのサーバレスアプリケーションのよくある構成の中で、リリース前にそれなりのボリュームのマスターデータを入れておきたいことがあります。またサンプルデータを入れたい時とかも出てくる話かと思います。

解決方法1 aws コマンド経由で実行

よくある手段の一つです。 サンプルにもあるように batch-write-item コマンドを使うと複数行のデータの挿入が可能です。

aws dynamodb batch-write-item --request-items file://ProductCatalog.json

この場合、入れたいデータがCSVで用意されており、サンプルにあるようにデータの構造を整形するのは手間だったので、別の方法を検討しました。

{
    "ProductCatalog": [
        {
            "PutRequest": {
                "Item": {
                    "Id": {
                        "N": "101"
                    },
                    "Title": {
                        "S": "Book 101 Title"
                    },
                    "ISBN": {
                        "S": "111-1111111111"
                    },
                    "Authors": {
                        "L": [
                            {
                                "S": "Author1"
                            }
                        ]
                    },
.......

解決方法2 CloudFormationを使ってS3経由で取り込む

公式にも記載されている方法ですね。 もし CloudFormation に慣れ親しんでいたり、S3から定常的に実行する必要があればこの手法が良いと思います。 CloudFormationを使う以外にも、AWS Data Pipeline経由で実行する方法も紹介されていますが、ちょっと手順が面倒でした。

解決方法3 スクリプトを実装して実行

  1. PHPスクリプト + AWS SDKでDynamoDBへ直接書き込む
  2. jsスクリプト + Lambda + DynamoDBで Lambda経由で書き込む etc...

いろいろな手段はありますが、今回はサーバレスでのアプリケーション構築を前提としていたため、AppSync(GraphQL) のセットアップができていたため、これ経由でinsertしてしまうのが楽だと思い、今回はこの手法で実行しました。

コマンド(Javascript + aws-sdk + aws-appsync client)--> AppSync  --> DynamoDB

この方法が今後のアプリケーションのSchemeのメンテなどに追従しやすいと考えたためです。 CSVからAppSync経由でDynamoDBにinsertする実装例を下記に記載します。

実装例

const aws = require('aws-sdk')
const AWSAppSyncClient = require('aws-appsync').default
const gql = require('graphql-tag')
const fetch = require('node-fetch')
const fs = require('fs');
const csvSync = require('csv-parse/lib/sync'); // requiring sync module

const file = 'example_master.csv';
let data = fs.readFileSync(file);

let csvData = csvSync(data);

global.fetch = fetch;
const appSyncConfig = {
// AppSyncから発行する
}

const exampleQuery = gql`
mutation MyMutation(
  $example_id: Int!,
  $created_at: Int!){
    put(
      example_id: $example_id,
      created_at: $created_at
    ) {
      example_id
    }
  }
`

const config = {
  url: appSyncConfig.graphqlEndpoint,
  region: appSyncConfig.region,
  disableOffline: true,
  auth: {
    type: appSyncConfig.authenticationType,
    apiKey: appSyncConfig.apiKey
  }
}
const options = {
  defaultOptions: {
    watchQuery: {
      fetchPolicy: 'cache-and-network'
    }
  }
}

const client = new AWSAppSyncClient(config, options)

const sleep = msec => new Promise(resolve => setTimeout(resolve, msec));

async function run(){
  try {
    const currentUnixtime = Math.floor((new Date()).getTime()/1000) // set sleep

    for (var i = 0; i < csvData.length; i++){
      let column = csvData[i]
      if (!isNaN(column[0])){
        const data = {
          example_id: Number(column[0]),
          created_at: currentUnixtime
        }
        const response = await client.mutate({
          mutation: exampleQuery,
          variables: data
        })
        await sleep(200);
      }
    }


  } catch (err) {
    console.log('error posting to appsync: ', err)
  }
}

(async ()=>{
  await run();
})();

基本的にはAppSyncに用意したQueryをjsで呼び出してCSVから渡しているだけです。 上述の batch-write-item を使うとより早く実行できますが、今回はそこまで速度も必要なかったのでそのまま実装しています。

できそうだけどできない方法

現在のDynamoDBでは PartiQL をサポートしているため、SQLライクな構文でテーブルへのアクセスが可能です。 気持ち的にはここで複数行をまるっとinsertしたいお気持ちですが、公式にもあるようにこれはサポートされておらず、上述の2つの別のやり方をする必要があります。

気をつけること

DynamoDB には CapacityUnitの設定があり、デフォルト値は結構小さく設定されています。 そのため上述のサンプルのように sleep を入れていても、これの上限を超えて処理が中断されることがあります。 そのため実行するボリュームに応じてこの設定を変更してください。 もしくは一時的に オンデマンド に設定を変更することも可能です。

CloudWatchのMetricsを見れば実行中の消費状況が見れるので、ここで判断するのが良いかと思います。 コストトレードオフなパラメータなので注意が必要です。

まとめ

まだまだ進化し続けるDynamoDBですが、複数のデータのエクスポートやインポートをしようとすると小中規模のソリューションはがもうちょっと欲しいなと思いました。