LoginSignup
4
6

DjangoChannelsを用いたWebsocket非同期通信(UnityとAndroidでの同時通信,APIをPaaSにデプロイするまで)

Last updated at Posted at 2024-05-20

この記事ではDjangoChannelsを用いた非同期通信を実現するための方法について記載する.

非同期通信とは?

プロセスやプログラムがタスクを実行する際に,その完了を待たずに次の操作に移ることができる通信方式のこと.この通信方式では,リクエストを送信した後,レスポンスが返ってくるまでの間にも他の作業を同時並行で進めることが可能である.

非同期通信が実装できると以下のようなプロダクトが開発できる.
1.リアルタイムチャットアプリ
2.ライブアップデートを提供するダッシュボード
3.非同期処理を必要とする幅広いWebサービス
4.リアルタイムゲーム

開発概要

スクリーンショット 2024-05-19 18.10.21.png

今回は簡単にこのようにUnity(VRなど)上でカメラの位置を送信し,Androidでそのカメラの位置(赤円)を受信することをリアルタイムで動作することを目的にする.

スクリーンショット 2024-05-19 18.25.21.png

マップまでは今回は実装しません
私用で開発したものを転用しています.

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にアクセスし,アプリケーションを作成し,アプリケーション内のResourcesHeroku Data for Redisアドオンを取り込む.(プランは任意で)
スクリーンショット 2024-05-20 14.19.43.png

Heroku Data for RedisSettingsのページを開き,ViewCredentials...を押すとURIの欄が出てくる.これがRedisサーバーに接続するための情報である.

スクリーンショット 2024-05-20 14.37.05.png

環境変数として値を格納した方がいいのでプロジェクトのルートディレクトリにアクセスし,.envファイルを作成し,以下の内容にする.他にもSECRET_URLやDB接続情報を書くと良い.

.env
REDIS_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を追加する(既存のものも含まれている)

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 に以下を追記する.

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.pyviews.pyではなくrouting.pyconsumers.pyを作成する必要がある.(後述)

asgi.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クラスを定義する.

yourappname/consumers.py
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におけるコントローラーに当たる部分である.

yourappname/routing.py
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のバージョンに合わせること.

runtime.txt
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に以下の内容を追記する.

AndroidManifest.xml
<uses-permission android:name="android.permission.INTERNET" />

シーン上にカメラを設置して,カメラを任意の方法で動かすスクリプトを書く.

カメラを動かすスクリプトについては省略する.

作成したAPIにアクセスして,座標を送るためのC#コードは以下の通りである.

CameraPositionSender.cs
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に以下の内容を追記する.

AndroidManifest.xml
<uses-permission android:name="android.permission.INTERNET" />

Android側の処理はJavaで書く.

WebsocketClient.javaの作成

WebsocketClient.javaではWebSocket 接続と通信を処理するコードを書く.

WebsocketClient.java
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.xmlMainActivity.javaにWebsocket通信の受信側のコードを書く

activity_main.xml
<?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>
MainActivity.java
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のメイン画面でカメラの座標が表示される.
これで完成.

4
6
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
4
6