みなさんUnity(Metaquest)とAndroid間といった2つのプラットフォーム間の通信を必要とした場面はありませんか?また,それぞれが同じUUID(固有の識別子)を持っている同士で個別の非同期通信を実現する場面はありませんか?
本記事では, GoとWebSocketを使用してUnityクライアントとAndroidクライアント間でリアルタイムな通信を実現するシステムの実装について詳しく解説します.
今回はサーバーの実装のみ記述します.クライアント側の実装はないので注意してください
goの文法基本はここから
システム概要
機能要件
- システム全体としてQuizとActionと言った永続的に格納しておくデータが存在する.
- それぞれのQuizとActionはidと難易度(difficulty)と2択の選択肢(rig_sel,lef_sel)が存在する.Quizに関してはクイズ名としてnameが存在する.
- Android端末から難易度(QuizとAction共通の難易度)を送信する.
- 難易度に応じたクイズとアクションデータをUnityに送信する.
- WebSocketを使用してリアルタイムな双方向通信を実現する.
- UUIDベースの接続管理により, 特定のAndroidとUnityのペアリングを実現する.
- Unity側でUUIDをパスパラメータに持たせてWebsocket通信を確立した場合に限り,Android側で同じUUIDをパスパラメータに持たせてWebSocket通信を確立させられるようにする.(簡単にいうとUnity側から先にUUIDをもとに非同期通信を始めていないとAndroid側から同じUUIDで非同期通信を始められないようにしたい)
簡単に言うと
- Android側からクイズの難易度を送ったらUnity側でクイズの難易度に応じたidを受け取るような非同期通信のシステムを作りたい
- ユーザごとにAndroid側からUnity側に確実にデータを送れるようにUUIDを使った接続管理やUnity側で先に接続している状態でのみAndroid側で接続できるようなシステムにしたい
具体的には以下のURLと送受信するメッセージで接続できるようにしたい
Android側からのリクエスト
URL
ws://localhost:8080/ws/difficulty/android/{uuid}
例
ws://localhost:8080/ws/difficulty/android/de358130-e94a-5692-8b09-d27007ad1944
送信するメッセージ
{
"difficulty": 1
}
Unityでのレスポンス
URL
ws://localhost:8080/ws/difficulty/unity/{uuid}
例
ws://localhost:8080/ws/difficulty/unity/de358130-e94a-5692-8b09-d27007ad1944
受信するメッセージ
{
"quiz_id":[1,2,3],
"action_id":1
}
こんな感じでAmdroid,unity間で同じUUID同士でWebsocket通信ができるようにする.
技術スタック
- サーバーサイド: Go
- データベース: GORM (O/Rマッパー)(DBMS:PostgreSQL)
- WebSocket: gorilla/websocket
- ルーティング: gorilla/mux
バックエンドは少し複雑なのでDDDとクリーンアーキテクチャで実装しています.
今回使用したレイヤー構造
わからなかったらスルーしていただいて結構です.
1. Domain
-
model
- 基本的なビジネスエンティティの定義(今回使用するデータ構造体)を作成する
-
repository
- データアクセスの抽象化インターフェースを定義
2. UseCase
- アプリケーションのビジネスロジック
- ドメインレイヤーのインターフェースを使用
3. Interface
-
handler
- WebSocket通信を処理するハンドラー(クライアント側に送信するデータの定義)を実装
4. Infrastructure
-
persistence
- データアクセスオブジェクト(DAO)の具体的な実装(データベースアクセスの具体的な実装)
今回は以下のようなディレクトリ構造にしています.
.
├── Dockerfile
├── docker-compose.yml
├── go.mod
├── go.sum
├── main.go
├── db
│ └── migrations
│ ├── 000001_create_quiz_table.down.sql
│ ├── 000001_create_quiz_table.up.sql
│ ├── 000002_create_action_table.down.sql
│ └── 000002_create_action_table.up.sql
├── domain
│ ├── models
│ │ ├── action.go
│ │ ├── message_difficulty.go
│ │ └── quiz.go
│ └── repositories
│ ├── action_repository.go
│ └──quiz_repositpry.go
├── infrastructure
│ └── persistence
│ ├── action_persistence.go
│ └──quiz_persistence.go
├── interfaces
│ └── handlers
│ └── difficulty_handler.go
└── usecase
└── difficulty_usecase.go
開発の見通し
- Dockerを用いた環境構築
- テーブルの作成をするSQLを定義
- データを扱う構造体を定義するためのドメインモデルを作成
- データアクセスの抽象化を行うためのインターフェースを定義するリポジトリを作成
- データベースにデータをアクセスするための具体的な実装を定義するインフラストラクチャを作成
- 5で定義したデータアクセス部を使用して実際に使用するビジネスロジックを定義するユースケースを作成
- 6で定義したユースケースをクライアント側とやり取りするためのデータ形式を定義するハンドラーを作成
- 今まで作成した関数を初期化したり,db接続を初期化したり,環境変数からデータを取得したり,ルーティングを定義するmain.goの作成
docker系
今回はDocker系のファイルは詳しく説明しない.
今回のシステムはAir,Ginを使用する.
1. Air
Airは,Goの開発環境でのホットリロードツール.ソースコードに変更を加えると,自動的にアプリケーションを再起動して変更を反映してくれる.これにより,開発中に毎回手動で再起動する手間が省け,効率的に開発を進めることができる.
2. Gin
Ginは、Go言語用の軽量で高性能なWebフレームワーク.
Dockerfile
FROM golang:1.23.1
RUN go install github.com/air-verse/air@latest
docker-compose.yml
version: "3.8"
services:
go:
container_name: app
volumes:
- ./:/project/
working_dir: /project
tty: true
build: "./"
ports:
- 8080:8080
command: sh -c 'go mod tidy && air'
テーブルの作成
まず,今回データベースに作成するテーブルをSQLで記載する.
quizzesテーブルの作成
CREATE TABLE quizzes (
id SERIAL PRIMARY KEY,
name VARCHAR(255),
difficulty INTEGER,
lef_sel VARCHAR(255),
rig_sel VARCHAR(255),
detail VARCHAR(255)
);
actionsテーブル
CREATE TABLE actions (
id SERIAL PRIMARY KEY,
difficulty INTEGER,
lef_sel VARCHAR(255),
rig_sel VARCHAR(255),
detail VARCHAR(255)
);
これを直接DBにアクセスしてテーブル作成しても良いし,マイグレーションをするパッケージを使用しても良い.
マイグレーションをするパッケージを使用するならば,DROP TABLEするマイグレーションファイル(000001_create_quiz_table.down.sql
や000002_create_action_table.down.sql
)も必要である.
ドメインモデルの設計
Quizモデルはゲーム内のクイズデータを表現する.
package models
type Quiz struct {
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name"`
Difficulty int `json:"difficulty"`
LefSel string `json:"lef_sel"`
RigSel string `json:"rig_sel"`
Detail string `json:"detail"`
}
イメージとして以下のような属性を持っている.
ID: PrimaryKey(主キー)
Name: クイズ名
Difficulty: 難易度
LefSel: 左選択肢
RigSel: 右選択肢
Detail: クイズの詳細
同様にActionモデルを作成する.
package models
type Action struct {
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
Difficulty int `json:"difficulty"`
LefSel string `json:"lef_sel"`
RigSel string `json:"rig_sel"`
Detail string `json:"detail"`
}
クライアントと通信するためのメッセージの構造体を定義する.
package models
type AndroidDifficultyMessage struct {
Difficulty int `json:"difficulty"`
}
type UnityDifficultyMessage struct {
QuizIDs []int `json:"quiz_id"`
ActionID int `json:"action_id"`
}
リポジトリの実装
データアクセスの抽象化を行うインターフェースを定義する.
まずQuizデータにアクセスするためのインターフェースを定義する.
package repositories
import "your-module/domain/models"
type QuizRepository interface {
FindByDifficulty(difficulty int, limit int) ([]models.Quiz, error)
}
同様にActionデータにアクセスするためのインターフェースを定義する.
package repositories
import "your-module/domain/models"
type ActionRepository interface {
FindOneByDifficulty(difficulty int) (*models.Action, error)
}
インフラストラクチャ部の作成
データベースにQuizデータにアクセスするための具体的な実装
package persistence
import (
"your-module/domain/models"
"your-module/domain/repositories"
"gorm.io/gorm"
)
type quizRepository struct {
db *gorm.DB
}
func NewQuizRepository(db *gorm.DB) repositories.QuizRepository {
return &quizRepository{db: db}
}
// 実際にDifficultyをもとにquizIDをとってくる関数
func (r *quizRepository) FindByDifficulty(difficulty int, limit int) ([]models.Quiz, error) {
var quizzes []models.Quiz
// ORDER BY RAND()を追加してランダムに取得
err := r.db.Where("difficulty = ?", difficulty).Order("RAND()").Limit(limit).Find(&quizzes).Error
return quizzes, err
}
同様にActionデータにアクセスするための具体的な実装
package persistence
import (
"your-module/domain/models"
"your-module/domain/repositories"
"gorm.io/gorm"
)
type actionRepository struct {
db *gorm.DB
}
func NewActionRepository(db *gorm.DB) repositories.ActionRepository {
return &actionRepository{db: db}
}
// 実際にDifficultyをもとにactionIDをとってくる関数
func (r *actionRepository) FindOneByDifficulty(difficulty int) (*models.Action, error) {
var action models.Action
// ORDER BY RAND()を追加してランダムに取得
err := r.db.Where("difficulty = ?", difficulty).Order("RAND()").First(&action).Error
return &action, err
}
今回はデータ量が少ないのでORDER BY RAND()を使っているが,これはDBヘの負担が多い操作なので注意.
データ量が多い場合は以下のようなクエリを実行するようなgoの実装があるといいと思う.
-- 1. まず難易度に一致するIDの範囲を取得
WITH id_range AS (
SELECT MIN(id) as min_id, MAX(id) as max_id
FROM quizzes
WHERE difficulty = :difficulty
)
-- 2. その範囲内でランダムにレコードを取得
SELECT *
FROM quizzes
WHERE difficulty = :difficulty
AND id IN (
-- ここでは例として3件取得する場合
SELECT FLOOR(
RAND() * ((SELECT max_id FROM id_range) - (SELECT min_id FROM id_range) + 1)
) + (SELECT min_id FROM id_range)
FROM (
SELECT 1 UNION SELECT 2 UNION SELECT 3
) t
)
ユースケース部の作成
QuizとActionの両方のデータを取得し、QuizID3つとActionIdを返す構造体をmodelsをで定義した構造体を基に作成するビジネスロジックを実装
package usecase
import (
"HOPcardAPI/domain/models"
"HOPcardAPI/domain/repositories"
)
type DifficultyUsecase struct {
quizRepo repositories.QuizRepository
actionRepo repositories.ActionRepository
}
func NewDifficultyUsecase(qr repositories.QuizRepository, ar repositories.ActionRepository) *DifficultyUsecase {
return &DifficultyUsecase{
quizRepo: qr,
actionRepo: ar,
}
}
func (u *DifficultyUsecase) ProcessDifficultyData(difficulty int) (*models.UnityDifficultyMessage, error) {
// difficultyに応じたクイズを3つ取得
quizzes, err := u.quizRepo.FindByDifficulty(difficulty, 3)
if err != nil {
return nil, err
}
// difficultyに応じたアクションを取得
action, err := u.actionRepo.FindOneByDifficulty(difficulty)
if err != nil {
return nil, err
}
// 3つの要素を持つquizIDsを作成
quizIDs := make([]int, len(quizzes))
for i, quiz := range quizzes {
quizIDs[i] = quiz.ID
}
// Unityに向けたレスポンスであるUnityDifficultyMessageを作成
return &models.UnityDifficultyMessage{
QuizIDs: quizIDs,
ActionID: action.ID,
}, nil
}
ハンドラーの作成
実際にクライアントとデータのやり取りをするデータの形式の定義やWebSocket通信の管理をする.
WebSocketについてはこちらでもPythonバージョンで解説している.
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のように通信を開始するためにコネクションを確立する必要がない.
package handlers
import (
"your-module/domain/models" // ドメインモデルをインポート(メッセージタイプなどを含む)
"your-module/usecase" // ビジネスロジックを処理するユースケース層をインポート
"github.com/gorilla/mux" // HTTPリクエストのルーティング用にmuxをインポート
"github.com/gorilla/websocket" // WebSocket接続を処理するためのwebsocketパッケージをインポート
"net/http"
"sync"
)
// DifficultyWebSocketHandlerは、AndroidおよびUnityクライアント間のWebSocket通信を処理するための
// WebSocket接続を保持し、関連するメソッドを提供する構造体です
type DifficultyWebSocketHandler struct {
upgrader websocket.Upgrader // HTTP接続をWebSocket接続にアップグレードするためのアップグレーダ.上記の補足に書いてある通り、HTTPからWebSocketにアップグレードする通信が必要
difficultyUsecase *usecase.DifficultyUsecase // 難易度データのビジネスロジックを処理するユースケース
androidConns map[string]*websocket.Conn // Androidクライアントの接続を保持するマップ
unityConns map[string]*websocket.Conn // Unityクライアントの接続を保持するマップ
mutex sync.RWMutex // 並行処理で接続マップを保護するための排他制御
}
// NewDifficultyWebSocketHandlerは、DifficultyWebSocketHandlerの新しいインスタンスを作成します
func NewDifficultyWebSocketHandler(difficultyUsecase *usecase.DifficultyUsecase) *DifficultyWebSocketHandler {
return &DifficultyWebSocketHandler{
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // クロスオリジンのリクエストも許可(本番環境では非推奨)
},
},
difficultyUsecase: difficultyUsecase,
androidConns: make(map[string]*websocket.Conn),
unityConns: make(map[string]*websocket.Conn),
}
}
// HandleAndroidWebSocketは、Android側のWebSocket接続を処理します
func (h *DifficultyWebSocketHandler) HandleAndroidWebSocket(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
uuid := vars["uuid"]
if uuid == "" {
http.Error(w, "UUIDが必要です", http.StatusBadRequest)
return
}
// Unity側の接続が存在するか確認
h.mutex.RLock() // 排他制御を用いる(参照ロック)
_, unityExists := h.unityConns[uuid] // unity側で接続済みのuuidを取得
h.mutex.RUnlock() // 排他制御解除(参照ロック解除)
if !unityExists {
http.Error(w, "一致するUnity接続がありません", http.StatusBadRequest)
return
}
// WebSocket接続へのアップグレードを試行(上の補足を参照)
conn, err := h.upgrader.Upgrade(w, r, nil)
if err != nil {
http.Error(w, "接続をアップグレードできませんでした", http.StatusInternalServerError)
return
}
// Android側の接続をマップに保存
h.mutex.Lock()
h.androidConns[uuid] = conn
h.mutex.Unlock()
// 関数終了時に接続を削除およびクローズ.Goのdeferは最後に実行する関数を表す文法
defer func() {
h.mutex.Lock()
delete(h.androidConns, uuid)
h.mutex.Unlock()
conn.Close()
}()
// メッセージの受信と処理ループ
for {
var androidMsg models.AndroidDifficultyMessage
err := conn.ReadJSON(&androidMsg) // Android側からのメッセージをJSONで受信
if err != nil {
break
}
// ユースケース層で難易度データを処理.difficultyに応じたクイズを3つ,アクションを1つ取得
unityMsg, err := h.difficultyUsecase.ProcessDifficultyData(androidMsg.Difficulty)
if err != nil {
continue
}
// Unity側の接続にメッセージを送信
h.mutex.RLock()
if unityConn, exists := h.unityConns[uuid]; exists {
err = unityConn.WriteJSON(unityMsg)
}
h.mutex.RUnlock()
// Android側には受信確認メッセージを送信(デバック用)
confirmMsg := map[string]string{"status": "received"}
err = conn.WriteJSON(confirmMsg)
if err != nil {
continue
}
}
}
// HandleUnityWebSocketは、Unity側のWebSocket接続を処理します
func (h *DifficultyWebSocketHandler) HandleUnityWebSocket(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
uuid := vars["uuid"]
if uuid == "" {
http.Error(w, "UUIDが必要です", http.StatusBadRequest)
return
}
// WebSocket接続へのアップグレードを試行.上記の補足を参照
conn, err := h.upgrader.Upgrade(w, r, nil)
if err != nil {
http.Error(w, "接続をアップグレードできませんでした", http.StatusInternalServerError)
return
}
// Unity側の接続をマップに保存
h.mutex.Lock()
h.unityConns[uuid] = conn
h.mutex.Unlock()
// 関数終了時に接続を削除およびクローズ
defer func() {
h.mutex.Lock()
delete(h.unityConns, uuid)
h.mutex.Unlock()
conn.Close()
}()
// メッセージの受信ループ(内容は処理せずに接続維持)
for {
_, _, err := conn.ReadMessage()
if err != nil {
break
}
}
}
main.goの実装
infrastructure,usecase,handlerを初期化したり,db接続を初期化したり,環境変数からデータを取得したり,ルーティングを定義する.
package main
import (
"your-module/domain/services"
"your-module/infrastructure/persistence"
"your-module/interfaces/handlers"
"your-module/usecase"
"fmt"
"github.com/gorilla/mux"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"log"
"net/http"
"os"
)
func main() {
// データベース接続の初期化
db, err := initDB()
if err != nil {
log.Fatal(err)
}
//quiz,action系の初期化
quizRepo := persistence.NewQuizRepository(db)
actionRepo := persistence.NewActionRepository(db)
//difficulty系の初期化
difficultyUsecase := usecase.NewDifficultyUsecase(quizRepo, actionRepo)
difficultyHandler := handlers.NewDifficultyWebSocketHandler(difficultyUsecase)
// ルーティング
r := mux.NewRouter()
r.HandleFunc("/ws/difficulty/android/{uuid}", difficultyHandler.HandleAndroidWebSocket)
r.HandleFunc("/ws/difficulty/unity/{uuid}", difficultyHandler.HandleUnityWebSocket)
log.Fatal(http.ListenAndServe(":8080", r))
}
// initDBは別ファイルの方がいいのかな\(´ω` \)
func initDB() (*gorm.DB, error) {
// .envファイルの読み込み(.envファイルにはDATABASE_URLのみ記載していることを想定)
if err := godotenv.Load(); err != nil {
log.Printf("Warning: .env file not found")
}
// 環境変数から接続情報を取得
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
return nil, fmt.Errorf("DATABASE_URL is not set")
}
log.Printf("Connecting to database with URL: %s", dbURL)
// データベースに接続
db, err := gorm.Open(postgres.Open(dbURL), &gorm.Config{})
if err != nil {
return nil, fmt.Errorf("failed to connect database: %w", err)
}
log.Printf("Connected to database")
return db, nil
}
これで以下のコマンドを実行すればサーバーを立ち上げられ,リアルタイム通信が可能になる.
docker compose up
今回作成した同じUUIDを持った異なるプラットフォーム(デバイス)同士のリアルタイム通信システムはさまざまな用途があると思う.例えばダイレクトメッセージのロジック作成や,マッチング機能など使い道がたくさんある.つまり,複数のプラットフォームでペアリングした通信を実装する際に有用である.