🚀

Unity + MagicOnion + ASP.NET Core Web API でリアルタイム通信とAPI通信を両立する方法

に公開

はじめに

GoogleCloudを使ってネット対戦ができるゲームを作ろうと思っているので、下準備としてUnity + MagicOnion + ASP.NET Core Web API(以下、Web API)の構成のプロジェクトを作りました。
そんなわけで上記構成の構築手順を解説します。

今回紹介する構成にすることで、ネット対戦中の処理はMagicOnionを利用したリアルタイム通信、それ以外のログイン等の処理はWeb APIによるAPI通信、といった使い分けができるようになります。

なぜMagicOnionオンリーではなく、WebAPIも併用する構成にしたのか

MagicOnionはAPIっぽい使い方はできるので、実はMagicOnion単体でも機能自体は問題なく実装可能です。
ですが以下の理由からWeb APIを組み合わせる構成を選びました。

  • MagicOnionはUnityとサーバー間のコネクションを維持するステートフルである都合上、スケールしにくい
  • 将来的にAPIの処理をCloud Runに移行することも考えているので、リアルタイム通信とAPI通信が分かれていた方が都合がよい

MagicOnionの導入

基本的には公式ドキュメントに記載されている手順通りです。

https://cysharp.github.io/MagicOnion/ja/quickstart-unity

テンプレートを使うのが一番手っ取り早いです。
とはいえ既存プロジェクトにMagicOnionを導入したいという方もいるでしょう。
その場合もドキュメント通りに進めていけば大体問題ないのですが、いくつかつまづきポイントがあるので補足します。

GrpcChannelではなくGrpcChannelx

ドキュメントにはチャンネル作成時に以下のような処理を行うよう書いてあります。

var channel = GrpcChannelx.ForAddress("http://localhost:5210");

クラス名に注目してください。
GrpcChannelではなくGrpcChannelxです。後ろにxがついているのが正しいです。
前者はGrpc.Net.Client.GrpcChannelで後者はMagicOnion.GrpcChannelxです。
エディタのコード入力補完任せだとついうっかりGrpcChannelにしてしまいがちなので注意しましょう。

Kestrelの設定を行う

MagicOnionはHttp2で通信を行います。
必ず通信プロトコルはHttp2を使うように設定をしましょう。

具体的にはappsettings.jsonに以下の設定を追加します。
ここの設定は公式のテンプレートの設定を参考にしています。

"Kestrel": {
    "EndpointDefaults": {
        "Protocols": "Http2"
    }
}

ちなみにKestrelとはASP.NET Coreが標準で用意してあるWebサーバーです。

WebAPIの追加と統合

サーバー側の修正

ここでは以下のようなフォルダ構成になっているものとします。(修正対象のもののみ記載)

Server/
├── Controllers/
│   └── SampleApiController.cs
├── appsettings.json
├── Program.cs
Shared/
└── Models/
    └── Api/
        ├── SampleApiRequest.cs
        └── SampleApiResponse.cs

新規ファイル

新規追加するファイルは以下のようにしてください。

Shared/Models/Api/SampleApiRequest.cs
[Serializable]
public class SampleApiRequest
{
    public string Message;
}
Shared/Models/Api/SampleApiResponse.cs
[Serializable]
public class SampleApiResponse
{
    public string ReceiveMessage;
    public string ServerTimeStamp;
}
Controllers/SampleApiController.cs
[ApiController]
[Route("api/[controller]")]
public class SampleApiController : ControllerBase
{
    [HttpPost("echo")]
    public ActionResult<SampleApiResponse> Echo([FromBody] SampleApiRequest request)
    {
        var response = new SampleApiResponse
        {
            ReceiveMessage = request.Message + " received!!",
            ServerTimeStamp = DateTime.UtcNow.ToString("o"),
        };

        return response;
    }
}

既存ファイル

appsettings.jsonKestrelの設定を以下のように修正してください。
ちなみにEndpointsにあるMagicOnionWebApiは任意の名前でかまいません。

"Kestrel": {
    "Endpoints": {
        "MagicOnion": {
            "Url": "http://0.0.0.0:5000",
            "Protocols": "Http2"
        },
        "WebApi": {
            "Url": "http://0.0.0.0:5001",
            "Protocols": "Http1"
        }
    }
}

Program.csは以下の処理を入れてください。

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers().AddJsonOptions(options =>
{
    options.JsonSerializerOptions.IncludeFields = true;
    options.JsonSerializerOptions.PropertyNamingPolicy = null;
});

builder.Services.AddMagicOnion();

var app = builder.Build();

app.UseRouting();
app.MapControllers();
app.MapMagicOnionService();

app.Run();

Unity側の修正

ここでは以下のようなフォルダ構成になっているものとします。(修正対象のもののみ記載)

Assets/
├── Scripts/
│   ├── Shared/
│   │   └── Models/
│   │       └── Api/
│   │           ├── SampleApiRequest.cs
│   │           └── SampleApiResponse.cs
│   └── Network/
│       └── Api/
│           └── SampleApiService.cs

新規ファイル

新規追加するファイルは以下のようにしてください。

Scripts/Network/Api/SampleApiRequest.cs
public sealed class SampleApiService
{
    private static SampleApiService _instance;
    public static SampleApiService Instance => _instance ??= new SampleApiService();

    public async Task<SampleApiResponse> EchoAsync(string message)
    {
        var request = new SampleApiRequest { Message = message };
        var url = "http://localhost:5001/api/SampleApi/echo";

        var json = JsonUtility.ToJson(request);
        var webRequest = new UnityWebRequest(url, "POST");
        webRequest.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(json));
        webRequest.downloadHandler = new DownloadHandlerBuffer();
        webRequest.SetRequestHeader("Content-Type", "application/json");

        await webRequest.SendWebRequest();

        if (webRequest.result == UnityWebRequest.Result.Success)
        {
            var responseJson = webRequest.downloadHandler.text;
            return JsonUtility.FromJson<SampleApiResponse>(responseJson);
        }
        else
        {
            throw new Exception("API呼び出し失敗: " + webRequest.error);
        }
    }
}

動作確認

Unity側のMagicOnionの接続先をappsettings.jsonの変更に合わせて以下のように修正します。

GrpcChannelx.ForAddress("http://localhost:5000");

続いてUnity側で新規追加したSampleApiServiceを呼び出す処理を適当な場所に追加します。
適当なシーンのStartであったり、ボタンを押した時の挙動とかで大丈夫です。

var response = await SampleApiService.Instance.EchoAsync("テストメッセージ");
Debug.Log($"受信メッセージ: {response.ReceiveMessage}, サーバー時刻: {response.ServerTimeStamp}");

最後に上記処理を実行してみましょう。
MagicOnionによるリアルタイム通信、Web ApiによるAPI通信のいずれも正常に処理が行われていれば成功です!

MagicOnionとWebAPI共存におけるつまづきポイント

HTTP2とHTTP1の共存問題

MagicOnionとAPIを統合する際、Kestrelの設定がHttp1AndHttp2だとMagicOnion側が接続時にHTTP1を使ってしまい、接続エラーが発生することがあります。

この場合、この記事で紹介しているappsettings.jsonのように、MagicOnionはHttp2専用エンドポイント、WebAPIはHttp1専用と分けることで解決します。

"Kestrel": {
    "Endpoints": {
        "MagicOnion": {
            "Url": "http://0.0.0.0:5000",
            "Protocols": "Http2"
        },
        "WebApi": {
            "Url": "http://0.0.0.0:5001",
            "Protocols": "Http1"
        }
    }
}

Unityとサーバーの共通モデルの落とし穴

この記事ではSampleApiRequestSampleApiResponseのシリアライズ・デシリアライズをJsonUtilityを使っています。
JsonUtilitypublicなフィールドしか対象ではないのですが、なんとサーバー側はデフォルトではpublicなフィールドをJsonのシリアライズ・デシリアライズの対象外にしていることがあるようなのです。

さらにサーバー側から返ってくるJsonは、フィールド名の最初が小文字になってしまいます。
そのためSampleApiResponse.csでいくら大文字始まりのフィールド名を定義していても、サーバー側からは小文字始まりのフィールド名で返ってくるのでうまく変換してくれません。

JsonUtilityを使わない」「Jsonにするやつは命名規則を変える」等の対応でももちろん解決できます。
しかし、Program.csに以下の処理を追加することでも解決できます。

builder.Services.AddControllers().AddJsonOptions(options =>
{
    options.JsonSerializerOptions.IncludeFields = true;
    options.JsonSerializerOptions.PropertyNamingPolicy = null;
});

おわりに

「サクッと4~5時間くらいでできるやろ」と思ってたのですが、想像以上に苦戦して2~3日かかってしまいました。
せっかくなので苦労した箇所の説明をはさみつつ、今回紹介した構成の実現方法を解説する記事にしました。

基本的に環境構築系はChatGPTに相談しつつやっているのですが、全く見当違いの修正方針を出してくることも多々あったので苦戦しました。
やはりちょっと複雑な環境構築になってくると、AIを使う側の知識もまだまだ必要だなと実感しました。おかげさまで今回の対応でより知識が身に付きました。

Discussion