この記事ではDjangoChannelsを用いた非同期通信を実現するための方法について記載する.
非同期通信とは?
プロセスやプログラムがタスクを実行する際に,その完了を待たずに次の操作に移ることができる通信方式のこと.この通信方式では,リクエストを送信した後,レスポンスが返ってくるまでの間にも他の作業を同時並行で進めることが可能である.
非同期通信が実装できると以下のようなプロダクトが開発できる.
1.リアルタイムチャットアプリ
2.ライブアップデートを提供するダッシュボード
3.非同期処理を必要とする幅広いWebサービス
4.リアルタイムゲーム
開発概要
今回は簡単にこのようにUnity(VRなど)上でカメラの位置を送信し,Androidでそのカメラの位置(赤円)を受信することをリアルタイムで動作することを目的にする.
マップまでは今回は実装しません
私用で開発したものを転用しています.
Webシステムの基本については以下の記事を参考にしてほしい
https://qiita.com/tarakokko3233/items/fcb3dc2e2c49bf1ee293
Websocketとは?
Webにおいて異なるプラットフォーム間など双方向通信を低いリソース消費で行うためのプロトコル.
HTTPとの違い
HTTPはWeb通信といえば...という感じ.主にHTMLテンプレートを転送するためのプロトコル.
Websocketはサーバとクライアントが一度ハンドシェイクをする (ステータスコード101(Switching Protocols)が返ってくる) と、その後の通信を専用のプロトコルで通信する.これで低コストにてリアルタイム通信を実現している.
ハンドシェイクとは
クライアント側とサーバー側でコネクションを確立すること.HTTPやWebsocketはTCPを使っているため双方向の通信が確立されてからデータ通信を開始している.
TCP(Transmission Control Protocol)
1対1のセッションによる信頼性の高い通信を行うためのプロトコル.
補足
WebSocket は基本的には TCP プロトコル上に構築されている.これは TCP の提供する信頼性や順序保証の特性を活かして,確実なデータ転送を行うためである.WebSocket は,HTTP プロトコルを使用して接続を開始するが,接続が確立されると,プロトコルが WebSocket 専用のものにアップグレードされる.
なぜHTTPだといけないのか
HTTPは1つのコネクションで1つのリクエストしか送ることができず,クライアント側からのみしかリクエストを送ることができない.また,クライアント側からのトリガーによって通信を開始することになるので即時性の通信を求めると高コストでの通信になってしまう.一応HTTPで非同期通信を実装することはできるがなんとか低コストで実現することができないものか...と考えられできたプロトコルがWebsocketである.
Websocketのメリット
一度コネクションを確立したあと,サーバとクライアントのどちらからも通信を行うことが可能で,そのコネクション上で通信し続ける.HTTPのように通信を開始するためにコネクションを確立する必要がない.
API(DjangoChannels)の概要
作成する前に言葉の説明だけしておく.
DjangoChannelsとは
DjangoChannels はDjangoのアプリケーションでASGIを利用するためのフレームワーク.DjangoはもともとWSGIベースで設計されており,非同期処理やWebSocketのような技術を直接サポートしていなかった.Channelsはこの制限を解消し,ASGIをベースとすることで,Djangoアプリケーション内で非同期処理やリアルタイム通信を実現する.
Django Channelsを使用することで,開発者はDjangoの既存の機能をそのままに,WebSocketやHTTP2など,その他のプロトコルを扱うことが可能になる.これにより,チャットアプリケーションやリアルタイム通知システム,さらにはIoTデバイスとの連携など,さまざまなリアルタイムWebアプリケーションを簡単に開発できるようになった.
ASGI(Asynchronous Server Gateway Interface)とは?
Pythonの非同期アプリケーションとサーバー間のインターフェース仕様のこと.WSGIの後継として開発され,非同期プログラミングをサポートすることが主な目的.ASGIは,単なるHTTPリクエストを超えて,WebSocketやその他の長期間の接続を扱う能力を提供する.
WSGI (Web Server Gateway Interface) とは
PythonにおけるWebアプリケーションとWebサーバー間の標準インターフェースで,同期的な処理に限定されている.これはHTTPリクエストを1つずつ順番に処理するため,リアルタイム処理や長時間の接続(例えばWebSocket)を効率的に扱うことができない.
Redisサーバーの取得
ここではPaaS(Heroku)上でRedisサーバーを動かしたい人向けにどのように設定するかを書く.
Redisサーバーとは
チャットアプリやリアルタイムのゲームサーバーなど、即時性が求められるアプリケーションで使われるキーと値のペアをメモリ内に保存する,オープンソースのインメモリデータストアのこと.データ構造を直接メモリに格納するため,ディスクベースのデータベースよりも迅速にデータを読み書きできる.
RedisサーバーとしてHeroku Data for Redisを使う.
Heroku Dashboardにアクセスし,アプリケーションを作成し,アプリケーション内のResources
でHeroku Data for Redis
アドオンを取り込む.(プランは任意で)
Heroku Data for Redis
のSettings
のページを開き,ViewCredentials...
を押すとURI
の欄が出てくる.これがRedisサーバーに接続するための情報である.
環境変数として値を格納した方がいいのでプロジェクトのルートディレクトリにアクセスし,
.env
ファイルを作成し,以下の内容にする.他にもSECRET_URLやDB接続情報を書くと良い..envREDIS_URL=YourRedis_urlName
API(DjangoChannels)の作成
ローカルマシンにDjangoプロジェクトを構築する
https://qiita.com/tarakokko3233/items/46c567fb32a26a69269a
今回は以下のディレクトリ構造であると仮定する.(.venvやDockerfileは省略する)
yourprojectname/
manage.py
yourprojectname/
__init__.py
settings.py
urls.py
asgi.py
wsgi.py
yourappname/
__init__.py
admin.py
apps.py
migrations/
__init__.py
models.py
tests.py
views.py
templates/
statics/
css/
js/
img/
仮想環境(.venvディレクトリ)またはDockerコンテナ上でDjangoプロジェクト上を動かすことを前提にしている.
ライブラリのインストール
まず以下の依存関係をrequirements.txtを追加する(既存のものも含まれている)
Django
psycopg2
python-dotenv
gunicorn
whitenoise
channels
channels_redis
dj-database-url
daphne
twisted[tls,http2]
.venvを使っている人は
requirements.txtで追加した依存関係をインストールする.pip install -r requirements.txt
Python3を用いているのならばpipではなくpip3を使う.
settings.pyの設定
settings.py
に以下を追記する.
INSTALLED_APPS = [
...
'yourappname',
'channels',
]
# asgiアプリケーションの位置
ASGI_APPLICATION = 'yourprojectname.asgi.application'
# websocket同時通信の接続情報(環境変数で行うことを推奨('REDIS_URL'は.envファイルに書くと良い))
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
'hosts': [os.getenv('REDIS_URL')], # import os を忘れないように
},
},
}
# 環境変数を使わないバージョン,上のCHANNEL_LAYERSとどちらかを使用
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
'hosts': [YourRedis_urlName],
},
},
}
# ローカル環境バージョンも掲載,今回はheroku Redisを使っているので不要ではある.
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
"hosts": [('127.0.0.1', 6379)],
},
},
}
asgi.pyの設定
今回はWSGIではなくASGIを使うので設定が必要である.DjangoChannelsはルーティングを管理するために専用のファイルを必要とするため,urls.py
やviews.py
ではなくrouting.py
やconsumers.py
を作成する必要がある.(後述)
# asgi.py
import os
from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
from yourappname.routing import websocket_urlpatterns
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'yourprojectname.settings')
application = ProtocolTypeRouter({
'http': get_asgi_application(),
'websocket': AuthMiddlewareStack(
URLRouter(
websocket_urlpatterns
)
),
})
consumers.pyの作成
WebSocketのコネクションを処理するためのConsumerクラスを定義する.
import json
from channels.generic.websocket import AsyncWebsocketConsumer
class CameraConsumer(AsyncWebsocketConsumer):
# WebSocket接続が確立されたときに呼び出される
async def connect(self):
# 特定のグループ"Camera_group"に接続し,その後,クライアントの接続を受け入れる
await self.channel_layer.group_add("Camera_group", self.channel_name)
await self.accept()
#クライアント(Unity)がWebSocket接続を切断したときに呼び出される
async def disconnect(self, close_code):
# クライアントをグループから削除
await self.channel_layer.group_discard("Camera_group", self.channel_name)
# クライアント(Unity)からメッセージを受信するたびに呼び出される
async def receive(self, text_data):
# 空のメッセージが送信された場合は,処理を中断
if not text_data.strip():
return
# 受信したデータはJSON形式として解析され,座標データ(x, y, z)を抽出
try:
data = json.loads(text_data)
except json.JSONDecodeError as e:
return
x = data.get('x')
y = data.get('y')
z = data.get('z')
# 返答データの準備
response_data = {
'x': x,
'y': y,
'z': z,
}
# 抽出したデータをクライアントに返送
# json.dumps()を使ってPython オブジェクトを JSON 形式の文字列に変換する
await self.send(text_data=json.dumps(response_data))
# グループ内の他のクライアントにも同じデータをブロードキャスト(JSONの中にJSONがある感じになる)
await self.channel_layer.group_send(
"Camera_group",
{
'type': 'camera_data',
'message': response_data
}
)
#グループ内の他のクライアントからメッセージを受信したときに呼び出されるハンドラー
async def camera_message(self, event):
message = event['message']
await self.send(text_data=json.dumps(message))
routing.pyの作成
WebSocketのURLを扱うために,専用のルーティングファイルをアプリケーションディレクトリyourappname
に作成する.Websocketにおけるコントローラーに当たる部分である.
from django.urls import re_path
from . import consumers
websocket_urlpatterns = [
re_path(r'ws/camera/$', consumers.CameraConsumer.as_asgi()),
]
ここまでの設定をDBに適用するために仮想環境を立ち上げるかDockerコンテナ上で以下のコマンドを実行すること.
python manage.py makemigrations
python manage.py migrate
APIをPaaSにデプロイする
以下のファイルをプロジェクトのルートディレクトリに作成する.
Procfile
web: daphne yourprojectname.asgi:application --port $PORT --bind 0.0.0.0 -v2
runtime.txt
使用するpythonのバージョンに合わせること.
python-3.10.4
.env
ファイル(環境変数)を使用したならばheroku上での設定が必要である.以下の記事を参考にすること.
https://qiita.com/tarakokko3233/items/bb63dbef9d91f44d3917
これをgitリポジトリにプッシュして適切なブランチを選択し,Heroku Dashboardでデプロイする.
デプロイしたAPIのURLはメモしておくと良い.
https://yourhostname.herokuapp.com
ならばWebsocket通信するためには
wss://yourhostname.herokuapp.com/ws/cameraとなる
Unity側での送信設定
Unity側のディレクトリのAndroidManifest.xml
に以下の内容を追記する.
<uses-permission android:name="android.permission.INTERNET" />
シーン上にカメラを設置して,カメラを任意の方法で動かすスクリプトを書く.
カメラを動かすスクリプトについては省略する.
作成したAPIにアクセスして,座標を送るためのC#コードは以下の通りである.
using UnityEngine;
using WebSocketSharp;
using System.Collections;
public class CameraPositionSender : MonoBehaviour
{
public string websocketUrl = "wss://yourhostname.herokuapp.com/ws/camera"; // WebSocketサーバーのURL
private WebSocket ws;
private void Start()
{
// WebSocketの初期化と接続
ws = new WebSocket(websocketUrl);
ws.OnOpen += (sender, e) => Debug.Log("WebSocket Open");
ws.OnMessage += (sender, e) => Debug.Log("WebSocket Message: " + e.Data);
ws.OnError += (sender, e) => Debug.Log("WebSocket Error: " + e.Message);
ws.OnClose += (sender, e) => Debug.Log("WebSocket Close");
ws.OnClose += (sender, e) => Debug.LogWarning($"WebSocket Close: {e.Code}, {e.Reason}");
ws.Connect();
// カメラの座標を定期的に送信するコルーチンを開始
StartCoroutine(SendCameraPosition());
}
private IEnumerator SendCameraPosition()
{
while (true)
{
if (ws.ReadyState == WebSocketState.Open)
{
// カメラの座標を取得
Vector3 cameraPosition = transform.position;
// 座標をJSON形式に変換
string json = JsonUtility.ToJson(new CameraPosition(cameraPosition.x, cameraPosition.y, cameraPosition.z));
// WebSocketで送信
ws.Send(json);
}
// 0.1秒ごとに更新
yield return new WaitForSeconds(0.1f);
}
}
private void OnDestroy()
{
// WebSocketを閉じる
if (ws != null)
{
ws.Close();
ws = null;
}
}
// カメラの座標データを格納するクラス
[System.Serializable]
public class CameraPosition
{
public float x;
public float y;
public float z;
public CameraPosition(float x, float y, float z)
{
this.x = x;
this.y = y;
this.z = z;
}
}
}
この作成したスクリプトを座標を送信したいカメラにAdd Component
すればOK.
Android側での受信設定
Unity側のディレクトリのAndroidManifest.xml
に以下の内容を追記する.
<uses-permission android:name="android.permission.INTERNET" />
Android側の処理はJavaで書く.
WebsocketClient.javaの作成
WebsocketClient.javaではWebSocket 接続と通信を処理するコードを書く.
package com.example.yourAndroidappname;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.WebSocket;
import okhttp3.WebSocketListener;
import okio.ByteString;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.widget.TextView;
import org.json.JSONException;
import org.json.JSONObject;
/**
* WebSocketClient は WebSocket 接続と通信を処理するクラス
*/
public class WebSocketClient extends WebSocketListener {
private static final String TAG = "WebSocketClient";
// メインスレッドにタスクを投稿するためのハンドラ
private Handler mainHandler = new Handler(Looper.getMainLooper());
// WebSocket インスタンス
private WebSocket webSocket;
// UI更新用のカスタムビューインスタンス
private CustomCircleView customCircleView;
/**
* コンストラクタ
* @param customCircleView カスタムされた円を表示するビュー
*/
public WebSocketClient(CustomCircleView customCircleView) {
this.customCircleView = customCircleView;
}
/**
* WebSocket 接続を開始する
*/
public void start() {
OkHttpClient client = new OkHttpClient();
Request request = new Request.Builder().url("wss://yourhostname.herokuapp.com/ws/camera").build();
webSocket = client.newWebSocket(request, this);
client.dispatcher().executorService().shutdown();
}
/**
* WebSocket 接続が開かれた時に呼ばれる
* @param webSocket WebSocket インスタンス
* @param response 応答情報
*/
@Override
public void onOpen(WebSocket webSocket, okhttp3.Response response) {
Log.d(TAG, "WebSocket 接続が開かれました");
}
/**
* メッセージを受信した時に呼ばれる
* @param webSocket WebSocket インスタンス
* @param text 受信したテキストメッセージ
*/
@Override
public void onMessage(WebSocket webSocket, String text) {
Log.d(TAG, "メッセージを受信しました: " + text);
// JSON をパースして内容を確認
try {
JSONObject json = new JSONObject(text);
// x, y, z 位置を取得し画面に表示
double x = json.getDouble("x");
double y = json.getDouble("y");
double z = json.getDouble("z");
mainHandler.post(() -> {
customCircleView.setCirclePosition(x, y, z);
});
} catch (JSONException e) {
Log.e(TAG, "JSON パースエラー: " + e.getMessage());
}
}
/**
* バイナリメッセージを受信した時に呼ばれる
* @param webSocket WebSocket インスタンス
* @param bytes 受信したバイトデータ
*/
@Override
public void onMessage(WebSocket webSocket, ByteString bytes) {
Log.d(TAG, "バイトメッセージを受信しました: " + bytes.hex());
}
/**
* WebSocket 接続が閉じる時に呼ばれる
* @param webSocket WebSocket インスタンス
* @param code 終了コード
* @param reason 終了理由
*/
@Override
public void onClosing(WebSocket webSocket, int code, String reason) {
webSocket.close(1000, null);
Log.d(TAG, "WebSocket 接続を閉じます: " + code + " / " + reason);
}
/**
* WebSocket 接続失敗時に呼ばれる
* @param webSocket WebSocket インスタンス
* @param t 例外/エラー
* @param response 応答情報
*/
@Override
public void onFailure(WebSocket webSocket, Throwable t, okhttp3.Response response) {
Log.e(TAG, "WebSocket 接続に失敗しました: " + t.getMessage());
}
}
activity_main.xml
とMainActivity.java
にWebsocket通信の受信側のコードを書く
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#E6F9FE"
tools:context=".MainActivity">
<com.example.yourAndroidappname.CustomCircleView
android:id="@+id/customCircleView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
package com.example.yourAndroidappname;
import android.os.Bundle;
import android.widget.TextView;
import androidx.appcompat.app.AppCompatActivity;
/**
* MainActivity は WebSocket 通信を使ってサーバーとリアルタイム通信を行う
*/
public class MainActivity extends AppCompatActivity {
private WebSocketClient webSocketClient;
private CustomCircleView customCircleView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_game);
customCircleView = findViewById(R.id.customCircleView);
// WebSocketClient インスタンスを生成
webSocketClient = new WebSocketClient(customCircleView);
// WebSocket 通信の開始
webSocketClient.start();
}
@Override
protected void onDestroy() {
super.onDestroy();
// WebSocket 接続を閉じる
webSocketClient.close();
}
}
これでAndroidで実行してかつUnityで該当のシーンを実行したらカメラが動いたらAndroidのメイン画面でカメラの座標が表示される.
これで完成.