MagicOnionを使ってリアルタイム通信ゲームを作る -4. サーバー処理-

.Net CoreによるホスティングやLogicLooperによるサーバーメインループ処理

目次

概要


作ったゲームの技術解説記事4回目です。 今回は.Net Coreによるサーバーホスティングやサーバーでゲームループを どうやって組んだかなど、サーバー技術詳細を紹介していきます。

本業はUnityなどのクライアント寄りの仕事がメインなので、サーバー側に関しては全然自信がありませんでしたが、何とか形にはできました。 もちろん初めて独力でサーバー処理を組んだため、色々間違ってる部分もあるかもしれませんが優しい気持ちで読んでもらえると嬉しいです。

他の記事へのリンクやソースコードはこちらから。

  1. MagicOnionを使ってリアルタイム通信ゲームを作る -1. 概要-
  2. MagicOnionを使ってリアルタイム通信ゲームを作る -2. アーキテクチャ-
  3. MagicOnionを使ってリアルタイム通信ゲームを作る -3. クライアント処理-
  4. MagicOnionを使ってリアルタイム通信ゲームを作る -4. サーバー処理-
  5. Githubソースコード / LineDeleteGame

.Net Coreによるサーバーホスティング


.Net Coreとは

自分では全く触ったことがないですが、少し昔まではWindowsでサーバー処理というと、 Windowsサーバー を思い浮かべました。 また、Windows OS以外では動作できずかなり縛りがあるのかなとも思っていました。

ところが最近のMicrosoftは自社プロダクトを積極的にオープンソース化、クロスプラットフォーム化しています。 特に .Net Core はクロスプラットフォームを特徴としてMicrosoftが開発を主導している.NET実装の1つで、 これを使ってコンソールアプリはもちろん、GUIアプリケーション、サーバーアプリケーションまで開発できます。 更にこれらをMacやLinuxで動作させることも可能です。

MagicOnionが.Net Coreで動作するのを想定しているようだったので、その流れで.Net Coreを採用しています。 .Net Coreはサーバーとしてアプリケーションを動作させるための実装を公式処理がサポートしているため、ただ動かすだけなら少ないコード量でサーバー動作します。

コード概要

大体の処理は下記2ソースに集約されており、ほぼこれだけでNet Coreによるサーバーホスティングアプリケーションとして動作させることが可能です。 もちろんMagicOnionやLogicLooperなど依存関係のプロジェクト設定などは別途必要で、 且つゲーム用に作成したシングルトンクラスも使用しているので下記2つのソースコードだけではビルドできません。 それでも、当初想定していたよりもずっと簡潔にサーバープログラムが作れたことに少し驚きました。

例として、今回のサーバーソースコードから.Net Coreのエッセンスのみを抜粋します。

// ==============================================
// Program.cs
// ==============================================
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core; // Kestrel is a cross-platform web server for ASP.NET Core
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using System.IO;
using System.Reflection;

namespace App.Server
{
    class Program
    {
        public static void Main(string[] args)
        {
            IHostBuilder builder = createCustomHostBuilder(args);
            IHost host = builder.Build();
            host.Run();
        }

        private static IHostBuilder createCustomHostBuilder(string[] args)
        {
            // https://docs.microsoft.com/en-us/aspnet/core/fundamentals/host/generic-host?view=aspnetcore-3.1#default-builder-settings
            IHostBuilder defaultBuilder =
                Host.CreateDefaultBuilder(args)
                    .ConfigureAppConfiguration(
                        (hostingContext, config) =>
                        {
                            var env = hostingContext.HostingEnvironment;
                            var path = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
                            // json(plane) -> json(各環境) -> 環境変数 -> コマンドライン引数 (右に行くほど優先順位上)
                            config.SetBasePath(Directory.GetCurrentDirectory())
                                  .AddJsonFile($"appsettings.json", optional: false)
                                  .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true)
                                  .AddEnvironmentVariables()
                                  .AddCommandLine(args)
                                  .Build();
                        }
                );

            IHostBuilder webDefaultBuilder = defaultBuilder.ConfigureWebHostDefaults(
                webBuilder =>
                {
                    IWebHostBuilder bld = webBuilder.UseKestrel(
                        options =>
                        {   // WORKAROUND: Accept HTTP/2 only to allow insecure HTTP/2 connections during development.
                            options.ConfigureEndpointDefaults(
                                endpointOptions =>
                                {
                                    endpointOptions.Protocols = HttpProtocols.Http2;
                                }
                            );
                            //options.Listen(System.Net.IPAddress.Parse("xxx.xxx.xxx.xxx"), 12345);
                        });
                    bld.UseStartup<Startup>();
                });
            return webDefaultBuilder;
        }
    }
}

// ==============================================
// Startup.cs
// ==============================================
using App.Server.Looper;
using Cysharp.Threading;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System;
using App.Shared.Common;

namespace App.Server
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        // This method gets called by the runtime. Use this method to add services to the container.
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddGrpc(); // MagicOnion depends on ASP.NET Core gRPC service.
            services.AddMagicOnion();

            services.AddSingleton<ILogicLooperPool>(_ => new LogicLooperPool(SharedConstant.FPS * 2, Environment.ProcessorCount, RoundRobinLogicLooperPoolBalancer.Instance));
            services.AddSingleton<MatchingRoomManager>();
            services.AddHostedService<MainGameLoopHostedService>();
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseExceptionHandler("/Home/Error");
                // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
                app.UseHsts();
            }


            app.UseRouting();
            app.UseEndpoints(endpoints =>
            {
                endpoints.MapMagicOnionService();
            });
        }
    }
}

CreateHostBuilderのメソッドチェーンやラムダ関数でパラメータを設定して.Net Coreで動作想定のサーバーホストを作成しています。 こちらはIPアドレスの定義や環境設定ファイルの読み込みなどインフラ向けの設定が多い印象です。 一方、 Startup.cs で定義している ConfigureServices 関数は、よりサーバーアプリ側に寄り添った定義方法を提供しています。

特にこのConfigureServices関数内にシングルトンとして運用したいクラスを「AddSingleton」で渡すと適切にDependency Injection(DI)してくれます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public class MatchingHubServerImpl : StreamingHubBase<IMatchingHub, IMatchingHubReceiver>, IMatchingHub
{
    private readonly ILogger logger;
    private IGroup matchingRoom = null;
    private readonly MatchingRoomManager roomManager;

    /// <summary>
    /// DIコンストラクタ
    /// </summary>
    /// <param name="_mgr"></param>
    /// <param name="_logger"></param>
    public MatchingHubServerImpl(MatchingRoomManager _mgr, ILogger<MatchingHubServerImpl> _logger)
    {
        roomManager = _mgr;
        logger = _logger;
    }

初めてDI Framework的な処理を経験したので、AddSingletonしたMatchingRoomManagerクラスが 上記のMatchingHubServerImplクラスのコンストラクタで自動的に渡されてきたのを確認したときにはかなり感動しました。

MagicOnion(サーバー)


サーバー側のMagicOnionで必要なのは

  • 「Client -> Server」通信にあたる Send インターフェースを実装
  • 「Server -> Client」通信に当たる Receive インターフェースのメソッドを実行し、クライアントに向けてレスポンスを返す

の2つです。

前回のクライアントでの説明と同じくマッチングの処理を例に見てみましょう。 “IMatchingHubReceiverインターフェース” がReceive的なインターフェースで、クライアントで実装を行います。 一方の”IMatchingHubインターフェース” がSend的なインターフェースで、サーバー側の実装が必要です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public interface IMatchingHub : IStreamingHub<IMatchingHub, IMatchingHubReceiver>
{
    Task JoinAsync(MatchingJoinRequest _request);
    Task LeaveAsync();
}

public interface IMatchingHubReceiver
{
    void OnMatchingSuccess(MatchingRequestSuccessResponse _response);
}

クライアントでは、さもクライアントで定義されているかのようにJoinAsync、LeaveAsync関数を呼び出しますが、その本体はサーバーにあります[1]。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public class MatchingHubServerImpl : StreamingHubBase<IMatchingHub, IMatchingHubReceiver>, IMatchingHub
{
    private IGroup matchingRoom = null;

    public async Task JoinAsync(MatchingJoinRequest _request)
    {
        matchingRoom = await Group.AddAsync("MatchingRoom");

        // レスポンス返す
        BroadcastToSelf(matchingRoom).OnMatchingSuccess();
    }

    public async Task LeaveAsync()
    {
        roomManager.RemoveFromMatchingRoom();
        await matchingRoom.RemoveAsync(Context);
    }

サーバーはStreamingHubBaseインターフェースのBroadcast関数とIGroupによるグルーピングを組み合わせてクライアントにレスポンスを返します。 これは自分自身に限らず、Groupに属している任意のプレイヤーに対してレスポンスを返すことが可能です。 今回はあまり真面目に組みませんでしたが、この部分をもっと工夫、例えばプレイヤーレベル別、またはスコア平均別などの材料を加味することで、 よりプレイヤーの満足度の高いマッチングを行えると思います。

メインゲーム部分のリアルタイム通信もほぼ同じ要領で実行しています。

LogicLooper


サーバーホスティングや通信処理自体は、.Net CoreやMagicOnionが厚くサポートしてくれたおかげでそこまで苦労しませんでした。 しかし、今回の最たる目的である「リアルタイム通信で二人プレイ」を達成するためには通信ができるだけでなく、「サーバーでゲームロジックを実行すること」が必要でした。 特に「サーバーループをどこで実行すべきか」が初見では全くわからず、最初は途方に暮れました。 そこで色々検索をかけたところ、MagicOnionを提供しているCysharp社のリポジトリに LogicLooper という、まさしく今回の目的に合致したリポジトリの存在を見つけました。

ゲームループ用スレッドの作成

LogicLooperはRegisterActionAsync関数に毎フレーム実行したい関数を登録後、スレッド上でループを開始し登録した関数を毎フレーム実行します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public static void CreateNew(ILogicLooperPool _looperPool, ILogger _logger, LoopData _loopData)
{
    var gameLoop = new ServerMainGameLoop(_logger, _loopData, info);
    _looperPool.RegisterActionAsync(gameLoop.UpdateFrame);
}

public bool UpdateFrame(in LogicLooperActionContext _ctx)
{
    // execute every frame
    return true;
}

ソースコード詳細はこのあたりを参照してください。

ゲームループを回すところまでこぎつければあとはクライアント処理の延長で処理を組むことができました。 サーバーで実行することも見越して、Unityに依存しない純粋なC#でゲームロジックを組み立てていたので Sharedな領域にロジックを配置し、なんとかサーバー側メインループへゲーム処理を持っていくこともできました。

スレッドプログラミングでのデータ競合

ゲームループを組むことができたので、いざ処理を実行してみるとオフラインプレイ時には発生しなかった配列外アクセス例外などが 起きたり起きなかったり する問題に悩まされました。 この 起きたり起きなかったり で気づいた方もいると思いますが、これはLogicLooperが「スレッド上で動作するゲームループ」であるために発生している問題でした。

今回はテトリスっぽいゲームを作るということで、ゲームロジック内では盤面上のブロックの値を頻繁に書き換えたり参照したりする必要があります。 また、リアルタイム通信をしているため自分だけでなく相手のデータも参照して表示する必要があります。 この時、別スレッドで動作している相手プレイヤーが盤面情報を構築中、もしくは盤面を更新中に盤面情報を参照しようとすると「データ競合」つまり不正なデータアクセスが発生します。

この問題の回避する手法として行われるのが、1スレッドしか実行できない領域 クリティカルセクション 、つまりlock領域を設定してデータ変更や参照はその領域内でのみ行うというものです。 今回は相手のデータを参照する際、自スレッドにデータをコピーする箇所と実際に値を書き換えている箇所の2箇所にlockを指定して対応しました。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// for thread lock
private static Object threadLockObj = new object();

// mine
updateMemberBoardInfo(loopData.MineId, PlayingState.Board, PlayingState.CurrentScore, PlayingState.NextFirst, PlayingState.NextSecond, PlayingState.IsActive);

// other
if (loopData.HubImpl.WithOther)
{
    var enem = TryTakeGame(loopData.EnemyId);
    if (enem != null)
    {
        BlockStatus first = new BlockStatus();
        BlockStatus second = new BlockStatus();
        int enemScore = 0;
        bool isActive = true;

        lock (threadLockObj)
        {   // lock for data racing
            for (int i = 0; i < SharedConstant.BOARD_ARRAY_SIZE; ++i)
            {
                enemyBoard[i] = enem.PlayingState.Board[i];
            }
            enemScore = enem.PlayingState.CurrentScore;
            first = enem.PlayingState.NextFirst;
            second = enem.PlayingState.NextSecond;
            isActive = enem.PlayingState.IsActive;
        }

        updateMemberBoardInfo(loopData.EnemyId, enemyBoard, enemScore, first, second, isActive);
    }
}

データ競合によるエラーは問題が起きたり起きなかったり、または毎回違う場所で問題が発生したりするせいで原因の特定が困難になりがちで、不具合のレベルとしてはかなり厄介です。 最悪、デバッグ中には問題が発生せず、本番で初めて表面化するなど深刻なインシデントが発生することも十分あり得えます。 そのため、スレッド処理を実装する際には注意が必要です。

まとめ


第4回目の記事ではサーバー側の技術詳細をメインに紹介しました。

個人的にサーバー処理に関しては

  • サーバーゲームループ周りで各ユーザーのインスタンスをかなり強引な持ち方をしている
  • 動くものを作るという目標優先でマッチング処理もキュー的にいい加減に捌いている
  • DBなどのバックエンド的なものの組み込みまで手が回せなかった

などなど、反省点も多いです。 あるかはわかりませんが次回は今回の反省を生かした設計ができればと考えています。


  1. このように、離れた場所(サーバー)に詳細がある関数をさもクライアント側で定義されているような形で関数呼び出しできるのが “Remote Procedure Call” と呼ばれる所以でもあります。 ^
ytmt's
ytmt's
ゲームプログラマ
ビルドエンジニア
前へ

関連項目