78
82

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.

Flask-SocketIOでWebSocketアプリケーション

Last updated at Posted at 2016-08-12

はじめに

ふとしたきっかけでチャットアプリの実装が必要になったため
Flaskを使ってWebSocketアプリケーションをどうやって実装できるか調べました。

サンプルを動かしてみるところまで書きますが
これだけで、チャットの主な機能は十分使えてしまいます。

エクステンションの選定

Flaskの肩に乗っかって実装するつもりで
選択肢に挙がったのが以下の2つのエクステンションです。

2014年2月と少し古いのですが、miguelgrinberg氏による解説記事
両者の比較を説明している箇所があります。

++++++++++++++++++++++++++++++++++++++++++++++++++

The main difference between Flask-Sockets and Flask-SocketIO is that the former wraps the native WebSocket protocol (through the use of the gevent-websocket project), so it can only be used by the most modern browsers that have native support. Flask-SocketIO transparently downgrades itself for older browsers.

Flask-SocketsはネイティブのWebSocketプロトコルを
gevent-websocketを通してラップしている。
ネイティブでWebSocketをサポートしているモダンブラウザのみで使用される。
一方で、Flask-SocketIOならもう少し古いブラウザでも使用可能。

Another difference is that Flask-SocketIO implements the message passing protocol exposed by the SocketIO Javascript library. Flask-Sockets just implements the communication channel, what is sent on it is entirely up to the application.

Flask-SocketIOはJavaScriptのSocketIOライブラリと
メッセージをやりとりするためのプロトコルを実装している。
Flask-Socketsはコミュニケーションのチャネルを実装しているのみであり
何を送るかはアプリケーションに委ねられている。

Flask-SocketIO also creates an environment for event handlers that is close to that of regular view functions, including the creation of application and request contexts. There are some important exceptions to this explained in the documentation, however.

Flask-SocketIOはビュー関数に近接するイベントハンドラのための環境を生成する。
これはアプリケーションコンテキストやリクエストコンテキストの生成を含む。
ドキュメントの中で説明されているように、例外はあるが。

++++++++++++++++++++++++++++++++++++++++++++++++++

3点目の翻訳がうまくできてないものの
要は既存のFlaskアプリにシームレスに実装できるということだと思います。

作者の信頼度もGitHubのスターも同程度で悩みましたが

  • アップデートの頻度が高い
  • すぐに動くサンプルが付属している

という点から、Flask-SocketIOを選択しました。
WebSocketプロトコルにがっつり触れたい場合や、クライアント(JavaScript)側に
自由度を求める場合はFlask-Socketsを使うべきでしょうか。

とはいえ、どちらでもやりたいことは実現できそうです。

準備

前回の記事と同様にローカルのDockerを使用します。
私の実行環境はDocker for MacのPublic Betaです。

バージョン
$ docker --version
Docker version 1.12.0, build 8eab29e, experimental

手早く環境を作るするためにDockerを使っているだけなので
ある程度新しいPythonが動く環境があれば、環境構築部分は読み飛ばしてください。

ソース

flask_socket/
    ├ Dockerfile
    └ requirements.txt

Dockerfile

# ベースイメージの指定
FROM python:3.5.2-alpine

# ソースを置くディレクトリを変数として格納                                                                                                                  
ARG project_dir=/web/socket/

# apkでインストールできるパッケージの更新後にgitをインストール
RUN apk update
RUN apk add git

# requirements.txtに記載されたパッケージをインストール
WORKDIR $project_dir
ADD requirements.txt .
RUN pip install -r requirements.txt

# GitHubのリポジトリからFlask-SocketIOのソースコードを取得
RUN git clone https://github.com/miguelgrinberg/Flask-SocketIO.git Flask-SocketIO
WORKDIR $project_dir/Flask-SocketIO/example

requirements.txt

FlaskFlask-SocketIOをpipでインストールしたあとに
$ pip freeze > requirements.txtしたもの。

requirements.txt
click==6.6
Flask==0.11.1
Flask-SocketIO==2.6.2
itsdangerous==0.24
Jinja2==2.8
MarkupSafe==0.23
python-engineio==0.9.2
python-socketio==1.4.4
six==1.10.0
Werkzeug==0.11.10

Flaskの本体が依存しているパッケージ以外では
Flask-SocketIOpython-engineiopython-socketiosixが追加されています。

手順

イメージ作成

イメージ作成
$ cd /path/to/flask_socket/
$ docker build -t flask_socket . 
  • -t flask_socket: 作成するイメージの名前を'flask_socket'にする
  • .: カレントディレクトリにあるDockerfileを使用

コンテナ起動

コンテナ起動
$ docker run -p 5000:5000 -it flask_socket /bin/sh
  • -p 5000:5000: hostの5000番ポートをコンテナの5000番ポートに向ける
  • -it: 現在の入力デバイスでコンテナを操作
  • flask_socket: イメージ名の指定
  • /bin/sh: 起動したコンテナでshコマンドを実行

コードの追加

アプリケーション実行のコードにhostの設定を追記します。

app.py
# socketio.run(app, debug=True)
socketio.run(app, host='0.0.0.0', debug=True)

サンプルアプリケーションの実行

Flask実行
$ python app.py

ブラウザからlocalhost:5000にアクセスすると、以下のような画面が表示されます。

Flask-SocketIO.png

'Send:'フォームの一部を解説

  • Echo: メッセージを入力したユーザにオウム返し
  • Broadcast: すべてのクライアント(同じページを開いているすべてのユーザ)に送信
  • Disconnect: サーバとの接続を切断

EchoBroadcastの違いはタブを並べてみるとわかりやすいと思います。
他の項目はチャットルーム関連の操作と解釈して割愛。
ちなみに部屋の名前は指定できるものの、自分の名前は入力できません。

コードの解釈

アプリケーション実行時の処理

app.py
# 必要なモジュールの読み込み
from flask import Flask, render_template, session, request         
from flask_socketio import SocketIO, emit, join_room, leave_room, \
      close_room, rooms, disconnect                                  

# 非同期処理に使用するライブラリの指定
# `threading`, `eventlet`, `gevent`から選択可能
async_mode = None

# Flaskオブジェクトを生成し、セッション情報暗号化のキーを指定
app = Flask(__name__)
app.config['SECRET_KEY'] = 'secret!'

# Flaskオブジェクト、async_modeを指定して、SocketIOサーバオブジェクトを生成
socketio = SocketIO(app, async_mode=async_mode)

# スレッドを格納するためのグローバル変数
thread = None
app.py
# SocketIOサーバをデバッグモードで起動
socketio.run(app, debug=True)

ページを開いた際の処理

html内のscript内で、SocketIOライブラリ(JavaScript)が読み込まれます。

index.html
<script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/socket.io/1.3.5/socket.io.min.js"></script>

その後、ネームスペース(WebSocket通信のエンドポイント)の指定と
SocketIOサーバとの接続が行われます。

サーバ接続時に発行されるイベントのハンドラ指定や
my responseイベントのハンドラ指定もここで行われています。
レスポンスは#logセレクタで指定されたタグに追加されていくようです。

index.html
namespace = '/test'; 

var socket = io.connect('http://' + document.domain + ':' + location.port + namespace);

socket.on('connect', function() {
    socket.emit('my event', {data: 'I\'m connected!'});
});

socket.on('my response', function(msg) {
    $('#log').append('<br>' + $('<div/>').text('Received #' + msg.count + ': ' + msg.data).html());
});                                                                                                                                                   

サーバ側では以下のコードでクライアントからの接続要求を受け
my responseイベントを発火させることでレスポンスを返しています。
socketio.start_background_task(target=background_thread)の部分は後述します。

app.py
@socketio.on('connect', namespace='/test')
def test_connect():
    global thread
    if thread is None:
        thread = socketio.start_background_task(target=background_thread)
    emit('my response', {'data': 'Connected', 'count': 0})

以上のコードにより、Responseログの初期動作部分が表示されます。

Received #0: Connected
Received #1: I'm connected!

PythonとJavaScriptの両方で、イベントに対するハンドラを記載することにより
双方向のやりとりを定義できることがわかりました。

Echo/Broadcastの際の処理

Echo/BroadcastはFormタグが以下のように記載されており

index.html
<form id="emit" method="POST" action='#'>
    <input type="text" name="emit_data" id="emit_data" placeholder="Message">
    <input type="submit" value="Echo">
</form>
<form id="broadcast" method="POST" action='#'>
    <input type="text" name="broadcast_data" id="broadcast_data" placeholder="Message">
    <input type="submit" value="Broadcast">
</form>

フォームのsubmitにより以下のコードで定義されたハンドラが実行されます。
それぞれmy eventイベントとmy broadcast event
フォームに入力されたデータを持たせて発火させています。

index.html
$('form#emit').submit(function(event) {
    socket.emit('my event', {data: $('#emit_data').val()});
    return false;
});
$('form#broadcast').submit(function(event) {
    socket.emit('my broadcast event', {data: $('#broadcast_data').val()});
    return false;
});

結果としてPython側の以下のコードが実行されて
my responseイベントを発火させることにより、結果を返しています。
ほとんど同じコードが並んでいますが、my broadcast eventの場合は
broadcast=Trueemitのキーワード引数として置くことで
メッセージをすべてのクライアントに送るように指定しています。

app.py
@socketio.on('my event', namespace='/test')
def test_message(message):
    session['receive_count'] = session.get('receive_count', 0) + 1
    emit('my response',                                               
         {'data': message['data'], 'count': session['receive_count']})
                                                                      
@socketio.on('my broadcast event', namespace='/test')
def test_broadcast_message(message):
    session['receive_count'] = session.get('receive_count', 0) + 1
    emit('my response',
         {'data': message['data'], 'count': session['receive_count']},
         broadcast=True)

ping/pongの処理

'Average ping/pong latency'の部分には
サーバとの通信のやりとりにどれだけレイテンシがあるかが表示されています。

以下のコードでは1秒ごとに通信の開始時間を記録し
my pingイベントを発火させています。
ping_pong_times変数は過去の通信を格納するための配列です。

index.html
var ping_pong_times = [];
var start_time;
window.setInterval(function() {
    start_time = (new Date).getTime();
    socket.emit('my ping');
}, 1000);

Python側はmy pingイベントの発火を受けて、my pongを発火させるのみです。

app.py
@socketio.on('my ping', namespace='/test')
def ping_pong():
    emit('my pong')

JavaScript側ではmy pongが発火した時間と開始時間の差を取り
過去の通信記録を平均したものを'Average ping/pong latency'に表示しています。

app.py
socket.on('my pong', function() {
    var latency = (new Date).getTime() - start_time;
    ping_pong_times.push(latency);
    ping_pong_times = ping_pong_times.slice(-30); // keep last 30 samples
    var sum = 0;
    for (var i = 0; i < ping_pong_times.length; i++)
        sum += ping_pong_times[i];
    $('#ping-pong').text(Math.round(10 * sum / ping_pong_times.length) / 10);
});

サーバ側でのイベント生成処理

ここまでの処理はすべてクライアント側(JavaScript)が起点になっていましたが
アプリケーションにおいて、サーバ側から情報をプッシュしたい場合もあります。

サンプルコードでは、connectイベントのハンドラに以下の記述がありました。

app.py
thread = socketio.start_background_task(target=background_thread)

targetとなっているbackground_threadは以下のように定義されています。

app.py
def background_thread():
    """Example of how to send server generated events to clients."""     
    count = 0
    while True:
        socketio.sleep(10)
        count += 1
        socketio.emit('my response',
                      {'data': 'Server generated event', 'count': count},
                      namespace='/test')

10秒ごとにmy responseイベントを発火させていることがわかります。
タイムラインの自動更新や、美人時計のようなアプリを実装する際に有用そうです。

むすび

サンプルコードを落として読んだだけですが、ためになりました。
これを少し改変するだけでも色々作れそうですね。

これからソースコード全体の読み込みは必要になるものの
エクステンションを含めても小さめで、とっつきやすいのはFlaskの魅力だと思います。

78
82
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
78
82

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?