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

Go Web開発の大枠

Web開発の大枠

今日学んだWeb開発の大枠の流れについてまとめてみた。

(参考)
Goプログラミング実践入門 標準ライブラリでゼロからWebアプリを作る

HTTP通信の流れ

まずHTTP通信の流れを理解する。

1.クライアントがサーバにリクエストを送る。
 ※この時Cookieなどのデータがあれば一緒に送る

2.サーバ側で処理

3.クライアントにレスポンスを返す

サーバが行う処理の流れ

次に実際にサーバで行われている処理を理解する。

1.クライアントからのリクエストをマルチプレクサが受理する

2.URIを解析し、指定のハンドラに処理を要求する

3.ハンドラで処理を行う
 ※必要なデータがあればデータベースにデータを要求し、データモデルとなる 構造体でデータを受け取る

4.ハンドラでテンプレートエンジンを呼び出し、テンプレートを作成

5.マルチプレクサに処理した内容を送り、クライアントにレスポンスを返す。

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

Go Module Mirror から壊れたパッケージが落ちてくる

$ go version
go version go1.14.1 darwin/amd64

TL;DR

  • パッケージのメンテナがリリースを間違えるとミラーから永遠に壊れたパッケージが落ちてくるのでユーザーは気をつけよう
  • パッケージをリリースする人は一度ミラーされたバージョンは永遠に消えないのでかなり気をつけよう

概要

今日、Go 向け Discord クライアントライブラリの DiscordGo にアップデートが降ってきました:

bwmarrin/discordgo

さて、早速自身のプロジェクトの依存関係のアップデートを試みます:

$ go get -u github.com/bwmarrin/discordgo
go: downloading github.com/bwmarrin/discordgo v0.21.0
go: downloading golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9
go: downloading github.com/gorilla/websocket v1.4.2
go: downloading golang.org/x/sys v0.0.0-20200620081246-981b61492c35
go: github.com/gorilla/websocket upgrade => v1.4.2
go: golang.org/x/crypto upgrade => v0.0.0-20200604202706-70a84ac30bf9
go: golang.org/x/sys upgrade => v0.0.0-20200620081246-981b61492c35
# github.com/bwmarrin/discordgo
../../../../go/pkg/mod/github.com/bwmarrin/discordgo@v0.21.0/wsapi.go:852:19: (*Session).Close redeclared in this block
        previous declaration at ../../../../go/pkg/mod/github.com/bwmarrin/discordgo@v0.21.0/wsapi.go:846:6

なんと、ライブラリのビルドに失敗してしまいました。

原因を探る

壊れたバージョンをそのままリリースしてしまったのでしょうか。GitHub でホストされているコードを確認します:

bwmarrin/discordgo

wsapi.go:846

// Close closes a websocket and stops all listening/heartbeat goroutines.
// TODO: Add support for Voice WS/UDP
func (s *Session) Close() error {
    return s.CloseWithCode(websocket.CloseNormalClosure)
}

// CloseWithCode closes a websocket using the provided closeCode and stops all
// listening/heartbeat goroutines.
// TODO: Add support for Voice WS/UDP connections
func (s *Session) CloseWithCode(closeCode int) (err error) {

    s.log(LogInformational, "called")
    s.Lock()
...

特に問題があるようには見えません。

では、実際にダウンロードされたソースコードを見てみます。

$GOPATH/pkg/mod/github.com/bwmarrin/discordgo@v0.21.0/wsapi.go:846

func (s *Session) Close() error {
    return s.CloseWithCode(websocket.CloseNormalClosure)
}

// Close closes a websocket and stops all listening/heartbeat goroutines.
// TODO: Add support for Voice WS/UDP
func (s *Session) Close() error {
    return s.CloseWithCode(websocket.CloseNormalClosure)
}

なんと、壊れたコードがダウンロードされているのです

Go Module Mirror

Go 1.13 以降では、モジュールをダウンロードする際に Go Module Mirror を使うようになりました:

golang/go

The go tool now defaults to downloading modules from the public Go module mirror at https://proxy.golang.org, and also defaults to validating downloaded modules (regardless of source) against the public Go checksum database at https://sum.golang.org.

proxy.golang.org からソースコードのミラーを落とし、 sum.golang.org データベースに対してチェックサムの検証を行います。

つまり、今回は Go Module Mirror から壊れたソースコードが落ちてきている疑いがあります。実際に確認をしてみます。

https://proxy.golang.org/github.com/bwmarrin/discordgo/@v/v0.21.0.zip にアクセスすると、ミラーされているソースコードが落ちてきます。これを展開し、中身を確認します:

wsapi.go:846

func (s *Session) Close() error {
    return s.CloseWithCode(websocket.CloseNormalClosure)
}

// Close closes a websocket and stops all listening/heartbeat goroutines.
// TODO: Add support for Voice WS/UDP
func (s *Session) Close() error {
    return s.CloseWithCode(websocket.CloseNormalClosure)
}

go get で落ちてきたものと全く同じ、壊れたコードです。

壊れたコードがミラーされている

なぜ壊れたコードが Go Module Mirror や Go Checksum Database に登録されてしまったのでしょうか。

リリースされたブランチを見てみると、実は一度壊れたバージョンがリリースされてしまったのではないかと推察できます。

現在の v0.21.0 タグの指すコミット:

Fix double commit on merge · bwmarrin/discordgo@cead8c7

1つ前のコミット:

Release version v0.21.0 · bwmarrin/discordgo@1294b31

おそらく、現在の v0.21.0 タグがされたコミットの1つ前のコミットが一度リリースされ、後から現在のコミットへタグを張り替えたと考えられますが、 git reflog できないので真相はわかりません。

また、 Checksum Database への登録や、 Module Mirror へミラーされるタイミング等が明確に説明されたドキュメントを見つけられていません。

以下のページより:

Go 1.13 に向けて知っておきたい Go Modules とそれを取り巻くエコシステム - blog.syfm

しかし、sumdb が存在していても、世界中で初めてあるモジュールを使用する場合はその真正性をチェックできないという問題はあります。

とあるように、Module Mirror は Checksum Database への初めてリクエストがあった際にミラーや登録が行われていると考えられますが、公式のドキュメントをご存知でしたら是非ご教示ください。

回避

ユーザとしてできる回避策としては、一時的に Go Module Mirror を利用せず、ツリーから直接パッケージを取得すれば問題はありません:

fish shell なので env GOPROXY=direct としていますが、 bash 等なら GOPROXY=direct としてください ( env 不要)

$ go clean --modcache
$ env GOPROXY=direct go get github.com/bwmarrin/discordgo@v0.21.0
go: downloading github.com/bwmarrin/discordgo v0.21.0
go get: github.com/bwmarrin/discordgo@v0.21.0: verifying module: checksum mismatch
        downloaded: h1:a4V4v2IPHPy7l5XVbjJkJAj9R2Lhvz7vs5I4Mq3OFYk=
        sum.golang.org: h1:jGuwVZTUHZBUFZ3sm5cOqrwphGQWeL0/9XkaCbDEcrs=

SECURITY ERROR
This download does NOT match the one reported by the checksum server.
The bits may have been replaced on the origin server, or an attacker may
have intercepted the download attempt.

For more information, see 'go help module-auth'.

go clean --modcache を行わないと、先程ダウンロードされた壊れたソースコードに対してビルドを試みるために失敗します。

また、当然 Checksum Database に対しての検証は失敗するため、セキュリティエラーを吐きます。

根本的な解決

結構ありがちな問題らしく、 proxy.golang.org へブラウザからアクセスした際に閲覧できる FAQ に記載があります:

Go modules services

I removed a bad release from my repository but it still appears in the mirror, what should I do?
Whenever possible, the mirror aims to cache content in order to avoid breaking builds for people that depend on your package, so this bad release may still be available in the mirror even if it is not available at the origin. The same situation applies if you delete your entire repository. We suggest creating a new version and encouraging people to use that one instead.

ミラーされたパッケージを修正や削除する方法は無いようです。

そのため、パッケージのメンテナに新しいリリースを作成してもらうのが現状ではベターだと考えられます。

今回は Issue を作成しました:

v0.21.0 mirror hosted by proxy.golang.org is broken · Issue #783 · bwmarrin/discordgo

まとめ

Module Mirror が導入された時より、「いつか壊れたリリース降ってくるんじゃないか…」と思っていましたが、実際に降ってきてしまったため、良い機会になりました。パッケージをリリースする際は気をつけましょう。


Go Module Mirror から壊れたパッケージが落ちてくる - Qiita

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

GoでHTTPサーバを立ち上げる(超基礎)

GoでHTTPサーバを立ち上げる

server.go
package main

import (
    "fmt"
    "net/http"
)

func handler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprint(w, "HTTPserver")
}

func main() {
    http.HandleFunc("/", handler)
    http.ListenAndServe(":8080", nil)
}

ハンドラを定義する

func handler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprint(w, "HTTPserver")
}

・1つ目の引数:レスポンス先
・2つ目の引数:リクエストの受付
・{ }内はハンドラで行う処理内容

ハンドラとエントリポイントを結びつけ

 http.HandleFunc("/", handler)

・1つ目の引数:エントリポイント
・2つ目の引数:呼び出したいハンドラ

サーバの立ち上げ

 http.ListenAndServe(":8080", nil)

・1つ目の引数でポート番号を決める

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

Golangで自作パッケージに対して入力補完が効かない場合

Golangを試している時にハマったので、事象と対応方法のメモです。

環境

OS:mac
エディタ:Visual Studio Code(Goの拡張機能インストール済み)
Golang Version:go1.14.4 darwin/amd64

自作パッケージに対して入力補完が効かない?

下記のような自作パッケージを作成して、
別のソースから呼び出そうとしたところ、入力補完が効かない・・・

package testpackage

import "fmt"

func Test() {
    fmt.Println("test")
}

image.png

解決策

go install {パッケージ名} コマンドを実行すると入力補完が効くようになりました。

go install sample/testpackage

image.png

このコマンドを実行することで、pkgフォルダ配下に静的ライブラリファイルが作成されて
入力補完が効くようになったようです。

普段IDEで楽しているから、無駄にハマってしまいました。

毎回installを実行するのも面倒だから
VSCodeのGoの拡張機能とかで設定できないかな。。。

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

Golangで自作パッケージに対して入力補完が効かない

Golangを試している時にハマったので、事象と対応方法のメモです。

環境

OS:mac
エディタ:Visual Studio Code(Goの拡張機能インストール済み)
Golang Version:go1.14.4 darwin/amd64

自作パッケージに対して入力補完が効かない?

下記のような自作パッケージを作成して、
別のソースから呼び出そうとしたところ、入力補完が効かない・・・

package testpackage

import "fmt"

func Test() {
    fmt.Println("test")
}

image.png

解決策

go install {パッケージ名} コマンドを実行すると入力補完が効くようになりました。

go install sample/testpackage

image.png

このコマンドを実行することで、pkgフォルダ配下に静的ライブラリファイルが作成されて
入力補完が効くようになったようです。

普段IDEで楽しているから、無駄にハマってしまいました。

毎回installを実行するのも面倒だから
VSCodeのGoの拡張機能とかで設定できないかな。。。

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

次の水曜日の18:30をtimeパッケージで作る

はじめに

Goのtimeパッケージで「次の水曜日の18:30」を作る方法を備忘録として残します。ここではエラー処理は省略します。
作ったコードは以下の通りです。

// ロケーション情報を取得
loc, _ := time.LoadLocation("Asia/Tokyo")

// 指定ロケーション(Asia/Tokyo)の現在時刻を取得
now := time.Now().In(loc)

// 指定ロケーション(Asia/Tokyo)の18:30を作成
next := time.Date(now.Year(), now.Month(), now.Day(), 18, 30, 0, 0, loc)

// 水曜日まで進める
d := time.Wednesday - next.Weekday()
if d <= 0 {
    d += 7
}
next = next.AddDate(0, 0, int(d))

fmt.Println(next)

playgroundで試してみる

コードの説明

timeパッケージのドキュメントはこちらにあります。詳細ドキュメントを参照してください。ここでは上記のコードの説明だけを簡単に記載します。

ロケーション情報を取得

ローカルPC上や固定のサーバ上で動作させる場合local指定でもいいと思いますが、時差のある場所で動作させる場合に困るのでロケーションはAsia/Tokyoを指定しました。
loctimeパッケージtype Locationです。

loc, _ := time.LoadLocation("Asia/Tokyo")

仕様はこちら
func LoadLocation

現在時刻を取得

上で作ったlocを使って、現在時刻("取得日"と表現します)を取得します。

now := time.Now().In(loc)

ロケーション指定しない場合は次のようにfunc (Time)Inを使わずに書きます。

now := time.Now()

仕様はこちら
func Now
func (Time)In

18:30を作成する

他にも方法はあると思いますが、timeパッケージのexampleにならった方法にしました。(Durationのexampleを参照)
func Dateは年月日と時刻を与えてtype Timeを取得する関数です。
年月日には上で取得した現在時刻(取得した日付)を使うことで、現在時刻を"次の水曜日"を探す基準にしています。
func Dateの引数の時刻部分はintなので使い勝手がいいです。

next := time.Date(now.Year(), now.Month(), now.Day(), 18, 30, 0, 0, loc)

仕様はこちら
func Date

次の水曜日まで進める

曜日はtimeパッケージtype Weekdayiotaを使ってSunday=0としてSaturday=6まで連番で定義されているので、水曜日と"取得日"の曜日の差分を使って次の水曜日に進めます。(この方法は@tenntennさんに教えてもらいました)
"取得日"の曜日はfunc (Time)Weekdayで取得します。
差分d==0の場合は該当曜日が"取得日"と同じことを表します。"取得日"が木曜日など該当曜日=水曜日よりも後の場合はd<0となるため、d<=0の場合は+7日して次週の日程とします。

d := time.Wednesday - next.Weekday()
if d <= 0 {
    d += 7
}

日付を進めるためにはfunc (Time)AddDateを使います。この関数はyear, month, dayを引数に取り、指定されただけ日付を進めます。1日ずつ進めるには第三引数day=1を指定します。

next = next.AddDate(0, 0, int(d))

仕様はこちら
func (Time)Weekday
func (Time)AddDate
type Weekday

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

[LINE Bot] LIFFとリッチメニューでも管理画面が作りたい! 2 -リッチメニュー切替-

この記事はこの記事の続きです
[LINE Bot] LIFFとリッチメニューでも管理画面が作りたい! 1 -グループ管理-

リッチメニューを作る

さて、前回はグループへの招待・退会で管理メンバーの追加・削除をできるようにしました。
その際にリッチメニューをユーザ用と管理者用に切り替えます。
今回はそのリッチメニューを作ります。

今回はユーザー用を 3 x 1 (1200 x 405)
管理者用を 3 x 2 (1200 x 810) のメニューを作ります。
動作確認のため、上の段はユーザと同じものにします

Line Messaging APIでリッチメニューを作る

Line Account Managerで作成したリッチメニューは残念ながらAPIでは使えません。
画像を準備し、APIで作成する必要があります。
画像はpngかjpgで1MB以内です。

pythonで適当に書いたので、ライブラリをインストールします。

pip install line-bot-sdk
pip install pyyaml
pip install boto3

botのシークレットキーなどは、serverless.ymlで環境変数として読み込んでいるymlをそのまま読み込みます。

conf/line-dev.yml
CHANNEL_SECRET: "シークレット"
CHANNEL_TOKEN: "トークン"
ADMIN_GROUP_ID: "前回作成した管理部屋のグループID"

ソースコード

お作法としては
- デフォルトのリッチメニューを解除する
- 全てのリッチメニューを削除する
- ユーザ用リッチメニューを作成する
- 管理者用のリッチメニューを作成する
のようになります。
既存のリッチメニューを削除しておかないと、1000件までの上限にそのうち引っかかります。

richmenu.py
#!/usr/bin/env python3

import sys
import requests
import yaml
import boto3
from linebot import (
    LineBotApi
)
from linebot.models import (
    RichMenu, RichMenuArea, RichMenuSize, RichMenuBounds,
    PostbackAction, URIAction,
)


def main():

    with open('conf/line-'+env+'.yml') as file:
        yml = yaml.safe_load(file)

    admin_group_id = yml['ADMIN_GROUP_ID']
    access_token = yml['CHANNEL_TOKEN']

    // botを作成する
    line_bot_api = LineBotApi(access_token)

    // デフォルトのリッチメニューを解除する
    cancel_richmenu(line_bot_api)
    line_bot_api.cancel_default_rich_menu()

    // 既存のリッチメニューを削除する
    delete_richmenu(line_bot_api)

    // ユーザ用メニューを作成する
    id = create_user_richmenu(line_bot_api)
    upload_user_richmenu_image(line_bot_api)

    // 全てのユーザにユーザ用メニューをデフォルトに設定する
    line_bot_api.set_default_rich_menu(id)

    // 管理者用メニューを作成する
    id = create_staff_richmenu(line_bot_api)
    upload_staff_richmenu_image(line_bot_api, id)

    // 管理者に管理者用リッチメニューを設定する
    set_staff_richmenu(line_bot_api, id )

// 全てのリッチメニューを削除する
def delete_richmenu(line_bot_api):
    print("delete user richmenu")
    menu_list = line_bot_api.get_rich_menu_list()

    for richmenu in menu_list:
        print("delete user richmenu "+richmenu.rich_menu_id)
        line_bot_api.delete_rich_menu(richmenu.rich_menu_id)

// ユーザ用リッチメニュー
def create_user_richmenu(line_bot_api, chara_name):
    print("create user richmenu")
    user_menu = RichMenu(
        size=RichMenuSize(width=1200, height=405),
        selected=True,
        name="minarai-chan user menu",
        chat_bar_text="お店情報・ご注文",
        areas=[
            RichMenuArea(
                bounds=RichMenuBounds(
                    x=0,
                    y=0,
                    width=400,
                    height=405,
                ),
                action=PostbackAction(
                    label='何ができるの?',
                    displayText='何ができるの?',
                    data="how_to_use",
                ),
            ),
            RichMenuArea(
                bounds=RichMenuBounds(
                    x=400,
                    y=0,
                    width=400,
                    height=405,
                ),
                action=URIAction(
                    label='shop',
                    uri='ユーザ向けLIFFのURL',
                ),
            ),
            RichMenuArea(
                bounds=RichMenuBounds(
                    x=800,
                    y=0,
                    width=400,
                    height=405,
                ),
                action=URIAction(
                    label='order',
                    uri='ユーザ向けLIFFのURL',
                ),
            ),
        ]
    )
    id = line_bot_api.create_rich_menu(rich_menu=user_menu)
    print("user richmenu id = "+id)
    return id

def upload_user_richmenu_image(line_bot_api, id):
    print("update user richmenu "+ id)
    with open('script/image/richmenu/user.png', 'rb') as f:
        line_bot_api.set_rich_menu_image(id, 'image/png', f)

# 3 x 2 のリッチメニュー。上3段はユーザ用と同じ
def create_staff_richmenu(line_bot_api, chara_name):
    print("create staff richmenu")
    user_menu = RichMenu(
        size=RichMenuSize(width=1200, height=810),
        selected=False,
        name="minarai-chan user menu",
        chat_bar_text="管理メニュー",
        areas=[
            RichMenuArea(
                bounds=RichMenuBounds(
                    x=0,
                    y=0,
                    width=400,
                    height=405,
                ),
                action=PostbackAction(
                    label='何ができるの?',
                    displayText='何ができるの?',
                    data="how_to_use",
                ),
            ),
            RichMenuArea(
                bounds=RichMenuBounds(
                    x=400,
                    y=0,
                    width=400,
                    height=405,
                ),
                action=URIAction(
                    label='shop',
                    uri='ユーザ向けLIFFのURL',
                ),
            ),
            RichMenuArea(
                bounds=RichMenuBounds(
                    x=800,
                    y=0,
                    width=400,
                    height=405,
                ),
                action=URIAction(
                    label='order',
                    uri='ユーザ向けLIFFのURL',
                ),
            ),
            # 下の段
            RichMenuArea(
                bounds=RichMenuBounds(
                    x=0,
                    y=405,
                    width=400,
                    height=405,
                ),
                action=URIAction(
                    label='staff_nanika',
                    uri='LIFFのURL',
                ),
            ),
            RichMenuArea(
                bounds=RichMenuBounds(
                    x=400,
                    y=405,
                    width=400,
                    height=405,
                ),
                action=URIAction(
                    label='staff_hoge',
                    uri='LIFFのURL',
                ),
            ),
            RichMenuArea(
                bounds=RichMenuBounds(
                    x=800,
                    y=405,
                    width=400,
                    height=405,
                ),
                action=URIAction(
                    label='staff_order',
                    uri='LIFFのURL',
                ),
            ),
        ]
    )
    id = line_bot_api.create_rich_menu(rich_menu=admin_menu)
    print("admin richmenu id = "+id)
    return id

# 管理者用画像アップロード
def upload_staff_richmenu_image(line_bot_api, id):
    print("update staff richmenu "+ id)
    with open('script/image/richmenu/admin.png', 'rb') as f:
        line_bot_api.set_rich_menu_image(id, 'image/png', f)

# 管理者用のリッチメニュー
def set_staff_richmenu(line_bot_api, id):
    // DBから管理者のユーザIDを取得しておく
    ...

    line_bot_api.link_rich_menu_to_user('管理者のユーザID', id)
    return


リッチメニューを切り替える

管理部屋に招待・退会した時のイベント処理は前回書きましたが、
そこにリッチメニューの切り替え処理を書きます。

リッチメニューのIDは環境変数にしています。

line/user.go
func (r *Line) LinkRichMenu(users []string) {
    // Line Messaging API リッチメニューを複数ユーザに紐付ける
    _ = r.bot.BulkLinkRichMenu(r.richMenuID, users...)
}

func (r *Line) UnlinkRichMenu(users []string) {
    // Line Messaging API リッチメニューを複数ユーザから解除する
    _, _ = r.bot.BulkUnlinkRichMenu(users...).Do()
}

これで、リッチメニューの切り替えは完了です!

次回、LIFFとAPIで管理者認証を行う。

LIFFからアクセストークンを取得し、サーバー側でユーザIDに変換し、管理者であるかどうかを認証します。(明日書きます)

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

echo APIがGoのオリジナルエラーをハンドリングしてくれたよ

初めに

Qiita…初カキコ…ども…
俺みたいな中3でログ見てる腐れ野郎、他に、いますかっていねーか、はは
it’a true wolrd.狂ってる?それ、400ね。

と言う訳で,駆け出しエンジニアのすなるQiita記事というものを書いてみたいと思います!
(お手柔らかにお願いします……)

動機

エラーのハンドリングを全体でカスタマイズする動機は,大きく分けて以下の2つがあると思われます.
1. Webページで,独自のエラーページに誘導したい
2. APIで,独自エラーをハンドリングしたい

恐らく2.に関してはアドホックにその場で対応すれば十分な場合が多く,あまり恩恵は得られないかもしれません.
どちらかというと,大規模なWebページを作る際に独自のエラーページを使用する事が多く,
また独自エラーハンドリングの書き方で調べて出て来るのが1でしたので,
1の需要が大きい様に思われます.

しかし,「APIが大規模化した場合」且つ「独自エラーをハンドリングしたい場合」は重要性が高まると期待されます.
他の方の記事でも近いものがありました2が,ここまで複雑にするつもりが無かったので簡素化したいと思います.特に様々なエラーが飛び交う環境だと一つ一つ改修するのは柔軟性が失われ大変な気がします.
従って需要は見込めないと言えども,本記事では主に2に関して書きます.

(日本語の解説が少なくてあれだったので自分用(便利な言葉)にも書き残して置きます.)

デフォルト動作の理解

Defaultで呼ばれるエラーハンドラーはこちら
場当たり的にブログのコピペばかりをすると全体像を捉えられず,整合性に掛けて無駄の多いパッチワークによるキメラコードが生まれるので,まずは公式を頑張って読んでみたいと思います.
とは言え,間違いがあるかもしれない御戯れコーナーですので,忙しい人は次項目へどうぞ.

echoのソースコードを(一部)読んでみた

公式曰く,よくある使われ方は以下らしいです1

package main

import (
  "net/http"

  "github.com/labstack/echo"
  "github.com/labstack/echo/middleware"
)

// Handler
func hello(c echo.Context) error {
  return c.String(http.StatusOK, "Hello, World!")
}

func main() {
  // Echo instance
  e := echo.New()

  // Middleware
  e.Use(middleware.Logger())
  e.Use(middleware.Recover())

  // Routes
  e.GET("/", hello)

  // Start server
  e.Logger.Fatal(e.Start(":1323"))
}

17行目で初めにEchoインスタンスeを作成し,そのeに様々なライブラリで用意されたものをセットしています(20~27行目).
ここでエラーが起き得るのは,handlerのhello()ですがここで投げられたerrorはどこへ行くのか調べてみたいと思います.

ではライブラリの中を見てみましょう. 最新のecho.go (2020.06.20 現在) を用います.
するとServeHTTP() 中,622~625行目にて以下の様な記述があります.
("net/http"server.goがリクエストを受け付けているようですが,難しかったので割愛しここから見てみます.)

echo.go(622~625)
    // Execute chain
    if err := h(c); err != nil {
        e.HTTPErrorHandler(err, c)
    }

恐らくここのhにリクエストに対応したハンドラー (上の例だとhello()) が入っており,ハンドラーの投げるエラーがe.HTTPErrorHandler(err, c)によってハンドリングされるのだと思います.

では HTTPErrorHandler() の正体に迫ってみたいと思います.

ここで思い出したいのが,New()です.これによってEchoのインスタンスが作成され,色々と設定していました.デフォルト値はどうなっているのでしょうか?

echo.go(296~320)
func New() (e *Echo) {
    e = &Echo{
        Server:    new(http.Server),
        TLSServer: new(http.Server),
        AutoTLSManager: autocert.Manager{
            Prompt: autocert.AcceptTOS,
        },
        Logger:   log.New("echo"),
        colorer:  color.New(),
        maxParam: new(int),
    }
    e.Server.Handler = e
    e.TLSServer.Handler = e
    e.HTTPErrorHandler = e.DefaultHTTPErrorHandler
    e.Binder = &DefaultBinder{}
    e.Logger.SetLevel(log.ERROR)
    e.StdLogger = stdLog.New(e.Logger.Output(), e.Logger.Prefix()+": ", 0)
    e.pool.New = func() interface{} {
        return e.NewContext(nil, nil)
    }
    e.router = NewRouter(e)
    e.routers = map[string]*Router{}
    return
}

長過ぎて全て読んだ初心者の方は今頃発狂していると思われますが,
重要なのはe.HTTPErrorHandler = e.DefaultHTTPErrorHandlerで,これによってエラーが起きたらDefaultHTTPErrorHandler()が実行されるようになっているようです.

つまり通常はe.HTTPErrorHandlerDefaultHTTPErrorHandler()が設定されていてこれが使われるっぽいです.
では,DefaultHTTPErrorHandler()を読んでみましょう.

echo.go(344~381)
// DefaultHTTPErrorHandler is the default HTTP error handler. It sends a JSON response
// with status code.
func (e *Echo) DefaultHTTPErrorHandler(err error, c Context) {
    he, ok := err.(*HTTPError)
    if ok {
        if he.Internal != nil {
            if herr, ok := he.Internal.(*HTTPError); ok {
                he = herr
            }
        }
    } else {
        he = &HTTPError{
            Code:    http.StatusInternalServerError,
            Message: http.StatusText(http.StatusInternalServerError),
        }
    }

    // Issue #1426
    code := he.Code
    message := he.Message
    if e.Debug {
        message = err.Error()
    } else if m, ok := message.(string); ok {
        message = Map{"message": m}
    }

    // Send response
    if !c.Response().Committed {
        if c.Request().Method == http.MethodHead { // Issue #608
            err = c.NoContent(he.Code)
        } else {
            err = c.JSON(code, message)
        }
        if err != nil {
            e.Logger.Error(err)
        }
    }
}

(色々Issueがあるみたいですね.挑戦すればcontributorになれるんですかね.)

3パートあるので分割して見てみます.
詳しい事は分かりませんが,入力で受け取ったerrHTTPErrorかどうか判定を行い,もし該当すればStatusCodeを持っている筈なので取り出します.しかし違った場合は,StatusCodeが500のHTTPErrorインスタンスを作ってくれるそうです.Msghttpライブラリが提供する500のデフォルトっぽいですね.

he, ok := err.(*HTTPError)
if ok {
  if he.Internal != nil {
    if herr, ok := he.Internal.(*HTTPError); ok {
      he = herr
    }
  }
} else {
  he = &HTTPError{
    Code:    http.StatusInternalServerError,
    Message: http.StatusText(http.StatusInternalServerError),
  }
}

疲れたのとあまり関係なので残り2パートは省きますが,メッセージを取り出してMap化し,それを基にレスポンスの形式を良い感じにJSON形式にしてくるみたいです,凄いですね.
(e.Debag が一番よく分かりませんでした.デバッガで見てたんですがここで大変なエラーが起きて大変でした.)

公式が最大手

Error Handling

公式1にエラーハンドリングについての記述があったので見てみましょう.
近年流行りのDeepLと協力して日本語に訳しました.

Echoは、ミドルウェアやハンドラからエラーを返すことで、HTTPのエラー処理を一元化することを提唱しています。エラーハンドラを一元化することで、統一された場所から外部サービスにエラーを記録し、カスタマイズされたHTTPレスポンスをクライアントに送ることができるようになります。

標準のエラーを返すこともできますし、echo.*HTTPErrorを返すこともできます。

例えば、基本的な 認証ミドルウェアが無効な認証情報を検出した場合、401 - Unauthorized エラーを返し、現在の HTTP リクエストを中止します。

e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
  return func(c echo.Context) error {
    // Extract the credentials from HTTP request header and perform a security
    // check

    // For invalid credentials
    return echo.NewHTTPError(http.StatusUnauthorized, "Please provide valid credentials")

    // For valid credentials call next
    // return next(c)
  }
})

メッセージを指定せずにecho.NewHTTPError()を使用することもできます。その場合,ステータスのテキストはエラーメッセージとして使用されます.例えば、"Unauthorized"といった具合です。

まずコードに関して,無名関数がハンドラーとして設定されていますね.
これはnext echo.HandlerFuncを受け取ってecho.HandlerFuncを返す関数らしいです.
で,どんな関数を返すかというとまたもや無名関数で,どうやらecho.Contextを引数にerrorを返すよくあるハンドラーを定義しているようです.因みにここでは見て来た様に,関数自体をやり取りしているのでこれだけでは動かないですね.
更に// Extract the credentials from HTTP request header and perform a securityの辺りで恐らく認証情報をヘッダーから取り出す動作をするように見えます.
そしてコメントアウトされていないecho.NewHTTPError(http.StatusUnauthorized, "Please provide valid credentials")がエラーを生成して其の場でerrorとして返していますね.
もしコメントアウトされているnext(c)が元気に動作していれば,引数にとっているこの関数の返すerrorが返されることになるでしょう.

恐らく例にも上がっているように,英語での名称がエラメッセージとして使われるだけの簡素なものということでしょう.

Default HTTP Error Handler

Echo は、エラーをJSON形式で送信するデフォルトのHTTPエラーハンドラを提供します。

{
  "message": "error connecting to redis"
}

標準エラーの場合、レスポンスは 500 - Internal Server Error として送信されますが、デバッグモードで実行している場合は、元のエラーメッセージが送信されます。errorが*HTTPErrorの場合、レスポンスは提供されたステータスコードとメッセージで送信されます。ロギングがオンになっている場合、エラーメッセージも記録されます。

例にもありましたが,エラメッセージを何も書かないと上の様なJSONの"message"のvalueが“Unauthorized”などで返って来ると言うことだと思います.一番上の例だとecho.NewHTTPError(http.StatusUnauthorized, "Please provide valid credentials")を返しているので,

{
  "message": "Please provide valid credentials"
}

が返って来ます.

Custom HTTP Error Handler

カスタムHTTPエラーハンドラはe.HTTPErrorHandlerを介して設定できます。

ほとんどの場合、デフォルトのHTTPエラーハンドラで十分です。しかし、異なるタイプのerrorを捕捉し、それに応じてアクションを実行したい場合には、カスタムHTTPエラーハンドラが便利です。また、エラーページやただのJSONレスポンスなど、カスタマイズしたレスポンスをクライアントに送信することもできます。

Error Pages

以下のカスタムHTTPエラーハンドラは、異なるタイプのerrorのためのエラーページを表示し、errorをログに記録する方法を示しています。エラーページの名前は<CODE>.html のようにしてください。このプロジェクト https://github.com/AndiDittrich/HttpErrorPages を参考にしてください。

func customHTTPErrorHandler(err error, c echo.Context) {
    code := http.StatusInternalServerError
    if he, ok := err.(*echo.HTTPError); ok {
        code = he.Code
    }
    errorPage := fmt.Sprintf("%d.html", code)
    if err := c.File(errorPage); err != nil {
        c.Logger().Error(err)
    }
    c.Logger().Error(err)
}

e.HTTPErrorHandler = customHTTPErrorHandler

本稿の趣旨とは逸れますが,これはこれで便利で楽しそうですね.

デフォルトでInternalServerErrorのステータスコードを設定して,もしerrにステータスコードがあれば取り出して反映する.そしてステータスに対応するhtmlファイルを読み込んで来る.最後にロギングするという感じでしょうか.

詳しい説明は挙げられたリポジトリにデモがあるのでWebページを作る人は見てみると良いでしょう.



簡素化した独自のエラーハンドラーの作成

サンプル例に独自エラーのハンドラーを使ってみる

前項で,凡そどのようにエラーがデフォルトでハンドリングされるのか分かりました.
では,早速エラーのハンドリングをカスタマイズしてみましょう.

背景として,処理の中でMyError1, MyError2, MyError3 があると仮定しましょう.
また独自のエラーを本当に実装するとコードがQiitaでは読み難いのでエラーハンドラーに焦点を絞っています.

これらに関してHandler毎に中で扱うのは大変なので共通して分岐させるようにします.
Handlerで拾ったエラーを上に投げたら勝手に良い感じに処理してくれる感じですね.
特にWrapしまくったエラーを毎回取り出すとソースコードが大氾濫して可読性が下がるので,纏めたいところです.

example_server.go
package main

import (
    "net/http"

    "github.com/labstack/echo"
    "github.com/labstack/echo/middleware"
)

type (
    // Server wraps Echo to customize.
    Server struct {
        e *echo.Echo
    }
)

// Handler
func hello(c echo.Context) error {
    return c.String(http.StatusOK, "Hello, World!")
}

// MyErrorHandler wraps DefaultHTTPErrorHandler.
func (svr *Server) MyErrorHandler(err error, c echo.Context) {
    // Unwrap error
    if e, ok := err.(interface{ Unwrap() error }); ok {
        err = e.Unwrap()
    }

    // Switch response
    switch err.(type) {
    case *MyError1:
        // err = echo.NewHTTPError(http.StatusNotFound, err.Error())
        err = echo.NewHTTPError(http.StatusNotFound)
    case *MyError2:
    case *MyError3:
        err = echo.NewHTTPError(http.StatusBadRequest)
    }

    // DefaultHTTPErrorHandler
    svr.e.DefaultHTTPErrorHandler(err, c)
}

func main() {
    // Echo instance
    svr := new(Server)
    e := echo.New()
    svr.e = e

    // Middleware
    svr.e.Use(middleware.Logger())
    svr.e.Use(middleware.Recover())

    // HTTPErrorHandler
    svr.e.HTTPErrorHandler = svr.MyErrorHandler

    // Routes
    svr.e.GET("/", getFoo)

    // Start server
    svr.e.Logger.Fatal(e.Start(":1323"))
}

これでエラーのハンドリングができる筈です.今回は挨拶関数hello()なので人間と違ってエラーが起きる事は無いですが,ここを変えて動かしてみると良いんじゃないかと思います.

更に定義したMyErrorHandler()に関して取り出してみてみましょう.

example_server.go(MyErrorHandler部分)
// MyErrorHandler wraps DefaultHTTPErrorHandler.
func (svr *Server) MyErrorHandler(err error, c echo.Context) {
  // Unwrap error
  if e, ok := err.(interface{ Unwrap() error }); ok {
    err = e.Unwrap()
  }

  // Switch response
  switch err.(type) {
  case MyError1:
    // err = echo.NewHTTPError(http.StatusNotFound, err.Error())
    err = echo.NewHTTPError(http.StatusNotFound)
  case MyError2:
  case MyError3:
    err = echo.NewHTTPError(http.StatusBadRequest)
  }

  // DefaultHTTPErrorHandler
  svr.echo.DefaultHTTPErrorHandler(err, c)
}

大きく3パートで構成されます.
初めのUnwrap部分で一番下のエラーを取り出しswitchで比較できる形にした後,switchでerrHTTPErrorに変えてしまい,さら以後にそれをDefaultHTTPErrorHandlerに処理してもらいます.
追ってそれぞれについてみてみます.

example_server.go(Unwrap部分)
if e, ok := err.(interface{ Unwrap() error }); ok {
  err = e.Unwrap()
}
  • 「(^o^) Unwrap()って何です?」「if err != nil { return err } で返って来たエラーしかハンドリングしないんだが?」という方は恐らくここは不要の筈だと思います.

Wrapとは
  • ここはGo 1.13以降の機能fmt.Errof("%w", err)3などでWrapされたエラーから中身を取り出して一番下の中身を取り出しています.
  • 正しい使い方を正直分かっていないですが,私はエラーの起きた箇所を追跡する為にWrapしがちなので,最深層のエラーをこれで取り出しすのが日課です.これでWrapが実装されていないエラーにも柔軟に対応できますね.

example_server.go(switch部分)
switch err.(type) {
case MyError1:
  // err = echo.NewHTTPError(http.StatusNotFound, err.Error())
  err = echo.NewHTTPError(http.StatusNotFound)
case MyError2:
case MyError3:
  err = echo.NewHTTPError(http.StatusBadRequest)
}
  • おなじみのエラー解体ショーですね.ここでは,デフォルトで500に介錯されるエラーを所望のStatusCodeに変換しています.
    • 例えば,MyError1は404,MyError2,MyError3は400に解釈されます.
    • これにで後は任せてDefaultHTTPErrorHandlerで良い具合にエラーメッセージなどを作成してもらえます.
example_server.go(DefaultHTTPErrorHandler部分)
svr.echo.DefaultHTTPErrorHandler(err, c)
  • 自分でエラーメッセージを生成する必要がなかったので,そっくり其の儘流用します.
    • 一応 echo.NewHTTPError(http.StatusNotFound, err.Error()) で独自エラーのメッセージを使用できるらしいです.よく分かりませんが.
    • MyErrorHandlerをechoには生やせないのでServerでechoをWrapしています.
  • 一応頑張ればエラーメッセージも作成できると思いますが,一説によるとエラーから内部の構造を調べられる可能性があるので,エラーメッセージはデフォルトの儘が良いかもしれないです4
    • よくある例は,「ログインに失敗した時のエラーでアカウントの存在がバレる」や「空いているポートが調べられる」などですかね.

物足りない方に

コードはこちら

- curl -v "localhost:1323?foo=3 | jq"のようにリクエストを投げるとエラーを変えて動作を確認できます.(jqはJSONレスポンスを整形するので無くても可)
1. { "message": "Not Found" }
2. { "message": "this is MyError2" }
3. { "message": "Bad Request" }
example_server.go(full)
package main

import (
    "net/http"

    "github.com/labstack/echo"
    "github.com/labstack/echo/middleware"
)

type (
    // Server wraps Echo to customize.
    Server struct {
        e *echo.Echo
    }
)

// Error

// MyError1 is sample error.
type MyError1 struct{}

func (e *MyError1) Error() string { return "this is MyError1" }

// MyError2 is sample error.
type MyError2 struct{}

func (e *MyError2) Error() string { return "this is MyError2" }

// MyError3 is sample error.
type MyError3 struct{}

func (e *MyError3) Error() string { return "this is MyError3" }

// Handler
func getFoo(c echo.Context) error {
    foo := c.QueryParam("foo")
    switch foo {
    case "1":
        return &MyError1{}
    case "2":
        return &MyError2{}
    case "3":
        return &MyError3{}
    default:
        return c.JSON(http.StatusOK, foo)
    }
}

// MyErrorHandler wraps DefaultHTTPErrorHandler.
func (svr *Server) MyErrorHandler(err error, c echo.Context) {
    // Unwrap error
    if e, ok := err.(interface{ Unwrap() error }); ok {
        err = e.Unwrap()
    }

    // Switch response
    switch err.(type) {
    case *MyError1:
        err = echo.NewHTTPError(http.StatusNotFound)
    case *MyError2:
        err = echo.NewHTTPError(http.StatusNotFound, err.Error())
    case *MyError3:
        err = echo.NewHTTPError(http.StatusBadRequest)
    }

    // DefaultHTTPErrorHandler
    svr.e.DefaultHTTPErrorHandler(err, c)
}

func main() {
    // Echo instance
    svr := new(Server)
    e := echo.New()
    svr.e = e

    // Middleware
    svr.e.Use(middleware.Logger())
    svr.e.Use(middleware.Recover())

    // HTTPErrorHandler
    svr.e.HTTPErrorHandler = svr.MyErrorHandler

    // Routes
    svr.e.GET("/", getFoo)

    // Start server
    svr.e.Logger.Fatal(e.Start(":1323"))
}



結論

以上からめでたく,APIが独自エラーのハンドリングを行えるようになりました.
(Goを触って数か月のにわかなので間違っている箇所があればご指摘頂けると有難いです.)

とはいえ,やはり同じ400では何を直すべきかハンドリングできず困る場合もあると思います.
なんで怒っているか分からないパワハラ系上司のAPIを実装しても皆が不幸になる5ので,セキュリティ的には問題が無い少し詳細な程度のエラーメッセージを生成すると良いかもしれないですね.

参考

エラーの作り方に関しては以下の記事にお世話になりました.
分かり易かったので紹介させていただきます.

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

AWS X-Ray+Goを使ってSQSでのメッセージングを観測する

概要

タイトルの遠り、AWS X-Rayを使って、SQSでのメッセージングを観測します。X-Rayを使えば、複数のアプリケーション間で「どこからどこにメッセージが送られているか」が図示されるため、アプリ間通信の仕様や障害発生箇所の把握がしやすくなります。今回は、Go言語を使って実装します。

結論(ソース)はページ下部にあります。

環境

  • Go言語 1.14.3
  • aws-sdk-go
  • aws-xray-sdk-go
  • Mac OS X 10.15.4

前提として、aws-sdk-go, aws-xray-sdk-goはgo getコマンドでインストール済みとします。

この記事の対象読者

  • AWSの有名どころサービスなら開発したことがある。AWS CLIも設定済み。
  • SQSは知識があるがX-Rayはあまり知らない。X-Rayの概要や実装方法を知りたい
  • (実装部分については)Go言語はそれなりに読める

X-Rayの概要

アプリから情報を収集して、以下のようなサービスグラフを作成したり、リクエストの実行時間を計測することができます。アプリのパフォーマンス可視化や障害発生箇所の特定など、可観測性の向上に役立てることができます。また様々なAWSサービスと統合することができます。
image.png
出典:https://docs.aws.amazon.com/ja_jp/xray/latest/devguide/aws-xray.html

データ収集の仕組み

X-Rayは、アプリに組み込まれたSDKが情報を収集し、X-Rayデーモンを経由してコンソールに情報を表示します。以下のイメージがわかりやすいです。左下のアプリケーションからの流れになります。
※スクリプトやツールから直接X-RayのAPIへデータを送る流れはこの記事では扱いません。
image.png
出典:20200526 AWS Black Belt Online Seminar AWS X-Ray

そのため、以下2つの準備が必要になります。

  • SQSを実行するアプリにxray-sdkを組み込む
  • X-Rayデーモンの起動

X-Rayの主要な要素

今回の実装に関係する部分のみ記載します。

セグメント/サブセグメント

セグメントは、X-Ray上で処理を分割する単位です。アプリやリソース、ホスト名などがイメージしやすいです。
一方、サブセグメントはセグメントを分割した細かな処理の単位です。HTTPのリクエスト、SQSのキューなどを定義できます。
基本的には、設定した名前がX-Rayのサービスグラフ上で表示される名前となります。

トレースヘッダー

X-Rayでは、アプリケーション間の通信をトレースするためにトレースIDが発行されます。これがHTTP通信ヘッダーにX-Amzn-Trace-Idとして設定されることで、通信のトレースが可能となります。これをトレースヘッダーと呼びます。

今回のSQSとX-Rayの統合では、SQSのメッセージ送受信元のアプリをセグメントに設定し、メッセージングにトレースヘッダーを設定することでトレースを可視化します。

その他の概要やより詳細な説明は、公式のドキュメントやBlack Belt資料がわかりやすいです。
AWS X-Ray の概念
20200526 AWS Black Belt Online Seminar AWS X-Ray

SQSとX-Rayの統合

先述の「データ収集の仕組み」に沿って、2つの準備をします。

SQSを実行するアプリにxray-sdkを組み込む

aws-xray-sdk-goの基本的な使い方として、コンテキスト(context)を用います。Go言語のコンテキストは、HTTPリクエスト等に引数として渡すことでタイムアウトやキャンセルを可能とする仕組みですが、X-Rayではトレースのためにコンテキストを利用しているわけです。

contextの設定
  ctx, seg := xray.BeginSegment(context.Background(), "service-name")  // service-nameがセグメント名
  subCtx, subSeg := xray.BeginSubsegment(ctx, "subsegment-name")  // subsegment-nameがサブセグメント名
  // ...略...
  // 終了時にはcontectのクローズを行う
  subSeg.Close(nil)
  seg.Close(nil)

通常、SQSでメッセージを送信するにはSendMessageを用いますが、これにはコンテキストを渡すことができません。そのため、コンテキストを渡すせるようWithContextを付加した関数が用意されています。SQSへのメッセージ送信の場合だとSendMessageWithContextとなります。この関数にcontextを追加で渡すことでトレースヘッダーが設定されます。受信側でそれを受け取ることにより、トレースが可能となるわけです。

SQSへのメッセージ送信処理
resp, err := svc.SendMessage(params)  // 通常のメッセージ送信
resp, err := svc.SendMessageWithContext(ctx, params)  // X-Rayを使う場合のメッセージ送信

コンテキストを追加で渡すだけで、それ以外は元の関数と差異はありません。

X-Rayデーモンの起動

X-Rayデーモンは、実行可能ファイルがAWS公式から提供されています。ここから環境に合わせた実行可能ファイルをダウンロードして実行します。
AWS X-Ray デーモン

なお、MacOSの場合、「開発元を確認できないため開けません」というメッセージが表示されることがありますが、以下などを参考に回避可能でした。
Macで「開発元を確認できないため、開けません」と表示された時の対処法

ソース

実際にX-RaySDKを組み込んだGoのソースです。

  • メッセージ送信処理(sqs-sender)
sqs-send-sample.go
package main

import (
    "context"
    "fmt"
    "log"

    "github.com/aws/aws-sdk-go/aws"
    "github.com/aws/aws-sdk-go/aws/session"
    "github.com/aws/aws-sdk-go/service/sqs"
    "github.com/aws/aws-xray-sdk-go/xray"
)

var (
    QueURL         = "https://sqs.<リージョン>.amazonaws.com/<アカウントID>/<キューの名前>"
    AwsRegion      = "<リージョン>"
)

var svc *sqs.SQS

func SendMessage() error {
    // SQSクライアントをxrayでラップ
    xray.AWS(svc.Client)

    // キューに送るメッセージと送信時に渡す構造体を設定
    message := "hello"
    params := &sqs.SendMessageInput{
        QueueUrl:                 aws.String(QueURL),
        MessageBody:              aws.String(message), 
    }

    // セグメントの宣言、contextの生成
    ctx, seg := xray.BeginSegment(context.Background(), "sqs-sender")
    subctx, subseg := xray.BeginSubsegment(ctx, "sqs-sender-sub")

    // メッセージ送信処理
    resp, err := svc.SendMessageWithContext(subctx, params)
    if err != nil {
        return err
    }
    fmt.Println(resp)

    // セグメントのクローズ
    subseg.Close(nil)
    seg.Close(nil)

    return nil
}

func main() {
    sampleSession := session.Must(session.NewSession())
    svc = sqs.New(sampleSession, aws.NewConfig().WithRegion(AwsRegion))

    if err := SendMessage(); err != nil {
        log.Fatal(err)
    }
}
  • メッセージ受信処理(sqs-reciever)
sqs-recieve-sample.go
package main

import (
    "context"
    "fmt"
    "log"

    "github.com/aws/aws-sdk-go/aws"
    "github.com/aws/aws-sdk-go/aws/session"
    "github.com/aws/aws-sdk-go/service/sqs"
    "github.com/aws/aws-xray-sdk-go/xray"
)

var (
    AwsRegion      = "<リージョン>"
    QueURL         = "https://sqs.<リージョン>.amazonaws.com/<アカウントID>/<キューの名前>"  // エンドポイント
)

var svc *sqs.SQS

func GetMessage() error {
    xray.AWS(svc.Client)

    // SQSから受け取る際に渡す構造体
    params := &sqs.ReceiveMessageInput{
        AttributeNames:      aws.StringSlice([]string{"AWSTraceHeader"}),  // トレースヘッダーを受け取る
        QueueUrl:            aws.String(QueURL),
        MaxNumberOfMessages: aws.Int64(10), // 一度に取得するメッセージの最大数
        WaitTimeSeconds:     aws.Int64(20), // ロングポーリングの時間
    }
    // セグメントの宣言、contextの生成
    ctx, seg := xray.BeginSegment(context.Background(), "sqs-reciever")
    subctx, subseg := xray.BeginSubsegment(ctx, "sqs-reciever-sub")

    // メッセージ受信の実行
    resp, err := svc.ReceiveMessageWithContext(subctx, params)
    if err != nil {
        return err
    }

    }
    for _, msg := range resp.Messages {
        fmt.Println(*msg.Body)

        // トレースヘッダーも取得&表示してみる
        msgAtr := msg.Attributes
        traceHeaderStr := msgAtr["AWSTraceHeader"]
        fmt.Println("AWSTraceHeader: ", traceHeaderStr)

        // メッセージ削除関数にもcontextを渡す
        if err := DeleteMessage(subctx, msg); err != nil {
            fmt.Println(err)
        }
    }
    // セグメントのクローズ
    subseg.Close(nil)
    seg.Close(nil)

    return nil
}

func DeleteMessage(ctx context.Context, msg *sqs.Message) error {
    params := &sqs.DeleteMessageInput{
        QueueUrl:      aws.String(QueURL),
        ReceiptHandle: aws.String(*msg.ReceiptHandle),
    }
    // メッセージの削除を実行。このときもcontextを渡す
    _, err := svc.DeleteMessageWithContext(ctx, params)

    if err != nil {
        return err
    }
    return nil

}

func main() {
    sampleSession := session.Must(session.NewSession())
    svc = sqs.New(sampleSession, aws.NewConfig().WithRegion(AwsRegion))

    // ポーリング
    for {
        if err := GetMessage(); err != nil {
            log.Fatal(err)
        }
    }
}

実行結果

事前にX-Rayデーモンを動かしておきます。

X-Rayデーモンの実行(東京リージョンの場合)
$ ./xray_mac -o -n ap-northeast-1
2020-06-19T22:29:57+09:00 [Info] Initializing AWS X-Ray daemon 3.2.0
2020-06-19T22:29:57+09:00 [Debug] Listening on UDP 127.0.0.1:2000
2020-06-19T22:29:57+09:00 [Info] Using buffer memory limit of 163 MB
2020-06-19T22:29:57+09:00 [Info] 2608 segment buffers allocated
2020-06-19T22:29:57+09:00 [Debug] Using Endpoint read from Config file: xray.ap-northeast-1.amazonaws.com
2020-06-19T22:29:57+09:00 [Debug] Using proxy address:
...  # 以下省略

その後、メッセージの送受信処理を動かします。一応マスクしていますが、こんな感じの実行結果が出力されます。

メッセージ送信処理
$ go run sqs-send-sample.go
2020-06-20T20:33:00+09:00 [INFO] X-Ray proxy using address : 127.0.0.1:2000
{
  MD5OfMessageBody: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
  MessageId: "XXXXXX-XXXXX-XXXX-XXXX-XXXXXXXXXXXXXXX"
}
2020-06-20T20:33:00+09:00 [INFO] Emitter using address: 127.0.0.1:2000
メッセージ受信処理
$ go run sqs-recieve-sample.go
2020-06-20T20:32:54+09:00 [INFO] X-Ray proxy using address : 127.0.0.1:2000
hello  # メッセージ本文
AWSTraceHeader:  XXXXXXXXXXX  # 16進数の値が出力される
2020-06-20T20:33:00+09:00 [INFO] Emitter using address: 127.0.0.1:2000
...  # ポーリングのため中断されるまで続きます 

X-Rayのコンソール

以下の通り、サービスマップが表示されました。下のsqs-senderからキューにメッセージが送られ、sqs-recieverがそれをポーリングしている状態です。
image.png

終わりに

X-RayをGo言語で使っているサンプルが少なく色々苦労しましたが、なんとか良い感じのグラフを描くことができました。X-Rayは使いこなせれば面白いサービスだと思うので、他のAWSサービスとも統合させてみたいと思います。

余談

SQSは、SNSと統合して用いることが多々あると思います。X-RayはSNSとの統合もサポートしているのですが、SNSのサブスクライバーとしてトレースがサポートされているのは、2020/6時点でHTTP/HTTPSとAWS Lambdaの2つです。つまり、残念ながらSNS+SQSの統合は、SQSがサブスクライバーとして統合されていないため、まとめてサービスグラフを描くことができません。今後のアップデートに期待したいと思います。

Amazon SNS および AWS X-Ray

その他参考

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