58
39

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Goでチャットサーバーを作ってgoroutineとchannelを理解してみる

Posted at

シェアフル Advent Calendar18日目 の記事になります。
最近Goを勉強したい熱が上がってきたので、今回はチャットサーバー的なのを作って goroutineとchannelの仕組みについて理解してみました。

はじめる前に

まだ学習中の身ですので、もし何かしら間違っている箇所や定義が曖昧な部分があったらバンバン指摘してもらえると助かります:pray:

goroutineとは? 

  • Goのランタイムによって管理されている処理実行用の軽量なスレッド
  • 並行処理を可能にする
  • 関数を呼び出す直前にgoをつけると呼び出すことができる (ex: go functionName(ctx))
  • 同じメモリのアドレス空間を共有している

channelとは?

  • goroutineで定義した並列で実行されている関数同士で値の送受信を可能にする型
  • 実際に送受信する値の型を定義することができる

なぜチャットサーバー?

単純に自分的にイメージがつきやすいかなーって思ったからです。
time.Sleepとかで試してみてある程度動きはわかるものの、実用的なケースはどういう物があるのかイメージがいまいち沸かなかったので。

構成図的な

かなりざっくりとした構成図ですが、最終的にはこんな感じになるかと思います。
クライアントがチャットサーバーにwebsocketで接続して、そのコネクションをgoroutine関数でハンドリングし、メッセージの送信を受け付けます。
送信されたメッセージはchannel型の変数に送り、別のgoroutine関数でメッセージを拾い、それをすべてのクライアントに送信するようにします。

無題のプレゼンテーション (1).png

作ってみる

今回出てくるソースコードはこちらの記事のもの使用しております。

まずは適当なディレクトリを作ります

$ mkdir $GOPATH/src/github.com/username/chat-server

チャットサーバーにはwebsocketは必須なのでwebsocketのパッケージをインストールしましょう。今回はよく使われているGorilla Toolkitをインストールします

$ go get github.com/gorilla/websocket

では実際にチャットサーバーの実装を進めていきましょう。
まずは必要なパッケージをインポートします。gorillaはもちろん、httpサーバとロギング用のパッケージもインポートします。

main.go
package main

import (
        "log"
        "net/http"

        "github.com/gorilla/websocket"
)

次に定義する変数はこのチャットサーバーのメインとなる部分です。
1番目の変数はmapでwebsocketのコネクションに対するポインタを定義しています。2番目クライアント側から送られて来たメッセージをキューイングする役目を担うものになります。

main.go
...
var clients = make(map[*websocket.Conn]bool)
var broadcast = make(chan Message)

次にアップグレーダを定義します。この変数はhttpコネクションを受け取ってwebsocketにアップグレードする関数を保持したオブジェクトになります。

main.go
...
var upgrader = websocket.Upgrader{}

次にメッセージを保持するための構造体を定義します。
メールアドレス、ユーザ名とメッセージなどの情報を保持したシンプルなものになります。

main.go
...
type Message struct {
        Email    string `json:"email"`
        Username string `json:"username"`
        Message  string `json:"message"`
}

これで必要最低限なものは定義しました。次にメインロジックを作っていきましょう。
静的ファイルを参照するファイルサーバの立ち上げやルーティングの紐づけなどがここに入ります。

main.go
...
func main() {
        fs := http.FileServer(http.Dir("./public"))
        http.Handle("/", fs)

        http.HandleFunc("/ws", handleConnections)
        go handleMessages()

        log.Println("http server started on :8000")
        err := http.ListenAndServe(":8000", nil)
        if err != nil {
                log.Fatal("ListenAndServe: ", err)
        }
}

チャットサーバーのメインの機能はこの2つになります。最初に/wsにwebsocketの接続を始めるためのリクエストをハンドリングします。受け取ったリクエストはhandleConnectionsという関数で処理しますが、これは後ほど作ります。
さらにここでhandleMessagesというgoroutineを開始させます。こちらも後ほど触れます。

main.go
func main() {
...
        http.HandleFunc("/ws", handleConnections)
        go handleMessages()
...
}

まずはコネクションハンドラとして動いてくれるhandleConnectionsの中身を作ります。
動作の流れとしては、GETリクエストをwebsocketにアップグレード→受け取ったリクエストをクライアントとして登録→websocketからのメッセージを待ち続け、受け取ったらbroadcastチャネルに送る、といった感じです。
もし関数の中の処理が終わった場合はdeferを使って必ずwebsocketのコネクションを閉じるようにしています。

httpのルーティングハンドラも実はgoroutineとして動いているので、複数のコネクションを順番を待たずに同時に処理することを可能にしています。

main.go
func handleConnections(w http.ResponseWriter, r *http.Request) {

	ws, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		log.Fatal(err)
	}

	defer ws.Close()

	clients[ws] = true

	for {
		var msg Message
		err := ws.ReadJSON(&msg)
		if err != nil {
			log.Printf("error: %v", err)
			delete(clients, ws)
			break
		}
		broadcast <- msg
	}
}

broadcastチャネルにメッセージが送信された時、それを取り出す役目をhandleMessagesが担っています。
handleConnectionsでハンドリングしているクライアントのどれかがメッセージを送信したら、それを取り出して現在接続しているクライアント全てにそのメッセージを送信するようになっています。

main.go
func handleMessages() {
	for {
		msg := <-broadcast
		for client := range clients {
			err := client.WriteJSON(msg)
			if err != nil {
				log.Printf("error: %v", err)
				client.Close()
				delete(clients, client)
			}
		}
	}
}

全体的には以下のような感じになります

main.go
package main

import (
	"log"
	"net/http"

	"github.com/gorilla/websocket"
)

var clients = make(map[*websocket.Conn]bool) // 接続されるクライアント
var broadcast = make(chan Message)           // メッセージブロードキャストチャネル

// アップグレーダ
var upgrader = websocket.Upgrader{}

// メッセージ用構造体
type Message struct {
	Email    string `json:"email"`
	Username string `json:"username"`
	Message  string `json:"message"`
}

func main() {
	// ファイルサーバーを立ち上げる
	fs := http.FileServer(http.Dir("./public"))
	http.Handle("/", fs)
	// websockerへのルーティングを紐づけ
	http.HandleFunc("/ws", handleConnections)
	go handleMessages()
	// サーバーをlocalhostのポート8000で立ち上げる
	log.Println("http server started on :8000")
	err := http.ListenAndServe(":8000", nil)
	// エラーがあった場合ロギングする
	if err != nil {
		log.Fatal("ListenAndServe: ", err)
	}
}

func handleConnections(w http.ResponseWriter, r *http.Request) {
	// 送られてきたGETリクエストをwebsocketにアップグレード
	ws, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		log.Fatal(err)
	}
	// 関数が終わった際に必ずwebsocketnのコネクションを閉じる
	defer ws.Close()

	// クライアントを新しく登録
	clients[ws] = true

	for {
		var msg Message
		// 新しいメッセージをJSONとして読み込みMessageオブジェクトにマッピングする
		err := ws.ReadJSON(&msg)
		if err != nil {
			log.Printf("error: %v", err)
			delete(clients, ws)
			break
		}
		// 新しく受信されたメッセージをブロードキャストチャネルに送る
		broadcast <- msg
	}
}

func handleMessages() {
	for {
		// ブロードキャストチャネルから次のメッセージを受け取る
		msg := <-broadcast
		// 現在接続しているクライアント全てにメッセージを送信する
		for client := range clients {
			err := client.WriteJSON(msg)
			if err != nil {
				log.Printf("error: %v", err)
				client.Close()
				delete(clients, client)
			}
		}
	}
}

では最後に実際にメッセージのやり取りをするためのクライアントのインターフェイスを作ります。
ユーザ名とメールアドレスを登録したあと、チャットメッセージのやり取りが可能になる単純なものです。
main.goが置かれているディレクトリにpublicというディレクトリを作成し、以下の各静的ファイルを置きます。
今回フロント周りは Vueを使っています。

index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Simple Chat</title>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/0.97.8/css/materialize.min.css">
    <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
    <link rel="stylesheet" href="/style.css">

</head>
<body>
<header>
    <nav>
        <div class="nav-wrapper">
            <a href="/" class="brand-logo right">Simple Chat</a>
        </div>
    </nav>
</header>
<main id="app">
    <div class="row">
        <div class="col s12">
            <div class="card horizontal">
                <div id="chat-messages" class="card-content" v-html="chatContent">
                </div>
            </div>
        </div>
    </div>
    <div class="row" v-if="joined">
        <div class="input-field col s8">
            <input type="text" v-model="newMsg" @keyup.enter="send">
        </div>
        <div class="input-field col s4">
            <button class="waves-effect waves-light btn" @click="send">
                <i class="material-icons right">chat</i>
                Send
            </button>
        </div>
    </div>
    <div class="row" v-if="!joined">
        <div class="input-field col s8">
            <input type="email" v-model.trim="email" placeholder="Email">
        </div>
        <div class="input-field col s8">
            <input type="text" v-model.trim="username" placeholder="Username">
        </div>
        <div class="input-field col s4">
            <button class="waves-effect waves-light btn" @click="join()">
                <i class="material-icons right">done</i>
                Join
            </button>
        </div>
    </div>
</main>
<footer class="page-footer">
</footer>
<script src="https://unpkg.com/vue@2.1.3/dist/vue.min.js"></script>
<script src="https://code.jquery.com/jquery-2.1.1.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/3.1.2/rollups/md5.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/0.97.8/js/materialize.min.js"></script>
<script src="/app.js"></script>
</body>
</head>
</html>
style.css
body {
    display: flex;
    min-height: 100vh;
    flex-direction: column;
}

main {
    flex: 1 0 auto;
}

#chat-messages {
    min-height: 10vh;
    height: 60vh;
    width: 100%;
    overflow-y: scroll;
}
app.js
new Vue({
    el: '#app',

    data: {
        ws: null,
        newMsg: '',
        chatContent: '',
        email: null,
        username: null,
        joined: false
    },
    created: function() {
        var self = this;
        this.ws = new WebSocket('ws://' + window.location.host + '/ws');
        this.ws.addEventListener('message', function(e) {
            var msg = JSON.parse(e.data);
            self.chatContent += '<div class="chip">'
                + '<img src="' + self.gravatarURL(msg.email) + '">'
                + msg.username
                + '</div>'
                + msg.message + '<br/>';

            var element = document.getElementById('chat-messages');
            element.scrollTop = element.scrollHeight;
        });
    },
    methods: {
        send: function () {
            if (this.newMsg != '') {
                this.ws.send(
                    JSON.stringify({
                            email: this.email,
                            username: this.username,
                            message: $('<p>').html(this.newMsg).text()
                        }
                    ));
                this.newMsg = '';
            }
        },
        join: function () {
            if (!this.email) {
                Materialize.toast('You must enter an email', 2000);
                return
            }
            if (!this.username) {
                Materialize.toast('You must choose a username', 2000);
                return
            }
            this.email = $('<p>').html(this.email).text();
            this.username = $('<p>').html(this.username).text();
            this.joined = true;
        },
        gravatarURL: function(email) {
            return 'http://www.gravatar.com/avatar/' + CryptoJS.MD5(email);
        }
    }
});

画面はこのような感じになります。
chat-server1.png

必要な情報を登録したらメッセージは送信できます。
chat-server2.png

2つのユーザ間でもメッセージのやり取りはできました。
chat-server3.png

最後に

いかがでしょうか。goroutineで複数の処理を並列で実行し、データの共有をchannelを通してやるというのがある程度このチャットサーバーを作ることによってより感覚的にわかりやすくなったのではないかと思います。
ただ、他に色々なパターンやユースケースもあるのでそれも調べていきたいと思います。

参考

Build a Realtime Chat Server With Go and WebSockets
goの並列処理入門編 goroutineとchannel
Go の並行処理

58
39
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
58
39

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?