【Golang】Goa v3でAPIキー認証(APIKyeAuth)を設定する

経緯

GoaのAPIキー認証(APIKeyAuth)の実装例はJWT認証などと比べると数が非常に少なく、さらにv2から仕様が大きく変わっておりv3での具体的な実装例を見つけられなかったため、今回実装してみた例を紹介します。

参考にしたサイト

①goadesign/goa/blob/v3/dsl/security.go

github.com

GoaのGithubリポジトリです。大まかな実装例が乗っており、参考になります。



②Goa v1 や Goa v2 から v3 にアップグレードする

goa.design

v1やv2での実装例をv3に適用する際に参考にしました。


実装

Goa公式のCalculator Serviceのdesign.goを題材にします。(別オリジンからのリクエストを想定しています。)

package design

import (
    . "goa.design/goa/v3/dsl"
)

var _ = API("calc", func() {
    Title("Calculator Service")
    Description("Service for adding numbers, a Goa teaser")
    Server("calc", func() {
        Host("localhost", func() {
            URI("http://localhost:8000")
        })
    })
})

var _ = Service("calc", func() {
    Description("The calc service performs operations on numbers.")
    cors.Origin("接続元のURL", func() {
        cors.Headers("Access-Control-Allow-Origin", "Authorization")
        cors.Methods("GET")
        cors.Credentials()
    })
    Method("add", func() {
        Security(APIKeyAuth)
        Payload(func() {
            APIKey("api_key", "key", String, "API key used to perform authorization")
            Field(1, "a", Int, "Left operand")
            Field(2, "b", Int, "Right operand")
            Required("key", "a", "b")
        })

        Result(Int)

        HTTP(func() {
            GET("/add/{a}/{b}")
            Header("key:Authorization")
        })
    })
})

var APIKeyAuth = APIKeySecurity("api_key", func() {
    Description("Secures endpoint by requiring an API key.")
})

順に説明していきます。

var APIKeyAuth = APIKeySecurity("api_key", func() {
    Description("Secures endpoint by requiring an API key.")
})

まず、APIKeyAuthを使用するため、ファイル最下部で以下の変数設定を行っています。


var _ = Service("calc", func() {
    Description("The calc service performs operations on numbers.")
    cors.Origin("接続元のURL", func() {
        cors.Headers("Access-Control-Allow-Origin", "Authorization")
        cors.Methods("GET")
        cors.Credentials()
    })

次に、CORSの設定を行います。ここで、後ほど設定するAPIキーを格納するヘッダーもcors.Headersに追加しておく必要があります(ここではAuthorizationヘッダー)。これを設定しないとCORSはクリアできてもリクエストヘッダーからAPIキーを取得できません。


    Method("add", func() {
        Security(APIKeyAuth)
        Payload(func() {
            APIKey("api_key", "key", String, "API key used to perform authorization")
            Field(1, "a", Int, "Left operand")
            Field(2, "b", Int, "Right operand")
            Required("key", "a", "b")
        })

        Result(Int)

次に、Methodへ記述していきます。まず、このメソッドでAPIキー認証を適用することを宣言するため、Security(APIKeyAuth)を追加します。次にPayload内でAPIKeyを記述し、APIキーの変数について定義します。(gRPCで利用する際は、APIKeyFieldの設定が必要になるようです。詳しい仕様は以下を参照してください。)

pkg.go.dev

またRequiredに、APIキー用の変数を追加します。


    HTTP(func() {
        GET("/add/{a}/{b}")
        Header("key:Authorization")
    })

最後にHTTP部で、HeaderにてAPIキーを格納するヘッダーを設定します。括弧内は、”キー:ヘッダー”の組み合わせです。


以上が、APIキー認証を実装する場合のdesignとなります。

【Golang】個々のチャットルーム(ウェブページ)毎で独立してWebSocket接続を管理する方法例


経緯

複数のチャットルームが存在するチャットアプリでWebSocketを使用するため、それぞれのルーム(ページ)毎にWebSocketを管理する必要がありました。そこで、セッション管理の仕組みを応用して、各ルームのIDとルームのインスタンスとのマップとしてメモリ上で管理することにしました。

全コードは以下のリンクにアップしています。

https://github.com/taishi-enomoto/go-chat-app/blob/main/wsserver/wsserver.go

参考にしたサイト

① 6.2 Goはどのようにしてsessionを使用するか
 (Build Web Application With Golang)

astaxie.gitbooks.io

メモリ上でのセッション管理は、こちらのサイトの方法を参考にさせていただきました。

② [ golang ] WebSocketを使ったチャット機能を実装してみる。
 (Wild Data Chase -データを巡る冒険-)

wild-data-chase.com

単一のルームにおけるWebSocketの実装は、こちらのサイトを参考にさせていただきました。

実装

チャットルームマネージャーの実装

まず、各ルームを保持する、チャットルームマネージャーを実装します。(リンク先①のセッションマネージャーと同じ仕組みです。) WsChatroomは、それぞれのチャットルームを表す構造体です。

type MANAGER struct {
    WsRooms map[string]*WsChatroom
}

var Manager *MANAGER

func init() {
    Manager = NewManager()
}

func NewManager() *MANAGER {
    database := make(map[string]*WsChatroom)
    return &MANAGER{WsRooms: database}
}

マネージャーは、各ルームのIDをキー、ルームインスタンスを値としたマップを保持します。

WebSocketHandlerの実装

次に、チャットルームへアクセスがあった際の処理を実装していきますが、ルームの存在状況により、①ルームが既に存在する場合と②部屋がまだ存在しない場合の2つの分岐を用意します。

func WebSocketHandler(ws *websocket.Conn) {
    defer ws.Close()

    var chatroomJson string
    if err := websocket.Message.Receive(ws, &chatroomJson); err == nil {
        var chatroom WsChat
        json.Unmarshal([]byte(chatroomJson), &chatroom)
        roomId := chatroom.Id

       //①ルームが既に存在する場合
        if _, exist := Manager.WsRooms[roomId]; exist {

            WsClient := &WsClient{
                Send: make(chan string),
                Room: Manager.WsRooms[roomId],
            }

            Manager.WsRooms[roomId].Join <- WsClient
            defer func() {
                Manager.WsRooms[roomId].Leave <- WsClient
                if len(Manager.WsRooms[roomId].clients) == 0 {
                    delete(Manager.WsRooms, roomId)
                    fmt.Println("WebSocket用ルーム削除")
                }
            }()

            go WsClient.Write(ws)
            WsClient.Read(ws)
        } else {
                        //②部屋がまだ存在しない場合
            WsChatroom := &WsChatroom{
                forward: make(chan string),
                Join:    make(chan *WsClient),
                Leave:   make(chan *WsClient),
                clients: make(map[*WsClient]bool),
            }

            go WsChatroom.ChatroomRun()

            var chatroom WsChat
            json.Unmarshal([]byte(chatroomJson), &chatroom)
            WsChatroom.id = chatroom.Id

            Manager.WsRooms[WsChatroom.id] = WsChatroom


            WsClient := &WsClient{
                Send: make(chan string),
                Room: WsChatroom,
            }

            WsChatroom.Join <- WsClient
            defer func() {
                WsChatroom.Leave <- WsClient
                Manager.WsRooms[WsChatroom.id].Leave <- WsClient
                if len(Manager.WsRooms[WsChatroom.id].clients) == 0 {
                    delete(Manager.WsRooms, WsChatroom.id)
                    fmt.Println("WebSocket用ルーム削除")
                }
            }()

            go WsClient.Write(ws)
            WsClient.Read(ws)
        }
    }
}
    var chatroomJson string
    if err := websocket.Message.Receive(ws, &chatroomJson); err == nil {
        var chatroom WsChat
        json.Unmarshal([]byte(chatroomJson), &chatroom)
        roomId := chatroom.Id

冒頭のコードについてだけ先に説明しておくと、初回接続確率時に送信されるデータにルームNoが含まれる仕組みにしており、ここで得られたルームNoとルームインスタンスを紐付けています。html側での詳しい処理については、ここでは割愛します。

①ルームが既に存在する場合

       //①ルームが既に存在する場合
        if _, exist := Manager.WsRooms[roomId]; exist {

            WsClient := &WsClient{
                Send: make(chan string),
                Room: Manager.WsRooms[roomId],
            }

            Manager.WsRooms[roomId].Join <- WsClient
            defer func() {
                Manager.WsRooms[roomId].Leave <- WsClient
                if len(Manager.WsRooms[roomId].clients) == 0 {
                    delete(Manager.WsRooms, roomId)
                    fmt.Println("WebSocket用ルーム削除")
                }
            }()

            go WsClient.Write(ws)
            WsClient.Read(ws)

最初に取得したルームIDを元に、チャットルームマネージャーが入室したチャットルームのインスタンスを保持しているかexistで確認します。チャットルームがすでに存在する場合、ユーザーを表すWsClientインスタンスを生成、ルームインスタンスのJoinフィールドに追加します。

【補足】 ルーム構造体のChatroomRunメソッドはチャネルにより入退室状況・メッセージの送受信状況を管理しており、WriteReadメソッドはデータの読み込み・書き出しを行います。詳細は参照元のページを確認ください。

②部屋がまだ存在しない場合

                        //②部屋がまだ存在しない場合
            WsChatroom := &WsChatroom{
                forward: make(chan string),
                Join:    make(chan *WsClient),
                Leave:   make(chan *WsClient),
                clients: make(map[*WsClient]bool),
            }

            go WsChatroom.ChatroomRun()

            var chatroom WsChat
            json.Unmarshal([]byte(chatroomJson), &chatroom)
            WsChatroom.id = chatroom.Id

            Manager.WsRooms[WsChatroom.id] = WsChatroom

            WsClient := &WsClient{
                Send: make(chan string),
                Room: WsChatroom,
            }

            WsChatroom.Join <- WsClient
            defer func() {
                WsChatroom.Leave <- WsClient
                Manager.WsRooms[WsChatroom.id].Leave <- WsClient
                if len(Manager.WsRooms[WsChatroom.id].clients) == 0 {
                    delete(Manager.WsRooms, WsChatroom.id)
                    fmt.Println("WebSocket用ルーム削除")
                }
            }()

            go WsClient.Write(ws)
            WsClient.Read(ws)
        }
    }
}

本来書く順序的には①より前が良さそうですが、、ルームがまだ作成されていなかった場合の処理になります。

            WsChatroom := &WsChatroom{
                forward: make(chan string),
                Join:    make(chan *WsClient),
                Leave:   make(chan *WsClient),
                clients: make(map[*WsClient]bool),
            }

            go WsChatroom.ChatroomRun()

            var chatroom WsChat
            json.Unmarshal([]byte(chatroomJson), &chatroom)
            WsChatroom.id = chatroom.Id

            Manager.WsRooms[WsChatroom.id] = WsChatroom

まずルーム構造体を初期化して入室したチャットルームのインスタンスを生成します。次に取得したルームIDをキーに、ルームインスタンスをチャットルームマネージャーで保持します。後は①の時と同様です。

以上の仕組みで、個々のチャットルームで独立したWebSocket通信を行うことが可能になります。

【Golang】ExecuteTemplate関数で渡した値を、scriptタグ内で参照する方法

経緯

Goのhtml/templateパッケージのExecuteTemplate関数の第二引数に何らかの値を代入することで、描画するhtmlに値を渡すことができ、htmlのタグ内に展開する例がよく紹介されています。しかし、渡した値が配列やスライスの場合、scriptタグ内で参照する時と、scriptタグ外で参照する時で記法が少し異なってきます。

scriptタグ内とscriptタグ外で参照方法が同じ場合

以下の2つの例のように1つのデータを渡す時は、scriptタグ内とscriptタグ外で参照方法は変わりません。

なお、scriptタグ内で{{.}}を使うとIDEの設定によってはエラー判定となりますが、問題なく動作します。

①単純な値を渡す場合

   hoge := "hoge"

    tpl := template.Must(template.ParseFiles("./templates/test.html"))

    tpl.ExecuteTemplate(w, "test.html", hoge)
<body>
  <p>{{.}}</p>  //hoge
</body>
<script>
  console.log({{.}})  //hoge
</script>

②構造体を1つ渡す場合

type Person struct {
    //json変換用のキー名の設定はなくてもok
    Name   string `json:"name"`
    Age    int    `json:"age"`
    Height int    `json:"height"`
}

    NewPerson := new(Person)
    NewPerson.Name = "John"
    NewPerson.Age = 30
    NewPerson.Height = 180

    tpl := template.Must(template.ParseFiles("./templates/test.html"))

    tpl.ExecuteTemplate(w, "test.html", NewPerson)
}
<body>
  <p>{{.}}</p>  //{John 30 180}
  <p>{{.Name}}</p>  //John
  <p>{{.Age}}</p>  //30
  <p>{{.Height}}</p>  //180
</body>
<script>
  console.log({{.}})  //{name: "John", age: 30, height: 180}

  //json用のキー名を設定している場合も、元の構造体のフィールド名を使う!
  console.log({{.Name}})  //John
  console.log({{.Age}})  //30
  console.log({{.Height}})  //180
</script>

scriptタグ内とscriptタグ外で参照方法が異なる場合

しかし、配列やスライスといったデータを渡す場合、参照方法が異なってきます。scriptタグ外では{{index . 0}}{{range .}} {{end}}などで参照を行いますが、scriptタグ内では{{.}}[0]{{.}}[1]というように{{.}}[n]で配列の要素を取得する形でまず参照を行います。配列の要素が構造体の場合、{{.}}[0].nameというようにキー名を指定し、値を取得することができます。

type Person struct {
    //json変換用のキー名の設定はなくてもok
    Name   string `json:"name"`
    Age    int    `json:"age"`
    Height int    `json:"height"`
}

func testHandler(w http.ResponseWriter, r *http.Request) {

    NewPerson1 := new(Person)
    NewPerson1.Name = "John"
    NewPerson1.Age = 30
    NewPerson1.Height = 180

    NewPerson2 := new(Person)
    NewPerson2.Name = "Mike"
    NewPerson2.Age = 25
    NewPerson2.Height = 175

    Slice := []*Person{NewPerson1, NewPerson2}

    tpl := template.Must(template.ParseFiles("./templates/test.html"))

    tpl.ExecuteTemplate(w, "test.html", Slice)
<body>
  <p>{{.}}</p>  //[0xc000169240 0xc000169260]などと表示される。
  <p>{{index . 0}}</p>  //{John 30 180}
  <p>{{(index . 0).Name}}</p>  //John
</body>
<script>
    console.log({{.}})  //↓(2) [{…}, {…}]
                         //→0: {Name: "John", Age: 30, Height: 180}
                         //→1: {Name: "Mike", Age: 25, Height: 175}
                         //→length: 2
                         //→__proto__: Array(0)
  console.log({{.}}[0]) //{Name: "Mike", Age: 25, Height: 175}

  console.log({{.}}[1]["name"]) //Mike  ※hashの値を取得する形式でも可
  console.log({{.}}[1].name) //Mike
  console.log({{.}}[1].age)  //25
  console.log({{.}}[1].height)  //175
</script>

ちなみに、例では構造体の定義でjson変換時のキー名の設定を追加していますが、設定しなくても値の取得が行えます。その場合はそのままフィールド名をキー名に使用して個々の値を取得できます。

ExecuteTemplate関数で渡される値について

html/templateパッケージの仕様書には詳しい記載はないのですが、これまでの例を見た限り、ExecuteTemplate関数での値渡しは以下の規則で行われるようです。
①ただの変数・配列などの単純な部分はそのまま渡す。
②構造体・連想配列などの部分があれば、json形式に変換する。 (json変換時のキー名が設定されていなければ、フィールド名をそのまま使用する)

注意点

値の参照でエラーが起きると、scriptタグ内の関数が全て実行されなくなります。

【Golang】同一ページ内の複数のPOSTメソッドを選択的に処理する方法

経緯

一つのページ内で複数のPOST処理を選択的に実行したいと思ったものの、ハンドラ関数内での分岐をGETPOSTかだけでswitchしており、どう処理するか迷いました。

解決法

html側の各input要素に異なるname属性を付け、Request.Methodで分岐後、さらにRequest.FormValue()関数でどのinput要素からPOSTされてきたかを、ifで分岐します。POSTされたフォームデータには使用されたinput要素の連想配列しか含まれないため、POSTされていない処理が行われてしまう心配はありません。

・html form部分の記述

  <form action="" method="post">
    <p><input type="submit" value="処理1" name="process1"></p>
  </form>

  <form action="" method="post">
    <p><input type="submit" value="処理2" name="process2"></p>
  </form>

サーバー側の記述

func SampleHandler(w http.ResponseWriter, r *http.Request) {
    switch r.Method {
    case "GET":
        //あればGET処理
    case "POST":

        err := r.ParseForm()   //FormValue関数だけ使う際は不要、r.Formの内容を使用したい場合はParseForm()を行う
        if err != nil {
            fmt.Println(err.Error())
        }

        //FormValue()は引数で渡したキーに対応する値を返す
        if r.FormValue("process1") == "処理1" {

                //処理1の内容

        fmt.Println(r.Form)  //r.Formの内容は map[process1:[処理1]]

        }
        if r.FormValue("process2") == "処理2" {

                //処理2の内容

        fmt.Println(r.Form)  //r.Formの内容は map[process2:[処理2]]

        }
    }
}

【Golang】自前でマルチプレクサを設定している場合のWebSocketの実装

前提

  • 標準ライブラリのみでの実装です。
  • WebSocketのパッケージはgorilla/websocketではなく、準標準ライブラリのgolang.org/x/net/WebSocketを使用しています。

一般的なWebSocketの実装方法

Goの準標準ライブラリでWebSocketを実装したサーバーを立てる際、http.Handle(/hoge, Handle)を用いて、

// https://pkg.go.dev/golang.org/x/net/websocketでの実装例
func EchoServer(ws *websocket.Conn) {
    io.Copy(ws, ws)
}

func main() {
    http.Handle("/echo", websocket.Handler(EchoServer))
    err := http.ListenAndServe(":12345", nil)
    if err != nil {
        panic("ListenAndServe: " + err.Error())
    }
}

DefaultServeMuxにWebSocket用のハンドラを登録するのが一般的かと思われます。

ここで問題…

しかし、私はいくつもhttp.Handleやらhttp.HandleFuncを書くのがなんとなく好きでなかったため、ListenAndServeで第二引数に'nil'を渡さず、下記のような自家製マルチプレクサを渡していました。
この場合、上記のhttp.Handle("/echo", websocket.Handler(EchoServer))の実装では、websocket.Handlerが自前のマルチプレクサに登録されずDefaultServeMuxに登録されてしまうため、/echoのルーティング時にWebSocketが機能しません。

// WebSocketが機能しない例(http.Handleで登録)
type Mux struct{}

func (mux Mux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    switch r.URL.Path {
    case "/hoge":
     
    case "/hage":
     
    }
    http.NotFound(w, r)
}

func EchoServer(ws *websocket.Conn) {
    io.Copy(ws, ws)
}

func main() {
    http.Handle("/echo", websocket.Handler(EchoServer)) //DefaultServeMuxに登録される
    err := http.ListenAndServe(":12345", mux)
    if err != nil {
        panic("ListenAndServe: " + err.Error())
    }
}

また、下記のようにマルチプレクサ内に処理を記述しても動きませんでした。

// WebSocketが機能しない例(マルチプレクサ内にハンドル記述)
type Mux struct{}

func (mux Mux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    switch r.URL.Path {
    case "/hoge":
     
    case "/echo":
               //error: websocket.Handler(EchoServer) evaluated but not used
        websocket.Handler(EchoServer)  //ただハンドラをコピペするだけではダメ
    }
    http.NotFound(w, r)
}

func EchoServer(ws *websocket.Conn) {
    io.Copy(ws, ws)
}

func main() {
    err := http.ListenAndServe(":12345", mux)
    if err != nil {
        panic("ListenAndServe: " + err.Error())
    }
}

解決策

Type Handlerに実装されているServeHTTPメソッドを付け加えると、ハンドリングがうまくいきWebSocketが機能します。イマイチはっきりした理屈はわからないのですが、http.HandleではハンドラのServeHTTPメソッドを勝手に呼び出してくれるところ、今回のように関数内で使用する場合はしっかりServeHTTP(w, r)を明示しないといけないようです。

// Websocketが起動する例
type Mux struct{}

func (mux Mux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    switch r.URL.Path {
    case "/hoge":
     
    case "/echo":
        websocket.Handler(EchoServer)).ServeHTTP(w, r)
    }
    http.NotFound(w, r)
}

func EchoServer(ws *websocket.Conn) {
    io.Copy(ws, ws)
}

func main() {
    err := http.ListenAndServe(":12345", mux)
    if err != nil {
        panic("ListenAndServe: " + err.Error())
    }
}