1クール続けるブログ

とりあえず1クール続けるエンジニアの備忘録

Go言語によるWebアプリケーション開発まとめ・感想 チャットアプリ編

オライリー出版のGo言語によるWebアプリケーション開発を読んだ感想 チャット部分編

オライリーから出版されているGo言語によるWebアプリケーション開発を読みました。1章から3章までがWebsocketを使用したチャットアプリケーション開発となるのですが、その部分を読んだまとめ・感想を書いていきます。

www.oreilly.co.jp

github.com

webサーバ公開の基本

アクセスパスと処理を結びつけます。
パスは完全一致ではなく前方一致となるため、注意が必要となります。/を登録した場合は/appとアクセスしても関連付けた処理が実行されます。 結びつけたあとに、ListenAndServeメソッドを使ってWebサーバを開始します。

http.HundleFuncでパスと関数を紐づける

この後に挙げるhttp.Handleとは違い、Handlerのインターフェースを実装した構造体を用意する必要がありません。
その代わり、状態などを持たせることが出来ないため注意です。

http.HandleFunc("/auth/", loginHandler) 

http.HandleでパスとHandlerを紐づける

Handlerのインターフェースを実装した構造体を用意した上で、http.Handle関数の第二引数に渡してあげる。Handler(https://golang.org/pkg/net/http/#Handler)は、ServeHTTP(w http.ResponseWriter, r *http.Request)メソッドのみを持つインターフェースになっている。http.Handle関数にて指定したリクエストパスにマッチするリクエストが来た場合に、ServeHTTPメソッドの中に書いた処理が実行される。

下の例は、返却するhtmlファイルに埋め込むデータオブジェクトを作成し、そのデータオブジェクトをコンパイル済のHtmlテンプレートにapplyする処理。ただし、初回リクエストが来たときにテンプレートファイルをコンパイルする。初回だけの実行を保証するための実装としては、sync.Onceを使用している。起動時にコンパイルしないこの方法を、遅延初期化として本では取り上げられており、セットアップ処理に時間がかかる場合や利用頻度の低い機能では効果的と説明されています。ただファイルが無いなどの状態を検知できないため、template.Mustを使用してグルーバル変数にセットする方法が好まれることもあるとのこと。

http.Handle("/login", &templateHandler{filename: "login.html"})

// テンプレートの読込と出力を受け持つ型。テンプレートのコンパイルは一度で良い。
type templateHandler struct {
    once     sync.Once // 複数のgoroutineが呼び出したとしても、引数として渡した関数が一度しか実行されない。
    filename string
    temp1    *template.Template //コンパイルされたテンプレートへの参照を保持
}

// 同じsync.Onceの値を使用しなくてはいけないためレシーバは必ずポインタでなくてはならない
func (t *templateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    t.once.Do(func() {
        t.temp1 =
            template.Must(template.ParseFiles(filepath.Join("templates", t.filename)))
    })
    data := map[string]interface{}{
        "Host": r.Host,
    }
    if authCookie, err := r.Cookie("auth"); err == nil {
        data["UserData"] = objx.MustFromBase64(authCookie.Value)
    }
    t.temp1.Execute(w, data)
}

template.Mustを使用してグルーバル変数にセットする方法は以下。この方法使用すると、もしエラーがある場合にはpanicを起こし起動しなくなるため、エラーに気づかない事象を避けることができる。

var temp1 = template.Must(template.ParseFiles(filepath.Join("templates", "login.html")))

以下のコマンドでWebサーバを開始する。

if err := http.ListenAndServe(*addr, nil); err != nil {
   log.Fatal("ListenAndServe:", err)
}

HTTP2をつかった通信をしたいのであれば、以下のようになる。

certFile, _ := filepath.Abs("server.crt")
keyFile, _ := filepath.Abs("server.key")

if err := http.ListenAndServeTLS(*addr, certFile, keyFile, nil); err != nil {
   log.Fatal("ListenAndServe:", err)
}

実装としては割れるのは、http.HandleFuncを使うかhttp.Handleを使うかというところだと思うが、遅延初期化を使わない場合には、http.HandleFuncで事足りるのではと私は感じている。

チャットアプリケーションのモデル化

ここでのチャットアプリケーションの要件は、一つの公開されたチャットルームにクライアントは自動的にログインして会話をするというもの。クライアントとの通信を管理するclientとという構造体とクライアントの入退出の管理やメッセージをブロードキャストする役割を持つroomという構造体で構成する。

client

Client構造体は、各クライアントごとに必要となるwebsocketのコネクションと他クライアントからのメッセージを格納するチャネルを持つ。また、ブラウザから送信されるメッセージをRoomに送るためにRoomへの参照を持っている。
clientのメソッドは各クライアントに対応するため、ServeHTTPメソッドの中で実行される。他クライアントからのメッセージを格納するチャネルをクライアント側で持つのは、言わずもがなWebsocketに書き出すWriteメソッドをクライアント側で持つため(おそらくsocketのコネクションを他ソースコードに使わせずチャネルでやり取りしたかった)。

type client struct {
    socket *websocket.Conn // クライアントのためのWebSocket
    send   chan []byte     // messageが送られるバッファ付きチャネル
    room   *room           // 参加しているチャットルーム
}

func (c *client) read() {
    for {                                                                                                // 無限ループ
        if _, msg, err := c.socket.ReadMessage(); err == nil {
            c.room.forward <- msg                                               // クライアントからメッセージを受信したらブロードキャストするためにRoomに送る 
        } else {
            break
        }
    }
    c.socket.Close()
}

func (c *client) write() {
    for msg := range c.send {    // チャネルが閉じられるまで、チャネルから値を繰り返し受信し続ける。channelがクローズされれば、ループから抜ける。 
        if err := c.socket.WriteMessage(websocket.TextMessage, msg); err != nil {
            break   // WebSocketへの書き込みが失敗するとbreakでforループから抜け出し、 Websocketが閉じられる。
        }
    }
    c.socket.Close()
}

room

room構造体は他のクライアントに転送するためのメッセージを保持するチャネルと入退出を受け取るチャネル、クライアント全体のデータを持つ。
外部から利用しやすいように、newRoom()という関数を作る。run()メソッドはgoルーチンでWEBサーバと並列で実行される。チャネルへのメッセージの処理には、selectを使用している。

type room struct {
    forward chan []byte      // 他のクライアントに転送するためのメッセージを保持するチャネル
    join    chan *client     // チャットに参加しようとするクライアント用
    leave   chan *client     // チャットルームから退室しようとする用のチャネル
    clients map[*client]bool //すべてのクライアントが保持
    tracer  trace.Tracer
}

func newRoom() *room {
    return &room{
        forward: make(chan []byte),
        join:    make(chan *client),
        leave:   make(chan *client),
        clients: make(map[*client]bool),
    }
}

func (r *room) run() {
    for {
        // いずれかのチャネルにメッセージが届くとcaseに合わせて処理を実行
        // case節の処理は同時に実行されないので同時にr.clientに変更を加えることはない
        select {
        case client := <-r.join:
            r.clients[client] = true
        case client := <-r.leave:
            delete(r.clients, client)
            close(client.send)  // client側のチャンネルを閉じたのでforループから抜けてwebsocket通信が閉じられる
        case msg := <-r.forward:
            for client := range r.clients {
                select {
                case client.send <- msg:
                    r.tracer.Trace(" -- クライアントに送信されました")
                default:
                    delete(r.clients, client)
                    close(client.send) // client側のチャンネルを閉じたのでforループから抜けてwebsocket通信が閉じられる
                }
            }

        }
    }
}

Roomでの処理をHTTPハンドラ化

HTTPハンドラ化するにあたり、room構造体はServeHTTPメソッドを実装する必要がある。
Upgraderメソッドによりhttp通信はWebsocket通信へアップグレードされます。そして変数socketにwebcsocket通信のコネクションが入ってきます。

func (r *room) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    socket, err := upgrader.Upgrade(w, req, nil)
    if err != nil {
        log.Fatal("ServeHTTP:", err)
        return
    }
    client := &client{
        socket: socket,
        send:   make(chan []byte, messageBufferSize),
        room:   r,
    }

    r.join <- client   // joinチャンネルに生成したclientをpassing
    defer func() { r.leave <- client }()  // client.readはソケット通信でエラーになったらbreakし、そのclientをleaveチャンネルに通知する。
    go client.write()
    client.read()
}

main関数の流れは以下のようになります。

        http.Handle("/room", r)
        http.Handle("/chat", &templateHandler{filename: "chat.html"})
        go r.run()
        // WEBサーバ起動

認証機能

HTTPハンドラのdecoratorパターン

認証機能を作るにあたり、/roomにアクセスした際の処理を認証用のHTTPハンドラを用意しラップします。次に実行する処理を持つハンドラを認証ハンドラの構造体にフィールドに登録できるようにしておきます。認証用のハンドラは、認証が正常に完了したら、認証用ハンドラの次に実行するハンドラのServeHTTP()メソッドを実行することで処理を渡します。

http.Handle("/chat", MustAuth(&templateHandler{filename: "chat.html"}))

func MustAuth(handler http.Handler) http.Handler {
    return &authHandler{next: handler}
}

func (h *authHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if _, err := r.Cookie("auth"); err == http.ErrNoCookie {
        // 未認証
        w.Header().Set("Location", "/login")
        w.WriteHeader(http.StatusTemporaryRedirect)
    } else if err != nil {
        panic(err.Error())
    } else {
        // 成功。ラップしたハンドラ呼び出し。
        h.next.ServeHTTP(w, r)
    }
}

このWrapするパターンですが、以下のようにWrapperをhttp.HandleFuncを使って記述することも可能です。
(参考元->https://medium.com/@matryer/the-http-handler-wrapper-technique-in-golang-updated-bc7fbcffa702)

http.Handle("/chat", MustAuth(&templateHandler{filename: "chat.html"}))

func MustAuth(h http.Handler) http.Handler {
  return http.HandlerFunc(func(w http.ResponseWriter, r 
                                          *http.Request) {
    if _, err := r.Cookie("auth"); err == http.ErrNoCookie {
        w.Header().Set("Location", "/login")
        w.WriteHeader(http.StatusTemporaryRedirect)
    } else if err != nil {
        panic(err.Error())
    } else {
        h.ServeHTTP(w, r)
    }
  })
}

wrapする側もされる側もHandleFuncにするなら以下のような形です。 (参考元->https://twinbird-htn.hatenablog.com/entry/2016/06/06/001704)

http.HandleFunc("/chat", MustAuth(templateHandleFunc("chat.html")))
func MustAuth(fn http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        if _, err := r.Cookie("auth"); err == http.ErrNoCookie {
        w.Header().Set("Location", "/login")
        w.WriteHeader(http.StatusTemporaryRedirect)
    } else if err != nil {
        panic(err.Error())
    } else {
        fn(w, r)
    }
    }
}

OAuth2

この本ではgomniauthというOSSが利用されていたが、gomniauthを使うと何故かobjx: JSON encode failed with: json: unsupported type: func() stringというgomiauthの中で使用されているライブラリの中で落ちるという現象にあい、ここで時間をとるのも…と思い、golang.org/x/oauth2というパッケージを利用しました。
処理の流れとしては、以下の記事を参考にいたしました。

dev.to

var googleOauthConfig = &oauth2.Config{
    RedirectURL:  "http://localhost:8080/auth/callback/google",
    ClientID:     os.Getenv("OAUTH_ID_GOOGLE"),
    ClientSecret: os.Getenv("OAUTH_KEY_GOOGLE"),
    Scopes:       []string{"https://www.googleapis.com/auth/userinfo.profile"}, // https://developers.google.com/identity/protocols/googlescopes#oauth2v2
    Endpoint:     google.Endpoint,
}

func loginHandler(w http.ResponseWriter, r *http.Request) {
    segs := strings.Split(r.URL.Path, "/")
    action := segs[2]
    // provider := segs[3]

    switch action {
    case "login":
        state, expiration := generateStateOauthCookie(w)
        u := googleOauthConfig.AuthCodeURL(state) // 認可サーバへのURL+クライアントIDやリダイレクト先の情報が入ったQuery
        cookie := http.Cookie{Name: "oauthstate", Value: state, Expires: expiration, Path: "/"}
        http.SetCookie(w, &cookie)
        http.Redirect(w, r, u, http.StatusTemporaryRedirect)
    case "callback":
        oauthState, _ := r.Cookie("oauthstate")

        fmt.Println(r.FormValue("state"))
        fmt.Println(oauthState.Value)
        if r.FormValue("state") != oauthState.Value {
            log.Fatalln("invalid oauth google state") // Cookieにセットしたstateとクエリにセットしたstateが合致しないのはCSRF攻撃を受けている場合がある
            return
        }

        // 正しく認可コードを得ることができたためアクセストークンを要求し、そのアクセストークンを使用しリソースサーバにアクセスできる。
        data, err := getUserDataFromGoogle(r.FormValue("code"))
        if err != nil {
            log.Println(err.Error())
            http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
            return
        }

        var profile Profile
        if err := json.Unmarshal(data, &profile); err != nil {
            fmt.Println("error:", err)
        }

        authCookieValue := objx.New(map[string]interface{}{
            "name": profile.Name,
        }).MustBase64()

        // Cookie は、ステートレスな HTTP プロトコルのためにステートフルな情報を記憶する
        // https://developer.mozilla.org/ja/docs/Web/HTTP/Cookies
        http.SetCookie(w, &http.Cookie{
            Name:  "auth",
            Value: authCookieValue,
            Path:  "/"})
        // Headerの方は map[string][]string
        w.Header()["Location"] = []string{"/chat"}
        w.WriteHeader(http.StatusTemporaryRedirect)
    default:
        w.WriteHeader(http.StatusNotFound)
        fmt.Fprintf(w, "アクション%sには非対応です", action)
    }
}

アバター

SNSから取得するパターン、Gravatarから取得するパターン、画像のアップロードしてもらうパターンの3種ありましたが、前の2つは実装自体は難しくなかったため3つ目の画像のアップロード・提供について書いていきます。 具体的な処理から抽象化していく工程も書いていきたかったのですが、言語化するのが難しかった…。

画像のアップロード

http.HandleFunc("/uploader", uploaderHandler)

func uploaderHandler(w http.ResponseWriter, req *http.Request) {
    userId := req.FormValue("userid") // HTMLフォームの隠しフィールドにセットされたユーザーIDを読み取り
    file, header, err := req.FormFile("avatarFile")
    if err != nil {
        fmt.Fprintln(w, err.Error())
        return
    }
    defer file.Close()
    data, err := ioutil.ReadAll(file)
    if err != nil {
        fmt.Fprintln(w, err.Error())
        return
    }
    filename := filepath.Join("avatars", userId+filepath.Ext(header.Filename)) // 元ファイル拡張子(extension)をjoinする
    err = ioutil.WriteFile(filename, data, 0644)
    if err != nil {
        fmt.Fprintln(w, err.Error())
        return
    }
    fmt.Fprintln(w, "success!!")
}

提供するときには組み込みのファイルサーバ機能を使います。

   // ScripPrefixもFileServerもhttp.Handler型を返す。本の2章で紹介されたDecoratorパターン。
    http.Handle("/avatars/",
        http.StripPrefix("/avatars/", // パスの中から接頭辞(/avatars/の部分を削除)。削除しないと./avatars/avatars/filenameにアクセスしようとする
            http.FileServer(http.Dir("./avatars")))) // 静的ファイルの提供ができる

まとめ

本自体は原本が2014年の出版でかなり古いので、新しい情報とかも見つつ整理していくのが大事だなと思いました。 普通WEBアプリケーションを作成するときは標準パッケージではなくGinとか使われていくのだろうなとは思っているのですが…。
ただメルカリさんのAPIサーバのように、net/httpパッケージだけを見て作成されるパターンもあるので、実際は用途によっての使い分けなんですかね。

メルカリさんのAPIサーバの実装に関する記事↓

tech.mercari.com