Goでチャットアプリを作る

Posted on 2018年9月3日 Under Go 0 Comments
Pocket
LINEで送る

こんにちは!少し涼しくなってきましたねー。新人の有安です!

今回も前回に引き続きGoを触っていこうと思います。

まず動作とコードを示し、その後処理の流れを丁寧に丁寧に追っていきます。対象者はGoあんまり知らない、けどちょっと知ってる人です。

予告通りフレームワークを使わずにゴリゴリ書いていきます。

今回作るのはWebsocketを利用したチャットアプリです。

まずは完成品の動作を確認してみましょう。

chat

はい!みて分かる通り、複数のユーザーがリアルタイムでやり取りできる形のチャットアプリです。よくあるやつ。

Goではチャネルなどの並行処理向けの仕組みが言語に組み込まれているので、この手の機能を非常に簡単に実装することができます。

早速作っていきます。ちなみに今回はログイン機能とか画像の表示とかcssの設定はやらないので、gifはチャット機能だけ参考にしてください。

まずは今回作成するファイルの全体像を。

main.go

room. go

client.go

templates — chat.html

3つのファイルとtemplatesディレクトリ、その中にchat.htmlの計たった4ファイルです。

1つずつ内容を書いていきます。

【main.go】

package main

import (
 "html/template"
 "log"
 "net/http"
 "path/filepath"
 "sync"
)

type templateHandler struct {
  once sync.Once
  filename string
  templ *template.Template
}

func (t *templateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
  t.once.Do(func() {
    t.templ = template.Must(template.ParseFiles(
      filepath.Join("templates", t.filename)))
 })
 t.templ.Execute(w, r)
}

func main() {
  r := newRoom()
  http.Handle("/", &templateHandler{filename: "chat.html"})
  http.Handle("/room", r)
  go r.run()
  if err := http.ListenAndServe(":8080", nil); err != nil {
    log.Fatal("ListenAndServe:", err)
  }
}

【room.go】

package main

import (
  "log"
  "net/http"
  "github.com/gorilla/websocket"
)

type room struct {
  forward chan []byte
  join chan *client
  leave chan *client
  clients map[*client]bool
}

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 {
    select {
    case client := <-r.join:
      // 参加
      r.clients[client] = true
    case client := <-r.leave:
      // 退室
      delete(r.clients, client)
      close(client.send)
    case msg := <-r.forward:
      // すべてのクライアントにメッセージを転送
      for client := range r.clients {
        select {
        case client.send <- msg: // メッセージを送信
        default:
          // 送信に失敗
          delete(r.clients, client)
          close(client.send)
        }
      }
    }
  }
}

const (
  socketBufferSize = 1024
  messageBufferSize = 256
)

var upgrader = &websocket.Upgrader{ReadBufferSize: socketBufferSize, WriteBufferSize: socketBufferSize}

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
  defer func() { r.leave <- client }()
  go client.write()
  client.read()
}

【client.go】

package main

import (
  "github.com/gorilla/websocket"
)

type client struct {
  socket *websocket.Conn
  send chan []byte
  room *room
}

func (c *client) read() {
  for {
    if _, msg, err := c.socket.ReadMessage(); err == nil {
      c.room.forward <- msg
    } else {
      break
    }
  }
  c.socket.Close()
}
func (c *client) write() {
  for msg := range c.send {
    if err := c.socket.WriteMessage(websocket.TextMessage, msg); err != nil {
      break
    }
  }
  c.socket.Close()
}

【chat.html】

<html>
  <head>
    <title>チャット</title>
    <style>
      input { display: block; }
      ul { list-style: none; }
   </style>
 </head>
 <body>
   <ul id="messages"></ul>
   WebSocketを使ったチャットアプリケーション
   <form id="chatbox">
     <textarea></textarea>
     <input type="submit" value="送信" />
   </form>

<script src="//ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js">
</script>
<script>
  $(function(){
    var socket = null;
    var msgBox = $("#chatbox textarea");
    var messages = $("#messages");
    $("#chatbox").submit(function(){
      if (!msgBox.val()) return false;
        if (!socket) {
        alert("エラー: WebSocket接続が行われていません。");
        return false;
      }
      socket.send(msgBox.val());
      msgBox.val("");
      return false;
    });
    if (!window["WebSocket"]) {
      alert("エラー: WebSocketに対応していないブラウザです。")
    } else {
      socket = new WebSocket("ws://{{.Host}}/room");
      socket.onclose = function() {
        alert("接続が終了しました。");
      }
      socket.onmessage = function(e) {
        messages.append($("<li>").text(e.data));
      }
    }
  });
</script>

 </body>
</html>

はい、以上4ファイルになります。

モデルは

・クライアント(ユーザー)

・ルーム

の2つになります。

コンパイルして実行し、ページを訪ねメッセージを送信した際の処理を追ってみます!

 

① 【実行】

main.goのmainメソッドが実行されます。

まず初めにroomオブジェクトを作成し、次の行でhttp.Handle関数を使い”/” というパスと&templateHandler{filename: “chat.html”}を結びつけています。

templateHandlerの参照に対してServeHTTPというメソッドが定義しておくと、http.Handle関数に渡すことができます。

roomオブジェクトに対しても同様のことが言えます。”/room” を訪れた際にroomオブジェクトのServeHTTPメソッドが走るように設定しているわけです。

続いてgo r.run() とやることで、runメソッドをgoroutineとして実行します。runメソッドではクライアントの参加やメッセージの送信を待ち受けるわけですが、goroutineとして実行することによってメインのスレッドでWebサーバーを実行できるようになります。

ということで続いての行でhttp.ListenAndServeメソッドを利用してWebサーバーを開始しています。ポートはハードコーディングしていますが、flagをimportして動的にするのが良いと思います。今回は省略!

はい、ここまででmain関数が終わり、準備完了です。room#run()がゴルーチンで走り続けています。

② 【ルートパスを訪れる】

この状態でrootを訪れると、冒頭のgifのような画面が表示されます。http.Handleメソッドのおかげですね。

表示とほぼ同時にjsが走り、”/room”のパスでWebSocketの接続が開始されます。

するとroom#ServeHTTPが走り、

WebSocketを利用するためにHTTP接続をアップグレードし、それを持ったクライアントを作成し、roomオブジェクトのjoinプロパティに送っています。

joinの型はmake(chan *client)という風に、clientを受け取れるようになっていますね。

room#runメソッドが走っている(room.joinを待ち構えている)ので、roomのclientsプロパティにクライアントが加えられます。つまり、ページを訪れると入室するわけですね。

ServeHTTPの続きですが、defer文でページを離れる際に退室することを保証しています。

そして続く行でまたゴルーチンを発動させています。今度はclient.write()という記述がありますね。ここではクライアントのsendプロパティにメッセージが送られるのを待ち構えて、送られてきたらそのメッセージを入室している全員に送ります。room#runメソッド内ではsendプロパティに送られて来るのを待ち構えています。

③ 【メッセージ送信】

ここまでで大体準備ができている(バックグラウンドで処理が走っている)のですが、メッセージを送る(submitボタンを押す)とまずjsが走ります。

scriptタグ内のsocket.send(msgBox.val());

が走り、client#read()内の

c.room.forward <- msg

が走ります。

するとrunメソッドで全てのクライアントにメッセージを送信します。case msg 〜 の箇所ですね。

クライアントのsendプロパティにメッセージが送信され、client#write()で描画がされます。

④ 退室

退室ボタンなどないので、ページを離れると退室となります。

離脱の際に前述のdefer文が実行されます。

roomオブジェクトのleaveプロパティにクライアントが送られ、またもやrunメソッドの処理が実行されます。

roomの参加メンバーからクライアントが消され、リソースが解放されているのが分かるかと思います。

——————————-

はいお疲れ様です!

ちょっと長くなってしまいましたが、ゴルーチンの便利さが伝わったでしょうか?

また気が向いたら続きとして、外部の認証プロバイダーを使った認証機能の実装や画像の登録などについて今回のように超丁寧に書きたいと思います。では!

Pocket
LINEで送る