GoとRedisにおける簡単なチャットアプリケーション

この記事は eureka Advent Calendar 2018 16日目の記事です。

エウレカのAPIチームに所属している鈴木です。
今回はRedisとGoだけを使用して作成した簡単なチャットアプリケーションの例を紹介します。
Redisを触ってみたいRedis初心者の方向けの記事です。

実装の参考となるプロジェクトをGitHubで公開しています。

紹介するRedisのtips

  • RedisのPub/Sub機能を利用してメッセージの送受信
  • TTLを使用し、有効期限切れのユーザーをデータから削除

Redisのインストール

Homebrewを利用してインストールします。

$ brew install redis

Redisに接続する

今回は、RedisクライアントライブラリであるRedigoを使用します。

Redigoとは

RedisのGo言語向けクライアントライブラリです。
Redisの公式サイトでもおすすめされているので今回はこちらのライブラリを使ってみます。
スターマークが付いているライブラリがおすすめだそうです。

redisをローカルで動かすためにRedisが動いているURLを取得する必要があります。RedisのURLを簡単に取得できるRedisURLという便利なパッケージがあるのでこちらを使ってみます。このRedisURLを利用するとローカルのRedisに簡単に接続することが出来ます。

// Connect using os.Getenv("REDIS_URL").
conn, err := redisurl.Connect()
if err != nil {
fmt.Println(err)
os.Exit(1)
}

環境変数を使用しない場合は、URLを直接取得するConnectToURL()というメソッドも用意されているのでこちらを使用しましょう。

ユーザーの作成

チャットアプリケーションでチャットをするためにまず初めにメッセージを送受信するためのユーザーを作成します。
コマンドラインの第一引数としてユーザ名を取得します。
ユーザーが無事にRedisに接続することが出来、メッセージを送受信できる状態をオンラインであると定義します。
ですので取得したユーザー名にオンラインかどうかのprefixをつけたものをRedisのキーとして使用します。

userName := os.Args[1]
userKey := “online.” + userName

無事にRedisに接続成功したら接続を試みたユーザーが既にオンラインになっていないかを確認します。
キーが既に存在する場合は、同一名のユーザーがオンライン上にいるため接続を強制終了させます。
しかし今のままだと強制終了させる際にRedisの保存してあるキーを削除することができません。
そこでGoのdefer文を利用して強制終了させた後にRedisとの接続を切ります。このdefer文ですが、プログラムが異常に終了した際は呼び出されません。
ですので、キーに有効期限を設定するRedisの機能(TTL key)を紹介します。

TTLとは

TTL は Time to Live の略で、日本語では有効生存期間、あるいは単に生存時間ということがある。

指定したキーの有効期間の残りを確認出来る機能は、Redisコマンドとしても用意されています。
ユーザーが設定した時間内に戻ってキーをリセットしない限り、自動で削除されるよう、キーのEXPIRE(有効期限)を設定しましょう。

val, err := conn.Do(“SET”, userkey, username, “NX”, “EX”, “120”)
if val == nil {
fmt.Println(“既にオンラインです。”)
os.Exit(1)
}

conn.DoでSETを実行しRedisに対して値を書き込みます。
SETはデータを格納するためのコマンドです。
NXオプションは同じkeyでデータが存在しない時のみ保存するためのオプションです。EXオプションを指定すると自分で指定した秒数後にデータが消去されるようになります。
EXオプションを使用してキーのTTLを設定します。今回は120秒に設定してみましょう。
キーがすでに存在する場合、 “ok”ではなくnilの値を返します。
これで接続を試みたユーザーが既にオンラインかどうかを判断しています。

ユーザーリストの設定

ユーザーの名前をSADDコマンドを使いusersというリストにデータを追加していきます。

val, err = conn.Do(“SADD”, “users”, userName)

このusersというリストで接続したことあるユーザーを保存していきます。key/valueをいつ更新するかをリマインドさせるGoのTickerをtimeパッケージのNewTickerを使い設定しましょう。

tickerChan := time.NewTicker(time.Second * 60).C

メッセージの送受信

Redisのパブリッシュおよびサブスクライブシステムを使用してメッセージを送受信します。
Goのチャンネルとゴルーチンを使用して常に新しいメッセージを見ることが出来るよう実装していきます。subChanというチャンネルを作ってから、Redisへの新しい接続を開始します。

subChan := make(chan string)
go func() {
subConn, err := redisurl.Connect()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
defer subConn.Close()

共通のRedisURLを与えることで、誰もが簡単に参加できるようになります。

次にサブスクライブするチャンネルを設定します。

psc := redis.PubSubConn{Conn: subconn}
psc.Subscribe(“messages”)

下記のコードでユーザーからのメッセージを待ちます。
Redisのpubsubメッセージを受信し、スイッチを使用してさまざまなタイプのメッセージに対応することができます。
データには、別のユーザーから送られた文字列メッセージが入ります。
それを受け取りstringに変換してから、subChanチャンネルにメッセージを送ります。

for {
switch v := psc.Receive().(type) {
case redis.Message:
subChan <- string(v.Data)
case redis.Subscription:
break
case error:
return
}
}

メッセージには、送信されたチャンネルの名前も付いています。Redisでチャンネルの複数のチャンネルやワイルドカードの選択することが出来るので、知っておくと便利です。
この例では、1つのチャンネルだけを使用していますが、異なるチャンネルに複数のチャットルームを持つことができます。

ターミナルからコマンドを読み込む

ユーザーからの入力を読み込むための別のチャンネルとゴルーチンを設定します。
sayChanチャンネルは、Stdinからの行を読み込むゴルーチンです。
チャネルに「/exit」を挿入すると、ユーザーは自身でチャットから退出することが出来ます。

sayChan := make(chan string) 
go func() {
prompt := username + “>”
bio := bufio.NewReader(os.Stdin)
for {
fmt.Print(prompt)
line, _, err := bio.ReadLine()
if err != nil {
fmt.Println(err)
sayChan <- “/exit”
return
}
sayChan <- string(line)
}
}()

チャットに入る準備が出来ました。

conn.Do(“PUBLISH”, “messages”, userName+” has joined”)

ユーザーがログインすると、参加しているすべてのユーザーに対して上記のメッセージが送信されます。

for !chatExit { 
select {

これで、chatExitフラグがtrueに設定されている時だけチャットから退出する仕様に設定できました。
3つのアクティブなチャンネル、サブチャンネルsubChan、ユーザー入力チャンネルsayChan、ティッカーチャンネルtickerChanがあるので、Go selectコマンドを使ってすべてのチャンネルに参加出来るようにします。

case msg := <-subChan: 
fmt.Println(msg)

読み込むべきメッセージがある場合は、そのメッセージを読み込み全てのユーザーに対して出力します。

今回は、キーがすでに存在していることを確認し、キーが存在しない場合は失敗させます。
そのために、 “XX”オプションを使用します。
キーが存在しなければメッセージは送信できないので、それ以外の場合は、一時停止され、有効期限が切れたとみなすことができます。
その場合、chatExitフラグを設定して、

case <-tickerChan: 
val, err = conn.Do(“SET”, userKey, userName, “XX”, “EX”, “120”)
if err != nil || val == nil {
fmt.Println(“Heartbeat set failed”)
chatExit = true
}

sayChanチャンネルからユーザーのエントリを読み込み、終了コマンド( “/exit”)の場合はchatExitをtrueに設定します。

case line := <-sayChan: 
if line == “/exit” {
chatExit = true
}

“/who”コマンドであれば、以前に追加したユーザーを取り出して出力します。redis.Stringsヘルパーを利用すると、
SMEMBERS Redisコマンドの結果を文字列の配列に簡単に変換できます。

else if line == “/who” { 
names, _ := redis.Strings(conn.Do(“SMEMBERS”, “users”))
for _, name := range names {
fmt.Println(name)
}
}

入力がこれらのコマンドのいずれでもなかった場合は、メッセージチャネルに入力されたものを送信します。

else { 
conn.Do(“PUBLISH”, “messages”, username+”:”+line)
}

チャットからの退出

conn.Do(“DEL”, userkey) 
conn.Do(“SREM”, “users”, userName)
conn.Do(“PUBLISH”, “messages”, userName+” has left”)

ユーザーが退出したときは、そのユーザーのキーをDELコマンドで削除します。

まとめ

RedisとGoで簡単なアプリケーションを作成してみました。
Redisを使い、最低限GETとSETができれば値の高速な呼び出しが可能なことを確認出来ました。
TTLをうまく利用すればリソースを圧迫せずにアプリケーションを稼働することが出来ます。

他のKVSも触ってみて、Redisとの比較を今後していきたいと思います。

最後まで読んでいただきありがとうございました。